#!/usr/bin/env bash
# scripts/fleet — bring up and drive the thread-session fleet in tmux.
#
# One tmux window per active thread (`claude -w <slug>`), plus a `review`
# window at index 1 that walks Reto through what the sessions need from him.
# The orchestrator session (`/fleet`) runs this; nothing here lands or
# deploys. State is derived from tmux at call time; the only stored input is
# `.claude/fleet/threads.tsv` (slug, effort, design-review flag, model), which
# follows the active set in `docs/roadmap.md`.
#
#   scripts/fleet up [--stagger N] [slug…]   create every missing window (idempotent)
#       With slugs: only those threads' windows (other thread windows untouched; the
#       review window and the MCP wait still run, both idempotent). A slug not in
#       threads.tsv exits 2 naming it, before anything is created.
#   scripts/fleet status             window · index · ctx% · busy/idle/dialog
#   scripts/fleet say <file> [win…]  send one message; skips open dialogs
#   scripts/fleet say -m "<text>" [win…]   same, inline text (no file)
#       --when-clear (either form): a window with a dialog open gets the message
#       held in <state>/fleet-queue/<win>/ (prints HELD) instead of skipped
#   scripts/fleet queue              list held messages: window · age · text
#   scripts/fleet deliver            send held messages to windows with no dialog
#   scripts/fleet verdict <slug> [--file f | -]   append a review verdict
#       (stdin by default) to <reviews>/<slug>.review.md under a `date -u`
#       stamped heading, then tell window <slug> (--when-clear semantics)
#   scripts/fleet peek <win> [-n N]  last N (default 25) non-empty pane lines
#   scripts/fleet dialogs            windows with a dialog: window · type · command
#   scripts/fleet refs               origin main/gated/prod shas + ages, newest green main
#   scripts/fleet compact <win…> [--at-idle]   /next, a grace, then /compact once idle
#       (PRECIS_FLEET_COMPACT_GRACE 90 s, PRECIS_FLEET_COMPACT_WAIT 1800 s per window).
#       Exit 3 = refused: busy or dialog (without --at-idle), wait timed out, or
#       the caller's own / window 0 / claude / organizer. Exit code = worst window.
#   scripts/fleet mcp-check [--fix]  per window: precis= connected|dead|unknown;
#       --fix sends `/mcp reconnect all` to idle windows with a dead one (busy or
#       dialog: `pending: <win> (busy|dialog)`; the orchestrator's seat is skipped)
#   scripts/fleet mcp-reconnect [--force] [--dry-run] [win…]   one line per window:
#       skipped(connected|busy|dialog|orchestrator|unknown) · reconnected · failed.
#       Sends `/mcp reconnect all` (benign on a connected session) to idle windows
#       whose precis is not connected; --force also reconnects connected ones.
#       No windows named: every window in the mcp-check table (unknown skipped);
#       named: unknown is tried too. --dry-run prints the would-be line, sends nothing.
#   scripts/fleet watch              ONE watcher, typed lines on transition only:
#       ctx <win> <pct>% <busy|idle> · note <slug> new|changed · dialog <win> <type>
#       · ci main <sha9> <conclusion> · mcp <win> dead · delivered <win>
#       Seen state persists in <state>/fleet-watch.state, so a restart replays
#       nothing (a first start seeds silently). Also drains the held queue.
#       PRECIS_FLEET_WATCH_TICK 15 s, PRECIS_FLEET_CTX_FLOOR 30
#   scripts/fleet effort             send each window its /effort level
#   scripts/fleet model <slug>       the model `up` starts that thread on (its row, else
#       sonnet; PRECIS_FLEET_MODEL overrides every row)
#   scripts/fleet watch-ctx          the old ctx-only watcher (kept; `watch` supersedes it)
#   scripts/fleet queue-dir          print the review-queue directory
#
# Test seams: PRECIS_FLEET_TMUX_SOCKET (private tmux server, tmux -L),
# PRECIS_FLEET_STATE (state dir), PRECIS_FLEET_NOTES (review-queue + reviews
# parent, default <main checkout>/fleet-notes), PRECIS_FLEET_REVIEWS (reviews dir),
# PRECIS_FLEET_WATCH_TICKS (watch exits after N ticks), PRECIS_FLEET_COMPACT_POLL
# (compact's idle-poll interval, 5 s).
set -euo pipefail

common=$(git rev-parse --path-format=absolute --git-common-dir)
root=$(dirname "$common")
conf="$(git rev-parse --show-toplevel)/.claude/fleet"
table="$conf/threads.tsv"
# Outside any worktree, so a reaped tree cannot take the queue with it.
state="${PRECIS_FLEET_STATE:-$HOME/.claude/projects/$(printf '%s' "$root" | tr '/.' '--')}"
# Review queue and notes sit in a gitignored, non-dot dir of the main checkout:
# file-tool writes under ~/.claude hit a "sensitive file" prompt (gr464238).
notes="${PRECIS_FLEET_NOTES:-$root/fleet-notes}"
queue="$notes/review-queue"
reviews="${PRECIS_FLEET_REVIEWS:-$notes/reviews}"
held="$state/fleet-queue"
# The review window's model; thread windows take thread_model's.
model="${PRECIS_FLEET_MODEL:-opus}"

tmux() { command tmux ${PRECIS_FLEET_TMUX_SOCKET:+-L "$PRECIS_FLEET_TMUX_SOCKET"} "$@"; }
threads() { grep -v -e '^#' -e '^[[:space:]]*$' "$table"; }
windows() { tmux list-windows -F '#{window_name}'; }
# Model for a thread window: PRECIS_FLEET_MODEL overrides every row, else the
# row's fourth column, else sonnet (missing or empty column).
thread_model() {
  [ -z "${PRECIS_FLEET_MODEL:-}" ] || { printf '%s\n' "$PRECIS_FLEET_MODEL"; return; }
  local m; m=$(threads | awk -F'\t' -v s="$1" '$1 == s { print $4; exit }')
  printf '%s\n' "${m:-sonnet}"
}
has_window() { windows | grep -q -x -- "$1"; }
# Every dialog marker this build shows; status, send, dialogs and the drain all key on it.
dialog_re='Enter to select|Do you want to|Do you trust the files'
has_dialog() { tmux capture-pane -p -t "$1" | grep -q -E "$dialog_re"; }
need_tmux() { [ -n "${TMUX:-}" ] || { echo "scripts/fleet: run inside tmux" >&2; exit 2; }; }

