genetics-mcp 2.26.0 → 2.27.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 (34) hide show
  1. package/bin/aarch64-apple-darwin/gcell +0 -0
  2. package/bin/aarch64-unknown-linux-musl/gcell +0 -0
  3. package/bin/manifest.json +5 -5
  4. package/bin/x86_64-apple-darwin/gcell +0 -0
  5. package/bin/x86_64-pc-windows-gnu/gcell.exe +0 -0
  6. package/bin/x86_64-unknown-linux-musl/gcell +0 -0
  7. package/genetics.mjs +1527 -191
  8. package/package.json +1 -1
  9. package/skills/gazette-report/SKILL.md +11 -1
  10. package/skills/gazette-report/scripts/tone.mjs +30 -3
  11. package/skills/gazette-report/scripts/tone.test.mjs +31 -0
  12. package/skills/genetics-cell-id/SKILL.md +73 -54
  13. package/skills/genetics-define/SKILL.md +34 -1
  14. package/skills/genetics-report/SKILL.md +67 -69
  15. package/skills/genetics-report/assets/example/cases-briefing-designs.json +15 -5
  16. package/skills/genetics-report/assets/example/cases-briefing.json +14 -5
  17. package/skills/genetics-report/assets/example/cases.json +25 -9
  18. package/skills/genetics-report/assets/example/decision.json +313 -0
  19. package/skills/genetics-report/assets/example/report-briefing-designs.json +2 -2
  20. package/skills/genetics-report/assets/example/report-briefing.json +1 -1
  21. package/skills/genetics-report/assets/example/report.json +65 -21
  22. package/skills/genetics-report/assets/render.py +204 -49
  23. package/skills/genetics-report/references/content-pipeline.md +6 -6
  24. package/skills/genetics-report/references/decision-format.md +112 -0
  25. package/skills/genetics-report/references/diagrams.md +52 -94
  26. package/skills/genetics-report/references/edition-review.workflow.js +81 -31
  27. package/skills/genetics-report/references/history.md +35 -0
  28. package/skills/genetics-report/references/lim-portal-format.md +92 -236
  29. package/skills/genetics-report/references/linear-read-test.workflow.js +33 -10
  30. package/skills/genetics-report/references/process.md +32 -25
  31. package/skills/genetics-report/references/readability.md +9 -8
  32. package/skills/genetics-report/references/reader-review.workflow.js +167 -25
  33. package/skills/genetics-report/references/visual-first.md +54 -42
  34. package/skills/genetics-report/assets/diagram.mjs +0 -208
@@ -25,11 +25,13 @@ cover-frame·ch-head·cindex·eof-endcard·Spoqa 가 전부 0회로 나갔다.
25
25
 
26
26
  ## 쓰는 법
27
27
 
28
- python3 render.py report.json # 기본 서식
29
- python3 render.py report.json --template lim-report # 브랜드 서식
30
- python3 render.py report.json --text /빈/폴더/본문.txt # + 화면에 보이는 글만 텍스트로(독자 검토용)
28
+ python3 render.py report.json # 원고의 template 칸, 없으면 lim-report(관보 — 서식은 이것 하나)
29
+ python3 render.py decision.json # 결정 블록(decision_brief.decisions)이 있으면 같은 서식의 결정 모드
30
+ python3 render.py report.json --template lim-report # 서식을 명령에서 고른다(원고 칸보다 이긴다 · 옛 이름 lim-decision 도 받는다)
31
+ python3 render.py report.json --text /빈/폴더/본문.txt # + 화면에 보이는 글만 텍스트로(독자 검토용) · 그림 자리에 도판 id·갈래, 끝에 그림 파일 목록
31
32
  python3 render.py report.json --tenant <slug> # 그 소속처가 반려한 이름으로도 센다(없으면 원고의 tenant)
32
33
  python3 render.py report.rev9.json # get_report 로 받은 원고 그대로 — report.rev9.html, 보관 그림은 자리 상자
34
+ GAZETTE_ARCHIFY=0 python3 render.py report.json # 도해 굽기를 끈 서버처럼(기본은 켬 — 저장소 고정 판 · Node 로 굽는다)
33
35
 
34
36
  레포 위치는 `GENETICS_REPO` 로 덮어쓸 수 있다.
35
37
 
@@ -49,6 +51,37 @@ cover-frame·ch-head·cindex·eof-endcard·Spoqa 가 전부 0회로 나갔다.
49
51
  같은 옆 파일 `mcp-call.mjs` 로 `list_rejected_names` 를 불러 받아 얹는다(발행과 같은 목록 · 같은 대조 규칙).
50
52
  못 받으면 렌더는 그대로 하고 「세지 않았다」고 한 줄 말한다 — 조용히 넘기면 「반려 이름 0」으로 읽힌다.
51
53
  원고에 `rejected_names` 가 적혀 있어도 쓰지 않는다(포털 입구도 버린다 — 두 경로가 같아야 한다).
