#!/usr/bin/env bash
# booth — post media and links to The Booth (dead simple). A booth is just a
# folder under $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL
# back.
#
#   booth new    <name> [--why W] [--title T]
#                                       make an empty booth, print its URL
#   booth add    <name> <file>... [--why W] [--title T]
#                                       copy files into a booth (creates it), print URL
#   booth url    <name>                 print a booth's URL
#   booth ls                            list booths (kept ones marked ★)
#   booth rm     <name>                 wipe a booth now (TTL would eventually anyway)
#
#   booth keep   <name>                 exempt a booth from the 24h sweep, forever
#                                       (NOT for "waiting on an answer" — an open
#                                        pick holds its own booth, see below)
#   booth unkeep <name>                 hand it back to the sweeper
#   booth link   <url> [description]    append a link to the standing link board
#                                       REFUSES a booth URL — a booth announces
#                                       itself now; use `booth new --why`
#   booth links                         list the board, numbered, with entry ids
#   booth unlink <id|index>             remove ONE link from the board
#
#   booth bench add <url> <name>        register or UPDATE a bench (upsert)
#   booth bench ls                      list benches, live -> promoted -> retired
#   booth bench state <id|url> <state>  live | promoted | retired
#   booth bench rm <id|url>             remove one
#   booth bench import                  classify the board's rows; writes NOTHING
#   booth bench import --apply <id>...  register ONLY the ids you name. A bare
#                                       --apply is REFUSED: a machine cannot tell
#                                       a bench from a bookmark by its URL, and
#                                       ~14 of 35 live candidates are bookmarks.
#                                       links.md is never edited by either form.
#
# THREE SURFACES, THREE JOBS. Telling them apart is the whole of U6:
#   a BOOTH is a review surface you post work to. It announces itself and is
#     swept 24h after its last activity. `booth new` / `booth add`.
#   a BENCH is a running thing — jackdaw's bench, talk's bench, the things that
#     get promoted to Homepage. Durable, and identified BY ITS URL, so posting
#     it again updates the row instead of adding a fifth. `booth bench add`.
#   a LINK is a reference bookmark — a repo, a model card, a doc page. The
#     standing board, unchanged and NOT deprecated. `booth link`.
# The board carried all three because only one of them had a surface: 178 of its
# 221 rows were booth URLs and 156 of those pointed at booths already swept.
#
#   booth ask    <name> <id> <prompt> <option>... [--no-notes]
#                                       pose a multiple-choice question in a booth
#   booth marks  <name> [--wait [SECS]] print every mark in a booth as JSON;
#                                       --wait blocks while any pick is still open
#   booth answer <name> <id> [--wait [SECS]]
#                                       print ONE pick's answer (exit 1 if unanswered);
#                                       --wait polls until it lands (default 3600 s)
#
# EXIT CODES for the two reading verbs. A read that FAILED gets its own code so
# a caller can tell "not yet" from "the file is damaged" — conflating them is
# how a broken `.marks.json` used to look like an unanswered question and wait
# out the full hour.
#   marks   0 read ok · 1 --wait timed out with picks open · 3 unreadable
#   answer  0 answered · 1 unanswered · 2 no such pick · 3 unreadable ·
#           4 the pick hydrated broken and can never be answered
#
# `answer` and `marks` use the SAME openness predicate. A partially-answered
# pick is still open to both; a broken one is closed to both.
#   booth marks-import <name>           import legacy *.ask.json into .marks.json
#   booth asks   <name>                 alias for `marks` (deprecated)
#
# MARKS. One primitive for operator judgment attached to an artifact:
#   pick — one of N options a session declared in advance   (this is `ask`)
#   note — free text the operator volunteered
#   flag — the operator pointing at one item
# All three are written by the OPERATOR IN THE BROWSER and read by the session.
# There are no `note` / `flag` verbs here on purpose: this CLI is the session's
# side of the loop, and a session does not author the operator's judgment.
#
# A session needs the operator to pick one of N things — which render, which
# plan, go/no-go — and act on the pick. `ask` declares it; the page renders a
# radio form with a notes field; submitting records the judgment. `answer --wait`
# blocks until it lands and prints it, so a session can
# `booth ask … && booth answer --wait …` and carry on. Re-answering overwrites:
# a mark is the CURRENT judgment, not a log. Several questions in ONE form: pass
# a `questions` list (see README § Asks); every verb handles both shapes.
#
# Marks live in ONE file per booth, `<booth>/.marks.json`, so "does this booth
# still owe an answer?" is a single read. Remote sessions have no filesystem
# access, so they poll the HTTP mirror instead:
#   http://10.100.10.50:8090/b/<name>/marks.json
#
# THE 24h RULE AND ITS THREE STATES. Every booth is wiped 24h after its last
# activity — that is the contract, and it is why nobody has to clean up after
# themselves. Two things exempt a booth, and only the first is a button:
#
#   KEPT  `keep` drops a `.forever` sentinel that exempts one booth from the
#         sweep and moves it into its own lane at the top of the index. Use it
#         for durable operator-facing boards, not for run output. `unkeep` is
#         just `rm` of the sentinel, so putting a board back costs nothing.
#   HELD  a booth with an UNANSWERED pick is never swept, automatically, for as
#         long as the question is open. You do not press anything: `booth ask`
#         is what holds it, and the operator answering is what releases it. A
#         partially-answered pick still counts as open, so a review in flight
#         cannot be swept out from under him.
#
# So: DO NOT `keep` a booth just because you are waiting on an answer. That was
# the old workaround, it is what made 70% of live booths "durable", and it is
# no longer needed. `keep` means durable. The question holds its own booth.
#
# VIEWING IS ACTIVITY TOO. The operator opening a booth page resets its clock —
# if he is still looking at it, it is still alive. Your polling does NOT: `booth
# marks --wait` and the `marks.json` endpoint are machine reads and deliberately
# do not count, so a session cannot hold its own booth open by waiting on it.
#
# DELETING A KEPT BOARD: `booth rm <name>` works on kept boards too and deletes
# NOW — it announces that the board was kept, so wiping something durable is
# never silent. In the web UI it is two deliberate steps: `release` on the kept
# card drops the sentinel, the card moves to the ephemeral lane, and the × wipes
# it from there.
#
# DO NOT "unkeep and let it expire". RELEASING A BOARD IS ACTIVITY — you just
# touched it — so a released board's clock resets and it survives another full
# 24h. Unkeep-and-wait is a delay, not a delete. Use `rm` (or the UI ×) when you
# mean now. (This was true before U4 as an accident of directory mtime; it is
# now the stated rule, which is why it no longer needs a warning shaped like a
# surprise.)
#
# `link` is the reason the exception exists: agent sessions hand the operator
# URLs that then drown in terminal scrollback. They go on a standing kept board
# instead, with provenance, so they outlive the session that produced them.
#
# ANNOUNCE YOUR BOOTH. `--why` is one line saying what the operator is looking
# at and why he should care; it lands on the index card and on the booth page
# beside your handle, taken from $ALTHING_HANDLE. It is optional and nothing
# breaks without it — but a booth that cannot say what it is has no way to ask
# for attention except by posting its URL somewhere, which is exactly how the
# link board came to be 69% dead rows. The booth is the place to say it.
#
#   booth add r18-ab out/*.png --why "pick the denoiser, left column is v3"
#
# Re-announcing (a second `new` or `add` on the same booth) updates the why and
# KEEPS the original creation stamp: the booth appeared once.
#
# On a host that is NOT nh3-dev, rsync into the data dir instead, e.g.:
#   rsync -a ./out/  nh3-dev:booth-data/my-run/
set -euo pipefail