fill() { # substitute {slug} {queue} {reviews} in a message file; newlines kept
  sed -e "s|{slug}|$2|g" -e "s|{queue}|$queue|g" -e "s|{reviews}|$reviews|g" "$1"
}

# Text typed with `send-keys -l` loses its head in a Claude Code pane when it is
# long (2026-10-03: five cases, one kept only the last 120 of 900 chars), and
# each newline typed that way would submit. So text over this many chars, or
# with a newline, goes in by bracketed paste (`paste-buffer -p`): one paste
# event, newlines inside it do not submit. Short single-line text is typed.
paste_min=200

send() { # send <window> <text>; refuses a window with a dialog open
  if has_dialog "$1"; then echo "SKIPPED (dialog open): $1"; return 0; fi
  local buf
  if [ "${#2}" -gt "$paste_min" ] || [[ $2 == *$'\n'* ]]; then
    buf="fleet-say-$$-$RANDOM"
    printf '%s' "$2" | tmux load-buffer -b "$buf" -
    # -d drops the buffer after pasting; on failure drop it here.
    tmux paste-buffer -p -d -b "$buf" -t "$1" || { tmux delete-buffer -b "$buf" 2>/dev/null || true; return 1; }
  else
    tmux send-keys -t "$1" -l "$2"
  fi
  # An Enter that arrives with the text is taken as part of the paste and
  # the message sits unsent in the box: pause, submit, then check.
  sleep 1
  tmux send-keys -t "$1" Enter
  sleep 1
  local pane; pane=$(tmux capture-pane -p -t "$1")
  # What an unsent message looks like after the prompt: Claude Code shows a
  # big paste as a `[Pasted text #N +M lines]` placeholder and a small one as
  # the text itself. The text probe is the first line only, at most 40 chars
  # (a newline in a -F pattern would be a second pattern; an empty one matches all).
  local head=${2%%$'\n'*} after; head=${head:0:40}
  after=$(printf '%s\n' "$pane" | grep -A1 '^❯' || true)
  # A busy session shows the queued text too; only an idle one needs the retry.
  if ! printf '%s\n' "$pane" | grep -q -E '… \([0-9]+' && ! has_dialog "$1" &&
     { printf '%s\n' "$after" | grep -q -E '\[Pasted text #[0-9]+' ||
       { [ -n "$head" ] && printf '%s\n' "$after" | grep -q -F -- "$head"; }; }; then
    tmux send-keys -t "$1" Enter
    echo "sent (second Enter): $1"
  else
    echo "sent: $1"
  fi
}

# A session connects to the shared MCP server once, at start, and gives up for
# its whole life after ~7 s of refused connects (3 retries at 1, 2, 4 s). After
# a reboot the server is minutes away, so a window created first never has
# precis (gr460711: the orchestrator, 2026-10-02 11:34Z). Start the server and
# wait for a real HTTP answer — the supervisor accepts connects during its
# prepare step, so a bare TCP connect is not ready.
wait_mcp() {
  local ensure="${PRECIS_MCP_ENSURE:-$HOME/work/infrastructure/precis-mcp/scripts/precis-mcp-http-ensure.sh}"
  local port="${PRECIS_MCP_HTTP_PORT:-8765}" limit="${PRECIS_FLEET_MCP_WAIT:-600}"
  local start=$SECONDS said=""
  [ -x "$ensure" ] && [ "$limit" != 0 ] || return 0
  bash "$ensure" || true
  until [ "$(curl -s -o /dev/null -m 5 -w '%{http_code}' -X POST "http://127.0.0.1:$port/mcp")" != 000 ]; do
    if [ $((SECONDS - start)) -ge "$limit" ]; then
      echo "scripts/fleet: no answer from the session MCP server on :$port after ${limit}s;" \
        "starting windows anyway — run /mcp in each once it is up" >&2
      return 0
    fi
    [ -n "$said" ] || { echo "waiting for the session MCP server on :$port (up to ${limit}s)"; said=1; }
    sleep 5
  done
}

# One thread window, as `up` makes it: boot prompt, the row's model, the
# stagger left to the caller. Returns 1 (and prints `exists:`) when it is there.
up_thread() { # up_thread <slug>
  local slug=$1
  if has_window "$slug"; then echo "exists: $slug"; return 1; fi
  # Reto 2026-10-03: the main loop keeps design and domain judgment and
  # hands the rest to subagents (AGENTS.md "Agent sizing"); editors run in
  # an isolated worktree so they never collide with the thread's own tree.
  local boot="Resume the work on $slug. Use subagents per AGENTS.md Agent sizing: you keep design and domain judgment; searches to navigator/extract/Explore, decided edits to coder/test-author/documenter, test runs to test-runner, cluster reads to cluster-ops, bug tracing to root-cause, pre-ship review to reviewer; independent pieces as parallel agents in one message; any agent that edits uses isolation worktree."
  tmux new-window -d -n "$slug" -c "$root" \
    "claude -w $slug --model $(thread_model "$slug") '$boot'"
  echo "created: $slug"
}

cmd_up() {
  need_tmux
  local stagger=10 slugs="" s
  while [ $# -gt 0 ]; do
    case "$1" in
      --stagger) [ $# -ge 2 ] || { echo "scripts/fleet up: --stagger needs a number" >&2; exit 2; }
                 stagger="$2"; shift 2 ;;
      -*) echo "scripts/fleet up: unknown option: $1" >&2; exit 2 ;;
      *) slugs="$slugs $1"; shift ;;
    esac
  done
  # Named slugs are checked against the table before anything is created.
  for s in $slugs; do
    threads | cut -f1 | grep -q -x -F -- "$s" ||
      { echo "scripts/fleet up: no such thread in $table: $s" >&2; exit 2; }
  done
  wait_mcp
  mkdir -p "$queue/open" "$queue/answered" "$reviews"
  if ! has_window review; then
    local prompt="You are the review session. Read $conf/review-protocol.md and follow it; the queue directory is $queue. Start with the gather step, then give Reto the status board and the first cluster."
    if tmux list-windows -F '#{window_index}' | grep -q -x 1; then
      tmux new-window -d -b -t 1 -n review -c "$root" "claude --model $model '$prompt'"
    else
      tmux new-window -d -t 1 -n review -c "$root" "claude --model $model '$prompt'"
    fi
    echo "created: review"
  fi
  if [ -z "$slugs" ]; then
    threads | while IFS=$'\t' read -r slug _effort _review _model; do
      up_thread "$slug" || continue
      sleep "$stagger"
    done
  else
    # Only the named windows; the stagger runs between created ones, not after the last.
    local made=0
    for s in $slugs; do
      if has_window "$s"; then echo "exists: $s"; continue; fi
      [ "$made" = 0 ] || sleep "$stagger"
      up_thread "$s"
      made=1
    done
  fi
}

