Skip to content
1 change: 1 addition & 0 deletions .agents/skills/secondmate-provisioning/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ Release happens only on explicit retirement or seed rollback, never on routine r

`bin/fm-home-seed.sh` copies the charter into the secondmate home as `data/charter.md`.
It also writes the gitignored `.fm-secondmate-parent` durable binding before the required `.fm-secondmate-home` identity marker; the parser header in [`bin/fm-secondmate-parent-lib.sh`](../../../bin/fm-secondmate-parent-lib.sh) owns the record contract, and both files must remain in place.
Each newly cloned project is also pointed at a treehouse pool root private to its home; [`bin/fm-treehouse-pool-lib.sh`](../../../bin/fm-treehouse-pool-lib.sh)'s header owns the derivation and what a project that already tracks its own `treehouse.toml` triggers.
`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 `<harness> [<model>] [<effort>]`, with only the first non-empty, non-comment line parsed.
Expand Down
8 changes: 7 additions & 1 deletion bin/fm-home-seed.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
# leases the worktree under the secondmate <id> so the home survives with
# no live process and is never recycled until the lease is released with
# "treehouse return". Projects are cloned
# from the active home into the secondmate home's projects/ directory.
# from the active home into the secondmate home's projects/ directory, and
# each new clone is pointed at a treehouse pool private to this home (see
# bin/fm-treehouse-pool-lib.sh, which owns the derivation and the
# project-owned treehouse.toml case).
# That project list is non-exclusive provisioning data. Pass --no-projects
# instead of a project list to seed a project-less home for a domain whose
# subject is the firstmate repo itself; it is mutually exclusive with a
Expand Down Expand Up @@ -49,6 +52,8 @@ SUB_HOME_PARENT_MARKER=".fm-secondmate-parent"
. "$SCRIPT_DIR/fm-secondmate-charter-lib.sh"
# shellcheck source=bin/fm-wake-lib.sh
. "$SCRIPT_DIR/fm-wake-lib.sh"
# shellcheck source=bin/fm-treehouse-pool-lib.sh
. "$SCRIPT_DIR/fm-treehouse-pool-lib.sh"

usage() {
echo "usage: fm-home-seed.sh <id> <home|-> {<project>...|--no-projects}" >&2
Expand Down Expand Up @@ -482,6 +487,7 @@ EOF
fi
url=$(source_origin_url "$project" "$mode" "$src") || return 1
git clone --quiet "$url" "$dst"
fm_treehouse_configure_pool_root "$dst" "$home"
}

validate_seed_project() {
Expand Down
8 changes: 7 additions & 1 deletion bin/fm-remote-home-provision.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
# "remote" - read by bin/fm-teardown.sh's cleanup gate so a delegated public
# reply promise, which the subsystem can only carry on the parent's own
# filesystem, is never mistaken for one this child could hold - and the
# .fm-secondmate-home marker commits the complete seed last.
# .fm-secondmate-home marker commits the complete seed last. Each newly cloned
# project is also pointed at a treehouse pool private to this home through
# bin/fm-treehouse-pool-lib.sh, which owns the derivation and the project-owned
# treehouse.toml case.
# A newly created home is removed on failure. An existing matching seeded home
# is converged only through guarded ordinary-file updates and new project clones.
set -eu
Expand All @@ -26,6 +29,8 @@ MAX_MANIFEST_BYTES=1048576

# shellcheck source=bin/fm-project-origin-lib.sh
. "$SCRIPT_DIR/fm-project-origin-lib.sh"
# shellcheck source=bin/fm-treehouse-pool-lib.sh
. "$SCRIPT_DIR/fm-treehouse-pool-lib.sh"

die() { printf 'error: %s\n' "$1" >&2; exit 1; }

Expand Down Expand Up @@ -230,6 +235,7 @@ EOF
else
printf '%s\n' "$NAME" >> "$CREATED_PROJECTS"
git clone --quiet -- "$ORIGIN" "$DEST" || die "could not clone project $NAME on the remote host"
fm_treehouse_configure_pool_root "$DEST" "$FM_HOME" || die "could not configure treehouse pool root for project $NAME"
if [ "$MODE" = no-mistakes ]; then
command -v no-mistakes >/dev/null 2>&1 || die "no-mistakes is unavailable for project $NAME"
(cd "$DEST" && no-mistakes init >/dev/null && no-mistakes doctor >/dev/null) \
Expand Down
2 changes: 1 addition & 1 deletion bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ family_for_basename() {
fm-backend-herdr-agent-exit-shell-e2e.test.sh|\
fm-herdr-attached-viewer-live-e2e.test.sh|fm-herdr-session-cleanup-e2e.test.sh|\
fm-backend-herdr-smoke.test.sh|fm-backend-herdr-workspace-per-home-e2e.test.sh|\
fm-control-herdr-smoke.test.sh)
fm-control-herdr-smoke.test.sh|fm-treehouse-pool-lib.test.sh)
printf '%s\n' real-herdr-gated
;;
fm-backlog-handoff.test.sh|fm-on.test.sh|fm-remote-backlog-handoff.test.sh|\
Expand Down
57 changes: 57 additions & 0 deletions bin/fm-treehouse-pool-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# fm-treehouse-pool-lib.sh - point a freshly cloned project's treehouse
# worktree pool at a root private to the home that cloned it.
#
# WHY: treehouse names a project's pool from a hash of the origin URL alone,
# placed under its configured root (default $HOME). Two firstmate homes that
# each clone the same origin therefore collide on one pool, and every
# worktree in it stays linked to whichever clone created it first - so a
# secondmate spawning work in a project its parent home also clones gets
# handed a worktree of the PARENT's clone. fm-spawn.sh's pre-registration
# guard (bin/fm-claude-trust.sh, bin/fm-agy-trust.sh) already catches and
# refuses that mismatch; this removes the collision instead of relaxing the
# guard. Treehouse reads treehouse.toml from a project's own repository root
# and honors a `root` key there ({root}/.treehouse/ instead of $HOME).
#
# The root is derived from the home's absolute path and lives in the machine
# state directory, outside the home worktree and every other git repository:
# treehouse keeps its pool out of git by appending the pool path to the
# .gitignore of whichever repository encloses {root}/.treehouse, so a root
# inside the home (itself a firstmate clone) would leave that home dirty and
# stop its fast-forward updates.

# fm_treehouse_pool_root <home>
# Prints the stable, collision-free pool root for <home>.
fm_treehouse_pool_root() {
local home=$1 base hash
base="${XDG_STATE_HOME:-$HOME/.local/state}/firstmate/treehouse-pools"
if command -v shasum >/dev/null 2>&1; then
hash=$(printf '%s' "$home" | shasum -a 256 | awk '{print $1}')
elif command -v sha256sum >/dev/null 2>&1; then
hash=$(printf '%s' "$home" | sha256sum | awk '{print $1}')
else
hash=$(printf '%s' "$home" | cksum | awk '{printf "%08x%08x", $1, $2}')
fi
printf '%s/%s\n' "$base" "$hash"
}

# fm_treehouse_configure_pool_root <clone-dir> <home>
# Points <clone-dir>'s treehouse pool at <home>'s own pool root and excludes
# the generated file locally so the clone stays clean. A project that tracks
# its own treehouse.toml keeps it: the seed warns and continues instead of
# aborting.
fm_treehouse_configure_pool_root() {
local clone=$1 home=$2 toml exclude pool_root
toml="$clone/treehouse.toml"
if git -C "$clone" ls-files --error-unmatch treehouse.toml >/dev/null 2>&1; then
echo "warning: project $(basename "$clone") keeps its own treehouse pool configuration in treehouse.toml, so this home's per-home pool root was not applied; worker spawns from this home may be refused if that configuration resolves to a pool shared with another home. Give that configuration a root unique to this home to avoid the collision." >&2
return 0
fi
pool_root=$(fm_treehouse_pool_root "$home")
printf 'root = "%s"\n' "$pool_root" > "$toml.tmp.$$"
mv -f -- "$toml.tmp.$$" "$toml"
exclude="$clone/.git/info/exclude"
mkdir -p "$(dirname "$exclude")"
touch "$exclude"
grep -qxF '/treehouse.toml' "$exclude" 2>/dev/null || printf '/treehouse.toml\n' >> "$exclude"
}
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,8 @@ The lease is held under the secondmate id until explicit retirement or seed roll
Teardown of a leased home fails closed if `treehouse return` cannot release the lease; plain-clone homes with no treehouse pool slot are removed directly.
Secondmate routes cover `no-mistakes` and `direct-PR` projects; `local-only` projects remain main-firstmate work.
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.
A project cloned into a secondmate home, local or remote, is also pointed at a treehouse pool root private to that home, so a secondmate's workers lease worktrees linked to the secondmate's own clone rather than to the home the parent cloned from.
The generated `treehouse.toml` is excluded locally so the clone stays clean, and a project that already tracks its own `treehouse.toml` is left untouched with a named warning that this home's per-home pool root was not applied and that worker spawns from this home may be refused if that configuration resolves to a pool shared with another home; [`bin/fm-treehouse-pool-lib.sh`](../bin/fm-treehouse-pool-lib.sh)'s header owns the pool-root derivation.
After creating a secondmate, move existing main-backlog queued items that you have judged in-scope with `fm-backlog-handoff.sh <secondmate-id> <item-key>...`; it refuses In flight, Done, or non-secondmate homes, and its [script header](../bin/fm-backlog-handoff.sh) owns route-specific wake outcomes and retries.
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.
The seeded home's `data/charter.md` owns the standard secondmate lifecycle and escalation contract; the route file points to it through the existing `home:` field instead of adding another pointer.
Expand Down
139 changes: 139 additions & 0 deletions tests/fm-treehouse-pool-lib.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# tests/fm-treehouse-pool-lib.test.sh - proves fm_treehouse_configure_pool_root
# (bin/fm-treehouse-pool-lib.sh) actually gives two clones of one origin their
# own treehouse worktree pools without dirtying either home, using the real
# treehouse binary end to end (harness-dependent check: the pool-collision
# behavior this fixes is treehouse's own, so a mocked path computation would
# only confirm the assumption already written into the mock). Skips when
# treehouse is absent.
set -u

# shellcheck source=tests/lib.sh
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"

command -v treehouse >/dev/null 2>&1 || { echo "skip: treehouse not found"; exit 0; }

# shellcheck source=/dev/null
. "$ROOT/bin/fm-treehouse-pool-lib.sh"

TMP_ROOT=$(fm_test_tmproot fm-treehouse-pool-lib) || fail "could not create temp root"
# Keep the derived pool roots inside the fixture instead of the developer's
# machine state directory. The fixture root is not a git repository, so a
# pool root here proves the pool can live outside every repository.
export XDG_STATE_HOME="$TMP_ROOT/state"
# Contain treehouse's own state, and any fallback to its default $HOME pool, in
# the fixture rather than the developer's home directory.
export HOME="$TMP_ROOT/user-home"
mkdir -p "$HOME"

# A realistic firstmate home: a real git repository that ignores its project
# clones, exactly as a seeded secondmate home does, so a pool root that dirties
# the home surfaces in its own `git status`.
make_home() {
local home=$1
fm_git_init_commit "$home"
printf 'projects/\n' > "$home/.gitignore"
git -C "$home" add .gitignore
git -C "$home" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm gitignore
}

# One throwaway origin, cloned twice - standing in for a project two separate
# firstmate homes have each cloned.
fm_git_init_commit "$TMP_ROOT/widget-origin"
fm_git_add_origin "$TMP_ROOT/widget-origin" "$TMP_ROOT/widget-origin.git"
make_home "$TMP_ROOT/home-a"
make_home "$TMP_ROOT/home-b"
git clone --quiet "$TMP_ROOT/widget-origin.git" "$TMP_ROOT/home-a/projects/widget" || fail "could not clone home A's widget"
git clone --quiet "$TMP_ROOT/widget-origin.git" "$TMP_ROOT/home-b/projects/widget" || fail "could not clone home B's widget"

WT_A=; WT_B=
cleanup_leases() {
[ -z "$WT_A" ] || treehouse return --force "$WT_A" >/dev/null 2>&1
[ -z "$WT_B" ] || treehouse return --force "$WT_B" >/dev/null 2>&1
fm_test_cleanup
}
trap cleanup_leases EXIT

fm_treehouse_configure_pool_root "$TMP_ROOT/home-a/projects/widget" "$TMP_ROOT/home-a" \
|| fail "fm_treehouse_configure_pool_root refused for home A"
fm_treehouse_configure_pool_root "$TMP_ROOT/home-b/projects/widget" "$TMP_ROOT/home-b" \
|| fail "fm_treehouse_configure_pool_root refused for home B"
pass "fm_treehouse_configure_pool_root accepts two fresh clones of one origin"

[ -z "$(git -C "$TMP_ROOT/home-a/projects/widget" status --porcelain)" ] \
|| fail "home A's clone reads as dirty after configuring its pool root"
[ -z "$(git -C "$TMP_ROOT/home-b/projects/widget" status --porcelain)" ] \
|| fail "home B's clone reads as dirty after configuring its pool root"
pass "the generated treehouse.toml is excluded locally, so both clones stay clean"

# A project that tracks its own treehouse.toml keeps its own pool
# configuration; the seed must warn instead of aborting, and the file must be
# left untouched.
fm_git_init_commit "$TMP_ROOT/owned-origin"
printf 'root = "/srv/owned"\n' > "$TMP_ROOT/owned-origin/treehouse.toml"
git -C "$TMP_ROOT/owned-origin" add treehouse.toml
git -C "$TMP_ROOT/owned-origin" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' commit -qm owned
fm_git_add_origin "$TMP_ROOT/owned-origin" "$TMP_ROOT/owned-origin.git"
git clone --quiet "$TMP_ROOT/owned-origin.git" "$TMP_ROOT/home-a/projects/owned" || fail "could not clone home A's owned project"
warning=$(fm_treehouse_configure_pool_root "$TMP_ROOT/home-a/projects/owned" "$TMP_ROOT/home-a" 2>&1) \
|| fail "fm_treehouse_configure_pool_root aborted the seed on a project-owned treehouse.toml"
[ "$(cat "$TMP_ROOT/home-a/projects/owned/treehouse.toml")" = 'root = "/srv/owned"' ] \
|| fail "fm_treehouse_configure_pool_root overwrote a project-owned treehouse.toml"
case "$warning" in
*"owned"*"treehouse.toml"*"per-home pool root was not applied"*"may be refused"*) : ;;
*) fail "fm_treehouse_configure_pool_root did not name the project and file whose pool config it left alone: $warning" ;;
esac
pass "a project-owned treehouse.toml is left untouched with a named warning, not a seed abort"

ROOT_A=$(fm_treehouse_pool_root "$TMP_ROOT/home-a")
ROOT_B=$(fm_treehouse_pool_root "$TMP_ROOT/home-b")
[ "$ROOT_A" != "$ROOT_B" ] || fail "the two homes derived the same pool root '$ROOT_A'"

WT_A=$(cd "$TMP_ROOT/home-a/projects/widget" && treehouse get --lease --lease-holder home-a) \
|| fail "treehouse get --lease failed for home A"
[ -n "$WT_A" ] || fail "treehouse get --lease did not report a worktree path for home A"
case "$WT_A" in
"$ROOT_A"/.treehouse/*) : ;;
*) fail "home A's worktree '$WT_A' is not under home A's configured pool root '$ROOT_A'" ;;
esac
GITDIR_A=$(cat "$WT_A/.git")
case "$GITDIR_A" in
*"$TMP_ROOT/home-a/projects/widget/.git/worktrees/"*) : ;;
*) fail "home A's pooled worktree links back to the wrong clone: $GITDIR_A" ;;
esac
POOL_A=$(dirname "$(dirname "$WT_A")")

# Return A before B acquires: against a shared pool treehouse hands B the exact
# slot A just freed, which is the reuse path that would hand a secondmate a
# parent-owned worktree. Releasing A first makes the pool comparison below fail
# if the two clones still resolve to one pool.
treehouse return --force "$WT_A" >/dev/null 2>&1 || fail "could not return home A's leased worktree before home B acquires"
WT_A=

WT_B=$(cd "$TMP_ROOT/home-b/projects/widget" && treehouse get --lease --lease-holder home-b) \
|| fail "treehouse get --lease failed for home B"
[ -n "$WT_B" ] || fail "treehouse get --lease did not report a worktree path for home B"
case "$WT_B" in
"$ROOT_B"/.treehouse/*) : ;;
*) fail "home B's worktree '$WT_B' is not under home B's configured pool root '$ROOT_B'" ;;
esac
GITDIR_B=$(cat "$WT_B/.git")
case "$GITDIR_B" in
*"$TMP_ROOT/home-b/projects/widget/.git/worktrees/"*) : ;;
*) fail "home B's pooled worktree links back to the wrong clone: $GITDIR_B" ;;
esac
POOL_B=$(dirname "$(dirname "$WT_B")")
[ "$POOL_A" != "$POOL_B" ] || fail "both homes resolved to the same treehouse pool '$POOL_A'"
pass "each clone acquires from its own pool, even after the first home returns its slot"

# The regression this fixes: treehouse keeps a pool out of git by rewriting the
# .gitignore of the repository enclosing {root}/.treehouse, so a root inside a
# home leaves that home permanently dirty and disables its fast-forward updates.
[ -z "$(git -C "$TMP_ROOT/home-a" status --porcelain)" ] \
|| fail "home A reads as dirty after an acquire"
[ -z "$(git -C "$TMP_ROOT/home-b" status --porcelain)" ] \
|| fail "home B reads as dirty after an acquire"
pass "neither home's own repository is dirtied by a project's pool acquire"

treehouse return --force "$WT_B" >/dev/null 2>&1 || fail "could not return home B's leased worktree"
WT_A=; WT_B=