#!/usr/bin/env bash
# scripts/ship — deterministic /land: commit WIP → sync → gate → squash-merge to main.
#
# Replaces the LLM-orchestrated land steps with one script (token
# efficient + reproducible). The caller's only job is to pass a good commit
# message; everything below is mechanical. Only genuine failures (merge
# conflicts, mypy/pytest errors, a lost CAS race) exit non-zero for a human
# or the LLM to resolve.
#
# Steps, in order:
#   1. sanity      — refuse to run on main
#   2. commit WIP  — stage + commit any dirty tree
#   3. lock        — NARROW mkdir-mutex on the shared .git dir, held ONLY around
#                    the final seconds-long section (fetch · ancestor check ·
#                    forward merge · squash · CAS push · local-main ff), never
#                    across sync, lint, gates or the CI wait. A second ship
#                    WAITS for those seconds instead of squashing a stale base
#                    over the first (the shared-index clobber). Steals the lock
#                    immediately if the holder's pid is dead, else falls back
#                    to a >PRECIS_SHIP_LOCK_STEAL_MIN (5) age check (holder
#                    file missing/unparseable, or from another host where the
#                    pid can't be checked). Released explicitly after the ff.
#   4. loop        — sync → gate → ship, re-running if main moves under us:
#                    · sync   `git fetch origin main`, squash the branch to one
#                             commit (only if it has several commits or a merge),
#                             then `git rebase origin/main` (Git-Town style; the
#                             branch stays linear, no merge commit). A conflict
#                             leaves the rebase IN PROGRESS for a human to
#                             resolve (`git rebase --continue`, re-run).
#                    · gate   in the precis-dev container over THIS worktree:
#                             auto-fix ruff (amended) then ruff/format/mypy/pytest
#                             (a docs/config-only diff runs a light ruff + doc-
#                             pointer gate instead). Red gate aborts (never red).
#                             --impacted (/land) narrows pytest to testmon's
#                             affected-tests set; full suite otherwise (/go);
#                             --quick (/qland) skips pytest (ruff+mypy still run).
#                    · ship   (under the lock) squash `commit-tree` onto
#                             origin/main + a `--force-with-lease` CAS push,
#                             then the local-main ff, then the lock is released.
#                             If main advanced during the gate (ancestor check)
#                             or the lease is rejected: --quick merges origin/main
#                             forward INSIDE the lock and pushes (lint is not
#                             re-run; the squash carries a `Gate:` trailer);
#                             a full-gate mode drops the lock and re-syncs +
#                             re-gates (≤3×; remote mode: two fresh CI runs, then
#                             one hybrid local gate), and once that budget is
#                             spent lands by the same in-lock forward merge,
#                             saying so and writing no `.ship-sha` pin. A CAS
#                             lost on a forward-merge path retries fetch ·
#                             merge · push inside the lock (never a re-gate),
#                             ≤PRECIS_SHIP_INLOCK_TRIES (5), then dies. Never
#                             forces a stale tree over a moved main.
#   5. local main  — (inside the lock) fast-forward the primary's main
#   6. reset       — reset the feature branch to the shipped main: zero
#                    divergence, so the next sync is a clean fast-forward and no
#                    phantom squash-artifact conflict on already-shipped work.
#                    Then reap agent worktrees/branches this branch had absorbed.
#   7. report      — print the new main sha
#
# A full-gate ship lands an exactly-tested tree, or says it did not and pins
# nothing. It also writes `.ship-sha` (gitignored, per-worktree): the exact
# sha the gate validated, for `scripts/deploy "$(cat .ship-sha)" --pinned` so a
# sibling's --quick ship can't substitute an ungated tree between ship and
# deploy. Not written by --quick (nothing gated), bare --impacted (subset), a
# docs-only diff (the light lane — no pytest — unless --full forces the suite),
# or a budget-exhausted forward-merged land (the landed tree was never gated).
#
# Usage:  scripts/ship "fix(x): one-line summary of the branch"
#         scripts/ship            # falls back to a generated message
#         scripts/ship --impacted "…"   # /land: testmon-selected pytest, not the
#                                        # full suite (also via PRECIS_SHIP_IMPACTED=1)
#         scripts/ship --mutate "…"     # /go: additionally record per-test coverage
#                                        # contexts for scripts/mutate-diff (also via
#                                        # PRECIS_SHIP_MUTATE=1)
#         scripts/ship --full "…"       # /go: run the full local suite even when
#                                        # the diff classifies as docs-only, so the
#                                        # deploy pin is always written (also via
#                                        # PRECIS_SHIP_FULL=1). Local gate only —
#                                        # GitHub picks its own lane.
#         scripts/ship --quick "…"      # /qland: NO pytest — commit → sync →
#                                        # ruff+mypy → squash-merge (also via
#                                        # PRECIS_SHIP_QUICK=1). For burst-landing
#                                        # many worktrees; the suite is deferred to
#                                        # the next full gate (/go or bare ship).
#         scripts/ship --release "…"    # a ROUND FIX, on the coordinator's request, from a
#                                        # fresh tree cut from the RELEASE head: apply the
#                                        # tree's change (merge-base with origin/main ..
#                                        # HEAD) onto the one open origin/release/r<N>,
#                                        # then merge that release commit forward into
#                                        # main as a real --no-ff merge, both pushes in
#                                        # ONE narrow-lock section. Implies --quick (the
#                                        # pre-qland lint on this tree; the release push's
#                                        # own CI run is the gate), skips the qland drift
#                                        # guard, writes no .ship-sha, never moves
#                                        # `gated`. A conflict on either merge stops
#                                        # before any push. Refuses a change touching
#                                        # */migrations/*.sql or safe_fetch.py unless
#                                        # --allow-migration (coordinator only).
#         scripts/ship --release --allow-migration "…"   # coordinator: let a release fix
#                                        # carry a migration or safe_fetch.py change
#                                        # (squawk runs first; a number collision against
#                                        # the release head or main dies in the lock)
#         scripts/ship --remote "…"     # /land: the gate runs on GITHUB, not here —
#                                        # push the synced branch to ci/<branch>,
#                                        # wait for the full check.yml matrix
#                                        # (Linux+db, macOS, Windows), then the same
#                                        # atomic CAS squash-push. If main moves
#                                        # during the CI wait, the race is lost:
#                                        # re-sync and run a FRESH CI cycle on the
#                                        # integrated tree (~90 min) once, and
#                                        # only for a tree that adds a migration
#                                        # or touches safe_fetch.py; otherwise,
#                                        # or after that, the in-lock
#                                        # forward-merge land (no pin) so a
#                                        # burst can't loop forever. Add
#                                        # --impacted for an opt-in local impacted
#                                        # gate BEFORE burning a CI cycle. (also
#                                        # via PRECIS_SHIP_REMOTE=1) Env:
#                                        # PRECIS_REMOTE_CI_TIMEOUT_MIN (150),
#                                        # PRECIS_REMOTE_CI_RETRIES (set: that
#                                        # many retries + the hybrid local gate).
#
# The full-suite path (no --impacted/--quick) also runs the DIFF-COVERAGE gate:
# pytest runs under pytest-cov in the container, then diff-cover on the HOST
# (the warm gate container has no .git) fails the ship when changed src/ lines
# aren't executed by any test (min PRECIS_DIFF_COVER_MIN, default 90).
#
# Plain git, no git-town: this repo runs flat feature branches on main, so the
# only thing git-town did here was `fetch + merge main`. Its `ship` does
# `git checkout main`, which fails from a linked worktree (main is checked out
# in the primary) — hence the commit-tree + CAS-push plumbing below, the
# standing worktree-safe workaround.
set -euo pipefail

cd "$(dirname "$0")/.."
WORKTREE="$PWD"
BRANCH="$(git branch --show-current)"
SHIP_T0="$(date +%s)"

# --impacted (or PRECIS_SHIP_IMPACTED=1): gate pytest with testmon impact
# selection — run ONLY the tests this change affects, not the full suite. /land
# opts in (fast inner-loop ship); /go and a bare scripts/ship stay on the full
# authoritative suite before a deploy. Safe fallback baked in: with no testmon
# map (fresh worktree, first ever /land) testmon runs everything and builds the
# map, so the first impacted ship is a full run and later ones are the fast
# selection. mypy + ruff always run in full regardless of this flag.
IMPACTED="${PRECIS_SHIP_IMPACTED:-0}"
# --mutate (or PRECIS_SHIP_MUTATE=1): record per-test coverage contexts
# (--cov-context=test) during the full-suite gate and keep the .coverage
# sqlite, so a follow-up scripts/mutate-diff can run each mutant against just
# its covering tests. /go opts in; meaningless with --impacted (no coverage
# runs there).
# --quick (/qland): skip PYTEST and the diff-coverage gate. ruff + mypy +
# import contracts + DB-free hygiene tests still run — the pre-qland lint, ~3 min in the warm gate
# container, no test DB and no fleet gate slot, so it does not queue behind a
# sibling's suite. They run because their failures are not this ship's problem
# but the NEXT tree's: a burst that qlands ruff-format drift or a mypy error
# reddens every gate downstream of it, which is what actually cost hours on
# 2026-09-25 (the qland drift guard, scripts/last-gated-main-sha). PRECIS_QLAND_LINT=0 is the
# deliberate escape. Everything else (sync/merge, the narrow ship lock around
# the squash CAS push + local-main ff, branch reset) runs unchanged, so the
# merge machinery stays race-safe. Use for burst-landing many in-flight worktrees, then run
# ONE full-gate integration (/go) over the merged main.
# Flags accepted in any order.
MUTATE="${PRECIS_SHIP_MUTATE:-0}"
QUICK="${PRECIS_SHIP_QUICK:-0}"
# Set only once a --quick ship's pre-qland lint has actually passed, so the
# closing banner reports what ran rather than inferring it from flags that may
# never have been evaluated on this path.
QLAND_LINT_RAN=0
# --full (or PRECIS_SHIP_FULL=1): never take the docs-only light lane — run
# the whole local suite so the gated-sha pin is written. /go passes it: a
# docs-only /go that skipped pytest used to pin a sha the suite never ran on
# top of (gr347014), and the pin is a deploy warrant.
FULL="${PRECIS_SHIP_FULL:-0}"
# --remote (/land, or PRECIS_SHIP_REMOTE=1): the correctness gate is GitHub's
# full check.yml matrix instead of the local container. Push the synced branch
# to ci/<branch>, poll the check run to conclusion, then do the ordinary CAS
# squash-push — the --force-with-lease IS the "did anything land meanwhile"
# check, and a rejection loops back through sync + a fresh CI run, so a
# full-gate ship lands an exactly-tested tree — or, once the race budget is
# spent, says it did not and pins nothing (forward-merge land, below). The
# ship lock is never held across the CI wait (nor any gate, in any mode) —
# it is taken only around the final seconds-long section.
REMOTE="${PRECIS_SHIP_REMOTE:-0}"
# Remote race policy (user-decided 2026-09-17, gr343941 option 1): when a
# GREEN remote gate loses the CAS race (main moved during the CI wait),
# re-sync (the ship lock is not held at that point) and run a fresh CI cycle
# on the integrated tree — up to PRECIS_REMOTE_CI_RETRIES (default 2) times.
# A CI retry costs ~90 min of free public-repo minutes and touches no local
# Docker, so siblings are never starved behind it. Only when the retries
# are exhausted (a burst where main moves every few minutes) does the
# 2026-09-13 HYBRID fallback engage: re-sync and validate the integrated tree
# with the FULL LOCAL container gate (~10 min) instead, then push. The
# fallback no longer holds the ship lock (it did until 2026-10-03, when a
# hybrid gate held it to the 2 h cap); it needs one of the two fleet-wide
# gate slots (gr343941 — the FIFO slot ticket is that gripe's remaining
# scope). If even that gate loses the race, the ship lands by the in-lock
# forward merge instead of looping.
REMOTE_FALLBACK=0
REMOTE_CI_RETRIES=0
# --slow (or PRECIS_GATE_SLOW=1): run the `slow`-marked cluster in the full
# local gate too. Default is to DESELECT it — see the SLOW_ARGS block in the
# full-gate branch for why, and what covers it instead.
SLOW="${PRECIS_GATE_SLOW:-0}"
# --release: land a round fix on the open release branch and forward-merge it
# into main (design note 5, ship-gate-ci). Implies --quick; see the usage header.
RELEASE=0
ALLOW_MIGRATION=0
MSG=""
while :; do
    case "${1:-}" in
        --release)  RELEASE=1; QUICK=1; shift ;;
        --allow-migration) ALLOW_MIGRATION=1; shift ;;
        --impacted) IMPACTED=1; shift ;;
        --mutate)   MUTATE=1; shift ;;
        --quick)    QUICK=1; shift ;;
        --remote)   REMOTE=1; shift ;;
        --full)     FULL=1; shift ;;
        --slow)     SLOW=1; shift ;;
        # git-commit muscle memory: accept -m/--message rather than letting
        # the literal "-m" become the shipped subject and the real message
        # get silently dropped (gr209915).
        -m|--message) MSG="${2:-}"; shift 2 ;;
        --) shift; break ;;
        -*) printf '\033[31m✖ unknown flag %s (known: --impacted --mutate --quick --remote --full --slow --release --allow-migration -m/--message)\033[0m\n' "$1" >&2; exit 2 ;;
        *) break ;;
    esac
done

[[ "$RELEASE" == 1 && "$REMOTE" == 1 ]] && { printf '\033[31m✖ --release and --remote are contradictory (the release push'"'"'s own CI run is the gate) — drop --remote.\033[0m\n' >&2; exit 2; }
[[ "$ALLOW_MIGRATION" == 1 && "$RELEASE" != 1 ]] && { printf '\033[31m✖ --allow-migration only means something with --release.\033[0m\n' >&2; exit 2; }

[[ "$REMOTE" == 1 && "$QUICK" == 1 ]] && { printf '\033[31m✖ --remote and --quick are contradictory (remote gate vs no gate) — pick one.\033[0m\n' >&2; exit 2; }
if [[ "$REMOTE" == 1 ]] && ! command -v gh >/dev/null 2>&1; then
    printf '\033[31m✖ --remote needs the gh CLI (authenticated) to poll the check run.\033[0m\n' >&2; exit 2
fi

MSG="${MSG:-${1:-}}"
# The trailer every commit this script mints carries. It used to hardcode one
# specific model name, which made a false claim on every commit minted by any
# other model — and with many sessions shipping through this script daily, that
# is most of them. The script cannot detect the caller's model, so: a session
# that knows its own sets PRECIS_CO_AUTHOR, and the default names no model
# rather than naming the wrong one.
CO_AUTHOR="Co-Authored-By: ${PRECIS_CO_AUTHOR:-Claude <noreply@anthropic.com>}"

say() { printf '\n\033[1m▶ %s\033[0m\n' "$*"; }
die() { printf '\n\033[31m✖ %s\033[0m\n' "$*" >&2; exit 1; }

