diff --git a/ROADMAP.md b/ROADMAP.md index a3d394e..9344577 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,7 @@ # The Booth — roadmap Design: [`docs/design/information-architecture.md`](docs/design/information-architecture.md). -Current version: `0.5.0` (U1, U2, U3, U4 and U5 landed; extracted from eshpfi 2026-09-21). +Current version: `0.5.0` (U1 through U6 landed; extracted from eshpfi 2026-09-21). ## v1 target @@ -15,17 +15,27 @@ defect — not a wish. The measurements are in the IA doc. | 3 | ~~**Declared embed seam**~~ — **landed `87e2c53`, released `v0.5.0`** | 6 regexes injected into arbitrary author HTML, load-bearing for asks | U3 | | 4 | ~~**Derived lifetime**~~ — **landed `c3a97c1`, released `v0.4.0`** | 70% of booths on the `.forever` escape hatch (54% when first counted) | U4 | | 5 | ~~**Self-announcing booths**~~ — **landed `c015a91`, released `v0.3.0`** | job 5 had no home, so it lived on the link board as 145 dead rows | U5 | -| 6 | **Benches** — registry, identity, enforced rule, migration | 69% link-board rot; the same bench posted 5× | U6 | +| 6 | ~~**Benches**~~ — registry, identity, enforced rule, migration | 69% link-board rot; the same bench posted 5× | U6 | | 7 | **Navigation at 270 items** — sections, rail, filters, grid keyboard | one flat wall; subfolder structure discarded at render | U7 | Ordering is dependency-driven, not priority-driven: **U1 → U2 → {U3, U4, U5} → U7**, with **U6 independent** of all of them (different storage, different surface) and therefore the safest thing to land first or in parallel. -**U1, U2, U3, U4 and U5 are landed — the whole middle tier is closed.** U6 -remains independent and unstarted; **U7 is now unblocked**, since its only -dependency was `{U3, U4, U5}`. Two units left to v1, and they do not depend on -each other, so either can go next. +**U1 through U6 are landed.** **U7 is the last unit before the 1.0 cut** — its +only dependency was `{U3, U4, U5}` and that closed with U3. + +⚠ **Before starting U7, read +`persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md`, and re-count +the booths first.** Half its premise is already known to be wrong — every booth +that actually needs navigation is FLAT — and the booth set churned again on +2026-09-22: the four large booths U7 was sized against (`pancake-v3-full` and +`pancake-v4-full` at 270 items, `sindra20-engines`, `sindra-finalists`) have all +been swept. The largest live booth is now `miranda-is` at 92 items, flat. Two of +23 booths have subfolders (`pewpew-ui-brief`, `dfa-concepts`) and **both are +reports** — the job where grid navigation matters least. Sections, one of U7's +four named components, buys close to nothing. The rail, the filters and the grid +keyboard are the unit. **U5's adoption is a measured prediction, not a finished result**, and it is TWO predictions rather than one. The operator declined a fleetwide announcement @@ -75,6 +85,8 @@ Where it already binds, and what the rule is in each case: | legacy ask import | `(mtime, name)`, which is the order `list_asks` gave them | | link board rows | pinned first, then newest-first | | a booth's announcement | not a collection — one flat record per booth, nothing to order (U5) | +| the bench registry | `(state rank, name casefolded, id)` — live before promoted before retired, then alphabetical, with the id as a TOTAL tie-break so two benches sharing a name cannot swap (U6) | +| the link board's dead marker | not an order — a per-row stamp read from the existing `order_for_display` sequence, so marking cannot move a row (U6) | | embed anchors in a verbatim report | **document order** — what `querySelectorAll` yields, so the author's markup decides (U3) | | the embed tail (fragments the author did not place) | **payload order**, which is the marks order `(created, id)` — one rule, whether a fragment lands at an anchor or at the end (U3) | | questions within a pick | declaration order, in the payload's `questions` LIST — carried by the format rather than by object-key insertion order (U3) | @@ -92,7 +104,9 @@ ordering: one rule, one place, every surface reading it. Where it is still to be decided, and must be before the unit ships: **U7's section ordering and its compare pairing** (sections need a stated order among themselves, not just within; pairing by filename needs a rule for what happens -to an unpaired file), and **U6's bench listing**. +to an unpaired file). **U6's bench listing is settled** — the row above. +Compare pairing is parked to v1.1 with compare mode itself, so U7 carries one +undecided rule, not two. The test for any new ordered surface: *can you write the rule down in one line?* If not, it does not have one yet. diff --git a/booth/app.py b/booth/app.py index c2627f7..918e07d 100644 --- a/booth/app.py +++ b/booth/app.py @@ -165,10 +165,19 @@ from booth.manifest import ( # noqa: E402 read_manifest, write_manifest, ) +from booth.benches import ( # noqa: E402 + BENCH_STATES, + normalize_bench_url, + read_benches, + remove_bench, + set_bench_state, + upsert_bench, +) from booth.links import ( # noqa: E402 LINK_LOCK, LINKS_FILE, PINS_FILE, + booth_target, link_entry_id, order_for_display, parse_link_entries, @@ -917,6 +926,15 @@ def create_app( # `read_manifest` already take. A booth whose `links.md` cannot # be read renders as a booth with no board. "board": _board_rows(booth), + # The bench registry, rendered on the STANDING BOARD's page and + # nowhere else: it belongs to exactly one booth, and a read per + # gallery page view would buy noise. `_board_rows` is empty for + # every other booth, so this pair is read only when it renders. + # `read_benches` never raises; a damaged registry costs its own + # panel and says so, which is the v0.2.2 lesson. + **dict(zip(("benches", "benches_error"), + read_benches(data_dir) if (booth / LINKS_FILE).is_file() + else ([], None))), # Marks: operator judgment attached to this booth or to one of # its items — a session's question (`pick`), the operator's own # remark (`note`), the operator's selection (`flag`). Rendered @@ -951,13 +969,41 @@ def create_app( try: if not (booth / LINKS_FILE).is_file(): return [] - return order_for_display( + rows = order_for_display( parse_link_entries((booth / LINKS_FILE).read_text()), read_pins(booth), ) + # DEAD = the row points at a booth that no longer exists. 156 of the + # board's 221 rows are exactly that, and nothing on the page could + # tell them apart, so the bulk-delete control that has existed since + # before this unit was unusable at that scale. Marking is all this + # does: removal stays the operator's two deliberate clicks, because + # "a migration that deletes anything" is not in v1. + for row in rows: + target = booth_target(row["url"]) + row["dead"] = target is not None and not _booth_exists(target) + return rows except (OSError, ValueError, UnicodeDecodeError): return [] + def _booth_exists(name: str) -> bool: + """Whether a booth name is a live directory. NEVER RAISES. + + SEAM REVIEW SR-2: this deliberately does NOT call `resolve_booth`, which + raises HTTPException(404) — called once per board row, one swept booth + would 404 the whole page, which is the opposite of the marker's purpose. + `booth_target` has already applied the same addressability rules + `resolve_booth` enforces, so the two cannot disagree about what is + reachable; all that is left is the existence check itself. + + Cost: one stat per booth-shaped row per render of the standing board — + 178 of 221 rows today, on the ONE booth that carries a links.md. + """ + try: + return (data_dir / name).is_dir() + except OSError: + return False + def _mark_redirect(name: str, form, anchor: str) -> RedirectResponse: """Land where the form was: the standalone marks page for a verbatim booth (its own index.html cannot show the recorded judgment), else the @@ -1459,6 +1505,47 @@ def create_app( toggle_pin(resolve_booth(name), entry) return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303) + @app.post("/b/{name}/bench-add") + def bench_add(name: str, url: str = Form(...), bname: str = Form("", alias="name")): + """Register or update a bench by normalized URL. + + A rejected URL must not 500 the page it was posted from. THE REJECTION + IS SILENT HERE, and that is stated rather than dressed up: the form's + `type="url"` catches the ordinary typo in the browser before the post, + and this `except` is the last resort for what slips past it — the bench + simply does not appear. Surfacing the reason would need a flash message, + which this service has no mechanism for; inventing one for a path the + browser already guards is not worth a unit's scope. + + `resolve_booth` is called for its 404: a POST at a booth that does not + exist is not a silent no-op. + """ + resolve_booth(name) + try: + upsert_bench(data_dir, url, bname, "operator") + except (ValueError, OSError): + pass + return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303) + + @app.post("/b/{name}/bench-state") + def bench_state(name: str, bench: str = Form(...), state: str = Form(...)): + resolve_booth(name) + if state in BENCH_STATES: + try: + set_bench_state(data_dir, bench, state) + except (ValueError, OSError): + pass + return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303) + + @app.post("/b/{name}/bench-remove") + def bench_remove(name: str, bench: str = Form(...)): + resolve_booth(name) + try: + remove_bench(data_dir, bench) + except (ValueError, OSError): + pass + return RedirectResponse(url=f"/b/{quote(name, safe='')}/", status_code=303) + def _safe_next(nxt: str) -> str: """Where to land after keep/unkeep. Defaults to the index; a booth page can ask to stay put. Only same-site absolute paths are honoured — `//` diff --git a/booth/benches.py b/booth/benches.py new file mode 100644 index 0000000..b135700 --- /dev/null +++ b/booth/benches.py @@ -0,0 +1,341 @@ +"""Benches: a running thing, registered. + +A bench is NOT a booth and NOT a bookmark. It is a durable middle-to-long-term +testing surface — jackdaw's current bench, talk's current bench, the things that +get promoted to Homepage when they are fully deployed. The standing link board +absorbed the job because it was the only surface on offer, and an O_APPEND log +with no identity turns "here is the bench again" into a fifth row rather than an +update: `talk` is on the board five times and Peedlar's root three. + +STDLIB ONLY, AND SIBLING-FREE, ON PURPOSE. `scripts/booth` imports this through +a `python3 -c` heredoc under the system python3 with no venv, exactly as it +imports `marks`, `asks`, `links` and `manifest`. A third-party import breaks +`booth bench` on every fleet host; a `from booth.links import ...` breaks it on +any host where both modules are not importable together, which is a second way +for the same invariant to fall. `tests/test_benches.py` forbids both. + +SINGLE-WRITER, MANY-READER — the opposite shape from `links.md`. The board is a +multi-writer append log because seventeen agent handles post to it at once. This +is the operator in one browser plus occasional CLI calls, so it is one file, +rewritten whole under a lock, replaced atomically. Inheriting the append-log +design here would be the mistake CLAUDE.md names by name. +""" + +from __future__ import annotations + +import fcntl +import json +import os +from dataclasses import dataclass, replace +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable +from urllib.parse import urlsplit, urlunsplit + +# At the DATA ROOT, not inside a booth. A dotfile there is invisible to +# `list_booths` and to `sweep_once` — both skip a child that is not a directory +# AND a child whose name starts with a dot, so the registry fails two guards +# rather than one. Verified against both functions (seam review SR-4, SR-5) +# rather than assumed: had either guard been absent, the sweeper would have +# eaten this file on its first tick. +BENCHES_FILE = ".benches.json" +BENCH_LOCK = ".benches.lock" + +# live → promoted (to Homepage) → retired. Order is meaningful: it is the +# first key of the rendered order, so a retired bench sinks. +BENCH_STATES = ("live", "promoted", "retired") +_STATE_RANK = {s: i for i, s in enumerate(BENCH_STATES)} + +# Display budgets, not storage limits — these land in a panel row. +NAME_MAX, OWNER_MAX, URL_MAX = 120, 64, 2048 + +# The read is on the render path, so it is bounded. 256 KiB holds thousands of +# benches; the live board has 43 non-booth rows total. +BENCHES_MAX_BYTES = 256 * 1024 + +_SCHEMES = ("http", "https") + + +@dataclass(frozen=True) +class Bench: + """One registered bench. + + `id` and `url` are two fields ON PURPOSE. The identity must be normalized so + that re-posting updates rather than appends; the href must be verbatim so a + server that cares about a trailing slash, a case-sensitive path or a query + still works when the operator clicks it. Collapsing them would make the + registry quietly change where a link goes — a bug that surfaces as "the + bench 404s" and is never traced back here. + """ + + id: str # the normalized URL — identity, and the key on disk + url: str # the URL as posted — what a click goes to + name: str + owner: str # an althing handle, or "booth" for the service + state: str + added: str # ISO-8601 with offset, from the FIRST registration + updated: str # ISO-8601 with offset, from the most recent upsert + error: str | None = None # a read-time verdict; never stored + + +def normalize_bench_url(url: str) -> str: + """The identity of a bench. Raises ValueError with a reason a human can act on. + + THE RULE, in full, because a vague identity is worse than a wrong one: + + * surrounding whitespace stripped + * scheme lowercased; anything but http/https refused + * userinfo (`user:pass@host`) REFUSED, never stripped + * host lowercased; an empty host refused + * port dropped when it is the scheme default (80 http, 443 https) + * path kept verbatim, except that a bare "/" becomes "" + * query kept verbatim INCLUDING parameter order (a query is opaque) + * fragment dropped + + WHY THE FULL URL AND NOT THE ORIGIN — measured, not chosen. Collapsing the + live board's 43 non-booth rows by origin yields 19 groups; by full URL, 35. + The difference is not duplication: it is eight distinct gitea repositories + merged into one row, three unrelated HuggingFace model cards merged into + one, and the two LRPG surfaces on `10.100.10.50:8321` merged into one — + which are the information-architecture doc's own example of two real + benches. Origin identity destroys more than it deduplicates. Full-URL + identity still collapses both cases that doc names: talk 5 → 1, Peedlar 3 → 1. + + WHY THE QUERY IS IN AND THE FRAGMENT IS OUT. Three ShutterChute rows on the + board differ only by `?token=`; they are three genuinely different one-shot + links, and dropping the query would merge them into a bench that is none of + them. A fragment is a position inside a page, never a different resource. + """ + raw = (url or "").strip() + if not raw: + raise ValueError("a bench needs a URL") + if len(raw) > URL_MAX: + raise ValueError(f"URL is longer than {URL_MAX} characters") + try: + parts = urlsplit(raw) + except ValueError as exc: # malformed IPv6 literal, etc. + raise ValueError(f"could not parse that URL: {exc}") from exc + + scheme = parts.scheme.lower() + if scheme not in _SCHEMES: + raise ValueError( + f"a bench must be http or https, not {parts.scheme or '(no scheme)'}" + ) + if "@" in parts.netloc: + # Refused, NOT stripped. Stripping would register a bench whose URL no + # longer works while telling the poster it succeeded — and would put a + # credential on a board that renders on an unauthenticated LAN surface + # on the way there. + raise ValueError("a bench URL must not carry credentials; strip the user:pass@ and re-post") + try: + host = (parts.hostname or "").lower() + port = parts.port + except ValueError as exc: # a non-numeric port + raise ValueError(f"could not read the host or port: {exc}") from exc + if not host: + raise ValueError("that URL has no host") + + default = {"http": 80, "https": 443}[scheme] + netloc = host if port in (None, default) else f"{host}:{port}" + # A bare "/" is the same resource as no path at all; a trailing slash on a + # REAL path is not, and is left alone. + path = "" if parts.path == "/" else parts.path + return urlunsplit((scheme, netloc, path, parts.query, "")) + + +# ---- storage ---------------------------------------------------------------- + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def _cap(value: object, limit: int, field: str) -> str: + if not isinstance(value, str): + raise ValueError(f"{field} must be text, not {type(value).__name__}") + return value[:limit] + + +def _bench_from(bench_id: str, row: object) -> Bench: + """One stored row to a record. Raises ValueError on any shape it cannot + trust — this is the STRICT half, used by the write path and by the read + path's single try/except.""" + if not isinstance(row, dict): + raise ValueError(f"{bench_id}: expected an object, found {type(row).__name__}") + state = row.get("state", "live") + if state not in BENCH_STATES: + raise ValueError(f"{bench_id}: unknown state {state!r}") + return Bench( + id=bench_id, + url=_cap(row.get("url", bench_id), URL_MAX, "url"), + name=_cap(row.get("name", ""), NAME_MAX, "name"), + owner=_cap(row.get("owner", ""), OWNER_MAX, "owner"), + state=state, + added=_cap(row.get("added", ""), 64, "added"), + updated=_cap(row.get("updated", ""), 64, "updated"), + ) + + +def _read_bytes(path: Path) -> bytes: + """Read at most BENCHES_MAX_BYTES + 1 bytes. + + BOUNDS THE READ, NEVER THE STAT. A FIFO reports st_size 0 and then blocks + forever; a size cap that trusts `st_size` inherits a meaning it does not + have, and the 2026-09-22 incident in this repo was exactly that — a bound + that opened a service-wide hang. Reading one byte past the cap is how you + learn you are over it without reading the rest. + """ + with path.open("rb") as fh: + return fh.read(BENCHES_MAX_BYTES + 1) + + +def _load_strict(root: Path) -> dict[str, Bench]: + """Every bench, or ValueError. The write path's reader. + + Whole-file, not per-row: a registry with one unreadable row is a registry + somebody has to look at, and quietly dropping the row is how a bench + disappears without anyone being told. + """ + path = Path(root) / BENCHES_FILE + if not path.exists(): + return {} + blob = _read_bytes(path) + if len(blob) > BENCHES_MAX_BYTES: + raise ValueError(f"registry is larger than {BENCHES_MAX_BYTES} bytes") + try: + raw = json.loads(blob.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"registry is not valid JSON: {exc}") from exc + if not isinstance(raw, dict): + raise ValueError(f"registry must be an object keyed by URL, found {type(raw).__name__}") + return {k: _bench_from(k, v) for k, v in raw.items()} + + +def read_benches(root: Path) -> tuple[list[Bench], str | None]: + """Every registered bench in the rendered order, plus a read-time error. + + NEVER RAISES. This runs on the render path, and the v0.2.2 lesson in this + repo was learned the expensive way: a poisoned `.marks.json` returned 500 + for `/` and `/healthz` across all 25 booths. A registry that cannot be read + costs its own panel, never the page. + + ABSENT AND DAMAGED ARE DIFFERENT and must render differently — only one of + them needs a human. Absent is `([], None)`; damaged is `([], "why")`. + """ + try: + return order_benches(_load_strict(root).values()), None + except ValueError as exc: + return [], str(exc) + except OSError as exc: + return [], f"registry could not be read: {exc}" + + +def _write_all(root: Path, benches: dict[str, Bench]) -> None: + """Atomic replace. Caller holds the lock. + + Temp file + os.replace, so a reader never sees a partial file and a crash + mid-write cannot truncate the registry into a shorter — and therefore + quieter — set of benches. CLAUDE.md invariant 5. + """ + root = Path(root) + path = root / BENCHES_FILE + payload = { + b.id: {"url": b.url, "name": b.name, "owner": b.owner, + "state": b.state, "added": b.added, "updated": b.updated} + # The key IS the id, so the record does not carry it twice — two copies + # of one fact is two things that can disagree. + for b in benches.values() + } + tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}") + tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + os.replace(tmp, path) + + +class _Locked: + """Exclusive flock over the whole read-modify-write, on a sidecar.""" + + def __init__(self, root: Path): + self.root = Path(root) + self.root.mkdir(parents=True, exist_ok=True) + self.path = self.root / BENCH_LOCK + + def __enter__(self): + self.path.touch(exist_ok=True) + self.fh = self.path.open("r+") + fcntl.flock(self.fh, fcntl.LOCK_EX) + return self + + def __exit__(self, *exc): + fcntl.flock(self.fh, fcntl.LOCK_UN) + self.fh.close() + return False + + +def upsert_bench(root: Path, url: str, name: str, owner: str) -> tuple[Bench, bool]: + """Register or update by normalized URL. Returns (bench, created). + + READS ARE LENIENT, WRITES ARE STRICT — and this is the strict side. A write + over a registry that cannot be parsed RAISES rather than starting a fresh + one: on 2026-09-21 this repo learned that a tolerant writer over a damaged + `.marks.json` wipes the operator's judgment, and a tolerant reader is a + completely different decision from a tolerant writer. + + `added` survives an update; `state` survives too, so a promoted bench that + re-announces itself after a deploy is not silently demoted. + """ + bench_id = normalize_bench_url(url) + with _Locked(root): + benches = _load_strict(root) # raises on damaged — deliberate + prior = benches.get(bench_id) + now = _now() + bench = Bench( + id=bench_id, + url=(url or "").strip(), + name=_cap(name or "", NAME_MAX, "name"), + owner=_cap(owner or "", OWNER_MAX, "owner"), + state=prior.state if prior else "live", + added=prior.added if prior else now, + updated=now, + ) + benches[bench_id] = bench + _write_all(root, benches) + return bench, prior is None + + +def set_bench_state(root: Path, bench_id: str, state: str) -> Bench | None: + """Move a bench between live / promoted / retired. None if no such bench.""" + if state not in BENCH_STATES: + raise ValueError(f"state must be one of {', '.join(BENCH_STATES)}, not {state!r}") + with _Locked(root): + benches = _load_strict(root) + prior = benches.get(bench_id) + if prior is None: + return None + moved = replace(prior, state=state, updated=_now()) + benches[bench_id] = moved + _write_all(root, benches) + return moved + + +def remove_bench(root: Path, bench_id: str) -> Bench | None: + """Drop one bench. Returns the removed record, or None.""" + with _Locked(root): + benches = _load_strict(root) + gone = benches.pop(bench_id, None) + if gone is None: + return None + _write_all(root, benches) + return gone + + +def order_benches(benches: Iterable[Bench]) -> list[Bench]: + """ORDER: (state rank, name casefolded, id). + + live before promoted before retired, then alphabetical, with the id as a + TOTAL tie-break so two benches sharing a name cannot swap between renders. + CLAUDE.md invariant 6 — the Booth's job is comparison, and an order that + moves between page loads files the operator's judgment against the wrong + row. Pure: no I/O, and the input sequence is not mutated. + """ + return sorted(benches, key=lambda b: (_STATE_RANK.get(b.state, len(BENCH_STATES)), + b.name.casefold(), b.id)) diff --git a/booth/links.py b/booth/links.py index 895b7c2..b3fda79 100644 --- a/booth/links.py +++ b/booth/links.py @@ -14,6 +14,7 @@ import hashlib import os import re from pathlib import Path +from urllib.parse import unquote, urlsplit # ---- the standing link board ------------------------------------------------ # @@ -194,3 +195,52 @@ def order_for_display(entries: list[dict], pinned: set[str]) -> list[dict]: stamped = [{**e, "pinned": e["id"] in pinned} for e in entries] stamped.reverse() # newest first return [e for e in stamped if e["pinned"]] + [e for e in stamped if not e["pinned"]] + + +# ---- what counts as a booth link ------------------------------------------- + + +def booth_target(url: str) -> str | None: + """The booth NAME a URL points at, or None when it is not a booth link. + + ONE PREDICATE, THREE CALLERS — the CLI's `link` refusal, the board's + dead-row marker, and `bench import`'s classifier. They must agree: a rule + that refuses a shape the board then fails to mark as dead (or the reverse) + is two readers of one truth, which is the bug this repo has now paid for + three times. `tests/test_benches.py` runs one table through every caller. + + HOST-AGNOSTIC AND PATH-SHAPED. A row is a booth link when its path is + `/b/` or `/b//...`, whatever the host. NOT a host allowlist: the + fleet reaches this service as `10.100.10.50:8090`, `localhost:8090` and + `nh3-dev.nh3.internal:8090`, and an allowlist would silently fail to refuse + from whichever name somebody used next — a rule that fails OPEN on the exact + case it exists to catch. The accepted cost is that a third-party URL with a + `/b/` path reads as a booth link; that failure is visible (a refusal + naming the reason) rather than silent, and no such URL is on the board. + + THE NAME SEGMENT IS PERCENT-DECODED. `app.py` emits booth links through + `quote(name, safe="")`, so a booth whose name needs encoding appears on the + board encoded. Comparing the raw segment against a directory name would mark + every such booth permanently dead and echo the encoded form back at the + poster in the refusal message. + + The returned name passes the SAME addressability rules `resolve_booth` + enforces (non-empty, no leading dot, no separator, no `..`), so the two + cannot disagree about what is reachable. + + NEVER RAISES. A board row is arbitrary operator-editable text; a predicate + that raises on one row takes the whole page. + """ + try: + parts = urlsplit((url or "").strip()) + if parts.scheme.lower() not in ("http", "https"): + return None + segments = parts.path.split("/") + if len(segments) < 3 or segments[1] != "b": + return None + name = unquote(segments[2]) + except (ValueError, UnicodeDecodeError): + return None + if not name or name.startswith(".") or "/" in name or "\\" in name or ".." in name: + return None + return name diff --git a/booth/templates/base.html b/booth/templates/base.html index 300f22e..737544d 100644 --- a/booth/templates/base.html +++ b/booth/templates/base.html @@ -496,7 +496,30 @@ .markdown-body table{border-collapse:collapse;display:block;overflow-x:auto} .markdown-body th,.markdown-body td{border:1px solid var(--rk-line,#252a35);padding:.4em .7em} .markdown-body img{max-width:100%} - + + /* U6 — the bench registry, on the standing board's page only. */ + .benches{margin:1rem 0;border:1px solid var(--line,#2a2a2a);border-radius:6px;overflow:hidden} + .bench-head{display:flex;gap:.6rem;align-items:baseline;padding:.5rem .7rem;background:rgba(255,255,255,.03)} + .bench-title{font-weight:600} + .bench-note,.bench-empty{opacity:.6;font-size:.85em} + .bench-empty{padding:.6rem .7rem} + .bench-err{padding:.6rem .7rem;color:#f2b8b5;background:rgba(242,184,181,.08)} + .bench-row{display:flex;gap:.6rem;align-items:center;padding:.45rem .7rem;border-top:1px solid var(--line,#2a2a2a)} + .bench-row.is-retired{opacity:.5} + .bench-state{font-size:.7em;text-transform:uppercase;letter-spacing:.06em;padding:.1rem .4rem;border-radius:3px;background:rgba(255,255,255,.08)} + .bench-row.is-live .bench-state{background:rgba(120,200,140,.18)} + .bench-row.is-promoted .bench-state{background:rgba(130,170,240,.18)} + .bench-main{flex:1;min-width:0} + .bench-url{font-size:.78em;opacity:.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .bench-acts{display:flex;gap:.3rem} + .bench-to,.bench-rm{font-size:.75em;padding:.15rem .4rem;cursor:pointer} + .bench-add{display:flex;gap:.4rem;padding:.5rem .7rem;border-top:1px solid var(--line,#2a2a2a)} + .bench-add input[type=url]{flex:2;min-width:0} + .bench-add input[type=text]{flex:1;min-width:0} + /* A board row whose booth has been swept. Marked, never auto-removed. */ + .board-row.board-dead{opacity:.45} + .board-dead-tag{font-size:.9em;color:#f2b8b5;opacity:.9} +
diff --git a/booth/templates/booth.html b/booth/templates/booth.html index d432d92..0060efc 100644 --- a/booth/templates/booth.html +++ b/booth/templates/booth.html @@ -66,7 +66,64 @@ {% else %}

