#!/usr/bin/env bash
# st-morning (docs/19 v2 + docs/16 + docs/20 escalation): the morning ritual — compose the brief,
# pick the executor model per the user's SETTING + the queue's COMPLEXITY routing, and open a
# terminal with Claude Code already prompted: the session walks the user through every decision
# (PR merges included) as interactive questions.
#
#   st-morning            # auto: open a terminal if a display exists; tmux fallback if headless; else print
#   st-morning --print    # never open; just compose + print the launch command
#   st-morning --deep [--item ID]  # ESCALATION pass (docs/20): brief contains the items the USER
#                         #   escalated in the standard digest (route=escalate-deep); executor = the
#                         #   deep (high-intelligence) model. Launched by the standard session's
#                         #   end-of-digest confirmation — combined, or per-item with --item.
#
# Settings (~/.config/inferroute/the-steward.json):
#   {"review": {"executor": "auto" | "native" | "sonnet" | "<gate-model>",  # standard digest model
#               "deep_executor": "<cmd>",                              # default: ir anthropic --model opus
#               "morning_launch": true}}
#   auto/native/sonnet → NATIVE Anthropic sonnet (`ir anthropic`, no routing/substitution — intended-Sonnet
#   must not be downgraded by the gate). A <gate-model> setting (e.g. kimi) uses the routed gate on purpose.
#   Routing (docs/20, Henry 2026-06-11): ALL items are triaged in the standard digest — deep is a
#   per-OPTION property (🧠 deep-redesign options escalate on the user's choice). Direct-to-deep
#   happens only when every pending item's RECOMMENDED route is deep-redesign.
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$(cd "$HERE/.." && pwd)"
CFG="$HOME/.config/inferroute/the-steward.json"

# Freshen gha-repo cards BEFORE composing. On a CLI-only box (macOS: no dashboard running, no
# st-gha-sync systemd unit) nothing else pulls the state branch, so the brief would compose over an
# empty card and surface 0 gha findings. Fail-soft + BOUNDED — a friend's morning must never hang on
# git — and no `timeout` (absent on macOS): background + poll, then reap. [ade 2026-08-19]
if grep -q '"mode":[[:space:]]*"gha"' "$CFG" 2>/dev/null; then
  python3 "$ROOT/tools/gha_sync.py" sync-all >/dev/null 2>&1 &
  _spid=$!; _n=0
  while kill -0 "$_spid" 2>/dev/null && [ "$_n" -lt 25 ]; do sleep 1; _n=$((_n+1)); done
  kill "$_spid" 2>/dev/null || true
fi
trap 'printf "\033[?25h" 2>/dev/null' EXIT INT TERM   # always restore the cursor (spinner hides it)

MODE=standard; ONLY_ITEM=""
[ "${1:-}" = --deep ] && { MODE=deep; shift; }
[ "${1:-}" = --work ] && { MODE=work; shift; }
[ "${1:-}" = --item ] && { ONLY_ITEM="${2:-}"; shift 2; }

# CATCH-UP GATE (morning_catchup.py): when fired by a SCHEDULED agent (ST_MORNING_SCHEDULED=1) — never by a
# manual `steward review` — surface the standard digest at most ONCE/day, at/after the user's morning_time,
# and only when a GUI session is actually up. So a sleep-prone Mac shows the review the moment it opens
# after that time (the RunAtLoad agent), instead of firing at a fixed hour it slept through, or launching
# into a tmux nobody is looking at. RunAtLoad can fire a few seconds before the GUI (Dock) is up at login,
# so give it a brief window before deciding; then defer on before-time / already-ran / GUI-down.
if [ "${ST_MORNING_SCHEDULED:-0}" = 1 ] && [ "$MODE" = standard ]; then
  for _i in $(seq 1 15); do python3 "$ROOT/tools/morning_catchup.py" gui >/dev/null 2>&1 && break; sleep 2; done
  _GATE="$(python3 "$ROOT/tools/morning_catchup.py" check 2>/dev/null)"
  if [ $? != 0 ]; then
    echo "[st-morning] scheduled gate (${ST_MORNING_TRIGGER:-scheduled}): DEFER — $_GATE" >&2
    exit 0
  fi
  echo "[st-morning] scheduled gate (${ST_MORNING_TRIGGER:-scheduled}): surface — $_GATE" >&2
fi

