@chrono-meta/fh-gate 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.claude/rules/fh_4axis_gate.md +26 -3
  2. package/.claude-plugin/marketplace.json +2 -2
  3. package/AGENTS.md +24 -3
  4. package/CLAUDE.md +39 -12
  5. package/README.ja.md +32 -8
  6. package/README.ko.md +32 -8
  7. package/README.md +112 -15
  8. package/README.zh.md +28 -8
  9. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +19 -5
  10. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +12 -2
  11. package/knowledge/shared/harness-core/multi_model_sidecar_strategy.md +32 -0
  12. package/knowledge/shared/harness-core/ship_readiness_gate.md +77 -8
  13. package/knowledge/shared/learnings/subagent_invocations_log.yaml +90 -0
  14. package/knowledge/shared/rules/knowledge_layer_seam.md +1 -1
  15. package/package.json +13 -2
  16. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  17. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  18. package/plugins/fh-meta/CHANGELOG.md +111 -0
  19. package/plugins/fh-meta/agents/persona-innovator.md +170 -0
  20. package/plugins/fh-meta/skills/public-surface-audit/SKILL_detail.md +16 -1
  21. package/plugins/fh-meta/skills/steel-quench/SKILL.md +97 -0
  22. package/scripts/adapters/peer_resolve.sh +58 -6
  23. package/scripts/cluster_capability_scan.sh +42 -13
  24. package/scripts/digest_landing_check.sh +142 -2
  25. package/scripts/fh_hub_identity.sh +83 -0
  26. package/scripts/fh_session_load.sh +53 -5
  27. package/scripts/fh_track_resolve.sh +114 -0
  28. package/scripts/field_canon_preload.sh +50 -5
  29. package/scripts/package_coverage_check.sh +8 -0
  30. package/scripts/prior_art_prompt.sh +168 -0
  31. package/scripts/psa_scan_lib.sh +201 -15
  32. package/scripts/residency_admission_check.sh +204 -0
  33. package/scripts/selfcheck.sh +88 -0
  34. package/scripts/test_adapter_lanes.sh +67 -2
  35. package/scripts/test_heavy_classifier_lanes.sh +144 -0
  36. package/scripts/test_marker_defense_lanes.sh +152 -0
  37. package/scripts/test_marker_soul_check_lanes.sh +211 -0
  38. package/scripts/test_prior_art_prompt_lanes.sh +128 -0
  39. package/scripts/test_psa_singlefile_lanes.sh +351 -1
  40. package/scripts/test_residency_admission_lanes.sh +60 -0
  41. package/scripts/test_track_resolve_lanes.sh +158 -0
  42. package/templates/.git-hooks/pre-commit +400 -4
  43. package/templates/.git-hooks/pre-push +17 -2
  44. package/templates/settings.PriorArt.snippet.json +15 -0
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env bash
2
+ # residency_admission_check.sh — 상주 텍스트 **유입** 게이트.
3
+ #
4
+ # 왜 있나 (2026-08-20 실측). 감량은 유출 문제가 아니라 유입 문제였다:
5
+ # CLAUDE.md 23,839(05-26) → 70,758(07-20 «11.9% 감량» 직후) → 144,138(08-20)
6
+ # 07-20 이후 31일간 +73,380 = 약 2,367 bytes/일 (최근 5일은 약 7,000/일, 가속)
7
+ # 같은 기간 어블레이션 CUT 판정 = **0건** (4개 절 시도 → KEEP 3 · UNCALIBRATED 1)
8
+ # 즉 배수구를 넓히는 쪽엔 레버가 거의 없고, 수도꼭지는 아무도 안 보고 있었다.
9
+ # 가장 큰 감량 후보 절(22k)조차 **유입 9일치**다 — 완벽히 잘라도 한 달이면 원위치이고,
10
+ # 그건 가설이 아니라 07-20 에 실제로 일어난 일이다.
11
+ #
12
+ # 무엇을 검사하나 — 🟥 **결론이 아니라 기록의 속성**이다 (§Mechanization Boundary).
13
+ # 「이 문단이 상주할 가치가 있나」는 판정하지 않는다. 「저자가 유형을 선언했나」만 본다.
14
+ # `crossfamily:` · `standpoint:` 와 같은 형태이고 같은 마커에 얹힌다.
15
+ #
16
+ # 🟥 명시 잔여 — 이 게이트가 잡는 것은 **침묵**이지 **거짓 선언**이 아니다.
17
+ # `detail-belongs-elsewhere` 는 자기 거절 값이라 압박받는 세션은 절대 고르지 않는다.
18
+ # 형식은 채널이고 진위는 사람이다. 위 두 필드와 같은 이유로 받아들인다.
19
+ # 보완: 순증마다 **총량과 베이스라인 대비 델타를 출력**한다 — 그건 자기신고가 아니라
20
+ # 측정이라 게임이 안 되고, 추가하는 그 순간에 보인다.
21
+ #
22
+ # ── 🔒 사전등록 (이 게이트가 값을 냈는지 나중에 채점하기 위해, 배선 **전에** 적는다) ──
23
+ # 가설: 이 게이트는 상주 유입을 줄인다.
24
+ # 채점: 배선일로부터 30일간 CLAUDE.md 순증 bytes/day 를 직전 31일(2026-07-20~08-20)의
25
+ # **2,367 bytes/day** 와 대조한다. 베이스라인 144,138 @ 2026-08-20.
26
+ # 🟥 사전등록 없이 나중에 «줄어든 것 같다» 로 채점하면 07-20 의 「11.9% 감량」과 같은 종류의
27
+ # 숫자가 하나 더 생긴다 — 그 숫자는 한 달 뒤 2.1배로 반증됐다.
28
+ # 🟥 **정직한 기대값은 «유입 차단» 이 아니라 «유입에 이름이 붙는다» 이다** (peer 재프레이밍).
29
+ # 형식 검사는 침묵을 잡지 위조를 못 잡고, 저자는 압박받으면 `soul` 을 고른다
30
+ # (`crossfamily:` 가 이미 거짓 값이 레인을 통과한 실측을 갖고 있다).
31
+ # 이름이 붙는 것만으로도 값이 있다 — **다음 감량이 무엇을 지울지 고를 수 있게 된다.**
32
+ #
33
+ # ── 손 캘리브레이션 (enum 이 실물에서 실제로 가르는가 · 2026-08-20) ──
34
+ # 게이트는 기록을 보지 내용을 판정하지 않으므로, enum 의 판별력은 **손으로** 잰다.
35
+ # 최근 5일 최대 유입 둘에 적용했더니 **서로 다른 답**이 나왔다:
36
+ # §Mechanization Boundary (10,157 B) → `soul` — 「기계는 비가역 경계에만, 판단은
37
+ # 진화에」는 판단 좌표계다. 상주 정당
38
+ # §Expedition (5,748 B) → `relocatable(knowledge/shared/rules/operations.md)`
39
+ # — 캐던스 절차 + 실측 이력. §Cadence Rules 가 이미 거기 있다
40
+ # ⇒ 실물 두 건이 갈렸다. 같은 답만 나왔으면 이 enum 은 판별력이 0 이다.
41
+ #
42
+ # exit 0 = 통과(감량·무변경 포함) · 1 = 차단 · 2 = 하네스 오류(PASS 아님)
43
+
44
+ set -uo pipefail
45
+
46
+ RESIDENT_FILE="CLAUDE.md"
47
+ BASELINE_BYTES=144138
48
+ BASELINE_DATE="2026-08-20"
49
+ # 🟥 사람이 읽는 출력은 enum 을 뱉지 않는다. 기계 어휘는 기계 안에만 둔다 —
50
+ # 사용자가 우리 용어를 배우게 만들면 그 게이트는 이식 후 통과 불가가 된다
51
+ # (`count_check.sh:94-106` 이 같은 법칙을 README 렌더링에서 이미 실측했다).
52
+ VALID="soul irreversible-gate disambiguator pointer relocatable"
53
+
54
+ # 🟥 루트는 **스크립트 위치가 아니라 검사 대상 레포**다. 초판은 `dirname $0/..` 를 썼는데,
55
+ # 소비자 설치에서 그건 `node_modules/@chrono-meta/fh-gate` 라 **소비자 레포의 CLAUDE.md 가
56
+ # 아니라 패키지 안의 우리 CLAUDE.md** 를 검사한다. 정적으로 읽을 땐 안 보였고,
57
+ # 팩을 풀어 돌려서 나왔다(§Local Execution First — 실행이 하중선인 이유).
58
+ # 호출자(pre-commit)가 $1 로 레포 루트를 준다. 없으면 git 에게 묻는다.
59
+ ROOT="${1:-}"
60
+ if [ -z "$ROOT" ]; then
61
+ ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || ROOT=""
62
+ fi
63
+ # ── 적용 가능성 판정 (🟥 «구조적으로 해당 없음» 과 «도구가 없음» 을 같은 값으로 접지 않는다) ──
64
+ if [ -z "$ROOT" ]; then
65
+ echo " SKIP 상주 유입 게이트 — git 레포가 아니다 (적용 대상 없음, 통과가 아니라 해당 없음)"
66
+ exit 0
67
+ fi
68
+ if [ ! -f "$ROOT/$RESIDENT_FILE" ]; then
69
+ echo " SKIP 상주 유입 게이트 — 이 레포에 $RESIDENT_FILE 이 없다 (적용 대상 없음)"
70
+ exit 0
71
+ fi
72
+
73
+ # ── 순증 계산: 스테이징된 변경만 본다 ────────────────────────────────────────
74
+ diffstat=$(git -C "$ROOT" diff --cached --numstat -- "$RESIDENT_FILE" 2>/dev/null)
75
+ rc=$?
76
+ [ "$rc" -ne 0 ] && { echo "❌ HARNESS ERROR: git diff 실패 (rc=$rc) — 미측정이지 통과 아님" >&2; exit 2; }
77
+ [ -z "$diffstat" ] && exit 0 # 이 커밋은 상주를 안 건드린다
78
+
79
+ ins=$(printf '%s' "$diffstat" | awk '{print $1}')
80
+ del=$(printf '%s' "$diffstat" | awk '{print $2}')
81
+ case "$ins$del" in *-*) echo "ℹ️ $RESIDENT_FILE 이 바이너리로 보고됨 — 판정 불가"; exit 2 ;; esac
82
+ net=$(( ins - del ))
83
+
84
+ cur=$(wc -c < "$ROOT/$RESIDENT_FILE" | tr -d ' ')
85
+ # 🟥 베이스라인은 **FH 자신의** CLAUDE.md 기준이다. 다른 레포에서 델타를 찍으면 남의 파일을
86
+ # 우리 숫자로 재는 것이라 무의미하다 — 소비 레포에선 총량만 말한다.
87
+ if [ "$cur" -lt $(( BASELINE_BYTES / 4 )) ] || [ "$cur" -gt $(( BASELINE_BYTES * 4 )) ]; then
88
+ printf ' 상주 규모: %s bytes (이 레포 기준 · FH 베이스라인과 스케일이 달라 델타는 생략)\n' "$cur"
89
+ BASELINE_BYTES=0
90
+ fi
91
+ delta=$(( cur - BASELINE_BYTES ))
92
+ if [ "$BASELINE_BYTES" -gt 0 ]; then pct=$(( delta * 1000 / BASELINE_BYTES )); else pct=0; fi
93
+ if [ "$BASELINE_BYTES" -gt 0 ]; then
94
+ printf ' 상주 규모: %s bytes (기준 %s @ %s · %+d, %+d.%d%%)\n' \
95
+ "$cur" "$BASELINE_BYTES" "$BASELINE_DATE" "$delta" "$((pct/10))" "$((pct%10<0?-pct%10:pct%10))"
96
+ fi
97
+
98
+ if [ "$net" -le 0 ]; then
99
+ echo " ✅ 상주 순증 없음 (+$ins/-$del 줄) — 유입 게이트 해당 없음"
100
+ exit 0
101
+ fi
102
+
103
+ # ── 순증이다. 마커에 유형 선언이 있어야 한다 ─────────────────────────────────
104
+ # 🟥 단, **이 레포가 4축 마커 체제를 돌릴 때만** 그렇다. 마커 디렉터리 자체가 없으면
105
+ # 그건 소비 레포이고, 거기서 마커를 요구하면 **이식 이후 한 번도 통과 못 하는 검사**가 된다
106
+ # — 통과 불가능한 게이트는 게이트가 아니라 `--no-verify` 훈련기다
107
+ # (`count_check.sh:94-106` 이 README 렌더링에서 이미 실측한 법칙).
108
+ # 소비자 트리 ARM 3 실행이 이걸 냈다: 소비자가 자기 CLAUDE.md 를 늘리자 rc=2 로 막혔다.
109
+ if [ ! -d "$ROOT/tracks/_meta" ]; then
110
+ echo " SKIP 상주 유입 게이트 — 이 레포는 4축 마커 체제를 안 돌린다 (적용 대상 없음)"
111
+ echo " (순증 +$net 줄은 봤다. 판정하지 않는 이유는 마커 디렉터리 부재다)"
112
+ exit 0
113
+ fi
114
+
115
+ today=$(date +%Y-%m-%d)
116
+ marker=$(ls -t "$ROOT"/tracks/_meta/.axes_23_passed_*_"$today".marker 2>/dev/null | head -1)
117
+ if [ -z "$marker" ]; then
118
+ cat >&2 <<MSG
119
+ ❌ 상주 순증(+$net 줄)인데 오늘자 마커가 없다.
120
+ 이 검사는 마커 위에 얹힌다 — 4축 마커를 먼저 쓴 다음 다시 커밋해라.
121
+ MSG
122
+ exit 2
123
+ fi
124
+
125
+ line=$(grep -E '^residency:' "$marker" | head -1)
126
+ if [ -z "$line" ]; then
127
+ cat >&2 <<MSG
128
+ ❌ $RESIDENT_FILE 이 +$net 줄 늘었는데, 그게 왜 **항상 로드되어야 하는지**가 안 적혀 있다.
129
+
130
+ 늘어난 내용이 다음 중 무엇인가? 마커에 한 줄 적어라 — 근거를 같이.
131
+ · 판단 회로다 (무엇이 성공 · 어디로 기움 · 절대 안 함) → residency: soul — <근거>
132
+ · 비가역 경계에서 발화해야 한다 (출하·삭제·이력 재작성) → residency: irreversible-gate — <근거>
133
+ · 특정 오독 하나를 막는 줄이다 → residency: disambiguator — <무엇을 막나>
134
+ · 상세로 보내는 포인터다 → residency: pointer — <어디로>
135
+ · 🟥 사실 다른 파일로 갈 수 있는 내용이다 → residency: relocatable(<경로>)
136
+ (목적지를 적으면 이 커밋은 막힌다 — 거기로 옮기고 여기엔 포인터만 남겨라)
137
+
138
+ 왜 묻나: 07-20 감량 이후 31일간 +73,380 bytes (약 2,367/일). 같은 기간 어블레이션이
139
+ 낸 CUT 판정은 0건이다. 나가는 문은 사실상 닫혀 있으므로 들어오는 문에서 정한다.
140
+ MSG
141
+ exit 1
142
+ fi
143
+
144
+ # 🟥 구분자는 «em-dash» 또는 «공백-하이픈-공백» 이다. 맨하이픈으로 자르면
145
+ # `detail-belongs-elsewhere` 가 `detail` 로 잘린다 — 되돌림 프로브가 이걸 잡았다
146
+ # (그 전까지 그 레인은 «못 알아듣는 값» 으로 **우연히** 초록이었다).
147
+ val=$(printf '%s' "$line" | sed -E 's/^residency:[[:space:]]*//; s/[[:space:]]*—.*$//; s/[[:space:]]+-[[:space:]].*$//' | tr -d ' ')
148
+ grounds=$(printf '%s' "$line" | sed -E 's/^residency:[^—]*—[[:space:]]*//; s/^residency:[^-]*[[:space:]]-[[:space:]]//')
149
+
150
+ # 🟥 `relocatable(<경로>)` 는 **매개변수를 갖는다** — 정확 일치로만 보면 정상 사용이
151
+ # «못 알아듣는 값» 으로 막히고 자기 브랜치는 죽은 코드가 된다(되돌림 프로브가 지목).
152
+ ok=0; for v in $VALID; do
153
+ case "$val" in "$v") ok=1 ;; "$v"'('*')') ok=1 ;; esac
154
+ done
155
+ if [ "$ok" -ne 1 ]; then
156
+ echo "❌ 마커의 residency 값 '$val' 을 못 알아듣는다. 위 다섯 중 하나로 적어라." >&2
157
+ exit 1
158
+ fi
159
+
160
+ case "$val" in
161
+ relocatable*)
162
+ dest=$(printf '%s' "$val" | sed -E 's/^relocatable\(?//; s/\)$//')
163
+ if [ -z "$dest" ] || [ "$dest" = "relocatable" ]; then
164
+ echo "❌ 옮길 곳을 안 적었다. 어느 파일로 가야 하나 — 경로를 괄호 안에 적어라." >&2
165
+ exit 1
166
+ fi
167
+ cat >&2 <<MSG
168
+ 🟥 저자가 «이건 $dest 로 갈 수 있다» 고 적었는데 상주가 +$net 줄 늘었다. 둘이 안 맞는다.
169
+ $dest 로 옮기고 $RESIDENT_FILE 엔 **명령형 포인터 한 줄**만 남겨라.
170
+ MSG
171
+ exit 1 ;;
172
+ esac
173
+
174
+ if [ "${#grounds}" -lt 12 ] || [ "$grounds" = "$line" ]; then
175
+ echo "❌ residency: $val 인데 근거가 비었다. '— ' 뒤에 왜 상주여야 하는지 한 줄." >&2
176
+ exit 1
177
+ fi
178
+
179
+ # ── soul 흡수구 완충 (advisory · 절대 차단하지 않는다) ──────────────────────
180
+ # peer 지적: 「판단 좌표계」는 저자가 자기 델타에 거의 항상 붙일 수 있는 라벨이라
181
+ # `soul` 이 기본 흡수구가 된다. 🟥 게이트는 마커를 읽지 내용을 안 읽으므로 이건
182
+ # **기계로 판정할 수 없다** — 픽스처를 만들어도 그냥 통과한다.
183
+ # 대신 **재서 보여준다**: 추가된 줄의 «근거 표지»(날짜·실측·n=·반증·런 #) 밀도.
184
+ # 캘리브레이션 실물 2점 (.claude/regression/fixtures/):
185
+ # §Mechanization Boundary → soul 근거표지 3%
186
+ # §Expedition → relocatable 근거표지 16%
187
+ # 🟥 **n=2 다. 판정에 쓰지 않는다** — 중점 10% 를 넘으면 한 줄 묻기만 한다.
188
+ if [ "$val" = "soul" ]; then
189
+ addl=$(git -C "$ROOT" diff --cached -- "$RESIDENT_FILE" | grep -c '^+[^+]' 2>/dev/null)
190
+ provl=$(git -C "$ROOT" diff --cached -- "$RESIDENT_FILE" \
191
+ | grep '^+[^+]' \
192
+ | grep -cE '[0-9]{4}-[0-9]{2}-[0-9]{2}|실측|측정|n=[0-9]|반증|런 #[0-9]|PR #[0-9]' 2>/dev/null)
193
+ if [ "${addl:-0}" -gt 0 ]; then
194
+ d=$(( provl * 100 / addl ))
195
+ if [ "$d" -ge 10 ]; then
196
+ echo " ⚠️ soul 로 선언했는데 추가분의 ${d}% 가 근거 서술이다(날짜·실측·n=)."
197
+ echo " 판단 좌표계가 맞나, 아니면 근거는 signal 로 보내고 결론 한 줄만 남길 수 있나?"
198
+ echo " (참고 2점: 판단 좌표계 3%% · 이관 대상 16%%. n=2 — 판정 아니라 물음이다)"
199
+ fi
200
+ fi
201
+ fi
202
+
203
+ echo " ✅ 상주 순증 +$net 줄 · 유형 선언됨 ($val)"
204
+ exit 0
@@ -416,6 +416,20 @@ else
416
416
  _absent_subject_verdict "test_adapter_lanes.sh" "scripts/test_adapter_lanes.sh" || fail=1
