Skip to content
Merged
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: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Hard rules, in priority order:
1. **Never write to a project.**
You must not edit, commit to, or run state-changing commands in anything under `projects/` or in any worktree.
You read projects to understand them; crewmates change them.
Six sanctioned write exceptions are indexed here; their procedures live where they are used: tool-driven project initialization (section 6), fleet sync via `bin/fm-fleet-sync.sh` (sections 3 and 7), local-HEAD secondmate sync via `bin/fm-bootstrap.sh` and `bin/fm-spawn.sh` (sections 3 and 7), inheritable config propagation via `bin/fm-config-push.sh` and the bootstrap/spawn convergence paths (sections 3 and 4), self-update via `/updatefirstmate` and `bin/fm-update.sh` (section 12), and approved `local-only` merge via `bin/fm-merge-local.sh` (section 7).
Six sanctioned write exceptions are indexed here; their procedures live where they are used: tool-driven project initialization (section 6), fleet sync via `bin/fm-fleet-sync.sh` (sections 3, 7, and 8), local-HEAD secondmate sync via `bin/fm-bootstrap.sh` and `bin/fm-spawn.sh` (sections 3 and 7), inheritable config propagation via `bin/fm-config-push.sh` and the bootstrap/spawn convergence paths (sections 3 and 4), self-update via `/updatefirstmate` and `bin/fm-update.sh` (section 12), and approved `local-only` merge via `bin/fm-merge-local.sh` (section 7).
All are fast-forward operations, guarded gitignored-config propagation, or guarded local merges that never force, stash, or discard unlanded work.
Project `AGENTS.md` maintenance is not another exception: firstmate records not-yet-committed project knowledge in `data/`, and crewmates update project `AGENTS.md` through normal delivery (section 6).
2. **Never merge a PR without the captain's explicit word.**
Expand Down Expand Up @@ -514,6 +514,7 @@ Steer a crewmate only with short single lines via `bin/fm-send.sh`; anything lon
Steer a secondmate the same way.
Its charter retargets escalation to the main firstmate's status file, so routine internal churn stays inside the secondmate home and only `done`, `blocked`, `needs-decision`, `failed`, or captain-relevant phase changes wake the main firstmate.
Because `fm-send` to a `kind=secondmate` target marks the request as from-firstmate (section 7 intake), the secondmate's answer comes back on that status/doc path too, not in its chat; read the response there as an ordinary status signal and do not peek its chat for it.
A secondmate-reported merged PR is exactly the case the fleet-sync-on-merge wake rule (section 8) exists for, since the secondmate's own teardown never touches this home's separate project clone.

### Delivery modes and yolo

Expand Down Expand Up @@ -680,6 +681,7 @@ On wake, in order of cheapness:
Do not report that the fleet is unchanged.

When a task reaches a terminal state on any of these wakes (a `done`/merge `check:`, a `failed` signal, a scout report, a local-only merge), and X mode is enabled, load `fmx-respond` (section 13) and post the X-mention's **final** completion follow-up if that task is X-linked: `bin/fm-x-followup.sh --check <id>` then `bin/fm-x-followup.sh <id> --final --text-file <path>`, so the link always clears here regardless of how many of the up-to-three follow-ups were already spent on earlier milestones.
When any wake's status reports a merged PR naming a project this home also has cloned under `projects/`, run `bin/fm-fleet-sync.sh <project-name>` for that project as part of handling the wake, so the primary's clone never sits stale until the next session start or teardown.

