@chrono-meta/fh-gate 2.3.0 → 2.4.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 (31) hide show
  1. package/.claude/rules/fh_4axis_gate.md +25 -0
  2. package/.claude-plugin/marketplace.json +2 -2
  3. package/CLAUDE.md +51 -6
  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/field_verdict_crossfamily_gate.md +231 -7
  9. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +215 -1
  10. package/knowledge/shared/harness-core/ship_readiness_gate.md +168 -1
  11. package/knowledge/shared/learnings/subagent_invocations_log.yaml +24 -0
  12. package/package.json +6 -1
  13. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  14. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  15. package/plugins/fh-meta/CHANGELOG.md +57 -0
  16. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +20 -0
  17. package/plugins/fh-meta/skills/steel-quench/SKILL.md +25 -2
  18. package/scripts/adapters/mate_agent_boundary.sh +27 -7
  19. package/scripts/capability_registry_check.sh +69 -3
  20. package/scripts/relay_channel.sh +151 -6
  21. package/scripts/selfcheck.sh +18 -0
  22. package/scripts/target_freeze.sh +239 -0
  23. package/scripts/test_adapter_lanes.sh +17 -0
  24. package/scripts/test_marker_standpoint_lanes.sh +83 -0
  25. package/scripts/test_marker_thirdparty_lanes.sh +73 -0
  26. package/scripts/test_regression_guard_ci_lanes.sh +170 -0
  27. package/scripts/test_relay_channel_lanes.sh +146 -17
  28. package/scripts/test_target_freeze_lanes.sh +203 -0
  29. package/templates/.git-hooks/pre-commit +242 -0
  30. package/templates/PRE-PUBLISH-CHECKLIST.md +22 -0
  31. package/templates/regression_guard.sh +32 -4
@@ -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
  # ── 명시 재매핑 (같은 숫자, 다른 사건 — 전파가 아니다) ───────────────────────
@@ -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"
@@ -21,10 +21,20 @@
21
21
  # ─────────────────────────────────────────────────────────────────────────────
22
22
  # 이 계기가 증명하지 않는 것 (과잉주장 금지 — 명명된 잔여)
23
23
  # ─────────────────────────────────────────────────────────────────────────────
24
- # · **등록 시점은 여기서 막는다.** 스펙 §1 이름 붙인
25
- # `scripts/capability_registry_check.sh`(M1–M5 + M4 쌍 실행)는 **여전히 없다.**
26
- # 여기는 *call moment* 본다 — 선언이 문법에 맞는지·enum 안인지는 런타임에
27
- # 다시 거부하지만(ⓑ.5),선언이 **진실인지**는 검사하지 않는다.
24
+ # · **등록 시점 선언층은 2026-08-17 부터 여기서 막는다(배선됨).** `do_run`
25
+ # `_registry_gate` 로 `scripts/capability_registry_check.sh --declaration-only` 부르고,
26
+ # REJECTED 조합을 열지 않는다(COMPOSITION_VIOLATION).
27
+ # 🟥 **남은 잔여는 좁아졌을 뿐 사라지지 않았다**: 모드는 M4(known-pair 실행)·M6(선언
28
+ # 진위)를 **안 돌린다**. 즉 여기서 닫히는 것은 «선언이 형식에 맞나»(M1·M2·M3·M5 +
29
+ # 추가조항)이고, «선언이 **진실인지**»는 여전히 검사하지 않는다 — `writes: read-only`
30
+ # 의 진위는 등록 시점 M6 의 몫이다. 왜 전량 호출을 안 했는지는 `_registry_gate` 헤더에.
31
+ #
32
+ # · (이력) 🟥 **이 자리에 이렇게 적혀 있었다 — 그리고 그 문장이 잔여를 은폐했다.**
33
+ # 원문: *"`scripts/capability_registry_check.sh`(M1–M5 + M4 쌍 실행)는 **여전히 없다**."*
34
+ # 2026-08-17 실측 시점에 그 파일은 **42KB 로 실재했고 정상 작동했다.** 낡은 서술이
35
+ # «없으니 안 부르는 게 당연하다» 를 정당화하는 형태로 남아 있었고, 그 사이 relay 는
36
+ # `❌ REJECTED` 판정을 받는 capfile 을 그냥 실행해 CLEAN 을 체인에 병합했다.
37
+ # **stale 한 잔여 서술은 잔여를 기록하는 게 아니라 은폐한다.**
28
38
  # · **호출 순간 자체엔 기계 floor 가 없다.** "지금 capability 를 조합하려 한다" 를
