@chrono-meta/fh-gate 2.3.0 → 2.5.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 (39) hide show
  1. package/.claude/rules/fh_4axis_gate.md +25 -0
  2. package/.claude-plugin/marketplace.json +2 -2
  3. package/CLAUDE.md +107 -7
  4. package/README.ja.md +14 -2
  5. package/README.ko.md +14 -1
  6. package/README.md +16 -2
  7. package/README.zh.md +12 -1
  8. package/knowledge/shared/harness-core/fh_three_layer_canon.md +18 -6
  9. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +262 -7
  10. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +215 -1
  11. package/knowledge/shared/harness-core/ship_readiness_gate.md +168 -1
  12. package/knowledge/shared/harness-core/sonnet_floor_doctrine.md +16 -0
  13. package/knowledge/shared/learnings/subagent_invocations_log.yaml +120 -0
  14. package/knowledge/shared/rules/multi_session_close_protocol.md +124 -2
  15. package/package.json +8 -1
  16. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  17. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  18. package/plugins/fh-meta/CHANGELOG.md +105 -0
  19. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +20 -0
  20. package/plugins/fh-meta/skills/steel-quench/SKILL.md +25 -2
  21. package/scripts/adapters/mate_agent_boundary.sh +27 -7
  22. package/scripts/capability_effect_probe.sh +173 -5
  23. package/scripts/capability_registry_check.sh +69 -3
  24. package/scripts/digest_landing_check.sh +168 -13
  25. package/scripts/fh_session_load.sh +71 -0
  26. package/scripts/relay_channel.sh +151 -6
  27. package/scripts/selfcheck.sh +47 -0
  28. package/scripts/target_freeze.sh +239 -0
  29. package/scripts/test_adapter_lanes.sh +17 -0
  30. package/scripts/test_evidence_root_psa_lanes.sh +78 -0
  31. package/scripts/test_marker_standpoint_lanes.sh +83 -0
  32. package/scripts/test_marker_thirdparty_lanes.sh +73 -0
  33. package/scripts/test_regression_guard_ci_lanes.sh +170 -0
  34. package/scripts/test_relay_channel_lanes.sh +146 -17
  35. package/scripts/test_satellite_publish_gate_lanes.sh +339 -0
  36. package/scripts/test_target_freeze_lanes.sh +203 -0
  37. package/templates/.git-hooks/pre-commit +306 -6
  38. package/templates/PRE-PUBLISH-CHECKLIST.md +22 -0
  39. package/templates/regression_guard.sh +32 -4
@@ -10,6 +10,111 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
10
10
 
11
11
  ## Plugin Level
12
12
 
