@chrono-meta/fh-gate 1.4.98 → 1.4.99

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.
@@ -8,10 +8,10 @@
8
8
 
9
9
  | What | Count | Notes |
10
10
  |---|---:|---|
11
- | Active skills | **33** | 29 in `fh-meta` + 4 in `fh-commons`; 3 deprecated redirect stubs not counted |
11
+ | Active skills | **40** | 35 in `fh-meta` + 5 in `fh-commons`; **0** deprecated redirect stubs currently exist, so that exclusion is a no-op today |
12
12
  | Agent definitions | **8** | `challenger`, `quench-challenger`, `fact-checker`, `hub-persona-auditor`, `persona-innovator`, `beginner`, `main-player`, `expert` |
13
- | Operating rules | **6** | `.claude/rules/*.md` — mapping, modes, sync, sister-asset, operations |
14
- | Knowledge docs | **23** | `knowledge/` — 6-axis framework, compounding loop, runtime flow, dialogue playbook |
13
+ | Operating rules | **1** | `.claude/rules/*.md` — `fh_4axis_gate.md`. The drop from 6 is **not** deletion: the others were relocated to `knowledge/shared/rules/` so they stop loading on every session, and this path now holds only the path-scoped gate |
14
+ | Knowledge docs | **57** | `knowledge/` — 6-axis framework, compounding loop, runtime flow, dialogue playbook, and the harness-core canon |
15
15
  | Plugins | **2** | `fh-meta` (meta-harness) + `fh-commons` (project-agnostic) |
16
16
  | Self-gate | **1** | 4-axis pre-commit hook (backward / adversarial / forward / record) |
17
17
 
@@ -19,12 +19,17 @@
19
19
 
20
20
  | Metric | Value |
21
21
  |---|---|
22
- | First commit → latest | **2026-05-26 → 2026-06-06** (12 days) |
23
- | Commits | **224** |
24
- | Merged PRs | **66** |
22
+ | First commit → latest | **2026-05-26 → 2026-08-15** (81 days) |
23
+ | Commits | **768** |
24
+ | Merged PRs | **372** |
25
25
 
26
- > Read honestly: this is *velocity*, not *maturity*. A 12-day-old project is early. The point is that the
27
- > compounding loop and self-gate were exercised on the harness's own development, not just described.
26
+ > ⚠️ **Counted 2026-08-15; a pace table is stale the day after it is written.** The previous version of
27
+ > this block sat at "12 days / 224 commits / 66 PRs" for two months and read as current, because nothing
28
+ > in it said when it was measured. Re-run the commands below rather than trusting the numbers above —
29
+ > and if you update them, update this date in the same edit.
30
+
31
+ > Read honestly: this is *velocity*, not *maturity*. The point is that the compounding loop and self-gate
32
+ > were exercised on the harness's own development, not just described.
28
33
 
29
34
  ## External artifacts (verifiable links)
30
35
 
@@ -106,10 +111,14 @@ rather than only synthetic ones.
106
111
  <sub>Reproduce the counts:</sub>
107
112
 
108
113
  ```bash
109
- # active skills (excludes deprecated redirect stubs)
110
- for d in plugins/*/skills/*/; do grep -qi "DEPRECATED merged\|redirect stub\|moved to" "$d/SKILL.md" || echo "$d"; done | wc -l
111
- # agents
112
- ls .claude/agents/*.md plugins/*/agents/*.md | wc -l
114
+ # active skills. NOTE: the old recipe here grepped each SKILL.md for "redirect stub"/"deprecated"
115
+ # and returned 38, because phantom-quench and hub-cc-pr-reviewerboth live merely MENTION those
116
+ # words in their prose. A body-text grep cannot tell "I am a stub" from "I detect stubs". There are
117
+ # currently zero stubs, so count the files and re-introduce an exclusion only when one exists, in
118
+ # frontmatter where it can be matched on a field rather than on a phrase.
119
+ find plugins -name SKILL.md | wc -l
120
+ # agents (there is no .claude/agents/ in this repo — that path is for field projects)
121
+ find plugins -path '*/agents/*.md' | wc -l
113
122
  # knowledge docs
114
123
  find knowledge -name '*.md' | wc -l
115
124
  # pace
@@ -1997,3 +1997,13 @@
1997
1997
  ★**The phantom-quench audit flagged a claim PHANTOM by reading a stale illustrative comment as live ground truth** — the checker's own header names an old example subject (directional_diff_gate.sh) instead of the real live-computed one (relay_channel.sh). Re-derived ground truth by RUNNING the checker's own Python detection logic directly rather than trusting either the audit's reading or the comment: confirmed the diff's own claim was TRUE, but the investigation surfaced a genuinely separate, real bug — lane_runner_check.sh's self-test detector scans a subject's OWN file among its 'runners' search and misreads directional_diff_gate.sh's usage-example comment as a real caller (self-referential false positive), so that subject silently reads as wired when nothing actually calls it. Recorded, not fixed this session (different file/job) — tracks/_meta/fh_signal_2026-08-15_selfcheck-wiring.md.
1998
1998
  ⚠️ **A same-session self-correction on the codex dispatch itself**: an operator-preferred lighter codex tier (gpt-5.6-luna) was believed to have silently fallen back to a bare 'gpt-5' based on a self-report identity probe ('what model are you'). The operator flagged this as surprising given the CLI's own model picker showed it as a valid current tier. A live control (pinning a clearly-different, smaller model and repeating the same probe) got the identical 'gpt-5' self-report, while the tool's own `model:` banner line was correct every time — proving the self-report probe itself is not a discriminating identity signal for this CLI, not that the pin failed. The R2 dispatch had already redirected to gpt-5.5-xhigh before this was caught, so the cross-family result stands (a clean pass on a stronger model is not weakened by this), but the diagnosis that motivated the redirect was wrong and is corrected in the Axis-2 marker.
1999
1999
  🟥 **Tally discrepancy, named not forced**: the mechanical SubagentStop tally counted 16 dispatches machine-wide for 2026-08-15; this entry accounts for 3 substantive + 3 diagnostic = 6. The remaining ~10 are unattributed — a live peer session shared this exact checkout for most of the day (confirmed via ListAgents/SendMessage during this session's own branch-claim conflict) and very likely contributed some of them, but the tally has no per-spawn attribution (same structural gap this log already named on 2026-08-13), so the split is not reconstructable after the fact. Recorded as unknown rather than estimated to fit."
2000
+
2001
+ - date: 2026-08-15
2002
+ session: fh-parallel-59b59c27 (Opus 5 1M, bg, A축 lane_runner 자기참조 오탐 + directional_diff_gate 배선 → README 3층 정본 반영)
2003
+ agents_summary: "4 dispatches: codex gpt-5.5×1 (cross-family adversarial on the working-tree diff, live tool execution) · fh-meta:challenger×1 (Axis 2 adversarial, isolated, static-trace only — Bash not in its tool set) · general-purpose×1 (Axis 3 phantom-quench grounding audit over the same diff) · Explore×1 (README.md staleness sweep vs measured repo reality)."
2004
+ dispatch_count: 4
2005
+ outcome: accepted
2006
+ evidence: "codex R1 → 2A+1B, all source-grounded and closed: `calibration passed (0 pairs)` certifies a suite whose lanes were all deleted (reproduced by neutralizing all 23 `t` lanes → rc=0 + PASS); a usage banner satisfied the substring match; no timeout while both neighbouring blocks had one. fh-meta:challenger R2 → 4A+6B, the decisive one falsifying this delta's own evidence: L14/L15 both put the self-reference on a `#` line, so comment-stripping alone satisfied them and the self-exclusion guard could be reverted with 16/16 still green — verified by executing the revert, not by reading. Closed with lane L16 (non-comment self-reference); guard present → 17/17, removed → exactly L16 red. Grounding audit → 31 claims, 0 phantoms, CONDITIONAL_PASS, 3 B closed (chief among them a factually wrong `$_LANE_TO` attribution that would have told the next editor a `set -u` dependency was safe to delete). Explore sweep → README roster enumerates 37 of 40 skills and 4 of 8 agents, model-tier list pinned to an older generation and naming a tier the source artifact deliberately anonymizes, and docs/OUTPUT_EVIDENCE.md drifted hard (skills 33 vs 40, rules 6 vs 1, knowledge 23 vs 57, '12-day-old project' vs 81 days, commits 224 vs 767)."
2007
+ notes: "★**Self-caught 0 of 7 A-grade.** Two different reviewer engines produced DISJOINT A-grade sets — codex found the verdict-predicate family, the challenger found the unanchored-guard family — which is the concrete argument for two legs over one leg twice, on the same diff, same day.
2008
+ ★**The challenger had no Bash** and said so plainly rather than implying execution ('아래는 전부 정적 추적이고, 실행 확인은 0건'). Its decisive finding was still correct, and the governor executed the revert to confirm it — sidecar finding as evidence candidate, verdict closed by a mechanical anchor.
2009
+ 🟥 **Self-inflicted, self-caught**: the Explore sweep was dispatched and README.md was then edited while it ran — `[[feedback_audit_target_must_be_frozen]]` reproduced. The agent itself flagged the file changing under it (494 → 572 lines). Its verdicts on the edited region were discarded; only the counts/links/translation-drift findings were used, and the translation port was deferred until the English file is committed and frozen."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrono-meta/fh-gate",
3
- "version": "1.4.98",
3
+ "version": "1.4.99",
4
4
  "description": "FH runtime adapters — run FH governance, skills, and agents via Claude or Codex with machine-parseable gates.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fh-commons",
