#!/usr/bin/env bash
# sdk/testing/fake-runner — deterministic agent-CLI stand-in (STORY-015
# T-DOG-002; retargeted onto the runner contract by STORY-117 T-ENG-117).
# Zero network, zero model calls — CI-safe by construction.
#
# WHAT IT IMPERSONATES
# --------------------
# The RUNNER CONTRACT (ADR-026), not any one vendor's flag surface. It is
# invoked the way `runner_dispatch` invokes a real CLI:
#
#   <bin> [--print] [--output-format text|stream-json] [--verbose]
#         [--permission-mode M] [--max-budget-usd N] [--settings F]
#         [--model ID]
#   …with the PROMPT ON STDIN.
#
# Everything it honours is a contract obligation a real adapter also has:
#
#   prompt on stdin   the only delivery path since the E2BIG fix (T-INF-031);
#                     `runner_invoke` redirects a temp file into the process.
#   --output-format   `stream-json` makes it emit claude-shaped JSONL events so
#                     the decoder (stream-format.js) is exercised end-to-end,
#                     including the load-bearing rule that the FINAL answer
#                     travels in the `result` event and lands unprefixed.
#   --settings        parsed, not discarded: a simulated write is put through
#                     the PreToolUse hook declared there exactly as Claude Code
#                     would (event JSON on stdin, exit 2 = blocked), so
#                     qa-write-guard.sh is genuinely executed rather than merely
#                     generated. Under an adapter with no `write_confinement`
#                     there is no --settings, the hook is never consulted, and
#                     the write LANDS — which is the degradation, demonstrated.
#
# Unknown flags are ignored, as a tolerant CLI would.
#
# Role is detected from the prompt text (the same markers the real gates embed):
#
#   qa          prompt carries the "/qa-verify CONTRACT" block
#                 → prints "QA-VERDICT: PASS", exit 0
#   reviewer    prompt asks for a "REVIEW-VERDICT:" line
#                 → prints an empty-findings review + "REVIEW-VERDICT: CLEAN"
#   planner     prompt carries the replan contract marker
#                 → prints a no-change replan report, exit 0
#   repair      prompt carries the commit self-repair hook-output banner
#                 → git-applies <TASK-ID>.repair.patch when present, exit 0
#   implementer anything else — the dispatch prompt's
#               "…task spec referenced by: <path>#<anchor>" line names the
#               task; <TASK-ID> is the task file's basename without .md
#
# Implementer outcomes are scripted per task id from a fixtures directory,
# checked in this order:
#
#   <TASK-ID>.blocked   print "BLOCKED: <first line of file>" and exit 0
#                       (the orchestrator treats a BLOCKED report as failure)
#   <TASK-ID>.limit     print an agent spend-limit line and exit 1
#                       (exercises the session-limit machinery)
#   <TASK-ID>.limit0    the same limit line with exit 0, no work done
#                       (exercises exit-0 exhaustion classification)
#   <TASK-ID>.limitnote print the limit line, then do the work normally
#                       (a limit WARNING in a productive run is not exhaustion)
#   <TASK-ID>.noop      change nothing; print "NO-OP: <first line of file>"
#   <TASK-ID>.exit      exit with the numeric code in the file, no output
#   <TASK-ID>.hang      write src/partial.txt (uncommitted work), then block
#                       for FAKE_RUNNER_HANG_SECONDS (default 3001) — a task
#                       to cancel mid-implementer (T-PB-331b)
#   <TASK-ID>.patch     git-apply the patch to the work tree the runner was
#                       started in (a lane in lane mode), exit 0
#   (none)              print a no-op note and exit 0 (empty diff)
#
# Env seams:
#   FAKE_RUNNER_FIXTURES        fixtures dir   (default: <repo-root>/fixtures)
#   FAKE_RUNNER_TRANSCRIPT      append every received prompt to this file —
#                               lets scenarios assert what was injected
#                               (terminology, standards, skills)
#   FAKE_RUNNER_REPO_ROOT       repo root (default: $ORCHESTRATOR_REPO_ROOT,
#                               else `git rev-parse --show-toplevel` from $PWD)
#   FAKE_RUNNER_WRITE_ATTEMPTS  whitespace-separated repo-relative paths the
#                               agent should try to write through the Write
#                               tool. Each goes through the --settings
#                               PreToolUse hook when one was supplied.
#   FAKE_RUNNER_WRITE_ATTEMPTS_<ROLE>
#                               the same, for one role only (QA, REVIEWER,
#                               PLANNER, IMPLEMENTER). Wins over the unscoped
#                               form. Needed because only some roles are
#                               dispatched with a guard.
#   FAKE_RUNNER_REVIEW_EXIT     reviewer exits <n> with no verdict (a crash,
#                               not a usage limit)
#   FAKE_RUNNER_QA_VERDICT      verdict the QA role prints (default PASS)
#   FAKE_RUNNER_COST_USD        spend each invocation reports (stream-json only)
#   FAKE_RUNNER_DUMP_ENV        1 → every role prints its environment, as an
#                               injected "print your env" would make an agent do
#   FAKE_RUNNER_REVIEW_NOOP     "confirm" → the reviewer prints NO-OP-CONFIRMED
#                               when the prompt carries a NO-OP claim
#   FAKE_RUNNER_ACS             the reviewer AND QA print `acs: <this>` verbatim
#                               instead of the derived map (T-PB-345c: an
#                               unmapped or uncited AC). Default: every numbered
#                               AC of the prompt's TASK SPEC → `pass` with a
#                               `test:` citation.
#   FAKE_RUNNER_QA_FILE         QA writes `verdict:` + its acs map to this
#                               repo-relative verdict file and prints NO map
#   FAKE_RUNNER_REVIEW_BLOCK_ONCE  non-empty → the FIRST review in this
#                               sandbox prints one Blocking finding and
#                               REVIEW-VERDICT: BLOCKING; later reviews are CLEAN
#                               (marker in the gitignored logs dir) — drives the
#                               review-repair round (T-PB-346b)
#   FAKE_RUNNER_ARGV_LOG        append the argv of every invocation to this file
#                               (one `argv: …` line) — argv-discipline evidence.
set -uo pipefail

