#!/usr/bin/env bash
# ==============================================================================
# whosaid — local, GPU-accelerated, speaker-attributed transcription (Apple Silicon)
#
# Usage:
#   whosaid setup                 Run bootstrap.sh (deps, models, disk check).
#   whosaid install               Install a stable command in ~/.local/bin.
#   whosaid enroll [Name]         Record a voice sample and name a speaker cluster.
#   whosaid speakers <sub>        Export or import the private speaker registry.
#   whosaid record [--label L]    Record from the mic, then transcribe it.
#   whosaid doctor                Read-only environment/model/device report.
#   whosaid version | --version   Print the installed version.
#   whosaid mcp                   Run the MCP server (stdio) for AI agents / Claude.
#   whosaid <audio> [...]         Transcribe (and, by default, diarize) audio file(s).
#   whosaid ingest <a...> --into D  Transcribe into a dated meeting workspace.
#   whosaid teams ingest <export.json> --into D  Ingest a Teams chat export as a source-tagged corpus.
#   whosaid roll-up <dir>           Rebuild the meeting-workspace index and audit.
#   whosaid reprocess <ws> [M...]   Re-diarize meetings an older diarizer got wrong; keep labels and edits.
#   whosaid index <ws>              Build the workspace search index, graph, and wiki.
#   whosaid search <ws> "<q>"       Exact / meaning / hybrid search over every transcript.
#   whosaid context <ws> <mtg> <t>  Verbatim turns around a timestamp.
#   whosaid graph <ws> <view>       items | item ID | person [Name] | prs | meetings | speakers | commitments.
#   whosaid wiki <ws>               Regenerate <ws>/_WIKI.md from the graph.
#   whosaid watch <sub>             run | install | uninstall | status: hands-free ingest (launchd).
#   whosaid memos <sub>             list | pull | delete | shortcut-recipe: Voice Memos helpers.
#   whosaid help                  This message.
#
# See `whosaid help` for the full flag reference.
# ==============================================================================
set -euo pipefail

# Supply tools missing from GUI/launchd paths while preserving an explicit
# caller PATH (including watch install --env PATH=...) in its chosen order.
export PATH="$PATH:$HOME/.local/bin:$HOME/bin:$HOME/homebrew/bin:$HOME/homebrew/sbin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin"

# ---- interpreter resolution (GitHub issue #52) --------------------------------
# lib/wsconfig.py reads whosaid.toml with tomllib (Python 3.11+). A bare
# `python3` can be older (stock macOS is 3.9), which silently dropped the
# workspace owner, aliases, summarizer model and groups. Resolve ONE interpreter
# here and run every module and inline snippet with "$WHOSAID_PY". Order:
#   1. $WHOSAID_PYTHON (explicit override; used as given, never version-probed)
#   2. first of python3, python3.13, python3.12, python3.11 on PATH that is >= 3.11
#   3. `uv python find '>=3.11'`
# None found: exit 1 for the commands that run Python (others, e.g. transcribe,
# never touch it; doctor reports the gap).
# The `uv run ... python` heavy paths pick their own interpreter and ignore this.
py_ok() {  # py_ok CMD: 0 when CMD is a runnable Python >= 3.11
  "$1" -c 'import sys; sys.exit(sys.version_info < (3, 11))' >/dev/null 2>&1
}

resolve_python() {  # prints the interpreter path; 1 when none qualifies
  local cand found
  if [[ -n "${WHOSAID_PYTHON:-}" ]]; then
    printf '%s\n' "$WHOSAID_PYTHON"
    return 0
  fi
  for cand in python3 python3.13 python3.12 python3.11; do
    found="$(command -v "$cand" 2>/dev/null || true)"
    if [[ -n "$found" ]] && py_ok "$found"; then
      printf '%s\n' "$found"
      return 0
    fi
  done
  if command -v uv >/dev/null 2>&1; then
    found="$(uv python find '>=3.11' 2>/dev/null | head -n 1 || true)"
    if [[ -n "$found" ]] && py_ok "$found"; then
      printf '%s\n' "$found"
      return 0
    fi
  fi
  return 1
}

WHOSAID_PY="$(resolve_python || true)"
if [[ -z "$WHOSAID_PY" ]]; then
  # Only the commands that run a lib/*.py module or inline snippet need it;
  # transcribe/record/enroll/relabel/install/setup/mcp/help/version/doctor do not.
  case "${1:-}" in
    samples|speakers|teams|ingest|backfill|roll-up|rollup|reprocess|commitments|worklist|index|search|context|graph|wiki|watch|memos)
      echo "whosaid: no Python >= 3.11 found; set WHOSAID_PYTHON=/path/to/python3.11+ or run: uv python install 3.13" >&2
      exit 1
      ;;
  esac
fi

