#!/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        — mkdir-mutex on the shared .git dir: serialise ships across
#                    sibling worktrees on this host so a second ship WAITS
#                    instead of squashing a stale base over the first (the
#                    shared-index clobber). Released on exit; steals the lock
#                    immediately if the holder's pid is dead, else falls back
#                    to a >30-min age check (holder file missing/unparseable,
#                    or from another host where the pid can't be checked).
#   4. loop        — sync → gate → ship, re-running if main moves under us:
#                    · sync   plain `git fetch origin main` + `git merge
#                             origin/main` (integrate once; the squash below
#                             collapses the merge so main stays linear). A
#                             conflict aborts for a human to resolve.
#                    · 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 the gate entirely.
#                    · ship   squash `commit-tree` onto origin/main + a
#                             `--force-with-lease` CAS push. If main advanced
#                             during the gate (ancestor check) or the lease is
#                             rejected, re-sync + re-gate + re-push (≤3×; remote
#                             mode: two fresh CI runs, then one hybrid local
#                             gate) — never force a stale tree over a moved main.
#   5. 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.
#   6. local main  — fast-forward the primary's main to the shipped sha
#   7. report      — print the new main sha
#
# A full-gate run 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), or
# a docs-only diff (the light lane — no pytest — unless --full forces the suite).
#
# 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 gate at all — commit → sync →
#                                        # squash-merge only (also via
#                                        # PRECIS_SHIP_QUICK=1). For burst-landing
#                                        # many worktrees; main is unvalidated until
#                                        # the next full gate (/go or bare ship).
#         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:
#                                        # drop the ship lock, re-sync, and run a
#                                        # FRESH CI cycle on the integrated tree
#                                        # (~12 min), up to PRECIS_REMOTE_CI_RETRIES
#                                        # (2) times; only then the HYBRID fallback
#                                        # (keep the lock, full LOCAL gate ~10 min)
#                                        # 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 (2).
#
# 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)"

# --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 the gate ENTIRELY — no ruff/mypy/pytest, no gate
# container, no gate slot. Everything else (ship lock, sync/merge, squash CAS
# push, branch reset, local-main ff) runs unchanged, so the merge machinery
# stays race-safe; only validation is deferred. 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}"
# --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 main
# only ever advances through an exactly-tested tree. The ship lock is NOT
# held across the CI wait (a queued matrix can exceed the lock's 30-min
# staleness steal and starve sibling ships) — it's taken only around the
# final CAS 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),
# RELEASE the ship lock, re-sync, and run a fresh CI cycle on the
# integrated tree — up to PRECIS_REMOTE_CI_RETRIES (default 2) times. A CI
# retry costs ~12 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 as the terminating step: keep the ship
# lock, re-sync, and validate the integrated tree with the FULL LOCAL
# container gate (~10 min) instead, then push. That fallback was the
# default while a CI run took ~1h; it needs one of the two fleet-wide gate
# slots, whose first-come admission starved a lock-holding ship for hours
# (gr343941 — the FIFO slot ticket is that gripe's remaining scope).
REMOTE_FALLBACK=0
REMOTE_CI_RETRIES=0
MSG=""
while :; do
    case "${1:-}" in
        --impacted) IMPACTED=1; shift ;;
        --mutate)   MUTATE=1; shift ;;
        --quick)    QUICK=1; shift ;;
        --remote)   REMOTE=1; shift ;;
        --full)     FULL=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 -m/--message)\033[0m\n' "$1" >&2; exit 2 ;;
        *) break ;;
    esac
done

