#!/usr/bin/env bash
# FH 4-Axis Gate Pre-Commit Hook
#
# Blocks git commit when FH assets are staged unless all required axes have passed.
# Full gate (all 4 axes): SKILL.md · .claude/rules/ · knowledge/shared/rules/ · templates/ · CLAUDE.md
#                         + substantive knowledge/ · docs/*.md · AGENTS.md
# Lightweight gate (Axis 1+4 only): CATALOG.md · tracks/ · prose-only carve-out docs
#
# Carve-out (knowledge/ · docs/*.md · AGENTS.md): these are FULL gate only when the
# staged diff is *substantive* — adds a fenced code block (```) or a factual claim
# token (arXiv: / DOI / http / a versioned dependency like x.y.z). Prose-only edits
# (typos, rewording, link text) stay lightweight. Mirrors CLAUDE.md §Substantive carve-out.
#
# Install (one-time, from repo root):
#   git config core.hooksPath templates/.git-hooks
#   chmod +x templates/.git-hooks/pre-commit

set -uo pipefail

REPO_ROOT=$(git rev-parse --show-toplevel)
# ── EVIDENCE_ROOT — 증거(tracks/)가 사는 곳. **소스 경로와 다르다.** ──────────────
# 🟥 WHY: 워크트리에서 `--show-toplevel` 은 **그 워크트리**를 준다. `tracks/` 는 gitignored 라
#    워크트리에 안 따라오므로, 마커와 매니페스트를 거기서 찾으면 **항상 부재**다. 그래서 게이트가
#    스스로 권하는 «세션당 worktree» 를 게이트 자신이 봉쇄하고 있었다(2026-08-18 실측: 워크트리
#    FH-자산 커밋 → Axis 2+3 「NOT CONFIRMED」 + Axis 4 「edit_manifest.yaml not found」).
#    「구조적으로 불가능」이 아니라 **경로 해석 한 줄**이었다.
# `--git-common-dir` 은 워크트리에서도 **메인 트리의 .git** 을 준다. 메인 트리에서는 `.git` 이라
# 같은 값이 나온다 ⇒ **하위호환**(3트리 대조 실측: 메인/워크트리/FH 전부 기대대로).
# ⚠️ 상대 경로로 나올 수 있으므로 절대화한다. 실패하면 REPO_ROOT 로 **degrade** — 증거를 못 찾는
#    쪽이지 통과시키는 쪽이 아니다(fail-closed 방향 유지).
#
# 🟥 **명시된 잔여 — 이 수리가 «두 번째 위험»을 처음 도달 가능하게 만든다** (2026-08-18,
#    타계열 리뷰 지적, 실측으로 확인):
#      ⓐ 증거 부재      = 이 블록이 닫는 것
#      ⓑ 훅 자기무력화   = **안 닫힌다.** `core.hooksPath` 가 **상대 경로**(`templates/.git-hooks`
#                        — FH 문서가 지시하는 바로 그 형태)면 워크트리는 **자기 사본**의 훅을 돌린다.
#                        그 사본을 워크트리 안에서 고치면 그 워크트리의 게이트가 죽고,
#                        메인 트리의 `git status` 에는 안 보인다.
#    종전엔 ⓐ 가 **사실상의 장벽**이라 ⓑ 가 도달 불가였다. ⓐ 를 없애면 ⓑ 가 **처음으로 실재**한다.
#    ⇒ 워크트리를 쓸 거면 `core.hooksPath` 를 **절대 경로**로 설정해라:
#         git config core.hooksPath "$(git rev-parse --show-toplevel)/templates/.git-hooks"
#       그러면 모든 워크트리가 **메인 트리의 훅 한 벌**을 돈다.
#    🟥 정정 (2026-08-18) — 초판은 여기에 «그러면 자기무력화가 **구조적으로 막힌다**» 라고
#       적었고 그건 **과대주장**이다. 절대 경로가 막는 것은 **훅 파일 자체의 교체**뿐이다:
#       이 훅은 `$REPO_ROOT/scripts/*` 를 10곳 넘게 **source** 하므로(psa_scan_lib ·
#       branch_claim · regression_guard …), 워크트리에서 그 헬퍼 하나를 고치면 게이트는
#       그대로 초록으로 통과한다. 「막힌다」가 아니라 **「한 겹 좁아진다」**가 정확하다.
#    🟥 이 훅은 그것을 **강제하지 않는다** — 자기 자신의 설치 형태를 자기가 검사하는 것은
#       이미 무력화된 뒤엔 안 돌기 때문이다(검사기가 검사 대상). 문서·설치 안내가 그 층이다.
_gcd=$(git rev-parse --git-common-dir 2>/dev/null || echo "")
case "$_gcd" in
  "") EVIDENCE_ROOT="$REPO_ROOT" ;;
  /*) EVIDENCE_ROOT=$(dirname "$_gcd") ;;
  *)  EVIDENCE_ROOT=$(dirname "$REPO_ROOT/$_gcd") ;;
esac
[ -d "$EVIDENCE_ROOT" ] || EVIDENCE_ROOT="$REPO_ROOT"
BRANCH=$(git rev-parse --abbrev-ref HEAD)
TODAY=$(date +%Y-%m-%d)
BRANCH_SLUG="${BRANCH//\//_}"
FAILED=0

# ── Is a path inside the gated (carve-out) namespace? ─────────────────────────
# Mirror of the CARVEOUT classifier below. Used to tell a rename WITHIN the gated
# namespace (already-reviewed content relocating) from a rename INTO it from outside
# (content gated for the first time → must be scanned in full).
is_carveout_path() {
  echo "$1" | grep -qE '(^knowledge/.*\.md$|^docs/.*\.md$|(^|/)AGENTS\.md$)'
}

# ── Helper: is a carve-out file's staged change substantive? ──────────────────
# Substantive ⇔ the content this commit puts into the gated file introduces a code
# fence (``` / ~~~, possibly indented) or a citation/version token. Mechanical.
#
# "What content is new to the gate" differs by change shape:
#  - plain edit/add (no rename): the staged DIFF additions — spec "diff adds …",
#    so a prose/typo fix to a doc that already contains a fence stays light.
#  - rename WITHIN the gated namespace (docs/→docs/, knowledge/→…): the rename-paired
#    diff — a pure move adds nothing (light); a move+edit shows only the real delta.
#  - rename INTO the gated namespace from outside (root→docs/, scratch→docs/): the
#    file is gated for the FIRST time, so its ENTIRE staged content is new to the gate
#    and is scanned whole. Otherwise content authored in an ungated path could be
#    `git mv`'d into docs/ to evade review (fh_signal_2026-06-08_hook-rename-false-positive,
#    challenger S1). Filtering by the new path alone also breaks rename pairing, which
#    was the original false positive on pure within-scope moves.
# Fails CLOSED (returns substantive) on any git error — a gate must not fail open.
# ── README first-screen rule (2026-08-28) ────────────────────────────────────────
# WHY: README*.md matched NONE of HEAVY/CARVEOUT/LIGHT — a measured blind spot. Three
# paths, all reproduced in a scratch repo before this was written:
#   ⓐ README alone      → "no FH asset staged; 4-axis gate not applicable" — Axes 1/2+3/4 ALL skipped
#   ⓑ README + a prose-only docs/*.md → that file's LIGHT verdict pulls the WHOLE commit to
#                          lightweight, so the README's real content change rides along (this is
#                          what actually happened on commits 8b982e9 / 73c1e4a)
#   ⓒ README + a HEAVY file → full mode, but Axis 1 still never scans README (regression_guard.sh
#                          has no README in its own pathspec — NOT closed here, named as residual)
# The READMEs are npm-shipped, so a consumer-visible surface was changing with no axis enforced.
#
# WHY NOT "README is always HEAVY": a typo or badge fix would then demand a marker. Over-blocking
# trains `--no-verify`, which disarms the Destructive-Op gate living in this same hook — the repo
# has already reasoned that trade once and it holds here.
#
# WHY THIS PREDICATE: `diff_is_substantive` keys on fenced code / citation / version tokens. A
# README's load-bearing change is often none of those — today's was a first-line rewrite with no
# fence, no URL, no version, and it read as prose-only. So README additionally asks WHERE the
# change landed: above the first `---` is the first screen, the part a reader decides on. That is
# a property of the record (which lines moved), not a judgement about whether the copy is good —
# a channel check, per CLAUDE.md §Mechanization Boundary.
# Fails CLOSED on any git error, same as diff_is_substantive.
readme_first_screen_touched() { # $1 = staged README path
  local file="$1" fold hunks body first_screen_cap=60
  body=$(git show ":$file" 2>/dev/null) || return 0   # unreadable → fail closed
  # 🟥 YAML front matter: a README that opens with `---` would set fold=1 and then EVERY real
  # first-screen edit reads as below-the-fold — fail-OPEN, and both cross-family auditors found
  # it independently (2026-08-28). So the fold search starts AFTER a leading front-matter block.
  local skip=0
  if printf '%s\n' "$body" | head -1 | grep -qE '^---[[:space:]]*$'; then
    skip=$(printf '%s\n' "$body" | awk 'NR>1 && /^---[[:space:]]*$/ {print NR; exit}')
    [ -n "$skip" ] || skip=1
  fi
  fold=$(printf '%s\n' "$body" | awk -v s="$skip" 'NR>s && /^---[[:space:]]*$/ {print NR; exit}')
  # 🟥 No horizontal rule at all: do NOT treat the whole file as first screen. That was the first
  # draft and it is an over-block mine — a typo on line 800 of a rule-less README would demand a
  # marker, which is exactly the `--no-verify` training this rule's own comment forbids. Both
  # auditors named the self-contradiction. Bound it instead: the first screen is what a reader
  # sees before scrolling, and 60 lines is the same window the cold-read fixture used.
  [ -n "$fold" ] || fold="$first_screen_cap"
  hunks=$(git diff --cached -U0 -- "$file" 2>/dev/null | grep -E '^@@')
  if [ -z "$hunks" ]; then
    # 🟥 No hunks is NOT "nothing changed above the fold". `git diff -U0` prints no @@ for a
    # binary blob or a pure rename, so returning false here renders UNMEASURED as none — the
    # exact family this repo keeps closing. Measure whether the file is staged at all first.
    git diff --cached --name-only -- "$file" 2>/dev/null | grep -q . || return 1  # genuinely unstaged
    return 0                                                                      # staged but unreadable → fail closed
  fi
  while IFS= read -r h; do
    local start
    start=$(printf '%s' "$h" | sed -E 's/^@@ [^+]*\+([0-9]+).*/\1/')
    case "$start" in ''|*[!0-9]*) return 0 ;; esac   # unparseable → fail closed
    [ "$start" -le "$fold" ] && return 0
  done <<< "$hunks"
  return 1
}

diff_is_substantive() {
  local file="$1" body rc oldpath mode
  oldpath=$(git diff --cached -M --name-status 2>/dev/null \
            | LC_ALL=C awk -F'\t' -v f="$file" '$1 ~ /^R/ && $3 == f { print $2; exit }')  # 로케일 접힘 방지(경로 비교)
  if [ -n "$oldpath" ] && ! is_carveout_path "$oldpath"; then
    body=$(git show ":$file" 2>/dev/null); rc=$?; mode=whole   # into-scope: whole file
  elif [ -n "$oldpath" ]; then
    body=$(git diff --cached -M -- "$oldpath" "$file" 2>/dev/null); rc=$?; mode=diff
  else
    body=$(git diff --cached -- "$file" 2>/dev/null); rc=$?; mode=diff
  fi
  [ "$rc" -ne 0 ] && return 0   # git error → fail closed (demand review)

  # In diff mode only ADDED lines count (removed/context lines near a fence must not
  # false-match); in whole mode the raw file content is the body. Strip exactly ONE
  # leading '+' rather than excluding '^+++' — an added content line beginning with
  # '++' renders as '+++…' and a pattern-exclude would silently drop it (challenger
  # A-grade). The diff file-header '+++ b/path' degrades to a harmless '++ b/path'.
  if [ "$mode" = diff ]; then
    body=$(printf '%s\n' "$body" | grep -E '^\+' | sed 's/^\+//' || true)
  fi
  # Fenced code block (leading indentation allowed), OR a citation/version token.
  if printf '%s\n' "$body" | grep -qE '^[[:space:]]*(```|~~~)'; then
    return 0
  fi
  if printf '%s\n' "$body" | grep -qE 'arXiv:|DOI|https?://|[0-9]+\.[0-9]+\.[0-9]+'; then
    return 0
  fi
  return 1
}

# ── Classify staged changes ──────────────────────────────────────────────────
# `-c core.quotePath=false` — git QUOTES non-ASCII paths by default ("\354\234\240...md"), and a
# quoted name matches no real file downstream, so a staged Korean-named file scanned CLEAN
# (cross-family audit R2, 2026-07-26). `--no-renames` — with rename detection ON, `git mv`-ing this
# hook out of templates/ reported ONLY the destination, so HEAVY and UGUARD_IMPL came up empty and a
# commit could DELETE the gate while the gate said PASS. Disabling rename detection lists both the
# old path (deletion) and the new one, so moving a protected file out is still a protected-path edit.
# NUL-delimited here too (R4, 2026-07-26): quotePath=false stops NON-ASCII quoting, but git still
# C-quotes a path containing a backslash, and the quoted spelling matches none of the classifier's
# path terms — so `scripts/back\slash.sh` (a governance script by every rule FH has) dropped clean
# through to "no FH assets staged". Fixing only the confidentiality loop left this half open: same
# defect class, second location, which is the propagation-boundary failure this repo keeps hitting.
STAGED=$(git -c core.quotePath=false diff --cached --name-only --no-renames -z 2>/dev/null | tr '\0' '\n' || true)
# A literal NEWLINE inside a tracked filename cannot be represented in a line-oriented classifier at
# all, so it is not silently mis-classified — it fails closed.
# The first version of this check compared a NUL count to a line count. That was wrong in exactly one
# direction: `$(...)` strips TRAILING newlines, so a path ending in a newline lost its extra line and
# the two counts agreed — a middle newline blocked, a trailing one sailed through (R5 audit,
# 2026-07-26). Counting is the wrong instrument here; inspect each record instead, which cannot be
# fooled by where in the name the newline sits.
# NL must come from $'\n', NOT from $(printf '\n'): command substitution strips trailing newlines,
# so $(printf '\n') is the EMPTY string and `case $p in *""*` matches EVERY path — the detector for
# trailing-newline paths, defeated by trailing-newline stripping, firing on all 10 staged files of an
# ordinary commit. Caught by running it on this repo (self-dogfood), not by reading it. Known-pair
# calibrated after the fix: an ordinary path passes, a path containing a newline blocks.
_NL=$'\n'
_badpath=0
while IFS= read -r -d '' _p; do
  case "$_p" in *"$_NL"*) _badpath=1 ;; esac
done < <(git -c core.quotePath=false diff --cached --name-only --no-renames -z 2>/dev/null || true)
if [ "$_badpath" -eq 1 ]; then
  echo "❌ BLOCKED — a staged path contains a newline; this gate's path classifier cannot represent it."
  echo "   Rename the file. (Fail-closed: an unclassifiable path must not be read as 'not an FH asset'.)"
  exit 1
fi

# Always-heavy by path (any change is a full gate).
# scripts/*.sh included (2026-06-26, fh_signal fh-gate-structured-verdict): FH's scripts/
# dir holds the governance/enforcement engines (fh-gate.sh, below_floor_scan.sh,
# dlp-filter.sh, …) — the gate's OWN tooling. Leaving it ungated was a gate-locality
# seam: the engine that enforces the 4-axis could itself ship unverified. Gated broadly,
# not by an allowlist, so a NEW gate script cannot silently slip in ungated (the drift
# that would re-open the seam). Scope is `scripts/**/*.{sh,py}` (2026-09-07: `.py` joined
# — a 252-line python instrument landed with no marker while 7 shipped python files sat
# outside this term the whole time; the heavy-classifier lane had it PINNED as `uncovered`).
# A governance script with yet another extension or placed elsewhere (bin/) is still NOT
# auto-caught and must be hand-gated — broaden this term if that changes. Axis 1 (regression_guard) skips non-markdown, so the live effect on a
# script is Axes 2-3 (adversarial design + phantom), NOT behavioral regression — that is
# covered downstream (selfcheck.sh / count_check.sh at publish), not by this commit gate.
# seam #3 (2026-06-27): agent definitions (plugins/*/agents/*.md, .claude/agents/*.md) are
# salience-dependent FH assets — a sub-agent follows its prose prompt, the exact class the gate +
# target-tier-sim discipline targets — yet matched NONE of the HEAVY terms above, so a behavioral
# change to an agent could ship with only Axis 1+4. Gated broadly (whole file, not substantive-only):
# an agent .md IS a behavioral spec, so even "prose" edits change behavior (unlike knowledge/ docs,
# which carve out pure-prose edits). N=3 of the gate-locality class (scripts/ 06-26, AGENTS.md
# inheritance #111/#117). If pure-doc agent .md edits over-friction in practice, add a substantive
# carve-out like CARVEOUT below.
# seam #4 (2026-07-26): SKILL_detail.md. The term above was `SKILL\.md`, and the string
# "SKILL_detail.md" does NOT contain "SKILL.md" — the underscore breaks it — so every detail file
# matched NOTHING here and nothing in regression_guard's pathspec either. Measured: 17 files /
# 208,710 B = 27.7% of the skill-spec surface, 16 of 17 carrying fenced code blocks. It LEAKED
# twice for real (371c04f, e661931 — both single-file edits to phantom-quench/SKILL_detail.md,
# i.e. a gate skill's own behavioral spec changed with zero 4-axis coverage). N=4 of the
# gate-locality class. Worst property: salience-splitter WIDENS this hole every time it moves
# content out of SKILL.md to lean the resident layer — coverage shrank as the diet progressed.
# Over-firing measured before shipping: of the last 60 commits, 28 touched a detail file and 26
# already tripped HEAVY via a companion file, so this adds a heavy path to ~7% of them.
# 🟥 `.claude/soul_tenets.txt` 추가 (2026-08-30, cross-family codex #7 — 자력 적발 0).
#    등록부는 **심지 원칙의 정본**인데 어느 분류에도 안 걸려서, 등록부만 고치는 커밋이
#    «no FH asset staged» 로 빠져 4축 게이트를 통째로 건너뛰었다. tenet 을 바꾸는 것은
#    이 레포에서 가장 하중 큰 편집 중 하나다 — LIGHT 는커녕 CARVEOUT 도 아니다.
HEAVY=$(echo "$STAGED" \
  | grep -E "(SKILL(_detail)?\.md|^plugins/[^/]+/skills/[^/]+/.*\.md$|\.claude/rules/|^knowledge/shared/rules/|templates/|CLAUDE\.md|^scripts/.*\.(sh|py)$|^plugins/[^/]+/agents/.*\.md$|^\.claude/agents/.*\.md$|^\.claude/soul_tenets\.txt$)" || true)

# Carve-out candidates: knowledge/ docs, docs/*.md, AGENTS.md. Heavy only if substantive.
CARVEOUT=$(echo "$STAGED" \
  | grep -E "(^knowledge/.*\.md$|^docs/.*\.md$|(^|/)AGENTS\.md$|^README(\.[A-Za-z]{2,3}([-_][A-Za-z]{2,4})?)?\.md$)" | grep -v "^knowledge/shared/rules/" || true)

# Always-light by path.
LIGHT=$(echo "$STAGED" \
  | grep -E "(CATALOG\.md|^tracks/)" || true)

# Evaluate carve-out files: substantive ones promote to HEAVY, the rest are LIGHT.
if [ -n "$CARVEOUT" ]; then
  while IFS= read -r f; do
    [ -z "$f" ] && continue
    if diff_is_substantive "$f"; then
      HEAVY="$HEAVY"$'\n'"$f (substantive: code/claim added)"
    elif case "$f" in README.md|README.*.md) true ;; *) false ;; esac && readme_first_screen_touched "$f"; then
      HEAVY="$HEAVY"$'\n'"$f (substantive: first screen — the part a reader decides on)"
    else
      LIGHT="$LIGHT"$'\n'"$f (prose-only)"
    fi
  done <<< "$CARVEOUT"
fi

# Trim leading blank lines that the appends may introduce.
HEAVY=$(echo "$HEAVY" | grep -vE '^\s*$' || true)
LIGHT=$(echo "$LIGHT" | grep -vE '^\s*$' || true)

