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
1 change: 1 addition & 0 deletions .agents/skills/project-management/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Choose the delivery mode when adding or creating the project:
- `direct-PR` pushes and opens a PR without the no-mistakes pipeline.
- `local-only` has no required remote or PR and lands only through the approved local fast-forward path.

Record the project's base branch in the same registry entry whenever the project is developed off a branch other than its repository default, so a freshly allocated task worktree does not start a worker on a stale tree.
The optional `+yolo` posture changes routine approval authority but does not change the delivery mode.
Default it off, and enable it only on the captain's explicit instruction.
`AGENTS.md` section 7 owns the complete authority boundary and exceptions when it is on.
Expand Down
50 changes: 40 additions & 10 deletions bin/fm-project-mode.sh
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
#!/usr/bin/env bash
# Resolve a project's delivery mode and yolo flag from the data/projects.md registry.
# Resolve a project's delivery mode, yolo flag, and base branch from the
# data/projects.md registry.
# Prints two words to stdout: "<mode> <yolo>" where mode is one of
# no-mistakes|direct-PR|local-only and yolo is on|off.
# With --base, prints the recorded base branch instead, or nothing when the
# project has no base= record (callers fall back to the repo's default branch).
#
# Registry line format (data/projects.md):
# - <name> - <desc> (added <date>) -> no-mistakes off (legacy default)
# - <name> [<mode>] - <desc> (added <date>) -> <mode> off
# - <name> [<mode> +yolo] - <desc> (added <date>) -> <mode> on
# - <name> [<mode> base=<branch>] - <desc> ... -> <mode> off, base <branch>
# The bracket holds unordered tokens, so +yolo and base=<branch> may appear in
# either order and either may be omitted.
#
# base = the branch a task worktree must start from. A freshly allocated pool
# worktree lands on the repo's DEFAULT branch, which is the wrong base for a
# project that develops elsewhere; bin/fm-spawn.sh checks this branch out and
# refuses to launch a worker it cannot confirm is on it.
# mode = how a finished change reaches main:
# no-mistakes full pipeline -> PR -> captain merge (default)
# direct-PR push + PR via gh-axi, no pipeline -> captain merge
Expand All @@ -18,46 +28,66 @@
#
# An unknown/missing project or unknown mode falls back to "no-mistakes off" and warns
# to stderr, so a typo never silently drops the gate.
# Usage: fm-project-mode.sh <project-name>
# Usage: fm-project-mode.sh [--base] <project-name>
set -eu

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FM_ROOT="${FM_ROOT_OVERRIDE:-$(cd "$SCRIPT_DIR/.." && pwd)}"
FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}"
REG="$DATA/projects.md"
NAME=${1:?usage: fm-project-mode.sh <project-name>}
WANT_BASE=no
if [ "${1:-}" = "--base" ]; then
WANT_BASE=yes
shift
fi
NAME=${1:?usage: fm-project-mode.sh [--base] <project-name>}

# A base query is advisory: an absent registry, project, or base= record all mean
# "no recorded base", and the caller falls back to the repo default branch.
if [ ! -f "$REG" ]; then
[ "$WANT_BASE" = yes ] && exit 0
echo "warn: no registry at $REG; defaulting $NAME to no-mistakes off" >&2
echo "no-mistakes off"
exit 0
fi

