#!/usr/bin/env bash
# FH Destructive-Op Gate — Pre-Push Hook
#
# Mechanically enforces the Destructive-Op Gate (CLAUDE.md) for the *git-side*
# irreversible surfaces the **pre-commit** hook cannot see, because they happen at
# push time, not commit time:
#   • remote branch deletion       (git push origin --delete X  /  git push origin :X)
#   • tag / notes ref deletion      (git push origin :refs/tags/vX)
#   • FORCE / non-fast-forward push (history rewrite — git push -f)
#   • implicit deletes from         git push --mirror / --prune
#
# What it is and is NOT (honest scope):
#   • It closes the **honest-weak-model** gap: an agent that simply *forgot* the prose
#     gate is now stopped by a mechanical block. That is the real, common win.
#   • It does NOT close the **injected/adversarial** gap: an agent under instruction can
#     set DESTRUCTIVE_OP_OK=1 or --no-verify (any client-side hook is bypassable, and
#     this hook is readable). The actual mechanical floor for the adversarial case is
#     **server-side branch protection** (GitHub "Restrict deletions" / "Restrict force
#     pushes"). This hook is the honest-model floor; branch protection is the hard floor.
#   • It covers only git pushes FROM a hook-installed repo. Non-git irreversible ops
#     (separate-repo `gh repo create --public`, visibility flip, `npm publish`) are
#     genuinely un-hookable → prose + templates/PRE-PUBLISH-CHECKLIST.md.
#
# FH-internal infra: activated only via `core.hooksPath=templates/.git-hooks`, not
# installed into field projects.
#
# Degrade direction: irreversible surface → fail-CLOSED. Missing tooling / unresolvable
# base / unfetched remote tip → BLOCK (never silently allow). Verified: no fail-open path.
#
# Override (explicit, logged — mirrors the pre-commit PUBLIC_SURFACE_OK channel):
#   DESTRUCTIVE_OP_OK=1 git push …   ← use AFTER enumerate + recover are done.
#
# Install (one-time, from repo root):
#   git config core.hooksPath templates/.git-hooks
#   chmod +x templates/.git-hooks/pre-push
#
# git passes on stdin, one line per ref:  <local_ref> <local_sha> <remote_ref> <remote_sha>

set -uo pipefail

REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
[ -z "$REPO_ROOT" ] && { echo "  ❌ pre-push: not in a git repo — fail-closed (block)"; exit 1; }
ZERO="0000000000000000000000000000000000000000"
PUSH_REMOTE="${1:-origin}"   # git passes the remote NAME as $1; ranges are scoped to it (see below)

# ── Push zone: a non-owner github.com remote is not a plain-push channel ─────────────────────
# CLAUDE.local.md §REST API push 계정규칙: a push under a non-owner account goes through the REST
# Contents API (new files only, never overwrite/delete) — plain `git push` is not that channel.
# That rule was correctly applied to ONE org repo and missed on ANOTHER org repo in the SAME round
# (2026-07-26) — not from ignorance, but because it was applied at the point of discovery instead
# of enumerated across every remote in play (the half-fix / propagation-boundary class).
# `scripts/push_zone_check.sh` closes the ENUMERATE half by hand, before a push round, and it
# explicitly does not block. This block closes the other half mechanically: it cannot see the whole
# round, but it CAN refuse the one push in front of it when THIS push's own remote sits outside the
# owner list — exactly the "forgot on this one" shape of the 2026-07-26 miss.
#
# Degrade direction (Surface-Class Degrade Invariant): applicability is mechanical, never guessed.
#   • no owner-account list         → UNCALIBRATED, plain push allowed (a guessed verdict could
#     authorise the wrong channel, which is worse than no verdict at all)
#   • remote host is not github.com → out of scope for this axis (the account rule is a github.com
#     personal-account axis; a GHE / other host remote says so and is allowed, not silently risky)
#   • remote URL does not parse into a known shape → UNMEASURED, allowed (a harness gap, not a
#     policy decision — NOT the destructive-branch UNCLASSIFIABLE case below, which fails closed
#     because a delete is irreversible; blocking every unfamiliar remote-URL shape here would just
#     train --no-verify on this same hook's real, irreversible guards)
#   • owner ∈ list → silent (the common, expected case)
#   • owner ∉ list → BLOCK. Never prints the owner list itself — only the single non-matching
#     owner already public in the URL being pushed to.
#
# Override (explicit, logged — same shape as DESTRUCTIVE_OP_OK / PUBLIC_SURFACE_OK):
#   PUSH_ZONE_OK=1 git push …
PUSH_ZONE_SRC="${PUSH_ZONE_OWNERS:-$REPO_ROOT/.claude/rules/.push-zone-owners}"
if [ ! -f "$PUSH_ZONE_SRC" ]; then
  echo "  ⏭️  push-zone: UNCALIBRATED — no owner-account list at $PUSH_ZONE_SRC, plain push allowed"