# ── Universal guards (surface-scoped, NOT 4-axis-scoped) ─────────────────────
# WHY A FUNCTION, AND WHY IT RUNS BEFORE THE "no FH assets" EXIT (2026-07-26, N=5 of the
# gate-locality class): these two guards protect the CONFIDENTIALITY BOUNDARY of a public repo,
# not the STRUCTURAL integrity of FH assets. They were authored inline BELOW the 4-axis
# classifier, so their scope silently inherited the classifier's asset pathspec — a commit
# staging only NON-asset paths hit `exit 0  # No FH assets staged` and skipped the
# confidentiality scan entirely. Measured 2026-07-26: 46/241 tracked files (19.1%) were
# unscannable that way; 32 of those are also outside npm files[], so they had no publish-time
# backstop either — including the .gitignore whose leaked comment is the very incident cited in
# the Privacy guard below, and .github/scripts/*.py. Known-pair verified: the same leak line
# BLOCKS when staged in CATALOG.md and passes UNSEEN when staged in README.md alone.
# CI had also delegated this role here (.github/workflows/validate.yml §internal-vocab) without
# anyone measuring the receiving layer's coverage — half-fix propagation, not a new defect.
# The 4 axes are a reversible surface (a commit is re-committable); THESE guard the publish
# boundary, so they run on EVERY commit regardless of what is staged.
# Body is intentionally NOT indented: it contains heredocs whose terminators must sit at column 0.
run_universal_guards() {
# ── Branch-claim guard — «내가 믿는 브랜치 ≠ HEAD» (2026-08-09) ───────────────
# WHY HERE, WITH THE OTHER UNIVERSAL GUARDS: surface-scoped, not 4-axis-scoped — a commit
# lands on a branch whether or not an FH asset is staged, so it must run before the
# "no FH assets → exit 0" path.
#
# WHAT IT CATCHES (measured 2026-08-09, two parallel sessions, one checkout):
#   `.git/HEAD` is one per working tree; session A's `git switch` moves B's ground too.
#   B had already "cut a branch at the start of work" — that did not prevent it.
#   Key insight: the judgment key is the SESSION, not the tree. A tree-level claim is
#   defeated when BOTH sessions follow the protocol (A switches + re-claims → claim==HEAD →
#   B passes) — i.e. it gets weaker as adoption rises. Per-session records
#   (.git/fh-claims/<session_id>, keyed on CLAUDE_CODE_SESSION_ID which git hooks inherit)
#   compare only MY record against HEAD, so peers can never over-block me.
#
# NOT A ROOT FIX: it does not block `git switch` (git has no switch hook) and does not
# protect the uncommitted worktree. Root fix = one worktree per session.
#
# DEGRADE: no record · unparseable · detached/rebase/bisect/merge · no LIVE peer session
#   → PASS. Sole rationale: over-blocking trains the override and kills the gate.
#   (Deliberately NOT "a commit is reversible" — this incident's recovery went through a
#   shared-branch history rewrite, which that invariant classes fail-closed.)
# Anchor: scripts/test_branch_claim_lanes.sh — includes the S-1 regression lane (both
# sessions protocol-compliant) and controls proving the pass-lanes are not unconditional.
BCLAIM="$REPO_ROOT/scripts/branch_claim.sh"
if [ -f "$BCLAIM" ]; then
  if ! bash "$BCLAIM" check; then
    FAILED=1
  fi
else
  # 부재를 통과로 렌더하지 않는다 — 이웃 가드(PSA_LIB)는 부재 시 FAIL 인데 여기만 무음이면
  # «가드가 안 돌았다»와 «가드가 통과했다»가 구분 불가해진다. 차단은 안 한다(과차단 회피).
  echo "  ⚠️  branch-claim guard NOT INSTALLED (scripts/branch_claim.sh) — skipped, not passed"
fi

# ── Privacy guard — tracks/ staged-path allowlist (structural, name-free) ─────
# tracks/** is local-by-default (frozen-seed policy); the only public lanes are
# tracks/_contrib/** (consent lane) and ALREADY-TRACKED .gitkeep skeleton files.
# A newly staged path outside those lanes is treated as a potential private-name
# leak and blocks fail-closed. No deny-list of names exists here on purpose: a
# tracked deny-list would itself publish the names it protects (measured origin:
# tracked .gitignore carried a private track name + company domain in its comment,
# fixed 2026-06-11). Intentional public mapping (a new track's .gitkeep) passes
# with explicit per-commit consent: TRACKS_PUBLIC_OK=1 git commit ...
echo "[Privacy] tracks/ staged-path allowlist..."
TRACKS_LEAK=0
while IFS= read -r p; do
  [ -z "$p" ] && continue
  case "$p" in
    tracks/_contrib/*) ;;
    tracks/*)
      if [ "$(basename "$p")" = ".gitkeep" ]; then
        if git ls-files --error-unmatch "$p" >/dev/null 2>&1; then
          continue  # already-tracked skeleton
        elif [ "${TRACKS_PUBLIC_OK:-0}" = "1" ]; then
          echo "  ⚠️  new public track skeleton allowed by TRACKS_PUBLIC_OK=1: $p"
          continue
        fi
      fi
      echo "  ❌ FAIL — staged tracks/ path outside public lanes: $p"
      TRACKS_LEAK=1; FAILED=1 ;;
  esac
done <<TRACKS_EOF
$(git -c core.quotePath=false diff --cached --name-only --no-renames --diff-filter=ACR)
TRACKS_EOF
if [ "$TRACKS_LEAK" -eq 1 ]; then
  echo "  tracks/** is local-only. Public lanes: tracks/_contrib/** · tracked .gitkeep."
  echo "  Consent-lane content → move under tracks/_contrib/. New public track skeleton"
  echo "  → re-run with TRACKS_PUBLIC_OK=1. Private content → unstage (git restore --staged)."
else
  echo "  ✅ PASS"
fi

# ── Nested-repo gitlink guard — a submodule entry BYPASSES .gitignore ─────────
# 🟥 WHY THIS IS NOT COVERED BY THE BLOCK ABOVE. That one reads `--name-only`, which lists a
# gitlink by path like any file — so a `tracks/` gitlink would be caught by it. What it does NOT
# cover is the reason a gitlink gets staged at all: **`.gitignore` does not apply to a nested
# repository.** `tracks/**` ignores every file under `tracks/`, but a directory containing its own
# `.git` is offered to the index as a mode-160000 entry regardless, so `git add -A` stages it while
# the author believes the whole subtree is ignored. Measured 2026-09-16 on
# `tracks/_meta/dominance_bench_B/recovered_2026-09-09/benchB/target_repo_O` (a benchmark's target
# repo, whose git history IS the evidence and must not be deleted): `git add -A --dry-run` staged it.
#
# 🟥 SAME ROOT AS THE SYNC DEFECT, DIFFERENT SURFACE. A nested `.git` under `tracks/` also made
# `sync-to-be.sh`'s destination-newer guard abort falsely four times (fixed separately). That one was
# noisy; this one is silent — a gitlink committed to a public repo points at a URL the clone cannot
# fetch, and nothing in the commit output says so.
#
# SCOPE, deliberately narrow. Under `tracks/` a gitlink is provably wrong (the lane is local-only),
# so it BLOCKS. Anywhere else this repo has no submodules today, but declaring that a defect would
# over-block a legitimate future one — and a gate that over-blocks trains `--no-verify`, which
# disarms the Destructive-Op gate living in this same hook. So elsewhere it SURFACES and proceeds.
# Override for a reviewed case: TRACKS_GITLINK_OK=1 (same channel shape as TRACKS_PUBLIC_OK).
echo "[Privacy] nested-repo gitlink..."
GITLINK_BLOCKED=0; GITLINK_SEEN=0
while IFS= read -r p; do
  [ -z "$p" ] && continue
  GITLINK_SEEN=1
  case "$p" in
    tracks/*)
      if [ "${TRACKS_GITLINK_OK:-0}" = "1" ]; then
        echo "  ⚠️  gitlink under tracks/ allowed by TRACKS_GITLINK_OK=1: $p"
      else
        echo "  ❌ FAIL — nested repo staged as a gitlink under tracks/: $p"
        GITLINK_BLOCKED=1; FAILED=1
      fi ;;
    *)
      echo "  ⚠️  gitlink staged outside tracks/ (surfaced, not blocked): $p" ;;
  esac
done <<GITLINK_EOF
$(git -c core.quotePath=false diff --cached --raw --no-renames --diff-filter=ACMR 2>/dev/null | awk -F'\t' '$1 ~ / 160000 / {print $2}')
GITLINK_EOF
if [ "$GITLINK_BLOCKED" -eq 1 ]; then
  echo "  A gitlink records a commit SHA in another repository — a clone cannot fetch it, so the"
  echo '  content is lost while the tree still looks complete. .gitignore never applies to it.'
  echo "  Fix: git rm --cached <path>   (the working directory and its history stay untouched)."
elif [ "$GITLINK_SEEN" -eq 0 ]; then
  echo "  ✅ PASS"
fi

# ── Confidentiality guard — public-surface scan on staged tracked content ─────
# A commit to a PUBLIC repo is an effective PUBLISH of its content, so the confidentiality boundary is
# checked here as well as at push/publish. Pattern loading and matching come from
# scripts/psa_scan_lib.sh — the single implementation shared with pre-push and the publish scanner.
# (Three near-duplicate copies used to exist; every confidentiality defect found in the 2026-07-26
# cross-family audit was a divergence between them, so the duplication was removed rather than
# repaired a fourth time.)
#
# DEGRADE DIRECTION HERE IS DELIBERATELY GENTLER THAN AT PUSH — do not "unify" it:
#   • no usable patterns at all, or unusable rows → FAIL (an instrument that cannot run cannot certify)
#   • operator override merely ABSENT            → WARN only. It is gitignored, so it is absent on
#     every fresh clone and CI runner by construction; blocking each of their first commits would
#     train PUBLIC_SURFACE_OK into a reflex and disarm the same channel the publish gate depends on.
#     The push-time gate blocks that state instead, which is the boundary that actually publishes.
echo "[Confidentiality] public-surface scan (staged tracked content)..."
PSA_LIB="$REPO_ROOT/scripts/psa_scan_lib.sh"
if [ ! -r "$PSA_LIB" ]; then
  echo "  ❌ FAIL — scripts/psa_scan_lib.sh missing; the confidentiality scanner cannot run."
  FAILED=1
else
  . "$PSA_LIB"
  # ── R4 (2026-08-18) · 오버라이드는 **EVIDENCE_ROOT** 에서 찾는다 ──────────────
  # `tracks/` 는 `--git-common-dir` 로 옮겼는데 **똑같이 gitignored 인** 이 패턴 파일은
  # `$REPO_ROOT` 에 남아 있었다. 그래서 워크트리 커밋이 매번 **defaults-only** 로 기밀성
  # 스캔을 돌았다(회사명·실명 클래스가 통째로 UNSCANNED, 게다가 비차단 경고라 조용하다).
  # 반쪽-픽스의 전형이다([[feedback_half_fix_propagation_boundary]]).
  # 🟥 `defaults` 는 **tracked** 라 `REPO_ROOT` 가 맞다 — 같이 옮기면 안 된다. 옮기는 것은
  #    gitignored 인 오버라이드 쪽 하나뿐이고, 없으면 REPO_ROOT 로 폴백한다.
  PSA_OVR="$EVIDENCE_ROOT/.claude/rules/.public-surface-patterns"
  [ -r "$PSA_OVR" ] || PSA_OVR="$REPO_ROOT/.claude/rules/.public-surface-patterns"
  psa_load "$REPO_ROOT/.claude/rules/.public-surface-patterns.defaults" \
           "${PSA_PATTERNS:-$PSA_OVR}"
  if [ "$PSA_OVERRIDE_PRESENT" -eq 0 ]; then
    # 2026-08-16 weekly-audit residual — operator decision: LOUDER WARNING, not fail-closed.
    # A hard block here would fail every fresh clone's / CI's / worktree's first commit (the
    # override is gitignored by construction), training PUBLIC_SURFACE_OK into a reflex — the same
    # override-habituation this repo has measured and warned against repeatedly. So the degrade
    # direction stays PASS; what changed is that the old two-line notice was easy to scroll past in
    # a green hook run. This is now impossible to miss, still non-blocking.
    echo ""
    echo "  🟧🟧🟧 CONFIDENTIALITY COVERAGE REDUCED — READ BEFORE TRUSTING THIS PASS 🟧🟧🟧"
    echo "  ⚠️  operator pattern override ABSENT — only committed defaults are active."
    echo "     Company/employer-name and real-username token classes are UNSCANNED this run."
    echo "     A clean PASS below reflects the defaults-only pattern set, not the full one."
    echo "     Fix: populate .claude/rules/.public-surface-patterns (gitignored, one severity<TAB>regex per line)."
    echo "  🟧🟧🟧 ────────────────────────────────────────────────────────────── 🟧🟧🟧"
    echo ""
  fi
  if [ "$PSA_DEFAULTS_OK" -eq 0 ] || [ "$PSA_BAD_ROWS" -gt 0 ] \
     || [ -z "$(printf '%s' "$PSA_STREAM" | grep -vE '^[[:space:]]*(#|$)' || true)" ]; then
    echo "  ❌ confidentiality gate INACTIVE/INCOMPLETE — cannot certify a clean surface."
    if [ "${PUBLIC_SURFACE_OK:-0}" = "1" ]; then
      echo "  ⚠️  proceeding with an incomplete gate by PUBLIC_SURFACE_OK=1 (conscious)"
      echo "$(date +%Y-%m-%dT%H:%M:%S) PUBLIC_SURFACE_OK override — branch $BRANCH — gate inactive/incomplete" \
        >> "$EVIDENCE_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
    else
      FAILED=1
    fi
  else
    PSA_LEAK=0
    PSA_DEAD=0
    # NUL-delimited (regression caught by the refactor's own cross-family review): the pre-refactor
    # loop read this list with `read -r -d ''`, and the rewrite dropped back to a line-oriented read.
    # `core.quotePath=false` stops git quoting NON-ASCII names, but a path holding a backslash or a
    # newline is still C-quoted or unsplittable, and the quoted spelling matches no real file — so the
    # file scanned clean. Exactly the hole R5 closed, reopened by a refactor that claimed to change no
    # behavior. This is why the refactor got its own adversarial pass instead of riding the earlier one.
    while IFS= read -r -d '' f; do
      [ -z "$f" ] && continue
      added=$(git diff --cached -- "$f" 2>/dev/null | grep -E '^\+' | sed 's/^\+//' || true)
      [ -z "$added" ] && continue
      # Per-line pass, path-tagged so the LOW file allowlist applies.
      # 🟥 rc 를 «비영» 으로 뭉개지 마라 (cross-family, 2026-08-21): 1=유출 · 3=계기 사망.
      #    둘을 합치면 아래 PUBLIC_SURFACE_OK 가 **계기 사망까지 «승인된 유출»로 통과**시킨다.
      _psa_rc=0
      printf '%s\n' "$added" | sed "s|^|$f	|" | psa_scan_tagged || _psa_rc=$?
      case "$_psa_rc" in 0) ;; 3) PSA_DEAD=1 ;; *) PSA_LEAK=1 ;; esac
      # Split-token backstop (pre-commit ONLY — this surface is a DIFF, so a literal can be wrapped
      # mid-word across two added lines and neither line matches). TIGHT-join, no separator, so
      # "fh-\nbe" becomes "fh-be". Accepted residual, verified not a missed fix: two whitespace-
      # separated words can RARELY fuse into a spurious match — safe direction (over-block) with the
      # PUBLIC_SURFACE_OK escape. A sentinel separator would close the fusion but RE-OPEN the split
      # catch, which is the more important case since real literals have no internal whitespace.
      joined=$(printf '%s' "$added" | tr -d '\n\r')
      _psa_rc2=0
      printf '%s\t%s\n' "$f" "$joined" | psa_scan_tagged >/dev/null 2>&1 || _psa_rc2=$?
      [ "$_psa_rc2" = "3" ] && PSA_DEAD=1
      if [ "$_psa_rc2" != "0" ] && [ "$_psa_rc2" != "3" ]; then
        # Only report if the per-line pass did not already flag this file, to avoid double-reporting.
        if ! printf '%s\n' "$added" | sed "s|^|$f	|" | psa_scan_tagged >/dev/null 2>&1; then :; else
          # 🟥 **표시 전용 — 판정 경로가 아니다** (2026-08-21 배선 리뷰 R-1). 이 파이프는 비말단이라
          #    `$?` 가 `sed` 것이다. 그래도 되는 이유는 도달 조건이 **이미 «유출 확정»** 이고 바로
          #    다음 줄이 `PSA_LEAK=1` 을 무조건 세우기 때문이다. 판정은 위 `_psa_rc` 3분기가 쥔다.
          #    ⚠️ 이 주석이 없으면 다음 사람이 판정 경로로 읽고 «고친다» — 그게 이 줄을 위험하게 만든다.
          printf '%s\t%s\n' "$f" "$joined" | psa_scan_tagged | sed 's/leak —/leak (line-split) —/'
          PSA_LEAK=1
        fi
      fi
    done < <(git -c core.quotePath=false diff --cached --name-only --no-renames --diff-filter=ACMR -z 2>/dev/null || true)
    # 🟥 계기 사망은 override 대상이 **아니다.** override 는 «알고 있는 언급»을 승인하는 것이지
    #    «안 잰 표면»을 승인하는 게 아니다. 초판은 1 과 3 을 합쳐서, PUBLIC_SURFACE_OK=1 이면
    #    스캐너가 죽은 상태도 통과했다 — 비가역 표면에서 가장 나쁜 조합이다.
    if [ "${PSA_DEAD:-0}" -eq 1 ]; then
      echo "  ⛔ INSTRUMENT DEAD — the public-surface scan did NOT run (rc=3). NOT SCANNED is not clean."
      echo "     PUBLIC_SURFACE_OK does NOT cover this. Fix the scanner, then commit:"
      echo "       · run under bash (zsh special vars can blank PATH inside the matcher)"
      echo "       · confirm psa_load ran and PSA_STREAM is non-empty"
      FAILED=1
    elif [ "$PSA_LEAK" -eq 1 ]; then
      if [ "${PUBLIC_SURFACE_OK:-0}" = "1" ]; then
        echo "  ⚠️  public-surface hit(s) allowed by PUBLIC_SURFACE_OK=1 (conscious, reviewed intent)"
        echo "$(date +%Y-%m-%dT%H:%M:%S) PUBLIC_SURFACE_OK override — branch $BRANCH — review suppressed hit(s) above" \
          >> "$EVIDENCE_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
      else
        echo "  Operator-private token reached the public surface. Generalize it (companion-store name"
        echo "  → 'a private companion store'; corp-context → 'restricted/corp env'; absolute home path"
        echo "  → '~' or '{project}'), or PUBLIC_SURFACE_OK=1 git commit … for a reviewed mention."
        FAILED=1
      fi
    elif [ "$PSA_OVERRIDE_PRESENT" -eq 0 ]; then
      # NOT `✅ PASS`. The scan found nothing, but one whole layer of it — the operator/company
      # literals — never ran. A missing measurement is not a zero (CLAUDE.md §Instrument
      # Calibration), and a verdict that reads identically whether a layer ran or not is the
      # fail-open this repo names. The commit is still allowed: a commit is reversible, so the
      # degrade here is advisory by the Surface-Class Degrade Invariant. Push and publish, which
      # are the acts that make content public, block this same state.
      echo "  ⚠️  PARTIAL — no hit in the committed defaults; company/companion literals UNMEASURED."
    else
      echo "  ✅ PASS"
    fi
  fi
fi
}

if [ -z "$HEAVY" ] && [ -z "$LIGHT" ]; then
  # No FH asset staged → the 4-AXIS gate does not apply. The universal guards still do:
  # their trigger is "content is being committed to a public repo", not "an FH asset changed".
  run_universal_guards
  if [ "$FAILED" -eq 1 ]; then
    echo
    echo "══════════════════════════════════════════════"
    echo " 🚫 BLOCKED — universal guard failed (no FH asset staged; 4-axis gate not applicable)"
    echo "══════════════════════════════════════════════"
    exit 1
  fi
  exit 0
fi

if [ -n "$HEAVY" ]; then
  GATE_MODE="full"
else
  GATE_MODE="lightweight"
fi

# TOCTOU 완화: 앞선 branch-claim check 이후 게이트들이 도는 동안 peer 가 브랜치를 옮길 수
# 있다. 최종 판정 직전에 한 번 더 돌려 창을 좁힌다. (원자적 close 는 reference-transaction
# 훅의 몫 — 이건 완화이지 해결이 아니다.)
if [ -f "$REPO_ROOT/scripts/branch_claim.sh" ]; then
  if ! bash "$REPO_ROOT/scripts/branch_claim.sh" check; then
    echo "  ↑ 게이트 실행 중에 브랜치가 옮겨졌다 (TOCTOU 창에서 포착)"
    FAILED=1
  fi
fi

echo ""
echo "══════════════════════════════════════════════"
echo " FH 4-Axis Gate — ${GATE_MODE} mode"
echo "══════════════════════════════════════════════"
if [ -n "$HEAVY" ]; then
  echo " Heavy assets staged:"
  echo "$HEAVY" | sed 's/^/   /'
fi
if [ -n "$LIGHT" ]; then
  echo " Light assets staged:"
  echo "$LIGHT" | sed 's/^/   /'
fi
echo ""

# ── Doc-code coupling check (measured class — WARN, never blocks) ─────────────
# Stale docs poison AI accuracy: when executable code changes but the docs that
# describe it do not, the manuals drift (import: Anthropic 4-layer L4 practice —
# "code change forces skill-doc update"). FH applies it as a WARN because many
# script changes are doc-neutral; the warning makes the *decision* conscious.
EXEC_STAGED=$(echo "$STAGED" | grep -E '^(bin/|scripts/).*' || true)
DOC_STAGED=$(echo "$STAGED" \
  | grep -E '(README\.md|CHEATSHEET\.md|CLAUDE\.md|AGENTS\.md|^docs/|^knowledge/|SKILL(_detail)?\.md|^\.claude/regression/)' || true)
if [ -n "$EXEC_STAGED" ] && [ -z "$DOC_STAGED" ]; then
  echo ""
  echo "⚠️  DOC-CODE COUPLING (measured — commit allowed)"
  echo "   Executable changes staged with no doc asset staged:"
  echo "$EXEC_STAGED" | sed 's/^/     /'
  echo "   If behavior/usage changed: update README/CHEATSHEET/SKILL.md or probes"
  echo "   (.claude/regression/probes.md) in this commit. If doc-neutral, proceed."
  echo ""
fi

# ── Half-fix propagation (advisory — MARK, never block) ──────────────────────
# The operator's spec for this debt is explicit: 표시(차단 아님 — 정당한 복제도 있다).
# templates/ ships deliberate copies of scripts/, so blocking on duplication would block correct
# work; and a gate that fires when the author did the right thing is a gate that gets disabled.
# Failing to RUN is likewise never a finding: a missing/erroring scan is silent here, because this
# surface is a commit (reversible) — the fail-closed direction belongs to publish and delete.
HALFFIX="$REPO_ROOT/scripts/halffix_propagation_scan.sh"
[ -f "$HALFFIX" ] && bash "$HALFFIX" 2>&1 || true

# ── Axis 1 — Regression Guard (always required) ──────────────────────────────
echo "[Axis 1] Regression Guard..."
GUARD="$REPO_ROOT/templates/regression_guard.sh"
if [ ! -f "$GUARD" ]; then
  echo "  ⚠️  SKIP — templates/regression_guard.sh not found"
else
  GUARD_EXIT=0
  # Pre-commit must evaluate the STAGED index, not --pr merge-base: on a direct-to-main
  # workflow merge-base(main,main)=HEAD makes staged changes invisible (fh_signal 2026-06-04).
  # typed 파일 채널로 verdict 수신 — stdout grep(prose-grep) 대체 (2026-07-23, #165 잔여 폐쇄).
  GUARD_RESULT_FILE=$(mktemp "${TMPDIR:-/tmp}/fh_guard_result.XXXXXX")
  GUARD_OUT="$(REGRESSION_GUARD_RESULT_FILE="$GUARD_RESULT_FILE" bash "$GUARD" --staged 2>&1)" || GUARD_EXIT=$?
  printf '%s\n' "$GUARD_OUT"
  GUARD_VERDICT=$(sed -n 's/^result=//p' "$GUARD_RESULT_FILE" 2>/dev/null | head -1)
  rm -f "$GUARD_RESULT_FILE"
  # 일관성 검사 (challenger C-1): exit 코드와 typed verdict 가 어긋나면 게이트 계기 오류다 —
  # 특히 중도 crash(exit 1, 파일 미작성)가 "S-tier 경고, 커밋 허용"으로 렌더되던 구멍.
  # fh-gate.sh 의 exit-10 harness-error 선례: 계기 오류는 통과도 경고도 아닌 fail-closed.
  if { [ "$GUARD_EXIT" -eq 1 ] && [ "$GUARD_VERDICT" != "review" ]; } \
     || { [ "$GUARD_EXIT" -eq 0 ] && [ "$GUARD_VERDICT" != "pass" ] && [ "$GUARD_VERDICT" != "skip" ]; }; then
    echo "  ❌ HARNESS-ERROR — guard exit=$GUARD_EXIT but typed verdict='$GUARD_VERDICT' (crash or instrument fault; not a pass, not a warning)"
    FAILED=1
  elif [ "$GUARD_EXIT" -eq 0 ] && [ "$GUARD_VERDICT" = "skip" ]; then
    # ★ 미검사를 통과로 렌더하지 않는다. 가역 표면이라 커밋은 막지 않지만,
    #   "PASS" 라고 쓰면 게이트가 자산을 봤다는 잘못된 신호가 된다(2026-07-22 수리).
    echo "  ⏭️  SKIP — 게이트 pathspec 에 걸린 파일이 없다 (검사 안 함 ≠ 통과)"
  elif [ "$GUARD_EXIT" -eq 0 ]; then
    echo "  ✅ PASS"
  elif [ "$GUARD_EXIT" -eq 1 ]; then
    echo "  ⚠️  S-tier warnings present — review before merge (commit allowed)"
    # Exit 1 = S-tier: guard itself says "merge allowed but verify intent" — not a commit blocker
  else
    echo "  ❌ FAIL — M-tier blockers or usage error (exit $GUARD_EXIT)"
    FAILED=1
  fi
fi

# ── Marker floor validation (mechanical below-floor detector) ─────────────────
# Adversarial (Axis 2) floor compliance was advisory-only — it depended on the
# orchestrator self-flagging in prose, which fails on rule salience, not tier
# (fh_signal_2026-06-10_adversarial-floor-enforcement: blind A/B showed both Opus
# and Sonnet self-flag when the rule is in active context; the live slip happened
# because it wasn't). So the marker now REQUIRES machine-greppable floor fields,
# and the hook validates them — compliance no longer depends on recall.
#
# Required marker fields (free prose may follow them):
#   axis2-engine: quench-challenger | inline | <external cli>
#   axis2-model:  <tier that produced the adversarial pass, e.g. opus>
#   axis2-evidence: <what the pass actually found — finding count + verdict, or
#                    "clean — 0 findings"; e.g. "PASS no-S, 4B applied" / "1S/4A fixed">
#                    ← Honest scope (judge-robustness swarm, 2026-06-13): the hook enforces
#                      this field's PRESENCE + NON-VACUITY (a real result was recorded), not
#                      PROVENANCE. The marker is a trusted-runner attestation — a session that
#                      fabricates a pass it never ran is the residual the WEEKLY AUDIT + OPERATOR
#                      cover, not mechanism (cryptographic provenance is unachievable when the
#                      runner controls everything the hook can see). This field makes the marker
#                      auditable (the audit can check the recorded verdict against reality) and
#                      catches the realistic failure: a vacuous "trust me, it ran" marker.
#   floor-status: at-floor | above-floor | sonnet-floor | below-floor
#                     (judged-depth floor = opus, PR #86; BASE floor = sonnet —
#                      Sonnet-Floor Doctrine 2026-07-10: a Sonnet inline pass is
#                      first-class for base commits when it carries a mechanical
#                      anchor line (axis2-anchor:) and it auto-enters the weekly
#                      re-validation queue; sub-Sonnet stays below-floor + ack)
#   axis2-anchor: <mechanical evidence grounding the Sonnet judged verdict — a
#                  regression test, lint/scan output, probe count; required iff
#                  floor-status: sonnet-floor (judged depth at Sonnet is weaker,
#                  so the anchor leg is the compensating requirement)>
#   below-floor-ack: "<verbatim operator approval utterance>" — <reason>
#                     ← required iff floor-status: below-floor; the quoted span is
#                       mandatory (rubber-stamp hardening, 2026-06-13). This narrows
#                       rubber-stamping to two residual classes — quote fabrication
#                       (active-fabrication, honesty-layer-blocked) and OUT-OF-CONTEXT
#                       quoting (a real but non-approval utterance) — both of which
#                       are weekly-audit targets: the audit re-queues below-floor
#                       markers and checks the quote against the session record.
#                       The hook enforces the form, not genuineness.
#
# Opus-inline is at-floor (capability met even though dispatch was skipped) — the
# gate must not block legitimate at/above-floor inline passes by the orchestrator.
# A below-floor judged verdict is PROVISIONAL (not gate-PASS evidence) per
# §Floor governance, so it blocks unless the operator explicitly accepts it; the
# weekly audit remains the standing consumer that re-runs or writes off acks.
# Single-line recreate command — indentation-immune on paste (a displayed heredoc
# pastes with leading indent: the anchored grep misses and an indented EOF never
# terminates <<'EOF' — non-converging fix loop, challenger Axis-5 A-finding).
# ── 템플릿 잔여 검사 (2026-08-30, 블라인드 sim 이 잡았다) ─────────────────────────
# 🟥 오늘 `marker_recreate_hint` 에 `soul:`/`defeater:` 를 넣자마자 플로어 티어 팔 3/3 이
#    **힌트의 자리표시자를 그대로 복사**해서 냈고 비공허성 검사를 **통과했다**
#    (`<이 fix가 통과했다고 판단할 관측 가능한 결과>` = 26 낱말). 즉 내가 만든 힌트가
#    게이트를 우회하는 가장 쉬운 답을 같이 제공했다.
# ⚠️ **채널 검사다**: 「그 값이 옳은가」가 아니라 「이 기록이 템플릿 자신인가」를 묻는다.
# 🟥 좁게 잡는다 — 꺾쇠를 정당하게 쓰는 기술 서술을 안 막으려고, **힌트에 실제로 등장하는
#    문자열**만 본다. 목록이지 정규식 일반화가 아니다. 힌트를 바꾸면 여기도 같이 바꿔라.
_marker_template_residue() { # $1 = 줄 내용  → 0 = 템플릿 잔여
  # 🟥 **세 번째 설계다. 앞의 둘이 왜 틀렸는지 남긴다** (cross-family 3라운드, 자력 적발 0).
  #    v1 손목록  → 규칙 문서가 새 자리표시자를 실을 때마다 어긋났다(구조적 desync).
  #    v2 일반규칙 → 「꺾쇠 안에 공백/비-ASCII」. 놓친 것: `<reason>` `< 무엇을 했나>` `<..>`.
  #                 **과차단**: `Map<String, Object>` · `Result<T, Error>` · `<사용자_ID>`.
  #                 두 계열이 각각 실물 문자열로 지적했다 — 그리고 그게 맞다.
  # 🟥 v2 가 틀린 이유는 «구멍»이 아니라 **범주**다: 「이 꺾쇠가 자리표시자인가 타입인가」는
  #    **판단**이고, §Mechanization Boundary 가 코드로 굳히지 말라는 바로 그 층이다.
  #    기계가 답할 수 있는 것은 **«이 줄이 우리가 배포한 템플릿과 같은가»** 뿐이다.
  # ⇒ v3: **템플릿에서 파생한다.** 힌트가 실제로 출력하는 문자열에서 `<...>` 조각을 런타임에
  #    뽑아 그것만 본다. 목록을 손으로 안 적으므로 desync 가 **구조적으로 불가능**하다.
  #    ⚠️ 정직한 대가: 「스펙 문서에만 있고 힌트에 없는」 자리표시자는 안 잡힌다. 그건 v1 의
  #       구멍이 남은 것인데, 판단을 코드로 굳히는 것보다 낫다고 보고 **명명한 채로 둔다.**
  local _line="$1" _tpl _frag
  _tpl=$(marker_recreate_hint "X" 2>/dev/null)
  # 힌트 문자열에서 `<...>` 조각을 뽑는다. 하나도 못 뽑으면 계기가 죽은 것이지 «잔여 없음»이 아니다.
  _frag=$(printf '%s' "$_tpl" | grep -oE '<[^<>]+>' | sort -u)
  if [ -z "$_frag" ]; then
    echo "  ⚠️  template-residue: 힌트에서 자리표시자를 못 뽑았다 — 이 검사는 SKIPPED 다(통과 아님)." >&2
    return 1
  fi
  while IFS= read -r f; do
    [ -n "$f" ] || continue
    case "$_line" in *"$f"*) return 0 ;; esac
  done <<EOF_FRAG
$_frag
EOF_FRAG
  return 1
}

marker_recreate_hint() {
  # 🟥 힌트는 «행위자가 읽는 자리»다(FH-T07). 2026-08-30 블라인드 sim 실측: 플로어 티어 세 팔이
  #    나머지 필드를 다 쓰고 `soul:` 을 **하나도 안 썼다** — 차단되는 필드인데도. 스펙에도 힌트에도
  #    없었기 때문이다. 여기에 최소 필드를 다 넣는다. 정본 = .claude/rules/fh_4axis_gate.md
  echo "     printf 'axis2-engine: quench-challenger\\naxis2-model: opus\\nfloor-status: at-floor\\naxis2-evidence: PASS no-S\\nsoul: 성공 정의 = <...> · 절대 안 함 = <...>\\nsoul-check: reflected(<무엇이 되돌아왔나>)\\ndefeater: <틀렸다면 무엇이 관측되나>\\n' > \"$1\""
}

# ── Cross-family leg — typed verdict (see the load-bearing block below for WHY) ──
# Grammar:  crossfamily: <VALUE>[ — <reason>]
# VALUE is a closed enum; the three degrade values additionally require a non-vacuous reason
# on the SAME line (keeping the established one-line marker convention rather than adding a
# second field). Returns 0 = accepted, 1 = block.
validate_defense_leg() { # $1 = marker path — fires ONLY at the floor tiers (see call site)
  # Wave 1-D defense line. Absorbed from a sibling harness 2026-08-20, but the machine is OURS:
  # 🟥 the source document claimed "the commit hook reads that line" and "pinned by
  # test_marker_floor_lanes.sh" — measured twice with a control (`crossfamily` 21 hits), BOTH were
  # 0 hits there. Porting the prose without building this function would have imported the phantom.
  #
  # WHY IT EXISTS: measured n=6 in the origin field — a floor-tier pass executes the attack angles
  # without defect but does NOT spontaneously ask these three of its own numbers. That is a
  # checklist gap, not a capability gap, and a harness whose behaviour depends on which model drives
  # it is defective by sonnet_floor_doctrine.md rather than merely limited.
  # FH-side corroboration (2026-08-20, this session, n=1): a comparison was published from two
  # DIFFERENT package versions and read as a defect ("15 lanes vs 13 lanes — two vanished"). The
  # 비교공정성 question — *were the two arms measured under the same conditions?* — asks exactly
  # that, and the claim was retracted only because a later step happened to surface the version gap.
  #
  # SCOPE, and why it is not wider: `below-floor` occurs 0 times across the existing marker corpus,
  # so gating on it alone would be a decoration that never fires. `sonnet-floor` occurs 11 times.
  # Both are gated here; the wide reading ("any marker carrying numbers") is deliberately NOT taken —
  # it would price this axis at a near-universal rate, which is the over-trigger §7 rejects.
  #
  # 🟥 WHAT WAS NOT IMPORTED: the origin also amends its floor rule so that below-floor + this line
  # + a crossfamily record PASSES WITHOUT the operator ack. That is a LOOSENING of an existing FH
  # gate and it is not adopted. Here this leg is purely additive: below-floor still requires
  # below-floor-ack, unchanged.
  local m="$1" line r f e
  if [ ! -f "$m" ] || ! grep -qE '^[[:space:]]*axis2-defense:[[:space:]]*[^[:space:]]' "$m"; then
    echo "  ❌ FAIL — floor-tier marker with no 'axis2-defense:' line."
    echo "     At the floor tier the attack angles run but the three defense questions do not get"
    echo "     asked spontaneously. Ask them OF YOUR OWN findings and numbers, then record:"
    echo "       axis2-defense: reproducibility=<exact command or file:line another session runs>"
    echo "                      fairness=<reps/inputs/env named for BOTH arms>"
    echo "                      estimation-layer=<per number: measured | estimate | quotation;"
    echo "                                        for measured, what showed the instrument works here>"
    echo "     Append to: $m"
    return 1
  fi
  if [ "$(grep -cE '^[[:space:]]*axis2-defense:' "$m")" -gt 1 ]; then
    echo "  ❌ FAIL — marker carries MORE THAN ONE 'axis2-defense:' line."
    grep -nE '^[[:space:]]*axis2-defense:' "$m" | sed 's/^/       /'
    echo "     The reader takes the FIRST, so an appended correction is silently shadowed."
    echo "     Leave exactly one line."
    return 1
  fi
  # The whole value, including any continuation lines, up to the next top-level field.
  line=$(sed -n '/^[[:space:]]*axis2-defense:/,/^[a-z0-9-]*:[[:space:]]/p' "$m"          | sed -E '2,$ { /^[a-z0-9-]+:[[:space:]]/d; }' | tr '\n' ' ')
  r=$(printf '%s' "$line" | sed -nE 's/.*reproducibility=([^ ].*)/\1/p' | sed -E 's/(fairness=|estimation-layer=).*//')
  f=$(printf '%s' "$line" | sed -nE 's/.*fairness=([^ ].*)/\1/p' | sed -E 's/(reproducibility=|estimation-layer=).*//')
  e=$(printf '%s' "$line" | sed -nE 's/.*estimation-layer=([^ ].*)/\1/p' | sed -E 's/(reproducibility=|fairness=).*//')
  local missing=""
  [ -n "$r" ] || missing="$missing reproducibility"
  [ -n "$f" ] || missing="$missing fairness"
  [ -n "$e" ] || missing="$missing estimation-layer"
  if [ -n "$missing" ]; then
    echo "  ❌ FAIL — axis2-defense: missing sub-answer(s):$missing"
    echo "     All three are required. A partially-filled form is not a defense — the question you"
    echo "     skipped is the one you did not want to answer."
    return 1
  fi
  # Non-vacuity. `ok` / `yes` / `n/a` / `done` are a FILLED FORM, not an answer — the same shape the
  # crossfamily leg rejects. Anything under 12 chars cannot name a command, a reps count, or a layer.
  local bad=""
  for pair in "reproducibility:$r" "fairness:$f" "estimation-layer:$e"; do
    local k="${pair%%:*}" v="${pair#*:}"
    v=$(printf '%s' "$v" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')
    case "$(printf '%s' "$v" | tr 'A-Z' 'a-z')" in
      ok|yes|y|n/a|na|none|done|true|-|tbd|todo) bad="$bad $k(vacuous:'$v')" ;;
      *) [ "${#v}" -ge 12 ] || bad="$bad $k(too short:'$v')" ;;
    esac
  done
  if [ -n "$bad" ]; then
    echo "  ❌ FAIL — axis2-defense: vacuous answer(s):$bad"
    echo "     'ok'/'yes'/'n/a' is a filled form, not an answer. Name the command, the reps, the layer."
    echo "     🟥 The hook checks PRESENCE and NON-VACUITY only. It cannot check whether the answers"
    echo "     are TRUE — that stays with the operator and the weekly audit, exactly like every other"
    echo "     marker field. Do not read a PASS here as a claim that these answers were verified."
    return 1
  fi
  return 0
}

