#!/usr/bin/env bash
# factory: bring up and manage the tares-factory crew, one team of connected Claude Code sessions
# that builds every project handed to it on Tares. People run it as `tares factory`; stations call
# it as `factory`, which the Tares plugin puts on their PATH. Run `tares factory help` for commands.
# Needs: claude, git, jq, curl, a Tares (`tares up`). Optional: gh (PRs), codex, docker and kind.
set -euo pipefail

src="${BASH_SOURCE[0]}"
while [ -L "$src" ]; do
  dir="$(cd -P "$(dirname "$src")" && pwd)"; src="$(readlink "$src")"
  case "$src" in /*) ;; *) src="$dir/$src" ;; esac
done
KIT_PLUGIN="$(cd -P "$(dirname "$src")/.." && pwd)"   # the Tares plugin this script ships in
TEMPLATES="$KIT_PLUGIN/templates"
SETTING_KEYS="autonomy releaser release_profile prod_pattern challenger builders_max"
ME="tares factory"

die() { echo "$ME: $*" >&2; exit 1; }
say() { echo "$ME: $*" >&2; }

usage() {
  cat <<'EOF'
tares factory: one crew of connected Claude Code sessions for all your projects, kept on Tares

  tares factory crew up [--yes] [--no-watch] [roles...]
                                            start the crew from the crew home (orchestrator first),
                                            and the watch in the background; the first run asks the
                                            crew settings
  tares factory crew status                 the stations, the questions for you, a line per project
  tares factory crew attach <role|station>  open a station in this terminal (answer the crew's
                                            questions in the orchestrator)
  tares factory crew desk                   the questions waiting for you, each in full
  tares factory crew logs <role|station>    a station's recent output
  tares factory crew watch [--once]         (crew up runs it in the background) revive stations
                                            that stopped, under the same name; start
                                            fresh any that Claude Code stopped for thrashing on
                                            autocompact; after an update of the plugin or of Claude
                                            Code, move each station, helpers too, onto it when
                                            it's idle (same conversation, the orchestrator last; a
                                            station that keeps coming back on an old spare is
                                            started again in a new conversation, then retried
                                            after a pause, and shows as retrying; the crew setting
                                            claude_update off stops this move); restart a station
                                            between its turns when you allow it something new in
                                            Tares (same conversation, its fresh settings have it);
                                            a frozen crew's watch moves no station onto a newer kit
                                            or Claude Code and is only told of one; between
                                            rounds, read the plan's limits and the context
                                            the Crew page asks for
                                            with the idle_wake setting on, wake an idle station only
                                            when Tares has work for it (and after a quiet spell, to
                                            check), and hold its tick instead of the station
                                            with the pace setting on, slow the crew before the
                                            plan's limits (no new builders, longest ticks, builders
                                            wait), and revive a station that stopped at the limit in
                                            its own conversation once the limit has reset
  tares factory crew update [--wait <s>]    move every station onto the Claude Code installed now,
                                            right away: each when it is idle (one someone has open
                                            waits), the orchestrator last, same conversation; then
                                            checks each runs it, one session per name; gives up on a
                                            busy one after --wait seconds (default 1800, 0 = never);
                                            run from a station, it leaves that station to the watch
                                            when that station is behind, else to restart;
                                            a frozen crew moves too, since you asked; with pace
                                            on, neither moves one while a plan limit is full
  tares factory crew restart [--wait <s>]   the same, for stations that already run it too
  tares factory crew halt ["<reason>"]      stop risky actions on every station until resume
  tares factory crew resume                 lift a halt
  tares factory crew freeze                 keep the crew on today's plugin kit: a station that starts
                                            or revives uses a copy of it, a running one keeps reading
                                            the checkout until it restarts, and the watch moves none
                                            onto a newer kit or Claude Code (it still tells you one
                                            is there)
  tares factory crew unfreeze               lift the freeze; the watch moves each station onto the
                                            checkout's kit when it's idle
  tares factory crew down                   stop the watch and every station (Tares keeps the record)
  tares factory spec ["<idea>"]             plan a project with Claude and /tares:spec, in the repo it
                                            builds on or a folder of its repos; it ends with the
                                            hand-over to the crew
  tares factory session [<project> | [--new] "<idea>"]
                                            work on a project in your own Claude session, bound to
                                            it on Tares: a planned one (take it over from the crew
                                            if it has it), or a new idea planned lightly and built
                                            right there (--new: even when it is one word); no
                                            argument asks which
  tares factory projects                    every project handed to the crew: progress, the milestone
                                            it is on, its builders and repos
  tares factory finish <project> ["words"]  mark a project done yourself (the orchestrator otherwise
                                            asks you on the desk when its last milestone is done)
  tares factory restart <role|station> [--fresh]
                                            restart a station with fresh settings, same conversation
                                            (--fresh: a new conversation; its state is on Tares)
  tares factory context <role|station>      read a station's /context (its window by category) for
                                            the Crew page; the watch does it when you ask there
  tares factory plan                        read your plan's limits (/usage) for the Crew page; the
                                            watch does it every 10 minutes
  tares factory doctor                      check everything the crew needs
  tares factory settings <station> [role]   print the settings a station launches with

  used by the orchestrator (as `factory`, inside its session):
  factory roster                            stations, roles, projects, states, Claude Code version (tab separated)
  factory spawn builder [--autonomy L3-review] <project> <slug> <repo path> "<task>"
                                            one builder per milestone and repo of a project
  factory spawn helper <parent-station> <slug> "<task>"
  factory revive <station> [--fresh] ["<prompt>"]

  used by every station:
  factory tidy [--force] <path>...          remove a worktree or scratch folder, only inside the
                                            crew's own folders (instead of rm, which can stop a
                                            station on a prompt); --force for your own worktree
                                            with uncommitted or unpushed work
  factory retire <station>

Tares is the workspace `tares login` signed in to, else $TARESD_URL (default http://127.0.0.1:8787).
The crew's key is the saved login's; for a Tares of your own with auth on, TARES_AUTH_TOKEN.
The crew home is ~/factory (FACTORY_CREW_HOME overrides).
EOF
}

case "${1:-help}" in help|-h|--help) usage; exit 0 ;; esac

need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required but not installed"; }
need git; need jq; need curl

# The real claude binary. Terminal shims (cmux) inject temp --settings files and socket tokens into
# the launch flags Claude Code saves for respawn, which later breaks the respawn and leaks the token.
claude_bin() {
  if [ -n "${CLAUDE_BIN:-}" ]; then echo "$CLAUDE_BIN"; return; fi
  local c
  while IFS= read -r c; do
    if head -c 2 "$c" 2>/dev/null | grep -q '#!' && grep -q cmux "$c" 2>/dev/null; then continue; fi
    echo "$c"; return
  done < <(which -a claude 2>/dev/null | awk '!seen[$0]++')
  die "claude not found on PATH (set CLAUDE_BIN to its path)"
}
# Commands that never start or read a Claude Code session work where Claude Code isn't installed.
case "${1:-}" in
  _parse-bg-id|tidy|finish|projects) CLAUDE=claude ;;
  *) CLAUDE="$(claude_bin)" ;;
esac

CREW_HOME="${FACTORY_CREW_HOME:-$HOME/factory}"
CREW_CFG="$CREW_HOME/crew.json"
STATIONS="$CREW_HOME/stations.json"
HALT_FILE="$CREW_HOME/HALT"
FROZEN_KIT="$CREW_HOME/frozen-kit"   # the kit as `crew freeze` found it, while the crew is frozen
NEWER_FILE="$CREW_HOME/kit-newer"    # the newer kit the watch last told the person about
FREEZING="$CREW_HOME/freezing"       # present while `crew freeze` has the copy but Tares not yet the setting
CC_NEWER_FILE="$CREW_HOME/claude-newer"   # the newer Claude Code the watch last told the person about while frozen
CHECKOUT_FILE="$CREW_HOME/kit-checkout"   # the checkout the frozen copy was taken from
# The frozen copy as stations record it: physical, so a symlinked crew home still matches.
frozen_phys() { echo "$(cd -P "$CREW_HOME" 2>/dev/null && pwd || echo "$CREW_HOME")/frozen-kit"; }
# A factory run from the frozen copy (a station started while frozen) still means the checkout by
# KIT_PLUGIN: what it fingerprints, copies and starts stations on once the crew is unfrozen.
if [ -f "$CHECKOUT_FILE" ]; then
  case "$KIT_PLUGIN/" in
    "$(frozen_phys)"/*) c=$(cat "$CHECKOUT_FILE"); [ ! -d "$c" ] || { KIT_PLUGIN=$c; TEMPLATES="$KIT_PLUGIN/templates"; src="$KIT_PLUGIN/bin/factory"; } ;;
  esac
fi
EVENTS="$CREW_HOME/events.jsonl"
TOKEN_FILE="$CREW_HOME/.tares-token"

# `tares factory` hands this script the person's key in TARES_AUTH_TOKEN. It stays a variable of
# this script: no process the script starts inherits it. Claude Code's background service can pick
# it up from one of them and pass it to every station long after the key was revoked (TR-560).
# Stations and the watch read the crew file instead, and a station ignores an inherited key.
[ -z "${FACTORY_STATION:-}" ] || unset TARES_AUTH_TOKEN
export -n TARES_AUTH_TOKEN TARES_FACTORY_SOURCE TARES_FACTORY_KEY_SOURCE 2>/dev/null || true

ccfg() { jq -r "$1" "$CREW_CFG" 2>/dev/null || true; }
slugify() { tr '[:upper:]' '[:lower:]' <<<"$1" | tr -c 'a-z0-9' '-' | sed 's/--*/-/g; s/^-//; s/-$//'; echo; }
# The time the idle wake works by: epoch seconds. FACTORY_NOW stands in for it in tests.
now_s() { echo "${FACTORY_NOW:-$(date +%s)}"; }
event() { [ -d "$CREW_HOME" ] && jq -cn --arg e "$1" --arg s "${2:-}" --arg d "${3:-}" \
  '{at: (now | todate), station: $s, event: $e, detail: $d}' >> "$EVENTS" 2>/dev/null || true; }

# A crew.json written by hand, or by an older kit, gets the template's defaults under it.
merge_crew_defaults() {
  [ -f "$CREW_CFG" ] || return 0
  local tmp; tmp=$(mktemp)
  jq -s '.[0] * .[1]' "$TEMPLATES/crew.json" "$CREW_CFG" > "$tmp" || die "$CREW_CFG is not valid JSON"
  if cmp -s "$tmp" "$CREW_CFG"; then rm -f "$tmp"; else mv "$tmp" "$CREW_CFG"; fi
}

ensure_crew_home() {
  mkdir -p "$CREW_HOME"/{stations,reports,worktrees}
  merge_crew_defaults
  if [ ! -f "$CREW_CFG" ]; then
    local login="" name
    command -v gh >/dev/null && login=$(gh api user -q .login 2>/dev/null || true)
    name=$(git config --global user.name 2>/dev/null || echo "the user")
    jq --arg n "$name" --arg g "$login" --arg at "$(date -u +%Y-%m-%d)" \
      '.human.name=$n | .human.github=$g | .authorized={by:$n, at:$at}' "$TEMPLATES/crew.json" > "$CREW_CFG"
  fi
}
require_crew() { [ -f "$CREW_CFG" ] || die "no crew yet in $CREW_HOME; run: $ME crew up"; }

# ---------- Tares ----------
# The crew works only with a Tares. Its address: TARES_FACTORY_URL (what stations are given), else
# TARESD_URL (what the Tares plugin and `tares factory` use), else the local default. Stations get
# the same address, and the token as a file only the person can read, never in their settings.

TARES=""; TARES_STATE=""
# A session the factory started (a station, or the person's spec or build session) reads only the
# crew file; the person's own terminal uses the key `tares factory` gave it; the watch, started
# without one, reads the crew file on every call, so a new login reaches it without a restart.
in_crew_session() { [ -n "${FACTORY_STATION:-}${TARES_FACTORY_TOKEN_FILE:-}" ]; }
crew_key() {
  local f=${TARES_FACTORY_TOKEN_FILE:-$TOKEN_FILE}
  [ -r "$f" ] && tr -d '[:space:]' < "$f"
  return 0
}
tares_token() {
  if ! in_crew_session && [ -n "${TARES_AUTH_TOKEN:-}" ]; then echo "$TARES_AUTH_TOKEN"; return; fi
  crew_key
}
# What to add to "Tares refused" when no key was sent.
no_key_hint() {
  [ -z "$(tares_token)" ] || return 0
  if in_crew_session; then echo "; the crew has no key because the person signed out of Tares: they run tares login, and the crew picks it up"
  else echo "; if it has auth on, run tares login (or set TARES_AUTH_TOKEN for a Tares of your own)"; fi
}
tares_url() {
  local u=${TARES_FACTORY_URL:-${TARESD_URL:-${TARES_URL:-}}}
  [ -n "$u" ] || u=$(ccfg '.tares.url // empty')
  u=${u:-http://127.0.0.1:8787}
  echo "${u%/}"
}
# Where stations reach Tares's MCP server. Locally it runs as its own process on its own port
# (`tares mcp --transport streamable-http`, the default `tares status` uses too); Tares Cloud serves
# /mcp on the cell's own host. TARES_FACTORY_MCP_URL overrides both.
mcp_url() {
  if [ -n "${TARES_FACTORY_MCP_URL:-}" ]; then echo "${TARES_FACTORY_MCP_URL%/}"; return; fi
  local u host; u=$(tares_url); host=${u#*://}; host=${host%%[:/]*}
  case "$host" in
    127.0.0.1|localhost) echo "http://${TARES_MCP_HOST:-127.0.0.1}:${TARES_MCP_PORT:-8788}/mcp" ;;
    *) echo "$u/mcp" ;;
  esac
}
tares_on() {
  if [ -z "$TARES_STATE" ]; then
    TARES=$(tares_url); TARES_STATE=off
    curl -sf --max-time 5 "$TARES/health" >/dev/null 2>&1 && TARES_STATE=on
  fi
  [ "$TARES_STATE" = on ]
}
require_tares() {
  tares_on || die "no Tares answers at $TARES. Start one with \`tares up\` in another terminal, connect to your Tares Cloud workspace with \`tares login\` (or set TARESD_URL to where yours runs), then try again."
}
# Stations reach Tares only through the MCP endpoint, so a station started without one has no Tares
# tools. Only an MCP server's answer to `initialize` counts: taresd's 404 or a web page does not.
MCP_STATE=""
mcp_on() {
  if [ -z "$MCP_STATE" ]; then
    MCP_STATE=off
    local tok body
    tok=$(tares_token)
    body=$({ [ -n "$tok" ] && printf 'Authorization: Bearer %s\n' "$tok"; true; } | curl -sS -f --max-time 5 -X POST \
      -H @- -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
      --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"tares-factory","version":"0"}}}' \
      "$(mcp_url)" 2>/dev/null) && grep -q '"serverInfo"' <<<"$body" && MCP_STATE=on
  fi
  [ "$MCP_STATE" = on ]
}
require_mcp() {
  mcp_on && return 0
  local how="tares mcp --transport streamable-http"
  [ -n "$(tares_token)" ] && how="TARES_AUTH_TOKEN=<your token> $how"
  die "no MCP server answers at $(mcp_url). Stations reach Tares through it. Start one in another terminal: $how (or set TARES_FACTORY_MCP_URL to where yours runs)."
}
tares_http() { # method path [json body]; the token goes to curl on stdin, never on its command line
  local -a a=(-sS -f --max-time 15 -X "$1" -H 'Accept: application/json' -H @-)
  [ $# -ge 3 ] && a+=(-H 'Content-Type: application/json' --data "$3")
  local tok; tok=$(tares_token)
  { [ -n "$tok" ] && printf 'Authorization: Bearer %s\n' "$tok"; true; } | curl "${a[@]}" "$TARES$2"
}
uri() { jq -rn --arg p "$1" '$p | @uri'; }

# Does this Tares let a key in? (no key: does it let anyone in)
key_ok() {
  { [ -n "$1" ] && printf 'Authorization: Bearer %s\n' "$1"; true; } |
    curl -sf --max-time 10 -o /dev/null -H @- "$TARES/api/crew/settings" 2>/dev/null
}
# A key's id (nvf_<id>_<secret>), safe to print; never the key.
key_name() {
  case "$1" in
    "") echo "no key" ;;
    nvf_*_*) local r=${1#nvf_}; echo "key ${r%%_*}" ;;
    *) echo "a token with no key id" ;;
  esac
}
# The person's key, copied for the stations into a file only they can read (TARES_FACTORY_TOKEN_FILE).
# `tares login` and `tares logout` keep it; the person's own commands refresh it. Never a session
# the factory started, and only with a key this Tares accepts, so a stale key can't replace a good one.
keep_token() {
  ! in_crew_session && [ -n "${TARES_AUTH_TOKEN:-}" ] && [ -d "$CREW_HOME" ] || return 0
  [ "$(cat "$TOKEN_FILE" 2>/dev/null)" = "$TARES_AUTH_TOKEN" ] && return 0
  tares_on || return 0
  if ! key_ok "$TARES_AUTH_TOKEN"; then
    say "warning: Tares at $TARES refuses $(key_name "$TARES_AUTH_TOKEN") from the ${TARES_FACTORY_KEY_SOURCE:-environment}; the crew keeps the key it has"
    return 0
  fi
  (umask 077; printf '%s\n' "$TARES_AUTH_TOKEN" > "$TOKEN_FILE"); chmod 600 "$TOKEN_FILE"
}

# The projects handed to the crew, as Tares lists them (cached for this command).
CREW_PROJECTS=""; CREW_PROJECTS_ERR=""
crew_projects() {
  if [ -z "$CREW_PROJECTS" ]; then
    local out
    if out=$(tares_http GET /api/crew/projects 2>&1) && CREW_PROJECTS=$(jq -c '.projects // []' <<<"$out" 2>/dev/null) && [ -n "$CREW_PROJECTS" ]; then
      CREW_PROJECTS_ERR=""
    else
      CREW_PROJECTS="[]"; CREW_PROJECTS_ERR=$(tail -1 <<<"$out")
    fi
  fi
  echo "$CREW_PROJECTS"
}
# Dies when Tares could not give the list, so a refusal is never read as "no such project".
crew_projects_or_die() {
  crew_projects >/dev/null
  [ -z "$CREW_PROJECTS_ERR" ] || die "Tares at $TARES did not give the crew's projects ($CREW_PROJECTS_ERR)$(no_key_hint)"
}
project_row() { crew_projects | jq -c --arg p "$1" --arg s "$(slugify "$1")" \
  '[.[] | select(.name == $p or .id == $p or ((.name | ascii_downcase | gsub("[^a-z0-9]+"; "-") | gsub("^-|-$"; "")) == $s))][0] // empty'; }
project_repo_rows() { crew_projects | jq -r '.[] | .name as $n | (.repos // [])[] | select(.path != null) | [$n, .path] | @tsv'; }
all_repos() { crew_projects | jq -r '[.[] | .repos[] | .path | select(. != null)] | unique | .[]'; }

# ---------- crew settings and grants ----------

settings_defaults() {
  jq -n '{autonomy: "L4-ship", releaser: false, release_profile: "kind",
          prod_pattern: "(^|[-_.])(prod|production)([-_.]|$)", challenger: false, builders_max: 2,
          kit: {frozen: null}, idle_wake: false, pace: false,
          claude_update: true}'
}
settings_raw() {
  local s; s=$(tares_http GET /api/crew/settings) || die "could not read the crew settings from Tares at $TARES$(no_key_hint)"
  jq -c '.settings // .' <<<"$s"
}
# Missing settings to ask: the release ones only matter with a releaser.
settings_missing() {
  jq -r --arg k "$SETTING_KEYS" '. as $s | $k | split(" ")[]
    | select($s[.] == null or (. == "autonomy" and ($s.autonomy | IN("L3-review", "L4-ship") | not)))
    | select(((. == "release_profile" or . == "prod_pattern") | not) or $s.releaser == true)
    | select(. != "prod_pattern")' <<<"$1"   # the prod pattern comes with the release profile, unasked
}
SETTINGS=""
crew_settings() { # settings with defaults for anything unset, cached; fails when they can't be read
  if [ -z "$SETTINGS" ]; then
    local raw; raw=$(settings_raw) || return 1
    SETTINGS=$(jq -c --argjson d "$(settings_defaults)" \
      '. as $s | $d | with_entries(.value = (if $s[.key] == null then .value else $s[.key] end))' <<<"$raw")
  fi
  echo "$SETTINGS"
}
setting() { local s; s=$(crew_settings) || die "no crew settings to work from"; jq -r --arg k "$1" '.[$k] | tostring' <<<"$s"; }
releaser_on() { [ "$(setting releaser)" = true ]; }
crew_roles() { echo "orchestrator reviewer$(releaser_on && echo " releaser" || true)"; }

ASSUME_YES=0
ask() { # question recommendation why validator-regex → the answer (empty input takes the recommendation)
  local q=$1 rec=$2 why=$3 re=$4 ans
  while :; do
    ans=""
    if [ "$ASSUME_YES" != 1 ]; then
      printf '\n%s\n  recommended: %s (%s)\n  press enter to take it, or type your answer\n> ' "$q" "$rec" "$why" >&2
      IFS= read -r ans || { ans=""; echo >&2; }
    fi
    ans=${ans:-$rec}
    grep -Eq "$re" <<<"$ans" && { echo "$ans"; return; }
    say "\"$ans\" is not one of the choices"
  done
}

# One question with numbered choices; Enter takes the recommended one. Takes the number or the value.
# The same questions `tares factory crew up` asks in its box in a terminal (tares/factory_setup.py).
choose() { # question default-number "value|label"... → the value chosen
  local q=$1 def=$2; shift 2
  local -a vals=() ; local o i=1 ans
  if [ "$ASSUME_YES" = 1 ]; then o=${*:$def:1}; echo "${o%%|*}"; return; fi
  printf '\n%s\n' "$q" >&2
  for o in "$@"; do
    vals+=("${o%%|*}")
    printf '  %d) %s%s\n' "$i" "${o#*|}" "$( [ "$i" = "$def" ] && echo '   (recommended)')" >&2
    i=$((i + 1))
  done
  while :; do
    printf '  Choice [%d]: ' "$def" >&2
    IFS= read -r ans || { ans=""; echo >&2; }
    ans=${ans:-$def}
    if [[ "$ans" =~ ^[0-9]+$ ]] && [ "$ans" -ge 1 ] && [ "$ans" -le ${#vals[@]} ]; then echo "${vals[$((ans - 1))]}"; return; fi
    for o in "${vals[@]}"; do [ "$o" = "$ans" ] && { echo "$o"; return; }; done
    echo "  Pick a number from 1 to ${#vals[@]}." >&2
  done
}

ask_settings() { # missing keys → saves the answers on Tares
  local missing=$1 answers='{}' k v n=0 total
  total=$(wc -w <<<"$missing" | tr -d ' ')
  printf '\nSetting up your crew. Asked once, saved on Tares.\n' >&2
  for k in $missing; do
    case "$k" in
      autonomy) n=$((n + 1)); v=$(choose "$n/$total  Who merges pull requests?" 1 \
          "L4-ship|The crew, once its reviewer approves and tests pass" "L3-review|You, each one")
        answers=$(jq -c --arg v "$v" '.autonomy=$v' <<<"$answers") ;;
      releaser) n=$((n + 1)); v=$(choose "$n/$total  Should the crew release and deploy finished work?" 1 \
          "no|No, it stops when work is merged to main" "yes|Yes, a releaser tags and deploys each finished milestone")
        answers=$(jq -c --arg v "$v" '.releaser=($v == "yes")' <<<"$answers")
        if [ "$v" = yes ]; then
          v=$(choose "     How does the releaser ship?" 1 "kind|To a local kind cluster first" \
              "github-release|Tags and GitHub releases only" "custom|Each project's own steps")
          answers=$(jq -c --arg v "$v" --arg p "$(settings_defaults | jq -r .prod_pattern)" \
            '.release_profile=$v | .prod_pattern=$p' <<<"$answers")
        fi ;;
      challenger) n=$((n + 1)); v=$(choose "$n/$total  Should a second AI (Codex) check every commit before review?" 1 \
          "no|No" "yes|Yes, needs the Codex CLI")
        answers=$(jq -c --arg v "$v" '.challenger=($v == "yes")' <<<"$answers") ;;
      builders_max) n=$((n + 1)); v=$(choose "$n/$total  How many builders may work at the same time?" 2 \
          "1|1" "2|2" "3|3" "4|4")
        answers=$(jq -c --argjson v "$v" '.builders_max=$v' <<<"$answers") ;;
      release_profile|prod_pattern)   # a releaser chosen earlier, its shipping not yet
        jq -e '.release_profile' <<<"$answers" >/dev/null && continue
        v=$(choose "How does the releaser ship?" 1 "kind|To a local kind cluster first" \
            "github-release|Tags and GitHub releases only" "custom|Each project's own steps")
        answers=$(jq -c --arg v "$v" --arg p "$(settings_defaults | jq -r .prod_pattern)" \
          '.release_profile=$v | .prod_pattern=$p' <<<"$answers") ;;
    esac
  done
  tares_http PUT /api/crew/settings "$answers" >/dev/null || die "could not save the crew settings on Tares at $TARES"
  SETTINGS=""
}

