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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/operating-fundamentals/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Apply these principles together to maximize verified fleet-wide progress.

## 1. Orchestrate; never work inline

Turn every captain ask into both a durable backlog item and a tracked crewmate assignment before project or deliverable work begins.
File a durable backlog item for every captain ask, then make its tracked crewmate assignment before project or deliverable work begins; `bin/fm-spawn.sh` enforces the row-before-endpoint order for new ship and scout tasks.
Keep firstmate's own thread for intake, dispatch, supervision, decisions, and outcome reporting; never perform project investigation, planning, implementation, or deliverable production inline.
Treat the backlog record and tracked owner as an atomic pair, and repair either immediately when missing so work survives context loss.
A dropped or forgotten ask is an operating failure; restore its record and owner immediately.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,8 +469,8 @@ A backend spawn refusal - a missing dependency, an unauthenticated socket, or a
For ship and scout tasks, the script asserts the resolved worktree is a genuine isolated worktree distinct from the primary checkout, aborting the spawn otherwise to prevent the worktree tangle of section 8.
For `kind=secondmate`, it launches in the registered or explicit firstmate home with the charter brief as the launch prompt, after the guarded home sync and inheritable-config propagation owned by `secondmate-provisioning`.
Project worktrees start at detached HEAD on a clean default branch; ship briefs tell the crewmate to create its branch, while scout briefs keep the worktree scratch.
For a genuinely new ship or scout task, `bin/fm-spawn.sh` asserts an In flight or Queued backlog row before endpoint creation.
After spawning, peek the endpoint to confirm the crewmate is processing the brief and handle any trust dialog with `harness-adapters`.
For a ship or scout task, add the task to `data/backlog.md` under In flight.
A secondmate spawn adds no backlog row: its identity and scope live in `data/secondmates.md`, its runtime lives in `state/<id>.meta`, and section 10 owns the backlog contract.