# ---- friendly loading view (TTY only) ----------------------------------------------------------
# The brief is composed by an LLM editor pass that takes ~a minute. Without a clear "working…" view
# the user sees raw compose logs and assumes it's frozen. Show a header + spinner + plain-English
# status while compose runs; fall back to plain logs when not attached to a terminal. [docs/34]
_steward_header() {
  [ -t 1 ] || return 0
  clear 2>/dev/null || true
  printf '\n  \033[38;5;111m🛡  The Steward\033[0m \033[2m— morning review\033[0m\n\n'
}
_compose_spinner() {   # $1=logfile — runs compose-digest in the background, spins until it exits
  local logf="$1"
  bash "$HERE/compose-digest.sh" >"$logf" 2>&1 &
  local pid=$! i=0 msg="gathering the night's signals"
  local frames=(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏)
  printf '\033[?25l'
  while kill -0 "$pid" 2>/dev/null; do
    case "$(tail -n1 "$logf" 2>/dev/null)" in
      *bundle:*)                    msg="gathering the night's signals" ;;
      *"attempt 1"*)                msg="the editor is reading through the night" ;;
      *"attempt 2"*|*"attempt 3"*)  msg="refining the brief" ;;
      *spawn*)                      msg="composing your brief — this takes a minute" ;;
    esac
    printf '\r  \033[38;5;111m%s\033[0m  %s\033[K' "${frames[i]}" "$msg"
    i=$(( (i + 1) % ${#frames[@]} ))
    sleep 0.12
  done
  wait "$pid"; local rc=$?
  printf '\r\033[K\033[?25h'
  return $rc
}

# DIGEST EDITOR (docs/20): compose the digest plan if stale (one tool-less editor pass; fail-OPEN —
# without a plan the brief renders the deterministic fallback, everything needs-reading).
if [ "$MODE" = standard ]; then
  _steward_header
  if [ -t 1 ]; then
    _CLOG="$(mktemp)"
    if _compose_spinner "$_CLOG"; then
      printf '  \033[32m✓\033[0m brief ready\n'
    else
      printf '  \033[33m•\033[0m using the deterministic brief (compose hit a snag)\n'
    fi
    rm -f "$_CLOG"
  else
    bash "$HERE/compose-digest.sh" >&2 || echo "[st-morning] compose failed — deterministic fallback brief" >&2
  fi
fi
# --work: the standard WORK session (three-lane clerk, docs/20) — implement the user's chosen
# moderate options; standard executor, never the deep one.

# routing counts: escalated items (user chose deep resolutions, waiting) + all-deep-recommended check
read -r N_ALL N_ESC N_DEEPREC <<< "$(python3 - "$ROOT" <<'PY'
import json, subprocess, sys
out = subprocess.run([sys.executable, sys.argv[1] + "/tools/review/queue.py", "--json"],
                     capture_output=True, text=True)
try: q = json.loads(out.stdout)
except Exception: q = []
def rec_deep(i):
    rec = [o for o in (i.get("options") or []) if o.get("recommended")]
    return bool(rec) and rec[0].get("difficulty") == "deep-redesign"
print(len(q), sum(1 for i in q if i.get("escalated")), sum(1 for i in q if rec_deep(i)))
PY
)"
# Direct-to-deep ONLY when every pending item's RECOMMENDED route is deep-redesign (Henry: triage
# normally otherwise — deep is a per-OPTION property; escalation happens by the user's choice).
if [ "$MODE" = standard ] && [ "${N_ALL:-0}" -gt 0 ] && [ "$N_ALL" = "${N_DEEPREC:-0}" ]; then
  MODE=deep; echo "[st-morning] ALL $N_ALL pending items RECOMMEND deep-redesign → deep executor directly" >&2
fi

# deep/work sessions launch ONE TERMINAL PER TARGET REPO, cwd'd INTO that repo (Henry 2026-06-12):
# the session inherits the repo's CLAUDE.md, conventions, permissions and tooling (deploy pipelines,
# test commands) — the implementation agent works where the work lives. Standard mode stays rooted
# in night-shift (it spans repos and only merges/transcribes/routes).
# one launch unit per PLAN-MERGE GROUP, each with its resolved target repo (queue.py work_groups:
# merged items = one decision = one session; repo = where the diagnosis's root_cause.file exists)
GROUPS_FOR_MODE() {
  # decision-units sharing a TARGET REPO combine into one session (one terminal per repo)
  python3 "$ROOT/tools/review/queue.py" --work-groups "$MODE" 2>/dev/null \
    | python3 -c "
import json, sys
by = {}
for g in json.load(sys.stdin):
    by.setdefault(g['repo'], []).extend(g['ids'])
for repo, ids in by.items():
    print(repo + '\t' + ','.join(sorted(set(ids))))"
}
BRIEF="$(python3 "$ROOT/tools/review/morning_brief.py" --mode "$MODE" ${ONLY_ITEM:+--item "$ONLY_ITEM"})"

DEEP_EXEC="$(python3 -c "
import json
try: print(json.load(open('$CFG')).get('review',{}).get('deep_executor','ir anthropic --model opus'))
except Exception: print('ir anthropic --model opus')")"

if [ "$MODE" = deep ]; then
  read -ra INVOKE <<< "$DEEP_EXEC"; WHY="deep escalation (docs/20): ${N_ESC:-?} escalated item(s)${ONLY_ITEM:+ · only $ONLY_ITEM}"
elif [ "$MODE" = work ]; then
  WORK_EXEC="$(python3 -c "
import json
try: print(json.load(open('$CFG')).get('review',{}).get('work_executor','ir anthropic --model sonnet'))
except Exception: print('ir anthropic --model sonnet')")"
  read -ra INVOKE <<< "$WORK_EXEC"; WHY="work session (three-lane clerk): user-chosen follow-up items${ONLY_ITEM:+ · only $ONLY_ITEM}"
else
  EXEC_SETTING="${ST_EXECUTOR:-$(python3 -c "
import json,sys
try: print(json.load(open('$CFG')).get('review',{}).get('executor','auto'))
except Exception: print('auto')")}"
  # INTENDED-SONNET → NATIVE ANTHROPIC (Henry 2026-06-15). The review is a user-facing DECISION session;
  # if we mean Sonnet, we use `ir anthropic` (plain claude, the user's own Anthropic creds, NO inferroute
  # routing). The routed gate (`ir --model sonnet`) substitutes the model server-side — on 2026-06-15 it
  # downgraded a "sonnet" request to MiniMax-M2.7, whose account was depleted → 402 in the live review.
  # Routing/substitution is right for the autonomous economy lane (kimi), NOT for an intended-Sonnet decision.
  if [ "$EXEC_SETTING" = auto ] || [ "$EXEC_SETTING" = native ] || [ "$EXEC_SETTING" = sonnet ]; then
    INVOKE=(ir anthropic --model sonnet); WHY="native Anthropic sonnet (intended-Sonnet → native, no routing/substitution)"
  else
    INVOKE=(ir --model "$EXEC_SETTING"); WHY="setting: $EXEC_SETTING (routed)"
  fi
fi
# ── Model policy (docs/67): routed by DEFAULT; native ONLY when remote-control is on (it costs more, and
# only native sessions are drivable from the Claude Code app). The substitution risk that forced native is
# avoided by routing to a SPECIFIC canonical model (no generic "sonnet" → server substitution). ──
RC="$(python3 "$ROOT/tools/settings.py" --get model.remote_control 2>/dev/null)"
if [ "$RC" = "True" ]; then                       # remote-control ON → native + --remote-control (drive from the app)
  case " ${INVOKE[*]} " in *" ir anthropic "*) : ;; *) INVOKE=(ir anthropic --model sonnet) ;; esac
  INVOKE+=(--remote-control); WHY="$WHY + remote-control (Claude Code app · native, costs more)"
