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
14 changes: 13 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, FORGE_CREDENTIAL, 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.
A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load.
user-invocable: false
metadata:
Expand All @@ -26,6 +26,18 @@ When any diagnostic needs captain attention, report the plain consequence and re
- `BACKEND_INVALID: <name> (known: <names>)` - the resolved runtime backend has no verified dependency or lifecycle contract, so do not dispatch work until the invalid `FM_BACKEND` or `config/backend` value is corrected to one of the listed backends.
- `NEEDS_GH_AUTH` - ask the captain to run `! gh auth login` (interactive; you cannot run it for them).
This probe now arrives from the deferred network stage, so it is also how an unreachable network shows up: `gh` cannot validate its token offline and reports the same failure. Confirm reachability before asking the captain to re-authenticate a credential that may be fine.
- `FORGE_CREDENTIAL: <forge>: <reason>` - this home tracks a repository on a forge whose credential firstmate holds itself, and that credential cannot be used, so merge detection and build results for every repository on that forge are unavailable until it is fixed.
The reason names the failing requirement only, never a credential value, and never quote a credential back to the captain or ask them to paste one into chat.
An absent or empty entry means the captain must create or re-cache it; a rejected credential means it was revoked, expired, or was created without the read scopes firstmate needs; a store read that did not answer in time means the stored item is raising a confirmation dialog no unattended session can answer, so it must be re-cached to allow an unattended read; a store that refused the read instead of reporting the entry missing means that same item is present but unreadable unattended, so it takes the same re-cache and never a hunt for a missing entry.
"cannot see <forge> repository <repo>" means the credential authenticated and the forge then refused to admit that repository exists, which does NOT say whose fault it is.
Scope refusal would have arrived as its own HTTP 403 line, so the live possibilities are a credential bound to the wrong account, a credential that has lost access to that specific private repository, or a repository that was renamed or moved.
Check those in that order: confirm which account the keychain entry belongs to, then confirm the captain still has access to the named repository, then confirm the repository still lives at the path the clone's origin remote gives.
"no credential store on this platform" is news, not a fault: this machine has no login keychain to read, that forge's merge and build checks are simply unavailable here, and there is nothing for the captain to retry.
Both of those two are reported once per home and then stay silent, so report each plainly and move on, and do not treat a later silence as the problem having been fixed.
The not-visible record is kept per probed repository, so a line naming a different repository later is fresh news reported in its own right rather than a repeat, while the no-store record is per forge because it names no repository.
A lock-refused session reports them without recording them, so the same line arriving again in the session that holds the lock is the expected handover, not a regression.
`bin/fm-forge-credential.sh`'s header owns the entry names, the required scopes, and the exit-code contract - read it before advising the captain, and tell them the consequence and the action rather than the diagnostic label.
Firstmate cannot create or store the credential itself, so this always ends in a captain action; work that does not touch that forge continues normally.
- `NETWORK_CHECKS: <what did not complete>; rerun <command>` - the deferred network stage itself could not finish, so the checks it names are simply unknown, not failed.
Rerun the printed command; it is idempotent and re-derives every finding.
A `hit the ...s bound` line means one of those checks is slow or unreachable - most often a remote secondmate host - and the stage stopped rather than letting it wedge; a `lock was no longer held` line means the session that asked for the sweeps no longer owns them, so leave them to the session that does.
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ If the session lock cannot be acquired and verified, report its exact diagnostic
A lock-refused session must not spawn, steer, merge, drain the wake queue, repair supervision, repair a checkout, or perform any other fleet mutation.

The digest itself makes no external-network call and never waits for one.
Every network check a session start owes - GitHub auth, dead-secondmate relaunch, secondmate convergence, pending handoff delivery, and project clone refresh - runs concurrently in a bounded worker owned by `bin/fm-startup-network.sh` and is reported in the digest's own `NETWORK CHECKS` section.
Every network check a session start owes - GitHub auth, the forge-credential check, dead-secondmate relaunch, secondmate convergence, pending handoff delivery, and project clone refresh - runs concurrently in a bounded worker owned by `bin/fm-startup-network.sh` and is reported in the digest's own `NETWORK CHECKS` section.
When that section reports its checks still in progress it names exactly what is unconfirmed; treat none of those as passed until the result lands, either from `bin/fm-startup-network.sh report` or as a `check: startup-network` wake.

