@chrono-meta/fh-gate 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.claude-plugin/marketplace.json +9 -3
  2. package/README.md +2 -2
  3. package/docs/codex-compat.md +1 -1
  4. package/knowledge/shared/learnings/subagent_invocations_log.yaml +89 -4
  5. package/package.json +8 -1
  6. package/plugins/fh-commons/.claude-plugin/plugin.json +2 -2
  7. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  8. package/plugins/fh-meta/CHANGELOG.md +22 -0
  9. package/plugins/fh-preprep/.claude-plugin/plugin.json +22 -0
  10. package/plugins/fh-preprep/README.md +24 -0
  11. package/plugins/{fh-commons → fh-preprep}/skills/preprep/SKILL.md +75 -3
  12. package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/mk_slide_fixtures.py +152 -1
  13. package/plugins/fh-preprep/skills/preprep/lane_attr_consistency.py +370 -0
  14. package/plugins/fh-preprep/skills/preprep/lane_geometry.py +339 -0
  15. package/plugins/fh-preprep/skills/preprep/lane_screen_parity.py +232 -0
  16. package/plugins/fh-preprep/skills/preprep/oox.py +305 -0
  17. package/plugins/{fh-commons → fh-preprep}/skills/preprep/preprep.py +52 -4
  18. package/plugins/fh-preprep/skills/preprep/safe_install.py +369 -0
  19. package/plugins/{fh-commons → fh-preprep}/skills/preprep/surfaces.example.yaml +29 -0
  20. package/plugins/fh-preprep/skills/preprep/test_preprep_lanes_rp.py +1417 -0
  21. package/plugins/fh-preprep/skills/preprep/usage_ledger.py +191 -0
  22. package/plugins/fh-qp/.claude-plugin/plugin.json +1 -1
  23. package/scripts/fh-run.sh +13 -0
  24. package/scripts/field_canon_preload.sh +1 -1
  25. package/scripts/selfcheck.sh +12 -8
  26. package/scripts/test_fh_run_resolver_lanes.sh +116 -0
  27. package/scripts/test_files_manifest_shipping_lanes.sh +31 -0
  28. package/scripts/test_preprep_adjacent_dup_lanes.sh +1 -1
  29. package/scripts/test_preprep_diagram_lanes.sh +1 -1
  30. package/scripts/test_preprep_drift_anchor.sh +27 -12
  31. package/scripts/test_preprep_drift_anchor_lanes.sh +4 -2
  32. package/scripts/test_preprep_font_lanes.sh +7 -7
  33. package/scripts/test_preprep_progression_lanes.sh +1 -1
  34. package/scripts/test_preprep_promise_lanes.sh +1 -1
  35. package/scripts/test_preprep_retired_lanes.sh +45 -2
  36. package/scripts/test_preprep_slide_refs_lanes.sh +5 -5
  37. package/scripts/test_usage_ledger_lanes.sh +243 -0
  38. package/scripts/usage_report.py +211 -0
  39. package/plugins/fh-commons/skills/preprep/lane_geometry.py +0 -181
  40. package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +0 -184
  41. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/README.md +0 -0
  42. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/canon_terms.example.yaml +0 -0
  43. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/diagram_from_json.py +0 -0
  44. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/adjdup_known_negative.md +0 -0
  45. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/adjdup_known_positive.md +0 -0
  46. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
  47. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
  48. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/font_revert_probe.py +0 -0
  49. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/progression_known_negative.md +0 -0
  50. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/progression_known_positive.md +0 -0
  51. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/promise_known_negative.md +0 -0
  52. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/fixtures/promise_known_positive.md +0 -0
  53. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/interslide_deps.py +0 -0
  54. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/jargon_terms.example.yaml +0 -0
  55. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_adjacent_dup.py +0 -0
  56. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_diagram.py +0 -0
  57. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_font.py +0 -0
  58. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_progression.py +0 -0
  59. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_promise.py +0 -0
  60. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_slide_refs.py +0 -0
  61. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/lane_slide_relations.py +0 -0
  62. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/ooxml/c1_baseline.txt +0 -0
  63. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/ooxml/gate.py +0 -0
  64. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/preprep_wire.py +0 -0
  65. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/presentation_checklist.md +0 -0
  66. /package/plugins/{fh-commons → fh-preprep}/skills/preprep/test_lane_font.py +0 -0
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env python3
2
+ """oox — 세 레인(geometry · attr_consistency · screen_parity)이 공유하는 OOXML 독자.
3
+
4
+ 🟥 왜 있나 (2026-09-11, codex cross-family R1~R6 — 여섯 라운드 연속): 각 레인이 정규식으로 XML 을 읽었고
5
+ 매 라운드 같은 부류가 S/A 를 냈다 — 속성 순서(`<a:off y= x=>`·`<p:sldId r:id= id=>`·`<p:sldSz cy= cx=>`),
6
+ 접두 변이(`<q:sldId xmlns:q=…>`), 여는 태그의 속성(`<p:sp useBgFill>`), 엔티티(`&amp;` vs `&#38;`),
7
+ 중첩 그룹의 변환(flip·이동)이 자식에 안 붙음. 「조이지 말고 줄여라」— 정규식을 지우고 **트리로 읽는다**.
8
+ 태그는 지역명(local-name)으로 맞춘다: 실물의 `p:`/`a:` 도, 레인 픽스처의 가짜 접두(`xmlns:p="p"`)도 같이 읽힌다.
9
+ 그룹은 grpSpPr/xfrm(off·ext·chOff·chExt·flipH/V) 을 **재귀로 합성**해 자식 좌표를 절대 EMU 로 낸다.
10
+
11
+ 이 파일은 정규식을 «안» 쓴다(접두 결박 한 줄 제외). 도형의 «무엇»(이름·좌표·flip·문단·런·선)만 낸다 —
12
+ 판정은 각 레인의 몫이다.
13
+ """
14
+ import re, zipfile, xml.etree.ElementTree as ET
15
+
16
+ _PREFIX_RE = re.compile(r'\b(p|a|r):')
17
+
18
+
19
+ def parse_xml(s):
20
+ """XML 문자열 → 루트. 레인 픽스처처럼 접두가 «선언 없이» 쓰인 문서도 읽는다(루트에 임시 결박)."""
21
+ if isinstance(s, bytes):
22
+ s = s.decode('utf-8', 'replace')
23
+ _NSMAP.clear(); _NSMAP.update({p_: u for p_, u in re.findall(r'xmlns:([\w.-]+)\s*=\s*["\']([^"\']*)["\']', s)})
24
+ m = None
25
+ for m_ in re.finditer(r'<!--.*?-->|<\?.*?\?>|<!\[CDATA\[.*?\]\]>|<([A-Za-z_][\w.-]*:)?[A-Za-z_][\w.-]*\b[^>]*>', s, re.S): # R8 B10: 주석·PI 안의 태그는 루트가 아니다
26
+ if not m_.group(0).startswith(('<!--', '<?', '<![CDATA[')):
27
+ m = m_
28
+ break
29
+ if m:
30
+ head = m.group(0)
31
+ need = [p for p in ('p', 'a', 'r') if not re.search(r'xmlns:%s\s*=' % p, head) and re.search(r'\b%s:' % p, s)] # R7 B8: `xmlns:p = "…"` 도 선언이다
32
+ if need:
33
+ inj = ''.join(' xmlns:%s="urn:fh-unbound-%s"' % (p, p) for p in need)
34
+ s = s[:m.start()] + head[:-1].rstrip('/') + inj + ('/>' if head.endswith('/>') else '>') + s[m.end():]
35
+ return ET.fromstring(s)
36
+
37
+
38
+ def local(tag):
39
+ return tag.rsplit('}', 1)[-1] if '}' in tag else tag.split(':', 1)[-1]
40
+
41
+
42
+ def attr(el, name):
43
+ """지역명으로 속성 찾기 — `r:id` 든 `{ns}id` 든."""
44
+ for k, v in el.attrib.items():
45
+ if local(k) == name:
46
+ return v
47
+ return None
48
+
49
+
50
+ def children(el, name):
51
+ return [c for c in el if local(c.tag) == name]
52
+
53
+
54
+ def child(el, name):
55
+ cs = children(el, name)
56
+ return cs[0] if cs else None
57
+
58
+
59
+ def find_desc(el, name):
60
+ for d in el.iter():
61
+ if d is not el and local(d.tag) == name:
62
+ return d
63
+ return None
64
+
65
+
66
+ # ── 발표 순서 ─────────────────────────────────────────────────────────────
67
+
68
+ def slide_order(z):
69
+ """[슬라이드 파일 번호 …] 발표 순서. 못 푸는 r:id 는 KeyError 로 올라간다(조용히 안 빠진다)."""
70
+ rels = {}
71
+ for rel in parse_xml(z.read('ppt/_rels/presentation.xml.rels')).iter():
72
+ if local(rel.tag) != 'Relationship':
73
+ continue
74
+ tgt = attr(rel, 'Target') or ''
75
+ if tgt.startswith('/'): # R8 B7: OPC 절대 Target
76
+ tgt = tgt[len('/ppt/'):] if tgt.startswith('/ppt/') else tgt.lstrip('/')
77
+ m = re.fullmatch(r'slides/slide(\d+)\.xml', tgt)
78
+ if m and attr(rel, 'Id'):
79
+ rels[attr(rel, 'Id')] = int(m.group(1))
80
+ pres = parse_xml(z.read('ppt/presentation.xml'))
81
+ lst = find_desc(pres, 'sldIdLst')
82
+ out = []
83
+ for sid in (list(lst) if lst is not None else []):
84
+ if local(sid.tag) != 'sldId':
85
+ continue
86
+ rid = attr(sid, 'id') if attr(sid, 'id') and not str(attr(sid, 'id')).isdigit() else None
87
+ # r:id 와 id 가 둘 다 «id» 지역명이다 — 숫자가 아닌 쪽이 관계 id
88
+ for k, v in sid.attrib.items():
89
+ if local(k) == 'id' and not str(v).isdigit():
90
+ rid = v
91
+ out.append(rels[rid])
92
+ return out
93
+
94
+
95
+ def slide_size(z):
96
+ """(cx, cy) EMU. 없으면 None."""
97
+ pres = parse_xml(z.read('ppt/presentation.xml'))
98
+ sz = find_desc(pres, 'sldSz')
99
+ if sz is None or attr(sz, 'cx') is None or attr(sz, 'cy') is None:
100
+ return None
101
+ return int(attr(sz, 'cx')), int(attr(sz, 'cy'))
102
+
103
+
104
+ # ── 변환 합성 ─────────────────────────────────────────────────────────────
105
+
106
+ class _T:
107
+ """그룹 변환의 «사슬». 자식 좌표 → (chOff 빼고 × scale) → 이 그룹 자신의 flip 으로 상자 안에서 반사 → off 더해
108
+ 부모 좌표 → 부모에게 같은 절차. 반사를 **각 단계에서** 하므로 바깥 그룹의 flip 이 안쪽 그룹의 내부 배치까지 거울로
109
+ 옮긴다(R7 A4 — 초판은 표식만 XOR 하고 자리를 안 옮겨 «이동» 이 사라졌다).
110
+ 회전(rot)은 합성하지 않는다 — 사슬 어딘가에 rot 가 있으면 `rot_unmeasured` 로 이름을 남긴다(잔여)."""
111
+ __slots__ = ('parent', 'ox', 'oy', 'sx', 'sy', 'chx', 'chy', 'gcx', 'gcy', 'own', 'flips', 'rot_unmeasured', 'geo_unmeasured')
112
+
113
+ def __init__(self):
114
+ self.parent = None
115
+ self.ox = self.oy = 0; self.sx = self.sy = 1.0; self.chx = self.chy = 0; self.gcx = self.gcy = 0
116
+ self.own = set(); self.flips = set(); self.rot_unmeasured = False; self.geo_unmeasured = False
117
+
118
+ def _apply_f(self, x, y, cx, cy):
119
+ if self.parent is None:
120
+ return (x, y, cx, cy)
121
+ lx, ly = (x - self.chx) * self.sx, (y - self.chy) * self.sy
122
+ w, h = cx * self.sx, cy * self.sy
123
+ if 'H' in self.own:
124
+ lx = self.gcx - lx - w
125
+ if 'V' in self.own:
126
+ ly = self.gcy - ly - h
127
+ return self.parent._apply_f(self.ox + lx, self.oy + ly, w, h)
128
+
129
+ def apply(self, x, y, cx, cy):
130
+ return tuple(int(round(v)) for v in self._apply_f(x, y, cx, cy))
131
+
132
+ def then(self, xfrm):
133
+ """이 변환 «안» 의 그룹 xfrm 을 합성한 새 변환."""
134
+ t = _T()
135
+ t.parent = self
136
+ off, ext, choff, chext = (child(xfrm, n) for n in ('off', 'ext', 'chOff', 'chExt'))
137
+ gx, gy = (int(attr(off, 'x') or 0), int(attr(off, 'y') or 0)) if off is not None else (0, 0)
138
+ gcx, gcy = (int(attr(ext, 'cx') or 0), int(attr(ext, 'cy') or 0)) if ext is not None else (0, 0)
139
+ cx0, cy0 = (int(attr(choff, 'x') or 0), int(attr(choff, 'y') or 0)) if choff is not None else (gx, gy)
140
+ ccx, ccy = (int(attr(chext, 'cx') or 0), int(attr(chext, 'cy') or 0)) if chext is not None else (gcx, gcy)
141
+ t.ox, t.oy = gx, gy # 부모 좌표계 값 그대로 — 부모 변환은 사슬이 통과시킨다
142
+ t.sx = (gcx / ccx) if ccx else 1.0
143
+ t.sy = (gcy / ccy) if ccy else 1.0
144
+ t.chx, t.chy = cx0, cy0
145
+ t.gcx, t.gcy = gcx, gcy # 이 그룹 상자(부모 좌표계 크기) — 자식 반사의 기준
146
+ for k in ('H', 'V'):
147
+ if attr(xfrm, 'flip' + k) in ('1', 'true'):
148
+ t.own.add(k)
149
+ t.flips = set(self.flips) ^ t.own
150
+ t.rot_unmeasured = self.rot_unmeasured or attr(xfrm, 'rot') not in (None, '0')
151
+ # R8 B11: ext 또는 chExt 가 0/부재인 그룹은 배율이 정의되지 않는다 — «안 움직였다» 로 접지 말고 이름으로 남긴다
152
+ t.geo_unmeasured = self.geo_unmeasured or not (gcx and gcy and ccx and ccy)
153
+ return t
154
+
155
+
156
+ def _xfrm_of(el):
157
+ """도형 요소의 xfrm — spPr/grpSpPr/graphicFrame 의 p:xfrm 중 첫 것."""
158
+ for d in el.iter():
159
+ if d is not el and local(d.tag) == 'xfrm':
160
+ return d
161
+ return None
162
+
163
+
164
+ def _text_of(el):
165
+ return ''.join(el.itertext())
166
+
167
+
168
+ # mc:AlternateContent — «우리가 읽는» 네임스페이스. Choice 의 Requires 접두가 전부 여기 결박돼 있으면 그 Choice 를 고르고,
169
+ # 하나라도 모르는 네임스페이스면 Fallback 을 고른다(OOXML Part 3 §8.3.4.2.1 — 이해 못 하는 Choice 는 건너뛴다). R9 A7a.
170
+ KNOWN_NS = {
171
+ 'http://schemas.openxmlformats.org/presentationml/2006/main',
172
+ 'http://schemas.openxmlformats.org/drawingml/2006/main',
173
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
174
+ 'http://schemas.openxmlformats.org/markup-compatibility/2006',
175
+ 'http://schemas.microsoft.com/office/powerpoint/2010/main',
176
+ 'http://schemas.microsoft.com/office/powerpoint/2012/main',
177
+ 'http://schemas.microsoft.com/office/drawing/2010/main',
178
+ 'http://schemas.microsoft.com/office/drawing/2014/main',
179
+ }
180
+ _NSMAP = {} # 마지막으로 parse_xml 한 문서의 접두 → URI (Requires 판정용)
181
+
182
+
183
+ def select_alternate(el):
184
+ """mc:AlternateContent 원소 → 걸을 자식(Choice 또는 Fallback) 또는 None.
185
+ 빈 Choice 라도 «이해하는» Requires 면 그 Choice(=아무것도 안 그림)다 — Element 진리값(길이)으로 고르지 않는다(R8 B6)."""
186
+ for c in el:
187
+ if local(c.tag) != 'Choice':
188
+ continue
189
+ req = (attr(c, 'Requires') or '').split()
190
+ # 접두 p·a·r·mc 는 이 독자가 «이름으로» 읽는 것이라 URI 가 무엇이든 이해한다(레인 픽스처는 가짜 URI 를 쓴다)
191
+ if all(p_ in ('p', 'a', 'r', 'mc') or _NSMAP.get(p_) in KNOWN_NS for p_ in req):
192
+ return c
193
+ # 모르는 네임스페이스를 요구하는 Choice 는 건너뛴다 → 다음 Choice / Fallback
194
+ return child(el, 'Fallback')
195
+
196
+
197
+ def _iter_selected(el):
198
+ """el 의 자손을 문서 순서로 — 단 AlternateContent 는 «선택된 분기» 만 들어간다(R9 A8: 양쪽 문단을 다 세던 구멍)."""
199
+ for c in el:
200
+ if local(c.tag) == 'AlternateContent':
201
+ alt = select_alternate(c)
202
+ if alt is not None:
203
+ yield from _iter_selected(alt)
204
+ continue
205
+ yield c
206
+ yield from _iter_selected(c)
207
+
208
+
209
+ def _paras(el):
210
+ """[{'algn': str|None, 'runs': [(text, sz_pt|None)]}] — 문단 단위, endParaRPr 제외, fld 포함."""
211
+ out = []
212
+ for p in _iter_selected(el):
213
+ if local(p.tag) != 'p':
214
+ continue
215
+ ppr = child(p, 'pPr')
216
+ algn = attr(ppr, 'algn') if ppr is not None else None
217
+ runs = []
218
+ for r in p:
219
+ if local(r.tag) in ('r', 'fld'):
220
+ rpr = child(r, 'rPr')
221
+ sz = int(attr(rpr, 'sz')) / 100 if (rpr is not None and attr(rpr, 'sz')) else None
222
+ t = ''.join(x.text or '' for x in r.iter() if local(x.tag) == 't')
223
+ runs.append((t, sz))
224
+ elif local(r.tag) == 'br':
225
+ runs.append(('\n', None))
226
+ out.append({'algn': algn, 'runs': runs})
227
+ return out
228
+
229
+
230
+ def walk_slide(z, sn):
231
+ """한 슬라이드의 «화면에 놓이는» 도형 전부(그룹은 풀어서, 좌표는 절대 EMU 로 합성).
232
+ 반환: [{'name','kind','x','y','cx','cy','flip','paras','ln_w','dash','in_group'} …] 문서 순서."""
233
+ root = parse_xml(z.read('ppt/slides/slide%d.xml' % sn))
234
+ tree = find_desc(root, 'spTree')
235
+ if tree is None:
236
+ return []
237
+ out = []
238
+ _walk(tree, _T(), out, False)
239
+ return out
240
+
241
+
242
+ def _walk(container, T, out, in_group):
243
+ for el in container:
244
+ kind = local(el.tag)
245
+ if kind == 'AlternateContent': # R7 A5 · R8 B6 · R9 A7a: 선택 규칙은 select_alternate 한 곳
246
+ alt = select_alternate(el)
247
+ if alt is not None:
248
+ _walk(alt, T, out, in_group)
249
+ continue
250
+ if kind == 'grpSp':
251
+ gpr = child(el, 'grpSpPr')
252
+ xf = child(gpr, 'xfrm') if gpr is not None else None
253
+ _walk(el, T.then(xf) if xf is not None else T, out, True)
254
+ continue
255
+ if kind not in ('sp', 'cxnSp', 'pic', 'graphicFrame'):
256
+ continue
257
+ nv = None
258
+ for d in el:
259
+ if local(d.tag).startswith('nv') and local(d.tag).endswith('Pr'):
260
+ nv = d
261
+ cnv = child(nv, 'cNvPr') if nv is not None else None
262
+ name = attr(cnv, 'name') if cnv is not None else None
263
+ xf = _xfrm_of(el)
264
+ geo = None
265
+ if xf is not None:
266
+ off, ext = child(xf, 'off'), child(xf, 'ext')
267
+ if off is not None and ext is not None and all(attr(off, k) is not None for k in ('x', 'y')) and all(attr(ext, k) is not None for k in ('cx', 'cy')):
268
+ geo = T.apply(int(attr(off, 'x')), int(attr(off, 'y')), int(attr(ext, 'cx')), int(attr(ext, 'cy')))
269
+ flips = set(T.flips)
270
+ if xf is not None:
271
+ for k in ('H', 'V'):
272
+ if attr(xf, 'flip' + k) in ('1', 'true'):
273
+ flips ^= {k}
274
+ ln = None
275
+ spr = child(el, 'spPr')
276
+ if spr is not None:
277
+ ln = child(spr, 'ln')
278
+ ln_w = int(attr(ln, 'w')) if (ln is not None and attr(ln, 'w')) else None
279
+ dash_el = child(ln, 'prstDash') if ln is not None else None
280
+ dash = attr(dash_el, 'val') if dash_el is not None else None
281
+ # 문단: txBody 의 것 + 표(graphicFrame) 셀의 것
282
+ paras = []
283
+ tx = child(el, 'txBody')
284
+ if tx is not None:
285
+ paras = _paras(tx)
286
+ elif kind == 'graphicFrame':
287
+ paras = _paras(el)
288
+ out.append(dict(name=name, kind=kind, x=geo[0] if geo else None, y=geo[1] if geo else None,
289
+ cx=geo[2] if geo else None, cy=geo[3] if geo else None,
290
+ flip=''.join(k for k in ('H', 'V') if k in flips), paras=paras,
291
+ ln_w=ln_w, dash=dash, in_group=in_group, rot_unmeasured=T.rot_unmeasured, geo_unmeasured=T.geo_unmeasured))
292
+
293
+
294
+ def para_texts(paras):
295
+ """문단 텍스트 목록(런 '' 로 이어붙임, 공백 정규화, 빈 문단 제외)."""
296
+ res = []
297
+ for p in paras:
298
+ t = re.sub(r'\s+', ' ', ''.join(t for t, _ in p['runs'])).strip()
299
+ if t:
300
+ res.append(t)
301
+ return res
302
+
303
+
304
+ def shape_text(paras):
305
+ return ' '.join(para_texts(paras))
@@ -776,8 +776,9 @@ def main():
776
776
  # [[feedback_rule_misdescribes_its_own_machine]] · [[feedback_instrument_vs_target_and_budget]]