54
+
55
+ ## 그림 자리 표지 (2026-10-01)
56
+
57
+ 독자 대리가 「[그림]」 글자만 받아 그 그림이 실제 화면인지 우리가 그린 것인지 몰랐고, 그림을 열 길도 없었다 —
58
+ 그래서 증거 없는 화면 주장을 일반 지식으로 메우고 지나갔다(관보 합의 착각 수리). 이제 그림 자리는
59
+ 「[그림] <도판 id> · <갈래>」(갈래: 캡처·녹화·도안·합성·문서 — 렌더러의 판정 `gazette.py asset_evidence_kind()`를
60
+ 그대로 부른다), 글 끝에 「[그림 파일]」 목록이 붙는다.
61
+ 도안은 설명이지 증거가 아니다(2026-10-01 사용자 결정).
62
+
63
+ ## 서식이 둘이 됐다 (2026-10-06 관보 버전2)
64
+
65
+ 전에는 서버 서식 표에서 이름은 다 읽으면서 css·폰트·`render` 는 `'lim-report'` 블록에서만 뽑았고, 설정에 얹는 것도
66
+ `--template lim-report` 일 때뿐이었다. 그래서 `lim-decision` 으로 부르면 `render` 없는 설정이 `build.py` 의 옛
67
+ 「수정 내역 보고」 경로로 떨어졌다(설계안 A-4). 이제 블록을 **서식 이름마다** 뽑고, 서식은 `--template` → 원고의
68
+ `template` 칸 → `lim-report` 차례로 고른다. 서버 발행은 `template` 을 발행 요청의 칸으로 받으므로, 로컬 원고는 그 칸을
69
+ 파일에 적어 두면 두 경로가 같은 서식으로 굽는다.
70
+
71
+ ## 서식이 다시 하나가 됐다 (2026-10-07 관보 v3)
72
+
73
+ 서식은 `lim-report` 하나이고, 보고 모드 · 결정 모드는 **원고 내용**이 고른다 — 결정 블록(`decision_brief.decisions` 가 비지 않은
74
+ 목록)이 있으면 결정 모드다. 그 판정은 렌더러(`build.py report_mode()`)가 하고 결정 모드의 덧 스타일시트도 렌더러가 더한다.
75
+ 그래서 여기서 할 일은 둘뿐이다. 옛 이름 `lim-decision` 을 서버의 옛 이름 표(`REPORT_TEMPLATE_ALIASES`)로 지금 이름에 맞추고,
76
+ 서식 블록의 `css` 는 `'css' => [...]` 칸에서만 읽는다(이제 `reads` 에도 `decision.css` 가 있다 — 블록 전체에서 `.css` 를 긁으면
77
+ 보고 모드 문서에도 결정 시트가 실린다).
78
+
79
+ ## 도해는 여기서 굽는다 (2026-10-07 관보 v3 ⑦)
80
+
81
+ 도해(`diagrams[]` 설계 데이터)는 렌더러가 저장소의 고정 판 Archify 로 굽는다 — 굽기 스위치(`GAZETTE_ARCHIFY=1`)가 켜진 렌더만
82
+ Node 를 불러 새로 굽고, 꺼지면 같은 번호의 대기 상자 + ⚠ 둘이다. 로컬 렌더는 스위치를 **켜고** 부른다(호출한 쪽이 값을 주면 그 값) —
83
+ 꺼진 채 부르면 멀쩡한 도해마다 「대기 상자」 ⚠ 가 서고, 「⚠0」을 맞추려는 작성자가 도해를 뺐다(관보 v3 의 원인 조사).
84
+ 서버가 굽는지는 그 서버의 dry_run 이 말한다(꺼진 서버는 「서버에서 실패한 그림이 N장」 — 원고를 고칠 일이 아니다).
52
85
  """
53
86
  import argparse
54
87
  import base64
@@ -66,7 +99,7 @@ DEFAULT_REPO = pathlib.Path.home() / 'project/FCG/report-portal'
66
99
 
67
100
  # 관보 전환(2026-08-24) 뒤로는 gazette 계열이 렌더러의 본체다 — 그 사본도 막는다.
68
101
  # 옛 서식 파일도 목록에 남긴다: 다시 생기면 그것도 「사본이 갈라진다」의 재발이다.
69
- FORKABLE = ('build.py', 'gazette.py', 'gazette.css', 'sentbreak.py', 'boundary.py',
102
+ FORKABLE = ('build.py', 'gazette.py', 'gazette.css', 'decision.py', 'decision.css', 'sentbreak.py', 'boundary.py',
70
103
  'report.css', 'lim-report.css', 'palette.py', 'editor.js', 'editor.css', 'fonts')
71
104
 
72
105
 
@@ -122,35 +155,56 @@ def templates_from_server(repo: pathlib.Path) -> dict:
122
155
  if not block:
123
156
  sys.exit('REPORT_TEMPLATES 를 못 읽었습니다 — report_publish.php 모양이 바뀌었습니다.')
124
157
  body = block.group(1)
125
- out = {}
126
- for name in re.findall(r"^\s{4}'([a-z0-9-]+)'\s*=>", body, re.M):
127
- out[name] = None
128
- if 'lim-report' not in out:
158
+ names = re.findall(r"^\s{4}'([a-z0-9-]+)'\s*=>", body, re.M)
159
+ if 'lim-report' not in names:
129
160
  sys.exit("서버에 'lim-report' 서식이 없습니다.")
130
- # css 목록과 폰트면은 값이 필요하므로 따로 뽑는다.
131
- lim = re.search(r"'lim-report'\s*=>\s*\[(.*?)\n \],", body, re.S)
132
- css = re.findall(r"'([\w.-]+\.css)'", lim.group(1)) if lim else []
133
- family = re.search(r"'family'\s*=>\s*'([^']+)'", lim.group(1)) if lim else None
161
+ out = {'names': names, 'aliases': _template_aliases(src)}
162
+ for name in names:
163
+ out[name] = _template_block(body, name)
164
+ return out
165
+
166
+
167
+ def _template_aliases(src: str) -> dict:
168
+ """옛 서식 이름 → 지금 이름 — 서버 `REPORT_TEMPLATE_ALIASES`(관보 v3 — `'lim-decision' => 'lim-report'`)를 읽는다.
169
+
170
+ 표가 없는 판(관보 v3 전 서버)이면 빈 표 — 그때는 서식 이름을 등록부 이름으로만 받는다(지금까지와 같다).
171
+ """
172
+ blk = re.search(r"const REPORT_TEMPLATE_ALIASES = \[(.*?)\n\];", src, re.S)
173
+ if not blk:
174
+ return {}
175
+ return dict(re.findall(r"^\s*'([a-z0-9-]+)'\s*=>\s*'([a-z0-9-]+)'", blk.group(1), re.M))
176
+
177
+
178
+ def _template_block(body: str, name: str) -> dict:
179
+ """서식 한 벌의 css 목록·폰트면·render 를 뽑는다 — `templates_from_server()` 가 서식 이름마다 부른다.
180
+
181
+ 블록의 끝은 네 칸 들여 쓴 `],` 다(서식 항목의 닫는 줄). 그 안의 목록(`reads`·`keys`)은 더 깊이 들여 쓰므로
182
+ 거기서 끊기지 않는다. `reads`·`keys` 는 뽑지 않는다 — 렌더러 설정이 아니다(서버도 `keys` 를 설정에 안 섞는다).
183
+ """
184
+ blk = re.search(r"^\s{4}'%s'\s*=>\s*\[(.*?)\n \],?" % re.escape(name), body, re.S | re.M)
185
+ src = blk.group(1) if blk else ''
186
+ # css 는 `'css' => [...]` 칸에서만 — 블록 전체에서 긁으면 `reads` 의 decision.css(관보 v3 — 결정 모드 시트는 렌더러가
187
+ # 결정 모드에서만 더한다)까지 보고 모드 문서에 실린다.
188
+ _css = re.search(r"'css'\s*=>\s*\[([^\]]*)\]", src)
189
+ css = re.findall(r"'([\w.-]+\.css)'", _css.group(1)) if _css else []
190
+ family = re.search(r"'family'\s*=>\s*'([^']+)'", src)
134
191
  # ⚠ 굵기는 **범위**일 수 있다(가변 폰트: `'300 700'`). `(\d+)` 로만 받으면 공백에서
135
192
  # 끊겨 정규식이 통째로 안 맞고, 그러면 faces 가 비어 **폰트가 안 실린 문서**가 나간다.
136
193
  # 육안으로는 「글꼴이 좀 다르네」로만 보이는 종류의 사고다(2026-08-24 에 실제로 밟았다).
137
- faces = re.findall(r"'file'\s*=>\s*'([^']+)'.*?'weight'\s*=>\s*'([\d ]+)'", lim.group(1)) if lim else []
194
+ faces = re.findall(r"'file'\s*=>\s*'([^']+)'.*?'weight'\s*=>\s*'([\d ]+)'", src)
138
195
  # 라이선스 문구는 PHP 에서 `. '…'` 로 여러 줄에 걸쳐 이어진다. 첫 조각만 받으면
139
196
  # 「… SIL Open Font License 1.1 ·」 에서 잘려 **출처 URL 이 없는 고지**가 나간다.
140
- _lic = re.search(r"'license'\s*=>\s*((?:'[^']*'\s*\.?\s*)+)", lim.group(1)) if lim else None
197
+ _lic = re.search(r"'license'\s*=>\s*((?:'[^']*'\s*\.?\s*)+)", src)
141
198
  lic = ''.join(re.findall(r"'([^']*)'", _lic.group(1))) if _lic else ''
142
199
  # 렌더 경로도 서식이 정한다 — 안 실어 오면 관보 서식 문서가 옛 렌더러로 구워진다.
143
- render = re.search(r"'render'\s*=>\s*'([a-z-]+)'", lim.group(1)) if lim else None
200
+ render = re.search(r"'render'\s*=>\s*'([a-z-]+)'", src)
144
201
  return {
145
- 'names': list(out),
146
- 'lim-report': {
147
- 'css': css,
148
- **({'render': render.group(1)} if render else {}),
149
- 'font': {
150
- 'family': family.group(1) if family else '',
151
- 'license': lic,
152
- 'faces': [{'file': f, 'weight': w} for f, w in faces],
153
- },
202
+ 'css': css,
203
+ **({'render': render.group(1)} if render else {}),
204
+ 'font': {
205
+ 'family': family.group(1) if family else '',
206
+ 'license': lic,
207
+ 'faces': [{'file': f, 'weight': w} for f, w in faces],
154
208
  },
155
209
  }
156
210
 
@@ -164,7 +218,8 @@ def templates_from_server(repo: pathlib.Path) -> dict:
164
218
  _TEXT_SKIP = {'head', 'script', 'style', 'template', 'noscript', 'title', 'desc'}
165
219
  # 머리띠 이동줄·진행 막대·측면 색인·「본문 바로가기」는 차례와 절 이름을 한 번 더 늘어놓는 화면 장치라 인쇄본도
166
220
  # 지운다(gazette.css @media print 「화면 전용 크롬」). 그 목록의 현황 줄(notice)은 요약면 본문이라 남긴다.
167
- _TEXT_CHROME = {'topbar', 'progress', 'rail', 'skip'}
221
+ # 결정 브리핑(lim-decision)의 1280px 이상 오른쪽 결정 이름 목록(`nav.dec-rail`)도 같은 화면 장치다(decision.css 가 인쇄에서 숨긴다).
222
+ _TEXT_CHROME = {'topbar', 'progress', 'rail', 'skip', 'dec-rail'}
168
223
  _TEXT_BLOCK = {'address', 'article', 'aside', 'blockquote', 'caption', 'dd', 'div', 'dl', 'dt',
169
224
  'figcaption', 'figure', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
170
225
  'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'tbody', 'tfoot',
@@ -176,37 +231,59 @@ _TEXT_VOID = {'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link'
176
231
  class _VisibleText(HTMLParser):
177
232
  """렌더된 HTML 에서 화면에 보이는 글만 문서 순서대로 모은다.
178
233
 