# ── residency= token inside crossfamily grounds (2026-09-05, operator-approved) ──────────────
# WHY: `scripts/residency_closure_scan.py` screens a payload for organizational identifiers
# before it leaves this family boundary — but until now "was it screened" was a claim with no
# channel: auto-decorrelation's own Step 5 note ("Residency still governs … sanitize before any
# external-family dispatch") was prose with nothing downstream reading it back
# ([[feedback_half_externalization_slot_without_consumer]] shape — a slot with no consumer). This
# wires the scan's own exit code into the SAME typed field the crossfamily lane already validates,
# rather than opening a new marker line — one channel, not two.
#
# GRAMMAR (inside crossfamily's own grounds, after the em-dash):
#   crossfamily: panel(codex) — residency=CLEAN(files=7) · R1..R2, 4 findings
#   crossfamily: DEGRADED_SINGLE_FAMILY — residency=TAINTED(2 files, stripped=no) → not sent · …
#   crossfamily: DEGRADED_SINGLE_FAMILY — residency=NOT_SCANNED(scanner absent) → not sent · …
#
# SCOPE — same Mechanization Boundary as the rest of this function: a channel check on the
# RECORD (present when a panel claim is made, closed-enum shaped whenever it appears at all),
# never a truth check. Whether the CLEAN verdict was ACCURATE is not this lane's question — that
# stays with the reader (cross-family reads the marker), identical ceiling to `standpoint:`.
#
# 🟥 NO RETROACTIVITY. Same pattern as `DEFEATER_GRACE_DATE` below: a marker dated before this
# constant is validated exactly as it was before this change existed. Forcing it retroactively
# would block every in-flight branch and teach `--no-verify`, which disarms the Destructive-Op
# gate sharing this hook.
RESIDENCY_TOKEN_GRACE_DATE="2026-09-05"
# 🟥 `evidence=` — panel(...) 이 «무엇을 받았는지» 를 적게 한다. 2026-09-12 신설, 근거는 외부 실측이다:
#   arXiv:2609.10969 (48 템플릿 · 2,880 시나리오 · **고정예산 2×2**) 에서 «같은 증거를 읽는 교차-모델
#   투표» 는 위험 제안의 **62.9 %** 를 승인하고 «독립 출처» 는 **22.9 %** — 출처 효과 40.9 %p 대
#   모델 다양성 11.3 %p, **3.6 배**. 즉 이 훅이 이미 하드 차단하는 축(계열)이 약한 쪽이고,
#   `panel(codex, gemini)` 가 **같은 diff 를 읽은 패널**이면 그것은 62.9 % 팔인데 기록은 강한 값으로
#   남는다. 그 구분을 닫는 토큰이다. 🟥 새 필드도 새 게이트도 만들지 않는다 — residency= 와 같은
#   자리(crossfamily 의 grounds) 에 같은 문법으로 얹는다.
EVIDENCE_TOKEN_GRACE_DATE="2026-09-12"
# 🟥 `standpoint:` tier2+ grounds — 2026-09-12 부로 **advisory 에서 차단으로**. 근거가 바뀌었다:
#   이 carve-out 은 증거가 FH 자기 관찰 n=4+8 뿐일 때 합리적이었다. arXiv:2609.10969 이 같은 비교를
#   n=2,880 에서 **40.9 %p 대 11.3 %p** 로 재면서, 「강한 축의 grounds 를 advisory 로 두고 약한 축을
#   하드 차단한다」가 더는 균형이 아니게 됐다. §Mechanization Boundary 검사는 여전히 통과한다 —
#   «저자가 실행한 명령과 본 출력을 명명했는가» 는 **기록의 속성**(존재·비공허·귀속가능)이지 결론이 아니다.
#   🟥 정직한 범위: 이 게이트는 **기록의 형태**만 막는다. 그 실행이 진짜였는지는 여전히 안 본다 —
#   게임 가능성은 §Mechanization Boundary 가 사람에게 남긴 의도된 잔여이고 이 변경이 닫는 구멍이 아니다.
STANDPOINT_GROUNDS_GRACE_DATE="2026-09-12"
validate_crossfamily_leg() { # $1 = marker path
  local m="$1" line val reason fams mdate _res_active _res_tokens _res_ntok _res_wellformed _res_kind
  local _ev_active _ev_tokens _ev_ntok _ev_full _ev_nfull _ev_wellformed _ev_kind _ev_body _ev_grace _ev_strip
  if [ ! -f "$m" ] || ! grep -qE '^[[:space:]]*crossfamily:[[:space:]]*[^[:space:]]' "$m"; then
    echo "  ❌ FAIL — load-bearing file staged with no 'crossfamily:' line in the Axes 2-3 marker."
    echo "     Verdict/gate/irreversible-surface code shares the author's blind spot with a"
    echo "     same-family reviewer. State the answer, whatever it is — the gate blocks silence:"
    echo "       crossfamily: panel(codex, gemini) — R1..R4, 16 findings, 15 fixed 1 refuted"
    echo "       crossfamily: DEGRADED_SINGLE_FAMILY — probed codex/agy/gemini, 0 capable reachable"
    echo "       crossfamily: DEGRADED_PANEL_UNUSED — codex+agy reachable, not recruited (<why>)"
    echo "       crossfamily: UNKNOWN — panel not probed this round (<why>)"
    echo "       crossfamily: declined — operator declined sidecars (UAP), chosen floor"
    echo "     Append one line to: $m"
    return 1
  fi
  # Duplicate guard (cross-family finding, codex 2026-08-08): a marker is an APPEND-ONLY
  # file in practice, and `grep -m1` takes the FIRST match — so a later corrected/degraded
  # value is shadowed by a stale permissive one and the lane reads the wrong verdict. Two
  # crossfamily lines is an ambiguous marker, not a resolvable one; fail closed rather than
  # pick a winner, because either choice silently discards a stated verdict.
  if [ "$(grep -cE '^[[:space:]]*crossfamily:' "$m")" -gt 1 ]; then
    echo "  ❌ FAIL — marker carries MORE THAN ONE 'crossfamily:' line."
    grep -nE '^[[:space:]]*crossfamily:' "$m" | sed 's/^/       /'
    echo "     A later line does not supersede an earlier one here — the reader takes the"
    echo "     first, so an appended correction would be silently shadowed. Delete the stale"
    echo "     line and leave exactly one."
    return 1
  fi
  line=$(grep -m1 -E '^[[:space:]]*crossfamily:' "$m" \
         | sed -E 's/^[[:space:]]*crossfamily:[[:space:]]*//')
  # Split on the em-dash separator, NOT on first whitespace: a family list legitimately
  # contains ", " (`panel(codex, gemini)`), and splitting on whitespace truncated it to
  # `panel(codex,` — a well-formed panel rejected as malformed. An over-block on the
  # correct shape is the failure that teaches --no-verify, so it is a defect, not strictness.
  val=$(printf '%s' "$line" | sed -E 's/[[:space:]]*(—|--).*$//' | sed -E 's/[[:space:]]+$//')
  reason=$(printf '%s' "$line" | sed -E 's/^[^—-]*(—|--)[[:space:]]*//')
  [ "$reason" = "$line" ] && reason=""

  # residency= token — computed once, consumed by whichever branch below needs it (REQUIRED for
  # panel(...), format-only-if-present for declined/DEGRADED_*/UNKNOWN). See the comment above
  # RESIDENCY_TOKEN_GRACE_DATE for the grammar and why this rides inside crossfamily's own grounds
  # rather than opening a new marker line.
  mdate=$(printf '%s' "$m" | sed -nE 's/.*_([0-9]{4}-[0-9]{2}-[0-9]{2})\.marker$/\1/p')
  _res_active=1
  # Same "can't-read-date → NOT exempt" rule as validate_defeater_leg, same reason: the hook
  # stamps TODAY's date into every marker filename it creates, so an unparseable date is an
  # unexpected shape, not evidence of age ([[feedback_not_found_is_not_zero_family]]).
  if [ -n "$mdate" ] && [ "$mdate" \< "$RESIDENCY_TOKEN_GRACE_DATE" ]; then _res_active=0; fi
  # Presence counts EVERY `residency=` occurrence (any shape); well-formedness is a SEPARATE grep for
  # the full token `residency=<KIND>(...)` with its closing paren. The first draft grepped the prefix
  # `residency=<KIND>(` only, so a malformed token could hide by FAILING the shape grep — cross-family
  # (codex gpt-5.5, 2026-09-05) showed `residency=CLEAN (files=2)` (space) and `residency=CLEAN(files=1`
  # (no close paren) passing the optional-token path as if absent. Presence-first closes that: a
  # `residency=` that is not one well-formed token is malformed, never invisible.
  _res_tokens=$(printf '%s' "$reason" | grep -oE 'residency=[^[:space:]]*' 2>/dev/null || true)
  _res_ntok=0
  [ -n "$_res_tokens" ] && _res_ntok=$(printf '%s\n' "$_res_tokens" | grep -c .)
  _res_full=$(printf '%s' "$reason" | grep -oE 'residency=(CLEAN|TAINTED|NOT_SCANNED)\([^)]*\)' 2>/dev/null || true)
  _res_nfull=0
  [ -n "$_res_full" ] && _res_nfull=$(printf '%s\n' "$_res_full" | grep -c .)
  _res_wellformed=0
  [ "$_res_ntok" -eq 1 ] && [ "$_res_nfull" -eq 1 ] && _res_wellformed=1
  _res_kind=""
  [ "$_res_wellformed" -eq 1 ] && _res_kind=$(printf '%s' "$_res_full" | sed -E 's/^residency=([A-Za-z_]*)\(.*$/\1/')

  # evidence= token — same grammar and same presence-first rule as residency= above. REQUIRED for
  # panel(...) on markers dated >= EVIDENCE_TOKEN_GRACE_DATE; format-only-if-present elsewhere.
  # 🟥 상수를 «먼저» 평가한다(cross-family agy 적발). 이걸 mdate 검사 뒤에 두면 두 결함이 같이 난다:
  #    ⓐ 미주입 시 `[ "$mdate" \< "" ]` 이 거짓이 되어 **grace 가 통째로 꺼지고** pre-grace 마커가
  #      전부 막힌다(실측 확인) ⓑ mdate 가 비면 단축평가로 `:?` 가드가 아예 안 돈다.
  _ev_grace="${EVIDENCE_TOKEN_GRACE_DATE:?EVIDENCE_TOKEN_GRACE_DATE unset — partial extraction of this hook? refusing to guess a grace date}"
  _ev_active=1
  if [ -n "$mdate" ] && [ "$mdate" \< "$_ev_grace" ]; then _ev_active=0; fi
  _ev_tokens=$(printf '%s' "$reason" | grep -oE 'evidence=[^[:space:]]*' 2>/dev/null || true)
  _ev_ntok=0
  [ -n "$_ev_tokens" ] && _ev_ntok=$(printf '%s\n' "$_ev_tokens" | grep -c .)
  # 🟥 `[^)]*` 는 중첩 괄호에서 안쪽 `)` 에 끊긴다 — `evidence=MIXED(codex(diff), gemini(repo))` 의
  #    본문이 `codex(diff` 로 잘려 «공허» 판정을 받고 **정당한 기록이 하드 차단**됐다(실측).
  #    한 겹 중첩을 허용한다(두 겹은 여전히 형식 위반 — 무한 중첩을 정규식으로 쫓지 않는다).
  _ev_full=$(printf '%s' "$reason" | grep -oE 'evidence=(SHARED|INDEPENDENT|MIXED)\(([^()]|\([^()]*\))*\)' 2>/dev/null || true)
  _ev_nfull=0
  [ -n "$_ev_full" ] && _ev_nfull=$(printf '%s\n' "$_ev_full" | grep -c .)
  _ev_wellformed=0
  [ "$_ev_ntok" -eq 1 ] && [ "$_ev_nfull" -eq 1 ] && _ev_wellformed=1
  _ev_kind=""; _ev_body=""
  if [ "$_ev_wellformed" -eq 1 ]; then
    _ev_kind=$(printf '%s' "$_ev_full" | sed -E 's/^evidence=([A-Z]*)\(.*$/\1/')
    _ev_body=$(printf '%s' "$_ev_full" | sed -E 's/^evidence=[A-Z]*\(//; s/\)$//')
  fi

  case "$val" in
    declined)
      # `declined` means ONE thing: the OPERATOR declined sidecars (UAP), a chosen floor. It was
      # the only value in this enum with no grounds requirement, and that made it the enum's soft
      # spot: an author who judged decorrelation unnecessary reached for the nearest permissive
      # token and it passed clean. Measured 2026-08-16, in this repo, by the session that had
      # CORRECTED a different marker's crossfamily value earlier the same day and had the correct
      # semantics printed to it in this hook's own error text — i.e. neither ignorance of the enum
      # nor of the file. Form was satisfied, so the question stopped.
      #
      # A closed enum stops free prose from collapsing distinct states; it does NOT stop a wrong
      # token from being a valid one. This lane is the difference. The grounds bar is deliberately
      # the same shape as the degrade branch below (substance + a token naming WHO/WHERE), because
      # the claim being made is factual and attributable: an operator decision has a record.
      # 🟥 THE TEST IS A RESOLVABLE RECORD, NOT VOCABULARY. The first version of this lane grepped
      # for words (operator|uap|consent|…). A cross-family review (codex/gpt-5.6-terra) attacked it
      # and all three findings reproduced on the spot:
      #   · SELF-VALIDATING  `declined — declined because I judged it unnecessary` PASSED, because
      #                      `declin` was in the keyword list and the author had echoed the value.
      #   · VACUOUS PASS     `declined — operator documentation says authors may choose` PASSED
      #                      while describing no declination at all.
      #   · OVER-BLOCK       `declined — the person who runs this harness asked us not to use
      #                      external reviewers here` was BLOCKED — a real operator declination,
      #                      in natural prose, carrying none of the listed tokens.
      # Adding patterns is the Grep-Collision Treadmill (P10): each new word relocates the evasion.
      # So the check changed KIND. Per CLAUDE.md §Mechanization Boundary a channel check asserts a
      # property OF THE RECORD, never a conclusion — and the honest record property here is:
      # **the claim names a file that exists.** Vocabulary cannot be stuffed into it, a fabricated
      # path fails, and a legitimate declination just cites where it lives.
      # HONEST RESIDUAL, stated rather than implied: this proves a cited record EXISTS, not that it
      # SAYS what is claimed. That is the marker's own declared scope (form + non-vacuity +
      # auditability, NOT provenance) — identical ceiling, not a new gap.
      _cf_cited=""
      for _tok in $reason; do
        _tok=${_tok%%,}; _tok=${_tok%%\)}; _tok=${_tok##\(}
        case "$_tok" in
          */*|*.md|*.yaml|*.yml|*.json)
            if [ -e "$REPO_ROOT/$_tok" ] || [ -e "$_tok" ] || [ -e "$HOME/$_tok" ]; then
              _cf_cited="$_tok"; break
            fi ;;
        esac
      done
      if [ "${#reason}" -lt 20 ] || [ -z "$_cf_cited" ]; then
        echo "  ❌ FAIL — 'declined' must cite a record that EXISTS on disk."
        echo "     declined = the OPERATOR declined sidecars, a chosen floor. It does NOT mean"
        echo "     'I judged decorrelation unnecessary here' — that is a CHOICE the author made"
        echo "     with a panel reachable, and its honest value is DEGRADED_PANEL_UNUSED."
        echo "     An operator decision has a record. Name its path on this same line:"
        echo "       crossfamily: declined — operator declined sidecars, tracks/_meta/user_adaptation_profile.md"
        echo "       crossfamily: declined — chosen floor per CLAUDE.local.md §sidecar"
        echo "     No such record → you are not describing an operator declination:"
        echo "       crossfamily: DEGRADED_PANEL_UNUSED — codex+agy reachable, not recruited (<why>)"
        [ "${#reason}" -ge 20 ] && echo "     (grounds were substantive but named no resolvable path)"
        return 1
      fi
      if [ "$_res_active" -eq 1 ] && [ "$_res_ntok" -gt 0 ]; then
        if [ "$_res_ntok" -gt 1 ]; then
          echo "  ❌ FAIL — grounds carries MORE THAN ONE 'residency=' token."
          printf '%s\n' "$_res_tokens" | sed 's/^/       /'
          echo "     Same trap as two crossfamily: lines — the reader takes the first, so an"
          echo "     appended correction is silently shadowed. Leave exactly one."
          return 1
        fi
        if [ "$_res_wellformed" -ne 1 ]; then
          echo "  ❌ FAIL — 'residency=' token present but not one of CLEAN|TAINTED|NOT_SCANNED:"
          printf '%s\n' "$_res_tokens" | sed 's/^/       /'
          echo "     Closed set, same reason crossfamily's own value is one — free prose here is"
          echo "     read as measured by a later session."
          return 1
        fi
      fi
      echo "  ✅ cross-family leg: declined (operator-chosen floor, first-class — not a degrade)"
      echo "     $(printf '%s' "$reason" | cut -c1-80)" ;;
    panel*)
      if ! printf '%s' "$val" | grep -qE '^panel\([^)]+\)$'; then
        echo "  ❌ FAIL — panel claim without a family list: '$val'"
        echo "     Write panel(<families>), e.g. panel(codex, gemini). Source the list from"
        echo "     scripts/sidecar_calibrate.sh's PANEL line rather than recall — that line"
        echo "     records a pin verified for THIS run."
        return 1
      fi
      fams=$(printf '%s' "$val" | sed -E 's/^panel\(//; s/\)$//')
      # (a) AUTHOR-FAMILY EXCLUSION. The point of this field is decorrelation; a panel of
      # the author's own family is a same-family pass wearing a panel label — the exact
      # substitution the load-bearing block exists to forbid. FH's governor is Claude.
      if printf '%s' "$fams" | grep -qiE '(^|[,[:space:]])(claude|opus|sonnet|haiku|fable)'; then
        echo "  ❌ FAIL — panel names the AUTHOR'S OWN family: '$fams'"
        echo "     A Claude-family auditor shares the governor's blind spots, so it is not"
        echo "     decorrelation. If that is genuinely all that was reachable, the honest value"
        echo "     is DEGRADED_SINGLE_FAMILY with a reason — not a panel claim."
        return 1
      fi
      # (b) INELIGIBLE-FIRST. Tokens overlap: GLM-OCR matches both `ocr` and `glm`,
      # Qwen3-Embedding matches both `embed` and `qwen`. Test capability-eligibility first
      # and both pass silently (pmh-dev #41 design decision 3, anchored there and here).
      if printf '%s' "$fams" | grep -qiE 'embed|rerank|[^a-z]rank|ocr|safeguard|moderat|classif|whisper|dall-e|-rm([^a-z]|$)|bge-|voyage|gte-|minilm'; then
        echo "  ❌ FAIL — panel names a review-INCAPABLE member: '$fams'"
        echo "     Embedding / reranker / reward-model / moderation / OCR / speech classes emit"
        echo "     vectors, scores or labels — they cannot return an adversarial finding, so"
        echo "     counting them inflates BOTH panel size and family-diversity. If nothing capable"
        echo "     remains, the honest value is DEGRADED_SINGLE_FAMILY with a reason."
        return 1
      fi
      # (c) DEFAULT DENY on unknown families (pmh-dev #41 design decision 1: unmeasured is
      # not eligible). A denylist alone cannot know `voyage-3` or `armorm` are not reviewers,
      # so an unrecognised token is UNDECIDABLE, never assumed capable. Matching is by FAMILY
      # PATTERN, not id enumeration (#41 decision 2), so a new build of a known family passes
      # without rewiring — which is the reason the CLI route was chosen at all.
      local unknown="" tok
      for tok in $(printf '%s' "$fams" | tr ',' ' '); do
        printf '%s' "$tok" | grep -qiE 'codex|gpt|openai|o[0-9]|gemini|agy|antigravity|glm|qwen|deepseek|mistral|llama|grok|command-|cohere|kimi|copilot|ollama|phi-|yi-' \
          || unknown="${unknown:+$unknown }$tok"
      done
      if [ -n "$unknown" ]; then
        echo "  ❌ FAIL — panel names unrecognised family/families: '$unknown'"
        echo "     Default-deny: an unrecognised token is UNDECIDABLE, not capable — that is how"
        echo "     'panel(none)' and 'panel(unprobed)' would otherwise launder a non-run into a"
        echo "     pass. Either use the family pattern this runtime belongs to, or add the"
        echo "     pattern here in the same commit that first uses it."
        return 1
      fi
      if [ "$_res_active" -eq 1 ]; then
        if [ "$_res_ntok" -gt 1 ]; then
          echo "  ❌ FAIL — grounds carries MORE THAN ONE 'residency=' token."
          printf '%s\n' "$_res_tokens" | sed 's/^/       /'
          echo "     Same trap as two crossfamily: lines — the reader takes the first. Leave"
          echo "     exactly one."
          return 1
        fi
        if [ "$_res_ntok" -eq 0 ]; then
          echo "  ❌ FAIL — panel(...) with no 'residency=CLEAN(' token in grounds."
          echo "     A panel claim means a payload left this family boundary. Screen it first"
          echo "     (scripts/residency_closure_scan.py --files <payload>) and record the result:"
          echo "       crossfamily: panel($fams) — residency=CLEAN(files=7) · R1..R3, ..."
          echo "     A TAINTED or NOT_SCANNED payload must not have been sent — if that is what"
          echo "     actually happened, the honest crossfamily value is DEGRADED_*, not panel(...)."
          return 1
        fi
        if [ "$_res_wellformed" -ne 1 ]; then
          echo "  ❌ FAIL — 'residency=' token present but not one of CLEAN|TAINTED|NOT_SCANNED:"
          printf '%s\n' "$_res_tokens" | sed 's/^/       /'
          echo "     Closed set, same reason crossfamily's own value is one — free prose here is"
          echo "     read as measured by a later session."
          return 1
        fi
        if [ "$_res_kind" != "CLEAN" ]; then
          echo "  ❌ FAIL — panel(...) (payload WAS sent) with residency=$_res_kind(...) (NOT clean"
          echo "     / not screened) on the SAME line — a contradiction in the record."
          echo "     A sent payload must carry residency=CLEAN(...). If it was actually TAINTED or"
          echo "     NOT_SCANNED, it should not have shipped — the honest value is DEGRADED_*."
          return 1
        fi
      fi
      # evidence= — WHAT the panel received. Same four checks in the same order as residency=
      # (duplicate → absent → shape → meaning). Why this exists at all: arXiv:2609.10969 measured
      # the two axes against each other at fixed call budget (48 templates, 2,880 scenarios) and a
      # cross-model vote over SHARED evidence approved 62.9% of unsafe proposals vs 22.9% with an
      # independent source — 40.9pp vs 11.3pp for model diversity. A `panel(codex, gemini)` that
      # read the same diff is that 62.9% arm while recording as this enum's STRONG value. The token
      # makes the distinction sayable; it does not make shared evidence illegal.
      # 🟥 grace 여부와 무관하게, 토큰이 «있으면» 형식은 본다 — residency 가 `DEGRADED_*` 에서 하는
      #    약속과 같은 모양이다. 이게 없으면 pre-grace 마커의 `evidence=PARTIAL(...)` 이 조용히
      #    통과한다(실측). 부재는 grace 가 면제하지만, **잘못 쓴 것은 면제하지 않는다.**
      if [ "$_ev_active" -eq 0 ] && [ "$_ev_ntok" -gt 0 ] && [ "$_ev_wellformed" -ne 1 ]; then
        echo "  ❌ FAIL — 'evidence=' token present but malformed (pre-grace marker: the token is"
        echo "     OPTIONAL here, but a token that IS written must be well-formed):"
        printf '%s\n' "$_ev_tokens" | sed 's/^/       /'
        echo "     Closed set evidence=(SHARED|INDEPENDENT|MIXED)(...) with a closing paren."
        return 1
      fi
      if [ "$_ev_active" -eq 1 ]; then
        if [ "$_ev_ntok" -gt 1 ]; then
          echo "  ❌ FAIL — grounds carries MORE THAN ONE 'evidence=' token."
          printf '%s\n' "$_ev_tokens" | sed 's/^/       /'
          echo "     Same trap as two crossfamily: lines — the reader takes the first. Leave one."
          return 1
        fi
        if [ "$_ev_ntok" -eq 0 ]; then
          echo "  ❌ FAIL — panel(...) with no 'evidence=' token in grounds."
          echo "     State WHAT the panel received, because that axis carries ~3.6x the effect of"
          echo "     family diversity (arXiv:2609.10969: shared evidence approves 62.9% of unsafe"
          echo "     proposals vs 22.9% independent; 40.9pp vs 11.3pp). One of:"
          echo "       evidence=SHARED(<what every family read — e.g. the same diff>)"
          echo "       evidence=INDEPENDENT(<what each got separately — own checkout, own run>)"
          echo "       evidence=MIXED(<which member got which>)"
          echo "     e.g. crossfamily: panel($fams) — residency=CLEAN(files=3) · evidence=SHARED(same"
          echo "          staged diff to both) · R1..R2, 4 findings"
          echo "     🟥 SHARED is a legal, common answer — it is not a failure. Recording it as if"
          echo "     it were INDEPENDENT is the failure this token exists to stop."
          return 1
        fi
        if [ "$_ev_wellformed" -ne 1 ]; then
          echo "  ❌ FAIL — 'evidence=' token present but not one well-formed"
          echo "     evidence=(SHARED|INDEPENDENT|MIXED)(...) token:"
          printf '%s\n' "$_ev_tokens" | sed 's/^/       /'
          echo "     Closed set with a closing paren — same shape as residency=, same reason: a"
          echo "     malformed token must be malformed, never invisible."
          return 1
        fi
        # Meaning check, and it is deliberately only on SHARED/MIXED: those are the values whose
        # whole content is "which evidence did they actually share", so an empty body says nothing.
        # INDEPENDENT is self-describing; over-blocking it would train the override.
        # 🟥 본문 검사, 두 층 — cross-family agy 가 초판의 두 결함을 다 지목했다:
        #   ⓐ **빈 본문과 자리표시자는 세 값 모두 차단**한다. 초판은 INDEPENDENT 를 통째로 면제해서
        #     `evidence=INDEPENDENT()` 와 `evidence=INDEPENDENT(<what>)` 가 **통과했다**(실측 fail-open).
        #     «자기서술적이라 본문 검사 면제» 는 «본문이 없어도 된다» 와 다른 말이었는데 초판이 접었다.
        #   ⓑ 길이 바는 **문자 수**로 센다. `wc -w` 는 `same-diff` 도 `동일diff` 도 1 낱말로 세서
        #     정당한 한국어/하이픈 기록을 막았다(실측) — 한국어로 근거를 쓰는 이 레포에서 이건 상습 과차단이다.
        _ev_strip=$(printf '%s' "$_ev_body" | tr -d '[:space:]')
        if [ -z "$_ev_strip" ] \
           || printf '%s' "$_ev_body" | grep -qE '^[[:space:]]*(TBD|tbd|N/?A|n/?a|없음|미정|-+|\?+|<[^>]*>)[[:space:]]*$'; then
          echo "  ❌ FAIL — evidence=$_ev_kind(...) has an EMPTY or placeholder body: '$_ev_body'"
          echo "     All three values need a body — 'self-describing' meant it is not length-checked,"
          echo "     never that it can be blank. Name what each member actually received."
          return 1
        fi
        case "$_ev_kind" in
          SHARED|MIXED)
            # 문자 수 기준(공백 제외). 4자 미만이면 «diff» 같은 한 토큰이고, 그건 어느 산출물인지
            # 말하지 않는다. 🟥 낱말 수로 세지 않는다 — CJK 와 하이픈 복합어가 전부 1 낱말이 된다.
            if [ "$(printf '%s' "$_ev_strip" | wc -m | tr -d ' ')" -lt 6 ]; then
              echo "  ❌ FAIL — evidence=$_ev_kind(...) body too thin to identify the artifact: '$_ev_body'"
              echo "     Name it (\"same staged diff\", \"one findings.jsonl\", \"동일 프롬프트 파일\")."
              echo "     A bare $_ev_kind(diff) records the weak arm as if it were documented."
              return 1
            fi ;;
        esac
      fi
      echo "  ✅ cross-family leg: $val${reason:+ — $(printf '%s' "$reason" | cut -c1-60)}" ;;
    DEGRADED_SINGLE_FAMILY|DEGRADED_PANEL_UNUSED|UNKNOWN)
      # A degrade is allowed; being silent about WHICH degrade, or about its grounds, is not.
      # Two conditions, because either alone is porous: enough substance to be a statement
      # (length) AND a token naming what was actually looked at.
      if [ "${#reason}" -lt 20 ] \
         || ! printf '%s' "$reason" | grep -qiE 'prob(e|ed|ing)|reachab|unreach|not reachable|0 |none reachable|empty|absent|declin|consent|codex|gemini|agy|copilot|gateway|cli|panel|scope|budget|offline'; then
        echo "  ❌ FAIL — $val without substantive grounds on the same line."
        case "$val" in
          UNKNOWN) echo "     UNKNOWN = the panel was never probed. Undecidable, not zero." ;;
          DEGRADED_SINGLE_FAMILY) echo "     = probed, nothing CAPABLE reachable (a constraint)." ;;
          DEGRADED_PANEL_UNUSED) echo "     = a capable panel WAS reachable and was not recruited (a choice)." ;;
        esac
        echo "     Name what you probed and what came back — the defect this field exists for"
        echo "     was a line that announced a state without naming its grounds:"
        echo "       crossfamily: $val — probed codex/agy/gemini, <what came back>"
        return 1
      fi
      if [ "$_res_active" -eq 1 ] && [ "$_res_ntok" -gt 0 ]; then
        if [ "$_res_ntok" -gt 1 ]; then
          echo "  ❌ FAIL — grounds carries MORE THAN ONE 'residency=' token."
          printf '%s\n' "$_res_tokens" | sed 's/^/       /'
          return 1
        fi
        if [ "$_res_wellformed" -ne 1 ]; then
          echo "  ❌ FAIL — 'residency=' token present but not one of CLEAN|TAINTED|NOT_SCANNED:"
          printf '%s\n' "$_res_tokens" | sed 's/^/       /'
          return 1
        fi
      fi
      echo "  ⚠️  cross-family leg: $val — recorded, not silent (weekly audit re-queues these)"
      echo "     $(printf '%s' "$reason" | cut -c1-80)"
      # 🟥 수락 경로에도 «안 짚은 채널»을 띄운다. 실패 메시지에만 채널 목록이 있으면,
      #    근거를 적어서 통과한 사람은 그 목록을 **영영 못 본다** — 오늘 저자가 선 자리가 거기다.
      #    실측 2026-08-29: 마커 다섯 개가 연속으로 DEGRADED 였고, `gemini` 가 인증 실패로 죽자
      #    거기서 멈췄다. `agy` 는 살아 있었고 `scripts/sidecar_calibrate.sh` 가 그것을 codex 보다
      #    많이 알고 있었는데(8 vs 7 히트) 아무도 그 자리에서 말해주지 않았다.
      #    ⚠️ 차단하지 않는다 — 근거는 이미 통과했다. 이건 살리언스이지 판정이 아니다.
      _unnamed=""
      for _ch in codex agy gemini copilot; do
        printf '%s' "$reason" | grep -qi -- "$_ch" || _unnamed="$_unnamed $_ch"
      done
      if [ -n "$_unnamed" ]; then
        echo "     ℹ️  근거에 안 나온 채널:$_unnamed"
        echo "        «probe 안 한 패널은 패널이 아니라 호스트명이 붙은 가정»이다."
        echo "        살아있는지 재려면:  bash scripts/sidecar_calibrate.sh --only <채널>"
      fi ;;
    single-family|none|none-this-round|N/A|n/a)
      # `single-family` is deliberately NOT accepted HERE. This block runs only on
      # load-bearing changes, where "decorrelation not required" is a contradiction — and
      # as a no-ack, no-warning pass it was a free bypass of the whole lane (any degrade
      # could take this exit instead of DEGRADED_*). `none` is worse: it merges three states.
      echo "  ❌ FAIL — '$val' is not an accepted value on a LOAD-BEARING change."
      echo "     This block only runs when gate/verdict/irreversible-surface code is staged, so"
      echo "     'decorrelation not required' does not apply — and a no-ack pass here would let"
      echo "     any degrade take this exit instead of naming itself. Say which state holds:"
      echo "       DEGRADED_SINGLE_FAMILY  probed, nothing capable reachable   (could not)"
      echo "       DEGRADED_PANEL_UNUSED   panel reachable, not recruited      (did not)"
      echo "       UNKNOWN                 never probed                        (did not look)"
      echo "       declined                operator declined sidecars          (chosen floor)"
      return 1 ;;
    *)
      echo "  ❌ FAIL — invalid crossfamily value: '$val'"
      echo "     Allowed: panel(<families>) | declined |"
      echo "              DEGRADED_SINGLE_FAMILY | DEGRADED_PANEL_UNUSED | UNKNOWN"
      echo "     Free prose is rejected on purpose: a prose verdict cannot be consumed by a gate,"
      echo "     and a later session cites it as though it were measured. That has happened."
      return 1 ;;
  esac
  return 0
}

# ── standpoint: typed value lane (2026-08-17) ────────────────────────────────
# Grammar:  standpoint: <VALUE>[ — <reason>]
#
# WHY NOW. This field shipped 2026-08-14 with its value DELIBERATELY unvalidated — the doctrine
# said "mechanize on the first recorded false value, not before". That value is now on record:
# a `release_2.3.0` marker wrote `not-applicable` on a delta whose OWN grounds line concedes
# "소비자 install 의 게이트 수용은 바뀐다 (BREAKING 2건)", and two 2026-08-14 deltas that changed
# shipped gate scripts / a shipped SKILL.md carried no line at all. The threshold this field set
# for itself is met.
#
# SCOPE — channel, not judgment (CLAUDE.md §Mechanization Boundary). This asserts properties of the
# RECORD: present · single · a member of the closed enum · non-vacuous · not wearing the OTHER
# axis's tokens. It never asserts the value is CORRECT — that stays with the reader, by design.
#
# 🟥 NAMED RESIDUAL — the EXECUTION claim warns, it does not block. tier2/tier2b/tier3 assert that
# something was RUN in the target, which §7 calls "the load-bearing half". Blocking on that needs a
# vocabulary grep, and on first contact with the real corpus that grep over-blocked a legitimate
# marker whose grounds read "그 레포에서 실제로 호출해 양·음 arm 을 확인했다" — it did not know 「호출」.
# Over-blocking trains `--no-verify`, which would disarm the Destructive-Op gate in this same hook.
# So the cost is stated instead of hidden: a fabricated tier2 with a fluent reason PASSES this lane.
# Closing that is §4-b's job (cross-family reads the marker), not this lane's.
#
# 🟥 DOGFOOD RESULT, STATED SO NOBODY READS THIS AS MORE THAN IT IS — the very marker whose false
# value MET this field's mechanization threshold (`release_2.3.0`, which wrote `not-applicable` on a
# release delta) **PASSES this lane**, because its grounds sentence is long and specific. That is not
# a bug in the lane; it is the Mechanization Boundary holding: the channel is checked, the judgment
# is not. Anyone reporting that this wiring "closed the self-report axis" is wrong. What closed is
# the shape of the record; what remains open is whether the recorded answer is true.
#
# Calibration before wiring (2026-08-17): known-pair 12/12 both directions; run against all 24
# corpus markers carrying the field → 7 blocked, every one a real defect (6 bare `not-applicable`,
# independently matching a hand count, + 1 marker carrying TWO contradictory standpoint lines),
# and 0 over-blocks. Anchor: scripts/test_marker_standpoint_lanes.sh
validate_standpoint_leg() { # $1 = marker path
  local m="$1" line val reason n quoted=""
  local _sp_mdate _sp_block _sp_grace
  n=$(grep -cE '^[[:space:]]*standpoint:' "$m" 2>/dev/null); n=${n:-0}
  if [ "$n" -eq 0 ]; then
    echo "  ❌ FAIL — load-bearing file staged with no 'standpoint:' line in the Axes 2-3 marker."
    echo "     State the answer, whatever it is — the gate blocks silence, not a modest value."
    echo "     Settle the TARGET CLASS first (§7 Q0), then the tier:"
    echo "       standpoint: not-applicable — <what you checked to conclude no target exists>"
    echo "       standpoint: tier1b(pmh-dev) — read the target's own files, executed nothing"
    echo "       standpoint: tier2(qasp-dev) — ran <command> there, saw <output>"
    echo "       standpoint: DEGRADED_NOT_RUN — target reachable, not run (<why>)"
    echo "     Append one line to: $m"
    return 1
  fi
  # Duplicate guard — same defect class the crossfamily lane closes: a marker is append-only in
  # practice and readers take the FIRST match, so an appended correction is silently shadowed.
  # Measured in this corpus: one marker carries `tier1` at :32 and `not-applicable` at :89.
  if [ "$n" -gt 1 ]; then
    echo "  ❌ FAIL — marker carries MORE THAN ONE 'standpoint:' line."
    grep -nE '^[[:space:]]*standpoint:' "$m" | sed 's/^/       /'
    echo "     A later line does not supersede an earlier one — the reader takes the first."
    echo "     Delete the stale line and leave exactly one."
    return 1
  fi
  line=$(grep -m1 -E '^[[:space:]]*standpoint:' "$m" | sed -E 's/^[[:space:]]*standpoint:[[:space:]]*//')
  case "$line" in '"'*|"'"*) quoted=" [format: value wrapped in quotes — an auditor's grep for
       'standpoint: tier2' misses it; drop the quotes next time]";; esac
  line=$(printf '%s' "$line" | sed -E 's/^["'"'"']//; s/["'"'"']$//')
  val=$(printf '%s' "$line" | sed -E 's/[[:space:]]*(—|--).*$//' | sed -E 's/[[:space:]]+$//')
  reason=$(printf '%s' "$line" | sed -E 's/^[^—-]*((—|--)[[:space:]]*)?//')
  # Cross-axis contamination. `crossfamily:` and `standpoint:` each carry a three-way degrade
  # triad and the doctrine says the literals are DISTINCT ON PURPOSE. Borrowing the other axis's
  # token records the wrong axis while looking well-formed.
  case "$val" in
    DEGRADED_SINGLE_FAMILY*|DEGRADED_PANEL_UNUSED*|panel\(*|declined)
      echo "  ❌ FAIL — '$val' belongs to the crossfamily: axis, not standpoint:."
      echo "     crossfamily = WHICH MODEL FAMILY reviewed. standpoint = WHOSE REPO was ground truth."
      echo "     standpoint's own triad: DEGRADED_NO_TARGET_ACCESS (could not) /"
      echo "     DEGRADED_NOT_RUN (did not) / UNKNOWN (did not look)."
      return 1 ;;
  esac
  case "$val" in
    tier1)
      echo "  ✅ standpoint leg: tier1 (content-only — a scoping fact, not a failure)$quoted" ;;
    tier1b\(*\)|tier2\(*\)|tier2b\(*\)|tier3\(*\))
      case "$val" in
        tier2\(*\)|tier2b\(*\)|tier3\(*\))
          # 🟥 키워드에 «뒤따르는 공백» 을 박아두면 정당한 기록이 막힌다 — cross-family agy 적발,
          #    실측 확인: `cargo check` · `python scripts/eval.py` · `ran: ./ci.sh` 가 전부 BLOCK 이었다.
          #    advisory 일 때는 경고 한 줄이었지만 **차단이 된 순간 이건 override 를 훈련시키는 결함**이다.
          #    ⇒ ⓐ 경계를 «비-알파벳» 으로 바꾸고(콜론·세미콜론·줄끝 허용) ⓑ 흔한 러너를 넣고
          #      ⓒ «출력» 의 형태(`30/30`, `rc=`, `./path`)도 실행의 증거로 받는다.
          #    ⚠️ 방향: 이건 **느슨하게** 만드는 변경이다. 막고 싶은 것은 «명령도 출력도 안 적은 줄» 이고,
          #    길이 20 자 하한은 그대로 남는다.
          if [ "${#reason}" -lt 20 ] || ! printf '%s' "$reason" \
              | grep -qiE '(^|[^[:alpha:]])(ran|run|runs|exec|invoke[ds]?|call|calls|called|exit|output|suite|test|tests|tested|bash|sh|zsh|grep|npm|pnpm|yarn|pytest|python|python3|cargo|make|go|gradle|mvn|dotnet|node|command)([^[:alpha:]]|$)|실행|돌렸|돌린|돌려|호출|출력|스위트|커맨드|rc=|[0-9]+/[0-9]+|\./'; then
            # Grace: markers dated before STANDPOINT_GROUNDS_GRACE_DATE keep the old advisory
            # behaviour (no retro-blocking), same pattern as the residency=/evidence= tokens.
            _sp_mdate=$(printf '%s' "$m" | sed -nE 's/.*_([0-9]{4}-[0-9]{2}-[0-9]{2})\.marker$/\1/p')
            _sp_block=1
            # Unreadable date → NOT exempt. The hook stamps TODAY into every marker filename it
            # creates, so an unparseable date is an unexpected shape, not evidence of age.
            # 🟥 상수를 «먼저» 평가한다 — 초판은 `[ -n "$_sp_mdate" ] && [ ... ${VAR:?} ]` 였고,
            #    mdate 가 비면 단축평가로 `:?` 가드가 **아예 안 돌았다**(agy 적발). 가드가 있다고
            #    적어놓고 안 도는 것은 장식이다([[feedback_anchor_can_be_decorative]]).
            _sp_grace="${STANDPOINT_GROUNDS_GRACE_DATE:?STANDPOINT_GROUNDS_GRACE_DATE unset — partial extraction of this hook? refusing to guess a grace date}"
            if [ -n "$_sp_mdate" ] && [ "$_sp_mdate" \< "$_sp_grace" ]; then _sp_block=0; fi
            if [ "$_sp_block" -eq 1 ]; then
              echo "  ❌ FAIL — standpoint leg: $val — the grounds do not NAME an execution."
              echo "     ${val%%(*} asserts code RAN in the target. The discriminator is mechanical:"
              echo "     name the command you ran and the output you saw. If you only read files,"
              echo "     the honest rung is tier1b — weaker on purpose, so the owed arm stays visible."
              echo "       standpoint: tier2(<harness>) — ran \`bash scripts/x.sh\` there, output: 30/30 PASS"
              echo "     🟥 BLOCKING since 2026-09-12 (was advisory). Why it changed:"
              echo "     arXiv:2609.10969 measured evidence-source diversity at 40.9pp against 11.3pp"
              echo "     for model diversity (n=2,880, fixed call budget) — this is the STRONGER axis,"
              echo "     and leaving its grounds advisory while hard-blocking the weaker one was not a"
              echo "     balance. Honest scope: this blocks on the RECORD's shape, never on whether the"
              echo "     run was real."
              return 1
            fi
            echo "  ⚠️  standpoint leg: $val — grounds name no execution (pre-$STANDPOINT_GROUNDS_GRACE_DATE marker, advisory)."
          else
            echo "  ✅ standpoint leg: $val$quoted"
          fi ;;
        *) echo "  ✅ standpoint leg: $val$quoted" ;;
      esac ;;
    not-applicable)
      # §7: asserting non-applicability without naming what was checked is indistinguishable from
      # UNKNOWN wearing a permissive label. Measured: 6 bare instances in this corpus.
      if [ "${#reason}" -lt 20 ]; then
        echo "  ❌ FAIL — bare 'not-applicable' with no grounds on the same line."
        echo "     That is UNKNOWN wearing a permissive label. Name what you checked:"
        echo "     no named peer carries this surface AND it changes no consumer-visible behavior."
        return 1
      fi
      echo "  ✅ standpoint leg: not-applicable$quoted" ;;
    DEGRADED_NO_TARGET_ACCESS*|DEGRADED_NOT_RUN*|UNKNOWN*)
      if [ "${#reason}" -lt 20 ]; then
        echo "  ❌ FAIL — $val without substantive grounds on the same line."
        echo "     could-not / did-not / did-not-look are separate values on purpose; collapsing"
        echo "     them renders an unrun probe as a zero finding. Name what you probed."
        return 1
      fi
      echo "  ⚠️  standpoint leg: $val — recorded, not silent" ;;
    *)
      echo "  ❌ FAIL — '$val' is not a member of the standpoint: enum."
      echo "     Closed enum: tier1 · tier1b(<h>) · tier2(<h>) · tier2b(<h>) · tier3(<h>) ·"
      echo "     not-applicable · DEGRADED_NO_TARGET_ACCESS · DEGRADED_NOT_RUN · UNKNOWN"
      echo "     Default-deny: an unrecognised value is UNDECIDABLE, never assumed benign."
      return 1 ;;
  esac
  return 0
}