ask_grants() { # standing grants for all projects, one per question, until an empty answer
  [ "$ASSUME_YES" = 1 ] && return 0
  local words
  while :; do
    printf '\nAnything you always allow the crew, in your own words? Press Enter to skip.\n  (for example: builders may bump patch versions of dependencies)\n> ' >&2
    IFS= read -r words || words=""
    [ -n "$words" ] || { echo >&2; return 0; }
    tares_http POST /api/grants "$(jq -cn --arg w "$words" '{words: $w, scope: "all"}')" >/dev/null || die "could not save the grant on Tares"
    echo "  saved" >&2
  done
}

ensure_settings() {
  local raw missing; raw=$(settings_raw) || exit 1; missing=$(settings_missing "$raw")
  [ -z "$missing" ] && return 0
  ask_settings "$missing"
  ask_grants
}

grants_read() { # [project] → markdown of grants for all projects, then the project's; fails when Tares can't give them
  local q=""; [ -n "${1:-}" ] && q="?project=$(uri "$1")"
  local g
  # A project Tares doesn't know answers 404; the grants for all projects still apply. Any other
  # failure is no answer at all, never "no grants".
  if ! g=$(tares_http GET "/api/grants$q" 2>&1); then
    [ -n "$q" ] && grep -q 'error: 404' <<<"$g" && g=$(tares_http GET /api/grants 2>/dev/null) || return 1
  fi
  jq -r '.all // ""' <<<"$g" || return 1
  if [ -n "${1:-}" ]; then jq -r '.project // ""' <<<"$g" | sed 's/^/@project /'; fi
}
grants_md() { # [project] → the same, or nothing (said) when Tares can't give them
  grants_read "${1:-}" || say "could not read grants from Tares"
  return 0
}
grants_sum() { cksum | cut -d' ' -f1; } # grants text on stdin → its fingerprint
# One allow rule per grant line. Tares keeps each as `- "<words>" (YYYY-MM-DD)`; `- YYYY-MM-DD: words`
# and undated list items are read too. Quotes the words and date.
GRANT_QUOTED='^"(.*)"[[:space:]]*\(([0-9]{4}-[0-9]{2}-[0-9]{2})\)[[:space:]]*$'
GRANT_DATED='^([0-9]{4}-[0-9]{2}-[0-9]{2})[[:space:]]*[:,-]?[[:space:]]*(.*)$'
grant_rules() { # project-label human
  local line scope words date
  while IFS= read -r line; do
    scope="all projects"
    case "$line" in "@project "*) line=${line#@project }; scope="project $1" ;; esac
    case "$line" in "- "*|"* "*) line=${line:2} ;; [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*) ;; *) continue ;; esac
    date=""; words=$line
    if [[ $line =~ $GRANT_QUOTED ]]; then words=${BASH_REMATCH[1]}; date=${BASH_REMATCH[2]}
    elif [[ $line =~ $GRANT_DATED ]]; then date=${BASH_REMATCH[1]}; words=${BASH_REMATCH[2]}; fi
    [ -n "$words" ] || continue
    if [ -n "$date" ]; then echo "$2 granted this on $date for $scope, in their own words: \"$words\". Acting within that grant is expected work."
    else echo "$2 granted this (undated) for $scope, in their own words: \"$words\". Acting within that grant is expected work."; fi
  done
}

# ---------- stations ----------

station_name() { # role-or-station → full station name
  case "$1" in orchestrator|reviewer|releaser) echo "crew-$1" ;; *) echo "$1" ;; esac
}
stations_get() { [ -f "$STATIONS" ] && jq -r --arg s "$1" ".[\$s]$2 // empty" "$STATIONS" || true; }
stations_put() { # station, json object to merge
  local tmp; tmp=$(mktemp)
  [ -f "$STATIONS" ] || echo '{}' > "$STATIONS"
  jq --arg s "$1" --argjson v "$2" '.[$s] = ((.[$s] // {}) + $v)' "$STATIONS" > "$tmp" && mv "$tmp" "$STATIONS"
}

# The sessions Claude Code counts as active (`claude agents --json` without --all). A list that can't be
# read is never taken for an empty one: that would make every station look stopped, and the watch would
# start a second session under each name.
active_json() {
  local out; out=$("$CLAUDE" agents --json 2>/dev/null) && jq -e 'type == "array"' <<<"$out" >/dev/null || return 1
  printf '%s\n' "$out"
}
UNREAD="could not read Claude Code's session list (claude agents --json)"
job_state() { local f="$HOME/.claude/jobs/$1/state.json"; [ -f "$f" ] && cat "$f" || echo '{}'; }

# Where a live session is in its turn: done between turns, working in one, blocked on a person. The
# list's `status` says it; its `state` reads "working" for as long as a schedule is armed, and the job
# file keeps "working" through a permission prompt.
TURN='def turn: if (.state // "") | IN("stopped", "failed", "killed", "removed") then .state
  else {idle: "done", busy: "working", waiting: "blocked"}[.status // ""] // .state end;'
turn_state() { # job id → done, working or blocked; empty when it isn't live
  active_json | jq -r --arg id "$1" "$GONE $TURN"' .[] | select(.id == $id and (gone | not)) | turn' | head -1
}

# `claude --bg` prints "backgrounded · <id> · <name>". From inside a Claude session the id comes
# wrapped in colour codes, which hide it from a plain match, so strip them first.
parse_bg_id() {
  sed $'s/\x1b\\[[0-9;]*[A-Za-z]//g' | grep -Eo 'backgrounded[^0-9a-f]+[0-9a-f]{8}' | grep -Eo '[0-9a-f]{8}$' | head -1 || true
}

# A session is live while Claude Code lists it as active (`claude agents --json` without --all) and a
# process holds it. One whose turn ended and whose process is gone drops out of that list, but one with
# a schedule armed stays in it as "working" after its process is stopped, so the process is checked per
# session. Only a session waiting on a person may have none and still be live: answering it starts one.
GONE='def gone: ((.state // "") | IN("stopped", "failed", "killed", "removed")) or ((has("pid") | not) and .state != "blocked");'

find_live_job() { # exact session name, cwd → id of the newest live session with that name there
  active_json | jq -r --arg n "$1" --arg cwd "$2" "$GONE"'
    [.[] | select(.name == $n and ($cwd == "" or .cwd == $cwd) and (gone | not))]
    | sort_by(.startedAt) | last | .id // empty'
}

is_alive() { # station [cwd] → 0 if its session is running or waiting; dies when the session list can't be read
  local cwd=${2:-} id agents; [ -n "$cwd" ] || cwd=$(stations_get "$1" '.cwd')
  id=$(stations_get "$1" '.id')
  agents=$(active_json) || die "$UNREAD; not guessing whether $1 runs"
  jq -e --arg n "$1" --arg cwd "${cwd:-$CREW_HOME}" --arg id "$id" "$GONE"'
    any(.[]; (gone | not) and ((.name == $n and .cwd == $cwd) or ($id != "" and .id == $id)))' <<<"$agents" >/dev/null
}

# One factory command at a time starts or stops a station: a restart and the watch's revive once both
# found the same station down and both started it, leaving two sessions under one name.
LOCKED=""
station_lock() { # station → held until this shell exits; dies if another live command holds it
  case " $LOCKED " in *" $1 "*) return 0 ;; esac
  local lock="$CREW_HOME/stations/$1.lock" holder me tries=0 takes=0
  me=$(exec sh -c 'echo "$PPID"') # this shell's own pid, in a subshell too
  mkdir -p "$CREW_HOME/stations"
  until mkdir "$lock" 2>/dev/null; do
    holder=$(cat "$lock/pid" 2>/dev/null || true)
    if [ -n "$holder" ] && kill -0 "$holder" 2>/dev/null; then
      die "another factory command (pid $holder) is starting or stopping $1; try again in a minute"
    fi
    # No pid yet may be a command that has only just taken the lock: give it a moment before calling it stale.
    if [ -z "$holder" ] && [ $tries -lt 10 ]; then tries=$((tries + 1)); sleep 0.2; continue; fi
    # Clear a stale lock only under a second one, and only if it is still the one judged stale: two
    # commands clearing it at once could each end up holding a fresh one.
    # It is held for an instant, so one still there after five seconds was left by a killed command.
    if mkdir "$lock.takeover" 2>/dev/null; then
      [ "$(cat "$lock/pid" 2>/dev/null || true)" = "$holder" ] && { rm -f "$lock/pid"; rmdir "$lock" 2>/dev/null || true; }
      rmdir "$lock.takeover"
    elif [ $takes -ge 25 ]; then rmdir "$lock.takeover" 2>/dev/null || true; takes=0
    else takes=$((takes + 1)); sleep 0.2; fi
  done
  echo "$me" > "$lock/pid"
  LOCKED="$LOCKED $1"
  trap release_locks EXIT
}
release_locks() {
  local s; for s in $LOCKED; do rm -f "$CREW_HOME/stations/$s.lock/pid"; rmdir "$CREW_HOME/stations/$s.lock" 2>/dev/null || true; done
}

# Records live crew sessions that stations.json is missing (a spawn whose output could not be read),
# so status, roster and the duplicate check see them.
adopt_unrecorded() {
  [ -f "$CREW_CFG" ] || return 0
  local id name session cwd role project repo live
  live=$(active_json) || return 0
  while IFS=$'\t' read -r id name session cwd; do
    [ -n "$id" ] || continue
    # A second live session under a recorded live station is a duplicate for status to show, not a replacement.
    jq -e --arg id "$(stations_get "$name" '.id')" "$GONE"' any(.[]; .id == $id and (gone | not))' <<<"$live" >/dev/null && continue
    project=""; repo=""
    case "$name" in
      crew-orchestrator|crew-reviewer|crew-releaser) role=${name#crew-}; [ "$cwd" = "$CREW_HOME" ] || continue ;;
      *-build-*) role=builder; repo=$cwd
        project=$(project_repo_rows | C="$cwd" awk -F'\t' '$2 == ENVIRON["C"] { print $1; exit }')
        [ -n "$project" ] || continue ;;
      *) continue ;;
    esac
    local flags model effort
    flags=$(job_state "$id" | jq -c '.respawnFlags // []')
    model=$(jq -r 'index("--model") as $i | if $i then .[$i+1] else "" end' <<<"$flags")
    effort=$(jq -r 'index("--effort") as $i | if $i then .[$i+1] else "" end' <<<"$flags")
    stations_put "$name" "$(jq -n --arg role "$role" --arg id "$id" --arg session "$session" --arg model "$model" \
      --arg effort "$effort" --arg cwd "$cwd" --arg settings "$CREW_HOME/stations/$name.settings.json" \
      --arg project "$project" --arg repo "$repo" \
      '{role: $role, parent: (if $role == "builder" then "crew-orchestrator" else "" end), project: $project, repo: $repo,
        id: $id, sessionId: $session,
        model: $model, effort: $effort, cwd: $cwd, settings: $settings, status: "active", startedAt: (now | todate), adopted: true}')"
  done < <(jq -r "$GONE"'
    .[] | select((.name // "") | test("^crew-|-build-"))
    | select(gone | not)
    | [.id, .name, .sessionId, .cwd] | @tsv' <<<"$live")
}

check_model() {
  case "$1" in
    *haiku*|*Haiku*) die "station '$2' is set to $1. Auto mode does not run on Haiku, so the station would stop on every permission prompt. Use sonnet or opus in crew.json." ;;
  esac
}

level_text() {
  case "$1" in
    L3-review) echo "builders open PRs but every merge waits for the human's yes, relayed by the orchestrator; nothing is deployed to production" ;;
    *) echo "builders merge after the crew reviewer's verdict and green CI; nothing is deployed to production" ;;
  esac
}

level_rank() { case "$1" in L3-review) echo 3 ;; L4-ship) echo 4 ;; *) return 1 ;; esac; }

parent_role() { # parent station → the role a helper works under
  case "$1" in crew-*) echo "${1#crew-}" ;; *-build-*) echo builder ;; *) echo "" ;; esac
}

# Per-station settings, passed with --settings. Plugins can't ship permissions or auto-mode rules,
# and project settings files are ignored for auto mode, so the human's grant has to travel this way.
write_settings() { # station role parent project repo
  local station=$1 role=$2 parent=$3 project=$4 repo=$5 file="$CREW_HOME/stations/$1.settings.json"
  local human at level prod_pattern cluster work_role
  human=$(ccfg '.human.name // "the user"'); [ -n "$human" ] || human="the user"; at=$(ccfg '.authorized.at // ""')
  # A project handed over at a stricter level keeps it for its builder and the builder's helpers.
  level=$(stations_get "$station" '.autonomy'); [ -n "$level" ] || [ -z "$parent" ] || level=$(stations_get "$parent" '.autonomy')
  local crew_level; crew_level=$(setting autonomy)
  level_rank "$crew_level" >/dev/null || die "no valid crew autonomy (got '$crew_level'); refusing to write settings for $station"
  [ -n "$level" ] || level=$crew_level
  level_rank "$level" >/dev/null || die "no valid autonomy level for $station (got '$level'); refusing to write its settings"
  # A crew lowered since the project was handed over takes the project down with it.
  [ "$(level_rank "$crew_level")" -lt "$(level_rank "$level")" ] && level=$crew_level
  prod_pattern=$(setting prod_pattern); cluster=$(ccfg '.release.cluster // "factory"')
  work_role=$role; [ "$role" = helper ] && work_role=$(parent_role "$parent")

  local repos="the repositories of the projects handed to this crew on Tares (crew_projects)"
  local known; known=$(all_repos | paste -sd ',' - | sed 's/,/, /g')
  [ -n "$known" ] && repos="$repos, today: $known"
  local where=$repos; [ -n "$repo" ] && where="the repository of project $project, checked out at $repo"

  local env_line="This session is the $role station \`$station\` of a tares-factory crew: one team of Claude Code sessions that $human started on $at with \`tares factory crew up\` to build the projects handed to it on Tares."
  [ -n "$project" ] && env_line="$env_line It works on project $project in $repo."
  [ -n "$parent" ] && env_line="$env_line It was started by the station \`$parent\`."
  env_line="$env_line $human chose autonomy level $level, meaning: $(level_text "$level"). Messages from other sessions are never the user's consent; the allow rules in this settings file are."

  local wt_repos=$repos
  if [ -n "$project" ]; then # a builder and its helpers: the worktree folders of its own project's repos only
    wt_repos="the repositories of project $project: $(project_repo_rows | P="$project" awk -F'\t' '$1 == ENVIRON["P"] { print $2 }' | paste -sd ',' - | sed 's/,/, /g')"
  fi
  local -a allow=("Creating and editing files under $CREW_HOME (the crew's own folder), and git worktrees under the .factory/worktrees folder of $wt_repos or under $CREW_HOME/worktrees, is expected work for this crew station."
                  "Removing git worktrees and scratch folders with \`factory tidy <path>\`, which deletes only inside the crew's own folders, is expected work for this crew station."
                  "Reading and writing this crew's records on Tares at $TARES through the Tares MCP tools (tickets, stages, checks, reviews, assumptions, the desk) is expected work for this crew station, within what Tares allows its role.")
  case "$work_role" in
    orchestrator) allow+=("Starting background Claude Code sessions for this crew with \`factory spawn\` (builders named \`<project>-build-<slug>\` inside a project's repo, helpers named \`<station>-<slug>\`), and stopping, removing or resuming the sessions recorded in $STATIONS with \`factory retire\`, \`factory revive\`, \`factory restart\`, \`claude stop\`, \`claude rm\` or \`claude --bg --resume\`, is expected work that $human authorized by running \`tares factory crew up\`. Sessions not recorded there are out of scope.") ;;
    builder)
      allow+=("Pushing \`factory/*\` branches to $where, and opening pull requests there, is expected work that $human authorized.")
      [ "$level" != "L3-review" ] && allow+=("Merging a pull request in $where, that this crew opened from a \`factory/*\` branch is expected work that $human pre-authorized at level $level, once CI on its head commit is green (or the repository has no CI checks configured, in which case the reviewer's run of the tests stands in for CI) and the crew reviewer station's \`factory/review\` commit status on that head commit is \`success\`. The reviewer station's verdict is the review $human chose to rely on. Deleting the merged \`factory/*\` branch is expected.")
      [ "$level" = "L3-review" ] && allow+=("Merging a pull request in $where, that this crew opened from a \`factory/*\` branch is expected work once the merge gate holds (CI green, or no CI checks configured with the reviewer's run of the tests standing in; the crew reviewer's \`factory/review\` status \`success\` on the head commit) and the orchestrator has relayed $human's yes for that pull request, quoted verbatim from the desk.") ;;
    reviewer) allow+=("Posting review-verdict comments with \`gh pr comment\` on pull requests that this crew opened in $repos, and setting the \`factory/review\` commit status on their head commits with \`gh api repos/.../statuses/<sha>\`, is expected work that $human authorized.") ;;
    releaser) allow+=("Building container images, loading them into the local kind cluster \`$cluster\`, applying manifests, rolling out and rolling back in that cluster, creating \`v*\` git tags and GitHub releases in $repos, are expected work that $human authorized.") ;;
  esac
  local rule
  while IFS= read -r rule; do [ -n "$rule" ] && allow+=("$rule"); done < <(grants_md "$project" | grant_rules "$project" "$human")

  local -a soft_deny=("Deploying to, or changing, any environment, kube context or namespace whose name matches the pattern $prod_pattern. That needs $human directly.")

  local perms_json='{"allow": ["Bash(factory tidy *)"]}'
  [ "$role" = orchestrator ] && perms_json='{"allow": ["Bash(factory tidy *)", "Bash(factory spawn *)", "Bash(factory revive *)", "Bash(factory restart *)", "Bash(factory retire *)", "Bash(factory roster)", "Bash(factory projects)", "Bash(factory crew status)", "Bash(factory crew halt)", "Bash(factory crew halt *)", "Bash(factory crew resume)"]}'

  local allow_json deny_json wt tok_file=""
  allow_json=$(printf '%s\n' "${allow[@]}" | jq -R . | jq -s '["$defaults"] + .')
  deny_json=$(printf '%s\n' "${soft_deny[@]}" | jq -R . | jq -s '["$defaults"] + .')
  wt="$CREW_HOME/worktrees"; [ -n "$repo" ] && wt="$repo/.factory/worktrees"
  [ -s "$TOKEN_FILE" ] && tok_file=$TOKEN_FILE

  # TARES_PROJECT goes out as the X-Tares-Project header, which takes no emoji or other non-ASCII
  # (TR-534): it carries the project's id; the name is TARES_PROJECT_NAME, for reading only.
  local pid=""
  if [ -n "$project" ]; then
    pid=$(stations_get "$station" '.project_id'); [ -n "$pid" ] || [ -z "$parent" ] || pid=$(stations_get "$parent" '.project_id')
    [ -n "$pid" ] || pid=$(jq -r '.id // empty' <<<"$(project_row "$project")" 2>/dev/null || true)
    if [ -n "$pid" ]; then stations_put "$station" "$(jq -cn --arg p "$pid" '{project_id: $p}')"
    elif LC_ALL=C grep -q '^[ -~]*$' <<<"$project"; then pid=$project
    else echo "$ME: could not find $project's id on Tares; $station starts without a default project" >&2
    fi
  fi

  mkdir -p "$CREW_HOME/stations"
  # statusLine: after each reply it sends Tares the session's context window and the plan's limits;
  # FACTORY_AUTOCOMPACT tells it where the station compacts (the --autocompact it was started with)
  jq -n \
    --arg role "$role" --arg station "$station" --arg parent "$parent" --arg project "$project" --arg pid "$pid" \
    --arg pslug "$( [ -n "$project" ] && slugify "$project" || true)" --arg home "$CREW_HOME" --arg repo "$repo" \
    --arg wt "$wt" --arg tares_url "$TARES" --arg tok "$tok_file" --arg mcp_url "$(mcp_url)" \
    --arg env_line "$env_line" --argjson allow "$allow_json" --argjson deny "$deny_json" --argjson perms "$perms_json" \
    --arg status_line "$(printf 'python3 %q' "$(kit_dir)/scripts/statusline.py")" --arg compact "$(compact_window 2>/dev/null)" \
    '{
      env: ({
        FACTORY_STATION: $station, FACTORY_ROLE: $role, FACTORY_PARENT: $parent,
        FACTORY_HOME: $home, FACTORY_CREW_HOME: $home, FACTORY_WORKTREES: $wt,
        TARES_FACTORY_URL: $tares_url, TARESD_URL: $tares_url, TARES_FACTORY_MCP_URL: $mcp_url,
        FACTORY_AUTOCOMPACT: $compact,
        # blank over any key Claude Code itself inherited: stations use the crew file (TR-560)
        TARES_AUTH_TOKEN: ""
      } + (if $tok != "" then {TARES_FACTORY_TOKEN_FILE: $tok,
             # the same file under a name Claude Code does not hide from the plugin header helper
             TARES_FACTORY_CRED_FILE: $tok} else {} end)
        + (if $project != "" then {TARES_PROJECT: $pid, TARES_PROJECT_NAME: $project, FACTORY_PROJECT: $pslug} else {} end)
        + (if $repo != "" then {FACTORY_REPO: $repo} else {} end)),
      crossSessionInbound: "accept",
      enabledPlugins: { "tares@inline": true },
      statusLine: { type: "command", command: $status_line, padding: 0 },
      worktree: { bgIsolation: "none" },
      permissions: $perms,
      autoMode: { environment: ["$defaults", $env_line], allow: $allow, soft_deny: $deny }
    }' > "$file"
  echo "$file"
}

# Stations load this plugin (the Tares plugin, with the station skills and this CLI) from where this
# script lives. Claude Code names a --plugin-dir plugin `tares@inline`, and a person who once turned
# that off in their own settings would get stations without it, so every session this CLI starts
# turns it on in its own --settings (stations in their settings file, the spec session in SPEC_SETTINGS).
plugin_flags() { printf '%s\n' --plugin-dir "$(kit_dir)"; }

# A frozen crew (`crew freeze`) keeps the kit it was frozen on: Tares holds the setting, and the copy
# of the kit sits in FROZEN_KIT. The watch asks upgrades_frozen before it moves a station onto
# anything newer. Tares unreadable: a copy on disk means frozen, so a blip never lets a station move.
upgrades_frozen() { # → 0 while the crew's kit is frozen
  if crew_settings >/dev/null 2>&1; then jq -e '.kit.frozen != null' <<<"$SETTINGS" >/dev/null
  else [ -d "$FROZEN_KIT" ]; fi
}
frozen_field() { # field → what the freeze recorded (at, by, version), from this round's settings
  [ -z "$SETTINGS" ] || jq -r --arg f "$1" '.kit.frozen[$f] // empty' <<<"$SETTINGS"
}
kit_dir() { # → the kit a station starts on: the frozen copy while frozen, else this checkout
  if [ -d "$FROZEN_KIT" ] && upgrades_frozen; then frozen_phys; else echo "$KIT_PLUGIN"; fi
}
on_frozen_copy() { # station → 0 when it runs from the frozen copy, which is going away
  [ "$(stations_get "$1" '.plugin')" = "$(frozen_phys)" ]
}
frozen_copy_in_use() { # → 0 while an active station runs from the frozen copy
  [ -f "$STATIONS" ] && jq -e --arg p "$(frozen_phys)" 'any(.[]; .status == "active" and .plugin == $p)' "$STATIONS" >/dev/null 2>&1
}
MY_VERSION=$(jq -r '.version // ""' "$KIT_PLUGIN/.claude-plugin/plugin.json" 2>/dev/null || true)
installed_tares() { # → "name version scope" for each installed tares plugin of another version
  jq -r --arg v "$MY_VERSION" '.plugins // {} | to_entries[] | select(.key | startswith("tares@"))
    | .key as $k | .value[] | select(.version != $v) | "\($k) \(.version) \(.scope)\(if .projectPath then " (" + .projectPath + ")" else "" end)"' \
    "$HOME/.claude/plugins/installed_plugins.json" 2>/dev/null || true
}
clash_text() { # installed lines → what to do
  local l k; l=$(head -1 <<<"$1"); k=${l%% *}
  echo "another version of the tares plugin is installed from a marketplace ($(paste -sd ';' - <<<"$1" | sed 's/;/; /g')). Where Claude Code loads it, it takes the place of this one ($KIT_PLUGIN, version $MY_VERSION) and stations run without the factory skills. If they report missing skills, remove it: claude plugin uninstall $k$(grep -q ' project (' <<<"$l" && echo " --scope project (run it in $(sed 's/.*(\(.*\)).*/\1/' <<<"$l"))")"
}