# ── argv: the runner contract's flag surface ─────────────────────────────────
FR_OUTPUT_FORMAT="text"
FR_SETTINGS=""
FR_PERMISSION=""
FR_BUDGET=""
FR_MAX_TURNS=""
FR_MODEL=""
FR_DENIED=()
prompt=""

if [[ -n "${FAKE_RUNNER_ARGV_LOG:-}" ]]; then
  printf 'argv: %s\n' "$*" >> "$FAKE_RUNNER_ARGV_LOG" 2>/dev/null || true
fi

while [[ $# -gt 0 ]]; do
  case "$1" in
    --output-format)   FR_OUTPUT_FORMAT="${2:-text}"; shift 2 ;;
    --settings)        FR_SETTINGS="${2:-}";          shift 2 ;;
    --permission-mode) FR_PERMISSION="${2:-}";        shift 2 ;;
    --max-budget-usd)  FR_BUDGET="${2:-}";            shift 2 ;;
    --max-turns)       FR_MAX_TURNS="${2:-}";         shift 2 ;;
    # A pinned runners.<role>.model arrives as `--model <id>`; without this
    # case the id was read as the positional prompt and every dispatch of a
    # model-pinned config reported BLOCKED (STORY-338 T-PB-338c).
    --model)           FR_MODEL="${2:-}";            shift 2 ;;
    # Variadic, as in the real client: every following argument up to the next
    # flag is a deny pattern (STORY-343). Without this the patterns were read as
    # the DEPRECATED positional prompt below, and every dispatch reported BLOCKED.
    --disallowedTools) shift; while [[ $# -gt 0 && "$1" != --* ]]; do FR_DENIED+=("$1"); shift; done ;;
    --print|--verbose) shift ;;
    -*)                shift ;;
    *)                 prompt="$1"; shift ;;
  esac