13
+ ### [2.5.0] — 2026-08-18
14
+
15
+ **정체성 ④(프런티어→조직 전파)의 «조직 = 레포» 축.** 다이제스트 러너가 FH 한 곳만 대상으로 지어져 있어 ④ 가 «자기 소비» 에 머물렀다. 대상 축을 열고, 그 산출이 **복제가 아니라 응용**이 되도록 프로필을 싣고, 공개 표면과 착지를 각각 기계로 잰다.
16
+
17
+ **위성 — `scripts/frontier_digest_daily.sh`**
18
+ - `FD_OUT_DIR`(대상 레포의 어디에) · `FD_MODEL`(위성별 티어 핀)
19
+ - `FD_PROFILE` — 대상 하네스가 **자기를 설명하는 파일**을 프롬프트에 싣는다. 🟥 없으면 산출은 **복제**다(실측: 대상 어휘 0 · FH 어휘 11 · Route 0). 있으면 **응용**(실측 2대상: 대상 어휘 0→33 · **Route 0→19**). **변인은 프로필 하나였다**
20
+ - `publish_gate()` — 공개 레포 대상이면 **매 런이 publish** 다(비가역). `0 게시 / 1 격리 / 그 외 보류` — 🟥 **세 값을 두 값으로 접지 않는다**
21
+ - `landing_witness()` — 직전 다이제스트 후보의 착지를 매일 기록. 차단하지 않는다(선별기). 프로덕션 호출부 **0 → 3**
22
+ - 앵커: `scripts/test_satellite_publish_gate_lanes.sh` (초판 **11 레인**, 아래 후속 수리로 **22 레인**), 되돌림 다중 arm
23
+
24
+ **착지 검증기 파서 — `scripts/digest_landing_check.sh`**
25
+ - 후보를 **표로만** 읽고 있었다. 실측 60건 중 표는 **4건(6.7%)**. 문단·헤딩·번호 리스트를 더해 **후보 절이 있는 56건 전부** 추출. 판별자 추출은 두 경로가 **공유**(중복 정규화기 방지)
26
+ - 🟥 이 결함은 **호출부가 0 이라 가려져 있었다.** 배선해서 처음 돌린 그 실행이 뱉었다 ⇒ **self-test 만 도는 스크립트는 실물과 어긋나 있어도 초록이다**
27
+
28
+ **규율 — `CLAUDE.md`**
29
+ - **§Expedition**: 원정 개시에 **«과녁 정체성» 한 줄** 의무. 근거는 1차 ⓐ 채점 **0건**이고 원인이 노력이 아니라 **겨냥**이었다(세 갈래가 전부 이미 🟢 인 정체성 위에 떨어졌다). 주기는 2차 이후로 연기, **계측 3항을 방법까지** 명시(거버너 토큰은 `UNMEASURED` 를 이름으로 남기고 **합계 금지**)
30
+ - **§AI Contribution Model**: 기본 워크플로에 **claim** 단계 + `scripts/branch_claim.sh` 를 이름으로. 기계는 이미 있었는데 산문이 그 이름을 안 불러 두 세션이 «없다»로 결론했다
31
+
32
+ **게이트 (peer 동시 착지분 #447·#448)**
33
+ ```
34
+ BREAKING (gate): edit_manifest.yaml 에 `date` 만 있는 엔트리가 있으면 커밋이 차단된다
35
+ — 동시 append 로 손상된 매니페스트의 지문이다(종전엔 무음 통과).
36
+ remedy: 훅이 지목한 엔트리를 손으로 확인해 필드를 복구하거나 제거한다.
37
+ ```
38
+ 실측: 이 레포 매니페스트 411건 중 date-only **0건** — **건강한 매니페스트를 가진 소비자는 안 막힌다.** 막히는 것은 동시 append 로 손상된 경우이고, 그 손상은 종전에 **조용히 통과**했다.
39
+
40
+ **동작 변경(차단 아님)**: 워크트리에서 FH 자산 커밋이 **가능해진다**(evidence-root 를 `--git-common-dir` 로 해석). 종전엔 `tracks/` 가 안 따라와 Axis 2+3·Axis 4 가 구조적으로 부재했다 — 즉 이 항목은 **완화**다.
41
+ ⚠️ **위 둘은 같은 사건의 앞뒤다.** 경로 변경으로 워크트리 소비자에게 게이트가 **처음 도달**하는데, 그 순간 `core.hooksPath` 가 **상대 경로**면 워크트리가 **자기 훅 사본**을 돌아 자기무력화가 가능하다.
42
+ 권고: `git config core.hooksPath "$(git rev-parse --show-toplevel)/templates/.git-hooks"`
43
+
44
+ **같은 날 후속 수리 — 위 배선이 만든 결함 5 + 타계열 3 (#452)**
45
+ 🟥 위 항목들을 「배선 완료」로 보고한 뒤 **되돌림 프로브·첫 실사용·타계열 패널**이 다음을 찾았다.
46
+ - **착지 증언이 배선 첫날부터 죽어 있었다** — 러너가 checker 에 **디렉터리를 «타깃 파일»로** 넘겨 매 런 `rc=10`. 손으로 잰 형태(1인자)와 러너가 부르는 형태(2인자)가 달랐다. 컨트롤 토큰도 하드코딩이라 대상 레포에선 고쳐도 죽은 채였다 ⇒ 스코프·컨트롤을 `DLC_*` 로 외부화, 유도 기본값은 **리터럴**(레포명에 정규식 메타문자가 있으면 «아무거나 매치»했다)
47
+ - **「직전 다이제스트」를 로케일 `sort` 로 골라 두 달 전 파일을 집었다** — 실물 코퍼스에 `2026-06-02`(대시)와 `2026_08_17`(언더바)가 섞여 있다 ⇒ `LC_ALL=C`. 🟥 **이건 레인이 아니라 첫 실사용이 잡았다**(그 시점 레인 21개는 전부 초록)
48
+ - **`publish_gate` 가 `head -1` 로 한 파일만 스캔**했고, 선택자가 `digest_ready` 와 갈려 있었다 ⇒ 단일 선택자 + 전 파일 순회 + 최악값. 선택자 자기모순은 **게시가 아니라 보류(3)**
49
+ - **공개 대상 트리 «안»에 로그를 팠고 findings 가 토큰 원문을 찍었다** ⇒ 트리 밖 + 리댁션 3단(인용 스팬·row 꼬리·경로). ⚠️ **이 러너는 출하 대상이 아니므로 npm 소비자에겐 해당 없다** — 러너를 복사해 쓰는 위성에서만 참이다
50
+ - **`pre-commit` 이 gitignored 패턴 오버라이드를 `REPO_ROOT` 에서만 찾았다** ⇒ `EVIDENCE_ROOT` 우선 + 폴백. **이건 출하된다.** 종전엔 워크트리 커밋이 매번 defaults-only 로 돌아 회사명·실명 클래스가 UNSCANNED 였고, 게다가 비차단 경고라 조용했다. ⓑ #448 의 반쪽-픽스였다
51
+ - **훅 주석 정정**: «절대 hooksPath 면 자기무력화가 **구조적으로 막힌다**» 는 과대주장 — 훅이 `$REPO_ROOT/scripts/*` 를 source 하므로 «한 겹 좁아진다» 가 정확하다
52
+ - 🟥 **셸 에러가 착지율 시계열에 «실측값»으로 들어갔다** (첫 실사용 발견). `set -u` + bash 3.2 에서 **빈 배열의 `${a[@]}` 는 unbound 에러**다. 대상 레포에 digest 이후 커밋이 없으면 타깃이 0건이 되고, 그러면 필터 루프가 터져 스크립트가 **판정을 인쇄하지 못한 채 rc=1** 로 끝난다 — 러너는 그 1 을 「SOME-UNLANDED」로 적는다. 올바른 값은 **rc=10(타깃 0건 = HARNESS-ERROR)**. 실측: forge-wiki 첫 런 로그가 정확히 그 형태였고 본문이 비어 있었다
53
+ - 🟥 **엔진 루트 ≠ 대상 루트** (첫 실사용 발견). 위성은 이 러너를 복사하지 않고 FH 의 것을 그대로 도는데(launchd 가 FH 경로를 가리킨다), 초판은 스캐너·패턴·checker 를 전부 `$FH_DIR`(=**대상 레포**)에서 찾았다 ⇒ 대상에 `scripts/psa_scan_lib.sh` 가 없으니 **publish gate 가 영영 fail-closed**. 게다가 그 분기는 파일을 **격리하지 않아** 「게시 안 함」이 **거짓**이었다(digest 는 이미 트리에 쓰여 있다). ⇒ 도구는 `ENGINE_DIR`, 대상만 env. 부재 분기도 실제 격리
54
+ - 🟥 **게시 직전 보안 패스가 신설 레인 2개에서 실 결함을 냈다**(MED·재현됨): 고정 `/tmp` 경로(`/tmp/.r4out` · `/tmp/.lw_*` · `/tmp/.lg_*`)라 공유 `/tmp` 에서 **심볼릭 링크 선점으로 임의 파일이 덮인다**(CWE-377). 이 스위트들은 **출하되고** `selfcheck.sh` 에 배선돼 있어 소비자가 셀프체크만 돌려도 발동했다 ⇒ `mktemp` + `trap` 정리. 재현으로 확인: 링크를 걸고 돌려도 피해 파일이 그대로다
55
+ - 앵커: 위성 **25 레인** · 착지 self-test **13 레인** · **`test_evidence_root_psa_lanes.sh` 신설 5 레인**(이 경로엔 앵커가 0개였다) · 착지 self-test 12. 되돌림 **8/8 이 각각 그 레인만** 적색
56
+
57
+ **호환성 — 위성 축은 BREAKING 아님.** 미설정 시 **종전 동작 그대로**이고(`N1`/`N3` 컨트롤이 그것을 주장), FH 자신의 일일 런은 무변경이다. ⚠️ `frontier_digest_daily.sh` 는 **출하 대상이 아니다**(종전 동일 — 출하되는 것은 `frontier_digest_autopilot.sh`).
58
+
59
+ **위성 주기 (2026-08-18 신설)**: `launchd` 2대 등록 — `com.forge-harness.satellite-forge-wiki`(10:00) · `satellite-the-bible`(11:00). FH 자신(09:00)과 시차를 둔다. 🟥 **엔진은 FH 에 있고 대상만 환경변수로 바뀐다** — 러너를 대상 레포에 복사하지 않는다(그게 복제다).
60
+
61
+ ### [2.4.0] — 2026-08-18
62
+
63
+ 🟥 **BREAKING (gate) ①**: the Axes 2–3 marker's **`standpoint:` and `thirdparty:` fields are now
64
+ validated by value**, not merely by presence (`validate_standpoint_leg` / `validate_thirdparty_leg`
65
+ in `templates/.git-hooks/pre-commit`). A marker carrying free prose where the closed enum is
66
+ required, a degrade value with **no substantive grounds on the same line**, or **more than one**
67
+ line of either field, now **blocks the commit**.
68
+ - **Remedy**: use the enum. `standpoint: tier1b(<harness>) — <what you read>` ·
69
+ `standpoint: not-applicable — <what you checked to conclude no target exists>` ·
70
+ `thirdparty: checked(<what prior art you searched>)`. The hook prints the accepted forms.
71
+ - **Why**: presence-only checking catches silence but not a **confident wrong answer** — measured on
72
+ the marker corpus, 4 markers used the enum correctly while **2 wrote free prose about peer-session
73
+ contact** and matched no enum member at all (the field-canon failure this repo already names:
74
+ normalizing a harness term into a general concept).
75
+
76
+ 🟥 **BREAKING (gate) ②**: **Axis 1 (`regression-guard.yml`) had never actually run** — an
77
+ instrument error was rendering **green**. It now runs, so a repo whose Axis 1 check was
78
+ "passing" may go **red on its first real execution**.
79
+ - **Remedy**: read the actual guard output; the failures it now reports were always there.
80
+ - **Why**: the sample was 5/5 instrument-errors-as-green. A check that cannot fail is not a check.
81
+
82
+ 🟥 **BREAKING (gate) ③**: `scripts/selfcheck.sh` now executes **`test_target_freeze_lanes.sh`**
83
+ (28 lanes) as part of the standard run. A consumer whose `selfcheck` was green may see new output,
84
+ and a genuinely broken environment (no `shasum`/`sha256sum`) now surfaces there instead of silently.
85
+ - **Remedy**: the lanes are self-contained (`mktemp` fixtures, no network). A missing hash tool is a
86
+ real finding, not a lane defect — the gate it anchors fails closed without one.
87
+ - **Why**: `lane-runner` flagged the suite as having **no runner** — *"a suite nothing executes is
88
+ prose."* The author of the lanes had not wired them.
89
+
90
+ **Added**
91
+ - `scripts/target_freeze.sh` + `scripts/test_target_freeze_lanes.sh` — **audit target freeze**.
92
+ Pin a target repo's content-addressed fingerprint before dispatching an audit; verify on return;
93
+ `WRONG-TARGET` (rc 1) invalidates that round's verdict wholesale. Third mechanization attempt,
94
+ deliberately routed **around** the two failures recorded in `steel-quench/SKILL.md` (a hook
95
+ advisory that fired on 100% of markers; a `base-SHA + diff-line-count` fingerprint invariant under
96
+ in-place edits and blind to untracked files).
97
+ 🟥 **Cross-family review found 3 fail-open paths while its lanes were 11/11 green** — `_sha`
98
+ losing `shasum`'s rc through `| awk`, `git ls-files -o` failure swallowed by a pipeline, and a
99
+ label-sanitizing collision that returned `MATCH` for a label **never pinned**. All reproduced by
100
+ hand, all fixed, lanes 11 → 28.
101
+ - `④ promotion-criteria` section in the identity gate (its absence was itself the first finding).
102
+ - Incubator routing: `net-new` failure now routes to **`CURATED`** (hand the maker the prior-art
103
+ list and the delta it does not cover) instead of `KILL`; judgment-shaped candidates route to
104
+ `NOT-APPLICABLE`. KILL survives for measured precision-shortfall, hub-state dependence, and
105
+ inability-to-run.
106
+
107
+ **Fixed**
108
+ - `relay_channel` registration-time checker had a **0-line call site** — the gate existed as 42KB
109
+ of code that nothing invoked.
110
+ - `standpoint: §7` closed its `not-applicable` definition (`Q0` target-class resolution, which can
111
+ return **more than one** target, each owing its own tier).
112
+ - `mate-agent-boundary` adapter: its **calibration pair was dead** — the peer entry point hardcodes
113
+ its target and ignores `$1`, so the positive and negative arms were measuring **the same file**.
114
+ The negative arm's green was not discrimination; the live target simply happened to be failing.
115
+ - `package.json files[]` omitted two scripts that shipped documentation instructs consumers to run
116
+ (a phantom pointer inside a gate instruction).
117
+
13
118
  ### [2.3.0] — 2026-08-17
14
119
 
15
120
  🟥 **BREAKING (gate) ①**: the capability effect-probe (`M6`) now watches **directory existence and
@@ -202,6 +202,26 @@ re-checks** it against the artifact (phantom-quench back-trace) before accepting
202
202
  source-grounded is **dropped, not judged**. No sidecar-only verdict (no weak-local-judge regression).
203
203
  Local 4090 = **canary tier** (evidence-of, never terminal verdict).
204
204
 
205
+ **Target freeze — a prior drop reason, checked before any of the above (2026-08-17).** Grounding a
206
+ finding against *today's* tree proves nothing if the sidecar reviewed *yesterday's*. Pin before
207
+ dispatch and verify on return:
208
+
209
+ ```bash
210
+ bash scripts/target_freeze.sh pin "$REPO" "$ROUND_LABEL" # 발주 직전
211
+ bash scripts/target_freeze.sh verify "$REPO" "$ROUND_LABEL" # 회수 직후
212
+ ```
213
+
214
+ `rc 1` = **WRONG-TARGET → drop the whole round's findings**, not individual ones — the round read a
215
+ tree that moved under it, so which findings survive is not decidable from the output. `rc 10` =
216
+ UNKNOWN (could not measure) — that is **not** a pass; re-pin and re-dispatch. Measured trigger: three
217
+ occurrences in one day, one of which was sending a sidecar a **pre-repair diff** and then grounding
218
+ its findings against the repaired tree — every finding "failed to ground", and the instrument, not
219
+ the reviewer, was wrong.
220
+
221
+ 🟥 **What this does NOT cover**: §L191's inlined-file pattern. When the prompt carries file contents
222
+ on stdin (the robust form for sidecars that have no file tools), the tree freeze says nothing about
223
+ **what bytes you actually sent**. That binding is still owed and is not claimed here.
224
+
205
225
  ## Step 6 — Degrade ladder (the intelligent scale-down)
206
226
 
207
227
  **Consent branch first — declined ≠ degraded** (`[[capability_escalation_consent]]`): if the UAP has
@@ -425,7 +425,8 @@ over **unchanged** code:
425
425
  `WRONG-TARGET` and stop on mismatch:
426
426
 
427
427
  ```bash
428
- git rev-parse --short HEAD; git diff HEAD | wc -l; git status --porcelain | grep -c '^??'
428
+ bash scripts/target_freeze.sh pin "$REPO" "$WAVE_LABEL" # 발주 직전 토큰을 프롬프트에 박는다
429
+ bash scripts/target_freeze.sh verify "$REPO" "$WAVE_LABEL" # 회수 직후 — rc 1 이면 WRONG-TARGET
429
430
  ```
430
431
 
431
432
  An audit that did not pin its target **cannot be counted as a terminating round** — it may have
@@ -435,7 +436,29 @@ over **unchanged** code:
435
436
  active — gate-locality on the very fix that was closing a gate-locality gap, caught by the round
436
437
  that audited it.
437
438
 
438
- > **Named residual — the freeze claim is currently SELF-ATTESTED, and deliberately not
439
+ > **MECHANIZED 2026-08-17 — the residual below is the history, not the current state.**
440
+ > The recurrence condition this paragraph set ("mechanize on the first recurrence") fired **three
441
+ > times in one day**: a cross-family review was sent a **pre-repair** diff · a review's output was
442
+ > truncated with `tail`, losing findings and the pin itself · a scan was followed by an edit that
443
+ > shifted every line number. All three were "did I send / read the tree that is there now?", and
444
+ > the prose rule above was already in place and caught **none** of them (operator-approved as the
445
+ > next session's 1순위, 2026-08-17).
446
+ >
447
+ > `scripts/target_freeze.sh` (lanes: `scripts/test_target_freeze_lanes.sh`, **28/28**) takes the
448
+ > third attempt deliberately **through** the two failures recorded below: it content-addresses
449
+ > `git diff HEAD` (the **patch**, not its line count — so a same-line-count in-place edit is
450
+ > caught, lane A) and hashes untracked files separately (lane B), and it is **not a hook** —
451
+ > two explicit commands the dispatcher runs, so it cannot become the 100%-firing background noise
452
+ > of attempt ①. It refuses to answer at all when `assume-unchanged`/`skip-worktree` is set
453
+ > (exit 10 UNKNOWN, never MATCH), and a **restore-to-original lane returns MATCH** — that arm is
454
+ > the point: without it, "always reports WRONG" would pass every other lane.
455
+ > 🟥 **Scope, stated narrowly**: it freezes the *target tree*, **not the bytes you inlined into a
456
+ > sidecar prompt**. Where files are pasted into the prompt (`auto-decorrelation` §L191 — sidecars
457
+ > often cannot read a tree at all), the inlined content needs its own binding; this script does
458
+ > not provide it.
459
+ >
460
+ > **Named residual (HISTORICAL — superseded by the block above) — the freeze claim was
461
+ > SELF-ATTESTED, and deliberately not
439
462
  > mechanized.** Criterion 1 turns on "unmodified since the previous audit", and nothing checks
440
463
  > that. Two attempts to mechanize it on the day the rule was written were both wrong: a hook
441
464
  > advisory that fired on 100% of markers (noise, and its convergence detector could not match the
@@ -100,14 +100,34 @@ if [ -n "$TARGET" ]; then
100
100
  [ -r "$TARGET" ] || _harness_error "대상 경로를 읽을 수 없다: $TARGET"
101
101
  fi
102
102
 
103
- # peer 트리를 **변이하지 않는다** — cwd 만 peer 로 두고 읽기만 한다.
104
- # (기본 대상이 peer 레포 상대경로라 cwd 가 peer 여야 한다.)
103
+ # peer 트리를 **변이하지 않는다** — cwd 만 두고 읽기만 한다.
105
104
  # 판정은 파이프를 통과시키지 않는다(PIPE-VERDICT).
106
- if [ -n "$TARGET" ]; then
107
- out=$(cd "$PEER_ROOT" && bash "$ENTRY" "$TARGET" 2>&1); rc=$?
108
- else
109
- out=$(cd "$PEER_ROOT" && bash "$ENTRY" 2>&1); rc=$?
110
- fi
105
+ #
106
+ # 🟥 **초판은 대상을 인자로 넘겼고, peer 는 그 인자를 안 읽는다** (2026-08-18 실측).
107
+ # peer 진입점은 `TARGET=".claude/agents/mate-failure-patcher.md"` 를 **하드코딩**하고
108
+ # `$1` 은 `pass()`/`fail()` 헬퍼 안에서만 쓴다. 그래서 `--known-positive` / `--known-negative`
109
+ # 두 arm 이 **같은 파일**(peer 실물 명세서)을 재고 있었다 ⇒ **캘리브레이션 쌍이 죽어 있었다.**
110
+ # 음성 arm 이 초록이던 것은 판별력이 아니라 **실물이 마침 실패 상태**여서다.
111
+ # 이 픽스처 파일의 헤더가 스스로 경고한 그 상태다 — *"실물을 양성 arm 으로 쓰면 계기의
112
+ # 판정이 남의 편집에 따라 조용히 뒤집힌다"*.
113
+ # ⇒ 대상이 명시되면 **peer 가 기대하는 모양의 임시 트리**를 만들어 거기서 돌린다.
114
+ # peer 코드를 고치지 않고(남의 레포다) peer 의 하드코딩된 상대경로를 만족시키는 방법이다.
115
+ _run_peer() {
116
+ if [ -z "$TARGET" ]; then
117
+ ( cd "$PEER_ROOT" && bash "$ENTRY" ) 2>&1
118
+ return $?
119
+ fi
120
+ local stage
121
+ stage=$(mktemp -d) || return 10
122
+ mkdir -p "$stage/.claude/agents" || { rm -rf "$stage"; return 10; }
123
+ cp "$TARGET" "$stage/.claude/agents/mate-failure-patcher.md" || { rm -rf "$stage"; return 10; }
124
+ local o r
125
+ o=$( cd "$stage" && bash "$ENTRY" 2>&1 ); r=$?
126
+ rm -rf "$stage"
127
+ printf '%s\n' "$o"
128
+ return $r
129
+ }
130
+ out=$(_run_peer); rc=$?
111
131
  printf '%s\n' "$out"
112
132
 
113
133
  # ── 명시 재매핑 (같은 숫자, 다른 사건 — 전파가 아니다) ───────────────────────
@@ -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"
@@ -373,6 +373,13 @@ _check_one() {
373
373
  if [ -z "$CAP_cal_pos_expect" ] || [ -z "$CAP_cal_neg_expect" ]; then
374
374
  _fail "M4" "캘리브레이션 쌍 미선언(양성·음성 expect 둘 다 필요) — 답을 아는 케이스를 못 가르는 계기는 재는 게 아니다"
375
375
  OBS_WHY="캘리브레이션 쌍 미선언"
376
+ # 🟥 위 «쌍 미선언» 검사는 **선언층**이므로 --declaration-only 에서도 돈다. 아래부터가
377
+ # **실행**이고, 그것만 건너뛴다. 둘을 한 덩어리로 스킵하면 «쌍을 아예 선언 안 한»
378
+ # capfile 이 선언층 검사를 통과해버린다 — 2026-08-17 실측에서 relay 가 삼킨
379
+ # `qasp_clean.cap` 이 정확히 그 값으로 걸렸다(M4 쌍 미선언).
380
+ elif [ "${DECL_ONLY:-0}" -eq 1 ]; then
381
+ printf ' ⏭ M4 — --declaration-only: 쌍 선언은 확인했고 **arm 은 실행하지 않았다**(SKIPPED, PASS 아님)\n'
382
+ OBS_WHY="--declaration-only 로 arm 미실행"
376
383
  elif [ "$FILE_FAILED" -eq 1 ] && [ -z "${CRC_FORCE_M4:-}" ]; then
377
384
  printf ' ⏭ M4 — 앞선 축이 실패해 실행 생략(SKIPPED, PASS 아님)\n'
378
385
  OBS_WHY="앞선 축 실패로 M4 생략"
@@ -417,6 +424,11 @@ _check_one() {
417
424
  # «못 쟀다» 를 출력에 남긴다. 미측정을 0 으로 렌더하지 않는다.
418
425
  if [ -z "$CAP_writes" ]; then
419
426
  : # writes 미선언은 M2 가 이미 처리한다
427
+ elif [ "${DECL_ONLY:-0}" -eq 1 ]; then
428
+ # 🟥 M6 는 통째로 실행 기반이다(effect probe 가 entry 를 실제로 돌린다). 선언층에
429
+ # 남길 것이 없으므로 전부 건너뛴다 — 그래서 이 모드의 출력이 REGISTRABLE 이 아니라
430
+ # DECLARATION_VALID 다. `writes:` 의 진위는 이 모드에서 **등록자 주장**으로 남는다.
431
+ printf ' ⏭ M6 — --declaration-only: 진입점을 실행하지 않았다. `writes:` 는 미검증 주장이다(SKIPPED, PASS 아님)\n'
420
432
  elif [ "$FILE_FAILED" -eq 1 ] && [ -z "${CRC_FORCE_M6:-}" ]; then
421
433
  # ★앞선 축이 실패했으면 **진입점을 실행하지 않는다**(2026-08-16 cross-family 지목).
422
434
  # M6 는 `eval` 로 entry 를 돌리므로, «등록 거부될 capfile» 의 진입점을 굳이 실행하는
@@ -619,6 +631,30 @@ LIAR
619
631
  _tg "관측범위: 미선언 args 를 미선언이라 적는다" 1 "$T/nocal.cap" \
620
632
  "실행된 arm: 양성 «<미선언>» / 음성 «<미선언>»"
621
633
 
634
+ # ── D1–D4 --declaration-only (2026-08-17, relay 배선용) ─────────────────────
635
+ # 이 모드는 `relay_channel.sh` 가 **호출 시점**에 부르는 경로다. 전량 호출하면 M4/M6 이
636
+ # 매 relay 실행마다 노드를 다시 돌리므로 선언층만 본다 — 그 대가를 레인으로 고정한다.
637
+ local dout drc
638
+ # D1 선언층 결함은 이 모드에서도 잡힌다(잡히는 게 요점이다 — 안 잡히면 배선이 무의미)
639
+ dout=$(bash "$0" --declaration-only "$T/nocal.cap" 2>&1); drc=$?
640
+ if [ "$drc" -eq 1 ]; then pass=$((pass+1)); printf ' ✅ %-40s\n' "D1: 선언층 결함은 --declaration-only 도 잡는다"
641
+ else fail=$((fail+1)); printf ' ❌ %-40s — rc=%s (기대 1)\n' "D1: 선언층 결함" "$drc"; fi
642
+ # D2 ★컨트롤 — 정상 선언은 통과한다. D1 만 있으면 「전부 막는 모드」와 구별이 안 된다
643
+ dout=$(bash "$0" --declaration-only "$T/good.cap" 2>&1); drc=$?
644
+ if [ "$drc" -eq 0 ]; then pass=$((pass+1)); printf ' ✅ %-40s\n' "D2: 컨트롤 — 정상 선언은 통과(과차단 아님)"
645
+ else fail=$((fail+1)); printf ' ❌ %-40s — rc=%s (기대 0)\n' "D2: 컨트롤" "$drc"; fi
646
+ # D3 🟥 어휘 분리 — 부분 검사를 REGISTRABLE 로 찍으면 안 된다. 그게 이 모드의 최대 위험이다
647
+ case "$dout" in
648
+ *DECLARATION_VALID*) pass=$((pass+1)); printf ' ✅ %-40s\n' "D3: REGISTRABLE 이 아니라 DECLARATION_VALID" ;;
649
+ *) fail=$((fail+1)); printf ' ❌ %-40s — 부분 검사가 전체 통과 어휘로 찍힌다\n' "D3: 어휘 분리" ;;
650
+ esac
651
+ # D4 M4/M6 을 «안 돌렸다» 고 말한다. 침묵하면 읽는 사람이 PASS 로 읽는다(미측정≠0)
652
+ case "$dout" in
653
+ *"M4 — --declaration-only"*|*"M6 — --declaration-only"*)
654
+ pass=$((pass+1)); printf ' ✅ %-40s\n' "D4: 안 돌린 축을 안 돌렸다고 적는다" ;;
655
+ *) fail=$((fail+1)); printf ' ❌ %-40s — 생략을 침묵으로 처리한다\n' "D4: 미실행 표기" ;;
656
+ esac
657
+
622
658
  printf '\n 통과 %d · 실패 %d\n' "$pass" "$fail"
623
659
  rm -rf "$T"
624
660
  [ "$fail" -eq 0 ] || return 1
@@ -626,14 +662,44 @@ LIAR
626
662
  }
627
663
 
628
664
  [ "${1:-}" = "--self-test" ] && { _self_test; exit $?; }
629
- [ $# -ge 1 ] || _die "capfile 인자가 없다. 사용법: $0 <capfile> [<capfile>...]"
630
665
 
631
- printf 'capability_registry_check M1–M5 + 추가조항 (등록 시점)\n'
666
+ # ── --declaration-only (2026-08-17) ───────────────────────────────────────────
667
+ # 🟥 **왜 이 모드가 있나 — 그리고 왜 「가벼운 등록검사」가 아닌가.**
668
+ # `relay_channel.sh` 가 호출 시점에 이 검사기를 부르게 배선하면서 필요해졌다. 그냥 전량
669
+ # 호출하면 **매 relay 실행마다 M4(known-pair arm 을 실제로 돌린다)와 M6(effect probe 가
670
+ # entry 를 `eval` 로 실행한다)가 재실행된다** — 호출 한 번이 노드를 여러 번 더 돌리고,
671
+ # M6 는 그 자체가 위험 노출이라 이 파일이 이미 «등록 거부될 capfile 의 진입점을 굳이
672
+ # 실행하지 않는다» 는 가드를 갖고 있다. 그 판단을 호출 경로에도 그대로 적용한다.
673
+ #
674
+ # **대안을 안 고른 이유**: relay 안에 선언 검사를 새로 쓰는 것. 그러면 같은 스키마에 대해
675
+ # 계기가 셋이 되고, 이 레포는 그 divergent-normalizer 함정을 **이미 두 번 밟았다**
676
+ # (relay 헤더 §META_KEYS — 2026-08-11 · 2026-08-16, 둘 다 «등록되는데 부를 수 없는» 선언).
677
+ # 스키마의 단일 소스는 이 파일이어야 한다.
678
+ #
679
+ # 🟥 **이 모드는 REGISTRABLE 을 말하지 않는다.** 출력 어휘를 일부러 분리한다 —
680
+ # `DECLARATION_VALID` 는 «선언층이 유효하다» 이지 «등록 가능하다» 가 아니다. 둘을 같은
681
+ # 단어로 찍으면 부분 검사가 전체 통과로 읽힌다(`[[feedback_not_found_is_not_zero_family]]`).
682
+ DECL_ONLY=0
683
+ if [ "${1:-}" = "--declaration-only" ]; then DECL_ONLY=1; shift; fi
684
+ export DECL_ONLY
685
+
686
+ [ $# -ge 1 ] || _die "capfile 인자가 없다. 사용법: $0 [--declaration-only] <capfile> [<capfile>...]"
687
+
688
+ if [ "$DECL_ONLY" -eq 1 ]; then
689
+ printf 'capability_registry_check — **선언층만** (M1·M2·M3·M5 + 추가조항 · M4/M6 미실행)\n'
690
+ else
691
+ printf 'capability_registry_check — M1–M6 + 추가조항 (등록 시점)\n'
692
+ fi
632
693
  for f in "$@"; do _check_one "$f"; done
633
694
 
634
695
  printf '\n'
635
696
  if [ "$FAILED" -eq 0 ]; then
636
- printf '✅ REGISTRABLE capfile 이 M1–M5 + 추가조항 통과\n'; exit "$RC_OK"
697
+ if [ "$DECL_ONLY" -eq 1 ]; then
698
+ printf '✅ DECLARATION_VALID — 선언층 통과. 🟥 REGISTRABLE 이 아니다: M4(known-pair 실행)·M6(선언 진위)는 **안 돌렸다**\n'
699
+ else
700
+ printf '✅ REGISTRABLE — 전 capfile 이 M1–M6 + 추가조항 통과\n'
701
+ fi
702
+ exit "$RC_OK"
637
703
  else
638
704
  printf '❌ REJECTED — 등록 불가. 그 표면은 오늘 있던 자리(dispatch 엔트리)에 그대로 남는다 — 손실이 아니다\n'
639
705
  exit "$RC_REJECT"