Skip to content
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ state/ volatile runtime signals; gitignored

A `state/<id>.status` line is a wake event, not current-state truth; `bin/fm-crew-state.sh` owns current-state reconciliation.
Treat `data/captain.md` as the domain-local record of captain preferences, optional `data/captain-shared.md` as the main-authoritative shared captain-preference file for secondmate inheritance, and `data/learnings.md` as curated home-local knowledge, regardless of harness memory.
`state/<id>.meta` records `kind=adopted` for a task registered by `bin/fm-adopt.sh` instead of spawned (section 7); see that script's header for its exact fields.

## 3. Session start (run once at every session start)

Expand Down Expand Up @@ -194,6 +195,7 @@ Reconcile only this home's recorded direct reports and their recorded backend in
For an ordinary direct report whose endpoint is dead or metadata has no window, load `stuck-crewmate-recovery` and preserve the recorded worktree and unlanded work while reconciling ownership.
For a dead secondmate direct report, load `secondmate-provisioning` and reconcile only that secondmate, never its whole child tree from the main home.
Each secondmate reconciles work already in its own home and then idles; recovery never authorizes it to invent work.
For a dead `kind=adopted` direct report, there is no worktree or branch to reconcile; re-run `bin/fm-adopt.sh` against the workspace's new id to re-register it, or tear the task down to un-register it.