# ── gated-sha pin ───────────────────────────────────────────────────────
# `scripts/deploy` defaults to the BRANCH NAME `main` and resolves it at
# deploy time, so a sibling --quick ship landing between this ship's CAS
# push and that resolution silently substitutes an UNGATED tree for the one
# the gate just validated — the ship reports green over tree A while the
# cluster receives tree B. Record the exact sha this run gated, so /go can
# pin the deploy to it (`scripts/deploy "$(cat .ship-sha)" --pinned`).
# Per-worktree and gitignored: a sibling ship writes its own, never this one.
#
# Written ONLY after a FULL gate. --quick validated nothing, and --impacted
# ran a testmon-narrowed subset — fine as a /land inner loop, not a warrant
# to push code at the fleet. A non-full run REMOVES any stale pin up front
# rather than merely skipping the write, so an older /go's sha in this
# worktree can never be mistaken for what this tree actually gated.
SHIP_SHA_FILE="${WORKTREE}/.ship-sha"
rm -f "$SHIP_SHA_FILE"

# ── deploy-lag helper (best-effort; NEVER blocks or fails a ship — every git
# call below is guarded) ─────────────────────────────────────────────────
# `.deploy-state` (gitignored, written by a successful scripts/deploy) holds
# `<sha> <epoch>` of what's actually running on the cluster. Ship and deploy
# are deliberately decoupled (deploy is a heavy outward action, opt-in only —
# this never auto-invokes it), so main can accumulate unshipped-but-shipped-
# to-main commits nobody has deployed yet. This helper surfaces that gap, used
# both as a loud up-front warning (start of ship) and a summary (end of ship).
#   PRECIS_DEPLOY_STALE_HOURS — threshold (hours) for the START warning,
#     default 1. A future PRECIS_AUTODEPLOY_STALE=1 could opt into actually
#     invoking scripts/deploy past this threshold — not implemented here.
_deploy_lag_stats() {
    # prints "<undeployed-commit-count> <oldest-undeployed-epoch> <source>", or
    # nothing if there is nothing countable. <source> is `origin/prod` (the
    # script-moved ref every deploy path advances — canonical and shared across
    # hosts; gr471806) or `marker` (the per-machine .deploy-state, used ONLY
    # when origin/prod is absent: scripts/round deploy on another host never
    # writes it, so it can read hundreds of commits stale).
    local state_file deployed_sha count oldest src
    # Refresh best-effort first (offline/no-remote is fine: a stale local ref
    # still beats the marker).
    git fetch -q origin prod:refs/remotes/origin/prod >/dev/null 2>&1 || true
    if git rev-parse --verify -q "refs/remotes/origin/prod^{commit}" >/dev/null 2>&1; then
        deployed_sha="$(git rev-parse "refs/remotes/origin/prod^{commit}" 2>/dev/null || true)"
        src="origin/prod"
    else
        # Shared marker (git common dir) so a sibling worktree's deploy counts.
        # No legacy per-worktree fallback (gr332009). See lib/deploy-state.sh.
        [[ -f "${WORKTREE}/scripts/lib/deploy-state.sh" ]] || return 0
        . "${WORKTREE}/scripts/lib/deploy-state.sh"
        state_file="$(deploy_state_read_path "${WORKTREE}")"
        [[ -n "$state_file" && -f "$state_file" ]] || return 0
        deployed_sha="$(awk '{print $1}' "$state_file" 2>/dev/null || true)"
        src="marker"
    fi
    [[ -n "$deployed_sha" ]] || return 0
    git cat-file -e "${deployed_sha}^{commit}" 2>/dev/null || return 0
    count="$(git rev-list --count "${deployed_sha}..main" 2>/dev/null || true)"
    [[ -n "$count" && "$count" != 0 ]] || return 0
    oldest="$(git log "${deployed_sha}..main" --format=%ct 2>/dev/null | tail -1 || true)"
    [[ -n "$oldest" ]] || return 0
    printf '%s %s %s\n' "$count" "$oldest" "$src"
}

_deploy_attempt_pending() {
    # prints "<sha> <epoch> <outcome>" when the last deploy attempt never
    # recorded success (no marker, or the marker predates the attempt) — the
    # fleet's sha is unknown and lag can't honestly be counted. gr332009; see
    # lib/deploy-state.sh. <outcome> is a gr338201 addition (empty string on
    # an old-format two-field attempt file — a real crash/still-running, same
    # as before); "refused" means scripts/deploy's rollback guard tripped and
    # NO host was ever touched, a materially different (and much less
    # alarming) situation from a died-red or still-running attempt.
    local attempt marker a_sha a_ep a_outcome m_ep
    [[ -f "${WORKTREE}/scripts/lib/deploy-state.sh" ]] || return 0
    . "${WORKTREE}/scripts/lib/deploy-state.sh"
    command -v deploy_attempt_path >/dev/null 2>&1 || return 0
    attempt="$(deploy_attempt_path "${WORKTREE}")"
    [[ -n "$attempt" && -f "$attempt" ]] || return 0
    a_sha="$(awk '{print $1}' "$attempt" 2>/dev/null || true)"
    a_ep="$(awk '{print $2}' "$attempt" 2>/dev/null || true)"
    a_outcome="$(awk '{print $3}' "$attempt" 2>/dev/null || true)"
    [[ -n "$a_sha" && -n "$a_ep" ]] || return 0
    marker="$(deploy_state_read_path "${WORKTREE}")"
    if [[ -n "$marker" && -f "$marker" ]]; then
        m_ep="$(awk '{print $2}' "$marker" 2>/dev/null || true)"
        [[ -n "$m_ep" ]] && (( m_ep >= a_ep )) && return 0
    fi
    printf '%s %s %s\n' "$a_sha" "$a_ep" "${a_outcome:-attempt}"
}

_deploy_marker_missing() {
    # true when the lib is present but NO success marker exists — "no
    # successful deploy on record", distinct from a zero lag (gr332009).
    local marker
    [[ -f "${WORKTREE}/scripts/lib/deploy-state.sh" ]] || return 1
    . "${WORKTREE}/scripts/lib/deploy-state.sh"
    marker="$(deploy_state_read_path "${WORKTREE}")"
    [[ -z "$marker" ]]
}

# ── session-lock re-assertion ────────────────────────────────────────────
# Shipping is exactly what makes a worktree clean + merged, which is exactly
# the state a sibling session's SessionStart reaper removes when no live lock
# is present — so ship OPENS the window that cost two sessions their tracked
# files (docs/backlog/reaper-removed-live-session-worktree.md, proposal 3).
# ship runs INSIDE the live session, so it can prove the session is alive at
# the moment the window opens and re-take the lock from its own parent chain.
# Full rules (never steals a live lock, best-effort, never fails a caller) are
# on reassert_session_lock in scripts/lib/session-lock.sh.
#
# Guarded source, same pattern the hooks use: an older checkout without the lib
# simply gets no re-assertion. A ship must never fail over its own lock
# bookkeeping — losing the lock costs a worktree, losing the ship costs the work.
if [[ -f "${WORKTREE}/scripts/lib/session-lock.sh" ]]; then
    source "${WORKTREE}/scripts/lib/session-lock.sh"
fi
_relock() {
    command -v reassert_session_lock >/dev/null 2>&1 || return 0
    local got
    got="$(reassert_session_lock "$WORKTREE" "${PPID:-}" || true)"
    if [[ -n "$got" ]]; then
        say "re-asserted session lock → pid ${got} (tree had no live lock; clean+merged buckets safe_remove)"
    fi
    return 0
}

# ── 1. sanity ───────────────────────────────────────────────────────────
if [ -d "$(git rev-parse --git-path rebase-merge)" ] || [ -d "$(git rev-parse --git-path rebase-apply)" ]; then
    die "a rebase is in progress — finish it ('git rebase --continue') or abort it, then re-run scripts/ship."
fi
[[ -n "$BRANCH" ]] || die "detached HEAD — check out a feature branch first."
[[ "$BRANCH" != "main" ]] || die "on main — nothing to ship."

# ── 1b. per-worktree ship lock (gr335894) ────────────────────────────────
# Taken here — after the sanity checks, before the first git write (§2 WIP
# commit) — and deliberately AFTER the "── 1. sanity" marker: everything
# above it is the side-effect-free prelude that tests/test_deploy_lag_honesty
# slices out and runs standalone. See scripts/lib/ship-lock.sh for the
# incident and why this REFUSES rather than waits, unlike the repo-wide
# squash lock taken around the final section only (§3). Armed immediately on success; the
# EXIT cleanup trap (§3, _ship_exit_cleanup) replaces this one and releases it, since a
# script can only hold one EXIT trap at a time.
# shellcheck source=scripts/lib/lock-holder.sh
source "${WORKTREE}/scripts/lib/lock-holder.sh"
# shellcheck source=scripts/lib/ship-lock.sh
source "${WORKTREE}/scripts/lib/ship-lock.sh"
acquire_worktree_ship_lock "$WORKTREE" || exit 1
trap _release_worktree_ship_lock EXIT

# A lock lost BEFORE this run (a nested `claude -p`, a killed background task)
# leaves the tree exposed the instant the squash lands; repair it up front
# rather than only at the end, so the exposure never spans the whole gate.
_relock

# ── 1a. deploy-lag warning (loud, WARN-only — the "begin of next ship burst"
# moment; never blocks) ───────────────────────────────────────────────────
if pending="$(_deploy_attempt_pending)" 2>/dev/null && [[ -n "$pending" ]]; then
    read -r _att_sha _att_ep _att_outcome <<< "$pending"
    _att_age_h=$(( ( $(date +%s) - _att_ep ) / 3600 ))
    if [[ "$_att_outcome" == "refused" ]]; then
        printf '\n\033[33m⚠ last deploy attempt of %.8s (%sh ago) was REFUSED by the rollback guard (target was an ancestor of the deployed/main sha) — no host was touched; fleet is unaffected. gr338201.\033[0m\n' "$_att_sha" "$_att_age_h"
    else
        printf '\n\033[33m⚠ deploy state uncertain: a deploy of %.8s started %sh ago and never recorded success (died red or still running) — scripts/deploy to retry.\033[0m\n' "$_att_sha" "$_att_age_h"
    fi
elif stats="$(_deploy_lag_stats)" 2>/dev/null && [[ -n "$stats" ]]; then
    read -r _lag_n _lag_oldest _lag_src <<< "$stats"
    _lag_age_h=$(( ( $(date +%s) - _lag_oldest ) / 3600 ))
    _stale_h="${PRECIS_DEPLOY_STALE_HOURS:-1}"
    if (( _lag_age_h > _stale_h )); then
        printf '\n\033[33m⚠ deploy lag: oldest undeployed commit is %sh old (>%sh) — consider scripts/deploy or /go\033[0m\n' "$_lag_age_h" "$_stale_h"
    fi
fi

# ── 1b. prebuild Tailwind CSS ────────────────────────────────────────────
# Regenerate the production static stylesheet from the content scan so it
# tracks whatever classes this branch added (the app serves this file, not the
# Play CDN). Best-effort: a network/npx hiccup WARNs rather than blocking a code
# ship — the committed CSS is used as-is until the next successful rebuild.
# Deliberately NOT --minify: the file is tracked, and one-line minified output
# turns every concurrent web-touching ship into an unmergeable conflict;
# unminified is line-per-rule and auto-merges (internal app — gzip covers size).
if [[ "$RELEASE" == 1 ]]; then
    # A release fix is the fix only: a regenerated tracked stylesheet would be
    # committed as WIP and ride onto the frozen release.
    echo "release fix — Tailwind rebuild skipped (the tree holds only the fix)"
elif command -v npx >/dev/null 2>&1; then
    say "rebuilding Tailwind CSS (static production build)"
    if npx --yes tailwindcss@3 -c tailwind.config.js \
         -i src/precis_web/static/tailwind.src.css \
         -o src/precis_web/static/tailwind.css >/dev/null 2>&1; then
        echo "→ src/precis_web/static/tailwind.css"
    else
        echo "WARNING: tailwind rebuild failed — shipping the committed CSS unchanged"
    fi
else
    echo "WARNING: npx not found — skipping tailwind rebuild (committed CSS unchanged)"
fi

# ── 2. commit WIP ───────────────────────────────────────────────────────
if [[ -n "$(git status --porcelain)" ]]; then
    say "committing WIP"
    git add -A
    git commit -q -m "${MSG:-wip(${BRANCH}): end-of-session snapshot}

${CO_AUTHOR}"
fi

# Agent branches this branch took in via scripts/take-agent ("branch sha" per
# line, in this worktree's git dir). An explicit record, not `--merged HEAD`:
# ancestry also matches a live agent branched off our unshipped HEAD, and it
# vanishes once a failed run has squashed the branch. See _reap_absorbed_agents.
TAKEN_FILE="$(git rev-parse --git-path taken-agents)"
ABSORBED_AGENTS=""
if [[ "$RELEASE" != 1 && -f "$TAKEN_FILE" ]]; then
    ABSORBED_AGENTS="$(cat "$TAKEN_FILE")"
fi

# Resolve + lint the squash message NOW (cheap), before any lint/gate/CI: -m, or
# the branch's own real commit(s); never a ship(...)/wip(...) placeholder.
. "${WORKTREE}/scripts/lib/ship-subject.sh"
_base="$(git merge-base origin/main HEAD 2>/dev/null || echo origin/main)"
_msg_err="$(mktemp)"
MSG="$(ship_resolve_msg "$MSG" "$_base" 2>"$_msg_err")" || die "$(cat "$_msg_err")"
rm -f "$_msg_err"; unset _base _msg_err

# Gate-slot admission (gr202193): cap concurrent gate containers fleet-wide
# so sibling gates queue instead of OOM-killing each other against the
# shared Docker VM ceiling. Acquired/released inside run_gate. Sourced even
# on a --quick ship (which never gates): the exit trap below calls
# gate_slot_release unconditionally, and release is a no-op when no slot is
# held.
# shellcheck source=scripts/lib/lock-holder.sh
source "${WORKTREE}/scripts/lib/lock-holder.sh"
source "${WORKTREE}/scripts/lib/gate-slot.sh"

