@chrono-meta/fh-gate 1.4.99 → 2.0.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.
Files changed (41) hide show
  1. package/.claude/capabilities/adapters/gstack-content-safety.cap +110 -0
  2. package/.claude/capabilities/adapters/mate-agent-boundary.cap +110 -0
  3. package/.claude/capabilities/adapters/qasp-new-code-anchor.cap +116 -0
  4. package/.claude/capabilities/degrade-direction-scan.cap +24 -0
  5. package/.claude/capabilities/public-surface-scan.cap +24 -0
  6. package/.claude-plugin/marketplace.json +3 -3
  7. package/AGENTS.md +9 -3
  8. package/CHEATSHEET.md +17 -1
  9. package/CLAUDE.md +2 -2
  10. package/README.ja.md +55 -20
  11. package/README.ko.md +86 -20
  12. package/README.md +57 -20
  13. package/README.zh.md +49 -19
  14. package/knowledge/shared/harness-core/fh_three_layer_canon.md +96 -5
  15. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +36 -0
  16. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +1 -1
  17. package/knowledge/shared/harness-core/harness_terminal_correlation_and_recommendations.md +214 -0
  18. package/knowledge/shared/harness-core/harness_verification_core_extended.md +1 -1
  19. package/knowledge/shared/harness-core/ship_readiness_gate.md +29 -10
  20. package/knowledge/shared/learnings/subagent_invocations_log.yaml +73 -0
  21. package/package.json +13 -3
  22. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  23. package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
  24. package/plugins/fh-meta/CHANGELOG.md +45 -0
  25. package/scripts/adapters/gstack_content_safety.sh +186 -0
  26. package/scripts/adapters/mate_agent_boundary.sh +120 -0
  27. package/scripts/adapters/peer_resolve.sh +99 -0
  28. package/scripts/adapters/qasp_new_code_anchor.sh +159 -0
  29. package/scripts/branch_claim.sh +14 -0
  30. package/scripts/capability_effect_probe.sh +701 -0
  31. package/scripts/capability_registry_check.sh +313 -16
  32. package/scripts/cluster_capability_scan.sh +629 -0
  33. package/scripts/fh_session_load.sh +70 -0
  34. package/scripts/portability_lint.sh +257 -0
  35. package/scripts/publish_freshness_check.sh +204 -0
  36. package/scripts/relay_channel.sh +70 -7
  37. package/scripts/selfcheck.sh +166 -1
  38. package/scripts/test_adapter_lanes.sh +296 -0
  39. package/scripts/test_capability_entrypoint_shipping.sh +83 -6
  40. package/scripts/test_relay_channel_lanes.sh +76 -0
  41. package/templates/.git-hooks/pre-commit +14 -2
@@ -20,7 +20,13 @@
20
20
  # 돌리면 판정하지만 돌리도록 강제하지는 못한다(relay_channel.sh 의 같은 잔여와 동형).
21
21
  # · **M3(모델 독립성)은 선언 검사다.** reps 로 재지 않는다 — `judge:` 축 선언과, mechanical
22
22
  # 선언인데 entry 가 LLM CLI 를 부르는 명백한 모순만 잡는다. M4 를 reps=2 로 돌려 부분 방어.
23
- # · 🟥 **`writes:` 축은 검증 불가 그리고 그게 파일에서 실제로 터졌다.**
23
+ # · **`writes:` 축은 2026-08-16 부터 관측된다(M6).** 아래 문단은 전의 상태이고,
24
+ # 구조 처방으로 적어둔 것이 실제로 지어졌다 — `capability_effect_probe.sh` 를 M6 에서
25
+ # 부른다. 사고 형태(`rm -rf scripts` + `read-only` 선언)는 이제 **REJECTED** 로 막힌다
26
+ # (self-test 레인으로 고정). 남은 한계는 그 프로브 헤더에 적혀 있다:
27
+ # 한 번의 실행만 본다 · 조건부 쓰기는 M4 쌍에 없으면 안 걸린다 · 네트워크는 안 본다.
28
+ #
29
+ # · (이력) 🟥 **`writes:` 축은 검증 불가였다 — 그리고 그게 이 파일에서 실제로 터졌다.**
24
30
  # 2026-08-11, 이 검사기를 통과한 capability(`writes: read-only` 선언)의 진입점이
25
31
  # 정리 트랩 결함으로 **레포의 `scripts/` 를 rm -rf 했다.** M1(실행 가능)·M2(닫힌 enum)·
26
32
  # M3(mechanical)·M4(known-pair 통과)·M5(cwd) 를 **전부 통과한 채로** 그랬다.
@@ -28,14 +34,15 @@
28
34
  # 를 보지 않는다. read-only 선언의 진위는 여기서 닫히지 않는다.
29
35
  # 부분 처방(오늘 적용): 진입점 쪽 트랩 규율(정리 대상 변수 재대입 금지 + 임시경로 검문).
30
36
  # 구조 처방(미구축): 샌드박스/읽기전용 마운트에서 M4 를 돌려 쓰기 시도를 관측하는 것.
31
- # 전까지 `writes: read-only` 는 **등록자 주장**이지 이 검사기의 판정이 아니다.
37
+ # ~~그 전까지 `writes: read-only` 는 등록자 주장이지 이 검사기의 판정이 아니다.~~
38
+ # → 2026-08-16 부터 **판정이다**(M6). 단 UNVERIFIABLE 이 나오면 그때는 여전히 주장이다.
32
39
  #