DATA="${BOOTH_DATA_DIR:-$HOME/booth-data}"
URL="${BOOTH_URL:-http://10.100.10.50:8090}"
KEEP=".forever"                                    # must match KEEP_MARKER in booth/app.py
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"

# `--why` / `--title` for `new` and `add`. Pulled out of "$@" wherever they
# appear, so `booth add b *.png --why "..."` and `booth add b --why "..." *.png`
# both work — a glob is usually last and a flag usually after it, but nothing
# enforces that and a session should not have to care.
# OMITTED IS NOT EMPTY. `booth new x --why "..."` then `booth add x out/*.png`
# is the ordinary sequence, and while an omitted flag meant "" the second
# command silently erased the sentence the first one existed to record. So the
# shell tracks WHETHER the flag was given, and only passes it on when it was —
# an explicit `--why ""` still clears, which is a different intention.
WHY=""; TITLE=""; WHY_SET=0; TITLE_SET=0; ARGS=()
strip_announce_flags() {
  ARGS=(); WHY_SET=0; TITLE_SET=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --why)     [ $# -ge 2 ] || usage; WHY="$2";   WHY_SET=1;   shift 2 ;;
      --title)   [ $# -ge 2 ] || usage; TITLE="$2"; TITLE_SET=1; shift 2 ;;
      --why=*)   WHY="${1#--why=}";     WHY_SET=1;   shift ;;
      --title=*) TITLE="${1#--title=}"; TITLE_SET=1; shift ;;
      *) ARGS+=("$1"); shift ;;
    esac
  done
}

