#!/usr/bin/env bash
# vibe-delegate — Launch Vibe programmatically and capture the result
# Usage: vibe-delegate <workdir> <prompt> [max-turns] [agent] [timeout-secs]
#
# ROOT CAUSE FIX (2026-05-10):
#   vibe checks for a TTY on startup. Without one (e.g. when piped directly),
#   it hangs indefinitely waiting for terminal initialisation → 0 tool calls,
#   silent timeout. Fix: wrap vibe in `script -q -c "..." /dev/null` to allocate
#   a pseudo-TTY. The JSON streaming output is then piped cleanly to the parser.
#
# TTY ALLOCATION (2026-05-23):
#   Superseded the `script` wrapper above with python3 pty.spawn — more portable
#   (no GNU/BSD flag differences) and works even without a parent controlling TTY.
#   See the SCRIPT_CMD definition below for the current implementation.
#
# PROMPT SAFETY (2026-05-12):
#   Long prompts with Python code, dicts, colons, emojis, or accented chars are
#   passed via a temp file (read by vibe via --prompt-file if supported, else via
#   a wrapper that cats the file). This avoids shell injection / truncation bugs.
#
# RUN LOG (2026-05-12):
#   Appends one JSONL entry to ~/.local/share/delegate-runs.jsonl after each run.
#   Fields: ts, delegate, project, prompt_words, agent, max_turns, timeout_secs,
#           exit_code, timed_out, tool_calls, files_changed, syntax_errors,
#           duration_secs, tokens_in, tokens_out, tokens_total, cost_usd, model,
#           warn_count, search_replace_fails, cost_claude_eq, wrote_nothing,
#           failure_reason, adaptations.

# Pull out --require "<string>" flags (repeatable) from anywhere in the args,
# then treat the rest as positional. Each required string must already exist in
# the workdir or the run is aborted before launch (see pre-flight gate below).
REQUIRES=()
POSITIONAL=()
VERBOSE=0
while [ $# -gt 0 ]; do
  case "$1" in
    --require) REQUIRES+=("$2"); shift 2 ;;
    --verbose) VERBOSE=1; shift ;;
    *) POSITIONAL+=("$1"); shift ;;
  esac
done
set -- "${POSITIONAL[@]}"

WORKDIR="${1:?Usage: vibe-delegate <workdir> <prompt> [max-turns] [agent] [timeout-secs] [--require STR ...]}"
PROMPT="${2:?Prompt required}"
MAX_TURNS="${3:-10}"
AGENT="${4:-}"
TIMEOUT_SECS="${5:-180}"

# Hard ceiling: SKILL.md has documented "never exceed 12 turns, decompose instead"
# since 2026-05, but nothing enforced it — the seo-monitor session (2026-06-17) ran
# a 4-file refactor at max-turns=12 that still only got 2/4 files done for 334k tokens.
# Decomposition was the documented fix; this makes it impossible to skip by accident.
if [ "$MAX_TURNS" -gt 12 ] 2>/dev/null; then
  echo "WARNING: max-turns $MAX_TURNS exceeds the hard cap of 12 — clamping to 12. Decompose the task into sequential runs instead of raising this." >&2
  MAX_TURNS=12
fi

if [ ! -d "$WORKDIR" ]; then
  echo "ERROR: workdir '$WORKDIR' does not exist" >&2
  exit 1
fi

# Pre-flight gate: a search_replace can't match an anchor that isn't in the file,
# so abort before wasting a vibe run if any --require string is absent. Turns the
# "grep the target first" rule into an enforced check; logs a cheap precheck_abort.
if [ "${#REQUIRES[@]}" -gt 0 ]; then
  MISSING=()
  for s in "${REQUIRES[@]}"; do
    grep -rqF -- "$s" "$WORKDIR" 2>/dev/null || MISSING+=("$s")
  done
  if [ "${#MISSING[@]}" -gt 0 ]; then
    echo "=== PRECHECK ABORT — required string(s) not found in $WORKDIR ==="
    for s in "${MISSING[@]}"; do echo "  missing: $s"; done
    echo "Nothing was launched. Fix the anchor (grep locally) or the workdir, then retry."
    python3 - "$WORKDIR" <<'PYABORT'
import json, os, sys
from pathlib import Path
from datetime import datetime, timezone
wd = sys.argv[1]
LOG = Path.home() / '.local' / 'share' / 'delegate-runs.jsonl'
LOG.parent.mkdir(parents=True, exist_ok=True)
entry = {
    'ts': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
    'delegate': 'vibe', 'workdir': wd, 'project': os.path.basename(wd.rstrip('/')),
    'exit_code': 2, 'tool_calls': 0, 'files_changed': 0, 'tokens_total': 0,
    'duration_secs': 0, 'failure_reason': 'precheck_abort',
}
with open(LOG, 'a') as f:
    f.write(json.dumps(entry) + '\n')
