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
5 changes: 4 additions & 1 deletion .agents/skills/bootstrap-diagnostics/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name: bootstrap-diagnostics
description: >-
Agent-only handling playbook for session-start bootstrap diagnostics.
Use whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, STARTUP_MEMORY_BUDGET, CREW_DISPATCH invalid, FLEET_SYNC, NETWORK_CHECKS, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, SECONDMATE_HANDOFF, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh or bin/fm-startup-network.sh run prints one of those lines.
Use whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, LANDING_REMOTE, STARTUP_MEMORY_BUDGET, CREW_DISPATCH invalid, FLEET_SYNC, NETWORK_CHECKS, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, SECONDMATE_HANDOFF, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh or bin/fm-startup-network.sh run prints one of those lines.
A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load.
user-invocable: false
metadata:
Expand Down Expand Up @@ -32,6 +32,9 @@ When any diagnostic needs captain attention, report the plain consequence and re
- `TANGLE: <remediation>` - the primary checkout is stranded on a feature branch instead of its default branch; `AGENTS.md` section 8 explains why this guard exists and what it protects.
The work is safe on that branch ref; restore the primary to its default branch with the printed `git -C <root> checkout <default>`, then re-validate that branch in a proper worktree.
This is the only sanctioned firstmate-initiated git write to the primary, and it is a non-destructive branch switch that strands nothing.
- `LANDING_REMOTE: <drift>; <remediation>` - the primary checkout's remotes no longer have the shape the landing remap leaves behind, so a branch, a push, or a PR can go to the third-party parent instead of the repository work lands on.
The line names the one broken invariant and the command that repairs it; `docs/configuration.md`'s "Landing remote" section owns why each invariant matters.
The repair rewrites the primary's remotes, so run it on the primary checkout and never from a linked worktree, and report the consequence to the captain before dispatching work whose PR would land in the wrong place.
- `STARTUP_MEMORY_BUDGET: invalid config/startup-memory-budget - <reason>` - the visible startup-memory budget is not a safe one-line positive decimal file; do not infer the default or propagate it.
Correct the local primary file, then rerun session start so the normal convergence path can deliver the validated value to secondmate homes.
- `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.
Expand Down
2 changes: 1 addition & 1 deletion .opencode/plugins/fm-primary-watch-arm.js
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ export const FmPrimaryWatchArm = async ({ client, directory, worktree }) => {
if (event.type !== "session.idle") return;
const sessionID = event.properties?.sessionID;
if (!sessionID) return;
void ensureArm(paths, sessionID, client);
return ensureArm(paths, sessionID, client);
},
};
};
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ It updates cloned repositories and firstmate from origin using local patch stack

These skills are not captain-invocable; load them only at their precise triggers.

- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `NETWORK_CHECKS:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `SECONDMATE_HANDOFF:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load.
- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `LANDING_REMOTE:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `NETWORK_CHECKS:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `SECONDMATE_HANDOFF:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load.
- `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report.
- `ask-user-authority` - load before deciding any ask-user finding, regardless of the project's `yolo` posture.
- `quota-array-dispatch` - load before choosing among a matched crew-dispatch profile array from current quota-axi output.
Expand Down
38 changes: 34 additions & 4 deletions bin/fm-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# "FLEET_SYNC: <repo>: skipped|recovered|STUCK: <detail>",
# "PR_CHECK_MIGRATION: <private remediation>",
# "TANGLE: <remediation>",
# "LANDING_REMOTE: <drift>; <remediation>",
# "SECONDMATE_SYNC: secondmate <id>: skipped: <reason>",
# "NUDGE_SECONDMATES: secondmate <id>: send failed: <reason>",
# "BOOTSTRAP_INFO: nudged fm-<id> with '<message>'",
Expand Down Expand Up @@ -48,6 +49,12 @@
# A TANGLE line means the firstmate primary checkout (FM_ROOT) is stranded
# on a feature branch instead of its default branch - a crewmate's work
# landed in the primary instead of its own worktree; restore it per the line.
# A LANDING_REMOTE line means the primary's remotes no longer have the
# shape bin/fm-landing-remote.sh apply leaves behind, so git, gh, or
# no-mistakes could send a branch, a push, or a PR to the third-party
# parent. That check is bin/fm-landing-remote.sh verify with no --ours,
# it needs no network, and it is silent for a single-remote clone that
# never had a parent to be remapped away from.
# treehouse is also MISSING when its installed version lacks
# "treehouse get --lease" support.
# no-mistakes is also MISSING when its installed version is older than
Expand Down Expand Up @@ -83,8 +90,8 @@
# (PR-check migration, secondmate_sync, secondmate_liveness_sweep,
# secondmate_handoff_resume, x_mode_setup, fleet_sync) while still
# printing every read-only detect line
# above; the TANGLE line switches to advisory-only wording with no
# checkout command. Used by
# above; the TANGLE and LANDING_REMOTE lines switch to advisory-only
# wording with no checkout or remap command. Used by
# fm-session-start.sh's read-only path when another live session holds
# the fleet lock, so a second concurrent session never race-mutates
# PR-check artifacts, secondmate homes, pending handoff outboxes,
Expand Down Expand Up @@ -116,8 +123,9 @@
# because THIS session already ran them while holding the fleet lock,
# rather than because it has no lock at all. The two cases differ in
# exactly one place: repair ownership. A locked session is told to
# restore a tangled primary checkout itself, while an unlocked one is
# told to leave that work to the lock holder. Unset/0 (the default)
# restore a tangled primary checkout or remap drifted landing remotes
# itself, while an unlocked one is told to leave that work to the lock
# holder. Unset/0 (the default)
# keeps detect-only meaning unlocked, exactly as before.
# fm-bootstrap.sh install <tool>...
# Install the named tools (only ones the captain approved).
Expand Down Expand Up @@ -1173,6 +1181,28 @@ detect_local_config() {
echo "TANGLE: primary checkout on feature branch '$tangle_branch' (expected '$tangle_default'); the work is safe on that ref - restore the primary with: git -C $FM_ROOT checkout $tangle_default, then re-validate the branch in a proper worktree"
fi
fi
# Landing-remote drift: apply is a one-off the operator runs on the primary,
# so nothing re-asserts it afterwards. Surface a primary whose remotes drifted
# back toward the parent - or that was never remapped at all - here, where the
# session already reads every other local hazard. bin/fm-landing-remote.sh is
# the one owner of the shape; this only relays its refusal.
# Silent for a root that is not a git checkout at all, exactly as the tangle
# check above is: that is a different problem and this check has nothing to say
# about it.
if git -C "$FM_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
landing_drift=$("$SCRIPT_DIR/fm-landing-remote.sh" verify --repo "$FM_ROOT" 2>&1 >/dev/null) || {
landing_drift=${landing_drift#error: }
landing_drift=${landing_drift%%$'\n'*}
if [ -n "$landing_drift" ]; then
if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" = 1 ] && [ "${FM_BOOTSTRAP_LOCKED:-0}" != 1 ]; then
landing_drift_cause=${landing_drift%%; *}
echo "LANDING_REMOTE: $landing_drift_cause; read-only session must leave remap work to the session holding the fleet lock"
else
echo "LANDING_REMOTE: $landing_drift"
fi
fi
}
fi
crew=
[ -f "$CONFIG/crew-harness" ] && crew=$(tr -d '[:space:]' < "$CONFIG/crew-harness" || true)
if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] && [ -n "$crew" ] && [ "$crew" != "default" ]; then
Expand Down
2 changes: 1 addition & 1 deletion bin/fm-ff-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ default_branch() {
return 0
fi
for branch in main master; do
if git -C "$dir" show-ref --verify --quiet "refs/heads/$branch"; then
if git -C "$dir" show-ref --verify --quiet "refs/heads/$branch" 2>/dev/null; then
echo "$branch"
return 0
fi
Expand Down
9 changes: 4 additions & 5 deletions bin/fm-fleet-sync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}"
# 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"
# shellcheck source=bin/fm-self-repo-lib.sh
. "$SCRIPT_DIR/fm-self-repo-lib.sh"
FM_LOCK_LOG_PREFIX=fleet-sync
"$FM_ROOT/bin/fm-guard.sh" || true

Expand Down Expand Up @@ -118,7 +120,7 @@ default_branch() {
return 0
fi
for branch in main master; do
if git -C "$PROJ" show-ref --verify --quiet "refs/heads/$branch"; then
if git -C "$PROJ" show-ref --verify --quiet "refs/heads/$branch" 2>/dev/null; then
echo "$branch"
return 0
fi
Expand Down Expand Up @@ -304,10 +306,7 @@ sync_project() {
echo "$label: skipped: not a git repo"
return 0
fi
proj_real=$(cd "$PROJ" 2>/dev/null && pwd -P || printf '%s\n' "$PROJ")
root_real=$(cd "$FM_ROOT" 2>/dev/null && pwd -P || printf '%s\n' "$FM_ROOT")
home_real=$(cd "$FM_HOME" 2>/dev/null && pwd -P || printf '%s\n' "$FM_HOME")
if [ "$proj_real" = "$root_real" ] || [ "$proj_real" = "$home_real" ]; then
if fm_is_firstmate_repo "$PROJ" "$FM_ROOT" "$FM_HOME"; then
echo "$label: skipped: firstmate home (upstream sync is manual)"
return 0
fi
Expand Down
86 changes: 82 additions & 4 deletions bin/fm-landing-remote.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# Usage:
# fm-landing-remote.sh apply --ours <url> --upstream <url> [--repo <dir>]
# fm-landing-remote.sh status [--repo <dir>]
# fm-landing-remote.sh verify --ours <url> [--repo <dir>]
# fm-landing-remote.sh verify [--ours <url>] [--repo <dir>]
#
# apply rewrites remotes in the named checkout so `origin` is --ours and
# `upstream` is --upstream, refetches `origin` on every path that decides to
Expand Down Expand Up @@ -48,8 +48,23 @@
# status prints the current origin, upstream, leftover fork remote, and the gh
# default gh recorded locally in `remote.origin.gh-resolved`; it needs no
# network.
# verify exits 0 only when origin matches --ours; otherwise it exits 1 and
# names the remote it actually found.
# verify has two modes and needs no network in either.
# With --ours it is an identity check: it exits 0 only when origin matches that
# URL, and otherwise exits 1 naming the remote it actually found.
# Without --ours it is the runtime drift check, for callers that know a checkout
# must have been remapped but do not carry the landing URL - bin/fm-bootstrap.sh
# runs it against the firstmate primary at every session start. It asserts the
# shape apply guarantees, without needing to know which repository is ours:
# `upstream` present and distinct from `origin`, no leftover `fork` remote, both
# git defaults pointed at `origin`, and gh's recorded default on `origin` when gh
# is installed. Applicability is decided by the checkout, not by a flag: a clone
# with neither `upstream` nor `fork` was never a fork checkout, so the remap does
# not apply to it and the check passes silently. A `fork` remote is the exact
# pre-apply shape and apply always removes it, so its presence means origin may
# still be the parent. Each refusal names the one broken invariant on a single
# stderr line so a caller can embed it in its own diagnostic, and closes with an
# apply command carrying the --ours and --upstream URLs the checkout itself
# states, so the printed repair runs as printed.
#
# URL comparison is spelling-tolerant for the GitHub https / ssh / trailing
# .git forms. apply is a no-op, and touches the network not at all, once origin,
Expand Down Expand Up @@ -406,10 +421,73 @@ cmd_status() {
printf 'gh-default=%s\n' "${gh_default:-absent}"
}

# Every refusal below is relayed verbatim by bin/fm-bootstrap.sh as the
# LANDING_REMOTE remediation, so the repair it names has to run as printed.
# apply takes both URLs, so fill them from what the checkout already states and
# fall back to a named placeholder only for a URL the checkout cannot supply.
landing_remote_repair_command() {
local ours=$1 parent=$2 restore_from=${3:-}
if [ -n "$restore_from" ]; then
printf 'run git remote rename %s origin, then fm-landing-remote.sh apply --ours %s --upstream %s on the primary checkout' \
"$restore_from" "${ours:-<the landing repository url>}" "${parent:-<the third-party parent url>}"
else
printf 'run fm-landing-remote.sh apply --ours %s --upstream %s on the primary checkout' \
"${ours:-<the landing repository url>}" "${parent:-<the third-party parent url>}"
fi
}

# The invariants apply leaves behind, expressed without the landing URL. Reading
# only what the checkout already states keeps this runnable from any caller that
# knows a remap was owed but does not carry --ours, and keeps it offline.
verify_remapped_shape() {
local origin upstream fork_url fork_parent

origin=$(remote_url origin)
upstream=$(remote_url upstream)
fork_url=$(remote_url fork)

if [ -z "$upstream" ] && [ -z "$fork_url" ]; then
# Single-remote clone: nothing was ever forked here, so there is no remap to
# have run and no drift to report.
printf 'origin=%s\n' "${origin:-absent}"
return 0
fi
if [ -z "$origin" ]; then
local restore_from
if [ -n "$fork_url" ]; then
restore_from="fork"
else
restore_from="upstream"
fi
fail "origin remote is absent, so nothing names the repository work lands on; $(landing_remote_repair_command "$fork_url" "$upstream" "$restore_from")"
fi
if [ -n "$fork_url" ]; then
fork_parent=${upstream:-$origin}
fail "a fork remote at $fork_url still exists beside origin $origin, which is the shape apply exists to remove, so origin may still be the third-party parent; $(landing_remote_repair_command "$fork_url" "$fork_parent")"
fi
if urls_equal "$origin" "$upstream"; then
fail "origin and upstream both name $origin, so work would branch from and open PRs on the third-party parent; $(landing_remote_repair_command "" "$origin")"
fi
if [ "$(git_config_get_local checkout.defaultRemote)" != origin ]; then
fail "checkout.defaultRemote is not origin while upstream $upstream exists, so an ambiguous branch name can resolve against the parent; $(landing_remote_repair_command "$origin" "$upstream")"
fi
if [ "$(git_config_get_local remote.pushDefault)" != origin ]; then
fail "remote.pushDefault is not origin while upstream $upstream exists, so a push can land on the parent; $(landing_remote_repair_command "$origin" "$upstream")"
fi
if ! gh_default_is_origin; then
fail "gh has no recorded default of origin while upstream $upstream exists, and gh ranks upstream above origin, so a flagless 'gh pr create' can open the PR on the parent; $(landing_remote_repair_command "$origin" "$upstream")"
fi
printf 'origin=%s\n' "$origin"
printf 'upstream=%s\n' "$upstream"
}

cmd_verify() {
local origin
[ -n "$OURS" ] || fail "verify requires --ours <url>"
require_repo
if [ -z "$OURS" ]; then
verify_remapped_shape
return 0
fi
origin=$(remote_url origin)
[ -n "$origin" ] || fail "origin remote is absent"
if urls_equal "$origin" "$OURS"; then
Expand Down
17 changes: 3 additions & 14 deletions bin/fm-merge-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ 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}"
# shellcheck source=bin/fm-self-repo-lib.sh
. "$SCRIPT_DIR/fm-self-repo-lib.sh"
"$FM_ROOT/bin/fm-guard.sh" || true
ID=${1:?usage: fm-merge-local.sh <task-id>}
META="$STATE/$ID.meta"
Expand All @@ -24,20 +26,7 @@ META="$STATE/$ID.meta"
PROJ=$(grep '^project=' "$META" | cut -d= -f2-)
MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true)

canonical_dir() {
local target=$1
( cd "$target" 2>/dev/null && pwd -P ) || printf '%s\n' "$target"
}

is_firstmate_repo() {
local proj_real root_real home_real
proj_real=$(canonical_dir "$PROJ")
root_real=$(canonical_dir "$FM_ROOT")
home_real=$(canonical_dir "$FM_HOME")
[ "$proj_real" = "$root_real" ] || [ "$proj_real" = "$home_real" ]
}

if [ "$MODE" != "local-only" ] && ! is_firstmate_repo; then
if [ "$MODE" != "local-only" ] && ! fm_is_firstmate_repo "$PROJ" "$FM_ROOT" "$FM_HOME"; then
echo "error: task $ID is mode=$MODE on $PROJ, not local-only; merge PR tasks with bin/fm-pr-merge.sh <id> <PR url> after approval" >&2
exit 1
fi
Expand Down
11 changes: 4 additions & 7 deletions bin/fm-pr-merge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"

# shellcheck source=bin/fm-pr-lib.sh
. "$SCRIPT_DIR/fm-pr-lib.sh"
# shellcheck source=bin/fm-self-repo-lib.sh
. "$SCRIPT_DIR/fm-self-repo-lib.sh"

if [ "$#" -lt 2 ]; then
echo "error: invalid PR merge request" >&2
Expand Down Expand Up @@ -84,11 +86,6 @@ fi
gh-axi pr merge "$PR_NUMBER" --repo "$PR_OWNER/$PR_REPO" "${merge_args[@]+"${merge_args[@]}"}" "$@"

PROJ=$(grep '^project=' "$META" | cut -d= -f2- || true)
if [ -n "$PROJ" ] && [ -d "$PROJ" ]; then
proj_real=$(cd "$PROJ" 2>/dev/null && pwd -P || printf '%s\n' "$PROJ")
root_real=$(cd "$FM_ROOT" 2>/dev/null && pwd -P || printf '%s\n' "$FM_ROOT")
home_real=$(cd "$FM_HOME" 2>/dev/null && pwd -P || printf '%s\n' "$FM_HOME")
if [ "$proj_real" = "$root_real" ] || [ "$proj_real" = "$home_real" ]; then
"$SCRIPT_DIR/fm-merge-local.sh" "$ID"
fi
if [ -n "$PROJ" ] && [ -d "$PROJ" ] && fm_is_firstmate_repo "$PROJ" "$FM_ROOT" "$FM_HOME"; then
"$SCRIPT_DIR/fm-merge-local.sh" "$ID"
fi
Loading
Loading