# ── thirdparty: typed value lane (2026-08-17) ────────────────────────────────
# Grammar:  thirdparty: <VALUE>(<grounds>)      ·  UNKNOWN takes no parenthetical
#
# WHAT THIS AXIS IS — and it has TWO halves, which is what made getting it wrong so easy.
# The 6-axis canon (fh_three_layer_canon.md:243) defines ⓓ by what it RECEIVES: «문제 + 남의
# 코드베이스», and it asks two questions:
#   ① prior art        — has this claimed-new thing already been solved outside?   checked/none-found
#   ② harness-level     — how does my change look from ANOTHER HARNESS's repo and   peer-review
#      adversarial        persona? (operator: put Sonnet in gstack, wear the gstack
#                         persona, review the qasp change — a cross-FAMILY, harness-scoped
#                         adversarial review. FH is the GOVERNOR: it creates the situation,
#                         observes, and judges. What the 4-axis gate verifies is the OPINION
#                         that third harness produced, judged LOCALLY — it does not put a gate
#                         on someone else's repo.)
# It is NOT "I talked to another session". The canonical spec
# (`.claude/rules/fh_4axis_gate.md`) has said so since the field shipped, and the evidence that
# created it was 6 presentation claims that turned out to have prior art (mutation testing,
# promptfoo/DeepEval, an official cross-family plugin, …).
#
# WHY NOW — the first recorded false values exist, and they are the author's own. Measured across
# the marker corpus 2026-08-17: 4 markers used the enum correctly; **2 wrote free prose about
# peer-session contact** and matched no enum member at all. That is the field-canon failure this
# repo already names — normalizing a harness term into a general concept ("third party" → "another
# session"). Presence was checked; the value was not; nothing caught it.
#
# SCOPE — channel, not judgment (CLAUDE.md §Mechanization Boundary). This asserts the RECORD is a
# member of the closed enum and names non-vacuous grounds. It never asserts the prior-art search
# was thorough, nor that what a third party told you is TRUE — 🟥 that second one is deliberate and
# load-bearing: the axis exists to surface information you could not predict, and the truth of what
# comes back is adjudicated by the OTHER axes (adversarial · grounding · first-real-use), which is
# what the 4-axis gate is for. A wrong answer from a third party is that party's defect, possibly
# one to go fix; it is not something this field should be asked to certify.
#
# TRIGGER — validate IF PRESENT. Presence stays pointer-driven (`ⓓ=→thirdparty`), deliberately:
# requiring the field on every load-bearing change would force a prior-art search per commit, and
# over-blocking trains the override that disarms the Destructive-Op gate in this same hook.
#
# Calibration before wiring: the corpus supplied BOTH arms — peer-authored markers 4/4 PASS,
# author's own malformed 2/2 BLOCK, plus 6 synthetic shapes, 0 over-blocks. An earlier draft DID
# over-block 3 legitimate markers by looking for the closing paren on the first line when the
# grounds wrap across lines; the corpus caught it. Anchor: scripts/test_marker_thirdparty_lanes.sh
validate_thirdparty_leg() { # $1 = marker path
  local m="$1" line val reason n
  n=$(grep -cE '^[[:space:]]*thirdparty:' "$m" 2>/dev/null); n=${n:-0}
  [ "$n" -eq 0 ] && return 0   # absent is governed by the ⓓ pointer check above, not here
  if [ "$n" -gt 1 ]; then
    echo "  ❌ FAIL — marker carries MORE THAN ONE 'thirdparty:' line."
    grep -nE '^[[:space:]]*thirdparty:' "$m" | sed 's/^/       /'
    echo "     Readers take the first, so an appended correction is silently shadowed."
    return 1
  fi
  line=$(grep -m1 -E '^[[:space:]]*thirdparty:' "$m" | sed -E 's/^[[:space:]]*thirdparty:[[:space:]]*//')
  val=$(printf '%s' "$line" | sed -E 's/\(.*$//' | sed -E 's/[[:space:]]*(—|--).*$//' | sed -E 's/[[:space:]]+$//')
  # Cross-axis contamination — standpoint's tiers and crossfamily's tokens are different axes.
  case "$val" in
    tier1|tier1b|tier2|tier2b|tier3|panel|declined|DEGRADED_SINGLE_FAMILY|DEGRADED_PANEL_UNUSED|DEGRADED_NO_TARGET_ACCESS)
      echo "  ❌ FAIL — '$val' belongs to another axis, not thirdparty:."
      echo "     standpoint = whose repo was ground truth · crossfamily = which model family reviewed"
      echo "     thirdparty = was this claimed-new thing already done OUTSIDE this repo."
      return 1 ;;
  esac
  case "$val" in
    UNKNOWN)
      echo "  ⚠️  thirdparty leg: UNKNOWN — 안 봤다. Recorded, not silent." ;;
    peer-review|checked|none-found|not-applicable|DEGRADED_NO_ACCESS|DEGRADED_NOT_RUN)
      # The parenthetical may wrap across lines; only require that it OPENS with content after it.
      case "$line" in *\(*) ;; *)
        echo "  ❌ FAIL — '$val' without a (<grounds>) parenthetical."
        echo "     none-found without naming WHAT you searched is indistinguishable from not looking."
        return 1 ;;
      esac
      reason=$(printf '%s' "$line" | sed -E 's/^[^(]*\(//')
      if [ "${#reason}" -lt 12 ]; then
        echo "  ❌ FAIL — '$val(...)' grounds are vacuous. Name what was searched and what came back."
        return 1
      fi
      echo "  ✅ thirdparty leg: $val" ;;
    *)
      echo "  ❌ FAIL — '$val' is not a member of the thirdparty: enum."
      echo "     Closed enum: peer-review(<harness>/<persona> -> <target>, <what came back>) ·"
      echo "     checked(<searched/found>) · none-found(<searched>) ·"
      echo "     DEGRADED_NO_ACCESS(<why>) · DEGRADED_NOT_RUN(<why>) · UNKNOWN · not-applicable(<why>)"
      echo "     🟥 ⓓ3자대면 = third-party PRIOR ART, not 'I talked to another session'. The two"
      echo "     markers that made this lane necessary both made exactly that substitution."
      return 1 ;;
  esac
  return 0
}
# ── ① 원자 tenet 참조 무결성 (2026-08-30) ─────────────────────────────────────────
# tenet: FH-T02 (기계는 «기록의 속성»만 — 여기서는 참조 무결성) · FH-T01 (부재는 0이 아니다)
#
# WHY: 마커의 `①영혼` 은 자유 산문이라 `validate_soul_present_leg` 은 «비었나» 밖에 못 본다.
# 저자마다 다른 말로 같은 원칙을 적고, 두 마커가 같은 원칙을 근거로 삼았는지 기계가 못 안다.
# 원자 ID 를 인용하면 그 관계가 **참조 무결성**이 된다.
# 외부 근거: arXiv 2605.24229 — 스펙을 atomic testable tenets 로 분해.
#
# SCOPE — 기록의 속성이지 결론이 아니다. 「인용한 ID 가 레지스트리에 실재하는가」**만** 본다.
# 그 tenet 이 옳은지, 이 델타가 그것을 지켰는지는 **묻지 않는다**(CLAUDE.md §Mechanization
# Boundary — 오늘의 판단을 코드로 굳히면 내일의 천장이 된다).
#
# 🟥 GRACE 가 필요 없다. ID 를 **하나도 인용하지 않은 마커는 통과**하므로 기존 300+ 마커에
# 소급 효과가 0이다. 채택은 점진적이고, 그것이 이 설계가 새 필드를 안 만든 이유다.
SOUL_TENET_REGISTRY_REL=".claude/soul_tenets.txt"
validate_soul_tenet_refs() { # $1 = marker path
  # tenet: FH-T02 (기계는 «기록의 속성»만 — 여기서는 참조 무결성) · FH-T01 (부재는 0이 아니다)
  # 🟥 `rel` 은 **함수 안에서 기본값을 갖는다.** 레인 스위트가 `sed` 로 함수만 추출해 격리
  # 실행하므로, 밖에 있는 상수를 참조하면 `set -u` 아래서 unbound 로 죽는다 — 그러면 레인은
  # 그 죽음을 «BLOCK» 으로 읽고 초록을 보고한다(실측 2026-08-30, 이 레인 3개가 그랬다.
  # [[feedback_broken_parser_reports_a_verdict]]).
  local m="$1" reg ids id missing="" n=0
  local rel="${SOUL_TENET_REGISTRY_REL:-.claude/soul_tenets.txt}"
  reg="${EVIDENCE_ROOT:-$REPO_ROOT}/$rel"
  [ -f "$reg" ] || reg="$REPO_ROOT/$rel"
  # 🟥 인용은 **`tenets:` 줄에서만** 읽는다 — 마커 전문에서 긁으면 «설명»이 «인용»이 된다.
  #    실측 2026-08-30: 이 마커의 `controls:` 줄이 «known-negative FH-T99 가 잡히면
  #    HARNESS-ERROR» 라고 **계기를 설명**했는데 훅이 그걸 인용으로 읽고 차단했다.
  #    같은 얼굴이 오늘 세 번째다(픽스처 토큰 3회 · 이번이 4회째) —
  #    «토큰을 설명하는 것»과 «쓰는 것»은 grep 에게 구분되지 않는다.
  #    `soul_trace.sh` 는 아침에 등록부 교집합으로 같은 오탐을 닫았는데 **여기는 안 닫았다**
  #    ([[feedback_half_fix_propagation_boundary]] — 오늘 세 번째 반쪽).
  #    ⚠️ 필드로 좁히는 것이 교집합보다 낫다: 교집합은 «미등록 인용»을 조용히 버리는데,
  #    마커에서 그건 오타일 수 있고 오타는 잡아야 한다. 필드는 «어디가 인용인가»를 정한다.
  # 🟥 cross-family 독립 수렴(codex #4 · agy 2-a): `^tenets:` 는 **선행 공백 하나로 우회**된다.
  #    다른 마커 필드는 대부분 `^[[:space:]]*` 를 허용하는데 여기만 안 했다. 들여쓴 줄은
  #    «인용 없음»으로 읽혀 미등록 ID 가 조용히 통과했다(실측 rc=0).
  #    단수형 `tenet:` near-miss 도 같이 잡는다 — 필드명을 살짝 틀리면 검사가 통째로 빠진다.
  ids=$(sed -n -E 's/^[[:space:]]*#*[[:space:]]*[Tt][Ee][Nn][Ee][Tt][Ss]?[[:space:]]*:[[:space:]]*//p' "$m" 2>/dev/null \
        | grep -oE 'FH-T[0-9]{2}' | sort -u)
  [ -z "$ids" ] && return 0   # 인용 없음 = 합법. 채택은 점진적이다(thirdparty: 와 같은 형태)
  if [ ! -f "$reg" ]; then
    echo "  ❌ FAIL — 마커가 tenet ID 를 인용하는데 등록부가 없다: $rel"
    echo "     부재를 «통과»로 렌더하지 않는다 — 없는 등록부는 «전부 유효»가 아니다."
    return 1
  fi
  for id in $ids; do
    n=$((n+1))
    grep -qE "^${id}:" "$reg" || missing="${missing:+$missing }$id"
  done
  if [ -n "$missing" ]; then
    echo "  ❌ FAIL — 마커가 등록부에 없는 tenet 을 인용한다: $missing"
    echo "     실재하는 ID: $(grep -oE '^FH-T[0-9]{2}' "$reg" | sort -u | tr '\n' ' ')"
    echo "     새 원칙이면 먼저 $rel 에 등재하라 — 다만 **최대 7개**이므로"
    echo "     넣으려면 하나를 빼야 한다(Amazon tenets 규율: 목록이 길어지면 우선순위가 죽는다)."
    return 1
  fi
  echo "  ✅ soul tenets: $n 건 인용, 전부 등록부에 실재 ($(printf '%s' "$ids" | tr '\n' ' '))"
  return 0
}

# ── ② defeater: — «틀렸다면 무엇이 관측될 것인가» (2026-08-30) ────────────────────
# tenet: FH-T02 (기록의 속성) · FH-T06 (실행이 하중 지는 절반 — defeater 는 관측 가능해야 한다)
#
# WHY: `①영혼` 은 성공 정의를 적게 하지만 **반증 조건**을 안 적게 한다. 반증 조건이 없는 성공
# 정의는 사후에 언제나 충족된 것으로 회상된다(저자는 늘 «고른 것»이라고 회상한다).
# 외부 근거: Assurance 2.0 (arXiv 2004.10474) — *"explicit identification of defeaters and
# counterevidence"*. 보증 논증의 신뢰도는 «무엇이 이 논증을 무너뜨리는가»를 명시하는 데서 온다.
#
# SCOPE — **비공허성만**. 「그 defeater 가 좋은가」·「실제로 관측했는가」는 안 묻는다.
# 형태는 `validate_crossfamily_leg` 의 grounds 검사와 **같다**(길이 + 기록의 속성). 새 클래스가
# 아니다. 다만 어휘 grep 은 쓰지 않는다 — Grep-Collision Treadmill 이 되기 때문이다.
#
# 🟥 소급 적용 금지. `SOUL_PRESENT_GRACE_DATE` 와 **같은 패턴**으로, GRACE 이전 날짜의 마커는
# 면제된다. 소급 강제는 진행 중인 모든 브랜치를 막고, 그 과차단이 `--no-verify` 를 훈련시켜
# 같은 훅 안의 Destructive-Op 게이트까지 무장해제한다.
DEFEATER_GRACE_DATE="2026-09-01"
validate_defeater_leg() { # $1 = marker path
  # tenet: FH-T02 (기록의 속성) · FH-T06 (실행이 하중 지는 절반 — defeater 는 관측 가능해야 한다)
  local m="$1" mdate n line body words soul_line
  n=$(grep -cE '^[[:space:]]*defeater:' "$m" 2>/dev/null); n=${n:-0}
  if [ "$n" -gt 1 ]; then
    echo "  ❌ FAIL — 마커에 'defeater:' 줄이 둘 이상이다."
    grep -nE '^[[:space:]]*defeater:' "$m" | sed 's/^/       /'
    echo "     읽는 쪽은 첫 줄을 취하므로 나중에 붙인 정정이 조용히 가려진다."
    return 1
  fi
  mdate=$(printf '%s' "$m" | sed -nE 's/.*_([0-9]{4}-[0-9]{2}-[0-9]{2})\.marker$/\1/p')
  # 🟥 near-miss 검사를 `n==0` 밖으로 꺼낸다 (cross-family codex #5, 2026-08-30 — 자력 적발 0).
  #    종전에는 «정확한 defeater: 줄이 하나라도 있으면» near-miss 를 건너뛰었다. 그래서
  #      defeater: 없음
  #      defeaters: 진짜로 하고 싶은 말은 여기 적혀 있다
  #    가 통과했다 — 저자는 두 번째 줄을 썼다고 믿는데 게이트는 첫 줄만 읽는다.
  #    오타는 «부재»가 아니라 **오타**이고, 그 판정은 정확한 줄의 존재와 무관하다.
  if grep -qE '^[[:space:]]*(defeaters:|defeator:|defeter:|defeater[[:space:]]+:|counter-evidence:|반증:)' "$m" 2>/dev/null; then
    echo "  ❌ FAIL — defeater 키처럼 보이지만 정확히 'defeater:' 가 아닌 줄이 있다."
    grep -nE '^[[:space:]]*(defeaters:|defeator:|defeter:|defeater[[:space:]]+:|counter-evidence:|반증:)' "$m" | sed 's/^/       /'
    echo "     저자는 적었다고 믿는데 게이트는 못 읽는다 — 가장 조용한 실패다."
    return 1
  fi
  if [ "$n" -eq 0 ]; then
    # 🟥 날짜를 못 읽으면 면제하지 않는다. 이 훅이 파일명을 `..._${TODAY}.marker` 로 만들므로
    # 날짜 없는 이름은 «오래된 것»이 아니라 «예상 밖»이다([[feedback_not_found_is_not_zero_family]]).
    [ -z "$mdate" ] || { [ "$mdate" \< "$DEFEATER_GRACE_DATE" ] && return 0; }
    echo "  ❌ FAIL — 마커에 'defeater:' 줄이 없다."
    echo "     성공 정의를 적었으면 **그것이 틀렸을 때 무엇이 관측되는지**도 적어라."
    echo "     반증 조건 없는 성공 정의는 사후에 언제나 충족된 것으로 회상된다."
    echo "       defeater: 소비자 경로 러너가 rc=0 인데 컨트롤 팔도 rc=0 이면 이 레인은 아무것도 안 가른 것이다"
    echo "       defeater: 없음   ← 선언된 부재도 값이다(기록되고, 침묵은 아니다)"
    echo "     Append to: $m"
    return 1
  fi
  line=$(grep -m1 -E '^[[:space:]]*defeater:' "$m" | sed -E 's/^[[:space:]]*defeater:[[:space:]]*//')
  body=$(printf '%s' "$line" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')
  # 선언된 부재는 값이다 — `①영혼: 없음` 과 같은 형태. 정직한 형태를 벌하고 분량으로 채운
  # 「안 썼다」 문단을 통과시키는 것이 [[feedback_unreachable_done_when_trains_evasion]] 이다.
  # 🟥 **`tr -d '[:space:].·—-]'` 를 걷어냈다 (2026-08-30, CI 가 잡았다 — 로컬은 초록이었다).**
  #    그 브래킷 표현은 이식 불가다: `—-]` 를 **범위**로 읽는 구현이 있고(GNU tr), 그러면 한글
  #    바이트가 삭제돼 `case` 가 안 걸린다 → `defeater: 없음` 이 «1 낱말 공허» 로 차단된다.
  #    macOS(BSD/ugrep 환경)에서는 `없음` 이 그대로 남아 통과했고, **그래서 로컬만 초록이었다.**
  #    ⇒ `tr` 대신 셸 파라미터 확장으로 «양끝 구두점/공백»만 벗긴다. 클래스 파싱에 안 기댄다.
  _dv="$body"
  while :; do
    case "$_dv" in
      ' '*|'	'*) _dv="${_dv# }"; _dv="${_dv#	}" ;;
      *' '|*'	') _dv="${_dv% }"; _dv="${_dv%	}" ;;
      *'.'|*'·'|*'—'|*'-'|*']') _dv="${_dv%?}" ;;
      *) break ;;
    esac
  done
  case "$_dv" in
    없음|none|None|NONE|n/a|N/A|해당없음)
      echo "  ⚠️  defeater: 없음 — 선언된 부재. 기록되었고, 침묵은 아니다."
      return 0 ;;
  esac
  # 비공허성 — 한글 코퍼스이므로 **낱말 수**로 센다. `${#}` 는 로케일 의존이고, 긴 한글 한 덩어리가
  # 문자 수 검사를 그냥 통과한 전례가 있다(A6 레인).
  words=$(printf '%s' "$body" | wc -w | tr -d ' '); words=${words:-0}
  if [ "$words" -lt 6 ]; then
    echo "  ❌ FAIL — defeater 가 공허하다 ($words 낱말). **무엇이 관측될 것인가**를 적어라."
    echo "     '틀렸을 수 있다' 는 반증 조건이 아니다 — 관측 가능한 사건이어야 한다."
    return 1
  fi
  # 🟥 기록의 속성 하나 더: defeater 가 ①영혼 줄의 **복붙**이면 두 필드가 한 필드다.
  # (이것은 «내용이 옳은가»가 아니라 «두 칸이 서로 다른 값을 담는가»다 — 채널 검사.)
  soul_line=$(grep -m1 -E '^[[:space:]]*#*[[:space:]]*(①영혼|soul)[[:space:]]*[:—-]' "$m" 2>/dev/null \
              | sed -E 's/^[^:—-]*[:—-][[:space:]]*//; s/[[:space:]]+$//')
  if [ -n "$soul_line" ] && [ "$body" = "$soul_line" ]; then
    echo "  ❌ FAIL — defeater 가 ①영혼 줄과 글자 그대로 같다. 두 칸이 한 칸이 되었다."
    echo "     성공 정의와 그 반증 조건은 다른 문장이다."
    return 1
  fi
  # 🟥 헬퍼 부재를 «잔여 없음»으로 렌더하지 않는다. 레인 스위트가 `sed` 로 이 함수만 추출하면
  #    헬퍼가 안 따라오고, 정의되지 않은 명령은 거짓이 되어 **fail-open** 이 된다.
  #    실측 2026-08-30: 그렇게 만든 레인 R1 이 BLOCK 기대에 PASS 를 냈다.
  if ! declare -f _marker_template_residue >/dev/null 2>&1; then
    echo "  ❌ HARNESS-ERROR — _marker_template_residue 가 정의돼 있지 않다."
    echo "     격리 실행이면 그 헬퍼도 같이 추출해야 한다. 부재는 «통과»가 아니다."
    return 1
  fi
  if _marker_template_residue "$body"; then
    echo "  ❌ FAIL — defeater 가 힌트의 자리표시자 그대로다 (기록이 아직 안 쓰였다)."
    echo "     «틀렸다면 무엇이 관측될 것인가»를 실제 사건으로 적어라."
    return 1
  fi
  echo "  ✅ defeater 기록됨 ($words 낱말)"
  return 0
}


