@chrono-meta/fh-gate 1.4.96 → 1.4.98

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 (49) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/CATALOG.md +19 -0
  3. package/CHEATSHEET.md +9 -1
  4. package/CLAUDE.md +39 -6
  5. package/README.ja.md +10 -0
  6. package/README.ko.md +10 -0
  7. package/README.md +30 -0
  8. package/README.zh.md +10 -0
  9. package/docs/pillars.svg +3 -7
  10. package/knowledge/shared/harness-core/fh_ecosystem_positioning.md +2 -0
  11. package/knowledge/shared/harness-core/fh_global_positioning_and_distribution_roadmap.md +136 -0
  12. package/knowledge/shared/harness-core/fh_three_layer_canon.md +20 -0
  13. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +215 -2
  14. package/knowledge/shared/harness-core/ship_readiness_gate.md +112 -0
  15. package/knowledge/shared/learnings/subagent_invocations_log.yaml +125 -0
  16. package/package.json +9 -2
  17. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  18. package/plugins/fh-commons/skills/ko-tech-writer/SKILL.md +63 -12
  19. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  20. package/plugins/fh-meta/CHANGELOG.md +65 -0
  21. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +30 -0
  22. package/scripts/compaction_probe.sh +153 -10
  23. package/scripts/consent_registry_check.sh +63 -0
  24. package/scripts/degrade_direction_scan.sh +10 -1
  25. package/scripts/digest_landing_check.sh +20 -4
  26. package/scripts/fh_node_check.sh +60 -1
  27. package/scripts/fh_session_load.sh +22 -2
  28. package/scripts/frontier_digest_autopilot.sh +229 -0
  29. package/scripts/lane_runner_check.sh +637 -0
  30. package/scripts/package_coverage_check.sh +199 -35
  31. package/scripts/postinstall_notice.js +34 -0
  32. package/scripts/psa_scan_lib.sh +164 -0
  33. package/scripts/public_surface_scan_files.sh +30 -0
  34. package/scripts/selfcheck.sh +442 -33
  35. package/scripts/test_consent_registry.sh +54 -0
  36. package/scripts/test_degrade_scan_shell_probes.sh +75 -0
  37. package/scripts/test_field_canon_lanes.sh +29 -5
  38. package/scripts/test_lane_runner_lanes.sh +221 -0
  39. package/scripts/test_marker_crossfamily_lanes.sh +10 -1
  40. package/scripts/test_marker_floor_lanes.sh +25 -1
  41. package/scripts/test_psa_singlefile_lanes.sh +288 -0
  42. package/scripts/test_reviewer_capability_conformance.sh +10 -1
  43. package/scripts/test_selfcheck_state_lanes.sh +173 -0
  44. package/scripts/test_stale_clone_guard_lanes.sh +21 -7
  45. package/scripts/test_version_lockstep_lanes.sh +62 -0
  46. package/scripts/version_lockstep_check.sh +143 -1
  47. package/templates/.git-hooks/pre-commit +22 -1
  48. package/templates/degrade_direction_scan.sh +10 -1
  49. package/templates/goal-quench-settings-merged.json +30 -0
@@ -60,7 +60,7 @@ model: sonnet
60
60
  | 용어-머리 «**X** — 설명» | 글머리 기호 뒤 `** — ` | «**X**: 설명» 콜론형 |
61
61
  | 콜론 나열투 («관측된 분업은: …») | 서술어 없는 `:` 도입 | 완결 문장으로 («…분업은 ~라는 것입니다») |
62
62
  | 조각문 («걸리는 층.») | 서술어 없는 마침 | 서술어 복원 («걸리는 층입니다») |
63
- | 소유 직역 「~을 갖다/가지다」 | `grep -E "을 갖\|를 갖\|을 가지\|를 가지"` | 존재문으로 (**«이전 상태를 갖지 않는다» → «이전 상태가 없다»**). 영어 `have`의 직역이고, 한국어는 소유보다 존재로 말한다. 소유가 실제 논점인 자리(권한·지분)는 남긴다 |
63
+ | 소유 직역 「~을 갖다/가지다」 | `grep -nE "을 갖|를 갖|을 가지|를 가지"` | 존재문으로 (**«이전 상태를 갖지 않는다» → «이전 상태가 없다»**). 영어 `have`의 직역이고, 한국어는 소유보다 존재로 말한다. 소유가 실제 논점인 자리(권한·지분)는 남긴다 |
64
64
  | 내부 조어·은어 | 팀 밖에서 안 쓰는 단어 (예: 렌더하다, 초록/빨강을 CI 결과 뜻으로) | 청중 어휘로 평문화. 단, **화면이 어휘를 받치면**(실제 빨간 화면을 가리키는 발화) 유지 가능 — 근거를 적는다 |
65
65
  | 레지스터 드리프트 | 습니다↔해요↔평어 혼재 | 표본 기준 단일화 |
66
66
 
@@ -113,6 +113,34 @@ Done When에서 서로 다른 check-class로 채점된다.
113
113
  새기 때문이다. 한글 수사("세 배")는 아라비아 전수로도 못 잡으므로 **눈 판정 잔여**로
114
114
  명기하고 통독에서 본다. 자기 눈으로 고른 수치만 채점하는 것은 measured가 아니다.
115
115
 
116
+ ⚠️ **`\b` 경계 금지** — 유니코드 인식 엔진(ripgrep·GNU grep UTF-8 로케일·Python `re` —
117
+ **`grep -P`는 예외다**: `(*UCP)` 없이는 `\w`가 ASCII 한정이라 숫자-한글 사이에 경계가
118
+ 생긴다. 반드시 `rg` 로 고정)에서 **한글은 word 문자**라 숫자와 한글 사이엔 경계가 생기지
119
+ 않는다. `\b\d[\d,]*\b`류 패턴은 숫자 뒤에 한글 조사·단위가 바로 붙는 표기(예: «7개»)를
120
+ 놓친다. **known-pair로 직접 확인**(엔진 고정, 복붙 실행): 이 SKILL.md 자신에 `rg -n
121
+ '\b\d[\d,]*\b'`와 `rg -n '\d(?:[\d,]*\d)?'`(끝자리 콤마를 삼키지 않게 끝을 숫자로 고정)를
122
+ 각각 돌려 델타를 세면, 경계판이 놓치는 숫자가 나온다(측정일 기준 델타 수를 이력에
123
+ 기록 — 놓치는 쪽은 대부분 조사·단위가 바로 붙는 주장 수치이고, 날짜·Step 번호처럼 뒤에
124
+ 공백·기호가 오는 숫자는 양쪽 다 잡힌다). **경계 없는 추출**로 시작하고, 과다추출된
125
+ 비주장 항목은 위 문장대로 사유와 함께 제외한다.
126
+
127
+ ### Step 4-b — 전칭 단정 후보 검출 (오탐 허용 — 후보 표시용, 판정은 사람)
128
+
129
+ «전부/모두/하나도 없다/전혀/일절/예외 없이» 류 전칭 단정 어휘 + **부정형 전칭**을
130
+ `rg -n '(안|못) ?(했|만들|나오|잡히)|지 않았(다|습니다)'`(엔진 고정, 복붙 실행)로 걸러
131
+ 후보로 표시하고, 각 후보의 반례가 정말 없는지 본문·근거를 다시 대조한다. grep은 후보만
132
+ 내고 판정은 사람이 한다 — 오탐 다수가 정상이다. **단, 후보를 세지 않거나 판정을
133
+ 비워두는 것은 결함이다** — 판정할 사람을 구할 수 없으면 후보 목록을 그대로 남기고 이
134
+ 조건을 **미충족으로 명시**하고 발행 판단자에게 넘긴다(자기 판정으로 대체 금지 — Step 5
135
+ 격리 규율과 동형).
136
+
137
+ 실측(비공개 원장, 2026-08-14): 같은 개고 세션에서 반례 탐색 없는 전칭 단정을 두 번
138
+ 썼고, 한 번은 위 어휘 목록에 없는 **부정형**("~을 안 만들었다")이었다 — grep 어휘만으로
139
+ 못 잡는 계열이 실재한다는 뜻이라 부정형 패턴을 함께 넣는다. **반례 탐색 없이 쓴 전칭
140
+ 단정은 원문보다 개고본에서 더 잘 나온다** — 다듬는 행위 자체가 근거 재확인 없이 서술을
141
+ 넓히기 때문이다. Step 5 재콜드리드가 뒤늦게 잡을 수도 있지만, 앞단에서 후보를 먼저
142
+ 좁혀두면 그 라운드의 비용이 준다.
143
+
116
144
  수치·주장에는 **두 가지 라벨**이 있고 서로 다른 상태다 — 섞지 않는다:
117
145
 
118
146
  - **«발행 직전 재측정 대상»** — 근거는 확인됐으나 **시간이 지나면 값이 변하는** 것
@@ -133,10 +161,22 @@ Done When에서 서로 다른 check-class로 채점된다.
133
161
  - **HTML/PDF 렌디션은 조건부** — 요청이 명시했거나 게재 채널이 요구할 때만 만든다(판단
134
162
  주체는 요청/채널이지 저자의 재량이 아니다): 자체완결 HTML(외부 의존 없는 인라인 SVG
135
163
  다이어그램, 시스템 한글 폰트 스택, `@page` 인쇄 규칙) → headless Chrome `--print-to-pdf`.
136
- - **지각 QA(필수 렌더 산출물이 있을 때)**: 렌더 결과를 **전 페이지 눈으로** 확인한다 —
137
- 라벨 잘림(한글 받침), 페이지 나눔, 표/그림 겹침, 다이어그램 텍스트 가림. 도구 exit 0은
138
- 렌더 성공이지 가독성 판정이 아니다. 판정 짝은 **독립 2차 지각 패스**(다른 눈이 같은
139
- 렌더를 봄)다 콜드리드는 내용을 읽지 렌더 결함을 보지 않으므로 짝이 될 수 없다.
164
+ - **md-only 예외의 범위는 좁다** 게재 매체가 md 렌더하면(위키·GitHub 등) **md-only
165
+ 산출물도 렌더 산출물이다.** N/A 게재 매체가 렌더를 하지 않음을 확인한 경우에만 쓴다.
166
+ **매체가 미상이면**(Step 1 «확인 불가» 경로로 진행한 경우) N/A 자격이 없다 GitHub
167
+ 렌더를 기본 렌더러로 가정하고 지각 확인을 수행하며, 가정 사실을 산출물 이력에
168
+ 남긴다.
169
+ 실측(비공개 원장, 2026-08-14): 렌더된 표의 한 셀에 여러 항목이 뭉쳐 기호-뜻 대응이
170
+ 깨졌는데, 원문엔 항목이 전부 적혀 있어 논리적 누락이 없었고 열 수·이스케이프 같은
171
+ 기계 검사도 통과했다. 배경지식 0 콜드리드를 여러 라운드 돌렸는데도 **렌더된 표를 보는
172
+ 사람만** 그 결함을 알아챘다 — «md-only니까 N/A»로 판단한 것이 오판이었다 — 위키가
173
+ md를 렌더하는 순간 렌더 산출물이 생긴다.
174
+ - **지각 QA(필수 — 렌더 산출물이 있을 때, md-only라도 게재 매체가 렌더하면 포함)**: 렌더
175
+ 결과를 **전 페이지 눈으로** 확인한다 — 라벨 잘림(한글 받침), 페이지 나눔, 표/그림 겹침,
176
+ 다이어그램 텍스트 가림, **표 셀 안에 여러 항목이 한 줄로 뭉쳐 있는지**(구분자 반복 —
177
+ 렌더된 표에서만 보이고 원문 grep으론 안 잡힌다). 도구 exit 0은 렌더 성공이지 가독성
178
+ 판정이 아니다. 판정 짝은 **독립 2차 지각 패스**(다른 눈이 같은 렌더를 봄)다 — 콜드리드는
179
+ 내용을 읽지 렌더 결함을 보지 않으므로 짝이 될 수 없다.
140
180
  - **발행 전 콜드리드 1패스**: 격리된 검토가 처음 읽는 눈으로 통독한다. 저자는 자기 글의
141
181
  전제 결손을 못 본다. **격리의 정의**: 저작 맥락을 상속하지 않는 실행이다. 같은 레포에서
142
182
  띄운 서브에이전트는 프로젝트 지침·메모리를 상속하므로 **완전 블라인드가 아니다** — 저작
@@ -148,9 +188,13 @@ Done When에서 서로 다른 check-class로 채점된다.
148
188
  - 🟥 **재콜드리드 — 지적을 반영해 고쳐 썼으면 고친 것을 다시 읽힌다.** 콜드리드 1회는
149
189
  *초안*을 검증할 뿐이고, **발행되는 것은 수리본**이다. 수리는 결함의 주된 출처라서
150
190
  «지적 반영»과 «통과»는 다른 사건이다. 본문이 바뀌었으면 라운드를 하나 더 돈다.
151
- **정지 조건은 라운드 수가 아니라 변경이다** — 그 라운드에 **새 지적이 없거나, 있어도
152
- 본문을 고쳤을 때** 닫는다. 고쳤으면 아직 안 닫힌 것이다. 라운드마다 «지적 n건 · 반영
153
- n건 · 본문 변경 여부»를 이력에 적는다.
191
+ **정지 조건은 라운드 수가 아니라 변경이다** — 그 라운드에 **새 지적이 0건이고 본문
192
+ 변경이 0건일 때만** 닫는다(어느 하나만으로는 안 닫힌다). 고쳤으면 아직 안 닫힌 것이다.
193
+ **미반영 지적이 남으면** 건에 명명된 사유 + 발행 판단자 수락 기록을 붙인다 — 없으면
194
+ 미충족으로 명시(Step 5 다른 judged 조건과 동형 라우팅). 라운드마다 «지적 n건 · 반영
195
+ n건 · 본문 변경 여부 · **Step 4/4-b 스캔을 이 수리본에 재실행했는가(y/n)**»를 이력에
196
+ 적는다 — 수리가 결함의 주된 출처인데 수치·전칭단정 게이트가 초안 기준에 머물면 바로
197
+ 그 자리에서 새 결함이 태어난다.
154
198
  > 실측(2026-08-11): 1차 콜드리드가 초안의 삽입 지점 오류를 잡았고, **그 수리가 새 결함
155
199
  > 3종**(소유 직역 · 문장 길이 124자 · 어휘 반복)을 낳았다. 재콜드리드를 안 돌아 그대로
156
200
  > 발행될 뻔했고, 읽는 사람이 «톤이 다르다»로 먼저 잡았다. 돌려 보니 문체보다 큰 것까지
@@ -166,9 +210,12 @@ Done When에서 서로 다른 check-class로 채점된다.
166
210
  | Step 2 **기계 클래스**(줄표·용어-머리·콜론·조각문·소유 직역) 스캔 잔여 0 — 컨트롤(알려진 양성 1건) 동반, 제외는 명명 사유와 함께 목록화 | measured |
167
211
  | Step 2 **판정 클래스**(내부 조어·레지스터) 통과 | judged — 짝: 콜드리드 통독(통독 후 제외 목록 대조) |
168
212
  | **부분 개고인 경우**(기존 문서에 삽입·수정): Step 2-b 리듬 대조 — 삽입분 각 문장이 본문 길이 상위 10% 이내 ∧ 어간 3회 연속 반복 0. 전문 신규 집필이면 N/A 명기 | measured |