GATE_MODE=bind
GATE_INFRA_READY=0
# Deferred so remote mode can start it lazily — only if the hybrid race
# fallback actually needs a local gate (the quiet-path remote ship never
# touches a container).
setup_gate_infra() {
    [[ "$GATE_INFRA_READY" == 1 ]] && return 0
    GATE_INFRA_READY=1
    # TEST-ONLY seam (tests/test_ship_narrow_lock.py): no container when the host gate command is set.
    [[ -n "${PRECIS_SHIP_TEST_GATE_CMD:-}" ]] && return 0
# A full docker VM disk fails the lint/gate on a raw ENOSPC with no test output
# (2026-10-03, 98%): refuse up front with a named message instead.
source "${WORKTREE}/scripts/lib/docker-disk.sh"
docker_disk_preflight "scripts/ship" || exit $?
INFRA_COMPOSE="${PRECIS_COMPOSE:-${PWD}/docker/dev/compose.yaml}"
[[ -f "$INFRA_COMPOSE" ]] || die "compose file not found at ${INFRA_COMPOSE} (set PRECIS_COMPOSE)."

# Per-worktree compose project so this worktree's gate DB (precis-test-db) is
# isolated from every sibling's `scripts/test`/`scripts/ship`, instead of all
# colliding on the default project `dev` (gr176375). See
# scripts/lib/compose-project.sh.
source "${WORKTREE}/scripts/lib/compose-project.sh"
COMPOSE_PROJECT="$(compose_project_for "$WORKTREE")"
compose() { env UID="$(id -u)" GID="$(id -g)" docker compose -f "$INFRA_COMPOSE" -p "$COMPOSE_PROJECT" --profile dev "$@"; }

# Co-located tmpfs test DB: a RAM-backed pgvector on precis-dev's own compose
# network, so the gate's per-worker CREATE DATABASE clones run at memory speed
# instead of crossing container→host to the dev DB (that latency ×15 xdist
# workers was the ~150s gate; the host does the same suite in ~36s). Bring it
# up (idempotent; --wait blocks on its healthcheck) and point the gate at it.
# Falls back to whatever PRECIS_TEST_PG_URL the container already carries (the
# host DB) if the service can't start, so a broken test-db never blocks a ship.
TEST_DB_ENV=()
# The test DB exists for pytest. --quick runs ruff+mypy only (the pre-qland
# lint below), so starting a RAM-backed pgvector for it is pure waste.
if [[ "$QUICK" != 1 ]]; then
    say "starting co-located tmpfs test DB (precis-test-db)"
    if compose up -d --wait precis-test-db >/dev/null 2>&1; then
        TEST_DB_ENV=(-e "PRECIS_TEST_PG_URL=postgresql://postgres@precis-test-db:5432/precis_test")
        echo "gate DB → precis-test-db (RAM, co-located)"
    else
        echo "WARNING: precis-test-db didn't start — gate falls back to the host DB"
    fi
fi

# Warm, native-FS gate. Prefer a long-lived precis-gate container (see the
# compose service header): it stays UP across ships (no per-run container
# create) and has NO /app bind mount, so we tar-sync the worktree into its
# native overlay FS and imports/collection run at native speed instead of over
# the virtiofs bind mount (the ~73s ceiling — 6+ xdist workers each re-reading
# the whole app over that mount self-serialise to ~2x parallelism). Its
# mypy/ruff caches live on a persistent volume, so mypy is incremental across
# ships too. If it can't start we fall back to the classic per-run
# `compose run -v $WORKTREE:/app precis-dev` bind gate, so this optimisation
# can never block a ship.
say "starting warm gate container (precis-gate)"
if compose up -d --wait precis-gate >/dev/null 2>&1; then
    GATE_MODE=warm
    echo "gate → precis-gate (warm, native FS)"
else
    echo "WARNING: precis-gate didn't start — gate falls back to the per-run bind-mount container"
fi
}  # end setup_gate_infra

if [[ "$QUICK" == 1 ]]; then
    say "quick ship (--quick) — no pytest, no gate slot; ruff+mypy still run (pre-qland lint, below)"
elif [[ "$REMOTE" == 1 && "$IMPACTED" != 1 ]]; then
    # Pure remote gate: no local containers on the quiet path — GitHub's
    # matrix is the gate. The hybrid race fallback calls setup_gate_infra
    # lazily inside the loop if it engages. (--remote --impacted keeps the
    # infra up-front: local impacted pre-gate first, then the remote matrix.)
    say "remote ship (--remote) — local gate skipped; GitHub check.yml is the gate"
else
    setup_gate_infra
fi

# Mirror the worktree into the warm container's native /app. `git archive
# HEAD` streams exactly the committed tree (no .git, no caches, portable across
# BSD/GNU tar) — safe because scripts/ship has already committed WIP and synced
# by this point, so HEAD == worktree. Wipe /app first so a file deleted on the
# branch is deleted in the container too. Any failure returns non-zero so the
# caller aborts rather than gating stale source.
sync_to_container() {
    compose exec -T precis-gate sh -c 'find /app -mindepth 1 -maxdepth 1 -exec rm -rf {} +' || return 1
    git -C "$WORKTREE" archive --format=tar HEAD \
        | compose exec -T precis-gate tar -C /app --no-same-owner -xf - || return 1
}

# Bring ruff --fix/format results back out to the host worktree so the amend
# below can commit them (in bind mode ruff edits the mount directly; in warm
# mode it edits the container's native copy). Only src/ + tests/ — the trees
# ruff rewrites — and never __pycache__/.pyc noise.
sync_from_container() {
    # `set -o pipefail` (top of file) makes a dead `compose exec` fail the
    # pipeline even when the receiving tar exits 0 on a truncated stream.
    compose exec -T precis-gate \
        tar -C /app --exclude='*/__pycache__' --exclude='*.pyc' -cf - src tests \
        | tar -C "$WORKTREE" --no-same-owner -xf - || return 1
}

# Carry the testmon map (./.testmondata*, gitignored → NOT in `git archive`, so
# the warm sync above wipes it) into the warm container before the gate and back
# out after, so impacted selection keeps its persistent test↔code map across
# ships. No-op in full mode (nothing built one) and harmless if absent. In bind
# mode the map lives on the mounted worktree already, so neither is needed.
testmon_into_container() {
    ls "${WORKTREE}"/.testmondata* >/dev/null 2>&1 || return 0
    # Purge any stale container-side sidecars first — a leftover -wal/-shm
    # from a *previous* container map must never mix with the host file we're
    # about to extract (mismatched sidecars = "malformed" on the next open).
    compose exec -T precis-gate sh -c 'rm -f /app/.testmondata*'
    tar -C "$WORKTREE" -cf - .testmondata* \
        | compose exec -T precis-gate tar -C /app --no-same-owner -xf - || true
}
testmon_from_container() {
    # Glob INSIDE the container (`sh -c`): on a first-ever impacted ship the
    # host worktree has no map yet, so a local `.testmondata*` would stay
    # literal and tar would miss the file testmon just built in /app.
    compose exec -T precis-gate sh -c 'ls /app/.testmondata* >/dev/null 2>&1' || return 0
    # A red/killed gate (OOM-137, mid-run kill) can leave the sqlite db
    # torn mid-transaction with un-checkpointed -wal/-shm sidecars. Exporting
    # that as-is corrupts the host map ("database disk image is malformed"),
    # poisoning every later --impacted run until a human `rm`s the three
    # files and eats a full-length rerun. Checkpoint (folds -wal into the
    # main file, leaving one clean file) and integrity-check *inside the
    # container* first; a red rc here means the map is unsalvageable, so
    # skip the export and keep the host's last-good map instead. This check
    # runs regardless of the gate's own rc — a red gate's map is still valid
    # once it passes checkpoint+integrity, and rc==0 doesn't guarantee the
    # map wasn't torn by some other interruption either.
    if ! compose exec -T precis-gate uv run python -c '
import sqlite3
c = sqlite3.connect("/app/.testmondata")
c.execute("PRAGMA wal_checkpoint(TRUNCATE)")
ok = c.execute("PRAGMA integrity_check").fetchone()[0]
c.close()
raise SystemExit(0 if ok == "ok" else 1)
'; then
        echo "testmon map failed integrity check in container — keeping host map"
        return 0
    fi
    # Purge stale host-side sidecars before extracting: after a TRUNCATE
    # checkpoint the export is a single clean file, so a leftover host
    # -wal/-shm from a prior run must not survive to shadow it.
    rm -f "${WORKTREE}"/.testmondata*
    compose exec -T precis-gate sh -c 'cd /app && tar -cf - .testmondata*' \
        | tar -C "$WORKTREE" --no-same-owner -xf - || true
}

# Pull the full-suite gate's coverage artifacts out of the warm container's
# native /app (no bind mount, so they'd otherwise be lost on the next sync):
# coverage.xml for the host-side diff-cover gate, .coverage (present only with
# --mutate) for scripts/mutate-diff's covering-test lookup. No-op in bind mode
# (they're written to the mounted worktree directly) and when no coverage ran.
coverage_from_container() {
    compose exec -T precis-gate sh -c 'ls /app/coverage.xml >/dev/null 2>&1' || return 0
    compose exec -T precis-gate sh -c 'cd /app && tar -cf - coverage.xml $(ls .coverage 2>/dev/null)' \
        | tar -C "$WORKTREE" --no-same-owner -xf - || true
}

# Run one gate command in the chosen gate. Warm: sync worktree in → (carry the
# testmon map in for impacted mode) → exec → sync ruff fixes back (always, even
# on a red gate, so the fixes stay staged for the human) → carry the updated
# testmon map back out. Bind fallback: the classic per-run container with the
# worktree mounted at /app (the map is on the mount already). NB `env UID=…` — a
# bare `UID=…` fails (UID readonly).
run_gate() {
    # Hold a fleet-wide gate slot for exactly the heavyweight part (gr202193);
    # released on every path below so a red gate's `die` doesn't keep it.
    # NOT taken for --quick: that path runs ruff+mypy without pytest, which is
    # neither the RAM pressure the 2-slot semaphore guards nor worth queueing
    # behind someone's 2h suite — queueing is the exact congestion /qland
    # exists to avoid. gate_slot_release is a no-op when no slot is held.
    [[ "$QUICK" == 1 ]] || gate_slot_acquire
    local rc=0
    if [[ -n "${PRECIS_SHIP_TEST_GATE_CMD:-}" ]]; then
        # TEST-ONLY seam (tests/test_ship_narrow_lock.py): run this host command instead of the container gate.
        bash -c "$PRECIS_SHIP_TEST_GATE_CMD" || rc=$?
    elif [[ "$GATE_MODE" == warm ]]; then
        if ! sync_to_container; then
            gate_slot_release
            echo "✖ could not sync source into precis-gate" >&2
            return 90
        fi
        [[ "$IMPACTED" == 1 ]] && testmon_into_container
        compose exec -T precis-gate bash -lc "$1" || rc=$?
        # A failed sync-back is fatal on a green gate: ruff's autofixes stayed
        # in the container, so the tree about to be amended is not the tree
        # that passed lint (gr346536). On a red gate it is only noise.
        if ! sync_from_container; then
            if [[ "$rc" == 0 ]]; then
                gate_slot_release
                echo "✖ gate passed but could not sync src/tests back out of precis-gate" >&2
                return 91
            fi
            echo "⚠ could not sync src/tests back out of precis-gate (gate already red)" >&2
        fi
        [[ "$IMPACTED" == 1 ]] && testmon_from_container
    else
        compose run --rm --no-deps "${TEST_DB_ENV[@]+"${TEST_DB_ENV[@]}"}" \
            -v "${WORKTREE}":/app precis-dev bash -lc "$1" || rc=$?
    fi
    gate_slot_release
    return "$rc"
}

# ── 3. the ship lock (serialise the FINAL section across sibling worktrees) ─
# All worktrees on this host share one .git, so a mkdir-mutex on the common
# git dir serialises the landing: a second ship WAITS instead of squashing a
# stale base straight over the first (the [[shared-index-ship-race]] clobber
# that silently reverted shipped work). macOS has no flock(1), so use an
# atomic mkdir.
#
# NARROW (2026-10-03, ship-gate-ci design note 4): the lock is taken ONLY for
# the final section — fetch · ancestor check · forward merge (when main moved)
# · nothing-new guard · commit-tree · CAS push · local-main ff — which is
# seconds, and released explicitly right after the ff. Sync, the pre-qland
# lint, every gate (the hybrid one too), diff-coverage and the CI wait all run
# UNLOCKED. Holding it across those used to queue a qland for its whole 30-min
# limit behind seven trees, and a hybrid fallback held it through a local gate
# until the 2 h cap killed it. Correctness never rested on the lock: the
# ancestor check and the `--force-with-lease` CAS refuse a stale tree either
# way; the lock only spares siblings wasted retries.
#
# Reclaim of an abandoned lock is the shared rule in lock-holder.sh (same rule
# drives the gate-slot semaphore):
#   1. pid-dead   — the holder file records worktree + pid + host. For a
#                   holder on THIS host the pid is authoritative: gone
#                   (crashed, killed, woke from sleep mid-ship) → steal at
#                   once; alive → wait, however long the hold lasts.
#   2. age        — >PRECIS_SHIP_LOCK_STEAL_MIN (default 5; a correct hold is
#                   seconds now, it was 30 when the lock spanned the gate) —
#                   only where the pid cannot decide: a holder from another
#                   host (their pids mean nothing in our namespace), or a
#                   holder file that was never written. It used to apply to
#                   live local holders as well, which stole the lock out from
#                   under any ship past 30 minutes — routine for --mutate
#                   plus a full gate — re-opening the clobber race below.
# The release is ownership-checked (holder pid == $$), so a sibling that
# steals our lock after a long hold doesn't get its own fresh lock rm'd by our
# late exit; for the same reason the EXIT trap below may be armed BEFORE we
# hold the lock (gr202363's hazard — a SIGTERM while WAITING rm -rf'ing a lock
# we never acquired — cannot recur: a non-owner release is a no-op).
LOCKDIR="$(git rev-parse --git-common-dir)/precis-ship.lock.d"
_release_ship_lock() {
    local holder_pid
    holder_pid="$(lock_holder_pid "$LOCKDIR")"
    # Remove ONLY on a positive ownership match (holder pid == $$). A
    # missing/unparseable holder is ambiguous — it could be ours (best-effort
    # write failed) or a sibling mid-steal that hasn't written its holder yet
    # — and deleting a sibling's live lock has no recovery, while leaking
    # ours self-heals via the pid-dead/age steals above.
    if [[ "$holder_pid" == "$$" ]]; then
        rm -rf "$LOCKDIR" 2>/dev/null || true
    fi
}
# EXIT safety net, armed for the whole run (the lock is no longer held during
# gates, so this is no longer "the lock's own trap"): drop any gate slot still
# held (gr202193 — run_gate releases on its own paths, but an unexpected exit
# mid-gate must not leave the slot to age out on siblings), the repo-wide lock
# if an in-section die/kill left it held, and the per-worktree lock (gr335894 —
# a script has only one EXIT trap slot). Each release is idempotent.
_ship_exit_cleanup() {
    gate_slot_release
    _release_ship_lock
    _release_worktree_ship_lock
}
trap _ship_exit_cleanup EXIT
acquire_ship_lock() {
    local _waited=0 _last_holder="" holder holder_desc holder_pid _reason
    # Re-entrant: a second acquire from the same pid is a no-op, not a
    # self-deadlock.
    holder_pid="$(lock_holder_pid "$LOCKDIR")"
    [[ "$holder_pid" == "$$" ]] && return 0
    while ! mkdir "$LOCKDIR" 2>/dev/null; do
        holder="$(cat "$LOCKDIR/holder" 2>/dev/null || true)"
        holder_desc="${holder:-<lock present, no holder file — crashed before it could write one>}"

        if _reason="$(lock_holder_reclaim_reason "$LOCKDIR" "${PRECIS_SHIP_LOCK_STEAL_MIN:-5}")"; then
            say "stealing the ship lock — ${_reason}: ${holder_desc}"
            rm -rf "$LOCKDIR"
            continue
        fi

        # Re-announce whenever the holder CHANGES, not just once (gr343941
        # comment 9): the lock is a 3 s mkdir poll with no ticket order, so
        # it routinely passes from the holder we first saw to a fresh
        # acquirer; a log that still names the original, long-dead holder
        # reads as a hang instead of a lost handoff. An empty read after the
        # first poll is a holder mid-write (mkdir before the holder file, or a
        # truncate-then-write) or mid-release, not a new holder: skip it, or
        # one handoff announces twice (gr476902).
        if [[ "$_waited" == 0 ]]; then
            say "waiting for the ship lock — held by: ${holder_desc}"
            echo "(will steal it immediately if that process dies; a live holder on this host is waited out however long it takes)"
            _last_holder="$holder"
        elif [[ -n "$holder" && "$holder" != "$_last_holder" ]]; then
            say "ship lock changed hands — now held by: ${holder_desc}"
            _last_holder="$holder"
        fi
        _waited=1
        sleep 3
    done
    lock_holder_write "$LOCKDIR"
}

