@chrono-meta/fh-gate 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.claude/regression/probes_live.yaml +137 -0
  2. package/.claude/rules/.residency-patterns.defaults +7 -0
  3. package/.claude/rules/fh_4axis_gate.md +50 -1
  4. package/.claude-plugin/marketplace.json +8 -2
  5. package/AGENTS.md +27 -0
  6. package/CATALOG.md +17 -0
  7. package/CLAUDE.md +12 -2
  8. package/README.ja.md +51 -7
  9. package/README.ko.md +48 -7
  10. package/README.md +37 -5
  11. package/README.zh.md +45 -8
  12. package/docs/STANDARDS_ALIGNMENT.md +61 -0
  13. package/docs/USER_GUIDE.md +3 -0
  14. package/docs/USE_CASES.md +50 -0
  15. package/docs/model_tier_expectations.md +60 -0
  16. package/knowledge/shared/harness-core/fh_three_layer_canon.md +30 -10
  17. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +8 -0
  18. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +9 -0
  19. package/knowledge/shared/harness-core/iso_ai_standards_crosswalk.md +139 -0
  20. package/knowledge/shared/harness-core/measurement-integrity-checklist.md +20 -0
  21. package/knowledge/shared/learnings/subagent_invocations_log.yaml +441 -4
  22. package/package.json +40 -2
  23. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  24. package/plugins/fh-commons/skills/preprep/README.md +4 -1
  25. package/plugins/fh-commons/skills/preprep/SKILL.md +99 -3
  26. package/plugins/fh-commons/skills/preprep/diagram_from_json.py +154 -0
  27. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
  28. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
  29. package/plugins/fh-commons/skills/preprep/fixtures/mk_slide_fixtures.py +179 -0
  30. package/plugins/fh-commons/skills/preprep/interslide_deps.py +98 -9
  31. package/plugins/fh-commons/skills/preprep/lane_adjacent_dup.py +4 -1
  32. package/plugins/fh-commons/skills/preprep/lane_diagram.py +95 -0
  33. package/plugins/fh-commons/skills/preprep/lane_geometry.py +181 -0
  34. package/plugins/fh-commons/skills/preprep/lane_promise.py +4 -1
  35. package/plugins/fh-commons/skills/preprep/lane_slide_refs.py +134 -0
  36. package/plugins/fh-commons/skills/preprep/lane_slide_relations.py +300 -0
  37. package/plugins/fh-commons/skills/preprep/preprep.py +129 -8
  38. package/plugins/fh-commons/skills/preprep/preprep_wire.py +210 -0
  39. package/plugins/fh-commons/skills/preprep/presentation_checklist.md +4 -0
  40. package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +18 -0
  41. package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +184 -0
  42. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  43. package/plugins/fh-meta/CHANGELOG.md +43 -1
  44. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +87 -2
  45. package/plugins/fh-meta/skills/frontier-digest/SKILL.md +2 -2
  46. package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +25 -5
  47. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL.md +11 -0
  48. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL_detail.md +3 -0
  49. package/plugins/fh-qp/.claude-plugin/plugin.json +22 -0
  50. package/plugins/fh-qp/README.md +71 -0
  51. package/plugins/fh-qp/fixtures/evidence_known_clean.txt +3 -0
  52. package/plugins/fh-qp/fixtures/evidence_known_dirty.txt +6 -0
  53. package/plugins/fh-qp/fixtures/reach_known_partial.tsv +4 -0
  54. package/plugins/fh-qp/fixtures/reach_known_reached.tsv +4 -0
  55. package/plugins/fh-qp/fixtures/reach_known_wall.tsv +3 -0
  56. package/plugins/fh-qp/fixtures/verdicts_known_bad_branch.tsv +2 -0
  57. package/plugins/fh-qp/fixtures/verdicts_known_bad_vacuous_machine.tsv +2 -0
  58. package/plugins/fh-qp/fixtures/verdicts_known_good.tsv +4 -0
  59. package/plugins/fh-qp/fixtures/verdicts_verify_only.tsv +3 -0
  60. package/plugins/fh-qp/qp_profile.example.yaml +29 -0
  61. package/plugins/fh-qp/scripts/qp_tools.sh +216 -0
  62. package/plugins/fh-qp/skills/qp/SKILL.md +65 -0
  63. package/plugins/fh-qp/skills/qp-plan/SKILL.md +42 -0
  64. package/plugins/fh-qp/skills/qp-regress/SKILL.md +45 -0
  65. package/plugins/fh-qp/skills/qp-run/SKILL.md +49 -0
  66. package/scripts/chamber_run.sh +14 -5
  67. package/scripts/com.forge-harness.live-eval.plist +84 -0
  68. package/scripts/compaction_probe.sh +9 -35
  69. package/scripts/directional_diff_gate.sh +14 -2
  70. package/scripts/frontier_digest_autopilot.sh +4 -1
  71. package/scripts/map_postprocess.py +90 -0
  72. package/scripts/outbound_query_guard.sh +131 -0
  73. package/scripts/outbound_query_hook.sh +373 -0
  74. package/scripts/package_coverage_check.sh +55 -16
  75. package/scripts/pipe_verdict_guard.sh +41 -1
  76. package/scripts/probe_live_eval.sh +240 -0
  77. package/scripts/probe_live_eval_lib.py +579 -0
  78. package/scripts/proposal_hook.sh +120 -17
  79. package/scripts/push_zone_check.sh +78 -0
  80. package/scripts/residency_closure_scan.py +252 -0
  81. package/scripts/selfcheck.sh +41 -1
  82. package/scripts/session_close_check.sh +100 -0
  83. package/scripts/sim_isolated_run.sh +98 -2
  84. package/scripts/test_action_yml_lanes.sh +97 -0
  85. package/scripts/test_fh_qp_lanes.sh +105 -0
  86. package/scripts/test_gate_two_verdicts_lanes.sh +139 -0
  87. package/scripts/test_map_postprocess_lanes.sh +143 -0
  88. package/scripts/test_marker_affected_lanes.sh +93 -0
  89. package/scripts/test_marker_crossfamily_lanes.sh +90 -6
  90. package/scripts/test_marker_oracle_lanes.sh +136 -0
  91. package/scripts/test_outbound_query_hook_lanes.sh +433 -0
  92. package/scripts/test_outbound_query_lanes.sh +87 -0
  93. package/scripts/test_pipe_verdict_guard_lanes.sh +26 -0
  94. package/scripts/test_preprep_diagram_lanes.sh +87 -0
  95. package/scripts/test_preprep_drift_anchor.sh +3 -3
  96. package/scripts/test_preprep_slide_refs_lanes.sh +169 -0
  97. package/scripts/test_probe_live_eval_lanes.sh +437 -0
  98. package/scripts/test_proposal_hook_lanes.sh +22 -1
  99. package/scripts/test_push_zone_lanes.sh +304 -0
  100. package/scripts/test_residency_closure_lanes.sh +70 -0
  101. package/scripts/test_sim_isolated_run_lanes.sh +119 -0
  102. package/scripts/test_utterance_intake_lanes.sh +414 -0
  103. package/scripts/test_worktree_reclaim_lanes.sh +70 -0
  104. package/scripts/transcript_utterances.py +222 -0
  105. package/scripts/utterance_intake.sh +424 -0
  106. package/scripts/validate_yaml.sh +27 -0
  107. package/scripts/worktree_reclaim.sh +95 -0
  108. package/templates/.git-hooks/pre-commit +353 -1
  109. package/templates/.git-hooks/pre-push +91 -0
  110. package/templates/RED_TEAM_REPORT.md +49 -0
  111. package/templates/settings.PreToolUse.snippet.json +65 -1
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env python3
2
+ """R1~R5 — 장 사이·장 안의 «관계» 검사 다섯. the private companion store's origin script (lane_slide_relations.py
3
+ (2026-09-04, 실사용 하루치에서 역산)를 preprep 관례로 이식한 것 — 로직은 그대로다.
4
+ 정본: `tracks-meta/fh_signal_2026-09-04_preprep-evolution.md` §1.
5
+
6
+ preprep L8(interslide) 은 «가리킨 대상이 있나»(화면 재사용 · 시각 지시)를 본다. 아래 다섯은
7
+ 그 사정거리 밖에서 실제로 놓친 결함이다(L8 HARD 목록에 그 장들이 아예 안 뜬다).
8
+
9
+ R1 orphan-connective 선두 줄이 «그러나/그래서»로 시작하는데, 그 문장의 주어가
10
+ **직전 장 어디에도 없다** → 받을 말 없이 뒤집는다
11
+ 실사고: 40p 부제 «그러나 깃허브는 막을 자리를 줄 뿐…» (39p 에 깃허브 0회)
12
+
13
+ R2 enum-dropped 앞 장이 ①②③ 으로 센 것을, 이 장이 «이 셋 중…» 이라 부르면서
14
+ **번호를 하나도 안 달았다** → 1:1 대응이 안 보인다
15
+ 실사고: 40p 가 38·39p 의 셋을 소유권으로 재편하며 번호를 버렸다
16
+ (원장 재현 결과 41p — 표 이웃 프레임이라 쪽수가 한 칸 어긋난다)
17
+
18
+ R3 lead-term-unbacked 선두 줄이 **강조색으로 선언한 낱말**이 그 장 다른 어디에도 없다
19
+ → 화면이 자기 부제를 못 받친다
20
+ 실사고: 20p 부제가 «방법론» 을 말하는데 장에 방법론이 없었다
21
+
22
+ R4 screen-heavy 화면 자수가 이 덱 자기 분포의 상위권 → 한 장에 너무 많이 얹었다
23
+ R5 read-load **직전 장 대비 새로 뜬 글자** ÷ 발화 초 — 화면은 늘었는데 말은 짧다.
24
+ 🟥 대사만 줄이고 화면을 그대로 두면 이 값이 올라간다. 그게 역효과다.
25
+ 🟥 «새로 뜬 글자»로 재는 이유: 빌드 프레임은 앞 장에서 이미 읽은 화면이라
26
+ 총 자수로 재면 44p(발화 4초·화면 240자) 같은 것이 1위로 뜬다 — 오탐이다.
27
+ 🟥 **무대 지시**([간지]/[진행])는 발화로 세지 않는다 — 대본 자수 규칙과 같이
28
+ 간다. 안 빼면 Q&A·참고문헌 장이 부담 1·4위로 잘못 오른다.
29
+
30
+ 🟥 다섯 다 **advisory** 다. 판정은 사람이 한다. preprep 배선(`preprep.py`)은 이 레인의
31
+ findings 를 종료코드에 태우지 않는다 — L8·L11 과 같은 자리다.
32
+ 🟥 강조색은 덱마다 다르므로 `surfaces.yaml` 의 `slide_relations.accent` 로 받는다
33
+ (기본값 FAE100 — 원 코퍼스의 그 회사 옐로. 다른 덱에 옮기면 재선언해야 한다).
34
+
35
+ ## 🟥 R3 «조이는 과정» — 옮길 때 반드시 같이 옮겨야 하는 것 (초판이 겪은 오탐 29건의 원인)
36
+
37
+ - **선두 줄 판별** — 「y 로 두 번째 도형」은 도해 라벨(「① 커밋할 때」류)을 문다.
38
+ ⇒ 「제목 아래 · 가로로 길게 · 한 줄」(`y < LEAD_MAX_Y` · `w > LEAD_MIN_CX` · `cy < 1.2M`)로
39
+ 정의한다 — 아래 `Deck.lead()`.
40
+ - **강조 비교** — 통문장 부분일치는 조사 하나로 갈린다. ⇒ **조사 벗긴 낱말 집합** 교집합으로 본다
41
+ (`strip_josa` + `terms`).
42
+ - **활용 어미 제외** — 「막고 서는」·「고친 것을 되돌려서」는 낭독 리듬이지 낱말 선언이 아니다
43
+ (`VERBAL_END`).
44
+ - 🟥 **전역 빈도 필터를 R3 에 걸면 안 된다** — 「방법론」처럼 덱에 흔한 낱말이 **바로 이 장에서**
45
+ 빠지는 것이 결함인데, 흔하다는 이유로 걸러 진짜를 죽이게 된다. R3 는 「이 장 안에 있나」만 본다
46
+ (R2 의 `distinct()` 전역빈도 필터와는 다른 자리다 — 섞지 않는다).
47
+ - **R2 는 세는 낱말을 «제목»에서만 찾는다** — 본문에서 찾으면 딴 「세 가지」에 물린다.
48
+
49
+ ## 🟥 R5 는 «신규 글자»로 재야 한다 (총량으로 재면 뒤집힌다)
50
+
51
+ 총 자수 기준이면 부담 1위가 **이미 앞 장에서 다 나온 빌드 프레임**으로 뒤집힌다 — 청중은 새로
52
+ 안 읽는다. 직전 장 대비 신규분(`Deck.new_chars`)으로 재야 진짜가 남는다. 그리고 무대 지시를
53
+ 발화로 세면 Q&A·참고문헌 장이 부담 상위에 잘못 오른다(`Deck.STAGE` 로 뺀다).
54
+
55
+ ## R4·R5 임계 — 숫자를 코드에 박지 않는다
56
+
57
+ 임계는 **이 덱 자기 분포**의 P90 에서 뽑는다(`statistics` + `sorted(...)[int(n*0.9)]`). 값은
58
+ 발표마다 다시 재야 한다. ⚠️ **이 레인은 게이트가 아니라 순위표다** — 정의상 늘 그 덱의 상위
59
+ 약 10%가 뜬다. 「후보 0건」을 목표로 삼지 마라.
60
+
61
+ ## 증거 등급 (§7, 인용 전에 읽어라)
62
+
63
+ R1·R2 는 **실제 백업본**에서 뜬 결함이다. R3 는 **합성 뮤턴트**(사람이 실물 2장을 줄여 만든
64
+ `fixtures/fixture_R3_{positive,negative}.pptx`)로만 계량됐다 — 자연 발생본이 파일로 안 남았다.
65
+ **묶어서 「전부 실물」이라 쓰지 마라.** R4·R5 는 순위표라 known-pair 자체가 성립 형태가 다르다
66
+ (오탐/미탐이 아니라 「1위가 진짜인가」로 잰다). reps=1, 이 저장소 자기 바(≥3) 미달.
67
+ """
68
+ import zipfile, re, os
69
+
70
+ CONNECTIVE = ('그러나', '하지만', '그런데', '그래서', '따라서', '그러므로', '반면')
71
+ ENUM = ('①', '②', '③', '④', '⑤', '첫째', '둘째', '셋째')
72
+ COUNT_WORD = re.compile(r'이\s*(셋|둘|넷|다섯)\s*중|(세|두|네|다섯)\s*가지|(셋|둘|넷)\s*중')
73
+ # 조사·흔한 낱말은 «대상»이 못 된다
74
+ STOP = set('것 수 때 곳 점 등 및 저희 우리 오늘 이번 발표 지금 여기 그것 이것 하나 자리 경우'.split())
75
+ TOKEN = re.compile(r'[가-힣A-Za-z][가-힣A-Za-z0-9]{1,11}')
76
+ JOSA = re.compile(r'(은|는|이|가|을|를|의|도|만|과|와|에|에서|으로|로|라는|처럼|보다)$')
77
+ # 활용 어미로 끝나면 «낱말 선언»이 아니라 문장 조각이다 (「막고 서는」 · 「고친 것을 되돌려서」)
78
+ VERBAL_END = re.compile(r'(는|은|고|서|며|야|지|다|까|나|면|아|어)$')
79
+
80
+
81
+ def strip_josa(t):
82
+ for _ in range(2):
83
+ t2 = JOSA.sub('', t)
84
+ if t2 == t or len(t2) < 2:
85
+ break
86
+ t = t2
87
+ return t
88
+
89
+
90
+ class Deck:
91
+ """pptx 를 장 단위 텍스트+위치로 읽는다. 텍스트 레이어만 본다(이미지 구운 텍스트는 안 보인다)."""
92
+
93
+ def __init__(self, path, accent='FAE100'):
94
+ self.z = zipfile.ZipFile(path)
95
+ self.accent = accent
96
+ rels = dict(re.findall(r'Id="([^"]+)"[^>]*Target="slides/slide(\d+)\.xml"',
97
+ self.z.read('ppt/_rels/presentation.xml.rels').decode('utf-8')))
98
+ lst = re.search(r'<p:sldIdLst>.*?</p:sldIdLst>',
99
+ self.z.read('ppt/presentation.xml').decode('utf-8'), re.S).group(0)
100
+ self.order = [int(rels[r]) for r in re.findall(r'<p:sldId id="\d+" r:id="([^"]+)"/>', lst)]
101
+ self.shapes = [self._shapes(sn) for sn in self.order]
102
+
103
+ def _shapes(self, sn):
104
+ x = self.z.read('ppt/slides/slide%d.xml' % sn).decode('utf-8')
105
+ out = []
106
+ for m in re.finditer(r'<p:sp>.*?</p:sp>', x, re.S):
107
+ b = m.group(0)
108
+ nm = re.search(r'name="([^"]*)"', b)
109
+ o = re.search(r'<a:off x="(-?\d+)" y="(-?\d+)"/><a:ext cx="(\d+)" cy="(\d+)"', b)
110
+ t = ' '.join(re.findall(r'<a:t>([^<]*)</a:t>', b)).strip()
111
+ if not t:
112
+ continue
113
+ acc = [re.sub(r'<[^>]+>', '', r) for r in re.findall(
114
+ r'<a:r><a:rPr[^>]*>(?:(?!</a:r>).)*?%s(?:(?!</a:r>).)*?<a:t>([^<]*)</a:t>'
115
+ % self.accent, b, re.S)]
116
+ out.append({'name': nm.group(1) if nm else '?',
117
+ 'y': int(o.group(2)) if o else 0,
118
+ 'w': int(o.group(3)) if o else 0,
119
+ 'cy': int(o.group(4)) if o else 0,
120
+ 'text': re.sub(r'\s+', ' ', t),
121
+ 'accent': [a.strip() for a in acc if a.strip()]})
122
+ return out
123
+
124
+ STAGE = ('[간지]', '[진행]', '[ 간지', '[ 진행')
125
+
126
+ def notes(self, i):
127
+ p = 'ppt/slides/_rels/slide%d.xml.rels' % self.order[i]
128
+ if p not in self.z.namelist():
129
+ return ''
130
+ m = re.search(r'notesSlide(\d+)', self.z.read(p).decode('utf-8'))
131
+ if not m:
132
+ return ''
133
+ return re.sub(r'\s+', ' ', ' '.join(re.findall(
134
+ r'<a:t>([^<]*)</a:t>',
135
+ self.z.read('ppt/notesSlides/notesSlide%s.xml' % m.group(1)).decode('utf-8')))).strip()
136
+
137
+ def spoken_sec(self, i):
138
+ """발화 초. 🟥 무대 지시([간지]·[진행])는 발화가 아니다 — 대본 자수 규칙과 같이 간다."""
139
+ n = self.notes(i)
140
+ if not n or any(n.lstrip().startswith(t) for t in self.STAGE):
141
+ return None
142
+ return round(len(re.sub(r'\s', '', n)) / 5.36)
143
+
144
+ def screen_chars(self, i):
145
+ return sum(len(re.sub(r'\s', '', s['text'])) for s in self.shapes[i])
146
+
147
+ def new_chars(self, i):
148
+ cur = {s['text'] for s in self.shapes[i]}
149
+ prev = {s['text'] for s in self.shapes[i - 1]} if i else set()
150
+ return sum(len(re.sub(r'\s', '', t)) for t in cur - prev)
151
+
152
+ def text(self, i):
153
+ return ' '.join(s['text'] for s in self.shapes[i])
154
+
155
+ # 선두 줄 = «제목 바로 아래에 가로로 길게 깔린 한 줄». 도해 라벨과 갈라야 한다
156
+ # 🟥 초판은 «y 로 두 번째 도형»이라고 했다가 도해 안 라벨(「① 커밋할 때」)을 물었다.
157
+ LEAD_MAX_Y, LEAD_MIN_CX = 4200000, 12000000
158
+
159
+ def lead(self, i):
160
+ sh = [s for s in self.shapes[i]
161
+ if s['cy'] and s['name'] != 'sec-guide'
162
+ and s['y'] < self.LEAD_MAX_Y and s['cy'] < 1200000
163
+ and s['w'] > self.LEAD_MIN_CX]
164
+ if not sh:
165
+ return None
166
+ sh.sort(key=lambda s: s['y'])
167
+ return sh[-1] # 제목이 위, 부제가 그 아래
168
+
169
+
170
+ def doc_freq(deck):
171
+ from collections import Counter
172
+ c = Counter()
173
+ for i in range(len(deck.order)):
174
+ for t in terms(deck.text(i)):
175
+ c[t] += 1
176
+ return c, len(deck.order)
177
+
178
+
179
+ def terms(txt):
180
+ return {strip_josa(t) for t in TOKEN.findall(txt)
181
+ if len(strip_josa(t)) >= 2 and strip_josa(t) not in STOP}
182
+
183
+
184
+ def analyze(deck):
185
+ """계산부 — findings 만 낸다. 출력은 호출자(`scan`) 몫이다(interslide_deps.analyze 와 같은
186
+ 분리: CLI/레인이 각자 계산하면 관대함이 갈린다)."""
187
+ F = []
188
+ df, N = doc_freq(deck)
189
+
190
+ def distinct(t):
191
+ return df.get(t, 0) <= max(2, N * 0.15)
192
+
193
+ for i in range(len(deck.order)):
194
+ page, lead = i + 1, deck.lead(i)
195
+ cur, prev = deck.text(i), deck.text(i - 1) if i else ''
196
+
197
+ # ── R1 선두 줄의 접속 부사가 받을 말이 없다
198
+ if lead:
199
+ for c in CONNECTIVE:
200
+ if not lead['text'].startswith(c):
201
+ continue
202
+ rest = lead['text'][len(c):].strip()
203
+ subj = next((strip_josa(t) for t in TOKEN.findall(rest)
204
+ if len(strip_josa(t)) >= 2 and strip_josa(t) not in STOP), None)
205
+ if subj and subj not in prev:
206
+ F.append(('R1', page, f'선두 줄이 «{c}»로 뒤집는데 «{subj}»가 직전 장에 없다'
207
+ f' ▸ {lead["text"][:60]}'))
208
+ break
209
+
210
+ # ── R2 앞 장이 번호로 센 것을 번호 없이 다시 묶는다
211
+ title = min(deck.shapes[i], key=lambda s: s['y'])['text'] if deck.shapes[i] else ''
212
+ # 🟥 세는 낱말이 «제목»에 있어야 한다. 본문에 있는 다른 「세 가지」에 물린다.
213
+ if COUNT_WORD.search(title) and not any(e in cur for e in ENUM):
214
+ for back in (1, 2, 3):
215
+ if i - back < 0:
216
+ break
217
+ ptxt = deck.text(i - back)
218
+ if sum(e in ptxt for e in ENUM) < 2:
219
+ continue
220
+ # 🟥 «가까이 번호가 있다»만으로는 안 된다 — 다른 「세 가지」에 물릴 수 있다.
221
+ # 같은 셋을 다시 묶는 것인지는 **드문 낱말을 공유하는가**로 본다.
222
+ shared = {t for t in terms(cur) & terms(ptxt) if distinct(t)}
223
+ if len(shared) >= 3:
224
+ F.append(('R2', page, f'«이 셋/세 가지»라 부르는데 번호가 없다 — '
225
+ f'{i - back + 1}p 가 번호로 센 것과 '
226
+ f'«{", ".join(sorted(shared)[:4])}» 를 공유한다'))
227
+ break
228
+
229
+ # ── R3 선두 줄이 강조로 선언한 낱말이 이 장 어디에도 없다
230
+ if lead:
231
+ body = ' '.join(s['text'] for s in deck.shapes[i] if s is not lead)
232
+ btok = terms(body)
233
+ for a in lead['accent']:
234
+ # 🟥 통문장 부분일치로 보면 안 된다 — 조사 하나로 갈린다. 낱말 집합으로 본다.
235
+ # 🟥 전역 빈도(distinct) 로 거르면 안 된다 — 흔한 낱말이 바로 이 장에서 빠지는
236
+ # 것이 결함이다. 여기서 묻는 건 «이 장 안에 있나» 뿐이다.
237
+ key = strip_josa(a.strip(' ,.·'))
238
+ if VERBAL_END.search(key):
239
+ continue # 활용형 강조는 낭독 리듬이지 «선언»이 아니다
240
+ atok = terms(a)
241
+ if not atok or len(a.strip()) > 14:
242
+ continue # 긴 강조는 «선언»이 아니라 문장이다
243
+ if not (atok & btok):
244
+ F.append(('R3', page, f'선두 줄이 «{a.strip()}»를 강조해 놓고 '
245
+ f'장 안에 그 낱말이 없다'))
246
+
247
+ # ── R4 / R5 — 임계는 «이 덱 자기 분포»에서 뽑는다. 숫자를 지어내지 않는다.
248
+ import statistics
249
+ sc = [deck.screen_chars(i) for i in range(len(deck.order))]
250
+ if sc:
251
+ p90 = sorted(sc)[int(len(sc) * 0.9)]
252
+ for i in range(len(deck.order)):
253
+ if sc[i] > p90:
254
+ F.append(('R4', i + 1, f'화면 {sc[i]}자 — 이 덱 상위 10% '
255
+ f'(중앙 {int(statistics.median(sc))}자 · P90 {p90}자)'))
256
+ load = []
257
+ for i in range(len(deck.order)):
258
+ sec = deck.spoken_sec(i)
259
+ if not sec or sec < 3:
260
+ continue
261
+ n = deck.new_chars(i)
262
+ if n < 60:
263
+ continue
264
+ load.append((i, n, sec, n / sec))
265
+ if load:
266
+ lp90 = sorted(x[3] for x in load)[int(len(load) * 0.9)]
267
+ med = statistics.median(x[3] for x in load)
268
+ for i, n, sec, r in load:
269
+ if r > lp90:
270
+ F.append(('R5', i + 1, f'새로 뜬 {n}자를 {sec}초에 — {r:.1f}자/초 '
271
+ f'(중앙 {med:.1f} · P90 {lp90:.1f}) ▸ 화면을 줄이거나 말을 늘려라'))
272
+ return F
273
+
274
+
275
+ def _resolve(root, p):
276
+ return os.path.normpath(os.path.join(root, os.path.expanduser(p)))
277
+
278
+
279
+ def scan(cfg, root):
280
+ """preprep.py 가 부르는 레인 진입점. (findings, notes) — 🟥 **advisory 고정**:
281
+ 호출자는 findings 를 버리고 notes 만 취한다(L8·L11 관례). 여기서도 findings 를 채워
282
+ 반환은 하되, 그 자체가 «종료코드에 안 태운다»는 약속을 깨지 않는다 — 판단은 호출부에 있다.
283
+ """
284
+ deck_s = (cfg.get('surfaces_by_id') or {}).get('built_deck')
285
+ if not deck_s:
286
+ return [], ['R1-R5 slide-relations : built_deck 미선언 — NOT_CONFIGURED (0 아님)']
287
+ path = _resolve(root, deck_s['path'])
288
+ if not os.path.exists(path):
289
+ return [], [f'R1-R5 slide-relations : built_deck 실물 없음({path}) — UNMEASURED (0 아님)']
290
+ accent = (cfg.get('slide_relations') or {}).get('accent', 'FAE100')
291
+ try:
292
+ deck = Deck(path, accent=accent)
293
+ F = analyze(deck)
294
+ except Exception as e:
295
+ return [], [f'R1-R5 slide-relations : 계기 오류({type(e).__name__}: {e}) — UNMEASURED (0 아님)']
296
+ notes = [f'R1-R5 slide-relations : {len(deck.order)}장 · 후보 {len(F)}건 '
297
+ f'🟥 advisory, 판정은 사람 (순위표 성격의 R4/R5 포함 — 「후보 0」을 목표로 삼지 마라)']
298
+ for k, p, m in sorted(F, key=lambda x: x[1]):
299
+ notes.append(f' {k} {p:3}p {m}')
300
+ return F, notes
@@ -9,6 +9,7 @@
9
9
  L2 unit-ref — 표면 쌍의 단위 참조 정합 (대상의 «은퇴 선언 어휘»를 존중 — 안 하면 20% FP)
