#!/usr/bin/env bash
# finish-branch.sh — the shared, tested "brain" for landing a finished feature branch.
# audience: AI coding agents first.
#
# WHY THIS EXISTS: landing a branch is the most destructive thing an agent does (publishes to
# main, removes worktrees, deletes branches). The old prose cascade let a subagent re-derive the
# git commands every time and blindly `-X theirs` — silently dropping work. This makes the
# mechanical, footgun-prone steps ONE set of fail-closed primitives invoked BY PATH, and hands
# every conflict to the AGENT (never the user) to resolve by judgment (see the conflict rubric).
#
# DIVISION OF LABOR:
#   - Script owns the FLOOR (deterministic + irreversible): cwd-correct git (every call `git -C`),
#     gated fast-forward (main only ever advances to a tree that has NO conflict markers, a clean
#     index, and PASSES the project test command), ordered cleanup (worktree-remove BEFORE
#     branch-delete), detect-and-abort on every learned footgun.
#   - Agent owns the CEILING (judgment): resolve each conflict per the rubric; decide whether the
#     test suite meaningfully covered the conflicted lines ("resolved but unverified at X").
#
# CONTRACT: each subcommand prints ONE line of JSON on stdout. A gate result (conflict, dirty tree,
#   tests-failed, blockers present) is DATA in that JSON with exit 0 — the agent reads `status`/
#   `ready` and acts. Exit non-zero ONLY on a usage/environment fault (bad args, not a git repo).
#   Diagnostics → stderr. FAIL-CLOSED: an unexpected state aborts to the agent, never guesses/forces.
#
# Resolve-on-branch invariant: conflicts are resolved on the BRANCH (in its worktree); main is only
#   ever fast-forwarded to an already-verified branch head. main never passes through a broken state.
#
# Subcommands:
#   preflight  <main> <branch> <wt>                 read-only; main-side pre-merge blockers
#   sync-base  <main> <branch> <base> <wt>          merge base INTO branch (resolve-on-branch)
#   land-merge <main> <branch> <base> <wt> <testcmd>  GATED ff of main to the verified branch head
#   land-pr    <main> <branch> <base> <wt> <testcmd>  push branch + open PR (never touches main)
#   deploy-preview <wt> <deploycmd>                 deploy branch to preview; capture .ship-preview-url
#   e2e-gate   <wt> <e2ecmd> <url>                  run e2e against the LIVE preview URL (pure gate)
#   cleanup    <main> <branch> <wt> [--keep-branch] [--no-push] [--assets-ok]
#   drift      --root R --base B --mode M [--anchor KIND:VAL]   validate frozen land facts (fail-closed)
#   land       --root R --base B --mode M [--anchor A] --testcmd T [--depcmd D]
#                  [--deploycmd C --e2ecmd C --promote pr|merge-to-main] -- <branch> <wt> [--assets-ok]
#              full per-project land ORCHESTRATOR (drift + mode-correct primitive cascade + ladder).
#              mode deploy-verify = deploy-preview → e2e-gate → _promote(--promote) on green.
#              The per-project ship.sh is now pure frozen DATA + one `land` delegation (no logic here
#              is duplicated per project). Exit 0 done / 20 agent-action-needed / 3 drift|usage.
set -uo pipefail