3
- "version": "1.4.98",
3
+ "version": "1.4.99",
4
4
  "engines": {
5
5
  "claudeCode": ">=1.0.0"
6
6
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fh-meta",
3
- "version": "1.4.98",
3
+ "version": "1.4.99",
4
4
  "engines": {
5
5
  "claudeCode": ">=1.0.0"
6
6
  },
@@ -10,6 +10,107 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
10
10
 
11
11
  ## Plugin Level
12
12
 
13
+ ### [1.4.99] — 2026-08-15
14
+
15
+ **fix: 검출기가 «호출부 0» 을 «10/10 초록» 으로 보고하고 있었다 — 그리고 이 릴리스는 #388 의 첫 출하이기도 하다**
16
+
17
+ - 🔴 **`lane_runner_check.sh` 가 거짓 초록을 생산했다.** 이 도구의 존재 이유가 «호출자 없는 레인
18
+ 찾기» 인데, `has_selftest_runner()` 가 subject **자기 파일**을 `runners` 에 포함하고
19
+ `selftest_dispatched()` 가 주석줄을 안 걸렀다. 그래서 `directional_diff_gate.sh` 가 **자기 usage
20
+ 주석**(`# bash scripts/directional_diff_gate.sh --self-test`)을 호출부로 오독당해, 실제 호출부가
21
+ **0개**인데 리포트는 `self-test: 10/10 wired` 를 찍었다. 같은 파일의 `has_runner()` /
22
+ `runner_dispatches()` 는 그 두 가드를 **이미** 갖고 있었다 — 몰라서가 아니라 **한 술어에만
23
+ 적용**돼 있었다. 검출기 수리와 그 부채의 실제 배선을 **한 커밋에** 실었다(따로 실으면 한쪽은
24
+ «새로 보이는데 여전히 안 도는 부채», 다른 쪽은 «거짓으로 평평한 카운트» 가 된다).
25
+ 🟥 **참값은 6일 전부터 기록돼 있었다** — 2026-08-09 핸드오프가 컨트롤까지 붙여 «호출자 0» 을 재고
26
+ *"오신뢰를 하나 더한 상태"* 라고 진단해 뒀는데, 그 6일 동안 검출기는 10/10 을 보고했고 이 건이
27
+ 닫힌 경로는 그 문서가 아니라 **검출기 자신의 오탐을 쫓던 세션**이다.
28
+ - **`selfcheck.sh` 의 판정은 종료코드만으로 하지 않는다.** 새 `directional_diff_gate --self-test`
29
+ 블록은 **종결 판정줄을 통째로**(`^✅ calibration passed (N pairs)$`) 요구하고 **N > 0** 을 강제한다.
30
+ cross-family 라운드가 준 구체 입력: 그 subject 는 *실패* 수가 0이면 **레인 수와 무관하게** 통과줄을
31
+ 찍으므로, 레인을 전부 지우면 `calibration passed (0 pairs)` + `exit 0` 이 나온다 — **삭제가 조용한
32
+ PASS 로 렌더**된다. 부분문자열 매치였다면 usage 배너도 게이트를 만족시켰다. `rc=124`(timeout kill)는
33
+ «레인 실패» 가 아니라 **HARNESS ERROR** 로 라우팅한다.
34
+ - **회귀 앵커 7개 신설** — `test_lane_runner_lanes.sh` 14→17, `test_selfcheck_state_lanes.sh` 44→48.
35
+ L16 이 특히 load-bearing 하다: 같은 세션의 적대검증이 **처음 넣은 자기제외 가드에 앵커가 없음**을
36
+ 지목했고(레인 둘 다 픽스처의 자기참조를 `#` 주석으로 써서, 가드를 되돌려도 16/16 초록이었다),
37
+ 비-주석 픽스처를 넣고서야 «가드 있으면 17/17 · 없으면 정확히 L16 만 적색» 이 됐다.
38
+ - **문서: README 가 정본 이전 프레이밍을 싣고 있었다.** `① Assemble / ② Forge / ③ Sidecar /
39
+ ④ Self-evolving loop` 는 2026-08-09 정식화 **이전** 판으로, 5대 정체성과 절반 겹치면서 **⑤ 증폭자와
40
+ ② 인큐베이터가 통째로 빠져** 있었다. 3층 정본(5정체성 → 4엔진 → 3단 공정 + 4축 검증 + standpoint)
41
+ 으로 교체하고 **번역 3종(ko/zh/ja)까지 같은 릴리스에서** 맞췄다. 등급표는 복제하지 않는다 —
42
+ `ship_readiness_gate.md` 가 유일 정본이고, 이 페이지는 4개 언어라 복제하면 사본이 4개가 된다.
43
+ - **수치 청소(전부 재측정)**: 로스터가 스킬 37/40 · 에이전트 4/8 만 열거하던 것 → 40/40 · 8/8 ·
44
+ `docs/OUTPUT_EVIDENCE.md` 스킬 33→40 · 규칙 6→1(삭제가 아니라 `knowledge/shared/rules/` 로 이전) ·
45
+ 지식 23→57 · 「12일차 · 커밋 224 · PR 66」→ **81일 · 768 · 372**, 그리고 **측정 날짜를 박았다** —
46
+ 이 표가 두 달간 현재처럼 읽힌 원인은 숫자가 아니라 «언제 쟀는지 안 적힌 것» 이었다.
47
+ 그 문서의 재현 recipe 는 SKILL.md 본문에서 "deprecated"/"redirect stub" 을 grep 해 **살아있는 스킬
48
+ 2개**(`phantom-quench` · `hub-cc-pr-reviewer`)를 stub 으로 오분류하고 38 을 뱉고 있었다 — 본문 grep 은
49
+ «내가 stub 이다» 와 «나는 stub 을 검출한다» 를 구분하지 못한다. 교체했다.
50
+ - 🟥 **이 릴리스는 #388 의 첫 출하이기도 하다.** `v1.4.98` 태그가 `#387` 에서 잘렸고 `#388`
51
+ (consent-gated auto fast-forward)은 그 뒤에 머지돼 **레지스트리에 한 번도 나간 적이 없다.**
52
+ 실측 대조: 출하된 1.4.98 tarball 의 `scripts/fh_node_check.sh` 는 **243줄**로 consent 게이트 arm 이
53
+ 통째로 없고, main 은 **282줄**이다. 1.4.98 항목이 «버전을 안 올려 런타임에 도달 못 한 규칙» 을
54
+ 기록했는데 **같은 얼굴이 태그 절단 지점에서 재발**했다. 다만 이번엔 계기가 잡았다 —
55
+ `session_close_check.sh` ④-b 가 «마지막 태그 이후 출하자산 변경» 을 발화했고, 이번엔 그 출력을
56
+ 표시필터로 자르지 않았다.
57
+ - **공개면 정리**: `README.zh.md` 가 마지막 두 줄에 `</content>` `</invoke>` 도구 잔재 태그를
58
+ **공개 상태로** 싣고 있었다(이전 커밋부터). 제거.
59
+
60
+ 🟥 **출하 직전 보안 패스가 이 릴리스를 한 번 막았고, 그래서 이 항목이 더 있다.** `npm publish` 전
61
+ 게이트로 돌린 코드 보안 리뷰가 **BLOCK** 을 냈다 — `scripts/fh_node_check.sh` 의 consent 게이트가
62
+ **클래스를 조인하지 않았다.** 두 조건이 각각 이랬다: ① `consent_registry_check.sh` 를 인자 없이 돌린
63
+ rc=0 — 그건 «레지스트리와 grant 들이 well-formed 하고 floor join 이 성립» 이라는 **파일 전체 속성**이라
64
+ **무관한 클래스 하나만 유효하게 승인돼 있어도 0** 이 난다 ② UAP **파일 전체**에 대한 raw grep 이라
65
+ `granted` 와 `revoked` 를 구분하지 못하고, 히트가 기계 판독 영역인 frontmatter 안인지 산문 문단인지도
66
+ 보지 않는다. **컨트롤 동반 재현**: 무관한 클래스 1건 유효 승인 + 대상 클래스는 산문에
67
+ ` repo-freshness-autopull: 안 쓰기로 했다` 한 줄 → 두 조건 통과 → **자동 fast-forward 실행**, 그리고
68
+ 배너는 운영자에게 *"standing consent"* 가 있다고 말한다. 망가진 쪽이 하필 **철회 경로**였고, 그건
69
+ `absent ≠ granted` floor 가 지키려던 바로 그 자리다.
70
+ - **수리**: `consent_registry_check.sh --require-class NAME` 신설 — 그 클래스가 **활성·등록·미만료
71
+ grant 로 전 항목을 통과했을 때만 0**, 아니면 3(=계속 물어라). 파일 전체 위반은 여전히 1 이 이긴다
72
+ («판정 불가 == 불허»는 그 스크립트 자신의 규칙이다). 조인 대상은 `validated`(per-grant 검사 **전에**
73
+ 증가하는 카운터)가 아니라 **모든 검사를 통과한 이름 집합**이다. 플래그를 안 주면 동작은 종전과 동일.
74
+ - **없던 컨트롤을 채웠다** — `lane10-f WRONG-CLASS`. 기존 거부 arm 셋(c/d/e)은 전부 **파일 전체 판정을
75
+ 깨는 방식**으로 거부를 만들어서(grant 블록 삭제 · 리스 과거화 · 발산), **클래스 조인 축을 하나도
76
+ 건드리지 않았다.** 새 arm 은 «다른 클래스는 유효 승인 + 이 클래스는 산문에만» 상태에서 거부되는지를
77
+ 보고, **동시에 「그 순간 파일 전체 검사가 여전히 0인지」를 같이 단언**한다 — 그 두 번째 단언이 없으면
78
+ lane10-c 와 같은 이유로 통과해 축이 또 안 돌아간다.
79
+ 🟥 그 레인도 **처음엔 장식이었다**: 되돌림 프로브를 돌리니 수리를 되돌려도 초록이었다(앞 arm 이 픽스처를
80
+ 발산 상태로 남겨 consent 와 무관하게 ff 가 거부됐다). 전제조건을 리셋·단언하도록 고쳤고, 그 리셋이
81
+ **tracked UAP 편집을 되돌린다**는 두 번째 함정도 순서를 뒤집어 닫았다. 최종: 수리 있으면 24/24,
82
+ 되돌리면 **정확히 lane10-f 만** 적색.
83
+ - **그 수리 자체도 cross-family 가 4건 깎았다** — 전부 **fail-open** 이었다: ⓐ `--require-class ' '`
84
+ (공백만)이 bash 의 `-z` 를 통과한 뒤 python 이 strip 해서 빈 문자열이 되어 **좁히기가 조용히 꺼졌다**
85
+ ⓑ 플래그를 경로 **뒤에** 쓰면 무시되고 옛 파일 전체 판정이 나왔다 ⓒ 미지 옵션이 경로로 삼켜졌다
86
+ ⓓ 클래스 거부인데 요약줄은 `PASS` 를 찍어 **판정과 모순**했다(이 파일 자신이 「요약이 판정과 모순되면
87
+ 거짓 초록」이라고 적어 둔 규칙). 넷 다 fail-closed 로 닫고 레인 10개를 붙였다(88 → 98).
88
+ - **라운드 3 이 또 두 건을 깎았고 하나는 내가 만든 A급이었다** — 첫 수리가 `tr -d '[:space:]'` 로
89
+ 이름의 **모든 공백을 삭제**해서 `--require-class 'repo-freshness-auto pull'` 이 실제 클래스로 접혀
90
+ **다른 이름을 물었는데 다른 클래스의 판정을 받았다**(신원 붕괴). 지금은 **트림만** 하고 내부 공백이
91
+ 남으면 **고쳐주지 않고 거부**한다 — 신원을 조용히 수선하는 것이 바로 잘못된 클래스가 승인되는 경로다.
92
+ 같은 라운드가 인자 재조립도 깎았다: 개행 구분 문자열 방식이 **argv 보존이 아니라** 개행 포함 경로를
93
+ 쪼개고 빈 위치인자를 드롭해 **UAP 를 레지스트리 자리로 승격**시켰다 → bash 배열로 교체. 레인 +4(98 → 102).
94
+ - **라운드 4 = 새 S/A 0** (수렴). 남은 B 1건은 **명시 잔여로 소스에 적었다**: 영-폭 결합자 같은 보이지
95
+ 않는 문자가 클래스 이름으로 허용된다. 같은 라운드 컨트롤이 **거짓 조인은 없음**을 보였고(`ok` vs
96
+ `o<ZWJ>k` → 3, NFC/NFD 불일치 → 3), 악용에는 레지스트리와 UAP **둘 다** 쓰기 권한이 필요한데 그건
97
+ 동의의 원천 자체라 권한 상승이 아니다. 올바른 수리는 NFC 정규화 + format/control 문자 거부이지
98
+ 비-ASCII 일괄 거부가 아니다(이 하네스는 언어 중립이고 한글 클래스명은 정당하다) — 릴리스 중에 넣을
99
+ 줄이 아니라 자기 known-pair 를 갖는 설계 결정이다.
100
+ - **머지 시간 바운드는 넣었다가 뺐다** — cross-family 실측이 그 워치독이 **git 을 안 묶는다**를 보였다
101
+ (`perl alarm 2` 로 감싼 ff 머지가 느린 smudge 필터 앞에서 **7.9초 걸리고 rc=0**). 안 묶는 장치를
102
+ 「바운드」라는 이름으로 남기면 이 릴리스가 고치려는 거짓 초록을 내가 다시 만드는 것이라 제거했다.
103
+ 🟥 **파급이 더 있다**: 같은 워치독이 그 위 **fetch** 도 감싸고 있고 그건 이 변경 이전부터다 —
104
+ 즉 fetch 가 실제로 묶이는지는 이제 **가정이 아니라 UNVERIFIED** 다. 소스에 잔여로 적었다.
105
+ - **머지에 자기 예산을 줬다**(`FH_NODE_GIT_MERGE_DEADLINE`, 기본 5s). fetch 는 워치독이 있는데 머지는
106
+ 없어서, fetch 가 예산 8s 를 다 쓰면 10s 훅 예산 안에서 큰 fast-forward 가 워킹트리 갱신 도중 죽을 수
107
+ 있었다. **실소요는 안 쟀다 — 바운드지 초과했다는 주장이 아니다.**
108
+
109
+ **남은 잔여(명시)**: 주석 필터는 줄-접두 전용이라 heredoc/문자열 안의 디스패치는 여전히 계수된다 ·
110
+ subject **발견** 쪽은 아직 주석을 읽는다(«언급 ≠ 선언» 이 runner 쪽에만 강제된다) ·
111
+ `selfcheck.sh` 의 인접 두 루프는 `rc=124` 를 라우팅하지 않는다(하나는 무음 실패, 다른 하나는
112
+ «dispatcher missing?» 이라는 확신에 찬 오진) — Added-Scope Gate 로 분리해 신호로 이월.
113
+
13
114
  ### [1.4.98] — 2026-08-15
14
115
 
15
116
  **fix: 1.4.97 이후 쌓인 출하면 36파일을 실제로 배포한다 — 특히 «있는데 실행에 도달 못 하던» 검증 축 하나**
@@ -66,9 +66,94 @@
66
66
  #
67
67
  # "N/A" is printed as N/A, never as PASS — an unmeasured surface is not a clean one.
68
68
  #
69
- # Usage: bash scripts/consent_registry_check.sh [registry.yaml] [uap.md-or-yaml]
69
+ # Usage: bash scripts/consent_registry_check.sh [--require-class NAME] [registry.yaml] [uap.md-or-yaml]
70
+ #
71
+ # `--require-class NAME` narrows the verdict from FILE-WIDE to ONE CLASS, and a caller acting on
72
+ # behalf of a single class MUST use it. Without it, exit 0 means "the registry and the grants are
73
+ # well-formed and the floor join holds" — a property of the FILE. A caller that reads that 0 as
74
+ # "my class is granted" is wrong whenever any OTHER class is validly granted.
75
+ #
76
+ # That is not hypothetical. Measured 2026-08-15 with a live control: fh_node_check.sh gated its
77
+ # auto-fast-forward on this script's file-wide 0 AND a raw `grep` for the class name anywhere in the
78
+ # UAP. With one unrelated class validly granted and `repo-freshness-autopull` appearing only as a
79
+ # prose line saying it had been REVOKED, both conditions passed and the merge ran — while the banner
80
+ # told the operator it was acting on a standing consent that did not exist. The control (a real
81
+ # grant for the class) also returned 0, so the two states were indistinguishable through that channel.
82
+ # With `--require-class` the same pair separates: 0 for the real grant, 3 for the revoked one.
70
83
  set -uo pipefail
71
84
  ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
85
+ # Flag parsing, hardened by a cross-family round on the first draft. Three defects it found, all
86
+ # reproduced, all fail-OPEN — the flag would appear to be in force while the verdict stayed file-wide:
87
+ # · `--require-class ' '` passed the `-z` test (non-empty), then Python's `.strip()` reduced it
88
+ # to "" and the require-class branch silently switched off.
89
+ # · the flag was read only at argv position 1, so `... reg.yaml uap.md --require-class NAME` was
90
+ # ignored without a word and returned the old 0.
91
+ # · an unknown flag was consumed as a path.
92
+ # A gate whose ON switch can be silently OFF is worse than no gate, so all three now fail closed.
93
+ FH_REQUIRE_CLASS=""
94
+ FH_REQUIRE_CLASS_SET=""
95
+ _pos=()
96
+ # `_norm_class` TRIMS the ends. It does NOT delete interior whitespace, and the difference is the
97
+ # whole point: a third round measured `--require-class 'repo-freshness-auto pull'` being squeezed to
98
+ # `repo-freshness-autopull` and returning 0 — i.e. asking about one class and being told about a
99
+ # DIFFERENT one. A name that still contains whitespace after trimming is refused rather than
100
+ # repaired, because silently repairing an identity is how the wrong class gets authorized.
101
+ _norm_class() {
102
+ local v="$1"
103
+ v="${v#"${v%%[![:space:]]*}"}" # strip leading
104
+ v="${v%"${v##*[![:space:]]}"}" # strip trailing
105
+ printf '%s' "$v"
106
+ }
107
+ _set_require_class() {
108
+ FH_REQUIRE_CLASS_SET=1
109
+ FH_REQUIRE_CLASS="$(_norm_class "$1")"
110
+ if [ -z "$FH_REQUIRE_CLASS" ]; then
111
+ echo "consent-registry: FAIL — --require-class given with an empty or whitespace-only class name; fail-closed" >&2
112
+ exit 1
113
+ fi
114
+ case "$FH_REQUIRE_CLASS" in
115
+ *[[:space:]]*)
116
+ echo "consent-registry: FAIL — class name '$FH_REQUIRE_CLASS' contains whitespace; a class identity is not normalised for you (fail-closed)" >&2
117
+ exit 1 ;;
118
+ esac
119
+ # 🟥 NAMED RESIDUAL — invisible and normalisation-confusable identities are still ACCEPTED.
120
+ # Round 4 measured it: a class whose name carries a zero-width joiner is admitted as an identity,
121
+ # and `unicodedata` is applied to the effect vocabulary but never to class names or grant keys.
122
+ # Why it is carried rather than closed here, stated so the next reader does not have to re-derive
123
+ # it: the same round's controls showed the failure direction is SAFE — `ok` vs `o<ZWJ>k` returns 3,
124
+ # and an NFC/NFD mismatch returns 3, so no confusable name FALSELY joins a real grant; the residue
125
+ # is that a weird name can be its own consistent identity. Exploiting that requires write access to
126
+ # BOTH the registry and the UAP, which are the consent source of truth — anyone holding those has
127
+ # already granted themselves whatever they wanted, so this is hygiene, not escalation.
128
+ # The right fix is NFC-normalising both sides and refusing Unicode format/control characters, NOT
129
+ # a blanket non-ASCII refusal: this harness is language-agnostic and a Korean class name is
130
+ # legitimate. That is a design decision with its own known-pair, not a line to add during a release.
131
+ }
132
+ # Positionals go into a bash ARRAY. The first draft accumulated them into a newline-delimited string
133
+ # and rebuilt "$@" with `read` — measured not to be argv-preserving: a path containing a newline
134
+ # split into two, and an empty positional was dropped, which silently promoted the UAP path into the
135
+ # registry slot. This file is `#!/usr/bin/env bash`, so an array is available and exact.
136
+ while [ "$#" -gt 0 ]; do
137
+ case "$1" in
138
+ --require-class)
139
+ if [ "$#" -lt 2 ]; then
140
+ echo "consent-registry: FAIL — --require-class given with no class name; fail-closed" >&2
141
+ exit 1
142
+ fi
143
+ _set_require_class "$2"; shift 2 ;;
144
+ --require-class=*)
145
+ _set_require_class "${1#--require-class=}"; shift ;;
146
+ --)
147
+ shift; while [ "$#" -gt 0 ]; do _pos+=("$1"); shift; done ;;
148
+ --*)
149
+ echo "consent-registry: FAIL — unknown option '$1'; fail-closed rather than treating it as a path" >&2
150
+ exit 1 ;;
151
+ *)
152
+ _pos+=("$1"); shift ;;
153
+ esac
154
+ done
155
+ set -- ${_pos[@]+"${_pos[@]}"}
156
+ export FH_REQUIRE_CLASS FH_REQUIRE_CLASS_SET
72
157
  REG="${1:-$ROOT/tracks/_meta/consent_classes.yaml}"
