#!/usr/bin/env bash
# package_coverage_check.sh — a shipped document must not point at a file the package omits.
#
# WHY (measured 2026-07-28): the npm tarball shipped CLAUDE.md, README, CATALOG and the knowledge/
# base while omitting much of what they instruct the reader to open — 35 distinct paths existed in
# the repo, were named by a shipped document, and were absent from the tarball. `CLAUDE.md` told
# consumers to run `templates/predelete_check.sh` before a destructive op; that file did not ship.
# A gate you are told to run and cannot run is worse than one you were never told about.
#
# This is the ANTI-REGROWTH instrument for that class. Closing the 35 once is worth little: the set
# regrows every time a doc gains a reference or files[] gains an entry. So the check is mechanical
# and the exceptions are ENUMERATED, never implicit.
#
# SOURCE-TREE ONLY. Inside an installed package the un-shipped files are legitimately absent and
# package.json's files[] may not even be present in a comparable form, so the check self-skips.
#
# Usage:  bash scripts/package_coverage_check.sh
# Exit:   0 = every referenced path is either shipped or explicitly accepted; 1 = a new phantom.
set -uo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT" || exit 1

# ── Accepted-absent, with the reason each one is NOT a defect ────────────────────────────────
# Adding a line here is a decision, not a silencer: each entry states why shipping it would be
# wrong. If you cannot write that sentence, the file probably belongs in files[].
#
#   .claude/registry/LOCAL_SKILL_REGISTRY.md — per-environment, generated by the registry scan.
#       Its consumers probe it with `ls … 2>/dev/null` and handle absence; shipping one machine's
#       registry would hand every consumer a false map of skills they do not have.
#   .claude/regression/probes.md            — per-environment prompt-regression baselines. The
#       skill explicitly prints NO_CUSTOM_PROBES when absent. Shipping FH's baselines would make
#       a consumer's regression run compare against someone else's harness.
#   scripts/sync-to-be.sh                   — operator-private companion-store sync. Never ships.
#       (The reference that flags it is a TEST FIXTURE in prepush_guard_check.sh which *creates*
#        a file of that name to exercise the LOW allowlist — not a pointer to this file at all.)
#   scripts/sync_guard_check.sh             — anchor for that same operator-private mirror sync;
#       no shipped hook invokes it.
#   scripts/sync_to_be_lanes.sh             — forward-path lane suite for sync-to-be.sh, itself
#       ACCEPTED_ABSENT above; added 2026-08-14, pmh-dev#69.
ACCEPTED_ABSENT=(
  # 🟥 2026-09-05 — outbound 가드와 그 레인은 **이 목록에서 나갔다(= 이제 출하한다).**
  # 종전 사유는 «override 층이 없으면 fail-closed 라 신선 설치를 100% 차단한다» 였는데,
  # 그 문장은 **두 가지를 뭉쳤다**: 가드가 fail-closed 인 것은 맞지만 **신선 설치에서 그 가드를
  # 부르는 것이 아무것도 없었다**(호출부 0개 — 그게 이 파일의 존재 이유였다). selfcheck 가
  # 도는 것은 가드가 아니라 **레인**이고, 레인은 픽스처 주입점으로만 돈다.
  # 실제로 막고 있던 것은 다른 것이었고, 실측으로 잡았다: 레인 L4 가 로그 경로를 박아 둬서
  # `tracks/` 가 없는 트리(= npm 설치본)에서 «+1» 단언이 0 이 되어 **빨개졌다.** 그 한 줄을
  # 주입점으로 바꾸자(OUTBOUND_QUERY_LOG) 소비자 형태 트리에서 11/11 초록이다.
  # 이제 출하하는 이유: `scripts/outbound_query_hook.sh`(PreToolUse WebSearch|WebFetch)가
  # 소비자에게 나가는데, 그 훅은 **WebSearch/WebFetch 만** 덮는다. Bash 로 나가는 질의
  # (curl·외부 CLI)는 사람이 손으로 이 CLI 를 부르는 것 말고 커버가 없다 — 훅만 내보내고
  # 가드를 빼면 소비자에게 «반쪽만» 준다.
  # ⚠️ 정직한 성질 하나: override 층이 없는 소비자가 이 CLI 를 직접 부르면 exit 3(미측정)이다.
  #    쓰려면 `.claude/rules/.public-surface-patterns` 를 만들어야 한다. 훅 쪽은 그 상태에서
  #    advisory + UNCALIBRATED 로 degrade 하므로 **소비자 세션이 막히지는 않는다.**
  # 🟥 2026-09-01 — 아래 여섯은 «출하 문서가 이름을 대지만 selfcheck 가 실행하지 않는» 것들이다.
  #    판별은 실측이다: `bash <경로>` 형태의 호출을 selfcheck 에서 센 결과 watermark 만 3회이고
  #    나머지는 0회다 — 그래서 watermark 는 files[] 로 갔고 이 여섯은 여기 남는다.
  #    🟥 소비자가 «필요로 하지 않는다»가 아니라 «이 레포의 연구·감사 계기다»가 이유다.
  #    출하하면 소비자 트리에 안 도는 스크립트가 늘고, 그게 orphan 스캔의 소음이 된다.
  "scripts/stray_path_scan.sh"          # 이 레포 경로 위생 스캐너 — 소비자 트리 구조가 다르다
  # 🟥 2026-09-05 — docs/map 은 레포·Pages 표면이다(렌더 산출물 ~4 MB + «노드 = 실재 경로» 계약이
  #    .github/·tests/ 등 출하하지 않는 경로를 가리킨다). npm 에는 README 의 포인터만 나간다 —
  #    지도를 출하하면 그 레인이 소비자 트리에서 «부재」로 빨개지고, 그건 결함이 아니라 표면 차이다.
  "docs/map/FH_MAP.md"                  # 리더용 지도 산문 — 레포/Pages 에서 읽는다
  "docs/map/fh_assets.architecture.json" # 지도 소스(노드 = 레포 경로 계약) — 이 레포 계기의 입력
  "scripts/test_fh_map_paths_lanes.sh"  # 그 계약의 레인 — 이 레포에서만 돈다
  "scripts/test_stray_path_lanes.sh"    # 위 레인
  "scripts/test_fixture_guard_lanes.sh" # 픽스처 가드 레인 — 가드 자체(fixture_guard_lib)는 출하한다
  "scripts/test_marker_first_use_lanes.sh"  # 4축 마커 first-use 레인 — 이 레포 마커 규약 전용
  "scripts/round/fallback_reach_probe.sh"   # 맥락유지 회차 계기 — 소비자는 회차를 안 돌린다
  # 🟥 round/ 계기 4종과 그 앵커 — 위 fallback_reach_probe 와 **같은 사유**다. 소비자는 회차를
  #    안 돌리고, 넷은 이 레포의 측정 원장(사전등록·봉인·적격)을 전제로 한다. 앵커도 같이 안
  #    나간다: 주체가 없는 트리에서 이 스위트는 첫 가드에서 exit 2(계기 오류)로 죽고, 그건
  #    신선 설치를 적색으로 만드는 형태다. selfcheck 짝 표는 주체 부재 팔이 먼저 발화해 SKIP 한다.
  "scripts/round/delta_guard.sh"
  "scripts/round/target_pin.sh"
  "scripts/round/instrument_manifest.sh"
  "scripts/round/eligcheck_qset.sh"
  "scripts/round/gatecheck_qset.sh"          # 같은 이유 — 회차 개시 게이트, 소비자 표면 아님 (2026-09-02 짝표 등재로 참조가 생겼다)
  "scripts/test_round_instruments_lanes.sh"
  "scripts/fixtures/isolation_assembly_BROKEN_2026-08-30_ccrun7.json"  # 역사 산출물(등급표가 증거로 인용)
  # 🟥 영혼(judgment-circuit) 3종 — **일부러 출하하지 않는다.**
  # (종전 «위 outbound 가드와 같은 이유»라는 포인터는 2026-09-05 에 끊겼다 — outbound 는 출하로
  #  갔다. 여기 사유는 그것과 무관하게 아래 자기 근거로 선다.)
  # 이 셋은 `.claude/soul_tenets.txt`(=**이 레포의** 심지 원칙 등록부)를 전제로 하는데, 그 파일은
  # 소비자에게 안 나간다 — 소비자의 tenet 은 소비자가 쓰는 것이지 우리가 주는 것이 아니다
  # (`.claude/regression/probes.md` 가 이미 같은 논리로 미출하다).
  # 🟥 측정하고 결정했다(2026-08-30): 등록부를 치우고 셋을 돌리니 rc=1·1·10 — 즉 그대로 출하하면
  # **신선 설치가 100% 실패**한다. CLAUDE.md 가 «모든 새 install 을 막는 게이트는 엄격한 게이트가
  # 아니라 우회 훈련기»(→ `--no-verify` 훈련 → 같은 훅의 Destructive-Op 게이트 무장해제)라 못박은
  # 그 형태다. 훅 자체(`templates/.git-hooks/pre-commit`)는 나가고, 인용이 없는 마커는 통과하므로
  # 소비자 게이트는 열린 채로 남는다.
  # ⚠️ **남은 갭을 이름으로**: 소비자는 자기 install 의 soul 다리를 검증할 레인을 못 받는다.
  #    `test_hook_leg_wiring_lanes.sh` 는 원리상 등록부 없이도 돌 수 있어야 하고(호출부만 보므로),
  #    그렇게 고치면 이 목록에서 빼는 것이 맞다. 오늘은 안 고쳤다 — 미측정이 아니라 미착수다.
  "scripts/test_marker_soul_tenet_lanes.sh"
  "scripts/test_hook_leg_wiring_lanes.sh"
  "scripts/soul_trace.sh"
  # 🟥 sim 경로격리 레인 — 출하 안 한다. `sim_isolated_run.sh` 는 이 레포의 측정 도구이고
  #    이 레인은 «그 러너가 클론에 무엇을 써넣는가»를 본다. 소비자에게 그 러너가 없으면
  #    레인이 HARNESS-ERROR 로 죽고, 그건 신선 설치를 막는 형태가 된다.
  #    ⚠️ 종전에 여기 «위 outbound 와 동일 논리» 라는 포인터가 있었는데 2026-09-05 에 끊었다 —
  #    outbound 는 출하로 갔고, 그 블록의 종전 사유는 실측으로 물러졌다. 이 블록의 사유는
  #    그것과 무관하게 **자기 자신으로 선다**: 여기서는 대상(러너)이 소비자 트리에 정말 없다.
  "scripts/test_sim_path_isolation_lanes.sh"
  ".claude/registry/LOCAL_SKILL_REGISTRY.md"
  ".claude/regression/probes.md"
  # Its sibling, and absent for the same reason one layer up: this file records which sections of
  # THIS repo's resident CLAUDE.md were measured load-bearing. A consumer's CLAUDE.md is their
  # own text, so our verdicts are not merely useless to them — cited from a shipped doc they would
  # read as claims about THEIR file. The shipped docs name it as the place verdicts live in the
  # harness repo, which is a pointer for contributors, not a promise of a shipped artifact.
  ".claude/regression/ablation_verdicts.md"
  # 챔버 **순서 증인 원장**(ship_readiness_gate §② P1). 바로 위 ablation_verdicts 와 **같은
  # 논거**다: 이건 THIS repo 의 챔버 런이 언제 무엇을 고정했는지에 대한 기록이고, 소비자의
  # 챔버 런은 그들 것이다 — 출하 문서에서 인용된 채로 딸려가면 소비자가 **자기 런에 대한
  # 주장**으로 읽는다. 게다가 이 원장은 우리 런 slug·시각을 담은 공개 표면이라 소비자에게
  # 보내는 것은 정보 유출 방향으로도 틀렸다.
  # 🟥 **부재가 소비자 쪽 기능을 깨지 않는다** — `chamber_witness.sh do_record` 는 원장이
  # 없으면 헤더를 만들어 생성한다(같은 파일의 `[ ! -f "$LEDGER" ]` 분기). 즉 스크립트는
  # 출하되고 원장은 소비자 머신에서 처음 쓸 때 생긴다. 「없으면 죽는다」가 아니라
  # 「없는 게 정상 초기 상태」다.
  "knowledge/shared/learnings/chamber_ordering_witness.yaml"
  "scripts/sync-to-be.sh"
  "scripts/sync_guard_check.sh"
  # Return path (companion store → hub) and its anchor. Same reason as the forward path above: the
  # transport only means anything on a machine that HAS the operator's companion store, and shipping
  # it would hand every consumer a script that resolves `$BE` to nothing. selfcheck references the
  # anchor but guards on the subject's presence, so package mode SKIPs rather than falling through.
  "scripts/sync-from-be.sh"
  "scripts/sync_from_be_lanes.sh"
  # Forward path's own lane suite (added 2026-08-14, pmh-dev#69). Same reason as its return-path
  # sibling directly above: it exercises scripts/sync-to-be.sh, itself ACCEPTED_ABSENT — a lane
  # suite for a script that never ships has nothing to verify on a consumer's machine either.
  "scripts/sync_to_be_lanes.sh"
  # NOTE (2026-08-29): `scripts/fh_hub_identity.sh` USED to sit here, with a comment saying it had
  # been removed from files[] on 2026-08-15 as a confidentiality misclassification. Both halves are
  # now stale: #484 deliberately put it BACK in files[] (without it, `fh_session_load.sh`'s hub-
  # identity resolution is dead on every consumer install), and a known-pair-calibrated PSA scan on
  # 2026-08-29 found the file clean (known-positive rc=1 with three hits · known-negative rc=0 ·
  # subject rc=0; the only private-looking word in it is "companion-store", which is public FH
  # vocabulary). A shipped path can never exercise an ACCEPTED_ABSENT entry, so the entry was a dead
  # exception — exactly the kind this check's own advisory warns a future real omission can land on
  # and be silenced by. Removed rather than re-justified.
  # ── The three lane suites selfcheck.sh's DEBT-12 pair-loop names but does not ship ────────────
  # Added 2026-08-13, and the way they got here is the point: this check CAUGHT them. Before that
  # loop existed, these names lived in lane_runner_check.sh's DEBT array as bare basenames
  # ("test_chamber_run_lanes.sh"), which the extractor's `scripts/…\.(sh|py)` pattern does not
  # match. Writing the same names as full paths in a SHIPPED file (selfcheck.sh) is what turned
  # them into phantoms — a shipped document pointing at a file the package omits. The source-tree
  # selfcheck went red on the first full run after the wiring, exactly as an adversarial review had
  # predicted from static reading alone. Recorded here rather than "fixed" by dropping the prefix,
  # because the prefix is what makes lane_runner_check.sh's direct-invocation detector see the
  # wiring at all; removing it would trade a loud failure for a silent blind spot.
  #
  # Why each is legitimately unshipped — and one of them is a NAMED RESIDUAL, not a clean answer:
  #   · test_frontier_digest_retry.sh — its subject, scripts/frontier_digest_daily.sh, is itself
  #     ACCEPTED_ABSENT (a launchd cadence runner for this operator's machine). Anchor follows
  #     subject; a consumer has nothing for it to measure.
  #   · test_residency_closure_lanes.sh — same shape: residency_closure_scan.py does not ship, so
  #     its calibration has no subject on a consumer machine.
  #   · test_chamber_run_lanes.sh — 🟥 NOT the same shape. Its subject scripts/chamber_run.sh DOES
  #     ship. So a consumer receives the chamber runner and no calibration for it: a shipped gate
  #     whose known-pair cannot execute on the machine that runs the gate. That is the defect class
  #     this whole campaign is closing, one layer over. It is entered here rather than shipped
  #     because adding an anchor to files[] is a consumer-facing change that needs its own
  #     tarball-mode run to prove it does not false-FAIL, and this delta is a WIRING delta. The
  #     honest state is "declared, and the declaration records a debt" — carried to the card.
  "scripts/test_chamber_run_lanes.sh"
  "scripts/test_frontier_digest_retry.sh"
  #   · test_satellite_publish_gate_lanes.sh / test_satellite_profile_schema_lanes.sh
  #     — 2026-08-20. 같은 모양이고, 둘 다 **이미 files[] 에 들어간 채 배포된 뒤에** 잡혔다.
  #     주체가 frontier_digest_daily.sh(아래 ACCEPTED_ABSENT)라 소비자 머신엔 잴 대상이 없다.
  #     🟥 실측(레지스트리 실물 2.5.1, 소비자 설치에서 실행): publish_gate 4 passed / 21 failed,
  #     `rc=127`(러너 부재) → selfcheck 가 하드 FAIL → **배포본이 `SELFCHECK: FAIL` 이었다.**
  #     🟥 이 검사기는 그걸 rc=0 으로 통과시켰다 — 여기는 「참조된 경로가 출하되나」를 보지
  #     「출하된 레인의 **주체**가 출하되나」를 안 본다. 그 갭은 아직 안 닫혔다(명시 잔여).
  "scripts/test_satellite_publish_gate_lanes.sh"
  "scripts/test_satellite_profile_schema_lanes.sh"
  #   · 2026-08-22, 파괴적-op 앵커 배선이 만든 셋. **같은 선례의 재현이고, 이 검사기가 또 잡았다** —
  #     `test_prepush_destructive_lanes.sh` 를 files[] 에 넣고 `selfcheck.sh` 의 pair-표에 전체
  #     경로 두 줄을 쓴 순간, 그 두 파일이 이름 대는 것들이 팬텀이 됐다. 위 블록이 예고한 그대로다.
  #     ⓐ test_prepush_destructive_liveness.sh — 일부러 안 싣는다. 7단계 중 둘이 **레포-개발 전용
  #       컨트롤**이라 소비자 install 에서 정직할 수 없다: ⑥ orphan 은 주체
  #       `templates/predelete_check.test.sh` 가 미출하라 **부재로 FAIL**(옳지 않은 red), ⑦
  #       containment 는 소비자 트리가 보통 git work tree 가 아니라 **잴 게 없다**. 싣게 하려면
  #       패키지-모드 라우팅을 그 두 단계에 심어야 하는데 그건 소비자에게 값이 없는 코드다.
  #     ⓑ 🟥 **철회 — 이 자리에 있던 `test_new_code_anchor_lanes.sh` 항목을 뺐다.** 그 항목이
  #       필요했던 유일한 이유는 `test_prepush_destructive_lanes.sh` 의 한 주석이 그 파일을 경로로
  #       인용했기 때문인데, **그 인용이 이번 라운드에 재조준됐다**(추적되는 선례로). 단독 착지
  #       준비 중 발견 — 안 고쳤으면 **커밋되지 않는 파일을 가리키는 팬텀 참조**를 출하할 뻔했다.
  #       인용이 사라지면 예외도 사라져야 한다. **소비되지 않는 예외는 다음 진짜 누락을 삼킨다.**
  #     ⓒ templates/predelete_check.test.sh — 주체 `templates/predelete_check.sh` 는 **출하되는데**
  #       이 앵커는 안 나간다. 🟥 위 test_chamber_run_lanes 와 **같은 모양의 빚**이고, 클린한 답이
  #       아니다. 여기 등재하는 이유도 같다: files[] 추가는 소비자-대면 변경이라 false-FAIL 이
  #       없음을 tarball 모드로 따로 증명해야 하는데, 이 델타는 **배선 델타**다. 카드로 이월.
  "scripts/test_prepush_destructive_liveness.sh"
  #     ⓐ-2 test_liveness_echo_token_lanes.sh — ⓐ 의 **앵커**다. 주체가 위에서 «일부러 안 싣는»
  #       파일이므로, 소비자 머신에는 이 레인이 잴 대상이 아예 없다. 실어봐야 매 실행 SKIP 이고,
  #       그 SKIP 은 「부재」와 「건강함」을 또 같은 글자로 만든다 — 이 레인이 존재하는 이유가
  #       바로 그 부류의 충돌이다. 🟥 이것은 **빚이 아니라 «안 싣는 게 옳은» 쪽**이다(위 ⓒ 철회가
  #       가른 그 구분): 주체가 없어서 못 재는 것이지, 재야 하는데 미룬 게 아니다. 주체가 언젠가
  #       출하되면 이 예외도 같이 사라져야 한다 — 소비되지 않는 예외는 다음 진짜 누락을 삼킨다.
  "scripts/test_liveness_echo_token_lanes.sh"
  #     ⓐ-3 test_checklist_unblocked_lanes.sh · test_tikitaka_score_lanes.sh — 같은 부류.
  #       주체(session_checklist.py · tikitaka_score.py)의 출하 여부와 무관하게, **레인은 이 레포의
  #       개발 표면**이다. 소비자에게 실어봐야 픽스처(scripts/fixtures/…)까지 딸려가야 하고 매 실행
  #       SKIP 이며, 그 SKIP 은 「부재」와 「건강함」을 또 같은 글자로 만든다.
  #       🟥 반면 checklist_unblocked_hook.sh 는 **출하한다** — 출하되는 스니펫
  #       (templates/settings.SubagentStop.snippet.json)이 그 경로를 직접 부르므로, 안 실으면
  #       소비자 머신에서 훅이 조용히 죽는다. 같은 PR 안에서 셋의 처분이 갈리는 이유가 그것이다.
  "scripts/test_checklist_unblocked_lanes.sh"
  "scripts/test_tikitaka_score_lanes.sh"
  "scripts/tikitaka_score.py"
  #     🟥 ⓒ 는 **철회했다 — cross-family R2 지적이 맞았다.** 초판이
  #       `templates/predelete_check.test.sh` 를 여기 넣고 «위 chamber 건과 같은 빚» 이라고 적었는데,
  #       그 둘은 같지 않다: chamber 는 «주체가 출하되는데 앵커가 안 나간다» 를 **빚으로 남긴** 것이고,
  #       여기 등재는 그 빚을 **침묵시킨다**. 이 목록의 뜻은 「출하 안 하는 게 옳다」이지
  #       「출하해야 하는데 아직 안 했다」가 아니다. 후자를 여기 넣으면 **다음 사람이 같은 누락을
  #       만나도 이 예외에 걸려 안 보인다** — 지적 원문: *"A future shipped reference to this same
  #       missing anchor will also be accepted, not surfaced."*
  #       ⇒ `files[]` 에 실었다. 주체 `templates/predelete_check.sh` 가 이미 출하되므로 소비자는
  #       자기 enumerate 도구를 검증할 수 있게 되고, 부수로 liveness stage ⑥ 의 오탐(주체 부재로
  #       ORPHANED)도 닫힌다.
  #     ⓓ 🟥 **그리고 ⓑ 의 사유를 쓰자 그 사유가 팬텀을 만들었다.** 위 주석이 주체 이름을
  #       경로로 적는데 **이 파일 자신이 shipped 문서**라, 「안 싣는 이유」를 설명한 행위가
  #       곧 「shipped 문서가 미출하 경로를 가리킴」이 됐다. 계기가 자기 수리를 잡은 것이고,
  #       위 블록이 예고한 형태(*전체 경로를 shipped 파일에 쓰면 팬텀이 된다*)의 **3차 재현**이다.
  #       (이 문단이 이어서 「주체 자신도 같은 사유로 등재한다」고 residency_closure_scan.py 를
  #       여기 넣었었다 — 그 등재는 2026-09-05 **철회했다**. 아래 참조.)
  # 🟥 **철회 (2026-09-05) — `residency_closure_scan.py` / `test_residency_closure_lanes.sh` 가
  #    ACCEPTED_ABSENT 였던 이유(「이 레포 전용 감사 도구, 소비자 트리엔 판정 대상 없음」)는
  #    운영자 승인 하에 뒤집혔다: `plugins/fh-meta/skills/auto-decorrelation/SKILL.md` §Step 4.5
  #    가 이제 **소비자의 세션**더러 cross-family dispatch 직전마다 이 스캐너를 돌리라고 지시하고,
  #    `templates/.git-hooks/pre-commit` 의 `validate_crossfamily_leg` 가 그 결과(`residency=
  #    CLEAN|TAINTED|NOT_SCANNED(...)`)를 `crossfamily:` 근거 안 타입 토큰으로 검사한다
  #    (`RESIDENCY_TOKEN_GRACE_DATE`, 소급 없음). 즉 「이 레포 자신의 커밋 이력을 감사하는 도구」
  #    에서 「모든 소비자가 실제로 실행하는 배선의 일부」로 성격이 바뀌었다 — 안 실으면 shipped
  #    SKILL.md 가 소비자에게 없는 스크립트를 돌리라고 지시하는 꼴이 되고, 그건 이 검사기 자신이
  #    막으려는 바로 그 결함(«출하 문서가 미출하 경로를 가리킴»)이다.
  #    ⚠️ **패턴층도 같이 실었다** — `.claude/rules/.residency-patterns.defaults`
  #    (일반형 패턴만, 회사 리터럴 없음; gitignored 운영자 override `.residency-patterns` 는
  #    여전히 미출하). 이게 없으면 스캐너는 "defaults 패턴 파일이 없다" 로 exit 10 — 스캐너
  #    자신의 **의도된** fail-closed(운영자 override 부재)보다 더 나쁜, 설치 결함성 원인으로
  #    신선 설치 100% 를 막는 형태였다. `.public-surface-patterns.defaults`(바로 위 줄)와 같은
  #    2층 패턴 관례를 그대로 따른다.
  #    남은 사실: 운영자 override 없는 신선 설치는 여전히 exit 10 이 **기본**이다 — 이건 결함이
  #    아니라 스캐너 자신의 문서화된 설계이고(§Step 4.5 가 명시), auto-decorrelation 은 그 결과를
  #    `residency=NOT_SCANNED(...)` 로 정직하게 적고 `DEGRADED_*` 로 내려간다 — panel(...) 을
  #    조용히 못 쓰게 될 뿐 커밋을 막지는 않는다.
  # ── caller-zero-ratchet, 2026-08-22. 셋 다 **싣는 것이 틀렸다** — 「아직 안 실었다」가 아니다 ──
  # 이 게이트는 「production 스크립트에 디스패처가 있나」를 **이 레포의 러너 표면**에 대고 판정한다.
  # 그 표면의 하나가 `.github/workflows/**` 이고, **워크플로 디렉터리는 files[] 에 없다.** 그러므로
  # 소비자 트리에서 이 검사기를 돌리면 워크플로에서만 디스패치되는 스크립트들이 전부 caller 0 으로
  # 떨어지고, 그것들은 소비자의 `caller_zero_baseline.txt` 에 선언돼 있지 않으므로 **UNDECLARED →
  # exit 1**. 즉 출하하면 **모든 신선한 설치에서 100% 적색**이다 — CLAUDE.md 가 「모든 새 install 을
  # 막는 게이트는 엄격한 게이트가 아니라 우회 훈련기」라고 못박은 그 형태다.
  # ⚠️ 종전에 여기 «바로 위 outbound_query_guard 블록과 같은 사유» 라고 적혀 있었다. 2026-09-05
  # 에 끊는다 — outbound 는 출하로 갔고, 그쪽 사유는 «가드를 부르는 것이 없었다»는 사실 확인으로
  # 물러졌다. **이 블록의 사유는 그대로 유효하다**: 여기는 검사기가 실제로 소비자 트리에서
  # 돌면서 100% 적색을 낸다(위 outbound 는 애초에 아무도 안 불렀다). 같은 결론, 다른 기전.
  # 앵커도 같이 안 나간다. 주체 부재라는 짝 규칙 말고 **자기 자신의 사유가 하나 더 있다**: 마지막
  # 레인(L27)이 `.github/workflows/caller-zero-ratchet.yml` 을 읽어 워크플로가 리졸버를 인라인으로
  # 되돌리지 않았는지 본다. 그 파일이 없는 트리에서 그 레인은 **부재를 결함으로 읽어 적색**이 된다.
  # 🟥 위 ⓒ 의 교훈은 여기 **해당하지 않는다**: 저건 「주체는 출하되는데 앵커만 빠진」 빚이었고,
  # 여기는 주체·앵커·베이스리졸버가 **셋 다** 소비자에게 판정 대상이 없다. 침묵시키는 게 아니라
  # 애초에 그쪽 트리에 질문이 없다.
  # ⚠️ 명시 잔여: 그래서 이 게이트는 **이 레포에서만** 회귀를 막는다. 소비자 설치본의
  # built-but-not-wired 는 이것으로 안 잡히고, 그걸 잡으려면 러너 표면 목록을 패키지 모드로
  # 라우팅하는 별도 설계가 필요하다 — 안 했다.
  "scripts/script_caller_ratchet.sh"
  "scripts/ratchet_base_resolve.sh"
  "scripts/test_script_caller_ratchet_lanes.sh"
  # ── D-5 runner-surface known pair, 2026-08-24 — 같은 사유로 미출하 ────────────────────────
  # test_runner_surface_index_lanes.sh 는 주체가 **둘**이다: lane_runner_check.sh(출하됨)와
  # script_caller_ratchet.sh(미출하). 후자가 없는 트리에서는 파일 상단 가드가 exit 2 로 죽으므로
  # 소비자에게 실으면 **모든 설치에서 계기 오류**가 된다. selfcheck 의 짝 표에서도 sentinel 주체를
  # script_caller_ratchet.sh 로 잡아, 소비자 트리에서는 주체 팔이 먼저 발화해 SKIP 으로 끝난다.
  "scripts/test_runner_surface_index_lanes.sh"
  # ── The two settings destinations, surfaced 2026-08-13 by fixing this file's own extractor ────
  # These were invisible until the `js|json` alternation below was corrected: every `.json`
  # reference in every shipped doc was being truncated to `.js`, a path that exists nowhere, so
  # `os.path.exists()` dropped it in silence. `.claude/settings.json` alone is named by TWENTY-SIX
  # shipped documents and had never once been examined by the check that exists to examine exactly
  # this. Both are INSTALL DESTINATIONS the consumer owns — the whole point of the docs that name
  # them is "put this in YOUR settings" — so shipping either would overwrite the reader's own
  # configuration with this harness's. Same reasoning as .claude/rules/local_fh_context.md above:
  # the template is what ships, the destination is what the user creates.
  ".claude/settings.json"
  ".claude/settings.local.json"
  # Its only input is `.claude/regression/probes.md`, itself ACCEPTED_ABSENT above (a consumer's
  # regression run must not compare against this harness's probe set). Shipping the reader without
  # its corpus would put a script in the package that can only ever report "instrument error".
  # ④ 계측 채널(발화-유도 스킬 로깅). 배선이 **로컬 `.claude/settings.json`** 에 있고 그 파일은
  # 출하물이 아니다 — 스크립트만 실으면 소비자 머신에서 아무것도 안 하는 죽은 파일이 된다.
  # 게다가 이건 이 허브의 정체성 ④ 계측이라 소비자가 잴 대상 자체가 다르다.
  "scripts/utterance_skill_probe.sh"
  # 바로 위의 앵커. 주어가 안 실리는데 앵커만 싣는 것은 이 체커가 스스로 defect 라 부르는 형태다
  # ("shipping an anchor whose subject is not in the package").
  "scripts/test_utterance_skill_probe_lanes.sh"
  "scripts/probe_scope_check.sh"
  # The known-pair precondition for `probe_scope_check.sh`'s ablation procedure. Absent for the same
  # reason as its subject — the procedure ablates THIS repo's resident CLAUDE.md, so a consumer has
  # nothing to point it at — plus one of its own: every run spends API calls against the consumer's
  # account. Shipping a script whose only effect on a consumer's machine is a bill is worse than
  # omitting it.
  "scripts/ablation_calibrate.sh"
  # Its anchor. Absent for exactly one reason — its SUBJECT is absent — and that pairing is the whole
  # rule: shipping an anchor whose subject is not in the package is the defect that put a red
  # selfcheck in front of every 1.4.85/1.4.86 consumer. selfcheck's block SKIPs when the subject is
  # missing, so the package stays green without pretending the lanes ran.
  "scripts/test_ablation_calibrate_lanes.sh"
  # Anchor for probe_scope_check.sh, which is ACCEPTED_ABSENT above for want of its corpus. Same
  # pairing rule: an anchor whose subject does not ship must not ship either, or the consumer's
  # selfcheck goes red on a subject they do not have.
  "scripts/test_probe_scope_lanes.sh"
  # Measures what the LIVE `claude` CLI does with several SessionStart hooks on one matcher —
  # so every run needs the CLI, auth, and spends tokens on the consumer's account. This anchor DOES
  # get a live run attempt when its subject ships and the file itself is present (selfcheck reports
  # NOT EXERCISED, exit 2, when that run finds no CLI). This entry covers the OTHER case: the anchor
  # FILE ITSELF is not shipped, so selfcheck never gets far enough to attempt the run at all — that
  # case renders SKIP, not NOT EXERCISED (the two are for missing-file vs. present-file-no-CLI,
  # not interchangeable; corrected 2026-08-12, cross-family review — the two exit paths were
  # conflated in an earlier revision of this comment).
  "scripts/test_sessionstart_multihook_lanes.sh"
  # The launchd-driven daily cadence runner. Two independent reasons it must not ship: it is half of
  # a pair whose other half is a machine-local plist (`scripts/com.forge-harness.frontier-digest.plist`),
  # and every run spends `claude` CLI calls on the consumer's account — the same "its only effect on
  # a consumer's machine is a bill" rule as `ablation_calibrate.sh` above. It also writes into
  # `tracks/`, which does not ship. The frontier-digest SKILL names it as the *hub's* production
  # runner (labelled hub-local at the reference), which is a pointer for contributors, not a promise
  # of a shipped artifact — the skill's own save path works without it.
  "scripts/frontier_digest_daily.sh"
  # 🟥 2026-09-18 — 발행 «확인» 3종. 이 레포 자기 릴리스 파이프라인의 부품이고 소비자 호출부가 없다.
  # `publish_verify_poll.sh` 는 `.github/workflows/publish.yml` 의 verify 단계가 유일한 호출부이고,
  # 워크플로 자체가 출하되지 않는다. 소비자는 `@chrono-meta/fh-gate` 를 발행하지 않으므로
  # 이 스크립트를 부를 이유가 구조적으로 없다(PKG_NAME 으로 매개화돼 있긴 하다 — 그래서
  # «불가능» 이 아니라 «호출부 부재» 라고 적는다).
  # ⚠️ 그 결과 소비자 install 에서는 이 셋이 없고, selfcheck 의 해당 레인은 SKIP 으로 렌더된다.
  # SKIP 은 PASS 가 아니다 — 이 레포에서는 레인이 실제로 돌고, 그것이 이 배선의 검증면이다.
  "scripts/publish_verify_poll.sh"
  "scripts/publish_verify_poll_stub_npm.sh"
  "scripts/test_publish_verify_poll_lanes.sh"
)