[[ "$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:-}}"
CO_AUTHOR="Co-Authored-By: Claude Opus 4.8 (1M context) <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>", or nothing
    # if the shared marker is missing/unreadable/stale-referencing a gone sha.
    local state_file deployed_sha count oldest
    # 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)"
    [[ -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\n' "$count" "$oldest"
}

_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 ───────────────────────────────────────────────────────────
[[ -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 acquired later in §3. Armed immediately on success; the
# repo-wide lock's own trap (§3) is widened to release this one too, 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 <<< "$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 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

# 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
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=()
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

# 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) — gate SKIPPED: no ruff/mypy/pytest, no gate containers"
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.
    gate_slot_acquire
    local rc=0
    if [[ "$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. acquire the ship lock (serialise ships across sibling worktrees) ──
# All worktrees on this host share one .git, so a mkdir-mutex on the common
# git dir serialises ships: 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. 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. >30-min age — 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.
# Released on exit via the trap below — armed only AFTER we hold the lock
# (gr202363): arming it across the wait loop meant a SIGTERM while still
# WAITING ran the trap and rm -rf'd a lock we never acquired, out from under
# whichever sibling legitimately held it. The release itself is also
# ownership-checked (holder pid == $$) so a sibling that steals our lock
# after a >30-min hold doesn't get its own fresh lock rm'd by our late exit.
LOCKDIR="$(git rev-parse --git-common-dir)/precis-ship.lock.d"
# Safety net: also 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.
_release_ship_lock() {
    gate_slot_release
    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/30-min steals above.
    if [[ "$holder_pid" == "$$" ]]; then
        rm -rf "$LOCKDIR" 2>/dev/null || true
    fi
}
acquire_ship_lock() {
    local _waited=0 _last_holder="" holder holder_desc holder_pid _reason
    # Re-entrant: the hybrid race fallback keeps the lock across its retry
    # iteration — a second acquire from the same pid is a no-op, not a
    # self-deadlock. (A CI-retry iteration released it first, so its
    # re-acquire below is a real, contended acquire.)
    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" 30)"; 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.
        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)"
        elif [[ "$holder" != "$_last_holder" ]]; then
            say "ship lock changed hands — now held by: ${holder_desc}"
        fi
        _last_holder="$holder"
        _waited=1
        sleep 3
    done
    lock_holder_write "$LOCKDIR"
    # Arm the release trap only now that we actually hold the lock (gr202363) —
    # arming it earlier meant a kill mid-wait fired the trap against a lock we
    # never acquired. (Re-arming on a remote-mode re-acquire is idempotent.)
    # Widened to also release the per-worktree lock (gr335894): a script has
    # only one EXIT trap slot, and that lock's own trap (armed right after it
    # was acquired, near the top of the script) would otherwise be clobbered
    # by this one. _release_worktree_ship_lock is itself ownership-checked and
    # a no-op once already released, so folding it in here is safe.
    trap '_release_ship_lock; _release_worktree_ship_lock' EXIT
}

# Classic modes hold the lock across the whole sync→gate→push section (the
# gate is minutes). Remote mode takes it ONLY around the final CAS push —
# holding it across a GitHub matrix (12 min green, unbounded when queued)
# would starve every sibling ship and trip the 30-min staleness steal above.
if [[ "$REMOTE" != 1 ]]; then
    acquire_ship_lock
fi

# Remote mode lost the race (main moved during the CI wait, or the CAS lease
# was rejected). Policy header near REMOTE_FALLBACK= above: first
# PRECIS_REMOTE_CI_RETRIES fresh CI cycles with the lock RELEASED (siblings
# keep landing meanwhile), then one hybrid local gate with the lock held so
# the retry loop terminates. No-op outside remote mode or once in fallback.
_remote_race_lost() {
    [[ "$REMOTE" == 1 && "$REMOTE_FALLBACK" != 1 ]] || return 0
    local budget="${PRECIS_REMOTE_CI_RETRIES:-2}"
    if (( REMOTE_CI_RETRIES < budget )); then
        REMOTE_CI_RETRIES=$(( REMOTE_CI_RETRIES + 1 ))
        say "race lost — releasing the ship lock; the re-synced tree gets a FRESH CI run (CI retry ${REMOTE_CI_RETRIES}/${budget}; hybrid local gate after that)"
        _release_ship_lock
    else
        REMOTE_FALLBACK=1; IMPACTED=0
        say "race lost ${budget}× — hybrid fallback: lock stays held; the integrated tree gets the FULL LOCAL gate (~10 min) instead of another CI run"
    fi
}

# ── 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, ~12 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
}

# ── 4. sync → gate → ship, re-running if main moves under us ────────────
# Hold the lock across the whole critical section so no sibling worktree can
# move main between our sync and our push. The retry loop additionally
# self-heals against a main that moved from ELSEWHERE (another host, or a
# stolen stale lock): re-sync (merge the new main) → re-gate → re-push, up to
# a few times, so we never squash a stale base. 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.
MAX_ATTEMPTS=3
[[ "$REMOTE" == 1 ]] && MAX_ATTEMPTS=$(( 2 + ${PRECIS_REMOTE_CI_RETRIES:-2} ))
# 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})"

    say "syncing origin/main"
    git fetch -q origin main || die "git fetch failed — check the network/remote and re-run scripts/ship."
    git merge --no-edit origin/main \
        || die "merge conflict integrating origin/main — resolve in ${WORKTREE}, then 'git add -A && git commit' and re-run scripts/ship."

    git fetch -q origin main
    CHANGED="$(git diff --name-only "$(git merge-base origin/main HEAD)" HEAD)"

    # 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 (sealed files are frozen and
    # never re-linted). Blocking, unlike the advisories above: 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).
    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)"
    if [[ -n "$NEW_MIGRATIONS" && "${PRECIS_SQUAWK:-1}" != 0 ]]; then
        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 $NEW_MIGRATIONS \
                || 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
    fi

    if [[ "$QUICK" == 1 ]]; then
        say "quick ship — gate skipped (validation 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"
            PYTEST_CMD="uv run pytest -q -n ${PYTEST_N} ${COV_ARGS}"
            say "integration gate (ruff autofix · mypy · import contracts · pytest+cov) — ${GATE_MODE} gate, -n ${PYTEST_N}"
        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}%)"
                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."
            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 (see acquire_ship_lock); red/timeout aborts, never retries.
    # Skipped entirely on a hybrid-fallback iteration — the full LOCAL gate
    # above already validated the integrated tree, under the held lock.
    if [[ "$REMOTE" == 1 && "$REMOTE_FALLBACK" != 1 ]]; then
        remote_gate_wait \
            || die "remote gate failed — fix the failure above and re-run scripts/ship --remote."
        # Only now serialise against sibling ships, for the short CAS section.
        acquire_ship_lock
    fi

    # Squash-merge to main via plumbing. If main moved during the gate (the
    # ancestor check fails) or the CAS lease is rejected, loop: re-sync picks
    # up the new main, re-gate, re-push — never force a stale tree over it.
    # In remote mode a retry means a FRESH CI cycle on the re-synced tree
    # (that's the contract: main only advances through an exactly-tested
    # tree), and the lock is dropped first so siblings aren't starved
    # through the CI wait (~12 min green, unbounded when queued). After
    # PRECIS_REMOTE_CI_RETRIES lost races the hybrid local gate takes over
    # (lock held) so the loop terminates — see _remote_race_lost.
    say "shipping to main"
    git fetch -q origin main
    if ! git merge-base --is-ancestor origin/main HEAD; then
        say "origin/main advanced during the gate — re-syncing"
        _remote_race_lost
        continue
    fi
    OLD_MAIN="$(git rev-parse origin/main)"
    TREE="$(git rev-parse 'HEAD^{tree}')"
    # Nothing-new guard: when the branch's tree already equals origin/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.
        GATED_SHA="$OLD_MAIN"
        shipped=1
        break
    fi
    NEW="$(git commit-tree "$TREE" -p "$OLD_MAIN" -F - <<EOF
${MSG:-ship(${BRANCH}): squash-merge to main}

${CO_AUTHOR}
EOF
)"
    if git push --force-with-lease=main:"$OLD_MAIN" origin "${NEW}:refs/heads/main"; then
        # Capture the pin HERE, not from origin/main at report time: steps 6-7
        # re-fetch, 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"
        shipped=1
        break
    fi
    say "CAS push rejected — origin/main moved from elsewhere; re-syncing and retrying"
    _remote_race_lost
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.
if [[ "$QUICK" != 1 && "$DOCS_ONLY" != 1 && ( "$IMPACTED" != 1 || "$REMOTE" == 1 ) && -n "$GATED_SHA" ]]; 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"
elif [[ "$QUICK" != 1 && "$DOCS_ONLY" == 1 && -n "$GATED_SHA" ]]; 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

# ── 5. 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 `git merge origin/main` sees the
# branch's commits AND main's squashed copy of the same lines as divergent → a
# phantom conflict on already-shipped work (the squash-merge 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

# 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

# ── 6. fast-forward the local main ──────────────────────────────────────
# The CAS push moved refs/heads/main on the REMOTE only; the local main
# pointer (checked out in the primary worktree) is now behind — the
# "GitHub is ahead of local main" drift after every session. Fast-forward
# it. refs/heads/main is shared across worktrees but checked out in the
# primary, so a `merge --ff-only` THERE 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).
say "syncing local main"
PRIMARY="$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}')"
git -C "$PRIMARY" fetch -q origin main
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

# ── 7. report ───────────────────────────────────────────────────────────
git fetch -q origin main
say "shipped — main is now:"
git log --oneline -1 origin/main
if [[ "$QUICK" == 1 ]]; then
    printf '\n\033[33m⚡ UNGATED ship — no ruff/mypy/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'
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 <<< "$stats"
    _lag_age_h=$(( ( $(date +%s) - _lag_oldest ) / 3600 ))
    printf '📦 %s commit(s) on main not yet deployed (oldest %sh ago) — scripts/deploy to flush.\n' "$_lag_n" "$_lag_age_h"
elif _deploy_marker_missing; then
    printf '📦 no successful deploy on record (marker absent) — lag unknown; the next green scripts/deploy sets it.\n'
fi