33
40
  # 사용법
34
41
  # capability_registry_check.sh <capfile> [<capfile> ...]
35
42
  # capability_registry_check.sh --self-test
36
43
  #
37
44
  # exit code
38
- # 0 REGISTRABLE 전 capfile 이 M1–M5 + 추가조항 통과
45
+ # 0 REGISTRABLE 전 capfile 이 M1–M6 + 추가조항 통과
39
46
  # 1 REJECTED 하나 이상 기준 미달 (등록 불가 — 그 표면은 dispatch 로 남는다)
40
47
  # 10 HARNESS_ERROR capfile 도달 불가·파손·검사기 자신의 전제 파손
41
48
  #
@@ -44,7 +51,10 @@ set -f # noglob — 선언 파일의 값은 데이터지 파일 패턴이 아
44
51
 
45
52
  RC_OK=0; RC_REJECT=1; RC_HARNESS=10
46
53
 
47
- CLOSED_KEYS="id entry requires_cwd verdict_channel verdict_enum verdict_stdout_key upstream_argv echoes_upstream approval reversibility residency degrade tier_floor writes judge verdict_binding calibration_positive_args calibration_positive_expect calibration_negative_args calibration_negative_expect"
54
+ # `summary`/`tags` **추천 전용**(2026-08-16, cluster-wizard). 판정에는 쓰이고
55
+ # `cluster_capability_scan.sh recommend` 의 어휘 매칭에만 쓰인다. 닫힌 목록에 넣는 이유는
56
+ # 이 목록의 목적이 «오타 축 무음 드롭 방지» 이기 때문이다 — 안 넣으면 정당한 키가 SCHEMA 로 막힌다.
57
+ CLOSED_KEYS="id entry requires_cwd summary tags verdict_channel verdict_enum verdict_stdout_key upstream_argv echoes_upstream approval reversibility residency degrade tier_floor writes judge verdict_binding calibration_positive_args calibration_positive_expect calibration_negative_args calibration_negative_expect"
48
58
 
49
59
  # 「안 돌았다」를 뜻하는 이름들 — 추가조항(§ⓑ.4 B1)이 요구하는 구분항
50
60
  DIDNOTRUN_NAMES="DID_NOT_RUN DIDNOTRUN NOT_RUN NO_TARGET SKIPPED UNMEASURED NOT_CONFIGURED HARNESS_ERROR"
@@ -60,7 +70,10 @@ _parse() {
60
70
  CAP_id=""; CAP_entry=""; CAP_requires_cwd=""; CAP_verdict_channel=""
61
71
  CAP_verdict_enum=""; CAP_verdict_stdout_key=""; CAP_judge=""; CAP_writes=""
62
72
  CAP_cal_pos_args=""; CAP_cal_pos_expect=""; CAP_cal_neg_args=""; CAP_cal_neg_expect=""
63
- UNKNOWN_KEYS=""
73
+ UNKNOWN_KEYS=""; CAP_requires_cwd_was_self=0
74
+ # 「선언했는데 값이 비었다」와 「선언 자체가 없다」는 다른 사실이다 — 관측 범위 블록이
75
+ # 둘을 구분해 적으려면 파서가 그 구분을 보존해야 한다(빈 문자열 하나로는 못 나눈다).
76
+ CAP_cal_pos_args_seen=0; CAP_cal_neg_args_seen=0
64
77
  local line key val
65
78
  while IFS= read -r line || [ -n "$line" ]; do
66
79
  case "$line" in ''|'#'*) continue ;; esac
@@ -77,9 +90,9 @@ _parse() {
77
90
  requires_cwd) CAP_requires_cwd="$val" ;; verdict_channel) CAP_verdict_channel="$val" ;;
78
91
  verdict_enum) CAP_verdict_enum="$val" ;; verdict_stdout_key) CAP_verdict_stdout_key="$val" ;;
79
92
  judge) CAP_judge="$val" ;; writes) CAP_writes="$val" ;;
80
- calibration_positive_args) CAP_cal_pos_args="$val" ;;
93
+ calibration_positive_args) CAP_cal_pos_args="$val"; CAP_cal_pos_args_seen=1 ;;
81
94
  calibration_positive_expect) CAP_cal_pos_expect="$val" ;;
82
- calibration_negative_args) CAP_cal_neg_args="$val" ;;
95
+ calibration_negative_args) CAP_cal_neg_args="$val"; CAP_cal_neg_args_seen=1 ;;
83
96
  calibration_negative_expect) CAP_cal_neg_expect="$val" ;;
84
97
  esac
85
98
  done < "$1"
@@ -114,15 +127,88 @@ _run_arm() { # $1=extra args → ARM_RC / ARM_NAME. 파이프로 읽지 않는
114
127
  ARM_NAME="$(_enum_name_of "$ARM_RC" "$CAP_verdict_enum")"
115
128
  }
116
129
 
