Skip to content
Merged
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
22 changes: 19 additions & 3 deletions bin/fm-merge-local.sh
Original file line number Diff line number Diff line change
@@ -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/<id> 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/<id>
# 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
Expand Down Expand Up @@ -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).
Expand Down
130 changes: 104 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,67 @@ 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 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
Expand All @@ -2868,6 +2933,15 @@ 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
# 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() { # <meta> <key>
Expand Down Expand Up @@ -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}"
Expand Down
13 changes: 12 additions & 1 deletion bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 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/<branch>` 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/<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
Loading
Loading