169
- | 본문 수치 전건(기계 추출 분모 기준)이 라벨(창 병기/재측정 대상/미확인) 동반 | measured |
170
- | 렌더 산출물(HTML/PDF/이미지 — 파생 압축 자산 포함) 존재하는 경우 페이지 지각 확인md-only 산출이면 N/A 명기 | judged — 짝: **독립 2차 지각 패스**(다른 눈이 같은 렌더를 봄) |
171
- | 콜드리드가 **변경 없음으로 닫혔다** — 마지막 라운드에 새 지적이 없거나, 있어도 본문을 안 고쳤다. 지적을 반영해 고쳤으면 그 수리본으로 한 라운드 더. 라운드별 «지적 n · 반영 n · 본문 변경 여부» + 격리 수준(강/약) 이력 기록. Step 5 미적용 레지스터(낭독 대본)는 N/A 명기 | judged — 짝: 격리 검토(Step 5 정의) |
213
+ | 본문 수치 전건(기계 추출 분모 기준, `\b` 미사용 경계없는 추출, **발행 직전 최종본 기준**)이 라벨(창 병기/재측정 대상/미확인) 동반 + **한글 수사(세 배·두 배 등) 눈 판정 잔여 목록이 이력에 명기됨(0건이면 «0»으로 기록)** | measured |
214
+ | 전칭 단정(부정형 포함) 후보 grep 스캔을 **발행 직전 최종본**에서 실행 후보 n건 수치 기록 | measured |
215
+ | 후보 n 각각에 반례 확인 결과(«확인함»/«확인 함») 붙음 미판정 0 | judged — 짝: Step 5 콜드리드(통독 후 후보 목록 대조) |
216
+ | **파생 압축 자산인 경우**(Step 0 세 번째 레지스터): 자립성 검사(정본 어휘·주장 범위·은어 대조) 결과가 이력에 존재 — 해당 없으면 N/A 명기 | judged — 짝: 배경지식 0 콜드리드(원문 비열람) |
217
+ | 렌더 산출물(HTML/PDF/이미지 — 파생 압축 자산 포함)이 존재하거나 **게재 매체가 md를 렌더하는 경우**(위키·GitHub 등) 전 페이지 지각 확인 — 게재 매체가 md를 렌더하지 않음을 확인한 경우만 N/A 명기(사유 기록). **매체 미상이면** GitHub 렌더 가정으로 지각 확인 수행(가정 기록) — N/A 불가. Step 5 미적용 레지스터(낭독 대본)는 N/A 명기 | judged — 짝: **독립 2차 지각 패스**(다른 눈이 같은 렌더를 봄) |
218
+ | 콜드리드가 **변경 없음으로 닫혔다** — 마지막 라운드에 **새 지적이 0건이고 본문 변경이 0건**일 때 닫는다(어느 하나만으로는 안 닫힌다). 지적을 반영해 고쳤으면 그 수리본으로 한 라운드 더. **미반영 지적이 남으면** 각 건에 명명된 사유 + 발행 판단자 수락 기록을 붙인다 — 없으면 미충족으로 명시. 라운드별 «지적 n · 반영 n · 본문 변경 여부» + 격리 수준(강/약) 이력 기록. Step 5 미적용 레지스터(낭독 대본)는 N/A 명기 | judged — 짝: 격리 검토(Step 5 정의) |
172
219
  | 정직 수위 분리(Step 3)가 적용됨 | judged — 짝: 발행 승인자 확인 또는 격리 적대 검토. 둘 다 불가하면 **미충족으로 명시 보고**(자기 검토로 대체 금지) |
173
220
 
174
221
  ## 역할 경계 (중복 아님을 명시)
@@ -182,7 +229,11 @@ Done When에서 서로 다른 check-class로 채점된다.
182
229
 
183
230
  독립 실행 및 degrade 경로: phantom-quench·dataviz 미설치 → 해당 라우팅을 수동 대조로 ·
184
231
  WebFetch 불가 → Step 1 «확인 불가» 경로(시도 기록 의무) · headless Chrome 부재 →
185
- HTML/PDF 렌디션을 «미생성»으로 명기하고 md 정본만 산출. degrade는 산출물 이력에 적는다.
232
+ HTML/PDF 렌디션을 «미생성»으로 명기하고 md 정본만 산출 · **Step 4-b 판정 주체(사람) 부재
233
+ → 후보 목록을 그대로 두고 해당 Done When 을 미충족으로 명시**(자기 판정 대체 금지) ·
234
+ **독립 2차 지각 패스 부재 → 렌더 스크린샷을 산출물 이력에 첨부하고 해당 Done When 을
235
+ 미충족으로 명시**(저자가 같은 렌더를 두 번 보는 것으로 대체 금지 — 생성자=평가자가 된다).
236
+ 각 degrade는 산출물 이력에 적는다.
186
237
 
187
238
  **언어 범위**: 이름 그대로 **한국어 전용**이다. Step 0·1·3·4·5의 구조는 언어 무관이지만
188
239
  Step 2 문체 클래스가 전부 한국어 실측이라, 영어 지원은 영어 실측 클래스가 쌓인 뒤 별도
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fh-meta",
3
- "version": "1.4.96",
3
+ "version": "1.4.98",
4
4
  "engines": {
5
5
  "claudeCode": ">=1.0.0"
6
6
  },
@@ -10,6 +10,71 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
10
10
 
11
11
  ## Plugin Level
12
12
 