PYABORT
    exit 2
  fi
fi

# Model override: /vibe-model-pick writes alias to this flag file.
# VIBE_ACTIVE_MODEL is inherited by the script child process via `script`.
VIBE_MODEL_FLAG="$HOME/.local/share/vibe-model.flag"
VIBE_MODEL_OVERRIDE=""
if [ -f "$VIBE_MODEL_FLAG" ]; then
  VIBE_MODEL_OVERRIDE=$(tr -d '[:space:]' < "$VIBE_MODEL_FLAG")
  export VIBE_ACTIVE_MODEL="$VIBE_MODEL_OVERRIDE"
fi
export DELEGATE_VERBOSE="$VERBOSE"

cd "$WORKDIR"
START_NS=$(date +%s%N)
PROMPT_WORDS=$(printf '%s' "$PROMPT" | wc -w | tr -d ' ')
GIT_BEFORE=$(git rev-parse HEAD 2>/dev/null || echo "no-git")

# Sentinel file for mtime-based change detection. `git diff --name-only` is binary
# (modified-vs-not), so it misses a re-edit to a file that was ALREADY dirty before
# this run started — exactly what happens with sequential same-file delegations,
# which RC-2 itself recommends as the mitigation for parallel edits. It also reports
# nothing at all in a non-git workdir. mtime-since-sentinel catches every real write
# regardless of prior git state.
SENTINEL=$(mktemp /tmp/vibe-sentinel-XXXXXX)

echo "=== VIBE START ==="
echo "Workdir : $WORKDIR"
echo "Agent   : ${AGENT:-auto-approve}"
echo "Model   : ${VIBE_MODEL_OVERRIDE:-(config default)}"
echo "Turns   : $MAX_TURNS"
echo "Timeout : ${TIMEOUT_SECS}s"
echo "Prompt  : ${PROMPT:0:120}..."
echo "==================="

# Write prompt to a temp file — avoids shell injection when prompt contains
# colons, quotes, emojis, accented chars, or multi-line Python/JS code.
PROMPT_FILE=$(mktemp /tmp/vibe-prompt-XXXXXX.txt)
printf '%s' "$PROMPT" > "$PROMPT_FILE"

# Write the vibe command to a temp script.
# Reads the prompt from the file to stay safe from shell expansion.
VIBE_SCRIPT=$(mktemp /tmp/vibe-cmd-XXXXXX.sh)

# Stats handoff file: Python parser writes JSON here; bash reads it for the log.
DELEGATE_STATS_FILE=$(mktemp /tmp/delegate-stats-XXXXXX.json)
printf '{}' > "$DELEGATE_STATS_FILE"
export DELEGATE_STATS_FILE

trap 'rm -f "$VIBE_SCRIPT" "$PROMPT_FILE" "$DELEGATE_STATS_FILE" "$SENTINEL"' EXIT

{
  echo '#!/usr/bin/env bash'
  echo "PROMPT_CONTENT=$(printf '%q' "$(cat "$PROMPT_FILE")")"
  # auto-approve is required for non-interactive tool execution (file writes, python3, etc.)
  # Explicit --agent overrides this. Valid primary agents (vibe 2026-08):
  #   default | plan | accept-edits | auto-approve | lean
  # 'explore' exists but is a SUBAGENT — --agent rejects it outright.
  _VIBE_AGENT="${AGENT:-auto-approve}"
  printf 'exec vibe --agent %q -p "$PROMPT_CONTENT" --trust --max-turns %q --output streaming --workdir %q\n' \
    "$_VIBE_AGENT" "$MAX_TURNS" "$WORKDIR"
} > "$VIBE_SCRIPT"
chmod +x "$VIBE_SCRIPT"

# Pseudo-TTY for vibe via python pty.spawn (works without a parent TTY, unlike `script`).
SCRIPT_CMD=(python3 -c 'import pty,sys,os; sys.exit(os.waitstatus_to_exitcode(pty.spawn(sys.argv[1:])))' "$VIBE_SCRIPT")

timeout "$TIMEOUT_SECS" "${SCRIPT_CMD[@]}" | python3 -c "
import sys, json, re, os

ANSI_RE = re.compile(r'\x1b\[[0-9;]*[mABCDEFGHJKSTfhilmnprsu]|\x1b\([AB]|\x0f|\x0e')
# Everything unprintable except tab / newline / CR.
CTRL_RE = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]')
tools_called = 0
warn_count = 0
sr_fails = 0
sandbox_blocked = False
denied_count = 0
writes = []
def clean(line):
    line = line.replace('\r', '')
    return ANSI_RE.sub('', line).strip()

