#!/usr/bin/env bash
# phase-tracker.sh  -  stateful card-stack pipeline tracker, cross-CLI.
#
# Why this exists: Claude Code has TaskCreate/TaskUpdate which renders
# native UI cards. Copilot CLI has nothing equivalent. This script gives
# both CLIs the same "card stack" visual: each `update` call re-renders
# the FULL task list to stdout so the user sees a snapshot of pipeline
# progress that updates as work proceeds.
#
# State persists to a per-task JSON file so multiple shell invocations
# share the same task list:
#   $HOME/.claude/logs/multi-agent/<task_id>/tracker-state.json
# (override with $TRACKER_FILE env var)
#
# Commands:
#   init   <task_id>                                  Reset tracker for this task
#   add    <phase_id> "<name>"                        Register a phase (status=pending); idempotent - existing id is a no-op
#   update <phase_id> <status>                        Change status + re-render
#   sub    <phase_id> <sub_id> "<name>" [status]      Add or update a sub-phase
#   tokens <phase_id> <in> <out> [cached]             Add to phase's token totals (in = fresh input, cache-exclusive)
#   model  <phase_id> <model_name>                    Tag phase with the LLM that produced its tokens (v8.3.0)
#   meta   <phase_id> <key> <value>                   Set phase context (account, repos, files, now, ...)
#   now    <phase_id> "<text>"                        Set the phase's live current-action line (quiet, 60-char cap)
#   cost   <phase_id>|total                           Print est. USD for one phase or the whole run ("-" when unpriceable)
#   render                                            Print current snapshot, no change
#
# Status enum: pending | in_progress | completed | failed | skipped
#
# OTel span emission (v6.1.0+, opt-in):
#   Set $MULTI_AGENT_OTEL_SPANS=1 to make every `update`, `sub`, and `tokens`
#   action append one JSON line to:
#     $HOME/.claude/logs/multi-agent/<task_id>/otel-spans.jsonl
#   Each line is an OTLP-compatible JSON span with trace_id/span_id derived
#   from the task_id, phase_id, and timestamp. Format intentionally simple
#   so an OTel Collector's `filelog` receiver can tail + forward without a
#   protobuf build step. Emission is never fatal  -  if jq is missing or the
#   log dir isn't writable, we silently skip. Default OFF so users without
#   an observability stack see zero behavior change.
#
# Examples:
#   phase-tracker.sh init "TASK-123"
#   for p in 0:Init 1:Analysis 2:Planning 3:Dev 4:Review 5:Test 6:Commit 7:Report; do
#     phase-tracker.sh add "${p%%:*}" "${p#*:}"
#   done
#   phase-tracker.sh update 0 in_progress
#   phase-tracker.sh update 0 completed
#   phase-tracker.sh update 1 in_progress
#   phase-tracker.sh sub    1 1 "Stack detection" completed
#   phase-tracker.sh sub    1 2 "Codebase scan"   in_progress
#
# Exit codes: 0 ok, 64 usage error, 65 state file unreadable.

set -uo pipefail

if [ "$#" -lt 1 ]; then
  cat >&2 <<USAGE
usage:
  phase-tracker.sh init   <task_id>
  phase-tracker.sh add    <phase_id> "<name>"
  phase-tracker.sh update <phase_id> <pending|in_progress|completed|failed|skipped>
  phase-tracker.sh sub    <phase_id> <sub_id> "<name>" [status]
  phase-tracker.sh tokens <phase_id> <in> <out> [cached]
  phase-tracker.sh model  <phase_id> <model_name>
  phase-tracker.sh meta   <phase_id> <key> <value>
  phase-tracker.sh now    <phase_id> "<text>"
  phase-tracker.sh cost   <phase_id>|total
  phase-tracker.sh tiles
  phase-tracker.sh report
  phase-tracker.sh render
USAGE
  exit 64
fi

ACTION="$1"; shift

# State file location.
TRACKER_FILE="${TRACKER_FILE:-}"
# Record whether the caller supplied the path so `init` honors it too (the init
# branch used to overwrite it unconditionally, so a test setting TRACKER_FILE to
# a temp path still had init clobber the real ~/.claude pointer and tree).
TRACKER_FILE_FROM_ENV=0
[ -n "$TRACKER_FILE" ] && TRACKER_FILE_FROM_ENV=1
if [ -z "$TRACKER_FILE" ]; then
  TASK_ID_FROM_ENV="${MULTI_AGENT_TASK_ID:-}"
  if [ "$ACTION" = "init" ] && [ "$#" -ge 1 ]; then
    TASK_ID_FROM_ENV="$1"
  fi
  if [ -z "$TASK_ID_FROM_ENV" ]; then
    # Strict mode: refuse the global pointer fallback entirely. Concurrent-safe by
    # construction  -  the orchestrator must export MULTI_AGENT_TASK_ID. Opt in with
    # MULTI_AGENT_STRICT_TASK_ID=1 (recommended whenever more than one run can be live).
    if [ "${MULTI_AGENT_STRICT_TASK_ID:-0}" = "1" ]; then
      echo "phase-tracker: MULTI_AGENT_STRICT_TASK_ID=1 but no MULTI_AGENT_TASK_ID in scope; refusing global pointer fallback. Export MULTI_AGENT_TASK_ID=<id>." >&2
      exit 64
    fi
    # Fallback to the file written by the most recent init.
    # WARNING: this pointer is global and flips when ANY shell on this user account runs
    # `phase-tracker.sh init <other-task>`. In concurrent multi-agent runs (two terminals,
    # two parallel pipelines), the pointer becomes the wrong task. Always pass
    # MULTI_AGENT_TASK_ID per shell invocation for reliable per-session resolution.
    POINTER="$HOME/.claude/logs/multi-agent/.tracker-current"
    if [ -f "$POINTER" ]; then
      TRACKER_FILE="$(cat "$POINTER")"
      if [ "${MULTI_AGENT_QUIET:-0}" != "1" ]; then
        POINTER_TASK=$(basename "$(dirname "$TRACKER_FILE")")
        echo "phase-tracker: WARN  -  using pointer fallback (task=$POINTER_TASK). Pass MULTI_AGENT_TASK_ID=<id> for concurrent-safe resolution." >&2
      fi
    else
      echo "phase-tracker: no task in scope; run 'init <task_id>' first or set MULTI_AGENT_TASK_ID" >&2
      exit 64
    fi
  else
    TRACKER_FILE="$HOME/.claude/logs/multi-agent/${TASK_ID_FROM_ENV}/tracker-state.json"
  fi
fi
TRACKER_DIR="$(dirname "$TRACKER_FILE")"

need_jq() {
  command -v jq >/dev/null 2>&1 || {
    echo "phase-tracker: jq is required" >&2
    exit 65
  }
}

# Live usage ping (best-effort). Emits one per-phase update to the private
# dashboard via usage-report.mjs, always status=running so per-phase updates
# never fold the run into rollup counters - the terminal fold comes only from
# the Phase 7 / halt emit that reads the real run status. The emitter no-ops
# unless prefs.global.usageLog.enabled; detached so it never blocks a boundary.
usage_live_ping() {
  # Smoke runs exercise this script's state handling, never the live dashboard -
  # a test gate must not leave phantom "running" rows on the timeline.
  [ -n "${MULTI_AGENT_SMOKE:-}" ] && return 0
  local task="$1" phase="$2"
  # The emitter ships into whichever host tree installed it; a Copilot- or
  # Codex-only install has no ~/.claude/scripts, so resolve across all three
  # roots instead of hard-coding one (a hard-coded path silently disabled every
  # live ping on those hosts).
  local script=""
  local root
  for root in "$HOME/.claude" "$HOME/.copilot" "$HOME/.codex"; do
    if [ -f "$root/scripts/usage-report.mjs" ]; then
      script="$root/scripts/usage-report.mjs"
      break
    fi
  done
  local prefs="$HOME/.claude/multi-agent-preferences.json"
  [ -n "$task" ] && [ -n "$script" ] || return 0
  # Cheap gate: only spawn the emitter when usage logging is actually on, so a
  # user who never enabled it pays nothing per phase boundary. jq is already a
  # hard dependency of this script.
  jq -e '.global.usageLog.enabled == true' "$prefs" >/dev/null 2>&1 || return 0
  ( node "$script" --task-id "$task" --phase "$phase" --status running >/dev/null 2>&1 & ) 2>/dev/null || true
}

