#!/usr/bin/env bash
# autonomy/lib/claude-flags.sh -- Phase B (v7.5.19) helpers.
#
# Compute Claude Code CLI flag values automatically from existing Loki state.
# No new env vars introduced (per binding constraint: nothing user-facing changes).
# Every value derives from RARV tier, complexity score, and budget config.
#
# Public API (all functions are pure: they read env + filesystem, write stdout).
#   loki_effort_for_tier <tier> [complexity] [model] -- emit one of: low|medium|high|xhigh|max
#   loki_remaining_budget                        -- emit dollar amount remaining as plain int/float, OR empty when unlimited
#   loki_fallback_for_primary <model>           -- emit fallback model alias (opus->sonnet, sonnet->haiku if allowed), OR empty when no fallback applies
#   loki_claude_flag_supported <flag>           -- 0 if `claude --help` lists the flag, 1 otherwise
#
# All functions are side-effect free. The caller (providers/claude.sh, loki-ts) decides whether to pass the result.

# Guard against double-source.
if [ "${__LOKI_CLAUDE_FLAGS_SH_LOADED:-0}" = "1" ]; then
    return 0 2>/dev/null || true
fi
__LOKI_CLAUDE_FLAGS_SH_LOADED=1

# ---------- Effort tier mapping ----------
# RARV tier names: planning (xhigh), development (high), fast (medium).
# Complexity shift: if the project is "complex", bump each tier one notch up.
# Honors LOKI_ALLOW_HAIKU=true semantics for the "fast" tier (medium stays medium; haiku is a separate model concept).
loki_effort_for_tier() {
    local tier="${1:-development}"
    local complexity="${2:-${LOKI_COMPLEXITY:-standard}}"
    local model="${3:-}"
    local effort=""
    case "$tier" in
        planning)    effort="xhigh" ;;
        development) effort="high" ;;
        fast)        effort="medium" ;;
        # Capability aliases used elsewhere in the codebase.
        best)        effort="xhigh" ;;
        balanced)    effort="high" ;;
        cheap)       effort="low" ;;
        *)           effort="high" ;;
    esac
    # LOKI_EFFORT is the one cross-provider knob: an explicit level replaces the tier-derived one
    # (no complexity bump) and is still clamped to the model's catalog levels below.
    local explicit=""
    case "${LOKI_EFFORT:-}" in low|medium|high|xhigh|max) effort="$LOKI_EFFORT"; explicit=1 ;; esac
    # Complex projects get one bump up. "max" is the ceiling and only for explicit user override (not auto-bumped to from xhigh).
    if [ "$complexity" = "complex" ] && [ -z "$explicit" ]; then
        case "$effort" in
            low)    effort="medium" ;;
            medium) effort="high" ;;
            high)   effort="xhigh" ;;
            # xhigh stays xhigh; max not auto-reached.
        esac
    fi
    # Clamp to the model's catalog effort.levels (mirror of effortForTier's model argument).
    # A model with an empty levels list takes no effort parameter: emit nothing and callers omit --effort.
    if [ -n "$model" ] && command -v python3 >/dev/null 2>&1; then
        local catalog="${LOKI_MODEL_CATALOG:-${BASH_SOURCE[0]%/*}/../../providers/model_catalog.json}"
        effort="$(python3 - "$catalog" "$model" "$effort" <<'PYEOF' 2>/dev/null || printf '%s' "$effort"
import json, sys
path, model, effort = sys.argv[1:4]
order = ["low", "medium", "high", "xhigh", "max"]
c = json.load(open(path))["providers"]["claude"]
mid = c.get("cli_aliases", {}).get(model, model)
levels = next((m.get("effort", {}).get("levels") for m in c["models"] if m["id"] == mid or m.get("alias") == model), None)
if levels is not None and len(levels) == 0:
    effort = ""
elif levels and effort not in levels:
    ok = [l for l in order if l in levels]
    below = [l for l in ok if order.index(l) <= order.index(effort)]
    effort = below[-1] if below else ok[0]
print(effort, end="")
PYEOF
)"
    fi
    printf '%s' "$effort"
}

# ---------- Remaining budget ----------
# Compute remaining budget = LOKI_BUDGET_LIMIT - cumulative spend so far.
# Spend lives in .loki/metrics/budget.json under "budget_used".
#
# It was read as "current_spend" until v9.50.0, a key NO production writer has
# ever emitted. All six writers of that file (run.sh:6581,17851,17869,
# loki-ts/src/runner/budget.ts:267, dashboard/server.py:7953, autonomy/loki)
# write "budget_used". The old citation pointed at run.sh:8167+, which is
# per-iteration token parsing and writes no budget key at all.
#
# Effect of the bug: spend always read 0, so --max-budget-usd was handed the
# FULL cap on every call instead of the remainder, and the per-call backstop
# never tightened as spend accumulated. Only reachable for users who set
# LOKI_BUDGET_LIMIT explicitly (run.sh sets BUDGET_LIMIT, not LOKI_*), i.e.
# exactly the cost-conscious users who asked for the cap.
# "current_spend" is still accepted as a fallback so a budget.json written by
# an in-flight older run still parses.
# Emit empty string when budget is unlimited (LOKI_BUDGET_LIMIT unset or 0).
# Emit empty when remaining <= 0 (caller decides what to do; we never emit 0).
loki_remaining_budget() {
    local limit="${LOKI_BUDGET_LIMIT:-}"
    # Empty or zero limit means no cap; emit nothing.
    if [ -z "$limit" ] || [ "$limit" = "0" ] || [ "$limit" = "0.0" ] || [ "$limit" = "0.00" ]; then
        return 0
    fi
    local budget_file="${TARGET_DIR:-.}/.loki/metrics/budget.json"
    local spend="0"
    if [ -f "$budget_file" ]; then
        # Pass the path via env var (os.environ), NOT string interpolation, so a
        # path containing a single quote (or other python/shell-breaking char)
        # cannot break the parse. Single-quoted program -> bash interpolates nothing.
        spend=$(_LOKI_BUDGET_FILE="$budget_file" python3 -c '
import json, os, sys
try:
    with open(os.environ["_LOKI_BUDGET_FILE"]) as f:
        d = json.load(f)
    v = d.get("budget_used")
    if v is None:
        v = d.get("current_spend", 0)
    print(float(v))
except Exception:
    print(0)
' 2>/dev/null)
    fi
    # Compute remaining via python3 (bash floats are unreliable across awk/bc variations).
    # Pass limit/spend via env vars too (same hardening; single-quoted program).
    _LOKI_BUDGET_LIMIT="$limit" _LOKI_BUDGET_SPEND="$spend" python3 -c '
import os, sys
try:
    limit = float(os.environ["_LOKI_BUDGET_LIMIT"])
    spend = float(os.environ["_LOKI_BUDGET_SPEND"])
    rem = limit - spend
    # Strictly positive; otherwise emit nothing (caller decides whether to bail or warn).
    if rem > 0:
        # Round to 2 decimal places for the CLI.
        print(f"{rem:.2f}")
except Exception:
    pass
' 2>/dev/null
}

# ---------- Fallback model ----------
# Map primary model alias to a sensible fallback for `--fallback-model`.
# opus -> sonnet (always safe; sonnet is cheaper + similar capability)
# sonnet -> haiku ONLY when LOKI_ALLOW_HAIKU=true; else emit nothing
# haiku -> nothing (already at the bottom)
# Anything else (specific dated IDs, alt-provider names) -> nothing (no auto-fallback we can reason about)
loki_fallback_for_primary() {
    local primary="${1:-}"
    case "$primary" in
        opus)   printf '%s' "sonnet" ;;
        sonnet)
            if [ "${LOKI_ALLOW_HAIKU:-false}" = "true" ]; then
                printf '%s' "haiku"
            fi
            ;;
        # No fallback for haiku or other model names.
        *) ;;
    esac
}

# ---------- Flag-support detection ----------
# Returns 0 if `claude --help` lists the named flag, 1 otherwise.
# Cached per-process so we do not shell out N times per iteration.
loki_claude_flag_supported() {
    local flag="${1:-}"
    [ -z "$flag" ] && return 1
    # Per-process cache: __LOKI_CLAUDE_HELP_CACHE populated on first call.
    if [ -z "${__LOKI_CLAUDE_HELP_CACHE:-}" ]; then
        if command -v claude >/dev/null 2>&1; then
            __LOKI_CLAUDE_HELP_CACHE=$(claude --help 2>&1 || true)
        else
            __LOKI_CLAUDE_HELP_CACHE="__no_claude__"
        fi
        export __LOKI_CLAUDE_HELP_CACHE
    fi
    case "$__LOKI_CLAUDE_HELP_CACHE" in
        *"$flag"*) return 0 ;;
        *) return 1 ;;
    esac
}