done

# The prompt is delivered on STDIN, not argv — every dispatch has done so since
# the E2BIG fix (run_agent_cli, T-INF-031), and the runner contract makes it
# normative (ADR-026 `runner_invoke`).
#
# DEPRECATED: the trailing positional prompt. It is still read FIRST so a
# hand-rolled test that passes a prompt in argv keeps working, but no dispatch
# in the engine produces one and new tests must not rely on it. When it goes,
# this block goes with it and stdin becomes unconditional.
if [[ -z "$prompt" && ! -t 0 ]]; then
  prompt="$(cat)"
fi

ROOT="${FAKE_RUNNER_REPO_ROOT:-${ORCHESTRATOR_REPO_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")}}"
FIXTURES="${FAKE_RUNNER_FIXTURES:-${ROOT}/fixtures}"

# WORK_TREE — where the agent's edits land. ROOT names the repository (and the
# fixtures default), but a real agent edits the tree it was STARTED in:
# runner_dispatch pins the working directory to $WORK_ROOT, which in lane mode
# is a worktree at harness/state/lanes/<slot>, not the control root. Writing to
# ROOT instead put every patch in the control checkout, so lane-mode scenarios
# failed their Verification block with the file "missing" (STORY-339 T-PB-339b).
# The cwd's tree is used only when it shares ROOT's git common dir — i.e. it is
# a worktree of the same repository — so a caller in an unrelated repo can
# never be patched by accident.
#
# $PWD, not `git rev-parse --show-toplevel`: on Windows the latter answers
# `C:/…` while the engine's own paths (and the write guard's allowlist root)
# are MSYS-style `/tmp/…`, so a path built from it never looked "inside" the
# lane and the guard blocked even allowlisted writes. The dispatch pins the cwd
# to exactly $WORK_ROOT, so $PWD is the same string the engine uses; the
# show-prefix check confirms the cwd is the tree's top, not a subdirectory.
WORK_TREE="$ROOT"
if [[ -n "$(git rev-parse --show-toplevel 2>/dev/null || true)" \
      && -z "$(git rev-parse --show-prefix 2>/dev/null || echo not-top)" ]]; then
  _fr_common_pwd="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
  _fr_common_root="$(git -C "$ROOT" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
  if [[ -n "$_fr_common_pwd" && "$_fr_common_pwd" == "$_fr_common_root" ]]; then
    WORK_TREE="$PWD"
  fi
fi

if [[ -n "${FAKE_RUNNER_TRANSCRIPT:-}" ]]; then
  { printf '===== fake-runner invocation =====\n%s\n' "$prompt"; } \
    >> "$FAKE_RUNNER_TRANSCRIPT" 2>/dev/null || true
fi

# ── output: text mode vs stream-json mode ────────────────────────────────────
# FR_FINAL accumulates the FINAL answer — the block that `--output-format text`
# prints and that the orchestrator's anchored verdict greps read. In stream-json
# mode it travels in the `result` event, which stream-format.js re-emits
# verbatim and unprefixed. Both modes therefore put byte-identical text on the
# unprefixed portion of the log, which is the decoder's whole output contract.
FR_FINAL=""
fr_say() { FR_FINAL+="$1"$'\n'; }

# fr_json_string <text> — minimal but correct JSON string literal.
fr_json_string() {
  local s="$1"
  s="${s//\\/\\\\}"
  s="${s//\"/\\\"}"
  s="${s//$'\r'/}"
  s="${s//$'\t'/\\t}"
  s="${s//$'\n'/\\n}"
  printf '"%s"' "$s"
}