777
777
  _lanes = sorted(n[5:] for n in globals() if n.startswith('lane_') and callable(globals()[n]))
778
778
  _mods = sorted(m for m in ('lane_promise', 'lane_adjacent_dup', 'lane_progression',
779
- 'lane_slide_relations', 'lane_geometry', 'lane_diagram', 'lane_slide_refs',
780
- 'lane_font')
779
+ 'lane_slide_relations', 'lane_geometry', 'lane_attr_consistency',
780
+ 'lane_screen_parity',
781
+ 'lane_diagram', 'lane_slide_refs', 'lane_font')
781
782
  if os.path.exists(os.path.join(HERE, m + '.py')))
782
783
  # 🟥 2026-09-04 신설 — `--lane R1,R2,...` 로 **새 모듈 레인(R1-R5·P1/P3)만** 골라 끈다/켠다.
783
784
  # L1~L11 은 아직 이 필터를 안 탄다(usage 줄의 --lane 은 그쪽엔 미배선인 채 남아 있다 —
@@ -793,7 +794,30 @@ def main():
793
794
  print(f" 레인 {len(_mods)}(모듈): {' · '.join(m[5:] for m in _mods) if _mods else '🟥 없음 — 갈라진 사본일 수 있다'}")
794
795
  cfg_path = sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith('--') \
