@chrono-meta/fh-gate 2.4.0 → 2.5.1

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.
@@ -10,6 +10,66 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
10
10
 
11
11
  ## Plugin Level
12
12
 
13
+ ### [2.5.1] — 2026-08-19
14
+
15
+ **진입점 패리티 — 규칙이 출하돼야 발화한다.** 2.5.0 직후 `④-b` 드리프트 검사가 **AGENTS.md 에 공유 체크아웃 규율이 없다**를 냈다. CLAUDE.md 에는 있고 Codex 진입점에는 없는 상태였고, 그 상태에서는 비-Claude 런타임에 그 규칙이 **보이지 않는다**(gate-locality). 이 릴리스는 그 한 항목을 소비자에게 실제로 보내기 위한 것이다.
16
+
17
+ - **`AGENTS.md` 항목 9 — 공유 체크아웃**: 스위치 전 `git branch --show-current` · 자른 직후 `git log main..HEAD` · **claim 파일은 lock 이 아니라 스냅샷**(남이 브랜치를 옮겨도 안 바뀐다) · 공유 체크아웃에서 `git add -A`/`git stash` 는 트리 전체에 닿는다. 🟥 이 레포에서 두 세션이 오늘 실제로 그걸로 부딪혔다. 기계층(`pre-commit` 의 `branch_claim` 게이트)은 **커밋 시점**이라 그 앞의 `git switch` 사고는 못 막는다
18
+ - 검증: 플로어 티어 블라인드 sim(무엇을 시험하는지 안 알림, reps=3) **arm 3/3 vs control 0/3**. 🟥 control 한 런이 죽어 있어(stdin 경고만) 재실행해 유효 3수를 채웠다 — 죽은 팔을 0 으로 계상하면 n 을 부풀린다
19
+ - 🟥 **잔여: 비-Claude 런타임에서 실제로 안 돌렸다.** sim 은 Claude 로 돌렸다 — 「따라와지는가」는 쟀고 「Codex 가 실제로 그렇게 하는가」는 못 쟀다
20
+
21
+ **함께 나가는 것 (데이터)**: `subagent_invocations_log.yaml` 3건 — 게시 전 보안 패스 · cross-family 패널 · **ⓓ 3자대면**. 마지막 것이 위성 설계에서 **미등록 redaction sink**(입력 무검사)와 **레포 밖 폭발반경**을 찾았고, 처방 5건은 **전부 미착수**다(`tracks/` 신호에 기록, 출하 대상 아님).
22
+
23
+ **BREAKING 없음.** 문서·데이터만이고 게이트 수용은 안 바뀐다.
24
+
25
+ ### [2.5.0] — 2026-08-18
26
+
27
+ **정체성 ④(프런티어→조직 전파)의 «조직 = 레포» 축.** 다이제스트 러너가 FH 한 곳만 대상으로 지어져 있어 ④ 가 «자기 소비» 에 머물렀다. 대상 축을 열고, 그 산출이 **복제가 아니라 응용**이 되도록 프로필을 싣고, 공개 표면과 착지를 각각 기계로 잰다.
28
+
29
+ **위성 — `scripts/frontier_digest_daily.sh`**
30
+ - `FD_OUT_DIR`(대상 레포의 어디에) · `FD_MODEL`(위성별 티어 핀)
31
+ - `FD_PROFILE` — 대상 하네스가 **자기를 설명하는 파일**을 프롬프트에 싣는다. 🟥 없으면 산출은 **복제**다(실측: 대상 어휘 0 · FH 어휘 11 · Route 0). 있으면 **응용**(실측 2대상: 대상 어휘 0→33 · **Route 0→19**). **변인은 프로필 하나였다**
32
+ - `publish_gate()` — 공개 레포 대상이면 **매 런이 publish** 다(비가역). `0 게시 / 1 격리 / 그 외 보류` — 🟥 **세 값을 두 값으로 접지 않는다**
33
+ - `landing_witness()` — 직전 다이제스트 후보의 착지를 매일 기록. 차단하지 않는다(선별기). 프로덕션 호출부 **0 → 3**
34
+ - 앵커: `scripts/test_satellite_publish_gate_lanes.sh` (초판 **11 레인**, 아래 후속 수리로 **22 레인**), 되돌림 다중 arm
35
+
36
+ **착지 검증기 파서 — `scripts/digest_landing_check.sh`**
37
+ - 후보를 **표로만** 읽고 있었다. 실측 60건 중 표는 **4건(6.7%)**. 문단·헤딩·번호 리스트를 더해 **후보 절이 있는 56건 전부** 추출. 판별자 추출은 두 경로가 **공유**(중복 정규화기 방지)
38
+ - 🟥 이 결함은 **호출부가 0 이라 가려져 있었다.** 배선해서 처음 돌린 그 실행이 뱉었다 ⇒ **self-test 만 도는 스크립트는 실물과 어긋나 있어도 초록이다**
39
+
40
+ **규율 — `CLAUDE.md`**
41
+ - **§Expedition**: 원정 개시에 **«과녁 정체성» 한 줄** 의무. 근거는 1차 ⓐ 채점 **0건**이고 원인이 노력이 아니라 **겨냥**이었다(세 갈래가 전부 이미 🟢 인 정체성 위에 떨어졌다). 주기는 2차 이후로 연기, **계측 3항을 방법까지** 명시(거버너 토큰은 `UNMEASURED` 를 이름으로 남기고 **합계 금지**)
42
+ - **§AI Contribution Model**: 기본 워크플로에 **claim** 단계 + `scripts/branch_claim.sh` 를 이름으로. 기계는 이미 있었는데 산문이 그 이름을 안 불러 두 세션이 «없다»로 결론했다
43
+
44
+ **게이트 (peer 동시 착지분 #447·#448)**
45
+ ```
46
+ BREAKING (gate): edit_manifest.yaml 에 `date` 만 있는 엔트리가 있으면 커밋이 차단된다
47
+ — 동시 append 로 손상된 매니페스트의 지문이다(종전엔 무음 통과).
48
+ remedy: 훅이 지목한 엔트리를 손으로 확인해 필드를 복구하거나 제거한다.
49
+ ```
50
+ 실측: 이 레포 매니페스트 411건 중 date-only **0건** — **건강한 매니페스트를 가진 소비자는 안 막힌다.** 막히는 것은 동시 append 로 손상된 경우이고, 그 손상은 종전에 **조용히 통과**했다.
51
+
52
+ **동작 변경(차단 아님)**: 워크트리에서 FH 자산 커밋이 **가능해진다**(evidence-root 를 `--git-common-dir` 로 해석). 종전엔 `tracks/` 가 안 따라와 Axis 2+3·Axis 4 가 구조적으로 부재했다 — 즉 이 항목은 **완화**다.
53
+ ⚠️ **위 둘은 같은 사건의 앞뒤다.** 경로 변경으로 워크트리 소비자에게 게이트가 **처음 도달**하는데, 그 순간 `core.hooksPath` 가 **상대 경로**면 워크트리가 **자기 훅 사본**을 돌아 자기무력화가 가능하다.
54
+ 권고: `git config core.hooksPath "$(git rev-parse --show-toplevel)/templates/.git-hooks"`
55
+
56
+ **같은 날 후속 수리 — 위 배선이 만든 결함 5 + 타계열 3 (#452)**
57
+ 🟥 위 항목들을 「배선 완료」로 보고한 뒤 **되돌림 프로브·첫 실사용·타계열 패널**이 다음을 찾았다.
58
+ - **착지 증언이 배선 첫날부터 죽어 있었다** — 러너가 checker 에 **디렉터리를 «타깃 파일»로** 넘겨 매 런 `rc=10`. 손으로 잰 형태(1인자)와 러너가 부르는 형태(2인자)가 달랐다. 컨트롤 토큰도 하드코딩이라 대상 레포에선 고쳐도 죽은 채였다 ⇒ 스코프·컨트롤을 `DLC_*` 로 외부화, 유도 기본값은 **리터럴**(레포명에 정규식 메타문자가 있으면 «아무거나 매치»했다)
59
+ - **「직전 다이제스트」를 로케일 `sort` 로 골라 두 달 전 파일을 집었다** — 실물 코퍼스에 `2026-06-02`(대시)와 `2026_08_17`(언더바)가 섞여 있다 ⇒ `LC_ALL=C`. 🟥 **이건 레인이 아니라 첫 실사용이 잡았다**(그 시점 레인 21개는 전부 초록)
60
+ - **`publish_gate` 가 `head -1` 로 한 파일만 스캔**했고, 선택자가 `digest_ready` 와 갈려 있었다 ⇒ 단일 선택자 + 전 파일 순회 + 최악값. 선택자 자기모순은 **게시가 아니라 보류(3)**
61
+ - **공개 대상 트리 «안»에 로그를 팠고 findings 가 토큰 원문을 찍었다** ⇒ 트리 밖 + 리댁션 3단(인용 스팬·row 꼬리·경로). ⚠️ **이 러너는 출하 대상이 아니므로 npm 소비자에겐 해당 없다** — 러너를 복사해 쓰는 위성에서만 참이다
62
+ - **`pre-commit` 이 gitignored 패턴 오버라이드를 `REPO_ROOT` 에서만 찾았다** ⇒ `EVIDENCE_ROOT` 우선 + 폴백. **이건 출하된다.** 종전엔 워크트리 커밋이 매번 defaults-only 로 돌아 회사명·실명 클래스가 UNSCANNED 였고, 게다가 비차단 경고라 조용했다. ⓑ #448 의 반쪽-픽스였다
63
+ - **훅 주석 정정**: «절대 hooksPath 면 자기무력화가 **구조적으로 막힌다**» 는 과대주장 — 훅이 `$REPO_ROOT/scripts/*` 를 source 하므로 «한 겹 좁아진다» 가 정확하다
64
+ - 🟥 **셸 에러가 착지율 시계열에 «실측값»으로 들어갔다** (첫 실사용 발견). `set -u` + bash 3.2 에서 **빈 배열의 `${a[@]}` 는 unbound 에러**다. 대상 레포에 digest 이후 커밋이 없으면 타깃이 0건이 되고, 그러면 필터 루프가 터져 스크립트가 **판정을 인쇄하지 못한 채 rc=1** 로 끝난다 — 러너는 그 1 을 「SOME-UNLANDED」로 적는다. 올바른 값은 **rc=10(타깃 0건 = HARNESS-ERROR)**. 실측: forge-wiki 첫 런 로그가 정확히 그 형태였고 본문이 비어 있었다
65
+ - 🟥 **엔진 루트 ≠ 대상 루트** (첫 실사용 발견). 위성은 이 러너를 복사하지 않고 FH 의 것을 그대로 도는데(launchd 가 FH 경로를 가리킨다), 초판은 스캐너·패턴·checker 를 전부 `$FH_DIR`(=**대상 레포**)에서 찾았다 ⇒ 대상에 `scripts/psa_scan_lib.sh` 가 없으니 **publish gate 가 영영 fail-closed**. 게다가 그 분기는 파일을 **격리하지 않아** 「게시 안 함」이 **거짓**이었다(digest 는 이미 트리에 쓰여 있다). ⇒ 도구는 `ENGINE_DIR`, 대상만 env. 부재 분기도 실제 격리
66
+ - 🟥 **게시 직전 보안 패스가 신설 레인 2개에서 실 결함을 냈다**(MED·재현됨): 고정 `/tmp` 경로(`/tmp/.r4out` · `/tmp/.lw_*` · `/tmp/.lg_*`)라 공유 `/tmp` 에서 **심볼릭 링크 선점으로 임의 파일이 덮인다**(CWE-377). 이 스위트들은 **출하되고** `selfcheck.sh` 에 배선돼 있어 소비자가 셀프체크만 돌려도 발동했다 ⇒ `mktemp` + `trap` 정리. 재현으로 확인: 링크를 걸고 돌려도 피해 파일이 그대로다
67
+ - 앵커: 위성 **25 레인** · 착지 self-test **13 레인** · **`test_evidence_root_psa_lanes.sh` 신설 5 레인**(이 경로엔 앵커가 0개였다) · 착지 self-test 12. 되돌림 **8/8 이 각각 그 레인만** 적색
68
+
69
+ **호환성 — 위성 축은 BREAKING 아님.** 미설정 시 **종전 동작 그대로**이고(`N1`/`N3` 컨트롤이 그것을 주장), FH 자신의 일일 런은 무변경이다. ⚠️ `frontier_digest_daily.sh` 는 **출하 대상이 아니다**(종전 동일 — 출하되는 것은 `frontier_digest_autopilot.sh`).
70
+
71
+ **위성 주기 (2026-08-18 신설)**: `launchd` 2대 등록 — `com.forge-harness.satellite-forge-wiki`(10:00) · `satellite-the-bible`(11:00). FH 자신(09:00)과 시차를 둔다. 🟥 **엔진은 FH 에 있고 대상만 환경변수로 바뀐다** — 러너를 대상 레포에 복사하지 않는다(그게 복제다).
72
+
13
73
  ### [2.4.0] — 2026-08-18
14
74
 
15
75
  🟥 **BREAKING (gate) ①**: the Axes 2–3 marker's **`standpoint:` and `thirdparty:` fields are now
@@ -54,6 +54,26 @@ set -f
54
54
 
55
55
  RC_OK=0; RC_VIOLATION=1; RC_UNVERIFIABLE=3; RC_HARNESS=10
56
56
 
57
+ # 🟥 **계기 전제 프리플라이트** (2026-08-18, 넓은-질문 탈상관 팔이 known-pair 로 재현).
58
+ # `_snapshot` 의 파일 축은 `… | xargs -0 shasum -a 256 2>/dev/null` 이다. `shasum` 이 없거나
59
+ # 죽으면 before/after 가 **둘 다 빈 문자열**이 되어 `changed=0` → **`✅ VERIFIED · 관측=no-write`**.
60
+ # 재현(같은 capfile, PATH 만 다름): 정상 `🟥 VIOLATION rc=1` / shasum=127 `✅ VERIFIED rc=0`.
61
+ # ⇒ 이 파일이 헤더에서 겨눈 「미측정을 0 으로 렌더」의 자기 판본이었고, `UNVERIFIABLE` 조차
62
+ # 아니라 **VERIFIED** 였다. 계기가 죽으면 판정을 내지 않는다.
63
+ # ★`2>/dev/null` 은 남긴다 — 개별 파일 권한 오류로 전체가 죽으면 그게 더 나쁘다.
64
+ # 막는 지점은 **전제**(도구 부재)이지 개별 항목 실패가 아니다.
65
+ # ⚠️ **폴백이 필요하다**(cross-family 지목 #3): `shasum` 은 macOS/perl 계열 관행이고
66
+ # Linux/BusyBox 소비자는 `sha256sum` 만 있는 경우가 흔하다. 이 파일은 `package.json
67
+ # files[]` 로 **출하되므로** shasum 강제는 과차단이다.
68
+ HASHER=""
69
+ if command -v shasum >/dev/null 2>&1; then HASHER="shasum -a 256"
70
+ elif command -v sha256sum >/dev/null 2>&1; then HASHER="sha256sum"
71
+ else
72
+ echo "❌ HARNESS_ERROR — \`shasum\` 도 \`sha256sum\` 도 PATH 에 없다." >&2
73
+ echo " 스냅샷의 해시 축이 죽어 거짓 \`VERIFIED\` 가 난다. 판정하지 않고 중단한다." >&2
74
+ exit "$RC_HARNESS"
75
+ fi
76
+
57
77
  # writes 축 순서 (좁음 → 넓음). 관측이 선언보다 넓으면 VIOLATION.
58
78
  ORDER_writes="read-only write-local write-remote"
59
79
 
@@ -82,7 +102,8 @@ GIT_META_WATCH=""
82
102
  # 아니라 **파일당 프로세스 스폰**이었다. `xargs` 배치로 한 번에 넘기면 같은 대상을 재면서
83
103
  # 비용이 떨어진다 — 정확도를 깎아 비용을 맞추려던 게 잘못된 트레이드였다.
84
104
 
85
- _snapshot() { # $1=dir → "sha path" 목록 (정렬)
105
+ _snapshot() { # $1=dir → "file <path>" + "sha path" + dir/기타 축 (정렬)
106
+ local _flist; _flist=$(mktemp "${TMPDIR:-/tmp}/fh_flist.XXXXXX")
86
107
  {
87
108
  # `.git` 은 통째로 제외한다 — `git status` 류가 `.git/index` 를 정당하게 갱신하므로
88
109
  # 전체를 재면 읽기 전용 capability 가 거짓 VIOLATION 을 받는다. 대신 아래에서
@@ -101,10 +122,19 @@ _snapshot() { # $1=dir → "sha path" 목록 (정렬)
101
122
  case "$w" in "$1"/*|"$1") ;; *) continue ;; esac
102
123
  find "$w" -type f 2>/dev/null
103
124
  done
104
- } | LC_ALL=C sort -u \
105
- | tr '\n' '\0' \
106
- | xargs -0 -n 400 shasum -a 256 2>/dev/null \
125
+ } | LC_ALL=C sort -u > "$_flist"
126
+ # 🟥 **이름 목록 축 — 해시와 별도로 낸다** (2026-08-18, cross-family 지목 #1).
127
+ # 해시 축만 두면 «해시가 실패한 파일»과 «없는 파일»이 **구분 불가**다. 실측 반례 둘:
128
+ # ⓐ `shasum` 이 존재하지만 exit 1 인 shim → 해시 0줄 → changed=0 → ✅ VERIFIED
129
+ # ⓑ **가짜 도구 없이도** `touch f && chmod 000 f` → 그 파일 해시만 실패 →
130
+ # `2>/dev/null` 이 삼킴 → ✅ VERIFIED
131
+ # ⇒ 이름 목록은 **해시 도구와 무관하게** 생성/삭제를 잡는다. 두 축은 서로의 폴백이 아니라
132
+ # **다른 것을 잰다**(이름=존재 · 해시=내용). 둘 다 필요하다.
133
+ LC_ALL=C sed 's/^/file /' "$_flist"
134
+ tr '\n' '\0' < "$_flist" \
135
+ | xargs -0 -n 400 $HASHER 2>/dev/null \
107
136
  | LC_ALL=C sort
137
+ rm -f "$_flist"
108
138
  # 🟥 **디렉토리 존재 축** (2026-08-17). 위 블록은 `-type f` 라 **파일만** 센다 — 빈
109
139
  # 디렉토리는 관측면에 아예 없었고, `mkdir -p uploads outputs tmp` 만 하는 capability 가
110
140
  # `writes: read-only` 선언으로 **✅ VERIFIED** 를 받았다(known-pair 3-arm 실측).
@@ -285,6 +315,12 @@ probe_one() { # $1=capfile → rc
285
315
 
286
316
  local before after out rc
287
317
  before=$(_snapshot "$sandbox")
318
+ # 🟥 스냅샷은 **절대 비지 않는다** — 디렉토리 축이 최소한 루트 자신을 찍기 때문이다.
319
+ # 비었다면 `find` 조차 실패한 것이고, 그건 관측이 아니라 계기 사망이다.
320
+ if [ -z "$before" ]; then
321
+ echo " ❌ HARNESS_ERROR — 사전 스냅샷이 비었다($sandbox). 관측면이 죽었다." >&2
322
+ return "$RC_HARNESS"
323
+ fi
288
324
  local out_before; out_before=$(_snapshot "$outside")
289
325
 
290
326
  # 실행 — **양 arm 을 같은 샌드박스 정책으로** 돌린다(2026-08-16 cross-family 지목).
@@ -787,6 +823,106 @@ EOF
787
823
  fails=$((fails+1)); echo "❌ L18 rc=$rc (1 기대) — 비정규 노드가 감시면 밖이다"
788
824
  fi
789
825
 
826
+ # ── L19 **집계가 위반을 «못 쟀음»으로 삼키지 않는다** ────────────────────────
827
+ # RC_VIOLATION=1 · RC_UNVERIFIABLE=3 이라 수치 max 로 접으면 exit 3 이 나왔다.
828
+ cat > "$d/agg_viol.cap" <<EOF
829
+ id: t:aggv
830
+ entry: bash -c 'touch aggregated_violation'
831
+ requires_cwd: $_rs
832
+ writes: read-only
833
+ EOF
834
+ out=$("$0" "$d/agg_viol.cap" "$d/nz_undeclared.cap" 2>&1); rc=$?
835
+ if [ "$rc" = "1" ] && printf '%s' "$out" | grep -q "nz_undeclared" \
836
+ && printf '%s' "$out" | grep -q "unverifiable=1"; then
837
+ pass=$((pass+1)); echo "✅ L19 위반+미측정 배치: exit=VIOLATION 이고 미측정이 요약에 남는다"
838
+ else
839
+ fails=$((fails+1)); echo "❌ L19 rc=$rc — 집계가 위반을 삼키거나 두 번째 capfile 을 안 쟀다"
840
+ fi
841
+ # L19b **순서 반전** — cross-family 지목 #4: 위반이 먼저면 short-circuit 퇴행도 초록이다.
842
+ out=$("$0" "$d/nz_undeclared.cap" "$d/agg_viol.cap" 2>&1); rc=$?
843
+ if [ "$rc" = "1" ]; then
844
+ pass=$((pass+1)); echo "✅ L19b 순서를 뒤집어도 VIOLATION 이 이긴다(미측정이 먼저여도)"
845
+ else
846
+ fails=$((fails+1)); echo "❌ L19b rc=$rc (1 기대) — 첫 rc 에 갇히거나 심각도가 뒤집힌다"
847
+ fi
848
+ # L19c 컨트롤 — 위반이 없으면 여전히 미측정(3)이다(과차단 방지).
849
+ out=$("$0" "$d/nz_undeclared.cap" 2>&1); rc=$?
850
+ if [ "$rc" = "3" ]; then
851
+ pass=$((pass+1)); echo "✅ L19c 컨트롤 — 위반 없는 배치는 여전히 UNVERIFIABLE"
852
+ else
853
+ fails=$((fails+1)); echo "❌ L19c rc=$rc (3 기대) — 심각도 매핑이 미측정을 잘못 접는다"
854
+ fi
855
+
856
+ # ── L20 계열 **계기가 죽으면 판정하지 않는다 · 살아 있으면 과차단하지 않는다** ──────
857
+ # 🟥 초판 레인은 두 번 틀렸다(둘 다 자력 적발 0):
858
+ # ⓐ `PATH=/nonexistent` 로 재서 **capfile 파싱 실패**를 프리플라이트로 오귀속 — 되돌림
859
+ # 3중 프로브가 적발(수리를 되돌려도 초록이었다)
860
+ # ⓑ shim 빌더가 `set -f`(전역 noglob) 때문에 확장이 안 됨 — **캘리브레이션 짝**이 적발
861
+ # 여기서는 도구를 **하나씩** 빼고, 매 arm 에 기대값을 다르게 건다.
862
+ # ★PATH 원소 분해는 공백/글롭 안전하게(`tr` → 줄 단위). cross-family 지목 #7.
863
+ _mkshim() { # $1=shim경로 $2..=제외할 도구명
864
+ local shimd="$1"; shift
865
+ mkdir -p "$shimd"
866
+ printf '%s\n' "$PATH" | tr ':' '\n' > "$d/_pathdirs"
867
+ local _p _f _b _x _skip
868
+ set +f
869
+ while IFS= read -r _p; do
870
+ [ -n "$_p" ] && [ -d "$_p" ] || continue
871
+ for _f in "$_p"/*; do
872
+ [ -e "$_f" ] || continue
873
+ _b=${_f##*/}; _skip=0
874
+ for _x in "$@"; do [ "$_b" = "$_x" ] && { _skip=1; break; }; done
875
+ [ "$_skip" = 1 ] && continue
876
+ [ -e "$shimd/$_b" ] || ln -s "$_f" "$shimd/$_b" 2>/dev/null
877
+ done
878
+ done < "$d/_pathdirs"
879
+ set -f
880
+ }
881
+
882
+ # L20 해시 도구가 **둘 다** 없으면 판정하지 않는다.
883
+ _mkshim "$d/bin_none" shasum sha256sum
884
+ out=$(PATH="$d/bin_none" "${BASH:-/bin/bash}" "$0" "$d/agg_viol.cap" 2>&1); rc=$?
885
+ if [ "$rc" = "10" ] && printf '%s' "$out" | grep -q "sha256sum"; then
886
+ pass=$((pass+1)); echo "✅ L20 해시 도구 전무는 HARNESS_ERROR (이유도 찍힌다)"
887
+ else
888
+ fails=$((fails+1)); echo "❌ L20 rc=$rc (10 기대) — 계기 사망이 통과/판정으로 렌더된다"
889
+ fi
890
+ # L20b **과차단 방지** — `shasum` 만 없고 `sha256sum` 이 있으면 정상 판정해야 한다.
891
+ # cross-family 지목 #3: 이 파일은 files[] 로 출하되고 Linux 소비자는 sha256sum 만 갖는다.
892
+ if command -v sha256sum >/dev/null 2>&1; then
893
+ _mkshim "$d/bin_s256" shasum
894
+ out=$(PATH="$d/bin_s256" "${BASH:-/bin/bash}" "$0" "$d/agg_viol.cap" 2>&1); rc=$?
895
+ if [ "$rc" = "1" ]; then
896
+ pass=$((pass+1)); echo "✅ L20b shasum 부재+sha256sum 존재 → 폴백이 정상 판정(과차단 없음)"
897
+ else
898
+ fails=$((fails+1)); echo "❌ L20b rc=$rc (1 기대) — 폴백이 없거나 과차단한다"
899
+ fi
900
+ else
901
+ echo "⏭ L20b SKIP — 이 머신에 sha256sum 이 없다(미측정, 통과 아님)"
902
+ fi
903
+ # L20c **«있는데 고장난» 해시 도구** — cross-family 지목 #1. 프리플라이트는 통과한다.
904
+ _mkshim "$d/bin_broken" shasum sha256sum
905
+ printf '#!/bin/sh\nexit 1\n' > "$d/bin_broken/shasum"; chmod +x "$d/bin_broken/shasum"
906
+ out=$(PATH="$d/bin_broken" "${BASH:-/bin/bash}" "$0" "$d/agg_viol.cap" 2>&1); rc=$?
907
+ if [ "$rc" = "1" ]; then
908
+ pass=$((pass+1)); echo "✅ L20c 해시 도구가 고장나도 파일 생성은 잡힌다(이름 목록 축)"
909
+ else
910
+ fails=$((fails+1)); echo "❌ L20c rc=$rc (1 기대) — 고장난 계기가 거짓 VERIFIED 를 낸다"
911
+ fi
912
+ # L20d **읽을 수 없는 파일** — 가짜 도구 없이 재현되는 형태(cross-family 지목 #1 두 번째).
913
+ cat > "$d/unreadable.cap" <<EOF
914
+ id: t:unread
915
+ entry: bash -c 'touch hidden_regular && chmod 000 hidden_regular'
916
+ requires_cwd: $_rs
917
+ writes: read-only
918
+ EOF
919
+ out=$(probe_one "$d/unreadable.cap" 2>&1); rc=$?
920
+ if [ "$rc" = "1" ]; then
921
+ pass=$((pass+1)); echo "✅ L20d chmod 000 파일 생성도 위반이다(해시 실패가 삼키지 않는다)"
922
+ else
923
+ fails=$((fails+1)); echo "❌ L20d rc=$rc (1 기대) — 해시 실패가 파일 생성을 삼킨다"
924
+ fi
925
+
790
926
  rm -rf "$d"
791
927
  echo "── capability_effect_probe lanes: $pass PASS / $fails FAIL ──"
792
928
  [ "$fails" = "0" ]
@@ -797,9 +933,41 @@ case "${1:-}" in
797
933
  "") echo "usage: $0 <capfile> [...] | --self-test" >&2; exit "$RC_HARNESS";;
798
934
  esac
799
935
 
936
+ # 🟥 **집계는 수치 max 가 아니라 심각도 max 다** (2026-08-18, 넓은-질문 탈상관 팔 지목).
937
+ # `RC_VIOLATION=1` · `RC_UNVERIFIABLE=3` 이라 수치 `-gt` 로 접으면
938
+ # **실제 위반(1) + 못 쟀음(3) → exit 3** 이 된다. 헤더가 3 을 「판정 아님」으로 정의해 놨으니
939
+ # `rc==1` 로 위반을 라우팅하는 상위 게이트는 **조용히 놓치고** 재시도/유예 경로를 탄다.
940
+ # ⇒ 수치 순서와 심각도 순서가 어긋나 있었다. 여기서 명시적으로 매핑한다.
941
+ # ★미지의 코드는 **가장 심각**으로 접는다(fail-closed) — 새 rc 가 생겼을 때 조용히
942
+ # 통과하는 쪽으로 기울지 않게.
943
+ _sev() { # 종료코드 → 심각도(클수록 심각). 수치 크기 ≠ 심각도.
944
+ case "$1" in
945
+ "$RC_OK") printf '0' ;;
946
+ "$RC_UNVERIFIABLE") printf '1' ;;
947
+ "$RC_VIOLATION") printf '2' ;;
948
+ "$RC_HARNESS") printf '3' ;;
949
+ *) printf '3' ;;
950
+ esac
951
+ }
952
+
953
+ # 🟥 **배치는 «가장 심각한 것» 하나만 종료코드로 낼 수 있다** — 그래서 접히는 정보가 생긴다.
954
+ # cross-family 지목 #2: 심각도 매핑을 넣은 뒤 「위반 + 미측정」 배치가 `1` 로 나가므로,
955
+ # `rc==3` 으로 «미측정 있음» 을 라우팅하던 소비자는 **미측정 capfile 을 잃는다.**
956
+ # ⇒ 종료코드는 심각도로 두고, **접힌 정보는 요약 줄로 표면화**한다(삼키지 않는다).
957
+ # 한 줄이면 상위가 grep 으로 집을 수 있고, 종료코드 계약은 하나로 유지된다.
800
958
  worst=0
