#!/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 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

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>" >&2; exit 64; }
    PID="$1"; STATUS="$2"
    case "$STATUS" in
      pending|in_progress|completed|failed|skipped) ;;
      *) echo "bad status: $STATUS" >&2; exit 64 ;;
    esac
    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
      )
    ')
    save_state "$new"
    release_state_lock
    emit_otel_span "phase.update" "$PID" "$STATUS" "{\"status\": \"$STATUS\"}"
    render
    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="$2"
    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
    ;;

  render)
    render
    ;;

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