73
158
  UAP="${2:-$ROOT/tracks/_meta/user_adaptation_profile.md}"
74
159
 
@@ -474,6 +559,16 @@ else:
474
559
  fails += 1; grants = None
475
560
 
476
561
  no_active_grant = False
562
+ # Names that survived EVERY per-grant check. `--require-class` joins against this set, never against
563
+ # `validated` (a count that includes names which then failed) and never against the file-wide verdict.
564
+ clean_grants = set()
565
+ REQUIRE_CLASS = os.environ.get("FH_REQUIRE_CLASS", "").strip()
566
+ # "was the flag given" is tracked separately from "is the name non-empty". Collapsing them is how
567
+ # the first draft turned a whitespace-only name into a silent fall-back to the file-wide verdict.
568
+ REQUIRE_SET = os.environ.get("FH_REQUIRE_CLASS_SET", "") == "1"
569
+ if REQUIRE_SET and not REQUIRE_CLASS:
570
+ print("consent-registry: FAIL — --require-class resolved to an empty class name; fail-closed")
571
+ sys.exit(1)
477
572
  if grants is None:
478
573
  pass
479
574
  elif not grants:
@@ -522,6 +617,12 @@ else:
522
617
  if st.strip().lower() in NON_GRANT:
523
618
  continue