130
+ # ── 관측 범위 블록 (2026-08-16) — «판정» 이 아니라 «채널» ────────────────────────
131
+ #
132
+ # 🟥 이 블록은 **exit code 에 아무 영향을 주지 않는다.** `_fail` 을 부르지 않고, 어떤 축도
133
+ # 새로 판정하지 않는다. 순수 정보 추가다 — 이 레포의 규율("기계는 비가역 경계와 채널에만,
134
+ # 판단은 안 짓는다")대로, 여기서 하는 일은 **무엇을 관측했고 무엇을 안 했는지 적는 것**뿐이다.
135
+ #
136
+ # 왜 필요한가 — 2026-08-16 실측 두 건이 같은 얼굴을 하고 있었다:
137
+ # ① **M4 는 enum↔구현 일치를 구조적으로 안 본다.** 같은 stale 선언이 캘리브레이션 쌍에
138
+ # 따라 판정이 갈린다 — 쌍이 우연히 «선언 밖 exit» 을 밟으면 REJECTED, 구 enum 안에서만
139
+ # 갈리면 M1–M6 전부 통과. 즉 M4 는 «자기가 부른 두 번의 실행에서 본 것» 만 판정한다.
140
+ # ② **M6 도 선언된 arm 만 관측한다.** `pmh-dev:merge-noop-check` 는 분기 입력에서 git 객체를
141
+ # 쓰지만(격리 클론 실측: `.git/objects` 2145 → 2146), 선언된 두 arm 이 그 경로를 안 지나므로
142
+ # `writes: read-only` 로 **거짓 선언해도 통과한다**(실측 확인).
143
+ #
144
+ # 둘 다 «판정이 틀렸다» 가 아니라 **«판정의 관측 범위가 출력에 안 적혀 있다»** 이다. 그래서
145
+ # 고치는 방향은 새 판정 축이 아니라 이 블록이다: 읽는 사람이 초록을 «선언 전체가 참이다» 로
146
+ # 읽지 않게, 이 실행이 실제로 밟은 곳을 이름으로 적는다.
147
+ # ★ 「없음」도 **반드시 찍는다.** 침묵은 «관측되지 않은 값이 없다» 와 «계산하지 않았다» 를
148
+ # 구분하지 못한다 — 미측정을 0 으로 렌더하지 않는다([[feedback_not_found_is_not_zero_family]]).
149
+ _observation_scope() {
150
+ printf ' ── 관측 범위 (이 판정이 «무엇을 안 봤는지») ──\n'
151
+ printf ' M4/M6 는 **선언된 캘리브레이션 arm 의 실행에서 본 것만** 판정한다.\n'
152
+
153
+ local _pa _na
154
+ if [ "${CAP_cal_pos_args_seen:-0}" = "1" ]; then
155
+ [ -n "$CAP_cal_pos_args" ] && _pa="$CAP_cal_pos_args" || _pa='<빈 값 — 인자 없이 실행>'
156
+ else _pa='<미선언>'; fi
157
+ if [ "${CAP_cal_neg_args_seen:-0}" = "1" ]; then
158
+ [ -n "$CAP_cal_neg_args" ] && _na="$CAP_cal_neg_args" || _na='<빈 값 — 인자 없이 실행>'
159
+ else _na='<미선언>'; fi
160
+ printf ' 실행된 arm: 양성 «%s» / 음성 «%s»\n' "$_pa" "$_na"
161
+
162
+ if [ "${OBS_STATE:-none}" = "run" ]; then
163
+ printf ' 관측된 exit: 양성 rc=%s(%s)%s / 음성 rc=%s(%s)\n' \
164
+ "$OBS_POS_RC" "${OBS_POS_NAME:-<enum 밖>}" \
165
+ "$( [ "$OBS_POS_RC" = "$OBS_POS_RC2" ] && printf ' ×2회 동일' || printf ' ×2회째 rc=%s(갈림)' "$OBS_POS_RC2" )" \
166
+ "$OBS_NEG_RC" "${OBS_NEG_NAME:-<enum 밖>}"
167
+ else
168
+ printf ' 관측된 exit: 없음 — arm 을 실행하지 않았다(%s)\n' "${OBS_WHY:-사유 미기록}"
169
+ fi
170
+
171
+ # 미관측 enum = 선언 enum − 이 실행에서 실제로 나온 rc. **계산해서** 적는다.
172
+ if [ -z "$CAP_verdict_enum" ]; then
173
+ printf ' ⚠️ 선언된 enum 이 없어 「관측되지 않은 값」을 계산할 수 없다(미측정이지 0 이 아니다 — M2 가 이미 실패로 기록했다)\n'
174
+ else
175
+ local _pair _code _unobs="" _seenrc=""
176
+ [ "${OBS_STATE:-none}" = "run" ] && _seenrc=" $OBS_POS_RC $OBS_POS_RC2 $OBS_NEG_RC "
177
+ for _pair in $CAP_verdict_enum; do
178
+ _code="${_pair%%=*}"
179
+ case "$_seenrc" in *" $_code "*) ;; *) _unobs="$_unobs $_pair" ;; esac
180
+ done
181
+ if [ -n "$_unobs" ]; then
182
+ printf ' ⚠️ 선언된 enum 중 이 실행에서 **관측되지 않은** 값:%s\n' "$_unobs"
183
+ else
184
+ printf ' · 선언된 enum 중 이 실행에서 관측되지 않은 값: 없음 (선언된 전 값이 이 실행에서 관측됐다)\n'
185
+ fi
186
+ fi
187
+ printf ' ⚠️ 이 실행이 지나지 않은 코드 경로의 부작용(M6 의 `writes:` 축 포함)은 관측 대상이 아니다.\n'
188
+ }
189
+
117
190
  _check_one() {
118
191
  local f="$1"
119
192
  [ -r "$f" ] || _die "capfile 도달 불가: $f"
120
193
  _parse "$f"
121
194
  FILE_FAILED=0
195
+ # 관측 원장 — 「안 돌았다」가 디폴트다. 실행한 arm 만 이 값들을 채운다.
196
+ OBS_STATE=none; OBS_WHY=""; OBS_POS_RC=""; OBS_POS_RC2=""; OBS_NEG_RC=""
197
+ OBS_POS_NAME=""; OBS_NEG_NAME=""
122
198
  printf '\n── %s (%s)\n' "${CAP_id:-<id 미선언>}" "$f"
123
199
 
124
200
  [ -n "$UNKNOWN_KEYS" ] && _fail "SCHEMA" "닫힌 키 목록 밖:$UNKNOWN_KEYS (오타 축 무음드롭 방지 — 무시하지 않는다)"
125
201
 
202
+ # ── `requires_cwd: SELF` 를 **M1 보다 먼저** 푼다 (2026-08-16 순서 수리) ────
203
+ # 초판은 M5 에서 풀었는데, M1 이 그보다 먼저 돌면서 **상대 entry 를 현재 cwd 기준으로** 찾았다.
204
+ # FH 로컬 등록부 시절엔 entry 가 늘 절대경로라 안 터졌다 — 하네스 자기 선언(상대 entry +
205
+ # SELF)이 그 잠재 가정을 처음 실행에 노출시킨 것이다([[feedback_wiring_surfaces_hidden_failures]]).
206
+ # 해석 실패는 여기서 조용히 넘기고 M5 가 판정한다(한 결함을 두 축에서 두 번 세지 않는다).
207
+ if [ "$CAP_requires_cwd" = "SELF" ]; then
208
+ local _sr; _sr="$(git -C "$(dirname "$f")" rev-parse --show-toplevel 2>/dev/null)"
209
+ [ -n "$_sr" ] && [ -d "$_sr" ] && CAP_requires_cwd="$_sr" && CAP_requires_cwd_was_self=1
210
+ fi
211
+
126
212
  # ── M1 실행 가능한 진입점 ──────────────────────────────────────────────────
127
213
  local first second
128
214
  first="$(printf '%s' "$CAP_entry" | awk '{print $1}')"
@@ -132,8 +218,15 @@ _check_one() {
132
218
  _fail "M1" "entry 가 argv 가 아니라 셸 문자열이다(파이프/리다이렉트/치환 포함) — 인젝션 표면" ;;
133
219
  '') _fail "M1" "entry 미선언" ;;
134
220
  *)