mcp_helper_path() {
  local helper; helper="$(dirname "$0")/lib/fleet_mcp_state.py"
  [ -f "$helper" ] || helper="$root/scripts/lib/fleet_mcp_state.py"
  printf '%s' "$helper"
}

cmd_status() {
  need_tmux
  # precis MCP per pane: a session that gave up on it is otherwise silent
  # (gr462596) — DOWN means run /mcp → precis → Reconnect in that window.
  # The helper sits beside this script; a copy run from elsewhere (an
  # unsynced tree, `git show origin/main:scripts/fleet`) falls back to the
  # main checkout's. No helper or a failed one prints `?`, never `-`, which
  # means "no MCP events yet".
  local mcp="" unknown="?" helper; helper=$(mcp_helper_path)
  if [ ! -f "$helper" ]; then
    echo "scripts/fleet: no fleet_mcp_state.py beside this script or in $root — mcp column unknown" >&2
  elif mcp=$(python3 "$helper"); then
    unknown="-"
  else
    echo "scripts/fleet: $helper failed — mcp column unknown" >&2
  fi
  printf '%-3s %-28s %-8s %-7s %s\n' "#" window ctx state mcp
  tmux list-windows -F '#{window_index} #{window_name} #{pane_id}' | while read -r idx w pid; do
    pane=$(tmux capture-pane -p -t "$w" 2>/dev/null) || continue
    pct=$(printf '%s\n' "$pane" | grep -o '[0-9]*% ctx' | tail -1 || true)
    if printf '%s\n' "$pane" | grep -q -E "$dialog_re"; then st=DIALOG
    elif printf '%s\n' "$pane" | grep -q -E '… \([0-9]+'; then st=busy
    else st=idle; fi
    m=$(printf '%s\n' "$mcp" | awk -v p="$pid" '$1 == p {print $2; exit}')
    printf '%-3s %-28s %-8s %-7s %s\n' "$idx" "$w" "${pct:--}" "$st" "${m:-$unknown}"
  done
}

# A held message is the final text (placeholders already filled), so
# `deliver` needs no knowledge of which form it came from.
hold_msg() { # hold_msg <window> <text>
  local d="$held/$1" stamp n=1 f
  mkdir -p "$d"
  stamp=$(date -u +%Y%m%dT%H%M%SZ)
  f=$(printf '%s/%s-%03d.msg' "$d" "$stamp" "$n")
  while [ -e "$f" ]; do
    n=$((n + 1))
    f=$(printf '%s/%s-%03d.msg' "$d" "$stamp" "$n")
  done
  printf '%s\n' "$2" > "$f"
}

say_one() { # say_one <window> <text> <hold: 1|0>
  has_window "$1" || { echo "no window: $1"; return 0; }
  if [ "$3" = 1 ] && has_dialog "$1"; then
    hold_msg "$1" "$2"
    echo "HELD: $1"
    return 0
  fi
  send "$1" "$2"
}

blank() { [ -z "$(printf '%s' "$1" | tr -d '[:space:]')" ]; }

cmd_say() {
  need_tmux
  local text="" inline=0 hold=0 file="" wins=""
  while [ $# -gt 0 ]; do
    case "$1" in
      -m) [ $# -ge 2 ] || { echo "scripts/fleet say: -m needs a text argument" >&2; exit 2; }
          text="$2"; inline=1; shift 2 ;;
      --when-clear) hold=1; shift ;;
      *) if [ -z "$file" ]; then file="$1"; else wins="$wins $1"; fi
         shift ;;
    esac
  done
  if [ "$inline" = 1 ]; then
    # With -m there is no file: the first positional was a window.
    [ -z "$file" ] || wins="$file$wins"
    # An empty message would be a bare Enter, which can answer a dialog.
    ! blank "$text" || { echo "scripts/fleet say: empty -m text" >&2; exit 2; }
    # Newlines stay: `send` pastes multi-line text, so none submits mid-message.
  else
    # An unreadable file would send an empty message, i.e. a bare Enter.
    [ -n "$file" ] && [ -s "$file" ] || { echo "scripts/fleet say: no such message file: $file" >&2; exit 2; }
  fi
  [ -n "$wins" ] || wins=$(threads | cut -f1)
  local w
  for w in $wins; do
    if [ "$inline" = 1 ]; then say_one "$w" "$text" "$hold"
    else say_one "$w" "$(fill "$file" "$w")" "$hold"; fi
  done
}

mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1"; } # GNU, then BSD

