diff --git a/.agents/skills/project-management/SKILL.md b/.agents/skills/project-management/SKILL.md index 206ad13115e..c370f0e5219 100644 --- a/.agents/skills/project-management/SKILL.md +++ b/.agents/skills/project-management/SKILL.md @@ -72,6 +72,8 @@ Firstmate never writes that file itself, because `AGENTS.md` hard rule 1 forbids For a project firstmate creates, write and commit it as part of that project's initial local content, which the captain's creation request authorizes. For a project firstmate clones, record the branch as `base=` in the registry entry so dispatch is correct immediately, and land `.firstmate-base` through the project's own delivery path as its own small task. +The resolved branch is the project's standing base, so never redirect one effort by editing it: `bin/fm-spawn.sh --base ` names the base for a single spawn, which is how an effort accumulating on one integration branch leaves shared state untouched. + ## Add or clone an existing project Confirm the source URL, local project name, delivery posture, autonomy posture, and development branch, stating the resolved default for each rather than asking the captain to invent one. diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index a96ff8e96c7..9a0221940c8 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash # Perform the approved local merge for a local-only ship task: fast-forward the -# project's default branch to the crewmate's fm/ branch. +# branch the task was dispatched against - its own recorded base when it has +# one, otherwise the project's development branch - to the crewmate's fm/ +# branch. # # This is firstmate's merge gate-action (the captain's merge authority applied # locally instead of via a GitHub PR). It is the one sanctioned exception to hard @@ -95,8 +97,22 @@ git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null || { e # The captain's ruling is that the recorded development branch governs the merge # as well as the worktree, and a local-only project lands on the LOCAL branch. -DEFAULT=$("$FM_ROOT/bin/fm-project-base.sh" "$PROJ" "$(basename "$PROJ")" 2>/dev/null || true) -[ -n "$DEFAULT" ] || DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; } +# The task's own recorded base outranks the project's standing one: a task +# dispatched with an explicit --base was created against that branch, and +# landing it anywhere else would fast-forward the standing branch over every +# commit the effort has accumulated - the one-merge-checked-as-a-whole rule the +# per-task base exists to keep. A recorded branch that has since disappeared +# falls back to the project's current declaration, exactly as cleanup does, +# rather than stranding the work with no command that lands it. +TASK_BASE=$(grep '^base=' "$META" | tail -n 1 | cut -d= -f2- || true) +DEFAULT= +if [ -n "$TASK_BASE" ] && git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$TASK_BASE" >/dev/null; then + DEFAULT=$TASK_BASE +fi +if [ -z "$DEFAULT" ]; then + DEFAULT=$("$FM_ROOT/bin/fm-project-base.sh" "$PROJ" "$(basename "$PROJ")" 2>/dev/null || true) + [ -n "$DEFAULT" ] || DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; } +fi # The project's main checkout must be on its default branch and clean, so the # fast-forward lands predictably (firstmate never writes here otherwise). diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index 36422519902..9c35c7b014b 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -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 --mode --yolo [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] -# fm-spawn.sh --scout [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] +# Usage: fm-spawn.sh --mode --yolo [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--base ] +# fm-spawn.sh --scout [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] [--base ] # fm-spawn.sh [] [--harness |harness|launch-command] [--model ] [--effort ] [--backend ] --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 @@ -73,6 +73,20 @@ # A backend spawn refusal (missing dependency, version gate, unauthenticated # socket, or unsupported secondmate mode) is terminal for that selected backend; # callers must surface it instead of silently retrying another backend. +# --base is the explicit base branch for this exact ship or scout +# spawn. It governs that spawn alone and is never written back to +# data/projects.md, so an effort that must accumulate on one integration +# branch never edits the shared registry and never depends on remembering to +# restore it. Precedence is --base, then the project's registry base= record, +# then the repository default branch, unchanged from before this flag. It +# resolves exactly as a registry base= does: origin/ for a shared +# clone, the local branch in local-only mode or when no origin is configured. +# A base that does not resolve refuses the spawn naming the branch rather than +# quietly falling back to the registry value or the default, so a worker never +# launches on a base other than the one it was dispatched for. The resolved +# base is recorded as base= in state/.meta; --relaunch reuses that record +# like every other identity axis and refuses --base alongside it. --secondmate +# has no task worktree and refuses the flag too. # A herdr crewmate or scout is placed in the exact workspace of the firstmate # or secondmate process launching it, resolved from that process's own herdr # pane rather than from a workspace label (herdr enforces no label uniqueness, @@ -521,6 +535,7 @@ HARNESS_ARG= MODEL= EFFORT= BACKEND_ARG= +BASE_ARG= MODE= YOLO= TRACEPARENT_ARG= @@ -528,6 +543,7 @@ HARNESS_SET=0 MODEL_SET=0 EFFORT_SET=0 BACKEND_SET=0 +BASE_SET=0 MODE_SET=0 YOLO_SET=0 TRACEPARENT_SET=0 @@ -559,6 +575,10 @@ for a in "$@"; do BACKEND_ARG=$a BACKEND_SET=1 ;; + base) + BASE_ARG=$a + BASE_SET=1 + ;; mode) MODE=$a MODE_SET=1 @@ -609,6 +629,11 @@ for a in "$@"; do BACKEND_ARG=${a#--backend=} BACKEND_SET=1 ;; + --base) want_value=base ;; + --base=*) + BASE_ARG=${a#--base=} + BASE_SET=1 + ;; --mode) want_value=mode ;; --mode=*) MODE=${a#--mode=} @@ -647,6 +672,10 @@ done echo "error: --backend requires a non-empty value" >&2 exit 1 } +[ "$BASE_SET" -eq 0 ] || [ -n "$BASE_ARG" ] || { + echo "error: --base requires a non-empty value" >&2 + exit 1 +} [ "$MODE_SET" -eq 0 ] || [ -n "$MODE" ] || { echo "error: --mode requires a non-empty value" >&2 exit 1 @@ -689,6 +718,10 @@ if [ "$RELAUNCH" -eq 1 ]; then echo "error: --relaunch reuses the task's recorded backend; --backend cannot override it" >&2 exit 1 } + [ "$BASE_SET" -eq 0 ] || { + echo "error: --relaunch reuses the task's recorded base branch; --base cannot override it" >&2 + exit 1 + } [ "$KIND_SET" -eq 0 ] || { echo "error: --relaunch reuses the task's recorded kind; --scout/--secondmate cannot override it" >&2 exit 1 @@ -743,6 +776,12 @@ else exit 1 } fi + # A secondmate launches in its own firstmate home rather than a task worktree + # of some project, so it has no base branch to be dispatched against. + [ "$KIND" != secondmate ] || [ "$BASE_SET" -eq 0 ] || { + echo "error: --base applies only to ship and scout spawns; a secondmate launches in its own home, not a task worktree" >&2 + exit 1 + } fi spawn_remote_secondmate() { @@ -1300,6 +1339,7 @@ if [ "${#POS[@]}" -gt 0 ] && [ "${POS[0]}" != "$idpart" ] && case "$idpart" in * [ -z "$MODEL" ] || shared_args+=(--model "$MODEL") [ -z "$EFFORT" ] || shared_args+=(--effort "$EFFORT") [ -z "$BACKEND_ARG" ] || shared_args+=(--backend "$BACKEND_ARG") + [ -z "$BASE_ARG" ] || shared_args+=(--base "$BASE_ARG") # One delivery contract applies to every pair in a batch, exactly like the shared # harness. Each pair still re-validates it against its own brief, so a batch # spanning several modes is two invocations rather than a silent mixed dispatch. @@ -2804,8 +2844,10 @@ spawn_worktree_has_origin_config() { # return 1 } -freshen_spawn_worktree_base() { # - local worktree=$1 default target expected actual status +# The base branch this spawn actually resolved, empty when no base applied. +SPAWN_RESOLVED_BASE= +freshen_spawn_worktree_base() { # + local worktree=$1 default base_source target expected actual status has_origin=1 posture status=$(git -C "$worktree" -c core.quotePath=false status --porcelain) || { echo "error: could not inspect pooled worktree '$worktree' before refreshing its base" >&2 return 1 @@ -2819,44 +2861,67 @@ freshen_spawn_worktree_base() { # return 1 fi if ! spawn_worktree_has_origin_config "$worktree"; then - return 0 - fi - if ! git -C "$worktree" fetch --quiet origin; then - echo "error: could not fetch origin for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 - fi - if ! git -C "$worktree" remote set-head origin --auto >/dev/null 2>&1; then - echo "error: could not resolve origin's current default branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 + # With no origin there is nothing to freshen against, so a spawn that was + # given no base keeps launching from the clean worktree's own HEAD. An + # explicit --base is the branch this exact task was dispatched for, so it + # still governs here and still refuses a branch this copy cannot reach. + [ -n "$BASE_ARG" ] || return 0 + has_origin=0 + else + if ! git -C "$worktree" fetch --quiet origin; then + echo "error: could not fetch origin for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + return 1 + fi + if ! git -C "$worktree" remote set-head origin --auto >/dev/null 2>&1; then + echo "error: could not resolve origin's current default branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + return 1 + fi fi # A freshly allocated pool slot lands on the repo's DEFAULT branch, which is # silently the wrong base for a project that develops elsewhere: a task once # audited a tree 1036 commits behind origin/develop and correctly reported - # that nothing in its brief existed. bin/fm-project-base.sh owns which branch - # the project declares; absent a declaration, the default stands. - default=$("$FM_ROOT/bin/fm-project-base.sh" "$worktree" "$PROJ_NAME" 2>/dev/null || true) - base_source=recorded - if [ -z "$default" ]; then - base_source=default - default=$(default_branch "$worktree") || { - echo "error: could not determine origin's default branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 - return 1 - } + # that nothing in its brief existed. An explicit --base names the branch THIS + # task was dispatched against and governs this spawn alone, which is what lets + # an effort accumulate on one integration branch without editing shared state + # and having to remember to put it back. Absent that, bin/fm-project-base.sh + # owns which branch the project declares; absent a declaration, the default + # stands. + if [ -n "$BASE_ARG" ]; then + default=$BASE_ARG + base_source=explicit + else + default=$("$FM_ROOT/bin/fm-project-base.sh" "$worktree" "$PROJ_NAME" 2>/dev/null || true) + base_source=recorded + if [ -z "$default" ]; then + base_source=default + default=$(default_branch "$worktree") || { + echo "error: could not determine origin's default branch for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + return 1 + } + fi fi # origin/ is the fetched truth for a shared clone. A local-only project # inverts that: it lands with bin/fm-merge-local.sh, which fast-forwards the # LOCAL branch and never pushes, so there origin/ is the stale one. - if [ "$MODE" = local-only ]; then + # $MODE alone cannot decide that for an explicit --base: a scout is refused + # --mode by design, so it is always empty there and a scout dispatched against + # a local integration branch would resolve it as a stale origin ref. The + # project's registered posture answers for that spawn; without --base the + # existing resolution is left exactly as it was. + posture=$MODE + [ -z "$BASE_ARG" ] || [ -n "$posture" ] \ + || posture=$("$FM_ROOT/bin/fm-project-mode.sh" --raw "$PROJ_NAME" 2>/dev/null | cut -d' ' -f1 || true) + if [ "$posture" = local-only ] || [ "$has_origin" = 0 ]; then target=$default else target="origin/$default" if ! git -C "$worktree" fetch --quiet origin "+refs/heads/$default:refs/remotes/origin/$default"; then - echo "error: could not fetch '$target' for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + echo "error: could not fetch $base_source base '$target' for pooled worktree '$worktree'; refusing to launch rather than falling back to another base" >&2 return 1 fi fi expected=$(git -C "$worktree" rev-parse --verify --quiet "$target^{commit}" 2>/dev/null) || { - echo "error: '$target' is not a commit for pooled worktree '$worktree'; refusing to launch from a potentially stale base" >&2 + echo "error: $base_source base '$target' is not a commit for pooled worktree '$worktree'; refusing to launch rather than falling back to another base" >&2 return 1 } if ! git -C "$worktree" reset --hard "$target" >/dev/null; then @@ -2868,6 +2933,15 @@ freshen_spawn_worktree_base() { # echo "error: pooled worktree '$worktree' is at '${actual:-unknown}', not current $base_source base '$target' ('$expected'); refusing to launch" >&2 return 1 fi + # Only a base this spawn was explicitly dispatched against is recorded below + # as base=, so the task record names the branch it was created against rather + # than a record that may have moved on since; a relaunch carries the line + # forward unchanged with the reused worktree. A spawn that named no base owns + # no base: its landing and cleanup keep resolving the project's standing + # declaration, which is free to change under an in-flight task. + if [ "$base_source" = explicit ]; then + SPAWN_RESOLVED_BASE=$default + fi } herdr_projection_meta_field_exact() { # @@ -4227,6 +4301,10 @@ preserve_relaunch_meta() { echo "kind=$KIND" [ -z "$MODE" ] || echo "mode=$MODE" [ -z "$YOLO" ] || echo "yolo=$YOLO" + # A fresh ship or scout records the base it was created against; a relaunch + # has none to resolve and keeps the task's own recorded line through + # preserve_relaunch_meta, exactly like every other unowned field. + [ -z "$SPAWN_RESOLVED_BASE" ] || echo "base=$SPAWN_RESOLVED_BASE" echo "tasktmp=$TASK_TMP" echo "model=${MODEL:-default}" echo "effort=${EFFORT:-default}" diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index e0b9c339be0..8ff45a1ef35 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -1740,7 +1740,18 @@ validate_worktree_teardown_safety() { unpushed=$(printf '%s\n' "$unpushed_raw" | head -5) if [ -n "$unpushed" ] && [ "$MODE" = local-only ]; then - DEFAULT=$("$FM_ROOT/bin/fm-project-base.sh" "$PROJ" "$(basename "$PROJ")" 2>/dev/null || true) + # Landed means merged into the branch this task was dispatched against. + # A task created with an explicit --base lands on that branch (see + # bin/fm-merge-local.sh), so measuring its work against the project's + # standing base would report already-merged commits as unmerged and refuse + # cleanup. A recorded base this copy cannot resolve falls back to the + # standing one, which only ever refuses more. + DEFAULT=$(meta_value "$META" base) + if [ -n "$DEFAULT" ] \ + && ! git -C "$WT" rev-parse --verify --quiet "refs/heads/$DEFAULT^{commit}" >/dev/null 2>&1; then + DEFAULT= + fi + [ -n "$DEFAULT" ] || DEFAULT=$("$FM_ROOT/bin/fm-project-base.sh" "$PROJ" "$(basename "$PROJ")" 2>/dev/null || true) [ -n "$DEFAULT" ] || DEFAULT=$(default_branch) || { echo "REFUSED: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master." >&2; return 1; } if ! unmerged_raw=$(git -C "$WT" log --oneline HEAD --not "$DEFAULT" -- 2>/dev/null); then if worktree_safety_blocked_by_lock "commits not on $DEFAULT"; then diff --git a/docs/architecture.md b/docs/architecture.md index c8e8a9e58ef..e0109db1ae9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -337,6 +337,10 @@ A project's development branch is declared in the project repository itself, in That branch is what a task worktree must start from, because a freshly allocated pool worktree otherwise lands on the repo's default branch; `bin/fm-spawn.sh` checks the resolved branch out and refuses to launch a worker onto a base it cannot confirm. It is also what `bin/fm-fleet-sync.sh` keeps each clone current against, so a clone of a project developed off its repository default cannot report itself current while falling months behind. For a `local-only` project it is equally where work lands: `bin/fm-merge-local.sh` fast-forwards the resolved branch and `bin/fm-teardown.sh` measures merged work against that same branch, so the landing and the cleanup check can never disagree about which branch holds the work. +That resolved branch is the project's standing base, not one task's: `bin/fm-spawn.sh --base ` overrides it for a single ship or scout spawn without writing anything back, so an effort that must accumulate on one integration branch never edits shared state it then has to remember to restore. +The spawn records an explicitly dispatched base in the task's metadata and a relaunch reuses that record, so such a task keeps the branch it was created against even after the project's own record moves on; a spawn that named no base records none and keeps resolving the project's standing declaration, which stays free to change under it. +That record also governs the landing and the cleanup: `bin/fm-merge-local.sh` fast-forwards the task's own recorded base and `bin/fm-teardown.sh` measures its merged work against it, falling back to the project's standing base when the task recorded none or when the branch it recorded has since disappeared - otherwise a single task would land the whole integration branch into the standing branch, and cleanup would call already-merged work unmerged. +Either base resolves to `origin/` for a shared clone and to the local branch for a `local-only` project, which lands through `bin/fm-merge-local.sh` and never pushes; an explicit `--base` on a spawn that carries no mode of its own - a scout, since `--mode` is refused there - reads the project's registered posture for that decision rather than treating the missing mode as a shared clone. When a selected delivery path calls for a diff, `bin/fm-review-diff.sh` refreshes the authoritative base and, when task meta records `pr=`, always fetches and compares against `refs/pull//head` by default (recorded `pr_head=` is only an offline fallback) before falling back to the local branch with a warning. Where a no-mistakes pipeline stores evidence in the repo, it publishes that PR-viewable validation evidence to an orphan evidence branch that shares no history with code branches, so it never enters the crew branch or the default branch. This repo uses that setting, and its own `.no-mistakes/` directory remains local state that stays gitignored and is rejected by CI if tracked; [`configuration.md`](configuration.md) owns the setting. diff --git a/docs/scripts.md b/docs/scripts.md index 636d93f4e16..575b5db33dd 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -70,7 +70,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-config-push.sh` | Push declared inherited local material to live local or remote secondmates and send the placement-specific config reread when changed | | `fm-project-mode.sh` | Resolve a project's registered delivery posture from `data/projects.md` for fleet sync and home seeding, plus the legacy `base=` fallback tier that `fm-project-base.sh` consults | | `fm-project-base.sh` | Resolve a project's development branch from its committed `.firstmate-base`, falling back to the registry record, for spawns, fleet sync, local landing, and teardown | -| `fm-merge-local.sh` | Fast-forward a `local-only` project's local development branch after approval | +| `fm-merge-local.sh` | Fast-forward a `local-only` task's own local base branch after approval | | `fm-review-diff.sh` | Review a crewmate branch or resolved PR head against the authoritative base | | `fm-marker-lib.sh` | Compatibility entry point for the from-firstmate carrier owned by `fm-operational-input.sh` | | `fm-task-inbox-lib.sh` | Single owner of durable steering-inbox records, acknowledgement, doorbells, and the delivery-attempt ladder | diff --git a/tests/fm-control-relaunch.test.sh b/tests/fm-control-relaunch.test.sh index f4cde27b896..1e6ab4c94ed 100755 --- a/tests/fm-control-relaunch.test.sh +++ b/tests/fm-control-relaunch.test.sh @@ -417,6 +417,7 @@ test_relaunch_preserves_durable_task_metadata() { printf '%s\n' 'pr_head=feature/relaunch' printf '%s\n' 'x_request=request-19' printf '%s\n' 'decisions_reviewed=1' + printf '%s\n' 'base=integration/multicurrency' } >> "$dir/home/state/rl19.meta" out=$(run_control "$dir" rl19 relaunch --note "continuing review work"); rc=$? @@ -429,6 +430,10 @@ test_relaunch_preserves_durable_task_metadata() { || fail "the task X request must survive relaunch" [ "$(meta_field "$dir" rl19 decisions_reviewed)" = 1 ] \ || fail "the task decision state must survive relaunch" + # A replacement worker must keep the branch the task was created against: a + # relaunch resolves no base of its own and never re-reads the project registry. + [ "$(meta_field "$dir" rl19 base)" = "integration/multicurrency" ] \ + || fail "the task base branch must survive relaunch" pass "fm-control relaunch: durable task metadata survives replacement launch publication" } diff --git a/tests/fm-spawn-base-branch.test.sh b/tests/fm-spawn-base-branch.test.sh index d7967b682af..08a76399be1 100755 --- a/tests/fm-spawn-base-branch.test.sh +++ b/tests/fm-spawn-base-branch.test.sh @@ -12,6 +12,13 @@ # work happens on develop, plus a cold worktree detached at main - and assert # the recorded base is checked out, an unconfirmable base is refused instead of # launched, and a project with no base= record still spawns as it does today. +# +# The same fixtures cover fm-spawn.sh's per-spawn --base flag, which exists so an +# effort can accumulate on one integration branch without editing the shared +# registry and having to remember to restore it: the flag wins over the registry, +# the registry still governs when the flag is absent, an explicit base that does +# not resolve refuses instead of falling back, and a relaunch reuses the task's +# own recorded base rather than accepting a new one. set -u # shellcheck source=tests/lib.sh @@ -109,15 +116,20 @@ EOF # at intake rather than the spawn re-reading the registry, so the caller says so. run_base_spawn() { local id=$1 mode=${2:-no-mistakes} kind=${3:-ship} - local kindflags=(--mode "$mode" --yolo off) - [ "$kind" != scout ] || kindflags=(--scout) + local -a kindargs + shift $(( $# < 3 ? $# : 3 )) + if [ "$kind" = scout ]; then + kindargs=(--scout) + else + kindargs=(--mode "$mode" --yolo off) + fi FM_ROOT_OVERRIDE='' FM_HOME="$HOME_DIR" \ FM_STATE_OVERRIDE="$HOME_DIR/state" FM_DATA_OVERRIDE="$HOME_DIR/data" \ FM_PROJECTS_OVERRIDE="$HOME_DIR/projects" FM_CONFIG_OVERRIDE="$HOME_DIR/config" \ FM_SPAWN_NO_GUARD=1 TMUX="fake,1,0" \ FM_FAKE_PANE_PATH="$WT_DIR" \ PATH="$FAKEBIN_DIR:$PATH" \ - "$SPAWN" "$id" "$PROJ_DIR" "${kindflags[@]}" 2>&1 + "$SPAWN" "$id" "$PROJ_DIR" "${kindargs[@]}" "$@" 2>&1 } head_sha() { git -C "$1" rev-parse HEAD; } @@ -406,6 +418,265 @@ test_declaration_resolves_under_stock_bash() { pass "the resolver reads a declaration from an unchecked-out branch under stock /bin/bash" } +# The per-spawn flag wins over the project's registry record. This is the whole +# point of the flag: an effort that must accumulate on one integration branch +# says so at dispatch instead of editing shared state it then has to restore. +test_explicit_base_beats_the_registry() { + local rec id out status + id=base-explicit-b8 + rec=$(make_base_case base-explicit "$id" main \ + '- base-explicit [no-mistakes base=main] - registry says main (added 2026-09-17)') + read_base_record "$rec" + + out=$(run_base_spawn "$id" no-mistakes ship --base develop) + status=$? + expect_code 0 "$status" "an explicit --base should spawn: $out" + [ "$(head_sha "$WT_DIR")" = "$(git -C "$PROJ_DIR" rev-parse origin/develop)" ] \ + || fail "explicit --base did not win over the registry record (HEAD $(head_sha "$WT_DIR"))" + assert_grep "base=develop" "$HOME_DIR/state/$id.meta" "resolved base was not recorded" + pass "an explicit --base wins over the project's registry base record" +} + +# The flag governs its own spawn only: nothing is written back to the registry, +# so the next unrelated task still reads the captain's standing base. +test_explicit_base_is_not_written_back() { + local rec id out status before + id=base-noleak-b9 + rec=$(make_base_case base-noleak "$id" main \ + '- base-noleak [no-mistakes base=main] - registry says main (added 2026-09-17)') + read_base_record "$rec" + before=$(cat "$HOME_DIR/data/projects.md") + + out=$(run_base_spawn "$id" no-mistakes ship --base develop) + status=$? + expect_code 0 "$status" "an explicit --base should spawn: $out" + [ "$(cat "$HOME_DIR/data/projects.md")" = "$before" ] \ + || fail "an explicit --base rewrote the shared project registry" + pass "an explicit --base is never written back to the shared registry" +} + +# Precedence below the flag is unchanged: with no --base the registry still wins. +test_registry_base_applies_without_the_flag() { + local rec id out status + id=base-noflag-b10 + rec=$(make_base_case base-noflag "$id" main \ + '- base-noflag [no-mistakes base=develop] - develop-based (added 2026-09-17)') + read_base_record "$rec" + + out=$(run_base_spawn "$id") + status=$? + expect_code 0 "$status" "a spawn with no --base should still use the registry: $out" + [ "$(head_sha "$WT_DIR")" = "$(git -C "$PROJ_DIR" rev-parse origin/develop)" ] \ + || fail "the registry base stopped applying when --base was absent" + assert_no_grep "base=develop" "$HOME_DIR/state/$id.meta" \ + "a spawn that named no base still recorded one, freezing its landing target" + pass "the registry base still applies when --base is absent" +} + +# The fail-closed half. A base the project cannot resolve must stop the spawn +# naming the branch, never quietly fall back to the registry value: a worker +# silently based on the wrong branch is exactly the failure the flag prevents. +test_unresolvable_explicit_base_refuses() { + local rec id out status + id=base-badflag-b11 + rec=$(make_base_case base-badflag "$id" main \ + '- base-badflag [no-mistakes base=develop] - develop-based (added 2026-09-17)') + read_base_record "$rec" + + out=$(run_base_spawn "$id" no-mistakes ship --base integration/never-created) + status=$? + [ "$status" -ne 0 ] || fail "an unresolvable --base should refuse the spawn: $out" + assert_contains "$out" "integration/never-created" "refusal did not name the requested base branch" + assert_contains "$out" "refusing to launch" "refusal did not say it was refusing to launch" + [ "$(head_sha "$WT_DIR")" != "$(git -C "$PROJ_DIR" rev-parse origin/develop)" ] \ + || fail "an unresolvable --base silently fell back to the registry base" + pass "an explicit base the project cannot resolve refuses instead of falling back" +} + +# A scout audits the tree a ship would build on, so it takes the flag too. +test_scout_accepts_an_explicit_base() { + local rec id out status + id=base-scoutflag-b12 + rec=$(make_base_case base-scoutflag "$id" main \ + '- base-scoutflag [no-mistakes base=main] - registry says main (added 2026-09-17)') + read_base_record "$rec" + + out=$(run_base_spawn "$id" no-mistakes scout --base develop) + status=$? + expect_code 0 "$status" "a scout should accept an explicit base: $out" + [ "$(head_sha "$WT_DIR")" = "$(git -C "$PROJ_DIR" rev-parse origin/develop)" ] \ + || fail "scout did not start from the explicit base (HEAD $(head_sha "$WT_DIR"))" + pass "a scout also starts from an explicit --base" +} + +# A scout is refused --mode by design, so its local-vs-origin choice has to come +# from the project's registered posture: a local-only project lands with +# bin/fm-merge-local.sh and never pushes, so origin/ is the stale ref and a +# scout resolving against it would audit an old tree. +test_scout_on_a_local_only_project_uses_the_local_branch() { + local rec id out status + id=base-scoutlocal-b15 + rec=$(make_base_case base-scoutlocal "$id" main \ + '- base-scoutlocal [local-only] - merged locally, never pushed (added 2026-09-17)') + read_base_record "$rec" + git -C "$PROJ_DIR" checkout -q -b integration/x origin/main + printf 'landed locally\n' > "$PROJ_DIR/landed.txt" + git -C "$PROJ_DIR" add landed.txt + git -C "$PROJ_DIR" commit -qm "landed locally on the integration branch" + git -C "$PROJ_DIR" checkout -q main + + out=$(run_base_spawn "$id" no-mistakes scout --base integration/x) + status=$? + expect_code 0 "$status" "a scout on a local-only project should spawn: $out" + [ "$(head_sha "$WT_DIR")" = "$(git -C "$PROJ_DIR" rev-parse integration/x)" ] \ + || fail "scout did not start from the local integration branch (HEAD $(head_sha "$WT_DIR"))" + pass "a scout on a local-only project starts from the local base branch" +} + +# A relaunch keeps every identity axis the task was created with, and the base is +# one of them: a replacement worker must not be handed a different branch. +test_relaunch_refuses_an_explicit_base() { + local rec id out status + id=base-relaunch-b13 + rec=$(make_base_case base-relaunch "$id" main \ + '- base-relaunch [no-mistakes base=develop] - develop-based (added 2026-09-17)') + read_base_record "$rec" + + out=$(FM_ROOT_OVERRIDE='' FM_HOME="$HOME_DIR" \ + FM_STATE_OVERRIDE="$HOME_DIR/state" FM_DATA_OVERRIDE="$HOME_DIR/data" \ + FM_PROJECTS_OVERRIDE="$HOME_DIR/projects" FM_CONFIG_OVERRIDE="$HOME_DIR/config" \ + FM_SPAWN_NO_GUARD=1 TMUX="fake,1,0" PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" "$id" --relaunch --base develop 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "--relaunch should refuse --base: $out" + assert_contains "$out" "--base" "refusal did not name the rejected flag" + pass "--relaunch refuses --base and keeps the task's own recorded base" +} + +# A secondmate launches in its own home, not a task worktree, so it has no base. +test_secondmate_refuses_an_explicit_base() { + local rec out status + rec=$(make_base_case base-secondmate sm-base-b14 main \ + '- base-secondmate [no-mistakes] - secondmate refusal fixture (added 2026-09-17)') + read_base_record "$rec" + + out=$(FM_ROOT_OVERRIDE='' FM_HOME="$HOME_DIR" \ + FM_STATE_OVERRIDE="$HOME_DIR/state" FM_DATA_OVERRIDE="$HOME_DIR/data" \ + FM_PROJECTS_OVERRIDE="$HOME_DIR/projects" FM_CONFIG_OVERRIDE="$HOME_DIR/config" \ + FM_SPAWN_NO_GUARD=1 TMUX="fake,1,0" PATH="$FAKEBIN_DIR:$PATH" \ + "$SPAWN" sm-base-b14 --secondmate --base develop 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "--secondmate should refuse --base: $out" + assert_contains "$out" "--base" "refusal did not name the rejected flag" + pass "--secondmate refuses --base" +} + +# --- the recorded base governs landing and cleanup too ---------------------- +# Recording base= would be theatre if the landing path ignored it: a local-only +# task dispatched against an integration branch would have bin/fm-merge-local.sh +# fast-forward the project's STANDING branch over every commit the effort has +# accumulated, which is precisely the one-merge-checked-as-a-whole rule the flag +# exists to keep. +# make_landing_case [base=]: +# a local-only project with main plus an integration branch, a task worktree +# holding one commit on top of , and the project +# checkout left on that branch so the landing can fast-forward it. Extra args +# are appended to the task's meta. Prints home|project|worktree. +make_landing_case() { + local name=$1 id=$2 on=$3 + shift 3 + local dir home proj wt + dir="$TMP_ROOT/landing/$name" + home="$dir/home" + proj="$dir/projects/$name" + wt="$dir/projects/$id" + mkdir -p "$home/state" "$home/data" "$home/config" "$dir/projects" + fm_git_init_commit "$proj" + git -C "$proj" branch integration/x main + git -C "$proj" worktree add --quiet -b "fm/$id" "$wt" "$on" + printf 'effort work\n' > "$wt/effort.txt" + git -C "$wt" add effort.txt + git -C "$wt" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' \ + commit -qm 'effort work' + git -C "$proj" checkout -q "$on" + fm_write_meta "$home/state/$id.meta" \ + "window=firstmate:fm-$id" "endpoint_task_id=$id" "worktree=$wt" \ + "project=$proj" "kind=ship" "mode=local-only" "spawn_gen=fixture-$id" "$@" + printf '%s|%s|%s\n' "$home" "$proj" "$wt" +} + +run_local_merge() { # + FM_ROOT_OVERRIDE="$ROOT" FM_HOME="$1" FM_STATE_OVERRIDE="$1/state" \ + FM_DATA_OVERRIDE="$1/data" FM_CONFIG_OVERRIDE="$1/config" \ + "$ROOT/bin/fm-merge-local.sh" "$2" 2>&1 +} + +test_local_merge_lands_on_the_tasks_recorded_base() { + local rec home proj wt id out status main_before + id=base-landing-b16 + rec=$(make_landing_case base-landing "$id" integration/x "base=integration/x") + IFS='|' read -r home proj wt < "$proj/.firstmate-base" + git -C "$proj" add .firstmate-base + git -C "$proj" -c user.name='Firstmate Tests' -c user.email='tests@example.invalid' \ + commit -qm 'declare integration/x' + git -C "$proj" checkout -q integration/x + + out=$(run_local_merge "$home" "$id") + status=$? + expect_code 0 "$status" "a task with no recorded base should land on the project's current declaration: $out" + [ "$(git -C "$proj" rev-parse integration/x)" = "$(git -C "$wt" rev-parse HEAD)" ] \ + || fail "the declared branch did not receive the work" + pass "a task dispatched without --base lands on the project's current declaration" +} + +# A recorded branch that has since disappeared is a reason to fall back to the +# project's current declaration, not to refuse forever: refusing there strands +# the work with no command that lands it. +test_local_merge_falls_back_when_the_recorded_base_is_gone() { + local rec home proj wt id out status + id=base-gonebase-b18 + rec=$(make_landing_case base-gonebase "$id" main "base=integration/gone") + IFS='|' read -r home proj wt < "$case_dir/stdout" 2> "$case_dir/stderr" + rc=$? + set -e + + expect_code 0 "$rc" "merged-task-base: teardown should succeed when work is merged into the task's recorded base" + ! grep -q REFUSED "$case_dir/stderr" || fail "merged-task-base: teardown printed a REFUSED line" + [ "$(git -C "$case_dir/project" rev-parse main)" = "$main_before" ] \ + || fail "merged-task-base: the project's standing base branch was moved" + pass "local-only worktree merged into the task's own recorded base branch is torn down" +} + test_no_mistakes_origin_remote_allows() { local case_dir rc case_dir=$(make_case nm-origin) @@ -3705,6 +3735,7 @@ test_teardown_manual_backend_leaves_the_backlog_to_the_operator test_local_only_truly_unpushed_refuses test_local_only_merged_to_local_main_allows test_local_only_merged_to_declared_branch_allows +test_local_only_merged_to_task_base_allows test_no_mistakes_origin_remote_allows test_no_mistakes_truly_unpushed_refuses test_local_only_force_overrides_unpushed