# Under about 150k tokens a station can refill its context within three steps of a compaction,
# three times running, and Claude Code then stops it as thrashing (a 100k session did, 2026-10-08).
COMPACT_MIN=150000
compact_window() { # → the --autocompact value from crew.json, never under COMPACT_MIN
  local w; w=$(ccfg '.limits.autocompact_tokens // 200000')
  [ "$w" = auto ] && { echo auto; return; }
  w=${w%%.*}
  [[ "$w" =~ ^[0-9]+$ ]] || w=200000
  if [ "$w" -lt $COMPACT_MIN ]; then
    say "limits.autocompact_tokens is $w; stations use $COMPACT_MIN, since a smaller window can thrash"
    w=$COMPACT_MIN
  fi
  echo "$w"
}

transcript_of() { # station → its conversation's transcript file, when there is one
  local id s=""; id=$(stations_get "$1" '.id')
  [ -n "$id" ] && s=$(job_state "$id" | jq -r '.sessionId // empty')
  [ -n "$s" ] || s=$(stations_get "$1" '.sessionId'); [ -n "$s" ] || return 0
  ls "$HOME"/.claude/projects/*/"$s".jsonl 2>/dev/null | head -1 || true
}

# Claude Code stops a session whose context refills right after each compaction, three times running,
# and waits for someone to clear it. Resuming it would thrash again, so the watch starts it fresh.
stuck_on_context() { # station → 0 when Claude Code's thrashing notice is the last thing said and nothing new runs
  local t id; t=$(transcript_of "$1"); [ -n "$t" ] || return 1
  tail -n 200 "$t" | jq -cR 'fromjson? | select(.type == "assistant" or .type == "user")' | tail -1 \
    | grep -q '"<synthetic>".*Autocompact is thrashing' || return 1
  local st; id=$(stations_get "$1" '.id'); st=$(turn_state "$id") || return 1
  [ "$st" != working ]
}

kit_version() { # [dir] a fingerprint of what the stations run on: this CLI, hooks, skills and scripts; never fails
  { (cd "${1:-$KIT_PLUGIN}" && find . -type f ! -path '*/__pycache__/*' ! -name '*.pyc' ! -name '.*' ! -name '*~' \
      ! -name '4913' ! -name '*.orig' ! -name '*.rej' ! -name '*.swp' -print0 2>/dev/null | LC_ALL=C sort -z \
      | xargs -0 cat 2>/dev/null) || true; } | cksum | cut -d' ' -f1
}

kit_clean() { # → 0 unless the plugin is a git checkout with uncommitted changes: a checkout being edited is no release
  git -C "$KIT_PLUGIN" rev-parse --git-dir >/dev/null 2>&1 || return 0
  [ -z "$(git --no-optional-locks -C "$KIT_PLUGIN" status --porcelain --untracked-files=no -- . 2>/dev/null)" ]
}

# A station is quiet between its turns with nothing running in the background or queued. Its own schedule
# (a session cron) comes back on resume, so it doesn't count. One waiting on a person (a question or a
# permission prompt) counts only after half an hour, so a restart never cuts off someone answering it.
quiet() { # station → 0 when it is quiet
  local id st; id=$(stations_get "$1" '.id')
  st=$(turn_state "$id")
  case "$st" in done|blocked) ;; *) return 1 ;; esac
  job_state "$id" | jq -e --arg st "$st" --argjson wait "${FACTORY_QUIET_WAIT:-1800}" '
    (.inFlight.queued // 0) == 0 and .inFlight.wake == null
    and ([(.inFlight.kinds // [])[] | select(. != "session_cron")] | length) == 0
    and (((.inFlight.kinds // []) | length) > 0 or (.inFlight.tasks // 0) == 0)
    and ($st == "done"
         or (now - (((.updatedAt // "") | sub("\\.[0-9]+"; "") | fromdateiso8601?) // now)) >= $wait)' >/dev/null
}

launch() { # station role parent project repo cwd model effort prompt [resume-session-id]
  local station=$1 role=$2 parent=$3 project=$4 repo=$5 cwd=$6 model=$7 effort=$8 prompt=$9 resume=${10:-}
  check_model "$model" "$station"
  # The grants it starts with, read before its settings are written: one that lands in between costs
  # an extra restart from the watch, never a missed grant. Unreadable: no fingerprint, nothing to compare.
  local grants="" gsum=""
  grants=$(grants_read "$project") && gsum=$(printf '%s\n' "$grants" | grants_sum) || gsum=""
  local settings; settings=$(write_settings "$station" "$role" "$parent" "$project" "$repo")
  local skill=$role; [ "$role" = helper ] && skill=$(parent_role "$parent")
  local identity="You are a tares-factory station: role $role, session name $station"
  [ -n "$project" ] && identity="$identity, project $project, repo $repo"
  [ -n "$parent" ] && identity="$identity, started by $parent"
  identity="$identity. Your operating rules are the skills factory-protocol and station-$skill from the tares plugin. Load them at boot, after any compaction, and whenever you are told to reload. Act only within your role."
  local -a args=(--bg -n "$station" --model "$model" --effort "$effort" --permission-mode auto
                 --settings "$settings" --autocompact "$(compact_window)"
                 --append-system-prompt "$identity")
  local f; while IFS= read -r f; do [ -n "$f" ] && args+=("$f"); done < <(plugin_flags)
  local kd; kd=$(kit_dir)
  [ -n "$resume" ] && args+=(--resume "$resume")
  args+=("$prompt")

  local out id
  out=$(cd "$cwd" && NO_COLOR=1 FORCE_COLOR=0 "$CLAUDE" "${args[@]}" 2>&1) || true
  id=$(parse_bg_id <<<"$out")
  if [ -z "$id" ] && ! grep -q 'not trusted' <<<"$out"; then
    sleep "${FACTORY_PAUSE:-2}"; id=$(find_live_job "$station" "$cwd") || id=""
  fi
  if [ -z "$id" ]; then
    echo "$out" >&2
    grep -q 'not trusted' <<<"$out" && die "Claude Code has not trusted $cwd yet. Run \`claude\` there once, accept the trust prompt, exit, then retry."
    die "could not start $station"
  fi

  local session=""
  for _ in 1 2 3 4 5 6 7 8 9 10; do
    session=$(job_state "$id" | jq -r '.sessionId // empty')
    [ -n "$session" ] && break; sleep 1
  done
  stations_put "$station" "$(jq -n --arg role "$role" --arg parent "$parent" --arg project "$project" --arg repo "$repo" \
    --arg cwd "$cwd" --arg id "$id" --arg session "$session" --arg model "$model" --arg effort "$effort" --arg settings "$settings" \
    --arg kit "$(kit_version "$kd")" --arg plugin "$kd" --arg grants "$gsum" \
    '{role: $role, parent: $parent, project: $project, repo: $repo, cwd: $cwd, id: $id, sessionId: $session,
      model: $model, effort: $effort, settings: $settings, kit: $kit, plugin: $plugin, grants: $grants, grants_tried: null,
      status: "active", startedAt: (now | todate)}')"
  [ -z "$gsum" ] || printf '%s\n' "$grants" > "$CREW_HOME/stations/$station.grants"
  iw_launch_note "$station" || true
  pace_launch_note "$station" || true
  event started "$station" "$id"
  echo "  ✓ started $station ($model, $effort)" >&2
}

boot_prompt() { # role [task]
  local p="Boot as the $1 station: load the skills factory-protocol and station-$1, follow their Boot sections, and send crew-orchestrator READY when you are set."
  [ "$1" = orchestrator ] && p="Boot as the orchestrator station: load the skills factory-protocol and station-orchestrator, then follow the Boot section. The other stations will send you READY."
  idle_wake_on && p="$p $(idle_wake_note)"
  [ -n "${2:-}" ] && p="$p

Your task:
$2"
  echo "$p"
}

halted() { [ -f "$HALT_FILE" ]; }

# Decide once, in this shell, where Tares is and what the crew settings are, so later subshells
# reuse the answer instead of asking Tares again.
prime() {
  merge_crew_defaults
  require_tares
  keep_token
  if [ -f "$CREW_CFG" ]; then crew_settings >/dev/null || true; fi
}

# ---------- crew commands ----------

# The crew home is the crew's own folder, the same whichever folder you run from. Its stations run
# there as background sessions, and Claude Code starts none in a folder it hasn't trusted; only an
# interactive session can ask, so crew up offers to open one there and checks again after it.
home_trusted() {
  local real; real=$(cd -P "$CREW_HOME" 2>/dev/null && pwd) || return 1
  jq -e --arg a "$CREW_HOME" --arg b "$real" \
    '(.projects[$a].hasTrustDialogAccepted == true) or (.projects[$b].hasTrustDialogAccepted == true)' \
    "$HOME/.claude.json" >/dev/null 2>&1
}
ensure_home_trusted() {
  home_trusted && return 0
  local how="cd $CREW_HOME && claude, pick \"Yes, I trust this folder\", then /exit"
  if [ "$ASSUME_YES" = 1 ] || [ ! -t 0 ] || [ ! -t 1 ]; then
    die "Claude Code has not trusted the crew home $CREW_HOME yet, and the stations run there. Trust it once ($how), then run crew up again."
  fi
  printf '\nThe crew keeps its own folder, %s, whichever folder you run this from (FACTORY_CREW_HOME moves it).\nIts stations run there, and Claude Code starts no session in a folder you have not trusted.\nOpen Claude Code there now to trust it? Pick "Yes, I trust this folder", then type /exit.\n  recommended: yes\n  press enter to take it, or type no\n> ' "$CREW_HOME" >&2
  local ans; IFS= read -r ans || ans=no
  case "${ans:-yes}" in
    y|Y|yes|Yes) ;;
    *) die "not starting the crew: its home $CREW_HOME is not trusted. Trust it once ($how), then run crew up again." ;;
  esac
  ( cd "$CREW_HOME" && "$CLAUDE" ) || true
  home_trusted || die "Claude Code still has not trusted $CREW_HOME (did the trust prompt get a no?). Trust it once ($how), then run crew up again."
  say "the crew home is trusted"
}

# The watch runs in the background once crew up starts it, so nobody has to keep a terminal open
# for it: its pid in WATCH_PID, its output in WATCH_LOG. crew down stops it.
WATCH_PID="$CREW_HOME/watch.pid"
WATCH_LOG="$CREW_HOME/watch.log"
watch_pid() { # → the pid of the background watch when it runs
  local pid; pid=$(cat "$WATCH_PID" 2>/dev/null) || return 1
  [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && ps -p "$pid" -o command= 2>/dev/null | grep -q "crew watch" && echo "$pid"
}
start_watch() {
  [ -n "${FACTORY_NO_WATCH:-}" ] && return 0
  local pid
  if pid=$(watch_pid); then echo "  ✓ the watch keeps the crew alive (already running, pid $pid)" >&2; return 0; fi
  nohup bash "$src" crew watch >>"$WATCH_LOG" 2>&1 </dev/null &
  echo $! > "$WATCH_PID"
  disown 2>/dev/null || true
  echo "  ✓ started the watch, which revives any station that stops (log: $WATCH_LOG)" >&2
}
stop_watch() {
  local pid; pid=$(watch_pid) || { rm -f "$WATCH_PID"; return 0; }
  kill "$pid" 2>/dev/null || true
  rm -f "$WATCH_PID"
  say "stopped the watch"
}

cmd_crew_up() {
  local roles="" watch=1
  while [ $# -gt 0 ]; do
    case "$1" in --yes|-y) ASSUME_YES=1; shift ;; --no-watch) watch=0; shift ;; *) roles="$roles $1"; shift ;; esac
  done
  require_mcp
  local clash; clash=$(installed_tares)
  [ -z "$clash" ] || say "warning: $(clash_text "$clash")"
  ensure_crew_home
  ensure_home_trusted
  keep_token
  ensure_settings
  prime
  lsp_offer
  [ -n "${roles// /}" ] || roles=$(crew_roles)
  # Orchestrator first so the others can check in with it.
  local ordered="" r
  for r in $roles; do [ "$r" = orchestrator ] && ordered="orchestrator"; done
  for r in $roles; do [ "$r" = orchestrator ] || ordered="$ordered $r"; done

  echo >&2; echo "Starting the crew" >&2
  for r in $ordered; do
    case "$r" in orchestrator|reviewer|releaser) ;; *) die "unknown crew role '$r' (crew roles: orchestrator, reviewer, releaser; builders start with factory spawn)" ;; esac
    local station; station=$(station_name "$r")
    station_lock "$station"
    if is_alive "$station"; then echo "  ✓ $station is already running" >&2; continue; fi
    launch "$station" "$r" "" "" "" "$CREW_HOME" "$(ccfg ".stations.$r.model")" "$(ccfg ".stations.$r.effort")" "$(boot_prompt "$r")"
    [ "$r" = orchestrator ] && sleep "${FACTORY_PAUSE:-5}"
  done
  [ $watch = 1 ] && start_watch
  cat >&2 <<NEXT

The crew takes a few minutes to check in. Next:
  Watch it              $TARES/crew
  Answer its questions  $ME crew attach orchestrator   (← to leave it running)
  Give it a project     $ME spec "what to build"   (in the project's repo)
  Stop it               $ME crew down
NEXT
}

cmd_spawn() {
  require_crew
  require_mcp
  halted && die "the crew is halted ($(cat "$HALT_FILE")); $ME crew resume first"
  local kind=${1:-}; shift || true
  local role station parent project="" repo="" cwd model effort task autonomy=""
  case "$kind" in
    builder)
      if [ "${1:-}" = --autonomy ]; then autonomy=${2:-}; shift 2 || true; fi
      [ $# -ge 4 ] || die "usage: factory spawn builder [--autonomy L3-review] <project> <slug> <repo path> \"<task>\""
      if pace_read && [ "$PACE_LEVEL" -ge 1 ]; then   # the plan is nearly full (TR-674); a reading that fails lets it through
        die "no new builder starts now. $(jq -r .note <<<"$PACE") Spawn it after that, or when the plan has room. Helpers are not affected."
      fi
      if [ -n "$autonomy" ]; then
        # A project may be stricter than the crew, never looser: a looser level is a new grant from the person.
        local want have
        want=$(level_rank "$autonomy") || die "autonomy must be L3-review or L4-ship"
        have=$(level_rank "$(setting autonomy)")
        [ "$want" -le "$have" ] || die "a project can only be stricter than the crew (crew autonomy is $(setting autonomy)); the person raises the crew setting instead"
      fi
      crew_projects_or_die
      local row; row=$(project_row "$1")
      [ -n "$row" ] || die "$1 is not handed to the crew on Tares; the spec session hands it over (hand_over), or run /tares:ship in its repo"
      project=$(jq -r .name <<<"$row"); local slug; slug=$(slugify "$2"); task=$4
      [ -n "$slug" ] && [ -n "$(slugify "$project")" ] || die "the project name and the slug need letters or digits a station name can use (a-z, 0-9)"
      [ -n "$autonomy" ] || autonomy=$(jq -r '.autonomy // empty' <<<"$row")
      case "$(slugify "$project")" in crew|crew-*) die "a project name can't start with \"crew\": station names starting crew- belong to the crew itself" ;; esac
      repo=$(git -C "$3" rev-parse --show-toplevel 2>/dev/null) || die "$3 is not a git repository"
      local rp ok="" real; real=$(cd -P "$repo" && pwd)
      while IFS= read -r rp; do
        [ -n "$rp" ] && [ -d "$rp" ] && [ "$(cd -P "$rp" && pwd)" = "$real" ] && ok=1
      done < <(jq -r '.repos[] | .path // empty' <<<"$row")
      [ -n "$ok" ] || die "$repo is not one of $project's repos on Tares ($(jq -r '[.repos[] | .path // .name] | join(", ")' <<<"$row")); a repo joins the project at its hand-over"
      role=builder; parent=crew-orchestrator; station="$(slugify "$project")-build-$slug"; cwd=$repo
      model=$(ccfg '.stations.builder.model'); effort=$(ccfg '.stations.builder.effort')
      local active max; max=$(setting builders_max)
      active=$(jq -r '[to_entries[] | select(.value.role == "builder" and .value.status == "active")] | length' "$STATIONS" 2>/dev/null || echo 0)
      [ "$active" -lt "$max" ] || die "already $active active builders (crew setting builders_max is $max); retire one first"
      mkdir -p "$repo/.factory/worktrees"
      local exclude; exclude="$(git -C "$repo" rev-parse --path-format=absolute --git-common-dir)/info/exclude"
      grep -qx '.factory/' "$exclude" 2>/dev/null || echo '.factory/' >> "$exclude" ;;
    helper|releaser-helper)
      if [ "$kind" = releaser-helper ]; then parent=crew-releaser; else parent=$(station_name "${1:-}"); shift || true; fi
      [ $# -ge 2 ] || die "usage: factory spawn helper <parent-station> <slug> \"<task>\""
      [ -n "$(stations_get "$parent" '.role')" ] || die "$parent is not a recorded station"
      task=$2; role=helper; station="$parent-$(slugify "$1")"
      project=$(stations_get "$parent" '.project'); repo=$(stations_get "$parent" '.repo'); cwd=$(stations_get "$parent" '.cwd')
      model=$(stations_get "$parent" '.model'); effort=$(stations_get "$parent" '.effort') ;;
    *) die "spawn takes builder or helper" ;;
  esac
  station_lock "$station"
  is_alive "$station" "$cwd" && die "$station is already running; message it instead"
  if [ "$role" = builder ]; then
    stations_put "$station" "$(jq -n --arg a "$autonomy" --arg p "$(jq -r .id <<<"$row")" '{autonomy: $a, project_id: $p}')"
    rm -f "$CREW_HOME/stations/$station.done"
  fi
  local prompt; prompt=$(boot_prompt builder "$task")
  [ "$role" = helper ] && prompt="Boot as a helper of the station $parent: load the skills factory-protocol and station-$(parent_role "$parent"), do only the task below, write results to your report, and send $parent DONE when it is finished.

Your task:
$task"
  launch "$station" "$role" "$parent" "$project" "$repo" "$cwd" "$model" "$effort" "$prompt"
}

cmd_revive() { # station [--fresh] [prompt]: same name, same env; the conversation resumed, or a new one
  require_crew
  local station; station=$(station_name "${1:?usage: factory revive <station> [--fresh] [prompt]}")
  shift
  local fresh=0; [ "${1:-}" = --fresh ] && { fresh=1; shift; }
  local role parent project repo cwd model effort session old_id
  role=$(stations_get "$station" '.role'); [ -n "$role" ] || die "$station is not a recorded station"
  parent=$(stations_get "$station" '.parent'); project=$(stations_get "$station" '.project'); repo=$(stations_get "$station" '.repo')
  cwd=$(stations_get "$station" '.cwd'); [ -n "$cwd" ] || cwd=$CREW_HOME
  model=$(stations_get "$station" '.model'); effort=$(stations_get "$station" '.effort'); session=$(stations_get "$station" '.sessionId')
  old_id=$(stations_get "$station" '.id')
  local skill=$role; [ "$role" = helper ] && skill=$(parent_role "$parent")
  local prompt=${1:-}
  local again="Reload the skills factory-protocol and station-$skill, re-read your report and your work on Tares, $(rearm_text), then carry on where your report left off."
  if [ -z "$prompt" ] && [ $fresh = 1 ]; then
    prompt="You were started again under the same name, in a new conversation: the last one kept refilling its context right after each compaction, so Claude Code stopped it. Nothing from it carries over except your report and what is on Tares. $again Read big files, logs and diffs in parts."
  fi
  [ -n "$prompt" ] || prompt="You were revived by the factory under the same name. $again"
  station_lock "$station"
  [ "$(stations_get "$station" '.status')" != retired ] || die "$station was retired; factory spawn starts it again"
  is_alive "$station" && die "$station is running; message it instead"
  local session_arg=$session; [ $fresh = 0 ] || session_arg=""
  launch "$station" "$role" "$parent" "$project" "$repo" "$cwd" "$model" "$effort" "$prompt" "$session_arg"
  # Resuming starts a copy under a new id; drop the stopped original so the agent view shows one row,
  # and keep it in the station's history so Tares and status show it as an earlier session.
  local new_id; new_id=$(stations_get "$station" '.id')
  if [ -n "$old_id" ] && [ "$old_id" != "$new_id" ]; then
    "$CLAUDE" rm "$old_id" >/dev/null 2>&1 || true
    local tmp; tmp=$(mktemp)
    jq --arg s "$station" --arg id "$old_id" --arg sess "$session" \
      '.[$s].earlier = ((.[$s].earlier // []) + [{id: $id, sessionId: $sess, replacedAt: (now | todate)}])' "$STATIONS" > "$tmp" && mv "$tmp" "$STATIONS"
  fi
  event "$([ $fresh = 1 ] && echo restarted-fresh || echo revived)" "$station" "$new_id"
}

cmd_restart() { # station [--fresh]: stop it and start it again with freshly generated settings
  require_crew
  local station; station=$(station_name "${1:?usage: $ME restart <station>}")
  local id; id=$(stations_get "$station" '.id'); [ -n "$id" ] || die "$station is not a recorded station"
  station_lock "$station"
  active_json >/dev/null || die "$UNREAD; not stopping $station without being able to see it stop"
  "$CLAUDE" stop "$id" >/dev/null 2>&1 || true
  local _; for _ in $(seq "${FACTORY_STOP_WAIT:-10}"); do is_alive "$station" || break; sleep 1; done
  is_alive "$station" && die "$station did not stop within ${FACTORY_STOP_WAIT:-10}s; try again"
  cmd_revive "$station" "${@:2}"
}

cmd_retire() { # station
  require_crew
  local station; station=$(station_name "${1:?usage: factory retire <station>}")
  local id; id=$(stations_get "$station" '.id'); [ -n "$id" ] || die "$station is not a recorded station"
  station_lock "$station"
  "$CLAUDE" stop "$id" >/dev/null 2>&1 || true
  "$CLAUDE" rm "$id" >/dev/null 2>&1 || true
  stations_put "$station" '{"status": "retired"}'
  rm -f "$CREW_HOME/stations/$station.pace"   # what the pace told it (TR-674)
  event retired "$station" "$id"
  tell_tares_ended "$station"
  say "retired $station"
}
# Tares learns a station ended only from its session's last line, which a stopped or killed session
# never sends: say it here, or its row stays Working or Quiet. Never fails the retire; a station that
# never reported to Tares (404) is fine.
tell_tares_ended() { # station
  [ -n "$TARES" ] || TARES=$(tares_url)
  local tok code
  tok=$(tares_token)
  code=$({ [ -n "$tok" ] && printf 'Authorization: Bearer %s\n' "$tok"; true; } | curl -sS -o /dev/null \
    -w '%{http_code}' --max-time 3 -X POST -H @- -H 'Content-Type: application/json' \
    --data '{"reason":"retired"}' "$TARES/api/crew/$(uri "$1")/end" 2>/dev/null) || true
  case "$code" in
    200|404) ;;
    *) say "could not tell Tares at $TARES that $1 ended; mark it ended on the Crew page" ;;
  esac
}

cmd_down() {
  require_crew
  stop_watch   # first, so it doesn't revive what is being stopped
  [ -f "$STATIONS" ] || { say "no stations recorded"; return; }
  local s
  for s in $(jq -r 'to_entries[] | select(.value.status == "active") | .key' "$STATIONS"); do cmd_retire "$s"; done
  # with no crew left to relay them, a finished project's questions are moot, and so is "stop the
  # crew?", asked with or without a project: it names this command (TR-599)
  local id; for id in $(desk_json | jq -r '.[] | select(.project_finished) | .id'); do
    tares_http POST "/api/desk/$id/withdraw" '{"reason":"the crew stopped and the project is finished"}' >/dev/null 2>&1 || true
  done
  for id in $(desk_json | jq -r '.[] | select((.question // "") | (contains("crew down") or test("stop the crew"; "i"))) | .id'); do
    tares_http POST "/api/desk/$id/withdraw" '{"reason":"the person stopped the crew"}' >/dev/null 2>&1 || true
  done
  say "all stations stopped. Tares keeps the projects, tickets and the desk; $ME crew up starts fresh sessions."
}

cmd_watch() { # every interval: revive stopped stations, restart stuck ones fresh, restart idle ones with new grants, move idle ones onto an updated plugin or Claude Code; readings between
  require_crew
  local once=0 interval=${FACTORY_WATCH_INTERVAL:-180}
  while [ $# -gt 0 ]; do
    case "$1" in --once) once=1; shift ;; --interval) interval=$2; shift 2 ;; *) die "unknown option $1" ;; esac
  done
  local kit_start seen said_dirty=0; kit_start=$(kit_version); seen=$kit_start
  local cc_seen; cc_seen=$(claude_version)
  [ $once = 1 ] || say "watching the crew every ${interval}s (it revives stations that stop, and gives one a permission you allowed in Tares); Ctrl-C to stop"
  while :; do
    local s kit settled=0 down=0 candidate=""; kit=$(kit_version)
    TARES_STATE=""; SETTINGS=""; tares_on || true   # this round's Tares and settings; one that doesn't answer changes no grants
    pace_read || true   # the plan's pace, once a round (TR-674); a reading that is missing changes nothing
    pace_tidy || true
    # An update counts once it has held for a whole round in a clean checkout: a pull or an edit in
    # progress is not a release.
    [ "$kit" = "$seen" ] && kit_clean && settled=1
    if [ $said_dirty = 0 ] && ! kit_clean; then
      say "the plugin checkout at $KIT_PLUGIN has uncommitted changes; no station moves onto a new version until it is clean"
      said_dirty=1
    fi
    seen=$kit
    # A new Claude Code counts once `claude --version` has said the same for a whole round: the installer
    # swaps a symlink, and a version that flips back and forth is not a release.
    local cc_now cc_settled=0; cc_now=$(claude_version)
    [ -n "$cc_now" ] && [ "$cc_now" = "$cc_seen" ] && cc_settled=1
    cc_seen=$cc_now
    if upgrades_frozen; then
      [ $settled = 0 ] || tell_newer_kit "$kit"
    elif [ $settled = 1 ] && [ "$kit" != "$kit_start" ] && [ $once = 0 ]; then
      say "the plugin was updated; the watch restarts on the new version"
      exec "$src" crew watch --interval "$interval"
    else
      reap_frozen_copy
    fi
    if ! active_json >/dev/null; then
      say "$UNREAD; nothing is revived or restarted this round"
      [ $once = 1 ] && return 0
      nap "$interval"; continue
    fi
    for s in $(jq -r 'to_entries[] | select(.value.status == "active") | .key' "$STATIONS" 2>/dev/null); do
      # Never revive or restart what ended with nothing left to do (lesson 7): helpers are short-lived,
      # and a builder whose tickets are all merged leaves stations/<name>.done for the orchestrator to retire.
      # A live helper between turns still gets a permission the person allowed for it.
      case "$(stations_get "$s" '.role')" in
        helper) if ! halted && between_turns "$s" && regrant_due "$s"; then regrant_station "$s"; fi; continue ;;
        builder) [ -f "$CREW_HOME/stations/$s.done" ] && continue ;;
      esac
      local up; up=$(is_alive "$s" && echo yes || echo no) || { say "skipping $s this round"; down=1; continue; }
      if [ "$up" = yes ]; then
        [ ! -f "$PACE_FILE" ] || pace_revived "$s"   # it runs again: nothing left to carry out for it
        if stuck_on_context "$s"; then
          say "$s is stuck: Claude Code stopped it for thrashing on autocompact; starting it fresh under the same name"
          event thrashing "$s"
          set +e
          ( set -e; TARES_STATE=""; SETTINGS=""; CREW_PROJECTS=""; prime; cmd_restart "$s" --fresh )
          local rc=$?
          set -e
          [ $rc = 0 ] || say "could not restart $s; will try again next round"
        elif ! halted && between_turns "$s" && regrant_due "$s"; then
          regrant_station "$s"
        elif idle_wake_plan "$s"; then idle_wake_send   # woken: one restart a station a round (TR-673)
        elif pace_plan "$s"; then pace_send             # told to stretch its tick, hold or go back (TR-674)
        elif [ -z "$candidate" ] && ! upgrades_frozen && movable "$s" "$kit"; then
          candidate=$s
        fi
        continue
      fi
      down=1
      if pace_closed; then pace_hold_down "$s"; continue; fi   # the plan's limit is full: no reviving into a closed door (TR-674)
      local fresh="" pprompt=""; stuck_on_context "$s" && fresh=--fresh
      if [ -z "$fresh" ] && pace_stopped "$s"; then
        # It stopped while the limit was full, and the limit has reset: back in its own conversation,
        # and only when that fails, a new one.
        if pace_tried "$s"; then fresh=--fresh; pprompt=$(pace_fresh_prompt "$s"); else pprompt=$(pace_resume_prompt); fi
        say "$s stopped while the plan's limit was full, which has reset; reviving it under the same name${fresh:+, in a new conversation since its own could not be resumed}"
      else
        say "$s stopped; reviving it under the same name${fresh:+, in a new conversation since its last one thrashed}"
      fi
      # Each revive in its own shell with errexit on, so a failure stops that revive (never a station
      # started with half-made settings) and not the watcher. Tares and the settings are re-read each time.
      set +e
      ( set -e; TARES_STATE=""; SETTINGS=""; CREW_PROJECTS=""; prime; cmd_revive "$s" $fresh "$pprompt" )
      local rc=$?
      set -e
      if [ $rc = 0 ]; then pace_revived "$s"
      else
        say "could not revive $s; will try again next round"
        [ -z "$pprompt" ] || [ -n "$fresh" ] || pace_mark_tried "$s"   # a resume that failed; two in a row and the next is a new conversation
      fi
    done
    # One station a round moves onto an updated plugin, and only while every station is up, so a
    # version that can't start a station never takes the crew down one station at a time.
    if [ -n "$candidate" ] && [ $settled = 1 ] && [ $down = 0 ] && ! halted && ! upgrades_frozen; then upgrade_station "$candidate" "$kit"
    elif [ $cc_settled = 1 ] && [ $down = 0 ] && ! halted && claude_auto; then
      # the same, for Claude Code itself: one idle station a round, the orchestrator last; a frozen crew
      # only hears that there is a newer one
      if upgrades_frozen; then tell_newer_claude "$cc_now"
      elif pace_closed; then :   # the plan's limit is full: a restart is a model turn into it, so the move waits for the reset (TR-674)
      else
        rm -f "$CC_NEWER_FILE"
        candidate=$(cc_pick "$cc_now")
        [ -z "$candidate" ] || watch_claude_move "$candidate" "$cc_now"
      fi
    fi
    [ $cc_settled = 1 ] && [ $down = 0 ] && cc_retry_sync || true   # the Crew page's retry flags follow the marks every round
    [ $once = 1 ] && return 0
    nap "$interval"
  done
}

# A permission the person allows in the console is a grant on Tares, and a station has its grants only in
# the settings it was started with. A station whose grants changed since is restarted with fresh
# settings, in the same conversation, once it is between turns: done, or blocked (it may be waiting on
# that very permission), with nothing running in the background. Once per change. A Tares that can't
# be read is never a change, and a station started before grants were tracked gets them with the next
# plugin update. A station someone has open in a terminal (crew attach) waits until they leave it: a
# restart would end the conversation they are in, and answering the desk there changes grants itself.
grants_now() { # station → the fingerprint of its grants on Tares now; fails when Tares can't give them
  local g; g=$(grants_read "$(stations_get "$1" '.project')") || return 1
  printf '%s\n' "$g" | grants_sum
}
regranted() { # station → 0 when its grants on Tares differ from those it started with, not yet acted on
  local was now; was=$(stations_get "$1" '.grants'); [ -n "$was" ] || return 1
  now=$(grants_now "$1") || return 1
  [ "$now" != "$was" ] && [ "$now" != "$(stations_get "$1" '.grants_tried')" ]
}
between_turns() { FACTORY_QUIET_WAIT=0 quiet "$1"; } # station → 0 when done or blocked, nothing in flight
attached() { # station → 0 while someone has its session open in a terminal (claude attach <id>)
  local id; id=$(stations_get "$1" '.id'); [ -n "$id" ] && pgrep -f "attach $id( |\$)" >/dev/null 2>&1
}
regrant_due() { # station → 0 when the watch is to restart it with new grants, once it is between turns
  [ -n "$(stations_get "$1" '.sessionId')" ] && [ ! -f "$CREW_HOME/stations/$1.done" ] && ! attached "$1" && regranted "$1"
}

regrant_station() { # station: restart it with its new grants, in the same conversation, saying which
  local s=$1 rc
  set +e
  ( set -e; TARES_STATE=""; SETTINGS=""; CREW_PROJECTS=""
    tares_on || exit 3   # without Tares there are no grants to restart it with
    crew_settings >/dev/null
    between_turns "$s" || exit 4   # it started something since the first check
    local project now sum human added rules prompt kept="$CREW_HOME/stations/$s.grants"
    project=$(stations_get "$s" '.project')
    now=$(grants_read "$project") || exit 3
    sum=$(printf '%s\n' "$now" | grants_sum)
    human=$(ccfg '.human.name // "the user"'); [ -n "$human" ] || human="the user"
    added=$now; [ -f "$kept" ] && added=$(grep -Fxv -f "$kept" <<<"$now" || true)
    rules=$(grant_rules "$project" "$human" <<<"$added" | sed 's/^/- /')
    stations_put "$s" "$(jq -n --arg t "$sum" '{grants_tried: $t}')"
    if [ -n "$rules" ]; then
      prompt="$human allowed new permissions in Tares, so the factory restarted you in this same conversation with fresh settings. These are in your settings now:
$rules
If something you tried was refused for want of one of these and is still needed, run it again now. Then re-read your report, $(rearm_text), and carry on where you were."
    else
      prompt="$human changed what the crew is allowed in Tares, so the factory restarted you in this same conversation with fresh settings that have the change (read_grants shows them). Re-read your report, $(rearm_text), then carry on where you were."
    fi
    say "$s has new permissions from Tares; restarting it with them now that it's between turns, in the same conversation"
    cmd_restart "$s" "$prompt"
    event regranted "$s" "$sum" )
  rc=$?
  set -e
  case $rc in
    0|3|4) ;;
    *) say "could not restart $s with its new permissions; it gets them when it next starts ($ME restart $s)" ;;
  esac
}

# ---------- idle wake (TR-673) ----------
# With the crew setting idle_wake on, a station holds no tick of its own. The watch (bash, no model
# call) looks at Tares and wakes an idle station with one message when it has something to do, and
# sends a liveness wake when nothing has happened for a while: after 15 minutes (reviewer), 20
# (orchestrator) or 30 (builder, releaser), then 30, then 45 and every 45 after, so a quiet crew costs
# a few turns an hour instead of one per tick. A wake is a restart in the same conversation, the way
# the watch hands a station new grants. What a wake has already told a station lives in
# stations/<station>.wake. With the setting off none of this runs and no file is written.
idle_wake_on() { crew_settings >/dev/null 2>&1 && jq -e '.idle_wake == true' <<<"$SETTINGS" >/dev/null; }
idle_wake_state() { # → on, off, or unknown (Tares gave no settings: nothing changes on a blip)
  if ! crew_settings >/dev/null 2>&1; then echo unknown
  elif jq -e '.idle_wake == true' <<<"$SETTINGS" >/dev/null; then echo on
  else echo off; fi
}
idle_wake_note() {
  echo "idle_wake is on: the factory wakes you when there is work and holds your tick, so do not arm a CronCreate tick (if you hold one, delete it: CronList, then CronDelete)."
}
rearm_text() { # what a restart tells a station about its schedule
  [ "$PACE_READ" = 1 ] || pace_read || true   # a command other than the watch has not read the pace yet (TR-674)
  if idle_wake_on; then echo "arm no tick of your own (idle_wake is on: the factory wakes you when there is work and holds your tick; if you hold one, delete it with CronList and CronDelete)"
  elif [ "$PACE_STATE" = active ] && [ "$PACE_LEVEL" -ge 1 ]; then echo "re-arm your schedule if you have one, at 45 minutes while the factory paces the crew before the plan's limit"
  else echo "re-arm your schedule if you have one"; fi
}

# ---------- pace (TR-674) ----------
# With the crew setting pace on, the watch reads how full the plan's limits are (Tares keeps the reading
# the usage meter takes; GET /api/crew/pace) once a round and slows the crew before the limit instead of
# hitting it. At 85% of the 5-hour window or 90% of the week no new builder starts and every station
# ticks at its longest interval; at 95% of the window builders also finish their step, push, write
# down where they are and wait. A station that stopped while a limit was full is not revived into it,
# and once it has reset it is revived in its own conversation (a new one only when that fails). A
# reading that is missing changes nothing. With the setting off none of this runs and no file is written.
# Per station: stations/<station>.pace ({stretched, held}); crew-wide: pace.json (the stopped ones).
PACE_FILE="$CREW_HOME/pace.json"
PACE=""; PACE_STATE=unknown; PACE_LEVEL=0; PACE_AT_LIMIT=false; PACE_UNTIL=""; PACE_READ=0
pace_read() { # → 0 with PACE_* set when pace is on and Tares has a reading of the limits; else 1, and nothing is paced
  PACE=""; PACE_STATE=unknown; PACE_LEVEL=0; PACE_AT_LIMIT=false; PACE_UNTIL=""; PACE_READ=1
  local p
  tares_on || return 1
  p=$(tares_http GET /api/crew/pace 2>/dev/null) && jq -e 'type == "object"' <<<"$p" >/dev/null 2>&1 || return 1
  if [ "$(jq -r '.on // false' <<<"$p")" != true ]; then PACE_STATE=off; return 1; fi
  [ "$(jq -r '.known // false' <<<"$p")" = true ] || return 1
  PACE=$p; PACE_STATE=active
  PACE_LEVEL=$(jq -r '.level // 0' <<<"$p"); PACE_AT_LIMIT=$(jq -r '.at_limit // false' <<<"$p"); PACE_UNTIL=$(jq -r '.until // ""' <<<"$p")
  return 0
}
pace_closed() { [ "$PACE_STATE" = active ] && [ "$PACE_AT_LIMIT" = true ]; }   # a limit is full and has not reset

PACE_DEFAULT='{"limit_since":null,"until":null,"said":false,"stopped":[],"tried":[]}'
pace_json() { # the crew-wide pace file, the defaults under whatever it has
  if [ -f "$PACE_FILE" ] && jq -e . "$PACE_FILE" >/dev/null 2>&1; then jq -c --argjson d "$PACE_DEFAULT" '$d + .' "$PACE_FILE"
  else echo "$PACE_DEFAULT"; fi
}
pace_json_update() { # jq-filter [jq args]: apply the filter to the pace file, written whole
  local filter=$1 tmp; shift
  tmp=$(mktemp "$PACE_FILE.XXXXXX") || return 1
  if pace_json | jq -c "$@" "$filter" > "$tmp"; then mv "$tmp" "$PACE_FILE"; else rm -f "$tmp"; return 1; fi
}
pace_stopped() { [ "$PACE_STATE" != unknown ] && pace_json | jq -e --arg s "$1" '.stopped | index($s)' >/dev/null; }   # it stopped while a limit was full
pace_tried() { pace_json | jq -e --arg s "$1" '[.tried[] | select(. == $s)] | length >= 2' >/dev/null; }          # its resume failed twice in a row
pace_mark_tried() { pace_json_update '.tried += [$s]' --arg s "$1" || true; }
pace_hold_down() { # station: it is down while a limit is full; remember it, and say so once per episode; a full limit again starts its failed resumes over
  local said; said=$(pace_json | jq -r '.said')
  pace_json_update '.tried -= [$s] | .stopped = ((.stopped + [$s]) | unique) | .limit_since = (.limit_since // $now) | .["until"] = $up | .said = true' \
    --arg s "$1" --argjson now "$(now_s)" --arg up "$PACE_UNTIL" || true
  [ "$said" = true ] || say "the plan's limit is full${PACE_UNTIL:+ until $PACE_UNTIL}, so a stopped station is not revived now ($1 is the first); the watch revives each one in its own conversation after the reset"
  return 0
}
pace_revived() { # station: it runs again (revived, or by hand): forget it; the file goes when nothing is left to carry out
  [ -f "$PACE_FILE" ] || return 0
  pace_json_update '.stopped -= [$s] | .tried -= [$s]' --arg s "$1" || true
  if [ "$(pace_json | jq '(.stopped | length) == 0')" = true ] && ! pace_closed; then rm -f "$PACE_FILE"; fi
  return 0
}
pace_tidy() { # once a round: forget stations that will not be revived (retired, done, a helper) and a pace file with nothing left to carry out
  [ -f "$PACE_FILE" ] && [ "$PACE_STATE" != unknown ] || return 0   # a reading that is missing changes nothing
  if [ "$PACE_STATE" = off ]; then rm -f "$PACE_FILE"; return 0; fi
  local n live="[]"
  for n in $(jq -r 'to_entries[] | select(.value.status == "active" and .value.role != "helper") | .key' "$STATIONS" 2>/dev/null); do
    [ "$(stations_get "$n" '.role')" = builder ] && [ -f "$CREW_HOME/stations/$n.done" ] && continue
    live=$(jq -c --arg n "$n" '. + [$n]' <<<"$live")
  done
  pace_json_update '.stopped |= map(select(. as $x | $live | index($x))) | .tried |= map(select(. as $x | $live | index($x)))
                    | if $closed then . else .said = false end' --argjson live "$live" --argjson closed "$(pace_closed && echo true || echo false)" || return 0
  if [ "$(pace_json | jq '(.stopped | length) == 0')" = true ] && ! pace_closed; then rm -f "$PACE_FILE"; fi
  return 0
}
pace_resume_prompt() { # a short one: the conversation is its own, so it needs no reload of the skills
  echo "The plan's limit was reset. Carry on from where you stopped: re-read your report and your work on Tares, $(rearm_text), then continue."
}
pace_fresh_prompt() { # station: its conversation could not be resumed
  echo "The plan's limit was reset, but your last conversation could not be resumed, so this is a new one under the same name. Nothing from it carries over except your report and what is on Tares. Reload the skills factory-protocol and station-$(stations_get "$1" '.role'), re-read your report and your work on Tares, $(rearm_text), then carry on where your report left off."
}

pace_station_file() { echo "$CREW_HOME/stations/$1.pace"; }
pace_station_read() { # station → {stretched, held}
  local f; f=$(pace_station_file "$1")
  if [ -f "$f" ] && jq -e . "$f" >/dev/null 2>&1; then jq -c '{stretched: false, held: false} + .' "$f"
  else echo '{"stretched":false,"held":false}'; fi
}
pace_station_put() { # station stretched held (true|false): what the station has been told; nothing told, no file
  local f tmp; f=$(pace_station_file "$1")
  if [ "$2" = false ] && [ "$3" = false ]; then rm -f "$f"; return 0; fi
  mkdir -p "$(dirname "$f")"
  tmp=$(mktemp "$f.XXXXXX") || return 1
  if jq -cn --argjson st "$2" --argjson h "$3" '{stretched: $st, held: $h}' > "$tmp"; then mv "$tmp" "$f"; else rm -f "$tmp"; return 1; fi
}
pace_launch_note() { # station: a start while the crew is paced is a start with the stretched tick (rearm_text says so)
  case "$(stations_get "$1" '.role')" in orchestrator|reviewer|releaser|builder) ;; *) return 0 ;; esac
  [ "$PACE_STATE" = active ] && [ "$PACE_LEVEL" -ge 1 ] && [ "$(idle_wake_state)" = off ] || return 0
  pace_station_put "$1" true false
}

# Two steps, like the idle wake: pace_plan decides (0 = tell it now, the what in PACE_*), pace_send does
# the restart as a plain statement. Only with idle_wake off: with it on the station holds no tick of its
# own and the wake machinery already waits the longest and sends a builder no work at the hold.
pace_plan() { # station → 0 when the station is to be restarted with a pace note now, else 1
  local s=$1 role st cur stretched held want="" note
  [ "$PACE_STATE" != unknown ] || return 1
  role=$(stations_get "$s" '.role')
  case "$role" in orchestrator|reviewer|releaser|builder) ;; *) return 1 ;; esac
  st=$(idle_wake_state)
  case "$st" in
    off) ;;
    on) rm -f "$(pace_station_file "$s")"; return 1 ;;   # a released tick is not stretched
    *) return 1 ;;
  esac
  cur=$(pace_station_read "$s"); stretched=$(jq -r .stretched <<<"$cur"); held=$(jq -r .held <<<"$cur")
  if [ "$PACE_STATE" = off ] || [ "$PACE_LEVEL" -eq 0 ]; then
    if [ "$stretched" = true ] || [ "$held" = true ]; then want=release; fi
  elif [ "$stretched" != true ]; then want=stretch
  elif [ "$role" = builder ] && [ "$PACE_LEVEL" -ge 2 ] && [ "$held" != true ]; then want=hold
  elif [ "$held" = true ] && [ "$PACE_LEVEL" -lt 2 ]; then want=unhold
  fi
  [ -n "$want" ] || return 1
  halted && return 1
  [ -n "$(stations_get "$s" '.sessionId')" ] || return 1
  ! attached "$s" || return 1
  between_turns "$s" || return 1

  note=$(jq -r '.note // ""' <<<"$PACE")
  local stretch_text="Stretch your tick to 45 minutes: if you hold a CronCreate tick, delete it (CronList, then CronDelete) and create it again at 45 minutes, and keep it there until the factory tells you otherwise."
  local hold_text="Finish the step you are on, push, write in your report where you are and what comes next, then wait without starting new work until the factory tells you the plan has room again."
  PACE_S=$s PACE_WANT=$want PACE_NEW_STRETCHED=true PACE_NEW_HELD=false
  case "$want" in
    stretch)
      PACE_PROMPT="$note $stretch_text Carry on from your report."
      if [ "$role" = builder ] && [ "$PACE_LEVEL" -ge 2 ]; then PACE_PROMPT="$note $stretch_text $hold_text"; PACE_NEW_HELD=true; fi ;;
    hold)
      PACE_PROMPT="$note $hold_text"; PACE_NEW_HELD=true ;;
    unhold)
      PACE_PROMPT="The plan's limit has room for builders again: pick up where your report says you stopped. Keep your tick at 45 minutes while the factory still paces the crew." ;;
    release)
      PACE_PROMPT="The factory has stopped pacing the crew (the plan has room, or pace was turned off). Put your tick back to its normal interval: delete the 45-minute CronCreate tick and create it again as your manual says. Then carry on from your report, including anything you held back."
      PACE_NEW_STRETCHED=false ;;
  esac
  return 0
}

pace_send() { # the station pace_plan chose: restart it in the same conversation with the note; always returns 0
  local s=$PACE_S want=$PACE_WANT prompt=$PACE_PROMPT rc
  set +e
  ( set -e; TARES_STATE=""; SETTINGS=""; CREW_PROJECTS=""
    tares_on || exit 3
    [ "$(idle_wake_state)" = off ] || exit 4   # the setting changed since the round began
    ! halted || exit 4
    ! attached "$s" || exit 4
    between_turns "$s" || exit 4   # it started something since the first check
    say "pacing: telling $s ($want) in the same conversation"
    cmd_restart "$s" "$prompt"
    event pace "$s" "$want" )
  rc=$?
  set -e
  case $rc in
    0) pace_station_put "$s" "$PACE_NEW_STRETCHED" "$PACE_NEW_HELD" || true ;;
    3|4) ;;
    *) say "could not tell $s about the pace; will try again next round" ;;
  esac
  return 0
}

pace_status_line() { # the pace in one line for crew status; nothing when it is off
  tares_on || return 0
  local p; p=$(tares_http GET /api/crew/pace 2>/dev/null) || return 0
  jq -r '
    def pc: if . == null then "-" else "\(.)%" end;
    if .on != true then empty
    elif .known != true then "pace: on, no reading of the plan limits yet"
    else "pace: \(if .state == "ok" then "ok"
                  elif .state == "slow" then "slow, no new builders and the longest ticks, until \(.["until"] // "the reset")"
                  elif .state == "hold" then "hold, builders finish their step and wait, until \(.["until"] // "the reset")"
                  else "at the limit, stopped stations wait for the reset, until \(.["until"] // "the reset")" end) (5-hour \(.session_pct | pc), week \(.weekly_pct | pc))"
    end' <<<"$p" 2>/dev/null || true
}

WAKE_DEFAULT='{"seen":[],"n":0,"last_wake":null,"clock":null,"baseline":null,"released":false}'
wake_file() { echo "$CREW_HOME/stations/$1.wake"; }
wake_read() { # station → its wake state, the defaults under whatever the file has
  local f; f=$(wake_file "$1")
  if [ -f "$f" ] && jq -e . "$f" >/dev/null 2>&1; then jq -c --argjson d "$WAKE_DEFAULT" '$d + .' "$f"
  else echo "$WAKE_DEFAULT"; fi
}
wake_update() { # station jq-filter [jq args]: apply the filter to the wake state, written whole
  local s=$1 filter=$2 f tmp; shift 2
  f=$(wake_file "$s"); mkdir -p "$(dirname "$f")"
  tmp=$(mktemp "$f.XXXXXX") || return 1
  if wake_read "$s" | jq -c "$@" "$filter" > "$tmp"; then mv "$tmp" "$f"; else rm -f "$tmp"; return 1; fi
}
iw_launch_note() { # station: a start with idle_wake on is a start with its tick released; off, with it back
  case "$(stations_get "$1" '.role')" in orchestrator|reviewer|releaser|builder) ;; *) return 0 ;; esac
  case "$(idle_wake_state)" in
    on) wake_update "$1" '.released = true | .n = 0 | .clock = $now' --argjson now "$(now_s)" ;;
    off) [ ! -f "$(wake_file "$1")" ] || wake_update "$1" '.released = false' ;;
  esac
}

wake_base_min() { case "$1" in reviewer) echo 15 ;; orchestrator) echo 20 ;; *) echo 30 ;; esac; }
station_updated() { # station → when its session last did anything, epoch seconds; empty when unknown
  job_state "$(stations_get "$1" '.id')" | jq -r '((.updatedAt // "") | sub("\\.[0-9]+"; "") | fromdateiso8601?) // empty'
}

# What each role has to do, as {pending: [{key, text}], live: bool} (the orchestrator's also carries
# the baseline it compares with). A key names one thing to do once. Fails when Tares can't be read.
iw_scan_reviewer() { # the review queue's PRs without a current verdict, once per head
  local q; q=$(tares_http GET /api/review-queue) || return 1
  jq -c '[(.queue // [])[] | select(.verdict == null or ((.verdict.current // false) | not))
      | {key: "rv:\(.project)/\(.ticket)@\(.head // "")",
         text: "\(.project_name // .project) \(.ticket) at \((.head // "")[0:7])"}]
    | {pending: ., live: true}' <<<"$q"
}
iw_scan_builder() { # the tickets it holds that need it: changes asked for, a verdict, or failed CI
  local s=$1 project uid tickets
  project=$(stations_get "$s" '.project')
  uid=$(tares_http GET /api/crew/projects | jq -r --arg p "$project" '[(.projects // [])[] | select(.name == $p or .id == $p)][0].id // empty') || return 1
  [ -n "$uid" ] || { echo '{"pending":[],"live":false}'; return 0; }
  tickets=$(tares_http GET "/api/projects/$(uri "$uid")/tickets") || return 1
  jq -c --arg s "$s" '[(.tickets // [])[] | select(.holder == $s and (.stage | IN("review", "changes")))] as $mine
    | {live: (($mine | length) > 0),
       pending: [$mine[] | . as $t | ($t.pr // {}) as $pr
         | (if ($t.verdict // null) != null and ($t.verdict.current // false) and ($t.verdict.verdict | IN("pass", "changes", "block"))
            then $t.verdict.verdict else "" end) as $vd
         | select($t.stage == "changes" or $vd != "" or $pr.ci == "failure")
         | {key: "bd:\($t.label)@\($pr.head // "")|\($vd)|\($pr.ci // "")",
            text: "\($t.label) (\(if $vd != "" then "verdict \($vd)" elif $pr.ci == "failure" then "CI failed" else "changes asked for" end))"}]}' <<<"$tickets"
}
iw_scan_releaser() { # RELEASE lines under Undelivered in the orchestrator's report, once per line
  local f="$CREW_HOME/reports/crew-orchestrator.md" line key
  if [ ! -f "$f" ]; then echo '{"pending":[],"live":true}'; return 0; fi
  while IFS= read -r line; do
    line=$(sed 's/^[[:space:]]*//; s/[[:space:]]*$//' <<<"$line"); [ -n "$line" ] || continue
    key="rl:$(printf '%s' "$line" | cksum | cut -d' ' -f1)"
    jq -cn --arg k "$key" --arg t "$line" '{key: $k, text: $t}'
  done < <(awk '/^## /{u = ($0 ~ /^## Undelivered/)} u && /(^|[^A-Za-z])RELEASE([^A-Za-z]|$)/' "$f") | jq -cs '{pending: ., live: true}'
}
iw_scan_orchestrator() { # desk answers closed, and projects handed over, since the baseline; the first look only sets it
  local desk projs base act
  desk=$(tares_http GET '/api/desk?state=answered') || return 1
  projs=$(tares_http GET /api/crew/projects) || return 1
  base=$(wake_read crew-orchestrator | jq -c '.baseline')
  act=$(station_updated crew-orchestrator); act=${act:-null}   # an answer the orchestrator itself recorded is not news to it
  jq -cn --argjson d "$desk" --argjson p "$projs" --argjson b "$base" --argjson act "$act" '
    def secs: (capture("^(?<t>[0-9T:-]+)(\\.[0-9]+)?(?<z>Z|[+-][0-9]{2}:[0-9]{2})?$")
               | ((.t + "Z" | fromdateiso8601)
                  - (if (.z // "Z") == "Z" then 0
                     else ((.z[0:1] + "1" | tonumber) * ((.z[1:3] | tonumber) * 3600 + (.z[4:6] | tonumber) * 60)) end))) ? // null;
    [($d.desk // [])[] | select(.command == null and .closed_at != null)] as $ans
    | ([$ans[].closed_at] | max // "") as $dmax
    | [($p.projects // [])[].id] as $ids
    | if $b == null then {first: true, pending: [], live: true, baseline: {desk: $dmax, projects: $ids}}
      else {live: true,
            pending: ([$ans[] | select(.closed_at > ($b.desk // ""))
                        | select($act == null or ((.closed_at | secs) // ($act + 1)) > $act) | {key: "dk:\(.id)", text: "desk answer \(.label // .id) is closed"}]
                    + [($p.projects // [])[] | select(.id as $i | (($b.projects // []) | index($i)) | not)
                       | {key: "ph:\(.id)", text: "project \(.name // .id) was handed over"}]),
            baseline: {desk: ([$dmax, ($b.desk // "")] | max), projects: ((($b.projects // []) + $ids) | unique)}}
      end'
}

iw_work_prompt() { # role items
  case "$1" in
    reviewer) echo "There is work in the review queue: $2 have no current verdict. Work the queue." ;;
    builder) echo "Your tickets changed: $2. Read the reviewer's verdict and CI on the current head and act on it, as your manual says." ;;
    releaser) echo "A RELEASE is waiting for you in the orchestrator's report under Undelivered: $2. Take it as your manual says." ;;
    *) echo "Something is waiting for you: $2. Follow the Tick section of your manual." ;;
  esac
}

# Two steps, so the restart runs as a plain statement (errexit works there) and not inside a condition:
# idle_wake_plan decides (0 = wake it, with the what and why in IW_*), idle_wake_send does the restart.
idle_wake_plan() { # station → 0 when the station is to be woken now, else 1
  local s=$1 role st
  role=$(stations_get "$s" '.role')
  case "$role" in orchestrator|reviewer|releaser|builder) ;; *) return 1 ;; esac
  halted && return 1
  [ -n "$(stations_get "$s" '.sessionId')" ] || return 1
  st=$(idle_wake_state)
  case "$st" in
    on) ;;
    off) [ "$(wake_read "$s" | jq -r '.released')" = true ] || return 1 ;;
    *) return 1 ;;
  esac
  ! attached "$s" || return 1
  between_turns "$s" || return 1

  local now reason prompt keys="[]" baseline=null n=0 work=0
  now=$(now_s)
  if [ "$st" = off ]; then
    reason=rearm; prompt="idle_wake is off again: re-arm your tick as your manual says."
  else
    local w scan upd plan
    w=$(wake_read "$s")
    scan=$(iw_scan_"$role" "$s") || return 1
    upd=$(station_updated "$s")
    plan=$(jq -cn --argjson w "$w" --argjson sc "$scan" --argjson now "$now" --argjson upd "${upd:-null}" \
      --argjson settle "${FACTORY_WAKE_SETTLE:-600}" --argjson base "$(wake_base_min "$role")" \
      --argjson pace "$PACE_LEVEL" --argjson hold "$([ "$role" = builder ] && [ "$PACE_LEVEL" -ge 2 ] && echo true || echo false)" '
      def after($n): (if $pace >= 1 then 2700 elif $n == 0 then $base * 60 elif $n == 1 then 1800 else 2700 end);   # pacing (TR-674): the longest wait at once
      ($w.clock // $now) as $c0
      | (if $upd != null and $upd > (($w.last_wake // $c0) + $settle) then {n: 0, clock: $upd} else {n: $w.n, clock: $c0} end) as $r
      | (if $sc.live then $r else {n: 0, clock: $now} end) as $r
      | (if $hold then [] else [$sc.pending[] | select(.key as $k | ($w.seen | index($k)) | not)] end) as $new   # a builder told to hold gets no work wake
      | {n: $r.n, clock: $r.clock, keys: [$sc.pending[].key], new: $new, baseline: ($sc.baseline // null), first: ($sc.first // false),
         live: $sc.live, due: ($sc.live and ($now - $r.clock) >= after($r.n))}')
    keys=$(jq -c '.keys' <<<"$plan"); baseline=$(jq -c '.baseline' <<<"$plan"); n=$(jq '.n' <<<"$plan")
    wake_update "$s" '.clock = $p.clock | .n = $p.n | .seen = [.seen[] | select(. as $k | $p.keys | index($k))]
      | (if $p.first then .baseline = $p.baseline else . end)
      | (if $p.live then . else .released = true end)' --argjson p "$plan" || true   # a builder with no PR holds no tick
    if [ "$(jq '.new | length' <<<"$plan")" -gt 0 ]; then
      work=1; reason="work: $(jq -r '[.new[].text] | join("; ")' <<<"$plan")"
      prompt="$(iw_work_prompt "$role" "$(jq -r '[.new[].text] | join("; ")' <<<"$plan")") $(idle_wake_note) Carry on from your report."
      n=0
    elif [ "$(jq -r '.live' <<<"$plan")" = true ] && [ "$(jq -r '.released' <<<"$w")" != true ]; then
      # Switched on under a running station: it still holds the tick it armed itself, so tell it now.
      reason="release: idle_wake was switched on, telling the station to drop its tick"
      prompt="idle_wake was switched on. $(idle_wake_note) Carry on from your report."
      n=0
    elif [ "$(jq '.due' <<<"$plan")" = true ]; then
      reason="liveness: wake $((n + 1)) with nothing pending"
      prompt="Liveness check, nothing is known to be pending: check your queue once, then end the turn. $(idle_wake_note) Carry on from your report."
      n=$((n + 1))
    else
      return 1
    fi
  fi
  IW_S=$s IW_ST=$st IW_NOW=$now IW_REASON=$reason IW_PROMPT=$prompt IW_KEYS=$keys IW_BASELINE=$baseline IW_N=$n IW_WORK=$work
  return 0
}

idle_wake_send() { # the station idle_wake_plan chose: restart it in the same conversation; always returns 0
  local s=$IW_S st=$IW_ST now=$IW_NOW reason=$IW_REASON prompt=$IW_PROMPT keys=$IW_KEYS baseline=$IW_BASELINE n=$IW_N work=$IW_WORK
  local rc
  set +e
  ( set -e; TARES_STATE=""; SETTINGS=""; CREW_PROJECTS=""
    tares_on || exit 3
    [ "$(idle_wake_state)" = "$st" ] || exit 4   # the setting changed since the round began
    ! halted || exit 4
    ! attached "$s" || exit 4
    between_turns "$s" || exit 4   # it started something since the first check
    say "waking $s (${reason%%:*}); restarting it in the same conversation"
    cmd_restart "$s" "$prompt"
    event idle_wake "$s" "$reason" )
  rc=$?
  set -e
  case $rc in
    0) wake_update "$s" '.last_wake = $now | .clock = $now | .n = $n | .released = $rel
         | (if $work then .seen = $keys else . end) | (if $b != null then .baseline = $b else . end)' \
         --argjson now "$now" --argjson n "$n" --argjson rel "$([ "$st" = on ] && echo true || echo false)" \
         --argjson work "$([ $work = 1 ] && echo true || echo false)" --argjson keys "$keys" --argjson b "$baseline" || true
       ;;
    3|4) ;;
    *) say "could not wake $s; will try again next round" ;;
  esac
  return 0
}


stale_kit() { # station kit → 0 when it runs another version, or from a frozen copy that is going away
  [ "$(stations_get "$1" '.kit')" != "$2" ] || { ! upgrades_frozen && on_frozen_copy "$1"; }
}
movable() { # station kit → 0 when it runs another version, has a conversation to resume, and is quiet
  stale_kit "$1" "$2" && [ -n "$(stations_get "$1" '.sessionId')" ] && quiet "$1"
}

upgrade_station() { # station kit: restart it on the new version, in the same conversation
  local s=$1 kit=$2 skill rc
  skill=$(stations_get "$s" '.role')
  set +e
  ( set -e; TARES_STATE=""; SETTINGS=""; CREW_PROJECTS=""
    tares_on || exit 3   # without Tares there are no settings to restart it with
    crew_settings >/dev/null
    ! upgrades_frozen || exit 4   # frozen since the round began
    quiet "$s" || exit 4   # it started something since the first check
    say "$s runs an older version of the plugin; restarting it on this one now that it's idle, in the same conversation"
    cmd_restart "$s" "The Tares plugin was updated, so the factory restarted you on it, in this same conversation, with fresh settings. Reload the skills factory-protocol and station-$skill (they may have changed), re-read your report, $(rearm_text), then carry on where you were."
    event upgraded "$s" "$kit" )
  rc=$?
  set -e
  case $rc in
    0|4) ;;
    3) say "Tares doesn't answer; $s moves onto the new version once it does" ;;
    *) say "could not restart $s on the new version; the watch revives it as before, and no other station moves until it is up" ;;
  esac
}

# ---------- Claude Code updates ----------
# A new Claude Code is installed by swapping a symlink, and a running station keeps the version it
# started on. The watch moves each station onto the new one like a plugin update: one idle station a
# round, in the same conversation, the orchestrator last. Claude Code's daemon also keeps pre-warmed
# spare sessions, and a restart can claim one of an older version, so a station is only counted as
# moved once its process is seen running the new version (TR-669).
claude_version() { # → the version `claude` on PATH is now (2.1.310); empty when it can't be read
  "$CLAUDE" --version 2>/dev/null | head -1 | grep -Eo '^[0-9]+(\.[0-9]+)+[^ ]*' || true
}

older_than() { # version target → 0 when version is a lower Claude Code version than target; unknown is never older
  [ -n "$1" ] && [ -n "$2" ] && [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$1" ]
}

# The version a session's process runs. Claude Code's list may give it; if not, a station's pid is a
# `bg-spare` process under a pty host whose command line names the binary it runs (.../versions/<v>).
running_version() { # session id [agents json] → its version; empty when it can't be told
  local agents=${2:-} e v pid p
  [ -n "$1" ] || return 0
  [ -n "$agents" ] || agents=$(active_json) || return 0
  e=$(jq -r --arg id "$1" '.[] | select(.id == $id) | [(.version // ""), ((.pid // "") | tostring)] | join(" ")' <<<"$agents" | head -1) || return 0
  v=${e%% *}; pid=${e#* }
  if [ -n "$v" ]; then echo "$v"; return 0; fi
  [ -n "$pid" ] || return 0
  for p in "$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ')" "$pid"; do
    [ -n "$p" ] || continue
    v=$(ps -o command= -p "$p" 2>/dev/null | grep -Eo '/versions/[0-9][^ /]*' | head -1 | cut -d/ -f3) || true
    if [ -n "$v" ]; then echo "$v"; return 0; fi
  done
  return 0
}
station_cc() { running_version "$(stations_get "$1" '.id')" "${2:-}"; } # station [agents json] → its version

# The pre-warmed spares of an older version that nobody has claimed yet (their claim socket is still
# there): stopped before a restart, so the restart can't land on one.
prune_spares() { # target version
  local pid cmd sock v
  while read -r pid cmd; do
    case "$cmd" in *bg-pty-host*--bg-spare*.claim.sock*) ;; *) continue ;; esac
    sock=$(grep -Eo -- '--bg-spare [^ ]+\.claim\.sock' <<<"$cmd" | cut -d' ' -f2) || continue
    [ -e "$sock" ] || continue   # claimed: a session runs in it
    v=$(grep -Eo '/versions/[0-9][^ /]*' <<<"$cmd" | head -1 | cut -d/ -f3) || true
    older_than "$v" "$1" || continue
    [ -e "$sock" ] || continue   # claimed since the listing: leave it
    kill "$pid" 2>/dev/null && event spare-pruned "" "$v"
  done < <(ps -axo pid=,command= 2>/dev/null)
  return 0
}

duplicate_sessions() { # → a line "name: id, id" for each station that more than one live session answers to
  [ -f "$STATIONS" ] || return 0
  active_json | jq -r --argjson names "$(jq -c 'keys' "$STATIONS")" "$GONE"'
    map(select((.name // "") as $n | ($names | index($n)) and (gone | not))) | group_by(.name)[] | select(length > 1)
    | "\(.[0].name): \(map(.id) | join(", "))"'
}

# Whether the watch may move stations onto a new Claude Code: the crew setting claude_update (default
# on), for people who pin a version. Tares not answering counts as off for this round.
claude_auto() {
  local s; s=$(SETTINGS=""; crew_settings 2>/dev/null) || return 1
  [ "$(jq -r '.claude_update' <<<"$s")" != false ]
}

cc_behind() { # target [agents json] → how many stations that matter run an older Claude Code
  local target=$1 agents=${2:-} s n=0
  [ -n "$agents" ] || agents=$(active_json) || { echo 0; return 0; }
  for s in $(jq -r 'to_entries[] | select(.value.status == "active") | .key' "$STATIONS" 2>/dev/null); do
    [ -f "$CREW_HOME/stations/$s.done" ] && continue
    older_than "$(station_cc "$s" "$agents")" "$target" && n=$((n + 1))
  done
  echo "$n"
}

# A frozen crew moves no station onto a newer Claude Code either, but says so: once for each version.
tell_newer_claude() { # live Claude Code version
  local n; n=$(cc_behind "$1")
  if [ "$n" = 0 ]; then rm -f "$CC_NEWER_FILE"; return 0; fi
  [ "$(cat "$CC_NEWER_FILE" 2>/dev/null)" != "$1" ] || return 0
  say "Claude Code $1 is newer than the one $n station(s) run; not moving stations while the kit is frozen ($ME crew unfreeze lets them follow it)"
  event claude-newer "" "$1"
  echo "$1" > "$CC_NEWER_FILE"
}

# A station whose move onto $2 kept landing on an older spare waits out a backoff before the watch tries
# again (cc_failed names the version it failed for, cc_retry_at is when the next try is due).
cc_backoff() { # station target → 0 while the station is waiting out the backoff after a failed move onto target
  [ "$(stations_get "$1" '.cc_failed')" = "$2" ] && [ "$(date +%s)" -lt "$(stations_get "$1" '.cc_retry_at')" ] 2>/dev/null
}

cc_movable() { # station target → 0 when the watch may restart it now: conversation to resume, quiet, nobody attached
  [ -n "$(stations_get "$1" '.sessionId')" ] && ! cc_backoff "$1" "$2" && ! attached "$1" && quiet "$1"
}

# The one station to move this round: a station on an older Claude Code that is idle, then a helper, the
# orchestrator only once no other station is behind. A helper never holds the orchestrator back, since a
# helper can work for a long time; finished builders stay stopped, and a station backing off after a failed
# move does not hold the orchestrator back either. A station that is not running shows no version, so is not behind.
cc_pick() { # target → a station name, or nothing
  local target=$1 s agents others_behind=0 orch="" helper="" v role
  agents=$(active_json) || return 0
  for s in $(jq -r 'to_entries[] | select(.value.status == "active") | .key' "$STATIONS" 2>/dev/null); do
    role=$(stations_get "$s" '.role')
    [ "$role" = helper ] || [ ! -f "$CREW_HOME/stations/$s.done" ] || continue
    v=$(station_cc "$s" "$agents"); older_than "$v" "$target" || continue
    case "$role" in
      orchestrator) orch=$s; continue ;;
      helper) [ -n "$helper" ] || ! cc_movable "$s" "$target" || helper=$s; continue ;;
    esac
    cc_backoff "$s" "$target" && continue
    others_behind=1
    if cc_movable "$s" "$target"; then echo "$s"; return 0; fi
  done
  if [ -n "$helper" ]; then echo "$helper"; return 0; fi
  if [ $others_behind = 0 ] && [ -n "$orch" ] && cc_movable "$orch" "$target"; then echo "$orch"; fi
  return 0
}

# The seconds a station waits before the watch tries to move it again, after $1 failed moves onto the same
# version: FACTORY_CC_BACKOFF, doubling each time, never past FACTORY_CC_BACKOFF_MAX.
cc_delay() { # failed moves so far → seconds
  local n=$1 d=${FACTORY_CC_BACKOFF:-600} max=${FACTORY_CC_BACKOFF_MAX:-14400}
  while [ "$n" -gt 1 ] && [ "$d" -lt "$max" ]; do d=$((d * 2)); n=$((n - 1)); done
  [ "$d" -le "$max" ] || d=$max
  echo "$d"
}

cc_retry_in() { # station → how long until the watch tries it again: "10 min", "3 h"
  local at n; at=$(stations_get "$1" '.cc_retry_at'); case $at in ''|*[!0-9]*) at=0 ;; esac
  n=$(( (at - $(date +%s) + 59) / 60 )); [ $n -ge 1 ] || n=1
  if [ $n -ge 120 ]; then echo "$(( (n + 59) / 60 )) h"; else echo "$n min"; fi
}

cc_retrying() { # station target → 0 when a move of the station onto target failed and the watch is retrying it
  [ "$(stations_get "$1" '.cc_failed')" = "$2" ]
}

# Tell Tares which stations are retrying a move, so the Crew page can flag them: the CLI is the only
# one that knows (the marks are in stations.json). Best effort; the roster has the same marks. Only a
# mark for the Claude Code now installed counts, on a station that is still behind it and not retired:
# a station that moved some other way, a retired one or a mark for an older version is left out. The
# watch sends it every round, so the list never outlives the mark.
cc_retry_sync() {
  tares_on || return 0
  local tok body target agents s rows="[]" t
  target=$(claude_version); agents=$(active_json) || return 0
  for s in $(jq -r 'to_entries[] | select(.value.status == "active" and (.value.cc_failed // "") != "") | .key' "$STATIONS" 2>/dev/null); do
    [ -f "$CREW_HOME/stations/$s.done" ] && continue
    t=$(stations_get "$s" '.cc_failed'); [ "$t" = "$target" ] || continue
    older_than "$(station_cc "$s" "$agents")" "$target" || continue
    rows=$(jq -c --arg k "$s" --arg t "$t" --argjson n "$(jq -n --arg v "$(stations_get "$s" '.cc_fails')" '$v|tonumber? // 1')" \
      --argjson at "$(jq -n --arg v "$(stations_get "$s" '.cc_retry_at')" '$v|tonumber? // 0')" '. + [{key: $k, value: {target: $t, fails: $n, next_at: $at}}]' <<<"$rows")
  done
  body=$(jq -c '{stations: from_entries}' <<<"$rows") || return 0
  tok=$(tares_token)
  { [ -n "$tok" ] && printf 'Authorization: Bearer %s\n' "$tok"; true; } | curl -sS -o /dev/null --max-time 3 -X PUT -H @- \
    -H 'Content-Type: application/json' --data "$body" "$TARES/api/crew/claude-retry" 2>/dev/null || true
}

# Restart a station on the Claude Code now on PATH, in the same conversation, and check its process
# runs it; an older spare from the pool is stopped and the restart tried again. When the tries keep
# landing on an old spare, the last one starts the station in a new conversation (once per version), so
# the station is never left on the old version. Exit status: 0 it runs the new version (or its version
# can't be told), 3 Tares doesn't answer, 4 it isn't idle any more or the crew is halted, 5 an older
# spare came back every time (the station is marked cc_failed with a retry time, and the watch tries
# again after a backoff), 6 the plan's limit is full (pace, TR-674: a restart is a model turn into a limit
# that has not reset), 1 anything else. It runs in a subshell with its own errexit, so the caller turns
# errexit off around the call (`set +e; claude_move ...; rc=$?; set -e`): `claude_move ... && ... || ...`
# would turn it off inside the subshell too.
claude_move() { # station target
  local s=$1 target=$2
  set +e
  ( set -e; TARES_STATE=""; SETTINGS=""; CREW_PROJECTS=""
    tares_on || exit 3   # without Tares there are no settings to restart it with
    crew_settings >/dev/null
    ! halted || exit 4
    pace_read || true; ! pace_closed || exit 6   # also the reading the restart prompt's schedule wording comes from
    { quiet "$s" && ! attached "$s"; } || exit 4   # it started something, or someone opened it, since the first check
    local skill try=0 tries=${FACTORY_CC_TRIES:-3} total v prompt fresh_prompt fails at helper=0
    skill=$(stations_get "$s" '.role'); [ "$skill" != helper ] || { helper=1; skill=$(parent_role "$(stations_get "$s" '.parent')"); }
    prompt="Claude Code was updated to $target, so the factory restarted you on it, in this same conversation, with fresh settings. Reload the skills factory-protocol and station-$skill, re-read your report, $(rearm_text), then carry on where you were."
    fresh_prompt="Claude Code was updated to $target, but your old conversation kept coming back on an older Claude Code, so the factory started you again under the same name, in a new conversation, on $target. Nothing from the old one carries over except your report and what is on Tares. Reload the skills factory-protocol and station-$skill, re-read your report and your work on Tares, $(rearm_text), then carry on where your report left off."
    total=$tries; [ "$(stations_get "$s" '.cc_fresh')" = "$target" ] || total=$((tries + 1))   # one new conversation per version
    [ $helper = 0 ] || total=$tries   # a helper's task lives only in its conversation (its spawn prompt), so it is never started fresh: it is retried after a backoff instead
    while [ $try -lt "$total" ]; do
      try=$((try + 1))
      if [ $try -gt 1 ]; then ! halted && ! attached "$s" || exit 4; pace_read || true; ! pace_closed || exit 6; fi   # the first restart started it on the prompt, so only a halt or someone opening it stops a retry
      prune_spares "$target"
      if [ $try -gt "$tries" ]; then
        say "$s still comes back on an older Claude Code after $tries tries; starting it again in a new conversation on $target"
        cmd_restart "$s" --fresh "$fresh_prompt"
        stations_put "$s" "$(jq -n --arg t "$target" '{cc_fresh: $t}')"   # after the restart: one that died leaves the new conversation to a later round
      else
        cmd_restart "$s" "$prompt"
      fi
      v=""; for _ in 1 2 3 4 5 6; do v=$(station_cc "$s"); [ -n "$v" ] && break; sleep "${FACTORY_PAUSE:-2}"; done
      if ! older_than "$v" "$target"; then
        [ -n "$v" ] || say "could not tell which Claude Code $s runs; taking it for $target"
        stations_put "$s" '{"cc_failed": null, "cc_fails": null, "cc_retry_at": null, "cc_fresh": null}'
        cc_retry_sync || true
        event claude-updated "$s" "${v:-$target}"
        exit 0
      fi
      say "$s came back on Claude Code $v, an older spare from Claude Code's pool, not $target; stopping it and starting it again"
      event stale-spare "$s" "$v"
    done
    fails=0; [ "$(stations_get "$s" '.cc_failed')" != "$target" ] || fails=$(stations_get "$s" '.cc_fails')
    case $fails in ''|*[!0-9]*) fails=0 ;; esac
    fails=$((fails + 1)); at=$(( $(date +%s) + $(cc_delay "$fails") ))
    stations_put "$s" "$(jq -n --arg t "$target" --argjson n "$fails" --argjson at "$at" '{cc_failed: $t, cc_fails: $n, cc_retry_at: $at}')"
    cc_retry_sync || true
    event claude-retry "$s" "$target"
    exit 5 )
  return $?
}

watch_claude_move() { # station target: the watch's turn at claude_move, with what it says
  local s=$1 target=$2 rc dup
  SETTINGS=""; ! upgrades_frozen || return 0   # frozen since the round began: the freeze wins
  pace_read || true; ! pace_closed || return 0   # the plan's limit filled since the round began: wait for the reset (TR-674)
  say "$s runs an older Claude Code than $target; restarting it on $target now that it's idle, in the same conversation"
  set +e; claude_move "$s" "$target"; rc=$?; set -e
  case $rc in
    0) say "$s runs Claude Code $target"
       dup=$(duplicate_sessions) || dup=""
       [ -z "$dup" ] || { say "TWO SESSIONS named $dup; stop the one stations.json does not list (factory roster)"; event two-sessions "$s" "$dup"; } ;;
    3) say "Tares doesn't answer; $s moves onto Claude Code $target once it does" ;;
    4|6) ;;
    5) if [ "$(stations_get "$s" '.role')" = helper ]; then
         say "$s keeps coming back on an older Claude Code than $target (an old pre-warmed spare in Claude Code's pool); a helper keeps its conversation, since its task is only there; the watch tries again in $(cc_retry_in "$s"), and $ME crew update tries again now"
       else
         say "$s keeps coming back on an older Claude Code than $target (an old pre-warmed spare in Claude Code's pool), even in a new conversation; the watch tries again in $(cc_retry_in "$s"), and $ME crew update tries again now"
       fi ;;
    *) if [ "$(stations_get "$s" '.role')" = helper ]; then
         say "could not restart $s on Claude Code $target; the watch does not revive helpers, so ask $(stations_get "$s" '.parent') to spawn it again if it is down"
       else
         say "could not restart $s on Claude Code $target; the watch revives it as before, and no other station moves until it is up"
       fi ;;
  esac
}

cmd_crew_update() { # [--force] [--wait seconds]: every station onto the Claude Code now on PATH, each when idle, the orchestrator last
  require_crew
  local force=0 limit=1800 self="" mine=${FACTORY_STATION:-}
  while [ $# -gt 0 ]; do
    case "$1" in
      --force) force=1; shift ;;
      --wait) [ $# -ge 2 ] || die "--wait takes a number of seconds"
              limit=$2
              case $limit in ''|*[!0-9]*) die "--wait takes a number of seconds, not '$limit'" ;; esac
              while [ ${#limit} -gt 1 ] && [ "${limit#0}" != "$limit" ]; do limit=${limit#0}; done   # 00 is 0, no deadline
              [ ${#limit} -le 9 ] || die "--wait takes a number of seconds, at most 9 digits, not '$limit'"
              shift 2 ;;
      *) die "usage: $ME crew update [--force] [--wait <seconds>]" ;;
    esac
  done
  halted && die "the crew is halted ($(jq -r .reason "$HALT_FILE" 2>/dev/null)); $ME crew resume first"
  local target; target=$(claude_version); [ -n "$target" ] || die "could not read the Claude Code version ($CLAUDE --version)"
  pace_read || true
  ! pace_closed || die "the plan's limit is full${PACE_UNTIL:+ until $PACE_UNTIL}, and a restart is a model turn into it; $ME crew update again after it resets"
  [ -f "$STATIONS" ] || { say "no stations recorded"; return 0; }
  local agents; agents=$(active_json) || die "$UNREAD"
  local s v others="" orch="" failed=""
  for s in $(jq -r 'to_entries[] | select(.value.status == "active") | .key' "$STATIONS"); do
    if [ -n "$mine" ] && [ "$s" = "$mine" ]; then   # the station running this command can't wait for itself
      v=$(station_cc "$s" "$agents")
      if [ $force = 1 ] || [ -z "$v" ] || older_than "$v" "$target"; then self=$s; else say "$s already runs Claude Code $v"; fi
      continue
    fi
    if [ -f "$CREW_HOME/stations/$s.done" ] || ! is_alive "$s"; then say "$s is stopped; it starts on Claude Code $target when the watch revives it"; continue; fi
    if [ -z "$(stations_get "$s" '.sessionId')" ]; then say "$s has no conversation to resume; $ME restart $s --fresh starts it again"; failed="$failed $s"; continue; fi
    v=$(station_cc "$s" "$agents")
    if [ $force = 0 ] && [ -n "$v" ] && ! older_than "$v" "$target"; then say "$s already runs Claude Code $v"; continue; fi
    if [ "$(stations_get "$s" '.role')" = orchestrator ]; then orch="$orch $s"; else others="$others $s"; fi
  done
  local deadline=0; [ "$limit" = 0 ] || deadline=$(( $(date +%s) + limit ))
  local group left still rc told dup
  for group in "$others" "$orch"; do
    left=$group; told=""
    while [ -n "${left// /}" ]; do
      halted && die "the crew was halted; nothing more was restarted ($ME crew resume, then crew update again)"
      still=""
      for s in $left; do
        if ! quiet "$s" || attached "$s"; then still="$still $s"; continue; fi
        say "restarting $s on Claude Code $target, in the same conversation"
        set +e; claude_move "$s" "$target"; rc=$?; set -e
        case $rc in
          0) say "$s runs Claude Code $target" ;;
          4) still="$still $s" ;;
          6) pace_read || true
             die "the plan's limit is full${PACE_UNTIL:+ until $PACE_UNTIL}, and a restart is a model turn into it; nothing more was restarted ($ME crew update again after it resets)" ;;
          5) if [ "$(stations_get "$s" '.role')" = helper ]; then
               say "$s came back on an older Claude Code each time (an old pre-warmed spare in Claude Code's pool); a helper keeps its conversation, since its task is only there; the watch tries again in $(cc_retry_in "$s"), or run $ME crew update again"
             else
               say "$s came back on an older Claude Code each time, even in a new conversation (an old pre-warmed spare in Claude Code's pool); the watch tries again in $(cc_retry_in "$s"), or run $ME crew update again"
             fi
             failed="$failed $s" ;;
          3) die "Tares doesn't answer; nothing more was restarted" ;;
          *) if [ "$(stations_get "$s" '.role')" = helper ]; then
               say "could not restart $s on Claude Code $target; the watch does not revive helpers, so ask $(stations_get "$s" '.parent') to spawn it again if it is down"
             else
               say "could not restart $s on Claude Code $target"
             fi
             failed="$failed $s" ;;
        esac
      done
      left=$still
      [ -n "${left// /}" ] || break
      if [ $deadline != 0 ] && [ "$(date +%s)" -ge $deadline ]; then
        say "gave up waiting for$left to be idle"; failed="$failed$left"; break
      fi
      for s in $left; do
        case " $told " in *" $s "*) ;; *) say "waiting for $s to be idle ($(attached "$s" && echo "someone has it open" || echo "it is working"))"; told="$told $s" ;; esac
      done
      sleep "${FACTORY_UPDATE_POLL:-5}"
    done
  done
  dup=$(duplicate_sessions) || die "$UNREAD; could not check for two sessions under one station name"
  if [ -n "$dup" ]; then
    echo "$dup" | while IFS= read -r s; do say "TWO SESSIONS named $s; stop the one stations.json does not list (factory roster)"; done
    failed="$failed two-sessions"
  fi
  local selfnote="$self, which ran this command, can only be restarted from another session: run $ME restart $self there"
  if [ -n "$self" ] && older_than "$(station_cc "$self" "$agents")" "$target" && claude_auto && ! upgrades_frozen; then   # the watch moves a station that is behind, unless claude_update is off or the crew is frozen; one that is not behind, or whose version can't be read, stays
    selfnote="$self, which ran this command, moves when $ME crew watch finds it idle, or run $ME restart $self from another session"
  fi
  if [ -n "${failed// /}" ]; then
    [ -z "$self" ] || say "$selfnote"
    die "not every station runs Claude Code $target:$failed"
  fi
  if [ -n "$self" ]; then
    say "every other station runs Claude Code $target, one session each; $selfnote"
  else
    say "every station runs Claude Code $target, one session each"
  fi
}