1. **Lock** - acquires the per-home session lock first, before anything mutates shared state, then starts the deferred network stage above.
Expand Down Expand Up @@ -520,7 +520,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 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`, `FORGE_CREDENTIAL:`, `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.
- `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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ Firstmate's skills live in two separate places with different audiences:
## Documentation

- [docs/architecture.md](docs/architecture.md) - maintainer architecture for the crew, supervision, worktrees, secondmates, and project modes.
- [docs/configuration.md](docs/configuration.md) - environment variables, `FM_HOME`, runtime backend selection, optional Relay and its X and Discord setup steps, the files you set, and harness support.
- [docs/configuration.md](docs/configuration.md) - environment variables, `FM_HOME`, runtime backend selection, forge credentials firstmate holds itself, optional Relay and its X and Discord setup steps, the files you set, and harness support.
- [docs/remote-secondmates.md](docs/remote-secondmates.md) - current setup, routing, transfer, recovery, and safety behavior for whole-home remote second mates.
- [docs/calm.md](docs/calm.md) - current Pi `/calm` behavior and supported presentation limits.
- [docs/wedge-alarm.md](docs/wedge-alarm.md) - configure the active alert for an away-mode escalation delivery that gets stuck.
Expand Down
165 changes: 163 additions & 2 deletions bin/fm-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
# Silent = all good.
# Lines: "MISSING: <tool> (install: <command>)",
# "MISSING_MANUAL: <tool> (instructions: <url>)", "NEEDS_GH_AUTH",
# "FORGE_CREDENTIAL: <forge>: <reason>",
# "BACKEND_INVALID: <name> (known: <names>)",
# "STARTUP_MEMORY_BUDGET: invalid config/startup-memory-budget - <reason>",
# "CREW_DISPATCH: invalid config/crew-dispatch.json - <reason>",
Expand Down Expand Up @@ -45,6 +46,27 @@
# failed names whether the endpoint was missing or agent-less.
# Already-live and successfully relaunched secondmates are silent
# unless FM_BOOTSTRAP_VERBOSE_FACTS=1 requests BOOTSTRAP_INFO facts.
# A FORGE_CREDENTIAL line means this home tracks a repository on a forge
# whose credential firstmate holds itself (today only Bitbucket), and
# that credential is missing, unusable, or refused - so merge and build
# checks for it cannot work. It is detect-only and probes exactly ONE
# deterministically chosen tracked repository per forge, so it costs at
# most one bounded request per session start however many clones on that
# forge the home tracks. It stays silent when no such repository is
# tracked and when the forge could not be reached.
# bin/fm-forge-credential.sh owns the resolution, the reason wording,
# the exit-code contract, and which forges firstmate holds a credential
# for; this check names no forge of its own.
# Two outcomes are reported ONCE per home and then stay silent, because
# each is news the first time and unactionable noise every session
# after: a machine with no credential store at all, and a repository
# the credential authenticated against but cannot see. The record is
# state/forge-credential-<outcome>.<forge>, keyed per home and per forge
# so the two outcomes cannot suppress each other; the not-visible record
# also keys on the probed repository, so each distinct unseen repository
# is worth one report, while the no-store record stays per forge.
# Under FM_BOOTSTRAP_DETECT_ONLY those records are NOT written: a
# lock-refused session reports the news without consuming it.
# 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.
Expand Down Expand Up @@ -750,6 +772,125 @@ secondmate_handoff_detect() {
done
}

# Forge-credential detection. A missing, empty, or rejected credential used to
# be invisible until a PR step failed an hour into finished work; this moves
# that discovery to session start. It runs ONLY when this home actually tracks a
# repository on a forge whose credential firstmate holds itself, so a
# GitHub-only home never sees a line (gh owns that credential, per
# bin/fm-forge-credential.sh). Exactly ONE tracked repository is probed per
# forge per session start, so this costs at most one bounded request however
# many clones on that forge the home tracks.
# One probe is enough, and which clone it lands on cannot change what is
# reported, because of what the forge answers. Verified live on 21/07/2026
# against api.bitbucket.org, every case with a fully resolved pair: an INVALID
# credential against a real private repository answers HTTP 401; a credential
# whose scopes do not cover the request answers HTTP 403 with a body naming the
# required and granted scopes, NOT 404; and a VALID credential against a
# nonexistent repository answers HTTP 404. So 401 and 403 are credential-level
# verdicts true of whichever repository was probed, and scope refusal announces
# itself rather than hiding as a 404. The clone is still chosen
# deterministically.
# What a 404 does NOT settle is whose fault it is: a credential holding
# repository read but bound to the wrong account, or one that has lost access to
# that specific private repository, is indistinguishable from a repository that
# was renamed or moved. Silencing it would put a genuinely broken credential
# back where this whole check exists to stop it being - invisible until a
# pull-request step fails - so it is REPORTED, once per home.
# Two outcomes are reported once per home and then stay silent, because each is
# news the first time and unactionable wallpaper every time after: a machine
# with no credential store at all, and a repository the credential cannot see.
# state/forge-credential-<outcome>.<forge> is that record, keyed per home and
# per forge so the two outcomes cannot suppress each other. The not-visible
# outcome additionally keys on the probed repository, because its line names a
# repository and a later 404 on a different one is fresh news; the no-store
# outcome names no repository and stays keyed per forge. No record holds a
# credential value.

# Returns 0 when this home has already been told this piece of news, 1 when it
# has not - and marks it told in the same step, so the line is printed exactly
# once. A record that cannot be written reports again rather than losing it.
forge_news_already_reported() { # <forge> <outcome>
local marker="$STATE/forge-credential-$2.$1"
[ -e "$marker" ] && return 0
# A session that did not get the fleet lock stays strictly read-only, so it
# reports the news without recording it: recording here would consume the one
# report and leave the session that CAN act about it silent.
[ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" = 1 ] && return 1
mkdir -p "$STATE" 2>/dev/null || return 1
: > "$marker" 2>/dev/null || return 1
return 1
}

forge_credential_report() { # <forge> <status> <reason> [<repository>]
local forge=$1 status=$2 reason=$3 repo=${4:-} repo_key
case "$status" in
0|7) return 0 ;;
6)
forge_news_already_reported "$forge" no-store && return 0
echo "FORGE_CREDENTIAL: $forge: no credential store on this platform, so $forge merge and build checks are unavailable here"
return 0
;;
8)
# The line names one repository, so the record must too: a later 404 on a
# DIFFERENT repository is genuinely new news. The identifier is validated
# to [A-Za-z0-9._/-] with a single slash, so mapping '/' to '%' (a char
# the identifier cannot contain) is a collision-free, filesystem-safe key.
repo_key=${repo//\//%}
forge_news_already_reported "$forge" "not-visible.$repo_key" && return 0
;;
esac
reason=$(first_line "${reason#error: }")
[ -n "$reason" ] || reason="credential check failed (exit $status)"
echo "FORGE_CREDENTIAL: $forge: $reason"
}

forge_credential_check() {
local resolver proj url urls out status target probe_forge probe_repo
resolver="$SCRIPT_DIR/fm-forge-credential.sh"
[ -x "$resolver" ] || return 0
[ -d "$PROJECTS" ] || return 0
urls=
for proj in "$PROJECTS"/*; do
[ -d "$proj" ] || continue
url=$(git -C "$proj" remote get-url origin 2>/dev/null) || continue
[ -n "$url" ] || continue
urls="$urls$url
"
done
[ -n "$urls" ] || return 0
# Collect the remotes first, then ask the resolver once. Two properties this
# buys are worth the extra variable. Which forges firstmate holds a credential
# for is never restated here, so a forge added to the resolver's table cannot
# be silently skipped by this scan. And the scan turns a home's clone count
# into neither a session-start request count nor a session-start process
# count: one resolver process chooses the target, one proves it.
# The remotes are handed over in glob order, so which clone gets probed stays
# deterministic.
target=$(printf '%s' "$urls" | "$resolver" probe-target 2>/dev/null) || return 0
probe_forge=${target%% *}
probe_repo=
case "$target" in
*' '*) probe_repo=${target#* } ;;
esac
[ -n "$probe_forge" ] || return 0
if [ -n "$probe_repo" ]; then
if ! command -v curl >/dev/null 2>&1; then
report_missing_tool curl
return 0
fi
out=$("$resolver" check "$probe_forge" "$probe_repo" 2>&1 >/dev/null)
status=$?
forge_credential_report "$probe_forge" "$status" "$out" "$probe_repo"
return 0
fi
# Every tracked clone on that forge has an unusable remote: fall back to the
# local proof, which still catches a missing or empty credential and needs no
# request at all.
out=$("$resolver" check "$probe_forge" 2>&1 >/dev/null)
status=$?
forge_credential_report "$probe_forge" "$status" "$out"
}

install_cmd() {
case "$1" in
tmux|node|git|gh|curl|jq|orca|zellij) echo "brew install $1 # or the platform's package manager" ;;
Expand Down Expand Up @@ -779,6 +920,19 @@ missing_tool_diagnostic() {
echo "MISSING: $tool (install: $(install_cmd "$tool"))"
}

# Several independent checks require the same tool - curl is required by both
# the forge-credential check and the X-mode relay poll - and the digest is
# parsed line by line, so a tool is named at most once however many checks want
# it. Every missing-tool report goes through here rather than echoing directly.
MISSING_TOOLS_REPORTED=
report_missing_tool() { # <tool>
case " $MISSING_TOOLS_REPORTED " in
*" $1 "*) return 0 ;;
esac
MISSING_TOOLS_REPORTED="$MISSING_TOOLS_REPORTED $1"
missing_tool_diagnostic "$1"
}

# Required-tool detection follows the RESOLVED backend, not a one-size default:
# a universal toolchain every home needs plus the backend-specific delta owned by
# fm_backend_required_tools (bin/fm-backend.sh). So a herdr/zellij/cmux home is
Expand Down Expand Up @@ -937,7 +1091,7 @@ x_mode_setup() {
missing=0
for tool in curl jq; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "MISSING: $tool (install: $(install_cmd "$tool"))"
report_missing_tool "$tool"
missing=1
fi
done
Expand Down Expand Up @@ -992,7 +1146,7 @@ crew_dispatch_validate() {
file="$CONFIG/crew-dispatch.json"
[ -f "$file" ] || return 0
if ! command -v jq >/dev/null 2>&1; then
echo "MISSING: jq (install: $(install_cmd jq))"
report_missing_tool jq
return 0
fi
if ! jq -e . "$file" >/dev/null 2>&1; then
Expand Down Expand Up @@ -1210,6 +1364,13 @@ if network_phase; then
__fm_timing_stamp=$(fm_timing_now_ms)
gh auth status >/dev/null 2>&1 || echo "NEEDS_GH_AUTH"
fm_timing_record phase gh-auth "$__fm_timing_stamp"
# The forge-credential probe is the same shape as the gh-auth check above - one
# bounded request that answers "can firstmate still act on this forge" - so it
# belongs in the deferred network phase beside it, never on the blocking local
# pass that composes the digest.
__fm_timing_stamp=$(fm_timing_now_ms)
forge_credential_check
fm_timing_record phase forge-credential "$__fm_timing_stamp"
fi
local_phase && detect_local_config

Expand Down
Loading