From 15c0aba0371bc58425e1770fce28db6cf49d0a21 Mon Sep 17 00:00:00 2001 From: Ryan Li Date: Thu, 25 Jun 2026 01:09:59 -0700 Subject: [PATCH 1/3] Add spawn resolver preflight --- bin/fm-resolve-spawn.sh | 67 +++++++++++++++++++ bin/fm-spawn.sh | 4 ++ tests/fm-resolve-spawn.test.sh | 119 +++++++++++++++++++++++++++++++++ tests/fm-spawn-batch.test.sh | 4 +- 4 files changed, 192 insertions(+), 2 deletions(-) create mode 100755 bin/fm-resolve-spawn.sh create mode 100755 tests/fm-resolve-spawn.test.sh diff --git a/bin/fm-resolve-spawn.sh b/bin/fm-resolve-spawn.sh new file mode 100755 index 0000000..af65a38 --- /dev/null +++ b/bin/fm-resolve-spawn.sh @@ -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 [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} " -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 diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 6c58ca4..1881d06 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -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() { diff --git a/tests/fm-resolve-spawn.test.sh b/tests/fm-resolve-spawn.test.sh new file mode 100755 index 0000000..20c02e5 --- /dev/null +++ b/tests/fm-resolve-spawn.test.sh @@ -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 diff --git a/tests/fm-spawn-batch.test.sh b/tests/fm-spawn-batch.test.sh index 7f36a1a..974b1b5 100755 --- a/tests/fm-spawn-batch.test.sh +++ b/tests/fm-spawn-batch.test.sh @@ -93,7 +93,7 @@ test_fm_home_scopes_projects_path() { home="$TMP_ROOT/home path" 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) + 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" @@ -111,7 +111,7 @@ test_fm_projects_override_scopes_projects_path() { projects="$TMP_ROOT/override projects" 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) + 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" From b573ab5666aebe9df684488475c6574d8232d9c6 Mon Sep 17 00:00:00 2001 From: Ryan Li Date: Thu, 25 Jun 2026 01:30:06 -0700 Subject: [PATCH 2/3] no-mistakes(review): fix batch test PATH coupling and redundant grep -e pattern --- bin/fm-resolve-spawn.sh | 2 +- tests/fm-spawn-batch.test.sh | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/bin/fm-resolve-spawn.sh b/bin/fm-resolve-spawn.sh index af65a38..47e89c1 100755 --- a/bin/fm-resolve-spawn.sh +++ b/bin/fm-resolve-spawn.sh @@ -49,7 +49,7 @@ project_name=${project%/} project_name=${project_name##*/} registry="$DATA/projects.md" if [ -f "$registry" ]; then - if ! grep -F -e "- ${project_name} " -e "- ${project_name} [" "$registry" >/dev/null 2>&1; 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 diff --git a/tests/fm-spawn-batch.test.sh b/tests/fm-spawn-batch.test.sh index 974b1b5..4f818c2 100755 --- a/tests/fm-spawn-batch.test.sh +++ b/tests/fm-spawn-batch.test.sh @@ -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() { @@ -89,10 +96,13 @@ 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='' \ + 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" @@ -106,11 +116,14 @@ 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='' \ + 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" From 06e1bbab9681babbde9d6648718ba81c70f435f1 Mon Sep 17 00:00:00 2001 From: Ryan Li Date: Thu, 25 Jun 2026 01:33:12 -0700 Subject: [PATCH 3/3] no-mistakes(document): document fm-resolve-spawn.sh in README and AGENTS.md --- AGENTS.md | 3 ++- README.md | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 0dfbd66..60a678c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/" "$FM_WORKTREE_BASE/" 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 `/`, closes the tab's leftover root shell so the tab is a single agent pane, parses the returned `pane_id`, records `state/.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/" "$FM_WORKTREE_BASE/" 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 `/`, closes the tab's leftover root shell so the tab is a single agent pane, parses the returned `pane_id`, records `state/.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). diff --git a/README.md b/README.md index 9c9cd5e..0e0e5a5 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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")