From 5ca826cf4347fd08291ffcb49dd2a5fc800ae167 Mon Sep 17 00:00:00 2001 From: Dongkeun Lee Date: Sat, 1 Aug 2026 12:55:03 -0400 Subject: [PATCH 1/2] feat: add permanent lavish captain boards --- .agents/skills/lavish-decisions/SKILL.md | 23 +- bin/fm-lavish-board.sh | 184 ++++++++++ docs/scripts.md | 1 + tests/fm-lavish-board.test.sh | 130 +++++++ tests/operating-fundamentals.test.sh | 3 +- tools/lavish/PROVENANCE.md | 3 +- tools/lavish/README.md | 66 +++- tools/lavish/package-lock.json | 283 ++++++++++++++- tools/lavish/package.json | 10 +- tools/lavish/src/board.mjs | 397 +++++++++++++++++++++ tools/lavish/src/cli.mjs | 82 ++++- tools/lavish/src/protocol.mjs | 420 ++++++++++++++++++++++- tools/lavish/src/relvino-tokens.css | 211 ++++++++++++ tools/lavish/test/lavish.test.mjs | 212 +++++++++++- 14 files changed, 1992 insertions(+), 33 deletions(-) create mode 100755 bin/fm-lavish-board.sh create mode 100755 tests/fm-lavish-board.test.sh create mode 100644 tools/lavish/src/board.mjs create mode 100644 tools/lavish/src/relvino-tokens.css diff --git a/.agents/skills/lavish-decisions/SKILL.md b/.agents/skills/lavish-decisions/SKILL.md index c76f78724f..11640a5ac1 100644 --- a/.agents/skills/lavish-decisions/SKILL.md +++ b/.agents/skills/lavish-decisions/SKILL.md @@ -21,21 +21,21 @@ Use the firstmate-owned `lavish-axi` file protocol documented in `tools/lavish/R Include the decision, recommendation, alternatives, consequences, and what happens after each choice. 3. Define the ordered questions and options in the JSON shape documented by the tool. Use nonempty unique lowercase-slug keys. + When a question needs visual evidence, reference copied filenames through the documented question `visuals` field and pass the source directory to `lavish-axi create --visuals`. 4. Choose a durable `$FM_HOME`-relative destination below `data/`. This is where intake commits the validated answer before writing its receipt. 5. Run `lavish-axi create` with a stable decision id, title, Markdown request, question JSON, and destination, and retain its emitted `Run:` line. 6. From firstmate's environment, run `lavish show ` and `lavish inbox` to verify the exact durable request. -7. Surface only the title and the exact `Run:` line emitted by `lavish-axi create`: +7. Run `bin/fm-lavish-board.sh --home ` from the active firstmate checkout. + Tell the captain that the board is open in a separate Chrome window: ```text Decision waiting: - + Board open in the separate Lavish Chrome window. ``` -The surfaced command is for the captain's shell, not firstmate's environment. -It must retain the emitted `--home` argument and resolved absolute home path even when firstmate has `FM_HOME` exported. -Never shorten the command or reconstruct it from a placeholder. -Always carrying the explicit home is slightly noisier than asking the captain to export `FM_HOME`, but it makes every decision independently runnable and avoids a hidden setup dependency. + If the browser helper cannot run, give the captain the exact `Run:` line emitted by `lavish-axi create` as the terminal fallback. + Never shorten or reconstruct that fallback: its quoted resolved `--home` path makes it work from the captain's shell without `FM_HOME`. Do not edit `request.md` or `manifest.toon` after surfacing the decision. Their digest and ordered question set are the immutable contract. @@ -45,6 +45,9 @@ Their digest and ordered question set are the immutable contract. Firstmate's ordinary wake drain and session start invoke Lavish intake. The answer file is authoritative; the wake record is only a pointer. +When a browser check wakes with `lavish-submit: `, read that payload and run `lavish-axi collect --payload --home `. +Confirm to the captain only after `collect` reports that the durable answer was saved. + When a destination appears: 1. Read the full validated answer from the declared destination. @@ -54,9 +57,11 @@ When a destination appears: ## Reliability boundary -Never start a server, open a browser, create or share a session URL, poll, long-poll, register a filesystem watcher, schedule a timer sweep, or launch a resident process for Lavish decision capture. -Do not use upstream `serve`, `poll`, browser, layout-audit, or session-lifecycle commands. +The Lavish fork runs no server, listener, poller, watcher, or resident process. +Its `board` command writes one self-contained HTML file and exits, just as its other commands finish one bounded local file operation and exit. +Do not use upstream `serve`, `poll`, layout-audit, or session-lifecycle commands. -Every Lavish command must finish its bounded local file operation and exit. +Firstmate may show that file through `bin/fm-lavish-board.sh`, which owns the separate named Chrome session and the task-neutral check integrated with Firstmate's existing watcher. +That browser glue must never attach to the captain's main Chrome profile or expose a shared board server or session URL. If `lavish answer` reports `answer saved; wake not queued`, do not ask the captain to answer again. The next ordinary intake scan recovers the durable unreceipted answer. diff --git a/bin/fm-lavish-board.sh b/bin/fm-lavish-board.sh new file mode 100755 index 0000000000..ee72c4354d --- /dev/null +++ b/bin/fm-lavish-board.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# Render one durable Lavish decision, open it in an isolated headed Chrome +# session, and arm a one-shot watcher check for the browser submit marker. +# +# Usage: +# fm-lavish-board.sh [--home ] +# +# Internal watcher entry point: +# fm-lavish-board.sh --check --home --session +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" +SUBMIT_MARKER='LAVISH-SUBMIT v2' + +usage() { + cat <<'USAGE' +Usage: fm-lavish-board.sh [--home ] + +Render a self-contained Lavish board, open it in a headed isolated Chrome +session, and arm a watcher check that captures LAVISH-SUBMIT v2. +USAGE +} + +fail() { + printf 'fm-lavish-board: %s\n' "$*" >&2 + exit 2 +} + +validate_id() { + local value=$1 + [[ "$value" =~ ^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$ ]] \ + || fail 'decision id must be a lowercase slug of at most 64 characters' +} + +resolve_home() { + local requested=$1 + [ -d "$requested" ] && [ ! -L "$requested" ] \ + || fail "unsafe or missing home: $requested" + (cd "$requested" && pwd -P) +} + +isolated_chrome() { + local session=$1 + shift + ( + unset CHROME_DEVTOOLS_AXI_AUTO_CONNECT + unset CHROME_DEVTOOLS_AXI_BROWSER_URL + unset CHROME_DEVTOOLS_AXI_CHROME_ARGS + unset CHROME_DEVTOOLS_AXI_PORT + unset CHROME_DEVTOOLS_AXI_USER_DATA_DIR + unset CHROME_DEVTOOLS_AXI_WS_HEADERS + export CHROME_DEVTOOLS_AXI_SESSION="$session" + export CHROME_DEVTOOLS_AXI_HEADED=1 + chrome-devtools-axi "$@" + ) +} + +check_submission() { + local decision_id=$1 + local home=$2 + local session=$3 + local state_dir="$home/state" + local payload_path="$state_dir/lavish-board-$decision_id.payload.json" + local check_path="$state_dir/lavish-board-$decision_id.check.sh" + local evaluation result_line temporary + + [ -d "$state_dir" ] && [ ! -L "$state_dir" ] || exit 0 + evaluation=$(isolated_chrome "$session" eval \ + 'JSON.stringify({title: document.title, payload: window.__lavishPayload ?? null})' \ + 2>/dev/null) || exit 0 + result_line=$(printf '%s\n' "$evaluation" | sed -n 's/^result: //p' | sed -n '1p') + [ -n "$result_line" ] || exit 0 + + temporary=$(mktemp "$state_dir/.lavish-board-$decision_id.payload.XXXXXX") + if printf '%s\n' "$result_line" | node -e ' + const fs = require("node:fs"); + const raw = fs.readFileSync(0, "utf8").trim(); + const target = process.argv[1]; + const marker = process.argv[2]; + let snapshot = raw; + try { + for (let depth = 0; depth < 4 && typeof snapshot === "string"; depth += 1) { + snapshot = JSON.parse(snapshot); + } + } catch { + process.exit(4); + } + if (snapshot?.title !== marker || snapshot?.payload == null) process.exit(3); + fs.writeFileSync(target, JSON.stringify(snapshot.payload) + "\n", { mode: 0o600 }); + ' "$temporary" "$SUBMIT_MARKER"; then + mv "$temporary" "$payload_path" + else + rm -f "$temporary" + exit 0 + fi + + rm -f "$check_path" + isolated_chrome "$session" stop >/dev/null 2>&1 || true + printf 'lavish-submit: %s %s\n' "$decision_id" "$payload_path" +} + +MODE=board +if [ "${1:-}" = '--check' ]; then + MODE=check + shift +fi + +[ "$#" -gt 0 ] || { usage >&2; exit 2; } +DECISION_ID=$1 +shift +validate_id "$DECISION_ID" + +HOME_ARG=${FM_HOME:-$REPO_ROOT} +SESSION= +while [ "$#" -gt 0 ]; do + case "$1" in + --home) + [ "$#" -gt 1 ] || fail '--home requires a path' + HOME_ARG=$2 + shift 2 + ;; + --session) + [ "$MODE" = check ] || fail '--session is internal to the watcher check' + [ "$#" -gt 1 ] || fail '--session requires a name' + SESSION=$2 + shift 2 + ;; + --help) + usage + exit 0 + ;; + *) + fail "unknown argument: $1" + ;; + esac +done + +HOME_PATH=$(resolve_home "$HOME_ARG") +STATE_DIR="$HOME_PATH/state" + +if [ "$MODE" = check ]; then + [ -n "$SESSION" ] || fail '--session is required in check mode' + check_submission "$DECISION_ID" "$HOME_PATH" "$SESSION" + exit 0 +fi + +command -v lavish >/dev/null 2>&1 || fail 'lavish is not installed' +command -v chrome-devtools-axi >/dev/null 2>&1 \ + || fail 'chrome-devtools-axi is not installed' +command -v node >/dev/null 2>&1 || fail 'node is not installed' + +mkdir -p "$STATE_DIR" +[ ! -L "$STATE_DIR" ] || fail "unsafe state directory: $STATE_DIR" +umask 077 + +HOME_DIGEST=$(printf '%s' "$HOME_PATH" | shasum -a 256 | awk '{print substr($1,1,12)}') +SESSION="lavish-${DECISION_ID:0:36}-$HOME_DIGEST" +HTML_PATH="$STATE_DIR/lavish-board-$DECISION_ID.html" +PAYLOAD_PATH="$STATE_DIR/lavish-board-$DECISION_ID.payload.json" +CHECK_PATH="$STATE_DIR/lavish-board-$DECISION_ID.check.sh" +CHECK_TMP=$(mktemp "$STATE_DIR/.lavish-board-$DECISION_ID.check.XXXXXX") + +lavish board "$DECISION_ID" --home "$HOME_PATH" --out "$HTML_PATH" +rm -f "$PAYLOAD_PATH" +{ + printf '#!/usr/bin/env bash\n' + printf 'exec %q --check %q --home %q --session %q\n' \ + "$SCRIPT_DIR/fm-lavish-board.sh" "$DECISION_ID" "$HOME_PATH" "$SESSION" +} > "$CHECK_TMP" +chmod 700 "$CHECK_TMP" +mv "$CHECK_TMP" "$CHECK_PATH" + +BOARD_URL=$(node -e \ + 'const { pathToFileURL } = require("node:url"); console.log(pathToFileURL(process.argv[1]).href);' \ + "$HTML_PATH") +if ! isolated_chrome "$SESSION" open "$BOARD_URL"; then + rm -f "$CHECK_PATH" + fail 'could not open the isolated Chrome session' +fi + +printf 'Opened Lavish board %s in isolated Chrome session %s.\n' \ + "$DECISION_ID" "$SESSION" +printf 'Armed submission check: %s\n' "$CHECK_PATH" diff --git a/docs/scripts.md b/docs/scripts.md index e9f05387dc..35666a7557 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -67,6 +67,7 @@ The shared no-mistakes gate refusal used by every directly invocable mutating co | `fm-process-tree-lib.sh` | Shared bounded command runner that terminates and reaps complete process trees | | `fm-config-inherit-lib.sh` | Shared primary-to-secondmate inheritable-config propagation | | `fm-tasks-axi-lib.sh` | Shared backlog-backend selector and `tasks-axi` compatibility probe | +| `fm-lavish-board.sh` | Open one self-contained decision board in isolated Chrome and arm its submit check | | `fm-lavish-intake.sh` | Invoke compatible store-and-forward Lavish intake at an existing turn boundary | | `fm-lavish-wake.sh` | Append one durable Lavish answer pointer through the canonical wake queue lock | | `fm-wake-drain.sh` | Intake Lavish answers, atomically drain queued wakes, then assert watcher liveness | diff --git a/tests/fm-lavish-board.test.sh b/tests/fm-lavish-board.test.sh new file mode 100755 index 0000000000..5996309a28 --- /dev/null +++ b/tests/fm-lavish-board.test.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# Behavioral contract for the isolated Lavish board launcher and its one-shot +# submit-marker check. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +BOARD="$ROOT/bin/fm-lavish-board.sh" +TMP_ROOT=$(fm_test_tmproot fm-lavish-board-tests) +HOME_PATH="$TMP_ROOT/home" +FAKEBIN="$TMP_ROOT/fakebin" +LOG="$TMP_ROOT/calls.log" +mkdir -p "$HOME_PATH/data/decisions/example" "$HOME_PATH/state" "$FAKEBIN" + +cat > "$FAKEBIN/lavish" <<'SH' +#!/usr/bin/env bash +printf 'lavish:%s\n' "$*" >> "$FM_TEST_LOG" +[ "${1:-}" = board ] || exit 2 +shift 2 +while [ "$#" -gt 0 ]; do + case "$1" in + --home) shift 2 ;; + --out) output=$2; shift 2 ;; + *) exit 2 ;; + esac +done +printf 'Lavish board\n' > "$output" +SH + +cat > "$FAKEBIN/chrome-devtools-axi" <<'SH' +#!/usr/bin/env bash +printf 'chrome:%s session=%s headed=%s auto=%s browser=%s profile=%s args=%s port=%s headers=%s\n' \ + "$*" "${CHROME_DEVTOOLS_AXI_SESSION:-}" "${CHROME_DEVTOOLS_AXI_HEADED:-}" \ + "${CHROME_DEVTOOLS_AXI_AUTO_CONNECT-unset}" \ + "${CHROME_DEVTOOLS_AXI_BROWSER_URL-unset}" \ + "${CHROME_DEVTOOLS_AXI_USER_DATA_DIR-unset}" \ + "${CHROME_DEVTOOLS_AXI_CHROME_ARGS-unset}" \ + "${CHROME_DEVTOOLS_AXI_PORT-unset}" \ + "${CHROME_DEVTOOLS_AXI_WS_HEADERS-unset}" >> "$FM_TEST_LOG" +case "${1:-}" in + open|stop) exit 0 ;; + eval) + FAKE_MARKER=${FM_TEST_MARKER:-absent} node -e ' + const present = process.env.FAKE_MARKER === "present"; + const snapshot = present + ? { + title: "LAVISH-SUBMIT v2", + payload: { + schema_version: 2, + decision_id: "example", + request_sha256: `sha256:${"a".repeat(64)}`, + answers: [{ + key: "choice", + value: "a", + question_note: "whole question", + option_comments: { a: "this option" }, + }], + note: "complete batch", + }, + } + : { title: "Lavish - Example", payload: null }; + console.log(`result: ${JSON.stringify(JSON.stringify(JSON.stringify(snapshot)))}`); + ' + ;; + *) exit 2 ;; +esac +SH +chmod +x "$FAKEBIN/lavish" "$FAKEBIN/chrome-devtools-axi" + +run_board() { + env -u FM_HOME \ + FM_TEST_LOG="$LOG" \ + CHROME_DEVTOOLS_AXI_AUTO_CONNECT=1 \ + CHROME_DEVTOOLS_AXI_BROWSER_URL=http://captain-main.invalid \ + CHROME_DEVTOOLS_AXI_CHROME_ARGS=--user-data-dir=/captain/main/profile \ + CHROME_DEVTOOLS_AXI_PORT=9222 \ + CHROME_DEVTOOLS_AXI_USER_DATA_DIR=/captain/main/profile \ + CHROME_DEVTOOLS_AXI_WS_HEADERS=secret \ + PATH="$FAKEBIN:$PATH" \ + "$BOARD" example --home "$HOME_PATH" +} + +test_launcher_isolates_chrome_and_arms_check() { + local output check + output=$(run_board) || fail "board launcher failed: $output" + check="$HOME_PATH/state/lavish-board-example.check.sh" + assert_contains "$output" 'Opened Lavish board example in isolated Chrome session' \ + "launcher did not report the isolated browser" + assert_contains "$output" "Armed submission check: $check" \ + "launcher did not report the check path" + assert_present "$check" "launcher did not create the check script" + [ -x "$check" ] || fail "launcher check script is not executable" + assert_grep "lavish:board example --home $HOME_PATH --out $HOME_PATH/state/lavish-board-example.html" \ + "$LOG" "launcher did not pass an explicit home to lavish" + assert_grep 'headed=1 auto=unset browser=unset profile=unset args=unset port=unset headers=unset' "$LOG" \ + "launcher inherited a captain Chrome connection or profile" + pass "fm-lavish-board opens a headed named session with an isolated throwaway profile" +} + +test_check_is_silent_until_marker_then_prints_once() { + local check absent present payload lines + check="$HOME_PATH/state/lavish-board-example.check.sh" + absent=$( + FM_TEST_MARKER=absent FM_TEST_LOG="$LOG" PATH="$FAKEBIN:$PATH" "$check" + ) || fail "absent-marker check failed" + [ -z "$absent" ] || fail "absent-marker check printed output: $absent" + assert_present "$check" "absent-marker check disarmed itself" + + present=$( + FM_TEST_MARKER=present FM_TEST_LOG="$LOG" PATH="$FAKEBIN:$PATH" "$check" + ) || fail "present-marker check failed" + lines=$(printf '%s\n' "$present" | awk 'NF { count++ } END { print count + 0 }') + [ "$lines" -eq 1 ] || fail "present-marker check printed $lines lines" + payload="$HOME_PATH/state/lavish-board-example.payload.json" + [ "$present" = "lavish-submit: example $payload" ] \ + || fail "present-marker check printed the wrong wake line: $present" + assert_absent "$check" "present-marker check did not disarm itself" + assert_present "$payload" "present-marker check did not save the browser payload" + node -e ' + const fs = require("node:fs"); + const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (payload.answers[0].question_note !== "whole question") process.exit(1); + if (payload.answers[0].option_comments.a !== "this option") process.exit(1); + ' "$payload" || fail "captured payload lost structured annotations" + pass "Lavish board check stays silent without the marker and emits one durable wake line with it" +} + +test_launcher_isolates_chrome_and_arms_check +test_check_is_silent_until_marker_then_prints_once diff --git a/tests/operating-fundamentals.test.sh b/tests/operating-fundamentals.test.sh index e056d20e1f..426237c6ad 100755 --- a/tests/operating-fundamentals.test.sh +++ b/tests/operating-fundamentals.test.sh @@ -136,7 +136,8 @@ test_live_surface_freshness_contract() { assert_grep "Reconcile the proposed decision against live fleet state" "$LAVISH_SKILL" "Lavish decisions must use live fleet state" assert_grep "Do not edit \`request.md\` or \`manifest.toon\` after surfacing" "$LAVISH_SKILL" "surfaced decision contracts must be immutable" assert_grep "The answer file is authoritative; the wake record is only a pointer" "$LAVISH_SKILL" "durable answers must outrank wake pointers" - assert_grep "Never start a server, open a browser, create or share a session URL, poll" "$LAVISH_SKILL" "the disqualified browser and polling lifecycle must stay prohibited" + assert_grep "The Lavish fork runs no server, listener, poller, watcher, or resident process" "$LAVISH_SKILL" "the fork must retain its no-resident-process reliability boundary" + assert_grep "browser glue must never attach to the captain's main Chrome profile" "$LAVISH_SKILL" "browser surfacing must retain captain-profile isolation" pass "live-surface freshness preserves completion history and in-progress answers" } diff --git a/tools/lavish/PROVENANCE.md b/tools/lavish/PROVENANCE.md index 8ce49d68cf..a7c2ae77b2 100644 --- a/tools/lavish/PROVENANCE.md +++ b/tools/lavish/PROVENANCE.md @@ -3,6 +3,7 @@ This package is the firstmate-owned, incompatible successor to [`lavish-axi`](https://github.com/kunchenguid/lavish-axi). The upstream package is MIT licensed and established the Lavish product name and the `lavish-axi` executable. -This fork retains those compatibility names but replaces the browser, local server, long-poll, and session lifecycle with a durable file protocol and short-lived terminal commands. +This fork retains those compatibility names but replaces the upstream browser runtime, local server, long-poll, and session lifecycle with a durable file protocol and bounded commands. +Its `board` command emits self-contained HTML and exits without opening a browser or starting a service. No upstream runtime source is copied into this implementation. diff --git a/tools/lavish/README.md b/tools/lavish/README.md index 858f4ce0ac..954f83edc1 100644 --- a/tools/lavish/README.md +++ b/tools/lavish/README.md @@ -1,9 +1,10 @@ # Lavish Lavish is firstmate's durable decision inbox. -It stores complete requests and answers under `$FM_HOME/data/decisions/` and uses short-lived terminal commands only. +It stores complete requests and answers under a Firstmate home and uses bounded commands that exit after each local operation. -There is no server, browser, URL, listener, poll, watcher, idle timeout, or resident process. +The fork runs no server, listener, poller, watcher, idle timeout, or resident process. +It can emit a self-contained browser board, but opening and supervising that file belongs to Firstmate glue outside the fork. An unanswered request remains answerable until the files are deliberately removed. ## Human commands @@ -16,6 +17,7 @@ For direct human use, pass the same explicit home to every command, replacing th lavish inbox --home '/Users/example/firstmate-home' lavish show --home '/Users/example/firstmate-home' lavish answer --home '/Users/example/firstmate-home' +lavish board --home '/Users/example/firstmate-home' --out ``` `lavish answer` renders the complete request, collects one numbered choice for every ordered question, accepts an optional note, shows the whole batch, and requires one explicit confirmation. @@ -25,6 +27,16 @@ It then queues a redundant wake pointer and exits without waiting for firstmate. If wake enqueueing fails, the durable answer remains authoritative. The command prints `answer saved; wake not queued`, exits nonzero, and the next ordinary intake scan recovers it. +`lavish board` renders the complete Markdown request, ordered questions and options, inline visual evidence, per-option comments, per-question notes, a final review, and a submit step into one HTML file. +The HTML includes its CSS, JavaScript, and images and makes no request to a shared board service. +Its dark default theme inlines the vendored Relvino tokens from `src/relvino-tokens.css`, including Inter, Ruby green, `#007AFF` actions, the semantic aliases, and the 16px card radius. + +Submitting the page assigns the complete JSON batch to `window.__lavishPayload` and sets `document.title` to `LAVISH-SUBMIT v2`. +Those browser signals are transport only and do not write an answer until `lavish-axi collect` validates the payload. + +Every command accepts `--home `. +A captain command run from a shell that does not export `FM_HOME` must include that option, while `bin/fm-lavish-board.sh` resolves its Firstmate home and supplies the option itself. + ## Agent commands Create a complete Markdown request and a JSON question definition: @@ -37,7 +49,8 @@ Create a complete Markdown request and a JSON question definition: "options": [ {"value": "a", "label": "Adopt policy A"}, {"value": "b", "label": "Adopt policy B"} - ] + ], + "visuals": ["deployment-overview.png"] } ] ``` @@ -50,11 +63,49 @@ lavish-axi create \ --title "Deployment policy" \ --request /path/to/request.md \ --questions /path/to/questions.json \ + --visuals /path/to/evidence \ --destination data/deployment-policy/captain-answer.toon ``` `request.md` must contain the complete context, recommendation, alternatives, consequences, and next actions. -`create` computes its digest, validates the ordered question set, and writes the request plus `manifest.toon`. +`create` computes its digest, validates the ordered question set, copies supported PNG, JPEG, GIF, and WebP files into the decision's `visuals/` directory, records their digests in the manifest, and writes the complete decision atomically. +Question `visuals` entries name files from that copied directory and place the evidence beside that question on the board. +For existing schema-version-1 manifests, `board` also discovers supported regular image files already present in the conventional `visuals/` directory and renders them with the request context. +Copied visual batches are limited to 64 files and 50 MiB total so rendering remains bounded. + +Collect a payload captured from a browser board without driving the interactive terminal flow: + +```sh +lavish-axi collect deployment-policy \ + --payload /absolute/path/to/payload.json \ + --home /absolute/path/to/firstmate-home +``` + +The payload shape is: + +```json +{ + "schema_version": 2, + "decision_id": "deployment-policy", + "request_sha256": "sha256:...", + "answers": [ + { + "key": "deployment", + "value": "a", + "question_note": "This applies to the whole policy choice.", + "option_comments": { + "a": "Keep the rollback guard.", + "b": "This is too risky today." + } + } + ], + "note": "Optional note on the complete batch." +} +``` + +`collect` validates the decision id, exact request hash, answer count, complete question-key set, selected values, and every annotated option value against the immutable manifest. +It derives labels from the manifest, writes the same atomic `answer.toon` that `lavish answer` writes, queues the same wake record, and exits. +Count drift, unknown keys, unknown values, and stale request hashes fail closed with named errors. At firstmate's existing wake and session-start boundaries: @@ -73,7 +124,8 @@ The firstmate bootstrap install command also records the checkout's narrow wake ## Protocol -The protocol has schema version `1`. +Manifest and receipt records use protocol schema version `1`. +New answer records use schema version `2`, and readers continue to accept existing schema version `1` answers. TOON's strict decoder validates every encoded array count before Lavish applies the schema rules below. `manifest.toon` contains: @@ -83,6 +135,7 @@ TOON's strict decoder validates every encoded array count before Lavish applies - an `$FM_HOME`-relative durable `destination` - `expected_count` and the ordered `questions` array - each question's nonempty unique key and ordered nonempty options +- optional copied visual metadata and question-level visual filename references - `request_sha256`, formatted as `sha256:` - optional `legacy_source` provenance for migration-created records @@ -92,6 +145,7 @@ TOON's strict decoder validates every encoded array count before Lavish applies - `schema_version`, `decision_id`, `request_sha256`, and `submitted_at` - exactly one ordered answer for every expected question key - each selected option value and label +- a question note and option comments keyed by option value for every answer in schema version `2` - an optional note `receipt.toon` contains: @@ -101,7 +155,7 @@ TOON's strict decoder validates every encoded array count before Lavish applies - the declared destination and its content digest The initial `answer.toon` is write-once. -Later revisions require a future numbered-revision protocol and are not part of schema version `1`. +Later revisions require a future numbered-revision protocol and are not part of answer schema version `2`. ## Legacy migration diff --git a/tools/lavish/package-lock.json b/tools/lavish/package-lock.json index 8ef2da2c64..72a5b724fe 100644 --- a/tools/lavish/package-lock.json +++ b/tools/lavish/package-lock.json @@ -1,20 +1,24 @@ { "name": "@ruby-labs/lavish", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ruby-labs/lavish", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { - "@toon-format/toon": "4.1.0" + "@toon-format/toon": "4.1.0", + "markdown-it": "14.3.0" }, "bin": { "lavish": "src/cli.mjs", "lavish-axi": "src/cli.mjs" }, + "devDependencies": { + "linkedom": "0.18.12" + }, "engines": { "node": ">=20" } @@ -24,6 +28,279 @@ "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-4.1.0.tgz", "integrity": "sha512-dBB3pkEx9QYvHnHR6rtkaBAh+7x4W/oA5ONur4G0fh7Ow69PbPuM7OFxzNRABqyxC0t6SZ3RixiGbCuaFjPDAQ==", "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/linkedom": { + "version": "0.18.12", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", + "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "css-select": "^5.1.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.0.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "dev": true, + "license": "ISC" } } } diff --git a/tools/lavish/package.json b/tools/lavish/package.json index 1365c6dbb1..f2d9a03319 100644 --- a/tools/lavish/package.json +++ b/tools/lavish/package.json @@ -1,6 +1,6 @@ { "name": "@ruby-labs/lavish", - "version": "1.0.0", + "version": "1.1.0", "description": "Durable store-and-forward decision capture for Firstmate", "type": "module", "license": "MIT", @@ -17,10 +17,14 @@ ], "scripts": { "test": "node --test", - "check": "node --check src/protocol.mjs && node --check src/migration.mjs && node --check src/cli.mjs && npm test && npm pack --dry-run" + "check": "node --check src/protocol.mjs && node --check src/migration.mjs && node --check src/board.mjs && node --check src/cli.mjs && npm test && npm pack --dry-run" }, "dependencies": { - "@toon-format/toon": "4.1.0" + "@toon-format/toon": "4.1.0", + "markdown-it": "14.3.0" + }, + "devDependencies": { + "linkedom": "0.18.12" }, "engines": { "node": ">=20" diff --git a/tools/lavish/src/board.mjs b/tools/lavish/src/board.mjs new file mode 100644 index 0000000000..009ef51e90 --- /dev/null +++ b/tools/lavish/src/board.mjs @@ -0,0 +1,397 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import MarkdownIt from 'markdown-it'; +import { ANSWER_SCHEMA_VERSION } from './protocol.mjs'; + +export const SUBMIT_MARKER = `LAVISH-SUBMIT v${ANSWER_SCHEMA_VERSION}`; + +const markdown = new MarkdownIt({ + html: false, + linkify: true, + typographer: false, +}); + +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function scriptJson(value) { + return JSON.stringify(value) + .replaceAll('<', '\\u003c') + .replaceAll('>', '\\u003e') + .replaceAll('&', '\\u0026') + .replaceAll('\u2028', '\\u2028') + .replaceAll('\u2029', '\\u2029'); +} + +async function loadVisuals(decision) { + const visuals = new Map(); + for (const visual of decision.visuals) { + const bytes = await readFile(join(decision.visualsDirectory, visual.file)); + visuals.set(visual.file, { + ...visual, + dataUrl: `data:${visual.media_type};base64,${bytes.toString('base64')}`, + }); + } + return visuals; +} + +function visualFigure(visual) { + return `
+ Evidence: ${escapeHtml(visual.file)} +
${escapeHtml(visual.file)}
+
`; +} + +function visualGallery(files, visuals, label) { + if (files.length === 0) return ''; + return `
+
+ ${files.map((filename) => visualFigure(visuals.get(filename))).join('\n')} +
+
`; +} + +function questionMarkup(question, index, visuals) { + const optionMarkup = question.options.map((option, optionIndex) => { + const inputId = `question-${index}-option-${optionIndex}`; + return `
+ + + +
`; + }).join('\n'); + const questionVisuals = question.visuals ?? []; + return `
+
+ ${index + 1} +
+