# `--list-accepted`: print the ACCEPTED_ABSENT paths, one per line, and exit — no git/package.json
# dependency, deliberately callable from a context this script's own coverage scan cannot run in
# (package mode, or a vendored tree with `.git` present but no `package.json` at this root). This is
# the single declared source of "known-legitimately-unshipped" for OTHER checks to consult instead of
# re-deriving the same judgment from an environment predicate (`.git` presence, directory existence)
# that answers a different question and can diverge from this list's actual coverage. Added
# 2026-08-12 (reship axis, cross-family review of card §🔱⑮ G/D) after selfcheck.sh's ref-path block
# was found re-deriving "is this legitimately absent" from `[ -e .git ]` — which reproduces the
# original bug in any git-tracked tree that vendors this package (a monorepo committing
# node_modules, or a consumer who runs `git init` after install): `.git` exists there, so the old
# predicate ran the check, found these exact paths missing, and FAILed — the same false-FAIL this
# array already declares correct to omit.
if [ "${1:-}" = "--list-accepted" ]; then
  printf '%s\n' "${ACCEPTED_ABSENT[@]}"
  exit 0
fi

# `--vs-tarball` swaps the coverage oracle from package.json files[] (a DECLARATION about the
# tarball) to `npm pack --dry-run --json` (the tarball). See the two-oracle comment in the python
# block. Kept as a flag rather than made the default for one reason: the default runs anywhere,
# offline, in ~1s, while this one shells out to npm and takes seconds — so the strict oracle belongs
# on the publish path, where the question "what does the consumer actually receive" is the one being
# asked, and the cheap one stays on every commit.
if [ "${1:-}" = "--vs-tarball" ]; then
  export FH_PKG_ORACLE=tarball
  shift