# ---------- v7.33.0 cheap-subcall + reviewer-guard flag emitters ----------
# These centralize three Claude Code 2.1.170 embeds so every cheap NON-MAIN
# subcall site emits the same gated flags. Each emitter prints its flags to
# stdout (space-free per element via one-per-line is not needed; callers read
# them into an array with command substitution + word-splitting on the single
# token they emit, OR append the function output as additional argv elements).
# All are default-ON, opt-out via env, and gated on loki_claude_flag_supported
# so an older CLI degrades gracefully (emits nothing).

# EMBED 2 -- --bare (cheap NON-MAIN subcalls only). Minimal mode. Per
# `claude --help` it SKIPS hooks, LSP, plugin sync, attribution, auto-memory,
# background prefetches, keychain reads, and CLAUDE.md AUTO-discovery. It does
# NOT nullify explicit --mcp-config/--settings/--agents (the help lists those as
# the way to "explicitly provide context" UNDER --bare); what it drops is the
# IMPLICIT/auto-discovered context. Therefore --bare is ONLY safe on subcalls
# whose prompt is fully self-contained (the entire instruction set + context is
# passed via -p), and NEVER on the main RARV loop or on any call that relies on
# auto-discovered CLAUDE.md / hooks / auto-memory.
#
# AUTH GATE (critical): --bare sets CLAUDE_CODE_SIMPLE=1 and per `claude --help`
# reads Anthropic auth STRICTLY from ANTHROPIC_API_KEY or an apiKeyHelper via
# --settings -- "OAuth and keychain are never read". A subscription/OAuth user
# (no ANTHROPIC_API_KEY) gets "Not logged in" on every --bare subcall, which
# exits 0 with the error on stdout, so a council vote parses as the default
# REJECT and the loop silently corrupts. So --bare is enabled ONLY when an
# API-key auth path exists (ANTHROPIC_API_KEY set, or an apiKeyHelper configured
# in settings); otherwise it emits nothing and the subcall runs full-auth, the
# same as before this embed. Subscription users are thus unaffected by default.
# Default-ON (when auth-safe); opt out entirely with LOKI_BARE_SUBCALLS=0.
# Predicate so call sites can append "--bare" to their argv array uniformly:
#   loki_subcall_bare_enabled && argv+=("--bare")
loki_subcall_bare_enabled() {
    [ "${LOKI_BARE_SUBCALLS:-1}" = "0" ] && return 1
    # API-key auth required (see AUTH GATE above). ANTHROPIC_API_KEY is the
    # common case; apiKeyHelper covers the settings-configured helper. Anything
    # else (OAuth/keychain subscription) must NOT use --bare. Trim the key so a
    # whitespace-only value does not count as set (it would fail --bare auth).
    local _key
    _key="$(printf '%s' "${ANTHROPIC_API_KEY:-}" | tr -d '[:space:]')"
    if [ -z "$_key" ] && ! _loki_apikey_helper_configured; then
        return 1
    fi
    loki_claude_flag_supported "--bare"
}

# True when an apiKeyHelper is configured in any Claude settings source, which
# (unlike OAuth/keychain) IS honored under --bare. Best-effort, read-only.
_loki_apikey_helper_configured() {
    local f
    for f in "$HOME/.claude/settings.json" "$HOME/.config/claude/settings.json" \
             "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json" \
             "$PWD/.claude/settings.json" "$PWD/.claude/settings.local.json"; do
        [ -f "$f" ] || continue
        # Require "apiKeyHelper" : "<non-empty value>" (a real key:value pair),
        # not the bare token in a comment/prose. Tolerates whitespace around the
        # colon. Not a full JSON parse, but rejects the obvious false-positives.
        grep -Eq '"apiKeyHelper"[[:space:]]*:[[:space:]]*"[^"]+"' "$f" 2>/dev/null && return 0
    done
    return 1
}

# ---------- S2: lean child-session flags + cost ledger ----------
# loki_lean_child_flags <role> prints the lean flag set for a NON-MAIN child
# `claude -p` session, ONE ARGUMENT PER LINE (never an empty line).
# Callers read it with a line loop.
#   - API-key/apiKeyHelper host: "--bare" (gated by loki_subcall_bare_enabled,
#     which keeps OAuth-only hosts off --bare; it would fail auth there).
#   - Otherwise: --setting-sources project, --strict-mcp-config, an empty
#     --mcp-config, --disable-slash-commands.
#   - Text-only roles additionally get the single argument "--tools=" (no tools). --tools is
#     variadic: "--tools" plus an empty argument swallows a trailing prompt.
# Every flag is probed with loki_claude_flag_supported; an unsupported flag and
# its value are dropped. LOKI_LEAN_CHILDREN=0 drops the lean flags but the walled
# check_author still gets "--tools=" (its isolation does not depend on the toggle).
# Main-session flags are not touched by this helper.
loki_lean_child_flags() {
    local role="${1:-}"
    if [ "${LOKI_LEAN_CHILDREN:-1}" = "0" ]; then
        if [ "$role" = "check_author" ] && loki_claude_flag_supported "--tools"; then
            printf '%s\n' "--tools="
        fi
        return 0
    fi
    if loki_subcall_bare_enabled; then
        printf '%s\n' "--bare"
    else
        if loki_claude_flag_supported "--setting-sources"; then
            printf '%s\n' "--setting-sources" "project"
        fi
        if loki_claude_flag_supported "--strict-mcp-config"; then
            printf '%s\n' "--strict-mcp-config"
        fi
        if loki_claude_flag_supported "--mcp-config"; then
            printf '%s\n' "--mcp-config" '{"mcpServers":{}}'
        fi
        if loki_claude_flag_supported "--disable-slash-commands"; then
            printf '%s\n' "--disable-slash-commands"
        fi
    fi
    case "$role" in
        grill|check_author|council|vote|review|reviewer|enrich|wiki|docs|docgen|summary)
            if loki_claude_flag_supported "--tools"; then
                printf '%s\n' "--tools="
            fi
            ;;
    esac
    return 0
}