221
+ # 상대 경로는 **선언된 cwd 기준**으로도 본다. 선언은 자기 레포를 기준으로 쓰는 것이
222
+ # 자연스럽고(그래야 이식된다), 검사기가 자기 cwd 로만 보면 정당한 선언을 못 읽는다.
223
+ local _second_abs="$second"
224
+ case "$second" in
225
+ /*) ;;
226
+ *) [ -n "$CAP_requires_cwd" ] && [ -r "$CAP_requires_cwd/$second" ] && _second_abs="$CAP_requires_cwd/$second" ;;
227
+ esac
135
228
  if [ -x "$first" ] 2>/dev/null; then _ok "M1" "실행 가능: $first"
136
- elif command -v "$first" >/dev/null 2>&1 && [ -r "$second" ]; then
229
+ elif command -v "$first" >/dev/null 2>&1 && [ -r "$_second_abs" ]; then
137
230
  _ok "M1" "선언된 인터프리터($first) + 읽을 수 있는 스크립트: $second"
138
231
  else _fail "M1" "entry 를 셸이 모델 없이 실행할 수 없다: $CAP_entry"; fi ;;
139
232
  esac
@@ -175,25 +268,64 @@ _check_one() {
175
268
 
176
269
  # ── M5 선언된 cwd (M4 를 그 자리에서 돌리므로 먼저) ────────────────────────
177
270
  case "$CAP_requires_cwd" in
178
- /*) [ -d "$CAP_requires_cwd" ] && _ok "M5" "requires_cwd 실재: $CAP_requires_cwd" \
179
- || _fail "M5" "requires_cwd 절대경로지만 존재하지 않는다: $CAP_requires_cwd" ;;
271
+ /*)
272
+ # `SELF` 위(M1 앞)에서 이미 레포 루트로 풀렸다. 그 사실을 판정문에 남긴다 —
273
+ # 안 남기면 독자가 선언 원문(`SELF`)과 검사 결과(절대경로)를 연결하지 못한다.
274
+ if [ -d "$CAP_requires_cwd" ]; then
275
+ if [ "${CAP_requires_cwd_was_self:-0}" = "1" ]; then
276
+ _ok "M5" "requires_cwd: SELF → 선언을 품은 레포 루트: $CAP_requires_cwd"
277
+ else
278
+ _ok "M5" "requires_cwd 실재: $CAP_requires_cwd"
279
+ fi
280
+ else
281
+ _fail "M5" "requires_cwd 가 절대경로지만 존재하지 않는다: $CAP_requires_cwd"
282
+ fi ;;
283
+ SELF)
284
+ # ★`SELF` = «이 선언을 품은 레포» (2026-08-16 신설, 정체성 ①-(a) cluster-wizard).
285
+ #
286
+ # 왜 필요했나: 이 스키마는 원래 **FH 로컬 등록부**(gitignored)를 위한 것이라 절대경로가
287
+ # 문제없었다. 그런데 (c) 를 닫으려면 선언이 **그 하네스 자신의 레포에 tracked 로** 살아야
288
+ # 하고, 그 순간 절대경로는 두 가지로 깨진다:
289
+ # ① 이식 불가 — 다른 머신·다른 체크아웃에서 그 경로는 없다
290
+ # ② **공개표면 위반** — 운영자 홈 경로가 tracked 파일에 실린다
291
+ # 그래서 «자기 자신» 을 가리키는 이식 가능한 표현이 필요했다.
292
+ #
293
+ # 해석은 **위치 가정이 아니라 git 레포 루트**다. `../../..` 같은 상대 오프셋으로 풀면
294
+ # `.claude/capabilities/` 배치를 하드코딩하게 되고, 배치가 바뀌는 순간 조용히 엉뚱한
295
+ # 디렉토리를 가리킨다. 레포 루트는 그 파일이 어디 놓이든 같은 답을 준다.
296
+ local _self_root
297
+ _self_root="$(git -C "$(dirname "$f")" rev-parse --show-toplevel 2>/dev/null)"
298
+ if [ -n "$_self_root" ] && [ -d "$_self_root" ]; then
299
+ CAP_requires_cwd="$_self_root"
300
+ _ok "M5" "requires_cwd: SELF → 선언을 품은 레포 루트로 해석: $_self_root"
301
+ else
302
+ # 🟥 fail-closed. git 레포 밖의 선언은 «자기» 가 무엇인지 정의되지 않는다 —
303
+ # 추측해서 cwd 를 정하면 M4 가 **엉뚱한 트리에서** 진입점을 돌린다.
304
+ _fail "M5" "requires_cwd: SELF 인데 이 선언이 git 레포 안에 없다 — «자기» 를 해석할 수 없다"
305
+ fi ;;
180
306
  '') _fail "M5" "requires_cwd 미선언 — 콕핏이 어디서 부를지 알 수 없다" ;;
181
- *) _fail "M5" "requires_cwd 가 절대경로가 아니다: $CAP_requires_cwd" ;;
307
+ *) _fail "M5" "requires_cwd 가 절대경로도 SELF 도 아니다: $CAP_requires_cwd" ;;
182
308
  esac
183
309
 
184
310
  # ── M4 캘리브레이션 쌍 — 선언 + **실행** ──────────────────────────────────
185
311
  if [ -z "$CAP_cal_pos_expect" ] || [ -z "$CAP_cal_neg_expect" ]; then
186
312
  _fail "M4" "캘리브레이션 쌍 미선언(양성·음성 expect 둘 다 필요) — 답을 아는 케이스를 못 가르는 계기는 재는 게 아니다"
313
+ OBS_WHY="캘리브레이션 쌍 미선언"
187
314
  elif [ "$FILE_FAILED" -eq 1 ] && [ -z "${CRC_FORCE_M4:-}" ]; then
188
315
  printf ' ⏭ M4 — 앞선 축이 실패해 실행 생략(SKIPPED, PASS 아님)\n'
316
+ OBS_WHY="앞선 축 실패로 M4 생략"
189
317
  elif ! _validate_arm_args "$CAP_cal_pos_args" || ! _validate_arm_args "$CAP_cal_neg_args"; then
190
318
  printf ' ⏭ M4 — args 검문 실패로 arm 을 실행하지 않았다(SKIPPED, PASS 아님)\n'
319
+ OBS_WHY="args 검문 실패"
191
320
  else
192
- local pos_name neg_name pos_rc neg_rc pos_name2
321
+ local pos_name neg_name pos_rc neg_rc pos_name2 pos_rc2
193
322
  _run_arm "$CAP_cal_pos_args"; pos_rc="$ARM_RC"; pos_name="$ARM_NAME"
194
323
  [ "$pos_rc" = "127" ] && _fail "M4" "requires_cwd 로 진입 실패 — arm 을 돌릴 수 없다"
195
- _run_arm "$CAP_cal_pos_args"; pos_name2="$ARM_NAME" # reps=2 (M3 부분 방어)
324
+ _run_arm "$CAP_cal_pos_args"; pos_name2="$ARM_NAME"; pos_rc2="$ARM_RC" # reps=2 (M3 부분 방어)
196
325
  _run_arm "$CAP_cal_neg_args"; neg_rc="$ARM_RC"; neg_name="$ARM_NAME"
326
+ # 관측 원장에 남긴다 — 판정과 무관하게, 이 실행이 **실제로 밟은 exit** 만 적는다.
327
+ OBS_STATE=run; OBS_POS_RC="$pos_rc"; OBS_POS_RC2="$pos_rc2"; OBS_NEG_RC="$neg_rc"
328
+ OBS_POS_NAME="$pos_name"; OBS_NEG_NAME="$neg_name"
197
329
 
198
330
  if [ -z "$pos_name" ]; then
199
331
  _fail "M4" "양성 arm 의 exit $pos_rc 가 선언된 enum 밖 — enum 밖 값은 HARNESS_ERROR 이지 PASS 가 아니다"
@@ -212,9 +344,71 @@ _check_one() {
212
344
  fi
213
345
  fi
214
346
 
215
- # ── 검증 불가 축의 정직한 표기 (판정 아님) ────────────────────────────────
216
- [ "$CAP_writes" = "read-only" ] && \
217
- printf ' ⚠️ writes: read-only **등록자 주장**이다 검사기는 진위를 잰다(헤더 §잔여 참조)\n'
347
+ # ── M6 선언 진위 `writes:` 를 **관측**한다 (2026-08-16) ──────────────────
348
+ #
349
+ # 파일 헤더가 «구조 처방(미구축): 샌드박스/읽기전용 마운트에서 M4 돌려 쓰기 시도를
350
+ # 관측하는 것» 이라고 적어놓은 자리다. `capability_effect_probe.sh` 가 그 처방이고,
351
+ # 여기서 **부른다** — 도구가 존재하는 것과 게이트가 그것을 부르는 것은 다르다.
352
+ #
353
+ # ★UNVERIFIABLE 은 **PASS 가 아니다.** 등록을 막지는 않되(과차단이 우회를 훈련시킨다)
354
+ # «못 쟀다» 를 출력에 남긴다. 미측정을 0 으로 렌더하지 않는다.
355
+ if [ -z "$CAP_writes" ]; then
356
+ : # writes 미선언은 M2 가 이미 처리한다
357
+ elif [ "$FILE_FAILED" -eq 1 ] && [ -z "${CRC_FORCE_M6:-}" ]; then
358
+ # ★앞선 축이 실패했으면 **진입점을 실행하지 않는다**(2026-08-16 cross-family 지목).
359
+ # M6 는 `eval` 로 entry 를 돌리므로, «등록 거부될 capfile» 의 진입점을 굳이 실행하는
360
+ # 것은 그 자체가 위험 노출이다. M4 가 이미 같은 가드를 갖고 있었고 M6 만 없었다.
361
+ printf ' ⏭ M6 — 앞선 축이 실패해 실행 생략(SKIPPED, PASS 아님)\n'
362
+ else
363
+ # SYMLINK-RESOLVED, not `${0%/*}` (fixed 2026-08-16, reproduced on both sides).
364
+ # `${0%/*}` yields the directory of the NAME USED TO INVOKE, so a symlink to this script made
365
+ # the probe lookup land next to the LINK — where nothing lives — and M6 then fail-closed with
366
+ # "probe 부재" about a probe that exists. A gate that blames a missing file for its own path bug
367
+ # is worse than one that simply fails: it sends the reader to the wrong repair.
368
+ # `BASH_SOURCE[0]` is the real file even when invoked through a link; the loop then walks any
369
+ # remaining links in the path. `readlink -f` is deliberately not used — it is GNU-only and this
370
+ # ships to macOS (BSD `readlink` has no `-f`).
371
+ # ★반복 상한. 보안 패스 [B] 가 «순환 심링크에서 영원히 돈다» 로 지목했고, 캡을 넣은 뒤
372
+ # **실제 순환 쌍으로 재보니 그 시나리오는 도달 불가였다**: `a.sh → b.sh → a.sh` 는 OS 가
373
+ # ELOOP 로 먼저 막아 bash 가 rc=126 으로 죽는다 — 이 루프는 시작조차 안 한다.
374
+ # 지적은 **루프 본문을 격리해서** 돌린 결과였다(부품 테스트지 도달성 테스트가 아니다).
375
+ # 그래도 캡은 남긴다: 값이 싸고, `$0` 가 파일이 아닌 경로로 들어오는 미래 호출 형태까지
376
+ # 내가 열거하지 못하기 때문이다. **다만 «무한루프를 고쳤다» 고 주장하지 않는다.**
377
+ local _src="${BASH_SOURCE[0]:-$0}" _dir _hops=0
378
+ while [ -L "$_src" ]; do
379
+ _hops=$((_hops+1))
380
+ if [ "$_hops" -gt 40 ]; then
381
+ _fail "M6" "심링크가 40회를 넘어간다 — 순환으로 본다. 프로브 경로를 못 정한다"
382
+ _observation_scope # 이 이른 return 에서도 관측 범위는 찍힌다(조건부로 찍히면 채널이 아니다)
383
+ return 0 # _fail 이 이미 기록했다. 판정 불가를 통과로 렌더하지 않는다
384
+ fi
385
+ _dir=$(cd -P "$(dirname "$_src")" >/dev/null 2>&1 && pwd) || break
386
+ _src=$(readlink "$_src")
387
+ case "$_src" in /*) ;; *) _src="$_dir/$_src" ;; esac
388
+ done
389
+ _dir=$(cd -P "$(dirname "$_src")" >/dev/null 2>&1 && pwd) || _dir="${0%/*}"
390
+ local _probe="$_dir/capability_effect_probe.sh"
391
+ if [ ! -r "$_probe" ]; then
392
+ # 🟥 **fail-CLOSED.** 등록은 «이후 자동 호출» 로 이어지는 비가역 표면이다
393
+ # (CLAUDE.md §Irreversibility Surface-Class Degrade Invariant).
394
+ # 계기가 없으면 «못 쟀다» 이지 «괜찮다» 가 아니고, 여기서 통과시키면 이 축이
395
+ # 존재하기 전과 같은 상태가 된다.
396
+ _fail "M6" "effect probe 부재($_probe) — 선언 진위를 **못 쟀다**. 비가역 표면이므로 fail-closed"
397
+ else
398
+ local _pout _prc
399
+ _pout=$(bash "$_probe" "$f" 2>&1); _prc=$?
400
+ case "$_prc" in
401
+ 0) _ok "M6" "선언 진위 관측 통과: writes=$CAP_writes (양 arm 샌드박스 실행에서 위반 없음)" ;;
402
+ 1) _fail "M6" "선언 진위 **위반**: $(printf '%s' "$_pout" | head -1)" ;;
403
+ 3) _fail "M6" "관측 불가(UNVERIFIABLE) — 등록은 이후 자동 호출로 이어지므로 **미측정을 통과로 렌더하지 않는다**: $(printf '%s' "$_pout" | head -1)" ;;
404
+ *) _fail "M6" "프로브 하네스 오류 rc=$_prc — 판정 아님이므로 fail-closed" ;;
405
+ esac
406
+ fi
407
+ fi
408
+
409
+ # ★무조건 찍는다 — PASS 든 REJECTED 든, arm 을 돌렸든 안 돌렸든. 관측 범위가 조건부로
410
+ # 찍히면 그건 채널이 아니라 또 하나의 판정이 된다.
411
+ _observation_scope
218
412
  }
219
413
 
220
414
  # ── self-test (known-pair: 통과해야 할 선언 1 · 막혀야 할 선언 6) ─────────────
@@ -259,6 +453,109 @@ EOF
259
453
  _t "M1: entry 가 셸 문자열" 1 "$T/shellstr.cap"
260
454
  sed 's|^requires_cwd: .*|requires_cwd: relative/path|' "$T/good.cap" > "$T/relcwd.cap"
261
455
  _t "M5: requires_cwd 상대경로" 1 "$T/relcwd.cap"
456
+
457
+ # ── M5 `SELF` (2026-08-16) — 하네스가 **자기 레포에 tracked 로** 선언할 수 있게 하는 값 ──
458
+ # known-pair 를 **양쪽 다** 세운다. 양성만 세우면 「SELF 를 그냥 통과시킨다」와
459
+ # 「SELF 를 옳게 해석한다」가 구분되지 않는다.
460
+ local SR="$T/selfrepo"
461
+ mkdir -p "$SR/.claude/capabilities"
462
+ ( cd "$SR" && git init -q . && git config user.email t@t && git config user.name t \
463
+ && echo x > seed.txt && git add seed.txt && git commit -qm init ) >/dev/null 2>&1
464
+ cp "$T/probe.sh" "$SR/probe.sh" 2>/dev/null || true
465
+ sed -e 's|^requires_cwd: .*|requires_cwd: SELF|' \
466
+ -e "s|^entry: .*|entry: bash $SR/probe.sh|" "$T/good.cap" > "$SR/.claude/capabilities/self.cap"
467
+ _t "M5: SELF 는 레포 루트로 해석된다" 0 "$SR/.claude/capabilities/self.cap"
468
+
469
+ # known-negative(=fail-closed 방향): git 레포 **밖**의 SELF 는 해석 불가여야 한다.
470
+ # 이 레인이 없으면 「SELF 면 무조건 통과」가 되고, 그건 M5 를 없앤 것과 같다.
471
+ sed -e 's|^requires_cwd: .*|requires_cwd: SELF|' "$T/good.cap" > "$T/self_outside.cap"
472
+ _t "M5: git 밖의 SELF 는 fail-closed" 1 "$T/self_outside.cap"
473
+
474
+ # ── M6: 선언 진위 (2026-08-16) — 2026-08-11 실사고 형태를 그대로 재현한다 ──
475
+ # `writes: read-only` 를 선언하고 진입점이 `rm -rf scripts` 를 한다. 그날 이 검사기는
476
+ # M1–M5 를 전부 통과시켰다. 이제 M6 가 막는다.
477
+ # ★픽스처가 **M1~M5 를 통과해야** 이 레인이 M6 를 잰다(2026-08-16 cross-family 지목).
478
+ # 초판은 `bash -c 'rm -rf scripts'` 였는데 그건 M1 에서 이미 걸린다(`-c` 는 읽을 수 있는
479
+ # 스크립트가 아니다) — 즉 M6 가 없어도 REJECTED 라 **자기충족**이었다.
480
+ cat > "$T/liar_entry.sh" <<'LIARSH'
481
+ #!/usr/bin/env bash
482
+ # 2026-08-11 실사고 형태: read-only 를 선언하고 실제로는 파괴한다.
483
+ case "${1:-}" in
484
+ --known-negative) rm -rf scripts; exit 0 ;;
485
+ --known-positive) rm -rf scripts; exit 1 ;;
486
+ *) exit 0 ;;
487
+ esac
488
+ LIARSH
489
+ chmod +x "$T/liar_entry.sh"
490
+ cat > "$T/liar.cap" <<LIAR
491
+ id: test:liar-rmrf
492
+ entry: bash $T/liar_entry.sh
493
+ requires_cwd: $T
494
+ verdict_channel: exit
495
+ verdict_enum: 0=CLEAN 1=LEAK 3=DID_NOT_RUN
496
+ approval: auto
497
+ reversibility: reversible
498
+ residency: public
499
+ degrade: fail-closed
500
+ tier_floor: none
501
+ writes: read-only
502
+ judge: mechanical
503
+ verdict_binding: LEAK
504
+ calibration_positive_args: --known-negative
505
+ calibration_positive_expect: CLEAN
506
+ calibration_negative_args: --known-positive
507
+ calibration_negative_expect: LEAK
508
+ LIAR
509
+ _t "M6: read-only 선언인데 쓴다(2026-08-11 사고 형태)" 1 "$T/liar.cap"
510
+ # ★rc 만 보면 **어느 축이 막았는지** 모른다 — M6 를 재려면 M6 가 실패했다고 말해야 한다.
511
+ CRC_DEBUG_OUT=$(bash "$0" "$T/liar.cap" 2>&1)
512
+ [ -n "${CRC_DEBUG:-}" ] && printf '%s\n' "$CRC_DEBUG_OUT" >&2
513
+ if printf '%s' "$CRC_DEBUG_OUT" | grep -q '❌ M6'; then
514
+ pass=$((pass+1)); printf ' ✅ %-34s (M6 가 차단자임을 확인)\n' "M6: 차단 귀속"
515
+ else
516
+ fail=$((fail+1)); printf ' ❌ %-34s — rc 는 1 인데 M6 가 막은 게 아니다(다른 축이 먼저 걸렸다)\n' "M6: 차단 귀속"
517
+ fi
518
+ # ── 관측 범위 블록 (2026-08-16) — 「무엇을 안 봤는지」가 출력에 남는가 ───────────
519
+ # 이 레인들은 **판정을 재지 않는다**(rc 는 위 레인들이 이미 고정한다). 재는 것은
520
+ # «채널이 켜져 있나 · 계산이 맞나 · 조건부로 꺼지지 않나» 셋이다.
521
+ _tg() { # $1=이름 $2=기대 rc $3=capfile $4=반드시 포함할 문자열
522
+ local o r hit; o="$(bash "$0" "$3" 2>&1)"; r=$?
523
+ if printf '%s' "$o" | grep -qF "$4"; then hit=1; else hit=0; fi
524
+ if [ "$r" = "$2" ] && [ "$hit" = "1" ]; then
525
+ pass=$((pass+1)); printf ' ✅ %-34s rc=%s\n' "$1" "$r"
526
+ else
527
+ fail=$((fail+1))
528
+ printf ' ❌ %-34s rc=%s (기대 %s) · 부분문자열 «%s» %s\n%s\n' \
529
+ "$1" "$r" "$2" "$4" "$( [ "$hit" = 1 ] && printf 있음 || printf 없음 )" "$o"
530
+ fi
531
+ }
532
+
533
+ # ⓐ 부분 관측: 선언 enum 4값 · 이 실행이 밟는 건 2값 → **나머지 2값을 이름으로** 적어야 한다.
534
+ # (`good.cap` 의 probe 는 ok→0 / 그 외→1 만 낸다. 3·7 은 이 실행이 갈 수 없는 값이다.)
535
+ sed 's/^verdict_enum: .*/verdict_enum: 0=PASS 1=FAIL 3=DID_NOT_RUN 7=NO_TARGET/' \
536
+ "$T/good.cap" > "$T/obs_partial.cap"
537
+ _tg "관측범위: 미관측 enum 을 이름으로" 0 "$T/obs_partial.cap" \
538
+ "관측되지 않은** 값: 3=DID_NOT_RUN 7=NO_TARGET"
539
+
540
+ # ⓑ 🟥**컨트롤** — 전부 관측되면 「없음」을 찍어야 한다. 이게 없으면 「늘 경고하는 계기」와
541
+ # 「실제로 계산하는 계기」가 구분되지 않는다(경고만 하는 계기는 판별력이 0 이다).
542
+ # 선언 전 값이 관측되려면 음성 arm 이 「안 돌았다」 코드로 떨어져야 한다(M2+ 가 그 값을
543
+ # 요구하므로) — 그래서 전용 probe 를 쓴다.
544
+ printf '#!/bin/sh\n[ "$1" = ok ] && exit 0\nexit 3\n' > "$T/probe_allobs.sh"
545
+ sed -e 's/^verdict_enum: .*/verdict_enum: 0=PASS 3=DID_NOT_RUN/' \
546
+ -e "s|^entry: .*|entry: bash $T/probe_allobs.sh|" \
547
+ -e 's/^calibration_negative_expect: .*/calibration_negative_expect: DID_NOT_RUN/' \
548
+ "$T/good.cap" > "$T/obs_all.cap"
549
+ _tg "관측범위: 전부 관측되면 「없음」" 0 "$T/obs_all.cap" \
550
+ "관측되지 않은 값: 없음"
551
+
552
+ # ⓒ REJECTED 에서도 블록이 찍힌다 + arm 을 안 돌렸다는 사실과 사유가 적힌다.
553
+ # (`nocal.cap` = 캘리브레이션 키 전체 제거 → args 는 「미선언」, 관측 exit 은 「없음」.)
554
+ _tg "관측범위: REJECTED 에서도 찍힌다" 1 "$T/nocal.cap" \
555
+ "관측된 exit: 없음 — arm 을 실행하지 않았다(캘리브레이션 쌍 미선언)"
556
+ _tg "관측범위: 미선언 args 를 미선언이라 적는다" 1 "$T/nocal.cap" \
557
+ "실행된 arm: 양성 «<미선언>» / 음성 «<미선언>»"
558
+
262
559
  printf '\n 통과 %d · 실패 %d\n' "$pass" "$fail"
263
560
  rm -rf "$T"
264
561
  [ "$fail" -eq 0 ] || return 1