# awk emits "<mode> <yolo>" (one line) or nothing if the project is absent.
# awk emits "<mode> <yolo> <base>" (one line, base may be empty) or nothing if
# the project is absent.
parsed=$(awk -v n="$NAME" '
$1=="-" && $2==n {
mode="no-mistakes"; yolo="off";
mode="no-mistakes"; yolo="off"; base="";
if ($3 ~ /^\[/) {
s="";
for (i=3; i<=NF; i++) { s = s (s==""?"":" ") $i; if ($i ~ /\]$/) break }
gsub(/^\[|\]$/, "", s); # strip the surrounding brackets
k = split(s, a, " ");
if (a[1] != "" && a[1] != "+yolo") mode = a[1];
for (j=1; j<=k; j++) if (a[j]=="+yolo") yolo="on";
if (a[1] != "" && a[1] != "+yolo" && a[1] !~ /^base=/) mode = a[1];
for (j=1; j<=k; j++) {
if (a[j]=="+yolo") yolo="on";
if (a[j] ~ /^base=/) base = substr(a[j], 6);
}
}
print mode, yolo; exit
print mode, yolo, base; exit
}
' "$REG")

if [ -z "$parsed" ]; then
[ "$WANT_BASE" = yes ] && exit 0
echo "warn: project \"$NAME\" not in registry; defaulting to no-mistakes off" >&2
echo "no-mistakes off"
exit 0
fi

mode=${parsed%% *}
yolo=${parsed##* }
read -r mode yolo base <<EOF
$parsed
EOF

if [ "$WANT_BASE" = yes ]; then
# A bracket token of "base=" alone records nothing usable; treat it as absent.
[ -n "${base:-}" ] && echo "$base"
exit 0
fi
case "$mode" in
no-mistakes|direct-PR|local-only) ;;
*) echo "warn: unknown mode \"$mode\" for $NAME; defaulting to no-mistakes off" >&2; mode=no-mistakes; yolo=off ;;
Expand Down
117 changes: 116 additions & 1 deletion bin/fm-spawn.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@
# 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.
# That worktree is then detached at the project's base branch - its base=
# record in data/projects.md (bin/fm-project-mode.sh), else the repo default
# branch - and the spawn is refused, naming expected branch, actual base, and
# the gap, when the resulting base cannot be confirmed correct.
# Batch dispatch: pass one or more `id=repo` pairs instead of a single <id> <project>, e.g.
# fm-spawn.sh fix-a-k3=projects/foo add-b-q7=projects/bar [--scout]
# Each pair re-execs this script in single-task mode, so the single path stays the only
Expand Down Expand Up @@ -809,6 +813,9 @@ if [ "$KIND" = secondmate ]; then
fi
else
PROJ_ABS="$(cd "$(resolve_project_dir_arg "$PROJ")" && pwd)"
# Registry key for this project: needed by the base-branch check below, well
# before the delivery-mode lookup at the end reuses it.
PROJ_NAME=$(basename "$PROJ_ABS")
WT=""
BRIEF="$DATA/$ID/brief.md"
fi
Expand Down Expand Up @@ -863,6 +870,110 @@ validate_spawn_worktree() { # <source> <inspect-target>
fi
}

# Base branch of a task worktree.
#
# A freshly allocated pool worktree lands on the repo's DEFAULT branch. For a
# project that develops elsewhere that is silently the wrong base: task
# jt-style-number-column audited a tree 1036 commits behind origin/develop and
# correctly reported that nothing described in its brief existed. A REUSED slot
# already sits on the right branch, which is why this only bites a cold slot.
#
# So the intended base is checked out first (the project's base= record in
# data/projects.md, else the repo default branch as before), then asserted: a
# worker is never launched onto a base that cannot be confirmed correct.
# Detached, exactly like treehouse's own handoff - the branch is often already
# checked out in the primary checkout, and the worker cuts its own branch here.
spawn_expected_base_branch() { # -> "<branch> recorded|default"; empty if unresolvable
local recorded ref
recorded=$("$FM_ROOT/bin/fm-project-mode.sh" --base "$PROJ_NAME" 2>/dev/null || true)
if [ -n "$recorded" ]; then
echo "$recorded recorded"
return 0
fi
ref=$(git -C "$WT" symbolic-ref -q --short refs/remotes/origin/HEAD 2>/dev/null || true)
if [ -n "$ref" ]; then
echo "${ref#origin/} default"
return 0
fi
# No remote HEAD (a local-only project): the primary checkout's own branch is
# the closest thing to a default branch this repo has.
ref=$(git -C "$PROJ_ABS" symbolic-ref -q --short HEAD 2>/dev/null || true)
[ -n "$ref" ] && echo "$ref default"
return 0
}