# loki_child_ledger_append <phase> <claude --output-format json> appends one
# line to .loki/metrics/child-sessions.jsonl in the format read by
# autonomy/lib/efficiency_cost.py read_child_ledger: phase, model,
# input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, usd,
# source. A missing usd is omitted, never written as 0. Returns 1 (writes
# nothing) when the json is not a parseable result object.
loki_child_ledger_append() {
    local phase="${1:-}" payload="${2:-}"
    [ -n "$phase" ] && [ -n "$payload" ] || return 1
    local dir="${LOKI_DIR:-.loki}/metrics" line
    line=$(printf '%s' "$payload" | python3 -I -c '
import json, sys
phase = sys.argv[1]
try:
    d = json.loads(sys.stdin.read())
except Exception:
    sys.exit(1)
if isinstance(d, list):
    d = next((x for x in reversed(d) if isinstance(x, dict) and x.get("type") == "result"), None)
if not isinstance(d, dict):
    sys.exit(1)
u = d.get("usage") if isinstance(d.get("usage"), dict) else {}
def n(v):
    return v if isinstance(v, int) and not isinstance(v, bool) else 0
mu = d.get("modelUsage")
model = next(iter(mu), "") if isinstance(mu, dict) and mu else (d.get("model") or "")
rec = {
    "phase": phase,
    "model": model if isinstance(model, str) else "",
    "input_tokens": n(u.get("input_tokens")),
    "output_tokens": n(u.get("output_tokens")),
    "cache_creation_tokens": n(u.get("cache_creation_input_tokens")),
    "cache_read_tokens": n(u.get("cache_read_input_tokens")),
}
usd = d.get("total_cost_usd", d.get("cost_usd"))
if isinstance(usd, (int, float)) and not isinstance(usd, bool):
    rec["usd"] = usd
rec["source"] = "claude-json"
print(json.dumps(rec, separators=(",", ":")))
' "$phase" 2>/dev/null) || return 1
    [ -n "$line" ] || return 1
    mkdir -p "$dir" 2>/dev/null || return 1
    printf '%s\n' "$line" >> "$dir/child-sessions.jsonl" || return 1
}

# EMBED 3 -- --disallowedTools (reviewer / adversarial subcalls). Motivation: a
# parallel agent once ran `git reset --hard` and wiped uncommitted work, so a
# reviewer/voter subcall should not casually mutate the tree. Per `claude --help`
# the value is a comma-or-space-separated list of tool names (e.g. "Bash(git *)
# Edit"); Bash(...) matching is command-PREFIX based (the `cmd:*` form).
#
# SCOPE / LIMITS (honest -- this is a denylist, NOT a sandbox):
#   - Denies the direct file-mutation tools (Edit, Write, NotebookEdit).
#   - Denies the common git MUTATION forms: the bare subcommand (`git reset:*`)
#     AND the global-flag-prefixed evasions (`git -C:*`, `git --git-dir:*`,
#     `git -c:*`) that slip a flag before the subcommand so the bare prefix
#     does not match. Read-only git (diff/log/show/status) stays allowed.
#   - It does NOT and cannot block every mutation path: a determined agent can
#     still write via `Bash(echo > f)`, `sed -i`, `cp/mv/tee`, `python -c`, etc.
#     This is a guardrail that raises the cost of the casual/common destructive
#     command, not a guarantee the tree is immutable. The real safety net is
#     that the integrator commits before any agent wave (see CLAUDE.md).
# The flag is variadic (<tools...>), so we emit ONE comma-separated token to
# avoid swallowing the following -p prompt as additional tool names.
# Default-ON; opt out with LOKI_REVIEW_TOOL_GUARD=0.
# Predicate + denylist so call sites append uniformly:
#   if loki_review_guard_enabled; then
#       argv+=("--disallowedTools" "$(loki_review_guard_denylist)")
#   fi
loki_review_guard_enabled() {
    [ "${LOKI_REVIEW_TOOL_GUARD:-1}" = "0" ] && return 1
    loki_claude_flag_supported "--disallowedTools"
}
loki_review_guard_denylist() {
    # Comma-separated single token. Bash(cmd:*) is command-prefix matching.
    # The `git -C:*` / `git --git-dir:*` / `git -c:*` entries close the
    # global-flag-before-subcommand evasion of the bare git-mutation rules.
    printf '%s' "Edit,Write,NotebookEdit,Bash(git commit:*),Bash(git reset:*),Bash(git push:*),Bash(git checkout:*),Bash(git clean:*),Bash(git rm:*),Bash(git stash:*),Bash(git -C:*),Bash(git --git-dir:*),Bash(git -c:*),Bash(git --exec-path:*),Bash(git diff --output:*),Bash(git diff --output=:*),Bash(git diff --ext-diff:*),Bash(git diff --no-index:*),Bash(git log --output:*),Bash(git log --output=:*),Bash(git log --ext-diff:*),Bash(git show --output:*),Bash(git show --output=:*),Bash(git show --ext-diff:*),Bash(git blame --output:*),Bash(git blame --output=:*),Bash(rg --pre:*),Bash(rg --pre=:*),Bash(rg --pre-glob:*),Bash(rg --pre-glob=:*),Bash(rg --hostname-bin:*),Bash(rg --hostname-bin=:*),Bash(find -delete:*),Bash(find -exec:*),Bash(find -execdir:*),Bash(find -ok:*),Bash(find -fprint:*),Bash(find -fprintf:*),Bash(find -fls:*),Bash(git * --output*),Bash(git * --ext-diff*),Bash(git * --no-index*),Bash(rg * --pre*),Bash(rg * --hostname-bin*),Bash(rg --pre*),Bash(rg --hostname-bin*),Bash(find * -delete*),Bash(find * -exec*),Bash(find * -execdir*),Bash(find * -fprint*),Bash(find * -fls*),Bash(find * -ok*)"
}

# EMBED 3b (v7.35.0, GitHub #167) -- --allowedTools POSITIVE ALLOWLIST for the
# reviewer / adversarial / council subcalls. Complements the v7.33 denylist with
# a least-privilege grant: the value lists ONLY the read/inspect tools a voter
# needs (Read, Grep, Glob, read-only git, and a small set of read-only shell
# commands). Per `claude --help` and the official permission docs
# (https://code.claude.com/docs/en/cli-reference,
#  https://code.claude.com/docs/en/permissions) --allowedTools entries are
# permission ALLOW rules ("tools that execute without prompting"), using the same
# Tool / Tool(specifier) / Bash(cmd:*) prefix syntax as --disallowedTools.
#
# PRECEDENCE (proven empirically + documented): the docs state rules are
# "evaluated in order: deny, then ask, then allow ... the first match in that
# order determines the outcome", and "if a tool is denied at any level, no other
# level can allow it." Verified live against claude 2.1.177 on 2026-06-13 with a
# clean minimal env (temp git dir, --strict-mcp-config --mcp-config
# '{"mcpServers":{}}', --advisor opus to dodge the Fable-pinned default advisor
# tool, --tools "Bash,Read"):
#   - allow-only  Bash(git status:*)                -> PERMITTED  (control)
#   - deny-only   Bash(git status:*)                -> BLOCKED    (control)
#   - allow + deny BOTH on Bash(git status:*)       -> BLOCKED    (deny wins)
#   - allow + deny BOTH, under --dangerously-skip-permissions -> BLOCKED
# So DENY PRECEDENCE holds even in bypassPermissions mode (the mode every council
# subcall uses). This is the SAFE outcome: the allowlist and the denylist can be
# emitted TOGETHER. The denylist still blocks every mutation form even though the
# allowlist names read-only git; the allowlist additionally narrows the surface
# to least-privilege. They are NOT mutually exclusive; we ship both.
#
# This is a GUARDRAIL, not a sandbox (same caveat as the denylist): --allowedTools
# governs whether a tool runs WITHOUT A PROMPT, and these subcalls already run
# under --dangerously-skip-permissions, so the practical effect is to restrict the
# in-context tool surface to the allowlisted set while the denylist hard-blocks
# the dangerous forms. echo>/sed -i/python -c style writes are not enumerable and
# remain possible; the real net is commit-before-agent-wave (see CLAUDE.md).
#
# DEFAULT ON (safety-additive least-privilege; opt OUT with LOKI_REVIEW_ALLOWLIST=0).
# Flipped default-on because deny precedence (verified live) means the denylist
# still hard-blocks every mutation form while this allowlist only narrows the
# reviewer surface to read/inspect tools -- pure safety win, no surprise spend, no
# egress. The escape hatch (LOKI_REVIEW_ALLOWLIST=0) restores the prior off state.
# Gated on CLI support so an older claude degrades gracefully (emits nothing).
# Predicate + token so call sites append uniformly:
#   if loki_review_allowlist_enabled; then
#       argv+=("--allowedTools" "$(loki_review_allowlist)")
#   fi
loki_review_allowlist_enabled() {
    [ "${LOKI_REVIEW_ALLOWLIST:-1}" = "0" ] && return 1
    loki_claude_flag_supported "--allowedTools"
}
loki_review_allowlist() {
    # Comma-separated single token (the flag is variadic; one token keeps the
    # following -p prompt from being swallowed as additional tool names). Grants
    # ONLY read/inspect tools: the file-read tools (Read/Grep/Glob), read-only
    # git (diff/log/show/status/ls-files/rev-parse/blame), and a small set of
    # read-only shell commands a reviewer uses to inspect the tree. No Edit,
    # Write, NotebookEdit, or any mutation form. Kept BYTE-IDENTICAL to
    # REVIEW_ALLOWLIST_TOKEN in loki-ts/src/providers/claude_flags.ts.
    printf '%s' "Read,Grep,Glob,Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git status:*),Bash(git ls-files:*),Bash(git rev-parse:*),Bash(git blame:*),Bash(cat:*),Bash(ls:*),Bash(grep:*),Bash(rg:*),Bash(find:*),Bash(head:*),Bash(tail:*),Bash(wc:*)"
}

# ---------- v7.34.0 Claude session-id stamping (Phase 1, correlation-only) -----
# Derive a deterministic per-run UUID from the existing trust-run-id so the same
# run always maps to the same claude session UUID, and the bash + Bun routes
# produce BYTE-IDENTICAL uuids for the same run id. We use RFC-4122 UUIDv5
# (SHA-1 over a stable namespace + the name). The namespace below is a fixed,
# Loki-specific constant; never change it (changing it re-keys every run's uuid).
# The TS mirror is loki-ts/src/providers/claude_flags.ts claudeSessionUuid().
#
# Phase 1 is correlation-only: the uuid is written to a metadata file and (only
# when LOKI_SESSION_STAMP=1) emitted as a PER-ITERATION --session-id on the main
# loop. It NEVER pins one id across the run (that is Phase 2 continuity, which
# would accumulate transcript and compete with Loki's own injected memory).
LOKI_CLAUDE_SESSION_NS="b6f3c7a2-9d41-5e8b-9c2a-3f7d6e1a4b50"

# UUIDv5 over the Loki session namespace + an arbitrary name string. Pure
# (stdout only), deterministic, no side effects. Uses python3 (always present on
# the bash route; every other helper here already depends on it). Emits empty on
# any failure so the caller degrades to metadata-only without breaking the run.
_loki_uuid5() {
    local name="${1:-}"
    [ -z "$name" ] && return 0
    command -v python3 >/dev/null 2>&1 || return 0
    LOKI_CLAUDE_SESSION_NS="$LOKI_CLAUDE_SESSION_NS" _LOKI_UUID5_NAME="$name" \
    python3 - <<'UUID5_PY' 2>/dev/null || true
import os, uuid
ns = uuid.UUID(os.environ["LOKI_CLAUDE_SESSION_NS"])
print(uuid.uuid5(ns, os.environ["_LOKI_UUID5_NAME"]))
UUID5_PY
}

# The stable per-run claude session UUID: UUIDv5 of the trust-run-id. Same run
# id -> same uuid, on every route. Emits empty when no run id is resolvable.
_loki_claude_session_uuid() {
    local run_id="${1:-${LOKI_TRUST_RUN_ID:-}}"
    [ -z "$run_id" ] && return 0
    _loki_uuid5 "$run_id"
}

# The PER-ITERATION session UUID emitted on the main loop when LOKI_SESSION_STAMP=1.
# UUIDv5 of "<run-id>:<iteration>", so every iteration gets a DISTINCT, deterministic
# id. This is deliberately NOT the stable per-run uuid: a single pinned id reused
# across iterations would make claude RESUME (accumulate transcript), which is
# Phase 2 continuity, explicitly out of scope here. Distinct-per-iteration keeps
# each iteration a fresh stateless session (byte-identical default behavior to
# v7.33 except for the added correlation flag).
_loki_claude_iteration_session_uuid() {
    local run_id="${1:-${LOKI_TRUST_RUN_ID:-}}"
    local iteration="${2:-${ITERATION_COUNT:-0}}"
    [ -z "$run_id" ] && return 0
    _loki_uuid5 "${run_id}:${iteration}"
}

# Predicate: emit the per-iteration --session-id ARGV flag? CONSERVATIVE DEFAULT
# is OFF (metadata-file-only) so the default claude argv stays byte-identical to
# v7.33 (the UX-monotonicity requirement). Opt IN with LOKI_SESSION_STAMP=1.
# Gated on CLI support so an older claude degrades gracefully (no flag emitted).
loki_session_stamp_enabled() {
    [ "${LOKI_SESSION_STAMP:-0}" = "1" ] || return 1
    loki_claude_flag_supported "--session-id"
}

# ---------- v7.36.0 ultrareview (cloud multi-agent review) gates ----------
# `claude ultrareview [options] [target]` (Claude Code 2.1.x) runs a
# cloud-hosted multi-agent code review of the current branch / a PR number / a
# base branch and prints the findings. It is a PAID CLOUD operation billed by
# Anthropic, SEPARATE from local model spend, and can take up to 30 minutes.
#
# These two predicates are the ONLY policy surface for the optional `loki review
# --ultra` tier (issue #168). Both are read-only and side-effect-free.
#
# DESIGN (issue #168, architected):
#   - This is an EXPLICIT, on-demand user action (`loki review --ultra`), NEVER
#     an automatic completion-council voice. The council runs many times per
#     build; auto-running a paid cloud call there would be a silent-billing
#     footgun. The user typing "ultra" IS the consent signal; a confirmation
#     prompt (or --yes / LOKI_ULTRAREVIEW=1) is still required before any spend.
#   - There is NO price API, so we CANNOT show a dollar figure. We disclose the
#     cost-CLASS (paid cloud op) and require confirmation. Never claim to show
#     an estimated dollar amount here (that would be a lie).

# Capability gate: does the installed `claude` CLI ship the `ultrareview`
# subcommand? loki_claude_flag_supported greps `claude --help`, whose top-level
# command list includes "ultrareview", so it matches subcommands too. Returns 0
# when supported, 1 otherwise (so callers can emit an honest upgrade message).
loki_ultrareview_supported() {
    loki_claude_flag_supported "ultrareview"
}

# Non-interactive opt-in: is LOKI_ULTRAREVIEW=1 set? This is the env equivalent
# of passing --yes to THIS command only. It is deliberately NOT wired into the
# completion council or any auto path -- it only suppresses the interactive
# confirmation prompt for an explicit `loki review --ultra` invocation. Any
# other value (unset, 0, "true", etc.) returns 1 so only the exact "1" opts in.
loki_ultrareview_enabled() {
    [ "${LOKI_ULTRAREVIEW:-0}" = "1" ]
}

# ---------- v7.38.0 Dynamic Workflows (ultracode) gates ----------
# Claude Code "Dynamic Workflows" are JS orchestration scripts Claude writes that
# fan out into many background subagents. They are triggered by the `ultracode`
# keyword in a prompt and fire under `claude -p` (verified empirically: a headless
# workflow returned "The workflow finished"). They are:
#   - Claude-PROVIDER-ONLY (no Codex/Cline/Aider equivalent),
#   - require claude CLI >= 2.1.154,
#   - cost meaningfully MORE than a normal run (a trivial workflow observed at
#     ~$0.71 vs ~$0.01 for a plain read; there is NO price API so we never quote
#     a dollar figure -- we disclose the cost CLASS only),
#   - disablable via CLAUDE_CODE_DISABLE_WORKFLOWS=1 or the `disableWorkflows`
#     setting in any Claude settings.json.
#
# These predicates mirror loki_ultrareview_supported/enabled exactly: read-only,
# side-effect free, the ONLY policy surface for the optional `loki ultracode`
# passthrough and the Phase 2 opt-in analysis dispatch. The user typing
# `loki ultracode` (or setting LOKI_USE_CLAUDE_WORKFLOWS=1) IS the consent signal.

# Minimum claude CLI version that ships Dynamic Workflows.
LOKI_WORKFLOWS_MIN_VERSION="${LOKI_WORKFLOWS_MIN_VERSION:-2.1.154}"

# Parse the installed claude CLI semantic version (e.g. "2.1.177") to stdout, or
# empty on any failure. Mirrors the `claude --version | sed` pattern already used
# at autonomy/loki:7888. Cached per-process so we do not shell out repeatedly.
_loki_claude_version() {
    if [ -z "${__LOKI_CLAUDE_VERSION_CACHE:-}" ]; then
        if command -v claude >/dev/null 2>&1; then
            __LOKI_CLAUDE_VERSION_CACHE="$(claude --version 2>/dev/null | head -1 | sed 's/[^0-9.]//g' | head -1)"
        fi
        # Sentinel so an empty real result does not re-shell every call.
        __LOKI_CLAUDE_VERSION_CACHE="${__LOKI_CLAUDE_VERSION_CACHE:-__none__}"
        export __LOKI_CLAUDE_VERSION_CACHE
    fi
    [ "$__LOKI_CLAUDE_VERSION_CACHE" = "__none__" ] && return 0
    printf '%s' "$__LOKI_CLAUDE_VERSION_CACHE"
}

# Dotted-version >= compare: returns 0 when $1 >= $2, 1 otherwise. Pure, no
# external tools beyond awk (always present). Pads missing components with 0.
_loki_version_ge() {
    local have="${1:-}" want="${2:-}"
    [ -z "$have" ] && return 1
    [ -z "$want" ] && return 0
    awk -v a="$have" -v b="$want" '
    function cmp(x, y,   na, nb, i, n, xi, yi) {
        na = split(x, A, ".")
        nb = split(y, B, ".")
        n = (na > nb) ? na : nb
        for (i = 1; i <= n; i++) {
            xi = (i <= na) ? A[i] + 0 : 0
            yi = (i <= nb) ? B[i] + 0 : 0
            if (xi > yi) return 1
            if (xi < yi) return -1
        }
        return 0
    }
    BEGIN { exit (cmp(a, b) >= 0) ? 0 : 1 }
    '
}

# True when CLAUDE_CODE_DISABLE_WORKFLOWS=1 OR a `disableWorkflows` setting is
# active in any Claude settings source. Best-effort, read-only (not a full JSON
# parse, but rejects the obvious cases). Returns 0 (disabled) / 1 (not disabled).
_loki_workflows_disabled() {
    [ "${CLAUDE_CODE_DISABLE_WORKFLOWS:-0}" = "1" ] && return 0
    local f
    for f in "$HOME/.claude/settings.json" "$HOME/.config/claude/settings.json" \
             "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json" \
             "$PWD/.claude/settings.json" "$PWD/.claude/settings.local.json"; do
        [ -f "$f" ] || continue
        # Match "disableWorkflows": true (tolerating whitespace around the colon).
        grep -Eq '"disableWorkflows"[[:space:]]*:[[:space:]]*true' "$f" 2>/dev/null && return 0
    done
    return 1
}

# Capability gate: is the active provider Claude AND the claude CLI present AND
# version >= the workflows minimum AND workflows not disabled? Returns 0 when
# workflows can run, 1 otherwise (so callers emit an honest message + clean exit).
loki_workflows_supported() {
    # Provider must be Claude (Tier 1). Workflows are Claude-only.
    [ "${LOKI_PROVIDER:-claude}" = "claude" ] || return 1
    command -v claude >/dev/null 2>&1 || return 1
    _loki_workflows_disabled && return 1
    local ver
    ver="$(_loki_claude_version)"
    _loki_version_ge "$ver" "$LOKI_WORKFLOWS_MIN_VERSION" || return 1
    return 0
}

# Non-interactive opt-in: is LOKI_USE_CLAUDE_WORKFLOWS=1 set? This is the env knob
# that turns ON the Phase 2 read-only-analysis workflow dispatch. Default OFF. Any
# value other than the exact "1" returns 1 so only an explicit opt-in counts. This
# is independent of the `loki ultracode` passthrough (which is itself an explicit
# user invocation and does not require this flag).
loki_workflows_enabled() {
    [ "${LOKI_USE_CLAUDE_WORKFLOWS:-0}" = "1" ]
}

# ---------- v7.x caveman output-token compressor gates ----------
# caveman (https://github.com/JuliusBrussee/caveman, MIT, vendor-less pin) is a
# Claude Code SKILL + SessionStart hook that instructs the model to compress its
# OUTPUT TOKENS only (prose style: lite / full / ultra / wenyan), keeping all
# technical substance. It wraps NO API, needs NO key, has NO network of its own.
# Once installed it activates GLOBALLY in Claude Code via a SessionStart hook
# that reads getDefaultMode() (env CAVEMAN_DEFAULT_MODE > repo .caveman config >
# user config > "full") and, unless the mode is "off", injects its ruleset.
#
# THE MOAT RISK (central, why this is wired the way it is): Loki's trust gates
# parse EXACT model prose -- council "VOTE: APPROVE|REJECT|CANNOT_VALIDATE", code
# review "^VERDICT:", the legacy completion-promise grep, the evidence-gate
# sentinels. A globally-active caveman would compress those subcall outputs and
# silently flip a verdict to the default (REJECT / not-complete), corrupting the
# loop. This is the same failure class as the --bare OAuth footgun documented at
# claude-flags.sh:152-161.
#
# THE DESIGN (off by construction, not by convention):
#   - ACTIVATE compression only on FREE-FORM generation (main RARV dev loop +
#     read-only codebase-analysis): inline `CAVEMAN_DEFAULT_MODE=<level> claude`.
#   - HARD-SUPPRESS on EVERY parsed-output subcall (council vote, ^VERDICT:
#     review, adversarial probe, conflict-merge, USAGE regen): inline
#     `CAVEMAN_DEFAULT_MODE=off claude`. The activate hook then deletes its flag
#     and emits nothing (verified: `CAVEMAN_DEFAULT_MODE=off node
#     caveman-activate.js` prints "OK" with no ruleset).
#
# Suppression is UNCONDITIONAL and UNGATED (see loki_caveman_suppress_env): it is
# a harmless no-op env value when caveman is absent and the essential carve-out
# when caveman is globally present (surface b, or a user's own install) even with
# LOKI_CAVEMAN=0. NEVER gate suppression on supported/enabled -- that would leave
# the trust gates unprotected exactly when a user has caveman on but Loki off.
#
# Disclosure (honest, no fabricated figures): caveman compresses OUTPUT tokens
# only, not input/thinking; savings are real but bounded. There is no price API,
# so we disclose the savings CLASS, never a dollar amount (same posture as the
# ultrareview/workflows gates).

# Version pin (vendor-less). Upgrade by bumping this. The upstream installer pins
# its hook downloads to PINNED_REF = CAVEMAN_REF || 'v1.9.0' (a git tag), and the
# curl|bash path delegates to `npx -y github:JuliusBrussee/caveman#<ref>`. We
# default to 1.9.0 and derive the `v`-prefixed tag in the bootstrap helper.
LOKI_CAVEMAN_VERSION="${LOKI_CAVEMAN_VERSION:-1.9.0}"

# The compression level for free-form activation. Maps directly to caveman's
# CAVEMAN_DEFAULT_MODE values: lite | full | ultra | wenyan | wenyan-lite |
# wenyan-full | wenyan-ultra. Never "off" here -- "off" is the suppression value,
# not an activation level.
#
# v7.x #593 -- INTELLIGENT AUTO-SELECTION (no new user knob): when the user does
# NOT set LOKI_CAVEMAN_LEVEL explicitly, the level is INFERRED per-invocation from
# the run's existing RARV-tier signal (see _loki_caveman_infer_level). When the
# user DOES set it, that value overrides the inference entirely (opt-out escape
# hatch). Capture set-ness BEFORE the ":-full" default clobbers it -- once the
# default fills the var, "user set full" and "defaulted to full" are
# indistinguishable, so the inference would silently never fire. ${var+set} is
# non-empty only when the var was genuinely set (even to empty). The "full"
# default is kept so the public var still reads "full" for external readers and
# so a child re-source re-derives USERSET correctly (unexported default).
if [ -z "${LOKI_CAVEMAN_LEVEL_USERSET+x}" ]; then
    LOKI_CAVEMAN_LEVEL_USERSET="${LOKI_CAVEMAN_LEVEL+set}"
fi
LOKI_CAVEMAN_LEVEL="${LOKI_CAVEMAN_LEVEL:-full}"

# ---------- DEFAULT-SUPPRESS: off by construction, tree-wide ----------
# THE MOAT GUARANTEE (v7.41.0 council fix): instead of hand-enumerating every
# parsed-output trust-gate subcall and remembering to prefix each with
# CAVEMAN_DEFAULT_MODE=off (a missed site silently corrupts a verdict -- caveman
# exits 0 with mangled prose and the `|| REJECT` fallback never fires), we flip
# the ENTIRE process tree to suppressed at the one module every tree sources.
#
# claude-flags.sh is sourced by EVERY tree that can spawn a parsed claude
# subcall: the run.sh orchestrator (via providers/claude.sh), grill.sh (standalone
# `loki grill`), lib/voter-agents.sh (Phase C agent-dispatch voters), and the
# loki standalone review/workflows commands (on-demand). council-v2.sh carries no
# source of its own but only ever runs inside completion-council.sh, which is in
# the run.sh tree, so it inherits this export too. Exporting off HERE makes the
# whole spawned subprocess tree inherit suppression -- caveman's SessionStart
# hook reads process.env CAVEMAN_DEFAULT_MODE -- closing council-v2.sh,
# voter-agents.sh, grill.sh, every existing parsed subcall, and any FUTURE one by
# construction. ACTIVATION on the handful of free-form generation sites overrides
# this per-invocation (CAVEMAN_DEFAULT_MODE=<level> claude ...).
#
# Capture the user's pre-existing global CAVEMAN_DEFAULT_MODE BEFORE we clobber
# it, so the activation path can respect (never RAISE) a user's lower level (see
# loki_caveman_activate_env). Guarded on UNSET (not empty): a child process that
# inherits our exported LOKI_CAVEMAN_USER_MODE="" (user had no global mode) and
# re-sources this file must NOT recapture the now-exported CAVEMAN_DEFAULT_MODE=off
# as the user mode. ${var+x} is empty only when var is genuinely unset, so the
# capture runs exactly once across the whole process tree, never recapturing "off".
if [ -z "${LOKI_CAVEMAN_USER_MODE+x}" ]; then
    LOKI_CAVEMAN_USER_MODE="${CAVEMAN_DEFAULT_MODE:-}"
fi
export LOKI_CAVEMAN_USER_MODE
export CAVEMAN_DEFAULT_MODE=off

# ---------- v7.x #593 intelligent compression-level inference ----------
# Infer the caveman compression level from the run's existing RARV-tier signal,
# so the level is DECIDED by inspecting the work rather than asked of the user.
# No new user input is introduced: the tier already drives effort/model selection
# (loki_effort_for_tier, get_rarv_tier). On the bash route the tier is read from
# LOKI_CURRENT_TIER (exported by run_autonomous each iteration); the TS mirror
# (cavemanActivateEnv) receives the same tier vocabulary (planning|development|
# fast) via call.tier, so both routes infer identically from the same signal.
#
# INFERENCE RULE (deterministic, conservative-for-accuracy):
#   planning    (Reason phase -- architecture / design / nuanced reasoning) -> lite
#   development (Act / Reflect -- implementation, the prior default)         -> full
#   fast        (Verify phase -- testing / validation, more routine)         -> full
#   unknown / empty tier                                                     -> full
# The auto ceiling is "full": inference NEVER selects ultra. ultra is reachable
# only via an explicit LOKI_CAVEMAN_LEVEL override (the opt-out escape hatch), so
# the autonomous path can never compress hard enough to lose technical nuance.
# "lite" on planning protects the highest-nuance output (architecture/design);
# everything else stays at the established "full" default. When the tier is
# unknown we pick the SAFER (established) "full", never something more aggressive.
# shellcheck disable=SC2120  # $1 is optional; callers rely on the global fallback
_loki_caveman_infer_level() {
    local tier="${1:-${LOKI_CURRENT_TIER:-}}"
    case "$tier" in
        planning) printf '%s' "lite" ;;
        *)        printf '%s' "full" ;;
    esac
}