Heartbeats back off exponentially while they are the only wakes firing (600s doubling to a 2h cap - an idle fleet stops burning turns); any signal, stale, or check wake resets the cadence to the base interval.
Due per-task checks run before signal scanning so chatty crewmate status updates cannot starve slow polls like merge detection.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ tests/fm-afk-inject-herdr-e2e.test.sh # real-herdr end-to-end test of the af
tests/fm-bootstrap.test.sh # bootstrap dependency, feature-probe, and crew-dispatch reporting tests
tests/fm-session-start.test.sh # fm-session-start.sh: ABSENT vs empty-vs-present digest files, lock-refusal read-only path skipping every mutating step, diagnostics-first section ordering, status-tail bounding, tmux/herdr endpoint liveness, and composition of the real fm-lock/fm-bootstrap/fm-wake-drain scripts
tests/fm-grok-harness.test.sh # grok adapter spawn hook, token guard, teardown cleanup, and session-lock detection tests
tests/fm-fleet-sync.test.sh # project clone refresh: safe detached recovery, STUCK drift reports, benign skips, and bootstrap relay
tests/fm-fleet-sync.test.sh # project clone refresh: safe detached recovery, STUCK drift reports, benign skips, single-project name resolution, and bootstrap relay
tests/fm-x-mode.test.sh # X-mode poll, inbox context round-trip, reply threading, dismiss, completion follow-up counters/caps, dry-run preview, and .env-presence activation tests
tests/fm-tangle-guard.test.sh # primary-checkout tangle detection, read-only remediation suppression, and spawn/brief isolation tests
tests/fm-brief.test.sh # fm-brief.sh bash -n parse regression guard (issue #166) and clean no-mistakes/direct-PR/local-only brief generation tests
Expand Down
48 changes: 45 additions & 3 deletions bin/fm-fleet-sync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@
# and fetch failures.
# Pruning never deletes the checked-out branch or a branch that still has a
# worktree, so it cannot discard unlanded work; set FM_FLEET_PRUNE=0 to disable it.
# Usage: fm-fleet-sync.sh [<project-dir>]
# Usage: fm-fleet-sync.sh [<project-dir-or-name>]
# The single-project form accepts either a path (absolute, or relative to the
# caller's cwd) or a bare "<name>"/"projects/<name>" form, resolved against
# this home's projects dir ($FM_HOME/projects, or $FM_PROJECTS_OVERRIDE).
# Bare names and "projects/<name>" forms prefer this home's projects dir before
# falling back to an explicit path. Example: from anywhere,
# `fm-fleet-sync.sh dotfiles-private` syncs just that one clone, same as
# passing its full projects/dotfiles-private path.
set -eu

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand All @@ -25,7 +32,7 @@ PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}"
"$FM_ROOT/bin/fm-guard.sh" || true

usage() {
echo "usage: fm-fleet-sync.sh [<project-dir>]" >&2
echo "usage: fm-fleet-sync.sh [<project-dir-or-name>]" >&2
}

if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
Expand All @@ -42,6 +49,41 @@ project_label() {
esac
}

# resolve_project_arg <arg>: accept a path (used as-is when it already exists)
# or a bare/"projects/<name>" project name, resolved against $PROJECTS. Falls
# back to the original argument unresolved so a genuinely bad path still hits
# sync_project's existing "not a directory" skip.
resolve_project_arg() {
local arg=$1 candidate
case "$arg" in
projects/*)
candidate="$PROJECTS/${arg#projects/}"
if [ -d "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
;;
*/*)
if [ -d "$arg" ]; then
printf '%s\n' "$arg"
return 0
fi
;;
*)
candidate="$PROJECTS/$arg"
if [ -d "$candidate" ]; then
printf '%s\n' "$candidate"
return 0
fi
if [ -d "$arg" ]; then
printf '%s\n' "$arg"
return 0
fi
;;
esac
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)
Expand Down Expand Up @@ -270,7 +312,7 @@ sync_project() {
}