959
+ n_viol=0; n_unver=0; n_harn=0; n_ok=0
801
960
  for c in "$@"; do
802
961
  probe_one "$c"; rc=$?
803
- [ "$rc" -gt "$worst" ] && worst=$rc
962
+ case "$rc" in
963
+ "$RC_OK") n_ok=$((n_ok+1)) ;;
964
+ "$RC_VIOLATION") n_viol=$((n_viol+1)) ;;
965
+ "$RC_UNVERIFIABLE") n_unver=$((n_unver+1)) ;;
966
+ *) n_harn=$((n_harn+1)) ;;
967
+ esac
968
+ [ "$(_sev "$rc")" -gt "$(_sev "$worst")" ] && worst=$rc
804
969
  done
970
+ if [ "$#" -gt 1 ]; then
971
+ echo "── BATCH SUMMARY: ok=$n_ok violation=$n_viol unverifiable=$n_unver harness=$n_harn (exit=$worst)"
972
+ fi
805
973
  exit "$worst"
@@ -85,13 +85,77 @@ FH="${CLAUDE_PROJECT_DIR:-$(cd "$SELF_DIR/.." && pwd)}"
85
85
  # ⚠️ 검증기는 **도구**이고 FH 는 **대상**이다 — 두 축을 같은 변수로 묶으면 격리 픽스처에서