# ── ⑤ affected: — 「이 변경이 건드리는 것 + 열린 질문」 (2026-09-04, frontier absorption) ──
# tenet: FH-T02 (기록의 속성) · FH-T07 (행위자가 읽는 자리)
#
# WHY: Anthropic AI-Native SDLC playbook `intent.md` 는 «Affected users and systems» ·
# «Open questions» 두 칸을 싣는다. FH 는 두 칸으로 쪼개지 않는다 — 별도 필드마다 빈 칸이 하나씩
# 늘어나는 것이 이 절 전체(soul-check·tenets·thirdparty)가 이미 피하는 모양이다. 한 줄 자유
# 산문 안에 「건드리는 것」과 「열린 질문 = …」 관례를 같이 담는다.
#
# SCOPE — **채널 검사만**. `defeater:` 와 같은 형태(단일 줄·중복 차단·비공허)이지만 한 가지
# 다르다: `defeater:` 는 「없음」을 정직한 선언으로 인정한다(반증 조건이 실제로 없을 수 있다).
# `affected:` 는 다르다 — 어떤 변경이든 반드시 무언가를 건드린다(제로 영향은 명제상 없다). 그래서
# 「없음/TBD/-」류 자리표시자만 있는 값은 정직한 부재가 아니라 **안 채운 것**이고, 차단한다.
#
# 옵셔널 필드다 — **없으면 통과**(soul-check:/tenets: 와 같은 패턴. 채택은 점진적이다).
validate_affected_leg() { # $1 = marker path
  local m="$1" n line body words
  n=$(grep -cE '^[[:space:]]*affected:' "$m" 2>/dev/null); n=${n:-0}
  if [ "$n" -eq 0 ]; then
    # near-miss: 키 오타는 부재가 아니라 오타다 (defeater: 와 같은 이유 — 저자는 적었다고
    # 믿는데 게이트는 못 읽는 것이 가장 조용한 실패다).
    if grep -qE '^[[:space:]]*(affects:|affected[[:space:]]+:|affeted:|affcted:|effected:)' "$m" 2>/dev/null; then
      echo "  ❌ FAIL — affected 키처럼 보이지만 정확히 'affected:' 가 아닌 줄이 있다."
      grep -nE '^[[:space:]]*(affects:|affected[[:space:]]+:|affeted:|affcted:|effected:)' "$m" | sed 's/^/       /'
      echo "     읽는 쪽은 정확히 'affected:' 만 읽는다."
      return 1
    fi
    return 0   # 진짜 부재 — 옵셔널 필드, 채택은 점진적이다(soul-check:/tenets: 와 같은 형태)
  fi
  if [ "$n" -gt 1 ]; then
    echo "  ❌ FAIL — 마커에 'affected:' 줄이 둘 이상이다."
    grep -nE '^[[:space:]]*affected:' "$m" | sed 's/^/       /'
    echo "     읽는 쪽은 첫 줄을 취하므로 나중에 붙인 정정이 조용히 가려진다."
    return 1
  fi
  line=$(grep -m1 -E '^[[:space:]]*affected:' "$m" | sed -E 's/^[[:space:]]*affected:[[:space:]]*//')
  body=$(printf '%s' "$line" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')
  # 양끝 구두점/공백만 벗긴다 — 클래스 파싱(`tr -d '[:punct:]'` 류)은 한글 바이트를 지우는
  # 이식성 결함을 낸 전례가 있다(defeater 의 2026-08-30 CI 리트로). 파라미터 확장만 쓴다.
  _av="$body"
  while :; do
    case "$_av" in
      ' '*|'	'*) _av="${_av# }"; _av="${_av#	}" ;;
      *' '|*'	') _av="${_av% }"; _av="${_av%	}" ;;
      *'.'|*'·'|*'—'|*'-'|*']') _av="${_av%?}" ;;
      *) break ;;
    esac
  done
  case "$(printf '%s' "$_av" | tr 'A-Z' 'a-z')" in
    없음|none|n/a|na|tbd|todo|-|해당없음|'')
      echo "  ❌ FAIL — affected 가 자리표시자뿐이다 ('$body')."
      echo "     이 변경이 건드리는 것(사람·하네스·표면)과 아직 열린 질문을 한 줄로 적어라."
      echo "       affected: 소비자 install 의 pre-commit 사용자(마커 형식) · 열린 질문 = 필드 강제 시점"
      echo "     Append to: $m"
      return 1 ;;
  esac
  # 🟥 헬퍼 부재를 «잔여 없음»으로 렌더하지 않는다 — 레인 스위트가 이 함수만 추출하면 헬퍼가
  # 안 따라오고, 정의되지 않은 명령은 거짓이 되어 fail-open 이 된다(defeater 가 이미 겪었다).
  if ! declare -f _marker_template_residue >/dev/null 2>&1; then
    echo "  ❌ HARNESS-ERROR — _marker_template_residue 가 정의돼 있지 않다."
    echo "     격리 실행이면 그 헬퍼도 같이 추출해야 한다. 부재는 «통과»가 아니다."
    return 1
  fi
  if _marker_template_residue "$body"; then
    echo "  ❌ FAIL — affected 가 힌트의 자리표시자 그대로다 (기록이 아직 안 쓰였다)."
    return 1
  fi
  words=$(printf '%s' "$body" | wc -w | tr -d ' '); words=${words:-0}
  if [ "$words" -lt 3 ]; then
    echo "  ❌ FAIL — affected 가 공허하다 ($words 낱말). 건드리는 것 · 열린 질문을 적어라."
    return 1
  fi
  echo "  ✅ affected 기록됨 ($words 낱말)"
  return 0
}


# ── ⑥ oracle: — 「기대값을 무엇으로 정했나」 오라클 유형 (2026-09-05, ISO/IEC TR 29119-11 정렬 · crosswalk §4 M1) ──
# tenet: FH-T02 (기록의 속성) · FH-T07 (행위자가 읽는 자리)
#
# WHY: TR 29119-11 의 핵심이 «테스트 오라클 문제»(기대값을 정할 수 없을 때 무엇으로 판정하나)다.
# FH 는 known-pair · 결과 전 채점기 · «미측정 ≠ 0» 으로 그 문제를 다루지만, 마커는 «컨트롤이
# 살아 있다» 까지만 적고 **어떤 종류의 오라클**로 판정했는지는 안 적었다 — 외부 독자가 첫 번째로
# 묻는 칸이 비어 있었다(knowledge/shared/harness-core/iso_ai_standards_crosswalk.md §4 M1,
# 운영자 승인 2026-09-05 «오라클 채널을 넣는 게 좋아 보인다면 마다할 이유가 없지»).
#
# SCOPE — **채널 검사만** (§Mechanization Boundary): 닫힌 enum(6) · 비공허 근거 · 단일 줄 · 근사키
# 차단. 값의 진위(그 오라클이 정말 그 종류였나)는 안 본다. `none` 은 합법이되 **사유가 필수**다 —
# «오라클 없이 판정했다» 는 기록이고, 사유 없는 none 은 «안 봤다» 와 구분이 안 된다.
#
# enum (형식: `oracle: <kind> — <근거>` · kind 뒤는 공백 · — · : · · · ( · . 중 하나, 또는 줄 끝)
#   known-pair    양성·음성 컨트롤이 같은 실행에 있다 (계기 보정)
#   metamorphic   입력 변환 → 기대 출력 변화 «관계»로 판정 (기대값 없이)
#   back-to-back  다른 구현/계열이 같은 입력에 낸 출력을 대조 (cross-family 가 같은 diff 를 읽는 것)
#   a-b           한 변수 ARM/CTRL 비교 (reps≥3). `A/B` 는 허용 표기
#   human         사람이 기대값을 판정 (운영자 눈검증 · HITL)
#   none          오라클 없음 — 사유 필수 (문서만 · 측정 없음 …)
#
# 근사키 규칙(한 문장, 여섯 문서면이 같은 문장을 쓴다): **키가 `oracle` 로 시작하는데 정확히 `oracle:`
# 이 아닌 줄 전부**(`Oracle:` `oracles:` `oracle :` `oracle　:` `oracle_type:` `oracle-type:` 키만 있는
# `oracle`) **+ 오타 `orcale:` `oralce:` `오라클:`** — 정상 `oracle:` 줄이 있어도 같이 있으면 차단한다
# (cross-family codex #2: 정상 줄 뒤의 `Oracle: …` 정정이 조용히 가려지는 형태).
#
# 옵셔널 필드다 — **없으면 통과**(affected:/soul-check:/tenets: 와 같은 패턴. 채택은 점진적이다).
# 힌트 템플릿에는 넣지 않는다 — 옵셔널 필드마다 빈 칸이 하나씩 느는 모양을 이 절이 피한다.
# 소비처(정직하게): 오늘은 마커 grep 감사와 표준 정렬 증거뿐, 기계가 읽는 곳은 없다.
# 명명된 잔여: 근거에 힌트 자리표시자 리터럴(`<...>`)이 있으면 차단된다(affected/defeater 와 같은 경계 —
# 다른 표기로 적어라) · 전각 공백으로 «시작하는» 줄은 [[:space:]] 밖이라 못 본다.
validate_oracle_leg() { # $1 = marker path
  local m="$1" n nm line body kind k rest words
  # near-miss — n 과 무관하게 항상 본다. `-n` 접두(N:) 뒤에서 정확한 소문자 `oracle:` 줄만 제외한다.
  nm=$(grep -niE '^[[:space:]]*(oracle:|oracle([^:]|$)|orcale:|oralce:|오라클:)' "$m" 2>/dev/null | grep -vE '^[0-9]+:[[:space:]]*oracle:')
  if [ -n "$nm" ]; then
    echo "  ❌ FAIL — oracle 키처럼 보이지만 정확히 'oracle:' 가 아닌 줄이 있다."
    printf '%s\n' "$nm" | sed 's/^/       /'
    echo "     읽는 쪽은 정확히 'oracle:'(소문자, 콜론 바로 뒤) 만 읽는다 — 저자는 적었다고 믿는데 게이트는 못 읽는 것이 가장 조용한 실패다."
    return 1
  fi
  n=$(grep -cE '^[[:space:]]*oracle:' "$m" 2>/dev/null); n=${n:-0}
  if [ "$n" -eq 0 ]; then
    return 0   # 진짜 부재 — 옵셔널 필드, 채택은 점진적이다
  fi
  if [ "$n" -gt 1 ]; then
    echo "  ❌ FAIL — 마커에 'oracle:' 줄이 둘 이상이다."
    grep -nE '^[[:space:]]*oracle:' "$m" | sed 's/^/       /'
    echo "     읽는 쪽은 첫 줄을 취하므로 나중에 붙인 정정이 조용히 가려진다."
    return 1
  fi
  line=$(grep -m1 -E '^[[:space:]]*oracle:' "$m" | sed -E 's/^[[:space:]]*oracle:[[:space:]]*//')
  body=$(printf '%s' "$line" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')
  if [ -z "$body" ]; then
    echo "  ❌ FAIL — oracle 값이 비어 있다."
    echo "     형식: oracle: <kind> — <근거>   kind ∈ known-pair · metamorphic · back-to-back · a-b · human · none"
    return 1
  fi
  # kind = 앞쪽 [A-Za-z/-] 연속 — 하이픈은 kind 안에 있다(known-pair · back-to-back · a-b).
  # 매치가 없으면 sed 가 body 를 그대로 돌려주고 enum 검사가 잡는다.
  kind=$(printf '%s' "$body" | sed -E 's/^([A-Za-z/-]+).*$/\1/')
  case "$kind" in "$body") rest="" ;; *) rest="${body#"$kind"}" ;; esac
  # kind 바로 뒤는 구분자이거나 줄 끝이어야 한다 — enum 멤버가 «접두어»로만 맞는 값(known-pair2 ·
  # human_review)이 통과하던 구멍(cross-family codex #1·#3).
  case "$rest" in
    ''|' '*|'	'*|'—'*|':'*|'·'*|'('*|'.'*) ;;
    *)
      echo "  ❌ FAIL — oracle kind 뒤에 구분자 없이 다른 문자가 붙어 있다 ('$body')."
      echo "     형식: oracle: <kind> — <근거>   kind 뒤는 공백 · — · : · · · ( 중 하나 (예: known-pair2 · human_review 는 enum 밖)"
      return 1 ;;
  esac
  k=$(printf '%s' "$kind" | tr 'A-Z' 'a-z'); [ "$k" = "a/b" ] && k="a-b"
  case "$k" in
    known-pair|metamorphic|back-to-back|a-b|human|none) ;;
    *)
      echo "  ❌ FAIL — oracle kind '$kind' 는 enum 밖이다."
      echo "     kind ∈ known-pair · metamorphic · back-to-back · a-b · human · none   (형식: oracle: <kind> — <근거>)"
      return 1 ;;
  esac
  # 근거 = kind 뒤. 양끝 구분자·공백만 벗긴다 — 리터럴 접두/접미 제거만 쓴다(`${x#?}` 는 C 로케일에서
  # 다바이트 구분자의 한 바이트만 깎고, 클래스 파싱은 한글 바이트를 지운 전례가 있다 — affected 참조).
  while :; do
    case "$rest" in
      ' '*) rest="${rest# }" ;;   '	'*) rest="${rest#	}" ;;
      '—'*) rest="${rest#—}" ;;   '-'*) rest="${rest#-}" ;;   ':'*) rest="${rest#:}" ;;
      '·'*) rest="${rest#·}" ;;   '('*) rest="${rest#(}" ;;   '.'*) rest="${rest#.}" ;;
      *' ') rest="${rest% }" ;;   *'	') rest="${rest%	}" ;;
      *')') rest="${rest%)}" ;;   *'.') rest="${rest%.}" ;;
      *) break ;;
    esac
  done
  case "$(printf '%s' "$rest" | tr 'A-Z' 'a-z')" in
    없음|none|n/a|na|tbd|todo|-|해당없음|'')
      if [ "$k" = none ]; then
        echo "  ❌ FAIL — oracle: none 은 사유가 필수다 (지금: '$body')."
        echo "     예: oracle: none — 문서만 변경, 측정 없음"
      else
        echo "  ❌ FAIL — oracle 근거가 자리표시자뿐이다 ('$body')."
        echo "     예: oracle: known-pair — 레인 PASS/BLOCK 픽스처 양쪽 + 되돌림"
      fi
      echo "     Append to: $m"
      return 1 ;;
  esac
  # 🟥 헬퍼 부재를 «잔여 없음»으로 렌더하지 않는다 — 격리 추출이 헬퍼를 안 가져오면 정의되지 않은
  # 명령은 거짓이 되어 fail-open 이 된다(defeater·affected 가 이미 겪었다). 레인 o31 이 이 가지를 잰다.
  if ! declare -f _marker_template_residue >/dev/null 2>&1; then
    echo "  ❌ HARNESS-ERROR — _marker_template_residue 가 정의돼 있지 않다."
    echo "     격리 실행이면 그 헬퍼도 같이 추출해야 한다. 부재는 «통과»가 아니다."
    return 1
  fi
  if _marker_template_residue "$rest"; then
    echo "  ❌ FAIL — oracle 근거가 힌트의 자리표시자 그대로다 (기록이 아직 안 쓰였다)."
    return 1
  fi
  words=$(printf '%s' "$rest" | wc -w | tr -d ' '); words=${words:-0}
  if [ "$words" -lt 2 ]; then
    echo "  ❌ FAIL — oracle 근거가 공허하다 ($words 낱말). 무엇을 무엇과 대조했는지 적어라."
    return 1
  fi
  echo "  ✅ oracle 기록됨 ($k · 근거 $words 낱말)"
  return 0
}


SOUL_PRESENT_GRACE_DATE="2026-08-21"
validate_soul_present_leg() { # $1 = marker path
  # ── ①영혼 존재 검사 (2026-08-21) ──────────────────────────────────────────────
  # CLAUDE.md §자기 대조 has mandated an ①영혼 line (성공 정의 / 절대 안 함) on every marker
  # since 2026-08-09. MEASURED on the real corpus this day, with the instrument calibrated over
  # EIGHT spellings and two controls (`axes-run` and `axis2-engine` = 98/98): of 98 post-grace
  # markers, 61 carry it and **37 (37.8%) carry no form of it at all**.
  #
  # 🟥 The first pass of that same measurement grepped only the exact `①영혼` string and returned
  # "92.9% missing". That would have been a false alarm ([[feedback_measured_scope_vs_claimed_scope]]
  # — hand-enumerating spellings). The union + hand-verification of one negative sample is what
  # makes the 37 real: `.axes_23_passed_feat_target-freeze-gate_2026-08-18.marker` was opened by
  # eye — a panel-reviewed, 28-lane, controls-alive marker with every OTHER field filled, and no
  # soul line in any spelling.
  #
  # 🟥 WHY THIS IS NOT THE THING THE ①영혼 COMMENT BELOW DECLINES. That comment refuses to check
  # «was it written BEFORE designing» — unverifiable from a file, correctly excluded, and this
  # lane does not check it. PRESENCE is a different question and is trivially decidable. The
  # impossible question carried the possible one out on its ticket.
  #
  # SCOPE — channel, not judgment. Asserts the field EXISTS and is not vacuous. Says nothing about
  # whether the success definition is good, was honoured, or was written in advance.
  local m="$1" n body words
  # Grace: only markers dated on/after the grace date are required to carry it. Retroactive
  # enforcement would block every in-flight branch, and that over-block trains `--no-verify`,
  # which would disarm the Destructive-Op gate living in this same hook.
  local mdate
  mdate=$(printf '%s' "$m" | sed -nE 's/.*_([0-9]{4}-[0-9]{2}-[0-9]{2})\.marker$/\1/p')
  # 🟥 An unparseable date used to `return 0` — fail-OPEN. The hook itself builds this filename
  # as `..._${TODAY}.marker`, so a name without a date is unexpected, not old. Unknown is not
  # exempt ([[feedback_not_found_is_not_zero_family]]); it falls through to the presence check.
  [ -z "$mdate" ] || { [ "$mdate" \< "$SOUL_PRESENT_GRACE_DATE" ] && return 0; }
  # 🟥 agy (claims axis): the comment above says the MEASUREMENT swept eight spellings; this
  # regex carries FOUR alternatives (①영혼 · a `soul:` key · 성공[ ]?정의 · 절대 안 함). Both are
  # true and they are different instruments — but writing only the first invites reading the
  # second's coverage off it ([[feedback_rule_misdescribes_its_own_machine]]). The four are the
  # ones the corpus actually uses as a FIELD; the other four measured spellings appeared only
  # inside prose, which this leg deliberately excludes.
  # 🟥 The soul detector must NOT read the `soul-check:` line itself. codex's alignment fix widened
  # the pattern and G8 flipped to PASS: `soul-check: reflected(…성공정의…)` matched as its own
  # evidence — self-referential FP ([[feedback_lane_runner_self_referential_fp]]). The claim can
  # never be its own ground. Same reason comment/prose lines are excluded: a marker that merely
  # SAYS 성공 정의 in narration has not declared one.
  # 🟥 A DECLARATION, not a MENTION. First real use (isolated clone, 2026-08-21) broke this on
  # attempt #1: this very delta's marker says «change: 마커의 ①영혼 슬롯에 소비처가 0이던 것을…»
  # in a prose FIELD VALUE, and the leg reported "①영혼 present: 10 words" for a marker that
  # declares nothing. Excluding `#` and `soul-check:` was not enough — any field whose value
  # discusses the axis satisfies a substring test, and markers ABOUT this axis always will.
  # 39 lanes, codex (diff axis) and agy (claims axis) all missed it; running it once did not
  # ([[feedback_adversarial_review_not_substitute_for_first_use]]).
  # So the token must sit at LINE START as the key (a leading markdown `#` is allowed — the real
  # corpus writes «## ①영혼 — 설계 전에 쓴 …» as a heading, and P7 flipped to BLOCK when the first
  # version of this regex forbade it. The known-pair caught that in one run).
  # Prose inside a field VALUE can no longer vote.
  _soul_src=$(grep -E '^[[:space:]]*#*[[:space:]]*(①영혼|soul|절대 안 함|성공 ?정의)[[:space:]]*[:—-]' "$m" 2>/dev/null \
              | grep -vE '^[[:space:]]*soul-check[[:space:]]*:')
  n=$(printf '%s\n' "$_soul_src" | grep -cE '①영혼|^[[:space:]]*soul:|성공 ?정의|절대 안 함'); n=${n:-0}
  if [ "$n" -eq 0 ]; then
    echo "  ❌ FAIL — marker carries no ①영혼 line (성공 정의 / 절대 안 함)."
    echo "     CLAUDE.md §자기 대조 requires it on every marker. Measured 2026-08-21: 37 of 98"
    echo "     post-grace markers had none — including panel-reviewed ones with every other"
    echo "     field filled. Nothing read the field, so nothing noticed."
    echo "     Write, before the design: 성공 정의 = «…». 절대 안 함 = «…»."
    echo "     🟥 This checks PRESENCE, not provenance. «Was it written first» stays unverifiable"
    echo "     and unchecked — see the ①영혼 comment below. Presence is not that question."
    return 1
  fi
  # Non-vacuity — counted in WORDS, not characters (Korean corpus; ${#} is locale-dependent).
  body=$(awk '/①영혼|^[[:space:]]*soul:|성공 ?정의|절대 안 함/{if (!f) f=1}
              f && /^[[:space:]]*$/{exit}
              f && seen && /^[[:space:]]*[A-Za-z][A-Za-z0-9_-]*:/{exit}
              f{print; seen=1}' <<< "$_soul_src")
  words=$(printf '%s' "$body" | wc -w | tr -d ' '); words=${words:-0}
  # 🟥 A DECLARED ABSENCE IS A VALUE, not a gap. CLAUDE.md §자기 대조 explicitly permits
  # «없으면 `없음`», and the first version of this leg BLOCKED a bare `soul: 없음` (2 words) while
  # PASSING a 33-word paragraph whose content was «I did not write one». That punishes the honest
  # form and rewards padding — [[feedback_unreachable_done_when_trains_evasion]] exactly. Found by
  # a peer session's first real use of this gate (forge-harness-59, 2026-08-21); self-detection 0,
  # and 41 lanes + two cross-family panels had all passed.
  # Same shape as the sibling axes' `UNKNOWN`: recorded, not silent, and never free of a record.
  # 🟥 `$body` — this leg's own local. The first version read `$soul_body`, which is the OTHER
  # leg's variable name; unset here, so the case never matched and the fix was inert while the
  # lane suite stayed green ([[feedback_unedited_parts_of_my_own_file]] · half-fix).
  _sbare=$(printf '%s' "$body" | sed -E 's/^[[:space:]]*#*[[:space:]]*(①영혼|soul|절대 안 함|성공 ?정의)[[:space:]]*[:—-]*//' | tr -d '[:space:].·]')
  case "$_sbare" in
    없음|없음—|none|None|NONE|n/a|N/A|-|해당없음)
      echo "  ⚠️  ①영혼: 없음 — declared absent. Recorded, not silent."
      echo "     🟥 A marker that declares no success definition cannot carry"
      echo "     soul-check: reflected(...) — there is nothing to reflect against."
      return 0 ;;
  esac
  if [ "$words" -lt 6 ]; then
    echo "  ❌ FAIL — ①영혼 line is vacuous ($words word(s)). Name the success definition AND"
    echo "     the 절대 안 함. A one-word placeholder trains the ritual this field exists to avoid."
    return 1
  fi
  # 🟥 헬퍼 부재를 «잔여 없음»으로 렌더하지 않는다. 레인 스위트가 `sed` 로 이 함수만 추출하면
  #    헬퍼가 안 따라오고, 정의되지 않은 명령은 거짓이 되어 **fail-open** 이 된다.
  #    실측 2026-08-30: 그렇게 만든 레인 R1 이 BLOCK 기대에 PASS 를 냈다.
  if ! declare -f _marker_template_residue >/dev/null 2>&1; then
    echo "  ❌ HARNESS-ERROR — _marker_template_residue 가 정의돼 있지 않다."
    echo "     격리 실행이면 그 헬퍼도 같이 추출해야 한다. 부재는 «통과»가 아니다."
    return 1
  fi
  if _marker_template_residue "$body"; then
    echo "  ❌ FAIL — soul: 줄이 힌트의 자리표시자 그대로다 (기록이 아직 안 쓰였다)."
    echo "     꺾쇠 안을 실제 내용으로 바꿔라. 이 검사는 «값이 옳은가»가 아니라"
    echo "     «이 기록이 템플릿 자신인가»만 본다 — 채널 검사다."
    return 1
  fi
  # ── ①영혼 2칸 존재 검사 (advisory only, 2026-09-04 — six_axis_review 판정안 4) ─────
  # «성공 정의»와 «절대 안 함» 두 낱말(변형 포함)이 둘 다 있는지만 본다. Mechanization
  # Boundary: 존재만 — 두 칸이 다 있어도 내용이 옳다는 보장은 없고, 하나가 없어도 커밋을
  # 막지 않는다(위 non-vacuity 검사가 이미 최소 6낱말을 요구·차단한다 — 이건 그 위에 얹는
  # 별도 관측이지 새 차단이 아니다). 「없음」 선언 분기는 위에서 이미 return 했으므로 여기
  # 도달하지 않는다.
  _soul_missing=""
  printf '%s' "$body" | grep -qE '성공 ?정의' || _soul_missing="성공 정의"
  printf '%s' "$body" | grep -qE '절대 ?안 ?함' || _soul_missing="${_soul_missing:+$_soul_missing · }절대 안 함"
  if [ -n "$_soul_missing" ]; then
    echo "  ⚠️  ①영혼 2칸 미달 — 빠진 낱말: $_soul_missing (advisory, 커밋은 안 막는다)"
  fi
  echo "  ✅ ①영혼 present: $words word(s)"
  return 0
}

validate_soul_check_leg() { # $1 = marker path
  # ── ⓒ 격리 그라운딩 — 사전 선언으로 확장 (2026-08-21) ─────────────────────────
  # 🟥 NOT a seventh axis. Classified against `fh_three_layer_canon.md §1-a-2`, whose
  # discriminator is explicit: «축은 «얼마나 적대적인가»로 갈리지 않는다. «무엇을 받았는가»로
  # 갈린다.» ⓒ 격리 그라운딩 receives «저자가 쓴 문장 + 지금의 트리» — which is, character for
  # character, what this leg receives (①영혼 is 저자가 쓴 문장; the delta is 지금의 트리).
  # The tense differs (사전 선언 vs 사후 주장) and tense is a posture, not an axis, exactly as
  # that section says adversarialness is («적대성은 자세지 축이 아니다»).
  # Precedent, in the canon itself: the author attributed 5 findings to ⓓ and two independent
  # cross-family classifiers reclassified 3 of them — «그 셋은 축이 필요했던 게 아니라 다른 축이
  # 놓친 것이다». Minting ⓖ would have been that mistake a second time.
  # ⇒ ⓒ's 「받는 것」 widens to «저자가 쓴 문장(사후 주장 및 **사전 선언**)», and `soul-check:`
  # is ⓒ's own field in the same shape `standpoint:` is ⓑ's. The six axes stay six.
  # WHY: CLAUDE.md §자기 대조 has required an `①영혼` line (성공 정의 / 절대 안 함) on every
  # marker since 2026-08-09, and it is written 4/4 on the real corpus. NOTHING has ever read it
  # back. Measured this day with a control: `crossfamily` = 21 hits in this hook, `①영혼`/`soul`
  # = 0 lines of consuming code anywhere in templates/ or scripts/; `fh_4axis_gate.md` does not
  # even name the field. That is the half-externalization shape — a slot with zero consumers,
  # which always reports "done" because presence is doing the judging.
  #
  # 🟥 The reason this hook gave for NOT checking the field answers a DIFFERENT question. Line
  # ~1058 declines it because «did you write it BEFORE designing» is unverifiable from a file —
  # true, and correctly excluded. But «was it then SATISFIED» is a separate question that was
  # never asked, and it rode out on the first one's ticket.
  #
  # SCOPE — channel, not judgment (CLAUDE.md §Mechanization Boundary). This lane asserts
  # properties of the RECORD: single · closed enum · non-vacuous · not wearing another axis's
  # tokens · **and consistent with the ①영혼 line in the same file**. It never asserts the
  # read-back reached the right conclusion. A fabricated `reflected(...)` with a fluent reason
  # PASSES — that hole is §4-b's (cross-family reads the marker), not this lane's, and it is
  # named here rather than papered over.
  local m="$1" line val reason n soul_n soul_body
  n=$(grep -cE '^[[:space:]]*soul-check:' "$m" 2>/dev/null); n=${n:-0}
  if [ "$n" -eq 0 ]; then
    # 🟥 A near-miss key reads as "absent" and takes the grace exit — silent, and the author
    # believes they recorded it. Absent is legal; MISSPELLED is not.
    if grep -qE '^[[:space:]]*(soul[ _-]?check[[:space:]]*:|soulcheck:|soul-checks:)' "$m" 2>/dev/null \
       && ! grep -qE '^[[:space:]]*soul-check:' "$m" 2>/dev/null; then
      echo "  ❌ FAIL — a line looks like a soul-check key but is not exactly 'soul-check:'."
      grep -nE '^[[:space:]]*(soul[ _-]?check[[:space:]]*:|soulcheck:|soul-checks:)' "$m" | sed 's/^/       /'
      return 1
    fi
    return 0   # genuinely absent — adoption is incremental, same as thirdparty:
  fi
  if [ "$n" -gt 1 ]; then
    echo "  ❌ FAIL — marker carries MORE THAN ONE 'soul-check:' line."
    grep -nE '^[[:space:]]*soul-check:' "$m" | sed 's/^/       /'
    echo "     Readers take the first, so an appended correction is silently shadowed."
    return 1
  fi
  line=$(grep -m1 -E '^[[:space:]]*soul-check:' "$m" | sed -E 's/^[[:space:]]*soul-check:[[:space:]]*//')
  val=$(printf '%s' "$line" | sed -E 's/\(.*$//' | sed -E 's/[[:space:]]*(—|--).*$//' | sed -E 's/[[:space:]]+$//')

  # Cross-axis contamination — the other three axes ask different questions.
  case "$val" in
    tier1|tier1b|tier2|tier2b|tier3|panel|declined|checked|none-found|peer-review|\
DEGRADED_SINGLE_FAMILY|DEGRADED_PANEL_UNUSED|DEGRADED_NO_TARGET_ACCESS|DEGRADED_NO_ACCESS)
      echo "  ❌ FAIL — '$val' belongs to another axis, not soul-check:."
      echo "     standpoint = whose repo was ground truth · crossfamily = which model family reviewed"
      echo "     thirdparty = was this already done outside · soul-check = was THIS marker's own"
      echo "     ①영혼 (성공 정의 / 절대 안 함) read back against the delta."
      return 1 ;;
  esac

  # Is there an ①영혼 line in this same marker, and does it carry content?
  # 🟥 The soul detector must NOT read the `soul-check:` line itself. codex's alignment fix widened
  # the pattern and G8 flipped to PASS: `soul-check: reflected(…성공정의…)` matched as its own
  # evidence — self-referential FP ([[feedback_lane_runner_self_referential_fp]]). The claim can
  # never be its own ground. Same reason comment/prose lines are excluded: a marker that merely
  # SAYS 성공 정의 in narration has not declared one.
  # 🟥 A DECLARATION, not a MENTION. First real use (isolated clone, 2026-08-21) broke this on
  # attempt #1: this very delta's marker says «change: 마커의 ①영혼 슬롯에 소비처가 0이던 것을…»
  # in a prose FIELD VALUE, and the leg reported "①영혼 present: 10 words" for a marker that
  # declares nothing. Excluding `#` and `soul-check:` was not enough — any field whose value
  # discusses the axis satisfies a substring test, and markers ABOUT this axis always will.
  # 39 lanes, codex (diff axis) and agy (claims axis) all missed it; running it once did not
  # ([[feedback_adversarial_review_not_substitute_for_first_use]]).
  # So the token must sit at LINE START as the key (a leading markdown `#` is allowed — the real
  # corpus writes «## ①영혼 — 설계 전에 쓴 …» as a heading, and P7 flipped to BLOCK when the first
  # version of this regex forbade it. The known-pair caught that in one run).
  # Prose inside a field VALUE can no longer vote.
  _soul_src=$(grep -E '^[[:space:]]*#*[[:space:]]*(①영혼|soul|절대 안 함|성공 ?정의)[[:space:]]*[:—-]' "$m" 2>/dev/null \
              | grep -vE '^[[:space:]]*soul-check[[:space:]]*:')
  soul_n=$(printf '%s\n' "$_soul_src" | grep -cE '①영혼|^[[:space:]]*soul:|성공 ?정의|절대 안 함'); soul_n=${soul_n:-0}
  # Body = the ①영혼 block only: stop at a blank line OR at the next ASCII field key. An earlier
  # draft ran the range to EOF, so a marker reading «①영혼: 없음» followed by any other field was
  # scored as having content — the fixture G9 caught it.
  # 🟥 Counted in WORDS, not characters. `${#var}` on Korean text is bytes-or-chars depending on
  # locale, and this corpus is Korean — a char threshold silently changes meaning per machine
  # ⚠️ Honest caveat, measured: `wc -w` is NOT a human word count on Korean either («①영혼: 완주»
  # reports 3, not 2). It is stable per-machine and monotonic, which is all a floor needs — but
  # every threshold here was calibrated by RUNNING it, never by reasoning about word counts.
  # ([[feedback_instrument_vs_target_and_budget]], 단위 혼동). Word count is locale-independent.
  soul_body=$(awk '/①영혼|^[[:space:]]*soul:|성공 ?정의|절대 안 함/{if (!f) f=1}
                   f && /^[[:space:]]*$/{exit}
                   f && seen && /^[[:space:]]*[A-Za-z][A-Za-z0-9_-]*:/{exit}
                   f{print; seen=1}' "$m" 2>/dev/null \
              | sed -E '1s/^[[:space:]]*(①영혼|soul)[^[:alnum:]«]*//')
  soul_words=$(printf '%s' "$soul_body" | wc -w | tr -d ' ')
  soul_words=${soul_words:-0}

  case "$val" in
    UNKNOWN)
      echo "  ⚠️  soul-check leg: UNKNOWN — 안 봤다. Recorded, not silent." ;;
    reflected|violated|DEGRADED_NOT_RUN|DEGRADED_NO_SOUL|not-applicable)
      case "$line" in *\(*) ;; *)
        echo "  ❌ FAIL — '$val' without a (<grounds>) parenthetical."
        echo "     A read-back that names neither WHAT read it nor WHAT came back is"
        echo "     indistinguishable from not running it."
        return 1 ;;
      esac
      # 🟥 Measure INSIDE the parens. The old form took everything after the first `(`, so
      # `reflected() — <long tail>` passed on text that was never grounds (codex, diff axis).
      reason=$(printf '%s' "$line" | sed -E 's/^[^(]*\(//; s/\)[^)]*$//')
      # 🟥 agy (claims axis, 2026-08-21): this leg's own comment above condemns `${#var}` on
      # Korean text and then used `${#reason}` twelve lines later — [[feedback_citing_a_rule_is_not_obeying_it]],
      # verbatim. Inherited from the sibling legs, which does not excuse it here. Word count.
      _rwords=$(printf '%s' "$reason" | wc -w | tr -d ' '); _rwords=${_rwords:-0}
      if [ "$_rwords" -lt 3 ]; then
        echo "  ❌ FAIL — '$val(...)' grounds are vacuous. Name what read the ①영혼 line back,"
        echo "     against what, and what came back."
        return 1
      fi
      # ── the consistency check that makes this lane non-decorative ──────────────
      # Two fields in ONE record, mechanically decidable. Not a conclusion.
      case "$val" in
        reflected|violated)
          if [ "$soul_n" -eq 0 ] || [ "$soul_words" -lt 6 ]; then
            echo "  ❌ FAIL — '$val' claims the ①영혼 line was read back, but this marker has"
            echo "     no ①영혼 line with content (found: $soul_n line(s), $soul_words word(s))."
            echo "     🟥 Reflecting against an absent success-definition is the exact"
            echo "     half-externalization this lane exists to catch. Write ①영혼 first,"
            echo "     or record DEGRADED_NO_SOUL(<why>)."
            return 1
          fi ;;
        DEGRADED_NO_SOUL)
          if [ "$soul_n" -gt 0 ] && [ "$soul_words" -ge 6 ]; then
            echo "  ❌ FAIL — 'DEGRADED_NO_SOUL' says there is nothing to reflect against, but"
            echo "     this marker DOES carry an ①영혼 line with content ($soul_n line(s))."
            echo "     A recorded absence contradicted by the same record is worse than silence."
            return 1
          fi ;;
      esac
      echo "  ✅ soul-check leg: $val" ;;
    *)
      echo "  ❌ FAIL — '$val' is not a member of the soul-check: enum."
      echo "     Closed enum: reflected(<what came back — 어긋남 여부. NOT just who reviewed:
     that is crossfamily's column, and duplicating it double-counts one event>) ·"
      echo "     violated(<which half broke — 성공정의 미달 / 절대안함 위반 — and what was done>) ·"
      echo "     DEGRADED_NOT_RUN(<why>) · DEGRADED_NO_SOUL(<why>) · UNKNOWN · not-applicable(<why>)"
      echo "     🟥 'violated' is a legal value ON PURPOSE. A recorded violation is worth more"
      echo "     than a hidden one, and a lane that only admits success trains erasure."
      return 1 ;;
  esac
  return 0
}