fmt_age() { # fmt_age <seconds> -> 45s / 12m / 3h / 2d
  local s=$1
  [ "$s" -ge 0 ] || s=0
  if [ "$s" -lt 60 ]; then echo "${s}s"
  elif [ "$s" -lt 3600 ]; then echo "$((s / 60))m"
  elif [ "$s" -lt 86400 ]; then echo "$((s / 3600))h"
  else echo "$((s / 86400))d"; fi
}

cmd_queue() {
  need_tmux
  local d w f text note now; now=$(date +%s)
  for d in "$held"/*/; do
    [ -d "$d" ] || continue
    w=$(basename "$d")
    note=""
    has_window "$w" || note=" (no window)"
    for f in "$d"*.msg; do
      [ -e "$f" ] || continue
      text=$(head -n 1 "$f")
      printf '%s %s%s %s\n' "$w" "$(fmt_age $((now - $(mtime "$f"))))" "$note" "${text:0:60}"
    done
  done
}

# deliver_held <mode>: send each held message to a window with no dialog.
# mode cli prints send's lines and `delivered: <win>`; mode watch prints only
# `delivered <win>`. The dialog is re-checked per message, at delivery time.
deliver_held() {
  local d w f text out
  for d in "$held"/*/; do
    [ -d "$d" ] || continue
    w=$(basename "$d")
    has_window "$w" || continue
    for f in "$d"*.msg; do # glob order is the stamp order: oldest first
      [ -e "$f" ] || continue
      # Stop at the first dialog: later messages must not overtake this one.
      ! has_dialog "$w" || break
      text=$(cat "$f")
      out=$(send "$w" "$text")
      [ "$1" = watch ] || echo "$out"
      case "$out" in SKIPPED*) break ;; esac
      rm -f "$f"
      if [ "$1" = watch ]; then echo "delivered $w"; else echo "delivered: $w"; fi
    done
  done
  return 0
}

cmd_deliver() {
  need_tmux
  deliver_held cli
}

cmd_verdict() {
  need_tmux
  local slug="${1:-}" src=- body first rest f
  [ -n "$slug" ] || { echo "usage: scripts/fleet verdict <slug> [--file f | -]" >&2; exit 2; }
  case "$slug" in */*|.*) echo "scripts/fleet verdict: bad slug: $slug" >&2; exit 2 ;; esac
  shift
  case "${1:-}" in
    --file) [ $# -ge 2 ] || { echo "scripts/fleet verdict: --file needs a path" >&2; exit 2; }
            src="$2" ;;
    -|"") src=- ;;
    *) echo "usage: scripts/fleet verdict <slug> [--file f | -]" >&2; exit 2 ;;
  esac
  if [ "$src" = - ]; then
    [ ! -t 0 ] || { echo "scripts/fleet verdict: no body on stdin" >&2; exit 2; }
    body=$(cat)
  else
    [ -r "$src" ] || { echo "scripts/fleet verdict: cannot read $src" >&2; exit 2; }
    body=$(cat "$src")
  fi
  ! blank "$body" || { echo "scripts/fleet verdict: empty body" >&2; exit 2; }
  body=$(printf '%s\n' "$body" | sed '/[^[:space:]]/,$!d') # drop leading blank lines
  first=$(printf '%s\n' "$body" | head -n 1 | sed -E 's/^#+[[:space:]]*//')
  rest=$(printf '%s\n' "$body" | sed '1d')
  # The stamp comes from this tool only: a hand stamp in the heading is refused.
  if printf '%s\n' "$first" | grep -q -E '\(orchestrator, [^)]*Z\)'; then
    echo "scripts/fleet verdict: the first line already carries an (orchestrator, …Z) stamp — drop it, the tool stamps" >&2
    exit 2
  fi
  mkdir -p "$reviews"
  f="$reviews/$slug.review.md"
  [ -e "$f" ] || printf '# %s — review (orchestrator)\n' "$slug" > "$f"
  [ -z "$(tail -c1 "$f")" ] || printf '\n' >> "$f"
  {
    printf '\n## %s (orchestrator, %s)\n' "$first" "$(date -u +'%Y-%m-%d %H:%MZ')"
    if ! blank "$rest"; then printf '\n%s\n' "$rest"; fi
  } >> "$f"
  echo "written: $f"
  say_one "$slug" "verdict written: $f, newest section at the end" 1
}

# Dialog classification. The markers are $dialog_re (has_dialog);
# the type patterns are best effort and await live samples (2026-10-03):
#   hook        "PreToolUse hook"-style or "hook … blocked" wording; checked
#               first. A bare "hook" is not enough: a command naming
#               scripts/hooks/ would read as one.
#   trust       "Do you trust the files in this folder" (a fresh worktree session)
#   permission  the dialog text has "Do you want to"
#   question    the dialog text has "Enter to select" (and no "Do you want")
#   unknown     any other marker (none today; reserved for new has_dialog markers)
# "Dialog text" is the pane from 30 lines above the last marker line to its end.
hook_re='(pre|post)tooluse[^ ]* hook|hook.*(block|refus)'
box='^([[:space:]]|│|┃|║|╭|╮|╰|╯|─|━|═|┌|┐|└|┘|├|┤|┬|┴|┼)*$'
side='^([[:space:]]|│|┃|║)+|([[:space:]]|│|┃|║)+$'

dialog_text() { # the dialog's lines out of a whole pane
  printf '%s\n' "$1" | awk -v re="$dialog_re" '
    { l[NR] = $0 }
    $0 ~ re { m = NR }
    END { s = m - 30; if (s < 1) s = 1; for (i = s; i <= NR; i++) print l[i] }'
}

dialog_type() { # dialog_type <dialog text>
  if printf '%s\n' "$1" | grep -q -i -E "$hook_re"; then echo hook
  elif printf '%s\n' "$1" | grep -q 'Do you trust the files'; then echo trust
  elif printf '%s\n' "$1" | grep -q 'Do you want to'; then echo permission
  elif printf '%s\n' "$1" | grep -q 'Enter to select'; then echo question
  else echo unknown; fi
}