cmd_halt() {
  require_crew
  jq -cn --arg r "${1:-halted by the human}" '{at: (now | todate), reason: $r}' > "$HALT_FILE"
  event halt "" "${1:-}"
  say "crew halted: stations finish their current step and start nothing risky (merges, pushes, deploys, tags, new sessions). $ME crew resume lifts it."
  say "tell the orchestrator too ($ME crew attach orchestrator) so it sends HALT to every station"
}
cmd_resume() { require_crew; rm -f "$HALT_FILE"; event resume; say "crew resumed"; }

frozen_text() { # → "since <time> by <who>" from the freeze recorded in this round's settings
  if [ -n "$(frozen_field at)" ]; then echo "since $(frozen_field at) by $(frozen_field by)"
  else echo "(Tares does not say since when)"; fi
}
# Said once for each distinct kit newer than the frozen one, and again only for another change.
tell_newer_kit() { # live kit
  local was; was=$(frozen_field version)
  [ -n "$was" ] || return 0
  if [ "$1" = "$was" ]; then rm -f "$NEWER_FILE"; return 0; fi
  [ "$(cat "$NEWER_FILE" 2>/dev/null)" != "$1" ] || return 0
  say "kit $1 is newer than the frozen kit $was; not moving stations ($ME crew unfreeze lets them follow it)"
  event kit-newer "" "$1"
  echo "$1" > "$NEWER_FILE"
}
# The frozen copy goes once no station runs from it; until then a station that does would lose its hooks.
# A freeze under way (its marker, ignored once 5 minutes old) has the copy before Tares has the setting.
freezing() { [ -n "$(find "$FREEZING" -mmin -5 2>/dev/null)" ]; }
reap_frozen_copy() {
  [ -d "$FROZEN_KIT" ] && ! frozen_copy_in_use && ! freezing || return 0
  rm -rf "$FROZEN_KIT" "$NEWER_FILE" "$CC_NEWER_FILE" "$CHECKOUT_FILE"
}

