@chrono-meta/fh-gate 1.4.90 → 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.
@@ -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
+ }