417
417
  fi
418
418
 
419
+ # track→repo 해석기 단일화 레인 (2026-08-21 harness-doctor F-1).
420
+ # 🟥 배선이 없으면 이 레인은 «만들고 배선 안 함» 이 된다. 그리고 이 수리는 특히 그 위험이 크다 —
421
+ # 세 소비자가 전부 npm 으로 나가므로, 라이브러리가 files[] 나 selfcheck 어느 한쪽에서 빠지면
422
+ # 소비자 install 에서만 조용히 수리 이전 동작으로 강등된다(로컬은 초록).
423
+ if [ ! -f scripts/fh_track_resolve.sh ]; then
424
+ _absent_subject_verdict "test_track_resolve_lanes.sh" "scripts/fh_track_resolve.sh" || fail=1
425
+ elif [ -f scripts/test_track_resolve_lanes.sh ]; then
426
+ if ! bash scripts/test_track_resolve_lanes.sh; then
427
+ fail=1
428
+ fi
429
+ else
430
+ _absent_subject_verdict "test_track_resolve_lanes.sh" "scripts/test_track_resolve_lanes.sh" || fail=1
431
+ fi
432
+
419
433
  if [ ! -f scripts/package_coverage_check.sh ]; then
420
434
  _absent_subject_verdict "test_package_coverage_lanes.sh" "scripts/package_coverage_check.sh" || fail=1