# ── 4축 자기 대조 — «어느 축을 돌렸고 어느 축을 안 돌렸나» 형식 검사 ────────────
# CLAUDE.md §3층 자기 대조가 마커에 3줄을 요구한다. 그중 **기계로 볼 수 있는 두 줄**만 여기서
# 강제한다. 나머지(①영혼을 «설계 전에» 썼는가)는 **원리적으로 확인 불가**라 안 넣는다 —
# 사후 작성과 사전 작성이 파일에서 구분되지 않으므로, 넣으면 의식(ritual)만 훈련시킨다.
#
# 🟥 **이 검사의 스코프를 오해하지 마라 — 침묵은 잡고 오답은 못 잡는다.**
#    「ⓐ 돌렸다」고 적었는데 안 돌렸으면 이 훅은 통과시킨다. 마커의 계약은 예전부터
#    form + non-vacuity + auditability 이지 provenance 가 아니다. 오답을 잡는 건
#    cross-family 가 이 마커를 읽는 것이고, 그건 이 훅의 일이 아니다(정본에 그렇게 적혀 있다).
#
# 날짜 유예: 마커 **파일명의 날짜**가 GRACE 이후인 것만 요구한다. 소급 강제하면 진행 중인
# 브랜치가 전부 막히고, 그 과차단이 `--no-verify` 를 습관화시켜 같은 훅 안의 Destructive-Op
# 게이트까지 무장해제한다(오늘 실측된 트레이드오프).
AXES_RUN_GRACE_DATE="2026-08-10"
# ── 6축 확장 (2026-08-17) ────────────────────────────────────────────────────────
# 산문 정본은 6축(`fh_three_layer_canon.md` §1-a-2)인데 이 훅은 네 글자만 봤다. 확장하면서
# **키 표기를 산문과 1:1 로 정렬**한다 — 그 이유가 「예쁘게」가 아니라 아래 실측이다.
#
# 🟥 옛 배열과 새 배열은 **같은 글자가 다른 축을 가리킨다**:
#      문자   §1-a (옛 4축, 마커가 쓰던 것)   §1-a-2 (현 6축, 산문이 쓰는 것)
#       ⓐ     다른 계열                       다른 계열        (동일)
#       ⓑ     첫 실사용                       **입장**         ← 옛 ⓑ 는 현 ⓔ 로 밀렸다
#       ⓒ     기록 그라운딩                   격리 그라운딩    (대응)
#       ⓓ     되돌림 실측                     **3자 대면**     ← 옛 ⓓ 는 현 ⓕ 로 밀렸다
#    그래서 `b=`/`d=` 를 그대로 쓰면서 의미만 바꾸면 **훅이 조용히 다른 축으로 읽으며
#    오류를 내지 않는다.** 무음 재해석은 이 레포가 `not found ≠ 0` 라 부르는 것의 형제다.
#
# 실측(2026-08-17): 디스크 마커 190개 중 유예일 이후 51개. 훅은 «오늘·이 브랜치» 마커
# **한 개만** 검증하므로(아래 호출부) 확장으로 **커밋이 막히는 기존 마커는 0건**이다.
# 실제 비용은 그 51건이 «옛 배열인지 새 배열인지 읽을 수 없다» 는 것 — 파손이 아니라
# **미측정**이다. 그래서 처방이 「고친다」가 아니라 **「어느 배열인지 말하게 만든다」**:
#      마커 파일명 날짜 <  2026-08-17  → 옛 4축 배열 (ASCII 키)
#      마커 파일명 날짜 >= 2026-08-17  → 신 6축 배열 (기호 키)
#
# 🟥 **판별자는 날짜지 표기법이 아니다 — 초판이 그 반대로 적었고 코퍼스가 반증했다.**
#    초판 주석: «표기법 자체가 배열을 선언하므로 감사자가 grep 한 방으로 판별한다».
#    ⓓ3자대면 축이 디스크 마커를 손으로 세어 반례를 냈다 — axes-run 보유 53건 중
#    기호 키 4 · 혼용 1 이고, 기호 4건 중 **2건이 2026-08-10 자이면서 옛 4축 의미**다:
#      …fix_digest-collection-spec-reality_2026-08-10.marker
#        → `ⓐ 미실행 · ⓑ 첫실사용 … · ⓓ 미실행`     (ⓑ·ⓓ 가 현 배열에선 ⓔ·ⓕ)
#      …fix_entrypoint-drift-and-close-protocol_2026-08-10.marker
#        → `ⓐ 적대검증 · ⓑ 첫실사용 … · ⓓ 되돌림`
#    훅은 이 셋을 절대 안 읽으므로 커밋을 막지 않는다. 틀리는 건 **감사자**다.
#    ⇒ 표기 정렬은 «앞으로 쓸 때 안 헷갈리게» 하는 값은 있으나 **소급 판별자로 쓰면 안 된다**.
#
# ⚠️ **그리고 이 날짜 비교는 프로덕션에서 도달 불가 분기다 — 숨기지 않고 적는다.**
#    아래 호출부가 마커 경로를 `${TODAY}` 로 **구성**하므로 `mdate` 는 항상 오늘이다. 즉
#    오늘 이후 `six=0` 분기를 타는 실제 커밋은 없고, 살아있는 소비자는 픽스처뿐이다.
#    **기존 마커를 지킨 것은 이 상수가 아니라 그 경로 구성이다.** 상수는 픽스처가 경계를
#    고정하는 값 + 훗날 호출부가 임의 마커를 검증하게 될 때의 대비로 남긴다.
#    (선례: 8일 전 `crossfamily:` 를 free prose → closed enum 으로 바꿀 때는 컷오프 없이
#     그냥 강제했고 문제가 없었다 — 이유가 정확히 같다.)
#    부수 노출: 시계가 과거로 틀어진 노드는 파일명도 과거 날짜라 `six=0` 으로 새 나간다
#    (fail-open, 저위험). 닫으려면 상수를 지우고 무조건 6축을 요구하면 된다 — 어차피
#    도달 불가 분기이므로 **지우는 것이 곧 닫는 것**이다. 지금은 안 지웠다(픽스처 경계값).
#
# 이식성: bash 3.2 + BSD grep, LC_ALL={C, POSIX, en_US.UTF-8, ko_KR.UTF-8} 4개 arm 에서
# 6/6 HIT · known-negative(ⓖ) 0건으로 실측했다. ⚠️ Linux/GNU grep 은 **미측정** — CI(ubuntu)
# 가 첫 측정이고, 깨지면 아래 픽스처 레인이 빨개진다(무음 아님).
#
# ⓑ입장은 값을 여기 적지 않는다 — 이미 `standpoint:` 자기 필드가 정본이라 이중 기록이 된다.
# 대신 `ⓑ=→standpoint` 포인터를 요구하고, 그 경우 `standpoint:` 줄의 **존재**를 확인한다.
# (`standpoint:` 값 자체의 enum 검증은 별건이고 정본이 명시적으로 유보해 뒀다.)
SIX_AXES_GRACE_DATE="2026-08-17"
# ── ⓔ 첫 실사용 — «새 계기를 지으면서 안 돌렸다» 에는 근거를 요구한다 ────────────────────
# 🟥 채널이지 결론이 아니다. 「여섯 축을 매번 다 돌려라」는 정본이 명시적으로 금지한다
#    («비용 경계: 넷을 매번 다 돌리는 축이 아니다. 곱하지 말고 실패 모드에 맞춰 골라라»).
#    그래서 이 검사는 **한 경우에만** 걸린다: 이 커밋이 **새 계기를 추가**하는데 `ⓔ=none` 일 때.
#
# WHY (2026-08-29 실측): 레인 넷 + 훅 하나를 지으면서 ⓐ(계열)·ⓕ(되돌림)를 여러 번 돌렸고
#   둘 다 초록이었다. ⓔ(첫 실사용)만 **주석 블록 오탐 · 회고문 오탐** 둘을 냈고, 그건
#   정본이 «ⓑ는 계기의 계기가 틀린 경우라 ⓐ·ⓓ 가 구조적으로 못 본다» 고 적어둔 그 자리다.
#   🟥 그런데 그 ⓔ 는 **운영자가 잡아서** 돌았다. 마커는 여섯 축을 다 적게 하는데,
#   **그 기록을 델타의 성격과 대조하는 소비처가 0** 이었다 — 슬롯은 있고 소비처가 없는 형태.
#
# 근거 요구의 모양은 `crossfamily:` degrade 삼종이 이미 쓰는 것과 **같다**: 값만으로는 못 지나가고
# 같은 줄에 무엇을 걸고 있는지 적어야 한다. 새 형식이 아니다.
# ⚠️ 판별자는 «새 파일이 계기 모양인가» 라는 **경로 규칙**이고, 그것이 정말 계기인지는 판단이다.
#    과탐은 근거 한 줄로 통과하므로 비용이 낮다(과차단은 --no-verify 를 훈련시킨다).
validate_first_use_leg() { # $1 = marker path
  m="$1"
  # 새로 «추가»된 계기 후보만 본다. 수정은 대상이 아니다 — 첫 실사용은 첫 번째에만 성립한다.
  new_instr=$(git -c core.quotePath=false diff --cached --name-only --no-renames --diff-filter=A 2>/dev/null \
    | grep -E '(^|/)(lane_[a-z_]+\.py|[a-z_]+_check\.sh)$' || true)
  [ -n "$new_instr" ] || return 0
  line=$(grep -m1 -E '^[[:space:]]*axes-run:' "$m" 2>/dev/null)
  [ -n "$line" ] || return 0            # axes-run 자체 부재는 위 검사가 이미 막는다
  # ⓔ 값 추출: `ⓔ=...` 부터 다음 축 키 직전까지
  # 🟥 2026-08-31 — 이 추출은 두 군데가 fail-OPEN 이었다. 둘 다 cross-family(codex/gpt-5.5)가
  #    지목했고 실행으로 재현했다. 자력 적발은 앞의 하나뿐이다.
  #  ① 앞 공백: 종전 sed 가 **뒤 공백만** 다듬어 `ⓔ= none` 이 `' none'` 으로 나와 어느 case
  #     분기에도 안 맞고 통과했다. ⚠️ 다만 «축 강제를 통째로 껐다»는 **과대주장이다** — 이 다리
  #     앞에서 `validate_marker_axes_run` 이 `(^|[[:space:]])ⓔ=[^[:space:]]` 를 요구하므로
  #     그 입력은 거기서 먼저 막힌다(:2031 의 && 사슬). 이 다리 **단독으로는** 뚫렸다는 것이
  #     정확한 서술이고, 초판 주석이 전체 게이트로 넓혀 적은 것을 여기서 정정한다.
  #  ② 중복 키: `.*ⓔ=` 의 `.*` 가 탐욕적이라 **줄의 마지막 `ⓔ=`** 를 잡는다. 그래서
  #     `… ⓔ=none ⓕ=되돌림 ⓔ=ran` 은 `ran` 이 추출되어 **끝까지 통과했다** — 이쪽은 앞의
  #     axes_run 검사도 못 막는다(첫 `ⓔ=none` 이 그 검사를 만족시키기 때문이다). 실재하는
  #     end-to-end fail-open 은 ②다.
  #  처방: 키 경계에 고정 + **정확히 1개**일 것을 강제 + 양쪽 trim.
  #  픽스처: scripts/test_marker_first_use_lanes.sh B5(앞공백) · B10(중복 키).
  e_cnt=$(printf '%s' "$line" | grep -oE '(^|[[:space:]])ⓔ=' | wc -l | tr -d ' ')
  if [ "$e_cnt" != "1" ]; then
    echo "  ❌ MARKER — 'axes-run:' 한 줄에 'ⓔ=' 가 ${e_cnt}개다(정확히 1개여야 한다)."
    echo "     0개면 축이 없는 것이고, 2개 이상이면 **뒤엣것이 앞엣것을 덮는다** — 어느 쪽도"
    echo "     읽는 사람과 기계가 같은 값을 보지 않는다. 한 줄에 한 번만 적어라."
    return 1
  fi
  e_val=$(printf '%s' "$line" | sed -n 's/.*[[:space:]]ⓔ=\([^ⓕ]*\).*/\1/p' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
  case "$e_val" in
    ''|none|None|NONE|-)
      echo "  ❌ MARKER — 이 커밋은 **새 계기를 추가**하는데 'axes-run' 의 ⓔ(첫 실사용)이 'none' 이다."
      printf '     새 계기: %s\n' $new_instr
      echo "     🟥 정본 실측: ⓐ(계열)·ⓕ(되돌림)가 전부 초록인데 ⓔ만 오탐 둘을 낸 사례가 있다 —"
      echo "        «계기의 계기가 틀린» 경우는 ⓐ·ⓓ 가 **구조적으로** 못 본다."
      echo "     둘 중 하나를 해라 (여섯 축을 다 돌리라는 뜻이 아니다):"
      echo "       · 실물 대상에 한 번 돌리고  ⓔ=<무엇에 돌렸고 무엇이 나왔나>"
      echo "       · 안 돌릴 거면 **같은 줄에 근거**를  ⓔ=none(<왜 이 계기는 첫 실사용이 불필요/불가한가>)"
      return 1 ;;
  esac
  return 0
}

validate_marker_axes_run() { # $1 = marker path
  local m="$1" mdate line ax miss="" nlines axkeys six=0
  mdate=$(basename "$m" | sed -nE 's/.*_([0-9]{4}-[0-9]{2}-[0-9]{2})\.marker$/\1/p')
  # 날짜를 못 읽으면 요구하지 않는다 — 파일명 규약 밖의 마커를 이 검사가 판정할 근거가 없다.
  [ -n "$mdate" ] || return 0
  [ "$mdate" \< "$AXES_RUN_GRACE_DATE" ] && return 0
  [ "$mdate" \< "$SIX_AXES_GRACE_DATE" ] || six=1

  # 다중 `axes-run:` 줄 차단. `crossfamily:` 는 예전부터 이걸 막는데 `axes-run` 은 안 막아서,
  # 둘째 줄이 **없다고 판정되는 게 아니라 안 보인다**(아래 grep -m1). 실측: 디스크에 2줄짜리
  # 마커 1건 존재(2026-08-15). 같은 얼굴이므로 같은 자리에서 닫는다.
  nlines=$(grep -c -E '^[[:space:]]*axes-run:' "$m" 2>/dev/null)
  if [ "${nlines:-0}" -gt 1 ]; then
    echo "  ❌ MARKER FORMAT — 'axes-run:' 줄이 ${nlines}개다 (하나여야 한다)"
    echo "     이 검사는 첫 줄만 읽는다. 둘째 줄은 «없다고 판정»되는 게 아니라 **안 보인다** —"
    echo "     즉 거기 적은 축은 검사를 통째로 우회한다. 한 줄로 합쳐라."
    return 1
  fi

  line=$(grep -m1 -E '^[[:space:]]*axes-run:' "$m" 2>/dev/null)
  if [ -z "$line" ]; then
    echo "  ❌ MARKER FORMAT — missing 'axes-run:' line (축 자기 대조)"
    echo "     CLAUDE.md §3층 자기 대조: **돌린 축과 안 돌린 축을 각각 이름으로** 적는다."
    echo "     안 돈 축은 침묵이 아니라 'none' 이다 — 미측정을 0으로 렌더하지 않는다."
    if [ "$six" -eq 1 ]; then
      echo "       axes-run: ⓐ=codex(4건) ⓑ=→standpoint ⓒ=none ⓓ=none ⓔ=CI배선 ⓕ=되돌림(국소성)"
      echo "     (ⓐ다른계열 · ⓑ입장 · ⓒ격리그라운딩 · ⓓ3자대면 · ⓔ첫실사용 · ⓕ되돌림실측)"
    else
      echo "       axes-run: a=codex(4건) b=CI배선 c=none d=되돌림(국소성 확인)"
      echo "     (a=다른계열 · b=첫실사용 · c=기록그라운딩 · d=되돌림)"
    fi
    return 1
  fi
  if [ "$six" -eq 1 ]; then axkeys="ⓐ ⓑ ⓒ ⓓ ⓔ ⓕ"; else axkeys="a b c d"; fi
  for ax in $axkeys; do
    echo "$line" | grep -qE "(^|[[:space:]])${ax}=[^[:space:]]" || miss="$miss $ax"
  done
  if [ -n "$miss" ]; then
    if [ "$six" -eq 1 ]; then
      echo "  ❌ MARKER — 'axes-run:' 에 빠진 축:$miss"
      echo "     여섯 축을 **전부** 적는다. 안 돌린 축은 'none' 으로 명시한다 — 빠뜨리는 것과"
      echo "     안 돌렸다고 적는 것은 다른 명제이고, 빠뜨리면 읽는 쪽이 전자를 후자로 읽는다."
      echo "       axes-run: ⓐ=codex(4건) ⓑ=→standpoint ⓒ=none ⓓ=none ⓔ=CI배선 ⓕ=되돌림(국소성)"
      echo "     (ⓐ다른계열 · ⓑ입장 · ⓒ격리그라운딩 · ⓓ3자대면 · ⓔ첫실사용 · ⓕ되돌림실측)"
      echo "     🟥 옛 ASCII 배열(a=…d=)과 **글자 의미가 다르다** — 옛 b=첫실사용은 지금 ⓔ,"
      echo "        옛 d=되돌림은 지금 ⓕ 다. 옛 표기를 그대로 옮기면 축이 뒤바뀐다."
    else
      echo "  ❌ MARKER — 'axes-run:' 에 빠진 축:$miss"
      echo "     네 축을 **전부** 적는다. 안 돌린 축은 'none' 으로 명시한다 — 빠뜨리는 것과"
      echo "     안 돌렸다고 적는 것은 다른 명제이고, 빠뜨리면 읽는 쪽이 전자를 후자로 읽는다."
      echo "       axes-run: a=codex(4건) b=CI배선 c=none d=되돌림(국소성 확인)"
    fi
    return 1
  fi

  if [ "$six" -eq 1 ]; then
    # ── 혼용 가드는 **짓지 않는다**. 지었다가 실측으로 지웠다 ────────────────────
    # 초판은 `grep -qE "(^|[[:space:]])[a-f]=[^[:space:]]"` 로 옛 ASCII 키 잔존을 차단했다.
    # 자기 프로브에서 두 가지가 드러났다:
    #   ⓐ **오탐이 실재한다** — 값이 자유 산문이라 `ⓔ=실사용 ⓕ=되돌림 f=0.9 상승` 이나
    #      `ⓔ=arm a=제어 b=처리 비교` 가 «혼용» 으로 차단됐다. 과차단은 `--no-verify` 를
    #      훈련시키고, 그건 같은 훅 안의 Destructive-Op 게이트까지 무장해제한다.
    #   ⓑ **그리고 애초에 불필요하다** — 「어느 배열인가」는 **여섯 기호가 다 있는가**로
    #      이미 판별된다. 옛 ASCII 키가 기호 키를 *대신하는* 경우는 그 기호가 빠졌다는
    #      뜻이므로 바로 위 missing-key 검사가 이미 차단한다. 여섯이 다 있는 줄에 남은
    #      ASCII 조각은 값의 일부일 뿐 배열 선언을 흐리지 않는다.
    # ⇒ 오탐만 만들고 아무것도 더 막지 않는 필터였다. **삭제가 수리보다 낫다**
    #   ([[feedback_deletion_beats_repair_dead_filter]]). 픽스처는 남긴다 — 그 케이스는
    #   여전히 차단돼야 하고, 이제 *다른 사유로* 차단된다는 사실 자체가 고정할 값어치가 있다.

    # ⓑ입장은 값을 여기 적지 않는다 — `standpoint:` 자기 필드가 정본이라 이중 기록이 된다.
    # 포인터를 쓴 경우 그 필드의 **존재**만 확인한다(값 enum 검증은 별건, 정본이 유보).
    # 🟥 화살표를 필수로 보면 **fail-open** 이다 — 자기 프로브 실측: `ⓑ=standpoint` (화살표
    #    없음)는 검사를 통째로 비껴가서 죽은 포인터가 그대로 통과했다. 그래서 «ⓑ 값이
    #    standpoint 를 참조하는가» 로 넓힌다. **명시 잔여**: `ⓑ=입장리뷰 standpoint 참조`
    #    처럼 참조어가 **다른 토큰**에 있으면 여전히 안 잡힌다(값에 공백이 허용되므로
    #    토큰 경계를 알 수 없다). 넓히려면 값 문법을 조여야 하고 그건 별건이다.
    if echo "$line" | grep -qE "(^|[[:space:]])ⓑ=[^[:space:]]*standpoint" \
       && ! grep -qE '^[[:space:]]*standpoint:[[:space:]]*[^[:space:]]' "$m" 2>/dev/null; then
      echo "  ❌ MARKER — 'ⓑ=→standpoint' 라고 적었는데 'standpoint:' 줄이 없다"
      echo "     포인터가 가리키는 곳이 비어 있으면 그건 기록이 아니라 죽은 포인터다."
      echo "       standpoint: tier1        (내용만 — 남의 하네스 정본을 안 읽었다)"
      echo "       standpoint: not-applicable"
      return 1
    fi

    # ⓓ3자대면도 같은 형태 — 값은 `thirdparty:` 자기 필드가 나른다 (2026-08-17 신설).
    # 🟥 왜 자기 필드가 필요한가: `axes-run` 한 칸은 **토큰**만 담는다. ⓓ가 실제로 내는 것은
    #    «무엇을 검색해서 무엇을 찾았나» 라 한 토큰에 안 들어간다. 자리가 없으면 안 적히고,
    #    안 적히면 다음 세션이 «이 축을 돌렸나»를 물을 수도 확인할 수도 없다.
    #    실측(2026-08-17): 이 축을 처음 제대로 돌린 세션에서 발표 주장 **6건**이 선행자산에
    #    걸렸는데, 그 결과를 기록할 필드가 없었다.
    # ⚠️ ⓑ 와 **똑같은 fail-open 을 반복하지 않는다** — 화살표를 필수로 보지 않고
    #    «ⓓ 값이 thirdparty 를 참조하는가» 로 넓힌다(ⓑ가 그렇게 뚫렸던 실측이 바로 위에 있다).
    #    같은 명시 잔여도 그대로 상속한다: 참조어가 다른 토큰에 있으면 안 잡힌다.
    if echo "$line" | grep -qE "(^|[[:space:]])ⓓ=[^[:space:]]*thirdparty" \
       && ! grep -qE '^[[:space:]]*thirdparty:[[:space:]]*[^[:space:]]' "$m" 2>/dev/null; then
      echo "  ❌ MARKER — 'ⓓ=→thirdparty' 라고 적었는데 'thirdparty:' 줄이 없다"
      echo "     포인터가 가리키는 곳이 비어 있으면 그건 기록이 아니라 죽은 포인터다."
      echo "       thirdparty: checked(promptfoo·DeepEval 훑음 — 겹침 없음)"
      echo "       thirdparty: none-found(<무엇을 검색했나>)   ← 찾았는데 없음. «안 봤다»와 다르다"
      echo "       thirdparty: UNKNOWN                        ← 안 봤다"
      echo "       thirdparty: not-applicable"
      return 1
    fi
  fi

  line=$(grep -m1 -E '^[[:space:]]*controls:' "$m" 2>/dev/null \
         | sed -E 's/^[[:space:]]*controls:[[:space:]]*//')
  if [ -z "$line" ]; then
    echo "  ❌ MARKER FORMAT — missing 'controls:' line"
    echo "     축을 «돌렸다»의 최소 증거는 **컨트롤이 살아 있는 실행 출력**이다."
    echo "     컨트롤 없는 측정은 실측으로 절반이 죽었다(정본 §1-c)."
    echo "       controls: alive — known-positive 'X' 히트 3 · known-negative 0"
    echo "       controls: n/a — 이번 델타에 측정이 없다 (<사유>)"
    return 1
  fi
  # 비-vacuity: 생사 토큰이 있어야 한다. "컨트롤 붙였다" 같은 무실질 문장을 막는다.
  if ! echo "$line" | grep -qiE 'alive|dead|살아|죽|n/a|없'; then
    echo "  ❌ MARKER — 'controls:' 에 생사 토큰이 없다 (무실질)"
    echo "     컨트롤이 **살았는지 죽었는지**를 적어라 — 'alive' / 'dead' / 'n/a'."
    echo "     죽은 컨트롤 위에서 나온 수치는 측정이 아니다."
    return 1
  fi
  return 0
}

validate_marker_floor() {
  local m="$1" fs model
  # Leading whitespace tolerated on field lines (defense-in-depth vs indented writes).
  fs=$(grep -m1 -E '^[[:space:]]*floor-status:' "$m" 2>/dev/null \
       | sed -E 's/^[[:space:]]*floor-status:[[:space:]]*//; s/[[:space:]].*$//')
  if [ -z "$fs" ]; then
    echo "  ❌ MARKER FORMAT — missing floor-status: line (legacy/empty marker)"
    echo "     The marker must carry machine-readable floor fields. Recreate it:"
    marker_recreate_hint "$m"
    return 1
  fi
  case "$fs" in
    at-floor|above-floor)
      : ;;
    sonnet-floor)
      # Sonnet-Floor Doctrine (2026-07-10): Sonnet inline is first-class for BASE
      # commits — no operator ack — but the judged verdict is weaker at Sonnet, so
      # a mechanical anchor line is the compensating requirement, and the marker
      # auto-enters the weekly re-validation queue (below_floor_scan.sh, R-tier).
      if ! grep -qE '^[[:space:]]*axis2-anchor:[[:space:]]*[^[:space:]]' "$m"; then
        echo "  ❌ SONNET-FLOOR pass without mechanical anchor"
        echo "     floor-status: sonnet-floor requires an axis2-anchor: line naming the"
        echo "     mechanical evidence that grounds the judged verdict (regression test,"
        echo "     scan output, probe count). Judged-only at Sonnet is not gate-PASS."
        echo "     Alternatives: dispatch the audit (sidecar / opus agent → at-floor),"
        echo "     or record the anchor you ran."
        return 1
      fi
      echo "  ⚠️  sonnet-floor pass accepted (base floor met; anchored — provisional"
      echo "     for judged depth: weekly audit re-queues sonnet-floor markers, R-tier)"
      ;;
    below-floor)
      if ! grep -qE '^[[:space:]]*below-floor-ack:[[:space:]]*[^[:space:]]' "$m"; then
        echo "  ❌ BELOW-FLOOR adversarial pass without operator ack"
        echo "     A judged verdict produced below the opus floor is provisional —"
        echo "     it is not gate-PASS evidence (§Floor governance). Either:"
        echo "       (1) re-run Axis 2 at ≥ floor: dispatch quench-challenger at opus,"
        echo "           then set floor-status: at-floor; or"
        echo "       (2) record explicit operator acceptance in the marker:"
        echo "           below-floor-ack: \"<verbatim operator utterance>\" — <reason>"
        return 1
      fi
      # Rubber-stamp guard: the ack must contain a QUOTED operator utterance
      # ("..." or “…”, ≥2 chars). A bare reason is agent-self-writable; a quote
      # ties the ack to a conversational event auditable in the session record.
      if ! grep -m1 -E '^[[:space:]]*below-floor-ack:' "$m" \
           | grep -qE '"[^"]{2,}"|“[^”]{2,}”'; then
        echo "  ❌ BELOW-FLOOR ack without quoted operator utterance (rubber-stamp guard)"
        echo "     The ack line must quote the operator's approval verbatim:"
        echo "       below-floor-ack: \"<what the operator actually said>\" — <reason>"
        return 1
      fi
      echo "  ⚠️  below-floor pass accepted via operator ack (provisional —"
      echo "     weekly audit re-queues below-floor markers for floor-tier re-run)"
      ;;
    *)
      echo "  ❌ MARKER FORMAT — invalid floor-status: '$fs'"
      echo "     Allowed: at-floor | above-floor | sonnet-floor | below-floor"
      return 1
      ;;
  esac
  if ! grep -qE '^[[:space:]]*axis2-engine:[[:space:]]*[^[:space:]]' "$m"; then
    echo "  ❌ MARKER FORMAT — missing axis2-engine: line"
    echo "     Record what ran the adversarial pass (quench-challenger | inline | <cli>)."
    return 1
  fi
  model=$(grep -m1 -E '^[[:space:]]*axis2-model:' "$m" 2>/dev/null \
          | sed -E 's/^[[:space:]]*axis2-model:[[:space:]]*//; s/[[:space:]].*$//')
  if [ -z "$model" ]; then
    echo "  ❌ MARKER FORMAT — missing axis2-model: line"
    echo "     Record the tier that actually produced the adversarial pass"
    echo "     (e.g. axis2-model: opus). This is what makes the floor auditable."
    return 1
  fi
  # axis2-evidence — presence + non-vacuity (NOT provenance; see header note + the
  # 2026-06-13 judge-robustness swarm). Catches the realistic failure: a marker that
  # asserts a pass with no recorded result. The hook cannot verify the pass ran — that
  # residual is the weekly audit's + operator's, by design (documented, not silent).
  ev=$(grep -m1 -E '^[[:space:]]*axis2-evidence:' "$m" 2>/dev/null \
       | sed -E 's/^[[:space:]]*axis2-evidence:[[:space:]]*//')
  if [ -z "$ev" ]; then
    echo "  ❌ MARKER FORMAT — missing axis2-evidence: line"
    echo "     Record what the adversarial pass actually found, so the marker is auditable:"
    echo "       axis2-evidence: PASS no-S   |   1S/4A fixed   |   clean — 0 findings"
    echo "     (The hook enforces form + non-vacuity, not provenance — a fabricated pass is"
    echo "      the weekly audit's + operator's residual, not the hook's. See header note.)"
    return 1
  fi
  # Non-vacuity: must carry a finding count/severity OR an explicit verdict token —
  # blocks an empty-substance "it ran, trust me" line.
  if ! echo "$ev" | grep -qiE '[0-9]|clean|pass|fail|no-s|none|finding|converg'; then
    echo "  ❌ MARKER — axis2-evidence too vacuous (no count or verdict token)"
    echo "     Cite the actual result: 'PASS no-S' / '1S/4A fixed' / 'clean — 0 findings'."
    return 1
  fi
  # Model/floor cross-check — a known below-floor tier cannot claim at/above-floor.
  # This catches the literal 2026-06-10 incident pattern (Sonnet pass labeled
  # "Opus floor") mechanically. Floor = opus; tiers known to sit below it are
  # enumerated here — above-floor models are NOT enumerated (no name baked in).
  case "$fs" in
    at-floor|above-floor)
      if echo "$model" | grep -qiE 'sonnet|haiku'; then
        echo "  ❌ MODEL/FLOOR MISMATCH — axis2-model: $model cannot be $fs (judged-depth floor=opus)"
        echo "     Sonnet inline → floor-status: sonnet-floor (+ axis2-anchor:); sub-Sonnet →"
        echo "     below-floor (+ below-floor-ack:); or dispatch Axis 2 at ≥ floor."
        return 1
      fi
      ;;
    sonnet-floor)
      # sonnet-floor is Sonnet's own lane — a sub-Sonnet tier claiming it is the
      # same mislabel class the at-floor check catches (haiku cannot ride the lane).
      if ! echo "$model" | grep -qiE 'sonnet'; then
        echo "  ❌ MODEL/FLOOR MISMATCH — axis2-model: $model cannot be sonnet-floor"
        echo "     sonnet-floor is for Sonnet-tier passes only; sub-Sonnet → below-floor + ack."
        return 1
      fi
      ;;
  esac
  return 0
}