524
619
  validated += 1
620
+ # Failure count at the START of this grant's checks. A name joins `clean_grants` at the end
621
+ # of the body only if nothing was recorded against it in between — `validated` cannot serve
622
+ # that purpose, because it is incremented HERE and every failing branch below still counted.
623
+ # Each of those branches also `continue`s after `fails += 1`, so a failing name never reaches
624
+ # the add; this counter covers the non-continuing ones.
625
+ _f0 = fails
525
626
  c = by_name.get(name)
526
627
  if c is None:
527
628
  out("❌", f"R3 `{name}` granted but NOT in the registry (unregistered == unknown)"); fails += 1; continue
@@ -669,6 +770,8 @@ else:
669
770
  out("❌", f"R7 `{name}` grant target {tgt!r} does not match the registered target "
670
771
  f"{c['target']!r} — scope drift between grant and class")
671
772
  fails += 1
773
+ if fails == _f0:
774
+ clean_grants.add(name)
672
775
  skipped = len(grants) - validated
673
776
  if validated == 0:
674
777
  # Same state as the empty-grants branch above, reached differently: every key present was
@@ -682,6 +785,11 @@ else:
682
785
  out("✅", f"R3-R6 all {validated} active grant(s) registered, eligible, unexpired, "
683
786
  f"scope-recorded{note}")
