#!/usr/bin/env bash
#clear
#export PS4='\e[90m+${LINENO} in ${#BASH_SOURCE[@]}>${FUNCNAME[0]}:${BASH_SOURCE[@]##*/} \e[0m'
#set -x

#echo "starting: $0 <LOG_LEVEL=$1>"

### new.method

# ============================================================================
# hiveMind - Multi-agent orchestrator for oosh
# Manages REAL Claude Code agents in tmux panes using agentRoom backend
# ============================================================================

# ─────────────────────────────────────────────────────────────────────────────
# CONFIGURATION
# ─────────────────────────────────────────────────────────────────────────────

: ${HIVEMIND_SESSION:=hivemind}
: ${HIVEMIND_MAIN_PANE_SIZE:=60}
: ${HIVEMIND_BASE_PORT:=11080}

# ── Naming conventions ────────────────────────────────────────────────────────
# tmux session  : <teamName>            e.g. cursorOrchestrator, ooshTeam
# tmux window   : team                  window 0 is always "team"
# env per pane  : HIVEMIND_ROLE=<role>  exported before Claude Code starts
# role registry : ~/config/hivemind.roles.env  write-through cache: pane targets → role names
#                 format: target|role  (fallback — live discovery is primary)
# session store : ~/config/hivemind.sessions.env  maps pane → Claude session UUID
#                 format: pane|session-uuid  (allows session rejoin, pane-scoped)
: ${HIVEMIND_WINDOW_NAME:=team}
: ${HIVEMIND_REGISTRY:=${CONFIG_PATH:-$HOME/config}/hivemind.roles.env}
: ${HIVEMIND_SESSIONS:=${CONFIG_PATH:-$HOME/config}/hivemind.sessions.env}
: ${HIVEMIND_TEAMS:=${CONFIG_PATH:-$HOME/config}/hivemind.teams.env}
: ${HIVEMIND_FORKS:=${CONFIG_PATH:-$HOME/config}/hivemind.forks.env}
: ${HIVEMIND_ACTIVE_TEAM_FILE:=${CONFIG_PATH:-$HOME/config}/hivemind.active.team}
: ${HIVEMIND_SNAPSHOTS:=${CONFIG_PATH:-$HOME/config}/hivemind.snapshots.env}
: ${HIVEMIND_SNAPSHOT_VERSION:=1}
: ${HIVEMIND_QUEUE_DIR:=${CONFIG_PATH:-$HOME/config}/hivemind.queue}
: ${HIVEMIND_QUEUE_MAX_DEPTH:=50}
: ${HIVEMIND_QUEUE_MAX_AGE_SEC:=3600}

# Naming convention (sprint-1-state-correctness/naming-migration-design.md):
#   Option C (unified — TRON directive 2026-05-24): role@HIVEMIND_HOST EVERYWHERE.
#   pane title    = role@HIVEMIND_HOST  (WHERE — humans reading otmux tree)
#   /rename       = role@HIVEMIND_HOST  (Claude customTitle — same value)
#   registry      = bare role           (machine-readable)
# role.fromTitle() strips @* — single parse point. pane.lock prevents Claude
# from overwriting the pane title (without lock, /rename propagates back).
: ${HIVEMIND_HOST:=$(hostname -s 2>/dev/null)}
# HIVEMIND_DEFAULT_MODEL kept for backwards compat; no longer used in /rename
# (Option A retired). Reserved for future model-selection use.
: ${HIVEMIND_DEFAULT_MODEL:=opus}

# B5.1 — TTL window during which manual registry.set takes priority over live
# discovery. After registry.set, the entry is "fresh"; live discovery will not
# overwrite it for HIVEMIND_REGISTRY_TTL seconds. After the window expires, live
# discovery resumes priority (handles cases where the agent /rename'd to a
# different role or moved panes externally).
: ${HIVEMIND_REGISTRY_TTL:=30}

# Default agent roles
HIVEMIND_DEFAULT_AGENTS=(
  "orchestrator"
  "coder"
  "tester"
  "reviewer"
)

# OOSH specialized agent roles
HIVEMIND_OOSH_AGENTS=(
  "oosh-expert"
  "oosh-tester"
)

# Canonical location for agent role definitions
# Resolved at runtime by private.hiveMind.find.agents.dir (searches upward from OOSH_DIR)
# Override via env or config: HIVEMIND_AGENTS_DIR=/path/to/.claude/agents

# Agent role descriptions (used for system prompts)
# Bash 3.2 compatible — uses function lookup instead of declare -A
# Roles with SKILL.md files get auto-loaded from .claude/agents/<role>/SKILL.md
private.hiveMind.get.role.prompt() {
  local role="$1"
  [ -z "$role" ] && return 1

  # Resolve agents dir if not set
  local agents_dir="$HIVEMIND_AGENTS_DIR"
  if [ -z "$agents_dir" ]; then
    agents_dir=$(private.hiveMind.find.agents.dir 2>/dev/null) || true
  fi

  # Role aliases: map alternate names to canonical role directories
  case "$role" in
    orchestrator|oosh-orchestrator) role="agent-teacher" ;;
  esac

  # Dynamic SKILL.md lookup
  local skill_file="$agents_dir/$role/SKILL.md"
  if [ -n "$agents_dir" ] && [ -f "$skill_file" ]; then
    # Extract description from YAML frontmatter (line starting with description:)
    local desc
    desc=$(sed -n '/^---$/,/^---$/p' "$skill_file" | grep '^description:' | head -1 | sed 's/^description: *//; s/^"//; s/"$//')
    if [ -n "$desc" ]; then
      echo "You are the $role. Read .claude/agents/$role/SKILL.md to learn your role. $desc"
    else
      echo "You are the $role. Read .claude/agents/$role/SKILL.md to learn your role."
    fi
    return 0
  fi

  # No SKILL.md found — unknown role
  return 1
}

# ─────────────────────────────────────────────────────────────────────────────
# PRIVATE HELPERS
# ─────────────────────────────────────────────────────────────────────────────

private.hiveMind.current.session() { # # return current tmux session name
  otmux pane.get '#{session_name}' 2>/dev/null || return 1
}

private.hiveMind.role.isGeneric() { # <agentName> # true if role is a generic placeholder
  local r="$1"
  [ -z "$r" ] || [ "$r" = "unknown" ] || [ "$r" = "ClaudeCode" ] || [ "$r" = "claude" ]
}

private.hiveMind.role.fromTitle() { # <title> # extract role name from pane title, stripping prefixes
  local r="$1"
  r="${r#✳ }"; r="${r#⠐ }"; r="${r#⠂ }"
  r="${r#✻ }"; r="${r#✢ }"; r="${r#✶ }"
  r="${r%%@*}"
  r=$(echo "$r" | tr -d '[:space:]' | head -c 30)
  private.hiveMind.role.isGeneric "$r" && return 1
  echo "$r"
}

private.hiveMind.env.set() { # <file> <key> <value> # set key|value in env file (add or replace)
  local file="$1" key="$2" value="$3"
  local old
  old=$(grep "^${key}|" "$file" 2>/dev/null | head -1 | cut -d'|' -f2)
  if [ -z "$old" ]; then
    echo "${key}|${value}" >> "$file"
  elif [ "$old" != "$value" ]; then
    sed "s#^${key}|${old}\$#${key}|${value}#" "$file" > "${file}.tmp" && mv "${file}.tmp" "$file"
  else
    return 1  # no change needed
  fi
  return 0
}

private.hiveMind.env.del() { # <file> <key> <?value> # delete matching line from env file
  local file="$1" key="$2" value="$3"
  if [ -n "$value" ]; then
    sed "/^${key}|${value}\$/d" "$file" > "${file}.tmp" && mv "${file}.tmp" "$file"
  else
    sed "/^${key}|/d" "$file" > "${file}.tmp" && mv "${file}.tmp" "$file"
  fi
}

private.hiveMind.liveUuid() { # <paneTarget> <?procArgs:ignored> <?probe> # get live session UUID (thin wrapper over claudeCode.session.current)
  # The procArgs parameter is retained for backward compatibility but ignored —
  # session.current handles forks via JSONL customTitle match, not ps-args.
  local target="$1" probe="${3:-}"
  local sid
  sid=$("$OOSH_DIR/claudeCode" session.current "$target" 2>/dev/null)
  [ -n "$sid" ] && echo "$sid" && return 0
  # Invasive fallback only when explicitly requested (e.g. consistency.fix).
  if [ "$probe" = "probe" ]; then
    sid=$(hiveMind.agent.session.probe "$target" 2>/dev/null)
    [ -n "$sid" ] && echo "$sid" && return 0
  fi
  return 1
}

private.hiveMind.session.resolve.uuid() # <pane> # UUID discovery — pure bash, handles forks + autocompact
{
  # Thin wrapper over claudeCode.session.current. The complex fork/autocompact
  # logic that used to live here is now centralized in session.discover (via
  # customTitle JSONL correlation + cwd disambiguator + state classifier).
  # This function retains ONLY the hiveMind-specific concern: write-through
  # to sessions.env cache so subsequent session.id lookups are fast.
  local target="$1"
  [ -z "$target" ] && return 1

  local sid
  sid=$("$OOSH_DIR/claudeCode" session.current "$target" 2>/dev/null)
  [ -z "$sid" ] && return 1

  # Write-through: update sessions.env if our resolved UUID differs from cache
  local sesFile="${HIVEMIND_SESSIONS:-${CONFIG_PATH:-$HOME/config}/hivemind.sessions.env}"
  if [ -f "$sesFile" ]; then
    local cached
    cached=$(grep "^${target}|" "$sesFile" 2>/dev/null | head -1 | cut -d'|' -f2)
    if [ "$cached" != "$sid" ]; then
      grep -v "^${target}|" "$sesFile" > "${sesFile}.tmp" 2>/dev/null && mv "${sesFile}.tmp" "$sesFile"
      echo "${target}|${sid}" >> "$sesFile"
    fi
  fi

  echo "$sid"
  return 0
}

private.hiveMind.pane.count() { # <target> # count panes in a window or session
  otmux panes -t "$1" ${2:+-s} 2>/dev/null | wc -l | tr -d ' '
}

private.hiveMind.claude.processes() { # # list Claude processes with pane targets: pid|tty|paneTarget|title|args
  # Bash 3.2 compat (task #29): assoc arrays unavailable. Store pane info as
  # `paneTty|paneRef|paneTitle\n` lines, lookup via grep.
  local paneInfo
  paneInfo=$(private.hiveMind.list.panes tty+title)
  while read -r pid tty comm rest; do
    [ -z "$pid" ] && continue
    [[ "$comm" == "bash" || "$comm" == "zsh" || "$comm" == "node" ]] && continue
    local ttyDev="/dev/$tty"
    local hit paneTarget title
    hit=$(echo "$paneInfo" | grep "^${ttyDev}|" | head -1)
    [ -z "$hit" ] && continue
    paneTarget=$(echo "$hit" | cut -d'|' -f2)
    title=$(echo "$hit" | cut -d'|' -f3-)
    echo "${pid}|${tty}|${paneTarget}|${title}|${rest}"
  done < <(ps -eo pid,tty,comm,args 2>/dev/null | grep -i 'claude' | grep -v grep | awk '{print $1, $2, $3, substr($0, index($0,$4))}')
}

private.hiveMind.ensure.pane() { # <session:window.pane> # create session/window/pane if missing
  local target="$1"
  local sess="${target%%:*}"
  local rest="${target#*:}"
  local win="${rest%%.*}"
  local pane="${rest#*.}"
  # Ensure session
  if ! otmux has "$sess" 2>/dev/null; then
    otmux new "$sess" -d -x 200 -y 50 2>/dev/null || return 1
  fi
  # Ensure window at exact index
  if ! otmux windows -t "$sess" -F "#{window_index}" 2>/dev/null | grep -qx "$win"; then
    otmux window.new -d -t "${sess}:${win}" 2>/dev/null || return 1
  fi
  # Ensure pane (split until enough)
  local current needed=$((pane + 1))
  current=$(private.hiveMind.pane.count "${sess}:${win}")
  while [ "$current" -lt "$needed" ]; do
    otmux split -d -t "${sess}:${win}" 2>/dev/null || break
    otmux tiled "${sess}:${win}" 2>/dev/null
    current=$(private.hiveMind.pane.count "${sess}:${win}")
  done
  [ "$current" -ge "$needed" ]
}

private.hiveMind.list.panes() { # <format> <?scope> # list panes with standard format strings
  # format: tty, tty+title, addr+cmd, addr, or raw tmux format string
  # scope: -a (all sessions, default) or session name
  local format="$1" scope="${2:--a}"
  local fmtStr
  case "$format" in
    tty)       fmtStr="#{pane_tty} #{session_name}:#{window_index}.#{pane_index}" ;;
    tty+title) fmtStr="#{pane_tty}|#{session_name}:#{window_index}.#{pane_index}|#{pane_title}" ;;
    addr+cmd)  fmtStr="#{window_index}.#{pane_index}|#{pane_current_command}" ;;
    addr)      fmtStr="#{session_name}:#{window_index}.#{pane_index}" ;;
    *)         fmtStr="$format" ;;
  esac
  if [ "$scope" = "-a" ]; then
    otmux panes -a -F "$fmtStr" 2>/dev/null
  else
    otmux panes -t "$scope" -s -F "$fmtStr" 2>/dev/null
  fi
}

private.hiveMind.find.agents.dir() {
  # Search upward from multiple starting points for .claude/agents with SKILL.md files
  # Tries: CWD, OOSH_DIR (resolved), symlink source — handles cross-repo layouts
  local search_dirs=()
  [ -n "$PWD" ] && search_dirs+=("$PWD")
  local resolved
  resolved=$(realpath "${OOSH_DIR:-.}" 2>/dev/null)
  [ -n "$resolved" ] && search_dirs+=("$resolved")
  [ -n "$OOSH_DIR" ] && [ "$OOSH_DIR" != "$resolved" ] && search_dirs+=("$OOSH_DIR")

  local start_dir
  for start_dir in "${search_dirs[@]}"; do
    local dir="$start_dir"
    while [ "$dir" != "/" ]; do
      if [ -d "$dir/.claude/agents" ]; then
        local has_skills=false
        for f in "$dir/.claude/agents"/*/SKILL.md; do
          [ -f "$f" ] && has_skills=true && break
        done
        [ "$has_skills" = "true" ] && echo "$dir/.claude/agents" && return 0
      fi
      dir=$(dirname "$dir")
    done
  done
  return 0
}

private.hiveMind.pane.identify() {
  # Register role for a pane: write to registry, set pane title, export env var
  # Usage: private.hiveMind.pane.identify <target> <role>
  local target="$1"
  local role="$2"
  private.hiveMind.registry.set "$target" "$role"
  # pane.lock (not pane.title): prevents Claude's /rename from overwriting the
  # title back to its customTitle (Option C — TRON 2026-05-24).
  otmux pane.lock "$target" "${role}@${HIVEMIND_HOST}"
  otmux send.enter "$target" "export FORCE_COLOR=2; unset COLORTERM; unset CLAUDECODE"
  otmux send.enter "$target" "export HIVEMIND_ROLE=$role"
}

# ─────────────────────────────────────────────────────────────────────────────
# SC-B.1 — Event dispatch primitives (Sprint 1 state-correctness)
# Function-table dispatch for cross-store state mutations. Per design §4 +
# expert review Q5 answer, the table lives in hiveMind (not a new script) and
# is in-process for hiveMind→hiveMind handlers. Cross-script observers still
# use the B5.1 subprocess pattern: `hiveMind protected.<event>`.
# PO-locked constraints:
#   U1 — handler failure: log+continue, never abort the mutation (reconcile catches drift)
# ─────────────────────────────────────────────────────────────────────────────

: ${HIVEMIND_EVENTS_LOG:=${CONFIG_PATH:-$HOME/config}/hivemind.events.log}
: ${HIVEMIND_EVENTS_LOG_MAX_BYTES:=1048576}   # 1 MiB before rotation (SC-B.2)

# Backing store — associative: event name → space-separated handler list.
# Declared once at script-load; idempotent under repeated source.
# Bash 3.2 compat (task #29): declare -gA and assoc arrays are bash 4+.
# On bash 3.2 (macOS default), the event system silently no-ops — handlers
# never fire but the rest of hiveMind keeps working. Use bash 5 (homebrew /
# `source ~/config/user.env`) for full SC-B event propagation.
HIVEMIND_EVENTS_AVAILABLE=""
if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
  if ! declare -p HIVEMIND_EVENT_HANDLERS 2>/dev/null | grep -q '^declare -A'; then
    declare -gA HIVEMIND_EVENT_HANDLERS=()
  fi
  HIVEMIND_EVENTS_AVAILABLE=1
fi

private.hiveMind.events.register() # <eventName> <handlerFunction> # idempotent — re-registering the same handler is a no-op
{
  local event="$1" handler="$2"
  if [ -z "$event" ] || [ -z "$handler" ]; then
    error.log "events.register: usage <eventName> <handlerFunction>"
    return 1
  fi
  # Bash 3.2 fallback: assoc arrays unavailable, silently no-op.
  [ -z "$HIVEMIND_EVENTS_AVAILABLE" ] && return 0
  # Idempotent: only add if not already in the space-separated list
  local current="${HIVEMIND_EVENT_HANDLERS[$event]:-}"
  case " $current " in
    *" $handler "*) return 0 ;;  # already registered
  esac
  HIVEMIND_EVENT_HANDLERS["$event"]="${current:+${current} }${handler}"
  return 0
}

private.hiveMind.events.emit() # <eventName> <arg...> # fan-out to all registered handlers, log+continue on failure (U1)
{
  local event="$1"; shift
  if [ -z "$event" ]; then
    error.log "events.emit: usage <eventName> <arg...>"
    return 1
  fi
  # Append to history log (SC-B.2 rotation) — works on any bash version
  private.hiveMind.events.history.append "$event" "$@"
  # Bash 3.2 fallback: assoc arrays unavailable, no handlers to fan out to.
  [ -z "$HIVEMIND_EVENTS_AVAILABLE" ] && return 0
  local handlers="${HIVEMIND_EVENT_HANDLERS[$event]:-}"
  [ -z "$handlers" ] && return 0   # no handlers = silent no-op
  local h rc
  for h in $handlers; do
    # Each handler runs isolated — failure logs but doesn't abort siblings (U1)
    "$h" "$@"
    rc=$?
    if [ "$rc" -ne 0 ]; then
      error.log "events.emit: handler '$h' for event '$event' returned rc=$rc (continuing)"
    fi
  done
  return 0
}

private.hiveMind.events.history.append() # <event> <arg...> # internal: append to log, rotate at 1 MiB
{
  local event="$1"; shift
  local ts size
  ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
  # Rotation: portable size check via wc -c (BSD + GNU compatible)
  if [ -f "$HIVEMIND_EVENTS_LOG" ]; then
    size=$(wc -c < "$HIVEMIND_EVENTS_LOG" 2>/dev/null | tr -d ' ')
    if [ -n "$size" ] && [ "$size" -ge "${HIVEMIND_EVENTS_LOG_MAX_BYTES}" ] 2>/dev/null; then
      mv "$HIVEMIND_EVENTS_LOG" "${HIVEMIND_EVENTS_LOG}.1" 2>/dev/null
    fi
  fi
  printf '%s|%s|%s\n' "$ts" "$event" "$*" >> "$HIVEMIND_EVENTS_LOG" 2>/dev/null
}

hiveMind.protected.events.register() # <eventName> <handlerFunction> # CLI wrapper for tests
{
  private.hiveMind.events.register "$@"
}

hiveMind.protected.events.emit() # <eventName> <arg...> # CLI wrapper for tests + cross-script events
{
  private.hiveMind.events.emit "$@"
}

hiveMind.events.list() # # show registered events and handler counts
{
  if [ "${#HIVEMIND_EVENT_HANDLERS[@]}" -eq 0 ]; then
    info.log "No events registered"
    return 0
  fi
  printf '%-32s %4s  %s\n' "EVENT" "N" "HANDLERS"
  printf '%-32s %4s  %s\n' "─────" "─" "────────"
  local event handlers count
  for event in "${!HIVEMIND_EVENT_HANDLERS[@]}"; do
    handlers="${HIVEMIND_EVENT_HANDLERS[$event]}"
    count=$(echo "$handlers" | wc -w | tr -d ' ')
    printf '%-32s %4s  %s\n' "$event" "$count" "$handlers"
  done | sort
}

hiveMind.events.history() # <?lines:50> # tail the events log
{
  local lines="${1:-50}"
  if [ ! -f "$HIVEMIND_EVENTS_LOG" ]; then
    info.log "No events log yet ($HIVEMIND_EVENTS_LOG)"
    return 0
  fi
  tail -n "$lines" "$HIVEMIND_EVENTS_LOG"
}
hiveMind.events.history.completion.lines() {
  for n in 10 25 50 100 200; do echo "$n"; done
}

# ─────────────────────────────────────────────────────────────────────────────
# SC-C — Event handlers (Sprint 1 design §4 catalog)
# ─────────────────────────────────────────────────────────────────────────────
# One handler per (event, target store). Registered at script-load below.
# Failure of any handler logs+continues per U1 — the event.emit primitive
# already wraps each call with rc capture, so handlers can return non-zero
# safely.
# Migration note: existing direct calls in mutation sites (e.g. agent.bootstrap
# calling private.hiveMind.registry.set directly) are RETAINED during Sprint 1.
# They become idempotent siblings of the event-driven path. Once SC-C tests
# confirm handlers cover all paths reliably, a future refactor will remove the
# direct calls and rely solely on event dispatch.

# ── SC-C.1 agent.spawned ─── args: <pane> <role> <?uuid> ────────────────────

private.hiveMind.handler.agent.spawned.registry() {
  local pane="$1" role="$2"
  [ -z "$pane" ] || [ -z "$role" ] && return 0
  private.hiveMind.registry.set "$pane" "$role"
}

private.hiveMind.handler.agent.spawned.sessions() {
  local pane="$1" role="$2" uuid="$3"
  [ -z "$pane" ] && return 0
  if [ -n "$uuid" ]; then
    private.hiveMind.session.store "$pane" "$uuid"
  elif [ -n "$role" ]; then
    # SC-H.2 Gap A: probe race — UUID unknown at spawn time. Schedule
    # background retries so S2 catches up without operator intervention.
    private.hiveMind.session.store.deferred "$pane" "$role"
  fi
}

# ── SC-C.2 agent.killed ─── args: <pane> ────────────────────────────────────

private.hiveMind.handler.agent.killed.registry() {
  local pane="$1"
  [ -z "$pane" ] && return 0
  [ -f "$HIVEMIND_REGISTRY" ] || return 0
  grep -v "^${pane}|" "$HIVEMIND_REGISTRY" > "${HIVEMIND_REGISTRY}.tmp" 2>/dev/null
  mv "${HIVEMIND_REGISTRY}.tmp" "$HIVEMIND_REGISTRY"
}

private.hiveMind.handler.agent.killed.sessions() {
  local pane="$1"
  [ -z "$pane" ] && return 0
  [ -f "$HIVEMIND_SESSIONS" ] || return 0
  grep -v "^${pane}|" "$HIVEMIND_SESSIONS" > "${HIVEMIND_SESSIONS}.tmp" 2>/dev/null
  mv "${HIVEMIND_SESSIONS}.tmp" "$HIVEMIND_SESSIONS"
}

private.hiveMind.handler.agent.killed.queue() {
  local pane="$1"
  [ -z "$pane" ] && return 0
  # queue.path may not exist yet if queue helpers aren't sourced — soft-fail.
  local qfile
  qfile=$(private.hiveMind.agent.queue.path "$pane" 2>/dev/null) || return 0
  [ -n "$qfile" ] && [ -f "$qfile" ] && rm -f "$qfile"
  return 0
}

# ── SC-C.3 agent.renamed ─── args: <pane> <oldName> <newName> ──────────────
# Fires after /rename inside a Claude TUI. Handlers update:
#   - S1 (roles.env): pane → newName (overwrite oldName)
#   - title:           otmux pane.lock pins the new title
#   - role.env:        push HIVEMIND_ROLE=newName to pane shell (Bug #3)

private.hiveMind.handler.agent.renamed.registry() {
  local pane="$1" newName="$3"
  [ -z "$pane" ] || [ -z "$newName" ] && return 0
  private.hiveMind.registry.set "$pane" "$newName"
}

private.hiveMind.handler.agent.renamed.title() {
  local pane="$1" newName="$3"
  [ -z "$pane" ] || [ -z "$newName" ] && return 0
  # Defensive: skip if pane doesn't exist (test fixtures or stale agent.rename).
  # pane.lock spawns a background enforcer; without this guard it'd churn.
  tmux list-panes -t "$pane" -F '#{pane_id}' >/dev/null 2>&1 || return 0
  otmux pane.lock "$pane" "${newName}@${HIVEMIND_HOST}" 2>/dev/null
  return 0
}

private.hiveMind.handler.agent.renamed.role_env() {
  local pane="$1" newName="$3"
  [ -z "$pane" ] || [ -z "$newName" ] && return 0
  private.hiveMind.pane.pushRoleEnv "$pane" "$newName"
}

# ── SC-C.4 agent.forked ─── args: <pane> <role> <parentUuid> <childUuid> ───
# Fires after a successful claudeCode fork. Handlers update:
#   - S1 (roles.env): pane → role (reaffirm post-fork)
#   - S2 (sessions.env): pane → childUuid
#   - S5 (forks.env): append lineage row `ts|pane|role|childUuid|live|parentUuid`

private.hiveMind.handler.agent.forked.registry() {
  local pane="$1" role="$2"
  [ -z "$pane" ] || [ -z "$role" ] && return 0
  private.hiveMind.registry.set "$pane" "$role"
}

private.hiveMind.handler.agent.forked.sessions() {
  local pane="$1" childUuid="$4"
  [ -z "$pane" ] || [ -z "$childUuid" ] && return 0
  private.hiveMind.session.store "$pane" "$childUuid"
}

private.hiveMind.handler.agent.forked.forks() {
  local pane="$1" role="$2" parentUuid="$3" childUuid="$4"
  [ -z "$pane" ] || [ -z "$childUuid" ] && return 0
  local forksFile="${HIVEMIND_FORKS:-${CONFIG_PATH:-$HOME/config}/hivemind.forks.env}"
  local dir
  dir=$(dirname "$forksFile")
  mkdir -p "$dir" 2>/dev/null
  local ts
  ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
  echo "${ts}|${pane}|${role}|${childUuid}|live|${parentUuid}" >> "$forksFile"
}

# ── SC-C.5 panes.shifted ─── args: <session> ───────────────────────────────
# Fired after split/insert in a session — pane indices may have changed; live
# discovery reconciles. queue.rename handler stub (future work — needs old→new
# index map; current B5.1 caller passes only session, not the shift delta).

private.hiveMind.handler.panes.shifted.registry() {
  local session="$1"
  [ -z "$session" ] && return 0
  otmux has "$session" 2>/dev/null || return 0
  if type -t hiveMind.registry.refresh >/dev/null 2>&1; then
    hiveMind.registry.refresh "$session" 2>/dev/null
  fi
}

# ── SC-C.6 panes.swapped ─── args: <session> <paneA> <paneB> ───────────────
# Two handlers: swap registry entries + push HIVEMIND_ROLE env to each shell
# (the agents moved with the panes, so each pane shell needs new role env).
# Pane address normalization (B5.2 SWAP-1): callers may pass addr-only ('0.0')
# or full target ('teamX:0.0') — handlers prepend session when prefix missing.

private.hiveMind.handler.panes.swapped.registry() {
  local session="$1" a="$2" b="$3"
  [ -z "$a" ] || [ -z "$b" ] && return 0
  [[ "$a" != *:* ]] && [ -n "$session" ] && a="${session}:${a}"
  [[ "$b" != *:* ]] && [ -n "$session" ] && b="${session}:${b}"
  local reg="${HIVEMIND_REGISTRY:-${CONFIG_PATH:-$HOME/config}/hivemind.roles.env}"
  [ -f "$reg" ] || return 0

  local roleA roleB
  roleA=$(grep "^${a}|" "$reg" 2>/dev/null | head -1 | cut -d'|' -f2)
  roleB=$(grep "^${b}|" "$reg" 2>/dev/null | head -1 | cut -d'|' -f2)
  grep -vE "^(${a}|${b})\|" "$reg" > "${reg}.tmp" 2>/dev/null
  mv "${reg}.tmp" "$reg"
  [ -n "$roleA" ] && private.hiveMind.registry.set "$b" "$roleA"
  [ -n "$roleB" ] && private.hiveMind.registry.set "$a" "$roleB"
  info.log "Panes swapped: $a ⇄ $b (roles: ${roleA:-∅}|${roleB:-∅} → ${roleB:-∅}|${roleA:-∅})"
}

private.hiveMind.handler.panes.swapped.role_env() {
  local session="$1" a="$2" b="$3"
  [ -z "$a" ] || [ -z "$b" ] && return 0
  [[ "$a" != *:* ]] && [ -n "$session" ] && a="${session}:${a}"
  [[ "$b" != *:* ]] && [ -n "$session" ] && b="${session}:${b}"
  local reg="${HIVEMIND_REGISTRY:-${CONFIG_PATH:-$HOME/config}/hivemind.roles.env}"
  [ -f "$reg" ] || return 0
  # Read POST-swap roles (registry handler ran first per registration order)
  local newA newB
  newA=$(grep "^${a}|" "$reg" 2>/dev/null | head -1 | cut -d'|' -f2)
  newB=$(grep "^${b}|" "$reg" 2>/dev/null | head -1 | cut -d'|' -f2)
  [ -n "$newA" ] && private.hiveMind.pane.pushRoleEnv "$a" "$newA"
  [ -n "$newB" ] && private.hiveMind.pane.pushRoleEnv "$b" "$newB"
}

# ── SC-C.7 pane.moved ─── args: <fromPane> <toPane> ────────────────────────
# Two handlers: rename registry key + push HIVEMIND_ROLE to destination shell.

private.hiveMind.handler.pane.moved.registry() {
  local from="$1" to="$2"
  [ -z "$from" ] || [ -z "$to" ] && return 0
  local reg="${HIVEMIND_REGISTRY:-${CONFIG_PATH:-$HOME/config}/hivemind.roles.env}"
  [ -f "$reg" ] || return 0
  local role
  role=$(grep "^${from}|" "$reg" 2>/dev/null | head -1 | cut -d'|' -f2)
  [ -z "$role" ] && return 0
  grep -v "^${from}|" "$reg" > "${reg}.tmp" 2>/dev/null
  mv "${reg}.tmp" "$reg"
  private.hiveMind.registry.set "$to" "$role"
  info.log "Pane moved: $from → $to (role $role preserved)"
}

private.hiveMind.handler.pane.moved.role_env() {
  local from="$1" to="$2"
  [ -z "$to" ] && return 0
  local reg="${HIVEMIND_REGISTRY:-${CONFIG_PATH:-$HOME/config}/hivemind.roles.env}"
  [ -f "$reg" ] || return 0
  # Read role from post-move location (registry handler ran first)
  local role
  role=$(grep "^${to}|" "$reg" 2>/dev/null | head -1 | cut -d'|' -f2)
  [ -n "$role" ] && private.hiveMind.pane.pushRoleEnv "$to" "$role"
}

# ── SC-C.8 team.created ─── args: <session> <?description> ─────────────────
# Fired after team.register confirms a session is a valid live tmux team.
# Handlers:
#   - teams:       append session|description to teams.env (Idempotent — caller
#                  already wrote the row; this handler is a write-through audit
#                  hook in case future emitters bypass team.register)
#   - tronMonitor: open a monitor window for the team (closes V→C event-gap —
#                  was a direct call in team.register, now an event handler)

private.hiveMind.handler.team.created.teams() {
  local session="$1" description="$2"
  [ -z "$session" ] && return 0
  local teamsFile="${HIVEMIND_TEAMS:-${CONFIG_PATH:-$HOME/config}/hivemind.teams.env}"
  # Idempotent: only append if not already present (team.register's own write
  # is the primary path; this catches emitters that bypass it).
  [ -f "$teamsFile" ] && grep -q "^${session}|" "$teamsFile" 2>/dev/null && return 0
  private.hiveMind.teams.ensure.dir 2>/dev/null
  echo "${session}|${description}" >> "$teamsFile"
}

private.hiveMind.handler.team.created.tronMonitor() {
  local session="$1"
  [ -z "$session" ] && return 0
  command -v tronMonitor >/dev/null 2>&1 || return 0
  # Soft-fail per U1 — tronMonitor add may fail (screen not running, name
  # collision); registration succeeded regardless.
  tronMonitor add "$session" 2>/dev/null || \
    info.log "tronMonitor add failed/skipped for $session (non-fatal)"
}

# ── SC-C.9 team.destroyed ─── args: <session> ──────────────────────────────
# Fired after team.remove confirms the team is gone. Cascade-cleans:
#   - teams:        remove row from teams.env (idempotent — caller may have
#                   already done this; safe to re-run)
#   - tronMonitor:  close monitor window
#   - registry:     prune all pane entries belonging to this session (S1)
#   - sessions:     prune all pane entries belonging to this session (S2)
#   - queue:        remove queue files for panes in this session (S6)

private.hiveMind.handler.team.destroyed.teams() {
  local session="$1"
  [ -z "$session" ] && return 0
  local teamsFile="${HIVEMIND_TEAMS:-${CONFIG_PATH:-$HOME/config}/hivemind.teams.env}"
  [ -f "$teamsFile" ] || return 0
  grep -v "^${session}|" "$teamsFile" > "${teamsFile}.tmp" 2>/dev/null
  mv "${teamsFile}.tmp" "$teamsFile"
}

private.hiveMind.handler.team.destroyed.tronMonitor() {
  local session="$1"
  [ -z "$session" ] && return 0
  command -v tronMonitor >/dev/null 2>&1 || return 0
  tronMonitor remove "$session" 2>/dev/null || \
    info.log "tronMonitor remove failed/skipped for $session (non-fatal)"
}

private.hiveMind.handler.team.destroyed.registry() {
  local session="$1"
  [ -z "$session" ] && return 0
  local reg="${HIVEMIND_REGISTRY:-${CONFIG_PATH:-$HOME/config}/hivemind.roles.env}"
  [ -f "$reg" ] || return 0
  grep -v "^${session}:" "$reg" > "${reg}.tmp" 2>/dev/null
  mv "${reg}.tmp" "$reg"
}

private.hiveMind.handler.team.destroyed.sessions() {
  local session="$1"
  [ -z "$session" ] && return 0
  local sess="${HIVEMIND_SESSIONS:-${CONFIG_PATH:-$HOME/config}/hivemind.sessions.env}"
  [ -f "$sess" ] || return 0
  grep -v "^${session}:" "$sess" > "${sess}.tmp" 2>/dev/null
  mv "${sess}.tmp" "$sess"
}

private.hiveMind.handler.team.destroyed.queue() {
  local session="$1"
  [ -z "$session" ] && return 0
  # queue files are at $CONFIG_PATH/hivemind.queue/<session>:<addr>.queue
  local queueDir="${CONFIG_PATH:-$HOME/config}/hivemind.queue"
  [ -d "$queueDir" ] || return 0
  # Match files starting with "<session>:" prefix (literal colon). Use a
  # glob expansion guard so unmatched-pattern returns no files cleanly.
  local f
  shopt -s nullglob
  for f in "$queueDir/${session}:"*.queue; do
    [ -f "$f" ] && rm -f "$f"
  done
  shopt -u nullglob
  return 0
}

# ── SC-C.10 team.restored ─── args: <session> ──────────────────────────────
# Fired after teams.restore re-creates a team from snapshot (B2 layout +
# claudeCode join.byID per pane). Handlers:
#   - teams:       ensure teams.env has the row (idempotent — teams.restore
#                  calls team.register which writes; this handler is a
#                  fallback if direct restore-paths bypass team.register)
#   - tronMonitor: re-add monitor window for the restored team
# NOTE: per-agent registry/sessions are repopulated by agent.spawned/agent.forked
# events fired during the restore pass, not by team.restored.

private.hiveMind.handler.team.restored.teams() {
  local session="$1" description="$2"
  [ -z "$session" ] && return 0
  local teamsFile="${HIVEMIND_TEAMS:-${CONFIG_PATH:-$HOME/config}/hivemind.teams.env}"
  [ -f "$teamsFile" ] && grep -q "^${session}|" "$teamsFile" 2>/dev/null && return 0
  private.hiveMind.teams.ensure.dir 2>/dev/null
  echo "${session}|${description}" >> "$teamsFile"
}

private.hiveMind.handler.team.restored.tronMonitor() {
  local session="$1"
  [ -z "$session" ] && return 0
  command -v tronMonitor >/dev/null 2>&1 || return 0
  tronMonitor add "$session" 2>/dev/null || \
    info.log "tronMonitor add failed/skipped for $session (non-fatal)"
}

# ── Registration (script-load) ───────────────────────────────────────────────

private.hiveMind.events.register "agent.spawned" "private.hiveMind.handler.agent.spawned.registry"
private.hiveMind.events.register "agent.spawned" "private.hiveMind.handler.agent.spawned.sessions"
private.hiveMind.events.register "agent.killed"  "private.hiveMind.handler.agent.killed.registry"
private.hiveMind.events.register "agent.killed"  "private.hiveMind.handler.agent.killed.sessions"
private.hiveMind.events.register "agent.killed"  "private.hiveMind.handler.agent.killed.queue"
# Order matters: registry mutates first, role_env reads post-mutation
private.hiveMind.events.register "agent.renamed" "private.hiveMind.handler.agent.renamed.registry"
private.hiveMind.events.register "agent.renamed" "private.hiveMind.handler.agent.renamed.title"
private.hiveMind.events.register "agent.renamed" "private.hiveMind.handler.agent.renamed.role_env"
private.hiveMind.events.register "agent.forked"  "private.hiveMind.handler.agent.forked.registry"
private.hiveMind.events.register "agent.forked"  "private.hiveMind.handler.agent.forked.sessions"
private.hiveMind.events.register "agent.forked"  "private.hiveMind.handler.agent.forked.forks"
private.hiveMind.events.register "panes.shifted" "private.hiveMind.handler.panes.shifted.registry"
# Order matters: registry mutates first, role_env reads post-mutation state
private.hiveMind.events.register "panes.swapped" "private.hiveMind.handler.panes.swapped.registry"
private.hiveMind.events.register "panes.swapped" "private.hiveMind.handler.panes.swapped.role_env"
# SC-C.8/9/10 team lifecycle — closes V→C event-gap for team.register/remove/restore
private.hiveMind.events.register "team.created"   "private.hiveMind.handler.team.created.teams"
private.hiveMind.events.register "team.created"   "private.hiveMind.handler.team.created.tronMonitor"
private.hiveMind.events.register "team.destroyed" "private.hiveMind.handler.team.destroyed.teams"
private.hiveMind.events.register "team.destroyed" "private.hiveMind.handler.team.destroyed.tronMonitor"
private.hiveMind.events.register "team.destroyed" "private.hiveMind.handler.team.destroyed.registry"
private.hiveMind.events.register "team.destroyed" "private.hiveMind.handler.team.destroyed.sessions"
private.hiveMind.events.register "team.destroyed" "private.hiveMind.handler.team.destroyed.queue"
private.hiveMind.events.register "team.restored"  "private.hiveMind.handler.team.restored.teams"
private.hiveMind.events.register "team.restored"  "private.hiveMind.handler.team.restored.tronMonitor"
private.hiveMind.events.register "pane.moved"    "private.hiveMind.handler.pane.moved.registry"
private.hiveMind.events.register "pane.moved"    "private.hiveMind.handler.pane.moved.role_env"

# ── Role Registry ─────────────────────────────────────────────────────────────
# Write-through cache: pane target → role name.
# PRIMARY source of truth is live discovery (process → session UUID → customTitle).
# This file is the FALLBACK for panes where Claude isn't running yet.
# Format: target|role  (one per line, e.g. cursorOrchestrator:0.2|oosh-expert)
# Location: ~/config/hivemind.roles.env (persistent, OOSH config pattern)

private.hiveMind.registry.migrate() {
  # Migrate old /tmp/ registry to ~/config/ pattern (one-time, backward compat)
  local old_registry="${TMPDIR:-/tmp}/hivemind.roles"
  [ -f "$old_registry" ] || return 0
  [ "$HIVEMIND_REGISTRY" = "$old_registry" ] && return 0
  if [ ! -f "$HIVEMIND_REGISTRY" ] || [ ! -s "$HIVEMIND_REGISTRY" ]; then
    local config_dir
    config_dir=$(dirname "$HIVEMIND_REGISTRY")
    mkdir -p "$config_dir" 2>/dev/null
    cp "$old_registry" "$HIVEMIND_REGISTRY"
    info.log "Migrated registry from $old_registry to $HIVEMIND_REGISTRY"
  fi
}

private.hiveMind.sessions.migrate() {
  # Migrate old /tmp/ sessions to ~/config/ pattern (one-time, backward compat)
  local old_sessions="${TMPDIR:-/tmp}/hivemind.sessions"
  [ -f "$old_sessions" ] || return 0
  [ "$HIVEMIND_SESSIONS" = "$old_sessions" ] && return 0
  if [ ! -f "$HIVEMIND_SESSIONS" ] || [ ! -s "$HIVEMIND_SESSIONS" ]; then
    local config_dir
    config_dir=$(dirname "$HIVEMIND_SESSIONS")
    mkdir -p "$config_dir" 2>/dev/null
    cp "$old_sessions" "$HIVEMIND_SESSIONS"
    info.log "Migrated sessions from $old_sessions to $HIVEMIND_SESSIONS"
  fi
}

private.hiveMind.registry.set() {
  # Add or update a target→role mapping with timestamp (B5.1 TTL).
  # Format: pane|role|epoch-timestamp (3 fields). Old 2-field entries are
  # backward-compatible — readers default missing timestamp to 0 (= stale).
  local target="$1"
  local role="$2"
  [ -z "$target" ] || [ -z "$role" ] && return 1
  # Validate pane target format: session:window.pane (e.g. projectTeam:0.3)
  if ! echo "$target" | grep -qE '^[a-zA-Z0-9_-]+:[0-9]+\.[0-9]+$'; then
    warn.log "registry.set: invalid pane target '$target' — expected session:window.pane"
    return 1
  fi
  # Validate role name: reject boot prompt garbage (>30 chars or contains spaces)
  if [ "${#role}" -gt 30 ] || echo "$role" | grep -q ' '; then
    warn.log "registry.set: rejecting invalid role name '${role:0:40}...' — too long or contains spaces"
    return 1
  fi
  private.hiveMind.registry.migrate
  # Ensure config directory exists
  local config_dir
  config_dir=$(dirname "$HIVEMIND_REGISTRY")
  mkdir -p "$config_dir" 2>/dev/null
  # Remove old entry for this target, then append new one with timestamp
  if [ -f "$HIVEMIND_REGISTRY" ]; then
    grep -v "^${target}|" "$HIVEMIND_REGISTRY" > "${HIVEMIND_REGISTRY}.tmp" 2>/dev/null
    mv "${HIVEMIND_REGISTRY}.tmp" "$HIVEMIND_REGISTRY"
  fi
  local ts
  ts=$(date +%s 2>/dev/null)
  echo "${target}|${role}|${ts}" >> "$HIVEMIND_REGISTRY"
}

private.hiveMind.registry.isRecent() { # <pane> # 0 if file entry was set within TTL window
  # B5.1 — true when manual registry.set should win over live discovery.
  # Checks 3rd field (timestamp) against now − HIVEMIND_REGISTRY_TTL.
  local target="$1"
  [ -z "$target" ] && return 1
  [ -f "$HIVEMIND_REGISTRY" ] || return 1
  local ts
  ts=$(grep "^${target}|" "$HIVEMIND_REGISTRY" 2>/dev/null | head -1 | cut -d'|' -f3)
  [ -z "$ts" ] && return 1               # 2-field entry (legacy) — not recent
  ! [[ "$ts" =~ ^[0-9]+$ ]] && return 1  # malformed timestamp
  local now
  now=$(date +%s 2>/dev/null)
  [ -z "$now" ] && return 1
  local age=$(( now - ts ))
  [ "$age" -lt 0 ] && return 1           # clock skew safety
  local ttl="${HIVEMIND_REGISTRY_TTL:-30}"
  [ "$ttl" -eq 0 ] && return 1           # TTL=0 means "always expired" (live-only mode)
  [ "$age" -le "$ttl" ]
}

private.hiveMind.registry.get() {
  # Look up role for a given pane target.
  # B5.1 priority order:
  #   1. File entry IF set within HIVEMIND_REGISTRY_TTL (manual-set wins fresh)
  #   2. Live discovery from running Claude process
  #   3. File entry (any age) — fallback when live can't find role
  local target="$1"
  [ -z "$target" ] && return 1
  private.hiveMind.registry.migrate

  # Priority 1 — recently-set file entry overrides live discovery
  if private.hiveMind.registry.isRecent "$target"; then
    grep "^${target}|" "$HIVEMIND_REGISTRY" 2>/dev/null | head -1 | cut -d'|' -f2
    return 0
  fi

  # Priority 2 — live discovery (handles /rename'd agents not yet in registry)
  local role
  role=$(private.hiveMind.live.discover "$target" 2>/dev/null)
  [ -n "$role" ] && echo "$role" && return 0

  # Priority 3 — any file entry (stale ok)
  [ -f "$HIVEMIND_REGISTRY" ] || return 1
  grep "^${target}|" "$HIVEMIND_REGISTRY" 2>/dev/null | head -1 | cut -d'|' -f2
}

private.hiveMind.registry.find() {
  # Find pane target by role name (case-insensitive partial match).
  # B5.1 priority order:
  #   1. Recently-set file entries (manual registry.set wins fresh)
  #   2. Live discovery from running Claude processes
  #   3. Any file entry (stale ok) — verified to exist in tmux
  local name="$1"
  local session="$2"
  private.hiveMind.registry.migrate

  # Priority 1 — scan file for recent entries matching role
  if [ -f "$HIVEMIND_REGISTRY" ]; then
    local recentCandidates
    if [ -n "$session" ]; then
      recentCandidates=$(grep "^${session}:" "$HIVEMIND_REGISTRY" 2>/dev/null | grep -i "$name" | cut -d'|' -f1)
    else
      recentCandidates=$(grep -i "$name" "$HIVEMIND_REGISTRY" 2>/dev/null | cut -d'|' -f1)
    fi
    local cand
    for cand in $recentCandidates; do
      if private.hiveMind.registry.isRecent "$cand"; then
        otmux has "${cand%%:*}" 2>/dev/null && echo "$cand" && return 0
      fi
    done
  fi

  # Priority 2 — live discovery
  if [ -n "$session" ]; then
    local match
    match=$(private.hiveMind.live.discover.session "$session" 2>/dev/null | grep -i "$name" | head -1 | cut -d'|' -f1)
    [ -n "$match" ] && echo "$match" && return 0
  fi

  # Priority 3 — any file entry (stale ok), verified to exist in tmux
  [ -f "$HIVEMIND_REGISTRY" ] || return 1
  local candidates
  if [ -n "$session" ]; then
    candidates=$(grep "^${session}:" "$HIVEMIND_REGISTRY" 2>/dev/null | grep -i "$name" | cut -d'|' -f1)
  else
    candidates=$(grep -i "$name" "$HIVEMIND_REGISTRY" 2>/dev/null | cut -d'|' -f1)
  fi
  local pane
  for pane in $candidates; do
    otmux has "${pane%%:*}" 2>/dev/null && echo "$pane" && return 0
  done
}

private.hiveMind.registry.list() {
  # List all entries, optionally filtered by session
  # PRIMARY: live discovery from running Claude processes
  # FALLBACK: static file (for listing across all sessions or offline sessions)
  local session="$1"
  if [ -n "$session" ] && otmux has "$session" 2>/dev/null; then
    local live
    live=$(private.hiveMind.live.discover.session "$session" 2>/dev/null)
    [ -n "$live" ] && echo "$live" && return 0
  fi
  # Fallback to static registry file
  private.hiveMind.registry.migrate
  [ -f "$HIVEMIND_REGISTRY" ] || return 0
  if [ -n "$session" ]; then
    grep "^${session}:" "$HIVEMIND_REGISTRY" 2>/dev/null
  else
    cat "$HIVEMIND_REGISTRY" 2>/dev/null
  fi
}

# ── Live Discovery ────────────────────────────────────────────────────────────
# Discover agent roles from live process state (tmux panes + Claude session names).
# Primary source of truth — no static file needed.
# A pane is just a view; an agent is a Claude process with a session UUID.
# The role is stored in the Claude session's customTitle (via /rename).

private.hiveMind.live.discover() {
  # Discover role for a pane from live Claude process + session data
  # Returns: role name (e.g. "hiveMind-expert") or empty
  local target="$1"
  [ -z "$target" ] && return 1

  # 1. Find Claude PID on this pane's TTY
  local pid
  pid=$(claudeCode process.find "$target" 2>/dev/null) || return 1

  # 2. Get session UUID via shared resolver (DRY — same as agents.discover)
  #    Fork-aware: PID+time disambiguation for sister panes, JSONL filename as UUID
  local sid
  sid=$(private.hiveMind.session.resolve.uuid "$target" 2>/dev/null)
  [ -z "$sid" ] && return 1

  # 3. Get session name (customTitle from /rename, or firstPrompt)
  local name
  name=$(claudeCode session.name "$sid" 2>/dev/null)
  [ -z "$name" ] && return 1

  # 4. Extract role — handle both "role@model" and bare "role" formats
  local role="$name"
  [[ "$name" == *"@"* ]] && role="${name%%@*}"
  # Validate: reject garbage (same guards as registry.refresh)
  [[ "$role" == *" "* ]] && return 1
  [[ ${#role} -gt 30 ]] && return 1
  [[ "$role" =~ ^(Read|You|I\ am|Write|Edit|Help|Search|Find|Look|Check|Run|Show) ]] && return 1
  echo "$role"
  return 0
}

private.hiveMind.live.discover.session() {
  # Discover all roles in a tmux session from live process state
  # Outputs: pane_target|role per line (same format as registry file)
  local session="$1"
  [ -z "$session" ] && return 1

  local pane_list
  pane_list=$(private.hiveMind.list.panes addr "$session")
  [ -z "$pane_list" ] && return 1

  while IFS= read -r pane_target; do
    [ -z "$pane_target" ] && continue
    local role
    role=$(private.hiveMind.live.discover "$pane_target" 2>/dev/null)
    if [ -n "$role" ]; then
      echo "${pane_target}|${role}"
    fi
  done <<< "$pane_list"
}

# ── Session Tracking ──────────────────────────────────────────────────────────
# File-based mapping: pane target → Claude session UUID.
# Allows rejoining a session after the Claude process exits.
# Format: pane|session-uuid  (one per line, e.g. projectTeam:0.2|a2c6b6c4-...)

private.hiveMind.session.store() {
  # Store or update pane → session UUID mapping
  local pane="$1"
  local sessionId="$2"
  [ -z "$pane" ] || [ -z "$sessionId" ] && return 1
  private.hiveMind.sessions.migrate
  # Ensure config directory exists
  local configDir
  configDir=$(dirname "$HIVEMIND_SESSIONS")
  mkdir -p "$configDir" 2>/dev/null
  # Remove old entry for this pane, then append new one
  if [ -f "$HIVEMIND_SESSIONS" ]; then
    grep -v "^${pane}|" "$HIVEMIND_SESSIONS" > "${HIVEMIND_SESSIONS}.tmp" 2>/dev/null
    mv "${HIVEMIND_SESSIONS}.tmp" "$HIVEMIND_SESSIONS"
  fi
  echo "${pane}|${sessionId}" >> "$HIVEMIND_SESSIONS"
}

private.hiveMind.session.lookup() {
  # Look up stored session UUID for a given pane target
  local pane="$1"
  private.hiveMind.sessions.migrate
  [ -f "$HIVEMIND_SESSIONS" ] || return 1
  grep "^${pane}|" "$HIVEMIND_SESSIONS" 2>/dev/null | tail -1 | cut -d'|' -f2
}

private.hiveMind.session.store.deferred() # <pane> <role> # bg retry probe @ 5/15/30s post-call
{
  # SC-H.2 Gap A: probe race fix. The synchronous probe in team.setup/
  # agent.bootstrap/etc. waits only 8s after Claude launch. Slow startup,
  # plan-mode prompts, or autocompact mid-launch can leave the TUI not yet
  # serving /status — probe returns empty, session.store never runs, S2 misses
  # the pane→UUID entry. Detector I10 (commit 53f2bd9) catches this post-fact;
  # this is the prevention side.
  #
  # Forks a disowned subshell that retries probe at 5s/15s/30s. Idempotent:
  # skips if S2 already populated; pidfile guards concurrent calls for the
  # same pane (event handler + bash-3.2 fallback can both fire — pidfile wins).
  local pane="$1" role="$2"
  [ -z "$pane" ] && return 1

  # Pidfile guard — sanitize pane target ( : . → _ ) for safe filename
  local sanitized
  sanitized=$(echo "$pane" | tr ':.' '__')
  local pidFile="${TMPDIR:-/tmp}/hivemind.deferred.${sanitized}.pid"
  if [ -f "$pidFile" ]; then
    local oldPid
    oldPid=$(cat "$pidFile" 2>/dev/null)
    if [ -n "$oldPid" ] && kill -0 "$oldPid" 2>/dev/null; then
      info.log "defer-probe: $pane already scheduled (pid $oldPid)"
      return 0
    fi
    # Stale pidfile from previous crashed run
    rm -f "$pidFile" 2>/dev/null
  fi

  # Spawn background retry loop. Each iteration checks S2 first (cheap), then
  # invokes probe (invasive — sends /status to the pane). On success: store
  # and exit. On final failure: warn and exit non-zero.
  (
    echo $$ > "$pidFile"
    trap 'rm -f "$pidFile" 2>/dev/null' EXIT

    local prev=0 delay wait sid existing
    for delay in 5 15 30; do
      wait=$((delay - prev))
      sleep "$wait"
      prev="$delay"

      # Skip if another path populated S2 in the meantime
      existing=$(private.hiveMind.session.lookup "$pane" 2>/dev/null)
      if [ -n "$existing" ]; then
        info.log "defer-probe: $pane already has UUID ${existing} — done"
        exit 0
      fi

      # Invasive probe — sends /status to pane and parses
      sid=$(hiveMind.agent.session.probe "$pane" 2>/dev/null)
      if [ -n "$sid" ]; then
        private.hiveMind.session.store "$pane" "$sid"
        info.log "defer-probe: $pane captured UUID ${sid} after ${delay}s"
        exit 0
      fi
    done

    warn.log "defer-probe: $pane gave up after 30s (3 attempts) — S2 still missing for role '$role'"
    exit 1
  ) &
  disown 2>/dev/null
  return 0
}

# ── Active Team ───────────────────────────────────────────────────────────────
# Single source of truth for which team session is "current".
# Replaces all hardcoded cursorOrchestrator defaults.

private.hiveMind.active.team() {
  # Return the active team name. Falls back to first registered team, then cursorOrchestrator.
  # All candidates are validated against tmux — stale sessions are skipped.
  local candidate
  if [ -f "$HIVEMIND_ACTIVE_TEAM_FILE" ] && [ -s "$HIVEMIND_ACTIVE_TEAM_FILE" ]; then
    candidate=$(cat "$HIVEMIND_ACTIVE_TEAM_FILE" 2>/dev/null)
    if [ -n "$candidate" ] && otmux has "$candidate" 2>/dev/null; then
      echo "$candidate"
      return 0
    fi
    # Stale active-team file — ignore and fall through
  fi
  # Fall back to first LIVE registered team from teams file
  if [ -f "$HIVEMIND_TEAMS" ] && [ -s "$HIVEMIND_TEAMS" ]; then
    while IFS='|' read -r candidate _rest; do
      [ -z "$candidate" ] && continue
      otmux has "$candidate" 2>/dev/null && echo "$candidate" && return 0
    done < "$HIVEMIND_TEAMS"
  fi
  # Fall back to first LIVE team found in roles registry
  if [ -f "$HIVEMIND_REGISTRY" ] && [ -s "$HIVEMIND_REGISTRY" ]; then
    local seen=""
    while IFS= read -r candidate; do
      [ -z "$candidate" ] && continue
      case " $seen " in *" $candidate "*) continue;; esac
      seen="$seen $candidate"
      otmux has "$candidate" 2>/dev/null && echo "$candidate" && return 0
    done < <(cut -d':' -f1 "$HIVEMIND_REGISTRY" 2>/dev/null)
  fi
  # Ultimate fallback
  echo "cursorOrchestrator"
}

# ── Team Registry ────────────────────────────────────────────────────────────
# File-based list of known team sessions with descriptions.
# Format: session_name|description  (one per line)
# Location: ~/config/hivemind.teams.env

private.hiveMind.teams.ensure.dir() {
  local config_dir
  config_dir=$(dirname "$HIVEMIND_TEAMS")
  mkdir -p "$config_dir" 2>/dev/null
}

private.hiveMind.teams.complete() {
  # Shared completion helper: registered teams + running tmux sessions, deduplicated
  { [ -f "$HIVEMIND_TEAMS" ] && cut -d'|' -f1 "$HIVEMIND_TEAMS" 2>/dev/null; otmux sessions -F "#{session_name}" 2>/dev/null; } | sort -u
}

private.hiveMind.roles.complete() {
  # Shared completion helper — registry file is fast and reliable for Tab
  [ -f "$HIVEMIND_REGISTRY" ] && cut -d'|' -f2 "$HIVEMIND_REGISTRY" 2>/dev/null | sort -u
}

private.hiveMind.resolve.alias() {
  # Map legacy/alternate names to canonical registry keys.
  # The registry stores canonical names; this resolves aliases when direct lookup fails.
  case "$1" in
    agent-teacher|oosh-orchestrator) echo "orchestrator" ;;
    *) echo "$1" ;;
  esac
}

# Session ID detection delegated to claudeCode wrapper (DRY).
# Use: ./claudeCode process.find <pane>    — get Claude PID
#      ./claudeCode process.running <pane> — boolean check
#      ./claudeCode session.id <pane>      — get session UUID

private.hiveMind.monitor.switch() { # <paneTarget> # auto-switch tronMonitor to the team containing this pane
  local pane="$1"
  [ -z "$pane" ] && return
  local session="${pane%%:*}"
  # SM-reported bug: tronMonitor switch could hang/crash during screen auto-recovery
  # after D1.6 — an agent.monitor call would stall, blocking PERMISSION inspection.
  # Bound the call at 2s and swallow ALL failures (including signals). A failed
  # visual switch is a UX nicety; it must never block callers like agent.monitor.
  if command -v timeout >/dev/null 2>&1; then
    timeout 2 "tronMonitor" switch "$session" >/dev/null 2>&1
  else
    "tronMonitor" switch "$session" >/dev/null 2>&1
  fi
  return 0
}

private.hiveMind.pane.activity() {
  # Detect real Claude Code activity state from pane content.
  # Returns one of: active | idle | permission | unknown
  local target="$1"
  [ -z "$target" ] && echo "unknown" && return 1

  local content
  content=$(otmux pane.capture "$target" 5 2>/dev/null)

  # Empty or whitespace-only → unknown
  local trimmed
  trimmed=$(echo "$content" | tr -d '[:space:]')
  [ -z "$trimmed" ] && echo "unknown" && return 0

  # Permission prompt: Claude Code shows "Allow" and "Deny" options together
  if echo "$content" | grep -q 'Allow' && echo "$content" | grep -q 'Deny'; then
    echo "permission"
    return 0
  fi

  # Idle: last non-empty line is the input prompt character
  local last_line
  last_line=$(echo "$content" | sed '/^[[:space:]]*$/d' | tail -1)
  if echo "$last_line" | grep -qE '^[[:space:]]*(>|❯)[[:space:]]*$'; then
    echo "idle"
    return 0
  fi

  # Default: active (generating text, running tools, or indeterminate)
  echo "active"
  return 0
}

# ── Shared Agent Discovery (SINGLE SOURCE — DRY) ─────────────────────────
# Returns: pane_target|role|state|uuid|title|is_claude
# Non-invasive: uses Claude process lookup + registry + sweep.detect
# ALL methods that need agent data MUST call this — no duplicate discovery

hiveMind.protected.agents.discover() # <?session_filter> # shared discovery — output pane|role|state|uuid|title|is_claude (protected: CLI-callable, hidden from Tab)
{
  local filter="$1"

  # Build Claude process lookup (non-invasive)
  # Bash 3.2 compat (task #29): delimited string instead of assoc array.
  # Format per line: `paneTarget|pid|rest` (rest is the ps args field).
  local claudeProcs procRow
  claudeProcs=$(private.hiveMind.claude.processes | awk -F'|' '{print $3"|"$1"|"$5}')

  # Enumerate all panes
  while IFS='|' read -r pane_target pane_title; do
    [ -z "$pane_target" ] && continue

    # Filter by session if requested
    if [ -n "$filter" ] && [[ "$pane_target" != "${filter}:"* ]]; then
      continue
    fi

    local is_claude="no"
    local procRest=""
    procRow=$(echo "$claudeProcs" | grep "^${pane_target}|" | head -1)
    if [ -n "$procRow" ]; then
      is_claude="yes"
      procRest=$(echo "$procRow" | cut -d'|' -f3-)
    fi

    # Role: registry → pane title → "pane N"
    local role
    role=$(private.hiveMind.registry.get "$pane_target" 2>/dev/null)
    if [ -z "$role" ]; then
      role="$pane_title"
      [ -z "$role" ] || [ "$role" = "bash" ] || [ "$role" = "zsh" ] && role=""
    fi

    # State: sweep.detect for Claude panes, "shell" for others
    local state="shell"
    if [ "$is_claude" = "yes" ]; then
      local detect
      detect=$(private.hiveMind.sweep.detect "$pane_target" 2>/dev/null)
      state="${detect%%|*}"
    fi

    # UUID: single source of truth (DRY — handles forks, sessions.env, args)
    local uuid=""
    if [ "$is_claude" = "yes" ]; then
      uuid=$(private.hiveMind.session.resolve.uuid "$pane_target" 2>/dev/null)
    fi

    echo "${pane_target}|${role:-}|${state}|${uuid:-}|${pane_title:-}|${is_claude}"
  done < <(private.hiveMind.list.panes "#{session_name}:#{window_index}.#{pane_index}|#{pane_title}" -a)
}

hiveMind.protected.agents.offline() # <session> # synthesize 6-field discovery rows from persisted state (roles.env + sessions.env) for offline teams
{
  local session="$1"
  [ -z "$session" ] && return 1
  local roleFile="${HIVEMIND_REGISTRY:-${CONFIG_PATH:-$HOME/config}/hivemind.roles.env}"
  local sesFile="${HIVEMIND_SESSIONS:-${CONFIG_PATH:-$HOME/config}/hivemind.sessions.env}"
  [ -f "$roleFile" ] || return 1

  while IFS='|' read -r pane role _ts; do
    [ -z "$pane" ] && continue
    case "$pane" in "${session}:"*) ;; *) continue ;; esac
    local uuid=""
    [ -f "$sesFile" ] && uuid=$(grep "^${pane}|" "$sesFile" 2>/dev/null | tail -1 | cut -d'|' -f2)
    echo "${pane}|${role}|offline|${uuid}||no"
  done < "$roleFile"
}

private.hiveMind.get.agents() {
  # Get all agent IDs from agentRoom
  agentRoom list.ids 2>/dev/null
}

private.hiveMind.get.agent.port() {
  local agent_id="$1"
  agentRoom list 2>/dev/null | grep "^$agent_id " | awk '{print $2}'
}

private.hiveMind.pane.for.agent() {
  local agent_id="$1"
  private.hiveMind.list.panes "#{pane_id} #{pane_title}" | grep "$agent_id" | awk '{print $1}' | head -1
}

private.hiveMind.create.agent.pane() {
  local agent_id="$1"
  local port="$2"
  local workdir="${3:-$(pwd)}"
  local window="${4:-agents}"

  # Select or create agents window
  if ! otmux windows -F "#{window_name}" 2>/dev/null | grep -q "^${window}$"; then
    otmux window.new "$window"
  else
    otmux window.select "$window"
  fi

  # Create new pane
  otmux split.h
  otmux tiled

  # Get the new pane
  local pane_id=$(otmux pane.get %)

  # Set pane title and export role env var
  private.hiveMind.pane.identify "$pane_id" "$agent_id"

  # Start Claude Code in the pane with agent role
  otmux send.enter "$pane_id" "cd '$workdir' && claudeCode opus" Enter

  echo "$pane_id"
}

# ─────────────────────────────────────────────────────────────────────────────
# PARAMETER COMPLETIONS (shared across all methods)
# ─────────────────────────────────────────────────────────────────────────────

hiveMind.parameter.completion.agentName() {
  private.hiveMind.roles.complete
}

hiveMind.parameter.completion.session() {
  private.hiveMind.teams.complete
}

hiveMind.parameter.completion.snapshotFile() {
  ls "${CONFIG_PATH:-$HOME/config}"/hivemind.snapshot.*.env 2>/dev/null
}

hiveMind.parameter.completion.configDir() {
  local configDir="${CONFIG_PATH:-$HOME/config}"
  ls -d "$configDir"/hivemind.*/ ${TMPDIR:-/tmp}/hivemind.*/ 2>/dev/null | grep -v '__test_'
}

hiveMind.parameter.completion.sshHost() {
  ossh parameter.completion.sshConfigHost "$@"
}

hiveMind.team.setup.completion.roles() {
  echo "orchestrator,oosh-expert,oosh-tester"
  echo "orchestrator,oosh-expert,oosh-tester,scrum-master"
  echo "orchestrator,oosh-expert,oosh-tester,scrum-master,agent-trainer"
  echo "orchestrator,oosh-expert,oosh-tester,scrum-master,agent-trainer,woda-writer"
}
hiveMind.init.completion.agents() {
  echo "orchestrator,coder,tester,reviewer"
  echo "coder,tester"
  echo "orchestrator,coder,tester,reviewer,researcher"
  echo "oosh-expert,oosh-tester"
}
hiveMind.agent.spawn.completion.agentName() {
  echo "orchestrator"
  echo "coder"
  echo "tester"
  echo "reviewer"
  echo "oosh-expert"
  echo "oosh-tester"
  echo "scrum-master"
  echo "developer"
  echo "agent-trainer"
  echo "task-agent"
}

hiveMind.task.delegate.completion.pane() {
  private.claudeCode.complete.panes 2>/dev/null
}

hiveMind.task.delegate.completion.taskFile() {
  ls session/tasks/*.md 2>/dev/null
}

hiveMind.process.lookup.completion.pid() {
  ps -eo pid,args 2>/dev/null | grep -i 'claude' | grep -v grep | awk '{print $1}'
}

hiveMind.agent.restart.completion.configDir() {
  # Pulled team config directories that contain a snapshot
  local d
  for d in ${TMPDIR:-/tmp}/hivemind.*/ "${CONFIG_PATH:-$HOME/config}"/hivemind.*/; do
    [ -f "${d}hivemind.snapshot.env" ] || continue
    [[ "$d" == *__test_* ]] && continue
    echo "${d%/}"
  done
}

hiveMind.team.restart.completion.configDir() {
  hiveMind.agent.restart.completion.configDir
}

hiveMind.agent.restart.completion.role() {
  # c2 passes: $1=cur $2=class $3=method — NOT the configDir
  # Scan all known pull directories for roles in snapshot files
  local snapFile
  for snapFile in ${TMPDIR:-/tmp}/hivemind.*/hivemind.snapshot.env "${CONFIG_PATH:-$HOME/config}"/hivemind.*/hivemind.snapshot.env; do
    [ -f "$snapFile" ] || continue
    while IFS='|' read -r sess addr role uuid title; do
      [[ "$sess" == "#"* ]] && continue
      [ -n "$role" ] && echo "$role"
    done < "$snapFile"
  done | sort -u
}

hiveMind.registry.set.completion.pane() {
  private.hiveMind.list.panes addr
}

hiveMind.registry.remove.completion.pane() {
  [ -f "$HIVEMIND_REGISTRY" ] && cut -d'|' -f1 "$HIVEMIND_REGISTRY" 2>/dev/null
}

hiveMind.registry.fix.completion() { :; }

hiveMind.team.sweep.completion.interval() {
  echo "15"
  echo "30"
  echo "60"
  echo "120"
}

hiveMind.team.loop.completion.interval() {
  echo "15"
  echo "30"
  echo "60"
}

hiveMind.pane.sweep.completion.interval() {
  echo "15"
  echo "30"
  echo "60"
  echo "120"
}

hiveMind.pane.sweep.cycle.completion() { :; }


hiveMind.git.commit.completion() { :; }


hiveMind.team.cycle.completion() { :; }


hiveMind.dashboard.completion() { :; }


hiveMind.agent.monitor.cycle.completion() { :; }

hiveMind.plan.improve.completion.action() {
  echo "next done list"
}

hiveMind.plan.create.completion.slug() {
  # List non-symlink .md files in ~/.claude/plans/ (plans not yet linked)
  local plans_dir="$HOME/.claude/plans"
  [ -d "$plans_dir" ] || return
  local f
  for f in "$plans_dir"/*.md; do
    [ -f "$f" ] && [ ! -L "$f" ] && basename "$f" .md
  done
}

hiveMind.pane.sweep.loop.completion.seconds() {
  echo "15"
  echo "30"
  echo "60"
  echo "120"
}

hiveMind.watchdog.completion() { :; }


hiveMind.watchdog.stop.completion() { :; }


hiveMind.watchdog.status.completion() { :; }


hiveMind.watchdog.supervisor.completion() { :; }


# ─────────────────────────────────────────────────────────────────────────────
# INITIALIZATION
# ─────────────────────────────────────────────────────────────────────────────

hiveMind.init() # <?agents:orchestrator,coder,tester,reviewer> <?workdir:.> # initialize hivemind with real Claude agents
{
  local agents="${1:-orchestrator,coder,tester,reviewer}"
  local workdir="${2:-$(pwd)}"

  info.log "Initializing HiveMind with agents: $agents"

  # Ensure agentRoom is available
  if ! command -v agentRoom &>/dev/null && [ ! -f "agentRoom" ]; then
    error.log "agentRoom script not found. Ensure OOSH is properly set up."
    return 1
  fi

  # Start agentRoom backend
  info.log "Starting agentRoom backend..."
  agentRoom backend.start "$HIVEMIND_BASE_PORT"

  sleep 2

  # Check if in tmux or create session
  if [ -z "$TMUX" ]; then
    info.log "Creating tmux session: $HIVEMIND_SESSION"
    otmux new "$HIVEMIND_SESSION" -d -n "main"

    # Main pane for orchestrator
    private.hiveMind.pane.identify "${HIVEMIND_SESSION}:0.0" "orchestrator"
  fi

  # Start each agent
  IFS=',' read -ra agent_array <<< "$agents"
  local port=$((HIVEMIND_BASE_PORT + 1))

  for agent_id in "${agent_array[@]}"; do
    info.log "Starting agent: $agent_id on port $port"

    # Start agentRoom agent backend
    agentRoom agent.start "$agent_id" "$port" "$workdir" "$agent_id"

    # Create tmux pane for agent
    hiveMind.pane.create "$agent_id" "$workdir"

    ((port++))
    sleep 1
  done

  success.log "HiveMind initialized!"
  echo ""
  echo "Agents running:"
  agentRoom list
  echo ""
  echo "Tmux session: $HIVEMIND_SESSION"
  [ -z "$TMUX" ] && echo "Attach with: otmux attach $HIVEMIND_SESSION"
}

hiveMind.pane.create() # <agentName> <?workdir:.> # create tmux pane with Claude Code for agent
{
  local agent_id="$1"
  local workdir="${2:-$(pwd)}"

  if [ -z "$agent_id" ]; then
    error.log "Usage: hiveMind pane.create <agentId> <?workdir>"
    return 1
  fi

  # Check if pane already exists
  local existing_pane=$(private.hiveMind.pane.for.agent "$agent_id")
  if [ -n "$existing_pane" ]; then
    info.log "Pane for $agent_id already exists: $existing_pane"
    return 0
  fi

  info.log "Creating pane for $agent_id..."

  # Get agent port
  local port=$(private.hiveMind.get.agent.port "$agent_id")

  # Create pane in agents window
  if [ -n "$TMUX" ]; then
    # Check if agents window exists
    if ! otmux windows -F "#{window_name}" | grep -q "^agents$"; then
      otmux window.new "agents"
    else
      otmux window.select agents
    fi

    # Split and create new pane
    otmux split.h -t agents
    otmux tiled agents

    # Set title and export role env var
    local pane_id=$(otmux pane.get %)
    private.hiveMind.pane.identify "$pane_id" "$agent_id"

    # Get system prompt for role (use role-specific or generic)
    local system_prompt
    system_prompt=$(private.hiveMind.get.role.prompt "$agent_id") || system_prompt="You are the $agent_id agent. Focus on your specialized role."

    # Start Claude Code with role context
    otmux send.enter "$pane_id" "cd '$workdir'" Enter
    otmux send.enter "$pane_id" "echo '=== $agent_id Agent (port: $port) ==='" Enter
    otmux send.enter "$pane_id" "claudeCode opus -p '$system_prompt'" Enter

    success.log "Created pane for $agent_id"
  else
    warn.log "Not in tmux session. Run: hiveMind attach first"
    return 1
  fi
}

# ─────────────────────────────────────────────────────────────────────────────
# SESSION MANAGEMENT
# ─────────────────────────────────────────────────────────────────────────────

hiveMind.attach() # # attach to hivemind tmux session
{
  if otmux has "$HIVEMIND_SESSION" 2>/dev/null; then
    otmux attach "$HIVEMIND_SESSION"
  else
    warn.log "HiveMind session not found. Run: hiveMind init"
    return 1
  fi
}

hiveMind.detach() # # detach from current session
{
  otmux detach
}

hiveMind.join() # <agentName> # rejoin an agent's Claude session by role name
{
  local name="$1"

  if [ -z "$name" ]; then
    error.log "Usage: hiveMind join <name>"
    echo "  Use 'hiveMind role.list' to see available roles"
    return 1
  fi

  # Resolve agent name to pane target
  local target
  target=$(hiveMind.resolve "$name")
  if [ $? -ne 0 ]; then
    return 1
  fi

  # Try to find session ID from a running Claude process first
  local session_id
  session_id=$(claudeCode session.id "$target" 2>/dev/null)

  if [ -n "$session_id" ]; then
    # Claude is still running — store the session ID and inform user
    private.hiveMind.session.store "$target" "$session_id"
    info.log "Claude is already running in $target (session: ${session_id})"
    echo "Session $session_id is active in pane $target"
    return 0
  fi

  # Process not running — look up stored session ID
  session_id=$(private.hiveMind.session.lookup "$target")

  if [ -z "$session_id" ]; then
    error.log "No session found for agent '$name'"
    echo "  The agent may not have been started yet, or session data was lost."
    echo "  Start fresh with: hiveMind agent.bootstrap $name"
    return 1
  fi

  info.log "Resuming session ${session_id} for $name in pane $target"

  # Send resume command to the pane
  otmux send.enter "$target" "claude --resume $session_id" Enter

  success.log "Sent resume command for $name (session: ${session_id})"
  return 0
}

hiveMind.kill() # # shutdown hivemind completely
{
  info.log "Shutting down HiveMind..."

  # Stop all agentRoom agents
  agentRoom stop.all 2>/dev/null

  # Kill tmux session
  if otmux has "$HIVEMIND_SESSION" 2>/dev/null; then
    otmux kill "$HIVEMIND_SESSION"
    success.log "HiveMind session killed"
  else
    info.log "No HiveMind tmux session found"
  fi
}

# ─────────────────────────────────────────────────────────────────────────────
# AGENT MANAGEMENT
# ─────────────────────────────────────────────────────────────────────────────

hiveMind.agent.spawn() # <agentName> <?workdir:.> # spawn a new agent with pane
{
  local agent_id="$1"
  local workdir="${2:-$(pwd)}"

  if [ -z "$agent_id" ]; then
    error.log "Usage: hiveMind agent.spawn <agentId> <?workdir>"
    return 1
  fi
  # SC-E.2 P3 — (a) role regex, (b) pipe-safe. Existence (c) is N/A here:
  # spawn CREATES the agent, so absence is the precondition. Pane creation
  # happens downstream in pane.create which inherits the validated id.
  if ! this.isRoleName "$agent_id"; then
    error.log "agent.spawn: invalid agent name '$agent_id' (must be [A-Za-z][A-Za-z0-9._-]{0,39})"
    return 1
  fi
  if ! this.isPipeSafe "$agent_id"; then
    error.log "agent.spawn: agent name '$agent_id' contains '|' or newline — rejected"
    return 1
  fi

  # Find next free port
  local used_ports=$(agentRoom list.ports 2>/dev/null | sort -n | tail -1)
  local port=$((used_ports + 1))
  [ -z "$used_ports" ] && port=$((HIVEMIND_BASE_PORT + 1))

  info.log "Spawning agent $agent_id on port $port..."

  # Start agent backend
  agentRoom agent.start "$agent_id" "$port" "$workdir" "$agent_id"

  # Create pane
  hiveMind.pane.create "$agent_id" "$workdir"

  # Lifecycle trigger — refresh the active team to pick up the new pane
  hiveMind.registry.refresh "$(private.hiveMind.active.team)" >/dev/null

  success.log "Agent $agent_id spawned"
}

hiveMind.list() # # list all active agent role names across all sessions
{
  while IFS='|' read -r pane role state uuid title is_claude; do
    [ "$is_claude" = "yes" ] && [ -n "$role" ] && echo "$role"
  done < <(hiveMind.protected.agents.discover)
}

hiveMind.workers() # # list worker agents (non-orchestrator panes)
{
  hiveMind.list | grep -v -E "^(orchestrator|queen|product-owner)$"
}

# DEPRECATED — old workers implementation
private.hiveMind.workers.legacy() # # list worker agents via agentRoom or pane titles
{
  if command -v agentRoom &>/dev/null && agentRoom backend.status &>/dev/null 2>&1; then
    agentRoom list.ids 2>/dev/null | grep -v "queen"
  else
    local session="${HIVEMIND_SESSION:-$(private.hiveMind.active.team)}"
    otmux panes -t "$session" -F "#{pane_title}" 2>/dev/null | grep -v -E "^(orchestrator|queen)$"
  fi
}

hiveMind.queen() # # show queen (orchestrator) agent ID
{
  if command -v agentRoom &>/dev/null && agentRoom backend.status &>/dev/null 2>&1; then
    agentRoom list.ids 2>/dev/null | grep "queen" | head -1
  else
    echo "queen"
  fi
}

hiveMind.status() # <?session> # show team status for one or all sessions (falls back to registry if no tmux)
{
  if [ -n "$1" ]; then
    hiveMind.team.status "$1"
    return
  fi

  # No arg: show ALL tmux sessions with agents
  local sessions
  sessions=$(otmux sessions -F "#{session_name}" 2>/dev/null)
  if [ -n "$sessions" ]; then
    while read -r sess; do
      hiveMind.team.status "$sess" 2>/dev/null
      echo ""
    done <<< "$sessions"
    return 0
  fi

  # Cold start — tmux empty. Fall back to persisted team registry so the user
  # can see what teams exist and can be restored/restarted.
  local teamsFile="${HIVEMIND_TEAMS:-${CONFIG_PATH:-$HOME/config}/hivemind.teams.env}"
  if [ ! -f "$teamsFile" ] || [ ! -s "$teamsFile" ]; then
    echo -e "${BOLD_YELLOW}No tmux sessions and no persisted teams in $teamsFile${NORMAL}"
    return 0
  fi

  echo -e "${BOLD_CYAN}No tmux sessions running.${NORMAL}"
  echo -e "${GRAY}Persisted teams from ${teamsFile}:${NORMAL}"
  echo ""
  printf "  %-30s %s\n" "TEAM" "DESCRIPTION"
  printf "  ${GRAY}%-30s %s${NORMAL}\n" "----" "-----------"
  local sess desc
  while IFS='|' read -r sess desc; do
    [[ "$sess" == "#"* ]] && continue
    [ -z "$sess" ] && continue
    printf "  ${BOLD_WHITE}%-30s${NORMAL} ${GRAY}%s${NORMAL}\n" "$sess" "${desc:-(no description)}"
  done < "$teamsFile"
  echo ""
  echo -e "${GRAY}Start with:${NORMAL}  hiveMind teams.restore fork"
  echo -e "${GRAY}Restart one:${NORMAL}  hiveMind team.restart <configDir>"
  echo -e "${GRAY}Pull remote:${NORMAL}  hiveMind team.pull <host>"
  return 0
}
hiveMind.pane.focus() # <agentName> # focus on specific agent pane
{
  local agent_id="$1"

  if [ -z "$agent_id" ]; then
    error.log "Usage: hiveMind pane.focus <agentId>"
    return 1
  fi

  local pane=$(private.hiveMind.pane.for.agent "$agent_id")

  if [ -n "$pane" ]; then
    otmux pane.select "$pane"
    success.log "Focused on $agent_id"
  else
    error.log "No pane found for agent: $agent_id"
    return 1
  fi
}

hiveMind.resolve() # <agentName> <?session> # resolve agent name to pane target; searches all registered teams when no session given, disambiguates by caller session or errors on ambiguity
{
  local name="$1"
  local explicitSession="$2"

  if [ -z "$name" ]; then
    error.log "Usage: hiveMind resolve <name> <?session>"
    return 1
  fi

  local canonical
  canonical=$(private.hiveMind.resolve.alias "$name")

  # Explicit session — search only there (with alias fallback), no cross-team search
  if [ -n "$explicitSession" ]; then
    local match
    match=$(private.hiveMind.registry.find "$name" "$explicitSession")
    if [ -z "$match" ] && [ "$canonical" != "$name" ]; then
      match=$(private.hiveMind.registry.find "$canonical" "$explicitSession")
    fi
    if [ -n "$match" ]; then
      debug.log "resolve: $name → $match via explicit session $explicitSession"
      echo "$match"
      return 0
    fi
    error.log "No agent matching '$name' in session '$explicitSession'"
    return 1
  fi

  # No session — search the full fleet via the registry FILE (fast).
  # HIVEMIND_REGISTRY is kept fresh by registry.refresh on every lifecycle
  # edge (agent.rename/spawn/bootstrap/respawn/restart/team.restart), so a
  # single grep across the whole file yields every pane→role mapping in the
  # fleet. No per-session live.discover (which would be O(sessions × panes ×
  # processes) and takes 45+ seconds across a multi-team setup). If the file
  # is stale because refresh wasn't called recently, run `hiveMind registry.refresh`.
  private.hiveMind.registry.migrate
  local reg="$HIVEMIND_REGISTRY"
  [ -f "$reg" ] || { error.log "No registry file: $reg"; return 1; }

  # Grep all candidates (direct name + alias) — exact role match in column 2
  local candidates
  candidates=$(awk -F'|' -v n="$name" -v c="$canonical" \
      '$2 == n || (c != n && $2 == c) { print $1 }' "$reg")

  # Verify panes still exist in tmux (drop stale entries from killed sessions)
  local matches=""
  local p liveSessions
  liveSessions=$(otmux sessions -F "#{session_name}" 2>/dev/null)
  while IFS= read -r p; do
    [ -z "$p" ] && continue
    local sessOfPane="${p%%:*}"
    echo "$liveSessions" | grep -qx "$sessOfPane" || continue
    matches="${matches}${p}"$'\n'
  done <<< "$candidates"

  matches=$(echo "$matches" | grep -v '^$' | sort -u)
  local matchCount
  matchCount=$(echo "$matches" | grep -c .)

  if [ "$matchCount" -eq 0 ]; then
    error.log "No agent matching '$name' in any registered team"
    return 1
  fi

  if [ "$matchCount" -eq 1 ]; then
    debug.log "resolve: $name → $matches (unique match across teams)"
    echo "$matches"
    return 0
  fi

  # Multiple matches — prefer caller's current tmux session if it's one of them
  if [ -n "$TMUX" ]; then
    local callerSession callerMatch
    callerSession=$(tmux display-message -p '#{session_name}' 2>/dev/null)
    if [ -n "$callerSession" ]; then
      callerMatch=$(echo "$matches" | grep "^${callerSession}:" | head -1)
      if [ -n "$callerMatch" ]; then
        debug.log "resolve: $name → $callerMatch (ambiguous, preferred caller session $callerSession)"
        echo "$callerMatch"
        return 0
      fi
    fi
  fi

  # Ambiguous and no caller-session preference — error with the team list
  error.log "Agent '$name' is ambiguous — found in multiple teams:"
  echo "$matches" | while IFS= read -r m; do
    [ -n "$m" ] && echo "  $m" >&2
  done
  error.log "Specify session: hiveMind resolve $name <session>"
  return 1
}

hiveMind.resolve.completion.agentName() {
  # Tab completes with role names from the full fleet — all registered teams,
  # not just the active team. Mirrors resolve's new multi-team search scope.
  local teamsFile="${HIVEMIND_TEAMS:-${CONFIG_PATH:-$HOME/config}/hivemind.teams.env}"
  local reg="${HIVEMIND_REGISTRY:-${CONFIG_PATH:-$HOME/config}/hivemind.roles.env}"
  [ -f "$reg" ] || return 0
  if [ -f "$teamsFile" ]; then
    local s _d
    while IFS='|' read -r s _d; do
      [[ "$s" == "#"* ]] && continue
      [ -z "$s" ] && continue
      grep "^${s}:" "$reg" 2>/dev/null | cut -d'|' -f2
    done < "$teamsFile" | sort -u
  else
    cut -d'|' -f2 "$reg" 2>/dev/null | sort -u
  fi
}

hiveMind.resolve.completion.session() {
  private.hiveMind.teams.complete
}

hiveMind.send() # <agentName> <text...> # DEPRECATED — context-aware wrapper around agent.send (Epic I Phase 2)
{
  # Epic I Phase 2 (PO decision 2): legacy hiveMind.send is now a thin wrapper
  # around hiveMind.agent.send. Existing callers automatically gain state-aware
  # routing — no longer interrupts active agents or sends into overlays. The
  # original Bug #4 target-format validation lives inside agent.send / route.
  local name="$1"
  shift
  if [ -z "$name" ]; then
    error.log "Usage: hiveMind send <name> <text...>"
    return 1
  fi
  # Tron P0 (2026-05-12): empty/whitespace text → silent no-op. Shared
  # this.isEmpty predicate so guard is identical to otmux's. Stops bare
  # `[@role pane]` prefix from being delivered as a phantom prompt.
  if this.isEmpty "$*"; then
    debug.log "hiveMind.send: empty/whitespace-only text — silent no-op (name=$name)"
    return 0
  fi
  hiveMind.agent.send "$name" "$@"
}

hiveMind.send.message() # <agentName> <message> # DEPRECATED — context-aware wrapper around agent.send (Epic I Phase 2)
{
  # See hiveMind.send above. This entry point is preserved for back-compat.
  local name="$1"
  shift
  if [ -z "$name" ]; then
    error.log "Usage: hiveMind send.message <name> <message>"
    return 1
  fi
  if this.isEmpty "$*"; then
    debug.log "hiveMind.send.message: empty/whitespace-only text — silent no-op (name=$name)"
    return 0
  fi
  hiveMind.agent.send "$name" "$@"
}
hiveMind.send.enter() { # <agentName> <message> # send message to agent (alias for send.message)
  hiveMind.send.message "$@"
}
# ─────────────────────────────────────────────────────────────────────────────
# TRANSPORT-INDEPENDENT MESSAGING
# ─────────────────────────────────────────────────────────────────────────────

private.hiveMind.channel.resolve() # <agentName|pane> # resolve agent name to best available communication channel
{
  local name="$1"
  [ -z "$name" ] && return 1

  # Direct pane target — bypass registry resolve entirely
  if [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_.-]*:[0-9]+\.[0-9]+$ ]]; then
    echo "pane|${name}"
    return 0
  fi

  # Channel 1: tmux pane via registry. Bug #4 gotcha: hiveMind.resolve writes
  # error.log to stdout — `[ -n "$pane_target" ]` alone is fooled. Use rc + format.
  local pane_target rc
  pane_target=$(hiveMind.resolve "$name" 2>/dev/null)
  rc=$?
  if [ $rc -eq 0 ] && [[ "$pane_target" =~ ^[A-Za-z_][A-Za-z0-9_.-]*:[0-9]+\.[0-9]+$ ]]; then
    echo "pane|${pane_target}"
    return 0
  fi

  # Channel 2: agentRoom API (if backend is running)
  if command -v agentRoom >/dev/null 2>&1; then
    local status
    status=$(agentRoom backend.status 2>/dev/null)
    if ! echo "$status" | grep -q "not running"; then
      echo "api|${name}"
      return 0
    fi
  fi

  # No channel available
  return 1
}

private.hiveMind.agent.inform() # <pane> <text...> # INFORM path: deliver text as user-turn input to agent at idle ❯
{
  # Epic I I1.2 — INFORM path. Pre-condition: caller (router) verified the
  # pane is at idle. Delegates to otmux.send (smart prefix from B5, target
  # validation from Bug #4). Notify monitor for observer pattern (B1.4).
  local pane="$1"
  shift
  [ -z "$pane" ] && { error.log "private.hiveMind.agent.inform: missing pane"; return 1; }
  # Tron P0: silent no-op on empty/whitespace text (DRY via this.isEmpty).
  # The downstream otmux.send also enforces this guard — duplicated here so we
  # don't pay the monitor.switch cost for a payload that won't be delivered.
  this.isEmpty "$*" && { debug.log "agent.inform: empty payload — silent no-op (pane=$pane)"; return 0; }

  private.hiveMind.monitor.switch "$pane" 2>/dev/null
  otmux send "$pane" "$@"
  return $?
}

private.hiveMind.agent.route() # <pane> # classify pane state → route name (inform|overlay|queue|unknown-state)
{
  # Epic I I1.1: maps sweep.detect's 18 states to one of 4 routes:
  #   inform              — INFORM path (only when at idle ❯ prompt)
  #   overlay             — REMOTE CONTROL allowed (modal needs approve/reject/dismiss/option)
  #   queue               — agent busy or transient state, defer message
  #   unknown-state       — sweep.detect couldn't classify (conservative queue)
  local pane="$1"
  [ -z "$pane" ] && { echo "unknown-state"; return 1; }

  local detect status
  detect=$(private.hiveMind.sweep.detect "$pane" 2>/dev/null)
  status="${detect%%|*}"

  case "$status" in
    idle)
      echo "inform"
      ;;
    permission|accept-edits|tool-confirm|overlay|panel|autocomplete|shell-escaped)
      echo "overlay"
      ;;
    active|queued|context-warning|just-compacted|shell)
      echo "queue"
      ;;
    rate-limit|subscription-limit|crash|api-error|mcp-error)
      # Critical: neither send nor remote-control fixes these. Queue for later;
      # drain will retry once state recovers (or human intervenes).
      echo "queue"
      ;;
    ""|unknown|*)
      echo "unknown-state"
      ;;
  esac
}

hiveMind.agent.send() # <agentName> <message> # context-aware send: idle→INFORM, busy→QUEUE, overlay→reject (use approve/reject) [Epic I]
{
  # Epic I I1.1 router. Transport-aware (pane vs api) AND state-aware (only sends
  # to idle agents; queues otherwise; rejects overlay states with guidance).
  local name="$1"
  shift
  local message="$*"

  if [ -z "$name" ]; then
    error.log "Usage: hiveMind agent.send <name> <message>"
    return 1
  fi
  # Tron P0: empty/whitespace message → silent no-op (DRY via this.isEmpty).
  # Same guard as otmux.send — Controller never produces a prefix-only payload
  # for the View even if a caller passes an empty $message by accident.
  if this.isEmpty "$message"; then
    debug.log "hiveMind.agent.send: empty/whitespace-only message — silent no-op (name=$name)"
    return 0
  fi

  local channel
  channel=$(private.hiveMind.channel.resolve "$name")
  if [ $? -ne 0 ]; then
    error.log "No communication channel available for agent '$name'"
    create.result 1 "no-channel: $name"
    return 1
  fi

  local transport="${channel%%|*}"
  local target="${channel#*|}"

  # API transport — no pane state to detect. Send directly.
  if [ "$transport" = "api" ]; then
    agentRoom chat "$target" "$message"
    info.log "Sent to $name via api ($target)"
    create.result 0 "delivered $name $target via api"
    return 0
  fi

  # Pane transport — route via state detection.
  local route
  route=$(private.hiveMind.agent.route "$target")

  case "$route" in
    inform)
      private.hiveMind.agent.inform "$target" "$message"
      local rc=$?
      if [ $rc -eq 0 ]; then
        # Operator-visible (console.log = level 3, default). Silent success
        # at default level looked like "message lost" — bug report 2026-05-26.
        console.log "INFORM delivered to $name ($target)"
        create.result 0 "delivered $name $target"
        return 0
      fi
      warn.log "INFORM uncertain for $name ($target)"
      create.result 1 "uncertain: inform-failed $name"
      return $rc
      ;;
    overlay)
      error.log "rejected: $name is in overlay state — use hiveMind agent.approve/reject/dismiss/option"
      create.result 1 "rejected: in-overlay"
      return 1
      ;;
    queue|unknown-state)
      # I1.4 — persist to queue file; drain happens via agent.unblock idle hook.
      private.hiveMind.agent.queue.enqueue "$target" "inform" "$message"
      local pos
      pos=$(private.hiveMind.agent.queue.depth "$target")
      # Operator-visible (console.log = level 3, default). The previous
      # info.log gate (>3) made queue feedback invisible at default level —
      # operators couldn't tell if message routed to pane vs disappeared.
      # Bug report 2026-05-26: "send to window 1 arrives wrong pane" was
      # actually "send to busy pane silently queued, no feedback".
      console.log "QUEUE: $name ($target) busy (route=$route) — queued at position $pos (drain when idle: hiveMind agent.queue.drain $name)"
      create.result 0 "queued $name $pos (route=$route)"
      return 0
      ;;
    *)
      error.log "Internal: unknown route '$route' for $name"
      create.result 1 "internal: unknown-route"
      return 1
      ;;
  esac
}
hiveMind.agent.send.completion.agentName() {
  private.hiveMind.roles.complete
}

hiveMind.agent.session.probe() # <agentName|pane> <?session> # ground-truth UUID via /status + JSONL correlation (slow ~3s)
{
  # A1.2 Fix #2b: Controller side of session.probe. View I/O lives here;
  # parsing delegates to claudeCode.session.probe.fromCapture (Model parser).
  # Replaces the pre-migration claudeCode.session.probe composite method.
  local input="$1"
  if [ -z "$input" ]; then
    error.log "Usage: hiveMind agent.session.probe <agentName|pane> <?session>"
    return 1
  fi

  # Accept pane target directly OR resolve agent name to pane
  local target
  if [[ "$input" =~ ^[A-Za-z_][A-Za-z0-9_.-]*:[0-9]+\.[0-9]+$ ]] || [[ "$input" =~ ^%[0-9]+$ ]]; then
    target="$input"
  else
    local rc
    target=$(hiveMind.resolve "$input" "${2:-}" 2>/dev/null)
    rc=$?
    if [ $rc -ne 0 ] || [ -z "$target" ]; then
      error.log "agent.session.probe: cannot resolve '$input' (rc=$rc)"
      return 1
    fi
  fi

  # Skip if pane is not running Claude Code (prevents /status pollution in plain bash)
  if ! claudeCode process.running "$target" 2>/dev/null; then
    return 1
  fi

  # View I/O: send /status, wait for TUI to render, capture, dismiss modal
  otmux send.raw "$target" "/status" Enter
  sleep 3
  local capture
  capture=$(otmux pane.capture "$target" 40 2>/dev/null)
  otmux send.raw "$target" Escape

  # Model parse: delegate to pure parser (no I/O, no tmux dep)
  claudeCode session.probe.fromCapture "$capture"
}
hiveMind.agent.session.probe.completion.agentName() {
  private.hiveMind.roles.complete
}
hiveMind.agent.session.probe.completion.session() {
  private.hiveMind.teams.complete
}
# ─────────────────────────────────────────────────────────────────────────────
# TASK MANAGEMENT
# ─────────────────────────────────────────────────────────────────────────────

hiveMind.task() # <agentName> <description> # send task to specific agent
{
  local agent_id="$1"
  shift
  local description="$*"

  if [ -z "$agent_id" ] || [ -z "$description" ]; then
    error.log "Usage: hiveMind task <agentId> <description>"
    return 1
  fi

  info.log "Sending task to $agent_id: $description"

  # Send via tmux pane
  local pane=$(private.hiveMind.pane.for.agent "$agent_id")
  if [ -n "$pane" ]; then
    private.hiveMind.monitor.switch "$pane"
    otmux send.enter "$pane" "$description"
  else
    # Fallback to API
    agentRoom chat "$agent_id" "$description"
  fi
}

hiveMind.delegate() # <agentName> <description> <?from:PO> # write task file and send file-read nudge to agent (searches all teams)
{
  local name="$1"
  local description="$2"
  local from="${3:-PO}"

  if [ -z "$name" ] || [ -z "$description" ]; then
    error.log "Usage: hiveMind delegate <agent-name> <description> <?from>"
    return 1
  fi

  # SC-E.2 P2: agent name flows into filesystem path (task file name) — reject
  # path-traversal vectors (slashes, ..) via isRoleName.
  if ! this.isRoleName "$name"; then
    error.log "delegate: invalid agent name '$name' (alphanumeric + dot/dash/underscore, max 40)"
    return 1
  fi

  # Cross-team resolve — task delegation shouldn't require team.switch.
  local target
  target=$(hiveMind.resolve "$name" 2>/dev/null)
  if [ -z "$target" ]; then
    error.log "Cannot resolve '$name' in any team"
    return 1
  fi

  # Generate task file
  local workspace
  workspace="$(git rev-parse --show-toplevel 2>/dev/null)"
  if [ -z "$workspace" ]; then
    error.log "Cannot determine workspace root"
    return 1
  fi

  local tasks_dir="${workspace}/session/tasks"
  mkdir -p "$tasks_dir" 2>/dev/null
  local timestamp
  timestamp=$(date -u "+%Y%m%dT%H%M%SZ")
  local task_file="${tasks_dir}/${timestamp}.task.md"

  {
    echo "# Task: $description"
    echo ""
    echo "**From**: $from"
    echo "**To**: $name"
    echo "**Priority**: NORMAL"
    echo "**Date**: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
    echo ""
    echo "## Description"
    echo ""
    echo "$description"
    echo ""
    echo "## Acceptance Criteria"
    echo ""
    echo "- [ ] Task completed as described"
  } > "$task_file"

  # Send file-read nudge (not inline text — avoids tmux garbling)
  local relative_path="session/tasks/${timestamp}.task.md"
  otmux send.enter "$target" "Read ${relative_path}" Enter

  # Verify nudge landed
  sleep 1
  local verify
  verify=$("otmux" pane.capture "$target" 5 2>/dev/null)
  if echo "$verify" | grep -q "$relative_path"; then
    console.log "Delegated to $name: $task_file"
    create.result 0 "$task_file"
  else
    warn.log "Nudge may not have landed — verify manually"
    create.result 0 "$task_file"
  fi
  return $(result)
}
hiveMind.task.delegate() # <sshHost> <pane> <taskFile> <?message> # delegate task file to remote agent via scp + send
{
  local sshHost="$1"
  local pane="$2"
  local taskFile="$3"
  local message="$4"

  if [ -z "$sshHost" ] || [ -z "$pane" ] || [ -z "$taskFile" ]; then
    error.log "Usage: hiveMind task.delegate <sshHost> <pane> <taskFile> <?message>"
    return 1
  fi

  # SC-E.2 P3: sshHost is a command-injection vector (flows to ssh "$host").
  # Pane target flows to remote tmux — same ingress hardening as local.
  if ! this.isSshHost "$sshHost"; then
    error.log "task.delegate: invalid sshHost '$sshHost' (alphanumeric/dot/dash/underscore, max 64)"
    return 1
  fi
  if ! this.isPaneTarget "$pane"; then
    error.log "task.delegate: invalid pane target '$pane' (expected session:window.pane or %N)"
    return 1
  fi

  if [ ! -f "$taskFile" ]; then
    error.log "Task file not found: $taskFile"
    return 1
  fi

  local filename
  filename=$(basename "$taskFile")

  # Transfer task file to remote
  private.hiveMind.task.transfer "$sshHost" "$taskFile" "$filename"
  if [ $? -ne 0 ]; then
    return 1
  fi

  # Build default message if none provided
  if [ -z "$message" ]; then
    message="Read session/tasks/${filename} — new task"
  fi

  # Send message to agent on remote tmux
  "ossh" exec "$sshHost" "otmux send '$pane' '$message' Enter"

  # Verify nudge landed
  sleep 1
  local capture
  capture=$("ossh" exec "$sshHost" "otmux pane.capture '$pane' 5" 2>/dev/null)
  if echo "$capture" | grep -q "$filename"; then
    success.log "Delegated to $pane on $sshHost: $filename"
  else
    warn.log "Nudge may not have landed on $pane@$sshHost — verify manually"
  fi

  create.result 0 "$taskFile"
  return $(result)
}

private.hiveMind.task.transfer() # <sshHost> <taskFile> <filename> # scp task file to remote session/tasks/
{
  local sshHost="$1"
  local taskFile="$2"
  local filename="$3"

  local remoteWorkspace="${CLAUDE_PROJECT_DIR:-/Users/Shared/Workspaces/AI/Claude}"
  local remoteDir="${remoteWorkspace}/session/tasks"

  # Ensure remote tasks directory exists
  "ossh" exec "$sshHost" "mkdir -p '$remoteDir'" 2>/dev/null

  if ! "ossh" scp "$taskFile" "${sshHost}:${remoteDir}/${filename}" 2>/dev/null; then
    error.log "scp failed — check SSH config for host '$sshHost'"
    return 1
  fi

  info.log "Transferred $filename to $sshHost:$remoteDir/"
  return 0
}

hiveMind.broadcast() # <message> # send message to all agents
{
  local message="$1"

  if [ -z "$message" ]; then
    error.log "Usage: hiveMind broadcast <message>"
    return 1
  fi
  # Tron P0: whitespace-only message refused at controller level (avoid
  # fanning out N silent no-ops in the per-agent loop).
  if this.isEmpty "$message"; then
    debug.log "hiveMind.broadcast: whitespace-only message — refusing fan-out"
    return 0
  fi

  info.log "Broadcasting: $message"

  for agent_id in $(hiveMind.list); do
    hiveMind.send.message "$agent_id" "$message"
  done
}

# ─────────────────────────────────────────────────────────────────────────────
# DISCOVERY
# ─────────────────────────────────────────────────────────────────────────────

hiveMind.discover() # # discover all agents as JSON
{
  agentRoom discover
}

hiveMind.panes() # # list all active agent panes across all sessions
{
  echo -e "${BOLD_CYAN}Agent Panes${NORMAL}"
  echo -e "${GRAY}$(printf '%.0s─' {1..105})${NORMAL}"
  printf "${BOLD_WHITE}%-28s %-22s %-14s %s${NORMAL}\n" "PANE" "ROLE" "STATE" "UUID"
  echo -e "${GRAY}$(printf '%.0s─' {1..105})${NORMAL}"

  local count=0
  while IFS='|' read -r pane role state uuid title is_claude; do
    [ "$is_claude" = "yes" ] || continue

    local state_color="${NORMAL}"
    case "$state" in
      active)        state_color="${BOLD_GREEN}" ;;
      idle|queued)   state_color="${GRAY}" ;;
      accept-edits)  state_color="${BOLD_CYAN}" ;;
      permission|tool-confirm|panel|overlay|autocomplete)
                     state_color="${BOLD_RED}" ;;
    esac

    printf "%-28s ${BOLD_WHITE}%-22s${NORMAL} ${state_color}%-14s${NORMAL} ${GRAY}%s${NORMAL}\n" \
      "$pane" "${role:-unknown}" "$state" "${uuid:--}"
    count=$((count + 1))
  done < <(hiveMind.protected.agents.discover)

  echo -e "${GRAY}$(printf '%.0s─' {1..105})${NORMAL}"
  echo -e "${BOLD_WHITE}$count${NORMAL} active agents"
}

# ─────────────────────────────────────────────────────────────────────────────
# CLAUDE CODE INTEGRATION
# ─────────────────────────────────────────────────────────────────────────────

hiveMind.claude() # <agentName> <?prompt> # interact with agent via Claude Code
{
  local agent_id="$1"
  shift
  local prompt="$*"

  if [ -z "$agent_id" ]; then
    error.log "Usage: hiveMind claude <agentId> <?prompt>"
    return 1
  fi

  local pane=$(private.hiveMind.pane.for.agent "$agent_id")

  if [ -z "$pane" ]; then
    error.log "No pane found for agent: $agent_id"
    return 1
  fi

  if [ -z "$prompt" ]; then
    # Focus on agent pane
    otmux pane.select "$pane"
  else
    # Send prompt to agent
    otmux send.enter "$pane" "$prompt" Enter
  fi
}

hiveMind.process.lookup() # <pid> # resolve Claude PID to pane target, role, and session UUID
{
  local pid="$1"
  if [ -z "$pid" ]; then
    error.log "Usage: hiveMind process.lookup <pid>"
    return 1
  fi

  # 1. Verify PID exists and get TTY
  local tty
  tty=$(ps -p "$pid" -o tty= 2>/dev/null)
  tty="${tty// /}"
  if [ -z "$tty" ] || [ "$tty" = "??" ]; then
    error.log "PID $pid not found or has no TTY"
    return 1
  fi

  # 2. Find tmux pane matching this TTY
  local pane_target=""
  local tty_dev="/dev/$tty"
  while IFS=' ' read -r pane_tty pane_ref; do
    if [ "$pane_tty" = "$tty_dev" ]; then
      pane_target="$pane_ref"
      break
    fi
  done < <(private.hiveMind.list.panes tty)

  if [ -z "$pane_target" ]; then
    error.log "PID $pid (TTY $tty) not found in any tmux pane"
    return 1
  fi

  # 3. Extract session UUID from process args
  local sid
  sid=$(ps -p "$pid" -o args= 2>/dev/null | grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)

  # 4. Get role via live discovery
  local role
  role=$(private.hiveMind.live.discover "$pane_target" 2>/dev/null)

  # Output
  if [ -n "$role" ]; then
    echo "PID $pid → $pane_target ($role)"
  else
    echo "PID $pid → $pane_target"
  fi
  [ -n "$sid" ] && echo "Session: $sid"
  echo "TTY: $tty_dev"
  return 0
}

hiveMind.process.list() # <?session> # list all Claude processes with pane targets, roles, UUIDs
{
  local session="$1"

  # Header
  printf "%-8s %-34s %-20s %s\n" "PID" "PANE" "ROLE" "SESSION UUID"
  printf "%-8s %-34s %-20s %s\n" "---" "----" "----" "------------"

  local found=0
  while IFS='|' read -r pid tty paneTarget title rest; do
    [ -z "$pid" ] && continue

    # Filter by session if specified
    if [ -n "$session" ] && [[ "$paneTarget" != "${session}:"* ]]; then
      continue
    fi

    # Extract UUID from --resume args → session.id → session.probe
    local sid
    sid=$(echo "$rest" | grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)
    if [ -z "$sid" ]; then
      sid=$("claudeCode" session.id "$paneTarget" 2>/dev/null)
    fi
    if [ -z "$sid" ]; then
      sid=$(hiveMind.agent.session.probe "$paneTarget" 2>/dev/null)
    fi
    

    # Get role
    local role
    role=$(private.hiveMind.live.discover "$paneTarget" 2>/dev/null)

    printf "%-8s %-34s %-20s %s\n" "$pid" "$paneTarget" "${role:--}" "${sid:--}"
    found=$((found + 1))
  done < <(private.hiveMind.claude.processes)

  [ "$found" -eq 0 ] && echo "(no Claude processes found)"
  return 0
}

hiveMind.roles.list.uuids() # <role> # list all session UUIDs that ever ran as <role>, sorted by recency (Epic J1)
{
  # Output: tab-separated rows + header. Status ∈ {live, dead, orphan}.
  # Sources (DRY):
  #   • JSONL scan for sessionId + tail customTitle + mtime (mirrors claudeCode list)
  #   • ps for live UUID→pane (mirrors hiveMind process.list internals via private.claude.processes)
  # Match: customTitle equal-or-prefix-of role|fallback-role (case-insensitive).
  # Strips '@model' suffix on the title before comparison.
  local role="$1"
  if [ -z "$role" ]; then
    error.log "Usage: hiveMind roles.list.uuids <role>"
    return 1
  fi

  # SC-E.2 P2: role flows into JSONL glob — reject path traversal via isRoleName.
  if ! this.isRoleName "$role"; then
    error.log "roles.list.uuids: invalid role '$role' (alphanumeric + dot/dash/underscore, max 40)"
    return 1
  fi

  ROLE="$role" python3 << 'EOF'
import os, glob, sys, re, time, subprocess

role = os.environ.get('ROLE', '').lower()

# 1. Live UUID → pane map via ps (same source private.hiveMind.claude.processes uses)
live = {}  # uuid → pane
try:
    out = subprocess.run(['ps', '-eo', 'args='], capture_output=True, text=True, check=False).stdout
    uuid_re = re.compile(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')
    for line in out.splitlines():
        if 'claude' not in line.lower():
            continue
        m = uuid_re.search(line)
        if m:
            live[m.group(0)] = '?'  # pane unknown without tmux cross-ref
except Exception:
    pass

# Cross-ref pane via tmux (one tmux call, parse pane→tty + ps tty→uuid)
try:
    panes = subprocess.run(['tmux', 'list-panes', '-a', '-F', '#{pane_tty} #{session_name}:#{window_index}.#{pane_index}'],
                            capture_output=True, text=True, check=False).stdout
    tty_to_pane = {}
    for line in panes.splitlines():
        parts = line.split(' ', 1)
        if len(parts) == 2:
            tty_to_pane[parts[0].replace('/dev/', '')] = parts[1]
    # Re-scan ps with tty for live UUIDs
    psout = subprocess.run(['ps', '-eo', 'tty,args'], capture_output=True, text=True, check=False).stdout
    for line in psout.splitlines():
        parts = line.strip().split(None, 1)
        if len(parts) != 2:
            continue
        tty, args = parts
        if 'claude' not in args.lower():
            continue
        m = uuid_re.search(args)
        if not m:
            continue
        uuid = m.group(0)
        pane = tty_to_pane.get(tty, '?')
        if pane != '?':
            live[uuid] = pane
        elif uuid not in live:
            live[uuid] = '?'
except Exception:
    pass

# 2. Scan JSONL files for sessionId + customTitle + mtime
projects_dir = os.path.expanduser('~/.claude/projects')
ct_re = re.compile(r'"customTitle":"([^"]*)"')
results = []

for project_dir in glob.glob(os.path.join(projects_dir, '*')):
    if not os.path.isdir(project_dir):
        continue
    if os.path.basename(project_dir) == '-':
        continue
    for f in glob.glob(os.path.join(project_dir, '*.jsonl')):
        sid = os.path.basename(f)[:-len('.jsonl')]
        try:
            mtime = int(os.path.getmtime(f))
        except OSError:
            continue
        # Read tail (last 64KB) for customTitle
        title = ''
        try:
            with open(f, 'rb') as fh:
                fh.seek(0, 2)
                size = fh.tell()
                fh.seek(max(0, size - 65536))
                tail = fh.read().decode('utf-8', errors='ignore')
            for ln in reversed(tail.split('\n')):
                m = ct_re.search(ln)
                if m:
                    title = m.group(1)
                    break
        except OSError:
            continue

        # Strip '@model' for comparison
        bare = title.split('@', 1)[0].lower()
        if not (bare == role or bare == 'fallback-' + role):
            continue

        if sid in live:
            status = 'live'
            pane = live[sid]
        else:
            age = time.time() - mtime
            status = 'dead' if age < 86400 else 'orphan'
            pane = '—'

        last_active = time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(mtime))
        results.append((sid, title, pane, last_active, status, mtime))

# 3. Sort by mtime desc and print (colorized like claudeCode list)
results.sort(key=lambda x: x[5], reverse=True)

# ANSI color codes — matches claudeCode list scheme:
#   UUID = gray, TITLE = bold white, status: live=green, dead=red, orphan=yellow
GRAY        = '\033[90m'
BOLD_WHITE  = '\033[1;37m'
BOLD_CYAN   = '\033[1;36m'
BOLD_GREEN  = '\033[1;32m'
BOLD_RED    = '\033[1;31m'
BOLD_YELLOW = '\033[1;33m'
NORMAL      = '\033[0m'

status_color = {'live': BOLD_GREEN, 'dead': BOLD_RED, 'orphan': BOLD_YELLOW}

# Header (gray underline-style row, no color on labels)
print(f'{GRAY}{"UUID":<36}  {"TITLE":<28}  {"PANE":<24}  {"LAST_ACTIVE":<19}  STATUS{NORMAL}')

for sid, title, pane, last_active, status, _ in results:
    sc = status_color.get(status, GRAY)
    pc = BOLD_CYAN if pane != '—' else GRAY
    # widths must use uncolored text; color wraps the text only
    print(
        f'{GRAY}{sid:<36}{NORMAL}  '
        f'{BOLD_WHITE}{title[:28]:<28}{NORMAL}  '
        f'{pc}{pane[:24]:<24}{NORMAL}  '
        f'{last_active:<19}  '
        f'{sc}{status}{NORMAL}'
    )

if not results:
    sys.exit(1)
EOF
}

hiveMind.roles.list.uuids.completion.role() {
  # Live roles + offer fallback-<role> variants
  local roles
  roles=$("$OOSH_DIR/hiveMind" role.list 2>/dev/null)
  echo "$roles"
  echo "$roles" | sed 's/^/fallback-/'
}

hiveMind.agent.fork.best() # <role> <targetPane> # find best-trained session for <role> and fork it into <targetPane> (Epic J2)
{
  # Selection per session/tasks/j2-fork-best-design.md:
  #   • PRIMARY signal: JSONL file size (large = trained, small = boot/recovery attempt)
  #   • Filter: skip <50KB (broken attempts)
  #   • Tiebreaker: bare role name > fallback-* prefix; more recent > older
  # Reuses the same JSONL+ps scan as roles.list.uuids (DRY logic, single python pass).
  # After fork: wait for startup, send boot.md if exists, register in roles.env.
  local role="$1"
  local targetPane="$2"
  if [ -z "$role" ] || [ -z "$targetPane" ]; then
    error.log "Usage: hiveMind agent.fork.best <role> <targetPane>"
    return 1
  fi

  # Pane target validity check (defence-in-depth — same as send methods)
  if ! [[ "$targetPane" =~ ^[A-Za-z_][A-Za-z0-9_.-]*:[0-9]+\.[0-9]+$ ]]; then
    error.log "agent.fork.best: malformed targetPane '$targetPane'"
    return 1
  fi

  local picked
  picked=$(ROLE="$role" python3 << 'EOF'
import os, glob, sys, re, time, subprocess

role = os.environ.get('ROLE', '').lower()

# Build live UUID set via ps (skip dead-process classification — fork.best
# doesn't care about live status, only quality)
live = set()
try:
    out = subprocess.run(['ps', '-eo', 'args='], capture_output=True, text=True, check=False).stdout
    uuid_re = re.compile(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')
    for line in out.splitlines():
        if 'claude' not in line.lower():
            continue
        for m in uuid_re.findall(line):
            live.add(m)
except Exception:
    pass

projects_dir = os.path.expanduser('~/.claude/projects')
ct_re = re.compile(r'"customTitle":"([^"]*)"')
candidates = []  # (size, tools, mtime, uuid, title, prefer_bare)

for project_dir in glob.glob(os.path.join(projects_dir, '*')):
    if not os.path.isdir(project_dir) or os.path.basename(project_dir) == '-':
        continue
    for f in glob.glob(os.path.join(project_dir, '*.jsonl')):
        sid = os.path.basename(f)[:-len('.jsonl')]
        try:
            size = os.path.getsize(f)
            mtime = int(os.path.getmtime(f))
        except OSError:
            continue
        # Read tail for customTitle
        title = ''
        try:
            with open(f, 'rb') as fh:
                fh.seek(0, 2)
                total = fh.tell()
                fh.seek(max(0, total - 65536))
                tail = fh.read().decode('utf-8', errors='ignore')
            for ln in reversed(tail.split('\n')):
                m = ct_re.search(ln)
                if m:
                    title = m.group(1)
                    break
        except OSError:
            continue
        bare = title.split('@', 1)[0].lower()
        is_bare = bare == role
        is_fallback = bare == 'fallback-' + role
        if not (is_bare or is_fallback):
            continue
        # Tool count via grep — cheap on text mode but we need a count
        # Use subprocess for speed on large files (10MB+)
        try:
            tools = int(subprocess.run(['grep', '-c', '"tool_use"', f],
                                        capture_output=True, text=True, check=False).stdout.strip() or 0)
        except Exception:
            tools = 0
        prefer_bare = 1 if is_bare else 0
        candidates.append((size, tools, mtime, sid, title, prefer_bare))

if not candidates:
    print('NO_CANDIDATES')
    sys.exit(1)

# Sort all by size desc for display
candidates.sort(key=lambda x: x[0], reverse=True)

THRESHOLD = 50000  # 50KB — design spec
qualified = [c for c in candidates if c[0] >= THRESHOLD]

# Display block to stderr (visible to caller)
def fmt_size(s):
    if s >= 1048576: return f'{s/1048576:.1f}MB'
    if s >= 1024:    return f'{s//1024}KB'
    return f'{s}B'

print(f'\nCandidates for {role} ({len(candidates)} found):', file=sys.stderr)
shown_best = False
for c in candidates:
    size, tools, mtime, sid, title, prefer_bare = c
    when = time.strftime('%b %d %H:%M', time.localtime(mtime))
    sized = fmt_size(size)
    flag = ''
    if size < THRESHOLD:
        flag = 'SKIP'
    elif not shown_best:
        flag = 'BEST'
        shown_best = True
    live_mark = ' (LIVE)' if sid in live else ''
    print(f'  {flag:>4} → {sid[:8]}  {sized:>7}  {tools:>5} tools  {title:<28}  {when}{live_mark}', file=sys.stderr)

if not qualified:
    # Fallback per design: warn + use most recent anyway
    print('NO_QUALIFIED', file=sys.stderr)
    candidates.sort(key=lambda x: x[2], reverse=True)  # most recent
    pick = candidates[0]
    print(f'  WARN: no candidate above 50KB — falling back to most recent ({pick[3][:8]})', file=sys.stderr)
    print(pick[3])  # uuid to stdout
    sys.exit(0)

# Tiebreaker on top candidates within ±10% of best size
best_size = qualified[0][0]
top = [c for c in qualified if c[0] >= best_size * 0.9]
# Within tied group: prefer bare > fallback, then more recent
top.sort(key=lambda c: (-c[5], -c[2]))
pick = top[0]
print(pick[3])  # uuid to stdout
EOF
)
  local rc=$?

  if [ "$picked" = "NO_CANDIDATES" ] || [ -z "$picked" ]; then
    error.log "No JSONL sessions found for role '$role'. Suggest: hiveMind agent.bootstrap $role"
    return 1
  fi

  # Live process safety: refuse to fork a UUID that's currently alive
  if ps -eo args= 2>/dev/null | grep -F "$picked" | grep -q claude; then
    warn.log "Selected UUID $picked is currently LIVE — forking the live UUID will create a sibling, not recover. Confirm intent or pick a different one."
  fi

  info.log "Forking $picked into $targetPane..."

  # Step 7: fork via Model. Cd handled by claudeCode internal resolve.
  "$OOSH_DIR/otmux" send.enter "$targetPane" "claudeCode fork $picked"

  # Step 8: wait for startup
  sleep 8

  # Step 9: rename inside Claude — sets customTitle so future session.id /
  # session.name lookups return the role. Slash command, not text.
  # Option C: Claude sees @hostname (same as pane title — unified).
  "$OOSH_DIR/otmux" send.raw "$targetPane" "/rename ${role}@${HIVEMIND_HOST}" Enter
  sleep 0.5

  # Step 10: pane.lock pins the title so Claude can't override it via /rename
  # propagation. (Was pane.title — replaced 2026-05-24 per TRON CMM4 directive.)
  "$OOSH_DIR/otmux" pane.lock "$targetPane" "${role}@${HIVEMIND_HOST}"

  # Step 11: send boot file if it exists (Controller knows where boot files live)
  local workspace bootFile
  workspace=$(git rev-parse --show-toplevel 2>/dev/null)
  bootFile="${workspace}/session/agents/${role}/boot.md"
  if [ -f "$bootFile" ]; then
    "$OOSH_DIR/otmux" send "$targetPane" "Read session/agents/${role}/boot.md" Enter
    info.log "Boot file sent: session/agents/${role}/boot.md"
  else
    info.log "No boot file at session/agents/${role}/boot.md — skipping"
  fi

  # Step 12: registry update — pane → role mapping. Sender prefix + agent.send
  # rely on this. HIVEMIND_ROLE env not pushed here because target pane runs
  # Claude TUI (text injection would land in the agent's input prompt).
  # Registry IS the source of truth for Claude panes.
  hiveMind.registry.set "$targetPane" "$role"
  success.log "Forked $role into $targetPane (UUID ${picked:0:8}, renamed, registered)"
}
hiveMind.agent.fork.best.completion.role() {
  hiveMind.roles.list.uuids.completion.role
}
hiveMind.agent.fork.best.completion.targetPane() {
  otmux panes -a -F "#{session_name}:#{window_index}.#{pane_index}" 2>/dev/null
}

private.hiveMind.pane.kind() # <paneTarget> # classify pane: claude | shell | monitor | unknown
{
  local target="$1"
  [ -z "$target" ] && { echo unknown; return 0; }
  local cmd
  cmd=$(otmux pane.get "$target" '#{pane_current_command}' 2>/dev/null)
  case "$cmd" in
    node|claude) echo claude ;;
    bash|zsh|sh|fish)
      # bash/zsh parent could be hiding Claude; check via Model
      if "$OOSH_DIR/claudeCode" process.running "$target" 2>/dev/null; then
        echo claude
      else
        echo shell
      fi
      ;;
    screen|screen-256color) echo monitor ;;
    *) echo unknown ;;
  esac
}

private.hiveMind.pane.model() # <paneTarget> # extract model flag from running claude's ps args
{
  local target="$1"
  [ -z "$target" ] && return 0
  local wrapperPid
  wrapperPid=$("$OOSH_DIR/claudeCode" process.find "$target" 2>/dev/null)
  [ -z "$wrapperPid" ] && return 0

  # process.find returns the bash wrapper PID (claudeCode ...). The actual
  # `claude --model X --resume UUID` is a child process. Walk children.
  local childPid
  childPid=$(pgrep -P "$wrapperPid" 2>/dev/null | head -1)
  # Try the child first, fall back to wrapper (in case direct claude invocation)
  local args
  args=$(ps -p "${childPid:-$wrapperPid}" -o args= 2>/dev/null)
  [ -z "$args" ] && [ -n "$wrapperPid" ] && args=$(ps -p "$wrapperPid" -o args= 2>/dev/null)
  # Match claude-<model>-<version>[1m]? from --model flag
  echo "$args" | grep -oE 'claude-(opus|sonnet|haiku)-[0-9a-z-]+(\[[0-9]+m\])?' | head -1
}

hiveMind.teams.save() # # snapshot all Claude processes + layout for cold-restart (schema: sess|addr|role|uuid|title|cwd|model|kind)
{
  local configPath="${CONFIG_PATH:-$HOME/config}"
  local outfile="${configPath}/hivemind.snapshot.$(date +%Y%m%dT%H%M%S).env"

  local count=0
  local tmpEntries="${outfile}.unsorted.$$"
  local seenSessions=""

  while IFS='|' read -r pid tty paneTarget title rest; do
    [ -z "$pid" ] && continue

    # Parse pane target → session + address
    local sess="${paneTarget%%:*}"
    local addr="${paneTarget#*:}"

    # UUID via canonical resolver (handles forks + autocompact)
    local sid
    sid=$(private.hiveMind.session.resolve.uuid "$paneTarget" 2>/dev/null)

    # Role priority: customTitle → registry → pane title → "unknown"
    local role
    role=$(private.hiveMind.live.discover "$paneTarget" 2>/dev/null)
    [ -z "$role" ] && role=$(private.hiveMind.registry.get "$paneTarget" 2>/dev/null)
    [ -z "$role" ] && role=$(private.hiveMind.role.fromTitle "$title" 2>/dev/null)
    [ -z "$role" ] && role="unknown"

    # NEW C1 fields: cwd, model, kind
    local cwd model kind
    cwd=$(otmux pane.get "$paneTarget" '#{pane_current_path}' 2>/dev/null)
    model=$(private.hiveMind.pane.model "$paneTarget" 2>/dev/null)
    kind=$(private.hiveMind.pane.kind "$paneTarget" 2>/dev/null)

    # SC-F.2: validate before writing — skip garbage rows (Bug #4 class)
    local _liveRow="${sess}|${addr}|${role}|${sid:-}|${role:-}|${cwd:-}|${model:-}|${kind:-unknown}"
    if private.hiveMind.snapshot.row.valid "$_liveRow"; then
      echo "$_liveRow" >> "$tmpEntries"
      count=$((count + 1))
      # Track unique sessions for layout.save
      echo "$seenSessions" | grep -q " ${sess} " || seenSessions="${seenSessions} ${sess} "
    fi
  done < <(private.hiveMind.claude.processes)

  # Also include dead agents + shell panes from registry+sessions.env.
  # 3rd field _reg_ts absorbs the optional B5.1 TTL timestamp; absent for
  # legacy 2-field entries (then _reg_ts is empty and is discarded).
  local reg="$HIVEMIND_REGISTRY"
  local ses="$HIVEMIND_SESSIONS"
  local dead_count=0
  local ghost_count=0
  if [ -f "$reg" ]; then
    while IFS='|' read -r pane_target reg_role _reg_ts; do
      [ -z "$pane_target" ] || [ -z "$reg_role" ] && continue
      [ -f "$tmpEntries" ] && grep -q "|${reg_role}|" "$tmpEntries" 2>/dev/null && continue
      # MIG-1: skip ghost registry entries — pane no longer exists in tmux.
      # Without this filter, stale registry pollutes snapshot and propagates
      # to remote via team.migrate. Use tmux list-panes (otmux has only checks
      # sessions; otmux pane.get falls back to focused pane on bad target).
      if ! tmux list-panes -t "$pane_target" -F '#{pane_id}' 2>/dev/null | grep -q .; then
        ghost_count=$((ghost_count + 1))
        continue
      fi
      local dead_sess="${pane_target%%:*}"
      local dead_addr="${pane_target#*:}"
      local dead_uuid=""
      [ -f "$ses" ] && dead_uuid=$(grep "^${pane_target}|" "$ses" 2>/dev/null | head -1 | cut -d'|' -f2)
      # Even dead/shell panes get cwd + kind captured
      local dead_cwd dead_kind
      dead_cwd=$(otmux pane.get "$pane_target" '#{pane_current_path}' 2>/dev/null)
      dead_kind=$(private.hiveMind.pane.kind "$pane_target" 2>/dev/null)
      # SC-F.2: validate dead entries too (registry can hold garbage role names)
      local _deadRow="${dead_sess}|${dead_addr}|${reg_role}|${dead_uuid:-}|${reg_role} (dead)|${dead_cwd:-}||${dead_kind:-shell}"
      if private.hiveMind.snapshot.row.valid "$_deadRow"; then
        echo "$_deadRow" >> "$tmpEntries"
        dead_count=$((dead_count + 1))
        echo "$seenSessions" | grep -q " ${dead_sess} " || seenSessions="${seenSessions} ${dead_sess} "
      fi
    done < "$reg"
  fi

  # Write sorted snapshot. SC-F.1: version header FIRST so readers can short-circuit
  # before parsing rows.
  {
    echo "# version: ${HIVEMIND_SNAPSHOT_VERSION:-1}"
    echo "# hiveMind snapshot $(date +%Y-%m-%dT%H:%M:%S)"
    echo "# session|address|role|uuid|title|cwd|model|kind"
    [ -f "$tmpEntries" ] && sort -t'|' -k1,1 -k2,2 "$tmpEntries"
  } > "$outfile"
  rm -f "$tmpEntries"

  # C1: save layout for each session touched (View-layer persistence via B2)
  local layout_count=0
  for sess in $seenSessions; do
    [ -z "$sess" ] && continue
    if otmux has "$sess" 2>/dev/null; then
      otmux layout.save "$sess" >/dev/null 2>&1 && layout_count=$((layout_count + 1))
    fi
  done

  echo "Saved $count live + $dead_count dead agents to $outfile (skipped $ghost_count ghost registry entries)"
  echo "Saved $layout_count session layouts via otmux layout.save"
  cat "$outfile"
  return 0
}

private.hiveMind.snapshot.version.check() # <snapfile> # SC-F.1 — 0 if supported (v1 explicit OR no header→grandfather), 1 if unknown
{
  # SC-F.1: gate snapshot consumers against unknown future versions. No header
  # → grandfather (pre-SC-F.1 snapshots predate the gate). v1 explicit →
  # supported. Anything else → loud refusal with actionable message.
  local snapfile="$1"
  [ -z "$snapfile" ] || [ ! -f "$snapfile" ] && return 1
  local ver
  ver=$(grep -m1 '^# version:' "$snapfile" 2>/dev/null | sed 's/^# version: *//' | tr -d '[:space:]')
  [ -z "$ver" ] && return 0
  [ "$ver" = "${HIVEMIND_SNAPSHOT_VERSION:-1}" ] && return 0
  error.log "snapshot version '$ver' not supported (this oosh handles v${HIVEMIND_SNAPSHOT_VERSION:-1} only)"
  error.log "  file: $snapfile"
  error.log "  fix: use a v${HIVEMIND_SNAPSHOT_VERSION:-1} snapshot or upgrade/downgrade oosh to match"
  return 1
}

private.hiveMind.snapshot.row.valid() # <row> # SC-F.2/3 — true if row passes per-field validation (logs reason on reject)
{
  # SC-F.2/3: row gate for snapshot save (write) + restore (read).
  # Uses kernel predicates (this.is*) from SC-E.2 P3 family. Skip-and-log
  # semantics — caller continues processing other rows. Defends against:
  #   • Bug #4 "Did/you/mean:" prompt garbage leaking into snapshot
  #   • pipe/newline injection in any field
  #   • non-tmux session names (e.g. error message captured as a row)
  # Schema: sess|addr|role|uuid|title|cwd|model|kind
  local row="$1"
  [ -z "$row" ] && return 1
  case "$row" in '#'*) return 1 ;; esac  # comments aren't data rows

  local sess addr role uuid title cwd model kind
  IFS='|' read -r sess addr role uuid title cwd model kind <<< "$row"

  # Field 1 — session: required, tmux session name format
  if ! this.isSessionName "$sess"; then
    warn.log "snapshot row reject: invalid session '$sess'"
    return 1
  fi
  # Field 2 — address: required, window.pane numeric
  if [[ ! "$addr" =~ ^[0-9]+\.[0-9]+$ ]]; then
    warn.log "snapshot row reject: invalid address '$addr' (sess=$sess)"
    return 1
  fi
  # Field 3 — role: empty allowed, otherwise isRoleName
  if [ -n "$role" ] && ! this.isRoleName "$role"; then
    warn.log "snapshot row reject: invalid role '$role' (sess=$sess)"
    return 1
  fi
  # Field 4 — uuid: empty allowed, otherwise canonical UUID
  if [ -n "$uuid" ] && ! this.isUuid "$uuid"; then
    warn.log "snapshot row reject: invalid uuid '$uuid' (sess=$sess)"
    return 1
  fi
  # Fields 5–8 — pipe-safe (titles can have spaces + parens; cwds are paths)
  local f label
  for f in "title:$title" "cwd:$cwd" "model:$model" "kind:$kind"; do
    label="${f%%:*}"
    local val="${f#*:}"
    if [ -n "$val" ] && ! this.isPipeSafe "$val"; then
      warn.log "snapshot row reject: pipe/newline in $label='$val' (sess=$sess)"
      return 1
    fi
  done
  return 0
}

private.hiveMind.wait.for.claude() # <paneTarget> <?timeout:30> # poll until Claude process alive
{
  local target="$1" timeout="${2:-30}" waited=0
  while [ "$waited" -lt "$timeout" ]; do
    if "$OOSH_DIR/claudeCode" process.running "$target" 2>/dev/null; then
      return 0
    fi
    sleep 1
    waited=$((waited + 1))
  done
  return 1
}

hiveMind.teams.restore() # <?snapshotFile> <?mode:join|fork> <?sessionFilter> # cold-restart teams from snapshot + layout (compose B2 + claudeCode)
{
  # Arg classification (positional, OOSH-style — no flags):
  #   fork | join                              → forkMode
  #   existing file on disk                    → snapshot path
  #   matches this.isSessionName (alpha-num + .-_) → sessionFilter (only that session is restored)
  # SC-F#7 (post-McDonges-disaster): sessionFilter scopes the restore to ONE
  # session instead of every row in the snapshot. Prevents bulk-explosion when
  # team.migrate calls back into restore on the remote.
  # SC-F#8 (post-McDonges): track whether the caller PROVIDED a snapshot path
  # vs left it implicit. An explicit-but-missing path must fail loud — the
  # silent `ls -t | head -1` fallback caused team.migrate's session-slice
  # transfer failures to fall back to the remote's latest full snapshot,
  # restoring EVERY session in it (McDonges 18-session bulk-clone disaster).
  local snapfile="" forkMode="" sessionFilter="" snapfileExplicit=""
  for arg in "$@"; do
    case "$arg" in
      fork) forkMode="yes" ;;
      join) forkMode="" ;;
      *)
        if [ -f "$arg" ]; then
          [ -z "$snapfile" ] && { snapfile="$arg"; snapfileExplicit="yes"; }
        elif this.isSessionName "$arg" 2>/dev/null; then
          [ -z "$sessionFilter" ] && sessionFilter="$arg"
        else
          # Looks like a path the caller meant — but file doesn't exist. Record
          # the intent so we can fail loud (not silently fall back to latest).
          [ -z "$snapfile" ] && { snapfile="$arg"; snapfileExplicit="yes"; }
        fi
        ;;
    esac
  done
  if [ "$snapfileExplicit" = "yes" ] && [ ! -f "$snapfile" ]; then
    error.log "teams.restore: snapshot file not found: '$snapfile' — refusing to fall back to latest (SC-F#8 fail-loud)"
    return 1
  fi
  if [ -z "$snapfile" ]; then
    snapfile=$(ls -t "${CONFIG_PATH:-$HOME/config}"/hivemind.snapshot.*.env 2>/dev/null | head -1)
  fi
  if [ -z "$snapfile" ] || [ ! -f "$snapfile" ]; then
    error.log "No snapshot found. Usage: hiveMind teams.restore <?file> <?mode:join|fork> <?sessionFilter>"
    return 1
  fi

  # SC-F.1: version gate. Reject unknown versions before any state mutation.
  private.hiveMind.snapshot.version.check "$snapfile" || return 1

  echo "Restoring from: $snapfile${forkMode:+ (fork mode)}"
  echo ""

  # Ensure tmux server is running (BUG-Z1)
  local RESTORE_CLEANUP_SESSION=""
  if ! otmux sessions >/dev/null 2>&1; then
    otmux new __restore_init -d -x 200 -y 50 2>/dev/null
    RESTORE_CLEANUP_SESSION="__restore_init"
  fi

  # C1 STEP 1: collect unique sessions, restore layout FIRST via B2 (geometry + titles + cwds)
  # Bug #4-class hardening: use array, not unquoted $var. A snapshot corrupted
  # with whitespace-containing session names (e.g. an error message that leaked
  # in via teams.save) would word-split on the for-loop, registering each token
  # as a separate team. Already in teams.env: "Did" / "you" / "mean:" entries
  # from a "Did you mean: <suggestion>" error captured into a 20260427 snapshot.
  local uniqueSessions=()
  while IFS= read -r sess; do
    [ -z "$sess" ] && continue
    # SC-F#7 — sessionFilter scopes restore to ONE session
    [ -n "$sessionFilter" ] && [ "$sess" != "$sessionFilter" ] && continue
    uniqueSessions+=("$sess")
  done < <(grep -v '^#' "$snapfile" 2>/dev/null | cut -d'|' -f1 | sort -u)
  if [ -n "$sessionFilter" ] && [ ${#uniqueSessions[@]} -eq 0 ]; then
    error.log "teams.restore: sessionFilter '$sessionFilter' matched no rows in $snapfile"
    return 1
  fi
  [ -n "$sessionFilter" ] && echo "Filter: only session '$sessionFilter' will be restored"

  # SC-F#10 (post-McDonges): precompute target pane count per session from the
  # snapshot. The McDonges disaster's ooshTeam 55-pane explosion came from
  # successive bulk-restore runs each calling ensure.pane for snapshot rows
  # against an already-populated session — every row beyond the existing max
  # pane index forced new splits. The guard caps creation: if current >= target
  # for a session, the iteration loop SKIPS the per-row ensure.pane call.
  # Metadata (title, registry, claude join) still applies to the existing pane.
  #
  # Bash 3.2 compat (task #29): declare -A is bash 4+. Store as `sess|N\n` lines
  # in a string instead; lookup via grep. Portable across bash 3.2 and 4+.
  local TARGET_PANE_COUNT
  TARGET_PANE_COUNT=$(grep -v '^#' "$snapfile" 2>/dev/null | cut -d'|' -f1 | \
    if [ -n "$sessionFilter" ]; then grep -Fx "$sessionFilter"; else cat; fi | \
    sort | uniq -c | awk '{printf "%s|%s\n", $2, $1}')

  for sess in "${uniqueSessions[@]}"; do
    [ -z "$sess" ] && continue
    if otmux has "$sess" 2>/dev/null; then
      # Idempotency: session exists → apply saved layout with --force (doesn't destroy panes)
      otmux layout.restore "$sess" --force >/dev/null 2>&1 \
        && echo "  layout.restore: $sess (session existed — applied saved layout)"
    else
      if otmux layout.restore "$sess" >/dev/null 2>&1; then
        echo "  layout.restore: $sess (created from saved layout)"
      else
        # Fallback: no saved layout → create bare session
        echo "  layout.restore: $sess (no layout file — creating bare session)"
        otmux new "$sess" -d -x 200 -y 50 2>/dev/null
      fi
    fi
  done

  # C1 STEP 2: iterate snapshot entries — schema: sess|addr|role|uuid|title|cwd|model|kind
  local claude_count=0 shell_count=0 skip_count=0
  while IFS='|' read -r sess addr role uuid title cwd model kind <&3; do
    [[ "$sess" == "#"* ]] && continue
    [ -z "$sess" ] && continue
    # SC-F#7 — skip rows outside the session filter (no-op when filter empty)
    [ -n "$sessionFilter" ] && [ "$sess" != "$sessionFilter" ] && continue
    # Default kind for backward-compat with old 5-field snapshots
    [ -z "$kind" ] && kind=$([ -n "$uuid" ] && echo claude || echo shell)
    # SC-F.3: per-row validation — skip + log garbage (Bug #4 class). Reconstruct
    # the row for the shared validator. Pre-SC-F.1 snapshots may have title with
    # trailing pipes; this catches them rather than mutating state from junk.
    local _row="${sess}|${addr}|${role}|${uuid}|${title}|${cwd}|${model}|${kind}"
    if ! private.hiveMind.snapshot.row.valid "$_row"; then
      skip_count=$((skip_count + 1))
      continue
    fi

    local pane_target="${sess}:${addr}"

    # SC-F#10 — pane-count guard. Skip ensure.pane when the session already has
    # at least as many panes as the snapshot expects. Prevents cumulative pane
    # explosion when restore runs against an already-populated session (the
    # McDonges 55-pane ooshTeam pattern). If the target pane index falls within
    # the existing pane range, ensure.pane is a no-op anyway; if it falls
    # beyond, skipping is the conservative choice (operator can layout.restore
    # explicitly if they want growth).
    local _curPanes _tgtPanes
    # session-wide pane count (2nd arg "all" triggers -s in pane.count helper)
    _curPanes=$(private.hiveMind.pane.count "$sess" all 2>/dev/null)
    # Bash 3.2 compat: read TARGET_PANE_COUNT as `sess|N\n` lines, not assoc array.
    _tgtPanes=$(echo "$TARGET_PANE_COUNT" | grep "^${sess}|" | head -1 | cut -d'|' -f2)
    [ -z "$_tgtPanes" ] && _tgtPanes=0
    if [ "${_curPanes:-0}" -ge "${_tgtPanes:-0}" ] && [ "${_tgtPanes:-0}" -gt 0 ]; then
      : "skipping ensure.pane — session saturated (${_curPanes} >= ${_tgtPanes})"
    else
      # Pane should already exist from layout.restore; ensure anyway
      private.hiveMind.ensure.pane "$pane_target"
    fi

    # Set pane title (layout.restore may have done this; re-set for correctness).
    # If snapshot title lacks @hostname (older snapshots) OR has a non-hostname
    # @suffix (e.g. legacy @opus from Option A), normalize to @HIVEMIND_HOST.
    # Use pane.lock so Claude can't override after a /rename propagation.
    if [ -n "$title" ]; then
      local restored_title="${title%%@*}"
      restored_title="${restored_title}@${HIVEMIND_HOST}"
      otmux pane.lock "$pane_target" "$restored_title" 2>/dev/null
    fi

    # C1 STEP 5: kind-aware dispatch
    case "$kind" in
      shell)
        # Shell panes stay bash — just cd if we have a saved cwd
        if [ -n "$cwd" ]; then
          otmux send.enter "$pane_target" "cd '${cwd}'" Enter 2>/dev/null
        fi
        echo "  ${pane_target}: ${role} [shell] — left as bash${cwd:+ (cwd: $cwd)}"
        shell_count=$((shell_count + 1))
        ;;
      monitor)
        # tronMonitor setup handled separately (see tronMonitor.setup)
        echo "  ${pane_target}: ${role} [monitor] — skipped (tronMonitor.setup handles)"
        skip_count=$((skip_count + 1))
        ;;
      claude|*)
        if [ -n "$uuid" ]; then
          # C1 STEP 4: idempotency — skip if Claude already running
          if "$OOSH_DIR/claudeCode" process.running "$pane_target" 2>/dev/null; then
            echo "  ${pane_target}: ${role} already running (idempotent skip)"
            private.hiveMind.registry.set "$pane_target" "$role" 2>/dev/null
            skip_count=$((skip_count + 1))
            continue
          fi
          local joinCmd="join"
          [ "$forkMode" = "yes" ] && joinCmd="fork"
          echo "  ${pane_target}: ${joinCmd}ing ${role} (${uuid:0:8}...)${model:+ model=$model}"
          # Per-pane cwd (from snapshot, not hardcoded)
          local restoreCwd="${cwd:-${CLAUDE_PROJECT_DIR:-/Users/Shared/Workspaces/AI/Claude}}"
          otmux send.enter "$pane_target" "cd '${restoreCwd}'" Enter
          sleep 0.3
          # Model flag: use saved model or default to opus[1m]
          local modelFlag="${model:-claude-opus-4-6[1m]}"
          otmux send.enter "$pane_target" "claudeCode ${joinCmd}.byID ${uuid}" Enter
          # C1 STEP 3: poll for Claude alive instead of fixed sleep
          if private.hiveMind.wait.for.claude "$pane_target" 30; then
            # Boot prompt for known role
            if [ -n "$role" ] && [ "$role" != "unknown" ]; then
              local boot_file="session/agents/${role}/boot.md"
              if [ -f "$HIVEMIND_AGENTS_DIR/../../$boot_file" ] 2>/dev/null; then
                otmux send.enter "$pane_target" "Read $boot_file" Enter
              fi
            fi
            # Re-register child UUID for fork (join keeps same UUID)
            if [ "$joinCmd" = "fork" ]; then
              sleep 2
              private.hiveMind.session.resolve.uuid "$pane_target" 2>/dev/null
            fi
            claude_count=$((claude_count + 1))
          else
            warn.log "${pane_target}: Claude did not start within 30s (timeout)"
          fi
        else
          echo "  ${pane_target}: ${role} (no UUID — start manually)"
          skip_count=$((skip_count + 1))
        fi
        ;;
    esac

    # Update registry
    if [ -n "$role" ] && [ "$role" != "unknown" ]; then
      private.hiveMind.registry.set "$pane_target" "$role" 2>/dev/null
    fi

  done 3< "$snapfile"

  # C1: re-register teams that were active
  # SC-C.10: team.register emits team.created (first creation semantics).
  # We also emit team.restored per-team so handlers can apply restore-specific
  # behavior (e.g. annotate as restored, run consistency reconcile). Both
  # handler chains are idempotent; firing both is intentional.
  for sess in "${uniqueSessions[@]}"; do
    [ -z "$sess" ] && continue
    if otmux has "$sess" 2>/dev/null; then
      hiveMind.team.register "$sess" "Restored from $(basename "$snapfile")" 2>/dev/null
      private.hiveMind.events.emit "team.restored" "$sess" "$(basename "$snapfile")"
    fi
  done

  # B8: enforce 80×40 size floor on restored sessions — guarantees panes are
  # readable even with no client attached (otherwise tmux + window-size=largest
  # collapses unattached sessions to 1×1). User can `otmux window.size.unlock`
  # to revert any session to dynamic.
  for sess in "${uniqueSessions[@]}"; do
    [ -z "$sess" ] && continue
    if otmux has "$sess" 2>/dev/null; then
      otmux window.size.lock "$sess" 2>/dev/null
    fi
  done

  # Clean up init session if we created it
  if [ -n "$RESTORE_CLEANUP_SESSION" ]; then
    otmux kill "$RESTORE_CLEANUP_SESSION" 2>/dev/null
  fi

  echo ""
  echo "Restore complete: ${claude_count} Claude resumed, ${shell_count} shells restored, ${skip_count} skipped."
  echo "Check with: hiveMind team.status"
  return 0
}

hiveMind.team.migrate() # <session> <sshHost> <?snapshotFile> # migrate ONE team to remote — session-filtered, merge-on-remote (preserves remote's other teams)
{
  # Single-team migration. Mirrors teams.migrate but filtered to one session at
  # every step. Architect/PO design (2026-05-18):
  #   Q1 merge-on-remote — preserves remote's other teams (vs clobber)
  #   Q2 auto-snapshot — generates if no snapshot found
  #   Q3 success.log only for v1 (no event emission yet)
  #   Q4 singular team.migrate (teams.migrate plural stays as full-machine)
  # Gotcha (architect): use $1==s exact match, not $1~s — prevents ooshTeam from
  # also matching ooshTeam2 as a prefix.
  local session="$1" host="$2" snapfile="$3"
  if [ -z "$session" ] || [ -z "$host" ]; then
    error.log "Usage: hiveMind team.migrate <session> <sshHost> <?snapshotFile>"
    return 1
  fi
  # Ingress P3 — SC-E.2 pattern
  if ! this.isSessionName "$session"; then
    error.log "team.migrate: invalid session name '$session'"
    return 1
  fi
  if ! this.isPipeSafe "$session"; then
    error.log "team.migrate: '|' or newline in session name — rejected"
    return 1
  fi
  if ! otmux has "$session" 2>/dev/null; then
    error.log "team.migrate: '$session' is not a live tmux session"
    return 1
  fi

  : ${LOG_LIVE:=$HOME/config/log.live.out}
  touch "$LOG_LIVE" 2>>"$LOG_LIVE"
  important.log "team.migrate $session → $host (stderr in $LOG_LIVE — tail with: log live)"

  # 1. Resolve / auto-create snapshot (PO Q2)
  if [ -z "$snapfile" ]; then
    snapfile=$(ls -t "${CONFIG_PATH:-$HOME/config}"/hivemind.snapshot.*.env 2>>"$LOG_LIVE" | head -1)
  fi
  if [ -z "$snapfile" ] || [ ! -f "$snapfile" ]; then
    info.log "No snapshot found — running teams.save first"
    hiveMind.teams.save
    snapfile=$(ls -t "${CONFIG_PATH:-$HOME/config}"/hivemind.snapshot.*.env 2>>"$LOG_LIVE" | head -1)
  fi
  info.log "Using snapshot: $snapfile"

  # 2. Build session-filtered slices in tmp dir
  local tmpdir
  tmpdir=$(mktemp -d -t hivemind.team.migrate.XXXXXX)
  trap "rm -rf '$tmpdir'" RETURN
  local sessSnap="$tmpdir/hivemind.snapshot.${session}.env"
  # $1==s — EXACT match to prevent ooshTeam matching ooshTeam2 (architect note)
  awk -F'|' -v s="$session" '/^#/{print;next} $1==s' "$snapfile" > "$sessSnap"
  if [ ! -s "$sessSnap" ] || ! grep -qv '^#' "$sessSnap" 2>/dev/null; then
    error.log "team.migrate: no entries for '$session' in snapshot — is the session running with agents?"
    return 1
  fi
  local configPath="${CONFIG_PATH:-$HOME/config}"
  # Slice registries by session prefix (pane keys are "session:win.pane")
  [ -f "$configPath/hivemind.roles.env" ] && \
    awk -F'|' -v p="^${session}:" '$1 ~ p' "$configPath/hivemind.roles.env" > "$tmpdir/roles.${session}.env"
  [ -f "$configPath/hivemind.sessions.env" ] && \
    awk -F'|' -v p="^${session}:" '$1 ~ p' "$configPath/hivemind.sessions.env" > "$tmpdir/sessions.${session}.env"
  # teams.env keys are bare session names — exact match
  [ -f "$configPath/hivemind.teams.env" ] && \
    awk -F'|' -v s="$session" '$1==s' "$configPath/hivemind.teams.env" > "$tmpdir/teams.${session}.env"

  # 3. Sync oosh code on remote (needed for protected.team.import to exist)
  echo ""
  important.log "Syncing oosh on $host..."
  "$OOSH_DIR/ossh" connection.open "$host" 2>>"$LOG_LIVE"
  if ! "$OOSH_DIR/ossh" exec "$host" "cd ~/oosh && git pull" 2>>"$LOG_LIVE"; then
    warn.log "git pull failed on $host — falling back to dir.push"
    "$OOSH_DIR/ossh" dir.push "$host" "$OOSH_DIR" 2>>"$LOG_LIVE"
  fi

  # 4. Push slices + snapshot + otmux layout file (PO directive 2026-05-19)
  echo ""
  important.log "Pushing session slices to $host..."
  "$OOSH_DIR/ossh" exec "$host" "mkdir -p ~/config ~/config/otmux" 2>>"$LOG_LIVE"
  local pushCount=0 f
  # Copy the session's otmux layout file to tmpdir FIRST so scp src!=dest
  # path. Without this, when remote host resolves to the same machine
  # (e.g. McDonges.fritz.box pointing at localhost), scp src==dest and
  # the source file gets truncated to 0 bytes.
  local layoutSrc="${CONFIG_PATH:-$HOME/config}/otmux/${session}.layout.env"
  local layoutStaged=""
  if [ -f "$layoutSrc" ]; then
    layoutStaged="$tmpdir/${session}.layout.env"
    cp "$layoutSrc" "$layoutStaged" 2>>"$LOG_LIVE"
  fi
  for f in "$sessSnap" "$tmpdir/roles.${session}.env" "$tmpdir/sessions.${session}.env" "$tmpdir/teams.${session}.env" "$layoutStaged"; do
    [ -n "$f" ] || continue
    [ -f "$f" ] || continue
    # Layout file targets ~/config/otmux/, others target ~/config/
    local remoteName remoteDir
    if [[ "$f" == *.layout.env ]]; then
      remoteDir="config/otmux"
    else
      remoteDir="config"
    fi
    if "$OOSH_DIR/ossh" scp "$f" "${host}:${remoteDir}/$(basename "$f")" 2>>"$LOG_LIVE"; then
      info.log "  pushed: $(basename "$f")"
      pushCount=$((pushCount + 1))
    else
      warn.log "  failed: $(basename "$f") — see $LOG_LIVE"
    fi
  done
  info.log "Pushed $pushCount slice file(s)"

  # 5. Push JSONLs for UUIDs in this session's snapshot only
  echo ""
  important.log "Pushing JSONL files for $session..."
  local claudeProjectsDir="$HOME/.claude/projects"
  local transferCount=0 seenUuids="" sess addr role uuid title
  while IFS='|' read -r sess addr role uuid title <&3; do
    [[ "$sess" == "#"* ]] && continue
    [ -z "$uuid" ] && continue
    echo "$seenUuids" | grep -q "$uuid" && continue
    seenUuids="${seenUuids}${uuid} "
    for dir in "$claudeProjectsDir"/*/; do
      if [ -f "${dir}${uuid}.jsonl" ]; then
        local remotePath="${dir}${uuid}.jsonl"
        "$OOSH_DIR/ossh" exec "$host" "mkdir -p '$(dirname "$remotePath")'" 2>>"$LOG_LIVE"
        if "$OOSH_DIR/ossh" scp "$remotePath" "${host}:${remotePath}" 2>>"$LOG_LIVE"; then
          info.log "  transferred: ${uuid:0:8}... ($role)"
          transferCount=$((transferCount + 1))
        else
          warn.log "  transfer failed: ${uuid:0:8}... ($role) — see $LOG_LIVE"
        fi
        break
      fi
    done
  done 3< "$sessSnap"
  info.log "Transferred $transferCount JSONL file(s)"

  # 6. Merge slices into remote's registries (preserves remote's other teams)
  echo ""
  important.log "Merging $session into $host's registries..."
  "$OOSH_DIR/ossh" exec "$host" "hiveMind protected.team.import $session" 2>>"$LOG_LIVE"

  # 7. Restore on remote — feed it the session-filtered snapshot AND the
  #    sessionFilter param (SC-F#9). Belt-and-braces: even if the slice
  #    snapshot somehow contains other sessions (corruption / future schema
  #    change), the filter scopes restore to one session. Pre-SC-F#9, the
  #    remote `teams.restore` would process every row in the slice file —
  #    if the file itself was wrong, McDonges-style explosion would recur.
  echo ""
  important.log "Restoring $session on $host..."
  local snapname
  snapname=$(basename "$sessSnap")
  "$OOSH_DIR/ossh" exec "$host" "hiveMind teams.restore ~/config/${snapname} ${session}" 2>>"$LOG_LIVE"

  echo ""
  success.log "team.migrate: $session → $host complete"
  echo "  Verify: ossh exec $host 'hiveMind team.status $session'"
}
hiveMind.team.migrate.completion.session() { private.hiveMind.teams.complete; }
hiveMind.team.migrate.completion.sshHost() {
  command -v ossh >/dev/null 2>&1 && private.ossh.config.complete.hosts 2>/dev/null
}

hiveMind.protected.team.import() # <session> # merge ~/config/{roles,sessions,teams}.<session>.env slice files into local registries (called by team.migrate on the remote side)
{
  # Idempotent: drop existing entries matching this session, append new.
  # Pattern from D2.1 observer family — protected because cross-script callers
  # (remote ssh exec invoking us during team.migrate) need it CLI-callable but
  # it shouldn't appear in Tab completion as a user-facing verb.
  local session="$1"
  if [ -z "$session" ]; then
    error.log "Usage: hiveMind protected.team.import <session>"
    return 1
  fi
  # SC-E.2 P3 ingress — observer family is the highest-leverage attack surface
  if ! this.isSessionName "$session"; then
    error.log "protected.team.import: invalid session '$session'"
    return 1
  fi
  if ! this.isPipeSafe "$session"; then
    error.log "protected.team.import: '|' or newline in session — rejected"
    return 1
  fi
  local cfg="${CONFIG_PATH:-$HOME/config}"
  local imported=0
  # Roles (S1): key format "session:win.pane|role|ts" — drop matching session prefix, append slice
  if [ -f "$cfg/roles.${session}.env" ]; then
    local reg="$cfg/hivemind.roles.env"
    [ -f "$reg" ] && grep -v "^${session}:" "$reg" > "${reg}.tmp" 2>/dev/null && mv "${reg}.tmp" "$reg"
    cat "$cfg/roles.${session}.env" >> "$reg"
    imported=$((imported + 1))
  fi
  # Sessions (S2): same pattern (pane-keyed)
  if [ -f "$cfg/sessions.${session}.env" ]; then
    local ses="$cfg/hivemind.sessions.env"
    [ -f "$ses" ] && grep -v "^${session}:" "$ses" > "${ses}.tmp" 2>/dev/null && mv "${ses}.tmp" "$ses"
    cat "$cfg/sessions.${session}.env" >> "$ses"
    imported=$((imported + 1))
  fi
  # Teams (S3): bare session name as key — exact match
  if [ -f "$cfg/teams.${session}.env" ]; then
    local teams="$cfg/hivemind.teams.env"
    [ -f "$teams" ] && grep -v "^${session}|" "$teams" > "${teams}.tmp" 2>/dev/null && mv "${teams}.tmp" "$teams"
    cat "$cfg/teams.${session}.env" >> "$teams"
    imported=$((imported + 1))
  fi
  success.log "protected.team.import: $session merged $imported slice file(s)"
}

hiveMind.teams.migrate() # <sshHost> <?snapshotFile> # migrate team to remote machine via ossh
{
  local host="$1"
  local snapfile="$2"

  if [ -z "$host" ]; then
    error.log "Usage: hiveMind teams.migrate <sshHost> <?snapshotFile>"
    return 1
  fi

  # SC-E.2 P3: sshHost is command-injection vector — flows to ssh "$host".
  if ! this.isSshHost "$host"; then
    error.log "teams.migrate: invalid sshHost '$host' (alphanumeric/dot/dash/underscore, max 64)"
    return 1
  fi

  # Ensure LOG_LIVE exists so stderr can be captured for post-mortem.
  # User watches live with:  log live   (tail -f $LOG_LIVE in another pane)
  : ${LOG_LIVE:=$HOME/config/log.live.out}
  touch "$LOG_LIVE" 2>>"$LOG_LIVE"
  important.log "teams.migrate → $host (stderr captured to $LOG_LIVE — tail with: log live)"

  # 1. Validate snapshot
  if [ -z "$snapfile" ]; then
    snapfile=$(ls -t "${CONFIG_PATH:-$HOME/config}"/hivemind.snapshot.*.env 2>>"$LOG_LIVE" | head -1)
  fi
  if [ -z "$snapfile" ] || [ ! -f "$snapfile" ]; then
    info.log "No snapshot found — running teams.save first"
    hiveMind.teams.save
    snapfile=$(ls -t "${CONFIG_PATH:-$HOME/config}"/hivemind.snapshot.*.env 2>>"$LOG_LIVE" | head -1)
  fi
  info.log "Using snapshot: $snapfile"

  # 2. Transfer ONLY hivemind config files (NOT entire ~/config — that would clobber user.env, PATH, creds)
  echo ""
  important.log "Transferring hiveMind config files to $host..."
  "$OOSH_DIR/ossh" connection.open "$host" 2>>"$LOG_LIVE"
  # Ensure remote ~/config exists (don't create the dir structure, just the folder)
  "$OOSH_DIR/ossh" exec "$host" "mkdir -p ~/config" 2>>"$LOG_LIVE"
  local configPath="${CONFIG_PATH:-$HOME/config}"
  local pushCount=0 skipCount=0
  # Files we OWN and may safely overwrite on the remote:
  #   hivemind.roles.env     — pane|role registry
  #   hivemind.sessions.env  — pane|UUID map
  #   hivemind.teams.env     — team registry (session|description)
  #   hivemind.snapshot.*.env — snapshots (includes the one we just saved)
  # Files we MUST NOT touch: user.env, log.env, oosh.env, credentials, state machines, etc.
  local f fname
  for f in "$configPath"/hivemind.roles.env \
           "$configPath"/hivemind.sessions.env \
           "$configPath"/hivemind.teams.env \
           "$configPath"/hivemind.forks.env \
           "$configPath"/hivemind.snapshot.*.env; do
    [ -f "$f" ] || { skipCount=$((skipCount + 1)); continue; }
    fname=$(basename "$f")
    if "$OOSH_DIR/ossh" scp "$f" "${host}:config/${fname}" 2>>"$LOG_LIVE"; then
      info.log "  pushed: $fname"
      pushCount=$((pushCount + 1))
    else
      warn.log "  failed: $fname — see $LOG_LIVE"
    fi
  done
  # Also push the specific snapshot we are restoring (belt-and-suspenders if it's older than the glob range)
  local snapname
  snapname=$(basename "$snapfile")
  if ! "$OOSH_DIR/ossh" exec "$host" "test -f ~/config/${snapname}" 2>>"$LOG_LIVE"; then
    "$OOSH_DIR/ossh" scp "$snapfile" "${host}:config/${snapname}" 2>>"$LOG_LIVE" && pushCount=$((pushCount + 1))
  fi
  info.log "Transferred $pushCount hiveMind file(s), skipped $skipCount (not present)"

  # 3. Transfer JSONL session files
  echo ""
  important.log "Transferring session JSONL files to $host..."
  local claudeProjectsDir="$HOME/.claude/projects"
  local transferCount=0 seenUuids=""
  while IFS='|' read -r sess addr role uuid title <&3; do
    [[ "$sess" == "#"* ]] && continue
    [ -z "$uuid" ] && continue
    echo "$seenUuids" | grep -q "$uuid" && continue
    seenUuids="${seenUuids}${uuid} "
    for dir in "$claudeProjectsDir"/*/; do
      if [ -f "${dir}${uuid}.jsonl" ]; then
        local remotePath="${dir}${uuid}.jsonl"
        # Ensure remote directory exists
        "$OOSH_DIR/ossh" exec "$host" "mkdir -p '$(dirname "$remotePath")'" 2>>"$LOG_LIVE"
        if "$OOSH_DIR/ossh" scp "$remotePath" "${host}:${remotePath}" 2>>"$LOG_LIVE"; then
          info.log "  transferred: ${uuid:0:8}... ($role)"
          transferCount=$((transferCount + 1))
        else
          warn.log "  transfer failed: ${uuid:0:8}... ($role) — see $LOG_LIVE"
        fi
        break
      fi
    done
  done 3< "$snapfile"
  info.log "Transferred $transferCount JSONL file(s)"

  # 4. Sync oosh code on remote
  echo ""
  important.log "Syncing oosh on $host..."
  if ! "$OOSH_DIR/ossh" exec "$host" "cd ~/oosh && git pull" 2>>"$LOG_LIVE"; then
    warn.log "git pull failed on $host — falling back to dir.push"
    "$OOSH_DIR/ossh" dir.push "$host" "$OOSH_DIR" 2>>"$LOG_LIVE"
  fi

  # 4b. Check prerequisites on remote
  echo ""
  important.log "Checking prerequisites on $host..."
  local missing=""
  missing=$("$OOSH_DIR/ossh" exec "$host" '
    errors=""
    command -v tmux >/dev/null 2>&1 || { test -x /opt/homebrew/bin/tmux && export PATH=/opt/homebrew/bin:$PATH; } || errors="${errors}tmux "
    command -v claude >/dev/null 2>&1 || test -x ~/.local/bin/claude || errors="${errors}claude "
    echo "$errors"
  ' 2>>"$LOG_LIVE")
  if [ -n "$missing" ]; then
    error.log "Missing on $host: $missing (see $LOG_LIVE for details)"
    return 1
  fi
  info.log "Prerequisites OK"

  # 5. Model compatibility check — opus[1m] blocks ALL API calls on some subscriptions
  local badModel
  badModel=$("$OOSH_DIR/ossh" exec "$host" 'grep -o "opus\[1m\]" ~/.claude/settings.json 2>/dev/null' 2>>"$LOG_LIVE")
  if [ -n "$badModel" ]; then
    warn.log "Remote has opus[1m] model — fixing to opus (200k)"
    "$OOSH_DIR/ossh" exec "$host" "sed -i.bak 's/opus\[1m\]/opus/g' ~/.claude/settings.json" 2>>"$LOG_LIVE"
  fi

  # 6. Ensure tmux server running + restore on remote (fork mode for cross-machine)
  echo ""
  important.log "Running teams.restore fork on $host..."
  "$OOSH_DIR/ossh" exec "$host" "source ~/config/user.env 2>/dev/null; hiveMind teams.restore fork" 2>>"$LOG_LIVE"

  # 7. Verify
  echo ""
  important.log "Verifying on $host..."
  "$OOSH_DIR/ossh" exec "$host" "source ~/config/user.env 2>/dev/null; hiveMind team.status" 2>>"$LOG_LIVE"

  echo ""
  success.log "Migration to $host complete."
  info.log "Connect with: ossh login $host"
  info.log "Captured stderr during migrate: $LOG_LIVE"
  return 0
}

# ─────────────────────────────────────────────────────────────────────────────
# REMOTE PULL — reverse of teams.migrate (pull FROM remote, restart locally)
# ─────────────────────────────────────────────────────────────────────────────

hiveMind.team.pull() # <sshHost> # pull team config from remote machine
{
  local host="$1"
  if [ -z "$host" ]; then
    error.log "Usage: hiveMind team.pull <sshConfigName>"
    return 1
  fi

  # SC-E.2 P3: sshHost is command-injection vector — flows to ssh "$host".
  if ! this.isSshHost "$host"; then
    error.log "team.pull: invalid sshHost '$host' (alphanumeric/dot/dash/underscore, max 64)"
    return 1
  fi

  local pullDir="${TMPDIR:-/tmp}/hivemind.${host}"
  local claudeProjectsDir="$HOME/.claude/projects"

  echo -e "${BOLD_CYAN}Pulling team config from $host${NORMAL}"

  # 1. Open persistent SSH connection
  "ossh" connection.open "$host" || { error.log "Cannot connect to $host"; return 1; }

  # 2. Run remote teams.save
  echo "Creating snapshot on $host..."
  "ossh" exec "$host" "hiveMind teams.save" 2>/dev/null

  # 3. Create local pull directory
  mkdir -p "$pullDir"

  # 4. Pull config files from remote
  echo "Pulling config files..."
  "ossh" scp "$host:~/config/hivemind.roles.env" "$pullDir/hivemind.roles.env" 2>/dev/null
  "ossh" scp "$host:~/config/hivemind.sessions.env" "$pullDir/hivemind.sessions.env" 2>/dev/null
  "ossh" scp "$host:~/config/hivemind.teams.env" "$pullDir/hivemind.teams.env" 2>/dev/null

  # 4b. Pull otmux layout files (PO directive 2026-05-19 — needed for teams.restore)
  mkdir -p "$pullDir/otmux"
  local remoteLayouts
  remoteLayouts=$("ossh" exec "$host" "ls ~/config/otmux/*.layout.env 2>/dev/null" 2>/dev/null)
  local layoutCount=0 layoutFile
  for layoutFile in $remoteLayouts; do
    [ -z "$layoutFile" ] && continue
    if "ossh" scp "$host:$layoutFile" "$pullDir/otmux/$(basename "$layoutFile")" 2>/dev/null; then
      layoutCount=$((layoutCount + 1))
    fi
  done
  [ "$layoutCount" -gt 0 ] && info.log "Pulled $layoutCount otmux layout file(s)"

  # 5. Pull latest snapshot
  local remoteSnap
  remoteSnap=$("ossh" exec "$host" "ls -t ~/config/hivemind.snapshot.*.env 2>/dev/null | head -1" 2>/dev/null)
  if [ -n "$remoteSnap" ]; then
    "ossh" scp "$host:$remoteSnap" "$pullDir/hivemind.snapshot.env" 2>/dev/null
  else
    error.log "No snapshot found on $host"
    return 1
  fi

  # 6. Pull JSONL files for each UUID in snapshot
  local transferCount=0 skipCount=0 failCount=0
  local claudeProjectsDir="$HOME/.claude/projects"
  # Determine local project subdir (first existing, or derive from remote)
  local localProjectDir
  localProjectDir=$(ls -d "$claudeProjectsDir"/*/ 2>/dev/null | head -1)
  [ -z "$localProjectDir" ] && localProjectDir="$claudeProjectsDir/default/"
  mkdir -p "$localProjectDir" 2>/dev/null

  # Deduplicate UUIDs (forked sessions share same UUID)
  local seenUuids=""
  while IFS='|' read -r sess addr role uuid title <&3; do
    [[ "$sess" == "#"* ]] && continue
    [ -z "$uuid" ] && continue
    echo "$seenUuids" | grep -q "$uuid" && continue
    seenUuids="${seenUuids}${uuid} "

    # Find JSONL path on remote
    local remotePath
    remotePath=$("ossh" exec "$host" "ls ~/.claude/projects/*/${uuid}.jsonl 2>/dev/null | head -1")
    if [ -z "$remotePath" ]; then
      info.log "  JSONL not found on remote: ${uuid} ($role)"
      skipCount=$((skipCount + 1))
      continue
    fi

    # Download to local projects dir (normalize path — remote home may differ)
    local localPath="${localProjectDir}${uuid}.jsonl"
    if "ossh" scp "$host:$remotePath" "$localPath"; then
      info.log "  Transferred: ${uuid} ($role)"
      transferCount=$((transferCount + 1))
    else
      warn.log "  Transfer failed: ${uuid} ($role)"
      failCount=$((failCount + 1))
    fi
  done 3< "$pullDir/hivemind.snapshot.env"

  # 7. Write timestamp
  date -u '+%Y-%m-%dT%H:%M:%SZ' > "$pullDir/hivemind.pulled.timestamp"

  echo ""
  success.log "Pulled from $host → $pullDir"
  echo -e "  Config files: ${BOLD_WHITE}hivemind.roles.env, hivemind.sessions.env, hivemind.snapshot.env${NORMAL}"
  echo -e "  JSONL files:  ${BOLD_WHITE}$transferCount${NORMAL} transferred, ${skipCount} not on remote, ${failCount} failed"
  echo ""
  echo "Restart one:  hiveMind agent.restart $pullDir <role>"
  echo "Restart all:  hiveMind team.restart $pullDir"
}

hiveMind.agent.restart() # <configDir> <role> # restart single agent from pulled config
{
  local pullDir="$1"
  local targetRole="$2"

  if [ -z "$pullDir" ] || [ ! -f "$pullDir/hivemind.snapshot.env" ]; then
    error.log "Usage: hiveMind agent.restart <configDir> <role>"
    echo "  Available dirs: $(ls -d ${TMPDIR:-/tmp}/hivemind.*/ "${CONFIG_PATH:-$HOME/config}"/hivemind.*/ 2>/dev/null | grep -v '__test_' | tr '\n' ' ')"
    return 1
  fi

  # SC-F.1: version gate before parsing snapshot rows.
  private.hiveMind.snapshot.version.check "$pullDir/hivemind.snapshot.env" || return 1

  if [ -z "$targetRole" ]; then
    error.log "Usage: hiveMind agent.restart <configDir> <role>"
    echo "  Available roles in $pullDir:"
    while IFS='|' read -r sess addr role uuid title <&3; do
      [[ "$sess" == "#"* ]] && continue
      [ -z "$role" ] && continue
      echo "    $role"
    done 3< "$pullDir/hivemind.snapshot.env"
    return 1
  fi

  # Find matching line in snapshot
  local matchLine=""
  while IFS='|' read -r sess addr role uuid title <&3; do
    [[ "$sess" == "#"* ]] && continue
    [ "$role" = "$targetRole" ] && matchLine="${sess}|${addr}|${role}|${uuid}|${title}" && break
  done 3< "$pullDir/hivemind.snapshot.env"

  if [ -z "$matchLine" ]; then
    error.log "Role '$targetRole' not found in $pullDir/hivemind.snapshot.env"
    return 1
  fi

  local sess addr role uuid title
  IFS='|' read -r sess addr role uuid title <<< "$matchLine"

  local hostName
  hostName=$(basename "$pullDir" | sed 's/^hivemind\.//' | tr '.' '_')

  # Session naming: prefix with host if collision with existing different session
  local localSess="$sess"
  if otmux has "$sess" 2>/dev/null; then
    # Check if it's a different team (not ours)
    local existingDesc
    existingDesc=$(hiveMind.team.active 2>/dev/null)
    if [ "$existingDesc" != "$sess" ]; then
      localSess="${hostName}_${sess}"
    fi
  fi

  # Create session if needed
  if ! otmux has "$localSess" 2>/dev/null; then
    echo "  Creating session: $localSess"
    otmux new "$localSess" 2>/dev/null
  fi

  local paneTarget="${localSess}:${addr}"

  # Create pane
  private.hiveMind.ensure.pane "$paneTarget"

  # Set identity
  if [ -n "$role" ] && [ "$role" != "unknown" ]; then
    private.hiveMind.pane.identify "$paneTarget" "$role"
    # SC-H.2 Gap C: agent.restart was silent — observers missed the restart
    # lifecycle. Emit agent.spawned with the snapshot UUID (best-known at this
    # point; fork-child UUID is filled in later by registry.refresh).
    private.hiveMind.events.emit "agent.spawned" "$paneTarget" "$role" "${uuid:-}"
    # SC-H.2 Gap A: bash-3.2 fallback. Event handler schedules defer-probe on
    # bash 5; events are no-op on bash 3.2 — schedule directly when UUID empty.
    [ -z "$HIVEMIND_EVENTS_AVAILABLE" ] && [ -z "$uuid" ] && \
      private.hiveMind.session.store.deferred "$paneTarget" "$role"
  fi

  # Fork session if UUID available
  if [ -n "$uuid" ]; then
    local hasJsonl=""
    for d in "$HOME/.claude/projects"/*/; do
      [ -f "${d}${uuid}.jsonl" ] && hasJsonl="yes" && break
    done

    if [ "$hasJsonl" = "yes" ]; then
      echo -e "  ${BOLD_WHITE}${role}${NORMAL} → fork ${uuid} in ${paneTarget}"
      otmux send.enter "$paneTarget" "claudeCode fork $uuid"
      # Auto-register child UUID after fork starts
      sleep 5
      private.hiveMind.session.resolve.uuid "$paneTarget" 2>/dev/null
    else
      echo -e "  ${BOLD_WHITE}${role}${NORMAL} → ${BOLD_YELLOW}no JSONL, starting fresh${NORMAL} in ${paneTarget}"
      otmux send.enter "$paneTarget" "claudeCode opus"
    fi
  else
    echo -e "  ${BOLD_WHITE}${role}${NORMAL} → ${GRAY}no UUID, starting fresh${NORMAL} in ${paneTarget}"
    otmux send.enter "$paneTarget" "claudeCode opus"
  fi

  # Register in local registry
  private.hiveMind.registry.set "$paneTarget" "$role"

  # Lifecycle trigger — refresh cache + forks.env for the restarted team
  hiveMind.registry.refresh "${paneTarget%%:*}" >/dev/null

  success.log "Restarted $role in $paneTarget"
}
hiveMind.team.restart() # <configDir> # restart ALL agents from pulled config directory
{
  local pullDir="$1"
  if [ -z "$pullDir" ] || [ ! -f "$pullDir/hivemind.snapshot.env" ]; then
    error.log "Usage: hiveMind team.restart <configDir>"
    echo "  Available: $(ls -d ${TMPDIR:-/tmp}/hivemind.*/ "${CONFIG_PATH:-$HOME/config}"/hivemind.*/ 2>/dev/null | grep -v '__test_' | tr '\n' ' ')"
    return 1
  fi

  # SC-F.1: version gate before parsing snapshot rows.
  private.hiveMind.snapshot.version.check "$pullDir/hivemind.snapshot.env" || return 1

  local hostName
  hostName=$(basename "$pullDir" | sed 's/^hivemind\.//' | tr '.' '_')

  echo -e "${BOLD_CYAN}Restarting ALL agents from $pullDir${NORMAL}"

  local prevSess="" agentCount=0

  while IFS='|' read -r sess addr role uuid title <&3; do
    [[ "$sess" == "#"* ]] && continue
    [ -z "$sess" ] && continue

    # Session naming: prefix with host if collision
    local localSess="$sess"
    if otmux has "$sess" 2>/dev/null && [ "$sess" != "$prevSess" ]; then
      localSess="${hostName}_${sess}"
    fi

    # Create session if new
    if [ "$localSess" != "$prevSess" ]; then
      if ! otmux has "$localSess" 2>/dev/null; then
        # PO directive 2026-05-19: use pulled layout file if available
        # (only when no name collision — layout file is keyed by ORIGINAL session name)
        local pulledLayout="$pullDir/otmux/${sess}.layout.env"
        if [ "$localSess" = "$sess" ] && [ -f "$pulledLayout" ]; then
          echo "  Creating session via pulled layout: $localSess"
          OTMUX_LAYOUT_DIR="$pullDir/otmux" otmux layout.restore "$sess" 2>/dev/null \
            || otmux new "$localSess" 2>/dev/null
        else
          echo "  Creating session: $localSess"
          otmux new "$localSess" 2>/dev/null
        fi
      fi
      prevSess="$localSess"
    fi

    local paneTarget="${localSess}:${addr}"

    # Create pane
    private.hiveMind.ensure.pane "$paneTarget"

    # Set identity
    if [ -n "$role" ] && [ "$role" != "unknown" ]; then
      private.hiveMind.pane.identify "$paneTarget" "$role"
      # SC-H.2 Gap C: team.restart per-agent loop now emits agent.spawned so
      # observer chain (registry refresh, tronMonitor sync, etc.) sees each
      # restarted agent uniformly with agent.bootstrap.
      private.hiveMind.events.emit "agent.spawned" "$paneTarget" "$role" "${uuid:-}"
      # SC-H.2 Gap A: bash-3.2 fallback (events.emit no-op without bash 5).
      [ -z "$HIVEMIND_EVENTS_AVAILABLE" ] && [ -z "$uuid" ] && \
        private.hiveMind.session.store.deferred "$paneTarget" "$role"
    fi

    # Fork session if UUID available
    if [ -n "$uuid" ]; then
      local hasJsonl=""
      for d in "$HOME/.claude/projects"/*/; do
        [ -f "${d}${uuid}.jsonl" ] && hasJsonl="yes" && break
      done

      if [ "$hasJsonl" = "yes" ]; then
        echo -e "  ${GRAY}${addr}${NORMAL} ${BOLD_WHITE}${role:-unknown}${NORMAL} → fork ${uuid}"
        otmux send.enter "$paneTarget" "claudeCode fork $uuid"
        agentCount=$((agentCount + 1))
        sleep 5
        # Auto-register child UUID after fork starts
        private.hiveMind.session.resolve.uuid "$paneTarget" 2>/dev/null
      else
        echo -e "  ${GRAY}${addr}${NORMAL} ${BOLD_WHITE}${role:-unknown}${NORMAL} → ${BOLD_YELLOW}no JSONL (start fresh)${NORMAL}"
        otmux send.enter "$paneTarget" "claudeCode opus"
        agentCount=$((agentCount + 1))
        sleep 2
      fi
    else
      echo -e "  ${GRAY}${addr}${NORMAL} ${BOLD_WHITE}${role:-unknown}${NORMAL} → ${GRAY}no UUID (skip)${NORMAL}"
    fi
  done 3< "$pullDir/hivemind.snapshot.env"

  # Register team
  hiveMind.team.register "$prevSess" "Pulled from $hostName" 2>/dev/null

  # Lifecycle trigger — give forks ~8s to boot then refresh cache + forks.env
  sleep 8
  hiveMind.registry.refresh "$prevSess" >/dev/null

  echo ""
  success.log "Restarted $agentCount agents from $hostName"
  echo "Check with: hiveMind team.status $prevSess"
}
hiveMind.protected.session.renamed() # <oldName> <newName> # update all env files after session rename
{
  local old="$1" new="$2"
  [ -z "$old" ] || [ -z "$new" ] && return 1
  [ "$old" = "$new" ] && return 0
  # SC-E.2 P3 — both names must be valid session identifiers and pipe-safe.
  # This handler rewrites multiple env files via sed; an unvalidated identifier
  # containing '|', spaces, or shell-meta chars would corrupt the env or
  # behave as a sed metachar. Highest-leverage observer: every otmux rename
  # flows through here.
  if ! this.isSessionName "$old" || ! this.isSessionName "$new"; then
    error.log "protected.session.renamed: invalid name(s) '$old' → '$new' — rejected"
    return 1
  fi
  if ! this.isPipeSafe "$old" || ! this.isPipeSafe "$new"; then
    error.log "protected.session.renamed: '|' or newline in name(s) — rejected"
    return 1
  fi

  local reg="${HIVEMIND_REGISTRY:-${CONFIG_PATH:-$HOME/config}/hivemind.roles.env}"
  local ses="${HIVEMIND_SESSIONS:-${CONFIG_PATH:-$HOME/config}/hivemind.sessions.env}"
  local teams="${CONFIG_PATH:-$HOME/config}/hivemind.teams.env"
  local active="${CONFIG_PATH:-$HOME/config}/hivemind.active.team"

  [ -f "$reg" ] && sed "s/^${old}:/${new}:/" "$reg" > "${reg}.tmp" && mv "${reg}.tmp" "$reg"
  [ -f "$ses" ] && sed "s/^${old}:/${new}:/" "$ses" > "${ses}.tmp" && mv "${ses}.tmp" "$ses"
  [ -f "$teams" ] && sed "s/^${old}|/${new}|/" "$teams" > "${teams}.tmp" && mv "${teams}.tmp" "$teams"
  [ -f "$active" ] && grep -q "^${old}$" "$active" 2>/dev/null && echo "$new" > "$active"

  info.log "Session renamed: $old → $new (env files updated)"
}

hiveMind.protected.panes.shifted() # <session> # B5.1 observer — emits panes.shifted event (SC-C.5)
{
  # View → Controller bridge: View notifies Controller after split/insert.
  # SC-C.5 migration: now a thin emitter; mutation logic lives in
  # `private.hiveMind.handler.panes.shifted.*` handlers (registered at load).
  local session="$1"
  [ -z "$session" ] && return 1
  private.hiveMind.events.emit "panes.shifted" "$session"
  info.log "Panes shifted in $session — registry refreshed"
}

private.hiveMind.pane.pushRoleEnv() { # <pane> <newRole> # Bug #3 — push HIVEMIND_ROLE update to pane shell
  # After swap/move, the pane index has changed but the shell process retained
  # its old HIVEMIND_ROLE export. The env value itself is NOT a source of truth
  # for the sender prefix anymore (Tron P0 2026-05-12 LATE: prefix now reads
  # registry only, never env — env was prone to staleness). We still push the
  # updated env here so any FUTURE Claude process started in this pane inherits
  # the correct role (bootstrap/respawn read it at launch time), and so user
  # commands in the shell see consistent `$HIVEMIND_ROLE`.
  #
  # Strategy: only push the export when pane is running a plain shell. Sending
  # to a Claude Code TUI would inject text into the agent's input prompt —
  # harmful.
  local pane="$1" newRole="$2"
  [ -z "$pane" ] || [ -z "$newRole" ] && return 0
  # Use raw tmux to avoid re-entering the OOSH dispatch chain (info.log noise
  # would pollute $cmd at higher LOG_LEVEL). hiveMind already invokes tmux
  # directly elsewhere via otmux which uses tmux -u; here we need a single
  # display-message read, no flag stacking — plain `tmux` is fine.
  local cmd
  cmd=$(tmux display-message -p -t "$pane" '#{pane_current_command}' 2>/dev/null)
  cmd="${cmd##*$'\n'}"   # last line only — defensive
  case "$cmd" in
    bash|zsh|sh|fish)
      # Verify it's not a Claude Code child process running under bash
      if "$OOSH_DIR/claudeCode" process.running "$pane" 2>/dev/null; then
        debug.log "Skipping HIVEMIND_ROLE update on $pane — Claude running under shell"
        return 0
      fi
      otmux send.enter "$pane" "export HIVEMIND_ROLE=$newRole" 2>/dev/null
      debug.log "Pushed HIVEMIND_ROLE=$newRole to shell at $pane"
      ;;
    *)
      # Claude/node/other TUI — env update would inject into prompt; skip.
      # Registry alone is authoritative for send.prefix (Tron P0); no env-fallback
      # needed here. Bootstrap/respawn will pick up the correct role from the
      # shell env at next launch if/when a new Claude starts in this pane.
      debug.log "Skipping HIVEMIND_ROLE update on $pane (cmd=$cmd) — TUI"
      ;;
  esac
}

hiveMind.protected.panes.swapped() # <session> <paneA> <paneB> # B5.1 observer — emits panes.swapped event (SC-C.6)
{
  # View → Controller bridge for tmux swap-pane. SC-C.6 migration: thin emitter;
  # logic lives in `private.hiveMind.handler.panes.swapped.{registry,role_env}`.
  # Order-dependent registration: registry handler runs first, role_env reads
  # post-mutation state to push HIVEMIND_ROLE correctly (Bug #3).
  local session="$1" a="$2" b="$3"
  [ -z "$a" ] || [ -z "$b" ] && return 1
  # SC-E.2 P3 — both panes regex-validated (accept addr-only "0.0" by
  # prefixing with session, or full "sess:0.0"), pipe-safe. Session optional
  # here (may be implicit from caller context).
  local checkA="$a" checkB="$b"
  [[ "$a" != *:* ]] && [ -n "$session" ] && checkA="${session}:${a}"
  [[ "$b" != *:* ]] && [ -n "$session" ] && checkB="${session}:${b}"
  if ! this.isPaneTarget "$checkA" || ! this.isPaneTarget "$checkB"; then
    error.log "protected.panes.swapped: invalid pane target(s) '$a' / '$b' — rejected"
    return 1
  fi
  if ! this.isPipeSafe "$a" || ! this.isPipeSafe "$b"; then
    error.log "protected.panes.swapped: '|' or newline in pane args — rejected"
    return 1
  fi
  if [ -n "$session" ] && ! this.isSessionName "$session"; then
    error.log "protected.panes.swapped: invalid session '$session' — rejected"
    return 1
  fi
  private.hiveMind.events.emit "panes.swapped" "$session" "$a" "$b"
}

hiveMind.protected.pane.moved() # <fromPane> <toPane> # B5.1 observer — emits pane.moved event (SC-C.7)
{
  # View → Controller bridge for tmux move-pane / join-pane. SC-C.7 migration:
  # thin emitter; logic lives in `private.hiveMind.handler.pane.moved.{registry,role_env}`.
  local from="$1" to="$2"
  [ -z "$from" ] || [ -z "$to" ] && return 1
  # SC-E.2 P3 — both pane targets must be canonical session:win.pane or %N,
  # pipe-safe. Pane registry rename via this observer is unrecoverable if
  # corrupted (entries lost).
  if ! this.isPaneTarget "$from" || ! this.isPaneTarget "$to"; then
    error.log "protected.pane.moved: invalid pane target(s) '$from' → '$to' — rejected"
    return 1
  fi
  if ! this.isPipeSafe "$from" || ! this.isPipeSafe "$to"; then
    error.log "protected.pane.moved: '|' or newline in pane args — rejected"
    return 1
  fi
  private.hiveMind.events.emit "pane.moved" "$from" "$to"
}

hiveMind.registry.set() # <pane> <role> # set registry entry for a pane
{
  local target="$1" role="$2"
  if [ -z "$target" ] || [ -z "$role" ]; then
    error.log "Usage: hiveMind registry.set <pane> <role>"
    return 1
  fi
  private.hiveMind.registry.set "$target" "$role"
  echo "Registry: $target → $role"
}

hiveMind.registry.remove() # <pane> # remove registry entry for a pane
{
  local target="$1"
  if [ -z "$target" ]; then
    error.log "Usage: hiveMind registry.remove <pane>"
    return 1
  fi
  # SC-E.2 P3 — (a) regex, (b) pipe-safe. Existence (c) is the registry-grep
  # below (we WANT the entry to exist to remove it; tmux liveness irrelevant).
  if ! this.isPaneTarget "$target"; then
    error.log "registry.remove: invalid pane target '$target' (expected session:win.pane or %N)"
    return 1
  fi
  if ! this.isPipeSafe "$target"; then
    error.log "registry.remove: pane target '$target' contains '|' or newline — rejected"
    return 1
  fi
  local reg="$HIVEMIND_REGISTRY"
  if [ -f "$reg" ] && grep -q "^${target}|" "$reg" 2>/dev/null; then
    grep -v "^${target}|" "$reg" > "${reg}.tmp" && mv "${reg}.tmp" "$reg"
    echo "Removed $target from registry"
    # SC-C.2 — emit agent.killed. Handlers ensure sessions.env and queue file
    # are also cleaned (the direct mutation above only touched roles.env).
    private.hiveMind.events.emit "agent.killed" "$target"
  else
    error.log "No registry entry for $target"
    return 1
  fi
}

hiveMind.registry.list() # <?session> # list registry entries (live discovery + file fallback)
{
  private.hiveMind.registry.list "$1"
}

hiveMind.agent.rename() # <agentName> <newName> # rename agent: /rename + pane.lock + registry update
{
  local name="$1"
  local newName="$2"

  if [ -z "$name" ] || [ -z "$newName" ]; then
    error.log "Usage: hiveMind agent.rename <agentName> <newName>"
    return 1
  fi
  # SC-E.2 P3 — newName flows into /rename inside the agent's TUI AND into
  # the registry AND into pane.lock title. All three need clean role-name
  # format. Existing name is the lookup key (resolved below via hiveMind.resolve
  # — that's the (c) existence layer for source side). Validate target name
  # format + pipe-safety for the destination side.
  if ! this.isRoleName "$newName"; then
    error.log "agent.rename: invalid new role name '$newName' (must be [A-Za-z][A-Za-z0-9._-]{0,39})"
    return 1
  fi
  if ! this.isPipeSafe "$newName"; then
    error.log "agent.rename: new name '$newName' contains '|' or newline — rejected"
    return 1
  fi

  local session
  session="$(private.hiveMind.active.team)"
  local target
  target=$(hiveMind.resolve "$name" "$session" 2>/dev/null)
  if [ -z "$target" ]; then
    error.log "Cannot resolve '$name' to a pane target"
    return 1
  fi

  # 1. Send /rename to Claude Code TUI (Option C: same @hostname as pane title)
  otmux send.raw "$target" "/rename ${newName}@${HIVEMIND_HOST}" Enter
  sleep 1

  # 2. Lock pane title (tmux shows @hostname)
  otmux pane.lock "$target" "${newName}@${HIVEMIND_HOST}"

  # 3. Update registry
  private.hiveMind.registry.set "$target" "$newName"

  # 4. Lifecycle trigger — refresh the team's UUIDs (the /rename just produced
  # a new custom-title entry in the JSONL; discover picks it up). Non-invasive.
  hiveMind.registry.refresh "$session" >/dev/null

  # SC-C.3 — emit agent.renamed. Handlers (registry, title, role_env) replicate
  # steps 2+3 idempotently AND push HIVEMIND_ROLE to the pane shell (which
  # rename did NOT do before). Direct calls retained during Sprint 1 transition.
  private.hiveMind.events.emit "agent.renamed" "$target" "$name" "$newName"

  success.log "Renamed $name → $newName ($target)"
}
hiveMind.team.activate() # <session> # set active team (alias for team.switch)
{
  hiveMind.team.switch "$@"
}

# ─────────────────────────────────────────────────────────────────────────────
# SC-A.1 — reconcile.diff primitive (Sprint 1 state-correctness foundation)
# Single source-of-truth diff between cached state (S1-S10) and ground truth
# (L1-L3) per sprint-1-design.md §3. Pure: no writes, no side effects.
# Used by: consistency.audit (read-only), consistency.fix (apply w/ confirm),
#          consistency.reconcile (apply silently via --apply, SM-cycle caller).
# Output format (one line per mutation):
#   <severity>|<invariant>|<store>|<op>|<key>|<expected>|<actual>
# Severities: CRITICAL / HIGH / MEDIUM / LOW (per U2 graded audit lock)
# Ops: ADD / REMOVE / UPDATE
# ─────────────────────────────────────────────────────────────────────────────

private.hiveMind.reconcile.diff() # <?invariant:all> # emit mutations needed to reconcile caches to live truth
{
  # Optional <?invariant> filter to check only one of i1..i7 (unit-test hook).
  # Default 'all' runs every check in CRITICAL → HIGH → MEDIUM → LOW order
  # (stable output: severity descending, then invariant id ascending within tier).
  local only="${1:-all}"
  if [ "$only" = "all" ] || [ "$only" = "i3" ]; then private.hiveMind.reconcile.check.i3; fi
  if [ "$only" = "all" ] || [ "$only" = "i7" ]; then private.hiveMind.reconcile.check.i7; fi
  if [ "$only" = "all" ] || [ "$only" = "i1" ]; then private.hiveMind.reconcile.check.i1; fi
  if [ "$only" = "all" ] || [ "$only" = "i2" ]; then private.hiveMind.reconcile.check.i2; fi
  if [ "$only" = "all" ] || [ "$only" = "i4" ]; then private.hiveMind.reconcile.check.i4; fi
  if [ "$only" = "all" ] || [ "$only" = "i5" ]; then private.hiveMind.reconcile.check.i5; fi
  if [ "$only" = "all" ] || [ "$only" = "i6" ]; then private.hiveMind.reconcile.check.i6; fi
  if [ "$only" = "all" ] || [ "$only" = "i8" ]; then private.hiveMind.reconcile.check.i8; fi
  if [ "$only" = "all" ] || [ "$only" = "i9" ]; then private.hiveMind.reconcile.check.i9; fi
  if [ "$only" = "all" ] || [ "$only" = "i10" ]; then private.hiveMind.reconcile.check.i10; fi
  return 0
}
private.hiveMind.reconcile.diff.completion.invariant() {
  echo "all"; for i in 1 2 3 4 5 6 7 8 9 10; do echo "i$i"; done
}

private.hiveMind.reconcile.check.i1() { # # I1 (HIGH): every pane in S1 exists in L1
  # Stale role entry for a pane that's no longer in tmux.
  local reg="${HIVEMIND_REGISTRY:-${CONFIG_PATH:-$HOME/config}/hivemind.roles.env}"
  [ -f "$reg" ] || return 0
  while IFS='|' read -r pane role rest; do
    [ -z "$pane" ] || [[ "$pane" == "#"* ]] && continue
    # L1 check — does the pane still exist?
    if ! tmux list-panes -t "$pane" -F '#{pane_id}' >/dev/null 2>&1; then
      echo "HIGH|I1|S1|REMOVE|${pane}|<not-in-tmux>|${role:-}"
    fi
  done < "$reg"
}

private.hiveMind.reconcile.check.i2() { # # I2 (HIGH): every pane in S2 is in S1, UUID matches live ps
  local reg="${HIVEMIND_REGISTRY:-${CONFIG_PATH:-$HOME/config}/hivemind.roles.env}"
  local ses="${HIVEMIND_SESSIONS:-${CONFIG_PATH:-$HOME/config}/hivemind.sessions.env}"
  [ -f "$ses" ] || return 0
  while IFS='|' read -r pane cachedUuid rest; do
    [ -z "$pane" ] || [[ "$pane" == "#"* ]] && continue
    # (a) Pane must be in S1 (else S2 entry is orphan)
    if [ -f "$reg" ] && ! grep -q "^${pane}|" "$reg" 2>/dev/null; then
      echo "HIGH|I2|S2|REMOVE|${pane}|<not-in-S1>|${cachedUuid}"
      continue
    fi
    # (b) Cached UUID must match live Claude on that pane — SKIPPED for now.
    # Per-pane `claudeCode session.current` call is ~1s each (sources `this`),
    # times 40-50 sessions.env entries = 45s+ audit which times out under
    # subshell capture. TODO Sprint 1 follow-up: batch live UUID discovery
    # via ONE ps scan + JSONL correlation, replacing the per-pane call.
    # The (a) orphan check above already catches the most-common drift
    # (sessions.env entry for pane no longer in registry).
  done < "$ses"
}

private.hiveMind.reconcile.check.i3() { # # I3 (CRITICAL): every team in S3 is a live tmux session
  # The Did/you/mean garbage class. Fixed at ingress (ebc8b5e) but reconcile
  # catches anything that slipped past (e.g. legacy entries from before ebc8b5e).
  local teams="${HIVEMIND_TEAMS:-${CONFIG_PATH:-$HOME/config}/hivemind.teams.env}"
  [ -f "$teams" ] || return 0
  while IFS='|' read -r session desc; do
    [ -z "$session" ] || [[ "$session" == "#"* ]] && continue
    if ! tmux has-session -t "$session" 2>/dev/null; then
      echo "CRITICAL|I3|S3|REMOVE|${session}|<not-in-tmux>|${desc:-}"
    fi
  done < "$teams"
}

private.hiveMind.reconcile.check.i4() { # # I4 (MEDIUM): tronMonitor.env (S8) ⊂ teams.env (S3)
  local monEnv="${TRON_MONITOR_ENV:-${CONFIG_PATH:-$HOME/config}/tronMonitor.env}"
  local teams="${HIVEMIND_TEAMS:-${CONFIG_PATH:-$HOME/config}/hivemind.teams.env}"
  [ -f "$monEnv" ] || return 0
  [ -f "$teams" ] || return 0
  while IFS='|' read -r winNum session; do
    [ -z "$winNum" ] || [ -z "$session" ] && continue
    if ! grep -q "^${session}|" "$teams" 2>/dev/null; then
      echo "MEDIUM|I4|S8|REMOVE|${session}|<not-in-S3>|window-${winNum}"
    fi
  done < "$monEnv"
}

private.hiveMind.reconcile.check.i5() { # # I5 (MEDIUM): snapshot UUID JSONL exists on disk
  local snap="${HIVEMIND_SNAPSHOTS:-${CONFIG_PATH:-$HOME/config}/hivemind.snapshots.env}"
  [ -f "$snap" ] || return 0
  while IFS='|' read -r role uuid ts ctxPct; do
    [ -z "$role" ] || [[ "$role" == "#"* ]] && continue
    [ -z "$uuid" ] && continue
    # JSONL must exist somewhere under ~/.claude/projects/*/
    local found=""
    for dir in "$HOME/.claude/projects"/*/; do
      [ -f "${dir}${uuid}.jsonl" ] && found="yes" && break
    done
    if [ -z "$found" ]; then
      echo "MEDIUM|I5|S4|UPDATE|${role}|<jsonl-missing>|${uuid}"
    fi
  done < "$snap"
}

private.hiveMind.reconcile.check.i6() { # # I6 (LOW): queue files reference valid panes
  local queueDir="${CONFIG_PATH:-$HOME/config}/hivemind.queue"
  [ -d "$queueDir" ] || return 0
  local reg="${HIVEMIND_REGISTRY:-${CONFIG_PATH:-$HOME/config}/hivemind.roles.env}"
  local f base pane
  for f in "$queueDir"/*.queue; do
    [ -f "$f" ] || continue
    base=$(basename "$f" .queue)
    # Queue filenames encode pane as 'session__win__pane' or 'session:win.pane'
    # — accept either convention. Normalize __ → : / . for comparison.
    pane=$(echo "$base" | sed 's/__/:/' | sed 's/__/./')
    if [ -f "$reg" ] && ! grep -q "^${pane}|" "$reg" 2>/dev/null; then
      echo "LOW|I6|S6|REMOVE|${base}|<pane-not-in-S1>|${f}"
    fi
  done
}

hiveMind.protected.reconcile.diff() # <?invariant:all> # CLI-callable wrapper for tests/diagnostics; SC-A.2 will layer consistency.audit on top
{
  private.hiveMind.reconcile.diff "$@"
}
hiveMind.protected.reconcile.diff.completion.invariant() {
  private.hiveMind.reconcile.diff.completion.invariant
}

private.hiveMind.reconcile.check.i10() { # # I10 (HIGH): every Claude-running pane should have an S2 (sessions.env) entry
  # Symmetric coverage check for sessions.env (I8 covers roles.env). The robbinTeam
  # incident (2026-05-25): forks via direct claudeCode fork (or any path that
  # skips session.probe/store) leave sessions.env without a UUID for the pane.
  # Detector-only: pure-bash resolve.uuid often returns empty for fork children
  # (their UUID lives only in the JSONL — needs invasive /status probe). Output
  # is FLAG-only; consistency.fix should NOT auto-mutate (would require probe).
  local ses="${HIVEMIND_SESSIONS:-${CONFIG_PATH:-$HOME/config}/hivemind.sessions.env}"
  # Gather live Claude PIDs by pane (cheap, no probe)
  local procs
  procs=$(private.hiveMind.claude.processes 2>/dev/null | awk -F'|' '{print $3}')
  [ -z "$procs" ] && return 0
  while IFS= read -r pane; do
    [ -z "$pane" ] && continue
    if [ ! -f "$ses" ] || ! grep -q "^${pane}|" "$ses" 2>/dev/null; then
      # Try cheap resolve (returns empty for fork children)
      local proposed
      proposed=$(private.hiveMind.session.resolve.uuid "$pane" 2>/dev/null)
      # Reject if proposed UUID already belongs to a DIFFERENT pane (collision
      # from cached/parent UUID — would corrupt sessions.env). Force probe path.
      if [ -n "$proposed" ] && [ -f "$ses" ] && grep -q "|${proposed}$" "$ses" 2>/dev/null; then
        proposed="<probe-required>"
      fi
      [ -z "$proposed" ] && proposed="<probe-required>"
      echo "HIGH|I10|S2|ADD|${pane}|${proposed}|<not-in-S2>"
    fi
  done <<< "$procs"
}

private.hiveMind.reconcile.check.i8() { # # I8 (HIGH): every live pane should be in S1 (coverage)
  # robbinTeam pane-shift incident (2026-05-22): new panes added via splits
  # had no registry entry — resolve broken silently. This catches it.
  # Output: HIGH per pane (so consistency.fix can register each individually).
  local reg="${HIVEMIND_REGISTRY:-${CONFIG_PATH:-$HOME/config}/hivemind.roles.env}"
  local pane title
  while IFS='|' read -r pane title; do
    [ -z "$pane" ] && continue
    if [ ! -f "$reg" ] || ! grep -q "^${pane}|" "$reg" 2>/dev/null; then
      # Derive proposed role from pane title (strip @* via role.fromTitle).
      local proposed
      proposed=$(private.hiveMind.role.fromTitle "$title" 2>/dev/null)
      [ -z "$proposed" ] && proposed="<unknown>"
      echo "HIGH|I8|S1|ADD|${pane}|${proposed}|<not-in-S1>"
    fi
  done < <(tmux list-panes -aF '#{session_name}:#{window_index}.#{pane_index}|#{pane_title}' 2>/dev/null)
}

private.hiveMind.reconcile.check.i9() { # # I9 (MEDIUM): pane titles should be role@HIVEMIND_HOST (not @model)
  # CMM4 directive (2026-05-24): titles must be role@hostname. Catches Claude's
  # /rename @opus propagation when pane.lock wasn't applied (Option A leftovers).
  # Only checks panes that ARE in the registry — coverage handled by I8.
  local reg="${HIVEMIND_REGISTRY:-${CONFIG_PATH:-$HOME/config}/hivemind.roles.env}"
  [ -f "$reg" ] || return 0
  [ -z "$HIVEMIND_HOST" ] && return 0
  local pane title role expected
  while IFS='|' read -r pane title; do
    [ -z "$pane" ] && continue
    # Only check panes with a registered role
    role=$(grep "^${pane}|" "$reg" 2>/dev/null | head -1 | cut -d'|' -f2)
    [ -z "$role" ] && continue
    expected="${role}@${HIVEMIND_HOST}"
    # If title has @ but suffix doesn't match HIVEMIND_HOST, flag it
    case "$title" in
      *@*)
        if [ "$title" != "$expected" ]; then
          echo "MEDIUM|I9|V1|UPDATE|${pane}|${expected}|${title}"
        fi
        ;;
      *)
        # No @ at all — also a mismatch
        echo "MEDIUM|I9|V1|UPDATE|${pane}|${expected}|${title}"
        ;;
    esac
  done < <(tmux list-panes -aF '#{session_name}:#{window_index}.#{pane_index}|#{pane_title}' 2>/dev/null)
}

private.hiveMind.reconcile.check.i7() { # # I7 (CRITICAL): tronMonitor display matches title
  # Active check — requires screen capture, slow (~0.5s). Skip if tronMonitor
  # not running. tronMonitor.verify is the single source of truth here.
  command -v tronMonitor >/dev/null 2>&1 || return 0
  # tronMonitor.verify returns rc=1 on mismatch (added in aa7d6ac)
  local verifyOut
  verifyOut=$(tronMonitor verify 2>&1)
  local rc=$?
  if [ "$rc" -ne 0 ]; then
    # Parse "Title claims: X (MISMATCH ...)" line for context
    local claim
    claim=$(echo "$verifyOut" | grep -oE "Title claims: [^ ]+" | head -1 | sed 's/Title claims: //')
    echo "CRITICAL|I7|S8|UPDATE|tronMonitor|<screen-content>|${claim:-<unknown>}"
  fi
}

hiveMind.consistency.audit() # <?session> <?format:human|json> # graded audit (I1-I10) via reconcile.diff — pure read; exit code = violation count (U2)
{
  # SC-A.2 — Sprint 1 state-correctness audit. Layers on top of SC-A.1
  # reconcile.diff primitive. Reports human OR json. Shows ALL violations
  # (does not exit early per U2 graded). Exit code is total violation count
  # (0 = clean). PO-locked dry-run (U3) — never mutates.
  local filter_session="$1"
  local format="${2:-human}"

  # Collect diff lines, optionally filter to a session
  local diff_lines
  diff_lines=$(private.hiveMind.reconcile.diff 2>/dev/null)
  if [ -n "$filter_session" ] && [ -n "$diff_lines" ]; then
    # Filter: key field (field 5) must start with session: or equal session
    diff_lines=$(echo "$diff_lines" | awk -F'|' -v s="$filter_session" '
      $5 == s || index($5, s":") == 1 || $5 == "tronMonitor" { print }
    ')
  fi

  # Count violations by severity (used by both renderers and exit code)
  local critical=0 high=0 medium=0 low=0 total=0
  if [ -n "$diff_lines" ]; then
    critical=$(echo "$diff_lines" | grep -c '^CRITICAL|' || true)
    high=$(echo "$diff_lines" | grep -c '^HIGH|' || true)
    medium=$(echo "$diff_lines" | grep -c '^MEDIUM|' || true)
    low=$(echo "$diff_lines" | grep -c '^LOW|' || true)
    total=$((critical + high + medium + low))
  fi

  case "$format" in
    json)
      private.hiveMind.audit.render.json "$diff_lines" "$total" "$critical" "$high" "$medium" "$low"
      ;;
    human|*)
      private.hiveMind.audit.render.human "$diff_lines" "$total" "$critical" "$high" "$medium" "$low"
      ;;
  esac

  # Exit code = total violation count (per U2 — non-zero indicates degraded state)
  return "$total"
}
hiveMind.consistency.audit.completion.format() { echo "human"; echo "json"; }
hiveMind.consistency.audit.completion.session() { private.hiveMind.teams.complete; }

private.hiveMind.audit.render.human() # <diff_lines> <total> <c> <h> <m> <l> # color-grouped report
{
  local diff_lines="$1" total="$2" c="$3" h="$4" m="$5" l="$6"
  echo -e "${BOLD_CYAN}State Correctness Audit${NORMAL} ${GRAY}(SC-A.2 · sprint-1-design.md §3)${NORMAL}"
  echo -e "${GRAY}$(printf '%.0s═' {1..78})${NORMAL}"

  if [ "$total" -eq 0 ]; then
    echo -e "${BOLD_GREEN}✓ CLEAN${NORMAL} — all 10 invariants pass."
    echo -e "${GRAY}$(printf '%.0s─' {1..78})${NORMAL}"
    return 0
  fi

  # One section per severity tier, in CRITICAL → LOW order
  local tier color label
  for tier in CRITICAL:BOLD_RED HIGH:BOLD_YELLOW MEDIUM:BOLD_CYAN LOW:GRAY; do
    label="${tier%:*}"
    color="${tier#*:}"
    local lines
    lines=$(echo "$diff_lines" | grep "^${label}|" || true)
    [ -z "$lines" ] && continue
    local count
    count=$(echo "$lines" | wc -l | tr -d ' ')
    echo
    echo -e "${!color}${label}${NORMAL} ${GRAY}($count)${NORMAL}"
    echo "$lines" | while IFS='|' read -r sev inv store op key expected actual; do
      printf "  ${!color}%-3s${NORMAL}  ${BOLD_WHITE}%-4s${NORMAL}  ${BOLD_WHITE}%-6s${NORMAL}  %-32s  ${GRAY}%s${NORMAL} → ${GRAY}%s${NORMAL}\n" \
        "$op" "$inv" "$store" "$key" "$actual" "$expected"
    done
  done

  echo
  echo -e "${GRAY}$(printf '%.0s─' {1..78})${NORMAL}"
  echo -e "Summary: ${BOLD_RED}${c} crit${NORMAL}, ${BOLD_YELLOW}${h} high${NORMAL}, ${BOLD_CYAN}${m} med${NORMAL}, ${GRAY}${l} low${NORMAL} = ${BOLD_WHITE}${total} violations${NORMAL}"
  echo -e "${GRAY}Recover (dry-run by default per U3):${NORMAL}"
  echo -e "${GRAY}  hiveMind consistency.fix          — interactive (y/N prompt before each fix)${NORMAL}"
  echo -e "${GRAY}  hiveMind consistency.reconcile --apply  — silent batch (SM-cycle-style)${NORMAL}"
}

private.hiveMind.audit.render.json() # <diff_lines> <total> <c> <h> <m> <l> # JSON output for CI/dashboards
{
  local diff_lines="$1" total="$2" c="$3" h="$4" m="$5" l="$6"
  echo "{"
  echo "  \"version\": 1,"
  echo "  \"task\": \"SC-A.2\","
  echo "  \"summary\": {"
  echo "    \"total\": $total,"
  echo "    \"critical\": $c,"
  echo "    \"high\": $h,"
  echo "    \"medium\": $m,"
  echo "    \"low\": $l"
  echo "  },"
  echo -n "  \"violations\": ["
  if [ -z "$diff_lines" ] || [ "$total" -eq 0 ]; then
    echo "]"
    echo "}"
    return 0
  fi
  echo
  local first=1
  echo "$diff_lines" | while IFS='|' read -r sev inv store op key expected actual; do
    [ -z "$sev" ] && continue
    if [ "$first" -eq 1 ]; then first=0; else echo ","; fi
    # JSON string escape: backslash + double-quote
    printf '    {"severity":"%s","invariant":"%s","store":"%s","op":"%s","key":"%s","expected":"%s","actual":"%s"}' \
      "$(private.hiveMind.audit.json.escape "$sev")" \
      "$(private.hiveMind.audit.json.escape "$inv")" \
      "$(private.hiveMind.audit.json.escape "$store")" \
      "$(private.hiveMind.audit.json.escape "$op")" \
      "$(private.hiveMind.audit.json.escape "$key")" \
      "$(private.hiveMind.audit.json.escape "$expected")" \
      "$(private.hiveMind.audit.json.escape "$actual")"
  done
  echo
  echo "  ]"
  echo "}"
}

private.hiveMind.audit.json.escape() { # <str> # minimal JSON string escape (\ and ")
  echo -n "${1//\\/\\\\}" | sed 's/"/\\"/g'
}

hiveMind.consistency.audit.table() # <?session> # LEGACY per-pane table view (Sprint 0 — kept for diagnostics)
{
  local filter_session="$1"

  # Load registry file
  local reg="$HIVEMIND_REGISTRY"
  # Load sessions.env (role|uuid)
  local sess_file="$HIVEMIND_SESSIONS"

  echo -e "${BOLD_CYAN}Identity Consistency Audit (legacy table)${NORMAL}"
  echo -e "${GRAY}$(printf '%.0s═' {1..85})${NORMAL}"
  printf "${BOLD_WHITE}%-28s %-16s %-16s %-13s %-16s %s${NORMAL}\n" "PANE" "TITLE" "REGISTRY" "SESS.ENV" "LIVE UUID" "MATCH"
  echo -e "${GRAY}$(printf '%.0s─' {1..85})${NORMAL}"

  local consistent=0 inconsistent=0 noAgent=0
  # Bash 3.2 compat (task #29): delimited strings instead of assoc arrays.
  # seenUuids: `uuid|pane\n` per row. claudeProcs: `pane|pid|rest\n`.
  local seenUuids="" claudeProcs procRow
  claudeProcs=$(private.hiveMind.claude.processes | awk -F'|' '{print $3"|"$1"|"$5}')

  # Enumerate ALL panes (not just Claude-running ones)
  while IFS='|' read -r pane_target pane_title; do
    [ -z "$pane_target" ] && continue

    # Filter by session
    if [ -n "$filter_session" ] && [[ "$pane_target" != "${filter_session}:"* ]]; then
      continue
    fi

    local title="${pane_title}"
    local titleShort="${title:0:14}"
    [ ${#title} -gt 14 ] && titleShort="${titleShort}.."

    # Check if this pane has a Claude process
    local hasClaude=""
    local procRest=""
    procRow=$(echo "$claudeProcs" | grep "^${pane_target}|" | head -1)
    if [ -n "$procRow" ]; then
      hasClaude="yes"
      procRest=$(echo "$procRow" | cut -d'|' -f3-)
    fi

    # Registry role
    local regRole=""
    [ -f "$reg" ] && regRole=$(grep "^${pane_target}|" "$reg" 2>/dev/null | head -1 | cut -d'|' -f2)

    # Non-Claude pane with no registry entry — show as "no agent" (not an error)
    if [ -z "$hasClaude" ] && [ -z "$regRole" ]; then
      printf "%-28s %-16s ${GRAY}%-16s %-13s %-16s %s${NORMAL}\n" \
        "$pane_target" "$titleShort" "—" "—" "—" "no agent"
      noAgent=$((noAgent + 1))
      continue
    fi

    local regDisplay="${regRole:-${BOLD_YELLOW}MISSING${NORMAL}}"

    # Sessions.env UUID (look up by pane target)
    local sessUuid=""
    if [ -f "$sess_file" ]; then
      sessUuid=$(grep "^${pane_target}|" "$sess_file" 2>/dev/null | head -1 | cut -d'|' -f2)
    fi
    # sessShort removed — use sessUuid directly

    # Live UUID (DRY — same as consistency.fix and agents.discover)
    local liveUuid=""
    liveUuid=$(private.hiveMind.session.resolve.uuid "$pane_target" 2>/dev/null)

    # Consistency checks
    local issues=()
    [ -z "$regRole" ] && issues+=("no registry")
    if [ -n "$regRole" ] && [ -n "$title" ]; then
      echo "$title" | grep -qi "$regRole" 2>/dev/null || issues+=("title≠reg")
    fi
    if [ -n "$sessUuid" ] && [ -n "$liveUuid" ] && [ "$sessUuid" != "$liveUuid" ]; then
      issues+=("UUID stale")
    fi
    if [ -n "$liveUuid" ]; then
      # Bash 3.2 compat: grep `uuid|` prefix in seenUuids string.
      if echo "$seenUuids" | grep -q "^${liveUuid}|"; then
        issues+=("dup UUID")
      fi
      seenUuids="${seenUuids}${liveUuid}|${pane_target}
"
    fi
    if [ -n "$regRole" ]; then
      [[ ${#regRole} -gt 30 ]] && issues+=("role>30")
      [[ "$regRole" == *" "* ]] && issues+=("role has space")
      private.hiveMind.role.isGeneric "$regRole" && issues+=("generic role")
    fi
    if [ -z "$hasClaude" ] && [ -n "$regRole" ]; then
      issues+=("no process")
    fi

    local match="${BOLD_GREEN}✓${NORMAL}"
    local issueStr=""
    if [ ${#issues[@]} -gt 0 ]; then
      match="${BOLD_RED}✗${NORMAL}"
      issueStr="${BOLD_RED}$(IFS=', '; echo "${issues[*]}")${NORMAL}"
      inconsistent=$((inconsistent + 1))
    else
      consistent=$((consistent + 1))
    fi

    printf "%-28s %-16s %-16b %-13s %-16s %b %b\n" \
      "$pane_target" "$titleShort" "$regDisplay" "${sessUuid:--}" "${liveUuid:--}" "$match" "$issueStr"

  done < <(private.hiveMind.list.panes "#{session_name}:#{window_index}.#{pane_index}|#{pane_title}" "${filter_session:--a}")

  echo -e "${GRAY}$(printf '%.0s─' {1..85})${NORMAL}"
  echo -e "Summary: ${BOLD_GREEN}$consistent consistent${NORMAL}, ${BOLD_RED}$inconsistent inconsistent${NORMAL}, ${GRAY}$noAgent no agent${NORMAL}"
  return 0
}

hiveMind.consistency.fix() # <?session> # interactive y/N apply of consistency.audit findings (SC-D.1)
{
  # SC-D.1 — top-of-diff applier with interactive confirmation. Same primitive
  # as consistency.audit (read-only) and consistency.reconcile (silent batch);
  # this one shows the diff + prompts before mutating.
  local filter_session="$1"
  local diff_lines total
  diff_lines=$(private.hiveMind.reconcile.diff 2>/dev/null)
  if [ -n "$filter_session" ] && [ -n "$diff_lines" ]; then
    diff_lines=$(echo "$diff_lines" | awk -F'|' -v s="$filter_session" '
      $5 == s || index($5, s":") == 1 || $5 == "tronMonitor" { print }
    ')
  fi
  total=0
  [ -n "$diff_lines" ] && total=$(echo "$diff_lines" | grep -c '^[A-Z]' || true)

  if [ "$total" -eq 0 ]; then
    echo -e "${BOLD_GREEN}✓ CLEAN${NORMAL} — nothing to fix."
    return 0
  fi

  # Show audit-style report so user knows what's being asked
  hiveMind.consistency.audit "$filter_session" human 2>/dev/null
  echo
  echo -en "${BOLD_YELLOW}Apply $total fix(es)?${NORMAL} [y/N] "
  local reply
  read -r reply
  case "$reply" in
    y|Y|yes|YES)
      ;;
    *)
      echo -e "${GRAY}Aborted — no mutations performed.${NORMAL}"
      return 1
      ;;
  esac

  echo "$diff_lines" | private.hiveMind.reconcile.apply
  return $?
}
hiveMind.consistency.fix.completion.session() { private.hiveMind.teams.complete; }

hiveMind.consistency.reconcile() # <?session> <?mode:dry-run|apply> # silent batch reconciliation; dry-run by default (U3 lock)
{
  # SC-D.1 — cron/SM-cycle-friendly. Per U3 (PO-locked): dry-run unless 'apply'
  # is explicitly passed. Output is minimal — total violation count + brief
  # severity breakdown. Exit code = total PRE-apply violations (so SM-cycle
  # can detect change-over-time).
  local filter_session="$1"
  local mode="${2:-dry-run}"

  local diff_lines total c h m l
  diff_lines=$(private.hiveMind.reconcile.diff 2>/dev/null)
  if [ -n "$filter_session" ] && [ -n "$diff_lines" ]; then
    diff_lines=$(echo "$diff_lines" | awk -F'|' -v s="$filter_session" '
      $5 == s || index($5, s":") == 1 || $5 == "tronMonitor" { print }
    ')
  fi
  total=0; c=0; h=0; m=0; l=0
  if [ -n "$diff_lines" ]; then
    c=$(echo "$diff_lines" | grep -c '^CRITICAL|' || true)
    h=$(echo "$diff_lines" | grep -c '^HIGH|'     || true)
    m=$(echo "$diff_lines" | grep -c '^MEDIUM|'   || true)
    l=$(echo "$diff_lines" | grep -c '^LOW|'      || true)
    total=$((c + h + m + l))
  fi

  if [ "$total" -eq 0 ]; then
    echo "reconcile: clean (0 violations)"
    return 0
  fi

  case "$mode" in
    apply)
      echo "reconcile: $total violations (C=$c H=$h M=$m L=$l) — applying..."
      local applied
      applied=$(echo "$diff_lines" | private.hiveMind.reconcile.apply | tail -1)
      echo "reconcile: $applied"
      ;;
    dry-run|*)
      echo "reconcile: $total violations (C=$c H=$h M=$m L=$l) — DRY RUN (use 'reconcile <?session> apply' to mutate)"
      ;;
  esac
  return "$total"
}
hiveMind.consistency.reconcile.completion.session() { private.hiveMind.teams.complete; }
hiveMind.consistency.reconcile.completion.mode() { echo "dry-run"; echo "apply"; }

private.hiveMind.reconcile.apply() # # consume diff lines on stdin, apply each mutation; echo summary at end
{
  # Apply primitive — reads diff format from stdin:
  #   <severity>|<invariant>|<store>|<op>|<key>|<expected>|<actual>
  # Skip I5 (snapshot stale — needs re-snapshot, not safe auto-mutation)
  # Skip I7 (tronMonitor display — Tron's call, not auto-apply)
  local sev inv store op key expected actual
  local applied=0 skipped=0
  while IFS='|' read -r sev inv store op key expected actual; do
    [ -z "$sev" ] && continue
    case "$inv" in
      I5|I7)
        info.log "  SKIP $inv ($store $op $key) — not safe to auto-apply"
        skipped=$((skipped + 1))
        continue
        ;;
    esac
    case "${store}:${op}" in
      S1:REMOVE)
        # Remove stale role entry — grep -v pattern
        if [ -f "$HIVEMIND_REGISTRY" ]; then
          grep -v "^${key}|" "$HIVEMIND_REGISTRY" > "${HIVEMIND_REGISTRY}.tmp" 2>/dev/null
          mv "${HIVEMIND_REGISTRY}.tmp" "$HIVEMIND_REGISTRY"
          applied=$((applied + 1))
        fi
        ;;
      S2:REMOVE)
        if [ -f "$HIVEMIND_SESSIONS" ]; then
          grep -v "^${key}|" "$HIVEMIND_SESSIONS" > "${HIVEMIND_SESSIONS}.tmp" 2>/dev/null
          mv "${HIVEMIND_SESSIONS}.tmp" "$HIVEMIND_SESSIONS"
          applied=$((applied + 1))
        fi
        ;;
      S2:UPDATE)
        # Replace cached UUID with live (key = pane, expected = liveUuid)
        if [ -f "$HIVEMIND_SESSIONS" ]; then
          grep -v "^${key}|" "$HIVEMIND_SESSIONS" > "${HIVEMIND_SESSIONS}.tmp" 2>/dev/null
          echo "${key}|${expected}" >> "${HIVEMIND_SESSIONS}.tmp"
          mv "${HIVEMIND_SESSIONS}.tmp" "$HIVEMIND_SESSIONS"
          applied=$((applied + 1))
        fi
        ;;
      S3:REMOVE)
        if [ -f "$HIVEMIND_TEAMS" ]; then
          grep -v "^${key}|" "$HIVEMIND_TEAMS" > "${HIVEMIND_TEAMS}.tmp" 2>/dev/null
          mv "${HIVEMIND_TEAMS}.tmp" "$HIVEMIND_TEAMS"
          applied=$((applied + 1))
        fi
        ;;
      S8:REMOVE)
        # tronMonitor.env — prefer tronMonitor.remove (handles screen window
        # cleanup too) over raw file edit. Soft-fail if tronMonitor isn't on PATH.
        if command -v tronMonitor >/dev/null 2>&1; then
          tronMonitor remove "$key" >/dev/null 2>&1
          applied=$((applied + 1))
        elif [ -f "$TRON_MONITOR_ENV" ]; then
          grep -v "|${key}$" "$TRON_MONITOR_ENV" > "${TRON_MONITOR_ENV}.tmp" 2>/dev/null
          mv "${TRON_MONITOR_ENV}.tmp" "$TRON_MONITOR_ENV"
          applied=$((applied + 1))
        fi
        ;;
      S6:REMOVE)
        # Queue file — actual field is the full file path
        if [ -f "$actual" ]; then
          rm -f "$actual"
          applied=$((applied + 1))
        fi
        ;;
      S1:ADD)
        # I8 — register pane that exists in tmux but missing from S1 registry.
        # Skip if proposed role is <unknown> (no title to derive from).
        if [ "$expected" = "<unknown>" ]; then
          info.log "  SKIP I8 $key — no title to derive role from"
          skipped=$((skipped + 1))
        else
          private.hiveMind.registry.set "$key" "$expected" >/dev/null 2>&1
          applied=$((applied + 1))
        fi
        ;;
      V1:UPDATE)
        # I9 — pane title format mismatch (e.g. role@opus → role@HIVEMIND_HOST).
        # Use pane.lock so Claude can't re-overwrite.
        otmux pane.lock "$key" "$expected" 2>/dev/null
        applied=$((applied + 1))
        ;;
      S2:ADD)
        # I10 — sessions.env missing entry for live Claude pane.
        # Skip if the pure-bash resolver couldn't find a UUID (fork child needs
        # invasive /status probe — operator must run `hiveMind agent.session.probe`
        # explicitly and `private.hiveMind.session.store` the result).
        if [ "$expected" = "<probe-required>" ]; then
          info.log "  SKIP I10 $key — fork child UUID needs invasive /status probe (manual: hiveMind agent.session.probe $key)"
          skipped=$((skipped + 1))
        else
          private.hiveMind.session.store "$key" "$expected" >/dev/null 2>&1
          applied=$((applied + 1))
        fi
        ;;
      *)
        info.log "  SKIP unhandled mutation: ${store} ${op} ${key}"
        skipped=$((skipped + 1))
        ;;
    esac
  done
  echo "applied=$applied skipped=$skipped"
}

# ────────────────────────────────────────────────────────────────────────────
# LEGACY consistency.fix.table — Sprint 0 per-pane hand-rolled reconciler.
# Kept available for diagnostic use; SC-D.1 above (consistency.fix) is the
# canonical Sprint 1 implementation on top of reconcile.diff primitive.
# ────────────────────────────────────────────────────────────────────────────

hiveMind.consistency.fix.table() # <?session> # LEGACY Sprint-0 per-pane fixer (kept for diagnostics)
{
  local filter_session="$1"

  local reg="$HIVEMIND_REGISTRY"
  local sess_file="$HIVEMIND_SESSIONS"

  # Ensure files exist
  [ -f "$reg" ] || touch "$reg"
  [ -f "$sess_file" ] || touch "$sess_file"

  echo -e "${BOLD_CYAN}Fixing identity consistency (legacy)...${NORMAL}"
  echo

  # --- 0. Clean up garbage entries in sessions.env ---
  # Clean garbage: UUIDs as keys, generic roles, old role-keyed entries (pre-schema-change), empty lines
  if [ -f "$sess_file" ]; then
    local garbageCount=0
    local gc
    # UUIDs used as keys
    gc=$(grep -cE '^[0-9a-f]{8}-' "$sess_file" 2>/dev/null) && garbageCount=$((garbageCount + gc))
    sed '/^[0-9a-f]\{8\}-/d' "$sess_file" > "${sess_file}.tmp" && mv "${sess_file}.tmp" "$sess_file"
    # Generic "ClaudeCode" entries
    gc=$(grep -cE '^ClaudeCode\|' "$sess_file" 2>/dev/null) && garbageCount=$((garbageCount + gc))
    sed '/^ClaudeCode|/d' "$sess_file" > "${sess_file}.tmp" && mv "${sess_file}.tmp" "$sess_file"
    # Legacy role-keyed entries (keys without ':' are old role names, not pane targets)
    gc=$(grep -cvE '^[^|]*:[0-9]' "$sess_file" 2>/dev/null) && garbageCount=$((garbageCount + gc))
    local paneOnly
    paneOnly=$(sed -n '/^[^|]*:[0-9]/p' "$sess_file")
    echo "$paneOnly" > "$sess_file"
    # Empty lines
    sed '/^$/d' "$sess_file" > "${sess_file}.tmp" && mv "${sess_file}.tmp" "$sess_file"
    [ $garbageCount -gt 0 ] && echo -e "${BOLD_GREEN}Cleaned $garbageCount garbage/legacy entries from sessions.env${NORMAL}"
  fi

  local fixedReg=0 fixedSess=0 fixedTitle=0 skipped=0 dupResolved=0
  # Bash 3.2 compat (task #29): delimited strings instead of assoc arrays.
  local seenUuids="" roleForUuid="" claudeProcs procRow
  claudeProcs=$(private.hiveMind.claude.processes | awk -F'|' '{print $3"|"$1"|"$5}')

  # Iterate ALL panes
  while IFS='|' read -r paneTarget title; do
    [ -z "$paneTarget" ] && continue

    if [ -n "$filter_session" ] && [[ "$paneTarget" != "${filter_session}:"* ]]; then
      continue
    fi

    # Check Claude process
    local hasClaude="" procRest=""
    procRow=$(echo "$claudeProcs" | grep "^${paneTarget}|" | head -1)
    if [ -n "$procRow" ]; then
      hasClaude="yes"
      procRest=$(echo "$procRow" | cut -d'|' -f3-)
    fi

    # Non-Claude pane with no registry — skip
    local curReg=""
    [ -f "$reg" ] && curReg=$(grep "^${paneTarget}|" "$reg" 2>/dev/null | head -1 | cut -d'|' -f2)
    if [ -z "$hasClaude" ] && [ -z "$curReg" ]; then
      continue
    fi

    # --- 1. UUID via session.resolve.uuid (DRY — handles forks + autocompact) ---
    local liveUuid=""
    liveUuid=$(private.hiveMind.session.resolve.uuid "$paneTarget" 2>/dev/null)

    # --- 2. Resolve role (ground truth) ---
    local role="$curReg"
    if private.hiveMind.role.isGeneric "$role"; then
      role=$(private.hiveMind.role.fromTitle "$title" 2>/dev/null)
    fi
    if [ -z "$role" ]; then
      local discoverResult
      discoverResult=$(private.hiveMind.live.discover "$paneTarget" 2>/dev/null)
      if [ -n "$discoverResult" ]; then
        role="${discoverResult%%@*}"
      fi
    fi
    if [ -z "$role" ] || private.hiveMind.role.isGeneric "$role"; then
      printf "${BOLD_YELLOW}%-28s %-16s SKIPPED (can't determine role)${NORMAL}\n" "$paneTarget" "${title:0:14}"
      skipped=$((skipped + 1))
      continue
    fi

    local lineParts=()

    # --- 3a. Fix registry ---
    if [ -z "$curReg" ]; then
      private.hiveMind.registry.set "$paneTarget" "$role"
      lineParts+=("${BOLD_GREEN}registry.set ✓${NORMAL}")
      fixedReg=$((fixedReg + 1))
    elif [ "$curReg" != "$role" ]; then
      private.hiveMind.env.set "$reg" "$paneTarget" "$role"
      lineParts+=("${BOLD_GREEN}registry ${curReg} → ${role} ✓${NORMAL}")
      fixedReg=$((fixedReg + 1))
    else
      lineParts+=("${GREEN}registry ✓${NORMAL}")
    fi

    # --- 3b. Fix sessions.env UUID ---
    if [ -n "$liveUuid" ]; then
      # Bash 3.2 compat: grep `uuid|` prefix in seenUuids/roleForUuid strings.
      if echo "$seenUuids" | grep -q "^${liveUuid}|"; then
        local dupRole
        dupRole=$(echo "$roleForUuid" | grep "^${liveUuid}|" | head -1 | cut -d'|' -f2)
        echo -e "${BOLD_RED}⚠ DUP UUID: $dupRole and $role share ${liveUuid}${NORMAL}"
        dupResolved=$((dupResolved + 1))
      fi
      # Purge stale entries sharing this UUID (only if pane has no live process)
      local dupPanes
      dupPanes=$(grep "|${liveUuid}$" "$sess_file" 2>/dev/null | cut -d'|' -f1 | grep -v "^${paneTarget}$")
      if [ -n "$dupPanes" ]; then
        while IFS= read -r stalePane; do
          [ -z "$stalePane" ] && continue
          if echo "$claudeProcs" | grep -q "^${stalePane}|"; then
            continue  # Live process — shared session, not stale
          fi
          private.hiveMind.env.del "$sess_file" "$stalePane" "$liveUuid"
          lineParts+=("${BOLD_YELLOW}purged ${stalePane}|${liveUuid}${NORMAL}")
          dupResolved=$((dupResolved + 1))
        done <<< "$dupPanes"
      fi
      seenUuids="${seenUuids}${liveUuid}|${paneTarget}
"
      roleForUuid="${roleForUuid}${liveUuid}|${role}
"

      if private.hiveMind.env.set "$sess_file" "$paneTarget" "$liveUuid"; then
        lineParts+=("${BOLD_GREEN}sessions.env → ${liveUuid} ✓${NORMAL}")
        fixedSess=$((fixedSess + 1))
      else
        lineParts+=("${GREEN}sessions.env ✓${NORMAL}")
      fi
    else
      lineParts+=("${BOLD_YELLOW}sessions.env — (no live UUID)${NORMAL}")
    fi

    # --- 3c. Fix pane title ---
    if [ -n "$hasClaude" ] && ! echo "$title" | grep -qi "$role" 2>/dev/null; then
      otmux pane.lock "$paneTarget" "${role}@${HIVEMIND_HOST}"
      lineParts+=("${BOLD_GREEN}title → ${role}@${HIVEMIND_HOST} ✓${NORMAL}")
      fixedTitle=$((fixedTitle + 1))
    fi

    # --- 3d. Deregister dead panes ---
    if [ -z "$hasClaude" ] && [ -n "$curReg" ]; then
      private.hiveMind.env.del "$reg" "$paneTarget"
      lineParts+=("${BOLD_YELLOW}deregistered (no process)${NORMAL}")
      fixedReg=$((fixedReg + 1))
    fi

    printf "%-28s %-16s %b\n" "$paneTarget" "$role" "$(IFS='  '; echo "${lineParts[*]}")"

  done < <(private.hiveMind.list.panes "#{session_name}:#{window_index}.#{pane_index}|#{pane_title}" "${filter_session:--a}")

  # --- Stage 4: Prune broken UUIDs from sessions.env ---
  # Broken = UUID referenced in sessions.env but no JSONL exists for it anywhere
  # in ~/.claude/projects. This happens when a JSONL gets deleted or when a
  # previously-cached UUID becomes unresolvable. We drop the dead entry and
  # append a |broken marker to forks.env so the lineage history survives.
  local brokenPruned=0
  local forksFile="${HIVEMIND_FORKS:-${CONFIG_PATH:-$HOME/config}/hivemind.forks.env}"
  if [ -f "$sess_file" ]; then
    local ts
    ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
    local cleanLines=""
    while IFS='|' read -r p u; do
      [ -z "$p" ] || [ -z "$u" ] && continue
      # Honor the optional session filter
      if [ -n "$filter_session" ] && [[ "$p" != "${filter_session}:"* ]]; then
        cleanLines="${cleanLines}${p}|${u}"$'\n'
        continue
      fi
      # Does the JSONL exist anywhere?
      if find "$HOME/.claude/projects" -name "${u}.jsonl" -type f 2>/dev/null | grep -q .; then
        cleanLines="${cleanLines}${p}|${u}"$'\n'
      else
        # Broken — drop. Record in forks.env audit log.
        local roleForPane=""
        [ -f "$reg" ] && roleForPane=$(grep "^${p}|" "$reg" 2>/dev/null | head -1 | cut -d'|' -f2)
        [ ! -f "$forksFile" ] && {
          echo "# hiveMind fork lineage — append-only audit log" > "$forksFile"
          echo "# timestamp|pane|role|uuid|state|parentUuid" >> "$forksFile"
        }
        echo "${ts}|${p}|${roleForPane}|${u}|broken|" >> "$forksFile"
        echo -e "${BOLD_YELLOW}pruned broken: ${p}|${u:0:8}... (no JSONL — logged to forks.env)${NORMAL}"
        brokenPruned=$((brokenPruned + 1))
      fi
    done < "$sess_file"
    printf '%s' "$cleanLines" > "$sess_file"
  fi

  echo
  echo -e "Fixed: ${BOLD_GREEN}$fixedReg${NORMAL} registry, ${BOLD_GREEN}$fixedSess${NORMAL} sessions, ${BOLD_GREEN}$fixedTitle${NORMAL} titles, ${BOLD_RED}$dupResolved${NORMAL} duplicates, ${BOLD_YELLOW}$brokenPruned${NORMAL} broken UUIDs pruned"
  [ $skipped -gt 0 ] && echo -e "${BOLD_YELLOW}Skipped: $skipped (unknown role)${NORMAL}"
  echo "Run hiveMind consistency.audit to verify."
  return 0
}

# ─────────────────────────────────────────────────────────────────────────────
# OOSH SPECIALIST AGENTS
# ─────────────────────────────────────────────────────────────────────────────

hiveMind.team.init.oosh() # <?session> # initialize OOSH expert and tester agents in existing session
{
  local session="${1:-$(private.hiveMind.active.team)}"
  
  info.log "Initializing OOSH specialist agents in session: $session"
  
  # Check if session exists
  if ! otmux has "$session" 2>/dev/null; then
    error.log "Session $session not found. Create it first with otmux new $session"
    return 1
  fi
  
  # List existing panes
  local pane_count=$(private.hiveMind.pane.count "$session")
  info.log "Session has $pane_count panes"
  
  success.log "OOSH agents ready in session: $session"
  echo ""
  echo "Use these methods to teach and interact:"
  echo "  hiveMind role.teach <pane> <role>    # Teach agent its role"
  echo "  hiveMind send.message <name> <msg> # Send message to agent"
  echo "  hiveMind.send <name> <keys>     # Send raw keys to agent (no Enter)"
  echo "  hiveMind.task <agentId> <task>  # Assign task to agent"
}

hiveMind.team.setup() # <roles> <?session> # create N-pane session with agents from comma-separated role list
{
  local roles="$1"
  local session="${2:-$(private.hiveMind.active.team)}"

  if [ -z "$roles" ]; then
    error.log "Usage: hiveMind team.setup <role1,role2,...> <?session>"
    echo "  Example: hiveMind team.setup orchestrator,oosh-expert,oosh-tester,scrum-master projectTeam"
    return 1
  fi

  # SC-E.2 P2: session name flows to tmux + tronMonitor + env files — reject garbage.
  if ! this.isSessionName "$session" || ! this.isPipeSafe "$session"; then
    error.log "team.setup: invalid session name '$session' (must match tmux name format, no pipes)"
    return 1
  fi

  if otmux has "$session" 2>/dev/null; then
    warn.log "Session '$session' already exists"
    echo "  Kill it first:  otmux kill $session"
    echo "  Or attach to it: otmux attach $session"
    return 1
  fi

  # Parse roles into array
  IFS=',' read -ra role_array <<< "$roles"
  local count=${#role_array[@]}

  if [ "$count" -lt 1 ]; then
    error.log "At least one role required"
    return 1
  fi

  # ── Create session ──────────────────────────────────────────────────────
  info.log "Creating session: $session with $count agents"
  otmux new "$session"

  # Name window 0
  otmux window.rename "${session}:0" "$HIVEMIND_WINDOW_NAME"

  # ── Split panes ─────────────────────────────────────────────────────────
  # First pane (0.0) already exists from session creation
  local i
  for ((i=1; i<count; i++)); do
    otmux split "${session}:0.0"
    otmux tiled "${session}:0"
  done

  # ── Identify panes + start Claude Code ──────────────────────────────────
  info.log "Starting Claude Code agents..."
  for ((i=0; i<count; i++)); do
    local pane="${session}:0.${i}"
    local role="${role_array[$i]}"

    # Set pane title and export role env var
    private.hiveMind.pane.identify "$pane" "$role"

    # Start Claude Code with 1M opus (no --dangerously-skip-permissions)
    otmux send.enter "$pane" "claudeCode opus"
    sleep 2
  done

  # Wait for Claude Code to initialize
  info.log "Waiting for agents to initialize..."
  sleep 8

  # ── Rename sessions + register ──────────────────────────────────────────
  info.log "Sending /rename to each agent..."
  for ((i=0; i<count; i++)); do
    local pane="${session}:0.${i}"
    local role="${role_array[$i]}"

    # Send /rename — Option C: customTitle = @hostname (same as pane title).
    # pane.identify already pane.lock'd the title; lock survives Claude's propagation.
    otmux send.enter "$pane" "/rename ${role}@${HIVEMIND_HOST}"
    sleep 1

    # Capture session UUID
    local sid
    sid=$(hiveMind.agent.session.probe "$pane" 2>/dev/null)
    if [ -n "$sid" ]; then
      private.hiveMind.session.store "$pane" "$sid"
    else
      # SC-H.2 Gap A: probe race — Claude TUI not ready in 8s window.
      # Schedule background retries (5s/15s/30s). Idempotent.
      private.hiveMind.session.store.deferred "$pane" "$role"
    fi
  done

  # Register team
  hiveMind.team.register "$session" "Team with $count agents" 2>/dev/null

  # ── Done ────────────────────────────────────────────────────────────────
  success.log "Team created: $session ($count agents)"
  echo ""
  echo -e "${BOLD_CYAN}$session${NORMAL} — $count panes:"
  for ((i=0; i<count; i++)); do
    echo -e "  ${GRAY}0.${i}${NORMAL}  ${BOLD_WHITE}${role_array[$i]}${NORMAL}"
  done
  echo ""
  echo "Attach with: otmux attach $session"
}
# DEPRECATED — use hiveMind team.setup <roles> <session>
hiveMind.team.setup.oosh() # <?session> # create 3-pane tmux session with oosh-orchestrator, expert, and tester agents
{
  local session="${1:-$(private.hiveMind.active.team)}"

  info.log "Setting up OOSH team in session: $session"

  # Check if session already exists
  if otmux has "$session" 2>/dev/null; then
    warn.log "Session '$session' already exists"
    echo "  Kill it first:  otmux kill $session"
    echo "  Or attach to it: otmux attach $session"
    return 1
  fi

  # ── Create session and pane layout ──────────────────────────────────────
  # ┌─────────────────────────────────────────┐
  # │ Pane 0 - ORCHESTRATOR (oosh-orchestrator)│
  # ├───────────────────────┬─────────────────┤
  # │ Pane 1 - EXPERT       │ Pane 2 - TESTER │
  # │ (oosh-expert)         │ (oosh-tester)   │
  # └───────────────────────┴─────────────────┘

  info.log "Creating tmux session: $session"
  otmux new "$session" -d

  # Name window 0
  otmux window.rename "${session}:0" "$HIVEMIND_WINDOW_NAME"

  # Split pane 0 vertically → pane 0 (upper) + pane 1 (lower)
  otmux split.v -t "${session}:0.0"

  # Split pane 1 horizontally → pane 1 (lower-left) + pane 2 (lower-right)
  otmux split.h -t "${session}:0.1"

  # Set pane titles and export role env vars
  private.hiveMind.pane.identify "${session}:0.0" "oosh-orchestrator"
  private.hiveMind.pane.identify "${session}:0.1" "oosh-expert"
  private.hiveMind.pane.identify "${session}:0.2" "oosh-tester"

  # ── Start Claude Code in each pane ─────────────────────────────────────
  info.log "Starting Claude Code in all panes..."

  otmux send.enter "${session}:0.0" "claudeCode opus"
  sleep 2
  otmux send.enter "${session}:0.1" "claudeCode opus"
  sleep 2
  otmux send.enter "${session}:0.2" "claudeCode opus"

  # Wait for Claude Code to initialize
  info.log "Waiting for Claude Code agents to initialize..."
  sleep 8

  # ── Capture UUIDs for identity tracking ─────────────────────────────────
  info.log "Capturing session UUIDs..."
  local ses="$HIVEMIND_SESSIONS"
  local _panes=("${session}:0.0" "${session}:0.1" "${session}:0.2")
  for i in 0 1 2; do
    local _sid
    _sid=$(hiveMind.agent.session.probe "${_panes[$i]}" 2>/dev/null)
    if [ -n "$_sid" ]; then
      private.hiveMind.session.store "${_panes[$i]}" "$_sid"
    fi
  done

  # ── Send role prompts ──────────────────────────────────────────────────
  info.log "Sending role prompts to agents..."

  otmux send.enter "${session}:0.0" "$(private.hiveMind.get.role.prompt oosh-orchestrator)"
  sleep 1
  otmux send.enter "${session}:0.1" "$(private.hiveMind.get.role.prompt oosh-expert)"
  sleep 1
  otmux send.enter "${session}:0.2" "$(private.hiveMind.get.role.prompt oosh-tester)"

  # ── Done ───────────────────────────────────────────────────────────────
  success.log "OOSH team created in session: $session"
  echo ""
  echo "Pane layout:"
  echo "┌─────────────────────────────────────────┐"
  echo "│ Pane 0 - ORCHESTRATOR (oosh-orchestrator)│"
  echo "├───────────────────────┬─────────────────┤"
  echo "│ Pane 1 - EXPERT       │ Pane 2 - TESTER │"
  echo "│ (oosh-expert)         │ (oosh-tester)   │"
  echo "└───────────────────────┴─────────────────┘"
  echo ""
  echo "Attach with: otmux attach $session"
}


hiveMind.roles() # [filter] # list available agent roles with descriptions (from SKILL.md)
{
  local filter="$1"
  local count=0

  if [ ! -d "$HIVEMIND_AGENTS_DIR" ]; then
    error.log "Agents directory not found: $HIVEMIND_AGENTS_DIR"
    return 1
  fi

  echo "Available HiveMind Roles:"
  echo "─────────────────────────"

  for role_dir in "$HIVEMIND_AGENTS_DIR"/*/; do
    [ -d "$role_dir" ] || continue
    local role_name=$(basename "$role_dir")
    local skill_file="$role_dir/SKILL.md"
    [ -f "$skill_file" ] || continue

    # Apply filter if given
    if [ -n "$filter" ] && [[ "$role_name" != *"$filter"* ]]; then
      continue
    fi

    # Extract description from SKILL.md YAML frontmatter
    local desc=""
    desc=$(grep '^description:' "$skill_file" | head -1 | sed 's/^description: *//; s/^"//; s/"$//')

    # Truncate long descriptions
    if [ ${#desc} -gt 60 ]; then
      desc="${desc:0:57}..."
    fi

    printf "  %-25s %s\n" "$role_name" "— $desc"
    count=$((count + 1))
  done

  echo "─────────────────────────"
  echo "$count roles found"
}

# ─────────────────────────────────────────────────────────────────────────────
# AGENT ROLE MANAGEMENT
# ─────────────────────────────────────────────────────────────────────────────

hiveMind.role.list() # # list all agent roles from .claude/agents/
{
  if [ ! -d "$HIVEMIND_AGENTS_DIR" ]; then
    error.log "Agents directory not found: $HIVEMIND_AGENTS_DIR"
    return 1
  fi

  for role_dir in "$HIVEMIND_AGENTS_DIR"/*/; do
    [ -d "$role_dir" ] || continue
    local role_name=$(basename "$role_dir")
    if [ -f "$role_dir/SKILL.md" ]; then
      echo "$role_name"
    fi
  done
}

hiveMind.role.prompt() # <agentName> # output the teaching prompt for a role
{
  local role="$1"

  if [ -z "$role" ]; then
    error.log "Usage: hiveMind role.prompt <role>"
    return 1
  fi

  local prompt
  prompt=$(private.hiveMind.get.role.prompt "$role")
  if [ -z "$prompt" ]; then
    error.log "Unknown role: $role"
    return 1
  fi

  local skill_path="$HIVEMIND_AGENTS_DIR/$role/SKILL.md"
  if [ -f "$skill_path" ]; then
    echo "$prompt Also read these key files to understand OOSH: CLAUDE.md, docs/oosh-architecture.md, and $skill_path"
  else
    echo "$prompt"
  fi
}

hiveMind.role.teach() # <pane> <role> # teach an agent its specialized role via SKILL.md
{
  local pane="$1"
  local role="$2"

  if [ -z "$pane" ] || [ -z "$role" ]; then
    error.log "Usage: hiveMind role.teach <pane> <role>"
    echo "  Use 'hiveMind role.list' to see available roles"
    return 1
  fi

  local prompt
  prompt=$(private.hiveMind.get.role.prompt "$role")
  if [ -z "$prompt" ]; then
    error.log "Unknown role: $role"
    echo "  Use 'hiveMind role.list' to see available roles"
    return 1
  fi

  info.log "Teaching $role to pane $pane..."

  local skill_path="$HIVEMIND_AGENTS_DIR/$role/SKILL.md"

  local full_prompt="$prompt"
  if [ -f "$skill_path" ]; then
    full_prompt="$prompt Also read these key files to understand OOSH: CLAUDE.md, docs/oosh-architecture.md, and $skill_path"
  fi

  otmux send.enter "$pane" "$full_prompt" Enter

  success.log "Sent role instructions to pane $pane"
  echo "The agent should now learn its role as $role"
}

hiveMind.registry.fix() # # clean up invalid entries in roles registry (raw %NNN pane IDs)
{
  local registry="$HIVEMIND_REGISTRY"
  [ -f "$registry" ] || { error.log "No registry file"; return 1; }

  local tmpfile="${registry}.tmp"
  local fixed=0 dropped=0
  > "$tmpfile"

  while IFS='|' read -r pane role _ts; do
    [ -z "$pane" ] || [ -z "$role" ] && continue
    # Skip entries with raw pane IDs (%NNN format)
    if echo "$pane" | grep -qE '^%[0-9]+$'; then
      local resolved
      resolved=$(otmux pane.get "$pane" '#{session_name}:#{window_index}.#{pane_index}' 2>/dev/null)
      if [ -n "$resolved" ] && echo "$resolved" | grep -qE '^[a-zA-Z0-9_-]+:[0-9]+\.[0-9]+$'; then
        echo "${resolved}|${role}" >> "$tmpfile"
        fixed=$((fixed + 1))
        info.log "Fixed: $pane → $resolved ($role)"
      else
        dropped=$((dropped + 1))
        warn.log "Dropped: $pane|$role (pane no longer exists)"
      fi
      continue
    fi
    echo "${pane}|${role}" >> "$tmpfile"
  done < "$registry"

  mv "$tmpfile" "$registry"
  console.log "Registry fix: $fixed resolved, $dropped dropped"
}
hiveMind.registry.refresh() # <?session> # refresh roles + sessions cache using non-invasive claudeCode.session.discover (no TUI status-dialog hijack); appends to forks.env
{
  local session="${1:-$(private.hiveMind.active.team)}"
  [ -z "$session" ] && session=$(private.hiveMind.current.session)

  local reg="$HIVEMIND_REGISTRY"
  local ses="$HIVEMIND_SESSIONS"
  local forksFile="${HIVEMIND_FORKS:-${CONFIG_PATH:-$HOME/config}/hivemind.forks.env}"
  local updated=0 broken=0
  local ts
  ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')

  # Ensure forks.env exists with header
  if [ ! -f "$forksFile" ]; then
    {
      echo "# hiveMind fork lineage — append-only audit log"
      echo "# timestamp|pane|role|uuid|state|parentUuid"
    } > "$forksFile"
  fi

  # Prune entries for panes that no longer exist in tmux (both files)
  if [ -f "$reg" ]; then
    local clean=""
    while IFS='|' read -r pt rl; do
      [ -z "$pt" ] && continue
      if otmux pane.get "$pt" % >/dev/null 2>&1; then
        clean="${clean}${pt}|${rl}\n"
      else
        info.log "  Pruning dead pane from roles: $pt ($rl)"
      fi
    done < "$reg"
    printf '%b' "$clean" > "$reg"
  fi
  if [ -f "$ses" ]; then
    local cleanS=""
    while IFS='|' read -r pt sd; do
      [ -z "$pt" ] && continue
      if otmux pane.get "$pt" % >/dev/null 2>&1; then
        cleanS="${cleanS}${pt}|${sd}\n"
      else
        info.log "  Pruning dead pane from sessions: $pt (uuid ${sd:0:8}...)"
      fi
    done < "$ses"
    printf '%b' "$cleanS" > "$ses"
  fi

  local pane_list
  pane_list=$(private.hiveMind.list.panes addr "$session")

  while IFS= read -r pane_target; do
    [ -z "$pane_target" ] && continue

    # Only refresh panes with a Claude process
    claudeCode process.find "$pane_target" >/dev/null 2>&1 || continue

    # Non-invasive discover: prints "<uuid>|<state>|<customTitle>"
    local discover uuid state title
    discover=$("$OOSH_DIR/claudeCode" session.current "$pane_target" 2>/dev/null)
    # For state + title we call the helper once more — cheap (JSONL + stat cached)
    local discoverFull
    discoverFull=$(bash -c "source '$OOSH_DIR/claudeCode' >/dev/null 2>&1; private.claudeCode.session.discover '$pane_target'" 2>/dev/null)
    uuid="${discoverFull%%|*}"
    local rest="${discoverFull#*|}"
    state="${rest%%|*}"
    title="${rest#*|}"

    # Skip empty + unknown
    [ -z "$uuid" ] && continue
    [ "$state" = "unknown" ] && continue

    # Derive role from pane title (title field from discover).
    # Accept bare role names — no role@model requirement. Still reject garbage.
    local role="$title"
    [ -z "$role" ] && continue
    # Strip @model suffix if present (role@model form still valid but we store bare role)
    role="${role%%@*}"
    [[ "$role" == *" "* ]] && { warn.log "  $pane_target: rejected role with spaces: $role"; continue; }
    [[ ${#role} -gt 40 ]] && { warn.log "  $pane_target: rejected role >40 chars: ${role:0:30}..."; continue; }
    [[ "$role" =~ ^(Read|You|I\ am|Write|Edit|Help|Search|Find|Look|Check|Run|Show) ]] && { warn.log "  $pane_target: rejected prompt-like role: $role"; continue; }

    # Remove stale pane entries from both caches
    grep -v "^${pane_target}|" "$reg" > "${reg}.tmp" 2>/dev/null
    mv "${reg}.tmp" "$reg"
    grep -v "^${pane_target}|" "$ses" > "${ses}.tmp" 2>/dev/null
    mv "${ses}.tmp" "$ses"

    # Write fresh cache entries
    echo "${pane_target}|${role}" >> "$reg"
    echo "${pane_target}|${uuid}" >> "$ses"

    # Parent UUID from JSONL first line (if present) — lineage recorded in forks.env
    local parentUuid=""
    local jsonl_path
    jsonl_path=$(find "$HOME/.claude/projects" -name "${uuid}.jsonl" 2>/dev/null | head -1)
    if [ -n "$jsonl_path" ]; then
      parentUuid=$(head -1 "$jsonl_path" 2>/dev/null | grep -oE '"parentUuid":"[^"]*"' | head -1 | cut -d'"' -f4)
    fi

    # Append to forks.env — audit log of every refresh
    echo "${ts}|${pane_target}|${role}|${uuid}|${state}|${parentUuid}" >> "$forksFile"

    updated=$((updated + 1))
    [ "$state" = "broken" ] && broken=$((broken + 1))
    info.log "  $pane_target → $role [$uuid] ($state)"
  done <<< "$pane_list"

  # Detect duplicate UUIDs across roles (potential data corruption)
  if [ -f "$ses" ]; then
    local dup_uuids
    dup_uuids=$(awk -F'|' '{print $2}' "$ses" 2>/dev/null | sort | uniq -d)
    if [ -n "$dup_uuids" ]; then
      while IFS= read -r dup_sid; do
        [ -z "$dup_sid" ] && continue
        local dupPanes
        dupPanes=$(grep "|${dup_sid}" "$ses" 2>/dev/null | cut -d'|' -f1 | tr '\n' ', ')
        warn.log "DUPLICATE UUID ${dup_sid} shared by panes: ${dupPanes%, }"
      done <<< "$dup_uuids"
    fi
  fi

  success.log "Registry refreshed: $updated entries${broken:+ ($broken broken)}"
  info.log "  fork lineage audit log: $forksFile"
}
# ─────────────────────────────────────────────────────────────────────────────
# AGENT LIFECYCLE
# ─────────────────────────────────────────────────────────────────────────────

hiveMind.agent.bootstrap() # <agentName> <?session> <?pane> # full bootstrap: create pane, start bash, launch claude, teach role
{
  local role="$1"
  local session="${2:-$(private.hiveMind.active.team)}"
  local target_pane="$3"

  if [ -z "$role" ]; then
    error.log "Usage: hiveMind agent.bootstrap <role> <?session> <?pane>"
    echo "  Use 'hiveMind role.list' to see available roles"
    return 1
  fi

  # Verify role exists
  local prompt
  prompt=$(private.hiveMind.get.role.prompt "$role")
  if [ -z "$prompt" ]; then
    error.log "Unknown role: $role"
    return 1
  fi

  # Check session exists
  if ! otmux has "$session" 2>/dev/null; then
    error.log "Session $session not found"
    return 1
  fi

  info.log "Bootstrapping $role agent in session $session..."

  # Step 1: Create pane if not specified
  if [ -z "$target_pane" ]; then
    # Split from the last pane in window 0
    otmux split.h -t "${session}:0"
    otmux tiled "${session}:0"
    # Get the new pane index
    local pane_count=$(otmux panes -t "${session}:0" -F "#{pane_index}" | sort -n | tail -1)
    target_pane="${session}:0.${pane_count}"
    info.log "Created new pane: $target_pane"
  fi

  # Step 2: Set pane title and export role env var
  private.hiveMind.pane.identify "$target_pane" "$role"

  # Step 3: Start Claude Code
  info.log "Starting Claude Code in pane $target_pane..."
  "otmux" send.enter "$target_pane" "claudeCode new"

  # Step 4: Wait for startup
  info.log "Waiting for Claude Code to initialize..."
  sleep 8

  # Step 4b: Capture UUID and write to sessions file
  local new_sid
  new_sid=$(hiveMind.agent.session.probe "$target_pane" 2>/dev/null)
  if [ -n "$new_sid" ]; then
    private.hiveMind.session.store "$target_pane" "$new_sid"
    info.log "Captured session UUID for $role ($target_pane): ${new_sid}"
  else
    # SC-H.2 Gap A: probe race fallback. Event handler also schedules on bash 5;
    # this covers the bash 3.2 path where events.emit is no-op. Pidfile-guarded
    # so double-schedule is harmless.
    [ -z "$HIVEMIND_EVENTS_AVAILABLE" ] && \
      private.hiveMind.session.store.deferred "$target_pane" "$role"
  fi

  # Step 4c: Set session name via /rename — Option C: customTitle = @hostname.
  info.log "Setting session name to ${role}@${HIVEMIND_HOST}..."
  otmux send.enter "$target_pane" "/rename ${role}@${HIVEMIND_HOST}"
  sleep 2
  # pane.lock (not pane.title): pins title against Claude's /rename propagation.
  otmux pane.lock "$target_pane" "${role}@${HIVEMIND_HOST}"

  # Step 5: Teach role
  local full_prompt
  full_prompt=$(hiveMind.role.prompt "$role")
  otmux send.enter "$target_pane" "$full_prompt"

  # Step 6: Wait briefly for processing
  sleep 3

  # Step 7: Lifecycle trigger — refresh the team's UUIDs so caches + forks.env
  # reflect this new agent immediately. Non-invasive.
  hiveMind.registry.refresh "$session" >/dev/null

  # SC-C.1 — emit agent.spawned. Handlers (registry, sessions) replicate the
  # direct calls above; harmless idempotent doubling during Sprint 1 transition.
  private.hiveMind.events.emit "agent.spawned" "$target_pane" "$role" "${new_sid:-}"

  success.log "Agent $role bootstrapped in pane $target_pane"
  echo "Verify with: hiveMind agent.verify $target_pane"
}

hiveMind.agent.verify() # <pane> # check if agent is alive and processing
{
  local pane="$1"

  if [ -z "$pane" ]; then
    error.log "Usage: hiveMind agent.verify <pane>"
    return 1
  fi

  info.log "Verifying agent in pane $pane..."

  local output
  output=$(otmux pane.capture "$pane" 15 2>/dev/null)

  if [ $? -ne 0 ]; then
    error.log "Cannot capture pane: $pane"
    return 1
  fi

  # Check for processing indicators
  if echo "$output" | grep -qiE "Composing|Musing|Thinking|Cascading|Incubating|Frosting|Reading.*files"; then
    success.log "Agent in $pane is PROCESSING"
    echo "$output" | tail -5
    return 0
  fi

  # Check for idle Claude prompt
  if echo "$output" | grep -qE "^>|❯"; then
    info.log "Agent in $pane is IDLE (ready for input)"
    return 0
  fi

  # Check for Claude Code running
  if echo "$output" | grep -qi "claude"; then
    info.log "Agent in $pane appears ACTIVE"
    echo "$output" | tail -5
    return 0
  fi

  warn.log "Agent in $pane status UNKNOWN"
  echo "$output" | tail -5
  return 1
}

hiveMind.agent.context.status() # <agentName> <?session> # show context % for one agent by role name
{
  local agent="$1"
  local session="${2:-$(private.hiveMind.active.team)}"

  if [ -z "$agent" ]; then
    echo "Usage: hiveMind agent.context.status <agent-name> [session]"
    return 1
  fi

  # Find the agent in the registry
  local target=""
  while IFS='|' read -r t r _ts; do
    [ -z "$t" ] || [ -z "$r" ] && continue
    case "$t" in "${session}:"*) ;; *) continue ;; esac
    if [ "$r" = "$agent" ]; then
      target="$t"
      break
    fi
  done < <(private.hiveMind.registry.list "$session")

  if [ -z "$target" ]; then
    echo "Agent '$agent' not found in $session"
    return 1
  fi

  local pane_short="${target#${session}:}"
  local self_pane
  self_pane=$("otmux" pane.get.target 2>/dev/null)

  # Self detection
  if [ "$target" = "$self_pane" ]; then
    printf "%-20s %-8s %-6s %-12s %s\n" "$agent" "$pane_short" "—" "—" "SELF (run from another pane)"
    return 0
  fi

  # Tron pane — never touch
  if [ "$pane_short" = "0.4" ]; then
    printf "%-20s %-8s %-6s %-12s %s\n" "$agent" "$pane_short" "—" "—" "TRON-SKIP"
    return 0
  fi

  # Capture pane
  local content
  content=$("otmux" pane.capture "$target" 20 2>/dev/null)
  if [ $? -ne 0 ] || [ -z "$content" ]; then
    printf "%-20s %-8s %-6s %-12s %s\n" "$agent" "$pane_short" "—" "—" "NO-PANE"
    return 1
  fi

  # Check if Claude is running
  if ! echo "$content" | grep -qE '❯|tool use|tokens|Composing|Musing|Thinking|Cascading|Incubating|Frosting|Kneading|Ideating|Seasoning|Noodling|Transmuting|Fluttering|Cerebrating|Orbiting|Running|Reading|Allow.*Deny|esc to interrupt'; then
    printf "%-20s %-8s %-6s %-12s %s\n" "$agent" "$pane_short" "—" "—" "NO-CLAUDE"
    return 1
  fi

  # Detect state
  local state="unknown"
  local bottom
  bottom=$(echo "$content" | tail -10)
  if echo "$bottom" | grep -qE '^[[:space:]]*❯|^[[:space:]]*>[[:space:]]*$'; then
    state="idle"
  elif echo "$content" | grep -q 'Allow' && echo "$content" | grep -q 'Deny'; then
    state="permission"
  elif echo "$content" | grep -qiE 'Composing|Musing|Thinking|Cascading|Incubating|Frosting|Kneading|Ideating|Seasoning|Noodling|Transmuting|Fluttering|Cerebrating|Orbiting|Running|Reading'; then
    state="active"
  fi

  # Only send /context to idle agents
  if [ "$state" != "idle" ]; then
    printf "%-20s %-8s %-6s %-12s %s\n" "$agent" "$pane_short" "—" "—" "BUSY:$state"
    return 0
  fi

  # Send /context, wait, capture result
  # Double-Enter: first accepts autocomplete dropdown, second submits
  "otmux" send.enter "$target" "/context"
  sleep 0.3
  "otmux" send.raw "$target" Enter
  sleep 5

  local ctx_output
  # Full scrollback — use large line count for complete /context output
  ctx_output=$(otmux pane.capture "$target" 32767 2>/dev/null)

  # Parse token line
  local token_line pct tokens
  token_line=$(echo "$ctx_output" | sed 's/\x1b\[[0-9;]*m//g' | sed 's/\x1b\[[0-9;]*[A-Za-z]//g' | tr '\n' ' ' | grep -oE '[0-9]+k/[0-9]+k tokens \([0-9]+%\)' | tail -1)

  if [ -n "$token_line" ]; then
    pct=$(echo "$token_line" | grep -oE '\([0-9]+%\)' | grep -oE '[0-9]+')
    tokens=$(echo "$token_line" | grep -oE '[0-9]+k/[0-9]+k')
  else
    local clean fallback_line
    clean=$(echo "$ctx_output" | sed 's/\x1b\[[0-9;]*m//g' | sed 's/\x1b\[[0-9;]*[A-Za-z]//g')
    fallback_line=$(echo "$clean" | grep -iE 'token|context' | grep -E '[0-9]+%' | head -1)
    pct=$(echo "$fallback_line" | grep -oE '[0-9]+%' | grep -oE '[0-9]+' | head -1)
    if echo "$fallback_line" | grep -qi 'remaining'; then
      local pct_is_remaining="yes"
    fi
    tokens="—"
  fi

  if [ -z "$pct" ]; then
    printf "%-20s %-8s %-6s %-12s %s\n" "$agent" "$pane_short" "?" "parse-fail" "UNKNOWN"
    return 1
  fi

  local remaining
  if [ "${pct_is_remaining:-}" = "yes" ]; then
    remaining=$pct
  else
    remaining=$((100 - pct))
  fi

  local threshold="OK"
  if [ "$remaining" -lt 25 ]; then
    threshold="DANGER"
  elif [ "$remaining" -lt 35 ]; then
    threshold="CRITICAL"
  elif [ "$remaining" -lt 50 ]; then
    threshold="WARN"
  fi

  printf "%-20s %-8s %-6s %-12s %s\n" "$agent" "$pane_short" "${remaining}%" "$tokens" "$threshold"
}

hiveMind.agent.restart.remote() # <agentName> <sshHost> <?pane> # restart agent on remote machine by copying session JSONL
{
  local role="$1"
  local host="$2"
  local remotePane="$3"

  if [ -z "$role" ] || [ -z "$host" ]; then
    error.log "Usage: hiveMind agent.restart.remote <role> <host> <?pane>"
    return 1
  fi

  # SC-E.2 P3: role + sshHost defense. role becomes registry lookup key;
  # sshHost flows to ssh "$host" (command-injection vector).
  if ! this.isRoleName "$role"; then
    error.log "agent.restart.remote: invalid role '$role' (alphanumeric + dot/dash/underscore, max 40)"
    return 1
  fi
  if ! this.isSshHost "$host"; then
    error.log "agent.restart.remote: invalid sshHost '$host' (alphanumeric/dot/dash/underscore, max 64)"
    return 1
  fi
  if [ -n "$remotePane" ] && ! this.isPaneTarget "$remotePane"; then
    error.log "agent.restart.remote: invalid pane '$remotePane' (expected session:window.pane or %N)"
    return 1
  fi

  # 1. Resolve role → pane → UUID (search ALL sessions, not just active team)
  local localPane
  localPane=$(private.hiveMind.registry.find "$role" 2>/dev/null)
  local uuid=""
  if [ -n "$localPane" ]; then
    uuid=$(private.hiveMind.session.lookup "$localPane")
    [ -z "$uuid" ] && uuid=$("claudeCode" session.id "$localPane" 2>/dev/null)
  fi
  if [ -z "$uuid" ]; then
    error.log "Could not resolve UUID for role '$role' in any session"
    return 1
  fi
  info.log "UUID: $uuid"

  # 2. Find JSONL file
  local jsonlFile=""
  local claudeProjectsDir="$HOME/.claude/projects"
  for dir in "$claudeProjectsDir"/*/; do
    if [ -f "${dir}${uuid}.jsonl" ]; then
      jsonlFile="${dir}${uuid}.jsonl"
      break
    fi
  done
  if [ -z "$jsonlFile" ]; then
    error.log "JSONL not found for UUID $uuid"
    return 1
  fi
  info.log "JSONL: $jsonlFile"

  # 3. SCP to remote host (same path)
  info.log "Copying JSONL to $host..."
  if ! "ossh" scp "$jsonlFile" "${host}:${jsonlFile}" 2>/dev/null; then
    error.log "scp failed — check SSH config for host '$host'"
    return 1
  fi
  success.log "JSONL copied to $host"

  # 4. Resolve remote pane (if not given, try all sessions — remote may have same layout)
  if [ -z "$remotePane" ]; then
    remotePane=$(private.hiveMind.registry.find "$role" 2>/dev/null)
    if [ -z "$remotePane" ]; then
      error.log "Could not resolve pane for '$role' — provide pane explicitly"
      return 1
    fi
    info.log "Remote pane (from local registry): $remotePane"
  fi

  # 5. Determine project directory
  # Use CLAUDE_PROJECT_DIR if set, otherwise derive from JSONL path hash
  local projectDir="${CLAUDE_PROJECT_DIR:-}"
  if [ -z "$projectDir" ]; then
    local projectHash
    projectHash=$(basename "$(dirname "$jsonlFile")")
    # Hash format: -Users-Shared-... → /Users/Shared/... (only works for paths without hyphens)
    projectDir=$(echo "$projectHash" | sed 's/^-/\//' | sed 's/-/\//g')
  fi
  info.log "Project dir: $projectDir"

  # 6. Send cd + fork to remote pane via ossh exec (must target REMOTE tmux, not local)
  "ossh" exec "$host" "otmux send '$remotePane' \"cd '$projectDir'\" Enter"
  sleep 0.5
  "ossh" exec "$host" "otmux send '$remotePane' \"claudeCode fork $uuid\" Enter"
  success.log "Sent fork command to $remotePane on $host"

  # 7. Wait and verify on remote
  sleep 5
  local capture
  capture=$("ossh" exec "$host" "otmux pane.capture '$remotePane' 10" 2>/dev/null)
  if echo "$capture" | grep -qE '❯|tool use|tokens|esc to interrupt|Composing|Thinking'; then
    success.log "Agent '$role' resumed on $host ($remotePane)"
  else
    warn.log "Could not confirm Claude UI on $remotePane — check manually"
  fi
}
hiveMind.team.context.status() # <?session> # show context % for all registered agents in a team
{
  local session="${1:-$(private.hiveMind.active.team)}"
  local self_pane
  self_pane=$("otmux" pane.get.target 2>/dev/null)

  echo "Agent Context Status — $session"
  echo "──────────────────────────────────────────"
  printf "%-20s %-8s %-6s %-12s %s\n" "AGENT" "PANE" "CTX%" "TOKENS" "STATUS"
  echo "──────────────────────────────────────────"

  local alerts=""
  local target role pane_title

  # Enumerate panes with tab-separated fields for reliable parsing
  while IFS=$'\t' read -r target pane_title _rest; do
    [ -z "$target" ] && continue

    # Look up role from registry; fall back to pane title or "(unregistered)"
    role=$(private.hiveMind.registry.get "$target")
    [ -z "$role" ] && role="${pane_title:-(unregistered)}"

    local pane_short="${target#${session}:}"

    # Self detection (42 principle — cannot measure yourself)
    if [ "$target" = "$self_pane" ]; then
      printf "%-20s %-8s %-6s %-12s %s\n" "$role" "$pane_short" "—" "—" "SELF (run from another pane)"
      continue
    fi

    # Tron pane (0.4) — NEVER send anything. User's interface.
    if [ "$pane_short" = "0.4" ]; then
      printf "%-20s %-8s %-6s %-12s %s\n" "$role" "$pane_short" "—" "—" "TRON-SKIP"
      continue
    fi

    # Capture pane to detect state
    local content
    content=$("otmux" pane.capture "$target" 20 2>/dev/null)
    if [ $? -ne 0 ] || [ -z "$content" ]; then
      printf "%-20s %-8s %-6s %-12s %s\n" "$role" "$pane_short" "—" "—" "NO-PANE"
      continue
    fi

    # Check if Claude is running — look for Claude TUI indicators
    if ! echo "$content" | grep -qE '❯|tool use|tokens|Composing|Musing|Thinking|Cascading|Incubating|Frosting|Kneading|Ideating|Seasoning|Noodling|Transmuting|Fluttering|Cerebrating|Orbiting|Running|Reading|Allow.*Deny|esc to interrupt'; then
      printf "%-20s %-8s %-6s %-12s %s\n" "$role" "$pane_short" "—" "—" "NO-CLAUDE"
      continue
    fi

    # Detect state: idle = prompt visible, busy = anything else
    # TUI status bar sits BELOW the ❯ prompt, so scan last 10 lines not just last line
    local state="unknown"
    local bottom
    bottom=$(echo "$content" | tail -10)

    if echo "$bottom" | grep -qE '^[[:space:]]*❯|^[[:space:]]*>[[:space:]]*$'; then
      state="idle"
    elif echo "$content" | grep -q 'Allow' && echo "$content" | grep -q 'Deny'; then
      state="permission"
    elif echo "$content" | grep -qiE 'Composing|Musing|Thinking|Cascading|Incubating|Frosting|Kneading|Ideating|Seasoning|Noodling|Transmuting|Fluttering|Cerebrating|Orbiting|Running|Reading'; then
      state="active"
    fi

    # Only send /context to idle agents — never disrupt busy ones
    if [ "$state" != "idle" ]; then
      printf "%-20s %-8s %-6s %-12s %s\n" "$role" "$pane_short" "—" "—" "BUSY:$state"
      [ "$state" = "permission" ] && alerts="${alerts}  ${role}: blocked on permission\n"
      continue
    fi

    # Send /context, wait, capture result
    # Slash commands trigger TUI autocomplete dropdown. First Enter accepts
    # the autocomplete selection, second Enter submits. Double-Enter is safe.
    "otmux" send.enter "$target" "/context"
    sleep 0.3
    "otmux" send.raw "$target" Enter
    sleep 5

    local ctx_output
    # Full scrollback capture — token line is at TOP of /context output,
    # ~400+ lines above bottom. Use large line count for full capture.
    ctx_output=$(otmux pane.capture "$target" 32767 2>/dev/null)

    # Parse token line: "claude-opus-4-6 · 79k/200k tokens (40%)"
    # Use tail -1 to get the MOST RECENT /context result in scrollback
    local token_line pct tokens
    # Join lines to handle narrow pane wrapping, strip ANSI, then match
    token_line=$(echo "$ctx_output" | sed 's/\x1b\[[0-9;]*m//g' | sed 's/\x1b\[[0-9;]*[A-Za-z]//g' | tr '\n' ' ' | grep -oE '[0-9]+k/[0-9]+k tokens \([0-9]+%\)' | tail -1)

    if [ -n "$token_line" ]; then
      pct=$(echo "$token_line" | grep -oE '\([0-9]+%\)' | grep -oE '[0-9]+')
      tokens=$(echo "$token_line" | grep -oE '[0-9]+k/[0-9]+k')
    else
      # Fallback: look for any percentage near "tokens" or "context"
      local clean fallback_line
      clean=$(echo "$ctx_output" | sed 's/\x1b\[[0-9;]*m//g' | sed 's/\x1b\[[0-9;]*[A-Za-z]//g')
      fallback_line=$(echo "$clean" | grep -iE 'token|context' | grep -E '[0-9]+%' | head -1)
      pct=$(echo "$fallback_line" | grep -oE '[0-9]+%' | grep -oE '[0-9]+' | head -1)
      # If "remaining" keyword found, pct IS the remaining — flag to skip inversion later
      if echo "$fallback_line" | grep -qi 'remaining'; then
        local pct_is_remaining="yes"
      fi
      tokens="—"
    fi

    if [ -z "$pct" ]; then
      printf "%-20s %-8s %-6s %-12s %s\n" "$role" "$pane_short" "?" "parse-fail" "UNKNOWN"
      alerts="${alerts}  ${role}: context parse failed\n"
      continue
    fi

    # Calculate remaining % (TUI shows usage%, we want remaining)
    # /context shows usage: "79k/200k tokens (40%)" means 40% used, 60% remaining
    # If fallback detected "remaining" keyword, pct is already the remaining value
    local remaining
    if [ "${pct_is_remaining:-}" = "yes" ]; then
      remaining=$pct
    else
      remaining=$((100 - pct))
    fi

    # Threshold assessment
    local threshold="OK"
    if [ "$remaining" -lt 25 ]; then
      threshold="DANGER"
      alerts="${alerts}  ${role}: ${remaining}% remaining — COMPACT NOW\n"
    elif [ "$remaining" -lt 35 ]; then
      threshold="CRITICAL"
      alerts="${alerts}  ${role}: ${remaining}% remaining — prepare compact\n"
    elif [ "$remaining" -lt 50 ]; then
      threshold="WARN"
    fi

    printf "%-20s %-8s %-6s %-12s %s\n" "$role" "$pane_short" "${remaining}%" "$tokens" "$threshold"
  done < <(otmux panes -t "$session" -s -F "#{session_name}:#{window_index}.#{pane_index}	#{pane_title}	#{pane_current_command}" 2>/dev/null)

  echo "──────────────────────────────────────────"

  if [ -n "$alerts" ]; then
    echo "Alerts:"
    printf '%b' "$alerts"
  else
    echo "No alerts."
  fi
}
# ─────────────────────────────────────────────────────────────────────────────
# TEAM SETUP
# ─────────────────────────────────────────────────────────────────────────────

hiveMind.team.setup.full() # <?session> # bootstrap full team: Agent Teacher + Expert + Tester + ScrumMaster
{
  local session="${1:-$(private.hiveMind.active.team)}"

  # SC-E.2 P2: session name flows to tmux + tronMonitor + env files — reject garbage.
  if ! this.isSessionName "$session" || ! this.isPipeSafe "$session"; then
    error.log "team.setup.full: invalid session name '$session' (must match tmux name format, no pipes)"
    return 1
  fi

  info.log "Setting up full OOSH team in session: $session"

  # Check if session already exists
  if otmux has "$session" 2>/dev/null; then
    warn.log "Session '$session' already exists"
    echo "  Kill it first:  otmux kill $session"
    echo "  Or attach to it: otmux attach $session"
    return 1
  fi

  # ── Create session and pane layout ──────────────────────────────────────
  # ┌─────────────────────────────────────────┐
  # │ Pane 0.0 - AGENT TEACHER                │
  # ├───────────────────────┬─────────────────┤
  # │ Pane 0.1 - EXPERT     │ Pane 0.2 - TEST │
  # │ (oosh-expert)         │ (oosh-tester)   │
  # ├───────────────────────┴─────────────────┤
  # │ Pane 0.3 - SCRUMMASTER                  │
  # └─────────────────────────────────────────┘

  info.log "Creating tmux session: $session"
  otmux new "$session" -d

  # Name window 0
  otmux window.rename "${session}:0" "$HIVEMIND_WINDOW_NAME"

  # Split pane 0 vertically → pane 0 (upper) + pane 1 (lower)
  otmux split.v -t "${session}:0.0"

  # Split pane 1 horizontally → pane 1 (lower-left) + pane 2 (lower-right)
  otmux split.h -t "${session}:0.1"

  # Split pane 0 vertically again → add pane 3 below for ScrumMaster
  otmux split.v -t "${session}:0.0"

  # Set pane titles and export role env vars
  private.hiveMind.pane.identify "${session}:0.0" "orchestrator"
  private.hiveMind.pane.identify "${session}:0.1" "scrum-master"
  private.hiveMind.pane.identify "${session}:0.2" "oosh-expert"
  private.hiveMind.pane.identify "${session}:0.3" "oosh-tester"

  # ── Start Claude Code in each pane ─────────────────────────────────────
  info.log "Starting Claude Code in all panes..."

  otmux send.enter "${session}:0.0" "claudeCode opus"
  sleep 2
  otmux send.enter "${session}:0.1" "claudeCode opus"
  sleep 2
  otmux send.enter "${session}:0.2" "claudeCode opus"
  sleep 2
  otmux send.enter "${session}:0.3" "claudeCode opus"

  # Wait for Claude Code to initialize
  info.log "Waiting for Claude Code agents to initialize..."
  sleep 8

  # ── Capture UUIDs for identity tracking ─────────────────────────────────
  info.log "Capturing session UUIDs..."
  local ses="$HIVEMIND_SESSIONS"
  local _panes=("${session}:0.0" "${session}:0.1" "${session}:0.2" "${session}:0.3")
  local _roles=("orchestrator" "scrum-master" "oosh-expert" "oosh-tester")
  for i in 0 1 2 3; do
    local _sid
    _sid=$(hiveMind.agent.session.probe "${_panes[$i]}" 2>/dev/null)
    if [ -n "$_sid" ]; then
      private.hiveMind.session.store "${_panes[$i]}" "$_sid"
    else
      # SC-H.2 Gap A: probe race — schedule background retries
      private.hiveMind.session.store.deferred "${_panes[$i]}" "${_roles[$i]}"
    fi
  done

  # ── Send role prompts ──────────────────────────────────────────────────
  info.log "Sending role prompts to agents..."

  otmux send.enter "${session}:0.0" "$(private.hiveMind.get.role.prompt orchestrator)"
  sleep 1
  otmux send.enter "${session}:0.1" "$(private.hiveMind.get.role.prompt scrum-master)"
  sleep 1
  otmux send.enter "${session}:0.2" "$(private.hiveMind.get.role.prompt oosh-expert)"
  sleep 1
  otmux send.enter "${session}:0.3" "$(private.hiveMind.get.role.prompt oosh-tester)"

  # ── Done ───────────────────────────────────────────────────────────────
  success.log "Full OOSH team created in session: $session"
  echo ""
  echo "Pane layout:"
  echo "┌─────────────────────────────────────────┐"
  echo "│ Pane 0.0 - AGENT TEACHER                │"
  echo "├───────────────────────┬─────────────────┤"
  echo "│ Pane 0.2 - EXPERT     │ Pane 0.3 - TEST │"
  echo "│ (oosh-expert)         │ (oosh-tester)   │"
  echo "├───────────────────────┴─────────────────┤"
  echo "│ Pane 0.1 - SCRUMMASTER                  │"
  echo "└─────────────────────────────────────────┘"
  echo ""
  echo "Attach with: otmux attach $session"
}

hiveMind.team.register() # <session> <?description> # register a team session in the team registry
{
  local session="$1"
  shift
  local description="${*:-}"

  if [ -z "$session" ]; then
    error.log "Usage: hiveMind team.register <session> <?description>"
    return 1
  fi

  # Bug #4-class hardening — reject invalid session names. Without this, callers
  # that forward unvalidated output (error text, word-split arrays, snapshot
  # corruption) write garbage entries like 'Did', 'you', 'mean:' from an upstream
  # "Did you mean: <suggestion>" error message. Same defense as registry.set
  # for role names. Allowed chars match tmux session-name conventions
  # ([A-Za-z0-9_][A-Za-z0-9_.-]*) — alphanumeric, underscore, dot, dash,
  # must start with alphanumeric or underscore.
  if ! [[ "$session" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]]; then
    error.log "team.register: invalid session name '$session' (must match [A-Za-z0-9_][A-Za-z0-9_.-]*)"
    return 1
  fi

  # Belt-and-braces: also reject names containing the pipe delimiter (would
  # break env-file parsing) and names matching the test-prefix pattern.
  if [[ "$session" == *"|"* ]]; then
    error.log "team.register: session name '$session' contains '|' delimiter — rejected"
    return 1
  fi

  # Existence check — the strongest defense. A "team" by definition is a live
  # tmux session. Names that pass regex but aren't real sessions (e.g. 'Did',
  # 'you' from a "Did you mean: ..." error tokenized by word-split) are
  # rejected here. Caller can override for known-pending sessions by setting
  # HIVEMIND_TEAM_REGISTER_SKIP_TMUX_CHECK=1 (used by tests).
  if [ "${HIVEMIND_TEAM_REGISTER_SKIP_TMUX_CHECK:-0}" != "1" ]; then
    if ! otmux has "$session" 2>/dev/null; then
      error.log "team.register: '$session' is not a live tmux session — rejected (export HIVEMIND_TEAM_REGISTER_SKIP_TMUX_CHECK=1 to bypass)"
      return 1
    fi
  fi

  private.hiveMind.teams.ensure.dir

  # Check if already registered
  if [ -f "$HIVEMIND_TEAMS" ] && grep -q "^${session}|" "$HIVEMIND_TEAMS" 2>/dev/null; then
    # Update description if provided
    if [ -n "$description" ]; then
      grep -v "^${session}|" "$HIVEMIND_TEAMS" > "${HIVEMIND_TEAMS}.tmp" 2>/dev/null
      mv "${HIVEMIND_TEAMS}.tmp" "$HIVEMIND_TEAMS"
      echo "${session}|${description}" >> "$HIVEMIND_TEAMS"
      info.log "Updated team: $session — $description"
    else
      info.log "Team $session already registered"
    fi
    return 0
  fi

  echo "${session}|${description}" >> "$HIVEMIND_TEAMS"
  success.log "Registered team: $session"

  # Auto-switch to first registered team if none active
  if [ ! -f "$HIVEMIND_ACTIVE_TEAM_FILE" ] || [ ! -s "$HIVEMIND_ACTIVE_TEAM_FILE" ]; then
    echo "$session" > "$HIVEMIND_ACTIVE_TEAM_FILE"
    info.log "Auto-switched active team to: $session"
  fi

  # SC-C.8 — emit team.created. Handlers fan out to teams.env (idempotent
  # write-through audit) and tronMonitor.add. Replaces the prior direct
  # `tronMonitor add` call (D2.1) which is now a handler.
  private.hiveMind.events.emit "team.created" "$session" "$description"
}
hiveMind.team.remove() # <session> # unregister a team session
{
  local session="$1"

  if [ -z "$session" ]; then
    error.log "Usage: hiveMind team.remove <session>"
    return 1
  fi
  # SC-E.2 P3 triple defense — (a) regex, (b) pipe-safe. Existence (c) is
  # the registry-grep check below — for removal we want session-in-registry,
  # not session-in-tmux (a team can be removed AFTER its tmux session died).
  if ! this.isSessionName "$session"; then
    error.log "team.remove: invalid session name '$session' (must match [A-Za-z0-9_][A-Za-z0-9_.-]*)"
    return 1
  fi
  if ! this.isPipeSafe "$session"; then
    error.log "team.remove: session name '$session' contains '|' or newline — rejected"
    return 1
  fi

  if [ ! -f "$HIVEMIND_TEAMS" ]; then
    error.log "No team registry found"
    return 1
  fi

  if ! grep -q "^${session}|" "$HIVEMIND_TEAMS" 2>/dev/null; then
    error.log "Team $session not registered"
    return 1
  fi

  # SC-C.9 — emit team.destroyed. Handlers fan out: teams.env row removal,
  # tronMonitor.remove, plus full S1/S2/S6 cleanup (pane entries in roles.env,
  # sessions.env, and queue files belonging to this session). The team.remove
  # caller also writes to teams.env below for the legacy synchronous path,
  # which the teams handler treats as idempotent.
  private.hiveMind.events.emit "team.destroyed" "$session"

  grep -v "^${session}|" "$HIVEMIND_TEAMS" > "${HIVEMIND_TEAMS}.tmp" 2>/dev/null
  mv "${HIVEMIND_TEAMS}.tmp" "$HIVEMIND_TEAMS"

  # SC-H.2 Gap B — direct prune fallback for bash 3.2 (where events.emit is a
  # no-op per task #29 gate, so the team.destroyed handler chain never runs).
  # Idempotent: handlers already pruned these on bash 5; grep -v is a no-op
  # there. Without this fallback, macOS-default-bash callers left S1/S2 orphans.
  if [ -z "$HIVEMIND_EVENTS_AVAILABLE" ]; then
    local reg="${HIVEMIND_REGISTRY:-${CONFIG_PATH:-$HOME/config}/hivemind.roles.env}"
    local ses="${HIVEMIND_SESSIONS:-${CONFIG_PATH:-$HOME/config}/hivemind.sessions.env}"
    if [ -f "$reg" ]; then
      grep -v "^${session}:" "$reg" > "${reg}.tmp" 2>/dev/null
      mv "${reg}.tmp" "$reg"
    fi
    if [ -f "$ses" ]; then
      grep -v "^${session}:" "$ses" > "${ses}.tmp" 2>/dev/null
      mv "${ses}.tmp" "$ses"
    fi
  fi

  success.log "Removed team: $session"

  # Clear active team if it was the removed one
  local active
  active=$(private.hiveMind.active.team)
  if [ "$active" = "$session" ]; then
    rm -f "$HIVEMIND_ACTIVE_TEAM_FILE"
    info.log "Active team cleared (was $session)"
  fi
}
hiveMind.team.switch() # <session> # set the active team context
{
  local session="$1"

  if [ -z "$session" ]; then
    error.log "Usage: hiveMind team.switch <session>"
    echo "  Registered teams:"
    hiveMind.team.list 2>/dev/null | sed 's/^/    /'
    return 1
  fi

  # SC-E.2 P2: session name is written to active-team file — reject garbage
  # before it pollutes downstream readers.
  if ! this.isSessionName "$session" || ! this.isPipeSafe "$session"; then
    error.log "team.switch: invalid session name '$session' (must match tmux name format, no pipes)"
    return 1
  fi

  # Verify session exists in tmux or in team registry
  if ! otmux has "$session" 2>/dev/null; then
    if [ -f "$HIVEMIND_TEAMS" ] && grep -q "^${session}|" "$HIVEMIND_TEAMS" 2>/dev/null; then
      warn.log "Team $session is registered but tmux session is not running"
    else
      warn.log "Session $session not found in tmux or team registry"
    fi
  fi

  # Auto-register if not already in team registry
  if [ ! -f "$HIVEMIND_TEAMS" ] || ! grep -q "^${session}|" "$HIVEMIND_TEAMS" 2>/dev/null; then
    hiveMind.team.register "$session"
  fi

  private.hiveMind.teams.ensure.dir
  echo "$session" > "$HIVEMIND_ACTIVE_TEAM_FILE"
  private.hiveMind.monitor.switch "${session}:0.0"
  success.log "Active team: $session"
}
hiveMind.team.active() # # show the current active team
{
  local active
  active=$(private.hiveMind.active.team)
  echo "$active"
}

hiveMind.team.list() # # list all registered team sessions
{
  # Prefer team registry if it exists
  if [ -f "$HIVEMIND_TEAMS" ] && [ -s "$HIVEMIND_TEAMS" ]; then
    local active
    active=$(private.hiveMind.active.team)
    while IFS='|' read -r session description; do
      [ -z "$session" ] && continue
      local marker=" "
      [ "$session" = "$active" ] && marker="*"
      local running=""
      otmux has "$session" 2>/dev/null && running="running" || running="stopped"
      if [ -n "$description" ]; then
        printf " %s %-24s %-10s %s\n" "$marker" "$session" "($running)" "$description"
      else
        printf " %s %-24s %s\n" "$marker" "$session" "($running)"
      fi
    done < "$HIVEMIND_TEAMS"
    return 0
  fi

  # Fall back to deriving teams from role registry — verify sessions exist in tmux
  if [ -f "$HIVEMIND_REGISTRY" ]; then
    local _sess
    for _sess in $(cut -d: -f1 "$HIVEMIND_REGISTRY" | sort -u); do
      otmux has "$_sess" 2>/dev/null && echo "$_sess"
    done
    return 0
  fi

  error.log "No team or role registry found"
  return 1
}

# ─────────────────────────────────────────────────────────────────────────────
# AGENT SNAPSHOTS — golden fork-source per role for re-forking trained agents
# Storage: $HIVEMIND_SNAPSHOTS (role|uuid|timestamp|ctxRemainingPct)
# See session/tasks/agent-persistence-and-forking.md
# ─────────────────────────────────────────────────────────────────────────────

private.hiveMind.snapshots.ensure.dir() {
  mkdir -p "$(dirname "$HIVEMIND_SNAPSHOTS")" 2>/dev/null
}

private.hiveMind.snapshot.get() { # <role> # echo "uuid|timestamp|ctxPct" or empty
  local role="$1"
  [ -z "$role" ] && return 1
  [ -f "$HIVEMIND_SNAPSHOTS" ] || return 1
  grep "^${role}|" "$HIVEMIND_SNAPSHOTS" 2>/dev/null | tail -1 | cut -d'|' -f2-
}

private.hiveMind.snapshot.set() { # <role> <uuid> <ctxPct> # upsert snapshot
  local role="$1" uuid="$2" ctx="${3:-unknown}"
  [ -z "$role" ] || [ -z "$uuid" ] && { error.log "snapshot.set: missing role or uuid"; return 1; }
  private.hiveMind.snapshots.ensure.dir
  local ts
  ts=$(date +%Y-%m-%dT%H:%M:%S)
  if [ -f "$HIVEMIND_SNAPSHOTS" ]; then
    grep -v "^${role}|" "$HIVEMIND_SNAPSHOTS" > "${HIVEMIND_SNAPSHOTS}.tmp" 2>/dev/null
    mv "${HIVEMIND_SNAPSHOTS}.tmp" "$HIVEMIND_SNAPSHOTS"
  fi
  echo "${role}|${uuid}|${ts}|${ctx}" >> "$HIVEMIND_SNAPSHOTS"
}

private.hiveMind.snapshot.jsonl.path() { # <uuid> # echo local JSONL path or empty
  local uuid="$1"
  [ -z "$uuid" ] && return 1
  local d
  for d in "$HOME/.claude/projects"/*/; do
    [ -f "${d}${uuid}.jsonl" ] && echo "${d}${uuid}.jsonl" && return 0
  done
  return 1
}

hiveMind.agent.snapshot() # <agentName> # register current session UUID as role snapshot
{
  local name="$1"
  [ -z "$name" ] && { error.log "Usage: hiveMind agent.snapshot <agentName>"; return 1; }

  local target
  target=$(hiveMind.resolve "$name" 2>/dev/null)
  [ -z "$target" ] && { error.log "Cannot resolve agent '$name' to a pane"; return 1; }

  local role
  role=$(private.hiveMind.registry.get "$target" 2>/dev/null)
  [ -z "$role" ] && { error.log "No role registered for pane $target"; return 1; }

  local uuid
  uuid=$(private.hiveMind.session.resolve.uuid "$target" 2>/dev/null)
  [ -z "$uuid" ] && { error.log "Cannot resolve UUID for pane $target"; return 1; }

  local jsonl
  jsonl=$(private.hiveMind.snapshot.jsonl.path "$uuid" 2>/dev/null)
  local ctx="unknown"
  if [ -n "$jsonl" ] && [ -f "$jsonl" ]; then
    ctx=$("$OOSH_DIR/claudeCode" context.read "$target" 2>/dev/null || echo "unknown")
    # Fallback: compute from JSONL directly
    if [ "$ctx" = "unknown" ] && type -t private.claudeCode.context.from.jsonl &>/dev/null; then
      ctx=$(private.claudeCode.context.from.jsonl "$jsonl" 2>/dev/null || echo "unknown")
    fi
  fi

  private.hiveMind.snapshot.set "$role" "$uuid" "$ctx"
  success.log "Snapshot: ${role} → ${uuid:0:8}... (ctx ${ctx}% remaining)"
}
hiveMind.agent.snapshot.completion.agentName() {
  private.hiveMind.roles.complete 2>/dev/null
}

hiveMind.snapshot.list() # # list all registered agent snapshots with valid/stale flag
{
  if [ ! -f "$HIVEMIND_SNAPSHOTS" ] || [ ! -s "$HIVEMIND_SNAPSHOTS" ]; then
    echo "No snapshots registered"
    return 0
  fi

  echo ""
  printf "${BOLD_WHITE}%-20s %-38s %-20s %-10s %s${NORMAL}\n" "ROLE" "UUID" "TIMESTAMP" "CTX%" "STATUS"
  echo "─────────────────────────────────────────────────────────────────────────────────────────────"

  local role uuid ts ctx jsonl color status
  while IFS='|' read -r role uuid ts ctx; do
    [ -z "$role" ] && continue
    jsonl=$(private.hiveMind.snapshot.jsonl.path "$uuid" 2>/dev/null)
    if [ -n "$jsonl" ]; then
      color="${BOLD_GREEN}"
      status="valid"
    else
      color="${BOLD_RED}"
      status="stale"
    fi
    printf "${color}%-20s %-38s %-20s %-10s %s${NORMAL}\n" \
      "$role" "$uuid" "$ts" "${ctx:-?}" "$status"
  done < "$HIVEMIND_SNAPSHOTS"
  echo ""
}

hiveMind.agent.respawn() # <agentName> # fork role snapshot into pane + /rename + re-register
{
  local name="$1"
  [ -z "$name" ] && { error.log "Usage: hiveMind agent.respawn <agentName>"; return 1; }

  local target
  target=$(hiveMind.resolve "$name" 2>/dev/null)
  [ -z "$target" ] && { error.log "Cannot resolve agent '$name' to a pane"; return 1; }

  local role
  role=$(private.hiveMind.registry.get "$target" 2>/dev/null)
  [ -z "$role" ] && role="$name"  # name might already be the role

  local snapLine
  snapLine=$(private.hiveMind.snapshot.get "$role" 2>/dev/null)
  [ -z "$snapLine" ] && { error.log "No snapshot registered for role '$role'. Run: hiveMind agent.snapshot $role"; return 1; }

  local uuid
  uuid=$(echo "$snapLine" | cut -d'|' -f1)
  [ -z "$uuid" ] && { error.log "Corrupt snapshot entry for role '$role'"; return 1; }

  # Validate JSONL still on disk
  local jsonl
  jsonl=$(private.hiveMind.snapshot.jsonl.path "$uuid" 2>/dev/null)
  [ -z "$jsonl" ] && { error.log "Snapshot JSONL missing for role '$role' (uuid ${uuid:0:8}... stale). Re-snapshot a live agent."; return 1; }

  # Safety: refuse if Claude still running in target pane
  if "$OOSH_DIR/claudeCode" process.running "$target" 2>/dev/null; then
    error.log "Claude already running in $target — send /exit first or kill the pane before respawn"
    return 1
  fi

  info.log "Respawning ${role} in ${target} from snapshot ${uuid:0:8}..."
  otmux send.enter "$target" "claudeCode fork $uuid"

  # Give Claude a few seconds to start the forked session
  sleep 5

  # Write-through: capture the child UUID into sessions.env
  local newUuid
  newUuid=$(private.hiveMind.session.resolve.uuid "$target" 2>/dev/null)
  [ -n "$newUuid" ] && info.log "Child UUID: ${newUuid:0:8}..."

  # /rename inside Claude TUI — Option C: customTitle = @hostname (same as pane).
  otmux send.raw "$target" "/rename ${role}@${HIVEMIND_HOST}" Enter 2>/dev/null
  sleep 1
  # pane.lock (not pane.title): pins against Claude's /rename propagation.
  otmux pane.lock "$target" "${role}@${HIVEMIND_HOST}"

  # Reaffirm pane↔role registration
  private.hiveMind.registry.set "$target" "$role"

  # Lifecycle trigger — the fork just produced a new child UUID + /rename wrote
  # a new customTitle. Refresh so caches + forks.env record the new lineage.
  local sessionOfPane="${target%%:*}"
  hiveMind.registry.refresh "$sessionOfPane" >/dev/null

  # SC-C.4 — emit agent.forked. Handlers update registry, sessions, forks.env.
  # The registry.refresh above already wrote forks.env once for live discovery;
  # the .forks handler appends a second row with explicit parent linkage (the
  # `uuid` here is the parent — refresh only sees the child via JSONL parentUuid).
  private.hiveMind.events.emit "agent.forked" "$target" "$role" "$uuid" "${newUuid:-}"

  success.log "Respawned ${role} in ${target} (fork of ${uuid:0:8}... → ${newUuid:0:8}...)"
}
hiveMind.agent.respawn.completion.agentName() {
  private.hiveMind.roles.complete 2>/dev/null
}

hiveMind.team.status() # <?session> # show team summary (no arg) or per-pane details (with session)
{
  local session="$1"

  # No argument: show one-line summary per team
  if [ -z "$session" ]; then
    local teams
    teams=$(hiveMind.team.list 2>/dev/null)
    [ -z "$teams" ] && { error.log "No teams registered"; return 1; }

    while read -r team; do
      if ! otmux has "$team" 2>/dev/null; then
        local offline_count
        offline_count=$(hiveMind.protected.agents.offline "$team" 2>/dev/null | grep -c '|' 2>/dev/null)
        printf "${BOLD_CYAN}%-24s${NORMAL} ${GRAY}(offline — %s agents, tmux session gone)${NORMAL}\n" "$team" "${offline_count:-0}"
        continue
      fi
      local agent_count=0 active_count=0 idle_count=0 blocked_count=0
      while IFS='|' read -r pane role state uuid title is_claude; do
        [ "$is_claude" = "yes" ] || continue
        agent_count=$((agent_count + 1))
        case "$state" in
          idle|queued|unknown|shell|shell-escaped|crash|subscription-limit) idle_count=$((idle_count + 1)) ;;
          permission|rate-limit|accept-edits|panel|overlay|autocomplete|tool-confirm|mcp-error|api-error|context-warning) blocked_count=$((blocked_count + 1)) ;;
          *) active_count=$((active_count + 1)) ;;
        esac
      done < <(hiveMind.protected.agents.discover "$team")
      local summary="${active_count} active, ${idle_count} idle"
      [ "$blocked_count" -gt 0 ] && summary="${summary}, ${blocked_count} blocked"
      local blocked_color=""
      [ "$blocked_count" -gt 0 ] && blocked_color="${BOLD_RED}"
      printf "${BOLD_CYAN}%-24s${NORMAL} ${BOLD_WHITE}%d agents${NORMAL}  (${BOLD_GREEN}%s${NORMAL}${blocked_color}%s${NORMAL})\n" \
        "$team" "$agent_count" "${active_count} active, ${idle_count} idle" \
        "$([ "$blocked_count" -gt 0 ] && echo ", ${blocked_count} blocked")"
    done <<< "$teams"
    return 0
  fi

  # With argument: show detailed per-pane tree using shared discovery
  local data offline_mode="no"
  if otmux has "$session" 2>/dev/null; then
    data=$(hiveMind.protected.agents.discover "$session")
  else
    data=$(hiveMind.protected.agents.offline "$session")
    if [ -z "$data" ]; then
      error.log "Session $session not found (no live tmux session and no persisted agents)"
      return 1
    fi
    offline_mode="yes"
  fi
  local total
  total=$(echo "$data" | grep -c '|' 2>/dev/null || echo 0)
  local count=0

  if [ "$offline_mode" = "yes" ]; then
    echo -e "${BOLD_CYAN}${session}${NORMAL} ${BOLD_RED}(offline — tmux session not running)${NORMAL}"
  else
    echo -e "${BOLD_CYAN}${session}${NORMAL}"
  fi
  while IFS='|' read -r pane_target role state uuid title is_claude; do
    [ -z "$pane_target" ] && continue
    count=$((count + 1))

    local addr="${pane_target#*:}"
    local label="${role:-${title:-pane $addr}}"

    local prefix="├──"
    [ "$count" -eq "$total" ] && prefix="└──"

    local sid_label=""
    [ -n "$uuid" ] && sid_label="  [$uuid]"

    # Map state to display vocabulary + color
    local display_state="$state" state_color="${NORMAL}"
    case "$state" in
      active)                          display_state="active";       state_color="${BOLD_GREEN}" ;;
      idle|queued)                     display_state="idle";          state_color="${GRAY}" ;;
      accept-edits)                    display_state="accept-edits";  state_color="${BOLD_CYAN}" ;;
      permission|tool-confirm|panel|overlay|autocomplete)
                                       display_state="stuck-prompt"; state_color="${BOLD_RED}" ;;
      context-warning)                 display_state="context-limit"; state_color="${BOLD_YELLOW}" ;;
      just-compacted)                  display_state="compacting";    state_color="${BOLD_YELLOW}" ;;
      shell|shell-escaped|crash)       display_state="offline";       state_color="${BOLD_RED}" ;;
      rate-limit|api-error|mcp-error)  display_state="stuck-prompt"; state_color="${BOLD_RED}" ;;
      subscription-limit)              display_state="offline";       state_color="${BOLD_RED}" ;;
    esac

    echo -e "${GRAY}${prefix}${NORMAL} ${GRAY}${addr}${NORMAL}  ${BOLD_WHITE}${label}${NORMAL} ${state_color}(${display_state})${NORMAL}${GRAY}${sid_label}${NORMAL}"
  done <<< "$data"
}

hiveMind.team.sweep() # <session> <?interval> # structured one-line-per-pane status of all registered agents (optional sleep before sweep)
{
  local session="$1"
  local interval="$2"

  if [ -z "$session" ]; then
    error.log "Usage: hiveMind team.sweep <session> <?interval>"
    return 1
  fi

  # Sleep first if interval provided (avoids compound commands that trigger permission prompts)
  if [ -n "$interval" ] && [ "$interval" -gt 0 ] 2>/dev/null; then
    sleep "$interval"
  fi

  if [ ! -f "$HIVEMIND_REGISTRY" ]; then
    error.log "No role registry found at $HIVEMIND_REGISTRY"
    return 1
  fi

  if ! otmux has "$session" 2>/dev/null; then
    error.log "Session $session not found"
    return 1
  fi

  if ! grep -q "^${session}:" "$HIVEMIND_REGISTRY" 2>/dev/null; then
    error.log "No agents registered for session $session"
    return 1
  fi

  # Identify own pane to skip self
  local own_pane=""
  [ -n "$TMUX_PANE" ] && own_pane="$TMUX_PANE"

  while IFS='|' read -r target role _ts; do
    [ -z "$target" ] || [ -z "$role" ] && continue
    case "$target" in "${session}:"*) ;; *) continue ;; esac

    local addr="${target#*:}"

    # Skip self
    if [ -n "$own_pane" ]; then
      local pane_id
      pane_id=$(otmux pane.get "$target" % 2>/dev/null)
      [ "$pane_id" = "$own_pane" ] && continue
    fi

    # Capture last 15 lines
    local content
    content=$(otmux pane.capture "$target" 15 2>/dev/null)

    # Determine state and details using sweep.detect + verb enrichment
    local state="IDLE"
    local details=""

    # Check for empty/unreachable pane
    local trimmed
    trimmed=$(echo "$content" | tr -d '[:space:]')
    if [ -z "$trimmed" ]; then
      state="UNKNOWN"
      printf "%-5s %-22s %s\n" "$addr" "$role" "$state"
      continue
    fi

    # Use sweep.detect for structured detection (handles all blocker types)
    local detect_raw detect_status detect_severity detect_detail
    detect_raw=$(private.hiveMind.sweep.detect "$target" 2>/dev/null)
    IFS='|' read -r detect_status _ detect_severity detect_detail <<< "$detect_raw"

    case "$detect_status" in
      permission)
        state="PERMISSION"
        details=$(echo "$content" | grep -oE '"[^"]*"' | tail -1)
        ;;
      tool-confirm)
        state="TOOL_CONFIRM"
        details=$(echo "$content" | grep -oE '"[^"]*"' | tail -1)
        ;;
      rate-limit)
        state="RATE_LIMIT"
        [ -n "$detect_detail" ] && details="$detect_detail"
        ;;
      subscription-limit)
        state="SUB_LIMIT !!"
        ;;
      accept-edits)
        state="ACCEPT_EDITS"
        [ -n "$detect_detail" ] && [ "$detect_detail" != "0" ] && details="${detect_detail} pending"
        ;;
      context-warning)
        state="CONTEXT_LOW"
        [ -n "$detect_detail" ] && details="$detect_detail"
        ;;
      just-compacted)
        state="COMPACTED"
        ;;
      overlay)
        state="OVERLAY"
        ;;
      panel)
        state="PANEL"
        ;;
      autocomplete)
        state="AUTOCOMPLETE"
        ;;
      mcp-error)
        state="MCP_ERROR"
        ;;
      api-error)
        state="API_ERROR"
        [ -n "$detect_detail" ] && details="$detect_detail"
        ;;
      crash)
        state="CRASH !!"
        ;;
      shell-escaped)
        state="SHELL"
        ;;
      queued)
        state="INPUT"
        local last_line
        last_line=$(echo "$content" | sed '/^[[:space:]]*$/d' | tail -1)
        details=$(echo "$last_line" | sed -E 's/^[[:space:]]*(>|❯)[[:space:]]+//')
        [ ${#details} -gt 40 ] && details="${details:0:37}..."
        details="\"$details\""
        ;;
      idle)
        state="IDLE"
        ;;
      active)
        # Enrich with verb detection for active agents
        if echo "$content" | grep -qiE 'Composing|Musing|Thinking|Cascading|Incubating|Frosting|Misting|Brewing|Baking|Cooking|Simmering|Distilling|Weaving|Crafting|Processing|Reading|Writing|Editing|Searching|Running'; then
          state="ACTIVE"
          details=$(echo "$content" | grep -oiE 'Composing|Musing|Thinking|Cascading|Incubating|Frosting|Misting|Brewing|Baking|Cooking|Simmering|Distilling|Weaving|Crafting|Processing|Reading|Writing|Editing|Searching|Running' | tail -1)
          local elapsed
          elapsed=$(echo "$content" | grep -oE '\([0-9]+[ms]\)' | tail -1)
          [ -n "$elapsed" ] && details="$details $elapsed"
        elif echo "$content" | grep -qiE 'Baked|Brewed|Cooked|Simmered|Distilled|Woven|Crafted|Composed|Frosted|Incubated|Cascaded'; then
          state="COMPLETED"
        else
          state="ACTIVE"
        fi
        ;;
      *)
        state="${detect_status:-UNKNOWN}"
        ;;
    esac

    if [ -n "$details" ]; then
      printf "%-5s %-22s %-14s %s\n" "$addr" "$role" "$state" "$details"
    else
      printf "%-5s %-22s %s\n" "$addr" "$role" "$state"
    fi
  done < "$HIVEMIND_REGISTRY"
}
hiveMind.team.loop() # <session> <?interval:30> # continuous team.sweep at interval
{
  local session="$1"
  local interval="${2:-30}"

  if [ -z "$session" ]; then
    error.log "Usage: hiveMind team.loop <session> <?interval>"
    return 1
  fi

  console.log "Team loop: $session every ${interval}s (Ctrl+C to stop)"

  while true; do
    echo ""
    echo "── team.sweep @ $(date '+%H:%M:%S') ──────────────────────────────"
    hiveMind.team.sweep "$session"
    sleep "$interval"
  done
}
hiveMind.agent.monitor() # <agentName> <?session> <?lines:30> # capture pane output for agent by name; searches all teams when no session given
{
  local name="$1"
  # $2 type-dispatched: digits → lines, else → session
  local session="" lines=30
  if [[ "$2" =~ ^[0-9]+$ ]]; then
    lines="$2"
  else
    session="$2"
    [[ "$3" =~ ^[0-9]+$ ]] && lines="$3"
  fi

  if [ -z "$name" ]; then
    error.log "Usage: hiveMind agent.monitor <name> <?session> <?lines>"
    return 1
  fi

  local target
  target=$(hiveMind.resolve "$name" "$session")
  [ -z "$target" ] && return 1
  private.hiveMind.monitor.switch "$target"

  echo -e "${GRAY}───────────────────────────────────────────────────────────${NORMAL}"
  echo -e " ${BOLD_CYAN}$name${NORMAL} ${GRAY}($target) — last $lines lines${NORMAL}"
  echo -e "${GRAY}───────────────────────────────────────────────────────────${NORMAL}"
  otmux pane.capture "$target" "$lines"
  echo ""
}
hiveMind.agent.monitor.completion.agentName() { hiveMind.resolve.completion.agentName; }
hiveMind.agent.monitor.completion.session() { private.hiveMind.teams.complete; }
hiveMind.team.monitor() # <?session> <?agentName> <?lines:30> # capture pane output for agents in session (optionally filtered by agent)
{
  local session="${1:-$(private.hiveMind.active.team)}"
  # $2 may be a line count (all digits) OR an agent-name filter
  local filterAgent=""
  local lines="30"
  if [ -n "$2" ]; then
    if [[ "$2" =~ ^[0-9]+$ ]]; then
      lines="$2"
    else
      filterAgent="$2"
      [ -n "$3" ] && lines="$3"
    fi
  fi

  if ! otmux has "$session" 2>/dev/null; then
    error.log "Session '$session' not found"
    return 1
  fi

  # If agent filter given, resolve once and constrain to that single pane
  local filterPaneIdx=""
  if [ -n "$filterAgent" ]; then
    local filterTarget
    filterTarget=$(hiveMind.resolve "$filterAgent" "$session" 2>/dev/null)
    local filterRc=$?
    # error.log writes to stdout, so also validate the format before trusting
    if [ "$filterRc" -ne 0 ] || ! [[ "$filterTarget" =~ ^[A-Za-z0-9_.-]+:[0-9]+\.[0-9]+$ ]]; then
      error.log "Agent '$filterAgent' not found in session '$session'"
      return 1
    fi
    filterPaneIdx="${filterTarget##*.}"
  fi

  local own_pane=""
  [ -n "$TMUX_PANE" ] && own_pane="$TMUX_PANE"

  echo -e "${GRAY}═══════════════════════════════════════════════════════════${NORMAL}"
  if [ -n "$filterAgent" ]; then
    echo -e " ${BOLD_CYAN}team.monitor${NORMAL} — ${BOLD_WHITE}$session${NORMAL} ${GRAY}/ $filterAgent (last $lines lines)${NORMAL}"
  else
    echo -e " ${BOLD_CYAN}team.monitor${NORMAL} — ${BOLD_WHITE}$session${NORMAL} ${GRAY}(last $lines lines)${NORMAL}"
  fi
  echo -e "${GRAY}═══════════════════════════════════════════════════════════${NORMAL}"

  otmux panes -t "$session" -F "#{pane_id}|#{pane_index}|#{pane_title}" 2>/dev/null | \
    while IFS='|' read -r pane_id pane_idx pane_title; do
      [ -n "$own_pane" ] && [ "$pane_id" = "$own_pane" ] && continue
      [ -n "$filterPaneIdx" ] && [ "$pane_idx" != "$filterPaneIdx" ] && continue
      local label="${pane_title:-pane $pane_idx}"
      echo -e "${GRAY}───────────────────────────────────────────────────────────${NORMAL}"
      echo -e " ${GRAY}[$pane_idx]${NORMAL} ${BOLD_WHITE}$label${NORMAL}"
      echo -e "${GRAY}───────────────────────────────────────────────────────────${NORMAL}"
      otmux pane.capture "${session}:0.${pane_idx}" "$lines"
      echo ""
    done
}
hiveMind.team.monitor.completion.session() {
  private.hiveMind.teams.complete 2>/dev/null
}
hiveMind.team.monitor.completion.agentName() {
  private.hiveMind.roles.complete 2>/dev/null
}
private.hiveMind.agent.overlay.key() # <overlayState> <verb> <?option> # map (state, verb) → tmux key sequence
{
  # Epic I I1.3 per-overlay key map (PO decision 5):
  #   permission   / tool-confirm:  approve=1   reject=2   option=N    dismiss=Escape
  #   accept-edits:                 approve=Tab reject=Esc option=N    dismiss=Escape
  #   overlay      / panel:         dismiss=Esc only (others meaningless)
  local state="$1"
  local verb="$2"
  local option="${3:-}"
  case "$verb" in
    dismiss)
      echo "Escape"
      return 0
      ;;
    option)
      [ -z "$option" ] && return 1
      echo "$option"
      return 0
      ;;
    approve)
      case "$state" in
        permission|tool-confirm) echo "1"; return 0 ;;
        accept-edits)             echo "Tab"; return 0 ;;
      esac
      ;;
    reject)
      case "$state" in
        permission|tool-confirm) echo "2"; return 0 ;;
        accept-edits)             echo "Escape"; return 0 ;;
      esac
      ;;
  esac
  return 1
}

private.hiveMind.agent.overlay.dispatch() # <agent|pane> <verb> <?option> # detect overlay state, send key, return outcome
{
  # Common entry for the 4 REMOTE CONTROL verbs. Validates that the agent is
  # actually in an overlay state, looks up the key per state, sends via raw.
  local name="$1"
  local verb="$2"
  local option="${3:-}"

  if [ -z "$name" ]; then
    error.log "Usage: hiveMind agent.${verb} <agent|pane> ${option:+<option>}"
    return 1
  fi

  # Resolve to pane (accepts both agent names and direct pane targets)
  local target resolveRc
  if [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_.-]*:[0-9]+\.[0-9]+$ ]]; then
    target="$name"
  else
    target=$(hiveMind.resolve "$name" 2>/dev/null)
    resolveRc=$?
    if [ $resolveRc -ne 0 ] || [ -z "$target" ]; then
      error.log "agent.${verb}: cannot resolve '$name'"
      create.result 1 "no-pane: $name"
      return 1
      fi
    if ! [[ "$target" =~ ^[A-Za-z_][A-Za-z0-9_.-]*:[0-9]+\.[0-9]+$ ]]; then
      error.log "agent.${verb}: malformed target '$target'"
      create.result 1 "no-pane: $name"
      return 1
    fi
  fi

  # Detect overlay state — only act if agent is in one
  local detect status
  detect=$(private.hiveMind.sweep.detect "$target" 2>/dev/null)
  status="${detect%%|*}"

  case "$status" in
    permission|tool-confirm|accept-edits|overlay|panel)
      ;;  # ok — proceed
    *)
      error.log "agent.${verb}: $name is not in an overlay state (current=$status) — refusing to send key"
      create.result 1 "rejected: not-in-overlay (state=$status)"
      return 1
      ;;
  esac

  # Look up key per (state, verb)
  local key
  key=$(private.hiveMind.agent.overlay.key "$status" "$verb" "$option")
  if [ -z "$key" ]; then
    error.log "agent.${verb}: no key mapping for state=$status verb=$verb option=$option"
    create.result 1 "no-mapping: state=$status verb=$verb"
    return 1
  fi

  # Send raw key (no prefix, no Enter — TUI key handling is exact)
  otmux send.raw "$target" "$key"
  info.log "REMOTE CONTROL: $name ($target) state=$status verb=$verb key=$key"
  create.result 0 "controlled $name $target $key"
  return 0
}

hiveMind.agent.approve() # <agent|pane> # affirmative response in current overlay (Epic I I1.3)
{
  private.hiveMind.agent.overlay.dispatch "$1" approve
}
hiveMind.agent.approve.completion.agentName() { private.hiveMind.roles.complete; }

hiveMind.agent.reject() # <agent|pane> # negative response in current overlay (Epic I I1.3)
{
  private.hiveMind.agent.overlay.dispatch "$1" reject
}
hiveMind.agent.reject.completion.agentName() { private.hiveMind.roles.complete; }

hiveMind.agent.dismiss() # <agent|pane> # dismiss any overlay with Escape (Epic I I1.3)
{
  private.hiveMind.agent.overlay.dispatch "$1" dismiss
}
hiveMind.agent.dismiss.completion.agentName() { private.hiveMind.roles.complete; }

hiveMind.agent.option() # <agent|pane> <N> # send arbitrary option N in current overlay (Epic I I1.3)
{
  private.hiveMind.agent.overlay.dispatch "$1" option "$2"
}
hiveMind.agent.option.completion.agentName() { private.hiveMind.roles.complete; }
hiveMind.agent.option.completion.N() { echo "1"; echo "2"; echo "3"; }

# ── Epic I I1.4: QUEUE path — defer messages when agent busy, drain on idle ──

private.hiveMind.agent.queue.path() { # <pane> # path to queue file for a pane (sanitize : and . to _)
  local pane="$1"
  [ -z "$pane" ] && return 1
  local safe="${pane//:/_}"
  safe="${safe//./_}"
  echo "${HIVEMIND_QUEUE_DIR}/${safe}.queue"
}

private.hiveMind.agent.queue.enqueue() { # <pane> <intent> <text> # append message; enforce depth bound
  # Format: <epoch>|<intent>|<text>
  # Atomic: append (>> is atomic for short writes per POSIX). Depth bound
  # rotates by dropping oldest after append (warn on drop).
  local pane="$1" intent="$2"
  shift 2
  local text="$*"
  [ -z "$pane" ] || [ -z "$intent" ] && return 1
  local f
  f=$(private.hiveMind.agent.queue.path "$pane")
  mkdir -p "$(dirname "$f")" 2>/dev/null
  echo "$(date +%s)|${intent}|${text}" >> "$f"

  # Depth bound — drop oldest if over limit
  local depth
  depth=$(wc -l < "$f" 2>/dev/null | tr -d ' ')
  if [ "${depth:-0}" -gt "$HIVEMIND_QUEUE_MAX_DEPTH" ]; then
    local over=$((depth - HIVEMIND_QUEUE_MAX_DEPTH))
    warn.log "queue ${pane}: depth ${depth} > max ${HIVEMIND_QUEUE_MAX_DEPTH}; dropping ${over} oldest"
    tail -n "$HIVEMIND_QUEUE_MAX_DEPTH" "$f" > "${f}.tmp" 2>/dev/null && mv "${f}.tmp" "$f"
  fi
}

private.hiveMind.agent.queue.depth() { # <pane> # number of pending messages
  local pane="$1"
  local f
  f=$(private.hiveMind.agent.queue.path "$pane")
  [ -f "$f" ] || { echo 0; return 0; }
  wc -l < "$f" 2>/dev/null | tr -d ' '
}

private.hiveMind.agent.queue.resolve() { # <agent|pane> # resolve to pane (used by public queue verbs)
  local name="$1"
  if [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_.-]*:[0-9]+\.[0-9]+$ ]]; then
    echo "$name"
    return 0
  fi
  local target rc
  target=$(hiveMind.resolve "$name" 2>/dev/null)
  rc=$?
  if [ $rc -ne 0 ] || ! [[ "$target" =~ ^[A-Za-z_][A-Za-z0-9_.-]*:[0-9]+\.[0-9]+$ ]]; then
    return 1
  fi
  echo "$target"
}

hiveMind.agent.queue.list() # <agent|pane> # show pending messages with age (Epic I I1.4)
{
  local target
  target=$(private.hiveMind.agent.queue.resolve "$1") || {
    error.log "agent.queue.list: cannot resolve '$1'"; return 1; }
  local f
  f=$(private.hiveMind.agent.queue.path "$target")
  if [ ! -f "$f" ] || [ ! -s "$f" ]; then
    info.log "queue $target: empty"
    return 0
  fi
  local now
  now=$(date +%s)
  local epoch intent text age idx=0
  printf "  %-4s %-8s %-12s %s\n" "#" "AGE" "INTENT" "MESSAGE"
  while IFS='|' read -r epoch intent text; do
    idx=$((idx + 1))
    age=$((now - epoch))
    printf "  %-4s %-8s %-12s %s\n" "$idx" "${age}s" "$intent" "$text"
  done < "$f"
}
hiveMind.agent.queue.list.completion.agentName() { private.hiveMind.roles.complete; }

hiveMind.agent.queue.clear() # <agent|pane> # cancel all pending queued messages (Epic I I1.4)
{
  local target
  target=$(private.hiveMind.agent.queue.resolve "$1") || {
    error.log "agent.queue.clear: cannot resolve '$1'"; return 1; }
  local f
  f=$(private.hiveMind.agent.queue.path "$target")
  [ -f "$f" ] && rm -f "$f"
  info.log "queue $target: cleared"
}
hiveMind.agent.queue.clear.completion.agentName() { private.hiveMind.roles.complete; }

hiveMind.agent.queue.drain() # <agent|pane> # FIFO drain pending messages (re-checks state per message) (Epic I I1.4)
{
  # Drain semantics:
  #   • FIFO order (oldest first)
  #   • Re-check state per-message — if agent re-enters busy mid-drain, stop
  #   • Drop messages older than HIVEMIND_QUEUE_MAX_AGE_SEC (warn each)
  #   • Atomic per-message: read line, dispatch, rewrite file without that line
  local target
  target=$(private.hiveMind.agent.queue.resolve "$1") || {
    error.log "agent.queue.drain: cannot resolve '$1'"; return 1; }
  local f
  f=$(private.hiveMind.agent.queue.path "$target")
  [ -f "$f" ] && [ -s "$f" ] || { info.log "queue $target: nothing to drain"; return 0; }

  local now drained=0 dropped_age=0 stopped_at=""
  now=$(date +%s)

  while [ -s "$f" ]; do
    # Read first line
    local first
    first=$(head -1 "$f")
    [ -z "$first" ] && { tail -n +2 "$f" > "${f}.tmp" 2>/dev/null && mv "${f}.tmp" "$f"; continue; }

    local epoch intent text
    IFS='|' read -r epoch intent text <<< "$first"

    # Age check
    if [ $((now - epoch)) -gt "$HIVEMIND_QUEUE_MAX_AGE_SEC" ]; then
      warn.log "queue $target: dropping stale message (age=$((now - epoch))s, intent=$intent)"
      tail -n +2 "$f" > "${f}.tmp" 2>/dev/null && mv "${f}.tmp" "$f"
      dropped_age=$((dropped_age + 1))
      continue
    fi

    # Re-check state — bail if agent re-entered busy
    local route
    route=$(private.hiveMind.agent.route "$target")
    if [ "$route" != "inform" ]; then
      stopped_at="state went $route mid-drain"
      break
    fi

    # Dispatch per intent
    case "$intent" in
      inform)
        private.hiveMind.agent.inform "$target" "$text" 2>/dev/null
        ;;
      remote-control:*)
        local key="${intent#remote-control:}"
        otmux send.raw "$target" "$key" 2>/dev/null
        ;;
      *)
        warn.log "queue $target: unknown intent '$intent', dropping"
        ;;
    esac

    # Remove first line on success
    tail -n +2 "$f" > "${f}.tmp" 2>/dev/null && mv "${f}.tmp" "$f"
    drained=$((drained + 1))
    sleep 0.2
  done

  # Clean empty file
  [ -f "$f" ] && [ ! -s "$f" ] && rm -f "$f"

  if [ -n "$stopped_at" ]; then
    info.log "queue $target: drained $drained, dropped $dropped_age stale; stopped — $stopped_at"
  else
    info.log "queue $target: drained $drained, dropped $dropped_age stale"
  fi
}
hiveMind.agent.queue.drain.completion.agentName() { private.hiveMind.roles.complete; }

# ─────────────────────────────────────────────────────────────────────────────
# SWEEP & UNBLOCK
# ─────────────────────────────────────────────────────────────────────────────

private.hiveMind.sweep.detect() {
  # Enhanced blocker detection — returns status|action|severity[|detail]
  #
  # Statuses (18):
  #   active, idle, permission, accept-edits, context-warning, just-compacted,
  #   queued, rate-limit, subscription-limit, autocomplete, shell-escaped, shell,
  #   overlay, panel, mcp-error, api-error, tool-confirm, crash, unknown
  #
  # Actions: none, enter, escape, down-enter, wait
  #
  # Severity: critical, blocker, warning, info
  #   critical  — needs immediate human intervention (crash, subscription-limit)
  #   blocker   — auto-resolvable or needs approval (permission, accept-edits, rate-limit)
  #   warning   — may need attention soon (context-warning, just-compacted)
  #   info      — normal operational states (active, idle)
  #
  # Detail (optional 4th field): extra context (percentage, count, retry time)
  #
  # Backward-compatible: callers using ${result%%|*} for status still work.

  local target="$1"
  [ -z "$target" ] && echo "unknown|none|info" && return 1

  local content
  content=$(otmux pane.capture "$target" 20 2>/dev/null)

  local trimmed
  trimmed=$(echo "$content" | tr -d '[:space:]')
  [ -z "$trimmed" ] && echo "unknown|none|info" && return 0

  # Build a scrubbed view for substring-looking pattern checks (rate-limit,
  # subscription-limit, crash) that tend to false-positive on source code
  # showing the pattern string literally (comments, `grep -qE '…limit…'`
  # regexes which match their own source). Structural checks below (menus,
  # ❯ arrow, prompt state) keep using $content because their signals are UI
  # shapes, not substrings, and don't collide with code.
  # F2.1 — strip ALL common line-comment markers before substring matching.
  # A source-code pane mentioning "rate limit" or "subscription limit" in a
  # comment must not trigger the detector. Patterns stripped per first
  # non-whitespace token:
  #   #    — shell, Python, Ruby, Makefile, YAML, conf
  #   //   — C, C++, JS, TS, Go, Rust, Java, Swift, CSS, Kotlin, Dart
  #   --   — SQL, Haskell, Lua, Ada, Elm
  #   /*   — C-family block comment open (single-line "/* … */" form)
  #   *    — C-family block comment continuation line (e.g. "  * see also …")
  #   <!-- — HTML/XML comment open
  # Also drop shell control-flow, grep/sed/awk/echo calls (regex self-matches),
  # and pipe continuations — unchanged from the original scrub.
  local prose
  prose=$(echo "$content" \
    | grep -vE '^[[:space:]]*(#|//|--|/\*|\*[^/]|<!--)' \
    | grep -vE "(grep|sed|awk|echo)[[:space:]]+-?[a-zA-Z]*[[:space:]]*['\"]" \
    | grep -vE "^[[:space:]]*(if|elif|fi|then|else|done|for|while|case|esac)[[:space:]]*[[(]" \
    | grep -vE "^[[:space:]]*\|[[:space:]]*(grep|sed|awk)|^[[:space:]]+\\\\$")

  # ── Overlays and panels (dismiss with Escape) ──────────────────────────

  # Background tasks overlay
  if echo "$content" | grep -q 'Background tasks'; then
    echo "overlay|escape|blocker"
    return 0
  fi

  # Git diff/status panel — needs Escape to close
  # Note: 'files +N -N' also appears in the normal status bar — only match with Esc close
  if echo "$content" | grep -qE 'Esc close'; then
    echo "panel|escape|blocker"
    return 0
  fi
  if echo "$content" | grep -qE 'Uncommitted changes|git diff' && echo "$content" | grep -qE '^\s*[+-]{3}\s|^diff --git'; then
    echo "panel|escape|blocker"
    return 0
  fi

  # ── Error states (most urgent first) ───────────────────────────────────
  # These are substring matches that can collide with source-code displays,
  # so they grep $prose (scrubbed of shell comments/code) not $content.

  # Crash / exited: Claude Code process terminated unexpectedly
  if echo "$prose" | grep -qiE 'claude.*exited|process.*terminated|segmentation fault|SIGKILL|SIGTERM|core dumped|fatal error'; then
    echo "crash|none|critical"
    return 0
  fi

  # API error: network/server errors from Anthropic API
  if echo "$prose" | grep -qiE 'APIConnectionError|APIStatusError|ECONNREFUSED|ETIMEDOUT|502 Bad Gateway|503 Service|529 Overloaded|internal server error|network error|connection reset'; then
    local retry_detail
    retry_detail=$(echo "$prose" | grep -oiE 'retry.*(in |after )[0-9]+\s*s(ec)?' | tail -1)
    echo "api-error|wait|blocker|${retry_detail:-transient}"
    return 0
  fi

  # MCP connection error: MCP server connection/reconnect issues
  if echo "$prose" | grep -qiE 'MCP.*error|MCP.*disconnect|MCP.*reconnect|MCP.*timeout|MCP server.*not responding|failed to connect.*MCP'; then
    echo "mcp-error|wait|warning"
    return 0
  fi

  # ── Permission and confirmation prompts ────────────────────────────────

  # Permission / confirmation prompt — unified detector.
  # All Claude Code approval dialogs share ONE invariant preamble: "Do you want to …?"
  # (edit, proceed, continue, create, delete, run, apply, modify …). Combined with
  # the interactive numbered menu (❯ arrow pointing at "N. Yes/No/Allow/Deny"),
  # this reliably distinguishes a LIVE prompt from prose that mentions the words.
  # Default action: Enter (CC pre-selects the safe affirmative option 1).
  if echo "$content" | grep -q 'Do you want to' && \
     echo "$content" | grep -qE '^\s*❯\s*[0-9]+\.\s*(Yes|No|Allow|Deny)'; then
    echo "permission|enter|blocker"
    return 0
  fi

  # ── Rate and subscription limits ───────────────────────────────────────
  # Substring matches — grep $prose (scrubbed) to avoid matching the pattern
  # strings literally in source-code displays (hiveMind itself shows these
  # patterns in comments and the grep regexes below self-match when visible).

  # Subscription limit: quota/subscription exhausted (distinct from rate-limit)
  if echo "$prose" | grep -qiE 'subscription.*limit|daily.*limit.*reached|monthly.*limit|quota.*exhausted|billing.*limit|plan.*limit.*exceeded'; then
    echo "subscription-limit|none|critical"
    return 0
  fi

  # Rate limit: temporary throttling
  if echo "$prose" | grep -qiE 'rate.limit|usage.limit|try again.*(in |later)|too many requests|throttl|request limit|capacity|overloaded.*try'; then
    local retry_time
    retry_time=$(echo "$prose" | grep -oiE '(in |after )[0-9]+ ?(sec|min|second|minute)s?' | tail -1)
    echo "rate-limit|wait|blocker|${retry_time:-unknown}"
    return 0
  fi

  # ── Edit and acceptance prompts ────────────────────────────────────────

  # Accept edits prompt: ⏵⏵ accept edits on · N bash(es)
  # F2.2 — match ONLY against the bottom 5 lines (the live status bar). Old
  # `⏵⏵ accept` text in scrollback (e.g. agent's previous output, conversation
  # history visible in pane) caused false positives that blocked Epic I
  # context-aware sends to actually-idle agents. The live indicator only
  # appears in the status bar above the prompt — never in conversation body.
  local tail_content
  tail_content=$(echo "$content" | tail -n 5)
  if echo "$tail_content" | grep -q '⏵⏵ accept'; then
    local edit_count
    edit_count=$(echo "$tail_content" | grep -oE '[0-9]+ bash' | head -1 | grep -oE '[0-9]+')
    [ -z "$edit_count" ] && edit_count=0
    echo "accept-edits|enter|blocker|$edit_count"
    return 0
  fi

  # ── Context and compact states ─────────────────────────────────────────

  # Context warning: "Context left until auto-compact: N%"
  if echo "$content" | grep -qE 'auto-compact: [0-9]+%'; then
    local ctx_pct
    ctx_pct=$(echo "$content" | grep -oE 'auto-compact: [0-9]+%' | grep -oE '[0-9]+' | tail -1)
    echo "context-warning|none|warning|${ctx_pct:-unknown}%"
    return 0
  fi

  # Just compacted: "Compacted" in recent output
  if echo "$content" | grep -qiE 'Compacted|auto-compact.*completed'; then
    echo "just-compacted|none|warning"
    return 0
  fi

  # ── UI states ──────────────────────────────────────────────────────────

  # Autocomplete stuck: completion menu visible (e.g. /compact dropdown)
  if echo "$content" | grep -qE '^\s*/\w+.*─'; then
    echo "autocomplete|escape|blocker"
    return 0
  fi

  local last_line
  last_line=$(echo "$content" | sed '/^[[:space:]]*$/d' | tail -1)

  # Shell-escaped: bare $ prompt (agent left Claude Code)
  if echo "$last_line" | grep -qE '^[[:space:]]*\$[[:space:]]*$'; then
    echo "shell-escaped|none|critical"
    return 0
  fi

  # Idle: clean prompt (just ❯ or > with no text after)
  if echo "$last_line" | grep -qE '^[[:space:]]*(>|❯)[[:space:]]*$'; then
    # ── State-transition catch: invisible-rate-limit bug (Tron 2026-05-26)
    # An agent that hit a rate-limit / subscription-limit / api-error often
    # shows the message briefly, then returns to IDLE. The 20-line content
    # capture above has already scrolled past the message — sweep classifies
    # IDLE when the agent is actually STUCK. Catch it by scanning the wider
    # scrollback for recent block markers. Only runs on the idle path so
    # active agents (working through a resolved limit) aren't misclassified.
    local history historyProse
    history=$(otmux pane.capture "$target" 200 2>/dev/null)
    historyProse=$(echo "$history" \
      | grep -vE '^[[:space:]]*(#|//|--|/\*|\*[^/]|<!--)' \
      | grep -vE "(grep|sed|awk|echo)[[:space:]]+-?[a-zA-Z]*[[:space:]]*['\"]" \
      | grep -vE "^[[:space:]]*(if|elif|fi|then|else|done|for|while|case|esac)[[:space:]]*[[(]" \
      | grep -vE "^[[:space:]]*\|[[:space:]]*(grep|sed|awk)|^[[:space:]]+\\\\\$")
    # Subscription limit — most severe, needs human
    if echo "$historyProse" | grep -qiE 'subscription.*limit|daily.*limit.*reached|monthly.*limit|quota.*exhausted|billing.*limit|plan.*limit.*exceeded'; then
      echo "subscription-limit|none|critical|scrolled-history"
      return 0
    fi
    # Rate limit — conservative pattern (drop generic "try again" / "overloaded" that false-positive in tool output)
    if echo "$historyProse" | grep -qiE 'rate.limit|usage.limit|too many requests|throttl|request limit exceeded'; then
      echo "rate-limit|wait|blocker|scrolled-history"
      return 0
    fi
    # API error — distinctive Anthropic error class names
    if echo "$historyProse" | grep -qiE 'APIConnectionError|APIStatusError|502 Bad Gateway|503 Service|529 Overloaded'; then
      echo "api-error|wait|blocker|scrolled-history"
      return 0
    fi
    echo "idle|none|info"
    return 0
  fi

  # Queued: text after prompt not submitted
  if echo "$last_line" | grep -qE '^[[:space:]]*(>|❯)[[:space:]]+.+'; then
    echo "queued|enter|blocker"
    return 0
  fi

  # Active (generating/running tools/indeterminate)
  echo "active|none|info"
  return 0
}

private.hiveMind.agent.unblock.pane() {
  # Detect and resolve blocker on a single pane.
  # Bug #2 fix: STRICT ALLOWLIST — keys are sent ONLY to panes confirmed in a
  # blocking state. Active/idle/completed/unknown/anything-else are no-ops.
  # SM previously interrupted web4-po twice because the old fallback `*)` case
  # forwarded $action (enter/down-enter) for non-blocked statuses.
  #
  # Handles sweep.detect format: status|action|severity[|detail]
  local target="$1"
  local label="$2"
  local attempt="${3:-1}"

  local result
  result=$(private.hiveMind.sweep.detect "$target")

  # Parse fields: status|action|severity|detail
  local status action severity detail
  IFS='|' read -r status action severity detail <<< "$result"

  # Bug #2 ALLOWLIST — explicit list of statuses that trigger key sends.
  # Per PO: permission, tool-confirm, accept-edits, queued.
  # Wait-only statuses (rate-limit, mcp-error, …) are handled separately below.
  # Anything not in either list is silently skipped — never send keys to an
  # ACTIVE agent.
  case "$status" in
    permission|tool-confirm|accept-edits|queued)
      ;;  # falls through to action handlers below
    rate-limit|api-error|mcp-error)
      console.log "Waiting $label ($status) — ${detail:-will auto-recover}"
      return 0
      ;;
    subscription-limit|crash|shell-escaped)
      warn.log "CRITICAL $label ($status) — needs human intervention"
      return 0
      ;;
    idle)
      # Epic I I1.4 — agent at idle ❯ : drain any queued messages FIFO.
      # Drain re-checks state per-message (will stop if agent re-enters busy
      # mid-drain). Safe no-op if queue is empty.
      private.hiveMind.agent.queue.drain "$target" 2>/dev/null
      return 0
      ;;
    *)
      # active|completed|unknown|overlay|panel|autocomplete|<anything-new>
      # → SKIP. No keys sent. Old fallback that read $action is REMOVED — that
      # was the regression vector that interrupted web4-po.
      debug.log "Skipping $label ($status) — not in unblock allowlist"
      return 0
      ;;
  esac

  # Below here only runs for: permission, tool-confirm, accept-edits, queued.
  case "$status" in
    permission)
      # Select option 2 ("Yes, and don't ask again") — Down then Enter
      otmux send.raw "$target" Down
      sleep 0.3
      otmux send.raw "$target" Enter
      console.log "Unblocked $label ($status) → Down+Enter (option 2)"
      ;;
    tool-confirm)
      # Tool confirmation — just accept with Enter
      otmux send.raw "$target" Enter
      console.log "Unblocked $label ($status) → Enter"
      ;;
    accept-edits)
      # Accept pending edits — Enter for each stacked edit + base
      local count=0
      [[ "$detail" =~ ^[0-9]+$ ]] && count="$detail"
      local i
      for ((i=0; i<=count; i++)); do
        otmux send.raw "$target" Enter
        sleep 0.5
      done
      # Toggle out of accept-edits mode back to normal prompt
      # BTab (shift-tab) cycles: accept-edits → plan-mode → normal
      sleep 0.5
      otmux send.raw "$target" BTab
      sleep 0.3
      otmux send.raw "$target" BTab
      sleep 0.3
      console.log "Unblocked $label ($status) → $((count+1))x Enter + 2x BTab (back to normal)"
      ;;
    queued)
      # Submit queued text
      otmux send.raw "$target" Enter
      console.log "Unblocked $label ($status) → Enter"
      ;;
  esac

  # Verify: re-detect after action. If still blocked with same/another
  # actionable state, retry once. If now active/idle/completed/etc., we're done.
  if [ "$attempt" -le 1 ]; then
    sleep 1
    local recheck
    recheck=$(private.hiveMind.sweep.detect "$target" 2>/dev/null)
    local new_status="${recheck%%|*}"
    case "$new_status" in
      permission|tool-confirm|accept-edits|queued)
        warn.log "$label still blocked ($new_status) — retrying once"
        private.hiveMind.agent.unblock.pane "$target" "$label" 2
        ;;
      *)
        debug.log "Verified: $label cleared ($status → $new_status)"
        ;;
    esac
  fi
}

hiveMind.pane.sweep() # <?session> <?interval> # show last lines of each agent pane (optional sleep before sweep)
{
  local session="${1:-$(private.hiveMind.active.team)}"
  local interval="$2"
  private.hiveMind.monitor.switch "${session}:0.0"

  # Sleep first if interval provided (avoids compound commands that trigger permission prompts)
  if [ -n "$interval" ] && [ "$interval" -gt 0 ] 2>/dev/null; then
    sleep "$interval"
  fi

  if [ ! -f "$HIVEMIND_REGISTRY" ]; then
    error.log "No role registry found at $HIVEMIND_REGISTRY"
    return 1
  fi

  if ! otmux has "$session" 2>/dev/null; then
    error.log "Session $session not found"
    return 1
  fi

  if ! grep -q "^${session}:" "$HIVEMIND_REGISTRY" 2>/dev/null; then
    error.log "No agents registered for session $session"
    return 1
  fi

  while IFS='|' read -r target role _ts; do
    [ -z "$target" ] || [ -z "$role" ] && continue
    case "$target" in "${session}:"*) ;; *) continue ;; esac

    local addr="${target#*:}"
    echo "=== $role ($addr) ==="
    otmux pane.capture "$target" 8
    echo ""
  done < "$HIVEMIND_REGISTRY"
}
hiveMind.pane.sweep.cycle() # <?session> <?interval:0> # run one sweep+unblock cycle with optional sleep
{
  local session="${1:-$(private.hiveMind.current.session)}"
  local interval="${2:-0}"

  [ "$interval" -gt 0 ] 2>/dev/null && sleep "$interval"

  hiveMind.pane.sweep "$session"
  hiveMind.agent.unblock all "$session"
}
hiveMind.git.commit() # <?message> # auto-commit uncommitted changes if any exist
{
  local msg="${1:-Auto-commit: cycle checkpoint $(date '+%Y-%m-%d %H:%M')}"

  # Only add tracked files that changed (never untracked — avoids committing secrets)
  # Also add session/ files which are safe to commit
  local has_changes=false

  if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then
    git add -u
    has_changes=true
  fi

  if [ "$has_changes" = "true" ]; then
    git commit -m "$msg

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
    git push 2>/dev/null &
    console.log "Auto-committed: $msg"
    return 0
  fi

  console.log "Nothing to commit"
  return 0
}
hiveMind.team.cycle() # <?session> # run full monitoring cycle: sweep + unblock + context check + auto-commit
{
  local session="${1:-$(private.hiveMind.current.session)}"

  # Step 1: Sweep and unblock
  console.log "Cycle: sweep $session"
  hiveMind.pane.sweep "$session" 2>/dev/null
  hiveMind.agent.unblock all "$session" 2>/dev/null

  # Step 2: Check context for all panes
  console.log "Cycle: context check"
  local panes
  panes=$(otmux panes -t "${session}:0" -F "#{pane_index}" 2>/dev/null)
  for pane in $panes; do
    local ctx
    ctx=$(claudeCode context.read "${session}:0.${pane}" 2>/dev/null)
    if [ -n "$ctx" ] && [ "$ctx" != "unknown" ]; then
      # Alert if below 25%
      local remaining
      remaining=$(echo "$ctx" | grep -oE '[0-9]+' | head -1)
      if [ -n "$remaining" ] && [ "$remaining" -lt 25 ] 2>/dev/null; then
        console.log "ALERT: ${session}:0.${pane} context at ${remaining}%"
      fi
    fi
  done

  # Step 3: Auto-commit if changes
  console.log "Cycle: auto-commit"
  hiveMind.git.commit "Cycle checkpoint $(date '+%H:%M')" 2>/dev/null

  console.log "Cycle complete for $session"
}
hiveMind.dashboard() # <?session> # write consolidated team state to session/dashboard.md
{
  local session="${1:-$(private.hiveMind.active.team)}"
  local registry="$HIVEMIND_REGISTRY"
  local dashboard_file="$(git rev-parse --show-toplevel 2>/dev/null)/session/dashboard.md"

  local timestamp
  timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
  local local_time
  local_time=$(date '+%Y-%m-%d %H:%M %Z')

  # Git status
  local git_branch git_status git_commit
  git_branch=$(git -C "$OOSH_DIR" branch --show-current 2>/dev/null)
  git_commit=$(git -C "$OOSH_DIR" log -1 --oneline 2>/dev/null)
  if git -C "$OOSH_DIR" diff --quiet 2>/dev/null && git -C "$OOSH_DIR" diff --cached --quiet 2>/dev/null; then
    git_status="clean"
  else
    git_status="uncommitted changes"
  fi

  # Subscription velocity (from cached metrics)
  local vel_five="-" vel_seven="-"
  local latest_sub
  latest_sub=$(ls -t "${CONFIG_PATH:-$HOME/config}/metrics"/subscription.*.scenario.env 2>/dev/null | head -1)
  if [ -n "$latest_sub" ] && [ -f "$latest_sub" ]; then
    source "$latest_sub"
    vel_five="${SUBSCRIPTION_FIVE_HOUR_UTIL:-0}%"
    vel_seven="${SUBSCRIPTION_SEVEN_DAY_UTIL:-0}%"
  fi

  # Start dashboard
  {
    echo "# Team Dashboard"
    echo ""
    echo "**Updated**: $timestamp ($local_time)"
    echo "**Session**: $session"
    echo ""
    echo "## Git Status"
    echo ""
    echo "| Field | Value |"
    echo "|-------|-------|"
    echo "| Branch | \`$git_branch\` |"
    echo "| Status | $git_status |"
    echo "| Last commit | \`$git_commit\` |"
    echo ""
    echo "## Subscription"
    echo ""
    echo "| Metric | Value |"
    echo "|--------|-------|"
    echo "| 5-hour usage | $vel_five |"
    echo "| 7-day usage | $vel_seven |"
    echo ""
    echo "## Team Status"
    echo ""
    echo "| Agent | Pane | Context | State | Activity |"
    echo "|-------|------|---------|-------|----------|"
  } > "$dashboard_file"

  # Iterate all agents
  if [ -f "$registry" ]; then
    while IFS='|' read -r target role _ts; do
      [ -z "$role" ] && continue
      [[ "$target" != "$session:"* ]] && continue

      local pane="${target##*.}"
      local context_pct state activity

      # Get context
      context_pct=$(claudeCode context.read "$target" 2>/dev/null)
      [ -z "$context_pct" ] && context_pct="-"
      [ "$context_pct" != "-" ] && context_pct="${context_pct}%"

      # Get state via sweep detect
      local detect_result
      detect_result=$(private.hiveMind.sweep.detect "$target" 2>/dev/null)
      state="${detect_result%%|*}"
      [ -z "$state" ] && state="unknown"

      # Get activity from pane capture
      local content
      content=$(otmux pane.capture "$target" 5 2>/dev/null | tail -3)
      activity=$(echo "$content" | grep -oE '(Reading|Writing|Thinking|Running|Editing|Searching|waiting|idle)' | tail -1)
      [ -z "$activity" ] && activity="-"

      echo "| $role | 0.$pane | $context_pct | $state | $activity |" >> "$dashboard_file"
    done < "$registry"
  fi

  # Recent commits
  {
    echo ""
    echo "## Recent Commits"
    echo ""
    echo "\`\`\`"
    git -C "$OOSH_DIR" log --oneline -5 2>/dev/null
    echo "\`\`\`"
    echo ""
    echo "## Recovery"
    echo ""
    echo "After \`/compact\`, read:"
    echo "1. This file (\`session/dashboard.md\`)"
    echo "2. Your role's context file (\`session/agents/<role>.context.md\`)"
    echo "3. Your SKILL.md (\`.claude/agents/<role>/SKILL.md\`)"
    echo ""
    echo "---"
    echo "*Generated by \`hiveMind dashboard\`*"
  } >> "$dashboard_file"

  console.log "Dashboard updated: $dashboard_file"
  create.result 0 "$dashboard_file"
  return $(result)
}
hiveMind.agent.unblock() # <agentName> <?session> # detect and resolve stuck prompt; name=agent name or 'all'
{
  local name="$1"
  if [ -z "$name" ]; then
    error.log "Usage: hiveMind agent.unblock <name|all>"
    return 1
  fi

  local session="${2:-$(private.hiveMind.active.team)}"

  if [ "$name" = "all" ]; then
    while IFS='|' read -r target role _ts; do
      [ -z "$target" ] || [ -z "$role" ] && continue
      case "$target" in "${session}:"*) ;; *) continue ;; esac
      private.hiveMind.agent.unblock.pane "$target" "$role"
    done < "$HIVEMIND_REGISTRY"
    return 0
  fi

  # Single agent
  local target
  target=$(hiveMind.resolve "$name")
  [ $? -ne 0 ] && return 1
  private.hiveMind.agent.unblock.pane "$target" "$name"
}
hiveMind.agent.monitor.cycle() # <?session> # full monitoring cycle: context health, velocity, detect blockers, unblock, log
{
  local session="${1:-$(private.hiveMind.current.session)}"

  # List all panes across all windows (-s = session-wide)
  local pane_lines
  pane_lines=$(private.hiveMind.list.panes addr+cmd "$session")
  [ -z "$pane_lines" ] && { error.log "No panes found for $session"; return 1; }

  local checked=0 blocked=0 low_context=0

  # Workspace for burn-log (computed once outside loop)
  local workspace
  workspace="$(git rev-parse --show-toplevel 2>/dev/null)"
  local logfile=""
  if [ -n "$workspace" ]; then
    logfile="${workspace}/session/context-burn-log.md"
    if [ ! -f "$logfile" ]; then
      echo "| Time | Pane | % | State | Velocity | TTC |" > "$logfile"
      echo "|------|------|---|-------|----------|-----|" >> "$logfile"
    fi
  fi

  while IFS='|' read -r addr pane_cmd; do
    local target="${session}:${addr}"
    local is_claude=false

    case "$pane_cmd" in
      claude*|[0-9].[0-9]*)
        is_claude=true ;;
      bash|zsh)
        "claudeCode" process.running "$target" && is_claude=true ;;
    esac

    [ "$is_claude" = "false" ] && continue
    checked=$((checked + 1))

    # Step 1: Context health check
    local pct
    pct=$("claudeCode" context.read "$target" 2>/dev/null)
    [ -z "$pct" ] && pct="unknown"

    # Step 2: Velocity (tokens/hr, time-to-compact)
    local velocity tokens_hr ttc
    velocity=$("claudeCode" context.velocity "$target" 2>/dev/null)
    tokens_hr=$(echo "$velocity" | grep -oE '^[0-9]+ tokens/hr' || echo "-")
    ttc=$(echo "$velocity" | grep -oE '~[0-9]+min' || echo "-")

    # Step 3: Alert if context low (<25%)
    if [ "$pct" != "unknown" ] && [ "$pct" -le 25 ] 2>/dev/null; then
      low_context=$((low_context + 1))
      warn.log "$target: ${pct}% context — triggering alert"
      "claudeCode" context.alert "$target" 25
    fi

    # Step 4: Detect blockers and unblock
    local detect_raw status
    detect_raw=$(private.hiveMind.sweep.detect "$target" 2>/dev/null)
    status="${detect_raw%%|*}"
    case "$status" in
      active|idle|unknown) ;; # healthy — skip
      *) blocked=$((blocked + 1))
         private.hiveMind.agent.unblock.pane "$target" "${addr}" ;;
    esac

    # Step 5: Log to burn-log
    if [ -n "$logfile" ]; then
      local ts
      ts=$(date '+%H:%M')
      echo "| $ts | $addr | ${pct}% | $status | $tokens_hr | $ttc |" >> "$logfile"
    fi

  done <<< "$pane_lines"

  info.log "Monitor cycle: $checked checked, $blocked blocked, $low_context low-context"
  create.result 0 "$checked"
  return $(result)
}
hiveMind.peer.compact() # <agentName> <?session> # trigger seamless compact for a peer agent
{
  local name="$1"
  local session="${2:-$(private.hiveMind.active.team)}"

  if [ -z "$name" ]; then
    error.log "Usage: hiveMind peer.compact <target> <?session>"
    return 1
  fi

  # Resolve name to pane target
  local target
  if [[ "$name" == *":"* ]] || [[ "$name" == *"."* ]]; then
    target="$name"
    [[ "$target" != *":"* ]] && target="${session}:${target}"
  else
    target=$(hiveMind.resolve "$name" "$session" 2>/dev/null)
  fi

  if [ -z "$target" ]; then
    error.log "Cannot resolve '$name' to a pane target"
    return 1
  fi

  local role
  role=$(private.hiveMind.registry.get "$target" 2>/dev/null)
  [ -z "$role" ] && role="$name"

  info.log "Compacting peer: $role at $target"

  # Step 1: Capture peer state (30 lines)
  local pane_content
  pane_content=$("otmux" pane.capture "$target" 30 2>/dev/null)
  if [ -z "$pane_content" ]; then
    warn.log "Could not capture $target — pane may be empty"
  fi

  # Step 2: Check context % before compact
  local pct
  pct=$("claudeCode" context.read "$target" 2>/dev/null)
  info.log "$role context: ${pct:-unknown}%"

  # Step 3: Kill rogue resume hook processes
  local addr="${target#*:}"
  local pid_file="${TMPDIR:-/tmp}/resume-${addr}.pid"
  if [ -f "$pid_file" ]; then
    local old_pid
    old_pid=$(cat "$pid_file" 2>/dev/null)
    if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
      info.log "Killing rogue hook process $old_pid"
      kill "$old_pid" 2>/dev/null
    fi
    rm -f "$pid_file" 2>/dev/null
  fi

  # Step 4: Clear input line
  "otmux" send.raw "$target" C-u

  # Step 5: Send /compact
  "otmux" send.enter "$target" "/compact"
  sleep 2
  "otmux" send.raw "$target" Enter

  # Step 6: Wait for hook to process
  info.log "Waiting for compact to complete..."
  local waited=0
  while [ "$waited" -lt 30 ]; do
    sleep 5
    waited=$((waited + 5))
    local check
    check=$("otmux" pane.capture "$target" 5 2>/dev/null)
    if echo "$check" | grep -qE 'Compacted|auto-compact|resumed'; then
      info.log "Compact confirmed after ${waited}s"
      break
    fi
  done

  # Step 7: Verify recovery
  local verify
  verify=$("otmux" pane.capture "$target" 10 2>/dev/null)
  if echo "$verify" | grep -qE 'Compacted|❯'; then
    console.log "peer.compact: $role at $target — OK"
    create.result 0 "compacted"
  else
    warn.log "peer.compact: $role — compact may not have completed"
    create.result 1 "unverified"
  fi
  return $(result)
}
hiveMind.agent.handoff() # <agentName> <?session> # full generational handoff: detect low context, compact, verify recovery
{
  local name="$1"
  local session="${2:-$(private.hiveMind.active.team)}"

  if [ -z "$name" ]; then
    error.log "Usage: hiveMind agent.handoff <target> <?session>"
    return 1
  fi

  # Resolve name to pane
  local target
  if [[ "$name" == *":"* ]] || [[ "$name" == *"."* ]]; then
    target="$name"
    [[ "$target" != *":"* ]] && target="${session}:${target}"
  else
    target=$(hiveMind.resolve "$name" "$session" 2>/dev/null)
  fi

  if [ -z "$target" ]; then
    error.log "Cannot resolve '$name' to a pane target"
    return 1
  fi

  local role
  role=$(private.hiveMind.registry.get "$target" 2>/dev/null)
  [ -z "$role" ] && role="$name"

  # ── Phase 1: Detect ──────────────────────────────────────────────────
  local pct
  pct=$("claudeCode" context.read "$target" 2>/dev/null)
  info.log "Handoff check: $role at $target — context: ${pct:-unknown}%"

  # If context is healthy (>15%), nothing to do
  if [ -n "$pct" ] && [ "$pct" -gt 15 ] 2>/dev/null; then
    console.log "handoff: $role has ${pct}% context — no handoff needed"
    create.result 0 "healthy"
    return 0
  fi

  # Alert agent to save state
  info.log "Context low (${pct:-?}%) — alerting $role to save state"
  "otmux" send.raw "$target" C-u
  "otmux" send "$target" "Save your context and run /compact NOW. You are at ${pct:-low}%." Enter

  # Wait for agent to save context file
  local workspace
  workspace="$(git rev-parse --show-toplevel 2>/dev/null)"
  local context_file="${workspace}/session/agents/${role}/context.md"
  local waited=0
  local context_saved=false
  while [ "$waited" -lt 15 ]; do
    sleep 3
    waited=$((waited + 3))
    # Check if context file was updated recently (within last 30s)
    if [ -f "$context_file" ]; then
      local age
      age=$(( $(date +%s) - $(stat -f %m "$context_file" 2>/dev/null || echo 0) ))
      if [ "$age" -lt 30 ]; then
        context_saved=true
        info.log "Context file saved (${age}s ago)"
        break
      fi
    fi
  done

  if [ "$context_saved" = false ]; then
    warn.log "Context file not updated — proceeding with compact anyway"
  fi

  # ── Phase 2: Trigger ─────────────────────────────────────────────────
  # Check if agent already ran /compact (may have self-compacted from our message)
  local check
  check=$("otmux" pane.capture "$target" 10 2>/dev/null)
  if echo "$check" | grep -qE 'Compacted|auto-compact'; then
    info.log "Agent already compacted — skipping trigger"
  else
    info.log "Triggering compact via peer.compact"
    hiveMind.peer.compact "$target" "$session"
  fi

  # ── Phase 3: Verify ──────────────────────────────────────────────────
  sleep 3
  info.log "Verifying recovery..."
  "claudeCode" agent.recover "$target"
  local recover_result=$?

  if [ $recover_result -eq 0 ]; then
    console.log "handoff: $role — complete (detect → compact → recover)"
    create.result 0 "handoff_complete"
  else
    warn.log "handoff: $role — recovery uncertain, may need manual intervention"
    create.result 1 "recovery_uncertain"
  fi
  return $(result)
}
hiveMind.team.recover() # <?session> # cold-start recovery: reconcile registry with live infrastructure, nudge agents
{
  local session="${1:-$(private.hiveMind.current.session)}"

  if [ -z "$session" ]; then
    error.log "No tmux session found — are you inside tmux?"
    return 1
  fi

  local workspace
  workspace="$(git rev-parse --show-toplevel 2>/dev/null)"

  info.log "Cold-start recovery for session: $session"

  # Step 1-2: Discover live infrastructure
  local live_panes
  live_panes=$(private.hiveMind.list.panes "#{session_name}:#{window_index}.#{pane_index}|#{pane_current_command}|#{pane_title}" "$session")

  if [ -z "$live_panes" ]; then
    error.log "No panes found in session $session"
    return 1
  fi

  local pane_count
  pane_count=$(echo "$live_panes" | wc -l | tr -d ' ')
  info.log "Found $pane_count live panes"

  # Step 3-5: Reconcile registry with reality
  local reg="$HIVEMIND_REGISTRY"
  local stale_count=0
  local live_count=0
  local recovered_count=0

  # Remove registry entries for panes that no longer exist
  if [ -f "$reg" ]; then
    local old_entries
    old_entries=$(grep "^${session}:" "$reg" 2>/dev/null)
    while IFS= read -r entry; do
      [ -z "$entry" ] && continue
      local reg_pane="${entry%%|*}"
      if ! echo "$live_panes" | grep -q "^${reg_pane}|"; then
        info.log "Stale registry entry: $entry — removing"
        grep -v "^${reg_pane}|" "$reg" > "${reg}.tmp" 2>/dev/null
        mv "${reg}.tmp" "$reg"
        stale_count=$((stale_count + 1))
      fi
    done <<< "$old_entries"
  fi

  # Walk live panes, identify agents
  while IFS='|' read -r pane_addr cmd title; do
    [ -z "$pane_addr" ] && continue

    local role
    role=$(private.hiveMind.registry.get "$pane_addr" 2>/dev/null)

    # Check if pane is running Claude Code (node = claude process)
    local is_claude=false
    case "$cmd" in
      node) is_claude=true ;;
    esac

    if [ "$is_claude" = true ]; then
      live_count=$((live_count + 1))

      if [ -z "$role" ]; then
        # Unregistered Claude pane — try to identify from title or capture
        local capture
        capture=$("otmux" pane.capture "$pane_addr" 10 2>/dev/null)
        # Check if it mentions a role name
        local guessed_role=""
        for candidate in orchestrator oosh-expert oosh-tester scrum-master agent-teacher product-owner developer; do
          if echo "$capture" | grep -qi "$candidate"; then
            guessed_role="$candidate"
            break
          fi
        done
        if [ -n "$guessed_role" ]; then
          info.log "Identified $pane_addr as $guessed_role (from pane content)"
          private.hiveMind.registry.set "$pane_addr" "$guessed_role"
          role="$guessed_role"
        else
          warn.log "Unidentified Claude pane at $pane_addr — register manually"
        fi
      fi

      # Step 6-10: Nudge agents that need recovery
      if [ -n "$role" ] && [ -n "$workspace" ]; then
        local boot_file="${workspace}/session/boot/${role}.md"
        local context_file="${workspace}/session/agents/${role}/context.md"

        # Check if agent looks stuck or just booted
        local state
        state=$(private.hiveMind.sweep.detect "$pane_addr" 2>/dev/null)
        local status="${state%%|*}"

        case "$status" in
          active|idle)
            info.log "$role at $pane_addr: $status — no recovery needed"
            ;;
          *)
            info.log "$role at $pane_addr: $status — sending recovery nudge"
            if [ -f "$boot_file" ]; then
              "otmux" send "$pane_addr" C-u
              "otmux" send "$pane_addr" "Read session/boot/${role}.md" Enter
            elif [ -f "$context_file" ]; then
              "otmux" send "$pane_addr" C-u
              "otmux" send "$pane_addr" "Read session/agents/${role}/context.md" Enter
            fi
            recovered_count=$((recovered_count + 1))
            ;;
        esac
      fi
    fi
  done <<< "$live_panes"

  console.log "cold.recover: session=$session panes=$pane_count claude=$live_count stale=$stale_count recovered=$recovered_count"
  create.result 0 "panes=$pane_count claude=$live_count stale=$stale_count recovered=$recovered_count"
  return 0
}
hiveMind.agent.train() # <agentName> <?session> # train agent: verify reading list, send training task, monitor, verify completion
{
  local role="$1"
  local session="${2:-$(private.hiveMind.active.team)}"

  if [ -z "$role" ]; then
    error.log "Usage: hiveMind agent.train <role> <?session>"
    return 1
  fi

  local workspace
  workspace="$(git rev-parse --show-toplevel 2>/dev/null)"
  if [ -z "$workspace" ]; then
    error.log "Cannot determine workspace root"
    return 1
  fi

  # Resolve role to pane
  local target
  target=$(hiveMind.resolve "$role" "$session" 2>/dev/null)
  if [ -z "$target" ]; then
    error.log "Cannot resolve '$role' — is the agent bootstrapped?"
    return 1
  fi

  info.log "Training $role at $target"

  # Step 1: Verify SKILL.md has a Reading List
  local agents_dir
  agents_dir=$(private.hiveMind.find.agents.dir 2>/dev/null)
  local skill_file="${agents_dir}/${role}/SKILL.md"
  if [ -z "$agents_dir" ] || [ ! -f "$skill_file" ]; then
    error.log "No SKILL.md found for role $role (tried $skill_file)"
    return 1
  fi

  if ! grep -q "## Reading List" "$skill_file" 2>/dev/null; then
    warn.log "SKILL.md for $role has no Reading List section — agent may not know what to read"
  else
    info.log "SKILL.md has Reading List section"
  fi

  # Step 2: Write training task file
  local tasks_dir="${workspace}/session/tasks"
  mkdir -p "$tasks_dir" 2>/dev/null
  local task_file="${tasks_dir}/${role}-training.md"

  local rel_skill
  rel_skill=$(echo "$skill_file" | sed "s|^${workspace}/||")

  {
    echo "# Training: $role"
    echo ""
    echo "**From**: agent-trainer"
    echo "**To**: $role"
    echo "**Priority**: HIGH"
    echo "**Date**: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
    echo ""
    echo "## Instructions"
    echo ""
    echo "1. Read your SKILL.md: \`${rel_skill}\`"
    echo "2. Follow the Reading List section — read each file in order"
    echo "3. After reading all files, write your context to \`session/agents/${role}/context.md\`"
    echo "4. Check \`session/tasks/\` for assigned work"
    echo "5. Report training complete with files-read count"
  } > "$task_file"

  # Send task to agent
  "otmux" send "$target" C-u
  "otmux" send "$target" "Read session/tasks/${role}-training.md" Enter

  # Step 3: Monitor progress — wait up to 60s for file reads
  info.log "Monitoring training progress..."
  local waited=0
  local trained=false
  while [ "$waited" -lt 60 ]; do
    sleep 10
    waited=$((waited + 10))

    local capture
    capture=$("otmux" pane.capture "$target" 15 2>/dev/null)

    # Check for signs of completion
    if echo "$capture" | grep -qE 'files read|training complete|context.*written|TaskList|session/tasks'; then
      trained=true
      info.log "Training activity detected after ${waited}s"
      break
    fi

    # Check if agent is actively reading (tool use activity)
    local state
    state=$(private.hiveMind.sweep.detect "$target" 2>/dev/null)
    local status="${state%%|*}"
    if [ "$status" = "active" ]; then
      debug.log "Agent active at ${waited}s — still training"
    fi
  done

  # Step 4: Verify completion
  local context_file="${workspace}/session/agents/${role}/context.md"
  if [ -f "$context_file" ]; then
    local age
    age=$(( $(date +%s) - $(stat -f %m "$context_file" 2>/dev/null || echo 0) ))
    if [ "$age" -lt 120 ]; then
      info.log "Context file written (${age}s ago)"
      trained=true
    fi
  fi

  # Step 5: Report result
  if [ "$trained" = true ]; then
    console.log "train: $role at $target — trained and ready for work"
    create.result 0 "trained"
  else
    warn.log "train: $role at $target — training may still be in progress (waited 60s)"
    create.result 0 "in_progress"
  fi
  return 0
}
hiveMind.plan.improve() # <?action:next|done|list> <?number> # CMM improvement workflow: find next, mark done, list status
{
  local action="${1:-next}"
  local number="$2"

  local workspace
  workspace="$(git rev-parse --show-toplevel 2>/dev/null)"
  if [ -z "$workspace" ]; then
    error.log "Cannot determine workspace root"
    return 1
  fi

  local imp_file="${workspace}/session/cmm.improvement.md"
  local pipeline_file="${workspace}/session/knowledge-base/cmm-pipeline.md"

  # Ensure improvement file exists
  if [ ! -f "$imp_file" ]; then
    if [ "$action" = "next" ] || [ "$action" = "list" ]; then
      warn.log "No improvement file at $imp_file"
      create.result 1 "no_file"
      return 1
    fi
  fi

  case "$action" in

    next)
      # Step 1: Find top unchecked improvement
      # Format: lines with "- [ ]" are unchecked, "- [x]" are done
      local next_item
      next_item=$(grep -n '\- \[ \]' "$imp_file" 2>/dev/null | head -1)
      if [ -z "$next_item" ]; then
        console.log "improvement: all items checked — pipeline empty"
        create.result 0 "all_done"
        return 0
      fi

      local line_num="${next_item%%:*}"
      local item_text="${next_item#*:}"
      # Strip leading whitespace and checkbox
      item_text=$(echo "$item_text" | sed 's/^[[:space:]]*- \[ \] //')

      info.log "Next improvement (line $line_num): $item_text"

      # Step 2: Extract KPIs — read lines after the item until next item or section
      local kpis=""
      local kpi_line=$((line_num + 1))
      local total_lines
      total_lines=$(wc -l < "$imp_file" | tr -d ' ')
      while [ "$kpi_line" -le "$total_lines" ]; do
        local line
        line=$(sed -n "${kpi_line}p" "$imp_file")
        # Stop at next item, blank line after KPIs, or section header
        case "$line" in
          "- ["*|"## "*|"# "*) break ;;
          "") break ;;
          *) kpis="${kpis}${line}"$'\n' ;;
        esac
        kpi_line=$((kpi_line + 1))
      done

      if [ -n "$kpis" ]; then
        echo "KPIs:"
        echo "$kpis"
      fi

      console.log "improvement next: $item_text"
      create.result 0 "$item_text"
      ;;

    done)
      # Steps 5-8: Mark improvement as done, commit
      if [ -z "$number" ]; then
        # Default: mark first unchecked item
        local first_unchecked
        first_unchecked=$(grep -n '\- \[ \]' "$imp_file" 2>/dev/null | head -1)
        if [ -z "$first_unchecked" ]; then
          warn.log "No unchecked items to mark done"
          return 1
        fi
        number="${first_unchecked%%:*}"
      fi

      # Mark the checkbox at line $number
      if sed -n "${number}p" "$imp_file" | grep -q '\- \[ \]'; then
        sed "${number}s/- \[ \]/- [x]/" "$imp_file" > "${imp_file}.tmp" && mv "${imp_file}.tmp" "$imp_file"
        info.log "Marked line $number as done"
      else
        warn.log "Line $number is not an unchecked item"
        return 1
      fi

      # Update pipeline status if it exists
      if [ -f "$pipeline_file" ]; then
        info.log "Remember to update $pipeline_file status"
      fi

      # Step 8: Commit
      (cd "$workspace" && git add -f session/cmm.improvement.md session/knowledge-base/cmm-pipeline.md 2>/dev/null && \
       git commit -m "CMM improvement done (line $number)" 2>/dev/null)
      local commit_result=$?
      if [ $commit_result -eq 0 ]; then
        console.log "improvement done: line $number — committed"
      else
        console.log "improvement done: line $number — marked (nothing to commit)"
      fi
      create.result 0 "done"
      ;;

    list)
      # Show status summary
      local total checked unchecked
      total=$(grep -c '\- \[' "$imp_file" 2>/dev/null || echo 0)
      checked=$(grep -c '\- \[x\]' "$imp_file" 2>/dev/null || echo 0)
      unchecked=$(grep -c '\- \[ \]' "$imp_file" 2>/dev/null || echo 0)
      echo "CMM Improvements: $checked/$total done, $unchecked remaining"
      grep '\- \[' "$imp_file" 2>/dev/null
      create.result 0 "$checked/$total"
      ;;

    *)
      error.log "Usage: hiveMind improvement [next|done|list] <?line-number>"
      return 1
      ;;
  esac
  return 0
}
hiveMind.metrics.log() # <?session> # log sweep metrics per agent + flag context/subscription thresholds
{
  local session="${1:-$(private.hiveMind.active.team)}"

  local workspace
  workspace="$(git rev-parse --show-toplevel 2>/dev/null)"
  if [ -z "$workspace" ]; then
    error.log "Cannot determine workspace root"
    return 1
  fi

  local logfile="${workspace}/session/metrics/sweep-log.md"
  mkdir -p "$(dirname "$logfile")" 2>/dev/null

  # Initialize log with header if new
  if [ ! -f "$logfile" ]; then
    echo "| Time | Agent | Pane | Context% | State | Velocity | TTC |" > "$logfile"
    echo "|------|-------|------|----------|-------|----------|-----|" >> "$logfile"
  fi

  local pane_lines
  pane_lines=$(private.hiveMind.list.panes addr+cmd "$session")
  [ -z "$pane_lines" ] && { error.log "No panes for $session"; return 1; }

  local ts logged=0 alerts=""
  ts=$(date -u '+%H:%M')

  while IFS='|' read -r addr cmd; do
    local target="${session}:${addr}"

    # Only log Claude panes
    local is_claude=false
    case "$cmd" in
      node) is_claude=true ;;
    esac
    [ "$is_claude" = false ] && continue

    local role
    role=$(private.hiveMind.registry.get "$target" 2>/dev/null)
    [ -z "$role" ] && role="$addr"

    # Context %
    local pct
    pct=$("claudeCode" context.read "$target" 2>/dev/null)
    [ -z "$pct" ] && pct="?"

    # State
    local detect status
    detect=$(private.hiveMind.sweep.detect "$target" 2>/dev/null)
    status="${detect%%|*}"

    # Velocity
    local velocity tokens_hr ttc
    velocity=$("claudeCode" context.velocity "$target" 2>/dev/null)
    tokens_hr=$(echo "$velocity" | grep -oE '^[0-9]+ tokens/hr' || echo "-")
    ttc=$(echo "$velocity" | grep -oE '~[0-9]+min' || echo "-")

    # Log row
    echo "| $ts | $role | $addr | ${pct}% | $status | $tokens_hr | $ttc |" >> "$logfile"
    logged=$((logged + 1))

    # Flag: context > 80% used (i.e. <20% remaining)
    if [ "$pct" != "?" ] && [ "$pct" -le 20 ] 2>/dev/null; then
      alerts="${alerts}CONTEXT: $role at ${pct}% — save state now\n"
      "claudeCode" context.alert "$target" 20
    fi

  done <<< "$pane_lines"

  # Flag: subscription threshold
  local sub_pct
  sub_pct=$("scrumMaster" subscription 2>/dev/null | grep -oE '[0-9]+%' | head -1 | tr -d '%')
  if [ -n "$sub_pct" ] && [ "$sub_pct" -ge 80 ] 2>/dev/null; then
    alerts="${alerts}SUBSCRIPTION: ${sub_pct}% — throttle team\n"
  fi

  if [ -n "$alerts" ]; then
    warn.log "Metric alerts:\n$alerts"
  fi

  console.log "metrics.log: $logged agents logged to sweep-log.md"
  create.result 0 "$logged"
  return 0
}
hiveMind.metrics.summary() # <?lines:20> # summarize recent sweep-log: trends, burn rates, projections
{
  local lines="${1:-20}"

  local workspace
  workspace="$(git rev-parse --show-toplevel 2>/dev/null)"
  if [ -z "$workspace" ]; then
    error.log "Cannot determine workspace root"
    return 1
  fi

  local logfile="${workspace}/session/metrics/sweep-log.md"
  if [ ! -f "$logfile" ]; then
    warn.log "No sweep-log.md — run hiveMind metrics.log first"
    return 1
  fi

  local total_rows
  total_rows=$(grep -c '^|' "$logfile" | tr -d ' ')
  total_rows=$((total_rows - 2))  # subtract header rows

  echo "=== Metrics Summary (last $lines entries of $total_rows total) ==="
  echo ""

  # Show recent entries
  tail -"$lines" "$logfile"
  echo ""

  # Count agents at risk (context <=20%)
  local at_risk
  at_risk=$(tail -"$lines" "$logfile" | grep -oE '[0-9]+%' | while read pct; do
    p="${pct%\%}"
    [ "$p" -le 20 ] 2>/dev/null && echo "$p"
  done | wc -l | tr -d ' ')

  # Count blocked agents
  local blocked
  blocked=$(tail -"$lines" "$logfile" | grep -cE 'permission|stuck|panel|overlay' || echo 0)

  echo "At risk (<=20% context): $at_risk"
  echo "Blocked states: $blocked"
  echo "Total rows in log: $total_rows"

  create.result 0 "entries=$total_rows at_risk=$at_risk blocked=$blocked"
  return 0
}

hiveMind.plan.create() # <slug> # link a plan from ~/.claude/plans/ to session/plans/ with timestamp, symlink back, git commit
{
  local slug="$1"
  local plans_dir="$HOME/.claude/plans"
  local source_file="$plans_dir/${slug}.md"

  if [ -z "$slug" ]; then
    error.log "Usage: hiveMind plan.create <slug>"
    return 1
  fi

  if [ ! -f "$source_file" ]; then
    error.log "Plan not found: $source_file"
    return 1
  fi

  if [ -L "$source_file" ]; then
    error.log "Already linked (symlink): $source_file"
    return 1
  fi

  # Find session/plans/ dir relative to git workspace
  local workspace
  workspace=$(git rev-parse --show-toplevel 2>/dev/null)
  if [ -z "$workspace" ]; then
    error.log "Not in a git repository"
    return 1
  fi

  local session_plans="$workspace/session/plans"
  mkdir -p "$session_plans"

  # Generate timestamped filename
  local timestamp
  timestamp=$(date -u '+%Y%m%dT%H%M%SZ')
  local target_name="${timestamp}.${slug}.plan.md"
  local target_path="$session_plans/$target_name"

  # Copy to session/plans/
  cp "$source_file" "$target_path"

  # Replace original with symlink
  rm "$source_file"
  ln -s "$target_path" "$source_file"

  # Git add + commit
  (cd "$workspace" && git add "session/plans/$target_name" && \
   git commit -m "Plan linked: $target_name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>")

  success.log "Plan linked: session/plans/$target_name"
  RESULT="session/plans/$target_name"
}
hiveMind.path.fix() # # verify OOSH PATH works, scan SKILL.md files for stale export patterns
{
  local issues=0

  # Step 1: Verify OOSH on PATH
  local otmux_path
  otmux_path=$(which otmux 2>/dev/null)
  if [ -n "$otmux_path" ]; then
    info.log "otmux on PATH: $otmux_path"
  else
    error.log "otmux NOT on PATH — check ~/.bashrc"
    issues=$((issues + 1))
  fi

  local hivemind_path
  hivemind_path=$(which hiveMind 2>/dev/null)
  if [ -n "$hivemind_path" ]; then
    info.log "hiveMind on PATH: $hivemind_path"
  else
    error.log "hiveMind NOT on PATH — check ~/.bashrc"
    issues=$((issues + 1))
  fi

  # Step 2: Verify direct command works (no export prefix needed)
  local test_result
  test_result=$(otmux pane.capture "$(otmux pane.get.target)" 1 2>/dev/null)
  if [ $? -eq 0 ]; then
    info.log "Direct otmux command: OK"
  else
    warn.log "Direct otmux command failed — PATH may not be inherited by subshells"
    issues=$((issues + 1))
  fi

  # Step 3: Scan SKILL.md files for stale PATH patterns
  local agents_dir
  agents_dir=$(private.hiveMind.find.agents.dir 2>/dev/null)
  if [ -n "$agents_dir" ]; then
    local stale_files
    stale_files=$(grep -rlE 'export PATH.*oosh|OOSH PATH Setup|MANDATORY.*run FIRST|cd.*/oosh.*&&' "$agents_dir" 2>/dev/null)
    if [ -n "$stale_files" ]; then
      warn.log "Stale PATH patterns found in:"
      echo "$stale_files"
      issues=$((issues + 1))
    else
      info.log "No stale PATH patterns in SKILL.md files"
    fi
  fi

  # Step 4: Check for compound-command workarounds in settings
  local workspace
  workspace="$(git rev-parse --show-toplevel 2>/dev/null)"
  if [ -n "$workspace" ]; then
    local settings_file="${workspace}/.claude/settings.json"
    if [ -f "$settings_file" ]; then
      if grep -qE 'sleep.*&&.*cd|export PATH' "$settings_file" 2>/dev/null; then
        warn.log "Stale compound-command patterns in settings.json"
        issues=$((issues + 1))
      else
        info.log "settings.json: no stale patterns"
      fi
    fi
  fi

  if [ "$issues" -eq 0 ]; then
    console.log "fix.path: all checks passed — PATH healthy"
    create.result 0 "healthy"
  else
    warn.log "fix.path: $issues issue(s) found"
    create.result 1 "$issues issues"
  fi
  return "$issues"
}

hiveMind.pane.sweep.loop() # <?seconds:30> # continuous sweep + unblock all sessions at interval
{
  local interval="${1:-30}"

  console.log "Starting sweep loop every ${interval}s across all sessions (Ctrl+C to stop)"

  while true; do
    echo ""
    echo "── sweep @ $(date '+%H:%M:%S') ──────────────────────────────────"
    local teams
    teams=$(hiveMind.team.list 2>/dev/null)
    if [ -n "$teams" ]; then
      while read -r session; do
        hiveMind.pane.sweep "$session"
        hiveMind.agent.unblock all "$session"
      done <<< "$teams"
    fi
    sleep "$interval"
  done
}
# ─────────────────────────────────────────────────────────────────────────────
# WATCHDOG — external sweep/unblock loop (runs outside Claude Code)
# ─────────────────────────────────────────────────────────────────────────────

: ${HIVEMIND_WATCHDOG_PID_FILE:=${TMPDIR:-/tmp}/hivemind.watchdog.pid}
: ${HIVEMIND_WATCHDOG_LOG:=${TMPDIR:-/tmp}/hivemind.watchdog.log}
: ${HIVEMIND_WATCHDOG_HEARTBEAT:=${TMPDIR:-/tmp}/hivemind.watchdog.heartbeat}

hiveMind.watchdog() # <?seconds:30> # start external sweep+unblock loop in a new tmux pane
{
  local interval="${1:-30}"

  # Check if already running
  if hiveMind.watchdog.status > /dev/null 2>&1; then
    console.log "Watchdog already running (PID $(cat "$HIVEMIND_WATCHDOG_PID_FILE"))"
    return 0
  fi

  local session
  session=$(private.hiveMind.current.session)
  [ -z "$session" ] && { error.log "Not inside a tmux session"; return 1; }

  # Spawn a new pane running plain bash (no Claude Code = no permission prompts)
  otmux split.v -t "${session}:0" -l 5 \
    "echo \$\$ > '$HIVEMIND_WATCHDOG_PID_FILE' && while true; do touch '$HIVEMIND_WATCHDOG_HEARTBEAT'; echo \"── watchdog @ \$(date '+%H:%M:%S') ──\" >> '$HIVEMIND_WATCHDOG_LOG'; for team in \$(hiveMind team.list 2>/dev/null); do hiveMind agent.unblock all \"\$team\" >> '$HIVEMIND_WATCHDOG_LOG' 2>&1; done; sleep $interval; done"

  # Lock the pane title
  sleep 0.5
  local watchdog_pane
  watchdog_pane=$(otmux panes -t "${session}:0" -F "#{pane_index}" | tail -1)
  otmux pane.lock "${session}:0.${watchdog_pane}" "watchdog" 2>/dev/null

  console.log "Watchdog started in ${session}:0.${watchdog_pane} (every ${interval}s)"
  console.log "Log: $HIVEMIND_WATCHDOG_LOG"
}
hiveMind.watchdog.stop() # # stop the external watchdog loop
{
  if [ ! -f "$HIVEMIND_WATCHDOG_PID_FILE" ]; then
    error.log "No watchdog PID file found"
    return 1
  fi

  local pid
  pid=$(cat "$HIVEMIND_WATCHDOG_PID_FILE")
  if kill -0 "$pid" 2>/dev/null; then
    kill "$pid" 2>/dev/null
    rm -f "$HIVEMIND_WATCHDOG_PID_FILE" "$HIVEMIND_WATCHDOG_HEARTBEAT"
    console.log "Watchdog stopped (PID $pid)"
  else
    rm -f "$HIVEMIND_WATCHDOG_PID_FILE" "$HIVEMIND_WATCHDOG_HEARTBEAT"
    console.log "Watchdog was not running (stale PID file cleaned)"
  fi
}
hiveMind.watchdog.status() # # check if watchdog is running
{
  if [ ! -f "$HIVEMIND_WATCHDOG_PID_FILE" ]; then
    echo "not running"
    return 1
  fi

  local pid
  pid=$(cat "$HIVEMIND_WATCHDOG_PID_FILE")
  if kill -0 "$pid" 2>/dev/null; then
    echo "running (PID $pid)"
    return 0
  else
    echo "not running (stale PID file)"
    rm -f "$HIVEMIND_WATCHDOG_PID_FILE"
    return 1
  fi
}
hiveMind.watchdog.supervisor() # <?interval:30> # check watchdog health and restart if dead/stale
{
  local watchdog_interval="${1:-30}"
  local max_stale=$((watchdog_interval * 3))  # 3x interval = stale

  # Check if watchdog is running (PID alive)
  if ! hiveMind.watchdog.status > /dev/null 2>&1; then
    echo "$(date '+%Y-%m-%d %H:%M:%S') SUPERVISOR: Watchdog dead, restarting..." >> "$HIVEMIND_WATCHDOG_LOG"
    hiveMind.watchdog "$watchdog_interval"
    return $?
  fi

  # Check heartbeat freshness (detect stuck watchdog)
  if [ -f "$HIVEMIND_WATCHDOG_HEARTBEAT" ]; then
    local last_beat now age
    last_beat=$(stat -f %m "$HIVEMIND_WATCHDOG_HEARTBEAT" 2>/dev/null)  # macOS stat
    now=$(date +%s)
    age=$(( now - last_beat ))
    if [ "$age" -gt "$max_stale" ]; then
      echo "$(date '+%Y-%m-%d %H:%M:%S') SUPERVISOR: Watchdog stale (${age}s > ${max_stale}s), restarting..." >> "$HIVEMIND_WATCHDOG_LOG"
      hiveMind.watchdog.stop
      hiveMind.watchdog "$watchdog_interval"
      return $?
    fi
  fi

  console.log "Watchdog healthy"
  return 0
}
# ─────────────────────────────────────────────────────────────────────────────
# USAGE
# ─────────────────────────────────────────────────────────────────────────────

hiveMind.usage()
{
  local this=${0##*/}
  echo "You started"
  echo "$0

  hiveMind - Multi-agent orchestrator for oosh
  Manages REAL Claude Code agents in tmux panes

  Usage:
  $this command     Description
  ─────────────────────────────────────────────────────
  INITIALIZATION:
      init              initialize hivemind with agents
      attach            attach to hivemind session
      detach            detach from session
      join              rejoin agent's Claude session by role name
      kill              shutdown hivemind completely

  AGENTS:
      spawn             spawn a new agent with pane
      list              list all agents
      status            one-line session overview
      focus             focus on agent pane
      resolve           resolve agent name to pane target
      panes             list all agent panes

  ROLES:
      roles             show all role descriptions
      role.list         list roles from .claude/agents/
      role.prompt       output teaching prompt for role
      role.teach        teach role to existing pane

  AGENT LIFECYCLE:
      agent.bootstrap   full bootstrap: pane + claude + teach
      agent.verify      check if agent is alive

  TEAM:
      team.register     register a team session
      team.remove       unregister a team session
      team.switch       set the active team context
      team.active       show the current active team
      team.list         list registered teams (with status)
      team.setup.oosh   create 3-pane oosh team
      team.setup.full   create 4-pane full team
      team.status       show team members as tree
      team.sweep        structured one-line-per-pane status
      team.loop         continuous team.sweep at interval
      pane.titles       set border labels for all agents
      teach             teach agent a role

  MONITORING:
      monitor           capture pane output (by name or all)
      monitor.approve   approve permission prompt in pane
      sweep <s> <?sec>  batch-capture all panes (optional sleep before)
      unblock <name>    detect and resolve stuck prompts (or 'all')
      sweep.loop <sec>  continuous sweep + unblock at interval

  MESSAGING:
      agent.send        send message via best available channel (transport-independent)
      send              send raw keys to agent by name (no Enter)
      send.enter        send message to agent by name (with Enter)
      task              send task to specific agent
      broadcast         broadcast message to all agents

  DISCOVERY:
      discover          discover agents as JSON

  CLAUDE CODE:
      claude            interact with agent via Claude Code
      pane.create       create tmux pane for agent
      process.lookup    resolve Claude PID to pane, role, UUID
      process.list      list all Claude processes with pane mappings
      teams.save        snapshot all agents for restore after restart
      teams.restore     restore teams from snapshot file
      teams.migrate     migrate team to remote machine via ossh
      registry.set      set registry entry for a pane
      registry.remove   remove registry entry for a pane
      registry.list     list registry entries (live discovery + file)
      registry.fix      clean up invalid entries in registry
      team.activate     set active team (alias for team.switch)
      consistency.audit cross-compare all identity sources, show mismatches
      consistency.fix  auto-repair identity mismatches from live truth
  ─────────────────────────────────────────────────────

  Examples:
    $this team.register projectTeam 'OOSH dev team'  # register a team
    $this team.register claudeWoda 'Story team'      # register another
    $this team.switch projectTeam    # set active team context
    $this team.active                # show active team
    $this team.list                  # list all teams with status
    $this team.status                # tree view of active team agents
    $this team.status projectTeam    # tree view of specific team
    $this resolve expert             # find expert's pane in active team
    $this agent.send expert 'fix bug' # send to expert (active team)
    $this send.message expert 'fix bug' # send message by name
    $this agent.monitor expert       # capture 5 lines from expert
    $this role.list                  # list available roles
    $this agent.bootstrap oosh-expert # bootstrap an expert
    $this join product-owner         # rejoin PO's Claude session
    $this team.setup.full            # create full team
  "
}

hiveMind.help() # # show usage information
{
  hiveMind.usage
}

hiveMind.start()
{
  #echo "sourcing init"
  source this
  log.init.colors 2>/dev/null

  # Resolve HIVEMIND_AGENTS_DIR: try known paths first, then search upward
  if [ -z "$HIVEMIND_AGENTS_DIR" ]; then
    if [ -d "/var/dev/Claude/.claude/agents" ]; then
      HIVEMIND_AGENTS_DIR="/var/dev/Claude/.claude/agents"
    elif [ -d "${OOSH_DIR}/../../../.claude/agents" ]; then
      HIVEMIND_AGENTS_DIR="${OOSH_DIR}/../../../.claude/agents"
    else
      HIVEMIND_AGENTS_DIR=$(private.hiveMind.find.agents.dir 2>/dev/null) || true
    fi
  fi

  if [ -z "$1" ]; then
    # When sourced (no args), do NOT scan sessions — caller wants functions only.
    # BUG-T5: source hiveMind was calling hiveMind.status → 30s scan of all sessions.
    if (this.isSourced); then
      return 0
    fi
    hiveMind.status
    return 0
  fi

  this.start "$@"
}

hiveMind.start "$@"