10
10
  L3 inventory — 선언된 표면이 실물로 있나 (부재 = UNMEASURED, **0 이 아니다**)
11
11
  L4 getput — 왕복 no-op 컨트롤. **v0.1 미배선 → NOT_WIRED 로 말한다**(거짓 초록 금지)
12
+ L12 diagram — 타입 JSON 도해가 «지금 JSON» 에서 validate 를 거쳐 구워졌나 (lane_diagram.py · 2026-09-05)
12
13
 
13
14
  exit 0 = 전 레인 통과 · 1 = 발견 있음 · 2 = 계기 오류/미측정으로 판정 불가 (PASS 아님)
14
15
 
@@ -25,6 +26,24 @@ def load(path):
25
26
  def resolve(root, p):
26
27
  return os.path.normpath(os.path.join(root, os.path.expanduser(p)))
27
28
 
29
+ # 🟥 2026-09-04 수리(신호 §3-2) — canon_terms/jargon_terms 는 이제까지 **HERE(이 스크립트
30
+ # 위치)** 기준으로만 풀렸다. 자산군 쪽(surfaces.yaml 이 있는 root)에 실제 목록 파일을 두면
31
+ # 못 찾았고, 실사용에서 절대경로를 넣어서야 돌았다(문서에도 없던 함정).
32
+ # ⇒ root 를 먼저 보고, 없을 때만 HERE(스킬 배포본에 딸린 example 계열)로 물러난다 —
33
+ # 스킬을 설치 위치와 무관하게 그대로 돌릴 수 있어야 하므로 폴백 자체는 남긴다. 폴백을
34
+ # 썼다는 사실은 출력에 «(skill-dir fallback)» 로 남긴다(조용히 넘기지 않는다).
35
+ def resolve_declared(root, value):
36
+ """(경로, skill-dir 폴백 여부). 절대경로면 그대로. root 에 없으면 HERE 로 시도한다."""
37
+ if os.path.isabs(value):
38
+ return value, False
39
+ p_root = resolve(root, value)
40
+ if os.path.exists(p_root):
41
+ return p_root, False
42
+ p_here = resolve(HERE, value)
43
+ if os.path.exists(p_here):
44
+ return p_here, True
45
+ return p_root, False # 둘 다 없다 — root 기준 경로를 대서 에러 메시지가 뭘 찾았는지 보이게 한다
46
+
28
47
  # 🟥 2026-08-25 신설 — 선언 경로가 판번호로 하드코딩돼 stale 해지는 클래스(G2).