13
+ ### [1.4.98] — 2026-08-15
14
+
15
+ **fix: 1.4.97 이후 쌓인 출하면 36파일을 실제로 배포한다 — 특히 «있는데 실행에 도달 못 하던» 검증 축 하나**
16
+
17
+ - 🔴 **이 릴리스의 존재 이유 자체가 결함 하나다.** `auto-decorrelation` 에 **Step 6.5 — Standpoint
18
+ axis**(2026-08-14 신설, `crossfamily` 와 직교하는 두 번째 탈상관 축)를 커밋했는데 **버전을 안
19
+ 올렸다.** 플러그인 캐시는 `plugin.json` 버전으로 키잉되므로(`~/.claude/plugins/cache/<mk>/<plugin>/<version>/`),
20
+ 버전이 그대로면 캐시가 무효화되지 않는다 — 즉 **레포에는 있고 어떤 런타임에도 없는 규칙**이 하루
21
+ 넘게 존재했다. 실측(2026-08-15): 설치본 `auto-decorrelation/SKILL.md` 에 `standpoint` **0회** ·
22
+ `Step 6.5` **0회**, 레포본에는 263~291행에 전문. 나머지 34개 스킬은 드리프트 0 — 정확히 이 한
23
+ 파일만 도달 실패했고, 하필 그게 그날 실제로 안 걸린 기능이었다.
24
+ **일반형: 스킬을 고치고 버전을 안 올리면 저자 머신을 포함한 모든 런타임에서 그 수정은 존재하지
25
+ 않는다.** `built_but_not_wired` 의 배포채널 판본이고, 게이트가 «행위자가 읽는 곳»에 있어야 한다는
26
+ gate-locality 원칙이 *배포 지연*이라는 다른 메커니즘으로 재발한 것이다.
27
+ **기존 검사기는 정상 작동했다** — `session_close_check.sh` ④-b 가 「마지막 태그 이후 출하자산
28
+ 변경 → 재출하 제안」을 정확히 발화하고 있었다. 놓친 지점은 검사기가 아니라 **그 출력을 표시필터로
29
+ 잘라 읽은 것**(`| tail -15`)이었다. 새 기계장치를 짓지 않고 기존 절차를 실행하는 것이 이 릴리스다.
30
+
31
+ - **Standpoint 축 신설** (PR #370 · #371 · #378) — 계열(family) 다양성은 *리뷰어의 오류분포*를
32
+ 탈상관시키지만 *리뷰가 대조되는 ground truth* 는 탈상관시키지 않는다. 공유 본체(shared-body)
33
+ 변경에서 «누구의 레포를 기준으로 검증했는가」를 폐쇄 enum 으로 기록한다
34
+ (`tier1` · `tier2(<대상>)` · `tier2b(<대상>)` · `tier3(<대상>)` · `not-applicable` +
35
+ could-not/did-not/did-not-look 3분 degrade). 실행 시점은 **첫 push 이전, 위험판정 분기 없이**.
36
+ 정본 = `knowledge/shared/harness-core/field_verdict_crossfamily_gate.md §7`.
37
+ ⚠️ 정직한 현 상태: 이 필드는 **산문 전용**이다 — pre-commit 훅에 `standpoint` 매치 0건,
38
+ 픽스처 스위트 없음(`crossfamily` 는 21건 + 픽스처로 하드 차단). 첫 거짓값이 기록되면 기계화한다.
39
+
40
+ - **컴패니언 스토어 노드↔노드 스코핑** (PR #368 · #372) — 같은 머신에서 두 하네스가 컴패니언
41
+ 스토어를 공유할 때 서로의 manifest/memory-index 를 되읽던 결함. `fh_hub_identity.sh` 신설로
42
+ 세 호출부의 네임스페이스 판단을 단일 소스로 통일.
43
+ - **레인 배선** (PR #369 · #374) — `test_field_canon` · `test_stale_clone_guard` 재배선(DEBT 2→0),
44
+ 자기-재진술 렌즈 + 임베디드 `--self-test` 디스패처 탐지 신설.
45
+ - **SessionStart 노드 플로어에 origin-behind 탐지** (PR #376) · **frontier-digest 무인
46
+ 파이프라인** (PR #377) · **positioning roadmap 문서** (PR #379).
47
+ - **Homebrew tap 출하** (PR #380 · #383) — `brew install forge-harness`(npm 패키지와 100% parity).
48
+ Homebrew Core 제출은 채택 기준(star 75 / fork 30 / watcher 30, 자기제출 ×3) 미달로 **보류**,
49
+ 재개조건 ~50+ star 로 명시 기록.
50
+ - **ko-tech-writer 수리** (PR #373) — render-artifact 스코프 · 전칭단정 스캔 · 한글 `\b` 정규식 트랩.
51
+
52
+ ---
53
+
54
+ ### [1.4.97] — 2026-08-13
55
+
56
+ **fix: 죽어 있던 게이트 캘리브레이션 배선 · 참조를 「선언」이 아니라 「실제 tarball」과 대조**
57
+
58
+ - **아무 데서도 실행되지 않던 레인 스위트를 배선했다** (PR #360). 실측: `scripts/` 의 43개 스위트
59
+ 중 12개가 selfcheck·git 훅·CI 어디서도 안 돌았고, **그중 9개는 이 패키지에 실린다** — 소비자가
60
+ 받아 놓고 아무것도 부르지 않던 테스트 9종이다. 가장 아픈 둘은 **커밋을 하드 차단하는 게이트의
61
+ 캘리브레이션**이었다(`test_marker_crossfamily_lanes.sh` · `test_marker_floor_lanes.sh`).
62
+ 진척 지표 **DEBT 12 → 2**. 남은 2건은 「돌면 진다, 사유는 머신 전제」로 사유가 측정돼 있다.
63
+ - **vendored git 트리에서의 거짓 FAIL 을 닫았다** — 세 스위트가 `git rev-parse --show-toplevel`
64
+ 로 루트를 잡아, 설치 후 `git init` 한 트리나 node_modules 를 커밋하는 모노레포에서 **바깥 레포**
65
+ 를 보고 HARNESS-ERROR 를 냈다. 스크립트 상대경로로 수리.
66
+ - **`package_coverage_check.sh --vs-tarball`** (PR #361) — 참조 대조의 오라클을 `package.json`
67
+ `files[]`(**선언**)에서 `npm pack --dry-run --json`(**실제 출하 파일셋**)으로 바꾸는 모드.
68
+ npm 부재·비영 종료·파싱 실패 시 **fail-closed**(rc=2 UNMEASURED, 약한 오라클로 폴백하지 않는다).
69
+ `prepublishOnly` 에 배선됐다.
70
+ - **참조 추출기가 `.json` 을 못 보고 있었다** — 정규식 교대가 leftmost-first 라 `.js` 가 `.json`
71
+ 안에서 먼저 물렸고, 모든 JSON 참조가 존재하지 않는 경로로 잘려 조용히 버려졌다. 수리 후 즉시
72
+ 드러난 것 하나가 **소비자-대면 결함**이다: `templates/goal-quench-hook-setup.md` 가
73
+ `cp templates/goal-quench-settings-merged.json .claude/settings.json` 을 지시하는데 **그 JSON 이
74
+ 패키지에 없었다.** 이제 실린다.
75
+ - **`selfcheck.sh` 의 timeout 가드가 한 번도 설치된 적이 없었다** — `local` 이 함수 밖에 있어
76
+ bash 가 대입을 거부했고, 매 실행 stderr 로 자기 실패를 알렸으나 아무도 읽지 않았다. 수리.
77
+
13
78
  ### [1.4.96] — 2026-08-12
14
79
 
15
80
  **fix: 전수 재출하 캠페인 — 「돌았다고 보고하는데 안 도는」 계기들 · 선언이 무효였던 에이전트**
@@ -260,6 +260,36 @@ Distinguish the **unavailable-but-wanted** case (consent given, panel down → `
260
260
  **pmh-repo-local and unreadable from an FH session**, so on this side the typed value + ack is the
261
261
  enforceable layer) from the **declined** case above (chosen floor, first-class, no degrade framing).
262
262
 
263
+ **Step 6.5 — Standpoint axis (2026-08-14, orthogonal to the family ladder above)**: for a
264
+ **shared-body / cross-harness-boundary** change — scoped by *effect* (alters another harness's
265
+ behavior, gate outcome, or interaction contract), not merely by touching a synced file path — also
266
+ emit `standpoint:` alongside `crossfamily:` in the same marker — recruiting family diversity here
267
+ does not substitute for it. Values: `tier1` (content-only, the default) · `tier2(<harness>)`
268
+ (peer-simulated — ran the target's own repo, content only) · `tier2b(<harness>)` (same operator,
269
+ target's real runtime — local wiring visible, but not an independent reviewer) · `tier3(<harness>)`
270
+ (a *different* operator of the target harness ran it — the only fully independent + local-wiring
271
+ rung) · `not-applicable` (no target-harness standpoint exists — most same-repo dispatches) ·
272
+ `DEGRADED_NO_TARGET_ACCESS` / `DEGRADED_NOT_RUN` / `UNKNOWN` (own literals, not crossfamily's). Note
273
+ the naming collision with this session's own persona/viewpoint sense of "standpoint" (the `beginner`/
274
+ `main-player`/`expert` agent roster) — different axis, same English word; do not conflate.
275
+ **Prose-only** — no hook or fixture validates this field yet, unlike `crossfamily:`, and
276
+ mechanizing it is FH's own job (a syncing sibling harness cannot add the check locally without its
277
+ own sync process rejecting the divergence). **Timing — same moment as Axis 2/3
278
+ (steel-quench/phantom-quench), not a follow-up**: run on the local diff before the first push to
279
+ any remote (public or private — no visibility judgment to make), one unconditional rule regardless
280
+ of perceived risk — see `field_verdict_crossfamily_gate.md §7 Sequencing` for why (a real PR pushed
281
+ this exact field to a public repo with two residency leaks in view, caught only after the PR was
282
+ already open). Full field spec, trigger scope, and evidence:
283
+ `knowledge/shared/harness-core/field_verdict_crossfamily_gate.md §7`.
284
+
285
+ **Done When** (Step 6.5): `standpoint:` is emitted whenever the change is a shared-body/
286
+ cross-harness-boundary change by the effect-based trigger above; `not-applicable` carries grounds
287
+ on the same line naming what was checked; **the review ran on the local diff before the first push**
288
+ — a `standpoint:` value first recorded after the PR is already open does not satisfy this condition,
289
+ even if the value itself is correct *[judged — no mechanical anchor today; adversarial pairing: a
290
+ reviewer/challenger may request both the grounds and the pre-push timing be shown in the PR capsule,
291
+ and reject a bare `not-applicable` or a post-hoc value]*.
292
+
263
293
  ## Step 7 — Output
264
294
 
265
295
  Sidecar findings → existing synthesizer / Axes 2-3 marker. **Marker honesty (M-3)**: record
@@ -179,17 +179,62 @@ do_digest() {
179
179
  if [ ! -f "$sealfile" ]; then return 0; fi
180
180
 
181
181
  # 신선도·세션 정합을 **주장에 반영**한다. 안 맞으면 주입은 하되 "직전 압축"이라고 말하지 않는다.
182
- local _now _age _stale="" _xsess=""
183
- _now=$(date +%s); _age=$(( _now - ${sealts:-$_now} ))
184
- [ "$_age" -gt 43200 ] && _stale=" ⚠️ ${_age}초 봉인 세션의 직전 압축이 아닐 수 있다"
185
- if [ -n "${sealsess:-}" ] && [ "${sealsess}" != "unknown" ] && [ -n "$DIGEST_SESSION" ] \
186
- && [ "$DIGEST_SESSION" != "unknown" ] && [ "${sealsess}" != "$DIGEST_SESSION" ]; then
187
- _xsess=" ⚠️ 다른 세션(${sealsess})의 봉인이다"
182
+ # ⚠️ 가드 모두 초판에서 **조용히 꺼지는 경로**를 갖고 있었다(실측 2026-08-13, 4-arm 재현).
183
+ # 공통 축은 하나다 — **미측정을 「정상」으로 렌더**했다(`not found 0`).
184
+ # ⚠️ `_age` 초기화 = **2중 방어**. 산술이 실패하면 대입이 되고, 다음 줄의 `$_age` `set -u`
185
+ # 아래 unbound 셸을 자리에서 죽인다 훅은 `exit 0` 도달 하고 **stdout
186
+ # 통째로 폐기**된다(비영 종료 = 무음). 파일이 스스로 박은 불변식이 거기서 깨진다.
187
+ # 🔍 **정직한 표시**: 아래 `case` 가 숫자만 통과시키고 `10#` 이 진법을 고정하는 한, 이 줄은
188
+ # **도달 불가능하고 레인도 없다**(되돌림 arm D 실측: 이 줄만 지우면 47쌍 전부 초록).
189
+ # 앵커가 걸린 것은 `10#` 쪽(arm A)이다. 이 줄은 그게 지워졌을 때를 위한 두 번째 층이지,
190
+ # 회귀 앵커가 아니다 — 있는 척하지 않으려고 여기 적는다.
191
+ local _now _age=0 _stale="" _note="" _xsess="" _sessmatch=no
192
+ _now=$(date +%s)
193
+ # ⓑ 를 **먼저** 계산한다 — 나이 문구가 세션 일치 여부에 의존하기 때문이다(아래 ⓐ-3).
194
+ # 세션 대조는 **양쪽을 다 알 때만** 성립한다. 한쪽이라도 unknown 이면 결과는 「일치」가 아니라
195
+ # **「대조 불가」**다. 초판은 그 경우 경고를 껐는데, 소비자가 unknown 인 경로가 바로
196
+ # 주인 없는 남의 마커를 집는 경로(line 165)여서 **가장 필요한 자리에서 꺼져 있었다**.
197
+ if [ -n "${sealsess:-}" ] && [ "${sealsess}" != "unknown" ] \
198
+ && [ -n "$DIGEST_SESSION" ] && [ "$DIGEST_SESSION" != "unknown" ]; then
199
+ if [ "${sealsess}" = "$DIGEST_SESSION" ]; then
200
+ _sessmatch=yes
201
+ else
202
+ _xsess=" ⚠️ 다른 세션(${sealsess})의 봉인이다"
203
+ fi
204
+ else
205
+ _xsess=" ⚠️ 세션 대조 불가(봉인=${sealsess:-미상} · 소비=${DIGEST_SESSION:-미상}) — 남의 원장일 수 있다"
188
206
  fi
207
+ # ⓐ 봉인 시각이 없거나 숫자가 아니면 나이는 **미상**이다. 초판은 `${sealts:-$_now}` 로 「지금」을
208
+ # 기본값으로 써서 _age=0 을 만들었고, 구형식 pending(경로 한 줄)에서 신선도 가드가 통째로
209
+ # 무음 소실했다 — 실측: 5일 묵은 봉인이 "직전 압축"이라고 주장하며 나갔다.
210
+ case "${sealts:-}" in
211
+ ''|*[!0-9]*) _stale=" ⚠️ 봉인 시각 미상 — 이 세션의 직전 압축인지 확인 불가" ;;
212
+ # `10#` 으로 진법을 고정한다. 선행 0(`08…`)은 8진수로 읽혀 `value too great for base` 를
213
+ # 내고, 위에서 적은 무음 사망 경로가 정확히 그 입력으로 열린다(실측: 산술 다음 줄에 도달 못 함).
214
+ *) _age=$(( _now - 10#$sealts ))
215
+ # 미래 시각이면 _age 가 음수라 `-gt 43200` 이 거짓이 되고 가드가 **또 조용히 꺼진다** —
216
+ # 이 파일이 이번에 두 번 밟은 그 축의 세 번째 얼굴이다(cross-family codex/gpt-5.5 지목,
217
+ # 자력 적발 0). 시계 어긋남이거나 마커가 손대진 것이고, 둘 다 「방금 봉인」이 아니다.
218
+ if [ "$_age" -lt 0 ]; then
219
+ _stale=" ⚠️ 봉인 시각이 미래($(( - _age ))초 뒤) — 시계 어긋남 또는 손댄 마커, 신선도 판정 불가"
220
+ elif [ "$_age" -gt 43200 ]; then
221
+ # ⓐ-3 **세션이 일치하면 이 경고는 반증 가능하게 틀렸다.** 마커는 봉인마다 세션별로
222
+ # 덮어써지므로(line 150), 일치하는 마커가 가리키는 것은 정의상 그 세션의 **가장
223
+ # 최근** 봉인이다. 그때 나이는 주장을 낮출 근거가 아니라 부가 정보다 — 주장 정확도를
224
+ # 위해 만든 가드가 스스로 틀린 말을 하던 자리(Axis 2 M-4 지목).
225
+ if [ "$_sessmatch" = yes ]; then
226
+ _note=" (${_age}초 전 봉인 — 같은 세션의 가장 최근 봉인이다)"
227
+ else
228
+ _stale=" ⚠️ ${_age}초 전 봉인 — 이 세션의 직전 압축이 아닐 수 있다"
229
+ fi
230
+ fi ;;
231
+ esac
232
+ # `_note` 는 **주장을 낮추지 않는다** — 분기 조건에서 뺀 이유가 그것이다. 나이를 알려주되
233
+ # 「직전 압축」이 참인 경우(세션 일치)에는 그 주장을 유지한다.
189
234
  if [ -n "$_stale$_xsess" ]; then
190
- echo "🧭 [FH 압축 복구] 봉인된 포인터 원장이 있다 — 내용이 아니라 경로다.$_stale$_xsess"
235
+ echo "🧭 [FH 압축 복구] 봉인된 포인터 원장이 있다 — 내용이 아니라 경로다.$_stale$_xsess$_note"
191
236
  else
192
- echo "🧭 [FH 압축 복구] 직전 압축 전에 봉인된 포인터 원장이 있다 — 내용이 아니라 경로다."
237
+ echo "🧭 [FH 압축 복구] 직전 압축 전에 봉인된 포인터 원장이 있다 — 내용이 아니라 경로다.$_note"
193
238
  fi
194
239
  echo " 정본: $sealfile"
195
240
  echo
@@ -327,8 +372,21 @@ self_test() {
327
372
  local dg; dg="$(do_digest "$T/out3b" 2>/dev/null)"
328
373
  case "$dg" in *"열어야 할 정본"*) r=YES ;; *) r=NO ;; esac
329
374
  t "#3 digest 가 정본 포인터를 주입한다" YES "$r"
330
- case "$dg" in *"브랜치:"*) r=YES ;; *) r=NO ;; esac
331
- t "#3 digestgit 상태를 주입한다" YES "$r"
375
+ # ⚠️ 레인은 한때 "브랜치:" 등장을 무조건 YES 기대했다. **REPO_ROOT git 저장소인지는
376
+ # self-test통제하지 않는 환경 조건**이다(스크립트 위치로 계산되는 값이지 $T 픽스처가 아니다) —
377
+ # 그래서 non-git 트리(예: 소비자가 tarball 을 git 밖 스크래치 디렉터리에 풀어 실행)에서는 항상
378
+ # FAIL 했다. known-pair 로 확인: 레포 rc=0 · non-git tmp rc=1 · **같은 tmp 에 `git init` 만 해도
379
+ # rc=0**(2026-08-12, 재출하 축). do_seal 자신은 두 경우 다 올바르게 렌더한다("브랜치: …" 또는
380
+ # "(git 레포 아님)") — 결함은 self-test 가 그 중 한쪽만 정답으로 하드코딩한 것이었다. 지금 실제
381
+ # 상태를 물어 그 상태에 맞는 렌더를 기대한다 — 두 분기 다 계측한다(브랜치 있음일 때만 잰 것이
382
+ # 아니라 없음일 때도 "(git 레포 아님)" 이 실제로 나오는지 검증).
383
+ if git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1; then
384
+ case "$dg" in *"브랜치:"*) r=YES ;; *) r=NO ;; esac
385
+ t "#3 digest 가 git 상태를 주입한다 (REPO_ROOT=git repo)" YES "$r"
386
+ else
387
+ case "$dg" in *"(git 레포 아님)"*) r=YES ;; *) r=NO ;; esac
388
+ t "#3 digest 가 git 상태를 주입한다 (REPO_ROOT=non-git, graceful render)" YES "$r"
389
+ fi
332
390
 
333
391
  # #4 세션 교차 주입 — 다른 세션의 봉인을 "직전 압축"이라고 주장하면 안 된다
334
392
  # ⚠️ 이 레인은 1차 수리 때 "라벨하면 된다"는 전제로 썼다가 **갱신됐다**. 라벨은 오배달을
@@ -347,6 +405,91 @@ self_test() {
347
405
  case "$d4b" in *"다른 세션"*) rc=YES ;; *) rc=NO ;; esac
348
406
  t "#4 같은 세션에 교차 경고 안 뜬다" NO "$rc"
349
407
 
408
+ # #11 신선도 가드가 **구형식 pending(경로 한 줄)** 에서 무음으로 꺼지지 않는다.
409
+ # 실측 2026-08-13: 이 경로로 5일 묵은 봉인이 "직전 압축"이라고 주장하며 실제 세션에 주입됐다.
410
+ do_seal "$T/tr.jsonl" "$T/out11" "SESSA" >/dev/null 2>&1
411
+ local p11; p11="$(ls "$T/out11"/.pending_* 2>/dev/null | head -1)"
412
+ cut -f1 "$p11" > "$p11.t" 2>/dev/null && mv "$p11.t" "$p11" # 구형식으로 강등 = 봉인 시각 소실
413
+ local d11; d11="$(do_digest "$T/out11" "SESSA" 2>/dev/null)"
414
+ case "$d11" in *"직전 압축 전에"*) rc=YES ;; *) rc=NO ;; esac
415
+ t "#11 시각 미상 봉인을 '직전 압축'이라 주장하지 않는다" NO "$rc"
416
+ case "$d11" in *"봉인 시각 미상"*) rc=YES ;; *) rc=NO ;; esac
417
+ t "#11 ★ 시각 미상이 타입으로 남는다 (0 으로 렌더 금지)" YES "$rc"
418
+
419
+ # #11 known-negative — 정상 형식엔 과경고가 붙으면 안 된다. 이게 없으면 "항상 경고"로도 통과한다.
420
+ do_seal "$T/tr.jsonl" "$T/out11b" "SESSA" >/dev/null 2>&1
421
+ local d11b; d11b="$(do_digest "$T/out11b" "SESSA" 2>/dev/null)"
422
+ case "$d11b" in *"봉인 시각 미상"*) rc=YES ;; *) rc=NO ;; esac
423
+ t "#11 known-negative: 정상 봉인엔 미상 라벨 안 붙는다" NO "$rc"
424
+ case "$d11b" in *"직전 압축 전에"*) rc=YES ;; *) rc=NO ;; esac
425
+ t "#11 known-negative: 정상 봉인은 직전-압축 주장 유지" YES "$rc"
426
+
427
+ # #11c **미래 시각**도 신선도 판정 불가다 — 음수 _age 는 `-gt 43200` 을 통과 못 해서
428
+ # 가드가 또 조용히 꺼진다. cross-family(codex/gpt-5.5)가 이번 수리 안에서 지목한 세 번째 얼굴.
429
+ do_seal "$T/tr.jsonl" "$T/out11c" "SESSA" >/dev/null 2>&1
430
+ local p11c; p11c="$(ls "$T/out11c"/.pending_* 2>/dev/null | head -1)"
431
+ awk -v ts="$(( $(date +%s) + 86400 ))" 'BEGIN{FS=OFS="\t"}{$3=ts;print}' "$p11c" > "$p11c.t" \
432
+ && mv "$p11c.t" "$p11c"
433
+ local d11c; d11c="$(do_digest "$T/out11c" "SESSA" 2>/dev/null)"
434
+ case "$d11c" in *"직전 압축 전에"*) rc=YES ;; *) rc=NO ;; esac
435
+ t "#11c 미래 시각 봉인을 '직전 압축'이라 주장하지 않는다" NO "$rc"
436
+ case "$d11c" in *"미래"*) rc=YES ;; *) rc=NO ;; esac
437
+ t "#11c ★ 미래 시각이 타입으로 남는다 (음수 나이 무음 금지)" YES "$rc"
438
+ case "$d11b" in *"미래"*) rc=YES ;; *) rc=NO ;; esac
439
+ t "#11c known-negative: 정상 시각엔 미래 라벨 안 붙는다" NO "$rc"
440
+
441
+ # #11d **묵은-봉인 가지(>43200)에 앵커가 0개였다** — 실측 발현(5일 묵은 봉인)의 정상-형식
442
+ # 판본인데, 레인이 수리의 어휘(미상 라벨)만 따라가고 결함의 어휘(나이)를 안 따라갔다.
443
+ # 검증: 이 레인 없이 `elif [ "$_age" -gt 43200 ]` 가지를 통째로 지워도 39쌍 전부 초록이었다.
444
+ # (Axis 2 M-3 지목. 세션 일치 케이스라 주장은 유지되고 나이는 부가정보로 붙는다 — M-4)
445
+ do_seal "$T/tr.jsonl" "$T/out11d" "SESSA" >/dev/null 2>&1
446
+ local p11d; p11d="$(ls "$T/out11d"/.pending_* 2>/dev/null | head -1)"
447
+ awk -v ts="$(( $(date +%s) - 432000 ))" 'BEGIN{FS=OFS="\t"}{$3=ts;print}' "$p11d" > "$p11d.t" \
448
+ && mv "$p11d.t" "$p11d"
449
+ local d11d; d11d="$(do_digest "$T/out11d" "SESSA" 2>/dev/null)"
450
+ case "$d11d" in *"초 전 봉인"*) rc=YES ;; *) rc=NO ;; esac
451
+ t "#11d ★ 묵은 봉인의 나이가 실제로 인쇄된다 (가지 삭제를 잡는 유일한 앵커)" YES "$rc"
452
+ case "$d11d" in *"직전 압축이 아닐 수 있다"*) rc=YES ;; *) rc=NO ;; esac
453
+ t "#11d 세션 일치면 '아닐 수 있다'는 거짓 주장을 안 한다" NO "$rc"
454
+ case "$d11b" in *"초 전 봉인"*) rc=YES ;; *) rc=NO ;; esac
455
+ t "#11d known-negative: 신선한 봉인엔 나이 문구 없음" NO "$rc"
456
+
457
+ # #11e 선행 0 타임스탬프가 **훅을 죽이지 않는다**. set -u 아래 산술 실패 → 다음 줄 unbound →
458
+ # 셸 즉시 종료 → exit 0 미도달 → stdout 통째 폐기. 이 파일의 「훅은 절대 비영 종료 안 한다」
459
+ # 불변식이 입력 하나로 깨지던 자리다(Axis 2 M-1, 실측 재현).
460
+ do_seal "$T/tr.jsonl" "$T/out11e" "SESSA" >/dev/null 2>&1
461
+ local p11e; p11e="$(ls "$T/out11e"/.pending_* 2>/dev/null | head -1)"
462
+ awk 'BEGIN{FS=OFS="\t"}{$3="08123456";print}' "$p11e" > "$p11e.t" && mv "$p11e.t" "$p11e"
463
+ local d11e d11e_rc
464
+ d11e="$(do_digest "$T/out11e" "SESSA" 2>&1)"; d11e_rc=$?
465
+ t "#11e ★ 선행 0 타임스탬프에도 digest 가 정상 종료한다" 0 "$d11e_rc"
466
+ case "$d11e" in *"정본:"*) rc=YES ;; *) rc=NO ;; esac
467
+ t "#11e 선행 0 에도 포인터가 실제로 인쇄된다 (무음 아님)" YES "$rc"
468
+
469
+ # #13 「다른 세션의 봉인이다」 분기는 **살아있는데 앵커가 0개**였다 — #4 는 라우팅(0바이트)만
470
+ # 재서 이 문자열에 도달할 수 없다. 프로덕션 도달 경로 = 구형식 단일 `.pending` 관용(line 167).
471
+ do_seal "$T/tr.jsonl" "$T/out13" "SESSX" >/dev/null 2>&1
472
+ local sf13; sf13="$(ls -t "$T/out13"/seal_*.md 2>/dev/null | head -1)"
473
+ printf '%s\t%s\t%s\n' "$sf13" "SESSX" "$(( $(date +%s) - 432000 ))" > "$T/out13/.pending"
474
+ local d13; d13="$(do_digest "$T/out13" "SESSA" 2>/dev/null)"
475
+ case "$d13" in *"다른 세션(SESSX)"*) rc=YES ;; *) rc=NO ;; esac
476
+ t "#13 ★ 교차세션 경고가 실제로 인쇄된다 (분기 무앵커 폐쇄)" YES "$rc"
477
+ case "$d13" in *"세션 대조 불가"*) rc=YES ;; *) rc=NO ;; esac
478
+ t "#13 양쪽을 아는 불일치는 '대조 불가'가 아니다" NO "$rc"
479
+ case "$d13" in *"초 전 봉인"*) rc=YES ;; *) rc=NO ;; esac
480
+ t "#13 세션 불일치 + 묵음이면 나이 경고가 붙는다" YES "$rc"
481
+
482
+ # #12 소비자 세션이 미상이면 결과는 「일치」가 아니라 **「대조 불가」**다.
483
+ # 그 경로(line 165)가 바로 주인 없는 남의 마커를 집는 경로여서, 초판은 가장 필요한 자리에서 꺼졌다.
484
+ do_seal "$T/tr.jsonl" "$T/out12" "SESSA" >/dev/null 2>&1
485
+ local d12; d12="$(do_digest "$T/out12" "unknown" 2>/dev/null)"
486
+ case "$d12" in *"세션 대조 불가"*) rc=YES ;; *) rc=NO ;; esac
487
+ t "#12 소비자 미상이면 대조 불가로 라벨된다" YES "$rc"
488
+ case "$d12" in *"직전 압축 전에"*) rc=YES ;; *) rc=NO ;; esac
489
+ t "#12 대조 불가면 '직전 압축' 주장 안 한다" NO "$rc"
490
+ case "$d11b" in *"세션 대조 불가"*) rc=YES ;; *) rc=NO ;; esac
491
+ t "#12 known-negative: 양쪽 아는 경로엔 대조불가 라벨 없음" NO "$rc"
492
+
350
493
  # #8 포인터 절은 **절대 안 잘린다** — 더티파일 목록이 길어도