# The network calls INSIDE the lock (fetch, push) are bounded: a hung one
# would hold every sibling's landing for the full steal window. macOS ships
# no `timeout`; perl's alarm is on both platforms (same pattern as
# scripts/fleet's gh calls). rc 142 = SIGALRM. Without perl: unbounded.
LOCKED_NET_TIMEOUT="${PRECIS_SHIP_LOCKED_NET_TIMEOUT:-60}"
_locked_net() {
    if command -v perl >/dev/null 2>&1; then
        perl -e 'alarm shift; exec @ARGV' "$LOCKED_NET_TIMEOUT" "$@"
    else
        "$@"
    fi
}

# Is this `git push` output a LOST RACE (worth a retry), as opposed to a
# refusal that would only fail again? Three tiers, in order:
#   1. markers only a lost lease/ref race prints: a failed client-side lease
#      ("stale info"), a stale tip ("fetch first"), and the SERVER-side form of
#      the same race, which also reads "[remote rejected]" ("incorrect old
#      value provided", "cannot lock ref ... is at X but expected Y" — a
#      sibling moved the ref between our fetch and the receive);
#   2. a server-side hook / protected-branch rejection ("[remote rejected]",
#      "hook declined", "protected branch", GH006, pre-receive): no retry;
#   3. a plain "[rejected]" / "non-fast-forward": a race.
_push_is_lost_race() {
    if grep -qiE 'stale info|fetch first|incorrect old value|cannot lock ref' <<< "$1"; then
        return 0
    fi
    if grep -qiE 'remote rejected|hook declined|protected branch|GH006|pre-receive|update hook' <<< "$1"; then
        return 1
    fi
    grep -qiE 'non-fast-forward|\[rejected\]' <<< "$1"
}

# Print the remaining race budget and total elapsed time (called wherever a
# lost race sends the loop round again). $1 = attempt just spent.
_race_note() {
    local spent="$1" left el
    left=$(( MAX_ATTEMPTS - spent ))
    el=$(( $(date +%s) - SHIP_T0 ))
    if (( left > 0 )); then
        printf '   race budget: %s of %s attempts left; elapsed %sm%02ss\n' "$left" "$MAX_ATTEMPTS" $(( el / 60 )) $(( el % 60 ))
    else
        printf '   race budget: exhausted (%s of %s attempts spent); elapsed %sm%02ss\n' "$spent" "$MAX_ATTEMPTS" $(( el / 60 )) $(( el % 60 ))
    fi
}

# A lost race in a full-gate mode (main moved during the gate/CI wait, or the
# CAS lease was rejected) with budget left: the loop re-syncs and re-gates.
# Remote race budget by risk (2026-10-04, orchestrator: one land had cost three
# green CI runs at ~33 pushes/h). A tree that adds no migration and leaves
# safe_fetch.py alone gets ONE CI run; a lost race then lands by the in-lock
# forward merge (Gate trailer, no pin). A tree that adds a migration or touches
# safe_fetch.py gets one CI retry, since an exactly-tested land is worth a
# second gate for the two things prod cannot take back. No hybrid local gate
# by default. An explicit PRECIS_REMOTE_CI_RETRIES skips this and keeps the
# old budget.
_set_remote_budget() {
    [[ -n "${PRECIS_REMOTE_CI_RETRIES:-}" ]] && return 0
    local base risky
    base="$(git merge-base origin/main HEAD)"
    risky="$(git diff --name-only --no-renames --diff-filter=A "$base" HEAD -- '*/migrations/*.sql')"
    risky="${risky}$(git diff --name-only --no-renames "$base" HEAD -- src/precis/utils/safe_fetch.py)"
    if [[ -n "$risky" ]]; then
        REMOTE_RETRY_BUDGET=1
        say "race budget: 2 CI runs (this tree adds a migration or touches safe_fetch.py)"
    else
        REMOTE_RETRY_BUDGET=0
        say "race budget: 1 CI run; a lost race lands forward-merged (Gate trailer, no pin)"
    fi
    MAX_ATTEMPTS=$(( 1 + REMOTE_RETRY_BUDGET ))
}

# Remote mode first spends its CI retry budget (above) on fresh CI cycles, then one
# hybrid local gate (policy header near REMOTE_FALLBACK= above); neither holds
# the ship lock any more, so this only does the bookkeeping. No-op outside
# remote mode or once in fallback.
_remote_race_lost() {
    [[ "$REMOTE" == 1 && "$REMOTE_FALLBACK" != 1 ]] || return 0
    local budget="$REMOTE_RETRY_BUDGET"
    if (( REMOTE_CI_RETRIES < budget )); then
        REMOTE_CI_RETRIES=$(( REMOTE_CI_RETRIES + 1 ))
        say "race lost — the re-synced tree gets a FRESH CI run (CI retry ${REMOTE_CI_RETRIES}/${budget}; hybrid local gate after that)"
    else
        REMOTE_FALLBACK=1; IMPACTED=0
        say "race lost ${budget}× — hybrid fallback: the integrated tree gets the FULL LOCAL gate (~10 min, unlocked) instead of another CI run"
    fi
}

# Fast-forward the local main (checked out in the primary worktree) to the
# just-shipped origin/main. Runs INSIDE the lock, as the last step of the
# final section. The CAS push moved refs/heads/main on the REMOTE only; the
# local main pointer is now behind — the "GitHub is ahead of local main"
# drift after every session. refs/remotes/origin/main is shared across
# worktrees (one common dir) and was just updated by our own fetch/push, so
# no further network call is needed under the lock. `merge --ff-only` IN the
# primary moves the ref + that working tree together and safely refuses if
# the primary is dirty/diverged. Best-effort: a failure here does NOT unship
# anything (the remote is already updated).
_sync_local_main() {
    local primary
    say "syncing local main"
    primary="$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}')"
    if [[ "$(git -C "$primary" symbolic-ref --quiet --short HEAD || true)" == "main" ]]; then
        git -C "$primary" merge --ff-only origin/main \
            && echo "local main → $(git -C "$primary" rev-parse --short main)" \
            || echo "WARNING: primary main not fast-forwarded (dirty or diverged) — run 'git merge --ff-only origin/main' there by hand."
    else
        git update-ref refs/heads/main origin/main \
            && echo "local main ref → $(git rev-parse --short main)"
    fi
}

# Squawk on this ship's NEW migration SQL (sealed files are frozen and never
# re-linted). Blocking, unlike the advisories in the loop: it catches the
# operational hazards the schema-design tests can't (NOT NULL without default,
# table-rewriting type changes). Rules mooted by the single-writer migration
# window are excluded in .squawk.toml. Runs even in --quick mode: it's
# host-side and ~1s, and a migration that qlands unchecked is sealed before
# the settle-up /go could lint it. PRECIS_SQUAWK=0 is the deliberate escape
# hatch; a missing uvx WARNs rather than blocks (same posture as the
# diff-coverage gate). $1 = newline-separated file list; empty = no-op.
_squawk_new_migrations() {
    local files="$1"
    [[ -n "$files" && "${PRECIS_SQUAWK:-1}" != 0 ]] || return 0
    if command -v uvx >/dev/null 2>&1; then
        say "squawk — migration SQL lint (new files only)"
        # shellcheck disable=SC2086
        uvx -q --from 'squawk-cli>=2' squawk $files \
            || die "squawk found migration hazards — fix them (or, deliberately, PRECIS_SQUAWK=0) and re-run scripts/ship."
    else
        echo "WARNING: uvx missing — skipping the squawk migration lint"
    fi
}

# Forward merge of a moved origin/main INTO the shipping branch, INSIDE the
# lock (--quick; and a full-gate mode whose race budget is spent). The tree
# that lands is then NOT the tree that was linted/gated; the commits merged in
# passed their own checks, and a cross-commit interaction is what main's CI
# verdict and the drift guard catch. Two things that cannot be taken back or
# silently collide are refused first, and the lock is released by the EXIT
# trap on every die:
#   · safe_fetch.py touched on BOTH sides (our diff vs the tested base, and
#     the range being merged in) — the SSRF guard never merges unreviewed;
#   · a migration NUMBER we add that the merged-in range also added under a
#     different name (a duplicate would seal a ledger fork). Same rule as
#     scripts/migration-check (one number, two slugs); its fleet-wide scan is
#     not used here because an unrelated sibling worktree's collision must not
#     refuse THIS landing.
# Squawk is NOT re-run here: the files were squawked at sync, and an unbounded
# `uvx` inside the lock could wedge the fleet.
# Everything is keyed to $OLD_MAIN (captured once, right after the in-lock
# fetch), never to origin/main: refs/remotes/origin/main is shared by every
# worktree and a sibling's unlocked fetch can move it under us.
# Sets FWD_N, FWD_BASE and GATE_TRAILER (squash-message trailer: git log tells
# an exact-tested land from a forward-merged one). $1 = "quick" | "full".
# Re-entrant across in-lock retries (a lost CAS): FWD_BASE keeps the ORIGINAL
# tested base and FWD_N accumulates the commits merged over, while the two
# guards judge each merge against that round's own base (cur_base: after a
# first merge, cur_base..HEAD is only our own work, not what we merged in).
_forward_merge_in_lock() {
    local kind="$1" ours theirs n mig_ours num f cur_base round_n
    cur_base="$(git merge-base "$OLD_MAIN" HEAD)"
    round_n="$(git rev-list --count "HEAD..$OLD_MAIN")"
    [[ -n "$FWD_BASE" ]] || FWD_BASE="$cur_base"
    FWD_N=$(( FWD_N + round_n ))
    if [[ -n "$(git diff --name-only "$cur_base" HEAD -- src/precis/utils/safe_fetch.py)" \
       && -n "$(git diff --name-only "$cur_base" "$OLD_MAIN" -- src/precis/utils/safe_fetch.py)" ]]; then
        die "safe_fetch.py changed on both sides (this branch vs ${cur_base:0:8}, and the ${round_n} commit(s) on main) — refusing to forward-merge the SSRF guard unreviewed. Nothing was pushed. Re-run scripts/ship so the integrated tree is re-synced and re-gated (or merge by hand)."
    fi
    ours="$(git diff --name-only --diff-filter=A "$cur_base" HEAD | grep -E '/migrations/[^/]*\.sql$' || true)"
    if [[ -n "$ours" ]]; then
        theirs="$(git diff --name-only --diff-filter=A "$cur_base" "$OLD_MAIN" | grep -E '/migrations/[^/]*\.sql$' || true)"
        while IFS= read -r f; do
            [[ -z "$f" ]] && continue
            num="$(basename "$f" | grep -oE '^[0-9]+' || true)"
            [[ -n "$num" ]] || continue
            while IFS= read -r n; do
                [[ -z "$n" || "$n" == "$f" ]] && continue
                # Migrations are numbered PER plugin dir: 0016 in precis_se and
                # 0016 in precis_chem are different ledgers, not a collision.
                if [[ "$(dirname "$n")" == "$(dirname "$f")" \
                   && "$(basename "$n" | grep -oE '^[0-9]+' || true)" == "$num" ]]; then
                    die "migration number collision: this branch adds $(basename "$f") but origin/main (merged in since ${cur_base:0:8}) added $(basename "$n") under the same number ${num} in $(dirname "$f"). Nothing was pushed; main is unchanged. Renumber the unshipped migration to the next free number and re-run scripts/ship."
                fi
            done <<< "$theirs"
        done <<< "$ours"
        mig_ours="$ours"
    else
        mig_ours=""
    fi
    git merge --no-edit "$OLD_MAIN" \
        || die "merge conflict integrating origin/main — resolve in ${WORKTREE}, then 'git add -A && git commit' and re-run scripts/ship."
    # Advisory fleet scan (purely local: worktree dirs + ls-tree), same as the
    # sync step's. Never blocks.
    [[ -n "$mig_ours" ]] && { scripts/migration-check --quiet || true; }
    if [[ "$kind" == quick ]]; then
        if [[ "$QLAND_LINT_RAN" == 1 ]]; then
            GATE_TRAILER="Gate: lint only; forward-merged over ${FWD_N} commits; linted base ${FWD_BASE}"
        else
            GATE_TRAILER="Gate: none; forward-merged over ${FWD_N} commits; base ${FWD_BASE}"
        fi
        say "forward-merged over ${FWD_N} commits (lint not re-run) — landing the merged tree; linted base ${FWD_BASE:0:8}"
    else
        GATE_TRAILER="Gate: forward-merged over ${FWD_N} commits; tested base ${FWD_BASE}"
        FWD_UNGATED=1
        say "gated against ${FWD_BASE:0:8}, merged forward over ${FWD_N} commits — not a deploy warrant; no .ship-sha pin"
    fi
}

# ── 3a. release fix (--release) ───────────────────────────────────────────
# ship-gate-ci design note 5. Apply THIS tree's change onto the one open
# origin/release/r<N> and merge that release commit forward into main, both
# pushes in ONE narrow-lock section. Both merges run in the object store
# (`git merge-tree --write-tree`, git >= 2.40 for --merge-base), so no
# worktree juggling and nothing touches this checkout until after the pushes.
# The forward merge on main is UNGATED, like a qland: the lint ran on this
# tree, the release push's own check.yml run is the gate for the deploy, and M
# carries `Gate: release-forward; lint on <branch> <HEAD8>` so `round gate` and
# a reader of the log can tell it from a gated land. No .ship-sha, no `gated`.
RELEASE_TREE_HINT="A release fix is written in a fresh tree cut from the RELEASE head (origin/release/r<N>), not from main: then the diff is exactly the fix, the release-side merge is trivial, and the lint ran on the code that will deploy. This tree's whole diff against its merge-base with origin/main is what gets applied."
REL_BRANCH=""; REL_N=""; RELEASE_B=""; RELEASE_NEW_MIGS=""; RELEASE_DELETED_MIGS=""
OLD_REL=""; RELEASE_LANDED=""; REL_R1=""; REL_M=""

# What to tell the user about the refs when an in-lock step dies. On try 1
# nothing has left this host ("nothing was pushed"); after R1 landed and the
# main lease was lost it must say so instead, or a retry-time failure reads as
# a clean abort over a half-applied fix. $1 = text for the nothing-landed case.
_release_state_note() {
    if [[ -n "$RELEASE_LANDED" ]]; then
        printf 'release r%s has %s; main lacks the forward merge — a re-run of scripts/ship --release does only the forward merge (if that conflicts, ask the coordinator)' "$REL_N" "$RELEASE_LANDED"
    else
        printf '%s' "$1"
    fi
}

