#!/usr/bin/env bash
# gates.sh — vendor gates.sh from run-plan-lib.sh with risk subcommand + safe CLI interface
# Deterministic, NEVER a seat, repo-agnostic check discovery (package.json scripts / Makefile)
# CLI: gates.sh gate0 <mode> <worktree> [<repoRoot> <slug>]
#       gates.sh risk <worktree> <base..head>
set -uo pipefail

LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Source the vendored gate0 functions from run-plan-lib.sh
import_gate0_functions() {
  # Import gate0_* functions from run-plan-lib.sh
  # We need to simulate importing them without including their logic here
  # Instead, we'll call the functions directly if they exist, or implement stubs
  # For this first draft, we'll create stubs that call the actual functions when available
  :
}

# Return 0 when name matches the verification-only allowlist (case-insensitive).
check_name_allowed() {
  local name=$1
  local lower
  lower=$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')

  case "$lower" in
    test|tests|lint|check|checks|typecheck|type-check|tsc|flow|build|compile|verify|ci)
      return 0
      ;;
    test:*|tests:*|lint:*|check:*|build:*|compile:*|verify:*|ci:*)
      return 0
      ;;
  esac

  return 1
}

gate0_cmd() {
  local name=$1 cmd
  while IFS= read -r cmd; do
    [[ -z "$cmd" ]] && continue
    case "$cmd" in
      "$HOME/.claude/bin/"*) continue ;;
    esac
    printf '%s\n' "$cmd"
    return 0
  done < <(type -P -a "$name" 2>/dev/null)
  return 1
}

gate0_exec_path() {
  local dir result=""
  local -a dirs=()
  IFS=':' read -r -a dirs <<< "${PATH:-}"
  for dir in "${dirs[@]}"; do
    [[ -n "$dir" && "$dir" != "$HOME/.claude/bin" ]] || continue
    result+="${result:+:}${dir}"
  done
  [[ -n "$result" ]] || return 1
  printf '%s\n' "$result"
}

export GATE0_DISPATCH="${GATE0_DISPATCH:-$HOME/.claude/lib/gate-dispatch.mjs}"
export GATE0_REMOTE="${GATE0_REMOTE:-1}"

# Run one check on a buildbox when one takes it, otherwise here. gate0 strips
# ~/.claude/bin from PATH, so the per-tool shims never see a gate check; this is
# the gate's own single dispatch seam and every record type funnels through it.
# The dispatcher's exit code is also the check's exit code, so it cannot signal
# "not dispatched" — the decision file does, and an absent decision fails closed.
gate0_run_check() {
  local worktree=$1 check_name=$2 exec_path=$3
  shift 3
  local -a env_kv=("${_GATE0_CHECK_ENV[@]}") cmd=("$@") env_args=()
  local kv decision verdict reason rc node_bin

  for kv in "${env_kv[@]}"; do
    env_args+=(--env "$kv")
  done

  if [[ -z "${GATE0_REMOTE_ACTIVE:-}" && "${GATE0_REMOTE:-1}" == "1" ]] \
    && { [[ ! -r "$GATE0_DISPATCH" ]] || ! node_bin=$(gate0_cmd node); }; then
    echo "gate0: remote dispatch unavailable (dispatcher=$GATE0_DISPATCH) - running check locally" >&2
  elif [[ -z "${GATE0_REMOTE_ACTIVE:-}" && "${GATE0_REMOTE:-1}" == "1" ]]; then
    decision=$(mktemp "${TMPDIR:-/tmp}/gate0-dispatch-XXXXXX") || {
      echo "gate0: fail-closed - cannot create dispatch decision file FAILCLASS=infra" >&2
      exit 1
    }
    rc=0
    PATH="$exec_path" "$node_bin" "$GATE0_DISPATCH" \
      --cwd "$worktree" --check "$check_name" --decision "$decision" \
      "${env_args[@]}" -- "${cmd[@]}" || rc=$?
    read -r verdict reason < "$decision" || verdict=""
    rm -f "$decision"
    case "$verdict" in
      remote)
        return "$rc"
        ;;
      local)
        echo "gate0: remote dispatch declined ($reason) - running check locally" >&2
        ;;
      blocked)
        echo "gate0: fail-closed - no buildbox answered ($reason); refusing to run the check on the workstation FAILCLASS=infra" >&2
        exit 1
        ;;
      *)
        echo "gate0: fail-closed - remote dispatcher recorded no decision (rc=$rc) FAILCLASS=infra" >&2
        exit 1
        ;;
    esac
  fi

  PATH="$exec_path" env "${env_kv[@]}" "${cmd[@]}"
}

# Execute a tab-delimited discovery record via argv-safe invocation.
exec_check_record() {
  local record=$1
  local worktree=$2
  local type name exec_path
  local npm_bin

  IFS=$'\t' read -r type name <<< "$record"

  cd "$worktree" || exit 1

  exec_path=$(gate0_exec_path) || {
    echo "gate0: fail-closed - no executable PATH outside the shim dir FAILCLASS=infra" >&2
    exit 1
  }

  # Strictness and worker limits are part of what the check IS: they travel with
  # it to the box, or the remote run is a different, weaker check.
  _GATE0_CHECK_ENV=(
    "npm_config_fail_if_no_match=true"
    "PNPM_CONFIG_FAIL_IF_NO_MATCH=true"
    "VITEST_MAX_FORKS=${VITEST_MAX_FORKS:-1}"
    "VITEST_MAX_THREADS=${VITEST_MAX_THREADS:-1}"
    "VITEST_TEST_TIMEOUT=${VITEST_TEST_TIMEOUT:-20000}"
    "VITEST_HOOK_TIMEOUT=${VITEST_HOOK_TIMEOUT:-20000}"
  )

  case "$type" in
    NPM_SCRIPT)
      npm_bin=$(gate0_cmd npm) || {
        echo "gate0: fail-closed - npm not found FAILCLASS=infra" >&2
        exit 1
      }
      gate0_run_check "$worktree" "$name" "$exec_path" "$npm_bin" run "$name"
      ;;
    MAKE_TARGET)
      _GATE0_CHECK_ENV=()
      gate0_run_check "$worktree" "$name" "$exec_path" make -- "$name"
      ;;
    *)
      echo "gate0: fail-closed - unknown check record type: $type FAILCLASS=infra" >&2
      exit 1
      ;;
  esac
}

_CAPTURE_OUTPUT=""
_CAPTURE_OUTPUT_PATH=""
_CAPTURE_FAILCLASS=""

gate0_now_ms() {
  date +%s%3N
}

gate_event() {
  local event="$1" check="$2" item="$3" pid="$4" elapsed_ms="$5" output_path="$6" rc="${7:-}"
  jq -cn --arg event "$event" --arg check "$check" --arg item "$item" \
    --argjson pid "${pid:-0}" --argjson elapsed_ms "${elapsed_ms:-0}" \
    --arg output_path "$output_path" --arg rc "$rc" \
    '{protocol:"runplan.gate/v1",event:$event,check:$check,item:$item,pid:$pid,elapsed_ms:$elapsed_ms,output_path:$output_path}
     + (if $rc=="" then {} else {rc:($rc|tonumber)} end)' >&2
}