# Rank a caveman mode by compression aggressiveness for the no-raise comparison.
# Higher number = more aggressive (drops more nuance). "off" is the floor; unknown
# or empty modes rank as -1 (treated as "no opinion", so they never win a min()).
# The wenyan-* variants mirror their plain counterparts' aggressiveness.
_loki_caveman_level_rank() {
    case "${1:-}" in
        off)                      printf '0' ;;
        lite|wenyan-lite)         printf '1' ;;
        full|wenyan|wenyan-full)  printf '2' ;;
        ultra|wenyan-ultra)       printf '3' ;;
        *)                        printf '%s' '-1' ;;
    esac
}

# Caveman config dir resolution mirrors caveman-config.js getConfigDir(): honors
# CLAUDE_CONFIG_DIR for the flag file location used to detect an existing install.
_loki_caveman_claude_dir() {
    printf '%s' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
}

# True (0) when caveman appears installed: its SessionStart hook file exists in
# the resolved Claude config dir. Best-effort, read-only. We check the hook the
# upstream installer writes to ~/.claude/hooks/caveman-activate.js (standalone)
# OR a plugin install marker. Either presence means activation will fire.
_loki_caveman_installed() {
    local dir
    dir="$(_loki_caveman_claude_dir)"
    [ -f "$dir/hooks/caveman-activate.js" ] && return 0
    # Plugin install: the activate hook lives under a plugin root; the flag file
    # path is stable. Treat a prior-session flag file as a weaker install signal.
    [ -f "$dir/.caveman-active" ] && return 0
    return 1
}

