diff --git a/.agents/skills/bearings/SKILL.md b/.agents/skills/bearings/SKILL.md index f543ebf32..82b672025 100644 --- a/.agents/skills/bearings/SKILL.md +++ b/.agents/skills/bearings/SKILL.md @@ -43,6 +43,9 @@ It never tears down a task, merges a PR, dispatches new work, or mutates any tas If today's file already exists, delete it first, then create a new file from scratch. - The chat response is the concise four-section digest defined by the contract below: materially shorter than the report file, complete as a current snapshot, internally consistent with the file, and linked to that file for the full picture. - For a richer review surface, optionally offer a Lavish board with `lavish-axi` when the report has enough structure to deserve one, but the markdown file is the required artifact and the four-section chat digest is the required minimum. + Arm any board's feedback as a watcher check with `bin/fm-lavish-check.sh ` rather than a bare `lavish-axi poll`, so the watcher re-arms the poll every sweep and queued feedback drains on arm. + Never treat a poll return with empty `prompts` as the captain's feedback, because a browser layout-warning returns an armed poll early with no prompts. + Never arm two polls on one board file, because concurrent drains race and can duplicate or drop a send. ## Chat-response contract diff --git a/bin/fm-lavish-check.sh b/bin/fm-lavish-check.sh new file mode 100755 index 000000000..14b70a00e --- /dev/null +++ b/bin/fm-lavish-check.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Generate and register a per-board Lavish feedback watcher check, or classify a +# poll result. The generated check lives at state/.check.sh. +# +# Why this exists: `lavish-axi poll` is a single-shot long-poll - it returns and +# the process exits on the FIRST wake event, and browser-generated layout_warnings +# share that wake with real user sends. So a warnings event can return an armed +# poll with empty prompts; a human send that then lands with no poll armed waits +# in the queue until the next arm. Modeling the poll as a watcher check makes the +# watcher re-arm it every sweep (reap-proof), drain-on-arm delivers any queued +# prompts, and warnings-only returns stay silent - so real feedback reaches +# firstmate within one sweep without a long-lived poll process to be reaped. +# +# Usage: +# fm-lavish-check.sh generate+register state/.check.sh +# fm-lavish-check.sh --classify read `lavish-axi poll` output on stdin, +# print a wake line ONLY when real user +# prompts arrived +# +# The generated check invokes this same script in --classify mode, so the wake +# decision has exactly one owner (lavish_classify below). +set -u + +# The per-poll wait bound baked into generated checks. Short is fine: drain-on-arm +# delivers anything already queued immediately, so this only caps how long one +# sweep waits for feedback that arrives mid-poll. Must stay well under the +# watcher's FM_CHECK_TIMEOUT (default 30s). +# ponytail: fixed 5s; if node cold-start makes checks flaky, raise here and regenerate. +FM_LAVISH_POLL_TIMEOUT_MS=5000 + +# Single owner of the wake decision: emit a wake line iff `lavish-axi poll` +# reported real user prompts. Its text digest renders a non-empty send as a +# `prompts[N]{...}:` header line (N>=1) and an empty one as `prompts: []`, so the +# presence of the bracketed-count header is the signal. Warnings-only, waiting, +# ended-without-prompts, empty, and unrecognized output all stay silent. +# ponytail: parses lavish-axi's human-readable poll digest (no --json flag exists +# in 0.1.42); if a future version changes that header, update this one matcher. +lavish_classify() { + local board=$1 out count + out=$(cat) + count=$(printf '%s\n' "$out" | sed -n 's/^prompts\[\([0-9][0-9]*\)\].*/\1/p' | head -1) + case "$count" in + ''|*[!0-9]*) count=0 ;; + esac + if [ "$count" -gt 0 ]; then + printf 'lavish-feedback %s: %s prompt(s)\n' "$(basename -- "$board")" "$count" + fi + return 0 +} + +if [ "${1-}" = --classify ]; then + if [ "$#" -ne 2 ]; then + echo "usage: fm-lavish-check.sh --classify " >&2 + exit 2 + fi + lavish_classify "$2" + exit 0 +fi + +# --- generate mode ---------------------------------------------------------- + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}" +FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" +STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" + +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" + +if [ "$#" -ne 2 ] || ! fm_pr_task_id_valid "${1-}"; then + echo "usage: fm-lavish-check.sh " >&2 + exit 2 +fi +ID=$1 +BOARD_RAW=$2 + +# An absolute path to the existing board is enough: the Lavish server realpaths +# both the session-open path and the poll path, so any path to the same file +# yields the same session key. +BOARD=$(cd "$(dirname -- "$BOARD_RAW")" 2>/dev/null && printf '%s/%s' "$(pwd -P)" "$(basename -- "$BOARD_RAW")") || BOARD= +if [ -z "$BOARD" ] || [ ! -f "$BOARD" ]; then + echo "error: board file not found: $BOARD_RAW" >&2 + exit 1 +fi + +[ -d "$STATE" ] && [ ! -L "$STATE" ] || { echo "error: state directory is unavailable" >&2; exit 1; } + +# The board and helper paths are baked as single-quoted shell literals; the whole +# file's bytes are then hash-bound by fm-check-register.sh, so any later edit is +# rejected by the watcher. ponytail: a single quote in either path would break the +# quoting - firstmate .lavish/*.html boards never contain one, so refuse rather +# than carry an escaping routine that would never run. +case "$BOARD$SCRIPT_DIR" in + *\'*) echo "error: single quote in a path is unsupported" >&2; exit 1 ;; +esac + +CHECK="$STATE/$ID.check.sh" +umask 077 +TMP=$(mktemp "$STATE/.fm-lavish-check.XXXXXX") || exit 1 +trap '[ -z "${TMP:-}" ] || rm -f -- "$TMP"' EXIT HUP INT TERM + +cat > "$TMP" </dev/null) || out= +printf '%s' "\$out" | '$SCRIPT_DIR/fm-lavish-check.sh' --classify '$BOARD' +exit 0 +EOF + +chmod 0700 "$TMP" || exit 1 +mv -f -- "$TMP" "$CHECK" || exit 1 +TMP= + +if ! "$SCRIPT_DIR/fm-check-register.sh" "$ID" >/dev/null; then + echo "error: could not register the Lavish feedback check" >&2 + exit 1 +fi +printf 'armed: state/%s.check.sh (%s)\n' "$ID" "$(basename -- "$BOARD")" diff --git a/tests/fm-lavish-check.test.sh b/tests/fm-lavish-check.test.sh new file mode 100755 index 000000000..13067ff33 --- /dev/null +++ b/tests/fm-lavish-check.test.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-lavish-check.sh. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +TMP_ROOT=$(fm_test_tmproot fm-lavish-check) + +BIN="$ROOT/bin/fm-lavish-check.sh" + +classify() { + # classify -> stdout of --classify for a fixed board label. + printf '%s' "$1" | "$BIN" --classify "work-status-board.html" +} + +# Samples are the real `lavish-axi poll` text digest (0.1.42): a non-empty send +# renders a `prompts[N]{...}:` header, an empty one renders `prompts: []`. +PROMPTS_2=$'session:\n status: feedback\nprompts[2]{uid,prompt,selector,tag,text}:\n "",a,"",message,""\n "",b,"",message,""\nnext_step: "apply the changes"' +SEND_AND_END_1=$'session:\n status: ended\nprompts[1]{uid,prompt,selector,tag,text}:\n "",final,"",message,""\nnext_step: "final feedback"' +WARNINGS_ONLY=$'session:\n status: feedback\nprompts: []\nlayout_warnings[1]{selector,kind,overflowPx,viewportWidth,severity,persistent}:\n #x,overlapping-text,3,1200,error,false\nnext_step: "fix overflow"' +WAITING=$'session:\n status: waiting\nnext_step: "No user feedback arrived before the optional timeout."' +ENDED_NO_PROMPTS=$'session:\n status: ended\nprompts: []' + +test_classify_real_prompts_wakes() { + local out + out=$(classify "$PROMPTS_2") + assert_contains "$out" "work-status-board.html" "wake line lost the board label" + assert_contains "$out" "2 prompt(s)" "wake line lost the prompt count" + pass "fm-lavish-check --classify: real prompts wake firstmate with a count" +} + +test_classify_send_and_end_with_prompts_wakes() { + local out + out=$(classify "$SEND_AND_END_1") + assert_contains "$out" "1 prompt(s)" "Send & End final feedback should still wake" + pass "fm-lavish-check --classify: ended-with-prompts (Send & End) wakes" +} + +test_classify_warnings_only_is_silent() { + local out + out=$(classify "$WARNINGS_ONLY") + [ -z "$out" ] || fail "warnings-only return must stay silent, got: $out" + pass "fm-lavish-check --classify: warnings-only (empty prompts) stays silent" +} + +test_classify_waiting_is_silent() { + local out + out=$(classify "$WAITING") + [ -z "$out" ] || fail "waiting return must stay silent, got: $out" + pass "fm-lavish-check --classify: waiting stays silent" +} + +test_classify_ended_without_prompts_is_silent() { + local out + out=$(classify "$ENDED_NO_PROMPTS") + [ -z "$out" ] || fail "ended-without-prompts must stay silent, got: $out" + pass "fm-lavish-check --classify: ended-without-prompts stays silent" +} + +test_classify_empty_and_unrecognized_are_silent() { + local out + out=$(classify '') + [ -z "$out" ] || fail "empty input must stay silent, got: $out" + out=$(classify $'just some noise\nno header here') + [ -z "$out" ] || fail "unrecognized input must stay silent, got: $out" + pass "fm-lavish-check --classify: empty and unrecognized input stay silent" +} + +test_classify_prompts_word_mid_line_is_silent() { + # The header match is line-anchored: `prompts[N]` inside a next_step sentence + # must NOT be read as real feedback. + local out + out=$(classify $'session:\n status: waiting\nnext_step: "re-run to collect prompts[5] later"') + [ -z "$out" ] || fail "a mid-line prompts[N] must stay silent, got: $out" + pass "fm-lavish-check --classify: a mid-line prompts[N] does not wake" +} + +test_generate_creates_registered_check() { + local home board out check trust + home="$TMP_ROOT/gen-ok" + mkdir -p "$home/state" + board="$home/board.html" + printf 'b' > "$board" + out=$(FM_HOME="$home" "$BIN" lavish-demo-1 "$board" 2>&1) \ + || fail "generate failed: $out" + assert_contains "$out" "armed: state/lavish-demo-1.check.sh" "generate did not report armed" + check="$home/state/lavish-demo-1.check.sh" + trust="$home/state/lavish-demo-1.check-trust" + assert_present "$check" "check script was not created" + assert_present "$trust" "check was not registered (trust file missing)" + [ -x "$check" ] || fail "generated check is not executable" + [ ! -L "$check" ] || fail "generated check must be a regular file, not a symlink" + assert_grep "lavish-axi poll" "$check" "generated check does not poll" + assert_grep "board.html" "$check" "generated check lost the board path" + assert_grep "--classify" "$check" "generated check does not delegate the wake decision" + pass "fm-lavish-check: generate creates a registered, executable per-board check" +} + +test_generate_refresh_is_idempotent() { + local home board + home="$TMP_ROOT/gen-refresh" + mkdir -p "$home/state" + board="$home/board.html" + printf '' > "$board" + FM_HOME="$home" "$BIN" lavish-demo-2 "$board" >/dev/null 2>&1 || fail "first generate failed" + FM_HOME="$home" "$BIN" lavish-demo-2 "$board" >/dev/null 2>&1 || fail "refresh (regenerate) failed" + assert_present "$home/state/lavish-demo-2.check-trust" "refresh left the check unregistered" + pass "fm-lavish-check: regenerating an existing board check is idempotent" +} + +test_generate_rejects_missing_board() { + local home + home="$TMP_ROOT/gen-missing" + mkdir -p "$home/state" + FM_HOME="$home" "$BIN" lavish-demo-3 "$home/nope.html" >/dev/null 2>&1 + expect_code 1 $? "missing board file should exit 1" + assert_absent "$home/state/lavish-demo-3.check.sh" "no check should be written for a missing board" + pass "fm-lavish-check: missing board file is refused" +} + +test_generate_rejects_invalid_id() { + local home board + home="$TMP_ROOT/gen-badid" + mkdir -p "$home/state" + board="$home/board.html" + printf '' > "$board" + FM_HOME="$home" "$BIN" "bad id" "$board" >/dev/null 2>&1 + expect_code 2 $? "invalid task id should exit 2 (usage)" + pass "fm-lavish-check: invalid task id is refused" +} + +test_classify_real_prompts_wakes +test_classify_send_and_end_with_prompts_wakes +test_classify_warnings_only_is_silent +test_classify_waiting_is_silent +test_classify_ended_without_prompts_is_silent +test_classify_empty_and_unrecognized_are_silent +test_classify_prompts_word_mid_line_is_silent +test_generate_creates_registered_check +test_generate_refresh_is_idempotent +test_generate_rejects_missing_board +test_generate_rejects_invalid_id