@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.
@@ -94,6 +94,128 @@ if command -v gh >/dev/null 2>&1; then
94
94
  [ "${PRS:-0}" -gt 0 ] && echo "⚠️ ①-b $PRS open PR(s) by you — classify: self-mergeable vs awaiting-external"
95
95
  fi
96
96
 
97
+ # ── ①-d CLOSE RE-OPENED? ──────────────────────────────────────────────────────
98
+ # WHY: a session that closed cleanly and then kept working looks IDENTICAL to one that never
99
+ # closed — both read Completed from outside, and ①-c cannot separate them. Measured 2026-08-09:
100
+ # this session ran step 5 eleven times and a peer independently named the same gap. The two need
101
+ # opposite actions — an unfinished close must be FINISHED, a re-opened one must be RE-FOLDED.
102
+ # HOW: stamp each close-gated pass and count. Self-attested (whoever runs the check writes it):
103
+ # it detects the pattern, it does not prove it.
104
+ CLOSE_STAMP="$FH/tracks/_meta/.close_stamps_$TODAY"
105
+ if [ "${FH_SESSION_CLOSE:-0}" = "1" ]; then
106
+ mkdir -p "$(dirname "$CLOSE_STAMP")" 2>/dev/null
107
+ date -u +%Y-%m-%dT%H:%M:%SZ >> "$CLOSE_STAMP" 2>/dev/null || true
108
+ fi
109
+ if [ -f "$CLOSE_STAMP" ]; then
110
+ CLOSE_N=$(grep -c . "$CLOSE_STAMP" 2>/dev/null | tr -d ' ')
111
+ if [ "${CLOSE_N:-0}" -gt 1 ]; then
112
+ echo "ℹ️ ①-d close ran $CLOSE_N times today — CLOSED-THEN-WORKED, not a stalled close."
113
+ echo " Both look Completed from outside; only this counter separates them."
114
+ echo " Every re-open owes a FULL step 5: append to fh_completed FIRST, then rewrite the card."
115
+ fi
116
+ fi
117
+
118
+ # ── ①-c LIVE PEER SESSIONS in this same harness ─────────────────────────────────
119
+ # WHY: FH's close chain has an agreed two-axis order (peer appends → ping → **ask "더 있나" before
120
+ # folding** → fold → re-check merged PRs), and it lived only in the session card as prose. Measured
121
+ # 2026-08-09, the first two-axis close: the ping surfaced four deltas the closing session did not
122
+ # know about, and **two of them were structurally invisible to `gh pr list`** (an inheritance
123
+ # channel and a landing check — no PR exists for either). So a PR sweep is not a substitute; the
124
+ # question is. This block does the DETECTION half mechanically. It never merges anything and never
125
+ # blocks — the merge is where the loss risk is (two sessions writing one card is the shared-checkout
126
+ # hazard this repo already named), and one occurrence is below this repo's own mechanization bar.
127
+ #
128
+ # HOW: a live session owns /tmp/cc-socks/<pid>.sock. pid → cwd resolves the ONE thing that actually
129
+ # matters, "is this peer in MY harness" — which the agent-facing session list cannot answer, because
130
+ # it reports names, not directories. Stale sockets are detectable (pid gone), so absence here is a
131
+ # measurement rather than silence.
132
+ # SCOPE (operator, 2026-08-09): this is a **claude-agents / Agent-View** feature — the surface where
133
+ # sessions are deliberately run in parallel. A plain solo terminal must not be nagged because some
134
+ # other window happens to be open. Discriminator is env, set by the parent that spawns the session.
135
+ # ⚠️ NEGATIVE ARM UNVERIFIED: a child session cannot observe a top-level session's environment, so
136
+ # "a real top-level session lacks these vars" is asserted, not measured. The lane simulates absence
137
+ # by unsetting them, which tests THIS SCRIPT'S logic — not that claim. Verify from a plain terminal.
138
+ if [ -z "${CLAUDE_CODE_CHILD_SESSION:-}${CLAUDE_CODE_AGENT:-}" ] && [ "${FH_PEER_SCAN_FORCE:-0}" != "1" ]; then
139
+ : # solo/top-level surface — ①-c is out of scope here, and silence is the correct output
140
+ else
141
+ PEER_LIVE=0; PEER_UNKNOWN=0; PEER_STALE=0; PEER_SPARE=0; PEER_IDS=""
142
+ SOCK_DIR="${FH_PEER_SOCK_DIR:-/tmp/cc-socks}"
143
+ # Self-exclusion must walk the ANCESTOR chain, not compare `$$`. This script is a grandchild of the
144
+ # session process that owns the socket, so `$$` never matches it — measured: the first run counted
145
+ # the calling session as its own peer, i.e. it would tell you to go ask yourself.
146
+ SELF_CHAIN=" $$ "
147
+ _p=$$
148
+ while :; do
149
+ _p=$(ps -o ppid= -p "$_p" 2>/dev/null | tr -d ' ')
150
+ case "$_p" in ''|0|1) break ;; esac
151
+ SELF_CHAIN="$SELF_CHAIN$_p "
152
+ done
153
+ _peer_cwd() { # $1=pid → cwd on stdout, empty if unresolvable
154
+ if [ -r "/proc/$1/cwd" ]; then readlink "/proc/$1/cwd" 2>/dev/null; return; fi
155
+ command -v lsof >/dev/null 2>&1 || return 0
156
+ lsof -a -p "$1" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -1
157
+ }
158
+ if [ -d "$SOCK_DIR" ]; then
159
+ for _s in "$SOCK_DIR"/*.sock; do
160
+ [ -e "$_s" ] || continue
161
+ _pid="${_s##*/}"; _pid="${_pid%.sock}"
162
+ case "$_pid" in ''|*[!0-9]*) continue ;; esac
163
+ case "$SELF_CHAIN" in *" $_pid "*) continue ;; esac # 나 자신(조상 체인)
164
+ if ! ps -p "$_pid" >/dev/null 2>&1; then PEER_STALE=$((PEER_STALE+1)); continue; fi
165
+ # bg-spare = pre-warmed worker, NOT a session. It owns a socket and a cwd like everything else,
166
+ # so cwd matching alone counts it as a peer — measured 2026-08-09, and it is a pure false
167
+ # positive with a clean signature. Exclude mechanically rather than explaining it away.
168
+ case "$(ps -p "$_pid" -o args= 2>/dev/null)" in *--bg-spare*) PEER_SPARE=$((PEER_SPARE+1)); continue ;; esac
169
+ _pcwd="$(_peer_cwd "$_pid")"
170
+ if [ -z "$_pcwd" ]; then
171
+ # Alive but undirected. NOT zero — a peer we cannot place is exactly the case that must not
172
+ # render as "no peers" (not-found != zero).
173
+ PEER_UNKNOWN=$((PEER_UNKNOWN+1)); continue
174
+ fi
175
+ case "$_pcwd" in
176
+ "$FH"|"$FH"/*) PEER_LIVE=$((PEER_LIVE+1)); PEER_IDS="$PEER_IDS $_pid" ;;
177
+ esac
178
+ done
179
+ else
180
+ PEER_UNKNOWN=-1
181
+ fi
182
+ if [ "$PEER_UNKNOWN" = "-1" ]; then
183
+ echo "ℹ️ ①-c peer scan UNMEASURED — no session-socket dir at $SOCK_DIR (not 'no peers')"
184
+ elif [ "$PEER_LIVE" -gt 0 ]; then
185
+ echo "⚠️ ①-c $PEER_LIVE peer CANDIDATE(s) in THIS harness —$PEER_IDS"
186
+ echo " The surplus over the fleet view Working list is MIXED — do not read it as one thing."
187
+ echo " Measured 2026-08-09, three kinds land here and only the first needs a message:"
188
+ echo " working session → ask before folding"
189
+ echo " idle terminal → a real session sitting at a prompt. Not working. Usually skip."
190
+ echo " lingering process → a close that never finished (seen once, had to be killed by hand)"
191
+ echo " Only the fleet view separates them. An earlier version of this text called the whole"
192
+ echo " surplus unfinished closes; that was one case generalised, and it was wrong."
193
+ echo " Do NOT switch to the job state file to narrow it: it marked THIS session done"
194
+ echo " (firstTerminalAt set) while it was still running — it under-reports as hard as"
195
+ echo " sockets over-report. Cross-read with the fleet view; neither source alone is right."
196
+ echo " THIS IS A DETECTION LIST, NOT A RECIPIENT LIST — do not message all of them."
197
+ echo " What survives here is: a live PROCESS whose cwd is this harness, minus pre-warmed"
198
+ echo " spares. That still includes IDLE TERMINALS sitting at a prompt. It is NOT a list of"
199
+ echo " sessions that are working — nothing here can tell you that; the fleet view can."
200
+ echo " Notify only the ones actually WORKING/BUSY right now. Messaging a finished session"
201
+ echo " is noise, burns a recipient approval, and does not land in their record."
202
+ echo " Of those still working, ASK '지금부터 마감이다, 더 있나' — the question is wider than a"
203
+ echo " PR sweep: a peer's inheritance-channel or landing-check work has no PR to find."
204
+ echo " Then fold, then re-check \`gh pr list --merged\`. Merging their delta stays MANUAL."
205
+ echo " SENDING IS NOT DELIVERING — a cross-session message can be held for the recipient"
206
+ echo " user approval and never arrive; the send call still reports success. Measured"
207
+ echo " 2026-08-09: 2 of 5 notices were held while the card already claimed all 5 informed."
208
+ echo " Record ANSWERS, not sends, and put the unanswered count in the card."
209
+ [ "$PEER_UNKNOWN" -gt 0 ] && echo " + $PEER_UNKNOWN live session(s) whose directory is UNKNOWN (counted, not dismissed)"
210
+ elif [ "$PEER_UNKNOWN" -gt 0 ]; then
211
+ echo "⚠️ ①-c 0 peers placed in this harness, but $PEER_UNKNOWN live session(s) are UNPLACEABLE"
212
+ echo " — treat as possible peers and ask; do not read this as 'closing alone'."
213
+ else
214
+ echo "✅ ①-c no live peer in this harness (${PEER_STALE} stale · ${PEER_SPARE} pre-warmed spare(s) ignored)"
215
+ fi
216
+
217
+ fi
218
+
97
219
  # ② FH assets changed today → harvest-loop owed
98
220
  # NOTE: no `grep -q` here — under `set -o pipefail`, -q's early exit SIGPIPEs git log (141),
99
221
  # masking a real match as pipeline failure so the warning NEVER fired on true positives
@@ -219,7 +341,18 @@ DISPATCHED=$(_int "$(grep -c "^$TODAY$" "$TALLY" 2>/dev/null | tr -d ' ' || true
219
341
  # while anything appended via yaml.dump renders `- date: '"'"'2026-08-02'"'"'`. Matching one form counted
220
342
  # half the entries as absent — a divergent-normalizer miss inside the check that exists to catch
221
343
  # missing records. Known-pair calibrated below in test_dispatch_log_lanes.sh.
222
- LOGGED=$(_int "$(grep -cE "^- date: *'?$TODAY'?" "$LOG" 2>/dev/null | tr -d ' ' || true)")
344
+ # ── 전환 설계: 레거시 단일 파일 세션별 디렉터리를 **둘 다** 읽는다 ────────────
345
+ # WHY: 같은 파일 끝에 여러 세션이 append 하면 브랜치 병합이 확정 충돌이고, 그 해소가
346
+ # 조용히 손상된다 — git 이 엔트리 헤더 한 줄을 공통 접두로 밀어내서 «양쪽 다 보존»이라는
347
+ # 정답 해소가 엔트리를 흡수시킨다(2026-08-09 재현: 항목 133/정답 134, 파싱은 통과).
348
+ # 처방은 세션당 한 파일. 다만 **마이그레이션과 배선을 같은 커밋에 묶으면** 아직 머지 안 된
349
+ # 원장 브랜치와 대형 충돌이 나므로, 소비자를 먼저 «둘 다 읽게» 만든다. 그러면 데이터가
350
+ # 언제 옮겨가든 이 검사는 안 깨진다. 두 상태 모두 known-pair 로 고정돼 있다.
351
+ LOGDIR="$FH/knowledge/shared/learnings/subagent_invocations"
352
+ _cat_logs() { # 다중 파일에 grep -c 를 직접 걸면 파일별 `경로:개수` 를 찍어 합산이 깨진다(실측).
353
+ { [ -f "$LOG" ] && cat "$LOG"; [ -d "$LOGDIR" ] && cat "$LOGDIR"/*.yaml; } 2>/dev/null
354
+ }
355
+ LOGGED=$(_int "$(_cat_logs | grep -cE "^- date: *'?$TODAY'?" | tr -d ' ' || true)")
223
356
  if [ "${DISPATCHED:-0}" -gt 0 ] && [ "${LOGGED:-0}" -eq 0 ]; then
224
357
  echo "❌ ④-e $DISPATCHED sub-agent dispatch(es) today and ZERO invocation-log entries — the 60/40"
225
358
  echo " promotion gate and the UAP loop both read that file; an unlogged session is invisible to"
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env bash
2
+ # test_branch_claim_lanes.sh — branch_claim.sh known-pair 앵커.
3
+ #
4
+ # 왜 실행 테스트인가: `bash -n` 은 계기가 아니다 — 문법만 보고 exit 0 을 내므로
5
+ # «전부 통과»와 구분이 안 된다([[feedback_gate_verification_must_execute]]).
6
+ # «막혔다» ≠ «옳게 막혔다» 이므로 BLOCK 레인은 **완성 문장**으로 매칭한다 —
7
+ # 조각 매칭은 claim/HEAD 역할을 뒤집는 뮤테이션을 통과시켰다(적대검증 A-5 실측).
8
+ # 그 역할 뒤바뀜은 사용자를 반대 브랜치로 보내므로 게이트가 사고를 만드는 쪽이 된다.
9
+ #
10
+ # 격리: 레인마다 임시 저장소를 새로 판다. 실제 레포는 안 건드린다.
11
+ # 세션 시뮬레이션: FH_CLAIM_TEST=1 + FH_CLAIM_SESSION 으로 두 세션을 흉내낸다.
12
+ #
13
+ # usage: bash scripts/test_branch_claim_lanes.sh
14
+
15
+ set -uo pipefail
16
+ SCRIPT="$(cd "$(dirname "$0")" && pwd)/branch_claim.sh"
17
+ PASS=0; FAIL=0; LIVE_PID=""
18
+
19
+ cleanup() { [ -n "$LIVE_PID" ] && kill "$LIVE_PID" 2>/dev/null; return 0; }
20
+ trap cleanup EXIT
21
+
22
+ _mkrepo() {
23
+ local d; d=$(mktemp -d "${TMPDIR:-/tmp}/bclaim.XXXXXX")
24
+ git -C "$d" init -q -b main
25
+ git -C "$d" config user.email t@t; git -C "$d" config user.name t
26
+ echo x > "$d/f"; git -C "$d" add f; git -C "$d" commit -qm init
27
+ echo "$d"
28
+ }
29
+ # 살아있는 peer 세션 하나를 실제 프로세스로 만든다 (kill -0 이 참이 되도록)
30
+ # ⚠️ 반드시 fd 를 닫아서 띄운다: 백그라운드 프로세스가 stdout 을 물고 있으면
31
+ # 이 스크립트를 파이프에 물릴 때 파이프가 안 닫혀 무한 대기한다(실측: 120s 타임아웃).
32
+ _spawn_live() { sleep 900 >/dev/null 2>&1 & LIVE_PID=$!; echo "$LIVE_PID"; }
33
+
34
+ _lane() { # name expected_rc actual_rc [must_contain...]
35
+ local name="$1" exp="$2" got="$3"; shift 3
36
+ local out="$LANE_OUT" ok=1 pat
37
+ [ "$got" = "$exp" ] || { ok=0; echo " ❌ $name — rc=$got (기대 $exp)"; }
38
+ for pat in "$@"; do
39
+ case "$out" in *"$pat"*) ;; *) ok=0; echo " ❌ $name — 출력에 «${pat}» 없음";; esac
40
+ done
41
+ if [ "$ok" = 1 ]; then echo " ✅ $name"; PASS=$((PASS+1)); else FAIL=$((FAIL+1));
42
+ echo " ── 실제 출력 ──"; echo "$out" | sed 's/^/ /'; fi
43
+ }
44
+ _ok() { if [ "$2" = 1 ]; then echo " ✅ $1"; PASS=$((PASS+1)); else echo " ❌ $1"; FAIL=$((FAIL+1)); fi; }
45
+
46
+ # 세션 B(=peer)의 claim 을 살아있는 pid 로 직접 심는다
47
+ _plant_peer() { # repo branch pid
48
+ mkdir -p "$1/.git/fh-claims"
49
+ printf 'branch=%s\nlabel=peer\npid=%s\nat_epoch=%s\n' "$2" "$3" "$(date +%s)" \
50
+ > "$1/.git/fh-claims/session-PEER"
51
+ }
52
+
53
+ # ⚠️ 주변 환경을 흡입하지 않는다 — 이 한 줄이 없어서 이 스위트는 «거짓 초록» 이었다.
54
+ # branch_claim.sh 의 `_session_pid() { echo "${CLAUDE_PID:-${PPID:-$$}}"; }` 때문에, 저자 셸에
55
+ # CLAUDE_PID 가 있으면 claim 이 **살아있는 pid**(그 Claude 프로세스)를 기록한다. 레인 ③ 은
56
+ # A 가 재-claim 해서 peer 기록을 덮는 시나리오라, 그 pid 가 살아있느냐가 판정을 통째로 뒤집는다.
57
+ # 실측 2026-08-09 (known-pair, 같은 트리·같은 커밋):
58
+ # CLAUDE_PID 있음 → 24 PASS / 0 FAIL (저자 로컬 — 초록)
59
+ # CLAUDE_PID 없음 → 23 PASS / 1 FAIL ③ (CI — 적색, rc=0 기대 1)
60
+ # 즉 코드가 아니라 **저자 환경**을 재고 있었고, 그 초록이 S-1 재설계의 유일한 증명이었다.
61
+ # 앵커가 배선되기 전까지(=호출부 0개) 이 사실은 드러날 수 없었다.
62
+ unset CLAUDE_PID
63
+
64
+ echo "[branch-claim] known-pair 앵커"
65
+ PEERPID=$(_spawn_live)
66
+
67
+ # ── ① 기록 없음 → 통과 (부재 ≠ 위반) ───────────────────────────────────────
68
+ R=$(_mkrepo); LANE_OUT=$(cd "$R" && bash "$SCRIPT" check 2>&1); rc=$?
69
+ _lane "① 기록 없음 → 통과" 0 "$rc"; rm -rf "$R"
70
+
71
+ # ── ② 내 기록 == HEAD → 통과, 그리고 at_epoch 이 갱신된다 (A-6) ────────────
72
+ R=$(_mkrepo)
73
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me FH_CLAIM_NOW=1000 bash "$SCRIPT" claim a >/dev/null 2>&1)
74
+ before=$(sed -n 's/^at_epoch=//p' "$R/.git/fh-claims/me")
75
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me FH_CLAIM_NOW=99000 bash "$SCRIPT" check 2>&1); rc=$?
76
+ after=$(sed -n 's/^at_epoch=//p' "$R/.git/fh-claims/me")
77
+ _lane "② 내 기록==HEAD → 통과" 0 "$rc"
78
+ _ok "②-b at_epoch 갱신됨 (${before} -> ${after}) — 나이가 «유휴»를 재게 된다" "$([ "$after" = 99000 ] && echo 1 || echo 0)"
79
+ rm -rf "$R"
80
+
81
+ # ── ③ ★ S-1 회귀: 양쪽 세션이 다 프로토콜을 지켜도 잡는다 ──────────────────
82
+ # 초판 설계는 여기서 통과했다(= 오늘 사고를 못 막았다). 이 레인이 그 반증이다.
83
+ # 타임라인: B가 main claim → A가 switch+claim(프로토콜 준수) → B가 커밋 시도
84
+ R=$(_mkrepo)
85
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=B bash "$SCRIPT" claim bee >/dev/null 2>&1)
86
+ _plant_peer "$R" main "$PEERPID" # A 를 살아있는 peer 로
87
+ git -C "$R" switch -q -c ifkakao # A 가 트리를 옮기고
88
+ # A 는 «살아있는» 세션이다 — 그 사실을 환경에서 빌리지 말고 레인이 직접 고정한다.
89
+ # CLAUDE_PID 를 안 주면 claim 이 곧 죽을 서브셸의 PPID 를 기록하고, B 의 check 는
90
+ # «살아있는 peer 0» 으로 통과한다 — 시나리오가 성립하지도 않은 채 초록이 된다.
91
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=session-PEER CLAUDE_PID="$PEERPID" bash "$SCRIPT" claim ay >/dev/null 2>&1) # 프로토콜대로 claim
92
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=B bash "$SCRIPT" check 2>&1); rc=$?
93
+ _lane "③ ★S-1 회귀: 양쪽이 프로토콜을 지켜도 B 는 차단된다" 1 "$rc" \
94
+ "이 세션은 'main' 로 기록돼 있는데 HEAD 는 'ifkakao' 다" "git switch -- 'main'"
95
+ rm -rf "$R"
96
+
97
+ # ── ④ 불일치인데 살아있는 peer 없음 → 경고만, 통과 (A-2 과차단 방지) ───────
98
+ R=$(_mkrepo)
99
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=solo bash "$SCRIPT" claim s >/dev/null 2>&1)
100
+ git -C "$R" switch -q -c next
101
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=solo bash "$SCRIPT" check 2>&1); rc=$?
102
+ _lane "④ 1인 세션(살아있는 peer 0) → 차단 안 함" 0 "$rc" "살아있는 peer 세션이 없어"
103
+ rm -rf "$R"
104
+
105
+ # ── ④-b 컨트롤: 같은 상황에 살아있는 peer 가 있으면 차단 ───────────────────
106
+ R=$(_mkrepo)
107
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=solo bash "$SCRIPT" claim s >/dev/null 2>&1)
108
+ _plant_peer "$R" whatever "$PEERPID"
109
+ git -C "$R" switch -q -c next
110
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=solo bash "$SCRIPT" check 2>&1); rc=$?
111
+ _lane "④-b 컨트롤: peer 살아있으면 차단 (④가 무조건 통과가 아님)" 1 "$rc"
112
+ rm -rf "$R"
113
+
114
+ # ── ④-c 죽은 peer 는 동시성 증거가 아니다 ──────────────────────────────────
115
+ R=$(_mkrepo)
116
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=solo bash "$SCRIPT" claim s >/dev/null 2>&1)
117
+ _plant_peer "$R" whatever 999999 # 존재하지 않는 pid
118
+ git -C "$R" switch -q -c next
119
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=solo bash "$SCRIPT" check 2>&1); rc=$?
120
+ _lane "④-c 죽은 peer → 차단 안 함 (시간 상수 의존을 줄인다)" 0 "$rc"
121
+ rm -rf "$R"
122
+
123
+ # ── ⑤ STALE → 통과 / 임계 미만 → 차단 ──────────────────────────────────────
124
+ R=$(_mkrepo)
125
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me FH_CLAIM_NOW=0 bash "$SCRIPT" claim s >/dev/null 2>&1)
126
+ _plant_peer "$R" x "$PEERPID"; git -C "$R" switch -q -c other
127
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me FH_CLAIM_NOW=$((13*3600)) bash "$SCRIPT" check 2>&1); rc=$?
128
+ _lane "⑤ STALE(13h) → 통과" 0 "$rc" "STALE"
129
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me FH_CLAIM_NOW=$((11*3600)) bash "$SCRIPT" check 2>&1); rc=$?
130
+ _lane "⑤-b 컨트롤: 11h → 여전히 차단" 1 "$rc"
131
+ rm -rf "$R"
132
+
133
+ # ── ⑥ override → 통과 + **파일에 실제로 기록** (A-4 팬텀 주장 수리) ────────
134
+ R=$(_mkrepo)
135
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim s >/dev/null 2>&1)
136
+ _plant_peer "$R" x "$PEERPID"; git -C "$R" switch -q -c other
137
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me FH_BRANCH_CLAIM_OK=1 bash "$SCRIPT" check 2>&1); rc=$?
138
+ _lane "⑥ override → 통과" 0 "$rc" "FH_BRANCH_CLAIM_OK"
139
+ _ok "⑥-b override 로그 파일이 실제로 생성·append 됨" \
140
+ "$([ -s "$R/.git/fh-branch-claim-overrides.log" ] && echo 1 || echo 0)"
141
+ rm -rf "$R"
142
+
143
+ # ── ⑦ detached / rebase 중 → 통과 (A-1 과차단 수리) ────────────────────────
144
+ R=$(_mkrepo)
145
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim s >/dev/null 2>&1)
146
+ _plant_peer "$R" x "$PEERPID"; git -C "$R" checkout -q --detach
147
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" check 2>&1); rc=$?
148
+ _lane "⑦ detached HEAD → 통과 (rebase/bisect 중 커밋을 막지 않는다)" 0 "$rc"
149
+ rm -rf "$R"
150
+ R=$(_mkrepo)
151
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim s >/dev/null 2>&1)
152
+ _plant_peer "$R" x "$PEERPID"; git -C "$R" switch -q -c other; mkdir -p "$R/.git/rebase-merge"
153
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" check 2>&1); rc=$?
154
+ _lane "⑦-b rebase 진행 중 → 통과" 0 "$rc"
155
+ rm -rf "$R"
156
+
157
+ # ── ⑧ 테스트 노브는 FH_CLAIM_TEST=1 없이 무시된다 (A-3 무음 우회 차단) ─────
158
+ R=$(_mkrepo)
159
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim s >/dev/null 2>&1)
160
+ _plant_peer "$R" x "$PEERPID"; git -C "$R" switch -q -c other
161
+ # 테스트 모드 없이 노브만 주면 → 세션ID 폴백이 달라 기록을 못 찾아 통과할 수 있으므로,
162
+ # 실제 세션ID를 고정해 두고 STALE/NOW 노브가 먹지 않는지만 본다
163
+ export CLAUDE_CODE_SESSION_ID=me
164
+ LANE_OUT=$(cd "$R" && FH_CLAIM_STALE_HOURS=0 FH_CLAIM_NOW=99999999999 bash "$SCRIPT" check 2>&1); rc=$?
165
+ _lane "⑧ FH_CLAIM_TEST 없이 STALE/NOW 노브 → 무시되고 차단 유지" 1 "$rc"
166
+ unset CLAUDE_CODE_SESSION_ID
167
+ rm -rf "$R"
168
+
169
+ # ── ⑨ 워크트리는 기록이 분리된다 ───────────────────────────────────────────
170
+ R=$(_mkrepo)
171
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim s >/dev/null 2>&1)
172
+ WT="$R-wt"; git -C "$R" worktree add -q -b wtb "$WT" >/dev/null 2>&1
173
+ if [ -d "$WT" ]; then
174
+ LANE_OUT=$(cd "$WT" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" check 2>&1); rc=$?
175
+ _lane "⑨ 워크트리는 기록 분리 → 통과" 0 "$rc"
176
+ git -C "$R" worktree remove --force "$WT" >/dev/null 2>&1
177
+ else echo " ⚠️ ⑨ 워크트리 생성 실패 — SKIPPED(통과로 세지 않음)"; fi
178
+ rm -rf "$R" "$WT"
179
+
180
+ # ── ⑩ 서브디렉터리에서도 같은 기록을 본다 (상대 .git 절대화) ───────────────
181
+ R=$(_mkrepo)
182
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim s >/dev/null 2>&1)
183
+ _plant_peer "$R" x "$PEERPID"; git -C "$R" switch -q -c other; mkdir -p "$R/deep/nested"
184
+ LANE_OUT=$(cd "$R/deep/nested" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" check 2>&1); rc=$?
185
+ _lane "⑩ 서브디렉터리에서도 차단" 1 "$rc"
186
+ _ok "⑩-b 기록이 저장소 .git 에만 생성됨" \
187
+ "$([ -d "$R/.git/fh-claims" ] && [ ! -d "$R/deep/nested/.git" ] && echo 1 || echo 0)"
188
+ rm -rf "$R"
189
+
190
+ # ── ⑪ release / reap / show 도 앵커가 있다 (B-3 무앵커 경로 수리) ──────────
191
+ R=$(_mkrepo)
192
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim s >/dev/null 2>&1)
193
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" release >/dev/null 2>&1)
194
+ _ok "⑪ release 가 실제로 기록을 지운다" "$([ ! -f "$R/.git/fh-claims/me" ] && echo 1 || echo 0)"
195
+ _plant_peer "$R" x 999999
196
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" reap >/dev/null 2>&1)
197
+ _ok "⑪-b reap 이 죽은 세션 기록을 지운다" "$([ ! -f "$R/.git/fh-claims/session-PEER" ] && echo 1 || echo 0)"
198
+ _plant_peer "$R" zzz "$PEERPID"
199
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" show 2>&1); rc=$?
200
+ _lane "⑪-c show 가 HEAD·세션·peer 를 낸다" 0 "$rc" "HEAD" "살아있는 peer claim: 1" "zzz"
201
+ rm -rf "$R"
202
+
203
+ # ── ⑫ 복구 명령의 브랜치명이 shell-quote 된다 (cross-family B-6) ──────────
204
+ # git ref 는 공백은 못 쓰지만 `;` `$` 는 허용한다. 복붙 시 주입형 사고.
205
+ R=$(_mkrepo); B='a;touch$IFS/tmp/BCLAIM_PWNED'
206
+ git -C "$R" branch "$B" >/dev/null 2>&1 && git -C "$R" switch -q "$B"
207
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim s >/dev/null 2>&1)
208
+ _plant_peer "$R" x "$PEERPID"; git -C "$R" switch -q main
209
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" check 2>&1); rc=$?
210
+ EXPECT="git switch -- '${B}'"
211
+ _lane "⑫ metachar 브랜치명이 인용된 채 출력된다" 1 "$rc" "$EXPECT"
212
+ rm -rf "$R"
213
+
214
+ # ── ⑬ --if-absent: 없으면 만들고, 있으면 안 덮는다 (세션시작 훅이 부르는 형태) ──
215
+ # 훅 안에 같은 로직을 복제하면 앵커가 안 걸리므로 스크립트에 두고 여기서 잡는다.
216
+ R=$(_mkrepo)
217
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim --if-absent s 2>&1); rc=$?
218
+ _ok "⑬ --if-absent: 기록 없으면 생성" \
219
+ "$([ "$rc" = 0 ] && [ -f "$R/.git/fh-claims/me" ] && echo 1 || echo 0)"
220
+ git -C "$R" switch -q -c moved # 브랜치가 옮겨진 뒤 다시 불러도
221
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim --if-absent s 2>&1); rc=$?
222
+ kept=$(sed -n 's/^branch=//p' "$R/.git/fh-claims/me")
223
+ _lane "⑬-b --if-absent: 기록 있으면 덮지 않는다" 0 "$rc" "이미 있다"
224
+ _ok "⑬-c 기존 값 보존 (main 이어야 함 · 지금=$kept)" "$([ "$kept" = main ] && echo 1 || echo 0)"
225
+ # ★ 이게 중요한 이유: 덮으면 「내가 믿는 브랜치」가 매 세션시작마다 HEAD 로 갱신돼
226
+ # 게이트가 영원히 통과한다 — 자동화가 게이트를 무장해제하는 경로다
227
+ rm -rf "$R"
228
+
229
+ echo
230
+ echo "[branch-claim] PASS=$PASS FAIL=$FAIL"
231
+ [ "$FAIL" -eq 0 ] || exit 1
@@ -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