fi

# Source-checkout test uses `-e`, not `-d`. In a git WORKTREE `.git` is a FILE (a gitdir pointer),
# so the old `-d` test read every worktree as "installed package" and skipped the check entirely —
# silently, with exit 0. Measured 2026-07-31: a worktree created specifically to approximate CI
# reported PASS while this check had not run at all, i.e. the instrument used to justify wiring CI
# was itself fail-open on the surface it was standing in. `-e` covers both the ordinary checkout
# (dir) and the worktree (file); genuine package mode has no `.git` of either kind, so it still
# skips. Anchored by scripts/test_package_coverage_lanes.sh.
if [ ! -e .git ]; then
  echo "SKIP  package-coverage (not a source checkout)"
  exit 0
fi
# PREDICATE SPLIT (cross-family review, 2026-07-31). These were one condition, and folding them
# together meant `.git` present + manifest missing returned SKIP + exit 0 — "we are in a checkout
# and cannot read what ships" reported as "nothing to check here". Absence of the input is not
# absence of the defect; `not found != 0` (CLAUDE.md §Instrument-Calibration). Only the no-.git
# case is a legitimate skip (an installed package, where the un-shipped files are correctly gone).
if [ ! -f package.json ]; then
  echo "FAIL  package-coverage: source checkout with no package.json — the shipped file list is"
  echo "      unreadable, so coverage is UNMEASURED, not clean"
  exit 1