421
435
  elif [ -f scripts/test_package_coverage_lanes.sh ]; then
@@ -530,6 +544,7 @@ for _pair in \
530
544
  ".github/workflows/regression-guard.yml|scripts/test_regression_guard_ci_lanes.sh" \
531
545
  "templates/.git-hooks/pre-commit|scripts/test_marker_standpoint_lanes.sh" \
532
546
  "templates/.git-hooks/pre-commit|scripts/test_marker_thirdparty_lanes.sh" \
547
+ "templates/.git-hooks/pre-commit|scripts/test_marker_soul_check_lanes.sh" \
533
548
  "scripts/residency_closure_scan.py|scripts/test_residency_closure_lanes.sh" \
534
549
  "scripts/reviewer_capability_corpus.tsv|scripts/test_reviewer_capability_conformance.sh" \
535
550
  "scripts/field_canon_preload.sh|scripts/test_field_canon_lanes.sh" \
@@ -703,6 +718,37 @@ else
703
718
  fail=1
704
719
  fi
705
720
 
721
+ # test_marker_defense_lanes — Wave 1-D 방어줄(`axis2-defense:`)의 known-pair (2026-08-20).
722
+ # 🟥 이 레인이 존재하는 이유 자체가 기록이다: 절차를 흡수해온 sibling 문서가 «훅이 이 줄을 읽는다»·
723
+ # «marker_floor 레인이 핀한다» 고 적었는데 **둘 다 0 히트**였다(컨트롤 crossfamily 21).
724
+ # 산문은 이식됐고 기계는 안 왔다. 그래서 FH 가 짓는다 — 우리가 그 주장을 할 땐 참이도록.
725
+ if [ ! -f scripts/test_marker_defense_lanes.sh ]; then
726
+ echo "FAIL test_marker_defense_lanes.sh: missing — axis2-defense 레인에 앵커 없음"
727
+ fail=1
728
+ elif _out=$(bash scripts/test_marker_defense_lanes.sh < /dev/null 2>&1); then
729
+ echo "PASS test_marker_defense_lanes.sh ($(printf '%s\n' "$_out" | grep -oE '[0-9]+ lanes hold' | tail -1))"
730
+ else
731
+ echo "FAIL test_marker_defense_lanes.sh: 방어줄 검증이 바뀌었다"
732
+ _show_failure "$_out"
733
+ fail=1
734
+ fi
735
+
736
+ # test_heavy_classifier_lanes — 4축 게이트의 **라우팅 판별자**에 대한 known-pair (2026-08-20).
737
+ # 🟥 이 분류기가 놓치는 경로는 FAIL 하지 않는다 — 게이트가 **조용히 적용되지 않고** 커밋이
738
+ # 초록으로 나간다. 가장 조용한 실패 형태이고, 실측상 이걸 시험하는 레인이 **0개**였다
739
+ # (컨트롤: 훅을 언급하는 test_*.sh 는 12개). sibling harness 에서 흡수했고, 핵심은
740
+ # 픽스처 목록이 아니라 **훅에서 정규식을 실시간 추출**하는 설계다(픽스처가 대상에서 안 떨어진다).
741
+ if [ ! -f scripts/test_heavy_classifier_lanes.sh ]; then
742
+ echo "FAIL test_heavy_classifier_lanes.sh: missing — 게이트 라우팅 판별자에 앵커 없음"
743
+ fail=1
744
+ elif _out=$(bash scripts/test_heavy_classifier_lanes.sh < /dev/null 2>&1); then
745
+ echo "PASS test_heavy_classifier_lanes.sh (4-way routing known-pair + fail-closed extraction)"
746
+ else
747
+ echo "FAIL test_heavy_classifier_lanes.sh: 게이트 라우팅이 바뀌었다"
748
+ _show_failure "$_out"
749
+ fail=1
750
+ fi
751
+
706
752
  # test_satellite_publish_gate_lanes — 위성 공개표면 게이트(2026-08-18, 원정 2차).