elif [ "${INVOKE[0]:-} ${INVOKE[1]:-}" = "ir anthropic" ]; then   # default OFF + resolver chose native → ROUTE it
  RMODEL="${ST_REVIEW_MODEL:-$(python3 -c "import json
try: print(json.load(open('$CFG')).get('review',{}).get('routed_model','kimi'))
except Exception: print('kimi')" 2>/dev/null || echo kimi)}"
  INVOKE=(ir --model "$RMODEL"); WHY="routed review model $RMODEL (model policy default — inferroute, no substitution)"
fi
if [ -t 1 ] && [ "$MODE" = standard ]; then
  printf '  \033[2mopening your review — %s item(s) · %s\033[0m\n\n' "${N_ALL:-0}" "${INVOKE[*]}"
  sleep 0.5
else
  echo "[st-morning] mode=$MODE · ${N_ALL:-?} pending (${N_ESC:-0} escalated) · executor: ${INVOKE[*]} ($WHY)" >&2
  echo "[st-morning] brief: $BRIEF" >&2
fi

# Sweep ALL nested-CC markers: gnome-terminal windows inherit the TERMINAL SERVER's env, and if the
# server was ever spawned from inside a CC session it carries CLAUDECODE/CLAUDE_CODE_SESSION_ID/
# AI_AGENT/… — any one of them trips claude's nested guard → silent instant exit 0 (the "empty
# terminal" bug, diagnosed 2026-06-10). Unset dynamically so future markers are covered too.
TUI_MODE="${ST_TUI:-default}"   # review sessions render INLINE by default (alt-screen hides ir's links/hints + scrollback)
DISP="${DISPLAY:-}"
[ -z "$DISP" ] && for x in /tmp/.X11-unix/X*; do [ -e "$x" ] && DISP=":${x##*X}" && break; done