fi

out=$(FH_PKG_ORACLE="${FH_PKG_ORACLE:-declaration}" python3 - "${ACCEPTED_ABSENT[@]}" <<'PY'
import re, os, json, sys, subprocess
accepted = set(sys.argv[1:])
files = json.load(open('package.json'))['files']

# ── TWO ORACLES, and the difference between them is the whole reason the second one exists ────
# `declaration` (default): a path is covered if package.json files[] says so. That is a CLAIM about
#     the tarball, checkable without npm, and it is what every caller before 2026-08-13 used.
# `tarball`: a path is covered if it is actually in `npm pack --dry-run --json`. That is the tarball
#     ITSELF.
# They come apart, and this repo has measured them coming apart (card §🔱⑮ G/C: repo ✅ / files[] ❌
# for two paths). A files[] entry can name a file that does not pack — .npmignore precedence, a
# pattern that no longer matches, a file deleted while its declaration stayed. In every one of those
# the declaration oracle says PASS and the consumer gets a broken reference, because the consumer
# receives the tarball and not the manifest. This is the general solution the campaign card has
# carried as open under "참조 ↔ 실제 출하 파일셋": the earlier repairs (A–D) routed AROUND it by
# consulting declarations, which was correct for those cases and is not the same thing as building
# it.
# FAIL-CLOSED, and deliberately not "fall back to the declaration": npm missing, a non-zero exit, or
# JSON that does not parse means the tarball is UNKNOWN, and a lenient fallback would silently
# convert the stricter oracle back into the weaker one — while still printing the stricter one's
# name. That is the "미측정을 통과로 렌더" class this whole file is an instrument against.
ORACLE = os.environ.get('FH_PKG_ORACLE', 'declaration')
packed = None
if ORACLE == 'tarball':
    try:
        r = subprocess.run(['npm', 'pack', '--dry-run', '--json'],
                           capture_output=True, text=True, timeout=180)
        if r.returncode != 0:
            print(f"ORACLE_UNAVAILABLE\tnpm pack exited {r.returncode}")
            raise SystemExit(2)
        # Two shapes have been seen for `npm pack --dry-run --json`: the documented one carries
        # `[0]['files'][*]['path']`; inside `npm publish`'s prepublishOnly on the CI runner
        # (Node 22 / npm 10, 2026-09-04, v3.0.0 first OIDC publish) the same call returned JSON
        # WITHOUT that key and this block died with a bare KeyError — fail-closed (correct) but
        # blind (no diagnosis). Parse defensively, and when the JSON does not carry a file list
        # fall back to the text listing (`npm notice <size> <path>` lines), which is what a human
        # reads. The diagnostic line prints the head of stdout so the NEXT failure names its shape.
        parsed = json.loads(r.stdout)
        entry = parsed[0] if isinstance(parsed, list) and parsed else (parsed if isinstance(parsed, dict) else None)
        flist = (entry or {}).get('files') if isinstance(entry, dict) else None
        if flist and all(isinstance(f, dict) and 'path' in f for f in flist):
            packed = {f['path'] for f in flist}
        else:
            t = subprocess.run(['npm', 'pack', '--dry-run'], capture_output=True, text=True, timeout=180)
            lines = (t.stdout + '\n' + t.stderr).splitlines()
            packed = set()
            for ln in lines:
                m = re.match(r'^npm notice\s+[0-9.]+[kMG]?B\s+(\S+)\s*$', ln)
                if m:
                    packed.add(m.group(1))
            if not packed:
                print(f"ORACLE_UNAVAILABLE\tnpm pack --json had no files[].path and the text listing had no file lines; json head: {r.stdout[:200]!r}")
                raise SystemExit(2)
    except FileNotFoundError:
        print("ORACLE_UNAVAILABLE\tnpm is not on PATH — the tarball cannot be read")
        raise SystemExit(2)
    except (json.JSONDecodeError, KeyError, IndexError) as e:
        print(f"ORACLE_UNAVAILABLE\tnpm pack --json did not parse ({type(e).__name__}); stdout head: {r.stdout[:200]!r}")
        raise SystemExit(2)
    except subprocess.TimeoutExpired:
        print("ORACLE_UNAVAILABLE\tnpm pack timed out")
        raise SystemExit(2)
    # An empty or absurdly small packed set means the instrument broke, not that the package is
    # empty — same impossible-zero rule the shipped-doc guard below applies to its own extractor.
    if not packed or len(packed) < 10:
        print(f"ORACLE_UNAVAILABLE\tnpm pack reported {len(packed or [])} files — implausible")
        raise SystemExit(2)

def covered(p):
    if packed is not None:
        return p in packed
    return any(p == f or p.startswith(f.rstrip('/') + '/') for f in files)

shipped = []
if packed is not None:
    # In tarball mode the set of SCANNED documents is the tarball too, not the declaration. Both
    # halves have to move together: scanning a doc that does not actually ship would report a
    # phantom no consumer can encounter, and that false positive is what would get the stricter
    # oracle switched back off.
    shipped = sorted(packed)
else:
    for f in files:
        if os.path.isfile(f):
            shipped.append(f)
        elif os.path.isdir(f):
            for root, _, names in os.walk(f):
                shipped.extend(os.path.join(root, n) for n in names)

# Only text surfaces can carry a reference a human or agent would follow.
shipped = [s for s in shipped if s.endswith(('.md', '.sh', '.js', '.json', '.yaml', '.yml'))]

pat = re.compile(
    r'(?<![\w/.-])((?:scripts|templates|bin|docs|knowledge|plugins|\.claude)'
    # LONGEST-FIRST, and the order is the whole fix. Python's `|` is leftmost-first, not
    # longest-match, so the previous order `sh|py|js|md|yaml|yml|json|defaults` matched `.js`
    # inside `.json` and truncated every JSON reference: `templates/settings.json` was extracted as
    # `templates/settings.js`, a path that exists nowhere, so `os.path.exists()` said False and the
    # reference was dropped in silence. This check has therefore NEVER examined a shipped doc's
    # JSON references. Same trap in `yml|yaml` (harmless by luck: neither is a prefix of the other)
    # and it would bite again for any future pair like `md`/`mdx`.
    # Known-pair, run before the fix: `templates/settings.json` → `templates/settings.js` (broken)
    # while `scripts/foo.sh` → `scripts/foo.sh` (control, intact). After: both intact.
    # The `(?![A-Za-z0-9])` tail is the belt to the longest-first braces: it stops a correct
    # alternative from matching a PREFIX of a longer real extension that nobody listed yet, so the
    # next person who adds an extension cannot silently reintroduce this by putting it in the wrong
    # place. Ordering alone is a convention; the lookahead is the mechanism.
    r'/[A-Za-z0-9_./-]+\.(?:defaults|json|yaml|yml|sh|py|js|md)(?![A-Za-z0-9]))'
)

phantom = {}
exercised = set()   # accepted entries a shipped doc ACTUALLY still points at
for s in shipped:
    try:
        text = open(s, encoding='utf-8', errors='ignore').read()
    except OSError:
        continue
    for m in set(pat.findall(text)):
        # Only a path that REALLY EXISTS here but is left out of the tarball is this defect.
        # A path that exists nowhere is the ordinary phantom-reference class the ref-path
        # check above already owns; a path outside files[] that is also absent is nothing.
        # EXISTENCE, not tracked-ness. A 2026-07-30 revision narrowed this to `git ls-files`
        # to silence what looked like a machine-local false positive; measurement showed that was a
        # WEAKENING — an existing-but-untracked path named by a shipped doc is exactly the defect
        # (the npm user cannot have that file), and selfcheck's ref-path check SKIPs gitignored
        # paths, so nothing else owns it. Reverted.
        #
        # WIDENING IS DEFERRED, AND THE REASON IS NOT A MEASUREMENT. Dropping `exists` entirely
        # (flag every referenced ∧ ¬covered path) is arguably the correct predicate, but it cannot
        # be evaluated while the extractor below is known-broken: its `(sh|py|js|md|json|…)`
        # alternation puts `js` before `json`, so `settings.json` is captured as `settings.js`.
        # A first pass at this comment cited a count of artifacts as evidence that `exists` is
        # load-bearing — that count came FROM the broken extractor, i.e. an instrument was used to
        # justify keeping a predicate before the instrument itself was validated (the circularity
        # CLAUDE.md §Instrument-Calibration exists to forbid; a cross-family review caught it, and
        # an independent extractor produced materially different numbers).
        # HONEST STATE: fix the `js|json` alternation first, re-measure, then decide. Until then
        # this check's true coverage is UNQUANTIFIED — treat a PASS as "no defect of the narrow
        # exists-and-uncovered kind", not as "every shipped reference is sound".
        if os.path.exists(m) and not covered(m):
            if m in accepted:
                exercised.add(m)
            else:
                phantom.setdefault(m, set()).add(s)

# Impossible-zero guard: this repo always has shipped docs. Zero scanned means the extractor
# broke — report that as a failure rather than letting a dead check print a pass
# (same rule as count_check.sh and the ref-path extractor).
if not shipped:
    print("EXTRACTOR_BROKE")
    raise SystemExit(2)

for p, srcs in sorted(phantom.items(), key=lambda kv: (-len(kv[1]), kv[0])):
    print(f"{p}\t{len(srcs)}\t{sorted(srcs)[0]}")

# An exception nobody exercises is a SILENCER waiting for a future real case to land on it.
# Report the unexercised ones so the list stays a set of decisions, not accumulated residue.
for a in sorted(accepted - exercised):
    print(f"STALE\t{a}")
raise SystemExit(1 if phantom else 0)
PY
)
rc=$?