86
86
  # 도구까지 임시 트리에서 찾다가 죽는다(실측). 도구는 언제나 이 스크립트 옆에 있다.
87
87
  CHECKER="$SELF_DIR/utterance_landing_check.sh"
88
+
89
+ # ── 착지 스코프 (2026-08-18, R1) ────────────────────────────────────────────
90
+ # 기본 스코프는 **FH 자신의 문법**이다(`knowledge` · `CLAUDE.md` · gitignored `tracks/_meta`).
91
+ # 위성이 겨눈 대상 하네스에는 그 셋이 없어서 타깃이 0건 → rc=10 이 매 런 찍힌다. 그건 정직한
92
+ # 값이지만(미측정≠0), 계기가 **구조적으로 죽은 채** 배선된 것과 같다.
93
+ # 🟥 스코프를 «러너가 손으로 목록을 만들어 넘기는» 식으로 풀지 않는다 — 그러면 선후 필터가
94
+ # 두 벌이 되고 관대함이 갈리는 순간 한쪽만 통과하는 입력이 무음 드롭된다
95
+ # ([[feedback_divergent_leniency_duplicate_normalizers]]). 필터는 여기 한 곳에 두고
96
+ # **무엇을 볼지만** 밖에서 정한다.
97
+ # 기본값은 종전 그대로 — FH 경로 무변경.
98
+ # ⚠️ **명명된 잔여 (cross-family/codex F4)**: 이 값은 **공백 분리**된 다중 pathspec 이다.
99
+ # 따라서 **경로에 공백이 있으면 갈린다** — `docs public` 은 «한 경로»가 아니라 두 경로가 된다.
100
+ # 다중 경로 지원과의 트레이드오프라 오늘은 **문서화만** 했고 기계 검사는 없다.
101
+ DLC_TRACKED_PATHS="${DLC_TRACKED_PATHS:-knowledge CLAUDE.md}" # git 추적축 (커밋시각)
102
+ DLC_IGNORED_DIR="${DLC_IGNORED_DIR-tracks/_meta}" # gitignored 축 (mtime). 빈 값 = 그 축 없음
88
103
  SECTION_RE='^## .*Immediate Application Candidates'