# Capability gate: can caveman compression be USED on this run? Provider is
# Claude AND the claude CLI is present AND caveman is installed-or-bootstrappable
# AND it is not disabled by the LOKI_CAVEMAN knob. Returns 0 when usable, 1
# otherwise (callers emit an honest message + degrade to an uncompressed run).
# Mirrors loki_workflows_supported's shape (provider + CLI + not-disabled).
loki_caveman_supported() {
    # Provider must be Claude (Tier 1). caveman is Claude-Code-only.
    [ "${LOKI_PROVIDER:-claude}" = "claude" ] || return 1
    command -v claude >/dev/null 2>&1 || return 1
    # Opt-out knob also suppresses the capability (no activation when off).
    [ "${LOKI_CAVEMAN:-1}" = "0" ] && return 1
    # Installed now, OR bootstrappable (node + npx present so the pin can install
    # on demand). Either way activation can take effect this run or the next.
    if _loki_caveman_installed; then
        return 0
    fi
    command -v node >/dev/null 2>&1 && command -v npx >/dev/null 2>&1 && return 0
    return 1
}

# Activation knob: is caveman compression ENABLED for free-form subcalls?
# DEFAULT ON (LOKI_CAVEMAN unset or 1). Opt out with LOKI_CAVEMAN=0.
#
# CROSS-COUPLING GUARD (moat safety): when LOKI_LEGACY_COMPLETION_MATCH=true the
# runner detects completion by grepping the MAIN-loop prose for the completion
# promise (run.sh:9641). Compressing the main loop would mangle that prose and
# break the legacy detector, so caveman activation is DISABLED whenever the
# legacy prose-match path is in use. The default completion path (the
# loki_complete_task MCP tool / COMPLETION_REQUESTED signal file) is immune to
# compression, so the default config keeps caveman on.
loki_caveman_enabled() {
    [ "${LOKI_CAVEMAN:-1}" = "0" ] && return 1
    [ "${LOKI_LEGACY_COMPLETION_MATCH:-false}" = "true" ] && return 1
    return 0
}