179
- 그림·영상은 자리 표시(「[그림]」「[영상]」) 한 줄만 남긴다 — 그림 안의 글자는 텍스트로 옮기지 못한다.
234
+ 그림·영상은 자리 표시 한 줄만 남긴다 — 「[그림] <도판 id> · <갈래>」「[영상] <id> · <갈래>」(갈래는 kinds 에서).
235
+ 그림 안의 글자는 텍스트로 옮기지 못한다. 도해(본문에 박힌 SVG)는 「[그림] <id> · 도안」 뒤에 그 글자가 이어지고,
236
+ 표지 고객사 마크는 「[로고]」다(도판이 아니다). 그림 자리에 선 도판 id 는 순서대로 seen 에 모인다(끝의 그림 파일 목록).
180
237
  표 칸은 「 | 」로 잇고 행마다 줄을 바꾼다.
181
238
  클래스가 붙은 인라인 요소와 span·label·i 는 화면에서 따로 서는 조각(문장 조각 `sn`, 차례의 절 번호·제목·건수)이라
182
239
  경계에서 한 칸 띄운다. 클래스 없는 b·strong·em·a 는 낱말에 붙는 강조·링크라 띄우지 않는다(「**1**건」은 「1건」).
183
240
  """
184
241
 
185
- def __init__(self):
242
+ def __init__(self, kinds=None):
186
243
  super().__init__(convert_charrefs=True)
187
244
  self.parts = []
188
- self.open = [] # (태그, 숨김, 경계 띄움) — 닫는 태그를 짝지어 숨김 깊이를 되돌린다
245
+ self.open = [] # (태그, 숨김, 경계 띄움, 도판 id) — 닫는 태그를 짝지어 숨김 깊이를 되돌린다
189
246
  self.hidden = 0
190
247
  self.gap = False
248
+ self.kinds = kinds or {} # 도판 id → 갈래
249
+ self.seen = [] # 그림 자리에 선 도판 id(처음 선 순서)
250
+
251
+ def _plate_id(self):
252
+ """지금 열려 있는 도판 figure 의 id(data-uid) — 렌더러가 도판·영상·도해 figure 에 assets[].id·도해 id 를 단다."""
253
+ for t, _h, _s, uid in reversed(self.open):
254
+ if t == 'figure':
255
+ return uid
256
+ return ''
257
+
258
+ def _mark(self, what, uid, kind=None):
259
+ tail = ' · '.join(x for x in (uid, kind or self.kinds.get(uid, '')) if x)
260
+ self.parts.append('\n[%s]%s\n' % (what, ' ' + tail if tail else ''))
261
+ if uid and kind is None and uid not in self.seen:
262
+ self.seen.append(uid)
191
263
 
192
264
  def handle_starttag(self, tag, attrs):
193
265
  a = dict(attrs)
266
+ cls = set((a.get('class') or '').split())
194
267
  if tag in _TEXT_VOID:
195
268
  if not self.hidden and tag in ('br', 'hr'):
196
269
  self.parts.append('\n')
197
270
  elif not self.hidden and tag == 'img':
198
- self.parts.append('\n[그림]\n')
271
+ if 'cover__logo' in cls:
272
+ self.parts.append('\n[로고]\n')
273
+ else:
274
+ self._mark('그림', self._plate_id())
199
275
  return
200
- hide = (tag in _TEXT_SKIP or tag == 'video' or 'hidden' in a
201
- or bool(_TEXT_CHROME & set((a.get('class') or '').split())))
276
+ hide = (tag in _TEXT_SKIP or tag == 'video' or 'hidden' in a or bool(_TEXT_CHROME & cls))
202
277
  if tag == 'video' and not self.hidden:
203
- self.parts.append('\n[영상]\n') # 안의 대체 글은 재생기가 있는 화면에서 안 보인다
278
+ self._mark('영상', self._plate_id()) # 안의 대체 글은 재생기가 있는 화면에서 안 보인다
204
279
  sep = tag not in _TEXT_BLOCK and (tag in ('span', 'label', 'i') or 'class' in a)
205
- self.open.append((tag, hide, sep))
280
+ self.open.append((tag, hide, sep, (a.get('data-uid') or '') if tag == 'figure' else ''))
206
281
  if hide:
207
282
  self.hidden += 1
208
283
  elif not self.hidden and tag in _TEXT_BLOCK:
209
284
  self.parts.append('\n')
285
+ if tag == 'figure' and 'diagram' in cls:
286
+ self._mark('그림', a.get('data-uid') or '', '도안') # 도해는 우리가 그린 그림이다 — 갈래는 늘 도안
210
287
  elif not self.hidden and sep:
211
288
  self.gap = True
212
289
 
@@ -214,7 +291,7 @@ class _VisibleText(HTMLParser):
214
291
  for i in range(len(self.open) - 1, -1, -1):
215
292
  if self.open[i][0] == tag:
216
293
  sep = self.open[i][2]
217
- self.hidden -= sum(1 for _, h, _s in self.open[i:] if h)
294
+ self.hidden -= sum(1 for x in self.open[i:] if x[1])
218
295
  del self.open[i:]
219
296
  break
220
297
  else:
@@ -240,15 +317,74 @@ class _VisibleText(HTMLParser):
240
317
  self.parts.append(data)
241
318
 
242
319
 
243
- def visible_text(html: str) -> str:
244
- """렌더본 HTML → 독자용 본문 텍스트. 빈 줄은 하나로 접고 줄 끝 칸 구분자는 걷는다."""
245
- p = _VisibleText()
320
+ def visible_text(html: str, kinds=None, seen=None) -> str:
321
+ """렌더본 HTML → 독자용 본문 텍스트. 빈 줄은 하나로 접고 줄 끝 칸 구분자는 걷는다.
322
+
323
+ kinds 는 도판 id → 갈래(그림 자리 표지에 찍는다). seen 에 목록을 주면 그림 자리에 선 도판 id 를 순서대로 채운다.
324
+ """
325
+ p = _VisibleText(kinds)
246
326
  p.feed(html)
247
327
  p.close()
328
+ if seen is not None:
329
+ seen.extend(p.seen)
248
330
  lines = [re.sub(r'(?:\s*\|)+$', '', ln.strip()).strip() for ln in ''.join(p.parts).split('\n')]
249
331
  return re.sub(r'\n{3,}', '\n\n', '\n'.join(lines)).strip() + '\n'
250
332
 
251
333
 
334
+ # ── 도판 갈래 · 그림 파일 목록(--text) ──
335
+ # 갈래(캡처·녹화·도안·합성·문서)는 렌더러의 판정을 그대로 쓴다 — 「화면을 말했는데 독자가 볼 증거가 없는 곳」 경고가 쓰는 그 판정이다
336
+ # (gazette.py asset_evidence_kind, 관보 합의 착각 수리 C10). 여기 같은 규칙을 베껴 두면 그것이 다음 사본이다(머리 주석 「규칙」).
337
+ _MIME_EXT = {'image/png': 'png', 'image/webp': 'webp', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/svg+xml': 'svg'}
338
+
339
+
340
+ def renderer_kind(builder: pathlib.Path):
341
+ """렌더러의 도판 갈래 판정 함수 — 렌더러가 그 판정을 모르는 옛 판이거나 못 읽으면 None.
342
+
343
+ --text 일 때만 읽는다 — 모듈로 불러 kept_stand_ins 만 쓰는 쪽(시험)이 렌더러를 읽지 않게.
344
+ """
345
+ here = str(builder.parent)
346
+ if here not in sys.path:
347
+ sys.path.insert(0, here)
348
+ try:
349
+ import gazette
350
+ except Exception: # 렌더러를 못 읽으면 갈래 없이 찍는다(부르는 쪽이 ⚠ 한 줄)
351
+ return None
352
+ return getattr(gazette, 'asset_evidence_kind', None)
353
+
354
+
355
+ def plate_files(cfg: dict, seen: list, kept: list, kinds: dict, out: pathlib.Path) -> list:
356
+ """글 끝 「[그림 파일]」 목록의 줄들 — 독자 대리가 본문 그림을 열 수 있게 한다.
357
+
358
+ 그림 자리에 선 도판을 그 순서대로 적는다. 렌더본에 실린 바이트(data:)는 텍스트 옆 폴더(<이름>-그림/)에 풀어
359
+ 그 경로를 적는다 — 독자 대리가 원고 폴더를 열지 않아도 된다. 보관 그림(get_report 표식)·포털 영상은 이 기계에
360
+ 바이트가 없다고 적는다. 원고에 있는데 그림 자리에 안 선 도판(로컬 렌더에 바이트가 없어 빠진 file·source 그림 등)은
361
+ 뒤에 따로 적는다 — 캡션은 싣지 않는다(본문에 없는 글이다).
362
+ """
363
+ plates = {str(a.get('id', '')): a for a in cfg.get('assets') or []
364
+ if isinstance(a, dict) and a.get('role') in ('figure', 'video')}
365
+ pics = out.parent / (out.stem + '-그림')
366
+
367
+ def line(pid, a):
368
+ head = '%s %s · ' % ('영상' if a.get('role') == 'video' else '그림', ' · '.join(x for x in (pid, kinds.get(pid, '')) if x))
369
+ if pid in kept:
370
+ return head + '파일 없음 — 서버 보관 그림(이 기계에 바이트 없음)'
371
+ m = re.match(r'data:(image/[\w.+-]+);base64,([A-Za-z0-9+/=\s]+)\Z', str(a.get('data') or ''))
372
+ if m:
373
+ p = pics / ('%s.%s' % (re.sub(r'[^\w.-]', '_', pid), _MIME_EXT.get(m.group(1), 'img')))
374
+ pics.mkdir(parents=True, exist_ok=True)
375
+ p.write_bytes(base64.b64decode(m.group(2)))
376
+ return head + str(p)
377
+ f = str(a.get('file') or '')
378
+ if f:
379
+ return head + f + ('' if pathlib.Path(f).expanduser().is_file() else '(이 기계에 없음)')
380
+ return head + ('파일 없음 — 포털 보관 영상' if a.get('role') == 'video' else '파일 없음')
381
+
382
+ shown = [line(pid, plates[pid]) for pid in seen if pid in plates]
383
+ rest = [line(pid, a) for pid, a in plates.items() if pid not in seen]
384
+ return ((['[그림 파일] 검토용 목록 — 독자 화면에 없음. 위 그림 자리 순서'] + shown) if shown else []) + \
385
+ ((['[본문에 안 선 도판] 원고에 있으나 이 렌더에는 그림 자리가 없다(로컬 렌더에 바이트가 없는 file·source 그림 등) — 본문 줄이 아니다'] + rest) if rest else [])
386
+
387
+
252
388
  # ── 소속처가 반려한 이름(--tenant) ──
253
389
  REJECTED_KEY = 'rejected_names' # 렌더러 cfg 의 칸(gazette.py Doc.REJECTED · report_publish.php REPORT_REJECTED_KEY 와 같은 낱말)
254
390
  REJECTED_TIMEOUT = 20 # 초 — MCP 서버를 띄워 창구 한 번을 부르는 값. 넘으면 못 받은 것으로 친다
@@ -319,13 +455,16 @@ def main() -> None:
319
455
  ap = argparse.ArgumentParser(
320
456
  description='단일 HTML 렌더 — 서버 서식을 읽어 관보로 굽는다 (레포 렌더러 호출)')
321
457
  ap.add_argument('config', help='report.json')
322
- # 기본은 브랜드 서식이다 — 서버의 REPORT_TEMPLATE_DEFAULT 와 같은 값으로 맞춘다.
458
+ # 기본값은 None — 명령에서 안 고르면 원고의 template 칸, 그것도 없으면 lim-report(서버 REPORT_TEMPLATE_DEFAULT 와 같은 값).
323
459
  # 어긋나면 「스킬로 만든 문서」와 「MCP 로 올린 문서」의 서식이 갈린다.
324
- ap.add_argument('--template', default='lim-report',
325
- help='lim-report(기본·관보 서식). 서버 목록에 있는 이름만 받는다')
460
+ ap.add_argument('--template', default=None,
461
+ help='서식 이름 — 주면 원고의 template 칸보다 이긴다. 안 주면 원고의 template, 그것도 없으면 lim-report(관보). '
462
+ '서식은 lim-report 하나 — 결정 블록(decision_brief.decisions)이 있으면 결정 모드로 굽는다. '
463
+ '옛 이름 lim-decision 도 받는다(서버의 옛 이름 표로 맞춘다). 서버 목록에 있는 이름만 받는다')
326
464
  # 독자 검토의 「처음 읽는 사람」·「선형 읽기」 관점은 이 파일 하나만 받는다(visible_text()).
327
465
  ap.add_argument('--text', metavar='출력.txt',
328
- help='렌더본에서 화면에 보이는 글만 뽑아 이 파일에 쓴다. 원고 폴더 밖 빈 폴더에 둔다')
466
+ help='렌더본에서 화면에 보이는 글만 뽑아 이 파일에 쓴다. 원고 폴더 밖 빈 폴더에 둔다 — '
467
+ '그림 자리는 「[그림] <id> · <갈래>」, 끝에 그림 파일 목록, 실린 그림 바이트는 옆 <이름>-그림/ 에 푼다')
329
468
  ap.add_argument('--tenant', metavar='slug',
330
469
  help='이 소속처가 반려한 이름으로도 센다(MCP list_rejected_names). 없으면 원고의 tenant 를 쓴다')
331
470
  args = ap.parse_args()
@@ -338,12 +477,17 @@ def main() -> None:
338
477
 
339
478
  # 서식 이름은 **서버가 가진 목록**으로만 검사한다 — 여기 사본을 두면 둘이 갈라진다.
340
479
  t = templates_from_server(repo)
341
- if args.template not in t['names']:
342
- sys.exit(f"모르는 서식입니다: {args.template} (있는 것: {'|'.join(t['names'])})")
480
+ asked = args.template or str(cfg.get('template') or '').strip() or 'lim-report'
481
+ # 옛 이름(관보 버전2 의 lim-decision)은 서버의 옛 이름 표로 지금 이름에 맞춘다 — 모드는 원고의 결정 블록이 정한다(build.py).
482
+ template = t['aliases'].get(asked, asked)
483
+ if template not in t['names']:
484
+ sys.exit(f"모르는 서식입니다: {asked} (있는 것: {'|'.join(t['names'])}"
485
+ + (f" · 옛 이름 {'·'.join(t['aliases'])}" if t['aliases'] else '') + ')')
486
+ if template != asked:
487
+ print(f'서식 이름 {asked} 는 옛 이름입니다 — {template} 로 굽습니다(결정 모드는 원고의 decision_brief.decisions 가 정합니다)', flush=True)
343
488
  # 서식이 render·css·폰트를 전부 정한다. 여기서 고를 것이 없다 —
344
489
  # 관보는 팔레트 토큰을 안 쓴다(report_publish.php 가 palette.py 를 지문에서 뺐다).
345
- if args.template == 'lim-report':
346
- cfg.update(t['lim-report'])
490
+ cfg.update(t[template])
347
491
 
348
492
  # 소속처가 반려한 이름 — 원고에 적힌 목록은 버리고(포털 입구와 같다) 서버의 목록만 얹는다.
349
493
  cfg.pop(REJECTED_KEY, None)
@@ -369,20 +513,31 @@ def main() -> None:
369
513
  '발행 때 서버가 같은 id 의 저장본을 잇습니다(저장본이 없는 id 는 dry_run 이 거절합니다) — '
370
514
  '이 HTML 은 그림 자리가 상자인 검토본이라 단일 HTML 로 보내지 않습니다', flush=True)
371
515
  # 서식을 얹은 설정으로 새로 쓴다 — 원본 report.json 은 안 건드린다.
372
- merged = cfg_path.parent / f'.render-{args.template}.json'
516
+ merged = cfg_path.parent / f'.render-{template}.json'
373
517
  merged.write_text(json.dumps(cfg, ensure_ascii=False), encoding='utf-8')
374
518
  try:
519
+ # 도해 굽기 스위치 — 로컬은 켜고 부른다(머리 설명 「도해는 여기서 굽는다」). 0 을 주면 꺼진 서버처럼 굽는다.
520
+ env = dict(os.environ)
521
+ env.setdefault('GAZETTE_ARCHIFY', '1')
375
522
  r = subprocess.run([sys.executable, str(builder), str(merged)],
376
- cwd=str(cfg_path.parent))
523
+ cwd=str(cfg_path.parent), env=env)
377
524
  finally:
378
525
  merged.unlink(missing_ok=True)
379
526
  if r.returncode == 0 and args.text:
380
527
  # 렌더러가 쓴 자리 그대로(build.py: 설정 파일 폴더 / cfg['out']) 읽는다.
381
- text = visible_text((cfg_path.parent / cfg['out']).read_text(encoding='utf-8'))
528
+ kind_of = renderer_kind(builder)
529
+ if kind_of is None:
530
+ print('⚠ 렌더러에 도판 갈래 판정(gazette.py asset_evidence_kind)이 없어 그림 자리에 갈래를 찍지 않았습니다', flush=True)
531
+ kinds = {str(a.get('id', '')): kind_of(a) for a in cfg.get('assets') or [] if isinstance(a, dict)} if kind_of else {}
532
+ seen = []
533
+ text = visible_text((cfg_path.parent / cfg['out']).read_text(encoding='utf-8'), kinds, seen)
382
534
  out = pathlib.Path(args.text).expanduser().resolve()
383
535
  out.parent.mkdir(parents=True, exist_ok=True)
536
+ files = plate_files(cfg, seen, kept, kinds, out)
537
+ if files:
538
+ text += '\n' + '\n'.join(files) + '\n'
384
539
  out.write_text(text, encoding='utf-8')
385
- print(f'독자용 본문 · {out} {text.count(chr(10)):,}줄')
540
+ print(f'독자용 본문 · {out} {text.count(chr(10)):,}줄 · 그림 자리 {len(seen)}곳')
386
541
  if out.parent == cfg_path.parent:
387
542
  print(' ⚠ 원고 폴더 안에 썼습니다 — 검토자가 옆의 결정 기록·핸드오프를 열 수 있으니 빈 폴더로 옮기세요')
388
543
  sys.exit(r.returncode)
@@ -55,12 +55,12 @@
55
55
  | 증상 | `symptom` | 「화면에서 무엇이 잘못 보였나」 — 제보자가 자기 경험과 대조할 유일한 앵커 | 그 행이 통째로 빠짐 |
56
56
  | 원인 | `cause` | 「왜 그랬나」 — 조치를 신뢰할지 판단할 근거 | 같음 |
57
57
  | 조치 | `fix` | 「무엇을 바꿨고 어디까지 확인했나」 — 본문 최장 | 같음 |
58
- | 확인 방법 | `how_to_check` | **독자가 자기 화면에서 볼 수 있는 결과.** 「주문서를 저장하면 목록에 새로고침 없이 바로 보임」 — 주어는 화면·값(SKILL 반드시 지키는 것 5) | 같음 |
58
+ | 확인 방법 | `how_to_check` | **독자가 그 결과를 볼 자리 + 우리가 그것을 본 때·환경.** 볼 자리는 이 문서의 그림·영상·원문 인용(`{{그림:id}}`·`quotes`) 또는 독자 화면(어디를 열면). 「주문서를 작성해 저장하면 새로고침 없이 그 주문이 목록 첫 줄에 나타남({{그림:order-saved}}), 8. 23. 운영 관리자 웹에서 봄」(본보기 04-1). 동사는 우리가 본 수준까지, 안 본 결과는 `remaining` 으로. 주어는 화면·값·우리 — 독자가 밟을 순서가 아님(SKILL 반드시 지키는 것 5) | 같음 |
59
59
  | 확인 요청 | `remaining` | **아직 닫히지 않은 것** — 미확인·미결인 사실(「기준 수치는 미정」 — 부탁은 요청 절 항으로), 그리고 우리 작업이지만 운영에 아직 안 나간 것(출시 전 수정). 대조표의 「미결」 열이 이것으로 갈림 | 그 행이 빠지고 대조표는 「완료」로 표시 |
60
60
 
61
61
  | 규칙 | 근거 | 어기면 |
62
62
  |---|---|---|
63
- | 모든 건에 `how_to_check` 를 채움 | 고객이 자기 화면에서 볼 수 있는 결과가 없으면 검수 요청이 아니라 통보임 | **렌더러가 조용히 그 행만 빼고 표를 세움.** 전 건 중 하나만 반쪽인 것을 눈으로는 못 찾음 |
63
+ | 모든 건에 `how_to_check` 를 채움 — 볼 자리와 우리가 본 때·환경까지 | 독자가 결과를 볼 자리가 없으면 검수 요청이 아니라 통보임. 볼 자리 없이 결과만 적으면 독자는 우리가 본 것으로 읽음 | **렌더러가 조용히 그 행만 빼고 표를 세움.** 전 건 중 하나만 반쪽인 것을 눈으로는 못 찾음. 볼 증거 없이 화면 결과를 단정하면 렌더러 ⚠(readability R14) |
64
64
  | `remaining` 은 **아직 닫히지 않은 것만** — ① 우리가 끝낼 수 없어 상대의 판단·확인이 필요한 것(그 사실만 — 부탁 문장은 요청 절) ② 우리 작업이지만 **운영에 아직 안 나간 것**(출시 전 수정). 없으면 비움. 이 칸의 뜻은 여기가 정본임 | 대조표의 「미결/완료」가 아직 안 닫힌 것의 목록임(`gazette.py Doc.board_block()`). ② 를 비우면 대조표·일감이 **완료로 셈** | 전건에 뭔가 적으면 그 열이 무의미해지고, 안 나간 건을 비우면 문서가 끝났다고 말함. 집행자는 **사람 눈** |
65
65
  | 다섯 칸이 다 비면 그 건은 **아예 안 실림** | `if not rows: continue`(`gazette.py Doc.sheets_block()`) | 건수는 세는데 문서에는 없음 |
66
66
 
@@ -293,12 +293,12 @@ MCP 발행 스키마에도 `text_fix` 칸이 없음(`mcp/genetics.mjs`).
293
293
  | # | 유형 | 방향 | 왜 | 예 (내부 → 고객) |
294
294
  |---|---|---|---|---|
295
295
  | A | 검수 환경·계정 지칭어 | 조직 내부 명칭 → 독자 화면에 그 매장·계정이 보이면 **그 이름 그대로**(첫 등장에 괄호로 용도 한 번), 독자가 볼 일이 없을 때만 **용도**로 지칭 | 고객에게 「테스트 매장」은 **자기 매장 중 하나**로 읽힘. 용도로 새 이름을 지으면 독자 화면 어디에도 없는 이름이 됨(SKILL 반드시 지키는 것 12 · 0단계 물음 5) | `테스트 매장의 테스트 고객에 올려` → `<독자 화면의 매장 이름> 매장(검수용)의 고객으로 올려` · 화면에 없는 것이면 `검수용 매장·고객으로 올려` |
296
- | B | 배포 이벤트 → **반영 단계** 서술 | 「배포했다」·「커밋했다」 → 반영 단계 넷 가운데 하나: 「앱에 반영함 — <독자 화면의 채널 이름> 앱 전달 전」 · 「<독자 화면의 채널 이름> 앱에서 확인함」 · 「운영에 반영됨」 · 「반영 전」(채널 이름은 0단계 이름 대응표에서). 실기기 확인이 없으면 같은 문장에 「실기기 미확인」을 붙임. ISO 날짜 → 한국어 날짜 | 고객은 릴리스 절차를 모르고 알 필요도 없음. 알아야 할 것은 「지금 내 쪽에 적용돼 있나」뿐. 동사는 원고 밖 상태 표(무엇 · 코드 반영 · 시험판 전달 · 실기기 · 운영, 칸마다 근거)의 확인 수준을 넘지 않게 고름. 판정 규칙은 이용자가 확인할 수 있는 동작으로만 적고 구현 규칙(「기록에 키가 없으면 프로필 키로 판정」)은 싣지 않음. 커밋·브랜치·파일·시험 도구·시험 건수는 상태 표에만 — 원고에 새면 렌더러 ⚠(lim-portal-format §10-1 「개발 식별자」). 확인 수준 동사는 사람 눈 | `2026-08-09 운영 배포를 마쳤고` → `2026년 8월 9일 운영 환경에 반영함` · `브랜치에 커밋, 시험 7/7 통과` → `앱에 반영함 — <독자 화면의 채널 이름> 앱 전달 전, 실기기 미확인` |
296
+ | B | 배포 이벤트 → **반영 단계** 서술 | 「배포했다」·「커밋했다」 → 반영 단계 넷 가운데 하나: 「앱에 반영함 — <독자 화면의 채널 이름> 앱 전달 전」 · 「<독자 화면의 채널 이름> 앱에서 확인함」 · 「운영에 반영됨」 · 「반영 전」(채널 이름은 0단계 이름 대응표에서). 실기기에서 보지 않았으면 그 건 `remaining` 에 한 번 「실기기에서는 보지 않았음」으로 적음 — 문장마다 「실기기 미확인」을 덧달지 않음(SKILL 반드시 지키는 것 4). ISO 날짜 → 한국어 날짜 | 고객은 릴리스 절차를 모르고 알 필요도 없음. 알아야 할 것은 「지금 내 쪽에 적용돼 있나」뿐. 동사는 원고 밖 상태 표(무엇 · 코드 반영 · 시험판 전달 · 실기기 · 운영, 칸마다 근거)의 확인 수준을 넘지 않게 고름. 판정 규칙은 이용자가 확인할 수 있는 동작으로만 적고 구현 규칙(「기록에 키가 없으면 프로필 키로 판정」)은 싣지 않음. 커밋·브랜치·파일·시험 도구·시험 건수는 상태 표에만 — 원고에 새면 렌더러 ⚠(lim-portal-format §10-1 「개발 식별자」). 확인 수준 동사는 독자 검토 state 관점과 사람 눈, 화면·동작을 말한 문장에 독자가 볼 증거가 없으면 렌더러 ⚠(readability R14) · 독자 검토 증거 가시성(visual-first §7) | `2026-08-09 운영 배포를 마쳤고` → `2026년 8월 9일 운영 환경에 반영함` · `브랜치에 커밋, 시험 7/7 통과` → `앱에 반영함 — <독자 화면의 채널 이름> 앱 전달 전` + 그 건 `remaining` 에 `실기기에서는 보지 않았음` |
297
297
  | C | 티켓 분리 서술 → **제 자리의 건** | 「별도 티켓으로 뺐다」 → 그 일을 제 절의 건으로 세우고 분리 서술은 지움. 가리켜야 하면 뒤 절이 앞 절의 건을 되짚는 방향만(「03-2 건과 같은 원인임」) | 티켓은 고객이 볼 수 없는 곳임. 그 일이 제 자리의 건으로 서면 **「빠진 것」이 「다른 데 적힌 것」이 됨**. 앞 절에서 뒤 절 번호를 가리키면 독자가 장을 오감(SKILL 0.5단계 7행 · readability R12h) | `별도 티켓으로 분리해 처리함` → (삭제 — 그 일은 제 절의 건으로) |
298
- | D | 내부 작업 규모 수치 제거 | 코드·테스트 카운트 → **보장 범위** 서술 | 「6곳」은 고객이 검증할 수 없는 수치라 신뢰를 못 만들고 **「왜 6곳뿐?」이라는 불안만** 만듦 | `저장 경로 6곳을 수정함` → `저장이 일어나는 모든 경로를 수정함` |
298
+ | D | 내부 작업 규모 수치 → **무엇을 몇 개 확인했나** | 코드·테스트 카운트 → 독자가 아는 대상과 그 수. 범위를 넓히지 않음(「6곳 → 모든 경로」 금지) | 「모든 경로」는 우리가 센 것보다 넓은 단정이라 반증할 수 없음. 센 대상을 독자 말로 적으면 수가 곧 범위가 됨. 시험 건수는 B 의 상태 표에만 | `저장 경로 6곳을 수정함` → `저장이 일어나는 경로 6곳을 모두 고침` |
299
299
  | E | 배포 채널 용어 → 사용자 체감 사건 | 스토어 빌드·버전 → 「앱 업데이트」 | 현장 인원의 세계에서 일어나는 사건은 「앱이 업데이트된다」뿐임 | `다음 스토어 빌드부터 적용됨` → `다음 앱 업데이트부터 적용됨` |
300
300
  | F | 기술 명사 → 관찰 가능한 대상 | 쿠키·세션·업체 → 화면에서 보이는 것 | 독자가 그 명사를 **눈으로 확인할 수 없으면** 문장이 검증 불가능해짐 | `세션이 잠깐 되살아나던` → `로그인 상태가 잠깐 되살아나던` |
301
- | G | 증거의 출처를 내부 도구에서 지움 | 「서버 응답으로 확인」 → 「직접 확인」 | 확인했다는 **사실**이 요점이고, 어느 계층에서 봤는지는 내부 사정 | `서버 응답으로 직접 확인함` → `직접 확인함` |
301
+ | G | 내부 도구 이름은 지우고 **확인 층은 남김** | 스크립트·쿼리 파일명 → 「화면에서 봄 / 운영 서버에 물어봄 / 시험으로 봄 / 코드로 봄」 가운데 하나 | 도구 이름은 독자가 못 쓰는 내부 사정이지만, 층은 독자가 얼마나 믿을지를 가름. 「직접 확인함」은 무엇으로 봤는지가 없어 독자가 화면을 본 것으로 읽음 | `서버 응답으로 직접 확인함` → `운영 서버에 물어봄` |
302
302
  | H | 한 문장 두 사실 → 두 문장 재편 | 복문 해체 | 3.3 의 문장 길이 규칙과 같은 뿌리임 | `같은 배포에 …(이전에는 잠시 남아 보임)이 함께 포함됐음` → `함께 반영된 수정이 하나 더 있음. 녹음을 삭제하면 일지 상세에서도 즉시 사라짐.` |
303
303
  | I | 내부 관용어 → 중립 어휘 | 팀 은어 정규화 | 같은 개념을 두 이름으로 부르면 독자가 **다른 것으로** 읽음 | `자동 정리 방식` → `일괄 정리 방식` |
304
304
  | J | **삭제** | 설계 검토 이력·내부 잔여 정리는 서술 대상이 아님 | 채택되지 않은 대안은 **고객의 판단·행동을 바꾸지 않음.** 문서에 남으면 「아직 안 끝났다」로만 읽힘 | `처음 검토안 중 …은 제외함` → (통째로 삭제) |
@@ -307,7 +307,7 @@ MCP 발행 스키마에도 `text_fix` 칸이 없음(`mcp/genetics.mjs`).
307
307
 
308
308
  **경계선 둘.**
309
309
  K 는 **우리 근거의 약함을 지우라는 뜻이 아님** — 약한 근거는 등급을 달아 노출함(process.md 「같은 사실이 두 문서로 갈리는 지점」 원리 3). 지우는 것은 「독자의 무력함」 서술임.
310
- 브리핑(§12.3b)에서는 J 보다 K 가 앞섬 — 고르지 않을 길도 지우지 않고, 선택지 표의 「정할 자리」·「다시 볼 때」 행에 조건을 주어로 적음.
310
+ 결정 모드(결정 블록)에서는 J 보다 K 가 앞섬 — 고르지 않을 선택지도 지우지 않고(`options[]`), 끝나거나 풀리는 조건을 그 선택지 설명(`what`)에 조건을 주어로 적음.
311
311
 
312
312
  **경계선 하나.**
313
313
  `별도 티켓` 은 금지, `별건`·`별도 건` 은 허용.
@@ -0,0 +1,112 @@
1
+ # 결정 블록 — 원고를 어떻게 쓰나
2
+
3
+ 칸의 이름·뜻·꼴의 정본은 도구 스키마 REPORT_DATA(mcp/genetics.mjs)다.
4
+ 이 문서는 칸을 되풀이하지 않고, 무엇이 필수 · 권함 · 선택인지 · 뼈대가 왜 그 차례인지 · 빈 값과 주체를 어떻게 고르는지 · 알림을 어떻게 읽는지 · 다 쓴 뒤 누구에게 넘기는지만 적는다.
5
+ 서식은 `lim-report` 하나다 — 원고에 결정 블록(`decision_brief.decisions` 가 하나 이상)이 있으면 문서가 결정 모드로 서고, 없으면 보고 모드(`lim-portal-format.md`)다.
6
+ `template` 은 적지 않는다(옛 이름 `lim-decision` 은 서버가 `lim-report` 로 맞춘다). 결정 블록을 쓸지는 0단계 물음 3(읽고 나서 무엇을 결정하는가)이 정한다 —
7
+ 답에 정할 것이 기한과 함께 있으면 쓴다(관보 v3 2026-10-07, 경위: `history.md` §19 · §20).
8
+
9
+ ## 0. 칸 — 필수 · 권함 · 선택
10
+
11
+ | 갈래 | 칸 | 없으면 |
12
+ |---|---|---|
13
+ | 필수(거절) | 결정마다 `name` · `question` · 선택지 둘 이상의 `name` · `line`, 공개 때 `reply_to.who` | 서버가 거절한다(`reply_to.who` 만은 초안 · dry_run 에서 알림) |
14
+ | 권함(⚠) | `due` · `now` · 결정에 닿는 `facts` · `no_reply` | 거절하지 않는다 — 결정 이름 아래 ⚠ 한 줄. 화면에 「기한 없음」을 짓지 않는다 |
15
+ | 선택(쓰면 서고, 안 쓰면 그리지 않음) | `options[].rows`(행 여섯) · `options[].cost` · `figures`(그림 칸 — 장치 표 `visual-first.md` §4) · `opinion` · `confirms` · `risks` · `unknowns` · `names` 와 그 밖의 칸 | 그 행 · 블록이 없다 — 빈 값 말을 짓지 않는다. 대칭: 한 선택지에 쓴 행 · 돈은 모든 선택지가 쓴다(한쪽만 쓰면 거절) |
16
+
17
+ - **돈** — 쓰면 `state`(듦 · 안 듦 · 모름)만 필수, `known_when`(언제 아나) · `payer`(누가 내나)는 아는 것만. 금액 · 견적 · 공수는 싣지 않는다 — 돈이 드는지는 고객의 고충이고 견적은 우리 과정이다.
18
+
19
+ ## 1. 뼈대 — 렌더러가 데이터에서 세우고, 작성자는 차례를 바꾸지 않는다
20
+
21
+ 첫 화면(머리 한 띠 · 점검 범위 · 「확인해 주실 것 · 정하실 것 · 막을 결정·일정이 아직 없는 위험」 · 낯선 말) → 결정 장(결정마다 한 장, 기한 순)
22
+ → 확인해 주실 것 → 지금 열려 있는 위험 → 나중에 정해도 되는 것 → 회신 모음 → 근거 자료(보고 본문) → 판권지.
23
+
24
+ - 결정권자가 처음 묻는 것은 「무엇을 언제까지 정하고, 그 전에 무엇이 끝나야 하나」다 — 첫 화면의 목록은 개수가 아니라 이름 · 누구에게 · 기한의 축소판이다.
25
+ - 한 결정의 물음 · 지금 상태 · 그림 칸 · 선택지 · 대칭 표 · 회신이 없을 때 · 사실 · 확인 · 의견 · 회신이 한 장에 선다 — 흩어 두면 독자가 「A 는 B 와 함께 봐야」를 스스로 조립한다. 「중요한 것 먼저」로 세우지 않는다 — 그 순서가 곧 판단이 된다.
26
+ - 확인 하나가 결정 여럿에 걸칠 수 있어 본문은 「확인해 주실 것」 한 곳에 선다. 고르면 남는 위험은 대칭 표 「남는 위험」 행에, 어느 쪽을 골라도 지금 있는 위험만 「지금 열려 있는 위험」에 — 한 목록에 섞으면 순서가 판정이 된다.
27
+ - 근거 자료는 읽는 곳이 아니라 찾아오는 곳이다 — 결정 장에 없는 새 사실을 거기서 처음 꺼내지 않는다.
28
+
29
+ 결정 장 안의 차례도 렌더러가 정한다 — 지금 상태를 지키는 선택지(`is_current`)가 늘 첫째이고, 결정 이름은 첫 화면·결정 장 머리·회신 세 자리, 기한은 첫 화면·결정 장 머리 두 자리에만 찍힌다.
30
+ 그 밖의 자리는 이름 고리(`{{결정:id}}` 등)로 부른다. 번호로 가리키지 않는다.
31
+
32
+ ## 2. 쓰는 법
33
+
34
+ - **결정 하나는 물음 하나다.** 「화면 개선」처럼 여러 물음의 묶음이면 고객이 실제로 고를 물음마다 나눈다. 원고에 물음이 없으면 지어내지 말고 `question` 을 빈 값으로 둔다 — 그 빈 값이 사용자에게 물을 자리다.
35
+ - **선택지는 그대로 두는 길까지다.** 그 길이 없으면 `no_current_why` 에 까닭을 적는다. `what` 에는 실제로 하는 일과 그 일이 끝나거나 풀리는 조건까지 쓴다 — 선택지 한 줄 · 대칭 표가 이미 말한 것은 되풀이하지 않는다.
36
+ - **글 칸을 채우기 전에 구조부터 찾는다**(`now` · `what` · `facts_lead` · `opinion`). 여럿을 같은 잣대로 견주면 `table`, 순서와 누가 하는지가 있으면 `steps`, 이름 → 설명이면 `items`, 다른 칸이 이미 말했으면 지운다. 남는 말만 한 줄(`p`) · 한정(`note`)으로 쓴다 — 이 넷은 그 블록 목록을 받는다(꼴은 도구 스키마 `DEC_PROSE`). 구조가 없는 칸만 문장으로 쓴다(2026-10-08 사용자 「메타인지」). 본보기 `assets/example/decision.json` 의 「검색 방식」 의견 · 「방식 A 로 바꾸기」 설명 · 결과 화면 도안 둘의 설명이 이 꼴이다.
37
+ - **한쪽만 문장인 행**(다른 쪽은 빈 값)은 알림이 선다 — 채울 수 있으면 채우고, 정말 모르면 그대로 두어 독자가 깊이 차이를 보게 한다.
38
+ - **사실은 결과 문장이다.** 측정 조건·도구·환경은 사실 문장에 싣지 않는다. 근거 종류는 `basis`, 독자가 볼 증거는 `source` 가 진다 — 직접 본 것(`seen`)은 도판·원본 주소·건 가운데 하나가 있어야 하고, 대장의 우리 글만으로는 받지 않는다. 사실을 빼려면 데이터에서 지운다(그 차분이 사용자 보고에 실린다).
39
+ - **판단은 `opinion` 하나에만 선다.** 다른 칸·첫 화면·제목·표에 「권고」「맞다고 봄」을 두지 않는다. 의견이 없으면 빈 값으로 둔다.
40
+ - **이미 정하신 것(`settled`)은 고객이 닫은 기록이 있는 것만**이다. 기록이 없거나 FCG·내부 대화에서 정했으면 결정 물음이나 의견으로 둔다.
41
+ - **요청 자리만 하십시오체다** — 확인의 `ask`·`steps`, 대칭 표 「고객 쪽이 하실 일」의 글. 그 밖의 칸은 우리·사물이 주어인 개조식이고 독자를 주어로 쓰지 않는다(SKILL 반드시 지키는 것 5).
42
+ - **확인 이름은 물음 꼴이다**(「…가 되는가」). 「검증 환경 주소 전달」 같은 명사구는 우리 일감으로 읽힌다.
43
+ - **낯선 말과 숫자는 `names` 에 한 번** 적고 본문은 `{{이름:id}}` 로 부른다 — 렌더러가 결정 장마다 처음 나오는 자리에 풀이를 단다. 이름은 독자가 문서 밖에서 보는 원래 이름이다(SKILL 반드시 지키는 것 12).
44
+ - 문장 칸에는 길이 상한이 없다. 문단으로 쓴다. 이름 칸(`question` 25자 · `line` 35자)의 눈금은 경고가 아니다.
45
+
46
+ 남는 그물은 보고 모드와 같다 — 해라체 · 「길」 같은 지은 이름 · 소속처가 반려한 이름 · 과정 어휘 · 대화 이력 · 자격 증명 · 회차 서술. 목록을 늘리지 않는다.
47
+ 포털이 이미 주는 것(회신 핀·핵심 요약)을 문서 안에 만들지 않는다.
48
+
49
+ ## 3. 빈 값과 주체 — 「없음」 한 마디는 받지 않는다
50
+
51
+ 문장 칸이 비면 넷 가운데 무엇인지 고른다. 한 결정 안에서 「확인하지 못함」의 내용은 결정 장의 「확인하지 못한 것」 목록에 한 번만 선다.
52
+
53
+ | 고를 때 | 꼴 | 예 |
54
+ |---|---|---|
55
+ | 그 칸이 이 선택지·결정에 뜻이 없다 | `{"empty":"na","why":…}` | 「그대로 두는 길이라 바꾸는 작업이 없음」 |
56
+ | 무엇이 오면 정해진다 | `{"empty":"undecided","until":…}` | 「업체가 한도를 회신하면」 |
57
+ | 이 문서 밖에서 누가 언제 정한다 | `{"empty":"elsewhere","who":…,"when":…}` | who 「경영 회의」 · when 「11월 예산 회의에서」 |
58
+ | 우리가 확인하지 못했다 | `{"empty":"unverified","fill":…}` | 「직원 노트북 화면에서 재면 채워짐」 — 채울 수단이 없으면 그렇다고 적는다 |
59
+
60
+ 「누가」는 `customer`(받는 쪽 — `client` 글자로 찍힘) · `fcg` · `party:<names id>`(업체 등) · 빈 값 가운데 하나다. 원고에 주체가 없으면 지어 넣지 않고 빈 값으로 둔다.
61
+
62
+ ## 4. 바꿀 수 있는 글자 — `labels`
63
+
64
+ 렌더러가 찍는 고정 문구는 모두 `labels` 로 고객의 원래 말로 바꾼다(「이용자가 겪는 일」 → 「직원이 겪는 일」). 처음 읽는 독자가 이 문구를 문서 밖 말로 알아듣는지도 본다.
65
+
66
+ | 키 | 기본 글자 |
67
+ |---|---|
68
+ | `dc_head_to` · `dc_head_from` · `dc_head_asof` | 받는 분 · 보낸 곳 · 사실 기준일 |
69
+ | `dc_first_confirms` · `dc_first_decisions` · `dc_first_risks` · `dc_names_head` | 확인해 주실 것 · 정하실 것 · 막을 결정·일정이 아직 없는 위험 · 이 문서에 나오는 낯선 말 |
70
+ | `dc_due_days` · `dc_due_until` · `dc_figures` | 받으신 날부터 {n}일 안 · {date}까지 · 그림 칸 |
71
+ | `dc_sheet_head` · `dc_now` · `dc_settled` · `dc_table` · `dc_no_reply` · `dc_facts` · `dc_facts_none` · `dc_unverified` · `dc_one_side` | 정하실 것 · 지금 상태 · 이미 정하신 것 · 고르시면 달라지는 것 · 회신이 없으면 · 확인한 사실 · 확인한 사실 없음 · 확인하지 못한 것 · 한쪽 선택지에만 있는 자료 |
72
+ | `dc_confirms_sways` · `dc_confirms_after` · `dc_confirms_none` · `dc_revisit` | 이 결정을 바꿀 수 있는 확인 · 결정 뒤에 할 확인 · 확인 없음 · 확인 결과가 오면 이 결정을 다시 볼 수 있음 |
73
+ | `dc_opinion_head` · `dc_opinion_none` | FCG 의견 — 위 사실과 갈라 둔 판단 · FCG 의견 없음 |
74
+ | `dc_reply` · `dc_reply_pick` · `dc_reply_attach` · `dc_reply_cond` · `dc_reply_attach_cond` · `dc_reply_to` | 회신 · {options} 가운데 하나로 회신해 주시기 바랍니다. · {items}도 함께 적어 주십시오. · 조건이 있으면 함께 적어 주십시오. · {items}도 함께 적어 주시고, 조건이 있으면 그것도 적어 주십시오.(덧붙일 값과 조건을 둘 다 받을 때 한 문장) · 회신 받는 곳 |
75
+ | `dc_then` · `dc_no_current` | 회신 뒤 일 · 그대로 두는 길이 선택지에 없는 까닭 |
76
+ | `dc_row_for_users` · `dc_row_client_does` · `dc_row_fcg_does` · `dc_row_cost` · `dc_row_risk_left` · `dc_row_settles_today` · `dc_row_others_do` | 이용자가 겪는 일 · 고객 쪽이 하실 일 · {발행처} 가 할 일(원고 `issuer` 첫 이름) · 돈 · 남는 위험 · 그날 정해지는 것 · 그 밖의 쪽이 할 일 |
77
+ | `dc_cost_yes` · `dc_cost_no` · `dc_cost_unknown` · `dc_cost_when` · `dc_cost_payer` | 듦 · 안 듦 · 모름 · 언제 아나 · 누가 내나 |
78
+ | `dc_basis_seen` · `dc_basis_counted` · `dc_basis_tested` · `dc_basis_tested_sim` · `dc_basis_read` · `dc_basis_computed` | 직접 봄 · 운영 기록에서 셈 · 시험에서 잼 · 시험에서 잼(운영과 같은 조건 아님) · 설정·코드를 읽음 · 계산 |
79
+ | `dc_empty_na` · `dc_empty_undecided` · `dc_empty_elsewhere` · `dc_empty_unverified` · `dc_fill_none` | 해당 없음 · 아직 안 정해짐 · 이 문서에 없음 · 확인하지 못함 · 채울 수단 없음 |
80
+ | `dc_who_customer` · `dc_who_fcg` | (`client` 칸, 없으면 「고객사」) · FCG |
81
+ | `dc_ch_confirms` · `dc_ch_risks` · `dc_risk_nosize` · `dc_size_measured` · `dc_size_estimated` · `dc_blocked_none` · `dc_ch_later` · `dc_ch_replies` · `dc_ch_ledger` · `dc_ch_compare` · `dc_ch_unknowns` · `dc_ch_colophon` · `dc_toc` | 확인해 주실 것 · 지금 열려 있는 위험 · 크기는 원고에 없음 · 잰 값 · 가늠한 값 · 막을 결정·일정 없음 · 나중에 정해도 되는 것 · 회신 모음 · 근거 자료 · 선택지 전체 비교 · 확인하지 못한 범위 · 판권지 · 차례 |
82
+ | `dc_zoom` · `dc_quote` · `dc_to_names` · `dc_to_top` | 크게 보기 · 원문 · 낯선 말 목록으로 · 첫 화면으로 |
83
+
84
+ `{n}`·`{date}`·`{options}`·`{items}` 는 렌더러가 채운다. 모르는 키는 조용히 무시된다.
85
+
86
+ ## 5. 굽고 알림 읽기
87
+
88
+ 로컬 렌더는 `python3 assets/render.py decision.json` — 결정 블록이 있으면 결정 모드로 굽는다. 본보기는 `assets/example/decision.json`(지어낸 예시 고객사, 결정 둘).
89
+
90
+ - **거절(422)** 은 서버 문구가 고칠 자리와 옮길 곳을 말한다 — 그대로 고친다. 결정 모드에 자리가 없는 칸(`open_items`·`summary`·`parts`·`marg` 등)은 받지 않고 옮길 곳을 알려 준다.
91
+ - **알림**은 결정 장 이름 아래 자리 글로 선다. 합계도 「경고 0」도 없다 — 알림을 없애는 것이 목표가 아니고, 알림이 짚은 자리가 독자에게 거짓이 되는지를 본다.
92
+ - 기한 역전(결정을 바꿀 수 있는 확인이 결정 기한 뒤에 옴) — 초안이면 공개일을 오늘로 가정한 셈이다. 확인 기한을 앞당길 수 있는지, 그 확인이 사실은 결정 뒤 이행(`relation:"after"`)인지 본다. 어느 쪽도 아니면 고객에게 그 역전을 숨기지 않는다.
93
+ - 깊이 비대칭(한 행에서 한쪽은 문장, 다른 쪽은 빈 값) — 2절.
94
+ - 권고 낱말이 의견 칸 밖에 있음 — 그 문장을 `opinion` 으로 옮기거나 사실 문장으로 고친다.
95
+ - 회신 받는 사람이 빔 — 공개 전에 채운다.
96
+ - 그 밖(해라체·과정 어휘·반려 이름·날짜·도판·증거)은 `lim-portal-format.md` §10·§10-1 의 같은 문구다.
97
+ - 보고 모드의 길이 상한(문장·표 칸·문단·판단 항목 220자 네 문장·요약) · 「글로만 서 있습니다」 · 여백 주석 · 번호 가리킴(W5·XREF) · 브리핑 넷 · 요약면 막대 대조는 결정 모드에서 돌지 않는다. 그 자리가 조용한 것은 통과가 아니라 칸이 그 뜻을 진다는 뜻이다 — 칸이 지지 못하는 넷(선택지 사이 서술 깊이 · 판단이 문장 칸에 섞임 · 말투 · 고객이 못 여는 링크)은 6절의 독자가 본다.
98
+
99
+ ## 6. 다 쓴 뒤 — 원고를 모르는 독자에게 한 번
100
+
101
+ 공개할 결정 문서마다 한 번 돈다. 점수·개수는 없다.
102
+
103
+ 1. 작성자가 결정 장마다 「이 장이 말하려던 것」을 한 문장으로 먼저 적는다 — 무엇에 관한, 언제까지의, 어떤 선택지이고, 고르면·안 고르면 무엇이 달라지나.
104
+ 2. `python3 assets/render.py decision.json --text <원고 폴더 밖 빈 폴더>/본문.txt` 로 렌더본을 뽑는다.
105
+ 3. `references/reader-review.workflow.js` 를 `args = { file, text, mode: "decision", intents: [{ decision: "<결정 이름>", intent: "<1의 한 문장>" }, …] }` 로 돌린다. 독자는 렌더본만 읽고(작성자 문장을 못 본다) 결정 장마다 자기 한 문장을 쓰고, 칸이 지지 못하는 넷과 「정할 것의 수 = 결정 장의 수」를 본다.
106
+ 4. 둘이 갈린 자리만 칸 이름으로 옮겨(「`what` 이 비었다」「`now` 에 판단이 섞였다」) 원고를 고친다. 처방이 「행 하나 더」「낱말 하나 더」로 기울면 칸·본보기에서 원인을 찾는다.
107
+ 5. 사용자 보고에 결정 장마다 작성자 한 문장 · 독자 한 문장 · 갈린 자리와 고친 것, 그리고 원고에서 지운 사실의 차분을 싣는다. 작업 폴더 파일로 남기지 않는다.
108
+
109
+ ## 7. 옛 결정 브리핑(결정 블록 전 보고 모드)
110
+
111
+ 이미 나간 옛 결정 브리핑을 고칠 때만 받은 판의 뼈대를 그대로 두고 고친다(SKILL 8단계 「수정 절차」). 그 본보기 `assets/example/report-briefing*.json` 은 새 결정 문서에 베끼지 않는다.
112
+ 옛 원고에 결정 블록을 넣어 교체하면(결정 모드로 바뀐다) 옛 판단 항목의 uid 는 이어지지 않는다 — 결정 장에 이어 붙이려면 `decisions[].uid` 에 옛 uid 를 적고, 교체 시험 실행의 핀 예보를 보고 사람이 옮긴다.