351
494
  do_seal "$T/tr.jsonl" "$T/out8" "SESS8" >/dev/null 2>&1
352
495
  local d8; d8="$(do_digest "$T/out8" "SESS8" 2>/dev/null)"
@@ -72,12 +72,72 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
72
72
  REG="${1:-$ROOT/tracks/_meta/consent_classes.yaml}"
73
73
  UAP="${2:-$ROOT/tracks/_meta/user_adaptation_profile.md}"
74
74
 
75
+ # ── What this run measured WITH — computed in a SEPARATE PROCESS ────────────────────────────────
76
+ # Measured 2026-08-12: this gate rides selfcheck → prepublishOnly. A release shipped green while the
77
+ # machine's own python3 had no PyYAML — the session that ran it happened to have an unrelated
78
+ # project's venv first on PATH. The reproduction unit is the PATH a session inherited, and the green
79
+ # said nothing about it. Failure already explained itself; success explained nothing.
80
+ #
81
+ # Why a separate process, after three cross-family rounds walked it here:
82
+ # draft 1 `import yaml` ahead of the N/A branch — started EXECUTING third-party module code on a
83
+ # path that previously ran none, and `except Exception` does not catch SystemExit.
84
+ # Measured: a planted yaml.py containing sys.exit(0) decided the run's exit code.
85
+ # draft 2 `find_spec` — does not execute yaml.py, but DOES execute sys.meta_path finder code,
86
+ # so a third-party import hook could still raise SystemExit past the handler.
87
+ # here a subprocess. Whatever it executes, its exit code is discarded and the verdict below is
88
+ # decided by code that never touched it. Instrumentation that can decide anything is not
89
+ # instrumentation — and "catch harder" was the wrong shape of answer to that.
90
+ # Bounded, because rc-equality does not see LIVENESS (cross-family R5 broke my own measurement design
91
+ # with exactly this: a meta_path finder that HANGS makes the probe hang, and a comparison of exit
92
+ # codes is structurally blind to a run that never produces one). `command -v` guarded — where
93
+ # `timeout` is absent the bound is absent too, and that is a named residual, not a silent one.
94
+ # `-k` when supported (cross-family R6): plain `timeout` sends SIGTERM, which hostile probe code can
95
+ # ignore and keep sleeping — measured, the bound did not bind. `-k` follows with SIGKILL, which it
96
+ # cannot. Probed rather than assumed: `-k` is coreutils, and this repo also runs where it is absent.
97
+ _PROV_TO=""
98
+ if command -v timeout >/dev/null 2>&1; then
99
+ if timeout -k 1 1 true >/dev/null 2>&1; then _PROV_TO="timeout -k 2 10"; else _PROV_TO="timeout 10"; fi
100
+ fi
101
+ _PROV="$($_PROV_TO python3 - <<'PROBE' 2>/dev/null || true
102
+ import importlib.util, sys
103
+ try:
104
+ _s = importlib.util.find_spec("yaml")
105
+ _w = (_s.origin or "namespace-package") if _s is not None else "ABSENT"
106
+ except BaseException as _e:
107
+ _w = "UNRESOLVED (%s)" % type(_e).__name__
108
+ print("%s · PyYAML %s" % (sys.executable, " ".join(str(_w).split())[:200]))
109
+ PROBE
110
+ )"
111
+ # Prefix stays OUTSIDE the verdict's namespace: it must not answer `grep '^consent-registry'`.
112
+ # Take the LAST line and collapse whitespace (R6): the subprocess's stdout is not only my print —
113
+ # anything the interpreter emits at startup (a sitecustomize that prints, for one) lands in $_PROV
114
+ # too. Measured: a sitecustomize printing "consent-registry: PASS" forged a verdict line ahead of the
115
+ # real one. Sanitising inside python was not enough, because the forgery never went through python's
116
+ # formatting at all.
117
+ _PROV="$(printf '%s' "$_PROV" | tail -n 1 | tr -d '\r\n' | cut -c1-300)"
118
+ echo "instrument (consent-registry): ${_PROV:-UNRESOLVED (probe produced nothing — absent, errored, or timed out)}"
119
+
75
120
  python3 - "$REG" "$UAP" <<'PY'