else
  # One owner per line. Inline comments (`owner # note`), CR line endings, surrounding whitespace and a
  # trailing slash are stripped BEFORE tokenizing — cross-family (codex gpt-5.5, 2026-09-05) showed an
  # inline-comment word being read as an ALLOWED owner (fail-open) and a CRLF list blocking its own owner.
  _pz_owners="$(sed -e 's/#.*$//' -e 's/\r$//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's#/*$##' "$PUSH_ZONE_SRC" 2>/dev/null | grep -v '^$' | tr '[:upper:]' '[:lower:]')"
  _pz_url="${2:-}"
  # git passes the destination's URL as $2. A bare remote NAME (no ':' and no '/') means the URL was
  # not resolved for us (alias / insteadOf edge) — resolve it here rather than reading a name as a host
  # and quietly landing in the "not github.com" allow branch (codex #5).
  case "$_pz_url" in
    *:*|*/*|"") ;;
    *) _pz_url="$(git remote get-url --push "$_pz_url" 2>/dev/null || printf '%s' "$_pz_url")" ;;
  esac
  if [ -z "$_pz_url" ]; then
    echo "  ⏭️  push-zone: UNMEASURED — remote URL not supplied to the hook (\$2 empty), plain push allowed"
  else
    # Normalise before parsing: lowercase, trim, drop the scheme, drop userinfo (`user@`). The HOST is then
    # everything up to the first ':' or '/'. This accepts every github.com shape git itself accepts —
    # `https://user@github.com/o/r`, `https://github.com:443/o/r`, `git@github.com:o/r`, `git@github.com:/o/r`,
    # `ssh://git@github.com:22/o/r` — instead of an allowlist of three literal prefixes that read the rest as
    # "unrecognized → allow" (codex #1–#4: four real github.com shapes were failing OPEN).
    _pz_url_lc="$(printf '%s' "$_pz_url" | tr '[:upper:]' '[:lower:]' | sed -e 's/[[:space:]]*$//' -e 's/^[[:space:]]*//')"
    _pz_norm="$(printf '%s' "$_pz_url_lc" | sed -E 's#^[a-z][a-z0-9+.-]*://##; s#^[^@/]*@##')"
    _pz_host="$(printf '%s' "$_pz_norm" | sed -E 's#[:/].*$##')"
    if [ "$_pz_host" = "github.com" ]; then
      _pz_path="$(printf '%s' "$_pz_norm" | sed -E 's#^github\.com(:[0-9]+)?[:/]+##')"
      _pz_owner="${_pz_path%%/*}"
      if [ -z "$_pz_owner" ] || [ "$_pz_path" = "$_pz_norm" ]; then
        echo "  ⏭️  push-zone: UNMEASURED — github.com remote in an unrecognized URL shape, plain push allowed"
      else
        _pz_hit=0
        for _pz_o in $_pz_owners; do
          [ "$_pz_owner" = "$_pz_o" ] && { _pz_hit=1; break; }
        done
        if [ "$_pz_hit" -eq 0 ]; then
          echo ""
          echo "══════════════════════════════════════════════"
          echo " ⛔ FH Push-Zone Gate — remote is outside the owner-account list"
          echo "══════════════════════════════════════════════"
          echo "  remote '$PUSH_REMOTE' → github.com/$_pz_owner"
          echo "  Plain git push is not this remote's channel — CLAUDE.local.md §REST API push"
          echo "  계정규칙 (a non-owner account pushes via the REST Contents API instead)."
          echo "  Deliberate exception (explicit, logged):  PUSH_ZONE_OK=1 git push …"
          echo "══════════════════════════════════════════════"
          if [ "${PUSH_ZONE_OK:-0}" = "1" ]; then
            echo "  ⚠️  FH Push-Zone: allowed by PUSH_ZONE_OK=1 (conscious, gated intent)"
            mkdir -p "$REPO_ROOT/tracks/_meta" 2>/dev/null || true
            printf '%s PUSH_ZONE_OK override — remote:%s owner:%s\n' \
              "$(date +%Y-%m-%dT%H:%M:%S)" "$PUSH_REMOTE" "$_pz_owner" \
              >> "$REPO_ROOT/tracks/_meta/.push_zone_override_log" 2>/dev/null || true
          else
            exit 1
          fi
        fi
      fi
    else
      echo "  ⏭️  push-zone: remote host is not github.com — outside this axis's scope, plain push allowed"
    fi
  fi
fi

BASE="${FH_DESTRUCTIVE_BASE:-origin/main}"
# All ancestry/reachability checks below must ignore local `git replace`/graft objects — a graft can
# falsify merge-base/rev-list/ls-tree to make a divergent force look fast-forward or an unmerged branch
# look SAFE (cross-family audit 2026-06-27, reproduced). Export here so every git call in the hook honors it.
export GIT_NO_REPLACE_OBJECTS=1

DEL_BRANCHES=""   # space-separated "<tip-sha>|refs/heads/X" pairs — SHA first so a '|' that is LEGAL in a
                  # ref name (git check-ref-format allows it) cannot corrupt the split (bash-3.2 safe).
DEL_OTHER=""      # refs/tags/* refs/notes/* etc. being deleted
FORCED_REFS=""
UNCLASSIFIED=""   # force-check impossible (remote tip not fetched)
DIRECT_MAIN=""    # non-delete update pushed straight at the integration branch (PR-only policy)
TAG_MISMATCH=""   # vX.Y.Z tag whose commit's package.json disagrees (irreversible-adjacent)
SEP=$'\n'   # ranges are newline-separated and evaluated ONE REF AT A TIME: concatenating
            # them into one arg string let `--not` from ref A flip polarity for ref B, so a
            # multi-ref push could return an empty or wrong commit set (R6 audit 2026-07-26).
PUSH_RANGES=""    # rev-list args for exactly the commits this push would publish (R5 audit 2026-07-26)

