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
4 changes: 2 additions & 2 deletions .agents/skills/bootstrap-diagnostics/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ When any diagnostic needs captain attention, report the plain consequence and re
- `CREW_DISPATCH: invalid config/crew-dispatch.json - <reason>` - the optional dispatch profile file exists but failed low-cost bootstrap validation; stop profile-based dispatch, report the actionable error, and require correction of the malformed schema, unverified harness name, or invalid harness/effort pair rather than falling back around it or selecting a bad profile.
- `FLEET_SYNC: <repo>: skipped: <reason>` - a benign one-off skip (offline, no origin, local-only); bootstrap continued, investigate only if it blocks work.
A skip can also report the bounded fleet-refresh timeout (`FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT`, or a fleet-size-aware default with a 20 second floor); a timeout never blocks startup.
- `FLEET_SYNC: <repo>: recovered: <detail>` - the clone had drifted onto a clean detached HEAD holding no unique commits and the sync self-healed it (re-attached the default branch and fast-forwarded); no action needed, it is reported only so the self-heal is visible.
- `FLEET_SYNC: <repo>: STUCK: on <state>, N commits behind <base> - needs attention` - the clone is dirty, on a non-default branch, detached with unique commits, or diverged, so the sync left it untouched (never forcing or discarding); it will keep falling behind until you look.
- `FLEET_SYNC: <repo>: recovered: <detail>` - no action needed; the guarded recovery described in `docs/architecture.md` under project-clone refresh succeeded.
- `FLEET_SYNC: <repo>: STUCK: <detail>` - inspect the named blocker under the project-clone refresh contract in `docs/architecture.md`; preserve work, and correct invalid or unpublished registry declarations rather than substituting another base.
A loud STUCK, especially a growing N across bootstraps, means that clone needs hands-on attention; dispatch a crewmate or resolve it before it strands work.
- `HOME_SUMMARY: this home has never published state/home-summary.json` or `... has not been republished since <stamp>` - this home's structured summary publication has failed repeatedly, and the line carries the failure count and the newest recorded reason from `state/.home-summary-refresh.log`.
Publication is deliberately best-effort, so it cannot change another session-start, spawn, teardown, or watcher-poll result, and the watcher runs it detached so a slow attempt cannot delay the liveness beacon.
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/secondmate-provisioning/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ Both of those cases require record intake before the new mate acts on any inheri

For an existing or inherited domain, the creating agent must:

1. Reconcile every inherited plan against the domain's authoritative shipped state, which is `origin/main` for each relevant project plus the live deployment.
1. Reconcile every inherited plan against the domain's authoritative shipped state, using each relevant project's integration base from `bin/fm-integration-branch-lib.sh` plus the live deployment.
A fetched clone of each relevant project is a precondition of that reconciliation, so wire the home to its projects before reconciling rather than on first task.
The imported backlog, the predecessor's own notes, instruction-surface prose, and an absent or unfetched local view are all inadmissible as shipped-state evidence.
2. Seed the new home with only genuinely open work plus the domain's durable knowledge, meaning the learnings, decisions, and delivery posture that are still live.
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ A report may recommend implementation but does not authorize it.
Before treating the investigation or any visual review as complete, load `captain-hold-lifecycle`; teardown enforces that shared completion gate.
When a scout's deliverable is a visual artifact the captain will iterate on, prefer keeping that scout alive to host its own Lavish loop rather than tearing it down and mediating from firstmate, so the scout keeps its investigation context and the captain iterates in one continuous session.
When implementation is separately authorized, promote the existing scout through `bin/fm-promote.sh` rather than creating a duplicate task.
The promoted worker must inventory scratch state, return to a clean default-branch base, carry over only intended fix changes, create the ship branch, and follow the project's selected delivery path while leaving scratch commits and debug edits behind and turning a reproduced bug into the regression test.
The promoted worker must inventory scratch state, return to a clean project integration base (resolved by `bin/fm-integration-branch-lib.sh`), carry over only intended fix changes, create the ship branch, and follow the project's selected delivery path while leaving scratch commits and debug edits behind and turning a reproduced bug into the regression test.

## 8. Supervision protocol

Expand Down
19 changes: 3 additions & 16 deletions bin/fm-ff-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,29 +35,16 @@
SUB_HOME_MARKER="${SUB_HOME_MARKER:-.fm-secondmate-home}"
# shellcheck source=bin/fm-secondmate-registry-lib.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-secondmate-registry-lib.sh"
# default_branch and the integration-branch resolver every base-picking path shares.
# shellcheck source=bin/fm-integration-branch-lib.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-integration-branch-lib.sh"

# --- helpers ---------------------------------------------------------------

first_line() {
printf '%s\n' "$1" | sed -n '1s/[[:space:]]\{1,\}/ /g;1p'
}