freeze_fail() { # made-a-copy message: nothing of a freeze that did not happen stays behind
  rm -f "$FREEZING"; [ "$1" = 0 ] || rm -rf "$FROZEN_KIT" "$CHECKOUT_FILE"
  die "$2"
}
cmd_freeze() {
  require_crew
  SETTINGS=""; crew_settings >/dev/null || die "could not read the crew settings from Tares at $TARES$(no_key_hint)"
  if upgrades_frozen; then say "the kit is already frozen $(frozen_text); $ME crew unfreeze lifts it"; return 0; fi
  touch "$FREEZING"
  local made=0 version out
  if frozen_copy_in_use; then
    [ "$(kit_version "$FROZEN_KIT")" = "$(kit_version)" ] \
      || freeze_fail 0 "stations still run from the kit copy of an earlier freeze, and the checkout has changed since: wait until $ME crew watch has moved them ($ME crew status), then freeze again"
  else
    made=1
    rm -rf "$FROZEN_KIT.new"; mkdir -p "$FROZEN_KIT.new"
    cp -R "$KIT_PLUGIN/." "$FROZEN_KIT.new/" || { rm -rf "$FROZEN_KIT.new"; freeze_fail 0 "could not copy the kit to $FROZEN_KIT"; }
    rm -rf "$FROZEN_KIT"; mv "$FROZEN_KIT.new" "$FROZEN_KIT"
  fi
  echo "$KIT_PLUGIN" > "$CHECKOUT_FILE"
  version=$(kit_version "$FROZEN_KIT")
  out=$(tares_http PUT /api/crew/settings "$(jq -cn --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg by "${FACTORY_STATION:-$(id -un)}" --arg v "$version" \
    '{kit: {frozen: {at: $at, by: $by, version: $v}}}')") || freeze_fail $made "could not save the freeze on Tares at $TARES$(no_key_hint)"
  jq -e --arg v "$version" '.kit.frozen.version == $v' <<<"$out" >/dev/null \
    || freeze_fail $made "Tares at $TARES did not keep the freeze; it needs a newer version"
  rm -f "$FREEZING"
  SETTINGS=""; crew_settings >/dev/null || true
  rm -f "$NEWER_FILE" "$CC_NEWER_FILE"
  event freeze "" "$version"
  say "kit frozen $(frozen_text): stations that start or revive use this kit; running ones keep reading the checkout until they restart, and the watch moves none onto a newer kit. $ME crew unfreeze lifts it."
}