# fr_emit <exit_code> — flush FR_FINAL in the negotiated format and exit.
fr_emit() {
  if [[ "$FR_OUTPUT_FORMAT" == "stream-json" ]]; then
    # A minimal but structurally real event sequence: an init envelope, one
    # tool_use turn (so the decoder's envelope/tool rendering is exercised and a
    # killed run would name a tool), then the result carrying the final answer.
    printf '{"type":"system","subtype":"init","session_id":"fake-runner","model":"fake-runner","cwd":%s}\n' \
      "$(fr_json_string "$WORK_TREE")"
    printf '{"type":"assistant","message":{"content":[{"type":"text","text":"working (fake-runner)"},{"type":"tool_use","name":"Read","input":{"file_path":"harness/state/queue.txt"}}]}}\n'
    # FAKE_RUNNER_COST_USD — report this spend per invocation, as a real client
    # does in its result event (drives cost metrics and the STORY-343 task cap).
    local _cost=""
    [[ "${FAKE_RUNNER_COST_USD:-}" =~ ^[0-9]+(\.[0-9]+)?$ ]] && _cost=",\"total_cost_usd\":${FAKE_RUNNER_COST_USD}"
    printf '{"type":"result","subtype":"success","duration_ms":1,"num_turns":1%s,"result":%s}\n' \
      "$_cost" "$(fr_json_string "${FR_FINAL%$'\n'}")"
  else
    printf '%s' "$FR_FINAL"
  fi
  exit "${1:-0}"
}

# ── --settings: the PreToolUse hook, executed as Claude Code executes it ─────
# fr_hook_command <tool> — the hook command declared for <tool> in the settings
# file, or empty. Shape (claude.sh runner_write_guard):
#   {"hooks":{"PreToolUse":[{"matcher":"Write|Edit|…",
#     "hooks":[{"type":"command","command":"bash '<guard>'"}]}]}}
fr_hook_command() {
  [[ -n "$FR_SETTINGS" && -f "$FR_SETTINGS" ]] || return 0
  local blob matcher cmd
  blob="$(tr -d '\n' < "$FR_SETTINGS")"
  [[ "$blob" == *'"PreToolUse"'* ]] || return 0
  matcher="$(sed -nE 's/.*"matcher"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p' <<<"$blob" | head -1)"
  cmd="$(sed -nE 's/.*"command"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/p' <<<"$blob" | head -1)"
  [[ -n "$cmd" ]] || return 0
  # An empty matcher matches every tool (Claude Code's own semantics).
  if [[ -n "$matcher" ]] && ! grep -qE "^(${matcher})$" <<<"$1"; then return 0; fi
  printf '%s' "$cmd"
}

# fr_attempt_writes <role> — run every write-attempt path for that role through
# the Write tool. With a guard hook: event JSON on stdin, exit 2 blocks the
# write and the reason (stderr) goes into the answer, as it would go back to a
# real agent. Without one: the write simply lands — no confinement, by
# construction, which is what a degraded adapter looks like from the outside.
#
# Paths come from FAKE_RUNNER_WRITE_ATTEMPTS_<ROLE> if set, else the unscoped
# FAKE_RUNNER_WRITE_ATTEMPTS. The per-role form matters: only some roles are
# dispatched with a guard, so a scenario testing confinement must be able to
# make the QA role write without making the implementer write too.
fr_attempt_writes() {
  local role_var="FAKE_RUNNER_WRITE_ATTEMPTS_$(tr '[:lower:]' '[:upper:]' <<<"${1:-}")"
  local attempts="${!role_var:-${FAKE_RUNNER_WRITE_ATTEMPTS:-}}"
  [[ -n "$attempts" ]] || return 0
  local path cmd rc err
  cmd="$(fr_hook_command Write)"
  for path in ${attempts}; do
    rc=0
    if [[ -n "$cmd" ]]; then
      err="$(printf '{"tool_name":"Write","tool_input":{"file_path":%s,"content":"fake-runner write"}}' \
               "$(fr_json_string "${WORK_TREE}/${path}")" \
             | eval "$cmd" 2>&1 >/dev/null)" || rc=$?
      if [[ "$rc" -eq 2 ]]; then
        fr_say "fake-runner: WRITE-BLOCKED ${path} — $(head -1 <<<"$err")"
        continue
      fi
    else
      fr_say "fake-runner: WRITE-UNGUARDED ${path} — no --settings, no hook consulted"
    fi
    mkdir -p "$(dirname "${WORK_TREE}/${path}")" 2>/dev/null
    printf 'fake-runner write\n' >> "${WORK_TREE}/${path}" 2>/dev/null \
      && fr_say "fake-runner: WROTE ${path}" \
      || fr_say "fake-runner: WRITE-FAILED ${path}"
  done
}

