Skip to content
Open
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/bootstrap-diagnostics/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ When any diagnostic needs captain attention, report the plain consequence and re

- `MISSING: <tool> (install: <command>)` - list the missing tools to the captain with a one-line purpose each plus the printed install commands, wait for consent (one approval may cover the list), then run `bin/fm-bootstrap.sh install <approved tools...>`.
For `treehouse`, this also covers an installed version whose `treehouse get` lacks `--lease`; treat it as an upgrade request.
For `no-mistakes`, this also covers an installed version older than 1.31.2, because crewmate validation briefs delegate gate mechanics to no-mistakes' version-matched guidance.
For `no-mistakes`, this also covers an installed version older than `bin/fm-bootstrap.sh`'s `NO_MISTAKES_MIN` floor, which tracks the structured step-attestation requirement enforced by `.github/workflows/no-mistakes-required.yml`.
For any axi-family tool - `gh-axi`, `lavish-axi`, `tasks-axi`, `quota-axi` - an installed version below its floor is a plain upgrade request; [`bin/fm-bootstrap.sh`](../../../bin/fm-bootstrap.sh) owns the floor policy, and never argue the floor down to whatever the home happens to have installed.
For `tasks-axi`, this additionally covers an installed build that fails the separate feature probe (`bin/fm-tasks-axi-lib.sh` owns the definition); `config/backlog-backend=manual` only suppresses the verbose `BOOTSTRAP_INFO: tasks-axi available` fact, not this missing-tool report.
For `quota-axi`, bootstrap requires it because firstmate reads its current output directly before resolving every crew-dispatch profile array; without it, report the missing requirement and do not choose around an unexamined candidate.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ GitHub Actions and Dependabot are exempt so their automation keeps working, but

1. Fork the repo, then clone the parent repo or set your local `origin` back to the parent (`git@github.com:kunchenguid/firstmate.git`).
2. Create a branch and make your changes.
3. Initialize the gate with your fork as the push target: `no-mistakes init --fork-url git@github.com:<you>/firstmate.git` (firstmate expects **no-mistakes v1.31.2+**; without a fork, plain `no-mistakes init` still works for maintainers with push access).
3. Initialize the gate with your fork as the push target: `no-mistakes init --fork-url git@github.com:<you>/firstmate.git` (firstmate expects **no-mistakes v1.46.0+** so the PR carries the required structured step attestation; without a fork, plain `no-mistakes init` still works for maintainers with push access).
4. Commit your changes.
5. Push through the gate instead of pushing to `origin`:

Expand Down
5 changes: 3 additions & 2 deletions bin/fm-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
# treehouse is also MISSING when its installed version lacks
# "treehouse get --lease" support.
# no-mistakes is also MISSING when its installed version is older than
# 1.31.2.
# 1.46.0, the first release that emits the structured pipeline step
# attestation required by this repository's PR compliance workflow.
# The AXI-family floor policy is owned beside GH_AXI_MIN and
# LAVISH_AXI_MIN below; the per-tool owners point there. An installed
# build below its floor reports MISSING like no-mistakes, so the operator
Expand Down Expand Up @@ -792,7 +793,7 @@ if ! BACKEND_TOOLS=$(fm_backend_required_tools "$BACKEND"); then
BACKEND_TOOLS=""
fi
TOOLS="$BACKEND_TOOLS $COMMON_TOOLS"
NO_MISTAKES_MIN=1.31.2
NO_MISTAKES_MIN=1.46.0
# AXI-FAMILY FLOOR POLICY. Every axi-family floor is the CURRENT LATEST published
# version of that tool, captain-bumped periodically to keep the whole fleet on the
# newest axi tools. It is NOT the minimum feature-introduced version. These floors
Expand Down
17 changes: 13 additions & 4 deletions bin/fm-spawn.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Spawn a direct report: a crewmate in a treehouse or Orca worktree, or a
# secondmate in its isolated firstmate home.
# Usage: fm-spawn.sh <task-id> <project-dir> --mode <no-mistakes|direct-PR|local-only> --yolo <on|off> [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>]
# fm-spawn.sh <task-id> <project-dir> --scout [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>]
# Usage: fm-spawn.sh <task-id> <project-dir-or-name> --mode <no-mistakes|direct-PR|local-only> --yolo <on|off> [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>]
# fm-spawn.sh <task-id> <project-dir-or-name> --scout [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>]
# fm-spawn.sh <task-id> [<firstmate-home>] [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>] --secondmate
# --mode and --yolo are this task's delivery contract, REQUIRED for every ship
# spawn and refused on --scout and --secondmate spawns. Firstmate resolves both
Expand Down Expand Up @@ -134,6 +134,9 @@
# default-branch commit when safe; skipped syncs warn and launch unchanged.
# Ship/scout spawns refuse to launch unless the resolved task path is a real
# git worktree root distinct from the primary project checkout.
# A bare project name and a projects/<name> argument resolve only against this
# home's projects dir. The explicit relative paths . and .. and every other
# path containing a slash retain the caller-supplied path semantics.
# Before a fresh ship or scout worker starts, its clean task worktree fetches
# origin, resolves the current remote default branch, and resets to its tip.
# An unreachable origin, unresolved default branch, or non-clean worktree
Expand Down Expand Up @@ -1485,8 +1488,10 @@ resolved_existing_dir() {
resolve_project_dir_arg() {
local path=$1
case "$path" in
.|..) printf '%s\n' "$path" ;;
projects/*) printf '%s/%s\n' "$PROJECTS" "${path#projects/}" ;;
*) printf '%s\n' "$path" ;;
*/*) printf '%s\n' "$path" ;;
*) printf '%s/%s\n' "$PROJECTS" "$path" ;;
esac
}