# Announce a booth. Goes through booth/manifest.py rather than printf-ing JSON
# from the shell, because a why containing a quote, a backslash or a newline is
# not an edge case — it is a sentence somebody wrote.
# announce <dir> <handle> [title] [why] — the trailing two are passed as
# environment variables that are UNSET when the flag was not given, because
# that is the only way the shell can say "leave it alone" rather than "".
announce() {
  local -a envs
  envs=( "BOOTH_SRC=$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)"
         "BOOTH_ANN_DIR=$1" "BOOTH_ANN_HANDLE=$2" )
  [ "${TITLE_SET:-0}" = 1 ] && envs+=( "BOOTH_ANN_TITLE=${3:-}" )
  [ "${WHY_SET:-0}" = 1 ]   && envs+=( "BOOTH_ANN_WHY=${4:-}" )
  env "${envs[@]}" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
try:
    from booth.manifest import write_manifest
    kw = {}
    # Absent means the flag was omitted; present-and-empty means it was given
    # as "" and the poster meant to take the line back.
    if "BOOTH_ANN_TITLE" in os.environ: kw["title"] = os.environ["BOOTH_ANN_TITLE"]
    if "BOOTH_ANN_WHY" in os.environ:   kw["why"] = os.environ["BOOTH_ANN_WHY"]
    write_manifest(pathlib.Path(os.environ["BOOTH_ANN_DIR"]),
                   os.environ["BOOTH_ANN_HANDLE"], **kw)
except Exception as exc:
    # A booth that could not announce itself is still a booth. Say so on stderr
    # and carry on: failing `booth add` over its metadata would lose the files
    # the session just copied, which is a far worse trade.
    print(f"booth: could not write the announcement: {exc}", file=sys.stderr)
'
}

# Who is posting. The same chain `link` uses for its rows, so provenance means
# the same thing on the board and on the card.
whoami_handle() {
  echo "${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
}

# Where booth/*.py lives, for the `python3 -c` calls below. The CLI runs under
# the SYSTEM python3 with no venv, which is why every module it imports is
# stdlib-only (CLAUDE.md invariant 1) and why no AST extractor can see these
# imports — `tests/test_cli.py` runs the real script, and is the only thing that
# catches a third-party import before a fleet host does.
booth_src() {
  (cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)
}

# The booth NAME a URL points at, or empty. ONE PREDICATE — this shells out to
# booth.links.booth_target rather than pattern-matching `:8090/b/` here, because
# the board's dead-row marker and `bench import` use that same function and a
# second implementation in the shell would classify the host-agnostic and
# percent-encoded cases differently (INV-2).
# Prints `B:<name>` for a booth URL and `N` for anything else.
#
# A SENTINEL, NOT AN EMPTY STRING. Command substitution strips trailing
# newlines, so a predicate that answers with the bare name cannot distinguish
# "not a booth" from "a booth whose name bash just erased" — and the guard
# then fails OPEN on that edge, which is the one direction a guard must never
# fail. The prefix makes the answer unambiguous whatever the name contains.
booth_target_of() {
  BOOTH_SRC="$(booth_src)" BOOTH_Q="$1" python3 -c '
import os, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.links import booth_target          # stdlib only — no venv needed
name = booth_target(os.environ["BOOTH_Q"])
sys.stdout.write("N" if name is None else "B:" + name)
'
}

usage() {
  echo "usage: booth {new <name> [--why W] [--title T]|add <name> <file>... [--why W] [--title T]|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> [<file>...]|unblur <name> [<file>...]|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|asks <name> (deprecated alias for marks)|answer <name> <id> [--wait [SECS]]|marks-import <name>|bench add <url> <name>|bench ls|bench state <id|url> <live|promoted|retired>|bench rm <id|url>|bench import [--apply <id>...]}" >&2
  exit 2
}