# origin/<branch> is the fetched truth for a shared clone; a local branch of the
# same name can itself be stale. A local-only project inverts that: it lands work
# with bin/fm-merge-local.sh, which fast-forwards the LOCAL branch and never
# pushes, so there origin/<branch> is the stale one whenever an origin exists at
# all. Empty when the branch exists in neither form.
spawn_base_ref() { # <branch>
local branch=$1 local_ref="" origin_ref=""
git -C "$WT" rev-parse --verify --quiet "refs/heads/$branch" >/dev/null 2>&1 && local_ref="$branch"
git -C "$WT" rev-parse --verify --quiet "refs/remotes/origin/$branch" >/dev/null 2>&1 && origin_ref="origin/$branch"
if [ "${SPAWN_BASE_MODE:-}" = local-only ] && [ -n "$local_ref" ]; then
echo "$local_ref"
elif [ -n "$origin_ref" ]; then
echo "$origin_ref"
elif [ -n "$local_ref" ]; then
echo "$local_ref"
fi
return 0
}

spawn_base_gap() { # <from-ref> <to-ref> -> "<behind> behind, <ahead> ahead"
local counts behind ahead
counts=$(git -C "$WT" rev-list --left-right --count "$1...$2" 2>/dev/null || true)
[ -n "$counts" ] || { echo "gap unknown (unrelated histories)"; return 0; }
behind=$(echo "$counts" | awk '{print $1}')
ahead=$(echo "$counts" | awk '{print $2}')
echo "$behind behind, $ahead ahead"
}