# The nearest non-empty, non-box line above the first marker. For a question
# (AskUserQuestion, sampled live 2026-10-03) the text sits in `│`-prefixed lines
# above numbered options: take the first of those, else the last line ending
# in `?`, else the nearest line that is not an option (`❯ 1. …`, `  5. …`).
dialog_command() { # dialog_command <dialog text> <type>
  local above cand
  above=$(printf '%s\n' "$1" | awk -v re="$dialog_re" '$0 ~ re { exit } { print }' |
    grep -v -E "$box" | sed -E "s/$side//g" || true)
  cand=""
  if [ "$2" = question ]; then
    cand=$(printf '%s\n' "$1" | grep -E '^[[:space:]]*│' | head -n 1 | sed -E "s/$side//g" || true)
    [ -n "$cand" ] || cand=$(printf '%s\n' "$above" | grep '?$' | tail -n 1 || true)
    [ -n "$cand" ] || cand=$(printf '%s\n' "$above" | grep -v -E '^(❯[[:space:]]*)?[0-9]+\.' | tail -n 1 || true)
  fi
  [ -n "$cand" ] || cand=$(printf '%s\n' "$above" | tail -n 1)
  printf '%s' "${cand:0:100}"
}

cmd_dialogs() {
  need_tmux
  local w pane text type
  for w in $(windows); do
    # -J joins wrapped lines, so a long command is one line, not its tail.
    pane=$(tmux capture-pane -p -J -t "$w" 2>/dev/null) || continue
    printf '%s\n' "$pane" | grep -q -E "$dialog_re" || continue
    text=$(dialog_text "$pane")
    type=$(dialog_type "$text")
    printf '%s %s %s\n' "$w" "$type" "$(dialog_command "$text" "$type")"
  done
}

cmd_peek() {
  need_tmux
  local w="${1:-}" n=25
  [ -n "$w" ] || { echo "usage: scripts/fleet peek <win> [-n N]" >&2; exit 2; }
  shift
  if [ "${1:-}" = -n ]; then n="${2:-}"; fi
  case "$n" in ''|*[!0-9]*) echo "scripts/fleet peek: -n wants a number" >&2; exit 2 ;; esac
  has_window "$w" || { echo "no window: $w" >&2; exit 1; }
  tmux capture-pane -p -J -S -200 -t "$w" | grep -v '^[[:space:]]*$' | tail -n "$n" || true
}

ref_line() { # ref_line <name> -> `name sha9 age [N behind main]`
  local name="$1" sha ts age behind
  sha=$(git rev-parse --verify -q "origin/$name^{commit}") || { echo "$name missing"; return 0; }
  ts=$(git log -1 --format=%ct "$sha")
  age=$(fmt_age $(($(date +%s) - ts)))
  if [ "$name" = main ]; then echo "$name ${sha:0:9} $age"; return 0; fi
  behind=$(git rev-list --count "$sha..origin/main" 2>/dev/null) || behind="?"
  echo "$name ${sha:0:9} $age $behind behind main"
}

cmd_refs() {
  git fetch -q origin 2>/dev/null || echo "scripts/fleet refs: fetch failed — showing the local origin/* refs" >&2
  ref_line main
  ref_line gated
  ref_line prod
  # last-gated-main-sha: exit 0 + sha = found; exit 0 + empty = unknowable;
  # exit 2 = looked and found none. Exit 2 is an answer, not a crash.
  local helper green rc=0 ts
  helper="$(dirname "$0")/last-gated-main-sha"
  [ -x "$helper" ] || { echo "green unknown"; return 0; }
  green=$("$helper" --start origin/main 2>/dev/null) || rc=$?
  if [ "$rc" = 2 ]; then echo "green none in 48h"
  elif [ "$rc" != 0 ] || [ -z "$green" ]; then echo "green unknown"
  elif ts=$(git log -1 --format=%ct "$green" 2>/dev/null); then
    echo "green ${green:0:9} $(fmt_age $(($(date +%s) - ts)))"
  else echo "green ${green:0:9} ?"; fi
}

# win_state <window> -> dialog | busy | idle | gone (same tests as `status`).
win_state() {
  local p; p=$(tmux capture-pane -p -t "$1" 2>/dev/null) || { echo gone; return 0; }
  if printf '%s\n' "$p" | grep -q -E "$dialog_re"; then echo dialog
  elif printf '%s\n' "$p" | grep -q -E '… \([0-9]+'; then echo busy
  else echo idle; fi
}

# The orchestrator's seat: window 0, `claude`, `organizer`, and the caller's own
# window. The classifier blocks tmux self-drive there and Reto types /compact
# himself, so compact and mcp-check --fix never send to it.
orch_window() {
  local w="$1" idx own
  idx=$(tmux list-windows -F '#{window_index} #{window_name}' | awk -v w="$w" '$2 == w {print $1; exit}')
  [ "$idx" != 0 ] || return 0
  case "$w" in claude|organizer) return 0 ;; esac
  if [ -n "${TMUX_PANE:-}" ]; then
    own=$(tmux display-message -p -t "$TMUX_PANE" '#{window_name}' 2>/dev/null || true)
    [ "$own" != "$w" ] || return 0
  fi
  return 1
}

# wait_idle <win> <start-seconds> <limit-seconds>: poll until two CONSECUTIVE
# polls see the window idle. 0 = idle, 1 = timed out, 2 = window gone.
wait_idle() {
  local run=0 poll="${PRECIS_FLEET_COMPACT_POLL:-5}" st
  while true; do
    st=$(win_state "$1")
    [ "$st" != gone ] || return 2
    if [ "$st" = idle ]; then run=$((run + 1)); else run=0; fi
    [ "$run" -lt 2 ] || return 0
    [ $((SECONDS - $2)) -lt "$3" ] || return 1
    sleep "$poll"
  done
}