# The activation env VALUE for a free-form subcall: the configured level, or
# empty when activation is not warranted (caveman unsupported or disabled). The
# caller inlines it as a per-invocation env prefix (NEVER `export` -- a persisted
# export would bleed into later parsed subcalls and defeat the carve-out):
#   _cm_lvl="$(loki_caveman_activate_env)"
#   if [ -n "$_cm_lvl" ]; then
#       CAVEMAN_DEFAULT_MODE="$_cm_lvl" claude ...   # free-form only
#   else
#       claude ...
#   fi
#
# NO-RAISE (v7.41.0 R2 finding 4): the level returned is the configured Loki
# level, EXCEPT we never silently RAISE a user who set a LOWER global caveman
# level. If the user globally chose "lite" (less aggressive, preserves more
# nuance) we honor "lite" rather than forcing "full" on their free-form output.
# We only ever lower toward the user's preference, never above it; the activation
# level itself is the conservative-for-accuracy ceiling. The user's global mode is
# captured at source time into LOKI_CAVEMAN_USER_MODE before the default-off
# export clobbers CAVEMAN_DEFAULT_MODE. Unknown / empty user modes (rank -1) are
# ignored so a malformed value can never accidentally suppress activation.
loki_caveman_activate_env() {
    loki_caveman_supported || return 0
    loki_caveman_enabled   || return 0
    # #593: the level is the EXPLICIT user value when LOKI_CAVEMAN_LEVEL was set
    # (override / opt-out escape hatch), else the INFERRED level from the RARV
    # tier. The no-raise guard below then runs unchanged on this base, so an
    # explicit level is still lowered toward a user's lower global mode exactly
    # as before -- "override" means override the inference, not the no-raise.
    local level
    if [ -n "${LOKI_CAVEMAN_LEVEL_USERSET:-}" ]; then
        level="${LOKI_CAVEMAN_LEVEL:-full}"
    else
        level="$(_loki_caveman_infer_level)"
    fi
    # Respect (never exceed) a user's explicitly-lower global level. A user who
    # globally set CAVEMAN_DEFAULT_MODE=off opted OUT of compression entirely;
    # honor that by activating nothing (empty -> bare claude invocation).
    local user_mode="${LOKI_CAVEMAN_USER_MODE:-}"
    if [ "$user_mode" = "off" ]; then
        return 0
    fi
    if [ -n "$user_mode" ]; then
        local user_rank level_rank
        user_rank="$(_loki_caveman_level_rank "$user_mode")"
        level_rank="$(_loki_caveman_level_rank "$level")"
        # Only defer to the user when their mode is a recognized, lower level.
        if [ "$user_rank" -ge 0 ] && [ "$level_rank" -ge 0 ] && [ "$user_rank" -lt "$level_rank" ]; then
            level="$user_mode"
        fi
    fi
    printf '%s' "$level"
}

# The suppression env VALUE for a parsed-output subcall: ALWAYS "off",
# UNCONDITIONALLY. Not gated on supported/enabled (see the design note above):
# it must hard-disable caveman on a trust-gate subcall even when a user has
# caveman globally on but LOKI_CAVEMAN=0, and it is a harmless no-op env value
# when caveman is absent. Every parsed-output call site uses this ONE helper so
# the carve-out is uniform:
#   CAVEMAN_DEFAULT_MODE="$(loki_caveman_suppress_env)" claude ...
loki_caveman_suppress_env() {
    printf '%s' "off"
}