### Supervise
Expand Down
103 changes: 100 additions & 3 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Spawn a direct report: a new crewmate in a treehouse worktree, an eligible
# pre-cutover Orca direct recovery with empirically verified provider authority,
# or a secondmate in its isolated firstmate home.
# Usage: fm-spawn.sh <task-id> <project-dir> [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>] [--account-pool <pool>] [--account-profile <profile>] [--no-account-routing] [--scout]
# Usage: fm-spawn.sh <task-id> <project-dir> [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>] [--account-pool <pool>] [--account-profile <profile>] [--no-account-routing] [--backlog-row-exemption <test-fixture|tracking-backend-repair>] [--scout]
# fm-spawn.sh <task-id> [<firstmate-home>] [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>] [--account-pool <pool>] [--account-profile <profile>] [--no-account-routing] --secondmate
# fm-spawn.sh <task-id> --recover-direct-account
# fm-spawn.sh <task-id> (--resume-account|--continue-account) [--harness <claude|codex>] [--account-pool <pool>] [--account-profile <profile>]
Expand Down Expand Up @@ -88,6 +88,9 @@
# --scout records kind=scout in the task's meta (report deliverable, scratch worktree;
# see AGENTS.md task lifecycle); --secondmate records kind=secondmate and launches in a
# provisioned firstmate home; the default is kind=ship.
# A genuinely new ship/scout spawn must already have an In flight or Queued row
# in this home's configured backlog. --backlog-row-exemption accepts only
# test-fixture or tracking-backend-repair and records the category in metadata.
# Before a secondmate launch, the home must fast-forward safely to the primary
# default-branch commit and independently match the live default tip.
# Any unproven freshness state refuses launch.
Expand All @@ -104,7 +107,8 @@
# Batch dispatch: pass one or more `id=repo` pairs instead of a single <id> <project>, e.g.
# fm-spawn.sh fix-a-k3=projects/foo add-b-q7=projects/bar [--scout]
# Each pair re-execs this script in single-task mode, so the single path stays the only
# source of truth; shared --scout/--harness/--model/--effort/--backend/account flags apply to every pair.
# source of truth; shared --scout/--harness/--model/--effort/--backend/account
# flags and --backlog-row-exemption apply to every pair.
# If config/crew-dispatch.json exists, shared --harness is required for crewmate
# and scout batches. The loop lives here, in bash, so callers never hand-write a
# multi-task shell loop (the tool shell is zsh, which does not word-split unquoted
Expand Down Expand Up @@ -154,6 +158,8 @@ CHECKOUT_LOCK_ROOT=$(fm_checkout_lock_root "$CHECKOUT_STATE_BASE")
. "$SCRIPT_DIR/fm-account-routing-lib.sh"
# shellcheck source=bin/fm-report-contract-lib.sh
. "$SCRIPT_DIR/fm-report-contract-lib.sh"
# shellcheck source=bin/fm-tasks-axi-lib.sh
. "$SCRIPT_DIR/fm-tasks-axi-lib.sh"
# shellcheck source=bin/fm-backend.sh
. "$SCRIPT_DIR/fm-backend.sh"
# shellcheck source=bin/fm-gate-refuse-lib.sh
Expand Down Expand Up @@ -340,6 +346,8 @@ BACKEND_ARG=
ACCOUNT_POOL=
ACCOUNT_PROFILE=
NO_ACCOUNT_ROUTING=0
BACKLOG_ROW_EXEMPTION=
BACKLOG_ROW_EXEMPTION_SET=0
RESUME_ACCOUNT=0
CONTINUE_ACCOUNT=0
DIRECT_ACCOUNT_RECOVERY=0
Expand Down Expand Up @@ -368,6 +376,7 @@ for a in "$@"; do
backend) BACKEND_ARG=$a; BACKEND_SET=1 ;;
account-pool) ACCOUNT_POOL=$a; ACCOUNT_POOL_SET=1 ;;
account-profile) ACCOUNT_PROFILE=$a; ACCOUNT_PROFILE_SET=1 ;;
backlog-row-exemption) BACKLOG_ROW_EXEMPTION=$a; BACKLOG_ROW_EXEMPTION_SET=1 ;;
*) echo "error: internal parser state for --$want_value" >&2; exit 1 ;;
esac
want_value=
Expand All @@ -389,6 +398,8 @@ for a in "$@"; do
--account-profile) want_value=account-profile ;;
--account-profile=*) ACCOUNT_PROFILE=${a#--account-profile=}; ACCOUNT_PROFILE_SET=1 ;;
--no-account-routing) NO_ACCOUNT_ROUTING=1 ;;
--backlog-row-exemption) want_value='backlog-row-exemption' ;;
--backlog-row-exemption=*) BACKLOG_ROW_EXEMPTION=${a#--backlog-row-exemption=}; BACKLOG_ROW_EXEMPTION_SET=1 ;;
--resume-account) RESUME_ACCOUNT=1 ;;
--continue-account) CONTINUE_ACCOUNT=1 ;;
--recover-direct-account) DIRECT_ACCOUNT_RECOVERY=1 ;;
Expand All @@ -402,6 +413,15 @@ done
[ "$BACKEND_SET" -eq 0 ] || [ -n "$BACKEND_ARG" ] || { echo "error: --backend requires a non-empty value" >&2; exit 1; }
[ "$ACCOUNT_POOL_SET" -eq 0 ] || [ -n "$ACCOUNT_POOL" ] || { echo "error: --account-pool requires a non-empty value" >&2; exit 1; }
[ "$ACCOUNT_PROFILE_SET" -eq 0 ] || [ -n "$ACCOUNT_PROFILE" ] || { echo "error: --account-profile requires a non-empty value" >&2; exit 1; }
[ "$BACKLOG_ROW_EXEMPTION_SET" -eq 0 ] || [ -n "$BACKLOG_ROW_EXEMPTION" ] \
|| { echo "error: --backlog-row-exemption requires a non-empty value" >&2; exit 1; }
case "$BACKLOG_ROW_EXEMPTION" in
''|test-fixture|tracking-backend-repair) ;;
*)
echo "error: --backlog-row-exemption must be one of test-fixture, tracking-backend-repair" >&2
exit 1
;;
esac
if [ "$NO_ACCOUNT_ROUTING" = 1 ] && { [ "$ACCOUNT_POOL_SET" = 1 ] || [ "$ACCOUNT_PROFILE_SET" = 1 ]; }; then
echo "error: --no-account-routing cannot be combined with --account-pool or --account-profile" >&2
exit 1
Expand All @@ -417,7 +437,7 @@ if [ "$DIRECT_ACCOUNT_RECOVERY" = 1 ]; then
if [ "$KIND_SET" = 1 ] || [ "$HARNESS_SET" = 1 ] || [ "$MODEL_SET" = 1 ] \
|| [ "$EFFORT_SET" = 1 ] || [ "$BACKEND_SET" = 1 ] \
|| [ "$ACCOUNT_POOL_SET" = 1 ] || [ "$ACCOUNT_PROFILE_SET" = 1 ] \
|| [ "$NO_ACCOUNT_ROUTING" = 1 ]; then
|| [ "$NO_ACCOUNT_ROUTING" = 1 ] || [ "$BACKLOG_ROW_EXEMPTION_SET" = 1 ]; then
echo "error: --recover-direct-account accepts only a task id; task context comes from metadata" >&2
exit 1
fi
Expand All @@ -431,6 +451,10 @@ esac