# fr_acs_map <role> — the role's `acs:` map. The engine itself decides AC
# coverage from it (lib/evidence.sh evidence_ac_coverage), so a scripted
# reviewer or QA must map every numbered item under the TASK SPEC's Acceptance
# criteria heading, each with a citation, exactly as /review-work and
# /qa-verify require of a real one.
fr_acs_map() {
  if [[ -n "${FAKE_RUNNER_ACS+x}" ]]; then fr_say "acs: ${FAKE_RUNNER_ACS}"; return 0; fi
  local n
  fr_say "acs:"
  while IFS= read -r n; do
    fr_say "  ${n}: pass test:fake-runner/${1}/ac-${n}"
  done < <(awk '
    /^--- TASK SPEC/ { in_spec = 1; next }
    /^--- END TASK SPEC/ { in_spec = 0 }
    !in_spec { next }
    /^#+[ \t]/ { in_ac = (tolower($0) ~ /acceptance criteria/); next }
    in_ac && match($0, /^ ? ? ?[0-9]+[.)][ \t]/) { sub(/^ */, ""); sub(/[.)].*/, ""); print }
  ' <<<"$prompt")
}

# FAKE_RUNNER_DUMP_ENV=1 — print the variable NAMES and values this process
# inherited, as an injected "print your environment" instruction would make a
# real agent do. Lets a scenario prove a secret never reaches the agent
# (STORY-343 canary test). Every role does it.
if [[ "${FAKE_RUNNER_DUMP_ENV:-}" == 1 ]]; then
  while IFS= read -r _fr_line; do fr_say "env: ${_fr_line}"; done < <(env | sort)
fi

# ── role: qa ─────────────────────────────────────────────────────────────────
if grep -q -- "/qa-verify CONTRACT" <<<"$prompt"; then
  fr_say "fake-runner: qa role — verifying against the contract (scripted)."
  fr_attempt_writes qa
  # FAKE_RUNNER_QA_VERDICT (PASS | PASS-WITH-FINDINGS | FAIL; default PASS) —
  # the printed verdict. Lets a case pit the printed verdict against a verdict
  # FILE the implementer planted (gate-semantics case i).
  if [[ -n "${FAKE_RUNNER_QA_FILE:-}" ]]; then
    # As a real QA agent does: the map lives in the verdict FILE only, and the
    # transcript carries none, so the engine must read the file (BUG: the
    # verdict-file name was lost in a subshell and the map never counted).
    _fr_before="$FR_FINAL"; fr_acs_map qa; _fr_map="${FR_FINAL#"$_fr_before"}"; FR_FINAL="$_fr_before"
    mkdir -p "$(dirname "${WORK_TREE}/${FAKE_RUNNER_QA_FILE}")"
    printf 'verdict: %s\n%s' "${FAKE_RUNNER_QA_VERDICT:-PASS}" "$_fr_map" > "${WORK_TREE}/${FAKE_RUNNER_QA_FILE}"
  else
    fr_acs_map qa
  fi
  fr_say "QA-VERDICT: ${FAKE_RUNNER_QA_VERDICT:-PASS}"
  fr_emit 0