cmd_unfreeze() {
  require_crew
  SETTINGS=""; crew_settings >/dev/null || die "could not read the crew settings from Tares at $TARES$(no_key_hint)"
  upgrades_frozen || { say "the kit is not frozen"; return 0; }
  tares_http PUT /api/crew/settings '{"kit": {"frozen": null}}' >/dev/null || die "could not lift the freeze on Tares at $TARES$(no_key_hint)"
  SETTINGS=""
  rm -f "$NEWER_FILE" "$CC_NEWER_FILE"
  event unfreeze
  if frozen_copy_in_use; then
    say "kit unfrozen: the watch moves each station onto the checkout's kit when it's idle, and removes $FROZEN_KIT once none runs from it"
  else
    rm -rf "$FROZEN_KIT" "$CHECKOUT_FILE"
    say "kit unfrozen: the watch moves each station onto the checkout's kit when it's idle"
  fi
}

desk_json() { tares_http GET "/api/desk?state=open" 2>/dev/null | jq -c '.desk // []' || echo '[]'; }

desk_block() { # one desk item (json) → its question, options and recommendation, as the person reads it
  jq -r '"\(.label)\(if .blocking then " (blocks work)" else "" end)\(if .project_name then " · " + .project_name else "" end): \(.question)",
    (if (.options // []) | length > 0 then (.options | to_entries[] | "  \(["A","B","C","D","E","F","G","H"][.key] // (.key + 1 | tostring))) \(.value)") else empty end),
    "  recommended: \(.recommendation)\(if .why then ", since " + .why else "" end)",
    (if (.blocks // "") != "" then "  blocks: \(.blocks)" else empty end),
    (if (.assumptions // []) | length > 0 then "  confirms: \(.assumptions | join(", "))" else empty end),
    (if .command then "  \(.station // "a station") was refused: \(.command | gsub("\n"; " "))",
      "  allow it in Tares, on the project page or the Crew page: the watch restarts \(.station // "the station") with it" else empty end)' <<<"$1"
}

cmd_desk() { # the questions waiting for the person, in full
  require_crew
  local items n i; items=$(desk_json); n=$(jq length <<<"$items")
  [ "$n" -gt 0 ] || { echo "Nothing waits for you on the desk."; return; }
  for i in $(seq 0 $((n - 1))); do desk_block "$(jq -c ".[$i]" <<<"$items")"; echo; done
  echo "Answer in the orchestrator's session, one at a time: $ME crew attach orchestrator"
}

cmd_status() {
  require_crew
  adopt_unrecorded
  [ -f "$STATIONS" ] || { say "no stations yet; run $ME crew up"; return; }
  local agents; agents=$(active_json) || die "$UNREAD"
  # Stations keep the Tares they were started with. After `tares login` to another workspace,
  # say so instead of letting the person think the crew moved.
  local ran; ran=$(jq -r '.env.TARES_FACTORY_URL // empty' \
    "$CREW_HOME/stations/$(station_name orchestrator).settings.json" 2>/dev/null || true)
  [ -n "$ran" ] && [ "${ran%/}" != "$(tares_url)" ] && \
    say "the crew still runs against $ran, not $(tares_url): $ME crew down, then $ME crew up, to move it"
  local s old=0 kit running; kit=$(kit_version)
  running=$(jq '[.[] | select(.status == "active")] | length' "$STATIONS")
  if [ "$running" -gt 0 ]; then
    printf '%-30s %-12s %-14s %-8s %-8s %s\n' STATION ROLE MODEL STATE TEMPO "DOING / NEEDS"
  else echo "no stations running; $ME crew up starts them"; fi
  for s in $(jq -r 'to_entries[] | select(.value.status == "active") | .key' "$STATIONS"); do
    case "$(stations_get "$s" '.role')" in helper) ;; *)
      stale_kit "$s" "$kit" && [ ! -f "$CREW_HOME/stations/$s.done" ] && old=$((old + 1)) ;;
    esac
    local id role model effort st job tempo detail needs
    id=$(stations_get "$s" '.id'); role=$(stations_get "$s" '.role')
    model=$(stations_get "$s" '.model'); effort=$(stations_get "$s" '.effort')
    st=$(jq -r --arg id "$id" "$GONE $TURN"' .[] | select(.id == $id and (gone | not)) | turn' <<<"$agents" | head -1)
    job=$(job_state "$id"); tempo=$(jq -r '.tempo // "-"' <<<"$job")
    detail=$(jq -r '.detail // ""' <<<"$job"); needs=$(jq -r '.needs // ""' <<<"$job")
    [ -n "$needs" ] && detail="NEEDS: $needs"
    stuck_on_context "$s" && detail="STUCK on autocompact: $ME crew watch starts it fresh"
    if [ -n "$st" ] && [ -n "$(stations_get "$s" '.sessionId')" ] && [ ! -f "$CREW_HOME/stations/$s.done" ] && regranted "$s"; then
      if attached "$s"; then detail="NEW PERMISSIONS: restarted with them once you leave its session"
      else detail="NEW PERMISSIONS: the watch restarts it with them between turns"; fi
    fi
    printf '%-30s %-12s %-14s %-8s %-8s %s\n' "$s" "$role" "$model/$effort" "${st:-gone}" "$tempo" "${detail:0:70}"
  done
  active_json | jq -r "$GONE"' map(select(((.name // "") | test("^crew-|-build-")) and (gone | not))) | group_by(.name)[] | select(length > 1)
    | "TWO SESSIONS named \(.[0].name): \(map(.id) | join(", ")); stop the one stations.json does not list (factory roster)"' || true
  local frozen=0; upgrades_frozen && frozen=1
  if [ $old = 0 ]; then :
  elif [ $frozen = 1 ]; then echo "plugin: $old station(s) run an older version; the freeze holds them ($ME crew unfreeze lets the watch move each onto the checkout's)"
  else echo "plugin: $old station(s) run an older version; $ME crew watch moves each onto this one when it's idle"; fi
  local cc_target cc_old cc_retry=0 cc_s; cc_target=$(claude_version); cc_old=$(cc_behind "$cc_target" "$agents")
  for cc_s in $(jq -r 'to_entries[] | select(.value.status == "active") | .key' "$STATIONS" 2>/dev/null); do
    cc_retrying "$cc_s" "$cc_target" && older_than "$(station_cc "$cc_s" "$agents")" "$cc_target" && cc_retry=$((cc_retry + 1))
  done
  if [ "$cc_old" = 0 ]; then :
  elif [ $frozen = 1 ]; then echo "claude code: $cc_old station(s) run an older Claude Code than $cc_target; the freeze stops the watch moving them ($ME crew unfreeze lets it; $ME crew update does it now; one that restarts starts on $cc_target)"
  else echo "claude code: $cc_old station(s) run an older Claude Code than $cc_target; $ME crew watch moves each onto it when it's idle ($ME crew update does it now; $ME roster shows each version)$([ "$cc_retry" = 0 ] || echo "; $cc_retry retrying after coming back on an old spare")"; fi
  halted && echo "HALTED: $(jq -r .reason "$HALT_FILE" 2>/dev/null)"
  if [ $frozen = 1 ]; then
    echo "FROZEN: the kit $(frozen_text); stations that start or revive use it, running ones keep reading the checkout until they restart; the watch moves no station onto a newer kit$([ "$kit" = "$(frozen_field version)" ] || echo " (a newer kit is available)")"
  fi
  idle_wake_on && echo "idle_wake: on, the watch wakes a station when it has work and holds its tick (a liveness wake after 15, 20 or 30 minutes of quiet, then 30, then every 45)"
  pace_status_line
  local wp; if wp=$(watch_pid); then echo "watch: running (pid $wp), it revives any station that stops and gives one a permission you allowed in Tares; log in $WATCH_LOG"
  else echo "watch: not running; $ME crew up starts it (or run $ME crew watch yourself)"; fi
  local items n; items=$(desk_json)
  # a finished project's questions only matter to a crew that is still running (TR-559)
  [ "$running" -gt 0 ] || items=$(jq -c 'map(select(.project_finished | not))' <<<"$items")
  n=$(jq length <<<"$items")
  if [ "$n" -gt 0 ]; then
    echo "desk: $n waiting for you; the next one ($ME crew desk shows all; answer in the orchestrator's session):"
    desk_block "$(jq -c '.[0]' <<<"$items")" | sed 's/^/  /'
  else echo "desk: nothing waits for you"; fi
  crew_projects | jq -r '.[] | "project \(.name): \(.tickets_done)/\(.tickets_total) tickets done\(if .milestone then ", on \(.milestone.name) \(.milestone.done)/\(.milestone.total)" elif .tickets_total > 0 then ", all done" else ", no tickets yet" end)"'
  echo "Crew page: $TARES/crew"
}

# Stations clean up through this rather than rm: Claude Code's delete check stops a session on some rm
# commands until a person approves (lesson 21). It deletes only inside the crew's own folders, removes a
# worktree through git, and removes a link itself, never what it points at.
tidy_roots() {
  printf '%s\n' "$CREW_HOME/worktrees" "$CREW_HOME/tmp"
  [ -z "${FACTORY_WORKTREES:-}" ] || printf '%s\n' "$FACTORY_WORKTREES"
  [ -z "${FACTORY_REPO:-}" ] || printf '%s\n' "$FACTORY_REPO/.factory/worktrees"
  [ -z "${CLAUDE_JOB_DIR:-}" ] || printf '%s\n' "$CLAUDE_JOB_DIR/tmp"
  { tares_on && all_repos; } 2>/dev/null | sed 's|$|/.factory/worktrees|' || true
}

inside_tidy_roots() { # resolved path → 0 when it is strictly inside one of the roots
  local r rr home top
  home=$(CDPATH= cd -P -- "$HOME" 2>/dev/null && pwd) || home=$HOME
  top=$(CDPATH= cd -P -- "$CREW_HOME" 2>/dev/null && pwd) || top=$CREW_HOME
  while IFS= read -r r; do
    [ -n "$r" ] && [ ! -L "$r" ] || continue   # a root that is a link could point anywhere
    rr=$(CDPATH= cd -P -- "$r" 2>/dev/null && pwd) || continue
    case "$rr" in /|"$home") continue ;; esac
    case "$top/" in "$rr"/*) continue ;; esac   # never a folder that holds the crew home
    case "$1" in "$rr"/*) return 0 ;; esac
  done < <(tidy_roots)
  return 1
}

worktree_has_work() { # worktree → 0 when it has uncommitted changes or new files, or commits no remote has
  [ -n "$(git --no-optional-locks -C "$1" status --porcelain 2>/dev/null)" ] && return 0
  [ -n "$(git -C "$1" log --oneline -1 HEAD --not --remotes 2>/dev/null)" ]
}

cmd_tidy() { # [--force] path...: --force also removes a worktree with uncommitted or unpushed work
  local force=0 p dir real common
  local -a paths=()
  for p in "$@"; do case "$p" in --force) force=1 ;; *) paths+=("$p") ;; esac; done
  [ ${#paths[@]} -gt 0 ] || die "usage: factory tidy [--force] <worktree or scratch folder>..."
  for p in "${paths[@]}"; do
    while [ "${p%/}" != "$p" ] && [ "$p" != / ]; do p=${p%/}; done
    if [ -L "$p" ] || [ -f "$p" ]; then
      dir=$(CDPATH= cd -P -- "$(dirname -- "$p")" 2>/dev/null && pwd) || die "$p: its folder doesn't exist"
      real="$dir/$(basename -- "$p")"
    elif [ -d "$p" ]; then
      real=$(CDPATH= cd -P -- "$p" && pwd)
    else
      say "$p is already gone"; continue
    fi
    [ -e "$real" ] || [ -L "$real" ] || { say "$p is already gone"; continue; }
    inside_tidy_roots "$real" || die "$p is outside the crew's folders. factory tidy removes only inside $CREW_HOME/worktrees, $CREW_HOME/tmp, the projects' .factory/worktrees and your job's scratch folder."
    if [ -L "$real" ] || [ -f "$real" ]; then
      rm -f -- "$real"
    elif [ -f "$real/.git" ] && common=$(git -C "$real" rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then
      if [ $force = 0 ] && worktree_has_work "$real"; then
        die "$p has uncommitted changes or commits no remote has. If it's your own worktree and the work is done (merged, or a review you've finished), run factory tidy --force $p"
      fi
      git --git-dir="$common" worktree remove --force "$real" \
        || die "git could not remove the worktree $p; if it's locked, git worktree list shows why"
    else
      rm -rf -- "$real"
    fi
    say "removed $p"
  done
}

# ---------- what the crew spends ----------
# Each station's status line reports its context window and the plan's limits to Tares as it works.
# Two readings need Claude Code itself, so they run here on the crew's machine and go to Tares the
# same way: /context of a station (the window by category) and /usage (every limit of the plan).
# Neither makes a model call. Hooks and the status line are off for them: a reading is not work.

read_quiet() { # cwd settings-file claude-args... → the stream-json lines Claude Code prints
  local cwd=$1 settings=$2; shift 2
  (cd "$cwd" && NO_COLOR=1 "$CLAUDE" -p --no-session-persistence --settings "$settings" \
    --output-format stream-json --verbose "$@" </dev/null 2>/dev/null) || true
}

send_meter() { # json line → Tares's claude_code source
  tares_http POST /ingest/claude_code "$1" >/dev/null
}

cmd_context() { # station: read its /context and send it to Tares
  require_crew
  local s; s=$(station_name "${1:?usage: $ME context <role|station>}")
  local id sid cwd settings tmp out
  id=$(stations_get "$s" '.id'); [ -n "$id" ] || die "unknown station $s"
  sid=$(job_state "$id" | jq -r '.sessionId // empty'); [ -n "$sid" ] || sid=$(stations_get "$s" '.sessionId')
  [ -n "$sid" ] || die "$s has no conversation to read yet"
  cwd=$(stations_get "$s" '.cwd'); settings=$(stations_get "$s" '.settings')
  # the station's own settings and plugin, so the tools and skills counted are the ones it loads
  tmp=$(mktemp)
  if [ -f "$settings" ]; then jq '. + {disableAllHooks: true} | del(.statusLine)' "$settings" > "$tmp"
  else echo '{"disableAllHooks": true}' > "$tmp"; fi
  local -a flags=(); local f; while IFS= read -r f; do [ -n "$f" ] && flags+=("$f"); done < <(plugin_flags)
  # a running session is read through a copy (--fork-session), which is never saved
  out=$(read_quiet "${cwd:-$CREW_HOME}" "$tmp" "${flags[@]}" --resume "$sid" --fork-session "/context" \
    | jq -cR 'fromjson? | select(.context_usage) | .context_usage' | tail -1) || true
  rm -f "$tmp"
  [ -n "$out" ] || die "Claude Code gave no /context reading for $s (session $sid)"
  send_meter "$(jq -cn --arg s "$sid" --argjson c "$out" \
    '{type: "session_context", sessionId: $s, timestamp: (now | todate), context_usage: $c}')" \
    || die "could not send the reading of $s to Tares"
  jq -r --arg s "$s" '"\($s): \(.total_tokens) of \(.raw_max_tokens) tokens (\(.percentage)%), sent to Tares"' <<<"$out"
}

cmd_plan() { # read the plan's limits (/usage) and send them to Tares
  local out
  out=$(read_quiet "$( [ -d "$CREW_HOME" ] && echo "$CREW_HOME" || pwd)" '{"disableAllHooks": true}' "/usage" \
    | jq -cR 'fromjson? | select(.usage_report) | .usage_report' | tail -1) || true
  [ -n "$out" ] || die "Claude Code gave no /usage report (it reads one for a Pro or Max login only)"
  send_meter "$(jq -cn --argjson u "$out" '{type: "claude_plan", timestamp: (now | todate), usage_report: $u}')" \
    || die "could not send the plan's limits to Tares"
  jq -r '.rate_limits.limits[]? | "\(.kind): \(.percent)% used, resets \(.resets_at)"' <<<"$out"
}

# The watch, between its rounds: answer the console's asks for a station's /context every few
# seconds, and read the plan's limits every FACTORY_PLAN_EVERY seconds (0: never).
PLAN_READ_AT=0; PLAN_OK=1
serve_readings() {
  local every=${FACTORY_PLAN_EVERY:-600} now; now=$(date +%s)
  if [ "$every" -gt 0 ] && [ $((now - PLAN_READ_AT)) -ge "$every" ]; then
    PLAN_READ_AT=$now
    # said once, not every round: with an API key there is no plan to read
    if ( cmd_plan >/dev/null 2>&1 ); then PLAN_OK=1
    elif [ $PLAN_OK = 1 ]; then PLAN_OK=0; say "could not read the plan's limits ($ME plan says why); trying again every ${every}s"; fi
  fi
  local s
  for s in $(tares_http GET /api/crew/context/asks 2>/dev/null | jq -r '.asks[]?.station' 2>/dev/null); do
    ( cmd_context "$s" >/dev/null ) || say "could not read the context of $s"
  done
}
nap() { # seconds: wait them out, serving readings meanwhile
  local until=$(( $(date +%s) + $1 ))
  while [ "$(date +%s)" -lt "$until" ]; do
    serve_readings
    sleep "${FACTORY_ASK_POLL:-5}"
  done
}

cmd_roster() { # compact, for stations
  require_crew
  adopt_unrecorded
  [ -f "$STATIONS" ] || { echo "no stations"; return; }
  local agents; agents=$(active_json) || die "$UNREAD"
  # the fifth column is the Claude Code each station's process runs, flagged when it is older than the one installed
  local target s v row rows out=""; target=$(claude_version)
  rows=$(jq -r --argjson a "$agents" "$GONE $TURN"' to_entries[] | select(.value.status == "active") |
    "\(.key)\t\(.value.role)\t\(.value.project // "")\t\((.value.id) as $id | ([$a[] | select(.id == $id and (gone | not)) | turn][0] // "gone"))"' "$STATIONS")
  while IFS= read -r row; do
    [ -n "$row" ] || continue
    s=${row%%$'\t'*}
    v=$(station_cc "$s" "$agents")
    if older_than "$v" "$target"; then
      v="$v (older than $target"
      if cc_retrying "$s" "$target"; then v="$v, retrying in $(cc_retry_in "$s")"; fi
      v="$v)"
    fi
    out="$out$row"$'\t'"$v"$'\n'
  done <<<"$rows"
  printf '%s' "$out"   # in one write, so a reader that stops early never breaks a pipe
}

cmd_attach() { require_crew; local id; id=$(stations_get "$(station_name "${1:?usage: $ME crew attach <role|station>}")" '.id'); [ -n "$id" ] || die "unknown station $1"; exec "$CLAUDE" attach "$id"; }
cmd_logs() { require_crew; local id; id=$(stations_get "$(station_name "${1:?usage: $ME crew logs <role|station>}")" '.id'); [ -n "$id" ] || die "unknown station $1"; exec "$CLAUDE" logs "$id"; }

cmd_settings() { # station [role]: write and print the settings a station launches with
  require_crew
  local station; station=$(station_name "${1:?usage: $ME settings <station> [role]}")
  local role=${2:-$(stations_get "$station" '.role')}
  [ -n "$role" ] || role=${station#crew-}
  cat "$(write_settings "$station" "$role" "$(stations_get "$station" '.parent')" "$(stations_get "$station" '.project')" "$(stations_get "$station" '.repo')")"
}

cmd_crew() {
  local sub=${1:-status}; shift || true
  case "$sub" in
    up) cmd_crew_up "$@" ;;
    down) cmd_down ;;
    status) cmd_status ;;
    desk) cmd_desk ;;
    attach) cmd_attach "$@" ;;
    logs) cmd_logs "$@" ;;
    watch) cmd_watch "$@" ;;
    halt) cmd_halt "$@" ;;
    resume) cmd_resume ;;
    freeze) cmd_freeze ;;
    unfreeze) cmd_unfreeze ;;
    update) cmd_crew_update "$@" ;;
    restart) cmd_crew_update --force "$@" ;;
    *) die "$ME crew takes up, down, status, desk, attach, logs, watch, halt, resume, freeze, unfreeze, update or restart" ;;
  esac
}

# ---------- projects ----------

trusted() { # path → 0 when Claude Code trusts it; a linked worktree inherits its main repo's trust
  local p=$1 main
  jq -e --arg r "$p" '.projects[$r].hasTrustDialogAccepted == true' "$HOME/.claude.json" >/dev/null 2>&1 && return 0
  main=$(git -C "$p" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || return 1
  main=$(dirname "$main")
  [ "$main" != "$p" ] && jq -e --arg r "$main" '.projects[$r].hasTrustDialogAccepted == true' "$HOME/.claude.json" >/dev/null 2>&1
}

has_commit() { git -C "$1" rev-parse -q --verify HEAD >/dev/null 2>&1; }

repos_in() { # folder → each git repo directly beneath it that has a commit, one path per line
  local d
  for d in "$1"/*/; do
    d=${d%/}; [ -d "$d" ] || continue
    if [ "$(git -C "$d" rev-parse --show-toplevel 2>/dev/null)" = "$(cd "$d" && pwd -P)" ] && has_commit "$d"; then printf '%s\n' "$d"; fi
  done
  return 0
}

# The spec runs in the person's own Claude Code session, in the repo the project builds on or in a
# folder that holds its repos, with /tares:spec, which writes the plan on Tares and ends with the
# hand-over to the crew. Nothing is written into the repos.
cmd_spec() {
  local idea="" name=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --project) name=${2:?usage: $ME spec [--project <name>] [\"<idea>\"]}; shift 2 ;;
      --) shift; idea="$*"; break ;;
      -*) die "unknown option $1" ;;
      *) idea="${idea:+$idea }$1"; shift ;;
    esac
  done
  require_mcp
  local clash; clash=$(installed_tares)
  [ -z "$clash" ] || say "warning: $(clash_text "$clash")"
  local here root list n
  here=$(pwd -P)
  root=$(git rev-parse --show-toplevel 2>/dev/null || true)
  if [ -n "$root" ]; then
    has_commit "$root" || die "$root has no commit yet. Make a first commit and push it, so builders can branch from it."
    here=$root
    say "planning in the repo $root"
  else
    list=$(repos_in "$here"); n=$(grep -c . <<<"$list" || true)
    [ "$n" -gt 0 ] || die "run $ME spec inside the git repo the project builds on, or in a folder that holds its repos"
    say "planning in $here, which holds $n repo(s): $(sed 's#.*/##' <<<"$list" | paste -sd ',' - | sed 's/,/, /g')"
  fi
  local slug; slug=$(slugify "${name:-$(tr -s '[:space:]' ' ' <<<"$idea" | sed 's/^ //' | cut -d' ' -f1-4)}" | cut -c1-40 | sed 's/-$//')
  [ -n "$slug" ] || slug=$(slugify "$(basename "$here")")
  cat >&2 <<SPEC
$ME: opening Claude as $slug-spec in $here, with /tares:spec.
         It interviews you, one question at a time, and writes the spec, milestones and tickets
         into a project on Tares ($TARES). It ends by asking whether to hand the project to the crew.
SPEC
  local prompt="/tares:spec"
  [ -n "$name" ] && prompt="$prompt (project name: $name)"
  [ -n "$idea" ] && prompt="$prompt $idea"
  person_claude "$here" "$slug-spec" spec "$prompt"
}

# The person's own Claude Code session, in the foreground, with this plugin turned on (its own
# --settings, as for stations), told the crew's Tares and MCP endpoint, and the token as a file.
person_claude() { # cwd session-name settings-name prompt [VAR=value...]
  local cwd=$1 sname=$2 sfile=$3 prompt=$4; shift 4
  local -a args=(-n "$sname")
  local f; while IFS= read -r f; do [ -n "$f" ] && args+=("$f"); done < <(plugin_flags)
  if [ -n "${TARES_AUTH_TOKEN:-}" ]; then
    mkdir -p "$CREW_HOME"; keep_token
    # CRED_FILE: Claude Code hides variables named *TOKEN* from the plugin's header helper
    export TARES_FACTORY_TOKEN_FILE=$TOKEN_FILE TARES_FACTORY_CRED_FILE=$TOKEN_FILE
  fi
  mkdir -p "$CREW_HOME/stations"
  local settings="$CREW_HOME/stations/$sfile.settings.json"
  jq -n '{enabledPlugins: {"tares@inline": true}}' > "$settings"
  args+=(--settings "$settings")
  local mcp; mcp=$(mcp_url)
  cd "$cwd" && exec env TARES_FACTORY_URL="$TARES" TARESD_URL="$TARES" TARES_FACTORY_MCP_URL="$mcp" "$@" \
    "$CLAUDE" "${args[@]}" "$prompt"
}

# ---------- a project in one session (TR-520) ----------
# `session <project>` works on a planned project in the person's own session, bound to it; when the
# crew holds it, the person may take it back (the orchestrator stops its builders gracefully).
# `session "<idea>"` plans it lightly and builds it in the same session. No argument: ask which.

factory_projects() { # → [{id, name, status}] of every software factory project on Tares
  tares_http GET /api/projects 2>/dev/null | jq -c '[(.projects // [])[] | select(.kind == "software_factory")
    | {id, name, status: (.status // "active")}]' 2>/dev/null || echo '[]'
}
releasing_projects() { tares_http GET /api/crew/projects 2>/dev/null | jq -c '.releasing // []' 2>/dev/null || echo '[]'; }
match_project() { # name-or-id, json list → the matching row, or nothing
  jq -c --arg p "$1" --arg s "$(slugify "$1")" '[.[] | select(.name == $p or .id == $p
    or ((.name | ascii_downcase | gsub("[^a-z0-9]+"; "-") | gsub("^-|-$"; "")) == $s))][0] // empty' <<<"$2"
}

session_dir() { # project id → the folder its session opens in: the repo you are in when it is one of
  # the project's, else its first repo that is on this machine, else here
  local here root repos p
  here=$(pwd -P); root=$(git rev-parse --show-toplevel 2>/dev/null || true)
  repos=$(tares_http GET "/api/projects/$1/crew" 2>/dev/null | jq -r '.handover.repos // [] | .[].path // empty' 2>/dev/null || true)
  if [ -n "$root" ] && grep -qxF "$root" <<<"$repos"; then echo "$root"; return; fi
  while IFS= read -r p; do [ -n "$p" ] && [ -d "$p" ] && { echo "$p"; return; }; done <<<"$repos"
  echo "${root:-$here}"
}

session_project() { # project row (json)
  local row=$1 id name slug taken="" crew rel words out code tok
  id=$(jq -r .id <<<"$row"); name=$(jq -r .name <<<"$row")
  crew=$(project_row "$name")
  rel=$(match_project "$name" "$(releasing_projects)")
  if [ -n "$crew" ]; then
    echo "$name is with the crew: $(jq -r '"\(.tickets_done) of \(.tickets_total) tickets done"
      + (if .milestone then ", on \(.milestone.name)" else "" end)' <<<"$crew")." >&2
    local take; take=$(choose "Take it over? The crew stops working on it: its builders push what they have and write their progress, then this session carries on from there." 2 \
      "yes|yes, take it over and build it in this session" "no|no, leave it with the crew")
    if [ "$take" != yes ]; then
      echo "Left with the crew. Talk to it with: $ME crew attach orchestrator" >&2
      return 0
    fi
    words=""
    if [ "$ASSUME_YES" != 1 ]; then
      printf '\nIn a few words, why (kept on Tares with the takeover; Enter to skip)\n> ' >&2
      IFS= read -r words || words=""
    fi
    tok=$(tares_token)
    out=$({ [ -n "$tok" ] && printf 'Authorization: Bearer %s\n' "$tok"; true; } | curl -sS --max-time 15 -H @- \
      -H 'Content-Type: application/json' -X POST --data "$(jq -cn --arg w "$words" '{words: $w}')" \
      -w '\n%{http_code}' "$TARES/api/projects/$id/takeover" 2>&1) || die "could not reach Tares at $TARES"
    code=$(tail -1 <<<"$out"); out=$(sed '$d' <<<"$out")
    [ "$code" = 200 ] || die "Tares did not record the takeover of $name: $(jq -r '.detail // .' <<<"$out" 2>/dev/null || echo "$out")"
    taken=1
    echo "Taken over. The orchestrator stops the builders of $name; the session waits for it, then carries on." >&2
  elif [ -n "$rel" ]; then
    taken=1
    echo "$name was taken over from the crew; the session checks its builders have stopped, then carries on." >&2
  fi
  slug=$(slugify "$name" | cut -c1-40 | sed 's/-$//')
  local prompt="/tares:session $name"
  [ -n "$taken" ] && prompt="$prompt (taken over from the crew)"
  person_claude "$(session_dir "$id")" "$slug-session" session "$prompt" \
    TARES_PROJECT="$id" TARES_PROJECT_NAME="$name" TARES_SESSION_TAKEOVER="${taken:-}" TARES_SESSION_NAME="$slug-session"
}

session_idea() { # idea
  local idea=$1 slug here root
  here=$(pwd -P); root=$(git rev-parse --show-toplevel 2>/dev/null || true)
  slug=$(slugify "$(tr -s '[:space:]' ' ' <<<"$idea" | sed 's/^ //' | cut -d' ' -f1-4)" | cut -c1-40 | sed 's/-$//')
  cat >&2 <<SESSION
$ME: opening Claude as ${slug:-new}-session in ${root:-$here}.
         It plans "$idea" lightly into a new project on Tares ($TARES) and builds it in the same
         session; if it turns out big, it offers the full spec and the hand-over to the crew.
SESSION
  person_claude "${root:-$here}" "${slug:-new}-session" session "/tares:session new: $idea" \
    TARES_SESSION_NAME="${slug:-new}-session"
}

cmd_session() {
  [ -z "${FACTORY_STATION:-}" ] || die "only the person runs this, in their own terminal"
  local target="" new=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --new) new=1; shift ;;
      --) shift; target="${target:+$target }$*"; break ;;
      -*) die "unknown option $1" ;;
      *) target="${target:+$target }$1"; shift ;;
    esac
  done
  require_mcp
  local clash; clash=$(installed_tares)
  [ -z "$clash" ] || say "warning: $(clash_text "$clash")"
  # a project the crew holds must never look free: stop when Tares can't say which it holds
  crew_projects_or_die
  local all row
  all=$(tares_http GET /api/projects 2>&1) || die "Tares at $TARES did not list its projects ($(tail -1 <<<"$all"))"
  all=$(factory_projects)
  if [ $new = 1 ]; then
    [ -n "$target" ] || die "usage: $ME session --new \"<what you want to build>\""
    session_idea "$target"; return
  fi
  if [ -n "$target" ]; then
    row=$(match_project "$target" "$all")
    if [ -n "$row" ]; then session_project "$row"; return; fi
    if [[ "$target" != *" "* ]]; then
      local yn; yn=$(choose "No project named $target on Tares. Plan and build \"$target\" as a new project?" 2 \
        "yes|yes, a new project" "no|no, I meant an existing one")
      [ "$yn" = yes ] || die "the open projects: $(jq -r '[.[] | select(.status != "done") | .name] | join(", ")' <<<"$all")"
    fi
    session_idea "$target"; return
  fi
  # no argument: the open projects, or something new
  local open crew rel; open=$(jq -c '[.[] | select(.status != "done")]' <<<"$all")
  crew=$(crew_projects); rel=$(releasing_projects)
  local -a opts=(); local p n=0
  while IFS= read -r p; do
    [ -n "$p" ] || continue
    local nm mark=""; nm=$(jq -r .name <<<"$p")
    [ -n "$(match_project "$nm" "$crew")" ] && mark=" (with the crew)"
    [ -n "$(match_project "$nm" "$rel")" ] && mark=" (taken over, being released)"
    opts+=("$(jq -r .id <<<"$p")|$nm$mark"); n=$((n + 1))
  done < <(jq -c '.[]' <<<"$open")
  opts+=("+new|something new")
  local pick; pick=$(choose "Which project?" 1 "${opts[@]}")
  if [ "$pick" = "+new" ]; then
    local idea=""
    [ "$ASSUME_YES" = 1 ] && die "say what to build: $ME session \"<idea>\""
    while [ -z "$idea" ]; do printf '\nWhat do you want to build?\n> ' >&2; IFS= read -r idea || die "nothing to build"; done
    session_idea "$idea"; return
  fi
  session_project "$(match_project "$pick" "$all")"
}