89
104
 
90
105
  # 후보 표에서 (id, 판별토큰 OR 정규식) 을 뽑는다. 토큰 없으면 id 만 내고 정규식은 빈 값.
91
106
  _extract() { # $1 = digest path
92
107
  awk -v sec="$SECTION_RE" '
108
+ # 판별자 추출 — 표/문단 **두 경로가 공유**한다(중복 정규화기 방지).
109
+ function harvest(body, toks, tmp, w, t) {
110
+ tmp=body
111
+ while (match(tmp, /\[\[[^]]+\]\]/)) {
112
+ w=substr(tmp, RSTART+2, RLENGTH-4); tmp=substr(tmp, RSTART+RLENGTH)
113
+ if (length(w) >= 5) toks = (toks == "" ? w : toks "|" w)
114
+ }
115
+ tmp=body
116
+ while (match(tmp, /`[^`]+`/)) {
117
+ t=substr(tmp, RSTART+1, RLENGTH-2); tmp=substr(tmp, RSTART+RLENGTH)
118
+ sub(/[: ].*$/, "", t)
119
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", t)
120
+ if (length(t) < 5) continue
121
+ if (t ~ /[][(){}.*+?^$\\|]/) { if (t ~ /^[A-Za-z0-9_.-]+$/) { } else continue }
122
+ gsub(/\./, "\\.", t)
123
+ toks = (toks == "" ? t : toks "|" t)
124
+ }
125
+ return toks
126
+ }
93
127
  $0 ~ sec { inblk=1; next }
94
- inblk && /^## / { inblk=0 }
128
+ inblk && /^## / { if (pid != "") { print pid "\t" ptoks; pid="" } inblk=0; inpara=0 }
129
+ # ── 문단 형식 (2026-08-18 신설) ───────────────────────────────────────
130
+ # 🟥 이 계기는 후보를 **표**로만 읽었다. 실측: 다이제스트 60건 중 표 형식은 **4건**,
131
+ # 나머지는 `**① …**` `**[A] …**` `**1. …**` `**#1 …**` 같은 **문단 형식**이다.
132
+ # 즉 계기가 실물의 6.7% 만 읽을 수 있었고, 그래서 첫 실측이 rc=10(«후보 절을 못 찾았다»)
133
+ # 을 냈다. 안 드러난 이유는 **프로덕션 호출부가 0** 이었기 때문 — 아무도 안 돌렸다.
134
+ # ⚠️ 머리 표기는 여러 종이라 한 모양에 맞추지 않는다(실측: ① · [A] · 1. · #1 · 그 외).
135
+ # **`**` 로 열리는 줄**을 항목 시작으로 보고, 다음 항목/절까지를 본문으로 모은다.
136
+ # 🟥 판별자 추출은 **표 경로와 같은 코드**를 쓴다 — 두 벌로 갈리면 관대함이 어긋나
137
+ # 한쪽만 통과하는 입력이 무음 드롭된다(divergent leniency).
138
+ # 항목 머리 **네** 형식을 다 받는다. 실측(60건 후보 절 전수, 하나씩 열어서 확인):
139
+ # `**…**` 74 · 표 `|` 28 · `### [M] 1.` 22 · **번호 리스트 `1. **…**`**(초기 다이제스트의 주 형식)
140
+ # 🟥 초판은 `**` 만 받았고, 표가 섞인 절에서 **표 앞의 `**` 줄을 먼저 삼켜** 표 행을
141
+ # UNCHECKABLE 로 떨어뜨렸다(회귀 실측). 그래서 **표 행은 항상 표 경로가 먼저 가져간다**.
142
+ inblk && (/^###+ / || /^\*\*/ || /^[0-9]+\. / || /^- \*\*/) {
143
+ if (pid != "") { print pid "\t" ptoks }
144
+ pid=$0; ptoks=""
145
+ sub(/^###+ /, "", pid); sub(/^[0-9]+\. /, "", pid); sub(/^- /, "", pid)
146
+ sub(/^\*\*/, "", pid); sub(/\*\*.*$/, "", pid)
147
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "", pid)
148
+ # 🟥 substr 로 자르지 않는다 — awk 는 로케일에 따라 **바이트** 단위라 한글/기호를
149
+ # 중간에서 쪼개고 그 출력이 UTF-8 로 안 읽힌다(실측: 집계 스크립트가 UnicodeDecodeError).
150
+ # id 는 표시용이므로 자르지 말고 **공백 기준 앞 6낱말**만 쓴다.
151
+ if (split(pid, _w, /[[:space:]]+/) > 6) {
152
+ pid=""; for (_i=1; _i<=6; _i++) pid = pid (_i>1 ? " " : "") _w[_i]
153
+ }
154
+ ptoks=harvest($0, ptoks)
155
+ inpara=1; next
156
+ }
157
+ # 표 행은 아래 표 경로가 처리한다 — 여기서 안 먹는다(우선순위 명시).
158
+ inblk && inpara && !/^\|/ && !/^###+ / && !/^[0-9]+\. / && !/^- \*\*/ { ptoks=harvest($0, ptoks); next }
95
159
  inblk && /^\|/ {
96
160
  line=$0
97
161
  # 헤더/구분선 제외
@@ -135,6 +199,7 @@ _extract() { # $1 = digest path
135
199
  }
136
200
  print id "\t" toks
137
201
  }
202
+ END { if (pid != "") print pid "\t" ptoks }
138
203
  ' "$1"
139
204
  }
140
205
 
@@ -143,8 +208,30 @@ do_extract() { # $1 = digest
143
208
  [ -f "$d" ] || { echo "❌ digest 없음: $d" >&2; return 10; }
144
209
  # 컨트롤 — 이 계기가 살아 있는지 증명한다. 대상 트리에 확실히 존재하는 토큰.
145
210
  # 컨트롤이 죽으면 착지 검증기가 rc=10 을 내고 타깃 결과를 인쇄하지 않는다.
146
- printf 'CONTROL\tforge-harness\t컨트롤: 레포명\n'
147
- printf 'CONTROL\tsession\t컨트롤: 흔한 토큰\n'
211
+ # 🟥 R1-b (2026-08-18) — 컨트롤이 `forge-harness`/`session` 으로 **하드코딩**이었다.
212
+ # 위성이 겨눈 레포에는 그 두 토큰이 없으므로 **컨트롤이 매번 죽고** 계기는 rc=10 만 낸다.
213
+ # R1 의 인자 버그를 고쳐도 이게 남아 있으면 계기는 여전히 구조적으로 죽은 채다 — 그 사실을
214
+ # 이 레인이 잡았다(수리가 신규 결함의 출처인 것과 같은 얼굴: 반쪽 수리).
215
+ # 기본값은 **자기 레포 이름**이라 FH 에서는 종전과 동일(`forge-harness`)하다.
216
+ # 🟥 두 갈래를 갈라야 한다 (cross-family/codex F3 지목, 실측으로 **더 나쁜 형태** 확인):
217
+ # 초판은 `for _ctl in ${DLC_CONTROLS:-$(basename "$FH") session}` 였는데, 비인용 명령치환은
218
+ # 단어분리 **와 글로빙**을 둘 다 탄다 — 레포명이 `.*` 인 디렉터리에서 실측하니 컨트롤이
219
+ # `.git` 등 **4개로 글로브 확장**됐다. 게다가 컨트롤은 ERE 로 쓰이므로 레포명에 정규식
220
+ # 메타문자가 있으면 «아무 내용에나 매치»해 **계기 생존이 오탐**이 된다.
221
+ # ⇒ 유도 기본값은 **리터럴로 취급**(메타문자 이스케이프) · 사용자 지정값만 정규식으로 둔다.
222
+ local _ctl _label=1
223
+ if [ -n "${DLC_CONTROLS:-}" ]; then
224
+ for _ctl in $DLC_CONTROLS; do # 명시값은 «정규식» 계약 — 의도적 분리
225
+ printf 'CONTROL\t%s\t컨트롤%s: %s\n' "$_ctl" "$_label" "$_ctl"
226
+ _label=$((_label+1))
227
+ done
228
+ else
229
+ local _repo; _repo="$(basename "$FH")"
230
+ # ERE 메타문자 이스케이프 — 유도값은 «그 문자열이 실제로 있는가» 를 묻는 것이지 패턴이 아니다
231
+ _repo="$(printf '%s' "$_repo" | sed 's/[][\.^$*+?(){}|]/\\&/g')"
232
+ printf 'CONTROL\t%s\t컨트롤1: %s\n' "$_repo" "$_repo"
233
+ printf 'CONTROL\tsession\t컨트롤2: session\n'
234
+ fi
148
235
  while IFS=$'\t' read -r id toks; do
149
236
  [ -n "${id:-}" ] || continue
150
237
  n=$((n+1))
@@ -168,7 +255,14 @@ do_check() {
168
255
  [ -x "$CHECKER" ] || [ -f "$CHECKER" ] || {
169
256
  echo "❌ 착지 검증기 부재: $CHECKER (이 스크립트는 검증을 자체 구현하지 않는다)" >&2; return 10; }
170
257
 
171
- local -a targets
258
+ # 🟥 R7 (2026-08-19, **첫 실사용 발견**) — `set -u` + bash 3.2 에서 **빈 배열의 `${a[@]}`
259
+ # unbound 에러**다. 대상 레포에 digest 이후 커밋이 없으면 targets 가 비고, 그러면 아래
260
+ # 필터 루프가 터진다. 그 순간 스크립트는 판정을 인쇄하지 못한 채 **rc=1 로 끝나고**,
261
+ # 러너는 그 1 을 「SOME-UNLANDED」로 로그에 적는다 — 즉 **셸 에러가 착지율 시계열에
262
+ # 실측값으로 들어간다**([[feedback_not_found_is_not_zero_family]] 의 가장 조용한 얼굴).
263
+ # 실측: forge-wiki 대상 첫 런이 정확히 그렇게 «SOME-UNLANDED (rc=1)» 을 남겼고 본문은 없었다.
264
+ # 올바른 값은 rc=10(타깃 0건 = HARNESS-ERROR)이다. 빈 배열 확장을 전부 방어한다.
265
+ local -a targets=()
172
266
  if [ "$#" -gt 0 ]; then targets=("$@")
173
267
  else
174
268
  # 기본 스코프 = 공개 FH 자산 + tracks. **비공개 store 제외**(운영자 결정).
@@ -176,7 +270,8 @@ do_check() {
176
270
  # ⚠️ CLAUDE.md 도 **선후 필터를 거친다.** 초판은 무조건 추가했고, 그래서 이 파일 하나가
177
271
  # 필터를 우회해 오탐 원천이 됐다 — 손검증에서 "M1 착지 1파일" 의 그 1파일이 CLAUDE.md
178
272
  # 였고, 후보와 무관하게 원래 있던 토큰이었다. 예외 경로 하나가 필터 전체를 무력화한다.
179
- [ -f "$FH/CLAUDE.md" ] && [ "$FH/CLAUDE.md" -nt "$d" ] && targets+=("$FH/CLAUDE.md")
273
+ case " $DLC_TRACKED_PATHS " in *" CLAUDE.md "*)
274
+ [ -f "$FH/CLAUDE.md" ] && [ "$FH/CLAUDE.md" -nt "$d" ] && targets+=("$FH/CLAUDE.md") ;; esac
180
275
  # ★ **digest 보다 나중에 수정된 파일만** 본다. grep 은 "토큰이 있다" 만 재고
181
276
  # "이 후보 때문에 생겼다" 는 못 잰다 — 이미 있던 문서가 착지로 잡힌다(실측: 후보 하나가
182
277
  # 21파일 히트, 전부 무관한 기존 문서였다). 선후 필터가 인과를 주지는 않지만
@@ -211,11 +306,13 @@ do_check() {
211
306
  [ -n "$f" ] || continue
212
307
  case "$f" in *.md) ;; *) continue ;; esac
213
308
  [ -f "$FH/$f" ] && targets+=("$FH/$f")
214
- done < <(git -C "$FH" log --since="@$_dts" --name-only --pretty=format: -- knowledge CLAUDE.md 2>/dev/null | sort -u)
215
- while IFS= read -r f; do targets+=("$f"); done < <(
216
- find "$FH/tracks/_meta" -name '*.md' -type f -newer "$d" 2>/dev/null | head -400)
309
+ done < <(git -C "$FH" log --since="@$_dts" --name-only --pretty=format: -- $DLC_TRACKED_PATHS 2>/dev/null | sort -u)
310
+ if [ -n "$DLC_IGNORED_DIR" ] && [ -d "$FH/$DLC_IGNORED_DIR" ]; then
311
+ while IFS= read -r f; do targets+=("$f"); done < <(
312
+ find "$FH/$DLC_IGNORED_DIR" -name '*.md' -type f -newer "$d" 2>/dev/null | head -400)
313
+ fi
217
314
  # dirty tracked — 커밋 안 된 수정본엔 git 시각 증거가 없다. 초록으로 만들지 않고 셈에 남긴다.
218
- DIRTY_TRACKED="$(git -C "$FH" diff --name-only -- knowledge CLAUDE.md 2>/dev/null | grep -c '[.]md$')" || true
315
+ DIRTY_TRACKED="$(git -C "$FH" diff --name-only -- $DLC_TRACKED_PATHS 2>/dev/null | grep -c '[.]md$')" || true
219
316
  DIRTY_TRACKED="${DIRTY_TRACKED//[^0-9]/}"; DIRTY_TRACKED="${DIRTY_TRACKED:-0}"
220
317
  fi
221
318
  # ★ 자기참조 차단 — **이 계기의 첫 실물 실행에서 100% 오탐을 낸 원인**이다.
@@ -224,7 +321,7 @@ do_check() {
224
321
  local _dbase; _dbase="$(basename "$d")"
225
322
  local -a _filtered=()
226
323
  local _t
227
- for _t in "${targets[@]}"; do
324
+ for _t in ${targets[@]+"${targets[@]}"}; do
228
325
  case "$_t" in
229
326
  *"$_dbase") continue ;; # digest 자신
230
327
  */logs/*) continue ;; # 그 생성 로그
@@ -232,7 +329,7 @@ do_check() {
232
329
  esac
233
330
  _filtered+=("$_t")
234
331
  done
235
- targets=("${_filtered[@]}")
332
+ targets=(${_filtered[@]+"${_filtered[@]}"})
236
333
  [ "${#targets[@]}" -gt 0 ] || { echo "❌ 타깃 0건" >&2; return 10; }
237
334
 
238
335
  local probes; probes="$(mktemp -t dlc.XXXXXX)" || return 10
@@ -244,7 +341,7 @@ do_check() {
244
341
  local unchk; unchk="$(grep -c '^# UNCHECKABLE' "$probes" 2>/dev/null)" || true
245
342
  unchk="${unchk//[^0-9]/}"; unchk="${unchk:-0}"
246
343
  echo "── digest 착지 검증: $(basename "$d") ──"
247
- echo " 스코프: 공개 FH 자산 + tracks/ (비공개 companion store 제외 — '조직 전파'가 아니라 '허브 내부 착지')"
344
+ echo " 스코프: git추적[${DLC_TRACKED_PATHS}] + ignored[${DLC_IGNORED_DIR:-없음}] (비공개 companion store 제외 — '조직 전파'가 아니라 '레포 내부 착지')"
248
345
  echo " 타깃 파일 ${#targets[@]}건 (digest 이후 수정분만) · 판별불가 후보 ${unchk}건"
249
346
  echo " 축: git추적=커밋시각 · gitignored=mtime · dirty tracked ${DIRTY_TRACKED:-0}건=UNMEASURED"
250
347
  echo " ⚠️ 이 계기는 **선후**를 잰다. 인과가 아니다 — 히트는 열어서 확인하라"
@@ -331,8 +428,66 @@ EOF
331
428
  touch knowledge/old.md
332
429
  printf 'forge-harness session control\n' > tracks/_meta/card.md
333
430
  ) >/dev/null 2>&1
334
- out="$( cd "$G" && CLAUDE_PROJECT_DIR="$G" bash "$SELF_DIR/$(basename "${BASH_SOURCE[0]}")" \
431
+ # 컨트롤은 픽스처가 실제로 담고 있는 토큰으로 고정한다(레포명이 mktemp 난수라서).
432
+ # 🟥 이 레인의 주장은 **git/mtime 두 축 분리**지 컨트롤 유도가 아니다 — 축을 섞지 않는다.
433
+ out="$( cd "$G" && CLAUDE_PROJECT_DIR="$G" DLC_CONTROLS="forge-harness session" \
434
+ bash "$SELF_DIR/$(basename "${BASH_SOURCE[0]}")" \
335
435
  "$G/tracks/_meta/frontier_digest_2001_01_02.md" 2>&1 )"
436
+ # ── ★N-ctl (2026-08-18, R1-b) — 컨트롤이 **레포 이름을 따라간다** ────────────
437
+ # 초판은 `forge-harness`/`session` 하드코딩이라 **위성이 겨눈 레포에선 매번 컨트롤 사망**
438
+ # (= rc=10)이었다. R1 의 인자 버그를 고쳐도 계기는 구조적으로 죽은 채였을 것이다.
439
+ local out_ctl rc_ctl
440
+ out_ctl="$( cd "$G" && CLAUDE_PROJECT_DIR="$G" bash "$SELF_DIR/$(basename "${BASH_SOURCE[0]}")" \
441
+ "$G/tracks/_meta/frontier_digest_2001_01_02.md" 2>&1 )"; rc_ctl=$?
442
+ # 픽스처 레포 이름(mktemp 난수)은 본문에 없다 ⇒ 유도된 컨트롤이 죽어 rc=10 이 정상이다.
443
+ # 이것이 «유도가 실제로 돈다»의 증거다(하드코딩이면 여기서 rc=1 이 나온다).
444
+ _t "★N-ctl 컨트롤이 레포명을 따라간다(하드코딩 아님)" 10 "$rc_ctl"
445
+ # ── ★N-ctl-re (cross-family/codex F3) — 유도 컨트롤은 **리터럴**이지 정규식이 아니다 ──────
446
+ # 초판은 `for _ctl in ${DLC_CONTROLS:-$(basename "$FH") session}` 였다. 비인용 명령치환이라
447
+ # ⓐ 레포명이 ERE 메타문자면 «아무 내용에나 매치»해 계기 생존이 **오탐**이 되고
448
+ # ⓑ 실측하니 글로빙까지 타서 컨트롤이 `.git` 등으로 **확장**되기도 했다.
449
+ # 🟥 픽스처는 **판별하는 형태**여야 한다. 첫 판본은 레포명을 `.*` 로 잡았는데, 그건 글로빙에
450
+ # 걸려 죽은 컨트롤이 생기는 바람에 **버그 판본도 rc=10** 을 냈다 — 되돌림 프로브가 그
451
+ # 장식성을 잡았다([[feedback_fixture_must_use_the_breaking_spelling]]).
452
+ # 그래서 레포명을 `a.b` 로 쓴다: 글로브로는 아무것도 안 맞아 그대로 남고(확장 없음),
453
+ # ERE 로는 `axb` 에 매치된다. 대상에 `axb` 만 두면
454
+ # 버그 판본 → 컨트롤 **생존(오탐)** → rc≠10
455
+ # 수리 판본 → 리터럴 `a.b` 없음 → 컨트롤 사망 → rc=10
456
+ local RG; RG="$(mktemp -d -t 'dlc_re.XXXXXX')" || return 10
457
+ local RD="$RG/a.b"
458
+ (
459
+ mkdir -p "$RD/tracks/_meta" "$RD/knowledge" && cd "$RD" \
460
+ && git init -q . && git config user.email t@t && git config user.name t
461
+ printf '# d\n## 📌 FH Immediate Application Candidates\n\n| # | 티어 | 내용 |\n|---|---|---|\n| **A1** | M | `zzz_absent_token` 후보 |\n\n## end\n' \
462
+ > tracks/_meta/frontier_digest_2001_01_01.md
463
+ touch -t 200101010000 tracks/_meta/frontier_digest_2001_01_01.md
464
+ # 리터럴 `a.b` 는 **없다**. `axb`(ERE 로만 매치) 와 `session` 만 있다.
465
+ printf 'axb 그리고 session\n' > tracks/_meta/card.md
466
+ ) >/dev/null 2>&1
467
+ local rc_re
468
+ ( cd "$RD" && CLAUDE_PROJECT_DIR="$RD" bash "$SELF_DIR/$(basename "${BASH_SOURCE[0]}")" \
469
+ "$RD/tracks/_meta/frontier_digest_2001_01_01.md" ) >/dev/null 2>&1; rc_re=$?
470
+ _t "★N-ctl-re 레포명이 정규식 메타문자여도 리터럴로 취급(컨트롤 오탐 없음)" 10 "$rc_re"
471
+ rm -rf "$RG"
472
+
473
+ # ── ★N-empty (2026-08-19, R7) — 타깃이 0건이면 **rc=10** 이지 rc=1 이 아니다 ────────────
474
+ # 🟥 `set -u` + bash 3.2 에서 빈 배열 확장이 터지면, 스크립트는 판정을 못 내고 **rc=1** 로
475
+ # 끝난다. 러너는 그 1 을 「SOME-UNLANDED」로 적는다 — 셸 에러가 착지율 시계열에 실측값으로
476
+ # 들어간다. 첫 실사용(forge-wiki 대상)에서 정확히 그 로그가 나왔고 본문은 비어 있었다.
477
+ local EG; EG="$(mktemp -d -t dlc_e.XXXXXX)" || return 10
478
+ (
479
+ mkdir -p "$EG/wiki" && cd "$EG" && git init -q . \
480
+ && git config user.email t@t && git config user.name t
481
+ printf '# d\n## 📌 FH Immediate Application Candidates\n\n| # | 티어 | 내용 |\n|---|---|---|\n| **A1** | M | `zzz_tok` 후보 |\n\n## end\n' \
482
+ > wiki/frontier_digest_2001_01_01.md
483
+ ) >/dev/null 2>&1
484
+ # digest 이후 커밋 0 · ignored 축 없음 ⇒ 타깃 0건
485
+ local rc_e
486
+ ( cd "$EG" && CLAUDE_PROJECT_DIR="$EG" DLC_TRACKED_PATHS="wiki" DLC_IGNORED_DIR="" \
487
+ DLC_CONTROLS="zzz_tok" bash "$SELF_DIR/$(basename "${BASH_SOURCE[0]}")" \
488
+ "$EG/wiki/frontier_digest_2001_01_01.md" ) >/dev/null 2>&1; rc_e=$?
489
+ _t "★N-empty 타깃 0건은 HARNESS-ERROR(10)이지 미착지(1)가 아니다" 10 "$rc_e"
490
+ rm -rf "$EG"
336
491
  # ⚠️ **줄 단위로** 본다. `case *"A1"*"미착지"*` 는 마지막 요약줄("N건 중 M건 미착지")까지
337
492
  # 삼켜 오매칭한다 — 문자열 위치로 판정하는 prose-grep 함정([[feedback_typed_verdict_channel]]).
338
493
  if printf '%s\n' "$out" | grep -qE '후보 A1[[:space:]]+미착지'; then