795
796
  else os.path.join(HERE, 'surfaces.yaml')
796
- cfg = load(cfg_path)
797
+ # 🟥 2026-09-13 — 설정을 못 읽는 것은 «발견» 이 아니라 «판정불가» 다.
798
+ # 종전엔 `load()` 가 그대로 터져 트레이스백 + rc=1 이었고, 이 파일의 선언(SKILL.md:164
799
+ # 「0 통과 · 1 발견 · 2 판정불가(계기오류/미측정)」)에서 **1 은 「발견 있음」**이다.
800
+ # 즉 「아무것도 못 쟀다」가 「뭔가 찾았다」와 **같은 종료코드**로 나갔다 — 부르는 쪽이
801
+ # 둘을 못 가른다. 이 저장소가 이름으로 관리하는 「미측정을 0(또는 남의 값)으로 렌더」의
802
+ # 한 얼굴이다. 그래서 여기서 잡아 **2** 로 확정한다(설정이 없으면 레인은 한 줄도 안 돌았다).
803
+ try:
804
+ cfg = load(cfg_path)
805
+ except FileNotFoundError:
806
+ print(f"\n{ANSI_BAD} 설정을 못 찾았다: {cfg_path}")
807
+ print(f" 이 디렉터리에 surfaces.example.yaml 이 있다 — 복사해서 surfaces.yaml 로 쓰거나,")
808
+ print(f" 설정 경로를 첫 인자로 줘라: python3 preprep.py <설정.yaml>")
809
+ print(f" ⚠️ 이것은 «발견 0» 이 아니라 «한 줄도 안 쟀다» 다 (exit 2 = 판정불가).")
810
+ return 2
811
+ except Exception as e:
812
+ print(f"\n{ANSI_BAD} 설정을 읽을 수 없다: {cfg_path}\n {type(e).__name__}: {e}")
813
+ print(f" ⚠️ «발견 0» 이 아니라 «한 줄도 안 쟀다» 다 (exit 2 = 판정불가).")
814
+ return 2
815
+ if not isinstance(cfg, dict) or 'root' not in cfg or 'surfaces' not in cfg:
816
+ # 파싱은 됐는데 계약이 아니다 — 빈 파일(`yaml.safe_load` → None)이 여기로 온다.
817
+ # 이것도 「돌았는데 발견 0」이 아니라 「돌 수 없었다」다.
818
+ print(f"\n{ANSI_BAD} 설정에 필수 키가 없다: {cfg_path} (root · surfaces)")
819
+ print(f" ⚠️ «발견 0» 이 아니라 «한 줄도 안 쟀다» 다 (exit 2 = 판정불가).")
820
+ return 2
797
821
  root = os.path.expanduser(cfg['root'])
