@chrono-meta/fh-gate 2.0.0 → 2.1.0

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 (56) 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/rules/fh_4axis_gate.md +77 -1
  7. package/.claude-plugin/marketplace.json +3 -3
  8. package/AGENTS.md +25 -3
  9. package/CHEATSHEET.md +17 -1
  10. package/CLAUDE.md +241 -5
  11. package/README.ja.md +55 -20
  12. package/README.ko.md +86 -20
  13. package/README.md +57 -20
  14. package/README.zh.md +49 -19
  15. package/knowledge/shared/harness-core/capability_composition_contract.md +68 -0
  16. package/knowledge/shared/harness-core/fh_three_layer_canon.md +122 -5
  17. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +115 -3
  18. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +83 -1
  19. package/knowledge/shared/harness-core/harness_terminal_correlation_and_recommendations.md +255 -0
  20. package/knowledge/shared/harness-core/harness_verification_core_extended.md +1 -1
  21. package/knowledge/shared/harness-core/ship_readiness_gate.md +234 -11
  22. package/knowledge/shared/learnings/subagent_invocations_log.yaml +128 -0
  23. package/knowledge/shared/rules/sister_asset_protocol.md +12 -0
  24. package/package.json +12 -3
  25. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  26. package/plugins/fh-commons/agents/quench-challenger.md +6 -1
  27. package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
  28. package/plugins/fh-meta/CHANGELOG.md +60 -0
  29. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +14 -2
  30. package/plugins/fh-meta/skills/steel-quench/SKILL_detail.md +13 -0
  31. package/plugins/fh-meta/skills/verify-bidirectional/SKILL.md +34 -1
  32. package/scripts/adapters/gstack_content_safety.sh +186 -0
  33. package/scripts/adapters/mate_agent_boundary.sh +120 -0
  34. package/scripts/adapters/peer_resolve.sh +99 -0
  35. package/scripts/adapters/qasp_new_code_anchor.sh +159 -0
  36. package/scripts/branch_claim.sh +14 -0
  37. package/scripts/capability_effect_probe.sh +340 -20
  38. package/scripts/capability_registry_check.sh +271 -20
  39. package/scripts/chamber_run.sh +24 -3
  40. package/scripts/chamber_witness.sh +25 -0
  41. package/scripts/cluster_capability_scan.sh +629 -0
  42. package/scripts/fh-gate.sh +16 -1
  43. package/scripts/fh_session_load.sh +70 -0
  44. package/scripts/package_coverage_check.sh +10 -0
  45. package/scripts/portability_lint.sh +257 -0
  46. package/scripts/prepublish_scope_note.sh +139 -0
  47. package/scripts/relay_channel.sh +70 -7
  48. package/scripts/selfcheck.sh +167 -5
  49. package/scripts/test_adapter_lanes.sh +296 -0
  50. package/scripts/test_capability_entrypoint_shipping.sh +8 -1
  51. package/scripts/test_fh_gate_regressions.sh +21 -9
  52. package/scripts/test_marker_axes_run_lanes.sh +87 -3
  53. package/scripts/test_marker_crossfamily_lanes.sh +31 -1
  54. package/scripts/test_relay_channel_lanes.sh +76 -0
  55. package/templates/.git-hooks/pre-commit +239 -12
  56. package/templates/subagent-tally-hook.json +2 -2
@@ -51,7 +51,10 @@ set -f # noglob — 선언 파일의 값은 데이터지 파일 패턴이 아
51
51
 
52
52
  RC_OK=0; RC_REJECT=1; RC_HARNESS=10
53
53
 
54
- 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 calibration_positive_stdin calibration_negative_stdin"
55
58
 
56
59
  # 「안 돌았다」를 뜻하는 이름들 — 추가조항(§ⓑ.4 B1)이 요구하는 구분항
57
60
  DIDNOTRUN_NAMES="DID_NOT_RUN DIDNOTRUN NOT_RUN NO_TARGET SKIPPED UNMEASURED NOT_CONFIGURED HARNESS_ERROR"
@@ -67,7 +70,13 @@ _parse() {
67
70
  CAP_id=""; CAP_entry=""; CAP_requires_cwd=""; CAP_verdict_channel=""
68
71
  CAP_verdict_enum=""; CAP_verdict_stdout_key=""; CAP_judge=""; CAP_writes=""
69
72
  CAP_cal_pos_args=""; CAP_cal_pos_expect=""; CAP_cal_neg_args=""; CAP_cal_neg_expect=""
70
- UNKNOWN_KEYS=""
73
+ # 다중 capfile 실행에서 앞 파일의 선언이 뒤 파일로 새지 않게 한다 — 초기화 누락은
74
+ # "뒤 파일이 선언하지 않은 stdin 으로 돌았다" 를 만들고, 그건 조용히 통과한다.
75
+ CAP_cal_pos_stdin=""; CAP_cal_neg_stdin=""
76
+ UNKNOWN_KEYS=""; CAP_requires_cwd_was_self=0
77
+ # 「선언했는데 값이 비었다」와 「선언 자체가 없다」는 다른 사실이다 — 관측 범위 블록이
78
+ # 둘을 구분해 적으려면 파서가 그 구분을 보존해야 한다(빈 문자열 하나로는 못 나눈다).
79
+ CAP_cal_pos_args_seen=0; CAP_cal_neg_args_seen=0
71
80
  local line key val
72
81
  while IFS= read -r line || [ -n "$line" ]; do
73
82
  case "$line" in ''|'#'*) continue ;; esac
@@ -84,9 +93,11 @@ _parse() {
84
93
  requires_cwd) CAP_requires_cwd="$val" ;; verdict_channel) CAP_verdict_channel="$val" ;;
85
94
  verdict_enum) CAP_verdict_enum="$val" ;; verdict_stdout_key) CAP_verdict_stdout_key="$val" ;;
86
95
  judge) CAP_judge="$val" ;; writes) CAP_writes="$val" ;;