684
787
 
788
+ # The class-scoped verdict is decided BEFORE the summary, so the summary can agree with it. The
789
+ # first draft printed it after, which produced `consent-registry: PASS` on a run that then exited 3
790
+ # for the requested class — the exact contradiction this file's own comment below calls a false
791
+ # green with extra steps (cross-family, 2026-08-16).
792
+ _class_missing = bool(REQUIRE_CLASS) and not fails and REQUIRE_CLASS not in clean_grants
685
793
  print("----")
686
794
  # The human-facing summary must agree with the typed exit. It previously printed PASS on a run whose
687
795
  # own line above said "nothing granted, keep asking (not a PASS)" and whose exit code was 3 — so an
@@ -690,8 +798,15 @@ print("----")
690
798
  # false green with extra steps. (Caught by hand 2026-08-02 while verifying the exit-3 fix.)
691
799
  if fails:
692
800
  print(f"consent-registry: {fails} violation(s)")
801
+ elif _class_missing:
802
+ seen = "none" if not clean_grants else ", ".join(sorted(clean_grants))
803
+ print(f"consent-registry: UNMEASURED for class `{REQUIRE_CLASS}` — nothing granted for it, "
804
+ f"keep asking (exit 3). Active grants that DID join: {seen}. A grant for another class, "
805
+ f"or the name merely appearing in the file, is not consent for this one.")
693
806
  elif no_active_grant:
694
807
  print("consent-registry: UNMEASURED — nothing granted, keep asking (exit 3)")
808
+ elif REQUIRE_CLASS:
809
+ print(f"consent-registry: PASS for class `{REQUIRE_CLASS}` (active, registered, unexpired)")
695
810
  else:
696
811
  print("consent-registry: PASS")
697
812
  # BROKEN outranks UNMEASURED: a violation is a decided negative, "nothing granted" is merely nothing
@@ -703,5 +818,13 @@ if ZERO_CLASSES and not fails:
703
818
  print("consent-registry: FAIL — a standing grant exists but the registry declares zero "
704
819
  "classes; every such grant is UNREGISTERED (R3), which is BROKEN, not unmeasured")
705
820
  sys.exit(1)
821
+ # ── --require-class: narrow the verdict to ONE class ───────────────────────────────────────────
822
+ # Ordering is deliberate and matches the existing precedence: BROKEN (1) outranks everything, so a
823
+ # violation anywhere still exits 1 even when the required class itself looks fine — an unparseable
824
+ # neighbour means the file could not be decided, and "cannot decide == not allowed" is this script's
825
+ # own rule. Below that, a required class that did not join is UNMEASURED (3), the same code as
826
+ # "nothing granted", because to the caller they are the same instruction: KEEP ASKING.
827
+ if _class_missing:
828
+ sys.exit(3)
706
829
  sys.exit(1 if fails else (3 if no_active_grant else 0))
707
830
  PY
@@ -199,7 +199,75 @@ if git -C "$FH" rev-parse --git-dir >/dev/null 2>&1 && git -C "$FH" remote get-u
199
199
  case "$_BEHIND" in
200
200
  ''|*[!0-9]*) : ;; # not measurable — silent, not a claim
201
201
  0) : ;;
202
- *) GIT_BEHIND_NOTE="local $_DEFAULT_BRANCH is ${_BEHIND} commit(s) behind origin/$_DEFAULT_BRANCH — run: git checkout $_DEFAULT_BRANCH && git pull --ff-only (or: git merge --ff-only origin/$_DEFAULT_BRANCH)" ;;
202
+ *)
203
+ # ── auto-apply, consent-gated, inside a deliberately narrow envelope ───────────────
204
+ # Operator request 2026-08-15, verbatim: "사람이 일일이 수동으로 깃풀해서 최신화해야하는지를
205
+ # 판단하지않고 … 세션 시작 시 레포체크를 클로드가 알아서 하고 최신화 제안하는 기능이 있으면
206
+ # 좋을것같아. 그리고 앞으로도 자동으로 이렇게 동기화할지 물어보는 것도."
207
+ #
208
+ # 🟥 IT NEVER SWITCHES BRANCHES, and that is the whole safety envelope — not a nicety.
209
+ # The recommendation this line used to print told the reader to `git checkout
210
+ # $_DEFAULT_BRANCH && git pull`. In a SHARED CHECKOUT a checkout yanks the ground out from
211
+ # under a peer session: measured on this repo 2026-08-09 (two sessions, one worktree, one
212
+ # committed onto the other's branch), which is why scripts/branch_claim.sh exists at all.
213
+ # As prose advice a human weighed that; automated, nobody would. So the apply arm fires
214
+ # ONLY when the default branch is ALREADY checked out, and `--ff-only` means it can
215
+ # neither rewrite history nor absorb a divergence — it refuses instead.
216
+ #
217
+ # Consent is a LEASE, joined mechanically, never inferred: the class must be registered
218
+ # promotion_eligible in tracks/_meta/consent_classes.yaml AND granted unexpired in the
219
+ # UAP frontmatter. scripts/consent_registry_check.sh is the single decider (exit 0 = a
220
+ # real grant was joined; 3 = nothing granted; 1 = broken). Absent, expired, unreadable, or
221
+ # unknown all take the same branch as "no": surface, do not apply. absent ≠ granted.
222
+ # 🟥 The two conditions this replaced did NOT ask whether THIS class was granted, and a
223
+ # security pass before the 1.4.99 publish caught it with a live control (2026-08-15).
224
+ # · a bare run of consent_registry_check.sh returns 0 for a FILE-WIDE property — "the
225
+ # registry and the grants are well-formed and the floor join holds". One validly
226
+ # granted UNRELATED class produces that 0.
227
+ # · the second condition was `grep -q '^\s*repo-freshness-autopull:'` over the WHOLE UAP,
228
+ # which does not distinguish `granted` from `revoked` and does not care whether the
229
+ # hit is inside the machine-read frontmatter or in a prose paragraph.
230
+ # Reproduced: one unrelated class granted + the line ` repo-freshness-autopull: 안 쓰기로
231
+ # 했다` in prose → both conditions passed, the merge ran, and the banner told the operator
232
+ # it was acting on a standing consent that had never existed. The revoke path was the one
233
+ # that broke, which is the exact floor `absent ≠ granted` exists to hold.
234
+ # `--require-class` joins the ONE class: 0 only if it is an active, registered, unexpired
235
+ # grant; 3 otherwise. Same known pair now separates 0 from 3.
236
+ _AUTOPULL=""
237
+ if [ -x "$FH/scripts/consent_registry_check.sh" ] \
238
+ && bash "$FH/scripts/consent_registry_check.sh" --require-class repo-freshness-autopull >/dev/null 2>&1; then
239
+ _AUTOPULL=1
240
+ fi
241
+ _ON_DEFAULT=""
242
+ [ "$(git -C "$FH" symbolic-ref --short -q HEAD 2>/dev/null)" = "$_DEFAULT_BRANCH" ] && _ON_DEFAULT=1
243
+ # 🟥 NO DEADLINE HERE, AND THAT IS A DECISION — read before adding one back.
244
+ # A deadline was added here and then REMOVED the same session, because a cross-family
245
+ # review measured that the watchdog does not do what its name says: wrapping
246
+ # `git merge --ff-only` in `perl -e 'alarm N; exec @ARGV'` with N=2, against an upstream
247
+ # adding 20 files behind a slow smudge filter, took ~7.9s and returned 0. The alarm did
248
+ # not bound git. Shipping it would have added the appearance of a bound with none of the
249
+ # behaviour — the same false-green shape this release exists to fix.
250
+ # ⚠️ The consequence reaches further than this line: `_fh_gitcheck_deadline` guards the
251
+ # FETCH above too, and that guard predates this change. Whether it actually bounds a
252
+ # stalled fetch is now UNVERIFIED rather than assumed — a network stall may differ from a
253
+ # CPU-bound checkout, and neither was measured. Recorded as a residual instead of being
254
+ # quietly relied on.
255
+ # The exposure that motivated the attempt is real but unmeasured: the fetch may spend its
256
+ # full 8s inside a SessionStart hook budgeted at 10s, leaving ~2s for a merge whose true
257
+ # duration nobody has timed. Fixing it properly means measuring that duration and then
258
+ # bounding with something that actually bounds — not re-adding this line.
259
+ if [ -n "$_AUTOPULL" ] && [ -n "$_ON_DEFAULT" ] \
260
+ && git -C "$FH" merge --ff-only "refs/remotes/origin/$_DEFAULT_BRANCH" >/dev/null 2>&1; then
261
+ # Announce every unprompted run — §Operational Adaptation Loop requires it of a standing
262
+ # grant, and a sync the reader never saw is indistinguishable from one that never ran.
263
+ GIT_BEHIND_NOTE="local $_DEFAULT_BRANCH was ${_BEHIND} commit(s) behind — fast-forwarded automatically (standing consent: repo-freshness-autopull). Nothing else was touched; your gitignored state is out of git's reach by construction."
264
+ else
265
+ # Every not-applied path lands here and says the same thing: what to run. It does NOT
266
+ # say why it did not apply, on purpose — "no grant" and "wrong branch" and "ff refused"
267
+ # would each need their own true sentence, and a wrong reason printed confidently is
268
+ # worse than none (this file's own §absent-subject rule).
269
+ GIT_BEHIND_NOTE="local $_DEFAULT_BRANCH is ${_BEHIND} commit(s) behind origin/$_DEFAULT_BRANCH — while ON that branch run: git merge --ff-only origin/$_DEFAULT_BRANCH"
270
+ fi ;;
203
271
  esac