If away mode is present, load `/afk` and let its daemon own supervision rather than arming another cycle.
Surface only captain-relevant decisions, review-ready PRs, failures, and credential needs; otherwise resume the emitted supervision protocol silently.
Expand Down Expand Up @@ -344,6 +346,14 @@ Before treating the investigation or any visual review as complete, load `decisi
When implementation is separately authorized, promote the existing scout through `bin/fm-promote.sh` rather than creating a duplicate task.
The promoted worker must inventory scratch state, return to a clean default-branch base, carry over only intended fix changes, create the ship branch, and follow the project's selected delivery path while leaving scratch commits and debug edits behind and turning a reproduced bug into the regression test.

### Adopt (existing session)

Adopt registers an already-running cmux workspace - one a human started, not a firstmate spawn - as a supervised task, so peek, send, current-state reads, and the watcher work on it without a firstmate worktree or branch.
Run `bin/fm-adopt.sh <id> --workspace <ws-uuid> [--surface <surf-uuid>]`; it is cmux-only and experimental, and the task is supervise-only, with no delivery mode, no validation, and no PR.
Teardown un-registers the task without ever closing the human's workspace, and the watcher exempts it from stale-pane wakes.
As with a spawn, the orchestrator adds the `data/backlog.md` In flight line, so record the task there after adopting.
See the script's header comment for the full mechanics.

## 8. Supervision protocol

Fleet supervision is an always-loaded operational contract; `docs/architecture.md`, `docs/turnend-guard.md`, the emitted session-start block, and script help own mechanisms and harness-specific recipes.
Expand Down
148 changes: 148 additions & 0 deletions bin/fm-adopt.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# Adopt an ALREADY-RUNNING cmux workspace as a supervised firstmate task.
# Usage: fm-adopt.sh <id> --workspace <ws-uuid> [--surface <surf-uuid>] [--backend cmux]
#
# Registers a human-started cmux workspace/surface (NOT firstmate-spawned) as a
# task, so fm-peek.sh, fm-send.sh, fm-crew-state.sh, and the watcher operate on
# it. Unlike a spawn there is NO treehouse worktree and NO branch: worktree=
# records the workspace's OWN live cwd (a real existing directory, never a
# firstmate-managed worktree), and the task is supervise-only - it carries no
# delivery mode and never produces a PR. It records kind=adopted (and
# mode=adopted) so bin/fm-teardown.sh un-registers the task WITHOUT ever closing
# the human's cmux workspace, and bin/fm-watch.sh exempts it from stale-pane
# wakes (a human-driven or idle session being quiet is healthy, not stuck).
#
# The human's cmux workspace title is kept exactly as-is - never renamed.
# Peek/send target the surface by pure UUID because kind=adopted makes the
# expected-label empty (bin/fm-backend.sh's fm_backend_expected_label_of_selector),
# so no firstmate-scoped title check can reject the human's own title.
#
# cmux only, EXPERIMENTAL (docs/cmux-backend.md); any other backend refuses with
# an actionable error. Absent an app relaunch, cmux workspace ids are stable for
# the session; adopted workspaces are not fm-titled, so title-based recovery
# cannot re-find them after a relaunch - re-run fm-adopt then.
#
# As with fm-spawn, adding the data/backlog.md "In flight" line is the
# orchestrator's job, not this script's.
# On success prints: adopted <id> backend=cmux window=<ws>:<surface> worktree=<path>
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}}"
STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"

# shellcheck source=bin/fm-backend.sh
. "$SCRIPT_DIR/fm-backend.sh"

"$SCRIPT_DIR/fm-guard.sh" || true

ID=
WS=
SURFACE=
BACKEND=cmux
want_value=
for a in "$@"; do
if [ -n "$want_value" ]; then
case "$a" in
--*) echo "error: --$want_value requires a value" >&2; exit 1 ;;
esac
case "$want_value" in
workspace) WS=$a ;;
surface) SURFACE=$a ;;
backend) BACKEND=$a ;;
esac
want_value=
continue
fi
case "$a" in
--workspace) want_value=workspace ;;
--workspace=*) WS=${a#--workspace=} ;;
--surface) want_value=surface ;;
--surface=*) SURFACE=${a#--surface=} ;;
--backend) want_value=backend ;;
--backend=*) BACKEND=${a#--backend=} ;;
--*) echo "error: unknown flag $a" >&2; exit 1 ;;
*)
if [ -z "$ID" ]; then
ID=$a
else
echo "error: unexpected extra argument '$a'" >&2; exit 1
fi
;;
esac
done
[ -z "$want_value" ] || { echo "error: --$want_value requires a value" >&2; exit 1; }

[ -n "$ID" ] || { echo "usage: fm-adopt.sh <id> --workspace <ws-uuid> [--surface <surf-uuid>] [--backend cmux]" >&2; exit 1; }
[ -n "$WS" ] || { echo "error: --workspace <ws-uuid> is required" >&2; exit 1; }
[ ! -f "$STATE/$ID.meta" ] || { echo "error: task $ID already registered ($STATE/$ID.meta)" >&2; exit 1; }
if [ "$BACKEND" != cmux ]; then
echo "error: fm-adopt currently supports only the cmux backend; got '$BACKEND'" >&2
exit 1
fi

fm_backend_validate_spawn cmux || exit 1
fm_backend_source cmux || exit 1
# Client version gate plus socket reachability (launches cmux if simply down).
fm_backend_cmux_container_ensure || exit 1

if [ -z "$SURFACE" ]; then
SURFACE=$(fm_backend_cmux_surface_id_for_workspace "$WS" || true)
[ -n "$SURFACE" ] || { echo "error: could not resolve a surface for cmux workspace $WS; pass --surface <surf-uuid> or check 'cmux workspace list'" >&2; exit 1; }
fi

T="$WS:$SURFACE"
# Pure-UUID liveness check (no expected-label, so no title scoping): the surface
# must currently exist in the running app.
if ! fm_backend_target_exists cmux "$T"; then
echo "error: no live cmux surface $T; is the workspace open? run 'cmux workspace list'" >&2
exit 1
fi

# Refuse if this exact surface is already registered under a DIFFERENT task id.
# The self-id collision is caught above ($STATE/$ID.meta); this catches adopting
# the same live cmux surface a second time under a new id, which would have two
# tasks supervise - and send into - one surface. Compare the full window= line
# (workspace:surface) as a fixed string, since WS/SURFACE are opaque uuids.
if [ -d "$STATE" ]; then
for other in "$STATE"/*.meta; do
[ -e "$other" ] || continue
[ "$other" = "$STATE/$ID.meta" ] && continue
if grep -qxF "window=$T" "$other"; then
echo "error: cmux surface $T is already registered as task '$(basename "$other" .meta)' ($other); tear that task down first, or adopt a different surface" >&2
exit 1
fi
done
fi

# Derive worktree/project from the workspace's own live cwd. Adopt never creates
# a treehouse worktree; worktree= must be a real existing directory so
# fm-crew-state.sh can probe it, and it must never point at a firstmate-managed
# worktree. Fall back to $HOME with a loud notice when cmux reports no usable cwd.
WSCWD=$(fm_backend_cmux_cli workspace list --json --id-format uuids 2>/dev/null | jq -r --arg id "$WS" '.workspaces[]? | select(.id==$id) | .current_directory // empty' 2>/dev/null || true)
if [ -z "$WSCWD" ] || [ ! -d "$WSCWD" ]; then
echo "NOTICE: cmux workspace $WS reported no usable current_directory; falling back to \$HOME for worktree=, so fm-crew-state.sh's worktree probe may be limited." >&2
WSCWD=$HOME
fi
WORKTREE=$WSCWD
PROJECT=$WSCWD

mkdir -p "$STATE"
{
echo "window=$WS:$SURFACE"
echo "endpoint_task_id=$ID"
echo "worktree=$WORKTREE"
echo "project=$PROJECT"
echo "harness=adopted"
echo "kind=adopted"
echo "mode=adopted"
echo "yolo=off"
echo "model=default"
echo "effort=default"
echo "backend=cmux"
echo "cmux_workspace_id=$WS"
echo "cmux_surface_id=$SURFACE"
} > "$STATE/$ID.meta"

echo "adopted $ID backend=cmux window=$WS:$SURFACE worktree=$WORKTREE"
8 changes: 7 additions & 1 deletion bin/fm-backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,13 @@ fm_backend_of_selector() { # <raw-target> <resolved-target> <state-dir>
fm_backend_expected_label_of_selector() { # <raw-target> <state-dir>
local raw=$1 state=$2 id
id=$(fm_backend_task_id_for_selector "$raw" "$state" 2>/dev/null || true)
[ -n "$id" ] && printf 'fm-%s' "$id"
# An adopted task (kind=adopted) carries the human's OWN cmux workspace
# title, not a firstmate-scoped fm-<id> title, so a scoped-title label
# check would wrongly reject it; leave the expected-label empty so
# peek/send target it by pure UUID (surface_exists), never by title.
if [ -n "$id" ] && [ "$(fm_meta_get "$state/$id.meta" kind)" != adopted ]; then
printf 'fm-%s' "$id"
fi
return 0
}

Expand Down
8 changes: 7 additions & 1 deletion bin/fm-crew-state.sh
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,16 @@ LOG_VERB=$(status_line_verb "$LOG_LINE")
# herdr task is read through fm_backend_capture instead of a bare tmux probe.
TASK_BACKEND=$(fm_backend_of_meta "$META")
BACKEND_TARGET=$(fm_backend_target_of_meta "$META")
EXPECTED_LABEL="fm-$ID"
EXPECTED_LABEL=$(fm_backend_expected_label_of_selector "fm-$ID" "$STATE")
pane_readable() { # <target>
case "$TASK_BACKEND" in
tmux) tmux display-message -p -t "$1" '#{pane_id}' >/dev/null 2>&1 ;;
# cmux liveness is a STRUCTURAL existence check (list-panes), never a content
# read: cmux read-screen fails on a genuinely fresh surface that has never
# been written to (docs/cmux-backend.md), so an adopted task's untouched
# surface would otherwise be misread as "backend target gone". EXPECTED_LABEL
# is empty for kind=adopted, so this targets by pure uuid.
cmux) fm_backend_target_exists cmux "$1" "$EXPECTED_LABEL" >/dev/null 2>&1 ;;
*) fm_backend_capture "$TASK_BACKEND" "$1" 1 "$EXPECTED_LABEL" >/dev/null 2>&1 ;;
esac
}
Expand Down
3 changes: 2 additions & 1 deletion bin/fm-session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,8 @@ for meta in "$STATE"/*.meta; do
target=$(fm_backend_target_of_meta "$meta")
if [ -n "$window" ]; then
backend=$(fm_backend_of_meta "$meta")
if fm_backend_target_exists "$backend" "${target:-$window}" "fm-$id"; then
label=$(fm_backend_expected_label_of_selector "fm-$id" "$STATE")
if fm_backend_target_exists "$backend" "${target:-$window}" "$label"; then
printf 'endpoint: alive (backend=%s window=%s)\n' "$backend" "$window"
else
printf 'endpoint: dead (backend=%s window=%s)\n' "$backend" "$window"
Expand Down
29 changes: 23 additions & 6 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,13 @@ work_is_landed() {
backlog_refresh_reminder() {
local pr done_cmd report_path
[ "$KIND" = secondmate ] && return 0
# An adopted task is un-registered, not "completed": its cmux workspace was
# left running (never closed) and there is no PR or merge, so it never gets a
# tasks-axi done --pr prompt.
if [ "$KIND" = adopted ]; then
printf '%s\n' "Backlog: adopted task $ID released - its cmux workspace was left running (never closed) and $ID is un-registered from firstmate. If you tracked it in data/backlog.md, move it to Done manually (no PR or merge is involved), then re-scan Queued for work whose blockers are gone and date is due."
return 0
fi
if fm_tasks_axi_backend_available "$CONFIG"; then
case "$KIND" in
scout)
Expand Down Expand Up @@ -1369,11 +1376,21 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != scout ] && [ "$KIND" != secondmate ] &&
fi

if [ -d "$WT" ] && [ "$FORCE" != "--force" ]; then
if validate_worktree_teardown_safety; then
if [ "$KIND" = adopted ]; then
# An adopted task owns no firstmate worktree - worktree= is the human's own
# live cwd - so there is nothing to land or dirty-check; teardown just
# un-registers it. Checked before validate_worktree_teardown_safety so its
# REFUSED side-effect never fires on the human's real, unlanded cwd.
:
else
safety_rc=$?
if [ "$safety_rc" -eq "$TEARDOWN_WORKTREE_SAFETY_LOCK_BLOCKED" ]; then
# Call as a standalone command guarded by ||, so set -e does not abort on a
# non-zero return and safety_rc captures the safety function's own exit code
# (not a preceding test's - the SC2319 trap the adopted-branch reorder fixed).
safety_rc=0
validate_worktree_teardown_safety || safety_rc=$?
if [ "$safety_rc" -eq 0 ]; then
:
elif [ "$safety_rc" -eq "$TEARDOWN_WORKTREE_SAFETY_LOCK_BLOCKED" ]; then
cleanup_stale_lock_for_safety_check "$WT" || exit 1
validate_worktree_teardown_safety || exit 1
else
Expand Down Expand Up @@ -1417,7 +1434,7 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then
fi
[ -z "$T_ORCA" ] || fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true
fm_backend_remove_worktree "$BACKEND" "$ORCA_WORKTREE_ID"
elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then
elif [ -d "$WT" ] && [ "$KIND" != secondmate ] && [ "$KIND" != adopted ]; then
branch=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)
if [ "$branch" != "HEAD" ]; then
if git -C "$WT" checkout --detach -q 2>/dev/null; then
Expand Down Expand Up @@ -1477,7 +1494,7 @@ elif [ "$BACKEND" = herdr ]; then
else
echo "warning: herdr session presentation lock path is unavailable; skipping the pane close rather than closing unlocked" >&2
fi
elif [ "$BACKEND" != orca ]; then
elif [ "$BACKEND" != orca ] && [ "$KIND" != adopted ]; then
fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true
fi
if [ "$HERDR_PRESENTATION_RETIRE_CANDIDATE" = 1 ]; then
Expand Down Expand Up @@ -1523,7 +1540,7 @@ retire_busy_state "$STATE" "$ID" "$BUSY_GEN" || exit 1
rm -f "$STATE/$ID.status" "$STATE/$ID.turn-ended" "$STATE/$ID.meta" \
"$STATE/$ID.pi-ext.ts" "$STATE/$ID.grok-turnend-token" \
"$STATE/$ID.kimi-turnend-token"
if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$MODE" != local-only ]; then
if [ "$KIND" != scout ] && [ "$KIND" != secondmate ] && [ "$KIND" != adopted ] && [ "$MODE" != local-only ]; then
"$FM_ROOT/bin/fm-fleet-sync.sh" "$PROJ" || true
fi
echo "teardown $ID complete (window $T, worktree $WT)"
Expand Down
10 changes: 10 additions & 0 deletions bin/fm-watch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,13 @@ EOF
# remembers the hash already classified).
while IFS= read -r w; do
kind=$(window_kind "$w")
# An adopted human-driven or idle session being quiet is healthy - it is
# not a stuck crewmate. Its parent supervises through status writes and
# heartbeats, not pane-idle staleness, and it has no declared-pause
# concept, so it is exempt unconditionally.
if [ "$kind" = adopted ]; then
continue
fi
task=$(window_to_task "$w" "$STATE")
key=${w//:/_}
key=${key//\//_}
Expand All @@ -866,6 +873,9 @@ EOF
if ! status_is_paused_or_captain_held "$last" && [ -e "$STATE/.paused-$key" ]; then
clear_pause_tracking "$w"
fi
# A secondmate idling on its own watcher is healthy too, unless it
# DECLARED a pause - then it falls through so the paused-recheck logic
# below can run instead of being skipped outright.
if [ "$kind" = secondmate ] && ! status_is_paused "$last"; then
continue
fi
Expand Down
Loading
Loading