ensure_spawn_base_branch() {
local resolved base source ref want head actual primary primary_ref behind err
SPAWN_BASE_MODE=$("$FM_ROOT/bin/fm-project-mode.sh" "$PROJ_NAME" 2>/dev/null | awk 'NR==1{print $1}' || true)
resolved=$(spawn_expected_base_branch)
if [ -z "$resolved" ]; then
echo "warning: no expected base branch could be resolved for $PROJ_NAME; launching $ID on the worktree's current base" >&2
return 0
fi
base=${resolved%% *}
source=${resolved##* }
ref=$(spawn_base_ref "$base")
if [ -z "$ref" ]; then
echo "error: expected base branch '$base' ($source) does not exist in $WT; refusing to launch $ID onto an unconfirmed base. Fetch that branch, or fix the base= record for $PROJ_NAME in data/projects.md." >&2
exit 1
fi
err=$(git -C "$WT" checkout --quiet --detach "$ref" 2>&1) || true

want=$(git -C "$WT" rev-parse "$ref")
head=$(git -C "$WT" rev-parse HEAD 2>/dev/null || true)
if [ "$head" != "$want" ]; then
actual=$(git -C "$WT" name-rev --name-only --always HEAD 2>/dev/null || echo unknown)
echo "error: $WT is not on its base branch and could not be moved there; refusing to launch $ID onto a stale tree. Expected $base ($ref, ${want:0:12}); actual base $actual (${head:-unknown}); $(spawn_base_gap "$ref" "HEAD"). Resolve the worktree by hand (uncommitted work, or a checkout conflict), then respawn.${err:+ git said: $(echo "$err" | tr '\n' ' ')}" >&2
exit 1
fi

# No recorded base means the default branch was only assumed correct. The
# primary checkout's own branch is independent evidence: if the assumed base
# is strictly behind it, this is the incident above, so refuse rather than
# launch a worker a thousand commits in the past.
if [ "$source" = default ]; then
primary=$(git -C "$PROJ_ABS" symbolic-ref -q --short HEAD 2>/dev/null || true)
if [ -n "$primary" ] && [ "$primary" != "$base" ]; then
primary_ref=$(spawn_base_ref "$primary")
if [ -n "$primary_ref" ]; then
behind=$(git -C "$WT" rev-list --count "$ref..$primary_ref" 2>/dev/null || echo 0)
if [ "${behind:-0}" -gt 0 ]; then
echo "error: refusing to launch $ID onto a base that cannot be confirmed correct. Actual base '$base' (the repo default branch) is $behind commits behind '$primary', the branch $PROJ_ABS is on; expected base '$primary'. No base branch is recorded for $PROJ_NAME, so which one is right depends on why the checkout is on '$primary': if the project genuinely develops there, record it in data/projects.md as \"[<mode> base=$primary]\"; if the checkout is merely parked on a transient branch (a leftover fm/<id>, a checked-out PR), do NOT record it - return the checkout to '$base' instead. Then respawn." >&2
exit 1
fi
fi
fi
fi
}

herdr_projection_meta_field_exact() { # <meta> <key>
local meta=$1 key=$2 count
[ -f "$meta" ] && [ ! -L "$meta" ] || return 1
Expand Down Expand Up @@ -1288,6 +1399,11 @@ if [ "$KIND" != secondmate ] && [ "$BACKEND" != orca ]; then
validate_spawn_worktree "treehouse get" "$T"
fi

# Both worktree sources (treehouse and orca) have produced $WT by here, and no
# harness has launched yet, so this is the one point where the base can still be
# corrected. A secondmate home is a firstmate worktree, not a project one.
[ "$KIND" = secondmate ] || ensure_spawn_base_branch

# Per-task temp root: /tmp/fm-<id>/ with Go's build temp nested at gotmp/. Go won't
# create GOTMPDIR, so mkdir before it is used; fm-teardown removes the whole root.
# Nested (not a bare /tmp/fm-<id>/gotmp) so other per-task temp can live alongside
Expand Down Expand Up @@ -1425,7 +1541,6 @@ if [ "$KIND" = secondmate ]; then
YOLO=off
SECONDMATE_PROJECTS=$(secondmate_registry_value "$ID" projects || true)
else
PROJ_NAME=$(basename "$PROJ_ABS")
read -r MODE YOLO <<EOF
$("$FM_ROOT/bin/fm-project-mode.sh" "$PROJ_NAME")
EOF
Expand Down
3 changes: 2 additions & 1 deletion bin/fm-test-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,8 @@ family_for_basename() {
printf '%s\n' live-harness-optin
;;
fm-backend-herdr.test.sh|fm-backend-tmux-smoke.test.sh|fm-backend.test.sh|\
fm-herdr-session-cleanup.test.sh|fm-send-strict.test.sh|fm-spawn-batch.test.sh|\
fm-herdr-session-cleanup.test.sh|fm-send-strict.test.sh|fm-spawn-base-branch.test.sh|\
fm-spawn-batch.test.sh|\
fm-spawn-dispatch-profile.test.sh|fm-spawn-worktree-settle.test.sh|\
fm-teardown-endpoint-safety.test.sh)
printf '%s\n' backend-dispatch
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,9 @@ The `data/secondmates.md` line contract is owned by the [`secondmate-provisionin

## Project modes are explicit

`data/projects.md` records each project's delivery mode and optional `+yolo` autonomy flag.
`data/projects.md` records each project's delivery mode, optional `+yolo` autonomy flag, and optional base branch.
`no-mistakes` projects run the full validation pipeline, `direct-PR` projects open PRs without that pipeline, and `local-only` projects stay local until firstmate performs an approved fast-forward merge.
The recorded base branch is what a task worktree must start from, because a freshly allocated pool worktree otherwise lands on the repo's default branch, which is the wrong base for a project developed elsewhere; `bin/fm-spawn.sh` checks that branch out and refuses to launch a worker onto a base it cannot confirm.
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.
For target project repos shipped through their own no-mistakes pipeline, commits under `.no-mistakes/evidence/` are the pipeline's PR-viewable validation evidence and are expected to stay in the crew branch until the evidence-hosting design changes.
The firstmate repo itself is the exception: its `.no-mistakes/` directory is local state, stays gitignored, and is rejected by CI if tracked.
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize
| `backends/orca.sh` | Experimental Orca backend adapter owning both worktree and terminal |
| `backends/cmux.sh` | Experimental cmux session-provider adapter |
| `fm-config-push.sh` | Push declared inherited local material to live secondmates mid-session and send a pointer to the literal-content config reread when config changed |
| `fm-project-mode.sh` | Resolve a project's delivery mode and `+yolo` flag from `data/projects.md` |
| `fm-project-mode.sh` | Resolve a project's delivery mode, `+yolo` flag, and base branch from `data/projects.md` |
| `fm-merge-local.sh` | Fast-forward a `local-only` project's local default 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` |
Expand Down
Loading
Loading