def safe(s):
    # clean() only strips escapes from the RAW stdin line. An ESC written as
    # an escaped-unicode literal (backslash-u-001b) inside a JSON string value
    # arrives here escape-free, so clean() sees nothing to strip; json.loads
    # then decodes it back into a real ESC, which reaches stdout when printed.
    # A tool name, path or denial reason can therefore repaint the terminal
    # (erase-line, cursor-up) and forge output. That matters twice over: this
    # text is read by a terminal AND fed back to the orchestrating model.
    # So strip at every print of a decoded value, not just on the raw line.
    return CTRL_RE.sub('', ANSI_RE.sub('', str(s)))

def as_text(content):
    # Vibe emits message content EITHER as a plain string (session-log /
    # legacy schema) OR as a list of content blocks (current --output
    # streaming schema: [{'type': 'text', 'text': ...}]). Calling .lower()
    # straight on the list crashed the whole parser and threw away the run
    # output, so normalise to str once, here, before anything touches it.
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        parts = []
        for blk in content:
            if isinstance(blk, dict):
                parts.append(blk.get('text') or blk.get('content') or '')
            elif isinstance(blk, str):
                parts.append(blk)
        return chr(10).join(p for p in parts if p)
    if content is None:
        return ''
    return str(content)

for raw in sys.stdin:
    line = clean(raw)
    if not line:
        continue
    try:
        msg = json.loads(line)
    except json.JSONDecodeError:
        # vibe reports startup failures as PLAIN TEXT on stdout, not JSON —
        # e.g. 'Error: Agent X not found' or 'Agent X is a subagent and cannot
        # be used as the primary agent'. Silently swallowing every non-JSON
        # line meant those surfaced only as exit 1 after 1.5s with no reason.
        low = line.lower()
        if low.startswith('error') or 'not found' in low or 'cannot be used' in low or 'usage:' in low:
            warn_count += 1
            print(f'  [ERROR] {safe(line)[:200]}', flush=True)
        continue

    role    = msg.get('role', '')
    content = as_text(msg.get('content', ''))
    etype   = msg.get('type', '')

    if role in ('system', 'user'):
        continue

    if 'not permitted' in content.lower():
        sandbox_blocked = True

    # ── streaming schema: tool calls arrive as 'effect', not role='tool' ──
    # Without this the run reports 'Tool calls: 0' even when the agent read
    # and wrote files, which then mislabels a fine run as WROTE_NOTHING.
    if etype == 'effect':
        detail = msg.get('detail') or {}
        tool_name = safe(detail.get('toolName') or msg.get('title') or '')
        kind = detail.get('kind') or ''
        tinput = detail.get('input') or {}
        filepath = safe(tinput.get('filePath') or tinput.get('file_path') or '')
        state = msg.get('state') or {}
        status = state.get('status') or ''
        disp = state.get('display') or {}
        tools_called += 1
        if status in ('cancelled', 'skipped'):
            # A denied approval lands here. Silent in the old parser: the run
            # exited 0 having done nothing, with no hint why.
            # 'skipped' matters too: project_effect_state tests event.cancelled
            # first and event.skipped second, so a denial carrying a custom
            # decision.feedback — and every pre-tool hook denial — arrives as
            # SkippedEffectState and was being printed as a plain [WARN].
            denied_count += 1
            sandbox_blocked = True
            reason = safe(disp.get('message') or state.get('reason') or 'denied')
            print(f'  [DENIED] {tool_name} {filepath} - {reason}', flush=True)
        elif kind in ('file_write', 'file_edit') or 'write' in tool_name.lower() or 'edit' in tool_name.lower():
            if filepath:
                writes.append(filepath)
            if 'search_replace' in tool_name.lower():
                ok = disp.get('success', True)
                if not ok:
                    sr_fails += 1
                sr_status = 'OK' if ok else 'FAIL'
                print(f'  [tool]  search_replace [{sr_status}] {filepath}', flush=True)
            else:
                print(f'  [tool]  file: {filepath or tool_name}', flush=True)
        elif kind == 'file_read' or 'read' in tool_name.lower():
            print(f'  [read]  {filepath or tool_name}', flush=True)
        else:
            summary = safe(disp.get('message') or (detail.get('display') or {}).get('summary') or '')
            print(f'  [tool]  {tool_name}: {summary[:100]}', flush=True)
        if disp.get('success') is False and status not in ('cancelled', 'skipped'):
            warn_count += 1
            # Print it — a counted-but-invisible warn is useless. The common
            # one is 'Output (N KiB) exceeds maximum allowed size (50.0 KiB)',
            # which means the agent silently saw only PART of the file.
            failmsg = safe(disp.get('message') or state.get('reason') or '')
            if failmsg:
                print(f'  [WARN]  {tool_name}: {failmsg[:160]}', flush=True)
        continue

    # Approval prompts that need a human: also invisible before.
    if etype == 'callback' and (msg.get('detail') or {}).get('kind') == 'approval':
        perms = (msg.get('detail') or {}).get('requiredPermissions') or []
        labels = ', '.join(safe(p.get('label', '')) for p in perms if isinstance(p, dict))
        cb_title = safe(msg.get('title', '') or '')
        print(f'  [APPROVAL] {cb_title} {labels}', flush=True)
        continue

    if etype == 'reasoning':
        continue

    if role == 'assistant':
        # Escapes stripped, text kept — legitimate output must survive intact.
        text = safe(content).strip()
        if not text:
            continue
        print(f'  [vibe] {text[:400]}', flush=True)

    elif role == 'tool':
        tools_called += 1
        first_line = safe(content.split('\n')[0]) if content else ''
        tool_name  = safe(msg.get('tool', '') or msg.get('name', ''))

        # Detect writes to track files for syntax check
        if 'write' in tool_name.lower() or 'edit' in tool_name.lower():
            filepath = safe(msg.get('path') or msg.get('file_path') or '')
            if filepath:
                writes.append(filepath)
            print(f'  [tool]  file: {filepath or first_line[:80]}', flush=True)
        elif first_line.startswith('path:'):
            print(f'  [read]  {first_line[5:].strip()}', flush=True)
        elif 'search_replace' in tool_name.lower() or 'SEARCH' in content:
            status = 'FAIL' if 'failed' in content.lower() else 'OK'
            if status == 'FAIL':
                sr_fails += 1
            print(f'  [tool]  search_replace [{status}] {first_line[:80]}', flush=True)
        elif first_line.startswith('error') or 'Error' in first_line or 'failed' in first_line.lower():
            warn_count += 1
            print(f'  [WARN]  {first_line[:120]}', flush=True)
        elif 'matches:' in first_line or 'grep' in tool_name.lower():
            print(f'  [tool]  matches: {first_line[:100]}', flush=True)
        elif 'command' in tool_name.lower():
            print(f'  [tool]  command: {first_line[:100]}', flush=True)
        else:
            snippet = safe(first_line or content)[:100]
            print(f'  [tool]  {snippet}', flush=True)