cmd="${1:-}"; shift || true
case "$cmd" in
  new)
    strip_announce_flags "$@"
    set -- ${ARGS+"${ARGS[@]}"}
    [ $# -ge 1 ] || usage
    mkdir -p -- "$DATA/$1"
    announce "$DATA/$1" "$(whoami_handle)" "$TITLE" "$WHY"
    echo "$URL/b/$1/"
    ;;
  add)
    strip_announce_flags "$@"
    set -- ${ARGS+"${ARGS[@]}"}
    [ $# -ge 2 ] || usage
    name="$1"; shift
    mkdir -p -- "$DATA/$name"
    cp -- "$@" "$DATA/$name/"
    announce "$DATA/$name" "$(whoami_handle)" "$TITLE" "$WHY"
    echo "$URL/b/$name/"
    ;;
  url)
    [ $# -ge 1 ] || usage
    echo "$URL/b/$1/"
    ;;
  ls)
    [ -d "$DATA" ] || exit 0
    for d in "$DATA"/*/; do
      [ -d "$d" ] || continue
      n="$(basename -- "$d")"
      if [ -e "$d$KEEP" ]; then echo "★ $n"; else echo "  $n"; fi
    done
    ;;
  rm)
    [ $# -ge 1 ] || usage
    # Say so when the thing destroyed was durable. Not a block — a CLI user
    # naming a booth is being explicit — but a kept board disappearing must not
    # look identical to run output disappearing.
    was_kept=""
    [ -e "$DATA/$1/$KEEP" ] && was_kept=" (was KEPT — durable board)"
    rm -rf -- "${DATA:?}/$1"
    echo "wiped $1$was_kept"
    ;;
  keep)
    [ $# -ge 1 ] || usage
    [ -d "$DATA/$1" ] || { echo "no such booth: $1" >&2; exit 1; }
    : > "$DATA/$1/$KEEP"
    echo "kept (exempt from the sweep): $URL/b/$1/"
    ;;
  unkeep)
    [ $# -ge 1 ] || usage
    rm -f -- "$DATA/$1/$KEEP"
    echo "unkept — $1 rejoins the 24h sweep"
    ;;
  blur|unblur)
    # ⚠ COSMETIC ONLY. A blurred item is still served at its own URL, still in
    # the zip, still on disk. This hides it from a glance — a shoulder, a
    # screen-share, a scroll past something you did not want full-size. The
    # Booth has no auth by design: if a thing must not be SEEN, it must not be
    # in a booth.
    [ $# -ge 1 ] || usage
    b="$1"; shift
    [ -d "$DATA/$b" ] || { echo "no such booth: $b" >&2; exit 1; }

    # NO FILES NAMED = THE WHOLE BOOTH. The Desk shows up to four images from
    # every booth on the page the operator opens first, so a booth that should
    # not be glanced at needs to say so as a BOOTH, not item by item — and the
    # session that posts it is the one that knows.
    #
    # A marker, and it COMPOSES with the per-item list rather than replacing
    # it: `unblur <name>` clears the booth flag and leaves individual choices
    # exactly as they were.
    if [ $# -eq 0 ]; then
      if [ "$cmd" = blur ]; then
        touch "$DATA/$b/.blurbooth"
        echo "whole booth blurred (cosmetic — still served): $URL/b/$b/"
      else
        rm -f -- "$DATA/$b/.blurbooth"
        echo "whole booth un-blurred (per-item blur kept): $URL/b/$b/"
      fi
      exit 0
    fi

    # Items are made booth-relative here; WHETHER each one is an item path is
    # booth.blur.check_rel's call, the same predicate the web route uses, so
    # `booth blur g a..b.png` and the operator's click agree. (A `*..*`
    # substring test here refused `a..b.png`, which the route accepted.)
    items=()
    for item in "$@"; do
      item="${item#"$DATA/$b/"}"
      [ -e "$DATA/$b/$item" ] || echo "warning: no such item in $b: $item" >&2
      items+=("$item")
    done
    # ONE WRITER. `.blurred.json` is a JSON array (a rel may carry a leading
    # space or a newline, which the old `.blurred` line format could not
    # round-trip), and the service writes it too, so the CLI goes through the
    # same stdlib-only booth.blur, never a grep/printf of its own. Items travel
    # as argv, which carries any byte but NUL. EVERY item is checked before ANY
    # is written, so a refused path leaves the blur set exactly as it was.
    # Exit 2: an item path refused. Exit 3: nothing written, and why (the
    # package is missing, or something that is not a file is in the way).
    BOOTH_SRC="$(booth_src)" BOOTH_DIR="$DATA/$b" python3 -c '
import os, sys
from pathlib import Path
sys.path.insert(0, os.environ["BOOTH_SRC"])
try:
    from booth.blur import BlurUnwritable, check_rel, set_blurred   # stdlib only
except ImportError as exc:
    src = os.environ["BOOTH_SRC"]
    sys.stderr.write(f"booth blur: cannot load booth.blur from {src} ({exc}).\n"
                     "  Run the booth script from its checkout, beside its booth/ package. Nothing was changed.\n")
    sys.exit(3)
on = sys.argv[1] == "blur"
rels = [r.lstrip("/") for r in sys.argv[2:]]
for rel in rels:
    try:
        check_rel(rel)
    except ValueError as exc:
        sys.stderr.write(f"booth blur: refusing {rel!r}: {exc}. Nothing was changed.\n")
        sys.exit(2)
for rel in rels:
    try:
        set_blurred(Path(os.environ["BOOTH_DIR"]), rel, on)
    except BlurUnwritable as exc:
        sys.stderr.write(f"booth blur: {exc}\n")
        sys.exit(3)
' "$cmd" "${items[@]}"
    if [ "$cmd" = blur ]; then
      echo "blurred (cosmetic — still served): $URL/b/$b/"
    else
      echo "un-blurred: $URL/b/$b/"
    fi
    ;;
  link)
    [ $# -ge 1 ] || usage
    link_url="$1"; shift
    desc="${*:-}"
    # THE REFUSAL COMES FIRST, BEFORE ANY WRITE (INV-3). A booth announces
    # itself now (U5), so a booth URL on the board is a row that rots the
    # moment the booth is swept — 156 of the board's 221 rows are exactly
    # that. Refusing AFTER the mkdir/announce below would leave a new booth
    # behind as the side effect of a call that failed.
    # `|| pred_rc=$?` so a BROKEN PREDICATE is handled here rather than aborting
    # the script under `set -e` with a raw Python traceback and nothing else.
    # The direction is FAIL-CLOSED and stays that way: if we cannot tell whether
    # this is a booth, we do not append. A guard that fails open is not a guard,
    # and the cost of being wrong in the other direction is one message telling
    # the poster exactly what broke.
    pred_rc=0
    refused_name="$(booth_target_of "$link_url" 2>/dev/null)" || pred_rc=$?
    if [ "$pred_rc" -ne 0 ]; then
      {
        echo "booth link: could not check whether that URL is a booth, so nothing was posted."
        echo "  the check runs booth/links.py under the system python3 with no venv."
        echo "  re-run from a checkout where \`python3 -c 'import booth.links'\` works,"
        echo "  or post it from a host that has one."
      } >&2
      exit 3
    fi
    case "$refused_name" in
      N) refused_name="" ;;
      B:*) refused_name="${refused_name#B:}" ;;
      *)
        echo "booth link: the booth check answered something unrecognised; nothing was posted." >&2
        exit 3 ;;
    esac
    # CREDENTIALS DO NOT GO ON THE BOARD, through any door. `normalize_bench_url`
    # refuses userinfo for a bench; `booth link` is the door this unit did not
    # touch, and the board renders on an unauthenticated LAN surface. A small,
    # deliberate widening of the unit -- named rather than smuggled.
    case "$link_url" in
      *://*@*)
        {
          echo "booth link: that URL carries credentials (user:pass@host) and the board"
          echo "  is readable by anyone who can reach this service. Nothing was posted."
          echo "  strip the credentials and post it again."
        } >&2
        exit 2 ;;
    esac
    if [ -n "$refused_name" ]; then
      {
        echo "booth link: that is a booth, and a booth announces itself now."
        echo "  booth new $refused_name --why \"${desc:-what the operator is looking at}\""
        echo "  (or --why on \`booth add\`; re-announcing keeps the original stamp)"
        echo "  the index at $URL/ is the feed."
      } >&2
      exit 2
    fi
    board="$DATA/$LINKS_BOARD"
    mkdir -p -- "$board"
    : > "$board/$KEEP"                             # the board is durable by definition
    # The board announces itself as the SERVICE's, not as any one agent's:
    # seventeen handles post to it, so no handle owns it. Idempotent — a second
    # link keeps the original creation stamp.
    TITLE_SET=1 WHY_SET=1 announce "$board" "booth" "$LINKS_BOARD" \
      "the standing link board — every agent session posts here"
    # Provenance, because a bare URL is unreadable three days later: who posted
    # it, from where, and when.
    who="${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
    when="$(date '+%Y-%m-%d %H:%M')"
    # ONE printf of ONE line. A single write under PIPE_BUF to an O_APPEND fd is
    # atomic on POSIX, so concurrent sessions cannot interleave a line — which
    # matters here precisely because many agents post to one board.
    # flock on the same sidecar the Python remover uses. The append is
    # atomic by itself, but `unlink` does read-modify-write, and without a
    # shared lock this line could land inside that window and be rewritten
    # away by the prune.
    touch -- "$board/.links.lock"
    # THE REDIRECTION OPENS INSIDE THE LOCK, which is why this is `sh -c` and
    # not a bare printf. `flock LOCK printf ... >> board` reads as locked and is
    # not: the SHELL opens the append fd while parsing, before flock acquires
    # anything. If a concurrent `unlink` rewrites the board in that window, the
    # rewrite lands on a NEW inode via os.replace and this fd still points at
    # the old, unlinked one — so the append succeeds, reports success, and the
    # row is gone. Found by a cold bug-hunt arm; pre-existing, not U6's, but it
    # is a silent data loss in the file this unit spends its time in.
    BK_DESC="${desc:-$link_url}" BK_URL="$link_url" BK_WHO="$who" BK_WHEN="$when" \
    BK_BOARD="$board/links.md" \
    flock "$board/.links.lock" sh -c '
      printf -- "- [%s](%s) <sub>· %s · %s</sub>\n" \
        "$BK_DESC" "$BK_URL" "$BK_WHO" "$BK_WHEN" >> "$BK_BOARD"
    ' 
    echo "$URL/b/$LINKS_BOARD/"
    ;;
  links)
    board="$DATA/$LINKS_BOARD/links.md"
    [ -f "$board" ] || { echo "no link board yet"; exit 0; }
    # The id is the same content hash the web UI and `unlink` use, so a row can
    # be named unambiguously even while other sessions are appending to the board.
    n=0
    while IFS= read -r line; do
      case "$line" in "- ["*) ;; *) continue ;; esac
      n=$((n+1))
      id="$(printf '%s' "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sha1sum | cut -c1-8)"
      printf '%3d  %s  %s\n' "$n" "$id" "$line"
    done < "$board"
    # `if`, NOT `[ ... ] && echo`: as the LAST statement of the branch that
    # idiom returns 1 whenever the board is non-empty, so `booth links` exits
    # non-zero on success — and `unlink`'s index lookup, which calls it inside
    # $( ) under `set -e`, then dies silently.
    if [ "$n" -eq 0 ]; then echo "board has no link rows"; fi
    ;;
  unlink)
    [ $# -ge 1 ] || usage
    board="$DATA/$LINKS_BOARD"
    [ -f "$board/links.md" ] || { echo "no link board" >&2; exit 1; }
    target="$1"
    # A bare number is accepted for convenience but resolved to the row's
    # CONTENT ID before anything is deleted: between `booth links` and
    # `booth unlink` another session may have appended, and deleting by POSITION
    # would then take the wrong row. An id either matches the row you saw or
    # matches nothing.
    # DISAMBIGUATE BY SHAPE, not by "is it numeric". A content id is exactly 8
    # hex chars, and roughly one id in forty is all digits — those were being
    # read as row numbers and silently resolving to nothing. Match the id's
    # actual shape first; anything else numeric is an index.
    case "$target" in
      [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f])
        ;;                                   # already a content id
      ''|*[!0-9]*)
        echo "not an entry id (8 hex chars) or a row number: $target" >&2; exit 1 ;;
      *)
        target="$("$0" links | awk -v n="$target" '$1==n{print $2}')"
        [ -n "$target" ] || { echo "no row $1 on the board" >&2; exit 1; } ;;
    esac
    # `|| exit 1` so a failure is reported rather than swallowed; `set -e` inside
    # a command substitution elsewhere in this script has bitten us already.
    BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.links import remove_link_entry  # stdlib only — no venv needed
removed = remove_link_entry(pathlib.Path(sys.argv[1]), sys.argv[2])
if removed is None:
    sys.exit("no such entry: %s (already removed?)" % sys.argv[2])
print("removed: %s  %s" % (removed["desc"], removed["url"]))
' "$board" "$target"
    ;;
  bench)
    # SEAM REVIEW SR-6: the first two-word verb in this script. A nested case,
    # and a bare `bench` names the bench verbs rather than falling through to
    # the generic usage, which would hide which of the two words was wrong.
    sub="${1:-}"; shift || true
    case "$sub" in
      add|ls|state|rm|import) ;;
      *)
        echo "usage: booth bench {add <url> <name>|ls|state <id|url> <live|promoted|retired>|rm <id|url>|import [--apply <id>...]}" >&2
        exit 2 ;;
    esac
    BOOTH_SRC="$(booth_src)" BOOTH_DATA="$DATA" BOOTH_SUB="$sub" \
    BOOTH_WHO="$(whoami_handle)" BOOTH_BOARD="$LINKS_BOARD" \
    python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
# stdlib only — no venv needed. benches.py imports no sibling either (INV-9).
from booth.benches import (normalize_bench_url, order_benches, read_benches,
                           remove_bench, set_bench_state, upsert_bench)
from booth.links import booth_target, parse_link_entries

root = pathlib.Path(os.environ["BOOTH_DATA"])
sub, who = os.environ["BOOTH_SUB"], os.environ["BOOTH_WHO"]
argv = sys.argv[1:]

def die(msg, code=2):
    print("booth bench: %s" % msg, file=sys.stderr)
    raise SystemExit(code)

def row(b):
    # ONE LINE PER BENCH, in the rendered order — state first, so a retired
    # bench sinks, then name, then id as a total tie-break (INV-4).
    # THE ID IS PRINTED WHOLE AND UNTRUNCATED, because it is the locator
    # `bench state` and `bench rm` take: a truncated one is not an id, it is a
    # string that looks like one and silently addresses nothing. The name and
    # the added date are the truncatable columns.
    return "%-9s  %-10s  %-16s  %-24s  %s" % (
        b.state, b.added[:10], b.owner[:16], b.name[:24], b.id)

if sub == "add":
    if len(argv) < 2: die("bench add <url> <name>")
    try:
        bench, created = upsert_bench(root, argv[0], " ".join(argv[1:]), who)
    except ValueError as exc:
        die(exc)
    print("%s: %s" % ("registered" if created else "updated", bench.id))
elif sub == "ls":
    benches, err = read_benches(root)
    if err:
        die("the registry could not be read: %s" % err, 3)
    if not benches:
        print("no benches registered yet")
    else:
        print("%-9s  %-10s  %-16s  %-24s  %s"
              % ("STATE", "ADDED", "OWNER", "NAME", "ID (pass to state|rm)"))
    for b in benches:
        print(row(b))
elif sub in ("state", "rm"):
    if not argv: die("bench %s <id|url>%s" % (sub, " <state>" if sub == "state" else ""))
    try:
        bench_id = normalize_bench_url(argv[0])
    except ValueError as exc:
        die(exc)
    if sub == "rm":
        gone = remove_bench(root, bench_id)
        if gone is None: die("no such bench: %s" % bench_id, 1)
        print("removed: %s" % gone.url)
    else:
        if len(argv) < 2: die("bench state <id|url> <live|promoted|retired>")
        try:
            moved = set_bench_state(root, bench_id, argv[1])
        except ValueError as exc:
            die(exc)
        if moved is None: die("no such bench: %s" % bench_id, 1)
        print("%s is now %s" % (moved.url, moved.state))
elif sub == "import":
    apply = "--apply" in argv
    picked = [a for a in argv if a != "--apply"]
    board = root / os.environ["BOOTH_BOARD"] / "links.md"
    if not board.is_file(): die("no link board at %s" % board, 1)
    skipped, candidates, refused = [], [], []
    for e in parse_link_entries(board.read_text()):
        name = booth_target(e["url"])
        if name is not None:
            skipped.append((e, name)); continue
        try:
            candidates.append((normalize_bench_url(e["url"]), e))
        except ValueError as exc:
            refused.append((e, str(exc)))
    print("SKIPPED — booth rows; a booth announces itself now (%d):" % len(skipped))
    for e, name in skipped:
        print("  %-30s %s" % (name, e["url"]))
    print()
    print("CANDIDATES — would be registered (%d rows, %d distinct):"
          % (len(candidates), len({i for i, _ in candidates})))
    for i, e in candidates:
        # THE NORMALIZED ID BESIDE THE RAW URL, which is the whole point of the
        # proposal: five rows of `talk` collapsing to one is only visible if you
        # can see which five raw URLs produced the one id. The description is
        # the thing to drop here, not the URL.
        print("  %-52s %s" % (i, e["url"]))
        if e["desc"]:
            print("  %-52s   %s" % ("", e["desc"][:70]))
    print()
    print("REFUSED — normalization said no (%d):" % len(refused))
    for e, why in refused:
        print("  %-52s %s" % (e["url"], why))
    if not apply:
        print()
        print("nothing was written.")
        print("  booth bench import --apply <id>...   register ONLY the ids you name")
        print()
        print("A MACHINE CANNOT TELL A BENCH FROM A BOOKMARK BY ITS URL. On the live")
        print("board roughly 14 of 35 candidates are repos, model cards and docs, for")
        print("which the board is the right and only home. So `--apply` takes the ids")
        print("YOU pick from the list above; it will not register the whole set.")
        raise SystemExit(0)
    # SELECTION IS MANDATORY. A bare `--apply` would do exactly the thing this
    # rationale of this very unit says is impossible -- decide bench-vs-bookmark
    # URL -- and it would do it silently, to ~14 rows that belong on the board.
    # The dry-run prints the ids; the operator names the ones that are benches.
    if not picked:
        print()
        print("booth bench import --apply needs the ids to register.", file=sys.stderr)
        print("  nothing was written. copy the ids you want from the list above:",
              file=sys.stderr)
        print("  booth bench import --apply <id> [<id>...]", file=sys.stderr)
        raise SystemExit(2)
    by_id = {i: e for i, e in candidates}
    unknown = [i for i in picked if i not in by_id]
    if unknown:
        print()
        for i in unknown:
            print("not a candidate id: %s" % i, file=sys.stderr)
        print("nothing was written.", file=sys.stderr)
        raise SystemExit(2)
    for i in picked:
        e = by_id[i]
        upsert_bench(root, e["url"], e["desc"], e["who"] or who)
    print()
    print("applied: %d bench(es) registered. links.md was NOT modified." % len(set(picked)))
' "$@"
    ;;
  ask)
    # booth ask <name> <id> <prompt> <opt>... [--no-notes]
    [ $# -ge 5 ] || usage
    name="$1"; mid="$2"; prompt="$3"; shift 3
    notes=1; opts=()
    for a in "$@"; do
      case "$a" in --no-notes) notes=0 ;; *) opts+=("$a") ;; esac
    done
    [ "${#opts[@]}" -ge 2 ] || { echo "a pick needs at least 2 options" >&2; exit 1; }
    # Validated through the SAME normaliser the page uses, so a session cannot
    # declare a question the renderer would refuse. stdlib only — no venv needed.
    BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" ASK_NOTES="$notes" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.asks import AskError
from booth.marks import MarksCorrupt, declare_pick
booth, mid, prompt, *opts = sys.argv[1:]
try:
    declare_pick(pathlib.Path(booth), mid,
                 {"prompt": prompt, "options": opts,
                  "notes": os.environ["ASK_NOTES"] == "1"})
except AskError as exc:
    sys.exit("bad pick: %s" % exc)
except MarksCorrupt as exc:
    sys.exit("this booth'"'"'s .marks.json is damaged, so nothing was written: %s" % exc)
' "$DATA/$name" "$mid" "$prompt" "${opts[@]}"
    echo "$URL/b/$name/#mark-$mid"
    ;;
  marks|asks)
    # booth marks <name> [--wait [SECS]]     (`asks` is the deprecated alias)
    #
    # EXIT CODES. 0 = the read succeeded and the document is on stdout; 1 =
    # --wait gave up with picks still open (the document is still printed); 3 =
    # the marks could not be read at all. A reader that CRASHED must never look
    # like an answer — the old shape printed a traceback and exited 0, so a
    # caller piping to `jq` saw success and got nothing.
    #
    # Whether anything is still open is in the payload's `open` list. The read
    # verb does not encode it in its status: a successful read is a success.
    [ $# -ge 1 ] || usage
    name="$1"; shift
    wait_s=0
    if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
    # Poll, do not inotify: the judgment is written by a different process via
    # os.replace, and a 2 s cadence is plenty for a human clicking a radio.
    deadline=$(( $(date +%s) + wait_s ))
    while :; do
      # CAPTURED, not streamed. Printing inside the loop wrote one whole JSON
      # document per poll, so `booth marks b --wait | jq` got several values
      # concatenated and could parse none of them. The wait is a wait; the
      # print is the result, and it happens once.
      rc=0
      out="$(BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
import json, os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
try:
    from booth.marks import as_dict, marks_for, open_marks, read_error
    booth = pathlib.Path(sys.argv[1])
    # Ask FIRST whether the file is readable. `marks_for` answers "no marks"
    # for a damaged file, which is the right answer for a page and the wrong
    # one for a session that wants to know whether its question survived.
    broken = read_error(booth)
    if broken:
        print(f"booth: {broken}", file=sys.stderr)
        sys.exit(3)
    marks = marks_for(booth)
    doc = json.dumps({"marks": [as_dict(m) for m in marks],
                      "open": [m.id for m in open_marks(marks)]},
                     ensure_ascii=False, indent=2)
except Exception as exc:
    print(f"booth: cannot read marks: {exc}", file=sys.stderr)
    sys.exit(3)
print(doc)
sys.exit(2 if open_marks(marks) else 0)
' "$DATA/$name")" || rc=$?
      case "$rc" in
        0) printf '%s\n' "$out"; exit 0 ;;                 # read ok, nothing open
        2) if [ "$wait_s" -eq 0 ]; then printf '%s\n' "$out"; exit 0; fi ;;
        *) echo "cannot read marks in $name" >&2; exit 3 ;;
      esac
      if [ "$(date +%s)" -ge "$deadline" ]; then
        printf '%s\n' "$out"
        echo "timed out after ${wait_s}s with marks still open in $name" >&2; exit 1
      fi
      sleep 2
    done
    ;;
  answer)
    # booth answer <name> <id> [--wait [SECS]]
    [ $# -ge 2 ] || usage
    name="$1"; mid="$2"; shift 2
    wait_s=0
    if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
    deadline=$(( $(date +%s) + wait_s ))
    while :; do
      rc=0
      out="$(BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
import json, os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
try:
    from booth.marks import marks_for, open_marks, read_error
    booth, mid = sys.argv[1:3]
    broken = read_error(pathlib.Path(booth))
    if broken:
        print(f"booth: {broken}", file=sys.stderr)
        sys.exit(3)
    # id AND shape, matching the web route. Matching on id alone reported a
    # note id as "unanswered" and then polled it for an hour — a question that
    # could never be answered because it was never a question.
    marks = marks_for(pathlib.Path(booth))
    m = next((x for x in marks if x.id == mid and x.shape == "pick"), None)
    # THE openness predicate, not a second spelling of it. `answer is None` is
    # what this read used to test, and it disagreed with `marks --wait` on a
    # PARTIALLY answered pick: one verb returned the half-filled form while the
    # other blocked on the same booth at the same instant. U2 put openness in
    # one function precisely so the two could not drift.
    still_open = m is not None and m in open_marks(marks)
except Exception as exc:
    print(f"booth: cannot read marks: {exc}", file=sys.stderr)
    sys.exit(3)
if m is None:
    sys.exit(2)
if m.error:
    # Not open, and never going to be: the web route refuses this form with a
    # 400, so waiting on it is waiting on nothing. `marks --wait` already
    # returns immediately here; this is the other half of that agreement.
    print(f"booth: pick is broken and cannot be answered: {m.error}",
          file=sys.stderr)
    sys.exit(4)
if still_open:
    sys.exit(1)
print(json.dumps(m.answer, ensure_ascii=False, indent=2))
' "$DATA/$name" "$mid")" || rc=$?
      case "$rc" in
        0) printf '%s\n' "$out"; exit 0 ;;
        2) echo "no such pick: $name/$mid" >&2; exit 2 ;;
        # A read that FAILED is not "not yet". Conflating them sent --wait
        # spinning for the full hour on a broken file and then blamed the
        # operator for not answering.
        3) echo "cannot read marks in $name" >&2; exit 3 ;;
        # A pick that hydrated broken is refused by the web route, so no answer
        # can ever land. Waiting on it is waiting on nothing.
        4) exit 4 ;;
      esac
      if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#mark-$mid" >&2; exit 1; fi
      if [ "$(date +%s)" -ge "$deadline" ]; then
        echo "timed out after ${wait_s}s waiting on $name/$mid" >&2; exit 1
      fi
      sleep 2
    done
    ;;
  marks-import)
    # booth marks-import <name> — idempotent, and it deletes nothing
    [ $# -ge 1 ] || usage
    BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
from booth.marks import import_legacy_asks
made = import_legacy_asks(pathlib.Path(sys.argv[1]))
if not made:
    print("nothing to import (or already imported)")
for m in made:
    print("imported %-24s %s" % (m.id, m.error or ("answered" if m.answer else "open")))
' "$DATA/$1"
    ;;
  *) usage ;;
esac