# The risky paths and the new migrations of B..HEAD. --no-renames: a rename
# shows as delete + add, so renaming safe_fetch.py or moving a sealed migration
# is refused, and a renamed migration counts as NEW for squawk and the
# collision guard. Run again after the lint (its auto-fix amend can change the
# tree). $1 = "first" prints the --allow-migration warning.
_release_scan_paths() {
    local risky
    risky="$(git diff --no-renames --name-only "$RELEASE_B" HEAD \
        | grep -E '/migrations/[^/]*\.sql$|^src/precis/utils/safe_fetch\.py$' || true)"
    RELEASE_NEW_MIGS=""
    RELEASE_DELETED_MIGS=""
    [[ -n "$risky" ]] || return 0
    if [[ "$ALLOW_MIGRATION" != 1 ]]; then
        die "a migration on a frozen release goes to the coordinator — this change touches: $(printf '%s' "$risky" | tr '\n' ' '). Those are the two things prod cannot take back and --release lands ungated. Nothing was pushed. The coordinator passes --allow-migration. ${RELEASE_TREE_HINT}"
    fi
    [[ "${1:-}" == first ]] \
        && printf '\n\033[33m⚠ --allow-migration: this release fix touches %s — squawk runs first; a migration number collision against the release head or main dies in the lock.\033[0m\n' "$(printf '%s' "$risky" | tr '\n' ' ')"
    RELEASE_NEW_MIGS="$(git diff --no-renames --name-only --diff-filter=A "$RELEASE_B" HEAD \
        | grep -E '/migrations/[^/]*\.sql$' || true)"
    # Files this fix removes (a moved migration's old name) are not collisions.
    RELEASE_DELETED_MIGS="$(git diff --no-renames --name-only --diff-filter=D "$RELEASE_B" HEAD \
        | grep -E '/migrations/[^/]*\.sql$' || true)"
    return 0
}

_release_preflight() {
    local out heads n ncommits
    out="$(git ls-remote --heads origin 'refs/heads/release/*')" \
        || die "could not read origin's release branches (git ls-remote failed) — check the network and re-run scripts/ship --release."
    heads="$(printf '%s\n' "$out" | sed -n 's#^[0-9a-f]*[[:space:]]*refs/heads/\(release/.*\)$#\1#p')"
    n="$(printf '%s' "$heads" | grep -c . || true)"
    if [[ "$n" == 0 ]]; then
        die "no open release branch on origin (release/r<N>) — nothing to apply a fix onto. The coordinator cuts one with 'scripts/round cut'; without a release, qland to main."
    elif [[ "$n" != 1 ]]; then
        die "$n release branches are open on origin ($(printf '%s' "$heads" | tr '\n' ' ')) — --release applies to exactly one. Ask the coordinator to settle or abandon the extras. Nothing was pushed."
    fi
    REL_BRANCH="$heads"
    [[ "$REL_BRANCH" =~ ^release/r([0-9]+)$ ]] \
        || die "the open release branch '${REL_BRANCH}' is not named release/r<N> — refusing. Nothing was pushed."
    REL_N="${BASH_REMATCH[1]}"
    RELEASE_B="$(git merge-base HEAD origin/main)" \
        || die "no merge-base between HEAD and origin/main — refusing. Nothing was pushed."

    ncommits="$(git rev-list --count "${RELEASE_B}..HEAD")"
    if [[ "$ncommits" == 0 ]] || git diff --quiet "$RELEASE_B" HEAD; then
        die "nothing to release — ${BRANCH} has no change against its merge-base with origin/main (${RELEASE_B:0:8}). ${RELEASE_TREE_HINT}"
    fi
    say "release fix → ${REL_BRANCH}; B = merge-base(HEAD, origin/main) = ${RELEASE_B}"
    echo "commits ${RELEASE_B:0:8}..HEAD (${ncommits}):"
    git log --oneline "${RELEASE_B}..HEAD"
    git diff --stat "$RELEASE_B" HEAD
    echo "(the whole ${RELEASE_B:0:8}..HEAD diff is applied; nothing can tell a fix from other unlanded work. ${RELEASE_TREE_HINT})"

    _release_scan_paths first
}

# merge-tree wrapper. $1 = step label, rest = merge-tree args. Sets MT_TREE;
# a conflict (exit 1) names the step and the paths and dies (lock released by
# the EXIT trap, nothing pushed).
_release_merge_tree() {
    local label="$1" out rc=0 paths
    shift
    out="$(git merge-tree --write-tree --name-only "$@" 2>&1)" || rc=$?
    MT_TREE="${out%%$'\n'*}"
    if (( rc == 1 )); then
        paths="$(printf '%s\n' "$out" | sed -n '2,/^$/p' | grep -v '^$' || true)"
        printf '\n\033[31m✖ release conflict at step "%s" — conflicted paths:\033[0m\n%s\n' "$label" "$paths" >&2
        die "release conflict — ask the coordinator (\`fleet say\`), $(_release_state_note "nothing was pushed"). Step: ${label}. $([[ -n "$RELEASE_LANDED" ]] || printf '%s and main are untouched. ' "$REL_BRANCH")${RELEASE_TREE_HINT}"
    elif (( rc != 0 )); then
        die "git merge-tree failed at step '${label}' (exit ${rc}; --write-tree --merge-base needs git >= 2.40): ${out} — $(_release_state_note "nothing was pushed")."
    fi
}

# Bounded fetch of one ref inside the lock; the tip is read from this
# worktree's own FETCH_HEAD (never the shared remote-tracking ref a sibling's
# fetch can move). Sets FETCHED_TIP. $1 = refspec.
_release_fetch_tip() {
    local rc=0
    _locked_net git fetch -q origin "$1" || rc=$?
    if (( rc == 142 )); then
        die "git fetch of $1 timed out after ${LOCKED_NET_TIMEOUT}s inside the ship lock (PRECIS_SHIP_LOCKED_NET_TIMEOUT) — lock released; $(_release_state_note "nothing pushed"). Check the network and re-run scripts/ship --release."
    elif (( rc != 0 )); then
        die "git fetch of $1 failed inside the ship lock (exit ${rc}; did the release branch get deleted?) — lock released; $(_release_state_note "nothing pushed"). Re-run scripts/ship --release."
    fi
    FETCHED_TIP="$(git rev-parse --verify -q 'FETCH_HEAD^{commit}')" \
        || die "internal: could not read FETCH_HEAD after fetching $1 — lock released; $(_release_state_note "nothing pushed"). Re-run scripts/ship --release."
}

# Migration NUMBER collision guard (per plugin dir, like the narrow lock's):
# a migration this fix adds must not share a number with a different file in
# the release head or in main. Both trees are checked.
_release_migration_guard() {
    local f dir num rev what other
    [[ -n "$RELEASE_NEW_MIGS" ]] || return 0
    while IFS= read -r f; do
        [[ -n "$f" ]] || continue
        dir="${f%/*}"
        num="$(basename "$f" | grep -oE '^[0-9]+' || true)"
        [[ -n "$num" ]] || continue
        for rev in "$OLD_REL" "$OLD_MAIN"; do
            other="$(git ls-tree --name-only "$rev" -- "${dir}/" \
                | grep -E "^${dir}/${num}_" | grep -vxF "$f" || true)"
            if [[ -n "$other" && -n "$RELEASE_DELETED_MIGS" ]]; then
                other="$(printf '%s\n' "$other" | grep -vxF "$RELEASE_DELETED_MIGS" || true)"
            fi
            if [[ -n "$other" ]]; then
                [[ "$rev" == "$OLD_REL" ]] && what="the release head ${REL_BRANCH}" || what="origin/main"
                die "migration number collision: this fix adds $(basename "$f") but ${what} carries $(basename "$other") under the same number ${num} in ${dir}. $(_release_state_note "Nothing was pushed; both refs are unchanged"). Renumber the unshipped migration and re-run."
            fi
        done
    done <<< "$RELEASE_NEW_MIGS"
}

# One leased push inside the lock: rc 0 landed, rc 1 a lost CAS (caller
# retries), anything else dies at once with git's own words (as the narrow
# lock). $1 = full ref, $2 = sha, $3 = expected old value (the lease).
_release_push() {
    local rc=0 out partial=""
    [[ -n "$RELEASE_LANDED" ]] && partial=" $(_release_state_note "")."
    out="$(_locked_net git push --force-with-lease="${1}:${3}" origin "${2}:${1}" 2>&1)" || rc=$?
    [[ -n "$out" ]] && printf '%s\n' "$out"
    (( rc == 0 )) && return 0
    if (( rc == 142 )); then
        die "git push of ${1} timed out after ${LOCKED_NET_TIMEOUT}s inside the ship lock (PRECIS_SHIP_LOCKED_NET_TIMEOUT) — lock released. The push may or may not have landed: check 'git ls-remote origin ${1}' for ${2:0:8} before re-running.${partial}"
    fi
    if ! _push_is_lost_race "$out"; then
        die "git push of ${1} failed inside the ship lock for a reason other than a lost race (exit ${rc}) — lock released. git said: ${out}${partial}"
    fi
    return 1
}

# The release-fix final section. Under the narrow lock: fetch both refs
# (bounded), compute both merges, push the release then main (each leased on
# the tip just fetched), local-main ff, release the lock. A lost CAS redoes the
# whole section in the lock (bounded, PRECIS_SHIP_INLOCK_TRIES, default 5),
# recomputing: after the release push landed, the recompute sees T_r equal to
# the release head's tree and skips it, so a lost main lease never mints a
# second release commit. The same check serves a plain re-run after a partial.
_release_land() {
    local max="${PRECIS_SHIP_INLOCK_TRIES:-5}" try=0 head_sha head8 subject
    local t_r rel_tree r1 r1_new t_m m
    head_sha="$(git rev-parse HEAD)"
    head8="${head_sha:0:8}"
    subject="${MSG%%$'\n'*}"
    subject="${subject:-fix from ${BRANCH}}"
    say "shipping release fix to ${REL_BRANCH}, forwarding into main"
    . "${WORKTREE}/scripts/lib/round-lock.sh"
    acquire_round_lock || die "release lifecycle busy — nothing pushed."
    release_journal_clear || die "release deployment journal owns the head — resume round deploy before any release fix. Nothing pushed."
    acquire_ship_lock
    while :; do
        try=$(( try + 1 ))
        _release_fetch_tip main
        OLD_MAIN="$FETCHED_TIP"
        _release_fetch_tip "refs/heads/${REL_BRANCH}"
        OLD_REL="$FETCHED_TIP"
        _release_migration_guard

        # Step 1: this tree's change (B..HEAD) onto the release head.
        _release_merge_tree "apply onto ${REL_BRANCH}" --merge-base="$RELEASE_B" "$OLD_REL" "$head_sha"
        t_r="$MT_TREE"
        rel_tree="$(git rev-parse "${OLD_REL}^{tree}")"
        r1_new=1
        if [[ "$t_r" == "$rel_tree" ]]; then
            # "Already there": the release head already carries the change
            # (a landed first push, or an earlier run of this very fix).
            r1_new=0
            r1="$OLD_REL"
            say "${REL_BRANCH} head ${OLD_REL:0:8} already carries this change — no release push"
            if git merge-base --is-ancestor "$OLD_REL" "$OLD_MAIN"; then
                say "nothing to do — ${REL_BRANCH} head ${OLD_REL:0:8} is already in main (${OLD_MAIN:0:8}); no empty merge"
                REL_R1="$OLD_REL"; REL_M=""
                _release_ship_lock
                release_round_lock
                return 0
            fi
        else
            r1="$(git commit-tree "$t_r" -p "$OLD_REL" -F - <<EOF
${MSG:-release(r${REL_N}): ${subject}}

Release: r${REL_N}; from ${BRANCH} ${head8}
${CO_AUTHOR}
EOF
)"
        fi

        # Step 2: R1 forward into main as a real merge (M, parents OLD_MAIN
        # and R1). merge-tree finds the base itself: the cut sha for the first
        # fix, the PREVIOUS release head (M's second parent) for later fixes.
        _release_merge_tree "forward merge of ${REL_BRANCH} into main" "$OLD_MAIN" "$r1"
        t_m="$MT_TREE"
        m="$(git commit-tree "$t_m" -p "$OLD_MAIN" -p "$r1" -F - <<EOF
Merge ${REL_BRANCH} fix forward: ${subject}

Gate: release-forward; lint on ${BRANCH} ${head8}
${CO_AUTHOR}
EOF
)"

        # Both trees are computed; only now does anything leave this host.
        if (( r1_new )); then
            if _release_push "refs/heads/${REL_BRANCH}" "$r1" "$OLD_REL"; then
                RELEASE_LANDED="$r1"
                say "${REL_BRANCH} → ${r1:0:8}"
            else
                if (( try >= max )); then
                    die "CAS push to ${REL_BRANCH} rejected ${max}× inside the ship lock — the release kept moving from another host. Lock released; $(_release_state_note "nothing landed"). Re-run scripts/ship --release."
                fi
                say "CAS push to ${REL_BRANCH} rejected — it moved from another host; re-fetching and recomputing in the lock (in-lock try ${try}/${max})"
                continue
            fi
        fi
        if _release_push refs/heads/main "$m" "$OLD_MAIN"; then
            REL_R1="$r1"; REL_M="$m"
            _sync_local_main
            _release_ship_lock
            release_round_lock
            return 0
        fi
        if (( try >= max )); then
            if [[ -n "$RELEASE_LANDED" ]]; then
                die "$(_release_state_note x) (CAS push to main rejected ${max}× inside the ship lock). Lock released."
            fi
            die "CAS push to main rejected ${max}× inside the ship lock — origin/main kept moving from another host. Lock released, nothing landed. Re-run scripts/ship --release."
        fi
        say "CAS push to main rejected — origin/main moved from another host; re-fetching and recomputing in the lock, no second release commit (in-lock try ${try}/${max})"
    done
}

# After the pushes (lock long released): put the thread branch right and say
# what happened. Merge origin/main in; when the tree then equals origin/main's
# the branch holds nothing but what landed, so reset to it, else warn.
_release_after_landing() {
    local url=""
    _relock
    git fetch -q origin main || true
    if [[ -n "$(git status --porcelain)" ]]; then
        echo "WARNING: worktree dirty after the release ship — skipping the branch sync."
    elif ! git merge --no-edit origin/main >/dev/null 2>&1; then
        git merge --abort 2>/dev/null || true
        echo "WARNING: merging origin/main into ${BRANCH} conflicted — branch left as it was (it holds more than the fix?). The release fix itself landed."
    elif [[ "$(git rev-parse 'HEAD^{tree}')" == "$(git rev-parse 'origin/main^{tree}')" ]]; then
        git reset --hard origin/main >/dev/null \
            && say "reset ${BRANCH} → $(git rev-parse --short origin/main) (tree equals origin/main; zero divergence)"
    else
        echo "WARNING: ${BRANCH}'s tree differs from origin/main after the merge — it holds work beyond the release fix; branch left merged, not reset."
    fi
    if [[ -z "$REL_M" ]]; then
        say "release ${REL_BRANCH}: nothing to do — already in main (release head ${REL_R1})"
        return 0
    fi
    say "release fix landed:"
    echo "R1 (release ${REL_BRANCH}): $(git log --oneline -1 "$REL_R1")"
    echo "M  (main, forward merge):  $(git log --oneline -1 "$REL_M")"
    if command -v gh >/dev/null 2>&1; then
        url="$(_locked_net gh run list --branch "$REL_BRANCH" --limit 5 --json headSha,url \
            --jq ".[] | select(.headSha==\"${REL_R1}\") | .url" 2>/dev/null | head -1 || true)"
    fi
    if [[ -n "$url" ]]; then
        echo "release CI run (the gate for the deploy): ${url}"
    else
        echo "release CI run: not visible yet — gh run list --branch ${REL_BRANCH}"
    fi
    printf '\n\033[33m⚡ the forward merge on main is UNGATED like a qland (Gate: release-forward; lint only). The release run above is the gate for the deploy; no .ship-sha was written and origin/gated was not moved.\033[0m\n'
    echo "mark the round with: scripts/round in ${REL_R1}"
}