204
272
  fi
205
273
  # no local branch named $_DEFAULT_BRANCH at all (e.g. a fork never checked it out) → silent,
@@ -356,10 +356,16 @@ wired = {s for s in suites if has_runner(s)}
356
356
  # ── Embedded --self-test dispatchers — a class the name-pattern `suites` glob cannot see ──────
357
357
  # Measured 2026-08-13, the header of this file, §WHAT DEBT:0 DOES NOT MEAN: a lane suite that
358
358
  # lives INSIDE its subject as a `--self-test` flag — not a separate `test_*.sh`/`*_lanes.sh` file
359
- # — is structurally invisible to the glob above. 4 scripts carry one chamber_witness.sh ·
360
- # capability_registry_check.sh · digest_landing_check.sh · directional_diff_gate.sh and none
361
- # showed up as WIRED or UNWIRED anywhere in this report; the wiring line for the 3 that ARE wired,
362
- # the `for _subj in ...` loop at scripts/selfcheck.sh:478, could be deleted and nothing here would
359
+ # — is structurally invisible to the glob above. When this block was written, 4 such scripts were
360
+ # known and none showed up as WIRED or UNWIRED anywhere in this report.
361
+ # ⚠️ Do not read that 4 as a current figure, and do not re-list the names here. The set is
362
+ # DISCOVERED at run time by `_st_candidates` below and the live figure is printed in this file's
363
+ # own SELFTEST_COUNTS line, so a hand-maintained example list beside a self-discovering scan is a
364
+ # second source of truth that goes stale in silence. That is not hypothetical: the previous
365
+ # version of this comment named a 4th example the code no longer agreed with, and the session that
366
+ # noticed had to trace the discrepancy to ground by hand.
367
+ # The wiring line for the subjects that ARE wired,
368
+ # the `for _subj in ...` loop in scripts/selfcheck.sh, could be deleted and nothing here would
363
369
  # go red. found→extend, not a new file: same idiom as `suites`/`has_runner` above — discover
364
370
  # subjects, detect dispatch, report undeclared — new predicates for the shape this pattern uses.
365
371
  #
@@ -378,7 +384,7 @@ wired = {s for s in suites if has_runner(s)}
378
384
  # literal paren out as an escape sequence instead of a bare character, rather than splitting it
379
385
  # across lines.
380
386
  # A bare substring match on --self-test would also catch prose that only DISCUSSES the flag
381
- # (measured: scripts/selfcheck.sh:482 has a comment naming it as an example of what NOT to grep
387
+ # (measured: scripts/selfcheck.sh's `_subj` loop carries a comment naming it as what NOT to grep
382
388
  # for, which is exactly the false positive this narrower check exists to avoid). Require one of
383
389
  # the two real dispatcher shapes instead: `"--self-test"` in a quoted comparison, or `--self-test)`
384
390
  # as a bare case-pattern. The close-paren is built via chr — see the paren-trap note above; a
@@ -392,6 +398,21 @@ def _read(path):
392
398
  except OSError:
393
399
  return ''
394
400
 
401
+ # 🟥 NAMED RESIDUALS in the self-test branch — two asymmetries the 2026-08-15 repair did NOT close,
402
+ # written here because this file names its other residuals and silence would read as coverage.
403
+ # 1. Comment-stripping is LINE-PREFIX only. A dispatch line living inside a heredoc body or
404
+ # inside an echoed string is not a comment and still counts as a live caller. Symmetric with
405
+ # runner_dispatches, which has the same limit — so "symmetry restored" is true, and the shared
406
+ # blind spot survives in both. In-tree example of the shape: the heredoc fixtures in
407
+ # scripts/test_lane_runner_lanes.sh. Harmless today only because those name a fixture subject.
408
+ # 2. Subject DISCOVERY below still reads comments. `_st_names` matches the raw dispatcher forms
409
+ # anywhere in the file, so a comment that merely mentions the flag can enrol a script as a
410
+ # self-test subject. Real case: chamber_witness.sh carries the flag inside a usage comment.
411
+ # Harmless today because that file also has a real dispatcher — but it means "a mention is not
412
+ # a declaration" is enforced on the RUNNER side and not on the SUBJECT side.
413
+ # Neither is mechanized: closing them means parsing shell rather than scanning lines, which is the
414
+ # Grep-Collision Treadmill this repo has already logged. Fix on the first case that actually bites.
415
+ #
395
416
  # One line on purpose — see the paren-trap note above the SELFTEST_PAT definition.
396
417
  _st_candidates = [f for f in glob.glob('scripts/*.sh') if os.path.basename(f) not in suites and os.path.basename(f) != 'lane_runner_check.sh']
397
418
  _st_names = [os.path.basename(f)[:-3] for f in _st_candidates if any(_form in _read(f) for _form in SELFTEST_DISPATCH_FORMS)]
@@ -399,8 +420,8 @@ selftest_subjects = sorted(set(_st_names))
399
420
 
400
421
  def selftest_dispatched(bare_name, txt):