default_branch() {
local dir=$1 ref branch
ref=$(git -C "$dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)
if [ -n "$ref" ]; then
echo "${ref#origin/}"
return 0
fi
for branch in main master; do
if git -C "$dir" show-ref --verify --quiet "refs/heads/$branch"; then
echo "$branch"
return 0
fi
done
return 1
}

# Resolve the PRIMARY checkout's current default-branch commit - the local-HEAD
# sync target every secondmate follows. Reads the default branch *ref* rather than
# HEAD, so even a primary stranded on a feature branch (the worktree tangle of
Expand Down
70 changes: 37 additions & 33 deletions bin/fm-fleet-sync.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
#!/usr/bin/env bash
# Refresh project clones: fast-forward the checked-out local default branch to
# origin/<default> when safe, and prune local branches whose upstream tracking
# Refresh project clones: fast-forward the checked-out registered integration
# branch, or the remote default branch for legacy registry entries, to its
# origin/<branch> when safe, and prune local branches whose upstream tracking
# branch is gone (the remote branch was deleted, i.e. its PR merged) and that no
# worktree still needs.
# Self-heals the one unambiguously safe drift: a clean, detached HEAD that holds
# no unique commits (it is an ancestor of origin/<default>) and whose <default>
# no unique commits (it is an ancestor of origin/<branch>) and whose integration
# branch is free to check out is re-attached and then fast-forwarded ("recovered:").
# Every other off-default state - a non-default named branch, a detached HEAD with
# unique commits, a dirty tree, or a diverged default - may hold real work, so it
Expand Down Expand Up @@ -40,6 +41,9 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}"
PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}"
# shellcheck source=bin/fm-lock-lib.sh
. "$SCRIPT_DIR/fm-lock-lib.sh"
# default_branch and the integration-branch resolver every base-picking path shares.
# shellcheck source=bin/fm-integration-branch-lib.sh
. "$SCRIPT_DIR/fm-integration-branch-lib.sh"
# Inert unless FM_TIMING_LOG names a file; only the deferred network stage sets it.
# shellcheck source=bin/fm-timing-lib.sh
. "$SCRIPT_DIR/fm-timing-lib.sh"
Expand Down Expand Up @@ -115,22 +119,6 @@ resolve_project_arg() {
printf '%s\n' "$arg"
}

default_branch() {
local ref branch
ref=$(git -C "$PROJ" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)
if [ -n "$ref" ]; then
echo "${ref#origin/}"
return 0
fi
for branch in main master; do
if git -C "$PROJ" show-ref --verify --quiet "refs/heads/$branch"; then
echo "$branch"
return 0
fi
done
return 1
}

first_line() {
printf '%s\n' "$1" | sed -n '1s/[[:space:]]\{1,\}/ /g;1p'
}
Expand Down Expand Up @@ -289,8 +277,8 @@ stuck_state() {
}

