#!/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 [--apply]        classify the board's rows; writes NOTHING
#                                       without --apply, and never edits links.md
#
# 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
BLUR=".blurred"   # one booth-relative item path per line; see `blur` below
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).
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
sys.stdout.write(booth_target(os.environ["BOOTH_Q"]) or "")
'
}

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]}" >&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 2 ] || usage
    b="$1"; shift
    [ -d "$DATA/$b" ] || { echo "no such booth: $b" >&2; exit 1; }
    f="$DATA/$b/$BLUR"
    for item in "$@"; do
      item="${item#"$DATA/$b/"}"; item="${item#/}"
      case "$item" in
        *..*) echo "refusing path with '..': $item" >&2; exit 2 ;;
      esac
      [ -e "$DATA/$b/$item" ] || echo "warning: no such item in $b: $item" >&2
      touch "$f"
      if [ "$cmd" = blur ]; then
        grep -qxF -- "$item" "$f" || printf '%s\n' "$item" >> "$f"
      else
        grep -vxF -- "$item" "$f" > "$f.tmp" || true
        mv -- "$f.tmp" "$f"
      fi
    done
    # An empty marker is a lie by omission — `ls -a` should say whether
    # anything here is blurred at all.
    [ -s "$f" ] || rm -f -- "$f"
    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
    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"
    flock "$board/.links.lock" \
    printf -- '- [%s](%s) <sub>· %s · %s</sub>\n' \
      "${desc:-$link_url}" "$link_url" "$who" "$when" >> "$board/links.md"
    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]}" >&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).
    return "%-9s %-28s %-52s %s" % (b.state, b.name[:28], b.url[:52], b.owner)

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")
    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
    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:
        print("  %-52s %s" % (i, e["desc"][:60]))
    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. re-run with --apply to register the candidates.")
        print("NOTE: a machine cannot tell a bench from a bookmark by its URL —")
        print("      roughly 14 of 35 live candidates are repos, model cards and docs,")
        print("      for which the board is the right home. Review before applying.")
        raise SystemExit(0)
    for i, e in candidates:
        upsert_bench(root, e["url"], e["desc"], e["who"] or who)
    print()
    print("applied: %d distinct benches registered. links.md was NOT modified."
          % len({i for i, _ in candidates}))
' "$@"
    ;;
  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