401
422
  """Two shapes, both real in this repo. Cross-family review (2026-08-14) caught the first draft
402
- shipping only the second — it read scripts/selfcheck.sh:478's `_subj` for-loop but missed
403
- :898/:933's direct `bash scripts/probe_scope_check.sh --self-test` / `bash scripts/
423
+ shipping only the second — it read scripts/selfcheck.sh's `_subj` for-loop but missed that
424
+ file's direct `bash scripts/probe_scope_check.sh --self-test` / `bash scripts/
404
425
  utterance_landing_check.sh --self-test`, so those two subjects were reported UNDECLARED while
405
426
  selfcheck.sh runs them every time. This is the exact failure the header above names by cite —
406
427
  a reader trusting the count over the source would have been told a false thing with confidence.
@@ -410,11 +431,17 @@ def selftest_dispatched(bare_name, txt):
410
431
  Shape 2 (indirect): bare_name sits in a `for VAR in ... bare_name ...; do` loop whose body
411
432
  dispatches $VAR with --self-test — mirrors the indirect-branch reasoning of runner_dispatches:
412
433
  the literal name is in a list construct, the invocation runs through the loop variable, so a
413
- direct-dispatch grep alone structurally cannot see it (scripts/selfcheck.sh:478)."""
414
- if re.search(rf'\bbash\s+scripts/{re.escape(bare_name)}\.sh\b[^\n]*--self-test', txt):
434
+ direct-dispatch grep alone structurally cannot see it (selfcheck.sh's `for _subj in ...` loop).
435
+
436
+ Comment lines are dropped first, exactly as runner_dispatches does for ordinary suites: "a
437
+ mention is not an invocation" applies identically here, and until 2026-08-15 it was enforced in
438
+ only one of the two predicates. Measured: a `# bash scripts/x.sh --self-test` usage line
439
+ matched the direct regex and certified a subject nothing ran."""
440
+ _body = '\n'.join(_l for _l in txt.split('\n') if not _l.strip().startswith('#'))
441
+ if re.search(rf'\bbash\s+scripts/{re.escape(bare_name)}\.sh\b[^\n]*--self-test', _body):
415
442
  return True
416
443
  in_loop = False; loop_var = None; has_name = False
417
- for ln in txt.split('\n'):
444
+ for ln in _body.split('\n'):
418
445
  s = ln.strip()
419
446
  m = re.match(r'for\s+(\w+)\s+in\b(.*)', s)
420
447
  if m:
@@ -430,7 +457,20 @@ def selftest_dispatched(bare_name, txt):
430
457
  return False
431
458
 
432
459
  def has_selftest_runner(bare_name):
433
- return any(selftest_dispatched(bare_name, _read(r)) for r in runners)
460
+ # A subject does not wire itself. has_runner() has skipped a suite's own file since this file
461
+ # was written; this branch did not, and the asymmetry was invisible because nothing exercised
462
+ # it — the only in-file control drove selftest_dispatched with synthetic text, never the
463
+ # runner set.
464
+ # ⚠️ Attribution, kept precise because a cross-family round caught it being loose: the 2026-08-15
465
+ # incident — directional_diff_gate.sh dispatched by nothing while the report said 10/10 — was
466
+ # caused by the COMMENT half, and comment-stripping alone closes it. Every self-reference in
467
+ # this tree at that date was a `#` line; a non-comment one existed nowhere. So this guard is
468
+ # SYMMETRY, not a repair of an observed case: it covers the shape where a subject names its own
469
+ # dispatch in live code, such as a usage helper. That shape is pinned by lane L16, which was
470
+ # added at the same time and for the same reason — without it this line is a repair no control
471
+ # drives, which is the failure mode this file exists to name.
472
+ _own = bare_name + '.sh'
473
+ return any(selftest_dispatched(bare_name, _read(r)) for r in runners if os.path.basename(r) != _own)
434
474
 
435
475
  selftest_wired = {s for s in selftest_subjects if has_selftest_runner(s)}
436
476
  selftest_undeclared = sorted(s for s in selftest_subjects if s not in selftest_wired)
@@ -445,12 +485,18 @@ if not selftest_dispatched('alpha', _ST_POS_FIXTURE):
445
485
  if selftest_dispatched('alpha', _ST_NEG_FIXTURE):
446
486
  print("CONTROL_FAILED\tself-test known-negative fixture with no --self-test flag read as dispatched")
447
487
  raise SystemExit(2)
488
+ # Second known-negative, added 2026-08-15 with the comment-skip repair. Without it the repair is a
489
+ # line of code no control drives — the failure mode this whole file exists to name.
490
+ _ST_CMT_FIXTURE = '# bash scripts/alpha.sh --self-test # usage example, not a caller\n'
491
+ if selftest_dispatched('alpha', _ST_CMT_FIXTURE):
492
+ print("CONTROL_FAILED\tself-test commented usage line read as a live dispatcher")
493
+ raise SystemExit(2)
448
494
 
449
495
  # ── CONTROL: the instrument must be able to see a suite known to be wired, and must NOT see one
450
496
  # known to be dead. Without both arms a broken detector reports "all clean" or "all broken" and
451
497
  # either reads as a verdict. [[feedback_absence_measurement_needs_control]]
452
498
  CTL_POS = 'test_selfcheck_state_lanes.sh' # selfcheck.sh invokes this DIRECTLY
453
- CTL_POS2 = 'test_session_close_lanes.sh' # selfcheck.sh:613 for-list + `bash "$_anchor"` — the
499
+ CTL_POS2 = 'test_session_close_lanes.sh' # selfcheck.sh's `_anchor` for-list + `bash "$_anchor"` — the
454
500
  # INDIRECT arm. Pins the second detection branch: the
455
501
  # strict detector called this UNWIRED and a hand-check
456
502
  # showed it runs. Without this control that branch
@@ -628,8 +674,18 @@ if [ -n "$SELFTEST_UNDECLARED" ]; then
628
674
  echo " dispatcher anywhere (self-test code exists, nothing calls it — see this file's own"
629
675
  echo " §Embedded --self-test comment for why the suites glob above cannot see this class):"
630
676
  printf '%s\n' "$SELFTEST_UNDECLARED" | sed 's/^/ /'
631
- echo " Fix by wiring \`bash scripts/<name>.sh --self-test\` into scripts/selfcheck.sh's"
632
- echo " _subj for-loop (scripts/selfcheck.sh:478), same shape as the 3 already there."
677
+ echo " Fix by wiring \`bash scripts/<name>.sh --self-test\` into scripts/selfcheck.sh, by"
678
+ echo " ONE of its two shapes and the choice is decided by the subject's own output, not"
679
+ echo " by which is shorter. The \`for _subj in ...\` loop gates on the substring 캘리브레이션;"
680
+ echo " a subject whose terminal verdict is worded any other way goes red there for a reason"
681
+ echo " that has nothing to do with its lanes — capability_registry_check was added to that"
682
+ echo " loop and reverted for exactly this. Such a subject takes the second shape: its own"
683
+ echo " direct-dispatch block, as capability_registry_check.sh and relay_channel.sh do there."
684
+ echo " 🟥 In that second shape do NOT gate on the exit code alone. Measured 2026-08-15 on"
685
+ echo " directional_diff_gate: a suite whose lanes were all deleted still printed a PASS"
686
+ echo " verdict and exited 0, and a usage banner carrying the same words satisfied a"
687
+ echo " substring match. Require the terminal verdict AS A WHOLE LINE, with a non-zero count"
688
+ echo " in it, or exit 0 will certify a suite that ran nothing."
633
689
  fi
634
690
 
635
691
  echo "PASS lane-runner: ${TOTAL} suites — ${WIRED} wired · ${N_EXEMPT} exempt · ${N_DEBT} declared debt" \