# ── 3b. remote gate (--remote): push ci/<branch>, wait for check.yml ─────
# The branch (already synced with origin/main by the loop below) goes to the
# throwaway ref ci/<branch>; check.yml triggers on ci/** pushes and runs the
# gate shape (lint · mypy · 6 shards of Linux+db 3.13; a docs-only diff gets
# the fast-set docs lane instead of the shards; 3.12 + macOS + Windows are
# nightly-only) on exactly
# the tree the CAS push below will make main. A force-push retry to the same
# ref auto-cancels the superseded run via the workflow's per-ref concurrency
# group. Green → return 0; red/timeout → return 1 (caller dies — a red gate
# is never retried, same contract as the local gate). If no run appears
# within 10 min (GitHub incidents DROP push events and never replay them —
# observed 2026-09-13), fall back to one explicit workflow_dispatch on the ref.
remote_gate_wait() {
    local sha run_id="" status dispatched=0 t0 deadline
    sha="$(git rev-parse HEAD)"
    t0="$(date +%s)"
    deadline=$(( t0 + 60 * ${PRECIS_REMOTE_CI_TIMEOUT_MIN:-150} ))
    say "remote gate — pushing ci/${BRANCH}, waiting for check.yml on ${sha:0:8} (gate shape: lint + 6 shards, ~90 min green; docs-only diffs ~5 min)"
    git push -q -f origin "HEAD:refs/heads/ci/${BRANCH}" \
        || { echo "✖ could not push ci/${BRANCH}" >&2; return 1; }
    while :; do
        if (( $(date +%s) > deadline )); then
            echo "✖ remote gate timed out after ${PRECIS_REMOTE_CI_TIMEOUT_MIN:-150} min (run: ${run_id:-never appeared})" >&2
            return 1
        fi
        if [[ -z "$run_id" ]]; then
            run_id="$(gh run list --workflow check.yml --branch "ci/${BRANCH}" --limit 10 \
                --json databaseId,headSha \
                --jq ".[] | select(.headSha==\"${sha}\") | .databaseId" 2>/dev/null | head -1 || true)"
            if [[ -z "$run_id" ]]; then
                if (( dispatched == 0 && $(date +%s) - t0 > 600 )); then
                    say "no check run after 10 min — push event likely dropped; dispatching check.yml on ci/${BRANCH} explicitly"
                    gh workflow run check.yml --ref "ci/${BRANCH}" 2>/dev/null || true
                    dispatched=1
                fi
                sleep 20; continue
            fi
            echo "check run ${run_id} → $(gh run view "$run_id" --json url --jq .url 2>/dev/null || echo "gh run view ${run_id}")"
        fi
        status="$(gh run view "$run_id" --json status,conclusion \
            --jq '.status + " " + (.conclusion // "")' 2>/dev/null || true)"
        case "$status" in
            "completed success"*) say "remote gate GREEN (run ${run_id})"; return 0 ;;
            completed*)
                echo "✖ remote gate RED: ${status#completed } — failing jobs:" >&2
                gh run view "$run_id" --json jobs \
                    --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped" and .conclusion != null) | "  " + .name + ": " + .conclusion' >&2 2>/dev/null || true
                echo "  → gh run view ${run_id} --log-failed" >&2
                return 1 ;;
            *) sleep 60 ;;
        esac
    done
}

# Sync by rebase: squash the branch to one commit when it has several commits
# or any merge, then rebase onto origin/main. (The in-lock forward merge stays a
# merge: it is squashed immediately and the branch reset.)
_squash_and_rebase() {
    local mb n
    mb="$(git merge-base origin/main HEAD)" || die "no merge-base with origin/main."
    n="$(git rev-list --count "$mb..HEAD")"
    if [[ "$n" == 0 ]]; then
        :
    elif git diff --quiet "$mb" HEAD; then
        git reset -q --soft "$mb"
    elif [[ "$n" -gt 1 ]] || [[ -n "$(git rev-list --merges "$mb..HEAD")" ]]; then
        git reset -q --soft "$mb" \
            && git commit -q --no-verify -m "${MSG}

${CO_AUTHOR}" \
            || die "could not squash ${BRANCH} onto its merge-base."
    fi
    git rebase -q origin/main \
        || die "rebase conflict onto origin/main — resolve in ${WORKTREE}, then 'git add -A && git rebase --continue' and re-run scripts/ship (or 'git rebase --abort' to back out)."
}

# True unless the pid is certainly gone (EPERM counts as live).
_pid_live() {
    kill -0 "$1" 2>/dev/null && return 0
    case "$(kill -0 "$1" 2>&1 | tr 'A-Z' 'a-z')" in
        *"no such process"*) return 1 ;;
    esac
    return 0
}

# An agent branch taken into this branch (scripts/take-agent) looks unmerged
# forever once main holds only our squash, so nothing else would reap it.
# After the land, remove each one recorded in $TAKEN_FILE. Best-effort: never
# dies; keeps (and leaves recorded) anything dirty, moved, or held by a live
# pid other than this session.
_reap_absorbed_agents() {
    [[ "${PRECIS_NO_AUTOREAP:-0}" == 1 || -z "$ABSORBED_AGENTS" ]] && return 0
    local b sha info p lk pid own="" kept=""
    _reap_absorbed_one() {
        [[ -n "$b" ]] || return 0
        info="$(git worktree list --porcelain | awk -v b="refs/heads/${b}" 'BEGIN{RS="";FS="\n"}
            {path="";br="";lk="";for(i=1;i<=NF;i++){
                if($i~/^worktree /)path=substr($i,10)
                else if($i~/^branch /)br=substr($i,8)
                else if($i~/^locked/)lk=$i}
             if(br==b)print path "\t" lk}')"
        p="${info%%$'\t'*}"; lk="${info#*$'\t'}"
        if [[ -n "$p" ]]; then
            if [[ -n "$(git -C "$p" status --porcelain 2>/dev/null)" ]]; then
                echo "reap: keeping ${b} — worktree ${p} is dirty"; return 1
            fi
            if [[ "$(git -C "$p" rev-parse HEAD 2>/dev/null)" != "$sha" ]]; then
                echo "reap: keeping ${b} — worktree HEAD moved since take-agent"; return 1
            fi
            if [[ -n "$lk" ]]; then
                pid="$(printf '%s' "$lk" | sed -n 's/.*pid \([0-9][0-9]*\).*/\1/p')"
                if [[ -n "$pid" && "$pid" != "$own" ]] && _pid_live "$pid"; then
                    echo "reap: keeping ${b} — worktree locked by live pid ${pid}"; return 1
                fi
                git worktree unlock "$p" 2>/dev/null || true
            fi
            if ! git worktree remove "$p" >/dev/null 2>&1; then
                echo "reap: keeping ${b} — could not remove worktree ${p}"; return 1
            fi
        fi
        # Gone already (removed by hand) counts as done.
        git rev-parse -q --verify "refs/heads/${b}" >/dev/null || return 0
        if git update-ref -d "refs/heads/${b}" "$sha" 2>/dev/null; then
            say "reaped taken agent branch ${b}${p:+ (worktree ${p} removed)}"
            return 0
        fi
        echo "reap: keeping ${b} — branch moved or could not be deleted"; return 1
    }
    # Harness agent trees are locked with the PARENT session's pid — the very
    # session running this ship — so that lock is ours, not a live stranger's.
    command -v find_session_pid >/dev/null 2>&1 && own="$(find_session_pid "$$" 2>/dev/null || true)"
    while read -r b sha; do
        _reap_absorbed_one || kept="${kept}${b} ${sha}"$'\n'
    done <<< "$ABSORBED_AGENTS"
    if [[ -n "$kept" ]]; then printf '%s' "$kept" > "$TAKEN_FILE"; else rm -f "$TAKEN_FILE"; fi
    return 0
}