if [ $# -eq 1 ]; then
sync_project "$1"
sync_project "$(resolve_project_arg "$1")"
exit 0
fi

Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ Generalizable firstmate knowledge goes to shared tracked docs through the normal

## Local clones stay fresh

The locked session-start bootstrap step and PR-based teardown refresh remote-backed project clones when the clone is safe to move.
The locked session-start bootstrap step, PR-based teardown, and merged-PR wake handling refresh remote-backed project clones when the clone is safe to move.
Wake-time refreshes can target a single clone by project name, so the primary home also catches up when a secondmate reports a merge from its own home.
Clean default-branch clones fast-forward to `origin/<default>`, and a clean detached HEAD that holds no unique commits is re-attached to the default branch before the same fast-forward path runs.
Dirty clones, non-default branches, detached HEADs with unique commits, diverged defaults, and default branches checked out in another worktree are reported as `STUCK:` with their behind count and left untouched.
Local-only projects, clones without an origin remote, and fetch failures remain benign skips.
Expand Down
2 changes: 1 addition & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ If you have changed away from the firstmate home in an interactive shell, invoke
| ------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| `fm-session-start.sh` | The one command AGENTS.md sections 3 and 5 run at every session start: composes `fm-lock.sh`, `fm-bootstrap.sh` (its three mutating sweeps gated on holding the lock via `FM_BOOTSTRAP_DETECT_ONLY`), and `fm-wake-drain.sh`, then prints a full context digest (`data/projects.md`, `data/secondmates.md`, `data/captain.md`, `data/learnings.md`, each `ABSENT`-marked when missing) and fleet-state digest (`data/backlog.md`, every `state/*.meta`, a bounded `state/*.status` tail, `state/.afk`, and a cheap per-task endpoint-liveness read); prints a loud read-only banner and skips every mutating step when the lock is held elsewhere; never arms the watcher itself |
| `fm-bootstrap.sh` | Detect required toolchain and version problems, dispatch profile JSON errors or active-rule blocks, default backlog-backend status, primary-checkout `TANGLE:` problems, and actionable clone refresh outcomes; refresh project clones best-effort; locally sync live secondmate homes and propagate declared inheritable config; set up opt-in X mode; install tools only after consent; `FM_BOOTSTRAP_DETECT_ONLY=1` skips the three mutating sweeps and prints advisory-only `TANGLE:` wording without a checkout command |
| `fm-fleet-sync.sh` | Fetch clones, fast-forward safe default-branch states, self-heal clean detached ancestor drift, report unsafe drift as `STUCK:`, and safely prune branches whose remote is gone |
| `fm-fleet-sync.sh` | Fetch all clones, or one clone selected by absolute path, relative path, bare project name, or `projects/<name>` resolved against this home's projects dir; fast-forward safe default-branch states, self-heal clean detached ancestor drift, report unsafe drift as `STUCK:`, and safely prune branches whose remote is gone |
| `fm-update.sh` | Self-update the running firstmate repo and registered secondmate homes with fast-forward-only pulls from origin |
| `fm-backlog-handoff.sh` | Move already-judged in-scope queued backlog items from the main home into a seeded secondmate home |
| `fm-brief.sh` | Scaffold a ship brief with a worktree-isolation assertion, a report-only scout brief with `--scout`, or a secondmate charter with `--secondmate` |
Expand Down
69 changes: 69 additions & 0 deletions tests/fm-fleet-sync.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,70 @@ test_local_only_skipped() {
pass "local-only clone is skipped (benign), not flagged STUCK"
}

test_single_project_by_bare_name_resolves() {
local home out
home=$(new_home)
build_pair "$home" kappa >/dev/null
advance_origin "$home" kappa C1

out=$(run_sync "$home" "kappa")

assert_contains "$out" "kappa: synced" "bare project name resolves against the home's projects dir"
pass "single-project form accepts a bare project name"
}

test_single_project_by_bare_name_ignores_cwd_shadow() {
local home cwd out
home=$(new_home)
build_pair "$home" mu >/dev/null
advance_origin "$home" mu C1
cwd="$home/shadow"
mkdir -p "$cwd/mu"

out=$(cd "$cwd" && run_sync "$home" "mu")

assert_contains "$out" "mu: synced" "bare project name prefers the home's projects dir"
assert_not_contains "$out" "skipped: not a git repo" "bare project name ignores a cwd shadow directory"
pass "single-project bare name resolution is not cwd-sensitive"
}

test_single_project_by_projects_relative_name_resolves() {
local home out
home=$(new_home)
build_pair "$home" lambda >/dev/null
advance_origin "$home" lambda C1

out=$(run_sync "$home" "projects/lambda")

assert_contains "$out" "lambda: synced" "projects/<name> form resolves against the home's projects dir"
pass "single-project form accepts a projects/<name> relative name"
}

test_single_project_by_projects_relative_name_ignores_cwd_shadow() {
local home cwd out
home=$(new_home)
build_pair "$home" nu >/dev/null
advance_origin "$home" nu C1
cwd="$home/shadow"
mkdir -p "$cwd/projects/nu"

out=$(cd "$cwd" && run_sync "$home" "projects/nu")

assert_contains "$out" "nu: synced" "projects/<name> form prefers the home's projects dir"
assert_not_contains "$out" "skipped: not a git repo" "projects/<name> form ignores a cwd shadow directory"
pass "single-project projects/<name> resolution is not cwd-sensitive"
}

test_single_project_unresolvable_name_still_skips() {
local home out
home=$(new_home)

out=$(run_sync "$home" "does-not-exist")

assert_contains "$out" "skipped: not a directory" "an unresolvable name still hits the existing not-a-directory skip"
pass "single-project form leaves a genuinely bad name unresolved"
}

test_whole_fleet_form() {
local home behind current out
home=$(new_home)
Expand Down Expand Up @@ -302,5 +366,10 @@ test_on_default_clean_behind_fast_forwards
test_already_current_unchanged
test_no_origin_skipped
test_local_only_skipped
test_single_project_by_bare_name_resolves
test_single_project_by_bare_name_ignores_cwd_shadow
test_single_project_by_projects_relative_name_resolves
test_single_project_by_projects_relative_name_ignores_cwd_shadow
test_single_project_unresolvable_name_still_skips
test_whole_fleet_form
test_bootstrap_relays_recovered_and_stuck