@chrono-meta/fh-gate 1.4.90 → 1.4.92

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.
@@ -12,6 +12,19 @@ load: on-demand
12
12
 
13
13
  ## §Collection-Bash
14
14
 
15
+ > **Execution form — canonical (revised 2026-08-10, operator decision ⓑ).** The default runtime path
16
+ > is **WebFetch (+WebSearch) against the endpoints below** — not `curl`. Measured: **13 consecutive
17
+ > unattended runs** executed via WebFetch while this section documented only the curl form (the digest
18
+ > reported the mismatch on every run — spec debt N=13). `curl` requires an allowlist entry that a
19
+ > default install does not carry; the bash blocks below are kept as the **endpoint/parameter spec**
20
+ > (query shapes, filters, item caps, refresh rules — all still authoritative), and a run applies them
21
+ > through WebFetch on the same URLs. If an environment *does* allow the curl form, it is equivalent —
22
+ > the transport is not the contract, the endpoints and criteria are.
23
+ >
24
+ > **arXiv HTTP 429 is a separate item, not part of this revision**: it is a remote rate limit
25
+ > (N=11 consecutive, transport-independent). On 429: back off once, then fall back to per-item
26
+ > `https://arxiv.org/abs/{id}` reads; record `FAILED (429)`, never 0 items.
27
+
15
28
  ### HackerNews (Algolia API)
16
29
 