case "$out" in
  ORACLE_UNAVAILABLE*)
    # Only reachable with --vs-tarball. The stricter oracle could not be read, and the ONLY wrong
    # answer here is to quietly re-run with the weaker one and print a pass — the caller asked
    # "what does the consumer actually receive", and "I could not look" is not an answer to that.
    echo "FAIL  package-coverage (--vs-tarball): the tarball oracle is UNAVAILABLE, so coverage"
    echo "      against the real packed file set is UNMEASURED — not clean."
    printf '%s\n' "$out" | sed 's/^ORACLE_UNAVAILABLE\t/      reason: /'
    echo "      Re-run without --vs-tarball to check against package.json files[] instead, but note"
    echo "      that is a DIFFERENT and weaker question (a declaration, not the tarball)."
    exit 2 ;;
esac

if [ "$rc" -eq 2 ] || [ "$out" = "EXTRACTOR_BROKE" ]; then
  echo "FAIL  package-coverage: extractor scanned 0 shipped docs — the check broke, it did not pass"
  exit 1
fi

STALE_LIST=$(printf '%s\n' "$out" | grep '^STALE	' | cut -f2- || true)
STALE_N=$(printf '%s' "$STALE_LIST" | grep -c . || true)
EXERCISED_N=$(( ${#ACCEPTED_ABSENT[@]} - ${STALE_N:-0} ))

if [ "$rc" -ne 0 ]; then
  echo "FAIL  package-coverage: shipped document(s) point at file(s) the package omits:"
  printf '%s\n' "$out" | grep -v '^STALE	' | while IFS=$'\t' read -r path n src; do
    [ -z "$path" ] && continue
    printf '        %s  (named by %s shipped doc(s), e.g. %s)\n' "$path" "$n" "$src"
  done
  echo "      Fix: add the path to package.json files[], OR list it in ACCEPTED_ABSENT here"
  echo "      with a one-sentence reason why shipping it would be wrong."
  exit 1
fi

# Advisory only — a stale exception is hygiene, not a shipped-doc defect, so it must not
# convert a clean package into a red gate (that trains the runner to ignore the check).
if [ "${STALE_N:-0}" -gt 0 ]; then
  echo "⚠️  package-coverage: ${STALE_N} ACCEPTED_ABSENT entry(ies) no longer exercised — remove, or a"
  echo "    future real omission can land on the stale exception and be silenced:"
  printf '%s\n' "$STALE_LIST" | sed 's/^/        /'
fi

echo "PASS  package-coverage: every referenced path is shipped or explicitly accepted (${#ACCEPTED_ABSENT[@]} accepted, ${EXERCISED_N} exercised)"
exit 0