_denied_note = f'  |  DENIED: {denied_count}' if denied_count else ''
print(f'Tool calls: {tools_called}  |  warns: {warn_count}  |  sr_fails: {sr_fails}{_denied_note}')
if denied_count:
    print('  ^ tool calls were DENIED (approval refused). Common cause: a path')
    print('    outside the workdir — --trust does not cover those. Move the file')
    print('    inside the workdir and re-run.')

# Write partial stats so the token block can merge into it.
_sf = os.environ.get('DELEGATE_STATS_FILE', '')
if _sf:
    try:
        with open(_sf, 'w') as _f:
            json.dump({'tool_calls': tools_called, 'warn_count': warn_count, 'search_replace_fails': sr_fails, 'sandbox_blocked': sandbox_blocked, 'denied_count': denied_count}, _f)
    except Exception:
        pass
"
VIBE_EXIT=${PIPESTATUS[0]}

# ── Real Mistral token counts from session log ─────────────────────────────────
VIBE_HOME="${VIBE_HOME:-$HOME/.vibe}"
# Only consider meta.json files written AFTER this run started (mtime newer than
# the pre-launch sentinel). Guards against inheriting a STALE session's tokens —
# happened on 2026-06-17 where a failed code-reviewer run silently inherited the
# prior run's 363,506 tokens — and narrows fan-out-vibe parallel cross-attribution
# to genuinely overlapping sessions.
SESSION_LOG=$(find "$VIBE_HOME/logs/session" -mindepth 2 -maxdepth 2 -name meta.json -newer "$SENTINEL" 2>/dev/null | xargs -r ls -t 2>/dev/null | head -1)
if [ -z "$SESSION_LOG" ]; then
  echo "(no session log newer than run start — token count unknown, not reusing stale data)"
fi
if [ -n "$SESSION_LOG" ]; then
  python3 -c "