{{ name }}

{% endif %} - {% if uploaded %}⬆ pickup {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}{{ marks_open }} open · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %} + {% if uploaded %}⬆ pickup {% endif %}{% if board or benches or benches_error %} + {# THE BENCH REGISTRY. A bench is a running thing — jackdaw's current bench, + talk's current bench, the things that get promoted to Homepage when they + are fully deployed. NOT a booth (a booth announces itself and is swept) and + NOT a bookmark (a repo page, a model card — those stay on the board below). + + Identity is the NORMALIZED URL, so re-announcing a bench updates its row + instead of appending a fifth. `talk` was on the board five times. + + ORDER: state (live → promoted → retired), then name, then id as a total + tie-break so two benches sharing a name cannot swap between renders. + + The href is `b.url` — the URL AS POSTED — never `b.id`. The id is + normalized for identity; a server that cares about a trailing slash or a + case-sensitive path would 404 on it. #} +
+
+ {{ benches|length }} bench{{ '' if benches|length == 1 else 'es' }} + a running thing, registered · re-posting updates the row +
+ {% if benches_error %} + {# DAMAGED AND ABSENT MUST NOT RENDER THE SAME. Only one of them needs a + human, and the v0.2.2 outage was learned by treating them alike. #} +
the bench registry could not be read: {{ benches_error }}
+ {% elif not benches %} +
no benches registered yet — booth bench add <url> <name>
+ {% endif %} + {% for b in benches %} +
+ {{ b.state }} +
+ {{ b.name or b.url }} +
{{ b.url }}
+
+
+ {% if b.owner %}{{ b.owner }}{% endif %} +
+
+ + {% for s in ("live", "promoted", "retired") %} + {% if s != b.state %} + + {% endif %} + {% endfor %} + +
+
+ {% endfor %} +
+ + + +
+
+{% endif %} + +{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %} · {{ lifetime(kept, hold, expires_in) }}{% else %}{% if marks_open %}{{ marks_open }} open · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · {{ lifetime(kept, hold, expires_in) }}{% endif %}
{% if items %}⬇ zip{% endif %} {{ provenance(manifest) }} {# A durable multi-writer board gets no one-click wipe — same rule as the @@ -143,14 +200,17 @@ formaction="/b/{{ name_url }}/unlink-many">🗑 delete 0 {% for e in board %} -
+ {# DEAD: the row points at a booth that has been swept. 156 of 221 rows. + MARKED, never removed — removal is the operator ticking the box and using + the bulk control that was already here. #} +
{{ e.desc }} -
{{ e.url }}
+
{{ e.url }}{% if e.dead %} booth is gone{% endif %}
{% if e.who %}{{ e.who }}{% endif %} diff --git a/docs/archive/links-2026-09-22.md b/docs/archive/links-2026-09-22.md new file mode 100644 index 0000000..6929a4b --- /dev/null +++ b/docs/archive/links-2026-09-22.md @@ -0,0 +1,234 @@ +# Standing link board — verbatim archive, 2026-09-22 + +Captured before U6 (benches) shipped, per the ROADMAP rule that a migration +destroys nothing. 221 rows: 178 booth URLs (156 of them pointing at booths +already swept) and 43 non-booth rows, 35 distinct after normalization. + +U6 itself deletes NOTHING — the dead rows are marked and removal stays the +operator's two clicks. This archive exists so the board is recoverable +off-box once he starts pruning, and so the measurements above are checkable +against the bytes they were taken from. + +```markdown +- [LRPG Authoring Studio — live demo endpoint (ldp-saga)](http://10.100.10.50:8321/Authoring%20Studio.dc.html) · ldp-dev · 2026-08-19 10:04 +- [LRPG GM Player — live demo endpoint (ldp-saga; open in iPhone Safari for native)](http://10.100.10.50:8321/GM%20Playback.dc.html) · ldp-dev · 2026-08-19 10:04 +- [Scriberr — self-hosted transcription + speaker diarization (ana-ml2 GPU1); also http://10.250.50.54:8080](http://scriberr.ana.internal:8080/) · infra-ops · 2026-08-23 19:31 +- [talk — chat with a fleet voice (HTTPS, trusted cert, no warning)](https://talk.nh3.phasefinal.com:8092/) · tts-dev · 2026-09-06 23:35 +- [YTVC noise floor A/B — raw vs shipped vs +75 Hz high-pass (2 clips)](http://10.100.10.50:8090/b/ytvc-noise/) · yt-voice-clipper-dev · 2026-09-09 10:58 +- [the interview noise floor measured — denoise BEFORE distilling carries 4x better](http://10.100.10.50:8090/b/noise-floor/) · tts-dev · 2026-09-09 11:00 +- [the 5 distillation sources staged for professional denoising — drop back as -clean.wav](http://10.100.10.50:8090/b/denoise-in/) · tts-dev · 2026-09-09 11:01 +- [hamr: the sliver lever + mutual-block pairing -- the operator four sites at two lever settings (2026-09-09)](http://10.100.10.50:8090/b/hamr-sliver-lever/) · nh3-dev · 2026-09-09 11:13 +- [YTVC subtractive denoiser audition — raw vs RNNoise vs DeepFilterNet 3 vs anlmdn, 2 clips + numbers](http://10.100.10.50:8090/b/ytvc-denoise/) · yt-voice-clipper-dev · 2026-09-09 11:13 +- [denoise-in — 5 clone sources handed to yt-voice-clipper-dev for a proper deep denoise pass](http://10.100.10.50:8090/b/denoise-in/) · tts-dev · 2026-09-09 12:49 +- [hamr: why the gear circle and peak edges read rough -- source vs output vs difference, measured (2026-09-09)](http://10.100.10.50:8090/b/hamr-rough-edges/) · nh3-dev · 2026-09-09 12:52 +- [hamr: CLEAN mode rendered on six marks -- and the 1024-vs-4096 test showing my instrument was under-resolved (2026-09-09)](http://10.100.10.50:8090/b/hamr-clean-mode/) · nh3-dev · 2026-09-09 13:29 +- [denoise A/B — 5 sources before/after, level-matched; emmie regressed](http://10.100.10.50:8090/b/denoise-ab/) · tts-dev · 2026-09-09 13:56 +- [REDO step 1 — pick anchors for lawson/jo/nichols/ana on the cleaned sources (one form)](http://10.100.10.50:8090/b/redo-anchors/asks) · tts-dev · 2026-09-09 14:01 +- [hamr: the sliver lever re-rendered at 4x -- the operator width's chunk is real geometry and 7x the default's edge residual (open ask: lever-default)](http://10.100.10.50:8090/b/hamr-sliver-lever/) · hamr-dev · 2026-09-09 14:31 +- [hamr: the golden corpus re-rendered at 4x -- the 1024 px instrument inflated edge roughness by 80% on a reading it could not resolve; the colour numbers were never affected](http://10.100.10.50:8090/b/hamr-corpus-4x/) · hamr-dev · 2026-09-09 14:44 +- [REDO step 2 — lawson register picks on the cleaned source (7 inline)](http://10.100.10.50:8090/b/redo-lawson/) · tts-dev · 2026-09-09 14:49 +- [REDO step 2 — jo register picks on the cleaned source (7 inline)](http://10.100.10.50:8090/b/redo-jo/) · tts-dev · 2026-09-09 14:49 +- [REDO step 2 — nichols register picks on the cleaned source (7 inline)](http://10.100.10.50:8090/b/redo-nichols/) · tts-dev · 2026-09-09 14:49 +- [REDO step 2 — ana register picks on the cleaned source (7 inline)](http://10.100.10.50:8090/b/redo-ana/) · tts-dev · 2026-09-09 14:49 +- [lawson warm rescue — seed axis vs instruction axis (warm is the corpus's untuned string)](http://10.100.10.50:8090/b/lawson-warm/) · tts-dev · 2026-09-09 15:04 +- [bank denoise vs source redo — v3+DN hits 49.8 dB; may make the whole redo unnecessary](http://10.100.10.50:8090/b/bank-denoise/) · tts-dev · 2026-09-09 15:16 +- [bank denoise A/B — lawson +26 dB, jo +21 dB; 4 of 10 banks would be DAMAGED by it](http://10.100.10.50:8090/b/bank-dn-ab/) · tts-dev · 2026-09-09 15:31 +- [Margaery step 1 — anchor picks; ⚠ 15.44s single-clip source, thinnest yet](http://10.100.10.50:8090/b/margaery-anchor/) · tts-dev · 2026-09-09 15:43 +- [hamr: 1-2 px regions -- the operator's hue/lightness rule separates 10-25x on his own artwork; lightness does the work; the eye survives at today's default](http://10.100.10.50:8090/b/hamr-thin-regions/) · hamr-dev · 2026-09-09 15:44 +- [pewpewstudio web UI restyled on PowerPellet (arcade design system): every screen, dark + daylight (2026-09-09)](http://10.100.10.50:8090/b/pewpew-powerpellet/) · pewpew-dev · 2026-09-09 15:47 +- [Margaery — 7 registers x 5 seeds, pick one per register (step 2 of 3)](http://10.100.10.50:8090/b/margaery-registers/) · tts-dev · 2026-09-09 16:03 +- [Margaery — denoise A/B on the spliced bank (step 3 of 3)](http://10.100.10.50:8090/b/margaery-denoise/) · tts-dev · 2026-09-09 16:19 +- [hamr: the blend-distance gate landed -- the crest's eye ring survives STRIP_WIDTH, the gear's rims and peak's dark-teal strip still go](http://10.100.10.50:8090/b/hamr-thin-regions/) · hamr-dev · 2026-09-09 17:10 +- [Breeze — probing the 7 unused direction axes (vendor instructions verbatim)](http://10.100.10.50:8090/b/breeze-axes/) · tts-dev · 2026-09-09 17:11 +- [ERP run 7 decision brief — gate failure, exposure, 5 decisions awaiting Vuong](http://10.100.10.50:8090/b/run07-decisions/) · infra-ops · 2026-09-09 18:10 +- [R47 tune line runs 4-7 — run 7: length FLAT, RP shape markers moved (quote-first 29%→15%)](http://10.100.10.50:8090/b/r47-runs/) · brokkr-smithy-dev · 2026-09-09 18:46 +- [hamr: the blend gate rendered -- the crest's eye ring comes back at STRIP_WIDTH, the gear's rims and peak's strip still go](http://10.100.10.50:8090/b/hamr-thin-regions/) · hamr-dev · 2026-09-09 18:52 +- [Tag sweep redone — leak test = vocabulary test; the ear questions](http://10.100.10.50:8090/b/tag-sweep/) · tts-dev · 2026-09-09 22:49 +- [hamr henge/66 peak: which site is 'the chunk' -- ask + the four candidate sites](http://10.100.10.50:8090/b/hamr-henge66-peak/) · hamr-dev · 2026-09-09 23:13 +- [Chunk seams A/B — paragraph-only chunking, and the render ceiling is lower than we thought](http://10.100.10.50:8090/b/chunk-seams/) · tts-dev · 2026-09-09 23:29 +- [hamr peak: the operator's chunk (the small peak's left face) -- under the size levers, before/after the apex unit](http://10.100.10.50:8090/b/hamr-peak-left-face/) · hamr-dev · 2026-09-10 07:26 +- [hamr: the peak's halo -- the tint reach null, the sliver lever, the support rule (henge/66 third rule)](http://10.100.10.50:8090/b/hamr-peak-halo/) · hamr-dev · 2026-09-10 07:58 +- [Level decay is LENGTH-driven, not soft/whisper — every direction collapses at 1400 chars](http://10.100.10.50:8090/b/level-decay/) · tts-dev · 2026-09-10 08:47 +- [BabyBronte voice A/B — base vs H02 LoRA on 9 neutral prompts, 2 seeds each](http://10.100.10.50:8090/b/babybronte-voice/) · infra-ops · 2026-09-10 15:08 +- [hamr: the 2.5 fold -- frame closing fix, the O(N) vote (byte-identical peak), the VMDE engine document read against hamr](http://10.100.10.50:8090/b/hamr-2-5-fold/) · hamr-dev · 2026-09-10 15:57 +- [hamr: the region-energy segmenter spike (henge 71) -- the Potts prior in the vote's seat, against the landed 2.5](http://10.100.10.50:8090/b/hamr-region-energy/) · hamr-dev · 2026-09-10 16:05 +- [BabyBronte rung 2 — 1.7B base vs 1.7B tuned vs 0.6B tuned, 9 prompts, 2 seeds](http://10.100.10.50:8090/b/babybronte-1p7b/) · infra-ops · 2026-09-10 22:38 +- [hamr: the state of the pipeline at c18c4e1 (v1.3.0 + the hygiene unit) -- seven reference marks and the synthetic corpus, source | 1x | 4x](http://10.100.10.50:8090/b/hamr-state-2026-09-11/) · hamr-dev · 2026-09-10 23:20 +- [BabyBronte rung 3 — 4B base vs 4B tuned vs 1.7B tuned, + the Abernathy frame prompt](http://10.100.10.50:8090/b/babybronte-4b/) · infra-ops · 2026-09-11 05:35 +- [bragi :8196 — the fleet direction layer, LIVE 2026-09-11 (U1 null director, +2.32ms TTFA cost, cap 6400)](http://irv-ml1.nh3.internal:8196/health) · nh3-dev · 2026-09-11 05:47 +- [BabyBronte rung 3 (step-75 recut) — 4B base vs 4B tuned vs 1.7B, + frame and embedded-instruction prompts](http://10.100.10.50:8090/b/babybronte-4b/) · infra-ops · 2026-09-11 05:54 +- [Bragi U2 spike — blinded 5-arm fast-director audition, 7 inline asks, ear verdict gates U2](http://10.100.10.50:8090/b/bragi-u2-spike/) · nh3-dev · 2026-09-11 06:00 +- [Skaldsong beat→paragraph — 10 formats on the adapted 4B vs an instruct model, + stitched story](http://10.100.10.50:8090/b/skaldsong-beats/) · infra-ops · 2026-09-11 06:24 +- [hamr state booth at a7ee4ab: seven reference marks + sixteen synthetic cases, source | 1x | 4x, after the ridge-order and test-hygiene units](http://10.100.10.50:8090/b/hamr-state-2026-09-11-a7ee4ab/) · hamr-dev · 2026-09-11 08:41 +- [hamr state booth, clean mode default (colour_geometry 3.13): only the crest's white tick changes against a7ee4ab](http://10.100.10.50:8090/b/hamr-state-2026-09-11-clean/) · hamr-dev · 2026-09-11 10:23 +- [hamr run_smoothing 2.2, the corner core: circuit/gear/peak/crest/vastblue at the new corner rule, with corner overlays](http://10.100.10.50:8090/b/hamr-corner-core/) · hamr-dev · 2026-09-11 11:12 +- [hamr regularizer 3.0, the run solve (U7 on runs): circuit/gear/peak/crest/vastblue after the stretch pool and solve, with the circuit site the first form broke](http://10.100.10.50:8090/b/hamr-run-solve/) · hamr-dev · 2026-09-11 13:50 +- [hamr regularizer 3.1, the junction at the meet: the circuit's pads 3.0 vs 3.1 and the five marks](http://10.100.10.50:8090/b/hamr-run-solve-31/) · hamr-dev · 2026-09-11 15:02 +- [BabyYarros eval — voice A/B + beat→paragraph + delta_cb (Base@125 vs Instruct vs base control)](http://10.100.10.50:8090/b/babyyarros-voice/) · infra-ops · 2026-09-11 15:59 +- [bifrost 1.2.0 on the gitea PyPI index — wire v0.8 memory.* record profile (#17)](https://gitea.phasefinal.com/vh/-/packages/pypi/bifrost/1.2.0) · bifrost-dev · 2026-09-11 16:58 +- [bifrost #17 — wire v0.8 record profile (adoption arc, gates, release)](https://gitea.phasefinal.com/vh/bifrost/issues/17) · bifrost-dev · 2026-09-11 16:58 +- [bifrost 1.2.1 — supplement-fold patch (explicit record-engine guards; descriptor ownership boundary)](https://gitea.phasefinal.com/vh/-/packages/pypi/bifrost/1.2.1) · bifrost-dev · 2026-09-11 17:32 +- [BabyYarros — Janis beat: 4 prompt arms x 4 seeds, beat->paragraph formula fitting](http://10.100.10.50:8090/b/babyyarros-janis/) · infra-ops · 2026-09-11 21:15 +- [hamr on five fresh arbo marks (owl, bee, rocket, wolf, lantern) -- landed pipeline, clean mode, 1x + 4x](http://10.100.10.50:8090/b/hamr-arbo-logos/) · hamr-dev · 2026-09-11 22:35 +- [FV colo on-site playbook — print before the trip (OPNsense + fv-ml1, anti-lockout)](http://10.100.10.50:8090/b/fv-onsite/) · infra-ops · 2026-09-12 07:54 +- [hamr arbo marks AFTER colour_decomposition 2.10 (the interior-ends tint reading): owl before/after, the four others byte-identical](http://10.100.10.50:8090/b/hamr-arbo-logos-2/) · hamr-dev · 2026-09-12 07:55 +- [hamr: the midline rule (colour_geometry 3.14) on the owl -- source | before | midline | far, 4x, and the runs the instrument flagged](http://10.100.10.50:8090/b/hamr-midline/) · hamr-dev · 2026-09-12 22:18 +- [Qwen3.8-Flash-Next ABLITERATED NVFP4 + FP8 PLE — candidate for the fv-ml1 single-card gen seat](https://huggingface.co/dealignai/Qwen3.8-Flash-Next-ABLITERATED-NVFP4) · infra-ops · 2026-09-12 22:21 +- [vLLM canonical Qwen3.8-Flash-Next recipe — PLE CPU-offload + the don't-enable-MTP measurement](https://recipes.vllm.ai/Qwen/Qwen3.8-Flash-Next/) · infra-ops · 2026-09-12 22:21 +- [hamr: FAR shipped (colour_geometry 3.16) -- the five arbo marks before | after at 4x, and the per-run instrument](http://10.100.10.50:8090/b/hamr-far/) · hamr-dev · 2026-09-13 00:13 +- [hamr: edge-pixel rule spike -- census overlays (third-layer boundary pixels, green explained / red not) and the geometry arms](http://10.100.10.50:8090/b/hamr-edge-pixels/) · hamr-dev · 2026-09-13 09:28 +- [hamr: colour_geometry 3.17 the line clause -- crest eye ring gone, lens kept; owl / circuit / lantern byte-identical at 4x](http://10.100.10.50:8090/b/hamr-width-clause/) · hamr-dev · 2026-09-13 14:00 +- [hamr: the golden corpus at colour_geometry 3.17 (the line clause) -- seven reference marks, faces and runs, source | 1x | 4x](http://10.100.10.50:8090/b/hamr-corpus-3.17/) · hamr-dev · 2026-09-13 16:53 +- [hamr: the DXF cut document beside the SVG runs profile on the seven corpus marks (source | SVG | DXF, 1x and 4x zooms; .dxf files alongside)](http://10.100.10.50:8090/b/hamr-dxf/) · hamr-dev · 2026-09-13 23:18 +- [Flash-Next gen-large candidate #1: abliterated + W4A16 weight-only experts + FP8 PLE; blocked only by a missing ple_embedding_dtype config key](https://huggingface.co/gorbatjovy/qwen3.8-flash-next-abliterated-NVFP4-plefp8) · infra-ops · 2026-09-14 02:16 +- [Flash-Next gen-large candidate #2: fully weight-only (W4A16 experts + FP8_PB_WO dense), loads as-is, but NOT abliterated](https://huggingface.co/lovedheart/Qwen3.8-Flash-Next-NVFP4-W4A16-4-Over-6-FP8) · infra-ops · 2026-09-14 02:16 +- [cyberprev-27b — abliterated Qwen3.8-27B sec seat (fv-ml1 GPU0, dflash k=7), replaced sentinel-r3](http://10.251.50.54:8025/docs) · infra-ops · 2026-09-14 04:29 +- [hamr-server 1.5: the SPA booth pass with Download DXF (state 08b) and the refused-selection state re-pinned to server 1.6](http://10.100.10.50:8090/b/hamr-server-1.5/) · hamr-dev · 2026-09-14 10:22 +- [hamr web front end UI brief (requirements and flow for a design system; also docs/design/ui-brief.md)](https://claude.ai/code/artifact/eae98fde-784b-4f4d-b0e3-c87a229da564) · hamr-dev · 2026-09-14 10:25 +- [https://claude.ai/code/artifact/eae98fde-784b-4f4d-b0e3-c87a229da564](https://claude.ai/code/artifact/eae98fde-784b-4f4d-b0e3-c87a229da564) · hamr-dev · 2026-09-14 10:25 +- [hamr web front end UI brief, boothed (kept): index.html + ui-brief.md](http://10.100.10.50:8090/b/hamr-ui-brief/) · hamr-dev · 2026-09-14 10:56 +- [pewpewstudio web front end UI brief, boothed (kept): index.html + ui-brief.md + the integration package (tarball + fixtures)](http://10.100.10.50:8090/b/pewpew-ui-brief/) · pewpew-dev · 2026-09-14 12:42 +- [pewpewstudio web front end UI brief (flow, shape, requirements for a design agent; also docs/design/ui-brief.md)](https://claude.ai/code/artifact/281bcdc7-bcce-46d7-b0ca-ec90df22151f) · pewpew-dev · 2026-09-14 12:42 +- [Headscale: Tailscale setup for macOS/iOS/tvOS — GUI steps + downloadable config profiles](https://headscale.phasefinal.com/apple) · infra-ops · 2026-09-14 13:49 +- [pewpewstudio: the UI blueprint vendored (Claude Design handoff from booth 28-indigo) -- provenance, state inventory, fidelity notes; source at docs/design/blueprint/](http://10.100.10.50:8090/b/pewpew-ui-brief/blueprint/README.md) · pewpew-dev · 2026-09-14 18:38 +- [pewpewstudio web: the blueprint implemented -- one still per surface per state (67), cabinet + daylight](http://10.100.10.50:8090/b/pewpew-blueprint/) · pewpew-dev · 2026-09-14 20:55 +- [hamr: the C kernel for the cubic fit -- where its geometry differs from 2.4 (4x panels) and the ask on the gate](http://10.100.10.50:8090/b/hamr-cubic-kernel/) · hamr-dev · 2026-09-14 22:28 +- [Homepage — Parakeet ASR card now live under AI - Audio Tools (fv-ml1 GPU 3, :8300)](http://10.0.50.45:5100/) · nh3-dev · 2026-09-15 01:41 +- [talk v10 — Sindra with ears: push-to-talk STT via ext-stt + barge-in (nh3-dev)](https://talk.nh3.phasefinal.com:8092/) · nh3-dev · 2026-09-15 08:27 +- [talk v10 — the fleet speaks AND listens (Grima push-to-talk + barge-in)](https://talk.nh3.phasefinal.com:8092/) · nh3-dev · 2026-09-15 08:28 +- [Open-weight releases landscape scan 2026-09-15 — LLM/image/TTS, ranked + licenses verified](https://gitea.phasefinal.com/vh/brokkr-smithy/src/commit/6adcde6/research/landscape-scans/open-weight-releases-2026-09-15.md) · brokkr-scan-dev · 2026-09-15 09:20 +- [ldp-saga — voice-over step with authored words: GM stage (iPhone) + Studio drawer screenshots](http://10.100.10.50:8090/b/ldp-vo-body/) · ldp-dev · 2026-09-15 11:31 +- [talk PREVIEW (v11 unreleased) — kiosk persona + prompt library + hands-free VAD; http so no mic](http://10.100.10.50:8095/) · nh3-dev · 2026-09-15 14:08 +- [talk v12 LIVE — hands-free VAD + 4 personas (assistant/sindra/narrator/kiosk) + Grima STT](https://talk.nh3.phasefinal.com:8092/) · nh3-dev · 2026-09-15 14:13 +- [talk v12 — internal IP (accept the cert warning; wildcard covers names, not IPs). Hands-free + 4 personas.](https://10.100.10.50:8092/) · nh3-dev · 2026-09-15 14:18 +- [hamr circuit: census of thin surviving regions, source|1x|4x per site (2026-09-16)](http://10.100.10.50:8090/b/hamr-circuit-slivers/) · hamr-dev · 2026-09-15 15:03 +- [hamr circuit: the full cut file (SVG runs profile + DXF) on white, 1x and 4x whole (2026-09-16)](http://10.100.10.50:8090/b/hamr-dxf/) · hamr-dev · 2026-09-15 15:10 +- [hamr owl (arbo 00-seed7777): the full cut file on white, 1x and 4x (2026-09-16)](http://10.100.10.50:8090/b/hamr-owl-cut/) · hamr-dev · 2026-09-15 15:17 +- [hamr circuit: the ten arrowed sites (possum-51), source | faces 4x | runs 4x, with the runs and junctions at each (2026-09-16)](http://10.100.10.50:8090/b/hamr-circuit-arrows/) · hamr-dev · 2026-09-15 15:17 +- [hamr: the owl before/after the shade rule (colour_decomposition 2.12), the four arrowed sites at 1x and 4x](http://10.100.10.50:8090/b/hamr-owl-shades/) · hamr-dev · 2026-09-15 20:01 +- [hamr unit 2: the circuit's edge teeth before/after (colour_geometry 3.27) -- the ten arrowed sites and two interior seam sites, SOURCE | before | after at 1x and 4x](http://10.100.10.50:8090/b/hamr-circuit-teeth/) · hamr-dev · 2026-09-15 22:57 +- [hamr 3.27: every thin excursion the clause reads on twenty marks at the pixel bar (175 panels; GOES/stays in each caption)](http://10.100.10.50:8090/b/hamr-excursions-f10/) · hamr-dev · 2026-09-15 22:57 +- [BabyYarros beat→paragraph: same beat, 4 arms (base / raw-text / pair-SFT 2ep / 3ep)](http://10.100.10.50:8090/b/babyyarros-beats/) · infra-ops · 2026-09-16 07:29 +- [hamr v2 S0: the smoother's chain vs potrace's fallback on every refused mono node of the eight marks, worst site per node at 4x (2026-09-16)](http://10.100.10.50:8090/b/hamr-v2-s0-smoother/) · hamr-dev · 2026-09-16 08:55 +- [hamr U0 — the truth-corpus acceptance gate: 24 conditions, potrace 3x vs the extractor's iso-contours, table + overlays at 1x and 4x](http://10.100.10.50:8090/b/hamr-u0-acceptance/) · hamr-dev · 2026-09-16 11:11 +- [hamr acceptance 1.2 verdict table -- 24 conditions, three arms over the raster per condition (from hamr-dev's fold of two Heid panels)](http://10.100.10.50:8090/b/hamr-u0-acceptance/) · heid · 2026-09-16 11:17 +- [Assistant voice — accent calibration: 7 endpoints from the existing battery, inline ask](http://10.100.10.50:8090/b/assistant-accent/) · nh3-dev · 2026-09-16 11:18 +- [Assistant voice — the blend n=5, matched-seed triples vs both endpoints](http://10.100.10.50:8090/b/assistant-blend/) · nh3-dev · 2026-09-16 11:20 +- [Peedlar repo (photo → eBay/FB Marketplace listing metadata) — minted 2026-09-16](https://gitea.phasefinal.com/vh/peedlar) · nh3-dev · 2026-09-16 11:26 +- [Sun and Sea Pro — concept tiles A/B/C + the rulings ask (design-systems)](http://10.100.10.50:8090/b/sunsea/) · design-dev · 2026-09-16 11:35 +- [Peedlar — UI design brief + northstar/frame/invariants/interview record (vor-ui pass 2026-09-16)](http://10.100.10.50:8090/b/peedlar-design-brief/) · peedlar-dev · 2026-09-16 14:01 +- [hamr U1 the tracer skeleton (tracer 3.0): the v2 tree over the eight marks with ids and holes, potrace beside it, 4x windows, the rule fixtures](http://10.100.10.50:8090/b/hamr-u1-tracer/) · hamr-dev · 2026-09-16 14:23 +- [Peedlar — vor-plan draft bundle (plan, frame, invariants, northstar, record) for teardown, 2026-09-16](http://10.100.10.50:8090/b/peedlar-plan-draft/) · peedlar-dev · 2026-09-16 16:17 +- [Peedlar — spike R-4 report: gen schema adherence, 180/180 valid (2026-09-16)](http://10.100.10.50:8090/b/peedlar-spike-r4/) · peedlar-dev · 2026-09-16 17:18 +- [hamr U2 (ir 7.0): the mono SVG before/after the IR moved onto points, eight marks, 1x and 4x](http://10.100.10.50:8090/b/hamr-u2-ir/) · hamr-dev · 2026-09-16 17:22 +- [JackDAW audition bench — live HEAD of main (self-signed HTTPS, one-time trust prompt)](https://10.100.10.50:4500/) · jackdaw-dev · 2026-09-16 18:32 +- [Peedlar UI in Sun and Sea Pro — nine surfaces + DESIGN.md (design-systems, for peedlar-dev)](http://10.100.10.50:8090/b/peedlar-ui/) · design-dev · 2026-09-16 19:27 +- [Assistant anchor — rp-s113 vs the existing emily, collision check before building a bank](http://10.100.10.50:8090/b/assistant-anchor/) · nh3-dev · 2026-09-16 19:29 +- [imogen — register bank ear gate before freezing (5 registers off rp-s113)](http://10.100.10.50:8090/b/imogen/) · nh3-dev · 2026-09-16 19:45 +- [imogen — gentle + dry re-roll, 3 draws each vs the rejected originals](http://10.100.10.50:8090/b/imogen-reroll/) · nh3-dev · 2026-09-16 19:50 +- [Peedlar — spike R-3 report: split heuristic on the cedarwood-4 pile (pairwise VLM + identify-and-merge, 4-image cap), 2026-09-16](http://10.100.10.50:8090/b/peedlar-spike-r3/) · peedlar-dev · 2026-09-16 19:53 +- [hamr U4: the colour spine on owner fields at 1x -- v1.6.1 (3x potrace) vs colour_spine 3.0, eight marks, 1x + 4x diff windows, the 1x/3x A/B table](http://10.100.10.50:8090/b/hamr-u4-readers/) · hamr-dev · 2026-09-16 20:26 +- [imogen LIVE — voice 22 on the roster, all five registers through the gateway](http://10.100.10.50:8090/b/imogen-live/) · nh3-dev · 2026-09-16 20:34 +- [talk v15 — imogen is the default voice; 22 voices, 4 personas, hands-free](https://talk.nh3.phasefinal.com:8092/) · nh3-dev · 2026-09-16 20:39 +- [Peedlar v0.1.0 — U0 scaffold deployed on nh3-dev (health placeholder SPA + /healthz)](http://10.100.10.50:8094/) · peedlar-dev · 2026-09-16 23:42 +- [hamr U3: the mono smoothing -- every refused node's chain (blue) beside the polyline it replaces (red), eight marks, 1x and 4x](http://10.100.10.50:8090/b/hamr-u3-mono-smoothing/) · hamr-dev · 2026-09-17 00:11 +- [2026-09-17 Civitai batch A/B — 6 promotion/retirement decisions, inline asks (comfy-dev)](http://10.100.10.50:8090/b/civitai-20260917-ab/) · comfy-dev · 2026-09-17 01:49 +- [Breeze v5 vendor-pin rebase — A/B clips, gate numbers, two decisions](http://10.100.10.50:8090/b/breeze-v5-gate/) · tts-dev · 2026-09-17 02:36 +- [ldp-saga U4 — control panel + bootstrap view screenshots (polish-pass input)](http://10.100.10.50:8090/b/ldp-u4-panel/) · ldp-dev · 2026-09-17 02:38 +- [lv voices four arms — same beat, same neutral prompt: control vs Bronte vs Yarros vs Hemingway (2026-09-17)](http://10.100.10.50:8090/b/lv-voices-four-arms/) · infra-ops · 2026-09-17 07:52 +- [hamr U6: the eight marks' faces and cut on white, v1.6.1 (potrace) beside main (own tracer), 1x + 4x worst window, trace timings](http://10.100.10.50:8090/b/hamr-u6-before-after/) · hamr-dev · 2026-09-17 08:06 +- [ldp-demo-kit 2026-09-17-0816 (build 99040b2): VO authored words in Eric's kit](http://10.100.10.50:8090/b/ldp-demo-kit/) · ldp-dev · 2026-09-17 08:17 +- [hamr U6 regression sites: crest/circuit/owl difference clusters at 4x, SOURCE | v1.6.1 | main | candidate (coverage-field evidence)](http://10.100.10.50:8090/b/hamr-u6-sites/) · hamr-dev · 2026-09-17 08:33 +- [talk favicon commission — comfy-dev raster candidates, hamr-dev SVG trace](http://10.100.10.50:8090/b/talk-favicon/) · tts-dev · 2026-09-17 08:43 +- [Peedlar U2 ingest screen — four phone states from a real headless Chromium run](http://10.100.10.50:8090/b/peedlar-u2/) · nh3-dev · 2026-09-17 09:19 +- [Peedlar v0.2.3 live — U2 ingest: photograph a pile from a phone, send it, top an item up](http://10.100.10.50:8094/) · nh3-dev · 2026-09-17 10:15 +- [Peedlar v0.2.4 live — U2 ingest, all three review rounds folded (17 defects)](http://10.100.10.50:8094/) · nh3-dev · 2026-09-17 11:04 +- [hamr circuit: the five sites where main's runs depart from v1.6.1's (SOURCE | v1 | main at 4x)](http://10.100.10.50:8090/b/hamr-u6-departures/) · hamr-dev · 2026-09-17 11:05 +- [hamr circuit: the trace-to-pad corners on both trees at 4x -- the indented-lines family](http://10.100.10.50:8090/b/hamr-u6-dents/) · hamr-dev · 2026-09-17 11:05 +- [Peedlar ingest UI — before/after in six states, with an open ask on fonts + pricing pills](http://10.100.10.50:8090/b/peedlar-ui-polish/) · design-dev · 2026-09-17 11:25 +- [hamr run_smoothing 3.4: the chord-of-a-curve clause -- the circuit's pads and trace ends as lines, before/after at 6x](http://10.100.10.50:8090/b/hamr-short-stretches/) · hamr-dev · 2026-09-17 11:56 +- [ldp-demo-kit 2026-09-17-1243 (a912928): Eric's 09-17 canonical + VO words — install this one](http://10.100.10.50:8090/b/ldp-demo-kit/) · ldp-dev · 2026-09-17 12:43 +- [Peedlar v0.2.5 — surface 1 dressed in Sun and Sea Pro (design-dev), four phone states](http://10.100.10.50:8090/b/peedlar-u2-design/) · nh3-dev · 2026-09-17 15:54 +- [talk favicon — the traced mark (B) and its 16/32/64px proof](http://10.100.10.50:8090/b/talk-favicon/) · nh3-dev · 2026-09-17 15:58 +- [Peedlar v0.3.0 — the first release a seller can use (ingest + top-up; split is U3)](https://gitea.phasefinal.com/vh/peedlar/releases/tag/v0.3.0) · nh3-dev · 2026-09-17 15:59 +- [hamr corner response A/B: 3.4 as landed vs the capped response by angle -- the circuit's bends, the crest's and gear's small fillets](http://10.100.10.50:8090/b/hamr-corner-ab/) · hamr-dev · 2026-09-17 17:24 +- [ldp-demo-kit 2026-09-17-1752 (a28e8d5): Eric's 09-17 canon + VO words + GM Markdown subset](http://10.100.10.50:8090/b/ldp-demo-kit/ldp-demo-kit-2026-09-17-1752.zip) · ldp-dev · 2026-09-17 17:52 +- [Sun and Sea Pro v1.1.0 — rulings + the Peedlar ingest before/after that started it](http://10.100.10.50:8090/b/peedlar-ui-polish/) · design-dev · 2026-09-17 17:59 +- [ldp-demo-kit 2026-09-17-1804 (265a3ad): + _underline_](http://10.100.10.50:8090/b/ldp-demo-kit/ldp-demo-kit-2026-09-17-1804.zip) · ldp-dev · 2026-09-17 18:04 +- [ldp-saga — GM Markdown subset samples (source + renders)](http://10.100.10.50:8090/b/ldp-markdown/) · ldp-dev · 2026-09-17 18:06 +- [hamr colour_spine 3.7, the paired witness: circuit arrows 1-3 at 12x, every departure site before/after at 1x+4x, the crest's eye](http://10.100.10.50:8090/b/hamr-witness/) · hamr-dev · 2026-09-17 18:48 +- [Dragonfire Acoustics — three concept directions + the five rulings that gate the build](http://10.100.10.50:8090/b/dfa-concepts/) · design-dev · 2026-09-17 18:49 +- [Dragonfire Acoustics — sample landing page, standalone HTML for client screenshots](http://10.100.10.50:8090/b/dfa-landing/) · design-dev · 2026-09-17 18:58 +- [hamr run_smoothing 3.5, the corner response by angle between two stretches: circuit arrows 2-3 and new corners, crest's curves unkinked, at 8x](http://10.100.10.50:8090/b/hamr-corner-guard/) · hamr-dev · 2026-09-17 18:59 +- [hamr: golden corpus on main 53356c5, faces and cut on white, 1x sheets and 4x wholes](http://10.100.10.50:8090/b/hamr-corpus-2026-09-18/) · hamr-dev · 2026-09-17 21:51 +- [Peedlar surface 2 — a live split of the R-3 pile, ready to confirm (U3)](http://10.100.10.50:8094/batches/6fb2952b-e3b1-4fbd-9694-5f3f3f5d75d0/split) · nh3-dev · 2026-09-18 07:08 +- [Peedlar surface 2 — a scratch split to poke at (merge/split/move/drop/restore all live)](http://10.100.10.50:8094/batches/a7058924-a855-40b3-bfc5-11f3f258df27/split) · nh3-dev · 2026-09-18 07:13 +- [Peedlar U3 — surface 2 on desk and phone, plus an interaction run](http://10.100.10.50:8090/b/peedlar-u3/) · nh3-dev · 2026-09-18 07:16 +- [tag placement A/B — does moving (giggle) stop it overlapping the next line? (ask inside)](http://10.100.10.50:8090/b/tag-placement/) · tts-dev · 2026-09-18 07:17 +- [seam gap audition — 0-500ms between generations, 11 arms (ask inside)](http://10.100.10.50:8090/b/seam-gap/) · tts-dev · 2026-09-18 07:27 +- [FleetTools index lives at ~/FLEETTOOLS.md on nh3-dev — agent-family-agnostic fleet capability map](http://10.100.10.50:8090/) · nh3-dev · 2026-09-18 07:35 +- [Peedlar v0.4.0 — the split ships; capability 1 of five is MET](http://10.100.10.50:8094/) · nh3-dev · 2026-09-18 08:54 +- [talk favicon — inverted, transparent, before/after proof at 4 sizes](http://10.100.10.50:8090/b/talk-favicon/) · tts-dev · 2026-09-18 13:53 +- [ShutterChute macOS app icon — 3 variants + the 16px proof sheets (comfy-dev, for shutter-dev)](http://10.100.10.50:8090/b/shutterchute-icon/) · comfy-dev · 2026-09-18 14:00 +- [NH3↔Anaheim mesh now DIRECT (was DERP-relayed): cross-site HTTP 1.2s→0.015s, STT 1.4s→0.25s — ana-gw UDP 41641 port-forward 2026-09-18](http://10.100.10.50:8090/b/links/) · nh3-dev · 2026-09-18 14:17 +- [talk favicon — three-way blue comparison (live vs page accent vs comfy remake)](http://10.100.10.50:8090/b/talk-favicon/) · tts-dev · 2026-09-18 14:26 +- [DNS fixed fleet-wide 2026-09-18: cross-site resolver ring + AdGuard ratelimit 20-per-/24 set to 0 — .internal stalls 1-in-8 to zero](http://10.100.10.50:8090/b/links/) · nh3-dev · 2026-09-18 14:35 +- [ShutterChute on Paula's mini (v0.9.7) — session token rotates on every restart, read it from /Users/Shared/shutterchute/app.url or the deploy output](http://10.100.10.50:8477/) · shutter-dev · 2026-09-18 14:46 +- [asking arbo vs directing it — both icon commissions re-run on the corrected chain, with the 16px verdicts](http://10.100.10.50:8090/b/arbo-asked/) · comfy-dev · 2026-09-18 14:54 +- [Blind A/B/C: is Imogen's 39.96s register bank worth 116ms a turn? (breeze v8)](http://10.100.10.50:8090/b/imogen-register/) · tts-dev · 2026-09-18 20:30 +- [Sindra identity scouting — 5 SFW/NSFW pairs on moody-krea2 (comfy-dev, for adhoc-agent)](http://10.100.10.50:8090/b/sindra-face-1/) · comfy-dev · 2026-09-19 12:44 +- [Sindra casting — 5 different women, 2 fixed scenes (gym / beach), comfy-dev](http://10.100.10.50:8090/b/sindra-cast/) · comfy-dev · 2026-09-19 15:25 +- [the three MiniMax Music 3 songs (Aug 2026) — recovered from render scratch, kept, captions carry the recovered lyrics](http://10.100.10.50:8090/b/music3-songs/) · comfy-dev · 2026-09-19 15:26 +- [Sindra A — curvier stepped across 4 levels, face frozen (comfy-dev)](http://10.100.10.50:8090/b/sindra-curve/) · comfy-dev · 2026-09-19 15:36 +- [the settled Sindra — 5 SFW environments + 5 NSFW poses, identity block verbatim (comfy-dev)](http://10.100.10.50:8090/b/sindra-set/) · comfy-dev · 2026-09-19 15:44 +- [NVV markers by ear: is (chuckle) real? + the leak test is dead on breeze v8](http://10.100.10.50:8090/b/nvv-probe/) · tts-dev · 2026-09-19 17:05 +- [tts-bench — type/direct/render against the live TTS seat (voice picker, custom directions, marker palette)](http://nh3-dev.nh3.internal:8095/) · tts-dev · 2026-09-19 17:14 +- [Sindra voice audition (adhoc-agent commission) — designed synthetic, 3 registers x 2 takes + polyglot probe](http://10.100.10.50:8090/b/sindra-voice-1/) · tts-dev · 2026-09-19 22:49 +- [Sindra ANCHOR field — n=15 on the intimate prompt, 13 in the 8-10s window, pick one to freeze](http://10.100.10.50:8090/b/sindra-anchor/) · tts-dev · 2026-09-19 22:55 +- [Sindra is LIVE — new designed voice replaces the NZ contralto; bank vs anchor A/B inside](http://10.100.10.50:8090/b/sindra-live/) · tts-dev · 2026-09-19 23:14 +- [Cicada repo (was Imogen) — embodied voice assistant, design bundle + embodiment](https://gitea.phasefinal.com/vh/cicada) · brokkr-smithy-dev · 2026-09-20 14:11 +- [ShutterChute: denoise strength + EV lift on the 4 darkest Pancake Breakfast frames (1:1 crops)](http://10.100.10.50:8090/b/sc-denoise-ev/) · shutter-dev · 2026-09-20 15:12 +- [ShutterChute: DSC03888.ARW (ISO 12800, darkest frame) + current style — for authoring a working denoise in darktable](http://10.100.10.50:8090/b/sc-denoise-raw/) · shutter-dev · 2026-09-20 15:27 +- [Cutesy robot girl — 5 briefs x 2 seeds, 259-372 Hz, plus three robot textures (EVE / classic / WALL-E)](http://10.100.10.50:8090/b/robot-girl/) · tts-dev · 2026-09-20 15:53 +- [cicada-raw is LIVE — fastest voice on the fleet at 220.2 ms; reference + clones + the defect I retracted](http://10.100.10.50:8090/b/cicada-raw/) · tts-dev · 2026-09-20 16:06 +- [ShutterChute: denoise strength ladder on the REPAIRED split — 1:1 crops, 4 dark frames](http://10.100.10.50:8090/b/sc-denoise-strength/) · shutter-dev · 2026-09-20 16:24 +- [ShutterChute: four-way denoise comparison — no denoise / classical / SCUNet (automatable) / neural restore](http://10.100.10.50:8090/b/sc-denoise-fourway/) · shutter-dev · 2026-09-20 17:25 +- [ShutterChute: RawNIND UtNet2 pre-demosaic — 8.01 to 2.07 at 2.8s/frame, running outside darktable](http://10.100.10.50:8090/b/sc-rawdenoise/) · shutter-dev · 2026-09-20 18:47 +- [ShutterChute: frequency-selective detail recovery after raw AI denoise](http://10.100.10.50:8090/b/sc-detail-recovery/) · shutter-dev · 2026-09-20 18:54 +- [ShutterChute: raw AI denoise @70% across six frames, mean luminance 20 to 148](http://10.100.10.50:8090/b/sc-iso-spread/) · shutter-dev · 2026-09-20 18:58 +- [raw-denoise first real-model run: A raw vs B linear TIFF (black) vs C sRGB-encoded (tonality right, colour wrong)](http://10.100.10.50:8090/b/denoise-first-run/) · shutter-dev · 2026-09-21 06:45 +- [Pancake Breakfast low-light: raw vs denoised+2EV, full res + 1:1 crops; 3.3-3.5x noise reduction measured](http://10.100.10.50:8090/b/pancake-denoise/) · shutter-dev · 2026-09-21 07:02 +- [raw-denoise: TIFF handoff vs LinearRaw DNG handoff - the colour fix, before/after](http://10.100.10.50:8090/b/dng-handoff/) · shutter-dev · 2026-09-21 07:38 +- [EV ladder on a denoised Pancake frame: face luma vs frame median vs the 18% grey reference](http://10.100.10.50:8090/b/ev-ladder/) · shutter-dev · 2026-09-21 07:51 +- [golden-frame candidates for the one-and-done white balance: two lighting clusters, two each](http://10.100.10.50:8090/b/golden-candidates/) · shutter-dev · 2026-09-21 07:57 +- [Sindra @ 20 (v2, replaced) — 5 NSFW engines x 4 scenes x 2 seeds, 40 renders + 4 sheets + the age-lever diagnostic](http://10.100.10.50:8090/b/sindra20-engines/) · comfy-dev · 2026-09-21 07:57 +- [vibrance/saturation spike: 4 steps on a well-lit and a recovered frame; which colorbalancergb float is which, measured](http://10.100.10.50:8090/b/vibrance-spike/) · shutter-dev · 2026-09-21 08:29 +- [face metering measured on all 696 keepers: gate 20.7% -> 34.2%, 94 frames newly caught](http://10.100.10.50:8090/b/face-metering/) · shutter-dev · 2026-09-21 08:29 +- [darktable 5.6.1 on nh3-dev: the versions disagree, and the vibrance pick was made on 4.2.1](http://10.100.10.50:8090/b/dt56-recheck/) · shutter-dev · 2026-09-21 09:05 +- [Pancake Breakfast re-delivery: all 270 heroes, exposure + denoise + vibrance, SmugMug-ready](http://10.100.10.50:8090/b/pancake-v2-delivery/) · shutter-dev · 2026-09-21 09:48 +- [Draupnir — agent-directed parametric CAD for 3D printing; many harnesses propose, one gate decides](https://gitea.phasefinal.com/vh/draupnir) · brokkr-smithy-dev · 2026-09-21 10:47 +- [Pancake lift spike — Paula vs ours-zero-lift vs ours-metered, 8 frames](http://10.100.10.50:8090/b/pancake-lift-spike/) · shutter-dev · 2026-09-21 10:55 +- [Pancake dark band (face 17-42) — Paula vs ours at zero lift](http://10.100.10.50:8090/b/pancake-dark-band/) · shutter-dev · 2026-09-21 10:57 +- [Lift ladder — your 15 labelled frames at zero / +0.67 / +1.33 EV](http://10.100.10.50:8090/b/pancake-lift-ladder/) · shutter-dev · 2026-09-21 11:22 +- [Saturation+vibrance ladder — current / 75% / 50%, zero lift throughout](http://10.100.10.50:8090/b/pancake-saturation/) · shutter-dev · 2026-09-21 11:22 +- [Pancake v3 — the full 270 at cap 4/3, saturation 33%, gate/meter split](http://10.100.10.50:8090/b/pancake-v3-full/) · shutter-dev · 2026-09-21 13:29 +- [Pancake v3 — the 53 lifted frames vs Paula, worst blown first](http://10.100.10.50:8090/b/pancake-v3-lifted/) · shutter-dev · 2026-09-21 13:29 +- [Sigmoid colour test — Paula vs per-channel / RGB-ratio / smooth, 6 lifted + 2 unlifted controls](http://10.100.10.50:8090/b/pancake-sigmoid/) · shutter-dev · 2026-09-21 14:15 +- [Draupnir: 5 of 6 gate checks real — min-wall lands and the control pair finally separates (thin-wall FAILs at 1.0001mm vs 1.2mm floor); renders, STLs, calibration data](http://10.100.10.50:8090/b/draupnir-first-stl/) · draupnir · 2026-09-21 14:25 +- [Closed loop — 12 samples: Paula vs open-loop vs closed-loop, with EV and blown %](http://10.100.10.50:8090/b/pancake-closed-loop/) · shutter-dev · 2026-09-21 14:46 +- [Draupnir first commission: puck-light diffuser cap — 90.4mm shroud, 55.9% open, renders + STL/STEP (and the gate bug this part found)](http://10.100.10.50:8090/b/draupnir-puck-cap/) · draupnir · 2026-09-21 14:49 +- [Pancake v4 — the full 270 through the closed loop](http://10.100.10.50:8090/b/pancake-v4-full/) · shutter-dev · 2026-09-21 15:46 +- [Pancake v4 — the frames the loop changed, Paula / open / closed, worst blown first](http://10.100.10.50:8090/b/pancake-v4-changed/) · shutter-dev · 2026-09-21 15:46 +- [ShutterChute v0.9.14 on the mini — final triage over the 270 closed-loop deliveries](http://10.100.10.50:8477/?token=wtIRzaqmRQ3Qg2cjUwMZjd-OvNFB9UP3GXyGjDW2d-E&triage=/Users/paulahoang/Photos/PancakeBreakfast/deliver-shutterchute-260921) · shutter-dev · 2026-09-21 21:07 +- [ShutterChute v0.9.15 on the mini — triage, fit fixed](http://10.100.10.50:8477/?token=dG9y44XQmJfH7q8Wy_o2KKeIxGnUeP5zh8yADOoEYA4&triage=/Users/paulahoang/Photos/PancakeBreakfast/deliver-shutterchute-260921) · shutter-dev · 2026-09-21 21:50 +- [ShutterChute v0.9.16 — triage: centred delete tag, 1:1 pans](http://10.100.10.50:8477/?token=Nx9zEhTOXIfCrmpA72OEqEHGz7atxKQL8y5v3ecoW98&triage=/Users/paulahoang/Photos/PancakeBreakfast/deliver-shutterchute-260921) · shutter-dev · 2026-09-21 22:01 +- [cr123a-to-d-sleeve — renders, STL + STEP, gate WARN on the 0.8 mm shoulder](http://10.100.10.50:8090/b/cr123a-to-d-sleeve/) · draupnir · 2026-09-21 23:14 +- [Sindra @ 20 EVIDENCE BOARD — all 20 sheets + diagnostics, zero single frames (replaces the 122-image finalists board)](http://10.100.10.50:8090/b/sindra-evidence/) · comfy-dev · 2026-09-21 23:33 +- [infra-ops: five ERP run-7 decisions, open and unanswered since 2026-09-09](http://10.100.10.50:8090/b/run07-decisions/) · brokkr-smithy-dev · 2026-09-21 23:52 +- [Moody vs Realism BAKEOFF — 8 new scenes (4 SFW / 4 NSFW, no bedroom), 32 renders; verdict is a framing-dependent split](http://10.100.10.50:8090/b/sindra-bakeoff/) · comfy-dev · 2026-09-22 00:20 +- [krea2 LoRA portability test — RAW-trained LoRAs DO activate on distilled turbo checkpoints (3 seeds, null+negative+positive controls)](http://10.100.10.50:8090/b/krea2-lora-portability/) · comfy-dev · 2026-09-22 01:53 +- [The High Seat — SVOS board + Miranda (nh3-dev)](http://10.100.10.50:8770) · svos-dev · 2026-09-22 08:29 +- [Sindra training corpus pass 1 (54 frames) + the two validated fixes before the corrected re-render](http://10.100.10.50:8090/b/sindra-corpus-v1/) · comfy-dev · 2026-09-22 09:10 +- [Miranda re-minted Icelandic — 4 briefs x 2 seeds + Swedish/Norwegian discrimination controls + the incumbent](http://10.100.10.50:8090/b/miranda-is/) · tts-dev · 2026-09-22 10:41 +- [Sindra nude selection pool — 36 frames (10 rear), pick ~10 matching body shapes](http://10.100.10.50:8090/b/sindra-nude-pool/) · comfy-dev · 2026-09-22 11:08 +``` diff --git a/docs/contracts/u6_benches.contract.md b/docs/contracts/u6_benches.contract.md new file mode 100644 index 0000000..e08dfa5 --- /dev/null +++ b/docs/contracts/u6_benches.contract.md @@ -0,0 +1,413 @@ +--- +contract_version: "1.0" +module: "booth.benches" +purpose: "A bench is a running thing, registered -- not a booth, and not a bookmark. The standing link board absorbed all three jobs because only one of them had a surface, and it now carries 221 rows of which 178 (80%) are booth announcements and 156 (71% of the whole board) point at booths that were swept. U5 gave the booth announcement a home; this unit gives the RUNNING SERVICE one, and closes the loop by refusing the one shape that now has somewhere better to go. Identity is the normalized URL, so re-announcing a bench UPDATES its row instead of appending a fifth -- `talk` is on the board five times and Peedlar's root three. Nothing on the board is deleted by this unit: the dead rows are MARKED so the operator can see and remove them with the bulk control that already exists." +depends_on: + - "booth.links (`booth_target` is DEFINED here and consumed there -- see INV-2. The board's existing parse/remove/pin machinery is untouched: rows keep their content-hash identity, `links.md` stays an O_APPEND multi-writer log, and no row is rewritten by anything this unit adds.)" + - "booth.app (the dead-row marker needs a booth-exists predicate. IT CANNOT USE `resolve_booth`: that is a CLOSURE inside `create_app` (app.py:798), not importable, and it RAISES HTTPException(404) -- calling it per row would turn one swept booth into a 404 for the whole board page, which is the opposite of the marker's purpose. The marker gets its own non-raising predicate carrying the SAME name-safety rules (no leading dot, no separator, no `..`) and returning False where `resolve_booth` raises. A row is dead when its target directory is absent, not when its target is nearly expired -- no new lifetime arithmetic. Verified against the real function, not assumed: seam review SR-2.)" +language: "python" +complexity: "medium" +estimated_loc: 320 +confidence: 0.80 +used_by: + - "scripts/booth (`bench add|ls|state|rm|import` are new; `link` gains ONE refusal and is otherwise unchanged)" + - "booth.app.booth_view (the board's rows gain a `dead` stamp; the benches panel renders on the standing board's page)" + - "booth.app.list_booths (unchanged -- named here because it was checked and does NOT need to change: benches live outside the booth namespace and are invisible to it)" +touches: + - "booth/benches.py (new -- the record, normalization, the lenient read, the atomic upsert, the stated order)" + - "booth/links.py (ONE new function, `booth_target`. No existing function changes.)" + - "booth/app.py (`_board_rows` stamps `dead`; the booth view passes `benches`; three POST routes for add/state/remove)" + - "booth/templates/booth.html (the benches panel; the dead-row marker on a board row)" + - "booth/templates/base.html (the .bench-* and .board-dead CSS)" + - "scripts/booth (the five bench verbs, the link refusal, the usage block, the header doc block)" + - "tests/test_benches.py (new)" + - "tests/test_cli.py (the bench verbs and the refusal, run against the real script under system python3)" + - "tests/test_marks.py (test_stdlib_only's parametrize list gains `benches`)" + - "docs/design/information-architecture.md (two corrections the measurement forces -- see 'What the measurement changed')" + - "ROADMAP.md (the bench listing order rule, which was one of the two undecided rows in the deterministic-order table)" +assumptions: + - "IDENTITY IS THE FULL NORMALIZED URL, NOT THE ORIGIN, AND THIS WAS MEASURED RATHER THAN CHOSEN. Collapsing the board's 43 non-booth rows by origin yields 19 groups; by full URL, 35. The 16-group difference is not duplication -- it is EIGHT distinct gitea repositories merged into one row, THREE unrelated HuggingFace model cards merged into one, and the two LRPG surfaces on `10.100.10.50:8321` (`Authoring Studio.dc.html` and `GM Playback.dc.html`) merged into one, which are the IA doc's own example of two real benches. Origin identity would have destroyed more than it deduplicated. Full-URL identity still collapses both cases the IA doc named: `talk` 5 rows to 1, Peedlar's root 3 to 1." + - "THE QUERY STRING IS PART OF THE IDENTITY, the fragment is not. Measured: three ShutterChute rows differ ONLY by `?token=`, and they are three genuinely different one-shot links, not one bench posted three times -- dropping the query would merge them into a bench that is none of them. A fragment is a position inside a page, never a different resource, so it is dropped. Userinfo (`user:pass@`) is REFUSED rather than stripped: a credential must not reach a board that renders on an unauthenticated LAN surface, and silently stripping it would register a bench whose URL no longer works while telling the poster it succeeded." + - "`booth_target` IS HOST-AGNOSTIC AND PATH-SHAPED. A row is a booth link when its path is `/b/` or `/b//...`, whatever the host. NOT a host allowlist: the fleet reaches this service as `10.100.10.50:8090`, `localhost:8090` and `nh3-dev.nh3.internal:8090`, and an allowlist would silently fail to refuse from whichever name somebody used next -- a rule that fails OPEN on the exact case it exists to catch. The accepted cost is that a third-party URL with a `/b/` path would be misread; the failure is visible (a refusal naming the reason, or a row marked dead) rather than silent, and no such URL exists on the board today." + - "NOTHING THIS UNIT SHIPS DELETES A ROW. ROADMAP names 'a migration that deletes anything' as explicitly not in v1. `links.md` is archived verbatim before the registry is seeded, the import writes nothing without `--apply`, and the 156 dead rows are MARKED, not pruned -- removal stays the operator's two deliberate clicks through the `unlink-many` control that has existed since before this unit. The marker is what makes the existing control usable at 221 rows; it is not a second delete path." + - "THE SERVICE NEVER PROBES THE NETWORK. `read_benches` is a filesystem read on the render path, exactly like `read_manifest` and `marks_for`. A bench's liveness is not checked by this unit at all -- see Out of scope, where the decision and its reversal cost are stated." + - "`booth/benches.py` IS STDLIB-ONLY and joins the CLAUDE.md invariant 1 list, for the same reason `manifest.py` did: `scripts/booth` imports it through a `python3 -c` heredoc under the system python3 with no venv. It must also be SIBLING-FREE -- it does not import `links`, `marks`, `asks` or `manifest`, because a cross-import between two stdlib-only modules is a second way for that invariant to break. `booth_target` therefore lives in `links.py` (the board's module, where the board's callers already are) and `benches.py` does not call it; the CLI and `app.py` each import both." + - "THE REGISTRY IS ONE FILE AT THE DATA ROOT, `~/booth-data/.benches.json` -- a dotfile OUTSIDE the booth namespace. It is therefore not a booth, cannot be swept, cannot be mistaken for one by `list_booths` (which iterates directories), and needs no exclusion rule anywhere. Single-writer with many readers, like marks and unlike `links.md`: the operator in one browser plus CLI calls, so it is a per-file atomic replace under an flock on the read-modify-write, NOT an append log. Inheriting the append-log shape here would be the multi-writer/single-writer mistake CLAUDE.md names." + - "THE ON-DISK SHAPE IS AN OBJECT KEYED BY ID, not a list. Two rows with the same identity are then impossible BY CONSTRUCTION rather than by an upsert remembering to check -- which is the whole point of giving a bench an identity. The rendered order is separate and stated (INV-4); the file's key order is not load-bearing and is never read as an order." +open_questions: + - "ONE BENCH, TWO URLS. `talk` is reachable as both `https://talk.nh3.phasefinal.com:8092/` (trusted cert) and `https://10.100.10.50:8092/` (internal IP, cert warning), and both are on the board with descriptions that say so. Full-URL identity correctly keeps them as two rows, because they ARE two URLs -- but they are one bench. An alias field would merge them; so would letting a bench carry a list of URLs. Neither is designed here: aliasing is a judgment about what counts as the same thing, the registry is ~14 rows, and two rows for one bench is legible. Deferred, not solved." + - "WHETHER `booth link` SHOULD ALSO NUDGE TOWARD `bench add` for a URL that looks like a service root. It is not refused -- measured, roughly 14 of the 35 distinct non-booth targets are reference bookmarks (repos, model cards, docs) for which the board is the right and only home, so a second refusal would break a job the board legitimately still does. A non-blocking hint is defensible and is not in this unit." +--- + +# U6 — benches + +## The defect, stated precisely + +Re-measured 2026-09-22 against the live board, because the numbers in the IA +doc are a day old and the board grew: + +| | IA doc, 2026-09-21 | today | +|---|---|---| +| rows on the standing board | 211 | **221** | +| rows that are booth URLs | not split out | **178 — 80% of the board** | +| …whose booth no longer exists | 145 (69%) | **156 — 71% of the whole board** | +| rows that are not booth URLs | ~40 | **43** | +| …distinct after normalization | — | **35** | + +The headline number in the IA doc — *69% rot* — is **two different defects +wearing one number**, and separating them is what makes this unit the right +size: + +1. **Booth-announcement rot (178 rows).** A session posted a booth URL because + a booth could not announce itself. **U5 closed the cause**: a booth now + carries `.booth.json` and the index is the feed. Nothing yet stops the + habit, so the board took 11 more of these rows in the day since it was + measured. This unit's *enforced rule* is the stopper, and the *dead marker* + is what lets the operator clear what already landed. + +2. **Bench re-post (8 rows).** `booth link` is an append with no identity, so + re-announcing a bench creates a row rather than updating one: `talk` five + times, Peedlar's root three. This unit's *registry* is the fix, and it is + the smaller half — which is worth saying plainly, because the IA doc's + single 69% figure implies otherwise. + +A third thing the measurement found, which the IA doc does not describe: **the +board has a legitimate residual job.** Of the 35 distinct non-booth targets, +roughly 14 are running services (benches) and roughly 14 are reference +bookmarks — gitea repositories, HuggingFace model cards, a vLLM recipe, a +Headscale setup page. The IA doc plans for `booth link` to survive "as a +deprecated alias". That would deprecate the only home a third of its live +content has. **`booth link` is not deprecated by this unit.** It loses exactly +one shape — the booth URL — and keeps the rest. + +## What the measurement changed + +Two lines of `docs/design/information-architecture.md` are wrong and are +corrected in the same commit, rather than left for a reader to trip over: + +- **`id : normalized URL`** stays, but the doc does not say what normalized + means, and the obvious reading — the origin — is measurably destructive here + (8 gitea repos into one row). The doc gains the rule and the number behind it. +- **"`booth link` … survives as a deprecated alias rather than vanishing"** is + struck. It survives as itself, minus one refused shape, for the reason above. + +## The record + +```python +@dataclass(frozen=True) +class Bench: + id: str # the normalized URL — the identity, and the dict key on disk + url: str # the URL AS POSTED — what a click goes to + name: str # what it is + owner: str # the althing handle that registered it, or "booth" + state: str # "live" | "promoted" | "retired" + added: str # ISO-8601 with offset, from the FIRST registration + updated: str # ISO-8601 with offset, from the most recent upsert + error: str | None = None # a read-time verdict; never stored +``` + +`id` and `url` are two fields on purpose. The identity must be normalized so +that re-posting updates; the href must be verbatim so that a URL whose server +cares about a trailing slash, a case-sensitive path or a query still works when +clicked. Collapsing them would make the registry quietly change where a link +goes, which is the kind of bug that surfaces as "the operator clicked a bench +and got a 404" and is never traced back here. + +`added` survives re-registration; `updated` does not. That is the same shape as +U5's `created`, and for the same reason: an upsert is the same bench saying +something new about itself, not a new bench. + +Caps, applied at the write and again at the read: `name` 120, `owner` 64, `id` +and `url` 2048, `state` one of three. Each is a display budget, not a storage +limit. + +## Signatures + +```python +BENCHES_FILE = ".benches.json" # at the DATA ROOT — not inside a booth +BENCH_LOCK = ".benches.lock" +BENCH_STATES = ("live", "promoted", "retired") +NAME_MAX, OWNER_MAX, URL_MAX = 120, 64, 2048 +BENCHES_MAX_BYTES = 256 * 1024 + + +def normalize_bench_url(url: str) -> str: + """The identity of a bench. Raises ValueError with a reason a human can act + on -- the CLI prints it verbatim. + + THE RULE, in full, because it is the identity and a vague identity is worse + than a wrong one: + * surrounding whitespace stripped + * scheme lowercased; anything but http/https is refused + * userinfo (`user:pass@host`) is REFUSED, never stripped + * host lowercased; an empty host is refused + * port dropped when it is the scheme default (80 for http, 443 for https) + * path kept verbatim, except that a bare "/" becomes "" + * query kept verbatim, INCLUDING its parameter order (a query is opaque) + * fragment dropped + """ + + +def read_benches(root: Path) -> tuple[list[Bench], str | None]: + """Every registered bench, in the order of `order_benches`, plus a read-time + error or None. NEVER RAISES -- this is on the render path (v0.2.2 lesson).""" + + +def upsert_bench(root: Path, url: str, name: str, owner: str) -> tuple[Bench, bool]: + """Register or update by normalized URL. Returns (bench, created). + `added` is preserved on update; `url`, `name`, `owner`, `updated` are + replaced. `state` is preserved on update and is "live" on create.""" + + +def set_bench_state(root: Path, bench_id: str, state: str) -> Bench | None: + """Move a bench between live / promoted / retired. None if no such bench.""" + + +def remove_bench(root: Path, bench_id: str) -> Bench | None: + """Drop one bench. Returns the removed record, or None.""" + + +def order_benches(benches: Iterable[Bench]) -> list[Bench]: + """ORDER: (state rank, name casefolded, id) -- live before promoted before + retired, then alphabetical, with the id as a total tie-break so two benches + sharing a name cannot swap between renders. CLAUDE.md invariant 6.""" +``` + +And in `booth/links.py`, the one addition: + +```python +def booth_target(url: str) -> str | None: + """The booth NAME a URL points at, or None when it is not a booth URL. + + ONE PREDICATE, THREE CALLERS -- the CLI's refusal, the board's dead marker, + and the import's classifier. They must agree: a rule that refuses a shape + the board then fails to mark as dead (or the reverse) is two readers of one + truth, which is the bug this repo has now paid for three times. + + THE NAME SEGMENT IS PERCENT-DECODED. `app.py` emits booth links through + `quote(name, safe="")`, so a booth whose name needs encoding appears on the + board encoded. Comparing the raw segment against a directory name would mark + every such booth dead and would print the encoded form back at the poster in + the refusal message. Seam review SR-7. + + Returns the DECODED name. A path of `/b/` with no name, or a decoded name + that is empty, starts with a dot, or contains a separator or `..`, is not a + booth link (None) — the same rules `resolve_booth` enforces, so the two + cannot disagree about what is addressable. + """ +``` + +## The enforced rule + +`booth link ` refuses when `booth_target(url)` is not None: + +``` +$ booth link http://10.100.10.50:8090/b/sindra-bakeoff/ "the bakeoff" +booth link: that is a booth, and a booth announces itself now. + booth new sindra-bakeoff --why "the bakeoff" (or --why on `booth add`) + the index at http://10.100.10.50:8090/ is the feed. +exit 2 +``` + +Three properties this refusal must have, each of which is an invariant below: + +- **It names the alternative.** The teaching moment belongs at the point of use; + 17 handles have the muscle memory and a bare "refused" would send them to a + human. +- **It writes nothing.** Not the row, and not the board's `.booth.json` + announcement that `booth link` creates on first use — a refused call must not + leave a new booth behind as a side effect. +- **It is the ONLY new refusal.** A reference bookmark is still a link. + +## What renders + +On the standing board's page, above the rows: + +- **The benches panel** — each bench as name, URL, owner, state, and the date + it was added; ordered by `order_benches`. Controls to change state and to + remove, both POST, both reversible in one click except remove. +- **A board row whose booth is gone is marked dead** — visibly, with its + checkbox pre-reachable by the existing select-all, so the operator can tick + and use the `unlink-many` control already on the page. **No new delete path.** + +A registry that cannot be read renders as a panel carrying its error, never as +an absent panel and never as a 500 — the v0.2.2 lesson, which this repo learned +by returning 500 for `/` and `/healthz` across all 25 booths. + +## The CLI surface + +``` +booth bench add register or update; prints created/updated +booth bench ls list, in the rendered order, with ids +booth bench state live | promoted | retired +booth bench rm remove one +booth bench import [--apply] classify the board's rows; WRITES NOTHING + without --apply, and never touches links.md +``` + +`import` prints three groups — **booth rows** (skipped; `booth_target` matched), +**candidates** (would be registered, with the normalized id beside the raw URL +so a collapse is visible before it happens), and **refused** (normalization +raised, with the reason). Under `--apply` it upserts the candidates and prints +the same three groups plus what it did. It is not automatic and it is not run by +this unit: roughly 14 of 35 candidates are reference bookmarks that belong on the +board, and a machine cannot tell a bench from a bookmark by its URL. The +operator seeds the registry by reviewing that list. + +## The migration + +1. `links.md` is archived verbatim to `~/booth-data/links/links-archive-2026-09-22.md` + **and committed to this repo**, before anything else. Nothing the operator + wrote is destroyed, and the archive is version-controlled rather than living + only on one box. +2. `booth bench import` proposes; the operator applies. +3. The 156 dead booth rows are marked, and removed by him or not at all. + +## Scope — the blast-radius pass + +`graphify explain` over `remove_link_entry`, `parse_link_entries`, +`order_for_display`, `read_pins` and `toggle_pin`, cross-checked with grep +because graphify cannot see the CLI's `python3 -c` import (it reports the +`app.py` importers and the test callers; `scripts/booth:353` is invisible to it +— the exact blindness CLAUDE.md names). + +No existing function in `links.py` changes signature or behaviour. The board's +rows keep their content-hash identity, so every pin, every `unlink` id in the +operator's history, and every concurrent `booth link` append keep working +untouched. + +## Out of scope + +- **Liveness probing.** The IA doc's BENCH shape carries `last_checked` / + `last_ok`; ROADMAP's v1 row does not — it names *registry, identity, enforced + rule, migration*, and the parking lot already parks the uptime history. This + unit ships none of it, deliberately: it is the only part that does network + I/O, which is the part that reliably takes 2–5 follow-up patches for cases the + first shape did not anticipate — the accretion signature this whole rewrite is + undoing. The record is designed so adding it later is purely additive (the + read is lenient to unknown keys, so an older Booth reading a newer file does + not break). **This is a scope reduction against the IA doc and the operator + can reverse it; the cost of reversing it is one field pair and one CLI verb.** +- **Pruning the board.** Not in v1, by ROADMAP. +- **Bench aliases.** See open questions. +- **A bench page.** A bench is a link to somewhere else; giving it a page here + would make the Booth a directory service. +- **Any change to how booths announce themselves.** That was U5 and it landed. + +## Invariants + +**INV-1 — one module knows the registry's filename and shape.** +`booth/benches.py` is the only place `.benches.json` is named, parsed or +written. No route body and no CLI branch constructs the path or reads the JSON. +*Falsifiable:* a test that fails if the literal `.benches.json` appears anywhere +outside `benches.py` — and specifically fails under the change that defeats it, +which is a route reading the file directly to save an import. Asserting only +that the panel renders would pass under exactly that change. + +**INV-2 — one predicate decides what a booth URL is.** `links.booth_target` is +the only implementation, and the CLI's refusal, the dead marker and the import's +classifier all call it. +*Falsifiable:* the defeating change is a second implementation — a `/b/` check +inlined in the shell for speed, or a regex in `app.py`. The test asserts +AGREEMENT rather than behaviour: a table of URLs (trailing slash, no slash, +nested path, query, uppercase host, a non-Booth host with a `/b/` path, a `/b/` +with no name) is run through the CLI's refusal AND the render's dead marker, and +the two must classify every row identically. A test that only checked the +refusal would stay green while the marker drifted. + +**INV-3 — a refused link writes nothing.** No row, no board directory, no +`.booth.json`, no lock file. +*Falsifiable:* the defeating change is moving the refusal after the `mkdir -p` / +`announce` block in the `link` branch — which is where it would naturally land +if written without thinking. The test refuses a link into a data root with NO +`links` booth and asserts the directory still does not exist, not merely that +`links.md` lacks the row. Asserting the row's absence alone would pass under the +defeating change. + +**INV-4 — the rendered bench order is total and stated.** `(state rank, name +casefolded, id)`. +*Falsifiable:* the defeating change is dropping the `id` tie-break, which leaves +two benches sharing a name in whatever order the dict yielded. The test +registers two benches with the SAME name in both insertion orders and asserts +the same output sequence from both. A test over distinct names would pass with +no tie-break at all. + +**INV-5 — the read cannot raise, and cannot cost the caller unboundedly.** +`read_benches` returns `([], "...")` for damaged, absent, oversized, or +unreadable; it never propagates. Over `BENCHES_MAX_BYTES` is refused by size +before it is parsed. +*Falsifiable:* the defeating change is `json.load` without the guard. The test +GETs the standing board's page with the registry (a) absent, (b) holding +non-JSON bytes, (c) holding valid JSON of the wrong shape, (d) holding a +well-formed record with a wrong-typed field, (e) over the size cap, and (f) +chmod'd unreadable, asserting 200 for all six AND that (b)–(f) render a visible +error rather than an empty panel. Case (d) is the one that matters: it is the +shape that is currently 500ing the gallery elsewhere in this service. + +**INV-6 — the identity collapses a re-post and nothing else.** Upserting the +same normalized URL updates one row; upserting two URLs that differ in path, +query or host creates two. +*Falsifiable:* the defeating change is normalizing to the origin. The test +registers the eight gitea URLs measured on the live board and asserts **eight** +benches, then registers `talk`'s five rows and asserts **one** — the same +fixture proves both directions. A test that only checked the talk collapse would +pass under origin normalization, which is precisely the wrong rule. + +**INV-7 — `url` is what a click goes to; `id` is never rendered as an href.** +*Falsifiable:* the defeating change is rendering `bench.id` in the anchor +because it is "the clean one". The test registers a URL whose normalization +differs from its raw form (a trailing slash on a non-empty path, an uppercase +host, a fragment) and asserts the anchor's `href` is the raw string, byte for +byte. + +**INV-8 — nothing this unit ships removes a board row.** The dead marker is a +render-time stamp; `import` without `--apply` writes nothing anywhere; `import` +with `--apply` writes only `.benches.json`. +*Falsifiable:* the defeating change is `import --apply` "tidying up" the rows it +consumed. The test snapshots `links.md` byte for byte, runs the full unit's CLI +surface against it — refusal, import, import --apply, bench add, bench rm — and +asserts the file is unchanged, including its mtime-independent content hash. + +**INV-9 — stdlib-only, and sibling-free.** `booth/benches.py` imports nothing +outside the standard library and nothing from `booth.*`. +*Falsifiable:* the defeating change is `from booth.links import booth_target` — +which is the natural thing to write, since `booth_target` is the predicate this +unit's CLI branch also needs. + +**The existing parametrized `test_stdlib_only` (tests/test_marks.py:280) DOES +NOT CATCH THAT, and an earlier draft of this contract claimed it did.** Its +failure set is `{r for r in roots if r != "booth" and r not in +sys.stdlib_module_names}` — it exempts `booth` explicitly, so a sibling import +passes it clean. The sibling-free clause exists only in the stricter copy at +tests/test_manifest.py:209. Adding `benches` to the parametrized list therefore +buys stdlib-only and NOT sibling-free. So: `benches` joins that list AND +`tests/test_benches.py` carries its own stricter copy, mirroring `manifest`'s, +which fails on a `booth` root. Verified by reading the real test — seam review +SR-1. + +## Seam review — what the real sibling surfaces said + +Run in-session against the actual `.py` files rather than their contracts, +after the cold panel was dispatched and before any code. Seven checks, five +findings, three of them real defects in this document. `/heid-contract-review` +is artifact-only by design and structurally cannot run this pass: its arms read +this file and are forbidden the siblings it borrows from. + +| # | seam | what the real surface said | disposition | +|---|---|---|---| +| **SR-1** | `test_stdlib_only` (tests/test_marks.py:280) | **The contract was wrong.** It claimed the parametrized test "already carries" the sibling-free clause. It does not — its failure set is `{r for r in roots if r != "booth" and ...}`, which exempts `booth` on purpose. Only tests/test_manifest.py:209 has the strict copy. | **Fixed.** INV-9 now requires both: the parametrize entry AND a stricter copy in `tests/test_benches.py`. Without this the unit would have shipped with its own INV-9 untested. | +| **SR-2** | `resolve_booth` (app.py:798) | **The contract invited an outage.** It named `resolve_booth` as the existence check for the dead marker. That function is a closure inside `create_app` (not importable) and **raises HTTPException(404)** — called per row, one swept booth would 404 the entire board page. It also calls `.resolve()`, a syscall per row, 178 of them on this board. | **Fixed.** `depends_on` now forbids it explicitly and specifies an own non-raising predicate with the same name-safety rules. Cost stated below. | +| **SR-7** | `quote(name, safe="")` (app.py, booth link emission) | **The contract was silent on encoding.** Booth links are emitted percent-encoded. A `booth_target` comparing the raw path segment to a directory name marks every encoded-name booth permanently dead and echoes the encoded form back in the refusal. | **Fixed.** `booth_target` decodes, and applies `resolve_booth`'s own addressability rules so the two cannot disagree. | +| **SR-6** | `scripts/booth` dispatch (flat `case "$cmd"`, 13 single-word verbs) | Not a defect — a gap. **`bench add` would be the first two-word verb in this script.** Nothing about the existing dispatch anticipates one, and `booth bench` with no sub-verb must not fall through into the generic usage in a way that hides which word was wrong. | **Recorded.** A nested `case` under `bench)`, and a bare `bench` prints the bench verbs specifically. Named so the implementer does not invent a third pattern. | +| **SR-3** | `data_dir` (app.py:704) vs `DATA` (scripts/booth:120) | The service resolves and expands its root in `create_app`; the CLI derives it from `$BOOTH_DATA_DIR`. Two independent derivations of one path. | **No change.** This is already true of `links.md`, `.marks.json` and `.booth.json` — pre-existing and out of this unit's scope. Recorded so it is a known property rather than a discovery. | +| **SR-4** | `list_booths` (app.py:418) | **Confirmed, not assumed.** `if not child.is_dir() or child.name.startswith("."): continue` — `.benches.json` fails both guards. The index cannot see the registry. | **Verified.** The assumption stands on read code. | +| **SR-5** | `sweep_once` (app.py:386) | **Confirmed, not assumed — and this was the dangerous one.** The sweeper iterates the data root and could in principle delete the registry. It cannot: the same `is_dir()` + leading-dot pair guards it, and `shutil.rmtree` is reached only past both. | **Verified.** Had either guard been absent this unit would have shipped a design that eats its own registry on the first tick. | + +**The per-render cost, stated because SR-2 surfaced it.** The dead marker runs +once per board row: 221 rows today, 178 of which parse as booth links and cost +one `is_dir()` each. That is one `stat` per booth row per render of the standing +board's page — and the page already does a `booth_items` walk plus a `hold_read` +per booth on the index, so it is not a new order of magnitude. It is bounded by +the row count, it touches no network, and it is confined to the ONE booth that +carries a `links.md`. If the board ever grows past a few thousand rows this +becomes worth caching; at 221 it would be premature. diff --git a/docs/design/information-architecture.md b/docs/design/information-architecture.md index cbd33b2..e6c9bdc 100644 --- a/docs/design/information-architecture.md +++ b/docs/design/information-architecture.md @@ -162,6 +162,7 @@ session that posted the set. ``` BENCH id : normalized URL (the identity — re-posting UPDATES, never appends) + NORMALIZED MEANS THE FULL URL, NOT THE ORIGIN — see below name : what it is owner : the agent handle that registered it state : live → promoted (to Homepage) → retired @@ -173,8 +174,51 @@ BENCH - `booth bench add ""` upserts on the normalized URL. The 5 `talk` rows and 4 `peedlar` rows collapse to one each, by construction. - **`booth link` refuses a `…:8090/b/…` URL** and names the right surface. It - survives as a deprecated alias rather than vanishing — 17 handles have the - muscle memory, and the teaching moment belongs at the point of use. + is **not deprecated** — 17 handles have the muscle memory, the teaching moment + belongs at the point of use, and (corrected 2026-09-22, U6) the board has a + legitimate residual job: of the 35 distinct non-booth targets on it, roughly + **14 are reference bookmarks** — gitea repositories, HuggingFace model cards, + a vLLM recipe, a Headscale setup page — for which the board is the right and + only home. Deprecating it would evict a third of its live content. It loses + exactly one shape, the booth URL, and keeps the rest. + +### What "normalized URL" means, and why it is not the origin + +Corrected 2026-09-22 while U6 was being contracted. This doc said *normalized +URL* and left it there; the obvious reading is the origin +(`scheme://host:port`), and that reading is **measurably destructive**. + +Collapsing the board's 43 non-booth rows by origin yields 19 groups; by full +URL, 35. The 16-group difference is not duplication: + +| what origin identity would merge | rows | +|---|---| +| eight distinct gitea repositories, issues and package versions | 8 → 1 | +| three unrelated HuggingFace model cards | 3 → 1 | +| **the two LRPG surfaces on `10.100.10.50:8321`** — this doc's own example of two real benches | 2 → 1 | +| two different claude.ai artifact briefs | 2 → 1 | + +Full-URL identity still collapses both cases this doc names — `talk` 5 rows to +1, Peedlar's root 3 to 1 — which is the entire win, without the losses. + +The **query string is part of the identity** and the **fragment is not**: three +ShutterChute rows differ only by `?token=` and are three genuinely different +one-shot links, while a fragment is a position inside a page. Credentials in a +URL are **refused rather than stripped** — stripping registers a bench whose URL +no longer works while telling the poster it succeeded. + +### One number that was two defects + +This doc's headline **69% rot** is two different defects wearing one number, and +U5 already closed the cause of the larger one: + +| defect | rows (2026-09-22) | what fixes it | +|---|---|---| +| **booth-announcement rot** — a session posts a booth URL because a booth cannot announce itself | 178 rows, 156 already dead | **U5** gave job 5 a home; U6's refusal stops the habit; U6's dead marker clears what landed | +| **bench re-post** — an append log with no identity | 8 rows | U6's registry | + +Worth stating plainly because the single figure implies the registry is the big +half. It is the smaller one. - Liveness is *flagged*, not enforced. A bench that stops answering gets a marker and a date; deleting is the operator's call. Nothing here deletes the operator's data on a timer. diff --git a/persistent-memory.d/2026-09-22-one-number-was-two-defects.md b/persistent-memory.d/2026-09-22-one-number-was-two-defects.md new file mode 100644 index 0000000..7260d97 --- /dev/null +++ b/persistent-memory.d/2026-09-22-one-number-was-two-defects.md @@ -0,0 +1,39 @@ +# The 69% link-board rot was two defects wearing one number + +_2026-09-22 · booth_ + +**Re-measuring the board before writing U6's contract split its headline number +in half, and the half U6 owns is the smaller one.** The IA doc records *211 +rows, 145 (69%) pointing at booths that no longer exist*. Re-counted on +2026-09-22 the board was 221 rows — and the split nobody had taken before: + +| | count | share | +|---|---|---| +| rows that are booth URLs | **178** | 80% of the board | +| …whose booth is already swept | **156** | **71% of the whole board** | +| rows that are NOT booth URLs | 43 | 19% | +| …distinct after full-URL normalization | 35 | | +| …collapsed by the re-post problem U6 names | **8 rows** | | + +So the 69% is: + +1. **Booth-announcement rot — 178 rows.** A session posted a booth URL because + a booth could not announce itself. **U5 already closed the cause.** Nothing + stopped the habit, so the board took 11 more of these in the day after it was + first measured. +2. **Bench re-post — 8 rows.** An append log with no identity. This is the part + the registry fixes, and it is an order of magnitude smaller. + +**The third thing, which the IA doc does not describe at all:** of the 35 +distinct non-booth targets, roughly **14 are running services (benches)** and +roughly **14 are reference bookmarks** — gitea repos, HuggingFace model cards, a +vLLM recipe, a Headscale page — with the rest ephemeral one-shot links. The IA +doc planned for `booth link` to survive "as a deprecated alias". That would have +evicted a third of the board's live content from the only home it has. **U6 does +not deprecate `booth link`**; it removes exactly one shape from it. + +**Why this is worth keeping.** The single 69% figure implies the registry is the +big win. It is not — the enforced rule and the dead marker are. A unit scoped +off the unsplit number would have built the registry, declared victory, and left +178 rows rotting. Re-measure before contracting; the number in the design doc is +a day old the moment it is written. diff --git a/persistent-memory.d/2026-09-22-u6-benches-landed.md b/persistent-memory.d/2026-09-22-u6-benches-landed.md new file mode 100644 index 0000000..4bb864a --- /dev/null +++ b/persistent-memory.d/2026-09-22-u6-benches-landed.md @@ -0,0 +1,65 @@ +# U6 landed — three surfaces, three jobs, one predicate + +_2026-09-22 · booth_ + +**The sixth of seven v1 units. Only U7 is left.** 444 → 555 tests, suite green, +deployed and verified live: 23/23 booths 200, and the board renders **156 dead +of 221 rows** — the exact count an independent shell measurement produced before +a line of code was written, from two different implementations. + +## What shipped + +- **`booth/benches.py`** (new, stdlib-only AND sibling-free): `Bench`, + `normalize_bench_url`, lenient `read_benches`, strict `upsert_bench`, + `set_bench_state`, `remove_bench`, `order_benches`. Registry at + `~/booth-data/.benches.json` — a dotfile at the DATA ROOT, keyed by id, so two + rows with one identity are impossible by construction. +- **`links.booth_target`** — ONE predicate for "is this a booth URL", consumed + by three callers (the CLI refusal, the board's dead marker, `bench import`). + Host-agnostic and path-shaped; percent-decodes the name. +- **`booth link` refuses a booth URL**, names `booth new --why`, and writes + nothing — not even the board directory. +- **The board marks dead rows.** Removal stays the operator's two clicks through + the bulk control that already existed. Nothing in the unit deletes a row. +- **`booth bench add|ls|state|rm|import`**; `import` writes nothing without + `--apply` and never touches `links.md`. +- `docs/archive/links-2026-09-22.md` — the board archived verbatim into git. + +## The decision that mattered most, and it was measured + +**Identity is the FULL normalized URL, not the origin.** Collapsing the 43 +non-booth rows by origin gives 19 groups; by full URL, 35. The difference is not +duplication — it is **eight distinct gitea repos merged into one**, three +unrelated HuggingFace model cards merged into one, and **the two LRPG surfaces +on `10.100.10.50:8321`, which are the IA doc's own example of two real benches**, +merged into one. Origin identity destroys more than it dedups. Full-URL identity +still collapses both cases the doc names (talk 5→1, Peedlar 3→1). + +Query is IN the identity (three ShutterChute rows differ only by `?token=` and +are three real links); fragment is OUT; credentials are REFUSED, not stripped. + +## The seam review earned it again — three real contract defects + +Run in-session against the real `.py` files, after the cold panel was dispatched: + +- **SR-1** — the contract claimed `test_stdlib_only` already forbids sibling + imports. **It does not**: its failure set is `{r for r in roots if r != + "booth" and ...}`, which exempts `booth` on purpose. Only test_manifest.py has + the strict copy. INV-9 would have shipped untested. +- **SR-2** — the contract named `resolve_booth` as the dead marker's existence + check. That function is a closure inside `create_app` and **raises + HTTPException(404)** — per row, one swept booth would 404 the whole board page. +- **SR-7** — booth links are emitted through `quote(name, safe="")`, so a + predicate comparing the raw segment marks every encoded-name booth dead + forever. + +SR-4 and SR-5 were **verified rather than assumed**: both `list_booths` and +`sweep_once` skip a child that is not a directory AND one whose name starts with +a dot, so the registry is safe from the sweeper by two guards, not one. Had +either been absent the design would have eaten its own registry on tick one. + +## Still open at the time of writing + +Both cold gates are IN FLIGHT — contract review `01M35BWCJ806MT75NA630Y4WFH`, +code review `01M35CK8YKEKMV7T15JXEF6A8N`. The bug-hunt has not run. **Committed +but NOT tagged**, per the v0.2.0 lesson: if a gate is outstanding, the tag waits. diff --git a/persistent-memory.md b/persistent-memory.md index 82e48d8..5a939b0 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -44,16 +44,29 @@ _As of 2026-09-22:_ deliberate and it CHANGES HOW THE 2026-10-06 RE-COUNT READS: the hold rides for free, but not-pressing-`keep` has to be learned, so a flat `.forever` rate does not falsify anything. Read its entry before measuring. -- **TWO UNITS LEFT TO v1, and they do not depend on each other.** U6 (benches, - independent, closes the 69% link-board rot) and U7 (navigation at 270 items, - which U3 just unblocked — its only dependency was {U3, U4, U5}). Which goes - next is the operator's call. ⚠ Before starting U7, read - `persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md`: every booth - that actually needs navigation is FLAT, so half its premise is already known - to be wrong. **No recommendation is recorded for this one on purpose** — U6 - and U7 are genuinely independent, they close different defects, and the last - two units before a 1.0 cut are a scope-direction call rather than a - dependency one. +- **U6 LANDED 2026-09-22 — ONE UNIT LEFT TO v1.** Benches: a registry keyed by + normalized URL, `booth link` refusing a booth URL, dead rows marked on the + board, and a non-destructive `bench import`. 444 → 555 tests, deployed and + verified live (23/23 booths 200, 156 of 221 rows marked dead — matching an + independent pre-implementation count exactly). ⚠ **COMMITTED BUT NOT TAGGED + AND NOT RELEASED**: both cold gates were still in flight at commit time + (contract review `01M35BWCJ806MT75NA630Y4WFH`, code review + `01M35CK8YKEKMV7T15JXEF6A8N`) and the bug-hunt had not run. Per the v0.2.0 + lesson, the tag waits for the gates. → `persistent-memory.d/2026-09-22-u6-benches-landed.md` +- ⚠ **U6 WAS CHOSEN WITHOUT THE OPERATOR ANSWERING.** He set an autonomous goal + ("hydrate and land next unit stated in handoff") and the handoff named no + unit. The session recommended U6 on measured grounds (its defect compounds — + 145 → 156 dead rows in a day — while U7's is dormant, and U6 had no + unresolved design questions) and proceeded rather than blocking. **The scope + call is still his to reverse**; nothing is pushed and nothing is tagged. +- **U7 IS THE LAST UNIT, and its premise degraded again.** ⚠ Read + `persistent-memory.d/2026-09-21-u7-section-premise-half-wrong.md` AND + re-count first. On 2026-09-22 the four large booths U7 was sized against + (`pancake-v3-full`/`pancake-v4-full` at 270 items, `sindra20-engines`, + `sindra-finalists`) had ALL been swept. Largest live booth is `miranda-is` at + **92 items, flat**. Two of 23 booths have subfolders and **both are reports**. + Sections buy close to nothing; the rail, filters and grid keyboard are the + unit. - **U3's tier was MINOR and the operator approved it** (2026-09-22). The argument that settled it, recorded because the tie-break rule says patch: a capability arrived AND one left — the verbatim path gained a declared public @@ -108,6 +121,8 @@ _As of 2026-09-22:_ ## Recent decisions +- `[2026-09-22]` **U6 landed — three surfaces, three jobs, one predicate** — the seam review caught three real contract defects incl. a per-row `resolve_booth` that would have 404'd the board; NOT TAGGED, gates in flight → `persistent-memory.d/2026-09-22-u6-benches-landed.md` +- `[2026-09-22]` **The 69% link-board rot was two defects wearing one number** — READ BEFORE SCOPING ANY LINK-BOARD WORK; U5 closed the larger half and full-URL-vs-origin identity is a measured call → `persistent-memory.d/2026-09-22-one-number-was-two-defects.md` - `[2026-09-22]` **U3 landed — the page declares the seam, the Booth mounts into it** — ten regexes against author HTML replaced by a substring test and a `+` → `persistent-memory.d/2026-09-22-u3-declared-embed-seam-landed.md` - `[2026-09-22]` **A wrong-shaped answer 500s the gallery and the marks page** — PRE-EXISTING (measured at `42ea67f`), NOT U3; the v0.2.2 lesson is only half-implemented → `persistent-memory.d/2026-09-22-a-wrong-shaped-answer-500s-the-gallery.md` - `[2026-09-22]` **The browser became a test surface** — READ BEFORE TOUCHING `playwright` IN pyproject; the pinned upper bound is the foot-gun, and these tests SKIP rather than fail → `persistent-memory.d/2026-09-22-the-browser-became-a-test-surface.md` diff --git a/scripts/booth b/scripts/booth index ca569e6..27c57f6 100755 --- a/scripts/booth +++ b/scripts/booth @@ -16,9 +16,29 @@ # pick holds its own booth, see below) # booth unkeep hand it back to the sweeper # booth link [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 remove ONE link from the board # +# booth bench add register or UPDATE a bench (upsert) +# booth bench ls list benches, live -> promoted -> retired +# booth bench state live | promoted | retired +# booth bench rm 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