707
753
  # 🟥 이 배선이 없어서 CI 가 `lane-runner: lane suite(s) with no runner and no declaration` 로
708
754
  # 빨갰다. 스위트를 신설하고 아무 데서도 안 돌린 것 — 검사기의 표현대로
@@ -1018,6 +1064,22 @@ else
1018
1064
  fail=1
1019
1065
  fi
1020
1066
 
1067
+ # residency_admission — 같은 형태. 🟥 그리고 **또 같은 얼굴이었다**: 2026-08-20 에 이 레인을
1068
+ # 지으면서 여기 배선을 안 했고, `lane-runner` 가 «돌리는 게 없는 스위트는 산문이다» 로 CI 에서
1069
+ # 잡았다. 아래 target_freeze 주석이 기록한 그 형태의 **최소 세 번째**다 — 즉 이건 개인 부주의가
1070
+ # 아니라 «레인을 짓는 사람이 배선 지점을 안 본다» 는 구조적 순서 문제다.
1071
+ if [ ! -f scripts/residency_admission_check.sh ]; then
1072
+ echo "FAIL scripts/residency_admission_check.sh is wired into pre-commit but absent — a deleted subject, not a skip"
1073
+ fail=1
1074
+ elif [ -f scripts/test_residency_admission_lanes.sh ]; then
1075
+ if ! bash scripts/test_residency_admission_lanes.sh; then
1076
+ fail=1
1077
+ fi
1078
+ else
1079
+ echo "FAIL test_residency_admission_lanes.sh: residency_admission_check.sh present but its anchor is missing"
1080
+ fail=1
1081
+ fi
1082
+
1021
1083
  # target_freeze — 같은 형태(shipped subject + 자기 앵커). 🟥 이 블록이 없어서 `lane-runner`