798
822
  surf = {s['id']: s for s in cfg['surfaces']}
799
823
 
@@ -899,6 +923,21 @@ def main():
899
923
  except Exception as _e:
900
924
  notes.append('P1/P3 geometry : 계기 미실행 — NOT_WIRED (%s: %s) (0 아님)'
901
925
  % (type(_e).__name__, _e))
926
+ try:
927
+ import lane_screen_parity
928
+ _f14, _n14 = lane_screen_parity.scan(cfg, root)
929
+ notes += _n14 # advisory 고정 — L8·L11 관례
930
+ except Exception as _e:
931
+ notes.append('L14 screen-parity : 계기 미실행 — NOT_WIRED (%s: %s) (0 아님)'
932
+ % (type(_e).__name__, _e))
933
+ if _lane_on('P4', 'P'):
934
+ try:
935
+ import lane_attr_consistency
936
+ _f4, _n4 = lane_attr_consistency.scan(cfg, root)
937
+ notes += _n4 # advisory 고정 — 같은 관례
938
+ except Exception as _e:
939
+ notes.append('P4 attr-consistency : 계기 미실행 — NOT_WIRED (%s: %s) (0 아님)'
940
+ % (type(_e).__name__, _e))
902
941
 
903
942
  for n in notes: print(f" · {n}")