# Loud, quantified report for a clone we deliberately leave untouched. Includes
# how far behind origin/<default> it is, so a chronically-stuck clone is visibly
# distinct from a benign one-off skip.
# how far behind the selected origin/<branch> it is, so a chronically-stuck clone
# is visibly distinct from a benign one-off skip.
report_stuck() {
local state=$1 behind
behind=$(git -C "$PROJ" rev-list --count "HEAD..$BASE" 2>/dev/null) || behind="?"
Expand Down Expand Up @@ -335,8 +323,23 @@ sync_project() {
return 0
fi

# The registry's structured integration branch is authoritative when present;
# a legacy entry falls back to the remote default branch (fm-integration-branch-lib.sh).
# A declaration the resolver rejects is the registry promising a base that
# cannot exist, so it is reported loudly on stdout (session-start discards this
# script's stderr) instead of degrading to the legacy no-default-branch skip.
if ! DEFAULT=$(integration_branch "$PROJ" "$label"); then
if declared_integration_branch "$label" >/dev/null 2>&1; then
echo "$label: skipped: cannot determine default branch"
else
echo "$label: STUCK: the registry declares an invalid integration branch - needs attention"
fi
return 0
fi
BASE="origin/$DEFAULT"

if ! fetch_with_packed_refs_lock_guard; then
reason="fetch failed"
reason="fetch failed for $BASE"
if [ -n "$FETCH_OUTPUT" ]; then
reason="$reason: $(first_line "$FETCH_OUTPUT")"
fi
Expand All @@ -345,14 +348,15 @@ sync_project() {
fi

prune_gone_branches || true

DEFAULT=$(default_branch) || {
echo "$label: skipped: cannot determine default branch"
return 0
}
BASE="origin/$DEFAULT"
if ! git -C "$PROJ" rev-parse --verify --quiet "$BASE^{commit}" >/dev/null; then
echo "$label: skipped: $BASE does not exist"
# A declared branch origin does not publish is a broken registry promise, not
# a benign absence: the clone would otherwise never be refreshed again and
# never be reported as needing attention.
if [ -n "$(declared_integration_branch "$label" 2>/dev/null)" ]; then
echo "$label: STUCK: declared integration branch $DEFAULT is not published by origin - needs attention"
else
echo "$label: skipped: $BASE does not exist"
fi
return 0
fi

Expand Down Expand Up @@ -405,9 +409,9 @@ sync_project() {
}
if [ "$local_rev" = "$remote_rev" ]; then
if [ "$recovered" = yes ]; then
echo "$label: recovered: re-attached $DEFAULT (already current)"
echo "$label: recovered: re-attached $DEFAULT (already current at $BASE)"
else
echo "$label: already current"
echo "$label: already current on $DEFAULT ($BASE)"
fi
return 0
fi
Expand All @@ -433,9 +437,9 @@ sync_project() {
return 0
}
if [ "$recovered" = yes ]; then
echo "$label: recovered: re-attached $DEFAULT, synced $before..$after"
echo "$label: recovered: re-attached $DEFAULT, synced $before..$after to $BASE"
else
echo "$label: synced $before..$after"
echo "$label: synced $before..$after to $BASE"
fi
return 0
}
Expand Down
30 changes: 29 additions & 1 deletion bin/fm-home-seed.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ SUB_HOME_PARENT_MARKER=".fm-secondmate-parent"
. "$SCRIPT_DIR/fm-secondmate-charter-lib.sh"
# shellcheck source=bin/fm-wake-lib.sh
. "$SCRIPT_DIR/fm-wake-lib.sh"
# The integration-branch resolver every base-picking path shares.
# shellcheck source=bin/fm-integration-branch-lib.sh
. "$SCRIPT_DIR/fm-integration-branch-lib.sh"

usage() {
echo "usage: fm-home-seed.sh <id> <home|-> {<project>...|--no-projects}" >&2
Expand Down Expand Up @@ -456,6 +459,30 @@ EOF
return 1
}

# A fresh clone checks out origin/HEAD. When the registry declares an integration
# branch, put the new clone on it here: that declaration is what fleet sync
# refreshes and what a spawn bases task copies on, so a clone left on the remote
# default would be reported STUCK on every later sync and never refreshed. Only
# newly created clones are moved; an already-seeded clone may hold work, so it is
# never switched. A declared branch the origin does not publish is refused loudly
# rather than silently seeded onto the wrong base.
checkout_declared_integration_branch() { # <project> <clone>
local project=$1 dst=$2 branch
branch=$(FM_HOME="$FM_HOME" FM_DATA_OVERRIDE="$DATA" declared_integration_branch "$project") || {
echo "error: project $project declares an integration branch the registry format rejects" >&2
return 1
}
[ -n "$branch" ] || return 0
if ! git -C "$dst" rev-parse --verify --quiet "refs/remotes/origin/$branch^{commit}" >/dev/null; then
echo "error: project $project declares integration branch $branch but its origin publishes no such branch" >&2
return 1
fi
git -C "$dst" checkout --quiet -B "$branch" --track "origin/$branch" >/dev/null 2>&1 || {
echo "error: could not check out declared integration branch $branch for project $project" >&2
return 1
}
}

clone_project() {
local project=$1 home=$2 src dst url dst_url mode
src="$PROJECTS/$project"
Expand All @@ -481,7 +508,8 @@ EOF
return 0
fi
url=$(source_origin_url "$project" "$mode" "$src") || return 1
git clone --quiet "$url" "$dst"
git clone --quiet "$url" "$dst" || return 1
checkout_declared_integration_branch "$project" "$dst"
}

validate_seed_project() {
Expand Down
64 changes: 64 additions & 0 deletions bin/fm-integration-branch-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# shellcheck shell=bash
# The one resolver for "which branch does this project integrate on".
# Usage: . bin/fm-integration-branch-lib.sh
#
# A registry entry may carry the structured `integration-branch=<branch>`
# annotation; bin/fm-project-mode.sh owns that format and its validation, and is
# the only reader of the registry here. The declaration is authoritative for
# every path that has to pick a base branch - bin/fm-fleet-sync.sh's refresh
# target, the branch bin/fm-home-seed.sh and bin/fm-remote-home-provision.sh
# check out in a new clone, the base bin/fm-spawn.sh resets a pooled worktree to,
# using origin/<branch> with a remote or the verified local branch without one,
# the base bin/fm-review-diff.sh diffs a task against, and the branch
# bin/fm-teardown.sh tests landed content and unmerged local-only work against
# and bin/fm-merge-local.sh fast-forwards - so a project cannot be synced,
# worked, reviewed and landed against four different branches.
#
# A project that declares nothing keeps the legacy resolution, origin's default
# branch, so unannotated homes behave exactly as before. A declaration that is
# empty or not a valid branch name is never downgraded to that fallback: the
# query fails, its diagnostic reaches the caller's stderr, and every consumer
# refuses rather than working from a base the registry did not ask for.
# Callers pass the registry home through the same FM_HOME/FM_DATA_OVERRIDE
# variables bin/fm-project-mode.sh already reads.

FM_INTEGRATION_BRANCH_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Origin's default branch for <dir>: the tracked origin/HEAD, else a local main
# or master. Returns 1 when neither resolves, so a caller can refuse rather than
# guess a base.
default_branch() { # <dir>
local dir=$1 ref branch
ref=$(git -C "$dir" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)
if [ -n "$ref" ]; then
echo "${ref#origin/}"
return 0
fi
for branch in main master; do
if git -C "$dir" show-ref --verify --quiet "refs/heads/$branch"; then
echo "$branch"
return 0
fi
done
return 1
}

# The branch <project-name> declares in the registry, or nothing when the entry
# uses the legacy format without a declaration. Returns non-zero, and lets the
# diagnostic through to stderr, when the entry declares an invalid branch.
declared_integration_branch() { # <project-name>
"$FM_INTEGRATION_BRANCH_LIB_DIR/fm-project-mode.sh" --integration-branch "$1"
}

# The branch new work, refreshes, reviews and landings must follow in <dir>: the
# declaration when the project makes one, otherwise origin's default branch.
# Returns 1 when the declaration is invalid, or when neither resolves.
integration_branch() { # <dir> <project-name>
local declared
declared=$(declared_integration_branch "$2") || return 1
if [ -n "$declared" ]; then
printf '%s\n' "$declared"
return 0
fi
default_branch "$1"
}
30 changes: 10 additions & 20 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.
# project's integration branch - its registered integration-branch declaration
# when it makes one, otherwise its default branch - to the crewmate's fm/<id>
# branch, so work lands on the branch bin/fm-spawn.sh cut it from.
#
# 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 All @@ -16,6 +18,10 @@ 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}}"
STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"
# The one resolver for the branch a project integrates on, so the landing target
# is the branch the task was cut from.
# shellcheck source=bin/fm-integration-branch-lib.sh
. "$SCRIPT_DIR/fm-integration-branch-lib.sh"
"$FM_ROOT/bin/fm-guard.sh" || true
# Role partition: landing local-only work is MAIN-owned; the Pi supervision
# branch reports readiness and never lands (contract: bin/fm-lease-lib.sh;
Expand All @@ -31,31 +37,15 @@ PROJ=$(grep '^project=' "$META" | cut -d= -f2-)
MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true)
[ "$MODE" = local-only ] || { echo "error: task $ID is mode=$MODE, not local-only; merge PR tasks with bin/fm-pr-merge.sh <id> <PR url> after approval" >&2; exit 1; }

default_branch() {
local ref branch
ref=$(git -C "$PROJ" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)
if [ -n "$ref" ]; then
echo "${ref#origin/}"
return 0
fi
for branch in main master; do
if git -C "$PROJ" show-ref --verify --quiet "refs/heads/$branch"; then
echo "$branch"
return 0
fi
done
return 1
}

BRANCH="fm/$ID"
git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$BRANCH" >/dev/null || { echo "error: branch $BRANCH does not exist in $PROJ" >&2; exit 1; }

DEFAULT=$(default_branch) || { echo "error: cannot determine default branch for $PROJ; expected origin/HEAD, main, or master" >&2; exit 1; }
DEFAULT=$(integration_branch "$PROJ" "$(basename "$PROJ")") || { echo "error: cannot determine the integration branch for $PROJ; expected a valid registry declaration, origin/HEAD, main, or master" >&2; exit 1; }

# The project's main checkout must be on its default branch and clean, so the
# The project's main checkout must be on its integration branch and clean, so the
# fast-forward lands predictably (firstmate never writes here otherwise).
cur=$(git -C "$PROJ" symbolic-ref --short HEAD 2>/dev/null || echo "")
[ "$cur" = "$DEFAULT" ] || { echo "error: $PROJ is on '$cur', expected default branch '$DEFAULT'; cannot merge safely" >&2; exit 1; }
[ "$cur" = "$DEFAULT" ] || { echo "error: $PROJ is on '$cur', expected integration branch '$DEFAULT'; cannot merge safely" >&2; exit 1; }
if [ -n "$(git -C "$PROJ" status --porcelain 2>/dev/null | head -1)" ]; then
echo "error: $PROJ has a dirty working tree; refusing to merge into it" >&2
exit 1
Expand Down
Loading
Loading