# compact_one <win> <at-idle: 1|0> -> 0 done, 1 no window, 3 refused / timed out
compact_one() {
  local w="$1" at_idle="$2" limit="${PRECIS_FLEET_COMPACT_WAIT:-1800}"
  local grace="${PRECIS_FLEET_COMPACT_GRACE:-90}" start=$SECONDS st rc out
  has_window "$w" || { echo "no window: $w" >&2; return 1; }
  if orch_window "$w"; then
    echo "scripts/fleet compact: refusing $w: the orchestrator's seat or the caller's own window" >&2
    return 3
  fi
  if [ "$at_idle" = 1 ]; then
    rc=0; wait_idle "$w" "$start" "$limit" || rc=$?
    case "$rc" in
      0) ;;
      2) echo "scripts/fleet compact: $w vanished while waiting for idle; nothing sent" >&2; return 3 ;;
      *) echo "scripts/fleet compact: $w not idle within ${limit}s; nothing sent" >&2; return 3 ;;
    esac
  else
    st=$(win_state "$w")
    [ "$st" = idle ] || { echo "scripts/fleet compact: refusing $w: $st (use --at-idle to wait)" >&2; return 3; }
  fi
  out=$(send "$w" "/next"); echo "$out"
  case "$out" in SKIPPED*) echo "scripts/fleet compact: $w opened a dialog before /next; nothing more sent" >&2; return 3 ;; esac
  # A window reports idle before /next registers: a fixed grace, then two idle polls.
  sleep "$grace"
  rc=0; wait_idle "$w" "$start" "$limit" || rc=$?
  case "$rc" in
    0) ;;
    2) echo "scripts/fleet compact: $w vanished after /next; /compact not sent" >&2; return 3 ;;
    *) echo "scripts/fleet compact: $w not idle within ${limit}s after /next; /compact not sent" >&2; return 3 ;;
  esac
  out=$(send "$w" "/compact"); echo "$out"
  case "$out" in SKIPPED*) echo "scripts/fleet compact: $w opened a dialog before /compact; not sent" >&2; return 3 ;; esac
  return 0
}

cmd_compact() {
  need_tmux
  local at_idle=0 wins="" w r rc=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --at-idle) at_idle=1 ;;
      -*) echo "usage: scripts/fleet compact <win…> [--at-idle]" >&2; exit 2 ;;
      *) wins="$wins $1" ;;
    esac
    shift
  done
  [ -n "$wins" ] || { echo "usage: scripts/fleet compact <win…> [--at-idle]" >&2; exit 2; }
  for w in $wins; do
    r=0; compact_one "$w" "$at_idle" || r=$?
    [ "$r" -le "$rc" ] || rc=$r
  done
  exit "$rc"
}

# mcp_states -> one `<win> <precis>` line per window, connected | dead |
# unknown. Fails (quietly) when the helper is missing or fails. precis is the
# only MCP a session needs (claude-context retired 2026-10-08).
mcp_states() {
  local helper out list w pid
  helper=$(mcp_helper_path)
  [ -f "$helper" ] || return 1
  out=$(python3 "$helper" --servers precis 2>/dev/null) || return 1
  list=$(tmux list-windows -F '#{window_name} #{pane_id}') || return 1
  printf '%s\n' "$list" | while read -r w pid; do
    printf '%s\n' "$out" | awk -v w="$w" -v p="$pid" '
      function m(s) { return s == "ok" ? "connected" : s == "DOWN" ? "dead" : "unknown" }
      BEGIN { a = "-" }
      $1 == p { split($2, x, "="); a = x[2]; exit }
      END { print w, m(a) }'
  done
}

cmd_mcp_check() {
  need_tmux
  local fix=0 states w p st
  case "${1:-}" in
    --fix) fix=1 ;;
    "") ;;
    *) echo "usage: scripts/fleet mcp-check [--fix]" >&2; exit 2 ;;
  esac
  states=$(mcp_states) || { echo "scripts/fleet mcp-check: no usable fleet_mcp_state.py — states unknown" >&2; exit 1; }
  printf '%s\n' "$states" | while read -r w p; do
    echo "$w precis=$p"
  done
  [ "$fix" = 1 ] || return 0
  printf '%s\n' "$states" | while read -r w p; do
    [ "$p" = dead ] || continue
    if orch_window "$w"; then echo "skipped: $w (orchestrator)"; continue; fi
    st=$(win_state "$w")
    case "$st" in
      idle) send "$w" "/mcp reconnect all" ;;
      *) echo "pending: $w ($st)" ;;
    esac
  done
}

# mcp_reconnect_verdict <precis-state> <win-state> <force> <named> -> one word:
# connected | busy | dialog | unknown (a skip reason) or `go`. Pure, so the
# selection is testable without a session.
mcp_reconnect_verdict() {
  case "$2" in busy|dialog) echo "$2"; return 0 ;; esac
  case "$1" in
    connected) [ "$3" = 1 ] && echo go || echo connected ;;
    unknown) [ "$4" = 1 ] && echo go || echo unknown ;;
    *) echo go ;;
  esac
}

cmd_mcp_reconnect() {
  need_tmux
  local force=0 dry=0 named=() a states w p st v out
  for a in "$@"; do
    case "$a" in
      --force) force=1 ;;
      --dry-run) dry=1 ;;
      -*) echo "usage: scripts/fleet mcp-reconnect [--force] [--dry-run] [win…]" >&2; exit 2 ;;
      *) named+=("$a") ;;
    esac
  done
  states=$(mcp_states) || { echo "scripts/fleet mcp-reconnect: no usable fleet_mcp_state.py — states unknown" >&2; exit 1; }
  for w in ${named[@]+"${named[@]}"}; do
    has_window "$w" || { echo "scripts/fleet mcp-reconnect: no window: $w" >&2; exit 2; }
  done
  printf '%s\n' "$states" | while read -r w p; do
    if [ "${#named[@]}" -gt 0 ]; then
      case " ${named[*]} " in *" $w "*) ;; *) continue ;; esac
    fi
    if orch_window "$w"; then echo "$w: skipped(orchestrator)"; continue; fi
    st=$(win_state "$w")
    v=$(mcp_reconnect_verdict "$p" "$st" "$force" "$([ "${#named[@]}" -gt 0 ] && echo 1 || echo 0)")
    if [ "$v" != go ]; then echo "$w: skipped($v)"; continue; fi
    if [ "$dry" = 1 ]; then echo "$w: would reconnect (precis=$p)"; continue; fi
    out=$(send "$w" "/mcp reconnect all") && case "$out" in SKIPPED*) false ;; esac \
      && echo "$w: reconnected" || echo "$w: failed"
  done
}