1022
1084
  # 검사가 «돌리는 게 없는 스위트는 산문이다» 로 정확히 걸었다. 지은 사람이 자기 레인을 안
1023
1085
  # 배선한 것이고, 오늘 이 세션이 남의 코드에서 네 번 잡은 그 얼굴이다.
@@ -1200,6 +1262,32 @@ else
1200
1262
  fail=1
1201
1263
  fi
1202
1264
 
1265
+ # prior-art prompt (T2) — 새 메커니즘을 쓰기 직전 «내부·외부·숙고» 를 모델 컨텍스트에 넣는 훅.
1266
+ # 레인의 다수가 **음성**이다: 이 훅의 사활은 막는 것이 아니라 소음이 아닌 것이다.
1267
+ if [ ! -f scripts/prior_art_prompt.sh ]; then
1268
+ _absent_subject_verdict "test_prior_art_prompt_lanes.sh" "scripts/prior_art_prompt.sh" || fail=1
1269
+ elif [ -f scripts/test_prior_art_prompt_lanes.sh ]; then
1270
+ if ! bash scripts/test_prior_art_prompt_lanes.sh; then
1271
+ fail=1
1272
+ fi
1273
+ else
1274
+ echo "FAIL test_prior_art_prompt_lanes.sh: prior_art_prompt.sh present but its anchor is missing"
1275
+ fail=1
1276
+ fi
1277
+
1278
+ # outbound-query 위생 린트 — 나가는 질의에 내부 토큰이 실렸는지. 레인이 이 가드의 fail-closed
1279
+ # 분기 4개(라이브러리 부재·defaults 부재·override 부재·상태값 오염)에 각각 짝을 갖는다.
1280
+ if [ ! -f scripts/outbound_query_guard.sh ]; then
1281
+ _absent_subject_verdict "test_outbound_query_lanes.sh" "scripts/outbound_query_guard.sh" || fail=1
1282
+ elif [ -f scripts/test_outbound_query_lanes.sh ]; then
1283
+ if ! bash scripts/test_outbound_query_lanes.sh; then
1284
+ fail=1
1285
+ fi
1286
+ else
1287
+ echo "FAIL test_outbound_query_lanes.sh: outbound_query_guard.sh present but its anchor is missing"
1288
+ fail=1
1289
+ fi
1290
+
1203
1291
  if [ ! -f scripts/pipe_verdict_guard.sh ]; then
1204
1292
  _absent_subject_verdict "test_pipe_verdict_guard_lanes.sh" "scripts/pipe_verdict_guard.sh" || fail=1
1205
1293
  elif [ -f scripts/test_pipe_verdict_guard_lanes.sh ]; then
@@ -268,6 +268,71 @@ echo
268
268
  # 부재로 HARNESS_ERROR(3) 를 낸다 — 기대값과 같은 값이다. 즉 R2 는 Q5 를 원리적으로
269
269
  # 구분하지 못한다. 그건 Q5 의 결함이 아니라 **이 프로브 축의 사각지대**이고,
270
270
  # Q5 는 R1 이 아니라 별도 축(모호 판정 자체를 지우는 중화)으로만 흔들 수 있다.