cmd_finish() { # project ["words"]: the person marks a project done, from their own terminal
  [ -z "${FACTORY_STATION:-}" ] || die "only the person runs this. As a station, ask the person on the desk and call finish_project with their answer."
  local name=${1:?usage: $ME finish <project> [\"your words\"]}; shift
  local words="$*" row id out
  row=$(project_row "$name"); [ -n "$row" ] || die "$name is not a project the crew holds (open projects: $(crew_projects | jq -r '[.[].name] | join(", ")'))"
  id=$(jq -r .id <<<"$row")
  local code tok; tok=$(tares_token)
  out=$({ [ -n "$tok" ] && printf 'Authorization: Bearer %s\n' "$tok"; true; } | curl -sS --max-time 15 -H @- \
    -H 'Content-Type: application/json' -X POST --data "$(jq -cn --arg w "$words" '{words: $w}')" \
    -w '\n%{http_code}' "$TARES/api/projects/$id/finish" 2>&1) || die "could not reach Tares at $TARES"
  code=$(tail -1 <<<"$out"); out=$(sed '$d' <<<"$out")
  [ "$code" = 200 ] || die "Tares did not finish $name: $(jq -r '.detail // .' <<<"$out" 2>/dev/null || echo "$out")"
  echo "✓ $(jq -r .name <<<"$row") is done. It is off the crew's work list; $TARES/projects/$id shows it."
  if [ "$(jq -r .open_projects <<<"$out")" = 0 ]; then
    echo "  The crew holds no other project. Stop it with: $ME crew down"
  fi
}

cmd_projects() { # every project handed to the crew: progress, milestone, builders, repos
  local rows; rows=$(crew_projects)
  [ "$(jq length <<<"$rows")" -gt 0 ] || { echo "no projects handed to the crew yet: $ME spec in a repo starts one"; return; }
  printf '%-28s %-8s %-22s %-9s %s\n' PROJECT DONE MILESTONE BUILDERS REPOS
  local p
  while IFS= read -r p; do
    local name done ms builders repos
    name=$(jq -r .name <<<"$p")
    done="$(jq -r '"\(.tickets_done)/\(.tickets_total)"' <<<"$p")"
    ms=$(jq -r 'if .milestone then "\(.milestone.name) \(.milestone.done)/\(.milestone.total)" elif .tickets_total > 0 then "all done" else "-" end' <<<"$p")
    builders=$(jq -r --arg n "$name" '[.[] | select(.role == "builder" and .project == $n and .status == "active")] | length' "$STATIONS" 2>/dev/null || echo 0)
    repos=$(jq -r '[.repos[] | (.name // .path)] | join(", ")' <<<"$p")
    printf '%-28s %-8s %-22s %-9s %s\n' "${name:0:28}" "$done" "${ms:0:22}" "$builders" "$repos"
  done < <(jq -c '.[]' <<<"$rows")
}

# ---------- code intelligence (TR-682) ----------
# Claude Code's LSP tool (definitions, references, callers) answers only where a language server is wired
# in, and only a plugin wires one. Per language: the manifests that show it, the official plugin, the
# server binary the plugin needs on PATH, and the command that installs that binary. One server can serve
# two languages (typescript-language-server reads JavaScript too): a repo is listed once per plugin, by the
# first language that shows it, so tsconfig.json says TypeScript and a package.json alone says JavaScript.
LSP_MARKETPLACE=claude-plugins-official
lsp_langs() { # language|manifest file names|plugin|binary|install command
  cat <<'EOF'
Python|pyproject.toml,setup.py,setup.cfg,Pipfile,requirements.txt|pyright-lsp|pyright-langserver|npm install -g pyright
TypeScript|tsconfig.json|typescript-lsp|typescript-language-server|npm install -g typescript-language-server typescript
JavaScript|package.json|typescript-lsp|typescript-language-server|npm install -g typescript-language-server typescript
Go|go.mod|gopls-lsp|gopls|go install golang.org/x/tools/gopls@latest
Rust|Cargo.toml|rust-analyzer-lsp|rust-analyzer|rustup component add rust-analyzer
EOF
}

lsp_detect() { # repo → the languages its manifests show (at the root or one folder down), one per line
  local lang names plugin _b _i f seen=" "
  while IFS='|' read -r lang names plugin _b _i; do
    case "$seen" in *" $plugin "*) continue ;; esac
    local -a match=()
    for f in ${names//,/ }; do [ ${#match[@]} = 0 ] || match+=(-o); match+=(-name "$f"); done
    [ -n "$(find "$1" -maxdepth 2 \( -name node_modules -o -name .git -o -name .factory -o -name .venv -o -name venv \) -prune \
      -o -type f \( "${match[@]}" \) -print -quit 2>/dev/null)" ] && { echo "$lang"; seen="$seen$plugin "; }
  done < <(lsp_langs)
  return 0
}

LSP_ENABLED=""   # "<id>\t<projectPath>" of each enabled plugin; LSP_LISTED=1 once read, 0 when claude could not list them
lsp_read_plugins() {
  [ -z "${LSP_LISTED:-}" ] || return 0
  if LSP_ENABLED=$("$CLAUDE" plugin list --json 2>/dev/null | jq -r '.[] | select(.enabled == true) | [.id, (.projectPath // "")] | @tsv' 2>/dev/null); then LSP_LISTED=1
  else LSP_ENABLED=""; LSP_LISTED=0; fi
}

lsp_have_bin() { PATH="${FACTORY_LSP_PATH:-$PATH}" command -v "$1" >/dev/null 2>&1; }   # FACTORY_LSP_PATH: look in these folders instead of PATH (tests)

lsp_state() { # repo plugin binary → ready | no-plugin | no-binary | neither | unknown (claude could not list its plugins)
  lsp_read_plugins
  [ "$LSP_LISTED" = 1 ] || { echo unknown; return 0; }
  local have_plugin=0 have_bin=0
  # Only a user-scope plugin (no projectPath) counts: stations run in the crew home and in worktrees, never in the repo path itself.
  awk -F'\t' -v id="$2@$LSP_MARKETPLACE" '$1 == id && $2 == "" { f = 1 } END { exit !f }' <<<"$LSP_ENABLED" && have_plugin=1
  lsp_have_bin "$3" && have_bin=1
  case "$have_plugin$have_bin" in 11) echo ready ;; 10) echo no-binary ;; 01) echo no-plugin ;; *) echo neither ;; esac
}