# 오늘자 마커 중 이 게이트가 «안 읽는» 것들. 순수 함수인 이유는 staged_worktree_drift 와 같다 —
# 레인이 저장소 없이 구동해야 한다. 입력은 파일명 목록과 이 게이트가 읽는 주소, 출력은 나머지.
# 🟥 빈 입력에서 무엇이 나오나: 목록이 비면 결과도 빈다(= 소견 없음). 그 방향이 옳다 — 마커가
# 아예 없으면 아래 NOT CONFIRMED 가 이미 말하므로 여기서 또 말하면 중복이다.
unread_markers() {   # $1 = 오늘자 마커 파일명들 (LF) · $2 = 이 게이트가 읽는 파일명
  printf '%s\n' "${1:-}" | sed '/^$/d' | LC_ALL=C sort -u | LC_ALL=C grep -vxF "${2:-}" || true
}

# ── Axes 2+3 — steel-quench + phantom-quench (full gate only) ─────────
if [ "$GATE_MODE" = "full" ]; then
  echo "[Axis 2+3] Adversarial + Source-Grounding..."
  MARKER_DIR="$EVIDENCE_ROOT/tracks/_meta"
  MARKER="$MARKER_DIR/.axes_23_passed_${BRANCH_SLUG}_${TODAY}.marker"

  if [ -f "$MARKER" ]; then
    # Wave 1-D defense leg — floor tiers only (sonnet-floor / below-floor), on ANY full-gate marker.
    # 🟥 It was first wired inside the LOAD-BEARING block below, which made the real condition
    # "load-bearing AND floor tier" while the skill documented "floor tiers only" — a SKILL.md
    # change at sonnet-floor would have passed with no defense line. Found by cross-family review
    # (codex/gpt-5.5, 2026-08-20); self-detection 0. The scope the doctrine states is the scope
    # the hook now has.
    _DEFENSE_OK=1
    if grep -qE '^[[:space:]]*floor-status:[[:space:]]*(sonnet-floor|below-floor)' "$MARKER"; then
      validate_defense_leg "$MARKER" || _DEFENSE_OK=0
    fi
    # ⓒ 사전 선언 (①영혼) — scope is EVERY marker (CLAUDE.md §자기 대조 mandates ①영혼 on all of them), so it
    # belongs HERE and not in the load-bearing block below.
    # 🟥 It was first wired there, exactly like `validate_defense_leg` above — the real condition
    # would have been "load-bearing AND has a marker" while the doctrine says every marker. The
    # note recording that precedent is ~8 lines above this one and was in the file at the time;
    # the same defect was made one day later anyway ([[feedback_unedited_parts_of_my_own_file]]).
    # Caught by the operator asking «배선 완료되었나?», not by a lane and not by cross-family.
    _SOUL_OK=1
    validate_soul_present_leg "$MARKER" || _SOUL_OK=0
    validate_soul_check_leg "$MARKER" || _SOUL_OK=0
    # 🟥 2026-08-30 신설 — 이 두 줄이 «정의만 하고 안 부르는» 상태로 착지할 뻔했다.
    #    `scripts/test_hook_leg_wiring_lanes.sh` 가 W3·W4 를 NOT WIRED 로 잡았다.
    #    함수가 옳은지가 아니라 «호출되는지»를 보는 레인이 없으면 이건 조용히 통과한다
    #    ([[feedback_built_but_not_wired]] — 오늘만 다섯 번째 얼굴).
    validate_soul_tenet_refs "$MARKER" || _SOUL_OK=0
    validate_defeater_leg "$MARKER" || _SOUL_OK=0
    validate_affected_leg "$MARKER" || _SOUL_OK=0
    validate_oracle_leg "$MARKER" || _SOUL_OK=0
    if [ "$_DEFENSE_OK" -eq 1 ] && [ "$_SOUL_OK" -eq 1 ] && validate_marker_floor "$MARKER" && validate_marker_axes_run "$MARKER" && validate_first_use_leg "$MARKER"; then
      echo "  ✅ PASS (marker confirmed + floor/axes fields valid:"
      echo "     .axes_23_passed_${BRANCH_SLUG}_${TODAY}.marker)"
    else
      FAILED=1
    fi
    # ── 전부-부재 마커 표면화 (advisory · 2026-08-30, cross-family codex «가장 싼 우회») ──
    # 🟥 모든 축을 «선언된 부재»로 채운 마커는 **형식적으로 완전히 합법**이다 — 그리고 그건
    #    설계다. 선언된 부재가 침묵보다 낫다는 것이 이 게이트들의 전제이므로 **차단하지 않는다.**
    #    그러나 «전부가 부재»라는 것 자체는 «기록의 속성»이라 기계가 볼 수 있다. 결론을 말하지
    #    않고 세어서 보여준다 — 저자와 리뷰어가 그 마커를 다시 보게 만드는 것이 전부다.
    #    (판정을 코드로 굳히면 §Mechanization Boundary 위반이다. 그래서 advisory 다.)
    _empt=0
    grep -qE '^[[:space:]]*soul:[[:space:]]*(없음|none|n/a)[[:space:]]*$' "$MARKER" && _empt=$((_empt+1))
    grep -qE '^[[:space:]]*defeater:[[:space:]]*(없음|none|n/a)[[:space:]]*$' "$MARKER" && _empt=$((_empt+1))
    grep -qE '^[[:space:]]*axes-run:.*ⓐ=none.*ⓒ=none.*ⓔ=none.*ⓕ=none' "$MARKER" && _empt=$((_empt+1))
    grep -qE '^[[:space:]]*crossfamily:[[:space:]]*(UNKNOWN|DEGRADED)' "$MARKER" && _empt=$((_empt+1))
    grep -qE '^[[:space:]]*standpoint:[[:space:]]*(UNKNOWN|DEGRADED)' "$MARKER" && _empt=$((_empt+1))
    grep -qE '^[[:space:]]*thirdparty:[[:space:]]*(UNKNOWN|DEGRADED)' "$MARKER" && _empt=$((_empt+1))
    # 🟥 3라운드 두 계열 독립 수렴: 임계 4 는 **정상 마커를 매번 시끄럽게** 만든다 —
    #    혼자 한 작업이면 `axes-run: ⓐ=none…` + crossfamily/standpoint/thirdparty UNKNOWN 만으로
    #    4점이 되고, 그건 «점진 채택»의 의도된 정상 상태다.
    #    ⇒ 하중 지는 둘(`soul:`·`defeater:`)이 **부재일 때만** 세고, 임계를 5로 올린다.
    _core_empty=0
    grep -qE '^[[:space:]]*soul:[[:space:]]*(없음|none|n/a)[[:space:]]*$' "$MARKER" && _core_empty=1
    grep -qE '^[[:space:]]*defeater:[[:space:]]*(없음|none|n/a)[[:space:]]*$' "$MARKER" && _core_empty=1
    if [ "$_core_empty" = 1 ] && [ "$_empt" -ge 5 ]; then
      echo "  ⚠️  이 마커는 $_empt 개 축이 «선언된 부재»다 — 형식은 통과지만 실질은 비어 있다."
      echo "     막지 않는다(선언된 부재는 침묵보다 낫다). 다만 «게이트를 통과했다»가"
      echo "     «검증했다»로 읽히지 않도록 여기 남긴다. 리뷰어는 이 줄을 보고 마커를 다시 봐라."
    fi
    # ── 이 게이트가 «안 읽는» 오늘자 마커가 또 있나 (advisory) ──────────────────────
    # WHY. 이 훅은 `.axes_23_passed_<브랜치슬러그>_<날짜>.marker` **하나만** 읽는다. 다른 이름으로
    # 쓴 마커는 기록으로는 유효하지만 **검증은 하나도 안 거친다** — 그런데 저자는 「마커를 썼다」는
    # 사실만으로 검증됐다고 믿는다. 2026-08-30 실측: 한 세션이 안 읽히는 주소에 **8개**를 썼고,
    # 그 8개 전부 `floor-status: opus-tier`(enum 밖)를 갖고 있었으며 아무도 안 잡았다. 이름을
    # 맞추자 게이트가 **두 번의 시도로** 그 형식 오류들을 잡았다.
    # 🟥 채널이지 결론이 아니다 — 「그 마커가 옳은가」는 안 묻는다. 「네 증거가 독자가 보는
    # 주소에 있는가」만 묻고, 절대 차단하지 않는다(다른 이름의 기록은 정당한 관행이다).
    _MK_ADDR=".axes_23_passed_${BRANCH_SLUG}_${TODAY}.marker"
    _MK_ALL=$(ls -1 "$MARKER_DIR"/.axes_23_passed_*"${TODAY}".marker 2>/dev/null | sed 's|.*/||' || true)
    _MK_UNREAD=$(unread_markers "$_MK_ALL" "$_MK_ADDR")
    if [ -n "$_MK_UNREAD" ]; then
      echo "  ⚠️  오늘자 마커가 더 있는데 이 게이트는 안 읽는다 — 검증을 하나도 안 거친 기록이다:"
      printf '%s\n' "$_MK_UNREAD" | sed 's/^/         /'
      echo "     이 게이트가 읽는 주소는 $_MK_ADDR 하나다."
      echo "     (advisory — 막지 않는다. 다른 이름의 «기록»은 정당하다. 다만 «검증됐다»고 읽지 마라.)"
    fi
  else
    echo "  ❌ NOT CONFIRMED"
    echo ""
    echo "  Run /steel-quench and /phantom-quench in your Claude session."
    echo "  After both PASS, Claude creates the marker automatically. Or manually:"
    echo ""
    echo "     mkdir -p \"$MARKER_DIR\""
    marker_recreate_hint "$MARKER"
    echo ""
    FAILED=1
  fi
else
  echo "[Axis 2+3] SKIP (lightweight mode — CATALOG.md / tracks/ only)"
fi

# ── Axis 4 — Edit Manifest entry (always required) ────────────────────────────
echo "[Axis 4] Edit Manifest..."
MANIFEST="$EVIDENCE_ROOT/tracks/_meta/edit_manifest.yaml"
if [ ! -f "$MANIFEST" ]; then
  echo "  ❌ FAIL — tracks/_meta/edit_manifest.yaml not found"
  echo "  Run /edit-manifest RECORD or create the file manually."
  FAILED=1
else
  # PRESENCE **AND** VALIDITY, decided by the canonical loader rather than by grep.
  #
  # This axis had been a pure substring grep, so it never asked whether the file it gates is the
  # thing its name promises. It was not: 12 of 147 entries were invalid YAML for months, silently,
  # because every consumer reads it line-wise (this hook, activity_log.sh awk, sync-to-be.sh cp).
  #
  # The FIRST fix for that was itself defective, and cross-family review measured all three holes —
  # which is why the verdict now travels on the EXIT CODE and one Python pass owns every predicate:
  #   - `$(python3 ...) || echo unchecked` SWALLOWED the exit code, so any interpreter failure, on a
  #     manifest with a confirmed syntax error, degraded to PASS. A checker whose crash means "fine"
  #     is not a checker.
  #   - it discarded the parse RESULT, so a duplicate `date:` key (YAML is last-wins) could delete
  #     today's entry while still reporting valid, and a list item drifted outside its parent list
  #     still passed — the exact class the same commit had just repaired, left undetected.
  #   - the grep was substring-based: a COMMENTED `# date: <today>` counted as an entry, while a
  #     quoted `date: "<today>"` or extra spacing did not. Fail-open and over-block in one predicate.
  # Free-form stdout is never the verdict channel either — a python3 shim printing one banner line
  # would otherwise fail a healthy manifest.
  MANIFEST_MSG=$(python3 - "$MANIFEST" "$TODAY" <<'PYEOF'
import sys
try:
    import yaml
except Exception:
    sys.exit(3)                      # 3 = checker unavailable (env gap, not a defect in the change)
try:
    doc = yaml.safe_load(open(sys.argv[1]))
except Exception as e:
    mk = getattr(e, "problem_mark", None)
    print("invalid YAML at line %s" % (mk.line + 1 if mk else "?")); sys.exit(1)
if not isinstance(doc, list):
    print("top level is %s, expected a list of entries" % type(doc).__name__); sys.exit(1)
for i, entry in enumerate(doc):
    if not isinstance(entry, dict):
        print("entry #%d is a %s, not a mapping — a list item may have drifted outside its parent"
              % (i, type(entry).__name__)); sys.exit(1)
# 🟥 인터리브 지문 — 동시 append 는 **YAML 을 깨지 않고** 내용을 뒤섞는다.
# 2026-08-18 실측(적대적 재현): 워커 4개가 한 엔트리를 여러 write 로 쪼개 append 하면
# 파싱은 OK 인데 필드가 서로 섞이고(w3 의 branch 에 w2 의 impact) 나머지는 `date` 만 남는다.
# ⇒ **형식 검사가 통과시키는 무음 손상.** 이 축이 없애려는 병을 이 축 자신이 통과시키고 있었다.
# 지문은 스키마가 아니다 — 실물 407 엔트리의 키 분포가 느슨해서(date 100% · branch 38%)
# 필수키를 걸면 과거를 과차단한다. known-pair 로 고른 지문은 **«date 만 있는 엔트리»**:
#   known-negative 실물 407건 → 0건 · known-positive 재현본 → 3/5.
# 워크트리 병렬 커밋이 열리기 전에는 이 조건 자체가 안 생겼다 — #448 이 문을 여니 같이 닫는다.
orphan = [i for i, e in enumerate(doc) if set(e.keys()) == {"date"}]
if orphan:
    print("entry #%s has only `date` — concurrent-append interleaving signature "
          "(fields lost/mixed). Re-check the last appended entries by hand." % orphan[0])
    sys.exit(1)
def norm(v):
    return v.isoformat() if hasattr(v, "isoformat") else str(v).strip()
if not any(norm(e.get("date")) == sys.argv[2] for e in doc):
    sys.exit(2)                      # 2 = parses fine, but no entry for today
sys.exit(0)
PYEOF
  )
  MANIFEST_RC=$?
  case "$MANIFEST_RC" in
    0) echo "  ✅ PASS (entry for $TODAY present; manifest parses and its shape is intact)" ;;
    3) # Degrade-to-advisory on purpose: a commit is a REVERSIBLE surface (CLAUDE.md
       # §Irreversibility Surface-Class Degrade Invariant), and a missing PyYAML is an environment
       # gap rather than a defect in the change being committed. Loud, never silent.
       if grep -q "date: $TODAY" "$MANIFEST" 2>/dev/null; then
         echo "  ✅ PASS (entry for $TODAY found by fallback grep; validity UNCHECKED — PyYAML unavailable)"
       else
         echo "  ❌ FAIL — no entry dated $TODAY in edit_manifest.yaml (fallback grep; PyYAML unavailable)"
         echo "  Run /edit-manifest RECORD to log predicted impact for today's changes."
         FAILED=1
       fi ;;
    2) echo "  ❌ FAIL — manifest is valid YAML but has no entry dated $TODAY"
       echo "  Run /edit-manifest RECORD to log predicted impact for today's changes."
       FAILED=1 ;;
    *) echo "  ❌ FAIL — edit_manifest.yaml did not validate: ${MANIFEST_MSG:-checker exited $MANIFEST_RC}"
       echo "  Usual causes: an unquoted value containing ': ' (quote it), or a list item indented"
       echo "  outside its parent list. Check:"
       echo "    python3 -c \"import yaml;yaml.safe_load(open('tracks/_meta/edit_manifest.yaml'))\""
       FAILED=1 ;;
  esac
fi

# Universal guards (defined above) — confidentiality/privacy boundary, every commit.
run_universal_guards

# ── Count-consistency shift-left (gated on a skills-dir add/remove) ───────────
# The skill/agent count-consistency check historically lived ONLY at the publish
# boundary (scripts/selfcheck.sh via prepublishOnly). But the actor that BREAKS it —
# a commit that adds/removes a skill dir — acts here, at commit time. So a skill-adding
# PR could merge with stale counts undetected until the next publish (fh_signal_2026-06-21:
# the gate-locality gap that PR #111 itself tripped — it added 2 skills without updating
# the 4 count declarations and merged green). Shift the check left: run the
# count-consistency slice WHEN a SKILL.md is added/removed/renamed under plugins/*/skills/.
# Gated so ordinary commits stay cheap; reuses scripts/count_check.sh (same logic selfcheck
# uses — single source, no reinvention). NOT the whole of selfcheck (that is a broad
# publish-readiness check; keep the hook light).
# ── Gate path-coverage anchor (mandatory-pass — blocks) ───────────────────────
# Runs when a gate implementation or its canonical rule is staged. The gate-locality class has
# recurred 4x; each time a path term silently stopped covering a declared asset class, and the miss
# rendered as PASS. This anchor is calibrated on known pairs (reopening the 07-26 hole makes it FAIL,
# verified) so the fix cannot be un-done unnoticed. Blocks, because a gate that no longer gates is
# not a reversible-surface concern — it disables the commit gate itself.
GATE_IMPL=$(echo "$STAGED" \
  | grep -E '(templates/\.git-hooks/pre-commit|templates/regression_guard\.sh|\.claude/rules/fh_4axis_gate\.md|scripts/gate_pathspec_check\.sh)' || true)
# CLAUDE.md carries a canonical asset list that gate_pathspec_check §5 verifies, so editing it must
# RUN the anchor. It is deliberately NOT added to GATE_IMPL: that variable also feeds $LOADBEARING,
# which demands a cross-family acknowledgment line in the marker. Enrolling every CLAUDE.md prose
# edit into cross-family review is a different job from path coverage, and over-blocking is how an
# override becomes muscle memory (CLAUDE.md §Destructive-Op — the same reasoning that keeps the
# session-close check advisory on ordinary pushes). Separate trigger, same anchor.
ASSETLIST_IMPL=$(echo "$STAGED" | grep -E '^CLAUDE\.md$' || true)
if [ -n "$GATE_IMPL" ] || [ -n "$ASSETLIST_IMPL" ]; then
  echo "[Gate] gate implementation or asset list staged — path-coverage known-pair anchor..."
  PSCHECK="$REPO_ROOT/scripts/gate_pathspec_check.sh"
  if [ ! -f "$PSCHECK" ]; then
    echo "  ❌ FAIL — scripts/gate_pathspec_check.sh missing while a gate file is being changed."
    echo "     The anchor is the only mechanical guard on gate path coverage; its absence during a"
    echo "     gate edit is fail-closed, not a skip."
    FAILED=1
  elif bash "$PSCHECK" >/dev/null 2>&1; then
    echo "  ✅ PASS (all known pairs hold)"
  else
    echo "  ❌ FAIL — a gate path term stopped covering a declared asset class:"
    bash "$PSCHECK" 2>&1 | grep -E '^\s+❌' | sed 's/^/    /'
    echo "     (run: bash scripts/gate_pathspec_check.sh)"
    FAILED=1
  fi
fi

# ── Universal-guard scope anchor (mandatory-pass — blocks) ────────────────────
# Sibling of the path-coverage anchor above, guarding the OTHER half of the gate-locality class:
# not "does the pathspec still cover the declared assets?" but "do the surface-scoped guards still
# run on surfaces the 4-axis classifier does not claim?" (the 2026-07-26 hole: the confidentiality
# scan sat below `exit 0  # No FH assets staged`, so a commit staging only non-asset paths skipped
# it). Also pins the credential-shape patterns and their one measured false positive, so a pattern
# edit cannot silently over- or under-block. Triggers on the hook itself or the pattern source.
# Blocks: a confidentiality gate that stops covering the public surface is not a reversible-surface
# concern — it is the publish boundary.
UGUARD_IMPL=$(echo "$STAGED" \
  | grep -E '(templates/\.git-hooks/pre-commit|\.claude/rules/\.public-surface-patterns\.defaults|scripts/universal_guard_check\.sh|scripts/public_surface_scan_files\.sh)' || true)
# The PUSH-side publish guards get the same treatment (2026-07-26). Their anchor is a separate script
# because it drives a different hook, but the wiring rule is identical: a check nobody calls is prose.
PPGUARD_IMPL=$(echo "$STAGED" \
  | grep -E '(templates/\.git-hooks/pre-push|\.claude/rules/\.public-surface-patterns\.defaults|scripts/prepush_guard_check\.sh)' || true)
if [ -n "$PPGUARD_IMPL" ]; then
  echo "[Gate] pre-push publish surface staged — known-pair anchor..."
  PPCHECK="$REPO_ROOT/scripts/prepush_guard_check.sh"
  if [ ! -f "$PPCHECK" ]; then
    echo "  ❌ FAIL — scripts/prepush_guard_check.sh missing while the publish guard is being changed."
    echo "     Fail-closed: the anchor is the only mechanical guard on that guard's behavior."
    FAILED=1
  elif bash "$PPCHECK" >/dev/null 2>&1; then
    echo "  ✅ PASS (all known pairs hold)"
  else
    echo "  ❌ FAIL — a pre-push known pair broke:"
    bash "$PPCHECK" 2>&1 | grep -E '^\s+❌' | sed 's/^/    /'
    echo "     (run: bash scripts/prepush_guard_check.sh)"
    FAILED=1
  fi
fi
if [ -n "$UGUARD_IMPL" ]; then
  echo "[Gate] universal-guard surface staged — scope known-pair anchor..."
  UGCHECK="$REPO_ROOT/scripts/universal_guard_check.sh"
  if [ ! -f "$UGCHECK" ]; then
    echo "  ❌ FAIL — scripts/universal_guard_check.sh missing while the confidentiality guard is"
    echo "     being changed. The anchor is the only mechanical guard on that guard's SCOPE; its"
    echo "     absence during such an edit is fail-closed, not a skip."
    FAILED=1
  elif bash "$UGCHECK" >/dev/null 2>&1; then
    echo "  ✅ PASS (all known pairs hold)"
  else
    echo "  ❌ FAIL — a universal-guard known pair broke:"
    bash "$UGCHECK" 2>&1 | grep -E '^\s+❌' | sed 's/^/    /'
    echo "     (run: bash scripts/universal_guard_check.sh)"
    FAILED=1
  fi
fi

# ── Load-bearing change gate — mechanical anchor for a rule that was PROSE ONLY ───────────────
# CLAUDE.md §Field-Harness Load-Bearing Change Gate specifies degrade-lint → cross-family adversarial
# review → converge, for changes to verdict/gate/irreversible-surface code. Measured 2026-07-26:
# `auto-decorrelation` had ZERO callers anywhere in scripts/ or the hooks (its only appearance outside
# prose was a string inside degrade_direction_scan.sh's own closing echo), and degrade_direction_scan.sh
# was likewise called by nothing. The gate fired that day only because a session chose to read CLAUDE.md
# and run it — exactly the salience-only floor the gate exists to replace elsewhere.
#
# WHAT THIS DOES AND DOES NOT DO — the distinction matters:
#   It does NOT require that a cross-family review happened. A commit is a REVERSIBLE surface, and per
#   the Surface-Class Degrade Invariant the fail-closed leg of this gate belongs at the MERGE boundary,
#   not here; forcing a sidecar dispatch per commit would over-block and train the escape into reflex.
#   It DOES forbid being SILENT about it. The marker must carry a `crossfamily:` line — either naming
#   the engine and its verdict, or explicitly recording the degrade with a reason. Same shape as the
#   existing below-floor-ack: the gate blocks on an unstated answer, never on the answer itself.
#   Non-vacuity is the marker's job; provenance is not (a fabricated line is the weekly-audit residual).
#
# ── TYPED (2026-08-08) — why presence-only was not enough ─────────────────────────────────────
# The original check accepted any non-empty value, so the answer was FREE PROSE. That is how a
# false one got in: a sibling harness's marker recorded `crossfamily: none this round — 도달 불가`
# (unreachable), the claim was later found FALSE, and a subsequent session CITED it as grounds.
# A prose verdict laundered itself into evidence — the failure `[[feedback_typed_verdict_channel]]`
# names. Presence-checking catches silence; it cannot catch a confident wrong answer.
#
# The load-bearing split is that `none` is THREE different states, and merging them hides a
# different thing each time:
#   DEGRADED_SINGLE_FAMILY   probed, nothing CAPABLE reachable   — could not
#   DEGRADED_PANEL_UNUSED    panel reachable, not recruited      — did not   (a choice, not a limit)
#   UNKNOWN                  never probed                        — did not look
# `UNKNOWN` collapsed into `none` renders an unrun probe as a zero finding (not-found-is-not-zero).
# `DEGRADED_PANEL_UNUSED` collapsed into it renders an unused panel as an unavailable one — the
# distinction a sibling harness stated about itself in its own words: "못 한 것이 아니라 안 한 것이다".
#
# Scope is unchanged: still load-bearing-only, still reversible-surface (blocks the unstated answer,
# never demands a dispatch). Only the ANSWER is now typed. Fixtures: scripts/test_marker_crossfamily_lanes.sh
# The degrade lint runs as an ADVISORY pre-screen, per its own doctrine (FP-tolerant, never a solo block).
LOADBEARING=$(printf '%s\n%s\n' "$GATE_IMPL" "$UGUARD_IMPL" | grep -v '^[[:space:]]*$' | sort -u || true)
if [ -n "$LOADBEARING" ]; then
  echo "[Gate] load-bearing change — degrade lint (advisory) + cross-family acknowledgment..."
  DDSCAN="$REPO_ROOT/scripts/degrade_direction_scan.sh"
  if [ -f "$DDSCAN" ]; then
    # SCOPE THE SCAN TO THE STAGED FILES. This used to call `bash "$DDSCAN"` with NO ARGUMENTS,
    # which makes the scanner walk the WHOLE REPOSITORY recursively — and then the very next pipe
    # threw almost all of it away, keeping only the lines naming files we already had in hand.
    # Measured on this repo 2026-08-13: whole-repo 42s / 260 lines · one staged file 0s / 2 lines.
    # A sibling harness measured the same call under load at 2m07s inside a commit that took
    # 7m54s–9m40s, one of which hit a 10-minute tool ceiling and could not complete at all.
    # ★ The structure was inverted: it held the target list, scanned everything, then filtered.
    # WHY THIS IS WORSE THAN A SLOW CHECK: this lane declares itself ADVISORY (see the doctrine line
    # above — FP-tolerant, never a solo block). So a lane that cannot block was consuming most of
    # every load-bearing commit. That trains `--no-verify`, and the SAME hook carries the
    # Destructive-Op gate — one learned bypass disarms an irreversible-surface gate too. CLAUDE.md
    # warns repeatedly that over-blocking trains the override; over-DELAYING gets there just as well.
    # Read into an array rather than relying on word-splitting: $LOADBEARING is newline-separated and
    # an unquoted expansion would also split on spaces. `while read` and not `mapfile`, because
    # mapfile does not exist on bash 3.2 (stock macOS), which this hook still has to run on.
    _dd_targets=()
    while IFS= read -r _p; do
      [ -n "$_p" ] && _dd_targets+=("$_p")
    done <<< "$LOADBEARING"
    # The grep filter is KEPT even though the scan is now scoped: the scanner emits summary lines
    # ("degrade-scan: N smell(s) …") that name no file, and those must not read as findings.
    # 🟥 2026-08-26 fail-open fix (§미정-둘 ⓐ): the old one-liner (`… 2>/dev/null | grep … || true`)
    # collapsed "scanner errored / left files unmeasured" (rc≠0, incl. its own
    # `exit "${_FORCE_NONCLEAN:-0}"` = 안 쟀다 ≠ 깨끗하다) into an empty string, and the else-branch
    # printed "✅ no smell" — the scanner's own defect class, committed by its caller. Capture rc
    # first; rc≠0 still shows any partial findings but never prints the clean checkmark.
    # rc alone cannot separate the states: the scanner exits 2 BOTH for "smells found" (a normal
    # advisory result, :542) and for "files could not be measured" (_FORCE_NONCLEAN=2, :548) —
    # codex cross-family catch on the first draft of this fix, which printed "DID NOT complete"
    # for a completed scan that merely found smells. Discriminate by the output markers.
    _dd_raw=$(bash "$DDSCAN" "${_dd_targets[@]}" 2>/dev/null); _dd_rc=$?
    _dd=$(printf '%s\n' "$_dd_raw" | grep -Ff <(printf '%s\n' "$LOADBEARING") 2>/dev/null | head -5 || true)
    _dd_unm=$(printf '%s\n' "$_dd_raw" | grep -c "COULD NOT BE MEASURED" || true); _dd_unm=${_dd_unm:-0}
    if [ "$_dd_unm" -gt 0 ]; then
      echo "  ⚠️  degrade lint: some file(s) COULD NOT BE MEASURED — NOT-SCANNED is not clean. Advisory: does not block."
    fi
    if [ -n "$_dd" ]; then
      echo "  ⚠️  degrade-direction smell(s) in the staged load-bearing files (advisory):"
      printf '%s\n' "$_dd" | sed 's/^/       /'
      echo "       Each = 'prove this is not default-toward-PASS'. Advisory: does not block."
    elif [ "$_dd_rc" -eq 0 ]; then
      echo "  ✅ degrade lint: no smell in the staged load-bearing files"
    elif [ "$_dd_unm" -eq 0 ]; then
      echo "  ⚠️  degrade lint rc=${_dd_rc} with no staged-file finding — scanner error, or findings outside"
      echo "       the staged set. NOT a clean pass. Advisory: does not block."
    fi
  else
    echo "  ⚠️  degrade lint unavailable (scripts/degrade_direction_scan.sh missing) — advisory leg skipped"
  fi
  MARKER_LB="$EVIDENCE_ROOT/tracks/_meta/.axes_23_passed_${BRANCH_SLUG}_${TODAY}.marker"
  if validate_crossfamily_leg "$MARKER_LB"; then :; else FAILED=1; fi
  if validate_standpoint_leg "$MARKER_LB"; then :; else FAILED=1; fi
  if validate_thirdparty_leg "$MARKER_LB"; then :; else FAILED=1; fi