# --- watch --------------------------------------------------------------------
# State is tab-separated `kind key value` lines, held in W_STATE and written
# whole, atomically, once a tick. bash 3.2: no associative arrays.
st_get() { printf '%s\n' "$W_STATE" | awk -F'\t' -v k="$1" -v key="$2" '$1 == k && $2 == key {print $3; exit}'; }
st_kind() { printf '%s\n' "$W_STATE" | awk -F'\t' -v k="$1" '$1 == k'; }
st_line() { printf '%s\t%s\t%s\n' "$1" "$2" "$3"; } # <kind> <key> <value>

watch_windows() { # ctx and dialog legs; one pane capture per window
  local w pane pct bucket old st type floor="${PRECIS_FLEET_CTX_FLOOR:-30}"
  for w in $(windows); do
    pane=$(tmux capture-pane -p -J -t "$w" 2>/dev/null) || continue
    case "$w" in organizer|presentation) ;; *)
      pct=$(printf '%s\n' "$pane" | grep -o '[0-9]*% ctx' | tail -1 | grep -o '^[0-9]*' || true)
      old=$(st_get ctx "$w")
      if [ -z "$pct" ]; then
        # No marker in this capture (a redraw): keep what was last seen.
        [ -z "$old" ] || wn_ctx="$wn_ctx$(st_line ctx "$w" "$old")"$'\n'
      elif [ "$pct" -ge "$floor" ]; then
        bucket=$((pct / 10 * 10))
        wn_ctx="$wn_ctx$(st_line ctx "$w" "$bucket")"$'\n'
        if [ "$W_FIRST" = 0 ] && { [ -z "$old" ] || [ "$bucket" -gt "$old" ]; }; then
          if printf '%s\n' "$pane" | grep -q -E '… \([0-9]+'; then st=busy; else st=idle; fi
          echo "ctx $w ${pct}% $st"
        fi
      fi ;;
    esac
    if printf '%s\n' "$pane" | grep -q -E "$dialog_re"; then
      wn_dialog="$wn_dialog$(st_line dialog "$w" 1)"$'\n'
      if [ "$W_FIRST" = 0 ] && [ -z "$(st_get dialog "$w")" ]; then
        type=$(dialog_type "$(dialog_text "$pane")")
        echo "dialog $w $type"
      fi
    fi
  done
}