# Decode HTML entities in a tile title.
#
# `rules.md` forbids entities in "titles, commit messages, task subjects, or body
# text" because nothing downstream decodes them - they render literally. A tracker
# tile title IS a task subject, and it rendered exactly that way:
#
#   Phase 1: Build &amp; Launch
#   Phase 3: Drive &amp; Compare
#
# The rule existed and `output-quality-check.sh` enforced it, but only over the PR
# body and the Jira comment, never over a tile title. The one surface where the
# rule actually broke was the one surface nothing inspected.
#
# `add` is the single funnel for every tile title, so fixing it here fixes every
# caller. Decode rather than reject: the intent behind `&amp;` is unambiguous, and
# a display path should render correctly now rather than abort a run over
# punctuation. The stderr warning keeps the caller mistake visible instead of
# silently papering over it.
#
# Handles all three encodings, because a title can arrive in any of them:
#   named    &amp;  &lt;  &quot;  &nbsp;  &mdash;
#   decimal  &#38;  &#60;  &#34;   &#160;  &#8212;
#   hex      &#x26; &#x3C; &#x22;  &#xA0;  &#x2014;
# A first version handled only the named forms, so the numeric spellings still
# rendered literally: the same bug fixed for one spelling out of three.
#
# ONE left-to-right pass, which also gets nesting right for free: a global replace
# never rescans its own output, so `&amp;lt;` yields the literal text `&lt;` rather
# than `<`. An ordered-sed version needed `&amp;` decoded last for the same effect,
# and that ordering was a latent trap.
#
# Fancy punctuation degrades to ASCII instead of decoding faithfully. `&mdash;`
# becomes `-`, not an em-dash: this repo bans em/en-dash and ellipsis in shipped
# text and gates it in the scorecard, so decoding them would make this function
# INJECT what another gate rejects. Same for any numeric entity resolving to one.
#
# The program is fed through a QUOTED heredoc, not `node -e '...'`. Inside a
# single-quoted shell string every apostrophe in the JS (and in its comments)
# terminates the string early; shellcheck flags it as SC2140 and the first version
# here worked only by accident of where the quotes happened to fall. A quoted
# heredoc passes the body through verbatim, so the JS can use any quote it likes.
decode_entities() {
  local raw="$1" out
  out=$(MA_RAW="$raw" node - <<'MA_DECODE_JS'
const NAMED = {
  amp: "&", lt: "<", gt: ">", quot: '"', apos: "'",
  nbsp: " ",
  // Deliberately ASCII: see the note above this function.
  ndash: "-", mdash: "-", hellip: "...", laquo: "<<", raquo: ">>",
};
// Codepoints that must degrade to ASCII rather than decode faithfully.
const ASCII_FOR = new Map([
  [0x00a0, " "], [0x2013, "-"], [0x2014, "-"], [0x2026, "..."],
  [0x2018, "'"], [0x2019, "'"], [0x201c, '"'], [0x201d, '"'],
  [0x00a7, ""],
]);
const src = process.env.MA_RAW || "";
process.stdout.write(
  src.replace(/&(#[xX][0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);/g, (m, body) => {
    if (body[0] === "#") {
      const hex = body[1] === "x" || body[1] === "X";
      const cp = parseInt(hex ? body.slice(2) : body.slice(1), hex ? 16 : 10);
      if (!Number.isFinite(cp) || cp < 0 || cp > 0x10ffff) return m;
      if (ASCII_FOR.has(cp)) return ASCII_FOR.get(cp);
      try { return String.fromCodePoint(cp); } catch { return m; }
    }
    const key = body.toLowerCase();
    // An unknown named entity is left alone: guessing would corrupt a title that
    // legitimately contains "&foo;".
    return Object.prototype.hasOwnProperty.call(NAMED, key) ? NAMED[key] : m;
  }),
);
MA_DECODE_JS
) || out="$raw"
  [ "$out" != "$raw" ] && echo "phase-tracker: decoded HTML entities in title (rules.md forbids them): '$raw' -> '$out'" >&2
  printf '%s' "$out"
}

# Portable SHA-256 over stdin, printing the same "<hex>  -" shape as either tool.
#
# GNU coreutils ships sha256sum; macOS ships shasum (a perl script). Neither is
# universal - Alpine and slim container images typically have sha256sum but no perl, and
# some hardened macOS setups have neither on a restricted PATH. Trying both keeps the
# tracker working on all three supported platforms, and the no-hasher branch emits an
# obviously-synthetic value rather than an empty string, so a broken environment shows
# up as a visibly fake id instead of a silently blank field.
sha256_hex() {
  if command -v sha256sum >/dev/null 2>&1; then
    sha256sum
  elif command -v shasum >/dev/null 2>&1; then
    shasum -a 256
  else
    cat >/dev/null
    printf '%s  -\n' "0000000000000000000000000000000000000000000000000000000000nohash"
  fi
}

# OTel-compatible span emission  -  v6.1.0+, opt-in via $MULTI_AGENT_OTEL_SPANS.
# Appends one JSON line to otel-spans.jsonl. Never fatal on failure.
#
# Args:
#   $1 = event_name (e.g. "phase.update", "phase.sub", "phase.tokens")
#   $2 = phase_id (required)
#   $3 = status or subject (string, optional)
#   $4 = attributes (JSON object string, optional  -  merged into span.attributes)
emit_otel_span() {
  [ "${MULTI_AGENT_OTEL_SPANS:-0}" = "1" ] || return 0
  command -v jq >/dev/null 2>&1 || return 0
  [ -n "${TRACKER_DIR:-}" ] || return 0
  [ -d "$TRACKER_DIR" ] || return 0

  local event="${1:-unknown}"
  local phase_id="${2:-?}"
  local subject="${3:-}"
  local extra_attrs="${4:-"{}"}"
  local task_id
  task_id=$(basename "$TRACKER_DIR")
  local now_ns
  now_ns=$(date +%s)000000000  # seconds * 1e9 (OTel wants nanos; bash-safe)
  local spans_file="${TRACKER_DIR}/otel-spans.jsonl"

  # Deterministic 128-bit trace_id from task_id, 64-bit span_id from event+phase+timestamp.
  #
  # sha256_hex, not `shasum` directly: this runs on every phase on every host, and
  # `shasum` is a perl script that is absent from minimal Linux images and many
  # containers. Without a fallback the ids came back empty and the span was written with
  # blank trace/span - valid JSON, useless telemetry, and no error to notice.
  local trace_id span_id
  trace_id=$(printf "%s" "$task_id" | sha256_hex | awk '{print substr($1,1,32)}')
  span_id=$(printf "%s:%s:%s" "$event" "$phase_id" "$now_ns" | sha256_hex | awk '{print substr($1,1,16)}')

  jq -nc \
    --arg trace "$trace_id" --arg span "$span_id" \
    --arg name "${event}.${phase_id}" \
    --arg ts "$now_ns" \
    --arg task "$task_id" --arg phase "$phase_id" --arg subj "$subject" \
    --argjson extra "$extra_attrs" \
    '{
      traceId: $trace,
      spanId: $span,
      name: $name,
      startTimeUnixNano: $ts,
      endTimeUnixNano: $ts,
      attributes: ({
        "task_id": $task,
        "phase_id": $phase,
        "subject": $subj,
        "service.name": "multi-agent-pipeline"
      } + $extra)
    }' >> "$spans_file" 2>/dev/null || true
}

# Color helpers (skip when not a TTY or TERM=dumb).
if [ -t 1 ] && [ "${TERM:-}" != "dumb" ]; then
  C_RESET=$'\033[0m'
  C_DIM=$'\033[2m'
  C_BOLD=$'\033[1m'
  C_BLUE=$'\033[34m'
  C_GREEN=$'\033[32m'
  C_YELLOW=$'\033[33m'
  C_RED=$'\033[31m'
  C_PURPLE=$'\033[35m'
  C_CYAN=$'\033[36m'