87
- calibration_positive_args) CAP_cal_pos_args="$val" ;;
96
+ calibration_positive_args) CAP_cal_pos_args="$val"; CAP_cal_pos_args_seen=1 ;;
88
97
  calibration_positive_expect) CAP_cal_pos_expect="$val" ;;
89
- calibration_negative_args) CAP_cal_neg_args="$val" ;;
98
+ calibration_negative_args) CAP_cal_neg_args="$val"; CAP_cal_neg_args_seen=1 ;;
99
+ calibration_positive_stdin) CAP_cal_pos_stdin="$val" ;;
100
+ calibration_negative_stdin) CAP_cal_neg_stdin="$val" ;;
90
101
  calibration_negative_expect) CAP_cal_neg_expect="$val" ;;
91
102
  esac
92
103
  done < "$1"
@@ -113,23 +124,129 @@ _validate_arm_args() { # $1=args → 셸 메타문자/상위경로 탈출을
113
124
  return 0
114
125
  }
115
126
 
116
- _run_arm() { # $1=extra args ARM_RC / ARM_NAME. 파이프로 읽지 않는다(PIPE-VERDICT).
127
+ # stdin 파일 검증 — args 같은 규율(메타문자·상위경로 탈출 거부) + 실재 확인.
128
+ _validate_arm_stdin() { # $1=선언된 경로(빈 값 허용)
129
+ [ -n "$1" ] || return 0
130
+ case "$1" in
131
+ *'|'*|*';'*|*'&'*|*'>'*|*'<'*|*'`'*|*'$('*|*$'\n'*)
132
+ _fail "M4" "캘리브레이션 stdin 경로에 셸 메타문자가 있다: $1"; return 1 ;;
133
+ *'../'*|/*)
134
+ _fail "M4" "캘리브레이션 stdin 경로가 레포 밖을 가리킨다(상대경로만 허용): $1"; return 1 ;;
135
+ esac
136
+ [ -f "$CAP_requires_cwd/$1" ] || {
137
+ _fail "M4" "캘리브레이션 stdin 파일이 없다: $1 (선언은 실재하는 픽스처를 가리켜야 한다)"; return 1; }
138
+ return 0
139
+ }
140
+
141
+ _run_arm() { # $1=extra args · $2=stdin 파일(선택) → ARM_RC / ARM_NAME. 파이프로 읽지 않는다(PIPE-VERDICT).
142
+ # ── STDIN 은 항상 정의된다. 상속하지 않는다. ────────────────────────────────
143
+ # 실측 2026-08-16 (자기선언 캠페인, the-bible arm): 이 함수는 `> /dev/null 2>&1` 로
144
+ # stdout/stderr 만 막고 **stdin 은 검사기의 것을 물려받았다.** 결과가 두 가지로 나빴다.
145
+ # ⓐ **비결정**: 같은 선언이 터미널·파이프·CI 어디서 돌리느냐에 따라 arm 의 입력이 달라진다.
146
+ # 판정 계기 안의 비결정은 그 자체로 결함이다 — 초록의 이유가 실행 환경에 달리게 된다.
147
+ # ⓑ **표현 불가**: 대상을 stdin 으로 받는 진입점(JSON 브리지 등)은 양·음 arm 이 **둘 다 빈
148
+ # 입력**으로 돌아 같은 코드를 내고, 판별 실패로 REJECT 된다. 능력이 없어서가 아니라
149
+ # **스키마에 채널이 없어서** 막힌 것이다. 실제로 4방향으로 갈리는 게이트가 그렇게 막혔다.
150
+ # ⇒ 선언된 파일이 있으면 그걸 먹이고, 없으면 **명시적으로 /dev/null** 을 먹인다. 후자가
151
+ # 중요하다 — "선언 안 했으니 상속"이 아니라 "선언 안 했으면 빈 입력"이라고 **정해야** ⓐ 가 닫힌다.
152
+ # 🟥 `set --` 는 위치 인자를 **덮어쓴다** — 그 뒤의 `$2` 는 stdin 파일이 아니라 entry 의 두 번째
153
+ # 토큰이다. 초판이 그걸 밟았고, 그 결과 두 arm 이 **진입점 스크립트 자신을 stdin 으로 먹었다.**
154
+ # 발견 경위가 이 수정의 요점이다: 그때 **음성 arm 만 빨개졌고 양성 arm 은 통과했다** — 스크립트
155
+ # 소스에 마침 양성 픽스처와 같은 토큰이 들어 있어서다. 즉 **둘 다 틀린 입력을 먹었는데 하나가
156
+ # 우연히 기대값과 맞았다.** 한쪽만 보고 있었으면 «양성은 되는데 음성이 이상하다»로 오진했다.
157
+ # ⇒ set -- 앞에서 이름 있는 변수로 잡는다.
158
+ local _stdin="${2:-}"
117
159
  ( cd "$CAP_requires_cwd" 2>/dev/null || exit 127
118
160
  # shellcheck disable=SC2086 # argv 토큰 분리는 의도 (noglob 로 확장은 막혀 있다)
119
- set -- $CAP_entry $1; "$@" ) > /dev/null 2>&1
161
+ set -- $CAP_entry $1
162
+ if [ -n "$_stdin" ]; then "$@" < "$_stdin"; else "$@" < /dev/null; fi
163
+ ) > /dev/null 2>&1
120
164
  ARM_RC=$?
121
165
  ARM_NAME="$(_enum_name_of "$ARM_RC" "$CAP_verdict_enum")"
122
166
  }
123
167
 
168
+ # ── 관측 범위 블록 (2026-08-16) — «판정» 이 아니라 «채널» ────────────────────────
169
+ #
170
+ # 🟥 이 블록은 **exit code 에 아무 영향을 주지 않는다.** `_fail` 을 부르지 않고, 어떤 축도
171
+ # 새로 판정하지 않는다. 순수 정보 추가다 — 이 레포의 규율("기계는 비가역 경계와 채널에만,
172
+ # 판단은 안 짓는다")대로, 여기서 하는 일은 **무엇을 관측했고 무엇을 안 했는지 적는 것**뿐이다.
173
+ #
174
+ # 왜 필요한가 — 2026-08-16 실측 두 건이 같은 얼굴을 하고 있었다:
175
+ # ① **M4 는 enum↔구현 일치를 구조적으로 안 본다.** 같은 stale 선언이 캘리브레이션 쌍에
176
+ # 따라 판정이 갈린다 — 쌍이 우연히 «선언 밖 exit» 을 밟으면 REJECTED, 구 enum 안에서만
177
+ # 갈리면 M1–M6 전부 통과. 즉 M4 는 «자기가 부른 두 번의 실행에서 본 것» 만 판정한다.
178
+ # ② **M6 도 선언된 arm 만 관측한다.** `pmh-dev:merge-noop-check` 는 분기 입력에서 git 객체를
179
+ # 쓰지만(격리 클론 실측: `.git/objects` 2145 → 2146), 선언된 두 arm 이 그 경로를 안 지나므로
180
+ # `writes: read-only` 로 **거짓 선언해도 통과한다**(실측 확인).
181
+ #
182
+ # 둘 다 «판정이 틀렸다» 가 아니라 **«판정의 관측 범위가 출력에 안 적혀 있다»** 이다. 그래서
183
+ # 고치는 방향은 새 판정 축이 아니라 이 블록이다: 읽는 사람이 초록을 «선언 전체가 참이다» 로
184
+ # 읽지 않게, 이 실행이 실제로 밟은 곳을 이름으로 적는다.
185
+ # ★ 「없음」도 **반드시 찍는다.** 침묵은 «관측되지 않은 값이 없다» 와 «계산하지 않았다» 를
186
+ # 구분하지 못한다 — 미측정을 0 으로 렌더하지 않는다([[feedback_not_found_is_not_zero_family]]).
187
+ _observation_scope() {
188
+ printf ' ── 관측 범위 (이 판정이 «무엇을 안 봤는지») ──\n'
189
+ printf ' M4/M6 는 **선언된 캘리브레이션 arm 의 실행에서 본 것만** 판정한다.\n'
190
+
191
+ local _pa _na
192
+ if [ "${CAP_cal_pos_args_seen:-0}" = "1" ]; then
193
+ [ -n "$CAP_cal_pos_args" ] && _pa="$CAP_cal_pos_args" || _pa='<빈 값 — 인자 없이 실행>'
194
+ else _pa='<미선언>'; fi
195
+ if [ "${CAP_cal_neg_args_seen:-0}" = "1" ]; then
196
+ [ -n "$CAP_cal_neg_args" ] && _na="$CAP_cal_neg_args" || _na='<빈 값 — 인자 없이 실행>'
197
+ else _na='<미선언>'; fi
198
+ printf ' 실행된 arm: 양성 «%s» / 음성 «%s»\n' "$_pa" "$_na"
199
+
200
+ if [ "${OBS_STATE:-none}" = "run" ]; then
201
+ printf ' 관측된 exit: 양성 rc=%s(%s)%s / 음성 rc=%s(%s)\n' \
202
+ "$OBS_POS_RC" "${OBS_POS_NAME:-<enum 밖>}" \
203
+ "$( [ "$OBS_POS_RC" = "$OBS_POS_RC2" ] && printf ' ×2회 동일' || printf ' ×2회째 rc=%s(갈림)' "$OBS_POS_RC2" )" \
204
+ "$OBS_NEG_RC" "${OBS_NEG_NAME:-<enum 밖>}"
205
+ else
206
+ printf ' 관측된 exit: 없음 — arm 을 실행하지 않았다(%s)\n' "${OBS_WHY:-사유 미기록}"
207
+ fi
208
+
209
+ # 미관측 enum = 선언 enum − 이 실행에서 실제로 나온 rc. **계산해서** 적는다.
210
+ if [ -z "$CAP_verdict_enum" ]; then
211
+ printf ' ⚠️ 선언된 enum 이 없어 「관측되지 않은 값」을 계산할 수 없다(미측정이지 0 이 아니다 — M2 가 이미 실패로 기록했다)\n'
212
+ else
213
+ local _pair _code _unobs="" _seenrc=""
214
+ [ "${OBS_STATE:-none}" = "run" ] && _seenrc=" $OBS_POS_RC $OBS_POS_RC2 $OBS_NEG_RC "
215
+ for _pair in $CAP_verdict_enum; do
216
+ _code="${_pair%%=*}"
217
+ case "$_seenrc" in *" $_code "*) ;; *) _unobs="$_unobs $_pair" ;; esac
218
+ done
219
+ if [ -n "$_unobs" ]; then
220
+ printf ' ⚠️ 선언된 enum 중 이 실행에서 **관측되지 않은** 값:%s\n' "$_unobs"
221
+ else
222
+ printf ' · 선언된 enum 중 이 실행에서 관측되지 않은 값: 없음 (선언된 전 값이 이 실행에서 관측됐다)\n'
223
+ fi
224
+ fi
225
+ printf ' ⚠️ 이 실행이 지나지 않은 코드 경로의 부작용(M6 의 `writes:` 축 포함)은 관측 대상이 아니다.\n'
226
+ }
227
+
124
228
  _check_one() {
125
229
  local f="$1"
126
230
  [ -r "$f" ] || _die "capfile 도달 불가: $f"
127
231
  _parse "$f"
128
232
  FILE_FAILED=0
233
+ # 관측 원장 — 「안 돌았다」가 디폴트다. 실행한 arm 만 이 값들을 채운다.
234
+ OBS_STATE=none; OBS_WHY=""; OBS_POS_RC=""; OBS_POS_RC2=""; OBS_NEG_RC=""
235
+ OBS_POS_NAME=""; OBS_NEG_NAME=""
129
236
  printf '\n── %s (%s)\n' "${CAP_id:-<id 미선언>}" "$f"
130
237
 
131
238
  [ -n "$UNKNOWN_KEYS" ] && _fail "SCHEMA" "닫힌 키 목록 밖:$UNKNOWN_KEYS (오타 축 무음드롭 방지 — 무시하지 않는다)"
132
239
 
240
+ # ── `requires_cwd: SELF` 를 **M1 보다 먼저** 푼다 (2026-08-16 순서 수리) ────
241
+ # 초판은 M5 에서 풀었는데, M1 이 그보다 먼저 돌면서 **상대 entry 를 현재 cwd 기준으로** 찾았다.
242
+ # FH 로컬 등록부 시절엔 entry 가 늘 절대경로라 안 터졌다 — 하네스 자기 선언(상대 entry +
243
+ # SELF)이 그 잠재 가정을 처음 실행에 노출시킨 것이다([[feedback_wiring_surfaces_hidden_failures]]).
244
+ # 해석 실패는 여기서 조용히 넘기고 M5 가 판정한다(한 결함을 두 축에서 두 번 세지 않는다).
245
+ if [ "$CAP_requires_cwd" = "SELF" ]; then
246
+ local _sr; _sr="$(git -C "$(dirname "$f")" rev-parse --show-toplevel 2>/dev/null)"
247
+ [ -n "$_sr" ] && [ -d "$_sr" ] && CAP_requires_cwd="$_sr" && CAP_requires_cwd_was_self=1
248
+ fi
249
+
133
250
  # ── M1 실행 가능한 진입점 ──────────────────────────────────────────────────
134
251
  local first second
135
252
  first="$(printf '%s' "$CAP_entry" | awk '{print $1}')"
@@ -139,8 +256,15 @@ _check_one() {
139
256
  _fail "M1" "entry 가 argv 가 아니라 셸 문자열이다(파이프/리다이렉트/치환 포함) — 인젝션 표면" ;;
140
257
  '') _fail "M1" "entry 미선언" ;;
141
258
  *)
259
+ # 상대 경로는 **선언된 cwd 기준**으로도 본다. 선언은 자기 레포를 기준으로 쓰는 것이
260
+ # 자연스럽고(그래야 이식된다), 검사기가 자기 cwd 로만 보면 정당한 선언을 못 읽는다.
261
+ local _second_abs="$second"
262
+ case "$second" in
263
+ /*) ;;
264
+ *) [ -n "$CAP_requires_cwd" ] && [ -r "$CAP_requires_cwd/$second" ] && _second_abs="$CAP_requires_cwd/$second" ;;
265
+ esac
142
266
  if [ -x "$first" ] 2>/dev/null; then _ok "M1" "실행 가능: $first"
143
- elif command -v "$first" >/dev/null 2>&1 && [ -r "$second" ]; then
267
+ elif command -v "$first" >/dev/null 2>&1 && [ -r "$_second_abs" ]; then
144
268
  _ok "M1" "선언된 인터프리터($first) + 읽을 수 있는 스크립트: $second"
145
269
  else _fail "M1" "entry 를 셸이 모델 없이 실행할 수 없다: $CAP_entry"; fi ;;
146
270
  esac
@@ -170,11 +294,35 @@ _check_one() {
170
294
  # ── M3 모델 독립성 (선언 검사 + 명백한 모순만) ─────────────────────────────
171
295
  case "$CAP_judge" in
172
296
  mechanical)
173
- case "$CAP_entry" in
174
- *claude*|*codex*|*gemini*|*copilot*|*ollama*|*llm*)
175
- _fail "M3" "judge: mechanical 선언인데 entry 모델 CLI 부른다: $CAP_entry" ;;
176
- *) _ok "M3" "judge: mechanical (모델 미개입 선언)" ;;
177
- esac ;;
297
+ # 토큰의 BASENAME 으로 본다 — 문자열 전체 부분매칭이 아니다.
298
+ #
299
+ # 실측 2026-08-16 (자기선언 캠페인, qasp-dev arm 지목): 검사는 `*claude*` 처럼
300
+ # entry **문자열 전체**에 부분매칭했다. 그래서 진입점이 모델을 전혀 안 부르는데도
301
+ # **경로에 그 단어가 있다는 이유만으로** REJECT 났다 — 실제 사례는 스크래치패드 경로
302
+ # `/private/tmp/claude-501/...` 였고, 경로만 바꾸니 같은 선언이 PASS 했다.
303
+ # 구조적으로 더 나쁜 경우가 둘 있다: ⓐ 진입점이 `.claude/` 아래 사는 하네스는 **전부**
304
+ # 막힌다(FH 자기 선언이 `.claude/capabilities/` 에 사는 걸 생각하면 남 얘기가 아니다)
305
+ # ⓑ `scripts/claude_md_lint.sh` 처럼 이름에 그 단어가 든 순수 기계 스크립트도 막힌다.
306
+ #
307
+ # 과차단은 «안전한 방향»이 아니다 — 정본이 명시하듯 override 를 습관화시켜 같은 훅의
308
+ # 다른 게이트까지 무장해제시킨다. 그리고 이 오탐은 **무음**이었다: 거부 사유가
309
+ # "모델 CLI 를 부른다" 로 찍히므로, 읽는 사람은 자기 진입점을 의심하지 경로를 의심하지 않는다.
310
+ #
311
+ # 이 검사가 실제로 답할 수 있는 질문은 «entry 줄이 모델 CLI 를 **직접 이름으로** 부르는가»
312
+ # 뿐이다. 래퍼 안에서 부르는 건 선언 층에서 원래 못 본다(M6 효과 프로브의 몫).
313
+ # 그러니 각 토큰의 basename 을 보고, 확장자를 떼고, **정확히 그 이름일 때만** 막는다.
314
+ _m3_bad=""
315
+ for _tok in $CAP_entry; do
316
+ _base="${_tok##*/}"; _base="${_base%.sh}"; _base="${_base%.js}"; _base="${_base%.py}"
317
+ case "$_base" in
318
+ claude|codex|gemini|copilot|ollama|llm) _m3_bad="$_base" ;;
319
+ esac
320
+ done
321
+ if [ -n "$_m3_bad" ]; then
322
+ _fail "M3" "judge: mechanical 선언인데 entry 가 모델 CLI 를 직접 부른다: $_m3_bad ($CAP_entry)"
323
+ else
324
+ _ok "M3" "judge: mechanical (entry 토큰에 모델 CLI 없음 — basename 기준)"
325
+ fi ;;
178
326
  model) _ok "M3" "judge: model — 선언됨(합법). 조합에서 이 PASS 는 NON_CLEARING 이다" ;;