29
39
  # 관측하는 훅은 존재하지 않는다(트리거가 의도다). 이 스크립트는 *호출되면* 계산을
30
40
  # 강제하지만, **호출되도록 강제하지는 못한다.** 스펙 §1 이 이미 그렇게 적어놨고
@@ -507,6 +517,73 @@ _invoke_node() {
507
517
  return 0
508
518
  }
509
519
 
520
+ # ── 등록 시점 게이트 ──────────────────────────────────────────────────────────
521
+ # 호출 시점(여기)에서 **등록 시점 검사기를 그대로 부른다.** 선언 검사를 여기 새로 쓰지
522
+ # 않는 것이 요점이다 — 같은 스키마에 계기가 둘이 되면 관대함이 갈리고, 이 파일은 그
523
+ # divergent-normalizer 함정을 **이미 두 번 밟았다**(§META_KEYS 헤더: 2026-08-11 · 08-16,
524
+ # 둘 다 «등록되는데 부를 수 없는» 선언을 만들었다). 스키마의 단일 소스는 검사기 쪽이다.
525
+ #
526
+ # 🟥 **`--declaration-only` 로 부르는 이유 — 그리고 그 대가를 명시한다.**
527
+ # 전량 호출하면 M4(known-pair arm 실행)와 M6(effect probe 가 entry 를 실행)가 **매 relay
528
+ # 호출마다 재실행된다.** 검사기 자신이 «등록 거부될 capfile 의 진입점을 굳이 실행하는 것은
529
+ # 그 자체가 위험 노출» 이라는 가드를 갖고 있고, 그 판단은 호출 경로에서도 같다.
530
+ # ⇒ **대가**: 이 게이트는 «선언이 형식에 맞나» 를 보지 «선언이 사실인가» 는 **안 본다.**
531
+ # `writes: read-only` 의 진위는 여기서 닫히지 않는다(그건 등록 시점 M6 의 몫이다).
532
+ # 이 파일 헤더가 원래 적어둔 잔여 — «선언이 진실인지는 검사하지 않는다» — 는
533
+ # **좁아졌을 뿐 사라지지 않았다.** 좁아진 폭이 정확히 M1·M2·M3·M5 + 추가조항이다.
534
+ #
535
+ # **degrade 방향 = fail-closed.** 검사기 부재·실행 실패·알 수 없는 rc 는 전부 차단이다.
536
+ # 이건 조합을 여는 게이트이고, 게이트의 도구가 없을 때 통과시키는 것은 게이트가 아니다
537
+ # (CLAUDE.md §Surface-Class Degrade Invariant). 과차단 위험은 인정하되, 여기서 열면
538
+ # 배선의 목적 자체가 사라진다 — 안 부르던 상태와 같아지기 때문이다.
539
+ _registry_gate() {
540
+ # 심볼릭 링크 해석. `${0%/*}` 는 **호출에 쓰인 이름**의 디렉터리를 주므로, 이 파일로의
541
+ # 심링크가 있으면 링크 옆(아무것도 없는 곳)을 뒤지고 «검사기 부재» 로 fail-closed 한다.
542
+ # 자기 경로 버그를 남의 파일 탓으로 돌리는 게이트는 그냥 실패하는 것보다 나쁘다 —
543
+ # 읽는 사람을 틀린 수리로 보낸다(검사기 M6 이 2026-08-16 에 실제로 밟은 형태).
544
+ local _src="${BASH_SOURCE[0]}" _dir
545
+ while [ -L "$_src" ]; do
546
+ _dir=$(cd -P "$(dirname "$_src")" && pwd)
547
+ _src=$(readlink "$_src")
548
+ case "$_src" in /*) ;; *) _src="$_dir/$_src" ;; esac
549
+ done
550
+ _dir=$(cd -P "$(dirname "$_src")" && pwd)
551
+ local checker="$_dir/capability_registry_check.sh"
552
+
553
+ if [ ! -r "$checker" ]; then
554
+ _violation "등록 검사기에 도달할 수 없다: $checker — fail-closed(조합을 열지 않는다)"
555
+ return 1
556
+ fi
557
+
558
+ local f out rc bad=0
559
+ GATE_SHA=() # 게이트가 «본» 바이트의 해시. 실행 직전에 대조한다(TOCTOU).
560
+ for f in "${CAPS[@]}"; do
561
+ # 🟥 rc 는 **직접** 취한다. 파이프로 읽으면 필터의 rc 가 잡혀 REJECTED 가 0 으로 읽힌다
562
+ # — 이 레포가 이름 붙인 결함이고 D6(pipefail) 과 같은 뿌리다.
563
+ out=$(bash "$checker" --declaration-only "$f" 2>&1); rc=$?
564
+ case "$rc" in
565
+ # 🟥 **성공 출력을 버리지 않는다** (cross-family A급 수리). 초판은 `0) ;;` 로 통째로
566
+ # 폐기했고, 그 결과 `relay run` 출력에는 `FH_STATUS: SUCCESS` 만 남아 **호출자가
567
+ # «전체 검사가 통과했다»로 읽었다.** 실제로는 M4(known-pair 실행)·M6(선언 진위)를
568
+ # 안 돌린 선언층 통과다. 침묵은 «다 봤다»로 읽힌다 — 안 본 축을 이름으로 찍는다.
569
+ 0) printf 'FH_REGISTRY_GATE: DECLARATION_VALID %s\n' "$(basename "$f")"
570
+ printf 'FH_REGISTRY_GATE_SKIPPED: M4 M6 (%s — 선언 진위는 등록 시점 검사의 몫)\n' "$(basename "$f")"
571
+ # 🟥 **검사한 바이트를 실행할 바이트에 결박한다** (cross-family A급 — TOCTOU).
572
+ # 게이트는 capfile «경로» 를 검사하고, 그 뒤 `_merge_caps`·`_invoke_node` 가 같은
573
+ # 경로를 **다시 읽는다.** 그 사이 파일이 바뀌면 «검사된 선언» 과 «실행된 선언» 이
574
+ # 다르고, 초판엔 둘을 묶는 것이 없었다. 여기서 해시를 남기고 실행 직전에 대조한다.
575
+ GATE_SHA+=("$(_sha < "$f")") ;;
576
+ 1) _violation "등록 시점 검사 REJECTED — 부를 수 없는 선언이다: $f"
577
+ printf '%s\n' "$out" | sed -n 's/^ ❌ / ↳ /p' >&2
578
+ bad=1 ;;
579
+ *) _violation "등록 검사기가 비정상 종료(rc=$rc): $f — fail-closed"
580
+ printf '%s\n' "$out" | tail -3 >&2
581
+ bad=1 ;;
582
+ esac
583
+ done
584
+ [ "$bad" -eq 0 ]
585
+ }
586
+
510
587
  # ── run ───────────────────────────────────────────────────────────────────────
511
588
  do_run() {
512
589
  local task="" recdir="" ack=0 n=0 upstream="" rc final=$RC_PASS _ci
@@ -572,6 +649,41 @@ do_run() {
572
649
  local ncap=${#CAPS[@]}
573
650
  [ "$ncap" -ge 2 ] || _die "relay 는 노드 2개 이상을 요구한다(받은 수: $ncap) — 1노드는 단순 호출이지 경유가 아니다"
574
651
 
652
+ # ── 등록 시점 게이트 배선 (2026-08-17) ──────────────────────────────────────
653
+ # 🟥 **왜 여기가 필요했나 — 이 파일이 게이트를 갖고도 안 불렀다.**
654
+ # 2026-08-17 실측: `capability_registry_check.sh qasp_clean.cap` → `❌ REJECTED`
655
+ # (M2+ enum 에 「안 돌았다」 없음 · M5 requires_cwd 미선언 · M4 캘리브레이션 쌍 미선언).
656
+ # **그런데 relay 는 그 cap 을 그냥 실행하고 CLEAN 을 체인에 병합했다** — grep 결과
657
+ # `capability_registry_check` 히트 3건이 **전부 주석**이고 실행문은 0줄이었다.
658
+ # 게이트는 42KB 로 실재하고 정상 작동하는데 **아무도 부르지 않는** 상태였다
659
+ # ([[feedback_built_but_not_wired]] — 「호출부 미배선이면 산문이 실행기」).
660
+ # 그 결과가 §2-b 의 «relay 3노드 CLEAN 동의»이고, 그 동의는 허상이었다.
661
+ # 🟥 **우회 채널이 있고, 왜 있는지 적는다 — 이걸 숨기면 그게 더 나쁘다.**
662
+ # 이 게이트를 켜자 relay 자신의 **런타임 심층 방어 4종**이 do_run 경로에서 도달 불가가 됐다
663
+ # (실측: L26b «126 이상» · L31b «중복 선언» · L36b 실행 불가 · N8 requires_cwd 부재).
664
+ # 구조적으로 배타적이다 — entry 가 실행 불가여야 126 이 나는데 그건 M1 이 먼저 잡고,
665
+ # requires_cwd 가 없어야 NOT_CALLABLE 인데 그건 M5 가 먼저 잡는다.
666
+ # 그 방어들은 **죽은 코드가 아니라 심층 방어**다: 게이트를 안 거치는 진입점, 게이트가
667
+ # fail-closed 로 죽은 뒤의 경로에서 여전히 마지막 벽이다. 시험할 수 없으면 썩는다
668
+ # ([[feedback_built_but_not_wired]] 의 거울상 — 배선했더니 아래층이 안 보이게 된 형태).
669
+ # ⇒ 끄는 길을 **하나만**, **시끄럽게** 둔다. 조용한 우회가 실사용으로 새는 것이지
670
+ # 존재 자체가 새는 게 아니다. 이 줄이 실사용 로그에 보이면 그게 결함 신호다.
671
+ if [ "${FH_RELAY_REGISTRY_GATE:-on}" = "off" ]; then
672
+ # 🟥 **우회는 실행을 막지 않지만 clearing PASS 는 구조적으로 막는다** (cross-family A급 수리).
673
+ # 초판은 경고만 stderr 로 찍었는데, 재현 결과 **REJECT capfile 이 실제로 실행되고
674
+ # `FH_RELAY_VERDICT: PASS` 까지 갔다.** 경고는 텍스트일 뿐 게이트가 아니다.
675
+ # ⇒ `DEGRADED_SEEN` 과 같은 형태로 **verdict 를 강등**한다. 심층 방어 레인들은 rc=2/4 를
676
+ # 기대하므로 안 깨지고, 실사용자가 이 스위치를 켜도 **게이트를 열지는 못한다.**
677
+ # («이중 조건 env» 대신 이걸 고른 이유: 조건을 늘려도 둘 다 켜면 그만이고, 그건 여전히
678
+ # PASS 를 낸다. 여기서 막아야 하는 것은 진입이 아니라 **통과**다.)
679
+ GATE_BYPASSED=1
680
+ printf '⚠️ 등록 게이트 우회 중 (FH_RELAY_REGISTRY_GATE=off) — 심층 방어 시험용 경로다.\n' >&2
681
+ printf '⚠️ 실사용에서 이 줄이 보이면 결함이다: 부를 수 없는 선언이 조합에 들어온다.\n' >&2
682
+ printf '⚠️ 이 런은 clearing PASS 를 낼 수 없다(강등 고정).\n' >&2
683
+ else
684
+ _registry_gate || { printf 'FH_STATUS: ERROR\nFH_RELAY_VERDICT: COMPOSITION_VIOLATION\n'; return "$RC_VIOLATION"; }
685
+ fi
686
+
575
687
  _merge_caps || { printf 'FH_STATUS: ERROR\nFH_RELAY_VERDICT: COMPOSITION_VIOLATION\n'; return "$RC_VIOLATION"; }
576
688
  _run_checks || { printf 'FH_STATUS: ERROR\nFH_RELAY_VERDICT: COMPOSITION_VIOLATION\n'; return "$RC_VIOLATION"; }
577
689
 
@@ -600,6 +712,21 @@ do_run() {
600
712
  : > "$recdir/CHAIN.txt"
601
713
  printf 'task_sha: %s\n' "$(printf '%s' "$task" | _sha)" >> "$recdir/CHAIN.txt"
602
714
 
715
+ # 🟥 **TOCTOU 재확인** — 게이트가 검사한 바이트와 지금 실행할 바이트가 같은지 본다.
716
+ # 게이트를 우회한 런(GATE_SHA 비어 있음)은 대조할 기준이 없으므로 건너뛴다 — 그 런은
717
+ # 이미 clearing PASS 를 못 내도록 강등돼 있다.
718
+ if [ "${#GATE_SHA[@]}" -gt 0 ]; then
719
+ local _i=0 _now
720
+ for f in "${CAPS[@]}"; do
721
+ _now=$(_sha < "$f")
722
+ if [ "$_now" != "${GATE_SHA[$_i]}" ]; then
723
+ printf 'FH_STATUS: ERROR\nFH_RELAY_VERDICT: HARNESS_ERROR\n'
724
+ _die "capfile 이 검사 후 변경됐다: $f — 검사한 선언과 실행할 선언이 다르다(TOCTOU)"
725
+ fi
726
+ _i=$((_i+1))
727
+ done
728
+ fi
729
+
603
730
  for f in "${CAPS[@]}"; do
604
731
  n=$((n+1))
605
732
  _invoke_node "$f" "$task" "$upstream" "$recdir" "$n" "${CAPARGS[$((n-1))]:-}"
@@ -679,7 +806,14 @@ do_run() {
679
806
  # D2 — 체인에 judge:model 이 있으면 PASS 는 **단독으로 게이트를 열 수 없다.**
680
807
  # D4 연장 — advisory 로 넘긴 HARNESS_ERROR 도 clearing PASS 를 영구히 막는다.
681
808
  # "안 재고 통과" 를 초록으로 렌더하지 않는다(미측정≠PASS).
682
- if [ "${DEGRADED_SEEN:-0}" -eq 1 ]; then
809
+ if [ "${GATE_BYPASSED:-0}" -eq 1 ]; then
810
+ # 🟥 등록 게이트를 우회한 런은 **어떤 경우에도 clearing PASS 가 아니다.**
811
+ # 선언이 부를 수 있는 것인지 확인하지 않았으므로 «안 재고 통과» 이고, 이 파일의 D4 가
812
+ # 이미 그것을 PASS 로 렌더하지 않기로 정했다(미측정 ≠ PASS).
813
+ printf 'FH_RELAY_VERDICT: PENDING\n'
814
+ printf 'FH_RELAY_CLEARING: NON_CLEARING (등록 게이트 우회 — 선언 유효성을 안 쟀다. 미측정은 PASS 가 아니다)\n'
815
+ final=$RC_PENDING
816
+ elif [ "${DEGRADED_SEEN:-0}" -eq 1 ]; then
683
817
  printf 'FH_RELAY_VERDICT: PENDING\n'
684
818
  printf 'FH_RELAY_CLEARING: NON_CLEARING (HARNESS_ERROR 를 advisory 로 통과시켰다 — 미측정은 PASS 가 아니다)\n'
685
819
  final=$RC_PENDING
@@ -702,7 +836,18 @@ do_merge_only() {
702
836
  [ "${#CAPS[@]}" -gt 0 ] || _die "usage: merge --cap <capfile> [--cap ...]"
703
837
  _merge_caps || return "$RC_VIOLATION"
704
838
  _run_checks || return "$RC_VIOLATION"
705
- printf 'FH_STATUS: SUCCESS\n'; _print_merge
839
+ # 🟥 **`merge` 는 등록 게이트를 부르지 않는다 — 그리고 그 사실을 출력이 말한다**
840
+ # (cross-family A급 수리). 재현: 같은 capfile 이 `capability_registry_check --declaration-only`
841
+ # 에서 rc=1 인데 `relay merge` 에서는 rc=0 `FH_STATUS: SUCCESS` 였다. 실행은 아니지만
842
+ # **preflight/조합 계산 표면으로 쓰이면 fail-open** 이다.
843
+ # ⇒ 게이트를 강제하지 **않는** 이유: `merge` 는 «병합만 계산» 하는 조회 경로라 등록 전
844
+ # 선언을 실험하는 정당한 용도가 있다. 막을 곳은 진입이 아니라 **오독**이다.
845
+ # ⇒ 그래서 `SUCCESS` 뒤에 **무엇을 안 봤는지**를 기계 필드로 찍는다. 침묵하면 호출자가
846
+ # «조합이 유효하다»로 읽는다 — 이 파일이 D4 에서 이미 정한 «미측정 ≠ PASS» 와 같은 규율.
847
+ printf 'FH_STATUS: SUCCESS\n'
848
+ printf 'FH_MERGE_SCOPE: SCHEMA_ONLY (병합 계산만 — 등록 시점 검사 미실행)\n'
849
+ printf 'FH_REGISTRY_GATE: NOT_RUN (이 경로는 capability_registry_check 를 부르지 않는다. `run` 에서만 검사한다)\n'
850
+ _print_merge
706
851
  }
707
852
 
708
853
  case "${1:-}" in
@@ -527,6 +527,9 @@ for _pair in \
527
527
  "scripts/knowledge_seam_check.sh|scripts/test_knowledge_seam_lanes.sh" \
528
528
  "templates/.git-hooks/pre-commit|scripts/test_marker_crossfamily_lanes.sh" \
529
529
  "templates/.git-hooks/pre-commit|scripts/test_marker_floor_lanes.sh" \
530
+ ".github/workflows/regression-guard.yml|scripts/test_regression_guard_ci_lanes.sh" \
531
+ "templates/.git-hooks/pre-commit|scripts/test_marker_standpoint_lanes.sh" \
532
+ "templates/.git-hooks/pre-commit|scripts/test_marker_thirdparty_lanes.sh" \
530
533
  "scripts/residency_closure_scan.py|scripts/test_residency_closure_lanes.sh" \
531
534
  "scripts/reviewer_capability_corpus.tsv|scripts/test_reviewer_capability_conformance.sh" \
532
535
  "scripts/field_canon_preload.sh|scripts/test_field_canon_lanes.sh" \
@@ -958,6 +961,21 @@ else
958
961
  fail=1
959
962
  fi
960
963
 
964
+ # target_freeze — 같은 형태(shipped subject + 자기 앵커). 🟥 이 블록이 없어서 `lane-runner`
965
+ # 검사가 «돌리는 게 없는 스위트는 산문이다» 로 정확히 걸었다. 지은 사람이 자기 레인을 안
966
+ # 배선한 것이고, 오늘 이 세션이 남의 코드에서 네 번 잡은 그 얼굴이다.
967
+ if [ ! -f scripts/target_freeze.sh ]; then
968
+ echo "FAIL scripts/target_freeze.sh is in package.json files[] but absent — a deleted subject, not a skip"
969
+ fail=1
970
+ elif [ -f scripts/test_target_freeze_lanes.sh ]; then
971
+ if ! bash scripts/test_target_freeze_lanes.sh; then
972
+ fail=1
973
+ fi
974
+ else
975
+ echo "FAIL test_target_freeze_lanes.sh: target_freeze.sh present but its anchor is missing"
976
+ fail=1
977
+ fi
978
+
961
979
  # And the ablation calibrator, for the third instance of the same reason. Its whole job is telling
962
980
  # apart states that look identical from outside — "the arm could not answer" vs "the runner is dead"
963
981
  # vs "the runner read the answer off disk" — and round 2 of its own adversarial review found that