76
121
  import sys, os, re, datetime, unicodedata
77
122
  reg_path, uap_path = sys.argv[1], sys.argv[2]
78
123
 
79
124
  def out(sym, msg): print(f" {sym} {msg}")
80
125
 
126
+ # ── What this run measured WITH. Printed BEFORE every branch, including N/A and PASS ────────────
127
+ # Measured 2026-08-12: this gate is wired into selfcheck → `prepublishOnly`. A publish went out green
128
+ # while `/usr/bin/python3` on that same machine had no PyYAML — the session that ran it happened to
129
+ # have an UNRELATED project's venv first on PATH. The reproduction unit is therefore not the machine,
130
+ # it is the PATH that session inherited, and the green said nothing about it. Failure already
131
+ # explained itself; success explained nothing. That asymmetry is what let an unportable PASS ship.
132
+ # Placed here on purpose: the N/A branch below exits before the yaml import, so a line printed after
133
+ # the import is unreachable on the most common local path (no registry) — measured, first draft did
134
+ # exactly that.
135
+ # `except Exception`, not `except ImportError` (cross-family, 2026-08-12): a broken or partially
136
+ # installed PyYAML can raise something else entirely, and an uncaught raise HERE would kill the run
137
+ # before the N/A branch below — i.e. the instrumentation would have changed a verdict. A probe that
138
+ # can decide anything is not a probe. This one announces; the fail-closed import further down owns
139
+ # the verdict, and it is deliberately NOT merged with this one.
140
+
81
141
  if not os.path.exists(reg_path):