29
48
  # 실측: surfaces.yaml 이 v4.6 을 가리키는 동안 실제 산출물은 v7.5 였다(12판 차이).
30
49
  # ⚠️ **최신본으로 자동 추종하지 않는다** — 옛 판을 «일부러» 재는 경우를 뺏기 때문이다.
@@ -71,6 +90,31 @@ def read_text(path, kind):
71
90
  src = open(path, encoding='utf-8', errors='replace').read()
72
91
  lits = re.findall(r'"([^"\n]{2,})"|\'([^\'\n]{2,})\'', src)
73
92
  return '\n'.join(a or b for a, b in lits)
93
+ if kind == 'diagram_source':
94
+ # 🟥 2026-09-05 신설 — 타입 있는 도해 JSON(archify 스키마). figure_source 와 같은 이유로
95
+ # «그림은 못 읽지만 그림을 그리는 소스는 읽는다». 노드 label/sublabel/tag · 간선 label ·
96
+ # 레인/구간/그룹 label · 카드 문장만 뽑는다(id·type 같은 기계 낱말은 L1/L5 에 안 태운다).
97
+ # 굽기 정합(지금 JSON 의 그림인가 · validate · 해상도)은 L12(lane_diagram.py) 몫이다.
98
+ import json as _json
99
+ d = _json.load(open(path, encoding='utf-8'))
100
+ out = []
101
+ for key in ('lanes', 'phases', 'groups', 'nodes', 'edges', 'participants', 'messages', 'states', 'transitions', 'components', 'relationships', 'stages', 'flows'):
102
+ for it in d.get(key) or []:
103
+ if isinstance(it, dict):
104
+ for f in ('label', 'sublabel', 'tag', 'note'):
105
+ if it.get(f): out.append(str(it[f]))
106
+ for c in d.get('cards') or []:
107
+ if isinstance(c, dict):
108
+ if c.get('title'): out.append(str(c['title']))
109
+ out += [str(x) for x in (c.get('items') or [])]
110
+ m = d.get('meta') or {}
111
+ for f in ('title', 'subtitle'):
112
+ if m.get(f): out.append(str(m[f]))
113
+ for v in m.get('views') or []:
114
+ if isinstance(v, dict):
115
+ for f in ('label', 'note'):
116
+ if v.get(f): out.append(str(v[f]))
117
+ return '\n'.join(out)
74
118
  if kind == 'keynote':