import json, sys, os
try:
    with open('$SESSION_LOG') as f:
        data = json.load(f)
    stats = data.get('stats', {})
    prompt = stats.get('last_turn_prompt_tokens', 0)
    completion = stats.get('last_turn_completion_tokens', 0)
    total = stats.get('last_turn_total_tokens', prompt + completion)
    cost = stats.get('session_cost', 0)
    session_total = stats.get('session_total_llm_tokens', 0)
    # Exact cumulative in/out for the whole run — preferred over the last-turn ratio.
    session_in  = stats.get('session_prompt_tokens', 0)
    session_out = stats.get('session_completion_tokens', 0)
    model_name = data.get('config', {}).get('active_model', 'unknown')

    # Read per-model pricing from ~/.vibe/config.toml (fallback: Mistral Medium 3.5 rates)
    input_price, output_price = 1.5, 7.5
    try:
        import tomllib
    except ImportError:
        try:
            import tomli as tomllib
        except ImportError:
            tomllib = None
    if tomllib:
        config_path = os.path.expanduser('~/.vibe/config.toml')
        try:
            with open(config_path, 'rb') as cf:
                cfg = tomllib.load(cf)
            active = model_name  # use session's actual model, not config default
            for m in cfg.get('models', []):
                if m.get('alias') == active or m.get('name') == active:
                    input_price = m.get('input_price', input_price)
                    output_price = m.get('output_price', output_price)
                    break
        except Exception:
            pass

    # Each -p invocation creates a new session, so the session_* totals cover the
    # whole run. Prefer Vibe's exact cumulative in/out split; fall back to the
    # last-turn ratio only when the cumulative fields are absent (older Vibe).
    if session_in > 0 or session_out > 0:
        run_tokens_in, run_tokens_out = session_in, session_out
    else:
        run_total = session_total if session_total > 0 else total
        if (prompt + completion) > 0 and run_total > 0:
            ratio_in = prompt / (prompt + completion)
            run_tokens_in  = round(run_total * ratio_in)
            run_tokens_out = run_total - run_tokens_in
        else:
            run_tokens_in, run_tokens_out = prompt, completion
    run_total = run_tokens_in + run_tokens_out
    # NOTE: cost is cache-blind — Vibe's meta.json exposes no cache-hit/miss split,
    # so all input is priced at the full input rate. Treat cost_usd as an upper bound.
    run_cost = (run_tokens_in * input_price + run_tokens_out * output_price) / 1_000_000
    claude_cost = (run_tokens_in * 3.0 + run_tokens_out * 15.0) / 1_000_000
    verbose = os.environ.get('DELEGATE_VERBOSE', '0') == '1'
    print(f'Model               : {model_name}')
    if verbose:
        in_cost  = run_tokens_in  * input_price  / 1_000_000
        out_cost = run_tokens_out * output_price / 1_000_000
        cl_in    = run_tokens_in  * 3.0  / 1_000_000
        cl_out   = run_tokens_out * 15.0 / 1_000_000
        print(f'  Input  : {run_tokens_in:>10,}  @ ${input_price:>5.2f}/M  = ${in_cost:.4f}')
        print(f'  Output : {run_tokens_out:>10,}  @ ${output_price:>5.2f}/M  = ${out_cost:.4f}')
        print(f'  Total  : {run_total:>10,}              = ${run_cost:.4f}')
        if run_cost > 0:
            print(f'  Claude : {run_total:>10,}  (@ $3/$15/M)  = ${claude_cost:.4f}  (x{claude_cost/run_cost:.1f} cheaper)')
        else:
            print(f'  Claude equiv            = ${claude_cost:.4f}')
    else:
        print(f'Delegate tokens (run): {run_total:,}  (last turn: {prompt:,}+{completion:,})  |  cost ~\${run_cost:.4f}')
        if run_cost > 0:
            print(f'Claude Sonnet 4.6 eq: same tokens would cost ~\${claude_cost:.4f}  (ratio x{claude_cost/run_cost:.1f})')
        else:
            print(f'Claude Sonnet 4.6 eq: ~\${claude_cost:.4f}')
    # Merge token stats into the delegate stats file.
    sf = os.environ.get('DELEGATE_STATS_FILE', '')
    if sf:
        try:
            existing = json.load(open(sf))
        except Exception:
            existing = {}
        existing.update({
            'tokens_in':    run_tokens_in,
            'tokens_out':   run_tokens_out,
            'tokens_total': run_total,
            'cost_usd':     round(run_cost, 6),
            'model':        model_name,
        })
        with open(sf, 'w') as wf:
            json.dump(existing, wf)
except Exception as e:
    print(f'(token read failed: {e})')
" 2>/dev/null
fi

echo ""
if [ "$VIBE_EXIT" -eq 124 ]; then
  echo "=== VIBE TIMEOUT (>${TIMEOUT_SECS}s) — killed ==="
else
  echo "=== VIBE DONE (exit: $VIBE_EXIT) ==="
fi

# ── Post-run syntax checks — language-agnostic ────────────────────────────────
# CWD is $WORKDIR (cd happened before launch). Anything with an mtime newer than
# the sentinel was actually written during this run — true regardless of whether
# the file was tracked, untracked, already dirty, or this is a non-git directory.
CHANGED=$(find . -type f -newer "$SENTINEL" -not -path './.git/*' 2>/dev/null | sed 's|^\./||')