gate0_check_monitor() {
  local check_pid=$1 interval=$2 record=$3 output_file=$4 start_ms=$5 stall_ms=$6 status_file=$7
  local output_size=0 observed_size=0 now_ms last_progress_ms=$start_ms last_heartbeat_ms=$start_ms

  trap 'exit 0' TERM INT
  while kill -0 "$check_pid" 2>/dev/null; do
    sleep 0.05
    now_ms=$(gate0_now_ms)
    observed_size=$(wc -c <"$output_file")
    if [[ "$observed_size" -ne "$output_size" ]]; then
      output_size=$observed_size
      last_progress_ms=$now_ms
    fi
    if (( now_ms - last_heartbeat_ms >= interval * 1000 )); then
      printf 'gate0: heartbeat: check still running: %s\n' "$record" >&2
      last_heartbeat_ms=$now_ms
    fi
    if (( stall_ms > 0 && now_ms - last_progress_ms >= stall_ms )); then
      printf 'stalled\n' >"$status_file"
      kill -TERM -- "-$check_pid" 2>/dev/null || kill -TERM "$check_pid" 2>/dev/null || true
      sleep 1
      kill -KILL -- "-$check_pid" 2>/dev/null || kill -KILL "$check_pid" 2>/dev/null || true
      return 0
    fi
  done
}

capture_check_record() {
  local record=$1 worktree=$2
  local interval=${GATE0_HEARTBEAT_INTERVAL_SECONDS:-30}
  local type check_name output_file status_file check_pid monitor_pid rc=0 start_ms now_ms
  local stall_ms=${RUNPLAN_GATE_STALL_MS:-0} elapsed_ms
  IFS=$'\t' read -r type check_name <<< "$record"
  _CAPTURE_FAILCLASS=""
  _CAPTURE_OUTPUT_PATH=""
  # Gate checks must run offline-by-default; ambient runner env must not widen provision.
  unset RUNPLAN_ALLOW_ONLINE
  if [[ ! "$interval" =~ ^[1-9][0-9]*$ ]]; then
    _CAPTURE_OUTPUT="gate0: fail-closed - invalid heartbeat interval: $interval FAILCLASS=infra"
    return 2
  fi

  output_file=$(mktemp "${TMPDIR:-/tmp}/gate0-check-output-XXXXXX") || {
    _CAPTURE_OUTPUT="gate0: fail-closed - cannot create check output file FAILCLASS=infra"
    return 2
  }
  status_file=$(mktemp "${TMPDIR:-/tmp}/gate0-check-status-XXXXXX") || {
    rm -f "$output_file"
    _CAPTURE_OUTPUT="gate0: fail-closed - cannot create check status file FAILCLASS=infra"
    return 2
  }
  _CAPTURE_OUTPUT_PATH="$output_file"
  start_ms=$(gate0_now_ms)
  gate_event "check.start" "$check_name" "$record" 0 0 "$output_file"
  export -f gate0_cmd gate0_exec_path gate0_run_check exec_check_record
  setsid bash -c 'exec_check_record "$@"' _ "$record" "$worktree" >"$output_file" 2>&1 &
  check_pid=$!
  gate_event "item.start" "$check_name" "$record" "$check_pid" 0 "$output_file"
  printf 'gate0: heartbeat: check still running: %s\n' "$record" >&2
  if [[ ! "$stall_ms" =~ ^[0-9]+$ ]]; then
    _CAPTURE_OUTPUT="gate0: fail-closed - invalid semantic stall timeout: $stall_ms FAILCLASS=infra"
    kill -- "-$check_pid" 2>/dev/null || true
    wait "$check_pid" 2>/dev/null || true
    rm -f "$status_file"
    return 2
  fi

  gate0_check_monitor "$check_pid" "$interval" "$record" "$output_file" "$start_ms" "$stall_ms" "$status_file" &
  monitor_pid=$!
  wait "$check_pid" || rc=$?
  kill "$monitor_pid" 2>/dev/null || true
  wait "$monitor_pid" 2>/dev/null || true
  if [[ -s "$status_file" ]]; then
    rc=124
    _CAPTURE_FAILCLASS="gate-stalled"
  fi
  now_ms=$(gate0_now_ms)
  elapsed_ms=$((now_ms - start_ms))
  _CAPTURE_OUTPUT=$(<"$output_file")
  gate_event "item.complete" "$check_name" "$record" "$check_pid" "$elapsed_ms" "$output_file" "$rc"
  rm -f "$status_file"
  return "$rc"
}

