diff --git a/.agents/skills/secondmate-provisioning/SKILL.md b/.agents/skills/secondmate-provisioning/SKILL.md index 00d7f591789..ddedc618fc5 100644 --- a/.agents/skills/secondmate-provisioning/SKILL.md +++ b/.agents/skills/secondmate-provisioning/SKILL.md @@ -59,6 +59,7 @@ The slot stays reserved across restarts until the lease is released. Release happens only on explicit retirement or seed rollback, never on routine restart or recovery. `bin/fm-home-seed.sh` copies the charter into the secondmate home as `data/charter.md`. +It also writes the required `.fm-secondmate-home` identity marker, which is gitignored and must remain in place for home validation. `bin/fm-spawn.sh --secondmate` launches it through the secondmate harness path, resolving `config/secondmate-harness` -> `config/crew-harness` -> the primary's own harness unless an explicit per-spawn harness override is passed. `config/secondmate-harness` may also pin a concrete model and effort for the secondmate agent, in the SAME file rather than a new one: the format is a single whitespace-separated line ` [] []`, with only the first non-empty, non-comment line parsed. @@ -71,6 +72,7 @@ Because this resolves from the file on every spawn, the pin is durable across ev This is secondmate-only: crewmate/scout model resolution is untouched by this file. Before launch, `fm-spawn.sh --secondmate` locally fast-forwards the home to the primary firstmate checkout's current default-branch commit when it is safe; dirty, diverged, or in-flight homes launch unchanged with a warning. +That no-fetch path advances a linked worktree immediately; a standalone clone that lacks the target receives firstmate updates through `/updatefirstmate`'s origin refresh. The same launch also propagates the primary's declared inheritable local config, currently `config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend`, into the secondmate home's `config/`. `config/secondmate-harness` is not inherited because it is only the primary's knob for launching secondmate agents. For already-live secondmates, use `bin/fm-config-push.sh` to push a mid-session inherited-config change without running the tracked-file fast-forward or nudging the agents. diff --git a/.gitignore b/.gitignore index dc785fb89fc..7e44dc4939d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ state/ data/ .no-mistakes/ .lavish/ +.fm-secondmate-home .DS_Store .env config/crew-harness diff --git a/AGENTS.md b/AGENTS.md index a07d6dcdbe5..fa2e82cb76f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,9 +149,10 @@ Tell the captain another active session is already managing the work and operate Bootstrap is detect, then consent, then install. Never install anything the captain has not approved in this session. The locked fleet-sync sweep runs via `bin/fm-fleet-sync.sh`, best-effort and non-fatal, under the hard-rule exception in section 1 (set `FM_FLEET_PRUNE=0` to temporarily disable that branch pruning). -The locked local secondmate sync sweep fast-forwards every live secondmate home's worktree to firstmate's own current default-branch commit so the fleet stays converged on whatever version firstmate is on. +The locked local secondmate sync sweep fast-forwards every live secondmate home to firstmate's own current default-branch commit so the fleet stays converged on whatever version firstmate is on. The live set comes from `state/.meta` records with `kind=secondmate`; `data/secondmates.md` only backfills `home=` for older or incomplete meta records. -This is a purely local fast-forward (every secondmate home is a worktree of this same repo, sharing one object store), never a fetch from origin and never a surprise pull: the version followed is simply whatever the primary is currently on, which only the captain changes deliberately via `git pull` or `/updatefirstmate`. +This is a purely local fast-forward for linked-worktree homes, which share the primary's object store, never a fetch from origin or a surprise pull. +A standalone clone that lacks the primary target is skipped untouched by this local sweep and advances through `/updatefirstmate`'s origin refresh instead. A tracked-files fast-forward never touches the gitignored operational dirs, so a secondmate's backlog, projects, and in-flight work are never disturbed; a dirty, diverged, or in-flight home is skipped untouched. The same sweep also propagates the primary's declared inheritable config (`config/crew-dispatch.json`, `config/crew-harness`, and `config/backlog-backend`; sections 4 and 10) into each live secondmate home's `config/`, so every secondmate's own crewmates, dispatch profiles, and backlog backend stay on the primary's settings. Because `config/` is gitignored this is a separate, primary-authoritative copy independent of the tracked-files fast-forward: it re-converges every live home whether or not its tracked files advanced, and it touches only the declared inheritable items (never `config/secondmate-harness`). @@ -749,7 +750,7 @@ If a guard warning says queued wakes are pending, drain them before doing anythi If a guard warning says watcher liveness is stale, drain any queued wakes and then resume the emitted supervision protocol. `fm-guard.sh` carries a second, independent alarm in the same bordered ●-marked style: the **worktree-tangle** guard. -Firstmate is a treehouse-pooled git repo of itself - the primary checkout (the repo root, `FM_ROOT`) and every crewmate worktree and secondmate home are linked worktrees of one repo - and the primary must stay on its default branch. +Firstmate's primary checkout (the repo root, `FM_ROOT`) and crewmate worktrees share the treehouse pool, while a secondmate home may be a linked worktree or a standalone clone, and the primary must stay on its default branch. If a crewmate sent to work firstmate-on-itself branches or commits in the primary instead of its own isolated worktree, the primary is stranded on a feature branch (the failure this guards against); the guard names the offending branch and prints the non-destructive restore (`git -C checkout `), so the tangle surfaces on the very next fleet action. The check is scoped precisely to the primary: detached HEAD (the legitimate resting state of crewmate worktrees and secondmate homes on the default branch) and the default branch itself never alarm; only a named non-default branch checked out in the primary does. The same assertion runs at session start as the bootstrap `TANGLE:` line inside the `bin/fm-session-start.sh` digest (section 3), with read-only wording when this session does not hold the fleet lock. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15004c9acd4..6b165d1a12a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,7 +100,7 @@ tests/fm-dispatch-select.test.sh # deterministic crew-dispatch profile tests/fm-spawn-batch.test.sh # batch dispatch and FM_HOME project-path scoping tests tests/fm-spawn-dispatch-profile.test.sh # concrete dispatch profile flags: active-profile backstop, harness/model/effort meta, launch templates, batch forwarding, and secondmate exemption tests/fm-update.test.sh # fast-forward-only self-update, reread, nudge, dedup, and skip-safety tests -tests/fm-secondmate-sync.test.sh # local-HEAD secondmate sync, no-fetch, bootstrap nudge gating, stable nudge selectors after respawn, and spawn hook tests +tests/fm-secondmate-sync.test.sh # local-HEAD secondmate sync, no-fetch, ignored seed-marker migration and real-dirt protection, bootstrap nudge gating, stable nudge selectors after respawn, and spawn hook tests tests/fm-secondmate-liveness.test.sh # session-start secondmate agent-liveness probe and respawn sweep tests tests/fm-secondmate-harness.test.sh # secondmate-vs-crewmate harness resolution, optional secondmate model/effort pins, primary-to-secondmate config inheritance, and config-push tests tests/fm-secondmate-lifecycle-e2e.test.sh # persistent secondmate routing, seeding, backlog handoff, spawn, recovery, teardown, and FM_HOME flow tests diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index f4d192d15b9..c60cf8e6a6e 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -180,10 +180,11 @@ fleet_sync() { } secondmate_sync() { - # Local-HEAD secondmate sync: fast-forward every LIVE secondmate home's worktree + # Local-HEAD secondmate sync: fast-forward every LIVE secondmate home # to the primary checkout's current default-branch commit. Purely LOCAL - no - # fetch, no origin dependency: a secondmate home is a worktree of this same repo - # and already holds the primary's commit (fm-ff-lib.sh). Emits NUDGE_SECONDMATES: + # fetch, no origin dependency: a linked-worktree home already holds the primary's + # commit (fm-ff-lib.sh), while a standalone clone without it is skipped until + # /updatefirstmate refreshes it from origin. Emits NUDGE_SECONDMATES: # only for RUNNING secondmates whose instruction surface (AGENTS.md, bin/, or # .agents/skills/) actually changed, so a secondmate already on the primary's # version is never disturbed (AGENTS.md bootstrap + supervision). Mirrors diff --git a/bin/fm-ff-lib.sh b/bin/fm-ff-lib.sh index 6a0971f0a4a..ae290020caa 100644 --- a/bin/fm-ff-lib.sh +++ b/bin/fm-ff-lib.sh @@ -10,12 +10,17 @@ # on startup) follows the PRIMARY checkout's current default-branch commit: # base_mode is that local commit, with NO fetch and no origin dependency. # -# Every secondmate home is a worktree of this same repo, so it already holds the -# primary's commit in the shared object store; the local-HEAD sync is therefore a -# purely local fast-forward that never touches the network. A tracked-files -# fast-forward never touches the gitignored operational dirs (data/, state/, -# config/, projects/, .no-mistakes/), so a secondmate's backlog, projects, and -# in-flight work are never disturbed. Homes are leased at a detached HEAD on the +# A linked-worktree secondmate home already holds the primary's commit in the +# shared object store, so its local-HEAD sync is a purely local fast-forward that +# never touches the network. A standalone clone moves through that path only when +# it already has the target; otherwise it is skipped until the origin path updates it. +# A tracked-files fast-forward never touches the gitignored operational dirs +# (data/, state/, config/, projects/, .no-mistakes/), so it cannot disturb a +# secondmate's backlog, projects, or in-flight work. +# The seeded .fm-secondmate-home identity marker is gitignored too; the local +# sync tolerates only that marker during the one-time upgrade of pre-ignore +# linked-worktree homes. +# Homes are leased at a detached HEAD on the # default branch, so the fast-forward advances HEAD only and never moves the # shared default branch or any other worktree's checkout. diff --git a/bin/fm-home-seed.sh b/bin/fm-home-seed.sh index 215c545107e..d506b95e4f8 100755 --- a/bin/fm-home-seed.sh +++ b/bin/fm-home-seed.sh @@ -16,7 +16,7 @@ # refuses a home with project clones or project-registry entries, so it # never converts populated homes in place. The charter brief # is copied to data/charter.md, newly cloned no-mistakes projects are -# initialized, a .fm-secondmate-home marker is written, and +# initialized, an ignored .fm-secondmate-home identity marker is written, and # data/secondmates.md is updated. # Seeding is transactional: on validation, clone, init, or registry failure, # generated briefs, new homes, new project clones, and registry edits are diff --git a/docs/architecture.md b/docs/architecture.md index e4309df65d5..3eeac54c2d4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -122,6 +122,9 @@ Idle secondmate panes are healthy; teardown is explicit and refuses while the se Secondmate homes stay on the same firstmate version as the primary checkout. On locked session start, `fm-bootstrap.sh` fast-forwards each live secondmate home recorded in `state/*.meta` to the primary default-branch commit with no origin fetch. +Linked-worktree homes share the primary's object store, so that local target is available immediately. +A standalone clone that does not contain the target remains unchanged in the local sweep and receives firstmate updates through `/updatefirstmate`'s origin refresh instead. +The seeded-home identity marker's ignored state and upgrade path are documented in [configuration.md](configuration.md#secondmate-routes-datasecondmatesmd). The live signal is a `state/.meta` record with `kind=secondmate`; `data/secondmates.md` only backfills `home=` for older or incomplete meta records. A tracked-files fast-forward leaves the home's gitignored `data/`, `state/`, `config/`, `projects/`, and `.no-mistakes/` directories untouched. The same locked session start probes each live secondmate endpoint for a real agent process and respawns only a confidently dead endpoint; inconclusive probes are reported and never acted on. diff --git a/docs/configuration.md b/docs/configuration.md index 34c0c071d79..d082e4bba06 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -106,6 +106,11 @@ Secondmate routes cover `no-mistakes` and `direct-PR` projects; `local-only` pro For `no-mistakes` projects, seeding initializes only projects newly cloned into a secondmate home and refuses to mutate a preexisting clone that is not already initialized. After creating a secondmate, move existing main-backlog queued items that you have judged in-scope with `fm-backlog-handoff.sh ...`; it is idempotent and refuses In flight, Done, or non-secondmate homes. Set `FM_SECONDMATE_CHARTER` to seed from inline charter text when no filled charter brief exists; set `FM_SECONDMATE_SCOPE` when the routing scope should differ from the charter text. +Each seed writes an `.fm-secondmate-home` identity marker at the home root. +The tracked root `.gitignore` ignores that marker, so validation can read it without making a freshly seeded home appear dirty to porcelain-based safety checks. +This does not relax protection for any other untracked file. +An existing linked-worktree home that predates this rule advances through its marker-only state during its next bootstrap or spawn local sync, after which Git ignores the marker normally. +A standalone-clone home cannot receive a primary-local commit through that no-fetch sync, so it receives the rule through `/updatefirstmate`'s origin refresh instead. ## FM_HOME diff --git a/docs/scripts.md b/docs/scripts.md index e91778778a1..11cdefb6c01 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -22,7 +22,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke | `fm-arm-pretool-check.sh` | Stable PreToolUse transport for the command-position watcher policy owned by `fm-arm-command-policy.mjs`; see `docs/arm-pretool-check.md` for the blessed tree, reason codes, adapter outputs, and validation record | | `fm-arm-command-policy.mjs` | The single semantic owner of the watcher-arm PreToolUse policy: a quote-aware, execution-position classifier that only parses executed positions and never executes, sources, evals, or expands any byte of the submitted command; resolves protected watcher-script identities, recognizes the blessed setup-plus-final tree and broad watcher kills, emits stable deny reason codes, and fails closed on unsupported or malformed syntax that contains a protected execution (docs/arm-pretool-check.md) | | `fm-supervision-instructions.sh` | Render the session-start primary-harness supervision block from `docs/supervision-protocols/`, or a one-line harness-aware repair instruction for guards and turn-end hooks | -| `fm-home-seed.sh` | Lease/provision a secondmate home transactionally, clone projects or deliberately seed a project-less firstmate-repo domain with `--no-projects`, initialize gates, and maintain `data/secondmates.md` | +| `fm-home-seed.sh` | Lease/provision a secondmate home transactionally, clone projects or deliberately seed a project-less firstmate-repo domain with `--no-projects`, initialize gates, write its ignored identity marker, 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; rejects `backend=codex-app`; 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-dispatch-select.sh` | Resolve one already-matched crew-dispatch rule to a concrete JSON profile; owns deterministic `quota-balanced` selection and quota-axi fallback behavior | | `fm-backend.sh` | Runtime session-provider backend selector with explicit/env/config/runtime auto-detection precedence, meta helper, exact-id-first selector resolver, spawn-capability validation, operation dispatcher, and shell-portable backend-name membership for bash-sourced scripts or zsh-sourced diagnostics; deliberately keeps `codex-app` out of known/spawn-capable backends; 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_agent_alive` is the deeper agent-process liveness probe used by the session-start secondmate sweep; `fm_backend_composer_state` exposes backend composer checks for pending-input guards and submit fallbacks | diff --git a/tests/fm-secondmate-sync.test.sh b/tests/fm-secondmate-sync.test.sh index 507bbfb39e5..bc806523d92 100755 --- a/tests/fm-secondmate-sync.test.sh +++ b/tests/fm-secondmate-sync.test.sh @@ -92,6 +92,30 @@ bump_primary() { head_of() { git -C "$1" rev-parse HEAD; } +# ignore_marker_commit : land THE FIX in the primary - add the seed marker to +# the tracked .gitignore and commit it on main. The marker (.fm-secondmate-home) +# is firstmate-generic, written by bin/fm-home-seed.sh into every seeded home; once +# a home fast-forwards past this commit the marker is git-ignored and can no longer +# read as a dirty working tree to any `git status --porcelain` dirtiness check. +ignore_marker_commit() { + local w=$1 + printf '.fm-secondmate-home\n' >> "$w/main/.gitignore" + git -C "$w/main" add -A + git -C "$w/main" commit -qm "gitignore seed marker" +} + +# seed_marked_home : a secondmate home matching what +# bin/fm-home-seed.sh actually lays down - a detached worktree at , the +# seed marker, a live kind=secondmate meta, and the gitignored operational dirs +# with a charter. The ONLY unignored extra file is the seed marker, which is +# exactly what this fix must keep from dirtying the home. +seed_marked_home() { + local w=$1 id=$2 commit=$3 + add_sm_worktree "$w" "$id" "$commit" + mkdir -p "$w/$id/data" "$w/$id/state" "$w/$id/config" "$w/$id/projects" + printf 'charter\n' > "$w/$id/data/charter.md" +} + # run_ff : drive the shared ff helper in THIS shell (output to a file, # not a subshell, so FF_STATUS / FF_INSTR propagate). Sets FF_OUT to the printed # status line. Uses allow_detached=yes, ignore_seed_marker=yes (the secondmate @@ -549,6 +573,87 @@ SH pass "T11 spawn warns when pre-launch sync is skipped" } +# --- T12: a freshly seeded home reads clean once the primary ignores the marker - +# The seed marker used to leave every home permanently dirty: bin/fm-fleet-sync.sh +# and any other plain `git status --porcelain` check counts the untracked marker, +# so a seeded home reported STUCK/dirty forever. With the marker in .gitignore, a +# home seeded from a primary that carries the fix reads clean to that exact signal. +test_seed_marker_clean_when_gitignored() { + local w base + w=$(new_world marker-clean) + ignore_marker_commit "$w" # primary now ignores the marker + base=$(primary_head_commit "$w/main") + seed_marked_home "$w" sm "$base" # fresh home at the post-fix HEAD + + # The exact dirtiness signal bin/fm-fleet-sync.sh reads (its line: dirty=yes when + # `git status --porcelain | head -1` is non-empty). + [ -z "$(git -C "$w/sm" status --porcelain)" ] \ + || fail "seed marker still dirties a fresh home: $(git -C "$w/sm" status --porcelain)" + # And the secondmate ff sweep sees no dirt: an at-HEAD home is a clean no-op. + run_ff "$w/sm" "$base" + [ "$FF_STATUS" = current ] || fail "fresh home not read as clean/current, got '$FF_STATUS': $FF_OUT" + pass "T12 gitignored marker: a freshly seeded home reads clean to fleet-sync and the ff sweep" +} + +# --- T13: an existing marker-only-dirty home converges on the next sweep -------- +# The convergence chicken-and-egg: existing homes predate the fix, so their marker +# is still untracked-and-unignored, and the fix itself only arrives by fast-forward. +# The marker-tolerant ff-skip (ignore_seed_marker=yes) bridges the gap for +# linked-worktree homes, which bootstrap/spawn fast-forward from the primary's local HEAD. +# Standalone-clone homes converge through /updatefirstmate's origin fetch instead. +# Once advanced, the now-ignored marker reads clean with no hand intervention. +test_seed_marker_converges_existing_home() { + local w c0 base + w=$(new_world marker-converge) # primary does NOT ignore the marker yet + c0=$(head_of "$w/main") + seed_marked_home "$w" sm "$c0" # existing home predates the fix + [ -n "$(git -C "$w/sm" status --porcelain)" ] \ + || fail "precondition: the untracked marker should dirty a pre-fix home" + ignore_marker_commit "$w" # THE FIX lands as a later commit + base=$(primary_head_commit "$w/main") + + run_ff "$w/sm" "$base" # the marker-tolerant convergence sweep + + [ "$FF_STATUS" = updated ] || fail "existing marker-only home did not converge, got '$FF_STATUS': $FF_OUT" + [ "$(head_of "$w/sm")" = "$base" ] || fail "home did not fast-forward to the fix commit" + [ -z "$(git -C "$w/sm" status --porcelain)" ] \ + || fail "marker still dirty after convergence: $(git -C "$w/sm" status --porcelain)" + pass "T13 gitignored marker: an existing marker-only-dirty home converges, then reads clean" +} + +# --- T14: marker tolerance does not mask a genuinely dirty home ----------------- +# The ff-skip only forgives the seed marker; a real uncommitted change alongside the +# marker must still refuse the fast-forward and leave the work untouched, exactly as +# before this fix. +test_seed_marker_does_not_mask_real_dirt() { + local w c0 base before + w=$(new_world marker-real-dirt) + c0=$(head_of "$w/main") + seed_marked_home "$w" sm "$c0" + printf 'real local change\n' >> "$w/sm/AGENTS.md" # genuine tracked-file edit + the marker + before=$(head_of "$w/sm") + ignore_marker_commit "$w" + base=$(primary_head_commit "$w/main") + + run_ff "$w/sm" "$base" + + [ "$FF_STATUS" = skipped ] || fail "a genuinely dirty home must skip, got '$FF_STATUS'" + assert_contains "$FF_OUT" "secondmate sm: skipped: dirty working tree" \ + "a genuinely dirty home is skipped even with the marker present" + [ "$(head_of "$w/sm")" = "$before" ] || fail "genuinely dirty home HEAD moved (work at risk)" + grep -q 'real local change' "$w/sm/AGENTS.md" || fail "genuine local edit was discarded" + pass "T14 marker tolerance does not mask a genuinely dirty home" +} + +# --- T15: the shipped firstmate repo gitignores the seed marker ----------------- +# Pins the actual fix so it cannot silently regress: without this .gitignore entry +# every seeded home would read dirty again the moment it lands on this repo's HEAD. +test_repo_gitignores_seed_marker() { + grep -qxF '.fm-secondmate-home' "$ROOT/.gitignore" \ + || fail "the firstmate repo .gitignore must ignore the seed marker (.fm-secondmate-home)" + pass "T15 the firstmate repo gitignores the secondmate seed marker" +} + test_ff_updated test_ff_current test_ff_dirty @@ -561,5 +666,9 @@ test_nudge_selector_stable_after_herdr_respawn test_bootstrap_sweep_surfaces_skipped_home test_spawn_fast_forwards_before_launch test_spawn_warns_when_sync_skipped_before_launch +test_seed_marker_clean_when_gitignored +test_seed_marker_converges_existing_home +test_seed_marker_does_not_mask_real_dirt +test_repo_gitignores_seed_marker echo "# all fm-secondmate-sync tests passed"