launch() {  # $1=cwd $2=brief $3=title → terminal (or print)
  # IR_LANE= : the interactive review/work/deep session is USER-FACING (you drive it live) — it must NEVER
  # ride the patient economy lane (which can wait/defer). Force standard lane, defending against an
  # inherited IR_LANE=economy. The MODEL is INVOKE's own concern (sonnet/opus). [Henry 2026-06-15]
  # POST-REVIEW HOOK: when the session exits, recompose the digest to reflect the decisions just made
  # and re-surface the fresh digest on Telegram (fail-soft; never blocks teardown). [docs/78 auto-refresh]
  # BRIEF BY REFERENCE, never inline: the brief is unbounded (2026-08-14: a fat inherited-receipts
  # section pushed it to 161 KB) and Linux caps a SINGLE argv string at 128 KiB (MAX_ARG_STRLEN) —
  # an inline "$(cat brief)" makes execve of ir fail with E2BIG ("Argument list too long") and the
  # review never opens. A short pointer prompt keeps argv tiny at any brief size; the session Reads
  # the file as its first step.
  local CMD="cd $1 && for v in \$(env | grep -oE '^(CLAUDE[A-Za-z_]*|AI_AGENT)'); do unset \$v; done; IR_LANE= IR_ALLOW_NESTED=1 ${INVOKE[*]} --settings '{\"tui\":\"$TUI_MODE\"}' 'First Read the file $2 in full — it is your morning-review brief. Then follow it exactly, starting now; do not summarize it back.'; bash $ROOT/tools/review/post_review.sh review-$MODE || true"
  if [ "${PRINT_ONLY:-0}" = 1 ]; then echo "$CMD"; return 0; fi
  # INLINE: run the review in the CURRENT terminal instead of spawning a new one. The web "open review"
  # button opens ONE terminal that runs st-morning inline, so compose progress + the review are visible
  # immediately in that window (no minutes-long invisible compose, no second terminal). Standard mode
  # only — it has a single launch unit. [docs/34: open-review button fix]
  if [ "${ST_MORNING_INLINE:-0}" = 1 ]; then eval "$CMD"; return $?; fi
  if [ -n "$DISP" ] && command -v gnome-terminal >/dev/null; then
    DISPLAY="$DISP" gnome-terminal --title="$3" --geometry=140x38 -- bash -lc "$CMD; exec bash" \
      && echo "[st-morning] terminal opened on $DISP ($3 · cwd $1)" >&2 && return 0
  fi
  # macOS: no X11/gnome, but there's an Aqua GUI session — open a VISIBLE Terminal.app window via
  # osascript so a Mac user gets the SAME "terminal pops open, Claude Code already driving the review"
  # morning experience as Linux. Preferred over the tmux fallback below (a visible window the user drives
  # beats a detached session they must attach to). CMD carries single/double quotes + $()+&& — write it
  # to a temp script (the same trick as the tmux path) so its quoting survives AppleScript's own string
  # layer intact; the script self-deletes when the review ends, so no state is left behind.
  # macOS fork (Henry's intent): GUI UP → open the review LOCALLY in Terminal.app; GUI DOWN → fall
  # through to the remote-control path below so GHA drives it and pushes a claude.ai/code URL. `pgrep -x
  # Dock` is the GUI-up signal — Dock runs iff an Aqua session is active for this user, exactly the
  # precondition for osascript-to-Terminal to surface a window (true over SSH into a logged-in desk;
  # false from a headless/logged-out launchd context, where we correctly defer to remote-control).
  if [ "$(uname -s)" = Darwin ] && command -v osascript >/dev/null && pgrep -x Dock >/dev/null 2>&1; then
    local TFILE; TFILE="$(mktemp /tmp/st-morning-XXXXXXXX.sh)"
    # PATH belt-and-braces: the review CMD runs `ir`/`claude` from ~/.local/bin (pipx) + Homebrew, but a
    # Terminal `bash -l` sources BASH profiles, not the user's zsh rc where pipx-ensurepath added them — so
    # export the known bin dirs explicitly or the session dies with `ir: command not found`. [ade 2026-08-19]
    { printf '#!/usr/bin/env bash\n'; \
      printf 'export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"\n'; \
      printf '%s\n' "$CMD"; printf 'rm -f %s\n' "$TFILE"; } > "$TFILE"
    chmod +x "$TFILE"
    if osascript -e "tell application \"Terminal\" to do script \"bash -l '$TFILE'\"" \
                 -e 'tell application "Terminal" to activate' >/dev/null 2>&1; then
      echo "[st-morning] Terminal.app opened ($3 · cwd $1)" >&2; return 0
    fi
    rm -f "$TFILE"   # osascript failed — don't strand the temp script
  fi
  # Headless fallback (remote session, GHA, no X display): launch via tmux detached session.
  # The CMD string is written to a temp script so single/double quotes inside it survive intact.
  # If INVOKE includes --remote-control the session surfaces a claude.ai/code URL in its pane;
  # capture it with: tmux capture-pane -t <SNAME> -p | grep claude.ai
  if command -v tmux >/dev/null; then
    local SNAME; SNAME="st-$(basename "$1" | tr -cd 'a-zA-Z0-9-' | head -c 20)-$$"
    local TFILE; TFILE="$(mktemp /tmp/st-morning-XXXXXXXX.sh)"
    { printf '#!/usr/bin/env bash -l\n'; printf '%s\n' "$CMD"; } > "$TFILE"
    chmod +x "$TFILE"
    tmux new-session -d -s "$SNAME" "bash -l '$TFILE'; rm -f '$TFILE'"
    echo "[st-morning] tmux session '$SNAME' launched (no display) — attach: tmux attach -t $SNAME" >&2
    # If --remote-control is active, poll the pane for the claude.ai URL and push it to Telegram.
    # Runs in a background subshell; $SNAME/$ROOT/$3 are captured at fork time. Times out after 3min.
    if [[ " ${INVOKE[*]} " == *" --remote-control "* ]]; then
      ( PANE="$SNAME" PUSH="$ROOT/tools/review/push.py" TITLE="$3"
        for i in $(seq 1 60); do
          sleep 3
          URL=$(tmux capture-pane -t "$PANE" -p 2>/dev/null \
                | grep -oE 'https://claude\.ai/code/session_[A-Za-z0-9]+' | head -1)
          [ -n "$URL" ] || continue
          python3 "$PUSH" --send "🧠 *session ready* — $TITLE
$URL" 2>/dev/null || true
          break
        done
      ) &
    fi
    return 0
  fi
  echo "[st-morning] no display/terminal — run it yourself:" >&2; echo "$CMD"
}
[ "${1:-}" = --print ] && PRINT_ONLY=1