# A shared workstation can starve an otherwise-correct Vitest worker while a
# monorepo turbo task is fanning out.  Only recover the narrow, observable
# case: every reported package failure is a stock five-second Vitest timeout,
# then rerun those package tests one-at-a-time.  Assertions, compile failures,
# missing commands, and any other failure stay fail-closed.
retry_vitest_timeouts_isolated() {
  local record=$1 worktree=$2 output=$3
  local type name package_path npm_bin retry_output
  local -a package_paths=()
  declare -A seen_paths=()

  IFS=$'\t' read -r type name <<< "$record"
  [[ "$type" == "NPM_SCRIPT" && "$name" == "test" ]] || return 1
  printf '%s\n' "$output" | grep -Fq 'Test timed out in 5000ms.' || return 1

  while IFS= read -r package_path; do
    [[ -n "$package_path" ]] || continue
    [[ "$package_path" == "$worktree"/* ]] || return 1
    [[ -f "$package_path/package.json" ]] || return 1
    if [[ -z "${seen_paths[$package_path]:-}" ]]; then
      seen_paths["$package_path"]=1
      package_paths+=("$package_path")
    fi
  done < <(printf '%s\n' "$output" | sed -n 's#.*command (\([^)]*\)) .*pnpm run test exited (1).*#\1#p')

  [[ ${#package_paths[@]} -gt 0 ]] || return 1
  npm_bin=$(gate0_cmd pnpm) || return 1

  for package_path in "${package_paths[@]}"; do
    echo "gate0: retrying Vitest-timeout package in isolation: $package_path" >&2
    retry_output="$(cd "$package_path" && \
      PATH="$(gate0_exec_path)" \
      npm_config_fail_if_no_match=true \
      PNPM_CONFIG_FAIL_IF_NO_MATCH=true \
      TMPDIR="${TMPDIR:-/tmp}" TEMP="${TEMP:-/tmp}" TMP="${TMP:-/tmp}" \
      VITEST_MAX_FORKS="${VITEST_MAX_FORKS:-1}" \
      VITEST_MAX_THREADS="${VITEST_MAX_THREADS:-1}" \
      "$npm_bin" run test 2>&1)" || {
        printf '%s\n' "$retry_output" >&2
        return 1
      }
  done

  return 0
}

# Per-test failure identities across the common TAP / Vitest / Jest / bun reporters.
# `pnpm -r` prefixes every child line with "<projectDir> <script>: "; that prefix
# is stripped first so a failure inside a workspace package still attributes.
# Empty output means the runner reported no individual test failures, so the red
# is a build/lint/command failure and attribution is already unambiguous.
failing_test_signature() {
  printf '%s\n' "$1" \
    | sed -E 's#^[[:space:]]*[A-Za-z0-9._@/+-]+ [A-Za-z0-9._+-]+: ##' \
    | grep -E '^([[:space:]]*not ok [0-9]+ -|[[:space:]]*(✕|✗|×|✖)|[[:space:]]*FAIL |[[:space:]]*\(fail\) )' \
    | sed -E 's/^[[:space:]]*not ok [0-9]+ - //; s/^[[:space:]]*(✕|✗|×|✖) //; s/^[[:space:]]*FAIL //; s/^[[:space:]]*\(fail\) //; s/ \(?[0-9]+(\.[0-9]+)? ?ms\)?$//; s/ \[[0-9]+(\.[0-9]+)? ?ms\]$//' \
    | sort -u
}

# A red check is only the task's fault if it reproduces. Re-run the failing
# check alone with runner parallelism pinned to one: green means the first red
# was contention, a different failure set means the check is nondeterministic
# and no diff can repair it, and an identical failure set is a real red.
retry_check_isolated() {
  local record=$1 worktree=$2 first_output=$3
  local first_sig second_sig check_name rc=0

  [[ "${RUNPLAN_GATE_ISOLATED_RETRY:-1}" == "1" ]] || return 1
  first_sig=$(failing_test_signature "$first_output")
  [[ -n "$first_sig" ]] || return 1

  IFS=$'\t' read -r _ check_name <<< "$record"
  echo "gate0: check red: $check_name - re-running isolated to confirm attribution" >&2

  export NODE_TEST_CONCURRENCY=1 VITEST_MAX_FORKS=1 VITEST_MAX_THREADS=1 JEST_WORKERS=1
  # In a workspace the package fan-out is the dominant parallelism; pinning only
  # the in-package runners leaves the retry as contended as the first run.
  export PNPM_CONFIG_WORKSPACE_CONCURRENCY=1
  capture_check_record "$record" "$worktree" || rc=$?
  unset NODE_TEST_CONCURRENCY VITEST_MAX_FORKS VITEST_MAX_THREADS JEST_WORKERS
  unset PNPM_CONFIG_WORKSPACE_CONCURRENCY

  if [[ $rc -eq 0 ]]; then
    echo "gate0: isolated re-run green: $check_name red was contention, not the task diff" >&2
    return 0
  fi

  second_sig=$(failing_test_signature "$_CAPTURE_OUTPUT")
  if [[ -n "$second_sig" && "$first_sig" != "$second_sig" ]]; then
    _CAPTURE_FAILCLASS="flaky-check"
    echo "gate0: isolated re-run red with a different failure set: $check_name is nondeterministic" >&2
  fi
  return 1
}

# Requires `worktree` in caller scope for path normalization.
normalize_check_output() {
  local raw=$1
  local wt_esc
  wt_esc=$(printf '%s' "$worktree" | sed 's/[\/&]/\\&/g')
  printf '%s\n' "$raw" | sed \
    -e 's/\x1b\[[0-9;]*[mK]//g' \
    -e "s#${wt_esc}#<WTROOT>#g" \
    -e 's/\b[0-9]\+\(\.[0-9]\+\)\?\(ms\|s\|m\)\b/<TIME>/g' \
    -e 's/\(\.\(ts\|js\|tsx\|jsx\|sh\|py\)\):[0-9]\+:[0-9]\+/\1:<LOC>/g' \
    | grep -v '^$'
}

declare -gA _BASELINE_LINES=()
_BASELINE_CMDS=()

parse_baseline_file() {
  local file=$1
  local line current_cmd=""
  _BASELINE_CMDS=()
  _BASELINE_LINES=()

  while IFS= read -r line || [[ -n "$line" ]]; do
    [[ -z "$line" ]] && continue
    if [[ "$line" == CMD:* ]]; then
      current_cmd="${line#CMD:}"
      _BASELINE_CMDS+=("$current_cmd")
      _BASELINE_LINES["$current_cmd"]=""
    elif [[ "$line" == LINE:* ]]; then
      [[ -n "$current_cmd" ]] || continue
      local ln="${line#LINE:}"
      if [[ -n "${_BASELINE_LINES[$current_cmd]}" ]]; then
        _BASELINE_LINES["$current_cmd"]+=$'\n'"$ln"
      else
        _BASELINE_LINES["$current_cmd"]="$ln"
      fi
    else
      current_cmd="$line"
      _BASELINE_CMDS+=("$current_cmd")
      _BASELINE_LINES["$current_cmd"]=""
    fi
  done <"$file"
}

baseline_cmd_in_baseline() {
  local cmd=$1 c
  for c in "${_BASELINE_CMDS[@]}"; do
    [[ "$c" == "$cmd" ]] && return 0
  done
  return 1
}

# Print normalized failure lines present now but absent from baseline (empty = none).
# Fail-closed on changed failure modes:
#   - baseline had lines but current output is empty -> emit sentinel so caller detects regression
#   - baseline was empty but current output has lines -> all lines are new (regression)
baseline_new_failure_lines() {
  local cmd=$1 output=$2
  local baseline="${_BASELINE_LINES[$cmd]:-}"
  local normalized
  normalized="$(normalize_check_output "$output")"
  local line

  # Normalized output is now empty
  if [[ -z "$normalized" ]]; then
    # Baseline had lines -> failure mode changed (used to produce output, now silent) -> regression marker
    [[ -n "$baseline" ]] && printf '<gate0:empty-output-regression>\n'
    return 0
  fi

  # Baseline is empty but current output is non-empty -> all lines are new (regression)
  if [[ -z "$baseline" ]]; then
    printf '%s\n' "$normalized"
    return 0
  fi

  # Both sides non-empty: emit only lines not present in baseline
  while IFS= read -r line; do
    [[ -z "$line" ]] && continue
    if ! printf '%s\n' "$baseline" | grep -Fxq -- "$line"; then
      printf '%s\n' "$line"
    fi
  done <<< "$normalized"
}

# Persist the last GATE0_OUTPUT_TAIL_LINES (default 150) lines of a failing
# check's combined output to a durable file under repoRoot/runstate so the
# fixer/journal can reference it after the ephemeral capture file is gone.
# Prints the absolute path to the written file on success.
gate0_write_output_tail() {
  local repoRoot=$1 slug=$2 check_name=$3 output=$4
  local dir="$repoRoot/runstate/gate0-output"
  mkdir -p "$dir" || return 1
  local safe_name
  safe_name=$(printf '%s' "$check_name" | tr -c 'A-Za-z0-9._-' '-')
  local file
  file=$(mktemp "$dir/${slug}-${safe_name}-XXXXXX.log") || return 1
  printf '%s\n' "$output" | tail -n "${GATE0_OUTPUT_TAIL_LINES:-150}" > "$file" || return 1
  printf '%s\n' "$file"
}

# Run all discovered checks in the worktree; exit nonzero on first failure.
run_discovered_checks() {
  local worktree=$1 repoRoot=${2:-$1} slug=${3:-default}
  local checks=()
  local cmd

  while IFS= read -r cmd; do
    [[ -z "$cmd" ]] && continue
    checks+=("$cmd")
  done < <(discover_checks "$worktree")

  if [[ ${#checks[@]} -eq 0 ]]; then
    echo "gate0: fail-closed - no check commands found FAILCLASS=infra" >&2
    exit 1
  fi

  for cmd in "${checks[@]}"; do
    echo "gate0: running check: $cmd" >&2
    capture_check_record "$cmd" "$worktree" || {
      local rc=$?
      printf '%s\n' "$_CAPTURE_OUTPUT" >&2
      if retry_vitest_timeouts_isolated "$cmd" "$worktree" "$_CAPTURE_OUTPUT"; then
        echo "gate0: recovered isolated Vitest timeout(s): $cmd" >&2
        continue
      fi
      if retry_check_isolated "$cmd" "$worktree" "$_CAPTURE_OUTPUT"; then
        continue
      fi
      local check_name tail_path
      IFS=$'\t' read -r _ check_name <<< "$cmd"
      local failure_class=${_CAPTURE_FAILCLASS:-check-failed}
      tail_path=$(gate0_write_output_tail "$repoRoot" "$slug" "$check_name" "$_CAPTURE_OUTPUT") || {
        echo "gate0: fail-closed - cannot persist check output tail FAILCLASS=infra" >&2
        exit 1
      }
      if printf '%s\n' "$_CAPTURE_OUTPUT" | grep -Fq 'local-gate: parse-config timed out'; then
        failure_class=infra
      fi
      gate_event "check.fail" "$check_name" "$cmd" 0 0 "$tail_path" "$rc"
      echo "gate0: fail-closed - check failed: $check_name rc=$rc tail=$tail_path FAILCLASS=$failure_class" >&2
      exit 1
    }
    IFS=$'\t' read -r _ check_name <<< "$cmd"
    gate_event "check.complete" "$check_name" "$cmd" 0 0 "$_CAPTURE_OUTPUT_PATH" 0
    rm -f "$_CAPTURE_OUTPUT_PATH"
  done
  gate_event "gate.complete" "" "" 0 0 "" 0
}

gate0_task_base() {
  local worktree=$1 slug=$2
  if [[ -n "$slug" ]] && git -C "$worktree" rev-parse --verify "plan/$slug" >/dev/null 2>&1; then
    git -C "$worktree" merge-base HEAD "plan/$slug" 2>/dev/null && return 0
  fi
  git -C "$worktree" rev-parse --verify HEAD^ 2>/dev/null || return 1
}

gate0_scope_base() {
  local worktree=$1 slug=$2 base
  if base=$(git -C "$worktree" merge-base HEAD origin/main 2>/dev/null) && [[ -n "$base" ]]; then
    printf '%s\n' "$base"
    return 0
  fi
  gate0_task_base "$worktree" "$slug"
}

# Non-code paths whose change cannot affect typecheck, build or test output.
gate0_path_is_docs() {
  case $1 in
    *.md|*.mdx|*.txt|*.rst|LICENSE|LICENSE.*|*/LICENSE|*/LICENSE.*) return 0 ;;
    *) return 1 ;;
  esac
}

