From f5ac6d886dec563cd7ccc30a469a87bd977b84fb 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 | 167 ++++++++++++++++++++++++++++---- 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, 308 insertions(+), 27 deletions(-) create mode 100644 tests/fm-send-strict.test.sh diff --git a/AGENTS.md b/AGENTS.md index 6fdaa675..b1d95a53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -437,6 +437,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 fm- ''` and let it run the normal lifecycle inside its own home. The bare `fm-` target resolves through this home's `state/.meta`; pass an explicit backend target only when intentionally targeting an endpoint outside this firstmate home. +`fm-send` is fail-closed: `FM_HOME` must be set, a missing `fm-` prefix is rejected with a "did you mean fm-?" diagnostic, 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 bare `fm-` 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. @@ -697,6 +698,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 08047c40..e8db2d93 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 70e21529..20eada45 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -1,8 +1,11 @@ #!/usr/bin/env bash # Send one line of literal text to a crewmate endpoint, then Enter. # Usage: fm-send.sh -# may be a bare firstmate task name (fm-xyz), resolved through -# this home's state/.meta, or an explicit backend target. +# must be either a firstmate task selector (fm-xyz), resolved +# through this home's state/.meta, or an explicit well-formed backend +# target. fm-send refuses unresolved/bare 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. @@ -35,8 +38,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" @@ -45,10 +61,124 @@ 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 stripped_meta direct_meta pane_meta target backend assumed colons id session hint + + RESOLVED_TARGET="" + TARGET_BACKEND="" + TARGET_HARNESS="" + EXPECTED_LABEL="" + TARGET_META="" + RESOLUTION_TRIED="" + + case "$raw" in + fm-*) + stripped_meta="$STATE/${raw#fm-}.meta" + direct_meta="$STATE/$raw.meta" + RESOLUTION_TRIED="meta=$stripped_meta; bare-id-meta=$direct_meta; backend=from-meta" + if [ ! -f "$stripped_meta" ]; then + if [ -f "$direct_meta" ]; then + echo "error: target '$raw' is a bare task/lane id with metadata at $direct_meta; did you mean fm-$raw? (tried $RESOLUTION_TRIED)" >&2 + return 1 + fi + 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 + fi + target=$(fm_backend_target_of_meta "$stripped_meta") + if [ -z "$target" ]; then + echo "error: no backend target recorded in $stripped_meta (tried $RESOLUTION_TRIED)" >&2 + return 1 + fi + backend=$(fm_backend_of_meta "$stripped_meta") + RESOLVED_TARGET=$target + TARGET_BACKEND=$backend + TARGET_META=$stripped_meta + TARGET_HARNESS=$(fm_meta_get "$stripped_meta" harness) + EXPECTED_LABEL=$raw + return 0 + ;; + esac + + direct_meta="$STATE/$raw.meta" + if [ -f "$direct_meta" ]; then + echo "error: target '$raw' is a bare task/lane id with metadata at $direct_meta; did you mean fm-$raw? (tried meta=$direct_meta; backend=none)" >&2 + return 1 + 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 + + 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") + 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") +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 bare `fm-` target, # 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). @@ -57,7 +187,7 @@ shift MARK_PREFIX="" case "$RAW_TARGET" in fm-*) - meta="$STATE/${RAW_TARGET#fm-}.meta" + meta="$TARGET_META" if [ -f "$meta" ] && grep -q '^kind=secondmate$' "$meta" 2>/dev/null; then MARK_PREFIX="$FM_FROMFIRST_MARK" fi @@ -68,22 +198,22 @@ esac # scope the codex `$` popup-settle below. A bare fm- target 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 fm- 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") +# The target's BACKEND comes from fm- meta, from matching an explicit target +# back to recorded meta, or from strict explicit-target shape validation. case "$RAW_TARGET" in fm-*) - meta="$STATE/${RAW_TARGET#fm-}.meta" - if [ -f "$meta" ]; then - TARGET_HARNESS=$(fm_meta_get "$meta" harness) + 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 @@ -104,14 +234,17 @@ else sleep_s=${FM_SEND_SLEEP:-0.4} # Type once, submit, verify. Lenient: only a positively-confirmed swallow # (text still in the composer) 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") + 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 b8aa8aad..4b730e79 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -41,7 +41,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, and watcher identity/health helpers sourced by the watcher, drain, arm, guard, turn-end guard, and daemon | | `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; bare `kind=secondmate` targets 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; bare `kind=secondmate` targets 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 84be0469..82fb0610 100755 --- a/tests/fm-backend-herdr.test.sh +++ b/tests/fm-backend-herdr.test.sh @@ -1036,7 +1036,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 6cf67f17..bad9a389 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 aef23c3f..acfaacca 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 4d455236..6fd8cc6a 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 00000000..707212b5 --- /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 ddccbd60ae233d43be45ff95dfe0ad45a87c31fc 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 b1d95a53..6beccdbf 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. ``` @@ -177,7 +178,7 @@ Otherwise it prints one line per problem or capability fact; handle each: 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. 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: ` - 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 window, 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 that secondmate picks up its new instructions. +- `NUDGE_SECONDMATES: ` - 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 that secondmate picks up its new instructions. 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. @@ -435,7 +436,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 fm- ''` 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 fm- ''`, and let it run the normal lifecycle inside its own home. The bare `fm-` target resolves through this home's `state/.meta`; pass an explicit backend target only when intentionally targeting an endpoint outside this firstmate home. `fm-send` is fail-closed: `FM_HOME` must be set, a missing `fm-` prefix is rejected with a "did you mean fm-?" diagnostic, 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. @@ -511,7 +512,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. @@ -612,7 +613,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 5d9555dc..827c1799 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 05972d29..5e834111 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -102,7 +102,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. -Bare `fm-send.sh fm-` 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, bare `fm-send.sh fm-` 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 516475f8..be640095 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -92,9 +92,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. @@ -214,7 +215,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 21645fc3..0b7fd9ad 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. @@ -229,7 +229,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. @@ -307,10 +307,10 @@ The `dead` branch remains a conservative, defensively-coded path for a herdr fai 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 55142998..fd0dfef0 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -24,7 +24,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 fm-` reads a task's terminal without opening Orca, and `bin/fm-send.sh fm- ""` steers it (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 fm-` reads a task's terminal without opening Orca, and `bin/fm-send.sh fm- ""` steers it (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 e5ba2c88..82598eee 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 551464a9..ac3d0fe1 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>-`. @@ -197,10 +197,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 3fcbb618f78224c17c55777df7d73301989d2292 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 42d0ea4e..7fd418b9 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -84,7 +84,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 dcf0be5b..c45513ba 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 4059e695..f25ef5c6 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 '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 20eada45..37c08fa3 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -200,14 +200,11 @@ esac # unknown and treated as non-codex (the safe default that keeps the fast path). # The target's BACKEND comes from fm- 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 bad9a389..d9d89ab3 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 d96d69a4373aefde97d79d638478f64077bbbf55 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 b66cba30..c12bcd28 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 still use firstmate's universal `fm-` selector vocabulary, 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 fm-` reads a task's surface without focusing it, and `bin/fm-send.sh fm- ""` 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 fm-` reads a task's surface without focusing it, and `bin/fm-send.sh fm- ""` 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 bd66e4f9cc524d95e450d62afcc183cb2f9a73b5 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 8ea2a70c..8f8ad9fa 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() { @@ -129,7 +129,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)" @@ -169,7 +169,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)" } @@ -182,7 +182,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 2248db6f9dd341d66b4665acf7d53e62a23d2d72 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 6beccdbf..f10c9193 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -699,7 +699,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 e8db2d93..e3cd8341 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 37c08fa3..d7a999dd 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -59,7 +59,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 707212b5..03cfba9f 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 1e9c6420..cd37da41 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 dabee58068ca7fe2f51dcf172314be202fe3c6c5 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 | 8 ++++---- 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, 13 insertions(+), 13 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 7fd418b9..e66308fb 100644 --- a/.agents/skills/harness-adapters/SKILL.md +++ b/.agents/skills/harness-adapters/SKILL.md @@ -84,7 +84,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 c45513ba..2204c6bb 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 f25ef5c6..073736af 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 f10c9193..20daf853 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,7 +178,7 @@ Otherwise it prints one line per problem or capability fact; handle each: 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. 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: ` - 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 that secondmate picks up its new instructions. +- `NUDGE_SECONDMATES: ` - 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. @@ -436,7 +436,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 fm- ''`, 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 fm- ''` unless `FM_HOME` is already set to the active firstmate home, and let it run the normal lifecycle inside its own home. The bare `fm-` target resolves through this home's `state/.meta`; pass an explicit backend target only when intentionally targeting an endpoint outside this firstmate home. `fm-send` is fail-closed: `FM_HOME` must be set, a missing `fm-` prefix is rejected with a "did you mean fm-?" diagnostic, 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. @@ -512,7 +512,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. @@ -613,7 +613,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 5e834111..7c8a5164 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -102,7 +102,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, bare `fm-send.sh fm-` 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, bare `fm-send.sh fm-` 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 c12bcd28..6f028712 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 still use firstmate's universal `fm-` selector vocabulary, 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 fm-` reads a task's surface without focusing it, and `bin/fm-send.sh fm- ""` 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 fm-` reads a task's surface without focusing it, and `FM_HOME= bin/fm-send.sh fm- ""` 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 0b7fd9ad..92df4a87 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 fd0dfef0..faf0d81b 100644 --- a/docs/orca-backend.md +++ b/docs/orca-backend.md @@ -24,7 +24,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 fm-` reads a task's terminal without opening Orca, and `bin/fm-send.sh fm- ""` steers it (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 fm-` reads a task's terminal without opening Orca, and `FM_HOME= bin/fm-send.sh fm- ""` steers it unless `FM_HOME` is already set to the active firstmate home (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 82598eee..229cfd42 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 ac3d0fe1..9e12fff1 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 fa11509ccc2c5ca7a975bd1f4892b6d900dd9cbd 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 d7a999dd..c6ee9222 100755 --- a/bin/fm-send.sh +++ b/bin/fm-send.sh @@ -161,9 +161,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 8254cfb8..5edbe55d 100755 --- a/tests/fm-backend.test.sh +++ b/tests/fm-backend.test.sh @@ -600,12 +600,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 @@ -613,7 +618,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" @@ -623,7 +632,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" @@ -637,10 +648,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 03cfba9f..96b8c8d2 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 fb71d32ce956cca69abfc6ae2f9fe8ce87db6bf9 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 5edbc3ac..54d1083c 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -126,8 +126,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 f3a2ec427ff455b69b417737a84c57ab89704213 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 + docs/configuration.md | 1 + docs/scripts.md | 104 +++++++++++++++++++++--------------------- 3 files changed, 54 insertions(+), 52 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e066fa8..fa7f378e 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/docs/configuration.md b/docs/configuration.md index be640095..a2517abc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -234,6 +234,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 4b730e79..bb788739 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -4,55 +4,55 @@ The first mate drives these; interactive entrypoints work by hand too, while `*- Each file also starts with a short header comment. If you have changed away from the firstmate home in an interactive shell, invoke these scripts by absolute path through the repo's `bin/` directory; the scripts self-locate internally after they start. -| Script | Description | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------- | -| `fm-session-start.sh` | The one command AGENTS.md sections 3 and 5 run at every session start: composes `fm-lock.sh`, `fm-bootstrap.sh` (its three mutating sweeps gated on holding the lock via `FM_BOOTSTRAP_DETECT_ONLY`), and `fm-wake-drain.sh`, then prints a full context digest (`data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/learnings.md`, each `ABSENT`-marked when missing) and fleet-state digest (`data/backlog.md`, every `state/*.meta`, a bounded `state/*.status` tail, `state/.afk`, and a cheap per-task endpoint-liveness read); prints a loud read-only banner and skips every mutating step when the lock is held elsewhere; never arms the watcher itself | -| `fm-bootstrap.sh` | Detect required toolchain and version problems, dispatch profile JSON errors or active-rule blocks, default backlog-backend status, primary-checkout `TANGLE:` problems, and actionable clone refresh outcomes; refresh project clones best-effort; locally sync live secondmate homes and propagate declared inheritable config; set up opt-in X mode; install tools only after consent; `FM_BOOTSTRAP_DETECT_ONLY=1` skips the three mutating sweeps and prints advisory-only `TANGLE:` wording without a checkout command | -| `fm-fleet-sync.sh` | Fetch clones, fast-forward safe default-branch states, self-heal clean detached ancestor drift, report unsafe drift as `STUCK:`, and safely prune branches whose remote is gone | -| `fm-update.sh` | Self-update the running firstmate repo and registered secondmate homes with fast-forward-only pulls from origin | -| `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 | -| `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-turnend-guard.sh` | Claude Code Stop hook, primary-scoped only: blocks (exit 2, exact reason) a primary turn end when work is in flight without a live identity-matched watcher lock and fresh beacon, using Claude Code's own `stop_hook_active` field so it never blocks twice in one turn (docs/turnend-guard.md) | -| `fm-home-seed.sh` | Lease/provision a secondmate home transactionally, clone projects, initialize gates, and maintain `data/secondmates.md` | -| `fm-spawn.sh` | Spawn one task, several `id=repo` pairs, or a persistent secondmate with `--secondmate`; accepts concrete `--harness`, `--model`, `--effort`, and `--backend` axes; ship/scout spawns require an explicit resolved harness when dispatch profiles are active and an isolated worktree, install per-harness turn-end signaling, and secondmate spawns resolve the secondmate harness plus optional `config/secondmate-harness` model/effort tokens, locally sync the home, propagate declared inheritable config, land herdr tabs in the target home's workspace, land home-scoped zellij tabs in the selected shared zellij session, land cmux workspaces in the shared cmux app, or create Orca worktrees/terminals before launch | -| `fm-backend.sh` | Runtime session-provider backend selector with explicit/env/config/runtime auto-detection precedence, meta helper, selector resolver, spawn-capability validation, operation dispatcher, and shell-portable backend-name membership for bash-sourced scripts or zsh-sourced diagnostics; defaults absent `backend=` meta to `tmux`; `fm_backend_target_exists` is a cheap read-only alive/dead endpoint check that never starts a server or session; `fm_backend_composer_state` exposes backend composer checks for guarded submit paths | -| `fm-backend-hometag-lib.sh` | Shared home-tag derivation for zellij tab titles and cmux workspace titles, using the active `FM_HOME` label plus a short hash of the resolved `FM_ROOT` path | -| `backends/tmux.sh` | Verified tmux session-provider adapter used by `fm-backend.sh`; owns create, send, capture, current-path, live-window, and kill primitives | -| `backends/herdr.sh` | Experimental herdr session-provider adapter used by `fm-backend.sh`; owns version/tool gating, per-home workspace/tab creation, created-vs-adopted default-tab prune safety, restored-layout husk respawn replacement, session-scoped CLI calls, send with structural composer-state verification, capture, native busy-state, current-path, label-based live discovery, and kill primitives | -| `backends/zellij.sh` | Experimental zellij session-provider adapter used by `fm-backend.sh`; owns version/tool gating, one-session/tab-per-task creation with home-scoped titles, session-scoped CLI calls, send, capture, active current-path probing, label-checked target safety, scoped live discovery, legacy-title fallback, and tab cleanup primitives | -| `backends/orca.sh` | Experimental Orca backend used by `fm-backend.sh`; owns repo registration, worktree creation/removal, terminal creation, capture, send text, Enter/Ctrl-C interrupt keys, and close; Escape is unsupported | -| `backends/cmux.sh` | Experimental cmux session-provider adapter used by `fm-backend.sh`; owns version/tool/socket-access gating, home-scoped workspace creation, current-path probing, title-based recovery, send/capture, structural composer-state verification, Enter/Escape/Ctrl-C keys, and workspace cleanup primitives | -| `fm-config-push.sh` | Config-only mid-session push of declared inheritable local config into live secondmate homes; reports each item as pushed, unchanged, skipped, or error without fast-forwarding tracked files or nudging agents | -| `fm-project-mode.sh` | Resolve a project's delivery mode and `+yolo` flag from `data/projects.md` | -| `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | -| `fm-review-diff.sh` | Review a crewmate branch or recorded PR head against the authoritative base, warning and falling back to the local branch when an expected PR head cannot be resolved, with optional `--stat` output | -| `fm-marker-lib.sh` | Shared from-firstmate request marker and detector sourced by `fm-send.sh`, `fm-brief.sh`, and tests | -| `fm-watch-arm.sh` | Verified per-home watcher re-arm; reports `started`, `healthy`, or `FAILED`; `--restart` relaunches only this home's watcher | -| `fm-watch.sh` | Singleton-safe always-on watcher and default pull-based event source; uses backend-native busy state when available before the shared regex fallback, absorbs no-verb signal and stale wakes only when the crew is provably working, checks that evidence before trusting stale status-log terminality, queues and exits for actionable wakes, and reverts to daemon-owned one-shot behavior while `state/.afk` exists | -| `fm-supervise-daemon.sh` | Presence-gated sub-supervisor for walk-away (`/afk`) supervision: wraps `fm-watch.sh`, uses the shared wake classifier, backend-aware stale rechecks, and tmux/herdr supervisor injection, self-handles routine wakes in bash, and escalates only captain-relevant events as one verified, batched, single-line digest prefixed with a sentinel marker | -| `fm-crew-state.sh` | Print one stable current-state line for a crew by reconciling its matching no-mistakes run-step, including coarse cross-branch attribution from `no-mistakes runs`, even when the pane has closed, with backend-aware pane fallback that corroborates native idle/unknown verdicts before the status log | -| `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification sourced by bootstrap and guard | -| `fm-supervision-lib.sh` | Shared grace-based "in-flight work exists but no watcher has a fresh beacon" status and predicate used by `fm-guard.sh`; `fm-turnend-guard.sh` uses it for banner fields and relies on `fm-wake-lib.sh` for live watcher lock health | -| `fm-ff-lib.sh` | Shared guarded fast-forward helper for `/updatefirstmate` origin pulls and no-fetch local secondmate syncs | -| `fm-config-inherit-lib.sh` | Shared primary->secondmate inheritable-config propagation (a declared, extensible item list - currently `config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend`) sourced by spawn, bootstrap, and config push | -| `fm-tasks-axi-lib.sh` | Shared backlog-backend selector and `tasks-axi` compatibility probe sourced by bootstrap and teardown | -| `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, and watcher identity/health helpers sourced by the watcher, drain, arm, guard, turn-end guard, and daemon | -| `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; requires explicit `FM_HOME`, refuses unresolved selectors instead of guessing tmux, exits non-zero on failed delivery or confirmed swallowed Enter; bare `kind=secondmate` targets 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 | -| `fm-pr-merge.sh` | Require a full GitHub PR URL, record `pr=` and available `pr_head=` via `fm-pr-check.sh`, parse it into `gh-axi pr merge --repo /`, default to `--squash` unless a merge method is forwarded, and reject malformed URLs or repo overrides | -| `fm-promote.sh` | Promote a scout task in place so it becomes a protected ship task | -| `fm-teardown.sh` | Return a clean, landed ship worktree or retire/release a secondmate home; requires scout reports, checks child work, removes firstmate-owned hook artifacts, closes recorded backend endpoints under their owning home context, releases Orca worktrees through `orca worktree rm`, and prints the backlog-backend reminder | -| `fm-harness.sh` | Detect the running harness; resolve the effective crewmate (`crew`) or secondmate-launch (`secondmate`) harness; expose optional `config/secondmate-harness` model and effort tokens with `secondmate-model` and `secondmate-effort` | -| `fm-lock.sh` | Per-home firstmate session lock | -| `fm-x-lib.sh` | Shared X-mode `.env`, alternate env-file, relay, dry-run config, reply-thread splitting, outbound image payloads, and task-to-X-request meta-link helpers | -| `fm-x-poll.sh` | Do one bounded X relay poll; without `FMX_PAIRING_TOKEN` it is silent, with a pending mention it stashes the full inbox JSON, including `in_reply_to`, and prints `x-mention ` | -| `fm-x-reply.sh` | Post or dry-run preview a composed public-safe X answer or `--followup`, auto-splitting long text into `{request_id,text,texts}` threads and optionally attaching `--image ` to the opener; reads text from an argument, stdin, or `--text-file` | -| `fm-x-dismiss.sh` | Dismiss or dry-run preview a skipped X mention without replying by sending `{request_id}` to the relay's `connector/dismiss` endpoint | -| `fm-x-link.sh` | Link a spawned task to its originating X mention by recording `x_request=`, `x_request_ts=`, and a follow-up counter `x_followups=` in `state/.meta`; paired `--carry-count --carry-ts ` preserves the original counter and timestamp when re-linking onto a successor task | -| `fm-x-followup.sh` | Detect, post, and manage up to three completion follow-ups (within a 7-day window) for an X-linked task, forwarding optional `--image `, incrementing the counter on a non-final success, clearing the link on `--final`/cap/window/relay-rejection, and retrying only on a generic post failure | +| Script | Description | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `fm-session-start.sh` | The one command AGENTS.md sections 3 and 5 run at every session start: composes `fm-lock.sh`, `fm-bootstrap.sh` (its three mutating sweeps gated on holding the lock via `FM_BOOTSTRAP_DETECT_ONLY`), and `fm-wake-drain.sh`, then prints a full context digest (`data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/learnings.md`, each `ABSENT`-marked when missing) and fleet-state digest (`data/backlog.md`, every `state/*.meta`, a bounded `state/*.status` tail, `state/.afk`, and a cheap per-task endpoint-liveness read); prints a loud read-only banner and skips every mutating step when the lock is held elsewhere; never arms the watcher itself | +| `fm-bootstrap.sh` | Detect required toolchain and version problems, dispatch profile JSON errors or active-rule blocks, default backlog-backend status, primary-checkout `TANGLE:` problems, and actionable clone refresh outcomes; refresh project clones best-effort; locally sync live secondmate homes and propagate declared inheritable config; set up opt-in X mode; install tools only after consent; `FM_BOOTSTRAP_DETECT_ONLY=1` skips the three mutating sweeps and prints advisory-only `TANGLE:` wording without a checkout command | +| `fm-fleet-sync.sh` | Fetch clones, fast-forward safe default-branch states, self-heal clean detached ancestor drift, report unsafe drift as `STUCK:`, and safely prune branches whose remote is gone | +| `fm-update.sh` | Self-update the running firstmate repo and registered secondmate homes with fast-forward-only pulls from origin | +| `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 | +| `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` | Claude Code Stop hook, primary-scoped only: blocks (exit 2, exact reason) a primary turn end when work is in flight without a live identity-matched watcher lock and fresh beacon, using Claude Code's own `stop_hook_active` field so it never blocks twice in one turn (docs/turnend-guard.md) | +| `fm-home-seed.sh` | Lease/provision a secondmate home transactionally, clone projects, initialize gates, and maintain `data/secondmates.md` | +| `fm-spawn.sh` | Spawn one task, several `id=repo` pairs, or a persistent secondmate with `--secondmate`; accepts concrete `--harness`, `--model`, `--effort`, and `--backend` axes; ship/scout spawns require an explicit resolved harness when dispatch profiles are active and an isolated worktree, install per-harness turn-end signaling, and secondmate spawns resolve the secondmate harness plus optional `config/secondmate-harness` model/effort tokens, locally sync the home, propagate declared inheritable config, land herdr tabs in the target home's workspace, land home-scoped zellij tabs in the selected shared zellij session, land cmux workspaces in the shared cmux app, or create Orca worktrees/terminals before launch | +| `fm-backend.sh` | Runtime session-provider backend selector with explicit/env/config/runtime auto-detection precedence, meta helper, selector resolver, spawn-capability validation, operation dispatcher, and shell-portable backend-name membership for bash-sourced scripts or zsh-sourced diagnostics; defaults absent `backend=` meta to `tmux`; `fm_backend_target_exists` is a cheap read-only alive/dead endpoint check that never starts a server or session; `fm_backend_composer_state` exposes backend composer checks for guarded submit paths | +| `fm-backend-hometag-lib.sh` | Shared home-tag derivation for zellij tab titles and cmux workspace titles, using the active `FM_HOME` label plus a short hash of the resolved `FM_ROOT` path | +| `backends/tmux.sh` | Verified tmux session-provider adapter used by `fm-backend.sh`; owns create, send, capture, current-path, live-window, and kill primitives | +| `backends/herdr.sh` | Experimental herdr session-provider adapter used by `fm-backend.sh`; owns version/tool gating, per-home workspace/tab creation, created-vs-adopted default-tab prune safety, restored-layout husk respawn replacement, session-scoped CLI calls, send with structural composer-state verification, capture, native busy-state, current-path, label-based live discovery, and kill primitives | +| `backends/zellij.sh` | Experimental zellij session-provider adapter used by `fm-backend.sh`; owns version/tool gating, one-session/tab-per-task creation with home-scoped titles, session-scoped CLI calls, send, capture, active current-path probing, label-checked target safety, scoped live discovery, legacy-title fallback, and tab cleanup primitives | +| `backends/orca.sh` | Experimental Orca backend used by `fm-backend.sh`; owns repo registration, worktree creation/removal, terminal creation, capture, send text, Enter/Ctrl-C interrupt keys, and close; Escape is unsupported | +| `backends/cmux.sh` | Experimental cmux session-provider adapter used by `fm-backend.sh`; owns version/tool/socket-access gating, home-scoped workspace creation, current-path probing, title-based recovery, send/capture, structural composer-state verification, Enter/Escape/Ctrl-C keys, and workspace cleanup primitives | +| `fm-config-push.sh` | Config-only mid-session push of declared inheritable local config into live secondmate homes; reports each item as pushed, unchanged, skipped, or error without fast-forwarding tracked files or nudging agents | +| `fm-project-mode.sh` | Resolve a project's delivery mode and `+yolo` flag from `data/projects.md` | +| `fm-merge-local.sh` | Fast-forward a `local-only` project's local default branch after approval | +| `fm-review-diff.sh` | Review a crewmate branch or recorded PR head against the authoritative base, warning and falling back to the local branch when an expected PR head cannot be resolved, with optional `--stat` output | +| `fm-marker-lib.sh` | Shared from-firstmate request marker and detector sourced by `fm-send.sh`, `fm-brief.sh`, and tests | +| `fm-watch-arm.sh` | Verified per-home watcher re-arm; reports `started`, `healthy`, or `FAILED`; `--restart` relaunches only this home's watcher | +| `fm-watch.sh` | Singleton-safe always-on watcher and default pull-based event source; uses backend-native busy state when available before the shared regex fallback, absorbs no-verb signal and stale wakes only when the crew is provably working, checks that evidence before trusting stale status-log terminality, queues and exits for actionable wakes, and reverts to daemon-owned one-shot behavior while `state/.afk` exists | +| `fm-supervise-daemon.sh` | Presence-gated sub-supervisor for walk-away (`/afk`) supervision: wraps `fm-watch.sh`, uses the shared wake classifier, backend-aware stale rechecks, and tmux/herdr supervisor injection, self-handles routine wakes in bash, and escalates only captain-relevant events as one verified, batched, single-line digest prefixed with a sentinel marker | +| `fm-crew-state.sh` | Print one stable current-state line for a crew by reconciling its matching no-mistakes run-step, including coarse cross-branch attribution from `no-mistakes runs`, even when the pane has closed, with backend-aware pane fallback that corroborates native idle/unknown verdicts before the status log | +| `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification sourced by bootstrap and guard | +| `fm-supervision-lib.sh` | Shared grace-based "in-flight work exists but no watcher has a fresh beacon" status and predicate used by `fm-guard.sh`; `fm-turnend-guard.sh` uses it for banner fields and relies on `fm-wake-lib.sh` for live watcher lock health | +| `fm-ff-lib.sh` | Shared guarded fast-forward helper for `/updatefirstmate` origin pulls and no-fetch local secondmate syncs | +| `fm-config-inherit-lib.sh` | Shared primary->secondmate inheritable-config propagation (a declared, extensible item list - currently `config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend`) sourced by spawn, bootstrap, and config push | +| `fm-tasks-axi-lib.sh` | Shared backlog-backend selector and `tasks-axi` compatibility probe sourced by bootstrap and teardown | +| `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, and watcher identity/health helpers sourced by the watcher, drain, arm, guard, turn-end guard, and daemon | +| `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; requires explicit `FM_HOME`, refuses unresolved selectors instead of guessing tmux, exits non-zero on failed delivery or confirmed swallowed Enter; bare `kind=secondmate` targets 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 | +| `fm-pr-merge.sh` | Require a full GitHub PR URL, record `pr=` and available `pr_head=` via `fm-pr-check.sh`, parse it into `gh-axi pr merge --repo /`, default to `--squash` unless a merge method is forwarded, and reject malformed URLs or repo overrides | +| `fm-promote.sh` | Promote a scout task in place so it becomes a protected ship task | +| `fm-teardown.sh` | Return a clean, landed ship worktree or retire/release a secondmate home; requires scout reports, checks child work, removes firstmate-owned hook artifacts, closes recorded backend endpoints under their owning home context, releases Orca worktrees through `orca worktree rm`, and prints the backlog-backend reminder | +| `fm-harness.sh` | Detect the running harness; resolve the effective crewmate (`crew`) or secondmate-launch (`secondmate`) harness; expose optional `config/secondmate-harness` model and effort tokens with `secondmate-model` and `secondmate-effort` | +| `fm-lock.sh` | Per-home firstmate session lock | +| `fm-x-lib.sh` | Shared X-mode `.env`, alternate env-file, relay, dry-run config, reply-thread splitting, outbound image payloads, and task-to-X-request meta-link helpers | +| `fm-x-poll.sh` | Do one bounded X relay poll; without `FMX_PAIRING_TOKEN` it is silent, with a pending mention it stashes the full inbox JSON, including `in_reply_to`, and prints `x-mention ` | +| `fm-x-reply.sh` | Post or dry-run preview a composed public-safe X answer or `--followup`, auto-splitting long text into `{request_id,text,texts}` threads and optionally attaching `--image ` to the opener; reads text from an argument, stdin, or `--text-file` | +| `fm-x-dismiss.sh` | Dismiss or dry-run preview a skipped X mention without replying by sending `{request_id}` to the relay's `connector/dismiss` endpoint | +| `fm-x-link.sh` | Link a spawned task to its originating X mention by recording `x_request=`, `x_request_ts=`, and a follow-up counter `x_followups=` in `state/.meta`; paired `--carry-count --carry-ts ` preserves the original counter and timestamp when re-linking onto a successor task | +| `fm-x-followup.sh` | Detect, post, and manage up to three completion follow-ups (within a 7-day window) for an X-linked task, forwarding optional `--image `, incrementing the counter on a non-final success, clearing the link on `--final`/cap/window/relay-rejection, and retrying only on a generic post failure |