# ── Recovery scan — only when the filesystem shows nothing changed ────────────
# "0 files changed" on stdout is not proof Vibe wrote nothing: the session
# journal (messages.jsonl) records every write_file call regardless of whether
# the write later succeeded, got wrapped in an unexpected envelope, or was
# undone by a subsequent rm. Confirmed case (2026-08-11): stdout showed 0 files
# changed and a turn-limit stop; the journal held a complete, correct 5,706-char
# module from the FIRST write_file call, lost because it was wrapped as
# {"module_docstring": """...."""} and the write step rejected the shape.
#
# This applies the last attempt per file_path directly to disk — no attempt to
# guess intent beyond that, no revert-tracking. The syntax check right below
# already validates whatever lands here (refresh $CHANGED after, same as any
# other write), so a bad recovery shows up as syntax_error, not silently as ok.
# If content lands wrong for some other project, that's the same outcome as
# today (wrote_nothing/exit_error) — this can only improve on that baseline.
if [ -z "$(printf '%s' "$CHANGED" | tr -d '[:space:]')" ] && [ -n "$SESSION_LOG" ]; then
  JOURNAL="$(dirname "$SESSION_LOG")/messages.jsonl"
  if [ -f "$JOURNAL" ]; then
    python3 - "$JOURNAL" << 'PYRECOVER'
import json, sys

journal = sys.argv[1]
by_path = {}  # last attempt per path wins
with open(journal, errors='replace') as fh:
    for line in fh:
        try:
            msg = json.loads(line)
        except ValueError:
            continue
        if msg.get('role') != 'assistant':
            continue
        for tc in (msg.get('tool_calls') or []):
            fn = tc.get('function') or {}
            name = str(fn.get('name', ''))
            if 'write' not in name and 'edit' not in name and 'replace' not in name:
                continue
            try:
                args = json.loads(fn.get('arguments') or '{}')
            except ValueError:
                continue
            body = args.get('content') or args.get('new_string') or ''
            path = args.get('file_path', '')
            if body and path:
                by_path[path] = (name, body)

if by_path:
    print(f'  [RECOVERY] stdout said 0 files changed, but the session journal has '
          f'{len(by_path)} write attempt(s) — applying the last attempt per file:')
    for path, (name, body) in by_path.items():
        try:
            with open(path, 'w') as f:
                f.write(body)
            print(f'    [applied] {name} -> {path}  ({len(body)} chars)')
        except OSError as e:
            print(f'    [FAILED to apply] {name} -> {path}: {e}')
    print(f'  [RECOVERY] full journal: {journal}')
    print(f'  [RECOVERY] syntax-checking recovered file(s) below, same as any other write.')
PYRECOVER
    # Refresh CHANGED so the syntax check (right below) picks up recovered files.
    CHANGED=$(find . -type f -newer "$SENTINEL" -not -path './.git/*' 2>/dev/null | sed 's|^\./||')
  fi
fi

SYNTAX_COUNT_FILE=$(mktemp /tmp/vibe-syntax-count-XXXXXX.txt)
echo 0 > "$SYNTAX_COUNT_FILE"
export DELEGATE_CHANGED="$CHANGED"
export SYNTAX_COUNT_FILE

python3 << 'PYSYNTAX'
import os, shutil, subprocess

changed = [f for f in os.environ.get('DELEGATE_CHANGED', '').splitlines() if f.strip()]
# CWD is always $WORKDIR — cd "$WORKDIR" runs before this block.
errors  = 0
checked = 0

def has(cmd):
    return shutil.which(cmd) is not None

def check(cmd, label, cwd=None):
    global errors, checked
    r = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd)
    checked += 1
    if r.returncode != 0:
        errors += 1
        msg = (r.stderr or r.stdout).strip().split('\n')[0][:120]
        print(f'  [SYNTAX ERROR] {label}: {msg}')

# Per-file checks — tools available without project context
for f in changed:
    if not os.path.isfile(f):
        continue
    if   f.endswith('.py'):
        check(['python3', '-m', 'py_compile', f], f)
    elif f.endswith(('.js', '.mjs', '.cjs')) and has('node'):
        check(['node', '--check', f], f)
    elif f.endswith('.rb') and has('ruby'):
        check(['ruby', '-c', f], f)
    elif f.endswith(('.sh', '.bash')):
        check(['bash', '-n', f], f)
    elif f.endswith('.php') and has('php'):
        check(['php', '-l', f], f)
    elif f.endswith('.json'):
        check(['python3', '-c', 'import json,sys;json.load(open(sys.argv[1]))', f], f)

# Project-level checks — run once if any matching files changed
live = [f for f in changed if os.path.isfile(f)]
if any(f.endswith('.go') for f in live) and has('go'):
    check(['go', 'vet', './...'], 'go vet')
if any(f.endswith('.rs') for f in live) and has('cargo'):
    check(['cargo', 'check', '--quiet'], 'cargo check')
if any(f.endswith(('.ts', '.tsx')) for f in live):
    if has('tsc') and os.path.isfile('tsconfig.json'):
        check(['tsc', '--noEmit', '--skipLibCheck'], 'tsc --noEmit')
    else:
        print('  [SKIP] tsc — no tsconfig.json found, TypeScript not checked')

if checked > 0:
    if errors == 0:
        print(f'=== SYNTAX OK ({checked} check(s)) ===')
    else:
        print(f'=== SYNTAX ERRORS: {errors} in {checked} check(s) — fix before committing ===')

