@chrono-meta/fh-gate 1.4.89 → 1.4.91

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 (47) hide show
  1. package/.claude/judgment_circuits.txt +14 -0
  2. package/.claude/rules/fh_4axis_gate.md +7 -0
  3. package/.claude-plugin/marketplace.json +2 -2
  4. package/AGENTS.md +25 -0
  5. package/CLAUDE.md +202 -12
  6. package/knowledge/shared/harness-core/dispatch_conditional_prohibition.md +105 -0
  7. package/knowledge/shared/harness-core/fh_three_layer_canon.md +307 -0
  8. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +100 -0
  9. package/knowledge/shared/harness-core/onboarding_acceleration_autopilot.md +3 -1
  10. package/knowledge/shared/harness-core/ship_readiness_gate.md +181 -13
  11. package/knowledge/shared/learnings/subagent_invocations_log.yaml +640 -0
  12. package/knowledge/shared/rules/multi_session_close_protocol.md +118 -0
  13. package/package.json +23 -2
  14. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  15. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  16. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +56 -8
  17. package/plugins/fh-meta/skills/install-wizard/SKILL.md +33 -0
  18. package/plugins/fh-meta/skills/install-wizard/SKILL_detail.md +104 -15
  19. package/scripts/branch_claim.sh +266 -0
  20. package/scripts/chamber_run.sh +64 -2
  21. package/scripts/chamber_witness.sh +439 -0
  22. package/scripts/compaction_probe.sh +456 -0
  23. package/scripts/digest_landing_check.sh +385 -0
  24. package/scripts/directional_diff_gate.sh +459 -0
  25. package/scripts/fh_env_delta_scan.sh +15 -0
  26. package/scripts/fh_session_load.sh +34 -1
  27. package/scripts/field_canon_preload.sh +129 -0
  28. package/scripts/hook_source_lib.sh +41 -0
  29. package/scripts/judgment_circuit_lint.sh +239 -0
  30. package/scripts/novelty_claim_check.sh +193 -0
  31. package/scripts/relay_channel.sh +645 -0
  32. package/scripts/reviewer_capability_corpus.tsv +124 -0
  33. package/scripts/selfcheck.sh +106 -0
  34. package/scripts/session_close_check.sh +134 -1
  35. package/scripts/test_branch_claim_lanes.sh +231 -0
  36. package/scripts/test_dispatch_log_lanes.sh +35 -1
  37. package/scripts/test_field_canon_lanes.sh +142 -0
  38. package/scripts/test_hook_source_gate_lanes.sh +81 -0
  39. package/scripts/test_marker_crossfamily_lanes.sh +132 -0
  40. package/scripts/test_marker_floor_lanes.sh +9 -8
  41. package/scripts/test_relay_channel_lanes.sh +583 -0
  42. package/scripts/test_reviewer_capability_conformance.sh +173 -0
  43. package/scripts/test_wizard_snippet_merge_lanes.sh +104 -11
  44. package/scripts/utterance_landing_check.sh +209 -0
  45. package/templates/.git-hooks/pre-commit +297 -13
  46. package/templates/settings.Compaction.snippet.json +56 -0
  47. package/templates/settings.FieldCanon.snippet.json +51 -0
@@ -33,7 +33,14 @@ if [ -z "$MATCHER" ]; then
33
33
  echo " this lane cannot verify what it claims to. Update the lane WITH the subject."
34
34
  exit 1
35
35
  fi
36
+ # 주어의 _cat_logs 정의를 그대로 들어온다(재작성 금지 — 재작성하면 정규화가 갈린다)
37
+ CATLOGS_DEF=$(awk '/^_cat_logs\(\) \{/,/^\}/' "$CHECK")
38
+ if [ -z "$CATLOGS_DEF" ]; then
39
+ echo "FAIL subject 에 _cat_logs 가 없다 — 전환 설계(레거시 ∪ 디렉터리)가 사라졌거나 이름이 바뀌었다."
40
+ exit 1
41
+ fi
36
42
  echo "── matcher located in the subject"
43
+ echo "── _cat_logs lifted from the subject ($(printf '%s' "$CATLOGS_DEF" | grep -c '') lines)"
37
44
 
38
45
  T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
39
46
  D=2026-08-02