904
943
  use = [f for f in findings if f[1] != 'mention']
@@ -922,4 +961,13 @@ def main():
922
961
  # 못 쓴다(레인 하나만 부르려 해도 전체가 돌고 종료한다). 배포본이 된 뒤에 드러난 결함이다:
923
962
  # 스킬 문서가 «레인을 골라 부를 수 있다»는 인상을 주는데 실제로는 CLI 한 형태뿐이었다.
924
963
  if __name__ == '__main__':
925
- sys.exit(main())
964
+ # ── 사용 원장 (2026-09-13) — 🟥 **옵트인**이다. `FH_USAGE_LEDGER` 가 없으면 완전 무동작이고
965
+ # 배포본에서 아무것도 안 남긴다(동의 없는 계측 금지 · 부재 ≠ 승인).
966
+ # 🟥 **판정을 안 바꾼다**: `observe` 는 `main()` 의 반환값을 그대로 통과시키고, 원장 쓰기가
967
+ # 실패해도 예외를 여기로 올리지 않는다. 관측이 대상을 바꾸면 그 숫자는 의미가 없다.
968
+ # 모듈이 없어도(부분 설치) 종전과 같이 돈다 — 그게 이 try 의 유일한 목적이다.
969
+ try:
970
+ from usage_ledger import observe as _observe
971
+ except Exception:
972
+ _observe = None
973
+ sys.exit(_observe('preprep', 'cli', main) if _observe else main())