Expand Down Expand Up @@ -1652,7 +1657,11 @@ if [ "$KIND" = secondmate ]; then
BRIEF="$DATA/$ID/brief.md"
fi
else
PROJ_ABS="$(cd "$(resolve_project_dir_arg "$PROJ")" && pwd)"
PROJ_RESOLVED=$(resolve_project_dir_arg "$PROJ")
if ! PROJ_ABS=$(CDPATH='' cd -- "$PROJ_RESOLVED" 2>/dev/null && pwd); then
echo "error: project directory cannot be resolved: $PROJ (tried '$PROJ_RESOLVED')" >&2
exit 1
fi
WT=""
BRIEF="$DATA/$ID/brief.md"
fi
Expand Down
9 changes: 5 additions & 4 deletions tests/fm-bootstrap.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ SH
cat > "$fakebin/no-mistakes" <<'SH'
#!/usr/bin/env bash
if [ "${1:-}" = --version ]; then
printf '%s\n' "${FM_FAKE_NO_MISTAKES_VERSION:-no-mistakes version v1.31.2 (fake) 2026-06-27T00:02:18Z}"
printf '%s\n' "${FM_FAKE_NO_MISTAKES_VERSION:-no-mistakes version v1.46.0 (fake) 2026-08-24T00:00:00Z}"
exit 0
fi
exit 0
Expand Down Expand Up @@ -332,10 +332,11 @@ test_no_mistakes_min_version() {
[ "$out" = "$missing" ] || fail "$label: expected '$missing', got: $out" ;;
esac
done <<'ROWS'
minimum no-mistakes version is accepted^no-mistakes version v1.31.2 (fake)^empty
newer no-mistakes minor is accepted^no-mistakes version v1.32.0 (fake)^empty
minimum no-mistakes version is accepted^no-mistakes version v1.46.0 (fake)^empty
newer no-mistakes minor is accepted^no-mistakes version v1.47.0 (fake)^empty
newer no-mistakes major is accepted^no-mistakes version v2.0.0 (fake)^empty
older no-mistakes patch reports an upgrade^no-mistakes version v1.31.1 (fake)^missing
pre-attestation no-mistakes version reports an upgrade^no-mistakes version v1.45.9 (fake)^missing
formerly accepted no-mistakes version reports an upgrade^no-mistakes version v1.31.2 (fake)^missing
unparseable no-mistakes version reports an upgrade^no-mistakes development build^missing
ROWS
pass "bootstrap enforces no-mistakes minimum version"
Expand Down
154 changes: 154 additions & 0 deletions tests/fm-tangle-guard.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,19 @@ run_spawn() {
"$ROOT/bin/fm-spawn.sh" "$id" "$proj" codex --mode no-mistakes --yolo off 2>&1
}

run_scout_spawn() {
local home=$1 id=$2 proj=$3 pane=$4 fakebin=$5
mkdir -p "$home/data/$id"
printf 'brief\n' > "$home/data/$id/brief.md"
FM_ROOT_OVERRIDE='' FM_HOME="$home" \
FM_STATE_OVERRIDE="$home/state" FM_DATA_OVERRIDE="$home/data" \
FM_PROJECTS_OVERRIDE="$home/projects" FM_CONFIG_OVERRIDE="$home/config" \
FM_SPAWN_NO_GUARD=1 FM_FAKE_PANE_PATH="$pane" TMUX="fake,1,0" \
PATH="$fakebin:$PATH" \
"$ROOT/bin/fm-spawn.sh" "$id" "$proj" --scout --harness codex \
--model gpt-5.6-sol --effort medium 2>&1
}

test_spawn_isolation_abort() {
local home proj fakebin out status
home="$TMP_ROOT/spawn-home"
Expand Down Expand Up @@ -215,6 +228,142 @@ test_spawn_isolation_abort() {
pass "fm-spawn: aborts unless the resolved worktree is a genuine, isolated worktree"
}

# Initiating trigger: the reconcile-github-delivery-state scout passed the bare
# project name `analytics`, while earlier successful spawns passed
# projects/analytics. Without a caller cwd shadow, the old resolver stopped
# before metadata, which was the visible symptom.
test_spawn_bare_project_name_reproduces_no_shadow_sequence() {
local home project wt caller fakebin out status meta
home="$TMP_ROOT/reconcile-no-shadow-home"
project="$home/projects/analytics"
wt="$TMP_ROOT/reconcile-no-shadow-wt"
caller="$TMP_ROOT/reconcile-no-shadow-caller"
mkdir -p "$home/projects" "$caller"
make_repo "$project" >/dev/null
git -C "$project" worktree add -q --detach "$wt" >/dev/null 2>&1
fakebin=$(make_spawn_fakebin "$TMP_ROOT/reconcile-no-shadow-fake")

assert_absent "$caller/analytics" "no-shadow regression setup unexpectedly contains an analytics shadow"
out=$(cd "$caller" && run_scout_spawn "$home" reconcile-github-delivery-state analytics "$wt" "$fakebin")
status=$?
meta="$home/state/reconcile-github-delivery-state.meta"
assert_present "$meta" "original visible symptom recurred: no task metadata was created"
expect_code 0 "$status" "bare-name scout spawn should reach an isolated worktree"
assert_contains "$out" "spawned reconcile-github-delivery-state harness=codex kind=scout" \
"bare-name scout spawn did not report success"
assert_contains "$out" "worktree=$wt" \
"bare-name scout spawn did not report the isolated treehouse worktree"
assert_grep "project=$project" "$meta" \
"bare project name did not resolve against the firstmate home's projects dir"
assert_grep "worktree=$wt" "$meta" \
"spawn metadata did not publish the isolated worktree"
[ "$(git -C "$wt" rev-parse --show-toplevel)" = "$wt" ] \
|| fail "resolved target is not the linked-worktree root"
[ "$wt" != "$project" ] || fail "resolved worktree equals the primary project checkout"
pass "fm-spawn: the no-shadow bare-name scout reaches a genuine isolated worktree"
}

# Masking condition: a caller cwd directory named `analytics` let the old
# resolver proceed while silently selecting that shadow instead of the active
# Firstmate home's registered project.
test_spawn_bare_project_name_ignores_cwd_shadow() {
local home project wt caller fakebin out status meta
home="$TMP_ROOT/reconcile-shadow-home"
project="$home/projects/analytics"
wt="$TMP_ROOT/reconcile-shadow-wt"
caller="$TMP_ROOT/reconcile-shadow-caller"
mkdir -p "$home/projects" "$caller/analytics"
make_repo "$project" >/dev/null
git -C "$project" worktree add -q --detach "$wt" >/dev/null 2>&1
fakebin=$(make_spawn_fakebin "$TMP_ROOT/reconcile-shadow-fake")

out=$(cd "$caller" && run_scout_spawn "$home" reconcile-github-delivery-state-shadow analytics "$wt" "$fakebin")
status=$?
expect_code 0 "$status" "cwd-shadow bare-name scout spawn should reach an isolated worktree"
assert_contains "$out" "spawned reconcile-github-delivery-state-shadow harness=codex kind=scout" \
"cwd-shadow bare-name scout spawn did not report success"
assert_contains "$out" "worktree=$wt" \
"cwd-shadow bare-name scout spawn did not report the isolated treehouse worktree"
meta="$home/state/reconcile-github-delivery-state-shadow.meta"
assert_grep "project=$project" "$meta" \
"cwd shadow captured the bare project name instead of the home registry project"
assert_no_grep "project=$caller/analytics" "$meta" \
"spawn metadata published the caller cwd shadow"
assert_grep "worktree=$wt" "$meta" \
"cwd-shadow spawn metadata did not publish the isolated worktree"
[ "$(git -C "$wt" rev-parse --show-toplevel)" = "$wt" ] \
|| fail "cwd-shadow target is not the linked-worktree root"
[ "$wt" != "$project" ] || fail "cwd-shadow worktree equals the primary project checkout"
pass "fm-spawn: the home registry project wins over a caller cwd shadow"
}

test_spawn_missing_bare_project_refuses_cwd_shadow() {
local home caller shadow wt fakebin out status meta
home="$TMP_ROOT/missing-bare-home"
caller="$TMP_ROOT/missing-bare-caller"
shadow="$caller/analytics"
wt="$TMP_ROOT/missing-bare-shadow-wt"
mkdir -p "$home/projects" "$caller"
make_repo "$shadow" >/dev/null
git -C "$shadow" worktree add -q --detach "$wt" >/dev/null 2>&1
fakebin=$(make_spawn_fakebin "$TMP_ROOT/missing-bare-fake")

out=$(cd "$caller" && run_scout_spawn "$home" missing-home-bare-project analytics "$wt" "$fakebin")
status=$?
expect_code 1 "$status" "missing home project should refuse a same-name cwd shadow"
assert_contains "$out" "project directory cannot be resolved: analytics" \
"missing home project did not report a clear resolution error"
assert_contains "$out" "tried '$home/projects/analytics'" \
"missing home project diagnostic did not name the home-scoped target"
meta="$home/state/missing-home-bare-project.meta"
assert_absent "$meta" "missing bare project captured the cwd shadow and published metadata"
assert_not_contains "$out" "spawned missing-home-bare-project" \
"missing bare project launched from the cwd shadow"
pass "fm-spawn: a missing bare project refuses a same-name cwd shadow"
}

test_spawn_project_argument_help() {
local out
out=$("$ROOT/bin/fm-spawn.sh" --help)
assert_contains "$out" "bare project name and a projects/<name> argument resolve only" \
"spawn help did not state that bare names are strictly home-scoped"
assert_contains "$out" "explicit relative paths . and .. and every other" \
"spawn help did not distinguish caller-supplied path arguments"
pass "fm-spawn: help distinguishes home-scoped names from explicit paths"
}

test_spawn_dot_project_args_remain_caller_relative() {
local home project subdir dot_wt dotdot_wt fakebin out status meta
home="$TMP_ROOT/relative-home"
project=$(make_repo "$TMP_ROOT/relative-project")
subdir="$project/subdir"
dot_wt="$TMP_ROOT/relative-dot-wt"
dotdot_wt="$TMP_ROOT/relative-dotdot-wt"
mkdir -p "$home/projects" "$subdir"
git -C "$project" worktree add -q --detach "$dot_wt" >/dev/null 2>&1
git -C "$project" worktree add -q --detach "$dotdot_wt" >/dev/null 2>&1
fakebin=$(make_spawn_fakebin "$TMP_ROOT/relative-fake")

out=$(cd "$project" && run_scout_spawn "$home" relative-dot-project . "$dot_wt" "$fakebin")
status=$?
expect_code 0 "$status" "dot project argument should remain caller-relative"
assert_contains "$out" "spawned relative-dot-project harness=codex kind=scout" \
"dot project argument spawn did not report success"
meta="$home/state/relative-dot-project.meta"
assert_grep "project=$project" "$meta" \
"dot project argument did not resolve to the caller's project"

out=$(cd "$subdir" && run_scout_spawn "$home" relative-dotdot-project .. "$dotdot_wt" "$fakebin")
status=$?
expect_code 0 "$status" "dotdot project argument should remain caller-relative"
assert_contains "$out" "spawned relative-dotdot-project harness=codex kind=scout" \
"dotdot project argument spawn did not report success"
meta="$home/state/relative-dotdot-project.meta"
assert_grep "project=$project" "$meta" \
"dotdot project argument did not resolve to the caller's project"
pass "fm-spawn: dot and dotdot project arguments stay caller-relative"
}

# --- GUARD 1c: fm-spawn tmux window construction ----------------------------

# The prevention guard also depends on fm-spawn building robust tmux commands
Expand Down Expand Up @@ -307,4 +456,9 @@ test_guard_banner
test_bootstrap_line
test_brief_assertion_precedes_branch
test_spawn_isolation_abort
test_spawn_bare_project_name_reproduces_no_shadow_sequence
test_spawn_bare_project_name_ignores_cwd_shadow
test_spawn_missing_bare_project_refuses_cwd_shadow
test_spawn_project_argument_help
test_spawn_dot_project_args_remain_caller_relative
test_spawn_tmux_window_construction