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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,8 @@ If one pair fails, the rest still run and the batch exits non-zero.
The script resolves the harness (`fm-harness.sh crew`), owns the verified launch templates, resolves the project's delivery mode (`fm-project-mode.sh`) for ship/scout tasks, and records `harness=`, `kind=`, `mode=`, `yolo=`, `pane=`, `domain=`, `workspace=`, and `worker=` in the task's meta; a non-flag third argument containing whitespace is treated as a raw launch command (only for verifying new adapters).
For `kind=secondmate`, the same script launches in the registered or explicit firstmate home instead of creating a project worktree, records `home=` and `projects=`, and uses the charter brief as the launch prompt.

For ship and scout tasks, the script creates a git worktree via `git worktree add -b "fm/<id>" "$FM_WORKTREE_BASE/<id>" HEAD`, resolves the domain/project herdr workspace (label = the project; created when absent, reused when it already exists), starts the agent in its OWN tab inside that workspace labelled `<supervisor>/<task-slug>`, closes the tab's leftover root shell so the tab is a single agent pane, parses the returned `pane_id`, records `state/<id>.meta`, and submits the brief. No `workspace_id` is recorded: the workspace is shared across the domain's tasks, so teardown cleans up only this task's pane and git worktree, never the whole workspace.
For ship and scout tasks, `bin/fm-resolve-spawn.sh` runs a preflight check before any git or herdr state is created: it verifies the harness binary is on PATH, warns if the project is not in the registry, and confirms the worktree base is writable; a failure aborts the spawn immediately.
The script then creates a git worktree via `git worktree add -b "fm/<id>" "$FM_WORKTREE_BASE/<id>" HEAD`, resolves the domain/project herdr workspace (label = the project; created when absent, reused when it already exists), starts the agent in its OWN tab inside that workspace labelled `<supervisor>/<task-slug>`, closes the tab's leftover root shell so the tab is a single agent pane, parses the returned `pane_id`, records `state/<id>.meta`, and submits the brief. No `workspace_id` is recorded: the workspace is shared across the domain's tasks, so teardown cleans up only this task's pane and git worktree, never the whole workspace.
For `kind=secondmate`, the script launches in the persistent home, placed as its own tab inside the single `ship` workspace (never a split of the focused tab) and labelled by the mate's name. If the seeded home is missing the shared `AGENTS.md` or `bin/`, fm-spawn auto-links them from the firstmate repo so the home is valid by construction.
Project worktrees start on a fresh branch off the default; ship briefs tell the crewmate to use that branch, while scout briefs keep the worktree scratch.
After spawning, peek the pane to confirm the crewmate is processing the brief (and handle any trust dialog per section 4).
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ The first mate drives these; you rarely need to, but they work by hand too.
| `fm-guard.sh` | Warn when tasks are in flight but queued wakes are pending or the watcher liveness beacon is stale or missing |
| `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` |
| `fm-resolve-spawn.sh` | Preflight check called by `fm-spawn.sh`: verifies harness binary is on PATH, warns if project is unregistered, and confirms worktree base is writable before any git or herdr state is created |
| `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 against the authoritative base, with optional `--stat` output |
Expand Down Expand Up @@ -250,6 +251,7 @@ tests/fm-update.test.sh # fast-forward-only self-update, rerea
tests/fm-secondmate.test.sh # persistent secondmate routing, seeding, idle charter, backlog handoff, spawn, recovery, teardown, and FM_HOME tests
tests/fm-teardown.test.sh # fm-teardown.sh safety and reminder checks: local-only fork-remote allow, truly-unpushed refuse, merged-to-main allow, no-mistakes regression, tasks-axi reminder, --force override
tests/fm-watch-stale.test.sh # watcher stale-skip behavior: PR-ready tasks awaiting merge, ordinary idle panes, done tasks without pr=, secondmate panes, and merge-poll continuity
tests/fm-resolve-spawn.test.sh # spawn resolver preflight: harness binary check, unregistered-project warn, worktree base check, and abort-before-worktree integration with fm-spawn
[ "$(readlink CLAUDE.md)" = "AGENTS.md" ]
[ "$(readlink .claude/skills)" = "../.agents/skills" ]
FM_HEARTBEAT=2 FM_POLL=1 bin/fm-watch.sh # watcher smoke test (prints "heartbeat")
Expand Down
67 changes: 67 additions & 0 deletions bin/fm-resolve-spawn.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Validate spawn prerequisites before fm-spawn creates a git worktree or herdr pane.
# Usage: fm-resolve-spawn.sh <project> [harness-override]
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}}"
DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}"

project=${1:-}
harness_arg=${2:-}

if [ -z "$project" ]; then
echo "error: fm-resolve-spawn requires a project argument" >&2
exit 2
fi

first_command_word() {
local launch=$1 word
for word in $launch; do
case "$word" in
[A-Za-z_]*=*) continue ;;
*) basename "$word"; return 0 ;;
esac
done
return 1
}

if [ -z "$harness_arg" ]; then
harness=$("$FM_ROOT/bin/fm-harness.sh" crew)
elif printf '%s' "$harness_arg" | grep '[[:space:]]' >/dev/null; then
harness=$(first_command_word "$harness_arg" || true)
else
harness=$harness_arg
fi

if [ -z "$harness" ]; then
echo "error: could not resolve spawn harness; check config/crew-harness" >&2
exit 1
fi

if ! command -v "$harness" >/dev/null 2>&1; then
echo "error: spawn harness binary '$harness' was not found on PATH; check config/crew-harness or pass an available harness override" >&2
exit 1
fi

project_name=${project%/}
project_name=${project_name##*/}
registry="$DATA/projects.md"
if [ -f "$registry" ]; then
if ! grep -F -e "- ${project_name} " "$registry" >/dev/null 2>&1; then
echo "warn: project '$project_name' does not appear in $registry; continuing because direct paths are allowed" >&2
fi
else
echo "warn: project registry $registry is missing; continuing because direct paths are allowed" >&2
fi

wtbase=${FM_WORKTREE_BASE:-$FM_HOME/worktrees}
if [ -e "$wtbase" ]; then
[ -d "$wtbase" ] || { echo "error: worktree base '$wtbase' exists but is not a directory" >&2; exit 1; }
[ -w "$wtbase" ] || { echo "error: worktree base '$wtbase' is not writable" >&2; exit 1; }
else
parent=$(dirname "$wtbase")
[ -d "$parent" ] || { echo "error: worktree base parent '$parent' does not exist" >&2; exit 1; }
[ -w "$parent" ] || { echo "error: worktree base parent '$parent' is not writable" >&2; exit 1; }
fi
4 changes: 4 additions & 0 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ else
ARG3=${POS[2]:-}
fi

if [ "$KIND" != secondmate ]; then
"$FM_ROOT/bin/fm-resolve-spawn.sh" "$PROJ" "$ARG3"
fi

# Launch templates per adapter. No turn-end hook placeholders needed since
# herdr tracks agent status natively. __BRIEF__ is still used.
launch_template() {
Expand Down
119 changes: 119 additions & 0 deletions tests/fm-resolve-spawn.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
# Fast prerequisite checks for fm-resolve-spawn.sh.
set -u

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RESOLVE="$ROOT/bin/fm-resolve-spawn.sh"
SPAWN="$ROOT/bin/fm-spawn.sh"
TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-resolve-spawn.XXXXXX")
trap 'rm -rf "$TMP_ROOT"' EXIT

fail() { printf 'not ok - %s\n' "$1" >&2; exit 1; }
pass() { printf 'ok - %s\n' "$1"; }

make_home() {
local name=$1 home
home="$TMP_ROOT/$name"
mkdir -p "$home/data" "$home/worktrees"
printf '%s\n' '- alpha [no-mistakes] - test project (added 2026-06-25)' > "$home/data/projects.md"
printf '%s\n' "$home"
}

make_fakebin() {
local dir=$1 name=$2
mkdir -p "$dir"
printf '#!/usr/bin/env bash\nexit 0\n' > "$dir/$name"
chmod +x "$dir/$name"
}

run_resolve() {
local home=$1 path=$2; shift 2
PATH="$path" \
FM_ROOT_OVERRIDE='' \
FM_HOME="$home" \
FM_DATA_OVERRIDE='' \
FM_WORKTREE_BASE="$home/worktrees" \
"$RESOLVE" "$@" 2>&1
}

test_missing_harness_binary_blocks() {
local home out status
home=$(make_home missing-harness)
out=$(run_resolve "$home" /usr/bin:/bin alpha codex)
status=$?
[ "$status" -ne 0 ] || fail "missing harness should fail"
printf '%s\n' "$out" | grep -F "spawn harness binary 'codex' was not found on PATH" >/dev/null \
|| fail "error did not name missing binary: $out"
printf '%s\n' "$out" | grep -F 'check config/crew-harness' >/dev/null \
|| fail "error did not suggest config/crew-harness: $out"
pass "missing harness binary blocks before spawn"
}

test_registry_miss_warns_but_allows() {
local home fakebin out status
home=$(make_home registry-warn)
fakebin="$TMP_ROOT/fakebin-registry"
make_fakebin "$fakebin" omp
out=$(run_resolve "$home" "$fakebin:/usr/bin:/bin" beta omp)
status=$?
[ "$status" -eq 0 ] || fail "registry miss should not fail: $out"
printf '%s\n' "$out" | grep -F "warn: project 'beta' does not appear" >/dev/null \
|| fail "registry miss did not warn: $out"
pass "unregistered project warns but does not block"
}

test_registered_project_is_quiet() {
local home fakebin out status
home=$(make_home registry-hit)
fakebin="$TMP_ROOT/fakebin-hit"
make_fakebin "$fakebin" omp
out=$(run_resolve "$home" "$fakebin:/usr/bin:/bin" projects/alpha omp)
status=$?
[ "$status" -eq 0 ] || fail "registered project should pass: $out"
[ -z "$out" ] || fail "registered project should not warn: $out"
pass "registered project passes quietly"
}

test_missing_worktree_parent_blocks() {
local home fakebin out status missing_base
home=$(make_home missing-worktree-parent)
fakebin="$TMP_ROOT/fakebin-worktree"
make_fakebin "$fakebin" omp
missing_base="$home/no-such-parent/worktrees"
out=$(PATH="$fakebin:/usr/bin:/bin" FM_ROOT_OVERRIDE='' FM_HOME="$home" FM_WORKTREE_BASE="$missing_base" "$RESOLVE" alpha omp 2>&1)
status=$?
[ "$status" -ne 0 ] || fail "missing worktree parent should fail"
printf '%s\n' "$out" | grep -F "worktree base parent" >/dev/null \
|| fail "worktree error was unclear: $out"
pass "missing worktree base parent blocks"
}

test_spawn_aborts_before_worktree_or_pane() {
local home out status
home=$(make_home spawn-preflight)
mkdir -p "$home/projects/alpha" "$home/data/preflight-z9"
(
cd "$home/projects/alpha" || exit 1
git init -q
git config user.email t@t
git config user.name t
printf 'x\n' > seed.txt
git add seed.txt
git commit -qm init
)
printf 'brief\n' > "$home/data/preflight-z9/brief.md"
out=$(PATH="/usr/bin:/bin" FM_ROOT_OVERRIDE='' FM_HOME="$home" FM_SPAWN_NO_GUARD=1 "$SPAWN" preflight-z9 projects/alpha codex 2>&1)
status=$?
[ "$status" -ne 0 ] || fail "spawn with missing harness should fail"
printf '%s\n' "$out" | grep -F "spawn harness binary 'codex' was not found on PATH" >/dev/null \
|| fail "spawn did not surface resolver error: $out"
[ ! -e "$home/worktrees/preflight-z9" ] \
|| fail "spawn created a worktree after resolver failure"
pass "fm-spawn aborts before creating a worktree or pane"
}

test_missing_harness_binary_blocks
test_registry_miss_warns_but_allows
test_registered_project_is_quiet
test_missing_worktree_parent_blocks
test_spawn_aborts_before_worktree_or_pane
25 changes: 19 additions & 6 deletions tests/fm-spawn-batch.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ pass() {
printf 'ok - %s\n' "$1"
}

make_fakebin() {
local dir=$1 name=$2
mkdir -p "$dir"
printf '#!/usr/bin/env bash\nexit 0\n' > "$dir/$name"
chmod +x "$dir/$name"
}

# Clear ambient firstmate overrides so the behavior test owns its environment.
# Use a known harness in targeted calls that must reach the missing-brief check.
run_spawn() {
Expand Down Expand Up @@ -89,11 +96,14 @@ test_id_with_slash_is_not_batch() {
}

test_fm_home_scopes_projects_path() {
local home out status expected
local home fakebin out status expected
home="$TMP_ROOT/home path"
fakebin="$TMP_ROOT/fakebin-home"
mkdir -p "$home/data" "$home/projects/alpha"
out=$(FM_ROOT_OVERRIDE='' FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' FM_PROJECTS_OVERRIDE='' FM_CONFIG_OVERRIDE='' \
FM_HOME="$home" FM_SPAWN_NO_GUARD=1 "$SPAWN" nope-home-z7 projects/alpha codex 2>&1)
make_fakebin "$fakebin" omp
out=$(PATH="$fakebin:/usr/bin:/bin" \
FM_ROOT_OVERRIDE='' FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' FM_PROJECTS_OVERRIDE='' FM_CONFIG_OVERRIDE='' \
FM_HOME="$home" FM_SPAWN_NO_GUARD=1 "$SPAWN" nope-home-z7 projects/alpha omp 2>&1)
status=$?
[ "$status" -ne 0 ] || fail "spawn with missing brief should fail"
expected="error: no brief at $home/data/nope-home-z7/brief.md"
Expand All @@ -106,12 +116,15 @@ test_fm_home_scopes_projects_path() {
}

test_fm_projects_override_scopes_projects_path() {
local home projects out status expected
local home projects fakebin out status expected
home="$TMP_ROOT/override home"
projects="$TMP_ROOT/override projects"
fakebin="$TMP_ROOT/fakebin-override"
mkdir -p "$home/data" "$projects/alpha"
out=$(FM_ROOT_OVERRIDE='' FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' FM_CONFIG_OVERRIDE='' \
FM_HOME="$home" FM_PROJECTS_OVERRIDE="$projects" FM_SPAWN_NO_GUARD=1 "$SPAWN" nope-override-z8 projects/alpha codex 2>&1)
make_fakebin "$fakebin" omp
out=$(PATH="$fakebin:/usr/bin:/bin" \
FM_ROOT_OVERRIDE='' FM_STATE_OVERRIDE='' FM_DATA_OVERRIDE='' FM_CONFIG_OVERRIDE='' \
FM_HOME="$home" FM_PROJECTS_OVERRIDE="$projects" FM_SPAWN_NO_GUARD=1 "$SPAWN" nope-override-z8 projects/alpha omp 2>&1)
status=$?
[ "$status" -ne 0 ] || fail "spawn with missing brief should fail"
expected="error: no brief at $home/data/nope-override-z8/brief.md"
Expand Down