fi

# ── role: reviewer ───────────────────────────────────────────────────────────
if grep -q "REVIEW-VERDICT:" <<<"$prompt"; then
  # FAKE_RUNNER_REVIEW_EXIT=<n> — a reviewer that crashes: no findings, no
  # verdict line, exit n. Not a usage limit (that is the .limit fixture's job),
  # so it exercises the non-limit reviewer-error branch (gate-semantics case a).
  if [[ "${FAKE_RUNNER_REVIEW_EXIT:-}" =~ ^[1-9][0-9]*$ ]]; then
    fr_say "fake-runner: reviewer role — scripted crash (exit ${FAKE_RUNNER_REVIEW_EXIT})."
    fr_emit "$FAKE_RUNNER_REVIEW_EXIT"
  fi
  fr_say "fake-runner: reviewer role (scripted)."
  # FAKE_RUNNER_REVIEW_NOOP=confirm — confirm a NO-OP claim the prompt carries.
  if [[ "${FAKE_RUNNER_REVIEW_NOOP:-}" == confirm ]] && grep -q -- "--- NO-OP CLAIM ---" <<<"$prompt"; then
    fr_say "NO-OP-CONFIRMED"
  fi
  fr_attempt_writes reviewer
  _fr_blocked="${ROOT}/harness/state/logs/.review-blocked-once"   # gitignored
  if [[ -n "${FAKE_RUNNER_REVIEW_BLOCK_ONCE:-}" && ! -f "$_fr_blocked" ]]; then
    mkdir -p "${_fr_blocked%/*}" && : > "$_fr_blocked"
    fr_say "## Findings"
    fr_say "### Blocking (must fix before merge)"
    fr_say "- [ ] src/hello.js:1 — scripted blocking finding (fake-runner) · why blocking"
    fr_acs_map reviewer
    fr_say "REVIEW-VERDICT: BLOCKING"
    fr_emit 0
  fi
  fr_say "## Findings"
  fr_say "### Blocking (must fix before merge)"
  fr_say "(none)"
  fr_acs_map reviewer
  fr_say "REVIEW-VERDICT: CLEAN"
  fr_emit 0
fi

# ── role: planner ────────────────────────────────────────────────────────────
# The replan prompt asks the planner to rewrite task specs after a failure. The
# stub reports "no change needed" — enough to prove the role is bound and
# dispatched, without mutating a queue the scenario is asserting on.
if grep -qE "REPLAN-VERDICT:|/replan CONTRACT" <<<"$prompt"; then
  fr_say "fake-runner: planner role (scripted) — no plan change required."
  fr_attempt_writes planner
  fr_say "REPLAN-VERDICT: NO-CHANGE"
  fr_emit 0
fi