# Idempotent on-demand bootstrap of caveman at the pinned version. Best-effort:
# installs once per machine, caches a marker under .loki/ so repeat runs are a
# no-op, degrades cleanly (run proceeds UNCOMPRESSED) with an honest stderr line
# if anything is missing or the upstream installer is unreachable. NEVER blocks
# or fails the run. Returns 0 if caveman is (now) installed, 1 on clean degrade.
#
# Opt out with LOKI_CAVEMAN_AUTO_BOOTSTRAP=0. Only attempts when provider==claude
# and the LOKI_CAVEMAN knob is on.
#
# GLOBAL SIDE EFFECT (disclosed): caveman installs GLOBALLY -- the upstream
# installer adds a SessionStart hook to ~/.claude/settings.json (or
# $CLAUDE_CONFIG_DIR) that affects EVERY Claude Code session on this machine, not
# only Loki runs. This is caveman's only install mode. The bootstrap therefore
# runs the upstream installer exactly as a user's own `curl|bash` would; we do
# not author or vendor any caveman file. The one-time stderr line below names
# this so the operator is never surprised.
#
# HARDENING: the npx call is forced non-interactive (--non-interactive, plus
# </dev/null so no stdin read can ever block), time-bounded, and run from a
# disposable directory. The upstream installer may create project-local agent
# files while wiring its global Claude hook; isolating its cwd prevents those
# files, lockfiles, or compiled Python cache entries from polluting the user's
# feature branch and proof diff. caveman's installer is already auto-
# non-interactive without a TTY, but we belt-and-suspenders it.
# Caveman bootstrap notices. In quiet `loki quick` (inner run, not verbose) they
# go to a run log next to quick-run.log, not the user's terminal; otherwise stderr.
_loki_caveman_say() {
    if [ -n "${LOKI_QUICK_INNER:-}" ] && [ "${LOKI_VERBOSE:-0}" != "1" ] && [ -d "${LOKI_QUICK_DIR:-}" ]; then
        printf '%s\n' "$1" >>"$LOKI_QUICK_DIR/quick-run.notices.log" 2>/dev/null || true
    else
        printf '%s\n' "$1" >&2
    fi
}

loki_caveman_bootstrap() {
    [ "${LOKI_CAVEMAN:-1}" = "0" ] && return 1
    [ "${LOKI_CAVEMAN_AUTO_BOOTSTRAP:-1}" = "0" ] && return 1
    [ "${LOKI_PROVIDER:-claude}" = "claude" ] || return 1
    # Already installed -> nothing to do.
    if _loki_caveman_installed; then
        return 0
    fi
    local ver="${LOKI_CAVEMAN_VERSION:-1.9.0}"
    local marker_dir=".loki/state"
    local marker="$marker_dir/caveman-bootstrap-${ver}.done"
    # Cached attempt marker: do not re-attempt a failed install over and over
    # within the same project tree (idempotent one-shot per pinned version).
    if [ -f "$marker" ]; then
        _loki_caveman_installed && return 0 || return 1
    fi
    if ! command -v node >/dev/null 2>&1 || ! command -v npx >/dev/null 2>&1; then
        _loki_caveman_say "[caveman] node>=18 + npx required to bootstrap; skipping (run proceeds uncompressed). Install Node or set LOKI_CAVEMAN=0 to silence."
        mkdir -p "$marker_dir" 2>/dev/null && : > "$marker" 2>/dev/null || true
        return 1
    fi
    _loki_caveman_say "[caveman] bootstrapping output-token compressor v${ver} (one-time, pinned). NOTE: caveman installs GLOBALLY (a Claude Code SessionStart hook in ~/.claude affecting every Claude Code session). Loki applies it only to free-form generation, NEVER to trust-gate subcalls. Opt out: LOKI_CAVEMAN=0."
    # Pin via the git tag (v-prefixed) on the npx ref AND CAVEMAN_REF so the
    # downloaded hooks match the pinned release. Default install (no --all) wires
    # the Claude Code hook for the detected `claude` CLI. A timeout backstops a
    # network stall; </dev/null guarantees no interactive stdin read blocks.
    local _cm_runner="npx"
    if command -v timeout >/dev/null 2>&1; then
        _cm_runner="timeout 120 npx"
    fi
    local bootstrap_tmp=""
    bootstrap_tmp="$(mktemp -d "${TMPDIR:-/tmp}/loki-caveman-bootstrap.XXXXXX")" || {
        _loki_caveman_say "[caveman] could not allocate an isolated bootstrap directory; run proceeds uncompressed."
        mkdir -p "$marker_dir" 2>/dev/null && : > "$marker" 2>/dev/null || true
        return 1
    }
    local bootstrap_rc=1
    (
        cd "$bootstrap_tmp" || exit 1
        CAVEMAN_REF="v${ver}" $_cm_runner -y "github:JuliusBrussee/caveman#v${ver}" -- --non-interactive >/dev/null 2>&1 </dev/null
    ) && bootstrap_rc=0
    rm -rf -- "$bootstrap_tmp" 2>/dev/null || true
    if [ "$bootstrap_rc" -eq 0 ]; then
        mkdir -p "$marker_dir" 2>/dev/null && : > "$marker" 2>/dev/null || true
        if _loki_caveman_installed; then
            _loki_caveman_say "[caveman] installed v${ver}."
            return 0
        fi
    fi
    _loki_caveman_say "[caveman] bootstrap unavailable (upstream unreachable, timed out, or install failed); run proceeds uncompressed."
    mkdir -p "$marker_dir" 2>/dev/null && : > "$marker" 2>/dev/null || true
    return 1
}

# ---------------------------------------------------------------------------
# Session-continuity Phase 2 (GitHub #165) -- LOKI_RESUME_SESSION recovery resume
#
# NAMING COLLISION WARNING: Loki already has a user-facing `loki heal --resume`
# / `loki migrate --resume` CHECKPOINT flag (autonomy/loki). LOKI_RESUME_SESSION
# governs the CLAUDE-CLI session-resume layer (claude --resume <uuid>), NOT the
# Loki checkpoint resume. They are unrelated.
#
# SCOPE (minimum useful, design s2): on a RESTARTED run (the prior run was
# interrupted -- crash / rate limit / --max-budget cutoff -- so its non-terminal
# autonomy-state.json persisted iterationCount>0), the FIRST main-loop claude
# call of the restarted run emits `claude --resume <stored-uuid>` (the stable
# per-run uuid from .loki/state/claude-session.json) instead of a fresh
# stateless call, reattaching the prior Claude context. After that single
# resumed call the run reverts to normal per-iteration stateless behavior
# (injected memory carries forward as today). This is a RECOVERY feature, NOT a
# per-iteration resume chain -- so transcript growth cannot accumulate.
#
# CONSERVATIVE DEFAULT OFF: with the knob unset the default claude argv is
# byte-identical to v7.34 (no --resume ever emitted). Opt IN with
# LOKI_RESUME_SESSION=1. Gated on CLI support so an older claude degrades.
#
# MUTUAL EXCLUSION: --session-id and --resume are mutually exclusive on one
# claude invocation (claude rejects a session-id already in use, and resume
# replaces the fresh-session intent). The run.sh main-loop block emits the
# resume slice INSTEAD of the stamp slice on the one resumed call, never both.
# ---------------------------------------------------------------------------

# Predicate: is recovery resume enabled AND supported? CONSERVATIVE DEFAULT OFF.
# Opt IN with LOKI_RESUME_SESSION=1; gated on `claude --resume` support so an
# older CLI degrades to normal stateless behavior (no flag emitted).
loki_resume_session_enabled() {
    [ "${LOKI_RESUME_SESSION:-0}" = "1" ] || return 1
    loki_claude_flag_supported "--resume"
}

# Predicate: when resuming, also fork into a NEW session id (leaving the parent
# transcript untouched)? Only honored together with LOKI_RESUME_SESSION=1.
# DEFAULT OFF. Gated on `claude --fork-session` support.
loki_session_fork_enabled() {
    [ "${LOKI_SESSION_FORK:-0}" = "1" ] || return 1
    loki_resume_session_enabled || return 1
    loki_claude_flag_supported "--fork-session"
}