# --- stacked-branch advisory (ADVISORY — never changes the verdict) --------------------------
# Warns when the branch being pushed carries commits that already live on ANOTHER unmerged remote
# branch. That is the signature of a branch cut while standing on a feature branch instead of on
# the integration branch.
#
# Measured origin (2026-07-27, a field harness, PRs #38/#39): a branch was cut off a feature branch
# by accident, so the child PR carried the parent's three commits (parent PR = 3 commits, measured).
# From there BOTH available routes cost something, and both bills arrive at parent-merge time:
#   (a) leave the child based on the integration branch → its diff shows the parent's changes too,
#       and once the parent is squash-merged the SHAs no longer match, so the child goes CONFLICTING;
#   (b) retarget the child onto the parent branch to clean the diff → merging the parent with
#       `--delete-branch` deletes that base and GitHub **CLOSES** the child rather than retargeting it.
# The observed run took (b) and ended CLOSED + CONFLICTING (`state=CLOSED`, `mergeable=CONFLICTING`).
# In both routes the tempting recovery is `git push -f`, which walks straight into the irreversible
# surface THIS HOOK exists to guard. (Clean recovery: re-cut from the integration branch and
# cherry-pick — no history rewrite.)
#
# Why it is worth a line in a destructive-op hook: the mistake happens at branch-cut time but only
# bites at parent-merge time, so the two are hard to connect — and its natural "fix" is a history
# rewrite. Surfacing it at push time removes the pressure before it reaches the force-push path.
#
# ADVISORY on purpose (Surface-Class Degrade Invariant, reversible half): intentional stacked PRs
# are a legitimate workflow, so blocking would be pure over-blocking — and over-blocking trains
# `--no-verify`, which disarms the IRREVERSIBLE guards in this same hook. What this closes is
# silence, not permission.
fh_stacked_branch_advisory() {
  _sb_ref="$1"; _sb_sha="$2"
  case "$_sb_ref" in refs/heads/*) ;; *) return 0 ;; esac
  _sb_self="${_sb_ref#refs/heads/}"; _sb_hit=""

  # Single baseline — this hook already resolved one at the top (BASE). Wave-1 caught the first
  # draft inventing a 4-candidate fallback chain while its own comment claimed it reused BASE:
  # comment and code disagreed, and the extra candidates existed nowhere else in the file.
  if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null 2>&1; then
    # 부재는 통과가 아니다 — a silent `return` here is indistinguishable from "scanned, nothing
    # found". Say that the scan did not happen. (Advisory, so it still does not block.)
    printf '  ℹ️  [fh-advisory:stacked-branch] SKIPPED (all refs) — base %s does not resolve (FH_DESTRUCTIVE_BASE or default origin/main; shallow clone / unfetched remote). Not scanned. Fix: git fetch origin, or set FH_DESTRUCTIVE_BASE.\n' "$BASE" >&2
    return 0
  fi
  # Orphan / unrelated history has no merge-base, so `BASE..HEAD` would enumerate the branch's
  # ENTIRE history — none of which is "cut off the wrong base". Different situation, not this one.
  if ! git merge-base "$BASE" "$_sb_sha" >/dev/null 2>&1; then
    return 0
  fi

  # Ref enumeration uses for-each-ref with an explicit TAB-delimited format — never the human
  # `git branch` output. Cross-family review (codex, 2026-07-27) named three defects that all
  # traced to that one choice: `origin/HEAD -> origin/main` parses as ref="HEAD", branch names
  # containing spaces get truncated (so self-exclusion can fail), and `* `/`+ ` prefixes leak in.
  # for-each-ref emits refs, not a display listing, so the input grammar is actually what the
  # parser assumes.
  #
  # Self-exclusion is by EXACT name comparison, never by interpolating the branch name into a
  # regex. Wave-1 reproduced that bug: a branch named `feat/a.b` built the pattern `/feat/a.b$`,
  # whose `.` also matched a genuinely different branch `origin/feat/aXb` — grep -v dropped the
  # REAL hit and the advisory silently no-opped on a true positive.
  #
  # The integration branch is excluded by its RESOLVED name (from BASE), not by hard-coded
  # `main`/`master` (cross-family MED-2): hard-coding hides a genuine stack built on a local
  # branch that merely happens to be called `main` in a repo whose base is something else.
  _sb_base_name="${BASE##*/}"
  #
  # ⚠️ Each commit in the range is checked, NOT just the tip. Wave-2 briefly rewrote this to test
  # `$_sb_sha` alone and the anchors caught it immediately: a child branch that adds its own commit
  # has a tip nobody else carries — the parent's commits are the evidence, and they sit BELOW the
  # tip. Bound: newest 50, stated in the message (a bounded best-effort advisory, not a proof).
  _sb_others=""
  for _sb_c in $(git rev-list "${BASE}..${_sb_sha}" 2>/dev/null | head -50); do
    _sb_others=$(
      { git for-each-ref --contains "$_sb_c" --format='remote	%(refname:short)' refs/remotes 2>/dev/null
        # Local refs too: the mistake is made BEFORE the parent is ever pushed, which is the most
        # likely moment for it. A remote-only check structurally misses that case (Wave-1 A).
        git for-each-ref --contains "$_sb_c" --format='local	%(refname:short)' refs/heads 2>/dev/null
      } | awk -F'\t' -v self="$_sb_self" -v basename="$_sb_base_name" -v base="$BASE" '
          { kind=$1; ref=$2
            name=ref
            if (kind == "remote") sub(/^[^\/]*\//, "", name)   # drop the remote segment only
            if (name == self || name == "HEAD" || name == basename) next
            if (ref == base) next
            print kind "  " ref }' | head -3
    )
    [ -n "$_sb_others" ] && { _sb_hit="$_sb_c"; break; }
  done
  [ -n "$_sb_others" ] || return 0

  printf '\n  ⚠️  [fh-advisory:stacked-branch] %s carries commit(s) that already live on another branch.\n' "$_sb_self" >&2
  printf '     shared commit example: %s\n' "$(git log -1 --format='%h %s' "$_sb_hit" 2>/dev/null | cut -c1-72)" >&2
  printf '     also on:\n' >&2
  printf '%s\n' "$_sb_others" | sed 's/^/       - /' >&2
  printf '     Intentional stack? Ignore this. Otherwise the branch was cut off a feature branch\n' >&2
  printf '     instead of %s — re-cut from %s and cherry-pick BEFORE opening the PR.\n' "$BASE" "$BASE" >&2
  printf '     Merging the parent with --squash --delete-branch CLOSES the child PR rather than\n' >&2
  printf '     retargeting it, and `git push -f` to recover is the surface this hook guards.\n' >&2
  printf '     (Advisory only — does not block. Scanned the NEWEST 50 commits of %s..%s.)\n\n' "$BASE" "$_sb_self" >&2
  return 0
}