# ── 4. sync → gate → ship, re-running if main moves under us ────────────
# Sync, lint, every gate and the CI wait run UNLOCKED; only the final
# section at the bottom of the loop body takes the ship lock (§3), so a
# sibling's gate never queues anyone. If main moved while we were gating, the
# loop re-syncs (merge the new main) → re-gates → re-pushes, up to a few
# times, so we never squash a stale base; once that budget is spent (or at
# once, for --quick) the final section lands by an in-lock forward merge
# instead. Merge conflicts and red gates are NOT retryable — they abort
# immediately for a human to resolve.
shipped=0
# Remote mode budgets one first CI run + PRECIS_REMOTE_CI_RETRIES fresh CI
# runs + one terminating hybrid local gate; classic modes keep the 3 local
# re-gates. PRECIS_SHIP_MAX_ATTEMPTS overrides the classic budget (1 = no
# re-gate: a lost race goes straight to the forward-merge land; the test
# suite uses it).
MAX_ATTEMPTS="${PRECIS_SHIP_MAX_ATTEMPTS:-3}"
# Remote: an explicit PRECIS_REMOTE_CI_RETRIES keeps the old shape (first run
# + that many CI retries + the hybrid local gate). Without it the budget is set
# by risk on attempt 1, once the diff is known (_set_remote_budget).
REMOTE_RETRY_BUDGET="${PRECIS_REMOTE_CI_RETRIES:-2}"
[[ "$REMOTE" == 1 ]] && MAX_ATTEMPTS=$(( 2 + REMOTE_RETRY_BUDGET ))
# Set when the landed tree is a forward merge of a full-gate ship (budget
# spent): it was never gated, so no .ship-sha pin and no `gated` pointer.
FWD_UNGATED=0
FWD_BASE=""
FWD_N=0
GATE_TRAILER=""
LANDED_SHA=""
# The sha this run actually gated — $NEW on a real squash, $OLD_MAIN when the
# tree was already landed (see the nothing-new guard below). Written to
# $SHIP_SHA_FILE after the loop, full gate only.
GATED_SHA=""
for (( attempt = 1; attempt <= MAX_ATTEMPTS; attempt++ )); do
    [[ "$attempt" -gt 1 ]] && say "main moved under us — re-syncing + re-gating (attempt ${attempt}/${MAX_ATTEMPTS})"
    GATE_TRAILER=""; FWD_UNGATED=0; FWD_BASE=""; FWD_N=0

    say "syncing origin/main"
    git fetch -q origin main || die "git fetch failed — check the network/remote and re-run scripts/ship."
    if [[ "$RELEASE" == 1 ]]; then
        # No merge of main into a release-fix tree: it stays as cut from the
        # release head, so the lint runs on the code that will deploy and
        # B..HEAD stays exactly the fix.
        _release_preflight
    else
        _squash_and_rebase
    fi

    git fetch -q origin main
    CHANGED="$(git diff --name-only "$(git merge-base origin/main HEAD)" HEAD)"
    if [[ "$attempt" == 1 && "$REMOTE" == 1 && "$RELEASE" != 1 ]]; then
        _set_remote_budget
    fi

    # Classify the change: a docs/config-only ship can't break the test
    # suite, so the local gate skips the ~3-min mypy+pytest and runs only
    # ruff + the doc-pointer test (seconds); check.yml makes the same call
    # for --remote (docs/ + root *.md — a subset of this set, so a local
    # "docs-only" verdict is never narrower than GitHub's). Heuristic:
    # `merge-base..HEAD` shows only HEAD's own changes, so a
    # misclassification can only under-scope toward "not docs-only" (safe →
    # full gate). Either way the sha is NOT pinned for deploy — the suite did
    # not run on this tree; --full opts the local gate back into the suite.
    DOCS_ONLY=1
    [[ -z "$CHANGED" ]] && DOCS_ONLY=0   # empty diff → play safe, full gate
    while IFS= read -r f; do
        [[ -z "$f" ]] && continue
        case "$f" in
            *.md | docs/* | .claude/* | .gitignore | LICENSE | CITATION.cff | SECURITY.md) ;;
            *) DOCS_ONLY=0; break ;;
        esac
    done <<< "$CHANGED"
    if [[ "$FULL" == 1 && "$DOCS_ONLY" == 1 ]]; then
        if [[ "$REMOTE" == 1 && "$REMOTE_FALLBACK" != 1 ]]; then
            say "--full: docs-only diff — GitHub picks the docs lane regardless; no deploy pin from this ship (certify the landed tree with /go)"
        else
            say "--full: docs-only diff, running the whole suite anyway (deploy pin wanted)"
            DOCS_ONLY=0
        fi
    fi

    # Pre-ship advisories (never block the ship — `|| true`): a migration-number
    # collision across worktrees when this ship touches migrations, and orphaned
    # Both are also in
    # /whatneedsdoing (the fleet-wide view).
    case "$CHANGED" in
        *src/precis/migrations/*) scripts/migration-check --quiet || true ;;
    esac
    case "$CHANGED" in
        *docs/backlog/*) scripts/backlog-lint || true ;;
    esac

    # Squawk on this ship's NEW migration SQL — see _squawk_new_migrations.
    NEW_MIGRATIONS="$(git diff --name-only --diff-filter=A "$(git merge-base origin/main HEAD)" HEAD \
        | grep -E '^src/precis/migrations/[0-9]+_[^/]+\.sql$' || true)"
    # --release --allow-migration: squawk every new migration the fix carries
    # (plugin dirs too), before any lock — uvx is unbounded.
    [[ "$RELEASE" == 1 ]] && NEW_MIGRATIONS="$RELEASE_NEW_MIGS"
    _squawk_new_migrations "$NEW_MIGRATIONS"

    if [[ "$QUICK" == 1 ]]; then
        # Drift guard: the pre-qland lint below blocks on YOUR change, but it
        # cannot see that main itself has outrun its verdicts. Reto 2026-09-30,
        # "a day or two": warn at 24h since the last all-green shard matrix,
        # refuse at 48h. Time rather than commits-behind on purpose — twenty
        # docs commits in five minutes is not the risk three src commits over
        # two days is.
        #
        # An unknown age NEVER refuses. `last-gated-main-sha --age-hours`
        # prints nothing and exits 0 when it cannot look (no gh, no auth, an
        # API error), and treating that as "too old" would let a GitHub
        # outage stop every tree in the fleet from landing — strictly worse
        # than the ungated main this is guarding against. Exit 2 is the other
        # empty answer: it walked its whole window (48 h) and found no green
        # sha, which IS a known age past the refuse threshold. Before that
        # split the window equalled the threshold and the refusal could never
        # fire (backlog/release-candidate-verdicts.md).
        if [[ "$RELEASE" == 1 ]]; then
            # A release fix must not be refused because main's verdicts are
            # old: the guard is about main's last green matrix, and the
            # release push has its own full run.
            say "release fix — qland drift guard skipped"
        else
            drift_warn="${PRECIS_QLAND_DRIFT_WARN_HOURS:-24}"
            drift_refuse="${PRECIS_QLAND_DRIFT_REFUSE_HOURS:-48}"
            drift_rc=0
            drift_age="$(scripts/last-gated-main-sha --age-hours --start origin/main --hours "$drift_refuse" 2>/dev/null)" || drift_rc=$?
            if [[ -z "$drift_age" && "$drift_rc" == 2 ]]; then
                if [[ "${PRECIS_QLAND_DRIFT_OVERRIDE:-0}" == 1 ]]; then
                    say "main has no green shard verdict in the last ${drift_refuse}h — REFUSAL OVERRIDDEN by PRECIS_QLAND_DRIFT_OVERRIDE=1"
                else
                    die "main has no all-green shard matrix in the last ${drift_refuse}h (refuse at ${drift_refuse}h). The burst has outrun its verdicts: stacking another untested merge on top makes the eventual bisect worse, not better. What this needs next is a red on main fixed or a /go over the integrated main, not another qland. Deliberate override: PRECIS_QLAND_DRIFT_OVERRIDE=1."
                fi
            elif [[ -z "$drift_age" ]]; then
                say "main's last shard verdict: age unknown — not gating on it (drift guard only ever refuses on a number it actually has)"
            elif awk "BEGIN{exit !($drift_age >= $drift_refuse)}"; then
                if [[ "${PRECIS_QLAND_DRIFT_OVERRIDE:-0}" == 1 ]]; then
                    say "main's last shard verdict is ${drift_age}h old (>= ${drift_refuse}h) — REFUSAL OVERRIDDEN by PRECIS_QLAND_DRIFT_OVERRIDE=1"
                else
                    die "main's last all-green shard matrix is ${drift_age}h old (refuse at ${drift_refuse}h). The burst has outrun its verdicts: stacking another untested merge on top makes the eventual bisect worse, not better. What this needs next is a /go (full gate + deploy) over the integrated main, not another qland. Deliberate override: PRECIS_QLAND_DRIFT_OVERRIDE=1."
                fi
            elif awk "BEGIN{exit !($drift_age >= $drift_warn)}"; then
                say "⚠ main's last shard verdict is ${drift_age}h old (warn at ${drift_warn}h, refuse at ${drift_refuse}h) — a /go is due"
            fi
        fi

        # Pre-qland lint: ruff + mypy + import contracts, NO pytest. /qland
        # exists so a burst of trees doesn't each pay the pytest suite, and
        # that trade stands — but the failures that actually cost hours are
        # not test failures. On 2026-09-25 a burst left main red four
        # independent ways at once (ruff-format drift in 3 files, two mypy
        # error clusters, one stale characterization test); every one but the
        # last is caught here, in ~3 min against the warm container, with no
        # pytest and no fleet gate slot. The cost of missing them is not this
        # ship: it is that the NEXT tree's gate is red for a reason its author
        # did not write (the qland drift guard, scripts/last-gated-main-sha).
        #
        # Still deliberately deferred to the settle-up /go: anything only
        # pytest sees. That is what /qland is for.
        # Hygiene tests are the DB-free AST/text walks (~45 s together, -n0).
        # The docs set runs on EVERY qland, docs-only included: two qlands
        # landed red on doc pointers / skill prose (gr458977), and a cluster
        # address in docs/ is as public as one in src/. The code set runs when
        # src/tests change: the type-ignore ratchet hid red on main for 12 h
        # behind cancelled CI runs (2026-10-03), and the posix/encoding/
        # extra-only guards only redden CI legs the local gate never runs
        # (backlog/release-candidate-verdicts.md slice 1). The UTC-date and
        # skill chunk-budget walks joined after both reddened main on
        # 2026-10-03 within one qland of each other.
        HYGIENE='uv run pytest -q -n0 tests/test_doc_pointers.py tests/test_skill_prose.py tests/test_deploy_tree_no_secrets.py tests/test_vocab_lint.py'
        CODE_HYGIENE='tests/test_type_ignore_ratchet.py tests/test_posix_only_guards.py tests/test_text_io_encoding.py tests/test_extra_only_import_guards.py tests/test_store_mixin_guard.py tests/test_utc_time_convention.py tests/test_skill_ingest.py::test_shipped_skill_corpus_has_zero_gate_findings'
        if [[ "${PRECIS_QLAND_LINT:-1}" == 0 ]]; then
            say "quick ship — pre-qland lint OFF (PRECIS_QLAND_LINT=0)"
        else
            setup_gate_infra
            if [[ "$DOCS_ONLY" == 1 ]]; then
                say "pre-qland lint (docs/config-only: doc-pointer · skill-prose · secret-scan · vocab tests) — ${GATE_MODE} gate, no gate slot"
                QLAND_CMD="$HYGIENE"
            else
                say "pre-qland lint (ruff autofix · mypy · import contracts · hygiene tests: type-ignore ratchet, posix/encoding/extra-only guards, docs, secret-scan) — ${GATE_MODE} gate, no gate slot"
                QLAND_CMD="uv run ruff check --fix . && uv run ruff format . && uv run ruff check . && uv run ruff format --check . && uv run mypy src tests && uv run lint-imports && $HYGIENE $CODE_HYGIENE"
            fi
            run_gate "$QLAND_CMD" \
                || die "pre-qland lint is RED — not shipping. This is the class of failure that reddens the NEXT tree's gate, so it does not get deferred (deliberate override: PRECIS_QLAND_LINT=0). Fix it and re-run scripts/ship --quick."
            if [[ -n "$(git status --porcelain)" ]]; then
                git add -A
                git commit -q --amend --no-edit
                say "amended ruff auto-fixes into the branch"
            fi
            QLAND_LINT_RAN=1
        fi
        if [[ "$RELEASE" == 1 ]]; then
            # The lint's auto-fix amend can change the tree the preflight scanned.
            _prev_migs="$RELEASE_NEW_MIGS"
            _release_scan_paths
            [[ "$RELEASE_NEW_MIGS" == "$_prev_migs" ]] || _squawk_new_migrations "$RELEASE_NEW_MIGS"
        fi
        say "quick ship — pytest deferred to the next full gate (/go or bare scripts/ship)"
    elif [[ "$REMOTE" == 1 && "$IMPACTED" != 1 && "$REMOTE_FALLBACK" != 1 ]]; then
        say "remote ship — no local gate; the GitHub matrix below is the gate"
    else
        setup_gate_infra

    # One gate invocation for the whole check. `ruff --fix`/`format` normalise
    # the source in-place (the mount in bind mode, the container's native copy
    # in warm mode); run_gate syncs the result back and we amend it below.
    #
    # -n6 is the measured sweet spot regardless of FS: the suite is CPU-bound
    # on this 15-vCPU VM (each test proc + its own pg backend ≈ 2 procs), so
    # past ~6 workers oversubscription makes it SLOWER, not faster (measured on
    # the RAM test DB: -n6 ≈73-85s, -n8 ≈98s, -n10 ≈100s, -n15 ≈124-131s — and
    # a native /app did NOT change this; the FS was never the ceiling). The
    # warm gate's real win is elsewhere: an incremental mypy cache on a
    # persistent volume (cold ~30s → ~1s) + no per-ship container create.
    #
    # PRECIS_GATE_N overrides the default for a memory/connection-pressured
    # host: on a machine already running several sibling worktree gates (or
    # under desktop RAM pressure), the full-suite -n6 run can saturate the test
    # DB's 100-connection ceiling — new connections get RST'd from the listen
    # backlog and surface client-side as "server closed the connection
    # unexpectedly" across every dir, with nothing logged server-side. Drop to
    # -n3/-n2 to halve peak connections + backends when that bites.
    PYTEST_N="${PRECIS_GATE_N:-6}"
    if [[ "$DOCS_ONLY" == 1 ]]; then
        say "docs/config-only change → light gate (ruff · doc-pointer test), skipping mypy+pytest"
        run_gate 'uv run ruff check --fix . && uv run ruff format . && uv run ruff check . && uv run ruff format --check . && uv run pytest -q -n0 tests/test_doc_pointers.py' \
            || die "light gate is RED — not shipping. Fix the failure above and re-run scripts/ship."
    else
        # Impacted mode (/land): testmon selects only the tests this change
        # touches, at /app/.testmondata (the map run_gate carries in/out of the
        # warm container, or the bind mount directly). -n0 is forced — testmon
        # is per-process, so xdist would fragment its map. Full mode (/go, bare):
        # the whole suite at -n6. mypy + ruff run in full either way.
        if [[ "$IMPACTED" == 1 ]]; then
            # Repo-invariant tests (migration numbering, schema-baseline
            # drift, schema design) assert over filesystem globs / the
            # migrated schema, so testmon has no code-dependency edge to
            # select them on a new migration — run them unconditionally
            # alongside the impacted set (they're fast). Same treatment for
            # the cross-cutting wire-shape guards (token budget, MCP verb
            # kwarg parity, vocab lint): they assert over the WHOLE tool
            # surface, and a testmon map built mid-red-gate can drop their
            # dependency edge — gr277298 shipped a token-budget overage
            # green because --impacted deselected the budget test right
            # after the fix cycle that changed the wire shape. The
            # `type: ignore` ratchet reads source text, not imports — same
            # no-edge problem.
            PYTEST_CMD='TESTMON_DATAFILE=/app/.testmondata uv run --with pytest-testmon pytest -q --testmon -n0 && uv run pytest -q -n0 tests/test_migration_numbering.py tests/test_schema_baseline.py tests/test_schema_design.py tests/test_token_budget.py tests/test_mcp_verb_kwarg_parity.py tests/test_vocab_lint.py tests/test_type_ignore_ratchet.py'
            say "integration gate (ruff autofix · mypy · import contracts · pytest --impacted + repo invariants) — ${GATE_MODE} gate"
        else
            # Full authoritative path measures coverage too: pytest-cov writes
            # /app/coverage.xml (xml only — no terminal table flooding the
            # gate output), consumed by the host-side diff-cover gate below.
            # --mutate additionally records WHICH test executed each line
            # (per-test contexts in the .coverage sqlite) so scripts/mutate-diff
            # can run each mutant against just its covering tests.
            COV_ARGS="--cov=src --cov-report=xml:coverage.xml"
            [[ "$MUTATE" == 1 ]] && COV_ARGS+=" --cov-context=test"
            # The `slow` cluster (pcb/se render · route · optimize · E2E
            # kernels) is a small number of tests holding a large share of the
            # gate's wall clock — the 2026-09-25 full gate spent 90+ of its 128
            # minutes on the last ~5% of the run with five of six xdist workers
            # idle, which is what made /go unable to win a CAS race against a
            # qland burst. Deselecting it is safe because check.yml's 6 Linux
            # shards carry NO -m filter, so every push to main and every
            # ci/<branch> gate still runs the whole cluster, as does the
            # nightly full matrix. The trade, taken deliberately: a pcb/se
            # regression can reach the cluster and be caught by CI minutes
            # later rather than blocked here. --slow / PRECIS_GATE_SLOW=1
            # restores the authoritative full set.
            SLOW_ARGS=""
            SLOW_LANE="full"
            if [[ "$SLOW" != 1 ]]; then
                SLOW_ARGS=" -m 'not slow'"
                SLOW_LANE="fast (-m 'not slow'; --slow for the full set)"
            fi
            PYTEST_CMD="uv run pytest -q -n ${PYTEST_N} ${COV_ARGS}${SLOW_ARGS}"
            say "integration gate (ruff autofix · mypy · import contracts · pytest+cov) — ${GATE_MODE} gate, -n ${PYTEST_N}, ${SLOW_LANE} lane"
        fi
        run_gate "uv run ruff check --fix . && uv run ruff format . && uv run ruff check . && uv run ruff format --check . && uv run mypy src tests && uv run lint-imports && ${PYTEST_CMD}" \
            || die "gate is RED — not shipping. Fix the failure above and re-run scripts/ship."
    fi

    if [[ -n "$(git status --porcelain)" ]]; then
        git add -A
        git commit -q --amend --no-edit
        say "amended ruff auto-fixes into the branch"
    fi

    # ── diff-coverage gate (full path only, post-amend so HEAD's line
    # numbers match the ruff-normalised source the suite actually ran) ────
    # Every changed src/ line must have been executed by a test in the gate
    # run above. Runs on the HOST: the warm container has no .git (source
    # goes in via `git archive`), and coverage.xml carries relative paths
    # (relative_files, pyproject) so it resolves here. Skipped when the diff
    # touches no src/ Python — docs/tests/scripts changes have nothing to
    # measure. PRECIS_DIFF_COVER_MIN=0 is the deliberate escape hatch; the
    # tool being unavailable WARNs rather than blocks (same posture as the
    # tailwind rebuild).
    if [[ "$DOCS_ONLY" != 1 && "$IMPACTED" != 1 ]]; then
        [[ "$GATE_MODE" == warm ]] && coverage_from_container
        if grep -qE '^src/.*\.py$' <<< "$CHANGED"; then
            DIFF_COVER_MIN="${PRECIS_DIFF_COVER_MIN:-90}"
            if [[ -f coverage.xml ]] && command -v uvx >/dev/null 2>&1; then
                say "diff-coverage gate — changed src lines need tests (min ${DIFF_COVER_MIN}%)"
                # The fast lane deselects the `slow` cluster, which is exactly
                # the set that covers the pcb/se src lines — so a genuinely
                # well-tested pcb change fails here for a reason that has
                # nothing to do with its tests. Say so, rather than leaving a
                # bare red.
                SLOW_HINT=""
                if [[ "$SLOW" != 1 ]]; then
                    SLOW_HINT=" NOTE: this gate ran the FAST lane (-m 'not slow'), so tests in the slow cluster did not execute — if the uncovered lines are pcb/se/hexfold, re-run with --slow before writing new tests."
                fi
                uvx -q 'diff-cover>=9' coverage.xml --compare-branch=origin/main \
                    --fail-under="$DIFF_COVER_MIN" \
                    || die "diff coverage under ${DIFF_COVER_MIN}% — the report above lists the untested changed lines. Add tests for them (or, deliberately, PRECIS_DIFF_COVER_MIN=0) and re-run scripts/ship.${SLOW_HINT}"
            else
                echo "WARNING: coverage.xml or uvx missing — skipping the diff-coverage gate"
            fi
        fi
    fi

    fi  # end of non-quick gate section (quick mode skips gate + diff-coverage)

    # Remote gate (--remote): CI on GitHub over exactly this tree. Runs
    # UNLOCKED; red/timeout aborts, never retries. Skipped entirely on a
    # hybrid-fallback iteration — the full LOCAL gate above already validated
    # the integrated tree.
    if [[ "$REMOTE" == 1 && "$REMOTE_FALLBACK" != 1 ]]; then
        remote_gate_wait \
            || die "remote gate failed — fix the failure above and re-run scripts/ship --remote."
    fi

    # ── FINAL SECTION — the only place the repo-wide ship lock is held ───
    # (§3) Seconds, not minutes: fetch · ancestor check · [forward merge] ·
    # nothing-new guard · commit-tree · CAS push · local-main ff, then the
    # lock is released explicitly (the EXIT trap is only the safety net for a
    # die/kill in between). Branch reset, the pin and the report follow it.
    #
    # If main moved during the gate (the ancestor check fails) the ship loses
    # the race:
    #   · --quick: merge origin/main into the branch HERE, under the lock, and
    #     push. Lint does not re-run — the commits merged in passed their own,
    #     and a cross-commit interaction is what main's CI verdict and the
    #     drift guard catch; qland promises nothing more.
    #   · full-gate modes: drop the lock and re-sync → re-gate (local), or
    #     re-sync → fresh CI cycle (--remote: PRECIS_REMOTE_CI_RETRIES of them,
    #     then one hybrid local gate — see _remote_race_lost), so the tree that
    #     lands is the tree that was tested. When that budget is spent, land by
    #     the same in-lock forward merge instead — never block, never loop —
    #     but pin nothing and say so (FWD_UNGATED).
    # A CAS rejection (main moved from another host, or a stolen lock) takes
    # the same exits. Never force a stale tree over a moved main.
    # A forward-merge path (--quick, or the budget spent) that loses the CAS
    # to a push from ANOTHER host (outside this lock) must not re-gate and
    # must not give up while a merge is still possible: it stays in the lock
    # and redoes fetch → forward merge (guards included) → commit-tree (the
    # trailer's N grows) → push, at most PRECIS_SHIP_INLOCK_TRIES (5) times,
    # then a named die that releases the lock. Every in-lock try is seconds.
    if [[ "$RELEASE" == 1 ]]; then
        _release_land
        shipped=1
        break
    fi
    say "shipping to main"
    acquire_ship_lock
    INLOCK_MAX="${PRECIS_SHIP_INLOCK_TRIES:-5}"
    inlock_try=0
    while :; do
        inlock_try=$(( inlock_try + 1 ))
        _locked_net git fetch -q origin main || {
            rc=$?
            [[ "$rc" == 142 ]] && die "git fetch timed out after ${LOCKED_NET_TIMEOUT}s inside the ship lock (PRECIS_SHIP_LOCKED_NET_TIMEOUT) — lock released, nothing pushed. Check the network and re-run scripts/ship."
            die "git fetch failed inside the ship lock — lock released, nothing pushed. Check the network/remote and re-run scripts/ship."
        }
        # Capture the tip ONCE, from this worktree's own FETCH_HEAD (per-worktree,
        # unlike the shared refs/remotes/origin/main that every sibling's unlocked
        # sync/report fetch rewrites). All of the section keys off $OLD_MAIN; a
        # read of origin/main later could return a sibling-fetched M2 that HEAD
        # lacks, and `commit-tree -p M2` + a lease on M2 would then revert M2.
        OLD_MAIN="$(git rev-parse --verify -q 'FETCH_HEAD^{commit}')" \
            || die "internal: could not read FETCH_HEAD after the in-lock fetch — lock released, nothing pushed. Re-run scripts/ship."
        if ! git merge-base --is-ancestor "$OLD_MAIN" HEAD; then
            if [[ "$QUICK" == 1 ]]; then
                _forward_merge_in_lock quick
            elif (( attempt >= MAX_ATTEMPTS )); then
                say "origin/main advanced during the gate and the race budget is spent (${attempt}/${MAX_ATTEMPTS})"
                _forward_merge_in_lock full
            else
                say "origin/main advanced during the gate — releasing the ship lock, re-syncing"
                _release_ship_lock
                _race_note "$attempt"
                _remote_race_lost
                continue 2
            fi
        fi
        TREE="$(git rev-parse 'HEAD^{tree}')"
        # Nothing-new guard: when the branch's tree already equals main's
        # (everything here was already landed — e.g. a /go run purely to gate +
        # deploy after a /qland burst), don't manufacture an empty squash commit;
        # in full mode the gate above still validated the integrated main.
        if [[ "$TREE" == "$(git rev-parse "${OLD_MAIN}^{tree}")" ]]; then
            say "nothing new to ship — tree identical to origin/main (already landed); skipping the push"
            # No new commit, but the gate above DID validate this tree — and it is
            # byte-identical to origin/main's. That makes OLD_MAIN the gated sha,
            # which is exactly the /go-after-a-qland-burst case: the whole point of
            # the run is to certify the trunk for deploy. (Not after a forward
            # merge: then the gate saw an older main, and FWD_UNGATED withholds
            # the pin.)
            GATED_SHA="$OLD_MAIN"
            LANDED_SHA="$OLD_MAIN"
            _sync_local_main
            _release_ship_lock
            shipped=1
            break 2
        fi
        # The `Gate:` trailer (forward-merged lands only) rides above the
        # Co-Authored-By line so `git log` tells an exact-tested land from a
        # forward-merged one.
        TRAILERS="$CO_AUTHOR"
        [[ -n "$GATE_TRAILER" ]] && TRAILERS="${GATE_TRAILER}"$'\n'"${CO_AUTHOR}"
        git merge-base --is-ancestor "$OLD_MAIN" HEAD \
            || die "internal: HEAD lacks OLD_MAIN (${OLD_MAIN:0:8}) — refusing to squash a tree that would revert main. Lock released, nothing pushed. Re-run scripts/ship and report this."
        NEW="$(git commit-tree "$TREE" -p "$OLD_MAIN" -F - <<EOF
${MSG}

${TRAILERS}
EOF
)"
        push_rc=0
        push_out="$(_locked_net git push --force-with-lease=main:"$OLD_MAIN" origin "${NEW}:refs/heads/main" 2>&1)" || push_rc=$?
        [[ -n "$push_out" ]] && printf '%s\n' "$push_out"
        if [[ "$push_rc" == 142 ]]; then
            die "git push timed out after ${LOCKED_NET_TIMEOUT}s inside the ship lock (PRECIS_SHIP_LOCKED_NET_TIMEOUT) — lock released. The push may or may not have landed: check 'git ls-remote origin main' for ${NEW:0:8} before re-running scripts/ship."
        fi
        if [[ "$push_rc" == 0 ]]; then
            # Capture the pin HERE, not from origin/main at report time: the
            # report re-fetches, so by then origin/main may already be a
            # sibling's newer, ungated commit — the very substitution this pin
            # exists to prevent.
            GATED_SHA="$NEW"
            LANDED_SHA="$NEW"
            _sync_local_main
            _release_ship_lock
            shipped=1
            break 2
        fi
        # Only a CAS LOSS is retried. Any other failure (auth, a hook, the
        # network) would just fail again: die at once, with git's own words.
        if ! _push_is_lost_race "$push_out"; then
            die "git push failed inside the ship lock for a reason other than a lost race (exit ${push_rc}) — lock released, nothing landed. git said: ${push_out}"
        fi
        # CAS rejected: main moved from elsewhere (another host, or a stolen
        # lock). A forward-merge path retries in the lock, bounded; anything
        # else is a full-gate ship with budget left → drop the lock, re-gate.
        if [[ "$QUICK" == 1 ]] || (( attempt >= MAX_ATTEMPTS )); then
            if (( inlock_try >= INLOCK_MAX )); then
                die "CAS push rejected ${INLOCK_MAX}× inside the ship lock — origin/main kept moving from another host (a push every few seconds). Lock released, nothing landed. Re-run scripts/ship; the gate is not re-run for a forward-merge land."
            fi
            say "CAS push rejected inside the lock — origin/main moved from another host; re-fetching and re-merging in the lock, no re-gate (in-lock try ${inlock_try}/${INLOCK_MAX})"
            continue
        fi
        _release_ship_lock
        say "CAS push rejected — origin/main moved from elsewhere; re-syncing and retrying"
        _race_note "$attempt"
        _remote_race_lost
        continue 2
    done
done
[[ "$shipped" == 1 ]] || die "could not ship after ${MAX_ATTEMPTS} attempts — main kept moving under us. Re-run scripts/ship."

# Write the gated-sha pin (see SHIP_SHA_FILE above). Full-gate runs only:
# --quick gated nothing; --impacted gated a testmon subset; a docs-only diff
# took the light lane (local: ruff + doc pointers; GitHub: the fast set) —
# no pytest ran on that tree, so it is no deploy warrant either (gr347014).
# --remote counts as full even alongside --impacted, because the impacted
# run there is only a pre-gate ahead of GitHub's complete matrix. A
# hybrid-race fallback has already forced IMPACTED=0 by this point (the
# integrated tree took the full local suite), so it lands here as full too.
# A budget-exhausted forward-merged land (FWD_UNGATED) skips both branches:
# the landed tree was never gated, so no pin and no `gated` pointer move.
if [[ "$FWD_UNGATED" == 1 ]]; then
    say "gated against ${FWD_BASE:0:8}, merged forward over ${FWD_N} commits — not a deploy warrant; no .ship-sha pin (origin/gated not moved)"
fi
if [[ "$QUICK" != 1 && "$DOCS_ONLY" != 1 && ( "$IMPACTED" != 1 || "$REMOTE" == 1 ) && -n "$GATED_SHA" && "$FWD_UNGATED" != 1 ]]; then
    printf '%s\n' "$GATED_SHA" > "$SHIP_SHA_FILE"
    say "gated sha pinned → ${SHIP_SHA_FILE##*/} (${GATED_SHA:0:8}); deploy it with: scripts/deploy \"\$(cat .ship-sha)\" --pinned"
    # Publish the same fact as origin's `gated` ref, so the gated sha is not
    # knowledge only this worktree has. Fast-forward only, best-effort — see
    # scripts/lib/env-pointers.sh.
    if [[ -f "${WORKTREE}/scripts/lib/env-pointers.sh" ]]; then
        . "${WORKTREE}/scripts/lib/env-pointers.sh"
        . "${WORKTREE}/scripts/lib/round-lock.sh"
        if acquire_main_gated_publication; then
            if env_pointer_move "$WORKTREE" gated "$GATED_SHA"; then
                say "origin/gated → ${GATED_SHA:0:8}"
            fi
            release_round_lock
        fi
    fi