else
  C_RESET=""; C_DIM=""; C_BOLD=""
  C_BLUE=""; C_GREEN=""; C_YELLOW=""; C_RED=""; C_PURPLE=""; C_CYAN=""
fi

glyph_for() {
  case "$1" in
    pending)     printf "${C_DIM}○${C_RESET}" ;;
    in_progress) printf "${C_CYAN}●${C_RESET}" ;;
    completed)   printf "${C_GREEN}✓${C_RESET}" ;;
    failed)      printf "${C_RED}✗${C_RESET}" ;;
    skipped)     printf "${C_YELLOW}↷${C_RESET}" ;;
    *)           printf "?" ;;
  esac
}

# Current time in epoch seconds (portable  -  works on both BSD and GNU date).
now_epoch() {
  date +%s
}

# Convert ISO-8601 UTC timestamp (YYYY-MM-DDTHH:MM:SSZ) to epoch seconds.
# BSD date (macOS) uses -j -f; GNU date uses -d. Try BSD first, fall back.
iso_to_epoch() {
  local ts="$1"
  [ -z "$ts" ] && { echo ""; return; }
  local e
  e=$(date -j -u -f "%Y-%m-%dT%H:%M:%SZ" "$ts" +%s 2>/dev/null)
  if [ -z "$e" ]; then
    e=$(date -u -d "$ts" +%s 2>/dev/null)
  fi
  echo "$e"
}

# Format a token count as "Nk" (1200 → "1.2k", 12543 → "12.5k", 187 → "187").
format_tokens() {
  local n="$1"
  [ -z "$n" ] || [ "$n" -le 0 ] 2>/dev/null && { echo ""; return; }
  if [ "$n" -lt 1000 ]; then
    printf "%d" "$n"
  else
    local whole=$((n / 1000))
    local frac=$(( (n % 1000) / 100 ))
    if [ "$frac" -eq 0 ]; then
      printf "%dk" "$whole"
    else
      printf "%d.%dk" "$whole" "$frac"
    fi
  fi
}

# Format elapsed seconds as "Ns" or "Nm Ms".
format_elapsed() {
  local s="$1"
  [ -z "$s" ] || [ "$s" -lt 0 ] 2>/dev/null && { echo ""; return; }
  if [ "$s" -lt 60 ]; then
    printf "%ds" "$s"
  else
    local m=$((s / 60))
    local r=$((s % 60))
    if [ "$r" -eq 0 ]; then
      printf "%dm" "$m"
    else
      printf "%dm %ds" "$m" "$r"
    fi
  fi
}

# Cost table ships next to this script in every install tree (repo,
# ~/.claude/scripts, ~/.copilot/scripts). Pricing math comes from cost-lib.sh
# (shared with the other renderers): tokens_in is FRESH input (cache-exclusive,
# per the disjoint token-count contract), tokens_cached is priced at the
# discounted cacheReadPerMtok rate, floored to cents. A missing/unparseable
# table never fails a caller - USD simply renders empty / "-".
COST_TABLE="$(cd "$(dirname "$0")" && pwd)/cost-table.json"
COST_LIB="$(cd "$(dirname "$0")" && pwd)/cost-lib.sh"
if [ -f "$COST_LIB" ]; then . "$COST_LIB"; else COST_JQ_DEFS=""; fi

# Print est. USD ("0.21") for one phase index or, with "total", the sum across
# priceable phases. Prints "-" when nothing is priceable. Never non-zero exit.
phase_usd() {
  local selector="$1"  # integer phase index into .phases[], or "total"
  [ -f "$COST_TABLE" ] || { echo "-"; return 0; }
  local state; state="$(load_state)"
  echo "$state" | jq -r --slurpfile prices "$COST_TABLE" --arg sel "$selector" "$COST_JQ_DEFS"'
    def usd_of(p): cost_usd_of($prices[0].prices[p.model // ""] // null; (p.tokens_in // 0); (p.tokens_out // 0); (p.tokens_cached // 0));
    if $sel == "total" then
      ([.phases[] | usd_of(.) | select(. != null)] | if length == 0 then "-" else (add | cost_floor_cents(.) | tostring) end)
    else
      (.phases[($sel | tonumber)] // null) as $p |
      if $p == null then "-" else (usd_of($p) | cost_floor_cents(.) | if . == null then "-" else tostring end) end
    end
  ' 2>/dev/null || echo "-"
}

# Read state, atomically write it back. Atomic = write to .tmp, rename.
load_state() {
  if [ ! -f "$TRACKER_FILE" ]; then
    echo '{"task_id":"unknown","started_at":"","phases":[]}'
  else
    cat "$TRACKER_FILE"
  fi
}
save_state() {
  # Refuse to persist an empty document: every caller pipes jq output in, and
  # a failed jq yields "" - writing that would destroy the whole tracker state.
  [ -n "$1" ] || { echo "save_state: refusing to write empty state" >&2; return 65; }
  mkdir -p "$TRACKER_DIR" 2>/dev/null
  # Per-process temp name: acquire_state_lock fails open after ~5s, so two
  # writers can legitimately be in the critical section. A fixed .tmp name lets
  # writer B truncate the file mid-write of A, and the rename then publishes a
  # corrupt document (render exits 65). $$ keeps each writer's temp private; the
  # rename is still atomic, so the last full write wins instead of a torn one.
  local tmp="${TRACKER_FILE}.tmp.$$"
  printf '%s\n' "$1" > "$tmp"
  mv "$tmp" "$TRACKER_FILE"
}

# --- read-modify-write lock ---------------------------------------------------
# tmp+rename makes each save atomic, but two concurrent invocations (e.g. two
# parallel Phase 4 reviewers reporting `tokens`) both load the same state and
# the second save silently drops the first delta. Guard every load->save
# sequence with a portable mkdir spinlock (macOS bash 3.2: no flock builtin).
# The wait is bounded and the lock FAILS OPEN with a warning so a stuck lock
# can never hang the pipeline; a dead owner pid or a lock older than 30s is
# reclaimed.
TRACKER_LOCK_DIR=""
TRACKER_LOCK_HELD=0

state_lock_stale() {
  local pid mtime now age
  pid=$(cat "$TRACKER_LOCK_DIR/pid" 2>/dev/null || true)
  if [ -n "$pid" ]; then
    kill -0 "$pid" 2>/dev/null && return 1
    return 0
  fi
  # GNU form first, and not because of style. On GNU coreutils `stat -f` is a valid
  # flag meaning --file-system, where `%m` prints the MOUNT POINT - so a BSD-first
  # chain succeeds on Linux, returns something like `/`, never reaches the `||`, and
  # then the arithmetic below fails on a path. The effect was that a stale lock with
  # no pid file was never reclaimed on Linux: every tracker call spun the full ~5s
  # bound and fell open with a warning. macOS `stat -c` is simply invalid, so it
  # errors and falls through cleanly - which is why this order works on both.
  mtime=$(stat -c %Y "$TRACKER_LOCK_DIR" 2>/dev/null || stat -f %m "$TRACKER_LOCK_DIR" 2>/dev/null || echo "")
  # Guard the arithmetic anyway: a non-numeric value here must not abort the caller
  # under `set -u`, and treating it as "not stale" is the conservative branch.
  case "$mtime" in
    '' | *[!0-9]*) return 1 ;;
  esac
  now=$(date +%s)
  age=$((now - mtime))
  [ "$age" -gt 30 ]
}

acquire_state_lock() {
  TRACKER_LOCK_DIR="${TRACKER_FILE}.lock"
  mkdir -p "$TRACKER_DIR" 2>/dev/null
  local tries=0
  # ~5s bound: 50 tries x 0.1s.
  while ! mkdir "$TRACKER_LOCK_DIR" 2>/dev/null; do
    if state_lock_stale; then
      rm -rf "$TRACKER_LOCK_DIR" 2>/dev/null
      continue
    fi
    tries=$((tries + 1))
    if [ "$tries" -ge 50 ]; then
      echo "phase-tracker: WARN  -  state lock busy ($TRACKER_LOCK_DIR); proceeding without lock" >&2
      return 0
    fi
    sleep 0.1
  done
  echo "$$" > "$TRACKER_LOCK_DIR/pid" 2>/dev/null
  TRACKER_LOCK_HELD=1
}

release_state_lock() {
  if [ "$TRACKER_LOCK_HELD" = "1" ]; then
    rm -rf "$TRACKER_LOCK_DIR" 2>/dev/null
    TRACKER_LOCK_HELD=0
  fi
}
# Never leave a lock behind if a mutating action dies mid-critical-section.
trap release_state_lock EXIT

# The cost table is keyed by family ("opus", "sonnet", "gpt-5.6"), but the name
# an agent has at hand is the full id ("claude-opus-5"). Storing the id verbatim
# priced every phase at "-" while the tracker looked perfectly healthy, so the
# family is resolved here, once, and an unresolvable name is said out loud
# instead of quietly costing nothing.
normalize_model() {
  local raw="$1" lower
  lower=$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')
  case "$lower" in
    *terra*) printf 'gpt-5.6-terra' ;;
    *gpt-5.6* | *gpt5.6*) printf 'gpt-5.6' ;;
    *gpt-5.4* | *gpt5.4*) printf 'gpt-5.4' ;;
    *opus*) printf 'opus' ;;
    *sonnet*) printf 'sonnet' ;;
    *haiku*) printf 'haiku' ;;
    *fable*) printf 'fable' ;;
    *) printf '%s' "$raw" ;;
  esac
}