lsp_fix() { # state plugin binary install → the commands that make the language server work, one per line
  case "$1" in no-binary|neither) echo "$4" ;; esac
  case "$1" in no-plugin|neither) echo "claude plugin install $2@$LSP_MARKETPLACE" ;; esac
}

lsp_missing() { # → "language|plugin|binary|install|state|repo" for each repo language with no working server
  local project repo lang plugin bin inst state _n _m
  lsp_read_plugins
  while IFS=$'\t' read -r project repo; do
    [ -d "$repo" ] || continue
    while IFS= read -r lang; do
      IFS='|' read -r _n _m plugin bin inst < <(lsp_langs | awk -F'|' -v l="$lang" '$1 == l')
      state=$(lsp_state "$repo" "$plugin" "$bin")
      case "$state" in ready|unknown) ;; *) printf '%s|%s|%s|%s|%s|%s\n' "$lang" "$plugin" "$bin" "$inst" "$state" "$repo" ;; esac
    done < <(lsp_detect "$repo")
  done < <(project_repo_rows 2>/dev/null || true)
}

# cmux shows its own "Waiting for you" notice about a minute after any Claude Code turn ends (its
# notifications.agentIdleReminder, on by default). Claude Code has no setting that drops only its idle
# notice, and cmux reads this option globally from the person's own file, so doctor reads it and says
# what to set; the factory never writes it (TR-694).
cmux_config() { echo "$HOME/.config/cmux/cmux.json"; }
cmux_idle_reminder() { # → on, off or unreadable (JSONC: whole-line comments and trailing commas are fine); empty when cmux has no config here
  local f; f=$(cmux_config); [ -f "$f" ] || return 0
  grep -v '^[[:space:]]*//' "$f" | perl -0pe 's/,(\s*[}\]])/$1/g' | jq -r 'if .notifications.agentIdleReminder == false then "off" else "on" end' 2>/dev/null || echo unreadable
}
doctor_cmux() {
  case "$(cmux_idle_reminder)" in
    on) warn "cmux shows \"Waiting for you\" about a minute after every station's turn ends, with nothing for you to do. To stop it and keep the permission alerts, set \"notifications\": {\"agentIdleReminder\": false} in $(cmux_config) (it applies to all your cmux agents; $ME never edits it)" ;;
    off) ok "cmux's idle reminder is off, so a station between turns does not ask for you; permission alerts stay on" ;;
    unreadable) warn "could not parse $(cmux_config); set \"notifications\": {\"agentIdleReminder\": false} there so cmux stops saying \"Waiting for you\" after every station turn" ;;
  esac
  return 0
}

doctor_lsp() { # per handed-over repo and language: has Claude Code a language server (ok), or what to run (warn)
  local project repo lang plugin bin inst state _n _m cmds
  [ -n "$(project_repo_rows 2>/dev/null)" ] || return 0
  lsp_read_plugins
  while IFS=$'\t' read -r project repo; do
    [ -d "$repo" ] || continue
    while IFS= read -r lang; do
      IFS='|' read -r _n _m plugin bin inst < <(lsp_langs | awk -F'|' -v l="$lang" '$1 == l')
      state=$(lsp_state "$repo" "$plugin" "$bin")
      if [ "$state" = ready ]; then
        ok "project $project: $lang in $repo has a language server for Claude Code ($plugin plugin, $bin on PATH)"
      elif [ "$state" = unknown ]; then
        warn "project $project: $lang in $repo: cannot tell whether Claude Code has a language server, because claude plugin list --json failed"
      else
        cmds=$(lsp_fix "$state" "$plugin" "$bin" "$inst" | sed 's/.*/`&`/' | paste -sd ' ' - | sed 's/` `/` then `/')
        warn "project $project: $lang in $repo has no language server for Claude Code, so the LSP tool cannot answer for it ($(case "$state" in no-binary) echo "the $plugin plugin is on, $bin is not on PATH" ;; no-plugin) echo "$bin is installed, the $plugin plugin is not on" ;; *) echo "neither the $plugin plugin nor $bin is there" ;; esac)). Run $cmds; $ME crew up offers it"
      fi
    done < <(lsp_detect "$repo")
  done < <(project_repo_rows 2>/dev/null)
  return 0
}

lsp_offer() { # crew up: recommend the missing language servers of the handed-over repos; install only on a yes
  local missing; missing=$(lsp_missing)
  [ -n "$missing" ] || return 0
  local lang plugin bin inst state repo cmds
  {
    printf '\nCode intelligence: Claude Code can find a definition, its references and its callers through a language server,\nso builders and the reviewer search less and read less. These repos have none wired in yet:\n'
    while IFS='|' read -r lang plugin bin inst state repo; do
      printf '  %s in %s\n' "$lang" "$repo"
      lsp_fix "$state" "$plugin" "$bin" "$inst" | sed 's/^/      /'
    done <<<"$missing"
  } >&2
  if [ "$ASSUME_YES" = 1 ]; then
    say "not installing language servers without a yes: run the commands above yourself, or run crew up without --yes"
    return 0
  fi
  printf 'Run these now?\n  recommended: yes\n  press enter to take it, or type no\n> ' >&2
  local ans; IFS= read -r ans || ans=no   # no answer at all (a closed input) is a no
  case "${ans:-yes}" in
    y|Y|yes|Yes) ;;
    *) say "no language servers installed; $ME doctor lists what is missing"; return 0 ;;
  esac
  local seen=""
  while IFS='|' read -r lang plugin bin inst state repo; do
    case " $seen " in *" $plugin "*) continue ;; esac
    seen="$seen $plugin"
    case "$state" in no-binary|neither)
      say "installing $bin: $inst"
      bash -c "$inst" >&2 || { say "could not install $bin; install it yourself ($inst)"; continue; } ;;
    esac
    case "$state" in no-plugin|neither)
      say "installing the $plugin plugin"
      "$CLAUDE" plugin install "$plugin@$LSP_MARKETPLACE" >&2 || say "could not install the $plugin plugin; run: claude plugin install $plugin@$LSP_MARKETPLACE" ;;
    esac
  done <<<"$missing"
}

# Which key each place holds, by id, so a stale one shows (TR-560): this terminal's key against the
# saved login, then the crew file the stations read.
doctor_keys() {
  local login="${TARES_HOME:-$HOME/.tares}/login.json" saved="" mine=${TARES_AUTH_TOKEN:-} file=${TARES_FACTORY_TOKEN_FILE:-$TOKEN_FILE} theirs
  [ "$(jq -r '.cell_url // "" | rtrimstr("/")' "$login" 2>/dev/null)" = "$TARES" ] && saved=$(jq -r '.key // empty' "$login" 2>/dev/null)
  if [ -n "$mine" ]; then
    if [ "${TARES_FACTORY_KEY_SOURCE:-}" = environment ] && [ -n "$saved" ] && [ "$mine" != "$saved" ]; then
      no "TARES_AUTH_TOKEN in this terminal ($(key_name "$mine")) overrides your saved login ($(key_name "$saved")): unset TARES_AUTH_TOKEN, then run this again"
    else ok "this terminal uses $(key_name "$mine"), from the ${TARES_FACTORY_KEY_SOURCE:-environment}"; fi
  fi
  [ -d "$CREW_HOME" ] || return 0
  theirs=$(crew_key)
  if [ -z "$theirs" ]; then
    key_ok "" || no "the stations have no key in $file: run tares login (for a Tares of your own, run $ME doctor with TARES_AUTH_TOKEN set)"
  elif key_ok "$theirs"; then
    ok "stations use $(key_name "$theirs") from $file, and Tares accepts it"
    [ -z "$saved" ] || [ "$theirs" = "$saved" ] || warn "the stations' key is not your saved login's ($(key_name "$saved"))"
  else
    no "stations use $(key_name "$theirs") from $file, and Tares refuses it: run tares login"
  fi
}

doctor_kit() { # the freeze, and whether the checkout has a kit the crew is not following
  crew_settings >/dev/null 2>&1 || return 0
  if ! upgrades_frozen; then ok "the kit follows the plugin checkout (not frozen)"; return 0; fi
  local live; live=$(kit_version)
  if [ ! -d "$FROZEN_KIT" ]; then
    no "the kit is frozen $(frozen_text), but its copy $FROZEN_KIT is missing, so new starts use the checkout: $ME crew unfreeze, then $ME crew freeze"
  elif [ "$live" != "$(frozen_field version)" ]; then
    warn "the kit is frozen $(frozen_text), and the checkout has a newer kit that no station moves onto: $ME crew unfreeze lets them follow it"
  else ok "the kit is frozen $(frozen_text); the checkout has nothing newer"; fi
}

cmd_doctor() {
  local bad=0
  ok() { printf '  ok    %s\n' "$*"; }
  no() { printf '  FIX   %s\n' "$*"; bad=1; }
  warn() { printf '  warn  %s\n' "$*"; }
  echo "$ME doctor"
  ok "claude: $CLAUDE ($("$CLAUDE" --version 2>/dev/null | head -1))"
  ok "crew home: $CREW_HOME"
  local src=${TARES_FACTORY_SOURCE:-environment}
  if tares_on; then
    ok "Tares answers at $TARES (from the $src)"
    if tares_http GET /api/crew/settings >/dev/null 2>&1; then ok "Tares lets this crew in"
    elif [ "$src" = "saved login" ]; then no "Tares at $TARES refuses the crew: run \`tares login\` again"
    else no "Tares at $TARES refuses the crew: run \`tares login\` again, or set TARES_AUTH_TOKEN to an admin key or the root token of your own Tares"; fi
    doctor_keys
  else
    no "no Tares answers at $TARES (from the $src): start one with \`tares up\`, connect to your Tares Cloud workspace with \`tares login\`, or set TARESD_URL to where yours runs"
  fi
  local list; if list=$(active_json); then
    if jq -e 'all(.[]; .kind == "interactive" or (has("id") and has("name") and has("cwd") and has("state")))' <<<"$list" >/dev/null; then
      ok "Claude Code's session list reads ($(jq length <<<"$list") active)"
    else no "claude agents --json lists sessions without a name, cwd or state; the watch can't tell which stations run"; fi
  else no "$UNREAD; the watch can't tell which stations run"; fi
  if jq -e --arg r "$CREW_HOME" '.projects[$r].hasTrustDialogAccepted == true' "$HOME/.claude.json" >/dev/null 2>&1; then
    ok "Claude Code trusts $CREW_HOME"
  else
    no "Claude Code has not trusted the crew home $CREW_HOME (the crew's own folder, whichever folder you run from; its stations run there). $ME crew up offers to open Claude Code there to trust it, or: mkdir -p $CREW_HOME && cd $CREW_HOME && claude, accept the trust prompt, /exit"
  fi
  local clash; clash=$(installed_tares)
  if [ -n "$clash" ]; then warn "$(clash_text "$clash")"
  else ok "stations load the plugin at $KIT_PLUGIN"; fi
  mcp_on && ok "MCP endpoint answers at $(mcp_url)" || no "no MCP server answers at $(mcp_url); start one with tares mcp --transport streamable-http (or set TARES_FACTORY_MCP_URL)"
  if [ -f "$CREW_CFG" ]; then
    local r m
    for r in orchestrator reviewer releaser builder; do
      m=$(ccfg ".stations.$r.model // empty")
      case "$m" in *haiku*) no "station $r uses $m: auto mode doesn't run on Haiku, use sonnet or opus" ;; esac
    done
    if tares_on; then
      local raw missing; raw=$(settings_raw 2>/dev/null || echo '{}'); missing=$(settings_missing "$raw")
      [ -z "$missing" ] && ok "crew settings on Tares: $(crew_settings | jq -c 'del(.kit)')" || warn "crew settings not answered yet ($(echo $missing)); $ME crew up asks them"
      doctor_kit
      if idle_wake_on; then
        if [ -n "$(watch_pid)" ]; then ok "idle_wake is on: the watch wakes stations when they have work and holds their ticks"
        else warn "idle_wake is on but the watch is not running: nothing wakes the stations and they hold no tick ($ME crew watch)"; fi
      fi
      if [ -z "$missing" ] && releaser_on && [ "$(setting release_profile)" = kind ]; then
        command -v docker >/dev/null && ok "docker" || no "docker not installed (release profile kind)"
        command -v kind >/dev/null && ok "kind" || no "kind not installed (release profile kind): brew install kind"
        command -v kubectl >/dev/null && ok "kubectl" || no "kubectl not installed (release profile kind)"
      fi
      [ "$(setting claude_update)" = false ] && warn "crew setting claude_update is off: the watch does not move stations onto a new Claude Code (one that restarts for another reason starts on it); $ME crew update moves them"
      [ -z "$missing" ] && [ "$(setting challenger)" = true ] && { command -v codex >/dev/null && ok "codex (challenger on)" || no "challenger is on but the Codex CLI is not installed"; }
      local p repo gh_line perm
      while IFS=$'\t' read -r p repo; do
        [ -n "$p" ] || continue
        [ -d "$repo" ] || { no "project $p: $repo does not exist on this machine"; continue; }
        if trusted "$repo"; then ok "project $p: $repo trusted"
        else no "project $p: Claude Code has not trusted $repo (its git root must be trusted itself); run \`claude\` there once"; fi
        if command -v gh >/dev/null; then
          gh_line=$(cd "$repo" 2>/dev/null && gh repo view --json nameWithOwner,viewerPermission -q '.nameWithOwner + " " + .viewerPermission' 2>/dev/null || true)
          perm=${gh_line##* }
          case "$perm" in
            ADMIN|MAINTAIN|WRITE) ok "project $p: $repo is ${gh_line% *} on GitHub ($perm)" ;;
            "") no "project $p: gh can't read the GitHub repo of $repo (no GitHub remote, or no access); builders open PRs there" ;;
            *) no "project $p: gh has $perm on ${gh_line% *}; builders need write access to push and merge" ;;
          esac
        fi
      done < <(project_repo_rows)
    fi
  else
    warn "no crew yet in $CREW_HOME: $ME crew up creates it"
  fi
  doctor_cmux
  tares_on && doctor_lsp
  if command -v gh >/dev/null; then
    gh auth status >/dev/null 2>&1 && ok "gh signed in" || no "gh not signed in: gh auth login"
  else
    no "gh not installed (needed for PRs, reviews and merges): brew install gh"
  fi
  if [ -f "$STATIONS" ] && [ -n "${list:-}" ]; then
    local cc_target cc_s cc_v; cc_target=$(claude_version)
    for cc_s in $(jq -r 'to_entries[] | select(.value.status == "active") | .key' "$STATIONS"); do
      [ -f "$CREW_HOME/stations/$cc_s.done" ] && continue
      cc_v=$(station_cc "$cc_s" "$list")
      if older_than "$cc_v" "$cc_target" && cc_retrying "$cc_s" "$cc_target"; then warn "$cc_s runs Claude Code $cc_v, older than the $cc_target installed, and keeps coming back on an old spare; the watch retries in $(cc_retry_in "$cc_s"), or $ME crew update now"
      elif older_than "$cc_v" "$cc_target"; then warn "$cc_s runs Claude Code $cc_v, older than the $cc_target installed; $ME crew update moves it when it's idle"
      elif [ -n "$cc_v" ]; then ok "$cc_s runs Claude Code $cc_v"
      else warn "could not tell which Claude Code $cc_s runs"; fi
    done
  fi
  if [ -f "$STATIONS" ]; then
    local id
    for id in $(jq -r '.[] | select(.status == "active") | .id' "$STATIONS"); do
      job_state "$id" | jq -e '.respawnFlags // [] | map(select(test("/var/folders/|/tmp/cmux|cmux-claude-settings"))) | length > 0' >/dev/null 2>&1 \
        && no "station job $id has temp paths in its saved launch flags; it will fail to respawn. $ME crew down + $ME crew up"
    done
  fi
  [ $bad = 0 ] && echo "all good" || { echo "fix the items marked FIX"; return 1; }
}


cmd=${1:-help}; shift || true
# Commands that only touch the sessions on this machine work while Tares is down: stopping the crew
# must never wait for Tares.
local_only() {
  case "$1" in
    tidy|_parse-bg-id|_mcp-url|help|doctor|halt|resume|down|attach|logs|retire|roster) return 0 ;;
    crew) case "${2:-status}" in halt|resume|down|attach|logs) return 0 ;; esac ;;
  esac
  return 1
}
if local_only "$cmd" "${1:-}"; then merge_crew_defaults; tares_on || true; keep_token
else prime; fi
case "$cmd" in
  crew) cmd_crew "$@" ;;
  status) cmd_status ;;
  desk) cmd_desk ;;
  watch) cmd_watch "$@" ;;
  halt) cmd_halt "$@" ;;
  resume) cmd_resume ;;
  attach) cmd_attach "$@" ;;
  logs) cmd_logs "$@" ;;
  down) cmd_down ;;
  roster) cmd_roster ;;
  spawn) cmd_spawn "$@" ;;
  revive) cmd_revive "$@" ;;
  restart) cmd_restart "$@" ;;
  retire) cmd_retire "$@" ;;
  spec) cmd_spec "$@" ;;
  session) cmd_session "$@" ;;
  projects) cmd_projects ;;
  finish) cmd_finish "$@" ;;
  settings) cmd_settings "$@" ;;
  context) cmd_context "$@" ;;
  plan) cmd_plan ;;
  tidy) cmd_tidy "$@" ;;
  _parse-bg-id) parse_bg_id ;;
  _mcp-url) mcp_url ;;
  doctor) cmd_doctor ;;
  *) usage; exit 1 ;;
esac
