From f15680695a2bd50430b22ecb658d97da0ed030d4 Mon Sep 17 00:00:00 2001 From: PP <121104417+BohnBawerick@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:59:06 +0800 Subject: [PATCH 1/5] fm-merge-local: push firstmate's own landing to its fork An approved firstmate landing fast-forwarded only local main, so the fork fell behind what the home runs, remote second mates synced stale code, and landed PRs still showed open. After the local fast-forward, firstmate's own repository now pushes local main to origin's main as a plain fast-forward once fm-landing-remote verify passes, reads the branch back, and proves a recorded PR merged with the shared forge read. A non-fast-forward, a failed push, or an unproved read-back keeps the local landing, names why and the command to finish, and exits 3. Project landings are unchanged. --- AGENTS.md | 2 +- bin/fm-merge-local.sh | 113 ++++++++++++++++++ bin/fm-pr-merge.sh | 7 +- bin/fm-self-repo-lib.sh | 3 +- docs/architecture.md | 1 + docs/configuration.md | 2 + docs/scripts.md | 2 +- tests/fm-merge-local.test.sh | 221 +++++++++++++++++++++++++++++++++++ 8 files changed, 345 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a32af6b4c96..b3ab12dfb24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Shared tracked material is `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, `.tasks. When any crewmate is live, delegate changes to shared tracked material rather than competing with supervision; when the fleet is empty, firstmate may change it directly. This repo is a shared template, while `.env`, `data/`, `state/`, `config/`, `projects/`, and `.no-mistakes/` are captain-private and gitignored. Ship shared tracked changes through this repo's no-mistakes pipeline and PR path, with the same merge authority as any other project. -Firstmate repo tasks land into this home's local `main` via `bin/fm-merge-local.sh` once approved, while the outward-facing PR remains open; `docs/configuration.md` owns which remote that PR opens on. +Firstmate repo tasks land into this home's local `main` via `bin/fm-merge-local.sh` once approved, and that landing also fast-forwards the landing remote's `main`, so the fork holds what this home runs and its PR reads back merged; `docs/configuration.md` owns which remote that is. Never add an agent name as a commit co-author. Use `gh-axi` for GitHub, `chrome-devtools-axi` for browser work, and compatible `lavish-axi` for visual decisions or reports; consult current help rather than memorizing flags. diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index 655d26df794..123894cd933 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -15,6 +15,21 @@ # merge, so a captain approval must be recorded as an `answer --release` before # this entrypoint is invoked. The lock ends when the fast-forward returns; # docs/captain-hold-lifecycle.md owns the accepted merge-to-cleanup residual. +# +# For firstmate's own repository (bin/fm-self-repo-lib.sh), the landing also +# updates the fork: after the local fast-forward it pushes local default to the +# landing remote `origin` (bin/fm-landing-remote.sh owns that `origin` is ours, +# and its drift check must pass first) as a plain fast-forward, never forced and +# never anywhere else, then reads the remote branch back. When the task records +# a pr=, that PR must then read back merged through the same forge read +# bin/fm-pr-merge.sh uses, retried a bounded number of times while the forge +# catches up (FM_MERGE_LOCAL_READBACK_DELAY seconds apart, 0-10, default 3). +# A home whose checkout has no `origin` remote has no fork and says so. +# Exit status: 0 landed (and, for firstmate's own repository, synced and proved); +# 3 landed locally but the fork sync or PR read-back was not proved - the local +# landing stays, and the message names why and the exact command to finish; +# any other non-zero value means nothing was landed. +# Project landings never push: local-only projects have no remote by design. # Usage: fm-merge-local.sh set -eu @@ -159,3 +174,101 @@ after=$(git -C "$PROJ" rev-parse --short "$DEFAULT") # Opt-in fleet activity ledger (docs/fleet-ledger.md); off costs one file test. [ ! -e "${FM_CONFIG_OVERRIDE:-$FM_HOME/config}/fleet-ledger" ] || FM_HOME=$FM_HOME FM_STATE_OVERRIDE=$STATE "$SCRIPT_DIR/fm-fleet-ledger.sh" merged "$ID" local || true echo "merged $BRANCH into local $DEFAULT ($before -> $after) in $PROJ" + +# Firstmate's own repository also updates its landing remote, so the fork on +# the forge holds exactly what this home runs. Project landings stop here. +fm_is_firstmate_repo "$PROJ" "$FM_ROOT" "$FM_HOME" || exit 0 + +FORK_REMOTE=origin +LOCAL_SHA=$(git -C "$PROJ" rev-parse "refs/heads/$DEFAULT") +FINISH=$(printf 'git -C %q push %s refs/heads/%s:refs/heads/%s' "$PROJ" "$FORK_REMOTE" "$DEFAULT" "$DEFAULT") +# Never wait on a credential prompt nobody will answer. +export GIT_TERMINAL_PROMPT=0 + +# Report an unsynced fork and exit 3. The local landing above stays as it is. +fork_not_synced() { # [...] + echo "fork not updated: local $DEFAULT landed at $after, but $FORK_REMOTE/$DEFAULT was not updated: $1" >&2 + shift + local line + for line in "$@"; do + echo "$line" >&2 + done + exit 3 +} + +if ! git -C "$PROJ" remote get-url "$FORK_REMOTE" >/dev/null 2>&1; then + echo "no $FORK_REMOTE remote in $PROJ: this home has no fork to update" + exit 0 +fi +# bin/fm-landing-remote.sh owns whether origin is the landing remote rather +# than the parent we forked from; any doubt keeps the push from happening. +if ! verify_output=$("$SCRIPT_DIR/fm-landing-remote.sh" verify --repo "$PROJ" 2>&1); then + fork_not_synced "$FORK_REMOTE is not proven to be our fork: $verify_output" \ + "Repair the remotes as described, then finish with: $FINISH" +fi +if ! remote_line=$(git -C "$PROJ" ls-remote "$FORK_REMOTE" "refs/heads/$DEFAULT" 2>&1); then + fork_not_synced "could not read $FORK_REMOTE/$DEFAULT: $remote_line" \ + "Finish with: $FINISH" +fi +REMOTE_SHA=${remote_line%%[[:space:]]*} +if [ -n "$REMOTE_SHA" ] && [ "$REMOTE_SHA" != "$LOCAL_SHA" ]; then + if ! git -C "$PROJ" cat-file -e "$REMOTE_SHA^{commit}" 2>/dev/null \ + && ! fetch_output=$(git -C "$PROJ" fetch --quiet "$FORK_REMOTE" "refs/heads/$DEFAULT" 2>&1); then + fork_not_synced "could not fetch $FORK_REMOTE/$DEFAULT: $fetch_output" \ + "Finish with: $FINISH" + fi + if ! git -C "$PROJ" merge-base --is-ancestor "$REMOTE_SHA" "$LOCAL_SHA" 2>/dev/null; then + fork_not_synced "it is not a fast-forward: $FORK_REMOTE/$DEFAULT is at ${REMOTE_SHA:0:8}, which local $DEFAULT does not contain" \ + "See the missing commits with: git -C $(printf '%q' "$PROJ") log --oneline $LOCAL_SHA..$REMOTE_SHA" \ + "Bring them into local $DEFAULT through a reviewed task, then finish with: $FINISH" + fi +fi +if [ "$REMOTE_SHA" != "$LOCAL_SHA" ]; then + # A plain refspec without "+": the remote itself refuses anything but a + # fast-forward, even if its branch moved after the check above. + if ! push_output=$(git -C "$PROJ" push --quiet "$FORK_REMOTE" "refs/heads/$DEFAULT:refs/heads/$DEFAULT" 2>&1); then + fork_not_synced "the push failed: $push_output" \ + "Finish with: $FINISH" + fi + if ! remote_line=$(git -C "$PROJ" ls-remote "$FORK_REMOTE" "refs/heads/$DEFAULT" 2>&1) \ + || [ "${remote_line%%[[:space:]]*}" != "$LOCAL_SHA" ]; then + fork_not_synced "the push returned, but $FORK_REMOTE/$DEFAULT does not read back as ${LOCAL_SHA:0:8}: $remote_line" \ + "Check it, and if needed finish with: $FINISH" + fi + echo "pushed local $DEFAULT to $FORK_REMOTE/$DEFAULT (${REMOTE_SHA:0:8} -> ${LOCAL_SHA:0:8})" +else + echo "$FORK_REMOTE/$DEFAULT already at ${LOCAL_SHA:0:8}" +fi + +# A recorded PR is proved merged by the same forge read the merge path uses. +PR_URL=$(grep '^pr=' "$META" | tail -n 1 | cut -d= -f2- || true) +[ -n "$PR_URL" ] || exit 0 +if ! fm_pr_url_parse "$PR_URL"; then + echo "error: $FORK_REMOTE/$DEFAULT now holds local $DEFAULT, but the recorded PR '$PR_URL' is not a URL this script can read back" >&2 + exit 3 +fi +# The forge marks a PR merged shortly after its head reaches the base branch, +# so the read is retried a bounded number of times. +readback_delay=${FM_MERGE_LOCAL_READBACK_DELAY:-3} +case "$readback_delay" in + [0-9] | 10) ;; + *) readback_delay=3 ;; +esac +readback_attempt=1 +while :; do + FM_PR_RECORD_STATE= + FM_PR_RECORD_MERGED= + case "$FM_PR_PROVIDER" in + github) fm_pr_github_read_record "$FM_PR_OWNER" "$FM_PR_REPO" "$FM_PR_NUMBER" || true ;; + gitlab) fm_pr_gitlab_read_record "$FM_PR_HOST" "$FM_PR_PATH" "$FM_PR_NUMBER" || true ;; + esac + [ "$FM_PR_RECORD_MERGED" != true ] || break + if [ "$readback_attempt" -ge 5 ]; then + echo "error: $FORK_REMOTE/$DEFAULT now holds local $DEFAULT, but $PR_URL does not read back as merged (state=${FM_PR_RECORD_STATE:-unreadable})" >&2 + echo "Re-check that PR on the forge; the fork itself is up to date." >&2 + exit 3 + fi + sleep "$readback_delay" + readback_attempt=$((readback_attempt + 1)) +done +echo "verified: $PR_URL is merged" diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 84ae55998f8..42186cda6d4 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -1490,9 +1490,10 @@ case "$outcome_rc" in ;; esac -# Firstmate's own repository is local-authoritative: the outward PR stays open on -# origin, and the proved merge above is followed by the guarded fast-forward into -# this home's local main. Reached only after the forge confirmed the merge landed. +# Firstmate's own repository is local-authoritative: the proved merge above is +# followed by the guarded landing into this home's local main, which also pushes +# that main to the fork (bin/fm-merge-local.sh owns both). Reached only after the +# forge confirmed the merge landed. PROJ=$(grep '^project=' "$META" | cut -d= -f2- || true) if [ -n "$PROJ" ] && [ -d "$PROJ" ] && fm_is_firstmate_repo "$PROJ" "$FM_ROOT" "$FM_HOME"; then "$SCRIPT_DIR/fm-merge-local.sh" "$ID" diff --git a/bin/fm-self-repo-lib.sh b/bin/fm-self-repo-lib.sh index c2c9d501abf..8faa07501bd 100644 --- a/bin/fm-self-repo-lib.sh +++ b/bin/fm-self-repo-lib.sh @@ -5,7 +5,8 @@ # Firstmate ships work on itself, so its tracked code root (FM_ROOT) and its # operational home (FM_HOME) both turn up as a task's project directory. Four # decisions branch on that fact and must agree exactly: -# bin/fm-merge-local.sh accepts a PR-mode task for the local fast-forward +# bin/fm-merge-local.sh accepts a PR-mode task for the local fast-forward, +# then pushes that landing to the fork # bin/fm-pr-merge.sh follows a merged PR with that same local landing # bin/fm-fleet-sync.sh leaves the checkout alone (upstream sync is manual) # bin/fm-spawn.sh refreshes a task worktree from the LOCAL default diff --git a/docs/architecture.md b/docs/architecture.md index 3a4735cbb40..7614c50ec05 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -428,6 +428,7 @@ The project-owned quality-gate contract and the receipt a hardened run must emit [`bin/fm-quality.sh`](../bin/fm-quality.sh) runs those commands, enforces the contract's bounds including its wall-clock one, and writes each phase's receipt; its header owns the round shape, the outcome-to-exit-code table, and the read-only mode that reports a standard task's scores without gating anything. A task recorded `quality=hardened` is not done until that receipt exists and passes, so `bin/fm-crew-state.sh` filters every `done` verdict through that script's own status verdict and leaves every other posture's line exactly as it was. `local-only` tasks and Firstmate's own repository tasks land into the local default branch through `bin/fm-merge-local.sh`. +For Firstmate's own repository that landing also fast-forwards the landing remote's default branch, so the fork never falls behind what the home runs; a `local-only` project has no remote and is never pushed. After the forge accepts firstmate's merge request, the merge path persists the resolved away or attended authority bound to the task's canonical PR identity; while an away record exists any green merge runs under away authority, while a quiet record keeps attended authority, and which merge the captain's away words meant is the supervision session's reading. A later merged poll consumes only that matching persisted value; with no match it records the landing as external rather than consulting a live away-posture record that may have been archived or replaced. [`bin/fm-merge-authority-lib.sh`](../bin/fm-merge-authority-lib.sh)'s header owns resolution, private atomic persistence, identity-checked consumption, and retirement, while only the merge path gates on the answer. diff --git a/docs/configuration.md b/docs/configuration.md index 6d49269d591..4ac2d9ecf9d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -151,6 +151,8 @@ Given `--ours` it is an identity check on `origin`; with no `--ours` it asserts `bin/fm-bootstrap.sh` runs the second form against the firstmate primary at every session start and relays a refusal as one `LANDING_REMOTE:` line, so a checkout that drifted back toward the parent - or that was never remapped at all - is surfaced there rather than discovered by a branch, a push, or a PR that went to the wrong repository. A clone with neither an `upstream` nor a `fork` remote never had a parent to be remapped away from, so the check passes silently for it. +Every approved Firstmate landing updates this remote: `bin/fm-merge-local.sh` pushes local `main` to `origin`'s `main` as a fast-forward once that `verify` passes, and its header owns the push, the PR read-back, and how an unsynced fork is reported. + ## Calm preference (config/calm) The Pi Calm extension and the Claude Code Calm mod share the local, gitignored `config/calm` preference under the effective Firstmate home. diff --git a/docs/scripts.md b/docs/scripts.md index f1abc17853b..b8a0c282caf 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -80,7 +80,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md | `fm-forge-detect.sh` | Propose a clone's forge binding from its origin remote for project-add intake, never recording it | | `fm-quality.sh` | Run a project's quality phase under its own bounds, write its receipt, report one outcome | | `fm-quality-receipt.sh` | Validate a quality-gate receipt against the D2 schema, or print that schema | -| `fm-merge-local.sh` | Fast-forward a `local-only` project or Firstmate's own repository local default branch after approval | +| `fm-merge-local.sh` | Fast-forward a `local-only` project or Firstmate's own repository local default branch after approval, then push Firstmate's own landing to its fork | | `fm-review-diff.sh` | Review a crewmate branch or resolved PR head against the authoritative base | | `fm-marker-lib.sh` | Compatibility entry point for the from-firstmate carrier owned by `fm-operational-input.sh` | | `fm-task-inbox-lib.sh` | Single owner of durable steering-inbox records, acknowledgement, doorbells, and the delivery-attempt ladder | diff --git a/tests/fm-merge-local.test.sh b/tests/fm-merge-local.test.sh index ee5af32f17d..b8ea6e16879 100755 --- a/tests/fm-merge-local.test.sh +++ b/tests/fm-merge-local.test.sh @@ -16,6 +16,14 @@ # (i) refuses when project checkout is not on its default branch # (j) refuses when project checkout is dirty # (k) refuses when branch has diverged (not a fast-forward) +# (l) Firstmate's own repository pushes the landing to origin as a fast-forward +# (m) Firstmate's own repository keeps the landing but reports, exit 3, an +# origin that local main does not contain, and leaves origin untouched +# (n) Firstmate's own repository keeps the landing but reports, exit 3, a +# push the remote rejects +# (o) Firstmate's own repository reads a recorded PR back as merged, and +# reports, exit 3, one that does not read back merged +# (p) Firstmate's own repository never pushes when origin may be the parent # # "Firstmate's own repository" is one shared predicate, bin/fm-self-repo-lib.sh, # used identically by fm-merge-local.sh, fm-pr-merge.sh, fm-fleet-sync.sh, and @@ -497,6 +505,213 @@ test_refuses_diverged_branch() { pass "fm-merge-local refuses when branch has diverged (not a fast-forward)" } +# A firstmate repository whose origin is a local bare clone, plus one ship branch +# ahead of main. Prints the bare remote's path. +make_fm_repo_with_origin() { + local case_dir=$1 id=$2 fm_root="$1/firstmate" remote="$1/origin.git" + mkdir -p "$case_dir/state" + make_repo "$fm_root" main + fm_git_add_origin "$fm_root" "$remote" + git -C "$fm_root" checkout -b "fm/$id" --quiet + echo "firstmate feature $id" >> "$fm_root/file.txt" + git -C "$fm_root" commit --quiet -am "firstmate fix $id" + git -C "$fm_root" checkout main --quiet + printf '%s\n' "$remote" +} + +# A gh stand-in that answers the PR read-back with FAKE_GH_STATE. +make_fake_gh() { + local fakebin + fakebin=$(fm_fakebin "$1") + cat > "$fakebin/gh" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$FAKE_GH_LOG" +case "${FAKE_GH_STATE:-MERGED}" in + MERGED) printf 'state=MERGED\nmerged=true\n' ;; + *) printf 'state=%s\nmerged=false\n' "$FAKE_GH_STATE" ;; +esac +SH + chmod +x "$fakebin/gh" + printf '%s\n' "$fakebin" +} + +run_fm_merge_local() { # + local case_dir=$1 fm_root="$1/firstmate" + FM_ROOT_OVERRIDE="$fm_root" \ + FM_HOME="$fm_root" \ + FM_STATE_OVERRIDE="$case_dir/state" \ + FM_MERGE_LOCAL_READBACK_DELAY=0 \ + run_merge_local "$2" > "$case_dir/stdout" 2> "$case_dir/stderr" +} + +test_firstmate_repo_pushes_fork() { + local case_dir fm_root remote rc local_sha + case_dir="$TMP_ROOT/fm-push-fork" + remote=$(make_fm_repo_with_origin "$case_dir" task-push) + fm_root="$case_dir/firstmate" + fm_write_meta "$case_dir/state/task-push.meta" \ + "window=fm-task-push" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" + + set +e + run_fm_merge_local "$case_dir" task-push + rc=$? + set -e + + expect_code 0 "$rc" "push-fork: fm-merge-local should succeed: $(cat "$case_dir/stderr")" + local_sha=$(git -C "$fm_root" rev-parse main) + assert_equals "$(git -C "$fm_root" rev-parse fm/task-push)" "$local_sha" \ + "push-fork: local main was not fast-forwarded" + assert_equals "$local_sha" "$(git -C "$remote" rev-parse main)" \ + "push-fork: origin main does not hold local main" + assert_grep "pushed local main to origin/main" "$case_dir/stdout" \ + "push-fork: the push was not reported" + pass "fm-merge-local pushes Firstmate's own landing to origin as a fast-forward" +} + +test_firstmate_repo_refuses_non_fast_forward_push() { + local case_dir fm_root remote other rc remote_before + case_dir="$TMP_ROOT/fm-push-diverged" + remote=$(make_fm_repo_with_origin "$case_dir" task-div2) + fm_root="$case_dir/firstmate" + other="$case_dir/other" + git clone --quiet "$remote" "$other" + echo "landed elsewhere" > "$other/elsewhere.txt" + git -C "$other" add elsewhere.txt + git -C "$other" commit --quiet -m "commit only the fork has" + git -C "$other" push --quiet origin main + remote_before=$(git -C "$remote" rev-parse main) + fm_write_meta "$case_dir/state/task-div2.meta" \ + "window=fm-task-div2" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" + + set +e + run_fm_merge_local "$case_dir" task-div2 + rc=$? + set -e + + expect_code 3 "$rc" "push-diverged: an unsynced fork must exit 3" + assert_equals "$(git -C "$fm_root" rev-parse fm/task-div2)" "$(git -C "$fm_root" rev-parse main)" \ + "push-diverged: the local landing was not kept" + assert_equals "$remote_before" "$(git -C "$remote" rev-parse main)" \ + "push-diverged: origin main moved" + assert_grep "fork not updated" "$case_dir/stderr" \ + "push-diverged: the unsynced fork was not reported" + assert_grep "not a fast-forward" "$case_dir/stderr" \ + "push-diverged: the reason was not given" + assert_grep "push origin refs/heads/main:refs/heads/main" "$case_dir/stderr" \ + "push-diverged: the finishing command was not given" + assert_no_grep "pushed local main" "$case_dir/stdout" \ + "push-diverged: a push was claimed" + pass "fm-merge-local keeps the landing and reports a fork it cannot fast-forward" +} + +test_firstmate_repo_reports_push_failure() { + local case_dir fm_root remote rc remote_before + case_dir="$TMP_ROOT/fm-push-rejected" + remote=$(make_fm_repo_with_origin "$case_dir" task-rej) + fm_root="$case_dir/firstmate" + printf '#!/bin/sh\necho "fork refuses pushes"\nexit 1\n' > "$remote/hooks/pre-receive" + chmod +x "$remote/hooks/pre-receive" + remote_before=$(git -C "$remote" rev-parse main) + fm_write_meta "$case_dir/state/task-rej.meta" \ + "window=fm-task-rej" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=direct-PR" + + set +e + run_fm_merge_local "$case_dir" task-rej + rc=$? + set -e + + expect_code 3 "$rc" "push-rejected: a failed push must exit 3" + assert_equals "$(git -C "$fm_root" rev-parse fm/task-rej)" "$(git -C "$fm_root" rev-parse main)" \ + "push-rejected: the local landing was not kept" + assert_equals "$remote_before" "$(git -C "$remote" rev-parse main)" \ + "push-rejected: origin main moved" + assert_grep "the push failed" "$case_dir/stderr" \ + "push-rejected: the failed push was not reported" + assert_grep "fork refuses pushes" "$case_dir/stderr" \ + "push-rejected: the remote's reason was not passed on" + assert_grep "push origin refs/heads/main:refs/heads/main" "$case_dir/stderr" \ + "push-rejected: the finishing command was not given" + pass "fm-merge-local keeps the landing and reports a push the fork rejects" +} + +test_firstmate_repo_reads_pr_back_merged() { + local case_dir fm_root fakebin rc + case_dir="$TMP_ROOT/fm-pr-readback" + make_fm_repo_with_origin "$case_dir" task-prm >/dev/null + fm_root="$case_dir/firstmate" + fakebin=$(make_fake_gh "$case_dir") + fm_write_meta "$case_dir/state/task-prm.meta" \ + "window=fm-task-prm" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" "pr=https://github.com/example/firstmate/pull/7" + + set +e + PATH="$fakebin:$PATH" FAKE_GH_STATE=MERGED FAKE_GH_LOG="$case_dir/gh.log" \ + run_fm_merge_local "$case_dir" task-prm + rc=$? + set -e + + expect_code 0 "$rc" "pr-readback: a merged PR should succeed: $(cat "$case_dir/stderr")" + assert_grep "verified: https://github.com/example/firstmate/pull/7 is merged" "$case_dir/stdout" \ + "pr-readback: the merged PR was not verified" + assert_grep "number=7" "$case_dir/gh.log" \ + "pr-readback: the recorded PR was not the one read" + pass "fm-merge-local reads a recorded PR back as merged after the push" +} + +test_firstmate_repo_reports_pr_not_merged() { + local case_dir fm_root remote fakebin rc + case_dir="$TMP_ROOT/fm-pr-open" + remote=$(make_fm_repo_with_origin "$case_dir" task-pro) + fm_root="$case_dir/firstmate" + fakebin=$(make_fake_gh "$case_dir") + fm_write_meta "$case_dir/state/task-pro.meta" \ + "window=fm-task-pro" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" "pr=https://github.com/example/firstmate/pull/8" + + set +e + PATH="$fakebin:$PATH" FAKE_GH_STATE=OPEN FAKE_GH_LOG="$case_dir/gh.log" \ + run_fm_merge_local "$case_dir" task-pro + rc=$? + set -e + + expect_code 3 "$rc" "pr-open: a PR that does not read back merged must exit 3" + assert_equals "$(git -C "$fm_root" rev-parse main)" "$(git -C "$remote" rev-parse main)" \ + "pr-open: the fork was not updated" + assert_grep "does not read back as merged (state=OPEN)" "$case_dir/stderr" \ + "pr-open: the unproved merge was not reported" + assert_no_grep "verified:" "$case_dir/stdout" \ + "pr-open: an unproved merge was claimed" + pass "fm-merge-local reports a recorded PR that does not read back merged" +} + +test_firstmate_repo_never_pushes_unproven_origin() { + local case_dir fm_root remote rc remote_before + case_dir="$TMP_ROOT/fm-push-unproven" + remote=$(make_fm_repo_with_origin "$case_dir" task-unp) + fm_root="$case_dir/firstmate" + # A leftover fork remote is the pre-remap shape: origin may still be the parent. + git -C "$fm_root" remote add fork "file://$case_dir/elsewhere.git" + remote_before=$(git -C "$remote" rev-parse main) + fm_write_meta "$case_dir/state/task-unp.meta" \ + "window=fm-task-unp" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" + + set +e + run_fm_merge_local "$case_dir" task-unp + rc=$? + set -e + + expect_code 3 "$rc" "push-unproven: an unproven origin must exit 3" + assert_equals "$remote_before" "$(git -C "$remote" rev-parse main)" \ + "push-unproven: origin main moved" + assert_grep "origin is not proven to be our fork" "$case_dir/stderr" \ + "push-unproven: the refusal was not explained" + pass "fm-merge-local never pushes to an origin that may be the parent" +} + test_shared_firstmate_repo_predicate_contract test_fast_forward_local_only_project test_fast_forward_no_mistakes_firstmate_repo @@ -510,3 +725,9 @@ test_refuses_missing_branch test_refuses_dirty_project test_refuses_off_default_project test_refuses_diverged_branch +test_firstmate_repo_pushes_fork +test_firstmate_repo_refuses_non_fast_forward_push +test_firstmate_repo_reports_push_failure +test_firstmate_repo_reads_pr_back_merged +test_firstmate_repo_reports_pr_not_merged +test_firstmate_repo_never_pushes_unproven_origin From c85a18d188f3059d23f086bd39823b33beffd2a0 Mon Sep 17 00:00:00 2001 From: PP <121104417+BohnBawerick@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:14:35 +0800 Subject: [PATCH 2/5] no-mistakes(review): Prove fork destinations and narrow PR read-back --- AGENTS.md | 2 +- bin/fm-merge-local.sh | 55 ++++++++------ docs/architecture.md | 2 +- docs/configuration.md | 2 +- tests/fm-merge-local.test.sh | 136 +++++++++++++++++++++++++++++++++-- 5 files changed, 165 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b3ab12dfb24..25f0aefad92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Shared tracked material is `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, `.tasks. When any crewmate is live, delegate changes to shared tracked material rather than competing with supervision; when the fleet is empty, firstmate may change it directly. This repo is a shared template, while `.env`, `data/`, `state/`, `config/`, `projects/`, and `.no-mistakes/` are captain-private and gitignored. Ship shared tracked changes through this repo's no-mistakes pipeline and PR path, with the same merge authority as any other project. -Firstmate repo tasks land into this home's local `main` via `bin/fm-merge-local.sh` once approved, and that landing also fast-forwards the landing remote's `main`, so the fork holds what this home runs and its PR reads back merged; `docs/configuration.md` owns which remote that is. +Firstmate repo tasks land into this home's local `main` via `bin/fm-merge-local.sh` once approved, and a home with a configured fork also fast-forwards the landing remote's `main`, so the fork holds what this home runs and its GitHub PR reads back merged; `docs/configuration.md` owns which remote that is. Never add an agent name as a commit co-author. Use `gh-axi` for GitHub, `chrome-devtools-axi` for browser work, and compatible `lavish-axi` for visual decisions or reports; consult current help rather than memorizing flags. diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index 123894cd933..33df978045c 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -18,14 +18,14 @@ # # For firstmate's own repository (bin/fm-self-repo-lib.sh), the landing also # updates the fork: after the local fast-forward it pushes local default to the -# landing remote `origin` (bin/fm-landing-remote.sh owns that `origin` is ours, -# and its drift check must pass first) as a plain fast-forward, never forced and -# never anywhere else, then reads the remote branch back. When the task records -# a pr=, that PR must then read back merged through the same forge read -# bin/fm-pr-merge.sh uses, retried a bounded number of times while the forge -# catches up (FM_MERGE_LOCAL_READBACK_DELAY seconds apart, 0-10, default 3). -# A home whose checkout has no `origin` remote has no fork and says so. -# Exit status: 0 landed (and, for firstmate's own repository, synced and proved); +# landing remote `origin` only after bin/fm-landing-remote.sh proves the remap +# and every effective origin push URL names that same remote, as a plain +# fast-forward, never forced and never anywhere else, then reads the remote +# branch back. When the task records a GitHub pr=, that PR must then read back +# merged through the same forge read bin/fm-pr-merge.sh uses, retried a bounded +# number of times while the forge catches up. A checkout with no `upstream` +# remote has no configured fork, so it reports that nothing was pushed. +# Exit status: 0 landed (and any configured fork was synced and proved); # 3 landed locally but the fork sync or PR read-back was not proved - the local # landing stays, and the message names why and the exact command to finish; # any other non-zero value means nothing was landed. @@ -196,16 +196,33 @@ fork_not_synced() { # [...] exit 3 } -if ! git -C "$PROJ" remote get-url "$FORK_REMOTE" >/dev/null 2>&1; then - echo "no $FORK_REMOTE remote in $PROJ: this home has no fork to update" +if ! git -C "$PROJ" remote get-url upstream >/dev/null 2>&1; then + echo "no fork is configured in $PROJ because there is no upstream remote; nothing was pushed" exit 0 fi +if ! git -C "$PROJ" remote get-url "$FORK_REMOTE" >/dev/null 2>&1; then + fork_not_synced "$FORK_REMOTE is absent from this remapped checkout" \ + "Repair the remotes, then finish with: $FINISH" +fi # bin/fm-landing-remote.sh owns whether origin is the landing remote rather # than the parent we forked from; any doubt keeps the push from happening. if ! verify_output=$("$SCRIPT_DIR/fm-landing-remote.sh" verify --repo "$PROJ" 2>&1); then fork_not_synced "$FORK_REMOTE is not proven to be our fork: $verify_output" \ "Repair the remotes as described, then finish with: $FINISH" fi +if ! push_urls=$(git -C "$PROJ" remote get-url --push --all "$FORK_REMOTE" 2>&1) \ + || [ -z "$push_urls" ]; then + fork_not_synced "could not resolve where $FORK_REMOTE would push: $push_urls" \ + "Repair remote.$FORK_REMOTE.pushurl, then finish with: $FINISH" +fi +while IFS= read -r push_url; do + if ! push_verify=$("$SCRIPT_DIR/fm-landing-remote.sh" verify --ours "$push_url" --repo "$PROJ" 2>&1); then + fork_not_synced "$FORK_REMOTE would push to $push_url rather than its verified fetch URL: $push_verify" \ + "Remove or correct remote.$FORK_REMOTE.pushurl, then finish with: $FINISH" + fi +done <&1); then fork_not_synced "could not read $FORK_REMOTE/$DEFAULT: $remote_line" \ "Finish with: $FINISH" @@ -243,32 +260,24 @@ fi # A recorded PR is proved merged by the same forge read the merge path uses. PR_URL=$(grep '^pr=' "$META" | tail -n 1 | cut -d= -f2- || true) [ -n "$PR_URL" ] || exit 0 -if ! fm_pr_url_parse "$PR_URL"; then - echo "error: $FORK_REMOTE/$DEFAULT now holds local $DEFAULT, but the recorded PR '$PR_URL' is not a URL this script can read back" >&2 - exit 3 +if ! fm_pr_url_parse "$PR_URL" || [ "$FM_PR_PROVIDER" != github ]; then + echo "$FORK_REMOTE/$DEFAULT now holds local $DEFAULT; PR state was not checked because '$PR_URL' is not a GitHub pull request" + exit 0 fi # The forge marks a PR merged shortly after its head reaches the base branch, # so the read is retried a bounded number of times. -readback_delay=${FM_MERGE_LOCAL_READBACK_DELAY:-3} -case "$readback_delay" in - [0-9] | 10) ;; - *) readback_delay=3 ;; -esac readback_attempt=1 while :; do FM_PR_RECORD_STATE= FM_PR_RECORD_MERGED= - case "$FM_PR_PROVIDER" in - github) fm_pr_github_read_record "$FM_PR_OWNER" "$FM_PR_REPO" "$FM_PR_NUMBER" || true ;; - gitlab) fm_pr_gitlab_read_record "$FM_PR_HOST" "$FM_PR_PATH" "$FM_PR_NUMBER" || true ;; - esac + fm_pr_github_read_record "$FM_PR_OWNER" "$FM_PR_REPO" "$FM_PR_NUMBER" || true [ "$FM_PR_RECORD_MERGED" != true ] || break if [ "$readback_attempt" -ge 5 ]; then echo "error: $FORK_REMOTE/$DEFAULT now holds local $DEFAULT, but $PR_URL does not read back as merged (state=${FM_PR_RECORD_STATE:-unreadable})" >&2 echo "Re-check that PR on the forge; the fork itself is up to date." >&2 exit 3 fi - sleep "$readback_delay" + sleep 3 readback_attempt=$((readback_attempt + 1)) done echo "verified: $PR_URL is merged" diff --git a/docs/architecture.md b/docs/architecture.md index 7614c50ec05..159f4fec3e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -428,7 +428,7 @@ The project-owned quality-gate contract and the receipt a hardened run must emit [`bin/fm-quality.sh`](../bin/fm-quality.sh) runs those commands, enforces the contract's bounds including its wall-clock one, and writes each phase's receipt; its header owns the round shape, the outcome-to-exit-code table, and the read-only mode that reports a standard task's scores without gating anything. A task recorded `quality=hardened` is not done until that receipt exists and passes, so `bin/fm-crew-state.sh` filters every `done` verdict through that script's own status verdict and leaves every other posture's line exactly as it was. `local-only` tasks and Firstmate's own repository tasks land into the local default branch through `bin/fm-merge-local.sh`. -For Firstmate's own repository that landing also fast-forwards the landing remote's default branch, so the fork never falls behind what the home runs; a `local-only` project has no remote and is never pushed. +For Firstmate's own repository with a configured fork, that landing also fast-forwards the landing remote's default branch, so the fork never falls behind what the home runs; a `local-only` project has no remote and is never pushed. After the forge accepts firstmate's merge request, the merge path persists the resolved away or attended authority bound to the task's canonical PR identity; while an away record exists any green merge runs under away authority, while a quiet record keeps attended authority, and which merge the captain's away words meant is the supervision session's reading. A later merged poll consumes only that matching persisted value; with no match it records the landing as external rather than consulting a live away-posture record that may have been archived or replaced. [`bin/fm-merge-authority-lib.sh`](../bin/fm-merge-authority-lib.sh)'s header owns resolution, private atomic persistence, identity-checked consumption, and retirement, while only the merge path gates on the answer. diff --git a/docs/configuration.md b/docs/configuration.md index 4ac2d9ecf9d..6d4ce1c1034 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -151,7 +151,7 @@ Given `--ours` it is an identity check on `origin`; with no `--ours` it asserts `bin/fm-bootstrap.sh` runs the second form against the firstmate primary at every session start and relays a refusal as one `LANDING_REMOTE:` line, so a checkout that drifted back toward the parent - or that was never remapped at all - is surfaced there rather than discovered by a branch, a push, or a PR that went to the wrong repository. A clone with neither an `upstream` nor a `fork` remote never had a parent to be remapped away from, so the check passes silently for it. -Every approved Firstmate landing updates this remote: `bin/fm-merge-local.sh` pushes local `main` to `origin`'s `main` as a fast-forward once that `verify` passes, and its header owns the push, the PR read-back, and how an unsynced fork is reported. +In a remapped checkout, every approved Firstmate landing updates this remote: `bin/fm-merge-local.sh` pushes local `main` to `origin`'s `main` as a fast-forward once that `verify` passes and every effective push URL matches `origin`, and its header owns the push, the PR read-back, and how an unsynced fork is reported. ## Calm preference (config/calm) diff --git a/tests/fm-merge-local.test.sh b/tests/fm-merge-local.test.sh index b8ea6e16879..c52260ab3cf 100755 --- a/tests/fm-merge-local.test.sh +++ b/tests/fm-merge-local.test.sh @@ -23,7 +23,9 @@ # push the remote rejects # (o) Firstmate's own repository reads a recorded PR back as merged, and # reports, exit 3, one that does not read back merged -# (p) Firstmate's own repository never pushes when origin may be the parent +# (p) Firstmate's own repository skips a checkout with no configured fork +# (q) Firstmate's own repository refuses an incomplete or unsafe fork remap +# (r) Firstmate's own repository does not read back a non-GitHub PR # # "Firstmate's own repository" is one shared predicate, bin/fm-self-repo-lib.sh, # used identically by fm-merge-local.sh, fm-pr-merge.sh, fm-fleet-sync.sh, and @@ -505,13 +507,18 @@ test_refuses_diverged_branch() { pass "fm-merge-local refuses when branch has diverged (not a fast-forward)" } -# A firstmate repository whose origin is a local bare clone, plus one ship branch -# ahead of main. Prints the bare remote's path. +# A remapped firstmate repository whose origin and upstream are local bare +# clones, plus one ship branch ahead of main. Prints origin's path. make_fm_repo_with_origin() { - local case_dir=$1 id=$2 fm_root="$1/firstmate" remote="$1/origin.git" + local case_dir=$1 id=$2 fm_root="$1/firstmate" remote="$1/origin.git" upstream="$1/upstream.git" mkdir -p "$case_dir/state" make_repo "$fm_root" main fm_git_add_origin "$fm_root" "$remote" + git clone --quiet --bare "$fm_root" "$upstream" + git -C "$fm_root" remote add upstream "file://$upstream" + git -C "$fm_root" config checkout.defaultRemote origin + git -C "$fm_root" config remote.pushDefault origin + git -C "$fm_root" config remote.origin.gh-resolved base git -C "$fm_root" checkout -b "fm/$id" --quiet echo "firstmate feature $id" >> "$fm_root/file.txt" git -C "$fm_root" commit --quiet -am "firstmate fix $id" @@ -532,6 +539,7 @@ case "${FAKE_GH_STATE:-MERGED}" in esac SH chmod +x "$fakebin/gh" + fm_fake_exit0 "$fakebin" sleep printf '%s\n' "$fakebin" } @@ -540,7 +548,6 @@ run_fm_merge_local() { # FM_ROOT_OVERRIDE="$fm_root" \ FM_HOME="$fm_root" \ FM_STATE_OVERRIDE="$case_dir/state" \ - FM_MERGE_LOCAL_READBACK_DELAY=0 \ run_merge_local "$2" > "$case_dir/stdout" 2> "$case_dir/stderr" } @@ -687,12 +694,71 @@ test_firstmate_repo_reports_pr_not_merged() { pass "fm-merge-local reports a recorded PR that does not read back merged" } +test_firstmate_repo_skips_single_origin_without_fork() { + local case_dir fm_root remote rc remote_before + case_dir="$TMP_ROOT/fm-no-configured-fork" + fm_root="$case_dir/firstmate" + remote="$case_dir/origin.git" + mkdir -p "$case_dir/state" + make_repo "$fm_root" main + fm_git_add_origin "$fm_root" "$remote" + git -C "$fm_root" checkout -b fm/task-no-fork --quiet + echo "firstmate feature" >> "$fm_root/file.txt" + git -C "$fm_root" commit --quiet -am "firstmate fix" + git -C "$fm_root" checkout main --quiet + remote_before=$(git -C "$remote" rev-parse main) + fm_write_meta "$case_dir/state/task-no-fork.meta" \ + "window=fm-task-no-fork" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" + + set +e + run_fm_merge_local "$case_dir" task-no-fork + rc=$? + set -e + + expect_code 0 "$rc" "no-configured-fork: a single-origin checkout should succeed" + assert_equals "$remote_before" "$(git -C "$remote" rev-parse main)" \ + "no-configured-fork: origin was pushed" + assert_grep "no fork is configured" "$case_dir/stdout" \ + "no-configured-fork: the skipped push was not explained" + assert_grep "nothing was pushed" "$case_dir/stdout" \ + "no-configured-fork: the output did not say that nothing was pushed" + pass "fm-merge-local skips a checkout with no configured fork" +} + +test_firstmate_repo_refuses_missing_origin_after_remap() { + local case_dir fm_root upstream rc upstream_before + case_dir="$TMP_ROOT/fm-remap-missing-origin" + make_fm_repo_with_origin "$case_dir" task-missing-origin >/dev/null + fm_root="$case_dir/firstmate" + upstream="$case_dir/upstream.git" + upstream_before=$(git -C "$upstream" rev-parse main) + git -C "$fm_root" remote remove origin + fm_write_meta "$case_dir/state/task-missing-origin.meta" \ + "window=fm-task-missing-origin" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" + + set +e + run_fm_merge_local "$case_dir" task-missing-origin + rc=$? + set -e + + expect_code 3 "$rc" "missing-origin: an incomplete remap must exit 3" + assert_equals "$upstream_before" "$(git -C "$upstream" rev-parse main)" \ + "missing-origin: upstream main moved" + assert_grep "fork not updated" "$case_dir/stderr" \ + "missing-origin: the unsynced fork was not reported" + assert_grep "origin is absent" "$case_dir/stderr" \ + "missing-origin: the incomplete remap was not explained" + pass "fm-merge-local reports a remapped checkout whose origin is missing" +} + test_firstmate_repo_never_pushes_unproven_origin() { local case_dir fm_root remote rc remote_before case_dir="$TMP_ROOT/fm-push-unproven" remote=$(make_fm_repo_with_origin "$case_dir" task-unp) fm_root="$case_dir/firstmate" - # A leftover fork remote is the pre-remap shape: origin may still be the parent. + # A leftover fork remote means the remap is incomplete. git -C "$fm_root" remote add fork "file://$case_dir/elsewhere.git" remote_before=$(git -C "$remote" rev-parse main) fm_write_meta "$case_dir/state/task-unp.meta" \ @@ -712,6 +778,60 @@ test_firstmate_repo_never_pushes_unproven_origin() { pass "fm-merge-local never pushes to an origin that may be the parent" } +test_firstmate_repo_never_uses_mismatched_push_url() { + local case_dir fm_root remote upstream rc remote_before upstream_before origin_url upstream_url + case_dir="$TMP_ROOT/fm-mismatched-push-url" + remote=$(make_fm_repo_with_origin "$case_dir" task-push-url) + fm_root="$case_dir/firstmate" + upstream="$case_dir/upstream.git" + origin_url=$(git -C "$fm_root" remote get-url origin) + upstream_url=$(git -C "$fm_root" remote get-url upstream) + git -C "$fm_root" remote set-url --add --push origin "$origin_url" + git -C "$fm_root" remote set-url --add --push origin "$upstream_url" + remote_before=$(git -C "$remote" rev-parse main) + upstream_before=$(git -C "$upstream" rev-parse main) + fm_write_meta "$case_dir/state/task-push-url.meta" \ + "window=fm-task-push-url" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" + + set +e + run_fm_merge_local "$case_dir" task-push-url + rc=$? + set -e + + expect_code 3 "$rc" "push-url: a mismatched push URL must exit 3" + assert_equals "$remote_before" "$(git -C "$remote" rev-parse main)" \ + "push-url: origin main moved" + assert_equals "$upstream_before" "$(git -C "$upstream" rev-parse main)" \ + "push-url: upstream main moved" + assert_grep "would push to $upstream_url rather than its verified fetch URL" "$case_dir/stderr" \ + "push-url: the unsafe destination was not explained" + pass "fm-merge-local checks every configured origin push URL before pushing" +} + +test_firstmate_repo_skips_non_github_pr_readback() { + local case_dir fm_root remote rc + case_dir="$TMP_ROOT/fm-non-github-pr" + remote=$(make_fm_repo_with_origin "$case_dir" task-non-github) + fm_root="$case_dir/firstmate" + fm_write_meta "$case_dir/state/task-non-github.meta" \ + "window=fm-task-non-github" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" \ + "pr=https://gitlab.example.com/group/firstmate/-/merge_requests/9" + + set +e + run_fm_merge_local "$case_dir" task-non-github + rc=$? + set -e + + expect_code 0 "$rc" "non-github-pr: a proved fork push should succeed" + assert_equals "$(git -C "$fm_root" rev-parse main)" "$(git -C "$remote" rev-parse main)" \ + "non-github-pr: the fork was not updated" + assert_grep "PR state was not checked" "$case_dir/stdout" \ + "non-github-pr: the skipped provider read-back was not reported" + pass "fm-merge-local stops after branch proof for a non-GitHub PR" +} + test_shared_firstmate_repo_predicate_contract test_fast_forward_local_only_project test_fast_forward_no_mistakes_firstmate_repo @@ -730,4 +850,8 @@ test_firstmate_repo_refuses_non_fast_forward_push test_firstmate_repo_reports_push_failure test_firstmate_repo_reads_pr_back_merged test_firstmate_repo_reports_pr_not_merged +test_firstmate_repo_skips_single_origin_without_fork +test_firstmate_repo_refuses_missing_origin_after_remap test_firstmate_repo_never_pushes_unproven_origin +test_firstmate_repo_never_uses_mismatched_push_url +test_firstmate_repo_skips_non_github_pr_readback From 5adf1c0768f07436ebb6eaef2f074fc1fd0008e0 Mon Sep 17 00:00:00 2001 From: PP <121104417+BohnBawerick@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:31:34 +0800 Subject: [PATCH 3/5] no-mistakes(review): Refuse self-repository PR merges before forge access --- bin/fm-pr-merge.sh | 16 +++++++--------- bin/fm-self-repo-lib.sh | 2 +- docs/architecture.md | 2 +- docs/scripts.md | 2 +- tests/fm-pr-merge.test.sh | 21 +++++++++++++-------- 5 files changed, 23 insertions(+), 20 deletions(-) diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index 42186cda6d4..238e0bf3cf2 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -7,6 +7,8 @@ # host and path, so any instance works and no host is hardcoded. A Gerrit change # is refused outright: that adapter is read-only, and the refusal at the parse # below owns why. +# Firstmate's own repository is local-authoritative, so this script refuses its +# tasks before any forge read or write and directs them to bin/fm-merge-local.sh. # # Merge method on GitHub defaults to --squash when the caller passes none of # --squash, --merge, --rebase, or --method after the optional -- separator. @@ -421,6 +423,11 @@ if [ "$FM_BACKLOG_META_SPAWN_GEN" != "$MERGE_EXPECTED_SPAWN_GEN" ]; then echo "error: task $ID changed incarnation while waiting to merge; refusing" >&2 exit 1 fi +PROJ=$(grep '^project=' "$META" | cut -d= -f2- || true) +if [ -n "$PROJ" ] && [ -d "$PROJ" ] && fm_is_firstmate_repo "$PROJ" "$FM_ROOT" "$FM_HOME"; then + echo "error: task $ID is Firstmate's local-authoritative repository; use bin/fm-merge-local.sh $ID after approval instead of merging its PR" >&2 + exit 1 +fi # Reading the merge request state needs both tools. Report them together and # before anything is recorded, so a missing tool is a named prerequisite rather @@ -1489,12 +1496,3 @@ case "$outcome_rc" in printf 'actionable: merged %s but could not record the outcome for supervision\n' "$URL" >&2 ;; esac - -# Firstmate's own repository is local-authoritative: the proved merge above is -# followed by the guarded landing into this home's local main, which also pushes -# that main to the fork (bin/fm-merge-local.sh owns both). Reached only after the -# forge confirmed the merge landed. -PROJ=$(grep '^project=' "$META" | cut -d= -f2- || true) -if [ -n "$PROJ" ] && [ -d "$PROJ" ] && fm_is_firstmate_repo "$PROJ" "$FM_ROOT" "$FM_HOME"; then - "$SCRIPT_DIR/fm-merge-local.sh" "$ID" -fi diff --git a/bin/fm-self-repo-lib.sh b/bin/fm-self-repo-lib.sh index 8faa07501bd..304e797cdd6 100644 --- a/bin/fm-self-repo-lib.sh +++ b/bin/fm-self-repo-lib.sh @@ -7,7 +7,7 @@ # decisions branch on that fact and must agree exactly: # bin/fm-merge-local.sh accepts a PR-mode task for the local fast-forward, # then pushes that landing to the fork -# bin/fm-pr-merge.sh follows a merged PR with that same local landing +# bin/fm-pr-merge.sh refuses a forge merge for the local-authoritative repo # bin/fm-fleet-sync.sh leaves the checkout alone (upstream sync is manual) # bin/fm-spawn.sh refreshes a task worktree from the LOCAL default # branch instead of fetching origin diff --git a/docs/architecture.md b/docs/architecture.md index 159f4fec3e0..cd1148950be 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -399,7 +399,7 @@ When a selected delivery path calls for a diff, `bin/fm-review-diff.sh` refreshe A GitLab merge request and a Gerrit change expose no such ref, so a task recording one of those diffs the local branch under that same warning, which is its current content. Where a no-mistakes pipeline stores evidence in the repo, it publishes that PR-viewable validation evidence to an orphan evidence branch that shares no history with code branches, so it never enters the crew branch or the default branch. This repo uses that setting, and its own `.no-mistakes/` directory remains local state that stays gitignored and is rejected by CI if tracked; [`configuration.md`](configuration.md) owns the setting. -PR-based task merges go through `bin/fm-pr-merge.sh`, which records `pr=` and any available `pr_head=` through `bin/fm-pr-check.sh` before calling the forge CLI. +PR-based task merges on remote-authoritative projects go through `bin/fm-pr-merge.sh`, which records `pr=` and any available `pr_head=` through `bin/fm-pr-check.sh` before calling the forge CLI; Firstmate's local-authoritative repository is refused before any forge read or write and lands through `bin/fm-merge-local.sh`. The helper requires a full canonical URL and rejects malformed URLs or repo override flags before recording merge state. A `https://github.com///pull/` URL requires `gh` and `jq`, is merged only after live reads confirm the pull request is open, not a draft, mergeable, conflict-free, every unwaived check is green at the current head, and every unwaived check the base branch requires has reported at that head, then `gh pr merge` binds that verified head with `--match-head-commit`. A required check that never reported is absent from the checks list rather than red; [`bin/fm-pr-merge.sh`](../bin/fm-pr-merge.sh)'s header owns required-context sources, producer identity, partial-read refusals, and attended check waivers. diff --git a/docs/scripts.md b/docs/scripts.md index b8a0c282caf..51d940ec516 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -144,7 +144,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md | `fm-pr-poll.sh` | Provide the byte-static watcher program for validated pull-request, merge-request, and Gerrit-change poll sidecars | | `fm-contributions.sh` | Observe owned publications, retain exact-head judgments, measure required actors, and wake on maintainer signals | | `fm-pr-check.sh` | Record validated task `pr=` and `pr_head=` values, then atomically arm a static merge poll; refuses GitHub drafts and persistent secondmate records (see [architecture.md](architecture.md)) | -| `fm-pr-merge.sh` | Record PR metadata, merge a task's canonical full GitHub or GitLab URL, refuse a Gerrit change because firstmate never submits one, then refuse an outcome it cannot prove landed or queued | +| `fm-pr-merge.sh` | Record PR metadata and merge a remote-authoritative task's canonical full GitHub or GitLab URL, while refusing Firstmate's local-authoritative repository and Gerrit changes before forge access | | `fm-pr-state.sh` | Read-only: print one line per GitHub pull-request blocker it can see, reporting on checks that have reported rather than verdicting merge-readiness | | `fm-pr-reviewers.sh` | Read-only: suggest reviewers from GitHub's own author mapping of recent commits on a pull request's changed files, never requesting one | | `fm-merge-outcome-lib.sh` | Publish a confirmed merge's durable, role-routed supervision outcome | diff --git a/tests/fm-pr-merge.test.sh b/tests/fm-pr-merge.test.sh index ae34f99b13d..1e204461a38 100755 --- a/tests/fm-pr-merge.test.sh +++ b/tests/fm-pr-merge.test.sh @@ -2358,9 +2358,9 @@ test_secondmate_without_parent_binding_is_loud() { pass "a secondmate home that cannot report upward says so instead of merging in silence" } -test_merges_firstmate_repo_locally() { +test_refuses_firstmate_repo_before_forge_merge() { local case_dir fm_root state_dir rc before after - case_dir=$(make_case fm-pr-merge-local) + case_dir=$(make_case fm-pr-merge-refuses-local-authority) fm_root="$case_dir/firstmate" state_dir="$case_dir/state" mkdir -p "$state_dir" "$case_dir/fakebin" "$fm_root/data" @@ -2391,12 +2391,17 @@ test_merges_firstmate_repo_locally() { rc=$? set -e - expect_code 0 "$rc" "fm-pr-merge-local: fm-pr-merge should succeed: $(cat "$case_dir/stderr")" + expect_code 1 "$rc" "fm-pr-merge-local: fm-pr-merge should refuse Firstmate's repository" after=$(git -C "$fm_root" rev-parse HEAD) - [ "$before" != "$after" ] || fail "fm-pr-merge-local: local main was not advanced after remote merge" - assert_grep "merged fm/task-fmpr into local main" "$case_dir/stdout" \ - "fm-pr-merge-local: local fast-forward output was not printed" - pass "fm-pr-merge fast-forwards local main when project is Firstmate's own repository" + assert_equals "$before" "$after" \ + "fm-pr-merge-local: local main changed despite the refusal" + [ ! -s "$case_dir/gh.log" ] && [ ! -s "$case_dir/gh-axi.log" ] \ + || fail "fm-pr-merge-local: a forge command ran for the local-authoritative repository" + assert_no_grep '^pr=' "$state_dir/task-fmpr.meta" \ + "fm-pr-merge-local: PR metadata was recorded before the refusal" + assert_grep "use bin/fm-merge-local.sh task-fmpr after approval" "$case_dir/stderr" \ + "fm-pr-merge-local: the refusal did not point at the local landing command" + pass "fm-pr-merge refuses Firstmate's repository before any forge merge" } test_github_zero_exit_queue_required_refuses_with_exact_retry @@ -3885,7 +3890,7 @@ test_queued_github_merge_leaves_the_poll_armed test_distinct_merged_prs_keep_distinct_wakes test_uncommitted_marker_retry_is_never_silent test_secondmate_without_parent_binding_is_loud -test_merges_firstmate_repo_locally +test_refuses_firstmate_repo_before_forge_merge test_absent_backlog_still_merges test_unreadable_backlog_refuses_the_merge test_unreadable_backend_config_refuses_the_merge From 44276faef2b3dbeed3291d9594122f731fe56596 Mon Sep 17 00:00:00 2001 From: PP <121104417+BohnBawerick@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:46:12 +0800 Subject: [PATCH 4/5] no-mistakes(review): Validate upstream URLs and snapshot PR metadata --- bin/fm-merge-local.sh | 9 ++- tests/fm-merge-local.test.sh | 109 +++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/bin/fm-merge-local.sh b/bin/fm-merge-local.sh index 33df978045c..b9ea51f0e5c 100755 --- a/bin/fm-merge-local.sh +++ b/bin/fm-merge-local.sh @@ -99,6 +99,7 @@ fi PROJ=$(grep '^project=' "$META" | cut -d= -f2-) MODE=$(grep '^mode=' "$META" | cut -d= -f2- || true) +PR_URL=$(grep '^pr=' "$META" | tail -n 1 | cut -d= -f2- || true) 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 after approval" >&2 @@ -196,10 +197,15 @@ fork_not_synced() { # [...] exit 3 } -if ! git -C "$PROJ" remote get-url upstream >/dev/null 2>&1; then +if ! git -C "$PROJ" remote | grep -Fx upstream >/dev/null 2>&1; then echo "no fork is configured in $PROJ because there is no upstream remote; nothing was pushed" exit 0 fi +if ! upstream_url=$(git -C "$PROJ" config --local --get remote.upstream.url 2>&1) \ + || [ -z "$upstream_url" ]; then + fork_not_synced "upstream is present but has no configured URL" \ + "Repair the remotes, then finish with: $FINISH" +fi if ! git -C "$PROJ" remote get-url "$FORK_REMOTE" >/dev/null 2>&1; then fork_not_synced "$FORK_REMOTE is absent from this remapped checkout" \ "Repair the remotes, then finish with: $FINISH" @@ -258,7 +264,6 @@ else fi # A recorded PR is proved merged by the same forge read the merge path uses. -PR_URL=$(grep '^pr=' "$META" | tail -n 1 | cut -d= -f2- || true) [ -n "$PR_URL" ] || exit 0 if ! fm_pr_url_parse "$PR_URL" || [ "$FM_PR_PROVIDER" != github ]; then echo "$FORK_REMOTE/$DEFAULT now holds local $DEFAULT; PR state was not checked because '$PR_URL' is not a GitHub pull request" diff --git a/tests/fm-merge-local.test.sh b/tests/fm-merge-local.test.sh index c52260ab3cf..ecd9394265a 100755 --- a/tests/fm-merge-local.test.sh +++ b/tests/fm-merge-local.test.sh @@ -753,6 +753,35 @@ test_firstmate_repo_refuses_missing_origin_after_remap() { pass "fm-merge-local reports a remapped checkout whose origin is missing" } +test_firstmate_repo_refuses_upstream_without_url() { + local case_dir fm_root remote rc remote_before + case_dir="$TMP_ROOT/fm-remap-upstream-without-url" + remote=$(make_fm_repo_with_origin "$case_dir" task-upstream-url) + fm_root="$case_dir/firstmate" + remote_before=$(git -C "$remote" rev-parse main) + git -C "$fm_root" config --unset-all remote.upstream.url + fm_write_meta "$case_dir/state/task-upstream-url.meta" \ + "window=fm-task-upstream-url" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" + + set +e + run_fm_merge_local "$case_dir" task-upstream-url + rc=$? + set -e + + expect_code 3 "$rc" "upstream-without-url: an incomplete remap must exit 3" + assert_equals "$remote_before" "$(git -C "$remote" rev-parse main)" \ + "upstream-without-url: origin main moved" + assert_equals "$(git -C "$fm_root" rev-parse fm/task-upstream-url)" \ + "$(git -C "$fm_root" rev-parse main)" \ + "upstream-without-url: local main was not landed" + assert_grep "fork not updated" "$case_dir/stderr" \ + "upstream-without-url: the unsynced fork was not reported" + assert_grep "upstream is present but has no configured URL" "$case_dir/stderr" \ + "upstream-without-url: the malformed remap was not explained" + pass "fm-merge-local refuses a named upstream remote without a URL" +} + test_firstmate_repo_never_pushes_unproven_origin() { local case_dir fm_root remote rc remote_before case_dir="$TMP_ROOT/fm-push-unproven" @@ -832,6 +861,84 @@ test_firstmate_repo_skips_non_github_pr_readback() { pass "fm-merge-local stops after branch proof for a non-GitHub PR" } +test_firstmate_repo_uses_locked_pr_snapshot() { + local case_dir fm_root remote fakebin real_git real_sleep ready release pid i rc + case_dir="$TMP_ROOT/fm-locked-pr-snapshot" + remote=$(make_fm_repo_with_origin "$case_dir" task-pr-snapshot) + fm_root="$case_dir/firstmate" + fakebin=$(make_fake_gh "$case_dir") + real_git=$(command -v git) + real_sleep=$(command -v sleep) + ready="$case_dir/post-lock.ready" + release="$case_dir/post-lock.release" + fm_write_meta "$case_dir/state/task-pr-snapshot.meta" \ + "window=fm-task-pr-snapshot" "worktree=$case_dir/wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" "spawn_gen=original" \ + "pr=https://github.com/BohnBawerick/firstmate/pull/7" + cat > "$fakebin/git" <<'SH' +#!/usr/bin/env bash +if [ "$*" = "-C ${FM_TEST_RACE_REPO} rev-parse refs/heads/main" ]; then + output=$("$FM_TEST_REAL_GIT" "$@") || exit $? + : > "$FM_TEST_RACE_READY" + while [ ! -e "$FM_TEST_RACE_RELEASE" ]; do + "$FM_TEST_REAL_SLEEP" 0.01 + done + printf '%s\n' "$output" + exit 0 +fi +exec "$FM_TEST_REAL_GIT" "$@" +SH + chmod +x "$fakebin/git" + + PATH="$fakebin:$PATH" \ + FAKE_GH_LOG="$case_dir/gh.log" \ + FAKE_GH_STATE=MERGED \ + FM_TEST_RACE_REPO="$fm_root" \ + FM_TEST_RACE_READY="$ready" \ + FM_TEST_RACE_RELEASE="$release" \ + FM_TEST_REAL_GIT="$real_git" \ + FM_TEST_REAL_SLEEP="$real_sleep" \ + run_fm_merge_local "$case_dir" task-pr-snapshot & + pid=$! + + i=0 + while [ ! -e "$ready" ] && kill -0 "$pid" 2>/dev/null && [ "$i" -lt 500 ]; do + "$real_sleep" 0.01 + i=$((i + 1)) + done + if [ ! -e "$ready" ]; then + : > "$release" + wait "$pid" || true + fail "locked-pr-snapshot: merge did not reach the post-lock fork read: $(cat "$case_dir/stderr")" + fi + if [ -e "$case_dir/state/.control-task-pr-snapshot.lock" ]; then + : > "$release" + wait "$pid" || true + fail "locked-pr-snapshot: fork read began before the task control lock was released" + fi + + fm_write_meta "$case_dir/state/task-pr-snapshot.meta" \ + "window=fm-task-pr-snapshot-reused" "worktree=$case_dir/reused-wt" "project=$fm_root" \ + "kind=ship" "mode=no-mistakes" "spawn_gen=replacement" \ + "pr=https://github.com/BohnBawerick/firstmate/pull/99" + : > "$release" + set +e + wait "$pid" + rc=$? + set -e + + expect_code 0 "$rc" "locked-pr-snapshot: proved fork sync should succeed: $(cat "$case_dir/stderr")" + assert_equals "$(git -C "$fm_root" rev-parse main)" "$(git -C "$remote" rev-parse main)" \ + "locked-pr-snapshot: the fork was not updated" + assert_grep "number=7" "$case_dir/gh.log" \ + "locked-pr-snapshot: the original task PR was not read back" + assert_no_grep "number=99" "$case_dir/gh.log" \ + "locked-pr-snapshot: the replacement task PR was read back" + assert_grep "verified: https://github.com/BohnBawerick/firstmate/pull/7 is merged" "$case_dir/stdout" \ + "locked-pr-snapshot: the original task PR was not reported" + pass "fm-merge-local keeps the locked task PR through fork sync" +} + test_shared_firstmate_repo_predicate_contract test_fast_forward_local_only_project test_fast_forward_no_mistakes_firstmate_repo @@ -852,6 +959,8 @@ test_firstmate_repo_reads_pr_back_merged test_firstmate_repo_reports_pr_not_merged test_firstmate_repo_skips_single_origin_without_fork test_firstmate_repo_refuses_missing_origin_after_remap +test_firstmate_repo_refuses_upstream_without_url test_firstmate_repo_never_pushes_unproven_origin test_firstmate_repo_never_uses_mismatched_push_url test_firstmate_repo_skips_non_github_pr_readback +test_firstmate_repo_uses_locked_pr_snapshot From ec6a79e0758ca00bc03ea7e6fef8d37550736177 Mon Sep 17 00:00:00 2001 From: PP <121104417+BohnBawerick@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:58:15 +0800 Subject: [PATCH 5/5] no-mistakes(document): Document configured Firstmate fork synchronization --- AGENTS.md | 2 +- bin/fm-self-repo-lib.sh | 6 +++--- docs/architecture.md | 2 +- docs/configuration.md | 7 ++++++- docs/scripts.md | 2 +- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 25f0aefad92..94d609ef05a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,7 +47,7 @@ Shared tracked material is `AGENTS.md`, `README.md`, `CONTRIBUTING.md`, `.tasks. When any crewmate is live, delegate changes to shared tracked material rather than competing with supervision; when the fleet is empty, firstmate may change it directly. This repo is a shared template, while `.env`, `data/`, `state/`, `config/`, `projects/`, and `.no-mistakes/` are captain-private and gitignored. Ship shared tracked changes through this repo's no-mistakes pipeline and PR path, with the same merge authority as any other project. -Firstmate repo tasks land into this home's local `main` via `bin/fm-merge-local.sh` once approved, and a home with a configured fork also fast-forwards the landing remote's `main`, so the fork holds what this home runs and its GitHub PR reads back merged; `docs/configuration.md` owns which remote that is. +Firstmate repo tasks land through `bin/fm-merge-local.sh` once approved; [`docs/configuration.md`](docs/configuration.md#landing-remote-git-origin) owns the configured-fork sync and recorded GitHub PR read-back. Never add an agent name as a commit co-author. Use `gh-axi` for GitHub, `chrome-devtools-axi` for browser work, and compatible `lavish-axi` for visual decisions or reports; consult current help rather than memorizing flags. diff --git a/bin/fm-self-repo-lib.sh b/bin/fm-self-repo-lib.sh index 304e797cdd6..22f7f7dec1c 100644 --- a/bin/fm-self-repo-lib.sh +++ b/bin/fm-self-repo-lib.sh @@ -6,14 +6,14 @@ # operational home (FM_HOME) both turn up as a task's project directory. Four # decisions branch on that fact and must agree exactly: # bin/fm-merge-local.sh accepts a PR-mode task for the local fast-forward, -# then pushes that landing to the fork +# then syncs any configured fork # bin/fm-pr-merge.sh refuses a forge merge for the local-authoritative repo # bin/fm-fleet-sync.sh leaves the checkout alone (upstream sync is manual) # bin/fm-spawn.sh refreshes a task worktree from the LOCAL default # branch instead of fetching origin # A project that counts as firstmate for one of them and not another is how a -# worker gets reset onto a remote tip the fleet never reviewed, or how a merged -# firstmate PR silently fails to reach the running tree. One predicate here +# worker gets reset onto a remote tip the fleet never reviewed, or how a +# Firstmate landing silently fails to reach its fork. One predicate here # keeps a later fix from reaching three call sites and missing the fourth. # # Comparison is by resolved PHYSICAL path, so a symlinked home, a trailing diff --git a/docs/architecture.md b/docs/architecture.md index cd1148950be..461146e7d23 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -428,7 +428,7 @@ The project-owned quality-gate contract and the receipt a hardened run must emit [`bin/fm-quality.sh`](../bin/fm-quality.sh) runs those commands, enforces the contract's bounds including its wall-clock one, and writes each phase's receipt; its header owns the round shape, the outcome-to-exit-code table, and the read-only mode that reports a standard task's scores without gating anything. A task recorded `quality=hardened` is not done until that receipt exists and passes, so `bin/fm-crew-state.sh` filters every `done` verdict through that script's own status verdict and leaves every other posture's line exactly as it was. `local-only` tasks and Firstmate's own repository tasks land into the local default branch through `bin/fm-merge-local.sh`. -For Firstmate's own repository with a configured fork, that landing also fast-forwards the landing remote's default branch, so the fork never falls behind what the home runs; a `local-only` project has no remote and is never pushed. +[`configuration.md`](configuration.md#landing-remote-git-origin) owns the configured-fork sync that applies only to Firstmate's own repository. After the forge accepts firstmate's merge request, the merge path persists the resolved away or attended authority bound to the task's canonical PR identity; while an away record exists any green merge runs under away authority, while a quiet record keeps attended authority, and which merge the captain's away words meant is the supervision session's reading. A later merged poll consumes only that matching persisted value; with no match it records the landing as external rather than consulting a live away-posture record that may have been archived or replaced. [`bin/fm-merge-authority-lib.sh`](../bin/fm-merge-authority-lib.sh)'s header owns resolution, private atomic persistence, identity-checked consumption, and retirement, while only the merge path gates on the answer. diff --git a/docs/configuration.md b/docs/configuration.md index 6d4ce1c1034..bb1fd721a27 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -151,7 +151,12 @@ Given `--ours` it is an identity check on `origin`; with no `--ours` it asserts `bin/fm-bootstrap.sh` runs the second form against the firstmate primary at every session start and relays a refusal as one `LANDING_REMOTE:` line, so a checkout that drifted back toward the parent - or that was never remapped at all - is surfaced there rather than discovered by a branch, a push, or a PR that went to the wrong repository. A clone with neither an `upstream` nor a `fork` remote never had a parent to be remapped away from, so the check passes silently for it. -In a remapped checkout, every approved Firstmate landing updates this remote: `bin/fm-merge-local.sh` pushes local `main` to `origin`'s `main` as a fast-forward once that `verify` passes and every effective push URL matches `origin`, and its header owns the push, the PR read-back, and how an unsynced fork is reported. +In a remapped checkout, every approved Firstmate landing updates this remote. +`bin/fm-merge-local.sh` pushes the local default branch to the same branch on `origin` as a plain fast-forward only after `verify` passes and every effective push URL identifies `origin`'s verified fetch URL. +A checkout without an `upstream` remote has no configured fork, so the local landing succeeds without a push. +If a configured fork cannot be proved or synchronized, the local landing remains and the command exits non-zero with the reason and the exact push command to finish after repairing the remote. +When the task records a GitHub PR, the command also confirms that GitHub reads it as merged. +The script header owns the exact checks, read-back retries, messages, and exit statuses. ## Calm preference (config/calm) diff --git a/docs/scripts.md b/docs/scripts.md index 51d940ec516..35de18da2b0 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -80,7 +80,7 @@ The shared no-mistakes gate lifecycle boundary is summarized in [architecture.md | `fm-forge-detect.sh` | Propose a clone's forge binding from its origin remote for project-add intake, never recording it | | `fm-quality.sh` | Run a project's quality phase under its own bounds, write its receipt, report one outcome | | `fm-quality-receipt.sh` | Validate a quality-gate receipt against the D2 schema, or print that schema | -| `fm-merge-local.sh` | Fast-forward a `local-only` project or Firstmate's own repository local default branch after approval, then push Firstmate's own landing to its fork | +| `fm-merge-local.sh` | Fast-forward a `local-only` project or Firstmate's own repository local default branch after approval, then sync Firstmate's configured fork | | `fm-review-diff.sh` | Review a crewmate branch or resolved PR head against the authoritative base | | `fm-marker-lib.sh` | Compatibility entry point for the from-firstmate carrier owned by `fm-operational-input.sh` | | `fm-task-inbox-lib.sh` | Single owner of durable steering-inbox records, acknowledgement, doorbells, and the delivery-attempt ladder |