if [ "$MODE" = standard ]; then
  launch "$ROOT" "$BRIEF" "🌅 The Steward — morning review"; _LRC=$?
  # a SCHEDULED run that actually surfaced marks today done, so the calendar tick and the login tick
  # don't both fire it. A manual review never marks (it's not the once/day scheduled surface).
  [ "${ST_MORNING_SCHEDULED:-0}" = 1 ] && [ "$_LRC" = 0 ] && python3 "$ROOT/tools/morning_catchup.py" mark >/dev/null 2>&1
else
  ICON="🧠 DEEP review"; [ "$MODE" = work ] && ICON="🔧 work session"
  WGROUPS="$(GROUPS_FOR_MODE)"
  [ -z "$WGROUPS" ] && { echo "[st-morning] no $MODE items pending" >&2; exit 0; }
  while IFS=$'\t' read -r R IDS; do
    [ -n "$R" ] || continue
    if [ -n "$ONLY_ITEM" ] && ! printf %s "$IDS" | grep -q "$ONLY_ITEM"; then continue; fi
    RB="$(python3 "$ROOT/tools/review/morning_brief.py" --mode "$MODE" --ids "$IDS" --out "/tmp/st-brief-$MODE-$(basename "$R")-$$.md")"
    launch "$R" "$RB" "$ICON — $(basename "$R")"
  done <<< "$WGROUPS"
fi