75
119
  raise RuntimeError('keynote: 사람만 읽는 면 — 기계 어댑터 없음')
76
120
  raise RuntimeError(f'unknown kind: {kind}')
@@ -160,9 +204,24 @@ def hits(text, lit, speech_marker=None): # yields (cls, line_no, line)
160
204
 
161
205
  # ── L1 canon ──────────────────────────────────────────────────────────────────
162
206
  def lane_canon(cfg, root, texts, surf_meta):
163
- terms = load(resolve(HERE, cfg['canon_terms']) if not os.path.isabs(cfg['canon_terms'])
164
- else cfg['canon_terms'])
165
207
  findings, notes = [], []
208
+ ct_cfg = cfg.get('canon_terms')
209
+ if not ct_cfg:
210
+ return [], ['L1 canon : canon_terms 미선언 — NOT_CONFIGURED (0 아님)']
211
+ ct_path, ct_fb = resolve_declared(root, ct_cfg)
212
+ if ct_fb:
213
+ notes.append(f'L1 canon : canon_terms 를 root 에서 못 찾아 스킬 위치로 폴백 '
214
+ f'(skill-dir fallback) — {ct_path}')
215
+ # 🟥 2026-09-04 수리(신호 §3-3) — 파일 부재가 여기서 `FileNotFoundError` 트레이스백으로
216
+ # 죽었다. 이 스킬 자신의 교리가 「부재 ↔ 0 을 가른다 · UNMEASURED 로 낸다」인데 자기
217
+ # 코드가 그걸 어기고 있었다([[feedback_rule_misdescribes_its_own_machine]] 과 같은 족).
218
+ # ⇒ UNMEASURED 로 낮춰 잡고 **나머지 레인은 계속 돈다**(L1 하나가 죽는다고 전체가
219
+ # 안 죽는다 — main() 은 이 함수를 감싸지 않으므로 여기서 반드시 잡아야 한다).
220
+ try:
221
+ terms = load(ct_path)
222
+ except Exception as e:
223
+ return [], [f'L1 canon : canon_terms 부재({ct_path}) — UNMEASURED (0 아님): '
224
+ f'{type(e).__name__}: {e}']
166
225
 