17
30
  ```bash
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env bash
2
+ # branch_claim.sh — 공유 체크아웃에서 «내가 믿는 브랜치»와 «실제 HEAD»가 갈린 채 커밋되는 것을 막는다.
3
+ #
4
+ # ─────────────────────────────────────────────────────────────────────────────
5
+ # WHY (2026-08-09 실측, FH 병렬 세션 2개, 한 체크아웃)
6
+ # `.git/HEAD` 는 워킹트리당 하나다. 세션 A 의 `git switch` 는 세션 B 의 발밑도 옮긴다.
7
+ # 17:35 B 가 브랜치를 땄다 (B 는 «작업 시작할 때 브랜치부터 따기»를 이미 했다)
8
+ # 17:54 A 가 브랜치를 따며 트리 이동
9
+ # 18:10 B 가 자기 브랜치인 줄 알고 **A 의 브랜치에 커밋**
10
+ # → 브랜치를 언제 따느냐는 무관하다. **HEAD 가 트리당 하나**인 게 원인이다.
11
+ #
12
+ # ─────────────────────────────────────────────────────────────────────────────
13
+ # 판정 키 = «세션» 이지 «트리» 가 아니다 ★ 이게 이 스크립트의 핵심이고, 초판은 틀렸다
14
+ # 초판은 트리 단위 claim 하나를 두고 그것과 HEAD 를 비교했다. 적대검증이 실행으로 반증:
15
+ # **양쪽 세션이 다 프로토콜을 지키면 정확히 그 사고를 못 잡는다** — A 가 switch 후 claim 하면
16
+ # claim==HEAD 가 되어 B 의 check 가 통과한다. 채택률이 오를수록 무력해지는 게이트였다.
17
+ # 그래서 claim 을 **세션마다** 따로 둔다: `.git/fh-claims/<session_id>`.
18
+ # 비교는 **내 claim vs HEAD** 만 한다 — 남의 claim 은 내 판정에 안 들어오므로
19
+ # peer 로 인한 과차단이 구조적으로 없다.
20
+ #
21
+ # 세션 정체성은 배관이 필요 없다. 실측(2026-08-09): git 훅은 Claude Code 가 주입한
22
+ # `CLAUDE_CODE_SESSION_ID` · `CLAUDE_PID` 를 그대로 상속한다.
23
+ #
24
+ # WHAT THIS DOES / DOESN'T
25
+ # 막는다 : «내가 마지막으로 claim 한 브랜치» ≠ HEAD 인 채로 커밋
26
+ # 안 막는다 : `git switch` 자체 (git 에 switch 훅이 없다 — 막을 자리가 없다)
27
+ # 🟥 근본 해결이 아니다. 이건 **완화책**이다. 공유 체크아웃은 미커밋 워킹트리 혼입/유실도
28
+ # 낳고(그건 커밋 게이트로 안 닫힌다), 근본 처방은 **세션당 worktree** 다.
29
+ # (worktree 를 쓰면 이 claim 도 자동으로 트리별로 갈린다 — `--git-dir` 이 갈리므로.
30
+ # 단 FH 자산 커밋은 worktree 에서 4축 증거가 구조적으로 부재하다는 별개 문제가 있다.)
31
+ #
32
+ # 🟥 TOCTOU 잔여 (cross-family 지적, 미해결): `check` 와 git 이 실제로 ref 를 갱신하는 순간
33
+ # 사이에 창이 있다. 그 사이 peer 가 switch 하면 이 게이트를 통과한 채 사고가 난다.
34
+ # 창은 «35분»에서 «훅 실행시간»으로 줄지만 0 이 아니다. 정통 처방은 pre-commit 이 아니라
35
+ # **`reference-transaction` 훅** — 갱신 대상 ref 를 stdin 으로 받으므로 원자적으로 판정된다.
36
+ # 지금은 짓지 않았다(별개 훅 = 별개 변경). 완화로 훅 말미에 재검사를 한 번 더 돌린다.
37
+ #
38
+ # DEGRADE DIRECTION
39
+ # claim 없음 / 손상 / detached·rebase·bisect 중 → **통과**.
40
+ # 근거는 **«과차단은 override 를 습관화시켜 게이트를 죽인다»** 하나다(FH 실측 반복 기록).
41
+ # ⚠️ «커밋은 가역이니까» 를 근거로 쓰지 않는다 — 이 사고의 실제 복구는 공유 브랜치
42
+ # history rewrite 로 갔고, 그건 Surface-Class Degrade Invariant 가 fail-closed 로 두는 표면이다.
43
+ # 근거를 하나로 좁히지 않으면 나중에 «가역이니 여기도 완화하자» 로 잘못 일반화된다.
44
+ #
45
+ # 동시성 증거 없음(살아있는 peer 세션 claim 0개) → **경고만, 차단 안 함.**
46
+ # 1인 작업자에겐 이 게이트의 편익이 0이고 비용만 있다.
47
+ # 불일치 + 살아있는 peer 존재 → **차단.** override = FH_BRANCH_CLAIM_OK=1 (파일에 기록된다).
48
+ #
49
+ # USAGE
50
+ # bash scripts/branch_claim.sh claim [<label>] # 현재 HEAD 를 «내 브랜치»로 기록
51
+ # bash scripts/branch_claim.sh claim --if-absent [<label>] # 없을 때만 (세션시작 훅용)
52
+ # bash scripts/branch_claim.sh check # pre-commit 이 부르는 것 (0=통과 1=차단)
53
+ # bash scripts/branch_claim.sh show | release | reap
54
+ #
55
+ # ENV (운영)
56
+ # FH_BRANCH_CLAIM_OK=1 차단 1회 우회 — override 로그에 append 된다
57
+ # ENV (테스트 전용 — FH_CLAIM_TEST=1 없이는 전부 무시된다)
58
+ # FH_CLAIM_TEST=1 FH_CLAIM_DIR FH_CLAIM_NOW FH_CLAIM_STALE_HOURS FH_CLAIM_SESSION
59
+ # ↑ 게이팅하는 이유: 초판은 이 노브들이 프로덕션에서도 먹어서 **정직한 override 보다
60
+ # 조용한 우회가 더 쉬웠다**(FH_CLAIM_FILE=/dev/null → rc=0, 화면에 아무 표시 없음).
61
+ #
62
+ # bash 3.2(macOS) 호환: 연관배열·${v^^}·mapfile 안 씀.
63
+ # `stat` 안 씀 — epoch 을 파일 안에 적는다(BSD/GNU `stat -f` 분기 회피).
64
+
65
+ set -uo pipefail
66
+
67
+ _test_mode() { [ "${FH_CLAIM_TEST:-}" = "1" ]; }
68
+ _now() { if _test_mode && [ -n "${FH_CLAIM_NOW:-}" ]; then echo "$FH_CLAIM_NOW"; else date +%s; fi; }
69
+ _stale_hours() { if _test_mode && [ -n "${FH_CLAIM_STALE_HOURS:-}" ]; then echo "$FH_CLAIM_STALE_HOURS"; else echo 12; fi; }
70
+
71
+ # 세션 정체성. Claude Code 가 주입한다(실측). 없으면 셸 PID 로 폴백 — 그 경우
72
+ # «세션»은 사실상 «이 프로세스 트리»가 되고, 게이트는 여전히 동작하되 범위가 좁아진다.
73
+ _session_id() {
74
+ if _test_mode && [ -n "${FH_CLAIM_SESSION:-}" ]; then echo "$FH_CLAIM_SESSION"; return; fi
75
+ if [ -n "${CLAUDE_CODE_SESSION_ID:-}" ]; then echo "$CLAUDE_CODE_SESSION_ID"; return; fi
76
+ echo "pid-${PPID:-$$}"
77
+ }
78
+ _session_pid() { echo "${CLAUDE_PID:-${PPID:-$$}}"; }
79
+
80
+ # 절대경로 강제: 본 트리에서 `--git-dir` 은 상대 ".git" 이라, 서브디렉터리에서 실행하면
81
+ # 엉뚱한 곳에 claim 을 만들어 «점유 없음»으로 조용히 통과한다 = 게이트 무력화.
82
+ _git_dir() {
83
+ local d
84
+ d=$(git rev-parse --absolute-git-dir 2>/dev/null) && [ -n "$d" ] && { echo "$d"; return 0; }
85
+ local top; top=$(git rev-parse --show-toplevel 2>/dev/null) || return 1
86
+ d=$(git rev-parse --git-dir 2>/dev/null) || return 1
87
+ case "$d" in /*) echo "$d" ;; *) echo "$top/$d" ;; esac
88
+ }
89
+
90
+ _claim_dir() {
91
+ if _test_mode && [ -n "${FH_CLAIM_DIR:-}" ]; then echo "$FH_CLAIM_DIR"; return 0; fi
92
+ local gd; gd=$(_git_dir) || return 1
93
+ echo "$gd/fh-claims"
94
+ }
95
+ _my_claim() { local d; d=$(_claim_dir) || return 1; echo "$d/$(_session_id)"; }
96
+
97
+ _head_branch() { git rev-parse --abbrev-ref HEAD 2>/dev/null; }
98
+ _field() { sed -n "s/^$2=//p" "$1" 2>/dev/null | head -1; }
99
+
100
+ # 출력하는 복구 명령에 브랜치명을 그대로 박으면 안 된다: git ref 는 공백은 못 쓰지만
101
+ # `;` `$` `(` `)` `&` `|` 백틱은 **허용**한다. 복붙하면 주입형 사고가 난다(cross-family 지적).
102
+ # bash 3.2 라 printf %q 대신 single-quote escape 로 감싼다.
103
+ _shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
104
+
105
+ # 브랜치가 «지금 어디에 얹히는가»의 질문이 성립하지 않는 상태들.
106
+ # 초판은 detached 를 차단해서 rebase --edit 중 커밋을 막았고, 제시한 세 선택지 중
107
+ # 하나는 실행 불가(claim 이 detached 를 거부), 하나는 rebase 를 깨는 switch 였다.
108
+ # 남는 게 override 뿐이면 그건 override 훈련이다.
109
+ _inapplicable_state() {
110
+ local b gd; b=$(_head_branch); gd=$(_git_dir) || return 1
111
+ [ "$b" = "HEAD" ] && return 0
112
+ for m in rebase-merge rebase-apply BISECT_LOG MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD; do
113
+ [ -e "$gd/$m" ] && return 0
114
+ done
115
+ return 1
116
+ }
117
+
118
+ # 살아있는 **다른** 세션의 claim 개수 (동시성 증거). 죽은 세션 claim 은 안 센다.
119
+ _live_peers() {
120
+ local d me n=0 f sid pid
121
+ d=$(_claim_dir) || { echo 0; return; }
122
+ [ -d "$d" ] || { echo 0; return; }
123
+ me=$(_session_id)
124
+ for f in "$d"/*; do
125
+ [ -f "$f" ] || continue
126
+ sid=$(basename "$f"); [ "$sid" = "$me" ] && continue
127
+ pid=$(_field "$f" pid)
128
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then n=$((n+1)); fi
129
+ done
130
+ echo "$n"
131
+ }
132
+
133
+ # 🟥 «peer 없음» 인지 «못 가르는 것» 인지를 나누는 **독립 계수기**.
134
+ # _live_peers 는 claim 기록만 세므로 claim 하지 않은 세션을 구조적으로 못 본다. 그 구멍의
135
+ # 폭을 재려면 claim 과 무관한 신호가 필요하고, 이 머신에서는 세션 소켓이 그 신호다.
136
+ #
137
+ # 반환: 살아있는 «claim 안 한» 다른 CC 세션 수 · 또는 `unknown`(경로 없음 = 교차확인 불가).
138
+ # ⚠️ **이건 상한이지 증거가 아니다.** 소켓은 머신 전역이라 다른 레포에서 도는 세션도 센다 —
139
+ # 그래서 «peer 가 이 체크아웃에 있다» 를 증명하지 못한다. 증명되는 건 «구분할 수 없다» 뿐이고,
140
+ # 그게 정확히 이 함수가 있는 이유다. 0 도 N 도 아닌 **세 번째 값**을 만든다.
141
+ # ⚠️ 소켓 경로는 런타임 구현 세부라 바뀔 수 있다 — 그때는 `unknown` 으로 degrade 하고
142
+ # «peer 없음» 이라고 말하지 않는다(부재를 0으로 렌더하지 않는 것이 이 함수의 계약이다).
143
+ _unclaimed_risk() {
144
+ local dir me n=0 s pid live_claims
145
+ dir="${FH_CLAIM_SOCK_DIR:-}"
146
+ if [ -z "$dir" ] || ! _test_mode; then dir=/tmp/cc-socks; fi
147
+ [ -d "$dir" ] || { echo unknown; return; }
148
+ me=$(_session_pid)
149
+ for s in "$dir"/*.sock; do
150
+ [ -e "$s" ] || continue
151
+ pid=$(basename "$s" .sock)
152
+ case "$pid" in ''|*[!0-9]*) continue;; esac
153
+ [ "$pid" = "$me" ] && continue
154
+ kill -0 "$pid" 2>/dev/null && n=$((n+1))
155
+ done
156
+ # 이미 claim 한 살아있는 peer 는 _live_peers 가 세므로 여기서 뺀다 — 중복 계상 방지.
157
+ live_claims=$(_live_peers)
158
+ n=$((n - live_claims)); [ "$n" -lt 0 ] && n=0
159
+ echo "$n"
160
+ }
161
+
162
+ _override_log() { local gd; gd=$(_git_dir) || return 1; echo "$gd/fh-branch-claim-overrides.log"; }
163
+
164
+ cmd_claim() {
165
+ # --if-absent: 이 세션의 기록이 이미 있으면 아무것도 하지 않는다(덮어쓰기 금지).
166
+ # 세션시작 훅이 부르는 형태 — 훅 안에 같은 로직을 복제하면 앵커를 못 걸어서 여기에 둔다.
167
+ local ifabsent=0
168
+ if [ "${1:-}" = "--if-absent" ]; then ifabsent=1; shift; fi
169
+ local label="${1:-${USER:-unknown}}" d f b now
170
+ d=$(_claim_dir) || { echo "branch-claim: git 저장소가 아니다 — 아무것도 안 한다"; return 0; }
171
+ b=$(_head_branch)
172
+ if [ -z "$b" ] || [ "$b" = "HEAD" ]; then
173
+ echo "branch-claim: HEAD 가 detached 라 기록하지 않는다 (브랜치 위에서 실행해라)" >&2
174
+ return 2
175
+ fi
176
+ mkdir -p "$d" || return 1
177
+ f=$(_my_claim); now=$(_now)
178
+ if [ "$ifabsent" = "1" ] && [ -f "$f" ]; then
179
+ echo "branch-claim: 이 세션의 기록이 이미 있다 — 그대로 둔다 ($(_field "$f" branch))"
180
+ return 0
181
+ fi
182
+ # 원자적 교체: 같은 디렉터리 내 rename. in-place truncate 는 동시 read 에서 필드가 찢어진다
183
+ # (실측: 동시 claim 300회 중 9회 owner 필드 파손).
184
+ printf 'branch=%s\nlabel=%s\npid=%s\nat_epoch=%s\n' "$b" "$label" "$(_session_pid)" "$now" \
185
+ > "$f.tmp.$$" && mv -f "$f.tmp.$$" "$f" || return 1
186
+ echo "✅ branch-claim: 이 세션의 브랜치를 '$b' 로 기록했다 (label=$label)"
187
+ local peers; peers=$(_live_peers)
188
+ [ "$peers" -gt 0 ] && echo " 살아있는 peer 세션 claim: $peers 개 — 게이트 활성"
189
+ return 0
190
+ }
191
+
192
+ cmd_release() {
193
+ local f; f=$(_my_claim) 2>/dev/null || return 0
194
+ if [ -f "$f" ]; then rm -f "$f"; echo "branch-claim: 이 세션의 기록을 지웠다"; else echo "branch-claim: 기록 없음"; fi
195
+ return 0
196
+ }
197
+
198
+ # 죽은 세션의 claim 정리
199
+ cmd_reap() {
200
+ local d f sid pid n=0
201
+ d=$(_claim_dir) || return 0; [ -d "$d" ] || { echo "branch-claim: 기록 디렉터리 없음"; return 0; }
202
+ for f in "$d"/*; do
203
+ [ -f "$f" ] || continue
204
+ pid=$(_field "$f" pid)
205
+ if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then rm -f "$f"; n=$((n+1)); fi
206
+ done
207
+ echo "branch-claim: 죽은 세션 기록 $n 개 정리"
208
+ }
209
+
210
+ cmd_show() {
211
+ local d f me; d=$(_claim_dir) || { echo "(git 저장소 아님)"; return 0; }
212
+ me=$(_session_id)
213
+ echo "HEAD $(_head_branch)"
214
+ echo "세션ID $me"
215
+ echo "살아있는 peer claim: $(_live_peers)"
216
+ if [ -d "$d" ]; then
217
+ for f in "$d"/*; do
218
+ [ -f "$f" ] || continue
219
+ local sid pid alive; sid=$(basename "$f"); pid=$(_field "$f" pid)
220
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=live; else alive=dead; fi
221
+ printf ' %s %s branch=%s label=%s (%s)\n' \
222
+ "$([ "$sid" = "$me" ] && echo '→' || echo ' ')" "$sid" \
223
+ "$(_field "$f" branch)" "$(_field "$f" label)" "$alive"
224
+ done
225
+ else echo " (기록 없음)"; fi
226
+ }
227
+
228
+ # 0 = 통과, 1 = 차단
229
+ cmd_check() {
230
+ local f b cb cl ca now age lim peers
231
+ f=$(_my_claim) 2>/dev/null || return 0
232
+ b=$(_head_branch); [ -n "$b" ] || return 0
233
+ if _inapplicable_state; then return 0; fi # detached·rebase·bisect·merge 중 → 질문이 성립 안 함
234
+ [ -f "$f" ] || return 0 # 이 세션의 기록 없음 → 의견 없음 (부재 ≠ 위반)
235
+
236
+ cb=$(_field "$f" branch); cl=$(_field "$f" label); ca=$(_field "$f" at_epoch)
237
+ if [ -z "$cb" ]; then
238
+ echo " ⚠️ branch-claim: 내 기록을 파싱 못 했다 — 통과시킨다 (fail-open, 과차단 회피)"
239
+ return 0
240
+ fi
241
+ if [ "$cb" = "$b" ]; then
242
+ # 일치 → 갱신해서 나이가 «재임 기간»이 아니라 «유휴 시간»을 재게 한다.
243
+ # (초판은 갱신을 안 해서, 오래 붙어 있는 세션일수록 STALE 로 무방비가 됐다.)
244
+ now=$(_now)
245
+ printf 'branch=%s\nlabel=%s\npid=%s\nat_epoch=%s\n' "$cb" "$cl" "$(_session_pid)" "$now" \
246
+ > "$f.tmp.$$" 2>/dev/null && mv -f "$f.tmp.$$" "$f" 2>/dev/null
247
+ return 0
248
+ fi
249
+
250
+ now=$(_now); lim=$(_stale_hours)
251
+ if [ -n "$ca" ] && [ "$ca" -eq "$ca" ] 2>/dev/null; then
252
+ age=$(( (now - ca) / 3600 ))
253
+ if [ "$age" -ge "$lim" ]; then
254
+ echo " ⚠️ branch-claim: 내 기록이 STALE(${age}h ≥ ${lim}h) — 차단하지 않는다."
255
+ echo " 기록='$cb' · HEAD='$b' → 맞으면: bash scripts/branch_claim.sh claim"
256
+ return 0
257
+ fi
258
+ fi
259
+
260
+ peers=$(_live_peers)
261
+ if [ "$peers" -eq 0 ]; then
262
+ # 🟥 «peer 없음» 과 «peer 가 claim 을 안 함» 은 다른 명제다. _live_peers 는 **claim 기록만**
263
+ # 세므로 claim 하지 않은 세션을 구조적으로 못 본다 — 그걸 0 으로 렌더하면 미측정이 0이 된다.
264
+ # 실측 2026-08-09 (라이브, N=2): peer 세션이 커밋할 때 이 분기가 «peer 없음» 을 찍었는데
265
+ # 그 순간 다른 세션이 살아서 메시지를 주고받고 있었다. 원인은 판정식이 아니라 **기록 부재** —
266
+ # 활성화 배선은 세션 시작 때 도는데, 배선보다 먼저 시작된 세션은 영원히 claim 이 없다.
267
+ local unclaimed; unclaimed=$(_unclaimed_risk)
268
+ case "$unclaimed" in
269
+ unknown)
270
+ echo " ⚠️ branch-claim: 기록('$cb')과 HEAD('$b')가 다르다 — 살아있는 peer claim 0."
271
+ echo " 🟡 교차 확인 불가(세션 소켓 경로 없음) — «peer 없음» 을 확인하지 못했다. 차단하지 않는다."
272
+ ;;
273
+ 0)
274
+ echo " ⚠️ branch-claim: 기록('$cb')과 HEAD('$b')가 다르다 — 살아있는 peer 세션이 없어 차단하지 않는다."
275
+ echo " (교차 확인: 이 머신에 다른 CC 세션도 없다)"
276
+ ;;
277
+ *)
278
+ echo " 🟥 branch-claim: 기록('$cb')과 HEAD('$b')가 다른데 **판정할 수 없다.**"
279
+ echo " peer claim 0 개 ↔ 이 머신에 살아있는 다른 CC 세션 ${unclaimed} 개."
280
+ echo " 이 게이트는 지금 «peer 가 없다» 와 «peer 가 claim 을 안 했다» 를 **구분하지 못한다.**"
281
+ echo " claim 하지 않은 세션이 이 체크아웃을 같이 쓰고 있으면 지금 커밋은 남의 브랜치에 얹힌다."
282
+ echo " → 확인: bash scripts/branch_claim.sh show · 다른 세션에서: claim"
283
+ echo " ⚠️ 차단하지 않는다(커밋은 가역 표면). 다만 이건 «안전» 이 아니라 «미판정» 이다."
284
+ ;;
285
+ esac
286
+ echo " 의도한 전환이면: bash scripts/branch_claim.sh claim"
287
+ return 0
288
+ fi
289
+
290
+ if [ "${FH_BRANCH_CLAIM_OK:-}" = "1" ]; then
291
+ local lg; lg=$(_override_log 2>/dev/null)
292
+ [ -n "$lg" ] && printf '%s override claim=%s head=%s session=%s peers=%s\n' \
293
+ "$(_now)" "$cb" "$b" "$(_session_id)" "$peers" >> "$lg" 2>/dev/null
294
+ echo " ⚠️ branch-claim: 불일치인데 FH_BRANCH_CLAIM_OK=1 로 우회한다."
295
+ echo " 기록='$cb' · HEAD='$b' · 로그: ${lg:-<기록 실패>}"
296
+ return 0
297
+ fi
298
+
299
+ echo " ❌ branch-claim: 이 세션은 '$cb' 로 기록돼 있는데 HEAD 는 '$b' 다."
300
+ echo " 살아있는 peer 세션 $peers 개가 이 체크아웃을 같이 쓴다 — 그 중 하나가 브랜치를 옮겼다."
301
+ echo " ⚠️ 지금 커밋하면 '$b' 브랜치에 얹힌다 — 2026-08-09 에 실제로 난 사고다."
302
+ echo
303
+ echo " ▸ '$b' 가 맞다 : bash scripts/branch_claim.sh claim"
304
+ echo " ▸ '$cb' 로 돌아간다 : git switch -- $(_shq "$cb")"
305
+ echo " ⚠️ 돌아가면 지금 '$b' 를 쓰는 세션의 발밑을 뺀다. 먼저 한 줄 알려라."
306
+ echo " ▸ 근본 해결 : 세션당 worktree (이 게이트는 완화책이다)"
307
+ echo " ▸ 알고도 강행 : FH_BRANCH_CLAIM_OK=1 git commit …"
308
+ return 1
309
+ }
310
+
311
+ case "${1:-check}" in
312
+ claim) shift; cmd_claim "$@" ;;
313
+ release) cmd_release ;;
314
+ reap) cmd_reap ;;
315
+ show) cmd_show ;;
316
+ check) cmd_check ;;
317
+ *) echo "usage: $0 {claim [label]|check|show|release|reap}" >&2; exit 2 ;;
318
+ esac
@@ -28,6 +28,14 @@
28
28
  # - Non-Mode-D public users: harmless — it only ever PROPOSES /install-wizard --dry-run, which is
29
29
  # itself read-only. Still, gate on the hub being present so a bare plugin install stays silent.
30
30
 
31
+
32
+ # ── SessionStart source (단일 소스 lib) ─────────────────────────────────────────
33
+ _FH_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/hook_source_lib.sh"
34
+ # shellcheck source=scripts/hook_source_lib.sh
35
+ if [ -f "$_FH_LIB" ]; then . "$_FH_LIB"; else
36
+ fh_hook_source() { printf 'unknown\n'; }; fh_cadence_due() { return 0; }
37
+ fi
38
+ _FH_HOOK_SRC="$(fh_hook_source)"
31
39
  set -u
32
40
 
33
41
  # FH = the HUB, derived from THIS script's location ($FH/scripts/fh_env_delta_scan.sh → ../ = hub) —
@@ -83,6 +91,13 @@ done
83
91
  CWD_UNMAPPED="$(_candidate "${CLAUDE_PROJECT_DIR:-$PWD}")" || CWD_UNMAPPED=""
84
92
 
85
93
  # Nothing new → silent no-op (majority path).
94
+ # ── /clear·compact 억제는 **표시 판단보다 먼저** 접는다 ─────────────────────────
95
+ # sibling 줄은 «사람이 이미 보고 결정한 나그» 라 같은 세션에서 다시 안 띄운다. 반면 cwd-미매핑
96
+ # 줄은 «지금 그 안에서 일하고 있다» 는 상태라 /clear 직후에 오히려 필요하다 — 둘을 같이 묶지
97
+ # 않는다. ★출력 지점이 아니라 여기서 접는 이유: 뒤에서 접으면 sibling 만 있는 경우에
98
+ # «변화 감지» 헤더가 찍히고 그 아래가 비는 알림이 나간다(=내용 없는 경보).
99
+ fh_cadence_due "$_FH_HOOK_SRC" || COUNT=0
100
+
86
101
  [ "$COUNT" -eq 0 ] && [ -z "$CWD_UNMAPPED" ] && exit 0
87
102
 
88
103
  # Emit the delta proposal (one block, imperative). Trim trailing ", ".
@@ -24,6 +24,16 @@
24
24
  # hard-codes no private path). HUB_DIR overrides the hub path. Never commit the registration to
25
25
  # the public settings.json — the hook is Mode-D-only.
26
26
 
27
+
28
+ # ── SessionStart source (단일 소스 lib) ─────────────────────────────────────────
29
+ # stdin 페이로드는 **한 번만** 읽을 수 있으므로 맨 앞에서 소비하고 변수로 들고 간다.
30
+ _FH_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/hook_source_lib.sh"
31
+ # shellcheck source=scripts/hook_source_lib.sh
32
+ if [ -f "$_FH_LIB" ]; then . "$_FH_LIB"; else
33
+ # lib 부재 = 파싱 불가. 무음으로 억제하지 말고 unknown(=띄운다) 으로 degrade 한다.
34
+ fh_hook_source() { printf 'unknown\n'; }; fh_cadence_due() { return 0; }
35
+ fi
36
+ _FH_HOOK_SRC="$(fh_hook_source)"
27
37
  set -uo pipefail
28
38
 
29
39
  FH="${HUB_DIR:-${CLAUDE_PROJECT_DIR:-$HOME/projects/forge-harness}}"
@@ -161,7 +171,10 @@ fi
161
171
  # so it surfaces and never blocks. Silent when current, and silent when the dir does not exist
162
172
  # (a fresh clone has no audit history and must not be nagged about one).
163
173
  _AUDIT_DIR="$FH/tracks/_audit"
164
- if [ -d "$_AUDIT_DIR" ]; then
174
+ # ── /clear 게이팅: 나그는 «사람이 이미 보고 결정한» 것이라 같은 세션에서 다시 띄우지 않는다.
175
+ # 위의 companion freshness 는 반대로 게이팅하지 않는다 — 그건 «세션이 잃어버린 상태» 라
176
+ # /clear 직후에 오히려 필요하다. 기준은 그 둘의 구분이지 "시작이냐" 가 아니다.
177
+ if [ -d "$_AUDIT_DIR" ] && fh_cadence_due "$_FH_HOOK_SRC"; then
165
178
  _LATEST_AUDIT="$(find "$_AUDIT_DIR" -maxdepth 1 -name 'weekly_audit_*.md' -print 2>/dev/null \
166
179
  | sort | tail -1)"
167
180
  if [ -n "$_LATEST_AUDIT" ]; then
@@ -173,6 +186,26 @@ if [ -d "$_AUDIT_DIR" ]; then
173
186
  fi
174
187
  fi
175
188
 
189
+ # ── branch-claim 자동 기록 (2026-08-09) ────────────────────────────────────────
190
+ # WHY HERE, ABOVE THE COMPANION-STORE EARLY EXIT: 공유 체크아웃 사고는 Mode D 와 무관하다.
191
+ # companion store 가 없는 설치에서도 병렬 세션은 돌고, 그때도 `.git/HEAD` 는 트리당 하나다.
192
+ # 아래 `[ -d "$BE/.git" ] || exit 0` 뒤에 두면 **공개 사용자에게는 게이트가 통째로 죽는다.**
193
+ #
194
+ # WHY AUTO AT ALL: 게이트(templates/.git-hooks/pre-commit → scripts/branch_claim.sh)는
195
+ # **기록이 없으면 «의견 없음»으로 통과**한다. 아무도 `claim` 을 안 하면 장식이 된다 —
196
+ # 오늘 하루 반복해서 본 «만들고 배선 안 함» 의 얼굴. 세션 시작이 유일한 자연스러운 배선점이다.
197
+ #
198
+ # WHY IT IS SAFE (마찰 0):
199
+ # · **없을 때만 기록한다** — 이미 있으면 손대지 않는다. 남의 기록은 애초에 파일이 다르다
200
+ # (`.git/fh-claims/<session_id>`), 그래서 steal 이 구조적으로 불가능하다
201
+ # · **1인 세션은 차단을 안 만난다** — check 는 «살아있는 peer claim 0» 이면 경고만 낸다
202
+ # · detached/rebase/bisect 중이면 `claim` 이 스스로 거부한다(rc=2) → 무해
203
+ # · 실패해도 세션을 막지 않는다(|| true · 출력 억제)
204
+ [ -x "$FH/scripts/branch_claim.sh" ] && {
205
+ ( cd "$FH" && bash scripts/branch_claim.sh claim --if-absent session >/dev/null 2>&1 ) || true
206
+ ( cd "$FH" && bash scripts/branch_claim.sh reap >/dev/null 2>&1 ) || true
207
+ }
208
+
176
209
  # Non-Mode-D / no companion store → silent no-op (this is the majority path for public users).
177
210
  [ -d "$BE/.git" ] || exit 0
178
211
 
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env bash
2
+ # field_canon_preload.sh — 매핑된 필드 하네스가 언급되면 **그 하네스의 정본 경로**를 띄운다.
3
+ #
4
+ # WHY (2026-08-09 실측, 하루 4회 정정 · 자력 적발 0):
5
+ # FH 세션 시작 자동 적재는 **FH 자기 정본만** 싣는다(`fh_session_load.sh` = 동반자 저장소).
6
+ # 그런데 한 세션이 종일 qasp 를 파면서 qasp `README.md`(604줄) · `docs/governance/`(32개)를
7
+ # **하나도 안 읽고** 코드에서 역추론했다. 네 번 정정당했고 네 번 다 **정본에 답이 있었다**:
8
+ # · MTM 을 "조건부 화이트박스 모드" 로 요약 → 정본은 블박+화박 **동시**(이중시야)
9
+ # · "매트릭스 = 축이 여럿" → 영화 매트릭스에서 **네오가 보는 시야**
10
+ # · 3막 본체가 act2 에 있는 걸 배치 결함 판정 → README 가 **의도**라고 명시(L1 공유·중복 0)
11
+ # · 전수조사 모수를 src/act2 로 한정 → b레인·mate 는 모수 밖
12
+ # README 는 그중 하나를 *"이 축이 안 보이면 3막을 mate 판정 전용으로 오해한다"* 로
13
+ # **경고까지 하고 있었다.** 산문 규율로는 안 읽힌다 — `fh_session_load.sh` 헤더가 이미 같은
14
+ # 결론을 적었다("prose is salience-dependent"). 같은 처방을 필드 하네스에 적용한다.
15
+ #
16
+ # WHAT: 프롬프트에 매핑된 프로젝트 이름이 나오면, **실재하는 정본 파일 경로만** 골라 한 번 띄운다.
17
+ # ★ 일반 훈계("정본을 읽어라")가 아니라 **이 레포의 이 파일들**이어야 한다 — 오늘의 실패는
18
+ # 규율을 몰라서가 아니라 **그 파일들이 거기 있는 줄 몰라서**였다.
19
+ #
20
+ # 프로젝트당 세션당 1회. 센티넬로 재나그 방지 — 반복 알림은 무시를 학습시킨다.
21
+ #
22
+ # 종료: 항상 0 · 보고는 stdout. (비영 종료는 stdout 이 폐기되고 stderr 도 안 전달된다 —
23
+ # `[[feedback_hook_nonzero_exit_is_silent]]`.)
24
+ set -uo pipefail
25
+
26
+ HUB="${CLAUDE_PROJECT_DIR:-${HOME:-}/projects/forge-harness}"
27
+ PROJ_ROOT="${FIELD_CANON_PROJECT_ROOT:-${HOME:-}/projects}"
28
+ # ★ `${HOME:-}` — `set -u` 아래서 HOME 부재 시 여기가 unbound 로 죽어 rc=1 이 됐다.
29
+ # 비영 종료는 stdout 이 폐기되므로 헤더의 "항상 0" 계약이 깨진다(cross-family 지적, 재현됨).
30
+ [ -n "${HUB:-}" ] && [ "$HUB" != "/projects/forge-harness" ] || exit 0
31
+ # stdin 은 훅 페이로드(JSON). prompt 를 못 뽑아도 **조용히 죽지 않는다** — 원문 전체로 폴백한다.
32
+ # ★ `session_id` 를 **같은 페이로드에서** 뽑는다. 센티넬 키가 여기 달려 있다(바로 아래).
33
+ # 🟥 SID 는 **자르거나 치환하지 않고 해시한다.** 초판 수리가 슬래시 치환 + 64자 절단이었는데
34
+ # cross-family(gpt-5.5)가 재현했다: 'A/B' 와 'A_B' 가 같은 키 · 65자 이상은 접두 충돌 ·
35
+ # session_id 에 개행이 들어오면 아래 「1행=SID · 2행이후=PROMPT」 프로토콜이 깨져
36
+ # **프롬프트까지 오염**된다(무관 프롬프트로 발화 실증). 원래 버그(전 세션이 한 칸 공유)와
37
+ # **같은 일가를 수리가 다시 연** 것이다. 해시가 충돌·개행·길이·경로문자를 한 번에 없앤다.
38
+ # ⚠️ 아래 python 은 **큰따옴표 문자열**이다 — 주석에 백틱을 넣으면 bash 명령치환이 터진다
39
+ # (이 파일에서 실제로 터뜨렸다. 설명은 여기 bash 주석에 둔다).
40
+ RAW=$(cat 2>/dev/null || true)
41
+ FIELDS=$(printf '%s' "$RAW" | python3 -c "
42
+ import json,sys
43
+ try:
44
+ d = json.load(sys.stdin)
45
+ import hashlib
46
+ sid = d.get('session_id') or ''
47
+ print(hashlib.sha1(sid.encode('utf-8','surrogatepass')).hexdigest()[:16] if sid else '')
48
+ print(d.get('prompt') or '')
49
+ except Exception:
50
+ print(''); print('')
51
+ " 2>/dev/null)
52
+ SID=$(printf '%s' "$FIELDS" | sed -n '1p')
53
+ PROMPT=$(printf '%s' "$FIELDS" | sed -n '2,$p')
54
+ [ -n "$PROMPT" ] || PROMPT="$RAW"
55
+ [ -n "$PROMPT" ] || exit 0
56
+
57
+ # ── 센티넬 키 — 초판이 여기서 틀렸다 (2026-08-09 첫 실사용 실측으로 수리) ──
58
+ # 초판: `${TMPDIR}/fh_field_canon_${CLAUDE_SESSION_ID:-shared}`
59
+ # 🟥 **훅 환경에 `CLAUDE_SESSION_ID` 가 없다.** 전 세션이 literal `shared` 한 칸으로 접혔고
60
+ # TMPDIR 는 재부팅까지 산다 → "프로젝트당 **세션당** 1회" 가 실제로는 **"머신당 1회"** 였다.
61
+ # 실측: 21:18 의 세션이 qasp 센티넬을 소비 → 21:40 에 실제로 qasp 를 파기 시작한 세션은
62
+ # 안내를 **못 받았다**. 이 훅이 막으려던 바로 그 상황에서 침묵했다.
63
+ # ★ 같은 얼굴이 같은 날 다른 스크립트에서도 났다(`branch_claim.sh` 의 `CLAUDE_PID` — 저자 셸엔
64
+ # 있고 CI 엔 없어 검사가 무력화). **환경변수 부재가 조용한 폴백으로 접히고 그 폴백이 검사를
65
+ # 무력화한다** — 처방은 같다: **페이로드에서 읽고 환경에서 빌리지 않는다.**
66
+ # (`session_id` 는 훅 페이로드 필드다 — `compaction_probe.sh:415` 가 이미 그렇게 읽는다.)
67
+ #
68
+ # 폴백 방향은 **과다 알림 쪽**이다: 세션을 못 가르면 `$PPID`(CC 프로세스별로 갈린다)로,
69
+ # 그것도 없으면 dedup 을 **포기**한다. 침묵보다 중복이 낫다 — 초판 폴백은 조용한 쪽이었고
70
+ # 그래서 실패가 안 보였다(`[[feedback_not_found_is_not_zero_family]]`).
71
+ # 🟥 **literal 상수로 폴백하지 마라.** 그 순간 전 세션이 한 칸을 공유한다.
72
+ if [ -n "${FIELD_CANON_SENTINEL_DIR:-}" ]; then
73
+ SENT_DIR="$FIELD_CANON_SENTINEL_DIR"
74
+ elif [ -n "$SID" ]; then
75
+ SENT_DIR="${TMPDIR:-/tmp}/fh_field_canon_sid_${SID}"
76
+ elif [ -n "${PPID:-}" ] && [ "${PPID:-0}" != "0" ]; then
77
+ SENT_DIR="${TMPDIR:-/tmp}/fh_field_canon_ppid_${PPID}"
78
+ else
79
+ SENT_DIR="" # 못 가른다 → dedup 포기(매번 알린다). 침묵 금지.
80
+ fi
81
+ [ -z "$SENT_DIR" ] || mkdir -p "$SENT_DIR" 2>/dev/null || true
82
+
83
+ # 매핑된 프로젝트 = tracks/ 하위 디렉토리(언더스코어 접두는 메타라 제외)
84
+ mapped=$(ls -d "$HUB"/tracks/*/ 2>/dev/null | while read -r d; do
85
+ b=$(basename "$d")
86
+ [ "${b#_}" = "$b" ] || continue # 언더스코어 접두 = 메타 디렉토리, 제외
87
+ printf '%s\n' "$b"
88
+ done)
89
+ [ -n "$mapped" ] || exit 0
90
+
91
+ emitted=0
92
+ for name in $mapped; do
93
+ # 프롬프트에 이름이 나오는가 (대소문자 무시). 짧은 이름의 오탐은 감수 — 과소보다 낫다.
94
+ printf '%s' "$PROMPT" | grep -qiF -- "$name" || continue # -F: 이름의 regex 문자 오탐/미탐 방지
95
+ [ -n "$SENT_DIR" ] && [ -e "$SENT_DIR/$name" ] && continue # 이 세션에서 이미 띄웠다
96
+
97
+ # 레포 해석: tracks 이름 그대로 → 없으면 `-dev` 접미 (qasp → qasp-dev 실측 사례)
98
+ repo=""
99
+ for cand in "$PROJ_ROOT/$name" "$PROJ_ROOT/${name}-dev"; do
100
+ [ -d "$cand/.git" ] && { repo="$cand"; break; }
101
+ done
102
+ [ -n "$repo" ] || continue
103
+
104
+ # **실재하는 것만** 싣는다. 없는 파일을 가리키면 다음 사람이 그 지시를 못 믿게 된다.
105
+ lines=""
106
+ [ -f "$repo/README.md" ] && lines="$lines\n README.md ($(wc -l < "$repo/README.md" | tr -d ' ')줄) — §구조·§지도 절 먼저"
107
+ [ -f "$repo/CLAUDE.md" ] && lines="$lines\n CLAUDE.md ($(wc -l < "$repo/CLAUDE.md" | tr -d ' ')줄)"
108
+ gov=$(ls "$repo/docs/governance" 2>/dev/null | wc -l | tr -d ' ')
109
+ [ "${gov:-0}" -gt 0 ] && lines="$lines\n docs/governance/ — 정본 ${gov}개 (용어·모드·계약의 출처)"
110
+ [ -n "$lines" ] || continue
111
+
112
+ if [ "$emitted" -eq 0 ]; then
113
+ echo ""
114
+ echo "📚 [field-canon] 매핑된 필드 하네스가 언급됐다 — **코드 역추론 전에 그쪽 정본을 읽어라.**"
115
+ emitted=1
116
+ fi
117
+ echo " ▸ $name → $repo"
118
+ printf '%b\n' "$lines"
119
+ [ -z "$SENT_DIR" ] || : > "$SENT_DIR/$name" 2>/dev/null || true
120
+ done
121
+
122
+ if [ "$emitted" -eq 1 ]; then
123
+ cat <<'EOF'
124
+ ⚠️ 이 안내는 프로젝트당 세션당 1회다. 2026-08-09 실측: 정본 미독으로 하루 4회 정정,
125
+ 자력 적발 0 — 네 번 다 답이 정본에 있었고 그중 하나는 README 가 그 오해를 경고까지 했다.
126
+ ★ 필드 하네스 용어를 일반 개념으로 정규화하지 마라(「MTM=화이트박스 모드」가 그 실패다).
127
+ EOF
128
+ fi
129
+ exit 0
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bash
2
+ # hook_source_lib.sh — SessionStart 의 `source` 를 읽는 **단일 소스**.
3
+ #
4
+ # WHY: `/clear` 는 SessionStart 를 재발동시키고 `source: "clear"` 를 준다(startup·resume·compact·
5
+ # fork 와 구분되는 별개 값, 공식 hooks 문서). 그런데 FH 훅들은 stdin 페이로드를 안 읽어서 진짜
6
+ # 시작과 구분하지 못했고, 결과적으로 **캐던스 나그가 /clear 마다 재발화**했다(2026-08-09 운영자 지적).
7
+ #
8
+ # WHY A LIB: 파서를 두 스크립트에 복붙하면 한쪽만 고쳐지는 순간 같은 입력이 서로 다르게 분류된다
9
+ # — 이 레포가 이미 이름 붙인 «관대함 갈린 중복 정규화» 다. 여섯 줄이라도 단일 소스로 둔다.
10
+ #
11
+ # 판별 기준(호출부가 지켜야 하는 것): 「세션이 잃어버린 상태」는 다시 보여주고,
12
+ # 「사람이 이미 보고 결정한 나그」는 안 보여준다. freshness/state = 항상, cadence = startup 계열만.
13
+ #
14
+ # DEGRADE: source 를 못 읽으면 `unknown` 이고, unknown 은 **보여주는 쪽**이다.
15
+ # 중복 알림은 눈에 보이는 소음이지만 놓친 캐던스는 무음 누락이고, 이 레포는 무음 누락을 더 나쁘게
16
+ # 친다. `unknown` 을 조용히 `clear` 로 접는 것이 정확히 그 결함이다(not-found ≠ zero).
17
+ #
18
+ # 이식성: `timeout` 을 쓰지 않는다 — GNU coreutils 이고 stock macOS 에 없다(같은 날 selfcheck.sh
19
+ # 에서 실측으로 물린 defect). `head`/`sed` 만 쓴다.
20
+
21
+ # fh_hook_source — stdout 에 source 를 한 단어로 출력한다.
22
+ fh_hook_source() {
23
+ # TTY = 사람이 손으로 돌린 것. 페이로드가 없으니 읽으려 하면 매달린다.
24
+ if [ -t 0 ]; then printf 'unknown\n'; return 0; fi
25
+ local _p _s
26
+ _p="$(head -c 65536 2>/dev/null || true)"
27
+ _s="$(printf '%s' "$_p" \
28
+ | sed -n 's/.*"source"[[:space:]]*:[[:space:]]*"\([A-Za-z_]*\)".*/\1/p' \
29
+ | head -1)"
30
+ [ -n "$_s" ] || _s="unknown"
31
+ printf '%s\n' "$_s"
32
+ }
33
+
34
+ # fh_cadence_due — 캐던스 나그를 띄워도 되는 source 인가. 0=띄운다 / 1=억제.
35
+ # 억제 대상은 «같은 세션의 연속»인 두 값뿐이다. resume 은 며칠 뒤일 수 있으므로 띄운다.
36
+ fh_cadence_due() {
37
+ case "${1:-unknown}" in
38
+ clear|compact) return 1 ;;
39
+ *) return 0 ;;
40
+ esac
41
+ }
@@ -320,6 +320,34 @@ else
320
320
  fail=1
321
321
  fi
322
322
 
323
+ # branch-claim lanes — shipped with 27 hand-run lanes and ZERO callers, so CI never ran one of them.
324
+ # That is the exact defect its own subject guards against (a gate nobody claims against passes
325
+ # forever), reproduced one layer up in its anchor. Same SKIP/FAIL shape: a missing subject is a
326
+ # legitimate skip, a present subject with a missing anchor is a real failure. The lanes are hermetic
327
+ # (per-PID temp dirs under the worktree, no network, no API spend), so running them here costs
328
+ # nothing.
329
+ #
330
+ # ⚠️ Subject-absent is a FAIL here, NOT the SKIP the sibling blocks use. The SKIP arm exists for
331
+ # subjects that may legitimately not ship; branch_claim.sh IS in package.json files[], so it is
332
+ # present in the source checkout AND in the published package — absence can only mean deletion.
333
+ # Nothing else catches that: measured 2026-08-09, package_coverage_check.sh returns PASS on a
334
+ # files[] entry whose file does not exist, so a deleted subject would be green on every surface
335
+ # (SKIP here + PASS there + npm silently omitting it). A cross-family reviewer caught this; the
336
+ # first draft of this block used SKIP and would have silenced exactly that case.
337
+ # Named residual, deliberately not fixed here: the sibling blocks above carry the same hole for
338
+ # their own shipped subjects. Fixing them is a separate change with its own verification.
339
+ if [ ! -f scripts/branch_claim.sh ]; then
340
+ echo "FAIL scripts/branch_claim.sh is in package.json files[] but absent — a deleted subject, not a skip"
341
+ fail=1
342
+ elif [ -f scripts/test_branch_claim_lanes.sh ]; then
343
+ if ! bash scripts/test_branch_claim_lanes.sh; then
344
+ fail=1
345
+ fi
346
+ else
347
+ echo "FAIL test_branch_claim_lanes.sh: branch_claim.sh present but its anchor is missing"
348
+ fail=1
349
+ fi
350
+
323
351
  # And the ablation calibrator, for the third instance of the same reason. Its whole job is telling
324
352
  # apart states that look identical from outside — "the arm could not answer" vs "the runner is dead"
325
353
  # vs "the runner read the answer off disk" — and round 2 of its own adversarial review found that
@@ -478,6 +506,18 @@ else
478
506
  fail=1
479
507
  fi
480
508
 
509
+ # hook_source_gate 는 subject 가 session_close_check.sh 가 아니라 별도로 돌린다 — 위 루프의
510
+ # SKIP 조건(subject 부재)에 얹으면 엉뚱한 이유로 건너뛴다.
511
+ if [ -f scripts/test_hook_source_gate_lanes.sh ]; then
512
+ if bash scripts/test_hook_source_gate_lanes.sh >/dev/null 2>&1; then
513
+ echo "PASS test_hook_source_gate_lanes.sh"
514
+ else
515
+ echo "FAIL test_hook_source_gate_lanes.sh (SessionStart source 게이트 쌍 붕괴)"
516
+ bash scripts/test_hook_source_gate_lanes.sh 2>&1 | grep "❌" | head -5 | sed "s/^/ /"
517
+ fail=1
518
+ fi
519
+ fi
520
+
481
521
  for _anchor in scripts/test_session_close_lanes.sh scripts/test_card_drift_probe.sh scripts/test_session_close_chain_lanes.sh; do
482
522
  if [ ! -f scripts/session_close_check.sh ]; then
483
523
  echo "SKIP ${_anchor##*/} (subject scripts/session_close_check.sh absent)"