#!/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, move each station onto it
                                            when it's idle (same conversation)
  tares factory crew halt ["<reason>"]      stop risky actions on every station until resume
  tares factory crew resume                 lift a halt
  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 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 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 (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 at $TARESD_URL (default http://127.0.0.1:8787); 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"
EVENTS="$CREW_HOME/events.jsonl"
TOKEN_FILE="$CREW_HOME/.tares-token"

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; }
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=""
tares_token() {
  if [ -n "${TARES_AUTH_TOKEN:-}" ]; then echo "$TARES_AUTH_TOKEN"; return; fi
  [ -n "${TARES_FACTORY_TOKEN_FILE:-}" ] && [ -r "$TARES_FACTORY_TOKEN_FILE" ] && tr -d '[:space:]' < "$TARES_FACTORY_TOKEN_FILE"
  return 0
}
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 (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'; }

# The person's token, kept for the stations in a file only they can read (TARES_FACTORY_TOKEN_FILE).
keep_token() {
  [ -n "${TARES_AUTH_TOKEN:-}" ] && [ -d "$CREW_HOME" ] || return 0
  [ "$(cat "$TOKEN_FILE" 2>/dev/null)" = "$TARES_AUTH_TOKEN" ] && return 0
  (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)$( [ -z "$(tares_token)" ] && echo "; if it has auth on, set TARES_AUTH_TOKEN")"
}
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}'
}
settings_raw() {
  local s; s=$(tares_http GET /api/crew/settings) || die "could not read the crew settings from Tares at $TARES$( [ -z "$(tares_token)" ] && echo "; if it has auth on, set TARES_AUTH_TOKEN")"
  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_md() { # [project] → markdown of grants for all projects, then the project's
  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.
  g=$(tares_http GET "/api/grants$q" 2>/dev/null) || { [ -n "$q" ] && g=$(tares_http GET /api/grants 2>/dev/null); } \
    || { say "could not read grants from Tares"; return 0; }
  jq -r '.all // ""' <<<"$g"
  [ -n "${1:-}" ] && jq -r '.project // ""' <<<"$g" | sed 's/^/@project /'
  return 0
}
# 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

  mkdir -p "$CREW_HOME/stations"
  jq -n \
    --arg role "$role" --arg station "$station" --arg parent "$parent" --arg project "$project" \
    --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" \
    '{
      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
      } + (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: $project, FACTORY_PROJECT: $pslug} else {} end)
        + (if $repo != "" then {FACTORY_REPO: $repo} else {} end)),
      crossSessionInbound: "accept",
      enabledPlugins: { "tares@inline": true },
      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_PLUGIN"; }
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() { # a fingerprint of what the stations run on: this CLI, hooks, skills and scripts; never fails
  { (cd "$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"
  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)
  [ -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)" \
    '{role: $role, parent: $parent, project: $project, repo: $repo, cwd: $cwd, id: $id, sessionId: $session,
      model: $model, effort: $effort, settings: $settings, kit: $kit, status: "active", startedAt: (now | todate)}')"
  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."
  [ -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
  [ -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 [ -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" '{autonomy: $a}')"
    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, re-arm your schedule if you have one, 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"}'
  event retired "$station" "$id"
  say "retired $station"
}

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
  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, move idle ones onto an updated plugin
  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
  [ $once = 1 ] || say "watching the crew every ${interval}s; Ctrl-C to stop"
  while :; do
    local s kit settled=0 down=0 candidate=""; kit=$(kit_version)
    # 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
    if [ $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"
    fi
    if ! active_json >/dev/null; then
      say "$UNREAD; nothing is revived or restarted this round"
      [ $once = 1 ] && return 0
      sleep "$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.
      case "$(stations_get "$s" '.role')" in
        helper) 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
        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 [ -z "$candidate" ] && movable "$s" "$kit"; then
          candidate=$s
        fi
        continue
      fi
      down=1
      local fresh=""; stuck_on_context "$s" && fresh=--fresh
      say "$s stopped; reviving it under the same name${fresh:+, in a new conversation since its last one thrashed}"
      # 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 )
      local rc=$?
      set -e
      [ $rc = 0 ] || say "could not revive $s; will try again next round"
    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; then upgrade_station "$candidate" "$kit"; fi
    [ $once = 1 ] && return 0
    sleep "$interval"
  done
}

movable() { # station kit → 0 when it runs another version, has a conversation to resume, and is quiet
  [ "$(stations_get "$1" '.kit')" != "$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
    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, re-arm your schedule if you have one, 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
}

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"; }

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)' <<<"$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"
  printf '%-30s %-12s %-14s %-8s %-8s %s\n' STATION ROLE MODEL STATE TEMPO "DOING / NEEDS"
  local s old=0 kit; kit=$(kit_version)
  for s in $(jq -r 'to_entries[] | select(.value.status == "active") | .key' "$STATIONS"); do
    case "$(stations_get "$s" '.role')" in helper) ;; *)
      [ "$(stations_get "$s" '.kit')" = "$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"
    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
  [ $old = 0 ] || echo "plugin: $old station(s) run an older version; $ME crew watch moves each onto this one when it's idle"
  halted && echo "HALTED: $(jq -r .reason "$HALT_FILE" 2>/dev/null)"
  local wp; if wp=$(watch_pid); then echo "watch: running (pid $wp), it revives any station that stops; 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); 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
}

cmd_roster() { # compact, for stations
  require_crew
  adopt_unrecorded
  [ -f "$STATIONS" ] || { echo "no stations"; return; }
  local agents; agents=$(active_json) || die "$UNREAD"
  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"
}

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 ;;
    *) die "$ME crew takes up, down, status, desk, attach, logs, watch, halt or resume" ;;
  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 -a args=(-n "$slug-spec")
  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 spec_settings="$CREW_HOME/stations/spec.settings.json"
  jq -n '{enabledPlugins: {"tares@inline": true}}' > "$spec_settings"
  args+=(--settings "$spec_settings")
  local prompt="/tares:spec"
  [ -n "$name" ] && prompt="$prompt (project name: $name)"
  [ -n "$idea" ] && prompt="$prompt $idea"
  local mcp; mcp=$(mcp_url)
  cd "$here" && TARES_FACTORY_URL=$TARES TARESD_URL=$TARES TARES_FACTORY_MCP_URL=$mcp exec "$CLAUDE" "${args[@]}" "$prompt"
}

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")
}

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"
  if tares_on; then
    ok "Tares answers at $TARES"
    if tares_http GET /api/crew/settings >/dev/null 2>&1; then ok "Tares lets this crew in"
    else no "Tares at $TARES refuses the crew: set TARES_AUTH_TOKEN to an admin key or the root token"; fi
  else
    no "no Tares answers at $TARES: start one with \`tares up\`, 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)" || warn "crew settings not answered yet ($(echo $missing)); $ME crew up asks them"
      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
      [ -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
  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" ]; 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 "$@" ;;
  projects) cmd_projects ;;
  finish) cmd_finish "$@" ;;
  settings) cmd_settings "$@" ;;
  tidy) cmd_tidy "$@" ;;
  _parse-bg-id) parse_bg_id ;;
  _mcp-url) mcp_url ;;
  doctor) cmd_doctor ;;
  *) usage; exit 1 ;;
esac