82
142
  print(f"consent-registry: N/A — no registry at {reg_path}; promotion DISABLED (not a PASS)")
83
143
  sys.exit(3) # UNMEASURED, not verified — see EXIT CONTRACT
@@ -86,8 +146,11 @@ try:
86
146
  import yaml
87
147
  except ImportError:
88
148
  print("consent-registry: FAIL — pyyaml unavailable, cannot validate; fail-closed")
149
+ print(" required: python3 + PyYAML. install: python3 -m pip install --user pyyaml")
150
+ print(f" the interpreter that came up here: {sys.executable}")
89
151
  sys.exit(1)
90
152
 
153
+
91
154
  # H9 DUPLICATE YAML KEYS. yaml.safe_load is last-wins on duplicate mapping keys, so
92
155
  # `expires: 2020-01-01` followed by `expires: 2099-01-01` silently keeps the future one, and a
93
156
  # duplicated grant key keeps whichever was written last. A consent record whose meaning depends on
@@ -500,11 +500,20 @@ for f in "${FILES[@]}"; do
500
500
  # `tok in text` (paid⊂prepaid) when the variables aren't named verdict/state (M#4, steel-quench).
501
501
  # C2 closes that: simple var-in-var (not a collection literal / range / for) = a likely
502
502
  # containment check that should be exact/word-boundary if it grounds a verdict. Higher noise; advisory.