# 0 = every changed path is documentation. Fails closed: unresolvable base,
# failed diff, empty diff or any non-doc path => 1 (run the full gate).
gate0_docs_only_diff() {
  local worktree=$1 slug=$2 base names path
  base=$(gate0_scope_base "$worktree" "$slug") || return 1
  [[ -n "$base" ]] || return 1
  names=$(git -C "$worktree" diff --name-only --no-renames "$base..HEAD" 2>/dev/null) || return 1
  [[ -n "$names" ]] || return 1
  while IFS= read -r path; do
    [[ -z "$path" ]] && continue
    gate0_path_is_docs "$path" || return 1
  done <<< "$names"
  return 0
}

gate0_changed_manifests() {
  local worktree=$1 base=$2
  git -C "$worktree" diff --name-only "$base..HEAD" -- 'package.json' '*/package.json' 2>/dev/null
}

gate0_detect_manager() {
  local worktree=$1 probe parent
  probe=$worktree
  while :; do
    if [[ -f "$probe/pnpm-lock.yaml" ]]; then
      printf 'pnpm\n'
      return 0
    elif [[ -f "$probe/package-lock.json" ]]; then
      printf 'npm\n'
      return 0
    elif [[ -f "$probe/yarn.lock" ]]; then
      printf 'yarn\n'
      return 0
    elif [[ -f "$probe/bun.lockb" || -f "$probe/bun.lock" ]]; then
      printf 'bun\n'
      return 0
    fi
    [[ -e "$probe/.git" ]] && break
    parent=$(dirname "$probe")
    [[ "$parent" == "$probe" ]] && break
    probe=$parent
  done
  printf 'npm\n'
}

gate0_lockfile_sync_check() {
  local worktree=$1 slug=$2 base manager manifest dir status manager_bin lock_rel lock_abs lock_tmp had_lock drift clean_path
  base=$(gate0_task_base "$worktree" "$slug") || return 0
  # A package manager here must run EXACTLY as typed. The ~/.claude/bin shims re-route a
  # node-backed command through the local-gate, which reshapes an install into a `ci` — so a repo
  # whose lockfile is perfectly in sync reads as drift.
  clean_path=$(gate0_exec_path) || clean_path="$PATH"

  while IFS= read -r manifest; do
    [[ -z "$manifest" ]] && continue
    dir="${manifest%/package.json}"
    [[ "$dir" == "$manifest" ]] && dir="."
    manager=$(gate0_detect_manager "$worktree/$dir")
    status=0
    case "$manager" in
      pnpm)
        if manager_bin=$(gate0_cmd pnpm); then
          (cd "$worktree/$dir" && PATH="$clean_path" "$manager_bin" install --lockfile-only --frozen-lockfile >/dev/null 2>&1)
          status=$?
        else
          status=127
        fi
        ;;
      npm)
        if manager_bin=$(gate0_cmd npm); then
          (cd "$worktree/$dir" && PATH="$clean_path" "$manager_bin" install --package-lock-only --dry-run --ignore-scripts --offline --no-audit --no-fund >/dev/null 2>&1)
          status=$?
          if [[ $status -eq 0 ]]; then
            lock_rel="$dir/package-lock.json"
            [[ "$dir" == "." ]] && lock_rel="package-lock.json"
            lock_abs="$worktree/$lock_rel"
            lock_tmp="$(mktemp "${TMPDIR:-/tmp}/gate0-package-lock-XXXXXX")"
            had_lock=0
            if [[ -f "$lock_abs" ]]; then
              cp "$lock_abs" "$lock_tmp"
              had_lock=1
            fi
            (cd "$worktree/$dir" && PATH="$clean_path" "$manager_bin" install --package-lock-only --ignore-scripts --offline --no-audit --no-fund >/dev/null 2>&1)
            status=$?
            drift=0
            if [[ $status -eq 0 ]]; then
              git -C "$worktree" diff --quiet -- "$lock_rel"
              drift=$?
            fi
            if [[ $had_lock -eq 1 ]]; then
              cp "$lock_tmp" "$lock_abs"
            else
              rm -f "$lock_abs"
            fi
            rm -f "$lock_tmp"
            [[ $status -eq 0 ]] && status=$drift
          fi
        else
          status=127
        fi
        ;;
      yarn)
        if manager_bin=$(gate0_cmd yarn); then
          (cd "$worktree/$dir" && PATH="$clean_path" "$manager_bin" install --mode=update-lockfile --immutable >/dev/null 2>&1)
          status=$?
        else
          status=127
        fi
        ;;
      bun)
        if manager_bin=$(gate0_cmd bun); then
          (cd "$worktree/$dir" && PATH="$clean_path" "$manager_bin" install --lockfile-only --frozen-lockfile >/dev/null 2>&1)
          status=$?
        else
          status=127
        fi
        ;;
      *)
        status=1
        ;;
    esac
    if [[ $status -ne 0 ]]; then
      echo "gate0: lockfile-out-of-sync: $manifest" >&2
      exit 1
    fi
  done < <(gate0_changed_manifests "$worktree" "$base")
}