# Which CLI tree this copy runs from. The card below is identical on all three
# hosts, but the WIDGET is not: Claude Code has TaskCreate/TaskUpdate, Codex has
# update_plan, Copilot CLI has no task UI at all. Every "what to call next" hint
# has to know where it is, or it names a tool the host does not have.
SELF_PATH="${BASH_SOURCE[0]:-$0}"
host_kind() {
  case "${MULTI_AGENT_HOST:-}" in
    claude | codex | copilot)
      printf '%s' "$MULTI_AGENT_HOST"
      return 0
      ;;
  esac
  case "$SELF_PATH" in
    */.codex/*) printf 'codex' ;;
    */.copilot/*) printf 'copilot' ;;
    *) printf 'claude' ;;
  esac
}

# The completion line the contract requires, built from what was actually
# recorded. Phases with no recorded spend say so rather than printing zeros,
# which is the difference between "cost unknown" and "cost was nothing".
narration_line() {
  local pid="$1" state name idx model t_in t_out usd
  state="$(load_state)"
  name=$(echo "$state" | jq -r --arg id "$pid" '(.phases[] | select(.id == $id) | .name) // ""')
  idx=$(echo "$state" | jq -r --arg id "$pid" '[.phases[]?.id] | index($id) // "-"')
  model=$(echo "$state" | jq -r --arg id "$pid" '(.phases[] | select(.id == $id) | .model) // ""')
  t_in=$(echo "$state" | jq -r --arg id "$pid" '(.phases[] | select(.id == $id) | .tokens_in) // 0')
  t_out=$(echo "$state" | jq -r --arg id "$pid" '(.phases[] | select(.id == $id) | .tokens_out) // 0')
  if [ "$t_in" -eq 0 ] && [ "$t_out" -eq 0 ]; then
    printf 'Phase %s %s done (no LLM calls)' "$pid" "$name"
    return 0
  fi
  usd="-"
  [ "$idx" != "-" ] && usd=$(phase_usd "$idx")
  printf 'Phase %s %s done - ~%s in / ~%s out tokens (%s, ~$%s)' \
    "$pid" "$name" "$(format_tokens "$t_in")" "$(format_tokens "$t_out")" \
    "${model:-unknown model}" "$usd"
}

# Printed after every status change. The card goes to stdout, and on every host
# that collapses tool output the user never sees it - so the hint names the
# host's own widget call, which the agent makes in its own turn where the user
# does see it.
tracker_next_hint() {
  local pid="$1" status="$2" name mirror
  [ "${TRACKER_QUIET:-0}" = "1" ] && return 0
  name=$(load_state | jq -r --arg id "$pid" '(.phases[] | select(.id == $id) | .name) // ""')
  case "$(host_kind)" in
    claude) mirror="TaskUpdate(\"$(subjects "$pid")\", status=\"$status\")  -  the subject is re-read from \`phase-tracker.sh subjects $pid\` so the widget carries the model, the elapsed time and the tokens; if TaskUpdate is not one of your tools, paste the card above into your reply VERBATIM in a code block (every phase on its own line, keep the elapsed and token columns; do not redraw or compact it)" ;;
    # Not every session carries the task tools: Claude Code provides them by
    # default only up to Opus 4.7 / Sonnet 4.6, a default that landed in
    # v2.1.268. Naming the fallback on the same line is what keeps a newer model
    # from advancing eight phases in silence.
    codex) mirror="update_plan: set step \"Phase $pid $name\" to $status (send the FULL step list, it is not a delta)" ;;
    *) mirror="no task widget on this host - reprint the card above inside your reply text" ;;
  esac
  printf '\n-- NEXT (required) --\n  %s\n' "$mirror"
  case "$status" in
    completed | failed)
      printf '  narrate one line in outputLanguage: %s\n' "$(narration_line "$pid")"
      ;;
  esac
}

# Elapsed with an hours bucket. format_elapsed stops at minutes because the card
# shows one phase at a time; a whole-run total reads as "102m" without this.
format_span() {
  local s="$1"
  [ -z "$s" ] && { echo "-"; return; }
  [ "$s" -lt 0 ] 2>/dev/null && { echo "-"; return; }
  if [ "$s" -lt 3600 ]; then
    format_elapsed "$s"
  else
    printf '%dh %dm' "$((s / 3600))" "$(((s % 3600) / 60))"
  fi
}

# The end-of-run report: what the pipeline spent, phase by phase, and what it
# has to say about phases it could not price. Printed by Phase 7 next to the
# work summary, which covers what actually changed on disk.
report() {
  need_jq
  local state task_id started
  state="$(load_state)"
  task_id=$(echo "$state" | jq -r '.task_id // "?"')
  started=$(echo "$state" | jq -r '.started_at // ""')

  printf '\n== Run report: %s ==\n' "$task_id"
  printf '%-22s %-11s %10s   %-16s %-16s %8s\n' \
    "Phase" "Status" "Elapsed" "Tokens in/out" "Model" "USD"
  printf -- '%s\n' "----------------------------------------------------------------------------------"

  local total_in=0 total_out=0 unpriced=""
  local row
  while IFS=$'\037' read -r idx id name status p_start p_end t_in t_out model no_llm; do
    [ -n "$id" ] || continue
    local secs="" span="-" toks="-" usd
    if [ -n "$p_start" ]; then
      local e_start e_end
      e_start=$(iso_to_epoch "$p_start")
      e_end=$([ -n "$p_end" ] && iso_to_epoch "$p_end" || now_epoch)
      [ -n "$e_start" ] && [ -n "$e_end" ] && secs=$((e_end - e_start))
      [ -n "$secs" ] && span=$(format_span "$secs")
    fi
    if [ "${t_in:-0}" -gt 0 ] || [ "${t_out:-0}" -gt 0 ]; then
      toks="$(format_tokens "${t_in:-0}") / $(format_tokens "${t_out:-0}")"
      total_in=$((total_in + t_in))
      total_out=$((total_out + t_out))
    elif [ "$status" = "completed" ] && [ "$no_llm" != "true" ]; then
      unpriced="$unpriced $id"
    fi
    usd=$(phase_usd "$idx")
    printf '%-22s %-11s %10s   %-16s %-16s %8s\n' \
      "$id $name" "$status" "$span" "$toks" "${model:--}" "$usd"
  done <<EOF
$(echo "$state" | jq -r '
  [.phases[]?] | to_entries[] |
  [ (.key|tostring), .value.id, (.value.name // ""), (.value.status // "pending"),
    (.value.started_at // ""), (.value.completed_at // ""),
    ((.value.tokens_in // 0)|tostring), ((.value.tokens_out // 0)|tostring),
    (.value.model // ""), ((.value.no_llm // false)|tostring) ] | join("\u001f")')
EOF

  printf -- '%s\n' "----------------------------------------------------------------------------------"
  local run_secs="" run_span="-"
  if [ -n "$started" ]; then
    local e_run last
    e_run=$(iso_to_epoch "$started")
    last=$(echo "$state" | jq -r '[.phases[]?.completed_at // empty] | max // ""')
    local e_last
    e_last=$([ -n "$last" ] && iso_to_epoch "$last" || now_epoch)
    [ -n "$e_run" ] && [ -n "$e_last" ] && run_secs=$((e_last - e_run))
    [ -n "$run_secs" ] && run_span=$(format_span "$run_secs")
  fi
  printf '%-22s %-11s %10s   %-16s %-16s %8s\n' \
    "Total" "" "$run_span" \
    "$(format_tokens "$total_in") / $(format_tokens "$total_out")" "" "$(phase_usd total)"
  if [ -n "$unpriced" ]; then
    printf 'Cost unavailable  -  no tokens recorded for phase(s):%s\n' "$unpriced"
  fi
  printf '\n'
}

# The native widget renders one row per task and gives us exactly one string to
# fill it: the subject. So the numbers a reader wants - which model is spending,
# how long the phase has run, what it cost - have to travel IN the subject or not
# at all. `render` already computes all of it for the fallback card; this prints
# the same values in the one shape the host will accept.
#
# Every segment is omitted while it is empty, so a pending phase is just its name
# and a finished one carries its whole bill. Separator is ` - `, not a middle dot:
# a subject is a task title and travels through surfaces that are not a terminal.
subjects() {
  need_jq
  local state want="${1:-}"
  state=$(load_state)
  local prices_json='{"prices":{}}'
  if [ -f "$COST_TABLE" ]; then
    prices_json=$(cat "$COST_TABLE" 2>/dev/null) || prices_json='{"prices":{}}'
    echo "$prices_json" | jq empty 2>/dev/null || prices_json='{"prices":{}}'
  fi
  local now_s; now_s=$(now_epoch)
  local rows
  rows=$(echo "$state" | jq -r --argjson prices "$prices_json" "$COST_JQ_DEFS"'
    def usd_of(p): cost_usd_of($prices.prices[p.model // ""] // null; (p.tokens_in // 0); (p.tokens_out // 0); (p.tokens_cached // 0));
    .phases // []
    | sort_by(.id | (tonumber? // 9999))
    | .[] | [
      (.id // ""), (.name // ""), (.status // ""),
      (.started_at // ""), (.completed_at // ""), (.model // ""),
      (((.tokens_in // 0) + (.tokens_out // 0)) | tostring),
      (usd_of(.) | if . == null then "" else (((. * 100) | floor) / 100 | tostring) end)
    ] | join("\u001f")')
  local pid pname pstatus p_start p_end pmodel ptok pusd
  while IFS=$'\x1f' read -r pid pname pstatus p_start p_end pmodel ptok pusd; do
    [ -n "$pid" ] || continue
    [ -z "$want" ] || [ "$want" = "$pid" ] || continue
    local line="Phase $pid $pname"
    [ -n "$pmodel" ] && line="$line - $pmodel"
    local s_ep e_ep el=""
    s_ep=$(iso_to_epoch "$p_start")
    e_ep=$(iso_to_epoch "$p_end")
    if [ -n "$s_ep" ]; then
      case "$pstatus" in
        completed|failed|skipped) [ -n "$e_ep" ] && el=$((e_ep - s_ep)) ;;
        in_progress) el=$((now_s - s_ep)) ;;
      esac
    fi
    if [ -n "$el" ] && [ "$el" -lt 0 ] 2>/dev/null; then el=0; fi
    [ -n "$el" ] && line="$line - $(format_elapsed "$el")"
    if [ "${ptok:-0}" -gt 0 ] 2>/dev/null; then
      line="$line - $(format_tokens "$ptok") tok"
      [ -n "$pusd" ] && line="$line - $(printf '~$%.2f' "$pusd" 2>/dev/null || echo '')"
    fi
    printf '%s\n' "$line"
  done <<< "$rows"
}

render() {
  need_jq
  local state
  state="$(load_state)"
  local task_id
  task_id=$(echo "$state" | jq -r '.task_id') || {
    echo "phase-tracker: corrupted state file  -  cannot parse task_id" >&2
    exit 65
  }

  # Batched extraction: one jq pass emits all phase fields so the render loop
  # does not spawn ~16 jq subprocesses per phase. Sub-phases, meta entries,
  # and the total-tokens footer are batched separately below.
  #
  # Separator: U+001F (ASCII Unit Separator), a non-whitespace control char.
  # Tab cannot be used here because bash `read` with `IFS=$'\t'` collapses
  # consecutive tabs (tab is in the default whitespace IFS class), which
  # silently drops empty middle columns and shifts later fields.
  # Column order:  status, started_at, completed_at, id, name,
  #                tokens_in, tokens_out, sub_count, meta_keys_csv, usd
  # The usd column is computed in the same pass with the shared pricing math;
  # a missing/unparseable cost table yields empty usd cells, never a failure.
  local prices_json='{"prices":{}}'
  if [ -f "$COST_TABLE" ]; then
    prices_json=$(cat "$COST_TABLE" 2>/dev/null) || prices_json='{"prices":{}}'
    echo "$prices_json" | jq empty 2>/dev/null || prices_json='{"prices":{}}'
  fi
  local phase_data
  phase_data=$(echo "$state" | jq -r --argjson prices "$prices_json" "$COST_JQ_DEFS"'
    def usd_of(p): cost_usd_of($prices.prices[p.model // ""] // null; (p.tokens_in // 0); (p.tokens_out // 0); (p.tokens_cached // 0));
    .phases // []
    | sort_by(.id | (tonumber? // 9999))
    | .[] | [
      (.status // ""),
      (.started_at // ""),
      (.completed_at // ""),
      (.id // ""),
      (.name // ""),
      ((.tokens_in // 0) | tostring),
      ((.tokens_out // 0) | tostring),
      ((.subs // []) | length | tostring),
      ((.meta // {}) | keys_unsorted | join(",")),
      (usd_of(.) | if . == null then "" else (((. * 100) | floor) / 100 | tostring) end)
    ] | join("")
  ')

  local phase_count=0
  local -a row_status=() row_started=() row_completed=() row_id=() row_name=()
  local -a row_tin=() row_tout=() row_subcount=() row_metakeys=() row_usd=()
  if [ -n "$phase_data" ]; then
    local _s _st _ed _id _nm _ti _to _sc _mk _us
    while IFS=$'\x1f' read -r _s _st _ed _id _nm _ti _to _sc _mk _us; do
      row_status+=("$_s"); row_started+=("$_st"); row_completed+=("$_ed")
      row_id+=("$_id");    row_name+=("$_nm")
      row_tin+=("$_ti");   row_tout+=("$_to")
      row_subcount+=("$_sc"); row_metakeys+=("$_mk"); row_usd+=("$_us")
      phase_count=$((phase_count + 1))
    done <<< "$phase_data"
  fi

  # Elapsed seconds per phase. No bar; just label.
  #   completed/failed/skipped → completed_at - started_at (stamped)
  #   in_progress              → now - started_at (live)
  #   pending                  → "" (no label)
  local now_s; now_s=$(now_epoch)
  local -a elapsed_arr=()
  local i=0
  while [ "$i" -lt "$phase_count" ]; do
    local pstatus="${row_status[$i]}"
    local s_iso="${row_started[$i]}"
    local e_iso="${row_completed[$i]}"
    local s_ep e_ep el=""
    s_ep=$(iso_to_epoch "$s_iso")
    e_ep=$(iso_to_epoch "$e_iso")
    if [ -n "$s_ep" ]; then
      case "$pstatus" in
        completed|failed|skipped)
          [ -n "$e_ep" ] && el=$((e_ep - s_ep))
          ;;
        in_progress)
          el=$((now_s - s_ep))
          ;;
      esac
    fi
    if [ -n "$el" ] && [ "$el" -lt 0 ] 2>/dev/null; then el=0; fi
    elapsed_arr+=("${el}")
    i=$((i+1))
  done

  # Top border + title.
  local title="Pipeline: ${task_id}"
  local title_pad
  title_pad=$(printf '%*s' $((56 - ${#title})) '' | tr ' ' '─')
  printf "\n${C_BOLD}${C_BLUE}╭─ %s ${title_pad}╮${C_RESET}\n" "$title"

  if [ "$phase_count" = "0" ]; then
    printf "${C_BOLD}${C_BLUE}│${C_RESET}  ${C_DIM}(no phases registered yet)${C_RESET}\n"
  else
    # Iterate phases.
    i=0
    while [ "$i" -lt "$phase_count" ]; do
      local pid="${row_id[$i]}"
      local pname="${row_name[$i]}"
      local pstatus="${row_status[$i]}"
      local g
      g=$(glyph_for "$pstatus")

      local el="${elapsed_arr[$i]}"
      local el_label=""
      [ -n "$el" ] && el_label=$(format_elapsed "$el")

      local el_color="${C_DIM}"
      case "$pstatus" in
        in_progress) el_color="${C_BOLD}${C_CYAN}" ;;
        completed)   el_color="${C_GREEN}" ;;
        failed)      el_color="${C_RED}" ;;
        skipped)     el_color="${C_YELLOW}" ;;
      esac

      local p_tin="${row_tin[$i]}"
      local p_tout="${row_tout[$i]}"
      local p_ttotal=$((p_tin + p_tout))
      local tok_label=""
      [ "$p_ttotal" -gt 0 ] && tok_label="$(format_tokens $p_ttotal) tok"

      # Est. USD suffix - only when the phase has a priced model AND tokens.
      # "0" (floored below one cent) still renders as ~$0.00 so a priced phase
      # is visibly priced; an unpriceable phase shows no USD at all.
      local p_usd="${row_usd[$i]}"
      local usd_label=""
      if [ -n "$p_usd" ] && [ "$p_ttotal" -gt 0 ]; then
        usd_label=$(printf '~$%.2f' "$p_usd" 2>/dev/null) || usd_label=""
      fi
      [ -n "$usd_label" ] && [ -n "$tok_label" ] && tok_label="${tok_label} · ${usd_label}"

      local right_block=""
      if [ -n "$el_label" ] && [ -n "$tok_label" ]; then
        right_block=$(printf "%b%s%b ${C_DIM}·${C_RESET} %b%s%b" "$el_color" "$el_label" "$C_RESET" "$C_DIM" "$tok_label" "$C_RESET")
      elif [ -n "$el_label" ]; then
        right_block=$(printf "%b%s%b" "$el_color" "$el_label" "$C_RESET")
      elif [ -n "$tok_label" ]; then
        right_block=$(printf "%b%s%b" "$C_DIM" "$tok_label" "$C_RESET")
      fi

      printf "${C_BOLD}${C_BLUE}│${C_RESET}  %b  Phase %-2s %-14s %b\n" \
        "$g" "$pid" "${pname}" "$right_block"

      # Meta block: only for active (in_progress) phase. Pretty-print key/value
      # in insertion order so the pipeline's narrative sequence is preserved.
      # Meta values are fetched in a single jq pass per active phase (rare).
      # Same Unit Separator rationale as the phase batch above.
      if [ "$pstatus" = "in_progress" ] && [ -n "${row_metakeys[$i]}" ]; then
        local meta_data key val
        meta_data=$(echo "$state" | jq -r --arg pid "$pid" '
          ([.phases[] | select(.id == $pid)][0].meta) // {} | to_entries[] | [.key, (.value | tostring)] | join("")
        ')
        while IFS=$'\x1f' read -r key val; do
          [ -z "$key" ] && continue
          printf "${C_BOLD}${C_BLUE}│${C_RESET}      ${C_DIM}%-10s${C_RESET}  %s\n" "${key}:" "$val"
        done <<< "$meta_data"
      fi

      # Sub-phases  -  batched per parent phase via a single jq call. Same
      # Unit Separator rationale as the phase batch above.
      local sub_count="${row_subcount[$i]}"
      if [ "$sub_count" != "0" ] && [ "$sub_count" -gt 0 ] 2>/dev/null; then
        local sub_data
        sub_data=$(echo "$state" | jq -r --arg pid "$pid" '
          ([.phases[] | select(.id == $pid)][0].subs) // [] | .[] | [(.id // ""), (.name // ""), (.status // "")] | join("")
        ')
        local j=0
        local sid sname sstatus sg connector
        while IFS=$'\x1f' read -r sid sname sstatus; do
          sg=$(glyph_for "$sstatus")
          connector="├─"
          if [ "$j" = "$((sub_count - 1))" ]; then connector="└─"; fi
          printf "${C_BOLD}${C_BLUE}│${C_RESET}      ${connector} %b  ${C_DIM}%s.%s${C_RESET}  %s\n" \
            "$sg" "$pid" "$sid" "$sname"
          j=$((j+1))
        done <<< "$sub_data"
      fi
      i=$((i+1))
    done
  fi

  # Total footer  -  tokens + est. USD (priceable phases) + cached tokens.
  local total_tokens total_cached
  total_tokens=$(echo "$state" | jq '[.phases[] | ((.tokens_in // 0) + (.tokens_out // 0))] | add // 0')
  total_cached=$(echo "$state" | jq '[.phases[] | (.tokens_cached // 0)] | add // 0')
  if [ "$total_tokens" -gt 0 ]; then
    local total_label total_usd total_suffix=""
    total_label=$(format_tokens "$total_tokens")
    total_usd=$(phase_usd total)
    if [ -n "$total_usd" ] && [ "$total_usd" != "-" ]; then
      total_suffix=$(printf ' · ~$%.2f' "$total_usd" 2>/dev/null) || total_suffix=""
    fi
    if [ "$total_cached" -gt 0 ] 2>/dev/null; then
      total_suffix="${total_suffix}  ($(format_tokens "$total_cached") cached)"
    fi
    printf "${C_BOLD}${C_BLUE}│${C_RESET}  ${C_DIM}%s${C_RESET}\n" "$(printf '%*s' 56 '' | tr ' ' '─')"
    printf "${C_BOLD}${C_BLUE}│${C_RESET}  ${C_BOLD}Total${C_RESET}                         ${C_BOLD}${C_CYAN}%s tok%s${C_RESET}\n" "$total_label" "$total_suffix"
  fi

  printf "${C_BOLD}${C_BLUE}╰────────────────────────────────────────────────────────────╯${C_RESET}\n"
}

case "$ACTION" in

  init)
    [ "$#" -ge 1 ] || { echo "init needs <task_id>" >&2; exit 64; }
    TASK_ID="$1"
    # Honor an explicit $TRACKER_FILE (tests, isolated runs); only derive the
    # canonical home path when the caller did not supply one.
    if [ "$TRACKER_FILE_FROM_ENV" != "1" ]; then
      TRACKER_FILE="$HOME/.claude/logs/multi-agent/${TASK_ID}/tracker-state.json"
    fi
    TRACKER_DIR="$(dirname "$TRACKER_FILE")"
    mkdir -p "$TRACKER_DIR" 2>/dev/null
    NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    acquire_state_lock
    save_state "$(jq -nc --arg id "$TASK_ID" --arg ts "$NOW" \
      '{task_id:$id,started_at:$ts,phases:[]}')"
    release_state_lock
    # Update pointer so subsequent calls without env can find this tracker.
    # NOTE: the pointer is global; for concurrent runs on the same user account,
    # callers should set MULTI_AGENT_TASK_ID in their shell or prefix every
    # subsequent tracker call (the pointer flips on the most recent init).
    # Skipped ONLY when the path was supplied via $TRACKER_FILE: that caller
    # already named its own file and must not repoint the shared pointer at it.
    # (Smoke isolation is handled by a sandbox HOME, so it still writes a pointer
    # inside its own tree - which its later calls depend on.)
    if [ "$TRACKER_FILE_FROM_ENV" != "1" ]; then
      echo "$TRACKER_FILE" > "$HOME/.claude/logs/multi-agent/.tracker-current"
    fi
    render
    usage_live_ping "$TASK_ID" 0
    if [ "${MULTI_AGENT_QUIET:-0}" != "1" ]; then
      echo "phase-tracker: tracker initialized for task=$TASK_ID" >&2
      echo "phase-tracker: TIP  -  for concurrent-safe resolution, set:" >&2
      echo "    export MULTI_AGENT_TASK_ID=$TASK_ID" >&2
    fi
    ;;

  add)
    need_jq
    [ "$#" -ge 2 ] || { echo "add needs <phase_id> <name>" >&2; exit 64; }
    PID="$1"; PNAME="$(decode_entities "$2")"
    acquire_state_lock
    state=$(load_state)
    # Idempotent: a phase id that already exists is a no-op (name, status, and
    # token history are preserved). This is what lets continuation commands
    # (/multi-agent:resume-local, resume) re-declare their phase set against a
    # pre-existing tracker without duplicating or resetting tiles.
    new=$(echo "$state" | jq --arg id "$PID" --arg name "$PNAME" '
      if any(.phases[]?; .id == $id) then .
      else .phases += [{"id":$id,"name":$name,"status":"pending","subs":[]}]
      end')
    save_state "$new"
    release_state_lock
    render
    ;;

  update)
    need_jq
    [ "$#" -ge 2 ] || { echo "update needs <phase_id> <status> [--no-llm]" >&2; exit 64; }
    PID="$1"; STATUS="$2"; shift 2
    NO_LLM=0
    while [ "$#" -gt 0 ]; do
      case "$1" in
        --no-llm) NO_LLM=1 ;;
        *) echo "update: unknown option $1" >&2; exit 64 ;;
      esac
      shift
    done
    case "$STATUS" in
      pending|in_progress|completed|failed|skipped) ;;
      *) echo "bad status: $STATUS" >&2; exit 64 ;;
    esac
    # Accounting gate. A phase that ran LLM work and recorded nothing produces a
    # completion line with nothing to say and a cost breakdown that reads as
    # unavailable - which is exactly how per-phase token reporting went missing
    # for months while every gate stayed green, because the gates lint the docs
    # for the CALL and nothing checks that the call happened. Refusing the
    # completion is recoverable (record, then re-run) and loses no work; a
    # phase that genuinely made no LLM call says so with --no-llm.
    if [ "$STATUS" = "completed" ] && [ "$NO_LLM" -eq 0 ]; then
      case " ${TRACKER_LLM_PHASES:-1 2 3 4} " in
        *" $PID "*)
          RECORDED=$(load_state | jq -r --arg id "$PID" \
            '(.phases[] | select(.id == $id) | (.tokens_in // 0) + (.tokens_out // 0)) // 0')
          if [ "${RECORDED:-0}" -eq 0 ]; then
            cat >&2 <<GATE
update: phase $PID has no recorded spend, so its completion line would have
nothing to report and the run report would price it as unavailable.

Record it first, then re-run this update:
  phase-tracker.sh model  $PID <model_name>
  phase-tracker.sh tokens $PID <input_count> <output_count> [cached_count]

If the phase genuinely made no LLM call, say so explicitly:
  phase-tracker.sh update $PID completed --no-llm
GATE
            exit 3
          fi
          ;;
      esac
    fi
    NOW_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
    acquire_state_lock
    state=$(load_state)
    # Set status, stamp started_at on first transition to in_progress,
    # stamp completed_at on transition to terminal states (completed/failed/skipped).
    new=$(echo "$state" | jq --arg id "$PID" --arg s "$STATUS" --arg ts "$NOW_ISO" '
      .phases |= map(
        if .id == $id then
          .status = $s
          | (if $s == "in_progress" and (.started_at // "") == "" then .started_at = $ts else . end)
          | (if ($s == "completed" or $s == "failed" or $s == "skipped") then .completed_at = $ts else . end)
        else . end
      )
    ')
    if [ "$NO_LLM" -eq 1 ]; then
      new=$(echo "$new" | jq --arg id "$PID" '.phases |= map(if .id == $id then .no_llm = true else . end)')
    fi
    save_state "$new"
    release_state_lock
    emit_otel_span "phase.update" "$PID" "$STATUS" "{\"status\": \"$STATUS\"}"
    render
    tracker_next_hint "$PID" "$STATUS"
    usage_live_ping "$(basename "$TRACKER_DIR")" "$PID"
    ;;

  sub)
    need_jq
    [ "$#" -ge 3 ] || { echo "sub needs <phase_id> <sub_id> <name> [status]" >&2; exit 64; }
    PID="$1"; SID="$2"; SNAME="$3"; SSTATUS="${4:-pending}"
    case "$SSTATUS" in
      pending|in_progress|completed|failed|skipped) ;;
      *) echo "bad status: $SSTATUS" >&2; exit 64 ;;
    esac
    acquire_state_lock
    state=$(load_state)
    # If sub with this id exists under this phase, update; else append.
    new=$(echo "$state" | jq \
      --arg pid "$PID" --arg sid "$SID" --arg sname "$SNAME" --arg ss "$SSTATUS" \
      '
      .phases |= map(
        if .id == $pid then
          if any(.subs[]?; .id == $sid) then
            .subs |= map(if .id == $sid then .name = $sname | .status = $ss else . end)
          else
            .subs += [{"id":$sid,"name":$sname,"status":$ss}]
          end
        else . end
      )')
    save_state "$new"
    release_state_lock
    # Build the attrs with jq so a sub_id containing " or \ can't produce
    # malformed JSON that --argjson silently drops.
    emit_otel_span "phase.sub" "$PID" "$SNAME" \
      "$(jq -nc --arg s "$SID" --arg st "$SSTATUS" '{sub_id:$s,status:$st}')"
    render
    ;;

  tokens)
    need_jq
    [ "$#" -ge 3 ] || { echo "tokens needs <phase_id> <in> <out> [cached]" >&2; exit 64; }
    PID="$1"; T_IN="$2"; T_OUT="$3"; T_CACHED="${4:-0}"
    # Validate integers (cached is optional, defaults to 0 for back-compat).
    # Each field checked separately: concatenation would let an empty field
    # pass and feed jq --argjson an empty string, nuking the state file.
    for v in "$T_IN" "$T_OUT" "$T_CACHED"; do
      case "$v" in
        ''|*[!0-9]*) echo "tokens: in/out/cached must be non-negative integers" >&2; exit 64 ;;
      esac
    done
    acquire_state_lock
    state=$(load_state)
    # Additive: add to existing totals so multiple LLM calls accumulate.
    new=$(echo "$state" | jq --arg id "$PID" --argjson ti "$T_IN" --argjson to "$T_OUT" --argjson tc "$T_CACHED" '
      .phases |= map(
        if .id == $id then
          .tokens_in  = ((.tokens_in  // 0) + $ti)
          | .tokens_out = ((.tokens_out // 0) + $to)
          | (if $tc > 0 then .tokens_cached = ((.tokens_cached // 0) + $tc) else . end)
        else . end
      )
    ')
    save_state "$new"
    release_state_lock
    # Carry the phase's current model/name onto the span too: the cost
    # renderers' OTel-fallback path groups phase.tokens spans by phase_id and
    # reads model/phase_name off the first span in each group, so without
    # these the fallback always renders the default model/"?" name even
    # though the tracker JSON already knows both.
    PHASE_MODEL_ATTR=$(printf '%s' "$new" | jq -r --arg id "$PID" '(.phases[] | select(.id == $id) | .model) // ""')
    PHASE_NAME_ATTR=$(printf '%s' "$new" | jq -r --arg id "$PID" '(.phases[] | select(.id == $id) | .name) // ""')
    TOKENS_EXTRA=$(jq -nc \
      --argjson ti "$T_IN" --argjson to "$T_OUT" --argjson tc "$T_CACHED" \
      --arg model "$PHASE_MODEL_ATTR" --arg name "$PHASE_NAME_ATTR" \
      '{tokens_in_delta: $ti, tokens_out_delta: $to, tokens_cached_delta: $tc}
       + (if $model != "" then {model: $model} else {} end)
       + (if $name != "" then {phase_name: $name} else {} end)')
    emit_otel_span "phase.tokens" "$PID" "" "$TOKENS_EXTRA"
    # Re-render so live token count is visible on the active phase. Callers
    # that want silent accumulation (e.g. tight loops) can suppress with
    # TRACKER_QUIET=1 to skip the re-render but still write state.
    if [ "${TRACKER_QUIET:-0}" != "1" ]; then
      render
    fi
    ;;

  meta)
    need_jq
    [ "$#" -ge 3 ] || { echo "meta needs <phase_id> <key> <value>" >&2; exit 64; }
    PID="$1"; KEY="$2"; VALUE="$3"
    acquire_state_lock
    state=$(load_state)
    new=$(echo "$state" | jq --arg id "$PID" --arg k "$KEY" --arg v "$VALUE" '
      .phases |= map(
        if .id == $id then
          .meta = ((.meta // {}) + {($k): $v})
        else . end
      )')
    save_state "$new"
    release_state_lock
    META_EXTRA=$(jq -nc --arg k "$KEY" --arg v "$VALUE" '{meta_key: $k, meta_value: $v}')
    emit_otel_span "phase.meta" "$PID" "$KEY" "$META_EXTRA"
    if [ "${TRACKER_QUIET:-0}" != "1" ]; then
      render
    fi
    ;;

  model)
    need_jq
    [ "$#" -ge 2 ] || { echo "model needs <phase_id> <model_name>" >&2; exit 64; }
    PID="$1"; MODEL="$(normalize_model "$2")"
    if [ -f "$COST_TABLE" ] && ! jq -e --arg m "$MODEL" '.prices[$m]' "$COST_TABLE" >/dev/null 2>&1; then
      echo "phase-tracker: '$2' is not a priced model  -  this phase will report USD as '-'." >&2
      echo "  priced names: $(jq -r '.prices | keys | join(", ")' "$COST_TABLE" 2>/dev/null)" >&2
    fi
    acquire_state_lock
    state=$(load_state)
    new=$(echo "$state" | jq --arg id "$PID" --arg m "$MODEL" '
      .phases |= map(
        if .id == $id then .model = $m else . end
      )')
    save_state "$new"
    release_state_lock
    emit_otel_span "phase.model" "$PID" "" "$(jq -nc --arg m "$MODEL" '{model:$m}')"
    if [ "${TRACKER_QUIET:-0}" != "1" ]; then
      render
    fi
    ;;

  now)
    need_jq
    [ "$#" -ge 2 ] || { echo "now needs <phase_id> <text>" >&2; exit 64; }
    PID="$1"; TEXT="$2"
    # Live current-action line for the active phase. Quiet by construction:
    # progress lines are frequent, so `now` never renders - the card picks the
    # value up at the next boundary render/update. Truncated to 60 chars.
    # 57 + "..." keeps the documented 60-char cap now that the marker is three
    # ASCII dots rather than a one-character ellipsis.
    if [ "${#TEXT}" -gt 60 ]; then
      TEXT="${TEXT:0:57}..."
    fi
    acquire_state_lock
    state=$(load_state)
    new=$(echo "$state" | jq --arg id "$PID" --arg v "$TEXT" '
      .phases |= map(
        if .id == $id then .meta = ((.meta // {}) + {"Now": $v}) else . end
      )')
    save_state "$new"
    release_state_lock
    emit_otel_span "phase.now" "$PID" "$TEXT" "{}"
    ;;

  cost)
    need_jq
    [ "$#" -ge 1 ] || { echo "cost needs <phase_id>|total" >&2; exit 64; }
    SEL="$1"
    if [ "$SEL" = "total" ]; then
      phase_usd total
    else
      # Resolve phase id -> array index (ids are strings like "3").
      IDX=$(load_state | jq -r --arg id "$SEL" '[.phases[]?.id] | index($id) // "-"')
      if [ "$IDX" = "-" ] || [ -z "$IDX" ]; then
        echo "-"
      else
        phase_usd "$IDX"
      fi
    fi
    ;;

  subjects)
    subjects "${1:-}"
    ;;

  tiles)
    need_jq
    tiles_state=$(load_state)
    tiles_count=$(echo "$tiles_state" | jq '[.phases[]?] | length')
    [ "${tiles_count:-0}" -gt 0 ] || {
      echo "tiles: no phases registered  -  run 'add' for each phase first" >&2
      exit 64
    }
    case "$(host_kind)" in
      claude)
        # The task tools are not in every session, and the reason is the model,
        # not the CLI version. Claude Code provides TaskCreate / TaskUpdate by
        # default only on Claude 3.x, Opus 4 through 4.7, Sonnet 4 through 4.6
        # and Haiku 4.5; on any newer model it leaves them out unless the user
        # opts in, and that default landed in v2.1.268. This contract was written
        # when the tools were universal and stayed true for years, so nothing
        # noticed: the tracker state was written correctly, every phase advanced,
        # and the screen stayed empty for a whole run.
        #
        # A shell script cannot probe the model's tool list, so the branch is
        # taken where the information actually lives - the model knows which
        # tools it has. And the fallback is not enough on its own: a user looking
        # at a missing widget needs the one command that brings it back, which is
        # why the opt-in is printed next to it.
        echo "REQUIRED  -  create one native tile per phase, in this exact order,"
        echo "BEFORE any TaskUpdate. The widget renders by creation order, not by"
        echo "phase number, so an out-of-order call scrambles the stack."
        subjects | sed 's/^/  TaskCreate(subject: "/; s/$/")/'
        echo
        echo "At every phase boundary re-run \`phase-tracker.sh subjects <id>\` and"
        echo "pass that line as the subject of the TaskUpdate. The subject is the only"
        echo "string the native widget renders, so the model, the elapsed time and the"
        echo "token count reach the user through it or not at all."
        echo
        echo "IF TaskCreate IS NOT ONE OF YOUR TOOLS this session's model does not"
        echo "carry them (default since v2.1.268 on models newer than Opus 4.7 /"
        echo "Sonnet 4.6). Then the card below IS the widget, and at every phase"
        echo "boundary you run \`phase-tracker.sh render\` and paste its output"
        echo "into your reply VERBATIM, inside a code block  -  every phase on"
        echo "its own line, with the elapsed time, the token count and the total"
        echo "row exactly as printed. Do NOT redraw it, do not compact phases"
        echo "onto one line, do not drop the columns: those numbers are the"
        echo "whole reason the card is worth showing. Tool output is collapsed,"
        echo "so a card left in stdout never reaches the user."
        echo "Say once, in outputLanguage, that the native widget returns with:"
        echo "  CLAUDE_CODE_ENABLE_TODO_TOOLS=1 claude"
        echo
        render
        ;;
      codex)
        echo "REQUIRED  -  register the plan in ONE update_plan call with this step"
        echo "list. update_plan takes the full list, not a delta, so every later"
        echo "boundary resends it with one step's status changed."
        echo "$tiles_state" | jq -c '{plan: [.phases[] | {step: "Phase \(.id) \(.name)", status: "pending"}]}'
        ;;
      *)
        echo "Copilot CLI has no native task widget: the bordered card IS the widget."
        echo "Reprint it inside your reply at every phase boundary  -  tool output is"
        echo "collapsed, so a card left in stdout never reaches the user."
        render
        ;;
    esac
    ;;

  report)
    report
    ;;

  render)
    render
    ;;

  *)
    echo "phase-tracker: unknown action '$ACTION' (use init|add|update|sub|tokens|model|meta|now|cost|render|subjects)" >&2
    exit 64
    ;;
esac