elif [[ "$QUICK" != 1 && "$DOCS_ONLY" == 1 && -n "$GATED_SHA" && "$FWD_UNGATED" != 1 ]]; then
    say "docs-only lane — no deploy pin written (the suite did not run on ${GATED_SHA:0:8}); to certify it for deploy run scripts/ship --full (/go) on the landed tree"
fi
git push origin --delete "$BRANCH" 2>/dev/null || true
if [[ "$REMOTE" == 1 ]]; then
    git push origin --delete "ci/${BRANCH}" 2>/dev/null || true
fi

# A release fix (--release) has its own tail: the branch sync is a merge +
# tree-equality check (not an unconditional reset), and the report names R1, M
# and the release CI run. See _release_after_landing.
if [[ "$RELEASE" == 1 ]]; then
    _release_after_landing
else
    # ── 6. reset the feature branch to the freshly-shipped main ─────────────
    # The squash put this branch's tree onto main as a NEW commit that is not an
    # ancestor of the branch. Left as-is, the next sync sees the branch's commit
    # AND main's squashed copy of the same lines as divergent → a phantom
    # conflict on already-shipped work (the squash re-sync artifact).
    # Resetting the branch to the shipped main gives it zero divergence, so the next
    # sync is a clean fast-forward and only GENUINE conflicts (our new work vs a
    # concurrent main change) ever surface. Safe by construction: everything is
    # committed + shipped here, so the working tree already equals origin/main and
    # the reset moves pointers, not files. Guard anyway — never reset over a dirty
    # tree (it would eat uncommitted work).
    if [[ -z "$(git status --porcelain)" ]]; then
        git reset --hard origin/main >/dev/null \
            && say "reset ${BRANCH} → $(git rev-parse --short origin/main) (zero divergence; next sync is a clean ff)"
    else
        echo "WARNING: worktree dirty after ship — skipping branch reset (run 'git reset --hard origin/main' when clean)."
    fi

    _reap_absorbed_agents

    # The window is now genuinely open: this tree is merged into main and (after
    # the reset above) clean. This is the call proposal 3 is actually about.
    _relock

    # (The local-main ff ran inside the ship lock, as the last step of the final
    # section — see _sync_local_main.)

    # ── 7. report ───────────────────────────────────────────────────────────
    git fetch -q origin main
    # The lock is long released: origin/main may already be a sibling's newer
    # commit, so name OUR landed sha rather than whatever main is by now.
    say "shipped — landed on main:"
    git log --oneline -1 "${LANDED_SHA:-origin/main}"
    if [[ "$QUICK" == 1 ]]; then
        # Names what actually ran. The old wording ("no ruff/mypy/pytest") outlived
        # the pre-qland lint by one commit, and a banner that overstates the debt is
        # read as licence to ignore it.
        if [[ "$QLAND_LINT_RAN" != 1 ]]; then
            printf '\n\033[33m⚡ UNTESTED ship — no lint and no pytest ran on this merge. main is unvalidated\n  until the next full gate: run /go (full suite + deploy) after the qland burst.\033[0m\n'
        else
            printf '\n\033[33m⚡ UNTESTED ship — ruff/mypy/import contracts and the DB-free hygiene tests passed; the\n  suite did NOT run. main is untested until its CI verdict or the next full gate.\033[0m\n'
        fi
    fi
fi

# ── 8. deploy-lag summary (best-effort). Three honest states (gr332009):
# a pending attempt with no recorded success → "uncertain"; a marker →
# commit count; no marker at all → "no successful deploy on record". ─────
if pending="$(_deploy_attempt_pending)" 2>/dev/null && [[ -n "$pending" ]]; then
    read -r _att_sha _att_ep _att_outcome <<< "$pending"
    _att_age_h=$(( ( $(date +%s) - _att_ep ) / 3600 ))
    if [[ "$_att_outcome" == "refused" ]]; then
        printf '📦 last deploy attempt of %.8s (%sh ago) was REFUSED by the rollback guard — no host touched; fleet unaffected (gr338201).\n' "$_att_sha" "$_att_age_h"
    else
        printf '📦 deploy state uncertain — deploy of %.8s started %sh ago, no success recorded (died red or still running); scripts/deploy to retry.\n' "$_att_sha" "$_att_age_h"
    fi
elif stats="$(_deploy_lag_stats)" 2>/dev/null && [[ -n "$stats" ]]; then
    read -r _lag_n _lag_oldest _lag_src <<< "$stats"
    _lag_age_h=$(( ( $(date +%s) - _lag_oldest ) / 3600 ))
    if [[ "$_lag_src" == "origin/prod" ]]; then
        _lag_src_txt="vs origin/prod"
    else
        _lag_src_txt="vs local deploy marker (origin/prod absent)"
    fi
    printf '📦 %s commit(s) on main not yet deployed (oldest %sh ago) %s — scripts/deploy to flush.\n' "$_lag_n" "$_lag_age_h" "$_lag_src_txt"
elif _deploy_marker_missing; then
    printf '📦 no successful deploy on record (marker absent) — lag unknown; the next green scripts/deploy sets it.\n'
fi