# Escape a string for a JSON double-quoted value (controlled, single-line).
jstr() { local s=${1//\\/\\\\}; s=${s//\"/\\\"}; s=${s//$'\t'/ }; s=${s//$'\n'/ }; printf '%s' "$s"; }

# Emit {"status":"<s>", <extra>} — <extra> is pre-formed JSON members (no leading comma) or empty.
emit_status() { local s=$1 extra=${2:-}; if [[ -n "$extra" ]]; then printf '{"status":"%s",%s}\n' "$s" "$extra"; else printf '{"status":"%s"}\n' "$s"; fi; }

# Agent-facing ladder line for the `land` orchestrator: {"stage","status","next","detail"}.
emit_ladder() { printf '{"stage":"%s","status":"%s","next":"%s","detail":"%s"}\n' "$1" "$2" "$3" "$(jstr "$4")"; }

# Extract a field from one line of the brain's OWN controlled single-line JSON (no python dep).
jq_status() { local s=$1; [[ "$s" =~ \"status\":\"([^\"]*)\" ]] && printf '%s' "${BASH_REMATCH[1]}"; }
jq_field()  { local s=$1 k=$2; [[ "$s" =~ \"$k\":\"([^\"]*)\" ]] && printf '%s' "${BASH_REMATCH[1]}"; }
ready_of()  { local s=$1; [[ "$s" =~ \"ready\":(true|false) ]] && printf '%s' "${BASH_REMATCH[1]}"; }

# git in a given dir, quietly. Usage: g <dir> <git-args...>
g() { git -C "$1" "${@:2}"; }

# Assert a path is a git working tree (has .git). Usage fault (exit 3) if not.
need_repo() { [[ -e "$1/.git" ]] || { echo "finish-branch: not a git working tree: $1" >&2; return 3; }; }

# List tracked files containing conflict markers in <wt>. Echoes newline-separated paths (may be empty).
conflict_marked() { git -C "$1" grep -lE '^(<<<<<<< |>>>>>>> )' -- . 2>/dev/null || true; }

# JSON array of porcelain-dirty TRACKED paths in <dir> (excludes untracked '??'). Echoes e.g. ["a","b"].
dirty_tracked_json() {
  git -C "$1" status --porcelain 2>/dev/null | awk '
    BEGIN{ printf "[" ; n=0 }
    { if (substr($0,1,2) != "??") { p=substr($0,4); if(n++) printf ","; printf "\"%s\"", p } }
    END{ printf "]" }'
}

run_test_gate() {
  local wt=$1 testcmd=$2 timeout_seconds=${FINISH_BRANCH_TEST_TIMEOUT_SECONDS:-1800}
  local gate=${FINISH_BRANCH_LOCAL_GATE:-$HOME/.claude/bin/local-gate} key
  [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || return 125
  [[ -x "$gate" ]] || return 127
  key="finish-branch-$(printf '%s\0%s' "$wt" "$testcmd" | sha256sum | cut -c1-16)"
  ( cd "$wt" && timeout --foreground --kill-after=10s "${timeout_seconds}s" "$gate" --key "$key" -- bash -c "$testcmd" )
}

# ── preflight: main-side blockers that must be cleared (by the AGENT) before any land ─────────
# Read-only. Emits {"ready":bool,"blockers":[{"name","detail","recovery"}]}.
preflight() {
  local main=$1 branch=$2 wt=$3
  need_repo "$main" || return 3
  local N=() D=() R=()
  add() { N+=("$1"); D+=("$2"); R+=("$3"); }

  # pending-merge-on-main: an in-progress/abandoned merge blocks checkout & re-merge.
  if git -C "$main" rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then
    local u; u=$(git -C "$main" diff --name-only --diff-filter=U 2>/dev/null | tr '\n' ' ')
    add pending-merge-on-main "main has an in-progress merge (MERGE_HEAD present); unmerged: ${u:-none}" \
        "complete or abort it: resolve each unmerged path, git -C <main> add, git -C <main> commit --no-edit (or git -C <main> merge --abort) before landing"
  fi

  # dirty-main-worktree: modified/staged TRACKED files on main (WIP or implementer contamination).
  local dirty; dirty=$(dirty_tracked_json "$main")
  if [[ "$dirty" != "[]" ]]; then
    add dirty-main-worktree "main working tree has uncommitted tracked changes: $dirty" \
        "HALT: the shared main checkout has uncommitted work that may belong to a live session. Landing will NOT touch it — this process never stashes, discards (checkout --), or resets the shared main tree. The OWNER of that WIP must commit it on a branch or clear it themselves before landing."
  fi

  local ready=true i; ((${#N[@]})) && ready=false
  local out='{"ready":'"$ready"',"blockers":['
  for ((i=0;i<${#N[@]};i++)); do
    [[ $i -gt 0 ]] && out+=','
    out+='{"name":"'"$(jstr "${N[$i]}")"'","detail":"'"$(jstr "${D[$i]}")"'","recovery":"'"$(jstr "${R[$i]}")"'"}'
  done
  out+=']}'
  printf '%s\n' "$out"
}

# ── sync-base: merge <base> INTO <branch> in its worktree (resolve-on-branch) ─────────────────
# Brings the branch current with base so a later land is a clean fast-forward. Conflicts are LEFT
# in the worktree for the AGENT to resolve per the rubric (then add+commit), NEVER auto-picked.
# Emits {"status":"clean"|"conflict"|"up-to-date","files":[...]}.
sync-base() {
  local main=$1 branch=$2 base=$3 wt=$4
  need_repo "$wt" || return 3
  # Worktree must have the branch checked out (resolve-on-branch happens here, not on main).
  local head; head=$(git -C "$wt" symbolic-ref --quiet --short HEAD 2>/dev/null || echo "")
  [[ "$head" == "$branch" ]] || { echo "finish-branch sync-base: worktree HEAD is '$head', expected branch '$branch'" >&2; return 3; }
  git -C "$wt" rev-parse -q --verify "$base^{commit}" >/dev/null 2>&1 || { echo "finish-branch sync-base: base does not resolve: $base" >&2; return 3; }

  if git -C "$wt" merge-base --is-ancestor "$base" "$branch" 2>/dev/null; then
    emit_status up-to-date '"files":[]'; return 0
  fi
  if git -C "$wt" merge --no-edit "$base" >/dev/null 2>&1; then
    emit_status clean '"files":[]'; return 0
  fi
  # Non-clean: surface conflicted paths and LEAVE the merge in progress for the agent.
  local files; files=$(git -C "$wt" diff --name-only --diff-filter=U 2>/dev/null \
    | awk 'BEGIN{printf "["}{if(n++)printf ",";printf "\"%s\"",$0}END{printf "]"}')
  emit_status conflict '"files":'"$files"
}

# ── land-merge: GATED fast-forward of main to a VERIFIED branch head ──────────────────────────
# Mechanical floor for the user's guarantee "main only ever advances to a tested commit":
#   1) no conflict markers in the branch worktree   2) clean index (no in-progress/unresolved merge)
#   3) base is an ancestor of branch head (ff is actually possible)   4) project test command passes.
# Only when ALL hold does main fast-forward. Any failure -> abort to the AGENT as data (exit 0).
land-merge() {
  local main=$1 branch=$2 base=$3 wt=$4 testcmd=$5
  need_repo "$main" || return 3; need_repo "$wt" || return 3

  local marked; marked=$(conflict_marked "$wt")
  if [[ -n "$marked" ]]; then
    local f; f=$(printf '%s' "$marked" | awk 'BEGIN{printf "["}{if(n++)printf ",";printf "\"%s\"",$0}END{printf "]"}')
    emit_status conflict-markers '"detail":"unresolved conflict markers remain — agent must finish resolving per the rubric","files":'"$f"; return 0
  fi
  local dirty; dirty=$(dirty_tracked_json "$wt")
  if [[ "$dirty" != "[]" ]]; then
    emit_status dirty-tree '"detail":"branch worktree has an uncommitted/unresolved index — commit the resolution first","files":'"$dirty"; return 0
  fi
  if ! git -C "$main" merge-base --is-ancestor "$base" "$branch" 2>/dev/null; then
    emit_status not-ff '"detail":"base is not an ancestor of branch — run sync-base first to make the land a clean fast-forward"'; return 0
  fi
  # Test gate — the FLOOR. Runs even when sync reported clean (a marker-free merge can still be
  # semantically broken). Ceiling (did tests cover the conflicted lines?) stays with the agent.
  local tlog rc detail
  echo "finish-branch: test gate running (timeout ${FINISH_BRANCH_TEST_TIMEOUT_SECONDS:-1800}s)" >&2
  tlog=$(run_test_gate "$wt" "$testcmd" 2>&1); rc=$?
  if [[ $rc -ne 0 ]]; then
    local tail; tail=$(printf '%s' "$tlog" | tail -c 400)
    detail="project test command exited $rc — resolution is wrong or incomplete; do NOT land"
    [[ $rc -eq 124 || $rc -eq 137 ]] && detail="project test command timed out after ${FINISH_BRANCH_TEST_TIMEOUT_SECONDS:-1800}s — do NOT land"
    emit_status tests-failed '"detail":"'"$(jstr "$detail")"'","tail":"'"$(jstr "$tail")"'"'; return 0
  fi
  # All gates green -> fast-forward main to the verified head. ff-only can never rewrite main.
  if ! git -C "$main" merge --ff-only "$branch" >/dev/null 2>&1; then
    emit_status ff-failed '"detail":"git merge --ff-only refused — main moved or is dirty; re-run preflight"'; return 0
  fi
  local sha; sha=$(git -C "$main" rev-parse --short HEAD 2>/dev/null)
  emit_status landed '"base":"'"$(jstr "$base")"'","head":"'"$(jstr "$sha")"'"'
}

# ── land-pr: push branch + open a PR; NEVER touches main ──────────────────────────────────────
# Same test FLOOR as land-merge, minus the ff/ancestor check (a PR need not be ff). Deliberately
# does NOT sync base into the branch — pre-merging pollutes the PR diff with merge commits; sync
# only when the branch is actually unmergeable, as a separate explicit step.
# Fail-closed: no remote / no gh / not authed / PR already open -> abort to agent.
land-pr() {
  local main=$1 branch=$2 base=$3 wt=$4 testcmd=$5
  need_repo "$main" || return 3; need_repo "$wt" || return 3

  local marked; marked=$(conflict_marked "$wt")
  [[ -n "$marked" ]] && { emit_status conflict-markers '"detail":"unresolved conflict markers remain — resolve per the rubric before opening a PR"'; return 0; }
  local dirty; dirty=$(dirty_tracked_json "$wt")
  [[ "$dirty" != "[]" ]] && { emit_status dirty-tree '"detail":"commit the branch worktree before opening a PR","files":'"$dirty"; return 0; }
  local origin; origin=$(git -C "$main" remote get-url origin 2>/dev/null) || { emit_status no-remote '"detail":"no origin remote — cannot open a PR; add a remote or use merge-land"'; return 0; }
  command -v gh >/dev/null 2>&1 || { emit_status no-gh '"detail":"gh CLI not installed — cannot open a PR"'; return 0; }
  gh auth status >/dev/null 2>&1 || { emit_status gh-unauthed '"detail":"gh not authenticated — run: gh auth login"'; return 0; }
  # gh resolves the target repo AND (--fill) reads local git refs from its CWD. The orchestrator runs
  # land-pr from the AGENT's cwd (any repo, incl. an unrelated one) — so EVERY gh call must run inside
  # $main: there the repo auto-detects, -R pins it explicitly, and --fill's `base...branch` refs both
  # resolve (the branch ref is shared from its worktree). Running from elsewhere fails "ambiguous
  # argument 'base...branch'" or silently targets the wrong repo.
  local slug; slug=$(printf '%s' "$origin" | sed -E 's#^.*github\.com[:/]##; s#\.git$##')
  [[ -n "$slug" ]] || { emit_status no-remote '"detail":"origin is not a github.com remote — cannot open a PR via gh"'; return 0; }

  local existing ghlog ghrc gh_timeout=${FINISH_BRANCH_GH_TIMEOUT_SECONDS:-30}
  [[ "$gh_timeout" =~ ^[1-9][0-9]*$ ]] || { emit_status pr-list-failed '"detail":"FINISH_BRANCH_GH_TIMEOUT_SECONDS must be a positive integer"'; return 0; }
  ghlog=$(mktemp /tmp/finish-branch-gh-XXXXXX) || { emit_status pr-list-failed '"detail":"could not create gh diagnostic log"'; return 0; }
  existing=$( cd "$main" && timeout --foreground --kill-after=5s "${gh_timeout}s" gh pr list -R "$slug" --head "$branch" --state open --json number --jq '.[0].number' 2>"$ghlog" ); ghrc=$?
  if [[ $ghrc -ne 0 ]]; then
    local ghtail; ghtail=$(tail -c 400 "$ghlog" 2>/dev/null); rm -f "$ghlog"
    [[ $ghrc -eq 124 || $ghrc -eq 137 ]] \
      && emit_status pr-list-failed '"detail":"gh pr list timed out after '"$gh_timeout"'s; landing stopped before tests or push","tail":"'"$(jstr "$ghtail")"'"' \
      || emit_status pr-list-failed '"detail":"gh pr list exited '"$ghrc"'; landing stopped before tests or push","tail":"'"$(jstr "$ghtail")"'"'
    return 0
  fi
  rm -f "$ghlog"
  # Existing PRs still need the same gate and branch push so rerunning land publishes new commits.

  local tlog rc detail
  echo "finish-branch: test gate running (timeout ${FINISH_BRANCH_TEST_TIMEOUT_SECONDS:-1800}s)" >&2
  tlog=$(run_test_gate "$wt" "$testcmd" 2>&1); rc=$?
  if [[ $rc -ne 0 ]]; then
    detail="tests exited $rc — fix before opening a PR"
    [[ $rc -eq 124 || $rc -eq 137 ]] && detail="tests timed out after ${FINISH_BRANCH_TEST_TIMEOUT_SECONDS:-1800}s — fix before opening a PR"
    emit_status tests-failed '"detail":"'"$(jstr "$detail")"'","tail":"'"$(jstr "$(printf '%s' "$tlog" | tail -c 400)")"'"'; return 0
  fi

  git -C "$main" push -u origin "$branch" >/dev/null 2>&1 || { emit_status push-failed '"detail":"git push -u origin '"$branch"' failed — see stderr"'; return 0; }
  [[ -n "$existing" ]] && { emit_status pr-exists '"number":'"$existing"; return 0; }
  local url; url=$( cd "$main" && gh pr create -R "$slug" --base "$base" --head "$branch" --fill 2>/dev/null ) || { emit_status pr-create-failed '"detail":"gh pr create failed — branch is pushed; open the PR manually"'; return 0; }
  emit_status pr-opened '"url":"'"$(jstr "$url")"'"'
}

# ── deploy-preview: deploy the branch to a preview env, capture the live URL ──────────────────
# Runs <deploycmd> inside the branch worktree. Contract: deploycmd writes the reachable preview URL
# (single line) to $wt/.ship-preview-url. Fail-closed: a non-zero deploy OR a missing/empty URL file
# aborts to the agent — we NEVER run the e2e gate (and never promote) against a build we can't address.
# Emits {"status":"deployed","url":...} | {"status":"deploy-failed","tail":...} | {"status":"no-url",...}.
deploy-preview() {
  local wt=$1 deploycmd=$2
  need_repo "$wt" || return 3
  local urlfile="$wt/.ship-preview-url"; rm -f "$urlfile" 2>/dev/null || true
  local dlog rc; dlog=$( ( cd "$wt" && bash -c "$deploycmd" ) 2>&1 ); rc=$?
  if [[ $rc -ne 0 ]]; then
    emit_status deploy-failed '"detail":"deploy command exited '"$rc"'","tail":"'"$(jstr "$(printf '%s' "$dlog" | tail -c 400)")"'"'; return 0
  fi
  local url=""; [[ -f "$urlfile" ]] && url=$(head -n1 "$urlfile" 2>/dev/null | tr -d '[:space:]')
  if [[ -z "$url" ]]; then
    emit_status no-url '"detail":"deploy exited 0 but wrote no preview URL to .ship-preview-url — the deploy command must echo the reachable URL to that file; cannot verify or promote"'; return 0
  fi
  emit_status deployed '"url":"'"$(jstr "$url")"'"'
}

# ── e2e-gate: run the project e2e suite against the LIVE preview URL ───────────────────────────
# Runs <e2ecmd> in the worktree with PREVIEW_URL exported. Pure verification gate — mutates nothing.
# Emits {"status":"passed"} | {"status":"failed","tail":...}.
e2e-gate() {
  local wt=$1 e2ecmd=$2 url=$3
  need_repo "$wt" || return 3
  [[ -n "$url" ]] || { emit_status failed '"detail":"no preview URL supplied to e2e-gate"'; return 0; }
  local elog rc; elog=$( ( cd "$wt" && PREVIEW_URL="$url" bash -c "$e2ecmd" ) 2>&1 ); rc=$?
  if [[ $rc -ne 0 ]]; then
    emit_status failed '"detail":"e2e exited '"$rc"' against '"$(jstr "$url")"'","tail":"'"$(jstr "$(printf '%s' "$elog" | tail -c 400)")"'"'; return 0
  fi
  emit_status passed '"url":"'"$(jstr "$url")"'"'
}

# ── cleanup: ordered, asset-safe teardown ────────────────────────────────────────────────────
# Order is load-bearing: push (optional) -> worktree remove --force -> branch -d. Removing the
# worktree before deleting the branch avoids "branch is used by worktree"; removing it is
# IRREVERSIBLE, so gitignored assets (test suites, caches) are detected and the agent must preserve
# them first (re-run with --assets-ok). Flags let PR mode keep the branch and skip the main push.
cleanup() {
  local main=$1 branch=$2 wt=$3; shift 3
  local keep_branch=0 no_push=0 assets_ok=0
  while [[ $# -gt 0 ]]; do case $1 in
    --keep-branch) keep_branch=1;; --no-push) no_push=1;; --assets-ok) assets_ok=1;;
    *) echo "finish-branch cleanup: unknown flag: $1" >&2; return 3;; esac; shift; done
  need_repo "$main" || return 3

  # Irreversible-removal guard: expensive gitignored assets live ONLY in the worktree.
  if [[ "$assets_ok" -eq 0 && -d "$wt" ]]; then
    local found=()
    local p; for p in tmp .cache .turbo dist coverage; do
      [[ -d "$wt/$p" ]] && [[ -n "$(ls -A "$wt/$p" 2>/dev/null)" ]] && found+=("$p")
    done
    if ((${#found[@]})); then
      local fj; fj=$(printf '%s\n' "${found[@]}" | awk 'BEGIN{printf "["}{if(n++)printf ",";printf "\"%s\"",$0}END{printf "]"}')
      emit_status assets-present '"paths":'"$fj"',"recovery":"these gitignored dirs exist ONLY in the worktree and removal is irreversible — copy anything worth keeping to <main>/tmp, then re-run cleanup with --assets-ok"'
      return 0
    fi
  fi

  if [[ "$no_push" -eq 0 ]]; then
    git -C "$main" push >/dev/null 2>&1 || { emit_status push-failed '"detail":"git -C <main> push failed — main is landed locally but not pushed; resolve and re-run cleanup --no-push? no: push then re-run"'; return 0; }
  fi
  if [[ -d "$wt" ]]; then
    git -C "$main" worktree remove "$wt" --force >/dev/null 2>&1 || { emit_status worktree-remove-failed '"detail":"git worktree remove --force failed for '"$(jstr "$wt")"'"'; return 0; }
  fi
  if [[ "$keep_branch" -eq 0 ]]; then
    git -C "$main" branch -d "$branch" >/dev/null 2>&1 || { emit_status branch-delete-failed '"detail":"git branch -d '"$branch"' failed — not fully merged? use -D only after confirming it landed"'; return 0; }
  fi
  # run-plan liveness beacons: clear on clean land (no-op for the ship-skill path — files absent).
  if [[ "$branch" == plan/* ]]; then
    local _rp="${RUN_PLAN_RUN_DIR:-$HOME/.claude/workflows/.run}" _slug=${branch#plan/}
    rm -f "$_rp/$_slug.owner" "$_rp/$_slug.live" "$_rp/$_slug.pid" 2>/dev/null || true
  fi
  emit_status cleaned '"branch_kept":'"$([[ $keep_branch -eq 1 ]] && echo true || echo false)"
}

stage_a_enabled() {
  [[ "$(git -C "$1" config --get harness.landQueue 2>/dev/null || true)" == "stage-a" ]]
}

_stage_a_fetch_main() {
  git -C "$1" fetch --quiet --no-tags origin refs/heads/main:refs/remotes/origin/main
}

_stage_a_candidate_guard() {
  local candidate=$1
  local marked; marked=$(conflict_marked "$candidate")
  if [[ -n "$marked" ]]; then
    local files; files=$(printf '%s' "$marked" | awk 'BEGIN{printf "["}{if(n++)printf ",";printf "\"%s\"",$0}END{printf "]"}')
    emit_status candidate-conflict-markers '"files":'"$files"; return 20
  fi
  local dirty; dirty=$(dirty_tracked_json "$candidate")
  if [[ "$dirty" != "[]" ]]; then
    emit_status candidate-dirty-tree '"files":'"$dirty"; return 20
  fi
  return 0
}

_stage_a_source_guard() {
  local wt=$1
  local marked; marked=$(conflict_marked "$wt")
  if [[ -n "$marked" ]]; then
    local files; files=$(printf '%s' "$marked" | awk 'BEGIN{printf "["}{if(n++)printf ",";printf "\"%s\"",$0}END{printf "]"}')
    emit_status source-conflict-markers '"files":'"$files"; return 20
  fi
  local dirty; dirty=$(dirty_tracked_json "$wt")
  if [[ "$dirty" != "[]" ]]; then
    emit_status source-dirty-tree '"files":'"$dirty"; return 20
  fi
  return 0
}

_stage_a_cleanup() {
  local root=$1 branch=$2 wt=$3 source_sha=$4 assets_ok=$5
  if [[ -z "$assets_ok" && -d "$wt" ]]; then
    local found=()
    local p; for p in tmp .cache .turbo dist coverage; do
      [[ -d "$wt/$p" ]] && [[ -n "$(ls -A "$wt/$p" 2>/dev/null)" ]] && found+=("$p")
    done
    if ((${#found[@]})); then
      local fj; fj=$(printf '%s\n' "${found[@]}" | awk 'BEGIN{printf "["}{if(n++)printf ",";printf "\"%s\"",$0}END{printf "]"}')
      emit_status assets-present '"paths":'"$fj"',"recovery":"these gitignored dirs exist ONLY in the worktree and removal is irreversible — copy anything worth keeping to <main>/tmp, then re-run cleanup with --assets-ok"'
      return 20
    fi
  fi

  local current; current=$(git -C "$root" rev-parse -q --verify "refs/heads/$branch^{commit}" 2>/dev/null) || {
    emit_status source-moved '"branch_kept":true,"detail":"source branch no longer resolves; cleanup retained the worktree"'; return 0
  }
  if [[ "$current" != "$source_sha" ]]; then
    emit_status source-moved '"branch_kept":true,"detail":"source branch moved after snapshot; cleanup retained branch and worktree"'; return 0
  fi
  if [[ -d "$wt" ]]; then
    git -C "$root" worktree remove "$wt" --force >/dev/null 2>&1 || {
      emit_status worktree-remove-failed '"detail":"git worktree remove --force failed for '"$(jstr "$wt")"'"'; return 20
    }
  fi
  current=$(git -C "$root" rev-parse -q --verify "refs/heads/$branch^{commit}" 2>/dev/null) || {
    [[ ! -d "$wt" ]] && { emit_status cleaned '"branch_kept":false'; return 0; }
    emit_status branch-delete-failed '"detail":"source branch disappeared before deletion"'; return 20
  }
  if [[ "$current" != "$source_sha" ]]; then
    emit_status source-moved '"branch_kept":true,"detail":"source branch moved during cleanup; retained the moved branch"'; return 0
  fi
  git -C "$root" branch -D "$branch" >/dev/null 2>&1 || {
    emit_status branch-delete-failed '"detail":"git branch -D '"$(jstr "$branch")"' failed after reachability proof"'; return 20
  }
  if [[ "$branch" == plan/* ]]; then
    local _rp="${RUN_PLAN_RUN_DIR:-$HOME/.claude/workflows/.run}" _slug=${branch#plan/}
    rm -f "$_rp/$_slug.owner" "$_rp/$_slug.live" "$_rp/$_slug.pid" 2>/dev/null || true
  fi
  emit_status cleaned '"branch_kept":false'
}

_stage_a_land() (
  local root=$1 wt=$2 branch=$3 testcmd=$4 depcmd=$5 assets_ok=$6 verified_tree=${7:-}
  need_repo "$root" || return 3; need_repo "$wt" || return 3
  command -v flock >/dev/null 2>&1 || { echo "finish-branch: flock is required for stage-a landing" >&2; return 3; }

  local common_dir lock_timeout=${FINISH_BRANCH_LAND_LOCK_TIMEOUT_SECONDS:-300}
  [[ "$lock_timeout" =~ ^[1-9][0-9]*$ ]] || {
    echo "finish-branch: FINISH_BRANCH_LAND_LOCK_TIMEOUT_SECONDS must be a positive integer" >&2; return 3
  }
  common_dir=$(git -C "$root" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || return 3
  mkdir -p "$common_dir/harness" || return 3
  local lock_fd; exec {lock_fd}>"$common_dir/harness/land.lock" || return 3
  if ! flock -w "$lock_timeout" "$lock_fd"; then
    emit_status land-lock-timeout '"detail":"timed out waiting for serialized land lock"'; return 20
  fi

  local source_sha safe timestamp rescue_ref origin_url candidate="" candidate_ref="" authority_file="" guard rc
  source_sha=$(git -C "$root" rev-parse -q --verify "refs/heads/$branch^{commit}" 2>/dev/null) || {
    echo "finish-branch: source branch does not resolve: $branch" >&2; return 3
  }
  safe=${branch//[^[:alnum:]._-]/-}
  timestamp=$(date -u +%Y%m%dT%H%M%S%NZ) || return 3
  rescue_ref="refs/rescue/land/$timestamp-$safe"
  git -C "$root" update-ref "$rescue_ref" "$source_sha" || return 3
  guard=$(_stage_a_source_guard "$wt"); rc=$?
  [[ $rc -eq 0 ]] || { printf '%s\n' "$guard"; return "$rc"; }
  origin_url=$(git -C "$root" remote get-url --push origin 2>/dev/null) || {
    emit_status no-remote '"detail":"no origin remote for stage-a candidate"'; return 20
  }
  if [[ "$origin_url" != /* && "$origin_url" != *:* ]]; then
    local origin_parent origin_name
    origin_parent=$(cd "$root/$(dirname "$origin_url")" 2>/dev/null && pwd -P) || return 3
    origin_name=$(basename "$origin_url")
    origin_url="$origin_parent/$origin_name"
  fi

  _stage_a_release() {
    [[ -n "$authority_file" ]] && rm -f "$authority_file" 2>/dev/null || true
    [[ -n "$candidate_ref" ]] && git -C "$root" update-ref -d "$candidate_ref" 2>/dev/null || true
    [[ -n "$candidate" ]] && rm -rf "$candidate" 2>/dev/null || true
  }
  trap _stage_a_release EXIT
  trap 'exit 20' HUP INT TERM

  local attempt=1 base_sha latest_sha candidate_sha candidate_tree merge_log tlog detail nonce
  while (( attempt <= 3 )); do
    if ! _stage_a_fetch_main "$root"; then
      emit_status fetch-failed '"detail":"git fetch origin main failed"'; return 20
    fi
    base_sha=$(git -C "$root" rev-parse refs/remotes/origin/main 2>/dev/null) || return 3
    # candidate MUST live on a normal filesystem: /tmp is an overlay mount on this machine
    # and SQLite (pnpm per-mount store index) cannot open databases there
    # One candidate path per repository, not one per land: the path is the identity of the remote
    # build mirror and its bare repo, so a fresh path each land strands a full checkout on every
    # buildbox. The land lock above serializes this repo's lands, so reuse is exclusive.
    local cand_base="${FINISH_BRANCH_CANDIDATE_DIR:-$HOME/.cache/finish-branch}"
    mkdir -p "$cand_base" || return 3
    candidate="$cand_base/candidate-$(printf '%s' "$common_dir" | sha256sum | cut -c1-12)"
    rm -rf "$candidate" || return 3
    if ! git clone --quiet --shared --no-checkout "$root" "$candidate" 2>/dev/null; then
      emit_status candidate-create-failed '"detail":"disposable shared clone creation failed"'; return 20
    fi
    git -C "$candidate" remote set-url origin "$origin_url" || return 3
    git -C "$candidate" config user.name "$(git -C "$root" config user.name 2>/dev/null || printf 'harness lander')"
    git -C "$candidate" config user.email "$(git -C "$root" config user.email 2>/dev/null || printf 'harness@localhost')"
    git -C "$candidate" checkout --quiet --detach "$base_sha" || return 3
    merge_log="$candidate/.git/harness-merge.log"
    if ! git -C "$candidate" merge --no-ff -m "harness: land $branch at $source_sha" "$source_sha" >"$merge_log" 2>&1; then
      if git -C "$candidate" rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then
        emit_status candidate-conflict '"attempt":'"$attempt"',"rescue_ref":"'"$(jstr "$rescue_ref")"'"'; return 20
      fi
      emit_status candidate-merge-failed '"attempt":'"$attempt"',"detail":"candidate merge failed without a conflict"'; return 20
    fi

    if [[ -n "$depcmd" ]]; then
      if ! ( cd "$candidate" && bash -c "$depcmd" ) >"$candidate/.git/harness-deps.log" 2>&1; then
        emit_status candidate-deps-failed '"attempt":'"$attempt"; return 20
      fi
    fi
    guard=$(_stage_a_candidate_guard "$candidate"); rc=$?
    [[ $rc -eq 0 ]] || { printf '%s\n' "$guard"; return "$rc"; }
    echo "finish-branch: candidate test gate running (attempt $attempt, timeout ${FINISH_BRANCH_TEST_TIMEOUT_SECONDS:-1800}s)" >&2
    tlog=$(run_test_gate "$candidate" "$testcmd" 2>&1); rc=$?
    if [[ $rc -ne 0 ]]; then
      detail="candidate test command exited $rc — do NOT land"
      [[ $rc -eq 124 || $rc -eq 137 ]] && detail="candidate test command timed out after ${FINISH_BRANCH_TEST_TIMEOUT_SECONDS:-1800}s — do NOT land"
      emit_status candidate-tests-failed '"attempt":'"$attempt"',"detail":"'"$(jstr "$detail")"'","tail":"'"$(jstr "$(printf '%s' "$tlog" | tail -c 400)")"'"'; return 20
    fi
    guard=$(_stage_a_candidate_guard "$candidate"); rc=$?
    [[ $rc -eq 0 ]] || { printf '%s\n' "$guard"; return "$rc"; }

    if ! _stage_a_fetch_main "$root"; then
      emit_status fetch-failed '"detail":"post-gate git fetch origin main failed"'; return 20
    fi
    latest_sha=$(git -C "$root" rev-parse refs/remotes/origin/main 2>/dev/null) || return 3
    if [[ "$latest_sha" != "$base_sha" ]]; then
      rm -rf "$candidate"; candidate=""
      if (( attempt == 3 )); then
        emit_status remote-churn '"attempts":3'; return 20
      fi
      attempt=$((attempt+1)); continue
    fi

    candidate_sha=$(git -C "$candidate" rev-parse HEAD 2>/dev/null) || return 3
    candidate_tree=$(git -C "$candidate" rev-parse "$candidate_sha^{tree}" 2>/dev/null) || return 3
    if [[ -n "$verified_tree" && "$candidate_tree" != "$verified_tree" ]]; then
      emit_status e2e-stale-base '"detail":"candidate tree differs from e2e-verified source tree"'; return 20
    fi

    IFS= read -r nonce < /proc/sys/kernel/random/uuid || {
      echo "finish-branch: could not generate land authority nonce" >&2; return 3
    }
    [[ -n "$nonce" ]] || { echo "finish-branch: empty land authority nonce" >&2; return 3; }
    candidate_ref="refs/harness/candidate-land-$nonce"
    git -C "$root" fetch --quiet --no-tags "$candidate" "+$candidate_sha:$candidate_ref" || {
      emit_status candidate-import-failed '"detail":"could not import verified candidate into guarded repository"'; return 20
    }
    authority_file="$common_dir/harness/land-authority"
    rm -f "$authority_file" || return 3
    ( umask 077; printf '%s\n' "$nonce" > "$authority_file" ) || return 3
    chmod 0600 "$authority_file" || return 3
    if HARNESS_LAND_TOKEN="$nonce" git -C "$root" push origin "$candidate_ref:refs/heads/main" >/dev/null 2>&1; then
      rm -f "$authority_file" || { emit_status authority-cleanup-failed '"detail":"could not remove land authority file"'; return 20; }
      authority_file=""
      git -C "$root" update-ref -d "$candidate_ref" || return 3
      candidate_ref=""
    else
      rm -f "$authority_file" || { emit_status authority-cleanup-failed '"detail":"could not remove land authority file"'; return 20; }
      authority_file=""
      if _stage_a_fetch_main "$root"; then
        latest_sha=$(git -C "$root" rev-parse refs/remotes/origin/main 2>/dev/null) || return 3
        if [[ "$latest_sha" != "$base_sha" ]]; then
          rm -rf "$candidate"; candidate=""
          if (( attempt == 3 )); then emit_status remote-churn '"attempts":3'; return 20; fi
          attempt=$((attempt+1)); continue
        fi
      fi
      emit_status push-failed '"detail":"plain candidate push to origin main failed"'; return 20
    fi

    if ! _stage_a_fetch_main "$root"; then
      emit_status reachability-failed '"detail":"could not fetch origin main for reachability proof"'; return 20
    fi
    latest_sha=$(git -C "$root" rev-parse refs/remotes/origin/main 2>/dev/null) || return 3
    if [[ "$latest_sha" != "$candidate_sha" ]]; then
      emit_status reachability-failed '"detail":"origin main is not at verified candidate after push — refusing destructive cleanup"'; return 20
    fi
    _stage_a_cleanup "$root" "$branch" "$wt" "$source_sha" "$assets_ok"; return $?
  done
)

_stage_a_promote() {
  local root=$1 wt=$2 branch=$3 testcmd=$4 depcmd=$5 assets_ok=$6 verified_tree=${7:-}
  local result rc status
  result=$(_stage_a_land "$root" "$wt" "$branch" "$testcmd" "$depcmd" "$assets_ok" "$verified_tree"); rc=$?
  if [[ $rc -eq 3 ]]; then echo "$result" >&2; return 3; fi
  status=$(jq_status "$result")
  if [[ $rc -eq 0 && "$status" == "cleaned" ]]; then
    emit_ladder landed done none "candidate merged to origin/main, source worktree removed, branch deleted"; return 0
  fi
  if [[ $rc -eq 0 && "$status" == "source-moved" ]]; then
    emit_ladder landed done none "candidate merged to origin/main; moved source branch and worktree retained"; return 0
  fi
  printf '%s\n' "$result"
  emit_ladder candidate-land "$status" fix-then-rerun "serialized candidate land stopped — source and rescue ref retained"
  return 20
}

# ── drift: validate a project's FROZEN land facts still hold (fail-closed) ────────────────────
# Args: --root R --base B --mode M [--anchor KIND:VAL]. Emits {"status":"ok"} or, on divergence,
# the ladder fault line and returns 3. The single source of truth for "are this project's frozen
# facts still valid" — the per-project wrapper holds only the DATA, never this logic.
#   anchor KIND:VAL — deploy:<relpath-from-root>  (file must exist; merge-to-main deploy-on-push)
#                     remote:<url>                (origin remote-url must equal; PR destination)
#                     (empty/absent)              → no anchor check
drift() {
  local root="" base="" mode="" anchor=""
  while [[ $# -gt 0 ]]; do case "$1" in
    --root) root=$2; shift 2;; --base) base=$2; shift 2;;
    --mode) mode=$2; shift 2;; --anchor) anchor=$2; shift 2;;
    *) echo "finish-branch drift: unknown arg: $1" >&2; return 3;; esac; done
  [[ -n "$root" && -n "$base" && -n "$mode" ]] || { echo "usage: finish-branch.sh drift --root R --base B --mode M [--anchor KIND:VAL]" >&2; return 3; }

  [[ -e "$root/.git" ]] || { emit_ladder drift fault reinit "PROJECT_ROOT is not a git repo: $root"; return 3; }
  git -C "$root" rev-parse -q --verify "$base^{commit}" >/dev/null 2>&1 \
    || { emit_ladder drift fault reinit "base '$base' no longer resolves — re-init"; return 3; }
  [[ "$mode" == "merge-to-main" || "$mode" == "pr" || "$mode" == "deploy-verify" ]] \
    || { emit_ladder drift fault reinit "unknown LAND_MODE '$mode'"; return 3; }
  if [[ -n "$anchor" ]]; then
    local kind=${anchor%%:*} val=${anchor#*:}
    case "$kind" in
      deploy)
        [[ -f "$root/$val" ]] || { emit_ladder drift fault reinit "deploy signal gone ($val) — deploy mechanism changed, re-init"; return 3; };;
      remote)
        local actual; actual=$(git -C "$root" remote get-url origin 2>/dev/null)
        [[ "$actual" == "$val" ]] || { emit_ladder drift fault reinit "origin remote moved ('$actual' != frozen '$val') — PR destination changed, re-init"; return 3; };;
      *) emit_ladder drift fault reinit "unknown anchor kind '$kind'"; return 3;;
    esac
  fi
  return 0
}

# ── land: the per-project land ORCHESTRATOR (was duplicated in every wrapper) ──────────────────
# All facts arrive as flags so the per-project ship.sh is pure frozen DATA + a delegation line.
# Drives the primitives (preflight|sync-base|deps|land-merge|land-pr|cleanup) on the mode-correct
# path and maps each gate to the agent ladder. Exit 0 done / 20 agent-action-needed / 3 drift|usage.
#   land --root R --base B --mode M [--anchor A] --testcmd T [--depcmd D] -- <branch> <wt> [--assets-ok]
# _promote: the post-verification landing cascade for a given mode (pr|merge-to-main). Single source
# for BOTH direct pr/merge-to-main lands AND the deploy-verify promote step — so the cascade lives
# once. Drives the mode-correct primitives + ladder. Echoes JSON; returns 0 done / 20 action / 3 fault.
#   _promote <mode> <root> <base> <wt> <branch> <testcmd> <depcmd> <assets_ok>
# _mtm_prepare: merge-to-main PRE-land steps (main-side blockers → sync base into branch → reconcile
# deps to the bumped lockfile). Split out so deploy-verify can run it BEFORE deploy, making the head
# e2e verifies equal the head _mtm_land fast-forwards main to. Echoes JSON on a blocker; 0 ready/20/3.
#   _mtm_prepare <root> <base> <wt> <branch> <depcmd>
_mtm_prepare() {
  local root=$1 base=$2 wt=$3 branch=$4 depcmd=$5
  # 1) main-side blockers (dirty main / pending merge) — agent clears, re-runs.
  local pf; pf=$(preflight "$root" "$branch" "$wt") || { echo "$pf" >&2; return 3; }
  if [[ "$(ready_of "$pf")" != "true" ]]; then
    printf '%s\n' "$pf"; emit_ladder preflight blocked clear-blockers "main not clean — see blockers[].recovery, fix, re-run"; return 20
  fi
  # 2) bring branch current with base (resolve-on-branch). Conflict → agent resolves per rubric.
  local sb st; sb=$(sync-base "$root" "$branch" "$base" "$wt") || { echo "$sb" >&2; return 3; }
  st=$(jq_status "$sb")
  if [[ "$st" == "conflict" ]]; then
    printf '%s\n' "$sb"; emit_ladder sync-base conflict resolve-then-rerun "resolve per ~/.claude/workflows/lib/merge-conflict-rubric.md, commit in $wt, re-run"; return 20
  fi
  [[ "$st" == "clean" || "$st" == "up-to-date" ]] || { printf '%s\n' "$sb"; emit_ladder sync-base "$st" investigate "unexpected sync-base status"; return 20; }
  # 2b) reconcile worktree deps to the (possibly base-bumped) lockfile BEFORE the gate — else the
  # gate false-passes on stale node_modules and lands a lockfile-mismatched tree. Idempotent.
  if [[ -n "$depcmd" ]]; then
    local deplog="$wt/.ship-deps.log"
    if ! ( cd "$wt" && eval "$depcmd" ) >"$deplog" 2>&1; then
      emit_ladder deps-refresh failed resolve-then-rerun "dep reconcile ('$depcmd') failed in $wt — read $deplog (full stderr), fix deps, re-run"; return 20
    fi
  fi
  return 0
}
# _mtm_land: merge-to-main POST-verify landing (GATED ff of main to the verified head → ordered
# cleanup). Echoes JSON; returns 0 done / 20 action / 3 fault.
#   _mtm_land <root> <base> <wt> <branch> <testcmd> <assets_ok>
_mtm_land() {
  local root=$1 base=$2 wt=$3 branch=$4 testcmd=$5 assets_ok=$6
  # GATED ff (no markers + clean + ancestor + tests) → main advances to the verified head.
  local lm; lm=$(land-merge "$root" "$branch" "$base" "$wt" "$testcmd") || { echo "$lm" >&2; return 3; }
  if [[ "$(jq_status "$lm")" != "landed" ]]; then
    printf '%s\n' "$lm"; emit_ladder land-merge "$(jq_status "$lm")" fix-then-rerun "land gate not green (tests/markers/dirty/not-ff) — fix in $wt, re-run"; return 20
  fi
  # ordered cleanup: push main → remove worktree → delete branch.
  local cl; cl=$(cleanup "$root" "$branch" "$wt" $assets_ok)
  if [[ "$(jq_status "$cl")" != "cleaned" ]]; then
    printf '%s\n' "$cl"; emit_ladder cleanup "$(jq_status "$cl")" resolve-then-rerun "landed on main but cleanup stalled (assets/push/worktree) — see detail, re-run"; return 20
  fi
  emit_ladder landed done none "merged to $base, pushed, worktree removed, branch deleted"; return 0
}
_promote() {
  local mode=$1 root=$2 base=$3 wt=$4 branch=$5 testcmd=$6 depcmd=$7 assets_ok=$8
  if [[ "$mode" == "pr" ]]; then
    # PR mode: push branch + open PR (NEVER touch main). No preflight (main untouched — a dirty-main
    # WIP must not block a PR) and NO base-sync (pre-merging pollutes the PR diff). If the branch is
    # actually unmergeable, the agent syncs deliberately per the rubric, not the driver by default.
    local pr st; pr=$(land-pr "$root" "$branch" "$base" "$wt" "$testcmd") || { echo "$pr" >&2; return 3; }
    st=$(jq_status "$pr")
    if [[ "$st" != "pr-opened" && "$st" != "pr-exists" ]]; then
      printf '%s\n' "$pr"; emit_ladder land-pr "$st" fix-then-rerun "PR not opened — see detail"; return 20
    fi
    # Cleanup keeps the branch (the PR needs it) and never pushes main. Surface a stalled cleanup
    # (e.g. assets-present) instead of swallowing it — else the worktree leaks silently and the
    # --assets-ok re-entry is unreachable. The PR is already open; re-entry is safe (pr-exists).
    local cl; cl=$(cleanup "$root" "$branch" "$wt" --keep-branch --no-push $assets_ok)
    if [[ "$(jq_status "$cl")" != "cleaned" ]]; then
      printf '%s\n%s\n' "$pr" "$cl"
      emit_ladder cleanup "$(jq_status "$cl")" resolve-then-rerun "PR is OPEN, but worktree cleanup stalled (assets present?) — see detail; preserve assets to <main>/tmp, then re-run with --assets-ok"; return 20
    fi
    printf '%s\n' "$pr"; return 0
  fi

  # merge-to-main path: prepare (blockers→sync→deps) then GATED land+cleanup. Two-step so deploy-verify
  # can reuse prepare BEFORE deploy and land AFTER e2e — same single source for the direct land here.
  if stage_a_enabled "$root"; then
    _stage_a_promote "$root" "$wt" "$branch" "$testcmd" "$depcmd" "$assets_ok"; return $?
  fi
  _mtm_prepare "$root" "$base" "$wt" "$branch" "$depcmd" || return $?
  _mtm_land "$root" "$base" "$wt" "$branch" "$testcmd" "$assets_ok"; return $?
}

land() {
  local root="" base="" mode="" anchor="" testcmd="" depcmd="" deploycmd="" e2ecmd="" promote="" postlandcmd=""
  while [[ $# -gt 0 ]]; do case "$1" in
    --root) root=$2; shift 2;; --base) base=$2; shift 2;; --mode) mode=$2; shift 2;;
    --anchor) anchor=$2; shift 2;; --testcmd) testcmd=$2; shift 2;; --depcmd) depcmd=$2; shift 2;;
    --deploycmd) deploycmd=$2; shift 2;; --e2ecmd) e2ecmd=$2; shift 2;; --promote) promote=$2; shift 2;;
    --postlandcmd) postlandcmd=$2; shift 2;;
    --) shift; break;;
    *) echo "finish-branch land: unknown flag: $1" >&2; return 3;; esac; done
  local branch=${1:-} wt=${2:-}; shift 2 2>/dev/null || true
  local assets_ok=""; [[ "${1:-}" == "--assets-ok" ]] && assets_ok="--assets-ok"
  [[ -n "$root" && -n "$base" && -n "$mode" && -n "$testcmd" && -n "$branch" && -n "$wt" ]] \
    || { echo "usage: finish-branch.sh land --root R --base B --mode M [--anchor A] --testcmd T [--depcmd D] [--postlandcmd C] [--deploycmd C --e2ecmd C --promote pr|merge-to-main] -- <branch> <wt> [--assets-ok]" >&2; return 3; }

  drift --root "$root" --base "$base" --mode "$mode" --anchor "$anchor" || return 3

  # Repo-tracked landing policy: <root>/.land-policy pins the landing mode. Any content other
  # than 'merge-to-main' forbids every merge-to-main promote (direct, stage-a, deploy-verify) —
  # fail-closed at this single entry. Absent file = no restriction.
  if [[ "$mode" == "merge-to-main" || ( "$mode" == "deploy-verify" && "$promote" == "merge-to-main" ) ]] \
    && [[ -f "$root/.land-policy" ]]; then
    local _lp; _lp=$(tr -d '[:space:]' < "$root/.land-policy")
    if [[ "$_lp" != "merge-to-main" ]]; then
      emit_ladder land-policy blocked use-pr-mode "$root/.land-policy pins landing to '$_lp' — merge-to-main is forbidden for this repo; land via --mode pr (push branch, open PR, merge through the repo's PR gate)"
      return 20
    fi
  fi

  # deploy-verify: deploy to a preview env → e2e against the LIVE preview → promote ONLY on green.
  # The deploy + e2e steps PREPEND a verification gate; landing reuses the same _promote cascade.
  if [[ "$mode" == "deploy-verify" ]]; then
    [[ -n "$deploycmd" && -n "$e2ecmd" && -n "$promote" ]] \
      || { echo "finish-branch land: deploy-verify requires --deploycmd, --e2ecmd, --promote pr|merge-to-main" >&2; return 3; }
    [[ "$promote" == "pr" || "$promote" == "merge-to-main" ]] \
      || { echo "finish-branch land: --promote must be pr|merge-to-main, got '$promote'" >&2; return 3; }
    local stage_a_mtm=0 verified_tree=""
    [[ "$promote" == "merge-to-main" ]] && stage_a_enabled "$root" && stage_a_mtm=1
    # promote==merge-to-main: sync base into the branch + reconcile deps BEFORE deploy, so the head we
    # deploy + e2e-verify is the SAME head land-merge fast-forwards main to — NEVER promote an unverified
    # build (a post-e2e sync would advance the landed head past what e2e saw). promote==pr pushes the
    # tested head as-is (no pre-sync — matches land-pr's no-pollute-the-diff contract).
    if [[ "$promote" == "merge-to-main" && "$stage_a_mtm" -eq 0 ]]; then
      _mtm_prepare "$root" "$base" "$wt" "$branch" "$depcmd" || return $?
    fi
    local dp; dp=$(deploy-preview "$wt" "$deploycmd") || { echo "$dp" >&2; return 3; }
    if [[ "$(jq_status "$dp")" != "deployed" ]]; then
      printf '%s\n' "$dp"; emit_ladder deploy-preview "$(jq_status "$dp")" fix-then-rerun "preview deploy did not yield a reachable URL — fix deploy, re-run"; return 20
    fi
    local url; url=$(jq_field "$dp" url)
    if [[ "$stage_a_mtm" -eq 1 ]]; then
      verified_tree=$(git -C "$wt" write-tree 2>/dev/null) || return 3
    fi
    local eg; eg=$(e2e-gate "$wt" "$e2ecmd" "$url") || { echo "$eg" >&2; return 3; }
    if [[ "$(jq_status "$eg")" != "passed" ]]; then
      printf '%s\n%s\n' "$dp" "$eg"; emit_ladder e2e-gate failed fix-then-rerun "e2e failed against preview $url — fix, do NOT promote, re-run"; return 20
    fi
    printf '%s\n%s\n' "$dp" "$eg"
    # land the verified head. merge-to-main: prepare already ran (head is current) → land-only (a second
    # sync would be a no-op but land-merge's ancestor re-check still fail-closes if base moved during e2e).
    if [[ "$stage_a_mtm" -eq 1 ]]; then
      _stage_a_promote "$root" "$wt" "$branch" "$testcmd" "$depcmd" "$assets_ok" "$verified_tree"; return $?
    fi
    if [[ "$promote" == "merge-to-main" ]]; then
      _mtm_land "$root" "$base" "$wt" "$branch" "$testcmd" "$assets_ok"; return $?
    fi
    _promote "$promote" "$root" "$base" "$wt" "$branch" "$testcmd" "$depcmd" "$assets_ok"; return $?
  fi

  local prc; _promote "$mode" "$root" "$base" "$wt" "$branch" "$testcmd" "$depcmd" "$assets_ok"; prc=$?
  [[ $prc -eq 0 ]] || return $prc

  # postlandcmd (merge-to-main only): local push=deploy — runs in root AFTER base is landed+pushed.
  # A failure here is a DEPLOY failure, not a land failure: base is already durable. Fail-closed
  # reporting, but the operator must NOT re-run land (branch is gone) — fix and run the cmd directly.
  if [[ "$mode" == "merge-to-main" && -n "$postlandcmd" ]]; then
    # root IS the deployment source: bring its base checkout up to the just-landed head first
    # (ff-only — a diverged/dirty root is a deploy failure, never guessed around).
    if [[ "$(git -C "$root" branch --show-current 2>/dev/null)" == "$base" ]]; then
      if ! git -C "$root" merge --ff-only --quiet "origin/$base" 2>/dev/null; then
        emit_ladder post-land-deploy failed fix-then-deploy "base LANDED+pushed OK; root $base cannot fast-forward to origin/$base — reconcile root, then run the deploy command directly: $postlandcmd"
        return 20
      fi
    fi
    local plog plrc; plog=$( ( cd "$root" && bash -c "$postlandcmd" ) 2>&1 ); plrc=$?
    if [[ $plrc -ne 0 ]]; then
      printf '%s\n' "$plog" >&2
      emit_ladder post-land-deploy failed fix-then-deploy "base LANDED+pushed OK; local deploy exited $plrc — do NOT re-run land; fix, then run the deploy command directly: $postlandcmd"
      return 20
    fi
    printf '%s\n' "$plog" | tail -1
  fi
  return 0
}

# ── dispatch (only when executed, not when sourced by the test harness) ───────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  cmd=${1:-}; shift || true
  case "$cmd" in
    preflight)      preflight      "$@";;
    sync-base)      sync-base      "$@";;
    land-merge)     land-merge     "$@";;
    land-pr)        land-pr        "$@";;
    deploy-preview) deploy-preview "$@";;
    e2e-gate)       e2e-gate       "$@";;
    cleanup)        cleanup        "$@";;
    land)           land           "$@";;
    drift)          drift          "$@" && emit_ladder drift ok none "frozen facts still hold";;
    *) echo "usage: finish-branch.sh {preflight|sync-base|land-merge|land-pr|deploy-preview|e2e-gate|cleanup|land|drift} ..." >&2; exit 2;;
  esac
fi