167
226
  # L1-a 원장 ↔ 기계 목록 커버리지 (이 하네스가 잡는 결함이 자기 자신에게도 난다)
168
227
  ledger = resolve(root, cfg['canon_ledger'])
@@ -366,12 +425,23 @@ def lane_jargon(cfg, root, texts, surf_meta):
366
425
  «선언한 것 중 풀이 없는 게 없다»이다. 0 을 «깨끗하다»로 읽지 말 것."""
367
426
  cfgp = cfg.get('jargon_terms')
368
427
  if not cfgp: return [], ['L5 jargon : 선언 파일 없음 — NOT_CONFIGURED (0 아님)']
369
- spec = load(resolve(HERE, cfgp) if not os.path.isabs(cfgp) else cfgp)
428
+ # 🟥 2026-09-04 canon_terms 같은 수리(신호 §3-2·3-3): root 우선 · HERE 폴백 ·
429
+ # 부재는 크래시가 아니라 UNMEASURED.
430
+ jt_path, jt_fb = resolve_declared(root, cfgp)
431
+ jt_notes = []
432
+ if jt_fb:
433
+ jt_notes.append(f'L5 jargon : jargon_terms 를 root 에서 못 찾아 스킬 위치로 폴백 '
434
+ f'(skill-dir fallback) — {jt_path}')
435
+ try:
436
+ spec = load(jt_path)
437
+ except Exception as e:
438
+ return [], [f'L5 jargon : jargon_terms 부재({jt_path}) — UNMEASURED (0 아님): '
439
+ f'{type(e).__name__}: {e}']
370
440
  win = int(spec.get('gloss_window', 2))
371
- findings, notes = [], []
441
+ findings, notes = [], list(jt_notes)
372
442
  spoken = [sid for sid, m in surf_meta.items() if m.get('spoken')]
373
443
  if not spoken:
374
- return [], ['L5 jargon : 낭독면 선언 0 — UNMEASURED (0 아님)']
444
+ return [], notes + ['L5 jargon : 낭독면 선언 0 — UNMEASURED (0 아님)']
375
445
  checked = glossed = 0
376
446
  for sid in spoken:
377
447
  if sid not in texts: continue
@@ -577,7 +647,9 @@ def lane_interslide(cfg, root):
577
647
  import importlib.util
578
648
  sp = importlib.util.spec_from_file_location('interslide_deps', mod_p)
579
649
  m = importlib.util.module_from_spec(sp); sp.loader.exec_module(m)
580
- r = m.analyze(draft_p, build_p)
650
+ # 🟥 2026-09-04 — 원고 표면의 unit_pattern 선언을 analyze() 로 그대로 넘긴다
651
+ # (신호 §3-1 수리 — units() 하드코딩을 없앤 절반이 여기다).
652
+ r = m.analyze(draft_p, build_p, pattern=ms.get('unit_pattern'))
581
653
  except Exception as e:
582
654
  return [], [f'L8 interslide : 계기 오류({e}) — UNMEASURED (0 아님)']
583
655
  if r.get('error'):
@@ -701,8 +773,17 @@ def main():
701
773
  # 별도 모듈 레인(L9/L10/L11)이라 둘 다 센다.
702
774
  # [[feedback_rule_misdescribes_its_own_machine]] · [[feedback_instrument_vs_target_and_budget]]
703
775
  _lanes = sorted(n[5:] for n in globals() if n.startswith('lane_') and callable(globals()[n]))
704
- _mods = sorted(m for m in ('lane_promise', 'lane_adjacent_dup', 'lane_progression')
776
+ _mods = sorted(m for m in ('lane_promise', 'lane_adjacent_dup', 'lane_progression',
777
+ 'lane_slide_relations', 'lane_geometry', 'lane_diagram', 'lane_slide_refs')
705
778
  if os.path.exists(os.path.join(HERE, m + '.py')))
779
+ # 🟥 2026-09-04 신설 — `--lane R1,R2,...` 로 **새 모듈 레인(R1-R5·P1/P3)만** 골라 끈다/켠다.
780
+ # L1~L11 은 아직 이 필터를 안 탄다(usage 줄의 --lane 은 그쪽엔 미배선인 채 남아 있다 —
781
+ # 있는 척하지 않는다). 최소 접촉 원칙: 기존 L* 배선은 안 건드린다.
782
+ _lane_arg = sys.argv[sys.argv.index('--lane') + 1] if '--lane' in sys.argv else None
783
+ _wanted = set(_lane_arg.split(',')) if _lane_arg else None
784
+
785
+ def _lane_on(*codes):
786
+ return _wanted is None or any(c in _wanted for c in codes)
706
787
  print(f"── 발표 준비 하네스 v0.1 ──")
707
788
  print(f" 판: {os.path.realpath(__file__)}")
708
789
  print(f" 레인 {len(_lanes)}(내장): {' · '.join(_lanes)}")
@@ -726,7 +807,11 @@ def main():
726
807
  for i, why in unreadable: print(f" {ANSI_UNK} 기계 어댑터 없음: {i} — {why} (UNMEASURED)")
727
808
 
728
809
  findings, notes = [], []
729
- surf_meta = {s['id']: {'marker': s.get('speech_marker'), 'spoken': s.get('spoken', False)}
810
+ # 🟥 2026-09-04 'unit_pattern' 추가했다. L6 pacing 은 이미 이 필드를 읽고 있었는데
811
+ # (surf_meta[sid].get('unit_pattern')) 여기서 채워준 적이 없어 **항상 None** 이었다 —
812
+ # L10/L11 도 같은 필드로 units() 에 pattern 을 전달하므로 여기가 단일 공급처가 된다.
813
+ surf_meta = {s['id']: {'marker': s.get('speech_marker'), 'spoken': s.get('spoken', False),
814
+ 'unit_pattern': s.get('unit_pattern')}
730
815
  for s in cfg['surfaces']}
731
816
  f1, n1 = lane_canon(cfg, root, texts, surf_meta); findings += f1; notes += n1
732
817
  try:
@@ -763,6 +848,42 @@ def main():
763
848
  f6, n6 = lane_pacing(cfg, root, texts, surf_meta); findings += f6; notes += n6
764
849
  f7, n7 = lane_regen(cfg, root); findings += f7; notes += n7
765
850
  f8, n8 = lane_interslide(cfg, root); findings += f8; notes += n8
851
+ if _lane_on('R1', 'R2', 'R3', 'R4', 'R5', 'R'):
852
+ try:
853
+ import lane_slide_relations
854
+ _fR, _nR = lane_slide_relations.scan(cfg, root)
855
+ notes += _nR # advisory 고정 — findings 에 안 태운다(L8·L11 관례)
856
+ except Exception as _e:
857
+ notes.append('R1-R5 slide-relations : 계기 미실행 — NOT_WIRED (%s: %s) (0 아님)'
858
+ % (type(_e).__name__, _e))
859
+ # L13 slide-refs — 「N p」 리터럴 장 번호 참조가 실재하는 장을 가리키나(2026-09-06 신설).
860
+ # 이웃 레인들은 «말로 가리키는 것»만 본다(L5/L8 화면 재사용·시각 지시 · R1~R5 접속어).
861
+ # 숫자 참조는 아무도 안 봤고, 장을 빼면 그 뒤가 통째로 당겨진다.
862
+ # 차단은 «범위 밖» 하나뿐 — 「그 번호가 맞는 장인가」는 의미 판단이라 안 본다.
863
+ try:
864
+ import lane_slide_refs
865
+ _f13, _n13 = lane_slide_refs.scan(cfg, root)
866
+ findings += _f13; notes += _n13
867
+ except Exception as _e:
868
+ notes.append('L13 slide-refs : 계기 미실행 — NOT_WIRED (%s: %s) (0 아님)'
869
+ % (type(_e).__name__, _e))
870
+ # L12 diagram — 타입 JSON 도해가 «지금 JSON» 에서 validate 를 거쳐 구워졌나(2026-09-05 신설).
871
+ # 차단: 선언된 diagram_source 표면에 한해 지문·validate·해상도·viewBox 폭·여백을 본다.
872
+ # 영수증 없는 PNG(손그림)는 UNMEASURED 노트로만 — 0 이 아니다.
873
+ try:
874
+ import lane_diagram
875
+ _f12, _n12 = lane_diagram.scan(cfg, root)
876
+ findings += _f12; notes += _n12
877
+ except Exception as _e:
878
+ notes.append('L12 diagram : 계기 미실행 — NOT_WIRED (%s: %s) (0 아님)' % (type(_e).__name__, _e))
879
+ if _lane_on('P1', 'P3', 'P'):
880
+ try:
881
+ import lane_geometry
882
+ _fP, _nP = lane_geometry.scan(cfg, root)
883
+ notes += _nP # advisory 고정 — findings 에 안 태운다(L8·L11 관례)
884
+ except Exception as _e:
885
+ notes.append('P1/P3 geometry : 계기 미실행 — NOT_WIRED (%s: %s) (0 아님)'
886
+ % (type(_e).__name__, _e))
766
887
 
767
888
  for n in notes: print(f" · {n}")
768
889
  use = [f for f in findings if f[1] != 'mention']