#!/usr/bin/env bash
# shellcheck disable=SC2034  # Many variables are used by sourced scripts
# shellcheck disable=SC2155  # Declare and assign separately (acceptable in this codebase)
# shellcheck disable=SC2329  # Functions may be invoked indirectly or via dynamic dispatch
# shellcheck disable=SC2086  # Word splitting is intentional in some contexts
#===============================================================================
# Loki Mode - Autonomous Runner
# Single script that handles prerequisites, setup, and autonomous execution
#
# Usage:
#   ./autonomy/run.sh [OPTIONS] [PRD_PATH]
#   ./autonomy/run.sh ./docs/requirements.md
#   ./autonomy/run.sh                          # Interactive mode
#   ./autonomy/run.sh --parallel               # Parallel mode with git worktrees
#   ./autonomy/run.sh --parallel ./prd.md      # Parallel mode with PRD
#
# Environment Variables:
#   LOKI_PROVIDER       - AI provider: claude (default), codex, cline, aider
#   LOKI_MAX_RETRIES    - Max retry attempts (default: 50)
#   LOKI_BASE_WAIT      - Base wait time in seconds (default: 60)
#   LOKI_MAX_WAIT       - Max wait time in seconds (default: 3600)
#   LOKI_SKIP_PREREQS   - Skip prerequisite checks (default: false)
#   LOKI_DASHBOARD      - Enable web dashboard (default: true)
#   LOKI_DASHBOARD_PORT - Dashboard port (default: 57374)
#   LOKI_TLS_CERT       - Path to PEM certificate (enables HTTPS for dashboard)
#   LOKI_TLS_KEY        - Path to PEM private key (enables HTTPS for dashboard)
#
# Resource Monitoring (prevents system overload):
#   LOKI_RESOURCE_CHECK_INTERVAL - Check resources every N seconds (default: 300 = 5min)
#   LOKI_RESOURCE_CPU_THRESHOLD  - CPU % threshold to warn (default: 80)
#   LOKI_RESOURCE_MEM_THRESHOLD  - Memory % threshold to warn (default: 80)
#
# Budget / Cost Limits (opt-in):
#   LOKI_BUDGET_LIMIT            - Max USD spend before auto-pause (default: empty = unlimited)
#                                  Example: "50.00" pauses session when estimated cost >= $50
#
# Security & Autonomy Controls (Enterprise):
#   LOKI_STAGED_AUTONOMY    - Require approval before execution (default: false)
#   LOKI_AUDIT_LOG          - Enable audit logging (default: true)
#   LOKI_AUDIT_DISABLED     - Disable audit logging (default: false)
#   LOKI_MAX_PARALLEL_AGENTS - Limit concurrent agent spawning (default: 10)
#   LOKI_SANDBOX_MODE       - Run in sandboxed container (default: false, requires Docker)
#   LOKI_ALLOWED_PATHS      - Comma-separated path allowlist. PARTIAL enforcement
#                             (honest scope): when set, run.sh rejects a sandbox
#                             custom --mount whose host path is outside the
#                             allowlist (a write surface run.sh itself controls).
#                             It does NOT and CANNOT restrict provider-driven
#                             agent file writes: claude/codex/cline/aider write
#                             files directly, not through run.sh, so run.sh never
#                             sees those writes. The containment for agent writes
#                             is the OS user + the LOKI_SANDBOX_MODE container
#                             (cap-drop, seccomp, read-only mounts), not this var.
#                             Treat ALLOWED_PATHS as defense-in-depth on the
#                             mount surface, not a complete write sandbox.
#   LOKI_BLOCKED_COMMANDS   - Comma-separated blocked shell commands (default: rm -rf /)
#
# OIDC / SSO Authentication (optional, works alongside token auth):
#   LOKI_OIDC_ISSUER        - OIDC issuer URL (e.g., https://accounts.google.com)
#   LOKI_OIDC_CLIENT_ID     - OIDC client/application ID
#   LOKI_OIDC_AUDIENCE      - Expected JWT audience (default: same as client_id)
#
# SDLC Phase Controls (all enabled by default, set to 'false' to skip):
#   LOKI_PHASE_UNIT_TESTS      - Run unit tests (default: true)
#   LOKI_PHASE_API_TESTS       - Functional API testing (default: true)
#   LOKI_PHASE_E2E_TESTS       - E2E/UI testing with Playwright (default: true)
#   LOKI_PHASE_SECURITY        - Security scanning OWASP/auth (default: true)
#   LOKI_PHASE_INTEGRATION     - Integration tests SAML/OIDC/SSO (default: true)
#   LOKI_PHASE_CODE_REVIEW     - 3-reviewer parallel code review (default: true)
#   LOKI_PHASE_WEB_RESEARCH    - Competitor/feature gap research (default: true)
#   LOKI_PHASE_PERFORMANCE     - Load/performance testing (default: true)
#   LOKI_PHASE_ACCESSIBILITY   - WCAG compliance testing (default: true)
#   LOKI_PHASE_REGRESSION      - Regression testing (default: true)
#   LOKI_PHASE_UAT             - UAT simulation (default: true)
#
# Autonomous Loop Controls (Ralph Wiggum Mode):
#   LOKI_COMPLETION_PROMISE    - EXPLICIT stop condition text (default: none - runs forever)
#                                Example: "ALL TESTS PASSING 100%"
#                                Only stops when the AI provider outputs this EXACT text
#   LOKI_MAX_ITERATIONS        - Max loop iterations before exit (default: 1000)
#   LOKI_PERPETUAL_MODE        - Ignore ALL completion signals (default: false)
#                                Set to 'true' for truly infinite operation
#
# Completion Council (v5.25.0) - Multi-agent completion verification:
#   LOKI_COUNCIL_ENABLED          - Enable completion council (default: true)
#   LOKI_COUNCIL_SIZE             - Number of council members (default: 3)
#   LOKI_COUNCIL_THRESHOLD        - Votes needed for completion (default: 2)
#   LOKI_COUNCIL_CHECK_INTERVAL   - Check every N iterations (default: 5)
#   LOKI_COUNCIL_MIN_ITERATIONS   - Min iterations before council runs (default: 3)
#   LOKI_COUNCIL_STAGNATION_LIMIT - Max iterations with no git changes (default: 5)
#
# Model Selection:
#   LOKI_ALLOW_HAIKU           - Enable Haiku model for fast tier (default: false)
#                                When false: Opus for dev/bugfix, Sonnet for tests/docs
#                                When true:  Sonnet for dev, Haiku for tests/docs (original)
#                                Use --allow-haiku flag or set to 'true'
#
# 2026 Research Enhancements:
#   LOKI_PROMPT_REPETITION     - Enable prompt repetition for Haiku agents (default: true)
#                                arXiv 2512.14982v1: Improves accuracy 4-5x on structured tasks
#   LOKI_CONFIDENCE_ROUTING    - Enable confidence-based routing (default: true)
#                                HN Production: 4-tier routing (auto-approve, direct, supervisor, escalate)
#   LOKI_AUTONOMY_MODE         - Autonomy level (default: perpetual)
#                                Options: perpetual, checkpoint, supervised
#                                Tim Dettmers: "Shorter bursts of autonomy with feedback loops"
#
# Parallel Workflows (Git Worktrees):
#   LOKI_PARALLEL_MODE         - Enable git worktree-based parallelism (default: false)
#                                Use --parallel flag or set to 'true'
#   LOKI_MAX_WORKTREES         - Maximum parallel worktrees (default: 5)
#   LOKI_MAX_PARALLEL_SESSIONS - Maximum concurrent AI sessions (default: 3)
#   LOKI_PARALLEL_TESTING      - Run testing stream in parallel (default: true)
#   LOKI_PARALLEL_DOCS         - Run documentation stream in parallel (default: true)
#   LOKI_PARALLEL_BLOG         - Run blog stream if site has blog (default: false)
#   LOKI_AUTO_MERGE            - Auto-merge completed features (default: true)
#
# Complexity Tiers (Auto-Claude pattern):
#   LOKI_COMPLEXITY            - Force complexity tier (default: auto)
#                                Options: auto, simple, standard, complex
#   Simple (3 phases):   1-2 files, single service, UI fixes, text changes
#   Standard (6 phases): 3-10 files, 1-2 services, features, bug fixes
#   Complex (8 phases):  10+ files, multiple services, external integrations
#
# GitHub Integration (v4.1.0):
#   LOKI_GITHUB_IMPORT   - Import open issues as tasks (default: false)
#   LOKI_GITHUB_PR       - Create PR when feature complete (default: false)
#   LOKI_GITHUB_SYNC     - Sync status back to issues (default: false)
#   LOKI_GITHUB_REPO     - Override repo detection (default: from git remote)
#   LOKI_GITHUB_LABELS   - Filter by labels (comma-separated)
#   LOKI_GITHUB_MILESTONE - Filter by milestone
#   LOKI_GITHUB_ASSIGNEE - Filter by assignee
#   LOKI_GITHUB_LIMIT    - Max issues to import (default: 100)
#   LOKI_GITHUB_PR_LABEL - Label for PRs (default: none, avoids error if label missing)
#
# Desktop Notifications (v4.1.0):
#   LOKI_NOTIFICATIONS   - Enable desktop notifications (default: true)
#   LOKI_NOTIFICATION_SOUND - Play sound with notifications (default: true)
#
# Uncertainty-Gated Escalation (v7.19.2, default-on):
#   LOKI_UNCERTAINTY_ESCALATION  - Master on/off for proactive stuck-escalation (default: 1; set 0 to
#                                  disable; byte-identical when off). Decision lives in
#                                  completion-council.sh (uncertainty_should_escalate); action in run.sh.
#                                  NOTE: AUTONOMY_MODE defaults to "perpetual"; in perpetual mode PAUSE
#                                  is auto-cleared by check_human_intervention, so escalation degrades
#                                  to notify-only (notification fires, run does NOT halt).
#   LOKI_UNCERTAINTY_ROUNDS      - Consecutive rounds where >=2 of 3 proxies must co-occur before
#                                  escalating (default: 2; recommended range 2-3). Debounces transient
#                                  noise: a single hot proxy never escalates alone.
#   LOKI_UNCERTAINTY_NOCHANGE_MIN - Proxy 1 threshold: consecutive_no_change value that marks p1 hot.
#                                  (default: COUNCIL_STAGNATION_LIMIT - 1, i.e. one below the circuit-
#                                  breaker limit so escalation fires before the breaker ends the run).
#                                  Floored at 1 at runtime.
#   LOKI_UNCERTAINTY_SPLIT_ROUNDS - Proxy 3 threshold: number of consecutive trailing council verdicts
#                                  that must be REJECTED-with-approver (split) to mark p3 hot
#                                  (default: 2). Between council votes p3 may be stale; it is always
#                                  fresh when proxy 1 is hot because proxy 1 hot forces a circuit-
#                                  breaker vote that refreshes verdicts.
#
# Human Intervention (Auto-Claude pattern):
#   PAUSE file:          touch .loki/PAUSE - pauses after current session
#   HUMAN_INPUT.md:      echo "instructions" > .loki/HUMAN_INPUT.md
#   STOP file:           touch .loki/STOP - stops immediately
#   Ctrl+C (once):       Pauses execution, shows options
#   Ctrl+C (twice):      Exits immediately
#
# Security (Enterprise):
#   LOKI_PROMPT_INJECTION - Enable HUMAN_INPUT.md processing (default: false)
#                           Set to "true" only in trusted environments
#
# Branch Protection (agent isolation):
#   LOKI_BRANCH_PROTECTION     - Create feature branch for agent changes (default: true)
#                                Agent works on loki/session-<timestamp>-<pid> branch
#                                Set to "false" to opt out and work on the current branch
#   LOKI_AUTO_PR               - Auto push + open a PR on session end (default: off)
#                                Default behavior PRINTS the PR command (advisory, no push)
#
# Process Supervision (opt-in):
#   LOKI_WATCHDOG              - Enable process health monitoring (default: false)
#   LOKI_WATCHDOG_INTERVAL     - Check interval in seconds (default: 30)
#===============================================================================
#
# Compatibility: bash 3.2+ (macOS default), bash 4+ (Linux), WSL
# Parallel mode (--parallel) requires bash 4.0+ for associative arrays
#===============================================================================

set -uo pipefail

# Compatibility check: Ensure we're running in bash (not sh, dash, zsh)
if [ -z "${BASH_VERSION:-}" ]; then
    echo "[ERROR] This script requires bash. Please run with: bash $0" >&2
    exit 1
fi

# Extract major version for feature checks
BASH_VERSION_MAJOR="${BASH_VERSION%%.*}"
BASH_VERSION_MINOR="${BASH_VERSION#*.}"
BASH_VERSION_MINOR="${BASH_VERSION_MINOR%%.*}"

# Warn if bash version is very old (< 3.2)
if [ "$BASH_VERSION_MAJOR" -lt 3 ] || { [ "$BASH_VERSION_MAJOR" -eq 3 ] && [ "$BASH_VERSION_MINOR" -lt 2 ]; }; then
    echo "[WARN] Bash version $BASH_VERSION is old. Recommend bash 3.2+ for full compatibility." >&2
    echo "[WARN] Some features may not work correctly." >&2
fi

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"

#===============================================================================
# Self-Copy Protection
# Bash reads scripts incrementally, so editing a running script corrupts execution.
# Solution: Copy ourselves to /tmp and run from there. The original can be safely edited.
#===============================================================================
if [[ -z "${LOKI_RUNNING_FROM_TEMP:-}" ]] && [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    TEMP_SCRIPT=$(mktemp /tmp/loki-run-XXXXXX)
    mv "$TEMP_SCRIPT" "${TEMP_SCRIPT}.sh"
    TEMP_SCRIPT="${TEMP_SCRIPT}.sh"
    cp "${BASH_SOURCE[0]}" "$TEMP_SCRIPT"
    chmod 700 "$TEMP_SCRIPT"
    # BUG-XC-011: Set trap BEFORE exec so the temp file gets cleaned up
    trap 'rm -f "$TEMP_SCRIPT"' EXIT
    export LOKI_RUNNING_FROM_TEMP=1
    # Record the EXACT temp-copy path so the post-exec cleanup trap deletes THIS
    # temp file and NEVER the canonical source (root cause of the recurring
    # "run.sh self-deleted on build spawn" bug: an inherited LOKI_RUNNING_FROM_TEMP
    # skips the self-copy block, leaving BASH_SOURCE[0]=the real run.sh).
    export LOKI_TEMP_SCRIPT_PATH="$TEMP_SCRIPT"
    export LOKI_ORIGINAL_SCRIPT_DIR="$SCRIPT_DIR"
    export LOKI_ORIGINAL_PROJECT_DIR="$PROJECT_DIR"
    exec "$TEMP_SCRIPT" "$@"
fi

# Restore original paths when running from temp
SCRIPT_DIR="${LOKI_ORIGINAL_SCRIPT_DIR:-$SCRIPT_DIR}"
PROJECT_DIR="${LOKI_ORIGINAL_PROJECT_DIR:-$PROJECT_DIR}"

# Clean up ONLY the recorded temp copy, and ONLY if it is a real temp file.
# Deleting BASH_SOURCE[0] here was the bug: an inherited LOKI_RUNNING_FROM_TEMP
# made BASH_SOURCE[0] the canonical source, so run.sh deleted itself on spawned
# builds. Guard on the recorded path being a real /tmp/loki-run-* file.
if [[ "${LOKI_RUNNING_FROM_TEMP:-}" == "1" ]] \
   && [[ -n "${LOKI_TEMP_SCRIPT_PATH:-}" ]] \
   && [[ "${LOKI_TEMP_SCRIPT_PATH}" == /tmp/loki-run-* || "${LOKI_TEMP_SCRIPT_PATH}" == "${TMPDIR:-/tmp}"loki-run-* ]]; then
    trap 'rm -f "${LOKI_TEMP_SCRIPT_PATH}" 2>/dev/null' EXIT
fi

#===============================================================================
# Configuration File Support (v4.1.0)
# Loads settings from config file, environment variables take precedence
#===============================================================================
# #691: source the canonical config mapping + shared per-key export helper. This
# is the SAME lib the loki CLI pre-pass uses, so the YAML/config-file mapping
# cannot drift between the CLI and the runner. Side-effect-free on source. The
# parsers below delegate to loki_config_export_key with override=0, preserving
# the shipped env-wins auto-discovery contract byte-for-byte.
_LOKI_CONFIG_MAP_LIB="$SCRIPT_DIR/lib/config-map.sh"
if [ -f "$_LOKI_CONFIG_MAP_LIB" ]; then
    # shellcheck source=lib/config-map.sh
    source "$_LOKI_CONFIG_MAP_LIB"
fi

# v8.1: one-switch SDK activation. LOKI_SDK_MODE=off|judges|full (default off)
# sets the default for the 8 per-site LOKI_SDK_* flags via a write-once resolver;
# per-site flags still win. Sourced + resolved here, before any judge lib or the
# main loop reads a flag. No-op (byte-identical) when the mode is unset.
_LOKI_SDK_MODE_LIB="$SCRIPT_DIR/lib/sdk-mode.sh"
if [ -f "$_LOKI_SDK_MODE_LIB" ]; then
    # shellcheck source=lib/sdk-mode.sh
    source "$_LOKI_SDK_MODE_LIB"
    loki_sdk_resolve_mode
fi

load_config_file() {
    local config_file=""

    # Search for config file in order of priority
    # Security: Reject symlinks to prevent path traversal attacks
    # 1. Project-local config
    if [ -f ".loki/config.yaml" ] && [ ! -L ".loki/config.yaml" ]; then
        config_file=".loki/config.yaml"
    elif [ -f ".loki/config.yml" ] && [ ! -L ".loki/config.yml" ]; then
        config_file=".loki/config.yml"
    # 2. User-global config (symlinks allowed in home dir - user controls it)
    elif [ -f "${HOME}/.config/loki-mode/config.yaml" ]; then
        config_file="${HOME}/.config/loki-mode/config.yaml"
    elif [ -f "${HOME}/.config/loki-mode/config.yml" ]; then
        config_file="${HOME}/.config/loki-mode/config.yml"
    fi

    # If no config file found, return silently
    if [ -z "$config_file" ]; then
        return 0
    fi

    # Check for yq (YAML parser)
    if ! command -v yq &> /dev/null; then
        # Fallback: parse simple YAML with sed/grep
        parse_simple_yaml "$config_file"
        return 0
    fi

    # Use yq for proper YAML parsing
    parse_yaml_with_yq "$config_file"
}

# Fallback YAML parser for simple key: value format.
# #691: iterates the canonical LOKI_CONFIG_MAP (single source of truth) instead
# of 64 hand-maintained set_from_yaml calls. override=0 -> ambient env still
# wins (auto-discovery contract unchanged). Falls back to nothing if the lib is
# absent (defensive; the lib ships alongside run.sh).
parse_simple_yaml() {
    local file="$1"
    if ! declare -p LOKI_CONFIG_MAP >/dev/null 2>&1; then
        return 0
    fi
    local mapping yaml_path env_var
    for mapping in "${LOKI_CONFIG_MAP[@]}"; do
        yaml_path="${mapping%%:*}"
        env_var="${mapping##*:}"
        set_from_yaml "$file" "$yaml_path" "$env_var"
    done
}

# Validate YAML value to prevent injection attacks
validate_yaml_value() {
    local value="$1"
    local max_length="${2:-1000}"

    # Reject empty values
    if [ -z "$value" ]; then
        return 1
    fi

    # Reject values with dangerous shell metacharacters.
    # SECURITY FIX (#691 wave): the previous [[ "$value" =~ [\$\`...] ]]
    # bracket-regex matched NOTHING -- backslash escapes inside a bash regex
    # bracket class are taken literally, so the guard ACCEPTED $(...), backticks,
    # pipes, etc. (verified behaviorally: it returned 0 for "$(touch /tmp/x)").
    # A `case` glob class has no such ambiguity and actually rejects the metachars.
    # Allow alphanumeric, spaces, dots, dashes, underscores, slashes, colons,
    # commas, @.
    case "$value" in
        *'$'* | *'`'* | *'|'* | *';'* | *'&'* | *'>'* | *'<'* | *'('* | *')'* \
        | *'{'* | *'}'* | *'['* | *']'* | *'\'* )
            return 1 ;;
    esac

    # Reject values that are too long (DoS protection)
    if [ "${#value}" -gt "$max_length" ]; then
        return 1
    fi

    # Reject values with newlines (could corrupt variables)
    if [[ "$value" == *$'\n'* ]]; then
        return 1
    fi

    return 0
}

# Escape regex metacharacters for safe grep usage
escape_regex() {
    local input="$1"
    # Escape: . * ? + [ ] ^ $ { } | ( ) \
    printf '%s' "$input" | sed 's/[.[\*?+^${}|()\\]/\\&/g'
}

# Helper: Extract value from YAML and set env var if not already set
set_from_yaml() {
    local file="$1"
    local yaml_path="$2"
    local env_var="$3"

    # Skip if env var is already set (env-wins guard; the shared helper also
    # enforces this with override=0, but checking here avoids the extraction).
    if [ -n "${!env_var:-}" ]; then
        return 0
    fi

    # Extract value using grep and sed (handles simple YAML)
    # Convert yaml path like "core.max_retries" to search pattern
    local value=""
    local key="${yaml_path##*.}"  # Get last part of path

    # Escape regex metacharacters in key for safe grep
    local escaped_key
    escaped_key=$(escape_regex "$key")

    # Simple grep for the key (works for flat or indented YAML)
    # Use read to avoid xargs command execution risks
    value=$(grep -E "^\s*${escaped_key}:" "$file" 2>/dev/null | head -1 | sed -E 's/.*:\s*//' | sed 's/#.*//' | sed 's/^["\x27]//;s/["\x27]$//' | tr -d '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')

    # #691: delegate the env-wins guard + ${VAR}-expand + validate + export to the
    # single shared helper (override=0 -> ambient env wins). Falls back to the
    # original inline export if the lib is unavailable, so behavior is identical
    # either way.
    if declare -f loki_config_export_key >/dev/null 2>&1; then
        loki_config_export_key "$env_var" "$value" 0 || true
    elif [ -n "$value" ] && [ "$value" != "null" ] && validate_yaml_value "$value"; then
        export "$env_var=$value"
    fi
}

# Parse YAML using yq (proper parser).
# #691: iterates the canonical LOKI_CONFIG_MAP and delegates the env-wins guard +
# expand + validate + export to the single shared helper (override=0 -> ambient
# env wins; auto-discovery contract unchanged). The inline 61-entry table that
# used to drift from parse_simple_yaml's 64 is gone.
parse_yaml_with_yq() {
    local file="$1"
    if ! declare -p LOKI_CONFIG_MAP >/dev/null 2>&1; then
        return 0
    fi

    local mapping yaml_path env_var value
    for mapping in "${LOKI_CONFIG_MAP[@]}"; do
        yaml_path="${mapping%%:*}"
        env_var="${mapping##*:}"

        # Extract value using yq (env-wins guard is enforced by the shared helper).
        value=$(yq eval ".$yaml_path // \"\"" "$file" 2>/dev/null)

        if declare -f loki_config_export_key >/dev/null 2>&1; then
            loki_config_export_key "$env_var" "$value" 0 || true
        elif [ -n "$value" ] && [ "$value" != "null" ] && [ "$value" != "" ] \
                && [ -z "${!env_var:-}" ] && validate_yaml_value "$value"; then
            export "$env_var=$value"
        fi
    done
}

# Load config file before setting defaults
load_config_file

# Load JSON settings from loki config set (v6.0.0)
#
# SECURITY NOTE (v7.5.10, L12#2 audit): The eval below is intentional and safe.
# The Python script's output is constrained to a fixed template:
#     [ -z "${VAR:-}" ] && export VAR=<value>
# where:
#   - VAR is a hardcoded env var name from the `mapping` dict (NOT user-controlled).
#   - <value> is produced by shlex.quote(), which emits POSIX-shell-safe single-
#     quoted strings even for adversarial input (e.g. quotes, semicolons, $()).
#   - Non-string values from settings.json are skipped (isinstance check).
# Therefore no user-controlled bytes can break out of the quoted value or alter
# the surrounding shell syntax. Do NOT remove the shlex.quote() call or relax
# the isinstance(val, str) guard without re-auditing this eval.
_load_json_settings() {
    local settings_file="${TARGET_DIR:-.}/.loki/config/settings.json"
    [ -f "$settings_file" ] || return 0
    eval "$(_LOKI_SETTINGS_FILE="$settings_file" python3 -c "
import json, sys, os, shlex

def get_nested(d, key):
    \"\"\"Resolve dotted keys through nested dicts (model.planning -> data['model']['planning'])\"\"\"
    parts = key.split('.')
    cur = d
    for p in parts:
        if isinstance(cur, dict):
            cur = cur.get(p)
        else:
            return None
    return cur

try:
    with open(os.environ['_LOKI_SETTINGS_FILE']) as f:
        data = json.load(f)
except Exception:
    sys.exit(0)
mapping = {
    'maxTier': 'LOKI_MAX_TIER',
    'model.planning': 'LOKI_MODEL_PLANNING',
    'model.development': 'LOKI_MODEL_DEVELOPMENT',
    'model.fast': 'LOKI_MODEL_FAST',
    'notify.slack': 'LOKI_SLACK_WEBHOOK',
    'notify.discord': 'LOKI_DISCORD_WEBHOOK',
    'provider': 'LOKI_PROVIDER',
    'issue.provider': 'LOKI_ISSUE_PROVIDER',
    'blind_validation': 'LOKI_BLIND_VALIDATION',
    'adversarial_testing': 'LOKI_ADVERSARIAL_TESTING',
    'spawn_timeout': 'LOKI_SPAWN_TIMEOUT',
    'spawn_retries': 'LOKI_SPAWN_RETRIES',
    'budget': 'LOKI_BUDGET_LIMIT',
}
for key, env_var in mapping.items():
    # Try nested dict lookup first, then flat key, then underscore variant
    val = get_nested(data, key) or data.get(key) or data.get(key.replace('.', '_'))
    if val and isinstance(val, str):
        safe_val = shlex.quote(val)
        print(f'[ -z \"\${{{env_var}:-}}\" ] && export {env_var}={safe_val}')
" 2>/dev/null)" 2>/dev/null || true
}
_LOKI_SETTINGS_FILE="${TARGET_DIR:-.}/.loki/config/settings.json" _load_json_settings

# Configuration
MAX_RETRIES=${LOKI_MAX_RETRIES:-50}
BASE_WAIT=${LOKI_BASE_WAIT:-60}
MAX_WAIT=${LOKI_MAX_WAIT:-3600}
SKIP_PREREQS=${LOKI_SKIP_PREREQS:-false}
ENABLE_DASHBOARD=${LOKI_DASHBOARD:-true}
DASHBOARD_PORT=${LOKI_DASHBOARD_PORT:-57374}
RESOURCE_CHECK_INTERVAL=${LOKI_RESOURCE_CHECK_INTERVAL:-300}  # Check every 5 minutes
RESOURCE_CPU_THRESHOLD=${LOKI_RESOURCE_CPU_THRESHOLD:-80}     # CPU % threshold
RESOURCE_MEM_THRESHOLD=${LOKI_RESOURCE_MEM_THRESHOLD:-80}     # Memory % threshold

# Budget / Cost Limit (opt-in, empty = unlimited)
BUDGET_LIMIT=${LOKI_BUDGET_LIMIT:-""}  # USD amount, e.g., "50.00"

# Background Mode
BACKGROUND_MODE=${LOKI_BACKGROUND:-false}                # Run in background

# Security & Autonomy Controls
STAGED_AUTONOMY=${LOKI_STAGED_AUTONOMY:-false}           # Require plan approval
AUDIT_LOG_ENABLED=${LOKI_AUDIT_LOG:-true}                # Enable audit logging (on by default)
MAX_PARALLEL_AGENTS=${LOKI_MAX_PARALLEL_AGENTS:-10}      # Limit concurrent agents
SANDBOX_MODE=${LOKI_SANDBOX_MODE:-false}                 # Docker sandbox mode (informational; the real dispatch reads LOKI_SANDBOX_MODE at autonomy/loki:1965 and execs sandbox.sh -- this var is not consumed in run.sh)
ALLOWED_PATHS=${LOKI_ALLOWED_PATHS:-""}                  # PARTIAL enforcement. When set, a sandbox custom --mount host path outside the allowlist is rejected in sandbox.sh (_sandbox_path_within_allowed), NOT in run.sh. Does NOT restrict provider-driven agent writes (run.sh never sees them); the sandbox container is the containment for those. Default empty = no restriction.
BLOCKED_COMMANDS=${LOKI_BLOCKED_COMMANDS:-"rm -rf /,dd if=,mkfs,:(){ :|:& };:"}

# LOKI_SESSION_ID is woven into many filesystem paths (.loki/sessions/<id>/...,
# session locks, PID files, per-session state). It is operator/issue-derived
# (trusted-ish), but a value like "../../tmp/x" would escape the .loki/ tree.
# Sanitize ONCE here at intake: any char outside [A-Za-z0-9._-] is replaced with
# "_" and a leading run of dots/dashes is neutralized, so every downstream
# session path is contained. Unset stays unset (default behavior unchanged).
if [ -n "${LOKI_SESSION_ID:-}" ]; then
    _loki_sid_raw="$LOKI_SESSION_ID"
    _loki_sid_safe="${_loki_sid_raw//[^A-Za-z0-9._-]/_}"
    # Strip leading dots/dashes so "../x" -> "x" cannot reference a parent dir
    # or look like an option; collapse a now-empty result to a safe literal.
    _loki_sid_safe="${_loki_sid_safe#"${_loki_sid_safe%%[!.-]*}"}"
    [ -n "$_loki_sid_safe" ] || _loki_sid_safe="session"
    if [ "$_loki_sid_safe" != "$_loki_sid_raw" ]; then
        printf 'loki: WARNING sanitized unsafe LOKI_SESSION_ID %s -> %s\n' "$_loki_sid_raw" "$_loki_sid_safe" >&2
    fi
    export LOKI_SESSION_ID="$_loki_sid_safe"
    unset _loki_sid_raw _loki_sid_safe
fi

# GENERIC TIER VOCABULARY (small|medium|high). A user should be able to ask for
# a capability class without naming a vendor model, and get that provider's
# latest model in the class. LOKI_SESSION_MODEL is the knob that already does
# this -- it accepts the raw tier names planning|development|fast alongside the
# Claude aliases -- so the generic words are normalized ONTO it here rather than
# becoming a fourth spelling. LOKI_MAX_TIER (a cost CEILING) and LOKI_TIER (the
# OSS/enterprise licensing seam) mean different things and are left alone.
#
# WHY NORMALIZE AT THE ENTRY POINT: the session-pin case block is byte-mirrored
# in the estimator (autonomy/loki) and the dashboard (dashboard/server.py), and
# every one of those mirrors is locked by a parity test. Translating here means
# they keep seeing only the three canonical tier names and none of them change.
#
# The mapping is loki_tier_alias() in providers/models.sh -- the single source
# of truth, not a second copy. Inlined as a case because run.sh must not source
# a provider file this early in startup. Kept in lockstep by
# tests/test-generic-tiers.sh.
#
# ONLY the three new words are translated. sonnet/haiku/opus/fable and the raw
# tier names pass through untouched, so an unset LOKI_SESSION_MODEL still
# defaults to sonnet and "medium" resolves to the same development-tier model
# today's builds already use. This changes no existing run's model.
# NORMALIZATION IS TRIM-ONLY + LOWERCASE, matching the estimator and dashboard
# mirrors exactly. Interior whitespace is deliberately PRESERVED, so " med ium "
# stays junk here just as it does there. Stripping interior spaces would make
# this reader accept a value the other two reject, which is the precise kind of
# divergence the session-pin parity tests exist to catch.
_loki_generic_tier="${LOKI_SESSION_MODEL:-}"
_loki_generic_tier="${_loki_generic_tier#"${_loki_generic_tier%%[![:space:]]*}"}"
_loki_generic_tier="${_loki_generic_tier%"${_loki_generic_tier##*[![:space:]]}"}"
case "$(printf '%s' "$_loki_generic_tier" | tr '[:upper:]' '[:lower:]')" in
    small)  LOKI_SESSION_MODEL="fast"        ; export LOKI_SESSION_MODEL ;;
    medium) LOKI_SESSION_MODEL="development" ; export LOKI_SESSION_MODEL ;;
    high)   LOKI_SESSION_MODEL="planning"    ; export LOKI_SESSION_MODEL ;;
esac
unset _loki_generic_tier

# Process Supervision (opt-in)
WATCHDOG_ENABLED=${LOKI_WATCHDOG:-"false"}          # Enable process health monitoring
WATCHDOG_INTERVAL=${LOKI_WATCHDOG_INTERVAL:-30}     # Check interval in seconds
LAST_WATCHDOG_CHECK=0

STATUS_MONITOR_PID=""
DASHBOARD_PID=""
DASHBOARD_LAST_ALIVE=0
_DASHBOARD_RESTARTING=false
RESOURCE_MONITOR_PID=""
_LOKI_NODE_PROBE_STATUS="not_checked"
_LOKI_NODE_PROBE_VERSION=""
_LOKI_NPM_PROBE_STATUS="not_checked"
_LOKI_NPM_PROBE_VERSION=""

# Build-profile fast-path (v7.129+). LOKI_BUILD_PROFILE selects a gate profile.
# Default unset = current full behavior (every phase runs). "simple-web" is a
# fast profile for static marketing/landing builds: it drops the phases that
# are irrelevant to a static frontend (API tests, SAML/OIDC/SSO integration,
# load/performance, regression, UAT, competitor web research) while KEEPING the
# gates that catch real defects on such a page: E2E/Playwright (catches the
# broken-reveal / blank-page class of bug), CODE_REVIEW, SECURITY, and
# ACCESSIBILITY (a landing page must be a11y-clean). The completion council and
# the evidence/proof gate are NOT phases and are untouched -- the moat stays.
# Pure env-defaulting: it only fills a LOKI_PHASE_* that the operator has NOT
# already set, so an explicit operator LOKI_PHASE_* override always wins.
loki_is_supervised_simple_web() {
    [ "${LOKI_SUPERVISED_BUILD:-0}" = "1" ] \
        && [ "${LOKI_BUILD_PROFILE:-}" = "simple-web" ]
}

_loki_bind_supervised_spec_sha() {
    local spec_path="${1:-}"
    loki_is_supervised_simple_web || return 0
    [ -n "$spec_path" ] && [ -f "$spec_path" ] && [ -r "$spec_path" ] || {
        log_error "Supervised build specification is missing or unreadable."
        return 1
    }

    local computed_sha supplied_sha="${LOKI_SPEC_SHA256:-}"
    computed_sha=$(python3 - "$spec_path" <<'PY'
import hashlib
import sys

with open(sys.argv[1], "rb") as handle:
    print(hashlib.sha256(handle.read()).hexdigest())
PY
    ) || return 1
    [[ "$computed_sha" =~ ^[0-9a-f]{64}$ ]] || return 1

    if [ -n "$supplied_sha" ] && [ "$supplied_sha" != "$computed_sha" ]; then
        log_error "Supervised build specification changed before execution."
        return 1
    fi
    LOKI_SPEC_SHA256="$computed_sha"
    export LOKI_SPEC_SHA256
}

# Scoped-change profile: a bug fix or a single feature in an EXISTING repo.
#
# Measured on a real user's run: a 322-word GitHub issue against an existing
# codebase spent 25+ minutes still inside iteration 1. The work itself was
# genuine, but the build was also running competitor web research, load and
# performance testing, regression simulation and UAT -- all of which default to
# true, and none of which a scoped issue fix needs. That is the difference
# between a 5-minute fix and a 30-minute one.
#
# What this NEVER touches: code review, security, tests, E2E, and the
# completion council all stay on. Speed here comes from not running phases that
# are irrelevant to the change, never from skipping verification. A greenfield
# build or a whole-repo refactor does not match this profile and keeps the full
# suite.
#
# Auto-detected rather than another flag the user has to know: an existing git
# repo with real history, plus a spec that reads as a scoped change. Set
# LOKI_SCOPED_CHANGE=0 to force the full suite, or =1 to force this profile.
loki_detect_scoped_change() {
    # Explicit operator intent always wins, in both directions.
    case "${LOKI_SCOPED_CHANGE:-}" in
        0|false) return 1 ;;
        1|true)  return 0 ;;
    esac

    # Greenfield is not a scoped change: no repo, or a repo with almost no
    # history, means we are building something new.
    #
    # Ask git whether this is a work tree rather than testing for a .git
    # DIRECTORY: in a git worktree (and in a submodule) .git is a FILE, so the
    # old -d test rejected every worktree-based run -- including the parallel
    # workflow streams this project runs by default. rev-parse is true for a
    # plain clone, a worktree, and a submodule alike, and replaces two
    # subprocesses' worth of checking with one.
    local target="${TARGET_DIR:-.}"
    git -C "$target" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 1
    local commits
    commits="$(git -C "$target" rev-list --count HEAD 2>/dev/null || echo 0)"
    [ "${commits:-0}" -ge 5 ] || return 1

    # An issue-sourced spec is the canonical scoped change: someone filed a
    # discrete request against code that already exists.
    #
    # The spec path is passed in by the caller ($1). It used to be read only
    # from LOKI_PRD_FILE / LOKI_ISSUE_REF, but `loki start <issue>` writes
    # .loki/prd-issue-N.md and hands run.sh that path as a POSITIONAL argument,
    # so neither variable was ever set and this check could not fire.
    local spec="${1:-${LOKI_PRD_FILE:-}}"
    [ -n "${LOKI_ISSUE_REF:-}" ] && return 0
    case "$spec" in
        *prd-issue-*) return 0 ;;
    esac

    return 1
}

loki_apply_scoped_change_profile() {
    loki_detect_scoped_change "${1:-}" || return 0

    # Off: cannot affect the correctness of a scoped change to existing code.
    : "${LOKI_PHASE_WEB_RESEARCH:=false}"
    : "${LOKI_PHASE_PERFORMANCE:=false}"
    : "${LOKI_PHASE_REGRESSION:=false}"
    : "${LOKI_PHASE_UAT:=false}"

    # On: every trust gate, unchanged. These are the moat.
    : "${LOKI_PHASE_CODE_REVIEW:=true}"
    : "${LOKI_PHASE_SECURITY:=true}"
    : "${LOKI_PHASE_UNIT_TESTS:=true}"
    : "${LOKI_PHASE_E2E_TESTS:=true}"

    export LOKI_PHASE_WEB_RESEARCH LOKI_PHASE_PERFORMANCE LOKI_PHASE_REGRESSION
    export LOKI_PHASE_UAT LOKI_PHASE_CODE_REVIEW LOKI_PHASE_SECURITY
    export LOKI_PHASE_UNIT_TESTS LOKI_PHASE_E2E_TESTS
    export LOKI_SCOPED_CHANGE_ACTIVE=1
}

loki_apply_build_profile() {
    [ "${LOKI_BUILD_PROFILE:-}" = "simple-web" ] || return 0
    : "${LOKI_PHASE_API_TESTS:=false}"
    : "${LOKI_PHASE_INTEGRATION:=false}"
    : "${LOKI_PHASE_PERFORMANCE:=false}"
    : "${LOKI_PHASE_REGRESSION:=false}"
    : "${LOKI_PHASE_UAT:=false}"
    : "${LOKI_PHASE_WEB_RESEARCH:=false}"
    : "${LOKI_PHASE_E2E_TESTS:=true}"
    : "${LOKI_PHASE_CODE_REVIEW:=true}"
    : "${LOKI_PHASE_SECURITY:=true}"
    : "${LOKI_PHASE_ACCESSIBILITY:=true}"

    # The hosted simple-web path is a measured execution policy, not an
    # operator preset. It removes only orchestration that cannot affect the
    # generated app, pins every model-bearing subcall to the selected model,
    # and keeps all applicable verification and proof gates fail closed.
    loki_is_supervised_simple_web || return 0
    local selected_model sdk_model
    selected_model="$(printf '%s' "${LOKI_SESSION_MODEL:-sonnet}" | tr '[:upper:]' '[:lower:]')"
    case "$selected_model" in
        haiku|sonnet|opus) ;;
        *) selected_model="sonnet" ;;
    esac
    sdk_model="$selected_model"
    if command -v python3 >/dev/null 2>&1 && [ -r "$PROJECT_DIR/providers/model_catalog.json" ]; then
        sdk_model="$(_LOKI_SELECTED_MODEL="$selected_model" _LOKI_MODEL_CATALOG="$PROJECT_DIR/providers/model_catalog.json" python3 -c '
import json, os
with open(os.environ["_LOKI_MODEL_CATALOG"], encoding="utf-8") as f:
    catalog = json.load(f)
print(catalog["providers"]["claude"]["cli_aliases"].get(os.environ["_LOKI_SELECTED_MODEL"], ""))
' 2>/dev/null)" || sdk_model=""
        [ -n "$sdk_model" ] || sdk_model="$selected_model"
    fi

    LOKI_PHASE_UNIT_TESTS=true
    LOKI_PHASE_E2E_TESTS=true
    LOKI_PHASE_CODE_REVIEW=true
    LOKI_PHASE_SECURITY=true
    LOKI_PHASE_ACCESSIBILITY=true
    LOKI_COUNCIL_ENABLED=true
    LOKI_EVIDENCE_GATE=1
    LOKI_PROOF_GATE=1
    LOKI_PROOF=1

    LOKI_DASHBOARD=false
    LOKI_PARALLEL_MODE=false
    LOKI_MAX_PARALLEL_AGENTS=1
    LOKI_MAX_ITERATIONS=2
    # MAX_RETRIES is the loop attempt ceiling, so 2 means an initial attempt
    # plus one targeted repair attempt.
    LOKI_MAX_RETRIES=2
    LOKI_BASE_WAIT=2
    LOKI_MAX_WAIT=10
    LOKI_PROVIDER_CALL_TIMEOUT=240
    LOKI_PROVIDER_IDLE_TIMEOUT=60
    LOKI_DRAFT_EFFORT=medium
    # One low-effort requirements review gets a hard 90 second ceiling. The
    # prior 75 second ceiling clipped valid Haiku responses, while a second
    # identical full review doubled latency without adding evidence.
    LOKI_REVIEW_CALL_TIMEOUT=90
    LOKI_SDK_REVIEW_TIMEOUT=90
    LOKI_COUNCIL_REVIEW_TIMEOUT=90
    LOKI_COUNCIL_TIMEOUT_MS=90000
    LOKI_REVIEW_EFFORT=low
    LOKI_GATE_TIMEOUT=60
    LOKI_DEPENDENCY_SETUP_TIMEOUT=60
    LOKI_COUNCIL_MIN_ITERATIONS=1
    LOKI_COUNCIL_CHECK_INTERVAL=1

    LOKI_SPEC_GRILL=0
    LOKI_DONE_RECOGNITION=0
    LOKI_INTELLIGENT_USAGE=0
    LOKI_PRD_ENRICH=0
    LOKI_AUTO_DOCS=false
    LOKI_GATE_DOC_COVERAGE=false
    LOKI_GATE_MAGIC_DEBATE=false
    LOKI_WIKI_AUTO=0
    LOKI_AUTO_FALLBACK=off
    LOKI_CAVEMAN=0
    LOKI_CAVEMAN_AUTO_BOOTSTRAP=0
    CAVEMAN_DEFAULT_MODE=off
    LOKI_FABLE_ARCHITECT=0
    LOKI_LEGACY_TIER_SWITCHING=false
    LOKI_REVIEW_RETRY=0
    LOKI_REVIEW_JSON_SCHEMA=off
    LOKI_REVIEW_REQUIREMENTS_ONLY=1
    LOKI_GATE_DEVILS_ADVOCATE=false
    LOKI_REVIEW_INCONCLUSIVE_BLOCK=1
    LOKI_EXPERIMENTAL_MANAGED_AGENTS=false
    LOKI_EXPERIMENTAL_MANAGED_REVIEW=false
    LOKI_EXPERIMENTAL_MANAGED_COUNCIL=false
    LOKI_COUNCIL_MODEL_VOTERS=0
    LOKI_OVERRIDE_COUNCIL=0
    LOKI_BUILD_CHECK_BLOCK=1
    LOKI_SESSION_MODEL="$selected_model"
    LOKI_MAX_TIER="$selected_model"
    LOKI_MODEL_OVERRIDE="$selected_model"
    [ "$selected_model" = "haiku" ] && LOKI_ALLOW_HAIKU=true
    LOKI_ADVISOR_MODEL="$selected_model"
    LOKI_GRILL_MODEL="$selected_model"
    LOKI_CLAUDE_MODEL_PLANNING="$selected_model"
    LOKI_CLAUDE_MODEL_DEVELOPMENT="$selected_model"
    LOKI_CLAUDE_MODEL_FAST="$selected_model"
    LOKI_SDK_COUNCIL_MODEL="$sdk_model"
    LOKI_SDK_GRILL_MODEL="$sdk_model"
    LOKI_SDK_JUDGE_MODEL="$sdk_model"
    LOKI_SDK_PRD_ENRICH_MODEL="$sdk_model"
    LOKI_SDK_REVIEW_MODEL="$sdk_model"

    MAX_RETRIES="$LOKI_MAX_RETRIES"
    BASE_WAIT="$LOKI_BASE_WAIT"
    MAX_WAIT="$LOKI_MAX_WAIT"
    ENABLE_DASHBOARD="$LOKI_DASHBOARD"
    MAX_PARALLEL_AGENTS="$LOKI_MAX_PARALLEL_AGENTS"

    export LOKI_PHASE_UNIT_TESTS LOKI_PHASE_E2E_TESTS
    export LOKI_PHASE_CODE_REVIEW LOKI_PHASE_SECURITY LOKI_PHASE_ACCESSIBILITY
    export LOKI_COUNCIL_ENABLED LOKI_EVIDENCE_GATE LOKI_PROOF_GATE LOKI_PROOF
    export LOKI_DASHBOARD LOKI_PARALLEL_MODE LOKI_MAX_PARALLEL_AGENTS
    export LOKI_MAX_ITERATIONS LOKI_MAX_RETRIES LOKI_BASE_WAIT LOKI_MAX_WAIT
    export LOKI_PROVIDER_CALL_TIMEOUT LOKI_PROVIDER_IDLE_TIMEOUT LOKI_DRAFT_EFFORT
    export LOKI_REVIEW_CALL_TIMEOUT LOKI_SDK_REVIEW_TIMEOUT LOKI_REVIEW_EFFORT
    export LOKI_COUNCIL_REVIEW_TIMEOUT LOKI_COUNCIL_TIMEOUT_MS LOKI_GATE_TIMEOUT
    export LOKI_DEPENDENCY_SETUP_TIMEOUT
    export LOKI_COUNCIL_MIN_ITERATIONS LOKI_COUNCIL_CHECK_INTERVAL
    export LOKI_SPEC_GRILL LOKI_DONE_RECOGNITION LOKI_INTELLIGENT_USAGE LOKI_PRD_ENRICH
    export LOKI_AUTO_DOCS LOKI_GATE_DOC_COVERAGE LOKI_GATE_MAGIC_DEBATE
    export LOKI_WIKI_AUTO LOKI_AUTO_FALLBACK LOKI_CAVEMAN
    export LOKI_CAVEMAN_AUTO_BOOTSTRAP CAVEMAN_DEFAULT_MODE
    export LOKI_FABLE_ARCHITECT LOKI_LEGACY_TIER_SWITCHING LOKI_REVIEW_RETRY
    export LOKI_REVIEW_JSON_SCHEMA LOKI_REVIEW_REQUIREMENTS_ONLY LOKI_GATE_DEVILS_ADVOCATE
    export LOKI_REVIEW_INCONCLUSIVE_BLOCK LOKI_EXPERIMENTAL_MANAGED_AGENTS
    export LOKI_EXPERIMENTAL_MANAGED_REVIEW LOKI_EXPERIMENTAL_MANAGED_COUNCIL
    export LOKI_COUNCIL_MODEL_VOTERS LOKI_OVERRIDE_COUNCIL LOKI_BUILD_CHECK_BLOCK
    export LOKI_SESSION_MODEL LOKI_MAX_TIER LOKI_MODEL_OVERRIDE LOKI_ALLOW_HAIKU
    export LOKI_ADVISOR_MODEL LOKI_GRILL_MODEL
    export LOKI_CLAUDE_MODEL_PLANNING LOKI_CLAUDE_MODEL_DEVELOPMENT
    export LOKI_CLAUDE_MODEL_FAST LOKI_SDK_COUNCIL_MODEL LOKI_SDK_GRILL_MODEL
    export LOKI_SDK_JUDGE_MODEL LOKI_SDK_PRD_ENRICH_MODEL LOKI_SDK_REVIEW_MODEL
}
loki_apply_build_profile
loki_apply_scoped_change_profile

# Default hang guard for EVERY build, not just simple-web.
#
# The two timeouts above are set inside loki_apply_build_profile(), which
# returns immediately unless LOKI_BUILD_PROFILE=simple-web. So on a normal
# build both resolved to 0, and 0 means no guard at all -- verified by running
# the deadline helper directly: `deadline.py 0 0 3 -- sleep 5` runs to
# completion unkilled. A provider that hung had nothing to stop it.
#
# IDLE only, and no retry. That is what keeps this compatible with the standing
# objection recorded above (search: "former invoke_with_timeout"), whose two
# reasons remain correct:
#
#   1. "No safe generous default" applies to a fixed TOTAL timeout, which
#      cannot tell a long legitimate iteration from a hang. An idle timeout
#      can: it measures silence, not duration. Verified both directions --
#      `sleep 600` under a 120s idle cap dies, while a process emitting output
#      every second survives indefinitely. A coding agent streams constantly;
#      one silent for two minutes is not working.
#   2. "Wrong retry semantics" stands, so nothing here retries. The call is
#      killed, and the existing failure path handles it. Re-running an agent
#      that may have already edited files remains off the table.
#
# 7200s hard ceiling is a backstop against a process that streams forever
# without converging; the idle cap is the load-bearing guard. Both are
# overridable, and setting either to 0 restores the old unguarded behaviour.
: "${LOKI_PROVIDER_IDLE_TIMEOUT:=120}"
: "${LOKI_PROVIDER_CALL_TIMEOUT:=7200}"
export LOKI_PROVIDER_IDLE_TIMEOUT LOKI_PROVIDER_CALL_TIMEOUT

loki_background_services_enabled() {
    ! loki_is_supervised_simple_web
}

_loki_with_deadline() {
    local seconds="${1:-0}"
    local kill_grace="${LOKI_DEADLINE_KILL_GRACE:-2}"
    local idle_seconds="${LOKI_DEADLINE_IDLE_TIMEOUT:-0}"
    shift
    case "$seconds" in
        ''|*[!0-9]*) seconds=0 ;;
    esac
    case "$kill_grace" in
        ''|*[!0-9]*) kill_grace=2 ;;
    esac
    case "$idle_seconds" in
        ''|*[!0-9]*) idle_seconds=0 ;;
    esac
    [ "$kill_grace" -gt 0 ] 2>/dev/null || kill_grace=2
    [ "$idle_seconds" -ge 0 ] 2>/dev/null || idle_seconds=0
    if [ "$seconds" -le 0 ] 2>/dev/null; then
        "$@"
    elif command -v python3 >/dev/null 2>&1 \
        && [ -r "${SCRIPT_DIR}/lib/deadline.py" ]; then
        python3 "${SCRIPT_DIR}/lib/deadline.py" \
            "$seconds" "$kill_grace" "$idle_seconds" -- "$@"
    else
        log_error "Required command deadline cannot be enforced: deadline helper is unavailable"
        return 125
    fi
}

_loki_with_deadline_stdin_text() {
    local seconds="${1:-0}"
    local input_text="${2:-}"
    local input_file rc=0
    shift 2
    input_file="$(mktemp "${TMPDIR:-/tmp}/loki-review-input.XXXXXX")" || return 125
    chmod 600 "$input_file" 2>/dev/null || {
        rm -f "$input_file" 2>/dev/null || true
        return 125
    }
    if ! printf '%s' "$input_text" > "$input_file"; then
        rm -f "$input_file" 2>/dev/null || true
        return 125
    fi
    _loki_with_deadline "$seconds" "$@" < "$input_file" || rc=$?
    rm -f "$input_file" 2>/dev/null || true
    return "$rc"
}

_loki_snapshot_workspace_tree() {
    local tree="${1:-${TARGET_DIR:-.}}"
    local snapshot_dir snapshot_index index_tree snapshot_tree rc=0
    snapshot_dir="$(mktemp -d "${TMPDIR:-/tmp}/loki-iter-index.XXXXXX")" || return 1
    snapshot_index="$snapshot_dir/index"
    index_tree=$(git -C "$tree" write-tree 2>/dev/null) || rc=$?
    if [ "$rc" -eq 0 ]; then
        GIT_INDEX_FILE="$snapshot_index" git -C "$tree" read-tree "$index_tree" \
            >/dev/null 2>&1 || rc=$?
    fi
    if [ "$rc" -eq 0 ]; then
        GIT_INDEX_FILE="$snapshot_index" git -C "$tree" add -A -- . ':(exclude).loki/' \
            >/dev/null 2>&1 || rc=$?
    fi
    if [ "$rc" -eq 0 ]; then
        snapshot_tree=$(GIT_INDEX_FILE="$snapshot_index" git -C "$tree" write-tree 2>/dev/null) || rc=$?
    fi
    rm -f "$snapshot_index" "$snapshot_index.lock" 2>/dev/null || true
    rmdir "$snapshot_dir" 2>/dev/null || true
    [ "$rc" -eq 0 ] && [ -n "${snapshot_tree:-}" ] || return 1
    printf '%s\n' "$snapshot_tree"
}

_loki_provider_pipeline_exit_code() {
    local provider_rc="${1:-125}"
    local tee_rc="${2:-125}"
    local parser_rc="${3:-0}"
    if [ "$provider_rc" -ne 0 ]; then
        printf '%s\n' "$provider_rc"
    elif [ "$tee_rc" -ne 0 ]; then
        printf '%s\n' "$tee_rc"
    else
        printf '%s\n' "$parser_rc"
    fi
}

_LOKI_DEPENDENCY_SETUP_LIB="${SCRIPT_DIR}/lib/dependency-setup.sh"
if [ -r "$_LOKI_DEPENDENCY_SETUP_LIB" ]; then
    # shellcheck source=lib/dependency-setup.sh
    source "$_LOKI_DEPENDENCY_SETUP_LIB"
fi

_loki_workspace_changed_since_iteration() {
    local start_sha="$1"
    local tree="${TARGET_DIR:-.}"
    [ -n "$start_sha" ] || return 0
    git -C "$tree" rev-parse --verify "$start_sha" >/dev/null 2>&1 || return 0
    git -C "$tree" diff --quiet "$start_sha" -- >/dev/null 2>&1
    case "$?" in
        1) return 0 ;;
        0) ;;
        *) return 0 ;;
    esac
    local untracked path
    untracked=$(git -C "$tree" ls-files --others --exclude-standard 2>/dev/null) || return 0
    while IFS= read -r path; do
        [ -n "$path" ] || continue
        case "$path" in
            .loki|.loki/*) continue ;;
            *) return 0 ;;
        esac
    done << UNTRACKED_EOF
$untracked
UNTRACKED_EOF
    return 1
}

_loki_supervised_claude_isolation_ready() {
    loki_is_supervised_simple_web || return 1
    type loki_claude_flag_supported >/dev/null 2>&1 || return 1
    loki_claude_flag_supported "--setting-sources" || return 1
    loki_claude_flag_supported "--disallowedTools" || return 1
    loki_claude_flag_supported "--tools" || return 1
    loki_claude_flag_supported "--mcp-config" || return 1
    loki_claude_flag_supported "--strict-mcp-config" || return 1
    loki_claude_flag_supported "--disable-slash-commands" || return 1
}

_loki_write_supervised_simple_web_policy() {
    loki_is_supervised_simple_web || return 0
    local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}/.loki}"
    local policy_file="$loki_dir/state/execution-policy.json"
    mkdir -p "$(dirname "$policy_file")" || return 1
    _LOKI_POLICY_FILE="$policy_file" \
    _LOKI_POLICY_MODEL="${LOKI_SESSION_MODEL:-}" \
    _LOKI_POLICY_MODEL_ID="${LOKI_SDK_REVIEW_MODEL:-}" \
    _LOKI_POLICY_LEARNINGS="$loki_dir" \
    _LOKI_POLICY_GIT_CONFIG="${GIT_CONFIG_GLOBAL:-}" \
    _LOKI_POLICY_NODE_STATUS="$_LOKI_NODE_PROBE_STATUS" \
    _LOKI_POLICY_NODE_VERSION="$_LOKI_NODE_PROBE_VERSION" \
    _LOKI_POLICY_NPM_STATUS="$_LOKI_NPM_PROBE_STATUS" \
    _LOKI_POLICY_NPM_VERSION="$_LOKI_NPM_PROBE_VERSION" \
    python3 -c '
import json, os

def number(name):
    try:
        return int(os.environ.get(name, "0"))
    except ValueError:
        return 0

target = os.environ["_LOKI_POLICY_FILE"]
policy = {
    "schema_version": 1,
    "policy": "supervised-simple-web",
    "active": True,
    "fail_closed": True,
    "build_profile": "simple-web",
    "verification_status": "required_not_yet_proven",
    "model": {
        "alias": os.environ.get("_LOKI_POLICY_MODEL", ""),
        "sdk_id": os.environ.get("_LOKI_POLICY_MODEL_ID", ""),
        "max_tier": os.environ.get("LOKI_MAX_TIER", ""),
        "nested_task_tool": False,
        "fallback": False,
    },
    "limits": {
        "iterations": number("LOKI_MAX_ITERATIONS"),
        "attempts": number("LOKI_MAX_RETRIES"),
        "provider_hard_seconds": number("LOKI_PROVIDER_CALL_TIMEOUT"),
        "provider_idle_seconds": number("LOKI_PROVIDER_IDLE_TIMEOUT"),
        "review_seconds": number("LOKI_REVIEW_CALL_TIMEOUT"),
        "council_seconds": number("LOKI_COUNCIL_REVIEW_TIMEOUT"),
        "gate_seconds": number("LOKI_GATE_TIMEOUT"),
    },
    "skipped": [
        "dashboard", "status_monitor", "resource_monitor", "enterprise_services",
        "spec_grill", "done_recognition", "prd_model_enrichment", "auto_docs",
        "auto_wiki", "caveman", "completion_model_voters",
    ],
    "required_gates": [
        "production_build", "unit_tests", "browser_e2e", "accessibility",
        "security", "code_review", "completion_checklist", "completion_heldout",
        "completion_evidence", "completion_assumptions", "proof",
    ],
    "state": {
        "learnings_root": os.environ.get("_LOKI_POLICY_LEARNINGS", ""),
        "git_config_global": os.environ.get("_LOKI_POLICY_GIT_CONFIG", ""),
        "git_baseline_required": True,
    },
    "runtime_probes": {
        "node": {"status": os.environ.get("_LOKI_POLICY_NODE_STATUS", ""), "version": os.environ.get("_LOKI_POLICY_NODE_VERSION", "")},
        "npm": {"status": os.environ.get("_LOKI_POLICY_NPM_STATUS", ""), "version": os.environ.get("_LOKI_POLICY_NPM_VERSION", "")},
    },
}
tmp = target + ".tmp." + str(os.getpid())
with open(tmp, "w", encoding="utf-8") as f:
    json.dump(policy, f, indent=2, sort_keys=True)
    f.write("\n")
os.replace(tmp, target)
' || return 1
    log_info "Execution policy: $policy_file"
}

# SDLC Phase Controls (all enabled by default)
PHASE_UNIT_TESTS=${LOKI_PHASE_UNIT_TESTS:-true}
PHASE_API_TESTS=${LOKI_PHASE_API_TESTS:-true}
PHASE_E2E_TESTS=${LOKI_PHASE_E2E_TESTS:-true}
PHASE_SECURITY=${LOKI_PHASE_SECURITY:-true}
PHASE_INTEGRATION=${LOKI_PHASE_INTEGRATION:-true}
PHASE_CODE_REVIEW=${LOKI_PHASE_CODE_REVIEW:-true}
PHASE_WEB_RESEARCH=${LOKI_PHASE_WEB_RESEARCH:-true}
PHASE_PERFORMANCE=${LOKI_PHASE_PERFORMANCE:-true}
PHASE_ACCESSIBILITY=${LOKI_PHASE_ACCESSIBILITY:-true}
PHASE_REGRESSION=${LOKI_PHASE_REGRESSION:-true}
PHASE_UAT=${LOKI_PHASE_UAT:-true}

# Autonomous Loop Controls (Ralph Wiggum Mode)
# Default: No auto-completion - runs until max iterations or explicit promise
COMPLETION_PROMISE=${LOKI_COMPLETION_PROMISE:-""}
MAX_ITERATIONS=${LOKI_MAX_ITERATIONS:-1000}
ITERATION_COUNT=0

# If this is an auto-fix task, allow more iterations
if [[ "${LOKI_AUTO_FIX:-}" == "true" ]]; then
    MAX_ITERATIONS="${LOKI_MAX_ITERATIONS:-5}"
fi
# Perpetual mode: never stop unless max iterations (ignores all completion signals)
PERPETUAL_MODE=${LOKI_PERPETUAL_MODE:-false}

# F4: bound the runaway ceiling for ORDINARY runs.
#
# MEASURED, not chosen. Real per-iteration wall clock across every recorded run
# on this machine: median 718s, max 1746s. So the 1000 default is an 8.3-DAY
# ceiling -- and it is the ONLY backstop, because the other two valves ship
# disabled: LOKI_BUDGET_LIMIT defaults to "" (check_budget_limit returns
# immediately) and LOKI_MAX_DURATION defaults to 0 (check_max_duration returns
# "never stop").
#
# It also contradicts our own documentation. SETUP.md tells users to RAISE the
# budget for large work with LOKI_MAX_ITERATIONS=40, and the demo uses 10 -- so
# the shipped default is 25x the documented "large" setting.
#
# What real runs actually use: 1, 1, 3, 4. Every one terminated `completed` via
# council approval or a completion promise; NONE hit a cap. Those are the
# evidence-driven terminals, and they are unaffected by this -- the cap is a
# backstop, not the mechanism.
#
# 25 is deliberately generous against that evidence (6x the observed maximum),
# because the research is explicit that a too-small cap fails runs whose
# approach was sound: 1-2 caps fail even when the agent was on track, and the
# recommended range is 5-10. This is not a first-pass target; F0 (v8.45.0)
# already stops a doomed run at its CAUSE, which is the better instrument.
#
# TWO GUARDS, both load-bearing:
#   - an explicit LOKI_MAX_ITERATIONS always wins, so nobody's setting changes
#   - PERPETUAL_MODE is untouched: it deliberately ignores every completion
#     signal and relies on max-iterations as its ONLY stop, so lowering the cap
#     there would silently truncate exactly the runs that opted out of stopping
if [ -z "${LOKI_MAX_ITERATIONS:-}" ] \
   && [ "$PERPETUAL_MODE" != "true" ] \
   && [ "${LOKI_AUTO_FIX:-}" != "true" ]; then
    MAX_ITERATIONS="${LOKI_MAX_ITERATIONS_DEFAULT:-25}"
fi

# Enterprise background service PIDs (OTEL bridge, audit subscriber, integration sync)
ENTERPRISE_PIDS=()

# Portable lock helper (v7.5.12) -- mkdir-mutex replacement for flock(1).
# Provides safe_acquire_lock / safe_release_lock / safe_with_lock so bash
# callers no longer need a Linux-only flock binary. Macs do not ship
# flock; pre-7.5.12 the fallback was a non-atomic PID check that emitted
# "[WARN] flock not available - using non-atomic PID check ...".
LOCK_LIB="$SCRIPT_DIR/lib/lock.sh"
if [ -f "$LOCK_LIB" ]; then
    # shellcheck source=lib/lock.sh
    source "$LOCK_LIB"
fi

# Git PR advisory (shared print-only helper for create_session_pr and loki deploy)
GIT_PR_ADVISORY_LIB="$SCRIPT_DIR/lib/git-pr-advisory.sh"
if [ -f "$GIT_PR_ADVISORY_LIB" ]; then
    # shellcheck source=lib/git-pr-advisory.sh
    source "$GIT_PR_ADVISORY_LIB"
fi

# Proven PR (Loop 6): shared print-only Evidence Receipt renderer for PR bodies.
# render_evidence_receipt_md prints the run's honest headline + facts +
# verify-yourself block into the PR body. Pure, never pushes/PRs/mutates.
PROOF_PR_LIB="$SCRIPT_DIR/lib/proof-pr.sh"
if [ -f "$PROOF_PR_LIB" ]; then
    # shellcheck source=lib/proof-pr.sh
    source "$PROOF_PR_LIB"
fi

# Proven PR (Loop 6 / Slice B): optional advisory verified-completion check-run.
# Owned by Slice B (autonomy/lib/proof-check.sh); sourced guarded so this slice
# is correct whether or not the file is present in the tree, and the single call
# site is itself guarded on declare -f + LOKI_PROVEN_PR_CHECK.
PROOF_CHECK_LIB="$SCRIPT_DIR/lib/proof-check.sh"
if [ -f "$PROOF_CHECK_LIB" ]; then
    # shellcheck source=lib/proof-check.sh
    source "$PROOF_CHECK_LIB"
fi

# Completion Council (v5.25.0) - Multi-agent completion verification
# Source completion council module
COUNCIL_SCRIPT="$SCRIPT_DIR/completion-council.sh"
if [ -f "$COUNCIL_SCRIPT" ]; then
    # shellcheck source=completion-council.sh
    source "$COUNCIL_SCRIPT"
fi

# PRD Checklist module (v5.44.0)
if [ -f "${SCRIPT_DIR}/prd-checklist.sh" ]; then
    # shellcheck source=prd-checklist.sh
    source "${SCRIPT_DIR}/prd-checklist.sh"
fi

# App Runner module (v5.45.0)
if [ -f "${SCRIPT_DIR}/app-runner.sh" ]; then
    # shellcheck source=app-runner.sh
    source "${SCRIPT_DIR}/app-runner.sh"
fi

# Build-time HOME isolation (F49).
#
# When Loki executes/tests the GENERATED app during a build (the app-runner
# server launch, restarts, and the project's own test suite in
# enforce_test_coverage), the child process inherits Loki's environment --
# including the user's REAL $HOME. A generated app that defaults its state file
# to a HOME-relative path (e.g. a todo CLI writing ~/.todo.json) would then
# litter the user's home directory with Loki's in-build test data. These two
# helpers give those in-build executions an isolated HOME/XDG/TMPDIR rooted
# under .loki/ so the app can run normally but cannot write into the real home.
#
# Scope: ONLY Loki's own in-build test executions are sandboxed. The user's
# later manual `npm start`/run of the app is unaffected -- they invoke it
# themselves in their own shell with their own HOME.

# Lazily create and echo a persistent isolated HOME directory under .loki.
# Persistent (not a per-call mktemp) so an app launched in one iteration and
# restarted in a later one keeps a stable home, and so its state survives
# across the build the same way a real run would.
_loki_app_sandbox_dir() {
    local _base="${TARGET_DIR:-.}/.loki/app-sandbox"
    if [ ! -d "$_base/home" ]; then
        mkdir -p "$_base/home" "$_base/config" "$_base/data" \
                 "$_base/cache" "$_base/state" "$_base/tmp" 2>/dev/null || return 1
    fi
    printf '%s\n' "$_base"
}

# Run a command with the build-time app sandbox env applied, then restore the
# previous environment. Works for both shell functions (app_runner_start) and
# external commands because it only mutates env vars around the call. The
# background app launched by app_runner_start captures the sandbox HOME at fork
# time and keeps it for its lifetime; restoring here ensures the orchestrator
# (and the next iteration's provider invocation, which needs the REAL HOME for
# OAuth/credentials) is never left pointing at the sandbox.
_loki_with_app_sandbox() {
    local _sb
    _sb=$(_loki_app_sandbox_dir 2>/dev/null) || _sb=""
    # If the sandbox could not be created, run unsandboxed rather than failing
    # the build -- correctness of the app run takes priority over isolation.
    if [ -z "$_sb" ]; then
        "$@"
        return $?
    fi
    # Resolve to an absolute path so child `cd` into the target dir cannot make
    # a relative HOME point somewhere unexpected.
    local _abs
    _abs=$(cd "$_sb" 2>/dev/null && pwd) || _abs="$_sb"

    # Save current values plus their set/unset state so restore is exact.
    local _had_home="${HOME+x}"        _old_home="${HOME:-}"
    local _had_xch="${XDG_CONFIG_HOME+x}" _old_xch="${XDG_CONFIG_HOME:-}"
    local _had_xdh="${XDG_DATA_HOME+x}"   _old_xdh="${XDG_DATA_HOME:-}"
    local _had_xcache="${XDG_CACHE_HOME+x}" _old_xcache="${XDG_CACHE_HOME:-}"
    local _had_xsh="${XDG_STATE_HOME+x}"  _old_xsh="${XDG_STATE_HOME:-}"
    local _had_tmp="${TMPDIR+x}"        _old_tmp="${TMPDIR:-}"

    export HOME="$_abs/home"
    export XDG_CONFIG_HOME="$_abs/config"
    export XDG_DATA_HOME="$_abs/data"
    export XDG_CACHE_HOME="$_abs/cache"
    export XDG_STATE_HOME="$_abs/state"
    export TMPDIR="$_abs/tmp"

    local _rc=0
    "$@" || _rc=$?

    # Restore exactly (re-export prior value, or unset if it was unset before).
    if [ -n "$_had_home" ]; then export HOME="$_old_home"; else unset HOME; fi
    if [ -n "$_had_xch" ]; then export XDG_CONFIG_HOME="$_old_xch"; else unset XDG_CONFIG_HOME; fi
    if [ -n "$_had_xdh" ]; then export XDG_DATA_HOME="$_old_xdh"; else unset XDG_DATA_HOME; fi
    if [ -n "$_had_xcache" ]; then export XDG_CACHE_HOME="$_old_xcache"; else unset XDG_CACHE_HOME; fi
    if [ -n "$_had_xsh" ]; then export XDG_STATE_HOME="$_old_xsh"; else unset XDG_STATE_HOME; fi
    if [ -n "$_had_tmp" ]; then export TMPDIR="$_old_tmp"; else unset TMPDIR; fi

    return "$_rc"
}

# Playwright Smoke Test module (v5.46.0)
if [ -f "${SCRIPT_DIR}/playwright-verify.sh" ]; then
    # shellcheck source=playwright-verify.sh
    source "${SCRIPT_DIR}/playwright-verify.sh"
fi

# Anonymous usage telemetry (opt-out: LOKI_TELEMETRY_DISABLED=true or DO_NOT_TRACK=1)
# Also check persistent opt-out from ~/.loki/config (#77)
if [ -f "${HOME}/.loki/config" ] && grep -q "^TELEMETRY_DISABLED=true" "${HOME}/.loki/config" 2>/dev/null; then
    LOKI_TELEMETRY_DISABLED=true
    export LOKI_TELEMETRY_DISABLED
    unset LOKI_OTEL_ENDPOINT
fi
TELEMETRY_SCRIPT="$SCRIPT_DIR/telemetry.sh"
if [ -f "$TELEMETRY_SCRIPT" ]; then
    # shellcheck source=telemetry.sh
    source "$TELEMETRY_SCRIPT"
fi

# Crash-reporting helpers (Phase 0: local-only, zero egress).
# Provides loki_collection_enabled (unified opt-out), loki_crash_capture,
# loki_crash_friction, loki_show_disclosure_once.
CRASH_SCRIPT="$SCRIPT_DIR/crash.sh"
if [ -f "$CRASH_SCRIPT" ]; then
    # shellcheck source=crash.sh
    source "$CRASH_SCRIPT"
fi



# 2026 Research Enhancements (minimal additions)
PROMPT_REPETITION=${LOKI_PROMPT_REPETITION:-true}
CONFIDENCE_ROUTING=${LOKI_CONFIDENCE_ROUTING:-true}
AUTONOMY_MODE=${LOKI_AUTONOMY_MODE:-perpetual}  # perpetual|checkpoint|supervised

# Session-pinned model (S0.1): pin a single tier for the whole main loop.
# Default is "sonnet" -> resolved via the "development" tier in the abstract
# tier map. Set LOKI_LEGACY_TIER_SWITCHING=true to restore the old
# per-iteration RARV-driven tier rotation in the main loop. The
# get_rarv_tier function is preserved for subagent dispatch regardless.
LOKI_SESSION_MODEL="${LOKI_SESSION_MODEL:-sonnet}"
LOKI_LEGACY_TIER_SWITCHING="${LOKI_LEGACY_TIER_SWITCHING:-false}"
export LOKI_SESSION_MODEL LOKI_LEGACY_TIER_SWITCHING

# Managed Agents (v6.83.0 Phase 1): opt-in integration with Claude Managed Agents
# Memory store backend. Parent flag gates everything; child flags gate features.
# Both off (default) => zero behavior change from v6.82.0.
LOKI_MANAGED_AGENTS="${LOKI_MANAGED_AGENTS:-false}"
LOKI_MANAGED_MEMORY="${LOKI_MANAGED_MEMORY:-false}"
# v7.0.0 Phase 2: remote->local hydrate on session boot (grandchild of MEMORY).
# Pulls semantic patterns + procedural skills once at init_loki_dir time.
LOKI_MANAGED_MEMORY_HYDRATE="${LOKI_MANAGED_MEMORY_HYDRATE:-false}"
# v7.0.0 Phase 3+4 foundation: umbrella flag for the multiagent-session path.
# Gates every providers/managed.py entry point (run_council,
# run_completion_council). Off by default because the Managed Agents
# multiagent surface is a research preview.
LOKI_EXPERIMENTAL_MANAGED_AGENTS="${LOKI_EXPERIMENTAL_MANAGED_AGENTS:-false}"
# v7.0.0 Phase 3 (T5): managed code-review council. Routes run_code_review
# through providers/managed.py::run_council when true. Requires parent +
# umbrella.
LOKI_EXPERIMENTAL_MANAGED_REVIEW="${LOKI_EXPERIMENTAL_MANAGED_REVIEW:-false}"
# v7.0.0 Phase 4 (T6): managed completion council. Routes council_should_stop
# through providers/managed.py::run_completion_council when true. Requires
# parent + umbrella.
LOKI_EXPERIMENTAL_MANAGED_COUNCIL="${LOKI_EXPERIMENTAL_MANAGED_COUNCIL:-false}"
export LOKI_MANAGED_AGENTS LOKI_MANAGED_MEMORY LOKI_MANAGED_MEMORY_HYDRATE LOKI_EXPERIMENTAL_MANAGED_AGENTS LOKI_EXPERIMENTAL_MANAGED_REVIEW LOKI_EXPERIMENTAL_MANAGED_COUNCIL

# Fail-fast: child on with parent off is a misconfiguration.
if [ "$LOKI_MANAGED_MEMORY" = "true" ] && [ "$LOKI_MANAGED_AGENTS" != "true" ]; then
    echo "ERROR: LOKI_MANAGED_MEMORY=true requires LOKI_MANAGED_AGENTS=true" >&2
    exit 2
fi

# Phase 2 fail-fast: HYDRATE is a grandchild of MEMORY.
if [ "$LOKI_MANAGED_MEMORY_HYDRATE" = "true" ] && [ "$LOKI_MANAGED_MEMORY" != "true" ]; then
    echo "ERROR: LOKI_MANAGED_MEMORY_HYDRATE=true requires LOKI_MANAGED_MEMORY=true" >&2
    exit 2
fi

# Same fail-fast for the experimental multiagent session path.
if [ "$LOKI_EXPERIMENTAL_MANAGED_AGENTS" = "true" ] && [ "$LOKI_MANAGED_AGENTS" != "true" ]; then
    echo "ERROR: LOKI_EXPERIMENTAL_MANAGED_AGENTS=true requires LOKI_MANAGED_AGENTS=true" >&2
    exit 2
fi

# Phase 3 fail-fast: REVIEW requires parent AND umbrella.
if [ "$LOKI_EXPERIMENTAL_MANAGED_REVIEW" = "true" ]; then
    if [ "$LOKI_MANAGED_AGENTS" != "true" ]; then
        echo "ERROR: LOKI_EXPERIMENTAL_MANAGED_REVIEW=true requires LOKI_MANAGED_AGENTS=true" >&2
        exit 2
    fi
    if [ "$LOKI_EXPERIMENTAL_MANAGED_AGENTS" != "true" ]; then
        echo "ERROR: LOKI_EXPERIMENTAL_MANAGED_REVIEW=true requires LOKI_EXPERIMENTAL_MANAGED_AGENTS=true" >&2
        exit 2
    fi
fi

# Phase 4 fail-fast: COUNCIL requires parent AND umbrella.
if [ "$LOKI_EXPERIMENTAL_MANAGED_COUNCIL" = "true" ]; then
    if [ "$LOKI_MANAGED_AGENTS" != "true" ]; then
        echo "ERROR: LOKI_EXPERIMENTAL_MANAGED_COUNCIL=true requires LOKI_MANAGED_AGENTS=true" >&2
        exit 2
    fi
    if [ "$LOKI_EXPERIMENTAL_MANAGED_AGENTS" != "true" ]; then
        echo "ERROR: LOKI_EXPERIMENTAL_MANAGED_COUNCIL=true requires LOKI_EXPERIMENTAL_MANAGED_AGENTS=true" >&2
        exit 2
    fi
fi

# Research-preview warning banner.
if [ "$LOKI_EXPERIMENTAL_MANAGED_AGENTS" = "true" ]; then
    echo "WARN: LOKI_EXPERIMENTAL_MANAGED_AGENTS uses Managed Agents research preview; expect beta churn." >&2
fi

# Parallel Workflows (Git Worktrees)
PARALLEL_MODE=${LOKI_PARALLEL_MODE:-false}
MAX_WORKTREES=${LOKI_MAX_WORKTREES:-5}
MAX_PARALLEL_SESSIONS=${LOKI_MAX_PARALLEL_SESSIONS:-3}
# Nested-agent guard: the fixed testing/docs sub-streams each spawn a FURTHER
# nested `claude` session in a NON-namespaced worktree (`<project>-testing`,
# `<project>-docs`). When Loki itself runs inside another agent session (Claude
# Code sets CLAUDECODE; the Loki Mode skill, or a user driving the CLI as a
# background process from an agent), those nested spawns fight over the shared
# checkout/worktree paths and flood the log -- the reported "N issues in one dir"
# failure. So default the sub-streams OFF when nested; the core issue work still
# runs. Explicit LOKI_PARALLEL_TESTING/DOCS=true overrides (opt back in). This
# does NOT disable the user's own --parallel/--pr issue work, only the auxiliary
# testing/docs fan-out that is unsafe to nest.
_loki_nested_agent=false
if [ -n "${CLAUDECODE:-}" ] || [ -n "${LOKI_NESTED_AGENT:-}" ]; then
    _loki_nested_agent=true
fi
if [ "$_loki_nested_agent" = "true" ]; then
    PARALLEL_TESTING=${LOKI_PARALLEL_TESTING:-false}
    PARALLEL_DOCS=${LOKI_PARALLEL_DOCS:-false}
else
    PARALLEL_TESTING=${LOKI_PARALLEL_TESTING:-true}
    PARALLEL_DOCS=${LOKI_PARALLEL_DOCS:-true}
fi

# Dynamic resource-aware session concurrency (Release 3, slice 3).
# DEFAULT OFF: when LOKI_DYNAMIC_CONCURRENCY is unset, effective_session_cap()
# returns exactly MAX_PARALLEL_SESSIONS, so behavior is identical to before.
# Opt in with LOKI_DYNAMIC_CONCURRENCY=1 to scale the session cap down when
# system CPU or memory is under pressure (read from .loki/state/resources.json).
DYNAMIC_CONCURRENCY=${LOKI_DYNAMIC_CONCURRENCY:-0}
# Optional higher ceiling on capable machines. Only takes effect with dynamic
# concurrency enabled; still resource-gated. Defaults to MAX_PARALLEL_SESSIONS.
MAX_PARALLEL_SESSIONS_CEILING=${LOKI_MAX_PARALLEL_SESSIONS_CEILING:-$MAX_PARALLEL_SESSIONS}
# Usage thresholds (percent). At/above CPU or MEM threshold the cap is halved.
CONCURRENCY_CPU_THRESHOLD=${LOKI_CONCURRENCY_CPU_THRESHOLD:-85}
CONCURRENCY_MEM_THRESHOLD=${LOKI_CONCURRENCY_MEM_THRESHOLD:-85}
# Critical threshold (percent). At/above this the cap is forced to 1.
CONCURRENCY_CRITICAL_THRESHOLD=${LOKI_CONCURRENCY_CRITICAL_THRESHOLD:-95}

# Gate Escalation Ladder (v6.10.0). LOKI_GATE_CLEAR_LIMIT is retained for
# compatibility, but it is now the repeated-blocker escalation threshold.
# A blocking gate is never cleared into a pass merely because it repeated.
GATE_CLEAR_LIMIT=${LOKI_GATE_CLEAR_LIMIT:-3}
GATE_ESCALATE_LIMIT=${LOKI_GATE_ESCALATE_LIMIT:-5}
GATE_PAUSE_LIMIT=${LOKI_GATE_PAUSE_LIMIT:-10}
# Workspace resolution: the build runs against TARGET_DIR, defaulting to the
# launch cwd. A caller can pin a per-build workspace by exporting
# LOKI_TARGET_DIR (and the matching LOKI_DIR=<dir>/.loki); the dashboard
# /api/control/start `workspace` param (Track-1 S1) does exactly this so a
# hosted build runs in its own dir instead of the engine's repo. Every
# $TARGET_DIR/.loki reference below then resolves under that workspace.
TARGET_DIR="${LOKI_TARGET_DIR:-$(pwd)}"
PARALLEL_BLOG=${LOKI_PARALLEL_BLOG:-false}
AUTO_MERGE=${LOKI_AUTO_MERGE:-true}

# Tier-aware harness policy (model-equivalence experiment, Planned/Stage-3).
# Given the resolved tier, export the steering-lever env vars UNLESS the operator
# already set them (operator override ALWAYS wins). Gated on
# LOKI_TIER_HARNESS_POLICY (default 0 = off) until Stage-2 ablation data justifies
# the numbers. Pure env-export, no control flow. The table below is a STUB --
# replace with Stage-2 ablation-derived numbers (see
# docs/MODEL-EQUIVALENCE-HARNESS-PLAN.md section 3).
loki_apply_tier_harness_policy() {
    [ "${LOKI_TIER_HARNESS_POLICY:-0}" = "1" ] || return 0
    local tier="$1" _iters _council _heal
    # STUB - replace with Stage-2 ablation-derived numbers.
    case "$tier" in
        haiku|fast)          _iters=8; _council=true; _heal=1 ;;
        sonnet|development)  _iters=5; _council=true; _heal=1 ;;
        opus|planning)       _iters=3; _council=true; _heal=0 ;;
        *)                   return 0 ;;
    esac
    [ -z "${LOKI_MAX_ITERATIONS:-}" ] && export LOKI_MAX_ITERATIONS="$_iters"
    [ -z "${LOKI_COUNCIL_ENABLED:-}" ] && export LOKI_COUNCIL_ENABLED="$_council"
    [ -z "${LOKI_SELF_HEAL:-}" ] && export LOKI_SELF_HEAL="$_heal"
}

# Multi-project registry (v7.7.29): register this running project in the
# machine-global registry (~/.loki/dashboard/projects.json) so the dashboard
# can list and switch between projects running in different folders. Records
# the absolute path, pid, port, and status. Fully non-blocking and
# failure-swallowed: registry problems must never affect a build. Marked
# inactive again on exit via the trap below.
loki_register_running_project() {
    local _status="${1:-running}"
    [ -n "${LOKI_SKIP_PROJECT_REGISTRY:-}" ] && return 0
    command -v python3 >/dev/null 2>&1 || return 0
    local _skill="${LOKI_SKILL_DIR:-${PROJECT_DIR:-$SCRIPT_DIR/..}}"
    LOKI_REG_TARGET="$TARGET_DIR" LOKI_REG_SKILL="$_skill" \
    LOKI_REG_PID="$$" LOKI_REG_PORT="${LOKI_DASHBOARD_PORT:-57374}" \
    LOKI_REG_STATUS="$_status" \
    python3 - <<'PYREG' >/dev/null 2>&1 || true
import os, sys
sys.path.insert(0, os.environ.get("LOKI_REG_SKILL", "."))
try:
    from dashboard import registry
    target = os.path.abspath(os.environ["LOKI_REG_TARGET"])
    entry = registry.register_project(target)
    # Enrich with runtime fields the dashboard switcher uses.
    reg = registry._load_registry()
    pid = entry.get("id") or registry._generate_project_id(target)
    if pid in reg.get("projects", {}):
        reg["projects"][pid]["pid"] = int(os.environ.get("LOKI_REG_PID", "0") or 0)
        reg["projects"][pid]["port"] = int(os.environ.get("LOKI_REG_PORT", "57374") or 57374)
        reg["projects"][pid]["status"] = os.environ.get("LOKI_REG_STATUS", "running")
        registry._save_registry(reg)
except Exception:
    pass
PYREG
}

# v7.7.30: deliberate-exit teardown for the shared dashboard + registry.
# Marks THIS project (abspath of TARGET_DIR) stopped in the machine-global
# registry, then decides whether the shared standalone dashboard at
# ~/.loki/dashboard/dashboard.pid should be killed. The shared dashboard is
# killed ONLY when no other registered project still has a live pid (CLEAR);
# if any other project is still running (KEEP) it is left up. NEVER uses a
# blanket pkill and NEVER touches another folder's pids. Best-effort and
# failure-swallowed: teardown bookkeeping must never block a clean exit.
# Is this pid plausibly OUR dashboard, or a recycled number now naming something
# else? Fails OPEN (returns 0) whenever ps cannot tell us, so the only behavior
# change is refusing to kill a process that is positively identified as NOT a
# dashboard. See the call site for the measured stale-file evidence.
_loki_pid_looks_like_dashboard() {
    local _p="$1" _cmd
    case "$_p" in ''|*[!0-9]*) return 1 ;; esac
    [ "$_p" -gt 1 ] 2>/dev/null || return 1     # never signal pid 0/1
    _cmd="$(ps -o command= -p "$_p" 2>/dev/null)"
    [ -n "$_cmd" ] || return 0                   # ps unavailable: behave as before
    case "$_cmd" in
        *dashboard*|*uvicorn*|*loki*) return 0 ;;
    esac
    return 1
}

loki_mark_project_stopped_and_maybe_kill_shared_dashboard() {
    local _skill="${LOKI_SKILL_DIR:-${PROJECT_DIR:-$SCRIPT_DIR/..}}"
    local _shared_pidfile="${HOME}/.loki/dashboard/dashboard.pid"
    local _decision="CLEAR"

    if [ -z "${LOKI_SKIP_PROJECT_REGISTRY:-}" ] && command -v python3 >/dev/null 2>&1; then
        # (a) Mark this project stopped in the shared registry.
        LOKI_REG_TARGET="$TARGET_DIR" LOKI_REG_SKILL="$_skill" \
        python3 - <<'PYSTOP' >/dev/null 2>&1 || true
import os, sys
sys.path.insert(0, os.environ.get("LOKI_REG_SKILL", "."))
try:
    from dashboard import registry
    registry.mark_project_stopped(os.path.abspath(os.environ["LOKI_REG_TARGET"]))
except Exception:
    pass
PYSTOP
        # (b) CLEAR/KEEP check: any OTHER project still alive keeps the
        # shared dashboard up (this project is already marked stopped above).
        _decision="$(LOKI_REG_SKILL="$_skill" python3 - <<'PYCHECK' 2>/dev/null || echo CLEAR
import os, sys
sys.path.insert(0, os.environ.get("LOKI_REG_SKILL", "."))
try:
    from dashboard import registry
    alive = 0
    for p in registry.list_projects(include_inactive=True):
        pid = p.get("pid")
        if isinstance(pid, int) and pid > 0:
            try:
                os.kill(pid, 0)
                alive += 1
            except OSError:
                pass
    print("CLEAR" if alive == 0 else "KEEP")
except Exception:
    print("CLEAR")
PYCHECK
)"
    fi

    # (c) Only tear down the SHARED dashboard when no other project remains
    # (CLEAR), or when python3 was unavailable (legacy fallback: avoid leaking
    # the shared dashboard on minimal systems).
    if [ "$_decision" = "CLEAR" ]; then
        if [ -f "$_shared_pidfile" ]; then
            local _shared_pid
            _shared_pid=$(cat "$_shared_pidfile" 2>/dev/null)
            # Identity check before signalling. dashboard.pid is removed only on
            # the explicit stop paths (:16608, :16658, :17399) -- nothing covers
            # a crash, so the file outlives its dashboard. Measured in this very
            # checkout: .loki/dashboard/dashboard.pid held a DEAD 87992 with an
            # 8-day-old mtime. PIDs recycle, so a stale number eventually names
            # an unrelated LIVE process and this `kill -9` hits it.
            #
            # kill -0 is NOT sufficient: a recycled pid is alive by definition,
            # which is exactly the insufficiency the loki.pgid self-check had.
            # Mirrors _app_runner_pid_is_ours (app-runner.sh:242) and fails OPEN
            # the same way -- if `ps` says nothing we signal as before, so a
            # legitimate dashboard is never left running by this check.
            if [ -n "$_shared_pid" ] && _loki_pid_looks_like_dashboard "$_shared_pid"; then
                kill "$_shared_pid" 2>/dev/null || true
                sleep 0.5
                kill -9 "$_shared_pid" 2>/dev/null || true
            fi
            rm -f "$_shared_pidfile" 2>/dev/null || true
        fi
        # (d) Defense-in-depth: reclaim the dashboard port only in the CLEAR
        # case, so we never kill a shared dashboard another project owns.
        # BUT never kill a HEALTHY dashboard already serving on the port: that is
        # almost always the user's own live dashboard (open in their browser), and
        # killing it mid-use drops their session (ERR_CONNECTION_REFUSED, WS fail).
        # A healthy server is reusable by every project, so probe /api/status first
        # and only reclaim the port when nothing is answering (a genuinely stale
        # listener). Opt out of the probe with LOKI_DASHBOARD_FORCE_RECLAIM=1.
        if command -v lsof >/dev/null 2>&1; then
            local _dash_port="${DASHBOARD_PORT:-57374}"
            local _dash_alive=""
            if [ "${LOKI_DASHBOARD_FORCE_RECLAIM:-}" != "1" ] && command -v curl >/dev/null 2>&1; then
                _dash_alive=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 \
                    "http://127.0.0.1:${_dash_port}/api/status" 2>/dev/null || true)
            fi
            if [ "$_dash_alive" = "200" ]; then
                log_info "Reusing the healthy dashboard already serving on port ${_dash_port} (not reclaiming)."
            else
                lsof -ti:"${_dash_port}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
            fi
        fi
    fi
}
# Register as running now. We deliberately do NOT install an EXIT trap to
# flip it to idle: a top-level EXIT trap here would be clobbered by the
# lock-release EXIT trap installed later in the main path (and could
# interfere with it). Instead the dashboard determines live vs stale by
# checking whether the recorded pid is still alive (registry stores pid),
# which is robust even on hard kills where a trap would never fire.
loki_register_running_project running

# Complexity Tiers (Auto-Claude pattern)
# auto = detect from PRD/codebase, simple = 3 phases, standard = 6 phases, complex = 8 phases
COMPLEXITY_TIER=${LOKI_COMPLEXITY:-auto}
DETECTED_COMPLEXITY=""

# Multi-Provider Support (v5.0.0)
# Provider: auto-detected when unset; claude > cline > codex > aider > opencode.
#
# WHY NOT `:-claude`. That default was a hard failure for anyone who has Codex
# but not Claude: run.sh would select a provider that is not installed and die,
# even though auto_detect_provider() has existed in providers/loader.sh -- with
# the right priority order and its own passing test -- since v5.0.0. Nothing in
# production ever called it. Detection was built, tested, and never wired.
#
# An EXPLICIT choice still wins: this only fills an unset value, so
# LOKI_PROVIDER=codex and --provider codex are untouched.
_LOKI_PROVIDER_WAS_EXPLICIT=1
[ -z "${LOKI_PROVIDER:-}" ] && _LOKI_PROVIDER_WAS_EXPLICIT=0

# Source provider configuration
PROVIDERS_DIR="$PROJECT_DIR/providers"
if [ -f "$PROVIDERS_DIR/loader.sh" ]; then
    # shellcheck source=/dev/null
    source "$PROVIDERS_DIR/loader.sh"

    # Detect only when the operator expressed no preference. Sourcing the
    # loader first is required -- auto_detect_provider() is defined there.
    if [ "$_LOKI_PROVIDER_WAS_EXPLICIT" -eq 0 ]; then
        _detected="$(auto_detect_provider 2>/dev/null || true)"
        if [ -n "$_detected" ]; then
            LOKI_PROVIDER="$_detected"
            echo "[loki] provider: $LOKI_PROVIDER (auto-detected)" >&2
        else
            # Nothing installed. Keep the historical default so the existing
            # "not installed" error path reports claude, which is the actionable
            # message -- rather than an empty provider name.
            LOKI_PROVIDER=claude
        fi
    fi

    # Validate provider
    if ! validate_provider "$LOKI_PROVIDER"; then
        echo "ERROR: Unknown provider: $LOKI_PROVIDER" >&2
        echo "Supported providers: ${SUPPORTED_PROVIDERS[*]}" >&2
        exit 1
    fi

    # Load provider config
    if ! load_provider "$LOKI_PROVIDER"; then
        echo "ERROR: Failed to load provider config: $LOKI_PROVIDER" >&2
        exit 1
    fi

    # Save provider for future runs (if .loki dir exists or will be created)
    if [ -d ".loki/state" ] || mkdir -p ".loki/state" 2>/dev/null; then
        echo "$LOKI_PROVIDER" > ".loki/state/provider"
    fi
else
    # Fallback: Claude-only mode (backwards compatibility)
    PROVIDER_NAME="claude"
    PROVIDER_CLI="claude"
    PROVIDER_AUTONOMOUS_FLAG="--dangerously-skip-permissions"
    PROVIDER_PROMPT_FLAG="-p"
    PROVIDER_DEGRADED=false
    PROVIDER_DISPLAY_NAME="Claude Code"
    PROVIDER_HAS_PARALLEL=true
    PROVIDER_HAS_SUBAGENTS=true
    PROVIDER_HAS_TASK_TOOL=true
    PROVIDER_HAS_MCP=true
    PROVIDER_PROMPT_POSITIONAL=false
fi

# Track worktree PIDs for cleanup (requires bash 4+ for associative arrays)
# BASH_VERSION_MAJOR is defined at script startup
if [ "$BASH_VERSION_MAJOR" -ge 4 ] 2>/dev/null; then
    declare -A WORKTREE_PIDS=()
    declare -A WORKTREE_PATHS=()
    declare -A WORKTREE_BASE_SHAS=()
else
    # Fallback: parallel mode will check and warn
    # shellcheck disable=SC2178
    WORKTREE_PIDS=""
    # shellcheck disable=SC2178
    WORKTREE_PATHS=""
    # shellcheck disable=SC2178
    WORKTREE_BASE_SHAS=""
fi
# Track background install PIDs for cleanup (indexed array, works on all bash versions)
WORKTREE_INSTALL_PIDS=()

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
NC='\033[0m'

#===============================================================================
# Logging Functions
#===============================================================================

log_header() {
    echo ""
    echo -e "${BLUE}╔════════════════════════════════════════════════════════════════╗${NC}"
    echo -e "${BLUE}║${NC} ${BOLD}$1${NC}"
    echo -e "${BLUE}╚════════════════════════════════════════════════════════════════╝${NC}"
}

# VERBOSITY. LOKI_LOG_LEVEL is one of debug|info|warn|error (default info);
# LOKI_QUIET=1 is shorthand for warn.
#
# Implemented at these five functions rather than at 527 call sites, which is
# both the smaller diff and the only version that cannot drift -- a new
# log_info added next year is covered without anyone remembering to gate it.
#
# WHY THIS IS NEEDED DESPITE GOOD TTY DETECTION. The heavy decoration (HUD,
# completion card, start headline) is already `[ -t 1 ]`-guarded and vanishes
# off a TTY, so CI output was never the wall of banners it might have been.
# But 527 log_info/log_step calls are unguarded and print regardless, and a
# pipeline that wants only warnings had no way to ask. log_debug already
# honored LOKI_DEBUG; the other levels honored nothing.
#
# ERRORS ARE NEVER SUPPRESSED. `error` is the floor: the quietest setting still
# prints failures. A verbosity flag that can hide the reason a build failed is
# a footgun, not a feature.
_loki_log_threshold() {
    case "${LOKI_LOG_LEVEL:-$([ "${LOKI_QUIET:-0}" = "1" ] && echo warn || echo info)}" in
        debug) echo 0 ;;
        info)  echo 1 ;;
        warn)  echo 2 ;;
        error) echo 3 ;;
        *)     echo 1 ;;   # unrecognized value behaves as the default, never silences
    esac
}
_loki_log_enabled() { [ "$1" -ge "$(_loki_log_threshold)" ]; }

log_info() { _loki_log_enabled 1 && echo -e "${GREEN}[INFO]${NC} $*" || true; }
log_warn() { _loki_log_enabled 2 && echo -e "${YELLOW}[WARN]${NC} $*" || true; }
log_warning() { log_warn "$@"; }  # Alias for backwards compatibility
log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
log_step() { _loki_log_enabled 1 && echo -e "${CYAN}[STEP]${NC} $*" || true; }
log_debug() { [[ "${LOKI_DEBUG:-}" == "true" ]] && echo -e "${CYAN}[DEBUG]${NC} $*" >&2 || true; }

#===============================================================================
# Failure diagnosis helpers (T2.4 / T2.5 / T2.6)
#
# These make a failing or crashed build self-explanatory: a copy-pasteable
# "loki why" hint on any non-zero exit, a durable CLASSIFIED LAST_ERROR record
# on a failed iteration, and a best-effort terminal record on an untrapped
# death. Every one is best-effort and NEVER alters the build's exit code.
#===============================================================================

# _loki_surface_why_hint (T2.4): print the "loki why" hint to stderr and write
# it to .loki/NEXT_STEPS.txt. Called once from main() finalization when the run
# failed (result != 0). Best-effort: never crashes, never changes exit code.
_loki_surface_why_hint() {
    local loki_dir="${TARGET_DIR:-.}/.loki"
    local hint="For a plain-language diagnosis of what happened, run: loki why"
    printf '%s\n' "$hint" >&2 || true
    mkdir -p "$loki_dir" 2>/dev/null || true
    printf '%s\n' "$hint" > "$loki_dir/NEXT_STEPS.txt" 2>/dev/null || true
    return 0
}

# _loki_write_last_error (T2.5): write a durable, classified failure record to
# .loki/state/LAST_ERROR.json. Schema:
#   {
#     "iteration":   <int>,
#     "error_class": "provider_empty_output"|"build_timeout"|"rate_limited"
#                    |"auth_error"|"unknown",
#     "brief":       "<one honest sentence>",
#     "timestamp":   "<UTC ISO-8601>"
#   }
# This is what `loki why` (in autonomy/loki, NOT owned here) can later read.
# Built via python3 so the free-text brief can never break the JSON. Entirely
# best-effort: any failure is swallowed and the build is never crashed.
# Usage: _loki_write_last_error <iteration> <error_class> <brief>
_loki_write_last_error() {
    local iteration="${1:-0}"
    local error_class="${2:-unknown}"
    local brief="${3:-An iteration failed.}"
    local loki_dir="${TARGET_DIR:-.}/.loki"
    local state_dir="$loki_dir/state"
    mkdir -p "$state_dir" 2>/dev/null || true
    LOKI_LE_ITER="$iteration" \
    LOKI_LE_CLASS="$error_class" \
    LOKI_LE_BRIEF="$brief" \
    LOKI_LE_FILE="$state_dir/LAST_ERROR.json" \
    python3 -c "
import json, os, tempfile
try:
    rec = {
        'iteration': int(os.environ.get('LOKI_LE_ITER', '0') or 0),
        'error_class': os.environ.get('LOKI_LE_CLASS', 'unknown'),
        'brief': os.environ.get('LOKI_LE_BRIEF', ''),
        'timestamp': __import__('datetime').datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
    }
    target = os.environ['LOKI_LE_FILE']
    d = os.path.dirname(target)
    fd, tmp = tempfile.mkstemp(dir=d, suffix='.json')
    with os.fdopen(fd, 'w') as f:
        json.dump(rec, f)
    os.replace(tmp, target)
except Exception:
    pass
" 2>/dev/null || true
    return 0
}

# _loki_archive_last_error <last_error_json> <history_jsonl>
# LEARN-FORWARD: append a prior run's LAST_ERROR record to an append-only,
# bounded failure-history so the next run can detect a REPEATED failure signature
# (same error_class recurring) even though the single LAST_ERROR.json is cleared
# each run. No-op when there is no prior error. Bounded to the most recent 50
# entries so it never grows unbounded. Best-effort; never fails the run.
_loki_archive_last_error() {
    local src="${1:-}"
    local hist="${2:-}"
    [ -n "$src" ] && [ -n "$hist" ] || return 0
    [ -f "$src" ] || return 0
    LOKI_AE_SRC="$src" LOKI_AE_HIST="$hist" python3 -c "
import json, os, tempfile
src = os.environ['LOKI_AE_SRC']
hist = os.environ['LOKI_AE_HIST']
try:
    with open(src) as f:
        rec = json.load(f)
    if not isinstance(rec, dict):
        raise ValueError('bad record')
    lines = []
    if os.path.exists(hist):
        try:
            with open(hist) as hf:
                lines = [ln for ln in hf.read().splitlines() if ln.strip()]
        except Exception:
            lines = []
    lines.append(json.dumps(rec, separators=(',', ':')))
    lines = lines[-50:]
    d = os.path.dirname(hist) or '.'
    fd, tmp = tempfile.mkstemp(dir=d, suffix='.jsonl')
    with os.fdopen(fd, 'w') as f:
        f.write('\n'.join(lines) + '\n')
    os.replace(tmp, hist)
except Exception:
    pass
" 2>/dev/null || true
    return 0
}

# _loki_classify_iteration_error (T2.5 helper): map an iteration's signals to one
# of the LAST_ERROR error_class values. Conservative: only returns a specific
# class when a signal confidently supports it, else "unknown". Never fabricates
# build_timeout (no timeout signal is detected here, so it is reserved for a
# caller that has one). Echoes the class on stdout.
# Usage: _loki_classify_iteration_error <iter_output_file> <empty_output_flag 0|1>
_loki_classify_iteration_error() {
    local iter_output="${1:-}"
    local empty_flag="${2:-0}"
    if [ "$empty_flag" = "1" ]; then
        echo "provider_empty_output"
        return 0
    fi
    # Rate limit: reuse the same detector the retry path uses.
    if [ -n "$iter_output" ] && [ -f "$iter_output" ] && detect_rate_limit "$iter_output" 2>/dev/null | grep -qE '^[1-9]'; then
        echo "rate_limited"
        return 0
    fi
    # Auth error: a clear 401/403/unauthorized in the output tail.
    if [ -n "$iter_output" ] && [ -f "$iter_output" ]; then
        local _tail
        _tail="$(tail -n 40 "$iter_output" 2>/dev/null || true)"
        if printf '%s\n' "$_tail" | grep -qiE '(http[ /]?40[13]|status[: ]+40[13]|unauthorized|invalid api key|authentication[_ ]error|401 )' 2>/dev/null; then
            echo "auth_error"
            return 0
        fi
    fi
    echo "unknown"
    return 0
}

# _loki_build_self_heal_hint (SLICE 6c): route the PRIOR iteration's classified
# error signature into the NEXT iteration's prompt so the loop fixes forward.
# Reads .loki/state/LAST_ERROR.json (written by _loki_write_last_error on a
# failed iteration), emits a concise structured heal hint naming the error_class
# + brief on stdout, then ARCHIVES-then-CLEARS the record so the hint injects
# exactly ONCE (reuses the existing archive-then-clear pattern; it does not
# repeat forever). Best-effort: any failure emits nothing and never crashes the
# build.
#
# Gated by the caller on LOKI_SELF_HEAL (default 0 -- opt-in, stock runs
# unaffected). "unknown" is treated as NOT actionable (no specific class to
# target), so an unclassified failure produces no hint.
# Usage: _loki_build_self_heal_hint  (echoes hint or nothing)
_loki_build_self_heal_hint() {
    local loki_dir="${TARGET_DIR:-.}/.loki"
    local src="$loki_dir/state/LAST_ERROR.json"
    [ -f "$src" ] || return 0
    local hint
    hint="$(_LOKI_SH_SRC="$src" python3 -c "
import json, os
try:
    with open(os.environ['_LOKI_SH_SRC']) as f:
        rec = json.load(f)
    if not isinstance(rec, dict):
        raise SystemExit
    ec = str(rec.get('error_class', '') or '').strip()
    if ec in ('', 'unknown'):
        raise SystemExit
    brief = str(rec.get('brief', '') or '').strip()
    it = rec.get('iteration', '?')
    print('SELF_HEAL_HINT: the previous iteration (#%s) failed with error_class=%s. %s Address this specific failure FIRST before any new work.' % (it, ec, brief))
except SystemExit:
    pass
except Exception:
    pass
" 2>/dev/null || true)"
    [ -n "$hint" ] || return 0
    # Archive-then-clear so it injects once (LEARN-FORWARD: the lesson still
    # lands in failure-history.jsonl before the single record is removed).
    _loki_archive_last_error "$src" "$loki_dir/state/failure-history.jsonl" 2>/dev/null || true
    rm -f "$src" 2>/dev/null || true
    printf '%s' "$hint"
    return 0
}

# _loki_terminal_record (T2.6, SAFE SUBSET): on an untrapped exit where the
# persisted run status is still "running" (a true mid-provider-call crash on a
# trappable signal -- SIGTERM/SIGINT/SIGHUP via the lock-release trap), leave a
# best-effort, classified LAST_ERROR record so a post-crash `loki why` is not
# stale. Piggybacks the existing lock-release EXIT trap (see main()) -- it does
# NOT install a new broad EXIT trap.
#
# IMPORTANT (why this does NOT rewrite autonomy-state.json): the resume-detection
# block in this file (search "Durable resume") keys the ENT-2 pod-loss resume on
# prev_status == "running". Flipping the status to "exited" here would make a
# crashed-but-resumable build (LOKI_DURABLE_STATE=1) reset to iteration 0 on the
# next start, destroying durable progress. So this writes ONLY the LAST_ERROR
# side-record (which is what `loki why` reads) and deliberately leaves the
# status untouched. SIGKILL / power-loss are uncatchable (no trap fires); the
# ENT-2 durable-resume path covers those. Never alters the exit code; best-effort.
_loki_terminal_record() {
    local state_file
    state_file="$(_loki_state_file 2>/dev/null)" || return 0
    [ -n "$state_file" ] || return 0
    [ -f "$state_file" ] || return 0
    local _status
    _status="$(LOKI_TR_FILE="$state_file" python3 -c "
import json, os
try:
    print(json.load(open(os.environ['LOKI_TR_FILE'])).get('status','unknown'))
except Exception:
    print('unknown')
" 2>/dev/null || echo "unknown")"
    # Only act on a genuinely mid-flight "running" status. Any settled status
    # (council_approved, failed, exited, paused, ...) is left untouched.
    [ "$_status" = "running" ] || return 0
    # Leave a classified LAST_ERROR so `loki why` has something honest -- WITHOUT
    # touching autonomy-state.json (preserving the ENT-2 "running" resume signal).
    _loki_write_last_error "${ITERATION_COUNT:-0}" "unknown" \
        "The build process exited unexpectedly before finishing (possible crash or kill)." 2>/dev/null || true
    return 0
}

# _loki_write_rate_limit_signal (T2.7): write a best-effort
# .loki/signals/RATE_LIMITED file. This is FORWARD-LAID infrastructure: no
# consumer reads it yet (a future dashboard / external watcher could, to tell a
# normal provider rate-limit wait apart from a hang). The user-visible signal
# today is the log_info line at the wait site; this file is the durable record. Schema:
#   {"rate_limited": true, "wait_seconds": <int>, "reset_time": "<string>"}
# Built via python3 so the reset-time string can never break the JSON. Entirely
# best-effort: never crashes, never alters the build.
# Usage: _loki_write_rate_limit_signal <wait_seconds> <reset_time>
_loki_write_rate_limit_signal() {
    local wait_seconds="${1:-0}"
    local reset_time="${2:-}"
    local signals_dir="${TARGET_DIR:-.}/.loki/signals"
    mkdir -p "$signals_dir" 2>/dev/null || true
    LOKI_RL_WAIT="$wait_seconds" \
    LOKI_RL_RESET="$reset_time" \
    LOKI_RL_FILE="$signals_dir/RATE_LIMITED" \
    python3 -c "
import json, os, tempfile
try:
    w = os.environ.get('LOKI_RL_WAIT', '0')
    try:
        w = int(w)
    except Exception:
        w = 0
    rec = {'rate_limited': True, 'wait_seconds': w, 'reset_time': os.environ.get('LOKI_RL_RESET', '')}
    target = os.environ['LOKI_RL_FILE']
    d = os.path.dirname(target)
    fd, tmp = tempfile.mkstemp(dir=d, suffix='.json')
    with os.fdopen(fd, 'w') as f:
        json.dump(rec, f)
    os.replace(tmp, target)
except Exception:
    pass
" 2>/dev/null || true
    return 0
}

# Live Build HUD (v7.71.0): a single append-only per-iteration status line on the
# interactive TTY path. Pure additive stdout decoration -- never piped into any
# tee, so the dashboard agent.log and the stream-json parser are untouched. The
# whole function is structured so any internal failure still returns 0; it can
# never abort the iteration loop. Gated: TTY + not background + LOKI_HUD != 0.
# Usage: render_build_hud <iter> <phase> <duration_secs>
render_build_hud() {
    # Gate first: emit nothing unless interactive TTY, not --bg, and not opted out.
    # Inverted into an early return so the off-TTY path is byte-identical (no output).
    if ! { [ -t 1 ] && [ "${BACKGROUND_MODE:-false}" != "true" ] && [ "${LOKI_HUD:-1}" != "0" ]; }; then
        return 0
    fi

    local _iter="${1:-0}" _phase="${2:-?}" _dur="${3:-0}"
    [ -z "$_phase" ] && _phase="?"
    # Sanitize numerics so set -u arithmetic/format below can never error out.
    case "$_dur" in (*[!0-9]*|'') _dur=0 ;; esac
    local _max="${MAX_ITERATIONS:-0}"
    case "$_max" in (*[!0-9]*|'') _max=0 ;; esac

    # Field list: each field is appended only when its data is present. Missing
    # data => the field is omitted entirely (never a fabricated value).
    local _fields="$_phase"
    _fields="${_fields} | iter ${_iter}/${_max}"

    # Cost from the context tracker totals (the same field the dashboard shows).
    # NOTE: tracking.json totals accumulate across runs in the same dir (not reset
    # on a fresh `loki start`), so this is cumulative cost for the project dir, not
    # strictly this single run. Labeled plainly "$" / "cost" to avoid overclaiming.
    # OMIT entirely when empty/missing/non-Claude.
    local _cost=""
    if command -v python3 >/dev/null 2>&1; then
        _cost="$(python3 -c "
import json
try:
    t = json.load(open('.loki/context/tracking.json'))
    c = t.get('totals', {}).get('total_cost_usd', 0)
    c = float(c)
    if c > 0:
        print('%.2f' % c)
except Exception:
    pass
" 2>/dev/null || true)"
    fi
    [ -n "$_cost" ] && _fields="${_fields} | \$${_cost}"

    # Files changed (+ins/-del and file count) vs the run start SHA. Reuse the
    # build_completion_summary diff approach incl. the .loki/.git exclude pathspec.
    # OMIT when no start sha, not a git repo, or no diff data.
    local _start_sha="${_LOKI_RUN_START_SHA:-}"
    if [ -n "$_start_sha" ]; then
        local _shortstat _ins _del _files
        _shortstat="$( (cd "${TARGET_DIR:-.}" && git diff --shortstat "${_start_sha}..HEAD" -- . ':(exclude).loki/' ':(exclude).git/' ':(exclude)**/.loki/**') 2>/dev/null || true )"
        if [ -n "$_shortstat" ]; then
            _files="$(printf '%s\n' "$_shortstat" | grep -oE '[0-9]+ file' | grep -oE '[0-9]+' | head -1 2>/dev/null || true)"
            _ins="$(printf '%s\n' "$_shortstat" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' | head -1 2>/dev/null || true)"
            _del="$(printf '%s\n' "$_shortstat" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' | head -1 2>/dev/null || true)"
            [ -z "$_files" ] && _files=0
            [ -z "$_ins" ] && _ins=0
            [ -z "$_del" ] && _del=0
            _fields="${_fields} | +${_ins}/-${_del} (${_files} files)"
        fi
    fi

    # Per-iteration time (the duration arg). Labeled "took" (not "iter") so it
    # does not collide with the "iter N/max" iteration-count field above.
    _fields="${_fields} | took $(_hud_fmt_secs "$_dur")"

    # Elapsed time for the whole run, from the once-captured run-start epoch.
    local _start_epoch="${_LOKI_RUN_START_EPOCH:-}"
    case "$_start_epoch" in (*[!0-9]*|'') _start_epoch="" ;; esac
    if [ -n "$_start_epoch" ]; then
        local _now _elapsed
        _now="$(date +%s 2>/dev/null || true)"
        case "$_now" in (*[!0-9]*|'') _now="" ;; esac
        if [ -n "$_now" ] && [ "$_now" -ge "$_start_epoch" ] 2>/dev/null; then
            _elapsed=$(( _now - _start_epoch ))
            _fields="${_fields} | elapsed $(_hud_fmt_secs "$_elapsed")"
        fi
    fi

    # ETA (PO lock #1): omit by default. Only a SMALL user-set LOKI_MAX_ITERATIONS
    # (not the 1000 default) yields a meaningful target, so that is the single ETA
    # trigger here. A LOKI_BUDGET_LIMIT cap is also an allowed trigger per the
    # plan, but a budget-derived ETA needs cost-rate math that is easy to get
    # wrong; per "keep it simple and safe, when unsure omit", budget ETA is left
    # out rather than rendered approximately.
    local _lmi="${LOKI_MAX_ITERATIONS:-}"
    case "$_lmi" in (*[!0-9]*|'') _lmi="" ;; esac
    if [ -n "$_lmi" ] && [ "$_lmi" -gt 0 ] 2>/dev/null && [ "$_lmi" -lt 1000 ] 2>/dev/null \
       && [ "$_iter" -gt 0 ] 2>/dev/null && [ "$_iter" -lt "$_lmi" ] 2>/dev/null \
       && [ -n "$_start_epoch" ]; then
        local _now2 _el2 _per _remain_iters _eta
        _now2="$(date +%s 2>/dev/null || true)"
        case "$_now2" in (*[!0-9]*|'') _now2="" ;; esac
        if [ -n "$_now2" ] && [ "$_now2" -ge "$_start_epoch" ] 2>/dev/null; then
            _el2=$(( _now2 - _start_epoch ))
            _per=$(( _el2 / _iter ))
            _remain_iters=$(( _lmi - _iter ))
            _eta=$(( _per * _remain_iters ))
            [ "$_eta" -gt 0 ] 2>/dev/null && _fields="${_fields} | eta ~$(_hud_fmt_secs "$_eta")"
        fi
    fi

    echo -e "${CYAN}[HUD]${NC} ${_fields}"
    return 0
}

# Format a whole-seconds integer as a compact duration: 37s, 2m11s, 1h03m.
# Best-effort and set -u safe; any odd input degrades to "0s".
_hud_fmt_secs() {
    local _s="${1:-0}"
    case "$_s" in (*[!0-9]*|'') _s=0 ;; esac
    if [ "$_s" -lt 60 ] 2>/dev/null; then
        printf '%ds' "$_s"
    elif [ "$_s" -lt 3600 ] 2>/dev/null; then
        printf '%dm%02ds' "$(( _s / 60 ))" "$(( _s % 60 ))"
    else
        printf '%dh%02dm' "$(( _s / 3600 ))" "$(( (_s % 3600) / 60 ))"
    fi
    return 0
}

#===============================================================================
# Process Registry (PID Supervisor)
# Central registry of all spawned child processes for reliable cleanup
#===============================================================================

PID_REGISTRY_DIR=""

# Initialize the PID registry directory
init_pid_registry() {
    PID_REGISTRY_DIR="${TARGET_DIR:-.}/.loki/pids"
    mkdir -p "$PID_REGISTRY_DIR"
}

# Parse a field from a JSON registry entry (python3 with shell fallback)
# Usage: _parse_json_field <file> <field>
_parse_json_field() {
    local file="$1" field="$2"
    if command -v python3 >/dev/null 2>&1; then
        python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get(sys.argv[2],''))" "$file" "$field" 2>/dev/null
    else
        # Shell fallback (no python3): extract value for simple flat JSON. Handles
        # BOTH quoted string values ("kind":"wrapper") and bare numeric values
        # ("ppid":206). The prior impl stripped the leading quote's content to empty
        # for string fields -- broke "kind" parsing on python3-less hosts, which
        # would drop wrapper entries into the legacy child path (loki-mode #92).
        sed 's/.*"'"$field"'":[[:space:]]*//' "$file" 2>/dev/null \
            | sed 's/^"//' \
            | sed 's/[",}].*//' | head -1
    fi
}

# Register a spawned process in the central registry
# Usage: register_pid <pid> <label> [<extra_info>]
# Example: register_pid $! "dashboard" "port=57374"
register_pid() {
    local pid="$1"
    # Sanitize label and extra for JSON safety (escape backslash first, then double-quote, strip newlines)
    local label="${2//\\/\\\\}"
    label="${label//\"/\\\"}"
    label="$(printf '%s' "$label" | tr -d '\n\r')"
    local extra="${3:-}"
    extra="${extra//\\/\\\\}"
    extra="${extra//\"/\\\"}"
    extra="$(printf '%s' "$extra" | tr -d '\n\r')"
    [ -z "$PID_REGISTRY_DIR" ] && init_pid_registry
    local entry_file="$PID_REGISTRY_DIR/${pid}.json"
    cat > "$entry_file" << EOF
{"pid":$pid,"label":"$label","started":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","ppid":$$,"extra":"$extra"}
EOF
}

# Unregister a process from the registry (called on clean shutdown)
# Usage: unregister_pid <pid>
unregister_pid() {
    local pid="$1"
    [ -z "$PID_REGISTRY_DIR" ] && init_pid_registry
    rm -f "$PID_REGISTRY_DIR/${pid}.json" 2>/dev/null
}

# Kill a registered process with SIGTERM -> wait -> SIGKILL escalation
# Usage: kill_registered_pid <pid>
kill_registered_pid() {
    local pid="$1"
    if kill -0 "$pid" 2>/dev/null; then
        kill "$pid" 2>/dev/null || true
        # Wait up to 2 seconds for graceful exit
        local waited=0
        while [ $waited -lt 4 ] && kill -0 "$pid" 2>/dev/null; do
            sleep 0.5
            waited=$((waited + 1))
        done
        # Escalate to SIGKILL if still alive
        if kill -0 "$pid" 2>/dev/null; then
            kill -9 "$pid" 2>/dev/null || true
        fi
    fi
    unregister_pid "$pid"
}

# Register the CURRENTLY-RUNNING loki-run wrapper itself in the registry.
# The wrapper registers its children but historically never itself, so a wrapper
# whose launching session dies is never reaped (loki-mode #92). We record the
# LAUNCHER's mortal pid (NOT $$ -- $$ is the wrapper's own pid, which would make
# the parent-death check always succeed and the reaper INERT; NOT $PPID either --
# in the detached setsid/nohup path bash caches getppid()==1 at exec, and kill -0 1
# is always true -> also inert). The launcher exports LOKI_LAUNCHER_PID=$$ at each
# backgrounding site; that launcher shell exits immediately after backgrounding, so
# the recorded ppid genuinely dies and the parent-death precondition CAN fire. For
# the foreground path LOKI_LAUNCHER_PID is unset -> $PPID (the user's shell) is the
# correct mortal fallback. Reap-vs-spare is then decided by the LIVENESS predicate,
# not by parentage alone. Uses a "kind":"wrapper" tag so cleanup_orphan_pids routes
# only wrapper entries through the predicate and keeps child entries byte-identical.
register_self_wrapper() {
    [ -z "$PID_REGISTRY_DIR" ] && init_pid_registry
    local launcher_ppid="${LOKI_LAUNCHER_PID:-$PPID}"
    case "$launcher_ppid" in ''|*[!0-9]*) launcher_ppid="$PPID" ;; esac
    local entry_file="$PID_REGISTRY_DIR/$$.json"
    cat > "$entry_file" << EOF
{"pid":$$,"label":"loki-wrapper","started":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","ppid":$launcher_ppid,"kind":"wrapper","extra":""}
EOF
}

# Pure, side-effect-free reap predicate (loki-mode #92). ALL inputs are
# pre-computed scalars (0/1 booleans or integer ms); NO stat/kill/pgrep inside so
# it is hermetically unit-testable. Prints "1" (reap) or "0" (spare); returns 0.
# Reap ONLY when: parent dead AND no live engine child AND idle past the budget.
# Mirrors the SaaS BFF sweepStuckBuilds and the #91 worker "no-activity != dead"
# symmetry: a genuinely-detached run that keeps writing .loki activity is spared.
#   parent_alive:     1 if recorded ppid is still alive, else 0
#   last_activity_ms: epoch-ms of most recent .loki activity (0 if none found)
#   now_ms:           current epoch-ms
#   idle_budget_ms:   generous idle budget (default 900000 = 15 min)
#   has_live_child:   1 if wrapper has a live ENGINE child (claude/node), else 0
shouldReapOrphan() {
    local parent_alive="$1" last_activity_ms="$2" now_ms="$3" idle_budget_ms="$4" has_live_child="$5"
    if [ "$parent_alive" = "0" ] && [ "$has_live_child" = "0" ] \
       && [ $(( now_ms - last_activity_ms )) -ge "$idle_budget_ms" ]; then
        echo 1
    else
        echo 0
    fi
}

# Most-recent .loki activity as epoch-ms across events.jsonl, signals/*,
# autonomy-state.json. Cross-platform stat (macOS -f %m / Linux -c %Y). On a stat
# PARSE FAILURE we default to now_ms (SPARE), never 0 -- a wrong flag must not make a
# live run look maximally idle and get reaped. "No activity files exist" legitimately
# yields 0 (reap-eligible); that is distinct from a stat error.
_loki_last_activity_ms() {
    local loki_dir="${TARGET_DIR:-.}/.loki"
    local newest=0 f mt stat_ok=0
    local candidates=()
    [ -f "$loki_dir/events.jsonl" ] && candidates+=("$loki_dir/events.jsonl")
    [ -f "$loki_dir/autonomy-state.json" ] && candidates+=("$loki_dir/autonomy-state.json")
    if [ -d "$loki_dir/signals" ]; then
        for f in "$loki_dir/signals"/*; do [ -e "$f" ] && candidates+=("$f"); done
    fi
    for f in "${candidates[@]:-}"; do
        [ -e "$f" ] || continue
        mt=$(stat -f %m "$f" 2>/dev/null) || mt=""
        [ -z "$mt" ] && { mt=$(stat -c %Y "$f" 2>/dev/null) || mt=""; }
        case "$mt" in ''|*[!0-9]*) continue ;; esac
        stat_ok=1
        [ "$mt" -gt "$newest" ] && newest="$mt"
    done
    # candidates present but every stat failed -> parse failure -> spare (now_ms)
    if [ "${#candidates[@]}" -gt 0 ] && [ "$stat_ok" = "0" ]; then
        echo "$(( $(date +%s) * 1000 ))"
        return 0
    fi
    echo "$(( newest * 1000 ))"
}

# Count LIVE ENGINE children of a wrapper (claude/node), EXCLUDING sleep and the
# status/resource monitors. The observed 3h orphan had only sleep children, so a
# naive `pgrep -P` (any child) would falsely spare it; the comm filter is what makes
# the reaper actually fire on the real orphan shape. Prints 1 (has engine child) or 0.
_loki_has_live_engine_child() {
    local wrapper_pid="$1" c cc
    for c in $(pgrep -P "$wrapper_pid" 2>/dev/null); do
        cc=$(ps -o comm= -p "$c" 2>/dev/null)
        case "$cc" in *claude*|*node*) echo 1; return 0 ;; esac
    done
    echo 0
}

# Scan registry for orphaned processes and kill them
# Called on startup and by `loki cleanup`
# Returns: number of orphans killed
cleanup_orphan_pids() {
    [ -z "$PID_REGISTRY_DIR" ] && init_pid_registry
    local orphan_count=0

    if [ ! -d "$PID_REGISTRY_DIR" ]; then
        echo "0"
        return 0
    fi

    for entry_file in "$PID_REGISTRY_DIR"/*.json; do
        [ -f "$entry_file" ] || continue
        local pid
        pid=$(basename "$entry_file" .json)

        # Skip non-numeric filenames
        case "$pid" in
            ''|*[!0-9]*) continue ;;
        esac

        # Never reap the currently-running wrapper itself (loki-mode #92 self-skip).
        [ "$pid" = "$$" ] && continue

        if kill -0 "$pid" 2>/dev/null; then
            # Process is alive -- check if its parent session is dead
            local ppid_val=""
            ppid_val=$(_parse_json_field "$entry_file" "ppid") || true

            # Validate ppid_val is numeric before using with kill
            case "$ppid_val" in ''|*[!0-9]*) ppid_val="" ;; esac

            # Route WRAPPER entries through the liveness predicate; CHILD entries
            # (no "kind" field) keep the exact parent-death-only behavior (#92).
            local kind=""
            kind=$(_parse_json_field "$entry_file" "kind") || true

            if [ "$kind" = "wrapper" ]; then
                # Wrapper: reap only if orphaned AND idle-past-budget AND no live
                # engine child. Compute the impure inputs here (predicate stays pure).
                if [ -n "$ppid_val" ]; then
                    local parent_alive=0
                    kill -0 "$ppid_val" 2>/dev/null && parent_alive=1
                    local last_activity_ms now_ms has_live_child idle_budget_ms
                    last_activity_ms=$(_loki_last_activity_ms)
                    now_ms=$(( $(date +%s) * 1000 ))
                    has_live_child=$(_loki_has_live_engine_child "$pid")
                    idle_budget_ms="${LOKI_WRAPPER_IDLE_BUDGET_MS:-900000}"
                    if [ "$(shouldReapOrphan "$parent_alive" "$last_activity_ms" "$now_ms" "$idle_budget_ms" "$has_live_child")" = "1" ]; then
                        log_warn "Reaping idle orphaned loki wrapper PID=$pid (parent $ppid_val dead, idle >=$((idle_budget_ms/60000))m, no engine child)" >&2
                        kill_registered_pid "$pid"
                        orphan_count=$((orphan_count + 1))
                    fi
                fi
            elif [ -n "$ppid_val" ] && [ "$ppid_val" != "$$" ]; then
                if ! kill -0 "$ppid_val" 2>/dev/null; then
                    # Parent is dead -- this is an orphan
                    local label=""
                    label=$(_parse_json_field "$entry_file" "label") || label="unknown"
                    log_warn "Killing orphaned process: PID=$pid label=$label (parent $ppid_val is dead)" >&2
                    kill_registered_pid "$pid"
                    orphan_count=$((orphan_count + 1))
                fi
            fi
        else
            # Process is dead -- clean up stale registry entry
            rm -f "$entry_file" 2>/dev/null
        fi
    done

    echo "$orphan_count"
}

# Kill ALL registered processes (used during full shutdown)
kill_all_registered() {
    [ -z "$PID_REGISTRY_DIR" ] && init_pid_registry

    if [ ! -d "$PID_REGISTRY_DIR" ]; then
        return 0
    fi

    for entry_file in "$PID_REGISTRY_DIR"/*.json; do
        [ -f "$entry_file" ] || continue
        local pid
        pid=$(basename "$entry_file" .json)
        case "$pid" in
            ''|*[!0-9]*) continue ;;
        esac
        # Never SIGKILL the running wrapper itself mid-shutdown (loki-mode #92):
        # it now self-registers, so it would otherwise appear in this sweep.
        [ "$pid" = "$$" ] && continue
        kill_registered_pid "$pid"
    done
}

#===============================================================================
# Event Emission (Dashboard Integration)
# Writes events to .loki/events.jsonl for dashboard consumption
#===============================================================================

emit_event() {
    local event_type="$1"
    shift
    local event_data="$*"
    local events_file=".loki/events.jsonl"
    local timestamp
    timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)

    mkdir -p .loki

    # Build JSON event with proper escaping
    local json_event
    json_event=$(python3 -c "
import json, sys
event = {
    'timestamp': sys.argv[1],
    'type': sys.argv[2],
    'data': sys.argv[3]
}
print(json.dumps(event))
" "$timestamp" "$event_type" "$event_data" 2>/dev/null)

    # Fallback to simple JSON if python fails
    if [ -z "$json_event" ]; then
        # Escape quotes and special chars for JSON
        local escaped_data
        escaped_data=$(printf '%s' "$event_data" | sed 's/\\/\\\\/g; s/"/\\"/g; s/	/\\t/g' | tr -d '\n')
        json_event="{\"timestamp\":\"$timestamp\",\"type\":\"$event_type\",\"data\":\"$escaped_data\"}"
    fi

    echo "$json_event" >> "$events_file"

    # Also log for debugging
    log_debug "Event: $event_type - $event_data"
}

# Emit structured event with key-value pairs
emit_event_json() {
    local event_type="$1"
    shift
    local events_file=".loki/events.jsonl"
    local timestamp
    timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)

    mkdir -p .loki

    # Build JSON from remaining args as key=value pairs
    local json_data="{"
    local first=true
    while [ $# -gt 0 ]; do
        local key="${1%%=*}"
        local value="${1#*=}"
        if [ "$first" = true ]; then
            first=false
        else
            json_data+=","
        fi
        # Quote string values, leave numbers/booleans/floats as-is
        # BUG-NEW-004: Also match floats (e.g., cost=3.14) not just integers
        if [[ "$value" =~ ^[0-9]+\.?[0-9]*$ ]] || [[ "$value" =~ ^(true|false|null)$ ]]; then
            json_data+="\"$key\":$value"
        else
            # Escape backslashes, quotes, and special chars in value
            value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g; s/	/\\t/g')
            json_data+="\"$key\":\"$value\""
        fi
        shift
    done
    json_data+="}"

    local json_event="{\"timestamp\":\"$timestamp\",\"type\":\"$event_type\",\"data\":$json_data}"
    echo "$json_event" >> "$events_file"

    log_debug "Event: $event_type - $json_data"
}

# Per-stage timeline event (v7.91.x). Emits one stage_complete record per
# quality-gate / build stage so the SaaS timeline can show where build time
# goes within an iteration (the provider call itself is already bracketed by
# iteration_start/iteration_complete). This is purely ADDITIVE: it appends one
# event line via emit_event_json and never changes a gate verdict, gate exit
# code, or control flow. Duration is computed by the caller (whole-second
# resolution, sufficient for multi-second gates) and carried on the event so
# the SaaS does not have to infer it from coarse ISO timestamps.
#   emit_stage_complete <stage_name> <status: pass|fail|not_run> <start_epoch_seconds>
# Event shape:
#   {type:"stage_complete", timestamp, data:{stage, status, duration_s, iteration}}
# Best-effort: any failure is swallowed so it can never block the build.
emit_stage_complete() {
    local stage="$1"
    local status="$2"
    local t0="$3"
    local now dur
    now=$(date +%s 2>/dev/null) || return 0
    [ -n "$t0" ] || return 0
    dur=$(( now - t0 ))
    [ "$dur" -ge 0 ] 2>/dev/null || dur=0
    emit_event_json "stage_complete" \
        "stage=$stage" \
        "status=$status" \
        "duration_s=$dur" \
        "iteration=${ITERATION_COUNT:-0}" 2>/dev/null || true
}

# Trust-layer metrics event writer (benchmark program section 3). Appends one
# durable record per trust event to .loki/metrics/trust-events.jsonl via the
# Python writer (single source of truth for the JSONL schema). This is ADDITIVE
# and purely a side effect: it writes nothing to stdout, ignores all errors, and
# never alters control flow or any caller's return value. The single-state
# control files (evidence-block.json, gate-failure-count.json) are untouched;
# this log exists because those files are erased on the successful-run path,
# losing exactly the self-correction events the trust metrics publish.
# Resolve a stable, UNIQUE-PER-RUN id for the trust event log. The cross-run
# denominators (block rate, gate distribution) require ids that are distinct per
# run. A persisted per-run file is the source of truth, NOT LOKI_SESSION_ID:
#  - On `loki start ./prd.md`, LOKI_SESSION_ID is unset entirely.
#  - On `loki run <issue>`, LOKI_SESSION_ID is the issue NUMBER, which is stable
#    across re-runs by design (so `loki stop <n>` works); using it would merge
#    every re-run of the same issue into one bucket and skew the rates.
# So a fresh run always MINTS a new unique id into .loki/state/trust-run-id, and
# every later event in that run reads it back. LOKI_SESSION_ID is only a
# last-resort fallback when no minted file exists (e.g. an event fired before
# any run_start, which the aggregator then treats as un-instrumented anyway).
# Events never join to proof.json (Metrics 1-3 are events-only, Metric 4 is
# proofs-only), so intra-log uniqueness is the only requirement.
# Usage: _loki_trust_run_id [--new]
_loki_trust_run_id() {
    local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}/.loki}"
    local id_file="$loki_dir/state/trust-run-id"
    if [ "${1:-}" = "--new" ]; then
        # Fresh run: mint a new unique id (epoch + pid + short random) and
        # persist it as the source of truth for this run's events.
        local new_id
        new_id="run-$(date -u +%Y%m%d%H%M%S)-$$-${RANDOM:-0}"
        mkdir -p "$loki_dir/state" 2>/dev/null || true
        printf '%s' "$new_id" > "$id_file" 2>/dev/null || true
        printf '%s' "$new_id"
        return 0
    fi
    # Read path: the minted per-run file wins over LOKI_SESSION_ID so a resume
    # in a separate process (no exported LOKI_TRUST_RUN_ID) still resolves to
    # the same run, and a stable issue-number session id never collapses re-runs.
    if [ -s "$id_file" ]; then
        cat "$id_file" 2>/dev/null || true
        return 0
    fi
    if [ -n "${LOKI_SESSION_ID:-}" ]; then
        printf '%s' "$LOKI_SESSION_ID"
        return 0
    fi
    # No persisted id and no session id: empty -> writer records "unknown".
    printf '%s' ""
}

# _advance_current_phase: atomically advance currentPhase in orchestrator.json.
# This is the single source of truth for the build dashboard and the BFF
# reconciliation gate (isTerminalPhase). The engine initialises it to "BOOTSTRAP"
# at session start; call this to advance through the SDLC lifecycle phases.
# Args: $1 = the new phase value (REASONING, BUILDING, VERIFYING, COMPLETED, etc.)
_advance_current_phase() {
    local new_phase="${1:?phase required}"
    local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}/.loki}"
    local orch="$loki_dir/state/orchestrator.json"
    [ -f "$orch" ] || return 0
    # Values are passed via argv (not interpolated into the source) so a phase or
    # path containing quotes can never break or inject into the python.
    python3 -c "
import json, sys
f, phase = sys.argv[1], sys.argv[2]
try:
    d = json.load(open(f))
    d['currentPhase'] = phase
    json.dump(d, open(f, 'w'))
except (json.JSONDecodeError, OSError):
    pass
" "$orch" "$new_phase" 2>/dev/null || true
}

# Usage: record_trust_event_bash <event_type> [key=value ...]
# Pass LOKI_TRUST_RUN_ID in the environment to override the resolved id (the
# run_start site sets it to the freshly minted id so the first event matches).
record_trust_event_bash() {
    local event_type="$1"
    shift || true
    local tm_mod="$SCRIPT_DIR/lib/trust_metrics.py"
    [ -f "$tm_mod" ] || return 0
    command -v python3 >/dev/null 2>&1 || return 0
    local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}/.loki}"
    local run_id="${LOKI_TRUST_RUN_ID:-$(_loki_trust_run_id)}"
    # Pass kv pairs as argv so Python parses (no shell JSON building). All
    # values stay strings except where the reader coerces (iteration -> int).
    _TM_LOKI_DIR="$loki_dir" \
    _TM_MOD_PATH="$tm_mod" \
    _TM_EVENT_TYPE="$event_type" \
    _TM_RUN_ID="$run_id" \
    _TM_ITERATION="${ITERATION_COUNT:-0}" \
    python3 - "$@" <<'TRUST_EVENT_PY' >/dev/null 2>&1 || true
import os, sys, importlib.util
spec = importlib.util.spec_from_file_location("trust_metrics", os.environ["_TM_MOD_PATH"])
tm = importlib.util.module_from_spec(spec)
spec.loader.exec_module(tm)
fields = {}
for arg in sys.argv[1:]:
    if "=" in arg:
        k, v = arg.split("=", 1)
        fields[k] = v
tm.record_trust_event(
    os.environ["_TM_LOKI_DIR"],
    os.environ["_TM_EVENT_TYPE"],
    run_id=os.environ.get("_TM_RUN_ID", "") or None,
    iteration=os.environ.get("_TM_ITERATION", "0"),
    **fields,
)
TRUST_EVENT_PY
}

# v7.0.2: Bash helper to emit a managed-agents event to the dashboard's
# managed event log (.loki/managed/events.ndjson). Mirrors the Python
# emit_managed_event helper so bash callers can land events in the same
# stream the dashboard reads. Schema: {ts, type, payload}.
emit_managed_event_bash() {
    local event_type="$1"
    shift
    local target_dir="${TARGET_DIR:-.}"
    local events_file="$target_dir/.loki/managed/events.ndjson"
    mkdir -p "$target_dir/.loki/managed"

    local timestamp
    timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)

    # Build payload JSON from key=value args (same convention as emit_event_json)
    local payload="{"
    local first=true
    while [ $# -gt 0 ]; do
        local key="${1%%=*}"
        local value="${1#*=}"
        if [ "$first" = true ]; then first=false; else payload+=","; fi
        if [[ "$value" =~ ^[0-9]+\.?[0-9]*$ ]] || [[ "$value" =~ ^(true|false|null)$ ]]; then
            payload+="\"$key\":$value"
        else
            value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g; s/	/\\t/g')
            payload+="\"$key\":\"$value\""
        fi
        shift
    done
    payload+="}"

    local json_event="{\"ts\":\"$timestamp\",\"type\":\"$event_type\",\"payload\":$payload}"
    echo "$json_event" >> "$events_file"
}

# Emit event to .loki/events/pending/ directory (for event bus subscribers)
# Used by OTEL bridge and other enterprise services that watch the pending dir.
# Usage: emit_event_pending <type> [key=value ...]
emit_event_pending() {
    local event_type="$1"
    shift
    local events_dir=".loki/events/pending"
    mkdir -p "$events_dir"

    local timestamp
    timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
    local event_id
    event_id=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n')

    # Build payload JSON from key=value args
    local payload="{"
    local first=true
    while [ $# -gt 0 ]; do
        local key="${1%%=*}"
        local value="${1#*=}"
        if [ "$first" = true ]; then
            first=false
        else
            payload+=","
        fi
        value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g; s/	/\\t/g')
        payload+="\"$key\":\"$value\""
        shift
    done
    payload+="}"

    local event_file="$events_dir/${timestamp//:/-}_${event_id}.json"
    printf '{"id":"%s","type":"%s","timestamp":"%s","payload":%s,"version":"1.0"}\n' \
        "$event_id" "$event_type" "$timestamp" "$payload" > "${event_file}.tmp" && mv "${event_file}.tmp" "$event_file"
}

#===============================================================================
# Enterprise Process Manager
# Manages background services: OTEL bridge, audit subscriber, integration sync
#===============================================================================

# Start enterprise background services
start_enterprise_services() {
    loki_background_services_enabled || return 0
    log_info "Starting enterprise services..."

    # OTEL Bridge (requires LOKI_OTEL_ENDPOINT and node)
    if [ -n "${LOKI_OTEL_ENDPOINT:-}" ] && command -v node >/dev/null 2>&1; then
        LOKI_TRACE_ID=$(node -e "console.log(require('crypto').randomBytes(16).toString('hex'))")
        export LOKI_TRACE_ID
        local bridge_script="${SCRIPT_DIR}/../src/observability/otel-bridge.js"
        if [ -f "$bridge_script" ]; then
            LOKI_OTEL_ENDPOINT="$LOKI_OTEL_ENDPOINT" \
            LOKI_TRACE_ID="$LOKI_TRACE_ID" \
            LOKI_DIR=".loki" \
            node "$bridge_script" &
            ENTERPRISE_PIDS+=($!)
            log_info "Started OTEL bridge (PID: ${ENTERPRISE_PIDS[-1]})"
        else
            log_warn "OTEL bridge script not found: $bridge_script"
        fi
    fi

    # Audit subscriber (P0.5-3)
    if [ "${LOKI_AUDIT_ENABLED:-false}" = "true" ]; then
        if command -v node >/dev/null 2>&1; then
            node "${SCRIPT_DIR}/../src/audit/subscriber.js" &
            ENTERPRISE_PIDS+=($!)
            log_info "Started audit subscriber (PID: ${ENTERPRISE_PIDS[-1]})"
        fi
    fi
}

# Stop all enterprise background services
stop_enterprise_services() {
    if [ ${#ENTERPRISE_PIDS[@]} -eq 0 ]; then
        return
    fi

    log_info "Stopping enterprise services (${#ENTERPRISE_PIDS[@]} processes)..."
    for pid in "${ENTERPRISE_PIDS[@]}"; do
        if kill -0 "$pid" 2>/dev/null; then
            kill -TERM "$pid" 2>/dev/null || true
            # Wait briefly for graceful shutdown
            local wait_count=0
            while kill -0 "$pid" 2>/dev/null && [ $wait_count -lt 10 ]; do
                sleep 0.1
                ((wait_count++))
            done
            # Force kill if still running
            if kill -0 "$pid" 2>/dev/null; then
                kill -9 "$pid" 2>/dev/null || true
                log_warn "Force-killed enterprise service PID $pid"
            else
                log_info "Enterprise service PID $pid stopped gracefully"
            fi
        fi
    done
    ENTERPRISE_PIDS=()
}

# Policy engine check wrapper (P0.5-2)
# Evaluates policies via Node.js CLI and returns appropriate exit codes.
# Exit 0 = ALLOW, Exit 1 = DENY, Exit 2 = REQUIRE_APPROVAL (logged but allowed for now)
check_policy() {
    local enforcement_point="$1"
    # Default to a valid empty-object JSON. Do NOT inline `${2:-{}}`: the
    # closing brace of the parameter expansion eats the first `}` of the
    # `{}` default, so a non-empty $2 like {"a":1} would pass through as
    # {"a":1}} (invalid JSON -> check.js JSON.parse fails -> exit 1 DENY
    # every iteration). Split the default assignment to avoid the footgun.
    local context_json="${2:-}"
    [ -z "$context_json" ] && context_json='{}'

    # Only check if policy files exist
    if [ ! -f ".loki/policies.json" ] && [ ! -f ".loki/policies.yaml" ]; then
        return 0
    fi

    # Requires Node.js
    if ! command -v node >/dev/null 2>&1; then
        return 0
    fi

    local result
    result=$(LOKI_PROJECT_DIR="$(pwd)" node "${SCRIPT_DIR}/../src/policies/check.js" "$enforcement_point" "$context_json" 2>/dev/null)
    local exit_code=$?

    if [ $exit_code -eq 1 ]; then
        log_error "Policy DENIED: $result"
        audit_agent_action "policy_denied" "Policy denied execution" "enforcement=$enforcement_point"
        emit_event_json "policy_denied" \
            "enforcement=$enforcement_point" \
            "result=$result"
        return 1
    elif [ $exit_code -eq 2 ]; then
        log_warn "Policy requires APPROVAL: $result"
        audit_agent_action "policy_approval_required" "Policy requires approval" "enforcement=$enforcement_point"
        # P3-3 (v7.51.0): honor the approval requirement when the operator has
        # opted into enforcement. This is OPT-IN and changes NOTHING for existing
        # users: the wait fires only when staged autonomy is on
        # (LOKI_STAGED_AUTONOMY=true) or the explicit
        # LOKI_POLICY_APPROVAL_ENFORCE=1 knob is set. Otherwise it stays advisory
        # (log + proceed), preserving the historical default behavior. The wait
        # reuses the same .loki/signals/ file-signal mechanism as staged-autonomy
        # plan approval (check_staged_autonomy), extended with a reject arm so an
        # operator can deny (deny == policy DENIED == return 1).
        if [ "$STAGED_AUTONOMY" = "true" ] || [ "${LOKI_POLICY_APPROVAL_ENFORCE:-0}" = "1" ]; then
            local _approve_sig=".loki/signals/POLICY_APPROVED"
            local _reject_sig=".loki/signals/POLICY_REJECTED"
            log_warn "Policy enforcement: waiting for approval at enforcement point '$enforcement_point'."
            log_warn "  Approve: create $_approve_sig  |  Reject: create $_reject_sig"
            audit_agent_action "policy_approval_wait" "Waiting for policy approval signal" "enforcement=$enforcement_point"
            while [ ! -f "$_approve_sig" ] && [ ! -f "$_reject_sig" ]; do
                sleep 5
            done
            if [ -f "$_reject_sig" ]; then
                rm -f "$_reject_sig" "$_approve_sig" 2>/dev/null || true
                log_error "Policy REJECTED by operator at enforcement point '$enforcement_point'"
                audit_agent_action "policy_approval_rejected" "Operator rejected policy approval" "enforcement=$enforcement_point"
                emit_event_json "policy_denied" \
                    "enforcement=$enforcement_point" \
                    "result=operator_rejected"
                return 1
            fi
            rm -f "$_approve_sig" 2>/dev/null || true
            log_info "Policy approved by operator at enforcement point '$enforcement_point'; continuing."
            audit_agent_action "policy_approval_granted" "Operator approved policy" "enforcement=$enforcement_point"
            return 0
        fi
        # Default (no staged autonomy, no enforce knob): advisory only -- log and
        # proceed. This preserves the historical behavior for existing users.
        return 0
    fi
    return 0
}

#===============================================================================
# Learning Signal Emission (SYN-018)
# Emits learning signals for cross-tool learning system
#===============================================================================

# Path to learning signal emitter
LEARNING_EMIT_SH="$SCRIPT_DIR/../learning/emit.sh"

# Emit learning signal (non-blocking)
# Usage: emit_learning_signal <signal_type> [options]
emit_learning_signal() {
    if [ -f "$LEARNING_EMIT_SH" ]; then
        # Run in background to be non-blocking
        (LOKI_DIR=".loki" LOKI_SKILL_DIR="$PROJECT_DIR" "$LEARNING_EMIT_SH" "$@" >/dev/null 2>&1 &)
    fi
}

# Track iteration timing for efficiency signals
ITERATION_START_MS=""

# Get current time in milliseconds (portable: works on macOS BSD date and GNU date)
_now_ms() {
    local ms
    ms=$(date +%s%3N 2>/dev/null)
    # macOS BSD date doesn't support %N -- outputs literal "N" or "%3N"
    # Detect non-numeric output and fall back to seconds * 1000
    case "$ms" in
        *[!0-9]*) echo $(( $(date +%s) * 1000 )) ;;
        *)        echo "$ms" ;;
    esac
}

record_iteration_start() {
    ITERATION_START_MS=$(_now_ms)
}

# Get iteration duration in milliseconds
get_iteration_duration_ms() {
    if [ -n "$ITERATION_START_MS" ]; then
        local end_ms
        end_ms=$(_now_ms)
        echo $((end_ms - ITERATION_START_MS))
    else
        echo "0"
    fi
}

#===============================================================================
# API Key Validation
# Validates required API key is set for the selected provider.
# Supports Docker/K8s secret file mounts as fallback.
#===============================================================================

# Zero-friction preflight helpers (T1.1). These run for ALL environments
# (not just Docker/K8s) BEFORE the build starts, via validate_api_keys. git is a
# genuine hard requirement (the build inits a repo) so a missing git BLOCKS with a
# copy-pasteable fix; node-version and network reachability are ADVISORY (warn and
# continue, fail-open) so a probabilistic or optional signal never blocks a working
# user. The goal: surface real problems early without ever wrongly refusing to start.
#
# _loki_check_node_version: ADVISORY only. If node is present and its major
# version is < 18, log a warning (node only matters for node-based builds) and
# continue. If node is absent entirely this is a NO-OP. Always returns 0 -- it
# never blocks the build (fail-open); the real node call, if any, is the test.
_loki_check_node_version() {
    command -v node >/dev/null 2>&1 || return 0
    local node_version major
    node_version="$(node --version 2>/dev/null || echo '')"
    # node --version -> "v20.11.0"; extract the leading major integer.
    major="$(printf '%s' "$node_version" | sed -E 's/^v?([0-9]+).*/\1/')"
    # Advisory only: node is OPTIONAL (absence is a no-op above, and many builds -
    # Python/Go/Rust - never touch node). A present-but-old node only matters for
    # node-based builds, so WARN and continue (fail-open); never hard-block a
    # working user. The actual node call (only for JS/TS work) is the real test.
    if [ -n "$major" ] && [[ "$major" =~ ^[0-9]+$ ]] && [ "$major" -lt 18 ]; then
        log_warn "Node.js >= 18 recommended for node-based builds; found ${node_version:-unknown}. Upgrade if your project uses node: https://nodejs.org"
    fi
    return 0
}

# _loki_check_git_present: the build initializes a git repo, so git is required.
_loki_check_git_present() {
    if ! command -v git >/dev/null 2>&1; then
        log_error "Git is required (the build initializes a repo). Install: https://git-scm.com/downloads"
        return 1
    fi
    return 0
}

# _loki_claude_login_state: report the Claude Code login state WITHOUT a network
# call, printing exactly one of: loggedin | expired | loggedout | unknown.
#
# v7.104.2: the previous preflight assumed OAuth credentials always live in
# ~/.claude/.credentials.json. That is false for the native install (Claude Code
# 2.x on macOS), which stores the login in the macOS Keychain under the service
# "Claude Code-credentials" and creates NO .credentials.json. A genuinely logged
# in subscription user was therefore falsely told "installed but not logged in"
# and blocked from every build -- the exact opposite of the zero-friction intent.
#
# Design: prefer the durable, CLI-owned signal (`claude auth status`, local +
# fast + non-interactive, JSON with "loggedIn"), because the credential STORE
# location has now moved twice (file -> keychain) and will move again. Fall back
# to the file, then the keychain, for older CLIs that lack `auth status`. Crucial
# rule (inconclusive-never-false-fails applied to auth): only ever return a hard
# "loggedout" on POSITIVE evidence of no login; when we cannot tell, return
# "unknown" and let the caller fail OPEN (warn, proceed, let the real call
# decide) -- never hard-block a paying user on uncertainty.
_loki_claude_login_state() {
    # 1) Primary: the CLI's own local auth-status (zero-network, ~0.2s). Newer
    #    claude CLIs print JSON with a boolean "loggedIn"; parse defensively.
    if command -v claude >/dev/null 2>&1; then
        local _status_json
        _status_json="$(claude auth status 2>/dev/null)"
        if [[ -n "$_status_json" ]]; then
            local _parsed
            _parsed="$(printf '%s' "$_status_json" | python3 -c "
import json, sys
try:
    d = json.load(sys.stdin)
    v = d.get('loggedIn')
    # ONLY the two explicit booleans are trusted. Anything else -- a missing
    # field, a renamed schema, a JSON error object -- is inconclusive and MUST
    # fall through to the file/keychain checks, never a hard 'loggedout' (that
    # would block a genuinely logged-in user with a valid creds file, violating
    # fail-open). Mirrors the bun doctor's === true / === false / else shape.
    if v is True:
        print('loggedin')
    elif v is False:
        print('loggedout')
    # else: print nothing -> fall through
except Exception:
    pass  # not JSON (older CLI / different output) -> stay silent, fall through
" 2>/dev/null)"
            if [[ "$_parsed" == "loggedin" ]]; then
                echo "loggedin"; return 0
            elif [[ "$_parsed" == "loggedout" ]]; then
                echo "loggedout"; return 0
            fi
            # auth status ran but was inconclusive (unparseable, or valid JSON
            # without an explicit loggedIn boolean) -> do NOT trust it as a
            # negative; fall through to the credential-store checks below.
        fi
    fi

    # 2) Fallback: a VALID (non-expired) OAuth credentials file (older CLIs write
    #    it here). Compute the expiry once; a valid file is a positive login.
    #    IMPORTANT: we do NOT conclude "expired" here yet -- a stale expired file
    #    can sit next to a live macOS-Keychain login (the native install writes
    #    the file once, then rotates only the Keychain). Concluding "expired" off
    #    the file before consulting the Keychain would wrongly block a genuinely
    #    logged-in user -- the exact class of bug this whole change fixes. So the
    #    Keychain (step 3, a POSITIVE signal) is checked BEFORE we ever return
    #    "expired" (step 4). Unknown schema -> treat as valid (fail open).
    local _creds="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
    local _file_state=""   # ""=absent, "valid"=present+fresh, "expired"=present+stale
    if [[ -s "$_creds" ]]; then
        _file_state="$(python3 -c "
import json, sys, time
try:
    d = json.load(open(sys.argv[1]))
    exp = d.get('claudeAiOauth', {}).get('expiresAt')
    if isinstance(exp, (int, float)) and exp > 0 and (exp / 1000.0) <= (time.time() + 60):
        print('expired')
    else:
        print('valid')
except Exception:
    print('valid')  # unreadable/unknown -> fail open (treat as valid)
" "$_creds" 2>/dev/null || echo valid)"
    fi
    if [[ "$_file_state" == "valid" ]]; then
        echo "loggedin"; return 0
    fi

    # 3) macOS Keychain (native install stores the login here, often with NO file
    #    at all, or alongside a now-stale file). A present Keychain entry is a
    #    POSITIVE login signal and OVERRIDES an expired file. Existence check
    #    ONLY -- never `-w` (reading the secret can pop a GUI keychain prompt that
    #    would hang a headless/CI build). Guarded to Darwin.
    if [[ "$(uname 2>/dev/null)" == "Darwin" ]] && command -v security >/dev/null 2>&1; then
        if security find-generic-password -s "Claude Code-credentials" >/dev/null 2>&1; then
            echo "loggedin"; return 0
        fi
    fi

    # 4) Only now, with no valid file and no Keychain login, is an expired file a
    #    real "expired" state (genuine expiry that would 401 mid-build).
    if [[ "$_file_state" == "expired" ]]; then
        echo "expired"; return 0
    fi

    # 5) No positive login evidence at all. On macOS (where the file AND the
    #    Keychain are the two real stores, both absent) with the CLI present, that
    #    is confident "logged out". Anywhere else we cannot prove absence of a
    #    login (no Keychain to consult), so stay "unknown" and let the caller fail
    #    open.
    if [[ "$(uname 2>/dev/null)" == "Darwin" ]] && command -v claude >/dev/null 2>&1; then
        echo "loggedout"; return 0
    fi
    echo "unknown"; return 0
}

# _loki_check_workspace_writable: the build writes state, proofs, and source into
# the working directory and its .loki/ subtree on every iteration. A read-only
# volume, a permission-denied directory, or a full disk would otherwise fail
# silently deep inside the loop (every state write swallows errors with
# `2>/dev/null || true`), leaving the user with a confusing half-built run. Catch
# it once, up front, with one actionable message instead. HARD requirement.
_loki_check_workspace_writable() {
    local dir="${TARGET_DIR:-$(pwd)}"
    local probe="${dir}/.loki/.write-probe.$$"
    # Ensure the .loki subtree can be created and written to.
    if ! mkdir -p "${dir}/.loki" 2>/dev/null; then
        if [ ! -w "$dir" ]; then
            log_error "Cannot create ${dir}/.loki -- the working directory is not writable. Fix permissions (e.g. chmod u+w '${dir}') or run from a directory you own, then retry."
        else
            log_error "Cannot create ${dir}/.loki. If the disk is full, free space (df -h '${dir}'); if it is a read-only mount, run from a writable location, then retry."
        fi
        return 1
    fi
    # Confirm we can actually write a byte (catches full disk / read-only mount
    # where the directory exists but writes fail).
    if ! (: > "$probe") 2>/dev/null; then
        rm -f "$probe" 2>/dev/null || true
        log_error "Cannot write to ${dir}/.loki -- check free disk space (df -h '${dir}') and that the volume is not read-only, then retry."
        return 1
    fi
    rm -f "$probe" 2>/dev/null || true
    return 0
}

# _loki_check_network_reachable: ADVISORY only. A fast (3s) reachability probe to
# the active provider endpoint that WARNS and continues if it cannot reach it -- a
# curl failure does not prove the provider CLI cannot connect (3s timeout under
# load, transient DNS, or a proxy set for the CLI but not the shell all curl-fail
# while the real build succeeds). Always returns 0 (fail-open); the actual
# provider call is the authoritative connectivity test. Skipped entirely when curl
# is missing, when LOKI_SKIP_NET_PREFLIGHT=1, when ANTHROPIC_BASE_URL is set (alt
# provider endpoint we cannot assume), or for any provider whose endpoint we do
# not know. It NEVER blocks the build.
# Alt-provider guard: ANTHROPIC_BASE_URL routes Loki at a non-Anthropic endpoint
# (OpenRouter, Ollama, LiteLLM, vLLM, self-hosted). The tier aliases Loki resolves
# by default -- opus / sonnet / haiku -- are Anthropic-only names, and most
# alt-providers reject them outright, so the run dies at the FIRST model call with
# a provider-side error that is hard to trace back to the missing override.
#
# LOKI_MODEL_OVERRIDE is what makes that path work (providers/claude.sh:520 and
# loki-ts/src/runner/providers.ts:328 both apply it only when BOTH vars are set).
# `loki doctor` already warns on this, but doctor is opt-in and the run is not.
#
# NON-FATAL by design: a mapping proxy (LiteLLM can do this) legitimately resolves
# the aliases server-side, so this is a warning and never a block. Emitted once per
# run, and silent unless an alt endpoint is actually configured.
_loki_warn_alt_provider_model_alias() {
    [ -n "${ANTHROPIC_BASE_URL:-}" ] || return 0
    [ -z "${LOKI_MODEL_OVERRIDE:-}" ] || return 0
    log_warn "ANTHROPIC_BASE_URL is set (${ANTHROPIC_BASE_URL}) but LOKI_MODEL_OVERRIDE is not."
    log_warn "  Loki will ask for the Anthropic tier aliases (opus/sonnet/haiku), which most"
    log_warn "  alt-providers do not serve. Set the exact model id your endpoint expects:"
    log_warn "    export LOKI_MODEL_OVERRIDE=<model-id>   # e.g. from 'ollama list' or your provider's model page"
    log_warn "  Ignore this if your gateway maps those aliases for you (LiteLLM can)."
    return 0
}

_loki_check_network_reachable() {
    local provider="${1:-claude}"
    [ "${LOKI_SKIP_NET_PREFLIGHT:-}" = "1" ] && return 0
    command -v curl >/dev/null 2>&1 || return 0
    # Alternate provider base URL set -> do not assume the default endpoint.
    [ -n "${ANTHROPIC_BASE_URL:-}" ] && return 0

    local endpoint=""
    case "$provider" in
        claude) endpoint="https://api.anthropic.com" ;;
        *)      return 0 ;;  # unknown endpoint -> fail open, never guess
    esac

    # Advisory only: a fast curl probe failing does NOT prove the provider CLI
    # cannot connect (a 3s timeout under load, transient DNS, or a proxy set for
    # the CLI but not the shell all curl-fail while the real build succeeds). WARN
    # and continue - the actual provider call is the authoritative connectivity
    # test. Silence this with LOKI_SKIP_NET_PREFLIGHT=1. Never hard-block here.
    if ! curl -sS -m 3 -o /dev/null "$endpoint" 2>/dev/null; then
        log_warn "Could not verify network reachability to the AI provider (firewall/VPN/transient?). Continuing; the provider call will be the real test. Silence with LOKI_SKIP_NET_PREFLIGHT=1."
    fi
    return 0
}

validate_api_keys() {
    local provider="${LOKI_PROVIDER:-claude}"

    # Zero-friction preflight (T1.1): toolchain + reachability checks that apply
    # to EVERY environment, run BEFORE the Docker/K8s early-return below so they
    # are not silently skipped in the common local case. Node/git are genuinely
    # required (node only when present-but-too-old); the network probe is
    # fail-open and opt-out (LOKI_SKIP_NET_PREFLIGHT=1).
    if ! _loki_check_node_version; then
        return 1
    fi
    if ! _loki_check_git_present; then
        return 1
    fi
    if ! _loki_check_workspace_writable; then
        return 1
    fi
    if ! _loki_check_network_reachable "$provider"; then
        return 1
    fi
    _loki_warn_alt_provider_model_alias

    # Zero-friction auth preflight for LOCAL runs (must run BEFORE the early
    # return below, which exits for non-Docker/K8s envs). When claude is the
    # provider and there is no ANTHROPIC_API_KEY, ask the login-state helper
    # (auth-status -> file -> keychain, all zero-network) whether the user is
    # actually logged in. A never-logged-in user would otherwise enter the build,
    # make a failing call, and 401 -- the worst first impression -- so we fail
    # fast with the one-step fix. But we ONLY hard-block on a confident
    # "loggedout"; an "expired" login gets its own message; "unknown" (we cannot
    # prove the login state, e.g. an older CLI on a non-macOS box with no file)
    # fails OPEN so a genuinely logged-in user is never blocked. This is the
    # v7.104.2 fix for the native/Keychain login being misread as logged out.
    # Opt out entirely with LOKI_SKIP_AUTH_PREFLIGHT=1.
    if [[ "$provider" == "claude" && "${LOKI_SKIP_AUTH_PREFLIGHT:-}" != "1" && -z "${ANTHROPIC_API_KEY:-}" ]]; then
        local _login_state
        _login_state="$(_loki_claude_login_state)"
        # Both branches report the blocker before returning. This is the wall a
        # user hits AFTER answering every quickstart prompt and confirming the
        # spend, and until now it emitted nothing -- so the funnel showed a first
        # run attempted, then silence, indistinguishable from a successful build.
        # Bounded enum only (`not_logged_in`), never the login state, path or
        # credential; backgrounded and non-fatal so a diagnostic can never break
        # the refusal it is describing.
        if [[ "$_login_state" == "loggedout" ]]; then
            if declare -f loki_emit_first_run_blocked >/dev/null 2>&1; then
                ( loki_emit_first_run_blocked "not_logged_in" >/dev/null 2>&1 </dev/null & ) 2>/dev/null || true
            fi
            log_error "Claude Code is installed but not logged in -- the build would stall instead of running."
            log_error "Log in once, then retry:"
            log_error "    claude login"
            log_error "(or set ANTHROPIC_API_KEY, or LOKI_SKIP_AUTH_PREFLIGHT=1 to bypass this check)"
            return 1
        elif [[ "$_login_state" == "expired" ]]; then
            if declare -f loki_emit_first_run_blocked >/dev/null 2>&1; then
                ( loki_emit_first_run_blocked "not_logged_in" >/dev/null 2>&1 </dev/null & ) 2>/dev/null || true
            fi
            log_error "Your Claude Code login has expired -- the build would stall instead of running."
            log_error "Fix it in one step, then retry:"
            log_error "    claude login"
            log_error "(or set ANTHROPIC_API_KEY, or LOKI_SKIP_AUTH_PREFLIGHT=1 to bypass this check)"
            return 1
        fi
        # "loggedin" or "unknown" -> proceed (fail open on uncertainty).
    fi

    # CLI tools (claude, codex, cline, aider) use their own login sessions.
    # Only require API keys inside Docker/K8s where CLI login isn't available.
    if [[ ! -f "/.dockerenv" ]] && [[ -z "${KUBERNETES_SERVICE_HOST:-}" ]]; then
        return 0
    fi

    local key_var=""
    case "$provider" in
        claude)
            # Inside Docker, the Claude Code CLI can authenticate either via
            # ANTHROPIC_API_KEY OR via a mounted OAuth credentials file (the
            # zero-friction `loki docker` wrapper mounts the host login at
            # ~/.claude/.credentials.json). If that file is present, the CLI
            # has a valid login and we must NOT block on the env var -- doing
            # so was the bug that made OAuth-based Docker runs exit at
            # pre-flight with "Required API key ... is not set". Mirror the
            # OAuth-aware doctor check (run.sh:9268).
            if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then
                local _claude_creds="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
                if [[ -s "$_claude_creds" ]]; then
                    log_info "Claude Code OAuth credentials detected ($_claude_creds); using CLI login."
                    return 0
                fi
            fi
            key_var="ANTHROPIC_API_KEY"
            ;;
        codex)  key_var="OPENAI_API_KEY" ;;
        cline)  # Cline manages its own keys via `cline auth`
            if ! command -v cline &>/dev/null; then
                log_error "Cline CLI not found. Install: npm install -g cline"
                return 1
            fi
            return 0
            ;;
        aider)  # Aider manages keys via env vars or .aider.conf.yml
            if ! command -v aider &>/dev/null; then
                log_error "Aider not found. Install: pip install aider-chat"
                return 1
            fi
            return 0
            ;;
    esac

    if [[ -z "$key_var" ]]; then
        return 0
    fi

    local key_value="${!key_var:-}"

    # Try loading from secret file mounts (Docker/K8s)
    if [[ -z "$key_value" ]]; then
        local lower_name
        lower_name=$(echo "$key_var" | tr '[:upper:]' '[:lower:]')
        for mount_path in /run/secrets /var/run/secrets; do
            if [[ -f "$mount_path/$lower_name" ]]; then
                key_value=$(cat "$mount_path/$lower_name" 2>/dev/null | tr -d '[:space:]')
                if [[ -n "$key_value" ]]; then
                    export "$key_var=$key_value"
                    log_info "Loaded $key_var from secret file: $mount_path/$lower_name"
                    break
                fi
            fi
        done
    fi

    if [[ -z "$key_value" ]]; then
        log_error "Required API key $key_var is not set for provider $provider"
        log_error "Set via environment variable or Docker/K8s secret mount"
        return 1
    fi

    # Log masked key for debugging
    local masked="${key_value:0:8}...${key_value: -4}"
    log_info "API key $key_var: $masked (${#key_value} chars)"

    # Fail-fast auth preflight (v7.91): for Claude OAuth logins, a present-but-
    # EXPIRED token passes the presence check above but then 401s on the first
    # provider call, leaving the build stalled at BOOTSTRAP with no clear cause.
    # Catch that here with a zero-network, zero-token local expiry check so the
    # user gets an instant, copy-pasteable fix instead of a silent stall.
    # Opt out with LOKI_SKIP_AUTH_PREFLIGHT=1 (offline/odd setups).
    if [[ "$provider" == "claude" && "${LOKI_SKIP_AUTH_PREFLIGHT:-}" != "1" && -z "${ANTHROPIC_API_KEY:-}" ]]; then
        local _creds="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
        if [[ -s "$_creds" ]]; then
            local _expired
            _expired=$(python3 -c "
import json, sys, time
try:
    d = json.load(open(sys.argv[1]))
    exp = d.get('claudeAiOauth', {}).get('expiresAt')
    # expiresAt is epoch milliseconds; compare with a 60s safety margin.
    if isinstance(exp, (int, float)) and exp > 0 and (exp / 1000.0) <= (time.time() + 60):
        print('expired')
except Exception:
    pass  # unreadable/unknown schema -> do not block (fail open, let the call decide)
" "$_creds" 2>/dev/null || true)
            if [[ "$_expired" == "expired" ]]; then
                log_error "Your Claude Code login has expired -- the build would stall instead of running."
                log_error "Fix it in one step, then retry:"
                log_error "    claude login"
                log_error "(or set ANTHROPIC_API_KEY, or LOKI_SKIP_AUTH_PREFLIGHT=1 to bypass this check)"
                return 1
            fi
        fi
        # NOTE: the never-logged-in (no credentials file) case is handled earlier,
        # BEFORE the local early-return, so it covers local runs too (this Docker/
        # K8s-only block would otherwise be unreachable for that case).
    fi

    return 0
}

#===============================================================================
# Complexity Tier Detection (Auto-Claude pattern)
#===============================================================================

# Detect project complexity from PRD and codebase
detect_complexity() {
    local prd_path="${1:-}"
    local target_dir="${TARGET_DIR:-.}"

    # If forced, use that
    if [ "$COMPLEXITY_TIER" != "auto" ]; then
        DETECTED_COMPLEXITY="$COMPLEXITY_TIER"
        return 0
    fi

    # Count files in project (excluding common non-source dirs)
    local file_count=0
    file_count=$(find "$target_dir" -type f \
        \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" \
        -o -name "*.py" -o -name "*.go" -o -name "*.rs" -o -name "*.java" \
        -o -name "*.rb" -o -name "*.php" -o -name "*.swift" -o -name "*.kt" \) \
        ! -path "*/node_modules/*" ! -path "*/.git/*" ! -path "*/vendor/*" \
        ! -path "*/dist/*" ! -path "*/build/*" ! -path "*/__pycache__/*" \
        2>/dev/null | wc -l | tr -d ' ')
    # Validate file_count is numeric (guard against empty/malformed pipeline output)
    file_count="${file_count:-0}"
    file_count="${file_count//[^0-9]/}"

    # Check for external integrations
    local has_external=false
    if grep -rq "oauth\|SAML\|OIDC\|stripe\|twilio\|aws-sdk\|@google-cloud\|azure" \
        "$target_dir" --include="*.json" --include="*.ts" --include="*.js" 2>/dev/null; then
        has_external=true
    fi

    # Check for multiple services (docker-compose, k8s)
    local has_microservices=false
    if [ -f "$target_dir/docker-compose.yml" ] || [ -d "$target_dir/k8s" ] || \
       [ -f "$target_dir/docker-compose.yaml" ]; then
        has_microservices=true
    fi

    # Analyze PRD if provided
    local prd_complexity="standard"
    if [ -n "$prd_path" ] && [ -f "$prd_path" ]; then
        local prd_words=$(wc -w < "$prd_path" | tr -d ' ')
        local feature_count=0
        local prd_lines=$(wc -l < "$prd_path" | tr -d ' ')

        # Detect PRD format and count features accordingly
        if [[ "$prd_path" == *.json ]]; then
            # JSON PRD: count features, requirements, tasks arrays
            if command -v jq &>/dev/null; then
                feature_count=$(jq '
                    [.features, .requirements, .tasks, .user_stories, .epics] |
                    map(select(. != null) | if type == "array" then length else 0 end) |
                    add // 0
                ' "$prd_path" 2>/dev/null || echo "0")
            else
                # Fallback: count array elements by pattern
                feature_count=$(grep -c '"title"\|"name"\|"feature"\|"requirement"' "$prd_path" 2>/dev/null || echo "0")
            fi
        else
            # Markdown PRD: count headers and checkboxes
            feature_count=$(grep -c "^##\|^- \[" "$prd_path" 2>/dev/null || echo "0")
        fi
        # WAVE8 FIX run.sh-provider-F1 (HIGH): grep -c prints "0" AND exits 1 on
        # zero matches; with the '|| echo "0"' fallback that yields "0\n0", which
        # crashes the integer tests below ([: 0\n0: integer expression expected)
        # and silently drops complexity from simple->standard. Strip to digits
        # after every assignment path (jq, both greps), mirroring file_count:1688.
        # "0\n0" -> "00" -> arithmetically 0.
        feature_count="${feature_count:-0}"
        feature_count="${feature_count//[^0-9]/}"
        feature_count="${feature_count:-0}"

        # Count distinct sections (h2/h3 headers) for structural complexity (#74)
        local section_count=0
        if [[ "$prd_path" != *.json ]]; then
            section_count=$(grep -c "^##\|^###" "$prd_path" 2>/dev/null || echo "0")
        fi
        # WAVE8 FIX run.sh-provider-F1: same grep -c double-output guard.
        section_count="${section_count:-0}"
        section_count="${section_count//[^0-9]/}"
        section_count="${section_count:-0}"

        # PRD complexity uses content length, feature count, AND structural depth (#74)
        # A PRD with multiple sections or substantial content is not "simple" even with few project files
        if [ "$prd_words" -lt 200 ] && [ "$feature_count" -lt 5 ] && [ "$section_count" -lt 3 ]; then
            prd_complexity="simple"
        elif [ "$prd_words" -gt 1000 ] || [ "$feature_count" -gt 15 ] || [ "$section_count" -gt 10 ]; then
            prd_complexity="complex"
        fi
    fi

    # Determine final complexity
    # A non-simple PRD always prevents "simple" classification regardless of file count (#74)
    if [ "$file_count" -le 5 ] && [ "$prd_complexity" = "simple" ] && \
       [ "$has_external" = "false" ] && [ "$has_microservices" = "false" ]; then
        DETECTED_COMPLEXITY="simple"
    elif [ "$file_count" -gt 50 ] || [ "$has_microservices" = "true" ] || \
         [ "$has_external" = "true" ] || [ "$prd_complexity" = "complex" ]; then
        DETECTED_COMPLEXITY="complex"
    else
        DETECTED_COMPLEXITY="standard"
    fi

    log_info "Detected complexity: $DETECTED_COMPLEXITY (files: $file_count, prd: $prd_complexity, external: $has_external, microservices: $has_microservices)"
}

# Get phases based on complexity tier
# Get phase names based on complexity tier
#===============================================================================
# Dynamic Tier Selection (RARV-aware model routing)
#===============================================================================
# Maps RARV cycle phases to model tiers. IMPORTANT (post-v7.104.0): this rotation
# is OFF by default -- CURRENT_TIER is pinned once from LOKI_SESSION_MODEL and held
# constant every iteration; get_rarv_tier() only fires under
# LOKI_LEGACY_TIER_SWITCHING=true (see the pin logic near run.sh:17150). And even
# when it does fire, planning/development/fast ALL resolve to the SAME model on
# stock config (CLAUDE_DEFAULT_*=sonnet), so the rotation changes only the
# --effort flag, not the model. The tier -> effort map (loki_effort_for_tier,
# autonomy/lib/claude-flags.sh):
#   - Reason phase  -> planning tier    (effort xhigh)
#   - Act phase     -> development tier (effort high)
#   - Reflect phase -> development tier (effort high)
#   - Verify phase  -> fast tier        (effort medium)

# Global tier for current iteration (set by get_rarv_tier)
CURRENT_TIER="development"
# Export for provider helper functions (e.g., provider_get_current_model)
LOKI_CURRENT_TIER="$CURRENT_TIER"
export LOKI_CURRENT_TIER

# Get the appropriate tier based on RARV cycle step
# Args: iteration_count (defaults to ITERATION_COUNT)
# Returns: tier name (planning, development, fast)
get_rarv_tier() {
    local iteration="${1:-$ITERATION_COUNT}"
    local rarv_step=$((iteration % 4))

    case $rarv_step in
        0)  # Reason phase - planning/architecture
            echo "planning"
            ;;
        1)  # Act phase - implementation
            echo "development"
            ;;
        2)  # Reflect phase - review/analysis
            echo "development"
            ;;
        3)  # Verify phase - testing/validation
            echo "fast"
            ;;
        *)  # Fallback to development
            echo "development"
            ;;
    esac
}

# Get RARV phase name for logging
get_rarv_phase_name() {
    local iteration="${1:-$ITERATION_COUNT}"
    local rarv_step=$((iteration % 4))

    case $rarv_step in
        0) echo "REASON" ;;
        1) echo "ACT" ;;
        2) echo "REFLECT" ;;
        3) echo "VERIFY" ;;
        *) echo "UNKNOWN" ;;
    esac
}

# Get provider-specific tier parameter based on current tier
# v6.0.0: Delegates to resolve_model_for_tier() if available (dynamic resolution).
# Falls back to static mapping for backward compatibility.
get_provider_tier_param() {
    local tier="${1:-$CURRENT_TIER}"

    # v6.0.0: Use dynamic resolution if provider has resolve_model_for_tier
    if type resolve_model_for_tier &>/dev/null; then
        local resolved
        resolved=$(resolve_model_for_tier "$tier")
        echo "$resolved"
        return
    fi

    # Legacy fallback: static tier mapping
    case "${PROVIDER_NAME:-claude}" in
        claude)
            case "$tier" in
                planning)
                    # Evidence-based routing (scoped): the official model-config
                    # docs explicitly name "architecture decisions" and
                    # "root-cause investigations" as where Fable 5's extra
                    # investigation and self-verification pay off. So the
                    # planning/architecture tier may opt in to Fable via
                    # LOKI_FABLE_ARCHITECT=1. Default OFF because Fable is 2x
                    # Opus per token; reserve it for the REASON/architecture
                    # iterations the user explicitly wants. An explicit
                    # PROVIDER_MODEL_PLANNING still wins (operator override).
                    if [ -n "${PROVIDER_MODEL_PLANNING:-}" ]; then
                        echo "${PROVIDER_MODEL_PLANNING}"
                    elif [ "${LOKI_FABLE_ARCHITECT:-0}" = "1" ]; then
                        # fable unavailable, collapse to opus. Claude Fable 5 is
                        # not available at the Claude API ("use Opus 4.8"); the
                        # architect opt-in now runs opus. Matches claude.sh
                        # resolve_model_for_tier and the estimator/dashboard.
                        echo "opus"
                    else
                        # v7.104.0: default planning to sonnet (was opus) to match
                        # the sourced claude.sh default. This bare fallback only
                        # fires when claude.sh is unsourced.
                        echo "sonnet"
                    fi
                    ;;
                development) echo "${PROVIDER_MODEL_DEVELOPMENT:-sonnet}" ;;
                fast) echo "${PROVIDER_MODEL_FAST:-sonnet}" ;;
                # fable unavailable, collapse to opus. Without this arm an
                # unsourced-claude.sh environment (this static fallback) would
                # silently downgrade a fable-pinned tier to sonnet via the `*`
                # default. Matches resolve_model_for_tier's explicit fable) arm,
                # which now resolves to opus (Fable 5 unavailable at the API).
                fable) echo "opus" ;;
                *) echo "sonnet" ;;
            esac
            ;;
        codex)
            case "$tier" in
                planning) echo "${PROVIDER_EFFORT_PLANNING:-xhigh}" ;;
                development) echo "${PROVIDER_EFFORT_DEVELOPMENT:-high}" ;;
                fast) echo "${PROVIDER_EFFORT_FAST:-low}" ;;
                *) echo "high" ;;
            esac
            ;;
        cline)
            echo "${CLINE_DEFAULT_MODEL:-${LOKI_CLINE_MODEL:-default}}"
            ;;
        aider)
            echo "${AIDER_DEFAULT_MODEL:-${LOKI_AIDER_MODEL:-openrouter/deepseek/deepseek-v3.2}}"
            ;;
        *)
            echo "development"
            ;;
    esac
}

#===============================================================================
# Provider Spawn Timeout (removed WAVE9 / provider-F2)
#
# A former invoke_with_timeout() helper (v6.0.0) wrapped a command in
# `timeout <s> "$@"` with a retry loop. It was never wired to the main
# provider invocation and is intentionally not revived, for two reasons:
#
#   1. No safe generous default. The main provider call is a long-running
#      autonomous coding agent. Any fixed timeout short enough to catch a
#      hang would also kill legitimate multi-minute iterations, and there is
#      no "generous enough" value that is both safe and useful by default.
#   2. Wrong retry semantics. The helper re-ran the same command on timeout.
#      Re-running a coding agent mid-work (it may have already edited files)
#      is actively harmful, not protective.
#
# The main invocation is also a pipeline (`claude | tee | python3`), which a
# positional-arg `timeout "$@"` wrapper cannot wrap at all. Interrupting a
# hung provider is handled by the SIGINT trap (kill_provider_child) instead.
#
# The `loki config spawn_timeout` / `spawn_retries` knobs (autonomy/loki) and
# the config->env mapping in this file (`'spawn_timeout':'LOKI_SPAWN_TIMEOUT'`
# in the config loader) still export LOKI_SPAWN_TIMEOUT / LOKI_SPAWN_RETRIES,
# but nothing consumes them now. The mapping line is intentionally left in place
# (a config-schema test may enumerate it); the full inert-knob removal spans
# autonomy/loki too and is a separate cross-file follow-up.
#===============================================================================

#===============================================================================
# GitHub Integration Functions (v4.1.0)
#===============================================================================

# GitHub integration settings
GITHUB_IMPORT=${LOKI_GITHUB_IMPORT:-false}
GITHUB_PR=${LOKI_GITHUB_PR:-false}
GITHUB_SYNC=${LOKI_GITHUB_SYNC:-false}
GITHUB_REPO=${LOKI_GITHUB_REPO:-""}
GITHUB_LABELS=${LOKI_GITHUB_LABELS:-""}
GITHUB_MILESTONE=${LOKI_GITHUB_MILESTONE:-""}
GITHUB_ASSIGNEE=${LOKI_GITHUB_ASSIGNEE:-""}
GITHUB_LIMIT=${LOKI_GITHUB_LIMIT:-100}
GITHUB_PR_LABEL=${LOKI_GITHUB_PR_LABEL:-""}

# Check if gh CLI is available and authenticated
check_github_cli() {
    if ! command -v gh &> /dev/null; then
        log_warn "gh CLI not found. Install with: brew install gh"
        return 1
    fi

    if ! gh auth status &> /dev/null; then
        log_warn "gh CLI not authenticated. Run: gh auth login"
        return 1
    fi

    return 0
}

# Get current repo from git remote or LOKI_GITHUB_REPO
get_github_repo() {
    if [ -n "$GITHUB_REPO" ]; then
        echo "$GITHUB_REPO"
        return
    fi

    # Try to detect from git remote
    local remote_url
    remote_url=$(git remote get-url origin 2>/dev/null || echo "")

    if [ -z "$remote_url" ]; then
        return 1
    fi

    # Extract owner/repo from various URL formats
    # https://github.com/owner/repo.git
    # git@github.com:owner/repo.git
    local repo
    repo=$(echo "$remote_url" | sed -E 's/.*github.com[:/]([^/]+\/[^/]+)(\.git)?$/\1/')
    repo="${repo%.git}"

    if [ -n "$repo" ] && [[ "$repo" == *"/"* ]]; then
        echo "$repo"
        return 0
    fi

    return 1
}

# Import issues from GitHub as tasks
import_github_issues() {
    if [ "$GITHUB_IMPORT" != "true" ]; then
        return 0
    fi

    if ! check_github_cli; then
        return 1
    fi

    local repo
    repo=$(get_github_repo)
    if [ -z "$repo" ]; then
        log_error "Could not determine GitHub repo. Set LOKI_GITHUB_REPO=owner/repo"
        return 1
    fi

    log_info "Importing issues from GitHub: $repo"

    # Build gh issue list command with filters
    local gh_args=("issue" "list" "--repo" "$repo" "--state" "open" "--limit" "$GITHUB_LIMIT" "--json" "number,title,body,labels,url,milestone,assignees")

    if [ -n "$GITHUB_LABELS" ]; then
        IFS=',' read -ra LABELS <<< "$GITHUB_LABELS"
        for label in "${LABELS[@]}"; do
            # Trim whitespace from label
            label=$(echo "$label" | xargs)
            gh_args+=("--label" "$label")
        done
    fi

    if [ -n "$GITHUB_MILESTONE" ]; then
        gh_args+=("--milestone" "$GITHUB_MILESTONE")
    fi

    if [ -n "$GITHUB_ASSIGNEE" ]; then
        gh_args+=("--assignee" "$GITHUB_ASSIGNEE")
    fi

    # Fetch issues with error capture
    local issues gh_error
    if ! issues=$(gh "${gh_args[@]}" 2>&1); then
        gh_error="$issues"
        if echo "$gh_error" | grep -q "rate limit"; then
            log_error "GitHub API rate limit exceeded. Wait and retry."
        else
            log_error "Failed to fetch issues: $gh_error"
        fi
        return 1
    fi

    if [ -z "$issues" ] || [ "$issues" == "[]" ]; then
        log_info "No open issues found matching filters"
        return 0
    fi

    # Convert issues to tasks
    local pending_file=".loki/queue/pending.json"
    local task_count=0

    # BUG #14 fix: Normalize to bare [] format (consistent with init_loki_dir
    # and all other queue consumers). Previously used {"tasks":[]} wrapper here
    # but bare [] everywhere else, causing format mismatch.
    if [ ! -f "$pending_file" ]; then
        echo '[]' > "$pending_file"
    elif jq -e 'type == "object"' "$pending_file" &>/dev/null; then
        # Normalize {"tasks":[...]} wrapper to bare array
        local _tmp_normalize
        _tmp_normalize=$(mktemp)
        jq 'if type == "object" then .tasks // [] else . end' "$pending_file" > "$_tmp_normalize" && mv "$_tmp_normalize" "$pending_file"
        rm -f "$_tmp_normalize"
    fi

    # Parse issues in the current shell so task_count remains accurate without
    # relying on descriptor-backed process substitution inside confinement.
    local issue_rows
    issue_rows=$(printf '%s\n' "$issues" | jq -c '.[]') || return 1
    while read -r issue; do
        [ -n "$issue" ] || continue
        local number title body full_body url labels
        number=$(echo "$issue" | jq -r '.number')
        title=$(echo "$issue" | jq -r '.title')
        full_body=$(echo "$issue" | jq -r '.body // ""')
        # Truncate body with indicator if needed
        if [ ${#full_body} -gt 500 ]; then
            body="${full_body:0:497}..."
        else
            body="$full_body"
        fi
        url=$(echo "$issue" | jq -r '.url')
        labels=$(echo "$issue" | jq -c '[.labels[].name]')

        # Check if task already exists (bare array format)
        if jq -e ".[] | select(.github_issue == $number)" "$pending_file" &>/dev/null; then
            log_info "Issue #$number already imported, skipping"
            continue
        fi

        # Determine priority from labels
        local priority="normal"
        if echo "$labels" | grep -qE '"(priority:critical|P0)"'; then
            priority="critical"
        elif echo "$labels" | grep -qE '"(priority:high|P1)"'; then
            priority="high"
        elif echo "$labels" | grep -qE '"(priority:medium|P2)"'; then
            priority="medium"
        elif echo "$labels" | grep -qE '"(priority:low|P3)"'; then
            priority="low"
        fi

        # Add task to pending queue
        local task_id="github-$number"
        local task_json
        task_json=$(jq -n \
            --arg id "$task_id" \
            --arg title "$title" \
            --arg desc "GitHub Issue #$number: $body" \
            --argjson num "$number" \
            --arg url "$url" \
            --argjson labels "$labels" \
            --arg priority "$priority" \
            --arg created "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
            '{
                id: $id,
                title: $title,
                description: $desc,
                source: "github",
                github_issue: $num,
                github_url: $url,
                labels: $labels,
                priority: $priority,
                status: "pending",
                created_at: $created
            }')

        # BUG-XC-010: Create temp file in same directory as target (avoids cross-filesystem mv).
        # v7.5.12: replace flock-only queue lock with portable mkdir-mutex via
        # safe_acquire_lock (works on macOS without util-linux flock).
        local temp_file
        temp_file=$(mktemp ".loki/queue/pending.json.tmp.XXXXXX")
        local lockfile=".loki/queue/.pending.lock"
        if type safe_acquire_lock >/dev/null 2>&1 && safe_acquire_lock "$lockfile" 5; then
            if jq ". += [$task_json]" "$pending_file" > "$temp_file" && mv "$temp_file" "$pending_file"; then
                log_info "Imported issue #$number: $title"
                task_count=$((task_count + 1))
            else
                log_warn "Failed to import issue #$number"
            fi
            safe_release_lock "$lockfile"
        else
            log_warn "Could not acquire queue lock for issue #$number, skipping"
        fi
        rm -f "$temp_file"
    done << GITHUB_ISSUES_EOF
$issue_rows
GITHUB_ISSUES_EOF

    log_info "Imported $task_count issues from GitHub"
}

# Create PR for completed feature
create_github_pr() {
    local feature_name="$1"
    local branch_name="${2:-$(git rev-parse --abbrev-ref HEAD)}"

    if [ "$GITHUB_PR" != "true" ]; then
        return 0
    fi

    if ! check_github_cli; then
        return 1
    fi

    local repo
    repo=$(get_github_repo)
    if [ -z "$repo" ]; then
        log_error "Could not determine GitHub repo"
        return 1
    fi

    log_info "Creating PR for: $feature_name"

    # Generate PR body from completed tasks
    local pr_body=".loki/reports/pr-body.md"
    mkdir -p "$(dirname "$pr_body")"

    local version
    version=$(cat "${SCRIPT_DIR%/*}/VERSION" 2>/dev/null || echo "unknown")
    cat > "$pr_body" << EOF
## Summary

Automated implementation by Loki Mode v$version ($ITERATION_COUNT iterations, provider: ${PROVIDER_NAME:-claude})

### Feature: $feature_name

### Tasks Completed
EOF

    # Add completed tasks from ledger
    if [ -f ".loki/ledger.json" ]; then
        jq -r '.completed_tasks[]? | "- [x] \(.title // .id)"' .loki/ledger.json >> "$pr_body" 2>/dev/null || true
    fi

    cat >> "$pr_body" << EOF

### Quality Gates
- Static Analysis: $([ -f ".loki/quality/static-analysis.pass" ] && echo "PASS" || echo "PENDING")
- Unit Tests: $([ -f ".loki/quality/unit-tests.pass" ] && echo "PASS" || echo "PENDING")
- Code Review: $([ -f ".loki/quality/code-review.pass" ] && echo "PASS" || echo "PENDING")

### Related Issues
EOF

    # Find related GitHub issues
    if [ -f ".loki/ledger.json" ]; then
        jq -r '.completed_tasks[]? | select(.github_issue) | "Closes #\(.github_issue)"' .loki/ledger.json >> "$pr_body" 2>/dev/null || true
    fi

    # Proven PR (Loop 6): append the Evidence Receipt to the body file before
    # gh pr create --body-file. Default-on; LOKI_PROVEN_PR=0 -> body file bytes
    # byte-identical to before. Empty expected_head_sha by design (R-DET-1
    # run_id pointer is the anti-stale guard; the branch head is offset by the
    # session commit). Best-effort: a missing proof appends nothing extra here.
    if [ "${LOKI_PROVEN_PR:-1}" != "0" ] && declare -f render_evidence_receipt_md >/dev/null 2>&1; then
        local _bf_proof=""
        _bf_proof="$(_loki_proof_json_for_pr 2>/dev/null || true)"
        if [ -n "$_bf_proof" ]; then
            { printf '\n'; render_evidence_receipt_md "$_bf_proof" "" "" 2>/dev/null; } >> "$pr_body" 2>/dev/null || true
        fi
    fi

    # Build PR create command
    local pr_args=("pr" "create" "--repo" "$repo" "--title" "[Loki Mode] $feature_name" "--body-file" "$pr_body")

    # Add label only if specified (avoids error if label doesn't exist)
    if [ -n "$GITHUB_PR_LABEL" ]; then
        pr_args+=("--label" "$GITHUB_PR_LABEL")
    fi

    # Create PR and capture output
    local pr_url
    if ! pr_url=$(gh "${pr_args[@]}" 2>&1); then
        log_error "Failed to create PR: $pr_url"
        return 1
    fi

    log_info "PR created: $pr_url"
}

# Sync task status to GitHub issue
sync_github_status() {
    local task_id="$1"
    local status="$2"
    local message="${3:-}"

    if [ "$GITHUB_SYNC" != "true" ]; then
        return 0
    fi

    if ! check_github_cli; then
        return 1
    fi

    # Extract issue number from task_id (format: github-123)
    local issue_number
    issue_number=$(echo "$task_id" | sed 's/github-//')

    if ! [[ "$issue_number" =~ ^[0-9]+$ ]]; then
        return 0  # Not a GitHub-sourced task
    fi

    local repo
    repo=$(get_github_repo)
    if [ -z "$repo" ]; then
        return 1
    fi

    # Track synced issues to avoid duplicate comments
    mkdir -p .loki/github
    local sync_log=".loki/github/synced.log"
    local sync_key="${issue_number}:${status}"
    if [ -f "$sync_log" ] && grep -qF "$sync_key" "$sync_log" 2>/dev/null; then
        return 0  # Already synced this status
    fi

    case "$status" in
        "in_progress")
            gh issue comment "$issue_number" --repo "$repo" \
                --body "**Loki Mode** -- Working on this issue (iteration $ITERATION_COUNT)" \
                2>/dev/null || true
            ;;
        "completed")
            local branch
            branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "main")
            local commit
            commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
            gh issue comment "$issue_number" --repo "$repo" \
                --body "**Loki Mode** -- Implementation complete on \`$branch\` ($commit). ${message:-}" \
                2>/dev/null || true
            ;;
        "closed")
            gh issue close "$issue_number" --repo "$repo" \
                --reason "completed" \
                --comment "**Loki Mode** -- Resolved. ${message:-}" \
                2>/dev/null || true
            ;;
    esac

    # Record sync to avoid duplicates
    echo "$sync_key" >> "$sync_log"
}

# Sync all completed GitHub-sourced tasks back to their issues
# Called after each iteration and at session end
sync_github_completed_tasks() {
    if [ "$GITHUB_SYNC" != "true" ]; then
        return 0
    fi

    if ! check_github_cli; then
        return 0
    fi

    local completed_file=".loki/queue/completed.json"
    if [ ! -f "$completed_file" ]; then
        return 0
    fi

    # Find GitHub-sourced tasks in completed queue that haven't been synced
    python3 -c "
import json, sys
try:
    with open('$completed_file') as f:
        tasks = json.load(f)
    for t in tasks:
        tid = t.get('id', '')
        if tid.startswith('github-'):
            print(tid)
except Exception:
    pass
" 2>/dev/null | while read -r task_id; do
        sync_github_status "$task_id" "completed"
    done
}

# Sync GitHub-sourced tasks currently in-progress
sync_github_in_progress_tasks() {
    if [ "$GITHUB_SYNC" != "true" ]; then
        return 0
    fi

    if ! check_github_cli; then
        return 0
    fi

    local pending_file=".loki/queue/pending.json"
    if [ ! -f "$pending_file" ]; then
        return 0
    fi

    # Find GitHub-sourced tasks in pending queue (about to be worked on)
    python3 -c "
import json
try:
    with open('$pending_file') as f:
        data = json.load(f)
    tasks = data.get('tasks', data) if isinstance(data, dict) else data
    for t in tasks:
        tid = t.get('id', '')
        if tid.startswith('github-'):
            print(tid)
except Exception:
    pass
" 2>/dev/null | while read -r task_id; do
        sync_github_status "$task_id" "in_progress"
    done
}

# Export tasks to GitHub issues (reverse sync)
export_tasks_to_github() {
    if ! check_github_cli; then
        return 1
    fi

    local repo
    repo=$(get_github_repo)
    if [ -z "$repo" ]; then
        log_error "Could not determine GitHub repo"
        return 1
    fi

    local pending_file=".loki/queue/pending.json"
    if [ ! -f "$pending_file" ]; then
        log_warn "No pending tasks to export"
        return 0
    fi

    # Export non-GitHub tasks as issues (handles both bare array and wrapper formats)
    jq -c 'if type == "object" then .tasks // [] else . end | .[] | select(.source != "github")' "$pending_file" 2>/dev/null | while read -r task; do
        local title desc
        title=$(echo "$task" | jq -r '.title')
        desc=$(echo "$task" | jq -r '.description // ""')

        log_info "Creating issue: $title"
        # BUG-GH-009: Check if label exists before using --label; skip if absent
        local label_flag=""
        if gh label list --repo "$repo" 2>/dev/null | grep -q "loki-mode"; then
            label_flag="--label loki-mode"
        fi
        gh issue create --repo "$repo" \
            --title "$title" \
            --body "$desc" \
            $label_flag \
            2>/dev/null || log_warn "Failed to create issue: $title"
    done
}

#===============================================================================
# Desktop Notifications (v4.1.0)
#===============================================================================

# Notification settings
NOTIFICATIONS_ENABLED=${LOKI_NOTIFICATIONS:-true}
NOTIFICATION_SOUND=${LOKI_NOTIFICATION_SOUND:-true}

# Send desktop notification (cross-platform)
send_notification() {
    local title="$1"
    local message="$2"
    local urgency="${3:-normal}"  # low, normal, critical

    if [ "$NOTIFICATIONS_ENABLED" != "true" ]; then
        return 0
    fi

    # Validate inputs - skip empty notifications
    if [ -z "$title" ] && [ -z "$message" ]; then
        return 0
    fi
    title="${title:-Notification}"  # Default title if empty

    # macOS: use osascript
    if command -v osascript &> /dev/null; then
        # Escape backslashes first, then double quotes for AppleScript
        local escaped_title="${title//\\/\\\\}"
        escaped_title="${escaped_title//\"/\\\"}"
        local escaped_message="${message//\\/\\\\}"
        escaped_message="${escaped_message//\"/\\\"}"

        osascript -e "display notification \"$escaped_message\" with title \"Loki Mode\" subtitle \"$escaped_title\"" 2>/dev/null || true

        # Play sound if enabled (low urgency intentionally silent)
        if [ "$NOTIFICATION_SOUND" = "true" ]; then
            case "$urgency" in
                critical)
                    osascript -e 'beep 3' 2>/dev/null || true
                    ;;
                normal)
                    osascript -e 'beep' 2>/dev/null || true
                    ;;
                low)
                    # Intentionally no sound for low urgency notifications
                    ;;
            esac
        fi
        return 0
    fi

    # Linux: use notify-send
    if command -v notify-send &> /dev/null; then
        local notify_urgency="normal"
        case "$urgency" in
            critical) notify_urgency="critical" ;;
            low) notify_urgency="low" ;;
            *) notify_urgency="normal" ;;
        esac

        # Escape markup characters for notify-send (supports basic Pango)
        local safe_title="${title//&/&amp;}"
        safe_title="${safe_title//</&lt;}"
        safe_title="${safe_title//>/&gt;}"
        local safe_message="${message//&/&amp;}"
        safe_message="${safe_message//</&lt;}"
        safe_message="${safe_message//>/&gt;}"

        notify-send -u "$notify_urgency" "Loki Mode: $safe_title" "$safe_message" 2>/dev/null || true
        return 0
    fi

    # Fallback: terminal bell for critical notifications
    if [ "$urgency" = "critical" ]; then
        printf '\a'  # Bell character
    fi

    return 0
}

notify_intervention_needed() {
    local reason="$1"
    # Delegate-then-notify: this helper ONLY fires the (gated) desktop ping. It
    # deliberately does NOT write the durable COMPLETION.txt / completion.json
    # record. Reason: notify_intervention_needed is also called from NON-terminal
    # sites (the perpetual-mode PAUSE auto-clear branch, uncertainty escalation)
    # where the run keeps going. Writing a "Needs input" durable file there would
    # falsely tell a detached user the run is done / blocked when it is not. The
    # durable intervention write now lives only at the genuinely blocking pause
    # sites (immediately before handle_pause), so the durable state matches the
    # actual run state.
    send_notification "Intervention Needed" "$reason" "critical"
}

notify_rate_limit() {
    local wait_time="$1"
    send_notification "Rate Limited" "Waiting ${wait_time}s before retry" "normal"
}

#===============================================================================
# Delegate-then-notify: completion summary (Release 2, "delegate then notify")
#
# build_completion_summary <outcome> writes two durable files that survive a
# detached (--bg) run where the terminal is gone and a bell would be useless:
#   .loki/COMPLETION.txt        human plain text (no emojis, no dashes)
#   .loki/state/completion.json machine-readable record of the same facts
# It also exports two strings for send_notification to consume:
#   _LOKI_SUMMARY_TITLE  short notification subtitle
#   _LOKI_SUMMARY_BODY   short notification body (outcome + branch + file count)
#
# All git reads are best-effort and non-fatal. The diff window is the run-start
# SHA captured once at runner init (_LOKI_RUN_START_SHA); we REUSE it and never
# recapture, so the reported diff matches the evidence gate's window exactly.
#
# This function NEVER sends a notification and NEVER gates on
# NOTIFICATIONS_ENABLED: the files are state, not a notification, and must be
# written even when desktop notifications are disabled. emit_completion_summary
# below is the wrapper that writes the files AND (gated) fires the desktop ping.
#===============================================================================
# Read the gate escalation signal into one human line, or print nothing.
# Shared by COMPLETION.txt's per-outcome guidance and PAUSED.md so the two never
# disagree about why a run stopped. Best-effort: a missing or corrupt signal
# yields an empty string, and the caller degrades to generic guidance.
_loki_summary_gate_reason() {
    local loki_dir="${1:-${TARGET_DIR:-.}/.loki}"
    [ -s "$loki_dir/signals/GATE_ESCALATION.json" ] || return 0
    _LOKI_GR_FILE="$loki_dir/signals/GATE_ESCALATION.json" python3 -c '
import json, os, sys
try:
    d = json.load(open(os.environ["_LOKI_GR_FILE"]))
except Exception:
    sys.exit(0)
if not isinstance(d, dict):
    sys.exit(0)
gate = str(d.get("gate", "") or "").strip()
if not gate:
    sys.exit(0)
count = d.get("count")
thr = d.get("threshold")
if isinstance(count, int) and isinstance(thr, int):
    print("%s (failed %d times, threshold %d)" % (gate, count, thr))
else:
    print(gate)
' 2>/dev/null || true
}

build_completion_summary() {
    local outcome="${1:-complete}"
    local loki_dir="${TARGET_DIR:-.}/.loki"
    mkdir -p "$loki_dir/state" 2>/dev/null || true

    # Human-readable outcome label and notification title.
    local outcome_label notify_title
    case "$outcome" in
        complete)       outcome_label="Completed";        notify_title="Run complete" ;;
        max_iterations) outcome_label="Max iterations";   notify_title="Run stopped (max iterations)" ;;
        stopped)        outcome_label="Stopped";          notify_title="Run stopped" ;;
        failed)         outcome_label="Failed";           notify_title="Run failed" ;;
        intervention)   outcome_label="Needs input";      notify_title="Input needed" ;;
        *)              outcome_label="$outcome";          notify_title="Run finished" ;;
    esac

    # Live app URL (best-effort): if the app runner has a running app, surface
    # where the user can try it. Reads .loki/app-runner/state.json written by
    # app-runner.sh. Empty when no app is running.
    local live_app_url=""
    local _app_state_file="$loki_dir/app-runner/state.json"
    if [ -f "$_app_state_file" ]; then
        live_app_url="$(python3 -c "import json,sys
try:
    d=json.load(open(sys.argv[1]))
    print(d.get('url','') if d.get('status')=='running' else '')
except Exception:
    print('')" "$_app_state_file" 2>/dev/null)"
    fi

    # Time to first preview: how long until the user could SEE something running.
    #
    # WRITTEN BUT NEVER READ, until now. app-runner.sh:177 has recorded
    # seconds_to_first_preview since v8, write-once and atomically, and NOTHING
    # consumed it -- not this summary, not the receipt, not the dashboard. A
    # measurement nobody surfaces cannot influence anything, so the number that
    # research identifies as the delight peak of this whole category (a working
    # preview at roughly four minutes) was invisible to the person who waited for
    # it and to us.
    #
    # Read-only and best-effort by design: absent file means the app never
    # previewed (or the run predates the writer), and we render nothing rather
    # than a zero. A fabricated "0s to preview" would be worse than silence.
    local first_preview_s=""
    local _fp_file="$loki_dir/app-runner/first-preview.json"
    if [ -f "$_fp_file" ]; then
        first_preview_s="$(python3 -c "import json,sys
try:
    v=json.load(open(sys.argv[1])).get('seconds_to_first_preview')
    print(int(v) if isinstance(v,(int,float)) and v>=0 else '')
except Exception:
    print('')" "$_fp_file" 2>/dev/null)"
    fi

    # Time to first CODE CHANGE. Companion to first_preview_s for the run that
    # never previews anything, which is most of them: a scoped issue fix has no
    # app to bring up, so the preview number is empty and the user had no signal
    # at all until the run ended.
    local first_artifact_s=""
    local _fa_read="$loki_dir/state/first-artifact.json"
    if [ -f "$_fa_read" ]; then
        first_artifact_s="$(python3 -c "import json,sys
try:
    v=json.load(open(sys.argv[1])).get('seconds_to_first_artifact')
    print(int(v) if isinstance(v,(int,float)) and v>=0 else '')
except Exception:
    print('')" "$_fa_read" 2>/dev/null)"
    fi

    # Where the time went, per stage. Same "written but never read" story as
    # first-preview above: emit_stage_complete (run.sh:2413) has appended a
    # stage_complete record -- stage, status, duration_s, iteration -- to
    # events.jsonl since v7.91.x, and NOTHING consumed it. A founder watching a
    # 322-word issue take 25+ minutes inside iteration 1 had no way to see which
    # step ate it, because the measurement existed and was never surfaced.
    #
    # Read-only aggregation over a file the run already wrote: no new subprocess
    # per stage, no new writer, one python3 pass at terminal time. We deliberately
    # render the AGENT remainder (wall clock minus summed stages) rather than
    # stages alone. The 9 emit_stage_complete sites are all post-iteration gates,
    # which sum to seconds; a table of only those would print "gates: 90s" on a
    # 25-minute run and still not answer the question. The remainder is the
    # provider/agent time, and it is usually the answer.
    #
    # Best-effort and honest about absence: no events file, no stage records, or
    # unparseable lines render NOTHING rather than a fabricated zero -- same
    # reasoning as first_preview_s. A wrong timing table is worse than silence.
    # Rework split for the user-facing summary. Reuses the SAME
    # iteration_attribution.py the prompt-side eval trend calls, so the number
    # the user reads and the number the agent steers on cannot drift apart.
    # Emits nothing unless the split is actually known.
    local _LOKI_REWORK_LINE=""
    if [ -r "${SCRIPT_DIR:-}/lib/iteration_attribution.py" ]; then
        _LOKI_REWORK_LINE="$(python3 "${SCRIPT_DIR}/lib/iteration_attribution.py" \
            --loki-dir "$loki_dir" --json 2>/dev/null | python3 -c "
import json, sys
try:
    d = json.load(sys.stdin)
except Exception:
    sys.exit(0)
prog, rew = d.get('progress', {}), d.get('rework', {})
pc, rc = prog.get('count', 0), rew.get('count', 0)
# Unknown-only runs render nothing: a 0/0 split is not evidence of no rework,
# it is absence of attribution, and printing it would read as a clean bill.
if pc + rc == 0:
    sys.exit(0)
cost = rew.get('cost_usd', 0.0) or 0.0
line = 'Rework: %d of %d iteration(s) redid earlier work' % (rc, pc + rc)
if cost > 0:
    line += ' (\$%.2f)' % cost
print(line)
" 2>/dev/null || true)"
    fi

    local stage_timing=""
    local _ev_file="$loki_dir/events.jsonl"
    if [ -f "$_ev_file" ]; then
        stage_timing="$(LOKI_RUN_START_EPOCH="${_LOKI_RUN_START_EPOCH:-}" python3 -c "
import json, os, sys
tot = {}
order = []
first = last = None

# events.jsonl is NEVER truncated between runs (no rm/rotate anywhere in the
# tree), so a second 'loki start' in the same workspace would otherwise sum
# stages from every previous run against THIS run's wall clock -- inflating
# staged past wall and silently killing the total/remainder rows. Filter to
# records at or after this run's start. The ISO timestamp is already on every
# record, so this costs nothing extra.
run_start_iso = None
_es = (os.environ.get('LOKI_RUN_START_EPOCH') or '').strip()
if _es:
    try:
        from datetime import datetime, timezone
        run_start_iso = datetime.fromtimestamp(float(_es), timezone.utc)
    except Exception:
        run_start_iso = None

def _in_run(ts):
    if run_start_iso is None or not isinstance(ts, str) or not ts:
        return True          # no reliable boundary: keep (old behavior)
    try:
        from datetime import datetime
        t = datetime.fromisoformat(ts.replace('Z', '+00:00'))
        return t >= run_start_iso
    except Exception:
        return True
try:
    with open(sys.argv[1], errors='replace') as fh:
        for line in fh:
            line = line.strip()
            if not line:
                continue
            try:
                rec = json.loads(line)
            except Exception:
                continue          # malformed line: skip, never abort the summary
            if not isinstance(rec, dict):
                continue
            ts = rec.get('timestamp')
            if not _in_run(ts):
                continue      # record belongs to an earlier run in this workspace
            if isinstance(ts, str) and ts:
                if first is None:
                    first = ts
                last = ts
            if rec.get('type') != 'stage_complete':
                continue
            d = rec.get('data') or {}
            if not isinstance(d, dict):
                continue
            name = d.get('stage')
            dur = d.get('duration_s')
            if not name or not isinstance(dur, (int, float)) or dur < 0:
                continue
            if name not in tot:
                tot[name] = 0.0
                order.append(name)
            tot[name] += float(dur)
except Exception:
    sys.exit(0)
if not tot:
    sys.exit(0)

def human(s):
    s = int(round(s))
    return '%dm %02ds' % (s // 60, s % 60) if s >= 60 else '%ds' % s

# Wall clock: prefer the run-start epoch the runner exported; else derive from
# the first/last event timestamps. Absent both, we print stages with no total
# rather than inventing a denominator.
wall = None
env_start = os.environ.get('LOKI_RUN_START_EPOCH') or ''
try:
    if env_start.strip():
        import time
        wall = time.time() - float(env_start)
except Exception:
    wall = None
if wall is None and first and last:
    try:
        from datetime import datetime
        f = datetime.fromisoformat(first.replace('Z', '+00:00'))
        l = datetime.fromisoformat(last.replace('Z', '+00:00'))
        wall = (l - f).total_seconds()
    except Exception:
        wall = None

staged = sum(tot.values())
out = []
for name in sorted(order, key=lambda n: -tot[n]):
    out.append('  %-22s %s' % (name.replace('_', ' '), human(tot[name])))
if wall is not None and wall >= staged:
    rem = wall - staged
    # The bucket that answers 'where did the 25 minutes go'.
    # NOT labeled 'agent': the provider call is itself a bracketed stage above,
    # so this remainder is everything else (checklist verification, app runner,
    # playwright, council, memory). Calling it 'agent' would print two different
    # measurements under one name.
    out.append('  %-22s %s' % ('other (unaccounted)', human(rem)))
    out.append('  %-22s %s' % ('total', human(wall)))
print('\n'.join(out))
" "$_ev_file" 2>/dev/null)"
    fi

    # Branch + diff stats vs the run-start SHA (best-effort; non-git or empty
    # baseline yields empty values, which we render as "unknown"/"0").
    local start_sha="${_LOKI_RUN_START_SHA:-}"
    local branch="" head_sha="" diff_stat="" files_changed=0 insertions=0 deletions=0 review_cmd=""
    branch="$( (cd "${TARGET_DIR:-.}" && git rev-parse --abbrev-ref HEAD) 2>/dev/null || true )"
    [ -z "$branch" ] && branch="unknown"
    head_sha="$( (cd "${TARGET_DIR:-.}" && git rev-parse HEAD) 2>/dev/null || true )"

    # Finding #596 (HIGH): exclude .loki/ and .git/ from the summary diff/stat and
    # from the "Review the work" command we print, so the user is never told to
    # review a .loki-bloated diff and the displayed counts match the gated diff.
    local _summary_pathspec=(-- . ':(exclude).loki/' ':(exclude).git/' ':(exclude)**/.loki/**')
    if [ -n "$start_sha" ]; then
        diff_stat="$( (cd "${TARGET_DIR:-.}" && git diff --stat "${start_sha}..HEAD" "${_summary_pathspec[@]}") 2>/dev/null || true )"
        # Parse the git diff --shortstat tail for counts (locale-stable enough
        # for our display; failures leave the zeros in place).
        local shortstat
        shortstat="$( (cd "${TARGET_DIR:-.}" && git diff --shortstat "${start_sha}..HEAD" "${_summary_pathspec[@]}") 2>/dev/null || true )"
        if [ -n "$shortstat" ]; then
            files_changed="$(printf '%s\n' "$shortstat" | grep -oE '[0-9]+ file' | grep -oE '[0-9]+' | head -1)"
            insertions="$(printf '%s\n' "$shortstat" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' | head -1)"
            deletions="$(printf '%s\n' "$shortstat" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' | head -1)"
        fi
        review_cmd="git diff ${start_sha}..HEAD -- . ':(exclude).loki/'"
    else
        # No baseline SHA. This is the GREENFIELD case: the run started in a repo
        # with no commits, so `git rev-parse --verify HEAD` correctly produced
        # nothing (see the start-sha capture). Everything the run built is new.
        #
        # Without this branch the counts stayed at zero and a user was told
        # "files_changed: 0" about a run that had produced real, committed,
        # working code -- observed on a PRD benchmark that built 4 files and
        # passed 28/28 of its own tests, yet reported 0. Diff against the empty
        # tree so the summary reports what was actually created.
        local _empty_tree
        _empty_tree="$( (cd "${TARGET_DIR:-.}" && git hash-object -t tree /dev/null) 2>/dev/null || true )"
        if [ -n "$_empty_tree" ] && (cd "${TARGET_DIR:-.}" && git rev-parse --verify HEAD >/dev/null 2>&1); then
            diff_stat="$( (cd "${TARGET_DIR:-.}" && git diff --stat "${_empty_tree}..HEAD" "${_summary_pathspec[@]}") 2>/dev/null || true )"
            local _shortstat_new
            _shortstat_new="$( (cd "${TARGET_DIR:-.}" && git diff --shortstat "${_empty_tree}..HEAD" "${_summary_pathspec[@]}") 2>/dev/null || true )"
            if [ -n "$_shortstat_new" ]; then
                files_changed="$(printf '%s\n' "$_shortstat_new" | grep -oE '[0-9]+ file' | grep -oE '[0-9]+' | head -1)"
                insertions="$(printf '%s\n' "$_shortstat_new" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' | head -1)"
                deletions="$(printf '%s\n' "$_shortstat_new" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' | head -1)"
            fi
            review_cmd="git diff ${_empty_tree}..HEAD -- . ':(exclude).loki/'"
        else
            review_cmd="git diff HEAD -- . ':(exclude).loki/'"
        fi
    fi
    [ -z "$files_changed" ] && files_changed=0
    [ -z "$insertions" ] && insertions=0
    [ -z "$deletions" ] && deletions=0

    # Task counts: reuse the SAME queue reads as update_status_file.
    local pending=0 in_progress=0 completed=0 failed=0
    [ -f "$loki_dir/queue/pending.json" ] && pending=$(python3 -c "import json; print(len(json.load(open('$loki_dir/queue/pending.json'))))" 2>/dev/null || echo "0")
    [ -f "$loki_dir/queue/in-progress.json" ] && in_progress=$(python3 -c "import json; print(len(json.load(open('$loki_dir/queue/in-progress.json'))))" 2>/dev/null || echo "0")
    [ -f "$loki_dir/queue/completed.json" ] && completed=$(python3 -c "import json; print(len(json.load(open('$loki_dir/queue/completed.json'))))" 2>/dev/null || echo "0")
    [ -f "$loki_dir/queue/failed.json" ] && failed=$(python3 -c "import json; print(len(json.load(open('$loki_dir/queue/failed.json'))))" 2>/dev/null || echo "0")

    # Optional delegate-mode extras populated by Slice 3 (branch isolation / PR).
    local delegate_branch="${_LOKI_DELEGATE_BRANCH_NAME:-}"
    local pr_url="${_LOKI_DELEGATE_PR_URL:-}"

    local ts
    ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)"

    # v7.28.0: evidence-gate inconclusive line. When the evidence gate could not
    # establish a diff baseline (no git repo, or no run-start SHA), it records a
    # durable .loki/state/evidence-inconclusive.json instead of silently passing.
    # Surface one honest line so the user knows completion was not independently
    # verified. The record is removed by the gate on any conclusive run.
    local evidence_inconclusive_line=""
    local _inc_file="$loki_dir/state/evidence-inconclusive.json"
    if [ -f "$_inc_file" ]; then
        local _inc_reason
        _inc_reason="$(python3 -c "import json,sys
try:
    d=json.load(open(sys.argv[1]))
    print(d.get('reason','') if d.get('inconclusive') else '')
except Exception:
    print('')" "$_inc_file" 2>/dev/null)"
        if [ -n "$_inc_reason" ]; then
            evidence_inconclusive_line="Evidence gate: inconclusive (${_inc_reason}) - completion not independently verified"
        fi
    fi

    # P2-2: assumption-ledger summary for proof-of-done. "done" means "done, plus
    # here are the N places your spec was ambiguous and what Loki assumed."
    # spec_ledger_counts echoes "<total> <high>"; defined when spec-interrogation.sh
    # is sourced (run.sh sources it in DISCOVERY). Guarded for safety.
    local assumptions_total=0 assumptions_high=0 assumption_lines=""
    if type spec_ledger_counts &>/dev/null; then
        local _ac
        _ac="$(spec_ledger_counts 2>/dev/null || echo '0 0')"
        assumptions_total="${_ac%% *}"
        assumptions_high="${_ac##* }"
        case "$assumptions_total" in ''|*[!0-9]*) assumptions_total=0 ;; esac
        case "$assumptions_high"  in ''|*[!0-9]*) assumptions_high=0 ;; esac
        if [ "$assumptions_total" != "0" ]; then
            local _ledger_md="$loki_dir/assumptions/ledger.md"
            if [ -f "$_ledger_md" ]; then
                # Pull just the "## <id> ..." headings as a terse one-per-line list.
                assumption_lines="$(grep '^## ' "$_ledger_md" 2>/dev/null | sed 's/^## /  - /' || true)"
            fi
        fi
    fi

    # ---- Durable human-readable file: .loki/COMPLETION.txt --------------------
    # Presentation-only receipt: a single fixed-width label column, the live-app
    # URL elevated right under the outcome headline, and a consistent rule line.
    # Stays pure ASCII (no emoji, no dashes, no color codes) so it pastes cleanly
    # into a PR description or a chat. Same facts and values as before.
    {
        echo "Loki Mode run summary"
        echo "====================="
        echo ""
        printf '%-14s %s\n' "Outcome:" "$outcome_label"
        if [ -n "$live_app_url" ]; then
            # Compute the dashboard scheme the same way start_dashboard does
            # (url_scheme is local to that function, not visible here).
            local _dash_scheme="http"
            [ -n "${LOKI_TLS_CERT:-}" ] && [ -n "${LOKI_TLS_KEY:-}" ] && _dash_scheme="https"
            printf '%-14s %s\n' "Live app:" "$live_app_url  (served locally on this machine)"
            printf '%-14s %s\n' "Dashboard:" "${_dash_scheme}://127.0.0.1:${DASHBOARD_PORT:-57374}/  (App Runner -> Live App)"
        fi
        printf '%-14s %s\n' "Branch:" "$branch"
        printf '%-14s %s\n' "Files:" "$files_changed (+$insertions / -$deletions)"
        # Only rendered when the app actually previewed. Silence beats a
        # fabricated zero for a run where nothing ever came up.
        if [ -n "$first_preview_s" ]; then
            printf '%-14s %ss\n' "First preview:" "$first_preview_s"
        fi
        # First code change, for the (far more common) run with no preview at
        # all -- a scoped issue fix produces no running app, so first_preview_s
        # is empty and the user had no signal of any kind until the end. Same
        # discipline: rendered only when actually recorded, never fabricated.
        if [ -n "$first_artifact_s" ]; then
            printf '%-14s %ss\n' "First change:" "$first_artifact_s"
        fi
        printf '%-14s %s\n' "Finished:" "$ts"
        if [ -n "$delegate_branch" ]; then
            printf '%-14s %s\n' "Delegate:" "$delegate_branch"
        fi
        if [ -n "$pr_url" ]; then
            printf '%-14s %s\n' "Pull request:" "$pr_url"
        elif [ "$outcome" = "complete" ]; then
            printf '%-14s %s\n' "Pull request:" "not opened (set LOKI_DELEGATE_PR=1 to open one)"
        fi
        printf '%-14s %s\n' "Tasks:" "pending=$pending in_progress=$in_progress completed=$completed failed=$failed"
        echo ""
        if [ -n "$stage_timing" ]; then
            echo "Where the time went:"
            echo "$stage_timing"
            echo ""
        fi
        # Rework attribution. The AGENT has been steering on this since the eval
        # trend shipped, but the user never saw it -- and it is the number that
        # answers "why did this cost so much".
        #
        # iterations alone cannot distinguish real work from a gate false
        # positive that forced redos. That is not hypothetical: a measured run
        # had the agent claim done on EVERY iteration while a mock-integrity
        # false positive blocked all six, so the run looked like six iterations
        # of work and was one iteration of work plus five of harness bug.
        #
        # Renders NOTHING when the split is unknown, rather than a fabricated
        # zero -- same rule as the timing table above. A wrong attribution is
        # worse than silence because it points the user at the wrong culprit.
        if [ -n "${_LOKI_REWORK_LINE:-}" ]; then
            echo "$_LOKI_REWORK_LINE"
            echo ""
        fi
        # What to DO about it. A terminal that names a bound without naming the
        # lever leaves the user to guess which knob applies, and the wrong guess
        # is expensive: raising an iteration cap on a run that was thrashing
        # buys more thrashing at full price.
        #
        # The rework split above already distinguishes the two cases, so this
        # points at the lever that actually matches the outcome rather than
        # listing every environment variable.
        case "$outcome" in
            max_iterations)
                echo "Next: this run hit its iteration ceiling, not a verdict."
                echo "  Converging (low rework above)? Raise it: LOKI_MAX_ITERATIONS=<n>"
                echo "  Thrashing (high rework above)? Raising the cap buys more"
                echo "  of the same. Read the last reviewer findings first:"
                echo "    loki why"
                echo ""
                ;;
            budget_exceeded)
                echo "Next: the spend cap stopped this run, not a failed gate."
                echo "  Raise it with LOKI_BUDGET_LIMIT=<usd>, or check the cache"
                echo "  hit ratio first -- a cold cache is the usual cause of a"
                echo "  surprising bill:"
                echo "    loki memory economics"
                echo ""
                ;;
            force_stopped)
                echo "Next: the council stopped this run WITHOUT approving it."
                echo "  The work is not verified complete. See what blocked it:"
                echo "    loki why"
                echo ""
                ;;
        esac
        if [ -n "$evidence_inconclusive_line" ]; then
            echo "$evidence_inconclusive_line"
            echo ""
        fi
        if [ "$assumptions_total" != "0" ]; then
            echo "Spec assumptions recorded: $assumptions_total ($assumptions_high high-severity)"
            echo "  These are places your spec was ambiguous and what Loki assumed. See .loki/assumptions/ledger.md"
            if [ -n "$assumption_lines" ]; then
                echo "$assumption_lines"
            fi
            echo ""
        fi
        echo "Review the work:"
        echo "  $review_cmd"
        echo ""
        if [ -n "$diff_stat" ]; then
            echo "Diff stat:"
            echo "$diff_stat"
        else
            echo "Diff stat: (no changes detected vs run start, or git unavailable)"
        fi
        # Empty-diff remedy (mirrors emit_completion_summary's on-screen line) so
        # a --bg or dashboard user reading COMPLETION.txt gets the same honest,
        # actionable guidance as a foreground user. Never implies success.
        case "$files_changed" in
            ''|0)
                case "$outcome" in
                    complete|max_iterations)
                        echo ""
                        echo "No file changes were produced this run. Likely the spec was too vague"
                        echo "or already satisfied. Next: re-run with a more concrete spec, e.g."
                        echo "  loki start \"<one concrete, testable change you want>\""
                        echo "or inspect what happened: loki why"
                        ;;
                esac
                ;;
        esac

        # Per-outcome next step. Deliberately NOT nested inside the
        # files_changed==0 case above: a run that stops for a gate reason has
        # usually built plenty, and that is exactly the run whose user most needs
        # to be told what to do. Keying this on an empty diff would silence it
        # for the case that motivated it.
        #
        # Measured on the PRD benchmark: outcome=intervention with 9 files built
        # and 28/28 tests passing produced NO guidance at all, because the old
        # block covered only complete|max_iterations.
        case "$outcome" in
            intervention)
                echo ""
                echo "Loki stopped and needs a decision from you."
                _cs_gate="$(_loki_summary_gate_reason "$loki_dir")"
                if [ -n "$_cs_gate" ]; then
                    echo "  Blocked by: $_cs_gate"
                    echo "  The findings say exactly what to change. Fix those, then resume:"
                else
                    echo "  See .loki/PAUSED.md for the reason and .loki/CONTINUITY.md for context."
                    echo "  Resume with:"
                fi
                echo "    rm .loki/PAUSE        # resume this run"
                echo "    touch .loki/STOP      # end it instead"
                echo "  Resuming without addressing the finding will stop at the same gate again."
                ;;
            failed)
                echo ""
                echo "The run failed. What went wrong:"
                echo "  loki why              # the classified cause, in one line"
                echo "  .loki/logs/           # the full session log"
                echo "Anything already committed is listed above and is yours to keep or discard."
                ;;
            stopped|force_stopped)
                echo ""
                echo "The run was stopped before it finished."
                echo "Work committed up to that point is listed above. To continue from here:"
                echo "  loki start <your spec>   # a fresh run over the current tree"
                ;;
            inconclusive_spec_contradiction)
                echo ""
                echo "Loki could not reconcile parts of the spec, so it stopped rather than"
                echo "guess. The specific contradictions are in .loki/assumptions/ledger.md."
                echo "Resolve those in the spec, then re-run."
                ;;
        esac
    } > "$loki_dir/COMPLETION.txt" 2>/dev/null || true

    # ---- Durable machine-readable file: .loki/state/completion.json -----------
    _LOKI_CS_OUTCOME="$outcome" \
    _LOKI_CS_BRANCH="$branch" \
    _LOKI_CS_START_SHA="$start_sha" \
    _LOKI_CS_HEAD_SHA="$head_sha" \
    _LOKI_CS_FILES="$files_changed" \
    _LOKI_CS_INS="$insertions" \
    _LOKI_CS_DEL="$deletions" \
    _LOKI_CS_REVIEW="$review_cmd" \
    _LOKI_CS_DELEGATE_BRANCH="$delegate_branch" \
    _LOKI_CS_PR_URL="$pr_url" \
    _LOKI_CS_TS="$ts" \
    _LOKI_CS_ASSUMPTIONS_TOTAL="$assumptions_total" \
    _LOKI_CS_ASSUMPTIONS_HIGH="$assumptions_high" \
    _LOKI_CS_OUT_FILE="$loki_dir/state/completion.json" \
    _LOKI_CS_LAST_ERROR="$loki_dir/state/LAST_ERROR.json" \
    python3 -c "
import json, os, tempfile
out = os.environ['_LOKI_CS_OUT_FILE']
def i(v):
    try: return int(v)
    except (TypeError, ValueError): return 0
# Carry the CLASSIFIED failure reason (error_class + brief) into the machine-
# readable completion record, from the side-record loki wrote before this summary
# (.loki/state/LAST_ERROR.json). Without it a terminal outcome like
# 'inconclusive_spec_contradiction' is only a label with no cause a downstream
# (operator, next run, bench) can act on. None on a clean run.
_err_class = None
_err_brief = None
try:
    with open(os.environ.get('_LOKI_CS_LAST_ERROR', '')) as _ef:
        _er = json.load(_ef)
    if isinstance(_er, dict):
        _err_class = _er.get('error_class')
        _err_brief = _er.get('brief')
except Exception:
    pass
rec = {
    'outcome': os.environ.get('_LOKI_CS_OUTCOME', ''),
    'error_class': _err_class,
    'error_brief': _err_brief,
    'branch': os.environ.get('_LOKI_CS_BRANCH', ''),
    'start_sha': os.environ.get('_LOKI_CS_START_SHA', ''),
    'head_sha': os.environ.get('_LOKI_CS_HEAD_SHA', ''),
    'files_changed': i(os.environ.get('_LOKI_CS_FILES')),
    'insertions': i(os.environ.get('_LOKI_CS_INS')),
    'deletions': i(os.environ.get('_LOKI_CS_DEL')),
    'review_cmd': os.environ.get('_LOKI_CS_REVIEW', ''),
    'delegate_branch': os.environ.get('_LOKI_CS_DELEGATE_BRANCH', ''),
    'pr_url': os.environ.get('_LOKI_CS_PR_URL', ''),
    'timestamp': os.environ.get('_LOKI_CS_TS', ''),
    'assumptions_total': i(os.environ.get('_LOKI_CS_ASSUMPTIONS_TOTAL')),
    'assumptions_high': i(os.environ.get('_LOKI_CS_ASSUMPTIONS_HIGH')),
}
d = os.path.dirname(out)
fd, tmp = tempfile.mkstemp(dir=d, suffix='.json')
with os.fdopen(fd, 'w') as f:
    json.dump(rec, f, indent=2)
os.replace(tmp, out)
" 2>/dev/null || true

    # ---- P3-5: run manifest / bill-of-materials: .loki/loki-run.json ----------
    # Best-effort, auditable + reproducible record of this run. Emitted from
    # build_completion_summary because that fires on EVERY terminal path
    # (complete / max_iterations / stopped / intervention / failed), including the
    # intervention/stopped calls that bypass emit_completion_summary. The whole
    # block is wrapped so a failure can NEVER abort the run.
    {
        # Portable sha256 (macOS shasum, Linux sha256sum). Echoes empty on miss.
        _loki_sha256() {
            local _f="$1"
            [ -f "$_f" ] || { printf ''; return 0; }
            if command -v shasum >/dev/null 2>&1; then
                shasum -a 256 "$_f" 2>/dev/null | awk '{print $1}'
            elif command -v sha256sum >/dev/null 2>&1; then
                sha256sum "$_f" 2>/dev/null | awk '{print $1}'
            else
                printf ''
            fi
        }

        local _loki_ver
        _loki_ver="$(cat "$PROJECT_DIR/VERSION" 2>/dev/null || echo "unknown")"
        local _spec_path="${PRD_PATH:-}"
        [ -z "$_spec_path" ] && _spec_path="none"
        local _spec_hash=""
        [ "$_spec_path" != "none" ] && _spec_hash="$(_loki_sha256 "$_spec_path")"

        # Evidence files we reference + hash (existing artifacts, not new ones).
        local _ev_tests="$loki_dir/quality/test-results.json"
        local _ev_cov="$loki_dir/quality/coverage.json"
        local _ev_completion="$loki_dir/state/completion.json"

        _LOKI_RM_OUT="$loki_dir/loki-run.json" \
        _LOKI_RM_VERSION="$_loki_ver" \
        _LOKI_RM_SPEC_PATH="$_spec_path" \
        _LOKI_RM_SPEC_HASH="$_spec_hash" \
        _LOKI_RM_PROVIDER="${PROVIDER_NAME:-${LOKI_PROVIDER:-claude}}" \
        _LOKI_RM_TIER="${CURRENT_TIER:-unknown}" \
        _LOKI_RM_OUTCOME="$outcome" \
        _LOKI_RM_BRANCH="$branch" \
        _LOKI_RM_START_SHA="$start_sha" \
        _LOKI_RM_HEAD_SHA="$head_sha" \
        _LOKI_RM_ITERS="${ITERATION_COUNT:-0}" \
        _LOKI_RM_TS="$ts" \
        _LOKI_RM_EV_TESTS="$_ev_tests" \
        _LOKI_RM_EV_TESTS_HASH="$(_loki_sha256 "$_ev_tests")" \
        _LOKI_RM_EV_COV="$_ev_cov" \
        _LOKI_RM_EV_COV_HASH="$(_loki_sha256 "$_ev_cov")" \
        _LOKI_RM_EV_COMPLETION="$_ev_completion" \
        _LOKI_RM_NODE="$(node --version 2>/dev/null || echo '')" \
        _LOKI_RM_PYTHON="$(python3 --version 2>&1 | awk '{print $2}' || echo '')" \
        _LOKI_RM_GIT="$(git --version 2>/dev/null | awk '{print $3}' || echo '')" \
        _LOKI_RM_BUN="$(bun --version 2>/dev/null || echo '')" \
        python3 -c "
import json, os, tempfile
out=os.environ['_LOKI_RM_OUT']
def s(k): return os.environ.get(k,'')
def i(k):
    try: return int(os.environ.get(k,'0'))
    except (TypeError, ValueError): return 0
def ev(path_k, hash_k=None):
    p=s(path_k)
    rec={'path': p, 'exists': bool(p) and os.path.isfile(p)}
    if hash_k:
        h=s(hash_k)
        if h: rec['sha256']=h
    return rec
manifest={
    'schema': 'loki-run-manifest/v1',
    'loki_version': s('_LOKI_RM_VERSION'),
    'timestamp': s('_LOKI_RM_TS'),
    'outcome': s('_LOKI_RM_OUTCOME'),
    'iterations': i('_LOKI_RM_ITERS'),
    'provider': s('_LOKI_RM_PROVIDER'),
    # Tier cycles per RARV iteration (R/A/R/V); this is the LAST tier set, not
    # the only tier used. Recorded honestly as last_tier, not a single 'model'.
    'last_tier': s('_LOKI_RM_TIER'),
    'spec': {
        'path': s('_LOKI_RM_SPEC_PATH'),
        'sha256': s('_LOKI_RM_SPEC_HASH') or None,
    },
    'git': {
        'branch': s('_LOKI_RM_BRANCH'),
        'start_sha': s('_LOKI_RM_START_SHA') or None,
        'head_sha': s('_LOKI_RM_HEAD_SHA') or None,
    },
    'tool_versions': {
        'node': s('_LOKI_RM_NODE') or None,
        'python': s('_LOKI_RM_PYTHON') or None,
        'git': s('_LOKI_RM_GIT') or None,
        'bun': s('_LOKI_RM_BUN') or None,
    },
    'evidence': {
        'test_results': ev('_LOKI_RM_EV_TESTS','_LOKI_RM_EV_TESTS_HASH'),
        'coverage': ev('_LOKI_RM_EV_COV','_LOKI_RM_EV_COV_HASH'),
        'completion': ev('_LOKI_RM_EV_COMPLETION'),
    },
}
d=os.path.dirname(out)
fd, tmp=tempfile.mkstemp(dir=d, suffix='.json')
with os.fdopen(fd,'w') as f:
    json.dump(manifest, f, indent=2)
os.replace(tmp, out)
" 2>/dev/null || true
        unset -f _loki_sha256 2>/dev/null || true
    } || true

    # ---- Short strings for the desktop notification --------------------------
    # Desktop body stays terse; full detail lives in COMPLETION.txt.
    _LOKI_SUMMARY_TITLE="$notify_title"
    _LOKI_SUMMARY_BODY="${outcome_label} on ${branch}: ${files_changed} files changed"
    if [ -n "$pr_url" ]; then
        _LOKI_SUMMARY_BODY="${_LOKI_SUMMARY_BODY}. PR: ${pr_url}"
    fi
    export _LOKI_SUMMARY_TITLE _LOKI_SUMMARY_BODY
    return 0
}

#===============================================================================
# emit_completion_summary <outcome> [urgency]
#
# The single entry point every terminal state calls. It ALWAYS writes the
# durable summary files (state, not a notification) and then fires ONE desktop
# notification gated by the existing LOKI_NOTIFICATIONS flag (send_notification
# already short-circuits when disabled, so the gate is implicit but explicit
# here for clarity). Centralizing this keeps the success-only PR side effect
# (Slice 3) in one place and prevents duplicate notifications.
#===============================================================================
emit_completion_summary() {
    local outcome="${1:-complete}"
    local urgency="${2:-normal}"
    build_completion_summary "$outcome"
    # Render the screenshot-worthy completion card inline on a foreground TTY run,
    # AFTER the durable files are written. The card re-reads the persisted
    # completion.json (never recomputes) so it can never diverge from the file.
    print_completion_card
    send_notification "${_LOKI_SUMMARY_TITLE:-Run finished}" "${_LOKI_SUMMARY_BODY:-}" "$urgency"
    return 0
}

#===============================================================================
# print_completion_card  (visible-delight completion card)
#
# Display-only. Renders a boxed summary card to the interactive TTY at the close
# of a foreground run: outcome, branch, files changed (+ins/-del), the live-app
# URL with a "try it" line when an app is running, the copy-pasteable review
# command, and the recorded-assumptions count. This is the single most
# screenshot-worthy moment of a run, which was previously only written to a file.
#
# It reads ONLY from the already-persisted .loki/state/completion.json (written
# by build_completion_summary just before this is called) and the app-runner
# state, so the card and the durable file are guaranteed identical. Nothing is
# computed or written here.
#
# Gate (same shape as the HUD at the colors block): interactive stdout, not
# --bg, and not opted out via LOKI_COMPLETION_CARD=0. Off-TTY / --bg / --json
# paths emit nothing, so machine output stays byte-identical. Wrapped so any
# internal failure still returns 0 and can never abort the completion path.
#===============================================================================
print_completion_card() {
    # Gate first: emit nothing unless interactive TTY, not background, not opted out.
    if ! { [ -t 1 ] && [ "${BACKGROUND_MODE:-false}" != "true" ] && [ "${LOKI_COMPLETION_CARD:-1}" != "0" ]; }; then
        return 0
    fi

    local loki_dir="${TARGET_DIR:-.}/.loki"
    local _cj="$loki_dir/state/completion.json"
    [ -f "$_cj" ] || return 0

    # Pull the fields we render straight from the persisted record. The python3
    # call emits one field per line in a fixed order; we read them line by line
    # so empty fields (e.g. no pr_url) keep their positions (a single delimiter
    # split would collapse adjacent empties). Any failure leaves the card
    # unrendered. Trailing-newline guard: NUL-free, fields are single-line.
    local _fields
    _fields="$(python3 -c "
import json,sys
try:
    d=json.load(open(sys.argv[1]))
except Exception:
    sys.exit(0)
def g(k):
    v=d.get(k,'')
    return '' if v is None else str(v).replace('\n',' ')
for k in ['outcome','branch','files_changed','insertions','deletions',
          'review_cmd','pr_url','assumptions_total','assumptions_high']:
    print(g(k))
" "$_cj" 2>/dev/null)" || return 0
    [ -z "$_fields" ] && return 0

    local _outcome _branch _files _ins _del _review _pr _atotal _ahigh
    {
        IFS= read -r _outcome
        IFS= read -r _branch
        IFS= read -r _files
        IFS= read -r _ins
        IFS= read -r _del
        IFS= read -r _review
        IFS= read -r _pr
        IFS= read -r _atotal
        IFS= read -r _ahigh
    } <<EOF
$_fields
EOF

    # Human outcome label (mirror build_completion_summary's mapping).
    local _label
    case "$_outcome" in
        complete)        _label="Completed" ;;
        max_iterations)  _label="Max iterations" ;;
        stopped)         _label="Stopped" ;;
        force_stopped)   _label="Stopped (not verified-complete)" ;;
        failed)          _label="Failed" ;;
        intervention)    _label="Needs input" ;;
        *)               _label="$_outcome" ;;
    esac

    # Live app URL (best-effort), same read as build_completion_summary.
    local _url="" _app_state="$loki_dir/app-runner/state.json"
    if [ -f "$_app_state" ]; then
        _url="$(python3 -c "import json,sys
try:
    d=json.load(open(sys.argv[1]))
    print(d.get('url','') if d.get('status')=='running' else '')
except Exception:
    print('')" "$_app_state" 2>/dev/null)" || _url=""
    fi

    # Box width matches log_header (66 inner columns). Render lines that fit;
    # this is decoration, so over-wide content is simply not boxed-truncated
    # (the durable file carries the full text).
    echo ""
    echo -e "${GREEN}+================================================================+${NC}"
    echo -e "${GREEN}|${NC} ${BOLD}Loki Mode: ${_label}${NC}"
    echo -e "${GREEN}|${NC}"
    if [ -n "$_url" ]; then
        echo -e "${GREEN}|${NC} ${BOLD}${CYAN}Your app is live at ${_url}${NC}  ${DIM}- open it to try it${NC}"
        echo -e "${GREEN}|${NC}"
    fi
    echo -e "${GREEN}|${NC} Branch: ${BOLD}${_branch}${NC}"
    echo -e "${GREEN}|${NC} Files:  ${BOLD}${_files}${NC} changed  ${GREEN}+${_ins}${NC} / ${RED}-${_del}${NC}"
    # Empty-diff guard (honest, not fake-green): a run that ended without
    # producing ANY file change is the classic confusing new-user outcome
    # ("it said done, but nothing happened"). Never imply success on a 0-file
    # run -- surface it plainly with one actionable next step. Only for the
    # non-failure outcomes (a "failed"/"stopped" box already explains itself).
    case "$_files" in
        ''|0)
            case "$_outcome" in
                complete|max_iterations)
                    echo -e "${GREEN}|${NC}"
                    echo -e "${GREEN}|${NC} ${YELLOW}No file changes were produced this run.${NC} Likely the spec was too vague"
                    echo -e "${GREEN}|${NC} or already satisfied. Next: re-run with a more concrete spec, e.g."
                    echo -e "${GREEN}|${NC}   ${BOLD}loki start \"<one concrete, testable change you want>\"${NC}"
                    echo -e "${GREEN}|${NC} or inspect what happened: ${BOLD}loki why${NC}"
                    ;;
            esac
            ;;
    esac
    case "$_atotal" in ''|0) : ;; *)
        echo -e "${GREEN}|${NC} Spec assumptions recorded: ${BOLD}${_atotal}${NC} (${_ahigh} high) ${DIM}see .loki/assumptions/ledger.md${NC}"
        ;;
    esac
    if [ -n "$_pr" ]; then
        echo -e "${GREEN}|${NC} Pull request: ${_pr}"
    fi
    echo -e "${GREEN}|${NC}"
    echo -e "${GREEN}|${NC} ${DIM}Review the work:${NC}"
    echo -e "${GREEN}|${NC}   ${_review}"
    echo -e "${GREEN}+================================================================+${NC}"
    echo ""
    return 0
}

#===============================================================================
# on_run_complete  (Slice 3: opt-in local git output on success)
#
# Called from every SUCCESS exit BEFORE emit_completion_summary so the PR url it
# discovers is folded into the summary. Default behavior is a no-op: it only
# acts when LOKI_DELEGATE_PR=1.
#
# LOKI_DELEGATE_PR=1 opens a LOCAL pull request from the user's machine, only if:
#   - this is a GitHub repo (gh + a github.com remote), AND
#   - `gh auth status` succeeds, AND
#   - the current branch is not main/master (never PR a default branch to itself)
# It mirrors the proven pattern at autonomy/loki:5524-5527: push the branch,
# then `gh pr create --head <branch>`. NO auto-merge. Every call is best-effort
# (`|| true`); failures never block completion. This is a single sanctioned
# local network call, never CI.
#
# Reconciliation with the existing GITHUB_PR path (run.sh create_github_pr,
# invoked after run_autonomous returns when LOKI_GITHUB_PR=true): if GITHUB_PR
# is already true we DEFER to that path and do nothing here, so a user who set
# both knobs never gets a double PR.
#===============================================================================
on_run_complete() {
    # Default OFF.
    if [ "${LOKI_DELEGATE_PR:-0}" != "1" ]; then
        return 0
    fi
    # Defer to the existing dedicated PR path to avoid a double PR.
    if [ "${GITHUB_PR:-false}" = "true" ]; then
        return 0
    fi
    # Network-call timeout guard: a stalled network / auth prompt would
    # otherwise hang the completion path indefinitely in --bg. Run each network
    # call through `timeout 30` when available; fall back to the bare call if
    # timeout is not installed (a local wrapper keeps this set -u safe on bash
    # 3.2, where an empty array expansion would error). Keeps every existing
    # `|| true` non-fatal behavior.
    _loki_net() {
        if command -v timeout >/dev/null 2>&1; then
            timeout 30 "$@"
        else
            "$@"
        fi
    }
    # Require gh + auth.
    if ! command -v gh >/dev/null 2>&1; then
        return 0
    fi
    if ! (cd "${TARGET_DIR:-.}" && _loki_net gh auth status) >/dev/null 2>&1; then
        return 0
    fi
    # Require a GitHub remote (skip silently on non-GitHub repos).
    local remote_url
    remote_url="$( (cd "${TARGET_DIR:-.}" && git config --get remote.origin.url) 2>/dev/null || true )"
    case "$remote_url" in
        *github.com*) : ;;
        *) return 0 ;;
    esac
    # Resolve current branch; never PR a default branch to itself.
    local branch
    branch="$( (cd "${TARGET_DIR:-.}" && git rev-parse --abbrev-ref HEAD) 2>/dev/null || true )"
    case "$branch" in
        ""|main|master|HEAD) return 0 ;;
    esac
    log_info "LOKI_DELEGATE_PR=1: opening a local pull request for branch '$branch'..."
    # Push, then create. Non-interactive (no tty in --bg). Best-effort, each
    # network call bounded by the timeout guard above.
    (cd "${TARGET_DIR:-.}" && _loki_net git push -u origin "$branch") >/dev/null 2>&1 || true
    local pr_title
    pr_title="Loki Mode: ${branch}"
    local pr_url=""
    # ENT-4 (idempotent PR): reuse an existing OPEN PR for this head instead of
    # attempting a second create on a platform retry / resume.
    local existing_pr
    existing_pr="$( (cd "${TARGET_DIR:-.}" && _loki_net gh pr list --head "$branch" --state open --json url --jq '.[0].url') 2>/dev/null || true )"
    if [ -n "$existing_pr" ]; then
        _LOKI_DELEGATE_PR_URL="$existing_pr"
        export _LOKI_DELEGATE_PR_URL
        log_info "LOKI_DELEGATE_PR=1: PR already exists for branch '$branch': $existing_pr (skipping create)."
        return 0
    fi
    # Proven PR (Loop 6): append the Evidence Receipt to the inline body.
    # Default-on; LOKI_PROVEN_PR=0 -> body byte-identical to before. This path
    # runs gh from a `cd "${TARGET_DIR:-.}"` subshell, so resolve the proof
    # relative to TARGET_DIR (not the bare-relative helper). Empty
    # expected_head_sha by design (R-DET-1 run_id pointer is the anti-stale guard).
    local _del_body="Opened by Loki Mode (delegate mode). Review locally before merge."
    if [ "${LOKI_PROVEN_PR:-1}" != "0" ] && declare -f render_evidence_receipt_md >/dev/null 2>&1; then
        local _del_loki="${TARGET_DIR:-.}/.loki"
        local _del_idfile="$_del_loki/state/last-proof-id.txt"
        if [ -s "$_del_idfile" ]; then
            local _del_rid=""
            _del_rid="$(cat "$_del_idfile" 2>/dev/null || true)"
            if [ -n "$_del_rid" ] && [ -f "$_del_loki/proofs/$_del_rid/proof.json" ]; then
                local _del_receipt=""
                _del_receipt="$(render_evidence_receipt_md "$_del_loki/proofs/$_del_rid/proof.json" "" "" 2>/dev/null || true)"
                if [ -n "$_del_receipt" ]; then
                    _del_body="${_del_body}

${_del_receipt}"
                fi
            fi
        fi
    fi
    pr_url="$( (cd "${TARGET_DIR:-.}" && _loki_net gh pr create --title "$pr_title" --body "$_del_body" --head "$branch") 2>/dev/null || true )"
    if [ -n "$pr_url" ]; then
        # Export so build_completion_summary folds the url into the summary.
        _LOKI_DELEGATE_PR_URL="$pr_url"
        export _LOKI_DELEGATE_PR_URL
        log_info "Pull request opened: $pr_url"
    else
        log_warn "LOKI_DELEGATE_PR=1: gh pr create did not return a URL (a PR may already exist for this branch)."
    fi
    return 0
}

#===============================================================================
# Parallel Workflow Functions (Git Worktrees)
#===============================================================================

# Production bridge for the Bun execution-manifest intelligence. The feature is
# explicitly opt-in; with LOKI_EXEC_MANIFEST unset this performs no I/O and the
# legacy parallel workflow remains byte-for-byte on its old path.
_loki_exec_manifest() {
    [ "${LOKI_EXEC_MANIFEST:-0}" = "1" ] || return 0
    bun "${SCRIPT_DIR}/../loki-ts/dist/loki.js" internal exec-manifest "$@"
}

init_exec_manifest() {
    [ "${LOKI_EXEC_MANIFEST:-0}" = "1" ] || return 0
    local base_sha plan_file
    # create_worktree's built-in streams branch from main (with HEAD only as its
    # final fallback), so the manifest must pin that exact production base.
    base_sha=$(git -C "$TARGET_DIR" rev-parse main 2>/dev/null) || \
        base_sha=$(git -C "$TARGET_DIR" rev-parse HEAD 2>/dev/null) || return 1
    mkdir -p "${TARGET_DIR}/.loki"
    plan_file=$(mktemp "${TARGET_DIR}/.loki/.exec-manifest-plan.XXXXXX") || return 1
    LOKI_PLAN_FILE="$plan_file" LOKI_BASE_SHA="$base_sha" \
      LOKI_PARALLEL_TESTING_VALUE="$PARALLEL_TESTING" \
      LOKI_PARALLEL_DOCS_VALUE="$PARALLEL_DOCS" \
      LOKI_PARALLEL_BLOG_VALUE="$PARALLEL_BLOG" python3 <<'PY'
import json, os
streams = []
if os.environ["LOKI_PARALLEL_TESTING_VALUE"] == "true":
    streams.append({"name": "testing", "paths": ["tests", "loki-ts/tests"],
                    "acceptance": "test stream exits successfully"})
if os.environ["LOKI_PARALLEL_DOCS_VALUE"] == "true":
    streams.append({"name": "docs", "paths": ["docs", "README.md", "CHANGELOG.md"],
                    "acceptance": "documentation stream exits successfully"})
if os.environ["LOKI_PARALLEL_BLOG_VALUE"] == "true":
    streams.append({"name": "blog", "paths": ["blog"],
                    "acceptance": "blog stream exits successfully"})
with open(os.environ["LOKI_PLAN_FILE"], "w") as f:
    json.dump({"baseSha": os.environ["LOKI_BASE_SHA"],
               "integrationOwner": "parallel-orchestrator",
               "streams": streams,
               "env": {"LOKI_EXEC_MANIFEST": "1"}}, f)
PY
    _loki_exec_manifest plan "$plan_file" "${TARGET_DIR}/.loki" >/dev/null
    local rc=$?
    rm -f "$plan_file"
    return "$rc"
}

validate_exec_manifest_result() {
    local stream_name="$1" branch="$2"
    [ "${LOKI_EXEC_MANIFEST:-0}" = "1" ] || return 0
    local base_sha result_file
    base_sha="${WORKTREE_BASE_SHAS[$stream_name]:-}"
    [ -n "$base_sha" ] || base_sha=$(git -C "$TARGET_DIR" merge-base "$branch" HEAD 2>/dev/null) || return 1
    result_file=$(mktemp "${TARGET_DIR}/.loki/.exec-manifest-result.XXXXXX") || return 1
    LOKI_RESULT_FILE="$result_file" LOKI_RESULT_STREAM="$stream_name" \
      LOKI_RESULT_BASE="$base_sha" LOKI_RESULT_BRANCH="$branch" \
      LOKI_RESULT_REPO="$TARGET_DIR" python3 <<'PY'
import json, os, subprocess
paths = subprocess.check_output(
    ["git", "-C", os.environ["LOKI_RESULT_REPO"], "diff", "--name-only",
     f'{os.environ["LOKI_RESULT_BASE"]}..{os.environ["LOKI_RESULT_BRANCH"]}'],
    text=True).splitlines()
with open(os.environ["LOKI_RESULT_FILE"], "w") as f:
    json.dump({"name": os.environ["LOKI_RESULT_STREAM"],
               "baseSha": os.environ["LOKI_RESULT_BASE"],
               "changedPaths": paths, "acceptanceMet": True}, f)
PY
    local outcome rc=0
    outcome=$(_loki_exec_manifest validate "$result_file" "${TARGET_DIR}/.loki" 2>&1) || rc=$?
    rm -f "$result_file"
    if [ "$rc" -ne 0 ]; then
        log_error "Execution manifest rejected $stream_name: $outcome"
        return 1
    fi
    log_info "Execution manifest accepted: $stream_name"
    return 0
}

# Check if parallel mode is supported (bash 4+ required for associative arrays)
check_parallel_support() {
    if [ "$BASH_VERSION_MAJOR" -lt 4 ] 2>/dev/null; then
        log_error "Parallel mode requires bash 4.0+ (current: $BASH_VERSION)"
        log_error "Parallel mode uses associative arrays which require bash 4+"
        log_error ""
        log_error "How to upgrade:"
        log_error "  macOS:  brew install bash && sudo chsh -s /opt/homebrew/bin/bash"
        log_error "  Ubuntu: sudo apt install bash"
        log_error "  WSL:    Usually has bash 4+ by default"
        log_error ""
        log_error "Or run without --parallel flag for sequential mode (works with bash 3.2+)"
        return 1
    fi
    return 0
}

# Create a worktree for a specific stream
create_worktree() {
    local stream_name="$1"
    local branch_name="${2:-}"
    local project_name=$(basename "$TARGET_DIR")
    local worktree_path="${TARGET_DIR}/../${project_name}-${stream_name}"

    if [ -d "$worktree_path" ]; then
        log_info "Worktree already exists: $stream_name"
        WORKTREE_PATHS[$stream_name]="$worktree_path"
        if [ "${LOKI_EXEC_MANIFEST:-0}" = "1" ]; then
            WORKTREE_BASE_SHAS[$stream_name]="$(python3 -c "import json; print(json.load(open('${TARGET_DIR}/.loki/manifest/exec-manifest.json'))['base_sha'])" 2>/dev/null)"
        fi
        return 0
    fi

    log_step "Creating worktree: $stream_name"

    local wt_exit=1
    if [ -n "$branch_name" ]; then
        # Create new branch
        git -C "$TARGET_DIR" worktree add "$worktree_path" -b "$branch_name" 2>/dev/null && wt_exit=0 || \
        { git -C "$TARGET_DIR" worktree add "$worktree_path" "$branch_name" 2>/dev/null && wt_exit=0; }
    else
        # BUG-PAR-001: Testing/docs worktrees use -b parallel-<stream> main (not bare main checkout)
        # This avoids "already checked out" errors and keeps each worktree on its own branch
        git -C "$TARGET_DIR" worktree add "$worktree_path" -b "parallel-${stream_name}" main 2>/dev/null && wt_exit=0 || \
        { git -C "$TARGET_DIR" worktree add "$worktree_path" "parallel-${stream_name}" 2>/dev/null && wt_exit=0; } || \
        { git -C "$TARGET_DIR" worktree add "$worktree_path" HEAD 2>/dev/null && wt_exit=0; }
    fi

    if [ $wt_exit -eq 0 ]; then
        WORKTREE_PATHS[$stream_name]="$worktree_path"
        WORKTREE_BASE_SHAS[$stream_name]="$(git -C "$worktree_path" rev-parse HEAD 2>/dev/null)"

        # Copy .loki state to worktree
        if [ -d "$TARGET_DIR/.loki" ]; then
            cp -r "$TARGET_DIR/.loki" "$worktree_path/" 2>/dev/null || true
        fi

        # Initialize environment (detect and run appropriate install)
        (
            cd "$worktree_path" || exit 1
            if [ -f "package.json" ]; then
                npm install --silent 2>/dev/null || true
            elif [ -f "requirements.txt" ]; then
                pip install -r requirements.txt -q 2>/dev/null || true
            elif [ -f "Cargo.toml" ]; then
                cargo build --quiet 2>/dev/null || true
            fi
        ) &
        # Capture install PID for cleanup on exit
        WORKTREE_INSTALL_PIDS+=($!)
        register_pid "$!" "worktree-install" "stream=$stream_name"

        log_info "Created worktree: $worktree_path"
        return 0
    else
        log_error "Failed to create worktree: $stream_name"
        # BUG-PU-001: Clean up partial worktree on creation failure
        if [ -d "$worktree_path" ]; then
            git -C "$TARGET_DIR" worktree remove "$worktree_path" --force 2>/dev/null || \
                rm -rf "$worktree_path" 2>/dev/null || true
        fi
        # Clean up any orphaned branch created during the attempt
        if [ -n "$branch_name" ]; then
            git -C "$TARGET_DIR" branch -D "$branch_name" 2>/dev/null || true
        else
            git -C "$TARGET_DIR" branch -D "parallel-${stream_name}" 2>/dev/null || true
        fi
        return 1
    fi
}

# Remove a worktree
remove_worktree() {
    local stream_name="$1"
    local worktree_path="${WORKTREE_PATHS[$stream_name]:-}"

    if [ -z "$worktree_path" ] || [ ! -d "$worktree_path" ]; then
        return 0
    fi

    log_step "Removing worktree: $stream_name"

    # Kill any running Claude session
    local pid="${WORKTREE_PIDS[$stream_name]:-}"
    if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
        kill "$pid" 2>/dev/null || true
        wait "$pid" 2>/dev/null || true
    fi

    # Remove worktree (with safety check for rm -rf)
    git -C "$TARGET_DIR" worktree remove "$worktree_path" --force 2>/dev/null || {
        # BUG-PAR-005: Safety check uses dirname with trailing / to prevent prefix-match false positives
        # e.g. TARGET_DIR=/foo/bar must not match /foo/bar-other
        local parent_dir
        parent_dir="$(dirname "$TARGET_DIR")/"
        if [[ -n "$worktree_path" && "$worktree_path" != "/" && "$worktree_path" == "${parent_dir}"* ]]; then
            rm -rf "$worktree_path" 2>/dev/null
        else
            log_warn "Skipping unsafe rm -rf for path: $worktree_path"
        fi
    }

    unset "WORKTREE_PATHS[$stream_name]"
    unset "WORKTREE_PIDS[$stream_name]"
    unset "WORKTREE_BASE_SHAS[$stream_name]"

    log_info "Removed worktree: $stream_name"
}

# Compute the effective parallel-session cap for the current scheduling pass.
# Default-off contract: when LOKI_DYNAMIC_CONCURRENCY is not "1" this echoes
# exactly MAX_PARALLEL_SESSIONS with zero file reads and zero subprocesses, so
# the spawn decision is byte-identical to the pre-feature behavior.
# When enabled, it starts from the configured ceiling and scales DOWN based on
# .loki/state/resources.json. All reads are best-effort: a missing, empty, or
# unparseable file (or non-numeric values) leaves the cap at the ceiling. The
# result is always clamped to the range [1, ceiling] and never exceeds it.
effective_session_cap() {
    # Fast default-off path: identical to today, no I/O, no subprocesses.
    if [ "${DYNAMIC_CONCURRENCY:-0}" != "1" ]; then
        echo "$MAX_PARALLEL_SESSIONS"
        return 0
    fi

    # Ceiling is the upper bound when dynamic scaling is on.
    local ceiling="${MAX_PARALLEL_SESSIONS_CEILING:-$MAX_PARALLEL_SESSIONS}"
    # Guard against a non-numeric or sub-1 ceiling override.
    case "$ceiling" in
        ''|*[!0-9]*) ceiling="$MAX_PARALLEL_SESSIONS" ;;
    esac
    [ "$ceiling" -lt 1 ] 2>/dev/null && ceiling=1

    local cap="$ceiling"
    local resources_file=".loki/state/resources.json"

    # No resource data -> best-effort, leave at ceiling.
    if [ ! -f "$resources_file" ]; then
        echo "$cap"
        return 0
    fi

    # Read usage and status best-effort. Defaults keep the cap at the ceiling
    # if the file is empty, malformed, or missing keys.
    local cpu_usage mem_usage status
    cpu_usage=$(python3 -c "import json; print(json.load(open('$resources_file')).get('cpu', {}).get('usage_percent', 0))" 2>/dev/null || echo "0")
    mem_usage=$(python3 -c "import json; print(json.load(open('$resources_file')).get('memory', {}).get('usage_percent', 0))" 2>/dev/null || echo "0")
    status=$(python3 -c "import json; print(json.load(open('$resources_file')).get('overall_status', 'ok'))" 2>/dev/null || echo "ok")

    # usage_percent can be a float (e.g. 85.3). Reduce to an integer part for
    # comparison and fall back to 0 if anything is non-numeric.
    cpu_usage="${cpu_usage%%.*}"
    mem_usage="${mem_usage%%.*}"
    case "$cpu_usage" in ''|*[!0-9]*) cpu_usage=0 ;; esac
    case "$mem_usage" in ''|*[!0-9]*) mem_usage=0 ;; esac

    local crit="${CONCURRENCY_CRITICAL_THRESHOLD:-95}"
    local cpu_thr="${CONCURRENCY_CPU_THRESHOLD:-85}"
    local mem_thr="${CONCURRENCY_MEM_THRESHOLD:-85}"

    if [ "$cpu_usage" -ge "$crit" ] || [ "$mem_usage" -ge "$crit" ]; then
        # Critical pressure: drop to a single session.
        cap=1
    elif [ "$cpu_usage" -ge "$cpu_thr" ] || [ "$mem_usage" -ge "$mem_thr" ] || [ "$status" != "ok" ]; then
        # Elevated pressure or a non-ok overall status: halve (integer floor).
        cap=$(( ceiling / 2 ))
    fi

    # Clamp to [1, ceiling]. Never runaway, never zero.
    [ "$cap" -lt 1 ] && cap=1
    [ "$cap" -gt "$ceiling" ] && cap="$ceiling"

    echo "$cap"
    return 0
}

# Auto-flag parity for parallel worktree Claude sessions (wave-5 fix).
# The main RARV loop applies adaptive cost/resilience flags to its claude
# invocation (run.sh:16007-16030 effort/max-budget/fallback) plus an MCP
# bundle, but the parallel worktree spawn shipped a bare invocation that missed
# them entirely. This populates the global _LOKI_WT_AUTO_FLAGS array with ONLY
# the flags that are safe on the plain (non stream-json) worktree invocation:
#   --effort           adaptive reasoning depth (dev tier)
#   --max-budget-usd   per-call hard backstop
#   --fallback-model   resilience to model overload/unavailability
#   --mcp-config       (+ --strict-mcp-config) the same MCP bundle the Bun
#                      route emits; a SUPERSET of the bash main loop, which does
#                      NOT emit --mcp-config (see ledger run.sh:15990-15996).
#                      Worktree dev streams benefit from the bundled MCP servers.
# We deliberately do NOT replicate the stream-json-coupled flags
# (--include-hook-events, --include-partial-messages) nor --output-format /
# --session-id: this invocation logs free-form text, not parsed stream-json, so
# those would be invalid or unwanted here. Each flag is gated on CLI support +
# its opt-out env var, matching the main loop. Extracted from spawn_worktree_session
# so it is unit-testable (tests/test-worktree-auto-flags.sh).
_loki_build_worktree_claude_flags() {
    _LOKI_WT_AUTO_FLAGS=()
    # Non-claude providers never receive these flags.
    if [ "${PROVIDER_NAME:-claude}" != "claude" ]; then
        return 0
    fi
    # Dev-tier model param drives the fallback derivation (worktree streams are
    # development work). Resolve via the provider helper when available.
    local _loki_wt_primary=""
    if type provider_get_tier_param >/dev/null 2>&1; then
        _loki_wt_primary="$(provider_get_tier_param development 2>/dev/null || true)"
    fi
    if [ "${LOKI_AUTO_EFFORT:-on}" != "off" ] \
       && type loki_effort_for_tier >/dev/null 2>&1 \
       && type loki_claude_flag_supported >/dev/null 2>&1 \
       && loki_claude_flag_supported "--effort"; then
        local _loki_wt_effort
        _loki_wt_effort="$(loki_effort_for_tier development "${DETECTED_COMPLEXITY:-${LOKI_COMPLEXITY:-standard}}")"
        [ -n "$_loki_wt_effort" ] && _LOKI_WT_AUTO_FLAGS+=("--effort" "$_loki_wt_effort")
    fi
    if [ "${LOKI_AUTO_BUDGET:-on}" != "off" ] \
       && type loki_remaining_budget >/dev/null 2>&1 \
       && type loki_claude_flag_supported >/dev/null 2>&1 \
       && loki_claude_flag_supported "--max-budget-usd"; then
        local _loki_wt_rem
        _loki_wt_rem="$(loki_remaining_budget)"
        [ -n "$_loki_wt_rem" ] && _LOKI_WT_AUTO_FLAGS+=("--max-budget-usd" "$_loki_wt_rem")
    fi
    if [ "${LOKI_AUTO_FALLBACK:-on}" != "off" ] \
       && [ -n "$_loki_wt_primary" ] \
       && type loki_fallback_for_primary >/dev/null 2>&1 \
       && type loki_claude_flag_supported >/dev/null 2>&1 \
       && loki_claude_flag_supported "--fallback-model"; then
        local _loki_wt_fb
        _loki_wt_fb="$(loki_fallback_for_primary "$_loki_wt_primary")"
        [ -n "$_loki_wt_fb" ] && _LOKI_WT_AUTO_FLAGS+=("--fallback-model" "$_loki_wt_fb")
    fi
    if type loki_mcp_config_argv >/dev/null 2>&1 \
       && type loki_claude_flag_supported >/dev/null 2>&1 \
       && loki_claude_flag_supported "--mcp-config"; then
        local _loki_wt_mcp
        if _loki_wt_mcp="$(loki_mcp_config_argv)" && [ -n "$_loki_wt_mcp" ]; then
            _LOKI_WT_AUTO_FLAGS+=("--mcp-config")
            local _loki_wt_mcp_path
            for _loki_wt_mcp_path in $_loki_wt_mcp; do
                _LOKI_WT_AUTO_FLAGS+=("$_loki_wt_mcp_path")
            done
            # --strict-mcp-config only alongside a real bundle, never bare.
            if [ "${LOKI_STRICT_MCP:-1}" != "0" ] \
               && loki_claude_flag_supported "--strict-mcp-config"; then
                _LOKI_WT_AUTO_FLAGS+=("--strict-mcp-config")
            fi
        fi
    fi
    return 0
}

# Spawn a Claude session in a worktree
spawn_worktree_session() {
    local stream_name="$1"
    local task_prompt="$2"
    local worktree_path="${WORKTREE_PATHS[$stream_name]:-}"

    if [ -z "$worktree_path" ] || [ ! -d "$worktree_path" ]; then
        log_error "Worktree not found: $stream_name"
        return 1
    fi

    # Check if session limit reached
    local active_count=0
    for pid in "${WORKTREE_PIDS[@]}"; do
        if kill -0 "$pid" 2>/dev/null; then
            ((active_count++))
        fi
    done

    local session_cap
    session_cap=$(effective_session_cap)
    if [ "$active_count" -ge "$session_cap" ]; then
        # BUG-PAR-014: Max-sessions rejection queues spawn for retry
        log_warn "Max parallel sessions reached ($session_cap). Queuing $stream_name for retry."
        mkdir -p "${TARGET_DIR:-.}/.loki/signals"
        echo "{\"stream\":\"$stream_name\",\"task\":\"$(echo "$task_prompt" | head -c 200)\",\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" \
            > "${TARGET_DIR:-.}/.loki/signals/SPAWN_QUEUED_${stream_name}"
        return 1
    fi

    local log_file="$worktree_path/.loki/logs/session-${stream_name}.log"
    mkdir -p "$(dirname "$log_file")"

    # Check provider parallel support
    if [ "${PROVIDER_HAS_PARALLEL:-false}" != "true" ]; then
        log_warn "Provider ${PROVIDER_NAME:-unknown} does not support parallel sessions"
        log_warn "Running sequentially instead (degraded mode)"
        return 1
    fi

    log_step "Spawning ${PROVIDER_DISPLAY_NAME:-Claude} session: $stream_name"

    # Build the worktree auto-flag set (effort/max-budget/fallback/mcp-config)
    # BEFORE the ( subshell so it is inherited (a `bash -c` would not). Populates
    # the global _LOKI_WT_AUTO_FLAGS array. See _loki_build_worktree_claude_flags.
    _loki_build_worktree_claude_flags

    (
        cd "$worktree_path" || exit 1
        _wt_exit=0
        # Provider-specific invocation for parallel sessions
        case "${PROVIDER_NAME:-claude}" in
            claude)
                # caveman ACTIVATION (free-form): a parallel worktree dev stream
                # is free-form generation; its output goes to a log, not a parsed
                # sentinel. Activate compression at the configured level when
                # warranted (empty -> bare invocation, byte-identical to before).
                # Type-guarded; inlined per-invocation (not exported).
                local _loki_wt_cm=""
                if type loki_caveman_activate_env >/dev/null 2>&1; then
                    _loki_wt_cm="$(loki_caveman_activate_env)"
                fi
                # Expand the auto-flag array with the bash-3.2 empty-array guard
                # (${arr[@]+...}) so a bare "${arr[@]}" under set -u does not
                # abort with "unbound variable" when no flags were collected.
                if [ -n "$_loki_wt_cm" ]; then
                    CAVEMAN_DEFAULT_MODE="$_loki_wt_cm" claude --dangerously-skip-permissions \
                        "${_LOKI_WT_AUTO_FLAGS[@]+"${_LOKI_WT_AUTO_FLAGS[@]}"}" \
                        -p "Loki Mode: $task_prompt. Read .loki/CONTINUITY.md for context." \
                        >> "$log_file" 2>&1 || _wt_exit=$?
                else
                    claude --dangerously-skip-permissions \
                        "${_LOKI_WT_AUTO_FLAGS[@]+"${_LOKI_WT_AUTO_FLAGS[@]}"}" \
                        -p "Loki Mode: $task_prompt. Read .loki/CONTINUITY.md for context." \
                        >> "$log_file" 2>&1 || _wt_exit=$?
                fi
                ;;
            codex)
                codex exec --sandbox workspace-write --skip-git-repo-check \
                    "Loki Mode: $task_prompt. Read .loki/CONTINUITY.md for context." \
                    >> "$log_file" 2>&1 || _wt_exit=$?
                ;;
            cline)
                invoke_cline "Loki Mode: $task_prompt. Read .loki/CONTINUITY.md for context." \
                    >> "$log_file" 2>&1 || _wt_exit=$?
                ;;
            aider)
                log_warn "Aider does not support parallel sessions, skipping"
                _wt_exit=1
                ;;
            *)
                log_error "Unknown provider: ${PROVIDER_NAME}"
                _wt_exit=1
                ;;
        esac

        # Completion signaling (v6.7.0)
        if [ $_wt_exit -eq 0 ]; then
            # BUG-PAR-006: git add excludes .env, *.key, *.pem, credentials*
            git -C "$worktree_path" add -A \
                ':!.env' ':!*.key' ':!*.pem' ':!credentials*' 2>/dev/null
            git -C "$worktree_path" commit -m "feat($stream_name): worktree work complete" 2>/dev/null || true
            # BUG-PAR-008: Signal files written atomically (temp + mv)
            mkdir -p "${TARGET_DIR:-.}/.loki/signals"
            local _sig_tmp
            _sig_tmp=$(mktemp "${TARGET_DIR:-.}/.loki/signals/.tmp.XXXXXX") || true
            cat > "$_sig_tmp" <<EOSIG
{"stream":"$stream_name","branch":"$(git -C "$worktree_path" branch --show-current 2>/dev/null)","worktree":"$worktree_path","timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","exit_code":$_wt_exit}
EOSIG
            mv "$_sig_tmp" "${TARGET_DIR:-.}/.loki/signals/MERGE_REQUESTED_${stream_name}" 2>/dev/null || \
                cp "$_sig_tmp" "${TARGET_DIR:-.}/.loki/signals/MERGE_REQUESTED_${stream_name}" 2>/dev/null
            rm -f "$_sig_tmp" 2>/dev/null
            echo "WORKTREE_COMPLETE: $stream_name" >> "$log_file"
        else
            # BUG-PAR-008: Signal files written atomically (temp + mv)
            mkdir -p "${TARGET_DIR:-.}/.loki/signals"
            local _fail_tmp
            _fail_tmp=$(mktemp "${TARGET_DIR:-.}/.loki/signals/.tmp.XXXXXX") || true
            echo "{\"stream\":\"$stream_name\",\"status\":\"failed\",\"exit_code\":$_wt_exit,\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" \
                > "$_fail_tmp"
            mv "$_fail_tmp" "${TARGET_DIR:-.}/.loki/signals/WORKTREE_FAILED_${stream_name}" 2>/dev/null || \
                cp "$_fail_tmp" "${TARGET_DIR:-.}/.loki/signals/WORKTREE_FAILED_${stream_name}" 2>/dev/null
            rm -f "$_fail_tmp" 2>/dev/null
        fi
    ) &

    local pid=$!
    WORKTREE_PIDS[$stream_name]=$pid
    register_pid "$pid" "worktree-session" "stream=$stream_name"

    log_info "Session spawned: $stream_name (PID: $pid)"
    return 0
}

# Merge a completed worktree back to main branch (v6.7.0)
# Usage: merge_worktree <stream_name>
merge_worktree() {
    local stream_name="$1"
    local signal_file="${TARGET_DIR:-.}/.loki/signals/MERGE_REQUESTED_${stream_name}"

    if [ ! -f "$signal_file" ]; then
        log_error "No merge signal found for: $stream_name"
        return 1
    fi

    # BUG-PAR-013: Signal file parsing falls back to jq when python3 unavailable
    local branch worktree_path
    branch=$(python3 -c "import json; print(json.load(open('$signal_file'))['branch'])" 2>/dev/null) || \
        branch=$(jq -r '.branch' "$signal_file" 2>/dev/null) || true
    worktree_path=$(python3 -c "import json; print(json.load(open('$signal_file'))['worktree'])" 2>/dev/null) || \
        worktree_path=$(jq -r '.worktree' "$signal_file" 2>/dev/null) || true

    if [ -z "$branch" ]; then
        log_error "Could not determine branch for: $stream_name"
        return 1
    fi

    log_step "Merging worktree: $stream_name (branch: $branch)"

    # Validate the actual branch result at the integration seam, immediately
    # before any checkout or merge can mutate the integration tree.
    if ! validate_exec_manifest_result "$stream_name" "$branch"; then
        return 1
    fi

    # BUG-PAR-009: Verify git checkout main before merge
    local current_branch
    current_branch=$(git -C "${TARGET_DIR:-.}" branch --show-current 2>/dev/null)
    if [ "$current_branch" != "main" ]; then
        log_info "Switching to main before merge (was on: $current_branch)"
        if ! git -C "${TARGET_DIR:-.}" checkout main 2>/dev/null; then
            log_error "Failed to checkout main for merge: $stream_name"
            return 1
        fi
    fi

    if git -C "${TARGET_DIR:-.}" merge --no-ff "$branch" -m "merge($stream_name): auto-merge from parallel worktree" 2>&1; then
        log_info "Merge successful: $stream_name"
        # Clean up signal and worktree
        rm -f "$signal_file"
        if [ -n "$worktree_path" ] && [ -d "$worktree_path" ]; then
            git -C "${TARGET_DIR:-.}" worktree remove "$worktree_path" --force 2>/dev/null || true
            git -C "${TARGET_DIR:-.}" branch -d "$branch" 2>/dev/null || true
        fi
        return 0
    else
        log_error "Merge conflict for $stream_name - manual resolution needed"
        git -C "${TARGET_DIR:-.}" merge --abort 2>/dev/null || true
        return 1
    fi
}

# Check and process all pending merge signals (v6.7.0)
process_pending_merges() {
    local signals_dir="${TARGET_DIR:-.}/.loki/signals"
    local merged=0
    local failed=0

    for signal_file in "$signals_dir"/MERGE_REQUESTED_*; do
        case "$signal_file" in *.ack) continue ;; esac
        [ -f "$signal_file" ] || continue
        local stream_name
        stream_name=$(basename "$signal_file" | sed 's/MERGE_REQUESTED_//')
        if merge_worktree "$stream_name"; then
            ((merged++))
        else
            ((failed++))
        fi
    done

    if [ $merged -gt 0 ] || [ $failed -gt 0 ]; then
        log_info "Merge results: $merged successful, $failed failed"
    fi
}

# List all active worktrees
list_worktrees() {
    log_header "Active Worktrees"

    git -C "$TARGET_DIR" worktree list 2>/dev/null

    echo ""
    log_info "Tracked sessions:"
    for stream in "${!WORKTREE_PIDS[@]}"; do
        local pid="${WORKTREE_PIDS[$stream]}"
        local status="stopped"
        if kill -0 "$pid" 2>/dev/null; then
            status="running"
        fi
        echo "  [$stream] PID: $pid - $status"
    done
}

# Check for completed features ready to merge
check_merge_queue() {
    local signals_dir="$TARGET_DIR/.loki/signals"

    if [ ! -d "$signals_dir" ]; then
        return 0
    fi

    for signal in "$signals_dir"/MERGE_REQUESTED_*; do
        # Skip our own acknowledgement markers (*.ack) so they are not treated
        # as signals themselves.
        case "$signal" in *.ack) continue ;; esac
        if [ -f "$signal" ]; then
            local feature=$(basename "$signal" | sed 's/MERGE_REQUESTED_//')

            if [ "$AUTO_MERGE" = "true" ]; then
                # Auto-merge consumes the signal (merge_feature rm's it), so the
                # log line fires exactly once per completed feature.
                log_info "Merge requested: $feature"
                merge_feature "$feature"
            else
                # Auto-merge is OFF (e.g. `--pr` without `--ship`): the PR is left
                # for a human to merge, so the signal is INTENTIONALLY not consumed.
                # Log ONCE (guarded by a sibling .ack marker) instead of re-logging
                # the same "Merge requested" line every orchestrator pass -- that
                # re-log was a 90-minute "looks stuck" spam while real work was
                # already done. The signal itself is preserved untouched.
                if [ ! -f "${signal}.ack" ]; then
                    log_info "Merge requested: $feature (PR ready; auto-merge off -- merge the PR, or use --ship to auto-merge)"
                    : > "${signal}.ack" 2>/dev/null || true
                fi
            fi
        fi
    done
}

# AI-powered conflict resolution (inspired by Auto-Claude)
resolve_conflicts_with_ai() {
    local feature="$1"
    local conflict_files=$(git diff --name-only --diff-filter=U 2>/dev/null)

    if [ -z "$conflict_files" ]; then
        return 0
    fi

    log_step "AI-powered conflict resolution for: $feature"

    for file in $conflict_files; do
        log_info "Resolving conflicts in: $file"

        # Get conflict markers
        local conflict_content=$(cat "$file")

        # Use AI to resolve conflict (provider-aware)
        local resolution=""
        local conflict_prompt="You are resolving a git merge conflict. The file below contains conflict markers.
Your task is to merge both changes intelligently, preserving functionality from both sides.

FILE: $file
CONTENT:
$conflict_content

Output ONLY the resolved file content with no conflict markers. No explanations."

        case "${PROVIDER_NAME:-claude}" in
            claude)
                # EMBED 2 (v7.33.0): --bare on this cheap NON-MAIN subcall.
                # Reasoning: $conflict_prompt is fully self-contained -- it
                # carries the complete instruction set AND the entire conflicted
                # file content inline, and the agent's output is captured to a
                # variable (the shell, not the agent, writes the resolved file).
                # It needs no hooks, LSP, CLAUDE.md auto-discovery, or MCP, so
                # --bare is safe and cheaper. Gated + opt-out LOKI_BARE_SUBCALLS=0.
                local _cr_argv=("--dangerously-skip-permissions")
                if type loki_subcall_bare_enabled >/dev/null 2>&1 && loki_subcall_bare_enabled; then
                    _cr_argv+=("--bare")
                fi
                # caveman HARD-SUPPRESS (parsed output): the output is captured as
                # the EXACT resolved file content (the shell writes it verbatim).
                # Compressing prose into the merged source would corrupt the file,
                # so disable caveman unconditionally here. No-op when absent.
                resolution=$(CAVEMAN_DEFAULT_MODE=off claude "${_cr_argv[@]}" -p "$conflict_prompt" --output-format text 2>/dev/null)
                ;;
            codex)
                resolution=$(codex exec --sandbox workspace-write --skip-git-repo-check "$conflict_prompt" 2>/dev/null)
                ;;
            cline)
                resolution=$(invoke_cline_capture "$conflict_prompt" 2>/dev/null)
                ;;
            aider)
                resolution=$(invoke_aider_capture "$conflict_prompt" 2>/dev/null)
                ;;
            *)
                log_error "Unknown provider: ${PROVIDER_NAME}"
                return 1
                ;;
        esac

        if [ -n "$resolution" ]; then
            echo "$resolution" > "$file"
            git add "$file"
            log_info "Resolved: $file"
        else
            log_error "AI resolution failed for: $file"
            return 1
        fi
    done

    return 0
}

# Merge a completed feature branch (with AI conflict resolution)
# BUG-PAR-011: Not in a subshell -- uses git -C instead of cd
# BUG-PAR-003: Strips feature- prefix to avoid feature/feature-auth double-prefix
merge_feature() {
    local feature="$1"
    # BUG-PAR-003: Strip feature- prefix if present to avoid double-prefix (feature/feature-auth)
    local clean_feature="${feature#feature-}"
    local branch="feature/$clean_feature"

    # The session signal is authoritative for worktree branches (for example
    # parallel-testing). The historical feature/<name> convention remains the
    # fallback for legacy feature streams.
    local _mf_signal="$TARGET_DIR/.loki/signals/MERGE_REQUESTED_$feature"
    if [ -f "$_mf_signal" ]; then
        local _mf_branch=""
        _mf_branch=$(LOKI_SIGNAL_FILE="$_mf_signal" python3 -c \
            "import json,os; print(json.load(open(os.environ['LOKI_SIGNAL_FILE'])).get('branch',''))" 2>/dev/null || true)
        [ -n "$_mf_branch" ] && branch="$_mf_branch"
    fi

    log_step "Merging feature: $clean_feature"

    # This is the autonomous orchestrator's real integration seam. Reject a
    # stale, out-of-scope, unknown, or unsuccessful stream before checkout/merge.
    if ! validate_exec_manifest_result "$feature" "$branch"; then
        return 1
    fi

    # BUG-PAR-011: Ensure we're on main using git -C (no subshell)
    git -C "$TARGET_DIR" checkout main 2>/dev/null

    # Attempt merge with no-ff for clear history
    if git -C "$TARGET_DIR" merge "$branch" --no-ff -m "feat: Merge $clean_feature" 2>/dev/null; then
        log_info "Merged cleanly: $clean_feature"
    else
        # Merge has conflicts - try AI resolution
        log_warn "Merge conflicts detected - attempting AI resolution"

        if resolve_conflicts_with_ai "$clean_feature"; then
            # AI resolved conflicts, commit the merge
            git -C "$TARGET_DIR" commit -m "feat: Merge $clean_feature (AI-resolved conflicts)"
            audit_agent_action "git_commit" "Committed changes" "merge=$clean_feature,resolution=ai"
            log_info "Merged with AI conflict resolution: $clean_feature"
        else
            # AI resolution failed, abort merge
            log_error "AI conflict resolution failed: $clean_feature"
            git -C "$TARGET_DIR" merge --abort 2>/dev/null || true
            return 1
        fi
    fi

    # Remove signal (and its log-once .ack marker, if any)
    rm -f "$TARGET_DIR/.loki/signals/MERGE_REQUESTED_$feature" \
          "$TARGET_DIR/.loki/signals/MERGE_REQUESTED_$feature.ack"

    # Remove worktree
    remove_worktree "feature-$clean_feature"

    # Delete branch
    git -C "$TARGET_DIR" branch -d "$branch" 2>/dev/null || true

    # DOCS_NEEDED signal: triggers the parallel docs worktree to run `loki docs update`
    # and regenerate documentation for recently changed files.
    mkdir -p "$TARGET_DIR/.loki/signals"
    touch "$TARGET_DIR/.loki/signals/DOCS_NEEDED"
}

# Initialize parallel workflow streams
init_parallel_streams() {
    # Check bash version
    if ! check_parallel_support; then
        return 1
    fi

    log_header "Initializing Parallel Workflows"

    if ! init_exec_manifest; then
        log_error "Failed to initialize execution manifest"
        return 1
    fi

    local active_streams=0

    # Create testing worktree (always tracks main)
    if [ "$PARALLEL_TESTING" = "true" ]; then
        create_worktree "testing"
        ((active_streams++))
    fi

    # Create documentation worktree
    if [ "$PARALLEL_DOCS" = "true" ]; then
        create_worktree "docs"
        ((active_streams++))
    fi

    # Create blog worktree if enabled
    if [ "$PARALLEL_BLOG" = "true" ]; then
        create_worktree "blog"
        ((active_streams++))
    fi

    log_info "Initialized $active_streams parallel streams"
    list_worktrees
}

# Spawn feature worktree from task
# Cleanup all worktrees on exit
cleanup_parallel_streams() {
    log_header "Cleaning Up Parallel Streams"

    # Kill background install processes
    for pid in "${WORKTREE_INSTALL_PIDS[@]}"; do
        if kill -0 "$pid" 2>/dev/null; then
            kill "$pid" 2>/dev/null || true
        fi
        unregister_pid "$pid"
    done
    WORKTREE_INSTALL_PIDS=()

    # Kill all sessions
    for stream in "${!WORKTREE_PIDS[@]}"; do
        local pid="${WORKTREE_PIDS[$stream]}"
        if kill -0 "$pid" 2>/dev/null; then
            log_step "Stopping session: $stream"
            kill "$pid" 2>/dev/null || true
        fi
        unregister_pid "$pid"
    done

    # Wait for all to finish
    wait 2>/dev/null || true

    # Optionally remove worktrees (keep by default for inspection)
    # Uncomment to auto-cleanup:
    # for stream in "${!WORKTREE_PATHS[@]}"; do
    #     remove_worktree "$stream"
    # done

    log_info "Parallel streams stopped"
}

# Orchestrator loop for parallel mode
run_parallel_orchestrator() {
    log_header "Parallel Orchestrator Started"

    # Initialize streams - exit if bash version is too old
    if ! init_parallel_streams; then
        log_error "Failed to initialize parallel streams"
        log_error "Falling back to sequential mode"
        PARALLEL_MODE=false
        return 1
    fi

    # Spawn testing session
    if [ "$PARALLEL_TESTING" = "true" ] && [ -n "${WORKTREE_PATHS[testing]:-}" ]; then
        spawn_worktree_session "testing" "Run all tests continuously. Watch for changes. Report failures to .loki/state/test-results.json"
    fi

    # Spawn docs session
    if [ "$PARALLEL_DOCS" = "true" ] && [ -n "${WORKTREE_PATHS[docs]:-}" ]; then
        spawn_worktree_session "docs" "Documentation maintenance stream. Steps: 1) Run 'loki docs generate' if .loki/docs/ does not exist. 2) Watch for .loki/signals/DOCS_NEEDED file. When found, run 'loki docs update' and remove the signal file. 3) After each doc update, run 'loki docs check' and report coverage. 4) Focus on documenting new files, changed APIs, and architectural decisions."
    fi

    # Main orchestrator loop
    local running=true
    # BUG-PAR-004: Orchestrator trap handles SIGTERM properly (cleanup + restore global trap + exit)
    trap 'running=false; cleanup_parallel_streams; trap cleanup INT TERM; exit 0' TERM
    trap 'running=false; cleanup_parallel_streams' INT

    while $running; do
        # Check for merge requests
        check_merge_queue

        # BUG-PAR-014: Retry queued spawns when sessions free up
        local active_count=0
        for _qpid in "${WORKTREE_PIDS[@]}"; do
            if kill -0 "$_qpid" 2>/dev/null; then
                ((active_count++))
            fi
        done
        local _session_cap
        _session_cap=$(effective_session_cap)
        if [ "$active_count" -lt "$_session_cap" ]; then
            for queued_signal in "${TARGET_DIR:-.}"/.loki/signals/SPAWN_QUEUED_*; do
                [ -f "$queued_signal" ] || continue
                local queued_stream
                queued_stream=$(basename "$queued_signal" | sed 's/SPAWN_QUEUED_//')
                local queued_task=""
                queued_task=$(python3 -c "import json; print(json.load(open('$queued_signal'))['task'])" 2>/dev/null) || \
                    queued_task=$(jq -r '.task' "$queued_signal" 2>/dev/null) || true
                if [ -n "$queued_task" ] && [ -n "${WORKTREE_PATHS[$queued_stream]:-}" ]; then
                    rm -f "$queued_signal"
                    spawn_worktree_session "$queued_stream" "$queued_task" && \
                        log_info "Retried queued spawn: $queued_stream"
                fi
            done
        fi

        # Check session health
        for stream in "${!WORKTREE_PIDS[@]}"; do
            local pid="${WORKTREE_PIDS[$stream]}"
            if ! kill -0 "$pid" 2>/dev/null; then
                log_warn "Session ended: $stream"
                unset "WORKTREE_PIDS[$stream]"
            fi
        done

        # Update orchestrator state
        local state_file="$TARGET_DIR/.loki/state/parallel-streams.json"
        mkdir -p "$(dirname "$state_file")"

        # BUG-PAR-007: Empty worktree map produces valid JSON
        local worktree_json=""
        if [ ${#WORKTREE_PATHS[@]} -gt 0 ]; then
            worktree_json=$(for stream in "${!WORKTREE_PATHS[@]}"; do
                local path="${WORKTREE_PATHS[$stream]}"
                local pid="null"
                if [ -n "${WORKTREE_PIDS[$stream]+x}" ]; then
                    pid="${WORKTREE_PIDS[$stream]}"
                fi
                local status="stopped"
                if [ "$pid" != "null" ] && kill -0 "$pid" 2>/dev/null; then
                    status="running"
                fi
                echo "    \"$stream\": {\"path\": \"$path\", \"pid\": $pid, \"status\": \"$status\"},"
            done | sed '$ s/,$//')
        fi

        cat > "$state_file" << EOF
{
  "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
  "worktrees": {
${worktree_json}
  },
  "active_sessions": ${#WORKTREE_PIDS[@]},
  "max_sessions": $MAX_PARALLEL_SESSIONS
}
EOF

        sleep 30
    done
}

#===============================================================================
# Prerequisites Check
#===============================================================================

check_prerequisites() {
    log_header "Checking Prerequisites"

    local missing=()

    # Check Provider CLI (uses PROVIDER_CLI from loaded provider config)
    local cli_name="${PROVIDER_CLI:-claude}"
    local display_name="${PROVIDER_DISPLAY_NAME:-Claude Code}"
    log_step "Checking $display_name CLI..."
    if command -v "$cli_name" &> /dev/null; then
        local version=$("$cli_name" --version 2>/dev/null | head -1 || echo "unknown")
        log_info "$display_name CLI: $version"
    else
        missing+=("$cli_name")
        log_error "$display_name CLI not found"
        case "$cli_name" in
            claude)
                log_info "Install: https://claude.ai/code or npm install -g @anthropic-ai/claude-code"
                ;;
            codex)
                log_info "Install: npm install -g @openai/codex"
                ;;
            cline)
                log_info "Install: npm install -g cline"
                ;;
            aider)
                log_info "Install: pip install aider-chat"
                ;;
            *)
                log_info "Install the $cli_name CLI for your provider"
                ;;
        esac
    fi

    # Check Python 3
    log_step "Checking Python 3..."
    if command -v python3 &> /dev/null; then
        local py_version=$(python3 --version 2>&1)
        log_info "Python: $py_version"
    else
        missing+=("python3")
        log_error "Python 3 not found"
    fi

    # Check Git
    log_step "Checking Git..."
    if command -v git &> /dev/null; then
        local git_version=$(git --version)
        log_info "Git: $git_version"
    else
        missing+=("git")
        log_error "Git not found"
    fi

    # Check Node.js (optional but recommended)
    log_step "Checking Node.js (optional)..."
    if command -v node &> /dev/null; then
        local node_version=""
        if node_version=$(node --version 2>/dev/null); then
            _LOKI_NODE_PROBE_STATUS="available"
            _LOKI_NODE_PROBE_VERSION="$node_version"
            log_info "Node.js: $node_version"
        else
            _LOKI_NODE_PROBE_STATUS="probe_failed"
            log_warn "Node.js executable found, but its version probe failed"
        fi
    else
        _LOKI_NODE_PROBE_STATUS="missing"
        log_warn "Node.js not found (optional, needed for some builds)"
    fi

    # Check npm (optional)
    if command -v npm &> /dev/null; then
        local npm_version=""
        if npm_version=$(npm --version 2>/dev/null); then
            _LOKI_NPM_PROBE_STATUS="available"
            _LOKI_NPM_PROBE_VERSION="$npm_version"
            log_info "npm: $npm_version"
        else
            _LOKI_NPM_PROBE_STATUS="probe_failed"
            log_warn "npm executable found, but its version probe failed"
        fi
    else
        _LOKI_NPM_PROBE_STATUS="missing"
    fi

    # Check curl (for web fetches)
    log_step "Checking curl..."
    if command -v curl &> /dev/null; then
        log_info "curl: available"
    else
        missing+=("curl")
        log_error "curl not found"
    fi

    # Check jq (optional but helpful)
    log_step "Checking jq (optional)..."
    if command -v jq &> /dev/null; then
        log_info "jq: available"
    else
        log_warn "jq not found (optional, for JSON parsing)"
    fi

    # Summary
    echo ""
    if [ ${#missing[@]} -gt 0 ]; then
        log_error "Missing required tools: ${missing[*]}"
        log_info "Please install the missing tools and try again."
        return 1
    else
        log_info "All required prerequisites are installed!"
        return 0
    fi
}

#===============================================================================
# Skill Installation Check
#===============================================================================

check_skill_installed() {
    log_header "Checking Loki Mode Skill"

    # Build skill locations array dynamically based on provider
    local skill_locations=()

    # Add provider-specific skill directory if set (e.g., ~/.claude/skills for Claude)
    if [ -n "${PROVIDER_SKILL_DIR:-}" ]; then
        skill_locations+=("${PROVIDER_SKILL_DIR}/loki-mode/SKILL.md")
    fi

    # Add local project skill locations
    skill_locations+=(
        ".claude/skills/loki-mode/SKILL.md"
        "$PROJECT_DIR/SKILL.md"
    )

    for loc in "${skill_locations[@]}"; do
        if [ -f "$loc" ]; then
            log_info "Skill found: $loc"
            return 0
        fi
    done

    # For providers without skill system (Codex, Aider), this is expected
    if [ -z "${PROVIDER_SKILL_DIR:-}" ]; then
        log_info "Provider ${PROVIDER_NAME:-unknown} has no native skill directory"
        log_info "Skill will be passed via prompt injection"
    else
        log_warn "Loki Mode skill not found in standard locations"
    fi

    log_info "The skill will be used from: $PROJECT_DIR/SKILL.md"

    if [ -f "$PROJECT_DIR/SKILL.md" ]; then
        log_info "Using skill from project directory"
        return 0
    else
        log_error "SKILL.md not found!"
        return 1
    fi
}

#===============================================================================
# Initialize Loki Directory
#===============================================================================

init_loki_dir() {
    log_header "Initializing Loki Mode Directory"

    # Clean up stale control files ONLY if no other session is running.
    # Deleting these while another session is active would destroy its signals.
    #
    # v7.5.12: PID-liveness probe replaces flock-based "is the lock held?"
    # check. The mkdir-mutex used by safe_acquire_lock is not introspectable
    # the same way (no FD to non-blocking-poll), but the PID file is the
    # source of truth for liveness anyway -- a stale lockdir without a
    # live owner means the session is gone, so cleanup is safe.
    #
    # Per-session locking (v6.4.0): When LOKI_SESSION_ID is set, only clean up
    # that session's files. Global control files (PAUSE/STOP) are only cleaned
    # when NO sessions are active.
    local lock_file can_cleanup=false

    if [ -n "${LOKI_SESSION_ID:-}" ]; then
        # Per-session: PID-liveness probe
        lock_file=".loki/sessions/${LOKI_SESSION_ID}/session.lock"
        local session_pid_file=".loki/sessions/${LOKI_SESSION_ID}/loki.pid"
        local existing_pid=""
        if [ -f "$session_pid_file" ]; then
            existing_pid=$(cat "$session_pid_file" 2>/dev/null)
        fi
        if [ -z "$existing_pid" ] || ! kill -0 "$existing_pid" 2>/dev/null; then
            can_cleanup=true
        fi
        if [ "$can_cleanup" = "true" ]; then
            rm -f "$session_pid_file" 2>/dev/null
            rm -f "$lock_file" 2>/dev/null
            rm -rf "${lock_file}.lockdir" 2>/dev/null
        fi
    else
        # Global: PID-liveness probe
        lock_file=".loki/session.lock"
        local existing_pid=""
        if [ -f ".loki/loki.pid" ]; then
            existing_pid=$(cat ".loki/loki.pid" 2>/dev/null)
        fi
        if [ -z "$existing_pid" ] || ! kill -0 "$existing_pid" 2>/dev/null; then
            can_cleanup=true
        fi
        if [ "$can_cleanup" = "true" ]; then
            # v7.4.16: extended stale-signal cleanup. Pre-v7.4.16 only
            # PAUSE / STOP / HUMAN_INPUT.md were cleaned -- but
            # PAUSE_AT_CHECKPOINT, PAUSED.md, and COMPLETED were added
            # to the signal-file family later without updating this
            # cleanup. A stale PAUSE_AT_CHECKPOINT from a prior session
            # (created by Ctrl+C in checkpoint mode) caused fresh
            # `loki start` to pause immediately when PRD-driven mode
            # auto-switched to checkpoint. User-reported regression.
            rm -f .loki/PAUSE .loki/STOP .loki/HUMAN_INPUT.md 2>/dev/null
            rm -f .loki/PAUSE_AT_CHECKPOINT .loki/PAUSED.md .loki/COMPLETED 2>/dev/null
            rm -f .loki/loki.pid 2>/dev/null
            rm -f .loki/session.lock 2>/dev/null
            rm -rf .loki/session.lock.lockdir 2>/dev/null
        fi
    fi

    mkdir -p .loki/{state,queue,messages,logs,config,prompts,artifacts,scripts}
    mkdir -p .loki/queue
    mkdir -p .loki/state/checkpoints
    mkdir -p .loki/artifacts/{releases,reports,backups}
    mkdir -p .loki/memory/{ledgers,handoffs,learnings,episodic,semantic,skills}
    # metrics/rewards was created and emptied every run and NOTHING ever wrote
    # or read it: the only reference in the whole codebase was the rm -f below.
    # A directory that exists only to be deleted is not a feature, and
    # documenting it as one told the agent something false.
    mkdir -p .loki/metrics/efficiency
    # Clear stale metrics from previous sessions so loki metrics shows current run data (#75)
    rm -f .loki/metrics/efficiency/iteration-*.json 2>/dev/null || true
    mkdir -p .loki/rules
    mkdir -p .loki/signals

    # BUG-XC-008: Initialize queue files only if missing or invalid JSON
    for queue in pending in-progress completed failed dead-letter; do
        local qfile=".loki/queue/${queue}.json"
        if [ ! -f "$qfile" ] || ! python3 -c "import json; json.load(open('$qfile'))" 2>/dev/null; then
            echo "[]" > "$qfile"
        fi
    done

    # Initialize orchestrator state if it doesn't exist
    if [ ! -f ".loki/state/orchestrator.json" ]; then
        cat > ".loki/state/orchestrator.json" << EOF
{
    "version": "$(cat "$PROJECT_DIR/VERSION" 2>/dev/null || echo "2.2.0")",
    "currentPhase": "BOOTSTRAP",
    "startedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
    "agents": {},
    "metrics": {
        "tasksCompleted": 0,
        "tasksFailed": 0,
        "retries": 0
    }
}
EOF
    fi

    # Write pricing.json with provider-specific model rates
    _write_pricing_json

    # Write budget.json if a budget limit is configured
    if [ -n "$BUDGET_LIMIT" ]; then
        # Validate budget limit is numeric before writing JSON
        if ! echo "$BUDGET_LIMIT" | grep -qE '^[0-9]+(\.[0-9]+)?$'; then
            log_warn "Invalid BUDGET_LIMIT '$BUDGET_LIMIT', ignoring (no cap set)"
            BUDGET_LIMIT=""
        fi
        # Mirror the CLI guard: a non-positive cap (0/0.00) would make
        # check_budget_limit pause before any work runs. Treat it as "no cap"
        # rather than a silent pre-work pause.
        if [ -n "$BUDGET_LIMIT" ] && ! awk -v b="$BUDGET_LIMIT" 'BEGIN{exit !(b+0 > 0)}'; then
            log_warn "BUDGET_LIMIT '$BUDGET_LIMIT' is not greater than 0, ignoring (no cap set)"
            BUDGET_LIMIT=""
        fi
    fi
    if [ -n "$BUDGET_LIMIT" ]; then
        cat > ".loki/metrics/budget.json" << BUDGET_EOF
{
  "limit": $BUDGET_LIMIT,
  "budget_limit": $BUDGET_LIMIT,
  "budget_used": 0,
  "created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
BUDGET_EOF
        log_info "Budget limit set: \$$BUDGET_LIMIT"
    fi

    # v7.0.0 Phase 2: remote->local hydrate. Runs ONCE at session boot (not
    # per iteration) to pull semantic patterns + procedural skills from the
    # managed store into .loki/memory/semantic/patterns.json and
    # .loki/memory/skills/*.json. Gated on parent + MEMORY + HYDRATE; all
    # three must be "true". 10s hard timeout so a slow remote never blocks
    # startup. Idempotent (sentinel: .loki/managed/hydrate.lock).
    if [ "$LOKI_MANAGED_AGENTS" = "true" ] \
        && [ "$LOKI_MANAGED_MEMORY" = "true" ] \
        && [ "$LOKI_MANAGED_MEMORY_HYDRATE" = "true" ]; then
        local _hydrate_target="${TARGET_DIR:-$(pwd)}"
        local _hydrate_out
        _hydrate_out=$(
            cd "$PROJECT_DIR" 2>/dev/null && \
            LOKI_TARGET_DIR="$_hydrate_target" \
            timeout 10 python3 -m memory.managed_memory.retrieve --hydrate 2>/dev/null || true
        )
        if [ -n "$_hydrate_out" ]; then
            log_info "Managed hydrate: $_hydrate_out"
        else
            LOKI_TARGET_DIR="$_hydrate_target" \
            python3 -c "from memory.managed_memory.events import emit_managed_event; emit_managed_event('managed_memory_hydrate_timeout', {'phase': 'init'})" 2>/dev/null || true
        fi
    fi

    log_info "Loki directory initialized: .loki/"
}

# Write .loki/pricing.json based on active provider
_write_pricing_json() {
    local provider="${LOKI_PROVIDER:-claude}"
    local updated
    updated=$(date -u +%Y-%m-%d)

    cat > ".loki/pricing.json" << PRICING_EOF
{
  "provider": "${provider}",
  "updated": "${updated}",
  "source": "static",
  "models": {
    "fable":           {"input": 10.00, "output": 50.00, "label": "Fable 5 (top, 2x Opus)", "provider": "claude"},
    "claude-fable-5":  {"input": 10.00, "output": 50.00, "label": "Fable 5 (top, 2x Opus)", "provider": "claude"},
    "opus":            {"input": 5.00,  "output": 25.00, "label": "Opus (latest)",   "provider": "claude"},
    "sonnet":          {"input": 3.00,  "output": 15.00, "label": "Sonnet (latest)", "provider": "claude"},
    "haiku":           {"input": 1.00,  "output": 5.00,  "label": "Haiku (latest)",  "provider": "claude"},
    "gpt-5.3-codex":   {"input": 1.75,  "output": 14.00, "label": "GPT-5.3 Codex", "provider": "codex"}
  }
}
PRICING_EOF
    log_info "Pricing data written: .loki/pricing.json (provider: ${provider})"
}

#===============================================================================
# Cline Invocation (Tier 2 - Near-Full)
#===============================================================================

# Invoke Cline CLI in autonomous mode
# Usage: invoke_cline "prompt" [additional args...]
invoke_cline() {
    local prompt="$1"
    shift
    local model="${LOKI_CLINE_MODEL:-}"
    if [[ -n "$model" ]]; then
        cline -y -m "$model" "$prompt" "$@" 2>&1
    else
        cline -y "$prompt" "$@" 2>&1
    fi
}

# Invoke Cline and capture output (for variable assignment)
# Usage: result=$(invoke_cline_capture "prompt")
invoke_cline_capture() {
    local prompt="$1"
    shift
    local model="${LOKI_CLINE_MODEL:-}"
    if [[ -n "$model" ]]; then
        cline -y -m "$model" "$prompt" "$@" 2>&1
    else
        cline -y "$prompt" "$@" 2>&1
    fi
}

#===============================================================================
# Aider Invocation (Tier 3 - Degraded, 18+ Providers)
#===============================================================================

# Invoke Aider in autonomous single-instruction mode
# Usage: invoke_aider "prompt" [additional args...]
invoke_aider() {
    local prompt="$1"
    shift
    local model="${AIDER_DEFAULT_MODEL:-${LOKI_AIDER_MODEL:-openrouter/deepseek/deepseek-v3.2}}"
    local extra_flags="${LOKI_AIDER_FLAGS:-}"
    # shellcheck disable=SC2086
    # < /dev/null prevents aider from blocking on stdin in non-interactive mode
    aider --message "$prompt" --yes-always --no-auto-commits \
          --model "$model" $extra_flags "$@" < /dev/null 2>&1
}

# Invoke Aider and capture output (for variable assignment)
# Usage: result=$(invoke_aider_capture "prompt")
invoke_aider_capture() {
    local prompt="$1"
    shift
    local model="${AIDER_DEFAULT_MODEL:-${LOKI_AIDER_MODEL:-openrouter/deepseek/deepseek-v3.2}}"
    local extra_flags="${LOKI_AIDER_FLAGS:-}"
    # shellcheck disable=SC2086
    aider --message "$prompt" --yes-always --no-auto-commits \
          --model "$model" $extra_flags "$@" < /dev/null 2>&1
}

#===============================================================================
# Copy Skill Files to Project Directory
#===============================================================================

copy_skill_files() {
    # Copy skill files from the CLI package to the project's .loki/ directory.
    # This makes the CLI self-contained - no need to install Claude Code skill separately.
    # All providers (Claude, Codex, Cline, Aider) use the same .loki/skills/ location.

    local skills_src="$PROJECT_DIR/skills"
    local skills_dst=".loki/skills"

    if [ ! -d "$skills_src" ]; then
        log_warn "Skills directory not found at $skills_src"
        return 1
    fi

    # Create destination and copy skill files
    mkdir -p "$skills_dst"

    # Copy all skill markdown files
    local copied=0
    for skill_file in "$skills_src"/*.md; do
        if [ -f "$skill_file" ]; then
            cp "$skill_file" "$skills_dst/"
            ((copied++))
        fi
    done

    # Also copy SKILL.md to .loki/ and rewrite paths for workspace access
    if [ -f "$PROJECT_DIR/SKILL.md" ]; then
        # Rewrite skill paths from skills/ to .loki/skills/
        sed -e 's|skills/00-index\.md|.loki/skills/00-index.md|g' \
            -e 's|skills/model-selection\.md|.loki/skills/model-selection.md|g' \
            -e 's|skills/quality-gates\.md|.loki/skills/quality-gates.md|g' \
            -e 's|skills/testing\.md|.loki/skills/testing.md|g' \
            -e 's|skills/troubleshooting\.md|.loki/skills/troubleshooting.md|g' \
            -e 's|skills/production\.md|.loki/skills/production.md|g' \
            -e 's|skills/parallel-workflows\.md|.loki/skills/parallel-workflows.md|g' \
            -e 's|skills/providers\.md|.loki/skills/providers.md|g' \
            -e 's|Read skills/|Read .loki/skills/|g' \
            "$PROJECT_DIR/SKILL.md" > ".loki/SKILL.md"
    fi

    log_info "Copied $copied skill files to .loki/skills/"
}

#===============================================================================
# Task Status Monitor
#===============================================================================

update_status_file() {
    # Create a human-readable status file
    local status_file=".loki/STATUS.txt"

    # Get current phase
    local current_phase="UNKNOWN"
    if [ -f ".loki/state/orchestrator.json" ]; then
        current_phase=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('currentPhase', 'UNKNOWN'))" 2>/dev/null || echo "UNKNOWN")
    fi

    # Count tasks in each queue
    local pending=0 in_progress=0 completed=0 failed=0
    [ -f ".loki/queue/pending.json" ] && pending=$(python3 -c "import json; print(len(json.load(open('.loki/queue/pending.json'))))" 2>/dev/null || echo "0")
    [ -f ".loki/queue/in-progress.json" ] && in_progress=$(python3 -c "import json; print(len(json.load(open('.loki/queue/in-progress.json'))))" 2>/dev/null || echo "0")
    [ -f ".loki/queue/completed.json" ] && completed=$(python3 -c "import json; print(len(json.load(open('.loki/queue/completed.json'))))" 2>/dev/null || echo "0")
    [ -f ".loki/queue/failed.json" ] && failed=$(python3 -c "import json; print(len(json.load(open('.loki/queue/failed.json'))))" 2>/dev/null || echo "0")

    cat > "$status_file" << EOF
╔════════════════════════════════════════════════════════════════╗
║                    LOKI MODE STATUS                            ║
╚════════════════════════════════════════════════════════════════╝

Updated: $(date)

Phase: $current_phase

Tasks:
  ├─ Pending:     $pending
  ├─ In Progress: $in_progress
  ├─ Completed:   $completed
  └─ Failed:      $failed

Monitor: watch -n 2 cat .loki/STATUS.txt
EOF
}

#===============================================================================
# Phase Management (Dashboard Integration)
#===============================================================================

# Track last known phase to detect changes
LAST_KNOWN_PHASE=""

# Set the current phase and emit event if changed
# v7.5.12: append a log entry to the iteration-N task in in-progress.json.
# Args: iteration, phase, level, message. All silent on failure -- this
# must NEVER kill the run.
append_iteration_task_log() {
    local iteration="${1:-0}"
    local phase="${2:-}"
    local level="${3:-info}"
    local message="${4:-}"
    local in_progress_file=".loki/queue/in-progress.json"

    [ -z "$iteration" ] && return 0
    [ "$iteration" = "0" ] && return 0
    [ ! -f "$in_progress_file" ] && return 0

    local timestamp
    timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)

    ITER="$iteration" PHASE="$phase" LEVEL="$level" \
    MESSAGE="$message" TIMESTAMP="$timestamp" \
    python3 - "$in_progress_file" <<'PY' 2>/dev/null || true
import json, os, sys, tempfile
path = sys.argv[1]
target_id = f"iteration-{os.environ['ITER']}"
entry = {
    "timestamp": os.environ["TIMESTAMP"],
    "iteration": int(os.environ["ITER"]),
    "level": os.environ.get("LEVEL", "info"),
    "phase": os.environ.get("PHASE", ""),
    "message": os.environ.get("MESSAGE", ""),
}
try:
    with open(path) as f:
        data = json.load(f)
except Exception:
    sys.exit(0)
# Support both [...] and {tasks: [...]} shapes (matches load_queue_tasks).
tasks = data["tasks"] if isinstance(data, dict) and isinstance(data.get("tasks"), list) else (data if isinstance(data, list) else None)
if tasks is None:
    sys.exit(0)
mutated = False
for t in tasks:
    if not isinstance(t, dict):
        continue
    if t.get("id") == target_id:
        logs = t.get("logs")
        if not isinstance(logs, list):
            logs = []
        logs.append(entry)
        t["logs"] = logs
        mutated = True
        break
if not mutated:
    sys.exit(0)
out_dir = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(dir=out_dir, suffix=".json")
with os.fdopen(fd, "w") as f:
    json.dump(data, f, indent=2)
os.replace(tmp, path)
PY
}

#===============================================================================
# Dashboard State Writer (Real-time sync with web dashboard)
#===============================================================================

write_dashboard_state() {
    # Write comprehensive dashboard state to JSON for web dashboard consumption
    local output_file=".loki/dashboard-state.json"

    # Get current phase and version
    local current_phase="BOOTSTRAP"
    local version="unknown"
    local started_at=""
    local tasks_completed=0
    local tasks_failed=0

    if [ -f ".loki/state/orchestrator.json" ]; then
        current_phase=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('currentPhase', 'BOOTSTRAP'))" 2>/dev/null || echo "BOOTSTRAP")
        version=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('version', 'unknown'))" 2>/dev/null || echo "unknown")
        started_at=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('startedAt', ''))" 2>/dev/null || echo "")
        tasks_completed=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('metrics', {}).get('tasksCompleted', 0))" 2>/dev/null || echo "0")
        tasks_failed=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('metrics', {}).get('tasksFailed', 0))" 2>/dev/null || echo "0")
    fi

    # Emit phase change event if phase has changed (checked in background monitor loop)
    if [ -n "$LAST_KNOWN_PHASE" ] && [ "$current_phase" != "$LAST_KNOWN_PHASE" ]; then
        emit_event_json "phase_change" \
            "from=$LAST_KNOWN_PHASE" \
            "to=$current_phase" \
            "iteration=$ITERATION_COUNT"
    fi
    LAST_KNOWN_PHASE="$current_phase"

    # Get task counts from queues
    local pending_tasks="[]"
    local in_progress_tasks="[]"
    local completed_tasks="[]"
    local failed_tasks="[]"
    local review_tasks="[]"

    # Read queue files, normalizing {"tasks":[...]} format to plain array
    [ -f ".loki/queue/pending.json" ] && pending_tasks=$(jq 'if type == "object" then .tasks // [] else . end' ".loki/queue/pending.json" 2>/dev/null || echo "[]")
    [ -f ".loki/queue/in-progress.json" ] && in_progress_tasks=$(jq 'if type == "object" then .tasks // [] else . end' ".loki/queue/in-progress.json" 2>/dev/null || echo "[]")
    [ -f ".loki/queue/completed.json" ] && completed_tasks=$(jq 'if type == "object" then .tasks // [] else . end' ".loki/queue/completed.json" 2>/dev/null || echo "[]")
    [ -f ".loki/queue/failed.json" ] && failed_tasks=$(jq 'if type == "object" then .tasks // [] else . end' ".loki/queue/failed.json" 2>/dev/null || echo "[]")
    [ -f ".loki/queue/review.json" ] && review_tasks=$(jq 'if type == "object" then .tasks // [] else . end' ".loki/queue/review.json" 2>/dev/null || echo "[]")

    # Get agents state
    local agents="[]"
    [ -f ".loki/state/agents.json" ] && agents=$(cat ".loki/state/agents.json" 2>/dev/null || echo "[]")

    # Get resources state
    local cpu_usage=0
    local mem_usage=0
    local resource_status="ok"

    if [ -f ".loki/state/resources.json" ]; then
        cpu_usage=$(python3 -c "import json; print(json.load(open('.loki/state/resources.json')).get('cpu', {}).get('usage_percent', 0))" 2>/dev/null || echo "0")
        mem_usage=$(python3 -c "import json; print(json.load(open('.loki/state/resources.json')).get('memory', {}).get('usage_percent', 0))" 2>/dev/null || echo "0")
        resource_status=$(python3 -c "import json; print(json.load(open('.loki/state/resources.json')).get('overall_status', 'ok'))" 2>/dev/null || echo "ok")
    fi

    # Check human intervention signals
    local mode="autonomous"
    if [ -f ".loki/PAUSE" ]; then
        mode="paused"
    elif [ -f ".loki/STOP" ]; then
        mode="stopped"
    fi

    # Get complexity tier
    local complexity="${DETECTED_COMPLEXITY:-standard}"

    # Get RARV cycle step from actual phase tracking (falls back to iteration-based)
    local rarv_step=${RARV_CURRENT_STEP:-$((ITERATION_COUNT % 4))}
    local rarv_stages='["reason", "act", "reflect", "verify"]'

    # Get memory system stats (if available)
    local episodic_count=0
    local semantic_count=0
    local procedural_count=0

    [ -d ".loki/memory/episodic" ] && episodic_count=$(find ".loki/memory/episodic" -type f -name "*.json" 2>/dev/null | wc -l | tr -d ' ')
    [ -d ".loki/memory/semantic" ] && semantic_count=$(find ".loki/memory/semantic" -type f -name "*.json" 2>/dev/null | wc -l | tr -d ' ')
    [ -d ".loki/memory/skills" ] && procedural_count=$(find ".loki/memory/skills" -type f -name "*.json" 2>/dev/null | wc -l | tr -d ' ')

    # Get quality gates status (if available)
    local quality_gates='null'
    if [ -f ".loki/state/quality-gates.json" ]; then
        quality_gates=$(cat ".loki/state/quality-gates.json" 2>/dev/null || echo 'null')
    fi

    # Get Completion Council state (v5.25.0)
    local council_state='{"enabled":false}'
    if [ -f ".loki/council/state.json" ]; then
        council_state=$(cat ".loki/council/state.json" 2>/dev/null || echo '{"enabled":false}')
    fi

    # PRD Checklist summary (v5.44.0)
    local checklist_summary='null'
    if [ -f ".loki/checklist/verification-results.json" ]; then
        checklist_summary=$(cat ".loki/checklist/verification-results.json" 2>/dev/null || echo "null")
    fi

    # App Runner state (v5.45.0)
    local app_runner_state='{"status":"not_initialized"}'
    if [ -f ".loki/app-runner/state.json" ]; then
        app_runner_state=$(cat ".loki/app-runner/state.json" 2>/dev/null || echo '{"status":"error"}')
    fi

    # Playwright verification results (v5.46.0)
    local playwright_results='null'
    if [ -f ".loki/verification/playwright-results.json" ]; then
        playwright_results=$(cat ".loki/verification/playwright-results.json" 2>/dev/null || echo "null")
    fi

    # Get budget status (if configured)
    local budget_json="null"
    if [ -f ".loki/metrics/budget.json" ]; then
        budget_json=$(cat ".loki/metrics/budget.json" 2>/dev/null || echo "null")
    fi

    # Get context window tracking state (v5.40.0)
    local context_state="null"
    if [ -f ".loki/context/tracking.json" ]; then
        context_state=$(cat ".loki/context/tracking.json" 2>/dev/null || echo "null")
    fi

    # Get notification summary (v5.40.0)
    local notification_summary='{"total":0,"unacknowledged":0,"critical":0,"warning":0,"info":0}'
    if [ -f ".loki/notifications/active.json" ]; then
        notification_summary=$(python3 -c "
import json,sys
try:
    data=json.load(open('.loki/notifications/active.json'))
    print(json.dumps(data.get('summary',{'total':0,'unacknowledged':0})))
except: print('{\"total\":0,\"unacknowledged\":0}')
" 2>/dev/null || echo '{"total":0,"unacknowledged":0}')
    fi

    # Write comprehensive JSON state (atomic via temp file + mv).
    # v7.7.5 fix: previously used `${output_file}.tmp` (no PID suffix). When two
    # background processes both called write_dashboard_state concurrently, they
    # raced on the same .tmp filename -- one would clobber the other's content,
    # the loser's `mv` would fail with "No such file or directory" because the
    # winner already moved the shared .tmp away. This flooded the agent output
    # with `mv: rename .loki/dashboard-state.json.tmp ...` errors and made
    # Loki sessions appear broken. Now each process gets a unique tmp suffix.
    local project_name=$(basename "$(pwd)")
    local project_path=$(pwd)
    local _tmp_state="${output_file}.tmp.$$.$RANDOM"

    # BUG #49 fix: Escape project path/name for JSON to handle special chars
    # (spaces, quotes, backslashes in directory names)
    local project_name_escaped
    local project_path_escaped
    project_name_escaped=$(printf '%s' "$project_name" | sed 's/\\/\\\\/g; s/"/\\"/g')
    project_path_escaped=$(printf '%s' "$project_path" | sed 's/\\/\\\\/g; s/"/\\"/g')

    cat > "$_tmp_state" << EOF
{
  "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
  "version": "$version",
  "project": {
    "name": "$project_name_escaped",
    "path": "$project_path_escaped"
  },
  "mode": "$mode",
  "provider": "${PROVIDER_NAME:-claude}",
  "phase": "$current_phase",
  "complexity": "$complexity",
  "iteration": $ITERATION_COUNT,
  "startedAt": "$started_at",
  "rarv": {
    "currentStep": $rarv_step,
    "stages": $rarv_stages
  },
  "tasks": {
    "pending": $pending_tasks,
    "inProgress": $in_progress_tasks,
    "review": $review_tasks,
    "completed": $completed_tasks,
    "failed": $failed_tasks
  },
  "agents": $agents,
  "metrics": {
    "tasksCompleted": $tasks_completed,
    "tasksFailed": $tasks_failed,
    "cpuUsage": $cpu_usage,
    "memoryUsage": $mem_usage,
    "resourceStatus": "$resource_status"
  },
  "memory": {
    "episodic": $episodic_count,
    "semantic": $semantic_count,
    "procedural": $procedural_count
  },
  "qualityGates": $quality_gates,
  "council": $council_state,
  "checklist": $checklist_summary,
  "appRunner": $app_runner_state,
  "playwright": $playwright_results,
  "budget": $budget_json,
  "context": $context_state,
  "tokens": $(python3 -c "
import json
try:
    t = json.load(open('.loki/context/tracking.json'))
    totals = t.get('totals', {})
    print(json.dumps({'input': totals.get('total_input', 0), 'output': totals.get('total_output', 0), 'cost_usd': totals.get('total_cost_usd', 0)}))
except: print('null')
" 2>/dev/null || echo "null"),
  "notifications": $notification_summary
}
EOF
    # v7.7.5 fix: silence mv stderr so any residual race (different process
    # already swapped a newer file in) doesn't flood the agent output. The
    # PID-suffixed tmp eliminates the race; the 2>/dev/null is belt-and-
    # suspenders. `|| rm -f "$_tmp_state" 2>/dev/null` cleans up the tmp
    # on the rare failure rather than leaking it.
    mv "$_tmp_state" "$output_file" 2>/dev/null || rm -f "$_tmp_state" 2>/dev/null
}

#===============================================================================
# Context Window Tracking (v5.40.0)
#===============================================================================

# Track context window usage (provider-agnostic)
track_context_usage() {
    local iteration="$1"
    mkdir -p .loki/context
    local provider_arg="${LOKI_PROVIDER:-claude}"
    local window_arg="${LOKI_CONTEXT_WINDOW_SIZE:-0}"
    python3 "${SCRIPT_DIR}/context-tracker.py" \
        --iteration "$iteration" \
        --loki-dir ".loki" \
        --provider "$provider_arg" \
        --window-size "$window_arg" 2>/dev/null || true
}

# Check notification triggers against current state
check_notification_triggers() {
    local iteration="$1"
    mkdir -p .loki/notifications
    python3 "${SCRIPT_DIR}/notification-checker.py" \
        --iteration "$iteration" \
        --loki-dir ".loki" 2>/dev/null || true
}

#===============================================================================
# Task Queue Auto-Tracking (for degraded mode providers)
#===============================================================================

# Derive a plain-language, honest summary of the spec this run is building from,
# for the dashboard iteration card (Task G). Mirrors the "Building:" start
# headline (run.sh:17061) and proof-generator's source resolution so the card,
# the banner, and the proof artifact all describe the same real intent.
#
# Echoes two tab-separated fields: <human spec label>\t<spec kind>, where kind is
# one of: brief | prd | codebase-analysis. The label is plain English with no
# RARV jargon. Best-effort and never fails (used only to enrich a card).
_loki_iteration_spec_summary() {
    local prd="${1:-}"
    local label="" kind=""

    # 1. A recorded one-line brief (`loki start "<brief>"`) is the user's own
    #    words; show them verbatim, truncated to stay on one tidy line.
    if [ -f ".loki/state/brief.txt" ]; then
        local _brief
        _brief="$(tr '\n' ' ' < .loki/state/brief.txt 2>/dev/null | sed 's/  */ /g; s/^ //; s/ $//')"
        if [ -n "$_brief" ]; then
            if [ "${#_brief}" -gt 80 ]; then
                _brief="${_brief:0:77}..."
            fi
            printf '%s\t%s' "$_brief" "brief"
            return 0
        fi
    fi

    # 2. A generated PRD means there was no user spec; we are reverse-engineering
    #    one from the code. Say exactly that rather than naming the internal file.
    case "$prd" in
        ""|*.loki/generated-prd.md|*.loki/generated-prd.json)
            label="the codebase (no spec provided)"
            kind="codebase-analysis"
            ;;
        *)
            # 3. A real user PRD / spec file: name it by basename, no path noise.
            label="$(basename "$prd" 2>/dev/null || printf '%s' "$prd")"
            kind="prd"
            ;;
    esac

    printf '%s\t%s' "$label" "$kind"
}

# Track iteration start - create task in in-progress queue
track_iteration_start() {
    local iteration="$1"
    local prd="${2:-}"
    local task_id="iteration-$iteration"

    mkdir -p .loki/queue

    # Record iteration start time for efficiency tracking (SYN-018)
    record_iteration_start

    # Emit iteration start event for dashboard
    emit_event_json "iteration_start" \
        "iteration=$iteration" \
        "provider=${PROVIDER_NAME:-claude}" \
        "prd=${prd:-Codebase Analysis}"

    # Also emit to pending dir for OTEL bridge
    if [ -n "${LOKI_OTEL_ENDPOINT:-}" ]; then
        emit_event_pending "iteration_start" \
            "iteration=$iteration" \
            "provider=${PROVIDER_NAME:-claude}"
    fi

    # Read next pending task for context (enrich iteration with PRD task details)
    local next_task_context=""
    if [[ -f ".loki/queue/pending.json" ]]; then
        next_task_context=$(python3 -c "
import json
try:
    with open('.loki/queue/pending.json') as f:
        tasks = json.load(f)
    if isinstance(tasks, dict):
        tasks = tasks.get('tasks', [])
    pending = [t for t in tasks if isinstance(t, dict) and t.get('status','pending') == 'pending']
    if pending:
        t = pending[0]
        print(json.dumps({
            'current_task': t.get('title',''),
            'description': t.get('description',''),
            'acceptance_criteria': t.get('acceptance_criteria', []),
            'user_story': t.get('user_story', ''),
            'source': t.get('source', ''),
            'project': t.get('project', '')
        }))
except: pass
" 2>/dev/null || true)
    fi

    # Create task entry (escape PRD path for safe JSON embedding)
    local prd_escaped
    prd_escaped=$(printf '%s' "${prd:-Codebase Analysis}" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g')

    # Task G: derive a plain-language, honest summary of what this run is building
    # from, so the card title/description describe the real work instead of the
    # generic "Iteration N" / "RARV iteration N" placeholder. Best-effort.
    local _spec_label="" _spec_kind=""
    local _spec_summary
    _spec_summary=$(_loki_iteration_spec_summary "$prd")
    _spec_label="${_spec_summary%%$'\t'*}"
    _spec_kind="${_spec_summary##*$'\t'}"
    # Escape for safe embedding in the python -c literals below.
    local _spec_label_esc _spec_kind_esc
    _spec_label_esc=$(printf '%s' "$_spec_label" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g')
    _spec_kind_esc=$(printf '%s' "$_spec_kind" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g')

    # Build enriched task JSON with pending task context.
    # Must initialize to empty: this script runs under `set -u` (line 152),
    # so `local task_json` without a value leaves it unset. When the pending
    # queue is empty, the enrichment `if` is skipped and the `-z` check below
    # would fire on an unset variable and kill the run.
    local task_json=""
    if [[ -n "${next_task_context:-}" ]]; then
        task_json=$(python3 -c "
import json, sys
ctx = json.loads('''$next_task_context''')
# Task G: the card must describe the REAL work in plain language, not the
# generic 'Iteration N' / 'RARV iteration N' placeholder. Prefer the pending
# PRD task's own title/description/criteria. When those are absent, fall back to
# a plain-language summary derived from the spec this run is building from, and
# OMIT acceptance criteria rather than show RARV phase-name boilerplate (only
# state what is really known).
spec_label = '${_spec_label_esc}'
spec_kind = '${_spec_kind_esc}'
if spec_kind == 'codebase-analysis':
    fallback_title = 'Analyzing the codebase and generating a spec'
    fallback_desc = 'Reading the existing code to reverse-engineer a spec, then building against it.'
elif spec_kind == 'brief':
    fallback_title = 'Building: ' + spec_label
    fallback_desc = 'Building from your brief: ' + spec_label
else:
    fallback_title = 'Building from ' + spec_label
    fallback_desc = 'Implementing the spec in ' + spec_label + ' and verifying it.'
task = {
    'id': 'iteration-$iteration',
    'type': 'iteration',
    'title': ctx.get('current_task') or fallback_title,
    'description': ctx.get('description') or fallback_desc,
    'status': 'in_progress',
    'priority': 'medium',
    'startedAt': '$(date -u +%Y-%m-%dT%H:%M:%SZ)',
    'provider': '${PROVIDER_NAME:-claude}',
    'acceptance_criteria': ctx.get('acceptance_criteria') or [],
    'notes': [],
    'logs': [{
        'timestamp': '$(date -u +%Y-%m-%dT%H:%M:%SZ)',
        'iteration': $iteration,
        'level': 'info',
        'phase': 'BOOTSTRAP',
        'message': 'Iteration $iteration started'
    }]
}
if ctx.get('user_story'):
    task['user_story'] = ctx['user_story']
if ctx.get('source'):
    task['source'] = ctx['source']
if ctx.get('project'):
    task['project'] = ctx['project']
print(json.dumps(task, indent=2))
" 2>/dev/null) || task_json=""
    fi

    # Fallback when there is no pending PRD task (codebase-analysis run or empty
    # queue). Task G: this was the worst placeholder card -- 'Iteration N' /
    # 'RARV iteration N' with RARV-phase-name acceptance criteria, which tells a
    # watching user nothing about the real work. Build an honest, plain-language
    # card from the spec summary instead, and omit acceptance criteria (we have no
    # real per-iteration criteria here -- do not invent RARV jargon).
    if [[ -z "${task_json:-}" ]]; then
        local _start_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
        task_json=$(python3 -c "
import json
spec_label = '${_spec_label_esc}'
spec_kind = '${_spec_kind_esc}'
if spec_kind == 'codebase-analysis':
    title = 'Analyzing the codebase and generating a spec'
    desc = 'Reading the existing code to reverse-engineer a spec, then building against it.'
elif spec_kind == 'brief':
    title = 'Building: ' + spec_label
    desc = 'Building from your brief: ' + spec_label
else:
    title = 'Building from ' + spec_label
    desc = 'Implementing the spec in ' + spec_label + ' and verifying it.'
task = {
    'id': '$task_id',
    'type': 'iteration',
    'title': title,
    'description': desc,
    'status': 'in_progress',
    'priority': 'medium',
    'startedAt': '$_start_ts',
    'provider': '${PROVIDER_NAME:-claude}',
    'acceptance_criteria': [],
    'notes': [],
    'logs': [{
        'timestamp': '$_start_ts',
        'iteration': $iteration,
        'level': 'info',
        'phase': 'BOOTSTRAP',
        'message': 'Iteration $iteration started'
    }]
}
print(json.dumps(task, indent=2))
" 2>/dev/null) || task_json=""
    fi

    # Last-resort safety net if python is unavailable. Still avoids the generic
    # 'RARV iteration' wording and omits boilerplate acceptance criteria.
    if [[ -z "${task_json:-}" ]]; then
        local _start_ts2="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
        local _safe_title
        case "$_spec_kind" in
            codebase-analysis) _safe_title="Analyzing the codebase and generating a spec" ;;
            brief) _safe_title="Building from your brief" ;;
            *) _safe_title="Building from the spec" ;;
        esac
        task_json=$(cat <<EOF
{
  "id": "$task_id",
  "type": "iteration",
  "title": "$_safe_title",
  "description": "$_safe_title (iteration $iteration).",
  "status": "in_progress",
  "priority": "medium",
  "startedAt": "$_start_ts2",
  "provider": "${PROVIDER_NAME:-claude}",
  "acceptance_criteria": [],
  "notes": [],
  "logs": [
    {
      "timestamp": "$_start_ts2",
      "iteration": $iteration,
      "level": "info",
      "phase": "BOOTSTRAP",
      "message": "Iteration $iteration started"
    }
  ]
}
EOF
)
    fi

    # Add to in-progress queue
    # BUG-XC-003: atomic queue modification.
    # v7.5.12: portable mkdir-mutex via safe_acquire_lock (no flock needed).
    # v7.5.12 Dev11 (R1 HIGH): gate the read-modify-write on acquire SUCCESS.
    # The prior `safe_acquire_lock ... || true` then unconditional
    # `safe_release_lock` mutated state on timeout AND released the OTHER
    # holder's lock -- a mutex correctness violation. Mirror the working
    # pattern at line 1845 (acquire-success guarded RMW + release inside).
    local in_progress_file=".loki/queue/in-progress.json"
    local lockfile=".loki/queue/.in-progress.lock"
    if type safe_acquire_lock >/dev/null 2>&1 && safe_acquire_lock "$lockfile" 5; then
        if [ -f "$in_progress_file" ]; then
            local existing=$(cat "$in_progress_file")
            if [ "$existing" = "[]" ] || [ -z "$existing" ]; then
                echo "[$task_json]" > "$in_progress_file"
            else
                # Append to existing array
                echo "$existing" | python3 -c "
import sys, json
data = json.load(sys.stdin)
data.append($task_json)
print(json.dumps(data, indent=2))
" > "$in_progress_file" 2>/dev/null || echo "[$task_json]" > "$in_progress_file"
            fi
        else
            echo "[$task_json]" > "$in_progress_file"
        fi
        safe_release_lock "$lockfile"
    else
        log_warn "could not acquire in-progress lock; skipping update"
    fi

    # BUG-ST-014: Atomic current-task.json update via temp file + mv
    local ct_tmp=".loki/queue/current-task.json.tmp.$$"
    echo "$task_json" > "$ct_tmp"
    mv -f "$ct_tmp" .loki/queue/current-task.json
}

# Track iteration completion - move task to completed queue
# v7.8.1: staleness-aware generated-PRD reuse helpers.
# Hash stdin with whatever digest tool is available (mirrors the existing
# stat -f%z || stat -c%s dual-probe portability pattern). Echoes a short hash.
_loki_hash_stdin() {
    if command -v shasum >/dev/null 2>&1; then
        shasum -a 256 | cut -c1-16
    elif command -v sha256sum >/dev/null 2>&1; then
        sha256sum | cut -c1-16
    else
        cksum | tr -d ' ' | cut -c1-16
    fi
}

# Compute a cheap, clone-stable signature of the codebase so we can tell whether
# it changed since the generated PRD was last written. Git repos: HEAD sha +
# dirty flag (.loki/.git churn filtered out). Non-git: a hash of sorted
# path+size pairs PLUS file content (v7.32.3, #569: path+size alone was blind to
# a same-size content edit, so a stale PRD could be silently reused with a
# false "codebase unchanged" disclosure). Content hashing is clone-stable
# (mtime is not, which is why mtime was never used). Three content tiers:
#   1. full content hash ("files:") when the tree is both at-or-under
#      LOKI_PRD_SIG_CONTENT_BUDGET bytes (default 50MB) AND at-or-under
#      LOKI_PRD_SIG_CONTENT_MAXFILES files (default 20000). Detects any edit.
#   2. sampled content hash ("files-sampled:", #171) when the tree exceeds
#      either bound: hashes the head + tail (first 4096 + last 4096 bytes) of
#      every file. Catches same-size edits at the start or end of a file
#      without a full read of a huge tree. Residual honest gap: a same-size
#      edit in the MIDDLE of a large file (>8192 bytes) that touches neither
#      4KB window is still invisible. Far narrower than the old size-blind
#      fallback, which missed ALL same-size edits.
#   3. (historical) "files-shallow:" was the old content-blind fallback. It is
#      no longer emitted, but is still ACCEPTED when read from a stored pre-#171
#      signature so the first post-upgrade run reuses instead of falsely
#      claiming "codebase changed" (one-run format-transition, see consumer).
# Echoes the signature.
compute_codebase_signature() {
    local dir="${1:-.}"
    ( cd "$dir" 2>/dev/null || exit 0
      if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
          # Content-identity signature (gitc:): the hash is over the WORKING-TREE
          # CONTENT of every tracked + untracked-not-ignored file, independent of
          # the commit boundary. Whether a file is committed or sitting dirty in
          # the worktree yields the SAME value. This is what makes reuse robust to
          # Loki's OWN session commit (commit_session_changes, default-on as of
          # v7.73.0): a rerun whose only "change" is that the prior run committed
          # work it had already analyzed still classifies as reuse, not a spurious
          # "codebase changed -> update". A genuine source edit (committed OR
          # uncommitted) changes a blob hash and is still detected, and a new
          # untracked file is detected too. .loki/ is excluded (runtime state).
          # Paths are enumerated NUL-safe, .loki dropped, sorted, then hashed in
          # ONE batched `git hash-object --stdin-paths` pass (order-preserving),
          # so cost is one git process regardless of file count.
          local gitc gitc_paths gitc_deleted gitc_paths_file gitc_deleted_file
          # Tracked files removed from the worktree (but not staged): they have no
          # content to hash and would make the batched hash-object abort mid-list,
          # truncating the output and misaligning the path<->hash pairing. Drop
          # them; their removal from the list is itself the detected change.
          gitc_deleted=$(git ls-files --deleted -z 2>/dev/null | tr '\0' '\n')
          # Exclude .loki/ AND Loki's own session-end artifacts (HANDOFF.md,
          # USAGE.md) written+committed to the repo ROOT by the completion path
          # (LOKI_HANDOFF / commit_session_changes, both default-on). Those are
          # runtime OUTPUT of the session, not user codebase; counting them would
          # make the next PRD-reuse check see a spurious "codebase changed" and
          # flip reuse->update (the signature is persisted per-iteration BEFORE
          # the after-loop HANDOFF write, so it can never account for them).
          gitc_paths=$( { git ls-files -z 2>/dev/null; git ls-files --others --exclude-standard -z 2>/dev/null; } \
              | tr '\0' '\n' | grep -vE '(^|/)\.loki(/|$)|^HANDOFF\.md$|^USAGE\.md$' | LC_ALL=C sort -u )
          mkdir -p .loki/tmp 2>/dev/null || { echo "gitc:"; exit 0; }
          gitc_paths_file=$(mktemp ".loki/tmp/signature-paths.XXXXXX") || { echo "gitc:"; exit 0; }
          gitc_deleted_file=$(mktemp ".loki/tmp/signature-deleted.XXXXXX") || {
              rm -f "$gitc_paths_file"
              echo "gitc:"
              exit 0
          }
          printf '%s\n' "$gitc_paths" > "$gitc_paths_file"
          printf '%s\n' "$gitc_deleted" > "$gitc_deleted_file"
          if [ -n "$gitc_deleted" ]; then
              gitc_paths=$(grep -vxF -f "$gitc_deleted_file" "$gitc_paths_file" || true)
              printf '%s\n' "$gitc_paths" > "$gitc_paths_file"
          fi
          if [ -z "$gitc_paths" ]; then
              # No tracked or untracked content (empty/fresh repo): a stable
              # constant so two empty-tree runs still compare equal (reuse).
              gitc=$(printf '' | _loki_hash_stdin)
          else
              gitc=$(printf '%s\n' "$gitc_paths" | git hash-object --stdin-paths 2>/dev/null \
                  | paste -d'\t' - "$gitc_paths_file" \
                  | LC_ALL=C sort | _loki_hash_stdin)
          fi
          rm -f "$gitc_paths_file" "$gitc_deleted_file"
          echo "gitc:${gitc}"
      else
          local listing count total_sz budget maxfiles
          listing=$(find . \
              -type d \( -name .loki -o -name .git -o -name node_modules -o -name dist \
                         -o -name build -o -name .next -o -name target -o -name vendor \
                         -o -name __pycache__ -o -name .venv -o -name venv \) -prune -o \
              -type f -print 2>/dev/null \
              | grep -vE '^\./(HANDOFF|USAGE)\.md$' \
              | while IFS= read -r f; do
                    local sz
                    sz=$(stat -f%z "$f" 2>/dev/null || stat -c%s "$f" 2>/dev/null || echo 0)
                    printf '%s\t%s\n' "$f" "$sz"
                done | LC_ALL=C sort)
          # grep -c prints 0 itself on no match (exit 1); '|| true' avoids the
          # old '|| echo 0' double-zero that embedded a newline on empty trees
          count=$(printf '%s\n' "$listing" | grep -c . || true)
          total_sz=$(printf '%s\n' "$listing" | awk -F'\t' '{s+=$2} END {printf "%d", s}')
          budget="${LOKI_PRD_SIG_CONTENT_BUDGET:-52428800}"
          maxfiles="${LOKI_PRD_SIG_CONTENT_MAXFILES:-20000}"
          if [ "${total_sz:-0}" -le "$budget" ] 2>/dev/null \
             && [ "${count:-0}" -le "$maxfiles" ] 2>/dev/null; then
              # Tier 1 -- full content pass: stream all file contents through one
              # hash in the same sorted order as the listing. Detects any edit,
              # including same-size ones. xargs -0 batches the reads into a
              # handful of cat invocations, so cost scales with BYTES (which the
              # budget above bounds), not file count: a fork-per-file loop here
              # measured ~38s of added startup on a 30k-small-file tree. Renames
              # and content swaps are still caught by the listing hash below.
              local content_hash
              content_hash=$(printf '%s\n' "$listing" | cut -f1 | tr '\n' '\0' \
                  | xargs -0 cat 2>/dev/null | _loki_hash_stdin)
              echo "files:$(printf '%s' "$listing" | _loki_hash_stdin):${count}:${content_hash}"
          else
              # Tier 2 -- sampled content pass (#171): the tree is over the byte
              # budget OR over the file-count cap, so a full read would be slow.
              # Hash the head + tail (first 4096 + last 4096 bytes) of every file
              # instead. This catches same-size edits at the start or end of a
              # file (which the old size-blind "files-shallow:" missed entirely),
              # at a fixed <=8KB-per-file cost. -n 64 batches files per sh fork
              # to avoid a fork-per-file storm on large trees. Same sorted order
              # as the listing keeps the hash deterministic. Residual honest gap:
              # a same-size edit in the middle of a >8KB file is still invisible.
              local sample_hash
              sample_hash=$(printf '%s\n' "$listing" | cut -f1 | tr '\n' '\0' \
                  | xargs -0 -n 64 sh -c 'for f in "$@"; do head -c 4096 -- "$f" 2>/dev/null; tail -c 4096 -- "$f" 2>/dev/null; done' _ 2>/dev/null \
                  | _loki_hash_stdin)
              echo "files-sampled:$(printf '%s' "$listing" | _loki_hash_stdin):${count}:${sample_hash}"
          fi
      fi
    )
}

# Recompute the PRE-content-hash git-mode signature ("git:<HEAD>:<dirty>") for a
# one-time format transition: a signature recorded by an older Loki (HEAD +
# porcelain) must still be comparable on the first run after the upgrade to the
# new content-identity "gitc:" format, or decide would falsely flip to "update".
# Echoes the legacy-format value, or "" when not inside a git work tree.
_loki_compute_legacy_git_signature() {
    local dir="${1:-.}"
    ( cd "$dir" 2>/dev/null || exit 0
      git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
      local head dirty porcelain
      head=$(git rev-parse HEAD 2>/dev/null || echo "nohead")
      porcelain=$(git status --porcelain 2>/dev/null | grep -vE '(^...?\.loki/|/\.loki/| \.loki/|\.git/)' || true)
      if [ -z "$porcelain" ]; then
          dirty="clean"
      else
          dirty=$(printf '%s' "$porcelain" | _loki_hash_stdin)
      fi
      echo "git:${head}:${dirty}"
    )
}

# Content hash of the generated PRD file itself (NOT the codebase). Used to
# detect that a user hand-edited the generated PRD: when the file no longer
# matches the prd_sha Loki recorded after it last wrote the file, the PRD is
# user-owned and must be used as-is, never silently overwritten. Echoes "" when
# no generated PRD file is present.
_loki_prd_file_hash() {
    local loki_dir="${1:-.}/.loki"
    local f=""
    if [ -f "$loki_dir/generated-prd.md" ]; then
        f="$loki_dir/generated-prd.md"
    elif [ -f "$loki_dir/generated-prd.json" ]; then
        f="$loki_dir/generated-prd.json"
    fi
    [ -n "$f" ] || { echo ""; return 0; }
    _loki_hash_stdin < "$f"
}

# Decide what to do with a previously generated PRD on a no-PRD run.
# Echoes one of: reuse | update | generate | user_owned. Never fails the run.
# Precedence: force-regen > user_owned (hand-edited) > reuse/update > generate.
#   - LOKI_PRD_REGEN=1 (or --regen-prd/--fresh-prd, which set it) -> generate.
#   - no generated PRD present -> generate (first run).
#   - generated PRD present but its content hash differs from the recorded
#     prd_sha -> user_owned (the user hand-edited it; use as-is, do not rewrite).
#   - generated PRD present, no recorded signature -> update (have a PRD but no
#     provenance: reconcile incrementally rather than trust-blindly or discard).
#   - signature matches current codebase -> reuse (unchanged).
#   - signature differs -> update (codebase changed; update incrementally).
decide_generated_prd_action() {
    local loki_dir="${TARGET_DIR:-.}/.loki"
    if [ "${LOKI_PRD_REGEN:-}" = "1" ]; then
        echo "generate"; return 0
    fi
    if [ ! -f "$loki_dir/generated-prd.md" ] && [ ! -f "$loki_dir/generated-prd.json" ]; then
        echo "generate"; return 0
    fi
    local sig_file="$loki_dir/state/prd-signature.json"
    if [ ! -f "$sig_file" ]; then
        echo "update"; return 0
    fi
    # source:"user" short-circuit (LOCK 2): an explicit user-provided PRD was
    # persisted into the canonical slot. Always use it as-is, never enter the
    # signature-diff update path -- even if the file hash drifted (hand-edit) or
    # the codebase changed since. --fresh-prd/LOKI_PRD_REGEN (checked above)
    # still wins and forces a regenerate. A missing/empty source falls through
    # to the generated-PRD logic below (defensive: correctness never depends on
    # a backfilled field).
    local prd_source
    prd_source=$(LOKI_SIG_FILE="$sig_file" python3 -c "
import json, os
try:
    print(json.load(open(os.environ['LOKI_SIG_FILE'])).get('source',''))
except Exception:
    print('')
" 2>/dev/null)
    if [ "$prd_source" = "user" ]; then
        echo "user_owned"; return 0
    fi
    local stored stored_prd_sha current cur_prd_sha
    stored=$(LOKI_SIG_FILE="$sig_file" python3 -c "
import json, os
try:
    print(json.load(open(os.environ['LOKI_SIG_FILE'])).get('signature',''))
except Exception:
    print('')
" 2>/dev/null)
    stored_prd_sha=$(LOKI_SIG_FILE="$sig_file" python3 -c "
import json, os
try:
    print(json.load(open(os.environ['LOKI_SIG_FILE'])).get('prd_sha',''))
except Exception:
    print('')
" 2>/dev/null)
    [ -z "$stored" ] && { echo "update"; return 0; }
    # Hand-edit detection (precedence above reuse/update): if we recorded a
    # prd_sha and the file no longer matches it, the user edited it themselves.
    # Treat as user-owned: use as-is, never regenerate over their changes.
    if [ -n "$stored_prd_sha" ]; then
        cur_prd_sha=$(_loki_prd_file_hash "${TARGET_DIR:-.}")
        if [ -n "$cur_prd_sha" ] && [ "$cur_prd_sha" != "$stored_prd_sha" ]; then
            echo "user_owned"; return 0
        fi
    fi
    current=$(compute_codebase_signature "${TARGET_DIR:-.}")
    if [ "$stored" = "$current" ]; then
        echo "reuse"
    else
        # v7.32.3 format transition (#569): a stored pre-content-hash signature
        # ("files:<listing>:<count>", 3 fields) compared against the new 4-field
        # format would falsely claim "codebase changed" on the first post-upgrade
        # run. When the new signature extends the stored one (same listing
        # fields), the tree is unchanged at the old format's trust level: reuse,
        # honestly. The next persist upgrades the stored format. A same-size edit
        # made BEFORE the upgrade stays invisible for this one run, exactly as it
        # was on the old version (no regression, no false disclosure).
        case "$stored" in
            files:*:*)
                # Require the legitimate old 3-field format (files:HASH:COUNT,
                # exactly 2 colons), not a truncated/corrupted 2-field value
                # (council hardening: corruption must fall to update, as before).
                if [ "$(printf '%s' "$stored" | tr -dc ':' | wc -c | tr -d ' ')" = "2" ] \
                   && [ "${current#"${stored}":}" != "$current" ]; then
                    echo "reuse"; return 0
                fi
                ;;
            files-shallow:*:*)
                # #171 format transition: a stored pre-#171 size-blind signature
                # ("files-shallow:<listing>:<count>", 3 fields) compared against
                # the new sampled signature ("files-sampled:<listing>:<count>:
                # <samplehash>") would falsely claim "codebase changed" on the
                # first post-upgrade run. When the listing-hash and count match
                # (i.e. the stored value with its prefix swapped to files-sampled:
                # is a prefix of the current sampled signature), the tree is
                # unchanged at the OLD format's trust level (paths+sizes): reuse,
                # honestly. The next persist upgrades the stored format to the
                # sampled tier. A same-size edit made BEFORE the upgrade stays
                # invisible for this one run, exactly as on the old version (no
                # regression, no false disclosure).
                if [ "$(printf '%s' "$stored" | tr -dc ':' | wc -c | tr -d ' ')" = "2" ]; then
                    local stored_sampled="files-sampled:${stored#files-shallow:}"
                    case "$current" in
                        files-sampled:*)
                            if [ "${current#"${stored_sampled}":}" != "$current" ]; then
                                echo "reuse"; return 0
                            fi
                            ;;
                    esac
                fi
                ;;
            git:*)
                # git-mode format transition: a stored pre-content-hash signature
                # ("git:<HEAD>:<dirty>") cannot be compared directly against the
                # new content-identity "gitc:" format, so the first run after the
                # upgrade would falsely claim "codebase changed". Recompute the
                # OLD-format signature and compare against the stored value: if it
                # matches, the tree is unchanged at the old format's trust level
                # (HEAD + dirty porcelain) -> reuse, honestly. The next persist
                # upgrades the stored format to "gitc:". Only honor this when the
                # current signature is the new git format (a real format change),
                # never when both are old git: (that path already matched above).
                case "$current" in
                    gitc:*)
                        local legacy
                        legacy=$(_loki_compute_legacy_git_signature "${TARGET_DIR:-.}")
                        if [ -n "$legacy" ] && [ "$legacy" = "$stored" ]; then
                            echo "reuse"; return 0
                        fi
                        ;;
                esac
                ;;
        esac
        echo "update"
    fi
}

# Persist the current codebase signature after a clean no-PRD iteration that has
# a generated PRD, so the next run can decide reuse vs update. Best-effort; never
# fails the run. Only records on exit_code==0 (do not bless a broken iteration).
persist_prd_signature_if_present() {
    local exit_code="${1:-0}"
    [ "$exit_code" = "0" ] || return 0
    # Hand-edited (user-owned) PRD: do NOT rewrite the signature. Re-hashing the
    # user's edited file would re-baseline its content as the new prd_sha, so the
    # next run would fall through to plain reuse with the wrong (non-user-owned)
    # disclosure. Preserve the prior Loki-authored prd_sha/generated_at so every
    # subsequent run keeps detecting user_owned until --fresh-prd forces a regen.
    [ "${GENERATED_PRD_ACTION:-}" = "user_owned" ] && return 0
    # only for no-PRD runs whose generated PRD exists
    case "${prd_path:-}" in
        ""|*.loki/generated-prd.md|*.loki/generated-prd.json) ;;
        *) return 0 ;;
    esac
    local loki_dir="${TARGET_DIR:-.}/.loki"
    [ -f "$loki_dir/generated-prd.md" ] || [ -f "$loki_dir/generated-prd.json" ] || return 0
    local sig
    sig=$(compute_codebase_signature "${TARGET_DIR:-.}")
    [ -n "$sig" ] || return 0
    mkdir -p "$loki_dir/state" 2>/dev/null || return 0
    local mode="files"; case "$sig" in git:*|gitc:*) mode="git" ;; esac
    # Record the content hash of the PRD file Loki just wrote so a later
    # hand-edit by the user is detectable (decide_generated_prd_action). This
    # runs AFTER the agent's own PRD writes, so Loki's updates are not mistaken
    # for user edits.
    local prd_sha; prd_sha=$(_loki_prd_file_hash "${TARGET_DIR:-.}")
    local tmp="$loki_dir/state/.prd-signature.json.tmp.$$"
    # git-mode format upgrade (old "git:<HEAD>:<dirty>" -> new content-identity
    # "gitc:..."): when this run is a reuse honored via the decide transition
    # (the recomputed legacy signature still matches the stored one), the PRD
    # content did not change, so the generated_at date must be preserved across
    # the upgrade, exactly like the files: -> files-sampled: upgrade clauses.
    # Recompute the legacy value once and pass a match flag to the persist below.
    local git_upgrade_match=""
    case "$sig" in
        gitc:*)
            local _stored_sig
            _stored_sig=$(LOKI_SIG_FILE="$loki_dir/state/prd-signature.json" python3 -c "
import json, os
try:
    print(json.load(open(os.environ['LOKI_SIG_FILE'])).get('signature',''))
except Exception:
    print('')
" 2>/dev/null)
            case "$_stored_sig" in
                git:*)
                    local _legacy
                    _legacy=$(_loki_compute_legacy_git_signature "${TARGET_DIR:-.}")
                    [ -n "$_legacy" ] && [ "$_legacy" = "$_stored_sig" ] && git_upgrade_match=1
                    ;;
            esac
            ;;
    esac
    # Preserve generated_at when the codebase signature is unchanged so the
    # reuse disclosure ("generated on <date>") stays honest across reuse runs;
    # only stamp a new date when the PRD content actually changed (sig differs).
    LOKI_SIG="$sig" LOKI_SIG_MODE="$mode" LOKI_SIG_VER="$(get_version 2>/dev/null || echo unknown)" \
    LOKI_PRD_SHA="$prd_sha" LOKI_SIG_FILE="$loki_dir/state/prd-signature.json" \
    LOKI_GIT_UPGRADE_MATCH="$git_upgrade_match" \
    python3 -c "
import json, os, datetime
sig = os.environ['LOKI_SIG']
prev = {}
try:
    prev = json.load(open(os.environ['LOKI_SIG_FILE']))
except Exception:
    prev = {}
prev_at = prev.get('generated_at') if isinstance(prev, dict) else None
prev_sig = prev.get('signature') if isinstance(prev, dict) else None
# Unchanged, OR the v7.32.3 files-signature format upgrade (#569): the new
# 4-field signature extends an old 3-field one whose listing fields match.
# Preserve the date in both cases; the PRD content did not change.
_legacy_upgrade = (
    isinstance(prev_sig, str) and prev_sig.startswith('files:')
    and prev_sig.count(':') == 2
    and sig.startswith(prev_sig + ':')
)
# #171 format upgrade: a stored pre-#171 size-blind 'files-shallow:<listing>:
# <count>' (3 fields) reused into the new sampled 'files-sampled:<listing>:
# <count>:<samplehash>' whose listing fields match. Same trust level (the
# decide returned reuse), so the PRD content did not change: preserve the date.
_sampled_upgrade = (
    isinstance(prev_sig, str) and prev_sig.startswith('files-shallow:')
    and prev_sig.count(':') == 2
    and sig.startswith('files-sampled:' + prev_sig[len('files-shallow:'):] + ':')
)
# git-mode format upgrade (old 'git:<HEAD>:<dirty>' -> new content-identity
# 'gitc:...'): the caller recomputed the legacy signature and confirmed it still
# matches the stored one (decide returned reuse), so the PRD content did not
# change: preserve the date across the one-time upgrade.
_git_upgrade = (
    bool(os.environ.get('LOKI_GIT_UPGRADE_MATCH'))
    and isinstance(prev_sig, str) and prev_sig.startswith('git:')
    and sig.startswith('gitc:')
)
if prev_at and (prev_sig == sig or _legacy_upgrade or _sampled_upgrade or _git_upgrade):
    generated_at = prev_at
else:
    generated_at = datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00','Z')
rec = {
  'signature': sig,
  'generated_at': generated_at,
  'prd_path': '.loki/generated-prd.md',
  'prd_sha': os.environ.get('LOKI_PRD_SHA',''),
  'mode': os.environ['LOKI_SIG_MODE'],
  'loki_version': os.environ['LOKI_SIG_VER'],
  'source': 'generated',
  }
print(json.dumps(rec))
" > "$tmp" 2>/dev/null && mv -f "$tmp" "$loki_dir/state/prd-signature.json" 2>/dev/null || rm -f "$tmp" 2>/dev/null
}

# Persist an explicit user-provided PRD into the canonical generated-PRD slot so
# later no-file runs continue from it (brownfield reuse), and stamp source:"user"
# so it is always treated as user-owned (reuse/use-as-is), never incrementally
# updated. Echoes the canonical relative path (".loki/generated-prd.md") on a
# successful persist so the caller can repoint prd_path; echoes nothing (empty)
# and changes no state on any failure (the caller then keeps the original path).
#
# $1 = the original user PRD file path (the arg passed to run_autonomous).
# Reads/writes under "${TARGET_DIR:-.}/.loki" to stay aligned with
# decide_generated_prd_action and _loki_prd_file_hash (which anchor there too).
persist_user_prd() {
    local src="$1"
    [ -n "$src" ] || { echo ""; return 0; }
    [ -f "$src" ] || { echo ""; return 0; }
    # Skip when the source already IS the canonical generated PRD (a no-op copy,
    # and the no-file reuse path owns that case). Mirrors the persist guard.
    case "$src" in
        *.loki/generated-prd.md|*.loki/generated-prd.json) echo ""; return 0 ;;
    esac

    local loki_dir="${TARGET_DIR:-.}/.loki"
    mkdir -p "$loki_dir" "$loki_dir/state" 2>/dev/null || { echo ""; return 0; }

    # Atomic copy: write to a temp file in the destination dir, then mv into
    # place so a concurrent reader never sees a half-written PRD.
    local dest="$loki_dir/generated-prd.md"
    local tmp_prd="$loki_dir/.generated-prd.md.tmp.$$"
    cp -f "$src" "$tmp_prd" 2>/dev/null || { rm -f "$tmp_prd" 2>/dev/null; echo ""; return 0; }
    mv -f "$tmp_prd" "$dest" 2>/dev/null || { rm -f "$tmp_prd" 2>/dev/null; echo ""; return 0; }

    # Content hash of the PRD we just wrote (over the copied file) + the current
    # codebase signature, recorded directly (NOT via persist_prd_signature_if_present,
    # whose guards skip non-canonical/user paths and whose user_owned early-return
    # would skip it anyway). source:"user" makes decide_generated_prd_action short
    # circuit to user_owned on every later no-file run (LOCK 2).
    local prd_sha sig mode
    prd_sha=$(_loki_prd_file_hash "${TARGET_DIR:-.}")
    sig=$(compute_codebase_signature "${TARGET_DIR:-.}")
    mode="files"; case "$sig" in git:*|gitc:*) mode="git" ;; esac

    local sig_tmp="$loki_dir/state/.prd-signature.json.tmp.$$"
    LOKI_SIG="$sig" LOKI_SIG_MODE="$mode" \
    LOKI_SIG_VER="$(get_version 2>/dev/null || echo unknown)" \
    LOKI_PRD_SHA="$prd_sha" LOKI_ORIGIN_PATH="$src" \
    python3 -c "
import json, os, datetime
rec = {
  'signature': os.environ.get('LOKI_SIG',''),
  'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00','Z'),
  'prd_path': '.loki/generated-prd.md',
  'prd_sha': os.environ.get('LOKI_PRD_SHA',''),
  'mode': os.environ.get('LOKI_SIG_MODE','files'),
  'loki_version': os.environ.get('LOKI_SIG_VER','unknown'),
  'source': 'user',
  'origin_path': os.environ.get('LOKI_ORIGIN_PATH',''),
  }
print(json.dumps(rec))
" > "$sig_tmp" 2>/dev/null \
        && mv -f "$sig_tmp" "$loki_dir/state/prd-signature.json" 2>/dev/null \
        || rm -f "$sig_tmp" 2>/dev/null

    echo ".loki/generated-prd.md"
}

# generate_proof_of_run: thin fire-and-forget wrapper around the standalone
# proof-of-run generator (autonomy/lib/proof-generator.py). Runs on both
# success and failure session ends. The generator owns the schema, redaction
# chokepoint, and HTML rendering; this wrapper only resolves the path and
# invokes python3. Never fails the session (|| true at the call site).
# NOTE: no inline python here on purpose -- keep this wrapper apostrophe-free
# to avoid the bash single-quote trap.
generate_proof_of_run() {
    local _result="${1:-0}"
    local gen="$SCRIPT_DIR/lib/proof-generator.py"
    [ -f "$gen" ] || return 0
    local loki_dir="${TARGET_DIR:-.}/.loki"
    [ -d "$loki_dir" ] || return 0
    local ver provider
    ver="$(get_version 2>/dev/null || echo unknown)"
    provider="${PROVIDER_NAME:-claude}"

    # Proven PR (Loop 6 / Slice A2): resolve a deterministic run_id so the
    # proof + the PR Evidence Receipt agree, and persist a stable pointer the PR
    # sites read (never newest-by-mtime). The generator otherwise mints a fresh
    # _gen_run_id() when LOKI_SESSION_ID is unset (the `loki start ./prd.md`
    # case), which the later PR step could not know. Gated on the SAME flag as the
    # receipt so LOKI_PROVEN_PR=0 is a byte-identical no-op (no --run-id passed,
    # no pointer written): the pointer is only ever READ by the renderer, which
    # is itself off under that flag.
    if [ "${LOKI_PROVEN_PR:-1}" != "0" ]; then
        local _rid=""
        if declare -f _loki_trust_run_id >/dev/null 2>&1; then
            _rid="$(_loki_trust_run_id 2>/dev/null || true)"
        fi
        # Fall back to a locally minted id when no persisted per-run id exists.
        # Do NOT call _loki_trust_run_id --new here: that would clobber the
        # trust-events run-id file; this is a read-or-mint-local resolution.
        if [ -z "$_rid" ]; then
            _rid="proof-$(date -u +%Y%m%d%H%M%S 2>/dev/null || echo 0)-$$-${RANDOM:-0}"
        fi
        ITERATION_COUNT="${ITERATION_COUNT:-0}" \
        PROVIDER_NAME="$provider" \
        PRD_PATH="${prd_path:-}" \
        python3 "$gen" \
            --loki-dir "$loki_dir" \
            --loki-version "$ver" \
            --provider "$provider" \
            --session-exit-code "$_result" \
            --run-id "$_rid" \
            --quiet >/dev/null 2>&1 || true
        # Persist the resolved run_id atomically (.tmp + mv) so the PR sites read
        # the exact run the generator wrote, never an mtime guess.
        local _id_dir="$loki_dir/state"
        local _id_file="$_id_dir/last-proof-id.txt"
        mkdir -p "$_id_dir" 2>/dev/null || true
        if printf '%s' "$_rid" > "${_id_file}.tmp" 2>/dev/null; then
            mv -f "${_id_file}.tmp" "$_id_file" 2>/dev/null || rm -f "${_id_file}.tmp" 2>/dev/null || true
        fi
        # Opt-in build-outcome analytics (default OFF; strict second-layer gate in
        # telemetry.sh). Reads the just-written receipt's allowlisted scalars only;
        # trust-core untouched. Fire-and-forget, never fails the session.
        if declare -f loki_emit_build_verified >/dev/null 2>&1; then
            loki_emit_build_verified "$loki_dir/proofs/$_rid/proof.json" 2>/dev/null || true
        fi
        return 0
    fi

    ITERATION_COUNT="${ITERATION_COUNT:-0}" \
    PROVIDER_NAME="$provider" \
    PRD_PATH="${prd_path:-}" \
    python3 "$gen" \
        --loki-dir "$loki_dir" \
        --loki-version "$ver" \
        --provider "$provider" \
        --session-exit-code "$_result" \
        --quiet >/dev/null 2>&1 || true
    return 0
}

# capture_preedit_snapshot: freeze the agent's raw diff BEFORE anything else
# touches the tree, so quality numbers measure the agent and not the
# agent-plus-whoever-fixed-it (autonomy/lib/preedit_snapshot.py owns the schema
# and the write-once rule; this is only the call site).
#
# WHY THIS IS NOT INSIDE generate_proof_of_run, even though the receipt is the
# obvious neighbour. Two reasons, both measured in this file:
#
#  1. TOO LATE AT THE LATE PROOF SITES. commit_session_changes commits the
#     session's work, and the module's default baseline is `git diff HEAD`.
#     After that commit `git diff HEAD` is EMPTY, so a capture at the teardown
#     proof site would freeze an empty diff -- and because the snapshot is
#     write-once by design, that empty capture would be permanent and
#     unrecoverable. run.sh already documents this mutation window itself: the
#     comment above the final generate_proof_of_run call says "HANDOFF.md and
#     commit_session_changes can change the worktree after the earlier receipt".
#     The receipt can be regenerated against a later tree; the snapshot cannot.
#  2. WRONG GATE. Every generate_proof_of_run call site is gated on
#     LOKI_PROOF!=0, and the run_id resolution only exists inside its
#     LOKI_PROVEN_PR!=0 branch. Authorship evidence and shareable proofs are
#     different concerns, so a user who turns off proofs must not silently lose
#     the ability to tell agent output from human edits.
#
# So the capture happens EARLIER, immediately after run_autonomous returns,
# before any post-processing step can modify the diff.
#
# Baseline: _LOKI_RUN_START_SHA (exported at runner init, persisted to
# .loki/state/start-sha) is passed when available, so the snapshot is anchored
# to the run's own starting commit rather than to a moving HEAD. That makes the
# capture correct even if a later caller fires after a commit. Falls back to the
# module's `git diff HEAD` default when no baseline resolved (greenfield repos
# with no commits write an empty file there by design).
#
# run_id: read-path _loki_trust_run_id ONLY, never --new (minting here would
# clobber the trust-events id file). If it resolves empty we SKIP: a snapshot
# filed under an id nothing else references is worse than no snapshot, because
# verdict.py would count it as authorship evidence that no receipt can join to.
#
# Guarded and non-fatal throughout: a diagnostic must never break the run it is
# diagnosing. Write-once makes repeat calls free (later ones return "exists"),
# so the earliest caller wins and extra call sites cost nothing.
capture_preedit_snapshot() {
    local snap="$SCRIPT_DIR/lib/preedit_snapshot.py"
    [ -f "$snap" ] || return 0
    command -v python3 >/dev/null 2>&1 || return 0
    # Match _loki_trust_run_id's dir expression, not generate_proof_of_run's:
    # with LOKI_DIR set, ${TARGET_DIR:-.}/.loki would write the snapshot beside
    # a run-id file that lives somewhere else.
    local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}/.loki}"
    [ -d "$loki_dir" ] || return 0
    local _rid=""
    if declare -f _loki_trust_run_id >/dev/null 2>&1; then
        _rid="$(_loki_trust_run_id 2>/dev/null || true)"
    fi
    [ -n "$_rid" ] || return 0
    # Resolve the baseline from the PERSISTED file when the exported variable is
    # not visible. _LOKI_RUN_START_SHA is exported inside run_autonomous, and in
    # PARALLEL_MODE run_autonomous runs in a subshell -- an export from a
    # subshell never reaches the parent, so at that call site the variable is
    # empty and only the file survives. Same read the pause path already does.
    # Without this the parallel branch would silently fall back to `git diff
    # HEAD` instead of the anchored baseline this function documents.
    local _base="${_LOKI_RUN_START_SHA:-}"
    [ -n "$_base" ] || _base="$(cat "$loki_dir/state/start-sha" 2>/dev/null || true)"
    # LOKI_PREEDIT_CWD is load-bearing: the module defaults cwd to os.getcwd(),
    # and if that is not the target repo capture returns not_a_git_repo and
    # writes nothing SILENTLY -- a call site that looks wired but never fires.
    LOKI_DIR="$loki_dir" \
    LOKI_PREEDIT_CWD="${TARGET_DIR:-.}" \
    LOKI_RUN_START_SHA="$_base" \
    python3 "$snap" capture "$_rid" >/dev/null 2>&1 || true
    return 0
}

# print_ttfv_next_steps: R7 zero-config first-run "what next / go deeper"
# message. The wording MUST match what actually ran, so it branches on the mode:
#   - brief: a one-line brief ran on the lightweight profile (council off,
#            simple tier, capped iterations). Proof contains diffs, cost, time
#            (council verdicts are absent because the council was disabled).
#   - repo:  a no-arg in-repo run analyzed the codebase and ran at full depth
#            (council on). Proof contains diffs, cost, time, and council
#            verdicts.
# This function only prints; the caller owns the TTY gate. Never fails the run.
# Usage: print_ttfv_next_steps <mode> <result>
print_ttfv_next_steps() {
    local mode="${1:-}"
    local result="${2:-0}"
    local loki_dir="${TARGET_DIR:-.}/.loki"
    local proofs_dir="$loki_dir/proofs"

    echo ""
    echo "============================================================"
    if [ "$result" = "0" ]; then
        echo "  First pass complete. Here is what you have:"
    else
        echo "  First pass ended early. Here is what was produced:"
    fi
    echo "============================================================"
    echo ""
    echo "  What I did:"
    if [ "$mode" = "brief" ] || [ "$mode" = "repo-fast" ]; then
        if [ "$mode" = "repo-fast" ]; then
            echo "    - Inspected your repository on a fast, lightweight first"
            echo "      pass (council off, simple tier, capped iterations)."
        else
        echo "    - Worked from your one-line brief on a fast, lightweight first"
        echo "      pass (council off, simple tier, capped iterations)."
        fi
        echo "    - Generated a proof-of-run (diffs, cost, time)."
    else
        echo "    - Analyzed your codebase and generated a PRD, then ran a full"
        echo "      first pass (council on, full RARV-C depth)."
        echo "    - Generated a proof-of-run (diffs, cost, time, council verdicts)."
    fi
    echo ""
    echo "  See the visible artifact (proof-of-run):"
    if [ -d "$proofs_dir" ]; then
        local latest
        latest=$(ls -1t "$proofs_dir" 2>/dev/null | head -1)
        if [ -n "$latest" ]; then
            echo "    loki proof open $latest"
            echo "    (or open $proofs_dir/$latest/index.html)"
        else
            echo "    loki proof list"
        fi
    else
        echo "    loki proof list"
    fi
    echo ""
    if [ "$mode" = "brief" ] || [ "$mode" = "repo-fast" ]; then
        echo "  Go deeper (full RARV-C depth, council-gated):"
        echo "    loki start                 # continue / harden this project"
        echo "    loki start ./prd.md        # build from a full PRD"
    else
        echo "  Next steps:"
        echo "    loki start ./prd.md        # build from a full PRD"
        echo "    loki start \"<one line>\"    # fast first pass from a brief"
    fi
    # Frictionless discovery: surface 'loki why' so users learn the
    # what-happened/what-to-do diagnosis without having to know it exists.
    echo "    loki why                   # explain this outcome + what to do next"
    echo ""
    return 0
}

# _read_iteration_cost <iteration>
# Emit "input output cost cache_read cache_creation" for the given iteration,
# preferring the authoritative result-cost file written by the embedded stream
# parser (Claude'\''s own total_cost_usd + usage, slug/symlink-independent) over
# the context-tracker-derived estimate in tracking.json. Falls back to
# tracking.json when no result-cost file exists, and to all zeros otherwise.
# Best-effort: any parse failure yields "0 0 0 0 0" and never aborts.
_read_iteration_cost() {
    local iteration="$1"
    local result_cost_file=".loki/metrics/result-cost-${iteration}.json"
    if [ -f "$result_cost_file" ]; then
        python3 -c "
import json
try:
    d = json.load(open('$result_cost_file'))
    print(
        d.get('input_tokens', 0) or 0,
        d.get('output_tokens', 0) or 0,
        d.get('total_cost_usd', 0) or 0,
        d.get('cache_read_tokens', 0) or 0,
        d.get('cache_creation_tokens', 0) or 0,
    )
except Exception:
    print(0, 0, 0, 0, 0)
" 2>/dev/null || echo "0 0 0 0 0"
    elif [ -f ".loki/context/tracking.json" ]; then
        python3 -c "
import json
try:
    t = json.load(open('.loki/context/tracking.json'))
    iters = t.get('per_iteration', [])
    match = [i for i in iters if i.get('iteration') == $iteration]
    if match:
        m = match[-1]
        print(
            m.get('input_tokens', 0),
            m.get('output_tokens', 0),
            m.get('cost_usd', 0),
            m.get('cache_read_tokens', 0),
            m.get('cache_creation_tokens', 0),
        )
    else:
        print(0, 0, 0, 0, 0)
except Exception:
    print(0, 0, 0, 0, 0)
" 2>/dev/null || echo "0 0 0 0 0"
    else
        echo "0 0 0 0 0"
    fi
}

track_iteration_complete() {
    local iteration="$1"
    local exit_code="${2:-0}"
    local task_id="iteration-$iteration"

    mkdir -p .loki/queue

    # Calculate iteration duration (SYN-018)
    local duration_ms
    duration_ms=$(get_iteration_duration_ms)

    # Emit iteration complete event for dashboard
    local status_str
    [ "$exit_code" = "0" ] && status_str="completed" || status_str="failed"
    emit_event_json "iteration_complete" \
        "iteration=$iteration" \
        "status=$status_str" \
        "exitCode=$exit_code" \
        "provider=${PROVIDER_NAME:-claude}"

    # Also emit to pending dir for OTEL bridge
    if [ -n "${LOKI_OTEL_ENDPOINT:-}" ]; then
        emit_event_pending "iteration_complete" \
            "iteration=$iteration" \
            "status=$status_str" \
            "exit_code=$exit_code"
    fi

    # Emit learning signals based on outcome (SYN-018)
    if [ "$exit_code" = "0" ]; then
        # Success pattern for completed iteration
        emit_learning_signal success_pattern \
            --source cli \
            --action "iteration_complete" \
            --pattern-name "rarv_iteration" \
            --action-sequence '["reason", "act", "reflect", "verify"]' \
            --duration "$((duration_ms / 1000))" \
            --outcome success \
            --context "{\"iteration\":$iteration,\"provider\":\"${PROVIDER_NAME:-claude}\"}"
        # Tool efficiency signal
        emit_learning_signal tool_efficiency \
            --source cli \
            --action "iteration_complete" \
            --tool-name "${PROVIDER_NAME:-claude}" \
            --execution-time-ms "$duration_ms" \
            --outcome success \
            --context "{\"iteration\":$iteration}"
    else
        # Error pattern for failed iteration
        emit_learning_signal error_pattern \
            --source cli \
            --action "iteration_complete" \
            --error-type "IterationFailure" \
            --error-message "Iteration $iteration failed with exit code $exit_code" \
            --recovery-steps '["Check logs", "Review error output", "Retry iteration"]' \
            --context "{\"iteration\":$iteration,\"provider\":\"${PROVIDER_NAME:-claude}\",\"exit_code\":$exit_code}"
        # Tool efficiency signal with failure
        emit_learning_signal tool_efficiency \
            --source cli \
            --action "iteration_failed" \
            --tool-name "${PROVIDER_NAME:-claude}" \
            --execution-time-ms "$duration_ms" \
            --outcome failure \
            --context "{\"iteration\":$iteration,\"exit_code\":$exit_code}"
    fi

    # Track context window usage FIRST to get token data (v5.42.0)
    track_context_usage "$iteration"

    # Write efficiency tracking file for /api/cost endpoint.
    # The recorded model MUST be the one that actually ran this iteration. The
    # dispatch exports LOKI_CURRENT_MODEL="$tier_param" (the exact --model value)
    # right before the provider call, AFTER every mutation (opus-pin force,
    # LOKI_MAX_TIER clamp, mid-flight override). That is the single source of
    # truth -- reading it avoids re-deriving the model here and drifting from
    # dispatch. Hardcoding PROVIDER_MODEL_DEVELOPMENT (the old code) mislabeled
    # every non-development iteration: a haiku/fast pin was recorded "sonnet" and
    # an opus pin (tier_param forced to opus) was recorded "sonnet" too, making
    # the model-equivalence bench unfalsifiable. Resolver is the fallback for the
    # policy-blocked path where no dispatch ran (LOKI_CURRENT_MODEL unset).
    mkdir -p .loki/metrics/efficiency
    local model_tier="${LOKI_CURRENT_MODEL:-}"
    if [ -z "$model_tier" ]; then
        model_tier="$(get_provider_tier_param "${CURRENT_TIER:-development}" 2>/dev/null)"
    fi
    # Fallback to the old per-provider default only if both the dispatched model
    # and the resolver are unavailable (e.g. provider config not sourced) so we
    # never write an empty model.
    if [ -z "$model_tier" ]; then
        model_tier="${PROVIDER_MODEL_DEVELOPMENT:-sonnet}"
        if [ "${PROVIDER_NAME:-claude}" = "codex" ]; then
            model_tier="${PROVIDER_MODEL_DEVELOPMENT:-${CODEX_DEFAULT_MODEL:-gpt-5.3-codex}}"
        elif [ "${PROVIDER_NAME:-claude}" = "cline" ]; then
            model_tier="${CLINE_DEFAULT_MODEL:-${LOKI_CLINE_MODEL:-sonnet}}"
        elif [ "${PROVIDER_NAME:-claude}" = "aider" ]; then
            model_tier="${AIDER_DEFAULT_MODEL:-${LOKI_AIDER_MODEL:-openrouter/deepseek/deepseek-v3.2}}"
        fi
    fi
    local phase="${LAST_KNOWN_PHASE:-}"
    [ -z "$phase" ] && phase=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('currentPhase', 'unknown'))" 2>/dev/null || echo "unknown")

    # Read token data, preferring Claude'\''s authoritative result-cost file over
    # the context-tracker estimate (v7.28.0 cost-capture fix). See
    # _read_iteration_cost for precedence rationale.
    # v6.82.0: also capture cache_read_tokens / cache_creation_tokens for
    # prompt-cache hit-rate analysis (S1.1 prompt restructure).
    local iter_input=0 iter_output=0 iter_cost=0 iter_cost_line=""
    local iter_cache_read=0 iter_cache_creation=0
    iter_cost_line="$(_read_iteration_cost "$iteration")"
    read -r iter_input iter_output iter_cost iter_cache_read iter_cache_creation << ITER_COST_EOF
$iter_cost_line
ITER_COST_EOF

    cat > ".loki/metrics/efficiency/iteration-${iteration}.json" << EFF_EOF
{
  "iteration": $iteration,
  "model": "$model_tier",
  "phase": "$phase",
  "duration_ms": $duration_ms,
  "provider": "${PROVIDER_NAME:-claude}",
  "status": "$status_str",
  "input_tokens": ${iter_input:-0},
  "output_tokens": ${iter_output:-0},
  "cache_read_tokens": ${iter_cache_read:-0},
  "cache_creation_tokens": ${iter_cache_creation:-0},
  "cost_usd": ${iter_cost:-0},
  "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EFF_EOF

    # Check notification triggers (v5.40.0)
    check_notification_triggers "$iteration"

    # Sync completed GitHub tasks back to issues (v5.41.0)
    sync_github_completed_tasks

    # Get task from in-progress
    local in_progress_file=".loki/queue/in-progress.json"
    local completed_file=".loki/queue/completed.json"
    local failed_file=".loki/queue/failed.json"

    # Initialize files if needed
    [ ! -f "$completed_file" ] && echo "[]" > "$completed_file"
    [ ! -f "$failed_file" ] && echo "[]" > "$failed_file"

    # Build the completed iteration record + move it out of in-progress, ATOMICALLY.
    # v7.104.3 task-list accuracy fix: the old writer emitted a THIN body
    # {id,type,title:"Iteration N",status,exitCode,provider} with no description
    # and no logs, and then DELETED the rich in-progress record -- so the done
    # column showed empty cards. We now LIFT the honest per-iteration parts (logs,
    # startedAt) from the in-progress record BEFORE removing it, and give the card
    # an HONEST iteration-scoped title/description derived from values in scope
    # ($phase/$exit_code/$duration_ms). We deliberately do NOT reuse the borrowed
    # PRD-story title: because pending stories never leave the queue, the
    # in-progress title is always pending[0] ("server.js..."), so carrying it onto
    # N done cards would falsely imply that story was built N times (fake-green).
    # We also upsert-by-id (no cross-sub-run "iteration-1 x5" accumulation) and
    # keep completed/failed mutually exclusive per id.
    local target_file="$completed_file"
    local other_file="$failed_file"
    [ "$exit_code" != "0" ] && { target_file="$failed_file"; other_file="$completed_file"; }
    local _completed_ts; _completed_ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    _LOKI_TASK_ID="$task_id" \
    _LOKI_ITER="$iteration" \
    _LOKI_PHASE="$phase" \
    _LOKI_EXIT="$exit_code" \
    _LOKI_DUR="$duration_ms" \
    _LOKI_PROVIDER="${PROVIDER_NAME:-claude}" \
    _LOKI_COMPLETED_TS="$_completed_ts" \
    _LOKI_TARGET="$target_file" \
    _LOKI_OTHER="$other_file" \
    _LOKI_INPROG="$in_progress_file" \
    python3 -c "
import json, os

tid = os.environ['_LOKI_TASK_ID']
it = os.environ['_LOKI_ITER']
phase = os.environ.get('_LOKI_PHASE') or 'unknown'
exit_code = os.environ.get('_LOKI_EXIT', '1')
dur = os.environ.get('_LOKI_DUR', '')
provider = os.environ.get('_LOKI_PROVIDER', 'claude')
completed_ts = os.environ['_LOKI_COMPLETED_TS']
target = os.environ['_LOKI_TARGET']
other = os.environ['_LOKI_OTHER']
inprog = os.environ['_LOKI_INPROG']
ok = (exit_code == '0')

def load(p):
    try:
        with open(p) as f:
            d = json.load(f)
        return d if isinstance(d, list) else (d.get('tasks', []) if isinstance(d, dict) else [])
    except Exception:
        return []

# Lift the honest body from the in-progress record before removing it.
prior = next((t for t in load(inprog) if isinstance(t, dict) and t.get('id') == tid), {})
logs = prior.get('logs') if isinstance(prior.get('logs'), list) else []
started = prior.get('startedAt')

dur_s = ''
try:
    if dur not in ('', None):
        _ms = int(float(dur))
        dur_s = f\", {_ms}ms\" if _ms < 1000 else f\", {round(_ms/1000)}s\"
except Exception:
    dur_s = ''

if ok:
    title = f'Iteration {it} complete - {phase}'
    desc = f'Iteration {it} finished cleanly in the {phase} phase (exit 0{dur_s}).'
else:
    title = f'Iteration {it} failed (exit {exit_code})'
    desc = f'Iteration {it} ended in the {phase} phase with exit code {exit_code}{dur_s}.'

entry = {
    'id': tid,
    'type': 'iteration',
    'title': title,
    'description': desc,
    'status': 'completed' if ok else 'failed',
    'phase': phase,
    'completedAt': completed_ts,
    'exitCode': int(exit_code) if str(exit_code).lstrip('-').isdigit() else exit_code,
    'provider': provider,
    'logs': logs,
}
if started:
    entry['startedAt'] = started

# Upsert-by-id into target (drop any stale same-id), cap at 50.
data = [t for t in load(target) if not (isinstance(t, dict) and t.get('id') == tid)]
data.append(entry)
data = data[-50:]
with open(target, 'w') as f:
    json.dump(data, f, indent=2)

# Mutual exclusion: remove this id from the OTHER terminal file.
odata = [t for t in load(other) if not (isinstance(t, dict) and t.get('id') == tid)]
with open(other, 'w') as f:
    json.dump(odata, f, indent=2)

# Remove from in-progress.
idata = [t for t in load(inprog) if not (isinstance(t, dict) and t.get('id') == tid)]
with open(inprog, 'w') as f:
    json.dump(idata, f, indent=2)
" 2>/dev/null || echo "[{\"id\":\"$task_id\",\"type\":\"iteration\",\"title\":\"Iteration $iteration\",\"status\":\"$([ "$exit_code" = "0" ] && echo completed || echo failed)\",\"completedAt\":\"$_completed_ts\",\"exitCode\":$exit_code,\"provider\":\"${PROVIDER_NAME:-claude}\"}]" > "$target_file"

    # BUG-ST-014: Atomic current-task.json clear via temp file + mv
    local ct_tmp=".loki/queue/current-task.json.tmp.$$"
    echo "{}" > "$ct_tmp"
    mv -f "$ct_tmp" .loki/queue/current-task.json

    # Write-back completed BMAD stories to source artifacts (v6.29.0)
    if [ "$exit_code" = "0" ]; then
        bmad_write_back
    fi
}

start_status_monitor() {
    loki_background_services_enabled || return 0
    log_step "Starting status monitor..."

    # Initial update
    update_status_file
    update_agents_state
    write_dashboard_state

    # Background update loop (2-second interval for realtime dashboard)
    (
        while true; do
            update_status_file
            update_agents_state
            write_dashboard_state
            sleep 2
        done
    ) &
    STATUS_MONITOR_PID=$!
    register_pid "$STATUS_MONITOR_PID" "status-monitor"

    log_info "Status monitor started"
    log_info "Monitor progress: ${CYAN}watch -n 2 cat .loki/STATUS.txt${NC}"
}

stop_status_monitor() {
    if [ -n "$STATUS_MONITOR_PID" ]; then
        kill "$STATUS_MONITOR_PID" 2>/dev/null || true
        wait "$STATUS_MONITOR_PID" 2>/dev/null || true
        unregister_pid "$STATUS_MONITOR_PID"
    fi
    stop_resource_monitor
}

#===============================================================================
# Web Dashboard
#===============================================================================

update_agents_state() {
    # Aggregate agent information from .agent/sub-agents/*.json into .loki/state/agents.json
    local agents_dir=".agent/sub-agents"
    local output_file=".loki/state/agents.json"

    # Initialize empty array if no agents directory
    if [ ! -d "$agents_dir" ]; then
        echo "[]" > "$output_file"
        return
    fi

    # Find all agent JSON files and aggregate them
    local agents_json="["
    local first=true

    for agent_file in "$agents_dir"/*.json; do
        # Skip if no JSON files exist
        [ -e "$agent_file" ] || continue

        # Read agent JSON
        local agent_data=$(cat "$agent_file" 2>/dev/null)
        if [ -n "$agent_data" ]; then
            # Add comma separator for all but first entry
            if [ "$first" = true ]; then
                first=false
            else
                agents_json="${agents_json},"
            fi
            agents_json="${agents_json}${agent_data}"
        fi
    done

    agents_json="${agents_json}]"

    # Write aggregated data (atomic via temp file + mv)
    local tmp_file="${output_file}.tmp.$$"
    echo "$agents_json" > "$tmp_file"
    mv -f "$tmp_file" "$output_file" 2>/dev/null || rm -f "$tmp_file"
}

#===============================================================================
# Resource Monitoring
#===============================================================================

check_system_resources() {
    # Check CPU and memory usage and write status to .loki/state/resources.json
    local output_file=".loki/state/resources.json"

    # Get CPU usage (average across all cores)
    local cpu_usage=0
    if [[ "$OSTYPE" == "darwin"* ]]; then
        # macOS: get CPU idle from top header, calculate usage = 100 - idle
        local idle=$(top -l 2 -n 0 | grep "CPU usage" | tail -1 | awk -F'[:,]' '{for(i=1;i<=NF;i++) if($i ~ /idle/) print $(i)}' | awk '{print int($1)}')
        cpu_usage=$((100 - ${idle:-0}))
    elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
        # Linux: use top or mpstat
        cpu_usage=$(top -bn2 | grep "Cpu(s)" | tail -1 | sed "s/.*, *\([0-9.]*\)%* id.*/\1/" | awk '{print int(100 - $1)}')
    else
        cpu_usage=0
    fi

    # Get memory usage
    local mem_usage=0
    if [[ "$OSTYPE" == "darwin"* ]]; then
        # macOS: use vm_stat
        local page_size=$(pagesize)
        local vm_stat=$(vm_stat)
        local pages_free=$(echo "$vm_stat" | awk '/Pages free/ {print $3}' | tr -d '.')
        local pages_active=$(echo "$vm_stat" | awk '/Pages active/ {print $3}' | tr -d '.')
        local pages_inactive=$(echo "$vm_stat" | awk '/Pages inactive/ {print $3}' | tr -d '.')
        local pages_speculative=$(echo "$vm_stat" | awk '/Pages speculative/ {print $3}' | tr -d '.')
        local pages_wired=$(echo "$vm_stat" | awk '/Pages wired down/ {print $4}' | tr -d '.')

        local total_pages=$((pages_free + pages_active + pages_inactive + pages_speculative + pages_wired))
        local used_pages=$((pages_active + pages_wired))
        mem_usage=$((used_pages * 100 / total_pages))
    elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
        # Linux: use free
        mem_usage=$(free | grep Mem | awk '{print int($3/$2 * 100)}')
    else
        mem_usage=0
    fi

    # Determine status
    local cpu_status="ok"
    local mem_status="ok"
    local overall_status="ok"
    local warning_message=""

    if [ "$cpu_usage" -ge "$RESOURCE_CPU_THRESHOLD" ]; then
        cpu_status="high"
        overall_status="warning"
        warning_message="CPU usage is ${cpu_usage}% (threshold: ${RESOURCE_CPU_THRESHOLD}%). Consider reducing parallel agent count or pausing non-critical tasks."
    fi

    if [ "$mem_usage" -ge "$RESOURCE_MEM_THRESHOLD" ]; then
        mem_status="high"
        overall_status="warning"
        if [ -n "$warning_message" ]; then
            warning_message="${warning_message} Memory usage is ${mem_usage}% (threshold: ${RESOURCE_MEM_THRESHOLD}%)."
        else
            warning_message="Memory usage is ${mem_usage}% (threshold: ${RESOURCE_MEM_THRESHOLD}%). Consider reducing parallel agent count or cleaning up resources."
        fi
    fi

    # Write JSON status
    cat > "$output_file" << EOF
{
  "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
  "cpu": {
    "usage_percent": $cpu_usage,
    "threshold_percent": $RESOURCE_CPU_THRESHOLD,
    "status": "$cpu_status"
  },
  "memory": {
    "usage_percent": $mem_usage,
    "threshold_percent": $RESOURCE_MEM_THRESHOLD,
    "status": "$mem_status"
  },
  "overall_status": "$overall_status",
  "warning_message": "$warning_message"
}
EOF

    # Log warning if resources are high
    if [ "$overall_status" = "warning" ]; then
        log_warn "RESOURCE WARNING: $warning_message"
    fi
}

start_resource_monitor() {
    loki_background_services_enabled || return 0
    log_step "Starting resource monitor (checks every ${RESOURCE_CHECK_INTERVAL}s)..."

    # Initial check
    check_system_resources

    # Background monitoring loop
    (
        while true; do
            sleep "$RESOURCE_CHECK_INTERVAL"
            check_system_resources
        done
    ) &
    RESOURCE_MONITOR_PID=$!
    register_pid "$RESOURCE_MONITOR_PID" "resource-monitor"

    log_info "Resource monitor started (CPU threshold: ${RESOURCE_CPU_THRESHOLD}%, Memory threshold: ${RESOURCE_MEM_THRESHOLD}%)"
    log_info "Check status: ${CYAN}cat .loki/state/resources.json${NC}"
}

stop_resource_monitor() {
    if [ -n "$RESOURCE_MONITOR_PID" ]; then
        kill "$RESOURCE_MONITOR_PID" 2>/dev/null || true
        wait "$RESOURCE_MONITOR_PID" 2>/dev/null || true
        unregister_pid "$RESOURCE_MONITOR_PID"
    fi
}

#===============================================================================
# Audit Logging (Enterprise Security)
#===============================================================================

audit_log() {
    # Log security-relevant events for enterprise compliance
    local event_type="$1"
    local event_data="$2"
    local audit_file=".loki/logs/audit-$(date +%Y%m%d).jsonl"

    if [ "$AUDIT_LOG_ENABLED" != "true" ]; then
        return
    fi

    mkdir -p .loki/logs

    local log_entry
    if command -v jq >/dev/null 2>&1; then
        log_entry=$(jq -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg evt "$event_type" --arg data "$event_data" --arg user "$(whoami)" --argjson pid "$$" '{timestamp:$ts,event:$evt,data:$data,user:$user,pid:$pid}')
    else
        local safe_type safe_data
        safe_type=$(printf '%s' "$event_type" | sed 's/["\\]/\\&/g; s/\n/\\n/g')
        safe_data=$(printf '%s' "$event_data" | sed 's/["\\]/\\&/g; s/\n/\\n/g')
        log_entry="{\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"event\":\"$safe_type\",\"data\":\"$safe_data\",\"user\":\"$(whoami)\",\"pid\":$$}"
    fi
    echo "$log_entry" >> "$audit_file"
}

#===============================================================================
# Engine-owned workspace git-init (Plan #16, Option A-1)
#===============================================================================

# Resolve a path to its physical absolute form (symlinks + .. collapsed) using
# whatever is available; falls back to the input unchanged. Portable across the
# BSD (macOS) and GNU userlands the engine runs on.
_loki_resolve_path() {
    local p="${1:-}"
    [ -n "$p" ] || { printf '%s' ""; return 0; }
    if command -v realpath >/dev/null 2>&1; then
        realpath "$p" 2>/dev/null && return 0
    fi
    # python3 is a hard engine dependency; use it as the portable fallback.
    python3 - "$p" <<'PYRESOLVE' 2>/dev/null && return 0
import os, sys
print(os.path.realpath(sys.argv[1]))
PYRESOLVE
    printf '%s' "$p"
}

# True (0) when TARGET_DIR is an engine-owned, freshly-minted build workspace
# that Loki may auto-git-init without surprising a user. Two honest signals:
#   1. LOKI_AUTO_GIT_INIT=1  -- explicit opt-in (non-SaaS automation).
#   2. LOKI_TARGET_DIR is set AND realpath-contained under one of the
#      colon-separated LOKI_WORKSPACE_ROOTS dirs (the v7.91.0 SaaS route: the
#      BFF mints <root>/<buildId> and the server pins LOKI_TARGET_DIR to it).
# A user's own folder (`loki start ./prd.md`, no workspace, roots unset) is
# NEVER engine-owned -- it must not get a silent .git. Realpath containment (not
# a prefix string match) so /root/build-other does not match /root/build.
_loki_workspace_is_engine_owned() {
    [ "${LOKI_AUTO_GIT_INIT:-0}" = "1" ] && return 0

    local roots_raw="${LOKI_WORKSPACE_ROOTS:-}"
    [ -n "${LOKI_TARGET_DIR:-}" ] || return 1
    [ -n "$roots_raw" ] || return 1

    local ws_real root_real
    ws_real="$(_loki_resolve_path "${TARGET_DIR:-.}")"
    [ -n "$ws_real" ] || return 1

    local IFS=':'
    local root
    for root in $roots_raw; do
        [ -n "$root" ] || continue
        root_real="$(_loki_resolve_path "$root")"
        [ -n "$root_real" ] || continue
        # Exact match or contained: ws == root, or ws starts with root + "/".
        if [ "$ws_real" = "$root_real" ] || case "$ws_real" in "$root_real"/*) true ;; *) false ;; esac; then
            return 0
        fi
    done
    return 1
}

# Engine-owned workspaces share their host with the Autonomi control plane.
# Claude Bash commands in those workspaces must load Loki's trusted PreToolUse
# guard explicitly, rather than relying on mutable project settings.
_loki_host_guard_required() {
    case "${LOKI_HOST_GUARD:-0}" in
        1|true|yes|on) return 0 ;;
    esac
    [ -n "${LOKI_TARGET_DIR:-}" ] || return 1
    [ -n "${LOKI_WORKSPACE_ROOTS:-}" ] || return 1
    LOKI_AUTO_GIT_INIT=0 _loki_workspace_is_engine_owned
}

_loki_prepare_host_guard() {
    _loki_host_guard_required || return 0

    if [ "${PROVIDER_NAME:-claude}" != "claude" ]; then
        log_error "Host command guard is required, but provider '${PROVIDER_NAME:-unknown}' has no enforced PreToolUse hook. Refusing to start."
        return 1
    fi
    if ! command -v python3 >/dev/null 2>&1; then
        log_error "Host command guard requires python3 for deterministic command inspection. Refusing to start."
        return 1
    fi

    local hook_path="${PROJECT_DIR}/autonomy/hooks/validate-bash.sh"
    if [ ! -r "$hook_path" ]; then
        log_error "Host command guard hook is missing or unreadable: $hook_path"
        return 1
    fi
    if ! type loki_claude_flag_supported >/dev/null 2>&1 \
       || ! loki_claude_flag_supported "--settings"; then
        log_error "Host command guard requires a Claude CLI with --settings support. Refusing to start."
        return 1
    fi

    local settings_json
    settings_json=$(_LOKI_HOST_GUARD_HOOK="$hook_path" python3 -c '
import json, os, shlex
command = "bash " + shlex.quote(os.environ["_LOKI_HOST_GUARD_HOOK"])
print(json.dumps({"hooks": {"PreToolUse": [{
    "matcher": "Bash",
    "hooks": [{"type": "command", "command": command}],
}]}}))
' 2>/dev/null) || settings_json=""
    if [ -z "$settings_json" ]; then
        log_error "Host command guard settings could not be generated. Refusing to start."
        return 1
    fi

    export LOKI_HOST_GUARD=1
    export LOKI_HOST_GUARD_SETTINGS_JSON="$settings_json"
    return 0
}

# Plan #16 Option A-1: establish git in an engine-owned build workspace so the
# review/verify gate (run_code_review) and branch-isolation chain can actually
# run. Without git history, run_code_review's diff resolves empty and the gate
# SKIPS (silently reporting PASS) -- so a build that was never reviewed could
# earn a VERIFIED receipt. This makes the gate RUN; it does NOT force a green
# (a build whose review/tests fail still gets an honest verdict).
#
# Constraints honored:
#   - Engine-owned workspaces ONLY (see _loki_workspace_is_engine_owned). A
#     user's own folder is never silently git-init'd.
#   - Already a git repo (user's repo OR the engine source tree) -> NO-OP. Only
#     a non-git workspace is initialized.
#   - One INITIAL commit (not a bare init): a zero-commit unborn HEAD breaks the
#     start-SHA capture (git rev-parse HEAD) and re-trips the HEAD~1 skip. The
#     commit uses --allow-empty because a greenfield workspace at build start
#     holds only .loki/ (the spec lands in .loki/specs/), which .loki/.gitignore
#     excludes -> nothing to stage -> a bare commit would fail and leave an
#     unborn HEAD. --allow-empty guarantees a real HEAD either way.
#   - Neutral repo-local identity (loki-build) so the initial commit AND the
#     later commit_session_changes commit never inherit a developer's global git
#     identity. Repo-local sticks on a freshly-init'd workspace (no revert hook).
#   - Secrets never committed: brownfield files are staged through the same
#     secret-scan guard (_commit_path_looks_secret / _commit_scan_secret_file)
#     used by commit_session_changes; any offender unstages the whole set and
#     the initial commit falls back to --allow-empty (HEAD still established).
maybe_git_init_engine_workspace() {
    local ws="${TARGET_DIR:-.}"
    if ! command -v git >/dev/null 2>&1; then
        loki_is_supervised_simple_web && log_error "Git is required for supervised review baselines"
        loki_is_supervised_simple_web && return 1
        return 0
    fi
    if [ ! -d "$ws" ]; then
        loki_is_supervised_simple_web && return 1
        return 0
    fi

    # Already a git repo (user repo or engine source tree): do nothing.
    if git -C "$ws" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
        if loki_is_supervised_simple_web \
           && ! git -C "$ws" rev-parse --verify HEAD >/dev/null 2>&1; then
            log_error "Supervised workspace has no Git baseline commit"
            return 1
        fi
        return 0
    fi
    if ! _loki_workspace_is_engine_owned; then
        loki_is_supervised_simple_web && log_error "Supervised workspace is not eligible for Git baseline initialization"
        loki_is_supervised_simple_web && return 1
        return 0
    fi

    log_info "Engine-owned workspace is not a git repo; initializing for review/verify gates"

    if ! git -C "$ws" init -q >/dev/null 2>&1; then
        if loki_is_supervised_simple_web; then
            log_error "git init failed in supervised engine workspace"
            return 1
        fi
        log_warn "git init failed in engine workspace; review gate will skip (non-fatal)"
        return 0
    fi

    # Neutral repo-local identity for the initial AND session-end commits.
    git -C "$ws" config user.name "loki-build" >/dev/null 2>&1 || true
    git -C "$ws" config user.email "loki-build@autonomi.dev" >/dev/null 2>&1 || true

    # Self-ignore .loki/ runtime state so no commit ever stages it (mirrors
    # setup_agent_branch). Idempotent.
    mkdir -p "$ws/.loki" 2>/dev/null || true
    [ -f "$ws/.loki/.gitignore" ] || printf '*\n' > "$ws/.loki/.gitignore" 2>/dev/null || true

    # Stage everything except .loki/ and an obvious-secret-path denylist (same
    # first-cut excludes commit_session_changes uses). The scan loop below is the
    # real guarantee for nested/weak secrets.
    git -C "$ws" add -A \
        ':!.loki' ':!.loki/' \
        ':!.env' ':!.env.*' ':!*.env' \
        ':!*.key' ':!*.pem' ':!*.p12' ':!*.keystore' \
        ':!id_rsa*' ':!*.token' ':!credentials*' >/dev/null 2>&1 || true

    # Secret-scan staged files. ANY offender -> unstage all (safe default: never
    # commit a possible secret). The --allow-empty commit below still runs so a
    # real HEAD is established regardless.
    local _offenders=""
    local _f _staged_paths
    _staged_paths=$(mktemp "$ws/.loki/.baseline-staged.XXXXXX") || {
        log_error "Initial Git baseline could not allocate a build-local scan file"
        return 1
    }
    if ! git -C "$ws" diff --cached --name-only -z > "$_staged_paths" 2>/dev/null; then
        rm -f "$_staged_paths"
        log_error "Initial Git baseline could not enumerate staged files"
        return 1
    fi
    while IFS= read -r -d '' _f; do
        [ -f "$ws/$_f" ] || continue
        if _commit_path_looks_secret "$ws/$_f" || _commit_scan_secret_file "$ws/$_f"; then
            _offenders="${_offenders}${_offenders:+, }${_f}"
        fi
    done < "$_staged_paths"
    rm -f "$_staged_paths"

    if [ -n "$_offenders" ]; then
        git -C "$ws" reset >/dev/null 2>&1 || true
        log_warn "Initial commit: possible secret in ${_offenders}; left unstaged (baseline commit will be empty)"
    fi

    # ONE initial commit. --allow-empty: a greenfield workspace stages nothing
    # (only .loki/, which is ignored), so a bare commit would fail and leave an
    # unborn HEAD -- the exact failure mode this whole block exists to avoid.
    if git -C "$ws" commit --allow-empty -q -m "loki: initial build workspace baseline" >/dev/null 2>&1; then
        log_info "Initialized git in engine workspace (initial baseline commit created)"
        audit_log "WORKSPACE_GIT_INIT" "workspace=$ws"
    else
        if loki_is_supervised_simple_web; then
            log_error "Initial supervised Git baseline commit failed"
            return 1
        fi
        log_warn "Initial baseline commit failed; review gate may skip (non-fatal)"
    fi
    if loki_is_supervised_simple_web \
       && ! git -C "$ws" rev-parse --verify HEAD >/dev/null 2>&1; then
        log_error "Supervised Git baseline could not be verified"
        return 1
    fi
    return 0
}

#===============================================================================
# Branch Protection for Agent Changes
#===============================================================================

setup_agent_branch() {
    # Create an isolated feature branch for agent changes off the branch Loki
    # was run from. This keeps the user's working branch clean and leaves work
    # on a feature branch ready to PR.
    # Controlled by LOKI_BRANCH_PROTECTION env var (default: true). Set it to
    # "false" to opt out fully and work on the current branch (back-compat).
    local branch_protection="${LOKI_BRANCH_PROTECTION:-true}"

    if [ "$branch_protection" != "true" ]; then
        log_info "Branch protection disabled (LOKI_BRANCH_PROTECTION=${branch_protection})"
        return 0
    fi

    # Need git to do anything here.
    command -v git >/dev/null 2>&1 || { log_warn "git not available - skipping branch protection"; return 0; }

    # Ensure we are inside a git repository
    if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
        log_warn "Not a git repository - skipping branch protection"
        return 0
    fi

    # Self-ignore .loki/ so NO git add (ours or the user's own `git add -A`)
    # can ever stage runtime state (checkpoints, semantic memory, etc.). This is
    # robust regardless of the repo's own .gitignore and applies brownfield and
    # greenfield. Idempotent: write only when missing. Never fatal.
    mkdir -p .loki 2>/dev/null || true
    [ -f .loki/.gitignore ] || printf '*\n' > .loki/.gitignore 2>/dev/null || true

    # Capture the ref Loki was run from. Detached HEAD yields the literal "HEAD".
    local cur=""
    cur="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"

    # Detached HEAD: do NOT branch, do NOT fabricate a base (LOCK A2/A6).
    if [ "$cur" = "HEAD" ]; then
        log_info "Detached HEAD; staying on current commit, no feature branch created"
        return 0
    fi

    # Already on a loki branch (session-* or delegate-*): idempotent reuse, do
    # not nest a branch off a loki branch (LOCK A5/A7).
    case "$cur" in
        loki/*)
            log_info "Already on loki branch ${cur}"
            mkdir -p .loki/state 2>/dev/null || true
            printf '%s\n' "$cur" > .loki/state/agent-branch.txt 2>/dev/null || true
            return 0
            ;;
    esac

    # Resume reuse: if a prior session recorded a checkout-able branch, reuse it
    # instead of minting a new one (LOCK A5).
    local recorded=""
    if [ -s .loki/state/agent-branch.txt ]; then
        recorded="$(cat .loki/state/agent-branch.txt 2>/dev/null || true)"
        if [ -n "$recorded" ] && git rev-parse --verify "$recorded" >/dev/null 2>&1; then
            if git checkout "$recorded" >/dev/null 2>&1; then
                log_info "Resuming on recorded agent branch: ${recorded}"
                return 0
            fi
            log_warn "Recorded agent branch ${recorded} could not be checked out - creating a new one"
        fi
    fi

    # Fresh run: persist the base branch (fresh-run-only) BEFORE branching, then
    # mint and check out the feature branch (LOCK A2).
    local timestamp
    timestamp=$(date +%s)
    local branch_name="loki/session-${timestamp}-$$"

    mkdir -p .loki/state 2>/dev/null || true
    # Persist the base only once per run tree; never overwrite an existing base.
    [ ! -s .loki/state/base-branch.txt ] && printf '%s\n' "$cur" > .loki/state/base-branch.txt 2>/dev/null

    log_info "Branch protection enabled - creating agent branch: $branch_name (base: $cur)"

    # Create and checkout the feature branch
    if ! git checkout -b "$branch_name" 2>/dev/null; then
        log_error "Failed to create agent branch: $branch_name"
        return 1
    fi

    # Store the branch name for later use (PR creation, cleanup)
    printf '%s\n' "$branch_name" > .loki/state/agent-branch.txt 2>/dev/null

    log_info "Agent branch created: $branch_name"
    audit_log "BRANCH_PROTECTION" "branch=$branch_name"
    echo "$branch_name"
}

# RUN-25 iter 21 (Wave D #2): the two secret matchers now live in one sourceable
# lib so the commit gate (here) and the completion evidence gate (completion-
# council.sh) share a single implementation. Sourced with a guard so a re-source
# is a no-op. Falls back to inert stubs only if the lib is somehow missing (the
# commit-time deny path then simply never flags -- same as pre-lib on a broken
# install), but the file ships in the package so this is the normal path.
if [ -f "$SCRIPT_DIR/lib/secret-scan.sh" ]; then
    # shellcheck source=lib/secret-scan.sh
    source "$SCRIPT_DIR/lib/secret-scan.sh"
fi
if ! type _commit_scan_secret_file >/dev/null 2>&1; then _commit_scan_secret_file() { return 1; }; fi
if ! type _commit_path_looks_secret >/dev/null 2>&1; then _commit_path_looks_secret() { return 1; }; fi

commit_session_changes() {
    # Squash the session's work into one honest session-end commit on the agent
    # branch (LOCK A3/A4/A8). Commit-always (incl. failed runs) so the user is
    # left with committed work to inspect/PR. Clean no-op when nothing changed.
    command -v git >/dev/null 2>&1 || return 0
    git rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0

    # Only act when a session feature branch was set up. This preserves the
    # LOCK A1 opt-out contract (LOKI_BRANCH_PROTECTION=false -> no agent-branch.txt
    # -> we never commit on the user's own branch), and also no-ops the detached
    # -HEAD case (setup writes no agent-branch.txt there).
    [ -s .loki/state/agent-branch.txt ] || return 0

    # The worktree/parallel path already commits and merges back (run.sh:3403);
    # skip the squash commit there to avoid a redundant commit on the merge.
    [ "${PARALLEL_MODE:-false}" = "true" ] && return 0

    # Only auto-commit on a branch Loki itself MINTED (loki/session-<ts>-<pid>).
    # If the user manually checked out a self-named loki/* branch (e.g.
    # loki/experiment), recorded it via the idempotent-reuse path, do NOT
    # auto-commit on their behalf. Honest skip.
    # symbolic-ref resolves the branch name even on an UNBORN branch (fresh
    # greenfield `git init` with zero commits, where rev-parse HEAD fails);
    # fall back to rev-parse for older edge cases. Detached HEAD yields nothing.
    local cur=""
    cur="$(git symbolic-ref --short -q HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
    case "$cur" in
        loki/session-*) : ;;  # Loki-minted: proceed.
        *)
            log_info "Not on a Loki-minted session branch (${cur}); skipping auto-commit"
            return 0
            ;;
    esac

    # Stage everything except .loki/ runtime state and a secret-path denylist.
    # These excludes are defense-in-depth ONLY for the top-level cases git
    # pathspec handles cleanly. We deliberately do NOT add nested globs like
    # ':!secrets/**' or ':!**/credentials*' here: in this `git add -A` context a
    # leading ':!**/' / ':!secrets/**' exclude WOULD drop the nested file before
    # it is ever staged, which would mask the file from the scan loop below and
    # make the scan-loop's nested-secret guarantee untestable (the file must be
    # STAGED so the loop can prove it catches it). The scan-and-abort loop below
    # (_commit_path_looks_secret + _commit_scan_secret_file over EVERY staged
    # file) is the actual guarantee for nested/weak secrets; these excludes are a
    # cheap first cut for the obvious top-level files only.
    git add -A \
        ':!.loki' ':!.loki/' \
        ':!.env' ':!.env.*' ':!*.env' \
        ':!*.key' ':!*.pem' ':!*.p12' ':!*.keystore' \
        ':!id_rsa*' ':!*.token' ':!credentials*' 2>/dev/null || true

    # Nothing staged = clean no-op, never an error.
    if git diff --cached --quiet 2>/dev/null; then
        return 0
    fi

    # Secret scan the STAGED files. If ANY staged file matches a secret pattern,
    # ABORT: unstage (git reset -- keeps the working tree changes), print an
    # honest message naming the offending file(s), and return 0 so the run
    # continues with the work PRESERVED uncommitted (safe default: never commit
    # a possible secret). -z handles paths with spaces/newlines.
    # Two complementary layers, OR-ed per staged file:
    #   layer 1 (path heuristic, cheaper, runs first): catches likely-secret
    #           files anywhere in the tree regardless of value strength
    #           (e.g. secrets/credentials.json with {"key":"sk-secret"}).
    #   layer 2 (content scan): catches strong secrets in non-obvious filenames.
    local offenders=""
    local f staged_paths
    staged_paths=$(mktemp ".loki/state/session-staged.XXXXXX") || {
        log_warn "Left uncommitted: could not allocate a build-local secret-scan file."
        git reset >/dev/null 2>&1 || true
        return 0
    }
    if ! git diff --cached --name-only -z > "$staged_paths" 2>/dev/null; then
        rm -f "$staged_paths"
        log_warn "Left uncommitted: could not enumerate staged files for secret scanning."
        git reset >/dev/null 2>&1 || true
        return 0
    fi
    while IFS= read -r -d '' f; do
        [ -f "$f" ] || continue
        if _commit_path_looks_secret "$f" || _commit_scan_secret_file "$f"; then
            offenders="${offenders}${offenders:+, }${f}"
        fi
    done < "$staged_paths"
    rm -f "$staged_paths"

    if [ -n "$offenders" ]; then
        git reset >/dev/null 2>&1 || true
        log_warn "Left uncommitted: possible secret detected in ${offenders}. Review and commit manually."
        audit_agent_action "git_commit_aborted" "Aborted session commit; possible secret" "files=${offenders}" || true
        return 0
    fi

    git commit -m "Loki Mode session changes (${ITERATION_COUNT:-0} iterations, result=${result:-0})" 2>/dev/null || true
    audit_agent_action "git_commit" "Committed session changes" "iterations=${ITERATION_COUNT:-0},result=${result:-0}" || true
    return 0
}

# _loki_proof_json_for_pr
# Resolve THIS run's proof.json path from the persisted run_id pointer
# (.loki/state/last-proof-id.txt, written by generate_proof_of_run / Slice A2).
# Echoes the path when both the pointer and the file exist, else empty. NEVER
# uses newest-by-mtime (R-DET-1). Best-effort, always returns 0. Uses the bare
# relative .loki to match the other create_session_pr state reads (cwd==TARGET_DIR
# at PR time). Returns empty under LOKI_PROVEN_PR=0 (pointer is never written).
_loki_proof_json_for_pr() {
    local id_file=".loki/state/last-proof-id.txt"
    [ -s "$id_file" ] || { printf '%s' ""; return 0; }
    local rid=""
    rid="$(cat "$id_file" 2>/dev/null || true)"
    [ -n "$rid" ] || { printf '%s' ""; return 0; }
    local p=".loki/proofs/$rid/proof.json"
    [ -f "$p" ] || { printf '%s' ""; return 0; }
    printf '%s' "$p"
    return 0
}

create_session_pr() {
    # Advise the user how to open a PR for the agent branch. PRINT-ONLY by
    # default (no push, no PR). LOKI_AUTO_PR=1 restores the legacy auto behavior.
    # Called during session cleanup, after commit_session_changes.
    local branch_file=".loki/state/agent-branch.txt"

    if [ ! -f "$branch_file" ]; then
        # No agent branch was created (branch protection was off)
        return 0
    fi

    local branch_name
    branch_name=$(cat "$branch_file" 2>/dev/null)

    if [ -z "$branch_name" ]; then
        return 0
    fi

    # Read the base branch captured at session start. Do NOT fabricate one.
    local base=""
    if [ -s .loki/state/base-branch.txt ]; then
        base="$(cat .loki/state/base-branch.txt 2>/dev/null || true)"
    fi
    if [ -z "$base" ]; then
        log_info "No recorded base branch; skipping PR advice"
        return 0
    fi

    # Count commits relative to the CAPTURED base (not a hardcoded main).
    local commit_count
    commit_count=$(git rev-list --count HEAD ^"$(git merge-base HEAD "$base" 2>/dev/null || echo HEAD)" 2>/dev/null || echo "0")

    if [ "$commit_count" = "0" ]; then
        log_info "No commits to PR on agent branch ${branch_name}"
        return 0
    fi

    # DEFAULT: advisory only. Print the exact commands; never push, never PR.
    if [ "${LOKI_AUTO_PR:-0}" != "1" ]; then
        if declare -f print_pr_advice >/dev/null 2>&1; then
            print_pr_advice "$base" "$branch_name"
        else
            log_info "To open a pull request: git push -u origin ${branch_name}, then open a PR (base: ${base})"
        fi
        # Proven PR (Loop 6): print the Evidence Receipt block AFTER the push/PR
        # advice so a user opening a manual PR can paste it into the body. This is
        # the print-PR-body fallback. Default-on; LOKI_PROVEN_PR=0 -> not invoked
        # (advisory output byte-identical to before). Production callers pass an
        # empty expected_head_sha: the session commit lands between proof-gen and
        # this point, so the branch head is structurally offset from the proof's
        # head and feeding it would false-degrade every legitimate receipt; the
        # anti-stale guarantee is the run_id pointer (R-DET-1), not a head match.
        if [ "${LOKI_PROVEN_PR:-1}" != "0" ] && declare -f render_evidence_receipt_md >/dev/null 2>&1; then
            local _pr_proof=""
            _pr_proof="$(_loki_proof_json_for_pr 2>/dev/null || true)"
            if [ -n "$_pr_proof" ]; then
                printf '\n'
                render_evidence_receipt_md "$_pr_proof" "" "" || true
            fi
        fi
        return 0
    fi

    # OPT-IN (LOKI_AUTO_PR=1): legacy auto push + PR, now with the correct base.
    log_info "Pushing agent branch: $branch_name"
    if ! git push -u origin "$branch_name" 2>/dev/null; then
        log_warn "Failed to push agent branch: $branch_name"
        return 1
    fi

    # Create PR if gh CLI is available
    if command -v gh &>/dev/null; then
        local pr_url
        # ENT-4 (idempotent PR): check-before-create. On a platform retry (k8s Job
        # backoffLimit / ECS / pod-loss resume) the run can reach this completion
        # path more than once for the SAME head branch. `gh pr create` dedupes by
        # head only because the branch name is stable, but we make the no-duplicate
        # guarantee explicit and the log honest: if an OPEN PR already exists for
        # this head, reuse its URL instead of attempting a second create.
        local existing_pr
        existing_pr=$(gh pr list --head "$branch_name" --state open --json url --jq '.[0].url' 2>/dev/null || true)
        if [ -n "$existing_pr" ]; then
            log_info "PR already exists for branch $branch_name: $existing_pr (skipping create)"
            audit_log "PR_EXISTS" "branch=$branch_name,url=$existing_pr"
            # Proven PR (Loop 6, PO-locked Q1): on idempotent reuse, leave the
            # existing PR untouched (do not rewrite the body, edit the PR, or
            # post a comment). Just hint that an Evidence Receipt is available.
            if [ "${LOKI_PROVEN_PR:-1}" != "0" ]; then
                local _exist_proof=""
                _exist_proof="$(_loki_proof_json_for_pr 2>/dev/null || true)"
                if [ -n "$_exist_proof" ]; then
                    log_info "Evidence Receipt available for this run: loki proof open (run id in .loki/state/last-proof-id.txt)"
                fi
            fi
            return 0
        fi
        # Build the body into a variable so the Proven PR Evidence Receipt can be
        # appended (Loop 6). Default-on; LOKI_PROVEN_PR=0 -> body bytes are
        # byte-identical to the legacy inline body. Empty expected_head_sha by
        # design (see the advisory-branch note: the session commit offsets the
        # branch head from the proof head; R-DET-1 run_id pointer is the guard).
        local _auto_body
        _auto_body="Automated changes from Loki Mode agent session.

Branch: \`$branch_name\`
Session PID: $$
Created: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
        local _auto_proof=""
        if [ "${LOKI_PROVEN_PR:-1}" != "0" ] && declare -f render_evidence_receipt_md >/dev/null 2>&1; then
            _auto_proof="$(_loki_proof_json_for_pr 2>/dev/null || true)"
            if [ -n "$_auto_proof" ]; then
                local _auto_receipt=""
                _auto_receipt="$(render_evidence_receipt_md "$_auto_proof" "" "" 2>/dev/null || true)"
                if [ -n "$_auto_receipt" ]; then
                    _auto_body="${_auto_body}

${_auto_receipt}"
                fi
            fi
        fi
        pr_url=$(gh pr create \
            --title "Loki Mode: Agent session changes ($branch_name)" \
            --body "$_auto_body" \
            --base "$base" \
            --head "$branch_name" 2>/dev/null) || true

        if [ -n "$pr_url" ]; then
            log_info "PR created: $pr_url"
            audit_log "PR_CREATED" "branch=$branch_name,url=$pr_url"
            # Proven PR (Loop 6 / Slice D linkage): persist {run_id, pr_url} next
            # to the proof so the dashboard proofs panel can show "PR #N:
            # <headline>". Slice D owns the READ; Slice A owns this WRITE. Atomic
            # .tmp + mv, python3-guarded for correct JSON escaping, only when a
            # proof for THIS run exists and a pr_url was returned. Best-effort.
            if [ -n "$_auto_proof" ] && command -v python3 >/dev/null 2>&1; then
                local _pr_json_dir
                _pr_json_dir="$(dirname "$_auto_proof" 2>/dev/null || true)"
                if [ -n "$_pr_json_dir" ] && [ -d "$_pr_json_dir" ]; then
                    local _pr_run_id
                    _pr_run_id="$(basename "$_pr_json_dir" 2>/dev/null || true)"
                    LOKI_PR_JSON_DIR="$_pr_json_dir" \
                    LOKI_PR_RUN_ID="$_pr_run_id" \
                    LOKI_PR_URL="$pr_url" \
                    python3 - <<'PR_JSON_PY' 2>/dev/null || true
import json
import os

d = os.environ.get("LOKI_PR_JSON_DIR", "")
run_id = os.environ.get("LOKI_PR_RUN_ID", "")
pr_url = os.environ.get("LOKI_PR_URL", "")
if d and os.path.isdir(d):
    path = os.path.join(d, "pr.json")
    tmp = path + ".tmp"
    try:
        with open(tmp, "w") as f:
            json.dump({"run_id": run_id, "pr_url": pr_url}, f)
        os.replace(tmp, path)
    except Exception:
        try:
            os.remove(tmp)
        except Exception:
            pass
PR_JSON_PY
                fi
            fi
            # Proven PR (Loop 6 / Slice B integration): one guarded call to the
            # optional advisory verified-completion check-run. Posts NOTHING by
            # default (LOKI_PROVEN_PR_CHECK unset). Slice B owns proof-check.sh;
            # guarded on the file being sourced (declare -f) so this slice is
            # correct whether or not B is integrated. Best-effort, never fails PR.
            if [ "${LOKI_PROVEN_PR_CHECK:-0}" = "1" ] && declare -f post_verified_completion_check >/dev/null 2>&1; then
                post_verified_completion_check "${_auto_proof:-}" "$pr_url" || true
            fi
        else
            log_warn "Failed to create PR - branch pushed to: $branch_name"
        fi
    else
        log_info "gh CLI not available - branch pushed to: $branch_name"
        log_info "Create a PR manually for branch: $branch_name"
    fi
}

#===============================================================================
# Agent Action Auditing
#===============================================================================

audit_agent_action() {
    # Record agent actions to a JSONL audit trail.
    # Fire-and-forget: errors are silently ignored to avoid blocking execution.
    # Args: action_type, description, [details]
    local action_type="${1:-unknown}"
    local description="${2:-}"
    local details="${3:-}"
    local audit_file=".loki/logs/agent-audit.jsonl"

    (
        mkdir -p .loki/logs 2>/dev/null

        # Requires python3 for JSON formatting; skip silently if unavailable
        command -v python3 &>/dev/null || exit 0

        local timestamp
        timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
        local iter="${ITERATION_COUNT:-0}"
        local pid="$$"

        python3 -c "
import json, sys
entry = {
    'timestamp': sys.argv[1],
    'action': sys.argv[2],
    'description': sys.argv[3],
    'details': sys.argv[4],
    'iteration': int(sys.argv[5]),
    'pid': int(sys.argv[6])
}
print(json.dumps(entry))
" "$timestamp" "$action_type" "$description" "$details" "$iter" "$pid" >> "$audit_file" 2>/dev/null
    ) &
}

check_staged_autonomy() {
    # In staged autonomy mode, write plan and wait for approval
    local plan_file="$1"

    if [ "$STAGED_AUTONOMY" != "true" ]; then
        return 0
    fi

    log_info "STAGED AUTONOMY: Waiting for plan approval..."
    log_info "Review plan at: $plan_file"
    log_info "Create .loki/signals/PLAN_APPROVED to continue"

    audit_log "STAGED_AUTONOMY_WAIT" "plan=$plan_file"

    # Wait for approval signal
    while [ ! -f ".loki/signals/PLAN_APPROVED" ]; do
        sleep 5
    done

    rm -f ".loki/signals/PLAN_APPROVED"
    audit_log "STAGED_AUTONOMY_APPROVED" "plan=$plan_file"
    log_info "Plan approved, continuing execution..."
}

check_command_allowed() {
    # Check if a command string contains any blocked patterns from BLOCKED_COMMANDS.
    #
    # SECURITY NOTE: This function is intentionally NOT called by run.sh because
    # run.sh does not directly execute arbitrary shell commands from user or agent
    # input. Command execution is handled by the AI CLI's own permission model:
    #   - Claude Code: --dangerously-skip-permissions (with its own allowlist)
    #   - Codex CLI: exec --sandbox workspace-write or exec --dangerously-bypass-approvals-and-sandbox
    #
    # HUMAN_INPUT.md content is injected as a text prompt to the AI agent (not
    # executed as a shell command), and is already guarded by:
    #   - LOKI_PROMPT_INJECTION=false by default (disabled unless explicitly enabled)
    #   - Symlink rejection (prevents path traversal attacks)
    #   - 1MB file size limit
    #
    # This function is retained as a utility for external callers (sandbox.sh,
    # custom hooks, or user scripts) that may need to validate commands against
    # the BLOCKED_COMMANDS list before execution.
    local command="$1"

    IFS=',' read -ra BLOCKED_ARRAY <<< "$BLOCKED_COMMANDS"
    for blocked in "${BLOCKED_ARRAY[@]}"; do
        if [[ "$command" == *"$blocked"* ]]; then
            audit_log "BLOCKED_COMMAND" "command=$command,pattern=$blocked"
            log_error "SECURITY: Blocked dangerous command: $command"
            return 1
        fi
    done

    return 0
}

# A5 (ALLOWED_PATHS enforcement) -- HONEST THREAT MODEL
# --------------------------------------------------------------------------
# The DOMINANT file-write path in an autonomous build is the PROVIDER CLI
# (claude / codex / cline / aider) writing files directly. run.sh hands the
# work to that CLI and never sees those writes as shell redirections, so run.sh
# CANNOT intercept provider-driven writes. ALLOWED_PATHS therefore cannot be a
# complete sandbox; the real containment for provider writes is the OS user +
# the LOKI_SANDBOX_MODE container (sandbox.sh, cap-drop/seccomp/read-only
# mounts), not this shell.
#
# Where ALLOWED_PATHS is actually enforced: the sandbox custom --mount surface.
# That enforcement lives in sandbox.sh, NOT here. When ALLOWED_PATHS is set, a
# host path that loki is about to bind-mount into the container is checked by
# _sandbox_path_within_allowed (autonomy/sandbox.sh), and an operator-supplied
# `loki sandbox run` argv is checked by _sandbox_command_allowed there too.
# run.sh itself does not consume ALLOWED_PATHS for any write decision -- it has
# no run.sh-controlled host-write surface to gate -- so there is no enforcement
# helper here. (A run.sh-local helper existed previously but had zero call
# sites; it was removed so the only enforcement path is the one that genuinely
# runs. See tests/test-allowed-paths-a5.sh, which exercises the sandbox.sh
# functions.)

#===============================================================================
# Cross-Project Learnings Database
#===============================================================================

loki_knowledge_root() {
    if loki_is_supervised_simple_web; then
        printf '%s\n' "${LOKI_DIR:-${TARGET_DIR:-.}/.loki}"
    else
        printf '%s\n' "${HOME}/.loki"
    fi
}

init_learnings_db() {
    # Initialize the cross-project learnings database
    local learnings_dir="$(loki_knowledge_root)/learnings"
    mkdir -p "$learnings_dir"

    # Create database files if they don't exist
    if [ ! -f "$learnings_dir/patterns.jsonl" ]; then
        echo '{"version":"1.0","created":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' > "$learnings_dir/patterns.jsonl"
    fi

    if [ ! -f "$learnings_dir/mistakes.jsonl" ]; then
        echo '{"version":"1.0","created":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' > "$learnings_dir/mistakes.jsonl"
    fi

    if [ ! -f "$learnings_dir/successes.jsonl" ]; then
        echo '{"version":"1.0","created":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' > "$learnings_dir/successes.jsonl"
    fi

    log_info "Learnings database initialized at: $learnings_dir"
}

get_relevant_learnings() {
    # Get learnings relevant to the current context
    local context="$1"
    local learnings_dir="$(loki_knowledge_root)/learnings"
    local output_file=".loki/state/relevant-learnings.json"

    if [ ! -d "$learnings_dir" ]; then
        echo '{"patterns":[],"mistakes":[],"successes":[]}' > "$output_file"
        return
    fi

    # Simple grep-based relevance (can be enhanced with embeddings)
    # Pass context via environment variable to avoid quote escaping issues
    LOKI_CONTEXT="$context" _LOKI_LEARNINGS_DIR="$learnings_dir" python3 << 'LEARNINGS_SCRIPT'
import json
import os

learnings_dir = os.environ["_LOKI_LEARNINGS_DIR"]
context = os.environ.get("LOKI_CONTEXT", "").lower()

def load_jsonl(filepath):
    entries = []
    try:
        with open(filepath, 'r') as f:
            for line in f:
                try:
                    entry = json.loads(line)
                    if 'description' in entry:
                        entries.append(entry)
                except:
                    continue
    except:
        pass
    return entries

def filter_relevant(entries, context, limit=5):
    scored = []
    for e in entries:
        desc = e.get('description', '').lower()
        cat = e.get('category', '').lower()
        score = sum(1 for word in context.split() if word in desc or word in cat)
        if score > 0:
            scored.append((score, e))
    scored.sort(reverse=True, key=lambda x: x[0])
    return [e for _, e in scored[:limit]]

patterns = load_jsonl(f"{learnings_dir}/patterns.jsonl")
mistakes = load_jsonl(f"{learnings_dir}/mistakes.jsonl")
successes = load_jsonl(f"{learnings_dir}/successes.jsonl")

result = {
    "patterns": filter_relevant(patterns, context),
    "mistakes": filter_relevant(mistakes, context),
    "successes": filter_relevant(successes, context)
}

with open(".loki/state/relevant-learnings.json", 'w') as f:
    json.dump(result, f, indent=2)
LEARNINGS_SCRIPT

    log_info "Loaded relevant learnings to: $output_file"
}

extract_learnings_from_session() {
    # Extract learnings from completed session
    local continuity_file=".loki/CONTINUITY.md"

    if [ ! -f "$continuity_file" ]; then
        return
    fi

    log_info "Extracting learnings from session..."

    # Parse CONTINUITY.md for all learning types
    _LOKI_LEARNINGS_DIR="$(loki_knowledge_root)/learnings" python3 << 'EXTRACT_SCRIPT'
import re
import json
import os
import hashlib
from datetime import datetime, timezone

continuity_file = ".loki/CONTINUITY.md"
learnings_dir = os.environ["_LOKI_LEARNINGS_DIR"]
os.makedirs(learnings_dir, exist_ok=True)

if not os.path.exists(continuity_file):
    exit(0)

with open(continuity_file, 'r') as f:
    content = f.read()

project = os.path.basename(os.getcwd())
timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")

def normalize_for_hash(text):
    """Normalize text for consistent hashing (case-insensitive, trimmed)"""
    return text.strip().lower()

def get_existing_hashes(filepath):
    """Get hashes of existing entries to avoid duplicates"""
    hashes = set()
    if os.path.exists(filepath):
        with open(filepath, 'r') as f:
            for line in f:
                try:
                    entry = json.loads(line)
                    if 'description' in entry:
                        normalized = normalize_for_hash(entry['description'])
                        h = hashlib.md5(normalized.encode()).hexdigest()
                        hashes.add(h)
                except:
                    continue
    return hashes

def save_entries(filepath, entries, category):
    """Save entries avoiding duplicates (case-insensitive)"""
    existing = get_existing_hashes(filepath)
    saved = 0
    with open(filepath, 'a') as f:
        for desc in entries:
            # Normalize for deduplication
            normalized = normalize_for_hash(desc)
            h = hashlib.md5(normalized.encode()).hexdigest()
            if h not in existing:
                entry = {
                    "timestamp": timestamp,
                    "project": project,
                    "category": category,
                    "description": desc.strip()
                }
                f.write(json.dumps(entry) + "\n")
                existing.add(h)
                saved += 1
    return saved

def extract_bullets(text):
    """Extract bullet points from text"""
    return [b.strip() for b in re.findall(r'[-*]\s+(.+)', text) if b.strip()]

def extract_numbered_items(text):
    """Extract numbered list items"""
    return [b.strip() for b in re.findall(r'\d+\.\s+(.+)', text) if b.strip()]

# === Extract Mistakes & Learnings ===
mistakes = []

# From ## Mistakes & Learnings section
mistakes_match = re.search(r'## Mistakes & Learnings\n(.*?)(?=\n## |\Z)', content, re.DOTALL)
if mistakes_match:
    mistakes.extend(extract_bullets(mistakes_match.group(1)))

# From ## Challenges Encountered section
challenges_match = re.search(r'## Challenges Encountered\n(.*?)(?=\n## |\Z)', content, re.DOTALL)
if challenges_match:
    mistakes.extend(extract_bullets(challenges_match.group(1)))

if mistakes:
    saved = save_entries(f"{learnings_dir}/mistakes.jsonl", mistakes, "session")
    if saved > 0:
        print(f"Extracted {saved} new mistakes")

# === Extract Patterns (learnings, insights, approaches) ===
patterns = []

# From **Learnings:** sections (most valuable source!)
for match in re.finditer(r'\*\*Learnings:\*\*\n(.*?)(?=\n\*\*|\n###|\n##|\Z)', content, re.DOTALL):
    patterns.extend(extract_bullets(match.group(1)))

# From ## Architecture Decisions section
arch_match = re.search(r'## Architecture Decisions\n(.*?)(?=\n## |\Z)', content, re.DOTALL)
if arch_match:
    patterns.extend(extract_bullets(arch_match.group(1)))

# From ## Patterns Used, ## Solutions Applied sections (if they exist)
for pattern_regex in [
    r'## Patterns Used\n(.*?)(?=\n## |\Z)',
    r'## Solutions Applied\n(.*?)(?=\n## |\Z)',
    r'## Key Approaches\n(.*?)(?=\n## |\Z)',
]:
    match = re.search(pattern_regex, content, re.DOTALL)
    if match:
        patterns.extend(extract_bullets(match.group(1)))

# Also extract inline mentions
patterns.extend(re.findall(r'(?:Pattern|Solution|Approach|Fix Applied):\s*(.+)', content))

if patterns:
    saved = save_entries(f"{learnings_dir}/patterns.jsonl", patterns, "session")
    if saved > 0:
        print(f"Extracted {saved} new patterns")

# === Extract Successes (completed tasks) ===
successes = []

# From **Completed:** sections (numbered lists)
for match in re.finditer(r'\*\*Completed:\*\*\n(.*?)(?=\n\*\*|\n###|\n##|\Z)', content, re.DOTALL):
    successes.extend(extract_numbered_items(match.group(1)))
    successes.extend(extract_bullets(match.group(1)))

# From ## Completed Tasks, ## Achievements sections (if they exist)
for pattern_regex in [
    r'## Completed Tasks\n(.*?)(?=\n## |\Z)',
    r'## Achievements\n(.*?)(?=\n## |\Z)',
    r'## Done\n(.*?)(?=\n## |\Z)',
]:
    match = re.search(pattern_regex, content, re.DOTALL)
    if match:
        successes.extend(extract_bullets(match.group(1)))

# Extract [x] completed checkboxes
successes.extend(re.findall(r'\[x\]\s+(.+)', content, re.IGNORECASE))

# From ## Session Summary sections (key accomplishments)
for match in re.finditer(r'## Session \d+ Summary.*?\n(.*?)(?=\n## |\Z)', content, re.DOTALL):
    successes.extend(extract_bullets(match.group(1)))

if successes:
    saved = save_entries(f"{learnings_dir}/successes.jsonl", successes, "session")
    if saved > 0:
        print(f"Extracted {saved} new successes")

print("Learning extraction complete")
EXTRACT_SCRIPT
}

# ============================================================================
# Session Continuity - Automatic CONTINUITY.md Management
# Creates/updates .loki/CONTINUITY.md with structured working memory
# so agents can cheaply load session context (<500 tokens / ~2KB)
# ============================================================================

update_continuity() {
    local continuity_file=".loki/CONTINUITY.md"
    local iteration="${ITERATION_COUNT:-0}"
    local provider="${PROVIDER_NAME:-claude}"
    local phase=""

    # Read current phase from orchestrator state
    if [ -f ".loki/state/orchestrator.json" ]; then
        phase=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('currentPhase', 'BOOTSTRAP'))" 2>/dev/null || echo "BOOTSTRAP")
    else
        phase="BOOTSTRAP"
    fi

    # Calculate elapsed time from orchestrator startedAt
    local elapsed="0m"
    if [ -f ".loki/state/orchestrator.json" ]; then
        local started_at
        started_at=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('startedAt', ''))" 2>/dev/null || echo "")
        if [ -n "$started_at" ]; then
            local elapsed_secs
            export _CONT_STARTED_AT="$started_at"
            elapsed_secs=$(python3 << 'ELAPSED_CALC'
import os
from datetime import datetime, timezone
try:
    sa = os.environ["_CONT_STARTED_AT"]
    start = datetime.fromisoformat(sa.replace("Z", "+00:00"))
    now = datetime.now(timezone.utc)
    print(int((now - start).total_seconds()))
except Exception:
    print(0)
ELAPSED_CALC
)
            elapsed_secs="${elapsed_secs:-0}"
            unset _CONT_STARTED_AT
            elapsed=$(format_duration "$elapsed_secs")
        fi
    fi

    # Get RARV phase name
    local rarv_phase=""
    if [ "$iteration" -gt 0 ]; then
        rarv_phase=$(get_rarv_phase_name "$iteration")
    fi

    # Use python3 with env vars (no shell interpolation into Python code)
    export _CONT_FILE="$continuity_file"
    export _CONT_ITERATION="$iteration"
    export _CONT_PHASE="$phase"
    export _CONT_PROVIDER="$provider"
    export _CONT_ELAPSED="$elapsed"
    export _CONT_RARV="$rarv_phase"

    python3 << 'CONTINUITY_SCRIPT'
import json
import os
from datetime import datetime, timezone

cont_file = os.environ["_CONT_FILE"]
iteration = os.environ["_CONT_ITERATION"]
phase = os.environ["_CONT_PHASE"]
provider = os.environ["_CONT_PROVIDER"]
elapsed = os.environ["_CONT_ELAPSED"]
rarv = os.environ.get("_CONT_RARV", "")
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

sections = []
sections.append(f"# Session Continuity\n\nUpdated: {timestamp}\n")

# Current State
state_lines = [f"- Iteration: {iteration}"]
if phase:
    state_lines.append(f"- Phase: {phase}")
if rarv:
    state_lines.append(f"- RARV Step: {rarv}")
state_lines.append(f"- Provider: {provider}")
state_lines.append(f"- Elapsed: {elapsed}")
sections.append("## Current State\n\n" + "\n".join(state_lines) + "\n")

# Last Completed Task - from last git commit
last_task_lines = []
try:
    import subprocess
    result = subprocess.run(
        ["git", "log", "-1", "--pretty=format:%s", "--no-merges"],
        capture_output=True, text=True, timeout=5
    )
    if result.returncode == 0 and result.stdout.strip():
        last_task_lines.append(f"- Last commit: {result.stdout.strip()[:120]}")
    files_result = subprocess.run(
        ["git", "diff", "--name-only", "HEAD~1", "HEAD"],
        capture_output=True, text=True, timeout=5
    )
    if files_result.returncode == 0 and files_result.stdout.strip():
        changed = files_result.stdout.strip().split("\n")[:5]
        last_task_lines.append(f"- Files changed: {', '.join(changed)}")
        if len(files_result.stdout.strip().split("\n")) > 5:
            last_task_lines.append(f"  (+{len(files_result.stdout.strip().split(chr(10))) - 5} more)")
except Exception:
    pass
if not last_task_lines:
    last_task_lines.append("- No commits yet")
sections.append("## Last Completed Task\n\n" + "\n".join(last_task_lines) + "\n")

# Active Blockers
blocker_lines = []
blocked_file = ".loki/queue/blocked.json"
if os.path.exists(blocked_file):
    try:
        with open(blocked_file) as f:
            blocked = json.load(f)
        if isinstance(blocked, dict):
            blocked = blocked.get("tasks", [])
        for b in blocked[:3]:
            title = b.get("title", b.get("id", "unknown"))
            reason = b.get("reason", b.get("description", ""))
            line = f"- {title}"
            if reason:
                line += f": {reason[:80]}"
            blocker_lines.append(line)
    except Exception:
        pass
if not blocker_lines:
    blocker_lines.append("- None")
sections.append("## Active Blockers\n\n" + "\n".join(blocker_lines) + "\n")

# Next Up - top 3 from pending queue
next_lines = []
pending_file = ".loki/queue/pending.json"
if os.path.exists(pending_file):
    try:
        with open(pending_file) as f:
            pending = json.load(f)
        if isinstance(pending, dict):
            pending = pending.get("tasks", [])
        for t in pending[:3]:
            title = t.get("title", t.get("id", "unknown"))
            next_lines.append(f"- {title}")
    except Exception:
        pass
if not next_lines:
    next_lines.append("- No pending tasks")
sections.append("## Next Up\n\n" + "\n".join(next_lines) + "\n")

# Key Decisions - from memory timeline (last 5)
decision_lines = []
timeline_file = ".loki/memory/timeline.json"
if os.path.exists(timeline_file):
    try:
        with open(timeline_file) as f:
            timeline = json.load(f)
        decisions = []
        if isinstance(timeline, list):
            for entry in timeline:
                if entry.get("type") == "key_decision" or "decision" in entry.get("type", ""):
                    decisions.append(entry)
                elif "key_decisions" in entry:
                    for d in entry["key_decisions"]:
                        decisions.append(d if isinstance(d, dict) else {"description": str(d)})
        elif isinstance(timeline, dict) and "key_decisions" in timeline:
            decisions = timeline["key_decisions"]
        for d in decisions[-5:]:
            desc = d.get("description", d.get("title", d.get("summary", str(d))))
            if isinstance(desc, str):
                decision_lines.append(f"- {desc[:100]}")
    except Exception:
        pass
if not decision_lines:
    decision_lines.append("- None recorded yet")
sections.append("## Key Decisions This Session\n\n" + "\n".join(decision_lines) + "\n")

# Write the file (overwrite each time to keep it fresh)
os.makedirs(os.path.dirname(cont_file) if os.path.dirname(cont_file) else ".", exist_ok=True)
with open(cont_file, "w") as f:
    f.write("\n".join(sections))
CONTINUITY_SCRIPT

    # Clean up exported env vars
    unset _CONT_FILE _CONT_ITERATION _CONT_PHASE _CONT_PROVIDER _CONT_ELAPSED _CONT_RARV

    log_info "Updated session continuity: $continuity_file"
}

# ============================================================================
# Knowledge Compounding - Structured Solutions (v5.30.0)
# Inspired by Compound Engineering Plugin's docs/solutions/ with YAML frontmatter
# ============================================================================

compound_session_to_solutions() {
    # Compound JSONL learnings into structured solution markdown files
    local knowledge_root="$(loki_knowledge_root)"
    local learnings_dir="$knowledge_root/learnings"
    local solutions_dir="$knowledge_root/solutions"

    if [ ! -d "$learnings_dir" ]; then
        return
    fi

    log_info "Compounding learnings into structured solutions..."

    _LOKI_LEARNINGS_DIR="$learnings_dir" _LOKI_SOLUTIONS_DIR="$solutions_dir" python3 << 'COMPOUND_SCRIPT'
import json
import os
import re
import hashlib
from datetime import datetime, timezone
from collections import defaultdict

learnings_dir = os.environ["_LOKI_LEARNINGS_DIR"]
solutions_dir = os.environ["_LOKI_SOLUTIONS_DIR"]

# Fixed categories
CATEGORIES = ["security", "performance", "architecture", "testing", "debugging", "deployment", "general"]

# Category keyword mapping
CATEGORY_KEYWORDS = {
    "security": ["auth", "login", "password", "token", "injection", "xss", "csrf", "cors", "secret", "encrypt", "permission", "role", "session", "cookie", "oauth", "jwt"],
    "performance": ["cache", "query", "n+1", "memory", "leak", "slow", "timeout", "pool", "index", "optimize", "bundle", "lazy", "render", "batch"],
    "architecture": ["pattern", "solid", "coupling", "abstraction", "module", "interface", "design", "refactor", "structure", "layer", "separation", "dependency"],
    "testing": ["test", "mock", "fixture", "coverage", "assert", "spec", "e2e", "playwright", "jest", "flaky", "snapshot"],
    "debugging": ["debug", "error", "trace", "log", "stack", "crash", "exception", "breakpoint", "inspect", "diagnose"],
    "deployment": ["deploy", "docker", "ci", "cd", "pipeline", "kubernetes", "k8s", "nginx", "ssl", "domain", "env", "config", "build"],
}

def load_jsonl(filepath):
    entries = []
    if not os.path.exists(filepath):
        return entries
    with open(filepath, 'r') as f:
        for line in f:
            try:
                entry = json.loads(line)
                if 'description' in entry:
                    entries.append(entry)
            except:
                continue
    return entries

def classify_category(description):
    desc_lower = description.lower()
    scores = {}
    for cat, keywords in CATEGORY_KEYWORDS.items():
        scores[cat] = sum(1 for kw in keywords if kw in desc_lower)
    best = max(scores, key=scores.get)
    return best if scores[best] > 0 else "general"

def slugify(text):
    slug = re.sub(r'[^a-z0-9]+', '-', text.lower().strip())
    return slug.strip('-')[:80]

def solution_exists(solutions_dir, title_slug):
    for cat in CATEGORIES:
        cat_dir = os.path.join(solutions_dir, cat)
        if os.path.exists(cat_dir):
            if os.path.exists(os.path.join(cat_dir, f"{title_slug}.md")):
                return True
    return False

# Load all learnings
patterns = load_jsonl(os.path.join(learnings_dir, "patterns.jsonl"))
mistakes = load_jsonl(os.path.join(learnings_dir, "mistakes.jsonl"))
successes = load_jsonl(os.path.join(learnings_dir, "successes.jsonl"))

# Group by category
grouped = defaultdict(list)
for entry in patterns + mistakes + successes:
    cat = classify_category(entry.get('description', ''))
    grouped[cat].append(entry)

created = 0
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")

for category, entries in grouped.items():
    if len(entries) < 2:
        continue  # Need at least 2 related entries to compound

    # Create category directory
    cat_dir = os.path.join(solutions_dir, category)
    os.makedirs(cat_dir, exist_ok=True)

    # Group similar entries (simple: by shared keywords)
    # Take the most descriptive entry as the title
    best_entry = max(entries, key=lambda e: len(e.get('description', '')))
    title = best_entry['description'][:120]
    slug = slugify(title)

    if solution_exists(solutions_dir, slug):
        continue  # Already compounded

    # Extract tags from all entries
    all_words = ' '.join(e.get('description', '') for e in entries).lower()
    tags = []
    for kw_list in CATEGORY_KEYWORDS.values():
        for kw in kw_list:
            if kw in all_words and kw not in tags:
                tags.append(kw)
    tags = tags[:8]  # Limit to 8 tags

    # Build symptoms from mistake entries
    symptoms = []
    for e in entries:
        desc = e.get('description', '')
        if any(w in desc.lower() for w in ['error', 'fail', 'bug', 'crash', 'issue', 'problem']):
            symptoms.append(desc[:200])
    symptoms = symptoms[:4]
    if not symptoms:
        symptoms = [entries[0].get('description', '')[:200]]

    # Build solution content from pattern/success entries
    solution_lines = []
    for e in entries:
        desc = e.get('description', '')
        if not any(w in desc.lower() for w in ['error', 'fail', 'bug', 'crash']):
            solution_lines.append(f"- {desc}")
    if not solution_lines:
        solution_lines = [f"- {entries[0].get('description', '')}"]

    project = best_entry.get('project', os.path.basename(os.getcwd()))

    # Write solution file
    filepath = os.path.join(cat_dir, f"{slug}.md")
    with open(filepath, 'w') as f:
        f.write(f"---\n")
        f.write(f'title: "{title}"\n')
        f.write(f"category: {category}\n")
        f.write(f"tags: [{', '.join(tags)}]\n")
        f.write(f"symptoms:\n")
        for s in symptoms:
            f.write(f'  - "{s}"\n')
        f.write(f'root_cause: "Identified from {len(entries)} related learnings across sessions"\n')
        f.write(f'prevention: "See solution details below"\n')
        f.write(f"confidence: {min(0.5 + 0.1 * len(entries), 0.95):.2f}\n")
        f.write(f'source_project: "{project}"\n')
        f.write(f'created: "{now}"\n')
        f.write(f"applied_count: 0\n")
        f.write(f"---\n\n")
        f.write(f"## Solution\n\n")
        f.write('\n'.join(solution_lines) + '\n\n')
        f.write(f"## Context\n\n")
        f.write(f"Compounded from {len(entries)} learnings ")
        f.write(f"({len([e for e in entries if e in patterns])} patterns, ")
        f.write(f"{len([e for e in entries if e in mistakes])} mistakes, ")
        f.write(f"{len([e for e in entries if e in successes])} successes) ")
        f.write(f"from project: {project}\n")

    created += 1

if created > 0:
    print(f"Compounded {created} new solution files to {solutions_dir}")
else:
    print("No new solutions to compound (need 2+ related learnings per category)")
COMPOUND_SCRIPT
}


# ============================================================================
# Hard Quality Gate: Static Analysis (v6.7.0)
# Detects project type and runs appropriate linter on changed files
# Results stored in .loki/quality/static-analysis.json
# ============================================================================

# ---------------------------------------------------------------------------
# enforce_build_check -- run the stack's build (if it has one) and record an
# HONEST build fact for the Evidence Receipt (#47). Historically nothing wrote
# .loki/quality/build-results.json, so facts.build was PERMANENTLY "not_run" for
# every project -- a CLI with no build step read identically to a skipped real
# build, and both dragged the receipt to "WITH GAPS". This is the missing writer.
#
# The distinction the receipt needs (and the founder demanded, no fabrication) is
# THREE-WAY, not two (a council review caught the two-way version laundering
# "unrecognized build system" into a fake N/A):
#   - a build command WE RUN exists (npm "build" / go / cargo) -> RUN it, record
#     ran/exit_code (verified on 0, failed otherwise -- a real gap kept honest).
#   - a build system EXISTS but we do not auto-run it (Make/Maven/Gradle/.NET, a
#     non-"build" npm script, a pyproject build-system) -> not_run (an HONEST GAP,
#     matching verify.sh:291's "skipped"). NEVER N/A -- a real, un-run build must
#     never read VERIFIED.
#   - genuinely NO build phase at all (a plain CLI/script) -> applicable:false
#     (status not_applicable). A POSITIVE "no build phase" determination, NOT an
#     absent file -- proof-generator maps applicable:false -> N/A (not a gap) but a
#     MISSING file or an unrecognized-real build stays not_run. One vocabulary, no
#     fake-green. Pinned by tests/test-build-check-applicability.sh.
# ---------------------------------------------------------------------------
_loki_supervised_build_result_passes() {
    local result_file="$1"
    ! loki_is_supervised_simple_web && return 0
    [ -s "$result_file" ] || return 1
    python3 - "$result_file" <<'PYEOF' 2>/dev/null
import json
import sys

try:
    with open(sys.argv[1], encoding="utf-8") as handle:
        result = json.load(handle)
except (OSError, ValueError, TypeError):
    raise SystemExit(1)

status = result.get("status")
applicable = result.get("applicable")
if status == "verified" and applicable is True:
    raise SystemExit(0)
if status == "not_applicable" and applicable is False:
    raise SystemExit(0)
raise SystemExit(1)
PYEOF
}

enforce_build_check() {
    local tree="${TARGET_DIR:-.}"
    local loki_dir="$tree/.loki"
    local quality_dir="$loki_dir/quality"
    mkdir -p "$quality_dir"
    local out_file="$quality_dir/build-results.json"
    local timeout_s="${LOKI_GATE_TIMEOUT:-300}"
    local ts; ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"

    # THREE-WAY classification (detection is always cheap). The anti-fake-green line
    # (council-caught): N/A is ONLY for a POSITIVE "this stack has no build phase"
    # determination. An unrecognized-but-real build system (Make, Maven, Gradle,
    # .NET, a non-"build" npm build script) must stay an HONEST GAP (not_run), NOT
    # be laundered into N/A -- otherwise a real, un-run build reads VERIFIED. This
    # mirrors verify.sh:291, which records the same fall-through as a "skipped" gap.
    #   runnable   -> a build command WE run (npm build / go / cargo) -> verified/failed
    #   present    -> a build system EXISTS but we don't auto-run it   -> not_run (GAP)
    #   none       -> positively no build phase at all                 -> not_applicable (N/A)
    local cmd="" ran="false" applicable="false" exit_code="null" status="not_run"
    local build_class="present"

    # INVERTED classification (three council rounds each found a fake-green hole in
    # an allowlist of build systems -> the allowlist is structurally leaky). The
    # two error directions are NOT equal: a REAL build read as N/A -> VERIFIED is
    # DISHONEST over-claim (the cardinal fake-green sin); a no-build CLI read as a
    # not_run gap is an HONEST under-claim (merely the founder's original UX nit).
    # So not_run is the SAFE CATCH-ALL and N/A requires a POSITIVE proof-of-no-build.
    # A forgotten stack (Bazel, meson, autotools, Zig, Nix...) now falls to not_run
    # (honest), never to fake N/A -- fixing the whole CLASS, not each instance.
    #   1. runnable: a build command WE run -> verified/failed
    #   2. none:     POSITIVE proof of no build phase -> not_applicable (N/A)
    #   3. else:     not_run (honest gap) -- the default, covers every unknown stack

    if [ -f "$tree/package.json" ] && grep -q '"build"[[:space:]]*:' "$tree/package.json" 2>/dev/null; then
        cmd="npm run build"; applicable="true"; build_class="runnable"
    elif [ -f "$tree/go.mod" ] && command -v go >/dev/null 2>&1; then
        cmd="go build ./..."; applicable="true"; build_class="runnable"
    elif [ -f "$tree/Cargo.toml" ] && command -v cargo >/dev/null 2>&1; then
        cmd="cargo build"; applicable="true"; build_class="runnable"
    else
        # POSITIVE proof-of-no-build (the ONLY path to N/A). Two narrow cases, and
        # anything short of a clear determination stays not_run:
        #   (a) a package.json whose scripts exist but include NO build-ish script
        #       AND there is no foreign build manifest -> a JS CLI/lib with no build.
        #   (b) NO build manifest of ANY known kind at all -> a plain script dir.
        # The build-ish regex is used as an EXCLUSION so a build hidden under a
        # non-"build" script name (compile/dist/...) does NOT misfire N/A.
        local has_foreign_manifest="false"
        if [ -f "$tree/go.mod" ] || [ -f "$tree/Cargo.toml" ] \
           || [ -f "$tree/Makefile" ] || [ -f "$tree/makefile" ] || [ -f "$tree/GNUmakefile" ] \
           || [ -f "$tree/Makefile.in" ] || [ -f "$tree/configure" ] || [ -f "$tree/configure.ac" ] || [ -f "$tree/configure.in" ] \
           || [ -f "$tree/CMakeLists.txt" ] || [ -f "$tree/meson.build" ] \
           || [ -f "$tree/pom.xml" ] || [ -f "$tree/build.xml" ] \
           || [ -f "$tree/build.gradle" ] || [ -f "$tree/build.gradle.kts" ] \
           || [ -f "$tree/settings.gradle" ] || [ -f "$tree/settings.gradle.kts" ] || [ -f "$tree/gradlew" ] \
           || [ -f "$tree/build.sbt" ] \
           || [ -f "$tree/WORKSPACE" ] || [ -f "$tree/WORKSPACE.bazel" ] || [ -f "$tree/MODULE.bazel" ] \
           || [ -f "$tree/setup.py" ] || [ -f "$tree/pyproject.toml" ] \
           || compgen -G "$tree/*.csproj" >/dev/null 2>&1 \
           || compgen -G "$tree/*.sln" >/dev/null 2>&1 \
           || compgen -G "$tree/*.fsproj" >/dev/null 2>&1; then
            has_foreign_manifest="true"
        fi
        # N/A fires on POSITIVE proof-of-no-build, which we can only assert for a
        # package.json project: it EXISTS and its scripts are build-ish-free and no
        # foreign manifest / build-tool devDep is present -> a JS CLI/lib that
        # genuinely has no build step (the founder's invoice case). CRITICAL (the
        # council's convergence line): a project with NO package.json is "I don't
        # recognize a build here" = IGNORANCE, not proof-of-no-build. Ignorance must
        # map to not_run (honest gap), NEVER N/A -- else Zig/autotools/bare-C/meson/
        # bazel/anything-unborn silently reads VERIFIED. This makes the ENTIRE
        # no-package.json universe honest by CONSTRUCTION (list-free), which is why
        # this converges where four rounds of adding-to-a-list could not.
        if [ "$has_foreign_manifest" = "false" ] && [ -f "$tree/package.json" ]; then
            local has_build_devdep="false"
            # A build TOOL as a (dev)dependency means a build exists even if it runs
            # under a non-"build" script name -> stay not_run, do not claim N/A.
            if grep -qE '"(vite|webpack|rollup|esbuild|parcel|typescript|tsc|@swc/core|turbopack|rspack|tsup|microbundle)"[[:space:]]*:' "$tree/package.json" 2>/dev/null; then
                has_build_devdep="true"
            fi
            if [ "$has_build_devdep" = "false" ] \
               && ! grep -qE '"(build|compile|dist|bundle|webpack|vite|tsc|rollup|esbuild|prepare|prepack)"[[:space:]]*:' "$tree/package.json" 2>/dev/null; then
                applicable="false"; build_class="none"
            fi
            # else: a build-ish script or a build-tool devDep we don't auto-run ->
            # stays not_run (an honest gap). Known bounded edge: a polyglot repo with
            # a package.json (no build) co-present with an UNLISTED foreign build
            # manifest could read N/A; not common and not list-free-fixable, so it is
            # a documented CONCERN, not a blocker.
        fi
        # No package.json (foreign or not) -> build_class stays "present" -> not_run.
        # has_foreign_manifest=true -> also not_run. Only the narrow package.json-
        # no-build case above reaches N/A.
    fi

    if [ "$build_class" = "present" ]; then
        # A build phase exists but we did not run it -> honest not_run gap. applicable
        # stays true (a build IS applicable), status not_run (we did not verify it).
        applicable="true"; ran="false"; status="not_run"
        log_info "Build check: a build system is present but not auto-run -> honest gap (not verified)"
    elif [ "$applicable" = "true" ]; then
        # EXECUTION is expensive (a real vite/webpack/go/cargo build, seconds to
        # minutes) so we run it AT MOST ONCE per build run, not every iteration --
        # honoring the "fastest / lowest cost" goal. A freshness marker (mirrors the
        # test-results.iter pattern) records that the build already ran this run; a
        # later iteration reuses the recorded verified/failed status instead of
        # re-building. LOKI_BUILD_CHECK=0 opts out entirely (records N/A-style skip
        # without running). If a prior result exists and is still valid, keep it.
        local marker="$quality_dir/.build-check.done"
        if [ "${LOKI_BUILD_CHECK:-1}" = "0" ]; then
            log_info "Build check: execution disabled (LOKI_BUILD_CHECK=0); recording applicability only"
            ran="false"; exit_code="null"; status="not_run"
        elif [ -f "$marker" ] && [ -f "$out_file" ]; then
            log_info "Build check: already ran this build; reusing recorded result"
            _loki_supervised_build_result_passes "$out_file"
            return $?
        else
            local rc=0
            ( cd "$tree" && timeout "$timeout_s" sh -c "$cmd" >/dev/null 2>&1 ) || rc=$?
            ran="true"; exit_code="$rc"
            if [ "$rc" -eq 0 ]; then status="verified"; else status="failed"; fi
            : > "$marker" 2>/dev/null || true
            log_info "Build check: $cmd -> exit $rc"
        fi
    else
        # build_class="none": a POSITIVE proof-of-no-build determination -> honest
        # N/A (the founder's CLI). applicable already false; status not_applicable.
        applicable="false"; ran="false"; status="not_applicable"
        log_info "Build check: no build step for this stack (N/A)"
    fi

    printf '{"timestamp":"%s","command":"%s","ran":%s,"applicable":%s,"exit_code":%s,"duration_sec":null,"status":"%s"}\n' \
        "$ts" "$cmd" "$ran" "$applicable" "$exit_code" "$status" > "$out_file"
    _loki_supervised_build_result_passes "$out_file"
}

# True when the workspace's package.json declares a non-empty `lint` script, so
# the static-analysis gate can run the app's OWN linter (oxlint/eslint/biome/...)
# via `npm run lint` rather than only recognizing eslint config files. Pure read
# of package.json; returns non-zero on absence / no lint script / unreadable.
has_npm_lint_script() {
    local dir="${1:-.}"
    local pkg="$dir/package.json"
    [ -f "$pkg" ] || return 1
    # Parse with python3 (already a hard dep of the engine) so a `"lint":` inside
    # a string value or comment cannot yield a false positive; require a real,
    # non-empty scripts.lint entry.
    python3 - "$pkg" <<'PYEOF' 2>/dev/null
import json, sys
try:
    with open(sys.argv[1]) as fh:
        data = json.load(fh)
except Exception:
    sys.exit(1)
scripts = data.get("scripts") or {}
lint = scripts.get("lint")
sys.exit(0 if isinstance(lint, str) and lint.strip() else 1)
PYEOF
}

enforce_static_analysis() {
    local loki_dir="${TARGET_DIR:-.}/.loki"
    local quality_dir="$loki_dir/quality"
    mkdir -p "$quality_dir" "$loki_dir/signals"

    local changed_files baseline="${_LOKI_ITER_START_SHA:-}"
    local baseline_tree="${_LOKI_ITER_START_TREE:-}"
    if [ -n "$baseline_tree" ] \
       && git -C "${TARGET_DIR:-.}" rev-parse --verify "${baseline_tree}^{tree}" >/dev/null 2>&1; then
        # Compare two throwaway-index snapshots so the baseline includes the
        # exact committed, staged, unstaged, and untracked bytes that existed
        # before the provider ran. The real index is never modified.
        local current_tree current_rc=0
        current_tree=$(_loki_snapshot_workspace_tree "${TARGET_DIR:-.}" 2>/dev/null) || current_rc=$?
        if [ "$current_rc" -eq 0 ]; then
            changed_files=$(git -C "${TARGET_DIR:-.}" diff-tree --no-commit-id \
                --name-only -r "$baseline_tree" "$current_tree" -- . ':(exclude).loki/' \
                2>/dev/null) || current_rc=$?
        fi
        if [ "$current_rc" -ne 0 ]; then
            log_warn "Static analysis: exact iteration snapshot comparison failed; using the conservative Git fallback"
            baseline_tree=""
        fi
    elif [ -n "$baseline_tree" ]; then
        log_warn "Static analysis: iteration snapshot is unavailable; using the conservative Git fallback"
        baseline_tree=""
    fi
    if [ -z "$baseline_tree" ]; then
        if [ -n "$baseline" ] \
           && git -C "${TARGET_DIR:-.}" rev-parse --verify "$baseline" >/dev/null 2>&1; then
            changed_files=$(
                {
                    git -C "${TARGET_DIR:-.}" diff --name-only "$baseline" -- 2>/dev/null
                    git -C "${TARGET_DIR:-.}" ls-files --others --exclude-standard 2>/dev/null
                } | awk '!/(^|\/)\.loki(\/|$)/' | LC_ALL=C sort -u
            )
        else
            # Conservative fallback for callers without an iteration baseline.
            # Never rely solely on HEAD~1, which is invalid in a one-commit repo.
            changed_files=$(
                {
                    git -C "${TARGET_DIR:-.}" diff --name-only HEAD -- 2>/dev/null
                    git -C "${TARGET_DIR:-.}" diff --name-only --cached 2>/dev/null
                    if git -C "${TARGET_DIR:-.}" rev-parse --verify HEAD~1 >/dev/null 2>&1; then
                        git -C "${TARGET_DIR:-.}" diff --name-only HEAD~1 HEAD -- 2>/dev/null
                    fi
                    git -C "${TARGET_DIR:-.}" ls-files --others --exclude-standard 2>/dev/null
                } | awk '!/(^|\/)\.loki(\/|$)/' | LC_ALL=C sort -u
            )
        fi
    fi
    if [ -z "$changed_files" ]; then
        log_info "Static analysis: no changed files to check"
        touch "$quality_dir/static-analysis.pass"
        return 0
    fi

    local findings=0
    local total_checked=0
    local details=""
    local gate_timeout="${LOKI_GATE_TIMEOUT:-300}"
    case "$gate_timeout" in
        ''|*[!0-9]*) gate_timeout=300 ;;
    esac
    [ "$gate_timeout" -gt 0 ] 2>/dev/null || gate_timeout=300

    # JavaScript/TypeScript
    local js_files
    js_files=$(echo "$changed_files" | grep -E '\.(js|ts|jsx|tsx)$' || true)
    if [ -n "$js_files" ]; then
        local abs_files=""
        for f in $js_files; do
            [ -f "${TARGET_DIR:-.}/$f" ] && abs_files="$abs_files ${TARGET_DIR:-.}/$f"
        done
        if [ -n "$abs_files" ]; then
            total_checked=$((total_checked + $(echo "$abs_files" | wc -w)))
            # v7.x (static-analysis honest coverage): ADDITIVELY run the app's OWN
            # declared `lint` script when present. Generated apps increasingly use
            # oxlint / biome (not eslint) -- e.g. package.json `"lint": "oxlint"`
            # with a `.oxlintrc.json` -- which the eslint-config probe below does
            # not recognize, so the app's real lint check was skipped. Running
            # `npm run lint` respects whatever linter the app declares. This is
            # ADDITIVE (not a replacement): we STILL run the eslint/tsc type checks
            # below, so a lint pass never silences the type-error check for TS apps
            # (oxlint is not type-aware). rc 127 = the declared linter is not
            # installed/resolvable -> HONEST skip, never counted as a violation
            # (rc-only signal; a real lint failure whose OUTPUT mentions "not
            # found" must still count, so we do NOT grep the message).
            if has_npm_lint_script "${TARGET_DIR:-.}"; then
                local lint_out lint_rc=0
                lint_out=$(cd "${TARGET_DIR:-.}" && \
                    LOKI_DEADLINE_IDLE_TIMEOUT=0 _loki_with_deadline "$gate_timeout" \
                    npm run --silent lint 2>&1) || lint_rc=$?
                if [ "$lint_rc" -eq 127 ]; then
                    log_info "Static analysis: app 'lint' script did not resolve a linter (not run, honest skip)"
                elif [ "$lint_rc" -ne 0 ]; then
                    findings=$((findings + 1))
                    if [ "$lint_rc" -eq 124 ]; then
                        details="${details}Lint (npm run lint) deadline: exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
                    else
                        details="${details}Lint (npm run lint, exit ${lint_rc}): $(echo "$lint_out" | tail -3 | tr '\n' ' '). "
                    fi
                fi
            fi
            # Type/syntax check path (unchanged contract): eslint when configured,
            # else tsc project-mode + per-file parse. Runs REGARDLESS of the lint
            # step above so TS type errors are always caught.
            if [ -f "${TARGET_DIR:-.}/.eslintrc.js" ] || [ -f "${TARGET_DIR:-.}/.eslintrc.json" ] || \
               [ -f "${TARGET_DIR:-.}/eslint.config.js" ] || [ -f "${TARGET_DIR:-.}/eslint.config.mjs" ]; then
                local eslint_out eslint_rc=0
                # shellcheck disable=SC2086
                eslint_out=$(cd "${TARGET_DIR:-.}" && \
                    LOKI_DEADLINE_IDLE_TIMEOUT=0 _loki_with_deadline "$gate_timeout" \
                    npx eslint $js_files 2>&1) || eslint_rc=$?
                if [ "$eslint_rc" -ne 0 ]; then
                    findings=$((findings + 1))
                    if [ "$eslint_rc" -eq 124 ]; then
                        details="${details}ESLint deadline: exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
                    else
                        details="${details}ESLint (exit ${eslint_rc}): $(echo "$eslint_out" | tail -3 | tr '\n' ' '). "
                    fi
                fi
            else
                # v7.5.12 (Triage #2): when tsconfig.json exists, run
                # `tsc --noEmit -p .` ONCE so paths/baseUrl/types resolve.
                # Per-file `tsc` invocations ignore tsconfig and false-block on
                # path-aliased imports (e.g. `@/x`) in Next.js / NestJS /
                # monorepo projects. Only count errors that reference files
                # changed in this iteration; pre-existing errors in unchanged
                # files must not block.
                local _ts_project_mode=0
                local _tsc_bin=""
                if [ -x "${TARGET_DIR:-.}/node_modules/.bin/tsc" ]; then
                    _tsc_bin="$(cd "${TARGET_DIR:-.}" 2>/dev/null && pwd -P)/node_modules/.bin/tsc"
                elif command -v tsc &>/dev/null; then
                    _tsc_bin="$(command -v tsc)"
                fi
                if [ -f "${TARGET_DIR:-.}/tsconfig.json" ] && [ -n "$_tsc_bin" ]; then
                    local _has_ts=0
                    for f in $abs_files; do
                        case "$f" in *.ts|*.tsx|*.jsx) _has_ts=1; break ;; esac
                    done
                    if [ "$_has_ts" -eq 1 ]; then
                        _ts_project_mode=1
                        local _tsc_out _tsc_rc=0
                        _tsc_out=$(cd "${TARGET_DIR:-.}" && \
                            LOKI_DEADLINE_IDLE_TIMEOUT=0 _loki_with_deadline "$gate_timeout" \
                            "$_tsc_bin" --noEmit -p . 2>&1) || _tsc_rc=$?
                        if [ "$_tsc_rc" -ne 0 ]; then
                            if [ "$_tsc_rc" -eq 124 ]; then
                                findings=$((findings + 1))
                                details="${details}TypeScript project check deadline: exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
                            else
                                local _changed_ts_errors=""
                                for f in $js_files; do
                                    case "$f" in
                                        *.ts|*.tsx|*.jsx)
                                            # tsc emits paths relative to project root with `(line,col):` suffix.
                                            # v7.5.12 Dev11 (R1 MED): use grep -F (literal) so filenames
                                            # containing regex metacharacters cannot cause false positives
                                            # or malformed regex. Two literal passes for the `(` and `:`
                                            # suffix forms tsc emits.
                                            if grep -qF -- "${f}(" <<<"$_tsc_out" || grep -qF -- "${f}:" <<<"$_tsc_out"; then
                                                _changed_ts_errors="${_changed_ts_errors}${f} "
                                            fi
                                            ;;
                                    esac
                                done
                                if [ -n "$_changed_ts_errors" ]; then
                                    findings=$((findings + 1))
                                    details="${details}TS errors in changed files: ${_changed_ts_errors}. "
                                else
                                    log_info "Static analysis: tsc -p . reported errors only in unchanged files (not blocking)"
                                fi
                            fi
                        fi
                    fi
                fi
                for f in $abs_files; do
                    # node --check cannot parse TypeScript / TSX / JSX files; it
                    # crashes with ERR_UNKNOWN_FILE_EXTENSION (JSX) or a parse error
                    # (TS syntax). Route .ts/.tsx AND .jsx to the JSX-capable tsc
                    # path; only plain .js/.mjs/.cjs go through node --check. Before
                    # the .jsx addition here, every valid .jsx file was falsely
                    # reported as a "Syntax error" (node --check ERR_UNKNOWN_FILE_
                    # EXTENSION) -- a whole class of false static-analysis findings
                    # on React apps.
                    case "$f" in
                        *.ts|*.tsx|*.jsx)
                            # When tsconfig project-mode handled it above, skip
                            # the per-file fallback to avoid duplicate / false errors.
                            if [ "$_ts_project_mode" -eq 1 ]; then
                                continue
                            fi
                            # Use the project's installed TypeScript compiler when
                            # available, then a global compiler. Never execute a
                            # TypeScript module as a syntax probe. In particular,
                            # `bun --check file.ts` executes the module on current
                            # Bun releases and can turn valid Vitest files into a
                            # false static-analysis failure.
                            if [ -n "$_tsc_bin" ]; then
                                local _ts_file_out _ts_file_rc=0
                                _ts_file_out=$(LOKI_DEADLINE_IDLE_TIMEOUT=0 \
                                    _loki_with_deadline "$gate_timeout" "$_tsc_bin" \
                                    --noEmit --allowJs --jsx preserve --target esnext "$f" 2>&1) \
                                    || _ts_file_rc=$?
                                if [ "$_ts_file_rc" -ne 0 ]; then
                                    findings=$((findings + 1))
                                    if [ "$_ts_file_rc" -eq 124 ]; then
                                        details="${details}TypeScript file check deadline: file=${f} exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
                                    else
                                        details="${details}TS syntax error: $f. "
                                    fi
                                fi
                            elif command -v bun &>/dev/null; then
                                # `bun build` compiles without evaluating the
                                # module. Externalizing imports keeps this a
                                # bounded syntax probe and avoids executing test
                                # runners, setup files, or application effects.
                                local _bun_rc=0
                                LOKI_DEADLINE_IDLE_TIMEOUT=0 _loki_with_deadline "$gate_timeout" \
                                    bun build "$f" --target=bun --external='*' --outfile /dev/null \
                                    >/dev/null 2>&1 || _bun_rc=$?
                                if [ "$_bun_rc" -ne 0 ]; then
                                    findings=$((findings + 1))
                                    if [ "$_bun_rc" -eq 124 ]; then
                                        details="${details}Bun syntax check deadline: file=${f} exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
                                    else
                                        details="${details}TS syntax error (bun build): $f. "
                                    fi
                                fi
                            elif command -v npx &>/dev/null; then
                                local _npx_tsc_out _npx_tsc_rc=0
                                _npx_tsc_out=$(cd "${TARGET_DIR:-.}" && \
                                    LOKI_DEADLINE_IDLE_TIMEOUT=0 _loki_with_deadline "$gate_timeout" \
                                    npx --no-install tsc --noEmit --allowJs --jsx preserve \
                                    --target esnext "$f" 2>&1) || _npx_tsc_rc=$?
                                if [ "$_npx_tsc_rc" -eq 127 ]; then
                                    log_info "Static analysis: skipping $f (local npx tsc is unavailable)"
                                elif [ "$_npx_tsc_rc" -ne 0 ]; then
                                    findings=$((findings + 1))
                                    if [ "$_npx_tsc_rc" -eq 124 ]; then
                                        details="${details}Local npx TypeScript check deadline: file=${f} exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
                                    else
                                        details="${details}TS syntax error (local npx tsc): $f. "
                                    fi
                                fi
                            else
                                log_info "Static analysis: skipping $f (no local or global tsc available)"
                            fi
                            ;;
                        *)
                            local _node_rc=0
                            LOKI_DEADLINE_IDLE_TIMEOUT=0 _loki_with_deadline "$gate_timeout" \
                                node --check "$f" 2>&1 || _node_rc=$?
                            if [ "$_node_rc" -ne 0 ]; then
                                findings=$((findings + 1))
                                if [ "$_node_rc" -eq 124 ]; then
                                    details="${details}Node syntax check deadline: file=${f} exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
                                else
                                    details="${details}Syntax error: $f. "
                                fi
                            fi
                            ;;
                    esac
                done
            fi
        fi
    fi

    # Product-source contract: catch deterministic defects before spending a
    # reviewer call. This is intentionally limited to universal UI failures,
    # not product-specific copy or design opinions.
    local _ui_contract_out="" _ui_contract_rc=0
    _ui_contract_out=$(
        _LOKI_CHANGED_FILES="$changed_files" python3 - "${TARGET_DIR:-.}" <<'PYEOF' 2>/dev/null
import os
import re
import sys
from pathlib import Path

root = Path(sys.argv[1]).resolve()
source_suffixes = {".html", ".htm", ".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"}
excluded_parts = {".git", ".loki", "build", "dist", "node_modules", "vendor"}
test_markers = {"__fixtures__", "__mocks__", "fixtures", "mocks", "test", "tests"}
emoji = re.compile(
    "["
    "\U0001F1E6-\U0001F1FF"
    "\U0001F300-\U0001FAFF"
    "\U00002600-\U000027BF"
    "]"
)
dead_href = re.compile(
    r"\bhref\s*=\s*(?:[\"']\s*(?:#|javascript\s*:\s*void\s*\(\s*0\s*\))?\s*[\"']|"
    r"\{\s*[\"']\s*(?:#|javascript\s*:\s*void\s*\(\s*0\s*\))?\s*[\"']\s*\})",
    re.IGNORECASE,
)

findings = []
for rel in os.environ.get("_LOKI_CHANGED_FILES", "").splitlines():
    rel = rel.strip()
    if not rel:
        continue
    path = Path(rel)
    if path.suffix.lower() not in source_suffixes or excluded_parts.intersection(path.parts):
        continue
    full = path if path.is_absolute() else root / path
    try:
        text = full.read_text(encoding="utf-8")
    except (OSError, UnicodeError):
        continue
    is_test = bool(test_markers.intersection(path.parts)) or bool(
        re.search(r"(?:^|[._-])(?:test|spec|fixture|mock)(?:[._-]|$)", path.name, re.IGNORECASE)
    )
    for number, line in enumerate(text.splitlines(), 1):
        if emoji.search(line):
            findings.append(f"emoji_character:{rel}:{number}")
        if not is_test and dead_href.search(line):
            findings.append(f"dead_href:{rel}:{number}")

for finding in findings[:20]:
    print(finding)
raise SystemExit(1 if findings else 0)
PYEOF
    ) || _ui_contract_rc=$?
    if [ "$_ui_contract_rc" -ne 0 ]; then
        local _ui_contract_count
        _ui_contract_count=$(printf '%s\n' "$_ui_contract_out" | awk 'NF { count++ } END { print count + 0 }')
        [ "$_ui_contract_count" -gt 0 ] 2>/dev/null || _ui_contract_count=1
        findings=$((findings + _ui_contract_count))
        details="${details}UI source contract: $(printf '%s' "$_ui_contract_out" | tr '\n\"' " '") . "
    fi

    # Python
    local py_files
    py_files=$(echo "$changed_files" | grep -E '\.py$' || true)
    if [ -n "$py_files" ]; then
        for f in $py_files; do
            [ -f "${TARGET_DIR:-.}/$f" ] || continue
            total_checked=$((total_checked + 1))
            local _py_compile_rc=0
            LOKI_DEADLINE_IDLE_TIMEOUT=0 _loki_with_deadline "$gate_timeout" \
                python3 -m py_compile "${TARGET_DIR:-.}/$f" 2>&1 || _py_compile_rc=$?
            if [ "$_py_compile_rc" -ne 0 ]; then
                findings=$((findings + 1))
                if [ "$_py_compile_rc" -eq 124 ]; then
                    details="${details}py_compile deadline: file=${f} exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
                else
                    details="${details}py_compile failed: $f. "
                fi
            fi
        done
        if command -v ruff &>/dev/null; then
            local ruff_files=""
            for f in $py_files; do
                [ -f "${TARGET_DIR:-.}/$f" ] && ruff_files="$ruff_files ${TARGET_DIR:-.}/$f"
            done
            if [ -n "$ruff_files" ]; then
                # shellcheck disable=SC2086
                local _ruff_rc=0
                LOKI_DEADLINE_IDLE_TIMEOUT=0 _loki_with_deadline "$gate_timeout" \
                    ruff check $ruff_files 2>&1 || _ruff_rc=$?
                if [ "$_ruff_rc" -ne 0 ]; then
                    findings=$((findings + 1))
                    if [ "$_ruff_rc" -eq 124 ]; then
                        details="${details}Ruff deadline: exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
                    else
                        details="${details}Ruff check found issues. "
                    fi
                fi
            fi
        fi
    fi

    # Shell scripts
    local sh_files
    sh_files=$(echo "$changed_files" | grep -E '\.sh$' || true)
    if [ -n "$sh_files" ]; then
        for f in $sh_files; do
            [ -f "${TARGET_DIR:-.}/$f" ] || continue
            total_checked=$((total_checked + 1))
            local _bash_syntax_rc=0
            LOKI_DEADLINE_IDLE_TIMEOUT=0 _loki_with_deadline "$gate_timeout" \
                bash -n "${TARGET_DIR:-.}/$f" 2>&1 || _bash_syntax_rc=$?
            if [ "$_bash_syntax_rc" -ne 0 ]; then
                findings=$((findings + 1))
                if [ "$_bash_syntax_rc" -eq 124 ]; then
                    details="${details}Bash syntax deadline: file=${f} exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
                else
                    details="${details}Syntax error: $f. "
                fi
            fi
        done
        if command -v shellcheck &>/dev/null; then
            # v7.5.12 (Triage #3): only `error` severity blocks. style/info/warning
            # findings on WIP shell scripts must not block iteration. `.shellcheckrc`
            # in the target dir is honored automatically by shellcheck (do not override).
            for f in $sh_files; do
                [ -f "${TARGET_DIR:-.}/$f" ] || continue
                local _shellcheck_rc=0
                LOKI_DEADLINE_IDLE_TIMEOUT=0 _loki_with_deadline "$gate_timeout" \
                    shellcheck -S error "${TARGET_DIR:-.}/$f" 2>&1 || _shellcheck_rc=$?
                if [ "$_shellcheck_rc" -ne 0 ]; then
                    findings=$((findings + 1))
                    if [ "$_shellcheck_rc" -eq 124 ]; then
                        details="${details}Shellcheck deadline: file=${f} exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
                    else
                        details="${details}shellcheck (error severity): $f. "
                    fi
                fi
            done
        fi
    fi

    # Go
    if [ -f "${TARGET_DIR:-.}/go.mod" ]; then
        local go_files
        go_files=$(echo "$changed_files" | grep -E '\.go$' || true)
        if [ -n "$go_files" ] && command -v go &>/dev/null; then
            total_checked=$((total_checked + $(echo "$go_files" | wc -w)))
            local _go_vet_rc=0
            (cd "${TARGET_DIR:-.}" && LOKI_DEADLINE_IDLE_TIMEOUT=0 \
                _loki_with_deadline "$gate_timeout" go vet ./... 2>&1) || _go_vet_rc=$?
            if [ "$_go_vet_rc" -ne 0 ]; then
                findings=$((findings + 1))
                if [ "$_go_vet_rc" -eq 124 ]; then
                    details="${details}Go vet deadline: exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
                else
                    details="${details}go vet found issues. "
                fi
            fi
        fi
    fi

    # Rust
    if [ -f "${TARGET_DIR:-.}/Cargo.toml" ] && command -v cargo &>/dev/null; then
        total_checked=$((total_checked + 1))
        local _cargo_check_rc=0
        (cd "${TARGET_DIR:-.}" && LOKI_DEADLINE_IDLE_TIMEOUT=0 \
            _loki_with_deadline "$gate_timeout" cargo check 2>&1) || _cargo_check_rc=$?
        if [ "$_cargo_check_rc" -ne 0 ]; then
            findings=$((findings + 1))
            if [ "$_cargo_check_rc" -eq 124 ]; then
                details="${details}Cargo check deadline: exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
            else
                details="${details}cargo check failed. "
            fi
        fi
    fi

    # C / C++ (P1-6: cppcheck is a standalone static analyzer that needs no
    # build system, headers, or compile flags, so it does not false-block on
    # missing includes the way a per-file `clang` compile would. The exit gate
    # fires only on `error` severity; style/warning/portability findings on WIP
    # code do not block. When cppcheck is absent we pass through honestly
    # (log, no block) rather than silently skipping.)
    local cfiles
    cfiles=$(echo "$changed_files" | grep -E '\.(c|cc|cpp|cxx|h|hpp|hxx)$' || true)
    if [ -n "$cfiles" ]; then
        local cabs=""
        for f in $cfiles; do
            [ -f "${TARGET_DIR:-.}/$f" ] && cabs="$cabs ${TARGET_DIR:-.}/$f"
        done
        if [ -n "$cabs" ]; then
            if command -v cppcheck &>/dev/null; then
                total_checked=$((total_checked + $(echo "$cabs" | wc -w)))
                # Default cppcheck reports ONLY error severity, so with
                # --error-exitcode=2 the gate returns 2 exclusively on an
                # error-severity finding. We deliberately do NOT pass
                # --enable=warning: that would make warning/style/portability
                # findings on incomplete WIP code block the iteration (verified:
                # a deref-then-null-check warning returns 2 under --enable=warning
                # but 0 under the default ruleset). Error severity only = honest
                # parity with the TS/shell `-S error` gates above.
                local cpp_out cpp_rc=0
                # shellcheck disable=SC2086
                cpp_out=$(LOKI_DEADLINE_IDLE_TIMEOUT=0 \
                    _loki_with_deadline "$gate_timeout" \
                    cppcheck --quiet --error-exitcode=2 $cabs 2>&1) || cpp_rc=$?
                if [ "$cpp_rc" -eq 2 ]; then
                    findings=$((findings + 1))
                    details="${details}cppcheck (error severity): $(echo "$cpp_out" | tail -3 | tr '\n' ' '). "
                elif [ "$cpp_rc" -eq 124 ]; then
                    findings=$((findings + 1))
                    details="${details}Cppcheck deadline: exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
                elif [ "$cpp_rc" -eq 125 ]; then
                    findings=$((findings + 1))
                    details="${details}Cppcheck deadline helper unavailable: exit_code=125. "
                fi
            else
                log_info "Static analysis: cppcheck not on PATH, skipping C/C++ check (pass-through)"
            fi
        fi
    fi

    # Kotlin (P1-6: ktlint and detekt are standalone, build-system-free linters.
    # Prefer ktlint; fall back to detekt. Absent -> honest pass-through.)
    #
    # ADVISORY ONLY (not blocking): unlike cppcheck/checkstyle which expose an
    # error-vs-style severity distinction, ktlint is a pure formatter -- every
    # finding it reports is a style/formatting issue and it exits nonzero on ANY
    # violation, with no CLI mode to fail only on error severity. detekt's failure
    # threshold is config-driven (maxIssues) and its findings are code smells, not
    # compiler errors; there is no stable CLI flag to fail only on error severity.
    # Per the gate principle (a new-language arm must NOT block on style/formatting,
    # consistent with cppcheck's error-exitcode-only and the JS/TS/Py `-S error`
    # gates), we run these linters as ADVISORY: report findings via log_warn and
    # the details string, but do NOT increment `findings` (no BLOCK). This avoids
    # false-blocking a WIP build on formatting. Absent -> honest pass-through.
    local kt_files
    kt_files=$(echo "$changed_files" | grep -E '\.(kt|kts)$' || true)
    if [ -n "$kt_files" ]; then
        local kt_abs=""
        for f in $kt_files; do
            [ -f "${TARGET_DIR:-.}/$f" ] && kt_abs="$kt_abs ${TARGET_DIR:-.}/$f"
        done
        if [ -n "$kt_abs" ]; then
            if command -v ktlint &>/dev/null; then
                total_checked=$((total_checked + $(echo "$kt_abs" | wc -w)))
                local kt_out kt_rc=0
                # shellcheck disable=SC2086
                kt_out=$(cd "${TARGET_DIR:-.}" && LOKI_DEADLINE_IDLE_TIMEOUT=0 \
                    _loki_with_deadline "$gate_timeout" ktlint $kt_files 2>&1) || kt_rc=$?
                if [ "$kt_rc" -ne 0 ]; then
                    # Advisory: ktlint reports only style/formatting; warn, do not block.
                    if [ "$kt_rc" -eq 124 ]; then
                        details="${details}Ktlint advisory deadline: exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
                    else
                        details="${details}ktlint advisory (style, non-blocking): $(echo "$kt_out" | tail -3 | tr '\n' ' '). "
                    fi
                    log_warn "Static analysis: ktlint reported style findings (advisory, non-blocking)"
                fi
            elif command -v detekt &>/dev/null; then
                total_checked=$((total_checked + $(echo "$kt_abs" | wc -w)))
                local dt_out dt_input dt_rc=0
                dt_input=$(echo "$kt_files" | tr ' \n' ',,' | sed 's/,*$//;s/^,*//')
                dt_out=$(cd "${TARGET_DIR:-.}" && LOKI_DEADLINE_IDLE_TIMEOUT=0 \
                    _loki_with_deadline "$gate_timeout" detekt --input "$dt_input" 2>&1) || dt_rc=$?
                if [ "$dt_rc" -ne 0 ]; then
                    # Advisory: detekt threshold is config-driven, findings are code
                    # smells (no error-severity-only CLI mode); warn, do not block.
                    if [ "$dt_rc" -eq 124 ]; then
                        details="${details}Detekt advisory deadline: exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
                    else
                        details="${details}detekt advisory (code smell, non-blocking): $(echo "$dt_out" | tail -3 | tr '\n' ' '). "
                    fi
                    log_warn "Static analysis: detekt reported findings (advisory, non-blocking)"
                fi
            else
                log_info "Static analysis: ktlint/detekt not on PATH, skipping Kotlin check (pass-through)"
            fi
        fi
    fi

    # Java (P1-6: checkstyle is a pure static linter that needs no compile or
    # classpath, but it REQUIRES a config file. A per-file `javac` would
    # false-block on unresolved imports/classpath the way per-file tsc did, so
    # Java is gated on checkstyle-with-config only. Without a config we pass
    # through honestly. C# is deferred: roslyn analyzers and `dotnet build` need
    # a full project + restore, which cannot be auto-detected cleanly per-file.)
    local java_files
    java_files=$(echo "$changed_files" | grep -E '\.java$' || true)
    if [ -n "$java_files" ]; then
        local java_abs=""
        for f in $java_files; do
            [ -f "${TARGET_DIR:-.}/$f" ] && java_abs="$java_abs ${TARGET_DIR:-.}/$f"
        done
        if [ -n "$java_abs" ]; then
            local _cs_config=""
            for cfg in checkstyle.xml .checkstyle.xml config/checkstyle/checkstyle.xml google_checks.xml sun_checks.xml; do
                if [ -f "${TARGET_DIR:-.}/$cfg" ]; then _cs_config="${TARGET_DIR:-.}/$cfg"; break; fi
            done
            if command -v checkstyle &>/dev/null && [ -n "$_cs_config" ]; then
                total_checked=$((total_checked + $(echo "$java_abs" | wc -w)))
                local cs_out cs_rc=0
                # checkstyle's exit code equals the count of audit events at
                # severity=error; warning/info violations are printed but do NOT
                # bump the exit code (verified against checkstyle CLI behavior).
                # So a nonzero exit means error-severity findings only -- this is
                # already error-gated like cppcheck (--error-exitcode) and the
                # JS/TS/Py `-S error` gates, and does NOT block on style/warning.
                # Whether a given rule is error vs warning is the user's explicit
                # choice in their checkstyle config, which we respect.
                # shellcheck disable=SC2086
                cs_out=$(cd "${TARGET_DIR:-.}" && LOKI_DEADLINE_IDLE_TIMEOUT=0 \
                    _loki_with_deadline "$gate_timeout" \
                    checkstyle -c "$_cs_config" $java_files 2>&1) || cs_rc=$?
                if [ "$cs_rc" -ne 0 ]; then
                    findings=$((findings + 1))
                    if [ "$cs_rc" -eq 124 ]; then
                        details="${details}Checkstyle deadline: exit_code=124 hard_seconds=${gate_timeout} idle_seconds=0. "
                    else
                        details="${details}checkstyle (error severity): $(echo "$cs_out" | tail -3 | tr '\n' ' '). "
                    fi
                fi
            else
                log_info "Static analysis: checkstyle+config not available, skipping Java check (pass-through)"
            fi
        fi
    fi

    # Write results
    cat > "$quality_dir/static-analysis.json" << SAFEOF
{"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","files_checked":$total_checked,"findings":$findings,"summary":"$details","pass":$([ $findings -eq 0 ] && echo "true" || echo "false")}
SAFEOF

    if [ "$findings" -gt 0 ]; then
        rm -f "$quality_dir/static-analysis.pass"
        echo "static_analysis" > "$loki_dir/signals/STATIC_ANALYSIS_FAILED" 2>/dev/null || true
        log_warn "Static analysis: $findings issue(s) in $total_checked files"
        return 1
    else
        touch "$quality_dir/static-analysis.pass"
        rm -f "$loki_dir/signals/STATIC_ANALYSIS_FAILED" 2>/dev/null || true
        log_info "Static analysis: $total_checked files checked, all clean"
        return 0
    fi
}

# ============================================================================
# Secure-by-default scan (v7.87.0 - Loop 4)
# Runs the high-precision rule engine (autonomy/lib/secure-scan.py) over the
# generated app and reports known-bad security patterns.
#
# ADVISORY BY DEFAULT (mirrors the ktlint/detekt advisory linters above):
# findings are reported via log_warn + the receipt json, but do NOT block. This
# guarantees no existing build starts blocking on this new gate.
#
# OPT-IN BLOCK: only when LOKI_SECURE_GATE=block do un-waived HIGH findings
# cause a blocking gate failure (return 1, same mechanism the other gates use).
#
# Waivers: .loki/quality/security-waivers.json ({"waivers":[{rule,file},...]})
# is READ here and honored (matched findings recorded as waived, never counted
# active). The waiver-write surface is a separate slice.
#
# Honest degrade: if python3 or secure-scan.py is absent, pass through cleanly
# (no crash, no block), exactly like the optional linters.
# ============================================================================
run_secure_scan() {
    local loki_dir="${TARGET_DIR:-.}/.loki"
    local quality_dir="$loki_dir/quality"
    mkdir -p "$quality_dir"

    local out_file="$quality_dir/security-findings.json"
    local waivers_file="$quality_dir/security-waivers.json"
    local scanner="$SCRIPT_DIR/lib/secure-scan.py"

    # Honest pass-through if the engine or python3 is unavailable. Still write a
    # valid (empty) receipt so downstream consumers never read malformed JSON.
    if ! command -v python3 >/dev/null 2>&1 || [ ! -f "$scanner" ]; then
        cat > "$out_file" << 'SECEMPTY'
{"rules_version":null,"findings":[],"summary":{"total":0,"by_severity":{}},"skipped":"scanner-unavailable"}
SECEMPTY
        log_info "Security scan: secure-scan.py or python3 not available, skipping (pass-through)"
        return 0
    fi

    # Run the scanner. exit 0 = no findings, 1 = findings, 2 = bad input.
    local raw rc=0
    raw=$(python3 "$scanner" "${TARGET_DIR:-.}" --json 2>/dev/null) || rc=$?
    if [ "$rc" -eq 2 ] || [ -z "$raw" ]; then
        cat > "$out_file" << 'SECEMPTY'
{"rules_version":null,"findings":[],"summary":{"total":0,"by_severity":{}},"skipped":"scanner-error"}
SECEMPTY
        log_info "Security scan: scanner returned no parseable output, skipping (pass-through)"
        return 0
    fi

    # Apply waivers, build the receipt json, and emit a machine-readable verdict.
    # All policy lives in this one python pass so the bash stays bash-3.2 safe.
    # It prints a final line: ACTIVE_HIGH=<n>\tACTIVE_TOTAL=<n>\tWAIVED=<n>
    # and writes the enriched receipt (findings carry a "waived" bool).
    local verdict
    verdict=$(_SEC_RAW="$raw" _SEC_WAIVERS="$waivers_file" _SEC_OUT="$out_file" python3 -c '
import json, os, sys
raw = os.environ.get("_SEC_RAW", "")
waivers_file = os.environ.get("_SEC_WAIVERS", "")
out_file = os.environ.get("_SEC_OUT", "")

try:
    data = json.loads(raw)
except Exception:
    data = {"rules_version": None, "findings": [], "summary": {"total": 0, "by_severity": {}}}

# Load waivers: {"waivers":[{"rule":..,"file":..}, ...]}. Match on rule+file.
waived_set = set()
try:
    with open(waivers_file) as f:
        wdoc = json.load(f)
    for w in wdoc.get("waivers", []):
        r = w.get("rule"); fl = w.get("file")
        if r is not None and fl is not None:
            waived_set.add((r, fl))
except (OSError, json.JSONDecodeError, AttributeError):
    pass

findings = data.get("findings", []) or []
active_high = 0
active_total = 0
waived_count = 0
for fnd in findings:
    key = (fnd.get("rule"), fnd.get("file"))
    is_waived = key in waived_set
    fnd["waived"] = is_waived
    if is_waived:
        waived_count += 1
    else:
        active_total += 1
        if str(fnd.get("severity", "")).upper() == "HIGH":
            active_high += 1

data["waived"] = waived_count
data["active"] = active_total
try:
    with open(out_file, "w") as f:
        json.dump(data, f, indent=2)
except OSError:
    pass

sys.stdout.write("ACTIVE_HIGH=%d\tACTIVE_TOTAL=%d\tWAIVED=%d" % (active_high, active_total, waived_count))
' 2>/dev/null) || verdict=""

    if [ -z "$verdict" ]; then
        # python policy pass failed unexpectedly; preserve the raw scan as the
        # receipt so nothing is lost, and pass through (never crash the gate).
        printf '%s\n' "$raw" > "$out_file" 2>/dev/null || true
        log_info "Security scan: result recorded (policy pass unavailable, advisory)"
        return 0
    fi

    local active_high active_total waived
    active_high=$(printf '%s' "$verdict" | sed -n 's/.*ACTIVE_HIGH=\([0-9]*\).*/\1/p')
    active_total=$(printf '%s' "$verdict" | sed -n 's/.*ACTIVE_TOTAL=\([0-9]*\).*/\1/p')
    waived=$(printf '%s' "$verdict" | sed -n 's/.*WAIVED=\([0-9]*\).*/\1/p')
    active_high=${active_high:-0}
    active_total=${active_total:-0}
    waived=${waived:-0}

    if [ "$active_total" -eq 0 ]; then
        log_info "Security scan: no active findings (waived: $waived)"
        return 0
    fi

    # Actionable advisory summary: rule + file + fix, from the receipt json.
    log_warn "Security scan: $active_total active finding(s) (HIGH: $active_high, waived: $waived)"
    _SEC_OUT="$out_file" python3 -c '
import json, os
try:
    with open(os.environ["_SEC_OUT"]) as f:
        data = json.load(f)
except Exception:
    data = {"findings": []}
for fnd in data.get("findings", []):
    if fnd.get("waived"):
        continue
    print("  [%s] %s %s:%s -- %s | fix: %s" % (
        fnd.get("severity", "?"), fnd.get("rule", "?"),
        fnd.get("file", "?"), fnd.get("line", "?"),
        fnd.get("message", ""), fnd.get("fix", "")))
' 2>/dev/null | while IFS= read -r line; do log_warn "$line"; done

    # OPT-IN BLOCK: only un-waived HIGH findings block, and only when explicitly
    # enabled. Advisory default returns 0 (never surprise-blocks).
    if [ "${LOKI_SECURE_GATE:-advisory}" = "block" ] && [ "$active_high" -gt 0 ]; then
        log_warn "Security gate: $active_high un-waived HIGH finding(s) - BLOCK (LOKI_SECURE_GATE=block)"
        return 1
    fi
    return 0
}

#===============================================================================
# Gate Failure Tracking (v6.10.0)
#===============================================================================

# _loki_gate_stuck: has this gate failed for the SAME reason too many times?
#
# F0 in docs/FIRST-PASS-COMPLETION-PLAN.md. A gate that keeps failing for a
# byte-identical reason is not going to pass on the next try, and re-running the
# model against it burns an iteration to reach the same verdict.
#
# MEASURED. FireLater spent 3 iterations against mutation_integrity, which
# failed in 0-1 SECONDS each time with the identical line:
#
#   [HIGH] mutation detector unavailable: .../tests/detect-test-mutations.sh
#
# The detector was never packaged (fixed v8.38.0), so the gate could NEVER pass.
# The run was doomed at iteration 1 and nothing noticed; it just iterated.
#
# WHY "SAME REASON" AND NOT "SAME COUNT". A gate failing three times for three
# DIFFERENT reasons is the loop working -- the agent is fixing things and finding
# the next problem. That must keep iterating. Only an unchanging reason means no
# progress is possible.
#
# FAIL-SAFE DIRECTION, load-bearing: on any doubt this returns 1 (not stuck) and
# the run continues exactly as before. A missing reason file, an unreadable one,
# a first failure, or a changed reason all keep iterating. This can only ever
# SHORTEN a doomed run; it can never stop a healthy one, and it never declares
# success -- the caller maps it to a named terminal failure.
#
# Threshold is deliberately 3, not 2: a reason can legitimately repeat once
# while the agent is mid-fix (it edits, the gate re-runs before the edit lands).
_loki_gate_stuck() {
    local gate_name="$1" reason_file="$2" count="${3:-0}"
    local threshold="${LOKI_GATE_STUCK_THRESHOLD:-3}"

    [ "${LOKI_GATE_STUCK_ABORT:-1}" = "0" ] && return 1
    [ -n "$reason_file" ] && [ -f "$reason_file" ] || return 1

    local cur prev_file prev
    # Extract a STABLE cause. Two artifact shapes, both real:
    #   plain text (mutation-findings.txt) -> first line names the cause; later
    #     lines carry per-file detail that churns while the cause is unchanged.
    #   JSON (static-analysis.json)        -> the "summary" field names it. The
    #     whole file can NOT be used: it carries a timestamp that differs every
    #     run, so a byte compare would never match and the valve would be dead.
    case "$reason_file" in
        *.json)
            # QUOTED heredoc, not `python3 -c "..."`. A double-quoted -c body
            # spanning multiple lines makes the repo's $<digit> checker treat
            # every following line as still inside the body -- it flagged the
            # `local gate_name="$1"` of the NEXT function. A quoted heredoc also
            # guarantees bash performs no expansion inside the program at all.
            cur="$(LOKI_RF="$reason_file" python3 <<'LOKI_STUCK_JSON' 2>/dev/null
import json, os
try:
    d = json.load(open(os.environ['LOKI_RF']))
except Exception:
    raise SystemExit
v = d.get('summary') or d.get('reason') or d.get('error')
print(str(v).strip() if v else '')
LOKI_STUCK_JSON
)" || return 1
            ;;
        *)
            # SKIP the header. These files open with a static banner --
            # "# Test mutation findings (HIGH blocks this iteration)" -- which is
            # byte-identical on every run. Comparing it meant EVERY repeated
            # failure looked "stuck", including a run making real progress
            # through different findings each iteration. That is the one
            # direction this valve must never fail in, and a real FireLater run
            # is what exposed it: gate-stuck-mutation_integrity.last had
            # recorded the banner, not a cause.
            #
            # Take the first line that is neither blank nor a comment, and strip
            # ANSI colour (the detectors emit it, and the same finding rendered
            # with and without colour would otherwise compare unequal).
            cur="$(grep -vE '^[[:space:]]*(#|$)' "$reason_file" 2>/dev/null \
                   | head -1 \
                   | sed 's/\x1b\[[0-9;]*m//g')" || return 1
            ;;
    esac
    [ -n "$cur" ] || return 1

    # RECORD ON EVERY FAILURE, compare only at threshold.
    #
    # The recording used to sit behind the threshold check, so the first
    # comparison could not happen until count == threshold+1. Replayed against
    # the REAL FireLater artifact that motivated this feature, the abort fired
    # at iteration 4 -- and that run ended at 3. The safety valve would have
    # missed the exact case it was built for, by one iteration.
    #
    # Found only by replaying the preserved .loki/quality/mutation-findings.txt
    # rather than trusting the unit test, which used synthetic counts and so
    # never exercised the real arrival order.
    prev_file="${TARGET_DIR:-.}/.loki/quality/gate-stuck-${gate_name}.last"
    prev="$(cat "$prev_file" 2>/dev/null || true)"
    ( mkdir -p "$(dirname "$prev_file")" 2>/dev/null \
        && printf '%s\n' "$cur" > "$prev_file" 2>/dev/null ) || true

    # Below threshold: the reason is now on record for the next comparison, but
    # this is not yet enough evidence to stop.
    [ "${count:-0}" -lt "$threshold" ] 2>/dev/null && return 1

    [ -n "$prev" ] && [ "$prev" = "$cur" ] && return 0
    return 1
}

track_gate_failure() {
    local gate_name="$1"
    # Optional evidence for the durable failure lesson (see the failure_memory
    # block below). Either a findings-artifact PATH or a literal detail string;
    # callers pass whichever they already name on an adjacent line.
    #
    # MUST be "${2:-}", not "$2": this file runs under `set -u` (line 185) and
    # most call sites are still one-arg, so a bare $2 aborts the gate it is only
    # supposed to be observing. Caught by the end-to-end check, not by review.
    local evidence="${2:-}"
    local gate_file="${TARGET_DIR:-.}/.loki/quality/gate-failure-count.json"
    mkdir -p "$(dirname "$gate_file")"

    # IMPORTANT: this function's stdout IS its return value (callers do
    # count=$(track_gate_failure ...)). Capture the count first, then do any
    # side-effects with their stdout suppressed, then echo ONLY the count.
    local count
    count=$(_GATE_FILE="$gate_file" _GATE_NAME="$gate_name" python3 -c "
import json, os
gate_file = os.environ['_GATE_FILE']
gate_name = os.environ['_GATE_NAME']
try:
    with open(gate_file) as f:
        counts = json.load(f)
except (json.JSONDecodeError, FileNotFoundError, OSError):
    counts = {}
counts[gate_name] = counts.get(gate_name, 0) + 1
with open(gate_file, 'w') as f:
    json.dump(counts, f, indent=2)
print(counts[gate_name])
" 2>/dev/null || echo "1")

    # Crash friction (gate_failure): fire exactly once at the threshold (3
    # consecutive failures) so a sustained failure does not re-fire every
    # iteration. Best-effort, stdout suppressed so the count stays clean.
    if [ "${count:-0}" -eq 3 ] 2>/dev/null && type loki_crash_friction &>/dev/null; then
        loki_crash_friction "gate_failure" "gate=${gate_name} consecutive=${count}" >/dev/null 2>&1 || true
    fi

    # Trust-metrics: append a durable per-failure record so the gate-failure
    # distribution survives clear_gate_failure (which resets the running
    # counter). CRITICAL: this function's stdout IS its return value, so the
    # write is fully stdout-suppressed and best-effort; it cannot change the
    # echoed count or any gate behavior.
    record_trust_event_bash "gate_failure" "gate=${gate_name}" "consecutive=${count}" >/dev/null 2>&1 || true

    # Failure memory: turn this measured failure into a durable, falsifiable
    # lesson the NEXT run is told about (read side: build_prompt, below the
    # cache breakpoint).
    #
    # EVIDENCE IS REQUIRED, and deliberately not defaulted. failure_memory.py
    # refuses to write without it, because a lesson recorded from the agent's
    # own account of why it failed is unfalsifiable -- it records what the agent
    # BELIEVED, which is exactly what was wrong. Passing "$gate_name" as its own
    # evidence would satisfy the truthiness check and defeat that, so callers
    # with nothing concrete in scope pass nothing and record nothing.
    #
    # A readable evidence PATH is reduced to its first non-blank, non-comment
    # line with ANSI colour stripped -- the same reduction _loki_gate_stuck
    # applies above, so the stored lesson matches the cause that valve compares.
    #
    # CRITICAL: this function's stdout IS its return value, so this is fully
    # stdout-suppressed and best-effort, exactly like the trust-event write
    # above. failure_memory.py exits 3 on an UNKNOWN status (an expected result,
    # not an error), hence the `|| true`.
    #
    # ponytail: failures.jsonl is append-only with no dedup, so a gate stuck for
    # N iterations writes N records and recall() reads the whole file. Counts
    # stay true, so this is a ceiling not a defect; dedup on gate+evidence if a
    # perpetual run ever makes the file big enough to matter.
    if [ -n "$evidence" ] && [ -r "${SCRIPT_DIR}/lib/failure_memory.py" ]; then
        local _fm_evidence="$evidence"
        if [ -r "$_fm_evidence" ] && [ -f "$_fm_evidence" ]; then
            # `|| true`: head closing the pipe kills grep with SIGPIPE, which is
            # nonzero under `set -o pipefail` (line 185) even though the value is
            # correct. Same discipline as the trust-event write above.
            _fm_evidence="$(grep -vE '^[[:space:]]*(#|$)' "$_fm_evidence" 2>/dev/null \
                | head -1 | sed 's/\x1b\[[0-9;]*m//g' | head -c 200 || true)"
        fi
        if [ -n "$_fm_evidence" ]; then
            # run_id via the repo's existing resolver (the same one
            # record_trust_event_bash uses above), so a lesson can be traced back
            # to the run that produced it. Resolves to "" if unavailable, which
            # the module accepts -- only EVIDENCE is mandatory.
            local _fm_run_id=""
            if declare -f _loki_trust_run_id >/dev/null 2>&1; then
                _fm_run_id="${LOKI_TRUST_RUN_ID:-$(_loki_trust_run_id 2>/dev/null || true)}"
            fi
            LOKI_DIR="${LOKI_DIR:-${TARGET_DIR:-.}/.loki}" \
            python3 "${SCRIPT_DIR}/lib/failure_memory.py" record \
                "--gate=${gate_name}" "--verdict=FAIL" \
                "--evidence=${_fm_evidence}" \
                "--run_id=${_fm_run_id}" >/dev/null 2>&1 || true
        fi
    fi

    echo "$count"
}

clear_gate_failure() {
    local gate_name="$1"
    local gate_file="${TARGET_DIR:-.}/.loki/quality/gate-failure-count.json"
    [ -f "$gate_file" ] || return 0

    _GATE_FILE="$gate_file" _GATE_NAME="$gate_name" python3 -c "
import json, os
gate_file = os.environ['_GATE_FILE']
gate_name = os.environ['_GATE_NAME']
try:
    with open(gate_file) as f:
        counts = json.load(f)
except (json.JSONDecodeError, FileNotFoundError, OSError):
    counts = {}
counts[gate_name] = 0
with open(gate_file, 'w') as f:
    json.dump(counts, f, indent=2)
" 2>/dev/null || true
}

gate_failure_disposition() {
    local count="$1"
    if [ "$count" -ge "$GATE_PAUSE_LIMIT" ]; then
        echo "pause"
    elif [ "$count" -ge "$GATE_ESCALATE_LIMIT" ] || [ "$count" -ge "$GATE_CLEAR_LIMIT" ]; then
        echo "escalate"
    else
        echo "block"
    fi
}

write_gate_escalation_guidance() {
    local gate_name="$1"
    local count="$2"
    local threshold="$3"
    local loki_dir="${TARGET_DIR:-.}/.loki"
    local latest_artifact=""

    case "$gate_name" in
        code_review)
            latest_artifact=$(find "$loki_dir/quality/reviews" -mindepth 1 -maxdepth 1 -type d -print 2>/dev/null | LC_ALL=C sort | tail -1)
            ;;
        mutation_integrity) latest_artifact="$loki_dir/quality/mutation-findings.txt" ;;
        mock_integrity) latest_artifact="$loki_dir/quality/mock-findings.txt" ;;
        test_coverage) latest_artifact="$loki_dir/quality/test-results.json" ;;
    esac
    [ -e "$latest_artifact" ] || latest_artifact=""

    _LOKI_GUIDANCE_DIR="$loki_dir" \
    _LOKI_GUIDANCE_GATE="$gate_name" \
    _LOKI_GUIDANCE_COUNT="$count" \
    _LOKI_GUIDANCE_THRESHOLD="$threshold" \
    _LOKI_GUIDANCE_ARTIFACT="$latest_artifact" \
    python3 -c '
import json, os
base = os.environ["_LOKI_GUIDANCE_DIR"]
target = os.path.join(base, "signals", "GATE_ESCALATION.json")
os.makedirs(os.path.dirname(target), exist_ok=True)
artifact = os.environ.get("_LOKI_GUIDANCE_ARTIFACT") or None
payload = {
    "action": "escalate",
    "gate": os.environ["_LOKI_GUIDANCE_GATE"],
    "count": int(os.environ["_LOKI_GUIDANCE_COUNT"]),
    "threshold": int(os.environ["_LOKI_GUIDANCE_THRESHOLD"]),
    "latest_artifact": artifact,
}
tmp = target + ".tmp." + str(os.getpid())
with open(tmp, "w", encoding="utf-8") as f:
    json.dump(payload, f, indent=2)
    f.write("\n")
os.replace(tmp, target)
' 2>/dev/null
}

build_gate_escalation_context() {
    local guidance_file="${TARGET_DIR:-.}/.loki/signals/GATE_ESCALATION.json"
    [ -f "$guidance_file" ] || return 0

    _LOKI_GATE_GUIDANCE_FILE="$guidance_file" python3 -c '
import json, os, re, sys
try:
    with open(os.environ["_LOKI_GATE_GUIDANCE_FILE"], encoding="utf-8") as f:
        data = json.load(f)
except (OSError, json.JSONDecodeError):
    sys.exit(0)
if not isinstance(data, dict) or data.get("action") != "escalate":
    sys.exit(0)
gate = data.get("gate")
count = data.get("count")
threshold = data.get("threshold")
artifact = data.get("latest_artifact")
if not isinstance(gate, str) or not re.fullmatch(r"[A-Za-z0-9_.-]+", gate):
    sys.exit(0)
if isinstance(count, bool) or not isinstance(count, int) or count < 1:
    sys.exit(0)
if isinstance(threshold, bool) or not isinstance(threshold, int) or threshold < 1:
    sys.exit(0)
if artifact is not None and not isinstance(artifact, str):
    sys.exit(0)
artifact_text = "Inspect latest artifact: %s." % artifact if artifact else "No latest artifact was recorded."
print("REPEATED_GATE_BLOCKER (PRIORITY): action=escalate gate=%s count=%d threshold=%d. Change implementation strategy on this attempt. %s Resolve the root blocker before new work. Do not suppress or filter console errors or React act warnings, mock those signals, or weaken tests or assertions." % (gate, count, threshold, artifact_text))
' 2>/dev/null || true
}

# ============================================================================
# Hard Quality Gate: Test Coverage (v6.7.0)
# Detects test runner and runs tests with coverage reporting
# Results stored in .loki/quality/test-results.json
# ============================================================================

# v7.5.15 (Triage #14): wrap pytest with a configurable timeout so a
# deadlocked or infinite-loop test under /test cannot hang the gate
# indefinitely. Uses `timeout` on Linux, `gtimeout` (coreutils) on macOS,
# and degrades gracefully if neither is available (logs a warning, runs
# unbounded). Configurable via LOKI_PYTEST_TIMEOUT (default 300s).
#
# Usage: _loki_run_pytest_with_timeout <target_dir> [pytest_args...]
# Stdout: combined pytest output
# Exit: 0 on pass, non-zero on fail. Exit 124 indicates the timeout fired.
# Portable timeout-prefix probe, shared by every gate that must be wall-clock
# bounded. Stock macOS ships NEITHER `timeout` NOR `gtimeout` (gtimeout arrives
# with coreutils), so a bare `timeout` would resolve to "command not found"
# (exit 127) and flip test_passed=false on every macOS run -- turning a rare
# hang into a universal false RED. When no timeout binary exists we emit an
# EMPTY prefix and run unbounded, which is the pre-existing behaviour.
#
# Usage: _loki_timeout_prefix <seconds> <gate-label>   (writes words to stdout)
#   local _cmd=(); read -r -a _cmd <<< "$(_loki_timeout_prefix 300 'go test')"
#   "${_cmd[@]}" go test ./...
_loki_timeout_prefix() {
    local secs="$1" label="${2:-gate}"
    if command -v gtimeout >/dev/null 2>&1; then
        printf 'gtimeout %ss' "$secs"
    elif command -v timeout >/dev/null 2>&1; then
        printf 'timeout %ss' "$secs"
    else
        # >&2 is LOAD-BEARING: this function's STDOUT becomes the command-prefix
        # array at every call site. log_warn writes to stdout (run.sh:1684), so
        # without this redirect the warning text itself would be executed as the
        # command -> exit 127 -> test_passed=false on every box lacking a timeout
        # binary (stock macOS). That would be a universal false RED, strictly
        # worse than the unbounded hang this helper exists to prevent.
        log_warn "Neither gtimeout nor timeout available; ${label} will run unbounded (install coreutils on macOS)" >&2
    fi
}

_loki_run_pytest_with_timeout() {
    local target_dir="$1"; shift
    local pytest_timeout="${LOKI_PYTEST_TIMEOUT:-${LOKI_GATE_TIMEOUT:-300}}"
    local _to_cmd=()
    read -r -a _to_cmd <<< "$(_loki_timeout_prefix "$pytest_timeout" 'pytest gate')"
    (cd "$target_dir" && "${_to_cmd[@]}" pytest "$@" 2>&1)
}

# ============================================================================
# P0-1 Fix A: real test-coverage MEASUREMENT (v7.47.0)
#
# enforce_test_coverage() runs the project's suite for PASS/FAIL only -- it must
# NOT add --coverage to that run, because a missing coverage provider
# (@vitest/coverage-v8, the `coverage` pkg, pytest-cov, cargo-llvm-cov) makes the
# instrumented command exit nonzero for a TOOLING reason, which would flip
# test_passed=false and BLOCK a project whose tests actually pass. That would
# destroy the honest pass/fail pass-through. So measurement is a SEPARATE,
# best-effort second pass that can NEVER change test_passed.
#
# Contract:
#   - Sets COVERAGE_MEASURED (true|false), COVERAGE_PCT (number or empty),
#     COVERAGE_TOOL (string), COVERAGE_REASON (why not measured).
#   - Tool absent / unsupported language -> measured=false, no number, NEVER block.
#   - Tests run a SECOND time here when instrumented; LOKI_COVERAGE_GATE=0 skips
#     this whole measurement pass (saves the double-run).
#
# Usage: measure_test_coverage <target_dir> <test_runner>
# ============================================================================
measure_test_coverage() {
    local target_dir="$1"
    local runner="$2"
    COVERAGE_MEASURED=false
    COVERAGE_PCT=""
    COVERAGE_TOOL="none"
    COVERAGE_REASON=""

    local gate_timeout="${LOKI_GATE_TIMEOUT:-300}"
    local cov_dir="$target_dir/.loki/quality"
    mkdir -p "$cov_dir" 2>/dev/null || true
    # Native tool reports land on a tool-specific path so they never collide
    # with our normalized coverage.json.
    local pyc_json="$cov_dir/coverage-pytest.json"

    case "$runner" in
        vitest|monorepo-vitest)
            COVERAGE_TOOL="vitest"
            (cd "$target_dir" && timeout "$gate_timeout" npx vitest run --coverage \
                  --coverage.reporter=json-summary \
                  --coverage.reportsDirectory=.loki/quality/vitest-cov >/dev/null 2>&1) || true
            local f="$target_dir/.loki/quality/vitest-cov/coverage-summary.json"
            if [ -f "$f" ]; then
                COVERAGE_PCT=$(_LOKI_COV_F="$f" python3 -c "
import json, os, sys
try:
    d=json.load(open(os.environ['_LOKI_COV_F']))
    print(d['total']['lines']['pct'])
except Exception:
    sys.exit(1)
" 2>/dev/null) && COVERAGE_MEASURED=true || COVERAGE_REASON="vitest coverage-summary.json unparsable"
            else
                COVERAGE_REASON="vitest coverage provider absent (install @vitest/coverage-v8)"
            fi
            ;;
        jest)
            COVERAGE_TOOL="jest"
            (cd "$target_dir" && timeout "$gate_timeout" npx jest --coverage \
                  --coverageReporters=json-summary \
                  --coverageDirectory=.loki/quality/jest-cov --passWithNoTests >/dev/null 2>&1) || true
            local f="$target_dir/.loki/quality/jest-cov/coverage-summary.json"
            if [ -f "$f" ]; then
                COVERAGE_PCT=$(_LOKI_COV_F="$f" python3 -c "
import json, os, sys
try:
    d=json.load(open(os.environ['_LOKI_COV_F']))
    print(d['total']['lines']['pct'])
except Exception:
    sys.exit(1)
" 2>/dev/null) && COVERAGE_MEASURED=true || COVERAGE_REASON="jest coverage-summary.json unparsable"
            else
                COVERAGE_REASON="jest coverage report absent"
            fi
            ;;
        pytest)
            COVERAGE_TOOL="pytest-cov"
            # pytest-cov is optional; only measure when the plugin is importable.
            if python3 -c "import pytest_cov" >/dev/null 2>&1; then
                rm -f "$pyc_json" 2>/dev/null || true
                _loki_run_pytest_with_timeout "$target_dir" \
                    --cov --cov-report="json:$pyc_json" -q >/dev/null 2>&1 || true
                if [ -f "$pyc_json" ]; then
                    COVERAGE_PCT=$(_LOKI_COV_F="$pyc_json" python3 -c "
import json, os, sys
try:
    d=json.load(open(os.environ['_LOKI_COV_F']))
    print(d['totals']['percent_covered'])
except Exception:
    sys.exit(1)
" 2>/dev/null) && COVERAGE_MEASURED=true || COVERAGE_REASON="pytest coverage.json unparsable"
                else
                    COVERAGE_REASON="pytest produced no coverage.json"
                fi
            else
                COVERAGE_REASON="pytest-cov not installed"
            fi
            ;;
        go-test)
            COVERAGE_TOOL="go-cover"
            local prof="$cov_dir/go-coverage.out"
            rm -f "$prof" 2>/dev/null || true
            (cd "$target_dir" && timeout "$gate_timeout" go test -coverprofile="$prof" ./... >/dev/null 2>&1) || true
            if [ -f "$prof" ]; then
                local total_line
                total_line=$(cd "$target_dir" && go tool cover -func="$prof" 2>/dev/null | tail -1)
                # "total:    (statements)    87.5%"
                COVERAGE_PCT=$(printf '%s\n' "$total_line" | grep -oE '[0-9]+(\.[0-9]+)?%' | tail -1 | tr -d '%')
                if [ -n "$COVERAGE_PCT" ]; then
                    COVERAGE_MEASURED=true
                else
                    COVERAGE_REASON="go tool cover produced no total"
                fi
            else
                COVERAGE_REASON="go test produced no coverage profile"
            fi
            ;;
        cargo-test)
            COVERAGE_TOOL="cargo-llvm-cov"
            if cargo llvm-cov --version >/dev/null 2>&1; then
                local out
                out=$(cd "$target_dir" && timeout "$gate_timeout" cargo llvm-cov --json 2>/dev/null) || true
                if [ -n "$out" ]; then
                    COVERAGE_PCT=$(_LOKI_COV_JSON="$out" python3 -c "
import json, os, sys
try:
    d=json.loads(os.environ['_LOKI_COV_JSON'])
    print(d['data'][0]['totals']['lines']['percent'])
except Exception:
    sys.exit(1)
" 2>/dev/null) && COVERAGE_MEASURED=true || COVERAGE_REASON="cargo llvm-cov json unparsable"
                else
                    COVERAGE_REASON="cargo llvm-cov produced no output"
                fi
            else
                COVERAGE_REASON="cargo-llvm-cov not installed"
            fi
            ;;
        *)
            COVERAGE_REASON="coverage not supported for runner '$runner'"
            ;;
    esac
    return 0
}

# _loki_zero_tests_executed -- shared "no real tests ran" detector (#82).
# A runner that exits 0 but executed ZERO actual tests is a mini fake-green: it
# records pass while proving nothing. This inspects a runner's raw output and
# returns 0 (true, "zero tests executed") ONLY on positive detection; any
# unrecognized/unparseable shape returns 1 (false) so a legitimate suite this
# helper cannot parse is NEVER false-downgraded (bounded blast radius).
#
#   $1 = runner label (node-test|jest|vitest|...)
#   $2 = raw runner output
#   $3.. = (optional) test-file paths that were passed to node --test; their
#          basenames are node's file-wrapper subtest labels and must be excluded
#          from the "real test" count.
#
# node --test: even a *.test.js with NO test() calls emits `# tests 1 # pass 1`
#   because node counts the FILE ITSELF as one passing pseudo-test, printed as
#   `ok N - <file-basename>`. So `# tests 0` NEVER fires (verified on Node 22).
#   The true executed count = ok/not-ok lines whose label is NOT a passed file
#   basename. Zero such lines + exit 0 = no real tests ran.
# jest --passWithNoTests: prints "No tests found" and NO "Tests:" summary line.
_loki_zero_tests_executed() {
    local _zt_runner="$1"; shift
    local _zt_out="$1"; shift
    case "$_zt_runner" in
        node-test)
            # node's file-wrapper subtest label is the ARGUMENT VERBATIM (the
            # full path we passed), and separately its basename for older node.
            # Register BOTH forms so the wrapper line is excluded either way.
            # Delimited with newlines so a path containing spaces still matches
            # exactly (space-delimited would split it).
            local _zt_f _zt_bases=$'\n'
            for _zt_f in "$@"; do
                [ -n "$_zt_f" ] || continue
                _zt_bases="${_zt_bases}${_zt_f}"$'\n'"$(basename "$_zt_f")"$'\n'
            done
            # Count ok/not-ok lines whose label is a REAL test (label not a
            # file-wrapper). node prints "ok N - <label>" / "not ok N - <label>".
            local _zt_real=0 _zt_line _zt_label _zt_matches
            _zt_matches=$(printf '%s\n' "$_zt_out" | grep -E '^(ok|not ok) [0-9]+ - ' 2>/dev/null || true)
            while IFS= read -r _zt_line; do
                [ -n "$_zt_line" ] || continue
                # Strip "ok N - " / "not ok N - " prefix to get the label.
                _zt_label="${_zt_line#* - }"
                # A file-wrapper label equals a passed file path or basename -> skip.
                case "$_zt_bases" in
                    *$'\n'"$_zt_label"$'\n'*) continue ;;
                esac
                _zt_real=$((_zt_real + 1))
            done << ZT_MATCHES_EOF
$_zt_matches
ZT_MATCHES_EOF
            if [ "$_zt_real" -gt 0 ]; then
                return 1   # real tests seen -> definitely not zero
            fi
            # ZERO ok-lines is NOT proof of zero tests. It is only proof that
            # this output is not TAP. node's DEFAULT reporter since node 26 is
            # `spec`, which prints "OK adds (0.3ms)" / "i tests 1" and emits no
            # "ok N - " lines at all, so a genuinely PASSING suite reached here
            # and got recorded as pass:"inconclusive" / status:"no_tests_run".
            #
            # That inverts the helper's own contract (see the header above):
            # return 0 ONLY on POSITIVE detection; anything unparseable returns 1
            # so a legitimate suite is never false-downgraded. Absence of a
            # format marker is not evidence of absence of tests -- that is
            # grep-absence-false-green with the sign flipped.
            #
            # Why the fix is here and not at the call site: commit 54469c1f
            # forced --test-reporter=tap on the two DIRECT `node --test`
            # fallbacks and deliberately left a project's own `npm test` alone
            # ("its format is the project's responsibility"). But the
            # package.json scripts.test branch runs FIRST (every later branch
            # gates on runner=="none"), so that fix is unreachable for exactly
            # the projects that hit this. Appending a flag to an arbitrary npm
            # script is also unsafe (`node --test && lint` breaks). Fixing the
            # DETECTOR repairs every present and future caller.
            #
            # So: require a POSITIVE TAP shape before trusting a zero count.
            case "$_zt_out" in
                *"TAP version"*|*$'\n# tests '*|"# tests "*)
                    return 0 ;;   # genuinely TAP, and genuinely zero tests
            esac
            # Non-TAP shape (spec reporter, dot reporter, junit, custom):
            # unparseable by this arm -> no opinion, never a downgrade.
            return 1
            ;;
        jest|monorepo-jest)
            # jest --passWithNoTests: "No tests found, exiting with code 0" and no
            # "Tests:" summary. A real run prints "Tests: N passed, ...".
            if printf '%s' "$_zt_out" | grep -qiE 'no tests found' 2>/dev/null \
               && ! printf '%s' "$_zt_out" | grep -qE '^Tests:' 2>/dev/null; then
                return 0
            fi
            return 1
            ;;
        go-test)
            # #89: `go test ./...` on a package with NO *_test.go EXITS 0 and prints
            # `?   <pkg>  [no test files]` -- so a Go project with source but no
            # tests would score `pass` (a mini fake-green; unlike vitest/pytest/
            # mocha which exit NON-zero on zero tests and are already not-pass, so
            # go-test is the only runner in this class that needs detection).
            # POSITIVE detection: a `[no test files]` line AND no ran-package result
            # (`ok `/`FAIL`). A mixed run (some pkgs tested) has `ok`, so it is NOT
            # downgraded. Verified: real passing prints `ok  <pkg>  0.2s`.
            if printf '%s' "$_zt_out" | grep -qE '\[no test files\]' 2>/dev/null \
               && ! printf '%s' "$_zt_out" | grep -qE '^(ok|FAIL|--- FAIL)' 2>/dev/null; then
                return 0
            fi
            return 1
            ;;
        unittest)
            # #139: `python3 -m unittest discover` on ZERO discovered tests prints
            # "Ran 0 tests" + "NO TESTS RAN". Depending on the Python version it
            # exits 5 (newer, already not-pass) OR 0 (older = a silent fake-green,
            # the #82/#89 trap). POSITIVE detection so it is inconclusive on BOTH:
            # a "Ran 0 tests" line (authoritative), or the "NO TESTS RAN" banner,
            # AND no ran-count >= 1. A real run prints "Ran N test(s)" with N>=1
            # and "OK"/"FAILED", so it is never downgraded. Verified live: zero ->
            # "Ran 0 tests"/"NO TESTS RAN"; one -> "Ran 1 test"/"OK".
            if printf '%s' "$_zt_out" | grep -qE '^Ran 0 tests' 2>/dev/null \
               || printf '%s' "$_zt_out" | grep -qE '^NO TESTS RAN$' 2>/dev/null; then
                return 0
            fi
            return 1
            ;;
        *)
            # Unknown/unparseable runner -> never claim zero-tests (safe default).
            return 1
            ;;
    esac
}

enforce_test_coverage() {
    local loki_dir="${TARGET_DIR:-.}/.loki"
    local quality_dir="$loki_dir/quality"
    mkdir -p "$quality_dir" "$loki_dir/signals"

    local min_coverage="${LOKI_MIN_COVERAGE:-80}"
    local test_passed=true
    local test_runner="none"
    local details=""

    # JavaScript/TypeScript
    if [ -f "${TARGET_DIR:-.}/package.json" ]; then
        # BUG-EC-014: Wrap test runners with timeout to prevent hanging indefinitely
        local gate_timeout="${LOKI_GATE_TIMEOUT:-300}"  # 5 minutes default
        # A DECLARED scripts.test wins over an installed package.
        #
        # The v7.41.x fix below already established that grep false-positives on
        # devDependencies -- read its comment -- but it only guarded the `else`
        # branch, so the three grep branches AHEAD of it still shadowed it. This
        # repo is the proof: jest is a devDependency with no jest config while
        # scripts.test runs `bash -n` + `node --test`, and the grep branch
        # hijacked the runner, ran jest over 895 non-jest files and failed every
        # one of them.
        #
        # _has_declared_test_script is true only when the project DECLARES a real
        # test script, which is the project's own statement of its runner. When
        # it declares nothing, the grep branches still apply exactly as before --
        # that is the legitimate case they were written for (a package that ships
        # a runner but no npm script).
        local _declared_test_script=""
        _declared_test_script=$(_LOKI_PKG="${TARGET_DIR:-.}/package.json" python3 -c "
import json, os, sys
try:
    with open(os.environ['_LOKI_PKG']) as f:
        d = json.load(f)
except Exception:
    sys.exit(0)
if not isinstance(d, dict):
    sys.exit(0)
t = (d.get('scripts') or {}).get('test') or ''
if 'no test specified' in t.lower():
    sys.exit(0)
sys.stdout.write(t.strip())
" 2>/dev/null || echo "")
        if [ -n "$_declared_test_script" ]; then
            # Run the DECLARED script. This body used to live in the trailing
            # `else`; a no-op `:` here would have terminated the if/elif chain
            # and left test_runner=none, turning a false BLOCK into a silently
            # unmeasured gate -- strictly worse than the bug being fixed.
            #
            # LOKI_TEST_COMMAND lets an operator override the invocation; the
            # default is the project's own `npm test`.
            local _test_cmd="${LOKI_TEST_COMMAND:-npm test}"
            # Label the runner by what the script invokes so evidence is honest
            # (node --test, vitest, jest, etc. all surface here).
            case "$_declared_test_script" in
                *"node --test"*|*"node:test"*) test_runner="node-test" ;;
                *vitest*) test_runner="vitest" ;;
                *jest*)   test_runner="jest" ;;
                *mocha*)  test_runner="mocha" ;;
                *)        test_runner="npm-test" ;;
            esac
            local output
            output=$(cd "${TARGET_DIR:-.}" && timeout "$gate_timeout" sh -c "$_test_cmd" 2>&1) || test_passed=false
            details="$test_runner ($_test_cmd): $(echo "$output" | tail -5 | tr '\n' ' ')"
        elif grep -q '"vitest"' "${TARGET_DIR:-.}/package.json" 2>/dev/null; then
            test_runner="vitest"
            local output
            output=$(cd "${TARGET_DIR:-.}" && timeout "$gate_timeout" npx vitest run --reporter=json 2>&1) || test_passed=false
            details="vitest: $(echo "$output" | tail -3 | tr '\n' ' ')"
        elif grep -q '"jest"' "${TARGET_DIR:-.}/package.json" 2>/dev/null; then
            test_runner="jest"
            local output
            output=$(cd "${TARGET_DIR:-.}" && timeout "$gate_timeout" npx jest --passWithNoTests --forceExit 2>&1) || test_passed=false
            details="jest: $(echo "$output" | tail -3 | tr '\n' ' ')"
        elif grep -q '"mocha"' "${TARGET_DIR:-.}/package.json" 2>/dev/null; then
            test_runner="mocha"
            local output
            output=$(cd "${TARGET_DIR:-.}" && timeout "$gate_timeout" npx mocha 2>&1) || test_passed=false
            details="mocha: $(echo "$output" | tail -3 | tr '\n' ' ')"
        else
            # v7.41.x (test-coverage fail-open fix): a real "scripts.test" was
            # previously missed entirely. A greenfield project whose package.json
            # has {"scripts":{"test":"node --test"}} (or any non-placeholder test
            # script) actually runs a working suite via `npm test`, yet the gate
            # reported runner:none + pass:true -- so a project whose tests FAIL
            # green-lit identically. Detect a real test script (excluding the npm
            # placeholder "no test specified") with a JSON parser, not grep (grep
            # would false-positive on devDeps / unrelated keys), then run the
            # configured command. This MUST sit before the monorepo/python/go/rust
            # checks, all of which gate on test_runner=="none".
            #
            # That handler now runs in the FIRST branch above, because a declared
            # script must win over an installed devDependency. Nothing is left to
            # do here: reaching this point means the project declares no test
            # script AND ships no recognised runner, so test_runner stays "none"
            # and the monorepo/python/go/rust detection below takes over.
            :
        fi
    fi

    # Monorepo: scan workspace packages for test runners (v6.10.0)
    if [ "$test_runner" = "none" ] && [ -f "${TARGET_DIR:-.}/package.json" ]; then
        local is_monorepo=false
        # Detect monorepo indicators
        if [ -f "${TARGET_DIR:-.}/pnpm-workspace.yaml" ] || \
           [ -f "${TARGET_DIR:-.}/turbo.json" ] || \
           [ -f "${TARGET_DIR:-.}/lerna.json" ] || \
           grep -q '"workspaces"' "${TARGET_DIR:-.}/package.json" 2>/dev/null; then
            is_monorepo=true
        fi

        if [ "$is_monorepo" = "true" ]; then
            # Allow env override
            if [ -n "${LOKI_MONOREPO_TEST_CMD:-}" ]; then
                # v7.5.8: Strict whitelist before eval (mirrors app-runner.sh
                # _validate_app_command hardening). Reject anything outside
                # [A-Za-z0-9_./= -] so command separators (; | & `), redirects,
                # subshells, and command substitution can't be smuggled in via
                # an env var. Failing input is treated as inconclusive (gate
                # skipped) rather than executed.
                if [[ ! "$LOKI_MONOREPO_TEST_CMD" =~ ^[A-Za-z0-9_./=\ -]+$ ]] || \
                   echo "$LOKI_MONOREPO_TEST_CMD" | grep -qE '[;|`$]|&&|\|\||>>|<<'; then
                    log_error "LOKI_MONOREPO_TEST_CMD rejected (only [A-Za-z0-9_./= -] allowed): $LOKI_MONOREPO_TEST_CMD"
                    test_runner="monorepo-custom-rejected"
                    details="monorepo-custom: rejected by whitelist (gate skipped, inconclusive)"
                else
                    test_runner="monorepo-custom"
                    local output
                    output=$(cd "${TARGET_DIR:-.}" && eval "$LOKI_MONOREPO_TEST_CMD" 2>&1) || test_passed=false
                    details="monorepo-custom: $(echo "$output" | tail -3 | tr '\n' ' ')"
                fi
            else
                # Scan workspace packages for test runners
                local workspace_runner=""
                for pkg_json in "${TARGET_DIR:-.}"/packages/*/package.json \
                                "${TARGET_DIR:-.}"/apps/*/package.json \
                                "${TARGET_DIR:-.}"/services/*/package.json; do
                    [ -f "$pkg_json" ] || continue
                    # Read the workspace's DECLARED test script, same rule as the
                    # single-package path above. A bare grep for '"jest"' matches
                    # a devDependency, so a workspace that merely depends on jest
                    # got labelled monorepo-jest.
                    #
                    # LOWER SEVERITY than the single-package case, and worth being
                    # precise about why: all three branches below dispatch the
                    # project's own script (turbo test / pnpm test --recursive /
                    # npm test), so the grep only ever picked the LABEL, never
                    # what ran. This corrects the evidence, not the execution.
                    local _ws_script
                    _ws_script=$(_LOKI_PKG="$pkg_json" python3 -c "
import json, os, sys
try:
    with open(os.environ['_LOKI_PKG']) as f:
        d = json.load(f)
except Exception:
    sys.exit(0)
if not isinstance(d, dict):
    sys.exit(0)
t = (d.get('scripts') or {}).get('test') or ''
if 'no test specified' in t.lower():
    sys.exit(0)
sys.stdout.write(t.strip())
" 2>/dev/null || echo "")
                    case "$_ws_script" in
                        *vitest*) workspace_runner="vitest"; break ;;
                        *jest*)   workspace_runner="jest"; break ;;
                        *mocha*)  workspace_runner="mocha"; break ;;
                        "")       : ;;
                        *)        workspace_runner="npm-test"; break ;;
                    esac
                done

                # FAIL-SAFE. If no workspace DECLARES a test script but one has a
                # runner installed, still run the monorepo suite -- labelled
                # "installed" so the evidence does not claim a declaration that
                # does not exist.
                #
                # Without this the change would trade a wrong label for a SKIPPED
                # GATE, which is the dangerous direction: the old grep at least
                # dispatched `npm test`. Reporting a runner inaccurately is a
                # documentation bug; silently not testing a monorepo is not.
                if [ -z "$workspace_runner" ]; then
                    for pkg_json in "${TARGET_DIR:-.}"/packages/*/package.json \
                                    "${TARGET_DIR:-.}"/apps/*/package.json \
                                    "${TARGET_DIR:-.}"/services/*/package.json; do
                        [ -f "$pkg_json" ] || continue
                        if grep -qE '"(vitest|jest|mocha)"' "$pkg_json" 2>/dev/null; then
                            workspace_runner="installed"
                            break
                        fi
                    done
                fi

                if [ -n "$workspace_runner" ]; then
                    test_runner="monorepo-$workspace_runner"
                    local output
                    if [ -f "${TARGET_DIR:-.}/turbo.json" ] && command -v turbo &>/dev/null; then
                        output=$(cd "${TARGET_DIR:-.}" && npx turbo test 2>&1) || test_passed=false
                        details="turbo test ($workspace_runner): $(echo "$output" | tail -3 | tr '\n' ' ')"
                    elif [ -f "${TARGET_DIR:-.}/pnpm-workspace.yaml" ] && command -v pnpm &>/dev/null; then
                        output=$(cd "${TARGET_DIR:-.}" && pnpm test --recursive 2>&1) || test_passed=false
                        details="pnpm test --recursive ($workspace_runner): $(echo "$output" | tail -3 | tr '\n' ' ')"
                    else
                        output=$(cd "${TARGET_DIR:-.}" && npm test 2>&1) || test_passed=false
                        details="npm test ($workspace_runner): $(echo "$output" | tail -3 | tr '\n' ' ')"
                    fi
                fi
            fi
        fi
    fi

    # Python.
    # v7.4.17: only fire pytest when there is actually a Python project
    # to test. Pre-v7.4.17 the gate fired on the mere existence of a
    # `tests/` directory -- which a JS-only project (e.g. `tests/foo.test.js`)
    # commonly has. pytest then collected 0 tests and the gate reported
    # FAILED, derailing the next iteration with a fake "fix the tests"
    # injection. User reported this exact regression in v7.4.15 quick mode.
    if [ "$test_runner" = "none" ]; then
        local has_python_project=false
        if [ -f "${TARGET_DIR:-.}/setup.py" ] || [ -f "${TARGET_DIR:-.}/pyproject.toml" ] \
           || [ -f "${TARGET_DIR:-.}/setup.cfg" ] || [ -f "${TARGET_DIR:-.}/pytest.ini" ] \
           || [ -f "${TARGET_DIR:-.}/conftest.py" ]; then
            has_python_project=true
        elif [ -d "${TARGET_DIR:-.}/tests" ]; then
            # Confirm tests/ actually has Python test files.
            if find "${TARGET_DIR:-.}/tests" -maxdepth 3 -type f \
                \( -name 'test_*.py' -o -name '*_test.py' -o -name 'conftest.py' \) \
                -print -quit 2>/dev/null | grep -q .; then
                has_python_project=true
            fi
        elif find "${TARGET_DIR:-.}" -maxdepth 2 -type f \
                \( -name 'test_*.py' -o -name '*_test.py' \) \
                -not -path '*/.loki/*' -not -path '*/.git/*' -not -path '*/node_modules/*' \
                -not -path '*/.venv/*' -not -path '*/venv/*' \
                -print -quit 2>/dev/null | grep -q .; then
            # #139: a ROOT-LEVEL (or shallow) test_*.py / *_test.py with NO tests/
            # dir and NO config file. The founder's invoice CLI had exactly this
            # shape (test_invoice_cli.py at the root) -> was missed -> tests read
            # "not run" on a genuinely-passing 16-test unittest suite. This is the
            # same false-negative class as #79 (validated work reads as unvalidated).
            has_python_project=true
        fi
        if [ "$has_python_project" = "true" ] && command -v pytest &>/dev/null; then
            test_runner="pytest"
            local output pytest_exit
            # v7.5.15 (Triage #14): wrapped with configurable timeout via helper.
            output=$(_loki_run_pytest_with_timeout "${TARGET_DIR:-.}" --tb=short)
            pytest_exit=$?
            if [ "$pytest_exit" -eq 124 ]; then
                local _pt_to="${LOKI_PYTEST_TIMEOUT:-${LOKI_GATE_TIMEOUT:-300}}"
                test_passed=false
                log_warn "pytest gate timed out after ${_pt_to}s (exit 124)"
                details="pytest: TIMED OUT after ${_pt_to}s -- $(echo "$output" | tail -3 | tr '\n' ' ')"
            else
                [ "$pytest_exit" -ne 0 ] && test_passed=false
                details="pytest: $(echo "$output" | tail -5 | tr '\n' ' ')"
            fi
        elif [ "$has_python_project" = "true" ] && command -v python3 &>/dev/null; then
            # #139: pytest ABSENT but a Python test suite exists. stdlib unittest
            # discovery runs test_*.py with ZERO third-party deps -- so a legit
            # unittest suite (the invoice CLI's 16 tests) is verified instead of
            # silently read as "not run". The zero-test guard (unittest case) makes
            # a zero-discovery run inconclusive, NOT a fake-green pass (unittest
            # prints "NO TESTS RAN" and exits 0 -- the #82/#89 trap, guarded).
            test_runner="unittest"
            local output unittest_exit _ut_to
            _ut_to="${LOKI_PYTEST_TIMEOUT:-${LOKI_GATE_TIMEOUT:-300}}"
            local _ut_cmd=()
            read -r -a _ut_cmd <<< "$(_loki_timeout_prefix "$_ut_to" 'unittest gate')"
            output=$(cd "${TARGET_DIR:-.}" && "${_ut_cmd[@]}" python3 -m unittest discover -p 'test_*.py' 2>&1)
            unittest_exit=$?
            if [ "$unittest_exit" -eq 124 ]; then
                test_passed=false
                log_warn "unittest gate timed out after ${_ut_to}s (exit 124)"
                details="unittest: TIMED OUT after ${_ut_to}s -- $(echo "$output" | tail -3 | tr '\n' ' ')"
            else
                [ "$unittest_exit" -ne 0 ] && test_passed=false
                details="unittest: $(echo "$output" | tail -5 | tr '\n' ' ')"
            fi
        fi
    fi

    # Go
    # Wall-clock bounded like every other runner above. `go test` self-imposes
    # -timeout 10m PER TEST BINARY, but `./...` runs one binary per package, so
    # the AGGREGATE is unbounded -- and a test blocked in a cgo call or a syscall
    # can outlive that panic. `cargo test` has no default timeout at all. Without
    # this the gate hangs the whole iteration with no verdict.
    # $gate_timeout is NOT in scope here (it is declared inside the package.json
    # block), so read LOKI_GATE_TIMEOUT directly.
    if [ "$test_runner" = "none" ] && [ -f "${TARGET_DIR:-.}/go.mod" ] && command -v go &>/dev/null; then
        test_runner="go-test"
        local output go_exit _go_to _go_cmd=()
        _go_to="${LOKI_GATE_TIMEOUT:-300}"
        read -r -a _go_cmd <<< "$(_loki_timeout_prefix "$_go_to" 'go test gate')"
        output=$(cd "${TARGET_DIR:-.}" && "${_go_cmd[@]}" go test ./... 2>&1)
        go_exit=$?
        if [ "$go_exit" -eq 124 ]; then
            test_passed=false
            log_warn "go test gate timed out after ${_go_to}s (exit 124)"
            details="go test: TIMED OUT after ${_go_to}s -- $(echo "$output" | tail -3 | tr '\n' ' ')"
        else
            [ "$go_exit" -ne 0 ] && test_passed=false
            details="go test: $(echo "$output" | tail -3 | tr '\n' ' ')"
        fi
    fi

    # Rust
    if [ "$test_runner" = "none" ] && [ -f "${TARGET_DIR:-.}/Cargo.toml" ] && command -v cargo &>/dev/null; then
        test_runner="cargo-test"
        local output cargo_exit _cargo_to _cargo_cmd=()
        _cargo_to="${LOKI_GATE_TIMEOUT:-300}"
        read -r -a _cargo_cmd <<< "$(_loki_timeout_prefix "$_cargo_to" 'cargo test gate')"
        output=$(cd "${TARGET_DIR:-.}" && "${_cargo_cmd[@]}" cargo test 2>&1)
        cargo_exit=$?
        if [ "$cargo_exit" -eq 124 ]; then
            test_passed=false
            log_warn "cargo test gate timed out after ${_cargo_to}s (exit 124)"
            details="cargo test: TIMED OUT after ${_cargo_to}s -- $(echo "$output" | tail -3 | tr '\n' ' ')"
        else
            [ "$cargo_exit" -ne 0 ] && test_passed=false
            details="cargo test: $(echo "$output" | tail -3 | tr '\n' ' ')"
        fi
    fi

    # node --test (built-in Node test runner) -- config-less fallback (task #79).
    # Node's built-in runner (stable since Node 18) runs *.test.{js,mjs,cjs}
    # with ZERO config and NO package.json. A deliverable of slug.js +
    # slug.test.js (a real, passing suite) previously fell straight through to
    # runner="none" and recorded verification_gap="source_without_tests" -- a
    # FALSE-NEGATIVE trust defect (symmetric to fake-green): genuinely-correct,
    # fully-tested work read as NOT VERIFIED. This branch fires ONLY as a
    # fallback below every explicit-framework path (vitest/jest/mocha/scripts.test
    # via package.json, monorepo, pytest, go, cargo all gate on
    # test_runner=="none"), so package.json-with-jest still selects jest. It runs
    # only when node is on PATH AND *.test.{js,mjs,cjs} files exist at root or
    # under test/ or tests/. A failing suite records test_passed=false (never
    # swallowed); absence of node or test files falls through to the honest
    # "none"/inconclusive path below (never fabricates a pass).
    if [ "$test_runner" = "none" ] && command -v node &>/dev/null; then
        local _nt_files=()
        local _nt_f _nt_found
        # Root-level test files (maxdepth 1) plus test/ and tests/ dirs, skipping
        # vendored trees so we never walk node_modules.
        _nt_found=$(find "${TARGET_DIR:-.}" -maxdepth 1 -type f \
                    \( -name '*.test.js' -o -name '*.test.mjs' -o -name '*.test.cjs' \) \
                    2>/dev/null || true)
        while IFS= read -r _nt_f; do
            [ -n "$_nt_f" ] && _nt_files+=("$_nt_f")
        done << NT_ROOT_EOF
$_nt_found
NT_ROOT_EOF
        for _nt_dir in test tests; do
            [ -d "${TARGET_DIR:-.}/$_nt_dir" ] || continue
            _nt_found=$(find "${TARGET_DIR:-.}/$_nt_dir" -maxdepth 3 -type f \
                        \( -name '*.test.js' -o -name '*.test.mjs' -o -name '*.test.cjs' \) \
                        -not -path '*/node_modules/*' 2>/dev/null || true)
            while IFS= read -r _nt_f; do
                [ -n "$_nt_f" ] && _nt_files+=("$_nt_f")
            done << NT_DIR_EOF
$_nt_found
NT_DIR_EOF
        done
        if [ "${#_nt_files[@]}" -gt 0 ]; then
            test_runner="node-test"
            local output
            # Pass matched files explicitly (node --test globbing is
            # Node-version-sensitive); quote each so paths with spaces survive.
            # Force --test-reporter=tap: Node 20+ made the human "spec" reporter
            # the default for a TTY and Node 26 emits it even under capture
            # ("i pass N" / check+cross marks) instead of the TAP "# pass N" /
            # "ok N - " lines that BOTH the count parser below AND
            # _loki_zero_tests_executed rely on. TAP has been stable since Node 18,
            # so forcing it restores a deterministic, version-independent format
            # without touching any parser. Older node ignores nothing here (tap is
            # a valid built-in reporter on every supported version).
            output=$(cd "${TARGET_DIR:-.}" && timeout "${LOKI_GATE_TIMEOUT:-300}" \
                         node --test --test-reporter=tap "${_nt_files[@]}" 2>&1) || test_passed=false
            # tail -14 so node's TAP summary block (# tests / # pass N / # fail N,
            # ~10 lines) survives truncation for the best-effort count parse below.
            details="node --test: $(echo "$output" | tail -14 | tr '\n' ' ')"
        fi
    fi

    if [ "$test_runner" = "none" ]; then
        log_info "Test coverage: no test runner detected, recording inconclusive (not pass)"
        # v7.41.x fail-open fix: previously this wrote pass:true, so a project
        # whose tests truly do not run was indistinguishable from one whose tests
        # passed. Record pass:"inconclusive" instead. The completion-council
        # evidence gate already treats runner=="none" as pass-through regardless
        # of the pass value (completion-council.sh: runner=='none' short-circuits
        # BEFORE the `passed is False` block), so genuinely-no-tests stays
        # non-blocking (no infinite hang), while the JSON record is now honest:
        # "no tests" never reads as "tests passed". A DETECTED runner that fails
        # still writes pass:false below and BLOCKS.
        #
        # unit-tests.pass is only read for the status-line display (run.sh ~2183,
        # PASS vs PENDING); keeping the touch preserves the historical
        # non-blocking behavior for legitimate no-test projects.
        #
        # F56/F53 (verification-gap honesty): a generated project that shipped
        # source but no runnable tests previously recorded a bare "not_run" and
        # the gate passed through silently -- so a real logic bug (F53: todo-id
        # numbering) reached the receipt unverified, and a TESTING.md that
        # DESCRIBES tests shipped with NONE executed (F56: test docs without test
        # execution). We do not scaffold+run tests here (generating correct test
        # code for an arbitrary project is the agent's job, not the gate's, and
        # would overreach a pass/fail gate). Instead we make the no-runner record
        # HONEST about the gap: if the project has generated source, the gap is
        # "source present, no runnable tests"; if a TESTING.md also claims tests,
        # the gap is the stronger "test docs without execution" mismatch. The
        # record stays non-blocking (runner=="none" short-circuits the evidence
        # gate as before; no infinite hang), but the receipt and the operator now
        # SEE the gap instead of a silent pass, and "no tests" never reads as
        # "tests verified".
        local _vgap="none" _vgap_summary="No test runner detected"
        local _has_src=false _has_testdoc=false
        # Generated source present? Look only at top-of-tree, skipping vendored /
        # tooling dirs, so this is cheap and never walks node_modules.
        if find "${TARGET_DIR:-.}" -maxdepth 3 -type f \
            \( -name '*.py' -o -name '*.js' -o -name '*.ts' -o -name '*.jsx' \
               -o -name '*.tsx' -o -name '*.go' -o -name '*.rs' -o -name '*.java' \
               -o -name '*.rb' -o -name '*.php' \) \
            -not -path '*/node_modules/*' -not -path '*/.loki/*' \
            -not -path '*/.git/*' -not -path '*/vendor/*' -not -path '*/dist/*' \
            -not -path '*/build/*' -not -path '*/.venv/*' -not -path '*/venv/*' \
            -print -quit 2>/dev/null | grep -q .; then
            _has_src=true
        fi
        # TESTING.md (project root or .loki/docs) that documents tests despite no
        # runner -- the docs-without-execution mismatch.
        local _td
        for _td in "${TARGET_DIR:-.}/TESTING.md" "${TARGET_DIR:-.}/.loki/docs/TESTING.md"; do
            if [ -f "$_td" ] && grep -qiE 'test|coverage' "$_td" 2>/dev/null; then
                _has_testdoc=true
                break
            fi
        done
        if [ "$_has_testdoc" = "true" ]; then
            _vgap="test_docs_without_execution"
            _vgap_summary="TESTING.md documents tests but no runner ran any (unverified)"
            log_warn "Verification gap: TESTING.md describes tests but no test runner executed -- shipping test docs without test execution"
        elif [ "$_has_src" = "true" ]; then
            _vgap="source_without_tests"
            _vgap_summary="Source present but no runnable tests detected (unverified logic)"
            log_warn "Verification gap: generated source present but no runnable tests -- logic is unverified by execution"
        fi
        touch "$quality_dir/unit-tests.pass"
        cat > "$quality_dir/test-results.json" << TREOF
{"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"none","pass":"inconclusive","summary":"$_vgap_summary","command":null,"exit_code":null,"status":"not_run","passed_count":null,"failed_count":null,"verification_gap":"$_vgap"}
TREOF
        # Finding #598: stamp the per-iteration freshness marker so a later
        # completion-route capture (ensure_completion_test_evidence) reuses this
        # run instead of re-running the suite. Single source of truth for "tests
        # ran this iteration", set on every return path that writes results.
        printf '%s\n' "${ITERATION_COUNT:-0}" > "$quality_dir/.test-results.iter" 2>/dev/null || true
        return 0
    fi

    # Sanitize details for JSON. A test runner's output can carry ANSI color
    # escapes and other C0 control characters (jest/vitest colorize by default);
    # embedding them raw produces an INVALID receipt (json.load -> "Invalid
    # control character"), which reads downstream as an empty/absent field -- a
    # corrupt Evidence Receipt, worse than none. Convert newlines to spaces
    # first, then delete every remaining control char (\000-\037, incl. ESC).
    details=$(echo "$details" | tr '"' "'" | tr '\n' ' ' | tr -d '\000-\037' | head -c 500)

    # Evidence Receipt provenance (v7.85.0): record the deterministic FACTS a
    # non-forgeable receipt needs -- the command that ran, its exit code, and a
    # status enum -- alongside the legacy pass/runner/min_coverage keys the
    # completion-council evidence gate reads (those are UNCHANGED for back-compat).
    # A receipt that says "tests passed" without the command+exit_code is exactly
    # the "trust me" transcript we are replacing. counts are best-effort parsed
    # from the runner summary; null (not 0) when unparseable, so "unknown" never
    # reads as "0 failures".
    local _tr_cmd _tr_exit _tr_status
    case "$test_runner" in
        pytest)      _tr_cmd="pytest" ;;
        go-test)     _tr_cmd="go test ./..." ;;
        cargo-test)  _tr_cmd="cargo test" ;;
        npm-test|jest|vitest) _tr_cmd="$test_runner" ;;
        *)           _tr_cmd="$test_runner" ;;
    esac
    if [ "$test_passed" = "true" ]; then _tr_exit=0; _tr_status="verified"; else _tr_exit=1; _tr_status="failed"; fi

    # #82 (zero-test-file hardening): a runner that EXITED 0 but executed ZERO
    # real tests is a mini fake-green -- it records "verified" while proving
    # nothing. node --test on a *.test.js with no test() calls exits 0 (node
    # counts the FILE ITSELF as one passing pseudo-test, so `# tests 0` never
    # fires); jest --passWithNoTests exits 0 on an empty suite. Detect the
    # zero-real-test case ONLY on a green run (a red run already records fail
    # and must stay fail), and downgrade the record from affirmative to an
    # HONEST inconclusive (status=no_tests_run, gap=source_without_runnable_tests,
    # pass:"inconclusive"). This is NOT a failure: the completion-council
    # evidence gate treats it as pass-through, exactly like runner=="none".
    # Positive-detection only -- an unparseable runner is left UNCHANGED
    # (pass:true) so a legitimate suite is never false-downgraded.
    local _tr_zero_tests=false
    if [ "$test_passed" = "true" ] \
       && _loki_zero_tests_executed "$test_runner" "${output:-}" "${_nt_files[@]:-}"; then
        _tr_zero_tests=true
        _tr_status="no_tests_run"
        log_warn "Verification gap: $test_runner exited 0 but ran ZERO tests -- recording inconclusive (no_tests_run), not verified"
    fi

    # Best-effort pass/fail counts from the summary text (null when not found).
    local _tr_passed_n _tr_failed_n
    _tr_passed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ passed' | grep -oE '[0-9]+' | head -1)
    _tr_failed_n=$(printf '%s' "$details" | grep -oE '[0-9]+ failed' | grep -oE '[0-9]+' | head -1)
    # node --test emits TAP-ish "# pass N" / "# fail N" summary lines, which the
    # "N passed"/"N failed" pattern above does not match. Fall back to those.
    [ -n "$_tr_passed_n" ] || _tr_passed_n=$(printf '%s' "$details" | grep -oE '# pass [0-9]+' | grep -oE '[0-9]+' | head -1)
    [ -n "$_tr_failed_n" ] || _tr_failed_n=$(printf '%s' "$details" | grep -oE '# fail [0-9]+' | grep -oE '[0-9]+' | head -1)
    [ -n "$_tr_passed_n" ] || _tr_passed_n=null
    [ -n "$_tr_failed_n" ] || _tr_failed_n=null

    # verification_gap is "none" whenever a real runner executed AND ran tests:
    # the suite ran, so there is no docs-without-execution / source-without-tests
    # gap. The #82 zero-test case is the exception -- a runner ran but executed
    # NO tests -- so it records pass:"inconclusive" (the string, distinct from the
    # bool true/false) + gap:source_without_runnable_tests. Keeping the key on
    # BOTH writers gives consumers a single stable schema shape.
    local _tr_pass_json="$test_passed" _tr_gap_json="none"
    if [ "$_tr_zero_tests" = "true" ]; then
        _tr_pass_json='"inconclusive"'
        _tr_gap_json="source_without_runnable_tests"
    fi
    cat > "$quality_dir/test-results.json" << TREOF
{"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","runner":"$test_runner","pass":$_tr_pass_json,"min_coverage":$min_coverage,"summary":"$details","command":"$_tr_cmd","exit_code":$_tr_exit,"status":"$_tr_status","passed_count":$_tr_passed_n,"failed_count":$_tr_failed_n,"verification_gap":"$_tr_gap_json"}
TREOF
    # Finding #598: stamp the per-iteration freshness marker (see above).
    printf '%s\n' "${ITERATION_COUNT:-0}" > "$quality_dir/.test-results.iter" 2>/dev/null || true

    # ---- P0-1 Fix A: best-effort coverage MEASUREMENT (v7.47.0) --------------
    # Runs AFTER test_passed is decided. NEVER mutates test_passed (a coverage
    # tooling failure must not flip a green suite to red). Writes a normalized
    # .loki/quality/coverage.json with honest measured/pct/reason. Blocks the
    # gate (coverage_block=true) ONLY when measurable AND below threshold AND
    # LOKI_ENFORCE_COVERAGE=1. A coverage block is distinct from a tests-red
    # block: it does NOT set TESTS_FAILED and does NOT remove unit-tests.pass.
    #
    # Knob semantics (measurement is OPT-IN: it re-runs the suite instrumented,
    # so for an autonomous loop iterating many times it is off unless requested):
    #   default (unset)        -> skip measurement entirely (no double-run).
    #   LOKI_COVERAGE_GATE=1    -> measure + record + warn, never block.
    #   LOKI_ENFORCE_COVERAGE=1 -> implies measurement; measurable + below
    #                              LOKI_MIN_COVERAGE -> BLOCK.
    #   tool absent / unsupported -> record measured:false, never block.
    local coverage_block=false
    if [ "${LOKI_COVERAGE_GATE:-0}" != "0" ] || [ "${LOKI_ENFORCE_COVERAGE:-0}" = "1" ]; then
        COVERAGE_MEASURED=false; COVERAGE_PCT=""; COVERAGE_TOOL="none"; COVERAGE_REASON=""
        measure_test_coverage "${TARGET_DIR:-.}" "$test_runner" || true

        local cov_enforced="${LOKI_ENFORCE_COVERAGE:-0}"
        local cov_below=false
        if [ "$COVERAGE_MEASURED" = "true" ] && [ -n "$COVERAGE_PCT" ]; then
            # Float-safe compare via python3 (pct may be e.g. 87.5).
            if _LOKI_COV_PCT="$COVERAGE_PCT" _LOKI_COV_MIN="$min_coverage" python3 -c "
import os, sys
try:
    pct=float(os.environ['_LOKI_COV_PCT']); mn=float(os.environ['_LOKI_COV_MIN'])
except Exception:
    sys.exit(2)
sys.exit(0 if pct < mn else 1)
" 2>/dev/null; then
                cov_below=true
            fi
        fi
        if [ "$COVERAGE_MEASURED" = "true" ] && [ "$cov_below" = "true" ] && [ "$cov_enforced" = "1" ]; then
            coverage_block=true
        fi

        # Normalized coverage.json (single source of truth for coverage facts).
        _LOKI_COV_MEASURED="$COVERAGE_MEASURED" \
        _LOKI_COV_PCT="$COVERAGE_PCT" \
        _LOKI_COV_TOOL="$COVERAGE_TOOL" \
        _LOKI_COV_REASON="$COVERAGE_REASON" \
        _LOKI_COV_MIN="$min_coverage" \
        _LOKI_COV_ENFORCED="$cov_enforced" \
        _LOKI_COV_BLOCKED="$coverage_block" \
        _LOKI_COV_RUNNER="$test_runner" \
        _LOKI_COV_OUT="$quality_dir/coverage.json" \
        python3 -c "
import json, os, tempfile
out=os.environ['_LOKI_COV_OUT']
measured = os.environ.get('_LOKI_COV_MEASURED','false') == 'true'
pct_raw = os.environ.get('_LOKI_COV_PCT','')
try:
    pct = float(pct_raw) if (measured and pct_raw != '') else None
except ValueError:
    pct = None
def b(v): return os.environ.get(v,'false') == 'true'
def i(v):
    try: return int(float(os.environ.get(v,'0')))
    except (TypeError, ValueError): return 0
rec = {
    'measured': measured,
    'pct': pct,
    'tool': os.environ.get('_LOKI_COV_TOOL','none'),
    'runner': os.environ.get('_LOKI_COV_RUNNER','none'),
    'threshold': i('_LOKI_COV_MIN'),
    'enforced': os.environ.get('_LOKI_COV_ENFORCED','0') == '1',
    'blocked': b('_LOKI_COV_BLOCKED'),
    'reason': os.environ.get('_LOKI_COV_REASON','') if not measured else '',
    'timestamp': __import__('datetime').datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
    }
d=os.path.dirname(out)
fd, tmp=tempfile.mkstemp(dir=d, suffix='.json')
with os.fdopen(fd,'w') as f:
    json.dump(rec, f, indent=2)
os.replace(tmp, out)
" 2>/dev/null || true

        if [ "$COVERAGE_MEASURED" = "true" ]; then
            if [ "$coverage_block" = "true" ]; then
                log_warn "Coverage gate: ${COVERAGE_TOOL} measured ${COVERAGE_PCT}% < ${min_coverage}% (LOKI_ENFORCE_COVERAGE=1) -- BLOCK"
            elif [ "$cov_below" = "true" ]; then
                log_warn "Coverage: ${COVERAGE_TOOL} measured ${COVERAGE_PCT}% < ${min_coverage}% (warn only; set LOKI_ENFORCE_COVERAGE=1 to block)"
            else
                log_info "Coverage: ${COVERAGE_TOOL} measured ${COVERAGE_PCT}% (threshold ${min_coverage}%)"
            fi
        else
            log_info "Coverage: not measured (${COVERAGE_REASON:-unknown}); pass-through, not blocking"
        fi
    else
        # P3-5/coverage-honesty (v7.51.0): measurement is OPT-IN (it re-runs the
        # suite instrumented, which would double every test run -- a UX
        # regression for an autonomous loop). At default-off we deliberately do
        # NOT measure, but we STILL write a coverage fact so the run manifest /
        # reproducibility record always has one honest coverage shape. This is
        # the "missing-artifact" fix, not a hollow gate: measured=false, pct=null,
        # blocked=false, with an explicit reason. ZERO runtime (no instrumented
        # re-run). Reuses the EXACT python3 writer + schema used at default-on so
        # consumers see a single shape. Single-pass, never blocks.
        _LOKI_COV_MEASURED="false" \
        _LOKI_COV_PCT="" \
        _LOKI_COV_TOOL="none" \
        _LOKI_COV_REASON="not requested (set LOKI_COVERAGE_GATE=1 to measure)" \
        _LOKI_COV_MIN="$min_coverage" \
        _LOKI_COV_ENFORCED="0" \
        _LOKI_COV_BLOCKED="false" \
        _LOKI_COV_RUNNER="$test_runner" \
        _LOKI_COV_OUT="$quality_dir/coverage.json" \
        python3 -c "
import json, os, tempfile
out=os.environ['_LOKI_COV_OUT']
measured = os.environ.get('_LOKI_COV_MEASURED','false') == 'true'
pct_raw = os.environ.get('_LOKI_COV_PCT','')
try:
    pct = float(pct_raw) if (measured and pct_raw != '') else None
except ValueError:
    pct = None
def b(v): return os.environ.get(v,'false') == 'true'
def i(v):
    try: return int(float(os.environ.get(v,'0')))
    except (TypeError, ValueError): return 0
rec = {
    'measured': measured,
    'pct': pct,
    'tool': os.environ.get('_LOKI_COV_TOOL','none'),
    'runner': os.environ.get('_LOKI_COV_RUNNER','none'),
    'threshold': i('_LOKI_COV_MIN'),
    'enforced': os.environ.get('_LOKI_COV_ENFORCED','0') == '1',
    'blocked': b('_LOKI_COV_BLOCKED'),
    'reason': os.environ.get('_LOKI_COV_REASON','') if not measured else '',
    'timestamp': __import__('datetime').datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
    }
d=os.path.dirname(out)
fd, tmp=tempfile.mkstemp(dir=d, suffix='.json')
with os.fdopen(fd,'w') as f:
    json.dump(rec, f, indent=2)
os.replace(tmp, out)
" 2>/dev/null || true
    fi

    if [ "$test_passed" = "true" ]; then
        touch "$quality_dir/unit-tests.pass"
        rm -f "$loki_dir/signals/TESTS_FAILED" 2>/dev/null || true
        log_info "Test suite gate: $test_runner passed"
        # Coverage block is distinct from tests-red: tests passed, but enforced
        # coverage is below threshold. Return nonzero to gate WITHOUT writing the
        # TESTS_FAILED signal or removing unit-tests.pass.
        if [ "$coverage_block" = "true" ]; then
            return 1
        fi
        return 0
    else
        rm -f "$quality_dir/unit-tests.pass"
        echo "tests_failed" > "$loki_dir/signals/TESTS_FAILED" 2>/dev/null || true
        log_warn "Test suite gate: $test_runner FAILED"
        return 1
    fi
}

# ============================================================================
# Finding #598 (HIGH): ensure REAL test evidence exists before the
# verified-completion evidence gate runs on a completion claim.
#
# The evidence gate (council_evidence_gate) blocks completion iff the diff is
# empty OR tests are red. The test axis reads .loki/quality/test-results.json.
# When that file is ABSENT or inconclusive (runner==none / unparsable), the gate
# treats the test axis as pass-through, so completion could be claimed on a
# nonzero diff alone with NO test evidence -- half-blind. This happens whenever
# enforce_test_coverage did not run this iteration, e.g. LOKI_HARD_GATES=false or
# PHASE_UNIT_TESTS=false (the gate at the quality-gate ladder is skipped) while
# the completion-promise route still fires the evidence gate.
#
# Rather than letting absent evidence pass (Option 1, which would live in the
# off-limits completion-council.sh), we GENERATE real evidence here (Option 2,
# preferred + autonomous): run the project's own test command via the existing
# detect-and-run enforce_test_coverage, which persists a fresh test-results.json.
# The gate then reads true PASS/FAIL. If no test runner truly exists, the file
# records runner:none and the test axis legitimately stays pass-through.
#
# Behavior:
#   - Default ON. Opt out with LOKI_COMPLETION_TEST_CAPTURE=0.
#   - Cheap: skips when a fresh test-results.json already exists for THIS
#     iteration (freshness marker .loki/quality/.test-results.iter), so we never
#     re-run the suite the quality-gate ladder already ran.
#   - Best-effort: enforce_test_coverage returns nonzero on red tests; that is
#     EXPECTED and must not crash the completion path. The gate is the decider,
#     so we always swallow the rc with `|| true` and let the gate read the file.
#   - CWD invariant: enforce_test_coverage writes ${TARGET_DIR}/.loki/...; the
#     gate reads .loki/... relative to CWD. Both are invoked from the same loop
#     body where CWD == TARGET_DIR (or TARGET_DIR=="."), matching the existing
#     gate call sites.
# ============================================================================
ensure_completion_test_evidence() {
    [ "${LOKI_COMPLETION_TEST_CAPTURE:-1}" = "0" ] && return 0
    type enforce_test_coverage &>/dev/null || return 0

    local loki_dir="${TARGET_DIR:-.}/.loki"
    local quality_dir="$loki_dir/quality"
    local tr_file="$quality_dir/test-results.json"
    local iter_marker="$quality_dir/.test-results.iter"
    local this_iter="${ITERATION_COUNT:-0}"

    # Freshness guard: if results already exist for this iteration, reuse them.
    if [ -f "$tr_file" ] && [ -f "$iter_marker" ]; then
        local marked
        marked="$(cat "$iter_marker" 2>/dev/null || echo "")"
        if [ "$marked" = "$this_iter" ]; then
            log_info "Completion test evidence: reusing this iteration's test-results.json"
            return 0
        fi
    fi

    log_info "Completion test evidence: capturing fresh test results before evidence gate (opt out: LOKI_COMPLETION_TEST_CAPTURE=0)"
    # Record the test-results.json mtime BEFORE capture so we only mark this
    # iteration "fresh" if enforce_test_coverage actually (re)wrote the file.
    # Guards LOW-2 (bug-hunt): if capture is interrupted before writing while a
    # prior-iteration file exists, the marker must NOT advance and let stale
    # evidence read as fresh. Window is narrow but the check is cheap.
    local _results_file="$quality_dir/test-results.json"
    local _mtime_before=""
    [ -f "$_results_file" ] && _mtime_before=$(stat -f %m "$_results_file" 2>/dev/null || stat -c %Y "$_results_file" 2>/dev/null || echo "")
    # The gate decides on the persisted file; a red suite (nonzero rc) is expected
    # and must not abort the completion path here.
    # F49: the project's test suite may exec the app, which can write into HOME;
    # run it under the isolated build-time HOME.
    _loki_with_app_sandbox enforce_test_coverage || true
    mkdir -p "$quality_dir" 2>/dev/null || true
    local _mtime_after=""
    [ -f "$_results_file" ] && _mtime_after=$(stat -f %m "$_results_file" 2>/dev/null || stat -c %Y "$_results_file" 2>/dev/null || echo "")
    # Only advance the freshness marker when the results file was actually
    # produced/updated by THIS capture (mtime advanced or file newly created).
    if [ -n "$_mtime_after" ] && [ "$_mtime_after" != "$_mtime_before" ]; then
        printf '%s\n' "$this_iter" > "$iter_marker" 2>/dev/null || true
    fi
    return 0
}

# P1-1 (v7.51.0): ADVISORY consumer for the evidence-gate detail record that
# completion-council.sh:_write_evidence_details writes on EVERY evidence-gate run
# (pass and block) to .loki/council/evidence-gate-details.json. Until now run.sh
# had ZERO consumers of that file -- the audit record was durable but invisible
# to the operator and to the next-iteration prompt. This surfaces a one-line
# advisory summary (verdict + diff axis + tests axis). It NEVER blocks and NEVER
# introduces a new gate (the evidence gate itself already blocks; this is purely
# visibility). Absent or malformed file -> degrade silently (no error, no block).
surface_evidence_gate_details() {
    local _det_file="${TARGET_DIR:-.}/.loki/council/evidence-gate-details.json"
    [ -f "$_det_file" ] || return 0
    local _summary
    _summary=$(_LOKI_EGD_FILE="$_det_file" python3 -c "
import json, os, sys
try:
    with open(os.environ['_LOKI_EGD_FILE']) as f:
        d = json.load(f)
except Exception:
    sys.exit(0)
if not isinstance(d, dict):
    sys.exit(0)
verdict = d.get('verdict', 'unknown')
diff = d.get('diff', {}) if isinstance(d.get('diff'), dict) else {}
tests = d.get('tests', {}) if isinstance(d.get('tests'), dict) else {}
diff_ok = diff.get('ok')
tests_ok = tests.get('ok')
runner = tests.get('runner', 'none')
parts = ['verdict=%s' % verdict]
parts.append('diff_ok=%s' % diff_ok)
parts.append('tests_ok=%s (runner=%s)' % (tests_ok, runner))
if diff.get('inconclusive'):
    parts.append('diff_inconclusive=%s' % (diff.get('inconclusive_reason') or 'yes'))
if tests.get('inconclusive'):
    parts.append('tests_inconclusive=%s' % (tests.get('inconclusive_reason') or 'yes'))
print(' '.join(str(p) for p in parts))
" 2>/dev/null) || return 0
    [ -n "$_summary" ] || return 0
    if printf '%s' "$_summary" | grep -q "verdict=block"; then
        log_warn "[Council] Evidence-gate details: $_summary"
    else
        log_info "[Council] Evidence-gate details: $_summary"
    fi
    return 0
}

# P1-1 (v7.51.0): wrapper that runs the evidence gate, then surfaces its detail
# record on BOTH the pass and block paths, and returns the gate's exact rc. This
# preserves the elif chain's `! council_evidence_gate` semantics byte-for-byte
# (fall-through on pass so the held-out and assumption gates downstream still
# evaluate; block on a 1). The surface call is advisory-only and never affects
# the returned rc. The detail file is fresh here -- _write_evidence_details ran
# inside council_evidence_gate just above on this same iteration.
_evidence_gate_and_surface() {
    local _rc=0
    council_evidence_gate || _rc=$?
    surface_evidence_gate_details || true
    return $_rc
}

# ============================================================================
# Documentation Staleness Check (v6.75.0)
# Checks if generated documentation is stale relative to HEAD
# ============================================================================

run_doc_staleness_check() {
    local manifest="$TARGET_DIR/.loki/docs/docs-manifest.json"
    if [ ! -f "$manifest" ]; then
        log_info "Documentation: No docs generated yet (run 'loki docs generate')"
        return 0
    fi

    local doc_sha
    doc_sha=$(python3 -c "import json; print(json.load(open('$manifest')).get('git_sha', ''))" 2>/dev/null)
    if [ -z "$doc_sha" ]; then
        return 0
    fi

    local commits_behind
    commits_behind=$(git -C "${TARGET_DIR:-.}" rev-list --count "$doc_sha..HEAD" 2>/dev/null || echo "0")

    if [ "$commits_behind" -gt 10 ]; then
        log_warn "Documentation is $commits_behind commits behind. Consider running 'loki docs update'."
        # Emit DOCS_NEEDED signal for the parallel docs worktree
        mkdir -p "$TARGET_DIR/.loki/signals"
        touch "$TARGET_DIR/.loki/signals/DOCS_NEEDED"
    else
        log_info "Documentation: up to date ($commits_behind commits since last update)"
    fi
}

# ============================================================================
# Documentation Quality Gate - Gate 7 (Documentation Coverage)
# Checks README, documentation freshness, and package API docs
# ============================================================================

# shellcheck disable=SC2120
run_doc_quality_gate() {
    local project_dir="${1:-${TARGET_DIR:-.}}"
    local score=100
    local issues=()

    # F52 (doc-scope, gate half): for a "simple"-tier project the accepted
    # documentation standard is README.md + USAGE.md, NOT the .loki/docs/
    # architecture suite. auto_generate_docs_if_needed deliberately skips the
    # suite for simple tier, so the manifest / API.md checks below MUST NOT
    # penalize it (else the gate would nag for docs we intentionally did not
    # generate, forcing extra iterations -- the exact waste F52 removes). Score
    # simple tier on README + USAGE only. Mirrors DOC_SCOPE_INSTRUCTION_SIMPLE.
    local doc_tier="${DETECTED_COMPLEXITY:-standard}"

    # Check 1: README.md exists
    if [ ! -f "$project_dir/README.md" ] || [ ! -s "$project_dir/README.md" ]; then
        score=$((score - 20))
        issues+=("README.md missing or empty")
    fi

    if [ "$doc_tier" = "simple" ]; then
        # USAGE.md is the always-on end-user handoff doc (usage_doc_instruction);
        # for a simple project it plus README is sufficient. Penalize only if the
        # always-on USAGE.md is absent/empty.
        if [ ! -f "$project_dir/USAGE.md" ] || [ ! -s "$project_dir/USAGE.md" ]; then
            score=$((score - 20))
            issues+=("USAGE.md missing or empty")
        fi
        if [ ${#issues[@]} -gt 0 ]; then
            log_warn "Documentation Gate: Score $score/100 (simple tier: README + USAGE)"
            for issue in "${issues[@]}"; do
                log_warn "  - $issue"
            done
        else
            log_info "Documentation Gate: PASS (Score $score/100, simple tier: README + USAGE)"
        fi
        [ "$score" -ge 70 ]
        return $?
    fi

    # Check 2: Documentation freshness
    local manifest="$project_dir/.loki/docs/docs-manifest.json"
    if [ -f "$manifest" ]; then
        local doc_sha
        doc_sha=$(python3 -c "import json; print(json.load(open('$manifest')).get('git_sha', ''))" 2>/dev/null)
        if [ -n "$doc_sha" ]; then
            local behind
            behind=$(git -C "$project_dir" rev-list --count "$doc_sha..HEAD" 2>/dev/null || echo "0")
            if [ "$behind" -gt 10 ]; then
                score=$((score - 15))
                issues+=("Documentation is $behind commits behind HEAD")
            fi
        fi
    else
        score=$((score - 10))
        if [ "${LOKI_AUTO_DOCS:-true}" = "true" ]; then
            issues+=("No generated documentation found (auto-generation did not complete)")
        else
            issues+=("No generated documentation found (auto-docs disabled; run 'loki docs generate')")
        fi
    fi

    # Check 3: Package documentation (for npm/pip packages)
    if [ -f "$project_dir/package.json" ] || [ -f "$project_dir/setup.py" ] || [ -f "$project_dir/pyproject.toml" ]; then
        if [ ! -f "$project_dir/.loki/docs/API.md" ]; then
            score=$((score - 15))
            issues+=("Package detected but no API documentation generated")
        fi
    fi

    # Report
    if [ ${#issues[@]} -gt 0 ]; then
        log_warn "Documentation Gate: Score $score/100"
        for issue in "${issues[@]}"; do
            log_warn "  - $issue"
        done
    else
        log_info "Documentation Gate: PASS (Score $score/100)"
    fi

    # Gate passes if score >= 70
    [ "$score" -ge 70 ]
}

# ============================================================================
# Auto-Documentation Generation (intelligent default)
# Generates the .loki/docs/ suite before the documentation gate evaluates so
# the gate scores on real generated docs instead of nagging the user to run
# 'loki docs generate' by hand. Default-on; opt out with LOKI_AUTO_DOCS=false.
#
# Bounded: runs at most once per run when docs are missing, and again only
# when the existing docs are >10 commits stale (the same threshold the gate
# and staleness check use). 'loki docs generate' writes its manifest
# unconditionally (template fallback when no provider), so the missing-docs
# trigger fires exactly once. Best-effort: never fails the iteration loop.
# ============================================================================

auto_generate_docs_if_needed() {
    [ "${LOKI_AUTO_DOCS:-true}" = "true" ] || return 0

    # F52 (doc-scope, generator half): a trivial "simple"-tier project does not
    # warrant the eight-file architecture suite. 'loki docs generate' runs the
    # provider agentically (claude -p), which writes ARCHITECTURE/API/COMPONENTS/
    # DECISIONS/SETUP/TESTING/README/CLAUDE to the project -- ~270s of pure token
    # + wall-clock burn with no reader for a 5-line CLI. The agent already wrote
    # README.md + USAGE.md under DOC_SCOPE_INSTRUCTION_SIMPLE, so skip the suite.
    # Parity: this is the generator-side mirror of DOC_SCOPE_INSTRUCTION_SIMPLE
    # (build_prompt.ts / run.sh:14421); the gate half (run_doc_quality_gate)
    # accepts README+USAGE for simple so skipping here does not fail Gate 7.
    # DETECTED_COMPLEXITY is set once by run_autonomous before the first
    # build_prompt, so it is populated by the time this runs (post code-review).
    if [ "${DETECTED_COMPLEXITY:-standard}" = "simple" ]; then
        log_info "Auto-documentation: simple project -- README + USAGE only, skipping architecture suite (F52 doc-scope)"
        return 0
    fi

    local project_dir="${TARGET_DIR:-.}"
    local manifest="$project_dir/.loki/docs/docs-manifest.json"
    # A timed-out attempt never writes the manifest (the provider call was killed
    # mid-write), so "manifest absent" stayed true forever and this re-fired the
    # full ~300s generation EVERY iteration with no progress (observed: ~20 min
    # burned across 4 iterations on one build, zero gate benefit -- doc_coverage
    # already passes on the partial docs a timed-out attempt leaves behind, since
    # it scores README/API.md presence, not the manifest). This marker records a
    # timeout attempt separately from the manifest so a killed run is remembered
    # and not retried every iteration; a genuine completed manifest still drives
    # the normal staleness (>10 commits behind) regen path unchanged.
    local timeout_marker="$project_dir/.loki/docs/.last-attempt-timed-out"
    local needs_gen=false

    if [ ! -f "$manifest" ]; then
        if [ -f "$timeout_marker" ]; then
            needs_gen=false
        else
            needs_gen=true
        fi
    else
        # Regenerate only when the existing docs are substantially stale.
        local doc_sha
        doc_sha=$(python3 -c "import json; print(json.load(open('$manifest')).get('git_sha', ''))" 2>/dev/null)
        if [ -n "$doc_sha" ]; then
            local behind
            behind=$(git -C "$project_dir" rev-list --count "$doc_sha..HEAD" 2>/dev/null || echo "0")
            [ "$behind" -gt 10 ] && needs_gen=true
        fi
        # A fresh manifest supersedes any earlier timeout; allow future staleness
        # regen to fire normally instead of being permanently suppressed.
        [ "$needs_gen" = "true" ] && rm -f "$timeout_marker"
    fi

    [ "$needs_gen" = "true" ] || return 0

    local loki_bin="$SCRIPT_DIR/loki"
    [ -x "$loki_bin" ] || return 0

    log_info "Auto-documentation: generating .loki/docs/ before documentation gate..."
    # Synchronous so docs exist before the gate scores. Provider-agnostic:
    # 'loki docs generate' picks the run's provider and falls back to
    # template-based docs when no provider CLI is available.
    #
    # BOUNDED: 'loki docs generate' spawns a provider (claude) call that can hang
    # with no cap. This runs AFTER completion, before the detached --pr push/PR
    # step, so a hang here silently blocks the whole PR from ever being created
    # (observed: a verified build stuck ~55 min in this step, work committed but
    # never pushed). Wrap it in a timeout; docs are non-gating, so on timeout we
    # warn and continue to the gate (which scores whatever docs exist).
    local _doc_to="${LOKI_DOCS_TIMEOUT:-${LOKI_GATE_TIMEOUT:-300}}"
    # SCALE THE BUDGET TO WHAT THERE IS TO DOCUMENT. Measured on a graded
    # single-file build (hard-1-order-api): 2.8 min of iteration work, 13.4 min
    # wall clock, and 5.0 of those minutes were THIS step timing out at 300s
    # over one 20-line Python file -- 37% of the build spent generating an
    # eight-file architecture suite for a project that has one module.
    #
    # The simple-tier fast-path above did not catch it: detect_complexity reads
    # PRD PROSE (288 words, 3 sections) rather than build effort, so a
    # tersely-specified one-function task classifies "standard". Rather than
    # move those thresholds -- they also drive iteration caps and model tiers --
    # this bounds the WASTE directly, where the cost actually is.
    #
    # Only ever LOWERS the budget, and only when the operator did not set one.
    # A big project keeps the full 300s. Counting is capped so the find itself
    # cannot become the new cost.
    if [ -z "${LOKI_DOCS_TIMEOUT:-}" ]; then
        local _doc_src=0
        _doc_src=$(find "$project_dir" -type f \
            \( -name '*.py' -o -name '*.js' -o -name '*.ts' -o -name '*.tsx' \
               -o -name '*.go' -o -name '*.rs' -o -name '*.java' \) \
            -not -path '*/node_modules/*' -not -path '*/.loki/*' \
            -not -path '*/.git/*' -not -path '*/dist/*' 2>/dev/null | head -40 | wc -l | tr -d ' ')
        _doc_src="${_doc_src:-0}"
        # <=3 source files cannot need an architecture suite. 90s still allows a
        # README + USAGE pass, which is all the gate asks of a small project.
        if [ "$_doc_src" -le 3 ] && [ "$_doc_to" -gt 90 ]; then
            log_info "Auto-documentation: ${_doc_src} source file(s) -- capping generation at 90s (was ${_doc_to}s)"
            _doc_to=90
        fi
    fi
    local _doc_cmd=()
    if command -v gtimeout >/dev/null 2>&1; then
        _doc_cmd=(gtimeout "${_doc_to}s")
    elif command -v timeout >/dev/null 2>&1; then
        _doc_cmd=(timeout "${_doc_to}s")
    fi
    if "${_doc_cmd[@]}" "$loki_bin" docs generate "$project_dir" >/dev/null 2>&1; then
        :
    else
        local _doc_rc=$?
        if [ "$_doc_rc" -eq 124 ]; then
            log_warn "Auto-documentation: timed out after ${_doc_to}s (gate will score on what exists); continuing"
            mkdir -p "$project_dir/.loki/docs" 2>/dev/null
            touch "$timeout_marker" 2>/dev/null
        else
            log_warn "Auto-documentation: generation did not complete (gate will score on what exists)"
        fi
    fi
}

# ============================================================================
# Magic Modules Debate Gate - Gate 12 (v6.77.0)
# Runs when any .loki/magic/specs/*.md changed since last iteration.
# Blocks iteration completion if debate flags any block severity.
# ============================================================================

run_magic_debate_gate() {
    local specs_dir="$TARGET_DIR/.loki/magic/specs"
    if [ ! -d "$specs_dir" ]; then
        return 0
    fi

    local has_specs
    has_specs=$(find "$specs_dir" -maxdepth 1 -name "*.md" 2>/dev/null | head -1)
    if [ -z "$has_specs" ]; then
        return 0
    fi

    # Auto-run update to catch stale generated files
    log_info "Magic Modules: running incremental update"
    (cd "$TARGET_DIR" && PYTHONPATH="$PROJECT_DIR" LOKI_PROVIDER="${PROVIDER_NAME:-claude}" \
        "$PROJECT_DIR/autonomy/loki" magic update 2>&1 | tail -10) || true

    # Run debate on most recently modified component
    local latest_spec
    latest_spec=$(find "$specs_dir" -maxdepth 1 -name "*.md" -type f -print0 2>/dev/null | xargs -0 ls -t 2>/dev/null | head -1)
    if [ -z "$latest_spec" ]; then
        return 0
    fi
    local latest_name
    latest_name=$(basename "$latest_spec" .md)

    # NOT guarded on "a generated artifact exists". That guard was written and
    # then removed after measuring: the `magic update` call above GENERATES the
    # component (verified -- a bare spec directory gains a 2689-byte
    # generated/react/<name>.tsx), so by this point the artifact is present and
    # its code does reach the personas. A guard here would be dead code resting
    # on a false premise.
    #
    # The BLOCK observed while fixing this ("CODE TO REVIEW is still empty") came
    # from debating a deliberately one-line stub spec, which is a legitimate
    # verdict on genuinely thin input, not a spurious process block.
    log_info "Magic Modules: running debate on '$latest_name'"
    local debate_out debate_rc
    debate_out=$(cd "$TARGET_DIR" && PYTHONPATH="$PROJECT_DIR" LOKI_PROVIDER="${PROVIDER_NAME:-claude}" \
        timeout 300 "$PROJECT_DIR/autonomy/loki" magic debate "$latest_name" --rounds 2 2>&1) \
        && debate_rc=0 || debate_rc=$?

    # A debate that could not RUN is not a debate that found nothing. The old
    # code ended this pipeline in '|| true' and then grepped for a blocking
    # severity, so a crash produced no match and the gate reported PASS -- which
    # is how a TypeError in the CLI call left Gate 12 silently fail-open.
    #
    # But "could not run" splits in two, and the halves need opposite handling:
    #
    #   ENVIRONMENT  the provider CLI is absent, timed out, or exited non-zero.
    #                Common and not the project's fault. Blocking here would
    #                stop every run without working provider credentials over an
    #                advisory gate, so this DEGRADES: warn, record, return 0.
    #   WIRING       the debate itself is broken (import error, bad arguments).
    #                Nobody's build is judged and nobody is told, which is the
    #                defect being fixed. This must be LOUD.
    #
    # Fail-safe direction is deliberate: an unrecognised failure degrades rather
    # than blocks, so a new provider error shape can never wedge every build.
    if [ "$debate_rc" -ne 0 ]; then
        case "$debate_out" in
            *"not available"*|*TypeError*|*SyntaxError*|*ImportError*|*"unexpected keyword"*)
                log_error "Magic Modules Gate 12 is BROKEN for '$latest_name' (rc=$debate_rc): the debate could not execute, so no component is being judged."
                printf '%s\n' "$debate_out" | tail -5 >&2
                return 1
                ;;
        esac
        if [ "$debate_rc" -eq 124 ]; then
            log_warn "Magic Modules Gate 12: debate timed out after 300s for '$latest_name'; treating as not-judged, not as PASS"
        else
            log_warn "Magic Modules Gate 12: debate could not run (rc=$debate_rc, provider/environment) for '$latest_name'; treating as not-judged, not as PASS"
        fi
        printf '%s\n' "$debate_out" | tail -3 >&2
        return 0
    fi

    # Parse debate outcome; block if any persona set severity=block.
    #
    # ADVISORY BY DEFAULT (LOKI_GATE_MAGIC_DEBATE_BLOCKING=true to enforce).
    # This gate was fail-open from v6.77.0 until the TypeError above was fixed,
    # so its blocking path had NEVER run against a real project. Measuring it
    # before enabling it showed why that matters: on a deliberately thorough
    # spec -- explicit KB budgets, a named device class, zero-JS server
    # component, stated contrast ratio -- THREE of four personas still returned
    # "block". Two independent specs, two blocks.
    #
    # A single "block" from any one persona ANDs four strict reviewers together,
    # so the gate approves only when all four are simultaneously satisfied. That
    # is a threshold almost nothing clears, and flipping it on would turn a gate
    # that never blocked into one that blocks nearly every build -- a worse
    # regression than the silent fail-open being fixed here.
    #
    # The finding is still surfaced and still recorded; it just does not stop
    # the run until the threshold is tuned against real projects. Making a
    # never-exercised gate enforcing is a separate, measured decision.
    if echo "$debate_out" | grep -qi '"severity"[[:space:]]*:[[:space:]]*"block"'; then
        if [ "${LOKI_GATE_MAGIC_DEBATE_BLOCKING:-false}" = "true" ]; then
            log_warn "Magic Modules Gate 12: debate returned BLOCK severity for '$latest_name'"
            return 1
        fi
        log_warn "Magic Modules Gate 12: debate returned BLOCK severity for '$latest_name' (advisory; set LOKI_GATE_MAGIC_DEBATE_BLOCKING=true to enforce)"
        return 0
    fi

    log_info "Magic Modules Gate 12: PASS"
    return 0
}

# ============================================================================
# Mock Integrity Gate (P0-3): wire tests/detect-mock-problems.sh as a blocking
# gate. The detector scans test files for mock patterns that mask real failures
# (tautological assertions, inline-mock-only tests, conditional/empty bodies,
# high internal-mock ratios). Invoked with --strict so it exits 1 iff CRITICAL
# or HIGH findings exist; MED/LOW never block (they are routed to a findings
# file for next-iteration injection). Opt out with LOKI_GATE_MOCK=false.
#
# Scan-target note: the wrapper exports LOKI_SCAN_DIR=TARGET_DIR at the detector
# invocation, and the detector honors it (tests/detect-mock-problems.sh:23), so
# the gate scans the target project, not the loki-mode tree. When LOKI_SCAN_DIR
# is unset the detector falls back to its own repo (the default for loki-mode's
# own test run); the wrapper always sets it, so the target is what gets scanned.
# ============================================================================
_loki_mock_integrity_has_applicable_tests() {
    local scan_dir="${1:-${TARGET_DIR:-.}}"
    find "$scan_dir" \
        \( -type d \( -name node_modules -o -name dist -o -name .git -o -name .loki \) -prune \) -o \
        \( -type f \( \
            -name "*.test.ts" -o -name "*.test.tsx" \
            -o -name "*.test.js" -o -name "*.test.jsx" \
            -o -name "*.spec.ts" -o -name "*.spec.tsx" \
            -o -name "*.spec.js" -o -name "*.spec.jsx" \
            -o -name "test_*.py" \
        \) -print -quit \) 2>/dev/null | grep -q .
}

enforce_mock_integrity() {
    local loki_dir="${TARGET_DIR:-.}/.loki"
    local quality_dir="$loki_dir/quality"
    mkdir -p "$quality_dir"
    local findings_file="$quality_dir/mock-findings.txt"
    local detector="$SCRIPT_DIR/../tests/detect-mock-problems.sh"
    local gate_timeout="${LOKI_GATE_TIMEOUT:-300}"

    _LOKI_MOCK_INTEGRITY_STATUS="not_run"
    _LOKI_MOCK_INTEGRITY_REASON="unknown"

    if ! _loki_mock_integrity_has_applicable_tests "${TARGET_DIR:-.}"; then
        _LOKI_MOCK_INTEGRITY_REASON="no_applicable_tests"
        log_info "Mock integrity gate: no applicable tests found; gate did not run"
        rm -f "$findings_file" 2>/dev/null || true
        return 0
    fi

    if [ ! -f "$detector" ]; then
        _LOKI_MOCK_INTEGRITY_REASON="detector_missing"
        log_info "Mock integrity gate: detector not found; gate did not run"
        rm -f "$findings_file" 2>/dev/null || true
        return 0
    fi

    local output rc
    output=$(cd "${TARGET_DIR:-.}" && LOKI_SCAN_DIR="${TARGET_DIR:-.}" \
        timeout "$gate_timeout" bash "$detector" --strict 2>&1)
    rc=$?

    # timeout exit 124 -- treat as inconclusive (do not block on a hang)
    if [ "$rc" -eq 124 ]; then
        _LOKI_MOCK_INTEGRITY_REASON="timeout"
        log_warn "Mock integrity gate: detector timed out after ${gate_timeout}s; gate did not run"
        rm -f "$findings_file" 2>/dev/null || true
        return 0
    fi

    if [ "$rc" -eq 1 ] && grep -qE '\[(CRITICAL|HIGH)\]' <<< "$output"; then
        _LOKI_MOCK_INTEGRITY_STATUS="fail"
        _LOKI_MOCK_INTEGRITY_REASON="critical_or_high_findings"
        # --strict exits 1 iff CRITICAL or HIGH found. Persist per-finding text.
        {
            echo "# Mock integrity findings (CRITICAL/HIGH block this iteration)"
            echo "$output" | grep -E '\[(CRITICAL|HIGH|MEDIUM|LOW)\]' || true
        } > "$findings_file"
        log_warn "Mock integrity gate: CRITICAL/HIGH mock problems detected -- BLOCK"
        return 1
    fi

    if [ "$rc" -ne 0 ]; then
        _LOKI_MOCK_INTEGRITY_REASON="detector_error_${rc}"
        log_warn "Mock integrity gate: detector exited ${rc} without a verdict; gate did not run"
        rm -f "$findings_file" 2>/dev/null || true
        return 0
    fi

    # Pass: record any MED/LOW findings for injection, then clear the block file.
    local med_low
    med_low=$(echo "$output" | grep -E '\[(MEDIUM|LOW)\]' || true)
    if [ -n "$med_low" ]; then
        {
            echo "# Mock integrity advisory findings (MED/LOW, non-blocking)"
            echo "$med_low"
        } > "$findings_file"
    else
        rm -f "$findings_file" 2>/dev/null || true
    fi
    _LOKI_MOCK_INTEGRITY_STATUS="pass"
    _LOKI_MOCK_INTEGRITY_REASON="measured"
    log_info "Mock integrity gate: PASS"
    return 0
}

enforce_lsp_diagnostics() {
    local lsp_file="${TARGET_DIR:-.}/.loki/quality/lsp-diagnostics.json"
    local verdict="absent"

    _LOKI_LSP_DIAGNOSTICS_STATUS="not_run"
    _LOKI_LSP_DIAGNOSTICS_REASON="artifact_absent"
    _LOKI_LSP_DIAGNOSTICS_DETAIL="absent"

    if [ "${LOKI_GATE_LSP_WRITER:-1}" != "0" ]; then
        # A failed writer must not leave a prior iteration's result looking fresh.
        rm -f "$lsp_file" 2>/dev/null || true
        if ! ( cd "$PROJECT_DIR" && LOKI_DIR="${TARGET_DIR:-.}/.loki" \
            python3 -m mcp.lsp_proxy --write-diagnostics --root "${TARGET_DIR:-.}" \
        ) >/dev/null 2>&1; then
            _LOKI_LSP_DIAGNOSTICS_REASON="writer_error"
            return 0
        fi
    fi

    if [ -f "$lsp_file" ]; then
        verdict=$(_LOKI_LSP_FILE="$lsp_file" python3 -c '
import json, os, sys
try:
    with open(os.environ["_LOKI_LSP_FILE"], encoding="utf-8") as handle:
        data = json.load(handle)
except Exception:
    print("absent")
    raise SystemExit(0)
if not isinstance(data, dict):
    print("absent")
    raise SystemExit(0)
diagnostics = data.get("diagnostics")
errors = data.get("count_errors")
warnings = data.get("count_warnings")
valid_count = lambda value: isinstance(value, int) and not isinstance(value, bool) and value >= 0
if not isinstance(diagnostics, list) or not valid_count(errors) or not valid_count(warnings):
    print("absent")
    raise SystemExit(0)
measured_errors = sum(1 for item in diagnostics if isinstance(item, dict) and item.get("severity") == 1)
measured_warnings = sum(1 for item in diagnostics if isinstance(item, dict) and item.get("severity") == 2)
if errors != measured_errors or warnings != measured_warnings:
    print("absent")
elif errors > 0:
    print("block %d %d" % (errors, warnings))
elif warnings > 0:
    print("warn %d %d" % (errors, warnings))
else:
    print("clean 0 0")
' 2>/dev/null) || verdict="absent"
        [ -n "$verdict" ] || verdict="absent"
    fi

    _LOKI_LSP_DIAGNOSTICS_DETAIL="$verdict"
    case "$verdict" in
        block*)
            _LOKI_LSP_DIAGNOSTICS_STATUS="fail"
            _LOKI_LSP_DIAGNOSTICS_REASON="errors"
            return 1
            ;;
        warn*)
            _LOKI_LSP_DIAGNOSTICS_STATUS="pass"
            _LOKI_LSP_DIAGNOSTICS_REASON="measured_with_warnings"
            return 0
            ;;
        clean*)
            _LOKI_LSP_DIAGNOSTICS_STATUS="pass"
            _LOKI_LSP_DIAGNOSTICS_REASON="measured_clean"
            return 0
            ;;
        *)
            _LOKI_LSP_DIAGNOSTICS_REASON="artifact_absent_or_invalid"
            return 0
            ;;
    esac
}

# ============================================================================
# Test Mutation Integrity Gate (P0-3): wire tests/detect-test-mutations.sh as a
# blocking gate. The detector flags assertion-value mutations that look like
# test-fitting (tests changed to match buggy output). We do NOT pass --strict:
# --strict blocks on ANY finding (over-blocks on MED/LOW). Instead we parse
# stdout and block only when a [HIGH] line is present; MED/LOW are routed to a
# findings file for next-iteration injection. Opt out with LOKI_GATE_MUTATION=false.
#
# Scan-target note: same as the mock gate -- the wrapper exports
# LOKI_SCAN_DIR=TARGET_DIR and the detector honors it
# (tests/detect-test-mutations.sh:33), so the gate scans the target project, not
# the loki-mode tree. The Check-5 git history is also read from that directory.
# ============================================================================
enforce_mutation_integrity() {
    local loki_dir="${TARGET_DIR:-.}/.loki"
    local quality_dir="$loki_dir/quality"
    mkdir -p "$quality_dir"
    local findings_file="$quality_dir/mutation-findings.txt"
    local detector="$SCRIPT_DIR/../tests/detect-test-mutations.sh"
    local gate_timeout="${LOKI_GATE_TIMEOUT:-300}"

    if [ ! -f "$detector" ]; then
        echo "[HIGH] mutation detector unavailable: $detector" > "$findings_file"
        log_warn "Mutation integrity gate: detector not found -- BLOCK (fail-closed)"
        return 1
    fi

    local output rc
    # --block-high provides an unambiguous rc 2 for HIGH findings without
    # over-blocking MED/LOW advisories.
    output=$(cd "${TARGET_DIR:-.}" && LOKI_SCAN_DIR="${TARGET_DIR:-.}" \
        timeout "$gate_timeout" bash "$detector" --block-high 2>&1)
    rc=$?

    if [ "$rc" -eq 124 ]; then
        echo "[HIGH] mutation detector timed out after ${gate_timeout}s" > "$findings_file"
        log_warn "Mutation integrity gate: detector timed out after ${gate_timeout}s -- BLOCK (fail-closed)"
        return 1
    fi

    local high_count
    high_count=$(echo "$output" | grep -c '\[HIGH\]' || true)
    # grep -c returns 0 with no matches but may print empty under set -e edge; normalize.
    [ -z "$high_count" ] && high_count=0

    if [ "$rc" -eq 2 ] || [ "$high_count" -gt 0 ]; then
        {
            echo "# Test mutation findings (HIGH blocks this iteration)"
            echo "$output" | grep -E '\[(HIGH|MEDIUM|MED|LOW)\]' || true
        } > "$findings_file"
        log_warn "Mutation integrity gate: $high_count HIGH test-fitting finding(s) -- BLOCK"
        return 1
    fi

    if [ "$rc" -ne 0 ]; then
        {
            echo "# Test mutation detector failure (blocks this iteration)"
            echo "[HIGH] detector exited $rc without a valid verdict"
        } > "$findings_file"
        log_warn "Mutation integrity gate: detector exited $rc -- BLOCK (fail-closed)"
        return 1
    fi

    # Pass: route any MED/LOW findings to injection file, else clear it.
    local med_low
    med_low=$(echo "$output" | grep -E '\[(MEDIUM|MED|LOW)\]' || true)
    if [ -n "$med_low" ]; then
        {
            echo "# Test mutation advisory findings (MED/LOW, non-blocking)"
            echo "$med_low"
        } > "$findings_file"
    else
        rm -f "$findings_file" 2>/dev/null || true
    fi
    log_info "Mutation integrity gate: PASS"
    return 0
}

# ============================================================================
# Semantic Test-Authenticity Gate (P1-3): wire tests/detect-semantic-test-problems.sh.
# The detector catches the harder class of fake tests that the regex detectors
# (gates 5+6) miss: assertions that look real but verify nothing because the
# asserted value never flows through code under test (literal-via-variable echo
# HIGH, mock-return echo MED, deleted assertions MED).
#
# POSTURE (v7.57.0): this enforce_* helper is the shared core for TWO callers:
#   1) the DEFAULT-ON mid-iteration ADVISORY arm (gated on LOKI_GATE_SEMANTIC_TESTS,
#      default true) -- runs every iteration, writes findings, and on a CRIT/HIGH
#      result the arm only calls track_gate_failure (surfaces to the next prompt),
#      NEVER PAUSEs / NEVER rejects completion (clone of the mock arm).
#   2) the OPT-IN completion-BLOCKING elif (gated on LOKI_GATE_SEMANTIC_TESTS_BLOCK,
#      default false) -- when set, rejects the completion claim on a CRIT/HIGH.
# The default-on flip applies ONLY to surfacing; blocking stays opt-in via the
# separate _BLOCK flag, so surfacing-default-on does NOT make blocking default-on.
#
# NO-DEADLOCK CONTRACT: it runs the detector with --block-high (clean exit-code
# contract: rc 2 iff a CRITICAL/HIGH finding exists). It surfaces ALL severities
# to a findings file (advisory) and returns nonzero ONLY on rc 2. Every other
# exit -- rc 0 (clean), rc 124 (timeout), detector absent, no test files,
# malformed output -- returns 0 (pass/fall-through), so the autonomous loop can
# NEVER deadlock on a clean run (default-on surfacing is therefore deadlock-safe,
# exactly as the mock/mutation gates prove in production). Mirrors
# enforce_mock_integrity's invocation (cd TARGET_DIR + LOKI_SCAN_DIR=TARGET_DIR +
# timeout), swapping --strict for --block-high and deciding on the rc-2 contract.
# ============================================================================
enforce_semantic_integrity() {
    local loki_dir="${TARGET_DIR:-.}/.loki"
    local quality_dir="$loki_dir/quality"
    mkdir -p "$quality_dir"
    local findings_file="$quality_dir/semantic-findings.txt"
    local detector="$SCRIPT_DIR/../tests/detect-semantic-test-problems.sh"
    local gate_timeout="${LOKI_GATE_TIMEOUT:-300}"

    if [ ! -f "$detector" ]; then
        log_info "Semantic test gate: detector not found, skipping (inconclusive)"
        rm -f "$findings_file" 2>/dev/null || true
        return 0
    fi

    local output rc
    # --block-high exits 2 iff CRITICAL/HIGH present; 0 otherwise (clean wrapper).
    output=$(cd "${TARGET_DIR:-.}" && LOKI_SCAN_DIR="${TARGET_DIR:-.}" \
        timeout "$gate_timeout" bash "$detector" --block-high 2>&1)
    rc=$?

    # timeout exit 124 -- inconclusive, never block on a hang (deny-filter)
    if [ "$rc" -eq 124 ]; then
        log_warn "Semantic test gate: detector timed out after ${gate_timeout}s -- inconclusive"
        rm -f "$findings_file" 2>/dev/null || true
        return 0
    fi

    if [ "$rc" -eq 2 ]; then
        # rc 2 == one or more CRITICAL/HIGH findings. Persist per-finding text.
        {
            echo "# Semantic test-authenticity findings (CRITICAL/HIGH block this completion)"
            echo "$output" | grep -E '\[(CRITICAL|HIGH|MEDIUM|LOW)\]' || true
        } > "$findings_file"
        log_warn "Semantic test gate: CRITICAL/HIGH fake-test problems detected -- BLOCK"
        return 1
    fi

    # rc 0 (and any other non-2, non-124 code, e.g. a malformed run) -> PASS.
    # Route any MED/LOW advisory findings to the injection file, else clear it.
    local med_low
    med_low=$(echo "$output" | grep -E '\[(MEDIUM|LOW)\]' || true)
    if [ -n "$med_low" ]; then
        {
            echo "# Semantic test advisory findings (MED/LOW, non-blocking)"
            echo "$med_low"
        } > "$findings_file"
    else
        rm -f "$findings_file" 2>/dev/null || true
    fi
    log_info "Semantic test gate: PASS"
    return 0
}

# P1-3 wrapper that runs the semantic gate and returns its exact rc, mirroring
# _evidence_gate_and_surface so the completion-promise elif arm reads cleanly
# (`! _semantic_gate_and_surface`). Returns nonzero ONLY when enforce_semantic_integrity
# saw an rc-2 (CRITICAL/HIGH) result; all deny-filter cases already collapse to 0
# inside enforce_semantic_integrity, so this never blocks a clean run.
_semantic_gate_and_surface() {
    local _rc=0
    enforce_semantic_integrity || _rc=$?
    return "$_rc"
}

# P1-4 invariant/property gate (bash-route parity, v7.51.0). The Bun route
# ships an invariant toggle (loki-ts/src/runner/quality_gates.ts:2057,
# `invariants: flag("LOKI_GATE_INVARIANTS", false)`) running
# tests/detect-invariant-violations.sh --strict; the bash route had NO
# counterpart, so the bash/Bun parity test reported "Only in bun:
# LOKI_GATE_INVARIANTS". This wires the bash side, mirroring
# enforce_semantic_integrity's structure byte-for-byte:
#   - detector --strict exit-code contract (tests/detect-invariant-violations.sh
#     :347-353): rc 1 iff CRITICAL/HIGH present, rc 0 otherwise.
#   - detector honors LOKI_SCAN_DIR (tests/detect-invariant-violations.sh:123),
#     so the wrapper exports it to the TARGET project (cwd alone does NOT
#     redirect the scan).
# The PLURAL token LOKI_GATE_INVARIANTS is used deliberately to match the Bun
# readToggles flag name; the detector's own reference comment suggests a
# singular variant (no trailing S), which is NOT used here (parity needs the
# plural).
#
# POSTURE (v7.57.0): this enforce_* helper is the shared core for TWO callers,
# mirroring enforce_semantic_integrity:
#   1) the DEFAULT-ON mid-iteration ADVISORY arm (gated on LOKI_GATE_INVARIANTS,
#      default true) -- runs every iteration, writes invariant-findings.txt, and
#      on a CRIT/HIGH result the arm only calls track_gate_failure (surfaces to
#      the next prompt via the invariant injector in build_prompt), NEVER PAUSEs /
#      NEVER rejects completion.
#   2) the OPT-IN completion-BLOCKING elif (gated on LOKI_GATE_INVARIANTS_BLOCK,
#      default false) -- when set, rejects the completion claim on a CRIT/HIGH.
# Surfacing-default-on does NOT make blocking default-on (separate _BLOCK flag).
enforce_invariant_integrity() {
    local loki_dir="${TARGET_DIR:-.}/.loki"
    local quality_dir="$loki_dir/quality"
    mkdir -p "$quality_dir"
    local findings_file="$quality_dir/invariant-findings.txt"
    local detector="$SCRIPT_DIR/../tests/detect-invariant-violations.sh"
    local gate_timeout="${LOKI_GATE_TIMEOUT:-300}"

    if [ ! -f "$detector" ]; then
        log_info "Invariant gate: detector not found, skipping (inconclusive)"
        rm -f "$findings_file" 2>/dev/null || true
        return 0
    fi

    local output rc
    # --strict exits 1 iff CRITICAL/HIGH present; 0 otherwise (clean wrapper).
    output=$(cd "${TARGET_DIR:-.}" && LOKI_SCAN_DIR="${TARGET_DIR:-.}" \
        timeout "$gate_timeout" bash "$detector" --strict 2>&1)
    rc=$?

    # timeout exit 124 -- inconclusive, never block on a hang (deny-filter)
    if [ "$rc" -eq 124 ]; then
        log_warn "Invariant gate: detector timed out after ${gate_timeout}s -- inconclusive"
        rm -f "$findings_file" 2>/dev/null || true
        return 0
    fi

    if [ "$rc" -eq 1 ]; then
        # rc 1 == one or more CRITICAL/HIGH findings. Persist per-finding text.
        {
            echo "# Invariant findings (CRITICAL/HIGH block this completion)"
            echo "$output" | grep -E '\[(CRITICAL|HIGH|MEDIUM|LOW)\]' || true
        } > "$findings_file"
        log_warn "Invariant gate: CRITICAL/HIGH invariant violations detected -- BLOCK"
        return 1
    fi

    # rc 0 (and any other non-1, non-124 code, e.g. a malformed run) -> PASS.
    # Route any MED/LOW advisory findings to the injection file, else clear it.
    local med_low
    med_low=$(echo "$output" | grep -E '\[(MEDIUM|LOW)\]' || true)
    if [ -n "$med_low" ]; then
        {
            echo "# Invariant advisory findings (MED/LOW, non-blocking)"
            echo "$med_low"
        } > "$findings_file"
    else
        rm -f "$findings_file" 2>/dev/null || true
    fi
    log_info "Invariant gate: PASS"
    return 0
}

# Thin wrapper mirroring _semantic_gate_and_surface so the completion-promise
# elif arm reads cleanly (`! _invariant_gate_and_surface`). Returns nonzero
# ONLY when enforce_invariant_integrity saw an rc-1 (CRITICAL/HIGH) result; all
# deny-filter cases already collapse to 0 inside enforce_invariant_integrity,
# so this never blocks a clean run.
_invariant_gate_and_surface() {
    local _rc=0
    enforce_invariant_integrity || _rc=$?
    return "$_rc"
}

# ============================================================================
# 3-Reviewer Parallel Code Review (v5.35.0)
# Specialist pool from skills/quality-gates.md with blind review
# architecture-strategist always included, 2 more selected by keyword scoring
# ============================================================================

# Write managed-council verdicts into the legacy per-reviewer .txt layout so
# the dashboard quality panel (which only reads .loki/quality/reviews/$id/*.txt)
# stays functional. Called from the managed branch of run_code_review().
# Single-writer invariant: either this helper writes the files, or the legacy
# CLI fan-out does -- never both for the same review_id.
council_verdicts_to_txt_files() {
    local review_id="$1"
    local verdicts_json="$2"
    local loki_dir="${TARGET_DIR:-.}/.loki"
    local review_dir="$loki_dir/quality/reviews/$review_id"
    mkdir -p "$review_dir"

    # Use python3 to fan the JSON verdict list out to individual .txt files
    # in the same VERDICT/FINDINGS format the legacy parser expects.
    local out_dir_env="$review_dir"
    export LOKI_COUNCIL_OUT_DIR="$out_dir_env"
    export LOKI_COUNCIL_VERDICTS_JSON="$verdicts_json"
    python3 << 'COUNCIL_WRITE'
import json
import os
import re

out_dir = os.environ["LOKI_COUNCIL_OUT_DIR"]
raw = os.environ.get("LOKI_COUNCIL_VERDICTS_JSON", "").strip()
if not raw:
    raise SystemExit(0)

try:
    payload = json.loads(raw)
except json.JSONDecodeError:
    raise SystemExit("council_verdicts_to_txt_files: invalid JSON")

if isinstance(payload, dict):
    verdicts = payload.get("verdicts") or []
else:
    verdicts = payload or []

SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
DOT_RUN = re.compile(r"\.{2,}")

def _pool_name(v):
    name = v.get("pool_name") or v.get("name") or v.get("agent_id") or "reviewer"
    cleaned = SAFE_NAME.sub("-", str(name))
    # Defend against path-traversal via ".." in pool names.
    cleaned = DOT_RUN.sub("-", cleaned).strip("-.")
    return cleaned[:80] or "reviewer"

def _verdict_token(v):
    token = str(v.get("verdict") or "").strip().upper()
    if token in ("APPROVE", "PASS"):
        return "PASS"
    if token in ("REQUEST_CHANGES", "REJECT", "FAIL"):
        return "FAIL"
    return "PASS"  # ABSTAIN => PASS per legacy behavior

def _findings(v):
    rationale = (v.get("rationale") or "").strip()
    sev = v.get("severity")
    if not rationale:
        return "- None"
    lines = []
    for line in rationale.splitlines():
        line = line.strip()
        if not line:
            continue
        if line.lstrip().startswith("- ["):
            lines.append(line)
        else:
            tag = f"[{sev.capitalize()}]" if sev else "[Medium]"
            lines.append(f"- {tag} {line}")
    return "\n".join(lines) if lines else "- None"

for v in verdicts:
    if not isinstance(v, dict):
        continue
    name = _pool_name(v)
    path = os.path.join(out_dir, f"{name}.txt")
    body = f"VERDICT: {_verdict_token(v)}\nFINDINGS:\n{_findings(v)}\n"
    with open(path, "w", encoding="utf-8") as f:
        f.write(body)
COUNCIL_WRITE
    local rc=$?
    unset LOKI_COUNCIL_OUT_DIR LOKI_COUNCIL_VERDICTS_JSON
    return $rc
}

# Execute the managed-agents multiagent council path. Writes legacy .txt
# files via council_verdicts_to_txt_files() on success so the existing
# aggregation loop below can read them exactly like the CLI path.
# Returns 0 on success, 1 on ManagedUnavailable (caller should fall back).
_run_managed_review_council() {
    local review_id="$1"
    local diff_file="$2"
    local files_file="$3"
    local review_dir="${TARGET_DIR:-.}/.loki/quality/reviews/$review_id"
    mkdir -p "$review_dir"

    export LOKI_MANAGED_REVIEW_ID="$review_id"
    export LOKI_MANAGED_REVIEW_DIFF_FILE="$diff_file"
    export LOKI_MANAGED_REVIEW_FILES_FILE="$files_file"
    export LOKI_MANAGED_REVIEW_OUT_JSON="$review_dir/managed_result.json"
    local project_dir_env="${PROJECT_DIR:-.}"
    export LOKI_MANAGED_REVIEW_PROJECT_DIR="$project_dir_env"

    local result_json
    result_json=$(python3 << 'MANAGED_REVIEW' 2>&1
import json
import os
import sys

project_dir = os.environ.get("LOKI_MANAGED_REVIEW_PROJECT_DIR", ".")
if project_dir and project_dir not in sys.path:
    sys.path.insert(0, project_dir)

try:
    from providers import managed as managed_mod
except Exception as e:
    print(json.dumps({"status": "unavailable", "reason": f"import_failed: {e}"}))
    sys.exit(0)

# Test hook: allow tests to inject a fake run_council by setting
# LOKI_MANAGED_REVIEW_FAKE_MODULE to a dotted path exposing run_council.
fake_mod = os.environ.get("LOKI_MANAGED_REVIEW_FAKE_MODULE", "").strip()
if fake_mod:
    try:
        import importlib
        fm = importlib.import_module(fake_mod)
        if hasattr(fm, "install"):
            fm.install(managed_mod)
    except Exception as e:
        print(json.dumps({"status": "unavailable", "reason": f"fake_install_failed: {e}"}))
        sys.exit(0)

if not managed_mod.is_enabled():
    print(json.dumps({"status": "unavailable", "reason": "is_enabled_false"}))
    sys.exit(0)

diff_path = os.environ.get("LOKI_MANAGED_REVIEW_DIFF_FILE", "")
files_path = os.environ.get("LOKI_MANAGED_REVIEW_FILES_FILE", "")
diff_text = ""
files_text = ""
if diff_path and os.path.exists(diff_path):
    with open(diff_path, "r", encoding="utf-8", errors="replace") as f:
        diff_text = f.read()
if files_path and os.path.exists(files_path):
    with open(files_path, "r", encoding="utf-8", errors="replace") as f:
        files_text = f.read()

target_paths = [p.strip() for p in files_text.splitlines() if p.strip()]

pool = ["security-sentinel", "test-coverage-auditor", "performance-oracle"]
context = {
    "diff": diff_text,
    "files": target_paths,
    "target_paths": target_paths,
}

try:
    result = managed_mod.run_council(pool, context, timeout_s=300)
except managed_mod.ManagedUnavailable as e:
    print(json.dumps({"status": "unavailable", "reason": str(e)}))
    sys.exit(0)
except Exception as e:
    # Anything else is unexpected; bubble up as unavailable so the caller
    # falls back rather than aborting the iteration.
    print(json.dumps({"status": "unavailable", "reason": f"unexpected: {e}"}))
    sys.exit(0)

verdicts_out = []
for v in (result.verdicts or []):
    verdicts_out.append({
        "agent_id": getattr(v, "agent_id", ""),
        "pool_name": getattr(v, "pool_name", ""),
        "verdict": getattr(v, "verdict", ""),
        "rationale": getattr(v, "rationale", ""),
        "severity": getattr(v, "severity", None),
    })
out = {
    "status": "ok",
    "verdicts": verdicts_out,
    "session_id": getattr(result, "session_id", None),
    "elapsed_ms": getattr(result, "elapsed_ms", 0),
    "partial": getattr(result, "partial", False),
}
print(json.dumps(out))
MANAGED_REVIEW
)
    local py_rc=$?
    unset LOKI_MANAGED_REVIEW_ID LOKI_MANAGED_REVIEW_DIFF_FILE LOKI_MANAGED_REVIEW_FILES_FILE
    unset LOKI_MANAGED_REVIEW_OUT_JSON LOKI_MANAGED_REVIEW_PROJECT_DIR

    if [ $py_rc -ne 0 ] || [ -z "$result_json" ]; then
        emit_event_json "managed_agents_fallback" \
            "op=run_code_review" \
            "reason=subprocess_failed" \
            "review_id=$review_id"
        emit_managed_event_bash "managed_agents_fallback" \
            "op=run_code_review" \
            "reason=subprocess_failed" \
            "review_id=$review_id"
        return 1
    fi

    local status
    status=$(printf '%s' "$result_json" | python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '{}'); print(d.get('status',''))" 2>/dev/null || echo "")

    if [ "$status" != "ok" ]; then
        local reason
        reason=$(printf '%s' "$result_json" | python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '{}'); print(d.get('reason',''))" 2>/dev/null || echo "")
        emit_event_json "managed_agents_fallback" \
            "op=run_code_review" \
            "reason=managed_unavailable" \
            "detail=${reason//\"/}" \
            "review_id=$review_id"
        emit_managed_event_bash "managed_agents_fallback" \
            "op=run_code_review" \
            "reason=managed_unavailable" \
            "detail=${reason//\"/}" \
            "review_id=$review_id"
        return 1
    fi

    # Persist the raw managed result for observability and write legacy .txt
    # files for the dashboard panel / aggregation loop.
    printf '%s\n' "$result_json" > "$review_dir/managed_result.json"
    if ! council_verdicts_to_txt_files "$review_id" "$result_json"; then
        emit_event_json "managed_agents_fallback" \
            "op=run_code_review" \
            "reason=verdict_write_failed" \
            "review_id=$review_id"
        emit_managed_event_bash "managed_agents_fallback" \
            "op=run_code_review" \
            "reason=verdict_write_failed" \
            "review_id=$review_id"
        return 1
    fi

    emit_event_json "managed_review_council_ok" \
        "review_id=$review_id" \
        "iteration=${ITERATION_COUNT:-0}"
    emit_managed_event_bash "managed_review_council_ok" \
        "review_id=$review_id" \
        "iteration=${ITERATION_COUNT:-0}"
    return 0
}

# Return the whole seconds left before one review dispatch's absolute deadline.
# The caller deliberately rounds up only the final partial second. This keeps a
# structured-output miss plus its text fallback inside one budget instead of
# granting each attempt a fresh LOKI_REVIEW_CALL_TIMEOUT.
_loki_review_deadline_remaining() {
    local deadline_ms="${1:-0}"
    local now_ms remaining_ms
    now_ms=$(python3 -c 'import time; print(time.monotonic_ns() // 1000000)' 2>/dev/null) || return 1
    remaining_ms=$((deadline_ms - now_ms))
    [ "$remaining_ms" -gt 0 ] 2>/dev/null || return 1
    printf '%s\n' "$(((remaining_ms + 999) / 1000))"
}

# Rebuild the requirements contract only from specification bytes matching the
# build-start SHA, then compare every persisted artifact byte-for-byte. The
# provider receives derived stdout, never authority read directly from a
# writable review artifact.
_loki_requirements_contract_emit() {
    local emit_kind="${1:-none}"
    local helper="${LOKI_REVIEW_REQUIREMENTS_HELPER:-}"
    [ -n "$helper" ] && [ -f "$helper" ] \
        && [ -n "${LOKI_REVIEW_REQUIREMENTS_SOURCE:-}" ] \
        && [ -n "${LOKI_REVIEW_REQUIREMENTS_SNAPSHOT:-}" ] \
        && [ -n "${LOKI_REVIEW_REQUIREMENTS_MANIFEST:-}" ] \
        && [ -n "${LOKI_REVIEW_REQUIREMENTS_SCHEMA:-}" ] \
        && [ -n "${LOKI_REVIEW_REQUIREMENTS_IDENTITY:-}" ] \
        && [[ "${LOKI_REVIEW_REQUIREMENTS_EXPECTED_SHA:-}" =~ ^[0-9a-f]{64}$ ]] \
        || return 1
    if [ -n "${LOKI_REVIEW_REQUIREMENTS_SHARD_INDEX:-}" ]; then
        [ -n "${LOKI_REVIEW_REQUIREMENTS_FULL_MANIFEST:-}" ] \
            && [ -n "${LOKI_REVIEW_REQUIREMENTS_FULL_SCHEMA:-}" ] \
            && [ -n "${LOKI_REVIEW_REQUIREMENTS_FULL_IDENTITY:-}" ] \
            && [ -n "${LOKI_REVIEW_REQUIREMENTS_SHARD_SIZE:-}" ] \
            && [ -n "${LOKI_REVIEW_REQUIREMENTS_SHARD_DIR:-}" ] \
            || return 1
        python3 "$helper" verify-shard \
            "$LOKI_REVIEW_REQUIREMENTS_SOURCE" \
            "$LOKI_REVIEW_REQUIREMENTS_SNAPSHOT" \
            "$LOKI_REVIEW_REQUIREMENTS_EXPECTED_SHA" \
            "${LOKI_REVIEW_REQUIREMENTS_MAX_BYTES:-}" \
            "${LOKI_REVIEW_REQUIREMENTS_HARD_MAX_BYTES:-}" \
            "$LOKI_REVIEW_REQUIREMENTS_FULL_MANIFEST" \
            "$LOKI_REVIEW_REQUIREMENTS_FULL_SCHEMA" \
            "$LOKI_REVIEW_REQUIREMENTS_FULL_IDENTITY" \
            "$LOKI_REVIEW_REQUIREMENTS_SHARD_SIZE" \
            "$LOKI_REVIEW_REQUIREMENTS_SHARD_DIR" \
            "$LOKI_REVIEW_REQUIREMENTS_SHARD_INDEX" \
            "$emit_kind" \
            "${LOKI_REVIEW_REQUIREMENTS_IDENTITY:-}"
        return $?
    fi
    python3 "$helper" verify \
        "$LOKI_REVIEW_REQUIREMENTS_SOURCE" \
        "$LOKI_REVIEW_REQUIREMENTS_SNAPSHOT" \
        "$LOKI_REVIEW_REQUIREMENTS_EXPECTED_SHA" \
        "${LOKI_REVIEW_REQUIREMENTS_MAX_BYTES:-}" \
        "${LOKI_REVIEW_REQUIREMENTS_HARD_MAX_BYTES:-}" \
        "$LOKI_REVIEW_REQUIREMENTS_MANIFEST" \
        "$LOKI_REVIEW_REQUIREMENTS_SCHEMA" \
        "$emit_kind" \
        "${LOKI_REVIEW_REQUIREMENTS_IDENTITY:-}"
}

# Persist one compact record per provider review. The sidecar is written by the
# same process that owns the dispatch, so parallel reviewer timings do not get
# distorted by the parent's ordered wait loop.
_dispatch_reviewer_recorded() {
    local prompt_text="$1"
    local review_output="$2"
    local stderr_output="${LOKI_REVIEW_STDERR_FILE:-${review_output%.txt}-stderr.log}"
    local started_ms ended_ms rc=0 outcome="output"
    : > "$stderr_output" || return 125
    chmod 600 "$stderr_output" 2>/dev/null || return 125
    started_ms=$(python3 -c 'import time; print(time.monotonic_ns() // 1000000)' 2>/dev/null) || started_ms=0
    _dispatch_reviewer "$prompt_text" "$review_output" 2>> "$stderr_output" || rc=$?
    ended_ms=$(python3 -c 'import time; print(time.monotonic_ns() // 1000000)' 2>/dev/null) || ended_ms="$started_ms"
    if [ "$rc" -eq 124 ]; then
        outcome="deadline"
    elif [ "$rc" -ne 0 ]; then
        outcome="error"
    elif [ ! -s "$review_output" ]; then
        outcome="no_output"
    fi
    _LOKI_RDT_PATH="${review_output%.txt}-timing.json" \
    _LOKI_RDT_STDERR="$stderr_output" \
    _LOKI_RDT_BUDGET="${LOKI_REVIEW_CALL_TIMEOUT:-0}" \
    _LOKI_RDT_START="$started_ms" _LOKI_RDT_END="$ended_ms" \
    _LOKI_RDT_RC="$rc" _LOKI_RDT_OUTCOME="$outcome" python3 - <<'REVIEW_TIMING' 2>/dev/null || true
import json
import os

started = int(os.environ.get("_LOKI_RDT_START", "0") or 0)
ended = int(os.environ.get("_LOKI_RDT_END", "0") or 0)
record = {
    "schema": "loki-review-dispatch/v1",
    "budget_seconds": int(os.environ.get("_LOKI_RDT_BUDGET", "0") or 0),
    "elapsed_ms": max(0, ended - started),
    "exit_code": int(os.environ.get("_LOKI_RDT_RC", "125") or 125),
    "outcome": os.environ.get("_LOKI_RDT_OUTCOME", "error"),
    "deadline_scope": "provider_with_fallbacks",
    "stderr_bytes": os.path.getsize(os.environ["_LOKI_RDT_STDERR"]),
}
with open(os.environ["_LOKI_RDT_PATH"], "w", encoding="utf-8") as handle:
    json.dump(record, handle, sort_keys=True)
REVIEW_TIMING
    return "$rc"
}

# _dispatch_reviewer: single-reviewer provider invocation, factored out of
# run_code_review so the blind-council loop AND the Devil's-Advocate re-review
# (P0-4) share ONE dispatch path. This preserves the load-bearing claude trust
# guards (no --model/Fable routing, --bare, --disallowedTools, caveman OFF) for
# both callers; a hand-written parallel dispatcher would drift from them.
# Args: $1 = prompt text, $2 = output file path. Writes the model reply to $2.
_dispatch_reviewer() {
    local prompt_text="$1"
    local review_output="$2"
    local _review_budget="${LOKI_REVIEW_CALL_TIMEOUT:-0}"
    local _review_requirements_manifest="${LOKI_REVIEW_REQUIREMENTS_MANIFEST:-}"
    if [ -n "$_review_requirements_manifest" ]; then
        if [ "${PROVIDER_NAME:-claude}" != "claude" ]; then
            log_error "Provider cannot enforce the supervised requirements contract."
            return 125
        fi
        if ! _loki_requirements_contract_emit none >/dev/null 2>&1; then
            log_error "Supervised requirements reviewer has no valid structured contract."
            return 125
        fi
    fi
    case "$_review_budget" in
        ''|*[!0-9]*) _review_budget=0 ;;
    esac
    local _review_deadline_ms=0 _review_started_ms=0
    if [ "$_review_budget" -gt 0 ] 2>/dev/null; then
        _review_started_ms=$(python3 -c 'import time; print(time.monotonic_ns() // 1000000)' 2>/dev/null) || return 125
        _review_deadline_ms=$((_review_started_ms + (_review_budget * 1000)))
    fi
    case "${PROVIDER_NAME:-claude}" in
        claude)
            # SECURITY-REVIEW MODEL GUARD (evidence-based routing, item 4b):
            # By DEFAULT reviewers pass NO --model, so they run on the account
            # default model and are NEVER routed to Fable by a mid-flight model
            # override or LOKI_FABLE_ARCHITECT (those only rewrite the
            # iteration's tier_param, not this dispatch). The ONLY way to pin the
            # reviewer model is the explicit opt-in LOKI_ADVISOR_MODEL below,
            # which honors haiku|sonnet|opus and REFUSES fable for the reason
            # stated here. This must stay true. The official model-config docs
            # CONTRADICT
            # routing security review to Fable: Fable's safety classifiers
            # refuse cybersecurity content, and in non-interactive (-p)
            # mode a flagged request ends the turn with stop_reason
            # "refusal" instead of a transparent Opus re-run. A refused
            # security reviewer would return no VERDICT and break the
            # unanimous-council gate. Defensive-cyber capability lives in
            # Mythos 5 (Project Glasswing), not Fable. If a future change
            # adds --model here, the security-sentinel reviewer must be
            # pinned to opus, never fable.
            # EMBED 2 + 3 (v7.33.0). This is a trust-gate council subcall.
            # $prompt_text is fully self-contained (the diff, changed files,
            # checks, and strict VERDICT/FINDINGS output format), output is
            # captured to $review_output, and it deliberately does NOT pass
            # --model or go through buildAutoFlags. So:
            #   EMBED 2 (--bare): the prompt needs no hooks/LSP/CLAUDE.md/
            #     MCP discovery, so --bare is safe and cheaper. Opt out
            #     LOKI_BARE_SUBCALLS=0.
            #   EMBED 3 (--disallowedTools): raise the cost of a reviewer
            #     casually mutating the tree (a parallel agent once ran
            #     `git reset --hard` and wiped uncommitted work). Deny
            #     Edit/Write/NotebookEdit + git mutation forms (incl. the
            #     git -C / --git-dir evasions); read-only git stays allowed.
            #     Guardrail, not a sandbox -- echo>/sed -i/etc. remain; the
            #     real net is commit-before-agent-wave. Opt out
            #     LOKI_REVIEW_TOOL_GUARD=0. See loki_review_guard_denylist.
            local _rv_argv=("--dangerously-skip-permissions")
            # OPT-IN advisor pin (v7.104.0, LOKI_ADVISOR_MODEL): reviewers still
            # pass NO --model by default (unchanged behavior: they run on the
            # account default). When the operator/user sets LOKI_ADVISOR_MODEL to
            # an allowlisted alias, pin the trust-gate reviewer to it for a
            # stronger/consistent judge - the founder's "advisor will be opus if
            # needed or user opted". FABLE IS REFUSED here: the model-config docs
            # say Fable's safety classifiers refuse cybersecurity content and end
            # a -p turn with stop_reason "refusal", which would return no VERDICT
            # and break the unanimous-council gate. So only haiku|sonnet|opus are
            # honored; fable (or any other value) is ignored with no --model.
            if [ -n "${LOKI_ADVISOR_MODEL:-}" ]; then
                local _adv _adv_norm
                _adv="${LOKI_ADVISOR_MODEL}"
                _adv_norm="$(printf '%s' "$_adv" | tr '[:upper:]' '[:lower:]' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
                case "$_adv_norm" in
                    haiku|sonnet|opus) _rv_argv+=("--model" "$_adv_norm") ;;
                    *) : ;;  # fable and invalid values: no --model (account default)
                esac
            fi
            if loki_is_supervised_simple_web; then
                if ! _loki_supervised_claude_isolation_ready; then
                log_error "Supervised reviewer requires isolated setting sources and tool-denial support."
                return 125
                fi
                if [ -z "${LOKI_HOST_GUARD_SETTINGS_JSON:-}" ]; then
                    log_error "Supervised reviewer requires trusted host-guard settings."
                    return 125
                fi
                # The review prompt is self-contained. Removing every provider
                # tool keeps parallel same-UID reviewers from touching sibling
                # staged verdicts or any other workspace path.
                _rv_argv+=(
                    "--settings" "$LOKI_HOST_GUARD_SETTINGS_JSON"
                    "--setting-sources" ""
                    "--tools" ""
                    "--mcp-config" '{"mcpServers":{}}'
                    "--strict-mcp-config"
                    "--disable-slash-commands"
                )
                if type loki_claude_flag_supported >/dev/null 2>&1 \
                   && loki_claude_flag_supported "--effort"; then
                    case "${LOKI_REVIEW_EFFORT:-medium}" in
                        low|medium|high|xhigh|max)
                            _rv_argv+=("--effort" "${LOKI_REVIEW_EFFORT:-medium}")
                            ;;
                    esac
                fi
            elif type loki_subcall_bare_enabled >/dev/null 2>&1 && loki_subcall_bare_enabled; then
                _rv_argv+=("--bare")
            fi
            if type loki_review_guard_enabled >/dev/null 2>&1 && loki_review_guard_enabled; then
                _rv_argv+=("--disallowedTools" "$(loki_review_guard_denylist)")
            fi
            #   EMBED 3b (--allowedTools, #167): positive least-privilege
            #     allowlist. DEFAULT OFF (opt-in LOKI_REVIEW_ALLOWLIST=1).
            #     Emitted ALONGSIDE the denylist: verified live (claude
            #     2.1.177) that deny precedence holds even under
            #     --dangerously-skip-permissions, so the denylist still
            #     hard-blocks mutations while this narrows the surface to
            #     read/inspect tools. See loki_review_allowlist.
            if type loki_review_allowlist_enabled >/dev/null 2>&1 && loki_review_allowlist_enabled; then
                _rv_argv+=("--allowedTools" "$(loki_review_allowlist)")
            fi
            # caveman HARD-SUPPRESS (parsed output): this is a trust-gate
            # subcall whose output is parsed for "^VERDICT:" + findings. A
            # globally-active caveman would compress/reword that line and
            # silently flip the verdict, so we UNCONDITIONALLY disable
            # caveman here with CAVEMAN_DEFAULT_MODE=off (the activate hook
            # then deletes its flag and emits nothing). Set inline, not via
            # the helper, so the carve-out holds even when the helper is
            # out of scope. No-op when caveman is absent.
            # STRUCTURED VERDICT (v8.x): when the CLI supports --json-schema,
            # force valid JSON and re-materialize the LEGACY VERDICT/FINDINGS text
            # so every downstream consumer (_classify_verdict,
            # _severity_is_blocking, _count_nonblocking_findings, mergeability,
            # DA arm, aggregate.json) stays byte-identical. Fail-closed: any
            # failure falls through to the text path below (which itself may leave
            # an empty file -> NO_VERDICT -> inconclusive block). NEVER a PASS on a
            # miss. --json-schema takes INLINE content, not a path (CLI 2.1.207
            # rejects a path). Opt out with LOKI_REVIEW_JSON_SCHEMA=off.
            local _cr_root _cr_here _cr_schema _cr_remat
            # run.sh executes from a protected /tmp self-copy. BASH_SOURCE points
            # at that copy, while PROJECT_DIR and SCRIPT_DIR were restored from
            # LOKI_ORIGINAL_* above. Resolve shipped review assets only from the
            # restored source checkout so a live run cannot silently miss the
            # schema/rematerializer and downgrade to free-form text.
            _cr_root="$PROJECT_DIR"
            _cr_here="$SCRIPT_DIR"
            _cr_schema="${_cr_root}/loki-ts/data/code-review-schema.json"
            _cr_remat="${_cr_here}/lib/cr-rematerialize.py"
            local _cr_requirements_manifest="$_review_requirements_manifest"
            if [ -n "$_cr_requirements_manifest" ]; then
                _cr_schema="${LOKI_REVIEW_REQUIREMENTS_SCHEMA:-}"
            fi
            local _cr_provider_env=(env)
            if [ -n "$_cr_requirements_manifest" ]; then
                local _cr_secret_name
                for _cr_secret_name in \
                    LOKI_REVIEW_REQUIREMENTS_HELPER \
                    LOKI_REVIEW_REQUIREMENTS_SOURCE \
                    LOKI_REVIEW_REQUIREMENTS_SNAPSHOT \
                    LOKI_REVIEW_REQUIREMENTS_MANIFEST \
                    LOKI_REVIEW_REQUIREMENTS_SCHEMA \
                    LOKI_REVIEW_REQUIREMENTS_EXPECTED_SHA \
                    LOKI_REVIEW_REQUIREMENTS_MAX_BYTES \
                    LOKI_REVIEW_REQUIREMENTS_HARD_MAX_BYTES \
                    LOKI_REVIEW_REQUIREMENTS_IDENTITY \
                    LOKI_REVIEW_REQUIREMENTS_FULL_MANIFEST \
                    LOKI_REVIEW_REQUIREMENTS_FULL_SCHEMA \
                    LOKI_REVIEW_REQUIREMENTS_FULL_IDENTITY \
                    LOKI_REVIEW_REQUIREMENTS_SHARD_SIZE \
                    LOKI_REVIEW_REQUIREMENTS_SHARD_DIR \
                    LOKI_REVIEW_REQUIREMENTS_SHARD_INDEX; do
                    _cr_provider_env+=("-u" "$_cr_secret_name")
                done
            fi
            # v8 RAW-SDK REVIEWER PATH (opt-in LOKI_SDK_CODE_REVIEW=1). Run the
            # reviewer via the pure-HTTPS @anthropic-ai/sdk bridge (no claude
            # binary) and re-materialize the SAME legacy VERDICT/FINDINGS text
            # through cr-rematerialize.py, so every downstream consumer stays
            # byte-identical. Runs BEFORE the claude --json-schema block so the
            # no-binary deploy win holds. Fail-closed: on ANY miss (flag off, no
            # key, bun/entrypoint absent, non-zero, empty, or rematerialize
            # reject) fall through to the claude paths below -- never a PASS on a
            # miss (rematerialize is itself fail-closed + forces FAIL on
            # Critical/High). Same schema the claude --json-schema path uses, so
            # verdict parity holds by construction.
            if [ "${LOKI_SDK_CODE_REVIEW:-0}" = "1" ] \
               && [ -f "$_cr_schema" ] && [ -f "$_cr_remat" ]; then
                local _crs_loki _crs_pf _crs_out _crs_rc _crs_schema_file=""
                local _crs_schema_content="" _crs_manifest_content=""
                local _crs_invoked=false
                _crs_loki="${_cr_root}/bin/loki"
                if [ -x "$_crs_loki" ] && command -v bun >/dev/null 2>&1; then
                    _crs_pf="$(mktemp 2>/dev/null)" || _crs_pf=""
                    _crs_schema_file="$_cr_schema"
                    if [ -n "$_cr_requirements_manifest" ]; then
                        _crs_schema_file="$(mktemp 2>/dev/null)" || _crs_schema_file=""
                    fi
                    if [ -n "$_crs_pf" ] && [ -n "$_crs_schema_file" ]; then
                        if [ -n "$_cr_requirements_manifest" ]; then
                            _crs_schema_content="$(_loki_requirements_contract_emit schema 2>/dev/null)" || {
                                rm -f "$_crs_pf" "$_crs_schema_file" 2>/dev/null || true
                                return 125
                            }
                            printf '%s' "$_crs_schema_content" > "$_crs_schema_file"
                            chmod 600 "$_crs_schema_file" 2>/dev/null || {
                                rm -f "$_crs_pf" "$_crs_schema_file" 2>/dev/null || true
                                return 125
                            }
                        else
                            _crs_schema_file="$_cr_schema"
                        fi
                        printf '%s' "$prompt_text" > "$_crs_pf"
                        _crs_rc=0
                        # OS-level ceiling around the bun subprocess (same
                        # rationale as done-recognition/council-v2): --timeout-ms
                        # only bounds the HTTP call; a bun cold-start could hang
                        # the substitution. Degrade to no-cap only if neither
                        # timeout binary exists.
                        local _crs_to_s="${LOKI_SDK_REVIEW_TIMEOUT:-180}"
                        local _crs_cap="$(( _crs_to_s + 15 ))"
                        if [ "$_review_deadline_ms" -gt 0 ]; then
                            _crs_cap=$(_loki_review_deadline_remaining "$_review_deadline_ms") || {
                                rm -f "$_crs_pf" 2>/dev/null || true
                                return 124
                            }
                        fi
                        _crs_out="$(_loki_with_deadline "$_crs_cap" \
                            "${_cr_provider_env[@]}" "$_crs_loki" internal sdk-judge \
                            --prompt-file "$_crs_pf" --schema-file "$_crs_schema_file" \
                            --model "${LOKI_SDK_REVIEW_MODEL:-claude-sonnet-5}" --effort high \
                            --timeout-ms "$(( _crs_to_s * 1000 ))")" || _crs_rc=$?
                        _crs_invoked=true
                        rm -f "$_crs_pf" 2>/dev/null || true
                        if [ -n "$_cr_requirements_manifest" ]; then
                            rm -f "$_crs_schema_file" 2>/dev/null || true
                        fi
                        case "$_crs_rc" in
                            124|125) return "$_crs_rc" ;;
                        esac
                        if [ "$_crs_rc" -eq 0 ] && [ -n "$_crs_out" ]; then
                            if [ -n "$_cr_requirements_manifest" ]; then
                                _crs_manifest_content="$(_loki_requirements_contract_emit manifest 2>/dev/null)" \
                                    || return 125
                            fi
                            if _LOKI_CR_JSON="$_crs_out" _LOKI_CR_OUT="$review_output" \
                               _LOKI_CR_REQUIREMENTS_MANIFEST="$_cr_requirements_manifest" \
                               _LOKI_CR_REQUIREMENTS_MANIFEST_JSON="$_crs_manifest_content" \
                               python3 "$_cr_remat"; then
                                return 0
                            fi
                        fi
                    fi
                fi
                if [ -n "$_cr_requirements_manifest" ] \
                   && [ "$_crs_invoked" = "true" ]; then
                    return 125
                fi
                # fall through to the claude paths below (fail-closed)
            fi
            # Requirements verdicts use the provider's ordinary text transport
            # with an exact schema embedded in the immutable prompt. Local
            # rematerialization then enforces every key, ID, order, bound, hash,
            # and verdict rule. This avoids the measured structured-transport
            # stall while keeping malformed or invented JSON fail-closed.
            if [ -n "$_cr_requirements_manifest" ] \
               && [ -f "$_cr_remat" ]; then
                local _cr_req_cap="$_review_budget"
                local _cr_req_raw="" _cr_req_rc=0 _cr_req_manifest_content=""
                local _cr_req_bytes=0
                local _cr_req_max_bytes="${LOKI_REVIEW_MAX_OUTPUT_BYTES:-1048576}"
                case "$_cr_req_max_bytes" in
                    ''|*[!0-9]*) return 125 ;;
                esac
                [ "$_cr_req_max_bytes" -gt 0 ] 2>/dev/null || return 125
                _cr_req_raw=$(mktemp "${TMPDIR:-/tmp}/loki-requirements-output.XXXXXX") \
                    || return 125
                chmod 600 "$_cr_req_raw" 2>/dev/null || {
                    rm -f "$_cr_req_raw" 2>/dev/null || true
                    return 125
                }
                if [ "$_review_deadline_ms" -gt 0 ]; then
                    _cr_req_cap=$(_loki_review_deadline_remaining "$_review_deadline_ms") \
                        || {
                            rm -f "$_cr_req_raw" 2>/dev/null || true
                            return 124
                        }
                fi
                _loki_with_deadline_stdin_text "$_cr_req_cap" "$prompt_text" \
                    "${_cr_provider_env[@]}" CAVEMAN_DEFAULT_MODE=off \
                        claude "${_rv_argv[@]}" -p \
                        --output-format text > "$_cr_req_raw" \
                    || _cr_req_rc=$?
                case "$_cr_req_rc" in
                    124|125)
                        rm -f "$_cr_req_raw" 2>/dev/null || true
                        return "$_cr_req_rc"
                        ;;
                esac
                _cr_req_bytes=$(wc -c < "$_cr_req_raw" 2>/dev/null | tr -d ' ') \
                    || _cr_req_bytes=0
                if [ "$_cr_req_rc" -ne 0 ] \
                   || [ "$_cr_req_bytes" -le 0 ] 2>/dev/null \
                   || [ "$_cr_req_bytes" -gt "$_cr_req_max_bytes" ] 2>/dev/null; then
                    rm -f "$_cr_req_raw" 2>/dev/null || true
                    return 125
                fi
                _cr_req_manifest_content="$(_loki_requirements_contract_emit manifest 2>/dev/null)" \
                    || {
                        rm -f "$_cr_req_raw" 2>/dev/null || true
                        return 125
                    }
                if _LOKI_CR_OUT="$review_output" \
                   _LOKI_CR_REQUIREMENTS_MANIFEST="$_cr_requirements_manifest" \
                   _LOKI_CR_REQUIREMENTS_MANIFEST_JSON="$_cr_req_manifest_content" \
                   python3 "$_cr_remat" < "$_cr_req_raw"; then
                    rm -f "$_cr_req_raw" 2>/dev/null || true
                    return 0
                fi
                rm -f "$_cr_req_raw" 2>/dev/null || true
                return 125
            fi
            if { [ -n "$_cr_requirements_manifest" ] \
                 || [ "${LOKI_REVIEW_JSON_SCHEMA:-on}" != "off" ]; } \
               && [ -f "$_cr_schema" ] && [ -f "$_cr_remat" ] \
               && type loki_claude_flag_supported >/dev/null 2>&1 \
               && loki_claude_flag_supported "--json-schema"; then
                local _cr_schema_content _cr_json _cr_rc=0 _cr_manifest_content=""
                if [ -n "$_cr_requirements_manifest" ]; then
                    _cr_schema_content="$(_loki_requirements_contract_emit schema 2>/dev/null)" \
                        || return 125
                else
                    _cr_schema_content="$(cat "$_cr_schema" 2>/dev/null)" || _cr_schema_content=""
                fi
                if [ -n "$_cr_schema_content" ]; then
                    local _cr_cap="$_review_budget"
                    if [ "$_review_deadline_ms" -gt 0 ]; then
                        _cr_cap=$(_loki_review_deadline_remaining "$_review_deadline_ms") || return 124
                    fi
                    _cr_json="$(_loki_with_deadline_stdin_text "$_cr_cap" "$prompt_text" \
                        "${_cr_provider_env[@]}" CAVEMAN_DEFAULT_MODE=off \
                            claude "${_rv_argv[@]}" -p \
                            --json-schema "$_cr_schema_content" \
                            --output-format json)" || _cr_rc=$?
                    case "$_cr_rc" in
                        124|125) return "$_cr_rc" ;;
                    esac
                    if [ "$_cr_rc" -eq 0 ] && [ -n "$_cr_json" ]; then
                        if [ -n "$_cr_requirements_manifest" ]; then
                            _cr_manifest_content="$(_loki_requirements_contract_emit manifest 2>/dev/null)" \
                                || return 125
                        fi
                        if _LOKI_CR_JSON="$_cr_json" _LOKI_CR_OUT="$review_output" \
                           _LOKI_CR_REQUIREMENTS_MANIFEST="$_cr_requirements_manifest" \
                           _LOKI_CR_REQUIREMENTS_MANIFEST_JSON="$_cr_manifest_content" \
                           python3 "$_cr_remat"; then
                            return 0
                        fi
                    fi
                fi
                # fall through to the text path below (fail-closed)
            fi
            if [ -n "$_cr_requirements_manifest" ]; then
                return 125
            fi
            local _cr_text_cap="$_review_budget"
            if [ "$_review_deadline_ms" -gt 0 ]; then
                _cr_text_cap=$(_loki_review_deadline_remaining "$_review_deadline_ms") || return 124
            fi
            _loki_with_deadline_stdin_text "$_cr_text_cap" "$prompt_text" \
                env CAVEMAN_DEFAULT_MODE=off claude "${_rv_argv[@]}" -p \
                --output-format text > "$review_output"
            ;;
        codex)
            local _codex_cap="$_review_budget"
            if [ "$_review_deadline_ms" -gt 0 ]; then
                _codex_cap=$(_loki_review_deadline_remaining "$_review_deadline_ms") || return 124
            fi
            _loki_with_deadline "$_codex_cap" \
                codex exec --sandbox workspace-write --skip-git-repo-check "$prompt_text" \
                > "$review_output"
            ;;
        cline)
            local _cline_cap="$_review_budget"
            if [ "$_review_deadline_ms" -gt 0 ]; then
                _cline_cap=$(_loki_review_deadline_remaining "$_review_deadline_ms") || return 124
            fi
            local _cline_argv=(-y)
            [ -n "${LOKI_CLINE_MODEL:-}" ] && _cline_argv+=(-m "$LOKI_CLINE_MODEL")
            _loki_with_deadline "$_cline_cap" cline "${_cline_argv[@]}" "$prompt_text" \
                > "$review_output"
            ;;
        aider)
            local _aider_cap="$_review_budget"
            if [ "$_review_deadline_ms" -gt 0 ]; then
                _aider_cap=$(_loki_review_deadline_remaining "$_review_deadline_ms") || return 124
            fi
            local _aider_model="${AIDER_DEFAULT_MODEL:-${LOKI_AIDER_MODEL:-openrouter/deepseek/deepseek-v3.2}}"
            local _aider_flags=()
            [ -n "${LOKI_AIDER_FLAGS:-}" ] && read -r -a _aider_flags <<< "$LOKI_AIDER_FLAGS"
            _loki_with_deadline "$_aider_cap" aider --message "$prompt_text" \
                --yes-always --no-auto-commits --model "$_aider_model" \
                "${_aider_flags[@]}" < /dev/null > "$review_output"
            ;;
        *)
            : > "$review_output"
            return 125
            ;;
    esac
}

# WAVE8 FIX run.sh-F1/F3 (CRITICAL/HIGH): SAFE-DEFAULT verdict classification.
# Given a reviewer file, extract the VERDICT: line (tolerant of leading
# markdown like '**VERDICT:**' or '# VERDICT:' so fewer reviewers fall to
# NO_VERDICT) and classify it as one of: FAIL, PASS, AMBIGUOUS, NONE.
#   FAIL   -> verdict text contains FAIL/REJECT/BLOCK (verbose suffixes like
#             "FAIL - [Critical] SQLi", "FAIL.", "FAIL (3 criticals)" all match)
#   PASS   -> verdict text contains PASS/APPROVE (and NOT a fail token); this
#             preserves the deliberate "PASS with concerns" = pass semantics.
#   AMBIGUOUS -> a VERDICT: line exists but matches neither (unparseable token).
#                Callers MUST treat this as non-passing (safe direction), never pass.
#   NONE   -> no parseable VERDICT: line at all (empty / missing).
# FAIL-first ordering means a verdict naming both (rare) blocks -- the safe way.
# Mirrors the council's _council_parse_vote: parse-miss defaults to the safe
# (blocking) direction, never to pass.
_classify_verdict() {
    local file="$1"
    [ -f "$file" ] && [ -s "$file" ] || { echo "NONE"; return 0; }
    local verdict
    # Tolerant anchor: optional leading whitespace, then optional markdown
    # markers (* # >), then optional whitespace, then VERDICT:. This rescues
    # '**VERDICT:** FAIL', '# VERDICT: PASS', '> VERDICT: FAIL' that the strict
    # '^VERDICT:' anchor missed (those previously became NO_VERDICT and dropped
    # the reviewer's dissent).
    verdict=$(grep -iE "^[[:space:]]*[*#>]*[[:space:]]*VERDICT:" "$file" \
        | head -1 \
        | sed -E 's/^[[:space:]]*[*#>]*[[:space:]]*[Vv][Ee][Rr][Dd][Ii][Cc][Tt]:[*[:space:]]*//' \
        | tr '[:lower:]' '[:upper:]')
    # Classify on the FIRST verdict TOKEN only, not a substring scan of the whole
    # despaced line. A whole-line scan is asymmetric and wrong: "PASS, no failures
    # found" or "PASS - no blocking issues" contain FAIL/BLOCK as substrings and
    # would misclassify a valid PASS as FAIL (a false-block, and worse, it breaks
    # the unanimous-PASS Devil's-Advocate trigger -> indirect false-PASS). Take
    # the leading alphabetic run as the verdict word: "FAIL - [Critical] x" ->
    # FAIL, "PASS, no failures" -> PASS. Strip leading markdown emphasis first.
    verdict=$(printf '%s' "$verdict" | sed -E 's/^[*_`[:space:]]+//')
    local _vtok
    _vtok=$(printf '%s' "$verdict" | sed -E 's/[^A-Z].*$//')
    if [ -z "$_vtok" ]; then echo "NONE"; return 0; fi
    case "$_vtok" in
        FAIL|FAILED|FAILURE|REJECT|REJECTED|BLOCK|BLOCKED) echo "FAIL" ;;
        PASS|PASSED|APPROVE|APPROVED|OK)                   echo "PASS" ;;
        *)                                                  echo "AMBIGUOUS" ;;
    esac
}

# WAVE8 FIX run.sh-F2 (HIGH): SAFE-DEFAULT severity detection. Returns 0
# (blocking) if the reviewer file names a Critical or High severity finding in
# any realistic emitted form: bracketed '[Critical]', bold '**Critical**',
# 'Severity: High', or a bullet line '- Critical' / '* High'. The strict
# bracket-only match previously missed unbracketed forms, so a FAIL naming an
# unbracketed Critical was treated as non-blocking. BSD/GNU portable (no \b).
_severity_is_blocking() {
    local file="$1"
    [ -f "$file" ] || return 1
    grep -qiE '(\[(critical|high)\])|(\*\*[[:space:]]*(critical|high)[[:space:]]*\*\*)|(severity:?[[:space:]]*(critical|high))|(^[[:space:]]*[-*][[:space:]]+(critical|high)([[:space:]:.,*]|$))' "$file"
}

# 7.114.0 (rank 9): count non-blocking (Medium/Low) findings in a reviewer file.
# Feeds the weighted mergeability quality score. Mirrors the severity-token
# tolerance of _severity_is_blocking (bracketed, bold, 'Severity:', or bullet).
# Echoes "<medium_count> <low_count>" so the caller can weight them differently.
# Parity-locked with countNonBlockingFindings() in loki-ts/src/runner/quality_gates.ts.
_count_nonblocking_findings() {
    local file="$1"
    if [ ! -f "$file" ]; then
        echo "0 0"
        return 0
    fi
    local med low
    med=$(grep -icE '(\[medium\])|(\*\*[[:space:]]*medium[[:space:]]*\*\*)|(severity:?[[:space:]]*medium)|(^[[:space:]]*[-*][[:space:]]+medium([[:space:]:.,*]|$))' "$file")
    low=$(grep -icE '(\[low\])|(\*\*[[:space:]]*low[[:space:]]*\*\*)|(severity:?[[:space:]]*low)|(^[[:space:]]*[-*][[:space:]]+low([[:space:]:.,*]|$))' "$file")
    echo "${med:-0} ${low:-0}"
}

_write_devils_advocate_prompt() {
    local diff_file="$1"
    local files_file="$2"
    local output_file="$3"
    LOKI_DA_PROMPT_DIFF_FILE="$diff_file" \
    LOKI_DA_PROMPT_FILES_FILE="$files_file" \
    LOKI_DA_PROMPT_OUT="$output_file" python3 <<'BUILD_DA_PROMPT'
import os

with open(os.environ["LOKI_DA_PROMPT_FILES_FILE"], "r", encoding="utf-8") as handle:
    files = handle.read().strip()
with open(os.environ["LOKI_DA_PROMPT_DIFF_FILE"], "r", encoding="utf-8") as handle:
    diff = handle.read().strip()

prompt = f"""You are a Devil's Advocate reviewer. Independent reviewers may all approve this change. Unanimous approval is a red flag for insufficient scrutiny. Your SOLE job is to find a Critical or High severity issue they missed.

Be adversarial and concrete. Hunt for: security holes, data loss, race conditions, broken error handling, silent failures, off-by-one and boundary bugs, resource leaks, injection, and logic that does not match intent. Do not rubber-stamp. If after genuine effort you find no Critical/High issue, say so honestly and do not invent one.

Files changed:
{files}

Diff:
{diff}

Output format (STRICT - follow exactly):
VERDICT: PASS or FAIL
FINDINGS:
- [severity] description (file:line)
Severity levels: Critical, High, Medium, Low

Output VERDICT: FAIL only if you found a real Critical or High issue. Otherwise output VERDICT: PASS."""

with open(os.environ["LOKI_DA_PROMPT_OUT"], "w", encoding="utf-8") as handle:
    handle.write(prompt)
BUILD_DA_PROMPT
}

# Derive a review size cap (in bytes) from the active provider's context window.
#
# Args: $1 = env override (wins outright when set), $2 = historical default.
# Echoes the effective cap.
#
# Why this exists: the caps were fixed byte counts sized for a ~200k-token model.
# A local 12b/14b with an 8k-32k window would be handed a 425000-byte prompt and
# fail in a way that reads as "the model is bad" rather than "we mis-sized it".
#
# Two stated assumptions, kept separate so they stay auditable:
#   1. ~3 bytes per token. Real tokenizers land around 3-4 for code-heavy text;
#      3 is the conservative end, and under-estimating capacity errs toward a
#      smaller cap, which is the safe direction for a fail-closed gate.
#   2. ~75% of the window is available for review INPUT. The remainder is the
#      reviewer's own output and reasoning, which share the same window.
# Neither is precise, and neither needs to be: the result is only ever used to
# LOWER a cap below the shipped default.
#
# The min() is load-bearing. PROVIDER_CONTEXT_WINDOW is set on every current run
# (LOKI_PROVIDER defaults to claude, whose window is 1000000), so deriving
# upward would raise the cap 4-8x for every existing user. Taking the smaller of
# derived-vs-default means a cap can only ever move DOWN. Concretely, a window
# clamps to the shipped default whenever it is >= ~188889 tokens; every provider
# that declares a window today (1M/400k/200k/200k) clears that, and a provider
# declaring none takes the unset path, so no shipped provider changes behavior.
# Only a genuinely small window (a local 12b/14b) shrinks anything.
#
# The shipped defaults are 425000 (prompt) and 400000 (diff). That 25000-byte gap
# is the reviewer scaffolding wrapped around the diff, and the ordering is
# load-bearing: if both caps derived to the SAME number, a diff sized just under
# the diff gate would build a prompt exceeding the prompt gate, so every review
# would block fail-closed with no operator-visible cause, on exactly the
# small-window providers this derivation exists to support. Scaling by
# _default/425000 preserves that gap at every window size. The 425000 denominator
# must track the prompt-cap default passed by the caller below; if that default
# changes, change the denominator with it or the diff-cap proportion breaks.
review_effective_cap() {
    local _override="$1" _default="$2"
    # Operator wins outright, at any value, over both the default and the window.
    if [ -n "$_override" ]; then
        printf '%s' "$_override"
        return 0
    fi
    # Fail safe to the historical default unless the window is a clean positive
    # integer. A non-numeric result here would make the caller's `[ ... -gt ... ]`
    # exit 2, which reads as false and would dispatch an oversized review.
    case "${PROVIDER_CONTEXT_WINDOW:-}" in
        ''|*[!0-9]*) printf '%s' "$_default"; return 0 ;;
    esac
    [ "$PROVIDER_CONTEXT_WINDOW" -gt 0 ] 2>/dev/null || { printf '%s' "$_default"; return 0; }
    # Derive the INPUT budget, then scale it to this caller's cap so the
    # prompt/diff proportion (and thus the scaffolding gap) is preserved.
    local _budget=$(( PROVIDER_CONTEXT_WINDOW * 3 / 4 * 3 ))
    local _derived=$(( _budget * _default / 425000 ))
    if [ "$_derived" -lt "$_default" ]; then
        printf '%s' "$_derived"
    else
        printf '%s' "$_default"
    fi
}

run_code_review() {
    local loki_dir="${TARGET_DIR:-.}/.loki"
    local review_dir="$loki_dir/quality/reviews"
    local review_id
    if [ "${_LOKI_REVIEW_RETRYING:-0}" != "1" ]; then
        _LOKI_REVIEW_FAILURE_KIND=""
    fi
    review_id="review-$(date -u +%Y%m%dT%H%M%SZ)-${ITERATION_COUNT:-0}"
    mkdir -p "$review_dir/$review_id"

    # Get diff from last commit (staged changes).
    #
    # Finding #596 (HIGH): exclude .loki/ and .git/ from the review diff via git
    # pathspec. When .loki/ is git-tracked the diff bloats (observed 2.18MB of
    # runtime state), the reviewer prompt overflows, the model returns EMPTY, and
    # every reviewer records NO_OUTPUT -> the gate passes with ZERO real review.
    # The evidence gate already excludes .loki/ (see the grep -vE near the
    # porcelain read); mirror that here so the code-review gate is never defeated
    # by Loki's own state. Behavior is identical when .loki/ is untracked (the
    # common case) because git ignores the exclude pathspec for paths it does not
    # track. ':(exclude).git/' is harmless (git never diffs .git/) and is kept
    # only for parity with the evidence-gate exclusion list.
    # Finding #596 + Plan #16: exclude .loki/, .git/, AND the standard dependency
    # / build noise dirs. The A-2 temp-index `git add -A` below stages EVERY
    # untracked file, and a fresh greenfield workspace has no root .gitignore yet
    # (only .loki/.gitignore, scoped to .loki/). Without these excludes a build
    # that ran `npm install` / `pip install` before writing a .gitignore would
    # stage node_modules/ (or .venv/, dist/, build/) into the temp index, bloat
    # the review diff to multi-MB, overflow the reviewer prompt, force NO_OUTPUT,
    # and defeat the gate -- exactly the Finding #596 class. The diff pathspec
    # filters the OUTPUT regardless of what the temp index staged, so this one
    # list fixes both diff_content and changed_files. Mirrors the metrics-path
    # noise set (run.sh:12592-12598) plus __pycache__ and vendor.
    local _review_pathspec=(-- . \
        ':(exclude).loki/' ':(exclude).git/' ':(exclude)**/.loki/**' \
        ':(exclude)node_modules/' ':(exclude)**/node_modules/**' \
        ':(exclude)venv/' ':(exclude).venv/' ':(exclude)**/venv/**' ':(exclude)**/.venv/**' \
        ':(exclude)dist/' ':(exclude)build/' ':(exclude)**/dist/**' ':(exclude)**/build/**' \
        ':(exclude)__pycache__/' ':(exclude)**/__pycache__/**' \
        ':(exclude)vendor/' ':(exclude)**/vendor/**')

    # Lockfiles are machine-generated dependency graphs. Their raw patches are
    # high-token and low-signal for general code reviewers, while the manifests
    # and every source change remain in the normal function-context diff. Use
    # native Git pathspecs to omit only known JavaScript lockfiles, then provide
    # compact Git and package-manager metadata below.
    local _review_lock_pathspec=(
        ':(exclude)package-lock.json' ':(glob,exclude)**/package-lock.json'
        ':(exclude)npm-shrinkwrap.json' ':(glob,exclude)**/npm-shrinkwrap.json'
        ':(exclude)yarn.lock' ':(glob,exclude)**/yarn.lock'
        ':(exclude)pnpm-lock.yaml' ':(glob,exclude)**/pnpm-lock.yaml'
        ':(exclude)bun.lock' ':(glob,exclude)**/bun.lock'
        ':(exclude)bun.lockb' ':(glob,exclude)**/bun.lockb'
    )

    # Client fix (code_review NO_OUTPUT on oversized diffs): the hardcoded excludes
    # above miss dirs that are git-TRACKED but listed in the target repo's
    # .gitignore -- e.g. a dir committed before the ignore rule was added. The
    # `git add -A` temp index below stages those tracked files, so an 11MB
    # tracked-but-ignored dir bloats the review diff, overflows the reviewer
    # prompt, and every reviewer returns NO_OUTPUT. Filter them out DYNAMICALLY:
    # list the tracked files the repo's own .gitignore would ignore (check-ignore
    # --no-index evaluates the rules even for already-tracked paths) and exclude
    # each at the EXACT depth of its containing directory -- NOT a collapsed
    # top-level prefix. Excluding the top-level prefix would drop sibling REAL
    # changes (e.g. ignoring loki-ts/dist/ must NOT exclude all of loki-ts/), and
    # because the empty-diff guard below reuses this same pathspec, that
    # over-exclusion could blind the guard and let a real change PASS unreviewed
    # (council-caught fail-closed hole). Nested paths are pruned to the shallowest
    # ignored dir so a/b and a/b/c collapse to a/b (one exclude, still precise).
    # Capped so a pathological repo cannot build a giant argv. Opt out
    # LOKI_REVIEW_GITIGNORE_FILTER=0. No-op (byte-identical diff) when nothing is
    # tracked-but-ignored.
    if [ "${LOKI_REVIEW_GITIGNORE_FILTER:-1}" != "0" ]; then
        local _gi_cap="${LOKI_REVIEW_GITIGNORE_MAX_EXCLUDES:-200}"
        local _gi_dirs _gi_count=0 _gi_d _gi_prev=""
        # tracked-and-ignored files -> the DIRNAME of each (exact depth), sorted so
        # a parent dir sorts before its children for the nested-prune below. A file
        # ignored at repo root (dirname ".") is excluded by its own exact path, not
        # a directory.
        _gi_dirs="$( (cd "${TARGET_DIR:-.}" && \
            git ls-files -z 2>/dev/null | git check-ignore --no-index --stdin -z 2>/dev/null) \
            | tr '\0' '\n' | grep -v '^$' \
            | while IFS= read -r _f; do d="$(dirname "$_f")"; [ "$d" = "." ] && printf '%s\n' "$_f" || printf '%s\n' "$d"; done \
            | sort -u || true )"
        if [ -n "$_gi_dirs" ]; then
            while IFS= read -r _gi_d; do
                [ -n "$_gi_d" ] || continue
                # nested-prune: if this path is under the previously-kept dir, skip
                # it (the parent exclude already covers it). Relies on the sort so a
                # parent precedes its children.
                if [ -n "$_gi_prev" ] && case "$_gi_d/" in "$_gi_prev"/*) true ;; *) false ;; esac; then
                    continue
                fi
                if [ "$_gi_count" -ge "$_gi_cap" ]; then
                    log_warn "Code review: gitignore-exclude cap ($_gi_cap) reached; some tracked-but-ignored paths remain in the diff. Raise LOKI_REVIEW_GITIGNORE_MAX_EXCLUDES or 'git rm --cached' them."
                    break
                fi
                # Exact-path exclude (a leading-path pathspec matches that path and
                # everything under it; it does NOT match siblings). No '**/' variant
                # -- that would re-introduce the over-broad match this fix removes.
                # A trailing '/' anchors a DIRECTORY; a root-level ignored FILE
                # (dirname was ".", so _gi_d is the file itself) must be excluded by
                # its EXACT path with no '/', else the pathspec matches nothing and
                # the ignored file leaks back into the diff (council note: safe
                # direction, but this makes it exact). Decide by what is on disk.
                if [ -d "${TARGET_DIR:-.}/${_gi_d}" ]; then
                    _review_pathspec+=(":(exclude)${_gi_d}/")
                else
                    _review_pathspec+=(":(exclude)${_gi_d}")
                fi
                _gi_prev="$_gi_d"
                _gi_count=$((_gi_count + 1))
            done <<< "$_gi_dirs"
            [ "$_gi_count" -gt 0 ] && log_info "Code review: excluded $_gi_count tracked-but-gitignored dir(s) from the review diff (e.g. $(printf '%s' "$_gi_dirs" | head -3 | tr '\n' ' '))"
        fi
    fi

    # Plan #16 (A-2): make the review diff base robust to shallow/fresh history,
    # and surface NEW (untracked) files -- the whole greenfield build is new
    # files, which `git diff <base>` (tracked-only) never shows.
    #
    # Base preference, mirroring the proven metrics-path fallback chain:
    #   1. ${_LOKI_RUN_START_SHA} when it resolves to a commit -- the correct
    #      per-run baseline (captured at run start; also what the summary/"Review
    #      the work" command uses). After A-1, a fresh workspace has exactly one
    #      commit at iteration 1, so HEAD~1 does NOT resolve -- the start-SHA is
    #      what makes the gate run on iteration 1.
    #   2. HEAD~1 when it resolves (the live engine-source case, deep history).
    #   3. the git empty-tree object (computed, never a hardcoded SHA-1 constant,
    #      so it survives a SHA-256 repo) so a single-commit repo still yields a
    #      real diff against an empty baseline instead of an empty string.
    local _review_base=""
    if [ -n "${_LOKI_RUN_START_SHA:-}" ] && \
       git -C "${TARGET_DIR:-.}" rev-parse --verify --quiet "${_LOKI_RUN_START_SHA}^{commit}" >/dev/null 2>&1; then
        _review_base="${_LOKI_RUN_START_SHA}"
    elif git -C "${TARGET_DIR:-.}" rev-parse --verify --quiet 'HEAD~1^{commit}' >/dev/null 2>&1; then
        _review_base="HEAD~1"
    else
        _review_base="$(git -C "${TARGET_DIR:-.}" hash-object -t tree /dev/null 2>/dev/null || echo "")"
    fi

    # Build the review diff against a THROWAWAY index (GIT_INDEX_FILE points at a
    # fresh, nonexistent path) so a `git add -A` captures the full working tree --
    # new + modified files alike -- WITHOUT touching the real index. This avoids
    # disturbing any other porcelain consumer (the evidence hard gate's status
    # read, create_checkpoint's `git stash create`, commit_session_changes). The
    # `.loki/.gitignore` (`*`) keeps runtime state out; the pathspec is a belt-
    # and-suspenders exclude. `diff --cached <base>` then compares that staged
    # snapshot to the baseline -- a real unified diff the reviewer can read.
    local diff_content=""
    local changed_files=""
    local dependency_context=""
    if [ -n "$_review_base" ]; then
        local _rev_idx
        _rev_idx="$(mktemp -u "${TMPDIR:-/tmp}/loki-revidx.XXXXXX")"
        GIT_INDEX_FILE="$_rev_idx" git -C "${TARGET_DIR:-.}" add -A 2>/dev/null || true
        # Native Git function context includes unchanged implementation around
        # each source edit. Machine-generated lockfile patches are replaced by
        # the bounded dependency metadata generated below.
        local _review_source_pathspec=("${_review_pathspec[@]}" "${_review_lock_pathspec[@]}")
        diff_content=$(GIT_INDEX_FILE="$_rev_idx" git -C "${TARGET_DIR:-.}" diff --cached --function-context "$_review_base" "${_review_source_pathspec[@]}" 2>/dev/null || echo "")
        changed_files=$(GIT_INDEX_FILE="$_rev_idx" git -C "${TARGET_DIR:-.}" diff --cached --name-only "$_review_base" "${_review_pathspec[@]}" 2>/dev/null || echo "")

        # Dependency review keeps exact file identity and package-manager
        # resolution evidence without repeating a generated lockfile patch in
        # every reviewer prompt. Git reads both snapshots from the throwaway
        # index, and `npm ls --package-lock-only` inspects the resolved top-level
        # graph without running lifecycle scripts or contacting the registry.
        local _dependency_files_file="$review_dir/$review_id/dependency-files.txt"
        printf '%s\n' "$changed_files" | awk -F/ '
            $NF == "package.json" || $NF == "package-lock.json" ||
            $NF == "npm-shrinkwrap.json" || $NF == "yarn.lock" ||
            $NF == "pnpm-lock.yaml" || $NF == "bun.lock" ||
            $NF == "bun.lockb" { print }
        ' > "$_dependency_files_file"
        if [ -s "$_dependency_files_file" ]; then
            if ! dependency_context="$(
                GIT_INDEX_FILE="$_rev_idx" python3 - \
                    "${TARGET_DIR:-.}" "$_review_base" "$_dependency_files_file" <<'DEPENDENCY_CONTEXT'
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys

root = Path(sys.argv[1]).resolve()
base = sys.argv[2]
paths = [line for line in Path(sys.argv[3]).read_text(encoding="utf-8").splitlines() if line]
lock_names = {
    "package-lock.json", "npm-shrinkwrap.json", "yarn.lock",
    "pnpm-lock.yaml", "bun.lock", "bun.lockb",
}


def git_bytes(*args):
    result = subprocess.run(
        ["git", "-C", str(root), *args],
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
        check=False,
    )
    return result.returncode, result.stdout


def blob(ref, path):
    code, content = git_bytes("show", f"{ref}:{path}")
    return content if code == 0 else None


def json_summary(content, basename):
    if content is None:
        return None
    result = {
        "bytes": len(content),
        "sha256": hashlib.sha256(content).hexdigest(),
    }
    if basename not in {"package.json", "package-lock.json", "npm-shrinkwrap.json"}:
        return result
    try:
        value = json.loads(content)
    except (UnicodeDecodeError, json.JSONDecodeError):
        result["json"] = "invalid"
        return result
    if not isinstance(value, dict):
        result["json"] = "non_object"
        return result
    if basename == "package.json":
        for key in (
            "name", "version", "packageManager", "engines", "dependencies",
            "devDependencies", "optionalDependencies", "peerDependencies",
        ):
            if key in value:
                result[key] = value[key]
    else:
        result["lockfileVersion"] = value.get("lockfileVersion")
        packages = value.get("packages")
        result["package_entries"] = len(packages) if isinstance(packages, dict) else None
        root_package = packages.get("") if isinstance(packages, dict) else None
        if isinstance(root_package, dict):
            for key in ("dependencies", "devDependencies", "optionalDependencies"):
                if key in root_package:
                    result[f"root_{key}"] = root_package[key]
    return result


files = []
npm_dirs = set()
for path in paths:
    basename = Path(path).name
    code, status_raw = git_bytes("diff", "--cached", "--name-status", base, "--", path)
    status_text = status_raw.decode("utf-8", "replace").strip()
    status = status_text.split("\t", 1)[0] if code == 0 and status_text else "unknown"
    code, numstat_raw = git_bytes("diff", "--cached", "--numstat", base, "--", path)
    numstat = numstat_raw.decode("utf-8", "replace").strip().split("\t")
    added = None if not numstat or numstat[0] == "-" else int(numstat[0])
    deleted = None if len(numstat) < 2 or numstat[1] == "-" else int(numstat[1])
    files.append({
        "path": path,
        "kind": "lockfile" if basename in lock_names else "manifest",
        "status": status,
        "added_lines": added,
        "deleted_lines": deleted,
        "before": json_summary(blob(base, path), basename),
        "after": json_summary(blob("", path), basename),
    })
    if basename in {"package.json", "package-lock.json", "npm-shrinkwrap.json"}:
        npm_dirs.add(str(Path(path).parent))

npm_results = []
npm = shutil.which("npm")
for relative_dir in sorted(npm_dirs):
    directory = (root / relative_dir).resolve()
    if root != directory and root not in directory.parents:
        raise SystemExit(f"dependency path escapes workspace: {relative_dir}")
    if npm is None or not (directory / "package.json").is_file():
        npm_results.append({"directory": relative_dir or ".", "status": "not_available"})
        continue
    command = [npm, "ls", "--package-lock-only", "--json", "--depth=0", "--ignore-scripts"]
    try:
        completed = subprocess.run(
            command,
            cwd=directory,
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            timeout=10,
            check=False,
            env={**os.environ, "NO_UPDATE_NOTIFIER": "1"},
        )
        parsed = json.loads(completed.stdout or b"{}")
        dependencies = parsed.get("dependencies", {}) if isinstance(parsed, dict) else {}
        npm_results.append({
            "directory": relative_dir or ".",
            "command": "npm ls --package-lock-only --json --depth=0 --ignore-scripts",
            "exit_code": completed.returncode,
            "name": parsed.get("name") if isinstance(parsed, dict) else None,
            "version": parsed.get("version") if isinstance(parsed, dict) else None,
            "problems": parsed.get("problems", []) if isinstance(parsed, dict) else [],
            "resolved_top_level": {
                name: details.get("version") if isinstance(details, dict) else None
                for name, details in dependencies.items()
            } if isinstance(dependencies, dict) else {},
        })
    except (json.JSONDecodeError, OSError, subprocess.TimeoutExpired) as exc:
        npm_results.append({
            "directory": relative_dir or ".",
            "status": "inspection_failed",
            "reason": type(exc).__name__,
        })

print(json.dumps({
    "schema_version": 1,
    "raw_lockfile_patches_included": False,
    "files": files,
    "npm_resolution": npm_results,
}, sort_keys=True, separators=(",", ":"), ensure_ascii=True))
DEPENDENCY_CONTEXT
            )"; then
                rm -f "$_rev_idx" 2>/dev/null || true
                log_error "Code review: dependency metadata generation failed; refusing to omit lockfile evidence"
                return 1
            fi
            local _dependency_context_bytes _dependency_context_max
            _dependency_context_bytes=$(printf '%s' "$dependency_context" | wc -c | tr -d ' ')
            _dependency_context_max="${LOKI_REVIEW_MAX_DEPENDENCY_BYTES:-64000}"
            if [ "${_dependency_context_bytes:-0}" -gt "$_dependency_context_max" ] 2>/dev/null; then
                printf '%s\n' "$dependency_context" > "$review_dir/$review_id/dependency-context.json"
                rm -f "$_rev_idx" 2>/dev/null || true
                log_error "Code review: dependency metadata is ${_dependency_context_bytes} bytes (limit ${_dependency_context_max}); refusing to truncate or dispatch a partial review"
                emit_event_json "code_review_dependency_context_oversized" \
                    "review_id=$review_id" \
                    "context_bytes=$_dependency_context_bytes" \
                    "limit_bytes=$_dependency_context_max" \
                    "iteration=${ITERATION_COUNT:-0}" 2>/dev/null || true
                return 1
            fi
            printf '%s\n' "$dependency_context" > "$review_dir/$review_id/dependency-context.json"
            diff_content="${diff_content}

Dependency change metadata follows. Raw lockfile patches are intentionally omitted. The metadata comes from the exact Git index snapshots and npm's package-lock resolver:
${dependency_context}"
        fi
        rm -f "$_rev_idx" 2>/dev/null || true
    fi

    # Persist the exact context before any size gate so a rejected review remains
    # inspectable. `diff.txt` contains every source patch plus compact dependency
    # metadata, but never a raw generated lockfile patch.
    local diff_file="$review_dir/$review_id/diff.txt"
    local files_file="$review_dir/$review_id/files.txt"
    printf '%s\n' "$diff_content" > "$diff_file"
    printf '%s\n' "$changed_files" > "$files_file"

    # Reject oversized context before dispatch. Sending it anyway converts a
    # deterministic local limit into model NO_OUTPUT, wastes four calls, and
    # produces an opaque block. This remains fail-closed and never truncates.
    local _review_diff_bytes=0
    _review_diff_bytes=$(printf '%s' "$diff_content" | wc -c | tr -d ' ')
    local _review_max_bytes
    _review_max_bytes=$(review_effective_cap "${LOKI_REVIEW_MAX_DIFF_BYTES:-}" 400000)
    if [ "${_review_diff_bytes:-0}" -gt "$_review_max_bytes" ] 2>/dev/null; then
        local _big_dirs
        _big_dirs=$(printf '%s\n' "$changed_files" | sed 's#/.*##' | grep -v '^$' | sort | uniq -c | sort -rn | head -3 | awk '{print $2" ("$1" files)"}' | tr '\n' ' ')
        log_error "Code review: context is ${_review_diff_bytes} bytes (limit ${_review_max_bytes}, derived from PROVIDER_CONTEXT_WINDOW=${PROVIDER_CONTEXT_WINDOW:-unset}); refusing to truncate or dispatch a partial review. Biggest dirs: ${_big_dirs:-unknown}. Split the change or raise LOKI_REVIEW_MAX_DIFF_BYTES."
        emit_event_json "code_review_diff_oversized" \
            "review_id=$review_id" \
            "diff_bytes=$_review_diff_bytes" \
            "limit_bytes=$_review_max_bytes" \
            "iteration=${ITERATION_COUNT:-0}" 2>/dev/null || true
        # Classify this as INFRASTRUCTURE, not a finding. The discriminator is
        # otherwise set at :14912, which this early return never reaches, so the
        # variable stayed "" from the reset at :12959 and the consumer at :22126
        # read a file-size condition as "Critical/High findings" -- escalating to
        # PAUSE over a diff that was merely too large to send.
        #
        # Still fail-closed: the review did NOT pass, and nothing here converts a
        # skipped review into a green one. It only records WHY it could not run,
        # which is the difference between "your code is bad" and "we could not
        # look at it".
        _LOKI_REVIEW_FAILURE_KIND="infrastructure_inconclusive"
        return 1
    fi

    if [ -z "$diff_content" ]; then
        # Honesty (Finding #596 class): a silent PASS on an empty diff is a trust
        # gap ONLY when the run actually produced changes we failed to diff.
        # Distinguish a genuine no-op iteration (nothing changed -> legitimate
        # PASS) from "changes exist but we could not compute a diff" (must not
        # pass silently). Use the same .loki/.git-excluding porcelain the
        # evidence gate uses to detect real changes independent of the diff base.
        local _dirty
        _dirty=$(git -C "${TARGET_DIR:-.}" status --porcelain "${_review_pathspec[@]}" 2>/dev/null | head -1 || echo "")
        if [ -n "$_dirty" ] || [ -n "$changed_files" ]; then
            # Return non-zero so the gate dispatcher records a failure (it calls
            # track_gate_failure on the else branch). Do NOT call track_gate_failure
            # here: it echoes its count to stdout (callers capture it) and the
            # dispatcher would double-count.
            log_warn "Code review: workspace has changes but the review diff is empty (could not compute a diff base); NOT passing the gate silently"
            return 1
        fi
        log_info "Code review: no changes this iteration, skipping (genuine no-op)"
        return 0
    fi

    # Hosted builds already run from the supervisor's immutable spec snapshot.
    # Give one dedicated requirements reviewer that exact input. Refuse an
    # absent or oversized snapshot instead of reviewing implementation alone and
    # calling omitted acceptance criteria complete.
    local requirements_file=""
    local requirements_manifest_file=""
    local requirements_schema_file=""
    local requirements_helper="$SCRIPT_DIR/lib/requirements_contract.py"
    local requirements_contract_identity=""
    local requirements_shard_dir=""
    local requirements_shard_count=0
    local requirements_shard_size="${LOKI_REVIEW_REQUIREMENTS_SHARD_SIZE:-8}"
    local requirements_shard_metadata=""
    local requirements_shard_identities_json="[]"
    local requirements_enabled=0
    if loki_is_supervised_simple_web; then
        local requirements_source="${PRD_PATH:-}"
        local requirements_expected_sha="${LOKI_SPEC_SHA256:-}"
        local requirements_bytes=0
        local requirements_max_bytes_raw="${LOKI_REVIEW_MAX_REQUIREMENTS_BYTES:-64000}"
        local requirements_hard_max_bytes=1048576
        local requirements_snapshot_result=""
        local requirements_snapshot_rc=0
        if [ -z "$requirements_source" ] || [ ! -f "$requirements_source" ] || [ ! -r "$requirements_source" ]; then
            log_error "Code review: supervised requirements snapshot is missing or unreadable"
            return 1
        fi
        if ! [[ "$requirements_expected_sha" =~ ^[0-9a-f]{64}$ ]]; then
            log_error "Code review: supervised requirements snapshot has no valid start-time SHA-256 binding"
            return 1
        fi
        requirements_file="$review_dir/$review_id/requirements.txt"
        requirements_manifest_file="$review_dir/$review_id/requirements-manifest.json"
        requirements_schema_file="$review_dir/$review_id/requirements-verdict-schema.json"
        requirements_snapshot_result=$(python3 \
            "$requirements_helper" \
            "$requirements_source" "$requirements_file" \
            "$requirements_expected_sha" "$requirements_max_bytes_raw" \
            "$requirements_hard_max_bytes" "$requirements_manifest_file" \
            "$requirements_schema_file") || requirements_snapshot_rc=$?
        case "$requirements_snapshot_rc" in
            0)
                requirements_bytes="${requirements_snapshot_result#ok:}"
                ;;
            2)
                log_error "Code review: LOKI_REVIEW_MAX_REQUIREMENTS_BYTES must be an integer from 1 to ${requirements_hard_max_bytes}"
                return 1
                ;;
            4)
                log_error "Code review: requirements snapshot exceeds the configured ${requirements_max_bytes_raw}-byte limit; refusing a partial requirements review"
                return 1
                ;;
            5)
                log_error "Code review: supervised requirements snapshot changed after build start; SHA-256 binding mismatch"
                return 1
                ;;
            *)
                log_error "Code review: could not verify and preserve the supervised requirements snapshot"
                return 1
                ;;
        esac
        if [ -z "$requirements_bytes" ]; then
            log_error "Code review: verified requirements snapshot has no size metadata"
            return 1
        fi
        requirements_contract_identity=$(python3 "$requirements_helper" bind \
            "$requirements_source" "$requirements_file" \
            "$requirements_expected_sha" "$requirements_max_bytes_raw" \
            "$requirements_hard_max_bytes" "$requirements_manifest_file" \
            "$requirements_schema_file" 2>/dev/null) || {
            log_error "Code review: could not bind requirements contract files"
            return 1
        }
        if ! [[ "$requirements_contract_identity" =~ ^[0-9]+:[0-9]+,[0-9]+:[0-9]+,[0-9]+:[0-9]+$ ]]; then
            log_error "Code review: requirements contract has no valid file identity binding"
            return 1
        fi
        case "$requirements_shard_size" in
            ''|*[!0-9]*)
                log_error "Code review: requirements shard size must be an integer from 1 to 8"
                return 1
                ;;
        esac
        if [ "$requirements_shard_size" -lt 1 ] 2>/dev/null \
           || [ "$requirements_shard_size" -gt 8 ] 2>/dev/null; then
            log_error "Code review: requirements shard size must be an integer from 1 to 8"
            return 1
        fi
        requirements_shard_dir="$review_dir/$review_id/requirements-shards"
        if ! mkdir -m 700 "$requirements_shard_dir" 2>/dev/null; then
            log_error "Code review: could not create immutable requirements shards"
            return 1
        fi
        requirements_shard_metadata=$(python3 "$requirements_helper" write-shards \
            "$requirements_source" "$requirements_file" \
            "$requirements_expected_sha" "$requirements_max_bytes_raw" \
            "$requirements_hard_max_bytes" "$requirements_manifest_file" \
            "$requirements_schema_file" "$requirements_contract_identity" \
            "$requirements_shard_size" "$requirements_shard_dir" 2>/dev/null) || {
            log_error "Code review: could not derive immutable requirements shards"
            return 1
        }
        requirements_shard_count=$(_LOKI_REQUIREMENTS_SHARD_METADATA="$requirements_shard_metadata" python3 -c '
import json, os
record = json.loads(os.environ["_LOKI_REQUIREMENTS_SHARD_METADATA"])
assert record["schema"] == "loki-requirements-shards/v1"
assert isinstance(record["count"], int) and record["count"] > 0
assert len(record["sizes"]) == record["count"]
assert all(isinstance(size, int) and 1 <= size <= 8 for size in record["sizes"])
assert sum(record["sizes"]) > 0
assert len(record["identities"]) == record["count"]
print(record["count"])
') || {
            log_error "Code review: requirements shard metadata is malformed"
            return 1
        }
        requirements_shard_identities_json=$(_LOKI_REQUIREMENTS_SHARD_METADATA="$requirements_shard_metadata" python3 -c '
import json, os, re
record = json.loads(os.environ["_LOKI_REQUIREMENTS_SHARD_METADATA"])
assert all(re.fullmatch(r"[0-9]+:[0-9]+,[0-9]+:[0-9]+", item) for item in record["identities"])
print(json.dumps(record["identities"], separators=(",", ":")))
') || {
            log_error "Code review: requirements shard identity binding is malformed"
            return 1
        }
        requirements_enabled=1
    fi

    log_header "CODE REVIEW: $review_id"

    # Phase 3 (v7.0.0): managed code-review council. When the flag is on,
    # route to providers/managed.py::run_council. On ManagedUnavailable,
    # emit a fallback event and drop through to the legacy CLI fan-out
    # below -- the existing v6.83.1 behavior is preserved.
    if [ "${LOKI_EXPERIMENTAL_MANAGED_REVIEW:-false}" = "true" ]; then
        local managed_diff_file="$review_dir/$review_id/diff.txt"
        local managed_files_file="$review_dir/$review_id/files.txt"
        printf '%s\n' "$diff_content" > "$managed_diff_file"
        printf '%s\n' "$changed_files" > "$managed_files_file"
        log_info "Managed review council: attempting multiagent session (Phase 3)"
        if _run_managed_review_council "$review_id" "$managed_diff_file" "$managed_files_file"; then
            log_info "Managed review council: verdicts written, skipping CLI fan-out"
            # Managed path wrote legacy .txt files; skip CLI fan-out but let
            # the aggregation step run by setting a minimal selection.json
            # the downstream loop can read.
            emit_event_json "code_review_complete" \
                "review_id=$review_id" \
                "source=managed" \
                "iteration=${ITERATION_COUNT:-0}"
            # Build a selection.json so any downstream consumer can find the
            # reviewer list. Mirrors the shape the CLI path writes below.
            python3 - "$review_dir/$review_id/selection.json" << 'MANAGED_SELECTION'
import json
import sys

path = sys.argv[1]
selection = {
    "reviewers": [
        {"name": "security-sentinel", "focus": "managed", "checks": "managed council"},
        {"name": "test-coverage-auditor", "focus": "managed", "checks": "managed council"},
        {"name": "performance-oracle", "focus": "managed", "checks": "managed council"},
    ],
    "scores": {},
    "pool_size": 3,
    "source": "managed",
}
with open(path, "w", encoding="utf-8") as f:
    json.dump(selection, f)
MANAGED_SELECTION
            return 0
        fi
        log_warn "Managed review council unavailable; falling back to CLI fan-out"
    fi

    if [ "${LOKI_REVIEW_REQUIREMENTS_ONLY:-0}" = "1" ]; then
        log_info "Selecting the single foreground requirements verifier for the MVP completion path..."
    else
        log_info "Selecting reviewers (architecture-strategist + maintainer-mergeability always on, plus keyword-scored specialists)..."
    fi

    # Select specialists via keyword scoring (python3 reads files, not env vars)
    # Loads from agents/types.json when available, falls back to hardcoded pool (v6.7.0)
    # v7.4.20: gate legacy-healing-auditor on healing-mode signals to match
    # the documented contract in skills/quality-gates.md (conditional backward-compat auditor, not one of the 8 numbered gates).
    local healing_active="false"
    if [ "${LOKI_HEAL_MODE:-}" = "true" ] || [ "${LOKI_HEAL_MODE:-}" = "1" ]; then
        healing_active="true"
    elif [ -f "${PROJECT_DIR}/.loki/healing/friction-map.json" ]; then
        healing_active="true"
    fi
    export LOKI_REVIEW_HEALING_ACTIVE="$healing_active"
    export LOKI_REVIEW_DIFF_FILE="$diff_file"
    export LOKI_REVIEW_FILES_FILE="$files_file"
    export LOKI_AGENTS_TYPES_FILE="${PROJECT_DIR}/agents/types.json"
    # Complexity-proportional verification (rec #3): scale the number of
    # keyword-selected specialists to the detected task tier. HARD FLOOR: every
    # tier keeps the 2 mandatory reviewers + 2 specialists (= today's behavior),
    # so simple/standard are byte-identical to prior releases and nothing
    # shippable ever gets LESS scrutiny. Only `complex` ADDS specialists (deeper
    # battery for hard tasks). Never drops below the floor.
    export LOKI_REVIEW_COMPLEXITY="${DETECTED_COMPLEXITY:-standard}"
    export LOKI_REVIEW_REQUIREMENTS_ENABLED="$requirements_enabled"
    local selected_specialists
    selected_specialists=$(python3 << 'SPECIALIST_SELECT'
import os
import json

# Hardcoded specialists (always available as fallback)
SPECIALISTS = {
    "security-sentinel": {
        "keywords": ["auth", "login", "password", "token", "api", "sql", "query", "cookie", "cors", "csrf"],
        "focus": "OWASP Top 10, injection, auth, secrets, input validation",
        "checks": "injection (SQL, XSS, command, template), auth bypass, secrets in code, missing input validation, OWASP Top 10, insecure defaults",
        "priority": 0
    },
    "test-coverage-auditor": {
        "keywords": ["test", "spec", "coverage", "assert", "mock", "fixture", "expect", "describe"],
        "focus": "Missing tests, edge cases, error paths, boundary conditions",
        "checks": "missing test cases, uncovered error paths, boundary conditions, mock correctness, test isolation, flaky test patterns",
        "priority": 1
    },
    "performance-oracle": {
        "keywords": ["database", "query", "cache", "render", "loop", "fetch", "load", "index", "join", "pool"],
        "focus": "N+1 queries, memory leaks, caching, bundle size, lazy loading",
        "checks": "N+1 queries, unbounded loops, memory leaks, missing caching, excessive re-renders, large bundle imports, missing pagination",
        "priority": 2
    },
    "dependency-analyst": {
        "keywords": ["package", "import", "require", "dependency", "npm", "pip", "yarn", "lock"],
        "focus": "Outdated packages, CVEs, bloat, unused deps, license issues",
        "checks": "outdated dependencies, known CVEs, unnecessary imports, dependency bloat, license compatibility, unused packages",
        "priority": 3
    },
    "legacy-healing-auditor": {
        "keywords": ["legacy", "heal", "migrate", "cobol", "fortran", "refactor", "modernize", "deprecat", "adapter", "friction", "characterization"],
        "focus": "Behavioral preservation, friction safety, institutional knowledge retention",
        "checks": "behavioral change without characterization test, removal of quirky code without friction map check, missing adapter layer for replaced components, institutional knowledge loss (deleted comments, removed error messages), breaking changes to undocumented APIs",
        "priority": 4
    }
}

# Load additional specialists from agents/types.json (v6.7.0)
types_file = os.environ.get("LOKI_AGENTS_TYPES_FILE", "")
if types_file and os.path.exists(types_file):
    try:
        with open(types_file) as f:
            agent_types = json.load(f)
        FOCUS_KEYWORDS = {
            "ops-security": ["auth", "security", "vuln", "cve", "injection", "xss", "csrf", "encrypt", "secret", "permission"],
            "eng-qa": ["test", "spec", "coverage", "assert", "mock", "fixture", "expect", "describe", "e2e", "unit"],
            "eng-perf": ["perf", "cache", "query", "slow", "memory", "leak", "optimize", "bundle", "load", "latency"],
            "eng-database": ["database", "sql", "query", "migration", "index", "join", "schema", "postgres", "mongo"],
            "eng-frontend": ["react", "vue", "css", "html", "component", "render", "dom", "accessibility", "responsive"],
            "eng-backend": ["api", "endpoint", "middleware", "route", "controller", "service", "auth", "validation"],
            "eng-infra": ["docker", "k8s", "kubernetes", "deploy", "ci", "cd", "pipeline", "terraform", "helm"],
            "review-code": ["refactor", "pattern", "solid", "coupling", "abstraction", "class", "function", "module"],
            "review-security": ["auth", "login", "password", "token", "secret", "inject", "xss", "cors", "permission", "encrypt"],
            "review-business": ["logic", "workflow", "business", "rule", "validation", "price", "payment", "order"],
        }
        for agent in agent_types:
            agent_type = agent.get("type", "")
            if agent_type in FOCUS_KEYWORDS and agent_type not in SPECIALISTS:
                SPECIALISTS[agent_type] = {
                    "keywords": FOCUS_KEYWORDS[agent_type],
                    "focus": agent.get("capabilities", ""),
                    "checks": "Review from " + agent.get("name", agent_type) + " perspective: " + ", ".join(agent.get("focus", [])),
                    "priority": len(SPECIALISTS),
                    "persona": agent.get("persona", "")
                }
    except Exception:
        pass  # Fall back to hardcoded specialists

# R10 extension seam: agents installed by the user via `loki agent install`
# (.loki/agents/installed.json) join the reviewer pool. Built-ins above are
# gated on a hardcoded FOCUS_KEYWORDS allowlist, which no user-chosen type can
# ever match, so an installed agent was silently dropped and its persona never
# reached a reviewer. Keywords come from the manifest's own `focus` list, which
# hub_install.py already validates as <= 200-char strings.
# Data only: hub_install.py never executes anything from a manifest.
# Kept in a SEPARATE dict, never merged into SPECIALISTS: entering the built-in
# pool would let a user agent win a `ranked[:want]` slot and DISPLACE a built-in
# reviewer (observed displacing security-sentinel before this was split out),
# and would also flip the all-zero defaults path.
INSTALLED_SPECIALISTS = {}
try:
    import importlib.util as _ilu
    _hub_path = os.path.join(os.path.dirname(os.path.abspath(types_file)), "hub_install.py")
    _spec = _ilu.spec_from_file_location("loki_hub_install", _hub_path)
    _hub = _ilu.module_from_spec(_spec)
    _spec.loader.exec_module(_hub)
    for _inst in _hub.installed_agent_list():
        _t = _inst.get("type", "")
        # Never let an installed agent shadow a built-in reviewer perspective.
        if not _t or _t in SPECIALISTS:
            continue
        _kw = [str(k).strip().lower() for k in _inst.get("focus", []) if str(k).strip()]
        if not _kw:
            continue  # No keywords means it could never score; skip rather than always-on.
        INSTALLED_SPECIALISTS[_t] = {
            "keywords": _kw,
            "focus": _inst.get("capabilities", "") or _inst.get("name", _t),
            "checks": "Review from " + _inst.get("name", _t) + " perspective: " + ", ".join(_inst.get("focus", [])),
            "priority": 100 + len(INSTALLED_SPECIALISTS),
            "persona": _inst.get("persona", ""),
        }
except Exception:
    pass  # Corrupt or absent installed.json must never break code review.

diff_path = os.environ.get("LOKI_REVIEW_DIFF_FILE", "")
files_path = os.environ.get("LOKI_REVIEW_FILES_FILE", "")

diff_text = ""
files_text = ""
if diff_path and os.path.exists(diff_path):
    with open(diff_path, "r") as f:
        diff_text = f.read().lower()
if files_path and os.path.exists(files_path):
    with open(files_path, "r") as f:
        files_text = f.read().lower()

search_text = diff_text + " " + files_text

# v7.4.20: gate legacy-healing-auditor on healing-mode signals to match
# skills/quality-gates.md (conditional backward-compat auditor, not one of the 8 numbered gates) which documents it as conditional. The
# auditor BLOCKs on missing characterization tests / missing adapters, which
# is a contract a greenfield project never agreed to maintain. agentbudget
# regression: the auditor pinned 9 of 10 iterations to forced PAUSE because
# common tokens like "refactor"/"adapter" landed it in the keyword pool.
healing_active = os.environ.get("LOKI_REVIEW_HEALING_ACTIVE", "false") == "true"
if not healing_active and "legacy-healing-auditor" in SPECIALISTS:
    del SPECIALISTS["legacy-healing-auditor"]

# Score each specialist by keyword matches
scores = {}
for name, spec in SPECIALISTS.items():
    score = sum(1 for kw in spec["keywords"] if kw in search_text)
    scores[name] = score

# Sort by score descending, then by priority ascending (tie-breaker)
ranked = sorted(scores.keys(), key=lambda n: (-scores[n], SPECIALISTS[n]["priority"]))

# Complexity-proportional specialist count (rec #3). HARD FLOOR of 2 keyword
# specialists on every tier -- combined with the 2 always-on mandatory reviewers
# below, that is the 4-reviewer battery every prior release shipped, so simple
# and standard are byte-identical. Only `complex` ADDS specialists (up to 4) for
# a deeper battery on hard tasks; it never drops below the floor. Capped at the
# available pool so a small pool cannot request more than exist.
complexity = os.environ.get("LOKI_REVIEW_COMPLEXITY", "standard").strip().lower()
SPECIALIST_COUNT_BY_TIER = {"simple": 2, "standard": 2, "complex": 4}
want = SPECIALIST_COUNT_BY_TIER.get(complexity, 2)
if want < 2:
    want = 2  # floor guard: an unknown/forced tier can never shrink the battery

# If no keywords matched at all, use defaults (still honoring the tier count:
# pad the two hardcoded defaults with the next-ranked specialists for `complex`).
if all(s == 0 for s in scores.values()):
    base_defaults = ["security-sentinel", "test-coverage-auditor"]
    selected = list(base_defaults)
    if want > len(selected):
        for n in ranked:
            if n not in selected:
                selected.append(n)
            if len(selected) >= want:
                break
else:
    selected = ranked[:want]

# User-installed agents are APPENDED, never allowed to compete for the `want`
# built-in slots -- same discipline as the dependency-analyst append below, so
# installing an agent can only ADD scrutiny, never remove a built-in reviewer.
# Only those whose keywords actually matched this diff fire, so an installed
# a11y auditor stays silent on a backend-only change.
# ponytail: hard cap of 2, no env var. Each appended agent costs one more LLM
# reviewer call every iteration. Raise the constant if that ceiling bites.
_MAX_INSTALLED_REVIEWERS = 2
installed_selected = []
for _n, _spec in INSTALLED_SPECIALISTS.items():
    scores[_n] = sum(1 for kw in _spec["keywords"] if kw in search_text)
for _n in sorted(INSTALLED_SPECIALISTS, key=lambda n: (-scores[n], INSTALLED_SPECIALISTS[n]["priority"])):
    if len(installed_selected) >= _MAX_INSTALLED_REVIEWERS:
        break
    if scores[_n] > 0:
        installed_selected.append(_n)

# A changed JavaScript manifest or lockfile always receives the specialist that
# understands the compact Git/npm metadata. Append rather than replace so a
# dependency change never removes another keyword-selected review perspective.
dependency_names = {
    "package.json", "package-lock.json", "npm-shrinkwrap.json", "yarn.lock",
    "pnpm-lock.yaml", "bun.lock", "bun.lockb",
}
dependency_changed = any(
    path.rsplit("/", 1)[-1] in dependency_names
    for path in files_text.splitlines()
)
if dependency_changed and "dependency-analyst" in SPECIALISTS and "dependency-analyst" not in selected:
    selected.append("dependency-analyst")

# Output JSON: architecture-strategist + maintainer-mergeability always first
# (both carry a mandate no keyword-selected specialist does), then the selected
# specialists. Dependency changes may add dependency-analyst to the tier floor.
# 7.114.0 (rank 9): maintainer-mergeability is the "would a real maintainer merge
# this PR" reviewer. It covers scope creep, dead/duplicated code, and conformance
# to the surrounding code's conventions -- the tech-lead axes the security/test/
# perf/dependency/architecture pool misses. Its findings feed the SAME
# Critical/High=block, Medium/Low=non-blocking mechanism and additionally the
# weighted quality score in aggregate.json (rank 9).
mandatory = [
        {
            "name": "architecture-strategist",
            "focus": "SOLID, coupling, cohesion, patterns, abstraction, dependency direction",
            "checks": "SOLID violations, excessive coupling, wrong patterns, missing abstractions, dependency direction issues, god classes/functions"
        },
        {
            "name": "maintainer-mergeability",
            "focus": "Would a maintainer merge this PR as-is: scope discipline, dead/duplicated code, convention conformance",
            "checks": "scope creep (changes unrelated to the stated task, drive-by edits, unrequested refactors), dead code (unreachable, unused, commented-out, leftover debug), duplicated logic that should reuse an existing helper, non-conformance to the surrounding code's conventions (naming, error handling, structure, formatting), unsupported factual or commercial claims in user-facing output, and anything a careful human reviewer would ask to be changed before merging"
        }
]
if os.environ.get("LOKI_REVIEW_REQUIREMENTS_ENABLED") == "1":
    mandatory.insert(0, {
        "name": "requirements-verifier",
        "focus": "Exact compliance with every explicit user requirement and acceptance criterion",
        "checks": "missing requested content, values, interactions, states, workflows, constraints, or disclosures; behavior that contradicts the immutable user specification; placeholders presented as completed requirements",
    })

reviewers = mandatory + [
        {
            "name": name,
            "focus": SPECIALISTS[name]["focus"],
            "checks": SPECIALISTS[name]["checks"]
        }
        for name in selected
    ] + [
        {
            "name": name,
            "focus": INSTALLED_SPECIALISTS[name]["focus"],
            "checks": INSTALLED_SPECIALISTS[name]["checks"]
        }
        for name in installed_selected
    ]
# TOTAL council cap (LOKI_REVIEW_MAX_REVIEWERS). The tier map sizes the
# SPECIALIST slots ({simple:2, standard:2, complex:4}), but installed agents and
# the dependency-analyst append AFTER that sizing, so nothing bounded the total.
# Measured consequence, from real code_review_start/complete pairs:
#
#     3 reviewers ->  31s        6 reviewers -> 177s
#     7 reviewers -> 280s        7 reviewers -> 502s
#
# Dispatch is already concurrent, so this superlinearity is the max-of-N tail
# plus contention on one provider -- a scoped issue was drawing a 7-member
# council (including two overlapping security reviewers) and paying 9-16x the
# 3-member wall clock for it.
#
# TRIMMING ORDER IS A SAFETY PROPERTY. Mandatory reviewers
# (requirements-verifier, architecture-strategist, maintainer-mergeability) are
# NEVER dropped: each carries a mandate no keyword-selected specialist has, and
# shrinking a council must never be able to manufacture an approval. Only the
# appended tail (installed agents, then keyword specialists beyond the floor) is
# trimmed, and the cap can never cut below the mandatory set.
#
# Default 0 = uncapped, preserving today's behaviour exactly. This is a knob to
# be turned on deliberately per route, not a silent change to every council.
try:
    _cap = int(os.environ.get("LOKI_REVIEW_MAX_REVIEWERS", "0") or "0")
except ValueError:
    _cap = 0
if _cap > 0 and len(reviewers) > _cap:
    _mandatory_names = {r["name"] for r in mandatory}
    _keep = [r for r in reviewers if r["name"] in _mandatory_names]
    for _r in reviewers:
        if len(_keep) >= _cap:
            break
        if _r["name"] not in _mandatory_names:
            _keep.append(_r)
    # Never below the mandatory set, even if the cap is set lower than it.
    reviewers = _keep if len(_keep) >= len(_mandatory_names) else reviewers

if os.environ.get("LOKI_REVIEW_REQUIREMENTS_ONLY") == "1":
    reviewers = [
        reviewer for reviewer in reviewers
        if reviewer["name"] == "requirements-verifier"
    ]

result = {
    "reviewers": reviewers,
    "scores": {n: scores[n] for n in scores},
    "pool_size": len(SPECIALISTS) + len(installed_selected)
}
print(json.dumps(result))
SPECIALIST_SELECT
    )
    unset LOKI_REVIEW_DIFF_FILE LOKI_REVIEW_FILES_FILE LOKI_AGENTS_TYPES_FILE LOKI_REVIEW_HEALING_ACTIVE LOKI_REVIEW_COMPLEXITY LOKI_REVIEW_REQUIREMENTS_ENABLED

    if [ -z "$selected_specialists" ]; then
        log_error "Code review: Specialist selection failed"
        return 1
    fi

    # Requirements shards are physical dispatches under one logical reviewer.
    # Keep selection and aggregation cardinality unchanged so shards can never
    # manufacture extra votes.
    local dispatch_specialists="$selected_specialists"
    local selection_record="$selected_specialists"
    dispatch_specialists=$(_LOKI_REVIEW_SELECTION="$selected_specialists" \
        _LOKI_REVIEW_SHARD_COUNT="$requirements_shard_count" python3 - <<'REVIEW_DISPATCH_EXPAND'
import copy
import json
import os

selection = json.loads(os.environ["_LOKI_REVIEW_SELECTION"])
shard_count = int(os.environ["_LOKI_REVIEW_SHARD_COUNT"])
dispatches = []
for logical_index, reviewer in enumerate(selection["reviewers"]):
    if reviewer["name"] == "requirements-verifier":
        for shard_index in range(1, shard_count + 1):
            physical = copy.deepcopy(reviewer)
            physical.update({
                "name": f"requirements-verifier-shard-{shard_index:03d}",
                "logical_name": "requirements-verifier",
                "logical_index": logical_index,
                "shard_index": shard_index,
            })
            dispatches.append(physical)
    else:
        physical = copy.deepcopy(reviewer)
        physical.update({
            "logical_name": reviewer["name"],
            "logical_index": logical_index,
            "shard_index": 0,
        })
        dispatches.append(physical)
print(json.dumps({"reviewers": dispatches}, separators=(",", ":")))
REVIEW_DISPATCH_EXPAND
    ) || {
        log_error "Code review: could not create requirements dispatch wave"
        return 1
    }
    if [ "$requirements_enabled" -eq 1 ]; then
        selection_record=$(_LOKI_REVIEW_SELECTION="$selected_specialists" \
            _LOKI_REVIEW_SHARD_METADATA="$requirements_shard_metadata" python3 - <<'REVIEW_SELECTION_RECORD'
import json
import os

selection = json.loads(os.environ["_LOKI_REVIEW_SELECTION"])
selection["requirements_sharding"] = json.loads(
    os.environ["_LOKI_REVIEW_SHARD_METADATA"]
)
print(json.dumps(selection, sort_keys=True))
REVIEW_SELECTION_RECORD
        ) || return 1
    fi

    # Save logical selection metadata. Physical dispatch metadata is retained
    # separately for auditability.
    printf '%s\n' "$selection_record" > "$review_dir/$review_id/selection.json"
    printf '%s\n' "$dispatch_specialists" > "$review_dir/$review_id/dispatch.json"

    # Extract reviewer names for logging
    local reviewer_names
    reviewer_names=$(echo "$selected_specialists" | python3 -c "import sys,json; d=json.load(sys.stdin); print(', '.join(r['name'] for r in d['reviewers']))")
    log_info "Selected reviewers: $reviewer_names"

    emit_event_json "code_review_start" \
        "review_id=$review_id" \
        "reviewers=$reviewer_names" \
        "iteration=$ITERATION_COUNT"

    # Dispatch all selected reviewers as parallel blind reviews (provider-specific
    # invocation). Count is dynamic: 2 always-on (architecture-strategist,
    # maintainer-mergeability), tier-selected specialists, and dependency-analyst
    # when a dependency file changed.
    local pids=()
    local dispatch_rc=()
    local dispatch_publication_ok=()
    local dispatch_publication_bindings=()
    local dispatch_stage_files=()
    local dispatch_logical_indices=()
    local dispatch_shard_indices=()
    local dispatch_names=()
    local dispatch_cancelled=()
    local dispatch_control_files=()
    local reviewer_dispatch_rc=()
    local reviewer_publication_ok=()
    local reviewer_count
    reviewer_count=$(echo "$selected_specialists" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['reviewers']))")
    local dispatch_count
    dispatch_count=$(echo "$dispatch_specialists" | python3 -c "import sys,json; print(len(json.load(sys.stdin)['reviewers']))")
    local _review_max_prompt_bytes
    _review_max_prompt_bytes=$(review_effective_cap "${LOKI_REVIEW_MAX_PROMPT_BYTES:-}" 425000)
    local _review_max_output_bytes="${LOKI_REVIEW_MAX_OUTPUT_BYTES:-1048576}"
    local review_pending_dir="$review_dir/$review_id/.pending"
    if ! mkdir -m 700 "$review_pending_dir" 2>/dev/null; then
        log_error "Code review: could not create the staged-result directory"
        return 1
    fi
    local da_output="$review_dir/$review_id/devils-advocate.txt"
    local da_stage_output="$review_pending_dir/devils-advocate.txt"
    local da_prompt_file="$review_dir/$review_id/devils-advocate-prompt.txt"
    local da_pid="" da_dispatch_rc=0
    local da_publication_ok=false
    local da_speculative=false
    local da_inconclusive=false da_status="not_run"
    local review_wave_started_ms=0 review_wave_ended_ms=0
    local prompt_bindings=()

    # Build and size-check every prompt before dispatching any reviewer. This
    # prevents a pathological reviewer definition or file list from creating a
    # partial council where early reviewers ran and a later prompt was rejected.
    for i in $(seq 0 $((dispatch_count - 1))); do
        local reviewer_name reviewer_logical_name reviewer_focus reviewer_checks
        local reviewer_logical_index reviewer_shard_index
        reviewer_name=$(echo "$dispatch_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['name'])")
        reviewer_logical_name=$(echo "$dispatch_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['logical_name'])")
        reviewer_logical_index=$(echo "$dispatch_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['logical_index'])")
        reviewer_shard_index=$(echo "$dispatch_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['shard_index'])")
        reviewer_focus=$(echo "$dispatch_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['focus'])")
        reviewer_checks=$(echo "$dispatch_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['checks'])")
        dispatch_names+=("$reviewer_name")
        dispatch_logical_indices+=("$reviewer_logical_index")
        dispatch_shard_indices+=("$reviewer_shard_index")

        # Build prompt via python to avoid shell quoting issues with diff content
        local review_prompt_file="$review_dir/$review_id/${reviewer_name}-prompt.txt"
        export LOKI_REVIEW_PROMPT_NAME="$reviewer_logical_name"
        export LOKI_REVIEW_PROMPT_FOCUS="$reviewer_focus"
        export LOKI_REVIEW_PROMPT_CHECKS="$reviewer_checks"
        export LOKI_REVIEW_PROMPT_DIFF_FILE="$diff_file"
        export LOKI_REVIEW_PROMPT_FILES_FILE="$files_file"
        export LOKI_REVIEW_PROMPT_TESTS_FILE="$loki_dir/quality/test-results.json"
        export LOKI_REVIEW_PROMPT_BUILD_FILE="$loki_dir/quality/build-results.json"
        local requirements_prompt_bundle=""
        if [ "$reviewer_logical_name" = "requirements-verifier" ]; then
            local reviewer_shard_identity
            reviewer_shard_identity=$(_LOKI_REVIEW_SHARD_IDENTITIES="$requirements_shard_identities_json" \
                _LOKI_REVIEW_SHARD_INDEX="$reviewer_shard_index" python3 -c '
import json, os
items = json.loads(os.environ["_LOKI_REVIEW_SHARD_IDENTITIES"])
print(items[int(os.environ["_LOKI_REVIEW_SHARD_INDEX"]) - 1])
') || return 1
            requirements_prompt_bundle=$(python3 "$requirements_helper" verify-shard \
                "$requirements_source" "$requirements_file" \
                "$requirements_expected_sha" "$requirements_max_bytes_raw" \
                "$requirements_hard_max_bytes" "$requirements_manifest_file" \
                "$requirements_schema_file" "$requirements_contract_identity" \
                "$requirements_shard_size" "$requirements_shard_dir" \
                "$reviewer_shard_index" prompt "$reviewer_shard_identity" \
                2>/dev/null) || {
                log_error "Code review: requirements contract changed before prompt construction"
                return 1
            }
        fi
        export LOKI_REVIEW_PROMPT_REQUIREMENTS_BUNDLE="$requirements_prompt_bundle"
        export LOKI_REVIEW_PROMPT_HELPER="$requirements_helper"
        export LOKI_REVIEW_PROMPT_OUT="$review_prompt_file"
        local built_prompt_sha=""
        built_prompt_sha=$(python3 << 'BUILD_PROMPT'
import os
import json
import hashlib
import importlib.util
import sys
from pathlib import Path

name = os.environ["LOKI_REVIEW_PROMPT_NAME"]
focus = os.environ["LOKI_REVIEW_PROMPT_FOCUS"]
checks = os.environ["LOKI_REVIEW_PROMPT_CHECKS"]

with open(os.environ["LOKI_REVIEW_PROMPT_FILES_FILE"], "r") as f:
    files = f.read().strip()
with open(os.environ["LOKI_REVIEW_PROMPT_DIFF_FILE"], "r") as f:
    diff = f.read().strip()

def compact_evidence(path, keys):
    try:
        with open(path, "r", encoding="utf-8") as handle:
            value = json.load(handle)
    except Exception:
        return {"status": "not_recorded"}
    if not isinstance(value, dict):
        return {"status": "invalid"}
    return {key: value.get(key) for key in keys if key in value}

tests = compact_evidence(
    os.environ["LOKI_REVIEW_PROMPT_TESTS_FILE"],
    ("status", "pass", "runner", "command", "exit_code", "passed_count", "failed_count", "summary"),
)
build = compact_evidence(
    os.environ["LOKI_REVIEW_PROMPT_BUILD_FILE"],
    ("status", "ran", "command", "exit_code", "applicable", "summary"),
)

requirements = ""
requirements_contract = None
requirements_verdict_schema = None
if name == "requirements-verifier":
    try:
        bundle = json.loads(os.environ["LOKI_REVIEW_PROMPT_REQUIREMENTS_BUNDLE"])
        requirements_contract = bundle["manifest"]
        requirements_verdict_schema = bundle["schema"]
        requirement_items = requirements_contract["requirements"]
        if not isinstance(requirement_items, list) or not all(
            isinstance(item, dict) and isinstance(item.get("text"), str)
            for item in requirement_items
        ):
            raise TypeError("invalid requirements")
        requirements = "\n".join(item["text"] for item in requirement_items)
    except (KeyError, TypeError, json.JSONDecodeError):
        raise SystemExit("requirements-verifier has no immutable specification")
    if (
        not isinstance(requirements, str)
        or not requirements
        or not isinstance(requirements_contract, dict)
        or not isinstance(requirements_verdict_schema, dict)
    ):
        raise SystemExit("requirements-verifier has no immutable specification")

requirements_section = ""
if requirements:
    requirements_section = f"""
Immutable user specification:
<user_specification>
{requirements}
</user_specification>

Every explicit requested element is an acceptance criterion. A missing requested
element is a real defect, even when tests and production build pass.
The machine contract below assigns every criterion a stable ID. Return exactly
one result for every ID in the given order. Mark an ID PASS only when all behavior
described by that criterion is supported by concrete source or recorded evidence.
Any uncertainty is FAIL.
<requirements_contract>
{json.dumps(requirements_contract, sort_keys=True, ensure_ascii=True)}
</requirements_contract>

Return one raw JSON object matching this exact verdict schema. Do not add prose
or markdown fences.
<requirements_verdict_schema>
{json.dumps(requirements_verdict_schema, sort_keys=True, ensure_ascii=True)}
</requirements_verdict_schema>

This is a bounded verdict, not an essay. Do not plan, explain, restate, quote, or
summarize the specification, source, or schema. Return the JSON immediately.
Keep each evidence value to one short file, line, or recorded-test citation with
at most 180 characters. Keep each finding description under 320 characters.
"""

if requirements_contract:
    output_format = """Output only the JSON object required by the supplied schema.
Set schema to loki-requirements-verdict/v1 and copy spec_sha256 exactly.
PASS is valid only when every requirements entry is PASS and findings is [].
Any finding requires VERDICT FAIL."""
    review_context_note = """The source patch and recorded checks below are the complete review evidence."""
    severity_calibration = """Decision rules:
- Review only explicit acceptance criteria. Do not suggest improvements.
- Trust a recorded check with exit code 0 unless the supplied source directly contradicts it.
- FAIL only a missing or contradicted criterion and cite the shortest concrete evidence.
- Do not invent findings."""
else:
    output_format = """Output format (STRICT - follow exactly):
VERDICT: PASS or FAIL
FINDINGS:
- [severity] description (file:line)
Severity levels: Critical, High, Medium, Low

If no issues found, output:
VERDICT: PASS
FINDINGS:
- None"""
    review_context_note = """The review context contains every source patch. Raw generated lockfile patches
are replaced with exact Git snapshot metadata and npm package-lock resolution
metadata. This saves tokens without treating dependency changes as unreviewed."""
    severity_calibration = """Severity calibration:
- Critical or High requires a concrete defect with plausible impact to correctness, security, privacy, data integrity, availability, accessibility, or an explicit acceptance criterion.
- Missing an exact-copy, styling, placeholder, or implementation-detail assertion is not Critical or High by itself. Prefer behavior and requirement tests over brittle content snapshots.
- An intentionally removed element is not a defect unless its removal violates a requirement or causes a demonstrated regression.
- Unsupported factual, customer, pricing, performance, availability, legal, or integration claims in production-facing output are High.
- A partial diff cannot prove unchanged code is absent. Make an absence claim only when the current function context demonstrates absence.
- If recorded tests passed with exit code 0, do not claim they will fail unless you cite a reproduced failing command and output.
- Unrequested behavior such as resetting a form, disabling a button, or preserving a message is not a requirement.
- An ordinary missing test is Medium or Low unless it is tied to a cited changed high-impact trust boundary or explicit acceptance criterion.
- Do not invent findings to satisfy the requested focus."""

prompt = f"""You are {name}. Your SOLE focus is: {focus}.

Review ONLY for: {checks}.

Files changed:
{files}

Review context:
{diff}
{requirements_section}

{review_context_note}

Recorded deterministic evidence before review:
Tests: {json.dumps(tests, sort_keys=True, ensure_ascii=True)}
Build: {json.dumps(build, sort_keys=True, ensure_ascii=True)}

{severity_calibration}

{output_format}"""

prompt_bytes = prompt.encode("utf-8")
helper_path = os.environ["LOKI_REVIEW_PROMPT_HELPER"]
helper_spec = importlib.util.spec_from_file_location(
    "loki_requirements_contract_prompt", helper_path
)
if helper_spec is None or helper_spec.loader is None:
    raise SystemExit("requirements contract helper is unavailable")
helper = importlib.util.module_from_spec(helper_spec)
sys.modules[helper_spec.name] = helper
helper_spec.loader.exec_module(helper)
helper._write_new(Path(os.environ["LOKI_REVIEW_PROMPT_OUT"]), prompt_bytes)
print(
    hashlib.sha256(prompt_bytes).hexdigest()
    + "|"
    + helper._path_identity(Path(os.environ["LOKI_REVIEW_PROMPT_OUT"]))
)
BUILD_PROMPT
        ) || {
            log_error "Code review: could not bind reviewer prompt"
            return 1
        }
        if ! [[ "$built_prompt_sha" =~ ^[0-9a-f]{64}\|[0-9]+:[0-9]+$ ]]; then
            log_error "Code review: reviewer prompt has no valid content binding"
            return 1
        fi
        prompt_bindings+=("$built_prompt_sha")
        unset LOKI_REVIEW_PROMPT_NAME LOKI_REVIEW_PROMPT_FOCUS LOKI_REVIEW_PROMPT_CHECKS
        unset LOKI_REVIEW_PROMPT_DIFF_FILE LOKI_REVIEW_PROMPT_FILES_FILE
        unset LOKI_REVIEW_PROMPT_TESTS_FILE LOKI_REVIEW_PROMPT_BUILD_FILE
        unset LOKI_REVIEW_PROMPT_REQUIREMENTS_BUNDLE LOKI_REVIEW_PROMPT_HELPER
        unset LOKI_REVIEW_PROMPT_OUT

        # Client fix (log the actual prompt/diff size per reviewer): so a
        # NO_OUTPUT post-mortem is a one-line log read, not repo archaeology.
        # Record the prompt byte size (and the shared diff size) both to the log
        # and to a per-review sizes.json for the dashboard/telemetry.
        local _prompt_bytes=0
        [ -f "$review_prompt_file" ] && _prompt_bytes=$(wc -c < "$review_prompt_file" 2>/dev/null | tr -d ' ')
        log_info "Reviewer $reviewer_name: prompt ${_prompt_bytes:-0} bytes (review context ${_review_diff_bytes:-0} bytes)"
        printf '%s\t%s\n' "$reviewer_name" "${_prompt_bytes:-0}" >> "$review_dir/$review_id/sizes.tsv" 2>/dev/null || true
        if [ "${_prompt_bytes:-0}" -gt "$_review_max_prompt_bytes" ] 2>/dev/null; then
            log_error "Code review: $reviewer_name prompt is ${_prompt_bytes} bytes (limit ${_review_max_prompt_bytes}); refusing to truncate or dispatch a partial council"
            emit_event_json "code_review_prompt_oversized" \
                "review_id=$review_id" \
                "reviewer=$reviewer_name" \
                "prompt_bytes=$_prompt_bytes" \
                "limit_bytes=$_review_max_prompt_bytes" \
                "iteration=${ITERATION_COUNT:-0}" 2>/dev/null || true
            return 1
        fi
    done

    # The supervised simple-web policy has a strict latency SLO and already
    # isolates every reviewer. Start the anti-sycophancy reviewer with the blind
    # council, then consume its result only if the council is unanimous. Other
    # profiles retain the historical on-demand serial dispatch.
    review_wave_started_ms=$(python3 -c 'import time; print(time.monotonic_ns() // 1000000)' 2>/dev/null) || review_wave_started_ms=0
    if loki_is_supervised_simple_web \
       && [ "${LOKI_GATE_DEVILS_ADVOCATE:-true}" = "true" ]; then
        if ! _write_devils_advocate_prompt "$diff_file" "$files_file" "$da_prompt_file"; then
            log_error "Code review: could not construct the Devil's Advocate prompt"
            return 1
        fi
        : > "$da_stage_output"
        chmod 600 "$da_stage_output" 2>/dev/null || return 1
        (
            local da_prompt_text
            da_prompt_text=$(cat "$da_prompt_file")
            LOKI_REVIEW_STDERR_FILE="$review_dir/$review_id/devils-advocate-stderr.log" \
                _dispatch_reviewer_recorded "$da_prompt_text" "$da_stage_output"
        ) &
        da_pid=$!
        da_speculative=true
        register_pid "$da_pid" "code-reviewer" "name=devils-advocate"
    fi

    for i in $(seq 0 $((dispatch_count - 1))); do
        local reviewer_name="${dispatch_names[$i]}"
        local reviewer_logical_index="${dispatch_logical_indices[$i]}"
        local reviewer_shard_index="${dispatch_shard_indices[$i]}"
        local reviewer_logical_name
        reviewer_logical_name=$(echo "$dispatch_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['logical_name'])")
        local review_stage_file="$review_pending_dir/${i}-${reviewer_name}.txt"
        local review_prompt_file="$review_dir/$review_id/${reviewer_name}-prompt.txt"

        log_step "Dispatching reviewer: $reviewer_name"
        : > "$review_stage_file"
        chmod 600 "$review_stage_file" 2>/dev/null || return 1
        dispatch_stage_files+=("$review_stage_file")
        dispatch_publication_ok+=(false)
        dispatch_publication_bindings+=("")
        dispatch_cancelled+=(false)
        dispatch_control_files+=("$review_pending_dir/deadline-${i}.json")

        # Launch the complete physical wave before waiting on any result.
        (
            local prompt_text prompt_binding prompt_sha prompt_identity
            prompt_binding="${prompt_bindings[$i]}"
            prompt_sha="${prompt_binding%%|*}"
            prompt_identity="${prompt_binding#*|}"
            prompt_text=$(python3 "$requirements_helper" read-bound \
                "$review_prompt_file" "$prompt_sha" \
                "$_review_max_prompt_bytes" "$prompt_identity" 2>/dev/null) \
                || exit 125
            if [ "$reviewer_logical_name" = "requirements-verifier" ]; then
                local shard_label shard_identity
                printf -v shard_label '%03d' "$reviewer_shard_index"
                shard_identity=$(_LOKI_REVIEW_SHARD_IDENTITIES="$requirements_shard_identities_json" \
                    _LOKI_REVIEW_SHARD_INDEX="$reviewer_shard_index" python3 -c '
import json, os
print(json.loads(os.environ["_LOKI_REVIEW_SHARD_IDENTITIES"])[int(os.environ["_LOKI_REVIEW_SHARD_INDEX"]) - 1])
') || exit 125
                LOKI_REVIEW_REQUIREMENTS_HELPER="$requirements_helper" \
                LOKI_REVIEW_REQUIREMENTS_SOURCE="$requirements_source" \
                LOKI_REVIEW_REQUIREMENTS_SNAPSHOT="$requirements_file" \
                LOKI_REVIEW_REQUIREMENTS_MANIFEST="$requirements_shard_dir/manifest-${shard_label}.json" \
                LOKI_REVIEW_REQUIREMENTS_SCHEMA="$requirements_shard_dir/schema-${shard_label}.json" \
                LOKI_REVIEW_REQUIREMENTS_EXPECTED_SHA="$requirements_expected_sha" \
                LOKI_REVIEW_REQUIREMENTS_MAX_BYTES="$requirements_max_bytes_raw" \
                LOKI_REVIEW_REQUIREMENTS_HARD_MAX_BYTES="$requirements_hard_max_bytes" \
                LOKI_REVIEW_REQUIREMENTS_IDENTITY="$shard_identity" \
                LOKI_REVIEW_REQUIREMENTS_FULL_MANIFEST="$requirements_manifest_file" \
                LOKI_REVIEW_REQUIREMENTS_FULL_SCHEMA="$requirements_schema_file" \
                LOKI_REVIEW_REQUIREMENTS_FULL_IDENTITY="$requirements_contract_identity" \
                LOKI_REVIEW_REQUIREMENTS_SHARD_SIZE="$requirements_shard_size" \
                LOKI_REVIEW_REQUIREMENTS_SHARD_DIR="$requirements_shard_dir" \
                LOKI_REVIEW_REQUIREMENTS_SHARD_INDEX="$reviewer_shard_index" \
                LOKI_DEADLINE_CONTROL_FILE="${dispatch_control_files[$i]}" \
                LOKI_REVIEW_STDERR_FILE="$review_dir/$review_id/${reviewer_name}-stderr.log" \
                    _dispatch_reviewer_recorded "$prompt_text" "$review_stage_file"
                local shard_rc=$?
                if [ "$shard_rc" -eq 0 ] \
                   && grep -qx 'VERDICT: FAIL' "$review_stage_file"; then
                    printf '%s\n' "$reviewer_shard_index" \
                        > "$review_pending_dir/requirements-fail-${reviewer_shard_index}.ready"
                fi
                exit "$shard_rc"
            else
                LOKI_REVIEW_STDERR_FILE="$review_dir/$review_id/${reviewer_name}-stderr.log" \
                    _dispatch_reviewer_recorded "$prompt_text" "$review_stage_file"
            fi
        ) &
        pids+=($!)
        register_pid "$!" "code-reviewer" "name=$reviewer_name"
    done

    # A valid semantic FAIL makes the other requirement checks irrelevant. The
    # child marker is emitted only after strict rematerialization succeeds. A
    # forged marker can at worst force a safe block, never a PASS.
    local requirements_early_fail_shard=0
    if [ "$requirements_shard_count" -gt 1 ] 2>/dev/null; then
        while [ "$requirements_early_fail_shard" -eq 0 ]; do
            local requirements_shards_running=false
            for i in $(seq 0 $((dispatch_count - 1))); do
                if [ "${dispatch_shard_indices[$i]}" -gt 0 ]; then
                    local fail_marker="$review_pending_dir/requirements-fail-${dispatch_shard_indices[$i]}.ready"
                    if [ -f "$fail_marker" ] && [ ! -L "$fail_marker" ]; then
                        requirements_early_fail_shard="${dispatch_shard_indices[$i]}"
                        break
                    fi
                    if kill -0 "${pids[$i]}" 2>/dev/null; then
                        requirements_shards_running=true
                    fi
                fi
            done
            [ "$requirements_early_fail_shard" -gt 0 ] && break
            [ "$requirements_shards_running" = "true" ] || break
            sleep 0.02
        done
        if [ "$requirements_early_fail_shard" -gt 0 ]; then
            for i in $(seq 0 $((dispatch_count - 1))); do
                if [ "${dispatch_shard_indices[$i]}" -gt 0 ] \
                   && [ "${dispatch_shard_indices[$i]}" -ne "$requirements_early_fail_shard" ] \
                   && kill -0 "${pids[$i]}" 2>/dev/null; then
                    dispatch_cancelled[$i]=true
                    local cancel_attempt=0 cancel_rc=125
                    while [ "$cancel_attempt" -lt 100 ]; do
                        if [ -f "${dispatch_control_files[$i]}" ] \
                           && [ ! -L "${dispatch_control_files[$i]}" ]; then
                            python3 "$SCRIPT_DIR/lib/deadline.py" cancel \
                                "${dispatch_control_files[$i]}" \
                                >/dev/null 2>&1 && cancel_rc=0
                            break
                        fi
                        kill -0 "${pids[$i]}" 2>/dev/null || {
                            cancel_rc=0
                            break
                        }
                        sleep 0.01
                        cancel_attempt=$((cancel_attempt + 1))
                    done
                    if [ "$cancel_rc" -ne 0 ]; then
                        log_error "Code review: could not cancel requirements shard ${dispatch_shard_indices[$i]} safely"
                    fi
                fi
            done
        fi
    fi

    # Wait only after the entire physical wave has started, then parent-validate
    # and publish every physical result under its bound dispatch identity.
    log_info "Waiting for $dispatch_count dispatches across $reviewer_count logical reviewers to complete (blind review)..."
    for i in $(seq 0 $((dispatch_count - 1))); do
        local pid="${pids[$i]}"
        local reviewer_rc=0
        local reviewer_name="${dispatch_names[$i]}"
        local reviewer_logical_name
        local reviewer_shard_index="${dispatch_shard_indices[$i]}"
        local review_output shard_label=""
        local review_stage_file="${dispatch_stage_files[$i]}"
        local review_prompt_file="$review_dir/$review_id/${reviewer_name}-prompt.txt"
        local prompt_binding="${prompt_bindings[$i]}"
        local prompt_sha="${prompt_binding%%|*}"
        local prompt_identity="${prompt_binding#*|}"
        reviewer_logical_name=$(echo "$dispatch_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['logical_name'])")
        if [ "$reviewer_logical_name" = "requirements-verifier" ]; then
            printf -v shard_label '%03d' "$reviewer_shard_index"
            review_output="$requirements_shard_dir/result-${shard_label}.txt"
        else
            review_output="$review_dir/$review_id/${reviewer_name}.txt"
        fi
        wait "$pid" || reviewer_rc=$?
        unregister_pid "$pid"
        if [ "$reviewer_rc" -eq 0 ]; then
            python3 "$requirements_helper" read-bound \
                "$review_prompt_file" "$prompt_sha" \
                "$_review_max_prompt_bytes" "$prompt_identity" \
                >/dev/null 2>&1 || reviewer_rc=125
        fi
        if [ "$reviewer_rc" -eq 0 ] \
           && [ "$reviewer_logical_name" = "requirements-verifier" ]; then
            local shard_identity
            shard_identity=$(_LOKI_REVIEW_SHARD_IDENTITIES="$requirements_shard_identities_json" \
                _LOKI_REVIEW_SHARD_INDEX="$reviewer_shard_index" python3 -c '
import json, os
print(json.loads(os.environ["_LOKI_REVIEW_SHARD_IDENTITIES"])[int(os.environ["_LOKI_REVIEW_SHARD_INDEX"]) - 1])
') || reviewer_rc=125
            if [ "$reviewer_rc" -eq 0 ]; then
                python3 "$requirements_helper" verify-shard \
                    "$requirements_source" "$requirements_file" \
                    "$requirements_expected_sha" "$requirements_max_bytes_raw" \
                    "$requirements_hard_max_bytes" "$requirements_manifest_file" \
                    "$requirements_schema_file" "$requirements_contract_identity" \
                    "$requirements_shard_size" "$requirements_shard_dir" \
                    "$reviewer_shard_index" none "$shard_identity" \
                    >/dev/null 2>&1 || reviewer_rc=125
            fi
        fi
        local publication_binding=""
        if [ "$reviewer_rc" -eq 0 ]; then
            publication_binding=$(python3 "$requirements_helper" publish-bound \
                "$review_stage_file" "$review_output" \
                "$_review_max_output_bytes" 2>/dev/null) || reviewer_rc=125
            if ! [[ "$publication_binding" =~ ^[0-9a-f]{64}\|[0-9]+:[0-9]+$ ]]; then
                reviewer_rc=125
            fi
        fi
        dispatch_rc[$i]="$reviewer_rc"
        if [ "$reviewer_rc" -eq 0 ]; then
            dispatch_publication_ok[$i]=true
            dispatch_publication_bindings[$i]="$publication_binding"
        else
            dispatch_publication_ok[$i]=false
            dispatch_publication_bindings[$i]=""
            rm -f "$review_stage_file" 2>/dev/null || true
            if [ ! -d "$review_output" ] || [ -L "$review_output" ]; then
                rm -f "$review_output" 2>/dev/null || true
            fi
        fi
        local stage_timing="${review_stage_file%.txt}-timing.json"
        local review_timing="${review_output%.txt}-timing.json"
        if [ -f "$stage_timing" ] && [ ! -L "$stage_timing" ]; then
            mv -f "$stage_timing" "$review_timing" 2>/dev/null || true
        fi
    done

    # Collapse physical dispatches back to the original logical reviewers. A
    # requirements verdict exists only if every exact shard is present and
    # valid. One shard FAIL yields one logical FAIL; any transport or contract
    # miss yields one logical NO_OUTPUT.
    for i in $(seq 0 $((reviewer_count - 1))); do
        reviewer_dispatch_rc[$i]=125
        reviewer_publication_ok[$i]=false
    done
    for i in $(seq 0 $((dispatch_count - 1))); do
        local logical_index="${dispatch_logical_indices[$i]}"
        if [ "${dispatch_shard_indices[$i]}" -eq 0 ]; then
            reviewer_dispatch_rc[$logical_index]="${dispatch_rc[$i]:-125}"
            reviewer_publication_ok[$logical_index]="${dispatch_publication_ok[$i]:-false}"
        fi
    done
    if [ "$requirements_enabled" -eq 1 ]; then
        local requirements_logical_index=""
        requirements_logical_index=$(echo "$selected_specialists" | python3 -c '
import json, sys
reviewers = json.load(sys.stdin)["reviewers"]
print(next(i for i, item in enumerate(reviewers) if item["name"] == "requirements-verifier"))
') || return 1
        local requirements_logical_rc=0
        local requirements_physical_count=0
        local requirements_result_bindings_lines=""
        local requirements_early_fail_binding=""
        for i in $(seq 0 $((dispatch_count - 1))); do
            if [ "${dispatch_logical_indices[$i]}" = "$requirements_logical_index" ] \
               && [ "${dispatch_shard_indices[$i]}" -gt 0 ]; then
                requirements_physical_count=$((requirements_physical_count + 1))
                if [ "$requirements_early_fail_shard" -gt 0 ]; then
                    if [ "${dispatch_shard_indices[$i]}" -eq "$requirements_early_fail_shard" ] \
                       && [ "${dispatch_rc[$i]:-125}" -eq 0 ] \
                       && [ "${dispatch_publication_ok[$i]:-false}" = "true" ] \
                       && [ -n "${dispatch_publication_bindings[$i]:-}" ]; then
                        requirements_early_fail_binding="${dispatch_publication_bindings[$i]}"
                    fi
                else
                    if [ "${dispatch_rc[$i]:-125}" -eq 124 ]; then
                        requirements_logical_rc=124
                    elif [ "${dispatch_rc[$i]:-125}" -ne 0 ] \
                         || [ "${dispatch_publication_ok[$i]:-false}" != "true" ] \
                         || [ -z "${dispatch_publication_bindings[$i]:-}" ]; then
                        [ "$requirements_logical_rc" -eq 124 ] || requirements_logical_rc=125
                    fi
                fi
                requirements_result_bindings_lines="${requirements_result_bindings_lines}${dispatch_publication_bindings[$i]:-}"$'\n'
            fi
        done
        if [ "$requirements_physical_count" -ne "$requirements_shard_count" ]; then
            requirements_logical_rc=125
        elif [ "$requirements_early_fail_shard" -gt 0 ] \
             && [ -z "$requirements_early_fail_binding" ]; then
            requirements_logical_rc=125
        fi
        local requirements_logical_output="$review_dir/$review_id/requirements-verifier.txt"
        local requirements_logical_stage="$review_pending_dir/logical-requirements-verifier.txt"
        if [ "$requirements_logical_rc" -eq 0 ]; then
            local synthesis_binding=""
            if [ "$requirements_early_fail_shard" -gt 0 ]; then
                local requirements_early_fail_identity=""
                requirements_early_fail_identity=$(_LOKI_REVIEW_SHARD_IDENTITIES="$requirements_shard_identities_json" \
                    _LOKI_REVIEW_SHARD_INDEX="$requirements_early_fail_shard" python3 -c '
import json, os
print(json.loads(os.environ["_LOKI_REVIEW_SHARD_IDENTITIES"])[int(os.environ["_LOKI_REVIEW_SHARD_INDEX"]) - 1])
') || requirements_logical_rc=125
                if [ "$requirements_logical_rc" -eq 0 ]; then
                    synthesis_binding=$(python3 "$requirements_helper" synthesize-fail \
                        "$requirements_source" "$requirements_file" \
                        "$requirements_expected_sha" "$requirements_max_bytes_raw" \
                        "$requirements_hard_max_bytes" "$requirements_manifest_file" \
                        "$requirements_schema_file" "$requirements_contract_identity" \
                        "$requirements_shard_size" "$requirements_shard_dir" \
                        "$requirements_early_fail_shard" \
                        "$requirements_early_fail_identity" \
                        "$requirements_early_fail_binding" \
                        "$_review_max_output_bytes" "$requirements_logical_stage" \
                        2>/dev/null) || requirements_logical_rc=125
                fi
            else
                local requirements_result_bindings_json=""
                requirements_result_bindings_json=$(_LOKI_REVIEW_RESULT_BINDINGS="$requirements_result_bindings_lines" \
                    _LOKI_REVIEW_SHARD_COUNT="$requirements_shard_count" python3 -c '
import json, os
items = os.environ["_LOKI_REVIEW_RESULT_BINDINGS"].splitlines()
assert len(items) == int(os.environ["_LOKI_REVIEW_SHARD_COUNT"])
assert all(items)
print(json.dumps(items, separators=(",", ":")))
') || requirements_logical_rc=125
                if [ "$requirements_logical_rc" -eq 0 ]; then
                    synthesis_binding=$(python3 "$requirements_helper" synthesize-shards \
                        "$requirements_source" "$requirements_file" \
                        "$requirements_expected_sha" "$requirements_max_bytes_raw" \
                        "$requirements_hard_max_bytes" "$requirements_manifest_file" \
                        "$requirements_schema_file" "$requirements_contract_identity" \
                        "$requirements_shard_size" "$requirements_shard_dir" \
                        "$requirements_shard_identities_json" \
                        "$requirements_result_bindings_json" \
                        "$_review_max_output_bytes" "$requirements_logical_stage" \
                        2>/dev/null) || requirements_logical_rc=125
                fi
            fi
            if [ "$requirements_logical_rc" -eq 0 ] \
               && [[ "$synthesis_binding" =~ ^[0-9a-f]{64}\|[0-9]+:[0-9]+$ ]]; then
                local logical_publication_binding=""
                logical_publication_binding=$(python3 "$requirements_helper" publish-bound \
                    "$requirements_logical_stage" "$requirements_logical_output" \
                    "$_review_max_output_bytes" 2>/dev/null) || requirements_logical_rc=125
                [[ "$logical_publication_binding" =~ ^[0-9a-f]{64}\|[0-9]+:[0-9]+$ ]] \
                    || requirements_logical_rc=125
            else
                requirements_logical_rc=125
            fi
        fi
        reviewer_dispatch_rc[$requirements_logical_index]="$requirements_logical_rc"
        if [ "$requirements_logical_rc" -eq 0 ]; then
            reviewer_publication_ok[$requirements_logical_index]=true
        else
            reviewer_publication_ok[$requirements_logical_index]=false
            rm -f "$requirements_logical_stage" 2>/dev/null || true
            if [ ! -d "$requirements_logical_output" ] || [ -L "$requirements_logical_output" ]; then
                rm -f "$requirements_logical_output" 2>/dev/null || true
            fi
        fi
        _LOKI_REQUIREMENTS_TIMING_DIR="$requirements_shard_dir" \
        _LOKI_REQUIREMENTS_TIMING_OUT="$review_dir/$review_id/requirements-verifier-timing.json" \
        _LOKI_REQUIREMENTS_TIMING_COUNT="$requirements_shard_count" \
        _LOKI_REQUIREMENTS_TIMING_RC="$requirements_logical_rc" \
        _LOKI_REQUIREMENTS_TIMING_BUDGET="${LOKI_REVIEW_CALL_TIMEOUT:-0}" python3 - <<'REQUIREMENTS_LOGICAL_TIMING' 2>/dev/null || true
import json
import os
from pathlib import Path

directory = Path(os.environ["_LOKI_REQUIREMENTS_TIMING_DIR"])
count = int(os.environ["_LOKI_REQUIREMENTS_TIMING_COUNT"])
records = []
for index in range(1, count + 1):
    path = directory / f"result-{index:03d}-timing.json"
    try:
        records.append(json.loads(path.read_text(encoding="utf-8")))
    except Exception:
        records.append({"elapsed_ms": 0, "exit_code": 125, "outcome": "error"})
rc = int(os.environ["_LOKI_REQUIREMENTS_TIMING_RC"])
outcome = "output" if rc == 0 else ("deadline" if rc == 124 else "error")
record = {
    "schema": "loki-review-dispatch/v1",
    "budget_seconds": int(os.environ.get("_LOKI_REQUIREMENTS_TIMING_BUDGET", "0") or 0),
    "elapsed_ms": max((int(item.get("elapsed_ms", 0)) for item in records), default=0),
    "exit_code": rc,
    "outcome": outcome,
    "deadline_scope": "parallel_requirements_shards",
    "shard_count": count,
    "shards": records,
}
Path(os.environ["_LOKI_REQUIREMENTS_TIMING_OUT"]).write_text(
    json.dumps(record, sort_keys=True), encoding="utf-8"
)
REQUIREMENTS_LOGICAL_TIMING
    fi
    if [ "$da_speculative" = "true" ]; then
        wait "$da_pid" || da_dispatch_rc=$?
        unregister_pid "$da_pid"
        if [ "$da_dispatch_rc" -eq 0 ]; then
            local da_publication_binding=""
            da_publication_binding=$(python3 "$requirements_helper" publish-bound \
                "$da_stage_output" "$da_output" \
                "$_review_max_output_bytes" 2>/dev/null) || da_dispatch_rc=125
            if [[ "$da_publication_binding" =~ ^[0-9a-f]{64}\|[0-9]+:[0-9]+$ ]]; then
                da_publication_ok=true
                da_status="ready"
            else
                da_dispatch_rc=125
            fi
        fi
        if [ "$da_dispatch_rc" -ne 0 ]; then
            da_publication_ok=false
            rm -f "$da_stage_output" 2>/dev/null || true
            if [ ! -d "$da_output" ] || [ -L "$da_output" ]; then
                rm -f "$da_output" 2>/dev/null || true
            fi
        fi
        local da_stage_timing="${da_stage_output%.txt}-timing.json"
        local da_timing="${da_output%.txt}-timing.json"
        if [ -f "$da_stage_timing" ] && [ ! -L "$da_stage_timing" ]; then
            mv -f "$da_stage_timing" "$da_timing" 2>/dev/null || true
        fi
    fi
    review_wave_ended_ms=$(python3 -c 'import time; print(time.monotonic_ns() // 1000000)' 2>/dev/null) || review_wave_ended_ms="$review_wave_started_ms"
    _LOKI_RWT_PATH="$review_dir/$review_id/assurance-timing.json" \
    _LOKI_RWT_START="$review_wave_started_ms" _LOKI_RWT_END="$review_wave_ended_ms" \
    _LOKI_RWT_COUNT="$reviewer_count" _LOKI_RWT_DA="$da_speculative" \
    _LOKI_RWT_DA_RC="$da_dispatch_rc" python3 - <<'REVIEW_WAVE_TIMING' 2>/dev/null || true
import json
import os

started = int(os.environ.get("_LOKI_RWT_START", "0") or 0)
ended = int(os.environ.get("_LOKI_RWT_END", "0") or 0)
record = {
    "schema": "loki-review-assurance/v1",
    "scope": "parallel_wave",
    "elapsed_ms": max(0, ended - started),
    "reviewer_count": int(os.environ.get("_LOKI_RWT_COUNT", "0") or 0),
    "devils_advocate_speculative": os.environ.get("_LOKI_RWT_DA") == "true",
    "devils_advocate_exit_code": int(os.environ.get("_LOKI_RWT_DA_RC", "0") or 0),
}
with open(os.environ["_LOKI_RWT_PATH"], "w", encoding="utf-8") as handle:
    json.dump(record, handle, sort_keys=True)
REVIEW_WAVE_TIMING

    log_info "All reviewers complete. Aggregating verdicts..."

    # Aggregate verdicts: check for FAIL + Critical/High severity
    local has_blocking=false
    local pass_count=0
    local fail_count=0
    local verdicts_summary=""
    # Finding #596 FIX A2 (HIGH): count REAL verdicts (a reviewer file that
    # exists, is non-empty, AND carries a recognized VERDICT: PASS|FAIL line).
    # A review where every reviewer produced no usable verdict (all NO_OUTPUT,
    # e.g. the model returned EMPTY because a .loki-bloated prompt overflowed)
    # must NOT silently pass with pass_count=0/fail_count=0/has_blocking=false.
    # Such a review proves nothing, so we treat it as INCONCLUSIVE -> blocking.
    local real_verdict_count=0
    local no_output_count=0
    # 7.114.0 (rank 9): accumulate non-blocking (Medium/Low) findings across all
    # reviewers to compute the weighted mergeability quality score below.
    local nonblocking_medium=0
    local nonblocking_low=0

    for i in $(seq 0 $((reviewer_count - 1))); do
        local reviewer_name
        reviewer_name=$(echo "$selected_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['name'])")
        local review_output="$review_dir/$review_id/${reviewer_name}.txt"

        if [ "${reviewer_dispatch_rc[$i]:-125}" -ne 0 ] \
           || [ "${reviewer_publication_ok[$i]:-false}" != "true" ] \
           || [ ! -f "$review_output" ] || [ ! -s "$review_output" ]; then
            log_warn "Reviewer $reviewer_name produced no output"
            verdicts_summary="${verdicts_summary}${reviewer_name}:NO_OUTPUT "
            ((no_output_count++))
            continue
        fi

        # Extract + classify verdict (WAVE8 FIX run.sh-F1/F3). _classify_verdict
        # uses a markdown-tolerant anchor (rescues '**VERDICT:** FAIL') and a
        # SAFE-DEFAULT contract: FAIL=any FAIL/REJECT/BLOCK token (so verbose
        # "FAIL - [Critical] SQLi" / "FAIL." / "FAIL (3 criticals)" all count as
        # FAIL, previously mis-counted as PASS); PASS=PASS/APPROVE; AMBIGUOUS=a
        # verdict line that parses to neither; NONE=no parseable verdict line.
        local verdict
        verdict=$(_classify_verdict "$review_output")

        # FIX A2 + WAVE8 FIX run.sh-F1/F3: a "real verdict" is a parseable
        # VERDICT line that classifies cleanly to PASS or FAIL. NONE (no usable
        # verdict line) AND AMBIGUOUS (a verdict line whose token is neither PASS
        # nor FAIL, e.g. "VERDICT: UNCLEAR") are BOTH routed to the NO_VERDICT
        # path. This is the SAFE-DEFAULT contract: an unparseable token must NOT
        # silently pass. It cannot count toward pass_count, and merely bumping
        # fail_count would be inert (only has_blocking / review_inconclusive gate
        # the return). So we treat it as a non-real verdict; the
        # real_verdict_count < reviewer_count check below then makes the review
        # inconclusive -> bounded retry -> block (FIX 3 machinery).
        if [ "$verdict" = "NONE" ] || [ "$verdict" = "AMBIGUOUS" ]; then
            log_warn "Reviewer $reviewer_name returned no usable verdict (empty, unparseable, or ambiguous token)"
            verdicts_summary="${verdicts_summary}${reviewer_name}:NO_VERDICT "
            ((no_output_count++))
            continue
        fi
        ((real_verdict_count++))
        # 7.114.0 (rank 9): tally this reviewer's non-blocking (Medium/Low)
        # findings for the weighted quality score. Counted for BOTH PASS and FAIL
        # reviewers -- a PASS verdict can still list Low-severity nits that should
        # lower the mergeability score without blocking the gate.
        local _nb_counts _nb_med _nb_low
        _nb_counts=$(_count_nonblocking_findings "$review_output")
        _nb_med=${_nb_counts%% *}
        _nb_low=${_nb_counts##* }
        nonblocking_medium=$((nonblocking_medium + _nb_med))
        nonblocking_low=$((nonblocking_low + _nb_low))
        # Severity is authoritative even when free-form reviewer text contains a
        # contradictory PASS token. Structured output already normalizes this in
        # cr-rematerialize.py; keep the text fallback equally fail-closed.
        local _reviewer_has_blocking=false
        if _severity_is_blocking "$review_output"; then
            _reviewer_has_blocking=true
            has_blocking=true
            if [ "$verdict" = "PASS" ]; then
                log_error "BLOCKING: $reviewer_name returned PASS with Critical/High findings; normalizing verdict to FAIL"
                verdict="FAIL"
            fi
        fi
        if [ "$verdict" = "FAIL" ] \
           && [ "$reviewer_name" = "requirements-verifier" ] \
           && loki_is_supervised_simple_web; then
            _reviewer_has_blocking=true
            has_blocking=true
        fi
        if [ "$verdict" = "FAIL" ]; then
            ((fail_count++))
            # Check for Critical/High severity findings (bracketed OR unbracketed
            # OR bold OR 'Severity:' OR bullet form -- WAVE8 FIX run.sh-F2).
            if [ "$reviewer_name" = "requirements-verifier" ] \
               && loki_is_supervised_simple_web; then
                log_error "BLOCKING: structured requirements contract did not pass"
            elif [ "$_reviewer_has_blocking" = "true" ]; then
                log_error "BLOCKING: $reviewer_name found Critical/High severity issues"
            else
                log_warn "FAIL: $reviewer_name found Medium/Low issues (non-blocking)"
            fi
        else
            ((pass_count++))
            log_info "PASS: $reviewer_name"
        fi
        verdicts_summary="${verdicts_summary}${reviewer_name}:${verdict:-UNKNOWN} "
    done

    # Finding #596 FIX A2 + WAVE8 FIX run.sh-F3: a review is INCONCLUSIVE (=>
    # blocking) whenever FEWER reviewers returned a usable verdict than were
    # dispatched. The original gate only fired on real_verdict_count==0 (ALL
    # reviewers empty); a MIXED review (e.g. 1 of 3 NO_VERDICT, 2 PASS) silently
    # passed on the surviving majority and dropped the malformed reviewer's
    # potential dissent (Devil's Advocate never fired). Now ANY NO_VERDICT
    # reviewer makes the review inconclusive: a dropped reviewer is a dropped
    # vote, and the safe direction is to refuse to pass on a partial council.
    # The markdown-tolerant anchor in _classify_verdict already rescues most
    # real-but-wrapped verdicts, so this fires only on genuinely unusable output.
    # Optional bounded retry first (LOKI_REVIEW_RETRY=1, default on) so a
    # transient empty-output blip does not hard-block; the retry re-runs the
    # whole review with the (now .loki-excluded) diff. Opt out of the block
    # entirely with LOKI_REVIEW_INCONCLUSIVE_BLOCK=0 (records, never blocks).
    local review_inconclusive=false
    if [ "$reviewer_count" -gt 0 ] && [ "$real_verdict_count" -lt "$reviewer_count" ]; then
        review_inconclusive=true
        log_error "CODE REVIEW INCONCLUSIVE: only $real_verdict_count of $reviewer_count reviewers returned a usable verdict (no_output=$no_output_count)"
        log_error "  A partial review drops dissent; refusing to pass the gate without every reviewer's verdict."
        if [ "${LOKI_REVIEW_RETRY:-1}" = "1" ] && [ "${_LOKI_REVIEW_RETRYING:-0}" != "1" ]; then
            log_warn "  Retrying code review once (LOKI_REVIEW_RETRY=1)..."
            _LOKI_REVIEW_RETRYING=1 run_code_review
            return $?
        fi
    fi

    # 7.114.0 (rank 9): weighted mergeability quality score (FrontierCode-style).
    # A SCORE reported alongside the binary block verdict, NOT a new hard gate
    # (surfaced only; opt-in enforcement via LOKI_REVIEW_MERGEABILITY_MIN below).
    # Deterministic rubric: start at 100; any blocker (Critical/High) or an
    # inconclusive review => 0; else subtract 5 per Medium finding and 2 per Low
    # finding, floored at 0. This separates a tight, mergeable change (high
    # score, no blocker) from a
    # sprawling one whose scope/dead-code/convention findings pile up.
    # The conclusive-review rubric is parity-locked with
    # computeMergeabilityScore() in loki-ts/src/runner/quality_gates.ts.
    local quality_score
    if [ "$has_blocking" = "true" ] || [ "$review_inconclusive" = "true" ]; then
        quality_score=0
    else
        quality_score=$((100 - (nonblocking_medium * 5) - (nonblocking_low * 2)))
        [ "$quality_score" -lt 0 ] && quality_score=0
    fi
    log_info "Mergeability quality score: ${quality_score}/100 (medium=${nonblocking_medium}, low=${nonblocking_low}, blocker=${has_blocking}, inconclusive=${review_inconclusive})"

    # Save aggregate results via python3 + env vars (no shell interpolation in JSON)
    export LOKI_REVIEW_AGG_FILE="$review_dir/$review_id/aggregate.json"
    export LOKI_REVIEW_AGG_ID="$review_id"
    export LOKI_REVIEW_AGG_ITER="$ITERATION_COUNT"
    export LOKI_REVIEW_AGG_PASS="$pass_count"
    export LOKI_REVIEW_AGG_FAIL="$fail_count"
    export LOKI_REVIEW_AGG_BLOCKING="$has_blocking"
    export LOKI_REVIEW_AGG_VERDICTS="$verdicts_summary"
    export LOKI_REVIEW_AGG_REAL="$real_verdict_count"
    export LOKI_REVIEW_AGG_INCONCLUSIVE="$review_inconclusive"
    export LOKI_REVIEW_AGG_QSCORE="$quality_score"
    export LOKI_REVIEW_AGG_QMED="$nonblocking_medium"
    export LOKI_REVIEW_AGG_QLOW="$nonblocking_low"
    python3 << 'AGG_SCRIPT'
import json, os
result = {
    "review_id": os.environ["LOKI_REVIEW_AGG_ID"],
    "iteration": int(os.environ["LOKI_REVIEW_AGG_ITER"]),
    "pass_count": int(os.environ["LOKI_REVIEW_AGG_PASS"]),
    "fail_count": int(os.environ["LOKI_REVIEW_AGG_FAIL"]),
    "has_blocking": os.environ["LOKI_REVIEW_AGG_BLOCKING"] == "true",
    "real_verdict_count": int(os.environ["LOKI_REVIEW_AGG_REAL"]),
    "inconclusive": os.environ["LOKI_REVIEW_AGG_INCONCLUSIVE"] == "true",
    "verdicts": os.environ["LOKI_REVIEW_AGG_VERDICTS"].strip(),
    # rank 9: weighted mergeability quality score (0 if blocking or inconclusive,
    # else 100 - 5*medium - 2*low, floored at 0). Reported metric, not a hard gate.
    "quality_score": int(os.environ["LOKI_REVIEW_AGG_QSCORE"]),
    "nonblocking_medium": int(os.environ["LOKI_REVIEW_AGG_QMED"]),
    "nonblocking_low": int(os.environ["LOKI_REVIEW_AGG_QLOW"])
}
with open(os.environ["LOKI_REVIEW_AGG_FILE"], "w") as f:
    json.dump(result, f, indent=2)
AGG_SCRIPT
    unset LOKI_REVIEW_AGG_FILE LOKI_REVIEW_AGG_ID LOKI_REVIEW_AGG_ITER
    unset LOKI_REVIEW_AGG_PASS LOKI_REVIEW_AGG_FAIL LOKI_REVIEW_AGG_BLOCKING LOKI_REVIEW_AGG_VERDICTS
    unset LOKI_REVIEW_AGG_REAL LOKI_REVIEW_AGG_INCONCLUSIVE
    unset LOKI_REVIEW_AGG_QSCORE LOKI_REVIEW_AGG_QMED LOKI_REVIEW_AGG_QLOW

    emit_event_json "code_review_council_complete" \
        "review_id=$review_id" \
        "pass_count=$pass_count" \
        "fail_count=$fail_count" \
        "has_blocking=$has_blocking" \
        "real_verdict_count=$real_verdict_count" \
        "inconclusive=$review_inconclusive" \
        "iteration=$ITERATION_COUNT"

    local council_unanimous=false da_should_consume=false
    if [ "$pass_count" -eq "$reviewer_count" ] && [ "$fail_count" -eq 0 ] && [ "$reviewer_count" -gt 0 ]; then
        council_unanimous=true
        log_warn "ANTI-SYCOPHANCY: All $reviewer_count reviewers passed unanimously"
        log_warn "Devil's advocate note: Unanimous approval may indicate insufficient scrutiny"
        log_warn "Consider manual review of $review_dir/$review_id/"
        echo "UNANIMOUS_PASS: All reviewers approved - potential sycophancy risk" \
            >> "$review_dir/$review_id/anti-sycophancy.txt"
    fi

    # Once a speculative adversarial review starts, its result is evidence and
    # can never be discarded because the council happened to be nonunanimous.
    # General profiles retain their historical on-demand unanimous review.
    if [ "${LOKI_GATE_DEVILS_ADVOCATE:-true}" = "true" ]; then
        if [ "$da_speculative" = "true" ] || [ "$council_unanimous" = "true" ]; then
            da_should_consume=true
        fi
    fi
    if [ "$da_should_consume" = "true" ]; then
        if [ "$council_unanimous" = "true" ]; then
            log_info "Devil's Advocate: re-reviewing unanimous PASS for missed Critical/High issues..."
        else
            log_info "Devil's Advocate: consuming the completed speculative review despite council dissent..."
        fi
        da_inconclusive=false
        da_status="pass"
        if [ "$da_speculative" != "true" ]; then
            if ! _write_devils_advocate_prompt "$diff_file" "$files_file" "$da_prompt_file"; then
                da_dispatch_rc=125
            else
                local da_prompt_text
                : > "$da_stage_output"
                chmod 600 "$da_stage_output" 2>/dev/null || da_dispatch_rc=125
                da_prompt_text=$(cat "$da_prompt_file")
                if [ "$da_dispatch_rc" -eq 0 ]; then
                    _dispatch_reviewer_recorded "$da_prompt_text" "$da_stage_output" \
                        || da_dispatch_rc=$?
                fi
                if [ "$da_dispatch_rc" -eq 0 ]; then
                    local da_publication_binding=""
                    da_publication_binding=$(python3 "$requirements_helper" publish-bound \
                        "$da_stage_output" "$da_output" \
                        "$_review_max_output_bytes" 2>/dev/null) \
                        || da_dispatch_rc=125
                    if [[ "$da_publication_binding" =~ ^[0-9a-f]{64}\|[0-9]+:[0-9]+$ ]]; then
                        da_publication_ok=true
                    else
                        da_dispatch_rc=125
                    fi
                fi
            fi
        fi

        if [ "$da_dispatch_rc" -ne 0 ] \
           || [ "$da_publication_ok" != "true" ] \
           || [ ! -s "$da_output" ]; then
            da_status="inconclusive"
            da_inconclusive=true
            log_error "Devil's Advocate: no usable verdict (exit=$da_dispatch_rc); refusing to pass"
            echo "DEVILS_ADVOCATE_INCONCLUSIVE: no usable reply, exit=$da_dispatch_rc" \
                >> "$review_dir/$review_id/anti-sycophancy.txt"
        else
                local da_verdict
                da_verdict=$(_classify_verdict "$da_output")
                case "$da_verdict" in
                    PASS)
                        if _severity_is_blocking "$da_output"; then
                            has_blocking=true
                            da_status="block"
                        elif [ "$council_unanimous" = "true" ]; then
                            log_info "Devil's Advocate: no additional Critical/High issues found"
                            echo "DEVILS_ADVOCATE_PASS: no Critical/High beyond unanimous council" \
                                >> "$review_dir/$review_id/anti-sycophancy.txt"
                        else
                            da_status="not_needed"
                            log_info "Devil's Advocate: valid PASS recorded; council was already nonunanimous"
                            echo "DEVILS_ADVOCATE_NOT_NEEDED: valid PASS after council dissent" \
                                >> "$review_dir/$review_id/anti-sycophancy.txt"
                        fi
                        ;;
                    FAIL)
                        if _severity_is_blocking "$da_output"; then
                            has_blocking=true
                            da_status="block"
                        else
                            da_status="inconclusive"
                            da_inconclusive=true
                        fi
                        ;;
                    *)
                        da_status="inconclusive"
                        da_inconclusive=true
                        ;;
                esac
                if [ "$da_status" = "block" ]; then
                    log_error "DEVIL'S ADVOCATE: found a Critical/High issue - BLOCK"
                    {
                        echo "DEVILS_ADVOCATE_BLOCK: Critical/High finding"
                        grep -iE '(\[(critical|high)\])|(\*\*[[:space:]]*(critical|high)[[:space:]]*\*\*)|(severity:?[[:space:]]*(critical|high))|(^[[:space:]]*[-*][[:space:]]+(critical|high)([[:space:]:.,*]|$))' "$da_output" || true
                    } >> "$review_dir/$review_id/anti-sycophancy.txt"
                elif [ "$da_inconclusive" = "true" ]; then
                    log_error "Devil's Advocate returned an unusable or contradictory verdict; refusing to pass"
                    echo "DEVILS_ADVOCATE_INCONCLUSIVE: verdict contract was not satisfied" \
                        >> "$review_dir/$review_id/anti-sycophancy.txt"
                fi
        fi

        _LOKI_DA_AGG_FILE="$review_dir/$review_id/aggregate.json" \
            _LOKI_DA_STATUS="$da_status" _LOKI_DA_RC="$da_dispatch_rc" \
            _LOKI_DA_SPECULATIVE="$da_speculative" python3 <<'DA_AGG_PATCH' 2>/dev/null || true
import json
import os

path = os.environ["_LOKI_DA_AGG_FILE"]
with open(path, encoding="utf-8") as handle:
    data = json.load(handle)
status = os.environ["_LOKI_DA_STATUS"]
data["devils_advocate"] = {
    "status": status,
    "exit_code": int(os.environ.get("_LOKI_DA_RC", "125") or 125),
    "speculative": os.environ.get("_LOKI_DA_SPECULATIVE") == "true",
}
if status == "block":
    data["has_blocking"] = True
    data["quality_score"] = 0
elif status == "inconclusive":
    data["inconclusive"] = True
    data["has_blocking"] = True
    data["quality_score"] = 0
with open(path, "w", encoding="utf-8") as handle:
    json.dump(data, handle, indent=2)
DA_AGG_PATCH
        if [ "$da_inconclusive" = "true" ]; then
            review_inconclusive=true
            has_blocking=true
        fi
    fi

    emit_event_json "code_review_complete" \
        "review_id=$review_id" \
        "pass_count=$pass_count" \
        "fail_count=$fail_count" \
        "has_blocking=$has_blocking" \
        "real_verdict_count=$real_verdict_count" \
        "inconclusive=$review_inconclusive" \
        "devils_advocate=$da_status" \
        "devils_advocate_speculative=$da_speculative" \
        "iteration=$ITERATION_COUNT"

    local review_infrastructure_only=false
    # A reviewer may return FAIL for Medium or Low advice. Those findings are
    # intentionally non-blocking, so they must not turn a separate missing
    # reviewer into a request for another implementation iteration. Only a
    # concrete blocking finding makes the failure repairable by code.
    if [ "$review_inconclusive" = "true" ] && [ "$has_blocking" = "false" ]; then
        review_infrastructure_only=true
        for ((i=0; i<reviewer_count; i++)); do
            review_output="$review_dir/$review_id/$(echo "$selected_specialists" | python3 -c "import sys,json; print(json.load(sys.stdin)['reviewers'][$i]['name'])").txt"
            if [ -s "$review_output" ]; then
                continue
            fi
            case "${reviewer_dispatch_rc[$i]:-125}" in
                124|125) ;;
                *) review_infrastructure_only=false ;;
            esac
        done
        if [ "$da_inconclusive" = "true" ]; then
            case "${da_dispatch_rc:-125}" in
                124|125) ;;
                *) review_infrastructure_only=false ;;
            esac
        fi
    fi
    if [ "$review_infrastructure_only" = "true" ]; then
        _LOKI_REVIEW_FAILURE_KIND="infrastructure_inconclusive"
    elif [ "$has_blocking" = "true" ] || [ "$fail_count" -gt 0 ]; then
        _LOKI_REVIEW_FAILURE_KIND="blocking_finding"
    else
        _LOKI_REVIEW_FAILURE_KIND=""
    fi

    # Blocking decision
    if [ "$has_blocking" = "true" ]; then
        log_error "CODE REVIEW BLOCKED: mandatory requirement or Critical/High finding detected"
        log_error "Review details: $review_dir/$review_id/"
        return 1
    fi

    # 7.114.0 (rank 9): OPT-IN mergeability-score gate. Default OFF -- the score
    # is reported in aggregate.json regardless, but it only BLOCKS when the
    # operator explicitly sets a floor via LOKI_REVIEW_MERGEABILITY_MIN. This
    # keeps the existing Critical/High=block, Medium/Low=non-blocking contract
    # unchanged by default while giving teams a knob to demand a minimum
    # mergeability score. Never fabricates a pass; a low score only ever blocks.
    if [ "$review_inconclusive" != "true" ] \
       && [ -n "${LOKI_REVIEW_MERGEABILITY_MIN:-}" ] \
       && [ "$quality_score" -lt "${LOKI_REVIEW_MERGEABILITY_MIN}" ]; then
        log_error "CODE REVIEW BLOCKED: mergeability quality score ${quality_score} < floor ${LOKI_REVIEW_MERGEABILITY_MIN} (LOKI_REVIEW_MERGEABILITY_MIN)"
        log_error "  Review details: $review_dir/$review_id/ ; unset LOKI_REVIEW_MERGEABILITY_MIN to disable this gate"
        return 1
    fi

    # Finding #596 FIX A2 + WAVE8 FIX run.sh-F3: an inconclusive review (fewer
    # usable verdicts than reviewers, retry already exhausted or disabled) blocks
    # unless explicitly opted out. This is the 'verified before done' promise: a
    # review missing any reviewer's verdict cannot stand in for a full review.
    if [ "$review_inconclusive" = "true" ]; then
        if [ "${LOKI_REVIEW_INCONCLUSIVE_BLOCK:-1}" = "0" ]; then
            log_warn "Code review inconclusive ($real_verdict_count/$reviewer_count real verdicts) but LOKI_REVIEW_INCONCLUSIVE_BLOCK=0 - not blocking"
            return 0
        fi
        log_error "CODE REVIEW BLOCKED: inconclusive ($real_verdict_count/$reviewer_count reviewers returned a usable verdict)"
        log_error "  Review details: $review_dir/$review_id/ ; opt out with LOKI_REVIEW_INCONCLUSIVE_BLOCK=0"
        return 1
    fi

    log_info "Code review passed ($pass_count/$reviewer_count PASS, $fail_count FAIL - no blocking issues)"
    return 0
}

#===============================================================================
# Adversarial Testing (v6.0.0) - For Standard+ complexity tiers
# Spawns an adversarial agent that tries to break the implementation.
# Only runs when complexity >= standard (6+ agents).
#===============================================================================

load_solutions_context() {
    # Load relevant structured solutions for the current task context
    local context="$1"
    local solutions_dir="$(loki_knowledge_root)/solutions"
    local output_file=".loki/state/relevant-solutions.json"

    if [ ! -d "$solutions_dir" ]; then
        echo '{"solutions":[]}' > "$output_file" 2>/dev/null || true
        return
    fi

    LOKI_SOL_CONTEXT="$context" _LOKI_SOLUTIONS_DIR="$solutions_dir" python3 << 'SOLUTIONS_SCRIPT'
import json
import os
import re

solutions_dir = os.environ["_LOKI_SOLUTIONS_DIR"]
context = os.environ.get("LOKI_SOL_CONTEXT", "").lower()
context_words = set(context.split())

results = []

for category in os.listdir(solutions_dir):
    cat_dir = os.path.join(solutions_dir, category)
    if not os.path.isdir(cat_dir):
        continue
    for filename in os.listdir(cat_dir):
        if not filename.endswith('.md'):
            continue
        filepath = os.path.join(cat_dir, filename)
        try:
            with open(filepath, 'r') as f:
                content = f.read()
        except:
            continue

        # Parse YAML frontmatter
        fm_match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
        if not fm_match:
            continue

        fm = fm_match.group(1)
        title = re.search(r'title:\s*"([^"]*)"', fm)
        tags_match = re.search(r'tags:\s*\[([^\]]*)\]', fm)
        root_cause = re.search(r'root_cause:\s*"([^"]*)"', fm)
        prevention = re.search(r'prevention:\s*"([^"]*)"', fm)
        symptoms = re.findall(r'^\s*-\s*"([^"]*)"', fm, re.MULTILINE)

        title_str = title.group(1) if title else filename.replace('.md', '')
        tags = [t.strip() for t in tags_match.group(1).split(',')] if tags_match else []

        # Score by matching
        score = 0
        for tag in tags:
            if tag.lower() in context:
                score += 2
        for symptom in symptoms:
            for word in symptom.lower().split():
                if word in context_words and len(word) > 3:
                    score += 3
        if category in context:
            score += 1

        if score > 0:
            results.append({
                "score": score,
                "category": category,
                "title": title_str,
                "root_cause": root_cause.group(1) if root_cause else "",
                "prevention": prevention.group(1) if prevention else "",
                "file": filepath
            })

# Sort by score, take top 3
results.sort(key=lambda x: x["score"], reverse=True)
top = results[:3]

output = {"solutions": top}
os.makedirs(".loki/state", exist_ok=True)
with open(".loki/state/relevant-solutions.json", 'w') as f:
    json.dump(output, f, indent=2)

if top:
    print(f"Loaded {len(top)} relevant solutions from cross-project knowledge base")
SOLUTIONS_SCRIPT
}

# ============================================================================
# Durable-state assertion (enterprise container deployment)
# ============================================================================
# In a containerized deployment (k8s Job / ECS task / docker-run), all per-build
# state -- .loki/state/checkpoints, .loki/ state, .loki/queue, .loki/signals,
# .loki/logs, the agent feature branch, and the refs/loki/cp/* checkpoint refs
# in the checkout's .git -- lives UNDER the working directory (TARGET_DIR), since
# run_autonomous() runs with cwd == TARGET_DIR and every state path is relative
# (or ${TARGET_DIR}/.loki). Mounting ONE durable volume at the working checkout
# therefore makes the whole per-build state survive pod loss with no code change.
# The machine-global registry (~/.loki/dashboard/projects.json) is intentionally
# NOT per-build and stays off the volume; /tmp scratch (the staged run script,
# mktemp temp files) is intentionally ephemeral and re-created on restart.
#
# This assertion is OPT-IN via LOKI_DURABLE_STATE=1 (set by the container
# ENTRYPOINT / Helm chart). Local runs are unaffected. When enabled it fails
# loudly BEFORE any work if TARGET_DIR is not a writable directory, so a
# misconfigured mount (wrong mountPath, read-only volume, missing PVC) surfaces
# as an immediate honest error instead of silent state loss on the first crash.
assert_durable_state_mount() {
    [ "${LOKI_DURABLE_STATE:-0}" = "1" ] || return 0
    local dir="${TARGET_DIR:-.}"
    # A misconfigured mount is a DETERMINISTIC config error: re-running on the same
    # broken mount fails identically. Exit 20 (the terminal-failure contract code)
    # so the Job's podFailurePolicy fails it immediately instead of burning the
    # whole backoffLimit retrying a config error that cannot self-heal.
    if [ ! -d "$dir" ]; then
        log_error "LOKI_DURABLE_STATE=1 but working directory does not exist: $dir"
        log_error "Mount a durable volume (PVC / EFS / bind mount) at the working checkout."
        exit 20
    fi
    # Probe writability with an actual write+remove (a read-only mount passes -w
    # on some filesystems but rejects the write). This proves the mount is
    # WRITABLE; durability (survival across pod loss) is a property of the volume
    # the operator mounts here (a PVC / EFS / bind mount), which a write probe
    # cannot detect -- so the success message claims only writability.
    local probe="${dir}/.loki-durable-probe.$$"
    if ! ( mkdir -p "${dir}/.loki" 2>/dev/null && : > "$probe" ) 2>/dev/null; then
        log_error "LOKI_DURABLE_STATE=1 but the working directory is not writable: $dir"
        log_error "Pod-loss resume requires a writable durable volume mounted here."
        rm -f "$probe" 2>/dev/null || true
        exit 20
    fi
    rm -f "$probe" 2>/dev/null || true
    log_info "Durable state: writable mount verified at $dir (per-build state persists here; mount a durable volume for pod-loss survival)"
}

# ============================================================================
# Checkpoint/Snapshot System (v5.34.0)
# Git-based checkpoints after task completion with state snapshots
# Inspired by Cursor Self-Driving Codebases + Entire.io provenance tracking
# ============================================================================

create_checkpoint() {
    # Create a git checkpoint after task completion
    # Args: $1 = task description, $2 = task_id (optional)
    local task_desc="${1:-task completed}"
    local task_id="${2:-unknown}"
    local checkpoint_dir=".loki/state/checkpoints"
    local iteration="${ITERATION_COUNT:-0}"

    mkdir -p "$checkpoint_dir"

    # Only checkpoint if there are uncommitted changes.
    # R6: _LOKI_CP_FORCE=1 bypasses this guard. Used by rollback to guarantee a
    # pre-rollback snapshot of .loki/ state even when the git tree is clean (the
    # .loki/ state files about to be overwritten are not git-tracked, so the
    # clean-tree guard would otherwise skip the safety snapshot). Mirrors the
    # Bun `forceCreate` seam in checkpoint.ts.
    if [ "${_LOKI_CP_FORCE:-0}" != "1" ]; then
        if git diff --quiet 2>/dev/null && git diff --cached --quiet 2>/dev/null; then
            log_info "No uncommitted changes to checkpoint"
            _LAST_CHECKPOINT_ID=""
            return 0
        fi
    fi

    # Capture git state
    local git_sha
    git_sha=$(git rev-parse HEAD 2>/dev/null || echo "no-git")
    local git_branch
    git_branch=$(git branch --show-current 2>/dev/null || echo "unknown")

    # Snapshot .loki state files
    local timestamp
    timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    local checkpoint_id="cp-${iteration}-$(date +%s)"
    local cp_dir="${checkpoint_dir}/${checkpoint_id}"

    mkdir -p "$cp_dir"

    # Copy critical state files (lightweight -- not full .loki/)
    # BUG-ST-009: Include autonomy-state.json in checkpoint backup
    # R6: Include CONTINUITY.md so a rollback also restores iteration/conversation
    # handoff context, not just machine state. Mirrors Bun COPIED_FILES.
    for f in state/orchestrator.json autonomy-state.json queue/pending.json queue/completed.json queue/in-progress.json queue/current-task.json CONTINUITY.md; do
        if [ -f ".loki/$f" ]; then
            local target_dir="$cp_dir/$(dirname "$f")"
            mkdir -p "$target_dir"
            cp ".loki/$f" "$cp_dir/$f" 2>/dev/null || true
        fi
    done
    # A6: under a namespaced session the live state file is at
    # .loki/sessions/<id>/autonomy-state.json, which the legacy loop above misses.
    # Snapshot it into the checkpoint as autonomy-state.json so a rollback still
    # captures the current run's machine state. (Default `loki start` has no
    # LOKI_SESSION_ID, so this block is skipped and the loop above is unchanged.)
    if [ -n "${LOKI_SESSION_ID:-}" ]; then
        local _ns_state_file
        _ns_state_file="$(_loki_state_file)"
        if [ -f "$_ns_state_file" ]; then
            cp "$_ns_state_file" "$cp_dir/autonomy-state.json" 2>/dev/null || true
        fi
    fi

    # R6: capture a real working-tree snapshot so code can be truly undone later.
    # Loki does not commit per iteration, so git_sha (HEAD) cannot reconstruct
    # this iteration's working tree. `git stash create` builds a commit object
    # capturing tracked changes WITHOUT disturbing the tree; we then anchor it
    # under refs/loki/cp/<id> so `git gc` cannot prune the dangling commit. The
    # snapshot sha goes in a sidecar (worktree-snapshot.txt), NOT metadata.json,
    # to preserve byte-for-byte parity with the Bun port.
    # Honest limit: captures tracked changes only (not untracked/ignored files).
    if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
        local snap_sha
        snap_sha=$(git stash create "loki checkpoint ${checkpoint_id}" 2>/dev/null || echo "")
        if [ -n "$snap_sha" ]; then
            git update-ref "refs/loki/cp/${checkpoint_id}" "$snap_sha" 2>/dev/null \
                && printf '%s\n' "$snap_sha" > "$cp_dir/worktree-snapshot.txt" 2>/dev/null || true
        fi
    fi

    # Write checkpoint metadata (use python3 json.dumps for safe serialization)
    local phase_val
    phase_val=$(cat .loki/state/orchestrator.json 2>/dev/null | python3 -c 'import sys,json; print(json.load(sys.stdin).get("currentPhase","unknown"))' 2>/dev/null || echo 'unknown')

    local index_file="${checkpoint_dir}/index.jsonl"
    _CP_ID="$checkpoint_id" _CP_TS="$timestamp" _CP_ITER="$iteration" \
    _CP_TASK_ID="$task_id" _CP_DESC="${task_desc:0:200}" _CP_SHA="$git_sha" \
    _CP_BRANCH="$git_branch" _CP_PROVIDER="${PROVIDER_NAME:-claude}" \
    _CP_PHASE="$phase_val" _CP_DIR="$cp_dir" _CP_INDEX="$index_file" \
    python3 << 'CPEOF'
import json, os
metadata = {
    "id": os.environ["_CP_ID"],
    "timestamp": os.environ["_CP_TS"],
    "iteration": int(os.environ["_CP_ITER"]),
    "task_id": os.environ["_CP_TASK_ID"],
    "task_description": os.environ["_CP_DESC"],
    "git_sha": os.environ["_CP_SHA"],
    "git_branch": os.environ["_CP_BRANCH"],
    "provider": os.environ["_CP_PROVIDER"],
    "phase": os.environ["_CP_PHASE"],
}
with open(os.path.join(os.environ["_CP_DIR"], "metadata.json"), "w") as f:
    json.dump(metadata, f, indent=2)
with open(os.environ["_CP_INDEX"], "a") as f:
    index_entry = {"id": metadata["id"], "ts": metadata["timestamp"],
                   "iter": metadata["iteration"], "task": metadata["task_description"],
                   "sha": metadata["git_sha"]}
    f.write(json.dumps(index_entry) + "\n")
CPEOF

    # Retention: keep last 50 checkpoints, prune older
    # Sort by epoch suffix (field after last hyphen) for correct chronological order
    local cp_count
    cp_count=$(find "$checkpoint_dir" -maxdepth 1 -type d -name "cp-*" 2>/dev/null | wc -l | tr -d ' ')
    if [ "$cp_count" -gt 50 ]; then
        local to_remove=$((cp_count - 50))
        # BUG-ST-012: Sort by basename epoch suffix, not full path with extra dashes
        find "$checkpoint_dir" -maxdepth 1 -type d -name "cp-*" 2>/dev/null \
            | while read -r p; do basename "$p"; done | sort -t'-' -k3 -n \
            | head -n "$to_remove" | while read -r old_cp; do
            # WAVE9 (checkpoint leak): also delete the anchored worktree-snapshot
            # ref so its stash commit becomes eligible for `git gc`. Without this,
            # refs/loki/cp/<id> (and its commit) leaked forever even after the
            # checkpoint directory was pruned. Targeted deletion of exactly the
            # ids being pruned ONLY -- never a blanket refs/loki/cp/* sweep, since
            # git refs are shared across worktrees of one repo while checkpoint
            # dirs are per-TARGET_DIR; a parallel worktree may still need a ref we
            # are not pruning here. `|| true` because not every checkpoint has a
            # ref (only those where `git stash create` returned a non-empty sha).
            git update-ref -d "refs/loki/cp/${old_cp}" 2>/dev/null || true
            old_cp="${checkpoint_dir}/${old_cp}"
            rm -rf "$old_cp" 2>/dev/null || true
        done
        # Rebuild index atomically from remaining checkpoints (sorted by epoch).
        # RUN-25 iter 18 (Wave C #5): ONE python3 process reads ALL surviving
        # metadata.json files, sorts by the checkpoint-dir-basename epoch, and
        # writes the whole index -- instead of a shell for-loop that spawned one
        # python3 -c PER checkpoint (up to the 50 retention cap = ~50 interpreter
        # cold-starts, ~30-50ms each). Same sort key (BUG-ST-012: the epoch is the
        # LAST hyphen-separated field of the dir basename cp-<iter>-<epoch>, robust
        # to extra hyphens in the cwd path) and same per-record JSON shape, so the
        # index is byte-identical to the old loop's output. Only fires on a prune,
        # not every iteration.
        local tmp_index="${index_file}.tmp.$$"
        _CP_DIR="$checkpoint_dir" python3 -c "
import json, os, glob
cp_dir = os.environ['_CP_DIR']
recs = []
for mp in glob.glob(os.path.join(cp_dir, '*', 'metadata.json')):
    base = os.path.basename(os.path.dirname(mp))
    if not base.startswith('cp-'):
        continue
    # epoch = last hyphen-separated field of the dir basename (cp-<iter>-<epoch>).
    try:
        epoch = int(base.rsplit('-', 1)[-1])
    except ValueError:
        epoch = 0
    try:
        m = json.load(open(mp))
    except Exception:
        continue
    recs.append((epoch, {
        'id': m['id'], 'ts': m['timestamp'], 'iter': m['iteration'],
        'task': m.get('task_description', ''), 'sha': m['git_sha'],
    }))
recs.sort(key=lambda r: r[0])
for _epoch, rec in recs:
    print(json.dumps(rec))
" > "$tmp_index" 2>/dev/null || true
        mv -f "$tmp_index" "$index_file" 2>/dev/null || true
    fi

    log_info "Checkpoint created: ${checkpoint_id} (git: ${git_sha:0:8})"
    # R6: expose the id via a global so callers (rollback, run loop) can reference
    # it without parsing stdout (log_info writes to stdout, so command-substitution
    # capture would include log lines).
    _LAST_CHECKPOINT_ID="$checkpoint_id"

    # A3: best-effort object-store sync of checkpoint state. No-op unless
    # LOKI_STORAGE_BACKEND is set to a non-local backend. Never blocks/fails the
    # build on a sync error (the shim swallows errors; we also `|| true`).
    _loki_object_store_sync_checkpoints || true
}

# A3: push .loki/state/checkpoints/** to the configured object store (best-effort).
# Local/unset backend => no-op (zero behavior change for local users). Provider
# writes are out of scope; this syncs only the checkpoint state run.sh owns.
_loki_object_store_sync_checkpoints() {
    local backend="${LOKI_STORAGE_BACKEND:-local}"
    case "$backend" in
        local|""|file|filesystem) return 0 ;;
    esac
    command -v python3 >/dev/null 2>&1 || return 0
    local shim="${SCRIPT_DIR}/lib/checkpoint_sync.py"
    [ -f "$shim" ] || return 0
    python3 "$shim" sync >/dev/null 2>&1 || log_warn "Object-store checkpoint sync skipped (backend=$backend); build continues."
    return 0
}

# A3: hydrate .loki/state/checkpoints/** from the object store on a durable
# resume when the local volume is empty (best-effort). No-op unless
# LOKI_DURABLE_STATE=1 AND a non-local LOKI_STORAGE_BACKEND is set. The shim
# itself guards against overwriting a non-empty local volume.
_loki_object_store_hydrate_checkpoints() {
    [ "${LOKI_DURABLE_STATE:-0}" = "1" ] || return 0
    local backend="${LOKI_STORAGE_BACKEND:-local}"
    case "$backend" in
        local|""|file|filesystem) return 0 ;;
    esac
    command -v python3 >/dev/null 2>&1 || return 0
    local shim="${SCRIPT_DIR}/lib/checkpoint_sync.py"
    [ -f "$shim" ] || return 0
    if python3 "$shim" hydrate >/dev/null 2>&1; then
        log_info "Durable resume: checked object store ($backend) for prior checkpoints."
    else
        log_warn "Object-store checkpoint hydrate skipped (backend=$backend); build continues with local state."
    fi
    return 0
}

start_dashboard() {
    loki_background_services_enabled || return 0
    log_header "Starting Loki Dashboard"

    # Create dashboard directory for logs
    mkdir -p .loki/dashboard/logs

    # Find available port - don't kill other loki instances
    local original_port=$DASHBOARD_PORT
    local max_attempts=10
    local attempt=0
    local DASHBOARD_REUSED=0

    while lsof -i :$DASHBOARD_PORT &>/dev/null && [ $attempt -lt $max_attempts ]; do
        # Check if it's our own dashboard
        local existing_pid=$(lsof -ti :$DASHBOARD_PORT 2>/dev/null | head -1)
        if [ -n "$existing_pid" ]; then
            # Only kill if it's a Python/uvicorn dashboard process
            local proc_cmd=$(ps -p "$existing_pid" -o comm= 2>/dev/null || true)
            if [[ "$proc_cmd" == *python* ]] || [[ "$proc_cmd" == *uvicorn* ]]; then
                # Never kill a HEALTHY dashboard already serving here: it is almost
                # always the user's own live dashboard (open in their browser), and
                # killing it on `loki start` drops their session mid-use. A healthy
                # server is reusable by this build too, so probe /api/status and, if
                # it answers 200, REUSE it (skip starting our own). Only kill a
                # dashboard process that is NOT serving (a genuinely stuck/dead one).
                # Opt out with LOKI_DASHBOARD_FORCE_RECLAIM=1.
                local _dash_alive=""
                if [ "${LOKI_DASHBOARD_FORCE_RECLAIM:-}" != "1" ] && command -v curl >/dev/null 2>&1; then
                    _dash_alive=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 \
                        "http://127.0.0.1:${DASHBOARD_PORT}/api/status" 2>/dev/null || true)
                fi
                if [ "$_dash_alive" = "200" ]; then
                    log_info "Reusing the healthy dashboard already serving on port $DASHBOARD_PORT (not killing it)."
                    DASHBOARD_REUSED=1
                    break
                fi
                log_step "Killing stuck dashboard on port $DASHBOARD_PORT (PID: $existing_pid, not serving)..."
                kill "$existing_pid" 2>/dev/null || true
                sleep 1
                break
            else
                log_info "Port $DASHBOARD_PORT in use by non-dashboard process ($proc_cmd), skipping..."
            fi
        fi
        ((DASHBOARD_PORT++))
        if [ "$DASHBOARD_PORT" -gt 65535 ]; then
            log_error "Exhausted valid port range"
            return 1
        fi
        ((attempt++))
        log_info "Port $((DASHBOARD_PORT-1)) in use, trying $DASHBOARD_PORT..."
    done

    if [ $attempt -ge $max_attempts ]; then
        log_error "Could not find available port after $max_attempts attempts"
        return 1
    fi

    # If we found a HEALTHY dashboard already serving on the port, reuse it: do
    # NOT launch a second server (that would fight for the port / kill the user's
    # live one). The existing server already serves this project's state.
    if [ "${DASHBOARD_REUSED:-0}" = "1" ]; then
        export LOKI_DASHBOARD_PORT="$DASHBOARD_PORT"
        log_info "Dashboard already live on port $DASHBOARD_PORT; reusing it."
        return 0
    fi

    # Start FastAPI dashboard server (unified UI + API)
    log_step "Starting unified dashboard server..."
    local log_file=".loki/dashboard/logs/dashboard.log"
    local project_path=$(pwd)

    # Set environment for dashboard
    export LOKI_DASHBOARD_PORT="$DASHBOARD_PORT"
    export LOKI_DASHBOARD_HOST="127.0.0.1"
    export LOKI_PROJECT_PATH="$project_path"

    # Determine URL scheme based on TLS configuration
    local url_scheme="http"
    local tls_env=""
    if [ -n "${LOKI_TLS_CERT:-}" ] && [ -n "${LOKI_TLS_KEY:-}" ]; then
        url_scheme="https"
        tls_env="LOKI_TLS_CERT=${LOKI_TLS_CERT} LOKI_TLS_KEY=${LOKI_TLS_KEY}"
        log_info "TLS enabled for dashboard"
    fi

    # Ensure dashboard Python dependencies via virtualenv
    # Use ~/.loki/dashboard-venv (persistent, writable, survives npm/brew upgrades)
    local skill_dir="${SCRIPT_DIR%/*}"
    local req_file="${skill_dir}/dashboard/requirements.txt"
    local dashboard_venv="$HOME/.loki/dashboard-venv"
    local python_cmd="python3"

    # Use venv python if available
    if [ -x "${dashboard_venv}/bin/python3" ]; then
        python_cmd="${dashboard_venv}/bin/python3"
    fi

    # Check all required imports
    if ! "$python_cmd" -c "import fastapi; import sqlalchemy; import aiosqlite" 2>/dev/null; then
        # The venv is HOST-GLOBAL, so concurrent runs must not rebuild it at
        # once: one run's `rm -rf` would delete the tree another is importing
        # from. Lock the venv path itself -- safe_acquire_lock appends
        # ".lockdir", giving a SIBLING mutex the teardown below cannot destroy.
        # Timeout must outlast a real cold venv create + pip install (not the 5s
        # used by the JSON read-modify-write call sites). Mirrors the same guard
        # in ensure_dashboard_venv (autonomy/loki) -- edit BOTH.
        local _venv_locked=false
        if type safe_acquire_lock >/dev/null 2>&1 \
            && safe_acquire_lock "$dashboard_venv" "${LOKI_VENV_LOCK_TIMEOUT:-300}"; then
            _venv_locked=true
        fi
        # Re-probe: the run we queued behind may have just built it for us.
        [ -x "${dashboard_venv}/bin/python3" ] && python_cmd="${dashboard_venv}/bin/python3"
        if "$python_cmd" -c "import fastapi; import sqlalchemy; import aiosqlite" 2>/dev/null; then
            [ "$_venv_locked" = true ] && safe_release_lock "$dashboard_venv"
            _venv_locked=false
        elif [ "$_venv_locked" = false ] && type safe_acquire_lock >/dev/null 2>&1; then
            # Lock timed out and the venv is still unusable. Do NOT rm -rf
            # unlocked -- that is the exact race this lock exists to prevent.
            #
            # The re-probe above may have pointed python_cmd at the OTHER run's
            # half-built venv (bin/python3 exists, pip install not finished).
            # Reset to the system interpreter so the server launch below does not
            # exec a knowingly-broken one.
            python_cmd="python3"
            log_warn "Timed out waiting for another run to build the dashboard venv"
            log_warn "Dashboard will not be available (stale lock? rm -rf ${dashboard_venv}.lockdir)"
        else
            log_step "Setting up dashboard virtualenv..."
            if ! [ -x "${dashboard_venv}/bin/python3" ]; then
                # Remove broken venv if exists
                [ -d "$dashboard_venv" ] && rm -rf "$dashboard_venv"
                mkdir -p "$HOME/.loki"
                python3 -m venv "$dashboard_venv" 2>/dev/null || python3.13 -m venv "$dashboard_venv" 2>/dev/null || {
                    log_warn "Failed to create virtualenv"
                    log_warn "You may need: sudo apt install python3-venv"
                }
            fi
            if [ -x "${dashboard_venv}/bin/python3" ]; then
                python_cmd="${dashboard_venv}/bin/python3"
                log_step "Installing dashboard dependencies..."
                if [ -f "$req_file" ]; then
                    "${dashboard_venv}/bin/pip" install -r "$req_file" 2>&1 | tail -1 || {
                        log_warn "Pinned deps failed, trying unpinned..."
                        "${dashboard_venv}/bin/pip" install fastapi uvicorn pydantic websockets sqlalchemy aiosqlite 2>&1 | tail -1 || {
                            log_warn "Failed to install dashboard dependencies"
                            log_warn "Dashboard will not be available"
                        }
                        # greenlet is optional (needs C compiler on some platforms)
                        "${dashboard_venv}/bin/pip" install greenlet 2>/dev/null || true
                    }
                else
                    "${dashboard_venv}/bin/pip" install fastapi uvicorn pydantic websockets sqlalchemy aiosqlite 2>&1 | tail -1 || {
                        log_warn "Failed to install dashboard dependencies"
                        log_warn "Dashboard will not be available"
                    }
                    "${dashboard_venv}/bin/pip" install greenlet 2>/dev/null || true
                fi
            else
                log_warn "Failed to install dashboard dependencies"
                log_warn "Run manually: python3 -m venv ${dashboard_venv} && ${dashboard_venv}/bin/pip install fastapi uvicorn sqlalchemy aiosqlite"
            fi
        fi
        [ "$_venv_locked" = true ] && safe_release_lock "$dashboard_venv"
    fi

    # Start the FastAPI dashboard server
    # Dashboard module is at project root (parent of autonomy/)
    # LOKI_SKILL_DIR tells server.py where to find static files
    # LOKI_DASHBOARD_AUTOSTARTED=1 marks this as a run-launched (auto-started)
    # dashboard, distinct from an explicit `loki dashboard start`. The server
    # uses this so that, after the dashboard Stop button stops the last active
    # run, it may shut ITSELF down -- but ONLY when it was auto-started, never
    # when the user started it deliberately to keep monitoring. The explicit
    # cmd_dashboard_start path never sets this var (M4 fix).
    LOKI_TLS_CERT="${LOKI_TLS_CERT:-}" LOKI_TLS_KEY="${LOKI_TLS_KEY:-}" \
        LOKI_DASHBOARD_AUTOSTARTED=1 \
        LOKI_SKILL_DIR="${skill_dir}" PYTHONPATH="${skill_dir}" nohup "$python_cmd" -m dashboard.server > "$log_file" 2>&1 &
    DASHBOARD_PID=$!
    register_pid "$DASHBOARD_PID" "dashboard" "port=${DASHBOARD_PORT:-57374}"

    # Save PID for later cleanup
    mkdir -p .loki/dashboard
    if ! echo "$DASHBOARD_PID" > .loki/dashboard/dashboard.pid; then
        log_error "Failed to write dashboard PID file"
        kill "$DASHBOARD_PID" 2>/dev/null || true
        return 1
    fi

    sleep 2

    if kill -0 "$DASHBOARD_PID" 2>/dev/null; then
        DASHBOARD_LAST_ALIVE=$(date +%s)
        log_info "Dashboard started (PID: $DASHBOARD_PID)"
        log_info "Dashboard: ${CYAN}${url_scheme}://127.0.0.1:$DASHBOARD_PORT/${NC}"

        # Auto-open the dashboard in the browser, but ONLY for an interactive
        # foreground session. Gated on: a TTY on stdout ([ -t 1 ]), not
        # background/detached mode, and not explicitly opted out via
        # LOKI_NO_AUTO_OPEN=1. This keeps CI, --detach, SSH-no-TTY, and piped
        # runs from spawning a browser. Cross-platform: open / xdg-open / start.
        if [ -t 1 ] && [ "${BACKGROUND_MODE:-false}" != "true" ] && [ "${LOKI_NO_AUTO_OPEN:-0}" != "1" ]; then
            local _dash_url="${url_scheme}://127.0.0.1:$DASHBOARD_PORT/"
            if command -v open >/dev/null 2>&1; then
                open "$_dash_url" 2>/dev/null || true
            elif command -v xdg-open >/dev/null 2>&1; then
                xdg-open "$_dash_url" 2>/dev/null || true
            elif command -v cmd.exe >/dev/null 2>&1; then
                # Windows (Git Bash/WSL): `start` is a cmd builtin, not on PATH,
                # so invoke it via cmd.exe. The empty "" is start's title arg.
                cmd.exe /c start "" "$_dash_url" 2>/dev/null || true
            fi
        fi
        return 0
    else
        log_warn "Dashboard failed to start"
        log_warn "Check logs: $log_file"
        DASHBOARD_PID=""
        return 1
    fi
}

stop_dashboard() {
    # Try to kill using saved PID
    if [ -n "$DASHBOARD_PID" ]; then
        kill "$DASHBOARD_PID" 2>/dev/null || true
        wait "$DASHBOARD_PID" 2>/dev/null || true
        unregister_pid "$DASHBOARD_PID"
    fi

    # Also try PID file
    if [ -f ".loki/dashboard/dashboard.pid" ]; then
        local saved_pid=$(cat ".loki/dashboard/dashboard.pid" 2>/dev/null)
        if [ -n "$saved_pid" ]; then
            kill "$saved_pid" 2>/dev/null || true
            unregister_pid "$saved_pid"
        fi
        rm -f ".loki/dashboard/dashboard.pid"
    fi
}

# Handle dashboard crash: restart silently without triggering pause handler
# This prevents a killed dashboard from being misinterpreted as a user interrupt
handle_dashboard_crash() {
    # Reentrancy guard: prevent recursive restarts from signal handlers
    if [[ "$_DASHBOARD_RESTARTING" == "true" ]]; then
        return 0
    fi

    if [[ "${ENABLE_DASHBOARD:-true}" != "true" ]]; then
        return 0
    fi

    local dashboard_pid_file="${TARGET_DIR:-.}/.loki/dashboard/dashboard.pid"
    if [[ ! -f "$dashboard_pid_file" ]]; then
        return 0
    fi

    local dpid
    dpid=$(cat "$dashboard_pid_file" 2>/dev/null)
    if [[ -z "$dpid" ]]; then
        return 0
    fi

    # Dashboard is still alive, nothing to do
    if kill -0 "$dpid" 2>/dev/null; then
        return 0
    fi

    # Dashboard is dead -- restart it silently (with throttle)
    DASHBOARD_RESTART_COUNT=${DASHBOARD_RESTART_COUNT:-0}
    local max_restarts=${DASHBOARD_MAX_RESTARTS:-3}

    if [ "$DASHBOARD_RESTART_COUNT" -ge "$max_restarts" ]; then
        log_warn "Dashboard restart limit reached ($max_restarts) - disabling dashboard for this session"
        ENABLE_DASHBOARD=false
        return 1
    fi

    DASHBOARD_RESTART_COUNT=$((DASHBOARD_RESTART_COUNT + 1))
    log_info "Dashboard process $dpid exited, restarting silently (attempt $DASHBOARD_RESTART_COUNT/$max_restarts)..."
    emit_event_json "dashboard_crash" \
        "pid=$dpid" \
        "action=auto_restart" \
        "attempt=$DASHBOARD_RESTART_COUNT" \
        "autonomy_mode=$AUTONOMY_MODE"
    DASHBOARD_PID=""
    rm -f "$dashboard_pid_file"
    _DASHBOARD_RESTARTING=true
    start_dashboard
    _DASHBOARD_RESTARTING=false
    return 0
}

# Check if a signal was caused by a child process dying (e.g., dashboard)
# rather than an actual user interrupt. Returns 0 if it was a child crash
# (handled silently), 1 if it was a real interrupt.
is_child_process_signal() {
    local dashboard_pid_file="${TARGET_DIR:-.}/.loki/dashboard/dashboard.pid"
    local now
    now=$(date +%s)

    # If dashboard PID is set and dashboard is now dead, check timing to
    # distinguish a real Ctrl+C (which kills both parent and child in the
    # same process group) from an independent child crash.
    if [ -n "$DASHBOARD_PID" ] && ! kill -0 "$DASHBOARD_PID" 2>/dev/null; then
        local time_since_alive=$((now - DASHBOARD_LAST_ALIVE))
        if [ "$DASHBOARD_LAST_ALIVE" -gt 0 ] && [ "$time_since_alive" -lt 2 ]; then
            # Dashboard was alive very recently -- it likely died from the same
            # SIGINT that we just received (process group signal). Treat as real
            # user interrupt, but still restart the dashboard in the background.
            handle_dashboard_crash
            return 1
        fi
        # Dashboard has been dead for a while -- this is an independent crash
        handle_dashboard_crash
        return 0
    fi

    # Check PID file as fallback
    if [ -f "$dashboard_pid_file" ]; then
        local dpid
        dpid=$(cat "$dashboard_pid_file" 2>/dev/null)
        if [ -n "$dpid" ] && ! kill -0 "$dpid" 2>/dev/null; then
            handle_dashboard_crash
            return 0
        fi
    fi

    return 1
}

#===============================================================================
# Calculate Exponential Backoff
#===============================================================================

calculate_wait() {
    local retry="$1"
    # BUG-RUN-004: Cap exponent to prevent overflow at retry>=34
    local exp=$((retry > 30 ? 30 : retry))
    local wait_time=$((BASE_WAIT * (2 ** exp)))

    # Add jitter (0-30 seconds)
    local jitter=$((RANDOM % 30))
    wait_time=$((wait_time + jitter))

    # Cap at max wait
    if [ $wait_time -gt $MAX_WAIT ]; then
        wait_time=$MAX_WAIT
    fi

    echo $wait_time
}

#===============================================================================
# Cross-Provider Auto-Failover (v6.19.0)
#===============================================================================

# Initialize failover state file on startup
init_failover_state() {
    local failover_dir="${TARGET_DIR:-.}/.loki/state"
    local failover_file="$failover_dir/failover.json"

    # Only create if failover is enabled via env or config
    if [ "${LOKI_FAILOVER:-false}" != "true" ]; then
        return
    fi

    mkdir -p "$failover_dir"

    if [ ! -f "$failover_file" ]; then
        local chain="${LOKI_FAILOVER_CHAIN:-claude,codex}"
        local primary="${PROVIDER_NAME:-claude}"
        cat > "$failover_file" << FEOF
{
  "enabled": true,
  "chain": $(printf '%s' "$chain" | python3 -c 'import sys,json; print(json.dumps(sys.stdin.read().strip().split(",")))' 2>/dev/null || echo '["claude","codex"]'),
  "currentProvider": "$primary",
  "primaryProvider": "$primary",
  "lastFailover": null,
  "failoverCount": 0,
  "healthCheck": {
    "$primary": "healthy"
  }
}
FEOF
        log_info "Failover initialized: chain=$chain, primary=$primary"
    fi
}

# Read failover config from state file
# Sets: FAILOVER_ENABLED, FAILOVER_CHAIN, FAILOVER_CURRENT, FAILOVER_PRIMARY
read_failover_config() {
    local failover_file="${TARGET_DIR:-.}/.loki/state/failover.json"

    if [ ! -f "$failover_file" ]; then
        FAILOVER_ENABLED="false"
        return 1
    fi

    eval "$(python3 << 'PYEOF' 2>/dev/null || echo 'FAILOVER_ENABLED=false'
import json, os
try:
    with open(os.path.join(os.environ.get('TARGET_DIR', '.'), '.loki/state/failover.json')) as f:
        d = json.load(f)
    chain = ','.join(d.get('chain', ['claude','codex']))
    print(f'FAILOVER_ENABLED={str(d.get("enabled", False)).lower()}')
    print(f'FAILOVER_CHAIN="{chain}"')
    print(f'FAILOVER_CURRENT="{d.get("currentProvider", "claude")}"')
    print(f'FAILOVER_PRIMARY="{d.get("primaryProvider", "claude")}"')
    print(f'FAILOVER_COUNT={d.get("failoverCount", 0)}')
except Exception:
    print('FAILOVER_ENABLED=false')
PYEOF
    )"
}

# Update failover state file
update_failover_state() {
    local key="$1"
    local value="$2"
    local failover_file="${TARGET_DIR:-.}/.loki/state/failover.json"

    [ ! -f "$failover_file" ] && return 1

    # BUG-RUN-008: Use single-quoted heredoc to prevent shell injection; pass vars via env
    _FAILOVER_KEY="$key" _FAILOVER_VALUE="$value" _FAILOVER_FILE="$failover_file" \
    python3 << 'PYEOF' 2>/dev/null || true
import json, os
fpath = os.environ['_FAILOVER_FILE']
try:
    with open(fpath) as f:
        d = json.load(f)
    key = os.environ['_FAILOVER_KEY']
    value = os.environ['_FAILOVER_VALUE']
    # Handle type conversion
    if value == "null":
        d[key] = None
    elif value == "true":
        d[key] = True
    elif value == "false":
        d[key] = False
    elif value.isdigit():
        d[key] = int(value)
    else:
        d[key] = value
    with open(fpath, 'w') as f:
        json.dump(d, f, indent=2)
except Exception:
    pass
PYEOF
}

# Update health status for a specific provider in failover.json
update_failover_health() {
    local provider="$1"
    local status="$2"  # healthy, unhealthy, unknown
    local failover_file="${TARGET_DIR:-.}/.loki/state/failover.json"

    [ ! -f "$failover_file" ] && return 1

    python3 << PYEOF 2>/dev/null || true
import json, os
fpath = os.path.join(os.environ.get('TARGET_DIR', '.'), '.loki/state/failover.json')
try:
    with open(fpath) as f:
        d = json.load(f)
    if 'healthCheck' not in d:
        d['healthCheck'] = {}
    d['healthCheck']["$provider"] = "$status"
    with open(fpath, 'w') as f:
        json.dump(d, f, indent=2)
except Exception:
    pass
PYEOF
}

# Check provider health: CLI installed + authentication available
# Returns: 0 if healthy, 1 if unhealthy
# BUG-PROV-003 fix: Claude Code supports OAuth sessions in addition to API keys.
# Checking only for ANTHROPIC_API_KEY incorrectly marks OAuth users as unhealthy,
# causing unnecessary failover to degraded providers. Now also checks for OAuth
# session files and `claude auth status` as fallback.
check_provider_health() {
    local provider="$1"

    # Check CLI is installed and authentication is available
    case "$provider" in
        claude)
            command -v claude &>/dev/null || return 1
            # Accept API key OR OAuth session (Claude Code supports both)
            if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
                return 0
            fi
            # Check for OAuth session files (~/.claude/ stores sessions)
            if [ -d "${HOME}/.claude" ] && [ -f "${HOME}/.claude/.credentials.json" ]; then
                return 0
            fi
            # Last resort: ask the CLI if it has valid auth
            if claude auth status &>/dev/null 2>&1; then
                return 0
            fi
            return 1
            ;;
        codex)
            command -v codex &>/dev/null || return 1
            [ -n "${OPENAI_API_KEY:-}" ] || return 1
            ;;
        cline)
            command -v cline &>/dev/null || return 1
            ;;
        aider)
            command -v aider &>/dev/null || return 1
            ;;
        *)
            return 1
            ;;
    esac

    return 0
}

# Attempt failover to next healthy provider in chain
# Called when rate limit is detected on current provider
# Returns: 0 if failover succeeded, 1 if all providers exhausted
attempt_provider_failover() {
    read_failover_config || return 1

    if [ "$FAILOVER_ENABLED" != "true" ]; then
        return 1
    fi

    local current="${FAILOVER_CURRENT:-${PROVIDER_NAME:-claude}}"
    log_warn "Failover: rate limit on $current, checking chain: $FAILOVER_CHAIN"

    # Mark current as unhealthy
    update_failover_health "$current" "unhealthy"

    # Walk the chain looking for the next healthy provider
    local IFS=','
    local found_current=false
    local tried_wrap=false

    # Two passes: first from current position to end, then from start to current
    for provider in $FAILOVER_CHAIN $FAILOVER_CHAIN; do
        if [ "$provider" = "$current" ]; then
            if [ "$found_current" = "true" ]; then
                # We've wrapped around, all exhausted
                break
            fi
            found_current=true
            continue
        fi

        [ "$found_current" != "true" ] && continue

        # Check if this provider is healthy
        if check_provider_health "$provider"; then
            log_info "Failover: switching from $current to $provider"

            # Load the new provider config
            local provider_dir
            provider_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/providers"
            if [ -f "$provider_dir/$provider.sh" ]; then
                source "$provider_dir/$provider.sh"
            fi

            # Update state
            update_failover_state "currentProvider" "$provider"
            update_failover_state "lastFailover" "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
            update_failover_state "failoverCount" "$((FAILOVER_COUNT + 1))"
            update_failover_health "$provider" "healthy"

            # Update runtime provider vars
            # BUG-PROV-008 fix: Update BOTH PROVIDER_NAME and LOKI_PROVIDER.
            # Without this, subprocesses and the MCP server (which read LOKI_PROVIDER)
            # continue using the old provider name, causing provider-specific behavior
            # in child processes to use the wrong config.
            PROVIDER_NAME="$provider"
            LOKI_PROVIDER="$provider"
            export LOKI_PROVIDER

            emit_event_json "provider_failover" \
                "from=$current" \
                "to=$provider" \
                "reason=rate_limit" \
                "iteration=$ITERATION_COUNT" 2>/dev/null || true

            log_info "Failover: now using $provider (failover #$((FAILOVER_COUNT + 1)))"
            return 0
        else
            log_debug "Failover: $provider is unhealthy, skipping"
            update_failover_health "$provider" "unhealthy"
        fi
    done

    log_warn "Failover: all providers in chain exhausted, falling back to retry"
    # Crash friction (rate_limit_loop): a clear threshold -- every provider in
    # the failover chain is rate-limited/unhealthy. Best-effort, never blocks.
    if type loki_crash_friction &>/dev/null; then
        loki_crash_friction "rate_limit_loop" "failover chain exhausted: ${FAILOVER_CHAIN}" >/dev/null 2>&1 || true
    fi
    return 1
}

# Check if primary provider has recovered after running on a fallback
# Called after each successful iteration when on a non-primary provider
# Returns: 0 if switched back to primary, 1 if still on fallback
check_primary_recovery() {
    read_failover_config || return 1

    if [ "$FAILOVER_ENABLED" != "true" ]; then
        return 1
    fi

    local current="${FAILOVER_CURRENT:-${PROVIDER_NAME:-claude}}"
    local primary="${FAILOVER_PRIMARY:-claude}"

    # Already on primary
    if [ "$current" = "$primary" ]; then
        return 1
    fi

    # Check if primary is healthy again
    if check_provider_health "$primary"; then
        log_info "Failover: primary provider $primary appears healthy, switching back"

        # Load primary provider config
        local provider_dir
        provider_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/providers"
        if [ -f "$provider_dir/$primary.sh" ]; then
            source "$provider_dir/$primary.sh"
        fi

        update_failover_state "currentProvider" "$primary"
        update_failover_health "$primary" "healthy"

        # BUG-PROV-008 fix: Update BOTH PROVIDER_NAME and LOKI_PROVIDER on recovery
        PROVIDER_NAME="$primary"
        LOKI_PROVIDER="$primary"
        export LOKI_PROVIDER

        emit_event_json "provider_recovery" \
            "from=$current" \
            "to=$primary" \
            "iteration=$ITERATION_COUNT" 2>/dev/null || true

        log_info "Failover: recovered to primary provider $primary"
        return 0
    fi

    return 1
}

#===============================================================================
# Rate Limit Detection
#===============================================================================

# Detect if output contains rate limit indicators (provider-agnostic)
# Returns: 0 if rate limit detected, 1 otherwise
is_rate_limited() {
    local log_file="$1"
    [ -f "$log_file" ] || return 1

    # Only consider the TAIL of the log: a real provider rate-limit appears at the
    # END of the iteration (the call that failed), not buried in mid-run prose.
    # Scanning the whole file false-positived on the agent's OWN output (a build
    # that printed or generated rate-limiting code -- "rate limit", "429",
    # "retry-after" as source/text -- wrongly triggered a multi-minute wait).
    local tail_txt
    tail_txt=$(tail -n 40 "$log_file" 2>/dev/null) || return 1

    # Require an ERROR CONTEXT, not a bare keyword: a rate-limit token must
    # co-occur (same line) with a genuine provider-error frame -- an explicit
    # error word ("Error"/"failed"/"exceeded"), an HTTP/status frame, or the
    # canonical "429 Too Many Requests" phrasing. This distinguishes a real
    # failing API line from the words "rate limit"/"retry-after"/"429" appearing
    # in the model's own generated source or prose. Note: a bare "429" or a bare
    # "retry-after" is NOT sufficient on its own (both occur in generated code).
    local _err='(error|errored|failed|exceeded|http[ /]?[0-9]|status[: ]+[0-9]|too many requests)'
    local _rl='(rate.?limit|too many requests|quota exceeded|request limit|429[ )"]*too many|retry.?after)'
    if printf '%s\n' "$tail_txt" | grep -qiE "(${_rl}).*(${_err})|(${_err}).*(${_rl})" 2>/dev/null; then
        return 0
    fi

    # SELF-SUFFICIENT phrases: unambiguous on their own, and broken by the
    # co-occurrence rule above. "quota exceeded" CONTAINS its own error word, so
    # the alternation consumed "exceeded" as the rate-limit half and then found
    # no error half left to match -- "API quota exceeded for project", the
    # canonical quota rate-limit line, did not match at all.
    #
    # These stay narrow deliberately. A bare "429", a bare "retry-after", or an
    # "X-RateLimit-*" header must still NOT qualify alone: those appear in the
    # agent's own generated source, and treating them as a limit caused the
    # multi-minute false waits the co-occurrence rule was added to stop.
    if printf '%s\n' "$tail_txt" \
        | grep -qiE '(quota exceeded|rate limit exceeded|too many requests)' 2>/dev/null; then
        return 0
    fi

    # Claude-specific: the explicit "resets Xam/pm" reset-time line is itself an
    # unambiguous provider rate-limit signal (the CLI only prints it on a limit).
    if printf '%s\n' "$tail_txt" | grep -qE 'resets [0-9]+[ap]m' 2>/dev/null; then
        return 0
    fi

    return 1
}

# Parse Claude-specific reset time from log
# Returns: seconds to wait, or 0 if no reset time found
parse_claude_reset_time() {
    local log_file="$1"

    # Look for rate limit message like "resets 4am" or "resets 10pm"
    local reset_time=$(grep -o "resets [0-9]\+[ap]m" "$log_file" 2>/dev/null | tail -1 | grep -o "[0-9]\+[ap]m")

    if [ -z "$reset_time" ]; then
        echo 0
        return
    fi

    # Parse the reset time
    local hour=$(echo "$reset_time" | grep -o "[0-9]\+")
    local ampm=$(echo "$reset_time" | grep -o "[ap]m")

    # Convert to 24-hour format
    if [ "$ampm" = "pm" ] && [ "$hour" -ne 12 ]; then
        hour=$((hour + 12))
    elif [ "$ampm" = "am" ] && [ "$hour" -eq 12 ]; then
        hour=0
    fi

    # Get current time
    local current_hour=$(date +%H)
    local current_min=$(date +%M)
    local current_sec=$(date +%S)

    # Calculate seconds until reset. Force base-10 (10#) on the zero-padded
    # date values: 08/09 are invalid octal, so bare arithmetic aborts ("value
    # too great for base") during those clock windows and silently discards the
    # real rate-limit reset wait, falling back to a too-short generic backoff.
    local current_secs=$((10#$current_hour * 3600 + 10#$current_min * 60 + 10#$current_sec))
    local reset_secs=$((hour * 3600))

    local wait_secs=$((reset_secs - current_secs))

    # If reset time is in the past, it means tomorrow
    if [ $wait_secs -le 0 ]; then
        wait_secs=$((wait_secs + 86400))  # Add 24 hours
    fi

    # Add 2 minute buffer to ensure limit is actually reset
    wait_secs=$((wait_secs + 120))

    echo $wait_secs
}

# Parse Retry-After header value (common across providers)
# Returns: seconds to wait, or 0 if not found
parse_retry_after() {
    local log_file="$1"

    # Look for Retry-After header (case insensitive)
    # Format: "Retry-After: 60" or "retry-after: 60"
    local retry_secs=$(grep -ioE 'retry.?after:?\s*[0-9]+' "$log_file" 2>/dev/null | tail -1 | grep -oE '[0-9]+$')

    if [ -n "$retry_secs" ]; then
        echo "$retry_secs"
    else
        echo 0
    fi
}

# Calculate default backoff based on provider rate limit
# Uses PROVIDER_RATE_LIMIT_RPM from loaded provider config
# Returns: seconds to wait
calculate_rate_limit_backoff() {
    local rpm="${PROVIDER_RATE_LIMIT_RPM:-50}"

    # Calculate wait time based on RPM
    # If RPM is 50, that's ~1.2 requests per second
    # Default backoff: 60 seconds / RPM * 60 = wait for 1 minute window
    # But add some buffer, so wait for 2 minute windows
    local wait_secs=$((120 * 60 / rpm))

    # Minimum 60 seconds, maximum 300 seconds for default backoff
    if [ "$wait_secs" -lt 60 ]; then
        wait_secs=60
    elif [ "$wait_secs" -gt 300 ]; then
        wait_secs=300
    fi

    echo $wait_secs
}

# Detect rate limit from log and calculate wait time until reset
# Provider-agnostic: checks generic patterns first, then provider-specific
# Returns: seconds to wait, or 0 if no rate limit detected
detect_rate_limit() {
    local log_file="$1"

    # First check if rate limited at all
    if ! is_rate_limited "$log_file"; then
        echo 0
        return
    fi

    # Rate limit detected - now determine wait time
    local wait_secs=0

    # Try provider-specific reset time parsing
    case "${PROVIDER_NAME:-claude}" in
        claude)
            wait_secs=$(parse_claude_reset_time "$log_file")
            ;;
        codex|cline|aider|*)
            # No provider-specific reset time format known
            # Fall through to generic parsing
            ;;
    esac

    # If no provider-specific time, try generic Retry-After header
    if [ "$wait_secs" -eq 0 ]; then
        wait_secs=$(parse_retry_after "$log_file")
    fi

    # If still no specific time, use calculated backoff based on provider RPM
    if [ "$wait_secs" -eq 0 ]; then
        wait_secs=$(calculate_rate_limit_backoff)
        log_debug "Using calculated backoff (${PROVIDER_RATE_LIMIT_RPM:-50} RPM): ${wait_secs}s"
    fi

    echo $wait_secs
}

# Format seconds into human-readable time
format_duration() {
    local secs="$1"
    local hours=$((secs / 3600))
    local mins=$(((secs % 3600) / 60))

    if [ $hours -gt 0 ]; then
        echo "${hours}h ${mins}m"
    else
        echo "${mins}m"
    fi
}

#===============================================================================
# Check Completion
#===============================================================================

is_completed() {
    # Check orchestrator state
    if [ -f ".loki/state/orchestrator.json" ]; then
        if command -v python3 &> /dev/null; then
            local phase=$(python3 -c "import json; print(json.load(open('.loki/state/orchestrator.json')).get('currentPhase', ''))" 2>/dev/null || echo "")
            # Accept various completion states
            if [ "$phase" = "COMPLETED" ] || [ "$phase" = "complete" ] || [ "$phase" = "finalized" ] || [ "$phase" = "growth-loop" ]; then
                return 0
            fi
        fi
    fi

    # Check for completion marker
    if [ -f ".loki/COMPLETED" ]; then
        return 0
    fi

    return 1
}

# Check if estimated cost has exceeded the budget limit
# Returns 0 (exceeded) or 1 (within budget / no limit set)
check_budget_limit() {
    [[ -z "$BUDGET_LIMIT" ]] && return 1  # No limit set

    # Validate BUDGET_LIMIT is a valid number (prevent shell injection)
    if ! python3 -c "float('${BUDGET_LIMIT//[^0-9.]/}')" 2>/dev/null; then
        log_error "BUDGET_LIMIT is not a valid number: $BUDGET_LIMIT"
        return 1
    fi

    local current_cost=0
    local efficiency_dir=".loki/metrics/efficiency"

    # Calculate cost from per-iteration efficiency files (same source as /api/cost)
    if [ -d "$efficiency_dir" ]; then
        current_cost=$(python3 -c "
import json, glob
total = 0.0
pricing = {
    'fable': {'input': 10.00, 'output': 50.00},
    'claude-fable-5': {'input': 10.00, 'output': 50.00},
    'opus': {'input': 5.00, 'output': 25.00},
    'sonnet': {'input': 3.00, 'output': 15.00},
    'haiku': {'input': 1.00, 'output': 5.00},
    'gpt-5.3-codex': {'input': 1.75, 'output': 14.00},
    'gpt-5.6-sol': {'input': 2.50, 'output': 20.00},
    'gpt-5.6-terra': {'input': 1.50, 'output': 12.00},
    'gpt-5.6-luna': {'input': 0.50, 'output': 4.00},
}
for f in glob.glob('${efficiency_dir}/*.json'):
    try:
        d = json.load(open(f))
        cost = d.get('cost_usd')
        if cost is not None:
            total += float(cost)
        else:
            model = d.get('model', 'sonnet').lower()
            p = pricing.get(model, pricing['sonnet'])
            inp = d.get('input_tokens', 0)
            out = d.get('output_tokens', 0)
            # Cache tiers. The writer has emitted these since v6.82.0 and they
            # DOMINATE real traffic: a measured iteration carried 797,496
            # cache-read tokens against 10,272 of plain input. Pricing them at
            # zero under-counted a real iteration 5.4x, so a breaker set to
            # stop a runaway let it run far past the cap. Published multipliers:
            # cache read 0.1x input, cache write 1.25x input.
            #
            # This mirrors the TS route's calculateCostFromRecords
            # (loki-ts/src/runner/budget.ts). Both routes must agree or the
            # same run reports two different spends.
            cr = d.get('cache_read_tokens', 0) or 0
            cw = d.get('cache_creation_tokens', 0) or 0
            total += (inp / 1_000_000) * p['input'] + (out / 1_000_000) * p['output']
            total += (cr / 1_000_000) * (p['input'] * 0.1) + (cw / 1_000_000) * (p['input'] * 1.25)
    except: pass
print(round(total, 4))
" 2>/dev/null || echo "0")
    fi

    # Compare against limit
    local exceeded
    exceeded=$(python3 -c "
import sys
try:
    cost = float(sys.argv[1])
    limit = float(sys.argv[2])
    print(1 if cost >= limit else 0)
except (ValueError, IndexError):
    print(0)
" "$current_cost" "$BUDGET_LIMIT" 2>/dev/null || echo "0")

    if [[ "$exceeded" == "1" ]]; then
        log_warn "BUDGET LIMIT REACHED: \$${current_cost} >= \$${BUDGET_LIMIT}"
        touch ".loki/PAUSE"
        mkdir -p ".loki/signals"
        echo "{\"type\":\"BUDGET_EXCEEDED\",\"limit\":${BUDGET_LIMIT},\"current\":${current_cost},\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > ".loki/signals/BUDGET_EXCEEDED"
        # Update budget.json with latest usage
        cat > ".loki/metrics/budget.json" << BUDGETUPD_EOF
{
  "limit": $BUDGET_LIMIT,
  "budget_limit": $BUDGET_LIMIT,
  "budget_used": $current_cost,
  "exceeded": true,
  "exceeded_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
BUDGETUPD_EOF
        emit_event_json "budget_exceeded" \
            "limit=${BUDGET_LIMIT}" \
            "current=${current_cost}" \
            "iteration=$ITERATION_COUNT"
        return 0
    fi

    # Update budget.json with current usage (not exceeded)
    if [ -n "$current_cost" ] && [ "$current_cost" != "0" ]; then
        cat > ".loki/metrics/budget.json" << BUDGETUPD_EOF
{
  "limit": $BUDGET_LIMIT,
  "budget_limit": $BUDGET_LIMIT,
  "budget_used": $current_cost,
  "exceeded": false
}
BUDGETUPD_EOF
    fi

    # Anti-surprise-cost warn (R3): when spend crosses 80% of the cap but is
    # still under 100%, log a warning and emit an event. Does NOT pause: the
    # warn is the transparency the user wants BEFORE the hard cap stops them.
    # Read-time classification only; budget.json schema is unchanged.
    local warn
    warn=$(python3 -c "
import sys
try:
    cost = float(sys.argv[1]); limit = float(sys.argv[2])
    print(1 if (limit > 0 and 0.80 * limit <= cost < limit) else 0)
except (ValueError, IndexError):
    print(0)
" "$current_cost" "$BUDGET_LIMIT" 2>/dev/null || echo "0")
    if [[ "$warn" == "1" ]]; then
        log_warn "BUDGET WARNING: \$${current_cost} is at or above 80% of cap \$${BUDGET_LIMIT}. Run continues; hard-stop at 100%."
        emit_event_json "budget_warning" \
            "limit=${BUDGET_LIMIT}" \
            "current=${current_cost}" \
            "threshold_percent=80" \
            "iteration=${ITERATION_COUNT:-0}"
    fi

    return 1
}

#===============================================================================
# Watchdog: Process Supervision and Health Monitoring
# Opt-in via LOKI_WATCHDOG=true. Detects crashed dashboard and agent processes.
#===============================================================================

watchdog_check() {
    [[ "$WATCHDOG_ENABLED" != "true" ]] && return 0

    # Check dashboard health
    local dashboard_pid_file="${TARGET_DIR:-.}/.loki/dashboard/dashboard.pid"
    if [[ -f "$dashboard_pid_file" ]]; then
        local dpid
        dpid=$(cat "$dashboard_pid_file" 2>/dev/null)
        if [[ -n "$dpid" ]] && ! kill -0 "$dpid" 2>/dev/null; then
            log_warn "WATCHDOG: Dashboard process $dpid is dead"
            emit_event_json "watchdog_alert" \
                "process=dashboard" \
                "pid=$dpid" \
                "action=detected_dead"

            # Auto-restart dashboard if it was previously running
            if [[ "${ENABLE_DASHBOARD:-true}" == "true" ]]; then
                log_info "WATCHDOG: Restarting dashboard..."
                DASHBOARD_PID=""
                rm -f "$dashboard_pid_file"
                start_dashboard
            fi
        else
            # Dashboard is alive -- update last-alive timestamp
            DASHBOARD_LAST_ALIVE=$(date +%s)
        fi
    fi

    # Check for zombie/dead agents
    local agents_file=".loki/state/agents.json"
    if [[ -f "$agents_file" ]]; then
        local dead_count=0
        local agent_pids
        agent_pids=$(python3 -c "
import json, sys
try:
    agents = json.load(open('$agents_file'))
    for a in agents:
        pid = a.get('pid')
        status = a.get('status', '')
        if pid and status not in ('terminated', 'completed', 'failed', 'crashed'):
            print(f\"{pid}:{a.get('id','unknown')}\")
except Exception:
    pass
" 2>/dev/null || true)

        if [[ -n "$agent_pids" ]]; then
            while IFS=: read -r apid aid; do
                [[ -z "$apid" ]] && continue
                if ! kill -0 "$apid" 2>/dev/null; then
                    dead_count=$((dead_count + 1))
                    log_warn "WATCHDOG: Agent $aid (PID $apid) is dead"
                    # Update agent status in agents.json
                    python3 -c "
import json
try:
    with open('$agents_file', 'r') as f:
        agents = json.load(f)
    for a in agents:
        if str(a.get('pid')) == '$apid':
            a['status'] = 'crashed'
            a['crashed_at'] = '$(date -u +%Y-%m-%dT%H:%M:%SZ)'
    with open('$agents_file', 'w') as f:
        json.dump(agents, f, indent=2)
except Exception:
    pass
" 2>/dev/null || true
                fi
            done <<< "$agent_pids"

            if [[ $dead_count -gt 0 ]]; then
                emit_event_json "watchdog_alert" \
                    "process=agents" \
                    "dead_count=$dead_count"
            fi
        fi
    fi

    return 0
}

# Check if the loki_complete_task MCP tool was invoked in this iteration.
# The tool writes a payload to .loki/signals/TASK_COMPLETION_CLAIMED with the
# structured completion claim. When the signal exists, we read it, log the
# structured event, and consume (remove) the file. Returns 0 on detection.
#
# v7.4.17: also accepts a file-based fallback at .loki/signals/
# COMPLETION_REQUESTED -- the LLM can `touch` this file directly when the
# MCP tool isn't surfaced in its environment (e.g., harness limitations,
# Codex CLI). User reproduction: the LLM said "the
# loki_complete_task MCP tool isn't loaded in this environment" and
# tried to signal completion via state files; we now honor that.
#
# Output on stdout: the JSON payload (for callers that want to log it).
# _loki_check_claim_grounding: does the completion claim name files this run
# actually changed? Report-only, never a gate.
#
# READS THE SIGNAL FILE WITHOUT CONSUMING IT. check_task_completion_signal below
# owns consumption (rm -f on read); this must run BEFORE that owner and must not
# race it, so it only ever opens the file for reading. Both signal shapes carry
# the text under the same key: the MCP tool writes {"statement": ...}, and the
# COMPLETION_REQUESTED fallback is normalised into the same envelope by the
# owner. One key covers both.
#
# The changed-file set is derived from _LOKI_RUN_START_SHA -- the same baseline
# the evidence gate and the review diff use (run.sh:13817) -- so the receipt and
# the grounding line describe ONE diff. Untracked files are included: a claim
# naming a file the agent created but never staged is grounded, and calling it
# ungrounded would be exactly the false positive this check must never produce.
#
# Passed via --files-from, never --files: --files is a comma-separated list, so
# any path containing a comma would split into two bogus paths, and a large
# changed set would approach ARG_MAX. The module's own comment documents that
# flag's history.
_loki_check_claim_grounding() {
    local lib="${SCRIPT_DIR}/lib/claim_grounding.py"
    [ -f "$lib" ] || return 0
    command -v python3 >/dev/null 2>&1 || return 0

    local target="${TARGET_DIR:-.}"
    local sig="$target/.loki/signals/TASK_COMPLETION_CLAIMED"
    [ -f "$sig" ] || sig="$target/.loki/signals/COMPLETION_REQUESTED"
    [ -f "$sig" ] || return 0

    local claim
    claim=$(python3 -c "
import json, sys
try:
    d = json.load(open(sys.argv[1]))
    sys.stdout.write(str(d.get('statement', '')) if isinstance(d, dict) else '')
except Exception:
    pass
" "$sig" 2>/dev/null || echo "")
    # A signal with no statement (bare touch of COMPLETION_REQUESTED) is
    # UNGROUNDABLE, not a finding. Nothing to check; leave no stale artifact.
    [ -n "$claim" ] || return 0

    local files_tmp="$target/.loki/state/claim-grounding-files.$$"
    mkdir -p "$target/.loki/state" 2>/dev/null || return 0
    {
        if [ -n "${_LOKI_RUN_START_SHA:-}" ] \
           && git -C "$target" rev-parse --verify --quiet "${_LOKI_RUN_START_SHA}^{commit}" >/dev/null 2>&1; then
            git -C "$target" diff --name-only "${_LOKI_RUN_START_SHA}" 2>/dev/null
        else
            git -C "$target" diff --name-only HEAD 2>/dev/null
        fi
        git -C "$target" diff --name-only --cached 2>/dev/null
        git -C "$target" ls-files --others --exclude-standard 2>/dev/null
    } | sort -u > "$files_tmp" 2>/dev/null || { rm -f "$files_tmp" 2>/dev/null; return 0; }

    # Exit 1 means "a named path is absent from the diff" -- the finding itself,
    # not an error. Swallowed: this reports, it never blocks completion.
    python3 "$lib" --claim "$claim" --files-from "$files_tmp" \
        > "$target/.loki/state/claim-grounding.json" 2>/dev/null || true
    rm -f "$files_tmp" 2>/dev/null
    return 0
}

check_task_completion_signal() {
    local signal_file=".loki/signals/TASK_COMPLETION_CLAIMED"
    local fallback_file=".loki/signals/COMPLETION_REQUESTED"

    # Prefer the structured MCP-tool signal if present.
    if [ ! -f "$signal_file" ] && [ -f "$fallback_file" ]; then
        # Fallback path: synthesize a minimal payload from the optional
        # contents of COMPLETION_REQUESTED (LLM may have written a
        # statement; if not, use a generic one).
        local fb_content
        fb_content=$(cat "$fallback_file" 2>/dev/null || echo "")
        local fb_statement="${fb_content:-All PRD requirements implemented and tests passing}"
        # Build minimal JSON payload
        signal_file="$fallback_file"
        # Synthesize the payload into a TEMP file then atomically move it into
        # place, so the signal file is never observed truncated/empty/malformed:
        # a bare `python3 ... > "$fallback_file"` truncates the file BEFORE python
        # runs, so a python crash (or missing interpreter) mid-write would leave
        # an empty/partial file that downstream json.loads then silently drops.
        local _fb_tmp="${fallback_file}.tmp.$$"
        if python3 -c "
import json, sys
sys.stdout.write(json.dumps({
    'statement': sys.argv[1][:1000],
    'evidence': 'file-based completion via COMPLETION_REQUESTED fallback',
    'confidence': 'medium',
    'source': 'completion_requested_file_fallback'
}))" "$fb_statement" > "$_fb_tmp" 2>/dev/null && [ -s "$_fb_tmp" ]; then
            mv -f "$_fb_tmp" "$fallback_file" 2>/dev/null || rm -f "$_fb_tmp" 2>/dev/null
        else
            # python unavailable or produced nothing: write a hand-built minimal
            # valid JSON (statement embedded with a conservative escape) atomically.
            rm -f "$_fb_tmp" 2>/dev/null
            local _fb_esc
            _fb_esc=$(printf '%s' "$fb_statement" | tr -d '"\\\n\r' | cut -c1-1000)
            printf '{"statement":"%s","evidence":"file-based completion via COMPLETION_REQUESTED fallback","confidence":"medium","source":"completion_requested_file_fallback"}' "$_fb_esc" > "${fallback_file}.tmp2.$$" 2>/dev/null \
                && mv -f "${fallback_file}.tmp2.$$" "$fallback_file" 2>/dev/null \
                || { rm -f "${fallback_file}.tmp2.$$" 2>/dev/null; printf '{"statement":"completion requested","evidence":"fallback","confidence":"low","source":"completion_requested_file_fallback"}' > "$fallback_file" 2>/dev/null; }
        fi
    fi

    if [ ! -f "$signal_file" ]; then
        return 1
    fi

    local payload
    payload=$(cat "$signal_file" 2>/dev/null || echo "")
    if [ -z "$payload" ]; then
        # Empty signal -- treat as noise and clean up
        rm -f "$signal_file" 2>/dev/null
        return 1
    fi

    # Emit a structured event for observability (best-effort).
    local statement evidence confidence
    statement=$(python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    print(d.get('statement',''))
except Exception:
    pass
" <<< "$payload" 2>/dev/null || echo "")
    evidence=$(python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    print(d.get('evidence',''))
except Exception:
    pass
" <<< "$payload" 2>/dev/null || echo "")
    confidence=$(python3 -c "
import json, sys
try:
    d = json.loads(sys.stdin.read())
    print(d.get('confidence','medium'))
except Exception:
    print('medium')
" <<< "$payload" 2>/dev/null || echo "medium")

    emit_event_json "task_completion_claim" \
        "statement=${statement:0:500}" \
        "confidence=${confidence}" \
        "evidence_length=${#evidence}"

    # Return the payload on stdout
    printf '%s\n' "$payload"

    # Consume the signal (next iteration would otherwise re-trigger).
    # Also remove the fallback if it coexists: TASK_COMPLETION_CLAIMED and
    # COMPLETION_REQUESTED are both valid, non-exclusive completion mechanisms, so
    # a belt-and-suspenders agent can leave both present. Removing only the active
    # one orphans the other, which then reads as a phantom claim on a later
    # iteration and forces every-iteration council evaluation. Consume both.
    rm -f "$signal_file" 2>/dev/null
    rm -f "$fallback_file" 2>/dev/null
    return 0
}

# Check if completion promise is fulfilled in log output.
#
# As of v6.82.0, the default path is the MCP tool `loki_complete_task`
# (detected via check_task_completion_signal above). The legacy grep-based
# detection is retained behind LOKI_LEGACY_COMPLETION_MATCH=true for rollback.
check_completion_promise() {
    local log_file="$1"

    # New default: structured signal from the loki_complete_task MCP tool.
    if check_task_completion_signal >/dev/null 2>&1; then
        return 0
    fi

    # Legacy grep fallback (opt-in via env flag for rollback).
    if [ "${LOKI_LEGACY_COMPLETION_MATCH:-false}" = "true" ]; then
        if grep -q "COMPLETION PROMISE FULFILLED" "$log_file" 2>/dev/null; then
            return 0
        fi
        if [ -n "$COMPLETION_PROMISE" ] && grep -qF "$COMPLETION_PROMISE" "$log_file" 2>/dev/null; then
            return 0
        fi
    fi

    return 1
}

# Check if max iterations reached
# EVIDENCE-AWARE ITERATION CAP.
#
# The cap used to be a bare counter: it consulted no gate, no council, and no
# evidence. A run one step from finishing was cut off identically to a run
# thrashing in circles, and both reported the same terminal.
#
# An iteration count is a PROXY for "is this converging". Where real evidence
# exists, prefer the evidence. Two signals are already on disk at this point:
#
#   1. the model's own completion request (.loki/signals/COMPLETION_REQUESTED),
#      which the agent writes when it believes the work is done
#   2. gate state (.loki/quality/gate-failures.txt), which says whether the
#      last verification pass actually found anything
#
# When the model says it is done AND no gate is failing, the run gets ONE extra
# iteration to land it. That is the difference between a finished product and a
# terminal failure at the buzzer.
#
# WHY THIS CANNOT LOOP FOREVER, which is the only thing that matters here:
# the grace is granted at most once per run (a marker file, checked before it
# is written), it requires POSITIVE evidence rather than the absence of a
# signal, and it extends by exactly one iteration. A run that keeps claiming
# done without finishing gets the cap, once, and then stops. Published
# measurements put automated-verifier false-negative rates near 24%, so an
# unbounded verifier-driven loop would burn real money on already-correct work.
# This is deliberately a bounded nudge, not a verifier-driven terminal.
#
# LOKI_ITERATION_GRACE=0 restores the pure counter.
_iteration_grace_available() {
    [ "${LOKI_ITERATION_GRACE:-1}" != "0" ] || return 1

    local _loki_root="${TARGET_DIR:-.}/.loki"
    local _marker="$_loki_root/state/iteration-grace-used"
    [ -f "$_marker" ] && return 1

    # POSITIVE evidence the model believes it is done. Absence is not evidence.
    [ -f "$_loki_root/signals/COMPLETION_REQUESTED" ] || return 1

    # ...and nothing is currently failing. A non-empty gate-failures.txt means
    # the last verification pass found real problems, so a "done" claim on top
    # of it is exactly the case the cap should still stop.
    local _gf="$_loki_root/quality/gate-failures.txt"
    if [ -s "$_gf" ]; then
        return 1
    fi

    mkdir -p "$_loki_root/state" 2>/dev/null || true
    printf 'granted at iteration %s\n' "${ITERATION_COUNT:-0}" > "$_marker" 2>/dev/null || true
    return 0
}

check_max_iterations() {
    if [ $ITERATION_COUNT -ge $MAX_ITERATIONS ]; then
        if _iteration_grace_available; then
            MAX_ITERATIONS=$((MAX_ITERATIONS + 1))
            log_info "Iteration cap reached, but the agent reports done with no failing gate -- granting ONE final iteration to land it (once per run; LOKI_ITERATION_GRACE=0 to disable)."
            return 1
        fi
        log_warn "Max iterations ($MAX_ITERATIONS) reached. Stopping."
        return 0
    fi
    return 1
}

# WALL-CLOCK CAP. Returns 0 (stop) when the run has exceeded LOKI_MAX_DURATION
# seconds. Unset or 0 = no cap, which is the default and exactly today's
# behavior.
#
# WHY A THIRD BOUND. Spend was already capped (LOKI_BUDGET_LIMIT) and so were
# iterations (LOKI_MAX_ITERATIONS), but a run that STALLS is bounded by neither:
# a hung provider call or a wedged subprocess burns hours while spending almost
# nothing and completing no iteration, so neither existing breaker ever trips.
# It runs until something external kills it -- and an external kill leaves no
# terminal status, so the receipt cannot say what happened.
#
# Kubernetes operators already have activeDeadlineSeconds in the Job spec, but
# that SIGKILLs the pod: no status is written, no receipt, and the platform
# sees a crash rather than a deliberate stop. This cap stops the loop cleanly
# at the next iteration boundary so the run still explains itself.
#
# It is checked at the boundary, not mid-iteration: interrupting an agent
# mid-write is how you get a half-applied change. So the effective stop time is
# the cap plus the remainder of the current iteration, which the log states
# rather than pretending to be exact.
check_max_duration() {
    local cap="${LOKI_MAX_DURATION:-0}"
    case "$cap" in
        ''|*[!0-9]*) return 1 ;;   # unset or non-numeric: no cap, never stop
    esac
    [ "$cap" -eq 0 ] && return 1

    local start="${_LOKI_RUN_START_EPOCH:-0}"
    [ "$start" -eq 0 ] 2>/dev/null && return 1

    local now elapsed
    now=$(date +%s)
    elapsed=$((now - start))
    if [ "$elapsed" -ge "$cap" ]; then
        log_warn "Wall-clock cap reached (${elapsed}s elapsed, LOKI_MAX_DURATION=${cap}s). Stopping at the iteration boundary."
        return 0
    fi
    return 1
}

# Load latest ledger content for context injection
load_ledger_context() {
    local ledger_content=""

    # Find most recent ledger
    local latest_ledger=$(ls -t .loki/memory/ledgers/LEDGER-*.md 2>/dev/null | head -1)

    if [ -n "$latest_ledger" ] && [ -f "$latest_ledger" ]; then
        ledger_content=$(cat "$latest_ledger" | head -100)
        echo "$ledger_content"
    fi
}

# BUG-RUN-006: Removed duplicate load_handoff_context() (dead definition)
# The active definition is below, after write_structured_handoff()

# Write structured handoff document (v5.49.0)
# Produces both JSON (machine-readable) and markdown (human-readable) handoffs
# Called at end of session or before context clear
write_structured_handoff() {
    local reason="${1:-session_end}"
    local handoff_dir=".loki/memory/handoffs"
    mkdir -p "$handoff_dir"

    local timestamp
    timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    local file_ts
    file_ts=$(date +"%Y%m%d-%H%M%S")
    local handoff_json="$handoff_dir/${file_ts}.json"
    local handoff_md="$handoff_dir/${file_ts}.md"

    # Gather structured data
    local files_modified=""
    files_modified=$(git diff --name-only HEAD 2>/dev/null | head -20 | tr '\n' ',' | sed 's/,$//')
    local recent_commits=""
    recent_commits=$(git log --oneline -5 2>/dev/null | tr '\n' '|' | sed 's/|$//')
    local pending_tasks=0
    local completed_tasks=0
    if [ -f ".loki/queue/pending.json" ]; then
        pending_tasks=$(_QF=".loki/queue/pending.json" python3 -c "import json,os;print(len(json.load(open(os.environ['_QF']))))" 2>/dev/null || echo "0")
    fi
    if [ -f ".loki/queue/completed.json" ]; then
        completed_tasks=$(_QF=".loki/queue/completed.json" python3 -c "import json,os;print(len(json.load(open(os.environ['_QF']))))" 2>/dev/null || echo "0")
    fi

    # Write JSON handoff
    _H_TS="$timestamp" \
    _H_REASON="$reason" \
    _H_ITER="${ITERATION_COUNT:-0}" \
    _H_FILES="$files_modified" \
    _H_COMMITS="$recent_commits" \
    _H_PENDING="$pending_tasks" \
    _H_COMPLETED="$completed_tasks" \
    _H_JSON="$handoff_json" \
    python3 -c "
import json, os
handoff = {
    'schema_version': '1.0.0',
    'timestamp': os.environ['_H_TS'],
    'reason': os.environ['_H_REASON'],
    'iteration': int(os.environ['_H_ITER']),
    'files_modified': [f for f in os.environ['_H_FILES'].split(',') if f],
    'recent_commits': [c for c in os.environ['_H_COMMITS'].split('|') if c],
    'task_status': {
        'pending': int(os.environ['_H_PENDING']),
        'completed': int(os.environ['_H_COMPLETED'])
    },
    'open_questions': [],
    'key_decisions': [],
    'blockers': []
}
with open(os.environ['_H_JSON'], 'w') as f:
    json.dump(handoff, f, indent=2)
" 2>/dev/null || log_warn "Failed to write structured handoff JSON"

    # Write markdown companion
    cat > "$handoff_md" << HANDOFF_EOF
# Session Handoff - $timestamp

**Reason:** $reason
**Iteration:** ${ITERATION_COUNT:-0}

## Files Modified
$files_modified

## Recent Commits
$(git log --oneline -5 2>/dev/null || echo "none")

## Task Status
- Pending: $pending_tasks
- Completed: $completed_tasks

## Notes
Session handoff generated automatically.
HANDOFF_EOF

    log_info "Structured handoff written to $handoff_json"
}

# Load recent handoffs for context (reads both JSON and markdown)
load_handoff_context() {
    local handoff_content=""

    # Prefer JSON handoffs (structured, v5.49.0+)
    local recent_json
    recent_json=$(find .loki/memory/handoffs -name "*.json" -mtime -1 2>/dev/null | sort -r | head -1)

    if [ -n "$recent_json" ] && [ -f "$recent_json" ]; then
        handoff_content=$(_HF="$recent_json" python3 -c "
import json, os
try:
    h = json.load(open(os.environ['_HF']))
    parts = []
    parts.append(f\"Handoff from {h.get('timestamp','unknown')} (reason: {h.get('reason','unknown')})\")
    parts.append(f\"Iteration: {h.get('iteration',0)}\")
    files = h.get('files_modified', [])
    if files:
        parts.append(f\"Modified files: {', '.join(files[:10])}\")
    tasks = h.get('task_status', {})
    parts.append(f\"Tasks - pending: {tasks.get('pending',0)}, completed: {tasks.get('completed',0)}\")
    for q in h.get('open_questions', []):
        parts.append(f\"Open question: {q}\")
    for b in h.get('blockers', []):
        parts.append(f\"Blocker: {b}\")
    print(' | '.join(parts))
except Exception as e:
    print(f'Error reading handoff: {e}')
" 2>/dev/null)
        echo "$handoff_content"
        return
    fi

    # Fallback to markdown handoffs (pre-v5.49.0)
    local recent_handoff
    recent_handoff=$(find .loki/memory/handoffs -name "*.md" -mtime -1 2>/dev/null | sort -r | head -1)

    if [ -n "$recent_handoff" ] && [ -f "$recent_handoff" ]; then
        handoff_content=$(cat "$recent_handoff" | head -80)
        echo "$handoff_content"
    fi
}

# Load relevant learnings
# Load pre-computed relevant learnings from CLI startup (SYN-008)
# Reads .loki/state/memory-context.json written by load_memory_context() in CLI
# Note: Different from get_relevant_learnings() which writes to relevant-learnings.json
load_startup_learnings() {
    local learnings_file=".loki/state/memory-context.json"
    local target_dir="${TARGET_DIR:-.}"

    # Check if file exists (written by CLI at startup)
    if [ ! -f "$target_dir/$learnings_file" ]; then
        return
    fi

    # Parse and format the pre-loaded memories with JSON schema validation
    python3 -c "
import sys
import json

def validate_memory_context_schema(data):
    '''Validate JSON has expected schema for memory-context.json'''
    # Check required top-level keys
    if not isinstance(data, dict):
        return False, 'Root must be an object'

    required_keys = ['memory_count', 'memories']
    for key in required_keys:
        if key not in data:
            return False, f'Missing required key: {key}'

    # Validate types
    if not isinstance(data.get('memory_count'), int):
        return False, 'memory_count must be an integer'
    if not isinstance(data.get('memories'), list):
        return False, 'memories must be an array'

    # Validate memory items
    for i, m in enumerate(data.get('memories', [])):
        if not isinstance(m, dict):
            return False, f'memories[{i}] must be an object'
        # Optional: validate expected fields exist
        for field in ['source', 'score', 'summary']:
            if field in m:
                # Just check they're the right types if present
                if field == 'score' and not isinstance(m[field], (int, float)):
                    return False, f'memories[{i}].score must be a number'

    return True, None

try:
    with open('$target_dir/$learnings_file', 'r') as f:
        data = json.load(f)

    # Validate schema before using
    valid, error = validate_memory_context_schema(data)
    if not valid:
        sys.stderr.write(f'Invalid memory-context.json schema: {error}\\n')
        sys.exit(0)

    memories = data.get('memories', [])
    if not memories:
        sys.exit(0)

    print('STARTUP LEARNINGS (pre-loaded):')
    for m in memories[:5]:
        source = m.get('source', 'unknown')
        summary = m.get('summary', '')[:100]
        score = m.get('score', 0)
        if summary:
            print(f'- [{source}|{score}] {summary}')
except json.JSONDecodeError as e:
    sys.stderr.write(f'Invalid JSON in memory-context.json: {e}\\n')
except Exception as e:
    pass  # Silently fail for other errors
" 2>/dev/null
}

#===============================================================================
# Memory System Integration
#===============================================================================

# Retrieve relevant memories from the new memory system
retrieve_memory_context() {
    local goal="$1"
    local phase="$2"
    local target_dir="${TARGET_DIR:-.}"

    # Check if memory system is available
    if [ ! -d "$target_dir/.loki/memory" ] || [ ! -f "$target_dir/.loki/memory/index.json" ]; then
        return
    fi

    # Use Python to retrieve relevant context
    # Pass parameters via environment variables to prevent command injection
    _LOKI_PROJECT_DIR="$PROJECT_DIR" _LOKI_TARGET_DIR="$target_dir" \
    _LOKI_GOAL="$goal" _LOKI_PHASE="$phase" \
    _LOKI_FAILURE_MEMORY="${LOKI_FAILURE_MEMORY:-1}" \
    python3 << 'PYEOF' 2>/dev/null
import sys
import os

project_dir = os.environ.get('_LOKI_PROJECT_DIR', '')
target_dir = os.environ.get('_LOKI_TARGET_DIR', '.')
goal = os.environ.get('_LOKI_GOAL', '')
phase = os.environ.get('_LOKI_PHASE', '')

sys.path.insert(0, project_dir)
try:
    from memory.retrieval import MemoryRetrieval
    from memory.storage import MemoryStorage
    import json
    storage = MemoryStorage(f'{target_dir}/.loki/memory')
    retriever = MemoryRetrieval(storage)
    context = {'goal': goal, 'phase': phase}
    # The autonomous RARV loop opts into persist_boost so retrieved memories are
    # reinforced on disk ("use it or lose it"). Manual surfaces (loki memory CLI,
    # dashboard, MCP) keep the default persist_boost=False so a human browsing
    # memories does not silently inflate their importance.
    results = retriever.retrieve_task_aware(context, top_k=3, persist_boost=True)
    if results:
        print('RELEVANT MEMORIES:')
        for r in results[:3]:
            summary = r.get('summary', r.get('pattern', ''))[:100]
            source = r.get('source', 'memory')
            print(f'- [{source}] {summary}')
    # CONNECTOR B (failure-memory loop): surface the most recent FAILURE
    # episodes by recency. Within a run the goal is constant, so the correct
    # retrieval key is "what did I just fail at" (recency), not goal-similarity.
    # Default-on knob LOKI_FAILURE_MEMORY; no-op when 0.
    if os.environ.get('_LOKI_FAILURE_MEMORY', '1') != '0':
        try:
            from memory.storage import MemoryStorage as _MS
            from memory.schemas import EpisodeTrace as _ET
            from datetime import datetime as _dt, timezone as _tz, timedelta as _td
            _s = storage if 'storage' in dir() else _MS(f'{target_dir}/.loki/memory')
            _since = _dt.now(_tz.utc) - _td(hours=24)
            _lessons = []
            for _eid in _s.list_episodes(since=_since, limit=50):
                _data = _s.load_episode(_eid)
                _ep = _ET.from_dict(_data) if isinstance(_data, dict) else _data
                if getattr(_ep, 'outcome', '') != 'failure':
                    continue
                # Sort key: the episode's own timestamp (wall-clock), NOT the
                # list_episodes order. list_episodes is newest-DAY first, but
                # within a day files sort by a random uuid suffix in the id
                # (schemas.py id = date + uuid8), so same-day order does NOT
                # follow wall-clock. In a long run with >3 same-day failures
                # (the target scenario) that would drop the most-recent lesson.
                # Sorting by the timestamp field gives true recency.
                _ts = getattr(_ep, 'timestamp', None)
                _ts_key = _ts.isoformat() if hasattr(_ts, 'isoformat') else str(_ts or '')
                for _e in getattr(_ep, 'errors_encountered', []):
                    _lessons.append((_ts_key, _e.error_type, _e.message))
            # Newest first by true wall-clock timestamp, then take 3.
            _lessons.sort(key=lambda _x: _x[0], reverse=True)
            _lessons = [(_t, _m) for (_k, _t, _m) in _lessons[:3]]
            if _lessons:
                print('')
                print('PAST FAILURES TO AVOID:')
                for _t, _m in _lessons:
                    _line = '- ' + str(_t)[:80]
                    if _m:
                        _line += ': ' + str(_m)[:160]
                    print(_line)
        except Exception:
            pass
        # Best-effort cross-run secondary (mostly empty locally; harmless).
        try:
            _anti = retriever.retrieve_anti_patterns((goal + ' ' + phase).strip() or goal, top_k=3)
            for _a in _anti[:3]:
                _w = _a.get('what_fails') or _a.get('incorrect_approach') or _a.get('pattern', '')
                if _w:
                    print('- (prior) ' + str(_w)[:120])
        except Exception:
            pass
except Exception as e:
    pass  # Silently fail if memory not available
PYEOF

    # v6.83.0 Phase 1: RARV-C REASON augment. When both managed flags are on,
    # pull related prior verdicts from the Claude Managed Agents store and
    # append them AFTER local results. 5s hard timeout so a slow remote never
    # blocks the loop. On timeout or error, emit a fallback event and continue.
    if [ "$LOKI_MANAGED_AGENTS" = "true" ] && [ "$LOKI_MANAGED_MEMORY" = "true" ]; then
        local managed_start_ms
        managed_start_ms=$(python3 -c "import time; print(int(time.time()*1000))" 2>/dev/null || echo "0")
        local managed_out
        managed_out=$(
            cd "$PROJECT_DIR" 2>/dev/null && \
            LOKI_TARGET_DIR="$target_dir" \
            timeout 5 python3 -m memory.managed_memory.retrieve \
                --query "$goal" --top-k 3 2>/dev/null || true
        )
        if [ -n "$managed_out" ]; then
            echo ""
            echo "RELATED PRIOR LEARNINGS (managed store):"
            echo "$managed_out"
        else
            # No output could mean: flags off (unreachable here), timeout, or
            # zero hits. Emit a fallback event only if a timeout likely occurred.
            LOKI_TARGET_DIR="$target_dir" \
            python3 -c "from memory.managed_memory.events import emit_managed_event; emit_managed_event('managed_memory_retrieve_empty', {'phase': '$phase'})" 2>/dev/null || true
        fi
    fi
}

# Store episode trace after task completion
store_episode_trace() {
    local task_id="$1"
    local outcome="$2"
    local phase="$3"
    local goal="$4"
    local duration="$5"
    local target_dir="${TARGET_DIR:-.}"

    # Only store if memory system exists
    if [ ! -d "$target_dir/.loki/memory" ]; then
        return
    fi

    # Pass parameters via environment variables to prevent command injection
    _LOKI_PROJECT_DIR="$PROJECT_DIR" _LOKI_TARGET_DIR="$target_dir" \
    _LOKI_TASK_ID="$task_id" _LOKI_OUTCOME="$outcome" _LOKI_PHASE="$phase" \
    _LOKI_GOAL="$goal" _LOKI_DURATION="$duration" \
    python3 << 'PYEOF' 2>/dev/null
import sys
import os

project_dir = os.environ.get('_LOKI_PROJECT_DIR', '')
target_dir = os.environ.get('_LOKI_TARGET_DIR', '.')
task_id = os.environ.get('_LOKI_TASK_ID', '')
outcome = os.environ.get('_LOKI_OUTCOME', '')
phase = os.environ.get('_LOKI_PHASE', '')
goal = os.environ.get('_LOKI_GOAL', '')
duration = os.environ.get('_LOKI_DURATION', '0')

sys.path.insert(0, project_dir)
try:
    from memory.engine import MemoryEngine
    from memory.schemas import EpisodeTrace
    from datetime import datetime, timezone
    # base_path= is required: MemoryEngine.__init__(self, storage=None, base_path=...)
    # takes `storage` first, so a bare positional path was assigned to
    # self.storage and engine.initialize() crashed on str.ensure_directory,
    # silently dropping every store_episode_trace into the except handler.
    engine = MemoryEngine(base_path=f'{target_dir}/.loki/memory')
    engine.initialize()
    trace = EpisodeTrace.create(
        task_id=task_id,
        agent='loki-orchestrator',
        phase=phase,
        goal=goal,
        outcome=outcome,
        duration_seconds=int(duration) if duration.isdigit() else 0
    )
    engine.store_episode(trace)
except Exception as e:
    # v7.7.17: replace silent-fail with structured log to .errors.log.
    # log_memory_error itself never raises (it has its own try/except).
    try:
        from memory.error_log import log_memory_error
        log_memory_error(f'{target_dir}/.loki/memory', 'store_episode_trace', e)
    except Exception:
        pass
PYEOF
}

# v7.7.3 F-3 fix: intelligent USAGE.md regeneration. Called at session end
# (after completion-promise fulfilled). Reads the FINAL project state
# (file tree + package manifests + recent commits) and asks Claude
# (haiku tier) to emit a USAGE.md tailored to the actual stack.
#
# Best-effort: any failure (no provider, network, parse) returns silently
# without disrupting completion. Costs ~$0.01-0.05 per session (one
# haiku call). Set LOKI_INTELLIGENT_USAGE=0 to skip entirely.
_intelligent_usage_regen() {
    local target_dir="${TARGET_DIR:-.}"
    local usage_path="$target_dir/USAGE.md"
    # Find a working `claude` binary; if absent, bail silently.
    if ! command -v claude >/dev/null 2>&1; then
        return 0
    fi
    # Snapshot project state. Keep it small (< ~4 KiB) so the prompt
    # stays cache-stable across sessions.
    local _tree _manifests _commits _state_prompt
    _tree=$(cd "$target_dir" && find . -maxdepth 3 -type f \
        -not -path './node_modules/*' -not -path './.loki/*' \
        -not -path './.git/*' -not -path './venv/*' -not -path './.venv/*' \
        -not -path './dist/*' -not -path './build/*' 2>/dev/null | head -30)
    # Capture package manifests inline so the model sees real scripts.
    _manifests=""
    for f in package.json requirements.txt pyproject.toml Cargo.toml go.mod composer.json Gemfile; do
        if [ -f "$target_dir/$f" ]; then
            _manifests="${_manifests}=== $f ===\n$(head -50 "$target_dir/$f" 2>/dev/null)\n\n"
        fi
    done
    # v7.7.10 F-3 fix: include entrypoint file content so the model can read
    # the ACTUAL port / host bindings instead of guessing from package.json
    # scripts (which often imply port 3000 by convention but server.js may
    # bind a different port like 3001). Without this the regen wrote
    # "curl http://localhost:3000" for projects where the server bound 3001.
    local _entrypoints=""
    local _ep_candidates=""
    # Detect entrypoint from package.json main field if present
    if [ -f "$target_dir/package.json" ]; then
        local _pkg_main
        _pkg_main=$(python3 -c "import json,sys; d=json.load(open('$target_dir/package.json'));print(d.get('main') or '')" 2>/dev/null)
        [ -n "$_pkg_main" ] && _ep_candidates="$_ep_candidates $_pkg_main"
        # Extract files referenced in `scripts.start` and `scripts.dev`
        local _pkg_scripts
        _pkg_scripts=$(python3 -c "import json,re,sys; d=json.load(open('$target_dir/package.json'));s=d.get('scripts',{});c=' '.join([s.get('start',''),s.get('dev','')]);[print(t) for t in re.findall(r'[\\w/.-]+\\.(?:js|mjs|cjs|ts|mts|cts|py)\\b',c)]" 2>/dev/null)
        [ -n "$_pkg_scripts" ] && _ep_candidates="$_ep_candidates $_pkg_scripts"
    fi
    # Fallback convention names for common stacks
    for _ep in server.js server.ts server.mjs index.js index.ts app.js app.ts \
               main.py app.py server.py manage.py wsgi.py asgi.py \
               main.go cmd/server/main.go src/main.rs src/index.ts dist/server.js \
               build/server.js; do
        _ep_candidates="$_ep_candidates $_ep"
    done
    # Read first 80 lines of up to 3 unique existing candidates, scrubbing
    # common secret-bearing lines before they ship to the haiku endpoint.
    # v7.7.10 privacy guard: replaces lines matching API_KEY/SECRET/PASSWORD/
    # TOKEN/PRIVATE_KEY/AUTH/CREDENTIAL/BEARER assignments with [REDACTED]
    # so default-on regen does not exfiltrate hardcoded secrets. Port-binding
    # lines (listen/run/ListenAndServe with numeric literals) are preserved.
    # Opt out entirely with LOKI_INTELLIGENT_USAGE_INCLUDE_SOURCE=0.
    local _include_source="${LOKI_INTELLIGENT_USAGE_INCLUDE_SOURCE:-1}"
    local _seen="" _count=0
    for _ep in $_ep_candidates; do
        # Skip duplicates and non-existent files
        case " $_seen " in *" $_ep "*) continue ;; esac
        _seen="$_seen $_ep"
        if [ -f "$target_dir/$_ep" ]; then
            local _ep_body
            if [ "$_include_source" = "0" ]; then
                _ep_body="(entrypoint source omitted: LOKI_INTELLIGENT_USAGE_INCLUDE_SOURCE=0)"
            else
                # Scrub: any line whose text contains a credential keyword
                # has its value (everything after the first `:` or `=`)
                # replaced with [REDACTED]. Then any literal high-entropy
                # token shape (stripe sk-, github ghp_/ghs_, slack xox, GCP
                # AIza, AWS AKIA) is replaced inline. Port-binding lines
                # (no credential keyword) pass through unchanged.
                _ep_body=$(head -80 "$target_dir/$_ep" 2>/dev/null \
                    | sed -E \
                        -e '/[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Tt][Oo][Kk][Ee][Nn]|[Pp][Rr][Ii][Vv][Aa][Tt][Ee][_-]?[Kk][Ee][Yy]|[Cc][Rr][Ee][Dd][Ee][Nn][Tt][Ii][Aa][Ll]|[Bb][Ee][Aa][Rr][Ee][Rr]/ s/[:=].*$/= [REDACTED]/' \
                        -e 's/(sk-[A-Za-z0-9_-]{16,}|pk_[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{16,}|ghs_[A-Za-z0-9]{16,}|xox[bpoa]-[A-Za-z0-9-]{16,}|AIza[A-Za-z0-9_-]{32,}|AKIA[A-Z0-9]{12,})/[REDACTED]/g')
            fi
            _entrypoints="${_entrypoints}=== Entrypoint: $_ep ===\n${_ep_body}\n\n"
            _count=$((_count + 1))
            [ "$_count" -ge 3 ] && break
        fi
    done
    _commits=$(cd "$target_dir" && git log --oneline -10 2>/dev/null || true)

    log_info "Regenerating USAGE.md from final project state (intelligent mode)..."
    local _ic_prompt="You are writing a USAGE.md for the project below. Detect the stack from the manifest files; emit a concise (under 100 lines) Markdown doc with sections: ## Prerequisites, ## Install, ## Start, ## Verify (2-3 copy-paste curl/browser/CLI commands with expected output), ## Stop. Use the ACTUAL command names from package.json scripts or pyproject entry points -- never generic placeholders. For ports, read the ENTRYPOINT file contents below (server.listen / app.listen / app.run / http.ListenAndServe / uvicorn.run port arg) -- do NOT infer port from script names or convention. If the entrypoint reads from process.env.PORT with a literal default, use the literal default. Output ONLY the Markdown body (no code-fence wrapper, no preamble).

=== Project tree (max 30 files, 3 levels deep) ===
${_tree}

=== Manifest files ===
${_manifests}

=== Entrypoint file contents (port bindings live here, NOT in package.json) ===
${_entrypoints}

=== Last 10 commits ===
${_commits}"

    # Use haiku for cheap, fast generation. --dangerously-skip-permissions
    # because this is a one-shot non-interactive call.
    # EMBED 2 (v7.33.0): --bare on this cheap NON-MAIN haiku subcall. The
    # USAGE.md-regen prompt ($_ic_prompt, piped via -p -) is fully self-contained
    # (project tree + manifests + entrypoint contents + commits inlined) and the
    # output is captured, not written by the agent. No hooks/LSP/CLAUDE.md/MCP
    # needed, so --bare is safe and cheaper. Opt out LOKI_BARE_SUBCALLS=0.
    # Always at least --dangerously-skip-permissions, so the array is never
    # empty (empty "${arr[@]}" under set -u errors on bash 3.2, stock macOS).
    local _ic_argv=("--dangerously-skip-permissions")
    if type loki_subcall_bare_enabled >/dev/null 2>&1 && loki_subcall_bare_enabled; then
        _ic_argv+=("--bare")
    fi
    _ic_argv+=("--model" "haiku")
    # caveman HARD-SUPPRESS (parsed output): the regen output is validated to be
    # Markdown (grep '^#') and written verbatim to USAGE.md. Compressed prose
    # would fail that check or produce caveman-style USAGE text, so disable
    # caveman unconditionally. Inlined on `claude` only (does not cross the pipe
    # to head). No-op when caveman is absent.
    local _ic_out
    _ic_out=$(printf '%s' "$_ic_prompt" \
        | timeout 60 env CAVEMAN_DEFAULT_MODE=off claude "${_ic_argv[@]}" -p - 2>/dev/null \
        | head -200)
    # Sanity check: response must look like Markdown (starts with # or ##).
    if [ -z "$_ic_out" ] || ! printf '%s' "$_ic_out" | head -1 | grep -qE '^#'; then
        log_info "Intelligent USAGE regen returned non-Markdown or empty; keeping existing USAGE.md."
        return 0
    fi
    printf '%s\n' "$_ic_out" > "$usage_path"
    log_info "USAGE.md regenerated intelligently from final project state -> $usage_path"
    return 0
}


# Auto-wiki regeneration after an iteration (v7.88.2). Mirrors the
# _intelligent_usage_regen contract: best-effort, NON-blocking, never fails the
# iteration (every path returns 0; the caller also guards with `|| true`).
#
# Default-ON; opt out with LOKI_WIKI_AUTO=0. Off-TTY / CI safe: no prompts, no
# stream-fighting, byte-identical behavior (no TTY checks, no interactive paths;
# the generator is deterministic given the same codebase index).
#
# INCREMENTAL change-detection (E3): regenerating the wiki walks + hashes the
# source set, which is wasted work on an iteration that did not change the
# codebase structure. We avoid even spawning python on an unchanged repo with a
# cheap bash-level signal: a hash over the source file list and their mtimes,
# cached at .loki/wiki/.auto-hash. We only invoke the generator when that hash
# differs from the cached value. The generator ALSO has its own content-hash
# signature gate (wiki-manifest.json), so this is a fast pre-filter, not the
# only guard -- if the cheap signal ever misfires, the generator still skips a
# truly-unchanged codebase. The cheap signal uses find (file list + mtimes),
# which is far cheaper than the generator's full byte-hash of every file.
_auto_wiki_regen() {
    # Opt-out honored first so a disabled run does zero work.
    if [ "${LOKI_WIKI_AUTO:-1}" = "0" ]; then
        return 0
    fi
    local target_dir="${TARGET_DIR:-.}"
    # python3 is required for the generator; bail silently if absent.
    if ! command -v python3 >/dev/null 2>&1; then
        return 0
    fi
    local gen="$SCRIPT_DIR/lib/wiki-generator.py"
    if [ ! -f "$gen" ]; then
        return 0
    fi

    # Cheap structure signal: a hash over (path, mtime, size) of every source
    # file, excluding the usual noise dirs (and .loki itself, so writing the
    # wiki never changes the signal). Computed with a small stat-only python
    # walk -- portable across macOS/BSD and Linux (GNU vs BSD `find -printf`
    # diverge), and far cheaper than the generator's full byte-hash of every
    # file because it reads no file contents. python3 is already required above.
    local hash_file="$target_dir/.loki/wiki/.auto-hash"
    local cur_hash
    cur_hash=$(python3 - "$target_dir" <<'PY' 2>/dev/null || true
import hashlib, os, sys
root = sys.argv[1]
SKIP = {"node_modules", ".git", ".loki", "dist", "build", "venv", ".venv",
        "__pycache__", "target", "vendor", ".next", ".cache", "out"}
h = hashlib.sha256()
for dirpath, dirnames, filenames in os.walk(root):
    dirnames[:] = sorted(d for d in dirnames if d not in SKIP)
    for fn in sorted(filenames):
        p = os.path.join(dirpath, fn)
        try:
            st = os.stat(p)
        except OSError:
            continue
        rel = os.path.relpath(p, root)
        h.update(("%s|%d|%d\n" % (rel, int(st.st_mtime), st.st_size)).encode("utf-8", "replace"))
sys.stdout.write(h.hexdigest())
PY
)
    if [ -n "$cur_hash" ] && [ -f "$hash_file" ]; then
        local prev_hash
        prev_hash=$(cat "$hash_file" 2>/dev/null || echo "")
        if [ "$cur_hash" = "$prev_hash" ]; then
            # Structure unchanged since the last wiki -- skip without spawning
            # the generator. No token burn on an unchanged repo.
            return 0
        fi
    fi

    log_info "Auto-regenerating project wiki (codebase structure changed)..."
    # The generator is deterministic and self-incremental; --quiet keeps the
    # iteration log clean. Never let a wiki failure surface to the iteration.
    if python3 "$gen" --root "$target_dir" --quiet >/dev/null 2>&1; then
        # Record the cheap signal only on a successful generation so a failed
        # run is retried next iteration rather than silently skipped.
        if [ -n "$cur_hash" ]; then
            mkdir -p "$target_dir/.loki/wiki" 2>/dev/null || true
            printf '%s\n' "$cur_hash" > "$hash_file" 2>/dev/null || true
        fi
        log_info "Project wiki refreshed -> $target_dir/.loki/wiki/"
    else
        log_info "Auto-wiki regen skipped (generator returned non-zero); keeping existing wiki."
    fi
    return 0
}


# Magic Modules COMPOUND: record successful component patterns (v6.77.0)
# Called at end of each iteration to capture generated/updated components
# as semantic memory patterns via magic.core.memory_bridge.
_magic_compound_capture() {
    local registry="$TARGET_DIR/.loki/magic/registry.json"
    if [ ! -f "$registry" ]; then
        return 0
    fi
    # Delegate to memory_bridge (built by agent 3)
    PYTHONPATH="$PROJECT_DIR" python3 -c "
try:
    from magic.core.memory_bridge import capture_iteration_compound
    capture_iteration_compound('${TARGET_DIR}', iteration=${ITERATION_COUNT:-0})
except Exception as exc:
    pass
" 2>/dev/null || true
}

# Automatic episode capture with enriched context (v6.15.0)
# Captures git changes, files modified, and RARV phase automatically
# after every iteration -- no manual invocation needed.
auto_capture_episode() {
    local iteration="$1"
    local exit_code="$2"
    local rarv_phase="$3"
    local goal="$4"
    local duration="$5"
    local log_file="$6"
    local target_dir="${TARGET_DIR:-.}"

    # Only capture if memory system exists
    if [ ! -d "$target_dir/.loki/memory" ]; then
        return
    fi

    # v7.6.4 B-3a fix: previously `git diff --name-only HEAD` only captured
    # UNSTAGED changes -- always empty after loki's per-iteration auto-commit
    # rolled the new files into HEAD. Now diff against the iteration-start
    # SHA captured at the top of the retry loop. Falls back to HEAD~1 if the
    # start SHA env is unset (older direct callers).
    # v7.7.7 fix: previously only captured files when target_dir was a git
    # repo. Real-user test on /tmp/loki-validate (no git init) produced
    # `files_modified: []` because git rev-parse failed silently and the
    # fallback also required git. Now: detect git-vs-non-git up front, and
    # for non-git dirs use a `find` snapshot diff against the timestamp
    # captured when loki created .loki/ (initialized_at). Skips standard
    # noise dirs (.loki, node_modules, .git, venv, .venv, dist, build).
    local files_modified=""
    local _diff_base="${_LOKI_ITER_START_SHA:-}"
    local _is_git=0
    if (cd "$target_dir" && git rev-parse --is-inside-work-tree >/dev/null 2>&1); then
        _is_git=1
    fi
    if [ "$_is_git" -eq 1 ]; then
        if [ -z "$_diff_base" ]; then
            _diff_base=$(cd "$target_dir" && git rev-parse HEAD~1 2>/dev/null || echo "")
        fi
        if [ -n "$_diff_base" ]; then
            files_modified=$(cd "$target_dir" && git diff --name-only "$_diff_base" HEAD 2>/dev/null | head -50 | tr '\n' '|' || true)
            # Also include unstaged changes (in case auto-commit didn't run)
            local _unstaged
            _unstaged=$(cd "$target_dir" && git diff --name-only HEAD 2>/dev/null | head -20 | tr '\n' '|' || true)
            if [ -n "$_unstaged" ]; then
                files_modified="${files_modified}${_unstaged}"
            fi
        else
            # Git repo but no prior commit (e.g. fresh init) -- list untracked.
            files_modified=$(cd "$target_dir" && git ls-files --others --exclude-standard 2>/dev/null | head -50 | tr '\n' '|' || true)
        fi
    else
        # NOT a git repo: snapshot diff via find. Use .loki/ mtime as the
        # iteration-start reference (loki creates .loki/ on session start).
        # `-newer` on directory mtime gives a rough but useful set of files
        # modified DURING this session. Skip noise dirs.
        local _ref_file="${target_dir}/.loki/state/orchestrator.json"
        if [ ! -f "$_ref_file" ]; then
            _ref_file="${target_dir}/.loki"
        fi
        if [ -e "$_ref_file" ]; then
            files_modified=$(cd "$target_dir" && find . -type f -newer "$_ref_file" \
                -not -path './.loki/*' \
                -not -path './node_modules/*' \
                -not -path './.git/*' \
                -not -path './venv/*' \
                -not -path './.venv/*' \
                -not -path './dist/*' \
                -not -path './build/*' \
                2>/dev/null | head -50 | sed 's|^\./||' | tr '\n' '|' || true)
        fi
        # Belt-and-suspenders: if find returned nothing, fall back to a
        # plain listing of non-noise files (every visible file, capped at 50).
        if [ -z "$files_modified" ]; then
            files_modified=$(cd "$target_dir" && find . -maxdepth 3 -type f \
                -not -path './.loki/*' \
                -not -path './node_modules/*' \
                -not -path './.git/*' \
                2>/dev/null | head -50 | sed 's|^\./||' | tr '\n' '|' || true)
        fi
    fi

    # Collect last git commit if any
    local git_commit=""
    git_commit=$(cd "$target_dir" && git rev-parse --short HEAD 2>/dev/null || true)

    # v7.6.4 B-3a fix + v7.7.7 filename fix: the actual filename is
    # `iteration-N.json` (not `iter-N.json` as v7.6.4 erroneously assumed).
    # Real-user test on /tmp/loki-validate showed `iteration-1.json` in
    # .loki/metrics/efficiency/. We now check both the canonical name and
    # the legacy `iter-N.json` for backward compat with any older runs.
    local _iter_metrics_file=""
    for _candidate in \
        "$target_dir/.loki/metrics/efficiency/iteration-${iteration}.json" \
        "$target_dir/.loki/metrics/efficiency/iter-${iteration}.json" \
    ; do
        if [ -f "$_candidate" ]; then
            _iter_metrics_file="$_candidate"
            break
        fi
    done
    local _iter_tokens_in=0 _iter_tokens_out=0 _iter_cost=0
    if [ -n "$_iter_metrics_file" ]; then
        _iter_tokens_in=$(python3 -c "import json; d=json.load(open('$_iter_metrics_file')); print(int(d.get('input_tokens', 0) or 0))" 2>/dev/null || echo 0)
        _iter_tokens_out=$(python3 -c "import json; d=json.load(open('$_iter_metrics_file')); print(int(d.get('output_tokens', 0) or 0))" 2>/dev/null || echo 0)
        _iter_cost=$(python3 -c "import json; d=json.load(open('$_iter_metrics_file')); print(float(d.get('cost_usd', 0) or 0))" 2>/dev/null || echo 0)
    fi
    local _iter_tokens_total=$((_iter_tokens_in + _iter_tokens_out))

    # Determine outcome
    local outcome="success"
    if [ "$exit_code" -ne 0 ]; then
        outcome="failure"
    fi

    # Pass all context via environment variables (prevents injection)
    # v6.83.0: also stash the resolved episode path so the bash caller can
    # optionally shadow-write it to the managed store if importance >= 0.6.
    local episode_path_file="/tmp/loki-episode-path-$$"
    : > "$episode_path_file"
    # CONNECTOR A (failure-memory loop): locate this iteration's scrubbed crash
    # file (failure only). Default-on knob LOKI_FAILURE_MEMORY; no-op when 0.
    local _crash_json=""
    if [ "${LOKI_FAILURE_MEMORY:-1}" != "0" ] && [ "$exit_code" -ne 0 ] \
        && [ -d "$target_dir/.loki/crash" ]; then
        _crash_json=$(ls -t "$target_dir/.loki/crash/"*.json 2>/dev/null | head -1 || true)
    fi
    _LOKI_PROJECT_DIR="$PROJECT_DIR" _LOKI_TARGET_DIR="$target_dir" \
    _LOKI_ITERATION="$iteration" _LOKI_EXIT_CODE="$exit_code" \
    _LOKI_RARV_PHASE="$rarv_phase" _LOKI_GOAL="$goal" \
    _LOKI_DURATION="$duration" _LOKI_OUTCOME="$outcome" \
    _LOKI_FILES_MODIFIED="$files_modified" _LOKI_GIT_COMMIT="$git_commit" \
    _LOKI_EPISODE_PATH_FILE="$episode_path_file" \
    _LOKI_TOKENS_IN="$_iter_tokens_in" _LOKI_TOKENS_OUT="$_iter_tokens_out" \
    _LOKI_TOKENS_TOTAL="$_iter_tokens_total" _LOKI_COST_USD="$_iter_cost" \
    _LOKI_FAILURE_MEMORY="${LOKI_FAILURE_MEMORY:-1}" _LOKI_CRASH_JSON="$_crash_json" \
    python3 << 'PYEOF' 2>/dev/null || true
import sys
import os
import json
from pathlib import Path

project_dir = os.environ.get('_LOKI_PROJECT_DIR', '')
target_dir = os.environ.get('_LOKI_TARGET_DIR', '.')
iteration = os.environ.get('_LOKI_ITERATION', '0')
rarv_phase = os.environ.get('_LOKI_RARV_PHASE', 'iteration')
goal = os.environ.get('_LOKI_GOAL', '')
duration = os.environ.get('_LOKI_DURATION', '0')
outcome = os.environ.get('_LOKI_OUTCOME', 'success')
files_modified = os.environ.get('_LOKI_FILES_MODIFIED', '')
git_commit = os.environ.get('_LOKI_GIT_COMMIT', '')
path_out_file = os.environ.get('_LOKI_EPISODE_PATH_FILE', '')

sys.path.insert(0, project_dir)
try:
    from memory.engine import MemoryEngine, create_storage
    from memory.schemas import EpisodeTrace

    storage = create_storage(f'{target_dir}/.loki/memory')
    engine = MemoryEngine(storage=storage, base_path=f'{target_dir}/.loki/memory')
    engine.initialize()

    trace = EpisodeTrace.create(
        task_id=f'iteration-{iteration}',
        agent='loki-orchestrator',
        phase=rarv_phase.upper() if rarv_phase else 'ACT',
        goal=goal,
    )
    trace.outcome = outcome
    trace.duration_seconds = int(duration) if duration.isdigit() else 0
    trace.git_commit = git_commit if git_commit else None
    trace.files_modified = [f for f in files_modified.split('|') if f] if files_modified else []

    # v7.6.4 B-3a + B-3b fix: hydrate tokens + cost from the iteration's
    # efficiency metrics file (same source `loki kpis` reads). Backward
    # compat: zero stays zero on missing metrics.
    try:
        trace.tokens_used = int(os.environ.get('_LOKI_TOKENS_TOTAL', '0') or 0)
    except (TypeError, ValueError):
        trace.tokens_used = 0
    # Try to set the input/output/cost fields if the schema accepts them.
    for attr, env_key, caster in (
        ('input_tokens', '_LOKI_TOKENS_IN', int),
        ('output_tokens', '_LOKI_TOKENS_OUT', int),
        ('cost_usd', '_LOKI_COST_USD', float),
    ):
        try:
            value = caster(os.environ.get(env_key, '0') or 0)
            setattr(trace, attr, value)
        except (AttributeError, TypeError, ValueError):
            pass
    # files_modified -> artifacts_produced shadow (so .artifacts_produced
    # reflects what was created if the schema has that field separately).
    try:
        if not getattr(trace, 'artifacts_produced', None):
            trace.artifacts_produced = list(trace.files_modified)
    except AttributeError:
        pass

    # CONNECTOR A (failure-memory loop): attach a scrubbed (or non-sensitive
    # fallback) ErrorEntry to the failed episode so the next iteration can learn
    # from it. Reuses the Phase 0 scrubbed crash file; never reads raw data.
    # Wrapped in try/except so it can never block episode capture.
    if os.environ.get('_LOKI_FAILURE_MEMORY', '1') != '0' and outcome == 'failure':
        try:
            from memory.schemas import ErrorEntry
            crash_json_path = os.environ.get('_LOKI_CRASH_JSON', '')
            _err_type = 'IterationError'
            _message = ''
            if crash_json_path:
                with open(crash_json_path, 'r', encoding='utf-8') as _cf:
                    _crash = json.load(_cf)
                _err_type = (_crash.get('error_class')
                             or _crash.get('friction_kind') or 'IterationError')
                _sig = _crash.get('stack_signature') or []
                _sig_str = ' > '.join(str(s) for s in _sig[:5]) if isinstance(_sig, list) else str(_sig)
                _phase = _crash.get('rarv_phase') or rarv_phase or ''
                _parts = []
                if _phase:
                    _parts.append('phase=' + str(_phase))
                if _crash.get('friction_kind'):
                    _parts.append('friction=' + str(_crash['friction_kind']))
                if _sig_str:
                    _parts.append('signature: ' + _sig_str)
                if _crash.get('fingerprint'):
                    _parts.append('fp=' + str(_crash['fingerprint'])[:12])
                _message = '; '.join(_parts) or 'iteration failed'
            else:
                # Telemetry-independent fallback: no crash file (e.g. telemetry
                # off). Synthesize from non-sensitive fields only. Nothing raw,
                # no scrub needed.
                _ec = os.environ.get('_LOKI_EXIT_CODE', '')
                _message = 'phase=' + str(rarv_phase or '') + '; exit=' + str(_ec)
            trace.errors_encountered.append(ErrorEntry(
                error_type=str(_err_type), message=_message, resolution=''))
        except Exception:
            pass  # never block episode capture

    engine.store_episode(trace)

    # v6.83.0: surface the on-disk episode path + importance so bash can
    # decide whether to shadow-write. Writing to a known file (not stdout)
    # keeps the existing stdout contract intact.
    try:
        importance = float(getattr(trace, 'importance', 0.0) or 0.0)
    except (TypeError, ValueError):
        importance = 0.0
    # Reconstruct the ACTUAL on-disk path. storage.save_episode writes to
    # episodic/<YYYY-MM-DD>/task-<id>.json (date from the trace timestamp),
    # NOT episodic/<id>.json. The old flat path never existed, so the
    # importance shadow-write guard in bash never fired.
    _ts = getattr(trace, 'timestamp', '') or ''
    _date_str = str(_ts)[:10] if _ts else __import__('datetime').datetime.now(
        __import__('datetime').timezone.utc).strftime('%Y-%m-%d')
    episode_file = (Path(f'{target_dir}/.loki/memory/episodic')
                    / _date_str / f'task-{trace.id}.json')
    if path_out_file:
        try:
            with open(path_out_file, 'w', encoding='utf-8') as f:
                json.dump({'path': str(episode_file), 'importance': importance}, f)
        except OSError:
            pass
except Exception as e:
    # v7.7.17: replace silent-fail with structured log to .errors.log.
    # log_memory_error never raises; outer try/except guards even
    # against import failure of the logger itself.
    try:
        from memory.error_log import log_memory_error
        log_memory_error(f'{target_dir}/.loki/memory', 'auto_capture_episode', e)
    except Exception:
        pass
PYEOF

    # v6.83.0 Phase 1: RARV-C REFLECT/VERIFY shadow-write. Only when both
    # managed flags are on AND the episode meets the consolidation importance
    # threshold (>= 0.6). Fully non-blocking (backgrounded subprocess).
    if [ "$LOKI_MANAGED_AGENTS" = "true" ] && [ "$LOKI_MANAGED_MEMORY" = "true" ] \
        && [ -s "$episode_path_file" ]; then
        local _ep_path _ep_imp
        _ep_path=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('path',''))" "$episode_path_file" 2>/dev/null || echo "")
        _ep_imp=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('importance',0.0))" "$episode_path_file" 2>/dev/null || echo "0")
        if [ -n "$_ep_path" ] && [ -f "$_ep_path" ]; then
            local _above_threshold
            _above_threshold=$(python3 -c "print('yes' if float('$_ep_imp') >= 0.6 else 'no')" 2>/dev/null || echo "no")
            if [ "$_above_threshold" = "yes" ]; then
                (
                    cd "$PROJECT_DIR" 2>/dev/null && \
                    LOKI_TARGET_DIR="$target_dir" \
                    timeout 15 python3 -m memory.managed_memory.shadow_write --path "$_ep_path" >/dev/null 2>&1 || true
                ) &
                disown 2>/dev/null || true
            fi
        fi
    fi
    rm -f "$episode_path_file" 2>/dev/null || true
}

# Run memory consolidation pipeline
run_memory_consolidation() {
    local target_dir="${TARGET_DIR:-.}"

    # Only run if memory system exists
    if [ ! -d "$target_dir/.loki/memory" ]; then
        return
    fi

    # Pass parameters via environment variables for consistency
    _LOKI_PROJECT_DIR="$PROJECT_DIR" _LOKI_TARGET_DIR="$target_dir" \
    python3 << 'PYEOF' 2>/dev/null || true
import sys
import os

project_dir = os.environ.get('_LOKI_PROJECT_DIR', '')
target_dir = os.environ.get('_LOKI_TARGET_DIR', '.')

sys.path.insert(0, project_dir)
try:
    from memory.consolidation import ConsolidationPipeline
    from memory.storage import MemoryStorage
    storage = MemoryStorage(f'{target_dir}/.loki/memory')
    pipeline = ConsolidationPipeline(storage)
    result = pipeline.consolidate(since_hours=24)
    if result.patterns_created > 0:
        print(f'Memory consolidation: {result.patterns_created} patterns created')
except Exception as e:
    # v7.7.17: replace silent-fail with structured log to .errors.log.
    try:
        from memory.error_log import log_memory_error
        log_memory_error(f'{target_dir}/.loki/memory', 'run_memory_consolidation', e)
    except Exception:
        pass
PYEOF
}

#===============================================================================
# Knowledge Graph Integration (v6.0.0)
# Enrich prompts with cross-project patterns and store new learnings.
#===============================================================================

# Enrich prompt context with relevant cross-project patterns
# Store new patterns to the knowledge graph after successful iterations
#===============================================================================
# Save/Load Wrapper State
#===============================================================================

# A6 (multi-build-per-pod state isolation): resolve the autonomy-state.json path.
# The existing .loki/sessions/<id>/ scoping (v6.4.0) namespaces PID + lock files
# so concurrent sessions in the SAME TARGET_DIR do not collide. autonomy-state.json
# was NOT covered by that scoping, so two concurrent builds with distinct
# LOKI_SESSION_IDs still raced on the one global .loki/autonomy-state.json
# (lost-update on retry/iteration counts). When LOKI_SESSION_ID is set we mirror
# the session scoping and put the file under .loki/sessions/<id>/. When unset
# (the normal `loki start ./prd.md` case) the legacy .loki/autonomy-state.json
# path is returned UNCHANGED, so default single-session behavior is byte-identical.
_loki_state_file() {
    if [ -n "${LOKI_SESSION_ID:-}" ]; then
        printf '%s' ".loki/sessions/${LOKI_SESSION_ID}/autonomy-state.json"
    else
        printf '%s' ".loki/autonomy-state.json"
    fi
}

save_state() {
    local retry_count="$1"
    local status="$2"
    local exit_code="$3"

    local state_file
    state_file="$(_loki_state_file)"

    # BUG-ST-013: Ensure target directory exists (defensive -- may be called from
    # signal handler). For a namespaced session path this also creates
    # .loki/sessions/<id>/; for the default path it is .loki/ as before.
    mkdir -p "$(dirname "$state_file")" 2>/dev/null || true

    # BUG-XC-004: Atomic write via temp file + mv (temp lives beside the target so
    # the rename stays on the same filesystem)
    local state_tmp="${state_file}.tmp.$$"
    cat > "$state_tmp" << EOF
{
    "retryCount": $retry_count,
    "iterationCount": $ITERATION_COUNT,
    "status": "$status",
    "lastExitCode": $exit_code,
    "lastRun": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
    "prdPath": "$(printf '%s' "${PRD_PATH:-}" | sed 's/\\/\\\\/g; s/"/\\"/g')",
    "pid": $$,
    "maxRetries": $MAX_RETRIES,
    "baseWait": $BASE_WAIT
}
EOF
    mv -f "$state_tmp" "$state_file"
}

load_state() {
    local state_file
    state_file="$(_loki_state_file)"

    # BUG-EP-015: Clean up orphaned temp files from kill -9 crashes
    # These are left behind when the process is killed during atomic writes
    find .loki/ -maxdepth 1 -name "*.tmp.*" -mmin +5 -delete 2>/dev/null || true
    find .loki/state/ -name "*.tmp.*" -mmin +5 -delete 2>/dev/null || true
    # A6: also sweep namespaced session state temps when running under a session id
    if [ -n "${LOKI_SESSION_ID:-}" ]; then
        find ".loki/sessions/${LOKI_SESSION_ID}/" -maxdepth 1 -name "*.tmp.*" -mmin +5 -delete 2>/dev/null || true
    fi

    if [ -f "$state_file" ]; then
        if command -v python3 &> /dev/null; then
            # BUG-ST-006: Validate checkpoint integrity before loading state
            local state_valid
            state_valid=$(LOKI_STATE_FILE="$state_file" python3 -c "
import json, os, sys
try:
    with open(os.environ['LOKI_STATE_FILE']) as f:
        d = json.load(f)
    # Validate required fields exist and have sane types
    rc = d.get('retryCount', 0)
    ic = d.get('iterationCount', 0)
    status = d.get('status', 'unknown')
    if not isinstance(rc, (int, float)) or not isinstance(ic, (int, float)):
        print('invalid')
        sys.exit(0)
    if rc < 0 or ic < 0:
        print('invalid')
        sys.exit(0)
    print('valid')
except (json.JSONDecodeError, KeyError, TypeError, OSError):
    print('invalid')
" 2>/dev/null || echo "invalid")

            if [ "$state_valid" != "valid" ]; then
                log_warn "State file corrupted or invalid - starting fresh"
                RETRY_COUNT=0
                ITERATION_COUNT=0
                # Back up corrupted state file for diagnosis
                mv "$state_file" "${state_file}.corrupt.$(date +%s)" 2>/dev/null || true
                return
            fi

            # Load retry count, iteration count, and status from previous session
            local prev_status
            prev_status=$(LOKI_STATE_FILE="$state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('status', 'unknown'))" 2>/dev/null || echo "unknown")
            RETRY_COUNT=$(LOKI_STATE_FILE="$state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('retryCount', 0))" 2>/dev/null || echo "0")
            # BUG-RUN-003: Restore ITERATION_COUNT from persisted state
            ITERATION_COUNT=$(LOKI_STATE_FILE="$state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('iterationCount', 0))" 2>/dev/null || echo "0")

            # Reset retry count + iteration count if previous session ended in a
            # terminal state. A fresh `loki start` after a terminal run is a NEW
            # run and must start from a fresh baseline. This matters for the
            # verified-completion evidence gate (v7.19.1): the run-start SHA
            # recapture in run_autonomous is gated on ITERATION_COUNT==0, so a
            # stale count here would leave the gate diffing against the PRIOR
            # run's start SHA (toothless). Terminal states covered:
            #   - failure terminals: failed|max_iterations_reached|
            #     max_retries_exceeded|exited
            #   - success terminals: council_approved|council_force_approved|
            #     completion_promise_fulfilled (the run finished; a re-run is new)
            #   - running: previous process died mid-run (crash); nothing resumes
            #     from "running" (paused/interrupted are the explicit resume
            #     signals), so this closes the crash-rerun toothless-gate path.
            # Deliberately NOT reset (genuine resume / user re-run expecting to
            # continue): paused, interrupted, budget_exceeded, stopped.
            #
            # ENT-2 (enterprise pod-loss resume): a crashed "running" run is
            # normally reset (a fresh `loki start` on a dev box after a crash is a
            # new run, and resetting closes the toothless-gate path). BUT in a
            # containerized deployment (LOKI_DURABLE_STATE=1) the platform RESTARTS
            # the SAME build on the SAME durable volume after a pod loss, and the
            # operator's intent is RESUME, not restart-from-scratch. In that mode
            # only, a "running" status with a still-valid run-start-SHA baseline on
            # the durable volume RESUMES (ITERATION_COUNT preserved). The gate stays
            # sharp because $_start_sha_file survived on the volume, so the run-start
            # SHA recapture (run_autonomous) keys on THIS run's real start SHA, not
            # the prior run's -- and resume re-enters the normal RARV iteration,
            # which re-runs verification; a crash never inherits a gate PASS (the
            # success terminals council_approved/.../completion_promise_fulfilled
            # are still reset, so a completed-then-rerun is a NEW run as before).
            local _resume_crashed_running=0
            if [ "$prev_status" = "running" ] \
               && [ "${LOKI_DURABLE_STATE:-0}" = "1" ] \
               && [ -s ".loki/state/start-sha" ]; then
                _resume_crashed_running=1
            fi
            case "$prev_status" in
                running)
                    if [ "$_resume_crashed_running" = "1" ]; then
                        log_info "Durable resume: previous build crashed mid-run (status: running). Resuming from iteration ${ITERATION_COUNT} on the durable volume; verification re-runs (a crash never inherits a gate PASS)."
                    else
                        log_info "Previous session ended with status: $prev_status. Resetting for new session."
                        RETRY_COUNT=0
                        ITERATION_COUNT=0
                    fi
                    ;;
                failed|max_iterations_reached|max_retries_exceeded|exited|council_approved|council_force_approved|completion_promise_fulfilled|reuse_already_satisfied)
                    log_info "Previous session ended with status: $prev_status. Resetting for new session."
                    RETRY_COUNT=0
                    ITERATION_COUNT=0
                    ;;
            esac
        else
            RETRY_COUNT=0
        fi
    else
        RETRY_COUNT=0
    fi
}

# Load tasks from queue files for prompt injection
# Supports both array format [...] and object format {"tasks": [...]}
# Enhanced in v6.63.0 to include rich task details (description, acceptance criteria, user stories)
load_queue_tasks() {
    local task_injection=""

    # Helper Python script to extract and format tasks with rich details
    # Handles both formats, includes description, acceptance criteria, and user stories
    local extract_script='
import json
import sys

def extract_tasks(filepath, prefix):
    try:
        data = json.load(open(filepath))
        # Support both formats: [...] and {"tasks": [...]}
        tasks = data.get("tasks", data) if isinstance(data, dict) else data
        if not isinstance(tasks, list):
            return ""

        results = []
        for i, task in enumerate(tasks[:3]):  # Limit to first 3 tasks
            if not isinstance(task, dict):
                continue
            task_id = task.get("id") or "unknown"
            source = task.get("source", "")

            # Rich PRD-sourced tasks (v6.63.0)
            if source == "prd" or task_id.startswith("prd-"):
                title = task.get("title", "Task")
                lines = [f"{prefix}[{i+1}] {task_id}: {title}"]
                desc = task.get("description", "")
                if desc and desc != title:
                    # First 300 chars of description, normalized
                    desc_short = desc.replace("\n", " ").replace("\r", "")[:300]
                    if len(desc) > 300:
                        desc_short += "..."
                    lines.append(f"  Description: {desc_short}")
                criteria = task.get("acceptance_criteria", [])
                if criteria:
                    criteria_str = "; ".join(str(c) for c in criteria[:5])
                    lines.append(f"  Acceptance: {criteria_str}")
                story = task.get("user_story", "")
                if story:
                    lines.append(f"  User Story: {story}")
                results.append("\n".join(lines))
            else:
                # Legacy format: extract action from payload
                task_type = task.get("type") or "unknown"
                payload = task.get("payload", {})
                if isinstance(payload, dict):
                    action = payload.get("action") or payload.get("goal") or ""
                else:
                    action = str(payload) if payload else ""
                # Also check top-level title/description for non-payload tasks
                if not action:
                    action = task.get("title", task.get("description", ""))
                # Normalize: remove newlines, truncate to 500 chars
                action = str(action).replace("\n", " ").replace("\r", "")[:500]
                if len(str(action)) > 500:
                    action += "..."
                results.append(f"{prefix}[{i+1}] id={task_id} type={task_type}: {action}")

        return "\n".join(results)
    except:
        return ""

# Check in-progress first
in_progress = extract_tasks(".loki/queue/in-progress.json", "TASK")
pending = extract_tasks(".loki/queue/pending.json", "PENDING")

output = []
if in_progress:
    output.append(f"IN-PROGRESS TASKS (EXECUTE THESE):\n{in_progress}")
if pending:
    output.append(f"PENDING:\n{pending}")

print("\n---\n".join(output))
'

    # First check in-progress tasks (highest priority)
    if [ -f ".loki/queue/in-progress.json" ] || [ -f ".loki/queue/pending.json" ]; then
        task_injection=$(python3 -c "$extract_script" 2>/dev/null || echo "")
    fi

    echo "$task_injection"
}

#===============================================================================
# Build Resume Prompt
#===============================================================================

_loki_supervised_actionable_brief() {
    local prd_path="${1:-}"
    case "$prd_path" in
        /*) ;;
        *) prd_path="${TARGET_DIR:-.}/$prd_path" ;;
    esac
    [ -n "$prd_path" ] && [ -f "$prd_path" ] || return 0
    LOKI_ACTIONABLE_PRD_PATH="$prd_path" python3 - <<'PYEOF'
import os
from pathlib import Path

text = Path(os.environ["LOKI_ACTIONABLE_PRD_PATH"]).read_text(
    encoding="utf-8", errors="replace"
)
markers = (
    "--- END AUTONOMI PRODUCT QUALITY CONTRACT ---",
    "--- END DESIGN SYSTEM DIRECTIVE ---",
)
brief = text
for marker in markers:
    start = text.find(marker)
    if start >= 0:
        brief = text[start + len(marker):]
        break
print(brief[:16_384], end="")
PYEOF
}

_loki_supervised_source_hints() {
    local root="${TARGET_DIR:-.}"
    LOKI_SOURCE_HINT_ROOT="$root" python3 - <<'PYEOF'
import json
import os
import re
from html.parser import HTMLParser
from pathlib import Path

MAX_BYTES = 65_536
MAX_DEPTH = 4
MAX_FILES = 24
CODE_EXTENSIONS = (".tsx", ".jsx", ".ts", ".js", ".mjs", ".cjs", ".mts", ".cts")
STYLE_EXTENSIONS = (".css", ".scss", ".sass", ".less")

root = Path(os.environ.get("LOKI_SOURCE_HINT_ROOT", ".")).resolve()
manifest = "not-detected"
page = None
stylesheet = None


def safe_file(path):
    try:
        resolved = path.resolve()
        relative = resolved.relative_to(root)
    except (OSError, ValueError):
        return None
    if not resolved.is_file():
        return None
    return relative.as_posix()


def fixed_file(relative):
    return safe_file(root / relative)


def read_text(relative):
    try:
        with (root / relative).open("r", encoding="utf-8", errors="replace") as handle:
            return handle.read(MAX_BYTES)
    except OSError:
        return ""


def resolve_local(importer, specifier, extensions):
    clean = specifier.split("?", 1)[0].split("#", 1)[0]
    if not clean or clean.startswith(("http://", "https://", "//")):
        return None
    if clean.startswith("/"):
        base = root / clean.lstrip("/")
    elif clean.startswith("./") or clean.startswith("../"):
        base = root / Path(importer).parent / clean
    else:
        return None

    candidates = [base]
    if not base.suffix:
        candidates.extend(Path(str(base) + extension) for extension in extensions)
        candidates.extend(base / ("index" + extension) for extension in extensions)
    for candidate in candidates:
        relative = safe_file(candidate)
        if relative and Path(relative).suffix.lower() in extensions:
            return relative
    return None


IMPORT_RE = re.compile(
    r"^[ \t]*import[ \t]+(?:type[ \t]+)?(?:[^\"'\n;]+?[ \t]+from[ \t]+)?[\"']([^\"']+)[\"']",
    re.MULTILINE,
)
EXPORT_RE = re.compile(
    r"^[ \t]*export[ \t]+[^\"'\n;]+?[ \t]+from[ \t]+[\"']([^\"']+)[\"']",
    re.MULTILINE,
)
REQUIRE_RE = re.compile(r"\brequire[ \t]*\([ \t]*[\"']([^\"']+)[\"'][ \t]*\)")
DEFAULT_IMPORT_RE = re.compile(
    r"^[ \t]*import[ \t]+([A-Za-z_$][\w$]*)[ \t]*(?:,[^\n]+)?[ \t]+from[ \t]+[\"']([^\"']+)[\"']",
    re.MULTILINE,
)
NAMESPACE_IMPORT_RE = re.compile(
    r"^[ \t]*import[ \t]+\*[ \t]+as[ \t]+([A-Za-z_$][\w$]*)[ \t]+from[ \t]+[\"']([^\"']+)[\"']",
    re.MULTILINE,
)
NAMED_IMPORT_RE = re.compile(
    r"^[ \t]*import[ \t]+\{([^\n}]*)\}[ \t]+from[ \t]+[\"']([^\"']+)[\"']",
    re.MULTILINE,
)


def import_specifiers(text):
    matches = []
    for pattern in (IMPORT_RE, EXPORT_RE, REQUIRE_RE):
        matches.extend((match.start(), match.group(1)) for match in pattern.finditer(text))
    return [specifier for _, specifier in sorted(matches)]


def local_imports(relative):
    results = []
    seen = set()
    for specifier in import_specifiers(read_text(relative)):
        target = resolve_local(relative, specifier, CODE_EXTENSIONS + STYLE_EXTENSIONS)
        if target and target not in seen:
            seen.add(target)
            results.append(target)
    return results


def reachable_from(entries):
    queue = [(entry, 0) for entry in entries if entry]
    visited = []
    seen = set()
    while queue and len(visited) < MAX_FILES:
        current, depth = queue.pop(0)
        if current in seen:
            continue
        seen.add(current)
        visited.append(current)
        if depth >= MAX_DEPTH or Path(current).suffix.lower() not in CODE_EXTENSIONS:
            continue
        for target in local_imports(current):
            if target not in seen:
                queue.append((target, depth + 1))
    return visited


def first_style(paths):
    for relative in paths:
        if Path(relative).suffix.lower() in STYLE_EXTENSIONS:
            return relative
    return None


def imported_bindings(relative):
    text = read_text(relative)
    bindings = []
    for match in DEFAULT_IMPORT_RE.finditer(text):
        bindings.append((match.start(), match.group(1), match.group(2)))
    for match in NAMESPACE_IMPORT_RE.finditer(text):
        bindings.append((match.start(), match.group(1), match.group(2)))
    for match in NAMED_IMPORT_RE.finditer(text):
        for item in match.group(1).split(","):
            words = re.findall(r"[A-Za-z_$][\w$]*", item)
            if not words or words[0] == "type":
                continue
            name = words[-1] if len(words) >= 3 and words[-2] == "as" else words[0]
            bindings.append((match.start(), name, match.group(2)))
    return sorted(bindings)


def vite_rendered_source(entry):
    text = read_text(entry)
    if not re.search(r"(?:\.[ \t]*render|\brender)[ \t]*\(", text):
        return None
    used = set(re.findall(r"<[ \t]*([A-Z][A-Za-z0-9_$]*)\b", text))
    used.update(re.findall(r"\bcreateElement[ \t]*\([ \t]*([A-Z][A-Za-z0-9_$]*)\b", text))
    for _, binding, specifier in imported_bindings(entry):
        if binding not in used:
            continue
        target = resolve_local(entry, specifier, CODE_EXTENSIONS)
        if target:
            return target
    if re.search(r"<[ \t]*[a-z][A-Za-z0-9:-]*\b", text):
        return entry
    return None


class IndexParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self.module_scripts = []
        self.stylesheets = []

    def handle_starttag(self, tag, attrs):
        values = dict(attrs)
        if tag.lower() == "script" and values.get("type", "").lower() == "module" and values.get("src"):
            self.module_scripts.append(values["src"])
        if tag.lower() == "link" and "stylesheet" in values.get("rel", "").lower().split() and values.get("href"):
            self.stylesheets.append(values["href"])


package = {}
for candidate in ("package.json", "deno.json", "deno.jsonc"):
    relative = fixed_file(candidate)
    if relative:
        manifest = relative
        break
if manifest == "package.json":
    try:
        package = json.loads(read_text(manifest))
    except (TypeError, ValueError):
        package = {}

dependencies = {}
scripts = {}
if isinstance(package, dict):
    for key in ("dependencies", "devDependencies", "peerDependencies"):
        value = package.get(key, {})
        if isinstance(value, dict):
            dependencies.update(value)
    value = package.get("scripts", {})
    if isinstance(value, dict):
        scripts = value
script_text = " ".join(str(value) for value in scripts.values())
is_next = "next" in dependencies or bool(re.search(r"(?:^|[ /])next(?:[ \t]|$)", script_text))

if is_next:
    for candidate in (
        "app/page.tsx", "app/page.jsx", "app/page.ts", "app/page.js",
        "src/app/page.tsx", "src/app/page.jsx", "src/app/page.ts", "src/app/page.js",
    ):
        page = fixed_file(candidate)
        if page:
            layout_entries = []
            for extension in CODE_EXTENSIONS:
                layout = fixed_file(str(Path(page).parent / ("layout" + extension)))
                if layout:
                    layout_entries.append(layout)
                    break
            page_graph = reachable_from([page])
            stylesheet = first_style(page_graph + reachable_from(layout_entries))
            break

if is_next and not page:
    for candidate in (
        "pages/index.tsx", "pages/index.jsx", "pages/index.ts", "pages/index.js",
        "src/pages/index.tsx", "src/pages/index.jsx", "src/pages/index.ts", "src/pages/index.js",
    ):
        page = fixed_file(candidate)
        if page:
            app_entries = []
            for extension in CODE_EXTENSIONS:
                app_entry = fixed_file(str(Path(page).parent / ("_app" + extension)))
                if app_entry:
                    app_entries.append(app_entry)
                    break
            page_graph = reachable_from([page])
            stylesheet = first_style(page_graph + reachable_from(app_entries))
            break

index = fixed_file("index.html")
index_parser = IndexParser()
if not page and index:
    index_parser.feed(read_text(index))
    entries = [resolve_local(index, specifier, CODE_EXTENSIONS) for specifier in index_parser.module_scripts]
    for entry in entries:
        if not entry:
            continue
        rendered = vite_rendered_source(entry)
        if not rendered:
            continue
        page = rendered
        graph = reachable_from([entry])
        stylesheet = first_style(local_imports(page) + graph)
        break

if not page and index and not index_parser.module_scripts:
    page = index
    linked = [resolve_local(index, specifier, STYLE_EXTENSIONS) for specifier in index_parser.stylesheets]
    stylesheet = first_style(linked)


def adjacent_test(relative):
    if not relative:
        return None
    path = Path(relative)
    stem = path.stem
    extensions = [path.suffix] + [extension for extension in CODE_EXTENSIONS if extension != path.suffix]
    candidates = []
    for extension in extensions:
        candidates.extend((
            path.with_name(stem + ".test" + extension),
            path.with_name(stem + ".spec" + extension),
            path.parent / "__tests__" / (stem + ".test" + extension),
            path.parent / "__tests__" / (stem + ".spec" + extension),
        ))
    for candidate in candidates:
        found = fixed_file(candidate.as_posix())
        if found:
            return found
    return None


test_file = adjacent_test(page)
print("manifest=" + manifest)
print("primary_rendered_source=" + (page or "not-detected"))
print("primary_stylesheet=" + (stylesheet or "not-detected"))
print("primary_test=" + (test_file or "not-detected"))
PYEOF
}

_loki_supervised_failure_context() {
    local iteration="${1:-1}"
    [ "$iteration" -gt 1 ] 2>/dev/null || return 0
    python3 - "${TARGET_DIR:-.}" "$iteration" <<'PYEOF'
import html
import json
import os
import re
import sys

root = os.path.abspath(sys.argv[1])
iteration = int(sys.argv[2])
loki = os.path.join(root, ".loki")
quality = os.path.join(loki, "quality")

try:
    with open(os.path.join(quality, "gate-failures.txt"), "r", encoding="utf-8") as handle:
        tokens = [
            token for token in re.split(r"[,\s]+", handle.read(2048))
            if re.fullmatch(r"[A-Za-z0-9_.:-]+", token or "")
        ]
except OSError:
    tokens = []

reviews = os.path.join(quality, "reviews")
review_dirs = []
try:
    review_dirs = [
        os.path.join(reviews, name)
        for name in os.listdir(reviews)
        if os.path.isdir(os.path.join(reviews, name))
    ]
except OSError:
    pass

diagnostics = []
if review_dirs:
    latest = max(review_dirs, key=os.path.getmtime)
    for name in sorted(os.listdir(latest)):
        if not name.endswith(".txt") or name.endswith("-prompt.txt"):
            continue
        if name in ("diff.txt", "files.txt"):
            continue
        path = os.path.join(latest, name)
        try:
            with open(path, "r", encoding="utf-8", errors="replace") as handle:
                body = handle.read(1600)
        except OSError:
            continue
        body = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", body).strip()
        if body:
            diagnostics.append({"reviewer": name[:-4], "output": body})

if not tokens and not diagnostics:
    raise SystemExit(0)

payload = json.dumps(
    {
        "previous_iteration": iteration - 1,
        "failed_gates": tokens,
        "diagnostics": diagnostics,
    },
    ensure_ascii=True,
    separators=(",", ":"),
)
payload = payload[:7000]
print('<previous_gate_failures trust="untrusted-diagnostics">')
print(html.escape(payload, quote=True))
print("Verify every diagnostic against current source and recorded test output. Fix supported findings only.")
print("</previous_gate_failures>")
PYEOF
}

_loki_build_supervised_simple_web_prompt() {
    local retry="${1:-0}"
    local prd_path="${2:-}"
    local iteration="${3:-1}"
    local brief
    local source_hints
    local failure_context
    brief=$(_loki_supervised_actionable_brief "$prd_path")
    source_hints=$(_loki_supervised_source_hints)
    failure_context=$(_loki_supervised_failure_context "$iteration")

    cat <<'SUPERVISED_PROMPT_EOF'
<loki_system profile="supervised-simple-web">
You are the implementation agent for a time-bounded hosted web build. Work directly in the existing workspace and begin with tools, not narration.

Required execution order:
1. Inspect round: use one parallel read-only tool round to inspect only the harness-detected manifest, primary rendered source, and primary stylesheet listed below. Skip any item marked not-detected. Do not read the test yet. Do not inventory the repository, inspect harness metadata, read planning artifacts, browse the web, install dependencies, or create documentation.
2. Preview round: make the first visible mutation a bounded, working first viewport, not the complete page. When primary_stylesheet is detected, write no more than 4,000 characters of coherent responsive styles before replacing the rendered placeholder. Then write no more than 6,000 characters to the primary rendered source for a responsive navigation, prompt-specific hero, working primary controls, and a meaningful code-native focal visual. The visual must communicate the product through recognizable objects, labels, relationships, or user-supplied data. Empty translucent shapes, generic gradient blobs, and blank frames do not qualify. Do not implement below-fold sections, plans, or tests in this round. Remove the placeholder sentinel only after this viewport is coherent. Do not write a plan or design document first. If no primary source was detected, use one bounded lookup for the rendered entrypoint and edit it immediately.
3. Completion round: immediately continue after the visible mutation. Implement every section and interaction explicitly named in the actionable user brief. Do not omit, merge away, replace with a static mock, or count planning text, comments, placeholders, or hidden elements as implementation. Wire every navigation link to a real target, give every visible button its stated effect, and make every form behavior truthful. If primary_test is detected, read it now, update it once so it renders the current primary source and checks prompt-specific visible behavior, and do this before any test command. If no primary test exists, add one using the existing runner. Reuse the existing stack and lockfile.
4. Verification round: run the real automated test once and the production build once. Fix only observed failures, then request completion. Never start a watch-mode test process. Prefer an existing test:ci script or a runner's one-shot mode; if neither exists, use the platform's native one-shot test runner instead of adding a test framework. Do not launch a development server or browser because the harness owns those checks.
4. Do not invent customer identities, testimonials, quantitative benefits, prices, availability, response times, legal claims, or integrations. A request to create a logo strip, candidate profile, placement, pricing, testimonial, metric, or integration section does not supply the commercial facts inside it. Preserve the requested structure while visibly labeling unknown material "Illustrative" or "Pending approval". Do not use real company names or realistic person names as placeholders. Do not invent or imply any other fact not supplied by the user brief or verified in workspace source, including compliance claims, endorsements, awards, certifications, or operational results. A no-backend form must never imply that it transmits or stores data, and must state that it does not.
5. Keep the implementation accessible, responsive, secure, keyboard operable, and free of console errors. Treat every explicit user constraint as binding, including allowed files, assets, libraries, and styling mechanisms. Source hints describe existing scaffold files but never authorize keeping one that conflicts with the user brief. Remove conflicting scaffold imports and files. React style objects may contain only valid CSS properties. Put media queries, pseudo-selectors, keyframes, and other CSS at-rules in a valid style element when external stylesheets are disallowed; otherwise use the detected stylesheet. Never claim a check passed without recorded execution evidence.
6. Do not use emoji characters, em dash characters, or en dash characters in source, copy, logs, or summaries.

The harness owns planning, dependency preparation, review, browser checks, proof, and process cleanup. Your job is to mutate the product source quickly, finish the requested app, and run its local tests and build. Do not install or upgrade dependencies, create documentation, use Git, or run temporary verification scripts. After one successful test and build round, run `touch .loki/signals/COMPLETION_REQUESTED` and end the turn. Do not run a second verification pass. Do not commit or push.
</loki_system>
SUPERVISED_PROMPT_EOF
    printf '<execution_context iteration="%s" retry="%s">\n' "$iteration" "$retry"
    printf '%s\n' "$source_hints"
    printf '</execution_context>\n'
    [ -n "$failure_context" ] && printf '%s\n' "$failure_context"
    printf '<actionable_user_brief>\n%s\n</actionable_user_brief>\n' "$brief"
}

build_prompt() {
    local retry="$1"
    local prd="$2"
    local iteration="$3"

    # Build SDLC phases configuration
    local phases=""
    [ "$PHASE_UNIT_TESTS" = "true" ] && phases="${phases}UNIT_TESTS,"
    [ "$PHASE_API_TESTS" = "true" ] && phases="${phases}API_TESTS,"
    [ "$PHASE_E2E_TESTS" = "true" ] && phases="${phases}E2E_TESTS,"
    [ "$PHASE_SECURITY" = "true" ] && phases="${phases}SECURITY,"
    [ "$PHASE_INTEGRATION" = "true" ] && phases="${phases}INTEGRATION,"
    [ "$PHASE_CODE_REVIEW" = "true" ] && phases="${phases}CODE_REVIEW,"
    [ "$PHASE_WEB_RESEARCH" = "true" ] && phases="${phases}WEB_RESEARCH,"
    [ "$PHASE_PERFORMANCE" = "true" ] && phases="${phases}PERFORMANCE,"
    [ "$PHASE_ACCESSIBILITY" = "true" ] && phases="${phases}ACCESSIBILITY,"
    [ "$PHASE_REGRESSION" = "true" ] && phases="${phases}REGRESSION,"
    [ "$PHASE_UAT" = "true" ] && phases="${phases}UAT,"
    phases="${phases%,}"  # Remove trailing comma

    # Ralph Wiggum Mode - Reason-Act-Reflect-VERIFY cycle with self-verification loop (Boris Cherny pattern)
    # 7.114.0 (rank 8): rarv_instruction is now mode-aware. The never-finished
    # tail is a self-contradiction in a finite PRD/checkpoint run (which has an
    # auto-derived COMPLETION_PROMISE and was switched out of perpetual). Gate the
    # never-finished tail on the SAME split that completion_instruction uses
    # (perpetual OR no completion promise). Finite runs instead get a concise
    # "stop when verified-done, the completion gates are the authority" directive.
    # The unverifiable "2-3x quality improvement" clause is removed from ALL modes.
    # Parity-locked with rarvInstruction() in loki-ts/src/runner/build_prompt.ts.
    local _rarv_perpetual="false"
    if [ "$AUTONOMY_MODE" = "perpetual" ] || [ "$PERPETUAL_MODE" = "true" ] || [ -z "$COMPLETION_PROMISE" ]; then
        _rarv_perpetual="true"
    fi
    local rarv_instruction="RALPH WIGGUM MODE ACTIVE. Use Reason-Act-Reflect-VERIFY cycle: 1) REASON - READ .loki/CONTINUITY.md including 'Mistakes & Learnings' section to avoid past errors. CHECK .loki/state/relevant-learnings.json for cross-project learnings from previous projects (mistakes to avoid, patterns to apply). Check .loki/state/ and .loki/queue/, identify next task. CHECK .loki/state/resources.json for system resource warnings - if CPU or memory is high, reduce parallel agent spawning or pause non-critical tasks. Limit to MAX_PARALLEL_AGENTS=${MAX_PARALLEL_AGENTS}. If queue empty, find new improvements. 2) ACT - Execute task, write code, commit changes atomically (git checkpoint). 3) REFLECT - Update .loki/CONTINUITY.md with progress, update state, identify NEXT improvement. Save valuable learnings for future projects. 4) VERIFY - Run automated tests (unit, integration, E2E), check compilation/build, verify against spec. IF VERIFICATION FAILS: a) Capture error details (stack trace, logs), b) Analyze root cause, c) UPDATE 'Mistakes & Learnings' in CONTINUITY.md with what failed, why, and how to prevent, d) Rollback to last good git checkpoint if needed, e) Apply learning and RETRY from REASON. If verification passes, mark task complete and continue."
    if [ "$_rarv_perpetual" = "true" ]; then
        rarv_instruction="${rarv_instruction} CRITICAL: There is NEVER a 'finished' state - always find the next improvement, optimization, test, or feature."
    else
        rarv_instruction="${rarv_instruction} When the PRD requirements are implemented and completion gates pass, claim done via loki_complete_task and STOP; do not add unrequested improvements. Verify once -- the completion gates (tests, checklist, evidence) are the authority on done; do not re-verify redundantly."
    fi

    # Completion instruction (S0.2 -- structured tool call).
    # When PRD requirements are implemented, tests pass, and the checklist is
    # at or near 100%, the agent MUST invoke the `loki_complete_task` MCP tool
    # (defined in mcp/server.py) with completion_statement + evidence fields,
    # instead of emitting a prose completion string.
    local completion_instruction=""
    # v7.4.17: explicit fallback path. The loki_complete_task MCP tool is
    # not always surfaced in the LLM's environment (Codex CLI,
    # certain Claude Code harness configs). When unavailable, the LLM
    # should `touch .loki/signals/COMPLETION_REQUESTED` instead -- the
    # runner's check_task_completion_signal honors that file as a
    # synthesized completion claim with confidence=medium.
    if [ -n "$COMPLETION_PROMISE" ]; then
        completion_instruction="COMPLETION_PROMISE: [$COMPLETION_PROMISE]. When all PRD requirements are implemented, tests pass, and the PRD checklist is at or near 100%, invoke the loki_complete_task MCP tool with your completion_statement and evidence (cite tests that passed, checklist items verified, files created/modified). Do NOT emit a completion string in prose -- use the tool call. FALLBACK: if the loki_complete_task tool is not available in your environment, instead run \`touch .loki/signals/COMPLETION_REQUESTED\` (optionally write a one-line statement to that file via \`echo 'statement' > .loki/signals/COMPLETION_REQUESTED\`); the runner detects this file and treats it as a completion claim."
    else
        completion_instruction="NO COMPLETION PROMISE SET. Continue finding improvements. The Completion Council will evaluate your progress periodically. Iteration $iteration of max $MAX_ITERATIONS. If you do decide the task is complete, invoke the loki_complete_task MCP tool with a structured statement and evidence rather than emitting prose. FALLBACK if that tool is unavailable: \`touch .loki/signals/COMPLETION_REQUESTED\`."
    fi

    # Core autonomous instructions - NO questions, NO waiting, NEVER say done
    local autonomous_suffix=""
    if [ "$AUTONOMY_MODE" = "perpetual" ] || [ "$PERPETUAL_MODE" = "true" ]; then
        autonomous_suffix="CRITICAL AUTONOMY RULES: 1) NEVER ask questions - just decide. 2) NEVER wait for confirmation - just act. 3) NEVER say 'done' or 'complete' - there's always more to improve. 4) NEVER stop voluntarily - if out of tasks, create new ones (add tests, optimize, refactor, add features). 5) Work continues PERPETUALLY. Even if PRD is implemented, find bugs, add tests, improve UX, optimize performance."
    else
        autonomous_suffix="CRITICAL AUTONOMY RULES: 1) NEVER ask questions - just decide. 2) NEVER wait for confirmation - just act. 3) When all PRD requirements are implemented and tests pass, invoke the loki_complete_task MCP tool (completion_statement='$COMPLETION_PROMISE' plus evidence + confidence). Do not emit completion prose. 4) If out of tasks but PRD is not fully implemented, continue working on remaining requirements. 5) Focus on completing PRD scope, not endless improvements."
    fi

    # Skill files are always copied to .loki/skills/ for all providers
    local sdlc_instruction="SDLC_PHASES_ENABLED: [$phases]. Execute ALL enabled phases. Log results to .loki/logs/. See .loki/SKILL.md for phase details. Skill modules at .loki/skills/."

    # Codebase Analysis Mode - when no PRD provided
    # v7.8.1: improved 3-pass instruction. More efficient (no blind full scan)
    # and more accurate (high-signal files first, fixed PRD section template so
    # the result is diff-friendly for later incremental updates).
    local analysis_instruction="CODEBASE_ANALYSIS_MODE: No PRD provided. Reverse-engineer a precise PRD from the existing code in three passes, cheaply and without blind full scans. PASS 1 (orient): list the top two directory levels; read ONLY high-signal manifests that exist (package.json, requirements.txt, pyproject.toml, Cargo.toml, go.mod, pom.xml, build.gradle, composer.json) to identify language, framework, and scripts; read README and any docs index. PASS 2 (locate): from the manifests and conventional layout, identify the entrypoints, the public API or CLI surface, the test directory and runner, and the config or env contract; read those first; skip generated, vendored, and lockfile content; prefer LSP workspace symbols when the lsp-proxy server is available. PASS 3 (write): write .loki/generated-prd.md with these sections: Overview, Detected Stack, Entrypoints and Components, Existing Behavior and Requirements (reverse-engineered, observable), Test and Build Setup, Gaps and TODOs, Out of Scope. Keep it under 200 lines, plain Markdown, no emojis, no em dashes. Do not invent features not evidenced by the code. THEN execute SDLC phases against that PRD."

    # v7.40.0 (#584): when the autonomous complexity-gated decision in
    # run_autonomous chose the Claude Code Dynamic Workflow path
    # (USE_WORKFLOW_ANALYSIS=1), prefix the read-only analysis instruction with
    # "ultracode: " so the three-pass reverse-engineer-a-PRD flow runs as a
    # workflow fan-out. The decision (and its one-time stderr disclosure) is made
    # ONCE in run_autonomous, not here -- this subshell only reads the resolved
    # global so the prefix is deterministic per iteration. When the global is 0 /
    # unset (simple/standard + var unset, or non-Claude, or degraded, or escape
    # hatch =0), the instruction stays byte-identical to before. Parity-locked
    # with analysisInstruction() in loki-ts/src/runner/build_prompt.ts.
    if [ "${USE_WORKFLOW_ANALYSIS:-0}" = "1" ]; then
        analysis_instruction="ultracode: ${analysis_instruction}"
    fi

    # v7.8.1: incremental-update instruction for when a generated PRD already
    # exists and the codebase changed (GENERATED_PRD_ACTION=update). Reconcile,
    # do not regenerate, so the PRD stays continuous and the update is cheap.
    local update_instruction=""
    if [ "${GENERATED_PRD_ACTION:-}" = "update" ]; then
        update_instruction="GENERATED_PRD_UPDATE_MODE: A previously generated PRD exists at .loki/generated-prd.md and the codebase has changed since it was written. Do NOT regenerate it from scratch. Read the existing .loki/generated-prd.md first, then reconcile it with the current code: add requirements for new entrypoints, components, or behaviors; remove or mark obsolete requirements whose code was deleted; correct the Detected Stack and Test and Build Setup sections if they drifted. Preserve the existing structure and still-accurate content. Keep edits minimal and evidence-based, under 200 lines, plain Markdown, no emojis, no em dashes. THEN execute SDLC phases against the updated PRD."
    fi

    # Context Memory Instructions (integrated with new memory system)
    local memory_instruction="MEMORY SYSTEM: Relevant context from past sessions is provided below (if any). Your actions will be automatically recorded for future reference. For complex handoffs: create .loki/memory/handoffs/{timestamp}.md. For important decisions: they will be captured in the timeline. Check .loki/CONTINUITY.md for session-level working memory."

    # USAGE.md instruction (v7.6.0) -- always-on end-user handoff doc.
    # REGARDLESS of whether the PRD mentions it, the agent MUST write USAGE.md
    # at the project root before signaling completion. This becomes the
    # canonical "how do I run and verify this" artifact surfaced to the user
    # and to the dashboard/Purple Lab UI.
    local usage_doc_instruction="USAGE_DOC_REQUIRED: Before invoking loki_complete_task (or touching .loki/signals/COMPLETION_REQUESTED), write USAGE.md at the project root. Detect the stack from package.json/requirements.txt/Cargo.toml/go.mod/etc. and include these sections: (1) Prerequisites (runtimes, ports, env vars), (2) Install (exact command, e.g. 'npm install' or 'pip install -r requirements.txt'), (3) Start (exact command, e.g. 'npm start' or 'python server.py'), (4) Verify -- 2 to 3 copy-paste commands the user can run to confirm it works (curl examples for APIs with expected output, browser URL for web UIs, command invocation for CLIs), (5) Stop (Ctrl+C or 'lsof -ti:PORT | xargs kill -9' for backgrounded servers). Keep it under 100 lines, plain Markdown, no emojis. If USAGE.md already exists and is accurate, leave it; otherwise create or update it."

    # DOC_SCOPE instruction (F52): scale generated documentation to the detected
    # project complexity. A trivial one-file app does not warrant a nine-file
    # architecture suite (ARCHITECTURE/COMPONENTS/DECISIONS/API/SETUP/TESTING) --
    # that is token and iteration burn with no reader. USAGE.md (above) and
    # HANDOFF.md (rendered post-completion) are written regardless of tier, so this
    # string only governs the OPTIONAL architecture suite. simple -> minimal set;
    # standard/complex -> full suite only where it genuinely helps a reader.
    # MUST stay byte-identical to DOC_SCOPE_INSTRUCTION_{SIMPLE,FULL} in
    # loki-ts/src/runner/build_prompt.ts (parity-locked, same precedent as
    # COMPOSE_INSTRUCTION). Tier is read from DETECTED_COMPLEXITY (set once per run
    # by run_autonomous before the first build_prompt); cache-stable within a run.
    local doc_scope_instruction
    if [ "${DETECTED_COMPLEXITY:-standard}" = "simple" ]; then
        doc_scope_instruction="DOC_SCOPE: This is a small, simple project. Keep documentation minimal and proportional: a short README.md (what it is, install, run) plus the required USAGE.md is sufficient. Do NOT generate a multi-file architecture documentation suite (ARCHITECTURE.md, COMPONENTS.md, DECISIONS.md, API.md, SETUP.md, TESTING.md) for a project this small -- it is overhead with no reader and wastes iterations. Only add one of those files if the code genuinely warrants it (e.g. write API.md only when there is a real public API surface). Never claim a doc exists that you did not write."
    else
        doc_scope_instruction="DOC_SCOPE: Scale documentation to what a reader of THIS project actually needs -- do not generate empty boilerplate. Beyond the required USAGE.md, write a README.md and add architecture-suite docs (ARCHITECTURE.md, COMPONENTS.md, DECISIONS.md, API.md, SETUP.md, TESTING.md) only where each one carries real, project-specific content (e.g. API.md only with a real public API, COMPONENTS.md only with multiple distinct components, DECISIONS.md only when there are non-obvious design decisions). Prefer fewer accurate docs over a complete-looking suite of stubs. Never claim a doc exists that you did not write."
    fi

    # v7.7.8: LSP grounding instruction. The lsp-proxy MCP server (auto-mounted
    # when a language server is on PATH) exposes four tools that ground the
    # agent in real workspace symbols instead of hallucinated names. Before
    # writing any reference to a symbol the agent has not already read with
    # the Read tool, prefer mcp__loki-mode-lsp-proxy__lsp_check_exists. This
    # is the single most leveraged grounding primitive per OpenCode research.
    local lsp_grounding_instruction="LSP_GROUNDING: When the loki-mode-lsp-proxy MCP server is available, prefer LSP tools for symbol verification BEFORE writing code that references those symbols. Workflow: (1) Need to call \`foo.bar()\` you have not already read? -> mcp__loki-mode-lsp-proxy__lsp_check_exists with symbol='bar' (sub-200ms when cached). If exists:false, do NOT write the call -- use mcp__loki-mode-lsp-proxy__lsp_workspace_symbols with the concept name to find the real symbol, or use Read to see the actual API. (2) Just edited a file? -> mcp__loki-mode-lsp-proxy__lsp_get_diagnostics on that file to see new errors before the next iteration. (3) Need to jump to a definition by name (no file:line known)? -> mcp__loki-mode-lsp-proxy__lsp_find_definition_by_name. Skip these tools silently when the server is not available -- check the tool list, do not retry on errors. Goal: eliminate hallucinated API calls before they ship. PARALLEL_TOOL_CALLS: When issuing independent read-only operations (reads, greps, file lookups, LSP checks) that do not depend on each other, issue them in a single message so they run in parallel; do not serialize independent lookups."

    # AGENTS.md instruction (agents.md standard: plain Markdown at repo root,
    # nearest-file-wins, read natively by Claude Code/Codex/etc.). Loki prefers
    # AGENTS.md and falls back to CLAUDE.md only when AGENTS.md is absent; the
    # two are never merged. This string MUST stay byte-identical to
    # AGENTS_MD_INSTRUCTION in loki-ts/src/runner/build_prompt.ts (parity-locked,
    # same precedent as AUTONOMY_OVERRIDE_TEXT in providers/claude_flags.ts).
    local agents_md_instruction="Project conventions: read AGENTS.md in the repository root for build, test, and style conventions. If AGENTS.md is absent, read CLAUDE.md instead. The nearest such file to the code you are editing takes precedence."

    # v8 harness intelligence (3c): GOAL MEASURABILITY.
    #
    # Flags a COMPLETION_PROMISE the loop cannot hill-climb (no number, no
    # comparison threshold, no named metric, no verifiable artifact). An agent
    # can only climb toward a goal it can MEASURE; an unmeasurable goal lets
    # every iteration claim progress and lets none be checked.
    #
    # PARITY-LOCKED. This block MUST stay byte-identical to
    # goalSharpeningInstruction() in loki-ts/src/runner/goal_score.ts, including
    # the suppression rules, or the build_prompt parity fixtures diverge.
    # Suppressed when: the goal is empty (perpetual runs set no promise by
    # design), PERPETUAL/AUTONOMY_MODE=perpetual (open-endedness IS the chosen
    # configuration there), or LOKI_GOAL_SCORING=0. Advisory only: it never
    # blocks a build and never rewrites the user's goal.
    local goal_sharpening_instruction=""
    if [ -n "${COMPLETION_PROMISE:-}" ] \
       && [ "${LOKI_GOAL_SCORING:-}" != "0" ] \
       && [ "${AUTONOMY_MODE:-}" != "perpetual" ] \
       && [ "${PERPETUAL_MODE:-}" != "true" ] && [ "${PERPETUAL_MODE:-}" != "1" ]; then
        local _goal_lc _goal_dims
        _goal_lc="$(printf '%s' "$COMPLETION_PROMISE" | tr '[:upper:]' '[:lower:]')"
        _goal_dims=0
        # Mirrors the four DIMENSIONS regexes in goal_score.ts, in the same order.
        printf '%s' "$_goal_lc" | grep -qE '[0-9]+(\.[0-9]+)?[[:space:]]*(%|ms|s\b|sec|second|min|minute|hour|day|kb|mb|gb|rps|qps|req|x\b|users?|items?|rows?)' && _goal_dims=$((_goal_dims + 1))
        printf '%s' "$_goal_lc" | grep -qE '\b(under|below|less than|no more than|at most|over|above|greater than|at least|within|between|<=?|>=?)\b' && _goal_dims=$((_goal_dims + 1))
        printf '%s' "$_goal_lc" | grep -qE '\b(latency|throughput|p50|p95|p99|uptime|error rate|conversion|coverage|score|accuracy|precision|recall|bundle size|load time|response time|memory|cpu|cost)\b' && _goal_dims=$((_goal_dims + 1))
        printf '%s' "$_goal_lc" | grep -qE '\b(tests? pass|builds? clean|endpoint|returns? [0-9]{3}|exit code|schema|migration|deploys?|renders?|compiles?)\b' && _goal_dims=$((_goal_dims + 1))
        # Only a goal with ZERO measurable dimensions is flagged (score == 0),
        # matching goalNeedsSharpening(). Flagging partially-measurable goals
        # would cry wolf, and a scorer that cries wolf gets ignored.
        if [ "$_goal_dims" -eq 0 ]; then
            local _goal_rationale
            if printf '%s' "$_goal_lc" | grep -qE '\b(fast|slow|good|bad|nice|clean|better|best|modern|beautiful|intuitive|robust|scalable|user-friendly|production-ready|polished|seamless)\b'; then
                _goal_rationale="Subjective goal with no measurable target: every iteration can claim progress and none can be verified. Add a number, a comparison, or a concrete artifact to check."
            else
                _goal_rationale="No measurable target detected: the loop has no gradient to climb. Add an explicit success threshold."
            fi
            goal_sharpening_instruction="GOAL_MEASURABILITY: the stated goal is not currently hill-climbable. ${_goal_rationale} Before implementing, restate it with at least one checkable success condition (a number with a unit, a comparison threshold, or a concrete artifact such as a passing test or an endpoint returning a specific status), and record that restatement so each iteration can be measured against it. Do NOT silently substitute your own easier goal -- if the goal cannot be sharpened from the spec alone, say so explicitly and state the assumption you are proceeding under."
        fi
    fi

    # Compose-first instruction (v7.26.0): unconditional string with conditional
    # phrasing (YOU decide whether the app warrants compose, not a static grep).
    # When an app needs more than one running service (web + database and/or
    # cache), a docker-compose.yml is the run contract Loki's app runner uses to
    # bring the whole stack up locally. This string MUST stay byte-identical to
    # COMPOSE_INSTRUCTION in loki-ts/src/runner/build_prompt.ts (parity-locked).
    local compose_instruction="RUN_CONTRACT: Decide whether this app needs more than one running service. If it needs a database (PostgreSQL/MySQL/MongoDB) and/or a cache (Redis) and/or separate backend and frontend services, generate a 12-factor docker-compose.yml at the project root so the whole stack runs with one command. Requirements when you generate it: (1) name the primary web/app service so it is obvious (service key 'web' or 'app', or add the label 'loki.primary=true' on it) and publish its HTTP port (host:container, e.g. '3000:3000'); (2) give every service a healthcheck (the web service must have an HTTP healthcheck so 'up' means actually serving, not just started); (3) wire dependencies with depends_on and config via environment variables; (4) write a .env.example listing every required variable with safe placeholder values; (5) keep secrets out of the compose file and out of git. If the app is a single service with no datastore, do NOT add compose; a plain run command is correct. If a working docker-compose.yml already exists and matches the app, leave it; otherwise create or update it. Verify the stack comes up (docker compose up) before claiming completion."

    # Load existing context if resuming
    local context_injection=""
    if [ $retry -gt 0 ]; then
        local ledger=""
        type -t load_ledger_context &>/dev/null && ledger=$(load_ledger_context)
        local handoff=""
        type -t load_handoff_context &>/dev/null && handoff=$(load_handoff_context)

        if [ -n "$ledger" ]; then
            context_injection="PREVIOUS_LEDGER_STATE: $ledger"
        fi
        if [ -n "$handoff" ]; then
            context_injection="$context_injection RECENT_HANDOFF: $handoff"
        fi
    fi

    # Load pre-computed startup learnings (from CLI load_memory_context)
    # These are loaded once at CLI start and cached in .loki/state/memory-context.json
    local startup_learnings=""
    if [ $iteration -eq 1 ]; then
        startup_learnings=$(load_startup_learnings)
        if [ -n "$startup_learnings" ]; then
            context_injection="$context_injection $startup_learnings"
        fi
    fi

    # Retrieve relevant memories from new memory system
    local memory_context=""
    # Determine goal for memory retrieval
    local goal_for_memory=""
    if [ -n "$prd" ]; then
        goal_for_memory="Execute PRD at $prd"
    else
        goal_for_memory="Analyze codebase and generate improvements"
    fi
    # Determine current phase
    local phase_for_memory="iteration-$iteration"
    memory_context=$(retrieve_memory_context "$goal_for_memory" "$phase_for_memory")
    if [ -n "$memory_context" ]; then
        context_injection="$context_injection $memory_context"
    fi

    # Phase F (v7.5.23): inject layered CLAUDE.md context from sibling repos
    # when this target is part of a cross-project graph. Silent no-op when
    # LOKI_PROJECT_GRAPH_ROOT is unset (single-project workflows untouched).
    local _pg_helper_rs="${PROJECT_DIR}/autonomy/lib/project-graph.sh"
    if [ -f "$_pg_helper_rs" ]; then
        # shellcheck disable=SC1090
        . "$_pg_helper_rs" 2>/dev/null || true
        if [ -n "${LOKI_PROJECT_GRAPH_ROOT:-}" ] && declare -f load_app_graph_context >/dev/null 2>&1; then
            local app_graph_context=""
            app_graph_context=$(load_app_graph_context 2>/dev/null || true)
            if [ -n "$app_graph_context" ]; then
                context_injection="$context_injection APP_GRAPH_CONTEXT: $app_graph_context"
            fi
        fi
    fi

    # Gate failure injection (v6.7.0) - tells LLM what to fix
    local gate_failure_context=""
    if [ -f "${TARGET_DIR:-.}/.loki/quality/gate-failures.txt" ]; then
        local failures
        # Cap at the FIRST 8000 bytes to bound prompt context growth from a large
        # prior-iteration gate-failures dump. Parity with the Bun route's
        # readBytesSafe(gfPath, 8000), which does buf.subarray(0, 8000) (head, not tail).
        failures=$(head -c 8000 "${TARGET_DIR:-.}/.loki/quality/gate-failures.txt")
        gate_failure_context="QUALITY GATE FAILURES FROM PREVIOUS ITERATION: [$failures]. "
        if [ -f "${TARGET_DIR:-.}/.loki/quality/static-analysis.json" ]; then
            local sa_summary
            sa_summary=$(python3 -c "import json; d=json.load(open('${TARGET_DIR:-.}/.loki/quality/static-analysis.json')); print(d.get('summary',''))" 2>/dev/null || echo "")
            [ -n "$sa_summary" ] && gate_failure_context="${gate_failure_context}Static analysis: ${sa_summary}. "
        fi
        if [ -f "${TARGET_DIR:-.}/.loki/quality/test-results.json" ]; then
            local test_summary
            test_summary=$(python3 -c "import json; d=json.load(open('${TARGET_DIR:-.}/.loki/quality/test-results.json')); print(d.get('summary',''))" 2>/dev/null || echo "")
            [ -n "$test_summary" ] && gate_failure_context="${gate_failure_context}Tests: ${test_summary}. "
        fi
        # P0-1 Fix A: when a coverage block fired (LOKI_ENFORCE_COVERAGE=1 +
        # measurable + below threshold), give the agent the ACCURATE reason. The
        # generic test_coverage token plus a passing test summary would otherwise
        # read as a contradictory "fix the tests" when the tests actually passed
        # and it is coverage that is low. Surface coverage.json so the next
        # iteration writes MORE TESTS rather than chasing a phantom red suite.
        if [ -f "${TARGET_DIR:-.}/.loki/quality/coverage.json" ]; then
            local cov_summary
            cov_summary=$(_LOKI_GFC="${TARGET_DIR:-.}/.loki/quality/coverage.json" python3 -c "
import json, os
try:
    d=json.load(open(os.environ['_LOKI_GFC']))
except Exception:
    raise SystemExit
if d.get('blocked'):
    print('Coverage %s%% is below the %s%% threshold (tests PASS; add tests to raise line coverage, do not change passing assertions).' % (d.get('pct'), d.get('threshold')))
" 2>/dev/null || echo "")
            [ -n "$cov_summary" ] && gate_failure_context="${gate_failure_context}${cov_summary} "
        fi
        gate_failure_context="${gate_failure_context}FIX THESE ISSUES BEFORE PROCEEDING WITH NEW WORK."
    fi

    # SLICE 6c self-heal: route the prior iteration's classified error signature
    # into this iteration's prompt so the loop fixes forward. Opt-in via
    # LOKI_SELF_HEAL (default 0 -- stock runs are unaffected). Consumes (archives
    # then clears) LAST_ERROR.json so the hint injects exactly once. A separate
    # dynamic var (NOT part of gate_failure_context) so it surfaces even when no
    # gate wrote gate-failures.txt (a provider_empty_output / rate_limited /
    # auth_error iteration failure leaves LAST_ERROR but no gate token).
    local self_heal_context=""
    if [ "${LOKI_SELF_HEAL:-0}" = "1" ]; then
        self_heal_context="$(_loki_build_self_heal_hint)"
    fi

    # P1-3: surface specific semantic test-authenticity findings (which fake test,
    # which line) when the opt-in gate (LOKI_GATE_SEMANTIC_TESTS) wrote them, so a
    # block converges: the agent gets the exact files/lines to fix rather than a
    # bare gate name. The file exists only when the gate ran AND found something
    # (cleared on clean), so this is zero-cost on the default path and when off.
    # Mirrors the static-analysis/test-results detail-surfacing above. Surfaced
    # whether the run blocked (CRIT/HIGH) or only advised (MED/LOW): both inform
    # the next iteration. Independent of gate-failures.txt presence (the
    # completion-promise arm does not append a gate token).
    if [ -f "${TARGET_DIR:-.}/.loki/quality/semantic-findings.txt" ]; then
        local sem_findings
        sem_findings=$(grep -E '\[(CRITICAL|HIGH|MEDIUM|LOW)\]' "${TARGET_DIR:-.}/.loki/quality/semantic-findings.txt" 2>/dev/null | head -20 || true)
        if [ -n "$sem_findings" ]; then
            gate_failure_context="${gate_failure_context} SEMANTIC TEST-AUTHENTICITY FINDINGS (fix the fake tests; an assertion must verify a value that flows through the code under test, not echo a literal back): ${sem_findings}"
        fi
    fi

    # P1-4 / v7.57.0: surface specific invariant/property findings (which file,
    # which violated invariant) when the default-on advisory gate (or the opt-in
    # LOKI_GATE_INVARIANTS_BLOCK gate) wrote them, so a block converges and an
    # advisory run still informs the next iteration. The file exists only when
    # the gate ran AND found something (cleared on clean), so this is zero-cost on
    # a clean run and when the surfacing gate is opted out. Mirrors the semantic
    # injector above and is independent of gate-failures.txt presence.
    if [ -f "${TARGET_DIR:-.}/.loki/quality/invariant-findings.txt" ]; then
        local inv_findings
        inv_findings=$(grep -E '\[(CRITICAL|HIGH|MEDIUM|LOW)\]' "${TARGET_DIR:-.}/.loki/quality/invariant-findings.txt" 2>/dev/null | head -20 || true)
        if [ -n "$inv_findings" ]; then
            gate_failure_context="${gate_failure_context} INVARIANT/PROPERTY FINDINGS (fix the violated invariants; the code must preserve the stated property/metamorphic relation, not just pass example-based tests): ${inv_findings}"
        fi
    fi

    # Consume repeated-blocker guidance on the next attempt. The JSON remains
    # durable for dashboards and operators, while this prompt context forces a
    # strategy change instead of merely recording the escalation.
    local gate_escalation_context=""
    gate_escalation_context=$(build_gate_escalation_context)
    local combined_gate_context="$gate_failure_context"
    if [ -n "$gate_escalation_context" ]; then
        combined_gate_context="${combined_gate_context}${combined_gate_context:+ }${gate_escalation_context}"
    fi

    # P2-2: high-severity spec-assumption context. When DISCOVERY recorded any
    # high-severity assumption (the spec was ambiguous in a high-impact place),
    # surface it to the build agent so it implements with the gap in view (or
    # fixes the spec) instead of obliviously coding past it. spec_ledger_prompt_block
    # is defined when spec-interrogation.sh is sourced (run.sh sources it in
    # DISCOVERY); guarded so build_prompt is safe when the module is absent.
    local assumption_context=""
    if type spec_ledger_prompt_block &>/dev/null; then
        assumption_context="$(spec_ledger_prompt_block 2>/dev/null || true)"
    fi

    # Human directive injection (from HUMAN_INPUT.md)
    # NOTE: Do NOT unset LOKI_HUMAN_INPUT here - build_prompt runs in a subshell
    # (command substitution) so unset would not affect the parent shell.
    # The caller (run_autonomous) clears it after consuming the prompt.
    local human_directive=""
    if [ -n "${LOKI_HUMAN_INPUT:-}" ]; then
        human_directive="HUMAN_DIRECTIVE (PRIORITY): $LOKI_HUMAN_INPUT Execute this directive BEFORE continuing normal tasks."
    fi

    # Queue task injection (from dashboard or API)
    local queue_tasks=""
    queue_tasks=$(load_queue_tasks)
    if [ -n "$queue_tasks" ]; then
        queue_tasks="QUEUED_TASKS (PRIORITY): $queue_tasks. Execute these tasks BEFORE finding new improvements."
    fi

    # Build memory context section (only if we have context)
    local memory_context_section=""
    if [ -n "$context_injection" ]; then
        memory_context_section="CONTEXT: $context_injection"
    fi

    # Efficiency trend injection -- close the eval feedback loop.
    # .loki/metrics/efficiency/iteration-N.json has been written every iteration
    # for the engine's entire life and read back only by a stop-only budget
    # breaker and an offline report, never by the agent producing the cost.
    #
    # SINGLE RENDERER: the text comes from iteration_attribution.py --prompt-block,
    # the exact same entry point the Bun route calls (build_prompt.ts
    # buildEfficiencyTrend), so the two routes are byte-identical by construction
    # rather than by two renderers kept in sync forever.
    #
    # Emits "" on absent/empty metrics, so an unmeasured run adds NOTHING.
    # Opt out with LOKI_EVAL_TREND=0.
    # Accepts BOTH "0" and "false" (case-insensitive): this repo uses both
    # toggle conventions, and honouring only one makes the other a silent no-op.
    # Byte-mirrored in build_prompt.ts buildEfficiencyTrend().
    local _eval_trend_optout
    _eval_trend_optout="$(printf '%s' "${LOKI_EVAL_TREND:-1}" | tr '[:upper:]' '[:lower:]')"
    local efficiency_trend=""
    if [ "$_eval_trend_optout" != "0" ] && [ "$_eval_trend_optout" != "false" ] \
        && [ -r "${SCRIPT_DIR}/lib/iteration_attribution.py" ] \
        && [ -d ".loki" ]; then
        efficiency_trend="$(python3 "${SCRIPT_DIR}/lib/iteration_attribution.py" \
            --loki-dir ".loki" --prompt-block 2>/dev/null || true)"
    fi

    # Failure memory (read side; write side: track_gate_failure). Tells this
    # iteration what has actually failed in THIS repo before, so a gate the
    # agent has already lost to is not re-learned from scratch every run.
    #
    # COUNTS, NOT PROSE, and that restriction is the whole point. "the
    # mock_integrity gate has failed here 6 times" is a fact the reader can
    # check; "this repo tends to have mocking problems" is a generalization that
    # reads identically and is not falsifiable. The module renders the lines and
    # this only prints them -- no second renderer to drift, matching the
    # single-renderer discipline used for efficiency_trend above.
    #
    # Emits "" when nothing has been recorded, so a repo with no failure history
    # adds NOTHING to the prompt (and the 60 build_prompt parity fixtures, none
    # of which carry a failures.jsonl, stay byte-identical).
    #
    # Calls prompt_context() in-process rather than the CLI: the CLI prints JSON
    # and exits 3 on an UNKNOWN status, which is an expected "no lessons yet"
    # result and not an error worth parsing around.
    local failure_memory_context=""
    if [ -r "${SCRIPT_DIR}/lib/failure_memory.py" ] && [ -d ".loki" ]; then
        failure_memory_context="$(_FM_LIB="${SCRIPT_DIR}/lib" \
            _FM_DIR="${LOKI_DIR:-${TARGET_DIR:-.}/.loki}" python3 -c '
import os, sys
sys.path.insert(0, os.environ["_FM_LIB"])
try:
    from failure_memory import prompt_context
    lines = prompt_context(os.environ["_FM_DIR"]).get("lines") or []
except Exception:
    lines = []
if lines:
    print("KNOWN FAILURE HISTORY IN THIS REPO (measured, from previous runs): "
          + "; ".join(lines) + ".")
' 2>/dev/null || true)"
    fi

    # PRD Checklist status injection (v5.44.0)
    local checklist_status=""
    if [ -n "$prd" ] && [ ! -f ".loki/checklist/checklist.json" ]; then
        # First iteration with PRD but no checklist yet: instruct AI to create it
        checklist_status="PRD_CHECKLIST_INIT: Create .loki/checklist/checklist.json from the PRD. Extract requirements into categories with items. Each item needs: id, title, description, priority (critical|major|minor), and verification checks (file_exists, file_contains, tests_pass, grep_codebase, command). This checklist will be auto-verified every ${CHECKLIST_INTERVAL:-5} iterations."
    elif type checklist_summary &>/dev/null && [ -f ".loki/checklist/verification-results.json" ]; then
        checklist_status=$(checklist_summary 2>/dev/null || true)
        if [ -n "$checklist_status" ]; then
            checklist_status="PRD_CHECKLIST_STATUS: ${checklist_status}. Review failing items and prioritize fixing them in this iteration."
        fi
    fi

    # App Runner status injection (v5.45.0)
    local app_runner_info=""
    if [ -f ".loki/app-runner/state.json" ]; then
        app_runner_info=$(python3 -c "
import json
try:
    d = json.load(open('.loki/app-runner/state.json'))
    s = d.get('status', '')
    if s == 'running':
        print('APP_RUNNING_AT: ' + d.get('url', '') + ' (auto-restarts on code changes). Method: ' + d.get('method', ''))
    elif s == 'crashed':
        print('APP_CRASHED: Application has crashed ' + str(d.get('crash_count', 0)) + ' times. Check .loki/app-runner/app.log for errors.')
except: pass
" 2>/dev/null || true)
    fi

    # Playwright verification status injection (v5.46.0)
    local playwright_info=""
    if [ -f ".loki/verification/playwright-results.json" ]; then
        playwright_info=$(python3 -c "
import json
try:
    d = json.load(open('.loki/verification/playwright-results.json'))
    if d.get('passed'):
        print('PLAYWRIGHT_SMOKE_TEST: PASSED - App loads correctly.')
    else:
        errors = d.get('errors', [])
        checks = d.get('checks', {})
        failing = [k for k, v in checks.items() if not v]
        print('PLAYWRIGHT_SMOKE_TEST: FAILED - ' + ', '.join(failing[:3]) + ('. Errors: ' + '; '.join(errors[:3]) if errors else ''))
except: pass
" 2>/dev/null || true)
    fi

    # BMAD context injection (if available)
    local bmad_context=""
    if [[ -f ".loki/bmad-metadata.json" ]]; then
        local bmad_arch=""
        if [[ -f ".loki/bmad-architecture-summary.md" ]]; then
            bmad_arch=$(head -c 16000 ".loki/bmad-architecture-summary.md")
        fi
        local bmad_tasks=""
        if [[ -f ".loki/bmad-tasks.json" ]]; then
            bmad_tasks=$(python3 -c "
import json, sys
try:
    with open('.loki/bmad-tasks.json') as f:
        data = json.load(f)
    out = json.dumps(data, indent=None)
    if len(out) > 32000 and isinstance(data, list):
        while len(json.dumps(data, indent=None)) > 32000 and data:
            data.pop()
        out = json.dumps(data, indent=None)
    print(out[:32000])
except: pass
" 2>/dev/null)
        fi
        local bmad_validation=""
        if [[ -f ".loki/bmad-validation.md" ]]; then
            bmad_validation=$(head -c 8000 ".loki/bmad-validation.md")
        fi
        bmad_context="BMAD_CONTEXT: This project uses BMAD Method structured artifacts. Architecture decisions and epic/story breakdown are provided below."
        if [[ -n "$bmad_arch" ]]; then
            bmad_context="$bmad_context ARCHITECTURE DECISIONS: $bmad_arch"
        fi
        if [[ -n "$bmad_tasks" ]]; then
            bmad_context="$bmad_context EPIC/STORY TASKS (from BMAD): $bmad_tasks"
        fi
        if [[ -n "$bmad_validation" ]]; then
            bmad_context="$bmad_context ARTIFACT VALIDATION: $bmad_validation"
        fi
    fi

    # OpenSpec delta context injection (if available)
    local openspec_context=""
    if [[ -f ".loki/openspec/delta-context.json" ]]; then
        openspec_context=$(_DELTA_FILE=".loki/openspec/delta-context.json" python3 -c "
import json, os
try:
    with open(os.environ['_DELTA_FILE']) as f:
        data = json.load(f)
    parts = ['OPENSPEC DELTA CONTEXT:']
    for domain, deltas in data.get('deltas', {}).items():
        for req in deltas.get('added', []):
            parts.append(f'  ADDED [{domain}]: {req[\"name\"]} - Create new code following existing patterns')
        for req in deltas.get('modified', []):
            parts.append(f'  MODIFIED [{domain}]: {req[\"name\"]} - Find and update existing code, do NOT create new files. Previously: {req.get(\"previously\", \"N/A\")}')
        for req in deltas.get('removed', []):
            parts.append(f'  REMOVED [{domain}]: {req[\"name\"]} - Deprecate or remove. Reason: {req.get(\"reason\", \"N/A\")}')
    parts.append(f'Complexity: {data.get(\"complexity\", \"unknown\")}')
    print(' '.join(parts))
except Exception:
    pass
" 2>/dev/null || true)
    fi

    # MiroFish market validation context injection (if available)
    local mirofish_context=""
    if [[ -f ".loki/mirofish-context.json" ]]; then
        mirofish_context=$(python3 -c "
import json
try:
    with open('.loki/mirofish-context.json') as f:
        data = json.load(f)
    parts = ['MIROFISH MARKET VALIDATION:']
    adv = data.get('analysis', {})
    summary = adv.get('overall_sentiment', '')
    score = adv.get('sentiment_score', 0)
    conf = adv.get('confidence', '')
    rec = adv.get('recommendation', '')
    if summary:
        parts.append(f'Overall: {summary} (score={score}, confidence={conf}, recommendation={rec})')
    concerns = adv.get('key_concerns', [])
    if concerns:
        parts.append('Key Concerns: ' + '; '.join(c[:200] for c in concerns[:5]))
    rankings = adv.get('feature_rankings', [])
    if rankings:
        ranked = ', '.join(f'{r[\"feature\"]}={r[\"reception_score\"]}' for r in rankings[:5])
        parts.append(f'Feature Reception: {ranked}')
    quotes = adv.get('notable_quotes', [])
    if quotes:
        parts.append('Agent Quotes: ' + ' | '.join(q[:150] for q in quotes[:3]))
    parts.append('NOTE: MiroFish results are advisory only. They do NOT override Completion Council or quality gates.')
    print(' '.join(parts))
except Exception:
    pass
" 2>/dev/null || true)
    elif [[ -f ".loki/mirofish/pipeline-state.json" ]]; then
        mirofish_context=$(python3 -c "
import json, os
try:
    with open('.loki/mirofish/pipeline-state.json') as f:
        state = json.load(f)
    status = state.get('status', 'unknown')
    stage = state.get('current_stage', 0)
    pid = state.get('pid', 0)
    alive = False
    if pid:
        try:
            os.kill(pid, 0)
            alive = True
        except OSError:
            pass
    if status == 'running' and alive:
        s3 = state.get('stages', {}).get('3_simulation', {})
        progress = ''
        if s3.get('status') == 'running':
            cr = s3.get('current_round', 0)
            tr = s3.get('total_rounds', 0)
            if tr:
                progress = f' (simulation round {cr}/{tr})'
        print(f'MIROFISH_STATUS: Market validation running stage {stage}/4{progress}. Advisory will appear when complete.')
    elif status == 'failed':
        error = state.get('error', 'unknown')[:200]
        print(f'MIROFISH_STATUS: Market validation failed at stage {stage}: {error}. Proceeding without.')
except Exception:
    pass
" 2>/dev/null || true)
    fi

    # Magic Modules context injection
    local magic_context=""
    local magic_specs_dir="$TARGET_DIR/.loki/magic/specs"
    if [ -d "$magic_specs_dir" ]; then
        local spec_count
        spec_count=$(find "$magic_specs_dir" -maxdepth 1 -name "*.md" 2>/dev/null | wc -l | tr -d ' ')
        if [ "$spec_count" -gt 0 ]; then
            local spec_list
            # v7.4.9: pipe through `sort` so output is filesystem-independent.
            # Pre-v7.4.9 this was raw `find` order which varies between macOS
            # (APFS creation order) and Linux (ext4 hash-table order). Sorting
            # alphabetically here matches the TS port which now also sorts.
            spec_list=$(find "$magic_specs_dir" -maxdepth 1 -name "*.md" -exec basename {} .md \; 2>/dev/null | sort | tr '\n' ',' | sed 's/,$//')
            magic_context="MAGIC_MODULES: ${spec_count} component specs exist: ${spec_list}. To add or update a component: write markdown to ${magic_specs_dir}/<Name>.md and run 'loki magic update'. The spec becomes source of truth; implementation regenerates automatically. Debate runs in VERIFY phase -- if accessibility or performance blocks, refine the spec and re-run."
        else
            magic_context="MAGIC_MODULES: available. To create UI components, write spec at ${magic_specs_dir}/<Name>.md and run 'loki magic update'. Spec-driven generation produces React + Web Component variants with auto-generated tests. Debate gate runs in VERIFY."
        fi
    fi

    # S1.1 -- Static-first prompt assembly with cache-breakpoint marker.
    #
    # The prior shape (v<=6.81.x) concatenated ~13 dynamic blobs BEFORE the
    # 4-5 static instruction blobs, which destroyed Claude's prefix cache on
    # every iteration. The new layout places the stable instruction set first
    # (prd_anchor + RARV/SDLC/autonomy/memory instructions), emits a literal
    # [CACHE_BREAKPOINT] marker, then appends the volatile per-iteration
    # context inside a <dynamic_context> tag.
    #
    # The [CACHE_BREAKPOINT] marker is a documentation anchor today. When the
    # Claude CLI migration exposes cache_control, the orchestrator can split
    # the prompt at this marker and set cache_control on the prefix half.
    #
    # Rollback: set LOKI_LEGACY_PROMPT_ORDERING=true to restore the previous
    # dynamic-first concatenation order.

    if [ "${LOKI_LEGACY_PROMPT_ORDERING:-false}" = "true" ]; then
        # Legacy dynamic-first ordering (pre-v6.82.0). Retained for rollback.
        if [ "${PROVIDER_DEGRADED:-false}" = "true" ]; then
            local _legacy_prd_content=""
            local _legacy_priority="$human_directive"
            if [ -n "$gate_escalation_context" ]; then
                _legacy_priority="${_legacy_priority}${_legacy_priority:+ }${gate_escalation_context}"
            fi
            if [ -n "$prd" ] && [ -f "$prd" ]; then
                _legacy_prd_content=$(head -c 4000 "$prd")
            fi
            if [ $retry -eq 0 ]; then
                if [ -n "$prd" ]; then
                    echo "You are a coding assistant. Read and implement the requirements from the PRD below. Write working code, run tests if possible, and commit changes. ${_legacy_priority:+Priority: $_legacy_priority} ${queue_tasks:+Tasks: $queue_tasks} PRD contents: $_legacy_prd_content"
                else
                    echo "You are a coding assistant. Analyze this codebase and suggest improvements. Write working code and commit changes. ${_legacy_priority:+Priority: $_legacy_priority} ${queue_tasks:+Tasks: $queue_tasks}"
                fi
            else
                if [ -n "$prd" ]; then
                    echo "You are a coding assistant. Continue working on iteration $iteration. Review what exists, implement remaining PRD requirements, fix any issues, add tests. ${_legacy_priority:+Priority: $_legacy_priority} ${queue_tasks:+Tasks: $queue_tasks} PRD contents: $_legacy_prd_content"
                else
                    echo "You are a coding assistant. Continue working on iteration $iteration. Review what exists, improve code, fix bugs, add tests. ${_legacy_priority:+Priority: $_legacy_priority} ${queue_tasks:+Tasks: $queue_tasks}"
                fi
            fi
        else
            if [ $retry -eq 0 ]; then
                if [ -n "$prd" ]; then
                    echo "Loki Mode with PRD at $prd. $update_instruction $human_directive $combined_gate_context $assumption_context $queue_tasks $bmad_context $openspec_context $mirofish_context $magic_context $checklist_status $app_runner_info $playwright_info $memory_context_section $rarv_instruction $memory_instruction $usage_doc_instruction $doc_scope_instruction $compose_instruction $lsp_grounding_instruction $agents_md_instruction $completion_instruction $sdlc_instruction $autonomous_suffix"
                else
                    echo "Loki Mode. $human_directive $combined_gate_context $assumption_context $queue_tasks $bmad_context $openspec_context $mirofish_context $magic_context $checklist_status $app_runner_info $playwright_info $memory_context_section $analysis_instruction $rarv_instruction $memory_instruction $usage_doc_instruction $doc_scope_instruction $compose_instruction $lsp_grounding_instruction $agents_md_instruction $completion_instruction $sdlc_instruction $autonomous_suffix"
                fi
            else
                if [ -n "$prd" ]; then
                    echo "Loki Mode - Resume iteration #$iteration (retry #$retry). PRD: $prd. $human_directive $combined_gate_context $assumption_context $queue_tasks $bmad_context $openspec_context $mirofish_context $magic_context $checklist_status $app_runner_info $playwright_info $memory_context_section $rarv_instruction $memory_instruction $usage_doc_instruction $doc_scope_instruction $compose_instruction $lsp_grounding_instruction $agents_md_instruction $completion_instruction $sdlc_instruction $autonomous_suffix"
                else
                    echo "Loki Mode - Resume iteration #$iteration (retry #$retry). $human_directive $combined_gate_context $assumption_context $queue_tasks $bmad_context $openspec_context $mirofish_context $magic_context $checklist_status $app_runner_info $playwright_info $memory_context_section Use .loki/generated-prd.md if exists. $rarv_instruction $memory_instruction $usage_doc_instruction $doc_scope_instruction $compose_instruction $lsp_grounding_instruction $agents_md_instruction $completion_instruction $sdlc_instruction $autonomous_suffix"
                fi
            fi
        fi
        return 0
    fi

    # --- New static-first layout (v6.82.0+) ---
    #
    # assemble_prompt_static outputs the cache-stable prefix:
    #   <loki_system>
    #   {prd_anchor}
    #   {rarv_instruction + sdlc_instruction + autonomous_suffix + memory_instruction}
    #   </loki_system>
    #   [CACHE_BREAKPOINT]
    #
    # assemble_prompt_dynamic outputs the volatile tail wrapped in
    # <dynamic_context iteration=".." retry=".."> ... </dynamic_context>.
    #
    # Keeping these as inline local helpers (nested functions via eval are
    # awkward in bash) -- we emit them as two contiguous printf blocks so the
    # logic is self-documenting and byte-reproducible.

    if [ "${PROVIDER_DEGRADED:-false}" = "true" ]; then
        # Degraded providers: simpler wording, but still static-first.
        local prd_content=""
        if [ -n "$prd" ] && [ -f "$prd" ]; then
            prd_content=$(head -c 4000 "$prd")
        fi

        local degraded_prd_anchor="Loki Mode"
        [ -n "$prd" ] && degraded_prd_anchor="Loki Mode with PRD"

        # STATIC PREFIX (cache-stable across iterations)
        printf '<loki_system>\n'
        printf '%s\n' "$degraded_prd_anchor"
        if [ -n "$prd" ]; then
            printf 'You are a coding assistant. Read and implement the requirements from the PRD. Write working code, run tests if possible, and commit changes.\n'
        else
            printf 'You are a coding assistant. Analyze this codebase and suggest improvements. Write working code and commit changes.\n'
        fi
        printf '%s\n' "$usage_doc_instruction"
        printf '%s\n' "$doc_scope_instruction"
        printf '%s\n' "$compose_instruction"
        printf '%s\n' "$lsp_grounding_instruction"
        printf '%s\n' "$agents_md_instruction"
        printf '</loki_system>\n'
        printf '[CACHE_BREAKPOINT]\n'

        # DYNAMIC TAIL (changes every iteration)
        printf '<dynamic_context iteration="%s" retry="%s">\n' "$iteration" "$retry"
        [ -n "$human_directive" ] && printf 'Priority: %s\n' "$human_directive"
        [ -n "$gate_escalation_context" ] && printf '%s\n' "$gate_escalation_context"
        [ -n "$queue_tasks" ] && printf 'Tasks: %s\n' "$queue_tasks"
        if [ -n "$prd" ]; then
            printf 'PRD contents: %s\n' "$prd_content"
        fi
        printf '</dynamic_context>\n'
        return 0
    fi

    # Full-featured providers (Claude, etc.)
    local prd_anchor
    if [ -n "$prd" ]; then
        prd_anchor="Loki Mode with PRD at $prd"
    else
        prd_anchor="Loki Mode"
    fi

    # STATIC PREFIX (cache-stable across iterations).
    # Order is deterministic so the prefix is byte-identical for iter N and N+1.
    #
    # LOKI_SIMPLE=1 -- THE ABLATION ARM. Default off; the emitted bytes are
    # unchanged unless it is explicitly set, so parity fixtures do not move.
    #
    # WHY THIS EXISTS. Every instruction below was written to correct a model
    # that needed correcting. Anthropic deleted ~80% of Claude Code's system
    # prompt for Opus 5 on the finding that the corrections had become dead
    # weight -- and that the model measured slightly MORE capable without them.
    # Their method was ablation: delete, then add back only what a measured
    # failure demands. Nothing here had ever been measured at all.
    #
    # THE DISTINCTION THIS FLAG IS BUILT AROUND, and the reason it strips the
    # prefix while leaving the tail completely alone:
    #
    #   The prefix is COACHING -- how to work. "Use a Reason-Act-Reflect-Verify
    #   cycle", "execute all SDLC phases", "consult memory". A frontier model
    #   does these natively; being told costs attention and buys nothing.
    #
    #   The tail is STATE -- what happened. Which gate failed, what the
    #   self-heal found, what the checklist still shows open. That is
    #   information the model cannot derive from anywhere else, and deleting
    #   it would be deleting the run's memory, not its lecture.
    #
    # So this ablates coaching ONLY. The dynamic tail below is untouched, and
    # so is every gate, receipt, and verification path: the trust core is not
    # prompt correction, and it is never an ablation arm.
    #
    # prd_anchor stays in both arms -- it names the task, which is the one
    # thing the model genuinely cannot infer.
    #
    # MEASURED, with its provenance: 8090 -> 1776 bytes (-78%) from a LIVE
    # build_prompt call under the fixture-1 ENVIRONMENT with a gate-failure
    # file present -- not from the fixture file itself, which is 7909 bytes.
    # The distinction matters because a number attributed to the wrong source
    # cannot be reproduced by the next person who tries.
    #
    # The strip is bounded: only the block below is gated, so the anchor and
    # the surrounding tags survive. Prefix size is a CEILING on the saving.
    printf '<loki_system>\n'
    printf '%s\n' "$prd_anchor"
    if [ "${LOKI_SIMPLE:-0}" != "1" ]; then
    printf '%s\n' "$rarv_instruction"
    printf '%s\n' "$sdlc_instruction"
    printf '%s\n' "$autonomous_suffix"
    printf '%s\n' "$memory_instruction"
    printf '%s\n' "$usage_doc_instruction"
    printf '%s\n' "$doc_scope_instruction"
    printf '%s\n' "$compose_instruction"
    printf '%s\n' "$lsp_grounding_instruction"
    printf '%s\n' "$agents_md_instruction"
    fi
    # v8 (3c): goal-measurability advisory. Empty (and therefore not emitted at
    # all) for a measurable goal, an absent goal, or perpetual mode. Sits in the
    # static prefix because COMPLETION_PROMISE is fixed for the run, so it stays
    # cache-stable. Position mirrors build_prompt.ts exactly.
    [ -n "$goal_sharpening_instruction" ] && printf '%s\n' "$goal_sharpening_instruction"
    # For codebase-analysis mode (no PRD), analysis_instruction is part of the
    # static prefix so it remains cache-stable.
    if [ -z "$prd" ]; then
        printf '%s\n' "$analysis_instruction"
    fi
    # v7.8.1: when reusing a generated PRD whose codebase changed, append the
    # incremental-update instruction (prd is the generated PRD here, so the
    # anchor already says "with PRD at .loki/generated-prd.md"). Decided once per
    # run (GENERATED_PRD_ACTION), so it stays cache-stable across iterations.
    if [ -n "$update_instruction" ]; then
        printf '%s\n' "$update_instruction"
    fi
    printf '</loki_system>\n'
    printf '[CACHE_BREAKPOINT]\n'

    # DYNAMIC TAIL -- all per-iteration context goes here.
    printf '<dynamic_context iteration="%s" retry="%s">\n' "$iteration" "$retry"
    if [ $retry -gt 0 ]; then
        if [ -n "$prd" ]; then
            printf 'Resume iteration #%s (retry #%s). PRD: %s\n' "$iteration" "$retry" "$prd"
        else
            printf 'Resume iteration #%s (retry #%s). Use .loki/generated-prd.md if exists.\n' "$iteration" "$retry"
        fi
    fi
    [ -n "$human_directive" ] && printf '%s\n' "$human_directive"
    [ -n "$gate_failure_context" ] && printf '%s\n' "$gate_failure_context"
    [ -n "$gate_escalation_context" ] && printf '%s\n' "$gate_escalation_context"
    [ -n "$self_heal_context" ] && printf '%s\n' "$self_heal_context"
    [ -n "$assumption_context" ] && printf '%s\n' "$assumption_context"
    [ -n "$queue_tasks" ] && printf '%s\n' "$queue_tasks"
    [ -n "$bmad_context" ] && printf '%s\n' "$bmad_context"
    [ -n "$openspec_context" ] && printf '%s\n' "$openspec_context"
    [ -n "$mirofish_context" ] && printf '%s\n' "$mirofish_context"
    [ -n "$magic_context" ] && printf '%s\n' "$magic_context"
    [ -n "$checklist_status" ] && printf '%s\n' "$checklist_status"
    [ -n "$app_runner_info" ] && printf '%s\n' "$app_runner_info"
    [ -n "$playwright_info" ] && printf '%s\n' "$playwright_info"
    [ -n "$memory_context_section" ] && printf '%s\n' "$memory_context_section"
    # Failure memory: volatile (it changes the moment a gate fails), so it lives
    # here in the dynamic tail, never in the cache-stable <loki_system> prefix.
    # Sits with the other memory context, before the efficiency trend.
    [ -n "$failure_memory_context" ] && printf '%s\n' "$failure_memory_context"
    # Volatile per-iteration data: belongs below [CACHE_BREAKPOINT], never in the
    # cache-stable prefix. Same ordinal position as the Bun route (after the
    # context section, before the completion instruction).
    [ -n "$efficiency_trend" ] && printf '%s\n' "$efficiency_trend"
    printf '%s\n' "$completion_instruction"
    printf '</dynamic_context>\n'
}

#===============================================================================
# BMAD Task Queue Population
#===============================================================================

# Populate the task queue from BMAD epic/story artifacts
# Only runs once -- skips if queue was already populated from BMAD
populate_bmad_queue() {
    # Skip if no BMAD tasks file
    if [[ ! -f ".loki/bmad-tasks.json" ]]; then
        return 0
    fi

    # Skip if already populated (marker file)
    if [[ -f ".loki/queue/.bmad-populated" ]]; then
        log_info "BMAD queue already populated, skipping"
        return 0
    fi

    log_step "Populating task queue from BMAD stories..."

    # Ensure queue directory exists
    mkdir -p ".loki/queue"

    # Read BMAD tasks and create queue entries
    python3 << 'BMAD_QUEUE_EOF'
import json
import os
import sys

bmad_tasks_path = ".loki/bmad-tasks.json"
pending_path = ".loki/queue/pending.json"
completed_stories_path = ".loki/bmad-completed-stories.json"

try:
    with open(bmad_tasks_path, "r") as f:
        bmad_data = json.load(f)
except (json.JSONDecodeError, FileNotFoundError) as e:
    print(f"Warning: Could not read BMAD tasks: {e}", file=sys.stderr)
    sys.exit(0)

# Load completed stories from sprint-status (if available)
completed_stories = set()
if os.path.exists(completed_stories_path):
    try:
        with open(completed_stories_path, "r") as f:
            completed_list = json.load(f)
            if isinstance(completed_list, list):
                completed_stories = {s.lower() for s in completed_list if isinstance(s, str)}
    except (json.JSONDecodeError, FileNotFoundError):
        pass

# Extract stories from BMAD structure
# Supports both flat list and nested epic/story format
stories = []
if isinstance(bmad_data, list):
    stories = bmad_data
elif isinstance(bmad_data, dict):
    # Handle {"epics": [...]} or {"tasks": [...]} formats
    for key in ("epics", "tasks", "stories"):
        if key in bmad_data:
            items = bmad_data[key]
            if isinstance(items, list):
                for item in items:
                    if isinstance(item, dict) and "stories" in item:
                        # Epic with nested stories
                        epic_name = item.get("title", item.get("name", ""))
                        for story in item["stories"]:
                            if isinstance(story, dict):
                                story.setdefault("epic", epic_name)
                                stories.append(story)
                    else:
                        stories.append(item)
            break

if not stories:
    print("No BMAD stories found to queue", file=sys.stderr)
    sys.exit(0)

# Sort stories by priority_weight (MVP=1 first, then phase2=2, then phase3=3)
stories.sort(key=lambda s: s.get("priority_weight", 2) if isinstance(s, dict) else 2)

# Filter out completed stories from sprint-status
skipped_count = 0
if completed_stories:
    filtered = []
    for story in stories:
        if isinstance(story, dict):
            title = story.get("title", story.get("name", "")).lower()
            if title and title in completed_stories:
                skipped_count += 1
                continue
        filtered.append(story)
    stories = filtered
    if skipped_count > 0:
        print(f"Skipped {skipped_count} completed stories (from sprint-status.yml)", file=sys.stderr)

# Load existing pending tasks (if any)
existing = []
if os.path.exists(pending_path):
    try:
        with open(pending_path, "r") as f:
            data = json.load(f)
            if isinstance(data, list):
                existing = data
            elif isinstance(data, dict) and "tasks" in data:
                existing = data["tasks"]
    except (json.JSONDecodeError, FileNotFoundError):
        existing = []

# Convert BMAD stories to queue task format (with deduplication)
existing_ids = {t.get("id") for t in existing if isinstance(t, dict)}
# BUG-ADP-005: Track added count separately from total stories
added_count = 0
for i, story in enumerate(stories):
    if not isinstance(story, dict):
        continue
    task_id = f"bmad-{i+1}"
    if task_id in existing_ids:
        continue
    task = {
        "id": task_id,
        "title": story.get("title", story.get("name", f"BMAD Story {i+1}")),
        "description": story.get("description", story.get("action", "")),
        "priority": story.get("priority", "medium"),
        "source": "bmad",
    }
    epic = story.get("epic", "")
    if epic:
        task["epic"] = epic
    acceptance = story.get("acceptance_criteria", story.get("criteria", []))
    if acceptance:
        task["acceptance_criteria"] = acceptance
    existing.append(task)
    added_count += 1

# Write updated pending queue
with open(pending_path, "w") as f:
    json.dump(existing, f, indent=2)

msg = f"Added {added_count} BMAD stories to task queue"
if skipped_count > 0:
    msg += f" (skipped {skipped_count} completed)"
print(msg)
BMAD_QUEUE_EOF

    local bmad_exit=$?
    if [[ $bmad_exit -ne 0 ]]; then
        log_warn "Failed to populate BMAD queue (python3 error)"
        # BUG-RUN-012: Do NOT touch marker file on failure -- allow retry on next run
        return 0
    fi

    # Mark as populated so we don't re-add on restart (only on success)
    touch ".loki/queue/.bmad-populated"
    log_info "BMAD queue population complete"
}

# Write-back completed BMAD stories to sprint-status.yml and epics.md
# Called after each iteration to sync completion state back to BMAD artifacts
bmad_write_back() {
    # Skip if not a BMAD project
    local bmad_project="${BMAD_PROJECT_PATH:-}"
    if [[ -z "$bmad_project" ]]; then
        return 0
    fi

    # Skip if no completed stories file
    local completed_file=".loki/bmad-completed-stories.json"
    if [[ ! -f "$completed_file" ]]; then
        return 0
    fi

    # Skip if completed stories file is empty or just []
    local story_count
    story_count=$(python3 -c "import json; data=json.load(open('$completed_file')); print(len(data))" 2>/dev/null || echo "0")
    if [[ "$story_count" -eq 0 ]]; then
        return 0
    fi

    # Find the adapter script
    local adapter_script="${SCRIPT_DIR}/bmad-adapter.py"
    if [[ ! -f "$adapter_script" ]]; then
        log_warn "BMAD adapter not found, skipping write-back"
        return 0
    fi

    # Run write-back (warn on failure, never crash)
    if python3 "$adapter_script" "$bmad_project" \
        --write-back \
        --completed-stories-file "$completed_file" 2>/dev/null; then
        log_info "BMAD write-back: synced completed stories to source artifacts"
    else
        log_warn "BMAD write-back failed (non-fatal)"
    fi
}

#===============================================================================
# OpenSpec Task Queue Population
#===============================================================================

# Compute a content hash for a file (cross-platform: uses Python hashlib so
# behavior is identical on macOS and Linux, no md5/md5sum fork).
_openspec_content_hash() {
    local file="$1"
    [[ -f "$file" ]] || { echo "none"; return 0; }
    python3 -c "import hashlib,sys; print(hashlib.md5(open(sys.argv[1],'rb').read()).hexdigest())" "$file" 2>/dev/null || echo "none"
}

# Remove all tasks with source=="openspec" from a queue JSON file, preserving
# every other source (prd, bmad, mirofish). Atomic: writes tmp + renames.
purge_openspec_from_queue() {
    local queue_file="$1"
    [[ -f "$queue_file" ]] || return 0
    local tmp="${queue_file}.tmp.$$"
    if jq '[.[] | select(.source != "openspec")]' "$queue_file" > "$tmp" 2>/dev/null; then
        local before after
        before=$(jq 'length' "$queue_file" 2>/dev/null || echo 0)
        after=$(jq 'length' "$tmp" 2>/dev/null || echo 0)
        mv "$tmp" "$queue_file"
        if [[ "$before" != "$after" ]]; then
            log_info "Purged $((before - after)) OpenSpec tasks from $(basename "$queue_file")"
        fi
    else
        rm -f "$tmp"
        log_warn "Could not purge OpenSpec tasks from $(basename "$queue_file") (jq failed)"
        return 1
    fi
}

# Populate the task queue from OpenSpec task artifacts.
# The sentinel .loki/queue/.openspec-populated is scoped per change:
#   line 1 = change path, line 2 = content hash of openspec-tasks.json.
# Same path + same hash -> skip (crash-restart preserves progress).
# Different path -> change switched, purge stale tasks and repopulate.
# Same path + different hash -> tasks.md edited, purge and repopulate.
populate_openspec_queue() {
    # Skip if no OpenSpec tasks file
    if [[ ! -f ".loki/openspec-tasks.json" ]]; then
        return 0
    fi

    local sentinel=".loki/queue/.openspec-populated"
    local current_path="${OPENSPEC_CHANGE_PATH:-}"
    local current_hash
    current_hash="$(_openspec_content_hash ".loki/openspec-tasks.json")"

    if [[ -f "$sentinel" ]]; then
        local stored_path stored_hash
        stored_path="$(sed -n '1p' "$sentinel")"
        stored_hash="$(sed -n '2p' "$sentinel")"
        if [[ "$stored_path" == "$current_path" ]] && [[ "$stored_hash" == "$current_hash" ]]; then
            log_info "OpenSpec queue already populated for this change (path + hash match), skipping"
            return 0
        fi
        if [[ "$stored_path" != "$current_path" ]]; then
            log_info "OpenSpec change switched (was: ${stored_path:-<legacy>}, now: ${current_path:-<unset>}) -- purging stale OpenSpec tasks"
        else
            log_info "OpenSpec tasks.md content changed (hash mismatch) -- purging and reloading"
        fi
        purge_openspec_from_queue ".loki/queue/pending.json"
        purge_openspec_from_queue ".loki/queue/in-progress.json"
        purge_openspec_from_queue ".loki/queue/completed.json"
    fi

    log_step "Populating task queue from OpenSpec tasks..."

    # Ensure queue directory exists
    mkdir -p ".loki/queue"

    # Read OpenSpec tasks and create queue entries
    python3 << 'OPENSPEC_QUEUE_EOF'
import json
import sys

openspec_tasks_path = ".loki/openspec-tasks.json"
pending_path = ".loki/queue/pending.json"

try:
    with open(openspec_tasks_path, "r") as f:
        openspec_tasks = json.load(f)
except (json.JSONDecodeError, FileNotFoundError) as e:
    print(f"Warning: Could not read OpenSpec tasks: {e}", file=sys.stderr)
    sys.exit(0)

# Load existing queue
existing = []
try:
    with open(pending_path, "r") as f:
        existing = json.load(f)
except (json.JSONDecodeError, FileNotFoundError):
    pass

# BUG-RUN-005: Add deduplication check (like BMAD and MiroFish queue functions)
existing_ids = {t.get("id") for t in existing if isinstance(t, dict)}
added_count = 0

# Convert OpenSpec tasks to queue format (skip completed tasks)
for task in openspec_tasks:
    if task.get("status") == "completed":
        continue
    task_id = task.get("id", "openspec-unknown")
    if task_id in existing_ids:
        continue
    queue_entry = {
        "id": task_id,
        "title": task.get("title", "Untitled"),
        "description": f"[OpenSpec] {task.get('group', 'General')}: {task.get('title', '')}",
        "priority": task.get("priority", "medium"),
        "status": "pending",
        "source": "openspec",
        "metadata": {
            "openspec_source": task.get("source", "tasks.md"),
            "openspec_group": task.get("group", ""),
        }
    }
    existing.append(queue_entry)
    added_count += 1

with open(pending_path, "w") as f:
    json.dump(existing, f, indent=2)

pending_count = added_count
if pending_count == 0:
    print("WARNING: All OpenSpec tasks are already marked as completed. No tasks added to queue.", file=sys.stderr)
    print("Check your tasks.md file -- all checkboxes are checked.", file=sys.stderr)
else:
    print(f"Added {pending_count} OpenSpec tasks to queue")
OPENSPEC_QUEUE_EOF

    if [[ $? -ne 0 ]]; then
        log_warn "Failed to populate OpenSpec queue (python3 error)"
        return 0
    fi

    # Mark as populated for this specific change + content hash so we don't
    # re-add on restart but DO repopulate when change-switching or tasks.md edits.
    printf '%s\n%s\n' "${OPENSPEC_CHANGE_PATH:-}" "$current_hash" > ".loki/queue/.openspec-populated"
    log_info "OpenSpec queue population complete"
}

#===============================================================================
# MiroFish Task Queue Population
#===============================================================================

# Populate the task queue from MiroFish market validation advisory
# Only runs once -- skips if queue was already populated from MiroFish
populate_mirofish_queue() {
    # Skip if no MiroFish tasks file
    if [[ ! -f ".loki/mirofish-tasks.json" ]]; then
        return 0
    fi

    # Skip if already populated (marker file)
    if [[ -f ".loki/queue/.mirofish-populated" ]]; then
        log_info "MiroFish queue already populated, skipping"
        return 0
    fi

    log_step "Populating task queue from MiroFish market validation..."

    # Ensure queue directory exists
    mkdir -p ".loki/queue"

    # Read MiroFish tasks and create queue entries
    python3 << 'MIROFISH_QUEUE_EOF'
import json
import os
import sys

mf_tasks_path = ".loki/mirofish-tasks.json"
pending_path = ".loki/queue/pending.json"

try:
    with open(mf_tasks_path, "r") as f:
        mf_tasks = json.load(f)
except (json.JSONDecodeError, FileNotFoundError) as e:
    print(f"Warning: Could not read MiroFish tasks: {e}", file=sys.stderr)
    sys.exit(0)

if not isinstance(mf_tasks, list) or not mf_tasks:
    print("No MiroFish tasks found to queue", file=sys.stderr)
    sys.exit(0)

# Load existing pending tasks (if any)
existing = []
if os.path.exists(pending_path):
    try:
        with open(pending_path, "r") as f:
            data = json.load(f)
            if isinstance(data, list):
                existing = data
            elif isinstance(data, dict) and "tasks" in data:
                existing = data["tasks"]
    except (json.JSONDecodeError, FileNotFoundError):
        existing = []

# Convert MiroFish tasks to queue format (with deduplication)
existing_ids = {t.get("id") for t in existing if isinstance(t, dict)}
added = 0
for i, task in enumerate(mf_tasks):
    if not isinstance(task, dict):
        continue
    task_id = task.get("id", f"mirofish-{i+1:03d}")
    if task_id in existing_ids:
        continue
    entry = {
        "id": task_id,
        "title": task.get("title", f"MiroFish Advisory {i+1}"),
        "description": task.get("description", ""),
        "priority": task.get("priority", "medium"),
        "source": "mirofish",
    }
    if task.get("category"):
        entry["category"] = task["category"]
    existing.append(entry)
    added += 1

with open(pending_path, "w") as f:
    json.dump(existing, f, indent=2)
print(f"Added {added} MiroFish advisory tasks to queue", file=sys.stderr)
MIROFISH_QUEUE_EOF

    if [[ $? -ne 0 ]]; then
        log_warn "Failed to populate MiroFish queue"
        return 0
    fi

    touch ".loki/queue/.mirofish-populated"
    log_info "MiroFish queue population complete"
}

# Populate task queue from plain PRD markdown (if no adapter populated tasks)
# Extracts features/requirements from markdown structure into rich task entries
populate_prd_queue() {
    local prd_file="${1:-}"
    if [[ -z "$prd_file" ]] || [[ ! -f "$prd_file" ]]; then
        return 0
    fi
    # Skip if already populated
    if [[ -f ".loki/queue/.prd-populated" ]]; then
        return 0
    fi
    # Skip if OpenSpec, BMAD, or MiroFish already populated tasks
    if [[ -f ".loki/queue/.openspec-populated" ]] || [[ -f ".loki/queue/.bmad-populated" ]] || [[ -f ".loki/queue/.mirofish-populated" ]]; then
        log_info "Task queue already populated by adapter, skipping PRD parsing"
        return 0
    fi

    # Prefer the original project PRD over generated quick-prd.md
    # quick-prd.md contains boilerplate that produces garbage tasks
    local effective_prd="$prd_file"
    if [[ "$prd_file" == *"quick-prd.md" ]] || [[ "$prd_file" == *"chat-prd.md" ]]; then
        # Look for the real PRD in the project root
        for candidate in "PRD.md" "prd.md" "requirements.md" "REQUIREMENTS.md" "spec.md" "SPEC.md"; do
            if [[ -f "$candidate" ]]; then
                effective_prd="$candidate"
                log_info "Using project PRD ($candidate) instead of generated $prd_file"
                break
            fi
        done
    fi

    log_step "Parsing PRD into structured tasks..."
    mkdir -p ".loki/queue"

    LOKI_PRD_FILE="$effective_prd" python3 << 'PRD_PARSE_EOF'
import json, re, os, sys

prd_path = os.environ.get("LOKI_PRD_FILE", "")
if not prd_path or not os.path.isfile(prd_path):
    sys.exit(0)

with open(prd_path, "r", errors="replace") as f:
    content = f.read()

# Hosted specs may prepend a design-policy envelope to the user's request. The
# provider and proof continue reading the original file; only task extraction
# sees the content after an explicit full-line directive terminator. Files
# without a terminator remain byte-for-byte identical at this parser boundary.
directive_terminator = re.compile(
    r"^---[ \t]+END[ \t]+[^\r\n]+?[ \t]+DIRECTIVE[ \t]+---[ \t]*$",
    re.IGNORECASE,
)
content_lines = content.splitlines(keepends=True)
for index, line in enumerate(content_lines):
    if directive_terminator.fullmatch(line.rstrip("\r\n")):
        content = "".join(content_lines[index + 1:])
        break

# Parse PRD structure
sections = {}
current_section = "Overview"
current_content = []

for line in content.split("\n"):
    heading_match = re.match(r'^#{1,3}\s+(.+)', line)
    if heading_match:
        if current_content:
            sections[current_section] = "\n".join(current_content).strip()
        current_section = heading_match.group(1).strip()
        current_content = []
    else:
        current_content.append(line)
if current_content:
    sections[current_section] = "\n".join(current_content).strip()

# Extract project name from first H1
project_name = "Project"
for line in content.split("\n"):
    m = re.match(r'^#\s+(.+)', line)
    if m:
        project_name = m.group(1).strip()
        break

# Helper: strip numbered prefixes like "4." or "4.1" or "6.3.2" from section names
def strip_numbering(name):
    return re.sub(r'^\d+(\.\d+)*\.?\s*', '', name).strip()

# Find feature/requirement sections -- expanded keywords for real-world PRDs
feature_keywords = [
    "features", "requirements", "key features", "core features",
    "functional requirements", "user stories", "deliverables",
    "project scope", "functionality", "capabilities", "modules",
    # Real-world PRD section names:
    "specification", "backend", "frontend", "api", "endpoints",
    "components", "services", "implementation", "architecture",
    "build instructions", "interface", "phase",
    "database", "data model", "workflow",
    "screens", "routes", "views", "controllers", "models",
    "pipeline", "integration", "scaffolding", "deploy",
]

# Meta sections to skip (applied after stripping numbered prefixes).
# Skip check runs BEFORE keyword matching so meta sections are never extracted.
skip_keywords = {
    "table of contents", "overview", "introduction", "summary",
    "executive summary", "appendix", "references", "changelog",
    "future roadmap", "out of scope", "environment variables",
    "risks", "mitigations", "success metrics", "timeline",
    "glossary", "terminology", "revision history",
    "target audience", "tech stack", "technology", "deployment",
    "non-functional", "problem statement", "value proposition",
    "background", "metrics", "roadmap",
}

def is_skip_section(name):
    """Check if a section name (after stripping numbers) matches a meta/skip section."""
    clean = strip_numbering(name).lower()
    if clean in skip_keywords:
        return True
    # Also check substring match for skip keywords
    for sk in skip_keywords:
        if sk in clean:
            return True
    return False

# Also skip the document title (H1 heading captured as a section name)
h1_title = None
for line in content.split("\n"):
    m = re.match(r'^#\s+(.+)', line)
    if m:
        h1_title = m.group(1).strip()
        break

# Extract features from bullet points in feature sections (keyword-matched)
features = []
for section_name, section_content in sections.items():
    # Skip meta sections first (takes priority over keyword match)
    if is_skip_section(section_name):
        continue
    # Skip the document title section
    if h1_title and section_name == h1_title:
        continue
    clean_name = strip_numbering(section_name).lower()
    is_feature_section = any(kw in clean_name for kw in feature_keywords)
    if is_feature_section:
        # Extract numbered items or bullet points
        for line in section_content.split("\n"):
            raw_line = line
            line = line.strip()
            # Match: "1. Feature name" or "- Feature name" or "* Feature name"
            m = re.match(r'^(?:\d+[\.\)]\s*|\-\s+|\*\s+)(.+)', line)
            if m:
                # BUG-V63-003 fix: skip indented sub-bullets (check raw_line before strip)
                if raw_line and raw_line[0] in (' ', '\t'):
                    continue
                feature_text = m.group(1).strip()
                # Skip boilerplate template lines
                boilerplate = {
                    "complete the task described above",
                    "follow existing code patterns and conventions",
                    "write tests if applicable",
                    "do not break existing functionality",
                    "task is completed as described",
                    "no errors or regressions introduced",
                    "code follows project conventions",
                    "keep changes minimal and focused",
                    "do not refactor unrelated code",
                }
                if feature_text.lower() in boilerplate:
                    continue
                if len(feature_text) > 10:  # Skip very short lines
                    features.append({
                        "title": feature_text,
                        "section": section_name,
                    })

# Also extract ### sub-headings from feature sections as tasks
for section_name, section_content in sections.items():
    if is_skip_section(section_name):
        continue
    if h1_title and section_name == h1_title:
        continue
    clean_name = strip_numbering(section_name).lower()
    is_feature_section = any(kw in clean_name for kw in feature_keywords)
    if is_feature_section:
        for line in section_content.split("\n"):
            sub_match = re.match(r'^###\s+(.+)', line)
            if sub_match:
                sub_title = strip_numbering(sub_match.group(1).strip())
                if len(sub_title) > 5:
                    # Avoid duplicates
                    if not any(f["title"] == sub_title for f in features):
                        features.append({"title": sub_title, "section": section_name})

# Fallback: if no features found via keyword matching, extract ### sub-headings
# from ALL non-meta sections
if not features:
    for section_name, section_content in sections.items():
        if is_skip_section(section_name):
            continue
        if h1_title and section_name == h1_title:
            continue
        for line in section_content.split("\n"):
            sub_match = re.match(r'^###\s+(.+)', line)
            if sub_match:
                sub_title = strip_numbering(sub_match.group(1).strip())
                if len(sub_title) > 5:
                    if not any(f["title"] == sub_title for f in features):
                        features.append({"title": sub_title, "section": section_name})

# Final fallback: extract from ## headings that are non-meta sections
if not features:
    for section_name, section_content in sections.items():
        if is_skip_section(section_name):
            continue
        if h1_title and section_name == h1_title:
            continue
        clean_name = strip_numbering(section_name)
        if len(section_content) > 20 and len(clean_name) > 5:
            # BUG-A fix: store the REAL section/heading key (not a hardcoded
            # "Requirements") so sections.get(feat["section"]) later resolves
            # to this section's body and the description is not just the title.
            features.append({
                "title": clean_name,
                "section": section_name,
            })

if not features:
    print("No features extracted from PRD", file=sys.stderr)
    sys.exit(0)

# Build acceptance criteria from section content
def extract_acceptance_criteria(section_name, sections):
    """Extract testable criteria from section content."""
    criteria = []
    seen_criteria = set()  # BUG-V63-004 fix: deduplicate criteria
    content = sections.get(section_name, "")
    for line in content.split("\n"):
        raw_line = line
        line = line.strip()
        # BUG-V63-003 fix: check indentation on raw_line before strip
        if raw_line and raw_line[0] in (' ', '\t'):
            # Indented sub-bullet
            if line.startswith(("- ", "* ")):
                text = re.sub(r'^[\-\*]\s+', '', line).strip()
                if len(text) > 5 and text not in seen_criteria:
                    criteria.append(text)
                    seen_criteria.add(text)
        elif line.startswith(("- ", "* ")):
            text = re.sub(r'^[\-\*]\s+', '', line).strip()
            if len(text) > 5 and text not in seen_criteria:
                criteria.append(text)
                seen_criteria.add(text)
    # Also check for acceptance criteria section
    for key in ["acceptance criteria", "success criteria", "definition of done"]:
        for sname, scontent in sections.items():
            if key in sname.lower():
                for cline in scontent.split("\n"):
                    cline = cline.strip()
                    m = re.match(r'^(?:\d+[\.\)]\s*|\-\s+|\*\s+|\[.\]\s*)(.+)', cline)
                    if m:
                        text = m.group(1).strip()
                        if text not in seen_criteria:
                            criteria.append(text)
                            seen_criteria.add(text)
    return criteria[:10]  # Cap at 10

# Determine priority based on position (earlier = higher)
def get_priority(index, total):
    if total <= 3:
        return "high"
    third = total / 3
    if index < third:
        return "high"
    elif index < 2 * third:
        return "medium"
    return "low"

# Build task queue entries
pending_path = ".loki/queue/pending.json"
existing = []
wrapper = None  # BUG-V63-005 fix: preserve dict wrapper if present
if os.path.exists(pending_path):
    try:
        with open(pending_path, "r") as f:
            raw_data = json.load(f)
            if isinstance(raw_data, list):
                existing = raw_data
                wrapper = None
            elif isinstance(raw_data, dict):
                existing = raw_data.get("tasks", [])
                wrapper = {k: v for k, v in raw_data.items() if k != "tasks"}
    except (json.JSONDecodeError, FileNotFoundError):
        existing = []

existing_ids = {t.get("id") for t in existing if isinstance(t, dict)}
added = 0

# Reuse done-recognition (v7.94.0): if a satisfied-requirements manifest exists
# AND its prd_sha matches THIS PRD's hash, skip any feature whose title is
# already satisfied, so an incremental reuse run rebuilds ONLY the gap. A stale
# or absent manifest is ignored (full build -- the safe default). The sha is
# computed over the SAME PRD file bytes the gate hashed (hashlib.sha256), so the
# guard matches byte-for-byte. Title match is normalized (case-insensitive, with
# a leading "feature:/requirement:/epic:/story:" heading prefix stripped) so a
# model-returned title like "User login" matches the parser's heading title
# "Feature: User login". bash 3.2 safe: all normalization happens in python.
def _dr_norm_title(s):
    s = (s or "").strip().lower()
    for _pfx in ("feature:", "requirement:", "epic:", "story:", "user story:"):
        if s.startswith(_pfx):
            s = s[len(_pfx):].strip()
            break
    return s

_dr_satisfied = set()
try:
    import hashlib
    _dr_manifest = ".loki/state/satisfied-requirements.json"
    if os.path.isfile(_dr_manifest):
        with open(_dr_manifest, "r") as _mf:
            _dr_data = json.load(_mf)
        _dr_manifest_sha = (_dr_data.get("prd_sha") or "").strip()
        with open(prd_path, "rb") as _pf:
            _dr_cur_sha = hashlib.sha256(_pf.read()).hexdigest()
        if _dr_manifest_sha and _dr_manifest_sha == _dr_cur_sha:
            for _t in _dr_data.get("satisfied", []):
                if isinstance(_t, str) and _t.strip():
                    _dr_satisfied.add(_dr_norm_title(_t))
except Exception:
    _dr_satisfied = set()

# BUG-V63-001 fix: extract audience once with flag to break both loops
audience = "a user"
audience_found = False
for key in ["target audience", "users", "user personas", "audience"]:
    if audience_found:
        break
    for sname in sections:
        if key in sname.lower():
            first_line = sections[sname].split("\n")[0].strip()
            if first_line:
                audience = first_line[:100]
                audience_found = True
                break

for i, feat in enumerate(features):
    task_id = f"prd-{i+1:03d}"
    if task_id in existing_ids:
        continue

    # Skip features the done-recognition gate verified as already satisfied
    # (manifest-driven, title-keyed, normalized match). Only unmet requirements
    # become tasks, so the RARV loop works only the gap.
    if _dr_satisfied and _dr_norm_title(feat.get("title")) in _dr_satisfied:
        continue

    criteria = extract_acceptance_criteria(feat["section"], sections)

    # Build a rich description
    section_content = sections.get(feat["section"], "")
    desc_parts = [feat["title"]]
    if section_content and len(section_content) > len(feat["title"]):
        # Include relevant context (first 500 chars of section)
        desc_parts.append(section_content[:500])

    task = {
        "id": task_id,
        "title": feat["title"],
        "description": "\n".join(desc_parts),
        "priority": get_priority(i, len(features)),
        "status": "pending",
        "source": "prd",
        "project": project_name,
    }

    if criteria:
        task["acceptance_criteria"] = criteria

    task["user_story"] = f"As {audience}, I want to {feat['title'].lower().rstrip('.')}, so that the product delivers its core value."

    existing.append(task)
    added += 1

# BUG-V63-005 fix: write back in original format (dict wrapper or bare list)
if wrapper is not None:
    wrapper["tasks"] = existing
    output = wrapper
else:
    output = existing

with open(pending_path, "w") as f:
    json.dump(output, f, indent=2)

print(f"Extracted {added} tasks from PRD ({len(features)} features found)", file=sys.stderr)
PRD_PARSE_EOF

    if [[ $? -ne 0 ]]; then
        log_warn "Failed to parse PRD into tasks"
        return 0
    fi

    # LLM enrichment post-pass (v7.52.0). The python heredoc above has no model
    # access, so stub tasks (description == title, or the templated user_story)
    # are enriched here via one batched provider call. Best-effort: on any
    # failure (non-claude provider, degraded mode, no binary, timeout, parse
    # error) the deterministic output is left intact. Never blocks the queue.
    local _prd_enrich_lib="$SCRIPT_DIR/lib/prd-enrich.sh"
    if ! loki_is_supervised_simple_web \
       && [ "${LOKI_PRD_ENRICH:-1}" != "0" ] \
       && [ -f "$_prd_enrich_lib" ]; then
        # shellcheck source=lib/prd-enrich.sh
        source "$_prd_enrich_lib" 2>/dev/null || true
        if declare -f loki_prd_enrich >/dev/null 2>&1; then
            loki_prd_enrich ".loki/queue/pending.json" "$effective_prd" || true
        fi
    fi

    touch ".loki/queue/.prd-populated"
    log_info "PRD task parsing complete"
}

#===============================================================================
# Main Autonomous Loop
#===============================================================================

_loki_supervised_completion_gates_pass() {
    loki_is_supervised_simple_web || return 1
    local current_failures="${1:-${gate_failures:-}}"
    case "$current_failures" in
        *[![:space:],]*)
            log_error "Supervised completion blocked by iteration gate failures: $current_failures"
            return 1
            ;;
    esac
    local quality_dir="${TARGET_DIR:-.}/.loki/quality"
    local test_results="$quality_dir/test-results.json"
    local test_iteration="$quality_dir/.test-results.iter"
    if [ ! -s "$test_results" ] || [ ! -s "$test_iteration" ] \
       || [ "$(tr -d '[:space:]' < "$test_iteration" 2>/dev/null)" != "${ITERATION_COUNT:-0}" ] \
       || ! python3 - "$test_results" <<'PYEOF' 2>/dev/null
import json
import sys

try:
    with open(sys.argv[1], encoding="utf-8") as handle:
        result = json.load(handle)
except (OSError, ValueError, TypeError):
    raise SystemExit(1)

runner = str(result.get("runner") or "").strip().lower()
command = result.get("command")
exit_code = result.get("exit_code")
valid = (
    runner not in {"", "none"}
    and result.get("pass") is True
    and isinstance(command, str)
    and bool(command.strip())
    and isinstance(exit_code, int)
    and not isinstance(exit_code, bool)
    and exit_code == 0
)
raise SystemExit(0 if valid else 1)
PYEOF
    then
        log_error "Supervised completion requires fresh, passing tests with a real runner, command, and exit code 0."
        return 1
    fi
    local gate
    for gate in council_checklist_gate council_heldout_gate council_evidence_gate council_assumption_ledger_gate; do
        if ! type "$gate" >/dev/null 2>&1; then
            log_error "Required supervised completion gate is unavailable: $gate"
            return 1
        fi
        "$gate" || return 1
    done
    local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}/.loki}"
    printf 'Supervised deterministic completion gates passed at %s\n' \
        "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$loki_dir/COMPLETED"
    return 0
}

#-------------------------------------------------------------------------------
# Sentrux architectural-drift gate hooks (v7.5.15).
#
# Opt-in via LOKI_SENTRUX_GATE=1. Default OFF -- zero behavior change for users
# who don't opt in. The helper at autonomy/lib/sentrux-gate.sh is sourced inside
# run_autonomous() under the same guard. Both hook functions no-op silently if
# the helper is not loaded or the sentrux binary is not on PATH.
#-------------------------------------------------------------------------------
_loki_sentrux_iteration_start() {
    local target="${1:-${TARGET_DIR:-.}}"
    if [ "${LOKI_SENTRUX_GATE:-0}" != "1" ]; then
        return 0
    fi
    if ! type sentrux_available >/dev/null 2>&1 || ! sentrux_available; then
        return 0
    fi
    sentrux_baseline_save "$target" >/dev/null 2>&1 || true
    return 0
}

_loki_sentrux_iteration_end() {
    local iter="${1:-0}"
    local target="${2:-${TARGET_DIR:-.}}"
    if [ "${LOKI_SENTRUX_GATE:-0}" != "1" ]; then
        return 0
    fi
    if ! type sentrux_available >/dev/null 2>&1 || ! sentrux_available; then
        return 0
    fi
    local diff before after verdict
    diff=$(sentrux_gate_diff "$target" 2>/dev/null || true)
    if [ -z "$diff" ]; then
        return 0
    fi
    before="${diff%%|*}"
    local rest="${diff#*|}"
    after="${rest%%|*}"
    verdict="${rest#*|}"
    if type log_info >/dev/null 2>&1; then
        log_info "sentrux gate iter=$iter verdict=$verdict before=${before:-?} after=${after:-?}"
    fi
    if [ "$verdict" = "DEGRADED" ]; then
        local state_dir="$target/.loki/state"
        mkdir -p "$state_dir" 2>/dev/null || true
        local finding_path="$state_dir/findings-sentrux-${iter}.json"
        local ts
        ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || echo "")
        local before_json="${before:-0}"
        local after_json="${after:-0}"
        # Guard against non-numeric values when serializing to JSON.
        if ! [[ "$before_json" =~ ^[0-9]+$ ]]; then before_json=0; fi
        if ! [[ "$after_json"  =~ ^[0-9]+$ ]]; then after_json=0; fi
        printf '{"type":"architectural-drift","iteration":%s,"before":%s,"after":%s,"verdict":"DEGRADED","timestamp":"%s","source":"sentrux"}\n' \
            "$iter" "$before_json" "$after_json" "$ts" \
            > "$finding_path" 2>/dev/null || true
    fi
    return 0
}

# show_run_start_estimate <prd_path>
#
# C4 (v7.x): before any real spend, the user must SEE (a) the budget-guard
# state and (b) a cost/time estimate -- honestly, with no fabricated dollar
# figures. This is the run.sh-side complement to the loki CLI's auto-plan:
#
#   - Budget guard: the hard cap is enforced by check_budget_limit (which
#     touches .loki/PAUSE at the cap). This helper only DISPLAYS the state;
#     it never sets a default BUDGET_LIMIT (doing so would change pause
#     behavior for every user). If BUDGET_LIMIT is set we show the cap and
#     the pause-at-cap promise; if not, we state plainly that no cap is set
#     and how to set one. Always shown -- the guard disclosure is universal.
#
#   - Estimate: `loki start` on a TTY already prints the estimate via
#     maybe_show_auto_plan -> show_prd_plan. The genuine gap is the non-TTY
#     route (Docker, dashboard, piped invocation): there the CLI skips the
#     plan, so we fill it here. We gate on stdout NOT being a TTY because
#     run.sh has no signal that `loki start` already showed the plan (no env
#     marker exists and `loki` is out of scope to edit), and the non-TTY test
#     is the only one that is both reliable and free of duplication.
#     KNOWN LIMITATION: a direct `./autonomy/run.sh <prd>` run in a terminal
#     (TTY, not launched via `loki start`) skips the estimate here AND was
#     never shown one by the CLI. The budget-guard disclosure above is still
#     always shown; only the cost/time estimate is missing on that one
#     power-user path. Closing it cleanly needs a "plan already shown" marker
#     set in the loki CLI, which is owned elsewhere.
#     The estimate is best-effort: it shells out to the loki binary with a
#     hard timeout, parses only real numbers, and prints an honest
#     "estimate unavailable" line on any failure. It NEVER fails the run and
#     NEVER fabricates a figure.
show_run_start_estimate() {
    local prd_path="$1"

    # --- Budget-guard disclosure (always) ---
    if [ -n "$BUDGET_LIMIT" ]; then
        log_info "Budget guard: hard cap \$$BUDGET_LIMIT (run pauses via .loki/PAUSE at the cap; warning at 80%)."
    else
        log_info "Budget guard: no cap set (no automatic spend stop). Set LOKI_BUDGET_LIMIT=<usd> to pause at a cap."
    fi

    # --- Estimate (non-TTY gap only; the loki CLI shows it on a TTY) ---
    if [ -t 1 ]; then
        return 0
    fi
    # No PRD on disk (codebase-analysis mode) -> nothing to estimate from.
    [ -n "$prd_path" ] && [ -f "$prd_path" ] || return 0

    local loki_bin="${SCRIPT_DIR}/loki"
    [ -x "$loki_bin" ] || { command -v loki >/dev/null 2>&1 && loki_bin="loki" || return 0; }

    local plan_json=""
    plan_json=$(timeout 30 "$loki_bin" plan "$prd_path" --json 2>/dev/null) || plan_json=""
    [ -n "$plan_json" ] || { log_info "Estimate: unavailable (estimator did not return a result); the run continues."; return 0; }

    # Parse REAL numbers only. argv keeps the JSON out of the script body so
    # there is no $<digit> heredoc footgun, and a missing field prints nothing.
    local parsed
    parsed=$(printf '%s' "$plan_json" | python3 -c '
import json, sys
try:
    d = json.load(sys.stdin)
except Exception:
    sys.exit(1)
cost = d.get("cost", {}).get("total_usd")
time_est = d.get("time", {}).get("estimated")
iters = d.get("iterations", {}).get("estimated")
tier = d.get("complexity", {}).get("tier", "")
if cost is None or time_est is None or iters is None:
    sys.exit(1)
print("{:.2f}".format(float(cost)))
print(time_est)
print(iters)
print(tier)
' 2>/dev/null) || parsed=""

    if [ -z "$parsed" ]; then
        log_info "Estimate: unavailable (estimator did not return a result); the run continues."
        return 0
    fi

    local est_cost est_time est_iters est_tier
    est_cost=$(printf '%s' "$parsed" | sed -n '1p')
    est_time=$(printf '%s' "$parsed" | sed -n '2p')
    est_iters=$(printf '%s' "$parsed" | sed -n '3p')
    est_tier=$(printf '%s' "$parsed" | sed -n '4p')

    if [ -n "$est_tier" ]; then
        log_info "Estimate (${est_tier} tier): ~\$${est_cost}, ~${est_time}, ~${est_iters} iterations. Actual usage varies with complexity, review cycles, and test failures."
    else
        log_info "Estimate: ~\$${est_cost}, ~${est_time}, ~${est_iters} iterations. Actual usage varies with complexity, review cycles, and test failures."
    fi
    return 0
}

run_autonomous() {
    local prd_path="$1"

    log_header "Starting Autonomous Execution"

    # Sentrux architectural-drift gate (opt-in via LOKI_SENTRUX_GATE=1, v7.5.15).
    # Source the helper only when the gate is enabled to avoid hot-path overhead
    # for the default-off case. Failure to source is non-fatal -- the wrapper
    # functions degrade to no-ops via type checks.
    if [ "${LOKI_SENTRUX_GATE:-0}" = "1" ]; then
        # shellcheck disable=SC1090,SC1091
        source "${SCRIPT_DIR}/lib/sentrux-gate.sh" 2>/dev/null || true
    fi

    # Explicit user PRD persistence (brownfield reuse, LOCK 1/LOCK 2): when the
    # user passed a real file that is NOT already the canonical generated PRD,
    # copy its content into .loki/generated-prd.md and stamp source:"user" so a
    # later no-file run continues from it without re-running codebase analysis,
    # and never rewrites it. Runs BEFORE the auto-detect block below (which only
    # handles the empty prd_path case). On any failure persist_user_prd echoes
    # "" and changes no state, so the original prd_path is preserved.
    if [ -n "$prd_path" ]; then
        case "$prd_path" in
            *.loki/generated-prd.md|*.loki/generated-prd.json) ;;
            *)
                # Contract ingest (RUN-25 iter 24): if the source is an OpenAPI /
                # GraphQL / Postman contract, expand it into a per-operation
                # checklist at .loki/generated-prd.md so every operation becomes a
                # build requirement, instead of copying the raw contract verbatim
                # and truncating it to the first 4000 prompt bytes. Only rewrites
                # the input; on a non-contract file it echoes "" and we fall
                # through to the normal persist path unchanged.
                if [ -f "$prd_path" ]; then
                    # shellcheck disable=SC1090,SC1091
                    [ -f "${SCRIPT_DIR}/lib/spec-expand.sh" ] && source "${SCRIPT_DIR}/lib/spec-expand.sh" 2>/dev/null || true
                    if declare -f spec_maybe_expand_contract >/dev/null 2>&1; then
                        local _expanded_tmp
                        # Default (temp) mode: the expander returns a TEMP checklist
                        # path; we repoint prd_path at it so the persist_user_prd
                        # call below copies it into .loki/generated-prd.md AND writes
                        # the source:"user" signature (so a later no-file resume
                        # reuses it exactly like a user PRD, not the update path).
                        _expanded_tmp=$(spec_maybe_expand_contract "$prd_path")
                        if [ -n "$_expanded_tmp" ] && [ -f "$_expanded_tmp" ]; then
                            log_info "Expanded API contract ($prd_path) into a per-operation build checklist"
                            prd_path="$_expanded_tmp"
                        fi
                    fi
                fi
                if [ -f "$prd_path" ]; then
                    local _persisted_prd
                    _persisted_prd=$(persist_user_prd "$prd_path")
                    if [ -n "$_persisted_prd" ]; then
                        log_info "Persisted your PRD ($prd_path) to $_persisted_prd; later runs without a file will reuse it as-is"
                        # If prd_path was a spec-expand temp checklist, it is now
                        # copied into the canonical slot -- drop the temp file.
                        case "$prd_path" in
                            *"/loki-spec-expand."*.md) rm -f "$prd_path" 2>/dev/null || true ;;
                        esac
                        prd_path="$_persisted_prd"
                        GENERATED_PRD_ACTION="user_owned"
                        export GENERATED_PRD_ACTION
                    fi
                fi
                ;;
        esac
    fi

    # Contract-first scaffold seam (opt-in via LOKI_SCAFFOLD_CONTRACT_FIRST=1,
    # default OFF -> default build path is byte-identical). Mirrors the
    # LOKI_SENTRUX_GATE pattern: source the helper only when the gate is enabled,
    # and it self-gates further on greenfield + a derivable REST resource. It can
    # ONLY add a starting skeleton (contract-first codegen + real backend + design
    # system, M1-M3); it never overwrites existing code or touches a brownfield
    # repo. On any doubt it no-ops and the normal build proceeds unchanged.
    if [ "${LOKI_SCAFFOLD_CONTRACT_FIRST:-0}" = "1" ]; then
        # shellcheck disable=SC1090,SC1091
        source "${SCRIPT_DIR}/lib/scaffold-hook.sh" 2>/dev/null || true
        if type run_contract_scaffold_hook >/dev/null 2>&1; then
            run_contract_scaffold_hook "${TARGET_DIR:-.}" "$prd_path" || true
        fi
    fi

    # Auto-detect PRD if not provided
    if [ -z "$prd_path" ]; then
        log_step "No PRD provided, searching for existing PRD files..."
        local found_prd=""

        # Search common PRD file patterns (markdown and JSON)
        for pattern in "PRD.md" "prd.md" "PRD.json" "prd.json" \
                       "REQUIREMENTS.md" "requirements.md" "requirements.json" \
                       "SPEC.md" "spec.md" "spec.json" \
                       "docs/PRD.md" "docs/prd.md" "docs/PRD.json" "docs/prd.json" \
                       "docs/REQUIREMENTS.md" "docs/requirements.md" "docs/requirements.json" \
                       "docs/SPEC.md" "docs/spec.md" "docs/spec.json" \
                       ".github/PRD.md" ".github/PRD.json" "PROJECT.md" "project.md" "project.json"; do
            if [ -f "$pattern" ]; then
                found_prd="$pattern"
                break
            fi
        done

        if [ -n "$found_prd" ]; then
            log_info "Found existing PRD: $found_prd"
            prd_path="$found_prd"
            # Warn if a generated PRD also exists (user file takes precedence)
            if [ -f ".loki/generated-prd.md" ] || [ -f ".loki/generated-prd.json" ]; then
                log_warn "Using user PRD ($found_prd) instead of generated PRD (.loki/generated-prd.md). Remove generated PRD if no longer needed."
            fi
        elif [ -f ".loki/generated-prd.md" ] || [ -f ".loki/generated-prd.json" ]; then
            # v7.8.1: staleness-aware reuse. Decide reuse|update|generate ONCE
            # (the decision must be stable across iterations so the cached static
            # prompt prefix does not change mid-run). reuse/update both point
            # prd_path at the existing generated PRD; generate (forced via
            # LOKI_PRD_REGEN) falls through to Codebase Analysis Mode.
            GENERATED_PRD_ACTION=$(decide_generated_prd_action)
            export GENERATED_PRD_ACTION
            local _gen_prd=".loki/generated-prd.md"
            [ -f ".loki/generated-prd.md" ] || _gen_prd=".loki/generated-prd.json"
            # Date the generated PRD was last written (for an honest disclosure).
            local _prd_date=""
            if [ -f ".loki/state/prd-signature.json" ]; then
                _prd_date=$(LOKI_SIG_FILE=".loki/state/prd-signature.json" python3 -c "
import json, os
try:
    d = json.load(open(os.environ['LOKI_SIG_FILE'])).get('generated_at','')
    print((d or '')[:10])
except Exception:
    print('')
" 2>/dev/null)
            fi
            case "$GENERATED_PRD_ACTION" in
                reuse)
                    if [ -n "$_prd_date" ]; then
                        log_info "Reusing the PRD last generated or updated on $_prd_date; pass --fresh-prd to regenerate ($_gen_prd)"
                    else
                        log_info "Reusing the generated PRD (codebase unchanged); pass --fresh-prd to regenerate ($_gen_prd)"
                    fi
                    prd_path="$_gen_prd"
                    ;;
                user_owned)
                    # The user hand-edited the generated PRD. Use it as-is (never
                    # overwrite their edits); distinct disclosure from a clean reuse.
                    log_info "Using your hand-edited PRD as-is ($_gen_prd); pass --fresh-prd to regenerate from the codebase"
                    prd_path="$_gen_prd"
                    ;;
                update)
                    log_info "No user PRD found. Codebase changed since the generated PRD; will update it incrementally ($_gen_prd); pass --fresh-prd to regenerate from scratch"
                    prd_path="$_gen_prd"
                    ;;
                *)
                    log_info "Regenerating PRD from codebase (forced)"
                    prd_path=""
                    ;;
            esac
        else
            GENERATED_PRD_ACTION="generate"
            export GENERATED_PRD_ACTION
            log_info "No PRD found - will analyze codebase and generate one"
        fi
    fi

    log_info "PRD: ${prd_path:-Codebase Analysis Mode}"
    log_info "Max retries: $MAX_RETRIES"
    log_info "Max iterations: $MAX_ITERATIONS"
    log_info "Completion promise: $COMPLETION_PROMISE"
    log_info "Completion council: ${COUNCIL_ENABLED:-true} (${COUNCIL_SIZE:-3} members, ${COUNCIL_THRESHOLD:-2}/${COUNCIL_SIZE:-3} majority)"
    log_info "Base wait: ${BASE_WAIT}s"
    log_info "Max wait: ${MAX_WAIT}s"
    log_info "Autonomy mode: $AUTONOMY_MODE"
    # C4: always surface the budget-guard state (and, on the non-TTY route, a
    # cost/time estimate) BEFORE any real spend. This subsumes the old bare
    # "Budget limit" line so there is exactly one honest disclosure.
    show_run_start_estimate "$prd_path"
    # Only show Claude-specific features for Claude provider
    if [ "${PROVIDER_NAME:-claude}" = "claude" ]; then
        log_info "Prompt repetition (Haiku): $PROMPT_REPETITION"
        log_info "Confidence routing: $CONFIDENCE_ROUTING"
    fi
    echo ""

    load_state
    local retry=$RETRY_COUNT

    # Reuse done-recognition gate (v7.94.0). On a no-PRD run that is REUSING an
    # already-generated PRD, model-verify whether the codebase already satisfies
    # that spec BEFORE rebuilding a task queue and re-running the RARV loop.
    # Routes to one of three outcomes (the verdict is the model's, grounded in
    # re-run tests + code; the only deterministic shortcut is NEGATIVE -> build):
    #   done        -> refresh the verified-completion record, finalize, return 0
    #                  so the queue/loop is skipped and main()'s terminal block
    #                  finishes the run (no wasted iterations, no stray delegate
    #                  branch -- this runs BEFORE the start-sha/delegate block).
    #   incomplete  -> write .loki/state/satisfied-requirements.json so
    #                  populate_prd_queue builds ONLY the unsatisfied items, then
    #                  fall through to the (now incremental) build.
    #   inconclusive-> fall through to the normal full build (safe default).
    # Default-on; LOKI_DONE_RECOGNITION=0 disables it. Armed only on a reuse of
    # an existing generated PRD; `update` (stale PRD) may never fast-stop as done.
    if [ "${LOKI_DONE_RECOGNITION:-1}" != "0" ]; then
    case "${GENERATED_PRD_ACTION:-}" in
        reuse|user_owned|update)
            local _done_recog_lib="$SCRIPT_DIR/lib/done-recognition.sh"
            if [ -f "$_done_recog_lib" ]; then
                # shellcheck source=lib/done-recognition.sh
                source "$_done_recog_lib" 2>/dev/null || true
                if declare -f reuse_done_recognition_gate >/dev/null 2>&1; then
                    if reuse_done_recognition_gate "$prd_path"; then
                        # done verdict: the gate finalized; main()'s terminal
                        # block (run_autonomous's caller) runs the COMPLETED
                        # marker, proof-of-run, and HANDOFF.md.
                        return 0
                    fi
                fi
            fi
            ;;
    esac
    fi

    # Capture run-start SHA for the evidence hard gate (v7.19.1).
    # Fresh-run-aware: recapture HEAD when ITERATION_COUNT==0 (fresh invocation,
    # reset, or corrupted/missing baseline); preserve only on a genuine resume
    # (ITERATION_COUNT>0) so the diff window is not moved mid-run. A naive
    # set-if-absent would leave a stale first-run baseline on every later run,
    # making the gate toothless. Non-git or zero-commit repos write an empty
    # file, which the gate treats as inconclusive (pass-through).
    local _start_sha_file=".loki/state/start-sha"
    mkdir -p ".loki/state"
    # termination.json describes the process that just ended. Once a new
    # process starts, including a durable resume, it must not poison that new
    # process's proof receipt if the resumed run later succeeds.
    rm -f ".loki/state/termination.json" 2>/dev/null || true

    # Delegate-then-notify (Slice 3): LOKI_DELEGATE_BRANCH=1 (default OFF)
    # isolates this run's work on a fresh branch loki/delegate-<timestamp> so the
    # user's working branch stays clean. Created IN-PROCESS (plain git, no
    # detached child) only on a genuine fresh run (ITERATION_COUNT==0) so a
    # resume does not spawn a new branch each time. Best-effort: a non-git repo,
    # dirty tree that blocks checkout, or any git failure leaves the run on the
    # current branch (default behavior preserved). Done BEFORE the start-sha
    # capture so the diff window baselines to the new branch HEAD.
    if [ "${LOKI_DELEGATE_BRANCH:-0}" = "1" ] && [ "${ITERATION_COUNT:-0}" -eq 0 ]; then
        if (cd "${TARGET_DIR:-.}" && git rev-parse --git-dir) >/dev/null 2>&1; then
            local _delegate_branch="loki/delegate-$(date +%Y%m%d-%H%M%S)"
            if (cd "${TARGET_DIR:-.}" && git checkout -b "$_delegate_branch") >/dev/null 2>&1; then
                _LOKI_DELEGATE_BRANCH_NAME="$_delegate_branch"
                export _LOKI_DELEGATE_BRANCH_NAME
                log_info "LOKI_DELEGATE_BRANCH=1: isolated work on new branch '$_delegate_branch'"
            else
                log_warn "LOKI_DELEGATE_BRANCH=1: could not create branch (dirty tree or git error); continuing on current branch."
            fi
        fi
    fi

    if [ "${ITERATION_COUNT:-0}" -eq 0 ] || [ ! -s "$_start_sha_file" ]; then
        # --verify is load-bearing. In a repo with NO commits yet (every
        # greenfield build: git init, nothing committed), plain
        # `git rev-parse HEAD` exits 128 but still prints the literal string
        # "HEAD" ON STDOUT -- so 2>/dev/null does NOT suppress it and the
        # unresolved ref name gets captured as the start SHA. Every downstream
        # diff then becomes `git diff HEAD..HEAD`, which is ALWAYS empty: the
        # completion council blocks with reason "empty_diff" and can never see
        # the work, so it can never vote done and the run burns to the cap.
        # `--verify` resolves-or-fails, emitting nothing on failure, which
        # leaves the file empty and lets consumers apply their no-baseline path.
        (cd "${TARGET_DIR:-.}" && git rev-parse --verify HEAD 2>/dev/null) > "$_start_sha_file" 2>/dev/null || true
    fi
    _LOKI_RUN_START_SHA="$(cat "$_start_sha_file" 2>/dev/null || echo "")"
    export _LOKI_RUN_START_SHA

    # Live Build HUD (v7.71.0): capture the run-start epoch ONCE for the elapsed
    # field. Pure assignment (`:` builtin), emits no output, so the non-TTY/CI/Bun
    # parity surface is byte-identical. := preserves a value across a resume only
    # within the same process; a fresh run stamps now.
    : "${_LOKI_RUN_START_EPOCH:=$(date +%s)}"
    export _LOKI_RUN_START_EPOCH

    # Session-scope the mid-flight model override (model-honesty fix). The
    # override file (.loki/state/model-override) is a LIVE-RUN control: the
    # dashboard UI and docs state it "applies to the current run". A leftover
    # file from a previous run must NOT silently pin every future `loki start`
    # to that model (and to its cost). So clear it once at the start of a FRESH
    # run (ITERATION_COUNT==0). A genuine resume (ITERATION_COUNT>0) and any
    # mid-flight switch made at iteration>0 are preserved, because the clear is
    # guarded on the fresh-run condition only.
    if [ "${ITERATION_COUNT:-0}" -eq 0 ] && [ -f ".loki/state/model-override" ]; then
        local _stale_override
        _stale_override="$(cat .loki/state/model-override 2>/dev/null | tr -d '[:space:]')"
        rm -f ".loki/state/model-override" 2>/dev/null || true
        if [ -n "$_stale_override" ]; then
            log_info "Cleared leftover model override ('$_stale_override') at session start; the override applies to the current run only."
        fi
    fi

    # Session-continuity Phase 2 (GitHub #165): snapshot whether THIS run is a
    # RESTARTED run BEFORE the main loop mutates ITERATION_COUNT. load_state
    # (called above) restored ITERATION_COUNT from .loki/autonomy-state.json's
    # iterationCount, resetting to 0 after a terminal prior run. So at this point
    # ITERATION_COUNT>0 means "the prior run was interrupted (non-terminal) and
    # is being restarted"; ==0 means fresh. The main loop increments
    # ITERATION_COUNT at the top of each pass, so the resume decision MUST key on
    # this run-start snapshot, never the live counter. _LOKI_RESUME_CONSUMED is
    # the once-only latch so the recovery resume fires on exactly the FIRST
    # main-loop call of a restarted run, then the run reverts to normal stateless
    # iterations (no resume chain -- transcript growth cannot accumulate).
    if [ "${ITERATION_COUNT:-0}" -gt 0 ]; then
        _LOKI_RESTARTED_RUN=1
    else
        _LOKI_RESTARTED_RUN=0
    fi
    _LOKI_RESUME_CONSUMED=0
    export _LOKI_RESTARTED_RUN _LOKI_RESUME_CONSUMED

    # Trust-metrics instrumentation marker: record one run_start event per
    # fresh run so the trust-metrics denominator counts ONLY instrumented runs.
    # This is what lets the aggregator distinguish "0 blocks measured" from
    # "this run predates instrumentation" (the central honesty rule). Additive,
    # best-effort, stdout-silent; never affects control flow. Mint a fresh
    # per-run id here and export it so every later event in this run shares it
    # (LOKI_SESSION_ID is absent on the `loki start` path).
    if [ "${ITERATION_COUNT:-0}" -eq 0 ]; then
        LOKI_TRUST_RUN_ID="$(_loki_trust_run_id --new)"
        export LOKI_TRUST_RUN_ID
        record_trust_event_bash "run_start" "start_sha=${_LOKI_RUN_START_SHA:-}" 2>/dev/null || true

        # v7.34.0 Phase 1 (correlation-only): write a deterministic claude
        # session UUID derived from the trust-run-id to .loki/state/claude-session.json.
        # mode is "stamp" (Phase 1); Phase 2 continuity is a separate, opt-in arc.
        # Best-effort: the helper is in scope via providers/claude.sh sourcing
        # claude-flags.sh; if absent (e.g. non-claude provider, no python3) we
        # skip silently and never fail the run. The dashboard reads this file to
        # surface it for correlating the run with its Claude session JSONL.
        if type _loki_claude_session_uuid >/dev/null 2>&1; then
            local _loki_session_uuid
            _loki_session_uuid="$(_loki_claude_session_uuid "$LOKI_TRUST_RUN_ID")"
            if [ -n "$_loki_session_uuid" ]; then
                local _loki_session_created
                _loki_session_created="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
                # mode reflects the active session-continuity layer for this run,
                # surfaced on the dashboard: "resume" when Phase 2 recovery resume
                # is enabled (GitHub #165), else "stamp" (Phase 1 correlation-only,
                # v7.34). DEFAULT (no knobs) stays "stamp" so existing behavior +
                # dashboard output are unchanged. The uuid is the SAME stable
                # per-run uuid either way -- it is the resume anchor a later
                # restart reads back. The "stamp" vs "resume" label only records
                # intent; the actual argv decision is gated again at call time.
                local _loki_session_mode="stamp"
                if type loki_resume_session_enabled >/dev/null 2>&1 \
                   && loki_resume_session_enabled; then
                    _loki_session_mode="resume"
                fi
                mkdir -p ".loki/state" 2>/dev/null || true
                printf '{"run_id":"%s","claude_session_uuid":"%s","mode":"%s","created_at":"%s"}\n' \
                    "$LOKI_TRUST_RUN_ID" "$_loki_session_uuid" "$_loki_session_mode" "$_loki_session_created" \
                    > ".loki/state/claude-session.json" 2>/dev/null || true
            fi
        fi
    fi

    # Notify dashboard of active project directory (for AI Chat cross-directory usage)
    if command -v curl &>/dev/null; then
        local project_cwd
        project_cwd="$(pwd)"
        curl -sf -X POST "http://127.0.0.1:${DASHBOARD_PORT}/api/focus" \
            -H "Content-Type: application/json" \
            -d "{\"project_dir\": \"${project_cwd}\"}" \
            >/dev/null 2>&1 || true
    fi

    # Initialize Cross-Provider Failover (v6.19.0)
    init_failover_state

    # Initialize Completion Council (v5.25.0)
    if type council_init &>/dev/null; then
        council_init "$prd_path"
    fi

    # PRD Quality Analysis and Checklist Init (v5.44.0)
    if [ -n "$prd_path" ] && [ -f "$prd_path" ]; then
        if [ -f "${SCRIPT_DIR}/prd-analyzer.py" ]; then
            log_step "Analyzing PRD quality..."
            python3 "${SCRIPT_DIR}/prd-analyzer.py" "$prd_path" \
                --output ".loki/prd-observations.md" \
                ${LOKI_INTERACTIVE_PRD:+--interactive} 2>/dev/null || true
        fi
        if type checklist_init &>/dev/null; then
            checklist_init "$prd_path"
        fi
    fi

    # P2-1: Spec interrogation (DISCOVERY phase, BEFORE iteration 1 begins
    # coding). Auto-detects spec ambiguities/contradictions/underspecification
    # via the Devil's-Advocate grill + prd-analyzer, classifies them with a
    # deterministic severity, and records every gap as a first-class assumption
    # under .loki/assumptions/. Default-on; LOKI_SPEC_GRILL=0 opts out.
    # Provider-aware and degrades cleanly (no provider -> prd-analyzer
    # assumptions only, no fabricated questions). Best-effort: never blocks the
    # run. The completion-side teeth are council_assumption_ledger_gate.
    if [ "${LOKI_SPEC_GRILL:-1}" != "0" ] && [ -f "${SCRIPT_DIR}/spec-interrogation.sh" ]; then
        # shellcheck disable=SC1090
        . "${SCRIPT_DIR}/spec-interrogation.sh" 2>/dev/null || true
        if type spec_interrogation_run &>/dev/null; then
            # TIMED. Startup was completely unmeasured: on a real run, 128
            # SECONDS elapsed between session_start and iteration_start -- over
            # two minutes in which the user sees nothing and no agent work has
            # begun. Nothing in .loki/events.jsonl accounted for any of it, so
            # the interval could not be attributed, let alone optimised.
            #
            # This step calls the provider, so it is the prime suspect for the
            # bulk of that window. Naming it turns "startup is slow" into a
            # number, the same way stage timings turned "the run is slow" into
            # "the agent call is 93% of wall clock".
            #
            # Uses the existing emit_stage_complete channel, so measure-run.sh
            # and every other consumer pick it up with no new plumbing.
            local _si_t0
            _si_t0=$(date +%s 2>/dev/null || echo 0)
            spec_interrogation_run "$prd_path" || true
            emit_stage_complete "spec_interrogation" "pass" "$_si_t0" 2>/dev/null || true
        fi
        # #87: no-HITL fast-fail on an unresolved spec-INTERNAL contradiction.
        # A contradiction (class=contradictory) is NEVER auto-acked (P2-4) and only
        # a human sets confirmed=true, so in an AUTONOMOUS run it can never clear ->
        # the completion gate blocks EVERY iteration -> the loop grinds to
        # max-iterations (~20min, opaque). This run is already doomed; here we fail
        # FAST + HONEST + NAMED instead. Honesty: this is the no-HITL rule verbatim
        # ("when unsafe to decide, inconclusive-and-proceed, never fake-green") --
        # it NEVER declares done/green (inconclusive_spec_contradiction maps to a
        # terminal-failure exit, tested), it just replaces the opaque grind.
        # SCOPE (deliberately narrow, no regression): fires ONLY when
        #   (a) non-interactive/no-HITL (a TTY human COULD resolve it -> let them), AND
        #   (b) LOKI_ASSUMPTIONS_REQUIRE_CONFIRM != 1 (that knob means a human WILL
        #       confirm -> do not pre-empt), AND
        #   (c) there is >=1 UNRESOLVED class=contradictory entry (the contradiction-
        #       specific count, NOT the broader high-unresolved which includes
        #       auto-ackable non-contradictions).
        # Opt-out: LOKI_SPEC_CONTRADICTION_FASTFAIL=0.
        # NEVER-FAIL-A-STAGE default (founder policy): a spec-contradiction is now
        # NON-FATAL by default -- it is recorded as a high-severity finding and the
        # build PROCEEDS (the completion gate still refuses a "green" done while an
        # unresolved contradiction stands, and it is surfaced in proof-of-done, so
        # this never fakes success; it only stops a false-positive from terminating
        # a valid build before iteration 1). The grill classifier is an LLM and
        # over-fires; combined with the no-retry terminal that produced valid
        # specs killed at 0 iterations. Opt INTO the old hard terminal-fail with
        # LOKI_SPEC_CONTRADICTION_FASTFAIL=1 (default is now 0).
        if [ "${LOKI_SPEC_CONTRADICTION_FASTFAIL:-0}" = "1" ] \
           && [ ! -t 0 ] \
           && [ "${LOKI_ASSUMPTIONS_REQUIRE_CONFIRM:-0}" != "1" ] \
           && type spec_ledger_contradiction_unresolved_count &>/dev/null; then
            _sc_out="$(spec_ledger_contradiction_unresolved_count 2>/dev/null)"
            _sc_n="$(printf '%s' "$_sc_out" | head -1)"
            case "$_sc_n" in ''|*[!0-9]*) _sc_n=0 ;; esac
            if [ "$_sc_n" -ge 1 ]; then
                # CONFIDENCE GATE (never fail a stage on a flaky verdict): the
                # contradiction count comes from a SINGLE Devil's-Advocate grill
                # sample, and an LLM judge is non-deterministic -- the same spec is
                # judged contradictory only some fraction of runs (~1/3 measured).
                # exit 20 is a NO-RETRY terminal whose contract asserts "re-running
                # fails the same way", which is false for a lone LLM sample. So
                # before terminal-failing, require the contradiction to reproduce
                # across LOKI_SPEC_CONTRADICTION_MIN_SAMPLES independent samples
                # (default 2). If it does NOT reproduce, it was flaky: do NOT
                # exit 20 -- fall through into the loop, where the existing
                # resolve-with-default recovery (spec_ledger_acknowledge_all)
                # handles it. Set LOKI_SPEC_CONTRADICTION_MIN_SAMPLES=1 to restore
                # the old single-sample terminal behavior.
                if type spec_contradiction_confident &>/dev/null \
                   && ! spec_contradiction_confident "$prd_path"; then
                    log_warn "Spec contradiction did not reproduce across ${LOKI_SPEC_CONTRADICTION_MIN_SAMPLES:-2} independent grill samples; treating the single-sample verdict as flaky and proceeding (resolve-with-default handles any residual). Not failing the run."
                else
                    # Confirmed across samples (or confidence gate unavailable):
                    # this is a real contradiction -> fast-fail honestly.
                    # Option C: name the contradicting clauses so `loki why` is actionable.
                    local _sc_titles
                    _sc_titles="$(printf '%s' "$_sc_out" | tail -n +2 | sed 's/^/  - /' | head -5)"
                    log_error "Spec has ${_sc_n} unresolved internal contradiction(s) (confirmed across samples); an autonomous run cannot resolve them (only a human can). Failing fast instead of grinding to max-iterations."
                    [ -n "$_sc_titles" ] && printf '%s\n' "$_sc_titles" >&2
                    if type _loki_write_last_error &>/dev/null; then
                        _loki_write_last_error 0 "spec_contradiction" \
                            "Spec is internally inconsistent (${_sc_n} unresolved contradiction(s)); resolve the conflicting requirements, then re-run."
                    fi
                    # 20, matching what this arm actually RETURNS a few lines
                    # below. save_state's third argument is persisted as
                    # lastExitCode, so recording 0 here left the state file
                    # claiming success for a run whose process exited 20.
                    # A consumer reading the record -- `loki why`, the
                    # dashboard, a CI script -- saw a clean stop for a spec that
                    # was never buildable.
                    #
                    # The TS route already classified this status as a terminal
                    # failure (ENT3_TERMINAL_FAILURE), and the bash ENT-3 arm
                    # lists it beside `failed` and `policy_blocked`. Only the
                    # persisted field disagreed.
                    save_state "$retry" "inconclusive_spec_contradiction" 20
                    if type emit_completion_summary &>/dev/null; then
                        emit_completion_summary inconclusive_spec_contradiction 2>/dev/null || true
                    fi
                    return 20
                fi
            fi
        fi
    fi

    # Auto-derive completion promise from PRD (v6.10.0)
    # When PRD exists but no explicit promise, auto-derive one and switch to checkpoint mode
    if [ -n "$prd_path" ] && [ -f "$prd_path" ] && [ -z "$COMPLETION_PROMISE" ]; then
        if [ "${LOKI_AUTO_COMPLETION_PROMISE:-true}" = "true" ]; then
            COMPLETION_PROMISE="All PRD requirements implemented and tests passing"
            log_info "Auto-derived completion promise: $COMPLETION_PROMISE"
            # PRD-driven work is finite; switch from perpetual to checkpoint
            if [ "${LOKI_FORCE_PERPETUAL:-false}" != "true" ] && [ "$AUTONOMY_MODE" = "perpetual" ]; then
                AUTONOMY_MODE="checkpoint"
                PERPETUAL_MODE="false"
                log_info "Switched autonomy mode: perpetual -> checkpoint (PRD-driven work is finite)"
            fi
        fi
    fi

    # Populate task queue from BMAD artifacts (if present, runs once)
    populate_bmad_queue

    # Populate task queue from OpenSpec artifacts (if present, runs once)
    populate_openspec_queue

    # Populate task queue from MiroFish advisory (if present, runs once)
    populate_mirofish_queue

    # Populate task queue from PRD (if no adapters already populated, runs once)
    populate_prd_queue "$prd_path"

    # Magic Modules BOOTSTRAP: extract design tokens from project so component
    # generation matches the codebase design language from iteration 1.
    if [ -x "${PROJECT_DIR}/autonomy/loki" ]; then
        PYTHONPATH="${PROJECT_DIR}" python3 -c "
try:
    from magic.core.design_tokens import DesignTokens
    dt = DesignTokens('${TARGET_DIR}')
    observed = dt.extract_from_codebase(save=True)
    print(f'[magic] Extracted design tokens: '
          f'{len(observed.get(\"colors\",{}))} colors, '
          f'{len(observed.get(\"spacing\",{}))} spacing')
except Exception as exc:
    print(f'[magic] Token extraction skipped: {exc}')
" 2>&1 | grep -E '\[magic\]' || true
    fi

    # Check max iterations before starting
    if check_max_iterations; then
        log_error "Max iterations already reached. Reset with: rm $(_loki_state_file)"
        # Delegate-then-notify: terminal state. Mirror the in-loop max-iterations
        # site so a detached (--bg) run still writes COMPLETION.txt + fires the
        # ping on this pre-loop exit. _LOKI_RUN_START_SHA is already exported
        # above (runner init), so the diff window is correct. This return is
        # mutually exclusive with the in-loop site (it returns before the loop),
        # so there is no double-emit.
        emit_completion_summary max_iterations
        save_state "$retry" "max_iterations_reached" 20
        return 20
    fi

    # v7.40.0 (#584): autonomous complexity-gated decision for the no-PRD
    # codebase-analysis dispatch. detect_complexity() is defined (~1611) but was
    # never called on the bash route, so DETECTED_COMPLEXITY stayed "" and the
    # gate was inert here. Call it ONCE per run, before the first build_prompt,
    # so DETECTED_COMPLEXITY is populated for the gate AND for the existing
    # complexity consumers (effort-for-tier, telemetry, phase selection) that
    # previously always fell back to "standard" on bash. detect_complexity()
    # sets the DETECTED_COMPLEXITY global directly (no echo); call once and let
    # the cached value carry through every iteration so the static prompt prefix
    # is stable across the run.
    if [ -z "${DETECTED_COMPLEXITY:-}" ]; then
        detect_complexity "$prd_path"
    fi

    # Decide the workflow-analysis dispatch ONCE here (parent shell), not inside
    # build_prompt (which runs in a $(...) subshell where a write would not
    # propagate). The decision is parity-locked with the Bun route
    # (useClaudeWorkflowsForAnalysis in loki-ts/src/runner/build_prompt.ts) and
    # evaluated in the SAME order:
    #   provider != claude          -> three-pass (USE_WORKFLOW_ANALYSIS=0)
    #   PROVIDER_DEGRADED=true       -> three-pass
    #   LOKI_USE_CLAUDE_WORKFLOWS=0  -> three-pass (escape hatch)
    #   LOKI_USE_CLAUDE_WORKFLOWS=1  -> workflow   (force on)
    #   var UNSET                    -> workflow IFF DETECTED_COMPLEXITY == complex
    # When the workflow path is chosen AUTONOMOUSLY (var unset + complex), emit a
    # one-time cost disclosure to stderr (NOT stdout, which is the prompt). The
    # decision is read by build_prompt to prefix the analysis instruction with
    # "ultracode: ". This ONLY affects the read-only no-PRD analysis instruction,
    # so the entire decision is gated on the no-PRD case ([ -z "$prd_path" ]).
    # When a PRD exists, build_prompt never emits the analysis instruction, so
    # USE_WORKFLOW_ANALYSIS stays 0 and no disclosure fires (the "no PRD found"
    # message would be false otherwise). detect_complexity above stays
    # unconditional because its other consumers run in PRD mode too.
    USE_WORKFLOW_ANALYSIS=0
    local _wf_autonomous=0
    if [ -z "$prd_path" ] && [ "${LOKI_PROVIDER:-claude}" = "claude" ] && [ "${PROVIDER_DEGRADED:-false}" != "true" ]; then
        if [ "${LOKI_USE_CLAUDE_WORKFLOWS:-}" = "0" ]; then
            USE_WORKFLOW_ANALYSIS=0
        elif [ "${LOKI_USE_CLAUDE_WORKFLOWS:-}" = "1" ]; then
            USE_WORKFLOW_ANALYSIS=1
        elif [ -z "${LOKI_USE_CLAUDE_WORKFLOWS:-}" ] && [ "${DETECTED_COMPLEXITY:-}" = "complex" ]; then
            USE_WORKFLOW_ANALYSIS=1
            _wf_autonomous=1
        fi
    fi
    # One-time autonomous-decision disclosure to stderr. Only fires when this run
    # took the workflow path on its own (not when forced via =1, not when off).
    # No fabricated dollar figure (no price API): name the cost CLASS and the
    # opt-out. Guard so it prints once per run.
    if [ "$_wf_autonomous" = "1" ] && [ -z "${_LOKI_WORKFLOW_DISCLOSED:-}" ]; then
        echo "Loki: no PRD found and this repo looks complex (complexity=complex), so the codebase-analysis pass is dispatching a Claude Code Dynamic Workflow (parallel fan-out). Workflows are more thorough but cost meaningfully more than the default three-pass analysis. Set LOKI_USE_CLAUDE_WORKFLOWS=0 to keep the cheaper three-pass pass." >&2
        _LOKI_WORKFLOW_DISCLOSED=1
        export _LOKI_WORKFLOW_DISCLOSED
    fi
    export USE_WORKFLOW_ANALYSIS

    while [ $retry -lt $MAX_RETRIES ]; do
        # Check for human intervention BEFORE incrementing iteration count
        # BUG-ST-010: Moved pause/stop checks before ITERATION_COUNT increment
        # to prevent spurious count increases when resuming from pause
        check_human_intervention
        local intervention_result=$?
        case $intervention_result in
            1) continue ;;  # PAUSE handled, restart loop
            2) return 0 ;;  # STOP requested
        esac

        # Check budget limit (creates PAUSE file if exceeded)
        if check_budget_limit; then
            log_warn "Session paused due to budget limit. Remove .loki/PAUSE to resume."
            save_state $retry "budget_exceeded" 0
            continue  # Will hit PAUSE check on next iteration
        fi

        # Check the completed-attempt count before starting the next attempt.
        # Checking after increment made a ceiling of 2 run only one attempt.
        if check_max_iterations; then
            save_state "$retry" "max_iterations_reached" 20
            # Delegate-then-notify: terminal state, write summary + ping so a
            # detached run tells the user it stopped at the iteration cap.
            emit_completion_summary max_iterations
            return 20
        fi

        # Wall-clock cap. Checked alongside the iteration cap and treated the
        # same way: a distinct terminal status so the receipt and `loki why`
        # can say the run ran out of TIME rather than iterations or money, and
        # exit 20 because re-running the same spec under the same cap will hit
        # the same wall. The operator raises LOKI_MAX_DURATION (or narrows the
        # spec) and submits again.
        if check_max_duration; then
            save_state "$retry" "max_duration_reached" 20
            emit_completion_summary max_duration
            return 20
        fi

        # Increment after all pre-attempt stop checks pass.
        ((ITERATION_COUNT++))

        # Watchdog: periodic process health check (opt-in via LOKI_WATCHDOG=true)
        if [[ "$WATCHDOG_ENABLED" == "true" ]]; then
            local now_epoch
            now_epoch=$(date +%s)
            if (( now_epoch - LAST_WATCHDOG_CHECK >= WATCHDOG_INTERVAL )); then
                watchdog_check
                LAST_WATCHDOG_CHECK=$now_epoch
            fi
        fi

        # Auto-track iteration start (for dashboard task queue)
        track_iteration_start "$ITERATION_COUNT" "$prd_path"

        # Sentrux architectural-drift baseline snapshot (opt-in, v7.5.15).
        _loki_sentrux_iteration_start "${TARGET_DIR:-.}"

        local prompt
        if loki_is_supervised_simple_web; then
            prompt=$(_loki_build_supervised_simple_web_prompt "$retry" "$prd_path" "$ITERATION_COUNT")
        else
            prompt=$(build_prompt "$retry" "$prd_path" "$ITERATION_COUNT")
        fi

        # P2-2 auto-acknowledgment lifecycle: build_prompt just injected the
        # high-severity spec assumptions into the prompt (assumption_context), so
        # the agent has now SEEN them. Mark them acknowledged so the completion
        # gate is not a permanent dead-end in autonomous (non-TTY) mode where no
        # human can ever set confirmed=true. This is the opposite of silent
        # autocorrect: the gap was recorded, prompt-injected, and is surfaced in
        # proof-of-done. LOKI_ASSUMPTIONS_REQUIRE_CONFIRM=1 disables auto-ack so
        # only a human confirmation clears the block (the helper checks the knob).
        if type spec_ledger_acknowledge_all &>/dev/null; then
            spec_ledger_acknowledge_all 2>/dev/null || true
        fi

        # BUG #5 fix: Clear LOKI_HUMAN_INPUT in the parent shell after build_prompt
        # consumed it. build_prompt runs in a subshell (command substitution), so
        # any unset inside it does not affect the parent. Clear here to prevent
        # the same directive from repeating every iteration.
        if [ -n "${LOKI_HUMAN_INPUT:-}" ]; then
            unset LOKI_HUMAN_INPUT
            rm -f "${TARGET_DIR:-.}/.loki/HUMAN_INPUT.md"
        fi

        echo ""
        log_header "Attempt $((retry + 1)) of $MAX_RETRIES"
        log_info "Prompt: $prompt"
        echo ""

        save_state $retry "running" 0

        # v7.6.4 B-3a fix: capture iteration-start git SHA so auto_capture_episode
        # can diff against this baseline (not just HEAD, which is empty after
        # loki's per-iteration auto-commit makes the new files HEAD).
        # --verify: without it, a no-commits-yet repo makes `git rev-parse HEAD`
        # print the literal "HEAD" on STDOUT while exiting 128, so the
        # `|| echo ""` fallback never fires and the unresolved ref name is
        # captured as a baseline. See the start-sha capture above.
        _LOKI_ITER_START_SHA=$(cd "${TARGET_DIR:-.}" && git rev-parse --verify HEAD 2>/dev/null || echo "")
        export _LOKI_ITER_START_SHA
        _LOKI_ITER_START_TREE=$(_loki_snapshot_workspace_tree "${TARGET_DIR:-.}" 2>/dev/null || echo "")
        export _LOKI_ITER_START_TREE
        if loki_is_supervised_simple_web && [ -z "$_LOKI_ITER_START_TREE" ]; then
            log_error "Could not capture the exact iteration workspace snapshot. Refusing an ambiguous brownfield gate baseline."
            return 1
        fi

        # Run AI provider with live output
        local start_time=$(date +%s)
        local log_file=".loki/logs/autonomy-$(date +%Y%m%d).log"
        local agent_log=".loki/logs/agent.log"

        # Ensure agent.log exists for dashboard real-time view
        # (Dashboard reads this file for terminal output)
        # Keep history but limit size to ~1MB to prevent memory issues
        if [ -f "$agent_log" ] && [ "$(stat -f%z "$agent_log" 2>/dev/null || stat -c%s "$agent_log" 2>/dev/null)" -gt 1000000 ]; then
            # Trim to last 500KB
            tail -c 500000 "$agent_log" > "$agent_log.tmp" && mv "$agent_log.tmp" "$agent_log"
        fi

        # Same cap on the daily log. agent.log has been trimmed since it was
        # introduced; its sibling never was, and it receives the full raw
        # stream-json of every iteration -- measured ~1.5MB per iteration, so a
        # 500-iteration run leaves ~725MB per day per build, times however many
        # builds share the machine. Same threshold, same trim, no new rotation
        # scheme.
        if [ -f "$log_file" ] && [ "$(stat -f%z "$log_file" 2>/dev/null || stat -c%s "$log_file" 2>/dev/null)" -gt 1000000 ]; then
            tail -c 500000 "$log_file" > "$log_file.tmp" && mv "$log_file.tmp" "$log_file"
        fi
        touch "$agent_log"
        echo "" >> "$agent_log"
        echo "════════════════════════════════════════════════════════════════" >> "$agent_log"
        echo "  NEW SESSION - $(date)" >> "$agent_log"
        echo "════════════════════════════════════════════════════════════════" >> "$agent_log"

        echo ""
        echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
        echo -e "${CYAN}  ${PROVIDER_DISPLAY_NAME:-CLAUDE CODE} OUTPUT (live)${NC}"
        if [ "${PROVIDER_DEGRADED:-false}" = "true" ]; then
            # Only claim "sequential only" when the provider genuinely cannot run
            # parallel sessions. Degraded is not synonymous with serial: a
            # provider can lack subagents or the Task tool while still supporting
            # concurrent worktree sessions.
            if [ "${PROVIDER_HAS_PARALLEL:-false}" = "true" ]; then
                echo -e "${YELLOW}  [DEGRADED MODE: reduced capability]${NC}"
            else
                echo -e "${YELLOW}  [DEGRADED MODE: Sequential execution only]${NC}"
            fi
        fi
        echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
        echo ""

        # BUG-RUN-001/RUN-002: Per-iteration output file for scoped checks
        # (completion promise and rate limit detection should not scan stale daily logs)
        local iter_output
        iter_output=$(mktemp ".loki/logs/iter-output-XXXXXX")

        # Log start time (to both archival and dashboard logs)
        echo "=== Session started at $(date) ===" | tee -a "$log_file" "$agent_log"
        echo "=== Provider: ${PROVIDER_NAME:-claude} ===" | tee -a "$log_file" "$agent_log"
        echo "=== Prompt (truncated): ${prompt:0:200}... ===" | tee -a "$log_file" "$agent_log"

        # Tier selection (S0.1):
        # Default: pin a single tier for the whole session via LOKI_SESSION_MODEL.
        # Legacy: LOKI_LEGACY_TIER_SWITCHING=true restores RARV-driven rotation.
        # Reset the opus-pin flag every iteration BEFORE either branch so a value
        # from a prior pass can never leak (the legacy branch never sets it).
        local _loki_session_pin_opus=0
        if [ "${LOKI_LEGACY_TIER_SWITCHING:-false}" = "true" ]; then
            CURRENT_TIER=$(get_rarv_tier "$ITERATION_COUNT")
        else
            # Map session-pinned model name to an abstract tier so provider
            # helpers (which expect tier names) resolve correctly. Unknown
            # model strings are passed through as-is; provider loaders fall
            # back to a sane default.
            #
            # Normalize case + surrounding whitespace BEFORE the match so
            # 'OPUS' and ' opus ' resolve identically to 'opus'. We do NOT use
            # loki_normalize_model_alias here: that helper is the narrow
            # OVERRIDE-file allowlist (haiku|sonnet|opus|fable) and would strip
            # the documented tier-name pins (planning|development|fast) to
            # empty, collapsing them onto the default tier. The session pin
            # legitimately accepts tier names (skills/model-selection.md), and
            # the estimator + dashboard mirror this exact tier route, so the
            # canonical session-pin rule is trim+lowercase WITHOUT the alias
            # allowlist. Interior whitespace is preserved (so 'fab le' stays a
            # junk value that falls through the '*' default arm), matching the
            # estimator/dashboard ports.
            local _session_pin="${LOKI_SESSION_MODEL:-sonnet}"
            _session_pin="${_session_pin#"${_session_pin%%[![:space:]]*}"}"
            _session_pin="${_session_pin%"${_session_pin##*[![:space:]]}"}"
            _session_pin="$(printf '%s' "$_session_pin" | tr '[:upper:]' '[:lower:]')"
            case "$_session_pin" in
                opus)   CURRENT_TIER="planning" ;;
                sonnet) CURRENT_TIER="development" ;;
                haiku)  CURRENT_TIER="fast" ;;
                fable)  CURRENT_TIER="fable" ;;
                planning|development|fast) CURRENT_TIER="$_session_pin" ;;
                *)      CURRENT_TIER="$_session_pin" ;;
            esac
            # v7.104.0 opus-pin fix: post the Sonnet-5 default flip, NO tier
            # resolves to opus (planning+development+fast all default to sonnet).
            # So mapping an opus SESSION pin to the planning tier would silently
            # dispatch SONNET - a model-honesty lie ("user picks opus, gets
            # sonnet"). A concrete opus pin must dispatch opus. Record it so the
            # tier_param resolution below sets the model directly to opus (still
            # clamped by LOKI_MAX_TIER). sonnet/haiku pins stay on the tier route
            # so the LOKI_ALLOW_HAIKU gate for haiku is preserved. Mirrored in the
            # estimator (autonomy/loki) and dashboard (server.py) + parity test.
            [ "$_session_pin" = "opus" ] && _loki_session_pin_opus=1
            # Apply the tier-aware harness policy (gated + operator-override-safe).
            loki_apply_tier_harness_policy "$_session_pin"
        fi
        # Architect opt-in (LOKI_FABLE_ARCHITECT=1): route ONLY the first
        # iteration (the architecture/REASON pass) to Fable, then fall back to
        # the session tier for all later iterations. This is the honest
        # implementation of "fable for architecture only": run.sh is the only
        # scope that has ITERATION_COUNT, so the decision lives here (not in the
        # stateless provider resolver). An EXPLICIT planning-model override still
        # wins, and the LOKI_MAX_TIER ceiling clamps fable down via the resolver.
        # Default OFF (Fable is 2x Opus). Without this scoping, a session pinned
        # to opus would route EVERY iteration to fable.
        #
        # NOTE on the index: ITERATION_COUNT is incremented at the TOP of the
        # loop (see "((ITERATION_COUNT++))" above), so the FIRST in-loop pass
        # has ITERATION_COUNT==1, not 0. The guard matches 1 so the architecture
        # iteration actually fires (a -eq 0 guard here would be a silent no-op,
        # the exact bug this fix removes). The estimator models this same first
        # iteration as its 0-indexed range() i==0, so quote and run agree.
        #
        # PRECEDENCE: a mid-flight model override (.loki/state/model-override,
        # applied later in this iteration body) WINS over this architect pin.
        # Deliberate: a live user action in the dashboard outranks an env
        # opt-in set at launch. The override is still clamped by LOKI_MAX_TIER.
        if [ "${ITERATION_COUNT:-0}" -eq 1 ] \
           && [ "${LOKI_FABLE_ARCHITECT:-0}" = "1" ] \
           && [ -z "${LOKI_CLAUDE_MODEL_PLANNING:-}" ] \
           && [ -z "${LOKI_MODEL_PLANNING:-}" ]; then
            CURRENT_TIER="fable"
            log_info "LOKI_FABLE_ARCHITECT=1: routing the first (architecture) iteration to fable; later iterations use the session tier"
        fi
        # Export LOKI_CURRENT_TIER so provider helper functions
        # can resolve the correct model.
        # Without this, LOKI_CURRENT_TIER is always empty and defaults to "planning".
        LOKI_CURRENT_TIER="$CURRENT_TIER"
        export LOKI_CURRENT_TIER
        local rarv_phase=$(get_rarv_phase_name "$ITERATION_COUNT")
        local tier_param=$(get_provider_tier_param "$CURRENT_TIER")
        # v7.104.0 opus-pin fix (see the case block above): an opus SESSION pin
        # must dispatch opus, not the sonnet-defaulted planning tier. Set the
        # model directly, then apply the SAME LOKI_MAX_TIER clamp the resolver
        # uses so the operator's cost ceiling still binds (e.g. LOKI_MAX_TIER=sonnet
        # caps this back down). Only for the claude provider; other providers map
        # tiers to their own model strings.
        if [ "${PROVIDER_NAME:-claude}" = "claude" ] && [ "${_loki_session_pin_opus:-0}" = "1" ]; then
            tier_param="opus"
            if type loki_apply_max_tier_clamp >/dev/null 2>&1; then
                # Clamp at the PLANNING level (opus conceptually sits at planning):
                # loki_apply_max_tier_clamp(model, tier). Passing tier=opus would
                # make the sonnet-cap arm a no-op (it only downgrades planning/fable),
                # letting opus ESCAPE a LOKI_MAX_TIER=sonnet ceiling. tier=planning
                # gives: no-cap->opus, haiku->fast, sonnet->development(sonnet),
                # opus->opus. The ceiling wins over the pin, as everywhere else.
                tier_param="$(loki_apply_max_tier_clamp "opus" "planning")"
            fi
        fi
        # Mid-flight model override: the dashboard (POST /api/session/model) or a
        # CLI user may rewrite .loki/state/model-override between iterations to
        # change the model a live run uses. Read it here, after tier_param is
        # resolved and before the claude argv is built (--model "$tier_param" is
        # assembled below), so the override flows through effort/budget/fallback
        # with no other change. Each iteration spawns a fresh `claude -p`, so the
        # switch takes effect at THIS iteration boundary and never mid-invocation
        # (claude -p fixes the model per call). Clearing/emptying the file reverts
        # to the tier mapping. The file is fed straight into --model, so only an
        # allowlisted alias is honored; invalid content is ignored with one warn.
        # The override applies ONLY to the claude provider; other providers map
        # tier_param to effort/model strings and have no fable equivalent.
        if [ "${PROVIDER_NAME:-claude}" = "claude" ] && [ -s ".loki/state/model-override" ]; then
            local _loki_override_file _loki_override_alias
            _loki_override_file="$(cat .loki/state/model-override 2>/dev/null)"
            # Canonical normalization shared with the dashboard + estimator
            # (trim + lowercase + exact allowlist). "fab le" and other non-exact
            # values normalize to empty and are rejected, so all three readers
            # agree on what the file means. Falls back to a local case only if
            # the provider helper is somehow not in scope.
            if type loki_normalize_model_alias >/dev/null 2>&1; then
                _loki_override_alias="$(loki_normalize_model_alias "$_loki_override_file")"
            else
                # Fallback only if the provider helper is not sourced. Mirror the
                # canonical rule EXACTLY: trim ends + lowercase + exact allowlist,
                # so interior whitespace ("fab le") is REJECTED here too (do NOT
                # use `tr -d [:space:]`, which would collapse it into a false
                # accept and re-introduce the normalization divergence).
                _loki_override_alias=""
                local _loki_ov_trim="$_loki_override_file"
                _loki_ov_trim="${_loki_ov_trim#"${_loki_ov_trim%%[![:space:]]*}"}"
                _loki_ov_trim="${_loki_ov_trim%"${_loki_ov_trim##*[![:space:]]}"}"
                _loki_ov_trim="$(printf '%s' "$_loki_ov_trim" | tr '[:upper:]' '[:lower:]')"
                case "$_loki_ov_trim" in
                    haiku|sonnet|opus|fable) _loki_override_alias="$_loki_ov_trim" ;;
                esac
            fi
            if [ -n "$_loki_override_alias" ]; then
                # Apply the SAME LOKI_MAX_TIER ceiling the tier resolver uses, so
                # a mid-flight override cannot silently bypass the operator's cost
                # cap. Clamp via the shared helper when available.
                local _loki_override_effective="$_loki_override_alias"
                if type loki_apply_max_tier_clamp >/dev/null 2>&1; then
                    _loki_override_effective="$(loki_apply_max_tier_clamp "$_loki_override_alias" "$_loki_override_alias")"
                fi
                if [ "$_loki_override_effective" != "$_loki_override_alias" ]; then
                    tier_param="$_loki_override_effective"
                    log_warn "model override '$_loki_override_alias' exceeds LOKI_MAX_TIER=${LOKI_MAX_TIER}; clamped to $tier_param (applies this iteration)"
                    echo "=== Model override: $_loki_override_alias clamped to $tier_param by LOKI_MAX_TIER=${LOKI_MAX_TIER} (applies this iteration $ITERATION_COUNT) ===" | tee -a "$log_file" "$agent_log"
                else
                    tier_param="$_loki_override_effective"
                    log_info "model override: $tier_param (applies this iteration)"
                    echo "=== Model override: $tier_param (applies this iteration $ITERATION_COUNT) ===" | tee -a "$log_file" "$agent_log"
                fi
            elif [ -z "$(printf '%s' "$_loki_override_file" | tr -d '[:space:]')" ]; then
                : # empty file means no override; fall back to tier mapping
            else
                log_warn "Ignoring invalid model override '$_loki_override_file' (allowed: haiku, sonnet, opus, fable); using tier $tier_param"
            fi
        fi
        # fable unavailable, collapse to opus (final dispatch backstop). Claude
        # Fable 5 is not available at the Claude API ("use Opus 4.8"). Any path
        # that left tier_param="fable" (static fallback, override file, architect
        # opt-in) is collapsed to opus here, after all tier_param mutations and
        # before the claude argv is built (--model "$tier_param"). Keeps dispatch,
        # the cost quote, and the dashboard effective-model in agreement. Only for
        # the claude provider: codex/cline/aider map tier_param to their own
        # effort/model strings and have no fable equivalent (v7.39.1).
        if [ "${PROVIDER_NAME:-claude}" = "claude" ] && [ "$tier_param" = "fable" ]; then
            tier_param="opus"
        fi
        echo "=== RARV Phase: $rarv_phase, Tier: $CURRENT_TIER ($tier_param) ===" | tee -a "$log_file" "$agent_log"
        log_info "RARV Phase: $rarv_phase -> Tier: $CURRENT_TIER ($tier_param)"

        # Emit OTEL phase span (if OTEL is enabled)
        if [ -n "${LOKI_OTEL_ENDPOINT:-}" ]; then
            emit_event_pending "otel_span_start" \
                "span_name=rarv.phase.$rarv_phase" \
                "iteration=$ITERATION_COUNT" \
                "phase=$rarv_phase" \
                "tier=$CURRENT_TIER"
        fi

        set +e
        # Policy engine check (P0.5-2: blocks execution if policy denies)
        local policy_context="{\"provider\":\"${PROVIDER_NAME:-claude}\",\"iteration\":$ITERATION_COUNT,\"tier\":\"$CURRENT_TIER\"}"
        if ! check_policy "pre_execution" "$policy_context"; then
            log_error "Execution blocked by policy engine"
            save_state $retry "policy_blocked" 1
            track_iteration_complete "$ITERATION_COUNT" "1"
            continue
        fi

        # Audit: record CLI invocation
        audit_agent_action "cli_invoke" "Starting iteration $ITERATION_COUNT" "provider=${PROVIDER_NAME:-claude},tier=$CURRENT_TIER"

        # Provider-specific invocation with dynamic tier selection
        local exit_code=0
        # v7.5.12: Mark provider pipeline as active so SIGINT trap can kill it.
        LOKI_PROVIDER_ACTIVE=1
        # v7.7.31: authorize autonomous operation at the system-prompt tier so
        # the spawned agent does not read the user's global ~/.claude/CLAUDE.md,
        # judge it to conflict with the loki_system prompt, call AskUserQuestion,
        # and exit having done nothing. An appended system prompt outranks
        # CLAUDE.md memory (verified empirically). Default-on; opt out with
        # LOKI_AUTONOMY_OVERRIDE=off. Only added when the installed CLI supports
        # the flag and the override helper is in scope (sourced via the provider).
        # Build the claude flag list as an array. The base flags are always
        # present so the array is never empty (empty "${arr[@]}" under `set -u`
        # is an error on bash 3.2, the stock macOS shell). The autonomy override
        # is appended conditionally.
        local _loki_claude_argv=("--dangerously-skip-permissions" "--model" "$tier_param")
        if [ "${LOKI_AUTONOMY_OVERRIDE:-on}" != "off" ] \
           && type _loki_autonomy_override_text >/dev/null 2>&1 \
           && type loki_claude_flag_supported >/dev/null 2>&1 \
           && loki_claude_flag_supported "--append-system-prompt"; then
            _loki_claude_argv+=("--append-system-prompt" "$(_loki_autonomy_override_text)")
        fi
        # v7.8.0: explicit settings precedence. Pin the loaded settings sources
        # so Loki's invocation does not drift if Claude Code changes its implicit
        # default. Behavior-neutral (these are the standard sources). Gated +
        # falls back to the implicit default when unsupported. Opt out with
        # LOKI_SETTING_SOURCES=off.
        if ! loki_is_supervised_simple_web \
           && [ "${LOKI_SETTING_SOURCES:-on}" != "off" ] \
           && type loki_claude_flag_supported >/dev/null 2>&1 \
           && loki_claude_flag_supported "--setting-sources"; then
            _loki_claude_argv+=("--setting-sources" "user,project,local")
        fi
        if [ "${LOKI_HOST_GUARD:-0}" = "1" ]; then
            if [ -z "${LOKI_HOST_GUARD_SETTINGS_JSON:-}" ]; then
                log_error "Host command guard settings disappeared before provider invocation. Refusing to run Claude."
                return 1
            fi
            _loki_claude_argv+=("--settings" "$LOKI_HOST_GUARD_SETTINGS_JSON")
        fi
        if loki_is_supervised_simple_web && [ "${PROVIDER_NAME:-claude}" = "claude" ]; then
            if ! _loki_supervised_claude_isolation_ready; then
                log_error "Supervised simple-web requires isolated setting sources and Task-tool denial. Refusing to run without both."
                return 1
            fi
            _loki_claude_argv+=("--setting-sources" "" "--disallowedTools" "Task")
        fi
        # v7.8.0: stream partial assistant deltas so the dashboard renders the
        # agent's output in real time instead of only at message boundaries. The
        # stream-json parser below handles the partial event type additively and
        # ignores it if unrecognized. Gated + fallback. Opt out with
        # LOKI_PARTIAL_MESSAGES=off.
        if [ "${LOKI_PARTIAL_MESSAGES:-on}" != "off" ] \
           && type loki_claude_flag_supported >/dev/null 2>&1 \
           && loki_claude_flag_supported "--include-partial-messages"; then
            _loki_claude_argv+=("--include-partial-messages")
        fi
        # Session-continuity Phase 2 (GitHub #165): on the FIRST main-loop call of
        # a RESTARTED run (snapshot _LOKI_RESTARTED_RUN==1, latch
        # _LOKI_RESUME_CONSUMED==0) with LOKI_RESUME_SESSION=1, emit
        # `--resume <stored-uuid>` INSTEAD of the per-iteration --session-id stamp
        # (the two are mutually exclusive on one invocation). This reattaches the
        # prior Claude context once, then the latch flips so every later iteration
        # reverts to normal stateless behavior. Optional --fork-session
        # (LOKI_SESSION_FORK=1) writes the resumed turn to a new id, leaving the
        # parent transcript untouched. DEFAULT OFF: with no knobs neither --resume
        # nor --session-id is emitted (argv byte-identical to v7.34).
        local _loki_did_resume=0
        if [ "${_LOKI_RESTARTED_RUN:-0}" = "1" ] && [ "${_LOKI_RESUME_CONSUMED:-0}" = "0" ] \
           && type loki_resume_session_enabled >/dev/null 2>&1 \
           && loki_resume_session_enabled; then
            local _loki_resume_uuid
            _loki_resume_uuid="$(_loki_resume_target_uuid)"
            if [ -n "$_loki_resume_uuid" ]; then
                _loki_claude_argv+=("--resume" "$_loki_resume_uuid")
                if type loki_session_fork_enabled >/dev/null 2>&1 \
                   && loki_session_fork_enabled; then
                    _loki_claude_argv+=("--fork-session")
                fi
                _loki_did_resume=1
                _LOKI_RESUME_CONSUMED=1
                export _LOKI_RESUME_CONSUMED
                log_info "LOKI_RESUME_SESSION=1: resuming Claude session $_loki_resume_uuid (recovery resume, first call of restarted run)"
            fi
        fi
        # v7.34.0 Phase 1 (correlation-only): per-iteration --session-id. OPT-IN
        # via LOKI_SESSION_STAMP=1 (CONSERVATIVE DEFAULT is OFF so the default
        # argv stays byte-identical to v7.33 -- the UX-monotonicity requirement).
        # The id is a DISTINCT, deterministic UUIDv5 of "<run-id>:<iteration>",
        # never one pinned id across the run: a reused id would make claude RESUME
        # and accumulate transcript (Phase 2 continuity, out of scope). This keeps
        # each iteration a fresh stateless session while making its ~/.claude
        # JSONL name predictable for dashboard correlation. Gated on CLI support.
        # MUTUAL EXCLUSION: skip the stamp on the call that emitted --resume above
        # (claude rejects --session-id + --resume together).
        if [ "$_loki_did_resume" = "0" ] \
           && type loki_session_stamp_enabled >/dev/null 2>&1 \
           && loki_session_stamp_enabled; then
            local _loki_iter_session_uuid
            _loki_iter_session_uuid="$(_loki_claude_iteration_session_uuid "${LOKI_TRUST_RUN_ID:-}" "$ITERATION_COUNT")"
            [ -n "$_loki_iter_session_uuid" ] && _loki_claude_argv+=("--session-id" "$_loki_iter_session_uuid")
        fi
        # ---- Bash<->Bun invocation-flag convergence ledger (v7.25.0) ----------
        # The fixture corpus covers build_prompt/stats output, NOT this claude
        # argv, so drift here is invisible to parity tests. Keep this ledger
        # current. Live route today is BASH (bin/loki routes `start` -> bash).
        # The claude provider in loki-ts/src/runner/providers.ts is implemented
        # but is NOT reached for `start` (start is not ported to the Bun router;
        # the shim falls through to bash), so its flag set has zero live impact
        # today.
        # Bash argv (canonical, live): --dangerously-skip-permissions --model M
        #   [--append-system-prompt] [--setting-sources] [--include-partial-messages]
        #   [--session-id UUID (only when LOKI_SESSION_STAMP=1, v7.34.0)]
        #   [--effort] [--max-budget-usd] [--fallback-model] -p PROMPT
        #   --output-format stream-json --verbose
        # v7.34.0: --session-id is emitted ONLY on this MAIN loop, only under
        #   LOKI_SESSION_STAMP=1, as a per-iteration distinct UUIDv5; the DEFAULT
        #   argv (knob unset) is byte-identical to v7.33. Bun mirror lives in
        #   loki-ts/src/runner/providers.ts (sessionStampArgv).
        # Bun buildAutoFlags also emits: --exclude-dynamic-system-prompt-sections
        #   (cost-only), --mcp-config (bash gets MCP via --setting-sources +
        #   .mcp.json discovery; a how-difference, likely behavior-equivalent),
        #   --include-hook-events (bash handles hook events in its embedded
        #   stream parser; likely moot). These three are Bun-only and MUST be
        #   reconciled to a deliberately chosen canonical set BEFORE `start`
        #   flips to the Bun runner. They have zero live impact today.
        # v7.25.0: long-run resilience + cost flags, appended individually here
        # (NOT via _loki_build_claude_auto_flags, which would double the three
        # flags above). Each is gated on CLI support + an opt-out env var, same
        # pattern as above. These improve unattended/long-run execution:
        #   --effort           adaptive reasoning depth per RARV tier
        #   --max-budget-usd   per-call hard backstop (complements the
        #                      cumulative check_budget_limit PAUSE gate)
        #   --fallback-model   resilience to model overload/unavailability
        # The trust/verification gates stay deterministic; these only tune how
        # the provider is invoked, never whether work is judged complete.
        if [ "${LOKI_AUTO_EFFORT:-on}" != "off" ] \
           && type loki_effort_for_tier >/dev/null 2>&1 \
           && type loki_claude_flag_supported >/dev/null 2>&1 \
           && loki_claude_flag_supported "--effort"; then
            local _loki_effort
            if loki_is_supervised_simple_web; then
                _loki_effort="${LOKI_DRAFT_EFFORT:-medium}"
            else
                _loki_effort="$(loki_effort_for_tier "$CURRENT_TIER" "${DETECTED_COMPLEXITY:-${LOKI_COMPLEXITY:-standard}}")"
            fi
            [ -n "$_loki_effort" ] && _loki_claude_argv+=("--effort" "$_loki_effort")
        fi
        if [ "${LOKI_AUTO_BUDGET:-on}" != "off" ] \
           && type loki_remaining_budget >/dev/null 2>&1 \
           && type loki_claude_flag_supported >/dev/null 2>&1 \
           && loki_claude_flag_supported "--max-budget-usd"; then
            local _loki_rem_budget
            _loki_rem_budget="$(loki_remaining_budget)"
            [ -n "$_loki_rem_budget" ] && _loki_claude_argv+=("--max-budget-usd" "$_loki_rem_budget")
        fi
        if [ "${LOKI_AUTO_FALLBACK:-on}" != "off" ] \
           && type loki_fallback_for_primary >/dev/null 2>&1 \
           && type loki_claude_flag_supported >/dev/null 2>&1 \
           && loki_claude_flag_supported "--fallback-model"; then
            local _loki_fallback
            _loki_fallback="$(loki_fallback_for_primary "$tier_param")"
            [ -n "$_loki_fallback" ] && _loki_claude_argv+=("--fallback-model" "$_loki_fallback")
        fi
        case "${PROVIDER_NAME:-claude}" in
            claude)
                # Claude: Full features with stream-json output and agent tracking
                # Uses dynamic tier for model selection based on RARV phase
                # Pass tier + iteration to the embedded stream parser via the
                # environment. A bare `VAR=val cmd | parser` prefix applies ONLY
                # to `cmd` (claude) and does NOT cross the pipe to the parser
                # subprocess, so these must be exported into the shell env first.
                # LOKI_ITERATION lets the parser stamp the authoritative
                # result-cost file under the correct iteration index.
                export LOKI_CURRENT_MODEL="$tier_param"
                export LOKI_ITERATION="$ITERATION_COUNT"
                # caveman ACTIVATION (free-form): the main RARV dev loop is
                # free-form generation, so we ask caveman to compress its OUTPUT
                # tokens at the configured level. Inlined as a per-invocation env
                # prefix (NOT exported) so it applies ONLY to `claude` (and the
                # SessionStart hook it spawns inherits it) and never bleeds into
                # later parsed subcalls. Empty when caveman is unsupported /
                # disabled / the legacy completion-prose match is active, in which
                # case the invocation is byte-identical to before. Type-guarded so
                # an older runtime without the helper degrades cleanly.
                local _loki_cm_level=""
                if type loki_caveman_activate_env >/dev/null 2>&1; then
                    _loki_cm_level="$(loki_caveman_activate_env)"
                fi
                # Best-effort one-time bootstrap when activation is warranted but
                # caveman is not yet installed (idempotent, non-blocking, clean
                # degrade). The level stays usable next run even if this run is
                # uncompressed.
                if [ -n "$_loki_cm_level" ] && type loki_caveman_bootstrap >/dev/null 2>&1; then
                    loki_caveman_bootstrap || true
                fi
                # NOTE: an EMPTY CAVEMAN_DEFAULT_MODE is NOT inert -- caveman's
                # getDefaultMode() treats empty as unset and falls back to the
                # user's global default (often "full"). So when activation is not
                # warranted we must NOT set the var at all (the bare branch),
                # keeping the invocation byte-identical to pre-caveman behavior.
                local -a _loki_provider_pipe_status=()
                local _loki_provider_stage_rc=125
                local _loki_tee_stage_rc=125
                local _loki_parser_stage_rc=125
                if [ -n "$_loki_cm_level" ]; then
                CAVEMAN_DEFAULT_MODE="$_loki_cm_level" \
                LOKI_DEADLINE_IDLE_TIMEOUT="${LOKI_PROVIDER_IDLE_TIMEOUT:-0}" \
                _loki_with_deadline "${LOKI_PROVIDER_CALL_TIMEOUT:-0}" \
                claude "${_loki_claude_argv[@]}" -p "$prompt" \
            --output-format stream-json --verbose 2>&1
                else
                LOKI_DEADLINE_IDLE_TIMEOUT="${LOKI_PROVIDER_IDLE_TIMEOUT:-0}" \
                _loki_with_deadline "${LOKI_PROVIDER_CALL_TIMEOUT:-0}" \
                claude "${_loki_claude_argv[@]}" -p "$prompt" \
            --output-format stream-json --verbose 2>&1
                fi | \
            tee -a "$log_file" "$agent_log" "$iter_output" | \
            python3 -u -c '
import sys
import json
import os
from datetime import datetime, timezone

# ANSI colors
CYAN = "\033[0;36m"
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
MAGENTA = "\033[0;35m"
DIM = "\033[2m"
NC = "\033[0m"

# Get current model tier from environment (set by run.sh dynamic tier selection)
CURRENT_MODEL = os.environ.get("LOKI_CURRENT_MODEL", "sonnet")

# Agent tracking
AGENTS_FILE = ".loki/state/agents.json"
QUEUE_IN_PROGRESS = ".loki/queue/in-progress.json"
active_agents = {}  # tool_id -> agent_info
orchestrator_id = "orchestrator-main"
session_start = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")

def init_orchestrator():
    """Initialize the main orchestrator agent (always visible)."""
    active_agents[orchestrator_id] = {
        "agent_id": orchestrator_id,
        "tool_id": orchestrator_id,
        "agent_type": "orchestrator",
        "model": CURRENT_MODEL,
        "current_task": "Initializing...",
        "status": "active",
        "spawned_at": session_start,
        "tasks_completed": [],
        "tool_count": 0
    }
    save_agents()

def update_orchestrator_task(tool_name, description=""):
    """Update orchestrator current task based on tool usage."""
    if orchestrator_id in active_agents:
        active_agents[orchestrator_id]["tool_count"] = active_agents[orchestrator_id].get("tool_count", 0) + 1
        if description:
            active_agents[orchestrator_id]["current_task"] = f"{tool_name}: {description[:80]}"
        else:
            active_agents[orchestrator_id]["current_task"] = f"Using {tool_name}..."
        save_agents()

def load_agents():
    """Load existing agents from file."""
    try:
        if os.path.exists(AGENTS_FILE):
            with open(AGENTS_FILE, "r") as f:
                data = json.load(f)
                return {a.get("tool_id", a.get("agent_id")): a for a in data if isinstance(a, dict)}
    except:
        pass
    return {}

def save_agents():
    """Save agents to file for dashboard."""
    try:
        os.makedirs(os.path.dirname(AGENTS_FILE), exist_ok=True)
        agents_list = list(active_agents.values())
        with open(AGENTS_FILE, "w") as f:
            json.dump(agents_list, f, indent=2)
    except Exception as e:
        print(f"{YELLOW}[Agent save error: {e}]{NC}", file=sys.stderr)

def save_in_progress(tasks):
    """Save in-progress tasks to queue file."""
    try:
        os.makedirs(os.path.dirname(QUEUE_IN_PROGRESS), exist_ok=True)
        with open(QUEUE_IN_PROGRESS, "w") as f:
            json.dump(tasks, f, indent=2)
    except:
        pass

# Phase D (v7.5.22): hook-event emission.
# Mirror events/emit.sh::safe_append_event_jsonl semantics from inside
# python by holding an fcntl.flock on .loki/events.jsonl.lock for the
# duration of the append. Bash function is not callable from this
# embedded process; fcntl matches the flock(1) path one-to-one.
EVENTS_JSONL = ".loki/events.jsonl"
HOOK_EVENTS_ENABLED = os.environ.get("LOKI_HOOK_EVENTS", "on") != "off"

def append_hook_event(event_name, payload):
    """Append a claude_hook_<event_name> record to .loki/events.jsonl."""
    if not HOOK_EVENTS_ENABLED:
        return
    try:
        import fcntl
    except ImportError:
        fcntl = None
    try:
        events_dir = os.path.dirname(EVENTS_JSONL)
        if events_dir:
            os.makedirs(events_dir, exist_ok=True)
        record = {
            "type": "claude_hook_" + str(event_name).lower(),
            "source": "claude_cli",
            "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
            "payload": payload,
        }
        line = json.dumps(record, default=str)
        lock_path = EVENTS_JSONL + ".lock"
        if fcntl is not None:
            # flock path: serialize across processes.
            with open(lock_path, "a") as lf:
                try:
                    fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
                    with open(EVENTS_JSONL, "a") as ef:
                        ef.write(line + "\n")
                finally:
                    try:
                        fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
                    except Exception:
                        pass
        else:
            # No fcntl available (extremely rare on POSIX). Best-effort.
            with open(EVENTS_JSONL, "a") as ef:
                ef.write(line + "\n")
    except Exception as e:
        print(f"{YELLOW}[Hook event append error: {e}]{NC}", file=sys.stderr)

def process_stream():
    global active_agents
    active_agents = load_agents()

    # Always show the main orchestrator
    init_orchestrator()
    print(f"{MAGENTA}[Orchestrator Active]{NC} Main agent started", flush=True)

    # v7.8.0: track whether the current assistant message text was already
    # streamed live via --include-partial-messages stream_event deltas, so the
    # final assistant block does not re-print it. Reset after each assistant
    # message. Stays False when partial messages are off (no stream_event lines).
    streamed_text_blocks = False

    # Per-turn usage samples for the context-growth record (L1). Appended on
    # every assistant message; written once at the result event. Bounded below
    # so a pathological run cannot grow this without limit.
    _turn_usage = []

    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            data = json.loads(line)
            msg_type = data.get("type", "")

            # v7.8.0: --include-partial-messages emits incremental stream_event
            # records (content_block_delta) BEFORE the final assistant message.
            # Render the delta text live so the dashboard/terminal shows progress
            # in real time, and remember that we streamed it so the final
            # assistant block does not print the same text again (double-print).
            # Purely additive: if partial messages are off, no stream_event lines
            # arrive and this branch never fires.
            if msg_type == "stream_event":
                ev = data.get("event", {})
                ev_type = ev.get("type")
                if ev_type == "message_start":
                    # New message beginning: reset the streamed-text tracker so
                    # the deltas of this message are tracked independently.
                    streamed_text_blocks = False
                elif ev_type == "content_block_delta":
                    delta = ev.get("delta", {})
                    if delta.get("type") == "text_delta":
                        dtext = delta.get("text", "")
                        if dtext:
                            print(dtext, end="", flush=True)
                            streamed_text_blocks = True
                continue

            if msg_type == "assistant":
                # Extract and print assistant text
                message = data.get("message", {})
                content = message.get("content", [])

                # PER-TURN CONTEXT GROWTH (read-only instrumentation, L1).
                #
                # WHY. One measured iteration re-sent 10,651,759 cached-read
                # tokens to produce 34,729 output tokens -- a 307:1 ratio, in a
                # SINGLE provider call (one iteration_start, one
                # result-cost-1.json, so cross-iteration reuse is ruled out).
                # That call was 100% of measured stage time.
                #
                # The provider cache already saved us 10x ($31.96 -> $3.20 of a
                # $4.74 iteration). We are not missing a cache; the ORDER being
                # discounted is enormous, and cached reads are still 67% of the
                # bill. Those tokens are prefill the model must process serially
                # before emitting a character, so this is the only measured lever
                # that touches BOTH cost and the 744s.
                #
                # "the tool loop re-accumulates history" is INFERRED from the
                # ratio, not observed. Trimming context on an inference is how
                # you ship an agent that forgets what it already tried and redoes
                # the work -- raising iterations and costing more than it saves.
                # So this MEASURES per turn and trims nothing. The cut is a
                # separate decision, gated on iterations-to-done rather than on
                # a token count.
                try:
                    _tu = (message.get("usage") or {})
                    _tcr = _tu.get("cache_read_input_tokens")
                    if isinstance(_tcr, int) and _tcr >= 0:
                        _turn_usage.append({
                            "turn": len(_turn_usage) + 1,
                            "cache_read_tokens": _tcr,
                            "input_tokens": _tu.get("input_tokens", 0) or 0,
                            "output_tokens": _tu.get("output_tokens", 0) or 0,
                            "cache_creation_tokens":
                                _tu.get("cache_creation_input_tokens", 0) or 0,
                        })
                except Exception:
                    pass
                for item in content:
                    if item.get("type") == "text":
                        text = item.get("text", "")
                        # Skip if we already streamed this text via stream_event
                        # deltas (avoids printing the full message a second time).
                        if text and not streamed_text_blocks:
                            print(text, end="", flush=True)
                    elif item.get("type") == "tool_use":
                        tool = item.get("name", "unknown")
                        tool_id = item.get("id", "")
                        tool_input = item.get("input", {})

                        # Extract description based on tool type
                        tool_desc = ""
                        if tool == "Read":
                            tool_desc = tool_input.get("file_path", "")
                        elif tool == "Edit" or tool == "Write":
                            tool_desc = tool_input.get("file_path", "")
                        elif tool == "Bash":
                            tool_desc = tool_input.get("description", tool_input.get("command", "")[:60])
                        elif tool == "Grep":
                            # This Python block runs inside bash `python3 -u -c '...'`,
                            # wrapped in a bash single-quoted string. A single-quoted
                            # Python literal here would close bash SQ mid-code and
                            # Python would receive a bare identifier instead of the
                            # "pattern" string, crashing with NameError on every Grep
                            # tool call. Use double quotes + concatenation only.
                            tool_desc = "pattern: " + tool_input.get("pattern", "")
                        elif tool == "Glob":
                            tool_desc = tool_input.get("pattern", "")

                        # Update orchestrator with current tool activity
                        update_orchestrator_task(tool, tool_desc)

                        # Track Task tool calls (agent spawning)
                        if tool == "Task":
                            agent_type = tool_input.get("subagent_type", "general-purpose")
                            description = tool_input.get("description", "")
                            model = tool_input.get("model", "sonnet")

                            agent_info = {
                                "agent_id": f"agent-{tool_id[:8]}",
                                "tool_id": tool_id,
                                "agent_type": agent_type,
                                "model": model,
                                "current_task": description,
                                "status": "active",
                                "spawned_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
                                "tasks_completed": []
                            }
                            active_agents[tool_id] = agent_info
                            save_agents()
                            print(f"\n{MAGENTA}[Agent Spawned: {agent_type}]{NC} {description}", flush=True)

                        # Track TodoWrite for task updates.
                        # v7.4.17: enrich the entry so the dashboard task-detail
                        # modal has more than a one-liner. TodoWrite items are
                        # internal LLM scratch (not PRD-derived work) so they
                        # do not have acceptance_criteria or user stories, but
                        # we surface the activeForm and a source tag.
                        # Note: this whole block is inside a python3 -u -c
                        # single-quoted shell string -- avoid apostrophes.
                        elif tool == "TodoWrite":
                            todos = tool_input.get("todos", [])
                            in_progress = [t for t in todos if t.get("status") == "in_progress"]
                            enriched = []
                            for i, t in enumerate(in_progress):
                                content = t.get("content", "")
                                active_form = t.get("activeForm", "") or content
                                enriched.append({
                                    "id": f"todo-{i}",
                                    "type": "todo",
                                    "title": content,
                                    "description": (
                                        "Internal task tracked by the agent TodoWrite tool. "
                                        "This is LLM scratch, not a PRD-derived work item, so "
                                        "it has no acceptance criteria or user story. "
                                        "Active form: " + active_form
                                    ),
                                    "source": "claude_code_todowrite",
                                    "priority": "medium",
                                    "payload": {"action": content, "activeForm": active_form},
                                })
                            save_in_progress(enriched)
                            print(f"\n{CYAN}[Tool: {tool}]{NC} {len(todos)} items", flush=True)

                        else:
                            print(f"\n{CYAN}[Tool: {tool}]{NC}", flush=True)

            elif msg_type == "user":
                # Tool results - check for agent completion
                content = data.get("message", {}).get("content", [])
                for item in content:
                    if item.get("type") == "tool_result":
                        tool_id = item.get("tool_use_id", "")

                        # Mark agent as completed if it was a Task
                        if tool_id in active_agents:
                            active_agents[tool_id]["status"] = "completed"
                            active_agents[tool_id]["completed_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
                            save_agents()
                            print(f"{DIM}[Agent Complete]{NC} ", end="", flush=True)
                        else:
                            print(f"{DIM}[Result]{NC} ", end="", flush=True)

            elif msg_type == "hook_event":
                # Phase D (v7.5.22): forward Claude hook lifecycle events
                # into .loki/events.jsonl as claude_hook_<eventname>.
                # Schema not fully specified upstream; probe common field
                # names for the event identifier and lowercase it.
                event_name = (
                    data.get("hook_event")
                    or data.get("event")
                    or data.get("name")
                    or data.get("hook")
                    or "unknown"
                )
                append_hook_event(event_name, data)

            elif msg_type == "result":
                # Session complete - mark all agents as completed
                completed_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
                for agent_id in active_agents:
                    if active_agents[agent_id].get("status") == "active":
                        active_agents[agent_id]["status"] = "completed"
                        active_agents[agent_id]["completed_at"] = completed_at
                        active_agents[agent_id]["current_task"] = "Session complete"

                # Add session stats to orchestrator
                if orchestrator_id in active_agents:
                    tool_count = active_agents[orchestrator_id].get("tool_count", 0)
                    active_agents[orchestrator_id]["tasks_completed"].append(f"{tool_count} tools used")

                save_agents()

                # Authoritative cost capture (path/slug/symlink-independent).
                # Claude'"'"'s result message carries its own total_cost_usd plus a
                # full usage object. The context-tracker session-file path is
                # brittle (slug derivation must guess Claude'"'"'s naming), so this
                # stamps the authoritative number to a per-iteration file that
                # the efficiency writer prefers. Best-effort: a malformed or
                # missing field must never break the iteration loop.
                try:
                    _iter = os.environ.get("LOKI_ITERATION", "0")
                    _u = data.get("usage", {}) or {}
                    _rec = {
                        "total_cost_usd": data.get("total_cost_usd"),
                        "input_tokens": _u.get("input_tokens", 0),
                        "output_tokens": _u.get("output_tokens", 0),
                        "cache_read_tokens": _u.get("cache_read_input_tokens", 0),
                        "cache_creation_tokens": _u.get("cache_creation_input_tokens", 0),
                    }
                    # CONTEXT-GROWTH RECORD (L1). Written whenever turns were
                    # observed, independently of whether cost was reported --
                    # the growth shape is the finding, and tying it to
                    # total_cost_usd would lose it on every provider that does
                    # not report dollars (codex reports tokens, never cost).
                    if _turn_usage:
                        try:
                            os.makedirs(".loki/metrics", exist_ok=True)
                            _first = _turn_usage[0]["cache_read_tokens"]
                            _last = _turn_usage[-1]["cache_read_tokens"]
                            _growth = {
                                "iteration": _iter,
                                "turns": len(_turn_usage),
                                "first_turn_cache_read": _first,
                                "last_turn_cache_read": _last,
                                # The headline: how much bigger the context got
                                # between the first and last turn of ONE call.
                                "growth_factor": (round(_last / _first, 2)
                                                  if _first > 0 else None),
                                "total_cache_read": sum(
                                    t["cache_read_tokens"] for t in _turn_usage),
                                "total_output": sum(
                                    t["output_tokens"] for t in _turn_usage),
                                # Bounded sample: the shape is visible in the
                                # first and last few turns, and an unbounded
                                # array would make this file grow with the run.
                                "sample": (_turn_usage[:5] + _turn_usage[-5:]
                                           if len(_turn_usage) > 10
                                           else _turn_usage),
                            }
                            _gp = ".loki/metrics/context-growth-" + str(_iter) + ".json"
                            _gt = _gp + ".tmp"
                            with open(_gt, "w") as _gf:
                                json.dump(_growth, _gf)
                            os.replace(_gt, _gp)
                        except Exception:
                            pass

                    if _rec["total_cost_usd"] is not None:
                        os.makedirs(".loki/metrics", exist_ok=True)
                        _p = ".loki/metrics/result-cost-" + str(_iter) + ".json"
                        _tmp = _p + ".tmp"
                        with open(_tmp, "w") as _f:
                            json.dump(_rec, _f)
                        os.replace(_tmp, _p)
                except Exception:
                    pass

                print(f"\n{GREEN}[Session complete]{NC}", flush=True)
                is_error = data.get("is_error", False)
                sys.exit(1 if is_error else 0)

        except json.JSONDecodeError:
            # Not JSON, print as-is
            print(line, flush=True)
        except Exception as e:
            print(f"{YELLOW}[Parse error: {e}]{NC}", file=sys.stderr)

if __name__ == "__main__":
    try:
        process_stream()
    except KeyboardInterrupt:
        sys.exit(130)
    except BrokenPipeError:
        sys.exit(0)
'
                _loki_provider_pipe_status=("${PIPESTATUS[@]}")
                _loki_provider_stage_rc="${_loki_provider_pipe_status[0]:-125}"
                _loki_tee_stage_rc="${_loki_provider_pipe_status[1]:-125}"
                _loki_parser_stage_rc="${_loki_provider_pipe_status[2]:-125}"
                exit_code="$(_loki_provider_pipeline_exit_code \
                    "$_loki_provider_stage_rc" "$_loki_tee_stage_rc" \
                    "$_loki_parser_stage_rc")"
                ;;

            codex)
                # Codex: Degraded mode - no stream-json, no agent tracking
                # Uses positional prompt after exec subcommand
                # Note: Effort is set via env var, not CLI flag
                # Uses dynamic tier from RARV phase (tier_param already set above)
                local _loki_codex_effort="$tier_param"
                if loki_is_supervised_simple_web; then
                    _loki_codex_effort="${LOKI_DRAFT_EFFORT:-medium}"
                fi
                local -a _loki_codex_pipe_status=()
                LOKI_CODEX_REASONING_EFFORT="$_loki_codex_effort" \
                CODEX_MODEL_REASONING_EFFORT="$_loki_codex_effort" \
                # Stamp BEFORE the call: the usage reader bounds its rollout
                # search by mtime, so a stale session from an earlier iteration
                # cannot be attributed to this one. Attributing the wrong
                # session is worse than reporting nothing -- it looks like data.
                _loki_codex_usage_since="$(date +%s 2>/dev/null || echo 0)"
                LOKI_DEADLINE_IDLE_TIMEOUT="${LOKI_PROVIDER_IDLE_TIMEOUT:-0}" \
                _loki_with_deadline "${LOKI_PROVIDER_CALL_TIMEOUT:-0}" \
                codex exec --sandbox workspace-write --skip-git-repo-check \
                    "$prompt" 2>&1 | tee -a "$log_file" "$agent_log" "$iter_output"
                _loki_codex_pipe_status=("${PIPESTATUS[@]}")
                exit_code="$(_loki_provider_pipeline_exit_code \
                    "${_loki_codex_pipe_status[0]:-125}" \
                    "${_loki_codex_pipe_status[1]:-125}" 0)"
                # W1: recover token usage from the codex session rollout.
                #
                # Measured on a real FireLater run: EVERY efficiency record had
                # input_tokens=0, output_tokens=0, cost_usd=0. Not just cost --
                # we recorded nothing, because _read_iteration_cost looks for a
                # result-cost file or context tracker and codex writes neither.
                # A zero is a claim that the iteration was free.
                #
                # codex reports usage only under `codex exec --json`, and the
                # dispatch above pipes stdout through tee into logs the runner
                # parses for completion signals -- switching to JSONL would
                # change the format every one of those readers depends on. The
                # session rollout carries the same total_token_usage, so this
                # reads it as a side channel with zero risk to the pipeline.
                #
                # Best-effort by construction: on any failure the helper prints
                # nothing and exits non-zero, and no result-cost file is
                # written, so cost stays UNKNOWN rather than a fabricated 0.
                if [ -n "${_loki_codex_usage_since:-}" ] \
                   && [ -f "${SCRIPT_DIR}/lib/codex-usage.py" ]; then
                    _cx_usage="$(LOKI_CODEX_RESOLVED_MODEL="${LOKI_CURRENT_MODEL:-${PROVIDER_MODEL_DEVELOPMENT:-}}" \
                        python3 "${SCRIPT_DIR}/lib/codex-usage.py" \
                        "$_loki_codex_usage_since" 2>/dev/null)" || _cx_usage=""
                    if [ -n "$_cx_usage" ]; then
                        set -- $_cx_usage
                        mkdir -p "${TARGET_DIR:-.}/.loki/metrics" 2>/dev/null || true
                        # total_cost_usd is emitted ONLY when the model was
                        # priced. Omitting the key leaves cost UNKNOWN; writing
                        # 0 would claim the iteration was free.
                        if [ -n "${5:-}" ]; then
                            printf '{"input_tokens":%s,"output_tokens":%s,"cache_read_tokens":%s,"cache_creation_tokens":%s,"total_cost_usd":%s}\n' \
                                "${1:-0}" "${2:-0}" "${3:-0}" "${4:-0}" "$5" \
                                > "${TARGET_DIR:-.}/.loki/metrics/result-cost-${ITERATION_COUNT}.json" 2>/dev/null || true
                        else
                            printf '{"input_tokens":%s,"output_tokens":%s,"cache_read_tokens":%s,"cache_creation_tokens":%s}\n' \
                                "${1:-0}" "${2:-0}" "${3:-0}" "${4:-0}" \
                                > "${TARGET_DIR:-.}/.loki/metrics/result-cost-${ITERATION_COUNT}.json" 2>/dev/null || true
                        fi
                        log_info "Codex usage: ${1:-0} in (+${3:-0} cached), ${2:-0} out, cost=${5:-unknown}"
                    else
                        log_warn "Codex token usage unavailable for iteration ${ITERATION_COUNT}; cost will read UNKNOWN, not zero."
                    fi
                fi
                ;;

            cline)
                # Cline: Tier 2 - near-full mode with subagents and MCP
                echo "[loki] Cline model: ${LOKI_CLINE_MODEL:-default}, tier: $tier_param" >> "$log_file"
                echo "[loki] Cline model: ${LOKI_CLINE_MODEL:-default}, tier: $tier_param" >> "$agent_log"
                { invoke_cline "$prompt" 2>&1 | tee -a "$log_file" "$agent_log" "$iter_output"; \
                } && exit_code=0 || exit_code=$?
                ;;
            aider)
                # Aider: Tier 3 - degraded mode, 18+ providers
                echo "[loki] Aider model: ${AIDER_DEFAULT_MODEL:-${LOKI_AIDER_MODEL:-openrouter/deepseek/deepseek-v3.2}}, tier: $tier_param" >> "$log_file"
                echo "[loki] Aider model: ${AIDER_DEFAULT_MODEL:-${LOKI_AIDER_MODEL:-openrouter/deepseek/deepseek-v3.2}}, tier: $tier_param" >> "$agent_log"
                { invoke_aider "$prompt" 2>&1 | tee -a "$log_file" "$agent_log" "$iter_output"; \
                } && exit_code=0 || exit_code=$?
                ;;

            *)
                log_error "Unknown provider: ${PROVIDER_NAME:-unknown}"
                local exit_code=1
                ;;
        esac
        # v7.5.12: Provider invocation finished (or was killed by trap).
        LOKI_PROVIDER_ACTIVE=0

        echo ""
        echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
        echo ""

        # Log end time
        echo "=== Session ended at $(date) with exit code $exit_code ===" >> "$log_file"

        local end_time=$(date +%s)
        local duration=$((end_time - start_time))

        log_info "${PROVIDER_DISPLAY_NAME:-Claude} exited with code $exit_code after ${duration}s"

        # The provider call is the largest single bucket in any iteration and was
        # the one the founder could not see. start_time already exists, so this
        # costs zero extra subprocesses -- we pass the existing epoch through.
        emit_stage_complete "agent" "$([ "$exit_code" -eq 0 ] 2>/dev/null && echo pass || echo fail)" "$start_time"

        # LLM DECISION RECORD (autonomy/lib/decision_record.py).
        #
        # WHY HERE. This is the single point where every provider arm converges
        # after dispatch: claude, codex, cline and aider all land here with
        # $tier_param (the model actually dispatched), $exit_code and $duration
        # in scope. Recording per-arm would be four call sites that drift.
        #
        # WHY tier_param AND NOT LOKI_CURRENT_MODEL. Only the claude arm exports
        # LOKI_CURRENT_MODEL (line ~22214); on a codex/cline/aider iteration that
        # variable is either unset or a STALE value left by an earlier claude
        # iteration after a failover. tier_param is the same string the claude
        # arm exports, and it is correct on every arm. It is read AFTER every
        # mutation (opus-pin force, LOKI_MAX_TIER clamp, mid-flight override,
        # fable collapse), so it is the model that ran, not the tier alias.
        #
        # WHAT IS DELIBERATELY OMITTED. temperature: this runtime never sets one
        # on any provider (claude dispatch passes --model/--effort, never a
        # temperature), so writing a value would be inventing the exact field
        # whose whole purpose is making config drift falsifiable. The module
        # treats an absent field as absent; a guessed 0.0 would be a lie that
        # reads as a measurement. confidence: self-reported and not available at
        # this seam. Tokens come from the authoritative per-iteration result-cost
        # file when the provider wrote one, and are omitted rather than zeroed
        # when it did not (a zero claims the call was free).
        #
        # NON-FATAL AND BACKGROUNDED: a diagnostic must never be able to break
        # the iteration it is diagnosing, and this is a python3 spawn on the
        # critical path of the loop's largest stage.
        if [ -n "${tier_param:-}" ] && [ -f "${SCRIPT_DIR:-}/lib/decision_record.py" ]; then
            local _dr_args=(
                "--model_id=$tier_param"
                "--provider=${PROVIDER_NAME:-claude}"
                "--stage=iteration_${ITERATION_COUNT:-0}_${rarv_phase:-unknown}"
                "--outcome=$([ "$exit_code" -eq 0 ] 2>/dev/null && echo ok || echo error)"
                "--duration_ms=$((duration * 1000))"
            )
            # Correlation ids only when genuinely set: an empty run_id written as
            # "" is indistinguishable from a real one in a later diff, and the
            # module records whatever an allowlisted field carries.
            [ -n "${LOKI_TRUST_RUN_ID:-}" ] && _dr_args+=("--run_id=$LOKI_TRUST_RUN_ID") || true
            [ -n "${LOKI_SESSION_ID:-}" ] && _dr_args+=("--session_id=$LOKI_SESSION_ID") || true
            local _dr_cost="${TARGET_DIR:-.}/.loki/metrics/result-cost-${ITERATION_COUNT:-0}.json"
            if [ -s "$_dr_cost" ]; then
                # Read into named locals, NOT `set --`: this runs in the middle of
                # run_autonomous, and clobbering the function's positional
                # parameters to parse a diagnostic is how a metrics read turns
                # into a control-flow bug.
                local _dr_in="" _dr_out=""
                read -r _dr_in _dr_out <<EOF
$(python3 -c 'import json,sys
d = json.load(open(sys.argv[1]))
# Print BOTH or neither: a half-record invites a reader to treat a missing
# output count as zero output, which reads as "the model produced nothing".
i, o = d.get("input_tokens"), d.get("output_tokens")
if isinstance(i, int) and isinstance(o, int):
    print(i, o)' "$_dr_cost" 2>/dev/null)
EOF
                case "${_dr_in}${_dr_out}" in
                    ''|*[!0-9]*) ;;   # unparseable -> omit rather than fabricate
                    *) _dr_args+=("--tokens_in=$_dr_in" "--tokens_out=$_dr_out") ;;
                esac
            fi
            ( LOKI_DIR="${TARGET_DIR:-.}/.loki" \
              python3 "${SCRIPT_DIR}/lib/decision_record.py" record "${_dr_args[@]}" \
              >/dev/null 2>&1 </dev/null & ) 2>/dev/null || true
        fi

        # AGENT PROMPT SIZE. The call this brackets is 93% of a run's wall clock
        # (1814s of 1941s measured), and its INPUT was never measured -- every
        # reviewer logs its prompt bytes, the dominant call logged nothing.
        #
        # Prompt size is the input side of that 93% and one of the few levers we
        # actually control: we cannot make the provider faster, but we can send
        # it less. Without the number, "the prompt got bigger" is invisible
        # until it shows up as latency and cost with no attributable cause --
        # the same gap W1 closed for tokens.
        #
        # Costs one `wc -c` on a string already in memory: no subprocess for the
        # provider, no extra file read. Emitted on the existing event channel so
        # measure-run.sh and the receipt pick it up with no new plumbing.
        if [ -n "${prompt:-}" ]; then
            local _agent_prompt_bytes
            _agent_prompt_bytes=$(printf '%s' "$prompt" | wc -c 2>/dev/null | tr -d ' ')
            case "$_agent_prompt_bytes" in
                ''|*[!0-9]*) ;;   # unmeasurable -> emit nothing, never a zero
                *)
                    emit_event_json "agent_prompt" \
                        "bytes=$_agent_prompt_bytes" \
                        "iteration=${ITERATION_COUNT:-0}" \
                        "duration_s=$duration" 2>/dev/null || true
                    ;;
            esac
        fi

        # TIME TO FIRST ARTIFACT. The companion to seconds_to_first_preview, for
        # the case that has no preview at all.
        #
        # first-preview.json only fires for a previewable app. A scoped GitHub
        # issue fix -- the shape the founder measured at 21 minutes -- produces
        # no preview, so NOTHING marks the moment the run first changed code.
        # The user sees an idle terminal until the whole iteration ends, which
        # is why the felt time is worse than the measured time even when the
        # measured time is competitive. Replit shows something at ~2 minutes;
        # we showed nothing until the end.
        #
        # Write-once per run, and best-effort: a failure here must never affect
        # the iteration. Measured against the ITERATION start, not the run
        # start, because that is the interval the user is actually staring at.
        if [ -z "${_LOKI_FIRST_ARTIFACT_DONE:-}" ]; then
            local _fa_file="${TARGET_DIR:-.}/.loki/state/first-artifact.json"
            if [ ! -f "$_fa_file" ]; then
                local _fa_changed
                _fa_changed="$(cd "${TARGET_DIR:-.}" 2>/dev/null \
                    && git status --porcelain 2>/dev/null | head -1)"
                if [ -n "$_fa_changed" ]; then
                    mkdir -p "$(dirname "$_fa_file")" 2>/dev/null || true
                    # Atomic: a partial read of this file must never look valid.
                    local _fa_tmp="${_fa_file}.$$"
                    printf '{"seconds_to_first_artifact":%s,"iteration":%s}\n' \
                        "$((end_time - start_time))" "${ITERATION_COUNT:-0}" \
                        > "$_fa_tmp" 2>/dev/null \
                        && mv -f "$_fa_tmp" "$_fa_file" 2>/dev/null \
                        && log_info "First code change after $((end_time - start_time))s"
                    rm -f "$_fa_tmp" 2>/dev/null || true
                    _LOKI_FIRST_ARTIFACT_DONE=1
                fi
            else
                _LOKI_FIRST_ARTIFACT_DONE=1
            fi
        fi

        # v7.5.12 Gap A: Distinguish signal-induced exits (130/143/137) from clean failure.
        # Without this, post-iteration logic may quietly proceed past a SIGINT/SIGTERM,
        # leaving stale state and confusing the next iteration. Any non-zero exit is a
        # failure, but signal exits warrant a louder log line for forensic clarity.
        case "$exit_code" in
            130)
                log_warn "Provider terminated by SIGINT (exit 130) -- treating as user interrupt"
                emit_event_pending "provider_interrupted" "signal=SIGINT" "exit_code=130" 2>/dev/null || true
                ;;
            143)
                log_warn "Provider terminated by SIGTERM (exit 143) -- treating as forced shutdown"
                emit_event_pending "provider_interrupted" "signal=SIGTERM" "exit_code=143" 2>/dev/null || true
                ;;
            137)
                log_warn "Provider killed by SIGKILL (exit 137) -- treating as forced shutdown"
                emit_event_pending "provider_interrupted" "signal=SIGKILL" "exit_code=137" 2>/dev/null || true
                ;;
        esac

        # BUG-EC-013: Detect empty provider output (0 bytes = no work done)
        # T2.5: track this distinct cause so the failure path can classify the
        # durable LAST_ERROR record as provider_empty_output specifically.
        local _empty_output=0
        if [ -f "$iter_output" ] && [ ! -s "$iter_output" ] && [ $exit_code -eq 0 ]; then
            log_warn "Provider returned empty output (0 bytes) despite exit code 0 -- treating as error"
            exit_code=1
            _empty_output=1
        fi

        save_state $retry "exited" $exit_code

        # Auto-track iteration completion (for dashboard task queue)
        track_iteration_complete "$ITERATION_COUNT" "$exit_code"
        if loki_is_supervised_simple_web \
           && [ "$exit_code" -eq 124 ] \
           && _loki_workspace_changed_since_iteration "${_LOKI_ITER_START_SHA:-}"; then
            local provider_deadline_reason="provider_timeout"
            if grep -q '"reason":"idle_timeout"' "$iter_output" 2>/dev/null; then
                provider_deadline_reason="provider_idle_timeout"
            elif grep -q '"reason":"hard_timeout"' "$iter_output" 2>/dev/null; then
                provider_deadline_reason="provider_hard_timeout"
            fi
            log_warn "Provider deadline reached after useful source changes; starting the repair attempt without reviewing known-incomplete work."
            _loki_write_last_error "$ITERATION_COUNT" "build_timeout" \
                "The provider reached ${provider_deadline_reason}; resume the partial implementation and complete tests and verification." || true
            mkdir -p "${TARGET_DIR:-.}/.loki/quality" 2>/dev/null || true
            printf '%s,\n' "$provider_deadline_reason" > \
                "${TARGET_DIR:-.}/.loki/quality/gate-failures.txt"
            save_state "$retry" "provider_deadline_partial_mutation" "$exit_code"
            emit_event_pending "provider_deadline_partial_mutation" \
                "exit_code=$exit_code" "reason=$provider_deadline_reason" 2>/dev/null || true
            create_checkpoint \
                "iteration-${ITERATION_COUNT} provider deadline partial" \
                "iteration-${ITERATION_COUNT}-provider-deadline"
            rm -f "$iter_output" 2>/dev/null
            ((retry++))
            continue
        fi
        if loki_is_supervised_simple_web \
           && [ "$exit_code" -ne 0 ] \
           && ! _loki_workspace_changed_since_iteration "${_LOKI_ITER_START_SHA:-}"; then
            local provider_failure_state="provider_failure_no_mutation"
            if [ "$exit_code" -eq 124 ]; then
                provider_failure_state="provider_deadline_no_mutation"
            fi
            log_error "Provider failed without a workspace mutation; skipping downstream gates and an identical retry."
            save_state "$retry" "$provider_failure_state" "$exit_code"
            emit_event_pending "provider_failed_no_mutation" \
                "exit_code=$exit_code" "reason=$provider_failure_state" 2>/dev/null || true
            rm -f "$iter_output" 2>/dev/null
            return 1
        fi
        # v7.8.1: record the codebase signature after a clean no-PRD iteration
        # that has a generated PRD, so the next no-PRD run can decide reuse vs
        # update. Best-effort, never fails the iteration.
        persist_prd_signature_if_present "$exit_code"

        # Sentrux architectural-drift gate diff + finding emission (opt-in, v7.5.15).
        _loki_sentrux_iteration_end "$ITERATION_COUNT" "${TARGET_DIR:-.}"

        # End OTEL phase span (if OTEL is enabled)
        if [ -n "${LOKI_OTEL_ENDPOINT:-}" ]; then
            emit_event_pending "otel_span_end" \
                "span_name=rarv.phase.$rarv_phase" \
                "status=$([[ $exit_code -eq 0 ]] && echo ok || echo error)"
        fi

        # Crash capture (Phase 0: local-only, best-effort, never blocks).
        # Conservative: only on a genuine non-zero failure exit. Signal-induced
        # exits (130 SIGINT / 143 SIGTERM / 137 SIGKILL) are user/operator
        # interrupts, not crashes, so we skip them. Known conservatism tradeoff:
        # this fires once per iteration on any nonzero exit, so a long, repeatedly
        # failing run can accumulate multiple local reports under .loki/crash/.
        if [ "$exit_code" -ne 0 ] 2>/dev/null && \
           [ "$exit_code" -ne 130 ] && [ "$exit_code" -ne 143 ] && [ "$exit_code" -ne 137 ] && \
           type loki_crash_capture &>/dev/null; then
            loki_crash_capture \
                "IterationError" \
                "provider exited non-zero on iteration ${ITERATION_COUNT:-?}" \
                "$([ -f "$iter_output" ] && tail -c 16384 "$iter_output" 2>/dev/null || true)" \
                "${rarv_phase:-iteration}" \
                "$exit_code"
        fi

        # PRD Checklist verification on interval (v5.44.0)
        if type checklist_should_verify &>/dev/null && checklist_should_verify; then
            checklist_verify
        fi

        # App Runner: init after first successful iteration (v5.45.0)
        if [ "${APP_RUNNER_INITIALIZED:-}" != "true" ] && [ $exit_code -eq 0 ] && \
           [ "${LOKI_APP_RUNNER:-true}" = "true" ] && type app_runner_init &>/dev/null; then
            if app_runner_init; then
                # F49: sandbox the generated app's HOME so its in-build run
                # cannot write into the user's real home directory.
                _loki_with_app_sandbox app_runner_start || log_warn "App runner: failed to start application"
                APP_RUNNER_INITIALIZED=true
            fi
        fi

        log_step "Post-iteration: running inter-iteration checks..."

        # App Runner: restart on code changes (v5.45.0)
        if [ "${APP_RUNNER_INITIALIZED:-}" = "true" ] && type app_runner_should_restart &>/dev/null; then
            if app_runner_should_restart; then
                # F49: re-launch under the same isolated HOME as the initial start.
                _loki_with_app_sandbox app_runner_restart || log_warn "App runner: failed to restart application"
            fi
        fi

        # App Runner: watchdog check (v5.45.0)
        if [ "${APP_RUNNER_INITIALIZED:-}" = "true" ] && type app_runner_watchdog &>/dev/null; then
            app_runner_watchdog
        fi

        # Playwright smoke test on interval (v5.46.0)
        if type playwright_verify_should_run &>/dev/null && playwright_verify_should_run; then
            if [ -f ".loki/app-runner/state.json" ]; then
                local app_url
                app_url=$(python3 -c "import json; d=json.load(open('.loki/app-runner/state.json')); print(d.get('url','') if d.get('status')=='running' else '')" 2>/dev/null || true)
                if [ -n "$app_url" ]; then
                    playwright_verify_app "$app_url" || true
                    # Proof-of-Function dynamic half: drive create->reload->assert
                    # (persistence) and logged-out->protected (auth), writing
                    # .loki/verification/functional-proof.json for the council
                    # evidence gate. Serveable-only (we are inside the state.json
                    # running check) and interval-gated (same should_run window).
                    # || true so it never fails the iteration; the gate is what
                    # turns proven:false into a BLOCK.
                    if type playwright_prove_functional &>/dev/null; then
                        playwright_prove_functional "$app_url" || true
                    fi
                fi
            fi
        fi

        # App Runner: check for dashboard control signals (v5.45.0)
        if [ "${APP_RUNNER_INITIALIZED:-}" = "true" ]; then
            if [ -f ".loki/app-runner/restart-signal" ]; then
                rm -f ".loki/app-runner/restart-signal"
                log_info "App runner: restart signal received from dashboard"
                # F49: dashboard-triggered restart uses the isolated HOME too.
                _loki_with_app_sandbox app_runner_restart || true
            fi
            if [ -f ".loki/app-runner/stop-signal" ]; then
                rm -f ".loki/app-runner/stop-signal"
                log_info "App runner: stop signal received from dashboard"
                app_runner_stop || true
            fi
        fi

        # Update session continuity file for next iteration / agent handoff
        update_continuity

        # Checkpoint after each iteration (v5.57.0)
        create_checkpoint "iteration-${ITERATION_COUNT} complete" "iteration-${ITERATION_COUNT}"
        # R6: prominent "you can safely undo this" signal so users run boldly.
        if [ -n "${_LAST_CHECKPOINT_ID:-}" ]; then
            log_info "Safety net: checkpoint ${_LAST_CHECKPOINT_ID} saved. Undo this iteration with: loki rollback to ${_LAST_CHECKPOINT_ID}"
        fi

        # Hosted previews install dependencies in an isolated Docker volume,
        # while Loki's deterministic gates execute from the host workspace.
        # Prepare the exact lockfile once after model work so missing host tools
        # can never masquerade as an application build or test failure.
        if loki_is_supervised_simple_web; then
            log_step "Preparing reproducible verification dependencies..."
            if ! type loki_prepare_project_dependencies >/dev/null 2>&1; then
                log_error "Required dependency setup contract is unavailable"
                return 1
            fi
            if ! _loki_with_app_sandbox loki_prepare_project_dependencies; then
                log_warn "Dependency setup failed; starting the targeted repair attempt without reviewing known-incomplete work."
                mkdir -p "${TARGET_DIR:-.}/.loki/quality" 2>/dev/null || true
                printf 'dependency_setup,\n' > \
                    "${TARGET_DIR:-.}/.loki/quality/gate-failures.txt"
                _loki_write_last_error "$ITERATION_COUNT" "dependency_setup" \
                    "Reconcile package.json with its lockfile, then prepare dependencies and run tests and the production build." || true
                if [ $((retry + 1)) -lt "$MAX_RETRIES" ]; then
                    ((retry++))
                    continue
                fi
                return 1
            fi
        fi

        # Quality gates (v6.10.0 - escalation ladder)
        log_step "Post-iteration: running quality gates..."
        local gate_failures=""
        if [ "${LOKI_HARD_GATES:-true}" = "true" ]; then
            # Static analysis gate
            if [ "${PHASE_STATIC_ANALYSIS:-true}" = "true" ]; then
                log_info "Quality gate: static analysis..."
                local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
                if enforce_static_analysis; then
                    clear_gate_failure "static_analysis"
                else
                    _stg_ok=fail
                    local sa_count
                    sa_count=$(track_gate_failure "static_analysis" \
                        "${TARGET_DIR:-.}/.loki/quality/static-analysis.json")
                    gate_failures="${gate_failures}static_analysis,"
                    log_warn "Static analysis FAILED ($sa_count consecutive) - findings injected into next iteration"
                    # F0, extended past mutation_integrity. Static analysis is
                    # the second of the three gates that have ever caused an
                    # extra iteration here, and an unchanging summary means the
                    # same syntax/lint error survived a whole pass.
                    if _loki_gate_stuck "static_analysis" \
                        "${TARGET_DIR:-.}/.loki/quality/static-analysis.json" "$sa_count"; then
                        log_error "Static analysis has failed $sa_count times for the SAME reason. Another iteration would reach the same verdict. Stopping instead of grinding."
                        emit_event_json "gate_stuck" \
                            "gate=static_analysis" \
                            "consecutive=$sa_count" 2>/dev/null || true
                        save_state "${retry:-0}" "gate_stuck_static_analysis" 20 2>/dev/null || true
                        return 20
                    fi
                fi
                emit_stage_complete "static_analysis" "$_stg_ok" "$_stg_t0"
            fi
            # Build check (#47). The hosted simple-web route blocks completion on
            # a failed or inconclusive production build. Other routes retain the
            # existing advisory behavior.
            if enforce_build_check; then
                clear_gate_failure "production_build"
            elif loki_is_supervised_simple_web; then
                local build_count
                build_count=$(track_gate_failure "production_build")
                gate_failures="${gate_failures}production_build,"
                log_warn "Production build gate FAILED ($build_count consecutive)"
            fi
            # Secure-by-default scan (v7.87.0). Advisory by default (never
            # blocks); records .loki/quality/security-findings.json each
            # iteration. Blocks only on un-waived HIGH when LOKI_SECURE_GATE=block.
            log_info "Quality gate: security scan (advisory)..."
            local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
            if run_secure_scan; then
                clear_gate_failure "security_scan"
            else
                _stg_ok=fail
                local sec_count
                sec_count=$(track_gate_failure "security_scan")
                gate_failures="${gate_failures}security_scan,"
                log_warn "Security gate BLOCKED ($sec_count consecutive) - un-waived HIGH findings (LOKI_SECURE_GATE=block)"
            fi
            emit_stage_complete "security_scan" "$_stg_ok" "$_stg_t0"
            # BUG-ST-002: Check pause signal between quality gates
            if [ -f "${TARGET_DIR:-.}/.loki/PAUSE" ] || [ -f "${TARGET_DIR:-.}/.loki/STOP" ]; then
                log_warn "Pause/stop signal detected between quality gates - deferring remaining gates"
                # Store partial gate failures before breaking out
                if [ -n "$gate_failures" ]; then
                    echo "$gate_failures" > "${TARGET_DIR:-.}/.loki/quality/gate-failures.txt"
                fi
                # Let the main loop handle the pause/stop on next iteration
                continue
            fi
            # Test coverage gate
            if [ "${PHASE_UNIT_TESTS:-true}" = "true" ]; then
                log_info "Quality gate: test suite (pass/fail)..."
                local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
                # F49: isolate HOME so the project's suite cannot pollute the
                # user's real home when it execs the generated app.
                if _loki_with_app_sandbox enforce_test_coverage; then
                    clear_gate_failure "test_coverage"
                else
                    _stg_ok=fail
                    local tc_count
                    tc_count=$(track_gate_failure "test_coverage")
                    gate_failures="${gate_failures}test_coverage,"
                    # Fourth dead branch, found by deriving the handled-gate set
                    # from the writer instead of hardcoding it: test_coverage
                    # maps to quality/test-results.json and had no caller either.
                    if [ "$(gate_failure_disposition "$tc_count")" != "block" ]; then
                        local _tc_thresh="$GATE_CLEAR_LIMIT"
                        [ "$GATE_ESCALATE_LIMIT" -lt "$_tc_thresh" ] && _tc_thresh="$GATE_ESCALATE_LIMIT"
                        write_gate_escalation_guidance "test_coverage" "$tc_count" "$_tc_thresh" || true
                    fi
                    # P0-1 Fix A: distinguish a coverage-only block (tests passed,
                    # enforced coverage below threshold) from a genuine tests-red
                    # block in the log so the operator is not misled.
                    if [ -f "${TARGET_DIR:-.}/.loki/quality/coverage.json" ] && \
                       python3 -c "import json,sys; sys.exit(0 if json.load(open('${TARGET_DIR:-.}/.loki/quality/coverage.json')).get('blocked') else 1)" 2>/dev/null; then
                        log_warn "Test coverage gate BLOCKED ($tc_count consecutive) - tests pass but coverage below threshold (LOKI_ENFORCE_COVERAGE=1)"
                    else
                        log_warn "Test suite gate FAILED ($tc_count consecutive) - must pass next iteration"
                    fi
                fi
                emit_stage_complete "test_suite" "$_stg_ok" "$_stg_t0"
            fi
            # BUG-ST-002: Check pause signal between quality gates (after test coverage)
            if [ -f "${TARGET_DIR:-.}/.loki/PAUSE" ] || [ -f "${TARGET_DIR:-.}/.loki/STOP" ]; then
                log_warn "Pause/stop signal detected between quality gates - deferring remaining gates"
                if [ -n "$gate_failures" ]; then
                    echo "$gate_failures" > "${TARGET_DIR:-.}/.loki/quality/gate-failures.txt"
                fi
                continue
            fi
            # Mock integrity gate (P0-3): block on CRITICAL/HIGH mock problems.
            if [ "${LOKI_GATE_MOCK:-true}" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
                log_info "Quality gate: mock integrity..."
                local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=not_run
                enforce_mock_integrity || true
                _stg_ok="${_LOKI_MOCK_INTEGRITY_STATUS:-not_run}"
                case "$_stg_ok" in
                    pass)
                        clear_gate_failure "mock_integrity"
                        ;;
                    fail)
                        local mk_count
                        mk_count=$(track_gate_failure "mock_integrity" \
                            "${TARGET_DIR:-.}/.loki/quality/mock-findings.txt")
                        gate_failures="${gate_failures}mock_integrity,"
                        log_warn "Mock integrity gate FAILED ($mk_count consecutive) - CRITICAL/HIGH mock problems"
                        # Escalation guidance was DEAD for this gate.
                        # write_gate_escalation_guidance already handles
                        # mock_integrity, mutation_integrity and test_coverage by
                        # name -- and only code_review ever called it, so those
                        # branches could never run.
                        #
                        # Measured: on a real run mock_integrity failed THREE
                        # times (the most of any gate) and
                        # .loki/signals/GATE_ESCALATION.json was never written.
                        # The agent was told the gate failed and never handed the
                        # findings file that says WHY, which is the 56%
                        # "did not attempt to recover" failure shape.
                        if [ "$(gate_failure_disposition "$mk_count")" != "block" ]; then
                            local _mk_thresh="$GATE_CLEAR_LIMIT"
                            [ "$GATE_ESCALATE_LIMIT" -lt "$_mk_thresh" ] && _mk_thresh="$GATE_ESCALATE_LIMIT"
                            write_gate_escalation_guidance "mock_integrity" "$mk_count" "$_mk_thresh" || true
                        fi
                        # F0, third gate. Measured on the v8.49.0 FireLater run:
                        # mock_integrity failed 3 times -- MORE than any other
                        # gate -- and was not wired to the stuck check, so an
                        # unfixable mock problem could grind indefinitely.
                        if _loki_gate_stuck "mock_integrity" \
                            "${TARGET_DIR:-.}/.loki/quality/mock-findings.txt" "$mk_count"; then
                            log_error "Mock integrity has failed $mk_count times for the SAME reason. Another iteration would reach the same verdict. Stopping instead of grinding."
                            emit_event_json "gate_stuck" \
                                "gate=mock_integrity" \
                                "consecutive=$mk_count" 2>/dev/null || true
                            save_state "${retry:-0}" "gate_stuck_mock_integrity" 20 2>/dev/null || true
                            return 20
                        fi
                        ;;
                    *)
                        _stg_ok=not_run
                        if loki_is_supervised_simple_web; then
                            gate_failures="${gate_failures}mock_integrity_not_run,"
                            log_warn "Supervised completion blocked: mock integrity was not measured (${_LOKI_MOCK_INTEGRITY_REASON:-unknown})"
                        fi
                        ;;
                esac
                emit_stage_complete "mock_integrity" "$_stg_ok" "$_stg_t0"
            fi
            # Test mutation integrity gate (P0-3): block on HIGH test-fitting.
            if [ "${LOKI_GATE_MUTATION:-true}" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
                log_info "Quality gate: test mutation integrity..."
                local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
                if enforce_mutation_integrity; then
                    clear_gate_failure "mutation_integrity"
                else
                    _stg_ok=fail
                    local mt_count
                    mt_count=$(track_gate_failure "mutation_integrity" \
                        "${TARGET_DIR:-.}/.loki/quality/mutation-findings.txt")
                    gate_failures="${gate_failures}mutation_integrity,"
                    log_warn "Mutation integrity gate FAILED ($mt_count consecutive) - HIGH test-fitting detected"
                    # Same dead-branch fix as mock_integrity above:
                    # write_gate_escalation_guidance maps mutation_integrity to
                    # mutation-findings.txt and nothing ever called it with that
                    # gate name, so the mapping could never fire.
                    if [ "$(gate_failure_disposition "$mt_count")" != "block" ]; then
                        local _mt_thresh="$GATE_CLEAR_LIMIT"
                        [ "$GATE_ESCALATE_LIMIT" -lt "$_mt_thresh" ] && _mt_thresh="$GATE_ESCALATE_LIMIT"
                        write_gate_escalation_guidance "mutation_integrity" "$mt_count" "$_mt_thresh" || true
                    fi
                    # F0: an unchanging cause means the next iteration reaches
                    # the same verdict. FireLater burned 3 iterations here on a
                    # detector that was never packaged, failing in 0-1s each
                    # time with an identical line. Stop honestly instead.
                    if _loki_gate_stuck "mutation_integrity" \
                        "${TARGET_DIR:-.}/.loki/quality/mutation-findings.txt" "$mt_count"; then
                        log_error "Mutation integrity has failed $mt_count times for the SAME reason:"
                        log_error "  $(head -1 "${TARGET_DIR:-.}/.loki/quality/mutation-findings.txt" 2>/dev/null)"
                        log_error "Another iteration would reach the same verdict. Stopping instead of grinding."
                        emit_event_json "gate_stuck" \
                            "gate=mutation_integrity" \
                            "consecutive=$mt_count" 2>/dev/null || true
                        save_state "${retry:-0}" "gate_stuck_mutation_integrity" 20 2>/dev/null || true
                        return 20
                    fi
                fi
                emit_stage_complete "mutation_integrity" "$_stg_ok" "$_stg_t0"
            fi
            # LSP diagnostics gate (P1-5 bash-route parity, v7.51.0; default-on
            # advisory-surfacing as of v7.57.0). Closes the parity gap: the Bun
            # route ships runLSPDiagnostics (loki-ts/src/runner/quality_gates.ts)
            # with a route-neutral Python writer (mcp/lsp_proxy.py); the bash
            # route had NO writer/reader.
            #
            # POSTURE (v7.57.0): DEFAULT-ON SURFACING (no blocking arm exists).
            # This is the mid-iteration advisory arm -- it RUNS by default (like
            # the mock/mutation gates), writes lsp-diagnostics.json, and surfaces
            # errors through its measured artifact, stage event, log, and
            # recurrence telemetry. Advisory results never enter the canonical
            # gate-failures blocker set.
            #   - Toggle: LOKI_GATE_LSP_DIAGNOSTICS (default TRUE = surfacing on;
            #     opt out with =false). Accepts "true" or "1". Single knob (no
            #     blocking arm exists on the bash route, so there is no _BLOCK flag).
            #   - count_errors > 0 -> surface (track_gate_failure), mirroring the
            #     TS "errorCount > 0" finding at quality_gates.ts:1667.
            #   - warnings only -> advisory PASS (quality_gates.ts:1673).
            #   - artifact absent, malformed, or unmeasured -> not_run, never pass.
            # The writer is OPT-OUT-able with LOKI_GATE_LSP_WRITER=0 (operator can
            # supply a pre-built artifact), matching the TS escape hatch
            # (quality_gates.ts:1630). cwd must be the install dir (PROJECT_DIR =
            # $SCRIPT_DIR/.. ) so `-m mcp.lsp_proxy` imports, while --root points
            # at the TARGET project the loop is building (mirrors
            # runLSPDiagnosticsWriter: cwd=REPO_ROOT, --root=ctx.cwd).
            if { [ "${LOKI_GATE_LSP_DIAGNOSTICS:-true}" = "true" ] || [ "${LOKI_GATE_LSP_DIAGNOSTICS:-true}" = "1" ]; } && [ "$ITERATION_COUNT" -gt 0 ]; then
                log_info "Quality gate: LSP diagnostics..."
                local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=not_run
                enforce_lsp_diagnostics || true
                _stg_ok="${_LOKI_LSP_DIAGNOSTICS_STATUS:-not_run}"
                case "$_stg_ok" in
                    fail)
                        local _lsp_e _lsp_w
                        _lsp_e=$(printf '%s' "${_LOKI_LSP_DIAGNOSTICS_DETAIL:-}" | awk '{print $2}')
                        _lsp_w=$(printf '%s' "${_LOKI_LSP_DIAGNOSTICS_DETAIL:-}" | awk '{print $3}')
                        local lsp_count
                        lsp_count=$(track_gate_failure "lsp_diagnostics" \
                            "${_LOKI_LSP_DIAGNOSTICS_DETAIL:-}")
                        log_warn "LSP diagnostics reported errors ($lsp_count consecutive) - ${_lsp_e} error(s), ${_lsp_w} warning(s); advisory only"
                        ;;
                    pass)
                        clear_gate_failure "lsp_diagnostics"
                        case "${_LOKI_LSP_DIAGNOSTICS_DETAIL:-}" in
                            warn*)
                                local _lsp_w2
                                _lsp_w2=$(printf '%s' "$_LOKI_LSP_DIAGNOSTICS_DETAIL" | awk '{print $3}')
                                log_info "LSP diagnostics: 0 errors, ${_lsp_w2} warning(s) (advisory)"
                                ;;
                            *) log_info "LSP diagnostics: 0 errors, 0 warnings" ;;
                        esac
                        ;;
                    *)
                        _stg_ok=not_run
                        log_info "LSP diagnostics: no valid measured artifact; gate did not run"
                        if loki_is_supervised_simple_web; then
                            log_warn "LSP diagnostics were not measured (${_LOKI_LSP_DIAGNOSTICS_REASON:-unknown}); recorded as advisory telemetry"
                        fi
                        ;;
                esac
                emit_stage_complete "lsp_diagnostics" "$_stg_ok" "$_stg_t0"
            fi
            # Semantic test-authenticity gate -- mid-iteration ADVISORY arm
            # (v7.57.0 default-on surfacing). Clones the mock arm (~15126)
            # byte-for-byte: runs enforce_semantic_integrity, which writes
            # semantic-findings.txt and returns 1 ONLY on a CRITICAL/HIGH
            # fake-test finding (clean / no-test-files / detector-absent / timeout
            # / malformed all collapse to rc 0 inside the function -- deny-filter).
            # On rc 1 we ONLY track_gate_failure (surface to the next prompt via
            # the semantic-findings injector at build_prompt); we NEVER PAUSE and
            # NEVER reject completion here. Default-on surfacing cannot deadlock
            # (mock/mutation prove this in production). The opt-in completion-
            # BLOCKING arm lives behind LOKI_GATE_SEMANTIC_TESTS_BLOCK at the
            # completion-promise elif below; this surfacing arm is independent.
            #   - Toggle: LOKI_GATE_SEMANTIC_TESTS (default TRUE = surfacing on;
            #     opt out with =false). Accepts "true" or "1".
            if { [ "${LOKI_GATE_SEMANTIC_TESTS:-true}" = "true" ] || [ "${LOKI_GATE_SEMANTIC_TESTS:-true}" = "1" ]; } && [ "$ITERATION_COUNT" -gt 0 ]; then
                log_info "Quality gate: semantic test-authenticity (advisory)..."
                if enforce_semantic_integrity; then
                    clear_gate_failure "semantic_tests"
                else
                    local sem_count
                    sem_count=$(track_gate_failure "semantic_tests" \
                        "${TARGET_DIR:-.}/.loki/quality/semantic-findings.txt")
                    if [ "${LOKI_GATE_SEMANTIC_TESTS_BLOCK:-false}" = "true" ] \
                       || [ "${LOKI_GATE_SEMANTIC_TESTS_BLOCK:-false}" = "1" ]; then
                        gate_failures="${gate_failures}semantic_tests,"
                    fi
                    log_warn "Semantic test-authenticity gate FAILED ($sem_count consecutive) - CRITICAL/HIGH fake-test problems (advisory; surfaced to next iteration)"
                fi
            fi
            # Invariant/property gate -- mid-iteration ADVISORY arm (v7.57.0
            # default-on surfacing). Mirrors the semantic arm above: runs
            # enforce_invariant_integrity, which writes invariant-findings.txt and
            # returns 1 ONLY on a CRITICAL/HIGH invariant violation (clean /
            # detector-absent / timeout / malformed all collapse to rc 0 inside
            # the function -- deny-filter). On rc 1 we ONLY track_gate_failure
            # (surfaced to the next prompt via the invariant-findings injector in
            # build_prompt); we NEVER PAUSE and NEVER reject completion here. The
            # opt-in completion-BLOCKING arm lives behind LOKI_GATE_INVARIANTS_BLOCK
            # at the completion-promise elif below.
            #   - Toggle: LOKI_GATE_INVARIANTS (default TRUE = surfacing on; opt
            #     out with =false). Accepts "true" or "1".
            if { [ "${LOKI_GATE_INVARIANTS:-true}" = "true" ] || [ "${LOKI_GATE_INVARIANTS:-true}" = "1" ]; } && [ "$ITERATION_COUNT" -gt 0 ]; then
                log_info "Quality gate: invariant/property (advisory)..."
                if enforce_invariant_integrity; then
                    clear_gate_failure "invariants"
                else
                    local inv_count
                    inv_count=$(track_gate_failure "invariants" \
                        "${TARGET_DIR:-.}/.loki/quality/invariant-findings.txt")
                    if [ "${LOKI_GATE_INVARIANTS_BLOCK:-false}" = "true" ] \
                       || [ "${LOKI_GATE_INVARIANTS_BLOCK:-false}" = "1" ]; then
                        gate_failures="${gate_failures}invariants,"
                    fi
                    log_warn "Invariant gate FAILED ($inv_count consecutive) - CRITICAL/HIGH invariant/property violations (advisory; surfaced to next iteration)"
                fi
            fi
            # SKIP THE COUNCIL WHEN A DETERMINISTIC GATE ALREADY FAILED
            # (LOKI_REVIEW_SKIP_ON_GATE_FAIL, default off).
            #
            # Measured: the council costs 31s at 3 reviewers and 280-502s at 6-7.
            # The gates above cost ~6s COMBINED (static_analysis 5s, security_scan
            # 1s, lsp_diagnostics 1s, test_suite <1s). When one of them has already
            # failed, the iteration cannot be accepted no matter what the council
            # says -- gate_failures is non-empty and feeds the same completion
            # decision -- so the review is spending 280-502s to produce advice on
            # code that is already going back for another pass.
            #
            # WHAT THIS IS NOT. It does not weaken any gate: a skipped review is
            # recorded as skipped, never as a PASS, and the failing gate still
            # blocks exactly as before. It cannot turn a rejection into an
            # approval -- it only declines to spend five minutes describing a
            # rejection that is already decided.
            #
            # DEFAULT OFF. Review findings are also next-iteration STEERING
            # (LOKI_INJECT_FINDINGS), so skipping trades some guidance for a large
            # latency win. That trade is a per-route decision, not a silent
            # global one.
            local _skip_review=false
            if [ "${LOKI_REVIEW_SKIP_ON_GATE_FAIL:-false}" = "true" ] \
               && [ -n "${gate_failures:-}" ]; then
                _skip_review=true
            fi
            if [ "$_skip_review" = "true" ]; then
                log_warn "Code review SKIPPED: deterministic gates already failed (${gate_failures%,}). The iteration is already going back; not spending a full council on it. Unset LOKI_REVIEW_SKIP_ON_GATE_FAIL to always review."
                emit_stage_complete "code_review" "skipped" "$(date +%s 2>/dev/null)"
            # Code review gate (upgraded from advisory, with escalation)
            elif [ "$PHASE_CODE_REVIEW" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
                log_info "Quality gate: code review..."
                local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
                if run_code_review; then
                    clear_gate_failure "code_review"
                else
                    _stg_ok=fail
                    local cr_count
                    cr_count=$(track_gate_failure "code_review")
                    # BUG-QG-007: Always append to gate_failures regardless of escalation tier
                    # BUG-RUN-009: Write PAUSE to .loki/PAUSE (not .loki/signals/PAUSE)
                    # v7.5.3 Phase 1 hook: try the override council BEFORE
                    # locking in the BLOCK / escalation. If counter-evidence
                    # is supplied AND a trusted proofType, this lifts the
                    # BLOCK and clears the code_review gate counter.
                    # No-op when no counter-evidence file exists. Embedded
                    # by default; opt out with LOKI_OVERRIDE_COUNCIL=0.
                    local _phase1_overrode=false
                    if [ "${LOKI_OVERRIDE_COUNCIL:-1}" != "0" ] && command -v bun >/dev/null 2>&1; then
                        local _override_out
                        _override_out=$(bun "${SCRIPT_DIR}/../loki-ts/dist/loki.js" internal phase1-hooks override "$ITERATION_COUNT" 2>/dev/null || true)
                        case "$_override_out" in
                            *"override: LIFTED"*)
                                log_info "Phase 1 override council lifted code_review BLOCK"
                                clear_gate_failure "code_review"
                                cr_count=0
                                _phase1_overrode=true
                                ;;
                        esac
                    fi
                    if [ "$_phase1_overrode" = "true" ]; then
                        _stg_ok=pass # BLOCK lifted; continue without escalation
                    else
                        local _gate_disposition
                        _gate_disposition=$(gate_failure_disposition "$cr_count")
                        if [ "$_gate_disposition" != "block" ]; then
                            local _guidance_threshold="$GATE_CLEAR_LIMIT"
                            if [ "$GATE_ESCALATE_LIMIT" -lt "$_guidance_threshold" ]; then
                                _guidance_threshold="$GATE_ESCALATE_LIMIT"
                            fi
                            write_gate_escalation_guidance "code_review" "$cr_count" "$_guidance_threshold" || true
                        fi
                    fi
                    if [ "$_phase1_overrode" = "true" ]; then
                        :
                    elif [ "$_gate_disposition" = "pause" ]; then
                        log_error "Gate escalation: code_review failed $cr_count times (>= $GATE_PAUSE_LIMIT) - forcing PAUSE for human intervention"
                        echo "PAUSE" > "${TARGET_DIR:-.}/.loki/signals/GATE_ESCALATION"
                        echo "code_review gate failed $cr_count consecutive times" >> "${TARGET_DIR:-.}/.loki/signals/GATE_ESCALATION"
                        # v7.5.3 Phase 1 hook: structured handoff doc before
                        # bare PAUSE. Embedded; opt out LOKI_HANDOFF_MD=0.
                        if [ "${LOKI_HANDOFF_MD:-1}" != "0" ] && command -v bun >/dev/null 2>&1; then
                            bun "${SCRIPT_DIR}/../loki-ts/dist/loki.js" internal phase1-hooks handoff code_review "$cr_count" "$ITERATION_COUNT" 2>/dev/null || true
                        fi
                        touch "${TARGET_DIR:-.}/.loki/PAUSE"
                        gate_failures="${gate_failures}code_review_PAUSED,"
                    elif [ "$_gate_disposition" = "escalate" ]; then
                        log_warn "Gate escalation: code_review remains blocked after $cr_count failures - escalating without converting failure to pass"
                        echo "ESCALATE" > "${TARGET_DIR:-.}/.loki/signals/GATE_ESCALATION"
                        gate_failures="${gate_failures}code_review_ESCALATED,"
                    else
                        gate_failures="${gate_failures}code_review,"
                        if [ "${_LOKI_REVIEW_FAILURE_KIND:-}" = "infrastructure_inconclusive" ]; then
                            log_warn "Code review incomplete ($cr_count consecutive) - reviewer infrastructure did not return a full council"
                        else
                            log_warn "Code review BLOCKED ($cr_count consecutive) - Critical/High findings"
                        fi
                    fi
                    # v7.5.3 Phase 1 hook: persist structured findings +
                    # auto-write learnings (one shell-out per iteration).
                    # Best-effort; never fails the main loop.
                    if [ "${LOKI_INJECT_FINDINGS:-1}" != "0" ]; then
                        if command -v bun >/dev/null 2>&1; then
                            bun "${SCRIPT_DIR}/../loki-ts/dist/loki.js" internal phase1-hooks reflect "$ITERATION_COUNT" 2>/dev/null || true
                        else
                            # DEGRADED, and said so. Findings injection is what
                            # tells the next iteration WHAT to fix; without it the
                            # agent knows only that it failed. Research puts "the
                            # agent did not attempt to recover from an error" at
                            # 56% of all agent failures, and a feedback loop that
                            # silently stops feeding back manufactures exactly that
                            # shape -- the next iteration then looks like the model
                            # failing, when it was never told what went wrong.
                            #
                            # Defaults ON but was gated on `command -v bun`, so on a
                            # machine without bun it degraded with no signal at all.
                            # A missing capability must be visible; a silent one is
                            # worse than an absent feature because it misattributes
                            # the failure.
                            log_warn "Findings injection unavailable (bun not found): the next iteration will be told it failed but NOT what to fix. Install bun, or set LOKI_INJECT_FINDINGS=0 to silence this."
                            emit_event_json "capability_degraded" \
                                "capability=inject_findings" \
                                "reason=bun_not_found" \
                                "impact=next_iteration_lacks_structured_findings" 2>/dev/null || true
                        fi
                    fi
                fi
                emit_stage_complete "code_review" "$_stg_ok" "$_stg_t0"
                if [ "$_stg_ok" = "fail" ] \
                   && loki_is_supervised_simple_web \
                   && [ "${_LOKI_REVIEW_FAILURE_KIND:-}" = "infrastructure_inconclusive" ]; then
                    log_error "Review verification remained unavailable after the bounded review-only retry."
                    log_error "The implementation will not run again without a concrete code finding."
                    emit_event_json "review_verification_failed" \
                        "reason=infrastructure_inconclusive" \
                        "iteration=$ITERATION_COUNT" \
                        "implementation_retry=false"
                    _loki_write_last_error "$ITERATION_COUNT" "review_verification_failed" \
                        "Build and tests passed, but the review provider did not return a complete verdict after one bounded retry." || true
                    save_state "$retry" "failed" 20
                    emit_completion_summary failed 2>/dev/null || true
                    rm -f "$iter_output" 2>/dev/null || true
                    return 20
                fi
            fi
            # Auto-generate docs (default-on) BEFORE the staleness check and the
            # gate, so neither nags the user to run 'loki docs generate' by hand.
            # Opt out with LOKI_AUTO_DOCS=false.
            # Bracketed because this is the single biggest non-provider step in
            # the loop (the doc suite has cost ~25min on a real build) and it was
            # the one nobody could see. Reuses the existing helper: one date call,
            # no new subprocess per stage.
            if [ "$ITERATION_COUNT" -gt 0 ] && ! loki_is_supervised_simple_web; then
                local _docgen_t0=$(date +%s 2>/dev/null); local _docgen_ok=pass
                auto_generate_docs_if_needed || _docgen_ok=fail
                emit_stage_complete "doc_generation" "$_docgen_ok" "$_docgen_t0"
            fi
            # Documentation staleness check (v6.75.0)
            if [ "$ITERATION_COUNT" -gt 0 ] && ! loki_is_supervised_simple_web; then
                run_doc_staleness_check
            fi
            # Documentation quality gate - Gate 7 (Documentation Coverage)
            if [ "${LOKI_GATE_DOC_COVERAGE:-true}" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
                log_info "Quality gate: documentation coverage..."
                local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
                if run_doc_quality_gate; then
                    clear_gate_failure "doc_coverage"
                else
                    _stg_ok=fail
                    local dc_count
                    dc_count=$(track_gate_failure "doc_coverage")
                    gate_failures="${gate_failures}doc_coverage,"
                    log_warn "Documentation coverage gate: Score below threshold ($dc_count consecutive)"
                fi
                emit_stage_complete "doc_coverage" "$_stg_ok" "$_stg_t0"
            fi
            # Magic Modules debate gate - Gate 12 (v6.77.0)
            if [ "${LOKI_GATE_MAGIC_DEBATE:-true}" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
                log_info "Quality gate: magic modules debate..."
                local _stg_t0=$(date +%s 2>/dev/null); local _stg_ok=pass
                if run_magic_debate_gate; then
                    clear_gate_failure "magic_debate"
                else
                    _stg_ok=fail
                    local md_count
                    md_count=$(track_gate_failure "magic_debate")
                    gate_failures="${gate_failures}magic_debate,"
                    log_warn "Magic Modules debate gate: BLOCK severity detected ($md_count consecutive)"
                fi
                emit_stage_complete "magic_debate" "$_stg_ok" "$_stg_t0"
            fi
            # Store gate failures for prompt injection
            if [ -n "$gate_failures" ]; then
                echo "$gate_failures" > "${TARGET_DIR:-.}/.loki/quality/gate-failures.txt"
            else
                rm -f "${TARGET_DIR:-.}/.loki/quality/gate-failures.txt"
            fi
        else
            if [ "$PHASE_CODE_REVIEW" = "true" ] && [ "$ITERATION_COUNT" -gt 0 ]; then
                log_info "Quality gate: code review (advisory)..."
                run_code_review || log_warn "Code review found issues - check .loki/quality/reviews/"
            fi
        fi
        log_info "Quality gates complete."

        # Automatic episode capture after every RARV iteration (v6.15.0)
        # Captures RARV phase, git changes, and iteration context automatically
        auto_capture_episode "$ITERATION_COUNT" "$exit_code" "${rarv_phase:-iteration}" \
            "${prd_path:-codebase-analysis}" "$duration" "$log_file"

        # Magic Modules COMPOUND capture (v6.77.0): record component patterns
        _magic_compound_capture

        # Auto-wiki regeneration after every iteration (v7.88.2). Best-effort,
        # non-blocking, incremental (only regenerates when the codebase
        # structure changed). Default-on; opt out LOKI_WIKI_AUTO=0. The `|| true`
        # is belt-and-suspenders -- the function already returns 0 on every path.
        if ! loki_is_supervised_simple_web; then
            _auto_wiki_regen 2>/dev/null || true
        fi

        # BUG-QG-008: Track iteration for convergence regardless of exit code
        if type council_track_iteration &>/dev/null; then
            council_track_iteration "$log_file"
        fi

        # Uncertainty-gated escalation (v7.19.2, Slice B action).
        # The decision lives in completion-council.sh:uncertainty_should_escalate
        # (pure, debounced once-per-stuck-episode, knob-first on
        # LOKI_UNCERTAINTY_ESCALATION). This block only ACTS when the function
        # returns rc 0. The type guard keeps it a silent no-op if the decision
        # function is not present (byte-identical when the feature is absent/off).
        if type uncertainty_should_escalate &>/dev/null && uncertainty_should_escalate; then
            log_error "[Uncertainty] Escalating to human: >=2 of 3 stuck-signals co-occurred for N rounds (no-change / oscillation / council-split). PAUSE written; handoff saved."
            log_warn  "[Uncertainty] To opt out of proactive escalation: set LOKI_UNCERTAINTY_ESCALATION=0"
            # Structured handoff doc before the bare PAUSE (mirrors GATE precedent).
            write_structured_handoff "uncertainty_escalation"
            notify_intervention_needed "Uncertainty escalation: >=2 of 3 stuck-signals co-occurred for N rounds"
            # Marker file for dashboard / external consumers. Empty touch has no
            # partial-write window, so atomic temp+mv is not required here.
            mkdir -p "${TARGET_DIR:-.}/.loki/signals"
            touch "${TARGET_DIR:-.}/.loki/signals/UNCERTAINTY_ESCALATION"
            # PAUSE is consumed by check_human_intervention: it halts in
            # non-perpetual mode; in perpetual mode it auto-clears + notifies.
            # That degrade is free; we add no consumer logic here.
            touch "${TARGET_DIR:-.}/.loki/PAUSE"
            # Perpetual-mode honesty: detect with the SAME vars the existing PAUSE
            # consumer uses (run.sh check_human_intervention), print-only.
            if [ "$AUTONOMY_MODE" = "perpetual" ] || [ "$PERPETUAL_MODE" = "true" ]; then
                log_warn "[Uncertainty] Perpetual mode: PAUSE will be auto-cleared; this is notify-only and will NOT halt the run."
            fi
        fi

        # Check for success - ONLY stop on explicit completion promise
        # There's never a "complete" product - always improvements, bugs, features
        if [ $exit_code -eq 0 ]; then
            # Episode trace already captured by auto_capture_episode above (v6.15.0)

            # Live Build HUD (v7.71.0): one append-only status line per successful
            # iteration. At the top of the success branch so it fires for ALL
            # success sub-paths (incl. perpetual mode / completion). TTY-gated and
            # `|| true` so it can never abort the loop. Never tee'd -> dashboard
            # agent.log + stream parser untouched.
            render_build_hud "${ITERATION_COUNT:-0}" "${rarv_phase:-?}" "${duration:-0}" || true

            # Perpetual mode: NEVER stop, always continue
            if [ "$PERPETUAL_MODE" = "true" ]; then
                log_info "Perpetual mode: Ignoring exit, continuing immediately..."
                # BUG-RUN-010: Reset retry counter on success (only count failures)
                retry=0
                # BUG-NEW-003/E2E-005: Clean up per-iteration output before continuing
                rm -f "$iter_output" 2>/dev/null
                continue  # Immediately start next iteration, no wait
            fi

            # Completion Council check (v5.25.0) - multi-agent voting on completion
            # Runs before completion promise check since council is more comprehensive
            log_step "Post-iteration: checking completion council..."
            # Finding #598 (HIGH): council_should_stop calls council_evidence_gate
            # internally; ensure fresh test evidence exists first so its test axis
            # is not half-blind when the quality-gate ladder did not run this
            # iteration (e.g. LOKI_HARD_GATES=false). Idempotent: the freshness
            # guard reuses results the ladder already wrote in the common case, so
            # tests are never run twice per iteration. Best-effort, never blocks.
            if type ensure_completion_test_evidence &>/dev/null; then
                ensure_completion_test_evidence || true
            fi
            # v7.105.0 convergence: if the agent EXPLICITLY claimed completion this
            # iteration (structured loki_complete_task / COMPLETION_REQUESTED
            # signal), tell the council to evaluate NOW instead of deferring to the
            # 5-iteration check interval. The council still must approve (MIN_
            # ITERATIONS + hard checklist + evidence gate + vote + devil's advocate
            # all unchanged); this only stops an already-verified build from
            # grinding needless "next improvement" iterations. Scoped to this call.
            #
            # CRITICAL (council-review finding): we must PEEK at the claim signal
            # NON-DESTRUCTIVELY. check_task_completion_signal() CONSUMES the signal
            # (rm -f on read), and the DEFAULT completion-promise route below
            # (v6.82.0, the live gate-based acceptance path) also consumes it -- so
            # calling the consuming detector here would drop the claim before that
            # route sees it, re-introducing the v7.28 "claim drop" bug. Instead we
            # only test the signal FILES' existence (the same two paths
            # check_task_completion_signal reads), leaving consumption to the
            # existing single owner. The council side likewise peeks
            # ($TARGET_DIR/.loki/signals/COMPLETION_REQUESTED) without consuming.
            local _loki_completion_claimed=0
            if [ -f "${TARGET_DIR:-.}/.loki/signals/TASK_COMPLETION_CLAIMED" ] \
               || [ -f "${TARGET_DIR:-.}/.loki/signals/COMPLETION_REQUESTED" ]; then
                _loki_completion_claimed=1
            fi
            # CLAIM GROUNDING (report-only): does the completion claim name files
            # that are actually in this run's diff? Every existing evidence axis is
            # a REPO-level fact (diff non-empty, tests green, app boots), so an
            # agent can finish by claiming "added retry logic to the payment
            # client" while the diff shows a README edit and all six axes pass.
            # The claim itself is the one artifact nothing else reads.
            #
            # PLACED HERE, at the non-destructive peek, deliberately: this is the
            # only point where the claim signal still EXISTS. The default route
            # below (check_completion_promise -> check_task_completion_signal)
            # consumes it with rm -f on read, so reading the statement after that
            # returns nothing, and re-reading it through the consuming detector
            # would re-introduce the v7.28 claim-drop bug. We read the signal file
            # directly and never remove it -- consumption keeps its single owner.
            #
            # FAIL-OPEN AND NON-BLOCKING BY DESIGN: only a claim naming a path
            # demonstrably absent from the diff is a finding, and it is written to
            # a file, never returned into the gate chain. claim_grounding.py exits
            # 1 on exactly that case, hence `|| true` -- an ungrounded claim must
            # report, not block. A grounding check that blocked on ambiguity would
            # fire on ordinary prose and be disabled within a week.
            if [ "$_loki_completion_claimed" = 1 ] \
               && [ -f "${SCRIPT_DIR}/lib/claim_grounding.py" ]; then
                _loki_check_claim_grounding || true
            fi
            local _loki_completion_ready=1
            if loki_is_supervised_simple_web; then
                _loki_supervised_completion_gates_pass "${gate_failures:-}" && _loki_completion_ready=0
            elif type council_should_stop &>/dev/null \
                 && LOKI_COMPLETION_CLAIMED="$_loki_completion_claimed" council_should_stop; then
                _loki_completion_ready=0
            fi
            if [ "$_loki_completion_ready" -eq 0 ]; then
                # bash-F1: council_should_stop returns 0 from a genuine approval
                # AND from two force-stop safety valves (stagnation flood /
                # repeated done-signals). A force-stop is NOT a verified-complete
                # product, so it must not claim "PROJECT COMPLETE" or open a PR.
                # The sentinel set inside council_should_stop disambiguates.
                if [ "${COUNCIL_FORCE_STOPPED:-0}" = "1" ]; then
                    echo ""
                    log_header "COMPLETION COUNCIL: STOPPED WITHOUT APPROVAL"
                    log_warn "Council force-stopped (stagnation or repeated done-signals); work is NOT verified-complete"
                    log_info "Running memory consolidation..."
                    run_memory_consolidation
                    # No on_run_complete: a force-stop must never open a "done" PR.
                    emit_completion_summary force_stopped
                    # Exit 20, not 0. Every other signal here already says this
                    # run is NOT verified-complete -- the header, the warning,
                    # the refusal to open a PR -- but the exit code said the
                    # opposite, and the exit code is the only one a CI job, a
                    # Kubernetes Job, or a shell `&&` actually reads. A
                    # stagnation force-stop was therefore indistinguishable
                    # from success to every automated caller.
                    #
                    # 20 is the established "deterministic terminal failure"
                    # code, already used by max_iterations_reached
                    # (run.sh:20796, :20877) for the same class of outcome:
                    # the run stopped without verifying the work. Retrying is
                    # pointless; a human needs to look. Two terminals with the
                    # same meaning must not report opposite exit codes.
                    save_state $retry "force_stopped" 20
                    rm -f "$iter_output" 2>/dev/null
                    return 20
                fi
                echo ""
                if loki_is_supervised_simple_web; then
                    log_header "SUPERVISED COMPLETION GATES: PROJECT COMPLETE"
                    log_info "Checklist, heldout, evidence, and assumption gates passed"
                else
                    log_header "COMPLETION COUNCIL: PROJECT COMPLETE"
                    log_info "Council voted to stop (convergence detected + requirements verified)"
                fi
                log_info "Running memory consolidation..."
                run_memory_consolidation
                # Delegate-then-notify: optional local PR on success, then the
                # durable summary + desktop ping. on_run_complete is idempotent
                # and only opens a PR when LOKI_DELEGATE_PR=1 (default OFF).
                on_run_complete
                emit_completion_summary complete
                if loki_is_supervised_simple_web; then
                    save_state $retry "deterministic_gates_passed" 0
                else
                    save_state $retry "council_approved" 0
                fi
                rm -f "$iter_output" 2>/dev/null
                return 0
            fi

            # Stop if either:
            #   (a) the agent invoked the loki_complete_task MCP tool
            #       (detected via .loki/signals/TASK_COMPLETION_CLAIMED), OR
            #   (b) LOKI_LEGACY_COMPLETION_MATCH=true AND the completion
            #       promise text appears in the iteration output.
            # The check_completion_promise() helper encapsulates both.
            # BUG-RUN-001: Use per-iteration output, not stale daily log.
            #
            # v7.6.2 B-17 fix: completion was firing even when code review
            # BLOCKED the iteration with Critical/High findings. That's a false
            # success signal -- review-blocked iterations cannot be considered
            # complete. Check the gate_failures accumulator for code_review and
            # refuse completion until the review passes.
            local _gate_block_for_completion=""
            case "${gate_failures:-}" in
                *code_review,*|*code_review_ESCALATED*|*code_review_PAUSED*) _gate_block_for_completion="code_review" ;;
            esac
            # DROP-FIX (v7.28): check_completion_promise -> check_task_completion_signal
            # CONSUMES the completion signal (rm -f) on the FIRST successful call.
            # The completion-promise chain below calls it up to five times in one
            # iteration (reverify guard, code-review arm, evidence arm, held-out
            # arm, success arm), so the first call consumed the claim and every
            # later arm saw nothing -- the success arm never fired and the run
            # iterated to max_iterations even though the agent had claimed done.
            # Fix: evaluate the claim EXACTLY ONCE here, capture it in
            # _completion_claimed, and have every arm test that variable. The
            # single call discards stdout (matching the prior call sites, which
            # also discarded it), so the task_completion_claim event still emits
            # exactly once. Consumption semantics are preserved: the claim is
            # consumed when evaluated; if a gate rejects it, the agent must
            # re-claim next iteration (see internal/DEMO-CLAIM-DROP-BUG.md).
            local _completion_claimed=0
            if check_completion_promise "$iter_output"; then
                _completion_claimed=1
            fi
            # MEDIUM-3: this completion-promise route evaluates the council hard
            # gates (evidence + held-out) without the council_evaluate freshness
            # step, so the held-out gate could read stale verification statuses
            # (and a stale reservation). Re-verify the checklist ONCE here, but
            # only when a completion claim is actually present (mirror the
            # check_completion_promise condition used by the gate chain below) so
            # verification does not run every iteration. Type-guarded and
            # best-effort: failure must never block the completion path.
            if [ "$_completion_claimed" = 1 ] && type council_reverify_checklist &>/dev/null; then
                council_reverify_checklist 2>/dev/null || true
            fi
            # Finding #598 (HIGH): generate real test evidence before the evidence
            # gate fires, so the gate's test axis is never half-blind on absent
            # test-results.json. Only on an actual completion claim (mirrors the
            # reverify guard above) so the suite does not run every iteration.
            # Type-guarded + best-effort: never blocks the completion path itself;
            # the evidence gate below is the decider that reads the file.
            if [ "$_completion_claimed" = 1 ] && type ensure_completion_test_evidence &>/dev/null; then
                ensure_completion_test_evidence || true
            fi
            # TRUST MOAT (fail-CLOSED): each completion gate below is armed only
            # when its function is loadable (`type fn && ! fn`). If the council
            # library failed to source, those `type` probes are FALSE, so every
            # gate arm is silently skipped and a completion claim sails straight to
            # the accept branch UNVERIFIED -- a fake-green. Before the gate chain,
            # verify the core gate functions exist; if any is missing, the library
            # is incomplete and we CANNOT verify completion, so we refuse the claim
            # (force another iteration) rather than pass ungated. This never fires
            # in a healthy install (functions are sourced) and only triggers on a
            # genuinely broken/partial load -- exactly when failing open is unsafe.
            if [ "$_completion_claimed" = 1 ]; then
                _gates_loadable=1
                for _gate_fn in council_checklist_gate council_evidence_gate council_heldout_gate council_assumption_ledger_gate; do
                    if ! type "$_gate_fn" &>/dev/null; then
                        log_error "Completion gate unavailable: ${_gate_fn} (council library incomplete or failed to load)."
                        _gates_loadable=0
                    fi
                done
                if [ "$_gates_loadable" -eq 0 ]; then
                    log_error "Cannot verify completion: required council gates are missing. Refusing the completion claim (fail-closed) and continuing to iterate."
                    _completion_claimed=0
                fi
                unset _gates_loadable _gate_fn
            fi
            if [ -n "$_gate_block_for_completion" ] && [ "$_completion_claimed" = 1 ]; then
                log_warn "Completion claim rejected: code review is BLOCKED for this iteration (Critical/High findings). Fix review issues before completion."
                log_warn "  Review details under .loki/quality/reviews/ ; gate_failures=${gate_failures}"
                _gate_block_for_completion=""
                # Fall through; the gate-failed loop continues normally
            # HIGH (trust-gate): the checklist hard gate must also guard the
            # DEFAULT completion-promise / loki_complete_task route, not only the
            # interval-gated council path (council_evaluate) and the dashboard
            # force-review path -- both of which already call this gate. Without
            # it, an agent that leaves a `priority: critical` checklist item
            # `failing` and claims done on a non-council-interval iteration would
            # ship, bypassing the checklist gate entirely. council_reverify_checklist
            # ran above (when a claim is present) so statuses are fresh here.
            # Mirrors the evidence/held-out gate arms below. No-op safe:
            # council_checklist_gate returns 0 (pass) when there is no checklist
            # results file or when no critical items are failing, so this branch
            # never fires on those projects. Gate output is written by the gate.
            elif [ "$_completion_claimed" = 1 ] && type council_checklist_gate &>/dev/null && ! council_checklist_gate; then
                log_warn "Completion claim rejected: critical checklist item(s) failing (hard gate)."
                log_warn "  Details under .loki/council/gate-block.json"
                # Fall through; keep iterating until critical checklist items pass.
            # v7.19.1: the verified-completion evidence gate must also guard the
            # DEFAULT completion route (a completion claim via loki_complete_task
            # / the completion-promise text), not only the interval-gated council
            # path. Otherwise an agent can self-assert "done" with an empty diff
            # and red tests and exit as completion_promise_fulfilled, bypassing
            # the gate entirely -- exactly the fabrication this feature prevents.
            # Mirrors the code_review block above (B-17). Opt-out: the gate's own
            # LOKI_EVIDENCE_GATE=0 (council_evidence_gate returns 0 immediately
            # when disabled, so this branch never fires). Gate output (reason +
            # opt-out hint) is printed by council_evidence_gate itself.
            elif [ "$_completion_claimed" = 1 ] && type council_evidence_gate &>/dev/null && ! _evidence_gate_and_surface; then
                log_warn "Completion claim rejected: evidence gate found no proof of completion (empty diff vs run-start SHA, or red tests)."
                log_warn "  Details under .loki/council/evidence-block.json ; opt out with LOKI_EVIDENCE_GATE=0"
                # Fall through; keep iterating until there is real evidence.
            # v7.28.0: the held-out spec-eval gate must also guard the DEFAULT
            # completion-promise route, not only the interval-gated council path
            # (council_evaluate). Otherwise an agent can self-assert "done" and
            # exit as completion_promise_fulfilled while a held-out acceptance
            # check is failing, bypassing the anti-reward-hacking gate entirely.
            # Mirrors the evidence-gate block above. Opt-out: the gate's own
            # LOKI_HELDOUT_GATE=0 (council_heldout_gate returns 0 immediately
            # when disabled or when no held-out items are reserved, so this
            # branch never fires). Gate output is printed by council_heldout_gate.
            elif [ "$_completion_claimed" = 1 ] && type council_heldout_gate &>/dev/null && ! council_heldout_gate; then
                log_warn "Completion claim rejected: held-out spec-eval gate found failing held-out acceptance check(s)."
                log_warn "  Details under .loki/council/heldout-block.json ; opt out with LOKI_HELDOUT_GATE=0"
                # Fall through; keep iterating until the held-out checks pass.
            # P2-2: the assumption ledger gate must also guard the DEFAULT
            # completion-promise route, not only the interval-gated council path.
            # Otherwise an agent can self-assert "done" while a high-severity spec
            # assumption is still unresolved, bypassing the spec-robustness gate.
            # Mirrors the evidence/held-out gate arms above. Opt-out: the gate's
            # own LOKI_ASSUMPTION_GATE=0 (returns 0 immediately when disabled, so
            # this branch never fires). Gate output is printed by the gate itself.
            elif [ "$_completion_claimed" = 1 ] && type council_assumption_ledger_gate &>/dev/null && ! council_assumption_ledger_gate; then
                log_warn "Completion claim rejected: assumption ledger gate found unresolved high-severity spec assumption(s)."
                log_warn "  Details under .loki/council/assumption-block.json ; opt out with LOKI_ASSUMPTION_GATE=0"
                # Fall through; keep iterating until high-sev assumptions resolve.
            # P1-3: semantic test-authenticity completion-BLOCKING gate (OPT-IN,
            # default OFF). Catches fake tests that look real but verify nothing
            # (literal-via-variable echo etc.) that the regex gates 5+6 miss.
            # v7.57.0: the SURFACING of these findings is now default-on (see the
            # mid-iteration advisory arm above, gated on LOKI_GATE_SEMANTIC_TESTS,
            # default true). This completion-BLOCKING arm is a SEPARATE opt-in,
            # gated on LOKI_GATE_SEMANTIC_TESTS_BLOCK (default OFF; accepts "true"
            # or "1"), so the surfacing-default-on flip does NOT make blocking
            # default-on. When the BLOCK flag is set it runs the detector with --block-high and
            # rejects completion ONLY on a CRITICAL/HIGH finding; clean /
            # no-test-files / detector-absent / timeout / malformed all collapse
            # to a pass inside _semantic_gate_and_surface, so the autonomous loop
            # can never deadlock on a clean run. Mirrors the evidence / held-out /
            # assumption arms above.
            elif [ "$_completion_claimed" = 1 ] && { [ "${LOKI_GATE_SEMANTIC_TESTS_BLOCK:-false}" = "true" ] || [ "${LOKI_GATE_SEMANTIC_TESTS_BLOCK:-false}" = "1" ]; } && type _semantic_gate_and_surface &>/dev/null && ! _semantic_gate_and_surface; then
                log_warn "Completion claim rejected: semantic test-authenticity gate found CRITICAL/HIGH fake-test problem(s)."
                log_warn "  Details under .loki/quality/semantic-findings.txt ; opt-in blocking -- disable with LOKI_GATE_SEMANTIC_TESTS_BLOCK=false"
                # Fall through; keep iterating until the fake tests are fixed.
            # P1-4: invariant/property completion-BLOCKING gate (OPT-IN, default
            # OFF). Mirrors the semantic arm above and the Bun route's invariants
            # toggle (loki-ts/src/runner/quality_gates.ts:2057). v7.57.0: the
            # SURFACING of these findings is now default-on (see the mid-iteration
            # advisory arm above, gated on LOKI_GATE_INVARIANTS, default true).
            # This completion-BLOCKING arm is a SEPARATE opt-in, gated on
            # LOKI_GATE_INVARIANTS_BLOCK (default OFF), so the surfacing-default-on
            # flip does NOT make blocking default-on. Accepts "true" or "1". When
            # the BLOCK flag is set it runs detect-invariant-violations.sh --strict
            # and rejects completion ONLY on a CRITICAL/HIGH (rc 1) finding;
            # clean / detector-absent / timeout / malformed all collapse to a pass
            # inside _invariant_gate_and_surface, so the autonomous loop can never
            # deadlock on a clean run.
            elif [ "$_completion_claimed" = 1 ] && { [ "${LOKI_GATE_INVARIANTS_BLOCK:-false}" = "true" ] || [ "${LOKI_GATE_INVARIANTS_BLOCK:-false}" = "1" ]; } && type _invariant_gate_and_surface &>/dev/null && ! _invariant_gate_and_surface; then
                log_warn "Completion claim rejected: invariant gate found CRITICAL/HIGH invariant/property violation(s)."
                log_warn "  Details under .loki/quality/invariant-findings.txt ; opt-in blocking -- disable with LOKI_GATE_INVARIANTS_BLOCK=false"
                # Fall through; keep iterating until the invariant violations are fixed.
            # OPT-IN test_coverage completion block (LOKI_GATE_TEST_COVERAGE_BLOCK,
            # default OFF; accepts "true" or "1"). By DEFAULT a failing suite is
            # advisory at completion (the council evidence gate is the backstop);
            # this opt-in ALSO refuses the completion claim at the loop layer when
            # this iteration's test-results.json shows a real failing suite
            # (pass:false), so a red suite cannot be declared complete even if a
            # heuristic council with no evidence gate is in play. Mirrors the Bun
            # route's LOKI_GATE_TEST_COVERAGE_BLOCK arm (loki-ts/src/runner/
            # autonomous.ts completionRefusalReason) for bash<->Bun parity. Only a
            # concrete pass:false blocks; inconclusive/not_run/absent never deadlock
            # a clean run.
            elif [ "$_completion_claimed" = 1 ] && { [ "${LOKI_GATE_TEST_COVERAGE_BLOCK:-false}" = "true" ] || [ "${LOKI_GATE_TEST_COVERAGE_BLOCK:-false}" = "1" ]; } && grep -q '"pass"[[:space:]]*:[[:space:]]*false' "${TARGET_DIR:-.}/.loki/quality/test-results.json" 2>/dev/null; then
                log_warn "Completion claim rejected: test suite is RED (test-results.json pass:false)."
                log_warn "  opt-in blocking -- disable with LOKI_GATE_TEST_COVERAGE_BLOCK=false"
                # Fall through; keep iterating until the tests pass.
            elif [ "$_completion_claimed" = 1 ]; then
                echo ""
                if [ -n "$COMPLETION_PROMISE" ]; then
                    log_header "COMPLETION PROMISE FULFILLED: $COMPLETION_PROMISE"
                else
                    log_header "TASK COMPLETION CLAIMED (via loki_complete_task)"
                fi
                log_info "Explicit completion signal detected."
                # v7.7.3 F-3 fix: intelligent USAGE.md regeneration. The static
                # USAGE_DOC_INSTRUCTION in build_prompt gets the agent to write
                # SOMETHING; this hook re-runs a cheap model call with the FINAL
                # project state to refine that output (or write it if missing).
                # Default-on per the "no user flag" mandate; set
                # LOKI_INTELLIGENT_USAGE=0 to disable. Best-effort: failures
                # never block completion.
                if [ "${LOKI_INTELLIGENT_USAGE:-1}" != "0" ]; then
                    _intelligent_usage_regen 2>/dev/null || true
                fi
                # Run memory consolidation on successful completion
                log_info "Running memory consolidation..."
                run_memory_consolidation
                # Delegate-then-notify: optional local PR on success, then the
                # durable summary + desktop ping (see on_run_complete).
                on_run_complete
                emit_completion_summary complete
                save_state $retry "completion_promise_fulfilled" 0
                rm -f "$iter_output" 2>/dev/null
                return 0
            fi

            # Warn if Claude says it's "done" but no explicit promise
            if is_completed; then
                log_warn "${PROVIDER_DISPLAY_NAME:-Claude} claims completion, but no explicit promise fulfilled."
                log_warn "Council will evaluate at next check interval (every ${COUNCIL_CHECK_INTERVAL:-5} iterations)"
            fi

            # Cross-provider failover: check if primary has recovered (v6.19.0)
            check_primary_recovery 2>/dev/null || true

            # SUCCESS exit - continue IMMEDIATELY to next iteration (no wait!)
            log_step "Starting next iteration..."
            # BUG-RUN-010: Reset retry counter on success (only count failures)
            retry=0
            # BUG-NEW-003/E2E-005: Clean up per-iteration output before continuing
            rm -f "$iter_output" 2>/dev/null
            continue  # Immediately start next iteration, no exponential backoff
        fi

        # Only apply retry logic for ERRORS (non-zero exit code)
        # Episode trace already captured by auto_capture_episode above (v6.15.0)

        # Live Build HUD (v7.71.0): a failing iteration still shows motion (lock
        # #3). At the top of the failure fall-through so it fires for ALL failure
        # sub-paths, incl. the rate-limit/failover branch that `continue`s before
        # the "Will retry" log_warn below. TTY-gated, `|| true`, never tee'd.
        render_build_hud "${ITERATION_COUNT:-0}" "${rarv_phase:-?}" "${duration:-0}" || true

        # T2.5: durable, classified failure record for `loki why`. Best-effort,
        # never crashes the build. Skip signal-induced exits (130 SIGINT /
        # 143 SIGTERM / 137 SIGKILL): a user/operator interrupt is not an error
        # to record (mirrors the crash-capture exclusion above). Classification
        # is conservative -- provider_empty_output | rate_limited | auth_error,
        # else unknown; never fabricates a class that no signal supports.
        if [ "$exit_code" -ne 130 ] && [ "$exit_code" -ne 143 ] && [ "$exit_code" -ne 137 ]; then
            local _err_class _err_brief
            _err_class="$(_loki_classify_iteration_error "$iter_output" "${_empty_output:-0}")"
            case "$_err_class" in
                provider_empty_output) _err_brief="The provider returned no output (0 bytes) on this iteration -- no work was done." ;;
                rate_limited)          _err_brief="The provider rate-limited the request; the build will wait and retry." ;;
                auth_error)            _err_brief="The provider rejected the request as unauthorized (check your login or API key)." ;;
                *)                     _err_brief="Iteration ${ITERATION_COUNT:-?} failed with exit code ${exit_code} (cause not classified)." ;;
            esac
            _loki_write_last_error "${ITERATION_COUNT:-0}" "$_err_class" "$_err_brief" || true
        fi

        # Checkpoint failed iteration state (v5.57.0)
        create_checkpoint "iteration-${ITERATION_COUNT} failed (exit=$exit_code)" "iteration-${ITERATION_COUNT}-fail"

        # Handle retry - check for rate limit first
        # BUG-RUN-002: Use per-iteration output, not stale daily log
        local rate_limit_wait=$(detect_rate_limit "$iter_output")
        local wait_time

        if [ $rate_limit_wait -gt 0 ]; then
            # Cross-provider failover (v6.19.0): try switching provider before waiting
            if attempt_provider_failover 2>/dev/null; then
                log_info "Failover succeeded - retrying immediately with ${PROVIDER_NAME}"
                ((retry++))
                continue
            fi

            wait_time=$rate_limit_wait
            local human_time=$(format_duration $wait_time)
            log_warn "Rate limit detected! Waiting until reset (~$human_time)..."
            local _reset_at
            _reset_at="$(date -v+${wait_time}S '+%I:%M %p' 2>/dev/null || date -d "+${wait_time} seconds" '+%I:%M %p' 2>/dev/null || echo 'soon')"
            log_info "Rate limit resets at approximately $_reset_at"
            # T2.7: elevate the wait to an explicit, reassuring INFO line (the
            # human time was previously only at DEBUG inside detect_rate_limit's
            # calculated-backoff branch) so a multi-minute wait does not look
            # like a hang, and persist a machine-readable signal for watchers.
            log_info "Rate-limited by the provider; waiting ~${wait_time}s (resets ${_reset_at}). This is normal, not a hang."
            _loki_write_rate_limit_signal "$wait_time" "$_reset_at" || true
            notify_rate_limit "$wait_time"
        else
            wait_time=$(calculate_wait $retry)
            log_warn "Will retry in ${wait_time}s..."
        fi

        log_info "Press Ctrl+C to cancel"

        # Countdown with progress.
        # v7.7.31: the countdown now sleeps in short 1s ticks and checks the
        # STOP/PAUSE signal on every tick. Previously it slept in 10s (or 60s
        # for long waits) chunks and never read the STOP file, so a dashboard
        # Stop button or `loki stop` issued DURING the inter-iteration wait did
        # nothing for up to 60s, and a SIGTERM was deferred by bash until the
        # current sleep chunk finished. Short ticks make Stop take effect within
        # ~1s and let the SIGTERM trap fire promptly.
        local remaining=$wait_time
        local _loki_dir_wait="${TARGET_DIR:-.}/.loki"
        local _last_shown=-1
        while [ $remaining -gt 0 ]; do
            # Honor an immediate stop/pause requested during the wait (dashboard
            # Stop button, `loki stop`, or a STOP file written by any control).
            if [ -f "$_loki_dir_wait/STOP" ] || [ -f "$_loki_dir_wait/PAUSE" ]; then
                echo ""
                log_warn "Stop/pause signal detected during wait - returning to control loop"
                break
            fi
            # Refresh the human-readable countdown at most once per 10s of change
            # so we do not spam the terminal while still ticking every second.
            if [ $((remaining % 10)) -eq 0 ] || [ "$_last_shown" -ne "$remaining" ]; then
                local human_remaining=$(format_duration $remaining)
                printf "\r${YELLOW}Resuming in ${human_remaining}...${NC}          "
                _last_shown=$remaining
            fi
            sleep 1
            remaining=$((remaining - 1))
        done
        echo ""

        # T2.7: the wait is over -- clear the RATE_LIMITED signal so it never
        # lingers stale once the build resumes. Best-effort.
        rm -f "${TARGET_DIR:-.}/.loki/signals/RATE_LIMITED" 2>/dev/null || true

        # Clean up per-iteration output file
        rm -f "$iter_output" 2>/dev/null

        ((retry++))
    done

    log_error "Max retries ($MAX_RETRIES) exceeded"
    save_state $retry "failed" 1
    # Delegate-then-notify: terminal failure. critical urgency so the desktop
    # ping is louder; the summary file records where the partial work landed.
    emit_completion_summary failed critical
    return 1
}

#===============================================================================
# Human Intervention Mechanism (Auto-Claude pattern)
#===============================================================================

# Track interrupt state for Ctrl+C pause/exit behavior
INTERRUPT_COUNT=0
INTERRUPT_LAST_TIME=0
PAUSED=false

# v7.5.12: Track active provider invocation for SIGINT propagation.
# When non-zero, indicates a provider pipeline (claude/codex/cline/aider)
# is currently running and should be killed on Ctrl+C.
LOKI_PROVIDER_ACTIVE=0

# v7.5.12: Kill provider pipeline children with SIGTERM, then SIGKILL escalation.
# Uses pkill -P $$ to target direct children only (the pipeline subshells).
# Returns 0 if anything was killed, 1 if no children present.
#
# v7.6.2 B-15 fix: previously `pkill -P $$` was indiscriminate -- it caught
# the dashboard server (started via nohup but still parented to this shell
# until the OS reparents it). The dashboard PID 29716 was killed mid-session
# after "Aggregating verdicts", breaking the browser UI. Now we explicitly
# exclude any PID registered in .loki/pids/ (dashboard, app-runner, etc.).
kill_provider_child() {
    local killed=0
    local protected_pids=""
    # v7.7.5 follow-up: previously this only read `*.pid` files, but the
    # canonical registry (`register_pid` in run.sh:873) writes `*.json` files
    # named `<PID>.json`. The dashboard PID was registered as JSON and thus
    # not protected; provider kill cascade caught it. Now reads BOTH:
    # *.pid files (legacy + .loki/dashboard/dashboard.pid) AND *.json files
    # (the canonical pid registry, where the JSON filename IS the PID).
    local pid_root="${TARGET_DIR:-.}/.loki/pids"
    if [ -d "$pid_root" ]; then
        local pid_file pid
        # Legacy / external `.pid` files: content is the PID
        for pid_file in "$pid_root"/*.pid; do
            [ -f "$pid_file" ] || continue
            pid=$(cat "$pid_file" 2>/dev/null | head -1 | tr -d '[:space:]')
            if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
                protected_pids="${protected_pids} ${pid}"
            fi
        done
        # Canonical `register_pid` registry: filename `<PID>.json` IS the PID.
        for pid_file in "$pid_root"/*.json; do
            [ -f "$pid_file" ] || continue
            pid=$(basename "$pid_file" .json)
            # Verify numeric + alive before adding (basename may be non-numeric
            # if some other consumer wrote a non-PID JSON file here).
            case "$pid" in
                ''|*[!0-9]*) continue ;;
            esac
            if kill -0 "$pid" 2>/dev/null; then
                protected_pids="${protected_pids} ${pid}"
            fi
        done
    fi
    # Also protect the dashboard PID file at .loki/dashboard/dashboard.pid (older path).
    local dash_pid_file="${TARGET_DIR:-.}/.loki/dashboard/dashboard.pid"
    if [ -f "$dash_pid_file" ]; then
        local dpid
        dpid=$(cat "$dash_pid_file" 2>/dev/null | head -1 | tr -d '[:space:]')
        if [ -n "$dpid" ] && kill -0 "$dpid" 2>/dev/null; then
            protected_pids="${protected_pids} ${dpid}"
        fi
    fi

    # Helper: returns 0 if $1 is in protected_pids list.
    _is_protected() {
        local target="$1"
        local p
        for p in $protected_pids; do
            [ "$p" = "$target" ] && return 0
        done
        return 1
    }

    # First pass: SIGTERM each direct child individually so we can skip protected PIDs.
    local child_pid
    for child_pid in $(pgrep -P $$ 2>/dev/null); do
        if _is_protected "$child_pid"; then
            continue
        fi
        kill -TERM "$child_pid" 2>/dev/null && killed=1
    done
    # Also kill provider leaf processes by name in case they were reparented.
    local proc
    for proc in claude codex aider cline; do
        pkill -TERM -f "^${proc}( |$)" 2>/dev/null && killed=1
    done

    # Brief wait for graceful exit (max ~2s).
    local i=0
    while [ $i -lt 20 ]; do
        local survivors=""
        for child_pid in $(pgrep -P $$ 2>/dev/null); do
            if ! _is_protected "$child_pid"; then
                survivors="${survivors} ${child_pid}"
            fi
        done
        if [ -z "$survivors" ]; then
            break
        fi
        sleep 0.1
        i=$((i + 1))
    done

    # Escalate to SIGKILL for unprotected survivors only.
    for child_pid in $(pgrep -P $$ 2>/dev/null); do
        if _is_protected "$child_pid"; then
            continue
        fi
        kill -KILL "$child_pid" 2>/dev/null
        killed=1
    done

    LOKI_PROVIDER_ACTIVE=0
    if [ $killed -eq 1 ]; then
        return 0
    fi
    return 1
}

# Authoritative self-reap of THIS run's process group on a normal completion.
#
# Why this exists: a normal completion (council stop / max-iterations /
# completion promise) returns from run_autonomous() into main()'s cleanup
# block, which reaps the app-runner but NOT the orchestrator's own process
# group. The provider agent (claude/codex/...) and any subagents it spawned
# share the orchestrator's group; if one detached or was reparented to init,
# it survived the `exit` and kept consuming CPU (observed: ~27 min orphan).
# The external `loki stop` path (autonomy/loki) already reaps the whole group
# via the recorded pgid; this brings the SAME authoritative reap to the
# completion path so a clean finish leaves no orphans.
#
# Foreign-run safety (CRITICAL): this is pgid-scoped to the group THIS run
# recorded at .loki/loki.pgid -- it NEVER uses a name-based `pkill claude`
# sweep. A concurrent foreign loki run is its own session leader with a
# DIFFERENT pgid and a different .loki, so it can never be a member of our
# group and is structurally unreachable. The pgid file only exists when this
# runner setsid'd into its own session (LOKI_OWN_SESSION=1, recorded at
# ~run.sh:15034); in interactive foreground we share the user's shell group,
# leave the pgid absent, and skip this reap entirely -- so Ctrl+C semantics
# and the user's shell are untouched.
# --- pgid stamp helpers (P0: stale-pgid session killer) ---------------------
# The pgid file is written as `pgid=<n> boot=<id> started=<epoch>` so a reader
# can tell a live record from a week-old orphan. See reap_own_process_group.

# Extract one field from a stamp. A bare number (legacy file) yields the pgid
# and nothing else, so the reader's fail-closed branch rejects it.
_loki_pgid_field() {
    local _s="$1" _k="$2"
    case "$_s" in
        *=*) ;;
        *) [ "$_k" = "pgid" ] && printf '%s' "$(printf '%s' "$_s" | tr -d ' ')"; return 0 ;;
    esac
    # Prefix/suffix trimming, not `for _tok in $_s`. Two reasons:
    #   - `while read` in a pipeline runs in a SUBSHELL and drops a final field
    #     with no trailing newline, silently returning an empty `started`.
    #   - unquoted `$_s` in a `for` relies on word splitting, which bash does
    #     and ZSH DOES NOT. run.sh is bash, but this file gets sourced and
    #     probed from other shells, and a helper whose correctness depends on
    #     the caller's shell is a trap. This form needs no word splitting.
    local _rest="$_s" _tok
    while [ -n "$_rest" ]; do
        _tok="${_rest%% *}"                    # first space-delimited token
        case "$_tok" in
            "$_k"=*) printf '%s' "${_tok#*=}"; return 0 ;;
        esac
        case "$_rest" in
            *" "*) _rest="${_rest#* }" ;;      # advance past this token
            *) _rest="" ;;                     # last token: stop
        esac
    done
}

# A value that changes on every reboot, so a pgid recorded before a reboot can
# never validate afterwards. Empty when unavailable -- callers fail closed.
_loki_boot_id() {
    if [ -r /proc/stat ]; then
        awk '/^btime /{print $2; exit}' /proc/stat 2>/dev/null && return 0
    fi
    # macOS: `kern.boottime` prints `{ sec = 1234567890, usec = 0 } ...`
    sysctl -n kern.boottime 2>/dev/null | sed -n 's/.*sec *= *\([0-9][0-9]*\).*/\1/p'
}

# Start time of a pid as a unix epoch, or empty when it cannot be determined.
_loki_proc_start_epoch() {
    local _p="${1:-$$}" _lstart
    _lstart="$(ps -o lstart= -p "$_p" 2>/dev/null)"
    [ -n "$_lstart" ] || return 0
    date -j -f '%a %b %e %T %Y' "$_lstart" '+%s' 2>/dev/null \
        || date -d "$_lstart" '+%s' 2>/dev/null \
        || true
}

reap_own_process_group() {
    local loki_dir="${TARGET_DIR:-.}/.loki"
    # Resolve the pgid file the same way main() recorded it (global or per-session).
    local _reap_pgid_file="$loki_dir/loki.pid"
    if [ -n "${LOKI_SESSION_ID:-}" ]; then
        _reap_pgid_file="$loki_dir/sessions/${LOKI_SESSION_ID}/loki.pid"
    fi
    _reap_pgid_file="${_reap_pgid_file%.pid}.pgid"
    [ -f "$_reap_pgid_file" ] || return 0   # interactive / no own session: skip

    # Parse the stamped format `pgid=<n> boot=<id> started=<epoch>`, falling back
    # to a bare number for a file written by an older version.
    local _stamp _pgid _boot _started
    _stamp=$(cat "$_reap_pgid_file" 2>/dev/null | tr -d '\n')
    _pgid=$(_loki_pgid_field "$_stamp" pgid)
    _boot=$(_loki_pgid_field "$_stamp" boot)
    _started=$(_loki_pgid_field "$_stamp" started)
    case "$_pgid" in ''|*[!0-9]*) return 0 ;; esac
    [ "$_pgid" -gt 1 ] 2>/dev/null || return 0    # never touch pgid 0/1

    # Safety: the recorded pgid MUST be our own group. If it isn't (stale file
    # from a prior run, copied tree), refuse -- killing a group we do not own
    # could hit unrelated processes.
    local _my_pgid
    _my_pgid=$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')
    [ -n "$_my_pgid" ] && [ "$_pgid" = "$_my_pgid" ] || return 0

    # BOOT + AGE GUARD (the fix for the session-killer).
    #
    # The self-check above is NOT sufficient on its own. loki.pgid was removed
    # only on the normal exit path, so a Ctrl+C'd or crashed run left the file
    # behind indefinitely -- measured on a real machine: two orphans aged 155h
    # and 202h. PIDs recycle (macOS wraps near 99999; max observed 99762), so a
    # week-old pgid eventually matches a LIVE, unrelated shell group. The
    # self-check then passes and every sibling in the user's terminal -- their
    # editor, another agent session -- is TERM'd then KILL'd.
    #
    # A recycled pgid cannot forge both of these:
    #   - boot id: a file from a previous boot can never match this boot.
    #   - started: the recording run must not predate THIS process.
    #
    # Fails CLOSED. A missing or malformed stamp means we do nothing: an
    # orphaned agent (the v7.41.x problem this reap was added for) is strictly
    # less harmful than killing a user's editor.
    local _now_boot
    _now_boot="$(_loki_boot_id)"
    if [ -z "$_boot" ] || [ -z "$_started" ]; then
        return 0    # unstamped legacy file: never trust it
    fi
    if [ -z "$_now_boot" ] || [ "$_boot" != "$_now_boot" ]; then
        return 0    # different boot (or boot id unavailable): refuse
    fi
    case "$_started" in ''|*[!0-9]*) return 0 ;; esac
    local _self_started
    _self_started="$(_loki_proc_start_epoch $$)"
    # The stamp must not predate this process by more than a small clock skew.
    # A stale file is always OLDER than the process now reading it.
    if [ -n "$_self_started" ] && [ "$_started" -lt "$(( _self_started - 5 ))" ]; then
        return 0
    fi

    # Collect protected pids (dashboard, app-runner, registered children) so the
    # reap never takes down the shared dashboard if it happens to share our
    # group. Mirrors the `loki stop` / dashboard reaper protection set.
    local _protected=" $$ "
    local _pf _p
    if [ -d "$loki_dir/pids" ]; then
        for _pf in "$loki_dir/pids"/*.json; do
            [ -f "$_pf" ] || continue
            _p=$(basename "$_pf" .json)
            case "$_p" in ''|*[!0-9]*) continue ;; esac
            _protected="${_protected}${_p} "
        done
        for _pf in "$loki_dir/pids"/*.pid; do
            [ -f "$_pf" ] || continue
            _p=$(cat "$_pf" 2>/dev/null | head -1 | tr -d '[:space:]')
            [ -n "$_p" ] && _protected="${_protected}${_p} "
        done
    fi
    for _pf in "$loki_dir/dashboard/dashboard.pid" "${HOME}/.loki/dashboard/dashboard.pid"; do
        [ -f "$_pf" ] && _protected="${_protected}$(cat "$_pf" 2>/dev/null | tr -d ' ') "
    done

    # Per-pid TERM then KILL of group members, EXCLUDING $$ (so main() survives
    # to finish its remaining cleanup and exit normally) and protected pids. We
    # do per-pid (not a blanket `kill -- -PGID`) precisely so $$ and the
    # dashboard are spared -- a group-wide signal cannot exclude members.
    local _gp _did=0
    for _gp in $(ps -axo pid=,pgid= 2>/dev/null | awk -v g="$_pgid" '$2==g{print $1}'); do
        case "$_protected" in *" $_gp "*) continue ;; esac
        kill -TERM "$_gp" 2>/dev/null && _did=1
    done
    [ "$_did" = "1" ] || return 0
    sleep 1
    for _gp in $(ps -axo pid=,pgid= 2>/dev/null | awk -v g="$_pgid" '$2==g{print $1}'); do
        case "$_protected" in *" $_gp "*) continue ;; esac
        kill -KILL "$_gp" 2>/dev/null || true
    done
    return 0
}

# Check for human intervention signals
check_human_intervention() {
    local loki_dir="${TARGET_DIR:-.}/.loki"

    # Check for PAUSE file
    # BUG #4 fix: Check handle_pause return value before deleting PAUSE file.
    # handle_pause returns 1 if STOP was requested during the pause, so we must
    # propagate that as return 2 (stop) instead of always returning 1 (continue).
    if [ -f "$loki_dir/PAUSE" ]; then
        # In perpetual mode: auto-clear PAUSE files and continue without waiting
        # EXCEPT when PAUSE was created by budget limit enforcement
        if [ "$AUTONOMY_MODE" = "perpetual" ] || [ "$PERPETUAL_MODE" = "true" ]; then
            if [ -f "$loki_dir/signals/BUDGET_EXCEEDED" ]; then
                log_warn "PAUSE file created by budget limit - NOT auto-clearing in perpetual mode"
                log_warn "Budget limit reached. Remove .loki/signals/BUDGET_EXCEEDED and .loki/PAUSE to continue."
                notify_intervention_needed "Budget limit reached - execution paused" 2>/dev/null || true
                # Same-instant snapshot as COMPLETION.txt (see the PAUSE-file site).
                (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
                # Genuinely blocking pause: write the durable intervention record
                # now (state-only; the ping above already fired). This is the
                # correct site for the durable file because the run actually halts
                # here until the operator clears the budget signal.
                build_completion_summary intervention 2>/dev/null || true
                local pause_result
                handle_pause
                pause_result=$?
                rm -f "$loki_dir/PAUSE"
                if [ "$pause_result" -eq 1 ]; then
                    # STOP requested DURING the pause: relabel the durable record
                    # as stopped (state-only; the user typed STOP and is aware).
                    (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
                    build_completion_summary stopped 2>/dev/null || true
                    return 2
                fi
                return 1
            fi
            log_warn "PAUSE file detected but autonomy mode is perpetual - auto-clearing"
            notify_intervention_needed "PAUSE file auto-cleared in perpetual mode" 2>/dev/null || true
            rm -f "$loki_dir/PAUSE" "$loki_dir/PAUSED.md"
            # Restart dashboard if it crashed (likely cause of the PAUSE)
            handle_dashboard_crash
            return 0
        fi
        log_warn "PAUSE file detected - pausing execution"
        notify_intervention_needed "Execution paused via PAUSE file"
        # Refresh STATUS.txt from the queue BEFORE writing the durable record, so
        # the two files snapshot the same instant. The status monitor refreshes
        # every 2s but is not running by the time we block here, so STATUS.txt
        # was frozen at its last tick: measured on a real paused run reporting
        # "Failed: 0" while COMPLETION.txt -- written now, from the same
        # queue/failed.json -- reported failed=1. Same source, different age.
        (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
        # Genuinely blocking pause: write the durable intervention record now
        # (state-only; the ping above already fired).
        build_completion_summary intervention 2>/dev/null || true
        local pause_result
        handle_pause
        pause_result=$?
        rm -f "$loki_dir/PAUSE"
        if [ "$pause_result" -eq 1 ]; then
            # STOP was requested during pause: relabel the durable record as
            # stopped (state-only; the user typed STOP and is aware).
            (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
            build_completion_summary stopped 2>/dev/null || true
            return 2
        fi
        return 1
    fi

    # Check for PAUSE_AT_CHECKPOINT (checkpoint mode deferred pause)
    if [ -f "$loki_dir/PAUSE_AT_CHECKPOINT" ]; then
        if [ "$AUTONOMY_MODE" = "checkpoint" ]; then
            log_warn "Checkpoint pause requested - pausing now"
            rm -f "$loki_dir/PAUSE_AT_CHECKPOINT"
            notify_intervention_needed "Execution paused at checkpoint"
            touch "$loki_dir/PAUSE"
            # Same-instant snapshot as COMPLETION.txt (see the PAUSE-file site).
            (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
            # Genuinely blocking pause: write the durable intervention record now
            # (state-only; the ping above already fired).
            build_completion_summary intervention 2>/dev/null || true
            local pause_result
            handle_pause
            pause_result=$?
            rm -f "$loki_dir/PAUSE"
            if [ "$pause_result" -eq 1 ]; then
                # STOP requested during pause: relabel as stopped (state-only).
                (cd "${TARGET_DIR:-.}" && update_status_file) 2>/dev/null || true
                build_completion_summary stopped 2>/dev/null || true
                return 2
            fi
            return 1
        else
            # Clean up stale checkpoint pause file
            rm -f "$loki_dir/PAUSE_AT_CHECKPOINT"
        fi
    fi

    # Check for HUMAN_INPUT.md (prompt injection)
    # Security: Check it's a regular file (not symlink) to prevent symlink attacks
    if [ -f "$loki_dir/HUMAN_INPUT.md" ] && [ ! -L "$loki_dir/HUMAN_INPUT.md" ]; then
        # Security: Prompt injection disabled by default for enterprise security
        if [ "${LOKI_PROMPT_INJECTION:-false}" != "true" ]; then
            log_warn "HUMAN_INPUT.md detected but prompt injection is DISABLED"
            log_warn "To enable, set LOKI_PROMPT_INJECTION=true (only in trusted environments)"
            # Move to rejected instead of processed
            mkdir -p "$loki_dir/logs" 2>/dev/null
            mv "$loki_dir/HUMAN_INPUT.md" "$loki_dir/logs/human-input-REJECTED-$(date +%Y%m%d-%H%M%S).md" 2>/dev/null || rm -f "$loki_dir/HUMAN_INPUT.md"
        else
            # Security: Check file size (1MB limit)
            local file_size
            file_size=$(stat -f%z "$loki_dir/HUMAN_INPUT.md" 2>/dev/null || stat -c%s "$loki_dir/HUMAN_INPUT.md" 2>/dev/null || echo "0")
            if [ "$file_size" -gt 1048576 ]; then
                log_warn "HUMAN_INPUT.md exceeds 1MB size limit, rejecting"
                mkdir -p "$loki_dir/logs" 2>/dev/null
                mv "$loki_dir/HUMAN_INPUT.md" "$loki_dir/logs/human-input-REJECTED-TOOLARGE-$(date +%Y%m%d-%H%M%S).md" 2>/dev/null || rm -f "$loki_dir/HUMAN_INPUT.md"
            else
                local human_input=$(cat "$loki_dir/HUMAN_INPUT.md")
                if [ -n "$human_input" ]; then
                    log_info "Human input detected:"
                    echo "$human_input"
                    echo ""
                    # Move to processed
                    mkdir -p "$loki_dir/logs" 2>/dev/null
                    mv "$loki_dir/HUMAN_INPUT.md" "$loki_dir/logs/human-input-$(date +%Y%m%d-%H%M%S).md"
                    # Inject into next prompt
                    export LOKI_HUMAN_INPUT="$human_input"
                    return 0
                fi
            fi
        fi
    elif [ -L "$loki_dir/HUMAN_INPUT.md" ]; then
        # Security: Reject symlinks
        log_warn "HUMAN_INPUT.md is a symlink - rejected for security"
        rm -f "$loki_dir/HUMAN_INPUT.md"
    fi

    # Check for council force-review signal (from dashboard)
    if [ -f "$loki_dir/signals/COUNCIL_REVIEW_REQUESTED" ]; then
        log_info "Council force-review requested from dashboard"
        rm -f "$loki_dir/signals/COUNCIL_REVIEW_REQUESTED"
        # MEDIUM-3: this route evaluates the council hard gates directly without
        # the council_evaluate freshness step, so re-verify the checklist ONCE
        # before the gate chain to restore that invariant (refreshes held-out
        # statuses and repairs a stale reservation). Type-guarded, best-effort.
        if type council_reverify_checklist &>/dev/null; then
            council_reverify_checklist 2>/dev/null || true
        fi
        # Finding #598 (HIGH): generate real test evidence before the force-review
        # evidence gate, so the test axis is not half-blind on absent results.
        if type ensure_completion_test_evidence &>/dev/null; then
            ensure_completion_test_evidence || true
        fi
        # TRUST MOAT (fail-CLOSED, parity with the default route ~17153): each gate
        # arm below is `type fn && ! fn`, so a missing gate fn (council library
        # failed to source) silently skips that gate and the chain can reach
        # council_vote and force-approve completion UNGATED -- a fake-green. Probe
        # the core gate fns first; if any is missing, refuse the force-review
        # approval and fall through to continue iterating. No-op on healthy loads.
        _frgate_loadable=1
        for _frgate_fn in council_checklist_gate council_evidence_gate council_heldout_gate council_assumption_ledger_gate; do
            if ! type "$_frgate_fn" &>/dev/null; then
                log_error "Council force-review: gate unavailable: ${_frgate_fn} (council library incomplete)."
                _frgate_loadable=0
            fi
        done
        if [ "$_frgate_loadable" -eq 0 ]; then
            log_error "Council force-review: required gates missing; refusing force approval (fail-closed)."
            unset _frgate_loadable _frgate_fn
        elif type council_checklist_gate &>/dev/null && ! council_checklist_gate; then
            unset _frgate_loadable _frgate_fn
            log_info "Council force-review: blocked by checklist hard gate"
        elif type council_evidence_gate &>/dev/null && ! _evidence_gate_and_surface; then
            log_info "Council force-review: blocked by evidence hard gate"
        elif type council_heldout_gate &>/dev/null && ! council_heldout_gate; then
            log_info "Council force-review: blocked by held-out spec-eval hard gate"
        elif type council_assumption_ledger_gate &>/dev/null && ! council_assumption_ledger_gate; then
            log_info "Council force-review: blocked by assumption ledger hard gate"
        elif {
            if loki_is_supervised_simple_web; then
                _loki_supervised_completion_gates_pass "${gate_failures:-}"
            else
                type council_vote &>/dev/null && council_vote
            fi
        }; then
            if loki_is_supervised_simple_web; then
                log_header "SUPERVISED FORCE REVIEW: PROJECT COMPLETE"
            else
                log_header "COMPLETION COUNCIL: FORCE REVIEW - PROJECT COMPLETE"
            fi
            # BUG #17 fix: Write COMPLETED marker, generate council report, and
            # run memory consolidation (matching the normal council approval path
            # in council_should_stop).
            echo "Council force-review approved at iteration $ITERATION_COUNT on $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$loki_dir/COMPLETED"
            if ! loki_is_supervised_simple_web && type council_write_report &>/dev/null; then
                council_write_report
            fi
            log_info "Running memory consolidation..."
            run_memory_consolidation
            # Delegate-then-notify: force-review approval is a real completion
            # (returns 2, which the run loop maps to a clean return 0). Treat it
            # like the other success exits: optional local PR + summary + ping.
            on_run_complete
            emit_completion_summary complete
            if loki_is_supervised_simple_web; then
                save_state ${RETRY_COUNT:-0} "deterministic_gates_passed" 0
            else
                save_state ${RETRY_COUNT:-0} "council_force_approved" 0
            fi
            return 2  # Stop
        fi
        log_info "Council force-review: voted to continue"
    fi

    # Check for STOP file (immediate stop)
    if [ -f "$loki_dir/STOP" ]; then
        log_warn "STOP file detected - stopping execution"
        rm -f "$loki_dir/STOP"
        # Delegate-then-notify: an explicit STOP file is a deliberate stop, but
        # a detached (--bg) user still benefits from a summary of partial work.
        # NOTE: the SIGTERM/`loki stop` group-kill path (cleanup handler near the
        # end of this file) is intentionally NOT notified: that user is at a
        # terminal issuing the stop and is already aware.
        emit_completion_summary stopped
        return 2
    fi

    return 0
}

# Handle pause state - wait for resume
handle_pause() {
    # BUG-ST-007: Guard against concurrent pause handler execution
    if [ "${_PAUSE_IN_PROGRESS:-0}" -eq 1 ]; then
        return 0
    fi
    _PAUSE_IN_PROGRESS=1

    PAUSED=true
    local loki_dir="${TARGET_DIR:-.}/.loki"

    # Human-intervention counter for the Evidence Receipt.
    #
    # Counted HERE because this is the single point where a run actually blocks
    # waiting on a person -- every pause path funnels through handle_pause, and
    # the _PAUSE_IN_PROGRESS guard above means one blocking pause counts once.
    # Counting at the PAUSE-file write sites instead would over-count
    # auto-cleared perpetual-mode pauses, which no human ever saw.
    #
    # proof-generator reads this; when the file is absent the receipt renders NO
    # interventions row rather than claiming zero. Best-effort: never fail a run.
    if [ -n "${loki_dir}" ]; then
        mkdir -p "$loki_dir/state" 2>/dev/null || true
        local _iv_file="$loki_dir/state/interventions.json"
        local _iv_prev=0
        if [ -f "$_iv_file" ]; then
            _iv_prev=$(python3 -c "import json,sys
try:
    v=json.load(open(sys.argv[1])).get('count')
    print(int(v) if isinstance(v,int) and v>=0 else 0)
except Exception:
    print(0)" "$_iv_file" 2>/dev/null || echo 0)
        fi
        printf '{"count":%s,"basis":"blocking pauses that waited on a human"}\n' \
            "$(( _iv_prev + 1 ))" > "$_iv_file" 2>/dev/null || true
    fi

    # Save state before pausing so it persists across potential crashes
    save_state ${RETRY_COUNT:-0} "paused" 0

    log_header "Execution Paused"
    echo ""
    log_info "To resume: Remove .loki/PAUSE or press Enter"
    log_info "To add instructions: echo 'your instructions' > .loki/HUMAN_INPUT.md"
    log_info "To stop completely: touch .loki/STOP"
    echo ""

    # Create resume instructions file
    cat > "$loki_dir/PAUSED.md" << 'EOF'
# Loki Mode - Paused

Execution is currently paused. Options:

1. **Resume**: Press Enter in terminal or `rm .loki/PAUSE`
2. **Add Instructions**: `echo "Focus on fixing the login bug" > .loki/HUMAN_INPUT.md`
3. **Stop**: `touch .loki/STOP`

Current state is saved. You can inspect:
- `.loki/CONTINUITY.md` - Progress and context
- `.loki/STATUS.txt` - Current status
- `.loki/logs/` - Session logs
EOF

    # Say WHY it paused. The block above is static boilerplate: a user who comes
    # back to a paused run learns how to resume but not what stopped it, and has
    # to go spelunking in .loki/signals to find out. Observed on a PRD benchmark
    # where a gate escalation paused a run that had produced 9 files and 28
    # passing tests -- the pause was correct, the report told them nothing.
    # Best-effort and append-only: never let a formatting failure block a pause.
    {
        _pause_reason_file="$loki_dir/signals/GATE_ESCALATION"
        if [ -s "$_pause_reason_file" ]; then
            printf '\n## Why this paused\n\n'
            printf 'A quality gate stayed blocked across repeated attempts, so Loki stopped\n'
            printf 'rather than keep spending on a loop that was not converging.\n\n'
            _esc_gate="$(python3 -c 'import json,sys
try:
    d = json.load(open(sys.argv[1]))
    print("%s (failed %s times, threshold %s)" % (d.get("gate","?"), d.get("count","?"), d.get("threshold","?")))
except Exception:
    print("")' "$loki_dir/signals/GATE_ESCALATION.json" 2>/dev/null || true)"
            [ -n "$_esc_gate" ] && printf -- '- Gate: %s\n' "$_esc_gate"
            _esc_art="$(python3 -c 'import json,sys
try:
    print(json.load(open(sys.argv[1])).get("latest_artifact","") or "")
except Exception:
    print("")' "$loki_dir/signals/GATE_ESCALATION.json" 2>/dev/null || true)"
            [ -n "$_esc_art" ] && printf -- '- Findings: %s\n' "$_esc_art"
            printf '\nRead the findings, fix what they name, then resume. Resuming without a\nchange will most likely stop at the same gate again.\n'
        fi
        # What DID get built, so a paused run never reads as "nothing happened".
        _pause_head="$( (cd "${TARGET_DIR:-.}" && git rev-parse --verify HEAD) 2>/dev/null || true )"
        if [ -n "$_pause_head" ]; then
            _pause_base="$( (cd "${TARGET_DIR:-.}" && git hash-object -t tree /dev/null) 2>/dev/null || true )"
            [ -s "$loki_dir/state/start-sha" ] && _pause_base="$(cat "$loki_dir/state/start-sha" 2>/dev/null)"
            _pause_stat="$( (cd "${TARGET_DIR:-.}" && git diff --shortstat "${_pause_base}..HEAD" -- . ':(exclude).loki/') 2>/dev/null || true )"
            if [ -n "$_pause_stat" ]; then
                printf '\n## What was built before the pause\n\n%s\n' "$_pause_stat"
                printf '\nReview it with:\n  git diff %s..HEAD -- . ":(exclude).loki/"\n' "$_pause_base"
            fi
        fi
    } >> "$loki_dir/PAUSED.md" 2>/dev/null || true

    # Wait for resume signal (unified: file removal, keyboard, or STOP)
    while [ "$PAUSED" = "true" ]; do
        # Check for stop signal
        if [ -f "$loki_dir/STOP" ]; then
            rm -f "$loki_dir/STOP" "$loki_dir/PAUSED.md"
            PAUSED=false
            _PAUSE_IN_PROGRESS=0
            return 1
        fi

        # Check if PAUSE file was removed (by CLI, API, or dashboard)
        if [ ! -f "$loki_dir/PAUSE" ]; then
            PAUSED=false
            break
        fi

        # Check for any key press (non-blocking)
        if read -t 1 -n 1 2>/dev/null; then
            rm -f "$loki_dir/PAUSE"
            PAUSED=false
            break
        fi

        sleep 1
    done

    rm -f "$loki_dir/PAUSED.md"
    log_info "Resuming execution..."
    PAUSED=false
    _PAUSE_IN_PROGRESS=0
    return 0
}

#===============================================================================
# Cleanup Handler (with Ctrl+C pause support)
#===============================================================================

# BUG-XC-007: Guard against re-entrant signal handler execution
_CLEANUP_IN_PROGRESS=0
_LOKI_SESSION_LOCK_FILE=""

_loki_remove_temp_self_copy() {
    local temp_copy="${LOKI_TEMP_SCRIPT_PATH:-}"
    [ -n "$temp_copy" ] || return 0
    case "$temp_copy" in
        /tmp/loki-run-*) rm -f "$temp_copy" 2>/dev/null || true; return 0 ;;
    esac
    if [ -n "${TMPDIR:-}" ]; then
        case "$temp_copy" in
            "${TMPDIR%/}"/loki-run-*) rm -f "$temp_copy" 2>/dev/null || true ;;
        esac
    fi
}

# P0 (stale-pgid session killer): the pgid file must never outlive its run.
# Recorded by the writer (see _LOKI_PGID_FILE below) rather than re-derived, so
# the removal hits the exact path written -- global OR per-session -- and can
# never delete a concurrent run's file.
_loki_remove_pgid_file() {
    [ -n "${_LOKI_PGID_FILE:-}" ] || return 0
    # A `trap ... EXIT` fires in SUBSHELLS too, and a subshell exiting must not
    # delete a LIVE parent's file (repo scar: a bare trap deleted a parent's
    # lock). $$ does NOT change in a subshell -- BASHPID does, so it is the only
    # real discriminator here.
    [ "${BASHPID:-$$}" = "${_LOKI_PGID_OWNER:-$$}" ] || return 0
    rm -f "$_LOKI_PGID_FILE" 2>/dev/null || true
}

_loki_session_exit_cleanup() {
    local exit_code=$?
    _loki_terminal_record 2>/dev/null || true
    if [ -n "${_LOKI_SESSION_LOCK_FILE:-}" ] \
       && type safe_release_lock >/dev/null 2>&1; then
        safe_release_lock "$_LOKI_SESSION_LOCK_FILE" 2>/dev/null || true
    fi
    # Covers EXIT plus INT/TERM: every cleanup() path that actually terminates
    # does so via `exit`, which fires this trap. Deliberately NOT called from
    # cleanup() itself -- its perpetual/pause branches RETURN and the run
    # continues, so removing the file there would silently disable the
    # completion-path reap for the rest of the run.
    _loki_remove_pgid_file
    _loki_remove_temp_self_copy
    return "$exit_code"
}

_loki_arm_session_exit_cleanup() {
    _LOKI_SESSION_LOCK_FILE="${1:-}"
    trap '_loki_session_exit_cleanup' EXIT
}

_loki_install_signal_traps() {
    trap 'cleanup INT' INT
    trap 'cleanup TERM' TERM
}

_loki_write_termination_record() {
    local signal_name="${1:-TERM}"
    local exit_code="${2:-143}"
    local termination_file="${TARGET_DIR:-.}/.loki/state/termination.json"
    mkdir -p "$(dirname "$termination_file")" 2>/dev/null || true
    LOKI_TERMINATION_FILE="$termination_file" \
    LOKI_TERMINATION_SIGNAL="$signal_name" \
    LOKI_TERMINATION_EXIT_CODE="$exit_code" \
    python3 -c "
import datetime, json, os, tempfile
path = os.environ['LOKI_TERMINATION_FILE']
record = {
    'terminated': True,
    'status': 'interrupted',
    'reason': 'supervisor_signal',
    'signal': os.environ.get('LOKI_TERMINATION_SIGNAL', 'TERM'),
    'exit_code': int(os.environ.get('LOKI_TERMINATION_EXIT_CODE', '143')),
    'timestamp': datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00', 'Z'),
}
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix='.termination.', suffix='.tmp')
with os.fdopen(fd, 'w') as handle:
    json.dump(record, handle, sort_keys=True)
os.replace(tmp, path)
" 2>/dev/null || true
}

cleanup() {
    local signal_name="${1:-}"
    # Prevent re-entrant execution
    if [ "$_CLEANUP_IN_PROGRESS" -eq 1 ]; then
        return
    fi
    _CLEANUP_IN_PROGRESS=1

    # Block further signals during critical cleanup operations
    trap '' INT TERM

    local current_time=$(date +%s)
    local time_diff=$((current_time - INTERRUPT_LAST_TIME))
    local loki_dir="${TARGET_DIR:-.}/.loki"
    local supervised_signal=false
    local final_status="stopped"
    local final_reason="stop_requested"
    local final_exit_code=0
    if [ "${LOKI_SUPERVISED_BUILD:-0}" = "1" ]; then
        case "$signal_name" in
            INT)
                supervised_signal=true
                final_status="interrupted"
                final_reason="supervisor_signal"
                final_exit_code=130
                ;;
            TERM)
                supervised_signal=true
                final_status="interrupted"
                final_reason="supervisor_signal"
                final_exit_code=143
                ;;
        esac
    fi

    # A supervisor signal is a terminal build event, not an interactive pause.
    # A STOP file keeps its existing graceful stop semantics.
    if [ "$supervised_signal" = "true" ] || [ -f "$loki_dir/STOP" ]; then
        echo ""
        if [ "$supervised_signal" = "true" ]; then
            log_warn "Loki Mode interrupted - shutting down on supervisor $signal_name"
        else
            log_warn "Loki Mode interrupted - shutting down (STOP signal)"
        fi
        # v7.5.12: Kill any running provider pipeline first, before slow cleanup.
        kill_provider_child 2>/dev/null || true
        rm -f "$loki_dir/STOP" "$loki_dir/PAUSE" "$loki_dir/PAUSED.md" 2>/dev/null
        # UT2-13: Clear cli-provider marker on session end.
        rm -f "$loki_dir/state/cli-provider" 2>/dev/null || true
        if type app_runner_cleanup &>/dev/null; then
            app_runner_cleanup
        fi
        stop_status_monitor
        # v7.7.30: tear down this project's dashboard contribution on a
        # deliberate STOP-file exit. stop_dashboard handles the project-local
        # dashboard (.loki/dashboard/dashboard.pid); the helper marks this
        # project stopped in the registry and kills the shared dashboard only
        # when no other project is still running.
        stop_dashboard
        loki_mark_project_stopped_and_maybe_kill_shared_dashboard
        kill_all_registered
        rm -f "$loki_dir/loki.pid" 2>/dev/null
        # Clean up per-session PID file if running with session ID
        if [ -n "${LOKI_SESSION_ID:-}" ]; then
            rm -f "$loki_dir/sessions/${LOKI_SESSION_ID}/loki.pid" 2>/dev/null
        fi
        if [ -f "$loki_dir/session.json" ]; then
            # BUG-ST-008: Atomic session.json update via temp file + mv
            _LOKI_SESSION_FILE="$loki_dir/session.json" \
            _LOKI_FINAL_STATUS="$final_status" \
            python3 -c "
import json, os, tempfile
sf = os.environ['_LOKI_SESSION_FILE']
try:
    with open(sf) as f:
        d = json.load(f)
    d['status'] = os.environ.get('_LOKI_FINAL_STATUS', 'stopped')
    sd = os.path.dirname(sf)
    fd, tmp = tempfile.mkstemp(dir=sd, suffix='.json')
    with os.fdopen(fd, 'w') as f:
        json.dump(d, f)
    os.replace(tmp, sf)
except (json.JSONDecodeError, OSError): pass
" 2>/dev/null || true
        fi
        save_state ${RETRY_COUNT:-0} "$final_status" "$final_exit_code"
        if [ "$supervised_signal" = "true" ]; then
            _loki_write_termination_record "$signal_name" "$final_exit_code"
        fi
        emit_event_json "session_end" "result=$final_exit_code" "reason=$final_reason"
        # An interrupted run still produced agent output, and this teardown is
        # the only exit it takes -- it never reaches the post-loop capture.
        # Backgrounded here (and ONLY here) because this runs inside a signal
        # handler, where a blocking git call would stall the shutdown the user
        # just asked for. Backgrounding is safe at this site specifically
        # because the capture is ORDERING-INDEPENDENT: it baselines to the
        # run-start SHA, not to a moving HEAD, and nothing between here and
        # process exit commits -- so it records the same diff whenever the
        # subshell lands. Do NOT copy this backgrounding to the post-loop site,
        # where completing before the tree mutates is the entire point.
        ( capture_preedit_snapshot >/dev/null 2>&1 </dev/null & ) 2>/dev/null || true
        if [ "$supervised_signal" = "true" ] \
           && [ "${LOKI_PROOF:-1}" != "0" ] \
           && type generate_proof_of_run >/dev/null 2>&1; then
            generate_proof_of_run "$final_exit_code" || true
        fi
        if [ "$supervised_signal" = "true" ]; then
            log_info "Interrupted state and proof receipt saved."
        else
            log_info "Session stopped."
        fi
        exit "$final_exit_code"
    fi

    # If double Ctrl+C within 2 seconds, exit immediately
    if [ "$time_diff" -lt 2 ] && [ "$INTERRUPT_COUNT" -gt 0 ]; then
        echo ""
        log_warn "Loki Mode interrupted -- shutting down (double Ctrl+C)"
        # v7.5.12: Kill provider pipeline immediately so we don't wait on it.
        kill_provider_child 2>/dev/null || true
        # Write STOP signal so any peer processes (dashboard, etc.) also stop.
        mkdir -p "$loki_dir" 2>/dev/null && touch "$loki_dir/STOP" 2>/dev/null || true
        if type app_runner_cleanup &>/dev/null; then
            app_runner_cleanup
        fi
        stop_status_monitor
        # v7.7.30: tear down this project's dashboard contribution on a
        # deliberate double-Ctrl+C exit. stop_dashboard handles the
        # project-local dashboard; the helper marks this project stopped in
        # the registry and kills the shared dashboard only when no other
        # project is still running.
        stop_dashboard
        loki_mark_project_stopped_and_maybe_kill_shared_dashboard
        kill_all_registered
        rm -f "$loki_dir/loki.pid" "$loki_dir/PAUSE" 2>/dev/null
        # UT2-13: Clear cli-provider marker on session end.
        rm -f "$loki_dir/state/cli-provider" 2>/dev/null || true
        # Clean up per-session PID file if running with session ID
        if [ -n "${LOKI_SESSION_ID:-}" ]; then
            rm -f "$loki_dir/sessions/${LOKI_SESSION_ID}/loki.pid" 2>/dev/null
        fi
        # Mark session.json as stopped
        if [ -f "$loki_dir/session.json" ]; then
            # BUG-ST-008: Atomic session.json update via temp file + mv
            _LOKI_SESSION_FILE="$loki_dir/session.json" python3 -c "
import json, os, tempfile
sf = os.environ['_LOKI_SESSION_FILE']
try:
    with open(sf) as f:
        d = json.load(f)
    d['status'] = 'stopped'
    sd = os.path.dirname(sf)
    fd, tmp = tempfile.mkstemp(dir=sd, suffix='.json')
    with os.fdopen(fd, 'w') as f:
        json.dump(d, f)
    os.replace(tmp, sf)
except (json.JSONDecodeError, OSError): pass
" 2>/dev/null || true
        fi
        save_state ${RETRY_COUNT:-0} "interrupted" 130
        emit_event_json "session_end" "result=130" "reason=interrupted"
        log_info "State saved. Run again to resume."
        exit 130
    fi

    # Re-enable signals for pause mode
    _CLEANUP_IN_PROGRESS=0
    _loki_install_signal_traps

    # Check if this signal was caused by a child process dying (e.g., dashboard)
    # rather than an actual user interrupt. In that case, handle silently.
    if is_child_process_signal; then
        log_info "Child process exit detected, handled silently"
        # Do NOT reset INTERRUPT_COUNT -- preserves double-Ctrl+C escape capability
        return
    fi

    # In perpetual/autonomous mode: NEVER pause, NEVER wait for input
    # v7.5.12: A single Ctrl+C now interrupts the *current provider invocation*
    # (so the user can abort a hung iteration) but lets the loop continue.
    # A second Ctrl+C within 2s exits via the double-interrupt branch above.
    if [ "$AUTONOMY_MODE" = "perpetual" ] || [ "$PERPETUAL_MODE" = "true" ]; then
        INTERRUPT_COUNT=$((INTERRUPT_COUNT + 1))
        INTERRUPT_LAST_TIME=$current_time
        echo ""
        if [ "$LOKI_PROVIDER_ACTIVE" -eq 1 ]; then
            log_warn "Interrupt received -- killing current provider invocation"
            kill_provider_child 2>/dev/null || true
        else
            log_warn "Interrupt received in perpetual mode -- iteration will continue"
        fi
        log_info "Press Ctrl+C again within 2 seconds to exit, or touch .loki/STOP"
        echo ""
        # Check and restart dashboard if it died
        handle_dashboard_crash
        # Do NOT reset INTERRUPT_COUNT -- let it accumulate so double-Ctrl+C escape works
        return
    fi

    # In checkpoint mode: only pause at explicit checkpoint boundaries, not on
    # random signals. A signal during normal execution is treated as noise.
    if [ "$AUTONOMY_MODE" = "checkpoint" ]; then
        INTERRUPT_COUNT=$((INTERRUPT_COUNT + 1))
        INTERRUPT_LAST_TIME=$current_time
        echo ""
        log_warn "Interrupt received in checkpoint mode - will pause at next checkpoint"
        log_info "To stop immediately: press Ctrl+C again within 2 seconds"
        echo ""
        # Mark that a pause was requested for the next checkpoint
        touch "${TARGET_DIR:-.}/.loki/PAUSE_AT_CHECKPOINT"
        handle_dashboard_crash
        # Do NOT reset INTERRUPT_COUNT -- let it accumulate so double-Ctrl+C escape works
        return
    fi

    # Supervised mode (or unrecognized): original behavior - pause and show options
    INTERRUPT_COUNT=$((INTERRUPT_COUNT + 1))
    INTERRUPT_LAST_TIME=$current_time

    echo ""
    log_warn "Interrupt received - pausing..."
    log_info "Press Ctrl+C again within 2 seconds to exit"
    log_info "Or wait to add instructions..."
    echo ""

    # Create pause state
    touch "${TARGET_DIR:-.}/.loki/PAUSE"
    handle_pause

    # Reset interrupt count after pause
    INTERRUPT_COUNT=0
}

#===============================================================================
# Main Entry Point
#===============================================================================

main() {
    _loki_install_signal_traps
    SESSION_START_EPOCH=$(date +%s)

    # First-run disclosure (shown once, before any work; best-effort).
    if type loki_show_disclosure_once &>/dev/null; then
        loki_show_disclosure_once
    fi

    # Legacy ASCII "LOKI MODE" banner. Skipped when the new `loki start` handoff
    # already rendered the Autonomi brand banner (LOKI_BRAND_SHOWN=1) so the user
    # does not see two banners back to back. Still shown on the non-handoff path
    # (CI / --bg / --yes / non-interactive / direct run.sh), unchanged.
    if [ "${LOKI_BRAND_SHOWN:-}" != "1" ]; then
        echo ""
        echo -e "${BOLD}${BLUE}"
        echo "  ██╗      ██████╗ ██╗  ██╗██╗    ███╗   ███╗ ██████╗ ██████╗ ███████╗"
        echo "  ██║     ██╔═══██╗██║ ██╔╝██║    ████╗ ████║██╔═══██╗██╔══██╗██╔════╝"
        echo "  ██║     ██║   ██║█████╔╝ ██║    ██╔████╔██║██║   ██║██║  ██║█████╗  "
        echo "  ██║     ██║   ██║██╔═██╗ ██║    ██║╚██╔╝██║██║   ██║██║  ██║██╔══╝  "
        echo "  ███████╗╚██████╔╝██║  ██╗██║    ██║ ╚═╝ ██║╚██████╔╝██████╔╝███████╗"
        echo "  ╚══════╝ ╚═════╝ ╚═╝  ╚═╝╚═╝    ╚═╝     ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝"
        echo -e "${NC}"
        echo -e "  ${CYAN}Autonomous Spec-to-Product System${NC}"
        echo -e "  ${CYAN}Version: $(cat "$PROJECT_DIR/VERSION" 2>/dev/null || echo "4.x.x")${NC}"
        echo ""
    fi

    # Parse arguments
    PRD_PATH=""
    REMAINING_ARGS=()
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --parallel)
                PARALLEL_MODE=true
                shift
                ;;
            --allow-haiku)
                export LOKI_ALLOW_HAIKU=true
                log_info "Haiku model enabled for fast tier"
                shift
                ;;
            --provider)
                if [[ -n "${2:-}" ]]; then
                    LOKI_PROVIDER="$2"
                    # Reload provider config
                    if [ -f "$PROVIDERS_DIR/loader.sh" ]; then
                        if ! validate_provider "$LOKI_PROVIDER"; then
                            log_error "Unknown provider: $LOKI_PROVIDER"
                            log_info "Supported providers: ${SUPPORTED_PROVIDERS[*]}"
                            exit 1
                        fi
                        if ! load_provider "$LOKI_PROVIDER"; then
                            log_error "Failed to load provider config: $LOKI_PROVIDER"
                            exit 1
                        fi
                    fi
                    shift 2
                else
                    log_error "--provider requires a value (claude, codex, cline, aider)"
                    exit 1
                fi
                ;;
            --provider=*)
                LOKI_PROVIDER="${1#*=}"
                # Reload provider config
                if [ -f "$PROVIDERS_DIR/loader.sh" ]; then
                    if ! validate_provider "$LOKI_PROVIDER"; then
                        log_error "Unknown provider: $LOKI_PROVIDER"
                        log_info "Supported providers: ${SUPPORTED_PROVIDERS[*]}"
                        exit 1
                    fi
                    if ! load_provider "$LOKI_PROVIDER"; then
                        log_error "Failed to load provider config: $LOKI_PROVIDER"
                        exit 1
                    fi
                fi
                shift
                ;;
            --bg|--background)
                BACKGROUND_MODE=true
                shift
                ;;
            --interactive-prd|--interactive)
                LOKI_INTERACTIVE_PRD=true
                shift
                ;;
            --help|-h)
                echo "Usage: ./autonomy/run.sh [OPTIONS] [PRD_PATH]"
                echo ""
                echo "Options:"
                echo "  --parallel           Enable git worktree-based parallel workflows"
                echo "  --allow-haiku        Enable Haiku model for fast tier (default: disabled)"
                echo "  --provider <name>    Provider: claude (default), codex, cline, aider"
                echo "  --bg, --background   Run in background mode"
                echo "  --interactive-prd    Interactive PRD pre-flight analysis"
                echo "  --help, -h           Show this help message"
                echo ""
                echo "Environment variables: See header comments in this script"
                echo ""
                echo "Provider capabilities:"
                if [ -f "$PROVIDERS_DIR/loader.sh" ]; then
                    print_capability_matrix
                fi
                exit 0
                ;;
            *)
                if [ -z "$PRD_PATH" ] && [[ ! "$1" == -* ]]; then
                    PRD_PATH="$1"
                fi
                REMAINING_ARGS+=("$1")
                shift
                ;;
        esac
    done
    # Safe expansion for empty arrays with set -u
    if [ ${#REMAINING_ARGS[@]} -gt 0 ]; then
        set -- "${REMAINING_ARGS[@]}"
    else
        set --
    fi

    # Re-apply the scoped-change profile now that PRD_PATH is known.
    #
    # The module-scope call runs at source time, BEFORE this argument parsing,
    # so the spec path was always empty there and an issue-sourced build could
    # never be recognised. Re-running it here is idempotent: the profile assigns
    # with := so anything already set (including an explicit operator override)
    # is left untouched, and a non-scoped run still returns immediately.
    loki_apply_scoped_change_profile "$PRD_PATH"

    # Validate PRD if provided
    if [ -n "$PRD_PATH" ] && [ ! -f "$PRD_PATH" ]; then
        log_error "PRD file not found: $PRD_PATH"
        exit 1
    fi
    if ! _loki_bind_supervised_spec_sha "$PRD_PATH"; then
        return 20
    fi

    # v7.82: one-line "Building:" headline under the start banner so the opening
    # frame reflects the user's own intent (their PRD / brief / this codebase),
    # not a generic banner. Display-only, derived from already-resolved values:
    # PRD basename for a file, the recorded brief text for a brief run, or fixed
    # text for a no-arg in-repo run. Truncated to one tidy line. Gated to an
    # interactive TTY, not --bg, and opt-out via LOKI_START_HEADLINE=0, so the
    # off-TTY / background path is byte-identical (no output). Best-effort; a
    # failure here must never affect parsing or the build flow.
    if [ -t 1 ] && [ "${BACKGROUND_MODE:-false}" != "true" ] && [ "${LOKI_START_HEADLINE:-1}" != "0" ]; then
        local _headline=""
        if [ -n "$PRD_PATH" ]; then
            _headline="$(basename "$PRD_PATH" 2>/dev/null || echo "$PRD_PATH")"
        elif [ -f ".loki/state/brief.txt" ]; then
            # Recorded one-line brief (written by cmd_start). Collapse to a single
            # line and truncate to ~60 chars so the banner stays clean.
            local _brief
            _brief="$(tr '\n' ' ' < .loki/state/brief.txt 2>/dev/null | sed 's/  */ /g; s/^ //; s/ $//')"
            if [ -n "$_brief" ]; then
                if [ "${#_brief}" -gt 60 ]; then
                    _brief="${_brief:0:57}..."
                fi
                _headline="\"$_brief\""
            fi
        fi
        [ -z "$_headline" ] && _headline="analyzing this codebase"
        echo -e "  ${BOLD}${CYAN}Building: ${_headline}${NC}"
        echo ""
    fi

    # Handle background mode
    if [ "$BACKGROUND_MODE" = "true" ]; then
        # Initialize .loki directory first
        mkdir -p .loki/logs

        local log_file=".loki/logs/background-$(date +%Y%m%d-%H%M%S).log"
        local pid_file
        if [ -n "${LOKI_SESSION_ID:-}" ]; then
            mkdir -p ".loki/sessions/${LOKI_SESSION_ID}"
            pid_file=".loki/sessions/${LOKI_SESSION_ID}/loki.pid"
        else
            pid_file=".loki/loki.pid"
        fi
        local project_path=$(pwd)
        local project_name=$(basename "$project_path")

        echo ""
        log_info "Starting Loki Mode in background..."

        # Build command without --bg flag
        local cmd_args=()
        [ -n "$PRD_PATH" ] && cmd_args+=("$PRD_PATH")
        [ "$PARALLEL_MODE" = "true" ] && cmd_args+=("--parallel")
        [ -n "$LOKI_PROVIDER" ] && cmd_args+=("--provider" "$LOKI_PROVIDER")
        [ "${LOKI_ALLOW_HAIKU:-}" = "true" ] && cmd_args+=("--allow-haiku")

        # Run in background using the ORIGINAL script (not the temp copy)
        # CRITICAL: Unset LOKI_RUNNING_FROM_TEMP so the background process does its own self-copy
        # Otherwise it would run directly from the original file and the trap would delete it
        local original_script="$SCRIPT_DIR/run.sh"
        # v7.7.34: launch the backgrounded runner as its own session leader so
        # its agent tree shares one process group, killable atomically on stop.
        # Prefer setsid (Linux/Docker), then perl, then python3, then plain nohup.
        local _sess_launcher=""
        if [ "${LOKI_NO_NEW_SESSION:-}" != "1" ]; then
            if command -v setsid >/dev/null 2>&1; then _sess_launcher="setsid"
            elif command -v perl >/dev/null 2>&1; then _sess_launcher="perl-setsid"
            elif command -v python3 >/dev/null 2>&1; then _sess_launcher="python-setsid"; fi
        fi
        # Background mode is never interactive, so a new session is always safe
        # and desirable (detaches from the tty and gives a dedicated group for
        # stop). Export LOKI_OWN_SESSION=1 so the backgrounded runner records its
        # pgid.
        case "$_sess_launcher" in
            setsid)
                LOKI_RUNNING_FROM_TEMP='' LOKI_OWN_SESSION=1 LOKI_LAUNCHER_PID=$$ nohup setsid "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
            perl-setsid)
                LOKI_RUNNING_FROM_TEMP='' LOKI_OWN_SESSION=1 LOKI_LAUNCHER_PID=$$ nohup perl -e 'use POSIX qw(setsid); setsid(); exec @ARGV or exit 127;' "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
            python-setsid)
                LOKI_RUNNING_FROM_TEMP='' LOKI_OWN_SESSION=1 LOKI_LAUNCHER_PID=$$ nohup python3 -c 'import os,sys; os.setsid(); os.execvp(sys.argv[1], sys.argv[1:])' "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
            *)
                LOKI_RUNNING_FROM_TEMP='' LOKI_LAUNCHER_PID=$$ nohup "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
        esac
        local bg_pid=$!
        echo "$bg_pid" > "$pid_file"
        register_pid "$bg_pid" "background-session" "log=$log_file"

        echo ""
        echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
        echo -e "${GREEN}  Loki Mode Running in Background${NC}"
        echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
        echo ""
        echo -e "  ${CYAN}Project:${NC}    $project_name"
        echo -e "  ${CYAN}Path:${NC}       $project_path"
        echo -e "  ${CYAN}PID:${NC}        $bg_pid"
        echo -e "  ${CYAN}Log:${NC}        $log_file"
        echo -e "  ${CYAN}Dashboard:${NC}  http://127.0.0.1:${DASHBOARD_PORT}/"
        echo ""
        echo -e "${YELLOW}Control Commands:${NC}"
        echo -e "  ${DIM}Pause:${NC}      touch .loki/PAUSE"
        echo -e "  ${DIM}Resume:${NC}     rm .loki/PAUSE"
        echo -e "  ${DIM}Stop:${NC}       touch .loki/STOP  ${DIM}or${NC}  kill $bg_pid"
        echo -e "  ${DIM}Logs:${NC}       tail -f $log_file"
        echo -e "  ${DIM}Status:${NC}     cat .loki/STATUS.txt"
        echo ""
        echo -e "${GREEN}You will be notified when done (or if input is needed).${NC}"
        echo -e "  ${DIM}Summary on completion:${NC} cat .loki/COMPLETION.txt"
        echo ""

        exit 0
    fi

    # Show provider info
    log_info "Provider: ${PROVIDER_DISPLAY_NAME:-Claude Code} (${PROVIDER_NAME:-claude})"
    if [ "${PROVIDER_DEGRADED:-false}" = "true" ]; then
        # Report what is ACTUALLY missing, read from the capability flags, rather
        # than asserting a fixed pair. The old wording hardcoded "Parallel agents
        # and Task tool not available" for every degraded provider, which stops
        # being true the moment one of them gains a capability: a provider whose
        # CLI really does support concurrent non-interactive runs would still be
        # told it does not. A warning that can be wrong is worse than one that
        # names its evidence, because users calibrate on it.
        local _degraded_missing=""
        [ "${PROVIDER_HAS_PARALLEL:-false}" != "true" ] && _degraded_missing="parallel agents"
        if [ "${PROVIDER_HAS_TASK_TOOL:-false}" != "true" ]; then
            _degraded_missing="${_degraded_missing}${_degraded_missing:+, }Task tool"
        fi
        if [ "${PROVIDER_HAS_SUBAGENTS:-false}" != "true" ]; then
            _degraded_missing="${_degraded_missing}${_degraded_missing:+, }subagents"
        fi
        if [ -n "$_degraded_missing" ]; then
            log_warn "Degraded mode: ${_degraded_missing} not available"
        else
            log_warn "Degraded mode: reduced capability (see limitations below)"
        fi
        # Check if array exists and has elements before iterating
        if [ -n "${PROVIDER_DEGRADED_REASONS+x}" ] && [ ${#PROVIDER_DEGRADED_REASONS[@]} -gt 0 ]; then
            log_info "Limitations:"
            for reason in "${PROVIDER_DEGRADED_REASONS[@]}"; do
                log_info "  - $reason"
            done
        fi
    fi

    # Show parallel mode status
    if [ "$PARALLEL_MODE" = "true" ]; then
        if [ "${PROVIDER_HAS_PARALLEL:-false}" = "true" ]; then
            log_info "Parallel mode enabled (git worktrees)"
        else
            log_warn "Parallel mode requested but not supported by ${PROVIDER_NAME:-unknown}"
            log_warn "Running in sequential mode instead"
            PARALLEL_MODE=false
        fi
    fi

    # Hosted engine workspaces fail closed unless the selected provider can
    # enforce direct-command mistake prevention before every Bash tool call.
    # This hook is not a substitute for platform process isolation.
    if ! _loki_prepare_host_guard; then
        exit 1
    fi

    # Validate API keys for the selected provider
    if ! validate_api_keys; then
        exit 1
    fi

    # Check prerequisites (unless skipped)
    if [ "$SKIP_PREREQS" != "true" ]; then
        if ! check_prerequisites; then
            exit 1
        fi
    else
        log_warn "Skipping prerequisite checks (LOKI_SKIP_PREREQS=true)"
    fi

    # Check skill installation
    if ! check_skill_installed; then
        exit 1
    fi

    # Initialize .loki directory
    init_loki_dir
    if ! _loki_write_supervised_simple_web_policy; then
        log_error "Could not persist the supervised execution policy. Refusing to start."
        exit 1
    fi

    # Initialize session continuity file with empty template
    update_continuity

    # Session lock: prevent concurrent sessions
    # Per-session locking (v6.4.0): LOKI_SESSION_ID enables multiple concurrent
    # sessions (e.g., loki run 52 -d && loki run 54 -d). Each session gets its
    # own PID/lock files under .loki/sessions/<id>/.
    # Without LOKI_SESSION_ID, the global .loki/loki.pid lock is used (single session).
    local pid_file lock_file
    if [ -n "${LOKI_SESSION_ID:-}" ]; then
        mkdir -p ".loki/sessions/${LOKI_SESSION_ID}"
        pid_file=".loki/sessions/${LOKI_SESSION_ID}/loki.pid"
        lock_file=".loki/sessions/${LOKI_SESSION_ID}/session.lock"
    else
        pid_file=".loki/loki.pid"
        lock_file=".loki/session.lock"
    fi

    # Atomic session lock via mkdir-mutex (v7.5.12). Replaces flock-only
    # path that emitted "[WARN] flock not available ..." on macOS. The
    # mkdir-based lock is portable, atomic on POSIX, and self-heals via
    # PID-stamped sentinel + 30s mtime-based stale reaping.
    touch "$lock_file" 2>/dev/null || true
    if type safe_acquire_lock >/dev/null 2>&1; then
        if ! safe_acquire_lock "$lock_file" 5; then
            if [ -n "${LOKI_SESSION_ID:-}" ]; then
                log_error "Session '${LOKI_SESSION_ID}' is already running (locked)"
                log_error "Stop it first with: loki stop ${LOKI_SESSION_ID}"
            else
                log_error "Another Loki session is already running (locked)"
                log_error "Stop it first with: loki stop"
            fi
            exit 1
        fi
        # Release on session-process exit so a fresh `loki start` can
        # immediately re-acquire after this one finishes / is killed.
        # T2.6: piggyback the existing lock-release trap (we deliberately do NOT
        # add a new broad EXIT trap) to write a best-effort terminal record on an
        # untrapped non-zero exit where the run status is still "running" -- so a
        # post-crash `loki why` is not stale. _loki_terminal_record is a strict
        # no-op on every graceful path (status already settled) and never alters
        # the exit code. SIGKILL/power-loss stay uncatchable (no trap fires); the
        # ENT-2 durable-resume path covers those.
        # shellcheck disable=SC2064
        _loki_arm_session_exit_cleanup "$lock_file"

        # Check PID file after acquiring lock
        if [ -f "$pid_file" ]; then
            local existing_pid
            existing_pid=$(cat "$pid_file" 2>/dev/null)
            # Skip if it's our own PID or parent PID (background mode writes PID before child starts)
            if [ -n "$existing_pid" ] && [ "$existing_pid" != "$$" ] && [ "$existing_pid" != "$PPID" ] && kill -0 "$existing_pid" 2>/dev/null; then
                if [ -n "${LOKI_SESSION_ID:-}" ]; then
                    log_error "Session '${LOKI_SESSION_ID}' is already running (PID: $existing_pid)"
                    log_error "Stop it first with: loki stop ${LOKI_SESSION_ID}"
                else
                    log_error "Another Loki session is already running (PID: $existing_pid)"
                    log_error "Stop it first with: loki stop"
                fi
                exit 1
            fi
        fi
    else
        # Lock helper not loaded (lib/lock.sh missing). PID-only fallback.
        # This branch still falls through to the pgid writer below, so it needs
        # the same EXIT cleanup or the pgid file leaks on every interrupt here.
        # An empty lock file is safe: the release is [ -n ]-guarded.
        _loki_arm_session_exit_cleanup ""
        if [ -f "$pid_file" ]; then
            local existing_pid
            existing_pid=$(cat "$pid_file" 2>/dev/null)
            if [ -n "$existing_pid" ] && [ "$existing_pid" != "$$" ] && [ "$existing_pid" != "$PPID" ] && kill -0 "$existing_pid" 2>/dev/null; then
                if [ -n "${LOKI_SESSION_ID:-}" ]; then
                    log_error "Session '${LOKI_SESSION_ID}' is already running (PID: $existing_pid)"
                    log_error "Stop it first with: loki stop ${LOKI_SESSION_ID}"
                else
                    log_error "Another Loki session is already running (PID: $existing_pid)"
                    log_error "Stop it first with: loki stop"
                fi
                exit 1
            fi
        fi
    fi

    # Write PID file for ALL modes (foreground + background)
    echo "$$" > "$pid_file"
    # v7.7.34: record the orchestrator's process-group id next to the pid so the
    # stop paths can `kill -- -PGID` the whole tree (orchestrator + agent +
    # monitors) atomically, closing the orphaned-agent hole.
    # CRITICAL SAFETY: only record the pgid when this runner is its OWN session
    # leader (LOKI_OWN_SESSION=1, set by the launcher when it setsid'd). If we
    # did NOT create a new session (interactive foreground, where we keep the
    # controlling tty for Ctrl+C), the runner may SHARE the user's shell process
    # group, and group-killing it would kill the user's shell. In that case we
    # leave loki.pgid absent and stop relies on the cwd+sentinel agent sweep.
    if [ "${LOKI_OWN_SESSION:-}" = "1" ]; then
        _loki_pgid="$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')"
        if [ -n "$_loki_pgid" ]; then
            # STAMPED, not a bare number. The reader requires boot id + start
            # epoch to match before it will signal anything, because this file
            # outlived its run (measured: 155h and 202h orphans) and PIDs
            # recycle -- a stale bare pgid eventually matches a live shell group
            # and the reap killed the user's other terminal sessions.
            _loki_pgid_started="$(_loki_proc_start_epoch $$)"
            [ -n "$_loki_pgid_started" ] || _loki_pgid_started="$(date +%s 2>/dev/null)"
            # Record the EXACT path written (global or per-session) plus the
            # owning shell, so the EXIT trap removes THIS file and only from
            # this process. Without removal on the interrupt/crash paths the
            # file outlived its run -- measured orphans aged 155h and 202h.
            _LOKI_PGID_FILE="${pid_file%.pid}.pgid"
            _LOKI_PGID_OWNER="${BASHPID:-$$}"
            printf 'pgid=%s boot=%s started=%s\n' \
                "$_loki_pgid" "$(_loki_boot_id)" "$_loki_pgid_started" \
                > "$_LOKI_PGID_FILE" 2>/dev/null || true
        fi
    fi
    # Store session ID in state for dashboard/status visibility
    if [ -n "${LOKI_SESSION_ID:-}" ]; then
        echo "${LOKI_SESSION_ID}" > ".loki/sessions/${LOKI_SESSION_ID}/session_id"
    fi

    # Initialize PID registry, self-register this wrapper (loki-mode #92 -- so a
    # future session can reap it if this run is orphaned+idle), then clean up
    # orphans from previous sessions.
    init_pid_registry
    register_self_wrapper
    local orphan_count
    orphan_count=$(cleanup_orphan_pids)
    if [ "$orphan_count" -gt 0 ]; then
        log_warn "Killed $orphan_count orphaned process(es) from previous session"
    fi

    # Copy skill files to .loki/skills/ - makes CLI self-contained
    # No need to install Claude Code skill separately
    copy_skill_files

    # Import GitHub issues if enabled (v4.1.0)
    if [ "$GITHUB_IMPORT" = "true" ]; then
        import_github_issues
        # Notify GitHub that imported issues are being worked on (v5.41.0)
        sync_github_in_progress_tasks
    fi

    # Start web dashboard (if enabled)
    if [ "$ENABLE_DASHBOARD" = "true" ]; then
        start_dashboard
    else
        log_info "Dashboard disabled (LOKI_DASHBOARD=false)"
    fi

    # Start status monitor (background updates to .loki/STATUS.txt)
    start_status_monitor

    # Start resource monitor (background CPU/memory checks)
    start_resource_monitor

    # Initialize cross-project learnings database
    init_learnings_db

    # Load relevant learnings for this project context
    if [ -n "$PRD_PATH" ] && [ -f "$PRD_PATH" ]; then
        get_relevant_learnings "$(head -100 "$PRD_PATH")"
        load_solutions_context "$(head -100 "$PRD_PATH")"
    else
        get_relevant_learnings "general development"
        load_solutions_context "general development"
    fi

    # Durable-state mount check (enterprise containers): fail loudly before any
    # work if LOKI_DURABLE_STATE=1 and the working checkout is not a writable
    # durable mount, so a misconfigured volume never silently loses build state.
    assert_durable_state_mount

    # A3: on a durable resume with an object-store backend, hydrate this run's
    # checkpoints from the store IF the local volume came up empty (a fresh pod /
    # non-durable volume). Best-effort + idempotent: the shim no-ops when the
    # backend is local, when the local volume already has checkpoints, or when
    # the store has nothing for this run. Runs before the main loop so any
    # resume/rollback sees the restored state. Zero behavior change for local.
    _loki_object_store_hydrate_checkpoints || true

    # Plan #16 (A-1): establish git in an engine-owned, non-git build workspace
    # BEFORE branch setup + start-SHA capture (both need a resolvable HEAD), so
    # the per-iteration review/verify gate actually runs instead of skipping on
    # an empty diff. No-op for the engine source tree, a user's own repo, or a
    # non-engine-owned folder. See maybe_git_init_engine_workspace.
    if ! maybe_git_init_engine_workspace; then
        log_error "Git baseline required by review and proof gates is unavailable. Refusing to start."
        exit 1
    fi

    # Setup agent branch protection (isolates agent changes to a feature branch)
    setup_agent_branch

    # Log session start for audit
    audit_log "SESSION_START" "prd=$PRD_PATH,dashboard=$ENABLE_DASHBOARD,staged_autonomy=$STAGED_AUTONOMY,parallel=$PARALLEL_MODE"
    audit_agent_action "session_start" "Session started" "prd=$PRD_PATH,provider=${PROVIDER_NAME:-claude}"

    # Emit session start event for dashboard
    emit_event_json "session_start" \
        "provider=${PROVIDER_NAME:-claude}" \
        "prd=${PRD_PATH:-}" \
        "parallel=${PARALLEL_MODE:-false}" \
        "complexity=${DETECTED_COMPLEXITY:-standard}" \
        "pid=$$"

    # Anonymous usage telemetry
    loki_telemetry "session_start" \
        "provider=${PROVIDER_NAME:-claude}" \
        "complexity=${DETECTED_COMPLEXITY:-standard}" \
        "parallel=${PARALLEL_MODE:-false}" 2>/dev/null || true

    # Start enterprise background services (OTEL bridge, etc.)
    start_enterprise_services

    # Also emit session_start to pending dir for OTEL bridge
    if [ -n "${LOKI_OTEL_ENDPOINT:-}" ]; then
        emit_event_pending "session_start" \
            "provider=${PROVIDER_NAME:-claude}" \
            "prd=${PRD_PATH:-}"
    fi

    # Run in appropriate mode
    local result=0
    if [ "$PARALLEL_MODE" = "true" ]; then
        # Check bash version before attempting parallel mode
        if ! check_parallel_support; then
            log_warn "Parallel mode unavailable, falling back to sequential mode"
            PARALLEL_MODE=false
        fi
    fi

    # Clear any stale per-run diagnosis record from a PRIOR run before this one
    # starts. LAST_ERROR.json is a single side-record; if it survived a previous
    # failed run it must not surface next to THIS run's outcome (a stale error
    # shown beside a fresh success would be a fake-green-adjacent lie). Mirrors
    # the RATE_LIMITED signal clear. Best-effort; never blocks the run.
    #
    # LEARN-FORWARD: before deleting, ARCHIVE the prior failure to an append-only
    # history (.loki/state/failure-history.jsonl) so the lesson survives the
    # clear. Deleting the single record made every failure invisible to the next
    # run (the founder's "the lesson learnt must not affect the next run" cuts
    # both ways: don't SHOW a stale error as current, but DO remember it happened
    # so a repeated failure signature can be surfaced at preflight). Append-only,
    # bounded, best-effort; never blocks the run.
    _loki_archive_last_error "${TARGET_DIR:-.}/.loki/state/LAST_ERROR.json" \
        "${TARGET_DIR:-.}/.loki/state/failure-history.jsonl" 2>/dev/null || true
    rm -f "${TARGET_DIR:-.}/.loki/state/LAST_ERROR.json" 2>/dev/null || true

    if [ "$PARALLEL_MODE" = "true" ]; then
        # Parallel mode: orchestrate multiple worktrees
        log_header "Running in Parallel Mode"
        log_info "Max worktrees: $MAX_WORKTREES"
        log_info "Max parallel sessions: $MAX_PARALLEL_SESSIONS"

        # Run main session + orchestrator
        (
            # Start main development session
            run_autonomous "$PRD_PATH"
        ) &
        local main_pid=$!
        register_pid "$main_pid" "parallel-main" ""

        # Run parallel orchestrator
        run_parallel_orchestrator &
        local orchestrator_pid=$!
        register_pid "$orchestrator_pid" "parallel-orchestrator" ""

        # Wait for main session (orchestrator continues watching)
        wait $main_pid || result=$?

        # Signal orchestrator to stop
        kill $orchestrator_pid 2>/dev/null || true
        wait $orchestrator_pid 2>/dev/null || true

        # Same pre-edit capture as the standard branch, placed before
        # cleanup_parallel_streams because that tears down worktrees and can
        # change what the diff sees. Parallel mode never reaches the standard
        # branch's call site, so without this the whole mode would have no
        # authorship evidence. Write-once, so this is still a single snapshot.
        capture_preedit_snapshot || true

        # Cleanup parallel streams
        cleanup_parallel_streams
    else
        # Standard mode: single session. Advance phase from BOOTSTRAP to BUILDING
        # before the first iteration so the dashboard shows real progress, not
        # a stuck "Planning" state.
        _advance_current_phase "BUILDING"
        run_autonomous "$PRD_PATH" || result=$?
        # PRE-EDIT SNAPSHOT: freeze the agent's raw diff HERE, the first
        # instruction after the loop returns, because everything below this line
        # can change the tree -- commit_session_changes commits the work (after
        # which `git diff HEAD` is empty), and HANDOFF.md/learnings writers touch
        # files before that. The snapshot is write-once, so capturing it late
        # would permanently record someone else's edits as the agent's. Runs in
        # the FOREGROUND on purpose: the entire value of this position is that
        # the capture COMPLETES before any mutation, and backgrounding it would
        # reintroduce exactly the race the placement exists to remove (the
        # module bounds each git call at 60s, so the cost is bounded).
        capture_preedit_snapshot || true
        # ZOMBIE-RECEIPT GUARD: proof generation + the COMPLETED marker live in the
        # teardown far below. If the process is killed (Docker restart, OOM, worker
        # reap) between here and there, a genuinely finished build (real code, exit
        # 0) leaves NO proof.json and STATUS stuck "BUILDING" -- the build "worked"
        # but produced no Evidence Receipt (observed on run-20260716194328). Emit
        # the proof HERE too, right after the loop returns, so the receipt survives
        # a late teardown death. Idempotent + fire-and-forget: the teardown's own
        # generate_proof_of_run re-runs harmlessly (same run_id -> same proof dir),
        # and LOKI_PROOF=0 still opts out. This closes the "nothing gets verified"
        # gap at its highest-value point without touching run_autonomous itself.
        if [ "${LOKI_PROOF:-1}" != "0" ] && type generate_proof_of_run &>/dev/null; then
            generate_proof_of_run "$result" || true
        fi
    fi

    # Final GitHub sync: sync all completed tasks and create PR (v5.41.0)
    sync_github_completed_tasks
    if [ "$GITHUB_PR" = "true" ] && [ "$result" = "0" ]; then
        local feature_name="${PRD_PATH:-Codebase improvements}"
        feature_name=$(basename "$feature_name" .md 2>/dev/null || echo "$feature_name")
        create_github_pr "$feature_name"
    fi

    # Extract and save learnings from this session
    extract_learnings_from_session

    # Compound learnings into structured solution files (v5.30.0)
    compound_session_to_solutions

    # Log checkpoint count before final checkpoint (v5.57.0)
    local cp_count=$(find .loki/state/checkpoints -maxdepth 1 -type d -name "cp-*" 2>/dev/null | wc -l | tr -d ' ')
    log_info "Session checkpoints: ${cp_count}"

    # Create session-end checkpoint (v5.34.0)
    create_checkpoint "session end (iterations=$ITERATION_COUNT)" "session-end"

    # Emit session_end to pending dir for OTEL bridge (before stopping services)
    if [ -n "${LOKI_OTEL_ENDPOINT:-}" ]; then
        emit_event_pending "session_end" \
            "result=$result" \
            "iterations=$ITERATION_COUNT"
    fi

    # Stop enterprise background services (OTEL bridge, etc.)
    stop_enterprise_services

    # Log session end for audit
    audit_log "SESSION_END" "result=$result,prd=$PRD_PATH"

    # Emit session end event for dashboard
    emit_event_json "session_end" \
        "result=$result" \
        "provider=${PROVIDER_NAME:-claude}" \
        "iterations=$ITERATION_COUNT"

    # Anonymous usage telemetry
    local session_duration=$(($(date +%s) - ${SESSION_START_EPOCH:-$(date +%s)}))
    loki_telemetry "session_end" \
        "provider=${PROVIDER_NAME:-claude}" \
        "duration=$session_duration" \
        "iterations=$ITERATION_COUNT" \
        "result=$result" 2>/dev/null || true

    # Emit learning signal for session completion (SYN-018)
    if [ "$result" = "0" ]; then
        emit_learning_signal success_pattern \
            --source cli \
            --action "session_complete" \
            --pattern-name "full_session" \
            --action-sequence '["init", "setup", "run_iterations", "extract_learnings", "cleanup"]' \
            --outcome success \
            --context "{\"provider\":\"${PROVIDER_NAME:-claude}\",\"iterations\":$ITERATION_COUNT,\"prd\":\"${PRD_PATH:-}\"}"
        emit_learning_signal workflow_pattern \
            --source cli \
            --action "session_complete" \
            --workflow-name "loki_session" \
            --steps '["prerequisites", "setup", "autonomous_loop", "learnings", "cleanup"]' \
            --outcome success \
            --context "{\"iterations\":$ITERATION_COUNT}"
    else
        emit_learning_signal error_pattern \
            --source cli \
            --action "session_failed" \
            --error-type "SessionFailure" \
            --error-message "Session failed with result code $result" \
            --recovery-steps '["Check logs at .loki/logs/", "Review iteration outputs", "Check for rate limits", "Restart session"]' \
            --context "{\"provider\":\"${PROVIDER_NAME:-claude}\",\"iterations\":$ITERATION_COUNT,\"exit_code\":$result}"
    fi

    # Write structured handoff for future sessions (v5.49.0)
    write_structured_handoff "session_end_result_${result}" 2>/dev/null || true

    # Generate shareable proof-of-run artifact (R1). Default-on, opt out with
    # LOKI_PROOF=0. Fire-and-forget on both success and failure runs.
    if [ "${LOKI_PROOF:-1}" != "0" ]; then
        generate_proof_of_run "$result" || true
    fi

    local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}/.loki}"
    local _terminal_status=""
    local _terminal_state_file
    _terminal_state_file="$(_loki_state_file)"
    _terminal_status=$(LOKI_STATE_FILE="$_terminal_state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('status',''))" 2>/dev/null || true)
    case "$_terminal_status" in
        deterministic_gates_passed|council_approved|council_force_approved|completion_promise_fulfilled|reuse_already_satisfied)
            if [ "$result" = "0" ]; then
                _advance_current_phase "COMPLETED"
                echo "Session completed at $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$loki_dir/COMPLETED" 2>/dev/null || true
            else
                _advance_current_phase "FAILED"
                rm -f "$loki_dir/COMPLETED" 2>/dev/null || true
            fi
            ;;
        *)
            _advance_current_phase "FAILED"
            rm -f "$loki_dir/COMPLETED" 2>/dev/null || true
            ;;
    esac

    # Finish-and-own (v7.88.0): write a plain-English ownership handoff
    # (HANDOFF.md) for a non-technical owner. Runs AFTER the proof so the
    # "is it working?" verdict reads the receipt's honest headline. Default-on,
    # opt out with LOKI_HANDOFF=0. Fire-and-forget: best-effort, never blocks
    # completion (same contract as the proof + usage-regen). A pure render over
    # the proof + completion + USAGE.md, so it cannot fabricate.
    if [ "${LOKI_HANDOFF:-1}" != "0" ]; then
        local _own_render="$SCRIPT_DIR/lib/own-render.py"
        if [ -f "$_own_render" ] && command -v python3 >/dev/null 2>&1; then
            # The renderer prints the plain-English doc on stdout (--md); the hook
            # places it at the project root as HANDOFF.md. Write to a temp then
            # move, so a partial write never leaves a truncated HANDOFF.md.
            local _handoff_dir _handoff_md _handoff_tmp
            _handoff_dir="${TARGET_DIR:-.}"
            _handoff_md="$_handoff_dir/HANDOFF.md"
            _handoff_tmp="$_handoff_dir/.HANDOFF.md.tmp"
            if python3 "$_own_render" --loki-dir "${LOKI_DIR:-${TARGET_DIR:-.}/.loki}" --md > "$_handoff_tmp" 2>/dev/null; then
                mv -f "$_handoff_tmp" "$_handoff_md" 2>/dev/null || rm -f "$_handoff_tmp" 2>/dev/null || true
            else
                rm -f "$_handoff_tmp" 2>/dev/null || true
            fi
        fi
    fi

    # R7 (zero-config first run): "what next / go deeper" framing. Only when the
    # CLI flagged this as a TTFV first run and stdout is a TTY, so it stays
    # silent in CI / pipes and never fires for normal PRD runs. The wording
    # branches on the mode (brief = lightweight first pass; repo = full-depth
    # codebase analysis) so the message always matches what actually ran.
    if [ -n "${LOKI_TTFV:-}" ] && [ -t 1 ]; then
        print_ttfv_next_steps "${LOKI_TTFV}" "$result" || true
    fi

    # Commit the session's work to the agent branch (squashed, honest message),
    # then advise the user how to open a PR. Both are no-ops when no agent branch
    # was set up (LOKI_BRANCH_PROTECTION=false) or nothing changed.
    commit_session_changes
    create_session_pr
    audit_agent_action "session_stop" "Session ended" "result=$result,iterations=$ITERATION_COUNT"

    # The first terminal summary is written before session changes are committed.
    # Refresh its durable files against the final HEAD without notifying twice.
    local _completion_file="${TARGET_DIR:-.}/.loki/state/completion.json"
    local _completion_outcome=""
    _completion_outcome=$(LOKI_COMPLETION_FILE="$_completion_file" python3 -c '
import json, os
try:
    value = json.load(open(os.environ["LOKI_COMPLETION_FILE"], encoding="utf-8")).get("outcome", "")
    print(value if isinstance(value, str) else "")
except Exception:
    pass
' 2>/dev/null || true)
    if [ -n "$_completion_outcome" ]; then
        build_completion_summary "$_completion_outcome" || true
    fi

    # Final source-tree binding for server-owned terminal state. HANDOFF.md and
    # commit_session_changes can change the worktree after the earlier receipt.
    # Regenerate idempotently only after those writers finish, before cleanup,
    # so proof.tree_sha256 describes the exact tree the runner returns.
    if [ "${LOKI_PROOF:-1}" != "0" ]; then
        generate_proof_of_run "$result" || true
    fi

    # Cleanup
    if type app_runner_cleanup &>/dev/null; then
        app_runner_cleanup
    fi
    stop_status_monitor
    # v7.41.x: authoritatively reap THIS run's process group on a normal
    # completion (council stop / max-iterations / completion promise), the same
    # group reap the STOP signal does. Without this, a provider agent that
    # detached or reparented survived the exit and ran as an orphan (~27 min in
    # the reported brownfield run). pgid-scoped to .loki/loki.pgid + excludes
    # $$/dashboard, so it cannot touch a foreign loki run. No-ops in interactive
    # foreground (no own session => no pgid file), preserving Ctrl+C semantics.
    reap_own_process_group 2>/dev/null || true
    local loki_dir="${TARGET_DIR:-.}/.loki"
    rm -f "$loki_dir/loki.pid" "$loki_dir/loki.pgid" 2>/dev/null
    # UT2-13: Clear cli-provider marker on normal session end.
    rm -f "$loki_dir/state/cli-provider" 2>/dev/null || true
    # Clean up per-session PID file if running with session ID
    if [ -n "${LOKI_SESSION_ID:-}" ]; then
        rm -f "$loki_dir/sessions/${LOKI_SESSION_ID}/loki.pid" \
              "$loki_dir/sessions/${LOKI_SESSION_ID}/loki.pgid" 2>/dev/null
    fi
    # ENT-3 (enterprise pod-loss / platform-retry contract): translate the
    # terminal RUN STATE into a stable PROCESS exit code so a k8s Job's
    # backoffLimit (or ECS/systemd retry) can distinguish "completed but failed
    # the gate -> do NOT retry" from "crashed -> retry and resume". Without this
    # both look like a generic exit 1 and the platform either loops a
    # deterministically-failing build forever or gives up on a recoverable crash.
    #
    # Contract (LOKI_DURABLE_STATE=1 only; local/CI exit codes are unchanged):
    #   0  = success / human-controlled clean stop (council approved, completion
    #        promise, force-stop, or paused/interrupted/stopped where a HUMAN
    #        chose to stop and will resume). Job -> Complete, no retry.
    #   20 = deterministic terminal failure (failed, max_iterations_reached,
    #        max_retries_exceeded, budget_exceeded, max_duration_reached,
    #        policy_blocked). Re-running on
    #        the same inputs fails the same way -> Job must NOT retry. The Helm Job pairs
    #        this with restartPolicy: Never + a podFailurePolicy rule that maps
    #        exit 20 to FailJob (no retry), so a deterministic failure does not
    #        burn the backoffLimit (the Job records the failure; an operator
    #        changes the spec/budget and re-submits a NEW Job). K8s 1.31+.
    #   anything else nonzero = crash/unexpected (e.g. status still "running"
    #        because the process was SIGKILLed before reaching here). Retryable;
    #        the restarted Job resumes via the ENT-2 durable-resume path.
    if [ "${LOKI_DURABLE_STATE:-0}" = "1" ]; then
        local _final_status _final_state_file
        _final_state_file="$(_loki_state_file)"
        _final_status=$(LOKI_STATE_FILE="$_final_state_file" python3 -c "import json, os; print(json.load(open(os.environ['LOKI_STATE_FILE'])).get('status','unknown'))" 2>/dev/null || echo "unknown")
        case "$_final_status" in
            council_approved|council_force_approved|deterministic_gates_passed|completion_promise_fulfilled|paused|interrupted|stopped)
                result=0 ;;
            # force_stopped is in the result=20 arm below, NOT here. A council
            # force-stop (stagnation, or a flood of done-signals) means the run
            # gave up WITHOUT verifying the work -- the code already says so in
            # its header, its warning, and its refusal to open a PR. Reporting
            # it as a clean stop made it indistinguishable from success to the
            # only consumer that matters to automation: the exit code.
            #
            # This comment previously read "belongs HERE too" while the status
            # appeared in NEITHER arm, so it fell through to `*)` and returned
            # the incoming code unchanged -- a force-stop after a nonzero
            # iteration exited nonzero, and one after a zero exited 1. The
            # diagnosis was written and never applied; the wording is corrected
            # here so the comment cannot be read as describing current behavior.
            # budget_exceeded belongs HERE, not with the human-controlled stops.
            # It sat in the result=0 arm on the rationale that "a human will
            # resume", which is true of `paused` (a human pressed pause) and
            # false of a cost breaker firing inside a k8s Job or a CI pipeline,
            # where there is no human. A build killed mid-work then reported
            # SUCCESS: the Job went Complete, the pipeline went green, and an
            # incomplete build looked finished. That is a false green produced
            # by our own gate, which is precisely what the Evidence Receipt
            # exists to prevent.
            #
            # It is deterministic rather than retryable: re-running the same
            # inputs against the same cap exhausts the same budget and fails
            # identically, so retrying only burns money to reach the same place.
            # The operator raises the cap (or narrows the spec) and submits a
            # NEW Job -- the same remedy as max_iterations_reached, which is why
            # it shares that code.
            failed|max_iterations_reached|max_retries_exceeded|budget_exceeded|max_duration_reached|policy_blocked|inconclusive_spec_contradiction|force_stopped)
                result=20 ;;
            *)
                # Unknown/running/exited terminal: leave $result as-is (nonzero on a
                # real failure path) so the platform treats it as a retryable crash.
                # "exited" is a TRANSIENT per-iteration status (save_state at the end
                # of each iteration), never a legitimate deterministic terminal, so
                # it must NOT map to no-retry: a SIGKILL while "exited" is persisted
                # is a recoverable crash that should resume, not a FailJob.
                [ "$result" = "0" ] && result=1 ;;
        esac
        log_info "Durable-state exit contract: final status '$_final_status' -> exit ${result} ($([ "$result" = "0" ] && echo "complete, no retry" || { [ "$result" = "20" ] && echo "terminal failure, no retry" || echo "crash, retryable"; }))"
    fi

    # Mark session.json as stopped
    if [ -f "$loki_dir/session.json" ]; then
        # BUG-ST-008: Atomic session.json update via temp file + mv
        _LOKI_SESSION_FILE="$loki_dir/session.json" python3 -c "
import json, os, tempfile
sf = os.environ['_LOKI_SESSION_FILE']
try:
    with open(sf) as f:
        d = json.load(f)
    d['status'] = 'stopped'
    sd = os.path.dirname(sf)
    fd, tmp = tempfile.mkstemp(dir=sd, suffix='.json')
    with os.fdopen(fd, 'w') as f:
        json.dump(d, f)
    os.replace(tmp, sf)
except (json.JSONDecodeError, OSError): pass
" 2>/dev/null || true
    fi

    # T2.4: on ANY non-zero final result, surface a plain-language next step
    # (print to stderr + write .loki/NEXT_STEPS.txt). Single chokepoint at the
    # finalization exit so it fires once and never double-prints on success.
    if [ "$result" != "0" ]; then
        _loki_surface_why_hint || true
    fi

    exit $result
}

# Run main only when executed directly (not when sourced by loki CLI)
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    main "$@"
fi