@@ -123,7 +130,10 @@ _eval_subject_assign() { # $1 = variable name; echoes what the SUBJECT computes
123
130
  [ -n "$line" ] || { echo "NOLINE"; return; }
124
131
  bash -c "set -uo pipefail
125
132
  _int() { case \"\${1:-}\" in (''|*[!0-9]*) echo 0 ;; (*) echo \"\$1\" ;; esac; }
126
- TODAY=NOSUCHDATE; TALLY='$CHECK'; LOG='$CHECK'
133
+ # LOGGED 는 주어의 _cat_logs 에 의존한다. 이걸 안 들여오면 «함수 없음 → 빈 입력 → 0»
134
+ # 으로 **틀린 이유로 통과**한다 — 이 파일이 경고하는 장식 앵커 그 자체다.
135
+ $CATLOGS_DEF
136
+ TODAY=NOSUCHDATE; TALLY='$CHECK'; LOG='$CHECK'; LOGDIR='$CHECK.nodir'
127
137
  $line
128
138
  printf %s \"\$$var\"" 2>/dev/null
129
139
  }
@@ -133,6 +143,30 @@ for _v in DISPATCHED LOGGED; do
133
143
  ( set -uo pipefail; [ "${_got:-x}" -eq 0 ] ) 2>/dev/null ; chk $? "subject's \$$_v survives \`[ -eq 0 ]\` (the disarm threw here)"
134
144
  done
135
145
 
146
+ echo ""
147
+ echo "── 전환 설계 레인: 레거시 단일 파일 ∪ 세션별 디렉터리 ──"
148
+ # 주어의 _cat_logs + LOGGED 를 그대로 들어와 4가지 상태에서 센다.
149
+ LOGGED_LINE=$(grep -E "^LOGGED=" "$CHECK" | head -1)
150
+ _count_in() { # $1=LOG 경로(없으면 빈문자) $2=LOGDIR 경로(없으면 빈문자)
151
+ bash -c "set -uo pipefail
152
+ _int() { case \"\${1:-}\" in (''|*[!0-9]*) echo 0 ;; (*) echo \"\$1\" ;; esac; }
153
+ $CATLOGS_DEF
154
+ TODAY='$D'; LOG='$1'; LOGDIR='$2'
155
+ $LOGGED_LINE
156
+ printf %s \"\$LOGGED\"" 2>/dev/null
157
+ }
158
+ LT="$T/split"; mkdir -p "$LT/dir"
159
+ printf -- "- date: %s\n" "$D" > "$LT/legacy.yaml" # 1건
160
+ printf -- "- date: %s\n- date: '%s'\n" "$D" "$D" > "$LT/dir/a.yaml" # 2건 (두 표기 다)
161
+ printf -- "- date: %s\n" "$D" > "$LT/dir/b.yaml" # 1건
162
+ [ "$(_count_in "$LT/legacy.yaml" "$LT/nosuchdir")" = 1 ] ; chk $? "레거시 파일만 → 1"
163
+ [ "$(_count_in "$LT/nofile.yaml" "$LT/dir")" = 3 ] ; chk $? "디렉터리만 → 3 (파일 2개 합산)"
164
+ [ "$(_count_in "$LT/legacy.yaml" "$LT/dir")" = 4 ] ; chk $? "둘 다 → 4 (합집합)"
165
+ [ "$(_count_in "$LT/nofile.yaml" "$LT/nosuchdir")" = 0 ] ; chk $? "둘 다 없음 → 0 (부재는 오류가 아니다)"
166
+ # ★ 컨트롤: 다중 파일에 grep -c 를 직접 걸면 깨진다는 실측을 레인으로 고정
167
+ _naive=$(grep -cE "^- date: *'?$D'?" "$LT/dir"/*.yaml 2>/dev/null | tr -d ' \n')
168
+ [ "$_naive" != 3 ] ; chk $? "컨트롤: naive grep -c 다중파일은 3을 못 낸다 (실제='$_naive' — 경로:개수 형태)"
169
+
136
170
  echo ""
137
171
  if [ "$FAILED" -ne 0 ]; then echo "DISPATCH-LOG LANES: FAIL"; exit 1; fi
138
172
  echo "DISPATCH-LOG LANES: PASS ($PASS/$PASS)"
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env bash
2
+ # field_canon_preload.sh 레인 — known-pair 교정.
3
+ #
4
+ # 이 파일이 있는 이유: 2026-08-09 에 한 세션이 종일 qasp 를 파면서 그 레포 정본(README 604줄 ·
5
+ # docs/governance 32개)을 **하나도 안 읽고** 코드 역추론했고 **네 번 정정당했다(자력 적발 0)**.
6
+ # ①은 그날의 **첫 메시지를 그대로** 넣는다 — 훅이 있었다면 그 자리에서 걸렸어야 한다.
7
+ set -uo pipefail
8
+ HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
9
+ H="$HERE/field_canon_preload.sh"
10
+ TMP=$(mktemp -d) || exit 10
11
+ trap 'rm -rf "$TMP"' EXIT
12
+ PASS=0; FAIL=0
13
+ ok(){ PASS=$((PASS+1)); printf ' ✅ %s\n' "$1"; }
14
+ no(){ FAIL=$((FAIL+1)); printf ' ❌ %s — %s\n' "$1" "$2"; }
15
+ # ★`${3:+VAR=v}` 는 파라미터 확장이라 bash 가 **환경 할당으로 안 읽는다** — env 로 넘긴다.
16
+ # (이 파일 초판이 그렇게 써서 「레포 부재」 레인이 거짓 적색을 냈다. 계기 결함이었다.)
17
+ run(){ local extra=""; [ -n "${3:-}" ] && extra="FIELD_CANON_PROJECT_ROOT=$3"
18
+ printf '%s' "$1" | env FIELD_CANON_SENTINEL_DIR="$2" $extra bash "$H" 2>&1; }
19
+
20
+ echo "== field-canon preload 레인 =="
21
+
22
+ out=$(run '{"prompt":"세션 시작하자 qasp 병렬세션"}' "$TMP/1")
23
+ case "$out" in *qasp*README.md*) ok "① 그날의 첫 메시지 → 정본 경로 제시" ;;
24
+ *) no "① 그날의 첫 메시지" "정본 경로 미출력" ;; esac
25
+ case "$out" in *docs/governance*) ok "①-b governance 정본 수를 함께 낸다" ;;
26
+ *) no "①-b governance" "미출력" ;; esac
27
+
28
+ run '{"prompt":"qasp 시작"}' "$TMP/2" >/dev/null
29
+ out=$(run '{"prompt":"qasp 또"}' "$TMP/2")
30
+ [ -z "$out" ] && ok "② 세션당 1회 — 재나그 없음" || no "②" "2회차에 또 출력(무시를 학습시킨다)"
31
+
32
+ out=$(run '{"prompt":"오늘 날씨 어때"}' "$TMP/3")
33
+ [ -z "$out" ] && ok "★컨트롤: 매핑 밖 프롬프트는 조용" || no "컨트롤" "오탐 출력"
34
+
35
+ out=$(run '{"prompt":"qasp"}' "$TMP/4" /nonexistent)
36
+ [ -z "$out" ] && ok "★컨트롤: 레포 부재 → 조용(허위 지시 금지)" || no "레포 부재" "없는 경로를 가리켰다"
37
+
38
+ printf 'not-json' | FIELD_CANON_SENTINEL_DIR="$TMP/5" bash "$H" >/dev/null 2>&1
39
+ [ $? = 0 ] && ok "깨진 입력도 rc=0 (비영종료는 stdout 폐기 = 무음)" || no "깨진 입력" "rc≠0"
40
+ printf '' | FIELD_CANON_SENTINEL_DIR="$TMP/6" bash "$H" >/dev/null 2>&1
41
+ [ $? = 0 ] && ok "빈 입력 rc=0" || no "빈 입력" "rc≠0"
42
+
43
+ # ── ⑦~⑩ 기본 센티넬 경로 (2026-08-09 추가) ─────────────────────────────────────
44
+ # 🟥 **위 ①~⑥ 은 전부 `FIELD_CANON_SENTINEL_DIR` 를 명시로 주입한다** — 즉 실제로 깨져 있던
45
+ # **기본 경로를 구조적으로 우회**했고, 그래서 결함이 초록으로 출하됐다. 여기서부터는 그 변수를
46
+ # **주지 않고** `TMPDIR` 만 격리해 기본 키 산출 로직 자체를 때린다.
47
+ # (`[[feedback_anchor_can_be_decorative]]` — 레인이 결함 지점을 비켜 가면 초록은 정보가 아니다.)
48
+ # ⚠️ macOS 는 bash **3.2** 다. `set -u` 아래서 빈 배열 `"${a[@]}"` 는 unbound variable 로 죽는다
49
+ # — 초판 레인이 정확히 그렇게 죽어 ⑦⑨⑩ 이 **거짓 적색**을 냈다(대상이 아니라 계기의 사망).
50
+ # 배열 없이 분기한다.
51
+ runD(){ # $1=payload $2=TMPDIR [$3=CLAUDE_SESSION_ID 를 환경에 심을지]
52
+ if [ -n "${3:-}" ]; then
53
+ printf '%s' "$1" | env TMPDIR="$2" CLAUDE_SESSION_ID="$3" bash "$H" 2>&1
54
+ else
55
+ printf '%s' "$1" | env TMPDIR="$2" bash "$H" 2>&1
56
+ fi; }
57
+ T7="$TMP/def"; mkdir -p "$T7"
58
+
59
+ a=$(runD '{"session_id":"AAAA","prompt":"qasp 가자"}' "$T7")
60
+ b=$(runD '{"session_id":"BBBB","prompt":"qasp 가자"}' "$T7")
61
+ if [ -n "$a" ] && [ -n "$b" ]; then ok "⑦ 세션이 다르면 **둘 다** 안내한다(머신당 1회가 아니다)"
62
+ else no "⑦ 세션별 분리" "두 번째 세션이 침묵했다 — 센티넬이 세션을 안 가른다"; fi
63
+
64
+ c=$(runD '{"session_id":"AAAA","prompt":"qasp 또"}' "$T7")
65
+ [ -z "$c" ] && ok "⑧ 같은 세션 2회차는 조용(재나그 없음)" || no "⑧ 동일 세션 dedup" "또 출력"
66
+
67
+ # ★ peer 조언 채택 — 다만 **판별력 있는 형태로** 짠다.
68
+ # 초안은 두 팔을 **다른 TMPDIR** 에서 돌렸는데, 그러면 수리 전 코드도 두 팔 다 발화해서
69
+ # 레인이 **원본에서도 초록**이었다(되돌림 실측으로 잡았다 — 존재만 재고 관계를 안 보는 형태).
70
+ # 진짜 판별자: **같은 TMPDIR · 같은 payload `session_id` · 다른 환경변수** → 키를 페이로드가
71
+ # 정한다면 **두 번째는 침묵**해야 한다. 환경을 읽고 있으면 키가 갈려서 또 발화한다.
72
+ T9="$TMP/env"; mkdir -p "$T9"
73
+ e1=$(runD '{"session_id":"CCCC","prompt":"qasp 환경팔"}' "$T9")
74
+ e2=$(runD '{"session_id":"CCCC","prompt":"qasp 환경팔"}' "$T9" "ZZZZ-환경에서-온-값")
75
+ if [ -n "$e1" ] && [ -z "$e2" ]; then ok "⑨ ★키를 페이로드가 정한다(환경변수를 바꿔도 같은 세션)"
76
+ else no "⑨ 환경 의존" "환경변수를 바꾸니 같은 세션이 다른 세션으로 갈렸다 — 환경을 읽고 있다"; fi
77
+
78
+ # session_id 가 아예 없는 페이로드 → **침묵하면 안 된다**(과다 알림 쪽으로 폴백).
79
+ # 🟡 명명된 한계: 이 레인은 수리 전 코드에서도 초록이다(literal `shared` 도 첫 발화는 한다).
80
+ # 폴백 **방향**만 못박고 **키의 유일성**은 못 잰다 — 그건 ⑦ 과 아래 컨트롤이 맡는다.
81
+ T10="$TMP/nosid"; mkdir -p "$T10"
82
+ f1=$(runD '{"prompt":"qasp sid 없음"}' "$T10")
83
+ [ -n "$f1" ] && ok "⑩ session_id 부재에도 안내한다(폴백 방향 = 과다알림)" \
84
+ || no "⑩ sid 부재 폴백" "조용히 삼켰다 — 초판이 실패한 방향 그대로다"
85
+
86
+ # ★컨트롤: 기본 경로 레인이 **실제로 기본 경로를 탄다**는 증명.
87
+ # (안 그러면 위 넷은 여전히 우회 레인이다.)
88
+ if find "$T7" -maxdepth 1 -name 'fh_field_canon_sid_*' | grep -q . ; then
89
+ ok "★컨트롤: 기본 센티넬 디렉토리가 TMPDIR 아래 실제로 생성됨(우회 아님)"
90
+ else
91
+ no "★컨트롤 기본경로" "센티넬이 TMPDIR 에 안 생겼다 — 이 레인들이 무엇을 쟀는지 불명"
92
+ fi
93
+ if find "$T7" -maxdepth 1 -name 'fh_field_canon_shared' | grep -q . ; then
94
+ no "★컨트롤 literal shared" "전 세션 공유 키가 다시 생겼다(초판 결함 재발)"
95
+ else
96
+ ok "★컨트롤: literal 'shared' 키가 생기지 않는다"
97
+ fi
98
+
99
+ # ── ⑪~⑭ cross-family(gpt-5.5) 가 **이 수리에서** 잡은 4건의 회귀 앵커 ──────────────
100
+ # 자력 적발 0 이었다. 넷 다 재현된 것이고, 특히 ⑪ 은 **원래 버그와 같은 일가를 수리가 다시 연** 것이다.
101
+
102
+ # ⑪ [S] 키 충돌 — 정규화/절단이 서로 다른 세션을 같은 칸으로 접으면 안 된다.
103
+ T11="$TMP/coll"; mkdir -p "$T11"
104
+ x1=$(runD '{"session_id":"A/B","prompt":"qasp 충돌"}' "$T11")
105
+ x2=$(runD '{"session_id":"A_B","prompt":"qasp 충돌"}' "$T11")
106
+ if [ -n "$x1" ] && [ -n "$x2" ]; then ok "⑪ ★'A/B' 와 'A_B' 는 다른 세션이다(치환 충돌 없음)"
107
+ else no "⑪ 키 충돌" "정규화가 두 세션을 같은 센티넬로 접었다 — 원래 버그 재개방"; fi
108
+ L1=$(printf 'S%.0s' $(seq 1 70))"-alpha"; L2=$(printf 'S%.0s' $(seq 1 70))"-beta"
109
+ y1=$(runD "{\"session_id\":\"$L1\",\"prompt\":\"qasp 길이\"}" "$T11")
110
+ y2=$(runD "{\"session_id\":\"$L2\",\"prompt\":\"qasp 길이\"}" "$T11")
111
+ if [ -n "$y1" ] && [ -n "$y2" ]; then ok "⑪-b ★긴 session_id 의 접두 절단 충돌 없음"
112
+ else no "⑪-b 절단 충돌" "앞 N자가 같은 두 세션이 한 칸을 공유한다"; fi
113
+
114
+ # ⑫ [A] 개행 오염 — session_id 의 개행이 프롬프트 파싱으로 새면 안 된다.
115
+ T12="$TMP/nl"; mkdir -p "$T12"
116
+ z=$(runD '{"session_id":"AA\nqasp","prompt":"오늘 날씨 어때"}' "$T12")
117
+ [ -z "$z" ] && ok "⑫ ★session_id 의 개행이 프롬프트로 새지 않는다" \
118
+ || no "⑫ 개행 오염" "무관 프롬프트인데 발화했다 — SID 개행이 PROMPT 로 샜다"
119
+
120
+ # ⑬ [A] HOME 부재에서도 rc=0 (헤더의 "항상 0" 계약)
121
+ env -u HOME -u CLAUDE_PROJECT_DIR -u FIELD_CANON_PROJECT_DIR TMPDIR="$TMP/nh" \
122
+ bash "$H" </dev/null >/dev/null 2>&1
123
+ [ $? = 0 ] && ok "⑬ ★HOME 부재에서도 rc=0(set -u unbound 로 안 죽는다)" \
124
+ || no "⑬ HOME 부재" "rc≠0 — 비영종료는 stdout 폐기라 무음 실패가 된다"
125
+
126
+ # ⑭ [B] 매핑 이름의 regex 문자 — 고정문자열 매칭이어야 한다.
127
+ # ⚠️ 초판 ⑭ 는 **양쪽에서 초록**이었다(약한 레인) — grep 이 매칭돼도 그 다음 «레포 해석» 단계가
128
+ # 막아서 결과가 안 드러났다. 레포와 README 까지 실제로 만들어 **grep 이 유일한 판별 지점**이 되게 한다.
129
+ HUB14="$TMP/hub14"; mkdir -p "$HUB14/tracks/q.sp"
130
+ P14="$TMP/proj14"; mkdir -p "$P14/q.sp/.git"; printf 'x\n' > "$P14/q.sp/README.md"
131
+ w=$(printf '%s' '{"session_id":"RX","prompt":"qXsp 를 보자"}' \
132
+ | env TMPDIR="$TMP/rx" CLAUDE_PROJECT_DIR="$HUB14" FIELD_CANON_PROJECT_ROOT="$P14" bash "$H" 2>&1)
133
+ [ -z "$w" ] && ok "⑭ ★'q.sp' 트랙이 'qXsp' 에 오탐하지 않는다(grep -F)" \
134
+ || no "⑭ regex 오탐" "이름의 '.' 가 임의문자로 매칭됐다"
135
+ # ★컨트롤 — 정확히 일치하는 이름은 **반드시** 잡혀야 한다(위 침묵이 공허하지 않음을 증명).
136
+ w2=$(printf '%s' '{"session_id":"RX2","prompt":"q.sp 를 보자"}' \
137
+ | env TMPDIR="$TMP/rx2" CLAUDE_PROJECT_DIR="$HUB14" FIELD_CANON_PROJECT_ROOT="$P14" bash "$H" 2>&1)
138
+ [ -n "$w2" ] && ok "⑭-b ★컨트롤: 정확히 'q.sp' 는 잡힌다(⑭ 의 침묵이 공허하지 않다)" \
139
+ || no "⑭-b 컨트롤" "정확한 이름도 못 잡는다 — ⑭ 는 무엇도 증명하지 않는다"
140
+
141
+ echo "── PASS $PASS · FAIL $FAIL"
142
+ [ "$FAIL" -eq 0 ] || exit 1
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env bash
2
+ # test_hook_source_gate_lanes.sh — SessionStart `source` 게이팅 known-pair.
3
+ #
4
+ # 무엇을 재는가: 캐던스 나그가 `clear`/`compact` 에서 **억제**되고 나머지에서 **뜨는가**.
5
+ # 왜 known-pair 인가: 억제만 확인하면 «아무것도 안 뜨는 스크립트» 도 만점이다 — 양성 arm 이
6
+ # 없으면 기능이 죽은 것과 구분이 안 된다(이 레포의 «부재측정엔 컨트롤 동반»).
7
+ # Exit: 0 전 쌍 성립 / 1 쌍 붕괴 / 10 하네스 오류
8
+ set -uo pipefail
9
+ D=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
10
+ LIB="$D/hook_source_lib.sh"
11
+ [ -f "$LIB" ] || { echo "❌ harness-error: $LIB 없음"; exit 10; }
12
+ # shellcheck source=scripts/hook_source_lib.sh
13
+ . "$LIB"
14
+ F=0; ok(){ echo " ✅ $1"; }; ng(){ echo " ❌ $1"; F=1; }
15
+
16
+ echo "[hook-source] known-pair 앵커"
17
+ echo
18
+ echo " ── 파서: 페이로드에서 source 를 뽑는가 ──"
19
+ for pair in 'startup:startup' 'clear:clear' 'compact:compact' 'resume:resume'; do
20
+ want="${pair##*:}"
21
+ got=$(printf '{"session_id":"x","source":"%s","cwd":"/tmp"}' "${pair%%:*}" | fh_hook_source)
22
+ [ "$got" = "$want" ] && ok "source=$want 추출" || ng "source=$want 인데 '$got'"
23
+ done
24
+ got=$(printf '{"session_id":"x","cwd":"/tmp"}' | fh_hook_source)
25
+ [ "$got" = "unknown" ] && ok "source 키 부재 → unknown (조용히 clear 로 접지 않는다)" \
26
+ || ng "키 부재인데 '$got'"
27
+ got=$(printf '' | fh_hook_source)
28
+ [ "$got" = "unknown" ] && ok "빈 stdin → unknown" || ng "빈 stdin 인데 '$got'"
29
+ got=$(printf 'not json at all' | fh_hook_source)
30
+ [ "$got" = "unknown" ] && ok "비-JSON → unknown" || ng "비-JSON 인데 '$got'"
31
+
32
+ echo
33
+ echo " ── 게이트: 억제(양성) ──"
34
+ for s in clear compact; do
35
+ fh_cadence_due "$s" && ng "source=$s 인데 캐던스가 뜬다" || ok "source=$s → 억제"
36
+ done
37
+ echo " ── 게이트: 통과해야 함 (음성 — 과억제가 더 위험하다) ──"
38
+ for s in startup resume fork unknown ''; do
39
+ fh_cadence_due "$s" && ok "source='${s:-<빈값>}' → 뜬다" || ng "source='${s:-<빈값>}' 인데 억제됐다"
40
+ done
41
+
42
+ echo
43
+ echo " ── 실물: fh_env_delta_scan 이 헤더만 남기지 않는가 ──"
44
+ # 억제 시 sibling-only 케이스에서 «헤더 있고 내용 없음» 이 나오면 안 된다.
45
+ T=$(mktemp -d) || { echo "❌ harness-error: mktemp"; exit 10; }
46
+ trap 'rm -rf "$T"' EXIT
47
+ out_clear=$(printf '{"source":"clear"}' | bash "$D/fh_env_delta_scan.sh" 2>/dev/null || true)
48
+ if printf '%s' "$out_clear" | grep -q 'capability-surface change detected'; then
49
+ printf '%s' "$out_clear" | grep -q ' • ' \
50
+ && ok "clear 에서 헤더가 떴다면 항목도 있다 (cwd-미매핑은 억제 대상 아님)" \
51
+ || ng "clear 에서 **헤더만 있고 항목이 없다** — 내용 없는 경보"
52
+ else
53
+ ok "clear: 헤더 자체가 안 뜬다 (sibling-only 였다는 뜻)"
54
+ fi
55
+ out_start=$(printf '{"source":"startup"}' | bash "$D/fh_env_delta_scan.sh" 2>/dev/null || true)
56
+ if [ -n "$out_start" ] && [ -z "$out_clear" ]; then
57
+ ok "CONTROL: startup 에서는 뜨고 clear 에서는 안 뜬다 (게이트가 실제로 동작)"
58
+ elif [ -z "$out_start" ] && [ -z "$out_clear" ]; then
59
+ ok "CONTROL: 이 머신엔 델타가 없어 양쪽 무출력 — 게이트 판정 불가(미측정, 통과 아님)"
60
+ else
61
+ ok "CONTROL: startup 출력 유무=$([ -n "$out_start" ] && echo Y || echo N)"
62
+ fi
63
+
64
+ echo
65
+ echo " ── ①-c peer 스캔: claude-agents 스코프 ──"
66
+ CC="$D/session_close_check.sh"
67
+ if [ -f "$CC" ]; then
68
+ a=$(CLAUDE_CODE_CHILD_SESSION=1 bash "$CC" 2>/dev/null | grep -c '①-c' || true)
69
+ b=$(env -u CLAUDE_CODE_CHILD_SESSION -u CLAUDE_CODE_AGENT bash "$CC" 2>/dev/null | grep -c '①-c' || true)
70
+ c=$(env -u CLAUDE_CODE_CHILD_SESSION -u CLAUDE_CODE_AGENT FH_PEER_SCAN_FORCE=1 bash "$CC" 2>/dev/null | grep -c '①-c' || true)
71
+ [ "$a" -gt 0 ] && ok "자식 세션 → ①-c 뜬다" || ng "자식 세션인데 ①-c 없음"
72
+ [ "$b" -eq 0 ] && ok "톱레벨(env 부재 시뮬) → 침묵 (솔로 세션 나그 금지)" || ng "env 부재인데 ①-c 떴다"
73
+ [ "$c" -gt 0 ] && ok "CONTROL: override 로 강제하면 뜬다 (b 의 침묵이 기능 사망이 아님을 증명)" \
74
+ || ng "override 인데 안 뜬다 — b 의 0 은 기능 사망일 수 있다"
75
+ else
76
+ ok "session_close_check.sh 부재 — ①-c 레인 미측정(통과 아님)"
77
+ fi
78
+
79
+ echo
80
+ if [ "$F" -eq 0 ]; then echo "[hook-source] ✅ all known pairs hold"; exit 0
81
+ else echo "[hook-source] ❌ 쌍 붕괴"; exit 1; fi
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env bash
2
+ # test_marker_crossfamily_lanes.sh — regression fixtures for pre-commit's
3
+ # validate_crossfamily_leg (typed cross-family verdict, 2026-08-08).
4
+ #
5
+ # WHY: the `crossfamily:` marker line has been REQUIRED on load-bearing changes since
6
+ # 2026-06 (commit c1fa459) — but only its PRESENCE was checked, so the value was free
7
+ # prose. That is how a false one shipped: a sibling harness recorded
8
+ # `crossfamily: none this round — 도달 불가` (unreachable), the claim was later found
9
+ # FALSE, and a later session cited it as grounds. Presence-checking catches silence; it
10
+ # cannot catch a confident wrong answer. This lane types the value.
11
+ #
12
+ # Fixtures assert BOTH directions (known-pair): every intended shape is admitted, and
13
+ # every hole the lane closes still blocks. A test that only asserts BLOCK cannot tell
14
+ # "blocked" from "blocked for the wrong reason"; one that only asserts PASS cannot see a
15
+ # lane that admits everything.
16
+ #
17
+ # Usage: bash scripts/test_marker_crossfamily_lanes.sh Exit: 0 = all behave; 1 = regression.
18
+
19
+ set -uo pipefail
20
+ REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
21
+ HOOK="$REPO_ROOT/templates/.git-hooks/pre-commit"
22
+ T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
23
+
24
+ sed -n '/^validate_crossfamily_leg()/,/^}/p' "$HOOK" > "$T/fn.sh"
25
+
26
+ # Instrument calibration: an empty extraction would let every fixture "pass" against
27
+ # nothing. Assert the function body actually arrived before measuring anything.
28
+ if ! grep -q 'DEGRADED_PANEL_UNUSED' "$T/fn.sh"; then
29
+ echo "❌ HARNESS-ERROR — validate_crossfamily_leg did not extract from $HOOK."
30
+ echo " Fixtures below would measure an empty function. Aborting rather than reporting green."
31
+ exit 1
32
+ fi
33
+
34
+ mk() { printf "$1" > "$T/$2"; } # $1 = marker body, $2 = fixture name
35
+ run() { bash -c "source '$T/fn.sh'; validate_crossfamily_leg '$1'" >/dev/null 2>&1; }
36
+
37
+ FAIL=0; N=0
38
+ check() { # $1=fixture $2=expected(PASS|BLOCK) $3=label
39
+ N=$((N+1))
40
+ if run "$T/$1"; then got=PASS; else got=BLOCK; fi
41
+ if [ "$got" = "$2" ]; then echo "✅ $3 → $got"; else echo "❌ $3 → $got (expected $2)"; FAIL=1; fi
42
+ }
43
+
44
+ echo "── admitted shapes (must PASS) ──"
45
+ mk 'crossfamily: panel(codex,gemini) — R1..R3, 9 findings, 8 fixed 1 refuted, CONVERGED\n' k1
46
+ check k1 PASS "panel() with family list + verdict"
47
+ mk 'crossfamily: panel(codex, gemini) — spaced list, the S-grade over-block\n' k1b
48
+ check k1b PASS "panel(codex, gemini) — SPACE after comma (was truncated to 'panel(codex,')"
49
+ mk 'crossfamily: declined\n' k3
50
+ check k3 PASS "declined (operator chose this floor — not a degrade)"
51
+ mk 'crossfamily: DEGRADED_SINGLE_FAMILY — probed codex/agy/4090, 0 capable reachable\n' k4
52
+ check k4 PASS "DEGRADED_SINGLE_FAMILY + substantive reason"
53
+ mk 'crossfamily: UNKNOWN — consent unset, panel not probed this round\n' k5
54
+ check k5 PASS "UNKNOWN + reason naming why it was not probed"
55
+ mk 'crossfamily: DEGRADED_PANEL_UNUSED — codex/agy/gemini reachable, not recruited (scope)\n' k6
56
+ check k6 PASS "DEGRADED_PANEL_UNUSED + reason naming the reachable families"
57
+ mk 'crossfamily: panel(qwen,gpt-oss,glm) — clean, 0 findings\n' k7
58
+ check k7 PASS "capable families only — guard does not over-block a real panel"
59
+
60
+ echo "── closed holes (must BLOCK) ──"
61
+ mk 'axis2-evidence: PASS no-S\n' b1
62
+ check b1 BLOCK "no crossfamily line at all (silence — the original guard, still intact)"
63
+ mk 'crossfamily: none this round — 인라인 same-family\n' b2
64
+ check b2 BLOCK "'none' free prose (the shape found on disk)"
65
+ mk 'crossfamily: none this round — 도달 불가\n' b3
66
+ check b3 BLOCK "'none' asserting unreachable (the FALSE one that shipped)"
67
+ mk 'crossfamily: none\n' b4
68
+ check b4 BLOCK "bare 'none' (merges could-not / did-not / did-not-look)"
69
+ mk 'crossfamily: DEGRADED_SINGLE_FAMILY\n' b5
70
+ check b5 BLOCK "degrade value with no reason (silent degrade — the whole point)"
71
+ mk 'crossfamily: UNKNOWN\n' b6
72
+ check b6 BLOCK "UNKNOWN with no reason (unprobed rendered as zero)"
73
+ mk 'crossfamily: DEGRADED_PANEL_UNUSED\n' b7
74
+ check b7 BLOCK "PANEL_UNUSED with no reason ('did not' passing as 'could not')"
75
+ mk 'crossfamily: DEGRADED_SINGLE_FAMILY — degraded\n' b8
76
+ check b8 BLOCK "reason too vacuous (names no probe and no grounds)"
77
+ mk 'crossfamily: panel\n' b9
78
+ check b9 BLOCK "bare panel claim naming no family"
79
+ mk 'crossfamily: panel()\n' b10
80
+ check b10 BLOCK "panel() with empty family list"
81
+ mk 'crossfamily: DEGRADED\n' b11
82
+ check b11 BLOCK "near-miss token (not an enum value)"
83
+ mk 'crossfamily: codex/gpt-5.5 — R1..R4, 16 findings, CONVERGED\n' b12
84
+ check b12 BLOCK "legacy free-form engine string (pre-typed convention) now rejected"
85
+
86
+ echo "── cross-family review findings (agy/Gemini 3.1 Pro, 2026-08-08) ──"
87
+ mk 'crossfamily: panel(claude) — reviewed by another claude session\n' x1
88
+ check x1 BLOCK "panel names the AUTHOR'S own family (decorrelation gate not seeing decorrelation)"
89
+ mk 'crossfamily: panel(opus,sonnet) — two claude tiers\n' x2
90
+ check x2 BLOCK "two same-family tiers dressed as a 2-family panel"
91
+ mk 'crossfamily: panel(none)\n' x3
92
+ check x3 BLOCK "panel(none) — a non-run laundered through the parens"
93
+ mk 'crossfamily: panel(unprobed)\n' x4
94
+ check x4 BLOCK "panel(unprobed) — same laundering, other token"
95
+ mk 'crossfamily: single-family\n' x5
96
+ check x5 BLOCK "single-family on a LOAD-BEARING change (free no-ack bypass of the lane)"
97
+ mk 'crossfamily: UNKNOWN — 0\n' x6
98
+ check x6 BLOCK "grounds '0' (porous non-vacuity: bare digit passed)"
99
+ mk 'crossfamily: UNKNOWN — problem\n' x7
100
+ check x7 BLOCK "grounds 'problem' (matched on substring 'prob')"
101
+ mk 'crossfamily: UNKNOWN — client error\n' x8
102
+ check x8 BLOCK "grounds 'client error' (matched on substring 'cli')"
103
+ mk 'crossfamily: panel(voyage-3,bge-m3) — 2 families\n' x9
104
+ check x9 BLOCK "embedding models WITHOUT 'embed' in the name (denylist false negative)"
105
+ mk 'crossfamily: panel(armorm,starling-rm) — 2 families\n' x10
106
+ check x10 BLOCK "reward models emitting scalars, not findings"
107
+ mk 'crossfamily: panel(cohere-rank-v3) — reranker named 'rank' not 'rerank'\n' x11
108
+ check x11 BLOCK "reranker named 'rank' (denylist false negative)"
109
+ mk 'crossfamily: DEGRADED_SINGLE_FAMILY — agy sidecar daemon crashed on startup, none reachable\n' x12
110
+ check x12 PASS "grounds naming agy (was rejected — keyword list omitted it)"
111
+
112
+ mk 'crossfamily: single-family\ncrossfamily: DEGRADED_SINGLE_FAMILY — probed codex/agy, 0 reachable\n' x13
113
+ check x13 BLOCK "two crossfamily lines — appended correction shadowed by stale first (codex net-new)"
114
+
115
+ echo "── review-capability guard (pmh-dev #41 field measurement) ──"
116
+ mk 'crossfamily: panel(qwen,embed,embed) — 3 families\n' c1
117
+ check c1 BLOCK "embeddings counted as panel members (the measured false panel)"
118
+ mk 'crossfamily: panel(embed,rerank,ocr) — 3 families\n' c2
119
+ check c2 BLOCK "every member incapable — '3 families', 0 reviewers"
120
+ mk 'crossfamily: panel(gpt-oss,safeguard) — 2 families\n' c3
121
+ check c3 BLOCK "safeguard classifier padding one real family"
122
+ # Ordering invariant: ineligibility is tested FIRST. Both tokens ALSO match a valid
123
+ # family (glm-ocr → glm, qwen-embedding → qwen), so an eligibility-first implementation
124
+ # admits them silently. This pair anchors the ORDER, not the list.
125
+ mk 'crossfamily: panel(glm-ocr) — glm family\n' c4
126
+ check c4 BLOCK "glm-ocr — matches a valid family AND an incapable class"
127
+ mk 'crossfamily: panel(qwen-embedding-8b) — qwen family\n' c5
128
+ check c5 BLOCK "qwen-embedding — same overlap, other direction"
129
+
130
+ echo
131
+ if [ "$FAIL" -eq 0 ]; then echo "✅ all $N fixtures behave"; else echo "❌ regression ($N fixtures run)"; fi
132
+ exit "$FAIL"
@@ -23,14 +23,15 @@ check() { # $1=fixture $2=expected(PASS|BLOCK) $3=label
23
23
  if [ "$got" = "$2" ]; then echo "✅ $3 → $got"; else echo "❌ $3 → $got (expected $2)"; FAIL=1; fi
24
24
  }
25
25
 
26
- printf 'axis2-engine: inline\naxis2-model: sonnet\nfloor-status: sonnet-floor\naxis2-anchor: regression test 5/5 pass\naxis2-evidence: PASS no-S, 2B applied\n' > "$T/m1"
27
- printf 'axis2-engine: inline\naxis2-model: sonnet\nfloor-status: sonnet-floor\naxis2-evidence: PASS no-S\n' > "$T/m2"
28
- printf 'axis2-engine: inline\naxis2-model: haiku\nfloor-status: sonnet-floor\naxis2-anchor: probe 3/3\naxis2-evidence: PASS no-S\n' > "$T/m3"
29
- printf 'axis2-engine: inline\naxis2-model: sonnet\nfloor-status: at-floor\naxis2-evidence: PASS no-S\n' > "$T/m4"
30
- printf 'axis2-engine: inline\naxis2-model: haiku\nfloor-status: below-floor\naxis2-evidence: PASS no-S\n' > "$T/m5"
31
- printf 'axis2-engine: quench-challenger\naxis2-model: opus\nfloor-status: at-floor\naxis2-evidence: 1S/4A fixed\n' > "$T/m6"
32
- printf 'axis2-engine: inline\naxis2-model: haiku\nfloor-status: below-floor\nbelow-floor-ack: "approved, proceed" — canary-only change\naxis2-evidence: PASS no-S\n' > "$T/m7"
33
- printf 'axis2-engine: inline\naxis2-model: opus\nfloor-status: bogus-status\naxis2-evidence: PASS\n' > "$T/m8"
26
+ CF=''
27
+ printf "axis2-engine: inline\naxis2-model: sonnet\nfloor-status: sonnet-floor\naxis2-anchor: regression test 5/5 pass\n${CF}axis2-evidence: PASS no-S, 2B applied\n" > "$T/m1"
28
+ printf "axis2-engine: inline\naxis2-model: sonnet\nfloor-status: sonnet-floor\n${CF}axis2-evidence: PASS no-S\n" > "$T/m2"
29
+ printf "axis2-engine: inline\naxis2-model: haiku\nfloor-status: sonnet-floor\naxis2-anchor: probe 3/3\n${CF}axis2-evidence: PASS no-S\n" > "$T/m3"
30
+ printf "axis2-engine: inline\naxis2-model: sonnet\nfloor-status: at-floor\n${CF}axis2-evidence: PASS no-S\n" > "$T/m4"
31
+ printf "axis2-engine: inline\naxis2-model: haiku\nfloor-status: below-floor\n${CF}axis2-evidence: PASS no-S\n" > "$T/m5"
32
+ printf "axis2-engine: quench-challenger\naxis2-model: opus\nfloor-status: at-floor\n${CF}axis2-evidence: 1S/4A fixed\n" > "$T/m6"
33
+ printf "axis2-engine: inline\naxis2-model: haiku\nfloor-status: below-floor\nbelow-floor-ack: \"approved, proceed\" — canary-only change\n${CF}axis2-evidence: PASS no-S\n" > "$T/m7"
34
+ printf "axis2-engine: inline\naxis2-model: opus\nfloor-status: bogus-status\n${CF}axis2-evidence: PASS\n" > "$T/m8"
34
35
 
35
36
  check "$T/m1" PASS "sonnet-floor + anchor + sonnet model (new lane, intended shape)"
36
37
  check "$T/m2" BLOCK "sonnet-floor WITHOUT anchor (anchor is the compensating requirement)"