fi

# ── staged-blob materializer (advisory linters) ───────────────────────────────
# 두 advisory 린터는 파일을 직접 grep/sed 한다. 워킹트리 경로를 넘기면 **커밋되는 내용이 아니라
# 지금 디스크에 있는 내용**을 재게 된다 — 스테이징 후 워킹트리에서 고치면 훅은 깨끗하다 보고하고
# 문제 있는 blob 이 커밋된다(high 리뷰 #6, 낙관 방향 fail-open). 이 훅의 다른 슬라이스는 전부
# `git diff --cached` / `git show :file` 로 추론한다. 여기만 예외였다.
_stage_blob() {   # $1=repo-relative path → echoes temp file holding the STAGED content
  local _p="$1" _t
  _t="$(mktemp "${TMPDIR:-/tmp}/fh_staged.XXXXXX")" || return 1
  if git -C "$REPO_ROOT" show ":$_p" > "$_t" 2>/dev/null; then printf '%s' "$_t"
  else rm -f "$_t"; return 1; fi
}
_STAGE_TMPS=()
_STAGE_MAP=()      # "tmpfile=repopath" — 보고를 사람이 읽을 수 있게 되돌리는 데 쓴다
# 히트를 익명 mktemp 경로로 인쇄하면, 그 파일은 _stage_cleanup 이 곧 지워서 **읽는 순간 이미 없다**.
# 대상이 둘 이상이면 어느 파일 얘긴지 귀속조차 안 된다. advisory 의 목적은 사람을 그 줄로 보내는 것이다.
_unmap() { local _o="$1" _m; for _m in "${_STAGE_MAP[@]:-}"; do
    [ -n "$_m" ] && _o="${_o//${_m%%=*}/${_m#*=}}"; done; printf '%s' "$_o"; }
_stage_cleanup() { [ "${#_STAGE_TMPS[@]}" -gt 0 ] && rm -f "${_STAGE_TMPS[@]}" 2>/dev/null; }

# ── Judgment-circuit lint (advisory — the PROMPT-layer sibling of the degrade lint above) ─────
# Same proposition, different corpus: degrade_direction_scan reads .py/.sh verdict code and skips
# .md by construction, so identity/circuit DOCUMENTS had no instrument at all. Measured basis
# (105 runs, 2026-08-07~08): role-assignment ("너는 ~이다") is a net loss on the weak tier and
# intensity orders ("꼼꼼히") multiply phantoms 3.1x — both are prose granting discretion with an
# unconstrained degrade direction, the doctrine's prompt-layer form.
#
# Trigger is an EXPLICIT REGISTRY, not a guess: `.claude/judgment_circuits.txt` (one path per line).
# Which documents are judgment circuits is a decision someone makes, not something a glob infers —
# an inferred trigger here would fire on every .md and get itself disabled.
# ADVISORY by construction: grep-heuristic, false positives expected, never blocks a commit.
JC_REG="$REPO_ROOT/.claude/judgment_circuits.txt"
JC_LINT="$REPO_ROOT/scripts/judgment_circuit_lint.sh"
if [ -f "$JC_REG" ] && [ -f "$JC_LINT" ]; then
  # Array, not a space-joined string: a tracked path CAN contain a space, and the string form
  # silently splits `docs/first known.md` into two nonexistent args (cross-family, 2026-08-08).
  _jc_targets=()
  while IFS= read -r _jc_line; do
    case "$_jc_line" in ''|'#'*) continue ;; esac
    if printf '%s\n' "$STAGED" | grep -qxF "$_jc_line" 2>/dev/null; then
      _b="$(_stage_blob "$_jc_line")" && { _jc_targets+=("$_b"); _STAGE_TMPS+=("$_b"); _STAGE_MAP+=("$_b=$_jc_line"); }
    fi
  done < "$JC_REG"
  if [ "${#_jc_targets[@]}" -gt 0 ]; then
    echo "[Gate] registered judgment-circuit doc staged — circuit lint (advisory)..."
    _jc_out="$(bash "$JC_LINT" "${_jc_targets[@]}" 2>&1)"; _jc_rc=$?
    printf '%s\n' "$(_unmap "$_jc_out")" | sed 's/^/  /'
    echo "  (경로는 스테이징 blob 을 레포 경로로 되돌려 표기했다 — 줄번호는 staged 내용 기준)"
    # exit 10 = the scanner's own HARNESS-ERROR (bad input / unreadable target). Neither PASS nor
    # FAIL — surfacing it is the whole point of having a typed exit; swallowing it would render a
    # dead instrument as a clean run.
    [ "$_jc_rc" = "10" ] && echo "  🟥 circuit lint HARNESS-ERROR (rc=10) — instrument did not measure"
    echo "  (advisory — does not block. A hit means 'prove this is a circuit, not a persona'.)"
  fi
fi

# ── Portability lint (advisory — the SHELL-layer sibling of the two lints above) ──────────────
# 🟥 WHY THIS IS HERE AND NOT DEFERRED. `scripts/portability_lint.sh` shipped in the immediately
# preceding commit with its self-test wired and NO real-run caller, and `fh_4axis_gate.md:232` says
# exactly what that is:
#     "If you build a checker and defer its caller, you shipped prose. The gate below trims what
#      you *attached*; it never licenses shipping something unreachable."
# The standpoint review (2026-08-16, isolated agent reading the canon COLD before the diff) caught
# it — and caught that the same commit CITED that section as its bundling justification while
# violating it in the other half. Two other adversarial legs and a cross-family leg had all missed
# this, because all three were handed the author's framing; the standpoint leg was handed the canon.
# That is the axis earning its place, and this block is the repair.
#
# Placement is also not free choice: weekly_audit_2026-08-16 🟧-3 prescribed "pre-commit 에 패턴
# denylist 한 장 … 새 러너가 아니라 degrade_direction_scan 옆 한 레인" — the runner already exists,
# so this lane is the half that was missing from the prescription, not a second mechanism.
#
# STAGED BLOBS, not working-tree paths — same reason the two lints above use _stage_blob: measuring
# the disk after `git add` reports clean while a different blob commits (optimistic fail-open).
# ADVISORY by construction: FP-tolerant pre-screen, never blocks. Over-blocking here would train
# `--no-verify`, which disarms the irreversible Destructive-Op gate living in the sibling hook.
PL_LINT="$REPO_ROOT/scripts/portability_lint.sh"
if [ -f "$PL_LINT" ]; then
  _pl_targets=()
  while IFS= read -r _pl_line; do
    [ -n "$_pl_line" ] || continue
    # 확장자 없는 셸 파일도 대상이다(.git-hooks/pre-commit·pre-push, bin/fh-*) — 이 레포에서
    # BSD/GNU 결함이 가장 비싼 파일들이 바로 그 형태이고, 초판 스캔이 그래서 놓쳤다.
    case "$_pl_line" in
      *.sh) : ;;
      templates/.git-hooks/*|bin/*) : ;;
      *) continue ;;
    esac
    _b="$(_stage_blob "$_pl_line")" || continue
    # 비-셸(bin/*.js 등)은 shebang 으로 걸러낸다. 확장자로 거르면 확장자 없는 훅이 다시 빠진다.
    case "$_pl_line" in
      *.sh) : ;;
      *) head -1 "$_b" 2>/dev/null | grep -qE '^#!.*\b(ba)?sh' || { rm -f "$_b"; continue; } ;;
    esac
    _pl_targets+=("$_b"); _STAGE_TMPS+=("$_b"); _STAGE_MAP+=("$_b=$_pl_line")
  done < <(printf '%s\n' "$STAGED")
  if [ "${#_pl_targets[@]}" -gt 0 ]; then
    echo "[Gate] shell file staged — portability lint (advisory)..."
    _pl_out="$(bash "$PL_LINT" "${_pl_targets[@]}" 2>&1)"; _pl_rc=$?
    printf '%s\n' "$(_unmap "$_pl_out")" | sed 's/^/  /'
    # rc=10 은 이 스캐너 자신의 HARNESS-ERROR 다. PASS 도 FAIL 도 아니며, 삼키면 죽은 계기가
    # 깨끗한 실행으로 렌더된다 — 이 커밋 전체가 고치고 있는 그 클래스다.
    [ "$_pl_rc" = "10" ] && echo "  🟥 portability lint HARNESS-ERROR (rc=10) — instrument did not measure"
    echo "  (advisory — does not block. 알고 쓰는 줄엔 \`# portability-noqa: <사유>\`.)"
  fi
fi

# ── Pre-ship read reminder (advisory) ─────────────────────────────────────────
# WHY (2026-08-17, ⓓ3자대면 첫 실사용의 답습분). A third harness (gstack), wearing its own
# `/plan-devex-review` persona, reviewed a peer harness's change and asked a question neither FH
# nor that harness asks: **who opens this file, and what do they see in the first eight lines?**
# Its finding: a human-facing report had been shipped to three surfaces where the body was 3 lines
# under 21 lines of fixed template, the answer to "did it pass?" was buried in the 4th bullet, and
# the last impression was "readers may skip this". The delta that shipped it was correct code.
#
# FH reached the same place independently the same day from a different axis — "readability is
# measured only by rendering it or by a person; a static scan catches what is ABSENT, never what is
# UNREADABLE", with 12 findings and 0 caught by static scanners. **Two independent arrivals** is
# why this was the one item the internalization filter accepted out of four
# (`harness_incubator_doctrine.md` §4-c).
#
# 🟥 WHAT THIS IS NOT. It does not judge readability, and it never blocks. Judging "is this
# readable" is exactly the frozen-judgment class §Mechanization Boundary reserves for people, and a
# vocabulary test for "human-facing document" over-blocked a legitimate case the same day it was
# first tried. The trigger is therefore a PATH SET (what actually ships to readers), not a
# vocabulary guess, and its whole output is one line of salience.
# ── STAGED ≠ WORKTREE — «네가 검증한 트리»와 «커밋되는 트리»가 갈렸다 (advisory) ──────
# WHY. 2026-08-29/30 하루에 네 번 났다, 매번 다른 계기가 잡았다 = 운이었다:
#   ⑴ pathspec 실패를 무시하고 검증 결과를 커밋에 귀속 ⑵ `git add` 뒤에 파일을 더 편집
#   ⑶ 검출기가 인덱스를 읽는데 나는 워킹트리에서 돌림(lane_runner_check)
#   ⑷ 래칫이 스테이징 전에는 새 배선을 못 봄
# 공통 형태 하나: **검사기가 본 바이트와 커밋되는 바이트가 다르다.** 그런데 그 사실은
# 커밋 시점에 한 줄로 계산된다 — `git diff`(워킹트리 vs 인덱스) ∩ `git diff --cached`.
#
# 🟥 WHAT THIS IS NOT — 판정이 아니라 채널이다(§Mechanization Boundary). 「이 편집이 검증을
# 무효화하는가」는 안 묻는다; 그건 사람 몫이고 부분 스테이징은 정당한 관행이다. 묻는 것은
# 오직 **«두 바이트열이 갈렸다»** 라는 기록의 성질이고, 그래서 절대 차단하지 않는다.
# 차단했다면 hunk 단위 스테이징마다 막혀 `--no-verify` 를 훈련시켰을 것이고, 그건 같은 훅
# 안의 Destructive-Op 게이트를 무장해제한다.
#
# 순수 함수인 이유: 레인이 저장소 없이 구동해야 한다. 입력은 두 경로 목록, 출력은 교집합.
staged_worktree_drift() {   # $1 = staged paths (LF) · $2 = worktree-modified paths (LF)
  # 🟥 빈 입력에서 무엇이 나오나 — `comm` 은 빈 파일 둘에서 빈 결과를 낸다(= 소견 없음).
  # 그 방향이 옳다: 「비교할 게 없다」와 「갈린 게 없다」는 여기서 같은 처방(아무것도 안 함)이라
  # 접어도 손실이 없다. 다른 필드에서 이 접기가 결함인 것과 구분해서 적어 둔다.
  _d_a=$(printf '%s\n' "${1:-}" | sed '/^$/d' | LC_ALL=C sort -u)
  _d_b=$(printf '%s\n' "${2:-}" | sed '/^$/d' | LC_ALL=C sort -u)
  LC_ALL=C comm -12 <(printf '%s\n' "$_d_a") <(printf '%s\n' "$_d_b")
}
_WT_MOD=$(git diff --name-only 2>/dev/null || true)
_DRIFT=$(staged_worktree_drift "$STAGED" "$_WT_MOD")
if [ -n "$_DRIFT" ]; then
  echo "[Gate] staged ≠ worktree — 검증한 바이트와 커밋되는 바이트가 다르다 (advisory)..."
  printf '%s\n' "$_DRIFT" | sed 's/^/       /'
  echo "  ⚠️  이 파일들은 **스테이징된 뒤에 또 편집됐다.** 방금 돌린 검사기가 워킹트리를"
  echo "     읽었다면 그 결과는 이 커밋을 서술하지 않는다."
  echo "     담으려면:  git add <경로>   ·   빼려면:  git restore <경로>"
  echo "  (advisory — 막지 않는다. 부분 스테이징은 정당하고, 여기서 막으면 --no-verify 를 훈련시킨다.)"
fi

_PRESHIP=$(printf '%s\n' "$STAGED" | grep -E '^(README([._a-z]*)?\.md|CHEATSHEET\.md|CATALOG\.md|docs/[^/]*\.md|templates/[^/]*\.md)$' || true)
if [ -n "$_PRESHIP" ]; then
  echo "[Gate] reader-facing surface staged — pre-ship read reminder (advisory)..."
  printf '%s\n' "$_PRESHIP" | sed 's/^/       /'
  echo "  ⚠️  이 파일들은 «사람이 연다». 나가기 전에 한 번 «독자로서» 읽었나?"
  echo "     첫 8줄에 결론이 있나 · 본문이 템플릿에 안 덮이나 · 마지막 인상이 무엇인가"
  echo "     렌즈가 이미 있다: /sim-conductor A-1 (beginner cold-read) 또는 직접 렌더해서 읽기"
  echo "  (advisory — 막지 않는다. 「읽었는가」는 이 훅이 보증할 수 없고 보증하려 들지도 않는다.)"
fi

# ── Novelty-claim ledger (advisory) ───────────────────────────────────────────
# "Ask the world before asserting novelty" is an INTENT trigger — un-hookable, like the
# repo-go-public surface. So this does not force the asking; it makes NOT having asked visible
# at commit time (shift-left ledger, not one more rule: the rule already existed and still failed).
# Direction matters: defect claims (FAIL) have long required source-grounding while novelty/strength
# claims (PASS) passed unchecked — the optimistic direction is the one that leaks. And an absence
# claim is structurally worse: with nothing cited, phantom-detection cannot engage at all.
# Scope = the same substantive doc surface the 4-axis carve-out already treats as claim-bearing.
NC_CHECK="$REPO_ROOT/scripts/novelty_claim_check.sh"
if [ -f "$NC_CHECK" ]; then
  _nc_targets=()
  while IFS= read -r _nc_line; do
    _b="$(_stage_blob "$_nc_line")" && { _nc_targets+=("$_b"); _STAGE_TMPS+=("$_b"); _STAGE_MAP+=("$_b=$_nc_line"); }
  done < <(printf '%s\n' "$STAGED" | grep -E '^(knowledge/|docs/).*\.md$' || true)
  if [ "${#_nc_targets[@]}" -gt 0 ]; then
    _nc_raw=$(bash "$NC_CHECK" "${_nc_targets[@]}" 2>&1); _nc_rc=$?
    # Same typed-exit rule as above: rc=10 is a dead instrument, not a clean corpus.
    [ "$_nc_rc" = "10" ] && echo "  🟥 novelty check HARNESS-ERROR (rc=10) — instrument did not measure"
    _nc_raw="$(_unmap "$_nc_raw")"
    # 부분 미스캔 경고를 **같이** 건진다. 호출부가 `🟥 무앵커` 만 grep 해서, 스크립트가 옳게 낸
    # "전수 스캔이 아니다" 를 통째로 버렸다 — 수리가 스크립트 안으로 옮겨졌을 뿐 이 표면에선
    # 미스캔 문서가 여전히 깨끗한 문서와 구분 불가였다(high 재리뷰 #5).
    _nc_warn=$(printf '%s\n' "$_nc_raw" | grep -F '전수 스캔이 아니다' | head -3 || true)
    if [ -n "$_nc_warn" ]; then
      echo "[Gate] novelty check: 부분 미스캔 (advisory):"
      printf '%s\n' "$_nc_warn" | sed 's/^/  /'
      echo "  → 이 문서는 전수 스캔이 아니다. 제외된 줄에 실제 주장이 있을 수 있다."
    fi
    _nc_out=$(printf '%s\n' "$_nc_raw" | grep -E '🟥 무앵커' | head -5 || true)
    if [ -n "$_nc_out" ]; then
      # 🟥 2026-08-30: advisory → **차단**. 운영자 결정("남은 다리 가기 위해서는 강행해야지").
      #    켜기 전에 **과차단률을 쟀다**: 대상 표면 전수(knowledge/·docs/ 69개)에서 무앵커 **0건**.
      #    즉 이 게이트를 켜는 비용이 현재 0 이다 — 아무도 안 막힌다. 재기 전에 켰으면 그건
      #    강행이 아니라 도박이었다. 과차단은 `--no-verify` 를 훈련시키고 그건 **같은 훅의**
      #    Destructive-Op 게이트를 무장해제한다(CLAUDE.md 가 반복해서 경고하는 그 축).
      #    그래서 집안 관행대로 **명시적·기록되는 override** 를 같이 둔다.
      if [ "${FH_NOVELTY_OK:-}" = "1" ]; then
        echo "[Gate] novelty/absence claim without an external anchor — OVERRIDDEN (FH_NOVELTY_OK=1):"
        printf '%s\n' "$_nc_out" | sed 's/^/  /'
        # 🟥 cross-family(gpt-5.5, 2026-08-30)가 잡았다: 초판은 «기록에 남는다» 고 «말만» 하고
        #    원장 append 가 없었다 — 터미널 로그가 사라지면 무엇을 우회했는지 감사 불가.
        #    집안 관행(`.psa_override_log`)과 같은 형태로 실제로 남긴다.
        mkdir -p "$EVIDENCE_ROOT/tracks/_meta" 2>/dev/null || true
        { printf '%s FH_NOVELTY_OK=1 branch=%s\n' "$(date '+%Y-%m-%dT%H:%M:%S')" "${BRANCH:-?}"
          printf '%s\n' "$_nc_out" | sed 's/^/    /'
        } >> "$EVIDENCE_ROOT/tracks/_meta/.novelty_override_log" 2>/dev/null || true
      else
        echo "[Gate] 🚫 novelty/absence claim without an external anchor:"
        printf '%s\n' "$_nc_out" | sed 's/^/  /'
        echo "  각 줄 = '세상에 대해 단언했는데 세상에 안 물어봤다'."
        echo "  고치는 법: 그 주장 ±6줄 안에 외부 앵커를 둬라 —"
        echo "             URL · arXiv/DOI · WebSearch/WebFetch · 출처 · 원문 확인 · 서베이"
        echo "  🟥 앵커 존재가 진위를 보증하지 않는다(그건 phantom-quench). 여기서 막는 것은"
        echo "     '안 물어본 채 단언한 것' 하나다."
        echo "  강행: FH_NOVELTY_OK=1 git commit …   (tracks/_meta/.novelty_override_log 에 append)"
        echo "  ⚠️ GUI git 클라이언트에서는 env 주입이 번거로워 --no-verify 로 새기 쉽다 —"
        echo "     그건 같은 훅의 Destructive-Op 게이트까지 끈다. 앵커를 다는 쪽이 싸다."
        FAILED=1
      fi
    fi
  fi
fi
_stage_cleanup

# SKILL.md ONLY here, deliberately (2026-07-26): the 07-26 sweep widened `SKILL\.md` →
# `SKILL(_detail)?\.md` in the HEAVY and doc-coupling terms above, but NOT here. This slice asks
# "did the number of SKILLS change?" — a detail file is a referenced companion, not a skill, so
# adding one must not trip a skill-count check. Do not "fix" this for consistency with the others.
SKILL_CHANGE=$(git diff --cached --name-status --diff-filter=ADR 2>/dev/null \
  | grep -E 'plugins/[^/]+/skills/[^/]+/SKILL\.md' || true)
if [ -n "$SKILL_CHANGE" ]; then
  echo "[Count] skills-dir add/remove staged — count-consistency check..."
  CCHECK="$REPO_ROOT/scripts/count_check.sh"
  CCOUT="$(mktemp 2>/dev/null || echo /tmp/fh_count_check.$$)"
  if [ ! -f "$CCHECK" ]; then
    echo "  ⚠️  SKIP — scripts/count_check.sh not found"
  elif bash "$CCHECK" --staged >"$CCOUT" 2>&1; then
    echo "  ✅ PASS (declared counts match the staged index)"
  else
    echo "  ❌ FAIL — declared skill/agent counts drift from the filesystem:"
    grep -E '^(FAIL|COUNT-CHECK)' "$CCOUT" | sed 's/^/     /'
    echo "     A skill was added/removed without updating every count declaration."
    echo "     Update: plugin.json · marketplace.json · README header · local_fh_context.md"
    echo "     (run: bash scripts/count_check.sh — same check selfcheck/publish runs)"
    FAILED=1
  fi
  rm -f "$CCOUT"
fi

# ── Detail-pointer resolution (staged markdown) ──────────────────────────────
# skill-splitter governance-semantic split relocates execution-detail to on-demand files,
# linked by imperative `**Detail**: See `<path> §Section`` pointers. phantom-quench (Axis 3)
# verifies these only WHEN RUN — the marker merely ATTESTS it ran, so a §header rename or file
# move silently breaks a pointer between manual runs (B#3, fh_signal 2026-06-23 CLAUDE.md split).
# Make the pointer-resolution slice MECHANICAL at commit: every Detail pointer in a staged .md
# must resolve to a `## §Section` header in a referenced *.md (sibling- or repo-relative). Bound
# to `**Detail**: See` blocks → no false-positive on general §cross-refs.
MD_STAGED=$(git -c core.quotePath=false diff --cached --name-only --no-renames --diff-filter=ACMR 2>/dev/null | grep -E '\.md$' || true)
if [ -n "$MD_STAGED" ]; then
  echo "[Pointers] Detail-pointer resolution (staged markdown)..."
  PTR_FAIL=0; PTR_CHECKED=0
  for f in $MD_STAGED; do
    # Read the STAGED blob, not the working-tree file. diff-filter=ACMR already guarantees $f
    # exists in the index, but a file can be staged and then deleted or further-edited on disk
    # before commit — `git commit` commits the INDEX, so `[ -f "$REPO_ROOT/$f" ] || continue`
    # here silently dropped the check on a committed broken pointer (deleted-on-disk case) and,
    # for a further-edited case, would have checked the wrong bytes entirely. Reproduced 2026-09-03:
    # stage a .md with a broken `**Detail**: See §X` pointer, `rm` it from disk (index untouched),
    # commit — PTR_CHECKED stayed 0 and the broken pointer landed silently.
    _md_blob="$(git -C "$REPO_ROOT" show ":$f" 2>/dev/null)" || {
      echo "  ❌ $f: staged (diff-filter=ACMR) but unreadable from the index — cannot verify its Detail pointers"
      PTR_FAIL=1; continue
    }
    fdir="$(cd "$REPO_ROOT/$(dirname "$f")" 2>/dev/null && pwd)"
    [ -n "$fdir" ] || fdir="$REPO_ROOT/$(dirname "$f")"
    blocks="$(printf '%s\n' "$_md_blob" | awk '
      inblk && /^>/ { blk=blk" "$0; next }
      inblk { print blk; inblk=0; blk="" }
      /\*\*Detail\*\*: See/ { blk=$0; inblk=1; next }
      END { if(inblk) print blk }
    ')"
    [ -z "$blocks" ] && continue
    while IFS= read -r block; do
      [ -z "$block" ] && continue
      bpaths="$(printf '%s\n' "$block" | grep -oE '[A-Za-z0-9_./-]+\.md' | sort -u)"
      bsecs="$(printf '%s\n' "$block" | grep -oE '§[A-Za-z0-9_-]+' | sed 's/§//' | sort -u)"
      [ -z "$bsecs" ] && continue
      for s in $bsecs; do
        PTR_CHECKED=$((PTR_CHECKED+1)); hit=0
        for p in $bpaths; do
          for cand in "$fdir/$p" "$REPO_ROOT/$p" "$p"; do
            if [ -f "$cand" ] && grep -qE "^## §${s}([[:space:]]|\$)" "$cand"; then hit=1; break; fi
          done
          [ "$hit" = 1 ] && break
        done
        if [ "$hit" = 0 ]; then
          echo "  ❌ $f: Detail pointer §$s has no '## §$s' header in [$(echo $bpaths)]"
          PTR_FAIL=1
        fi
      done
    done <<PTR_EOF
$blocks
PTR_EOF
  done
  if [ "$PTR_FAIL" -eq 1 ]; then
    echo "     A relocated detail section was renamed/moved without updating its pointer."
    echo "     Fix the §header or the pointer so every '**Detail**: See ... §X' resolves."
    FAILED=1
  else
    echo "  ✅ PASS ($PTR_CHECKED pointer(s) resolve)"
  fi
fi

# ── 상주 유입 게이트 (mandatory-pass — blocks) ────────────────────────────────
# 왜 여기 있나 (2026-08-20 실측): 감량은 유출이 아니라 **유입** 문제다.
#   CLAUDE.md 23,839(05-26) → 70,758(07-20 «11.9% 감량» 직후) → 144,138(08-20)
#   07-20 이후 31일 +73,380 = 약 2,367 bytes/일. 같은 기간 어블레이션 CUT 판정 **0건**.
#   최대 감량 후보 절(22k)조차 유입 9일치 ⇒ 나가는 문만 당기면 구조적으로 진다.
# 검사하는 것은 **기록의 속성**이지 결론이 아니다(§Mechanization Boundary):
# 「이 문단이 상주할 가치가 있나」를 판정하지 않고 「저자가 유형을 선언했나」만 본다.
# Anchor: scripts/test_residency_admission_lanes.sh (레인 10 · 컨트롤 2 · 가드 7 전수 되돌림 격리)
RESID="$REPO_ROOT/scripts/residency_admission_check.sh"
if [ -f "$RESID" ]; then
  echo "[Residency] 상주 유입 게이트..."
  resid_out=$(bash "$RESID" "$REPO_ROOT" 2>&1); resid_rc=$?
  printf '%s\n' "$resid_out" | sed 's/^/  /'
  if [ "$resid_rc" -ne 0 ]; then FAILED=1; fi
else
  echo "  ⚠️  상주 유입 게이트 미설치 (scripts/residency_admission_check.sh) — skipped, not passed"
fi

# ── 수용-근거 한 줄 (보고 전용 — 절대 안 막는다) ────────────────────────────
# WHY (2026-09-06, digest 답습 · arXiv:2609.04167 SWE-Gate): 그 논문은 «기능 테스트 통과»와
# «리뷰 제약 충족»을 **따로** 채점하라고 처방한다 — 실측에서 기능 테스트를 통과한 수리 644 건 중
# **221 건(34%)** 이 실제 PR 리뷰 코멘트에서 뽑은 제약을 어겼다. 한 숫자로 합치면 그 34% 가 안 보인다.
# FH 도 같은 형태였다: 이 훅은 Axis 1(회귀)·레인·매니페스트가 통과하면 «ALL AXES PASSED» 라는
# **합성 판정 하나**를 찍었고, 그동안 마커의 `crossfamily: DEGRADED_PANEL_UNUSED` 같은 값은
# 출력 어디에도 안 나왔다. 통과는 통과인데 «무엇이 기록됐나»는 안 보이는 것이다.
# 그래서 판정을 둘로 찍는다 — 위는 «초록인가», 아래는 «무엇이 기록됐나».
# 🟥 §Mechanization Boundary: 이건 **채널**이다. 기록의 속성(어떤 값이 적혔나)만 찍고,
#    그 값이 참인지는 판정하지 않으며 아무것도 막지 않는다. 차단은 기존 다리들이 그대로 한다.
# Anchor: scripts/test_gate_two_verdicts_lanes.sh
_acceptance_evidence_line() {   # $1 = marker path (비었거나 없으면 n/a)
  local m="${1:-}" f v out=""
  if [ -z "$m" ] || [ ! -f "$m" ]; then
    printf 'acceptance-evidence: n/a (경량 게이트 — 이 경로는 마커를 읽지 않는다)\n'
    return 0
  fi
  for f in crossfamily standpoint thirdparty oracle; do
    v=$(sed -n "s/^[[:space:]]*${f}:[[:space:]]*\([^[:space:]][^[:space:]]*\).*/\1/p" "$m" | head -1)
    # 🟥 첫 실사용(이 훅의 첫 두-판정 커밋)이 즉시 잡은 것: 값이 `checked(확인한 것 = …)` 처럼
    #    **괄호 안에 공백**을 담으면 첫 토큰이 `checked(확인한` 으로 잘려, 값도 아니고 문법도
    #    깨져 보이는 문자열이 찍힌다. 반면 `panel(codex,gemini)` · `tier2(qasp)` 는 괄호가 값의
    #    일부라 살려야 한다. 판별자는 **괄호가 닫혔는가** 하나뿐이다 — 안 닫혔으면 여는 괄호
    #    앞까지가 값이다. (마커 defeater 에 «관측될 것»으로 적어 둔 그 줄이 그대로 발화했다.)
    case "$v" in
      *'('*')'*) : ;;                      # panel(codex,gemini) — 괄호가 값의 일부
      *'('*)     v="${v%%(*}" ;;           # checked(확인한 … — 잘린 괄호는 값이 아니다
    esac
    [ -n "$v" ] || v='(없음)'
    out="${out}${out:+ · }${f}=${v}"
  done
  printf 'acceptance-evidence: %s\n' "$out"
}


# ── Verdict ───────────────────────────────────────────────────────────────────
echo ""
echo "══════════════════════════════════════════════"
if [ "$FAILED" -eq 1 ]; then
  echo " 🚫 BLOCKED — resolve failing axes, then retry"
  echo " See CLAUDE.md §FH Improvement 4-Axis Auto-Gate"
  echo "══════════════════════════════════════════════"
  echo ""
  exit 1
fi

echo " ✅ ALL AXES PASSED — commit allowed"
echo "    functional         : PASS (Axis 1 회귀 · 레인 · 매니페스트)"
echo "    $(_acceptance_evidence_line "${MARKER:-}")"
echo "    🟥 둘은 한 판정이 아니다 — 위는 «초록인가», 아래는 «무엇이 기록됐나»다."
echo "       형식이 통과했다는 뜻이지 그 값이 참이라는 뜻이 아니다 (arXiv:2609.04167)."
echo "══════════════════════════════════════════════"
echo ""
exit 0