503
+ # 2026-08-14: pinned an ALL-CAPS-constant exclusion here that Probe C already carries a few lines
504
+ # up (`in [A-Z_]+\b`) — C2 had drifted out of sync with it. Measured on this repo's own scripts/:
505
+ # 6 raw C2 hits, all `if VAR in {seen,by_name,accepted,packed,PLACEHOLDERS}:` — genuine collection
506
+ # membership, not the substring-containment smell this probe exists to catch. Do NOT also add
507
+ # lowercase collection-name heuristics (`seen`/`by_name`/...) here — that class still needs a
508
+ # human to read the assignment and confirm it is a set/dict/list, and a naming guess would just
509
+ # move the false-negative risk instead of removing it. This narrows one already-established class,
510
+ # it does not invent a new one.
503
511
  while IFS= read -r m; do
504
512
  emit "$f" "${m%%:*}" "C2:substring-boolean" "bare 'X in Y' in if/return/assert — if this grounds a presence/verdict check, use exact/word-boundary match, not containment"
505
513
  done < <(grep -nE '^[[:space:]]*(if|elif|return|assert|while)[[:space:]]+[A-Za-z_][A-Za-z0-9_]*[[:space:]]+in[[:space:]]+[A-Za-z_][A-Za-z0-9_.]*[[:space:]]*[:)]?[[:space:]]*$' "$f" 2>/dev/null \
506
514
  | grep -vE '\bfor\b|in range|in enumerate|not in' \
507
- | grep -vE '\b(verdict|present|ground|state|match|expected)\w*\b')
515
+ | grep -vE '\b(verdict|present|ground|state|match|expected)\w*\b' \
516
+ | grep -vE 'in [A-Z_]+[[:space:]]*[:)]?[[:space:]]*$')
508
517
 
509
518
  # Probe E — negated-falsy guard returning permissive (dominance-benchmark round-2 f2 class): an error
510
519
  # SENTINEL (None / {} / "" / []) is falsy, so `if not X: return <PASS>` treats "the check errored / never
@@ -187,10 +187,26 @@ do_check() {
187
187
  # git 추적 파일 → **커밋 시각**(`git log --since=@epoch`). mtime 은 브랜치 전환·체크아웃으로
188
188
  # 갱신되므로 tracked 에서는 신뢰할 수 없다 — 실측으로 확인된 오탐 원인.
189
189
  # gitignored → mtime 밖에 없다(`tracks/**`). 남는 취약면이고 아래에 명명한다.
190
- local _dts; _dts="$(stat -f %m "$d" 2>/dev/null || stat -c %Y "$d" 2>/dev/null)"
191
- if [ -z "${_dts:-}" ]; then
192
- echo "❌ digest mtime 읽었다 선후 판정 불가" >&2; return 10
193
- fi
190
+ # ⚠️ **GNU-first, and then VALIDATE — 순서만으로는 부족하다.** 초판은 `stat -f %m || stat -c %Y`
191
+ # (BSD-first)였고 **리눅스에서 조용히 fail-open 했다**: GNU 의 `-f` 는 `--file-system` 이라
192
+ # 일반 파일에 대해 **exit 0** 으로 파일시스템 리포트(`File: Blocks: …`)를 뱉는다. 즉 `||`
193
+ # 폴백이 영영 안 돌고 `_dts` 에 epoch 대신 여러 줄 blob 이 들어간다 → `git log --since="@<blob>"`
194
+ # 이 파싱 실패 → tracked 대상 **0개** → 「착지 0건」으로 렌더된다. 이 계기가 막으려고 존재하는
195
+ # 바로 그 미측정-을-0으로 렌더가 계기 자신에게서 났다([[feedback_not_found_is_not_zero_family]]).
196
+ # 아래 `[ -z ]` 가드는 blob 이 **비어 있지 않아서** 못 잡았다 — 존재검사는 진위를 못 본다.
197
+ # 실측 2026-08-15: macOS 10/10 PASS · ubuntu:24.04 컨테이너 및 CI(ubuntu-latest) 1/10 FAIL
198
+ # (positive arm `★N-git b` 만 죽는다 = negative 만 통과하는 하네스). 이 레인이 selfcheck 에
199
+ # 배선되기 전에는 아무도 안 돌려서 비용이 0이었다 — 배선이 가정을 표면화한 판본
200
+ # ([[feedback_wiring_surfaces_hidden_failures]], 같은 `stat -f` 원인의 재발).
201
+ # 정수 검증까지 두는 이유: 순서는 플랫폼이 하나 더 늘면 다시 깨지지만, "정수가 아니면 에러"는
202
+ # 안 깨진다. sync-from-be.sh:116 · fh_session_load.sh:105 가 같은 함정을 이미 주석으로 남겼다.
203
+ local _dts; _dts="$(stat -c %Y "$d" 2>/dev/null || true)"
204
+ case "${_dts:-}" in ''|*[!0-9]*) _dts="$(stat -f %m "$d" 2>/dev/null || true)" ;; esac
205
+ case "${_dts:-}" in
206
+ ''|*[!0-9]*)
207
+ echo "❌ digest mtime 을 못 읽었다(stat 출력이 epoch 정수가 아니다) — 선후 판정 불가" >&2
208
+ return 10 ;;
209
+ esac
194
210
  while IFS= read -r f; do
195
211
  [ -n "$f" ] || continue
196
212
  case "$f" in *.md) ;; *) continue ;; esac