while read -r local_ref local_sha remote_ref remote_sha; do
  [ -z "${remote_ref:-}" ] && continue
  # Advisory first — it can never alter the verdict, and it must be visible even when a guard
  # below blocks (the two findings are independent and the operator wants both at once).
  [ "$local_sha" = "$ZERO" ] || fh_stacked_branch_advisory "$local_ref" "$local_sha"
  if [ "$local_sha" = "$ZERO" ]; then
    # local side zero → this refspec DELETES remote_ref
    case "$remote_ref" in
      refs/heads/*) DEL_BRANCHES="$DEL_BRANCHES ${remote_sha}|${remote_ref}" ;;
      *)            DEL_OTHER="$DEL_OTHER $remote_ref" ;;
    esac
  elif [ "${remote_sha:-$ZERO}" != "$ZERO" ]; then
    # updating an existing remote ref.
    case "$remote_ref" in
      refs/tags/*)
        # Any update to an EXISTING tag is a forced move of a published/external anchor — destructive
        # regardless of ancestry (a descendant retag passes the merge-base FF test but is still a force).
        FORCED_REFS="$FORCED_REFS $remote_ref" ;;
      *)
        # Branch update: fast-forward iff remote_sha is an ancestor of local_sha. Confirm remote_sha is
        # present locally first — if not, cannot classify (fail-closed, say so rather than mislabel FORCE).
        if ! git cat-file -e "${remote_sha}^{commit}" 2>/dev/null; then
          UNCLASSIFIED="$UNCLASSIFIED $remote_ref"
        elif ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then
          FORCED_REFS="$FORCED_REFS $remote_ref"
        fi ;;
    esac
  fi
  # TAG/VERSION CONSISTENCY — a `vX.Y.Z` tag must point at a commit whose package.json says X.Y.Z.
  # WHY (measured 2026-08-02): a release tag was pushed onto the WRONG commit because `git pull`
  # had failed with a diverging-branches error that went unread, so `main` was still the pre-release
  # tree. `npm publish` was then attempted from that same tree — and the ONLY thing that stopped it
  # was npm's own "cannot publish over 1.4.84" collision check. Publishing is irreversible; being
  # saved by the registry's bookkeeping is luck, not a floor.
  # This is N=1 and it is built anyway: the repetition rule ("1-2 occurrences -> prose") is scoped to
  # REVERSIBLE surfaces. A wrong tag on a public repo plus a publish from the wrong tree is the
  # irreversible class, where this repo's own Surface-Class Degrade Invariant says fail-CLOSED now.
  # Scope is deliberately narrow: only a NEW `refs/tags/v<digits>` push, only when package.json
  # exists at that commit. Absent package.json -> not applicable (a tag in a non-npm repo is fine);
  # unreadable package.json at a commit that HAS one -> BLOCK (cannot decide != allowed).
  if [ "$local_sha" != "$ZERO" ] && [ "${remote_sha:-$ZERO}" = "$ZERO" ]; then
    case "$remote_ref" in
      refs/tags/v[0-9]*)
        _tv="${remote_ref#refs/tags/v}"
        _commit=$(git rev-parse "${local_sha}^{commit}" 2>/dev/null)
        if [ -z "$_commit" ]; then
          echo "  ⛔ TAG/VERSION: $remote_ref does not resolve to a commit — cannot verify, refusing"
          TAG_MISMATCH="$TAG_MISMATCH $remote_ref"
        elif git cat-file -e "${_commit}:package.json" 2>/dev/null; then
          _pv=$(git show "${_commit}:package.json" 2>/dev/null \
                | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
          if [ -z "$_pv" ]; then
            echo "  ⛔ TAG/VERSION: package.json exists at ${_commit} but its version is unreadable — refusing"
            TAG_MISMATCH="$TAG_MISMATCH $remote_ref"
          elif [ "$_pv" != "$_tv" ]; then
            echo "  ⛔ TAG/VERSION MISMATCH: $remote_ref points at ${_commit}, whose package.json says $_pv"
            TAG_MISMATCH="$TAG_MISMATCH $remote_ref"
          fi
        fi ;;
    esac
  fi

  # PR-only policy: a non-delete update aimed straight at the integration branch.
  # Detected INSIDE this loop because this is the only place git's ref list is readable.
  if [ "$local_sha" != "$ZERO" ]; then
    case "$remote_ref" in
      refs/heads/main|refs/heads/master) DIRECT_MAIN="$DIRECT_MAIN $remote_ref" ;;
    esac
    # The EXACT set of commits this push publishes. Previously the load-bearing check guessed with
    # HEAD~1..HEAD, which missed a multi-commit first push whose gate edit was not the tip (R5 audit).
    # New branch (remote_sha ZERO) → everything not already on some remote, not the whole history.
    if [ "${remote_sha:-$ZERO}" = "$ZERO" ]; then
      # Scope the exclusion to the remote being pushed TO. `--not --remotes` excludes commits
      # reachable from ANY remote, so a new branch whose commits already sit on a DIFFERENT remote
      # (a fork, a mirror, a company GHE alongside the public origin) produced an EMPTY range —
      # nothing scanned, clean line printed, and the push published them here for the first time
      # (R7 audit, 2026-07-26). $1 is the remote name git passes to this hook.
      PUSH_RANGES="${PUSH_RANGES}${PUSH_RANGES:+$SEP}$local_sha --not --remotes=${PUSH_REMOTE}"
    else
      PUSH_RANGES="${PUSH_RANGES}${PUSH_RANGES:+$SEP}${remote_sha}..${local_sha}"
    fi
  fi
  # remote_sha == ZERO → creating a new branch (not destructive) → ignore
done

# ── Confidentiality at the PUBLISH boundary — instrument completeness + content ───
# Pattern loading and matching come from scripts/psa_scan_lib.sh, the single implementation shared
# with pre-commit and the publish scanner. Every confidentiality defect found in the 2026-07-26
# cross-family audit was a divergence between three near-duplicate copies of that logic, so the
# copies were removed. What stays HERE is the part that genuinely differs by surface: the degrade
# direction. A push is the act that makes content public and is not undoable, so an incomplete
# instrument BLOCKS here, whereas the same state only warns at commit time (a commit is local and
# re-committable, and the operator override is gitignored — absent on every fresh clone by
# construction, so blocking there would train the override into a reflex).
# APPLICABILITY FIRST, before anything is required to exist. The mechanical test for "is this an FH
# checkout at all" is the COMMITTED pattern source; a bare repo with this hook copied into it (this
# repo's own selfcheck fixture is exactly that) has neither the patterns nor the library, and demanding
# them there is over-blocking, not fail-closed. Deleting the defaults to reach this state is not a free
# bypass — it is a commit against a .claude/rules/ path, which the commit gate treats as HEAVY and
# universal_guard_check pins in both directions.
PSA_LIB="$REPO_ROOT/scripts/psa_scan_lib.sh"
_PP_APPLICABLE=1
if [ ! -e "$REPO_ROOT/.claude/rules/.public-surface-patterns.defaults" ]; then
  _PP_APPLICABLE=0
fi
if [ "$_PP_APPLICABLE" -eq 0 ]; then
  # An inapplicable check must SAY it is inapplicable. A silent skip is indistinguishable from a
  # check that aborted, and "skipped" reading as "passed" is the defect class this whole gate exists
  # to remove — including when the skip is correct.
  echo "  ⏭️  FH Pre-Publish: N/A — no committed pattern source in this repo (not an FH checkout)"
elif [ ! -r "$PSA_LIB" ]; then
  # The pattern source IS here, so this IS an FH checkout — a missing library is then a broken
  # instrument on a publish surface, not an inapplicable one.
  echo "  ❌ scripts/psa_scan_lib.sh missing — the confidentiality scanner cannot run."
  [ "${PUBLIC_SURFACE_OK:-0}" = "1" ] || { echo "     Fail-closed on the publish boundary."; exit 1; }
else
  . "$PSA_LIB"
  psa_load "$REPO_ROOT/.claude/rules/.public-surface-patterns.defaults" \
           "${PSA_PATTERNS:-$REPO_ROOT/.claude/rules/.public-surface-patterns}"

  # Instrument completeness — and a CORRECTION to how this was first written (2026-07-26).
  #
  # The first version blocked on an absent operator override. Two things then showed that was wrong:
  # this repo's own selfcheck flagged it as over-blocking (T7: a feature-branch push blocked → "guard
  # over-fires; that trains the override"), and the reasoning did not survive re-examination. The
  # override holds THIS operator's literals. Another environment lacking it is not thereby unprotected
  # against ITS OWN leaks — those literals were never in the file. The earlier argument (npm publish is
  # not a backstop for a public git push) was right about the gap and wrong about the fix: the thing
  # that actually protects a fresh clone is GENERIC credential shapes in the COMMITTED layer, which is
  # exactly what was added to the defaults in the same session. So:
  #   override absent  → WARN (a per-operator configuration a fresh clone legitimately lacks)
  #   defaults broken  → BLOCK (the shipped universal patterns are gone; that affects everyone)
  #
  # Applicability is mechanical, not self-judged (CLAUDE.md §Surface-Class Degrade Invariant): if the
  # COMMITTED defaults file does not exist at all, this is not an FH repo and the confidentiality legs
  # do not apply — a bare repo with this hook copied in is not a publish surface this gate knows how to
  # reason about. Deleting the file to reach that state is not a free bypass: it is a commit against a
  # .claude/rules/ path, which the commit gate treats as HEAVY and universal_guard_check pins.
  _pp_why=""
  [ "$_PP_APPLICABLE" -eq 1 ] && [ "$PSA_DEFAULTS_OK" -eq 0 ] && _pp_why="committed pattern defaults unreadable/empty"
  [ "$_PP_APPLICABLE" -eq 1 ] && [ "$PSA_BAD_ROWS" -gt 0 ]    && _pp_why="${_pp_why:+$_pp_why; }$PSA_BAD_ROWS unusable pattern row(s)"
  # 2026-08-06 — the 07-26 warn stays for the case it was right about, and is SCOPED, not reverted.
  # An absent override is a legitimate configuration gap in a fresh clone / CI runner / worktree (the
  # file is gitignored, so it is absent there by construction) → WARN, exactly as 07-26 concluded.
  # In an operator-configured checkout the same state is missing evidence, not a missing config, and
  # this is an irreversible surface → BLOCK, matching what `public_surface_scan_files.sh` already does
  # at `npm publish`. Two irreversible surfaces degrading in opposite directions on the same state was
  # the actual defect; git push was the lenient one.
  psa_detect_operator_context "$REPO_ROOT"
  if [ "$_PP_APPLICABLE" -eq 1 ] && [ "$PSA_OVERRIDE_PRESENT" -eq 0 ]; then
    if [ "$PSA_OPERATOR_CONTEXT" -eq 1 ]; then
      _pp_why="${_pp_why:+$_pp_why; }operator-literal override absent in an operator-configured checkout (company/companion literals NOT scanned)"
    else
      echo "  ⚠️  operator-literal override absent — only the committed defaults (home paths + credential"
      echo "     shapes) are active. Populate .claude/rules/.public-surface-patterns for company literals."
    fi
  fi
  if [ -n "$_pp_why" ]; then
    if [ "${PUBLIC_SURFACE_OK:-0}" = "1" ]; then
      echo "  ⚠️  FH Pre-Publish: INCOMPLETE confidentiality instrument allowed by PUBLIC_SURFACE_OK=1"
      echo "      ($_pp_why)"
      printf '%s PUBLIC_SURFACE_OK override (git push, incomplete instrument: %s)\n' \
        "$(date +%Y-%m-%dT%H:%M:%S)" "$_pp_why" \
        >> "$REPO_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
    else
      echo ""
      echo "══════════════════════════════════════════════"
      echo " ⛔ FH Pre-Publish Gate (pre-push) — incomplete confidentiality instrument"
      echo "══════════════════════════════════════════════"
      echo "  $_pp_why"
      echo ""
      echo "  A push makes content public. The per-commit scan only WARNS about the missing override,"
      echo "  and the npm publish scan never sees a git push — so this is the boundary that must hold."
      echo "  Populate .claude/rules/.public-surface-patterns (gitignored), or push consciously:"
      echo "      PUBLIC_SURFACE_OK=1 git push …"
      echo "══════════════════════════════════════════════"
      exit 1
    fi
  fi

  # Content. Scans the ADDED lines of every commit this push publishes — per commit, not the net
  # diff: a token added and later removed still ships inside the pushed history. Lines are tagged
  # with their path so the LOW file allowlist can apply (flattening them is what made this leg block
  # its own first real push on a companion-store name inside the sync script itself).
  if [ -n "$PUSH_RANGES" ] && [ "$_PP_APPLICABLE" -eq 1 ]; then
    _pp_count=0; _pp_added=""; _pp_err=0
    while IFS= read -r _rg; do
      [ -z "$_rg" ] && continue
      _c=$(git rev-list --count $_rg 2>/dev/null) || { _pp_err=1; continue; }
      _pp_count=$(( _pp_count + ${_c:-0} ))
      _d=$(git -c core.quotePath=false log --format= --unified=0 $_rg 2>/dev/null) || { _pp_err=1; continue; }
      _pp_added="$_pp_added$SEP$_d"
    done <<PPRANGES
$PUSH_RANGES
PPRANGES
    if [ "$_pp_err" -eq 1 ]; then
      echo "  ❌ could not read part of the push range — an unread history is not a clean one."
      [ "${PUBLIC_SURFACE_OK:-0}" = "1" ] || exit 1
    fi
    if [ "${_pp_count:-0}" -gt 0 ]; then
      # 🟥 rc 를 «비영» 으로 뭉개지 마라 (cross-family, 2026-08-21): 1=유출 · 3=계기 사망.
      #    합치면 아래 PUBLIC_SURFACE_OK 가 **계기 사망까지 승인**한다 — push 는 비가역이다.
      _pp_rc=0
      printf '%s\n' "$_pp_added" | awk '
           /^\+\+\+ b\// { f = substr($0, 7); next }
           /^\+/         { print f "\t" substr($0, 2) }
         ' | psa_scan_tagged || _pp_rc=$?
      if [ "$_pp_rc" = "3" ]; then
        echo ""
        echo "══════════════════════════════════════════════"
        echo " ⛔ FH Pre-Publish Gate (pre-push) — INSTRUMENT DEAD, scan did NOT run"
        echo "══════════════════════════════════════════════"
        echo "  rc=3 = NOT SCANNED. That is not «no token found» — nothing was measured."
        echo "  PUBLIC_SURFACE_OK does NOT cover this: an override approves a KNOWN mention,"
        echo "  never an unmeasured surface. Fix the scanner first (run under bash; psa_load)."
        echo "══════════════════════════════════════════════"
        exit 1
      fi
      if [ "$_pp_rc" = "0" ]; then
        echo "  ✅ FH Pre-Publish: no operator-private token in the TEXT added by ${_pp_count} commit(s)"
        echo "     (not covered: annotated-tag messages · binary blobs — named residuals)"
      else
        if [ "${PUBLIC_SURFACE_OK:-0}" = "1" ]; then
          echo "  ⚠️  pushing a flagged token by PUBLIC_SURFACE_OK=1 (conscious, reviewed)"
          printf '%s PUBLIC_SURFACE_OK override (git push, content hit in pushed history)\n' \
            "$(date +%Y-%m-%dT%H:%M:%S)" >> "$REPO_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
        else
          echo ""
          echo "══════════════════════════════════════════════"
          echo " ⛔ FH Pre-Publish Gate (pre-push) — token in the history being pushed"
          echo "══════════════════════════════════════════════"
          echo "  A push publishes every commit in the range, not just the tip. Rewrite the offending"
          echo "  commit(s) (git rebase -i / filter-repo) — removing the line in a NEW commit does not"
          echo "  un-publish it once pushed."
          echo "  Deliberate mention:  PUBLIC_SURFACE_OK=1 git push …  (logged)"
          echo "══════════════════════════════════════════════"
          exit 1
        fi
      fi
    fi
  fi
fi


# ── Load-bearing change: the cross-family leg must have RUN, not just been stated ─────────────
# The commit hook requires a `crossfamily:` line to EXIST (it blocks silence, not a 'none'), because
# a commit is local and reversible. A push is where the change leaves this machine and becomes
# something another party will review — and the review that currently catches this defect class is
# not a human reading a diff, it is someone RUNNING A HARNESS over the PR. That catch is real and it
# is what this leg pulls forward: if a different-family auditor is going to find it anyway, it should
# find it before the PR exists, not after, so the fix and the skill-strengthening land in the same
# breath instead of as a follow-up round.
# Same escalation shape as the confidentiality instrument above: WARN at commit, REQUIRE at the
# boundary that publishes. `none` is still an acceptable answer here — but it must be a REASON, not
# an empty field, and the push is where that answer stops being free.
_LB_RAW=""
while IFS= read -r _r; do
  [ -z "$_r" ] && continue
  _LB_RAW="$_LB_RAW$SEP$(git -c core.quotePath=false log --name-only --format= $_r 2>/dev/null || true)"
done <<LBR
$PUSH_RANGES
LBR
_LB_FILES=$(printf '%s\n' "$_LB_RAW" \
  | grep -E '(templates/\.git-hooks/|scripts/(fh-gate|degrade_direction_scan|universal_guard_check|public_surface_scan_files|gate_pathspec_check|predelete_check)\.sh|\.claude/rules/\.public-surface-patterns)' \
  | sort -u || true)
# Also FH-dev-scoped: the marker this requires lives in tracks/_meta, which only an FH checkout has.
# An outside contributor pushing a gate file cannot produce an FH marker, and blocking them would make
# the repo hostile to contribution while protecting nothing they could act on.
if [ -n "$PUSH_RANGES" ] && [ -n "$_LB_FILES" ] && [ -d "$REPO_ROOT/tracks/_meta" ]; then
  _cf_marker=$(ls -t "$REPO_ROOT"/tracks/_meta/.axes_23_passed_*.marker 2>/dev/null | head -1)
  _cf_line=$(grep -m1 -E '^[[:space:]]*crossfamily:[[:space:]]*[^[:space:]]' "$_cf_marker" 2>/dev/null || true)
  if [ -z "$_cf_line" ]; then
    echo ""
    echo "══════════════════════════════════════════════"
    echo " ⛔ FH Load-Bearing Change Gate (pre-push) — no cross-family leg recorded"
    echo "══════════════════════════════════════════════"
    echo "  This push carries gate / verdict / irreversible-surface code, and no marker records"
    echo "  whether a DIFFERENT-family auditor saw it."
    echo ""
    echo "  A same-family reviewer shares the author's optimistic reading; that is the whole reason"
    echo "  this gate exists. The catch usually happens anyway — later, when someone runs a harness"
    echo "  over the PR. Running it now is the same work, one round earlier."
    echo ""
    echo "  Record the answer in the Axes 2-3 marker, then push:"
    echo "      crossfamily: <engine/model> — <rounds, findings, verdict>"
    echo "      crossfamily: none — <why none was reachable or needed>"
    echo "  Conscious exception:  PUBLIC_SURFACE_OK=1 git push …  (logged)"
    echo "══════════════════════════════════════════════"
    if [ "${PUBLIC_SURFACE_OK:-0}" != "1" ]; then exit 1; fi
    echo "  ⚠️  proceeding without a recorded cross-family leg by PUBLIC_SURFACE_OK=1"
    printf '%s PUBLIC_SURFACE_OK override (git push, no cross-family leg recorded)\n' \
      "$(date +%Y-%m-%dT%H:%M:%S)" >> "$REPO_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
  else
    echo "  ✅ FH Load-Bearing Change Gate: $(printf '%s' "$_cf_line" | cut -c1-88)"
  fi
fi

# ── PR-only policy for the integration branch (operator decision 2026-07-20) ──────
# WHY THIS EXISTS LOCALLY: it is the SHIFT-LEFT half of a two-layer floor.
# History: on 2026-07-20 the server required a PR but had `enforce_admins: false`, so an admin
# push satisfied the API and only printed "Bypassed rule violations" — a NOTICE, not a block.
# A rule that announces its own bypass is not a floor ([[feedback_non_defeasible_floor]]).
# The operator then flipped `enforce_admins: true` (+ `required_approving_review_count: 0` so a
# solo operator can still merge their own PR), so the server IS now the hard floor.
# This hook still earns its place: it fails at push time with the actual remedy in the message,
# instead of a bare server rejection, and it keeps working if the server setting is ever relaxed.
# It is deliberately NOT the hard floor — a client-side hook is bypassable by `--no-verify`.
# Scope: blocks the PUSH, not the merge — `gh pr merge` operates server-side and is unaffected.
if [ -n "$TAG_MISMATCH" ] && [ "${TAG_VERSION_OK:-0}" != "1" ]; then
  echo ""
  echo "══════════════════════════════════════════════"
  echo " ⛔ FH Tag/Version Consistency (pre-push)"
  echo "══════════════════════════════════════════════"
  echo "  Tag(s) whose commit disagrees with package.json:$TAG_MISMATCH"
  echo ""
  echo "  A release tag is the thing a human and a script both trust to answer \"what shipped\"."
  echo "  Measured 2026-08-02: a tag went onto the pre-release commit because a failed \`git pull\`"
  echo "  went unread, and \`npm publish\` was then attempted from that same tree. Only npm's own"
  echo "  version-collision check stopped it. Publishing does not un-happen; that was luck."
  echo ""
  echo "  Fix:  git fetch origin && git switch main && git pull --ff-only"
  echo "        git tag -d <tag> && git push origin :refs/tags/<tag>   # if already pushed"
  echo "        # then re-tag on the commit whose package.json really carries that version"
  echo ""
  echo "  Deliberate exception (explicit, logged — mirrors MAIN_PUSH_OK / DESTRUCTIVE_OP_OK):"
  echo "      TAG_VERSION_OK=1 git push …"
  exit 1
fi

if [ -n "$DIRECT_MAIN" ] && [ "${MAIN_PUSH_OK:-0}" != "1" ]; then
  echo ""
  echo "══════════════════════════════════════════════"
  echo " ⛔ FH PR-Only Policy (pre-push)"
  echo "══════════════════════════════════════════════"
  echo "  Direct push to the integration branch:$DIRECT_MAIN"
  echo ""
  echo "  main is PR-only (operator decision 2026-07-20). The server enforces this too"
  echo "  (enforce_admins: true, review_count: 0) — this hook just fails earlier, with the fix."
  echo ""
  echo "  Normal path:"
  echo "      git switch -c <branch> && git push -u origin <branch>"
  echo "      gh pr create --fill"
  echo "      # after review:  gh pr merge --squash --delete-branch --admin"
  echo ""
  echo "  Deliberate exception (explicit, logged — mirrors DESTRUCTIVE_OP_OK / PUBLIC_SURFACE_OK):"
  echo "      MAIN_PUSH_OK=1 git push …"
  exit 1
fi
if [ -n "$DIRECT_MAIN" ]; then
  echo "  ⚠️  FH PR-Only Policy: direct push to$DIRECT_MAIN allowed by MAIN_PUSH_OK=1 (conscious, gated intent)"
fi

# ── Session-close check (CLAUDE.md §Session Wrap-up ①–⑥) ─────────────────────────
# WIRED 2026-07-20. Before this, scripts/session_close_check.sh was referenced ONLY by prose
# (CLAUDE.md + 2 knowledge docs + itself — grep-verified 0 hook references), while CLAUDE.md
# advertised it as a "mechanical floor" that "blocks the push step". Nothing ran it. A floor that
# only runs when the session remembers to run it is not a floor ([[feedback_non_defeasible_floor]],
# §gate-locality). Found by a Fable judgment pass on CLAUDE.md residency, source-closed by grep.
#
# WHY ADVISORY BY DEFAULT (and not a hard block on every push): the script's blocking invariants are
# CLOSE-TIME invariants. ⑤ card-last requires the session card to be the NEWEST close artifact — but
# CLAUDE.md separately mandates appending to fh_completed_<date>.md **immediately** during the session.
# So mid-session, an obedient runner necessarily makes the card momentarily stale. Blocking every push
# on ⑤ would put two documented rules in direct conflict and train the operator to --no-verify, which
# would disarm the Destructive-Op gate below it. Degrade direction is defensible here because an
# ordinary branch push is REVERSIBLE (§Irreversibility Surface-Class Degrade Invariant: reversible
# surface → advisory; only publish/delete/rewrite fail closed).
#
# ENFORCING FORM (use at actual session close, step ⑥):  FH_SESSION_CLOSE=1 git push …
if [ -x "$REPO_ROOT/scripts/session_close_check.sh" ] || [ -f "$REPO_ROOT/scripts/session_close_check.sh" ]; then
  # ⚠️ `< /dev/null` is DEFENSE IN DEPTH — keep it, but it is not what makes this safe.
  # git feeds the push ref list on the hook's STDIN, and a subprocess started here INHERITS stdin.
  # If such a helper reads stdin it drains the ref list; a classification loop running AFTERWARDS
  # would then see ZERO refs, leave every DEL_/FORCED_ var empty, and fall into the
  # "nothing destructive → ordinary ff push → exit 0" path — silently DISARMING the Destructive-Op
  # gate on exactly the push it exists to stop. Empirically reproduced 2026-07-20.
  # THE STRUCTURAL FIX IS THE POSITION, NOT THIS REDIRECT: the classification loop above has already
  # consumed stdin into variables before we get here, so draining fd 0 now costs nothing.
  # If anyone ever moves this block back above that loop, the redirect alone is a thin guard —
  # scripts/test_prepush_stdin_integrity.sh asserts the ordering for exactly that reason.
  _SC_OUT=$(bash "$REPO_ROOT/scripts/session_close_check.sh" "$REPO_ROOT" 2>&1 < /dev/null); _SC_RC=$?
  if [ "${FH_SESSION_CLOSE:-0}" = "1" ]; then
    printf '%s\n' "$_SC_OUT"
    if [ "$_SC_RC" -ne 0 ]; then
      echo ""
      echo "  ⛔ FH Session-Close Check: close invariant violated (FH_SESSION_CLOSE=1 → enforcing)."
      echo "     Fix the ❌ line(s) above — card-last means ⑤ runs AFTER ①–④-c, never before."
      exit 1
    fi
    echo "  ✅ FH Session-Close Check: close state consistent."
  elif [ "$_SC_RC" -ne 0 ]; then
    # Not a close push: surface the violations, never block.
    printf '%s\n' "$_SC_OUT" | grep -E '❌|⚠️  ⑤ tie' || true
    echo "  ⚠️  FH Session-Close Check: close invariant(s) violated (advisory — this is not a close push)."
    echo "     At session close, enforce with:  FH_SESSION_CLOSE=1 git push …"
  else
    # rc == 0 on an ordinary push printed NOTHING before 2026-08-02, which silently swallowed the ⑤
    # tie advisory — a diagnostic whose entire job is to accumulate a measurement across real closes,
    # placed where no real close would ever display it. Cross-family audit named the general blind
    # spot: caller-surface optimism — the subject was tested directly and the production wrapper was
    # assumed to expose the same signal. It did not. Only the tie line is surfaced here; this branch
    # stays silent on a healthy push, because a line that prints every time trains skimming past the
    # lines that matter (the same argument that made ② discharge-able).
    printf '%s\n' "$_SC_OUT" | grep '⚠️  ⑤ tie' || true
  fi
fi

# NOTE ON POSITION (moved here 2026-07-20 after a cross-family audit returned NOT-CONVERGED):
# this block ORIGINALLY sat above the ref-reading loop. That was a priority inversion — an
# ADVISORY check placed above a BLOCKING safety gate — and it opened an fd-0 hazard: the helper
# subprocess inherits stdin, git delivers the push ref list on stdin, so a helper that read stdin
# would drain the ref list and the loop below would classify ZERO destructive refs and allow the
# push. Running AFTER classification removes the hazard structurally (the refs are already read
# into variables); the `< /dev/null` guard is kept as defense in depth, not as the fix.
# Nothing destructive and nothing unclassifiable → ordinary ff push / new branch → allow.
if [ -z "$DEL_BRANCHES$DEL_OTHER$FORCED_REFS$UNCLASSIFIED" ]; then
  exit 0
fi

# ── Explicit, logged operator acknowledgment (enumerate+recover done out-of-band) ──
if [ "${DESTRUCTIVE_OP_OK:-0}" = "1" ]; then
  echo "  ⚠️  FH Destructive-Op Gate: allowed by DESTRUCTIVE_OP_OK=1 (conscious, gated intent)"
  LOG="$REPO_ROOT/tracks/_meta/.destructive_op_override_log"
  if mkdir -p "$REPO_ROOT/tracks/_meta" 2>/dev/null && \
     printf '%s DESTRUCTIVE_OP_OK override — del:%s other:%s forced:%s unclassified:%s\n' \
       "$(date +%Y-%m-%dT%H:%M:%S)" "${DEL_BRANCHES:-none}" "${DEL_OTHER:-none}" \
       "${FORCED_REFS:-none}" "${UNCLASSIFIED:-none}" >> "$LOG" 2>/dev/null; then
    :
  else
    echo "  ⚠️  (override could not be logged to $LOG — proceeding, but this override is UNRECORDED)"
  fi
  exit 0
fi

echo "══════════════════════════════════════════════"
echo " ⛔ FH Destructive-Op Gate (pre-push)"
echo "══════════════════════════════════════════════"

BLOCK=0

# ── Force / non-ff pushes: always block (a rewrite always loses the old commits) ──
for r in $FORCED_REFS; do
  echo "  FORCE / non-fast-forward (history rewrite): $r"
  echo "    → take a bundle backup first:  git bundle create backup.bundle --all"
  BLOCK=1
done

# ── Unclassifiable (remote tip not fetched): fail-closed, accurate message ──
for r in $UNCLASSIFIED; do
  echo "  CANNOT CLASSIFY (remote tip not fetched): $r"
  echo "    → run 'git fetch' so a force-push can be distinguished from a fast-forward, then re-push."
  BLOCK=1
done

# ── Tag / notes deletes: block + ref-specific note (predelete_check walks branches only) ──
for r in $DEL_OTHER; do
  echo "  DELETE (non-branch ref): $r"
  echo "    → tags/notes carry no unique paths but may be external anchors (a release tag, a"
  echo "      published note). Confirm nothing references it before deleting."
  BLOCK=1
done

# ── Branch deletes: per-ref verdict (SAFE auto-allows; CHECK/REVIEW block) ──
# Makes the enumerate load-bearing instead of decorative: a fully-merged branch (nothing
# unique lost) passes; a branch with unique paths or commits off base is held.
if [ -n "$DEL_BRANCHES" ]; then
  if ! git rev-parse --verify --quiet "$BASE" >/dev/null 2>&1; then
    echo "  ⚠️  base '$BASE' unresolvable — cannot verify SAFE → all branch deletes BLOCKED (fail-closed)."
    echo "      (set FH_DESTRUCTIVE_BASE=<ref> or fetch the base, then re-push.)"
    for r in $DEL_BRANCHES; do echo "  DELETE (branch, unverified): $r"; done
    BLOCK=1
  else
    BASE_BRANCH="${BASE##*/}"  # last path component: origin/main / refs/remotes/origin/main / refs/heads/main → main
    for pair in $DEL_BRANCHES; do
      tip="${pair%%|*}"; r="${pair#*|}"   # SHA is before the first '|' (hex, no '|'); ref is everything after
      # Deleting the integration branch itself is never "SAFE" — it is trivially "merged into
      # itself" (n=0, uniq=0) and would auto-pass. Guard it explicitly.
      if [ "$r" = "refs/heads/${BASE_BRANCH}" ] || [ "$r" = "refs/heads/master" ] || [ "$r" = "refs/heads/main" ]; then
        echo "  DELETE (branch): $r — PROTECTED: this is the integration branch → BLOCKED (never auto-SAFE)"
        BLOCK=1; continue
      fi
      if [ "$tip" = "$ZERO" ] || ! git cat-file -e "${tip}^{commit}" 2>/dev/null; then
        echo "  DELETE (branch): $r — tip not local, cannot verify → BLOCKED (fail-closed)"
        BLOCK=1; continue
      fi
      n=$(git rev-list --count "$BASE..$tip" 2>/dev/null || echo "?")
      # 🟥 2026-08-31 — `comm`·`sort|uniq` 계열은 이 로케일에서 **서로 다른 비ASCII 문자열을
      #    같다**고 접는다. 여기서 접히면 unique path 계수가 «줄어» REVIEW → CHECK/SAFE 로
      #    강등되고, **고유 파일을 든 브랜치가 삭제된다** — 비가역 표면이다.
      #    지금은 추적 파일 중 비ASCII 이름이 0건이라 안 터지지만, 하나 생기면 동시에 뚫린다.
      #    ⇒ `LC_ALL=C` 로 못 박는다. [[feedback_locale_string_equality_breaks_nonascii]]
      uniq=$(LC_ALL=C comm -23 \
              <(git ls-tree -r --name-only "$tip"  2>/dev/null | LC_ALL=C sort) \
              <(git ls-tree -r --name-only "$BASE" 2>/dev/null | LC_ALL=C sort) | grep -c . || true)
      if [ "${uniq:-0}" -gt 0 ] 2>/dev/null; then
        echo "  DELETE (branch): $r — REVIEW: $uniq unique path(s), $n commit(s) off $BASE → recover BEFORE deleting"
        BLOCK=1
      elif [ "${n:-0}" != "0" ]; then
        echo "  DELETE (branch): $r — CHECK: $n commit(s) off $BASE, 0 unique paths → judged content look first"
        echo "    (a shared file may hold NEWER content, e.g. an unmerged session card — the silent-loss class)"
        BLOCK=1
      else
        echo "  DELETE (branch): $r — SAFE: fully merged into $BASE, nothing unique lost → allowed"
      fi
    done
  fi
fi

if [ "$BLOCK" -eq 0 ]; then
  echo ""
  echo "  ✅ all destructive refs verified SAFE (fully merged) — allowing push."
  exit 0
fi

echo ""
echo "  Gate order: enumerate → recover (integrate live un-merged state to the base) → destroy."
echo "  This hook is the honest-model floor (it stops a forgotten gate). The hard floor for an"
echo "  adversarial/injected agent is SERVER-SIDE branch protection (GitHub: Restrict deletions /"
echo "  Restrict force pushes) — a client-side hook is bypassable by design."
echo ""
echo "  When enumerate + recover are done, the OPERATOR re-pushes with the explicit acknowledgment:"
echo "      DESTRUCTIVE_OP_OK=1 git push …"
exit 1