# The stable per-run claude session uuid to resume, read from the run-start
# metadata file .loki/state/claude-session.json (written on the FRESH run, so it
# survives into a restart). Emits the stored uuid on stdout, or nothing when the
# file is absent / unreadable / has no uuid (caller then skips resume and runs a
# normal fresh call -- safe degrade). Pure read, no side effects. Honors LOKI_DIR
# / TARGET_DIR exactly like the rest of run.sh.
_loki_resume_target_uuid() {
    local loki_dir="${LOKI_DIR:-${TARGET_DIR:-.}/.loki}"
    local cs_file="$loki_dir/state/claude-session.json"
    [ -s "$cs_file" ] || return 0
    command -v python3 >/dev/null 2>&1 || return 0
    _LOKI_CS_FILE="$cs_file" python3 - <<'RESUME_UUID_PY' 2>/dev/null || true
import json, os, re
try:
    with open(os.environ["_LOKI_CS_FILE"]) as f:
        d = json.load(f)
    if not isinstance(d, dict):
        raise ValueError
    u = d.get("claude_session_uuid", "")
    # RFC-4122 shape check: only print a well-formed uuid so a corrupt file
    # never injects a bogus --resume argument.
    if isinstance(u, str) and re.match(
        r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", u
    ):
        print(u, end="")
except Exception:
    pass
RESUME_UUID_PY
}

# ---------- v9.23.0 subagent fleet capacity (Claude Code 2.1.217 defaults) ----------
# WHY THIS EXISTS. Claude Code 2.1.217 changed two subagent defaults, and both
# silently degrade a fan-out harness rather than failing loudly:
#
#   "Changed subagents to no longer spawn nested subagents by default; set
#    CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH to allow deeper nesting"
#   "Added a cap on concurrently-running subagents (default 20, override with
#    CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS) so one message can't fan out
#    unbounded background agents"
#   -- https://github.com/anthropics/claude-code/releases/tag/v2.1.217
#
# The spawn-depth default dropped from 5 to 1 (mirrored in Agent SDK 0.3.217).
# Loki's documented SDLC fleet pattern dispatches an agent that itself delegates
# (skills/model-selection.md), so at depth 1 the second level never spawns: the
# fleet quietly collapses to a single tier and the run still reports success.
# That is the dangerous shape -- no error, just less work done than claimed.
#
# We set the depth EXPLICITLY rather than relying on any default, in either
# direction. 2 is the depth Loki actually uses (orchestrator -> dev/reviewer
# agent -> its own helpers); we do not raise it further, because unbounded
# nesting is the runaway the upstream default exists to prevent.
#
# Concurrency is left at the upstream default of 20 unless the operator raises
# it: the council (3 reviewers) and dev fleet (3-5) both sit well under it, and
# a harness that silently raises a safety cap on every user's machine is the
# wrong trade. We export it only when the operator asked for more, so the value
# shows up in a diff rather than being an invisible default.
#
# Both are opt-out via LOKI_SUBAGENT_CAPACITY=0 and honor a pre-set value from
# the environment, so an operator's own export always wins.
loki_export_subagent_capacity() {
    [ "${LOKI_SUBAGENT_CAPACITY:-1}" = "0" ] && return 0

    if [ -z "${CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH:-}" ]; then
        export CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH="${LOKI_SUBAGENT_SPAWN_DEPTH:-2}"
    fi

    # Only exported when explicitly requested; no silent raise of the cap.
    if [ -n "${LOKI_SUBAGENT_CONCURRENCY:-}" ] && \
       [ -z "${CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS:-}" ]; then
        export CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS="$LOKI_SUBAGENT_CONCURRENCY"
    fi
    return 0
}

# Applied at SOURCE time, like the caveman default-off export above: the whole
# subprocess tree inherits it, so every subcall site gets the same capacity
# without each one having to remember to call this.
loki_export_subagent_capacity

# P12-26 / C-C2: the compiled permission policy swaps --dangerously-skip-permissions for
# --permission-mode / --allowedTools / --disallowedTools compiled from the task policy by
# src/policies/claude_permissions.js (shared with the TS permissionArgv). Resolution:
#   LOKI_PERMISSION_POLICY=legacy    -> legacy (old flag, byte-identical)
#   LOKI_PERMISSION_POLICY=compiled  -> compiled (fail-closed acceptEdits with no policy)
#   unset                            -> compiled only when an operator claude_permissions
#                                       policy exists, else legacy (headless acceptEdits
#                                       with no allowedTools denies npm test / git commit)
#   any other value                  -> compiled, with a one-time warning per process
# Result lands in _LOKI_PERM_ARGV. A compile failure fails closed to plan.
_LOKI_PERM_UNKNOWN_WARNED=0
_loki_permission_policy_mode() {
    case "${LOKI_PERMISSION_POLICY:-}" in
        legacy) echo legacy ;;
        compiled) echo compiled ;;
        "")
            local _helper
            _helper="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/src/policies/claude_permissions.js"
            if command -v node >/dev/null 2>&1 && [ -f "$_helper" ] \
                && [ "$(node "$_helper" --has-operator-policy 2>/dev/null)" = "1" ]; then
                echo compiled
            else
                echo legacy
            fi
            ;;
        *) echo compiled ;;
    esac
}

# Unknown-value warning, once per process. Runs in the caller shell (never inside $(...)),
# so the guard variable persists.
_loki_permission_warn_unknown_once() {
    case "${LOKI_PERMISSION_POLICY:-}" in
        ""|legacy|compiled) return 0 ;;
    esac
    [ "${_LOKI_PERM_UNKNOWN_WARNED:-0}" = "1" ] && return 0
    _LOKI_PERM_UNKNOWN_WARNED=1
    echo "[loki] WARNING: LOKI_PERMISSION_POLICY=${LOKI_PERMISSION_POLICY}: unknown value; using compiled" >&2
}

# The one start-banner line naming the active mode.
loki_permission_policy_banner() {
    if [ "$(_loki_permission_policy_mode 2>/dev/null)" = "legacy" ]; then
        if [ -z "${LOKI_PERMISSION_POLICY:-}" ]; then
            echo "Permission policy: legacy (no operator policy found; add one to use compiled)"
        else
            echo "Permission policy: legacy (unset LOKI_PERMISSION_POLICY to use compiled)"
        fi
    else
        echo "Permission policy: compiled (set LOKI_PERMISSION_POLICY=legacy to revert)"
    fi
}

_loki_claude_permission_argv() {
    _LOKI_PERM_ARGV=(--dangerously-skip-permissions)
    _loki_permission_warn_unknown_once
    local _helper _out _a
    _helper="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/src/policies/claude_permissions.js"
    if [ "$(_loki_permission_policy_mode)" = "legacy" ]; then
        # w2-07: legacy keeps bypass but carries the default web/credential deny rules (same js source).
        # Unusable compiler or helper failure fails closed to plan, like compiled mode and the TS route.
        _LOKI_PERM_ARGV=(--permission-mode plan)
        if ! command -v node >/dev/null 2>&1 || [ ! -f "$_helper" ]; then
            echo "[loki] WARNING: legacy permission policy: compiler unavailable; using most restrictive mode (plan)" >&2
            return 0
        fi
        _out="$(node "$_helper" --legacy-args)" || return 0
        [ -n "$_out" ] || return 0
        _LOKI_PERM_ARGV=()
        while IFS= read -r _a; do _LOKI_PERM_ARGV+=("$_a"); done <<< "$_out"
        return 0
    fi
    _LOKI_PERM_ARGV=(--permission-mode plan)
    if ! command -v node >/dev/null 2>&1 || [ ! -f "$_helper" ]; then
        echo "[loki] WARNING: compiled permission policy: compiler unavailable; using most restrictive mode (plan)" >&2
        return 0
    fi
    _out="$(node "$_helper" "${LOKI_PROJECT_DIR:-$PWD}")" || return 0
    [ -n "$_out" ] || return 0
    _LOKI_PERM_ARGV=()
    while IFS= read -r _a; do _LOKI_PERM_ARGV+=("$_a"); done <<< "$_out"
}

# SHELL value for a claude child. Claude Code's Bash tool replays the aliases of
# the shell named in SHELL, so a user's `alias ls=eza` hangs agent Bash commands.
# Pin /bin/bash when executable (same rule as the engine10 route,
# loki-ts/src/runner/worker_proc.ts pinBashShellEnv); LOKI_E10_KEEP_USER_SHELL=1
# keeps the user's shell. Only SHELL changes; claude spawn sites only.
_loki_claude_shell_value() {
    if [ "${LOKI_E10_KEEP_USER_SHELL:-}" != "1" ] && [ -x /bin/bash ]; then
        printf '%s' "/bin/bash"
    else
        printf '%s' "${SHELL-}"
    fi
}