179
327
  '') _fail "M3" "judge 축 미선언 — 모델 개입 여부가 불명이면 조합이 계산될 수 없다" ;;
180
328
  *) _fail "M3" "judge 값이 {mechanical|model} 밖: '$CAP_judge'" ;;
@@ -182,25 +330,65 @@ _check_one() {
182
330
 
183
331
  # ── M5 선언된 cwd (M4 를 그 자리에서 돌리므로 먼저) ────────────────────────
184
332
  case "$CAP_requires_cwd" in
185
- /*) [ -d "$CAP_requires_cwd" ] && _ok "M5" "requires_cwd 실재: $CAP_requires_cwd" \
186
- || _fail "M5" "requires_cwd 절대경로지만 존재하지 않는다: $CAP_requires_cwd" ;;
333
+ /*)
334
+ # `SELF` 위(M1 앞)에서 이미 레포 루트로 풀렸다. 그 사실을 판정문에 남긴다 —
335
+ # 안 남기면 독자가 선언 원문(`SELF`)과 검사 결과(절대경로)를 연결하지 못한다.
336
+ if [ -d "$CAP_requires_cwd" ]; then
337
+ if [ "${CAP_requires_cwd_was_self:-0}" = "1" ]; then
338
+ _ok "M5" "requires_cwd: SELF → 선언을 품은 레포 루트: $CAP_requires_cwd"
339
+ else
340
+ _ok "M5" "requires_cwd 실재: $CAP_requires_cwd"
341
+ fi
342
+ else
343
+ _fail "M5" "requires_cwd 가 절대경로지만 존재하지 않는다: $CAP_requires_cwd"
344
+ fi ;;
345
+ SELF)
346
+ # ★`SELF` = «이 선언을 품은 레포» (2026-08-16 신설, 정체성 ①-(a) cluster-wizard).
347
+ #
348
+ # 왜 필요했나: 이 스키마는 원래 **FH 로컬 등록부**(gitignored)를 위한 것이라 절대경로가
349
+ # 문제없었다. 그런데 (c) 를 닫으려면 선언이 **그 하네스 자신의 레포에 tracked 로** 살아야
350
+ # 하고, 그 순간 절대경로는 두 가지로 깨진다:
351
+ # ① 이식 불가 — 다른 머신·다른 체크아웃에서 그 경로는 없다
352
+ # ② **공개표면 위반** — 운영자 홈 경로가 tracked 파일에 실린다
353
+ # 그래서 «자기 자신» 을 가리키는 이식 가능한 표현이 필요했다.
354
+ #
355
+ # 해석은 **위치 가정이 아니라 git 레포 루트**다. `../../..` 같은 상대 오프셋으로 풀면
356
+ # `.claude/capabilities/` 배치를 하드코딩하게 되고, 배치가 바뀌는 순간 조용히 엉뚱한
357
+ # 디렉토리를 가리킨다. 레포 루트는 그 파일이 어디 놓이든 같은 답을 준다.
358
+ local _self_root
359
+ _self_root="$(git -C "$(dirname "$f")" rev-parse --show-toplevel 2>/dev/null)"
360
+ if [ -n "$_self_root" ] && [ -d "$_self_root" ]; then
361
+ CAP_requires_cwd="$_self_root"
362
+ _ok "M5" "requires_cwd: SELF → 선언을 품은 레포 루트로 해석: $_self_root"
363
+ else
364
+ # 🟥 fail-closed. git 레포 밖의 선언은 «자기» 가 무엇인지 정의되지 않는다 —
365
+ # 추측해서 cwd 를 정하면 M4 가 **엉뚱한 트리에서** 진입점을 돌린다.
366
+ _fail "M5" "requires_cwd: SELF 인데 이 선언이 git 레포 안에 없다 — «자기» 를 해석할 수 없다"
367
+ fi ;;
187
368
  '') _fail "M5" "requires_cwd 미선언 — 콕핏이 어디서 부를지 알 수 없다" ;;
188
- *) _fail "M5" "requires_cwd 가 절대경로가 아니다: $CAP_requires_cwd" ;;
369
+ *) _fail "M5" "requires_cwd 가 절대경로도 SELF 도 아니다: $CAP_requires_cwd" ;;
189
370
  esac
190
371
 
191
372
  # ── M4 캘리브레이션 쌍 — 선언 + **실행** ──────────────────────────────────
192
373
  if [ -z "$CAP_cal_pos_expect" ] || [ -z "$CAP_cal_neg_expect" ]; then
193
374
  _fail "M4" "캘리브레이션 쌍 미선언(양성·음성 expect 둘 다 필요) — 답을 아는 케이스를 못 가르는 계기는 재는 게 아니다"
375
+ OBS_WHY="캘리브레이션 쌍 미선언"
194
376
  elif [ "$FILE_FAILED" -eq 1 ] && [ -z "${CRC_FORCE_M4:-}" ]; then
195
377
  printf ' ⏭ M4 — 앞선 축이 실패해 실행 생략(SKIPPED, PASS 아님)\n'
196
- elif ! _validate_arm_args "$CAP_cal_pos_args" || ! _validate_arm_args "$CAP_cal_neg_args"; then
378
+ OBS_WHY="앞선 실패로 M4 생략"
379
+ elif ! _validate_arm_args "$CAP_cal_pos_args" || ! _validate_arm_args "$CAP_cal_neg_args" \
380
+ || ! _validate_arm_stdin "${CAP_cal_pos_stdin:-}" || ! _validate_arm_stdin "${CAP_cal_neg_stdin:-}"; then
197
381
  printf ' ⏭ M4 — args 검문 실패로 arm 을 실행하지 않았다(SKIPPED, PASS 아님)\n'
382
+ OBS_WHY="args 검문 실패"
198
383
  else
199
- local pos_name neg_name pos_rc neg_rc pos_name2
200
- _run_arm "$CAP_cal_pos_args"; pos_rc="$ARM_RC"; pos_name="$ARM_NAME"
384
+ local pos_name neg_name pos_rc neg_rc pos_name2 pos_rc2
385
+ _run_arm "$CAP_cal_pos_args" "${CAP_cal_pos_stdin:-}"; pos_rc="$ARM_RC"; pos_name="$ARM_NAME"
201
386
  [ "$pos_rc" = "127" ] && _fail "M4" "requires_cwd 로 진입 실패 — arm 을 돌릴 수 없다"
202
- _run_arm "$CAP_cal_pos_args"; pos_name2="$ARM_NAME" # reps=2 (M3 부분 방어)
203
- _run_arm "$CAP_cal_neg_args"; neg_rc="$ARM_RC"; neg_name="$ARM_NAME"
387
+ _run_arm "$CAP_cal_pos_args" "${CAP_cal_pos_stdin:-}"; pos_name2="$ARM_NAME"; pos_rc2="$ARM_RC" # reps=2 (M3 부분 방어)
388
+ _run_arm "$CAP_cal_neg_args" "${CAP_cal_neg_stdin:-}"; neg_rc="$ARM_RC"; neg_name="$ARM_NAME"
389
+ # 관측 원장에 남긴다 — 판정과 무관하게, 이 실행이 **실제로 밟은 exit** 만 적는다.
390
+ OBS_STATE=run; OBS_POS_RC="$pos_rc"; OBS_POS_RC2="$pos_rc2"; OBS_NEG_RC="$neg_rc"
391
+ OBS_POS_NAME="$pos_name"; OBS_NEG_NAME="$neg_name"
204
392
 
205
393
  if [ -z "$pos_name" ]; then
206
394
  _fail "M4" "양성 arm 의 exit $pos_rc 가 선언된 enum 밖 — enum 밖 값은 HARNESS_ERROR 이지 PASS 가 아니다"
@@ -254,6 +442,7 @@ _check_one() {
254
442
  _hops=$((_hops+1))
255
443
  if [ "$_hops" -gt 40 ]; then
256
444
  _fail "M6" "심링크가 40회를 넘어간다 — 순환으로 본다. 프로브 경로를 못 정한다"
445
+ _observation_scope # 이 이른 return 에서도 관측 범위는 찍힌다(조건부로 찍히면 채널이 아니다)
257
446
  return 0 # _fail 이 이미 기록했다. 판정 불가를 통과로 렌더하지 않는다
258
447
  fi
259
448
  _dir=$(cd -P "$(dirname "$_src")" >/dev/null 2>&1 && pwd) || break
@@ -279,6 +468,10 @@ _check_one() {
279
468
  esac
280
469
  fi
281
470
  fi
471
+
472
+ # ★무조건 찍는다 — PASS 든 REJECTED 든, arm 을 돌렸든 안 돌렸든. 관측 범위가 조건부로
473
+ # 찍히면 그건 채널이 아니라 또 하나의 판정이 된다.
474
+ _observation_scope
282
475
  }
283
476
 
284
477
  # ── self-test (known-pair: 통과해야 할 선언 1 · 막혀야 할 선언 6) ─────────────
@@ -324,6 +517,23 @@ EOF
324
517
  sed 's|^requires_cwd: .*|requires_cwd: relative/path|' "$T/good.cap" > "$T/relcwd.cap"
325
518
  _t "M5: requires_cwd 상대경로" 1 "$T/relcwd.cap"
326
519
 
520
+ # ── M5 `SELF` (2026-08-16) — 하네스가 **자기 레포에 tracked 로** 선언할 수 있게 하는 값 ──
521
+ # known-pair 를 **양쪽 다** 세운다. 양성만 세우면 「SELF 를 그냥 통과시킨다」와
522
+ # 「SELF 를 옳게 해석한다」가 구분되지 않는다.
523
+ local SR="$T/selfrepo"
524
+ mkdir -p "$SR/.claude/capabilities"
525
+ ( cd "$SR" && git init -q . && git config user.email t@t && git config user.name t \
526
+ && echo x > seed.txt && git add seed.txt && git commit -qm init ) >/dev/null 2>&1
527
+ cp "$T/probe.sh" "$SR/probe.sh" 2>/dev/null || true
528
+ sed -e 's|^requires_cwd: .*|requires_cwd: SELF|' \
529
+ -e "s|^entry: .*|entry: bash $SR/probe.sh|" "$T/good.cap" > "$SR/.claude/capabilities/self.cap"
530
+ _t "M5: SELF 는 레포 루트로 해석된다" 0 "$SR/.claude/capabilities/self.cap"
531
+
532
+ # known-negative(=fail-closed 방향): git 레포 **밖**의 SELF 는 해석 불가여야 한다.
533
+ # 이 레인이 없으면 「SELF 면 무조건 통과」가 되고, 그건 M5 를 없앤 것과 같다.
534
+ sed -e 's|^requires_cwd: .*|requires_cwd: SELF|' "$T/good.cap" > "$T/self_outside.cap"
535
+ _t "M5: git 밖의 SELF 는 fail-closed" 1 "$T/self_outside.cap"
536
+
327
537
  # ── M6: 선언 진위 (2026-08-16) — 2026-08-11 실사고 형태를 그대로 재현한다 ──
328
538
  # `writes: read-only` 를 선언하고 진입점이 `rm -rf scripts` 를 한다. 그날 이 검사기는
329
539
  # M1–M5 를 전부 통과시켰다. 이제 M6 가 막는다.
@@ -368,6 +578,47 @@ LIAR
368
578
  else
369
579
  fail=$((fail+1)); printf ' ❌ %-34s — rc 는 1 인데 M6 가 막은 게 아니다(다른 축이 먼저 걸렸다)\n' "M6: 차단 귀속"
370
580
  fi
581
+ # ── 관측 범위 블록 (2026-08-16) — 「무엇을 안 봤는지」가 출력에 남는가 ───────────
582
+ # 이 레인들은 **판정을 재지 않는다**(rc 는 위 레인들이 이미 고정한다). 재는 것은
583
+ # «채널이 켜져 있나 · 계산이 맞나 · 조건부로 꺼지지 않나» 셋이다.
584
+ _tg() { # $1=이름 $2=기대 rc $3=capfile $4=반드시 포함할 문자열
585
+ local o r hit; o="$(bash "$0" "$3" 2>&1)"; r=$?
586
+ if printf '%s' "$o" | grep -qF "$4"; then hit=1; else hit=0; fi
587
+ if [ "$r" = "$2" ] && [ "$hit" = "1" ]; then
588
+ pass=$((pass+1)); printf ' ✅ %-34s rc=%s\n' "$1" "$r"
589
+ else
590
+ fail=$((fail+1))
591
+ printf ' ❌ %-34s rc=%s (기대 %s) · 부분문자열 «%s» %s\n%s\n' \
592
+ "$1" "$r" "$2" "$4" "$( [ "$hit" = 1 ] && printf 있음 || printf 없음 )" "$o"
593
+ fi
594
+ }
595
+
596
+ # ⓐ 부분 관측: 선언 enum 4값 · 이 실행이 밟는 건 2값 → **나머지 2값을 이름으로** 적어야 한다.
597
+ # (`good.cap` 의 probe 는 ok→0 / 그 외→1 만 낸다. 3·7 은 이 실행이 갈 수 없는 값이다.)
598
+ sed 's/^verdict_enum: .*/verdict_enum: 0=PASS 1=FAIL 3=DID_NOT_RUN 7=NO_TARGET/' \
599
+ "$T/good.cap" > "$T/obs_partial.cap"
600
+ _tg "관측범위: 미관측 enum 을 이름으로" 0 "$T/obs_partial.cap" \
601
+ "관측되지 않은** 값: 3=DID_NOT_RUN 7=NO_TARGET"
602
+
603
+ # ⓑ 🟥**컨트롤** — 전부 관측되면 「없음」을 찍어야 한다. 이게 없으면 「늘 경고하는 계기」와
604
+ # 「실제로 계산하는 계기」가 구분되지 않는다(경고만 하는 계기는 판별력이 0 이다).
605
+ # 선언 전 값이 관측되려면 음성 arm 이 「안 돌았다」 코드로 떨어져야 한다(M2+ 가 그 값을
606
+ # 요구하므로) — 그래서 전용 probe 를 쓴다.
607
+ printf '#!/bin/sh\n[ "$1" = ok ] && exit 0\nexit 3\n' > "$T/probe_allobs.sh"
608
+ sed -e 's/^verdict_enum: .*/verdict_enum: 0=PASS 3=DID_NOT_RUN/' \
609
+ -e "s|^entry: .*|entry: bash $T/probe_allobs.sh|" \
610
+ -e 's/^calibration_negative_expect: .*/calibration_negative_expect: DID_NOT_RUN/' \
611
+ "$T/good.cap" > "$T/obs_all.cap"
612
+ _tg "관측범위: 전부 관측되면 「없음」" 0 "$T/obs_all.cap" \
613
+ "관측되지 않은 값: 없음"
614
+
615
+ # ⓒ REJECTED 에서도 블록이 찍힌다 + arm 을 안 돌렸다는 사실과 사유가 적힌다.
616
+ # (`nocal.cap` = 캘리브레이션 키 전체 제거 → args 는 「미선언」, 관측 exit 은 「없음」.)
617
+ _tg "관측범위: REJECTED 에서도 찍힌다" 1 "$T/nocal.cap" \
618
+ "관측된 exit: 없음 — arm 을 실행하지 않았다(캘리브레이션 쌍 미선언)"
619
+ _tg "관측범위: 미선언 args 를 미선언이라 적는다" 1 "$T/nocal.cap" \
620
+ "실행된 arm: 양성 «<미선언>» / 음성 «<미선언>»"
621
+
371
622
  printf '\n 통과 %d · 실패 %d\n' "$pass" "$fail"
372
623
  rm -rf "$T"
373
624
  [ "$fail" -eq 0 ] || return 1
@@ -125,8 +125,11 @@ if [ ! -f "$WS/BUDGET.md" ]; then
125
125
 
126
126
  # Route through goal-quench's budget gate for an expensive run, then record here.
127
127
  # Demo-scale runs may self-cap — but an ESTIMATE line is mandatory (this is the entry cap).
128
+ #
129
+ # 🟥 ACTUAL 은 이 파일에 적지 마라 — step 6 이 별도 ACTUAL.md 를 쓴다.
130
+ # 이유: 이 파일의 판정-전 해시가 순서 증인(ship_readiness_gate §② P1)이다. 사후에
131
+ # 이 파일을 고치면 증인이 TAMPERED 로 죽는다 (2026-08-17 런 #11 실측).
128
132
  ESTIMATE: <e.g. ~3 persona dispatches, demo-scale, self-capped | or a token budget>
129
- ACTUAL: <filled at step 6>
130
133
  EOF
131
134
  echo " ⛔ step 3 BLOCKED: record an ESTIMATE in $WS/BUDGET.md (budget-entry gate G2), then re-run."; exit 1
132
135
  fi
@@ -203,8 +206,26 @@ if [ -f "$_WITNESS" ]; then
203
206
  fi
204
207
 
205
208
  # STEP 6 — actual cost / carry-forward record.
206
- if grep -qE '^ACTUAL:[[:space:]]*<' "$WS/BUDGET.md" || ! grep -qE '^ACTUAL:[[:space:]]*\S' "$WS/BUDGET.md"; then
207
- echo " ⛔ step 6 BLOCKED: record ACTUAL cost in $WS/BUDGET.md (actual-vs-estimate calibration)."
209
+ # 🟥 별도 파일이다. BUDGET.md 아니다 그리고 분리가 이 게이트의 핵심이다.
210
+ # 2026-08-17 #11(첫 형식 완주)이 실측한 결함: step 3 이 BUDGET.md 해시를 순서 증인으로
211
+ # 기록하는데 step 6 이 같은 파일에 ACTUAL 을 요구하며 하드 차단했다 → 완주하면 반드시
212
+ # 증인 아티팩트가 사후 변경되고 verify 가 TAMPERED 를 낸다 → **완주한 런은 구조적으로
213
+ # ② 승급 근거가 될 수 없었다.** 대상 선정 실수가 아니라 한 파일에 두 역할(①불변 요구 =
214
+ # 증인 · ②변경 요구 = 사후 캘리브레이션)이 겹친 것이고, 개별로는 둘 다 옳아서 어느 쪽
215
+ # 코드를 읽어도 안 보였다. 그래서 스펙(P1 이 BUDGET 을 증인으로 지목)은 안 건드리고
216
+ # **변경 요구만** 빼낸다. ACTUAL.md 는 정의상 판정 후 산물이므로 **증인에 기록하지 않는다.**
217
+ if [ ! -f "$WS/ACTUAL.md" ]; then
218
+ cat > "$WS/ACTUAL.md" <<EOF
219
+ # ACTUAL — $SLUG (chamber run, 판정 후 기록)
220
+ #
221
+ # 이 파일은 **순서 증인 대상이 아니다** — 판정 후에 쓰이는 것이 정상이므로 해시를 걸지 않는다.
222
+ # ESTIMATE 는 BUDGET.md 에 있고 그 파일은 판정 전에 얼어 있다. 대조는 사람이 한다.
223
+ ACTUAL: <number/summary — dispatch 수 · 토큰 · 예산 대비 편차>
224
+ EOF
225
+ fi
226
+ if grep -qE '^ACTUAL:[[:space:]]*<' "$WS/ACTUAL.md" || ! grep -qE '^ACTUAL:[[:space:]]*\S' "$WS/ACTUAL.md"; then
227
+ echo " ⛔ step 6 BLOCKED: record ACTUAL cost in $WS/ACTUAL.md (actual-vs-estimate calibration)."
228
+ echo " 🟥 BUDGET.md 가 아니다 — 그 파일은 판정 전 증인이라 사후 변경하면 증인이 죽는다."
208
229
  echo " Expected — a LINE PREFIX, not a heading:"
209
230
  echo " ACTUAL: <number/summary> ← this form is checked"
210
231
  echo " ## ACTUAL ← a heading does NOT satisfy it"
@@ -140,6 +140,31 @@ HDR
140
140
  echo "❌ 원장 헤더 쓰기 실패: $LEDGER" >&2; return 10
141
141
  fi
142
142
  fi
143
+ # ── 멱등: 같은 (run, artifact, sha) 삼중항이 이미 있으면 다시 안 적는다 ──────────
144
+ # 기원 2026-08-17 런 #11(첫 형식 완주): 이 함수가 **무조건 append** 라서 러너를 advance
145
+ # 할 때마다 step 2~5 가 재실행되며 같은 해시가 다시 쌓였다 — 4스텝 런에 **엔트리 11개**.
146
+ # 증인 판정 자체는 안 틀리지만(같은 해시는 같은 결론) 공개 tracked 파일이 부풀고,
147
+ # verify 출력이 같은 줄을 여러 번 뱉어 **읽는 사람이 「몇 건이 문제인가」를 오독**한다.
148
+ # 🟥 내용이 바뀐 경우는 **여전히 새 엔트리로 남는다** — 그게 TAMPERED 를 성립시키는
149
+ # 증거이므로 여기서 접으면 안 된다. 접는 것은 «완전히 동일한 재기록»뿐이다.
150
+ # 🟥 오프셋 주의 — `- run: ` 는 **7자**라 값은 8부터다. 초판이 9로 썼고(cross-family
151
+ # gpt-5.5 적발, A급) 그러면 슬러그 첫 글자가 잘려 **어떤 정상 엔트리와도 매칭되지 않는다**
152
+ # → 가드가 조용히 무력화되고 append 가 계속된다. 재현: `printf -- '- run: g2\n' |
153
+ # awk '{print substr($0,9)}'` → `2`. **레인이 이걸 못 잡았다**(아래 L14 신설로 닫음).
154
+ # 인접성도 강제한다: run → artifact → sha256 이 **연속**일 때만 인정. 안 그러면 깨진
155
+ # YAML 에서 stale run/art 가 무관한 sha 와 짝지어 **필요한 append 를 눌러버린다**(같은 리뷰 B급).
156
+ if awk -v r="$slug" -v a="$base" -v s="$h" '
157
+ /^- run: / { run=substr($0,8); art=""; prev="run"; next }
158
+ /^ artifact: / { if (prev=="run") { art=substr($0,13); prev="art" }
159
+ else { art=""; prev="" } ; next }
160
+ /^ sha256: / { if (prev=="art" && run==r && art==a && substr($0,11)==s) { found=1; exit }
161
+ prev=""; next }
162
+ { prev="" }
163
+ END { exit(found?0:1) }
164
+ ' "$LEDGER" 2>/dev/null; then
165
+ echo "witnessed(already): $slug/$base — 동일 해시가 이미 원장에 있다(재기록 안 함)"
166
+ return 0
167
+ fi
143
168
  if ! printf -- '- run: %s\n artifact: %s\n sha256: %s\n recorded: %s\n' \
144
169
  "$slug" "$base" "$h" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$LEDGER" 2>/dev/null; then
145
170
  echo "❌ 원장 append 실패: $LEDGER" >&2; return 10