cf = os.environ.get('SYNTAX_COUNT_FILE', '')
if cf:
    with open(cf, 'w') as fh:
        fh.write(str(errors))
PYSYNTAX

TOTAL_ERRORS=$(cat "$SYNTAX_COUNT_FILE" 2>/dev/null || echo 0)
rm -f "$SYNTAX_COUNT_FILE"

# ── Git summary ────────────────────────────────────────────────────────────────
GIT_AFTER=$(git rev-parse HEAD 2>/dev/null || echo "no-git")

if [ "$GIT_BEFORE" != "$GIT_AFTER" ]; then
  echo ""
  echo "=== COMMITS CREATED ==="
  git log "$GIT_BEFORE".."$GIT_AFTER" --oneline
  echo ""
  echo "=== DIFF STAT ==="
  git diff "$GIT_BEFORE".."$GIT_AFTER" --stat
else
  echo ""
  echo "=== UNCOMMITTED CHANGES ==="
  git diff --stat -- "$WORKDIR" 2>/dev/null || echo "(no git)"
  git status --short -- "$WORKDIR" 2>/dev/null
fi


# ── Run log ────────────────────────────────────────────────────────────────────
END_NS=$(date +%s%N)
FILES_CHANGED_COUNT=$(printf '%s\n' "$CHANGED" | grep -c '[^[:space:]]' 2>/dev/null)
FILES_CHANGED_COUNT=${FILES_CHANGED_COUNT:-0}
TOTAL_ERRORS=${TOTAL_ERRORS:-0}

export DELEGATE_NAME="vibe"
export DELEGATE_WORKDIR="$WORKDIR"
export DELEGATE_EXIT="$VIBE_EXIT"
export DELEGATE_TIMEOUT="$TIMEOUT_SECS"
export DELEGATE_PROMPT_WORDS="$PROMPT_WORDS"
export DELEGATE_AGENT="${AGENT:-auto-approve}"
export DELEGATE_MAX_TURNS="$MAX_TURNS"
export DELEGATE_FILES_CHANGED="$FILES_CHANGED_COUNT"
export DELEGATE_SYNTAX_ERRORS="$TOTAL_ERRORS"
export DELEGATE_START_NS="$START_NS"
export DELEGATE_END_NS="$END_NS"
export DELEGATE_PROMPT_FILE="$PROMPT_FILE"

python3 << 'PYLOG'
import json, os, re
from pathlib import Path
from datetime import datetime, timezone

LOG = Path.home() / '.local' / 'share' / 'delegate-runs.jsonl'
LOG.parent.mkdir(parents=True, exist_ok=True)

sf = os.environ.get('DELEGATE_STATS_FILE', '')
stats = {}
if sf:
    try:
        stats = json.loads(Path(sf).read_text())
    except Exception:
        pass

start_ns = int(os.environ.get('DELEGATE_START_NS', 0) or 0)
end_ns   = int(os.environ.get('DELEGATE_END_NS',   0) or 0)
duration = round((end_ns - start_ns) / 1e9, 1) if start_ns and end_ns else 0

# Detect prompt adaptations from the prompt text.
# These are logged per-run so failure rates can be compared across adaptation styles.
prompt_text = ''
pf = os.environ.get('DELEGATE_PROMPT_FILE', '')
if pf:
    try:
        prompt_text = Path(pf).read_text(errors='replace')
    except Exception:
        pass

adaptations = []
if prompt_text:
    # contract: prompt includes a Python function signature or return type annotation
    if re.search(r'\bdef \w+\s*\(|->|\bCallable\b|: \w+\[', prompt_text):
        adaptations.append('contract')
    # output_format: prompt requests a structured receipt (Modified:/Does:/OUTPUT FORMAT)
    if re.search(r'OUTPUT\s+FORMAT|Modified:|Created/Modified:|Does:', prompt_text, re.I):
        adaptations.append('output_format')
    # compact: short, focused prompt (fewer context tokens on the delegate side)
    if len(prompt_text.split()) < 80:
        adaptations.append('compact')

entry = {
    'ts':            datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
    'delegate':      os.environ.get('DELEGATE_NAME', 'vibe'),
    'workdir':       os.environ.get('DELEGATE_WORKDIR', ''),
    'project':       os.path.basename(os.environ.get('DELEGATE_WORKDIR', '')),
    'prompt_words':  int(os.environ.get('DELEGATE_PROMPT_WORDS', 0) or 0),
    'agent':         os.environ.get('DELEGATE_AGENT', 'default'),
    'max_turns':     int(os.environ.get('DELEGATE_MAX_TURNS', 0) or 0),
    'timeout_secs':  int(os.environ.get('DELEGATE_TIMEOUT', 0) or 0),
    'exit_code':     int(os.environ.get('DELEGATE_EXIT', 0) or 0),
    'timed_out':     os.environ.get('DELEGATE_EXIT') == '124',
    'files_changed':        int(os.environ.get('DELEGATE_FILES_CHANGED', 0) or 0),
    'syntax_errors':        int(os.environ.get('DELEGATE_SYNTAX_ERRORS', 0) or 0),
    'duration_secs':        duration,
    'warn_count':           stats.get('warn_count', 0),
    'search_replace_fails': stats.get('search_replace_fails', 0),
    'adaptations':          adaptations,
}
entry.update(stats)

