Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .agents/skills/bearings/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <task-id> <board-file>` 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

Expand Down
122 changes: 122 additions & 0 deletions bin/fm-lavish-check.sh
Original file line number Diff line number Diff line change
@@ -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/<task-id>.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 <task-id> <board-file> generate+register state/<task-id>.check.sh
# fm-lavish-check.sh --classify <board-file> 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 <board-file>" >&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 <task-id> <board-file>" >&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" <<EOF
#!/usr/bin/env bash
# Auto-generated by fm-lavish-check.sh - do NOT edit; regenerate to change.
# Per-board Lavish feedback poll, re-armed by the watcher every sweep. The short
# timeout means drain-on-arm delivers any queued prompts each sweep; the wake
# decision (real prompts vs warnings-only) is owned by fm-lavish-check.sh.
set -u
out=\$(lavish-axi poll '$BOARD' --timeout-ms $FM_LAVISH_POLL_TIMEOUT_MS 2>/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")"
143 changes: 143 additions & 0 deletions tests/fm-lavish-check.test.sh
Original file line number Diff line number Diff line change
@@ -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 <lavish-axi-poll-text> -> 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 '<html><body>b</body></html>' > "$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 '<html></html>' > "$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 '<html></html>' > "$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