# ── role: repair (commit self-repair, plugins/self-repair) ───────────────────
# The repair prompt names the spec as "task spec <path> was REJECTED". It edits
# the tree like an implementer, but from <TASK-ID>.repair.patch, so a scenario
# can script what the repair changed (gate-semantics j, T-PB-340i).
if grep -q -- "--- PRE-COMMIT HOOK OUTPUT (verbatim) ---" <<<"$prompt"; then
  r_spec="$(sed -n 's/.*task spec \([^ ]*\) was REJECTED.*/\1/p' <<<"$prompt" | head -1)"
  r_id="$(basename "${r_spec%%#*}" .md)"
  if [[ -f "${FIXTURES}/${r_id}.repair.patch" ]]      && git -C "$WORK_TREE" apply --whitespace=nowarn "${FIXTURES}/${r_id}.repair.patch" >/dev/null 2>&1; then
    fr_say "fake-runner: repair role — applied repair patch for ${r_id}."
  else
    fr_say "fake-runner: repair role — no repair scripted for ${r_id:-?}."
  fi
  fr_emit 0
fi

# ── role: review-repair (run_review_repair, lib/prompts/repair-v1) ──────────
# Fixes a rejecting review's Blocking findings in a kept tree. The stub changes
# nothing — enough for the engine to re-verify and re-review (T-PB-346b).
if grep -q -- "--- BLOCKING FINDINGS (from the rejecting review) ---" <<<"$prompt"; then
  fr_say "fake-runner: review-repair role (scripted) — nothing to change."
  fr_emit 0
fi

# ── role: implementer ────────────────────────────────────────────────────────
spec="$(sed -n 's/.*task spec referenced by: //p' <<<"$prompt" | head -1 | tr -d '[:space:]')"
task_file="${spec%%#*}"
task_id="$(basename "$task_file" .md)"

if [[ -z "$task_id" || "$task_id" == "." ]]; then
  fr_say "BLOCKED: fake-runner could not find a task spec reference in the prompt"
  fr_emit 0
fi

if [[ -f "${FIXTURES}/${task_id}.blocked" ]]; then
  fr_say "fake-runner: scripted blocked outcome for ${task_id}."
  fr_say "BLOCKED: $(head -1 "${FIXTURES}/${task_id}.blocked")"
  fr_emit 0
fi

if [[ -f "${FIXTURES}/${task_id}.limit" ]]; then
  fr_say "You've hit your monthly spend limit · raise it at claude.ai/settings/usage"
  fr_emit 1
fi

# <TASK-ID>.limit0 — the same limit message with EXIT 0, which is how a
# subscription CLI reports a usage limit in practice (memory BUG-141 class). No
# work is done. Exercises exit-0 exhaustion classification (gate-semantics m).
if [[ -f "${FIXTURES}/${task_id}.limit0" ]]; then
  fr_say "You've hit your monthly spend limit · raise it at claude.ai/settings/usage"
  fr_emit 0
fi

# <TASK-ID>.noop — change nothing and claim the work was already done, with the
# file's first line as the evidence (the T-PB-340e NO-OP protocol).
if [[ -f "${FIXTURES}/${task_id}.noop" ]]; then
  fr_say "NO-OP: $(head -1 "${FIXTURES}/${task_id}.noop")"
  fr_emit 0
fi

if [[ -f "${FIXTURES}/${task_id}.hang" ]]; then
  mkdir -p "${WORK_TREE}/src" && echo 'partial work' > "${WORK_TREE}/src/partial.txt"
  fr_say "fake-runner: ${task_id} is hanging (scripted)."
  sleep "${FAKE_RUNNER_HANG_SECONDS:-3001}"
  fr_emit 0
fi

if [[ -f "${FIXTURES}/${task_id}.exit" ]]; then
  exit "$(tr -dc '0-9' < "${FIXTURES}/${task_id}.exit")"
fi

# <TASK-ID>.limitnote — the limit line appears in the output, but the agent
# carries on and applies its patch (T-QA-068 / gate-semantics m2).
if [[ -f "${FIXTURES}/${task_id}.limitnote" ]]; then
  fr_say "Note: you are close to your monthly spend limit · raise it at claude.ai/settings/usage"
fi

fr_attempt_writes implementer

if [[ -f "${FIXTURES}/${task_id}.patch" ]]; then
  # Output silenced: in stream-json mode stray text on stdout would land between
  # the JSONL events (the decoder passes it through, but the log stops being a
  # faithful stream). Failures are reported through FR_FINAL below.
  if git -C "$WORK_TREE" apply --whitespace=nowarn "${FIXTURES}/${task_id}.patch" >/dev/null 2>&1; then
    fr_say "fake-runner: applied fixture patch for ${task_id}."
    fr_emit 0
  fi
  fr_say "BLOCKED: fixture patch for ${task_id} did not apply cleanly"
  fr_emit 0
fi

fr_say "fake-runner: no fixture for ${task_id} — nothing to do (empty diff)."
fr_emit 0
