Five mechanisms existed to get one question next to one artifact. Three of
them were the same thing wearing different clothes, and the third of the three
had no code at all: the operator picked winners out of a 270-image set and
told the session in conversation. `sindra-finalists` is 86 items, every one
captioned, with the selection encoded in the booth's NAME.
A MARK is operator judgment attached to a target — the booth, or one item in
it, addressed by the `rel` U1 established as item identity. Three shapes:
pick — one of N options a session declared in advance (was: an ask)
note — free text the operator volunteered (had nothing)
flag — this one (had nothing)
One file per booth, one read path, one place openness is computed, one slot
beside the artifact. The storage shape is the operator's call (2026-09-21) and
follows from U4: "does this booth still owe an answer?" gets asked per booth
per sweep tick and per card per index render, so it has to be one read and not
a walk of a booth holding 270 files. Marks are also not links.md — that is an
O_APPEND content-hash log because 17 handles write it concurrently, whereas a
booth's marks see one session and one operator, so locking the common path
costs nothing.
The 2026-09-09 pick semantics are preserved by NOT rewriting them: partial
answers legal, a blank question lands in `unanswered`, `complete` false until
every question has a pick, the only refusal a submission carrying nothing.
`write_answer` split into the pure `build_answer` plus the storage that went
away with the sidecar; `normalize_ask` untouched.
Three findings worth naming, because each was caught by a gate rather than by
reading the diff again:
* The seam review found `inline.place` indexes asks by SUBSCRIPT — the only
consumer in the service that does — so a frozen dataclass breaks it, and
`inline.py` had been missing from the contract's scope entirely.
* A retargeted test found a regression in the legacy importer: a malformed
sidecar that renders "broken" today would have silently vanished on
migration. It now imports carrying its reason.
* A partially-answered pick counted as CLOSED on the index while the panel
beside it rendered it "partial" — the two disagreed about one booth. Open
is the reading U4 needs, and it is declared rather than smuggled in.
`GET /b/<n>/marks.json` is new and load-bearing: sessions on other hosts polled
`<stem>.answer.json` over HTTP, so removing the sidecar without it would have
taken that capability away. `/b/<n>/asks` 308s to `/marks`. Legacy sidecars are
imported, never deleted — four are live and unanswered.
Also records the operator's deterministic-order directive as a cross-cutting v1
invariant, in ROADMAP.md with the per-collection rule table and as CLAUDE.md
invariant 6. The Booth's job is comparison; an order that moves between renders
does not crash, it misfiles the judgment.
242 tests. No version bump — a release tier for this is the operator's call.
342 lines
15 KiB
Bash
Executable File
342 lines
15 KiB
Bash
Executable File
#!/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> make an empty booth, print its URL
|
||
# booth add <name> <file>... 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
|
||
# booth unkeep <name> hand it back to the sweeper
|
||
# booth link <url> [description] append a link to the standing link board
|
||
# booth links list the board, numbered, with entry ids
|
||
# booth unlink <id|index> remove ONE link from the board
|
||
#
|
||
# 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)
|
||
# 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 ONE EXCEPTION. Every booth is wiped 24h after its last
|
||
# activity — that is the contract, and it is why nobody has to clean up after
|
||
# themselves. `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 under the sweeper costs nothing.
|
||
#
|
||
# 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". Removing the sentinel BUMPS the booth
|
||
# directory's mtime, and a booth's age is the newest mtime in its tree — 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.
|
||
#
|
||
# `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.
|
||
#
|
||
# 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}"
|
||
|
||
usage() {
|
||
echo "usage: booth {new <name>|add <name> <file>...|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]]|answer <name> <id> [--wait [SECS]]|marks-import <name>}" >&2
|
||
exit 2
|
||
}
|
||
|
||
cmd="${1:-}"; shift || true
|
||
case "$cmd" in
|
||
new)
|
||
[ $# -ge 1 ] || usage
|
||
mkdir -p -- "$DATA/$1"
|
||
echo "$URL/b/$1/"
|
||
;;
|
||
add)
|
||
[ $# -ge 2 ] || usage
|
||
name="$1"; shift
|
||
mkdir -p -- "$DATA/$name"
|
||
cp -- "$@" "$DATA/$name/"
|
||
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="${*:-}"
|
||
board="$DATA/$LINKS_BOARD"
|
||
mkdir -p -- "$board"
|
||
: > "$board/$KEEP" # the board is durable by definition
|
||
# 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"
|
||
;;
|
||
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 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)
|
||
' "$DATA/$name" "$mid" "$prompt" "${opts[@]}"
|
||
echo "$URL/b/$name/#mark-$mid"
|
||
;;
|
||
marks|asks)
|
||
# booth marks <name> [--wait [SECS]] (`asks` is the deprecated alias)
|
||
[ $# -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
|
||
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
||
import json, os, pathlib, sys
|
||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||
from booth.marks import as_dict, marks_for, open_marks
|
||
marks = marks_for(pathlib.Path(sys.argv[1]))
|
||
print(json.dumps({"marks": [as_dict(m) for m in marks],
|
||
"open": [m.id for m in open_marks(marks)]},
|
||
ensure_ascii=False, indent=2))
|
||
sys.exit(1 if open_marks(marks) else 0)
|
||
' "$DATA/$name" && exit 0
|
||
# exit 1 from the reader means at least one pick is still open
|
||
if [ "$wait_s" -eq 0 ]; then exit 0; fi
|
||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||
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
|
||
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" python3 -c '
|
||
import json, os, pathlib, sys
|
||
sys.path.insert(0, os.environ["BOOTH_SRC"])
|
||
from booth.marks import marks_for
|
||
booth, mid = sys.argv[1:3]
|
||
m = next((x for x in marks_for(pathlib.Path(booth)) if x.id == mid), None)
|
||
if m is None:
|
||
sys.exit(2)
|
||
if m.answer is None:
|
||
sys.exit(1)
|
||
print(json.dumps(m.answer, ensure_ascii=False, indent=2))
|
||
' "$DATA/$name" "$mid" && exit 0
|
||
rc=$?
|
||
if [ "$rc" -eq 2 ]; then echo "no such pick: $name/$mid" >&2; exit 1; fi
|
||
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
|