RECOVERY_ACCOUNT=0
[ "$RESUME_ACCOUNT" = 0 ] && [ "$CONTINUE_ACCOUNT" = 0 ] && [ "$DIRECT_ACCOUNT_RECOVERY" = 0 ] || RECOVERY_ACCOUNT=1
[ "$RECOVERY_ACCOUNT" = 0 ] || [ "$BACKLOG_ROW_EXEMPTION_SET" = 0 ] || {
echo "error: --backlog-row-exemption applies only to a genuinely new ship or scout task" >&2
exit 1
}
RESUME_META=
LIFECYCLE_LOCK=
LIFECYCLE_LOCK_OWNED=0
Expand Down Expand Up @@ -529,6 +553,62 @@ spawn_preflight_kind_value() {
esac
}

spawn_manual_backlog_has_row() { # <task-id> <backlog-file>
local task_id=$1 backlog=$2
[ -f "$backlog" ] || return 1
awk -v wanted="$task_id" '
/^## (In flight|Queued)[[:space:]]*$/ { active = 1; next }
/^## / { active = 0 }
!active { next }
{
line = $0
sub(/^[[:space:]]*-[[:space:]]*/, "", line)
sub(/^\[[ xX]\][[:space:]]*/, "", line)
if (substr(line, 1, 2) == "**") {
line = substr(line, 3)
closing_pos = index(line, "**")
if (closing_pos == 0) next
key = substr(line, 1, closing_pos - 1)
} else {
key = line
sub(/[[:space:]].*$/, "", key)
}
if (key == wanted) found = 1
}
END { exit(found ? 0 : 1) }
' "$backlog"
}

spawn_backlog_has_row() { # <task-id>
local task_id=$1 backlog task
backlog="$DATA/backlog.md"
if fm_tasks_axi_backend_available "$CONFIG"; then
task=$(tasks-axi show "$task_id" --backend markdown --file "$backlog" 2>/dev/null) || return 1
printf '%s\n' "$task" | grep -Eq '^[[:space:]]*state: (in_flight|queued)[[:space:]]*$'
return $?
fi
spawn_manual_backlog_has_row "$task_id" "$backlog"
}

spawn_shell_quote() { # <value>
printf "'"
printf '%s' "$1" | sed "s/'/'\\\\''/g"
printf "'"
}