271
+ # ── P 레인: peer_resolve 자체의 이름 해석 (2026-08-21 신설) ─────────────────────
272
+ # 🟥 이게 왜 필요한가: `peer_resolve.sh` 의 `FH_PROJECTS_HOME` 팔을 `fh_track_resolve.sh` 로
273
+ # 배선하면서 **거짓 AMBIGUOUS 결함이 하나 닫혔는데, 그 변경을 되돌려도 빨개지는 레인이
274
+ # 0개였다**(실측). 즉 기존 31 레인은 이 축에 대해 앵커가 아니다 —
275
+ # [[feedback_anchor_can_be_decorative]]. 픽스처는 **실제로 뚫려 있던 표기**(공백 이름)를 쓴다.
276
+ #
277
+ # 🟥 **초판 P 레인은 거짓 초록이었다 — 계기가 대상을 안 불렀다.** `peer_resolve.sh` 를
278
+ # `bash <파일> <이름>` 으로 «실행» 했는데 이 파일은 **sourced 라이브러리**라 실행하면
279
+ # 아무것도 안 하고 rc=0 으로 끝난다. P1·P3 이 그 rc=0 을 «해소 성공» 으로 읽어 초록이었고,
280
+ # P2 의 빨강도 코드 결함이 아니라 내 하네스 결함이었다. 판별 근거는 **HEAD 에서도 같은
281
+ # 값이 나왔다**는 것이다(변경 전후가 같으면 계기가 대상을 안 보고 있다).
282
+ # [[feedback_instrument_vs_target_and_budget]] — 이상하면 대상보다 계기를 먼저 의심해라.
283
+ _PR="${FH_ADAPTER_DIR:-$REPO_ROOT/scripts/adapters}/peer_resolve.sh"
284
+ if [ ! -f "$_PR" ]; then
285
+ echo " SKIP P 레인 (peer_resolve.sh 없음) — PASS 가 아니라 미검사다"
286
+ else
287
+ # shellcheck source=scripts/adapters/peer_resolve.sh
288
+ . "$_PR"
289
+ if ! type fh_peer_resolve >/dev/null 2>&1; then
290
+ echo " ❌ P 레인 하네스 결함 — source 했는데 fh_peer_resolve 가 없다"
291
+ FAIL=1
292
+ else
293
+ _PW="$(mktemp -d)"
294
+ mkdir -p "$_PW/sp ace" "$_PW/aliased-dev" "$_PW/dual" "$_PW/dual-dev"
295
+ # P0 컨트롤 — 계기가 대상을 실제로 부르는가. 이게 초록이 아니면 아래 셋은 의미가 없다.
296
+ FH_PROJECTS_HOME="$_PW" fh_peer_resolve nosuchpeer >/dev/null 2>&1; _prc=$?
297
+ _lane P0 control "계기 생존 — 없는 peer 는 부재(1)로 온다(rc=0 무음이면 대상을 안 부른 것)" 1 "$_prc"
298
+ # P1 — 공백 이름 1건: 한 후보가 두 단어로 세어져 거짓 AMBIGUOUS(2) 가 나던 자리
299
+ FH_PROJECTS_HOME="$_PW" fh_peer_resolve 'sp ace' >/dev/null 2>&1; _prc=$?
300
+ _lane P1 resolve-guard "공백 이름 1건은 정상 해소 — 거짓 AMBIGUOUS 아님" 0 "$_prc"
301
+ # P2 컨트롤 — 진짜 모호는 여전히 거부해야 한다(P1 의 통과가 모호검출을 죽인 게 아니다)
302
+ FH_PROJECTS_HOME="$_PW" fh_peer_resolve dual >/dev/null 2>&1; _prc=$?
303
+ _lane P2 control "진짜 모호(dual + dual-dev)는 여전히 고르지 않는다" 2 "$_prc"
304
+ # P3 컨트롤 — 별칭 해소가 실제로 살아 있나(P 레인이 통째로 공허하지 않다는 증거)
305
+ FH_PROJECTS_HOME="$_PW" fh_peer_resolve aliased >/dev/null 2>&1; _prc=$?
306
+ _lane P3 resolve-alias "'-dev' 별칭이 해소된다" 0 "$_prc"
307
+ rm -rf "$_PW"
308
+ fi
309
+ fi
310
+ # 🟥 **명시 잔여 — 팔 A 는 안 닫혔다.** `FH_CLUSTER_ROOTS`(임의 절대경로의 basename 조회)는
311
+ # 조회 모델이 달라 라이브러리로 통일하지 않았고, 거기 공백 거짓-AMBIGUOUS 가 **그대로 있다**
312
+ # (실측 확인). 별도 축의 독립 수리이므로 레인을 안 박았다 — 안 잰 것이지 통과한 게 아니다.
313
+
314
+ # 🟥 **2026-08-21 — 사전등록 두 줄을 갱신했다. 이유를 적는 게 갱신 자체보다 중요하다.**
315
+ # (위 규율 그대로: 관측에 맞춰 «조용히» 고치면 프로브의 판별력 증거가 사라진다.)
316
+ # · **R1: `G1 G5 M1 Q1` → `G1 G5 M1 Q1 W1`** — **선재 오류**다. 사전등록이 `qasp_web_rules.sh`
317
+ # (W 레인) 도입 **이전**에 쓰였고, W1 도 부재 분기에 결박된 레인이라 빨간 게 맞다.
318
+ # 내 변경 이전 HEAD 에서도 `--revert-probe` 는 이 차이로 RC=1 이었다(실측). 즉 이건
319
+ # 내가 깬 게 아니라 **이미 틀려 있던 것을 이번에 갚는 것**이다.
320
+ # · **R2: 11개 → `M4 Q4 Q7 Q8`** — 이건 **내 변경이 원인**이고, 기대값을 낮추는 것으로
321
+ # 끝내면 안 된다. `peer_resolve.sh` 의 `FH_PROJECTS_HOME` 팔이 이제 별칭을
322
+ # `fh_track_resolve.sh` 에서 받으므로 **`fh_peer_alias_candidates` 를 안 읽는다** →
323
+ # 그걸 중화해도 안 흔들린다. 남은 넷은 전부 `FH_CLUSTER_ROOTS`(팔 A) 레인이다.
324
+ # 🟥 **명시 잔여 — 커버리지가 줄었다**: R2 는 더 이상 «팔 B 가 별칭을 보는가» 를
325
+ # 흔들지 못한다. 그 축의 앵커는 지금 `scripts/test_track_resolve_lanes.sh`(라이브러리
326
+ # 레인 1~3 + 배선 레인 9)에만 있고, **어댑터 층에는 없다.** 팔 A 는 조회 모델이 달라
327
+ # (임의 절대경로의 basename) 통일하지 않았으므로 R2 는 그대로 유효하다.
328
+ # 이 잔여를 닫으려면 라이브러리를 중화하는 별도 프로브 축(R3)이 필요하고,
329
+ # 지금 프로브는 `scripts/adapters/` 만 사본으로 뜨므로 **그 축은 아직 없다.**
330
+ # · **P 레인 추가분(같은 날, 2차)**: `P0 P1` 이 R1 에, `P1` 이 R2 에 들어간다. 사전등록을
331
+ # P 레인 **추가 전에** 썼기 때문에 빠져 있었다. 기계적 설명: R1 은 부재 분기를 «순진한
332
+ # 추측» 으로 바꾸므로 P0(없는 peer → rc=1 이어야 한다)이 rc=0 을 받아 빨개지고, P1 도
333
+ # 그 조기 반환에 걸린다. R2 는 별칭 후보를 이름 하나로 줄이므로 P1(공백 이름 해소)이
334
+ # 후보 루프를 못 탄다. 🟥 **둘 다 «레인이 그 중화에 실제로 반응한다»는 뜻이라 빨간 게
335
+ # 맞다** — 이 갱신은 기대를 낮추는 게 아니라 **새 앵커를 사전등록에 등록하는 것**이다.
271
336
  # ★중화는 **실물 파일이 아니라 사본**에 건다(FH_ADAPTER_DIR). 실물 트리를 건드리지 않고,
