From f48352ab29a2bb961bbfb9e5c175fdc6c61b6588 Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sat, 4 Jul 2026 11:52:54 -0700 Subject: [PATCH 01/10] Make fm-send fail loudly on unresolved targets --- AGENTS.md | 2 + bin/fm-guard.sh | 1 + bin/fm-send.sh | 141 +++++++++++++++++++++++++++---- docs/scripts.md | 2 +- tests/fm-backend-herdr.test.sh | 2 +- tests/fm-backend-orca.test.sh | 9 +- tests/fm-backend-zellij.test.sh | 4 +- tests/fm-daemon.test.sh | 6 +- tests/fm-send-strict.test.sh | 142 ++++++++++++++++++++++++++++++++ 9 files changed, 281 insertions(+), 28 deletions(-) create mode 100644 tests/fm-send-strict.test.sh diff --git a/AGENTS.md b/AGENTS.md index 6d6f28c818d..a0eaa70d4e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -469,6 +469,7 @@ A project may appear in several `projects:` clone lists, so choose the secondmat If the resolved project is `local-only`, keep the work with the main firstmate even when a secondmate scope sounds relevant. If a secondmate's scope fits, steer that secondmate with one concise instruction via `bin/fm-send.sh ''` and let it run the normal lifecycle inside its own home. The stable `fm-` label printed by lifecycle commands still works, but exact task ids resolve first through this home's `state/.meta`; pass an explicit backend target containing `:` only when intentionally targeting an endpoint outside this firstmate home. +`fm-send` is fail-closed: `FM_HOME` must be set, and any target that cannot be resolved through this home's metadata or a well-formed explicit backend target exits non-zero instead of guessing a tmux window. A secondmate is itself a firstmate, so a request reaches it in its own chat, which you never read - the return channel that wakes you is its status file. So `fm-send` to a task selector whose meta is `kind=secondmate` automatically prepends a from-firstmate marker (`bin/fm-marker-lib.sh`); the secondmate recognizes it and returns its answer via its status file, or via a doc under its home plus a status pointer for a detailed response, never only in chat. Expect and read that response on the status/doc path the same way you read any other status signal; do not peek the secondmate's chat for the answer. @@ -737,6 +738,7 @@ While running, `fm-watch.sh` touches `state/.last-watcher-beat` every poll cycle The supervision scripts (`fm-peek`, `fm-send`, `fm-spawn`, `fm-teardown`, `fm-pr-check`, `fm-promote`, `fm-review-diff`, `fm-fleet-sync`, `fm-update`) call `bin/fm-guard.sh` first, which warns to stderr when any task is in flight (`state/*.meta` exists) but queued wakes are pending, or that beacon is missing or older than `FM_GUARD_GRACE` (default 300s). `bin/fm-wake-drain.sh` runs the same guard after it drains, so the liveness check also fires on a drain-and-handle turn that runs no other supervision script, narrowing the window in which a lapsed chain can hide; the grace beacon keeps it silent right after a normal fire and it warns only on a genuine stale-beyond-grace lapse. The no-watcher case leads with a prominent, bordered ●-marked banner (in-flight count, beacon age, and the exact one-line re-arm command) so it reads as an alarm rather than a buried stderr line you can skim past. +The banner is only a supervision warning: the guarded operation still runs, and the text says explicitly that a requested message WILL still be sent. So the next time you touch the fleet with queued wakes or no watcher alive, the tool output itself tells you what to do - a pull-based guard that works on any harness, since it rides the script output you already read rather than a harness-specific hook. The grace window keeps normal handling (watcher briefly down between a wake and its re-arm) silent. If a guard warning says queued wakes are pending, drain them before doing anything else. diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index 08047c40a60..e8db2d9354d 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -89,6 +89,7 @@ if [ "$watcher_fresh" = false ]; then else printf '● Trust bin/fm-watch-arm.sh for the true state: it confirms a live watcher and a fresh beacon, or fails loudly.\n' fi + printf '● This is a supervision warning only; the requested message WILL still be sent.\n' printf '● %s\n' "$fix" printf '●%s\n' "$rule" } >&2 diff --git a/bin/fm-send.sh b/bin/fm-send.sh index f0d85d22988..4afaea83339 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -2,7 +2,10 @@ # Send one line of literal text to a crewmate endpoint, then Enter. # Usage: fm-send.sh # may be an exact task id, a legacy fm- task label resolved -# through this home's state/.meta, or an explicit backend target. +# through this home's state/.meta, or an explicit well-formed backend +# target. fm-send refuses unresolved guesses rather than falling back to a +# tmux window search, because a "successful" send to the wrong endpoint is +# worse than a loud failure. # Special keys instead of text: fm-send.sh --key Enter # Key support is backend-specific: tmux/herdr support Escape, Enter, and C-c; # Orca currently supports Enter and C-c only, and rejects Escape. @@ -34,8 +37,21 @@ set -eu 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}}" + +if [ -z "${FM_HOME+x}" ] || [ -z "${FM_HOME:-}" ]; then + echo "error: FM_HOME is not set; fm-send refuses to resolve targets without an explicit firstmate home" >&2 + exit 1 +fi + STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" +if [ ! -d "$FM_HOME" ]; then + echo "error: FM_HOME '$FM_HOME' is not a directory; fm-send cannot resolve this home's state" >&2 + exit 1 +fi +if [ ! -d "$STATE" ]; then + echo "error: state dir '$STATE' is missing; fm-send cannot resolve targets for FM_HOME '$FM_HOME'" >&2 + exit 1 +fi # shellcheck source=bin/fm-backend.sh . "$SCRIPT_DIR/fm-backend.sh" @@ -44,11 +60,92 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}" "$SCRIPT_DIR/fm-guard.sh" || true +fm_send_id_from_meta() { # + local base + base=${1##*/} + printf '%s' "${base%.meta}" +} + +fm_send_meta_for_key_value() { # + local state=$1 key=$2 value=$3 meta got + for meta in "$state"/*.meta; do + [ -e "$meta" ] || continue + got=$(fm_meta_get "$meta" "$key") + [ "$got" = "$value" ] || continue + printf '%s' "$meta" + return 0 + done + return 1 +} + +fm_send_count_colons() { # + local s=$1 no_colons + no_colons=${s//:/} + printf '%s' $(( ${#s} - ${#no_colons} )) +} + +fm_send_resolve_target() { # + local raw=$1 meta pane_meta target backend assumed colons id session hint + + RESOLVED_TARGET="" + TARGET_BACKEND="" + TARGET_HARNESS="" + EXPECTED_LABEL="" + TARGET_META="" + RESOLUTION_TRIED="" + + meta=$(fm_backend_meta_for_selector "$raw" "$STATE" 2>/dev/null || true) + if [ -n "$meta" ]; then + RESOLUTION_TRIED="meta=$meta; backend=from-meta" + target=$(fm_backend_target_of_meta "$meta") + if [ -z "$target" ]; then + echo "error: no backend target recorded in $meta (tried $RESOLUTION_TRIED)" >&2 + return 1 + fi + backend=$(fm_backend_of_meta "$meta") + RESOLVED_TARGET=$target + TARGET_BACKEND=$backend + TARGET_META=$meta + TARGET_HARNESS=$(fm_meta_get "$meta" harness) + EXPECTED_LABEL=$(fm_backend_expected_label_of_selector "$raw" "$STATE") + return 0 + fi + + pane_meta=$(fm_send_meta_for_key_value "$STATE" herdr_pane_id "$raw" 2>/dev/null || true) + if [ -n "$pane_meta" ]; then + session=$(fm_meta_get "$pane_meta" herdr_session) + hint="${session:-}:$raw" + id=$(fm_send_id_from_meta "$pane_meta") + echo "error: target '$raw' matches herdr_pane_id in $pane_meta but is missing its herdr session prefix; expected : such as '$hint' or use 'fm-$id' (tried meta=$STATE/$raw.meta; backend=herdr)" >&2 + return 1 + fi + + case "$raw" in + *:*) + colons=$(fm_send_count_colons "$raw") + if [ "$colons" -ge 2 ]; then + assumed=herdr + else + assumed=tmux + fi + RESOLVED_TARGET=$raw + TARGET_BACKEND=$assumed + RESOLUTION_TRIED="meta=$STATE/$raw.meta; metadata window/terminal lookup; backend=$assumed" + return 0 + ;; + esac + + echo "error: target '$raw' is not resolvable (tried meta=$STATE/$raw.meta; metadata window/terminal lookup; backend=none). Use fm-$raw for a recorded task/lane, or pass a well-formed explicit backend target such as session:window." >&2 + return 1 +} + RAW_TARGET=$1 -T=$(fm_backend_resolve_selector "$1" "$STATE") -TARGET_META=$(fm_backend_meta_for_selector "$RAW_TARGET" "$STATE" 2>/dev/null || true) +fm_send_resolve_target "$RAW_TARGET" || exit 1 +T=$RESOLVED_TARGET shift +fm_backend_validate "$TARGET_BACKEND" || exit 1 + # Mark a from-firstmate -> secondmate request. Only a task selector resolved # through this home's meta and recording kind=secondmate is marked: the # secondmate then routes its reply via the status path (see fm-marker-lib.sh). @@ -63,17 +160,22 @@ fi # scope the codex `$` popup-settle below. A task selector carries # meta; an explicit backend-target escape hatch has none, so its harness is # unknown and treated as non-codex (the safe default that keeps the fast path). -# The target's BACKEND comes from selector meta, or from matching the resolved -# explicit target back to recorded meta, then falls back to tmux. -TARGET_HARNESS="" -TARGET_BACKEND=$(fm_backend_of_selector "$RAW_TARGET" "$T" "$STATE") -EXPECTED_LABEL=$(fm_backend_expected_label_of_selector "$RAW_TARGET" "$STATE") -if [ -n "$TARGET_META" ]; then - TARGET_HARNESS=$(fm_meta_get "$TARGET_META" harness) -fi +# The target's BACKEND comes from selector meta, from matching an explicit target +# back to recorded meta, or from strict explicit-target shape validation. +case "$RAW_TARGET" in + fm-*) + if ! fm_backend_target_exists "$TARGET_BACKEND" "$T" "$EXPECTED_LABEL"; then + echo "error: resolved target '$RAW_TARGET' to '$T' via $TARGET_META, but backend endpoint is not live (tried $RESOLUTION_TRIED; backend=$TARGET_BACKEND)" >&2 + exit 1 + fi + ;; +esac if [ "${1:-}" = "--key" ]; then - fm_backend_send_key "$TARGET_BACKEND" "$T" "$2" "$EXPECTED_LABEL" + if ! fm_backend_send_key "$TARGET_BACKEND" "$T" "$2" "$EXPECTED_LABEL"; then + echo "error: key '$2' not sent to $T ($TARGET_BACKEND send failed; tried $RESOLUTION_TRIED)" >&2 + exit 1 + fi else # Slash commands open a completion popup in some TUIs (verified on codex); # submitting too fast selects nothing, so give the popup time to settle before @@ -92,16 +194,19 @@ else esac retries=${FM_SEND_RETRIES:-3} sleep_s=${FM_SEND_SLEEP:-0.4} - # Type once, submit, verify. Lenient: only a positively-confirmed swallowed - # submit is an error; an unreadable pane is assumed sent. - verdict=$(fm_backend_send_text_submit "$TARGET_BACKEND" "$T" "$MARK_PREFIX$*" "$retries" "$sleep_s" "$settle" "$EXPECTED_LABEL") + # Type once, submit, verify. Lenient: only a positively-confirmed swallow + # (text still in the composer) is an error; an unreadable pane is assumed sent. + if ! verdict=$(fm_backend_send_text_submit "$TARGET_BACKEND" "$T" "$MARK_PREFIX$*" "$retries" "$sleep_s" "$settle" "$EXPECTED_LABEL"); then + echo "error: text not sent to $T ($TARGET_BACKEND send failed; tried $RESOLUTION_TRIED)" >&2 + exit 1 + fi case "$verdict" in pending) - echo "error: text not submitted to $T (Enter swallowed; text left in composer)" >&2 + echo "error: text not submitted to $T (Enter swallowed; text left in composer; tried $RESOLUTION_TRIED)" >&2 exit 1 ;; send-failed) - echo "error: text not sent to $T ($TARGET_BACKEND send failed)" >&2 + echo "error: text not sent to $T ($TARGET_BACKEND send failed; tried $RESOLUTION_TRIED)" >&2 exit 1 ;; esac diff --git a/docs/scripts.md b/docs/scripts.md index 72b4e64c444..d7d8fcfe7e9 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -43,7 +43,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `fm-wake-drain.sh` | Atomically drain queued watcher wakes before handling supervision work, then run the watcher-liveness guard | | `fm-wake-lib.sh` | Shared durable wake queue, portable lock helpers, path-age helpers, and watcher identity/health helpers sourced by the watcher, drain, arm, guard, turn-end guard, daemon, and teardown | | `fm-classify-lib.sh` | Shared captain-relevant wake classifier sourced by the watcher and daemon, plus the watcher's provably-working predicate | -| `fm-send.sh` | Send one verified literal line or backend-supported `--key` through the target's recorded runtime backend; exits non-zero on confirmed swallowed Enter; metadata-routed `kind=secondmate` task selectors are marked as from-firstmate; slash commands and codex `$...` skill invocations get popup-settle before backend-specific submit verification; text sends pause `FM_SEND_SETTLE` seconds after success | +| `fm-send.sh` | Send one verified literal line or backend-supported `--key` through the target's recorded runtime backend; requires explicit `FM_HOME`, refuses unresolved selectors instead of guessing tmux, exits non-zero on failed delivery or confirmed swallowed Enter; metadata-routed `kind=secondmate` task selectors are marked as from-firstmate; slash commands and codex `$...` skill invocations get popup-settle before backend-specific submit verification; text sends pause `FM_SEND_SETTLE` seconds after success | | `fm-tmux-lib.sh` | Shared tmux pane primitives for busy detection, dim-ghost-aware and border-aware composer detection, and verified submit retry | | `fm-peek.sh` | Print a bounded tail of a crewmate endpoint through the target's recorded runtime backend | | `fm-pr-check.sh` | Record `pr=` and GitHub's `pr_head=` when available for a PR-ready task, then arm the watcher's merge poll | diff --git a/tests/fm-backend-herdr.test.sh b/tests/fm-backend-herdr.test.sh index 619468c5001..92585b7053f 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -1350,7 +1350,7 @@ SH "fm-peek did not route the explicit stale target through herdr capture" : > "$log" - PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" \ + PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$neutral" FM_HOME="$neutral" FM_STATE_OVERRIDE="$state" \ FM_HERDR_LOG="$log" FM_HERDR_RESPONSES="$resp" \ "$ROOT/bin/fm-send.sh" default:w1:p2 --key Escape >/dev/null 2>&1 expect_code 0 $? "fm-send --key should route an explicit metadata-matched target through herdr" diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index 6cf67f17957..bad9a389018 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -698,11 +698,14 @@ test_peek_send_and_crew_state_route_through_orca_meta() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_SEND_SETTLE=0 \ "$ROOT/bin/fm-peek.sh" "fm-$id" 10 ) [ "$out" = ready ] || fail "fm-peek should read through Orca metadata, got '$out'" - printf '{"ok":true,"result":{"terminal":{"tail":["│ > │"]}}}\n' > "$RESP/4.out" + printf '{"ok":true,"result":{"terminal":{"tail":["ready"]}}}\n' > "$RESP/2.out" + printf '{"ok":true,"result":{"send":{"handle":"term-io","accepted":true}}}\n' > "$RESP/3.out" + printf '{"ok":true,"result":{"send":{"handle":"term-io","accepted":true}}}\n' > "$RESP/4.out" + printf '{"ok":true,"result":{"terminal":{"tail":["│ > │"]}}}\n' > "$RESP/5.out" PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ - FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_SEND_SETTLE=0 \ + FM_ROOT_OVERRIDE="$neutral" FM_HOME="$neutral" FM_STATE_OVERRIDE="$state" FM_SEND_SETTLE=0 \ "$ROOT/bin/fm-send.sh" "fm-$id" "hello orca" - printf '{"ok":true,"result":{"terminal":{"tail":["idle prompt"]}}}\n' > "$RESP/5.out" + printf '{"ok":true,"result":{"terminal":{"tail":["idle prompt"]}}}\n' > "$RESP/6.out" out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" "$ROOT/bin/fm-crew-state.sh" "$id" ) assert_contains "$out" "state: unknown" "crew-state should fall back cleanly for an idle Orca scout" diff --git a/tests/fm-backend-zellij.test.sh b/tests/fm-backend-zellij.test.sh index aef23c3f73e..acfaacca9ef 100755 --- a/tests/fm-backend-zellij.test.sh +++ b/tests/fm-backend-zellij.test.sh @@ -955,7 +955,7 @@ SH "fm-peek did not route the explicit metadata-matched target through zellij capture" : > "$dir/log" - PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" \ + PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$neutral" FM_HOME="$neutral" FM_STATE_OVERRIDE="$state" \ FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" FM_ZELLIJ_SESSION_LIST="firstmate" \ "$ROOT/bin/fm-send.sh" firstmate:7 --key Escape >/dev/null 2>&1 expect_code 0 $? "fm-send --key should route an explicit metadata-matched target through zellij" @@ -998,7 +998,7 @@ test_scripts_reject_fm_target_label_mismatch() { zellij_tab_response "$dir" 2 3 not-the-task fb=$(make_zellij_fakebin "$dir") - PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" \ + PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$neutral" FM_HOME="$neutral" FM_STATE_OVERRIDE="$state" \ FM_ZELLIJ_LOG="$dir/log" FM_ZELLIJ_RESPONSES="$dir/responses" FM_ZELLIJ_SESSION_LIST="firstmate" \ "$ROOT/bin/fm-send.sh" fm-zreuse --key Escape >/dev/null 2>&1 status=$? diff --git a/tests/fm-daemon.test.sh b/tests/fm-daemon.test.sh index 4d45523657c..6fd8cc6a874 100755 --- a/tests/fm-daemon.test.sh +++ b/tests/fm-daemon.test.sh @@ -767,13 +767,13 @@ test_fm_send_exits_nonzero_on_confirmed_swallow() { dir=$(make_bordered_case send-swallow) fakebin="$dir/fakebin"; err="$dir/send.err" # Clean submit -> exit 0. - PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$dir/state" FM_FAKE_COMPOSER="$dir/composer" \ + PATH="$fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$dir/state" FM_FAKE_COMPOSER="$dir/composer" \ FM_SEND_SLEEP=0.05 "$ROOT/bin/fm-send.sh" sess:win 'route this work' >/dev/null 2>"$err" \ || fail "fm-send exited non-zero on a clean submit: $(cat "$err")" # Persistent swallow -> exit non-zero with a clear message. printf '│ > │\n' > "$dir/composer" touch "$dir/.swallow" - if PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$dir/state" FM_FAKE_COMPOSER="$dir/composer" \ + if PATH="$fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$dir/state" FM_FAKE_COMPOSER="$dir/composer" \ FM_FAKE_SWALLOW="$dir/.swallow" FM_FAKE_PERSIST_SWALLOW=1 FM_SEND_SLEEP=0.05 \ "$ROOT/bin/fm-send.sh" sess:win 'fix findings 1 and 3, skip 2' >/dev/null 2>"$err"; then fail "fm-send exited zero despite a swallowed Enter (silent unsubmitted instruction)" @@ -786,7 +786,7 @@ test_fm_send_exits_nonzero_on_initial_send_failure() { local dir fakebin err dir=$(make_bordered_case send-type-failure) fakebin="$dir/fakebin"; err="$dir/send.err" - if PATH="$fakebin:$PATH" FM_STATE_OVERRIDE="$dir/state" FM_FAKE_COMPOSER="$dir/composer" \ + if PATH="$fakebin:$PATH" FM_HOME="$dir" FM_STATE_OVERRIDE="$dir/state" FM_FAKE_COMPOSER="$dir/composer" \ FM_FAKE_SEND_FAIL=1 FM_SEND_SLEEP=0.05 \ "$ROOT/bin/fm-send.sh" sess:win 'route this work' >/dev/null 2>"$err"; then fail "fm-send exited zero despite initial tmux send-keys failure" diff --git a/tests/fm-send-strict.test.sh b/tests/fm-send-strict.test.sh new file mode 100644 index 00000000000..707212b5375 --- /dev/null +++ b/tests/fm-send-strict.test.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# fm-send strict target resolution. +# +# A send that cannot be tied to a recorded task/lane or to an explicit +# well-formed backend target must fail loudly. These tests pin the historical +# silent-fallback failures: bare ids, missing FM_HOME, unresolved selectors, +# prefixless herdr pane ids, and the healthy fm- path. +set -u + +# shellcheck source=tests/lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +SEND="$ROOT/bin/fm-send.sh" +TMP_ROOT=$(fm_test_tmproot fm-send-strict) + +make_stubs() { # -> echoes fakebin dir + local dir=$1 fb="$1/fakebin" + mkdir -p "$fb" + cat > "$fb/tmux" <<'SH' +#!/usr/bin/env bash +set -u +case "${1:-}" in + send-keys) + shift + literal=0 + target= + while [ $# -gt 0 ]; do + case "$1" in + -t) target=$2; shift 2 ;; + -l) literal=1; shift ;; + *) break ;; + esac + done + printf 'send-keys target=%s literal=%s arg=%s\n' "$target" "$literal" "${1:-}" >> "$FM_TMUX_LOG" + exit 0 ;; + display-message) + printf '%%1\n' + exit 0 ;; + capture-pane) + printf '\xe2\x94\x82 \xe2\x94\x82\n' + exit 0 ;; + list-windows) + printf 'foreign:%s\n' "${FM_FAKE_TMUX_WINDOW:-fm-lost}" + exit 0 ;; +esac +exit 0 +SH + chmod +x "$fb/tmux" + cat > "$fb/sleep" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$fb/sleep" + printf '%s\n' "$fb" +} + +setup_home() { # -> echoes home dir + local home="$TMP_ROOT/$1-$RANDOM" + mkdir -p "$home/state" + printf '%s\n' "$home" +} + +test_bare_lane_id_fails_with_suggestion() { + local dir fb home err log rc + dir="$TMP_ROOT/bare"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); home=$(setup_home bare); err="$dir/send.err"; log="$dir/tmux.log"; : > "$log" + fm_write_meta "$home/state/mpf-lane-m8.meta" "window=sess:fm-mpf-lane-m8" "kind=ship" + + PATH="$fb:$PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$home" FM_TMUX_LOG="$log" FM_SEND_SETTLE=0 \ + "$SEND" mpf-lane-m8 "lost dispatch" >/dev/null 2>"$err"; rc=$? + [ "$rc" -ne 0 ] || fail "bare lane id should fail" + assert_contains "$(cat "$err")" "did you mean fm-mpf-lane-m8?" "bare id diagnostic should suggest the fm- prefix" + assert_contains "$(cat "$err")" "$home/state/mpf-lane-m8.meta" "bare id diagnostic should name the checked meta path" + [ ! -s "$log" ] || fail "bare lane id fell through to tmux send"$'\n'"$(cat "$log")" + pass "fm-send strict: bare task/lane id fails with a did-you-mean fm- diagnostic" +} + +test_unset_fm_home_fails() { + local dir fb err log rc + dir="$TMP_ROOT/nohome"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); err="$dir/send.err"; log="$dir/tmux.log"; : > "$log" + + env -u FM_HOME PATH="$fb:$PATH" FM_ROOT_OVERRIDE="$dir" FM_TMUX_LOG="$log" FM_SEND_SETTLE=0 \ + "$SEND" sess:win "hello" >/dev/null 2>"$err"; rc=$? + [ "$rc" -ne 0 ] || fail "unset FM_HOME should fail" + assert_contains "$(cat "$err")" "FM_HOME is not set" "unset FM_HOME diagnostic should be explicit" + [ ! -s "$log" ] || fail "unset FM_HOME still attempted a send"$'\n'"$(cat "$log")" + pass "fm-send strict: unset FM_HOME fails before target resolution" +} + +test_unresolvable_target_does_not_tmux_fallback() { + local dir fb home err log rc + dir="$TMP_ROOT/unresolved"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); home=$(setup_home unresolved); err="$dir/send.err"; log="$dir/tmux.log"; : > "$log" + + PATH="$fb:$PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$home" FM_TMUX_LOG="$log" FM_FAKE_TMUX_WINDOW=lost-target FM_SEND_SETTLE=0 \ + "$SEND" lost-target "hello" >/dev/null 2>"$err"; rc=$? + [ "$rc" -ne 0 ] || fail "unresolvable target should fail" + assert_contains "$(cat "$err")" "not resolvable" "unresolvable diagnostic should be loud" + assert_contains "$(cat "$err")" "metadata window/terminal lookup" "unresolvable diagnostic should name the attempted lookup" + assert_contains "$(cat "$err")" "backend=none" "unresolvable diagnostic should name that no backend was assumed" + [ ! -s "$log" ] || fail "unresolvable target fell through to tmux send"$'\n'"$(cat "$log")" + pass "fm-send strict: unresolvable selectors do not fall back to tmux" +} + +test_prefixless_herdr_pane_id_fails() { + local dir fb home err log rc + dir="$TMP_ROOT/herdr-pane"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); home=$(setup_home herdr); err="$dir/send.err"; log="$dir/tmux.log"; : > "$log" + fm_write_meta "$home/state/nudge.meta" \ + "window=default:wB:p2" "backend=herdr" "herdr_session=default" "herdr_pane_id=wB:p2" "kind=ship" + + PATH="$fb:$PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$home" FM_TMUX_LOG="$log" FM_SEND_SETTLE=0 \ + "$SEND" wB:p2 "nudge" >/dev/null 2>"$err"; rc=$? + [ "$rc" -ne 0 ] || fail "prefixless herdr pane id should fail" + assert_contains "$(cat "$err")" "matches herdr_pane_id" "herdr pane diagnostic should name the meta match" + assert_contains "$(cat "$err")" "expected :" "herdr pane diagnostic should show expected shape" + assert_contains "$(cat "$err")" "default:wB:p2" "herdr pane diagnostic should show the canonical target" + [ ! -s "$log" ] || fail "prefixless herdr pane id fell through to tmux send"$'\n'"$(cat "$log")" + pass "fm-send strict: prefixless herdr pane ids are rejected before tmux fallback" +} + +test_healthy_fm_id_send_still_works() { + local dir fb home err log rc got + dir="$TMP_ROOT/healthy"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); home=$(setup_home healthy); err="$dir/send.err"; log="$dir/tmux.log"; : > "$log" + fm_write_meta "$home/state/lane-ok.meta" "window=sess:fm-lane-ok" "kind=ship" "harness=codex" + + PATH="$fb:$PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$home" FM_TMUX_LOG="$log" FM_SEND_SETTLE=0 \ + "$SEND" fm-lane-ok "hello captain" >/dev/null 2>"$err"; rc=$? + expect_code 0 "$rc" "healthy fm-id send should succeed" + got=$(cat "$log") + assert_contains "$got" "target=sess:fm-lane-ok literal=1 arg=hello captain" "healthy send should type literal text to the meta target" + assert_contains "$got" "target=sess:fm-lane-ok literal=0 arg=Enter" "healthy send should submit with Enter" + pass "fm-send strict: healthy fm- sends still type once and submit" +} + +test_bare_lane_id_fails_with_suggestion +test_unset_fm_home_fails +test_unresolvable_target_does_not_tmux_fallback +test_prefixless_herdr_pane_id_fails +test_healthy_fm_id_send_still_works From a3b891b5ebbc34c8244e8bb015153c27c4025924 Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:01:18 -0700 Subject: [PATCH 02/10] no-mistakes(review): Document fm-send FM_HOME contract --- AGENTS.md | 11 ++++++----- bin/fm-promote.sh | 3 ++- docs/architecture.md | 2 +- docs/configuration.md | 5 +++-- docs/herdr-backend.md | 12 ++++++------ docs/orca-backend.md | 2 +- docs/tmux-backend.md | 2 +- docs/zellij-backend.md | 10 +++++----- 8 files changed, 25 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a0eaa70d4e8..78f0cfd57ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,9 +57,10 @@ Never add an agent name as co-author. ## 2. Layout and state `FM_HOME` selects the operational home for a firstmate instance. -When it is unset, the home is this repo root, which is today's behavior. +When it is unset, most scripts use this repo root as the home, which is today's behavior. When it is set, scripts still use their own `bin/` from the repo they live in, but operational dirs come from `$FM_HOME`: `state/`, `data/`, `config/`, and `projects/`. Existing overrides remain compatible: `FM_STATE_OVERRIDE` can still point at a custom state dir, and `FM_ROOT_OVERRIDE` still behaves like the old whole-root override when `FM_HOME` is unset. +`bin/fm-send.sh` is the fail-closed exception: it requires `FM_HOME` to be set so target resolution is always scoped to an explicit firstmate home. Each secondmate gets its own persistent `FM_HOME`, so its local state, backlog, projects, and session lock are isolated from the main firstmate. ``` @@ -181,7 +182,7 @@ Otherwise it prints one line per problem or capability fact; handle each: - `TASKS_AXI: available` - a default-backend capability fact, not a problem; record it silently and use section 10 for backlog mutations. It prints only when `config/backlog-backend` is absent or set to `tasks-axi` and the compatibility probe accepts `tasks-axi --version` as 0.1.1 or newer. When the backend is opted out with `manual`, firstmate hand-edits and bootstrap stays silent here even if compatible `tasks-axi` is installed. -- `NUDGE_SECONDMATES: fm-...` - the secondmate sweep fast-forwarded one or more *running* secondmate homes to firstmate's current version and their instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) actually changed; for each listed `fm-` selector, send a one-line re-read nudge with `bin/fm-send.sh fm- 'firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions.'` so meta resolves the current backend target even when the same session-start run also respawned the secondmate. +- `NUDGE_SECONDMATES: fm-...` - the secondmate sweep fast-forwarded one or more *running* secondmate homes to firstmate's current version and their instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) actually changed; from an active firstmate session with `FM_HOME` exported, send a one-line re-read nudge with `bin/fm-send.sh 'firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions.'` so meta resolves the current backend target even when the same session-start run also respawned the secondmate. This mirrors `/updatefirstmate`'s `nudge-secondmates:` report: it is a gentle steer, never an interruption, and the fast-forward already landed safely. A secondmate that was skipped, already current, or whose advance changed no instructions is not listed and must not be disturbed. - `FMX: X mode on ...` / `FMX: X mode off ...` - bootstrap confirmed or removed the local X-mode poll artifacts; follow section 14 for watcher cadence restart only when a running watcher needs the transition applied immediately. @@ -467,7 +468,7 @@ Read `data/secondmates.md` before dispatching and compare the work request to ea Route by the nature of the task, not just the project name. A project may appear in several `projects:` clone lists, so choose the secondmate whose natural-language scope actually fits the work, such as triage versus feature development. If the resolved project is `local-only`, keep the work with the main firstmate even when a secondmate scope sounds relevant. -If a secondmate's scope fits, steer that secondmate with one concise instruction via `bin/fm-send.sh ''` and let it run the normal lifecycle inside its own home. +If a secondmate's scope fits, steer that secondmate from an active firstmate session with `FM_HOME` exported by sending one concise instruction via `bin/fm-send.sh ''`, and let it run the normal lifecycle inside its own home. The stable `fm-` label printed by lifecycle commands still works, but exact task ids resolve first through this home's `state/.meta`; pass an explicit backend target containing `:` only when intentionally targeting an endpoint outside this firstmate home. `fm-send` is fail-closed: `FM_HOME` must be set, and any target that cannot be resolved through this home's metadata or a well-formed explicit backend target exits non-zero instead of guessing a tmux window. A secondmate is itself a firstmate, so a request reaches it in its own chat, which you never read - the return channel that wakes you is its status file. @@ -545,7 +546,7 @@ Add the task to `data/backlog.md` under In flight. ### Supervise Covered by section 8. -Steer a crewmate only with short single lines via `bin/fm-send.sh`; anything long belongs in a file the crewmate can read. +Steer a crewmate only with short single lines via `bin/fm-send.sh` from an active firstmate session with `FM_HOME` exported; anything long belongs in a file the crewmate can read. Steer a secondmate the same way. Its charter retargets escalation to the main firstmate's status file, so routine internal churn stays inside the secondmate home and only `done`, `blocked`, `needs-decision`, `failed`, or captain-relevant phase changes wake the main firstmate. Because `fm-send` to a `kind=secondmate` target marks the request as from-firstmate (section 7 intake), the secondmate's answer comes back on that status/doc path too, not in its chat; read the response there as an ordinary status signal and do not peek its chat for it. @@ -649,7 +650,7 @@ A scout task follows Intake, Spawn, and Supervise exactly as above - scaffold th - Tear down immediately - no merge gate. `bin/fm-teardown.sh` allows a scout worktree's scratch commits and dirty files once the report exists; if the report is missing, it refuses, because the findings are the work product. - Record it in Done with the report path instead of a PR link using `tasks-axi done` when the default tasks-axi backend is active and compatible, otherwise hand-edit `data/backlog.md` and keep Done to the 10 most recent, then re-evaluate the queue and dispatch only queued work whose blockers are gone and whose time/date gate, if any, has arrived. -**Promotion.** When a scout's findings reveal shippable work (a reproduced bug with a clear fix) and the captain wants it shipped, promote the task in place instead of respawning: run `bin/fm-promote.sh ` (flips `kind=` to ship in meta, restoring teardown's full protection), then send the crewmate its ship instructions - inventory scratch state, reset to a clean default-branch base, carry over only intended fix changes, create branch `fm/`, implement, and report `done` according to the project's delivery mode. +**Promotion.** When a scout's findings reveal shippable work (a reproduced bug with a clear fix) and the captain wants it shipped, promote the task in place instead of respawning: run `bin/fm-promote.sh ` (flips `kind=` to ship in meta, restoring teardown's full protection), then from an active firstmate session with `FM_HOME` exported send the crewmate its ship instructions - inventory scratch state, reset to a clean default-branch base, carry over only intended fix changes, create branch `fm/`, implement, and report `done` according to the project's delivery mode. The crewmate keeps its worktree, loaded context, and repro, but the ship branch must start from a clean base with only intended changes; scratch commits and debug edits from the scout phase never ride along. The repro becomes the regression test. From there the task is an ordinary ship task through its mode-specific validation, PR or local merge, and Teardown. diff --git a/bin/fm-promote.sh b/bin/fm-promote.sh index 5d9555dc311..827c17998f2 100755 --- a/bin/fm-promote.sh +++ b/bin/fm-promote.sh @@ -24,5 +24,6 @@ grep -v '^kind=' "$META" > "$TMP" echo "kind=ship" >> "$TMP" mv "$TMP" "$META" +HOME_Q=$(printf '%q' "$FM_HOME") echo "promoted $ID to ship (teardown protection restored)" -echo "next: bin/fm-send.sh fm-$ID ''" +echo "next: FM_HOME=$HOME_Q bin/fm-send.sh fm-$ID ''" diff --git a/docs/architecture.md b/docs/architecture.md index 64389d6acbd..abec3c9ba21 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -107,7 +107,7 @@ Seeding is transactional: if validation, cloning, initialization, or registry up `local-only` projects stay with the main first mate because they merge into the main local checkout instead of a remote-backed PR path. The same project may appear in multiple secondmate homes when their scopes differ, such as issue triage versus feature development. Secondmates are idle by default: after startup recovery reconciles only work already in their own home, an empty queue waits silently for routed tasks, and they never self-initiate surveys or audits. -Metadata-routed `fm-send.sh` requests to a live `kind=secondmate` are prefixed with the from-firstmate marker from `bin/fm-marker-lib.sh`, so the secondmate returns terse answers through status lines and detailed answers through docs plus status pointers instead of replying only in its own chat. +With `FM_HOME` exported, metadata-routed `fm-send.sh` requests to a live `kind=secondmate` are prefixed with the from-firstmate marker from `bin/fm-marker-lib.sh`, so the secondmate returns terse answers through status lines and detailed answers through docs plus status pointers instead of replying only in its own chat. Explicit backend-target sends and direct human typing stay unmarked, so captain intervention in a secondmate pane remains conversational. After seeding a secondmate, `fm-backlog-handoff.sh` moves already-judged in-scope queued items from the main backlog into that secondmate home so the domain queue starts in the right place. Idle secondmate panes are healthy; teardown is explicit and refuses while the secondmate home has in-flight work unless the captain has approved discard with `--force`. diff --git a/docs/configuration.md b/docs/configuration.md index 49d5869f919..97f7d2303e1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -101,9 +101,10 @@ Set `FM_SECONDMATE_CHARTER` to seed from inline charter text when no filled char ## FM_HOME `FM_HOME` selects the operational home for one firstmate instance. -When it is unset, the repo root is the home; when it is set, scripts still run from this repo's `bin/`, but `state/`, `data/`, `config/`, and `projects/` come from `$FM_HOME`. +When it is unset, most scripts use the repo root as the home; when it is set, scripts still run from this repo's `bin/`, but `state/`, `data/`, `config/`, and `projects/` come from `$FM_HOME`. `FM_ROOT_OVERRIDE` overrides the firstmate repo root used by scripts, including the primary checkout watched by the worktree-tangle guard. When `FM_HOME` is unset, it also behaves as the old whole-root override. +`bin/fm-send.sh` is intentionally stricter than that general fallback: it requires `FM_HOME` to be set before resolving a target, so operator steers cannot silently resolve against the wrong home. `FM_STATE_OVERRIDE`, `FM_DATA_OVERRIDE`, `FM_PROJECTS_OVERRIDE`, and `FM_CONFIG_OVERRIDE` override individual operational directories for tests and specialized harness setup. For the herdr backend, `FM_HOME` also determines the workspace label used by the adapter. For the zellij backend, `FM_HOME` does not split containers, but it determines the readable home prefix embedded in visible tab titles; use `FM_ZELLIJ_SESSION` when a separate zellij session is needed. @@ -225,7 +226,7 @@ These paths need `jq` to build the JSON payload, but they run before token and n Runtime tuning via environment variables (defaults shown): ```sh -FM_HOME= # optional operational home; unset means this repo root +FM_HOME= # optional operational home for most scripts, unset means this repo root; fm-send requires it explicitly FM_ROOT_OVERRIDE= # override firstmate repo root, tangle-guard target, and zellij/cmux home-title hash; also legacy whole-root override when FM_HOME is unset FM_STATE_OVERRIDE= # alternate state dir, mainly for tests FM_DATA_OVERRIDE= # alternate data dir, mainly for tests diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index 130221060fd..d22253ef891 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -31,7 +31,7 @@ No first-run provisioning is needed beyond having `herdr` and `jq` on `PATH`; fi Watching and attaching: each firstmate home gets its own herdr workspace (the primary uses `firstmate`; each secondmate uses `2ndmate-`), with one tab per task inside it, named `fm-`. Attach to the selected `HERDR_SESSION` and switch to the workspace for the home you want to watch to see every one of that home's tasks as tabs in one tab bar. -You do not need to attach for routine supervision: `bin/fm-peek.sh fm-` reads a task's pane without attaching, and `bin/fm-send.sh fm- ""` steers it. +You do not need to attach for routine supervision: from an active firstmate session with `FM_HOME` exported, `bin/fm-peek.sh fm-` reads a task's pane without attaching, and `bin/fm-send.sh fm- ""` steers it. Verify it works by spawning a trivial task with `--backend herdr` and confirming the task's meta records `backend=herdr` plus `herdr_session=`, `herdr_workspace_id=`, `herdr_tab_id=`, and `herdr_pane_id=`; the workspace for your home should show the new `fm-` tab. @@ -232,7 +232,7 @@ This confirms the same hazard tmux already mitigates: submitting immediately aft ## Incident (2026-07-03): a slash command left fully typed but unsubmitted, silently Two grok/herdr crewmates were each sent `/no-mistakes` via `fm-send.sh`. -In both panes the command sat fully typed in the composer, unsubmitted (footer still read `Enter:send`), for minutes, until a manual `fm-send.sh --key Enter` landed it instantly. +In both panes the command sat fully typed in the composer, unsubmitted (footer still read `Enter:send`), for minutes, until a manual `FM_HOME= fm-send.sh --key Enter` landed it instantly. `fm-send.sh` had exited 0 both times - no failure surfaced to the caller. Root cause, reproduced live against real grok 0.2.82 on an isolated herdr session: the send-text-submit verification at the time used the old delta-based strategy and declared success whenever the captured pane content changed AT ALL between before and after an Enter. @@ -320,10 +320,10 @@ Unlike tmux's probe, herdr's has no equivalent "which harness is running under a Beyond the fake-CLI unit tests (`tests/fm-backend-herdr.test.sh`) and the real-CLI smoke tests (`tests/fm-backend-herdr-smoke.test.sh` and `tests/fm-backend-autodetect-smoke.test.sh`), the full firstmate lifecycle was driven end to end against a real `claude` crewmate through this branch's own scripts, in a scratch `FM_HOME`, a scratch `local-only` git project, and an isolated `HERDR_SESSION`: 1. `FM_HOME= FM_BACKEND=herdr HERDR_SESSION= bin/fm-spawn.sh herdr-e2e-t1 projects/scratch-e2e-project claude` - spawned successfully, printing `backend=herdr` in the summary and writing `herdr_session=`/`herdr_workspace_id=`/`herdr_tab_id=`/`herdr_pane_id=` to the task's meta. -2. `bin/fm-peek.sh fm-herdr-e2e-t1` - showed the live claude trust dialog. -3. `bin/fm-send.sh fm-herdr-e2e-t1 --key Enter` - accepted the trust dialog. -4. `bin/fm-peek.sh fm-herdr-e2e-t1` again - showed claude actively working through the brief (creating the branch, writing the file). -5. `bin/fm-send.sh fm-herdr-e2e-t1 "captain says: proceed as planned"` - a plain-text steer, exercising the send-and-verify path; the text appeared correctly in the pane. +2. `FM_HOME= HERDR_SESSION= bin/fm-peek.sh fm-herdr-e2e-t1` - showed the live claude trust dialog. +3. `FM_HOME= HERDR_SESSION= bin/fm-send.sh fm-herdr-e2e-t1 --key Enter` - accepted the trust dialog. +4. `FM_HOME= HERDR_SESSION= bin/fm-peek.sh fm-herdr-e2e-t1` again - showed claude actively working through the brief (creating the branch, writing the file). +5. `FM_HOME= HERDR_SESSION= bin/fm-send.sh fm-herdr-e2e-t1 "captain says: proceed as planned"` - a plain-text steer, exercising the send-and-verify path; the text appeared correctly in the pane. 6. The crewmate appended `done: hello.txt committed on fm/herdr-e2e-t1` to its status file, and its commit (`add hello.txt` on branch `fm/herdr-e2e-t1`) was confirmed present in the project's git history. 7. `bin/fm-teardown.sh herdr-e2e-t1` **REFUSED**, exactly as required: `REFUSED: local-only worktree ... has work not yet merged into main and not on any remote.` 8. `bin/fm-merge-local.sh herdr-e2e-t1` - fast-forwarded local `main` to the crewmate's commit. diff --git a/docs/orca-backend.md b/docs/orca-backend.md index 5c68473db94..65f505b9462 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -25,7 +25,7 @@ Spawn fails closed if the runtime is not ready. The first spawn against a given project also auto-registers that project's repo in Orca (`orca repo add --path`) if it is not already registered - no manual registration step is needed. Watching and attaching: Orca owns both the worktree and the terminal for its tasks, so there is nothing to attach to outside the Orca app itself - open the app and find the terminal for the task (recorded as `terminal=` in the task's meta, with `window=fm-` as the shared firstmate alias). -You do not need to open the app for routine supervision: `bin/fm-peek.sh ` reads a task's terminal without opening Orca, and `bin/fm-send.sh ""` steers it (the stable `fm-` alias also works; Enter and Ctrl-C are supported; Escape is not). +You do not need to open the app for routine supervision: from an active firstmate session with `FM_HOME` exported, `bin/fm-peek.sh ` reads a task's terminal without opening Orca, and `bin/fm-send.sh ""` steers it (the stable `fm-` alias also works; Enter and Ctrl-C are supported; Escape is not). Verify it works by spawning a trivial task with `--backend orca` and confirming the task's meta records `backend=orca`, `terminal=`, `orca_worktree_id=`, and `worktree=`; the Orca app should show a new terminal for the task. diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index f052d0e1181..01e0a23a93c 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -58,7 +58,7 @@ tmux select-window -t :fm- # jump to one, or use ctrl-b Use the current tmux session name when firstmate was launched inside tmux; use `firstmate` only for the detached outside-tmux path. Typing directly into an attached window is authoritative direct intervention - the first mate treats it the same as any other captain instruction and reconciles at the next heartbeat. -You do not need to attach at all for routine supervision: the first mate reads crew windows itself with `bin/fm-peek.sh fm-` (a bounded, read-only capture) and steers a crew with `bin/fm-send.sh fm- ""` when it needs to intervene. +You do not need to attach at all for routine supervision: from an active firstmate session with `FM_HOME` exported, the first mate reads crew windows itself with `bin/fm-peek.sh fm-` (a bounded, read-only capture) and steers a crew with `bin/fm-send.sh fm- ""` when it needs to intervene. ## Verifying it works diff --git a/docs/zellij-backend.md b/docs/zellij-backend.md index 88779f2e8f2..4471c830c4d 100644 --- a/docs/zellij-backend.md +++ b/docs/zellij-backend.md @@ -27,7 +27,7 @@ No first-run provisioning is needed beyond having `zellij` and `jq` on `PATH`; f Watching and attaching: firstmate uses one shared session (default name `firstmate`, overridable with `FM_ZELLIJ_SESSION`) with one tab per task. The tab's caller-facing label is always `fm-`, but its actual visible title is home-scoped - `fm--`, e.g. `fm-firstmate-a1b2c3d4-fix-login-k3` - so that two firstmate homes sharing this one session (a primary plus a secondmate, two secondmates, or two independent primary installations on the same machine) never collide on the tab bar even if their task ids happen to match; see "Home-scoped tab titles" below. Attach to the selected `FM_ZELLIJ_SESSION` (or the default `firstmate` session) with `zellij attach ` to see every task, primary or secondmate, as a tab in that one tab bar. -You do not need to attach for routine supervision: `bin/fm-peek.sh fm-` reads a task's pane without attaching, and `bin/fm-send.sh fm- ""` steers it. +You do not need to attach for routine supervision: from an active firstmate session with `FM_HOME` exported, `bin/fm-peek.sh fm-` reads a task's pane without attaching, and `bin/fm-send.sh fm- ""` steers it. Verify it works by spawning a trivial task with `--backend zellij` and confirming the task's meta records `backend=zellij` plus `zellij_session=`, `zellij_tab_id=`, and `zellij_pane_id=`; attaching to the session should show the new home-scoped tab title, such as `fm-firstmate-<8hex>-`. @@ -199,10 +199,10 @@ Every real-zellij test in this document and its accompanying test files uses a u Beyond the fake-CLI unit tests (`tests/fm-backend-zellij.test.sh`) and the real-CLI smoke tests (`tests/fm-backend-zellij-smoke.test.sh`), the full firstmate lifecycle was driven end to end against a real `claude` crewmate through this branch's own scripts, in a scratch `FM_HOME`, a scratch `local-only` git project, and an isolated `FM_ZELLIJ_SESSION` (never the real `firstmate` session name): 1. `FM_HOME= FM_BACKEND=zellij FM_ZELLIJ_SESSION= bin/fm-spawn.sh zellij-e2e-t1 projects/scratch-e2e-project claude` - spawned successfully, printing `window=:` in the summary and writing `backend=zellij`, `zellij_session=`, `zellij_tab_id=`, `zellij_pane_id=` to the task's meta. The worktree-discovery poll correctly resolved the real treehouse worktree path using the active `pwd`-probe workaround. -2. `bin/fm-peek.sh fm-zellij-e2e-t1` - showed the live claude trust dialog ("Quick safety check: Is this a project you created or one you trust?"). -3. `bin/fm-send.sh fm-zellij-e2e-t1 --key Enter` - accepted the trust dialog. -4. `bin/fm-peek.sh fm-zellij-e2e-t1` again - showed claude actively working through the brief (verifying isolation, then implementing). -5. `bin/fm-send.sh fm-zellij-e2e-t1 "captain says: proceed as planned, this is a trivial verification task"` - a plain-text steer while claude was mid-turn, exercising the delta-based send-and-verify path; the send completed without a `pending`/`send-failed` error. +2. `FM_HOME= FM_ZELLIJ_SESSION= bin/fm-peek.sh fm-zellij-e2e-t1` - showed the live claude trust dialog ("Quick safety check: Is this a project you created or one you trust?"). +3. `FM_HOME= FM_ZELLIJ_SESSION= bin/fm-send.sh fm-zellij-e2e-t1 --key Enter` - accepted the trust dialog. +4. `FM_HOME= FM_ZELLIJ_SESSION= bin/fm-peek.sh fm-zellij-e2e-t1` again - showed claude actively working through the brief (verifying isolation, then implementing). +5. `FM_HOME= FM_ZELLIJ_SESSION= bin/fm-send.sh fm-zellij-e2e-t1 "captain says: proceed as planned, this is a trivial verification task"` - a plain-text steer while claude was mid-turn, exercising the delta-based send-and-verify path; the send completed without a `pending`/`send-failed` error. 6. The crewmate appended `done: ready in branch fm/zellij-e2e-t1` to its status file, and its commit (`add hello.txt`, message `add hello.txt`) was confirmed present on branch `fm/zellij-e2e-t1` in the project's git history, with `hello.txt` containing exactly the expected line. 7. `bin/fm-teardown.sh zellij-e2e-t1` **REFUSED**, exactly as required: `REFUSED: local-only worktree ... has work not yet merged into main and not on any remote.` 8. `bin/fm-merge-local.sh zellij-e2e-t1` - fast-forwarded local `main` to the crewmate's commit (`02c9dd2 -> ba41f90`). From 2841489dcc7b9beaf457bc96ee5d55eede12c8a6 Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:08:33 -0700 Subject: [PATCH 03/10] Fix fm-send readiness docs and backend send path --- .agents/skills/harness-adapters/SKILL.md | 2 +- .agents/skills/stuck-crewmate-recovery/SKILL.md | 4 ++-- .agents/skills/updatefirstmate/SKILL.md | 3 ++- bin/fm-send.sh | 13 +++++-------- tests/fm-backend-orca.test.sh | 7 +++---- 5 files changed, 13 insertions(+), 16 deletions(-) diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index 89d9246d30c..fc15702ea7b 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -93,7 +93,7 @@ Natural language is acceptable if uncertain. First launch in a fresh worktree, or first ever on a machine, may show a trust or bypass-permissions confirmation. After every spawn, peek the pane within about 20 seconds. -If such a dialog is showing, accept it with `bin/fm-send.sh --key Enter`, or the choice the dialog requires, and verify the brief started processing. +If such a dialog is showing, accept it from an active firstmate session with `FM_HOME` exported using `bin/fm-send.sh --key Enter`, or the choice the dialog requires, and verify the brief started processing. Claude renders a predicted-next-prompt suggestion as dim/faint text inside an otherwise-empty composer after a turn completes. A plain `tmux capture-pane` cannot tell that ghost text apart from typed text. diff --git a/.agents/skills/stuck-crewmate-recovery/SKILL.md b/.agents/skills/stuck-crewmate-recovery/SKILL.md index dcf0be5b3f0..c45513ba841 100644 --- a/.agents/skills/stuck-crewmate-recovery/SKILL.md +++ b/.agents/skills/stuck-crewmate-recovery/SKILL.md @@ -16,9 +16,9 @@ The target window's harness is recorded as `harness=` in `state/.meta`. Escalate in order: 1. Peek the pane. -2. If the crewmate is waiting on a question its brief already answers, answer in one line via `bin/fm-send.sh`. +2. If the crewmate is waiting on a question its brief already answers, answer in one line via `bin/fm-send.sh` from an active firstmate session with `FM_HOME` exported. 3. If the crewmate is confused or looping, interrupt with the adapter's interrupt key, then redirect with one corrective line. - For example, for a single-Escape adapter: `bin/fm-send.sh --key Escape`. + For example, for a single-Escape adapter: `FM_HOME= bin/fm-send.sh --key Escape`. 4. If the crewmate is genuinely wedged after redirection, exit the agent with the adapter's exit command and relaunch with the same brief plus a `progress so far` note appended to it. Genuine wedging means looping, unresponsive, repeating the same obstacle, or truly dead. A low context reading is not wedging; modern harnesses auto-compact and keep going. diff --git a/.agents/skills/updatefirstmate/SKILL.md b/.agents/skills/updatefirstmate/SKILL.md index 6ab4b6456a7..5f91bcf2e78 100644 --- a/.agents/skills/updatefirstmate/SKILL.md +++ b/.agents/skills/updatefirstmate/SKILL.md @@ -37,8 +37,9 @@ This touches only the firstmate repo and its own worktrees, never anything under 3. **Nudge each updated live secondmate.** For every target listed on the `nudge-secondmates:` line (do nothing when it says `none`), send a one-line re-read nudge so that secondmate picks up its new instructions too: ```sh - bin/fm-send.sh fm- 'firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions.' + FM_HOME= bin/fm-send.sh 'firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions.' ``` + In a normal active firstmate session, `FM_HOME` is already exported; include it explicitly when copying the command elsewhere. This is a gentle steer, not an interruption: the secondmate already got a safe tracked-files fast-forward, and the nudge never forces, tears down, or discards its work. A secondmate that was skipped, already current, or has no live metadata is not on the list and needs no nudge. diff --git a/bin/fm-send.sh b/bin/fm-send.sh index 4afaea83339..71c330e1379 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -162,14 +162,11 @@ fi # unknown and treated as non-codex (the safe default that keeps the fast path). # The target's BACKEND comes from selector meta, from matching an explicit target # back to recorded meta, or from strict explicit-target shape validation. -case "$RAW_TARGET" in - fm-*) - if ! fm_backend_target_exists "$TARGET_BACKEND" "$T" "$EXPECTED_LABEL"; then - echo "error: resolved target '$RAW_TARGET' to '$T' via $TARGET_META, but backend endpoint is not live (tried $RESOLUTION_TRIED; backend=$TARGET_BACKEND)" >&2 - exit 1 - fi - ;; -esac +# Do not add a separate passive liveness preflight here. Active send paths own +# backend readiness: herdr, for example, must route through its session-aware +# target_ready path before sending, while zellij verifies pane labels in its +# send implementation. A failed backend send is still surfaced below as a hard +# error with the attempted resolution attached. if [ "${1:-}" = "--key" ]; then if ! fm_backend_send_key "$TARGET_BACKEND" "$T" "$2" "$EXPECTED_LABEL"; then diff --git a/tests/fm-backend-orca.test.sh b/tests/fm-backend-orca.test.sh index bad9a389018..d9d89ab3122 100755 --- a/tests/fm-backend-orca.test.sh +++ b/tests/fm-backend-orca.test.sh @@ -698,14 +698,13 @@ test_peek_send_and_crew_state_route_through_orca_meta() { FM_ROOT_OVERRIDE="$neutral" FM_STATE_OVERRIDE="$state" FM_SEND_SETTLE=0 \ "$ROOT/bin/fm-peek.sh" "fm-$id" 10 ) [ "$out" = ready ] || fail "fm-peek should read through Orca metadata, got '$out'" - printf '{"ok":true,"result":{"terminal":{"tail":["ready"]}}}\n' > "$RESP/2.out" + printf '{"ok":true,"result":{"send":{"handle":"term-io","accepted":true}}}\n' > "$RESP/2.out" printf '{"ok":true,"result":{"send":{"handle":"term-io","accepted":true}}}\n' > "$RESP/3.out" - printf '{"ok":true,"result":{"send":{"handle":"term-io","accepted":true}}}\n' > "$RESP/4.out" - printf '{"ok":true,"result":{"terminal":{"tail":["│ > │"]}}}\n' > "$RESP/5.out" + printf '{"ok":true,"result":{"terminal":{"tail":["│ > │"]}}}\n' > "$RESP/4.out" PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ FM_ROOT_OVERRIDE="$neutral" FM_HOME="$neutral" FM_STATE_OVERRIDE="$state" FM_SEND_SETTLE=0 \ "$ROOT/bin/fm-send.sh" "fm-$id" "hello orca" - printf '{"ok":true,"result":{"terminal":{"tail":["idle prompt"]}}}\n' > "$RESP/6.out" + printf '{"ok":true,"result":{"terminal":{"tail":["idle prompt"]}}}\n' > "$RESP/5.out" out=$( PATH="$FB:$PATH" FM_ORCA_LOG="$LOG" FM_ORCA_RESPONSES="$RESP" \ FM_ROOT_OVERRIDE="$ROOT" FM_STATE_OVERRIDE="$state" "$ROOT/bin/fm-crew-state.sh" "$id" ) assert_contains "$out" "state: unknown" "crew-state should fall back cleanly for an idle Orca scout" From 6bd0abe25086e86f693f36e07fb57d0530040698 Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:18:32 -0700 Subject: [PATCH 04/10] Fix fm-send docs for cmux and X skill metadata --- docs/cmux-backend.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cmux-backend.md b/docs/cmux-backend.md index 9c059aed41c..7881f0bfe5a 100644 --- a/docs/cmux-backend.md +++ b/docs/cmux-backend.md @@ -56,7 +56,7 @@ No first-run provisioning beyond the socket-access setup above and having `jq` i Watching and attaching: firstmate uses one workspace per task in whatever cmux window is currently open. Callers can use exact task ids or stable `fm-` labels, while the actual cmux workspace title is home-scoped as `fm--`, for example `fm-firstmate-<8hex>-cmux-e2e-t1` in the primary home or `fm-2ndmate--<8hex>-cmux-e2e-t1` in a secondmate home. -You do not need to bring the window forward for routine supervision: `bin/fm-peek.sh ` reads a task's surface without focusing it, and `bin/fm-send.sh ""` steers it - workspace/surface/pane creation all default `focus` to `false`, so an unattended spawn never steals your view. +You do not need to bring the window forward for routine supervision: from an active firstmate session with `FM_HOME` exported, `bin/fm-peek.sh ` reads a task's surface without focusing it, and `bin/fm-send.sh ""` steers it - workspace/surface/pane creation all default `focus` to `false`, so an unattended spawn never steals your view. Verify it works by spawning a trivial task with `--backend cmux` and confirming the task's meta records `backend=cmux` plus `cmux_workspace_id=` and `cmux_surface_id=`. The cmux sidebar should show a new `fm-firstmate-<8hex>-` workspace in the primary home. @@ -284,10 +284,10 @@ Beyond the fake-CLI unit tests (`tests/fm-backend-cmux.test.sh`) and the real-CL 1. `FM_HOME= bin/fm-spawn.sh cmux-e2e-t1 projects/scratch-e2e-project --backend cmux claude` - spawned successfully, printing `window=:` in the summary and writing `backend=cmux`, `cmux_workspace_id=`, `cmux_surface_id=` to the task's meta. The worktree-discovery poll correctly resolved the real treehouse worktree path using the active `pwd`-marker-probe workaround (finding #2), exactly as designed. 2. `bin/fm-peek.sh fm-cmux-e2e-t1` - showed the live claude trust dialog ("Quick safety check: Is this a project you created or one you trust?"). -3. `bin/fm-send.sh fm-cmux-e2e-t1 --key Enter` - accepted the trust dialog; `send_key`'s Escape/Enter path confirmed live against the real claude TUI, not just a plain shell. +3. `FM_HOME= bin/fm-send.sh fm-cmux-e2e-t1 --key Enter` - accepted the trust dialog; `send_key`'s Escape/Enter path confirmed live against the real claude TUI, not just a plain shell. 4. `bin/fm-peek.sh fm-cmux-e2e-t1` again - showed claude actively working through the brief (confirming worktree isolation, writing `hello.txt`, committing). -5. `bin/fm-send.sh fm-cmux-e2e-t1 "captain says: proceed as planned, this is a trivial verification task"` - a plain-text steer sent after the crewmate had already finished and stopped; `fm-send` reported no `pending`/`send-failed` error, and the message was confirmed landed and acknowledged in the next peek. This is the first live proof of `fm_backend_cmux_composer_state`'s structural border-row classifier against a REAL claude TUI composer box (every Phase 1 empirical test used a plain shell prompt, which has no bordered composer at all). -6. `bin/fm-send.sh fm-cmux-e2e-t1 "/compact"` - the popup-placeholder/second-Enter regression class, tested live: `fm-send` reported success with no error, and the next peek confirmed `/compact` had genuinely EXECUTED ("Compacting conversation... 25%", later "Compacted"), not merely sat typed-but-unsubmitted in the composer. This directly confirms `fm_backend_cmux_send_text_submit` correctly retries past a popup-closing first Enter and lands a genuine second Enter against the real app, the same incident class herdr hit on 2026-07-03. +5. `FM_HOME= bin/fm-send.sh fm-cmux-e2e-t1 "captain says: proceed as planned, this is a trivial verification task"` - a plain-text steer sent after the crewmate had already finished and stopped; `fm-send` reported no `pending`/`send-failed` error, and the message was confirmed landed and acknowledged in the next peek. This is the first live proof of `fm_backend_cmux_composer_state`'s structural border-row classifier against a REAL claude TUI composer box (every Phase 1 empirical test used a plain shell prompt, which has no bordered composer at all). +6. `FM_HOME= bin/fm-send.sh fm-cmux-e2e-t1 "/compact"` - the popup-placeholder/second-Enter regression class, tested live: `fm-send` reported success with no error, and the next peek confirmed `/compact` had genuinely EXECUTED ("Compacting conversation... 25%", later "Compacted"), not merely sat typed-but-unsubmitted in the composer. This directly confirms `fm_backend_cmux_send_text_submit` correctly retries past a popup-closing first Enter and lands a genuine second Enter against the real app, the same incident class herdr hit on 2026-07-03. 7. The crewmate's commit (`add hello.txt`, message `add hello.txt`) was confirmed present on branch `fm/cmux-e2e-t1` in the scratch project's git history, with `hello.txt` containing exactly the expected line, and the status file ending in `done: ready in branch fm/cmux-e2e-t1`. 8. `bin/fm-teardown.sh cmux-e2e-t1` **REFUSED**, exactly as required: `REFUSED: local-only worktree ... has work not yet merged into main and not on any remote.` 9. `bin/fm-merge-local.sh cmux-e2e-t1` - fast-forwarded the scratch project's local `main` to the crewmate's commit (`e99f00a -> f064d41`). From 6feb1aa9c98d6c0af9ec633c2a1e23048294ae03 Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:34:25 -0700 Subject: [PATCH 05/10] Make gotmp teardown test home-explicit --- tests/fm-gotmp.test.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/fm-gotmp.test.sh b/tests/fm-gotmp.test.sh index 2a2404bc360..68c96cdae64 100755 --- a/tests/fm-gotmp.test.sh +++ b/tests/fm-gotmp.test.sh @@ -6,7 +6,7 @@ # meta. fm-teardown reads tasktmp= and removes the whole root on cleanup. # # These tests exercise behavior directly: fm-teardown is run as a subprocess against a -# fake FM_ROOT (built so the real script resolves into it), with stub helper scripts. +# fake FM_HOME/FM_ROOT (built so the real script resolves into it), with stub helper scripts. # Nothing is sourced. The fm-spawn side is verified both structurally (the source has # the contract lines) and behaviorally (the mkdir + meta-write pattern it uses). set -u @@ -35,8 +35,8 @@ trap cleanup EXIT TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-gotmp-tests.XXXXXX") -# Build a fake FM_ROOT so the real fm-teardown.sh (symlinked in) resolves FM_ROOT to -# it via its BASH_SOURCE computation. Stub the helper scripts fm-teardown calls so no +# Build a fake FM_HOME/FM_ROOT so the real fm-teardown.sh (symlinked in) resolves +# state and helper scripts inside it. Stub the helper scripts fm-teardown calls so no # live tmux/treehouse/fleet state is touched. A nonexistent worktree path makes both # `if [ -d "$WT" ]` guards skip, so teardown runs straight to the cleanup + state rm. make_fake_root() { @@ -132,7 +132,7 @@ test_teardown_removes_tasktmp_dir() { # Sanity: dir + contents exist before teardown. [ -d "$task_tmp/gotmp" ] || fail "precondition: gotmp missing before teardown" # Run the REAL teardown against the fake root. - bash "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>&1 \ + FM_HOME="$fake" bash "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>&1 \ || fail "teardown exited non-zero with a valid tasktmp" [ ! -e "$task_tmp" ] \ || fail "teardown did not remove the tasktmp dir ($task_tmp still exists)" @@ -173,7 +173,7 @@ kind=ship mode=no-mistakes yolo=off META - bash "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>&1 \ + FM_HOME="$fake" bash "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>&1 \ || fail "teardown exited non-zero when tasktmp= was absent" pass "fm-teardown skips gracefully when tasktmp= is absent (backward compat)" } @@ -186,7 +186,7 @@ test_teardown_skips_gracefully_when_dir_missing() { [ ! -e "$task_tmp" ] || fail "precondition: task_tmp should not exist yet" local fake fake=$(make_fake_root "$id" "$task_tmp") - bash "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>&1 \ + FM_HOME="$fake" bash "$fake/bin/fm-teardown.sh" "$id" >/dev/null 2>&1 \ || fail "teardown exited non-zero when tasktmp dir was missing" [ ! -e "$task_tmp" ] || fail "teardown created/left the tasktmp dir unexpectedly" pass "fm-teardown skips gracefully when tasktmp= points to a nonexistent dir" From cffdd75a533ae62c1069a2bcf196eb626a844487 Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sat, 4 Jul 2026 12:50:40 -0700 Subject: [PATCH 06/10] Scope watcher warning wording to fm-send --- AGENTS.md | 3 ++- bin/fm-guard.sh | 3 ++- bin/fm-send.sh | 2 +- tests/fm-send-strict.test.sh | 1 + tests/fm-watcher-lock.test.sh | 2 ++ 5 files changed, 8 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 78f0cfd57ca..53ab485e404 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -739,7 +739,8 @@ While running, `fm-watch.sh` touches `state/.last-watcher-beat` every poll cycle The supervision scripts (`fm-peek`, `fm-send`, `fm-spawn`, `fm-teardown`, `fm-pr-check`, `fm-promote`, `fm-review-diff`, `fm-fleet-sync`, `fm-update`) call `bin/fm-guard.sh` first, which warns to stderr when any task is in flight (`state/*.meta` exists) but queued wakes are pending, or that beacon is missing or older than `FM_GUARD_GRACE` (default 300s). `bin/fm-wake-drain.sh` runs the same guard after it drains, so the liveness check also fires on a drain-and-handle turn that runs no other supervision script, narrowing the window in which a lapsed chain can hide; the grace beacon keeps it silent right after a normal fire and it warns only on a genuine stale-beyond-grace lapse. The no-watcher case leads with a prominent, bordered ●-marked banner (in-flight count, beacon age, and the exact one-line re-arm command) so it reads as an alarm rather than a buried stderr line you can skim past. -The banner is only a supervision warning: the guarded operation still runs, and the text says explicitly that a requested message WILL still be sent. +The banner is only a supervision warning: the guarded operation still runs. +When the guarded operation is `fm-send`, `fm-send` sets the banner's continuation line to say explicitly that the requested message WILL still be sent. So the next time you touch the fleet with queued wakes or no watcher alive, the tool output itself tells you what to do - a pull-based guard that works on any harness, since it rides the script output you already read rather than a harness-specific hook. The grace window keeps normal handling (watcher briefly down between a wake and its re-arm) silent. If a guard warning says queued wakes are pending, drain them before doing anything else. diff --git a/bin/fm-guard.sh b/bin/fm-guard.sh index e8db2d9354d..e3cd8341af4 100755 --- a/bin/fm-guard.sh +++ b/bin/fm-guard.sh @@ -22,6 +22,7 @@ GRACE=${FM_GUARD_GRACE:-300} queue_pending=false READ_ONLY=${FM_GUARD_READ_ONLY:-0} case "$READ_ONLY" in 1|true|TRUE|yes|YES) READ_ONLY=1 ;; *) READ_ONLY=0 ;; esac +CONTINUE_LINE=${FM_GUARD_CONTINUE_LINE:-This is a supervision warning only; the guarded operation WILL still run.} # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" @@ -89,7 +90,7 @@ if [ "$watcher_fresh" = false ]; then else printf '● Trust bin/fm-watch-arm.sh for the true state: it confirms a live watcher and a fresh beacon, or fails loudly.\n' fi - printf '● This is a supervision warning only; the requested message WILL still be sent.\n' + printf '● %s\n' "$CONTINUE_LINE" printf '● %s\n' "$fix" printf '●%s\n' "$rule" } >&2 diff --git a/bin/fm-send.sh b/bin/fm-send.sh index 71c330e1379..6e882632526 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -58,7 +58,7 @@ fi # shellcheck source=bin/fm-marker-lib.sh . "$SCRIPT_DIR/fm-marker-lib.sh" -"$SCRIPT_DIR/fm-guard.sh" || true +FM_GUARD_CONTINUE_LINE='This is a supervision warning only; the requested message WILL still be sent.' "$SCRIPT_DIR/fm-guard.sh" || true fm_send_id_from_meta() { # local base diff --git a/tests/fm-send-strict.test.sh b/tests/fm-send-strict.test.sh index 707212b5375..03cfba9fce7 100644 --- a/tests/fm-send-strict.test.sh +++ b/tests/fm-send-strict.test.sh @@ -132,6 +132,7 @@ test_healthy_fm_id_send_still_works() { got=$(cat "$log") assert_contains "$got" "target=sess:fm-lane-ok literal=1 arg=hello captain" "healthy send should type literal text to the meta target" assert_contains "$got" "target=sess:fm-lane-ok literal=0 arg=Enter" "healthy send should submit with Enter" + assert_contains "$(cat "$err")" "requested message WILL still be sent" "fm-send guard banner should keep send-specific continuation wording" pass "fm-send strict: healthy fm- sends still type once and submit" } diff --git a/tests/fm-watcher-lock.test.sh b/tests/fm-watcher-lock.test.sh index 26d847df71d..177b41f8e89 100755 --- a/tests/fm-watcher-lock.test.sh +++ b/tests/fm-watcher-lock.test.sh @@ -121,6 +121,8 @@ test_guard_warnings() { grep -F 'WATCHER DOWN - SUPERVISION IS OFF' "$err" >/dev/null || fail "guard banner missing the alarm title" grep -F '2 task(s) in flight' "$err" >/dev/null || fail "guard banner missing the in-flight count" grep -F 'last beat: never' "$err" >/dev/null || fail "guard banner missing the beacon age" + grep -F 'guarded operation WILL still run' "$err" >/dev/null || fail "guard banner missing generic continuation wording" + ! grep -F 'requested message WILL still be sent' "$err" >/dev/null || fail "shared guard used send-specific continuation wording" grep -F 'bin/fm-watch-arm.sh' "$err" >/dev/null || fail "guard banner missing the fix command" grep -F 'queued wakes pending - drain them' "$err" >/dev/null || fail "guard did not warn about pending queue" grep -F 'After draining queued wakes, re-arm the watcher' "$err" >/dev/null || fail "guard did not order re-arm after drain" From fe9316ed56d4eda5707c673f702468dd48fef55d Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:02:05 -0700 Subject: [PATCH 07/10] Fix fm-send review findings --- .agents/skills/harness-adapters/SKILL.md | 2 +- .agents/skills/stuck-crewmate-recovery/SKILL.md | 2 +- .agents/skills/updatefirstmate/SKILL.md | 2 +- AGENTS.md | 11 ++++++----- docs/architecture.md | 2 +- docs/cmux-backend.md | 2 +- docs/herdr-backend.md | 2 +- docs/orca-backend.md | 2 +- docs/tmux-backend.md | 2 +- docs/zellij-backend.md | 2 +- tests/fm-send-strict.test.sh | 0 11 files changed, 15 insertions(+), 14 deletions(-) mode change 100644 => 100755 tests/fm-send-strict.test.sh diff --git a/.agents/skills/harness-adapters/SKILL.md b/.agents/skills/harness-adapters/SKILL.md index fc15702ea7b..bff9f4c0f95 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -93,7 +93,7 @@ Natural language is acceptable if uncertain. First launch in a fresh worktree, or first ever on a machine, may show a trust or bypass-permissions confirmation. After every spawn, peek the pane within about 20 seconds. -If such a dialog is showing, accept it from an active firstmate session with `FM_HOME` exported using `bin/fm-send.sh --key Enter`, or the choice the dialog requires, and verify the brief started processing. +If such a dialog is showing, accept it from an active firstmate session using `FM_HOME= bin/fm-send.sh --key Enter`, or the choice the dialog requires, unless `FM_HOME` is already set to the active firstmate home; verify the brief started processing. Claude renders a predicted-next-prompt suggestion as dim/faint text inside an otherwise-empty composer after a turn completes. A plain `tmux capture-pane` cannot tell that ghost text apart from typed text. diff --git a/.agents/skills/stuck-crewmate-recovery/SKILL.md b/.agents/skills/stuck-crewmate-recovery/SKILL.md index c45513ba841..2204c6bb7ef 100644 --- a/.agents/skills/stuck-crewmate-recovery/SKILL.md +++ b/.agents/skills/stuck-crewmate-recovery/SKILL.md @@ -16,7 +16,7 @@ The target window's harness is recorded as `harness=` in `state/.meta`. Escalate in order: 1. Peek the pane. -2. If the crewmate is waiting on a question its brief already answers, answer in one line via `bin/fm-send.sh` from an active firstmate session with `FM_HOME` exported. +2. If the crewmate is waiting on a question its brief already answers, answer in one line via `FM_HOME= bin/fm-send.sh` from an active firstmate session unless `FM_HOME` is already set to the active firstmate home. 3. If the crewmate is confused or looping, interrupt with the adapter's interrupt key, then redirect with one corrective line. For example, for a single-Escape adapter: `FM_HOME= bin/fm-send.sh --key Escape`. 4. If the crewmate is genuinely wedged after redirection, exit the agent with the adapter's exit command and relaunch with the same brief plus a `progress so far` note appended to it. diff --git a/.agents/skills/updatefirstmate/SKILL.md b/.agents/skills/updatefirstmate/SKILL.md index 5f91bcf2e78..015bc13502e 100644 --- a/.agents/skills/updatefirstmate/SKILL.md +++ b/.agents/skills/updatefirstmate/SKILL.md @@ -39,7 +39,7 @@ This touches only the firstmate repo and its own worktrees, never anything under ```sh FM_HOME= bin/fm-send.sh 'firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions.' ``` - In a normal active firstmate session, `FM_HOME` is already exported; include it explicitly when copying the command elsewhere. + Include `FM_HOME=` unless `FM_HOME` is already set to the active firstmate home. This is a gentle steer, not an interruption: the secondmate already got a safe tracked-files fast-forward, and the nudge never forces, tears down, or discards its work. A secondmate that was skipped, already current, or has no live metadata is not on the list and needs no nudge. diff --git a/AGENTS.md b/AGENTS.md index 53ab485e404..84c0891bd04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,8 +181,9 @@ Otherwise it prints one line per problem or capability fact; handle each: Treat `already-live` and `respawned` as handled; investigate `skipped` or `respawn failed` because that secondmate is not guaranteed live. - `TASKS_AXI: available` - a default-backend capability fact, not a problem; record it silently and use section 10 for backlog mutations. It prints only when `config/backlog-backend` is absent or set to `tasks-axi` and the compatibility probe accepts `tasks-axi --version` as 0.1.1 or newer. - When the backend is opted out with `manual`, firstmate hand-edits and bootstrap stays silent here even if compatible `tasks-axi` is installed. -- `NUDGE_SECONDMATES: fm-...` - the secondmate sweep fast-forwarded one or more *running* secondmate homes to firstmate's current version and their instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) actually changed; from an active firstmate session with `FM_HOME` exported, send a one-line re-read nudge with `bin/fm-send.sh 'firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions.'` so meta resolves the current backend target even when the same session-start run also respawned the secondmate. + If the backend is not opted out and `tasks-axi` is missing or incompatible, bootstrap reports `MISSING: tasks-axi (install: npm install -g tasks-axi)` but still falls back to hand-editing and never blocks work. + If `config/backlog-backend=manual`, bootstrap hand-edits and does not suggest installing `tasks-axi`. +- `NUDGE_SECONDMATES: fm-...` - the secondmate sweep fast-forwarded one or more *running* secondmate homes to firstmate's current version and their instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) actually changed; send a one-line re-read nudge with `FM_HOME= bin/fm-send.sh 'firstmate was updated to the latest - please re-read your AGENTS.md to pick up the new instructions.'` unless `FM_HOME` is already set to the active firstmate home. This mirrors `/updatefirstmate`'s `nudge-secondmates:` report: it is a gentle steer, never an interruption, and the fast-forward already landed safely. A secondmate that was skipped, already current, or whose advance changed no instructions is not listed and must not be disturbed. - `FMX: X mode on ...` / `FMX: X mode off ...` - bootstrap confirmed or removed the local X-mode poll artifacts; follow section 14 for watcher cadence restart only when a running watcher needs the transition applied immediately. @@ -468,7 +469,7 @@ Read `data/secondmates.md` before dispatching and compare the work request to ea Route by the nature of the task, not just the project name. A project may appear in several `projects:` clone lists, so choose the secondmate whose natural-language scope actually fits the work, such as triage versus feature development. If the resolved project is `local-only`, keep the work with the main firstmate even when a secondmate scope sounds relevant. -If a secondmate's scope fits, steer that secondmate from an active firstmate session with `FM_HOME` exported by sending one concise instruction via `bin/fm-send.sh ''`, and let it run the normal lifecycle inside its own home. +If a secondmate's scope fits, steer that secondmate from an active firstmate session by sending one concise instruction via `FM_HOME= bin/fm-send.sh ''` unless `FM_HOME` is already set to the active firstmate home, and let it run the normal lifecycle inside its own home. The stable `fm-` label printed by lifecycle commands still works, but exact task ids resolve first through this home's `state/.meta`; pass an explicit backend target containing `:` only when intentionally targeting an endpoint outside this firstmate home. `fm-send` is fail-closed: `FM_HOME` must be set, and any target that cannot be resolved through this home's metadata or a well-formed explicit backend target exits non-zero instead of guessing a tmux window. A secondmate is itself a firstmate, so a request reaches it in its own chat, which you never read - the return channel that wakes you is its status file. @@ -546,7 +547,7 @@ Add the task to `data/backlog.md` under In flight. ### Supervise Covered by section 8. -Steer a crewmate only with short single lines via `bin/fm-send.sh` from an active firstmate session with `FM_HOME` exported; anything long belongs in a file the crewmate can read. +Steer a crewmate only with short single lines via `FM_HOME= bin/fm-send.sh` from an active firstmate session unless `FM_HOME` is already set to the active firstmate home; anything long belongs in a file the crewmate can read. Steer a secondmate the same way. Its charter retargets escalation to the main firstmate's status file, so routine internal churn stays inside the secondmate home and only `done`, `blocked`, `needs-decision`, `failed`, or captain-relevant phase changes wake the main firstmate. Because `fm-send` to a `kind=secondmate` target marks the request as from-firstmate (section 7 intake), the secondmate's answer comes back on that status/doc path too, not in its chat; read the response there as an ordinary status signal and do not peek its chat for it. @@ -650,7 +651,7 @@ A scout task follows Intake, Spawn, and Supervise exactly as above - scaffold th - Tear down immediately - no merge gate. `bin/fm-teardown.sh` allows a scout worktree's scratch commits and dirty files once the report exists; if the report is missing, it refuses, because the findings are the work product. - Record it in Done with the report path instead of a PR link using `tasks-axi done` when the default tasks-axi backend is active and compatible, otherwise hand-edit `data/backlog.md` and keep Done to the 10 most recent, then re-evaluate the queue and dispatch only queued work whose blockers are gone and whose time/date gate, if any, has arrived. -**Promotion.** When a scout's findings reveal shippable work (a reproduced bug with a clear fix) and the captain wants it shipped, promote the task in place instead of respawning: run `bin/fm-promote.sh ` (flips `kind=` to ship in meta, restoring teardown's full protection), then from an active firstmate session with `FM_HOME` exported send the crewmate its ship instructions - inventory scratch state, reset to a clean default-branch base, carry over only intended fix changes, create branch `fm/`, implement, and report `done` according to the project's delivery mode. +**Promotion.** When a scout's findings reveal shippable work (a reproduced bug with a clear fix) and the captain wants it shipped, promote the task in place instead of respawning: run `bin/fm-promote.sh ` (flips `kind=` to ship in meta, restoring teardown's full protection), then from an active firstmate session send the crewmate its ship instructions with `FM_HOME= bin/fm-send.sh` unless `FM_HOME` is already set to the active firstmate home - inventory scratch state, reset to a clean default-branch base, carry over only intended fix changes, create branch `fm/`, implement, and report `done` according to the project's delivery mode. The crewmate keeps its worktree, loaded context, and repro, but the ship branch must start from a clean base with only intended changes; scratch commits and debug edits from the scout phase never ride along. The repro becomes the regression test. From there the task is an ordinary ship task through its mode-specific validation, PR or local merge, and Teardown. diff --git a/docs/architecture.md b/docs/architecture.md index abec3c9ba21..06e1a81ba44 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -107,7 +107,7 @@ Seeding is transactional: if validation, cloning, initialization, or registry up `local-only` projects stay with the main first mate because they merge into the main local checkout instead of a remote-backed PR path. The same project may appear in multiple secondmate homes when their scopes differ, such as issue triage versus feature development. Secondmates are idle by default: after startup recovery reconciles only work already in their own home, an empty queue waits silently for routed tasks, and they never self-initiate surveys or audits. -With `FM_HOME` exported, metadata-routed `fm-send.sh` requests to a live `kind=secondmate` are prefixed with the from-firstmate marker from `bin/fm-marker-lib.sh`, so the secondmate returns terse answers through status lines and detailed answers through docs plus status pointers instead of replying only in its own chat. +When called with `FM_HOME=` or when `FM_HOME` is already set to the active firstmate home, metadata-routed `fm-send.sh` requests to a live `kind=secondmate` are prefixed with the from-firstmate marker from `bin/fm-marker-lib.sh`, so the secondmate returns terse answers through status lines and detailed answers through docs plus status pointers instead of replying only in its own chat. Explicit backend-target sends and direct human typing stay unmarked, so captain intervention in a secondmate pane remains conversational. After seeding a secondmate, `fm-backlog-handoff.sh` moves already-judged in-scope queued items from the main backlog into that secondmate home so the domain queue starts in the right place. Idle secondmate panes are healthy; teardown is explicit and refuses while the secondmate home has in-flight work unless the captain has approved discard with `--force`. diff --git a/docs/cmux-backend.md b/docs/cmux-backend.md index 7881f0bfe5a..e3a3366e2b9 100644 --- a/docs/cmux-backend.md +++ b/docs/cmux-backend.md @@ -56,7 +56,7 @@ No first-run provisioning beyond the socket-access setup above and having `jq` i Watching and attaching: firstmate uses one workspace per task in whatever cmux window is currently open. Callers can use exact task ids or stable `fm-` labels, while the actual cmux workspace title is home-scoped as `fm--`, for example `fm-firstmate-<8hex>-cmux-e2e-t1` in the primary home or `fm-2ndmate--<8hex>-cmux-e2e-t1` in a secondmate home. -You do not need to bring the window forward for routine supervision: from an active firstmate session with `FM_HOME` exported, `bin/fm-peek.sh ` reads a task's surface without focusing it, and `bin/fm-send.sh ""` steers it - workspace/surface/pane creation all default `focus` to `false`, so an unattended spawn never steals your view. +You do not need to bring the window forward for routine supervision: from an active firstmate session, `bin/fm-peek.sh ` reads a task's surface without focusing it, and `FM_HOME= bin/fm-send.sh ""` steers it unless `FM_HOME` is already set to the active firstmate home - workspace/surface/pane creation all default `focus` to `false`, so an unattended spawn never steals your view. Verify it works by spawning a trivial task with `--backend cmux` and confirming the task's meta records `backend=cmux` plus `cmux_workspace_id=` and `cmux_surface_id=`. The cmux sidebar should show a new `fm-firstmate-<8hex>-` workspace in the primary home. diff --git a/docs/herdr-backend.md b/docs/herdr-backend.md index d22253ef891..ae9729163b3 100644 --- a/docs/herdr-backend.md +++ b/docs/herdr-backend.md @@ -31,7 +31,7 @@ No first-run provisioning is needed beyond having `herdr` and `jq` on `PATH`; fi Watching and attaching: each firstmate home gets its own herdr workspace (the primary uses `firstmate`; each secondmate uses `2ndmate-`), with one tab per task inside it, named `fm-`. Attach to the selected `HERDR_SESSION` and switch to the workspace for the home you want to watch to see every one of that home's tasks as tabs in one tab bar. -You do not need to attach for routine supervision: from an active firstmate session with `FM_HOME` exported, `bin/fm-peek.sh fm-` reads a task's pane without attaching, and `bin/fm-send.sh fm- ""` steers it. +You do not need to attach for routine supervision: from an active firstmate session, `bin/fm-peek.sh fm-` reads a task's pane without attaching, and `FM_HOME= bin/fm-send.sh fm- ""` steers it unless `FM_HOME` is already set to the active firstmate home. Verify it works by spawning a trivial task with `--backend herdr` and confirming the task's meta records `backend=herdr` plus `herdr_session=`, `herdr_workspace_id=`, `herdr_tab_id=`, and `herdr_pane_id=`; the workspace for your home should show the new `fm-` tab. diff --git a/docs/orca-backend.md b/docs/orca-backend.md index 65f505b9462..d02e95f8fbb 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -25,7 +25,7 @@ Spawn fails closed if the runtime is not ready. The first spawn against a given project also auto-registers that project's repo in Orca (`orca repo add --path`) if it is not already registered - no manual registration step is needed. Watching and attaching: Orca owns both the worktree and the terminal for its tasks, so there is nothing to attach to outside the Orca app itself - open the app and find the terminal for the task (recorded as `terminal=` in the task's meta, with `window=fm-` as the shared firstmate alias). -You do not need to open the app for routine supervision: from an active firstmate session with `FM_HOME` exported, `bin/fm-peek.sh ` reads a task's terminal without opening Orca, and `bin/fm-send.sh ""` steers it (the stable `fm-` alias also works; Enter and Ctrl-C are supported; Escape is not). +You do not need to open the app for routine supervision: from an active firstmate session, `bin/fm-peek.sh ` reads a task's terminal without opening Orca, and `FM_HOME= bin/fm-send.sh ""` steers it unless `FM_HOME` is already set to the active firstmate home (the stable `fm-` alias also works; Enter and Ctrl-C are supported; Escape is not). Verify it works by spawning a trivial task with `--backend orca` and confirming the task's meta records `backend=orca`, `terminal=`, `orca_worktree_id=`, and `worktree=`; the Orca app should show a new terminal for the task. diff --git a/docs/tmux-backend.md b/docs/tmux-backend.md index 01e0a23a93c..8ce20decabf 100644 --- a/docs/tmux-backend.md +++ b/docs/tmux-backend.md @@ -58,7 +58,7 @@ tmux select-window -t :fm- # jump to one, or use ctrl-b Use the current tmux session name when firstmate was launched inside tmux; use `firstmate` only for the detached outside-tmux path. Typing directly into an attached window is authoritative direct intervention - the first mate treats it the same as any other captain instruction and reconciles at the next heartbeat. -You do not need to attach at all for routine supervision: from an active firstmate session with `FM_HOME` exported, the first mate reads crew windows itself with `bin/fm-peek.sh fm-` (a bounded, read-only capture) and steers a crew with `bin/fm-send.sh fm- ""` when it needs to intervene. +You do not need to attach at all for routine supervision: from an active firstmate session, the first mate reads crew windows itself with `bin/fm-peek.sh fm-` (a bounded, read-only capture) and steers a crew with `FM_HOME= bin/fm-send.sh fm- ""` unless `FM_HOME` is already set to the active firstmate home. ## Verifying it works diff --git a/docs/zellij-backend.md b/docs/zellij-backend.md index 4471c830c4d..f2546174f5f 100644 --- a/docs/zellij-backend.md +++ b/docs/zellij-backend.md @@ -27,7 +27,7 @@ No first-run provisioning is needed beyond having `zellij` and `jq` on `PATH`; f Watching and attaching: firstmate uses one shared session (default name `firstmate`, overridable with `FM_ZELLIJ_SESSION`) with one tab per task. The tab's caller-facing label is always `fm-`, but its actual visible title is home-scoped - `fm--`, e.g. `fm-firstmate-a1b2c3d4-fix-login-k3` - so that two firstmate homes sharing this one session (a primary plus a secondmate, two secondmates, or two independent primary installations on the same machine) never collide on the tab bar even if their task ids happen to match; see "Home-scoped tab titles" below. Attach to the selected `FM_ZELLIJ_SESSION` (or the default `firstmate` session) with `zellij attach ` to see every task, primary or secondmate, as a tab in that one tab bar. -You do not need to attach for routine supervision: from an active firstmate session with `FM_HOME` exported, `bin/fm-peek.sh fm-` reads a task's pane without attaching, and `bin/fm-send.sh fm- ""` steers it. +You do not need to attach for routine supervision: from an active firstmate session, `bin/fm-peek.sh fm-` reads a task's pane without attaching, and `FM_HOME= bin/fm-send.sh fm- ""` steers it unless `FM_HOME` is already set to the active firstmate home. Verify it works by spawning a trivial task with `--backend zellij` and confirming the task's meta records `backend=zellij` plus `zellij_session=`, `zellij_tab_id=`, and `zellij_pane_id=`; attaching to the session should show the new home-scoped tab title, such as `fm-firstmate-<8hex>-`. diff --git a/tests/fm-send-strict.test.sh b/tests/fm-send-strict.test.sh old mode 100644 new mode 100755 From 5ceb6b3150f7c21e52cecd839bf139acc6023afe Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:11:31 -0700 Subject: [PATCH 08/10] Verify explicit tmux targets before sending --- bin/fm-send.sh | 6 +++++- tests/fm-backend.test.sh | 23 ++++++++++++++++++----- tests/fm-send-strict.test.sh | 25 +++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/bin/fm-send.sh b/bin/fm-send.sh index 6e882632526..8f882c3be58 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -128,9 +128,13 @@ fm_send_resolve_target() { # else assumed=tmux fi + if ! fm_backend_target_exists "$assumed" "$raw"; then + echo "error: explicit target '$raw' is not a live $assumed endpoint (tried meta=$STATE/$raw.meta; metadata window/terminal lookup; backend=$assumed). Use fm- for a recorded task/lane, or pass a target whose backend endpoint can be verified." >&2 + return 1 + fi RESOLVED_TARGET=$raw TARGET_BACKEND=$assumed - RESOLUTION_TRIED="meta=$STATE/$raw.meta; metadata window/terminal lookup; backend=$assumed" + RESOLUTION_TRIED="meta=$STATE/$raw.meta; metadata window/terminal lookup; backend=$assumed; endpoint=verified" return 0 ;; esac diff --git a/tests/fm-backend.test.sh b/tests/fm-backend.test.sh index ee8e01392d3..58779621e3d 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -630,12 +630,17 @@ run_send_case() { # -- "$bin/bin/fm-send.sh" "$@" >/dev/null 2>&1 } +strip_send_preflight() { # + grep -v $'\x1f''display-message'$'\x1f''-p'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''#{pane_id}' "$1" +} + test_send_conformance_old_vs_new() { - local old_bin fb log_old log_new home rc_old rc_new + local old_bin fb log_old log_new home rc_old rc_new filtered_old filtered_new old_bin=$(build_old_bin send-old) fb=$(make_send_fakebin "$TMP_ROOT/send-fake") home="$TMP_ROOT/send-home"; mkdir -p "$home/state" log_old="$TMP_ROOT/send-old.log"; log_new="$TMP_ROOT/send-new.log" + filtered_old="$TMP_ROOT/send-old.filtered.log"; filtered_new="$TMP_ROOT/send-new.filtered.log" # Case 1: --key path. run_send_case "$old_bin" "$fb" "$log_old" "$home" -- "sess:win" --key Escape @@ -643,7 +648,11 @@ test_send_conformance_old_vs_new() { run_send_case "$ROOT" "$fb" "$log_new" "$home" -- "sess:win" --key Escape rc_new=$? expect_code "$rc_old" "$rc_new" "fm-send --key: old vs new exit code" - diff -u "$log_old" "$log_new" > "$TMP_ROOT/send-diff-key.txt" 2>&1 \ + assert_contains "$(cat "$log_new")" $'\x1f''display-message'$'\x1f''-p'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''#{pane_id}' \ + "fm-send --key did not verify the explicit tmux target before sending" + cp "$log_old" "$filtered_old" + strip_send_preflight "$log_new" > "$filtered_new" + diff -u "$filtered_old" "$filtered_new" > "$TMP_ROOT/send-diff-key.txt" 2>&1 \ || fail "fm-send --key: tmux command log differs old vs new"$'\n'"$(cat "$TMP_ROOT/send-diff-key.txt")" assert_contains "$(cat "$log_new")" $'\x1f''Escape' "fm-send --key did not send the named key" @@ -653,7 +662,9 @@ test_send_conformance_old_vs_new() { run_send_case "$ROOT" "$fb" "$log_new" "$home" -- "sess:win" hello captain rc_new=$? expect_code "$rc_old" "$rc_new" "fm-send plain text: old vs new exit code" - diff -u "$log_old" "$log_new" > "$TMP_ROOT/send-diff-plain.txt" 2>&1 \ + cp "$log_old" "$filtered_old" + strip_send_preflight "$log_new" > "$filtered_new" + diff -u "$filtered_old" "$filtered_new" > "$TMP_ROOT/send-diff-plain.txt" 2>&1 \ || fail "fm-send plain text: tmux command log differs old vs new"$'\n'"$(cat "$TMP_ROOT/send-diff-plain.txt")" assert_contains "$(cat "$log_new")" $'\x1f''send-keys'$'\x1f''-t'$'\x1f''sess:win'$'\x1f''-l'$'\x1f''hello captain' \ "fm-send did not send the literal text with send-keys -l" @@ -667,10 +678,12 @@ test_send_conformance_old_vs_new() { run_send_case "$ROOT" "$fb" "$log_new" "$home" -- "sess:win" /some-skill rc_new=$? expect_code "$rc_old" "$rc_new" "fm-send /skill: old vs new exit code" - diff -u "$log_old" "$log_new" > "$TMP_ROOT/send-diff-slash.txt" 2>&1 \ + cp "$log_old" "$filtered_old" + strip_send_preflight "$log_new" > "$filtered_new" + diff -u "$filtered_old" "$filtered_new" > "$TMP_ROOT/send-diff-slash.txt" 2>&1 \ || fail "fm-send /skill: tmux command log differs old vs new"$'\n'"$(cat "$TMP_ROOT/send-diff-slash.txt")" - pass "fm-send.sh: --key, plain text, and /skill tmux command logs are byte-identical old vs new (send-keys -l, Enter submission preserved)" + pass "fm-send.sh: explicit tmux targets are verified, while --key/plain/slash send command shape stays old-compatible" } # --- old vs new: fm-peek.sh -------------------------------------------------- diff --git a/tests/fm-send-strict.test.sh b/tests/fm-send-strict.test.sh index 03cfba9fce7..96b8c8d243d 100755 --- a/tests/fm-send-strict.test.sh +++ b/tests/fm-send-strict.test.sh @@ -34,6 +34,16 @@ case "${1:-}" in printf 'send-keys target=%s literal=%s arg=%s\n' "$target" "$literal" "${1:-}" >> "$FM_TMUX_LOG" exit 0 ;; display-message) + target= + while [ $# -gt 0 ]; do + case "$1" in + -t) target=$2; shift 2 ;; + *) shift ;; + esac + done + if [ -n "${FM_FAKE_TMUX_DEAD_TARGET:-}" ] && [ "$target" = "$FM_FAKE_TMUX_DEAD_TARGET" ]; then + exit 1 + fi printf '%%1\n' exit 0 ;; capture-pane) @@ -120,6 +130,20 @@ test_prefixless_herdr_pane_id_fails() { pass "fm-send strict: prefixless herdr pane ids are rejected before tmux fallback" } +test_unmatched_single_colon_target_must_exist() { + local dir fb home err log rc + dir="$TMP_ROOT/dead-explicit"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); home=$(setup_home deadexplicit); err="$dir/send.err"; log="$dir/tmux.log"; : > "$log" + + PATH="$fb:$PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$home" FM_TMUX_LOG="$log" FM_FAKE_TMUX_DEAD_TARGET=sess:missing FM_SEND_SETTLE=0 \ + "$SEND" sess:missing "hello" >/dev/null 2>"$err"; rc=$? + [ "$rc" -ne 0 ] || fail "dead explicit tmux-shaped target should fail" + assert_contains "$(cat "$err")" "not a live tmux endpoint" "dead explicit target diagnostic should name the assumed backend" + assert_contains "$(cat "$err")" "backend=tmux" "dead explicit target diagnostic should name the tried backend" + [ ! -s "$log" ] || fail "dead explicit target still attempted a send"$'\n'"$(cat "$log")" + pass "fm-send strict: unmatched single-colon explicit targets must verify live before sending" +} + test_healthy_fm_id_send_still_works() { local dir fb home err log rc got dir="$TMP_ROOT/healthy"; mkdir -p "$dir" @@ -140,4 +164,5 @@ test_bare_lane_id_fails_with_suggestion test_unset_fm_home_fails test_unresolvable_target_does_not_tmux_fallback test_prefixless_herdr_pane_id_fails +test_unmatched_single_colon_target_must_exist test_healthy_fm_id_send_still_works From 2510af8ed5e303cb3b831c548b885b796df61d73 Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:54:35 -0700 Subject: [PATCH 09/10] Isolate turnend guard test home --- tests/fm-turnend-guard.test.sh | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index efa42c7d8ef..478d452f1ea 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -133,8 +133,9 @@ make_crewmate_worktree_dir() { } run_hook() { - local dir=$1 stop_active=$2 - printf '{"stop_hook_active":%s}' "$stop_active" | bash "$dir/bin/fm-turnend-guard.sh" 2>&1 + local dir=$1 stop_active=$2 home + home=$(cd "$dir" && pwd) + printf '{"stop_hook_active":%s}' "$stop_active" | FM_HOME="$home" bash "$dir/bin/fm-turnend-guard.sh" 2>&1 } nonexistent_pid() { From 59ffa4c3e69e40d832f77b0fc66a1bc0d063a095 Mon Sep 17 00:00:00 2001 From: mielyemitchell <249051873+mielyemitchell@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:02:07 -0700 Subject: [PATCH 10/10] no-mistakes(document): Documented fm-send FM_HOME/backend guard additions missing from doc inventories --- CONTRIBUTING.md | 1 + bin/fm-send.sh | 27 ++++++++++++++++++++++++++- docs/configuration.md | 1 + docs/scripts.md | 2 +- tests/fm-send-strict.test.sh | 24 ++++++++++++------------ 5 files changed, 41 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8baccf9ee91..31bfbeae344 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,6 +78,7 @@ tests/fm-daemon.test.sh # sub-supervisor classifier, /afk pres tests/fm-send-settle.test.sh # fm-send post-submit settle pause, tuning, disable, and --key bypass tests tests/fm-send-popup-settle.test.sh # fm-send pre-Enter popup-settle selection for slash commands and codex $skill invocations tests/fm-send-secondmate-marker.test.sh # fm-send from-firstmate marker for kind=secondmate targets: marked vs crewmate/explicit/--key, and the exact marker byte sequence +tests/fm-send-strict.test.sh # fm-send strict target resolution: bare lane id did-you-mean, unset FM_HOME, unresolvable selectors, prefixless herdr pane ids, dead explicit tmux targets, and healthy fm- sends tests/fm-wake-daemon-lifecycle-e2e.test.sh # watcher + daemon lifecycle e2e: restart catch-up, batching, dedupe, stale-pane routing, and digest injection tests/fm-composer-ghost.test.sh # dim-ghost stripping, ghost-only composer detection, and escape-free peek tests tests/fm-afk-inject-e2e.test.sh # private-socket end-to-end test of the afk injection path (partial-input deferral, swallowed-Enter retry) diff --git a/bin/fm-send.sh b/bin/fm-send.sh index 8f882c3be58..1a15d4b0c6b 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -92,6 +92,7 @@ fm_send_resolve_target() { # TARGET_HARNESS="" EXPECTED_LABEL="" TARGET_META="" + TARGET_SELECTOR="" RESOLUTION_TRIED="" meta=$(fm_backend_meta_for_selector "$raw" "$STATE" 2>/dev/null || true) @@ -108,9 +109,18 @@ fm_send_resolve_target() { # TARGET_META=$meta TARGET_HARNESS=$(fm_meta_get "$meta" harness) EXPECTED_LABEL=$(fm_backend_expected_label_of_selector "$raw" "$STATE") + TARGET_SELECTOR=1 return 0 fi + case "$raw" in + fm-*) + RESOLUTION_TRIED="meta=$STATE/$raw.meta; legacy-meta=$STATE/${raw#fm-}.meta; backend=none" + echo "error: no metadata for $raw in $STATE (tried $RESOLUTION_TRIED); pass a well-formed explicit backend target only when targeting outside this firstmate home" >&2 + return 1 + ;; + esac + pane_meta=$(fm_send_meta_for_key_value "$STATE" herdr_pane_id "$raw" 2>/dev/null || true) if [ -n "$pane_meta" ]; then session=$(fm_meta_get "$pane_meta" herdr_session) @@ -120,6 +130,21 @@ fm_send_resolve_target() { # return 1 fi + meta=$(fm_backend_meta_for_window "$raw" "$STATE" 2>/dev/null || true) + if [ -n "$meta" ]; then + target=$(fm_backend_target_of_meta "$meta") + if [ -z "$target" ]; then + echo "error: no backend target recorded in $meta (tried explicit target '$raw' via recorded window/terminal; backend=from-meta)" >&2 + return 1 + fi + RESOLVED_TARGET=$target + TARGET_BACKEND=$(fm_backend_of_meta "$meta") + TARGET_META=$meta + TARGET_HARNESS=$(fm_meta_get "$meta" harness) + RESOLUTION_TRIED="explicit target '$raw' matched $meta; backend=$TARGET_BACKEND" + return 0 + fi + case "$raw" in *:*) colons=$(fm_send_count_colons "$raw") @@ -156,7 +181,7 @@ fm_backend_validate "$TARGET_BACKEND" || exit 1 # An explicit backend target (the escape hatch for endpoints outside this home) # and any crewmate/scout target are left unmarked, and so is the --key path. MARK_PREFIX="" -if [ -n "$TARGET_META" ] && grep -q '^kind=secondmate$' "$TARGET_META" 2>/dev/null; then +if [ -n "$TARGET_SELECTOR" ] && [ -n "$TARGET_META" ] && grep -q '^kind=secondmate$' "$TARGET_META" 2>/dev/null; then MARK_PREFIX="$FM_FROMFIRST_MARK" fi diff --git a/docs/configuration.md b/docs/configuration.md index 97f7d2303e1..4e3d0050edb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -248,6 +248,7 @@ CMUX_SOCKET_PASSWORD= # cmux-only: socket password fallback when config/cmux-s FM_SESSION_START_STATUS_TAIL=5 # state/*.status lines printed per task in the session-start digest FM_BOOTSTRAP_DETECT_ONLY=0 # internal/read-only session-start mode: skip bootstrap's mutating sweeps and print advisory TANGLE wording FM_GUARD_READ_ONLY=0 # internal/read-only guard mode: keep alarms but suppress drain, arm, and checkout repair commands +FM_GUARD_CONTINUE_LINE='This is a supervision warning only; the guarded operation WILL still run.' # banner continuation line; fm-send.sh overrides it to name the requested message specifically FM_POLL=15 # seconds between watcher poll cycles FM_HEARTBEAT=600 # base seconds between heartbeat scans; no-change heartbeats are absorbed while idle FM_HEARTBEAT_MAX=7200 # heartbeat backoff cap diff --git a/docs/scripts.md b/docs/scripts.md index d7d8fcfe7e9..be4137756cd 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -13,7 +13,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `fm-backlog-handoff.sh` | Move already-judged in-scope queued backlog items from the main home into a seeded secondmate home | | `fm-brief.sh` | Scaffold a ship brief with a worktree-isolation assertion, a report-only scout brief with `--scout`, or a secondmate charter with `--secondmate` | | `fm-ensure-agents-md.sh` | Ensure project `AGENTS.md` is the real memory file and `CLAUDE.md` symlinks to it; owns the canonical `## Maintaining this file` self-governance wording and appends it when creating the skeleton or promoting a real `CLAUDE.md` | -| `fm-guard.sh` | Warn when the primary checkout is tangled, when queued wakes are pending, or when a stale or missing watcher needs a prominent banner; `FM_GUARD_READ_ONLY=1` keeps the alarms but suppresses drain, arm, and checkout repair commands | +| `fm-guard.sh` | Warn when the primary checkout is tangled, when queued wakes are pending, or when a stale or missing watcher needs a prominent banner; `FM_GUARD_READ_ONLY=1` keeps the alarms but suppresses drain, arm, and checkout repair commands; `FM_GUARD_CONTINUE_LINE` overrides the banner's continuation line (`fm-send.sh` sets it to confirm the requested message will still be sent) | | `fm-turnend-guard.sh` | Shared primary turn-end guard predicate for all verified harnesses: blocks direct Stop hooks or feeds passive follow-up adapters when work is in flight without a live identity-matched watcher lock and fresh beacon; primary-scoped only and loop-safe (docs/turnend-guard.md) | | `fm-turnend-guard-grok.sh` | Grok primary Stop-hook adapter: runs `fm-turnend-guard.sh` and, when Grok's passive Stop event would end blind, forces one same-session `grok --resume` follow-up with a loop guard | | `fm-home-seed.sh` | Lease/provision a secondmate home transactionally, clone projects, initialize gates, and maintain `data/secondmates.md` | diff --git a/tests/fm-send-strict.test.sh b/tests/fm-send-strict.test.sh index 96b8c8d243d..21305d4a233 100755 --- a/tests/fm-send-strict.test.sh +++ b/tests/fm-send-strict.test.sh @@ -3,8 +3,8 @@ # # A send that cannot be tied to a recorded task/lane or to an explicit # well-formed backend target must fail loudly. These tests pin the historical -# silent-fallback failures: bare ids, missing FM_HOME, unresolved selectors, -# prefixless herdr pane ids, and the healthy fm- path. +# silent-fallback failures: missing FM_HOME, unresolved selectors, prefixless +# herdr pane ids, dead explicit endpoints, and the healthy exact/fm-id paths. set -u # shellcheck source=tests/lib.sh @@ -70,19 +70,19 @@ setup_home() { # -> echoes home dir printf '%s\n' "$home" } -test_bare_lane_id_fails_with_suggestion() { - local dir fb home err log rc - dir="$TMP_ROOT/bare"; mkdir -p "$dir" - fb=$(make_stubs "$dir"); home=$(setup_home bare); err="$dir/send.err"; log="$dir/tmux.log"; : > "$log" +test_exact_lane_id_send_still_works() { + local dir fb home err log rc got + dir="$TMP_ROOT/exact"; mkdir -p "$dir" + fb=$(make_stubs "$dir"); home=$(setup_home exact); err="$dir/send.err"; log="$dir/tmux.log"; : > "$log" fm_write_meta "$home/state/mpf-lane-m8.meta" "window=sess:fm-mpf-lane-m8" "kind=ship" PATH="$fb:$PATH" FM_HOME="$home" FM_ROOT_OVERRIDE="$home" FM_TMUX_LOG="$log" FM_SEND_SETTLE=0 \ "$SEND" mpf-lane-m8 "lost dispatch" >/dev/null 2>"$err"; rc=$? - [ "$rc" -ne 0 ] || fail "bare lane id should fail" - assert_contains "$(cat "$err")" "did you mean fm-mpf-lane-m8?" "bare id diagnostic should suggest the fm- prefix" - assert_contains "$(cat "$err")" "$home/state/mpf-lane-m8.meta" "bare id diagnostic should name the checked meta path" - [ ! -s "$log" ] || fail "bare lane id fell through to tmux send"$'\n'"$(cat "$log")" - pass "fm-send strict: bare task/lane id fails with a did-you-mean fm- diagnostic" + expect_code 0 "$rc" "exact task id send should succeed when metadata exists" + got=$(cat "$log") + assert_contains "$got" "target=sess:fm-mpf-lane-m8 literal=1 arg=lost dispatch" "exact id should type literal text to the meta target" + assert_contains "$got" "target=sess:fm-mpf-lane-m8 literal=0 arg=Enter" "exact id should submit with Enter" + pass "fm-send strict: exact task/lane ids resolve through home metadata" } test_unset_fm_home_fails() { @@ -160,7 +160,7 @@ test_healthy_fm_id_send_still_works() { pass "fm-send strict: healthy fm- sends still type once and submit" } -test_bare_lane_id_fails_with_suggestion +test_exact_lane_id_send_still_works test_unset_fm_home_fails test_unresolvable_target_does_not_tmux_fallback test_prefixless_herdr_pane_id_fails