Skip to content
Closed
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: 2 additions & 0 deletions .agents/skills/project-management/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <branch>` 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.
Expand Down
123 changes: 97 additions & 26 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> --mode <no-mistakes|direct-PR|local-only> --yolo <on|off> [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>] [--base <branch>]
# fm-spawn.sh <task-id> <project-dir> --scout [--harness <name>|harness|launch-command] [--model <name>] [--effort <level>] [--backend <name>] [--base <branch>]
# 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 @@ -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 <branch> 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/<branch> 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/<id>.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,
Expand Down Expand Up @@ -521,13 +535,15 @@ HARNESS_ARG=
MODEL=
EFFORT=
BACKEND_ARG=
BASE_ARG=
MODE=
YOLO=
TRACEPARENT_ARG=
HARNESS_SET=0
MODEL_SET=0
EFFORT_SET=0
BACKEND_SET=0
BASE_SET=0
MODE_SET=0
YOLO_SET=0
TRACEPARENT_SET=0
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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=}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -2804,8 +2844,10 @@ spawn_worktree_has_origin_config() { # <worktree>
return 1
}

freshen_spawn_worktree_base() { # <worktree>
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() { # <worktree>
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
Expand All @@ -2819,44 +2861,65 @@ freshen_spawn_worktree_base() { # <worktree>
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/<branch> 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/<branch> is the stale one.
if [ "$MODE" = local-only ]; then
# $MODE alone cannot decide that: a scout is refused --mode by design, so it is
# always empty there and every scout on a local-only project would resolve
# against the stale origin ref. The project's registered posture answers for
# the spawns that carry no mode of their own.
posture=$MODE
[ -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
Expand All @@ -2868,6 +2931,10 @@ freshen_spawn_worktree_base() { # <worktree>
echo "error: pooled worktree '$worktree' is at '${actual:-unknown}', not current $base_source base '$target' ('$expected'); refusing to launch" >&2
return 1
fi
# Recorded below as base= so recovery, relaunch, and teardown read the branch
# this task was actually created against rather than re-resolving a registry
# that may have moved on since.
SPAWN_RESOLVED_BASE=$default
}

herdr_projection_meta_field_exact() { # <meta> <key>
Expand Down Expand Up @@ -4227,6 +4294,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}"
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,8 @@ 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 <branch>` 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 the base it resolved in the task's metadata and a relaunch reuses that record, so a task keeps the branch it was created against even after the project's own record moves on.
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/<n>/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.
Expand Down
5 changes: 5 additions & 0 deletions tests/fm-control-relaunch.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=$?
Expand All @@ -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"
}

Expand Down
Loading
Loading