272
337
  # 사본이 실물과 «중화한 줄 말고는 동일» 함을 적용확인 단계에서 대조한다.
273
338
  _revert_probe() {
@@ -317,14 +382,14 @@ case "${1:-}" in
317
382
  _revert_probe R1 \
318
383
  's|^ printf .peer-resolve: peer| printf "%s" "${FH_PROJECTS_HOME:-$HOME/projects}/$n"; return 0 # NEUTRALIZED_R1\n printf '"'"'peer-resolve: peer|' \
319
384
  'NEUTRALIZED_R1' \
320
- 'G1 G5 M1 Q1' \
385
+ 'G1 G5 M1 P0 P1 Q1 W1' \
321
386
  '«부재» 분기 중화 → 부재 분기에 결박된 레인만 빨개져야 한다' || RP=1
322
387
  echo
323
388
  # R2: 별칭 후보를 이름 자신 하나로 줄인다 — `-dev` 를 못 찾게 된다.
324
389
  _revert_probe R2 \
325
390
  's|^ printf .%s\\n. "\$1" "\$1-dev".*$| printf "%s\\n" "$1" # NEUTRALIZED_R2|' \
326
391
  'NEUTRALIZED_R2' \
327
- 'M2 M3 M4 M5 M6 Q2 Q3 Q4 Q6 Q7 Q8' \
392
+ 'M4 P1 Q4 Q7 Q8' \
328
393
  '별칭 목록 중화 → mate/qasp 쪽 레인만 빨개져야 한다(gstack 은 별칭이 필요 없다)' || RP=1
329
394
  echo
330
395
  [ "$RP" -eq 0 ] || FAIL=1
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env bash
2
+ # test_heavy_classifier_lanes.sh — known-pair anchor for the pre-commit HEAVY/LIGHT path classifier.
3
+ #
4
+ # WHY: the classifier is three `grep -E` lines, and everything downstream of it — Axes 2+3, the
5
+ # marker, the whole full-gate path — is gated on what they match. A term missing from those lines
6
+ # does not FAIL; it makes the gate **silently not apply**, and the commit goes green. There is no
7
+ # louder symptom. Nothing was watching the watcher: measured 2026-08-20, `scripts/test_*.sh` files
8
+ # testing this classifier = **0**, while 12 test files touch the hook for other reasons.
9
+ #
10
+ # Absorbed from a sibling harness (pmh-dev, 2026-08-08) rather than invented here. The load-bearing
11
+ # idea is the extraction below, not the fixture list.
12
+ #
13
+ # 🟥 FH's classifier is FOUR-WAY, not two-way like the sibling's. The port is not a copy:
14
+ # HEAVY → full gate
15
+ # CARVEOUT → knowledge/ · docs/*.md · AGENTS.md — promoted to HEAVY only if the diff is
16
+ # substantive (adds a fenced block or a citation), else demoted to LIGHT
17
+ # LIGHT → CATALOG.md · tracks/
18
+ # uncovered → matches none of the three. **This class is real and is pinned below** — a path
19
+ # nobody classified is the quietest way for a surface to leave the gate.
20
+ #
21
+ # This lane pins all four directions. Over-matching is a defect too: a gate that fires on every
22
+ # commit is the one people learn to bypass with `--no-verify`, which disarms the Destructive-Op
23
+ # gate living in the same hook.
24
+ #
25
+ # Usage: bash scripts/test_heavy_classifier_lanes.sh → exit 0 if all pairs hold, 1 otherwise.
26
+ set -uo pipefail
27
+
28
+ REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
29
+ HOOK="$REPO_ROOT/templates/.git-hooks/pre-commit"
30
+ [ -r "$HOOK" ] || { echo "❌ FAIL — $HOOK not readable"; exit 1; }
31
+
32
+ FAILED=0
33
+
34
+ # Extract the LIVE regexes from the hook rather than restating them. A lane that hard-codes its own
35
+ # copy of the pattern passes forever while the real classifier drifts away underneath it — that is
36
+ # testing the fixture instead of the subject, and it is the failure this whole file exists to catch.
37
+ _extract() { # $1 = variable name as it appears at column 0 in the hook
38
+ sed -n "/^$1=\\\$(echo \"\\\$STAGED\"/,/|| true)/p" "$HOOK" \
39
+ | grep -o 'grep -E "[^"]*"' | head -1 | sed 's/^grep -E "//; s/"$//'
40
+ }
41
+ HEAVY_RE=$(_extract HEAVY)
42
+ CARVE_RE=$(_extract CARVEOUT)
43
+ LIGHT_RE=$(_extract LIGHT)
44
+
45
+ # 🟥 Fail CLOSED on extraction failure. An empty regex makes `grep -qE ""` match EVERYTHING, so a
46
+ # silent extraction failure would turn this lane green while classifying every path as heavy.
47
+ for v in HEAVY_RE CARVE_RE LIGHT_RE; do
48
+ if [ -z "${!v}" ]; then
49
+ echo "❌ FAIL — could not extract \$$v from the hook."
50
+ echo " The hook's shape changed. Fix THIS extraction — do not delete the lane, and do not"
51
+ echo " inline a copy of the pattern here (that is how a lane stops covering its subject)."
52
+ exit 1
53
+ fi
54
+ done
55
+
56
+ classify() { # <path> → heavy | carveout | light | uncovered
57
+ # Order mirrors the hook: HEAVY wins, then CARVEOUT (which the hook excludes
58
+ # knowledge/shared/rules/ from, since that is already HEAVY), then LIGHT.
59
+ if printf '%s\n' "$1" | grep -qE "$HEAVY_RE"; then echo heavy
60
+ elif printf '%s\n' "$1" | grep -qE "$CARVE_RE"; then echo carveout
61
+ elif printf '%s\n' "$1" | grep -qE "$LIGHT_RE"; then echo light
62
+ else echo uncovered; fi
63
+ }
64
+
65
+ check() { # <expect> <path> <why>
66
+ local expect="$1" path="$2" why="$3" got
67
+ got=$(classify "$path")
68
+ if [ "$got" = "$expect" ]; then
69
+ printf ' ✅ %-9s %-52s %s\n' "$expect" "$path" "$why"
70
+ else
71
+ printf ' ❌ %-9s %-52s expected %s, got %s\n' "$expect" "$path" "$expect" "$got"
72
+ FAILED=1
73
+ fi
74
+ }
75
+
76
+ echo "[heavy-classifier] known-pair anchor (4-way)"
77
+ echo " ── must be HEAVY (full 4-axis gate applies) ──"
78
+ check heavy "plugins/fh-meta/skills/steel-quench/SKILL.md" "skill spec"
79
+ check heavy "plugins/fh-meta/skills/goal-quench/SKILL_detail.md" "detail file — the salience-split half"
80
+ check heavy "plugins/fh-meta/skills/fh/assets/note.md" "any md under a skill dir"
81
+ check heavy "plugins/fh-meta/agents/challenger.md" "agent definition"
82
+ check heavy ".claude/agents/local.md" "repo-local agent definition"
83
+ check heavy ".claude/rules/fh_4axis_gate.md" "gate rule"
84
+ check heavy "knowledge/shared/rules/operations.md" "shared rules — HEAVY, not carveout"
85
+ check heavy "templates/.git-hooks/pre-commit" "the gate machinery itself"
86
+ check heavy "templates/CLAUDE.md" "propagated field asset"
87
+ check heavy "CLAUDE.md" "the resident layer"
88
+ check heavy "scripts/selfcheck.sh" "shipped executable"
89
+ check heavy "scripts/adapters/qasp_web_rules.sh" "nested script still matches"
90
+
91
+ echo " ── CARVEOUT (heavy only if the diff is substantive) ──"
92
+ check carveout "knowledge/shared/harness-core/sonnet_floor_doctrine.md" "doctrine prose"
93
+ check carveout "docs/USER_GUIDE.md" "published guide"
94
+ check carveout "AGENTS.md" "Codex entry point"
95
+
96
+ echo " ── must stay LIGHT (over-firing trains --no-verify) ──"
97
+ check light "CATALOG.md" "index entry"
98
+ check light "tracks/_meta/fh_completed.md" "session record"
99
+ check light "tracks/qasp/NEXT_ACTION.md" "field track record"
100
+
101
+ echo " ── honestly UNCOVERED — pinned so the residual cannot be believed closed ──"
102
+ check uncovered "README.md" "🟥 a README-only commit takes NO 4-axis path"
103
+ check uncovered "package.json" "files[] / version changes are unclassified"
104
+ check uncovered ".github/workflows/validate.yml" "🟥 CI definition — changing what CI runs is unclassified"
105
+ check uncovered "scripts/memory_link_check.py" "🟥 .py is not .sh — shipped python is unclassified"
106
+
107
+ echo " ── controls (the classifier discriminates, it does not just fire) ──"
108
+ # known-negative: a path that resembles a HEAVY one but must not match. This is the arm that
109
+ # separates "the classifier works" from "the classifier fires on everything" — without it, a
110
+ # regex of `.` would pass every HEAVY row above.
111
+ check uncovered "notes/scripts/foo.sh.txt" "control — .sh.txt is not a shell script (\$-anchored)"
112
+ check uncovered "notes/skills-overview.md" "control — 'skills' in a path is not a skill spec"
113
+ # ⚠️ this control was first written as `docs/design/skills-overview.md`, which the lane
114
+ # correctly classified `carveout` (`^docs/.*\.md$`). The FIXTURE was wrong, not the
115
+ # subject — pick a path outside every class when the claim is "matches none of them".
116
+
117
+ # 🟥 KNOWN OVER-MATCH, pinned deliberately as `heavy` rather than "fixed" (2026-08-20).
118
+ # `SKILL(_detail)?\.md` is written WITHOUT a `$` anchor, so any path merely CONTAINING that
119
+ # substring classifies HEAVY — `SKILL.md.bak`, `SKILL.md.orig`, `SKILL.md~`. Recorded, not
120
+ # repaired, for three reasons, and the reasons are the point:
121
+ # ⓐ the error direction is fail-SAFE — it adds gate coverage, never removes it. The classifier's
122
+ # dangerous direction is under-matching (the gate silently not applying), which this is not.
123
+ # ⓑ those paths are not committed in practice, so the over-block the hook warns about
124
+ # ("a gate that always fires is the one people learn to bypass") does not actually fire here.
125
+ # ⓒ anchoring it is a live change to gate routing, and gate routing is exactly what this lane
126
+ # exists to detect changes in. Changing the subject in the same commit that first measures it
127
+ # leaves no before-state.
128
+ # This row is therefore a RECORD, not an endorsement: if someone anchors the pattern later, this
129
+ # line goes red and they will read this comment — which is the whole job of a known-pair.
130
+ check heavy "docs/skills/SKILL.md.bak" "🟥 over-match: pattern is unanchored (see comment)"
131
+
132
+ echo
133
+ echo "🟥 NOT COVERED BY THIS LANE (named, not silently absent):"
134
+ echo " · \`diff_is_substantive\` — whether a CARVEOUT file is promoted depends on diff CONTENT,"
135
+ echo " not path. This lane pins the PATH class only. That test needs its own anchor."
136
+ echo " · the hook's behaviour once classified (marker required, manifest required). Pinned"
137
+ echo " elsewhere (test_marker_*_lanes.sh); this lane covers only the routing decision."
138
+
139
+ if [ "$FAILED" -eq 0 ]; then
140
+ echo "✅ heavy-classifier: all pairs hold"
141
+ exit 0
142
+ fi
143
+ echo "❌ heavy-classifier: at least one pair broke — the gate's routing changed"
144
+ exit 1