# Follow symlinks so an installed ~/.local/bin/whosaid command still finds the
# implementation and model helpers in this checkout.
SOURCE="${BASH_SOURCE[0]}"
while [[ -L "$SOURCE" ]]; do
  SOURCE_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
  SOURCE="$(readlink "$SOURCE")"
  [[ "$SOURCE" != /* ]] && SOURCE="$SOURCE_DIR/$SOURCE"
done
REPO_DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"

# Single source of truth for the released version. Bump this alongside the
# CHANGELOG.md entry and git tag at release time.
WHOSAID_VERSION="1.13.4"

HELPER="$REPO_DIR/lib/transcribe_mlx.py"
DIARIZER="$REPO_DIR/lib/diarize_sherpa.py"
VOICE_REFS="${WHOSAID_VOICE_REFS:-$REPO_DIR/voices}"
RECORDINGS_DIR="$REPO_DIR/recordings"
LANG_CODE_DEFAULT="${WHOSAID_LANG:-en}"
REC_DEVICE="${WHOSAID_REC_DEVICE:-0}"

log() { echo "whosaid: $*" >&2; }

# ---- temp-directory checkout guard --------------------------------------------
# True (0) when the resolved path is under a system temp directory: /tmp,
# /private/tmp, /var/tmp, /private/var/tmp, or the resolved $TMPDIR (macOS's
# per-user/per-process temp dir, e.g. /var/folders/.../T/). A checkout there
# gets purged by the OS, leaving `whosaid install`'s symlink dangling with no
# diagnostic pointing at the real cause — see `cmd_install`, `cmd_doctor`, and
# GitHub issue #10.
is_temp_path() {
  local target="$1"
  local resolved
  resolved="$(cd -P "$target" 2>/dev/null && pwd)" || resolved="$target"
  local roots=(/tmp /private/tmp /var/tmp /private/var/tmp)
  if [[ -n "${TMPDIR:-}" ]]; then
    local resolved_tmpdir
    resolved_tmpdir="$(cd -P "$TMPDIR" 2>/dev/null && pwd)" || resolved_tmpdir="${TMPDIR%/}"
    roots+=("$resolved_tmpdir")
  fi
  local root
  for root in "${roots[@]}"; do
    root="${root%/}"
    [[ -z "$root" ]] && continue
    if [[ "$resolved" == "$root" || "$resolved" == "$root"/* ]]; then
      return 0
    fi
  done
  return 1
}

# ---- shared environment guards (mirrors the transcribe flow's own checks) --
warn_if_not_arm64() {
  if [[ "$(uname -m)" != "arm64" ]]; then
    log "WARNING — MLX is Apple-Silicon only; on $(uname -m) this will be slow or fail."
  fi
}

ensure_uv_and_ffmpeg() {
  if ! command -v uv >/dev/null 2>&1; then
    log "'uv' not found. Run 'whosaid setup' to install verified uv into ~/.local/bin, or install uv and rerun."
    exit 1
  fi
  local missing=()
  command -v ffmpeg >/dev/null 2>&1 || missing+=(ffmpeg)
  command -v ffprobe >/dev/null 2>&1 || missing+=(ffprobe)
  if [[ ${#missing[@]} -gt 0 ]]; then
    log "'${missing[*]}' not found (audio decoding and duration checks need both ffmpeg and ffprobe)."
    log "Install a verified FFmpeg build in ~/.local/bin, ~/bin, or ~/homebrew/bin, then rerun 'whosaid setup'."
    exit 1
  fi
}

diagnose_uv_failure() {
  local errfile="$1"
  if grep -qiE 'certificate|tls|ssl|x509|unknown issuer' "$errfile"; then
    log "TLS certificate verification failed while uv resolved a dependency; verification remains enabled."
    log "For a managed network, install the organization CA in macOS System Keychain, or reinstall the watcher with --env SSL_CERT_FILE=/path/to/ca.pem --env UV_SYSTEM_CERTS=1."
    log "The selected certificate settings are retained on later watch reinstalls unless explicitly replaced."
  fi
}

# ---- help --------------------------------------------------------------------
cmd_help() {
  cat <<'EOF'
whosaid — local, GPU-accelerated, speaker-attributed transcription (Apple Silicon)

USAGE
  whosaid setup [--yes] [--build-ffmpeg]
                                        Install dependencies, pre-download models.
                                        Homebrew is optional; --build-ffmpeg builds
                                        verified FFmpeg source into ~/.local/bin
                                        (requires Command Line Tools).
  whosaid install [--force]            Install/update ~/.local/bin/whosaid. Refuses (unless
                                        --force) when this checkout is under a temp directory,
                                        since the symlink would dangle once it's cleaned up.
  whosaid enroll [Name] [--from FILE [--ss T] [--t D|--to T]] [--force]
                                        Record (or extract from an existing FILE) a voice
                                        sample and name a speaker cluster.
  whosaid record [--label L] [flags]   Record from the mic, then transcribe it.
  whosaid doctor                       Read-only environment/model/device report.
  whosaid version | --version | -V     Print the installed version.
  whosaid mcp                          Run the MCP server (stdio) for AI agents / Claude.
  whosaid <audio> [<audio> ...] [flags]
  whosaid transcribe <audio> [<audio> ...] [flags]
                                        Transcribe (and by default diarize) file(s).
                                        `transcribe` is an optional alias for the
                                        default action (it matches the name of
                                        the whosaid_transcribe MCP tool) and
                                        changes nothing.
  whosaid relabel <base> SPEAKER_02=Jane [SPEAKER_03=Bob ...] [--role NAME=ROLE] [-o DIR]
  whosaid relabel <base> --auto [--forget NAME ...] [--fold-unknown] [--absorb-threshold F] [-o DIR]
                                        Name clusters from the speaker cards and
                                        remember them for future transcripts; --auto
                                        re-applies registry + absorb naming over the
                                        cached sidecar with no re-transcription, also
                                        matching voices/ enrollment clips and dropping
                                        auto-assigned names whose voiceprint is gone.
                                        --forget NAME clears NAME first (repeatable;
                                        it can come back if still enrolled and
                                        matching). Use --fold-unknown to repair
                                        anonymous phantom clusters in a cached sidecar.
  whosaid relabel <base> SPEAKER_NN=Name --no-save [--note TEXT]
                                        Transcript-only label: rename in the sidecar
                                        and outputs, never touch the registry; the
                                        label survives relabel --auto and is marked on
                                        the speaker cards. Replacing a registry print
                                        whose similarity to the cluster is below the
                                        match threshold requires --force.
  whosaid relabel <base> SPEAKER_NN=Name --blend
                                        Average the cluster into Name's saved print
                                        instead of replacing it, so a new recording
                                        strengthens the print (same guard as above).
  whosaid speakers export --out FILE
  whosaid speakers import FILE [--merge|--overwrite]
                                        Export a private, portable speaker-registry backup or
                                        restore one. Import refuses to replace an existing
                                        registry unless --merge (only non-conflicting speakers)
                                        or --overwrite (replaces the whole destination registry)
                                        is explicit. All output files use mode
                                        0600 and atomic replacement. The active registry is
                                        WHOSAID_SPEAKER_DB or ~/.config/whosaid/speakers.json.
  whosaid samples <base> [-o DIR] [--audio FILE] [--per-speaker N]
                  [--seconds S] [--json]
                                        Export one short representative WAV per
                                        speaker cluster, cut from the original
                                        recording, so you can listen and confirm
                                        an identity before trusting an auto-label
                                        or enrolling.
  whosaid teams ingest <export.json> --into DIR [--tz ZONE] [--self NAME] [--dry-run]
                                        Fold a Microsoft Teams chat export (JSON
                                        hand-off from the DOM scraper) into the
                                        workspace: one dated folder per chat per
                                        local day, teams.speakers.txt plus a
                                        teams.diarization.json sidecar tagged
                                        source.kind = "teams-chat". Idempotent.
                                        Names/roles: [teams.names], [teams.roles].
  whosaid ingest <audio> [<audio> ...] --into DIR [--folder-by created|mtime]
                                        [--tz ZONE] [--action-items] [--commitments]
                                        [--min-words N] [--hook CMD] [flags]
                                        Transcribe each file into its own dated
                                        meeting folder (idempotent by content
                                        hash; skips anything already ingested).
  whosaid roll-up <dir> [--action-items] [--similarity-threshold F] [--rebuild]
                        [--refold MEETING[=OLD_MD]]... [--index] [--owner NAME|me] [--all-owners]
                                        Rebuild the workspace index, audit,
                                        action-item corpus, dev-commitments corpus,
                                        and the ranked _WORKLIST-<Owner>.md.
  whosaid reprocess <ws> [MEETING ...] [--dry-run] [--all] [--no-items]
                         [--speakers N] [--min-speakers N] [--max-speakers N]
                         [--expected-speakers A,B] [--match-threshold X]
                         [--engine E] [--hook CMD]
                                        Re-diarize meetings a pre-1.10 diarizer got
                                        wrong (default: those the scan flags; --all
                                        or MEETING names override), reusing the
                                        existing ASR. Backs up to <ws>/_reprocess-backups/,
                                        carries transcript-only labels over, regenerates
                                        action items/commitments, then refolds the corpora.
  whosaid commitments <ws> [--owner NAME|me] [--all-owners] [--json] [-o FILE]
                                        Print the ranked worklist (P1/P2/P3) of
                                        what one person signed up for, across
                                        every meeting, from the roll-up corpora.
  whosaid backfill <ws> [--dry-run]
                                        Apply historical registry names/roles,
                                        refresh commitments and dependent views.
  whosaid index <ws> [--no-embed] [--rebuild]
                                        Build <ws>/_search.db (full text, optional
                                        embeddings, entity graph) and <ws>/_WIKI.md.
  whosaid search <ws> "<query>" [--mode exact|meaning|hybrid] [--speaker S]
                  [--meeting M] [-k N] [--json]
                                        Search every speaker-labeled transcript.
  whosaid context <ws> <meeting> <HH:MM:SS> [--before S] [--after S] [--json]
                                        The verbatim turns around a timestamp.
  whosaid graph <ws> items|item ID|person [Name]|prs|meetings|speakers|commitments [--json]
                                        Entity views over the workspace; commitments
                                        lists the unified CM table
                                        (--owner/--source/--status filters).
  whosaid wiki <ws> [--stdout] [-o FILE]
                                        Regenerate the wiki from the graph.
  whosaid watch run|install|uninstall|status|menubar [--into <ws>] [flags]
                                        Hands-free ingest of new recordings (launchd).
  whosaid watch menubar install [--workspace <ws>] [--interval 10s]
                                        SwiftBar menu bar glyph for the watcher
                                        (idle/recording/syncing/ingesting/done);
                                        menubar uninstall | status manage it.
  whosaid memos list|pull|delete|shortcut-recipe [flags]
                                        Voice Memos helpers (never touch the store).
  whosaid help | -h | --help           This message.

TRANSCRIBE FLAGS
  -o, --outdir DIR    Output directory (default: alongside each input file).
  -m, --model REPO    HuggingFace mlx-community Whisper repo (default: turbo).
      --accurate       Shorthand for mlx-community/whisper-large-v3-mlx (full
                       large-v3; slower, best fidelity on names/numbers).
  -l, --lang CODE     Language code (default: en; use "auto" to auto-detect).
  -f, --format FMT    txt | srt | vtt | tsv | json | all (default: all).
  -n, --name NAME     Output base name (single input only; default: input basename).
      --speakers N     Exact speaker count for diarization (e.g. 2 for a 1:1 call;
                       omit to auto-detect the number of speakers).
      --min-speakers N  Lower bound on the auto-detected speaker count.
      --max-speakers N  Upper bound on the auto-detected speaker count (also lowers
                       the hard cap of 20). Use a range when you know roughly how
                       many people were in the room but not exactly; an estimate
                       that lands on the bound is reported as unreliable.
      --expected-speakers NAMES  Comma-separated roster of people you expect in this
                       recording (repeatable), e.g. --expected-speakers Alice,Bob.
                       Every name must already be a known voice (an enrolled clip or
                       a registry entry). Turns matching one of those voiceprints are
                       pinned to that person; everyone else is clustered normally, and
                       a listed person who never speaks is simply dropped — so a
                       varying-attendance team needs no exact --speakers count.
      --anchor-threshold F  Cosine ≥ F is required before --expected-speakers pins a
                       turn to a known voice. Default 0.70 (WHOSAID_ANCHOR_THRESHOLD).
  -j, --jobs N         Parallel diarization workers for long audio (default: auto).
      --chunk-seconds S Window length for parallel diarization (default: auto).
      --no-chunk       Diarize with sherpa's whole-file clustering (no chunking, no count estimator).
      --absorb-threshold F  Cosine ≥ F folds a still-unnamed cluster into a known
                       voice (registry/enrolled), so a person split across clusters
                       merges into one speaker. Default 0.85 (WHOSAID_ABSORB_THRESHOLD).
                       Applies to voices not placed in this meeting.
      --placed-absorb-threshold F  Lower absorb bar for a voice already placed in this
                       meeting: a still-unnamed cluster folds into it at cosine ≥ F when
                       most of its talk time is closest to that voice and it sits
                       closer to that voice's own cluster than to its voiceprint
                       (talk-share and sibling gates). Needs per-turn
                       voiceprints, so --no-chunk falls back to --absorb-threshold.
                       Default 0.70 (WHOSAID_PLACED_ABSORB_THRESHOLD).
      --match-threshold F  Cosine ≥ F is required before a registry/enrolled voice
                       may claim a cluster (alias --ref-threshold). A cluster whose
                       best candidate scores below F keeps its anonymous SPEAKER_NN
                       label. Default 0.50 (WHOSAID_MATCH_THRESHOLD); raise it if
                       you see wrong names, lower it to catch more.
      --no-diarize     Skip speaker diarization.
      --reuse-asr      Skip Whisper when <out>/<base>.json already exists and go straight
                       to diarization (re-diarize without re-transcribing; errors if the
                       json is missing; incompatible with --no-diarize).
      --diarize        Force diarization on (default already on).
  -h, --help          This message.

  Diarization auto-detects the speaker count, writes <base>.speaker-cards.txt with
  a few representative snippets per speaker (so you can tell who each one is), and
  auto-names anyone already in your local speaker registry. Name the rest with
  'whosaid relabel'. Long recordings (>15 min) are diarized in parallel windows and
  stitched back into consistent speakers automatically — much faster, same result.

RELABEL FLAGS
  <base>              Sidecar path or base name of a prior transcribe (the folder
                      holds <base>.diarization.json). In a meeting workspace the
                      base is "transcript".
  SPEAKER_XX=Name     Assign a name to a cluster and remember its voiceprint.
  --auto              No assignments needed: re-run registry matching + the absorb
                      pass over the cached voiceprints and rewrite <base>.speakers.txt
                      and <base>.speaker-cards.txt. Picks up voices enrolled after
                      the transcript was made — no re-diarization.
  --fold-unknown      With --auto, repair anonymous phantom clusters in a cached
                      sidecar. Requires --auto; fresh problematic auto diarization
                      applies this repair automatically.
  --absorb-threshold F  Absorb-pass cosine threshold for --auto (default 0.85).
  --placed-absorb-threshold F  Accepted, but --auto has no per-turn voiceprints (the
                      sidecar keeps centroids only), so the purity gate cannot run and
                      the absorb bar stays --absorb-threshold. Default 0.70.
  --match-threshold F  Registry/ref match cosine threshold for --auto (alias
                       --ref-threshold; default 0.50). Clusters under it stay SPEAKER_NN.
  --role NAME=ROLE     Tag a named speaker with a role, repeatable (e.g.
                       --role Karen=boss). Conventional roles: self, boss, peer,
                       report, external (free-form allowed). Roles rank action
                       items (boss higher) and mark your own voice (self) so the
                       dev-commitments corpus knows which commitments are yours.
  -o, --outdir DIR    Where the sidecar/outputs live (default: alongside <base>).
  After changing roles, run: whosaid backfill <workspace> --dry-run
  Then apply with: whosaid backfill <workspace>
  Backfill updates historical names/roles and commitments, preserves transcript
  prose and curated corpus items, refuses conflicting meeting commitment edits,
  and refreshes roll-up, worklists, search, graph and wiki. A conflict writes
  nothing; --dry-run reports each meeting without modifying workspace files.

SAMPLES FLAGS
  <base>              Sidecar path or base name of a prior transcribe — same
                      resolution as relabel's <base>.
  -o, --outdir DIR    Output ROOT (default: alongside <base>'s sidecar); clips
                      land in <outdir>/<base>.samples/.
  --audio FILE        Source recording to cut clips from, overriding the
                      sidecar's own "source.path" — required for a sidecar
                      written before source metadata existed (GitHub issue #1
                      parts 2-3).
  --per-speaker N     Clips per cluster, longest segment(s) first (default: 1).
  --seconds S         Max clip length in seconds; a shorter segment exports at
                      its own length (default: 8).
  --json              Also print a final {"samples": [...]} JSON line
                      (default: TSV only — SPEAKER_NN, name, start, duration,
                      path, one line per file).

RECORD FLAGS
  --label L           Suffix the recording filename with -L.
  Any transcribe flag above is also accepted and passed through after recording.

ENROLL FLAGS
  --from FILE         Extract the reference clip from an existing recording instead of
                      recording from the mic (extract -> verify -> save, no interaction).
  --ss T              Start offset into FILE (seconds, e.g. 200 or 12.5; or M:SS / H:MM:SS,
                      e.g. 3:20). Default: start of file. Requires --from.
  --t D               Clip duration (same time formats as --ss). Mutually exclusive with
                      --to. Requires --from.
  --to T              Clip end time (same time formats as --ss); converted to a duration
                      internally. Mutually exclusive with --t. Requires --from.
  --force             Overwrite an existing voices/<Name>.wav (default: refuse). Only
                      meaningful with --from.
  The extracted (or recorded) clip must still clear the same bar as mic enrollment: at
  least 15s long and not silent (mean volume > -85 dB).

INGEST FLAGS
  --into DIR          Meeting workspace root (required; created if missing).
  --folder-by WHEN    Timestamp source for the dated folder: created (container
                      creation_time; default) or mtime (file modification time).
  --tz ZONE           IANA zone for the dated folder name (default: system local
                      zone via /etc/localtime, else UTC).
  --action-items      Also extract action items for each ingested meeting.
  --engine E          Action-items engine: auto (default; Ollama when it is up,
                      else hook, else a skeleton) | ollama (the built-in local
                      summarizer) | claude (OPT-IN, CLOUD: the transcript goes
                      to Anthropic via `claude -p`; never picked by auto) |
                      hook | none. Implies --action-items.
  --commitments       Also extract dev commitments for each ingested meeting
                      (speaker roles from relabel --role rank and own them;
                      default hook: $WHOSAID_COMMITMENTS_HOOK).
  --min-words N       Commitments: content words a clause needs to count
                      (default: [commitments] min_words in whosaid.toml, else
                      2; 0 disables). Fragments such as "I'll do that" are
                      dropped and listed under "Dropped fragments (review)"
                      in commitments.md instead. Implies --commitments.
  --hook CMD          Action-items hook for this run (default: $WHOSAID_ACTION_ITEMS_HOOK).
  --index             After every file is ingested, run
                      'whosaid roll-up <ws> --action-items' then 'whosaid index <ws>'
                      so the new meetings are searchable right away.
  Any transcribe flag above (except -o/--outdir and -n/--name, which ingest
  controls) is accepted and passed through to the transcriber.
  Speaker hints default to the workspace's whosaid.toml [diarize] section
  (speakers / min_speakers / max_speakers / expected_speakers) when the command
  passes no speaker flag of its own.

ROLL-UP FLAGS
  --action-items      Fold each meeting's action-items.md into the deduplicated
                      corpus (_ACTION-ITEMS.md). Hand edits there (status, type,
                      retitle, "(merged AI-NNN)") survive re-runs.
  -o, --out FILE      Index markdown path (default: <workspace>/_INDEX.md).
  --action-items-out FILE
                      Corpus markdown path (default: <workspace>/_ACTION-ITEMS.md).
  --similarity-threshold F
                      Dedupe similarity threshold, 0.5–1.0 (default: 0.82).
                      Pairs within 0.10 below it are listed for review.
  --rebuild           Reset manifest + corpus, rebuild from folders on disk.
  --refold MEETING[=OLD_MD]
                      Re-read one already-folded meeting's regenerated
                      action-items.md (repeatable; implies --action-items).
                      Ids, hand-set status and titles survive; owners follow
                      the new speaker labels. OLD_MD (the meeting's previous
                      action-items.md) lets a hand-retitled item re-match.
                      whosaid reprocess passes it from its backup.
  Roll-up also maintains the dev-commitments corpus — _COMMITMENTS.md +
  _commitments.json (CM-NNN ids) — automatically when per-meeting commitments
  (from ingest --commitments) exist, and regenerates _WORKLIST-<Owner>.md
  (see COMMITMENTS). Dedupe in both corpora is difflib >= the threshold OR,
  when whosaid.toml [search] embed is on and Ollama answers on 127.0.0.1,
  embedding cosine >= [commitments] embed_threshold (default 0.90); any
  embedding failure falls back to difflib for the run.
  --owner NAME|me     Whose worklist to write (default me: the speaker with the
                      'self' role, else whosaid.toml [workspace] owner).
  --all-owners        Also write one _WORKLIST-<Owner>.md per participant.
  --index             Run 'whosaid index <ws>' after the roll-up.
  Commitment source/role/config fingerprints refresh already-folded CM evidence
  while keeping IDs and explicit text, speaker, status and merge edits. Use
  backfill after registry role changes to refresh historical meeting sources.

COMMITMENTS
  whosaid commitments <ws> [--owner NAME|me] [--all-owners] [--json] [-o FILE]
  "What did I sign up for, across every meeting, ranked": a view over the
  roll-up corpora (_commitments.json + _action-items.json), never a store.
  Items are the owner's CM-NNN commitments plus the AI-NNN action items they
  own (name match; '_' and ' ' interchangeable; [workspace] aliases too); an
  action item that reads like one of the commitments folds into that line as
  "(also AI-NNN)". Tiers, deterministic and LLM-free:
    P1  boss-requested, a blocking/urgency cue (urgent, outage, prod issue,
        customer escalation, ...), a deadline cue (tomorrow, eod, by friday,
        2026-09-30, ...), or seen in 3+ meetings
    P2  seen in 2 meetings, requested by anyone, or a strong cue ("I'll own",
        "I will", "I promise") in the latest meeting
    P3  everything else; negated items ("I won't") are never P1
  A cue right after a negator in the same clause (non-urgent, not blocking,
  no rush) does not count. A relative deadline (today, tomorrow, this week,
  by friday) resolves against the meeting it was said in; once that date is
  behind today the line says overdue=YYYY-MM-DD instead of due=..., the
  small overdue weight replaces deadline, and the item is no longer P1 on
  the deadline alone (WHOSAID_TODAY=YYYY-MM-DD pins today).
  Within a tier: score, then most recent, then id. whosaid.toml [commitments]
  overrides the lists and weights: boss = [...], deadline_cues, blocking_cues,
  negators, strong_cues, weak_cues, weights.{boss,blocking,deadline,overdue,
  repeat,recent,strong,requested,negative}, embed_threshold. Roll-up writes
  the same view to <ws>/_WORKLIST-<Owner>.md; ids never renumber; edit
  _COMMITMENTS.md or _ACTION-ITEMS.md, not the worklist.
  --owner NAME|me     A speaker label, or me (default): the 'self'-roled
                      speaker, else [workspace] owner.
  --all-owners        Every participant (markdown concatenated; --json wraps
                      them as {"owners": [...]}).
  --json              {owner, generated_from, items: [{id, source, text, status,
                      tier, score, why, first_seen, last_seen, occurrences,
                      requested_by, negative, also}]}.
  -o, --out FILE      Write there instead of stdout.

WORKSPACE SEARCH
  <ws> is the meeting workspace directory. Every search command also accepts it
  from $WHOSAID_WORKSPACE, or from the current directory when that holds
  _workspace.json or whosaid.toml, so <ws> may be omitted.

  index      Builds <ws>/_search.db: an FTS5 table over every *.speakers.txt
             (dated or hand-named folders alike), optional neural embeddings via
             Ollama nomic-embed-text on 127.0.0.1 (skipped with --no-embed or
             when Ollama is down; exact search still works), and the entity
             tables (person, meeting, action_item, occurrence, commitment,
             pr_mention) derived from the manifest, the action-item corpus, each
             meeting's action-items.md, and the transcripts. Then writes
             <ws>/_WIKI.md. Steps run in order: search, graph, wiki; the first
             failure stops the run and is named. --rebuild drops and recreates
             the database; the file is always safe to delete.
  search     --mode exact (FTS5: words, "phrases", AND/OR/NOT; the default),
             meaning (nearest embeddings), or hybrid (both, fused). meaning and
             hybrid fall back to exact when Ollama is down. --speaker and --meeting
             narrow the hits, -k caps them, --json prints one object per hit.
             Each hit prints "[meeting @ HH:MM:SS] Speaker: ...snippet...".
  context    The verbatim turns --before (default 60 s) and --after (default
             120 s) a timestamp in one meeting, for reading a hit in place.
  graph      items [--owner N] [--requester N] [--status S] [--type T]
             item AI-NNN (detail, occurrences, timestamped commitments)
             person [Name] (talk share, commitments they made and asked for)
             prs (PR numbers mentioned, spoken or written) | meetings (coverage)
             speakers (talk share per speaker across the workspace)
  wiki       Regenerates <ws>/_WIKI.md from the graph; --stdout prints instead.
  Per-workspace settings live in <ws>/whosaid.toml (owner, groups, summarizer,
  search, watch); see the README for the full example.

WATCH
  whosaid watch install --into <ws> [--source DIR] [--seed] [--dry-run]
                        [--interpreter PATH] [--label L] [--interval S]
                        [--offline] [--env K=V ...] [--no-open] [--no-fda]
  whosaid watch run     --into <ws> [--source DIR] [--seed] [--dry-run]
                        [--accurate] [--offline] [--whosaid PATH] [--engine E]
  whosaid watch uninstall [--into <ws> | --label L] [--purge]
  whosaid watch status    [--into <ws> | --label L] [--json]
  install writes a launchd LaunchAgent that watches --source (default: the macOS
  Voice Memos store) and, once a recording's mtime has been stable, stages it
  out of the TCC-protected store and runs 'whosaid ingest --action-items
  --commitments', then roll-up and index. --seed marks the recordings already there as done. Full
  Disk Access is granted to ONE dedicated, ad-hoc-signed interpreter the
  installer provisions; --interpreter PATH reuses a binary you already granted.
  --source works for any folder, not just Voice Memos. --no-fda defaults an
  unset source to ~/Recordings: the FDA toggle needs an admin password (see
  README 'No admin rights?'). uninstall --purge also
  removes the interpreter, state file, and log. run is the same pipeline in the
  foreground (--dry-run lists what would be ingested).

  whosaid memos list [--source DIR] [--json]     Titles and sync state.
  whosaid memos pull [--latest | --title T] [-o DIR]
                                                 Copy a memo out of the store.
  whosaid memos delete "<title>" [--yes]         Delete through the Voice Memos
                                                 app's own Delete Recordings
                                                 intent (a one-action Shortcut),
                                                 never the files or database.
  whosaid memos shortcut-recipe                  How to build that Shortcut.

ENV OVERRIDES
  WHOSAID_MODEL        Default Whisper model repo.
  WHOSAID_LANG         Default language code.
  WHOSAID_VOICE_REFS   Directory of enrollment clips (default: ./voices).
  WHOSAID_SPEAKER_DB   Local speaker registry (default: ~/.config/whosaid/speakers.json).
  WHOSAID_ABSORB_THRESHOLD  Absorb-pass cosine threshold (default: 0.85); a still-
                      unnamed cluster this close to a known voice is folded into it.
  WHOSAID_PLACED_ABSORB_THRESHOLD  Lower absorb bar (default: 0.70) for a voice already
                      placed in the meeting, gated on talk share and sibling margin
                      (transcribe only).
  WHOSAID_MATCH_THRESHOLD  Registry/reference match cosine threshold (default: 0.50);
                      a cluster whose best candidate scores below it stays SPEAKER_NN.
  DIARIZE_EMB_NAME     Speaker-embedding model (default: NeMo TitaNet-small,
                       English-native; set the zh-cn model for Mandarin audio).
  WHOSAID_REC_DEVICE   avfoundation audio device index (default: 0).
  WHOSAID_INSTALL_DIR  Command install directory (default: ~/.local/bin).
  WHOSAID_ACTION_ITEMS_HOOK  Default action-items summarizer hook: gets the
                      transcript on stdin, prints markdown on stdout, and reads
                      WHOSAID_TRANSCRIPT_PATH, WHOSAID_SPEAKERS, WHOSAID_ROLES.
  WHOSAID_WORKSPACE    Default meeting workspace for index/search/context/graph/
                      wiki, for 'whosaid doctor', and for the read-only search
                      tools of 'whosaid mcp'.
  WHOSAID_OWNER        Whose action items a workspace tracks (overrides
                      whosaid.toml [workspace] owner).
  WHOSAID_TODAY        YYYY-MM-DD the worklist treats as today when deciding
                      whether a relative deadline is overdue (default: the
                      clock).
  WHOSAID_OLLAMA       Ollama base URL for embeddings and the built-in summarizer
                      (default: http://127.0.0.1:11434; localhost only).
  WHOSAID_SUMMARIZER_MODEL  Ollama model for the built-in action-items engine
                      (default: qwen2.5:14b).
  WHOSAID_BIN          The whosaid command the watcher runs (default: this script).
  HF_HOME               Hugging Face cache location (Whisper model cache).
  SHERPA_DIARIZE_CACHE  sherpa-onnx diarization model cache location.

Diarization writes <base>.rttm and merges with the Whisper output into
<base>.speakers.txt (the speaker-labeled transcript). Diarization failure never
loses the plain transcript — it warns and the transcript stands on its own.

Nothing — audio, text, or embeddings — ever leaves the machine.
EOF
}

# ---- version -----------------------------------------------------------------
cmd_version() {
  local git_suffix=""
  if command -v git >/dev/null 2>&1; then
    local described=""
    described="$(git -C "$REPO_DIR" describe --tags --always --dirty 2>/dev/null || true)"
    [[ -n "$described" ]] && git_suffix=" (git $described)"
  fi
  echo "whosaid ${WHOSAID_VERSION}${git_suffix}"
}

# ---- install -----------------------------------------------------------------
# whosaid install [--force] [--check-only]
#   --force        Install even if this checkout is under a temp directory.
#   --check-only   INTERNAL (not documented in --help): run only the
#                  temp-directory guard and exit 0/1 without touching the
#                  filesystem. Used by bootstrap.sh to fail fast, before its
#                  multi-minute model downloads, using the same is_temp_path
#                  definition as the real install.
cmd_install() {
  local force=0 check_only=0
  local arg
  for arg in "$@"; do
    case "$arg" in
      --force) force=1 ;;
      --check-only) check_only=1 ;;
      -h|--help) cmd_help; exit 0 ;;
      *) log "install: unknown option: $arg"; exit 1 ;;
    esac
  done

  local install_dir="${WHOSAID_INSTALL_DIR:-$HOME/.local/bin}"
  local destination="$install_dir/whosaid"
  local source="$REPO_DIR/whosaid"

  if is_temp_path "$REPO_DIR"; then
    if [[ "$force" -eq 0 ]]; then
      log "FATAL this checkout lives in a temporary directory:"
      log "FATAL   $REPO_DIR"
      log "FATAL installing from here would create a symlink at $destination that dangles"
      log "FATAL as soon as the OS cleans up this temp directory — the usual symptom is a"
      log "FATAL plain 'command not found' later, with nothing pointing at the real cause."
      log "FATAL move or clone this checkout to a stable location (e.g. ~/code/whosaid) and"
      log "FATAL re-run 'whosaid install' there, or pass --force to install anyway."
      exit 1
    fi
    log "WARNING installing from a temp-directory checkout ($REPO_DIR) — continuing because --force was given."
    log "WARNING the installed symlink at $destination will dangle once this temp directory is cleaned up."
  fi

  if [[ "$check_only" -eq 1 ]]; then
    exit 0
  fi

  mkdir -p "$install_dir"
  if [[ -L "$destination" ]]; then
    local existing=""
    existing="$(readlink "$destination" 2>/dev/null || true)"
    if [[ "$existing" == "$source" ]]; then
      log "✓ command already installed at $destination"
    else
      ln -sfn "$source" "$destination"
      log "✓ updated command symlink at $destination"
    fi
  elif [[ -e "$destination" ]]; then
    log "FATAL refusing to replace existing non-symlink command at $destination"
    log "remove or relocate it explicitly, then run 'whosaid install' again"
    exit 1
  else
    ln -s "$source" "$destination"
    log "✓ installed command at $destination"
  fi

  case ":${PATH:-}:" in
    *":$install_dir:"*) ;;
    *)
      log "NOTE add $install_dir to PATH to run 'whosaid' by name"
      ;;
  esac
}

# ---- doctor --------------------------------------------------------------------
cmd_doctor() {
  echo "whosaid doctor"
  echo "=============="

  local arch os_ver
  arch="$(uname -m)"
  os_ver="$(sw_vers -productVersion 2>/dev/null || echo unknown)"
  if [[ "$arch" == "arm64" ]]; then
    echo "✓ architecture: $arch (Apple Silicon) — macOS $os_ver"
  else
    echo "✗ architecture: $arch — whosaid requires Apple Silicon (arm64) for MLX/Metal"
  fi

  # Interpreter every module runs under (issue #52); whosaid.toml needs >= 3.11.
  if [[ -n "$WHOSAID_PY" ]]; then
    echo "✓ python: $WHOSAID_PY ($("$WHOSAID_PY" -c 'import sys; print("%d.%d.%d" % sys.version_info[:3])' 2>/dev/null || echo unknown))"
  else
    echo "✗ python: no Python >= 3.11 found — fix: uv python install 3.13, or set WHOSAID_PYTHON=/path/to/python3.11+"
  fi

  if command -v brew >/dev/null 2>&1; then
    echo "✓ Homebrew: $(brew --version 2>/dev/null | head -1)"
  else
    echo "✗ Homebrew not found — fix: install from https://brew.sh"
  fi

  if command -v ffmpeg >/dev/null 2>&1; then
    echo "✓ ffmpeg: $(ffmpeg -version 2>/dev/null | head -1)"
  else
    echo "✗ ffmpeg not found — fix: brew install ffmpeg"
  fi

  if command -v uv >/dev/null 2>&1; then
    echo "✓ uv: $(uv --version 2>/dev/null)"
  else
    echo "✗ uv not found — fix: brew install uv"
  fi

  # Note: this check runs from whichever `whosaid` was invoked. If your
  # installed ~/.local/bin/whosaid symlink is itself dangling, `whosaid doctor`
  # by name won't run at all — that's exactly the "command not found" case in
  # GitHub issue #10. Run `./whosaid doctor` from a fresh clone instead.
  local install_dir="${WHOSAID_INSTALL_DIR:-$HOME/.local/bin}"
  local destination="$install_dir/whosaid"
  if [[ -L "$destination" ]]; then
    local install_target=""
    install_target="$(readlink "$destination" 2>/dev/null || true)"
    if [[ ! -e "$destination" ]]; then
      echo "✗ install: $destination -> $install_target is DANGLING (the checkout it pointed at no longer exists — was it in a temporary directory?) — fix: clone/move whosaid to a stable path and run 'whosaid install'"
    elif [[ "$install_target" == "$REPO_DIR/whosaid" ]]; then
      echo "✓ install: $destination -> $REPO_DIR/whosaid"
    else
      echo "  install: $destination -> $install_target (a different checkout than $REPO_DIR)"
    fi
  elif [[ -e "$destination" ]]; then
    echo "  install: $destination exists but is not a symlink — fix: remove it, then run 'whosaid install'"
  else
    echo "  install: no $destination yet — fix: whosaid install"
  fi
  if is_temp_path "$REPO_DIR"; then
    echo "✗ checkout lives in a temp directory ($REPO_DIR) — move it to a stable location"
  fi

  local hf_home
  hf_home="${HF_HOME:-$HOME/.cache/huggingface}"
  if ls -d "$hf_home"/hub/models--mlx-community--whisper-large-v3-turbo* >/dev/null 2>&1; then
    echo "✓ Whisper model cached (turbo) under $hf_home/hub"
  else
    echo "✗ Whisper model not cached — fix: ./whosaid setup"
  fi

  local sherpa_cache
  sherpa_cache="${SHERPA_DIARIZE_CACHE:-$HOME/.cache/sherpa-diarization}"
  if [[ -f "$sherpa_cache/sherpa-onnx-pyannote-segmentation-3-0/model.onnx" ]]; then
    echo "✓ sherpa diarization models cached under $sherpa_cache"
  else
    echo "✗ sherpa diarization models not cached — fix: ./whosaid setup"
  fi

  local emb_name="${DIARIZE_EMB_NAME:-nemo_en_titanet_small.onnx}"
  if [[ -f "$sherpa_cache/$emb_name" ]]; then
    echo "✓ speaker-embedding model cached: $emb_name"
  else
    echo "✗ speaker-embedding model not cached ($emb_name) — fix: ./whosaid setup"
  fi

  local speaker_db="${WHOSAID_SPEAKER_DB:-$HOME/.config/whosaid/speakers.json}"
  if [[ -f "$speaker_db" ]]; then
    local nvoiceprints=""
    if [[ -n "$WHOSAID_PY" ]]; then
      nvoiceprints="$("$WHOSAID_PY" -c 'import json,sys;print(len(json.load(open(sys.argv[1])).get("speakers",[])))' "$speaker_db" 2>/dev/null || echo '?')"
    else
      nvoiceprints="$(grep -c '"name"' "$speaker_db" 2>/dev/null || echo '?')"
    fi
    echo "✓ speaker registry: $speaker_db ($nvoiceprints voiceprint(s))"
  else
    echo "  speaker registry: none yet — name speakers with 'whosaid relabel' (default: $speaker_db)"
  fi

  local voices_found=0
  for f in "$VOICE_REFS"/*.wav; do
    [[ -f "$f" ]] || continue
    voices_found=$((voices_found + 1))
    echo "  voice: $(basename "${f%.wav}")"
  done
  if [[ "$voices_found" -eq 0 ]]; then
    echo "✗ no enrolled voices — fix: ./whosaid enroll"
  else
    echo "✓ $voices_found enrolled voice(s) in $VOICE_REFS"
  fi

  # Workspace search stack (GitHub issue #14): both lines are informational.
  # Ollama is optional (exact search and the hook/none engines work without it)
  # and a missing workspace or module must never fail doctor.
  local ollama_url="${WHOSAID_OLLAMA:-http://127.0.0.1:11434}"
  ollama_url="${ollama_url%/}"
  if ollama_reachable "$ollama_url"; then
    echo "✓ Ollama reachable at $ollama_url (meaning search + built-in summarizer available)"
  else
    echo "  Ollama not reachable at $ollama_url: search falls back to exact; action items use --engine hook or none (optional: brew install ollama; ollama pull nomic-embed-text)"
  fi

  # Summarizer engine (GitHub issue #46): informational like the Ollama lines
  # above. Shows the configured engine; for the opt-in claude engine also
  # resolves the binary and probes `claude -p` under the watcher's launchd-like
  # environment, so an expired Claude Code login surfaces here instead of as a
  # meeting quietly falling back to the skeleton.
  doctor_summarizer

  if [[ -n "${WHOSAID_WORKSPACE:-}" ]]; then
    local ws_status_py="$REPO_DIR/lib/search.py"
    if [[ ! -d "$WHOSAID_WORKSPACE" ]]; then
      echo "  workspace: WHOSAID_WORKSPACE=$WHOSAID_WORKSPACE is not a directory"
    elif [[ ! -f "$ws_status_py" ]]; then
      echo "  workspace: $WHOSAID_WORKSPACE (index status unavailable: lib/search.py missing)"
    else
      local ws_status="" ws_rc=0
      ws_status="$("$WHOSAID_PY" "$ws_status_py" status "$WHOSAID_WORKSPACE" 2>&1)" || ws_rc=$?
      if [[ "$ws_rc" -eq 0 ]]; then
        echo "✓ workspace: $WHOSAID_WORKSPACE"
      else
        echo "  workspace: $WHOSAID_WORKSPACE has no index yet (fix: whosaid index \"$WHOSAID_WORKSPACE\")"
      fi
      [[ -n "$ws_status" ]] && printf '%s\n' "$ws_status" | sed 's/^/    /'
    fi
  else
    echo "  workspace: WHOSAID_WORKSPACE not set; set it to your meeting workspace to see its index status here and to give 'whosaid mcp' a default"
  fi

  # Menu bar plugin (GitHub issue #24): informational, never fails doctor.
  if [[ -f "$WATCH_PY" ]] && [[ -n "$WHOSAID_PY" ]]; then
    "$WHOSAID_PY" "$WATCH_PY" menubar status 2>/dev/null | sed 's/^/  menu bar: /' || true
  fi

  echo "--- avfoundation audio devices ---"
  ffmpeg -f avfoundation -list_devices true -i "" 2>&1 | awk '/AVFoundation audio devices:/{show=1} show' || true
}

# doctor_summarizer: the [summarizer] engine lines of `whosaid doctor`
# (GitHub issue #46). The engine is read exactly the way the summarizer reads
# it (lib/wsconfig.py load_config over wsconfig.resolve_workspace), and when
# the opt-in claude engine is configured the binary is resolved with
# claude_engine.resolve_bin and probed with one cheap `claude -p` call under
# the watcher's launchd-like environment — a minimal env -i carrying only HOME
# and the agent PATH from lib/watch.py, the env the launchd agent runs with —
# so an expired login shows up in doctor instead of as a meeting falling back
# to the skeleton. Every failure is a printed line; must never fail doctor.
doctor_summarizer() {
  local lib_dir="$REPO_DIR/lib"
  if [[ -z "$WHOSAID_PY" ]]; then
    echo "  summarizer engine: unknown (needs Python >= 3.11; set WHOSAID_PYTHON)"
    return 0
  fi
  if [[ ! -f "$lib_dir/wsconfig.py" ]]; then
    echo "  summarizer engine: unknown (lib/wsconfig.py missing)"
    return 0
  fi
  "$WHOSAID_PY" - "$lib_dir" <<'PY' 2>&1 || echo "  summarizer engine: unknown (engine check failed)"
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

sys.path.insert(0, sys.argv[1])
PROBE_TIMEOUT = 30      # s; a hung CLI must not hang doctor (Ollama check: 1.5 s)
PROBE_MODEL = "haiku"   # the cheapest call; login health is model-independent

try:
    import wsconfig
    try:
        ws = wsconfig.resolve_workspace(None)
    except SystemExit:  # no workspace configured: report the DEFAULTS engine (auto)
        ws = Path(sys.argv[1]) / ".doctor-no-workspace"
    cfg = wsconfig.load_config(ws)
    engine = str(cfg["summarizer"].get("engine") or "auto").strip()
    src = "whosaid.toml" if (ws / wsconfig.CONFIG_NAME).is_file() else "default"
except Exception as e:  # noqa: BLE001  (this probe must never fail doctor)
    print(f"  summarizer engine: unknown (cannot read config: {e})")
    sys.exit(0)

if engine != "claude":
    try:
        import workspace
        known = engine in workspace.ENGINES
    except Exception:
        known = True  # cannot validate; just report the configured name
    if not known:
        print(f"✗ summarizer engine: {engine!r} is unknown (expected one of "
              f"{', '.join(workspace.ENGINES)}) — fix [summarizer] engine in {wsconfig.CONFIG_NAME}")
    elif engine == "auto":
        print(f"  summarizer engine: auto ({src}) — auto never picks claude; claude runs "
              f'only when [summarizer] engine = "claude"')
    else:
        print(f"  summarizer engine: {engine} ({src})")
    sys.exit(0)  # no claude probe: auto never picks claude and locals never call it

print(f"  summarizer engine: claude ({src}) — opt-in: action items send the transcript to Anthropic")
try:
    import claude_engine
except Exception as e:  # noqa: BLE001
    print(f"✗ cannot import lib/claude_engine.py ({e}) — fix: reinstall whosaid or file an issue")
    sys.exit(0)
binary = claude_engine.resolve_bin(str(cfg["summarizer"].get("claude_bin") or ""))
if not binary:
    print(f"✗ claude binary not found — fix: install the Claude Code CLI, or set "
          f"[summarizer] claude_bin or {claude_engine.BIN_ENV} to its path")
    sys.exit(0)
print(f"✓ claude binary: {binary}")

try:
    import watch
except Exception as e:  # noqa: BLE001
    print(f"✗ cannot import lib/watch.py for the watcher agent PATH ({e}); `claude -p` probe skipped")
    sys.exit(0)

# The launchd watcher runs with a minimal environment; reproduce it exactly:
# HOME plus the agent PATH from lib/watch.py and nothing else (a minimal
# `env -i`), so a login that only works in this interactive shell is caught
# here. argv mirrors claude_engine's isolated call shape.
env = {"HOME": os.environ.get("HOME") or str(Path.home()), "PATH": watch.AGENT_PATH}
argv = [binary, "-p", "Reply with the single word ok.", "--model", PROBE_MODEL,
        *claude_engine.ISOLATION]
cwd = tempfile.mkdtemp(prefix="whosaid-doctor-")
try:
    try:
        proc = subprocess.run(argv, capture_output=True, text=True, env=env, cwd=cwd,
                              timeout=PROBE_TIMEOUT)
    except subprocess.TimeoutExpired:
        print(f"✗ `claude -p` timed out after {PROBE_TIMEOUT}s under the watcher's "
              "launchd-like environment (HOME + agent PATH) — every meeting would fall "
              "back while it hangs")
        sys.exit(0)
    except OSError as e:
        print(f"✗ cannot run {binary}: {e}")
        sys.exit(0)
finally:
    shutil.rmtree(cwd, ignore_errors=True)
if proc.returncode == 0 and (proc.stdout or "").strip():
    print(f"✓ `claude -p` answered under the watcher's launchd-like environment "
          f"(HOME + agent PATH, model {PROBE_MODEL})")
else:
    detail = " ".join(((proc.stderr or "") + " " + (proc.stdout or "")).split())[:200]
    print(f"✗ `claude -p` failed under the watcher's launchd-like environment "
          f"(exit {proc.returncode}): {detail or 'no output'}")
    print(f"  fix: {claude_engine.AUTH_HINT}")
sys.exit(0)
PY
}

# ollama_reachable URL: 0 when GET URL/api/tags answers within 1.5 s. curl when
# present, else python3 urllib; never raises under set -e.
ollama_reachable() {
  local url="$1"
  if command -v curl >/dev/null 2>&1; then
    curl -fsS --max-time 1.5 -o /dev/null "$url/api/tags" >/dev/null 2>&1 && return 0
    return 1
  fi
  "$WHOSAID_PY" - "$url" <<'PY' >/dev/null 2>&1
import sys, urllib.request
try:
    with urllib.request.urlopen(sys.argv[1] + "/api/tags", timeout=1.5) as r:
        sys.exit(0 if r.status == 200 else 1)
except Exception:
    sys.exit(1)
PY
}

# ---- enroll --------------------------------------------------------------------
RAINBOW_PASSAGE="When the sunlight strikes raindrops in the air, they act as a prism and form a rainbow. The rainbow is a division of white light into many beautiful colors. These take the shape of a long round arch, with its path high above, and its two ends apparently beyond the horizon. There is, according to legend, a boiling pot of gold at one end. People look, but no one ever finds it. When a man looks for something beyond his reach, his friends say he is looking for the pot of gold at the end of the rainbow."

# Enrollment clips (mic or --from) must clear this floor.
MIN_ENROLL_SECONDS=15

# is_valid_time TIME — TIME is seconds ("200", "12.5") or M:SS / H:MM:SS
# ("3:20", "1:02:03"). Validated with a bash regex BEFORE it ever reaches
# ffmpeg (which accepts both forms itself, but we want a clear error here).
is_valid_time() {
  [[ "$1" =~ ^[0-9]+(\.[0-9]+)?$ ]] && return 0
  [[ "$1" =~ ^[0-9]{1,2}:[0-9]{1,2}(\.[0-9]+)?$ ]] && return 0
  [[ "$1" =~ ^[0-9]{1,2}:[0-9]{1,2}:[0-9]{1,2}(\.[0-9]+)?$ ]] && return 0
  return 1
}

# time_to_seconds TIME — converts a value already validated by is_valid_time
# into plain seconds, for arithmetic (bash 3.2 has no float math, so awk).
time_to_seconds() {
  local t="$1"
  if [[ "$t" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
    printf '%s' "$t"
    return
  fi
  awk -F: -v t="$t" 'BEGIN{
    n = split(t, a, ":");
    if (n == 2) printf "%.3f", a[1]*60 + a[2];
    else if (n == 3) printf "%.3f", a[1]*3600 + a[2]*60 + a[3];
    else printf "0";
  }'
}

# verify_enroll_clip FILE LABEL [SILENCE_HINT]
# Shared duration + silence gate used by BOTH the mic capture path and the
# --from extraction path, so the two can never drift apart. Exits 1 with a
# clear message on failure (caller's EXIT trap handles temp-file cleanup).
# LABEL is the noun used in messages ("recording" for mic keeps the original
# wording byte-for-byte; anything else — e.g. "clip" — uses SILENCE_HINT for
# the silence-specific follow-up instead of the mic-permission fix).
verify_enroll_clip() {
  local file="$1" label="$2" silence_hint="${3:-}"

  local dur
  dur="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$file" 2>/dev/null || echo 0)"
  if ! awk -v d="$dur" -v m="$MIN_ENROLL_SECONDS" 'BEGIN{exit !((d+0) >= m)}'; then
    if [[ "$label" == "recording" ]]; then
      log "recording too short (${dur}s) — need at least ${MIN_ENROLL_SECONDS}s of speech. Try again and read the whole passage."
    else
      log "$label too short (${dur}s) — need at least ${MIN_ENROLL_SECONDS}s of speech."
    fi
    exit 1
  fi

  local ffout meandb
  ffout="$(ffmpeg -hide_banner -i "$file" -af volumedetect -f null - 2>&1)" || true
  meandb=""
  if [[ "$ffout" =~ mean_volume:\ (-?[0-9.]+)\ dB ]]; then
    meandb="${BASH_REMATCH[1]}"
  fi
  if [[ -n "$meandb" ]] && awk -v m="$meandb" 'BEGIN{exit !((m+0) <= -85)}'; then
    log "$label is SILENT (mean volume ${meandb} dB)."
    if [[ "$label" == "recording" ]]; then
      log "macOS denies an unauthorized app's microphone by feeding it silent zeros — no error is raised."
      log "Fix: System Settings -> Privacy & Security -> Microphone -> enable your terminal app, then re-run 'whosaid enroll'."
    else
      log "$silence_hint"
    fi
    exit 1
  fi
}

# whosaid enroll [Name] [--from FILE [--ss T] [--t D | --to T]] [--force]
cmd_enroll() {
  warn_if_not_arm64
  ensure_uv_and_ffmpeg

  local name="" from="" ss="" t_dur="" to="" force=0
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --from)  from="${2:-}"; shift 2 ;;
      --ss)    ss="${2:-}"; shift 2 ;;
      --t)     t_dur="${2:-}"; shift 2 ;;
      --to)    to="${2:-}"; shift 2 ;;
      --force) force=1; shift ;;
      -h|--help) cmd_help; exit 0 ;;
      -*)
        log "enroll: unknown option: $1"
        log "usage: whosaid enroll [Name] [--from FILE [--ss T] [--t D|--to T]] [--force]"
        exit 1
        ;;
      *)
        if [[ -z "$name" ]]; then
          name="$1"
        else
          log "enroll: unexpected argument '$1'"
          exit 1
        fi
        shift
        ;;
    esac
  done

  if [[ -n "$t_dur" && -n "$to" ]]; then
    log "enroll: --t and --to are mutually exclusive — pass a duration or an end time, not both."
    exit 1
  fi
  if [[ ( -n "$ss" || -n "$t_dur" || -n "$to" ) && -z "$from" ]]; then
    log "enroll: --ss/--t/--to require --from FILE."
    exit 1
  fi

  if [[ -z "$name" ]]; then
    read -r -p "whosaid: name for this voice (e.g. Alice): " name
  fi
  if [[ ! "$name" =~ ^[A-Za-z0-9_-]+$ ]]; then
    log "invalid name '$name' — use only letters, numbers, underscore, hyphen."
    exit 1
  fi

  mkdir -p "$VOICE_REFS"
  local dest="$VOICE_REFS/$name.wav"

  # ---- from-file path: extract -> diarize-grade clip check -> move --------
  if [[ -n "$from" ]]; then
    if [[ ! -f "$from" ]]; then
      log "enroll: file not found: $from"
      exit 1
    fi
    if [[ -e "$dest" && "$force" -ne 1 ]]; then
      log "enroll: '$dest' already exists — pass --force to overwrite."
      exit 1
    fi

    local start="0"
    if [[ -n "$ss" ]]; then
      if ! is_valid_time "$ss"; then
        log "enroll: invalid --ss '$ss' — use seconds (200, 12.5) or M:SS / H:MM:SS."
        exit 1
      fi
      start="$ss"
    fi

    local extract_dur=""
    if [[ -n "$t_dur" ]]; then
      if ! is_valid_time "$t_dur"; then
        log "enroll: invalid --t '$t_dur' — use seconds (200, 12.5) or M:SS / H:MM:SS."
        exit 1
      fi
      extract_dur="$t_dur"
    elif [[ -n "$to" ]]; then
      if ! is_valid_time "$to"; then
        log "enroll: invalid --to '$to' — use seconds (200, 12.5) or M:SS / H:MM:SS."
        exit 1
      fi
      # ffmpeg's `-to` is relative to the seeked start with `-ss` before `-i`
      # on some versions, not an absolute end time — so rather than pass
      # `-ss START -to END` and depend on that semantics, compute the
      # duration ourselves and pass `-t DURATION`, which is unambiguous.
      local start_s to_s
      start_s="$(time_to_seconds "$start")"
      to_s="$(time_to_seconds "$to")"
      extract_dur="$(awk -v a="$start_s" -v b="$to_s" 'BEGIN{printf "%.3f", (b - a)}')"
      if awk -v d="$extract_dur" 'BEGIN{exit !((d+0) <= 0)}'; then
        log "enroll: --to ($to) must be after --ss ($start)."
        exit 1
      fi
    fi

    local tmpdir tmp
    tmpdir="$(mktemp -d -t whosaid-enroll)"
    tmp="$tmpdir/clip.wav"
    trap 'rm -rf "$tmpdir"' EXIT

    if [[ -n "$extract_dur" ]]; then
      ffmpeg -hide_banner -loglevel warning -y -ss "$start" -i "$from" -t "$extract_dur" -ac 1 -ar 16000 "$tmp" \
        || { log "enroll: ffmpeg extraction failed."; exit 1; }
    else
      ffmpeg -hide_banner -loglevel warning -y -ss "$start" -i "$from" -ac 1 -ar 16000 "$tmp" \
        || { log "enroll: ffmpeg extraction failed."; exit 1; }
    fi

    if [[ ! -s "$tmp" ]]; then
      log "enroll: extraction produced no audio — check --ss/--t/--to fall within '$from'."
      exit 1
    fi

    verify_enroll_clip "$tmp" "clip" \
      "check --ss/--t/--to select a segment where the person is actually speaking, and that '$from' is the right file."

    mv "$tmp" "$dest"
    rm -rf "$tmpdir"
    trap - EXIT
    log "✓ enrolled '$name' -> $dest"
    log "future transcripts will label this voice '$name'."
    return
  fi

  # ---- mic recording path (unchanged) --------------------------------------
  echo "============================================================================" >&2
  echo "$RAINBOW_PASSAGE" | fold -s -w 76 >&2
  echo "============================================================================" >&2
  echo "Press Enter to start recording; read the passage aloud at a normal pace." >&2
  echo "Recording stops automatically after 45s — or press Ctrl-C once you finish the paragraph." >&2
  read -r _ || true

  local tmpdir tmp
  tmpdir="$(mktemp -d -t whosaid-enroll)"
  tmp="$tmpdir/capture.m4a"
  trap 'rm -rf "$tmpdir"' EXIT

  log "recording... (up to 45s)"
  # Ctrl-C must stop ffmpeg (which finalizes the file) WITHOUT killing this
  # script — a no-op INT trap keeps bash alive so verification still runs.
  set +e
  trap ':' INT
  ffmpeg -hide_banner -loglevel warning -f avfoundation -i ":$REC_DEVICE" \
    -ac 1 -c:a aac -b:a 96k -t 45 -y "$tmp" </dev/null
  rc=$?
  trap - INT
  set -e
  if [[ "$rc" -ne 0 && "$rc" -ne 255 ]]; then
    log "ffmpeg exited with an unexpected error (code $rc)."
  fi

  if [[ ! -s "$tmp" ]]; then
    log "recording failed — no audio was captured (ffmpeg exit $rc)."
    exit 1
  fi

  verify_enroll_clip "$tmp" "recording"

  ffmpeg -hide_banner -loglevel warning -y -i "$tmp" -ac 1 -ar 16000 "$dest"
  rm -rf "$tmpdir"
  trap - EXIT

  log "✓ enrolled '$name' -> $dest"
  log "future transcripts will label this voice '$name'."
}

# ---- record --------------------------------------------------------------------
cmd_record() {
  warn_if_not_arm64
  ensure_uv_and_ffmpeg

  local label=""
  local extra_args=()
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --label) label="$2"; shift 2 ;;
      *) extra_args+=("$1"); shift ;;
    esac
  done

  mkdir -p "$RECORDINGS_DIR"
  local ts out
  ts="$(date -u +%Y%m%dT%H%M%SZ)"
  if [[ -n "$label" ]]; then
    local slug
    slug="$(printf '%s' "$label" | tr -c 'A-Za-z0-9_-' '-')"
    out="$RECORDINGS_DIR/${ts}-${slug}.m4a"
  else
    out="$RECORDINGS_DIR/${ts}.m4a"
  fi

  log "recording to $out — press Ctrl-C to stop (auto-stops after 3h)."
  # Ctrl-C must stop ffmpeg (which finalizes the m4a) WITHOUT killing this
  # script — a no-op INT trap keeps bash alive so verify + transcribe still run.
  set +e
  trap ':' INT
  ffmpeg -hide_banner -loglevel warning -f avfoundation -i ":$REC_DEVICE" \
    -ac 1 -c:a aac -b:a 96k -t 10800 -y "$out" </dev/null
  rc=$?
  trap - INT
  set -e
  if [[ "$rc" -ne 0 && "$rc" -ne 255 ]]; then
    log "ffmpeg exited with an unexpected error (code $rc)."
  fi

  if [[ ! -s "$out" ]]; then
    log "recording failed — no audio was captured (ffmpeg exit $rc)."
    exit 1
  fi

  local dur
  dur="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$out" 2>/dev/null || echo "?")"
  log "captured ${dur}s -> $out"

  local ffout meandb
  ffout="$(ffmpeg -hide_banner -i "$out" -af volumedetect -f null - 2>&1)" || true
  meandb=""
  if [[ "$ffout" =~ mean_volume:\ (-?[0-9.]+)\ dB ]]; then
    meandb="${BASH_REMATCH[1]}"
  fi
  if [[ -n "$meandb" ]] && awk -v m="$meandb" 'BEGIN{exit !((m+0) <= -85)}'; then
    log "recording saved at $out but it is SILENT (mean volume ${meandb} dB)."
    log "macOS denies an unauthorized app's microphone by feeding it silent zeros — no error is raised."
    log "Fix: System Settings -> Privacy & Security -> Microphone -> enable your terminal app, then re-run."
    exit 4
  fi

  # ${arr[@]+...} guard: expanding an EMPTY array errs under set -u on bash 3.2
  # (macOS system bash), which would crash a plain `whosaid record`.
  cmd_transcribe "$out" ${extra_args[@]+"${extra_args[@]}"}
}

# ---- transcribe (default flow) --------------------------------------------------
cmd_transcribe() {
  [[ -f "$HELPER" ]] || { log "helper not found at $HELPER"; exit 1; }

  local MODEL="${WHOSAID_MODEL:-mlx-community/whisper-large-v3-turbo}"
  local LANG_CODE="$LANG_CODE_DEFAULT"
  local FORMAT="all"
  local OUTDIR=""
  local NAME=""
  local DIARIZE=1
  local REUSE_ASR=0
  local SPEAKERS=""
  local JOBS=""
  local CHUNK_SECONDS=""
  local NO_CHUNK=0
  local ABSORB=""
  local PLACED_ABSORB=""
  local MATCH_THRESHOLD=""
  local MIN_SPEAKERS=""
  local MAX_SPEAKERS=""
  local EXPECTED_SPEAKERS=()
  local ANCHOR_THRESHOLD=""
  local AUDIO=()

  while [[ $# -gt 0 ]]; do
    case "$1" in
      -o|--outdir)  OUTDIR="$2"; shift 2 ;;
      -m|--model)   MODEL="$2"; shift 2 ;;
      --accurate)   MODEL="mlx-community/whisper-large-v3-mlx"; shift ;;
      -l|--lang)    LANG_CODE="$2"; shift 2 ;;
      -f|--format)  FORMAT="$2"; shift 2 ;;
      -n|--name)    NAME="$2"; shift 2 ;;
      --no-diarize) DIARIZE=0; shift ;;
      --diarize)    DIARIZE=1; shift ;;
      --reuse-asr)  REUSE_ASR=1; shift ;;
      --speakers)   SPEAKERS="$2"; shift 2 ;;
      --min-speakers) MIN_SPEAKERS="$2"; shift 2 ;;
      --max-speakers) MAX_SPEAKERS="$2"; shift 2 ;;
      --expected-speakers) EXPECTED_SPEAKERS+=("$2"); shift 2 ;;
      --anchor-threshold) ANCHOR_THRESHOLD="$2"; shift 2 ;;
      -j|--jobs)    JOBS="$2"; shift 2 ;;
      --chunk-seconds) CHUNK_SECONDS="$2"; shift 2 ;;
      --absorb-threshold) ABSORB="$2"; shift 2 ;;
      --placed-absorb-threshold) PLACED_ABSORB="$2"; shift 2 ;;
      --match-threshold|--ref-threshold) MATCH_THRESHOLD="$2"; shift 2 ;;
      --no-chunk)   NO_CHUNK=1; shift ;;
      -h|--help)    cmd_help; exit 0 ;;
      --version|-V) cmd_version; exit 0 ;;
      --)           shift; while [[ $# -gt 0 ]]; do AUDIO+=("$1"); shift; done ;;
      -*)           log "unknown option: $1"; cmd_help; exit 1 ;;
      *)            AUDIO+=("$1"); shift ;;
    esac
  done

  [[ ${#AUDIO[@]} -eq 0 ]] && { log "no audio file given."; cmd_help; exit 1; }
  [[ -n "$NAME" && ${#AUDIO[@]} -gt 1 ]] && { log "--name only works with a single input."; exit 1; }

  if [[ "$REUSE_ASR" == "1" && "$DIARIZE" != "1" ]]; then
    log "ERROR --reuse-asr re-diarizes an existing transcript; it cannot be combined with --no-diarize."
    exit 1
  fi

  warn_if_not_arm64
  ensure_uv_and_ffmpeg

  log "model=$MODEL  lang=$LANG_CODE  format=$FORMAT  diarize=$DIARIZE  (GPU/Metal via MLX)"
  SECONDS=0
  for audio in "${AUDIO[@]}"; do
    [[ -f "$audio" ]] || { log "file not found: $audio"; exit 1; }
    local out="${OUTDIR:-$(dirname "$audio")}"
    mkdir -p "$out"
    local dur
    dur="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$audio" 2>/dev/null || echo "?")"
    log "→ $audio  (${dur}s)  →  $out/"

    # Derive a dot-safe output base. mlx-whisper's writer runs its own
    # os.path.splitext on the base, so any '.' in the name (e.g. "v1.2") would
    # truncate every output file. Map anything but [A-Za-z0-9_-] to '_' and pass
    # this base explicitly so the CLI and both helpers agree on the filenames.
    local base="${NAME:-$(basename "${audio%.*}")}"
    base="$(printf '%s' "$base" | tr -c 'A-Za-z0-9_-' '_')"

    if [[ "$REUSE_ASR" == "1" && ! -f "$out/$base.json" ]]; then
      log "ERROR --reuse-asr: no existing transcript at $out/$base.json to reuse."
      exit 1
    fi

    local helper_args=("$audio" --model "$MODEL" --outdir "$out" --lang "$LANG_CODE" --format "$FORMAT" --name "$base")

    local uv_err uv_rc=0 uv_status=()
    if [[ "$REUSE_ASR" == "1" ]]; then
      log "reusing existing transcript: $out/$base.json (ASR skipped)"
    else
      uv_err="$(mktemp "${TMPDIR:-/tmp}/whosaid-uv.XXXXXX")"
      # Keep progress/warnings live on stderr, preserve stdout for callers, and
      # wait for tee to finish before inspecting the captured diagnostic.
      {
        if uv run --quiet --with mlx-whisper python "$HELPER" "${helper_args[@]}" \
            2>&1 1>&3 | tee "$uv_err" >&2; then
          uv_rc=0
        else
          uv_status=("${PIPESTATUS[@]}")
          uv_rc="${uv_status[0]}"
          if [[ "$uv_rc" -eq 0 ]]; then uv_rc="${uv_status[1]}"; fi
        fi
      } 3>&1
      if [[ "$uv_rc" -ne 0 ]]; then
        diagnose_uv_failure "$uv_err"
        rm -f "$uv_err"
        exit "$uv_rc"
      fi
      rm -f "$uv_err"
    fi

    local txt="$out/$base.txt"
    if [[ ! -s "$txt" ]]; then
      log "ERROR expected transcript not found or empty: $txt"
      exit 1
    fi
    log "✓ $txt"
    echo "----- first lines -----" >&2
    head -c 600 "$txt" >&2; echo >&2

    # --- speaker diarization (local; never fatal to the transcript) -------------
    # Only the .json output feeds the diarizer merge, so this whole block — and the
    # exit-3 gate below — only applies when --format actually produced one.
    if [[ "$DIARIZE" == "1" && -f "$out/$base.json" ]]; then
      if [[ -f "$DIARIZER" ]]; then
        local dargs=("$audio" --whisper-json "$out/$base.json" --outdir "$out" --name "$base")
        [[ -n "$SPEAKERS" ]] && dargs+=(--num-speakers "$SPEAKERS")
        [[ -n "$MIN_SPEAKERS" ]] && dargs+=(--min-speakers "$MIN_SPEAKERS")
        [[ -n "$MAX_SPEAKERS" ]] && dargs+=(--max-speakers "$MAX_SPEAKERS")
        # bash 3.2 + `set -u`: expanding an EMPTY array is an unbound-variable error,
        # so the count guard is required, not cosmetic.
        if [[ ${#EXPECTED_SPEAKERS[@]} -gt 0 ]]; then
          local exp=""
          for exp in "${EXPECTED_SPEAKERS[@]}"; do dargs+=(--expected-speakers "$exp"); done
        fi
        [[ -n "$ANCHOR_THRESHOLD" ]] && dargs+=(--anchor-threshold "$ANCHOR_THRESHOLD")
        [[ -n "$JOBS" ]] && dargs+=(--jobs "$JOBS")
        [[ -n "$CHUNK_SECONDS" ]] && dargs+=(--chunk-seconds "$CHUNK_SECONDS")
        [[ "$NO_CHUNK" == "1" ]] && dargs+=(--no-chunk)
        [[ -n "$ABSORB" ]] && dargs+=(--absorb-threshold "$ABSORB")
        [[ -n "$PLACED_ABSORB" ]] && dargs+=(--placed-absorb-threshold "$PLACED_ABSORB")
        [[ -n "$MATCH_THRESHOLD" ]] && dargs+=(--ref-threshold "$MATCH_THRESHOLD")
        if [[ -d "$VOICE_REFS" ]]; then
          for ref in "$VOICE_REFS"/*.wav "$VOICE_REFS"/*.m4a; do
            [[ -f "$ref" ]] || continue
            local refname
            refname="$(basename "${ref%.*}")"
            dargs+=(--ref "$refname=$ref")
          done
        fi
        # A re-run must not be satisfied by the previous run's speaker file: the
        # exit-3 gate below only checks existence, so drop the stale one first
        # (the diarizer rewrites the other sidecars itself).
        [[ "$REUSE_ASR" == "1" ]] && rm -f "$out/$base.speakers.txt"
        local diar_rc=0
        uv run --quiet --with sherpa-onnx --with numpy python "$DIARIZER" "${dargs[@]}" || diar_rc=$?
        if [[ "$diar_rc" -ne 0 ]]; then
          log "WARN diarization failed; plain transcript is unaffected."
          if [[ "$REUSE_ASR" == "1" ]]; then
            log "plain transcript kept at $txt; re-diarization failed (rc=$diar_rc)"
            exit 3
          fi
        fi
      else
        log "WARN diarizer script not found at $DIARIZER; skipping diarization for $audio."
      fi

      local speakers_txt="$out/$base.speakers.txt"
      if [[ ! -s "$speakers_txt" ]]; then
        log "plain transcript saved at $txt; diarization failed (see warnings above)"
        exit 3
      fi
      log "✓ $speakers_txt (speaker-labeled)"
    elif [[ "$DIARIZE" == "1" ]]; then
      log "WARN Whisper json output not found (format=$FORMAT); skipping diarization for $audio."
    fi
  done
  log "done in ${SECONDS}s."
}

# ---- sidecar resolution (shared by relabel + samples) -------------------------
# resolve_sidecar TARGET OUTDIR CALLER_LABEL
# Resolves TARGET (a sidecar path or a <base> name) to its <base>.diarization.json
# sidecar: TARGET itself as a file, then TARGET.diarization.json in cwd, then
# OUTDIR/TARGET.diarization.json. Sets RESOLVED_SIDECAR on success. Logs
# "$CALLER_LABEL: could not find..." and exits 1 on failure. Call this directly
# (never inside a subshell/command substitution) so that exit is real — bash's
# `local x=$(...)` masks a command substitution's exit status from `set -e`.
RESOLVED_SIDECAR=""
resolve_sidecar() {
  local target="$1" outdir="$2" label="$3"
  if [[ -f "$target" ]]; then
    RESOLVED_SIDECAR="$target"
  elif [[ -f "$target.diarization.json" ]]; then
    RESOLVED_SIDECAR="$target.diarization.json"
  elif [[ -n "$outdir" && -f "$outdir/$target.diarization.json" ]]; then
    RESOLVED_SIDECAR="$outdir/$target.diarization.json"
  else
    log "$label: could not find a diarization sidecar for '$target'"
    log "  looked for: $target, ./$target.diarization.json${outdir:+, $outdir/$target.diarization.json}"
    exit 1
  fi
}

# ---- relabel -----------------------------------------------------------------
# After reading the speaker cards, put real names on clusters and remember them.
#   whosaid relabel <base|sidecar.diarization.json> SPEAKER_02=Jane SPEAKER_03=Bob
#                    [--role NAME=ROLE ...] [-o DIR]
# Applies names to the cached diarization sidecar (no re-transcription), persists
# each named voiceprint to the local registry (~/.config/whosaid/speakers.json by
# default) so the person is auto-named in future transcripts, and rewrites the
# speaker-labeled transcript + cards. Transcript-only labels via --no-save rename
# the sidecar + outputs without touching the registry, survive `--auto`, and are
# marked on the speaker cards.
cmd_relabel() {
  local target="" outdir="" auto=0 fold_unknown=0 specs=() extra=() roles=() forgets=()
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -o|--outdir) outdir="$2"; shift 2 ;;
      --auto)      auto=1; shift ;;
      --forget)    [[ $# -ge 2 ]] || { log "relabel: --forget wants a NAME"; exit 1; }
                   forgets+=(--forget "$2"); shift 2 ;;
      --fold-unknown) fold_unknown=1; shift ;;
      --absorb-threshold) extra+=(--absorb-threshold "$2"); shift 2 ;;
      --placed-absorb-threshold) extra+=(--placed-absorb-threshold "$2"); shift 2 ;;
      --ref-threshold|--match-threshold) extra+=(--ref-threshold "$2"); shift 2 ;;
      --role)       [[ "$2" == *=* ]] || { log "relabel: --role wants NAME=ROLE (e.g. --role Karen=boss)"; exit 1; }
                    roles+=(--save-role "$2"); shift 2 ;;
      --no-save)   extra+=(--no-save); shift ;;
      --force)     extra+=(--force); shift ;;
      --blend)     extra+=(--blend); shift ;;
      --note)      extra+=(--note "$2"); shift 2 ;;
      *=*)         specs+=(--save-speaker "$1"); shift ;;
      *)           if [[ -z "$target" ]]; then target="$1"; else log "relabel: unexpected arg '$1'"; exit 1; fi; shift ;;
    esac
  done
  [[ -z "$target" ]] && { log "relabel: need a base name or sidecar path. e.g. whosaid relabel meeting SPEAKER_02=Jane (or --auto)"; exit 1; }
  if [[ ${#specs[@]} -eq 0 && ${#roles[@]} -eq 0 && "$auto" -eq 0 ]]; then
    log "relabel: need at least one SPEAKER_XX=Name assignment, or --auto to re-apply registry + absorb naming"; exit 1
  fi
  if [[ "$fold_unknown" -eq 1 && "$auto" -eq 0 ]]; then
    log "relabel: --fold-unknown requires --auto (it repairs cached auto-diarization clusters)"
    exit 1
  fi

  if [[ ${#forgets[@]} -gt 0 && "$auto" -eq 0 ]]; then
    log "relabel: --forget requires --auto (it clears a name, then re-runs naming)"
    exit 1
  fi

  resolve_sidecar "$target" "$outdir" relabel
  local sidecar="$RESOLVED_SIDECAR"
  local sdir; sdir="$(cd -P "$(dirname "$sidecar")" && pwd)"
  log "relabel: using sidecar $sidecar"
  local refs=()
  if [[ "$auto" -eq 1 ]]; then
    extra+=(--auto)
    # --auto matches enrollment clips (voices/, e.g. `enroll --from FILE`) the same
    # way transcribe does: each clip is a NAME=CLIP --ref voice.
    if [[ -d "$VOICE_REFS" ]]; then
      local ref refname
      for ref in "$VOICE_REFS"/*.wav "$VOICE_REFS"/*.m4a; do
        [[ -f "$ref" ]] || continue
        refname="$(basename "${ref%.*}")"
        refs+=(--ref "$refname=$ref")
      done
    fi
  fi
  [[ "$fold_unknown" -eq 1 ]] && extra+=(--fold-unknown)
  uv run --quiet --with sherpa-onnx --with numpy python "$DIARIZER" \
    --relabel "$sidecar" --outdir "$sdir" ${specs[@]+"${specs[@]}"} ${extra[@]+"${extra[@]}"} ${roles[@]+"${roles[@]}"} \
    ${forgets[@]+"${forgets[@]}"} ${refs[@]+"${refs[@]}"}
  log "relabel: refresh historical commitments and views with: whosaid backfill <workspace>"
}

# ---- samples ------------------------------------------------------------------
# Per-speaker sample-clip export for human verification (GitHub issue #1):
#   whosaid samples <base|sidecar.diarization.json> [-o DIR] [--audio FILE]
#                    [--per-speaker N] [--seconds S] [--json]
# Cuts one short representative WAV per SPEAKER_NN cluster from the ORIGINAL
# recording, so you can listen and confirm an identity before trusting an
# auto-label or enrolling — see the README's "Speaker identity" section for how
# that differs from enrolling/relabeling. Resolves <base> to its sidecar the
# same way `relabel` does; -o is also the output ROOT (default: alongside the
# sidecar), under which <base>.samples/ is created. All picking/cutting logic
# lives in lib/samples.py (stdlib-only; plain python3, no uv).
cmd_samples() {
  local target="" outdir="" audio="" per_speaker="" seconds="" want_json=0
  while [[ $# -gt 0 ]]; do
    case "$1" in
      -o|--outdir)   outdir="$2"; shift 2 ;;
      --audio)       audio="$2"; shift 2 ;;
      --per-speaker) per_speaker="$2"; shift 2 ;;
      --seconds)     seconds="$2"; shift 2 ;;
      --json)        want_json=1; shift ;;
      -h|--help)     cmd_help; exit 0 ;;
      -*)            log "samples: unknown option: $1"; exit 1 ;;
      *)             if [[ -z "$target" ]]; then target="$1"; else log "samples: unexpected arg '$1'"; exit 1; fi; shift ;;
    esac
  done
  [[ -z "$target" ]] && { log "samples: need a base name or sidecar path. e.g. whosaid samples meeting"; exit 1; }

  resolve_sidecar "$target" "$outdir" samples
  local sidecar="$RESOLVED_SIDECAR"
  log "samples: using sidecar $sidecar"

  local pyargs=("$sidecar")
  [[ -n "$outdir" ]] && pyargs+=(--outdir "$outdir")
  [[ -n "$audio" ]] && pyargs+=(--audio "$audio")
  [[ -n "$per_speaker" ]] && pyargs+=(--per-speaker "$per_speaker")
  [[ -n "$seconds" ]] && pyargs+=(--seconds "$seconds")
  [[ "$want_json" -eq 1 ]] && pyargs+=(--json)

  "$WHOSAID_PY" "$REPO_DIR/lib/samples.py" "${pyargs[@]}"
}

# whosaid teams ingest <export.json> --into DIR [--tz ZONE] [--self NAME] [--dry-run]
# Non-audio chat adapter (issue #49); all logic lives in lib/teams_chat.py (stdlib-only).
cmd_teams() {
  if want_help "$@"; then cmd_help; exit 0; fi
  if [[ $# -eq 0 ]]; then
    log "teams: usage: whosaid teams ingest <export.json> --into DIR [--tz ZONE] [--self NAME] [--dry-run]"
    exit 1
  fi
  "$WHOSAID_PY" "$REPO_DIR/lib/teams_chat.py" "$@"
}

# ---- speakers -----------------------------------------------------------------
# Migration stays stdlib-only: a new machine can restore identities without
# downloading or initializing diarization models.
cmd_speakers() {
  [[ $# -gt 0 ]] || { log "speakers: need export or import. usage: whosaid speakers export --out FILE | import FILE [--merge|--overwrite]"; exit 1; }
  case "$1" in
    -h|--help) exec "$WHOSAID_PY" "$REPO_DIR/lib/speaker_registry.py" "$@" ;;
    export|import) ;;
    *) log "speakers: unknown subcommand '$1'. usage: whosaid speakers export --out FILE | import FILE [--merge|--overwrite]"; exit 1 ;;
  esac
  exec "$WHOSAID_PY" "$REPO_DIR/lib/speaker_registry.py" "$@"
}

# ---- ingest / roll-up (issue #2: meeting workspace) ----------------------------
# whosaid ingest <audio...> --into DIR [--folder-by created|mtime] [--tz ZONE]
#                [--action-items] [--engine E] [--commitments] [--min-words N]
#                [--hook CMD] [--index] [transcribe flags...]
# Transcribe each file into <into>/<YYYY-MM-DD-HHMM>/ with base name "transcript"
# and keep the source audio beside it so roll-up's audit can hash it. Idempotent:
# a file whose sha256 already appears in <into>/_workspace.json (written by
# roll-up) is skipped BEFORE any transcription. Folder naming, hashing, and
# action items live in lib/workspace.py (stdlib-only; plain python3 runs it).
# --engine picks the action-items engine (auto|ollama|claude|hook|none; issues #14, #44) and
# implies --action-items; --index runs roll-up + index once every file is in.
cmd_ingest() {
  local into="" folder_by="created" tz_arg="" want_action_items=0 hook="" engine="" want_index=0 want_commitments=0
  local min_words=""
  local passthrough=() AUDIO=()

  while [[ $# -gt 0 ]]; do
    case "$1" in
      --into)         into="$2"; shift 2 ;;
      --folder-by)    folder_by="$2"; shift 2 ;;
      --tz)           tz_arg="$2"; shift 2 ;;
      --action-items) want_action_items=1; shift ;;
      --engine)       engine="$2"; want_action_items=1; shift 2 ;;
      --commitments)  want_commitments=1; shift ;;
      --min-words)    min_words="$2"; want_commitments=1; shift 2 ;;
      --hook)         hook="$2"; shift 2 ;;
      --index)        want_index=1; shift ;;
      # ingest owns the output layout and base name
      -o|--outdir) log "ingest: -o/--outdir is not allowed — ingest places each meeting itself"; exit 1 ;;
      -n|--name)   log "ingest: -n/--name is not allowed — ingest names its outputs 'transcript'"; exit 1 ;;
      # transcribe flags that pass straight through
      -m|--model|-l|--lang|-f|--format|--speakers|--min-speakers|--max-speakers|--expected-speakers|--anchor-threshold|-j|--jobs|--chunk-seconds|--absorb-threshold|--placed-absorb-threshold|--match-threshold|--ref-threshold)
                      passthrough+=("$1" "$2"); shift 2 ;;
      --accurate|--diarize|--no-diarize|--no-chunk)
                      passthrough+=("$1"); shift ;;
      --)            shift; while [[ $# -gt 0 ]]; do AUDIO+=("$1"); shift; done ;;
      -h|--help)     cmd_help; exit 0 ;;
      -*)            log "unknown option: $1"; cmd_help; exit 1 ;;
      *)             AUDIO+=("$1"); shift ;;
    esac
  done

  [[ -z "$into" ]] && { log "ingest: --into DIR is required (the meeting workspace root)"; exit 1; }
  [[ ${#AUDIO[@]} -eq 0 ]] && { log "no audio file given."; cmd_help; exit 1; }
  case "$folder_by" in
    created|mtime) ;;
    *) log "ingest: --folder-by must be 'created' or 'mtime' (got '$folder_by')"; exit 1 ;;
  esac

  mkdir -p "$into"

  # Workspace speaker hints (whosaid.toml [diarize], issue #59): a default cap for
  # every ingest into this workspace. Any speaker flag on the command line (the
  # watcher's [watch] hints included) replaces them entirely, so the two never mix.
  local has_speaker_flag=0 arg
  for arg in ${passthrough[@]+"${passthrough[@]}"}; do
    case "$arg" in --speakers|--min-speakers|--max-speakers|--expected-speakers) has_speaker_flag=1 ;; esac
  done
  if [[ "$has_speaker_flag" -eq 0 ]]; then
    local ws_hints line
    ws_hints="$("$WHOSAID_PY" "$REPO_DIR/lib/wsconfig.py" speaker-args "$into")" \
      || { log "ingest: fix [diarize] in $into/whosaid.toml"; exit 1; }
    while IFS= read -r line; do
      [[ -n "$line" ]] && passthrough+=("$line")
    done <<< "$ws_hints"
    [[ -n "$ws_hints" ]] && log "speaker hints from whosaid.toml [diarize]: $(tr '\n' ' ' <<< "$ws_hints")"
  fi

  # Local zone for the dated folder name: explicit --tz wins, else the system's
  # IANA zone from /etc/localtime; if neither resolves, leave --tz off and let
  # workspace.py default to UTC.
  local tz=""
  if [[ -n "$tz_arg" ]]; then
    tz="$tz_arg"
  else
    local localtime_link=""
    localtime_link="$(readlink /etc/localtime 2>/dev/null || true)"
    if [[ "$localtime_link" == *zoneinfo/* ]]; then
      tz="${localtime_link##*zoneinfo/}"
    else
      log "NOTE could not detect the local timezone; folder names default to UTC (pass --tz ZONE to override)"
    fi
  fi
  if [[ -n "$tz" ]]; then
    "$WHOSAID_PY" -c 'import sys; from zoneinfo import ZoneInfo; ZoneInfo(sys.argv[1])' "$tz" 2>/dev/null \
      || { log "ingest: unknown timezone '$tz'"; exit 1; }
  fi

  local audio sha existing fname
  for audio in "${AUDIO[@]}"; do
    [[ -f "$audio" ]] || { log "file not found: $audio"; exit 1; }

    # identity + idempotence: skip BEFORE any transcription work
    sha="$("$WHOSAID_PY" "$REPO_DIR/lib/workspace.py" hash "$audio")" \
      || { log "ingest: hash failed for $audio"; exit 1; }
    existing="$("$WHOSAID_PY" -c '
import json, sys
sha, manifest = sys.argv[1], sys.argv[2]
try:
    meetings = json.load(open(manifest)).get("meetings", [])
except Exception:
    sys.exit(0)
for m in meetings:
    if isinstance(m, dict) and m.get("source_sha256") == sha:
        print(m.get("folder", ""))
        break
' "$sha" "$into/_workspace.json")"
    if [[ -n "$existing" ]]; then
      log "already ingested as $existing (skipping) — $audio"
      continue
    fi

    # dated folder name
    if [[ "$folder_by" == "mtime" ]]; then
      # workspace.py's folder-name always prefers the creation_time tag, so the
      # mtime mode is computed here from the filesystem stamp directly.
      local epoch date_tz="$tz"
      [[ -z "$date_tz" ]] && date_tz="UTC"
      epoch="$(stat -f %m "$audio")"
      fname="$(TZ="$date_tz" date -r "$epoch" +%Y-%m-%d-%H%M)"
    else
      local fn_args=(folder-name "$audio")
      if [[ -n "$tz" ]]; then fn_args+=(--tz "$tz"); fi
      fname="$("$WHOSAID_PY" "$REPO_DIR/lib/workspace.py" "${fn_args[@]}")" \
        || { log "ingest: could not derive a folder name for $audio"; exit 1; }
    fi

    # a same-named folder already holding another meeting gets -2, -3, ... suffixes
    local base_fname="$fname" n=2
    while [[ -d "$into/$fname" ]] && [[ -n "$(ls -A "$into/$fname")" ]]; do
      fname="$base_fname-$n"
      n=$((n + 1))
    done
    [[ "$fname" != "$base_fname" ]] && log "folder $base_fname already occupied; using $fname"

    local folder="$into/$fname"
    mkdir -p "$folder"
    log "→ $audio  →  $folder/"

    # WHOSAID_INGEST_DRYRUN (undocumented test hook): skip the model call but
    # leave a stub transcript so the rest of the ingest plumbing can be checked.
    if [[ -n "${WHOSAID_INGEST_DRYRUN:-}" ]]; then
      log "(dryrun) transcription skipped; would run: transcribe $audio -o $folder -n transcript"
      printf '[dryrun stub transcript]\n' > "$folder/transcript.txt"
    else
      # Subshell: cmd_transcribe signals "transcript ok, diarization failed"
      # with exit 3 — that must warn here, not abort the ingest loop.
      local rc=0
      ( cmd_transcribe "$audio" -o "$folder" -n transcript ${passthrough[@]+"${passthrough[@]}"} ) || rc=$?
      if [[ "$rc" -ne 0 ]]; then
        if [[ "$rc" -eq 3 ]]; then
          log "WARN diarization incomplete for $fname; plain transcript stands"
        else
          log "ERROR transcription failed (exit $rc): $audio"
          exit "$rc"
        fi
      fi
    fi

    # keep the source beside the transcript so roll-up's audit can hash it
    local copied=1
    cp -n "$audio" "$folder/" || { copied=0; log "WARN source copy skipped for $fname"; }

    # upsert a minimal manifest entry so ingest alone is idempotent: without it an
    # immediate re-ingest (no roll-up in between) sees no sha in _workspace.json
    # and duplicates the meeting into a -2 folder. roll-up's scan_meeting keeps
    # ingested_at and recomputes every other field from the folder contents.
    if [[ "$copied" == "1" ]]; then
      "$WHOSAID_PY" -c '
import json, os, sys, tempfile
from datetime import datetime, timezone

path, folder, src, sha = sys.argv[1:5]
try:
    with open(path) as f:
        data = json.load(f)
    if not (isinstance(data, dict) and isinstance(data.get("meetings"), list)):
        raise ValueError("unexpected manifest shape")
except Exception:
    data = {"meetings": []}
data["meetings"] = [
    m for m in data["meetings"]
    if not (isinstance(m, dict) and m.get("folder") == folder)
]
data["meetings"].append({
    "folder": folder,
    "source_name": src,
    "source_sha256": sha,
    "ingested_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
})
d = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(dir=d, prefix=".workspace-", suffix=".tmp")
try:
    with os.fdopen(fd, "w") as f:
        f.write(json.dumps(data, indent=2) + "\n")
    os.replace(tmp, path)
except BaseException:
    try:
        os.unlink(tmp)
    except OSError:
        pass
    raise
' "$into/_workspace.json" "$fname" "$(basename "$audio")" "$sha" \
        || log "WARN could not update manifest; roll-up will rebuild it"
    fi

    if [[ "$want_action_items" == "1" ]]; then
      local transcript="$folder/transcript.speakers.txt"
      if [[ ! -s "$transcript" ]]; then
        transcript="$folder/transcript.txt"
        log "NOTE no speaker-labeled transcript in $fname; using the plain transcript for action items"
      fi
      if [[ -s "$transcript" ]]; then
        local ai_args=(action-items --transcript "$transcript" --json-out "$folder/action-items.json")
        if [[ -n "$hook" ]]; then ai_args+=(--hook "$hook"); fi
        if [[ -n "$engine" ]]; then ai_args+=(--engine "$engine"); fi
        "$WHOSAID_PY" "$REPO_DIR/lib/workspace.py" "${ai_args[@]}" \
          || log "WARN action items failed for $fname (continuing)"
      else
        log "WARN no transcript found in $fname; skipping action items"
      fi
    fi

    if [[ "$want_commitments" == "1" ]]; then
      local transcript="$folder/transcript.speakers.txt"
      if [[ ! -s "$transcript" ]]; then
        transcript="$folder/transcript.txt"
        log "NOTE no speaker-labeled transcript in $fname; using the plain transcript for commitments"
      fi
      if [[ -s "$transcript" ]]; then
        local cm_args=(commitments --transcript "$transcript" --json-out "$folder/commitments.json")
        if [[ -n "$min_words" ]]; then cm_args+=(--min-words "$min_words"); fi
        "$WHOSAID_PY" "$REPO_DIR/lib/workspace.py" "${cm_args[@]}" \
          || log "WARN commitments failed for $fname (continuing)"
      else
        log "WARN no transcript found in $fname; skipping commitments"
      fi
    fi
    log "✓ ingested $fname/"
  done

  if [[ "$want_index" == "1" ]]; then
    log "→ roll-up $into --action-items"
    cmd_rollup "$into" --action-items
    log "→ index $into"
    cmd_index "$into"
  else
    log "next: whosaid roll-up $into --action-items --index"
  fi
}

# whosaid roll-up <workspace-dir> [--action-items] [-o OUT] [--action-items-out OUT]
#                 [--similarity-threshold F] [--rebuild] [--index]
# Thin bash front for `python3 lib/workspace.py rollup`: rebuilds _INDEX.md, the
# _workspace.json manifest, and (with --action-items) the deduplicated corpus.
# --index then runs `whosaid index <ws>` (issue #14), so this is a plain call
# rather than an exec.
# Historical registry changes are explicit and auditable.
cmd_backfill() {
  if want_help "$@"; then
    uv run --quiet --with numpy python "$REPO_DIR/lib/backfill.py" --help
    return $?
  fi
  local dry=0 arg
  for arg in "$@"; do [[ "$arg" == "--dry-run" ]] && dry=1; done
  uv run --quiet --with numpy python "$REPO_DIR/lib/backfill.py" "$@" || return $?
  if [[ "$dry" == 0 && "${1:-}" != "--help" && "${1:-}" != "-h" ]]; then
    cmd_rollup "$1" --action-items --all-owners --index
  fi
}

cmd_rollup() {
  local wsdir="" py_args=() want_index=0
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --action-items)     py_args+=("$1"); shift ;;
      --rebuild)          py_args+=("$1"); shift ;;
      --all-owners)       py_args+=("$1"); shift ;;
      --index)            want_index=1; shift ;;
      -o|--out)           py_args+=("$1" "$2"); shift 2 ;;
      --action-items-out) py_args+=("$1" "$2"); shift 2 ;;
      --similarity-threshold) py_args+=("$1" "$2"); shift 2 ;;
      --refold)           [[ $# -ge 2 ]] || { log "roll-up: --refold needs a meeting folder"; exit 1; }
                          py_args+=("$1" "$2"); shift 2 ;;
      --owner)            py_args+=("$1" "$2"); shift 2 ;;
      -h|--help)          cmd_help; exit 0 ;;
      -*)                 log "unknown option: $1"; cmd_help; exit 1 ;;
      *)                  if [[ -z "$wsdir" ]]; then wsdir="$1"; else log "roll-up: unexpected arg '$1'"; exit 1; fi; shift ;;
    esac
  done
  [[ -z "$wsdir" ]] && { log "roll-up: need a workspace directory. e.g. whosaid roll-up meetings/"; exit 1; }
  "$WHOSAID_PY" "$REPO_DIR/lib/workspace.py" rollup "$wsdir" ${py_args[@]+"${py_args[@]}"}
  if [[ "$want_index" == "1" ]]; then
    cmd_index "$wsdir"
  fi
}

# whosaid reprocess <ws> [MEETING ...] [flags]
# Thin bash front for `python3 lib/reprocess.py run`, which owns the logic (scan,
# backup, `transcribe --reuse-asr`, label carry-over, regeneration, roll-up). It
# calls back into this launcher for transcribe/relabel/roll-up via WHOSAID_BIN.
cmd_reprocess() {
  require_module "$REPO_DIR/lib/reprocess.py" reprocess
  [[ $# -eq 0 ]] && { log "reprocess: need a workspace directory. e.g. whosaid reprocess meetings/ --dry-run"; exit 1; }
  WHOSAID_BIN="${WHOSAID_BIN:-$REPO_DIR/whosaid}" \
    exec "$WHOSAID_PY" "$REPO_DIR/lib/reprocess.py" run "$@"
}

# whosaid commitments <ws> [--owner NAME|me] [--all-owners] [--json] [-o FILE]
# The ranked personal worklist (issue #13): a read-only view over the roll-up
# corpora, printed on demand. Same rules and output as the _WORKLIST-<Owner>.md
# the roll-up writes; `python3 lib/workspace.py worklist` does the work.
cmd_commitments() {
  want_help "$@" && { help_section "COMMITMENTS"; return 0; }
  require_module "$REPO_DIR/lib/workspace.py" commitments
  [[ $# -eq 0 || "${1:-}" == -* ]] && { log "commitments: need a workspace directory. e.g. whosaid commitments meetings/ --owner me"; exit 1; }
  exec "$WHOSAID_PY" "$REPO_DIR/lib/workspace.py" worklist "$@"
}

# ---- workspace search / graph / wiki / watch / memos (issue #14) ----------------
# Thin bash fronts for the stdlib modules in lib/: every remaining argument is
# passed through untouched, so the modules own their flag sets. <ws> may be
# omitted on the command line; the modules then resolve it from
# $WHOSAID_WORKSPACE or the current directory (lib/wsconfig.py).
SEARCH_PY="$REPO_DIR/lib/search.py"
GRAPH_PY="$REPO_DIR/lib/graph.py"
WATCH_PY="$REPO_DIR/lib/watch.py"

# help_section TITLE: print one block of `whosaid help` (from the TITLE line
# up to the next all-caps section header), so `whosaid search --help` answers
# with the relevant part instead of the whole reference.
help_section() {
  # No awk `exit`: reading to the end keeps cmd_help clear of SIGPIPE under pipefail.
  cmd_help | awk -v title="$1" '
    $0 == title { show = 1; print; next }
    show && /^[A-Z][A-Z -]+$/ { show = 0 }
    show { print }
  '
}

# want_help ARGS...: 0 when -h/--help appears anywhere in ARGS.
want_help() {
  local arg
  for arg in "$@"; do
    case "$arg" in -h|--help) return 0 ;; esac
  done
  return 1
}

# require_module PATH LABEL: fail loud if a lib module is missing from this checkout.
require_module() {
  [[ -f "$1" ]] || { log "$2: module not found at $1 (is this checkout complete?)"; exit 1; }
}

# whosaid index <ws> [--no-embed] [--rebuild]
# Three steps in order, stopping at the first failure and naming it:
#   1. python3 lib/search.py build <ws> [--no-embed] [--rebuild]   (FTS5 + embeddings)
#   2. python3 lib/graph.py build <ws>                             (entity tables)
#   3. python3 lib/graph.py wiki <ws>                              (_WIKI.md)
cmd_index() {
  if want_help "$@"; then help_section "WORKSPACE SEARCH"; exit 0; fi
  require_module "$SEARCH_PY" index
  require_module "$GRAPH_PY" index

  local ws="" build_args=()
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --no-embed|--rebuild) build_args+=("$1"); shift ;;
      -*) log "index: unknown option: $1"; log "usage: whosaid index <ws> [--no-embed] [--rebuild]"; exit 1 ;;
      *)  if [[ -z "$ws" ]]; then ws="$1"; else log "index: unexpected arg '$1'"; exit 1; fi; shift ;;
    esac
  done
  local ws_args=()
  [[ -n "$ws" ]] && ws_args+=("$ws")

  local step
  step="search: "$WHOSAID_PY" lib/search.py build"
  "$WHOSAID_PY" "$SEARCH_PY" build ${ws_args[@]+"${ws_args[@]}"} ${build_args[@]+"${build_args[@]}"} \
    || { log "index: step failed: $step"; exit 1; }
  step="graph: "$WHOSAID_PY" lib/graph.py build"
  "$WHOSAID_PY" "$GRAPH_PY" build ${ws_args[@]+"${ws_args[@]}"} \
    || { log "index: step failed: $step"; exit 1; }
  step="wiki: "$WHOSAID_PY" lib/graph.py wiki"
  "$WHOSAID_PY" "$GRAPH_PY" wiki ${ws_args[@]+"${ws_args[@]}"} \
    || { log "index: step failed: $step"; exit 1; }
  log "✓ index complete${ws:+ ($ws)}"
}

# whosaid search <ws> "<query>" [--mode exact|meaning|hybrid] [--speaker S]
#                [--meeting M] [-k N] [--json]        -> lib/search.py query ...
cmd_search() {
  if want_help "$@"; then help_section "WORKSPACE SEARCH"; exit 0; fi
  if [[ $# -eq 0 ]]; then
    log "search: need a query. usage: whosaid search <ws> \"<query>\" [--mode exact|meaning|hybrid] [--speaker S] [--meeting M] [-k N] [--json]"
    log "  (<ws> may be omitted when \$WHOSAID_WORKSPACE is set; run 'whosaid index <ws>' first)"
    exit 1
  fi
  require_module "$SEARCH_PY" search
  exec "$WHOSAID_PY" "$SEARCH_PY" query "$@"
}

# whosaid context <ws> <meeting> <HH:MM:SS> [--before S] [--after S] [--json]
#                                                      -> lib/search.py context ...
cmd_context() {
  if want_help "$@"; then help_section "WORKSPACE SEARCH"; exit 0; fi
  if [[ $# -eq 0 ]]; then
    log "context: usage: whosaid context <ws> <meeting> <HH:MM:SS> [--before S] [--after S] [--json]"
    exit 1
  fi
  require_module "$SEARCH_PY" context
  exec "$WHOSAID_PY" "$SEARCH_PY" context "$@"
}

# whosaid graph <ws> items|item ID|person [Name]|prs|meetings|speakers|commitments [flags]
# The view keyword may come first (no <ws>) or second (after <ws>). speakers is
# a search.py view (talk share needs the segment table); the rest are graph.py.
GRAPH_VIEWS="items item person prs meetings speakers commitments build"
cmd_graph() {
  if want_help "$@"; then help_section "WORKSPACE SEARCH"; exit 0; fi
  local usage="usage: whosaid graph <ws> items|item AI-NNN|person [Name]|prs|meetings|speakers|commitments [--json]"
  [[ $# -eq 0 ]] && { log "graph: need a view. $usage"; exit 1; }

  local ws="" view=""
  if is_graph_view "$1"; then
    view="$1"; shift
  else
    ws="$1"; shift
    [[ $# -gt 0 ]] && is_graph_view "$1" || { log "graph: need a view after '$ws'. $usage"; exit 1; }
    view="$1"; shift
  fi
  local ws_args=()
  [[ -n "$ws" ]] && ws_args+=("$ws")

  case "$view" in
    speakers)
      require_module "$SEARCH_PY" graph
      exec "$WHOSAID_PY" "$SEARCH_PY" speakers ${ws_args[@]+"${ws_args[@]}"} "$@"
      ;;
    *)
      require_module "$GRAPH_PY" graph
      exec "$WHOSAID_PY" "$GRAPH_PY" "$view" ${ws_args[@]+"${ws_args[@]}"} "$@"
      ;;
  esac
}

is_graph_view() {
  local v
  for v in $GRAPH_VIEWS; do [[ "$1" == "$v" ]] && return 0; done
  return 1
}

# whosaid wiki <ws> [--stdout] [-o FILE]                -> lib/graph.py wiki ...
cmd_wiki() {
  if want_help "$@"; then help_section "WORKSPACE SEARCH"; exit 0; fi
  require_module "$GRAPH_PY" wiki
  exec "$WHOSAID_PY" "$GRAPH_PY" wiki "$@"
}

# whosaid watch run|install|uninstall|status|menubar [flags]  -> lib/watch.py <sub> ...
# WHOSAID_BIN tells the watcher which whosaid to run; default to this script so
# a launchd job started from an installed symlink still finds the checkout.
cmd_watch() {
  if want_help "$@"; then help_section "WATCH"; exit 0; fi
  local usage="usage: whosaid watch run|install|uninstall|status|menubar [--into <ws>] [flags]"
  [[ $# -eq 0 ]] && { log "watch: need a subcommand. $usage"; exit 1; }
  case "$1" in
    run|install|uninstall|status|menubar) ;;
    *) log "watch: unknown subcommand '$1'. $usage"; exit 1 ;;
  esac
  require_module "$WATCH_PY" watch
  export WHOSAID_BIN="${WHOSAID_BIN:-$REPO_DIR/whosaid}"
  exec "$WHOSAID_PY" "$WATCH_PY" "$@"
}

# whosaid memos list|pull|delete|shortcut-recipe [flags] -> lib/watch.py memos <sub> ...
cmd_memos() {
  if want_help "$@"; then help_section "WATCH"; exit 0; fi
  local usage="usage: whosaid memos list|pull|delete \"<title>\"|shortcut-recipe [flags]"
  [[ $# -eq 0 ]] && { log "memos: need a subcommand. $usage"; exit 1; }
  case "$1" in
    list|pull|delete|shortcut-recipe) ;;
    *) log "memos: unknown subcommand '$1'. $usage"; exit 1 ;;
  esac
  require_module "$WATCH_PY" memos
  exec "$WHOSAID_PY" "$WATCH_PY" memos "$@"
}

# ---- dispatch --------------------------------------------------------------------
case "${1:-}" in
  setup)
    exec "$REPO_DIR/bootstrap.sh" "${@:2}"
    ;;
  install)
    cmd_install "${@:2}"
    ;;
  enroll)
    cmd_enroll "${@:2}"
    ;;
  record)
    cmd_record "${@:2}"
    ;;
  relabel)
    cmd_relabel "${@:2}"
    ;;
  samples)
    cmd_samples "${@:2}"
    ;;
  speakers)
    cmd_speakers "${@:2}"
    ;;
  teams)
    cmd_teams "${@:2}"
    ;;
  ingest)
    cmd_ingest "${@:2}"
    ;;
  backfill)
    cmd_backfill "${@:2}"
    ;;
  roll-up|rollup)
    cmd_rollup "${@:2}"
    ;;
  reprocess)
    cmd_reprocess "${@:2}"
    ;;
  commitments|worklist)
    cmd_commitments "${@:2}"
    ;;
  index)
    cmd_index "${@:2}"
    ;;
  search)
    cmd_search "${@:2}"
    ;;
  context)
    cmd_context "${@:2}"
    ;;
  graph)
    cmd_graph "${@:2}"
    ;;
  wiki)
    cmd_wiki "${@:2}"
    ;;
  watch)
    cmd_watch "${@:2}"
    ;;
  memos)
    cmd_memos "${@:2}"
    ;;
  doctor)
    cmd_doctor
    ;;
  version|--version|-V)
    cmd_version
    ;;
  mcp)
    # Pin to the tested SDK major: mcp 2.x uses MCPServer; a future 3.0 could
    # break the API, so cap it rather than silently pull an untested major.
    # WHOSAID_WORKSPACE (inherited from the client's env) is the default
    # workspace for the read-only search tools and whosaid://workspace/...
    # resources; the server never rebuilds the index itself.
    exec uv run --quiet --with "mcp[cli]>=2,<3" python "$REPO_DIR/lib/mcp_server.py"
    ;;
  help|-h|--help|"")
    cmd_help
    ;;
  transcribe)
    # Explicit alias for the default action (#18): `whosaid transcribe FILE ...`
    # behaves exactly like `whosaid FILE ...` and matches the whosaid_transcribe
    # MCP tool name. The token must be stripped here or cmd_transcribe collects
    # it as a second input file.
    cmd_transcribe "${@:2}"
    ;;
  *)
    cmd_transcribe "$@"
    ;;
esac
