From 38b455de5e7345f67dd7659fda5033a9da9140e6 Mon Sep 17 00:00:00 2001 From: Amplify Logic AI Date: Tue, 21 Jul 2026 01:55:07 +0200 Subject: [PATCH 1/2] feat(bootstrap): surface fork upstream drift at session start Detect when a configured upstream remote has commits this home lacks, and report count plus subjects through the existing bootstrap diagnostic path so forks stop silently drifting behind without auto-merging third-party changes. Co-authored-by: Cursor --- .agents/skills/bootstrap-diagnostics/SKILL.md | 7 +- AGENTS.md | 2 +- bin/fm-bootstrap.sh | 12 + bin/fm-upstream-lib.sh | 210 ++++++++++++++++ docs/scripts.md | 1 + tests/fm-upstream.test.sh | 230 ++++++++++++++++++ 6 files changed, 460 insertions(+), 2 deletions(-) create mode 100644 bin/fm-upstream-lib.sh create mode 100755 tests/fm-upstream.test.sh diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 102071b58e1..d9af9d4bfaf 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -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 section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_DISPATCH invalid, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. + Use whenever the session-start digest's bootstrap section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, CREW_DISPATCH invalid, FLEET_SYNC, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, NUDGE_SECONDMATES, UPSTREAM, or FMX - or when a standalone bin/fm-bootstrap.sh run prints one of those lines. A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load. user-invocable: false metadata: @@ -48,5 +48,10 @@ When any diagnostic needs captain attention, report the plain consequence and re Investigate the reason because that secondmate is not guaranteed live. - `NUDGE_SECONDMATES: secondmate : send failed: ` - the secondmate sweep fast-forwarded a running secondmate home and its loaded instruction surface (`AGENTS.md`, `bin/`, or `.agents/skills/`) changed, but the deterministic `fm-send.sh fm-` re-read nudge failed. Inspect the reason, keep the pending marker under `state/.secondmate-nudge-pending/` intact, and rerun session start after the endpoint or metadata issue is fixed so bootstrap can retry the exact same marked send. +- `UPSTREAM: commits behind / () - ` - this home is a fork whose configured upstream has commits the local checkout lacks. + Tell the captain the commit count, the upstream URL, and the listed subjects so they can judge urgency. + Do not merge, rebase, or fast-forward from upstream yourself; `/updatefirstmate` and `bin/fm-update.sh` only advance from origin and cannot deliver upstream work on a fork. + Wait for an explicit captain decision on whether and how to take the upstream commits. + Absence of this line is normal for a non-fork home, an offline probe, or a current tip - never invent drift. - `FMX: X mode on ...` / `FMX: X mode off ...` - bootstrap confirmed or removed the local X-mode poll artifacts (`docs/configuration.md` "X mode (.env)"). Only when a running watcher needs the cadence transition applied immediately, restart the home-scoped watcher through the emitted harness supervision protocol; bootstrap deliberately never restarts the watcher itself. diff --git a/AGENTS.md b/AGENTS.md index e90cb55cc7e..469e7fa4c40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -452,7 +452,7 @@ It performs guarded fast-forward updates of firstmate and registered secondmate These skills are not captain-invocable; load them only at their precise triggers. -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `NUDGE_SECONDMATES:`, `UPSTREAM:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `harness-adapters` - load before spawning or recovering a crewmate or secondmate, handling a trust dialog, sending a harness-specific skill invocation, interrupting or exiting an agent, resuming an exited agent, or verifying a new harness adapter. - `firstmate-orca` - load before switching to Orca, spawning or supervising Orca-backed work, smoke-testing Orca backend behavior, debugging Orca task state, or reconciling Orca-backed task metadata. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 0f3adbe018d..75919a9e0fb 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -16,7 +16,14 @@ # "NUDGE_SECONDMATES: secondmate : send failed: ", # "BOOTSTRAP_INFO: nudged fm- with ''", # "SECONDMATE_LIVENESS: secondmate : skipped: |respawn failed: ", +# "UPSTREAM: commits behind / () - ", # "FMX: X mode on ..." or "FMX: X mode off ...". +# UPSTREAM is detect-only and silent when there is no upstream remote, +# origin and upstream share a URL (not a fork), the home is a secondmate, +# the network is unavailable, or HEAD already contains the upstream tip. +# It never merges and never touches projects/; see bin/fm-upstream-lib.sh. +# Surrounding tooling (no-mistakes, treehouse) is out of scope here - those +# already surface their own version gaps through MISSING / their CLIs. # When a RUNNING secondmate worktree is fast-forwarded to firstmate's # own current default-branch commit (a purely LOCAL fast-forward, never # an origin fetch) AND its loaded instruction surface (AGENTS.md, bin/, @@ -100,6 +107,8 @@ DATA="${FM_DATA_OVERRIDE:-$FM_HOME/data}" . "$SCRIPT_DIR/fm-x-lib.sh" # shellcheck source=bin/fm-backend.sh disable=SC1091 . "$SCRIPT_DIR/fm-backend.sh" +# shellcheck source=bin/fm-upstream-lib.sh disable=SC1091 +. "$SCRIPT_DIR/fm-upstream-lib.sh" fleet_sync_origin_backed_project_count() { local count proj @@ -800,6 +809,9 @@ if [ "${FM_BOOTSTRAP_VERBOSE_FACTS:-0}" = 1 ] \ && ! fm_backlog_backend_manual "$CONFIG" && fm_tasks_axi_compatible; then echo "BOOTSTRAP_INFO: tasks-axi available" fi +# Read-only fork drift check: surfaces commits on the configured upstream remote +# that this home lacks. Silent when not a fork / offline / current. Never merges. +fm_upstream_check "$FM_ROOT" "$FM_HOME" if [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" != 1 ]; then secondmate_sync secondmate_liveness_sweep diff --git a/bin/fm-upstream-lib.sh b/bin/fm-upstream-lib.sh new file mode 100644 index 00000000000..f1c66960dbb --- /dev/null +++ b/bin/fm-upstream-lib.sh @@ -0,0 +1,210 @@ +# shellcheck shell=bash +# Read-only upstream-drift detection for a forked firstmate home. +# Usage: . bin/fm-upstream-lib.sh +# +# fm_upstream_check prints at most one actionable bootstrap line when the +# configured upstream remote has commits this home lacks: +# UPSTREAM: commits behind / () - ; ... +# Silent (no output) when there is no upstream remote, origin and upstream are +# the same URL (not a fork), the home is a secondmate, the network is down, the +# tip is already reachable and current, or any probe fails. +# +# Detection never merges, never force-updates local branches, and never touches +# projects/. The only git write is a bounded fetch that updates +# refs/remotes// so commit subjects can be listed. +# Mechanics and defaults below are owned by this file; bin/fm-bootstrap.sh only +# sources and calls it. + +FM_UPSTREAM_REMOTE_DEFAULT="${FM_UPSTREAM_REMOTE_DEFAULT:-upstream}" +FM_UPSTREAM_LS_TIMEOUT_DEFAULT="${FM_UPSTREAM_LS_TIMEOUT_DEFAULT:-3}" +FM_UPSTREAM_FETCH_TIMEOUT_DEFAULT="${FM_UPSTREAM_FETCH_TIMEOUT_DEFAULT:-5}" +FM_UPSTREAM_SUBJECT_LIMIT_DEFAULT="${FM_UPSTREAM_SUBJECT_LIMIT_DEFAULT:-8}" + +# Run under a portable wall-clock timeout of seconds. +# Exit status is the command's, or 124 on timeout (GNU timeout convention). +fm_upstream_run_timeout() { + local secs=$1 + shift + if command -v timeout >/dev/null 2>&1; then + timeout "$secs" "$@" + return $? + fi + if command -v gtimeout >/dev/null 2>&1; then + gtimeout "$secs" "$@" + return $? + fi + perl -e ' + my $seconds = shift; + my $pid = fork; + die "fork failed\n" unless defined $pid; + if (!$pid) { + setpgrp(0, 0); + exec @ARGV; + die "exec failed: $!\n"; + } + local $SIG{ALRM} = sub { + kill "TERM", -$pid; + select undef, undef, undef, 0.2; + kill "KILL", -$pid; + exit 124; + }; + alarm $seconds; + waitpid $pid, 0; + exit($? >> 8); + ' "$secs" "$@" +} + +# Normalize a remote URL for equality checks: strip trailing .git and slash, +# and peel a file:// scheme so path-form and file-form remotes compare equal. +fm_upstream_normalize_url() { + local u=$1 + u=${u%.git} + u=${u%/} + case "$u" in + file://*) u=${u#file://} ;; + esac + printf '%s\n' "$u" +} + +# Resolve the branch name to compare against on in . +# Prefers refs/remotes//HEAD, then origin-default, then main/master. +fm_upstream_branch() { + local dir=$1 remote=$2 ref branch + ref=$(git -C "$dir" symbolic-ref --quiet --short "refs/remotes/$remote/HEAD" 2>/dev/null || true) + if [ -n "$ref" ]; then + printf '%s\n' "${ref#"$remote"/}" + return 0 + fi + if command -v fm_default_branch >/dev/null 2>&1; then + branch=$(fm_default_branch "$dir" 2>/dev/null || true) + if [ -n "$branch" ]; then + printf '%s\n' "$branch" + return 0 + fi + fi + for branch in main master; do + if git -C "$dir" show-ref --verify --quiet "refs/remotes/$remote/$branch" \ + || git -C "$dir" show-ref --verify --quiet "refs/heads/$branch"; then + printf '%s\n' "$branch" + return 0 + fi + done + return 1 +} + +# Truncate to characters, appending "..." when clipped. +fm_upstream_clip() { + local text=$1 max=$2 + if [ "${#text}" -le "$max" ]; then + printf '%s\n' "$text" + return 0 + fi + printf '%s...\n' "${text:0:$((max - 3))}" +} + +# Print subjects for commits in not reachable from , bounded. +fm_upstream_subjects() { + local dir=$1 base=$2 tip=$3 limit=$4 + local line clipped out="" n=0 + while IFS= read -r line; do + [ -n "$line" ] || continue + clipped=$(fm_upstream_clip "$line" 72) + if [ -z "$out" ]; then + out=$clipped + else + out="$out; $clipped" + fi + n=$((n + 1)) + [ "$n" -lt "$limit" ] || break + done < <(git -C "$dir" log --format=%s --no-decorate -n "$limit" "$base..$tip" 2>/dev/null || true) + printf '%s\n' "$out" +} + +# Detect upstream drift for the firstmate repo at . +# Optional enables the secondmate-home silence rule when that home carries +# a .fm-secondmate-home marker. Always exits 0; prints one line or nothing. +fm_upstream_check() { + local dir=$1 home=${2:-} remote branch origin_url upstream_url tip head + local track_ref count subjects ls_timeout fetch_timeout subject_limit url_disp + local tip_ok=0 + + remote=${FM_UPSTREAM_REMOTE:-$FM_UPSTREAM_REMOTE_DEFAULT} + ls_timeout=${FM_UPSTREAM_LS_TIMEOUT:-$FM_UPSTREAM_LS_TIMEOUT_DEFAULT} + fetch_timeout=${FM_UPSTREAM_FETCH_TIMEOUT:-$FM_UPSTREAM_FETCH_TIMEOUT_DEFAULT} + subject_limit=${FM_UPSTREAM_SUBJECT_LIMIT:-$FM_UPSTREAM_SUBJECT_LIMIT_DEFAULT} + + case "$ls_timeout" in *[!0-9]* | '') ls_timeout=$FM_UPSTREAM_LS_TIMEOUT_DEFAULT ;; esac + case "$fetch_timeout" in *[!0-9]* | '') fetch_timeout=$FM_UPSTREAM_FETCH_TIMEOUT_DEFAULT ;; esac + case "$subject_limit" in *[!0-9]* | '' | 0) subject_limit=$FM_UPSTREAM_SUBJECT_LIMIT_DEFAULT ;; esac + + [ -n "$dir" ] || return 0 + git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 || return 0 + + if [ -n "$home" ] && [ -f "$home/.fm-secondmate-home" ]; then + return 0 + fi + + git -C "$dir" remote get-url "$remote" >/dev/null 2>&1 || return 0 + + origin_url=$(git -C "$dir" remote get-url origin 2>/dev/null || true) + upstream_url=$(git -C "$dir" remote get-url "$remote" 2>/dev/null || true) + [ -n "$upstream_url" ] || return 0 + if [ -n "$origin_url" ] \ + && [ "$(fm_upstream_normalize_url "$origin_url")" = "$(fm_upstream_normalize_url "$upstream_url")" ]; then + return 0 + fi + + branch=$(fm_upstream_branch "$dir" "$remote" 2>/dev/null || true) + [ -n "$branch" ] || branch=main + track_ref="refs/remotes/$remote/$branch" + + tip=$( + GIT_TERMINAL_PROMPT=0 \ + fm_upstream_run_timeout "$ls_timeout" \ + git -C "$dir" ls-remote --refs "$remote" "refs/heads/$branch" 2>/dev/null \ + | awk 'NR==1 { print $1; exit }' + ) || true + [ -n "$tip" ] || return 0 + + head=$(git -C "$dir" rev-parse HEAD 2>/dev/null || true) + [ -n "$head" ] || return 0 + [ "$tip" != "$head" ] || return 0 + + if git -C "$dir" cat-file -e "$tip^{commit}" 2>/dev/null; then + tip_ok=1 + else + # Bounded fetch into the remote-tracking ref only - never merges, never + # touches local branches or projects/. + if GIT_TERMINAL_PROMPT=0 \ + fm_upstream_run_timeout "$fetch_timeout" \ + git -C "$dir" fetch --no-tags --quiet "$remote" \ + "+refs/heads/$branch:$track_ref" >/dev/null 2>&1; then + if git -C "$dir" cat-file -e "$tip^{commit}" 2>/dev/null; then + tip_ok=1 + fi + fi + fi + [ "$tip_ok" -eq 1 ] || return 0 + + # Upstream tip already contained in HEAD means we are ahead or equal, not behind. + if git -C "$dir" merge-base --is-ancestor "$tip" "$head" 2>/dev/null; then + return 0 + fi + + count=$(git -C "$dir" rev-list --count "$head..$tip" 2>/dev/null || true) + case "$count" in + '' | *[!0-9]*) return 0 ;; + 0) return 0 ;; + esac + + subjects=$(fm_upstream_subjects "$dir" "$head" "$tip" "$subject_limit") + url_disp=$(fm_upstream_normalize_url "$upstream_url") + if [ -n "$subjects" ]; then + printf 'UPSTREAM: %s commits behind %s/%s (%s) - %s\n' \ + "$count" "$remote" "$branch" "$url_disp" "$subjects" + else + printf 'UPSTREAM: %s commits behind %s/%s (%s)\n' \ + "$count" "$remote" "$branch" "$url_disp" + fi + return 0 +} diff --git a/docs/scripts.md b/docs/scripts.md index 22fdb209df2..7d0e2171539 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -61,6 +61,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-task-outcome.sh` | Resolve a worker outcome from an explicit value, structured backlog title, or safe fallback | | `fm-visible-status.sh` | Project authoritative worker details onto Herdr presentation metadata | | `fm-tangle-lib.sh` | Shared default-branch resolution and primary-checkout tangle classification | +| `fm-upstream-lib.sh` | Read-only fork upstream-drift detection for session-start bootstrap (`UPSTREAM:`) | | `fm-supervision-lib.sh` | Shared in-flight-work-without-fresh-watcher-beacon predicate | | `fm-ff-lib.sh` | Shared guarded fast-forward helper for origin pulls and local secondmate syncs | | `fm-lock-lib.sh` | Shared "is this git lock provably abandoned?" proof used by teardown and fleet-sync | diff --git a/tests/fm-upstream.test.sh b/tests/fm-upstream.test.sh new file mode 100755 index 00000000000..2f6def9dc80 --- /dev/null +++ b/tests/fm-upstream.test.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +# Behavior tests for bin/fm-upstream-lib.sh: read-only fork upstream-drift +# detection used by session-start bootstrap. +# +# Contracts under test: +# - Behind an upstream remote: one UPSTREAM: line with count and subjects. +# - No upstream remote configured: silent. +# - Offline / unreachable upstream: silent. +# - Origin and upstream share a URL (not a fork): silent. +# - Already current with upstream tip: silent. +# - Secondmate home marker: silent. +# - Never merges; the only git write is a remote-tracking ref fetch. +# shellcheck disable=SC2016 +set -u + +# shellcheck source=tests/lib.sh disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# shellcheck source=bin/fm-tangle-lib.sh disable=SC1091 +. "$ROOT/bin/fm-tangle-lib.sh" +# shellcheck source=bin/fm-upstream-lib.sh disable=SC1091 +. "$ROOT/bin/fm-upstream-lib.sh" + +fm_git_identity fmtest fmtest@example.invalid +TMP_ROOT=$(fm_test_tmproot fm-upstream-tests) + +# Build a fork world: +# upstream.git - bare, the third-party tip +# origin.git - bare, the fork's origin (starts at the same commit) +# home/repo - clone of origin with an upstream remote +# Echoes the world dir. +new_fork_world() { + local name=$1 w seed + w="$TMP_ROOT/$name" + mkdir -p "$w" + seed="$w/seed" + mkdir -p "$seed" + git -C "$seed" init -q + git -C "$seed" checkout -q -b main + printf 'v1\n' > "$seed/README.md" + git -C "$seed" add README.md + git -C "$seed" commit -qm 'c1 initial' + git clone -q --bare "$seed" "$w/upstream.git" + git clone -q --bare "$seed" "$w/origin.git" + git clone -q "$w/origin.git" "$w/repo" + git -C "$w/repo" remote add upstream "file://$w/upstream.git" + git -C "$w/repo" remote set-head upstream main >/dev/null 2>&1 || true + git -C "$w/repo" remote set-head origin main >/dev/null 2>&1 || true + mkdir -p "$w/home" + printf '%s\n' "$w" +} + +# Advance the upstream bare repo by commits with distinct subjects. +bump_upstream() { + local w=$1 n=$2 i work + work="$w/upstream-work" + rm -rf "$work" + git clone -q "$w/upstream.git" "$work" + i=1 + while [ "$i" -le "$n" ]; do + printf 'u%s\n' "$i" >> "$work/README.md" + git -C "$work" add README.md + git -C "$work" commit -qm "upstream-fix-$i noteworthy subject" + i=$((i + 1)) + done + git -C "$work" push -q origin main +} + +run_check() { + local w=$1 + shift + FM_UPSTREAM_LS_TIMEOUT=5 FM_UPSTREAM_FETCH_TIMEOUT=5 \ + fm_upstream_check "$w/repo" "$w/home" "$@" +} + +test_reports_commits_behind_with_subjects() { + local w out + w=$(new_fork_world behind) + bump_upstream "$w" 3 + out=$(run_check "$w") + assert_contains "$out" "UPSTREAM: 3 commits behind upstream/main" \ + "behind fork did not report commit count" + assert_contains "$out" "upstream-fix-3 noteworthy subject" \ + "behind fork did not include a commit subject" + assert_contains "$out" "upstream-fix-1 noteworthy subject" \ + "behind fork omitted an earlier subject" + # Ensure we did not merge into the local checkout. + [ "$(git -C "$w/repo" rev-parse HEAD)" = "$(git -C "$w/origin.git" rev-parse main)" ] \ + || fail "check merged or moved local HEAD" + pass "reports N commits behind with subjects" +} + +test_silent_without_upstream_remote() { + local w out + w=$(new_fork_world no-upstream) + git -C "$w/repo" remote remove upstream + bump_upstream "$w" 2 + out=$(run_check "$w") + [ -z "$out" ] || fail "no-upstream home emitted noise: $out" + pass "silent when no upstream remote is configured" +} + +test_silent_when_offline() { + local w out + w=$(new_fork_world offline) + git -C "$w/repo" remote set-url upstream "file://$w/missing-upstream.git" + out=$(run_check "$w") + [ -z "$out" ] || fail "offline probe emitted noise: $out" + pass "silent when upstream is unreachable" +} + +test_silent_when_origin_equals_upstream() { + local w out origin_url work + w=$(new_fork_world not-fork) + origin_url=$(git -C "$w/repo" remote get-url origin) + # Same URL via file:// form must still count as not-a-fork after normalize. + git -C "$w/repo" remote set-url upstream "file://$w/origin.git" + [ "$(fm_upstream_normalize_url "$origin_url")" = "$(fm_upstream_normalize_url "file://$w/origin.git")" ] \ + || fail "normalize failed to equate path-form origin with file:// upstream" + # Advance origin so there is something to be behind if the equality guard failed. + work="$w/origin-work" + git clone -q "$w/origin.git" "$work" + printf 'x\n' >> "$work/README.md" + git -C "$work" add README.md + git -C "$work" commit -qm 'should-not-report' + git -C "$work" push -q origin main + # Repo stays on old tip; equality guard must still silence. + out=$(run_check "$w") + [ -z "$out" ] || fail "same-URL origin/upstream emitted noise: $out" + pass "silent when origin and upstream share a URL" +} + +test_silent_when_current() { + local w out tip + w=$(new_fork_world current) + bump_upstream "$w" 2 + # Fast-forward the local repo to upstream tip without going through origin. + tip=$(git -C "$w/upstream.git" rev-parse main) + git -C "$w/repo" fetch -q upstream main + git -C "$w/repo" checkout -q --detach "$tip" + out=$(run_check "$w") + [ -z "$out" ] || fail "current tip emitted noise: $out" + pass "silent when HEAD already contains the upstream tip" +} + +test_silent_for_secondmate_home() { + local w out + w=$(new_fork_world secondmate) + bump_upstream "$w" 2 + printf 'domain\n' > "$w/home/.fm-secondmate-home" + out=$(run_check "$w") + [ -z "$out" ] || fail "secondmate home emitted noise: $out" + pass "silent for secondmate homes" +} + +test_bootstrap_wires_upstream_line() { + local w fakebin out + w=$(new_fork_world bootstrap-wire) + bump_upstream "$w" 2 + # Minimal toolchain so bootstrap reaches the upstream check without MISSING noise. + fakebin=$(fm_fakebin "$w/fakebin") + fm_fake_exit0 "$fakebin" tmux node gh-axi chrome-devtools-axi lavish-axi quota-axi + cat > "$fakebin/gh" <<'SH' +#!/usr/bin/env bash +[ "${1:-}" = auth ] && [ "${2:-}" = status ] && exit 0 +exit 0 +SH + chmod +x "$fakebin/gh" + cat > "$fakebin/treehouse" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = get ] && [ "${2:-}" = --help ]; then + printf '%s\n' 'Usage: treehouse get [--lease]' + exit 0 +fi +exit 0 +SH + chmod +x "$fakebin/treehouse" + cat > "$fakebin/no-mistakes" <<'SH' +#!/usr/bin/env bash +[ "${1:-}" = --version ] && { printf '%s\n' 'no-mistakes version v1.31.2'; exit 0; } +exit 0 +SH + chmod +x "$fakebin/no-mistakes" + cat > "$fakebin/tasks-axi" <<'SH' +#!/usr/bin/env bash +[ "${1:-}" = --version ] && { printf '%s\n' '0.1.1'; exit 0; } +if [ "${1:-}" = update ] && [ "${2:-}" = --help ]; then + printf '%s\n' 'usage: tasks-axi update' ' --archive-body' + exit 0 +fi +if [ "${1:-}" = mv ] && [ "${2:-}" = --help ]; then + printf '%s\n' 'usage: tasks-axi mv [...] --to ' + exit 0 +fi +exit 0 +SH + chmod +x "$fakebin/tasks-axi" + mkdir -p "$w/home/config" "$w/home/state" "$w/home/data" "$w/home/projects" + # FM_ROOT_OVERRIDE is the git repo under test; scripts still load from ROOT + # via the invoked fm-bootstrap.sh path (SCRIPT_DIR), not from FM_ROOT. + out=$( + PATH="$fakebin:/usr/bin:/bin" \ + FM_ROOT_OVERRIDE="$w/repo" \ + FM_HOME="$w/home" \ + FM_BOOTSTRAP_DETECT_ONLY=1 \ + FM_UPSTREAM_LS_TIMEOUT=5 \ + FM_UPSTREAM_FETCH_TIMEOUT=5 \ + "$ROOT/bin/fm-bootstrap.sh" 2>&1 || true + ) + assert_contains "$out" "UPSTREAM: 2 commits behind upstream/main" \ + "bootstrap did not surface UPSTREAM line" + pass "bootstrap wires the UPSTREAM diagnostic" +} + +test_skill_and_agents_trigger_mention_upstream() { + assert_grep 'UPSTREAM' "$ROOT/.agents/skills/bootstrap-diagnostics/SKILL.md" \ + "bootstrap-diagnostics skill lost UPSTREAM handling" + assert_grep '`UPSTREAM:`' "$ROOT/AGENTS.md" \ + "AGENTS.md bootstrap-diagnostics trigger lost UPSTREAM" + pass "skill and AGENTS.md trigger mention UPSTREAM" +} + +test_reports_commits_behind_with_subjects +test_silent_without_upstream_remote +test_silent_when_offline +test_silent_when_origin_equals_upstream +test_silent_when_current +test_silent_for_secondmate_home +test_bootstrap_wires_upstream_line +test_skill_and_agents_trigger_mention_upstream From 0088a306ebe48886b96baa1a4fd01194f44bc8bd Mon Sep 17 00:00:00 2001 From: Amplify Logic AI Date: Tue, 21 Jul 2026 10:14:16 +0200 Subject: [PATCH 2/2] no-mistakes(document): document FM_UPSTREAM_* env knobs in configuration reference --- docs/configuration.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 5ca22dd1999..3e6d37d51c8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -423,6 +423,10 @@ FM_WEDGE_DEMAND_INSPECT_COUNT=3 # consecutive provably-working stale escalati FM_WATCH_TRIAGE_LOG_MAX_BYTES=262144 # size cap for the watcher's absorbed-wake debug log FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT= # optional seconds allowed for bootstrap's best-effort clone refresh; unset/blank defaults to max(20, 5 + 3 * origin-backed-project-count) FM_FLEET_PRUNE=1 # set to 0 to skip pruning local branches whose upstream is gone +FM_UPSTREAM_REMOTE=upstream # remote probed by bootstrap's read-only fork-drift check (`UPSTREAM:` line; bin/fm-upstream-lib.sh) +FM_UPSTREAM_LS_TIMEOUT=3 # seconds allowed for the fork-drift ls-remote probe before it silently gives up +FM_UPSTREAM_FETCH_TIMEOUT=5 # seconds allowed for the bounded tracking-ref-only fetch that lists commit subjects +FM_UPSTREAM_SUBJECT_LIMIT=8 # max upstream commit subjects listed on the UPSTREAM: line FM_STALE_WORKTREE_LOCK_AGE_SECS=30 # min mtime age before fm-teardown.sh treats a leftover worktree git index.lock as provably stale FM_TREEHOUSE_RETURN_LOCK_RETRIES=3 # retries after a treehouse return fails on the transient git index.lock signature FM_TREEHOUSE_RETURN_LOCK_RETRY_WAIT_SECS=1 # seconds fm-teardown.sh waits before each retry after that signature