gate0_pre_commit_hook_path() {
  local worktree=$1 hooks_path hook
  hooks_path=$(git -C "$worktree" config --path core.hooksPath 2>/dev/null || true)
  if [[ -n "$hooks_path" ]]; then
    [[ "$hooks_path" = /* ]] || hooks_path="$worktree/$hooks_path"
    hook="$hooks_path/pre-commit"
    [[ -x "$hook" ]] && printf '%s\n' "$hook"
    return 0
  fi
  hook=$(git -C "$worktree" rev-parse --git-path hooks/pre-commit 2>/dev/null || true)
  [[ -n "$hook" && -x "$hook" ]] && printf '%s\n' "$hook"
}

gate0_commit_hook_check() {
  local worktree=$1 slug=$2 base hook saved_head saved_tree hook_output rc restore_rc pre_untracked post_untracked created unstaged_paths unstaged_snapshot unstaged_deleted path changed_paths
  hook=$(gate0_pre_commit_hook_path "$worktree")
  [[ -n "$hook" ]] || return 0
  base=$(gate0_task_base "$worktree" "$slug") || return 0

  git -C "$worktree" diff --quiet "$base..HEAD" && return 0

  saved_head=$(git -C "$worktree" rev-parse --verify HEAD) || {
    echo "gate0: fail-closed - commit-hook: cannot snapshot HEAD FAILCLASS=infra" >&2
    exit 1
  }
  saved_tree=$(git -C "$worktree" write-tree) || {
    echo "gate0: fail-closed - commit-hook: cannot snapshot index FAILCLASS=infra" >&2
    exit 1
  }
  unstaged_paths=$(mktemp) || {
    echo "gate0: fail-closed - commit-hook: cannot snapshot worktree FAILCLASS=infra" >&2
    exit 1
  }
  unstaged_snapshot=$(mktemp -d) || {
    rm -f "$unstaged_paths"
    echo "gate0: fail-closed - commit-hook: cannot snapshot worktree FAILCLASS=infra" >&2
    exit 1
  }
  unstaged_deleted="$unstaged_snapshot/.gate0-deleted"
  : > "$unstaged_deleted" || {
    rm -rf "$unstaged_snapshot"
    rm -f "$unstaged_paths"
    echo "gate0: fail-closed - commit-hook: cannot snapshot worktree FAILCLASS=infra" >&2
    exit 1
  }
  git -C "$worktree" diff --name-only > "$unstaged_paths" || {
    rm -rf "$unstaged_snapshot"
    rm -f "$unstaged_paths"
    echo "gate0: fail-closed - commit-hook: cannot snapshot worktree FAILCLASS=infra" >&2
    exit 1
  }
  while IFS= read -r path; do
    [[ -n "$path" ]] || continue
    if [[ -e "$worktree/$path" || -L "$worktree/$path" ]]; then
      mkdir -p "$unstaged_snapshot/$(dirname "$path")" || {
        rm -rf "$unstaged_snapshot"
        rm -f "$unstaged_paths"
        echo "gate0: fail-closed - commit-hook: cannot snapshot worktree FAILCLASS=infra" >&2
        exit 1
      }
      cp -a "$worktree/$path" "$unstaged_snapshot/$path" || {
        rm -rf "$unstaged_snapshot"
        rm -f "$unstaged_paths"
        echo "gate0: fail-closed - commit-hook: cannot snapshot worktree FAILCLASS=infra" >&2
        exit 1
      }
    else
      printf '%s\n' "$path" >> "$unstaged_deleted" || {
        rm -rf "$unstaged_snapshot"
        rm -f "$unstaged_paths"
        echo "gate0: fail-closed - commit-hook: cannot snapshot worktree FAILCLASS=infra" >&2
        exit 1
      }
    fi
  done < "$unstaged_paths"
  pre_untracked=$(mktemp) || {
    rm -rf "$unstaged_snapshot"
    rm -f "$unstaged_paths"
    echo "gate0: fail-closed - commit-hook: cannot snapshot worktree FAILCLASS=infra" >&2
    exit 1
  }
  post_untracked=$(mktemp) || {
    rm -rf "$unstaged_snapshot"
    rm -f "$unstaged_paths"
    rm -f "$pre_untracked"
    echo "gate0: fail-closed - commit-hook: cannot snapshot worktree FAILCLASS=infra" >&2
    exit 1
  }
  git -C "$worktree" ls-files --others --exclude-standard | sort > "$pre_untracked" || {
    rm -rf "$unstaged_snapshot"
    rm -f "$unstaged_paths"
    rm -f "$pre_untracked" "$post_untracked"
    echo "gate0: fail-closed - commit-hook: cannot snapshot worktree FAILCLASS=infra" >&2
    exit 1
  }

  git -C "$worktree" reset --soft "$base" || {
    git -C "$worktree" read-tree "$saved_tree" >/dev/null 2>&1 || true
    rm -rf "$unstaged_snapshot"
    rm -f "$unstaged_paths"
    rm -f "$pre_untracked" "$post_untracked"
    echo "gate0: fail-closed - commit-hook: cannot reset to task base FAILCLASS=infra" >&2
    exit 1
  }

  hook_output=$((cd "$worktree" && "$hook") 2>&1)
  rc=$?
  restore_rc=0
  git -C "$worktree" reset --soft "$saved_head" >/dev/null 2>&1 || restore_rc=$?
  git -C "$worktree" read-tree "$saved_tree" >/dev/null 2>&1 || restore_rc=$?
  changed_paths=$(mktemp) || restore_rc=$?
  if [[ $restore_rc -eq 0 ]]; then
    git -C "$worktree" diff --name-only > "$changed_paths" || restore_rc=$?
  fi
  if [[ $restore_rc -eq 0 ]]; then
    while IFS= read -r path; do
      [[ -n "$path" ]] || continue
      if grep -Fxq -- "$path" "$unstaged_paths"; then
        if grep -Fxq -- "$path" "$unstaged_deleted"; then
          rm -f -- "$worktree/$path" || restore_rc=$?
        else
          mkdir -p "$worktree/$(dirname "$path")" || restore_rc=$?
          rm -rf -- "$worktree/$path" || restore_rc=$?
          cp -a "$unstaged_snapshot/$path" "$worktree/$path" || restore_rc=$?
        fi
      else
        git -C "$worktree" checkout -- "$path" >/dev/null 2>&1 || restore_rc=$?
      fi
    done < "$changed_paths"
  fi
  rm -f "${changed_paths:-}"
  git -C "$worktree" ls-files --others --exclude-standard | sort > "$post_untracked" || restore_rc=$?
  if [[ $restore_rc -eq 0 ]]; then
    while IFS= read -r created; do
      [[ -n "$created" ]] || continue
      if ! grep -Fxq -- "$created" "$pre_untracked"; then
        rm -rf -- "$worktree/$created" || restore_rc=$?
        if [[ "$(dirname "$created")" != "." ]]; then
          rmdir -p --ignore-fail-on-non-empty "$worktree/$(dirname "$created")" 2>/dev/null || true
        fi
      fi
    done < "$post_untracked"
  fi
  rm -rf "$unstaged_snapshot"
  rm -f "$unstaged_paths"
  rm -f "$pre_untracked" "$post_untracked"
  if [[ $restore_rc -ne 0 ]]; then
    echo "gate0: fail-closed - commit-hook: cannot restore worktree FAILCLASS=infra" >&2
    exit 1
  fi

  if [[ $rc -ne 0 ]]; then
    echo "gate0: pre-commit-hook-rejected: exit $rc" >&2
    [[ -n "$hook_output" ]] && printf '%s\n' "$hook_output" >&2
    exit 1
  fi
}

gate0_preflight_checks() {
  local worktree=$1 slug=$2
  gate0_lockfile_sync_check "$worktree" "$slug"
  gate0_commit_hook_check "$worktree" "$slug"
}

gate0_restore_runtime_manifest() {
  local worktree=$1 version_path="$worktree/VERSION"
  git -C "$worktree" cat-file -e HEAD:VERSION 2>/dev/null || return 0
  if [[ -s "$version_path" ]]; then
    return 0
  fi
  git -C "$worktree" restore --source=HEAD --worktree -- VERSION || {
    echo "gate0: fail-closed - cannot restore tracked VERSION FAILCLASS=infra" >&2
    exit 1
  }
  echo "gate0: restored empty tracked VERSION from HEAD" >&2
}

# Hands the whole gate to a buildbox as one job. Returns 250 when nothing was
# dispatched, so the caller runs the identical gate locally instead.
gate0_remote_dispatch() {
  local worktree=$1 repoRoot=$2 slug=$3
  local module="$HOME/.claude/lib/remote-build.mjs"
  [[ -z "${GATE0_REMOTE_ACTIVE:-}" ]] || return 250
  [[ "${GATE0_REMOTE:-1}" == "1" ]] || return 250
  [[ -r "$module" ]] || return 250
  local state="${GATE0_REMOTE_STATE:-$HOME/.cache/remote-build}"
  mkdir -p "$state" || return 250
  GATE0_REMOTE_MODULE="$module" GATE0_REMOTE_STATE="$state" node --input-type=module -e '
    const [worktree, repoRoot, slug] = process.argv.slice(1);
    let result;
    try {
      const { tryRemoteBuild } = await import(process.env.GATE0_REMOTE_MODULE);
      result = tryRemoteBuild({
        key: `gate0-${slug || "gate"}`,
        argv: ["env", "GATE0_REMOTE_ACTIVE=1", "bash", "modules/harness/lib/gates.sh", "gate0", "strict", worktree, repoRoot, slug],
        cwd: worktree,
        stateDir: process.env.GATE0_REMOTE_STATE,
        log: (line) => process.stderr.write(`gate0-remote: ${line}\n`),
      });
    } catch (err) {
      process.stderr.write(`gate0-remote: dispatch failed (${err?.message ?? err})\n`);
      process.exit(250);
    }
    if (!result.ran) {
      process.stderr.write(`gate0-remote: not dispatched (${result.reason ?? "unavailable"})\n`);
      process.exit(250);
    }
    process.exit(result.status ?? 1);
  ' -- "$worktree" "$repoRoot" "$slug"
}

# gate0 subcommand - strict mode (default)
gate0_strict() {
  local worktree=$1 repoRoot=$2 slug=$3

  # Accept both regular repos (.git dir) and git worktrees (.git file)
  if [[ ! -d "$worktree/.git" ]] && [[ ! -f "$worktree/.git" ]]; then
    echo "gate0: fail-closed - not a git repository: $worktree FAILCLASS=infra" >&2
    exit 1
  fi

  gate0_restore_runtime_manifest "$worktree"
  gate0_preflight_checks "$worktree" "$slug"
  echo "gate0: strict mode in worktree: $worktree" >&2
  if [[ "${GATE0_DOCS_SCOPE:-1}" == "1" ]] && gate0_docs_only_diff "$worktree" "$slug"; then
    echo "gate0: documentation-only change - skipping code checks" >&2
    gate_event "gate.complete" "" "docs-only" 0 0 "" 0
    echo "SUCCESS: gate0 strict mode completed successfully" >&2
    exit 0
  fi
  run_discovered_checks "$worktree" "${repoRoot:-$worktree}" "${slug:-default}"

  echo "SUCCESS: gate0 strict mode completed successfully" >&2
  exit 0
}

# gate0 subcommand - baseline-ratchet-init mode (explicit baseline creation)
gate0_baseline_ratchet_init() {
  local worktree=$1 repoRoot=$2 slug=$3

  echo "gate0: baseline-ratchet-init mode in worktree: $worktree" >&2

  # Accept both regular repos (.git dir) and git worktrees (.git file)
  if [[ ! -d "$worktree/.git" ]] && [[ ! -f "$worktree/.git" ]]; then
    echo "gate0: fail-closed - not a git repository: $worktree" >&2
    exit 1
  fi

  [[ -z "$repoRoot" ]] && repoRoot="$worktree"
  [[ -z "$slug" ]] && slug="default"
  [[ "$slug" =~ ^[a-zA-Z0-9_-]+$ ]] || { echo "gate0: fail-closed - invalid slug (must match [a-zA-Z0-9_-]+): $slug" >&2; exit 1; }

  local baseline_file="$repoRoot/runstate/gate0-baseline-${slug}.txt"
  mkdir -p "$repoRoot/runstate"

  local checks=()
  local cmd
  while IFS= read -r cmd; do
    [[ -z "$cmd" ]] && continue
    checks+=("$cmd")
  done < <(discover_checks "$worktree")

  if [[ ${#checks[@]} -eq 0 ]]; then
    echo "gate0: fail-closed - no check commands found" >&2
    exit 1
  fi

  local failing=()
  local baseline_tmp
  baseline_tmp="$(mktemp "${TMPDIR:-/tmp}/gate0-baseline-XXXXXX")"
  : >"$baseline_tmp"

  for cmd in "${checks[@]}"; do
    echo "gate0: running check: $cmd" >&2
    local combined_output="" failed=0
    capture_check_record "$cmd" "$worktree" || failed=$?
    combined_output="$_CAPTURE_OUTPUT"
    if [[ $failed -ne 0 ]]; then
      failing+=("$cmd")
      printf 'CMD:%s\n' "$cmd" >>"$baseline_tmp"
      while IFS= read -r nline; do
        printf 'LINE:%s\n' "$nline" >>"$baseline_tmp"
      done < <(normalize_check_output "$combined_output")
    fi
  done

  mv "$baseline_tmp" "$baseline_file"

  echo "gate0: baseline-ratchet-init: established baseline with ${#failing[@]} failing checks" >&2
  exit 0
}

# gate0 subcommand - baseline-ratchet mode
gate0_baseline_ratchet() {
  local worktree=$1 repoRoot=$2 slug=$3

  echo "gate0: baseline-ratchet mode in worktree: $worktree" >&2

  # Accept both regular repos (.git dir) and git worktrees (.git file)
  if [[ ! -d "$worktree/.git" ]] && [[ ! -f "$worktree/.git" ]]; then
    echo "gate0: fail-closed - not a git repository: $worktree" >&2
    exit 1
  fi

  [[ -z "$repoRoot" ]] && repoRoot="$worktree"
  [[ -z "$slug" ]] && slug="default"
  [[ "$slug" =~ ^[a-zA-Z0-9_-]+$ ]] || { echo "gate0: fail-closed - invalid slug (must match [a-zA-Z0-9_-]+): $slug" >&2; exit 1; }

  local baseline_file="$repoRoot/runstate/gate0-baseline-${slug}.txt"
  mkdir -p "$repoRoot/runstate"

  local checks=()
  local cmd
  while IFS= read -r cmd; do
    [[ -z "$cmd" ]] && continue
    checks+=("$cmd")
  done < <(discover_checks "$worktree")

  if [[ ${#checks[@]} -eq 0 ]]; then
    echo "gate0: fail-closed - no check commands found" >&2
    exit 1
  fi

  if [[ ! -f "$baseline_file" ]]; then
    echo "gate0: baseline-ratchet: fail-closed - baseline file not found: $baseline_file. Run 'gate0 baseline-ratchet-init' to establish." >&2
    exit 1
  fi

  parse_baseline_file "$baseline_file"

  local regressions=()
  local regression_new_lines=()
  local fixed=0
  local still_failing=0

  for cmd in "${checks[@]}"; do
    echo "gate0: running check: $cmd" >&2
    local combined_output="" failed=0
    capture_check_record "$cmd" "$worktree" || failed=$?
    combined_output="$_CAPTURE_OUTPUT"

    if [[ $failed -ne 0 ]]; then
      if baseline_cmd_in_baseline "$cmd"; then
        local new_lines=()
        while IFS= read -r line; do
          [[ -z "$line" ]] && continue
          new_lines+=("$line")
        done < <(baseline_new_failure_lines "$cmd" "$combined_output")
        if [[ ${#new_lines[@]} -gt 0 ]]; then
          regressions+=("$cmd")
          regression_new_lines+=("$(printf '%s\n' "${new_lines[@]}")")
        else
          ((still_failing++)) || true
        fi
      else
        regressions+=("$cmd")
        regression_new_lines+=("")
      fi
    elif baseline_cmd_in_baseline "$cmd"; then
      ((fixed++)) || true
    fi
  done

  declare -A _CHECKS_SET=()
  declare -A _DISAPPEARED=()
  for cmd in "${checks[@]}"; do
    _CHECKS_SET["$cmd"]=1
  done
  local bline_cmd
  for bline_cmd in "${_BASELINE_CMDS[@]}"; do
    if [[ ! -v _CHECKS_SET["$bline_cmd"] ]]; then
      regressions+=("$bline_cmd")
      regression_new_lines+=("")
      _DISAPPEARED["$bline_cmd"]=1
    fi
  done

  if [[ ${#regressions[@]} -gt 0 ]]; then
    echo "gate0: baseline-ratchet: regressions detected:" >&2
    local i=0 nl
    for cmd in "${regressions[@]}"; do
      echo "gate0: baseline-ratchet: regression: $cmd" >&2
      if [[ -v _DISAPPEARED["$cmd"] ]]; then
        echo "gate0: baseline-ratchet:   (check disappeared — re-run baseline-ratchet-init if intentional)" >&2
      fi
      if [[ -n "${regression_new_lines[$i]:-}" ]]; then
        while IFS= read -r nl; do
          [[ -z "$nl" ]] && continue
          echo "gate0: baseline-ratchet:   new failure line: $nl" >&2
        done <<< "${regression_new_lines[$i]}"
      fi
      ((i++)) || true
    done
    exit 1
  fi

  echo "gate0: baseline-ratchet: success - ${still_failing} baseline failure(s) remain, ${fixed} fixed" >&2
  exit 0
}

# risk subcommand - calls lib/risk-router.sh
risk_check() {
  local worktree=$1
  local range=$2

  echo "gate0: risk mode on $worktree range $range" >&2

  # Call risk-router.sh
  local risk_script="$LIB_DIR/risk-router.sh"
  if [[ ! -f "$risk_script" ]]; then
    echo "gate0: fail-closed - risk-router.sh not found" >&2
    exit 1
  fi

  # Execute risk-router.sh with worktree and range
  "$risk_script" "$worktree" "$range"
}

# Run shared Python discovery (package.json or Makefile) — name-based only.
# gate0 runs the repo's OWN test suite; it is not a security boundary against
# an adversarial repo. Body/recipe validation is out of scope and creates an
# infinite maintenance loop trying to block delegated invocations.
_gates_discover_py() {
  python3 - "$@" <<'GATES_DISCOVER_PY'
import json, re, sys

ALLOWED_EXACT = {
    'test', 'tests', 'lint', 'check', 'checks', 'typecheck', 'type-check',
    'tsc', 'flow', 'build', 'compile', 'verify', 'ci',
}
ALLOWED_PREFIXES = (
    'test:', 'tests:', 'lint:', 'check:', 'build:', 'compile:', 'verify:', 'ci:',
)
_TARGET_RE = re.compile(r'^([a-zA-Z0-9_.-]+)\s*:')


def is_allowed(name):
    lower = name.lower()
    if lower in ALLOWED_EXACT:
        return True
    return any(lower.startswith(prefix) for prefix in ALLOWED_PREFIXES)


def discover_package_json(pkg_file):
    try:
        with open(pkg_file) as f:
            data = json.load(f)
    except (json.JSONDecodeError, OSError):
        print('gate0: fail-closed - package.json parse error', file=sys.stderr)
        sys.exit(1)
    scripts = data.get('scripts')
    if not scripts:
        print('gate0: warning - no allowlisted scripts in package.json', file=sys.stderr)
        sys.exit(0)
    found = False
    for key in scripts:
        if not is_allowed(key):
            continue
        body = scripts[key]
        if not isinstance(body, str):
            print(
                f'gate0: fail-closed - allowlisted script {key!r}: body is not a string',
                file=sys.stderr,
            )
            sys.exit(1)
        found = True
        print(f'NPM_SCRIPT\t{key}')
    if not found:
        print('gate0: warning - no allowlisted scripts in package.json', file=sys.stderr)
        sys.exit(0)


def discover_makefile(makefile):
    try:
        with open(makefile) as f:
            content = f.read()
    except OSError:
        print('gate0: fail-closed - Makefile read error', file=sys.stderr)
        sys.exit(1)
    found = False
    for line in content.splitlines():
        m = _TARGET_RE.match(line)
        if not m:
            continue
        name = m.group(1)
        if not is_allowed(name):
            continue
        found = True
        print(f'MAKE_TARGET\t{name}')
    if not found:
        print('gate0: warning - no allowlisted targets in Makefile', file=sys.stderr)
        sys.exit(0)


mode = sys.argv[1]
path = sys.argv[2]
if mode == 'package-json':
    discover_package_json(path)
elif mode == 'makefile':
    discover_makefile(path)
else:
    print(f'gate0: fail-closed - unknown discovery mode: {mode!r}', file=sys.stderr)
    sys.exit(1)
GATES_DISCOVER_PY
}

# Detect available check commands; emit runnable commands to stdout (one per line).
discover_checks() {
  local worktree=$1

  local pkg_file="$worktree/package.json"
  local makefile="$worktree/Makefile"

  if [[ -f "$pkg_file" ]]; then
    echo "Found package.json in $worktree" >&2
    _gates_discover_py package-json "$pkg_file" || exit 1
  elif [[ -f "$makefile" ]]; then
    echo "Found Makefile in $worktree" >&2
    _gates_discover_py makefile "$makefile" || exit 1
  else
    echo "gate0: warning - no package.json or Makefile found" >&2
    exit 0
  fi
}

# Execute gate0 based on arguments
if [[ $# -lt 2 ]]; then
  echo "Usage: $0 gate0 <mode> <worktree> [<repoRoot> <slug>]" >&2
  echo "Usage: $0 risk <worktree> <base..head>" >&2
  exit 2
fi

# Return 0 when the path is a check-weakening surface the fixer must never modify,
# even when the coder already touched it (absolute deny, complements the allowlist).
is_absolute_deny_path() {
  local p=$1 base
  base="${p##*/}"

  [[ "$base" == ".warnignore" ]] && return 0

  # Check-config: files that define WHAT the gate runs or HOW strict it is.
  case "$base" in
    package.json|Makefile|makefile|GNUmakefile) return 0 ;;
    tsconfig*.json|jest.config.*|vitest.config.*|.mocharc*|babel.config.*|.babelrc*) return 0 ;;
    .eslintrc*|eslint.config.*|pyproject.toml|setup.cfg|tox.ini|pytest.ini|.flake8) return 0 ;;
  esac

  return 1
}

