feat(marks): one primitive for operator judgment, so the loop stops running through chat
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.
This commit is contained in:
+106
-62
@@ -15,24 +15,36 @@
|
||||
# booth links list the board, numbered, with entry ids
|
||||
# booth unlink <id|index> remove ONE link from the board
|
||||
#
|
||||
# booth ask <name> <stem> <prompt> <option>... [--no-notes]
|
||||
# booth ask <name> <id> <prompt> <option>... [--no-notes]
|
||||
# pose a multiple-choice question in a booth
|
||||
# booth asks <name> list a booth's asks and whether each is answered
|
||||
# booth answer <name> <stem> [--wait [SECS]]
|
||||
# print the answer JSON (exit 1 if unanswered);
|
||||
# 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)
|
||||
#
|
||||
# ASKS. A session needs the operator to pick one of N things — which render,
|
||||
# which plan, go/no-go — and act on the pick. `ask` writes <stem>.ask.json into
|
||||
# a booth; the page renders it as a radio form with a notes field; submitting
|
||||
# writes <stem>.answer.json next to it. `answer --wait` blocks until that file
|
||||
# exists and prints it, so a session can `booth ask … && booth answer --wait …`
|
||||
# and carry on. Re-answering overwrites: the sidecar is the CURRENT answer.
|
||||
# Several questions in ONE form: write <stem>.ask.json by hand with a
|
||||
# `questions` list (see services/booth/README.md § Asks); `asks` and `answer`
|
||||
# handle both shapes.
|
||||
# Remote sessions: rsync the ask in, then poll
|
||||
# http://10.100.10.50:8090/b/<name>/<stem>.answer.json (404 until answered).
|
||||
# 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
|
||||
@@ -67,7 +79,7 @@ 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> <stem> <prompt> <option>... [--no-notes]|asks <name>|answer <name> <stem> [--wait [SECS]]}" >&2
|
||||
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
|
||||
}
|
||||
|
||||
@@ -229,69 +241,101 @@ print("removed: %s %s" % (removed["desc"], removed["url"]))
|
||||
' "$board" "$target"
|
||||
;;
|
||||
ask)
|
||||
# booth ask <name> <stem> <prompt> <opt>... [--no-notes]
|
||||
# booth ask <name> <id> <prompt> <opt>... [--no-notes]
|
||||
[ $# -ge 5 ] || usage
|
||||
name="$1"; stem="$2"; prompt="$3"; shift 3
|
||||
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 "an ask needs at least 2 options" >&2; exit 1; }
|
||||
[ "${#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
|
||||
# post a question the renderer would refuse. stdlib only — no venv needed.
|
||||
# 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, write_ask
|
||||
booth, stem, prompt, *opts = sys.argv[1:]
|
||||
from booth.asks import AskError
|
||||
from booth.marks import declare_pick
|
||||
booth, mid, prompt, *opts = sys.argv[1:]
|
||||
try:
|
||||
write_ask(pathlib.Path(booth), stem, prompt, opts, notes=os.environ["ASK_NOTES"] == "1")
|
||||
declare_pick(pathlib.Path(booth), mid,
|
||||
{"prompt": prompt, "options": opts,
|
||||
"notes": os.environ["ASK_NOTES"] == "1"})
|
||||
except AskError as exc:
|
||||
sys.exit("bad ask: %s" % exc)
|
||||
' "$DATA/$name" "$stem" "$prompt" "${opts[@]}"
|
||||
echo "$URL/b/$name/#ask-$stem"
|
||||
sys.exit("bad pick: %s" % exc)
|
||||
' "$DATA/$name" "$mid" "$prompt" "${opts[@]}"
|
||||
echo "$URL/b/$name/#mark-$mid"
|
||||
;;
|
||||
asks)
|
||||
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.asks import list_asks
|
||||
asks = list_asks(pathlib.Path(sys.argv[1]))
|
||||
if not asks:
|
||||
print("no asks in this booth")
|
||||
for a in asks:
|
||||
if a["error"]:
|
||||
state = "BROKEN " + a["error"]
|
||||
elif a["answer"] and a["multi"]:
|
||||
picks = ", ".join("%s=%s" % (k, v["label"]) for k, v in a["answer"]["answers"].items())
|
||||
state = "answered %s (%s)" % (picks, a["answer"]["answered_at"])
|
||||
elif a["answer"]:
|
||||
state = "answered %s (%s)" % (a["answer"]["label"], a["answer"]["answered_at"])
|
||||
elif a["multi"]:
|
||||
state = "open (%d questions)" % len(a["questions"])
|
||||
else:
|
||||
state = "open"
|
||||
print("%-24s %s" % (a["stem"], state))
|
||||
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"
|
||||
;;
|
||||
answer)
|
||||
# booth answer <name> <stem> [--wait [SECS]]
|
||||
[ $# -ge 2 ] || usage
|
||||
name="$1"; stem="$2"; shift 2
|
||||
wait_s=0
|
||||
if [ "${1:-}" = "--wait" ]; then wait_s="${2:-3600}"; fi
|
||||
f="$DATA/$name/$stem.answer.json"
|
||||
[ -f "$DATA/$name/$stem.ask.json" ] || { echo "no such ask: $name/$stem" >&2; exit 1; }
|
||||
# Poll, do not inotify: the answer 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 [ ! -f "$f" ]; do
|
||||
if [ "$wait_s" -eq 0 ]; then echo "unanswered: $URL/b/$name/#ask-$stem" >&2; exit 1; fi
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then echo "timed out after ${wait_s}s waiting on $name/$stem" >&2; exit 1; fi
|
||||
sleep 2
|
||||
done
|
||||
cat -- "$f"
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
Reference in New Issue
Block a user