${escapeHtml(question.key)}

+

${escapeHtml(question.prompt)}

+
+
+ ${visualGallery(questionVisuals, visuals, `Evidence for ${question.prompt}`)} +
${optionMarkup}
+ + +
`; +} + +function boardStyles(tokens) { + return `${tokens} +* { box-sizing: border-box; } +html { min-height: 100%; background: var(--bg-canvas); } +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at 10% 0%, rgba(0, 122, 255, 0.13), transparent 34rem), + radial-gradient(circle at 96% 8%, rgba(8, 94, 62, 0.16), transparent 30rem), + var(--bg-canvas); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: var(--body-lg-size); + line-height: var(--body-lg-lh); +} +button, input, textarea { font: inherit; } +button, input[type='radio'] { cursor: pointer; } +main { width: min(980px, calc(100% - 32px)); margin: 0 auto; padding: 56px 0 96px; } +.masthead { display: grid; gap: 16px; margin-bottom: 32px; } +.brand { display: flex; align-items: center; gap: 10px; color: var(--text-secondary); } +.brand-mark { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border-radius: var(--radius-sm); + background: var(--ruby-green); + color: var(--white); + font-weight: var(--fw-bold); +} +.brand strong { color: var(--text-primary); font-weight: var(--fw-semibold); } +h1 { margin: 0; font-size: clamp(34px, 7vw, var(--h2-size)); line-height: 1.05; letter-spacing: var(--tracking-tight); } +h2 { margin: 2px 0 0; font-size: clamp(20px, 3vw, var(--h7-size)); line-height: 1.25; } +.subtitle { margin: 0; color: var(--text-secondary); } +.context, .card, .review-card { + border: 1px solid var(--border-hairline); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--bg-surface) 95%, transparent); + box-shadow: var(--shadow-card); +} +.context { margin-bottom: 20px; padding: clamp(20px, 4vw, 32px); } +.context > :first-child { margin-top: 0; } +.context > :last-child { margin-bottom: 0; } +.context h1 { font-size: var(--h5-size); } +.context h2 { margin-top: 28px; font-size: var(--sub3-size); } +.context h3 { margin-top: 24px; } +.context p, .context li { color: var(--text-secondary); } +.context strong { color: var(--text-primary); } +.context a { color: var(--text-link); } +.context code { border-radius: var(--radius-xs); background: var(--bg-subtle); padding: 2px 6px; font-family: var(--font-mono); } +.context pre { overflow: auto; border-radius: var(--radius-md); background: var(--bg-sunken); padding: 16px; } +.context blockquote { margin-left: 0; border-left: 3px solid var(--ruby-green-ink); padding-left: 16px; color: var(--text-secondary); } +.eyebrow { margin: 0; color: var(--ruby-green-ink); font-size: var(--xs-size); font-weight: var(--fw-bold); letter-spacing: 0.09em; text-transform: uppercase; } +.questions { display: grid; gap: 20px; } +.question { padding: clamp(20px, 4vw, 30px); } +.question-header { display: grid; grid-template-columns: 36px 1fr; gap: 14px; align-items: start; margin-bottom: 22px; } +.question-number { display: grid; width: 34px; height: 34px; place-items: center; border-radius: var(--radius-full); background: var(--bg-selected); color: var(--blue-500); font-weight: var(--fw-bold); } +.options { display: grid; gap: 12px; } +.option { border: 1px solid var(--border-hairline); border-radius: var(--radius-md); background: var(--bg-sunken); padding: 14px; transition: border-color var(--dur-fast) var(--ease-standard), background var(--dur-fast) var(--ease-standard); } +.option:has(input:checked) { border-color: var(--blue-500); background: var(--bg-selected); box-shadow: 0 0 0 1px var(--blue-500); } +.option-choice { display: grid; grid-template-columns: 20px 1fr; gap: 12px; align-items: center; } +.option-choice input { position: absolute; opacity: 0; pointer-events: none; } +.radio-dot { width: 18px; height: 18px; border: 1px solid var(--border-default); border-radius: var(--radius-full); background: var(--bg-surface); box-shadow: inset 0 0 0 4px var(--bg-surface); } +.option-choice input:checked + .radio-dot { border-color: var(--blue-500); background: var(--blue-500); } +.option-choice input:focus-visible + .radio-dot { outline: 3px solid color-mix(in srgb, var(--blue-500) 35%, transparent); outline-offset: 2px; } +.option-choice strong { display: block; font-weight: var(--fw-semibold); } +.option-choice small { color: var(--text-tertiary); font-family: var(--font-mono); } +.annotation-label { display: block; margin: 14px 0 6px; color: var(--text-secondary); font-size: var(--sm-size); font-weight: var(--fw-medium); } +textarea { width: 100%; resize: vertical; border: 1px solid var(--border-default); border-radius: var(--radius-md); background: var(--bg-surface); color: var(--text-primary); padding: 11px 12px; } +textarea::placeholder { color: var(--text-placeholder); } +textarea:focus { outline: 2px solid var(--border-focus); outline-offset: 1px; border-color: transparent; } +.visual-block { margin: 0 0 22px; } +.visual-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 12px; } +.evidence { margin: 0; overflow: hidden; border: 1px solid var(--border-hairline); border-radius: var(--radius-md); background: var(--bg-sunken); } +.evidence img { display: block; width: 100%; max-height: 520px; object-fit: contain; background: #090B0F; } +.evidence figcaption { padding: 8px 11px; color: var(--text-secondary); font-family: var(--font-mono); font-size: var(--xs-size); } +.overall { margin-top: 20px; padding: clamp(20px, 4vw, 30px); } +.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: 24px; } +.button { min-height: 44px; border: 1px solid var(--border-default); border-radius: var(--radius-md); padding: 10px 18px; background: var(--bg-subtle); color: var(--text-primary); font-weight: var(--fw-semibold); } +.button:hover { background: var(--color-surface-active); } +.button.primary { border-color: var(--action-primary-ring); background: var(--action-primary); color: var(--white); box-shadow: var(--shadow-btn-primary); } +.button.primary:hover { background: var(--action-primary-hover); } +.button:disabled { cursor: default; opacity: 0.6; } +.form-error { margin: 12px 0 0; border-radius: var(--radius-md); background: color-mix(in srgb, var(--red-500) 14%, transparent); color: #FF9B94; padding: 10px 12px; } +.review-card { padding: clamp(20px, 4vw, 32px); } +.review-list { display: grid; gap: 14px; margin: 24px 0; } +.review-item { border-bottom: 1px solid var(--border-hairline); padding-bottom: 14px; } +.review-item:last-child { border-bottom: 0; } +.review-item h3 { margin: 0; font-size: var(--sub2-size); } +.review-item p { margin: 5px 0 0; color: var(--text-secondary); } +.review-item .selection { color: var(--text-primary); font-weight: var(--fw-semibold); } +.confirmation { border: 1px solid color-mix(in srgb, var(--green-500) 55%, var(--border-hairline)); border-radius: var(--radius-md); background: color-mix(in srgb, var(--ruby-green) 22%, var(--bg-surface)); padding: 14px; color: #8FE0BF; } +[hidden] { display: none !important; } +@media (max-width: 600px) { + main { width: min(100% - 20px, 980px); padding-top: 28px; } + .question, .context, .overall, .review-card { border-radius: var(--radius-md); } + .actions .button { flex: 1; } +}`; +} + +function boardScript(decision) { + const clientManifest = { + decision_id: decision.id, + request_sha256: decision.manifest.request_sha256, + questions: decision.manifest.questions.map((question) => ({ + key: question.key, + prompt: question.prompt, + options: question.options, + })), + }; + return `(() => { + 'use strict'; + const MANIFEST = ${scriptJson(clientManifest)}; + const SUBMIT_MARKER = ${scriptJson(SUBMIT_MARKER)}; + const form = document.querySelector('#decision-form'); + const review = document.querySelector('#review-step'); + const reviewList = document.querySelector('#review-list'); + const formError = document.querySelector('#form-error'); + const overallNote = document.querySelector('#overall-note'); + window.__lavishPayload = null; + + function buildPayload() { + return { + schema_version: ${ANSWER_SCHEMA_VERSION}, + decision_id: MANIFEST.decision_id, + request_sha256: MANIFEST.request_sha256, + answers: MANIFEST.questions.map((question) => { + const selected = form.querySelector( + 'input[data-question-key="' + question.key + '"]:checked', + ); + const questionNote = form.querySelector( + 'textarea[data-question-note][data-question-key="' + question.key + '"]', + ); + const optionComments = Object.create(null); + for (const input of form.querySelectorAll( + 'textarea[data-option-comment][data-question-key="' + question.key + '"]', + )) { + if (input.value !== '') optionComments[input.dataset.optionValue] = input.value; + } + return { + key: question.key, + value: selected?.value, + question_note: questionNote.value, + option_comments: optionComments, + }; + }), + note: overallNote.value, + }; + } + + function addReviewLine(parent, text, className) { + const line = document.createElement('p'); + line.textContent = text; + if (className) line.className = className; + parent.append(line); + } + + function renderReview(payload) { + reviewList.replaceChildren(); + for (const answer of payload.answers) { + const question = MANIFEST.questions.find((candidate) => candidate.key === answer.key); + const option = question.options.find((candidate) => candidate.value === answer.value); + const item = document.createElement('article'); + item.className = 'review-item'; + const heading = document.createElement('h3'); + heading.textContent = question.prompt; + item.append(heading); + addReviewLine(item, option.label + ' [' + option.value + ']', 'selection'); + if (answer.question_note) addReviewLine(item, 'Question note: ' + answer.question_note); + for (const [value, comment] of Object.entries(answer.option_comments)) { + addReviewLine(item, 'Comment on ' + value + ': ' + comment); + } + reviewList.append(item); + } + if (payload.note) { + const item = document.createElement('article'); + item.className = 'review-item'; + const heading = document.createElement('h3'); + heading.textContent = 'Overall note'; + item.append(heading); + addReviewLine(item, payload.note); + reviewList.append(item); + } + } + + document.querySelector('#review-button').addEventListener('click', () => { + const missing = MANIFEST.questions.find((question) => !form.querySelector( + 'input[data-question-key="' + question.key + '"]:checked', + )); + if (missing) { + formError.textContent = 'Choose one option for: ' + missing.prompt; + formError.hidden = false; + form.querySelector('[data-question-key="' + missing.key + '"]').scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + return; + } + formError.hidden = true; + renderReview(buildPayload()); + form.hidden = true; + review.hidden = false; + window.scrollTo({ top: 0, behavior: 'smooth' }); + }); + + document.querySelector('#back-button').addEventListener('click', () => { + review.hidden = true; + form.hidden = false; + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + }); + + document.querySelector('#submit-button').addEventListener('click', (event) => { + const payload = buildPayload(); + window.__lavishPayload = payload; + document.title = SUBMIT_MARKER; + event.currentTarget.disabled = true; + document.querySelector('#confirmation').hidden = false; + }); +})();`; +} + +export async function renderBoard(decision) { + const [tokens, visuals] = await Promise.all([ + readFile(fileURLToPath(new URL('./relvino-tokens.css', import.meta.url)), 'utf8'), + loadVisuals(decision), + ]); + const assignedVisuals = new Set( + decision.manifest.questions.flatMap((question) => question.visuals ?? []), + ); + const contextVisuals = [...visuals.keys()].filter((filename) => !assignedVisuals.has(filename)); + const questions = decision.manifest.questions + .map((question, index) => questionMarkup(question, index, visuals)) + .join('\n'); + const title = escapeHtml(decision.manifest.title); + return ` + + + + + + Lavish - ${title} + + + +
+
+
RRelvinoLavish decision
+

${title}

+

Review the full context, annotate any option or question, then submit one complete batch.

+
+
${markdown.render(decision.requestText)}
+ ${visualGallery(contextVisuals, visuals, 'Decision evidence')} +
+
${questions}
+
+ + +
+ +
+
+ +
+ + +\n`; +} diff --git a/tools/lavish/src/cli.mjs b/tools/lavish/src/cli.mjs index fb99e41f63..626a2ffd70 100755 --- a/tools/lavish/src/cli.mjs +++ b/tools/lavish/src/cli.mjs @@ -18,10 +18,12 @@ import { listDecisions, readAnswer, readDecision, + validateCollectPayload, } from './protocol.mjs'; +import { renderBoard } from './board.mjs'; import { migrateLegacy } from './migration.mjs'; -const VERSION = '1.0.0'; +const VERSION = '1.1.0'; const PROGRAM = basename(process.argv[1] ?? 'lavish-axi'); const SOURCE_WAKE_ADAPTER = fileURLToPath( new URL('../../../bin/fm-lavish-wake.sh', import.meta.url), @@ -34,17 +36,21 @@ Human commands: lavish inbox [--home ] lavish show [--home ] lavish answer [--home ] + lavish board [--home ] [--out ] Agent commands: lavish-axi create --id --title --request <request.md> - --questions <questions.json> --destination <relative-path> [--home <path>] + --questions <questions.json> --destination <relative-path> + [--visuals <dir>] [--home <path>] + lavish-axi collect <decision-id> --payload <json-file> [--home <path>] lavish-axi intake [--home <path>] lavish-axi configure-wake --command <absolute-executable> [--home <path>] lavish-axi migrate-legacy --state <state.json> --snapshot-dir <dir> [--pending-map <map.json>] [--home <path>] Every command reads files, performs bounded local work, and exits. -There is no server, browser, listener, poll, watcher, or resident process.`; +There is no server, listener, poller, watcher, or resident process. +The board command writes self-contained HTML but does not open a browser.`; } function parseArguments(argv) { @@ -317,6 +323,66 @@ async function answerCommand(id, options) { process.stdout.write(`Answer saved for ${id}; firstmate wake queued.\n`); } +async function boardCommand(id, options) { + rejectUnknownOptions(options, ['home', 'out']); + const decision = await readDecision(resolveHome(options), id); + try { + const existing = await readAnswer(decision); + throw new LavishError( + `decision ${id} was already submitted at ${existing.answer.submitted_at}`, + 3, + ); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + const outputPath = resolve(options.out ?? `${id}.html`); + await atomicWrite(outputPath, await renderBoard(decision), { mode: 0o600 }); + process.stdout.write(`Board written: ${outputPath}\n`); +} + +async function collectCommand(id, options) { + rejectUnknownOptions(options, ['home', 'payload']); + const home = resolveHome(options); + const decision = await readDecision(home, id); + const payloadPath = resolve(requireOption(options, 'payload')); + let payload; + try { + payload = JSON.parse(await readFile(payloadPath, 'utf8')); + } catch (error) { + throw new LavishError(`payload_invalid_json: ${error.message}`, 2); + } + const batch = validateCollectPayload(payload, decision.manifest); + try { + const existing = await readAnswer(decision); + if ( + JSON.stringify(existing.answer.answers) === JSON.stringify(batch.selections) + && existing.answer.note === batch.note + ) { + process.stdout.write( + `Decision ${id} already contains this collected payload; answer unchanged.\n`, + ); + return; + } + throw new LavishError(`decision ${id} is already answered with different content`, 3); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + const committed = await commitAnswer(decision, batch.selections, batch.note); + const wake = await enqueueWake(home, decision, committed.digest); + if (!wake.ok) { + process.stderr.write( + `answer saved; wake not queued${wake.detail ? `: ${wake.detail}` : ''}\n`, + ); + process.exitCode = 5; + return; + } + process.stdout.write(`Collected answer for ${id}; firstmate wake queued.\n`); +} + async function createCommand(options) { rejectUnknownOptions(options, [ 'home', @@ -325,6 +391,7 @@ async function createCommand(options) { 'request', 'questions', 'destination', + 'visuals', 'created-at', ]); const home = resolveHome(options); @@ -342,6 +409,7 @@ async function createCommand(options) { request: await readFile(requestPath), questions, destination: requireOption(options, 'destination'), + visualsDirectory: options.visuals === undefined ? undefined : resolve(options.visuals), createdAt: options['created-at'] ?? new Date().toISOString(), }); process.stdout.write( @@ -422,10 +490,18 @@ async function main(argv) { if (id === undefined) throw new LavishError('answer requires a decision id', 2); await answerCommand(id, options); break; + case 'board': + if (id === undefined) throw new LavishError('board requires a decision id', 2); + await boardCommand(id, options); + break; case 'create': if (id !== undefined) throw new LavishError('create uses --id, not a positional id', 2); await createCommand(options); break; + case 'collect': + if (id === undefined) throw new LavishError('collect requires a decision id', 2); + await collectCommand(id, options); + break; case 'intake': if (id !== undefined) throw new LavishError('intake takes no decision id', 2); await intakeCommand(options); diff --git a/tools/lavish/src/protocol.mjs b/tools/lavish/src/protocol.mjs index 89eb2d4983..a05697924f 100644 --- a/tools/lavish/src/protocol.mjs +++ b/tools/lavish/src/protocol.mjs @@ -18,10 +18,20 @@ import { unlink, } from 'node:fs/promises'; import { createHash, randomBytes } from 'node:crypto'; -import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { + basename, + dirname, + extname, + isAbsolute, + join, + relative, + resolve, + sep, +} from 'node:path'; import { decode, encode } from '@toon-format/toon'; export const SCHEMA_VERSION = 1; +export const ANSWER_SCHEMA_VERSION = 2; export const MANIFEST_KIND = 'lavish-decision-manifest'; export const ANSWER_KIND = 'lavish-decision-answer'; export const RECEIPT_KIND = 'lavish-decision-receipt'; @@ -29,6 +39,15 @@ export const DECISIONS_RELATIVE_DIR = 'data/decisions'; const ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; const SHA_PATTERN = /^sha256:[0-9a-f]{64}$/; +const VISUAL_MEDIA_TYPES = new Map([ + ['.gif', 'image/gif'], + ['.jpeg', 'image/jpeg'], + ['.jpg', 'image/jpeg'], + ['.png', 'image/png'], + ['.webp', 'image/webp'], +]); +const MAX_VISUAL_COUNT = 64; +const MAX_VISUAL_BYTES = 50 * 1024 * 1024; export class LavishError extends Error { constructor(message, exitCode = 1) { @@ -91,6 +110,21 @@ function requirePlainObject(value, label) { return value; } +function validateVisualFilename(value, label) { + const filename = requireString(value, label); + if (basename(filename) !== filename || filename === '.' || filename === '..') { + throw new LavishError(`${label} must be a plain filename`, 2); + } + const mediaType = VISUAL_MEDIA_TYPES.get(extname(filename).toLowerCase()); + if (mediaType === undefined) { + throw new LavishError( + `${label} must use png, jpg, jpeg, gif, or webp`, + 2, + ); + } + return { filename, mediaType }; +} + export function validateQuestions(questions) { if (!Array.isArray(questions) || questions.length === 0) { throw new LavishError('questions must be a nonempty array', 2); @@ -141,7 +175,25 @@ export function validateQuestions(questions) { values.add(value); return { value, label }; }); - return { key, prompt, options }; + let visuals = []; + if (question.visuals !== undefined) { + if (!Array.isArray(question.visuals)) { + throw new LavishError(`questions[${questionIndex}].visuals must be an array`, 2); + } + const visualNames = new Set(); + visuals = question.visuals.map((rawVisual, visualIndex) => { + const { filename } = validateVisualFilename( + rawVisual, + `questions[${questionIndex}].visuals[${visualIndex}]`, + ); + if (visualNames.has(filename)) { + throw new LavishError(`question ${key} repeats visual ${filename}`, 2); + } + visualNames.add(filename); + return filename; + }); + } + return { key, prompt, options, ...(visuals.length === 0 ? {} : { visuals }) }; }); } @@ -194,6 +246,57 @@ export function validateManifest(raw, expectedId = undefined) { 'manifest.request_sha256', ); const questions = validateQuestions(manifest.questions); + let visuals = []; + if (manifest.visuals !== undefined) { + if (!Array.isArray(manifest.visuals)) { + throw new LavishError('manifest.visuals must be an array', 2); + } + if (manifest.visuals.length > MAX_VISUAL_COUNT) { + throw new LavishError( + `manifest.visuals exceeds the ${MAX_VISUAL_COUNT} file limit`, + 2, + ); + } + const filenames = new Set(); + let totalVisualBytes = 0; + visuals = manifest.visuals.map((rawVisual, index) => { + const visual = requirePlainObject(rawVisual, `manifest.visuals[${index}]`); + const { filename, mediaType } = validateVisualFilename( + visual.file, + `manifest.visuals[${index}].file`, + ); + if (filenames.has(filename)) { + throw new LavishError(`manifest repeats visual ${filename}`, 2); + } + filenames.add(filename); + if (visual.media_type !== mediaType) { + throw new LavishError(`manifest visual media type does not match ${filename}`, 2); + } + const sha256 = requireDigest(visual.sha256, `manifest.visuals[${index}].sha256`); + if (!Number.isInteger(visual.size) || visual.size < 0) { + throw new LavishError(`manifest.visuals[${index}].size must be a nonnegative integer`, 2); + } + totalVisualBytes += visual.size; + if (totalVisualBytes > MAX_VISUAL_BYTES) { + throw new LavishError( + `manifest.visuals exceeds the ${MAX_VISUAL_BYTES} byte total limit`, + 2, + ); + } + return { file: filename, media_type: mediaType, sha256, size: visual.size }; + }); + } + const declaredVisuals = new Set(visuals.map((visual) => visual.file)); + for (const question of questions) { + for (const filename of question.visuals ?? []) { + if (!declaredVisuals.has(filename)) { + throw new LavishError( + `question ${question.key} references undeclared visual ${filename}`, + 2, + ); + } + } + } if (!Number.isInteger(manifest.expected_count) || manifest.expected_count < 1) { throw new LavishError('manifest.expected_count must be a positive integer', 2); } @@ -220,13 +323,17 @@ export function validateManifest(raw, expectedId = undefined) { expected_count: manifest.expected_count, request_sha256: requestSha256, questions, + ...(visuals.length === 0 ? {} : { visuals }), ...(legacySource === undefined ? {} : { legacy_source: legacySource }), }; } export function validateAnswer(raw, manifest) { const answer = requirePlainObject(raw, 'answer'); - if (answer.kind !== ANSWER_KIND || answer.schema_version !== SCHEMA_VERSION) { + if ( + answer.kind !== ANSWER_KIND + || ![SCHEMA_VERSION, ANSWER_SCHEMA_VERSION].includes(answer.schema_version) + ) { throw new LavishError('unsupported answer kind or schema version', 2); } if (answer.decision_id !== manifest.decision_id) { @@ -269,7 +376,49 @@ export function validateAnswer(raw, manifest) { if (selection.label !== option.label) { throw new LavishError(`answer label does not match option ${value} for ${key}`, 2); } - return { key, value, label: option.label }; + let questionNote = ''; + let optionComments = {}; + if (answer.schema_version === ANSWER_SCHEMA_VERSION) { + questionNote = selection.question_note === undefined + ? '' + : requireString( + selection.question_note, + `answer.answers[${index}].question_note`, + { allowEmpty: true }, + ); + if (selection.option_comments !== undefined) { + const rawComments = requirePlainObject( + selection.option_comments, + `answer.answers[${index}].option_comments`, + ); + const comments = new Map(); + for (const [optionValue, comment] of Object.entries(rawComments)) { + if (!expectedQuestion.options.some((candidate) => candidate.value === optionValue)) { + throw new LavishError( + `answer comment value ${optionValue} is not an option for ${key}`, + 2, + ); + } + comments.set(optionValue, requireString( + comment, + `answer.answers[${index}].option_comments.${optionValue}`, + { allowEmpty: true }, + )); + } + optionComments = Object.fromEntries( + expectedQuestion.options + .filter((candidate) => comments.has(candidate.value)) + .map((candidate) => [candidate.value, comments.get(candidate.value)]), + ); + } + } + return { + key, + value, + label: option.label, + question_note: questionNote, + option_comments: optionComments, + }; }); const note = answer.note === undefined @@ -277,7 +426,7 @@ export function validateAnswer(raw, manifest) { : requireString(answer.note, 'answer.note', { allowEmpty: true }); return { kind: ANSWER_KIND, - schema_version: SCHEMA_VERSION, + schema_version: answer.schema_version, decision_id: manifest.decision_id, request_sha256: manifest.request_sha256, submitted_at: answer.submitted_at, @@ -286,6 +435,127 @@ export function validateAnswer(raw, manifest) { }; } +function payloadError(name, message) { + throw new LavishError(`${name}: ${message}`, 2); +} + +export function validateCollectPayload(raw, manifest) { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + payloadError('payload_invalid', 'payload must be an object'); + } + if (raw.schema_version !== ANSWER_SCHEMA_VERSION) { + payloadError( + 'payload_schema_mismatch', + `expected schema version ${ANSWER_SCHEMA_VERSION}`, + ); + } + if (raw.decision_id !== manifest.decision_id) { + payloadError('payload_decision_mismatch', 'decision id does not match manifest'); + } + if (raw.request_sha256 !== manifest.request_sha256) { + payloadError('payload_stale_request', 'request hash does not match manifest'); + } + if (!Array.isArray(raw.answers)) { + payloadError('payload_count_mismatch', 'answers must be an array'); + } + if (raw.answers.length !== manifest.expected_count) { + payloadError( + 'payload_count_mismatch', + `received ${raw.answers.length}; expected ${manifest.expected_count}`, + ); + } + + const questionsByKey = new Map( + manifest.questions.map((question) => [question.key, question]), + ); + const answersByKey = new Map(); + for (const [index, rawSelection] of raw.answers.entries()) { + if ( + rawSelection === null + || typeof rawSelection !== 'object' + || Array.isArray(rawSelection) + ) { + payloadError('payload_invalid_answer', `answer ${index} must be an object`); + } + if (typeof rawSelection.key !== 'string' || !questionsByKey.has(rawSelection.key)) { + payloadError('payload_unknown_key', `unknown question key ${String(rawSelection.key)}`); + } + if (answersByKey.has(rawSelection.key)) { + payloadError('payload_duplicate_key', `duplicate question key ${rawSelection.key}`); + } + const question = questionsByKey.get(rawSelection.key); + const selected = question.options.find( + (option) => option.value === rawSelection.value, + ); + if (selected === undefined) { + payloadError( + 'payload_unknown_option', + `unknown option ${String(rawSelection.value)} for ${question.key}`, + ); + } + const questionNote = rawSelection.question_note ?? ''; + if (typeof questionNote !== 'string') { + payloadError( + 'payload_invalid_annotation', + `question note for ${question.key} must be a string`, + ); + } + const rawComments = rawSelection.option_comments ?? {}; + if ( + rawComments === null + || typeof rawComments !== 'object' + || Array.isArray(rawComments) + ) { + payloadError( + 'payload_invalid_annotation', + `option comments for ${question.key} must be an object`, + ); + } + const comments = new Map(); + for (const [optionValue, comment] of Object.entries(rawComments)) { + if (!question.options.some((option) => option.value === optionValue)) { + payloadError( + 'payload_unknown_option', + `unknown annotated option ${optionValue} for ${question.key}`, + ); + } + if (typeof comment !== 'string') { + payloadError( + 'payload_invalid_annotation', + `comment for ${question.key}.${optionValue} must be a string`, + ); + } + comments.set(optionValue, comment); + } + const optionComments = Object.fromEntries( + question.options + .filter((option) => comments.has(option.value)) + .map((option) => [option.value, comments.get(option.value)]), + ); + answersByKey.set(question.key, { + key: question.key, + value: selected.value, + label: selected.label, + question_note: questionNote, + option_comments: optionComments, + }); + } + + for (const question of manifest.questions) { + if (!answersByKey.has(question.key)) { + payloadError('payload_unknown_key', `missing question key ${question.key}`); + } + } + const note = raw.note ?? ''; + if (typeof note !== 'string') { + payloadError('payload_invalid_annotation', 'overall note must be a string'); + } + return { + selections: manifest.questions.map((question) => answersByKey.get(question.key)), + note, + }; +} + export function validateReceipt(raw, manifest, answerSha256) { const receipt = requirePlainObject(raw, 'receipt'); if (receipt.kind !== RECEIPT_KIND || receipt.schema_version !== SCHEMA_VERSION) { @@ -441,6 +711,74 @@ export async function atomicWrite(path, content, { } } +async function readDecisionVisuals(home, visualsDirectory, declaredVisuals) { + const directoryState = await lstatIfPresent(visualsDirectory); + if (directoryState === undefined) { + if (declaredVisuals.length > 0) { + throw new LavishError('manifest declares visuals but the visuals directory is missing', 2); + } + return []; + } + await ensureSafeDirectoryTree(home, visualsDirectory); + const entries = (await readdir(visualsDirectory, { withFileTypes: true })) + .filter((entry) => !entry.name.startsWith('.')) + .sort((left, right) => left.name.localeCompare(right.name)); + if (entries.length > MAX_VISUAL_COUNT) { + throw new LavishError( + `decision visuals exceed the ${MAX_VISUAL_COUNT} file limit`, + 2, + ); + } + const declaredByName = new Map( + declaredVisuals.map((visual) => [visual.file, visual]), + ); + const seen = new Set(); + const visuals = []; + let totalBytes = 0; + for (const entry of entries) { + if (!entry.isFile() || entry.isSymbolicLink()) { + throw new LavishError(`visual must be a regular file: ${entry.name}`, 2); + } + const { filename, mediaType } = validateVisualFilename(entry.name, 'visual filename'); + const visualPath = join(visualsDirectory, filename); + await assertNoSymlink(visualPath, visualPath); + const visualState = await lstat(visualPath); + totalBytes += visualState.size; + if (totalBytes > MAX_VISUAL_BYTES) { + throw new LavishError( + `decision visuals exceed the ${MAX_VISUAL_BYTES} byte total limit`, + 2, + ); + } + const sha256 = await sha256File(visualPath); + const declared = declaredByName.get(filename); + if (declared !== undefined) { + if (declared.media_type !== mediaType) { + throw new LavishError(`visual media type mismatch for ${filename}`, 2); + } + if (declared.size !== visualState.size) { + throw new LavishError(`visual size mismatch for ${filename}`, 2); + } + if (declared.sha256 !== sha256) { + throw new LavishError(`visual digest mismatch for ${filename}`, 2); + } + seen.add(filename); + } + visuals.push({ + file: filename, + media_type: mediaType, + sha256, + size: visualState.size, + }); + } + for (const declared of declaredVisuals) { + if (!seen.has(declared.file)) { + throw new LavishError(`declared visual is missing: ${declared.file}`, 2); + } + } + return visuals; +} + export async function readDecision(home, id) { validateDecisionId(id); const decisionDirectory = join(resolve(home), DECISIONS_RELATIVE_DIR, id); @@ -458,6 +796,12 @@ export async function readDecision(home, id) { if (requestDigest !== manifest.request_sha256) { throw new LavishError(`request digest mismatch for ${id}`, 2); } + const visualsDirectory = join(decisionDirectory, 'visuals'); + const visuals = await readDecisionVisuals( + home, + visualsDirectory, + manifest.visuals ?? [], + ); return { id, directory: decisionDirectory, @@ -465,6 +809,8 @@ export async function readDecision(home, id) { requestText: request.toString('utf8'), manifest, manifestText, + visuals, + visualsDirectory, answerPath: join(decisionDirectory, 'answer.toon'), receiptPath: join(decisionDirectory, 'receipt.toon'), }; @@ -496,12 +842,58 @@ export async function listDecisions(home) { return decisions; } +async function inspectVisualsDirectory(visualsDirectory) { + if (visualsDirectory === undefined) { + return []; + } + const sourceDirectory = resolve(visualsDirectory); + const directoryState = await lstat(sourceDirectory); + if (!directoryState.isDirectory() || directoryState.isSymbolicLink()) { + throw new LavishError('--visuals must name a real directory, not a symlink', 2); + } + const entries = (await readdir(sourceDirectory, { withFileTypes: true })) + .filter((entry) => !entry.name.startsWith('.')) + .sort((left, right) => left.name.localeCompare(right.name)); + if (entries.length > MAX_VISUAL_COUNT) { + throw new LavishError( + `--visuals contains ${entries.length} entries; maximum is ${MAX_VISUAL_COUNT}`, + 2, + ); + } + const visuals = []; + let totalBytes = 0; + for (const entry of entries) { + if (!entry.isFile() || entry.isSymbolicLink()) { + throw new LavishError(`visual must be a regular file: ${entry.name}`, 2); + } + const { filename, mediaType } = validateVisualFilename(entry.name, 'visual filename'); + const source = join(sourceDirectory, filename); + const sourceState = await lstat(source); + totalBytes += sourceState.size; + if (totalBytes > MAX_VISUAL_BYTES) { + throw new LavishError( + `--visuals exceeds the ${MAX_VISUAL_BYTES} byte total limit`, + 2, + ); + } + visuals.push({ + source, + file: filename, + media_type: mediaType, + sha256: await sha256File(source), + size: sourceState.size, + }); + } + return visuals; +} + export async function createDecision(home, { id, title, request, destination, questions, + visualsDirectory = undefined, createdAt = new Date().toISOString(), legacySource = undefined, }) { @@ -514,6 +906,8 @@ export async function createDecision(home, { if (requestBytes.length === 0) { throw new LavishError('request.md must not be empty', 2); } + const sourceVisuals = await inspectVisualsDirectory(visualsDirectory); + const visuals = sourceVisuals.map(({ source: _source, ...visual }) => visual); const requestDigest = sha256Bytes(requestBytes); const manifest = validateManifest({ kind: MANIFEST_KIND, @@ -525,6 +919,7 @@ export async function createDecision(home, { expected_count: normalizedQuestions.length, request_sha256: requestDigest, questions: normalizedQuestions, + ...(visuals.length === 0 ? {} : { visuals }), ...(legacySource === undefined ? {} : { legacy_source: legacySource }), }, id); @@ -561,6 +956,19 @@ export async function createDecision(home, { await atomicWrite(join(staging, 'manifest.toon'), encodeToon(manifest), { failIfExists: true, }); + if (sourceVisuals.length > 0) { + const stagingVisuals = join(staging, 'visuals'); + await mkdir(stagingVisuals, { mode: 0o700 }); + for (const visual of sourceVisuals) { + const destinationPath = join(stagingVisuals, visual.file); + await copyFile(visual.source, destinationPath, fsConstants.COPYFILE_EXCL); + await chmod(destinationPath, 0o600); + if (await sha256File(destinationPath) !== visual.sha256) { + throw new LavishError(`visual changed while being copied: ${visual.file}`, 4); + } + } + await syncDirectory(stagingVisuals); + } await syncDirectory(staging); await rename(staging, finalDirectory); committed = true; @@ -658,7 +1066,7 @@ export async function commitAnswer(decision, selections, note, { } = {}) { const answer = validateAnswer({ kind: ANSWER_KIND, - schema_version: SCHEMA_VERSION, + schema_version: ANSWER_SCHEMA_VERSION, decision_id: decision.id, request_sha256: decision.manifest.request_sha256, submitted_at: submittedAt, diff --git a/tools/lavish/src/relvino-tokens.css b/tools/lavish/src/relvino-tokens.css new file mode 100644 index 0000000000..86b911785a --- /dev/null +++ b/tools/lavish/src/relvino-tokens.css @@ -0,0 +1,211 @@ +/* + * Vendored from Ruby-Labs/relvino apps/web-app/src/index.css at + * e2f59088bc0db68da277c2aa9079373f9c8cd7f5. + * Keep the foundation and semantic names aligned with that source block. + */ +:root { + --ruby-green: #085E3E; + --ruby-green-ink: #099774; + --ruby-green-deep: #004691; + + --black: #000000; + --grey-900: #1A1A1A; + --grey-700: #545454; + --grey-500: #929292; + --grey-450: #A2A3A5; + --grey-300: #D4D4D4; + --grey-280: #D7D7D7; + --grey-200: #ECECEC; + --grey-150: #F2F2F2; + --grey-100: #F5F5F5; + --grey-75: #F7F7F7; + --white: #FFFFFF; + + --border-soft: #DFE3EB; + --border-lilac: #ECE9F1; + + --blue-50: #E5F2FF; + --blue-500: #007AFF; + --blue-700: #004691; + --blue-steel: #2B83B8; + + --green-500: #0AA71A; + --green-700: #085E3E; + --orange-500: #FF9500; + --red-500: #FF3B30; + --red-700: #8D1B15; + --pink-500: #FF016B; + + --viz-1: #007AFF; + --viz-2: #0AA71A; + --viz-3: #8D1B15; + --viz-4: #9747FF; + --viz-bar-track: #ECECEC; + + --bg-canvas: var(--grey-100); + --bg-surface: var(--white); + --bg-sunken: var(--grey-75); + --bg-subtle: var(--grey-150); + --bg-selected: var(--blue-50); + + --text-primary: var(--black); + --text-secondary: var(--grey-500); + --text-tertiary: var(--grey-450); + --text-placeholder: var(--grey-450); + --text-inverse: var(--white); + --text-link: var(--blue-500); + --text-positive: var(--green-500); + --text-negative: var(--red-500); + + --icon-default: var(--grey-700); + --icon-strong: var(--black); + --icon-muted: var(--grey-500); + --icon-accent: var(--blue-500); + + --border-default: var(--grey-300); + --border-hairline: var(--grey-280); + --border-strong: var(--black); + --border-focus: var(--blue-500); + + --action-primary: var(--blue-500); + --action-primary-ring: var(--blue-700); + --action-primary-hover: #0A6FE0; + --action-tertiary: var(--grey-200); + + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace; + + --fw-regular: 400; + --fw-medium: 500; + --fw-semibold: 600; + --fw-bold: 700; + + --h1-size: 64px; + --h1-lh: 100%; + --h1-weight: 700; + --h2-size: 40px; + --h2-lh: 100%; + --h2-weight: 700; + --h3-size: 32px; + --h3-lh: 110%; + --h3-weight: 700; + --h5-size: 28px; + --h5-lh: 120%; + --h5-weight: 700; + --h7-size: 24px; + --h7-lh: 120%; + --h7-weight: 600; + --sub3-size: 20px; + --sub3-lh: 130%; + --sub3-weight: 700; + --sub2-size: 18px; + --sub2-lh: 140%; + --sub2-weight: 600; + --body-lg-size: 16px; + --body-lg-lh: 24px; + --body-weight-strong: 600; + --body-weight-medium: 500; + --body-weight: 400; + --sm-size: 14px; + --sm-lh: 100%; + --sm-lh-relaxed: 22px; + --xs-size: 12px; + --xs-lh: 100%; + --mono-size: 13px; + --mono-lh: 150%; + --tracking-tight: -0.01em; + --tracking-normal: 0; + + --space-0: 0; + --space-1: 2px; + --space-2: 4px; + --space-3: 8px; + --space-4: 12px; + --space-5: 16px; + --space-6: 20px; + --space-7: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --space-16: 64px; + + --radius-xs: 5px; + --radius-sm: 8px; + --radius-md: 10px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-pill: 38px; + --radius-full: 999px; + + --border-width: 1px; + --hairline: 1px solid var(--border-hairline); + --divider: 1px solid var(--border-default); + --ring-1: inset 0 0 0 1px var(--border-default); + --ring-soft: inset 0 0 0 1px var(--border-soft); + --shadow-card: + 0px 6px 2px 0px rgba(184, 184, 184, 0), + 0px 4px 1px 0px rgba(184, 184, 184, 0.01), + 0px 2px 1px 0px rgba(184, 184, 184, 0.05), + 0px 1px 1px 0px rgba(184, 184, 184, 0.09), + 0px 0px 1px 0px rgba(184, 184, 184, 0.10); + --shadow-pop: + 0px 12px 24px -8px rgba(125, 125, 125, 0.18), + 0px 4px 8px -4px rgba(125, 125, 125, 0.12), + inset 0 0 0 1px var(--border-soft); + --shadow-btn-primary: + inset 0 0 0 1px var(--blue-700), + 0px 2px 1px 0px rgba(125, 125, 125, 0.05), + 0px 1px 1px 0px rgba(125, 125, 125, 0.09); + + --ease-standard: cubic-bezier(0.2, 0, 0, 1); + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --dur-fast: 120ms; + --dur-base: 180ms; + --dur-slow: 280ms; + + --color-accent: var(--action-primary); + --color-accent-hover: var(--action-primary-hover); + --color-accent-deep: var(--action-primary-ring); + --color-danger: var(--red-500); + --color-success: var(--green-500); + --color-warning: var(--orange-500); + --color-muted: var(--grey-500); + --color-muted-strong: var(--grey-700); + --color-line: var(--grey-280); + --color-line-strong: var(--grey-300); + --color-surface: var(--grey-100); + --color-surface-active: var(--grey-200); + --color-surface-2: var(--grey-150); + --color-ink: var(--black); +} + +:root[data-theme='dark'] { + color-scheme: dark; + --bg-canvas: #0D1015; + --bg-surface: #151A21; + --bg-sunken: #10141A; + --bg-subtle: #20262F; + --bg-selected: #112B48; + --text-primary: #F7F7F7; + --text-secondary: #B3BAC5; + --text-tertiary: #8B94A3; + --text-placeholder: #6F7887; + --text-inverse: var(--white); + --icon-default: #B3BAC5; + --icon-strong: #F7F7F7; + --icon-muted: #8B94A3; + --border-default: #343C48; + --border-hairline: #2A313B; + --border-strong: #667080; + --color-muted: var(--text-secondary); + --color-muted-strong: var(--text-primary); + --color-line: var(--border-hairline); + --color-line-strong: var(--border-default); + --color-surface: var(--bg-canvas); + --color-surface-active: var(--bg-subtle); + --color-surface-2: var(--bg-surface); + --color-ink: var(--text-primary); + --ring-1: inset 0 0 0 1px var(--border-default); + --ring-soft: inset 0 0 0 1px var(--border-hairline); +} diff --git a/tools/lavish/test/lavish.test.mjs b/tools/lavish/test/lavish.test.mjs index 809a6394e0..2b9b467a4b 100644 --- a/tools/lavish/test/lavish.test.mjs +++ b/tools/lavish/test/lavish.test.mjs @@ -18,13 +18,18 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import test from 'node:test'; -import { decode } from '@toon-format/toon'; +import { decode, encode } from '@toon-format/toon'; +import { parseHTML } from 'linkedom'; const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const REPO_ROOT = resolve(PACKAGE_ROOT, '../..'); const CLI = join(PACKAGE_ROOT, 'src/cli.mjs'); const WAKE_ADAPTER = join(REPO_ROOT, 'bin/fm-lavish-wake.sh'); const WAKE_DRAIN = join(REPO_ROOT, 'bin/fm-wake-drain.sh'); +const ONE_PIXEL_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +); async function exists(path) { try { @@ -186,6 +191,7 @@ async function createRequest(fx, { destination = 'data/replies/release-choice.toon', createdAt = undefined, returnResult = false, + visuals = undefined, } = {}) { const args = [ 'create', @@ -201,11 +207,35 @@ async function createRequest(fx, { destination, ]; if (createdAt !== undefined) args.push('--created-at', createdAt); + if (visuals !== undefined) args.push('--visuals', visuals); const result = await runCli(args, { home: fx.home }); assert.equal(result.code, 0, result.stderr); return returnResult ? { id, result } : id; } +async function manifestFor(fx, id) { + return decode( + await readFile(join(fx.home, 'data/decisions', id, 'manifest.toon'), 'utf8'), + { strict: true }, + ); +} + +function browserPayload(manifest, overrides = {}) { + return { + schema_version: 2, + decision_id: manifest.decision_id, + request_sha256: manifest.request_sha256, + answers: manifest.questions.map((question) => ({ + key: question.key, + value: question.options[0].value, + question_note: '', + option_comments: {}, + })), + note: '', + ...overrides, + }; +} + async function answer(fx, id, { choice = 1, note = '', @@ -412,6 +442,186 @@ test('configured wake adapter works from an installed-style CLI location', async assert.match(await readFile(adapterLog, 'utf8'), /--decision release-choice/); }); +test('board renders the manifest, annotations, Markdown context, visuals, and submit contract', async () => { + const fx = await fixture('board'); + const visuals = join(fx.root, 'visuals'); + await mkdir(visuals); + await writeFile( + join(visuals, 'rollout.png'), + ONE_PIXEL_PNG, + ); + await writeFile( + fx.questions, + `${JSON.stringify([ + { + key: 'rollout', + prompt: 'Which rollout should we use?', + visuals: ['rollout.png'], + options: [ + { value: 'blue', label: 'Blue rollout' }, + { value: 'green', label: 'Green rollout' }, + ], + }, + { + key: 'timing', + prompt: 'When should it begin?', + options: [ + { value: 'now', label: 'Begin now' }, + { value: 'later', label: 'Wait until later' }, + ], + }, + ])}\n`, + ); + const id = await createRequest(fx, { visuals }); + const output = join(fx.root, 'board.html'); + const result = await runCli(['board', id, '--out', output], { home: fx.home }); + assert.equal(result.code, 0, result.stderr); + + const html = await readFile(output, 'utf8'); + const { document } = parseHTML(html); + assert.equal(document.documentElement.dataset.theme, 'dark'); + assert.equal(document.querySelector('[data-request-context] h1').textContent, 'Release choice'); + const questionNodes = [...document.querySelectorAll('[data-question-key].question')]; + assert.equal(questionNodes.length, 2); + for (const [index, question] of (await manifestFor(fx, id)).questions.entries()) { + const node = questionNodes[index]; + assert.equal(node.dataset.questionKey, question.key); + assert.equal(node.querySelector('h2').textContent, question.prompt); + assert.deepEqual( + [...node.querySelectorAll('input[type="radio"]')].map((input) => input.value), + question.options.map((option) => option.value), + ); + assert.equal(node.querySelectorAll('textarea[data-option-comment]').length, question.options.length); + assert.ok(node.querySelector('textarea[data-question-note]')); + } + const evidence = document.querySelector('figure[data-visual-file="rollout.png"] img'); + assert.ok(evidence); + assert.match(evidence.getAttribute('src'), /^data:image\/png;base64,/); + assert.equal(document.querySelectorAll('link[rel="stylesheet"]').length, 0); + const script = document.querySelector('script').textContent; + assert.match(script, /window\.__lavishPayload = payload/); + assert.match(script, /document\.title = SUBMIT_MARKER/); + assert.match(script, /LAVISH-SUBMIT v2/); +}); + +test('board renders a conventional visuals directory on an existing manifest', async () => { + const fx = await fixture('board-existing-visual'); + const id = await createRequest(fx); + const visualDirectory = join(fx.home, 'data/decisions', id, 'visuals'); + await mkdir(visualDirectory); + await writeFile(join(visualDirectory, 'existing.png'), ONE_PIXEL_PNG); + const output = join(fx.root, 'board.html'); + const result = await runCli(['board', id, '--out', output], { home: fx.home }); + assert.equal(result.code, 0, result.stderr); + + const { document } = parseHTML(await readFile(output, 'utf8')); + const evidence = document.querySelector('figure[data-visual-file="existing.png"] img'); + assert.ok(evidence); + assert.match(evidence.getAttribute('src'), /^data:image\/png;base64,/); +}); + +test('collect validates and persists structured annotations through the normal wake path', async () => { + const fx = await fixture('collect'); + const id = await createRequest(fx); + const manifest = await manifestFor(fx, id); + const payloadPath = join(fx.root, 'payload.json'); + await writeFile( + payloadPath, + `${JSON.stringify(browserPayload(manifest, { + answers: [{ + key: 'rollout', + value: 'green', + question_note: 'Prefer the faster path after one health check.', + option_comments: { + blue: 'Safe, but too slow for this release.', + green: 'Use this with the rollback guard.', + }, + }], + note: 'Proceed after the current deploy completes.', + }))}\n`, + ); + const result = await runCli(['collect', id, '--payload', payloadPath], { home: fx.home }); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /Collected answer.*wake queued/); + + const answerPath = join(fx.home, 'data/decisions', id, 'answer.toon'); + const stored = decode(await readFile(answerPath, 'utf8'), { strict: true }); + assert.equal(stored.schema_version, 2); + assert.equal(stored.answers[0].value, 'green'); + assert.equal(stored.answers[0].label, 'Green rollout'); + assert.equal( + stored.answers[0].question_note, + 'Prefer the faster path after one health check.', + ); + assert.deepEqual(stored.answers[0].option_comments, { + blue: 'Safe, but too slow for this release.', + green: 'Use this with the rollback guard.', + }); + assert.equal(stored.note, 'Proceed after the current deploy completes.'); + assert.match(await readFile(join(fx.home, 'state/.wake-queue'), 'utf8'), /lavish:release-choice/); +}); + +test('collect fails closed with named errors for count, key, option, and request drift', async () => { + const fx = await fixture('collect-invalid'); + const id = await createRequest(fx); + const manifest = await manifestFor(fx, id); + const payloadPath = join(fx.root, 'payload.json'); + const cases = [ + { + name: 'payload_count_mismatch', + payload: browserPayload(manifest, { answers: [] }), + }, + { + name: 'payload_unknown_key', + payload: browserPayload(manifest, { + answers: [{ key: 'missing', value: 'blue', question_note: '', option_comments: {} }], + }), + }, + { + name: 'payload_unknown_option', + payload: browserPayload(manifest, { + answers: [{ key: 'rollout', value: 'purple', question_note: '', option_comments: {} }], + }), + }, + { + name: 'payload_stale_request', + payload: browserPayload(manifest, { request_sha256: `sha256:${'0'.repeat(64)}` }), + }, + ]; + for (const failure of cases) { + await writeFile(payloadPath, `${JSON.stringify(failure.payload)}\n`); + const result = await runCli(['collect', id, '--payload', payloadPath], { home: fx.home }); + assert.equal(result.code, 2, `${failure.name}: ${result.stderr}`); + assert.match(result.stderr, new RegExp(failure.name)); + assert.equal( + await exists(join(fx.home, 'data/decisions', id, 'answer.toon')), + false, + ); + } +}); + +test('schema version 1 answers remain readable', async () => { + const fx = await fixture('answer-v1'); + const id = await createRequest(fx); + const manifest = await manifestFor(fx, id); + const answer = { + kind: 'lavish-decision-answer', + schema_version: 1, + decision_id: id, + request_sha256: manifest.request_sha256, + submitted_at: '2026-08-01T00:00:00.000Z', + answers: [{ key: 'rollout', value: 'blue', label: 'Blue rollout' }], + note: 'Existing schema one answer.', + }; + await writeFile( + join(fx.home, 'data/decisions', id, 'answer.toon'), + `${encode(answer)}\n`, + ); + const shown = await runCli(['show', id], { home: fx.home }); + assert.equal(shown.code, 0, shown.stderr); + assert.match(shown.stdout, /Status: answered at 2026-08-01T00:00:00.000Z/); +}); + test('strict TOON count validation rejects a malformed manifest before showing it', async () => { const fx = await fixture('toon-count'); const id = await createRequest(fx); From 6aa0f186f1162e218237ee01baeef638c14aacb4 Mon Sep 17 00:00:00 2001 From: Dongkeun Lee <dongkeun@rubydata.ai> Date: Sat, 1 Aug 2026 17:28:35 -0400 Subject: [PATCH 2/2] fix: preserve lavish browser submissions --- bin/fm-lavish-board.sh | 98 +++++++++++++++++++++++++++---- tests/fm-lavish-board.test.sh | 40 ++++++++++++- tools/lavish/README.md | 10 +++- tools/lavish/src/board.mjs | 30 +++++++++- tools/lavish/test/lavish.test.mjs | 8 +++ 5 files changed, 171 insertions(+), 15 deletions(-) diff --git a/bin/fm-lavish-board.sh b/bin/fm-lavish-board.sh index ee72c4354d..9f5791b880 100755 --- a/bin/fm-lavish-board.sh +++ b/bin/fm-lavish-board.sh @@ -1,24 +1,28 @@ #!/usr/bin/env bash # Render one durable Lavish decision, open it in an isolated headed Chrome -# session, and arm a one-shot watcher check for the browser submit marker. +# session, and arm a one-shot watcher check for its durable download with a +# browser submit-marker fallback. # # Usage: -# fm-lavish-board.sh <decision-id> [--home <path>] +# fm-lavish-board.sh <decision-id> [--home <path>] [--downloads <path>] # # Internal watcher entry point: # fm-lavish-board.sh --check <decision-id> --home <path> --session <name> +# --downloads <path> --opened-at <milliseconds> set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" SUBMIT_MARKER='LAVISH-SUBMIT v2' +LAVISH_BIN=${FM_LAVISH_BIN:-$REPO_ROOT/tools/lavish/src/cli.mjs} usage() { cat <<'USAGE' -Usage: fm-lavish-board.sh <decision-id> [--home <path>] +Usage: fm-lavish-board.sh <decision-id> [--home <path>] [--downloads <path>] Render a self-contained Lavish board, open it in a headed isolated Chrome -session, and arm a watcher check that captures LAVISH-SUBMIT v2. +session, and arm a watcher check that captures the downloaded payload first, +with LAVISH-SUBMIT v2 live-page capture as a fallback. USAGE } @@ -40,6 +44,13 @@ resolve_home() { (cd "$requested" && pwd -P) } +resolve_downloads() { + local requested=$1 + [ -d "$requested" ] && [ ! -L "$requested" ] \ + || fail "unsafe or missing downloads directory: $requested" + (cd "$requested" && pwd -P) +} + isolated_chrome() { local session=$1 shift @@ -60,12 +71,60 @@ check_submission() { local decision_id=$1 local home=$2 local session=$3 + local downloads=$4 + local opened_at=$5 local state_dir="$home/state" local payload_path="$state_dir/lavish-board-$decision_id.payload.json" local check_path="$state_dir/lavish-board-$decision_id.check.sh" - local evaluation result_line temporary + local downloaded_path evaluation result_line temporary [ -d "$state_dir" ] && [ ! -L "$state_dir" ] || exit 0 + + downloaded_path=$(node -e ' + const fs = require("node:fs"); + const path = require("node:path"); + const directory = process.argv[1]; + const decisionId = process.argv[2]; + const openedAt = Number(process.argv[3]); + const pattern = new RegExp( + "^lavish-answer-" + decisionId + "(?: \\(\\d+\\))?\\.json$", + ); + const candidates = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (!entry.isFile() || !pattern.test(entry.name)) continue; + const candidate = path.join(directory, entry.name); + const info = fs.statSync(candidate); + if (info.mtimeMs < openedAt - 1000) continue; + try { + const payload = JSON.parse(fs.readFileSync(candidate, "utf8")); + if (payload?.decision_id !== decisionId) continue; + } catch { + continue; + } + candidates.push({ candidate, mtimeMs: info.mtimeMs }); + } + candidates.sort((left, right) => right.mtimeMs - left.mtimeMs); + if (candidates[0]) process.stdout.write(candidates[0].candidate); + ' "$downloads" "$decision_id" "$opened_at" 2>/dev/null) || downloaded_path= + + if [ -n "$downloaded_path" ]; then + temporary=$(mktemp "$state_dir/.lavish-board-$decision_id.payload.XXXXXX") + if cp "$downloaded_path" "$temporary" \ + && chmod 600 "$temporary" \ + && node -e ' + const fs = require("node:fs"); + const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (payload?.decision_id !== process.argv[2]) process.exit(3); + ' "$temporary" "$decision_id"; then + mv "$temporary" "$payload_path" + rm -f "$check_path" + isolated_chrome "$session" stop >/dev/null 2>&1 || true + printf 'lavish-submit: %s %s\n' "$decision_id" "$payload_path" + return + fi + rm -f "$temporary" + fi + evaluation=$(isolated_chrome "$session" eval \ 'JSON.stringify({title: document.title, payload: window.__lavishPayload ?? null})' \ 2>/dev/null) || exit 0 @@ -113,6 +172,8 @@ validate_id "$DECISION_ID" HOME_ARG=${FM_HOME:-$REPO_ROOT} SESSION= +DOWNLOADS_ARG=${LAVISH_DOWNLOADS_DIR:-${HOME:+$HOME/Downloads}} +OPENED_AT= while [ "$#" -gt 0 ]; do case "$1" in --home) @@ -120,12 +181,24 @@ while [ "$#" -gt 0 ]; do HOME_ARG=$2 shift 2 ;; + --downloads) + [ "$#" -gt 1 ] || fail '--downloads requires a path' + DOWNLOADS_ARG=$2 + shift 2 + ;; --session) [ "$MODE" = check ] || fail '--session is internal to the watcher check' [ "$#" -gt 1 ] || fail '--session requires a name' SESSION=$2 shift 2 ;; + --opened-at) + [ "$MODE" = check ] || fail '--opened-at is internal to the watcher check' + [ "$#" -gt 1 ] || fail '--opened-at requires milliseconds since epoch' + [[ "$2" =~ ^[0-9]+$ ]] || fail '--opened-at must be milliseconds since epoch' + OPENED_AT=$2 + shift 2 + ;; --help) usage exit 0 @@ -137,15 +210,18 @@ while [ "$#" -gt 0 ]; do done HOME_PATH=$(resolve_home "$HOME_ARG") +[ -n "$DOWNLOADS_ARG" ] || fail 'could not resolve a Downloads directory; pass --downloads' +DOWNLOADS_PATH=$(resolve_downloads "$DOWNLOADS_ARG") STATE_DIR="$HOME_PATH/state" if [ "$MODE" = check ]; then [ -n "$SESSION" ] || fail '--session is required in check mode' - check_submission "$DECISION_ID" "$HOME_PATH" "$SESSION" + [ -n "$OPENED_AT" ] || fail '--opened-at is required in check mode' + check_submission "$DECISION_ID" "$HOME_PATH" "$SESSION" "$DOWNLOADS_PATH" "$OPENED_AT" exit 0 fi -command -v lavish >/dev/null 2>&1 || fail 'lavish is not installed' +[ -x "$LAVISH_BIN" ] || fail "Lavish fork CLI is not executable: $LAVISH_BIN" command -v chrome-devtools-axi >/dev/null 2>&1 \ || fail 'chrome-devtools-axi is not installed' command -v node >/dev/null 2>&1 || fail 'node is not installed' @@ -160,13 +236,15 @@ HTML_PATH="$STATE_DIR/lavish-board-$DECISION_ID.html" PAYLOAD_PATH="$STATE_DIR/lavish-board-$DECISION_ID.payload.json" CHECK_PATH="$STATE_DIR/lavish-board-$DECISION_ID.check.sh" CHECK_TMP=$(mktemp "$STATE_DIR/.lavish-board-$DECISION_ID.check.XXXXXX") +OPENED_AT=$(node -e 'process.stdout.write(String(Date.now()))') -lavish board "$DECISION_ID" --home "$HOME_PATH" --out "$HTML_PATH" +"$LAVISH_BIN" board "$DECISION_ID" --home "$HOME_PATH" --out "$HTML_PATH" rm -f "$PAYLOAD_PATH" { printf '#!/usr/bin/env bash\n' - printf 'exec %q --check %q --home %q --session %q\n' \ - "$SCRIPT_DIR/fm-lavish-board.sh" "$DECISION_ID" "$HOME_PATH" "$SESSION" + printf 'exec %q --check %q --home %q --session %q --downloads %q --opened-at %q\n' \ + "$SCRIPT_DIR/fm-lavish-board.sh" "$DECISION_ID" "$HOME_PATH" "$SESSION" \ + "$DOWNLOADS_PATH" "$OPENED_AT" } > "$CHECK_TMP" chmod 700 "$CHECK_TMP" mv "$CHECK_TMP" "$CHECK_PATH" diff --git a/tests/fm-lavish-board.test.sh b/tests/fm-lavish-board.test.sh index 5996309a28..1641cbe4cb 100755 --- a/tests/fm-lavish-board.test.sh +++ b/tests/fm-lavish-board.test.sh @@ -11,7 +11,9 @@ TMP_ROOT=$(fm_test_tmproot fm-lavish-board-tests) HOME_PATH="$TMP_ROOT/home" FAKEBIN="$TMP_ROOT/fakebin" LOG="$TMP_ROOT/calls.log" -mkdir -p "$HOME_PATH/data/decisions/example" "$HOME_PATH/state" "$FAKEBIN" +DOWNLOADS_PATH="$TMP_ROOT/Downloads" +mkdir -p "$HOME_PATH/data/decisions/example" "$HOME_PATH/state" "$FAKEBIN" \ + "$DOWNLOADS_PATH" cat > "$FAKEBIN/lavish" <<'SH' #!/usr/bin/env bash @@ -41,6 +43,7 @@ printf 'chrome:%s session=%s headed=%s auto=%s browser=%s profile=%s args=%s por case "${1:-}" in open|stop) exit 0 ;; eval) + [ "${FM_TEST_PAGE_CLOSED:-0}" != 1 ] || exit 1 FAKE_MARKER=${FM_TEST_MARKER:-absent} node -e ' const present = process.env.FAKE_MARKER === "present"; const snapshot = present @@ -77,8 +80,9 @@ run_board() { CHROME_DEVTOOLS_AXI_PORT=9222 \ CHROME_DEVTOOLS_AXI_USER_DATA_DIR=/captain/main/profile \ CHROME_DEVTOOLS_AXI_WS_HEADERS=secret \ + FM_LAVISH_BIN="$FAKEBIN/lavish" \ PATH="$FAKEBIN:$PATH" \ - "$BOARD" example --home "$HOME_PATH" + "$BOARD" example --home "$HOME_PATH" --downloads "$DOWNLOADS_PATH" } test_launcher_isolates_chrome_and_arms_check() { @@ -98,8 +102,39 @@ test_launcher_isolates_chrome_and_arms_check() { pass "fm-lavish-board opens a headed named session with an isolated throwaway profile" } +test_download_survives_closed_page_and_precedes_live_read() { + local check downloaded durable output lines + check="$HOME_PATH/state/lavish-board-example.check.sh" + downloaded="$DOWNLOADS_PATH/lavish-answer-example.json" + durable="$HOME_PATH/state/lavish-board-example.payload.json" + printf '%s\n' '{"schema_version":2,"decision_id":"example","request_sha256":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","answers":[{"key":"choice","value":"a","question_note":"survives close","option_comments":{"a":"download first"}}],"note":"closed-page proof"}' > "$downloaded" + : > "$LOG" + + output=$( + FM_TEST_PAGE_CLOSED=1 FM_TEST_LOG="$LOG" PATH="$FAKEBIN:$PATH" "$check" + ) || fail "download-first closed-page check failed" + lines=$(printf '%s\n' "$output" | awk 'NF { count++ } END { print count + 0 }') + [ "$lines" -eq 1 ] || fail "download-first check printed $lines lines" + [ "$output" = "lavish-submit: example $durable" ] \ + || fail "download-first check printed the wrong wake line: $output" + assert_absent "$check" "download-first check did not disarm itself" + assert_present "$durable" "download-first check did not persist the payload" + assert_no_grep 'chrome:eval' "$LOG" \ + "download-first check tried to read the already closed page" + node -e ' + const fs = require("node:fs"); + const payload = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (payload.answers[0].question_note !== "survives close") process.exit(1); + if (payload.answers[0].option_comments.a !== "download first") process.exit(1); + if (payload.note !== "closed-page proof") process.exit(1); + ' "$durable" || fail "downloaded payload was not recovered intact after page close" + rm -f "$downloaded" "$durable" + pass "Lavish board download remains recoverable after the page closes" +} + test_check_is_silent_until_marker_then_prints_once() { local check absent present payload lines + run_board >/dev/null || fail "could not rearm live-page fallback check" check="$HOME_PATH/state/lavish-board-example.check.sh" absent=$( FM_TEST_MARKER=absent FM_TEST_LOG="$LOG" PATH="$FAKEBIN:$PATH" "$check" @@ -127,4 +162,5 @@ test_check_is_silent_until_marker_then_prints_once() { } test_launcher_isolates_chrome_and_arms_check +test_download_survives_closed_page_and_precedes_live_read test_check_is_silent_until_marker_then_prints_once diff --git a/tools/lavish/README.md b/tools/lavish/README.md index 954f83edc1..b86cc35c19 100644 --- a/tools/lavish/README.md +++ b/tools/lavish/README.md @@ -31,8 +31,14 @@ The command prints `answer saved; wake not queued`, exits nonzero, and the next The HTML includes its CSS, JavaScript, and images and makes no request to a shared board service. Its dark default theme inlines the vendored Relvino tokens from `src/relvino-tokens.css`, including Inter, Ruby green, `#007AFF` actions, the semantic aliases, and the 16px card radius. -Submitting the page assigns the complete JSON batch to `window.__lavishPayload` and sets `document.title` to `LAVISH-SUBMIT v2`. -Those browser signals are transport only and do not write an answer until `lavish-axi collect` validates the payload. +Submitting the page synchronously starts a JSON file download named `lavish-answer-<decision-id>.json` before it assigns the complete batch to `window.__lavishPayload` and sets `document.title` to `LAVISH-SUBMIT v2`. +The downloaded file survives page and browser closure, while the browser signals remain a live-page fallback. +The done screen also exposes the complete formatted JSON in a read-only selectable textarea for browsers that block downloads. +None of these transports writes an answer until `lavish-axi collect` validates the payload. + +`bin/fm-lavish-board.sh` defaults to the shell user's `Downloads` directory and accepts `--downloads <path>` when the browser uses a different one. +Its one-shot check copies a fresh matching download into the Firstmate home's durable state directory before attempting the live-page marker read. +It never uses `localStorage`, which is not a reliable transport for `file://` boards. Every command accepts `--home <path>`. A captain command run from a shell that does not export `FM_HOME` must include that option, while `bin/fm-lavish-board.sh` resolves its Firstmate home and supplies the option itself. diff --git a/tools/lavish/src/board.mjs b/tools/lavish/src/board.mjs index 009ef51e90..dfb29c4203 100644 --- a/tools/lavish/src/board.mjs +++ b/tools/lavish/src/board.mjs @@ -211,6 +211,9 @@ textarea:focus { outline: 2px solid var(--border-focus); outline-offset: 1px; bo .review-item p { margin: 5px 0 0; color: var(--text-secondary); } .review-item .selection { color: var(--text-primary); font-weight: var(--fw-semibold); } .confirmation { border: 1px solid color-mix(in srgb, var(--green-500) 55%, var(--border-hairline)); border-radius: var(--radius-md); background: color-mix(in srgb, var(--ruby-green) 22%, var(--bg-surface)); padding: 14px; color: #8FE0BF; } +.payload-backup { margin-top: 18px; } +.payload-backup p { margin: 6px 0 10px; color: var(--text-secondary); } +.payload-backup textarea { min-height: 220px; font-family: var(--font-mono); font-size: var(--sm-size); } [hidden] { display: none !important; } @media (max-width: 600px) { main { width: min(100% - 20px, 980px); padding-top: 28px; } @@ -238,6 +241,9 @@ function boardScript(decision) { const reviewList = document.querySelector('#review-list'); const formError = document.querySelector('#form-error'); const overallNote = document.querySelector('#overall-note'); + const payloadBackup = document.querySelector('#payload-backup'); + const submittedPayload = document.querySelector('#submitted-payload'); + const downloadFilename = 'lavish-answer-' + MANIFEST.decision_id + '.json'; window.__lavishPayload = null; function buildPayload() { @@ -304,6 +310,19 @@ function boardScript(decision) { } } + function downloadPayload(payloadJson) { + const blob = new Blob([payloadJson], { type: 'application/json;charset=utf-8' }); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = downloadFilename; + anchor.hidden = true; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + } + document.querySelector('#review-button').addEventListener('click', () => { const missing = MANIFEST.questions.find((question) => !form.querySelector( 'input[data-question-key="' + question.key + '"]:checked', @@ -332,7 +351,11 @@ function boardScript(decision) { document.querySelector('#submit-button').addEventListener('click', (event) => { const payload = buildPayload(); + const payloadJson = JSON.stringify(payload, null, 2) + '\\n'; + downloadPayload(payloadJson); window.__lavishPayload = payload; + submittedPayload.value = payloadJson; + payloadBackup.hidden = false; document.title = SUBMIT_MARKER; event.currentTarget.disabled = true; document.querySelector('#confirmation').hidden = false; @@ -384,7 +407,12 @@ export async function renderBoard(decision) { <p class="eyebrow">Final review</p> <h2 id="review-title">Submit this complete answer batch?</h2> <div id="review-list" class="review-list"></div> - <p id="confirmation" class="confirmation" role="status" hidden>Answer captured. Firstmate will validate and confirm receipt.</p> + <p id="confirmation" class="confirmation" role="status" hidden>Answer downloaded. Firstmate will validate and confirm receipt.</p> + <section id="payload-backup" class="payload-backup" aria-labelledby="payload-backup-title" hidden> + <h3 id="payload-backup-title">Manual payload backup</h3> + <p>If the browser blocked the download, copy and save this complete JSON payload.</p> + <textarea id="submitted-payload" rows="14" readonly spellcheck="false" aria-label="Submitted payload JSON"></textarea> + </section> <div class="actions"> <button id="back-button" class="button" type="button">Back</button> <button id="submit-button" class="button primary" type="button">Submit to firstmate</button> diff --git a/tools/lavish/test/lavish.test.mjs b/tools/lavish/test/lavish.test.mjs index 2b9b467a4b..f8a66512c9 100644 --- a/tools/lavish/test/lavish.test.mjs +++ b/tools/lavish/test/lavish.test.mjs @@ -498,8 +498,16 @@ test('board renders the manifest, annotations, Markdown context, visuals, and su assert.ok(evidence); assert.match(evidence.getAttribute('src'), /^data:image\/png;base64,/); assert.equal(document.querySelectorAll('link[rel="stylesheet"]').length, 0); + const payloadBackup = document.querySelector('#submitted-payload'); + assert.ok(payloadBackup); + assert.equal(payloadBackup.hasAttribute('readonly'), true); const script = document.querySelector('script').textContent; assert.match(script, /window\.__lavishPayload = payload/); + assert.match(script, /new Blob\(\[payloadJson\]/); + assert.match(script, /URL\.createObjectURL\(blob\)/); + assert.match(script, /anchor\.download = downloadFilename/); + assert.match(script, /anchor\.click\(\)/); + assert.match(script, /submittedPayload\.value = payloadJson/); assert.match(script, /document\.title = SUBMIT_MARKER/); assert.match(script, /LAVISH-SUBMIT v2/); });