spawn_refuse_missing_backlog_row() { # <task-id> <kind> <project-dir>
local task_id=$1 kind=$2 project=$3 repo backlog
repo=${project%/}
repo=${repo##*/}
[ -n "$repo" ] || repo=unknown
backlog="$DATA/backlog.md"
echo "error: new $kind task $task_id has no In flight or Queued row in $backlog; file it before dispatch." >&2
printf 'fix: tasks-axi add %s %s --kind %s --repo %s --start --backend markdown --file %s\n' \
"$(spawn_shell_quote "$task_id")" "$(spawn_shell_quote '<one line>')" \
"$(spawn_shell_quote "$kind")" "$(spawn_shell_quote "$repo")" \
"$(spawn_shell_quote "$backlog")" >&2
}

spawn_refuse_report_required_orca() {
local report_count
if [ "$SPAWN_META_PRESENT" != 1 ]; then
Expand Down Expand Up @@ -1827,6 +1907,7 @@ if [ "${#POS[@]}" -gt 0 ] && [ "${POS[0]}" != "$idpart" ] && case "$idpart" in *
[ -z "$ACCOUNT_POOL" ] || shared_args+=(--account-pool "$ACCOUNT_POOL")
[ -z "$ACCOUNT_PROFILE" ] || shared_args+=(--account-profile "$ACCOUNT_PROFILE")
[ "$NO_ACCOUNT_ROUTING" = 0 ] || shared_args+=(--no-account-routing)
[ -z "$BACKLOG_ROW_EXEMPTION" ] || shared_args+=(--backlog-row-exemption "$BACKLOG_ROW_EXEMPTION")
if [ "$RECOVERY_ACCOUNT" = 1 ]; then
echo "error: batch dispatch does not support account recovery; recover tasks individually" >&2
exit 1
Expand Down Expand Up @@ -1909,6 +1990,21 @@ else
ARG3=${POS[2]:-}
fi

if [ "$BACKLOG_ROW_EXEMPTION_SET" = 1 ] \
&& { [ "$KIND" = secondmate ] || [ "$SPAWN_META_PRESENT" = 1 ]; }; then
echo "error: --backlog-row-exemption applies only to a genuinely new ship or scout task" >&2
exit 1
fi
if [ "$KIND" != secondmate ] && [ "$RECOVERY_ACCOUNT" = 0 ] && [ "$SPAWN_META_PRESENT" = 0 ]; then
[ -f "$DATA/$ID/brief.md" ] || { echo "error: no brief at $DATA/$ID/brief.md" >&2; exit 1; }
if [ -n "$BACKLOG_ROW_EXEMPTION" ]; then
echo "WARNING: backlog row exemption '$BACKLOG_ROW_EXEMPTION' is active for new $KIND task $ID; the category will be recorded in task metadata" >&2
elif ! spawn_backlog_has_row "$ID"; then
spawn_refuse_missing_backlog_row "$ID" "$KIND" "$PROJ"
exit 1
fi
fi

if [ -z "$LIFECYCLE_LOCK" ]; then
LIFECYCLE_LOCK=$(fm_account_lifecycle_lock_acquire "$STATE" "$ID") || exit 1
LIFECYCLE_LOCK_OWNED=1
Expand Down Expand Up @@ -3583,6 +3679,7 @@ META_TMP=$(mktemp "$STATE/.$ID.meta.XXXXXX") || exit 1
echo "effort=${EFFORT:-default}"
echo "generation_id=$SPAWN_GENERATION_ID"
[ "$NO_ACCOUNT_ROUTING" != 1 ] || echo "account_routing_emergency_bypass=1"
[ -z "$BACKLOG_ROW_EXEMPTION" ] || echo "backlog_row_exemption=$BACKLOG_ROW_EXEMPTION"
[ -z "$DIRECT_ACCOUNT_HOME" ] || echo "account_home=$DIRECT_ACCOUNT_HOME"
if [ "$RECOVERY_ACCOUNT" = 1 ]; then
if grep -q '^report_required=' "$RESUME_META"; then
Expand Down
6 changes: 6 additions & 0 deletions tests/fm-account-directory.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,8 @@ make_spawn_case() {
"$home/treehouse-pools"
printf '%s\n' "$harness" > "$home/config/crew-harness"
printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md"
printf '# Backlog\n\n## In flight\n- [ ] %s - account directory spawn test (repo: project)\n\n## Queued\n\n## Done\n' \
"$id" > "$home/data/backlog.md"
touch "$home/state/.last-watcher-beat"
# A leased Treehouse worktree is handed over at detached HEAD on a clean
# default branch, and fm-spawn refuses one that is still attached to a branch
Expand Down Expand Up @@ -852,6 +854,7 @@ test_direct_recovery_preserves_recorded_task_context() {
set_remaining 1 20,15
set_remaining 2 90,85
rm -f "$SPAWN_HOME/state/.fake-endpoint"
printf '# Backlog\n\n## In flight\n\n## Queued\n\n## Done\n' > "$SPAWN_HOME/data/backlog.md"

out=$(run_direct_spawn "$SPAWN_HOME" "$SPAWN_WORKTREE" "$SPAWN_LAUNCH_LOG" \
"$id" --recover-direct-account 2>&1)
Expand Down Expand Up @@ -881,6 +884,9 @@ test_direct_recovery_preserves_recorded_task_context() {
assert_grep "generation_id=$generation" "$meta" "direct recovery replaced the task generation identity"
assert_grep "dispatch_profile_required=1" "$meta" "direct recovery dropped dispatch-profile metadata"
assert_grep "account_home=$ACCOUNT_ROOT/codex/2" "$meta" "direct recovery did not update account_home"
if grep -q "$id" "$SPAWN_HOME/data/backlog.md"; then
fail "direct recovery fixture unexpectedly retained its historical backlog row"
fi
[ ! -s "$TMP_ROOT/agent-fleet.log" ] || fail "direct recovery invoked Agent Fleet"
pass "direct recovery preserves recorded task context while refreshing account selection"
}
Expand Down
20 changes: 20 additions & 0 deletions tests/fm-account-routing.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -437,10 +437,13 @@ make_case() {
"$wt" "$first_id" > "$case_dir/treehouse-pool/treehouse-state.json"
fi
touch "$home/state/.last-watcher-beat"
printf '# Backlog\n\n## In flight\n' > "$home/data/backlog.md"
for id in "$@"; do
mkdir -p "$home/data/$id"
printf 'brief for %s\n' "$id" > "$home/data/$id/brief.md"
printf -- '- [ ] %s - account routing spawn test (repo: project)\n' "$id" >> "$home/data/backlog.md"
done
printf '\n## Queued\n\n## Done\n' >> "$home/data/backlog.md"
printf '%s|%s|%s|%s|%s\n' "$case_dir" "$home" "$proj" "$wt" "$fakebin"
}

Expand All @@ -465,6 +468,10 @@ EOF
FM_TEST_SPAWN_ROOT=
}

empty_case_backlog() {
printf '# Backlog\n\n## In flight\n\n## Queued\n\n## Done\n' > "$HOME_DIR/data/backlog.md"
}

run_spawn() {
local id=$1
FM_ROOT_OVERRIDE="${FM_TEST_SPAWN_ROOT:-${FM_TEST_ROOT_OVERRIDE:-}}" FM_HOME="$HOME_DIR" \
Expand Down Expand Up @@ -1206,6 +1213,7 @@ test_resume_uses_sticky_recovery_and_preserves_mapping_on_failure() {
grep -v '^report_required=' "$HOME_DIR/state/$id.meta" > "$HOME_DIR/state/$id.meta.precutover"
mv "$HOME_DIR/state/$id.meta.precutover" "$HOME_DIR/state/$id.meta"
rm -f "$CASE_DIR/endpoint-live"
empty_case_backlog
: > "$AF_LOG"
: > "$TMUX_LOG"
: > "$LAUNCH_LOG"
Expand All @@ -1224,6 +1232,7 @@ test_resume_uses_sticky_recovery_and_preserves_mapping_on_failure() {
assert_not_contains "$launch" 'cat ' "resume started a fresh prompted conversation"
[ "$(sed -n 's/^provider_session_id=//p' "$HOME_DIR/state/$id.meta" | tail -1)" = "$before_session" ] || fail "resume changed provider session identity"
assert_not_grep '^report_required=' "$HOME_DIR/state/$id.meta" "pre-cutover recovery silently activated the report gate"
assert_not_grep "$id" "$HOME_DIR/data/backlog.md" "resume recovery fixture retained its backlog row"

: > "$AF_LOG"
: > "$TMUX_LOG"
Expand Down Expand Up @@ -1484,13 +1493,15 @@ test_unmanaged_respawn_preserves_report_cutover_state() {
"pr=418" \
"x_request=req-legacy" \
"custom_extension=preserve-success"
empty_case_backlog
out=$(run_spawn "$id" "$PROJ_DIR" --account-pool claude-crew)
status=$?
[ "$status" -eq 0 ] || fail "pre-cutover unmanaged respawn should succeed (exit $status): $out"
assert_not_grep '^report_required=' "$HOME_DIR/state/$id.meta" "pre-cutover unmanaged respawn silently activated the report gate"
assert_grep 'pr=418' "$HOME_DIR/state/$id.meta" "managed respawn dropped the existing PR pointer"
assert_grep 'x_request=req-legacy' "$HOME_DIR/state/$id.meta" "managed respawn dropped the existing X-mode link"
assert_grep 'custom_extension=preserve-success' "$HOME_DIR/state/$id.meta" "managed respawn dropped extension metadata"
assert_not_grep "$id" "$HOME_DIR/data/backlog.md" "existing-metadata relaunch fixture retained its backlog row"
pass "unmanaged respawn preserves a legacy task's report cutover state"
}

Expand Down Expand Up @@ -3170,6 +3181,7 @@ test_failed_continuation_cleanup_restores_predecessor_for_retry() {
assert_grep "account_predecessor_task=$old_task" "$HOME_DIR/state/$id.meta" "failed continuation cleanup lost predecessor identity"

clear_case_logs
empty_case_backlog
out=$(FM_FAKE_AF_PROFILE=claude-3 FM_FAKE_AF_POOL=explicit run_spawn "$id" --continue-account --account-profile claude-3)
status=$?
[ "$status" -eq 0 ] || fail "continuation retry could not clean and replace its failed generation: $out"
Expand All @@ -3179,6 +3191,7 @@ test_failed_continuation_cleanup_restores_predecessor_for_retry() {
assert_grep "session remove --task $failed_task" "$AF_LOG" "continuation retry did not clean its failed mapping"
assert_grep "lease release --task $old_task --force" "$AF_LOG" "continuation retry did not clean its restored predecessor"
assert_not_grep '^account_rollback_' "$HOME_DIR/state/$id.meta" "continuation retry retained rollback metadata"
assert_not_grep "$id" "$HOME_DIR/data/backlog.md" "continuation recovery fixture retained its backlog row"
pass "failed continuation cleanup restores predecessor state before retry"
}

Expand Down Expand Up @@ -5944,6 +5957,13 @@ run_isolated_test() {
[ "$status" -eq 0 ] || exit "$status"
}

if [ "${FM_TEST_FOCUSED:-}" = backlog-row-exemptions ]; then
run_isolated_test test_resume_uses_sticky_recovery_and_preserves_mapping_on_failure
run_isolated_test test_unmanaged_respawn_preserves_report_cutover_state
run_isolated_test test_failed_continuation_cleanup_restores_predecessor_for_retry
exit 0
fi

if [ "${FM_TEST_FOCUSED:-}" = stale-reclaim-generation ]; then
run_isolated_test test_stale_reclaim_guard_is_owned_before_lock_removal
exit 0
Expand Down
2 changes: 2 additions & 0 deletions tests/fm-backend-autodetect-smoke.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ fm_herdr_lab_prepare "$SESSION" || fail "could not prepare isolated Herdr lab se
STATE="$TMP_ROOT/state"; DATA="$TMP_ROOT/data"; CONFIG="$TMP_ROOT/config"
mkdir -p "$STATE" "$DATA/$ID" "$CONFIG"
printf 'trivial autodetect-smoke brief: nothing to do.\n' > "$DATA/$ID/brief.md"
printf '# Backlog\n\n## In flight\n- [ ] %s - autodetect smoke (repo: project)\n\n## Queued\n\n## Done\n' \
"$ID" > "$DATA/backlog.md"

PROJ="$TMP_ROOT/scratch-project"
mkdir -p "$PROJ"
Expand Down
Loading