is_protected_path() {
  local p=$1

  if is_absolute_deny_path "$p"; then
    return 0
  fi

  if is_test_shape_path "$p"; then
    return 0
  fi

  return 1
}

is_test_shape_path() {
  local p=$1 base
  base="${p##*/}"
  case "$p" in
    test/*|tests/*|*/test/*|*/tests/*|*/__tests__/*) return 0 ;;
  esac
  case "$base" in
    *.test.*|*.spec.*|*_test.*|test_*.py) return 0 ;;
  esac
  return 1
}

is_scope_guard_exempt_path() {
  local p=$1 exemptions=$2 item
  while IFS= read -r item; do
    [[ "$item" == "$p" ]] && return 0
  done <<< "$exemptions"
  return 1
}

is_generated_typescript_artifact() {
  local worktree=$1 pre_fix=$2 added_files=$3 p=$4 stem extension
  case "$p" in
    *.d.ts) stem=${p%.d.ts} ;;
    *.js) stem=${p%.js} ;;
    *) return 1 ;;
  esac
  if ! printf '%s\n' "$added_files" | grep -Fxq -- "${stem}.js" \
    || ! printf '%s\n' "$added_files" | grep -Fxq -- "${stem}.d.ts"; then
    return 1
  fi
  for extension in ts tsx mts cts; do
    if git -C "$worktree" cat-file -e "${pre_fix}:${stem}.${extension}" 2>/dev/null; then
      return 0
    fi
  done
  return 1
}

# scope-guard: the rescue fixer may modify files the coder changed or exact task
# deliverables, and never a check-weakening surface (absolute deny). Exact task
# deliverables may exempt test/ directory-shaped paths; check config and named
# test files remain absolute denies.
# Endpoints are tree-ish objects (gate-loop edits are uncommitted; the runner snapshots
# the worktree — including untracked files — via a temp-index write-tree).
#   base    = task base commit
#   preFix  = worktree tree before the fixer ran (coder's state)
#   postFix = worktree tree after the fixer ran
#   exemptDeliverables = newline-separated exact task deliverable paths
# Usage: gates.sh scope-guard <worktree> <base> <preFix> <postFix> [<exemptDeliverables>]
scope_guard() {
  local worktree=$1 base=$2 pre_fix=$3 post_fix=$4 exempt_deliverables="${5:-}"

  if [[ ! -d "$worktree/.git" ]] && [[ ! -f "$worktree/.git" ]]; then
    echo "gate0: fail-closed - scope-guard: not a git repository: $worktree FAILCLASS=infra" >&2
    exit 1
  fi

  local coder_files fix_files added_files
  coder_files=$(git -C "$worktree" diff --name-only "$base" "$pre_fix" 2>/dev/null) || {
    echo "gate0: fail-closed - scope-guard: cannot diff base..preFix ('$base' '$pre_fix') FAILCLASS=infra" >&2
    exit 1
  }
  fix_files=$(git -C "$worktree" diff --name-only "$pre_fix" "$post_fix" 2>/dev/null) || {
    echo "gate0: fail-closed - scope-guard: cannot diff preFix..postFix ('$pre_fix' '$post_fix') FAILCLASS=infra" >&2
    exit 1
  }
  added_files=$(git -C "$worktree" diff --name-only --diff-filter=A "$pre_fix" "$post_fix" 2>/dev/null) || {
    echo "gate0: fail-closed - scope-guard: cannot list preFix..postFix additions ('$pre_fix' '$post_fix') FAILCLASS=infra" >&2
    exit 1
  }

  local f
  while IFS= read -r f; do
    [[ -z "$f" ]] && continue
    if is_absolute_deny_path "$f"; then
      echo "gate0: fail-closed - scope-guard: fixer touched protected check surface: $f FAILCLASS=scope-violation" >&2
      exit 1
    elif is_test_shape_path "$f"; then
      if is_scope_guard_exempt_path "$f" "$exempt_deliverables"; then
        echo "scope-guard: exempt declared deliverable: $f" >&2
      else
        echo "gate0: fail-closed - scope-guard: fixer touched protected check surface: $f FAILCLASS=scope-violation" >&2
        exit 1
      fi
    fi
    if printf '%s\n' "$added_files" | grep -Fxq -- "$f" \
      && is_generated_typescript_artifact "$worktree" "$pre_fix" "$added_files" "$f"; then
      rm -f -- "$worktree/$f"
      continue
    fi
    if printf '%s\n' "$coder_files" | grep -Fxq -- "$f"; then
      continue
    fi
    if is_scope_guard_exempt_path "$f" "$exempt_deliverables"; then
      echo "scope-guard: exempt declared deliverable: $f" >&2
      continue
    fi
    echo "gate0: fail-closed - scope-guard: fixer touched file outside task scope: $f FAILCLASS=scope-violation" >&2
    exit 1
  done <<< "$fix_files"

  echo "SUCCESS: gate0 scope-guard clean" >&2
  exit 0
}

command=$1
shift

case "$command" in
  gate0)
    if [[ $# -lt 2 ]]; then
      echo "Usage: $0 gate0 <mode> <worktree> [<repoRoot> <slug>]" >&2
      exit 2
    fi

    mode=$1
    shift
    worktree=$1
    shift

    repoRoot=""
    slug=""
    if [[ $# -ge 1 ]]; then
      repoRoot=$1
      shift
    fi
    if [[ $# -ge 1 ]]; then
      slug=$1
      shift
    fi

    # Default mode is strict
    if [[ "$mode" != "strict" && "$mode" != "baseline-ratchet" && "$mode" != "baseline-ratchet-init" ]]; then
      echo "gate0: fail-closed - unknown mode: $mode (must be 'strict', 'baseline-ratchet', or 'baseline-ratchet-init')" >&2
      exit 1
    fi

    case "$mode" in
      strict)
        gate0_remote_dispatch "$worktree" "$repoRoot" "$slug"
        remote_status=$?
        if (( remote_status != 250 )); then
          exit "$remote_status"
        fi
        gate0_strict "$worktree" "$repoRoot" "$slug"
        ;;
      baseline-ratchet)
        gate0_baseline_ratchet "$worktree" "$repoRoot" "$slug"
        ;;
      baseline-ratchet-init)
        gate0_baseline_ratchet_init "$worktree" "$repoRoot" "$slug"
        ;;
    esac
    ;;

  risk)
    if [[ $# -lt 2 ]]; then
      echo "Usage: $0 risk <worktree> <base..head>" >&2
      exit 2
    fi

    worktree=$1
    shift
    range=$1
    shift

    # Accept both regular repos (.git dir) and git worktrees (.git file)
    if [[ ! -d "$worktree/.git" ]] && [[ ! -f "$worktree/.git" ]]; then
      echo "gate0: fail-closed - risk mode: not a git repository: $worktree" >&2
      exit 1
    fi

    # Execute risk check
    risk_check "$worktree" "$range"
    ;;

  scope-guard)
    if [[ $# -lt 4 ]]; then
      echo "Usage: $0 scope-guard <worktree> <base> <preFix> <postFix> [<exemptDeliverables>]" >&2
      exit 2
    fi

    worktree=$1
    shift
    base=$1
    shift
    pre_fix=$1
    shift
    post_fix=$1
    shift
    exempt_deliverables="${1:-}"

    scope_guard "$worktree" "$base" "$pre_fix" "$post_fix" "$exempt_deliverables"
    ;;

  *)
    echo "gate0: fail-closed - unknown command: $command (must be 'gate0', 'risk', or 'scope-guard')" >&2
    exit 1
    ;;
esac
