@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.
@@ -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
@@ -188,6 +188,40 @@ LIGHT=$(echo "$LIGHT" | grep -vE '^\s*$' || true)
188
188
  # boundary, so they run on EVERY commit regardless of what is staged.
189
189
  # Body is intentionally NOT indented: it contains heredocs whose terminators must sit at column 0.
190
190
  run_universal_guards() {
191
+ # ── Branch-claim guard — «내가 믿는 브랜치 ≠ HEAD» (2026-08-09) ───────────────
192
+ # WHY HERE, WITH THE OTHER UNIVERSAL GUARDS: surface-scoped, not 4-axis-scoped — a commit
193
+ # lands on a branch whether or not an FH asset is staged, so it must run before the
194
+ # "no FH assets → exit 0" path.
195
+ #
196
+ # WHAT IT CATCHES (measured 2026-08-09, two parallel sessions, one checkout):
197
+ # `.git/HEAD` is one per working tree; session A's `git switch` moves B's ground too.
198
+ # B had already "cut a branch at the start of work" — that did not prevent it.
199
+ # Key insight: the judgment key is the SESSION, not the tree. A tree-level claim is
200
+ # defeated when BOTH sessions follow the protocol (A switches + re-claims → claim==HEAD →
201
+ # B passes) — i.e. it gets weaker as adoption rises. Per-session records
202
+ # (.git/fh-claims/<session_id>, keyed on CLAUDE_CODE_SESSION_ID which git hooks inherit)
203
+ # compare only MY record against HEAD, so peers can never over-block me.
204
+ #
205
+ # NOT A ROOT FIX: it does not block `git switch` (git has no switch hook) and does not
206
+ # protect the uncommitted worktree. Root fix = one worktree per session.
207
+ #
208
+ # DEGRADE: no record · unparseable · detached/rebase/bisect/merge · no LIVE peer session
209
+ # → PASS. Sole rationale: over-blocking trains the override and kills the gate.
210
+ # (Deliberately NOT "a commit is reversible" — this incident's recovery went through a
211
+ # shared-branch history rewrite, which that invariant classes fail-closed.)
212
+ # Anchor: scripts/test_branch_claim_lanes.sh — includes the S-1 regression lane (both
213
+ # sessions protocol-compliant) and controls proving the pass-lanes are not unconditional.
214
+ BCLAIM="$REPO_ROOT/scripts/branch_claim.sh"
215
+ if [ -f "$BCLAIM" ]; then
216
+ if ! bash "$BCLAIM" check; then
217
+ FAILED=1
218
+ fi
219
+ else
220
+ # 부재를 통과로 렌더하지 않는다 — 이웃 가드(PSA_LIB)는 부재 시 FAIL 인데 여기만 무음이면
221
+ # «가드가 안 돌았다»와 «가드가 통과했다»가 구분 불가해진다. 차단은 안 한다(과차단 회피).
222
+ echo " ⚠️ branch-claim guard NOT INSTALLED (scripts/branch_claim.sh) — skipped, not passed"
223
+ fi
224
+
191
225
  # ── Privacy guard — tracks/ staged-path allowlist (structural, name-free) ─────
192
226
  # tracks/** is local-by-default (frozen-seed policy); the only public lanes are
193
227
  # tracks/_contrib/** (consent lane) and ALREADY-TRACKED .gitkeep skeleton files.
@@ -338,6 +372,16 @@ else
338
372
  GATE_MODE="lightweight"
339
373
  fi
340
374
 
375
+ # TOCTOU 완화: 앞선 branch-claim check 이후 게이트들이 도는 동안 peer 가 브랜치를 옮길 수
376
+ # 있다. 최종 판정 직전에 한 번 더 돌려 창을 좁힌다. (원자적 close 는 reference-transaction
377
+ # 훅의 몫 — 이건 완화이지 해결이 아니다.)
378
+ if [ -f "$REPO_ROOT/scripts/branch_claim.sh" ]; then
379
+ if ! bash "$REPO_ROOT/scripts/branch_claim.sh" check; then
380
+ echo " ↑ 게이트 실행 중에 브랜치가 옮겨졌다 (TOCTOU 창에서 포착)"
381
+ FAILED=1
382
+ fi
383
+ fi
384
+
341
385
  echo ""
342
386
  echo "══════════════════════════════════════════════"
343
387
  echo " FH 4-Axis Gate — ${GATE_MODE} mode"
@@ -0,0 +1,51 @@
1
+ {
2
+ "_README": [
3
+ "field-canon 선적재 훅 스니펫 — TRACKED 라 clone 과 함께 도착한다. 이 레포의 모든",
4
+ ".claude/settings*.json 경로는 gitignored 이므로(`git check-ignore -v .claude/settings.json` 으로",
5
+ "확인) **훅 등록 자체를 tracked 로 둘 수 없다.** 이것이 install-wizard 가 사용자 로컬 설정에",
6
+ "머지하는 tracked SOURCE 다. 머지 단계가 없으면 훅은 돌지 않는다 —",
7
+ "**스크립트를 출하하는 것과 배선하는 것은 같은 명제가 아니다.**",
8
+ "",
9
+ "이 파일이 왜 뒤늦게 생겼나 (2026-08-09 실측, 컨트롤 동반):",
10
+ " PR #299 가 scripts/field_canon_preload.sh 를 심으면서 스니펫도 files[] 등재도 안 했다.",
11
+ " npm pack --dry-run | grep field_canon → 0건",
12
+ " CONTROL 같은 명령 | grep compaction_probe → 1건 (계기 생존)",
13
+ " 즉 npm 사용자에게는 **스크립트도 배선도 아무것도 가지 않았다.** git clone 사용자는",
14
+ " 스크립트는 받지만 배선이 없어 훅이 안 돈다. 위 Compaction 스니펫의 _README 가 이미",
15
+ " 같은 문장을 적어놨는데 #299 가 그걸 안 따랐다 — 산문 규율의 한계이자, 이 스니펫이",
16
+ " 그 규율을 파일로 바꾸는 이유다.",
17
+ "",
18
+ "무엇을 하나: 프롬프트에 **매핑된 필드 하네스 이름**이 나오면 그 레포의 정본 경로",
19
+ "(README · CLAUDE.md · docs/governance/)를 **세션당 1회** 띄운다. 코드 역추론 전에 정본을",
20
+ "읽게 하는 것이 목적이다. 2026-08-09 실측: 한 세션이 종일 qasp 를 파면서 정본을 하나도",
21
+ "안 읽고 코드에서 역추론해 **네 번 정정당했고 자력 적발 0** 이었다 — 네 번 다 답이",
22
+ "정본에 있었고 그중 하나는 README 가 그 오해를 경고까지 하고 있었다.",
23
+ "",
24
+ "🟥 부재를 침묵으로 렌더하지 않는다. 초판 배선은 `[ -f \"$S\" ] && bash \"$S\"` 였는데,",
25
+ "그러면 **「이 install 에 파일이 없는 게 정상」과 「체크아웃이 옛날 컷 브랜치라 파일이",
26
+ "사라진 상태」가 구분되지 않는다.** 공유 체크아웃에서 후자가 실제로 반복 발생했고",
27
+ "(하루 6회 브랜치 이동), 그동안 훅은 exit 0 · 출력 0줄로 조용히 꺼져 있었다.",
28
+ "그래서 else 가지에서 **한 줄 경보**를 낸다 — 차단은 하지 않는다(UserPromptSubmit 은",
29
+ "차단 표면이 아니고, 사용자 앞을 막는 게이트는 꺼지는 게이트다).",
30
+ "",
31
+ "PRIVATE PATH 없음 → .claude/settings.json(프로젝트 로컬)에 속한다.",
32
+ "머지하되 덮어쓰지 마라: 사용자의 기존 UserPromptSubmit 엔트리를 보존하고 **스크립트",
33
+ "이름을 키로** FH 것만 교체해라."
34
+ ],
35
+ "project_settings_json": {
36
+ "hooks": {
37
+ "UserPromptSubmit": [
38
+ {
39
+ "matcher": "",
40
+ "hooks": [
41
+ {
42
+ "type": "command",
43
+ "command": "bash -c 'HUB=\"${CLAUDE_PROJECT_DIR:-$HOME/projects/forge-harness}\"; S=\"$HUB/scripts/field_canon_preload.sh\"; if [ -f \"$S\" ]; then bash \"$S\"; else echo \"\"; echo \"⚠️ [field-canon] 선적재 훅 스크립트가 없다 — 이 체크아웃이 옛날 컷 브랜치이거나 설치가 불완전할 수 있다.\"; echo \" 필드 하네스 정본 안내가 **꺼져 있다**. git log --oneline -1 로 브랜치를 확인해라.\"; fi; exit 0'",
44
+ "timeout": 10
45
+ }
46
+ ]
47
+ }
48
+ ]
49
+ }
50
+ }
51
+ }