watch_notes() {
  local f mt old
  for f in "$reviews"/*.md; do
    [ -e "$f" ] || continue
    case "$f" in *.review.md) continue ;; esac
    mt=$(mtime "$f")
    wn_note="$wn_note$(st_line note "$f" "$mt")"$'\n'
    [ "$W_FIRST" = 0 ] || continue
    old=$(st_get note "$f")
    if [ -z "$old" ]; then echo "note $(basename "$f" .md) new"
    elif [ "$old" != "$mt" ]; then echo "note $(basename "$f" .md) changed"; fi
  done
}

# Newly completed check.yml push runs on main, oldest first. The conclusion and
# the tested sha are printed as gh reports them; a gh failure is silent and
# retried at the next ci tick. The first successful listing only seeds.
#
# 2026-10-03, two bursts of 20 repeated lines: the page is the 20 newest
# completed runs, and with main's shared group (many cancelled runs) a run drops
# off it and comes back. So a seen id is CARRIED across ticks with its creation
# time (`ci <id> <epoch>`), not rebuilt from the page; only runs CREATED within
# PRECIS_FLEET_CI_WINDOW_HOURS (default 6, whole hours) are eligible, and seen
# ids older than that are pruned, since they can never be emitted again. At
# most PRECIS_FLEET_CI_MAX_LINES (default 5) go out per tick; the rest stay
# unseen and go out next tick. A createdAt watermark would be wrong: a run
# queued behind others completes after a younger one and would be skipped.
watch_ci() {
  local out runs id sha concl ep seeded bound=() emitted=0 page=" " _k
  local cap="${PRECIS_FLEET_CI_MAX_LINES:-5}" hours="${PRECIS_FLEET_CI_WINDOW_HOURS:-6}"
  local cutoff=$(( $(date +%s) - hours * 3600 ))
  # A hung gh would stall the tick and with it the queue drain. macOS ships no
  # `timeout`; perl's alarm is on both platforms (SIGALRM ends gh, the leg
  # retries at the next ci tick).
  command -v perl >/dev/null && bound=(perl -e 'alarm shift; exec @ARGV' "${PRECIS_FLEET_GH_TIMEOUT:-30}")
  out=$(${bound[@]+"${bound[@]}"} gh run list --workflow check.yml --branch main --event push --status completed \
    --limit 20 --json databaseId,headSha,conclusion,createdAt 2>/dev/null) || return 0
  runs=$(printf '%s' "$out" | python3 -c '
import json, sys
from datetime import datetime, timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(hours=float(sys.argv[1]))
for r in reversed(json.load(sys.stdin)):
    created = r.get("createdAt") or ""
    try:
        when = datetime.fromisoformat(created.replace("Z", "+00:00"))
    except ValueError:
        continue  # no usable date: never eligible
    if when < cutoff:
        continue
    print(r["databaseId"], str(r["headSha"])[:9], r.get("conclusion") or "unknown",
          int(when.timestamp()))
' "$hours" 2>/dev/null) || return 0
  seeded=$(st_get seed ci)
  wn_ci_ran=1
  while read -r id sha concl ep; do
    [ -n "$id" ] || continue
    if [ -n "$seeded" ] && [ -z "$(st_get ci "$id")" ]; then
      # Over the cap: leave it unseen, so it goes out next tick.
      [ "$emitted" -lt "$cap" ] || continue
      echo "ci main $sha $concl"
      emitted=$((emitted + 1))
    fi
    page="$page$id "
    wn_ci="$wn_ci$(st_line ci "$id" "$ep")"$'\n'
  done <<EOF
$runs
EOF
  # Carry ids seen earlier that are off this page but still inside the window.
  while IFS=$'\t' read -r _k id ep; do
    [ -n "$id" ] || continue
    case "$page" in *" $id "*) continue ;; esac
    [ "$ep" -ge "$cutoff" ] 2>/dev/null || continue
    wn_ci="$wn_ci$(st_line ci "$id" "$ep")"$'\n'
  done <<EOF
$(st_kind ci)
EOF
}

watch_mcp() {
  local states w p seeded
  states=$(mcp_states) || return 0
  seeded=$(st_get seed mcp)
  wn_mcp_ran=1
  while read -r w p; do
    [ -n "$w" ] || continue
    if [ "$p" = dead ]; then
      wn_mcp="$wn_mcp$(st_line mcp "$w" 1)"$'\n'
      if [ -n "$seeded" ] && [ -z "$(st_get mcp "$w")" ]; then echo "mcp $w dead"; fi
    fi
  done <<EOF
$states
EOF
}

watch_tick() { # watch_tick <tick number from 0>
  local n="$1" new sf="$state/fleet-watch.state"
  wn_ctx=""; wn_dialog=""; wn_note=""; wn_ci=""; wn_mcp=""; wn_ci_ran=""; wn_mcp_ran=""
  watch_windows
  watch_notes
  # gh and the MCP logs are slower: every 8th / 4th tick, the first tick included.
  if [ $((n % 8)) = 0 ]; then watch_ci; fi
  if [ $((n % 4)) = 0 ]; then watch_mcp; fi
  deliver_held watch
  # A leg that did not run, or failed, keeps its previous state; once a leg has
  # succeeded its `seed` line is set, which is what lets it emit.
  if [ -n "$wn_ci_ran" ]; then wn_ci="$wn_ci$(st_line seed ci 1)"
  else wn_ci="$(st_kind ci)"$'\n'"$(st_kind seed | awk -F'\t' '$2 == "ci"')"; fi
  if [ -n "$wn_mcp_ran" ]; then wn_mcp="$wn_mcp$(st_line seed mcp 1)"
  else wn_mcp="$(st_kind mcp)"$'\n'"$(st_kind seed | awk -F'\t' '$2 == "mcp"')"; fi
  new=$(printf '%s\n%s\n%s\n%s\n%s\n' "$wn_ctx" "$wn_note" "$wn_dialog" "$wn_ci" "$wn_mcp" | awk 'NF')
  if [ "$W_FIRST" = 1 ] || [ "$new" != "$W_STATE" ]; then
    printf '%s\n' "$new" > "$sf.tmp.$$"
    mv "$sf.tmp.$$" "$sf"
  fi
  W_STATE="$new"
  W_FIRST=0
}

cmd_watch() {
  need_tmux
  local tick="${PRECIS_FLEET_WATCH_TICK:-15}" max="${PRECIS_FLEET_WATCH_TICKS:-0}" n=0
  mkdir -p "$state"
  W_STATE=""; W_FIRST=1
  if [ -f "$state/fleet-watch.state" ]; then
    W_STATE=$(cat "$state/fleet-watch.state"); W_FIRST=0
  fi
  while true; do
    watch_tick "$n"
    n=$((n + 1))
    if [ "$max" != 0 ] && [ "$n" -ge "$max" ]; then break; fi
    sleep "$tick"
  done
}

cmd_effort() {
  need_tmux
  # `/effort` saves its level as the default for new sessions, last write
  # wins: send medium first so the saved default ends on high.
  for level in medium high; do
    threads | while IFS=$'\t' read -r slug effort _review _model; do
      [ "$effort" = "$level" ] || continue
      has_window "$slug" && send "$slug" "/effort $level"
    done
  done
}

cmd_watch_ctx() {
  need_tmux
  local seen="" w pane pct bucket st
  while true; do
    for w in $(windows | grep -v -x -e organizer -e presentation); do
      pane=$(tmux capture-pane -p -t "$w" 2>/dev/null) || continue
      pct=$(printf '%s\n' "$pane" | grep -o '[0-9]*% ctx' | tail -1 | grep -o '^[0-9]*' || true)
      [ -z "$pct" ] && continue
      [ "$pct" -lt "${PRECIS_FLEET_CTX_FLOOR:-30}" ] && continue
      bucket=$((pct / 10 * 10))
      case "$seen" in *" $w:$bucket"*) continue ;; esac
      seen="$seen $w:$bucket"
      if printf '%s\n' "$pane" | grep -q -E '… \([0-9]+'; then st=busy; else st=idle; fi
      echo "ctx $w ${pct}% $st"
    done
    sleep 60
  done
}

case "${1:-}" in
  up) shift; cmd_up "$@" ;;
  status) cmd_status ;;
  say) shift; cmd_say "$@" ;;
  queue) cmd_queue ;;
  deliver) cmd_deliver ;;
  verdict) shift; cmd_verdict "$@" ;;
  peek) shift; cmd_peek "$@" ;;
  dialogs) cmd_dialogs ;;
  refs) cmd_refs ;;
  compact) shift; cmd_compact "$@" ;;
  mcp-check) shift; cmd_mcp_check "$@" ;;
  mcp-reconnect) shift; cmd_mcp_reconnect "$@" ;;
  watch) cmd_watch ;;
  effort) cmd_effort ;;
  model) thread_model "${2:?usage: scripts/fleet model <slug>}" ;;
  watch-ctx) cmd_watch_ctx ;;
  queue-dir) echo "$queue" ;;
  *) awk 'NR>1 && !/^#/ {exit} NR>1 {sub(/^# ?/, ""); print}' "$0"; exit 2 ;;
esac