# Compute Claude Sonnet 4.6 equivalent cost for savings tracking.
ti = entry.get('tokens_in', 0)
to = entry.get('tokens_out', 0)
entry['cost_claude_eq'] = round((ti * 3.0 + to * 15.0) / 1_000_000, 6)

# failure_reason: specific classification of run outcome (more precise than wrote_nothing).
# silent_exit   — vibe never engaged: 0 tokens, 0 tool calls (immediate exit or auth failure)
# near_empty    — minimal output (<50 tokens), nothing written (scaffold header only)
# wrote_nothing — had tool calls but wrote no files (prompt too vague or task already done)
# timeout / exit_error / syntax_error / sr_fail — other failure modes
# warn_only     — completed with non-fatal warnings
# ok            — clean run
# Agents that legitimately produce no file writes, so "wrote nothing" is the
# expected outcome and not a failure. 'plan' / 'explore' are the current Vibe
# built-ins; the last three names are kept for older installs where they
# existed as custom agents (a name that is simply absent here just means a
# read-only run gets flagged wrote_nothing, which is what happened to every
# `plan` review before 2026-08-02). 'chat' is deliberately NOT here: it is
# absent from BUILTIN_AGENTS in 2.22.0 and dropped from the enum in 2.24.0,
# so --agent chat never reaches this code at all.
_readonly_agents = ('plan', 'explore', 'code-reviewer', 'code-architect', 'planner')
_tok_out  = entry.get('tokens_out', 0) or 0
_tc       = entry.get('tool_calls', 0) or 0
_fc       = entry.get('files_changed', 0) or 0
_exit     = entry.get('exit_code', 0)
_agent    = entry.get('agent', 'default')

if entry.get('timed_out'):
    _reason = 'timeout'
elif _exit not in (0, 124):
    # A sandbox "Tool execution not permitted" message inflates exit to 1 even when
    # the files were actually written (seen 2026-06-17, run with fc=2 still logged
    # exit_error). Don't let that mask a real success.
    if stats.get('sandbox_blocked') and _fc > 0:
        _reason = 'sandbox_blocked_ok'
    else:
        _reason = 'exit_error'
elif entry.get('syntax_errors', 0) > 0:
    _reason = 'syntax_error'
elif entry.get('search_replace_fails', 0) > 0:
    _reason = 'sr_fail'
elif stats.get('denied_count') and _fc == 0:
    # Denials were counted but never classified: a fully-denied run wrote no
    # files, so it fell through to wrote_nothing/silent_exit and triggered the
    # retry-then-ghostwrite ladder — retrying a run the sandbox will deny again.
    # Deliberately NOT added to the wrote_nothing tuple below.
    _reason = 'denied'
elif _tc == 0 and _fc == 0 and _agent not in _readonly_agents:
    # Zero tool calls always means nothing was written, regardless of how much text
    # came back. Previously this only fired when tok_out was ALSO 0, which is exactly
    # how the mistral-medium "narrate the file instead of calling write_file" failure
    # (seo-monitor, 2026-06-17 13:03:42 — tc=0, fc=0, but tok_out large) got logged as 'ok'.
    _reason = 'narrated_no_tools' if _tok_out > 0 else 'silent_exit'
elif _tok_out < 50 and _fc == 0 and _tc < 3 and _agent not in _readonly_agents:
    _reason = 'near_empty'
elif _fc == 0 and _tc >= 3 and _exit == 0 and _agent not in _readonly_agents:
    _reason = 'wrote_nothing'
elif entry.get('warn_count', 0) > 0:
    _reason = 'warn_only'
else:
    _reason = 'ok'

entry['failure_reason'] = _reason
# wrote_nothing kept for backwards compat with older delegate-report versions
entry['wrote_nothing'] = _reason in ('wrote_nothing', 'silent_exit', 'near_empty')

with open(LOG, 'a') as f:
    f.write(json.dumps(entry) + '\n')

tok = entry.get('tokens_total', '?')
saved = entry['cost_claude_eq'] - entry.get('cost_usd', 0)
saved_str = f', saved ~${saved:.4f} vs Claude' if saved > 0 else ''
adapt_str = f'  [{",".join(adaptations)}]' if adaptations else ''
reason_str = f'  {_reason}' if _reason != 'ok' else ''
print(f'[log] → {LOG}  ({tok} tokens, exit {_exit}, {duration}s{saved_str}{reason_str}{adapt_str})')
PYLOG

exit $VIBE_EXIT
