genetics-mcp 2.24.0 → 2.26.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.
@@ -17,61 +17,108 @@
17
17
 
18
18
  python3 patch.py report.json edits.json # 적용
19
19
  python3 patch.py report.json edits.json --dry # 재 보기만(파일 안 건드림)
20
+ python3 patch.py base.json draft.json --freeze edits.json # draft 의 set 에 old 를 base 에서 채워 굳힘(원고는 안 건드림)
20
21
 
21
22
  `edits.json` 은 목록이고, 각 항목은 `op` 하나다. `path` 는 원고 JSON 의 자리를
22
23
  `["sections", 1, "lede"]` 처럼 적는다(문자열 `"sections[1].lede"` 도 받는다).
24
+ 줄은 번호 대신 **첫 칸 글자**로 가리킬 수 있다 — `rows[label=관리자 통계 빈칸]`(목록 꼴은 `{"label": "…"}`). 앞 op 가 줄을
25
+ 끼우거나 빼도 같은 줄을 가리킨다. 보는 글자는 표 줄의 첫 칸 · 흐름도 줄의 `cells[0].text` · 발행 이력 줄의 `no` 뿐이고
26
+ (도표 `rows` 의 `label` 칸은 보지 않는다), 그 글자인 줄이 **정확히 하나**일 때만 찾는다 — 없거나 둘 이상이면 멈춘다.
27
+ **`[label=X]` 는 X 를 안 바꾸는 칸에만 쓴다** — 그 줄의 첫 칸(흐름도 `cells[0].text` · 발행 이력 `no`)을 바꾸면 두 번째 실행이 X 를 못 찾아
28
+ 멱등이 깨지므로, 그런 `set`·`replace` 는 돌리기 전에 멈춘다. 첫 칸을 바꾸는 편집은 번호 경로로 적는다(`--freeze` 가 `old` 를 채운다).
29
+ 배열 원소는 `assets[id=fig-a]` 처럼 `id` 로도 가리킨다 — 끝자리여도 된다(그 `id` 인 원소가 정확히 하나일 때만). 없는 자리(없는 칸 · 넘친 번호)는
30
+ 만들지 않고 멈춘다 — 칸을 더하려면 그 위 객체를 통째로 `set` 한다.
23
31
 
24
32
  | op | 칸 | 하는 일 |
25
33
  |---|---|---|
26
34
  | `set` | `path` · `old` · `new` | 그 자리 값이 `old` 면 `new` 로. 이미 `new` 면 건너뛴다 |
27
35
  | `replace` | `path` · `old` · `new` | 그 자리 **문자열 안**의 조각을 바꾼다 |
28
36
  | `drop_rows` | `path` · `labels` | 앞줄들을 뺀다. 첫 칸 글자가 `labels` 와 **정확히** 같을 때만 |
37
+ | `remove_row` | `path` · `label` | 첫 칸이 그 글자인 줄 하나를 뺀다(자리는 어디든). 없으면 건너뛰고, 둘 이상이면 멈춘다 |
29
38
  | `append_row` | `path` · `row` | 없으면 뒤에 붙인다(있으면 건너뛴다) |
39
+ | `insert_row` | `path` · `row` · `before` 또는 `after` | 없으면 첫 칸이 그 글자인 줄 앞(뒤)에 끼운다. 있는지를 자리보다 **먼저** 본다(있으면 건너뛴다) |
30
40
  | `forbid` | `words` | 원고 전체에서 그 낱말이 남았는지 **센다**(안 고친다) |
31
41
 
32
42
  모든 op 는 **멱등**이다 — 두 번 돌리면 두 번째는 「바꿈 0」이어야 하고, 그것이 이 도구의 계약이다.
33
43
  값이 `old` 도 `new` 도 아니면 **멈춘다** — 그 사이에 누가 고쳤다는 뜻이고, 조용히 덮으면 안 된다.
44
+ 끼울 줄(`append_row`·`insert_row` 의 `row`)은 **최종 값**으로 적는다 — 끼운 줄을 다른 op 로 또 고치면 두 번째 실행이 한 번 더 끼운다.
45
+
46
+ ## draft 와 `--freeze` (2026-09-29)
47
+
48
+ 긴 표 행의 `old` 를 손으로 옮기기 어려워, 한 수정 판에서는 스크래치 스크립트가 받은 판의 값을 읽어 `old` 를 채웠다.
49
+ 이제 draft 에는 `set` 의 `new` 만 적고, `--freeze` 가 base(`get_report` 로 받아 둔 판)에서 `old` 를 채워 OUT 에 쓴다.
50
+ 멈추는 때(그때는 OUT 을 안 쓴다): 편집이 0개 · OUT 이 비었거나 받은 판·draft 와 같은 파일 · set 한 자리를 다른 op 가 또 고침 ·
51
+ 줄을 끼우거나 뺀 표(`drop_rows`·`remove_row`·`insert_row` 뒤)의 칸을 번호로 가리키는 `old` 없는 `set` — `old` 는 받은 판 번호로 뜨는데
52
+ 적용은 밀린 번호로 돌아, 두 칸 값이 우연히 같으면 엉뚱한 줄을 고치고도 통과한다(2026-09-30 검토 재현 — 실제 표 칸의 8~17%). 그 줄은
53
+ `[label=…]` 로 가리키거나 `old` 를 손으로 적는다(그때 번호는 앞 op 를 적용한 뒤의 번호) · base 사본에 두 번 돌려 첫 번째에서 건너뛰거나
54
+ 멈춤(base 에 이미 새 값이 있거나, 손으로 적은 번호 경로가 앞 op 가 민 번호가 아니다) · 두 번째에서 멈추거나 「바꿈 0」이 아님(멱등이 아니다 —
55
+ 번호 경로와 같은 표를 줄 op 가 밀었거나, 끼운 줄을 다른 op 로 또 고쳤다). 멈춘 op 가 줄을 끼우거나 뺀 표를 번호로 가리키면 그 까닭을 이름으로 말한다.
56
+ `--dry` 와 `--freeze` 는 함께 쓰지 않는다.
57
+ 남은 금지어는 적용 때와 같은 규칙이다 — OUT 은 쓰고 종료 코드 1 로 끝난다.
58
+ 편집이 도판 칸(`assets…`)을 고치면 적용·굳히기가 한 줄 알린다 — 교체 인자에서 `assets` 를 빼면 서버가 저장된 도판(캡션·출처 줄·alt 포함)을
59
+ 그대로 이으므로, 그 판은 새 판의 `assets` 를 `get_report` 표식째 싣는다.
60
+ **`old` 는 적용할 때 채우지 않는다** — 그러면 「그 사이 누가 고쳤다」를 못 잡는다. 적용에 `old` 없는 `set` 이 오면 멈춘다.
34
61
  """
35
62
  import argparse
63
+ import copy
36
64
  import json
65
+ import os
37
66
  import re
38
67
  import sys
39
68
 
40
69
  # 자산 바이트·도해 SVG 는 글자가 아니다 — `forbid` 가 훑지 않는다.
41
70
  SKIP_KEYS = {'data', 'file', 'ref', 'mime', 'provider', 'svg'}
42
- _TOK = re.compile(r'([A-Za-z_]\w*)|\[(\d+)\]|\[id=([^\]]+)\]|\.')
71
+ _TOK = re.compile(r'([A-Za-z_]\w*)|\[(\d+)\]|\[id=([^\]]+)\]|\[label=([^\]]+)\]|\.')
43
72
 
44
73
 
45
74
  def parse_path(p):
46
75
  """`["sections", 1, "lede"]` 또는 `"sections[1].lede"` → 자리 목록.
47
76
 
48
- `assets[id=fig-a]` 꼴도 받는다 — 배열에서 `id` 로 찾는다(자리 번호가 흔들려도 안 깨진다).
77
+ `assets[id=fig-a]` 꼴도 받는다 — 배열에서 `id` 로 찾는다(자리 번호가 흔들려도 안 깨진다 · 끝자리여도 된다 — `_find_id`).
78
+ `rows[label=…]` 는 첫 칸 글자로 줄을 찾는다(`_find_label`).
49
79
  """
50
80
  if isinstance(p, list):
51
81
  return list(p)
52
82
  out = []
53
- for name, idx, aid in _TOK.findall(str(p)):
83
+ for name, idx, aid, lab in _TOK.findall(str(p)):
54
84
  if name:
55
85
  out.append(name)
56
86
  elif idx:
57
87
  out.append(int(idx))
58
88
  elif aid:
59
89
  out.append({'id': aid})
90
+ elif lab:
91
+ out.append({'label': lab})
60
92
  return out
61
93
 
62
94
 
63
95
  def locate(doc, path):
64
- """자리 목록 → (부모, 열쇠). 마지막 한 칸은 남긴다."""
96
+ """자리 목록 → (부모, 열쇠). 마지막 한 칸은 남긴다 — 그 칸도 있어야 한다(`_step`)."""
65
97
  cur = doc
66
98
  for k in path[:-1]:
67
- if isinstance(k, dict):
68
- cur = next(x for x in cur if isinstance(x, dict) and x.get('id') == k['id'])
69
- else:
70
- cur = cur[k]
71
- last = path[-1]
72
- if isinstance(last, dict):
73
- raise ValueError('마지막 자리를 id 로 적을 수 없습니다: %r' % (path,))
74
- return cur, last
99
+ cur = cur[_step(cur, k, path)]
100
+ return cur, _step(cur, path[-1], path)
101
+
102
+
103
+ def _step(cur, k, path):
104
+ """한 칸의 열쇠 — `[label=…]`·`[id=…]` 는 번호로 푼다. 없는 자리는 트레이스백 대신 경로를 말하고 멈춘다(만들지 않는다)."""
105
+ if isinstance(k, dict) and 'label' in k:
106
+ k = _find_label(cur, k['label'])
107
+ elif isinstance(k, dict):
108
+ k = _find_id(cur, k['id'])
109
+ if not ((isinstance(cur, dict) and isinstance(k, str) and k in cur)
110
+ or (isinstance(cur, list) and isinstance(k, int) and -len(cur) <= k < len(cur))):
111
+ sys.exit('✗ 그 자리가 없습니다 @ %s (%r) — 없는 칸은 만들지 않습니다(칸을 더하려면 그 위 객체를 통째로 set)'
112
+ % (json.dumps(path, ensure_ascii=False), k))
113
+ return k
114
+
115
+
116
+ def _find_id(items, aid):
117
+ """`id` 가 `aid` 인 원소의 번호 — 없거나 둘 이상이면 멈춘다(`_find_label` 과 같은 규칙)."""
118
+ hits = [i for i, x in enumerate(items if isinstance(items, list) else []) if isinstance(x, dict) and x.get('id') == aid]
119
+ if len(hits) != 1:
120
+ sys.exit('✗ id 가 「%s」인 칸이 %d개입니다 — 하나여야 합니다' % (aid, len(hits)))
121
+ return hits[0]
75
122
 
76
123
 
77
124
  def _label(r):
@@ -82,6 +129,40 @@ def _label(r):
82
129
  return r[0] if r else ''
83
130
 
84
131
 
132
+ def _find_label(rows, label):
133
+ """첫 칸 글자가 `label` 인 줄의 번호 — 없거나 둘 이상이면 멈춘다(앞 op 가 줄을 끼우거나 빼도 같은 줄)."""
134
+ hits = [i for i, r in enumerate(rows if isinstance(rows, list) else []) if str(_label(r)) == str(label)]
135
+ if len(hits) != 1:
136
+ sys.exit('✗ 첫 칸이 「%s」인 줄이 %d개입니다 — 하나여야 합니다' % (label, len(hits)))
137
+ return hits[0]
138
+
139
+
140
+ def _relabels(e):
141
+ """`[label=X]` 로 찾은 줄의 첫 칸 X 를 바꾸는 set·replace 면 X — 두 번째 실행이 그 줄을 못 찾는다(멱등이 깨진다). 아니면 None."""
142
+ if e.get('op') not in ('set', 'replace') or 'path' not in e:
143
+ return None
144
+ p = parse_path(e['path'])
145
+ for i, k in enumerate(p):
146
+ if not (isinstance(k, dict) and 'label' in k):
147
+ continue
148
+ rest, x, new = p[i + 1:], str(k['label']), e.get('new')
149
+ if e['op'] == 'replace':
150
+ after = x.replace(str(e.get('old')), str(new)) if rest in ([0], ['no'], ['cells', 0, 'text']) else x
151
+ elif not rest:
152
+ after = _label(new) if isinstance(new, (list, dict)) else None
153
+ elif rest in ([0], ['no'], ['cells', 0, 'text']):
154
+ after = new
155
+ elif rest == ['cells', 0]:
156
+ after = new.get('text', '') if isinstance(new, dict) else None
157
+ elif rest == ['cells']:
158
+ after = _label({'cells': new}) if isinstance(new, list) else None
159
+ else:
160
+ after = x
161
+ if after is None or str(after) != x:
162
+ return x
163
+ return None
164
+
165
+
85
166
  def walk_strings(o, path=''):
86
167
  if isinstance(o, dict):
87
168
  for k, v in o.items():
@@ -103,6 +184,8 @@ class Run:
103
184
  return e.get('why') or e.get('path') or e.get('op')
104
185
 
105
186
  def set(self, e):
187
+ if 'old' not in e:
188
+ sys.exit('✗ set 에 old 가 없습니다 @ %s — draft 는 --freeze 로 먼저 굳힙니다' % self._where(e))
106
189
  parent, key = locate(self.doc, parse_path(e['path']))
107
190
  cur = parent[key]
108
191
  if cur == e['new']:
@@ -149,6 +232,32 @@ class Run:
149
232
  rows.append(e['row'])
150
233
  self.done += 1
151
234
 
235
+ def insert_row(self, e):
236
+ """`before`/`after` 줄 옆에 끼운다. 있는지를 자리보다 **먼저** 본다 — 두 번째 실행이 자리 줄을 못 찾아도 멈추지 않게."""
237
+ parent, key = locate(self.doc, parse_path(e['path']))
238
+ rows = parent[key]
239
+ if e['row'] in rows:
240
+ self.skipped += 1
241
+ return
242
+ if ('before' in e) == ('after' in e):
243
+ sys.exit('✗ insert_row 는 before · after 중 하나만 적습니다 @ %s' % self._where(e))
244
+ at = _find_label(rows, e['before'] if 'before' in e else e['after'])
245
+ rows.insert(at if 'before' in e else at + 1, e['row'])
246
+ self.done += 1
247
+
248
+ def remove_row(self, e):
249
+ """첫 칸이 `label` 인 줄 하나를 뺀다 — 없으면 이미 뺀 것으로 건너뛰고, 둘 이상이면 어느 줄인지 모르니 멈춘다."""
250
+ parent, key = locate(self.doc, parse_path(e['path']))
251
+ rows = parent[key]
252
+ hits = [i for i, r in enumerate(rows) if str(_label(r)) == str(e['label'])]
253
+ if not hits:
254
+ self.skipped += 1
255
+ return
256
+ if len(hits) > 1:
257
+ sys.exit('✗ 첫 칸이 「%s」인 줄이 %d개입니다 — 어느 줄을 뺄지 모릅니다 @ %s' % (e['label'], len(hits), self._where(e)))
258
+ del rows[hits[0]]
259
+ self.done += 1
260
+
152
261
  def forbid(self, e):
153
262
  for w in e['words']:
154
263
  for p, s in walk_strings(self.doc):
@@ -157,27 +266,129 @@ class Run:
157
266
  self.left.append((p, w, s[max(0, i - 25):i + 25]))
158
267
 
159
268
 
160
- OPS = {'set': 'set', 'replace': 'replace', 'drop_rows': 'drop_rows',
161
- 'append_row': 'append_row', 'forbid': 'forbid'}
269
+ OPS = {'set': 'set', 'replace': 'replace', 'drop_rows': 'drop_rows', 'remove_row': 'remove_row',
270
+ 'append_row': 'append_row', 'insert_row': 'insert_row', 'forbid': 'forbid'}
271
+
272
+
273
+ SHIFTERS = ('drop_rows', 'remove_row', 'insert_row') # 목록 번호를 미는 op — append_row 는 끝에만 붙여 앞 번호를 안 민다
274
+
275
+
276
+ def freeze(doc, edits):
277
+ """draft → edits: `set` 에 빠진 `old` 를 base(doc)에서 채우고, base 사본에 두 번 돌려 멱등을 잰다(doc 은 안 건드린다)."""
278
+ if not isinstance(edits, list) or not edits:
279
+ sys.exit('✗ 편집이 0개입니다 — draft 는 편집 목록(JSON 배열)이어야 합니다(받은 판과 draft 의 자리를 바꿔 주지 않았는지)')
280
+ # 줄을 끼우거나 뺀 표의 칸을 번호로 굳히지 않는다 — old 는 받은 판 번호로 뜨는데 적용은 밀린 번호로 돈다(머리 주석).
281
+ shifted = []
282
+ for e in edits:
283
+ p = parse_path(e['path']) if 'path' in e else []
284
+ if e['op'] == 'set' and 'old' not in e:
285
+ for s in shifted:
286
+ if len(p) > len(s) and p[:len(s)] == s and isinstance(p[len(s)], int):
287
+ sys.exit('✗ 줄을 끼우거나 뺀 표의 칸을 번호로 굳히지 않습니다 @ %s — 그 줄은 [label=…] 로 가리키거나, '
288
+ 'set 의 old 를 손으로 적습니다(그때 번호는 앞 op 를 적용한 뒤의 번호)' % (e.get('why') or e['path']))
289
+ if e['op'] in SHIFTERS:
290
+ shifted.append(p)
291
+ # 사슬 — set 한 자리(또는 그 안)를 다른 op 가 또 고치면 첫 실행은 되고 두 번째 실행이 멈춘다(표를 통째 set 한 뒤 그 칸을 replace 등)
292
+ paths = [(e['op'], parse_path(e['path'])) for e in edits if 'path' in e]
293
+ for i, (op, p) in enumerate(paths):
294
+ if op != 'set':
295
+ continue
296
+ for j, (op2, q) in enumerate(paths):
297
+ if j != i and q[:len(p)] == p:
298
+ sys.exit('✗ set 한 자리(또는 그 안)를 또 고칩니다 @ %s (%s · %s) — 한 자리는 한 번만'
299
+ % (json.dumps(p, ensure_ascii=False), op, op2))
300
+ out = []
301
+ for e in edits:
302
+ e = copy.deepcopy(e)
303
+ if e['op'] == 'set' and 'old' not in e:
304
+ parent, key = locate(doc, parse_path(e['path']))
305
+ # 손으로 쓴 목록과 같은 꼴 — old 를 new 바로 앞에 끼우고 나머지 칸 순서는 그대로 둔다(실사용 목록은 why 가 new 뒤)
306
+ filled = {}
307
+ for k, v in e.items():
308
+ if k == 'new':
309
+ filled['old'] = copy.deepcopy(parent[key])
310
+ filled[k] = v
311
+ e = filled
312
+ out.append(e)
313
+ trial, first = copy.deepcopy(doc), None
314
+ for n in (1, 2):
315
+ run, at = Run(trial), -1
316
+ try:
317
+ # 사본으로 돈다 — set 이 꽂은 new 를 뒤 op 가 고치면 굳힌 목록의 new 가 조용히 바뀐다(2026-09-30 검토)
318
+ for at, e in enumerate(copy.deepcopy(out)):
319
+ skipped = run.skipped
320
+ getattr(run, OPS[e['op']])(e)
321
+ if n == 1 and run.skipped > skipped: # draft 의 op 는 base 에서 모두 바뀌어야 한다
322
+ sys.exit('✗ 바꿀 것이 없습니다 @ %s — base 에 이미 있거나(고친 뒤의 원고를 base 로 줬는지) 줄 라벨이 안 맞습니다'
323
+ % run._where(e))
324
+ except SystemExit as x:
325
+ sys.exit('✗ 시험 적용 %d번째에서 멈춤 — %s\n%s' % (n, _why_stopped(n, at, out), x.code))
326
+ if n == 2 and run.done:
327
+ sys.exit('✗ 시험 적용 두 번째에서 바꿈 %d — 멱등이 아닙니다' % run.done)
328
+ first = first or run
329
+ return out, first
330
+
331
+
332
+ def _why_stopped(n, at, edits):
333
+ """시험 적용이 멈춘 까닭의 한 줄 — 멈춘 op 가 줄을 끼우거나 뺀 표를 번호로 가리키면 그것을 이름으로 말한다.
334
+
335
+ 첫 번째 실행에서는 앞선 줄 op 만 번호를 밀었고, 두 번째 실행에서는 목록의 모든 줄 op 가 이미 밀어 놓았다.
336
+ 두 번째에서 멈췄다면 첫 번째는 건너뜀 없이 끝까지 돈 뒤라 base 탓일 수 없다 — 멱등이 아닌 것이다.
337
+ """
338
+ p = parse_path(edits[at]['path']) if 0 <= at < len(edits) and 'path' in edits[at] else []
339
+ for e in (edits[:at] if n == 1 else edits):
340
+ s = parse_path(e['path']) if e['op'] in SHIFTERS and 'path' in e else None
341
+ if s is not None and len(p) > len(s) and p[:len(s)] == s and isinstance(p[len(s)], int):
342
+ return ('이 op 는 줄을 끼우거나 뺀 표를 번호로 가리킵니다 — 그 줄은 [label=…] 로 가리키거나, '
343
+ '손으로 적은 번호(set 의 old · replace)를 앞 op 를 적용한 뒤의 번호로 맞춥니다')
344
+ if n == 1:
345
+ return ('base 가 draft 를 쓴 판인지(고친 뒤의 원고를 base 로 줬는지), 손으로 적은 번호 경로(set 의 old · replace)가 '
346
+ '앞 op 를 적용한 뒤의 번호인지 봅니다')
347
+ return ('멱등이 아닙니다(첫 번째는 끝까지 돌았습니다) — 한 자리를 두 op 가 고쳤는지 봅니다: set 한 줄·칸을 다른 철자의 경로'
348
+ '(번호 ↔ [label=…])로 또 고쳤거나, 끼운 줄을 다른 op 로 또 고쳤습니다(끼울 줄은 최종 값으로 적습니다)')
162
349
 
163
350
 
164
351
  def main():
165
352
  ap = argparse.ArgumentParser(description='원고 부분 고치기(멱등) — 회차 데이터는 edits.json 에')
166
353
  ap.add_argument('report', help='원고 JSON (get_report 가 준 발행 데이터)')
167
354
  ap.add_argument('edits', help='고칠 목록 JSON')
168
- ap.add_argument('--dry', action='store_true', help='재 보기만 — 파일을 안 건드린다')
355
+ mode = ap.add_mutually_exclusive_group()
356
+ mode.add_argument('--dry', action='store_true', help='재 보기만 — 파일을 안 건드린다')
357
+ mode.add_argument('--freeze', metavar='OUT',
358
+ help='edits 를 draft 로 읽어 set 의 old 를 report(base)에서 채우고, base 사본에 두 번 돌려 멱등이면 OUT 에 쓴다 — 원고는 안 건드린다')
169
359
  a = ap.parse_args()
170
360
 
171
- doc = json.load(open(a.report, encoding='utf-8'))
172
- edits = json.load(open(a.edits, encoding='utf-8'))
361
+ try:
362
+ doc = json.load(open(a.report, encoding='utf-8'))
363
+ edits = json.load(open(a.edits, encoding='utf-8'))
364
+ except ValueError as x:
365
+ sys.exit('✗ JSON 으로 못 읽었습니다 — %s. get_report 로 받은 파일이면 머리줄·꼬리 줄 없이 mcp-call.mjs --json 으로 받습니다' % x)
366
+ if not isinstance(doc, dict):
367
+ sys.exit('✗ 원고가 JSON 객체가 아닙니다 — 원고와 편집 목록의 자리를 바꿔 준 것 아닌지 봅니다')
173
368
  if isinstance(edits, dict):
174
369
  edits = edits.get('edits') or []
175
- run = Run(doc)
176
370
  for i, e in enumerate(edits):
177
- op = e.get('op')
178
- if op not in OPS:
179
- sys.exit('✗ 모르는 op: %r (edits[%d]) — 있는 것: %s' % (op, i, ' · '.join(OPS)))
180
- getattr(run, OPS[op])(e)
371
+ if e.get('op') not in OPS:
372
+ sys.exit('✗ 모르는 op: %r (edits[%d]) — 있는 것: %s' % (e.get('op'), i, ' · '.join(OPS)))
373
+ for i, e in enumerate(edits):
374
+ x = _relabels(e)
375
+ if x is not None:
376
+ sys.exit('✗ [label=%s] 로 찾은 줄의 첫 칸을 바꿉니다(edits[%d]) — 두 번째 실행이 그 줄을 못 찾아 멱등이 깨집니다. 첫 칸을 바꾸는 편집은 '
377
+ '번호 경로로 적습니다(같은 표에 줄을 끼우거나 빼는 op 가 앞서면 old 를 손으로 — 그때 번호는 앞 op 를 적용한 뒤의 번호)' % (x, i))
378
+ if a.freeze is not None:
379
+ if not a.freeze.strip():
380
+ sys.exit('✗ --freeze 의 OUT 이 비었습니다')
381
+ for src in (a.report, a.edits):
382
+ if os.path.exists(a.freeze) and os.path.samefile(a.freeze, src):
383
+ sys.exit('✗ OUT 이 %s 와 같은 파일입니다 — 받은 판·draft 를 덮지 않게 다른 이름으로' % src)
384
+ out, run = freeze(doc, edits)
385
+ json.dump(out, open(a.freeze, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
386
+ print('굳힘 %d개 → %s (base 사본에 두 번 돌려 두 번째 바꿈 0)' % (len(out), a.freeze))
387
+ a.dry = True
388
+ else:
389
+ run = Run(doc)
390
+ for e in edits:
391
+ getattr(run, OPS[e['op']])(e)
181
392
 
182
393
  if not a.dry:
183
394
  json.dump(doc, open(a.report, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
@@ -189,6 +400,10 @@ def main():
189
400
  # 두 번째 실행이 0 이어야 한다는 계약을 사람이 눈으로 확인할 수 있게 한 줄 남긴다.
190
401
  if run.done and not a.dry:
191
402
  print('(멱등 확인: 한 번 더 돌려 「바꿈 0」이면 맞습니다)')
403
+ touched = sum(1 for e in edits if 'path' in e and (parse_path(e['path']) or [None])[0] == 'assets')
404
+ if touched:
405
+ print('도판 칸을 고친 편집 %d개 — 교체 인자에서 assets 를 빼면 서버가 저장된 도판(캡션·출처 줄·alt 포함)을 그대로 잇는다: '
406
+ '이 판은 새 판의 assets 를 get_report 표식째 싣는다' % touched)
192
407
  sys.exit(1 if run.left else 0)
193
408
 
194
409
 
@@ -29,9 +29,19 @@ cover-frame·ch-head·cindex·eof-endcard·Spoqa 가 전부 0회로 나갔다.
29
29
  python3 render.py report.json --template lim-report # 브랜드 서식
30
30
  python3 render.py report.json --text /빈/폴더/본문.txt # + 화면에 보이는 글만 텍스트로(독자 검토용)
31
31
  python3 render.py report.json --tenant <slug> # 그 소속처가 반려한 이름으로도 센다(없으면 원고의 tenant)
32
+ python3 render.py report.rev9.json # get_report 로 받은 원고 그대로 — report.rev9.html, 보관 그림은 자리 상자
32
33
 
33
34
  레포 위치는 `GENETICS_REPO` 로 덮어쓸 수 있다.
34
35
 
36
+ ## get_report 로 받은 원고 (2026-09-29)
37
+
38
+ 되읽은 원고에는 `out` 이 없고, 그림 바이트 대신 `(생략 — NB)` 표식이 온다(되읽기는 바이트를 안 준다).
39
+ 전에는 `out` 이 없어 렌더가 죽었고, 표식 도판은 ⚠ 로 빠져 독자 본문에서 그림 자리·캡션·출처 줄이 사라졌다 —
40
+ 그래서 수정 판마다 렌더 사본을 따로 지어 `out` 과 로컬 PNG 를 끼웠다. 이제 `out` 은 원고 이름 옆 `.html` 이 기본이고,
41
+ 표식 도판은 서버 기록 치수의 자리 상자로 선다(`kept_stand_ins()`). 그 HTML 은 검토본이다 — 전달본은 그림 바이트가 실린 원고로 굽는다.
42
+ 받을 때는 `mcp-call.mjs --json` 으로 JSON 만 받는다(머리줄이 붙은 파일은 못 읽는다). 원고에 처음부터 `out` 이 있으면 작성 원고(단일 HTML 전달 경로)라,
43
+ 자리 상자가 생기면 그 한 줄을 ⚠ 로 낸다 — 전달 관문 「⚠0」이 검토본을 통과시키지 않게(2026-09-30 적대 검토).
44
+
35
45
  ## 소속처가 반려한 이름 (2026-09-26)
36
46
 
37
47
  목록은 그 소속처의 반려 결정 장(`remember_decision` 의 `rejected_names` 칸)에 산다 — 제품 저장소에 없다.
@@ -41,6 +51,8 @@ cover-frame·ch-head·cindex·eof-endcard·Spoqa 가 전부 0회로 나갔다.
41
51
  원고에 `rejected_names` 가 적혀 있어도 쓰지 않는다(포털 입구도 버린다 — 두 경로가 같아야 한다).
42
52
  """
43
53
  import argparse
54
+ import base64
55
+ import html
44
56
  import json
45
57
  import os
46
58
  import pathlib
@@ -245,7 +257,7 @@ REJECTED_TIMEOUT = 20 # 초 — MCP 서버를 띄워 창구 한 번
245
257
  def rejected_names_from_server(tenant: str):
246
258
  """(목록, 못 받은 까닭) — 목록은 [{name, use, near}]. 서버가 발행 때 쓰는 그 목록이다(GET /api/wiki/rejected_names).
247
259
 
248
- 옆 파일 `mcp-call.mjs` 로 저장소의 genetics.mjs 를 띄운다(npm 판과 무관하게 이 저장소의 도구). 세포 자동 설치·스킬 배치·
260
+ 옆 파일 `mcp-call.mjs` 로 MCP 서버를 띄운다 — 저장소 안 스킬이면 저장소의 genetics.mjs, 사본이면 게시본(찾는 순서는 그 파일 머리 주석). 세포 자동 설치·스킬 배치·
249
261
  심박은 이 한 번의 읽기에 필요 없어 끈다. 도구 응답 뒤에 메일·세계 소식이 덧붙을 수 있어 앞의 JSON 한 덩어리만 읽는다.
250
262
  `--no-mail` — 이 세션의 신원을 자식에게 안 물려준다. 물려주면 자식이 이 세션 앞 메일을 긷고 「받음」 도장을 찍은 뒤
251
263
  위의 앞 JSON 만 읽기로 버린다(mcp-call.mjs 머리 주석).
@@ -276,6 +288,33 @@ def rejected_names_from_server(tenant: str):
276
288
  for n in names if isinstance(n, dict)], ''
277
289
 
278
290
 
291
+ # ── 보관 그림(get_report 표식) ──
292
+ # get_report 는 그림 바이트 대신 `(생략 — NB)` 표식을 준다(report_publish.php report_assets_elide — 되읽기는 바이트를 안 준다).
293
+ # 그 원고를 발행하면 서버가 같은 id 의 저장본을 잇는다(report_carry_assets — 저장본이 없는 id 는 입구 검증이 거절하니 dry_run 이 말한다).
294
+ # 로컬은 바이트가 없으니 도판 **자리**만 서버 기록 치수로 세운다 — 그림 번호·캡션·출처 줄·「[그림]」이 발행본과 같은 자리에 서야
295
+ # --text 독자 검토가 캡션을 읽는다(2026-09-29 실측: 자리를 빼면 독자 본문에서 도판 셋의 16줄 — 「[그림]」·캡션·출처 — 이 빠졌다).
296
+ # file·source 가 있으면 발행이 그것으로 새로 굳힌다(genetics.mjs materializeAssets) — 보관 그림이 아니다.
297
+ # 치수가 없는 표식도 건드리지 않는다 — 전과 같이 ⚠ 로 센다(2026-09-29 받은 판 표본의 figure 표식은 모두 치수가 있었다).
298
+ KEPT_MARK = '(생략'
299
+
300
+
301
+ def kept_stand_ins(cfg: dict) -> list:
302
+ """보관 그림의 data 를 서버 기록 치수의 SVG 자리 상자로 바꾸고 그 id 목록을 돌려준다(원고 파일은 안 건드린다 — 합친 설정만)."""
303
+ ids = []
304
+ for a in cfg.get('assets') or []:
305
+ if not (isinstance(a, dict) and a.get('role') == 'figure' and str(a.get('data') or '').startswith(KEPT_MARK)
306
+ and not a.get('file') and not a.get('source') and a.get('width') and a.get('height')):
307
+ continue
308
+ w, h = int(a['width']), int(a['height'])
309
+ # 상자 글은 id 까지만 — 좁은 도판(폭 296·372)에서 긴 풀이 문장이 잘렸다. 풀이는 안내 줄이 한다.
310
+ svg = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d"><rect width="100%%" height="100%%" fill="#f5f6f7"/>'
311
+ '<text x="50%%" y="50%%" text-anchor="middle" dominant-baseline="middle" font-size="%d" fill="#6b7280">%s</text></svg>'
312
+ % (w, h, max(14, w // 45), html.escape('보관 그림 %s' % a.get('id', ''))))
313
+ a['data'] = 'data:image/svg+xml;base64,' + base64.b64encode(svg.encode('utf-8')).decode('ascii')
314
+ ids.append(str(a.get('id', '')))
315
+ return ids
316
+
317
+
279
318
  def main() -> None:
280
319
  ap = argparse.ArgumentParser(
281
320
  description='단일 HTML 렌더 — 서버 서식을 읽어 관보로 굽는다 (레포 렌더러 호출)')
@@ -320,6 +359,15 @@ def main() -> None:
320
359
  cfg[REJECTED_KEY] = names
321
360
  print(f'소속처 「{tenant}」 반려 이름 {len(names)}개로 셈', flush=True)
322
361
 
362
+ # 출력 파일 — 원고에 out 이 없으면(get_report 원고) 원고 이름 옆 .html. 서버는 늘 report.html 을 박는다(report_publish.php report_render).
363
+ authored = bool(cfg.get('out')) # out 이 있으면 작성 원고(전달 경로) — get_report 로 받은 판에는 out 이 없다
364
+ if not cfg.get('out'):
365
+ cfg['out'] = cfg_path.with_suffix('.html').name
366
+ kept = kept_stand_ins(cfg)
367
+ if kept:
368
+ print(f'{"⚠ " if authored else ""}보관 그림 {len(kept)}장({", ".join(kept)}) — get_report 표식이라 서버 기록 치수의 자리 상자로 그렸습니다. '
369
+ '발행 때 서버가 같은 id 의 저장본을 잇습니다(저장본이 없는 id 는 dry_run 이 거절합니다) — '
370
+ '이 HTML 은 그림 자리가 상자인 검토본이라 단일 HTML 로 보내지 않습니다', flush=True)
323
371
  # 서식을 얹은 설정으로 새로 쓴다 — 원본 report.json 은 안 건드린다.
324
372
  merged = cfg_path.parent / f'.render-{args.template}.json'
325
373
  merged.write_text(json.dumps(cfg, ensure_ascii=False), encoding='utf-8')
@@ -422,10 +422,9 @@ AssertionError: 금지 표현 유출: 조회 색인 → ['조회 색인']
422
422
 
423
423
  ⚠ **경어체 0건은 요청 자리 밖에만 거는 기준임.**
424
424
  요청 자리(`cta`·`open_items`·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)는 **사실 문장까지 하십시오체로 맺음**(SKILL 5·5a).
425
- 두 문체 관문이 자리를 가림 — 서버 `report_tone.php report_tone_warnings()`(dry_run)는 `open_items[].body`·`cta`·`colophon.note`·`asks` 절 `marg` 와 「」 인용을 빼고 셈(lim-portal-format §10).
426
- `open_items[].title` 은 셈(제목은 「~ 요청」·물음이 규칙). 건의 `how_to_check` 에서 걸린 요청문은 「요청 절로 옮김」 처방이 따로 섬.
427
- 로컬 `tone.mjs` 는 요청 자리 가운데 넷(판단 항목 본문·`cta`·요청 PART 의 PART I 간지·판권지 회신 안내)과 「」 인용을 빼고 셈(2026-09-26) — 읽는 법 요청 한 줄은 셈.
428
- 서버 관문은 PART I 간지의 문장을, 두 관문은 읽는 법 요청 한 줄을 셈 — PART I 이 요청 PART 일 때 그 둘은 의도이고 고치지 않음(통과 기준은 lim-portal-format §10).
425
+ 두 문체 관문(서버 `report_tone.php report_tone_warnings()`·로컬 `tone.mjs`)은 같은 요청 자리 넷(`open_items[].body`·`cta`·`colophon.note`·요청 PART 의 PART I 간지 도입문·물음)과 「」 인용을 빼고 셈(2026-09-29 · lim-portal-format §10).
426
+ `open_items[].title`·PART 제목·요청 절 `marg` 는 셈(제목은 「~ 요청」·물음·이름이 규칙). 건의 `how_to_check` 에서 걸린 요청문은 「요청 절로 옮김」 처방이 따로 섬.
427
+ 읽는 법 요청 한 줄은 두 관문 모두 셈 — 그 한 줄은 의도이고 고치지 않음(통과 기준은 lim-portal-format §10).
429
428
 
430
429
  개조식 `~함 / ~했음 / ~임` 은 **요청 자리 밖에서 우리·사물이 주어인 문장**의 문체임.
431
430
  명사형 종결(`-ㅁ/-음`)은 청자 높임 등급이 없어, 상대에게 말 거는 자리에 쓰면 하대로 읽힘 —
@@ -1,9 +1,14 @@
1
1
  // 판 갱신 검토 — 한 판의 **변경 자리**를 규칙·사실 대조로, 새 판 렌더본 전체를 선형 독자로 읽고 심각도 3 이상은 반박한다.
2
2
  // 실행: Workflow 도구에 이 파일을 scriptPath 로 주고 args 를 넘긴다(배열·객체는 JSON 값 그대로).
3
- // args = { file: "<새 판 report.json 절대경로>",
4
- // backup: "<직전 판 백업 절대경로>", // 새 판을 만든 스크립트의 출발점
5
- // edit_script: "<edit-N.py 절대경로>", // put/sub 호출로 변경 자리가 전부 적힌 스크립트(멱등)
6
- // doc: "<문서 이름 한 줄>", // 예: "고객사 결정 브리핑 16판"
3
+ // args = { file: "<새 판 report.json 절대경로>", // 편집을 적용한 새 판 원고 — 교체에 싣는 인자 파일·교체 뒤 되읽은 판이 아니다
4
+ // backup: "<직전 판 백업 절대경로>", // 새 판을 만든 편집의 출발점
5
+ // edits: "<edits.json 절대경로>", // assets/patch.py 편집 목록 — 변경 기록의 기본
6
+ // patch: "<patch.py 절대경로>", // 편집 목록일 때 — 편집 목록을 굳힌 그 patch.py. 기본 ~/.claude/skills/genetics-report/assets/patch.py
7
+ // // (없거나 그 목록을 못 읽는 판이면 시작 전 검사에서 멈춘다 — 스킬 폴더가 다른 트리의 심링크인 기계는 옛 판일 수 있다)
8
+ // canon: "<정본을 가리키는 한 줄>", // 수치 편집을 대조할 정본 — 파일 경로와 자리를 글로(「…/점검표.md 의 끝 7줄 · …tsv」). 여러 출처여도 한 줄로.
9
+ // // 안 주면 검토자는 doc 글과 handoff 에서 정본을 찾고, 어디에도 없으면 수치 편집을 「정본 없음」으로 적는다
10
+ // edit_script: "<edit-N.py 절대경로>", // 옛 기록: put/sub 호출로 변경 자리가 전부 적힌 스크립트(멱등). edits 가 없을 때만 쓴다
11
+ // doc: "<문서 이름 한 줄>", // 예: "고객사 결정 브리핑 16판" — 프롬프트 머리 「대상:」에 글자 그대로 실린다
7
12
  // renderer: "<gazette.py 절대경로>", // 기본 report-portal private/builder/gazette.py
8
13
  // handoff: "<HANDOFF.md 절대경로>", // 있으면 그 판의 절을 배경으로 준다(선형 독자는 안 받는다)
9
14
  // text: "<새 판 렌더본 텍스트 절대경로>" } // 필수 — python3 assets/render.py <새 판 report.json> --text <원고 폴더 밖 빈 폴더>/본문.txt
@@ -15,7 +20,10 @@
15
20
  // 세 관점 — 선형 독자(그 자리에서 뜻이 닫히나·뒤 절 번호를 가리키나·조사·중복) · 규칙 대조(렌더러 text_scan 의
16
21
  // W2·R12h·RX_PROC·장치 이름·SOURCE_MAX·그림 번호를 코드 줄로 대조) · 사실 대조(옛 문구의 사실이 바뀌지 않았나,
17
22
  // 백업↔새 판 JSON diff 에 스크립트가 바꾼 자리가 빠짐없이 있고 기록 안 된 변경이 없는지 — 삽입·삭제가 섞이면 줄 수는 호출 수와
18
- // 다르니 자리로 센다, 수치 무변경). 심각도 3 이상만 반박 단계(최대 6).
23
+ // 다르니 자리로 센다, 수치 무변경. 편집 목록이면 직전 판 사본에 patch.py 를 적용한 결과가 새 판과 같은지, 수치를 바꾼 편집은 모두 적는다).
24
+ // 심각도 3 이상만 반박 단계(최대 6).
25
+ // 편집 목록(edits)을 정식 입력으로 받는 까닭 — 2026-09-29 관보 한 판이 patch.py 편집 목록으로 만들어졌는데 이 워크플로는
26
+ // edit_script 만 받아, 목록을 edit_script 에 넣고 「JSON 편집 목록이다」라는 풀이를 doc 에 적어야 했다(두 번 돌림).
19
27
  // 선형 독자는 새 판 렌더본 텍스트(args.text) 하나만 받고 CLAUDE.md 를 싣지 않는 Explore 유형으로 돈다 — 편집 스크립트·옛 문구·핸드오프를
20
28
  // 받은 채 읽으면 처음 읽는 사람이 아니다(2026-09-26 스킬 검토: 변경 판 검토의 선형 독자 15/15 가 원고 밖 과정 파일을 열었다).
21
29
  // 그래서 변경 자리가 아니라 문서 전체를 처음부터 읽는다. 확인 전: MEMORY.md 까지 빠지는지는 첫 실행 전사의 첨부로 확인한다.
@@ -23,37 +31,71 @@
23
31
  // 결과 — { head·failed(검증이 실패했거나 관점이 결과를 못 냈거나 선형 독자가 렌더본을 못 열었을 때만 — 「검토 미완 — 검증 N건 실패」),
24
32
  // kept(진단이 거짓이 아닌 발견 + rx), refuted(진단 거짓), weak(심각도 2 이하 — 사람이 훑어 고를 것), unverified(상한 6 밖·검증 실패 — 사람이 훑는다), passed }.
25
33
  // 시작 전 검사(Check): 렌더본 파일(args.text)이 없거나 비었거나 새 판 원고(args.file)보다 오래되면 읽기 전에 멈추고 무엇이 필요한지 말한다.
34
+ // 편집 목록이면 patch.py 도 잰다 — 없거나, 새 판에 그 목록을 --dry 로 돌렸을 때 목록을 못 읽거나(모르는 op · 트레이스백 · JSON 아님)
35
+ // 「바꿈 N」(N>0 — 새 판에 편집이 아직 적용돼 있지 않다: file 과 backup 을 바꿔 줬을 때)이면 멈춘다. 종료 코드로는 가리지 않는다(금지어가 남은 정상 판도 1 이다).
26
36
  // 워크플로 스크립트는 파일을 못 연다 — 에이전트 하나가 명령 한 줄의 출력을 옮기고 판정은 JS 가 한다(reader-review.workflow.js 머리 주석과 같다).
27
37
  export const meta = {
28
38
  name: 'report-edition-review',
29
- description: '보고서 한 판을 선형 독자(렌더본 텍스트만)·규칙 대조·사실 대조(변경 자리 edit-N.py) 세 관점으로 읽고, 심각도 3 이상은 진단·처방을 따로 반박 검증한다',
39
+ description: '보고서 한 판을 선형 독자(렌더본 텍스트만)·규칙 대조·사실 대조(변경 자리 — patch.py 편집 목록 또는 edit-N.py) 세 관점으로 읽고, 심각도 3 이상은 진단·처방을 따로 반박 검증한다',
30
40
  phases: [
31
- { title: 'Check', detail: '렌더본이 있고 비지 않았고 새 판 원고보다 새로운지 — 명령 한 줄' },
41
+ { title: 'Check', detail: '렌더본이 있고 비지 않았고 새 판 원고보다 새로운지 · 편집 목록이면 patch.py 가 그 목록을 읽는지 — 명령 한 줄' },
32
42
  { title: 'Review', detail: '세 관점 병렬 — 선형 독자 · 렌더러 규칙 대조 · 사실 대조' },
33
43
  { title: 'Verify', detail: '심각도 3 이상 발견을 반박(최대 6) — 진단 거짓만 refuted' },
34
44
  ],
35
45
  }
36
46
  const A = args || {}
37
- if (!A.file || !A.backup || !A.edit_script) throw new Error('args.file · args.backup · args.edit_script 가 필요하다')
47
+ if (!A.file || !A.backup || !(A.edits || A.edit_script)) throw new Error('args.file · args.backup 과 args.edits(patch.py 편집 목록) 또는 args.edit_script(edit-N.py) 가 필요하다')
38
48
  const MAKE_TEXT = 'python3 assets/render.py <새 판 report.json> --text <원고 폴더 밖 빈 폴더>/본문.txt'
39
49
  if (!A.text) throw new Error(`args.text 가 필요하다 — 새 판 렌더본 텍스트 파일 절대경로. 선형 독자는 이 파일 하나만 읽는다. 만드는 법: ${MAKE_TEXT}`)
40
50
  phase('Check')
41
51
  // 렌더본이 있고 비지 않았고 새 판 원고보다 새로운지 — 파일 상태는 에이전트가 옮긴 명령 출력으로만 읽고 판정은 여기서 한다(머리 주석 「시작 전 검사」).
42
52
  const shq = (p) => `'${String(p).replace(/'/g, `'\\''`)}'`
43
- const STAT_CMD = `python3 -c 'import json,os,sys; print(json.dumps([dict(f=os.path.isfile(p), b=(len(open(p,"rb").read().strip()) if os.path.isfile(p) else -1), m=(os.path.getmtime(p) if os.path.isfile(p) else -1)) for p in sys.argv[1:]]))' ${shq(A.text)} ${shq(A.file)}`
53
+ // patch.py — 편집 목록일 때만 쓴다. 글로만 가리키면 검토자가 다른 트리의 옛 사본을 찾는다(이 맥은 스킬 폴더가 심링크라 `find -type d` 로 안 잡힌다 —
54
+ // 2026-09-30 적대 검토). 그래서 경로를 주고, 시작 전 검사에서 셋째 파일로 잰다(`~` 는 파이썬이 푼다). 편집 스크립트 쪽 명령은 그대로다.
55
+ // 있는지만 재면 모자란다 — 기본 경로가 다른 트리의 옛 patch.py 로 풀리면(심링크 설치본) 새 op·[label=] 경로를 못 읽어 사실 관점이 재다 죽는다.
56
+ // 그래서 새 판에 그 목록을 --dry 로 한 번 돌려 넷째 칸(rc·tb·dry·out)으로 옮긴다 — 목록을 **읽는지**와 새 판에 이미 적용돼 있는지(「바꿈 0」)만
57
+ // 여기서 가리고, 값이 맞는지는 사실 관점 ①이 잰다. 종료 코드로 가리지 않는다 — 금지어가 남은 정상 판도 「바꿈 0」 뒤에 1 로 끝난다.
58
+ // 트레이스백은 따로 한 칸(tb), 「바꿈 N · …」 줄도 따로 한 칸(dry)이다 — out 은 끝 300자라 경로가 길면 「Traceback」 낱말과 앞줄이 잘린다.
59
+ const PATCH = A.patch || '~/.claude/skills/genetics-report/assets/patch.py'
60
+ const STAT_PY = 'dict(f=os.path.isfile(p), b=(len(open(p,"rb").read().strip()) if os.path.isfile(p) else -1), m=(os.path.getmtime(p) if os.path.isfile(p) else -1))'
61
+ const STAT_CMD = A.edits
62
+ ? `python3 -c 'import json,os,subprocess,sys; a=list(map(os.path.expanduser, sys.argv[1:])); st=[${STAT_PY} for p in a[:3]]; r=subprocess.run([sys.executable, a[2], a[1], a[3], "--dry"], capture_output=True, text=True) if all(os.path.isfile(p) for p in a[1:]) else None; print(json.dumps(st + [dict(rc=(r.returncode if r else -1), tb=bool(r and "Traceback" in r.stderr), dry=next((l for l in (r.stdout if r else "").splitlines() if l.startswith("바꿈 ")), ""), out=((r.stdout + r.stderr)[-300:] if r else ""))]))' ${shq(A.text)} ${shq(A.file)} ${shq(PATCH)} ${shq(A.edits)}`
63
+ : `python3 -c 'import json,os,sys; print(json.dumps([${STAT_PY} for p in sys.argv[1:]]))' ${shq(A.text)} ${shq(A.file)}`
44
64
  const PRE = { type: 'object', properties: { stdout: { type: 'string', description: '명령의 표준 출력 한 줄 — 글자 그대로' }, exit: { type: 'integer' } }, required: ['stdout', 'exit'] }
45
65
  const pre = await agent(`아래 명령을 Bash 로 **한 번만, 글자 그대로** 실행하고 표준 출력 한 줄을 stdout 에 고치지 말고 옮긴다. 종료 코드는 exit 에. 명령을 바꾸거나 다른 명령·파일을 열지 않는다.\n${STAT_CMD}`,
46
66
  { label: 'check:files', phase: 'Check', schema: PRE, effort: 'low' })
47
67
  let st = null
48
68
  try { st = pre && pre.exit === 0 ? JSON.parse(pre.stdout) : null } catch (e) { st = null }
49
- if (!Array.isArray(st) || st.length !== 2) throw new Error(`시작 전 검사 실패 — 렌더본·원고 파일 상태를 못 읽었다(${pre ? `exit ${pre.exit} · ${String(pre.stdout).slice(0, 200)}` : '검사 에이전트 결과 없음'}). resumeFromRunId 없이 새로 띄운다`)
69
+ if (!Array.isArray(st) || st.length !== (A.edits ? 4 : 2)) throw new Error(`시작 전 검사 실패 — 렌더본·원고${A.edits ? '·patch.py' : ''} 파일 상태를 못 읽었다(${pre ? `exit ${pre.exit} · ${String(pre.stdout).slice(0, 200)}` : '검사 에이전트 결과 없음'}). resumeFromRunId 없이 새로 띄운다`)
50
70
  if (!st[1].f) throw new Error(`args.file 파일이 없다 — ${A.file}. 새 판 report.json 절대경로를 준다`)
51
71
  if (!st[0].f) throw new Error(`args.text 파일이 없다 — ${A.text}. 만드는 법: ${MAKE_TEXT}`)
52
72
  if (st[0].b <= 0) throw new Error(`args.text 파일이 비었다 — ${A.text}. 렌더가 실패했을 수 있다 — 다시 뽑고 출력의 ⚠·오류를 본다: ${MAKE_TEXT}`)
73
+ if (A.edits && !st[2].f) throw new Error(`patch.py 가 없다 — ${PATCH}. args.patch 로 스킬 설치본의 assets/patch.py 절대경로를 준다(사실 관점이 편집 목록을 이 파일로 잰다)`)
74
+ if (A.edits && st[3].rc === -1) throw new Error(`args.edits 파일이 없다 — ${A.edits}. 편집 목록(edits.json) 절대경로를 준다`)
75
+ if (A.edits && (st[3].tb || /모르는 op|JSON 으로 못 읽었습니다/.test(String(st[3].out)))) throw new Error(`patch.py 가 이 편집 목록을 못 읽는다 — ${PATCH} (${String(st[3].out).trim().split('\n').pop().slice(0, 200)}). 편집 목록을 굳힌 그 patch.py 의 절대경로를 args.patch 로 준다 — 기본 경로가 다른 트리의 옛 판으로 풀렸을 수 있다`)
76
+ if (A.edits && Number((/^바꿈 (\d+) ·/.exec(String(st[3].dry)) || [])[1]) > 0) throw new Error(`새 판에 편집 목록이 아직 적용돼 있지 않다 — ${A.file} 에 --dry 로 돌리니 「${st[3].dry}」. args.file 은 편집을 적용한 새 판, args.backup 이 그 출발점이다(둘을 바꿔 주지 않았는지 본다)`)
53
77
  if (st[0].m < st[1].m) throw new Error(`렌더본이 새 판 원고보다 오래됐다 — ${A.text} 가 ${A.file} 보다 ${Math.round(st[1].m - st[0].m)}초 앞선다. 새 판을 고친 뒤 다시 뽑는다: ${MAKE_TEXT} (그다음 resumeFromRunId 없이 새로 띄운다)`)
54
78
  const RENDERER = A.renderer || '레포 report-portal 의 private/builder/gazette.py(args.renderer 가 없으면 이 기계에서 경로를 찾아 쓴다 — 못 찾으면 규칙 관점 결과에 「렌더러 못 읽음」으로 적는다)'
79
+ // 반박에도 렌더러 경로를 준다 — 2026-09-30 실제 1회에서 반박 하나가 args.renderer 대신 다른 트리의 옛 렌더러를 읽고 판정했다.
80
+ const RENDERER_PATH = A.renderer || '레포 report-portal 의 private/builder/gazette.py'
55
81
  const DOC = A.doc || A.file
56
- const CTX = `대상: ${DOC}. 직전 판 → 이 판의 변경은 ${A.edit_script} 에 put/sub 호출로 전부 적혀 있다(옛 문구 → 새 문구). 새 판 원고는 ${A.file}, 직전 판은 ${A.backup}.${A.handoff ? ` 배경은 ${A.handoff} 의 이 판 절.` : ''} 한글로 답한다. 파일은 읽기만 한다 — 고치지 않는다.`
82
+ // 변경 기록이 무엇인지(CTX)와 사실 관점 ①(기록 대조), 반박이 읽을 기록의 이름(src)만 기록 종류에 따라 갈린다. edit_script 쪽 세 값은 전과 한 글자도 같다.
83
+ // 「제자리에서 고친다」 경고는 CTX 에 둔다 — 규칙 관점·반박도 patch.py 를 돌릴 수 있다.
84
+ // 정본 — 글로만 「doc·handoff 가 가리키는 정본」이라 하면 검토자는 그 인자 이름을 본 적이 없다(CTX 는 「대상」·「배경」으로만 싣는다).
85
+ // args.canon 을 받아 CTX 에 「정본」으로 싣고, 없으면 프롬프트에 **보이는 자리**(「대상」 글 · 배경 파일)를 가리킨다 — 정본을 doc 글에 적어
86
+ // 넘기던 호출(2026-09-29 실제 호출: 「새 사실의 정본은 …md 의 끝 7줄」)이 끊기지 않게, 어디에도 없을 때만 「정본 없음」이다(2026-09-30 적대 검토).
87
+ const CANON = A.canon ? '②에서 그 수치를 원고 안의 다른 자리와, 위 정본에 대조한다'
88
+ : '②에서 그 수치를 원고 안의 다른 자리와 대조하고, 위 「대상」 글이나 배경 파일이 정본을 가리키면 거기에도 대조한다 — 어디에도 정본이 없으면 수치 편집마다 「정본 없음」으로 적는다'
89
+ const REC = A.edits ? {
90
+ src: '편집 목록',
91
+ change: `${A.edits} 의 patch.py 편집 목록에 전부 적혀 있다 — JSON 배열이고 항목마다 op 하나다. patch.py 는 ${PATCH} 이다 — op 의 칸과 뜻은 그 파일 머리 주석의 op 표를 읽는다. patch.py 는 준 파일을 제자리에서 고친다 — 새 판·직전 판에는 --dry 로만 돌리고, 적용은 빈 임시 폴더에 복사한 사본에 한다.`,
92
+ count: '① 새 판에 patch.py 를 --dry 로 돌려 「바꿈 0」인지 보고, 직전 판 사본에 편집 목록을 적용한 결과가 새 판과 JSON 값까지 같은지 본다 — 다르면 기록되지 않은 변경이 있다. 두 판을 diff 해 forbid 밖의 편집마다 diff 에 보이는지 path 로 센다(set 한 번이 표의 여러 행을 바꾸므로 diff 줄 수는 편집 수와 다르다). forbid 는 바꾸지 않고 남은 낱말을 센다. 수치(인원·kg·cm·날짜)를 바꾼 편집은 path 와 옛 값 → 새 값으로 모두 센다(목록 밖 변경은 위 사본 대조가 잡는다) — ' + CANON + '.',
93
+ } : {
94
+ src: '스크립트',
95
+ change: `${A.edit_script} 에 put/sub 호출로 전부 적혀 있다(옛 문구 → 새 문구).`,
96
+ count: '① 직전 판과 새 판의 JSON 을 diff 해 스크립트의 put/sub·삽입·삭제가 바꾼 자리가 빠짐없이 보이는지, 기록되지 않은 변경이 없는지 자리로 센다(삽입·삭제가 섞이면 diff 줄 수는 호출 수와 다르다). 수치(인원·kg·cm·날짜)가 한 곳도 안 바뀌었는지도 센다.',
97
+ }
98
+ const CTX = `대상: ${DOC}. 직전 판 → 이 판의 변경은 ${REC.change} 새 판 원고는 ${A.file}, 직전 판은 ${A.backup}.${A.handoff ? ` 배경은 ${A.handoff} 의 이 판 절.` : ''}${A.canon ? ` 정본(수치의 출처)은 ${A.canon}.` : ''} 한글로 답한다. 파일은 읽기만 한다 — 고치지 않는다.`
57
99
  // 선형 독자의 머리말 — 위 CTX(편집 스크립트·직전 판·핸드오프)를 받지 않는다. 독자가 보는 것은 렌더본뿐이다(render.py --text).
58
100
  const TEXT_CTX = `[읽기 전용. 한글로 답한다. 철저함: very thorough — 발췌하지 말고 파일 전체를 처음부터 끝까지 읽는다.]
59
101
  읽을 것: ${A.text} — 새 판 렌더본에서 화면에 보이는 글만 화면 순서대로 뽑은 텍스트(그림·영상 자리는 「[그림]」「[영상]」, 표 칸은 「 | 」).
@@ -72,7 +114,7 @@ const VERDICT = { type: 'object', properties: {
72
114
  const LENSES = [
73
115
  { key: 'linear', agentType: 'Explore', text: true, prompt: `${TEXT_CTX}\n[선형 독자] 새 판을 처음 읽는 고객 담당이 되어 파일 처음부터 끝까지 순서대로 읽는다. 자리마다 (a) 그 자리에서 뜻이 닫히는가 (b) 앞에서 정의되지 않은 낱말·번호를 가리키는가 — 뒤 절 번호 가리킴은 금지 규칙이고 표 번호·「다음 절」로 바꿔 놓은 것도 독자를 뒤로 보내면 적는다 (c) 어색한 문장·조사·중복(절 이름과 부제, 같은 답 묶음 안의 되풀이)이 있는가 (d) 인쇄본에서 죽는 화면 어휘(「누르면」)가 있는가 (e) 대상·판·시험·상태·선택지를 부르는 낱말마다, 독자가 문서 밖(자기 화면 글자, 원본 문서, 공급사 공식 명칭)에서 본 글자인가? 이 문서가 붙인 이름이면 정의가 있어도 발견이다. 처방은 뜻풀이가 아니라 원래 이름으로 바꾸는 것이다 — 원래 이름을 이 파일만으로 알 수 없으면 fix 에 이름을 지어 넣지 말고 「원래 이름으로 바꾼다(이 파일에 없음)」라고 적는다. 문제가 없는 자리도 passed 에 적는다. severity 1~5(5=독자가 뜻을 잃음).` },
74
116
  { key: 'rules', prompt: `${CTX}\n[규칙 대조] 렌더러 ${RENDERER} 의 text_scan 규칙 — W2 출처 줄(rx_loc: 쪽·행·https·건 §·§ n·부록 N, user-provided 만; rx_meta 제작 설명·그림 번호), R12h 앞 절→뒤 절 가리킴(rx_fwd_case/rx_fwd_sec/rx_other_doc, fwd_spot 이 훑는 칸), RX_PROC 과정 어휘, 장치 이름 충돌(건 행 이름 labels 낱말이 다른 절 표 열·부제에), SOURCE_MAX, 그림·표 번호 가리킴, 문장 60자·괄호 부연·표 주석 문장 수 — 를 코드에서 직접 읽고, 변경 자리의 새 문구가 각 규칙을 만족하는지, 새 문구가 다른 규칙을 새로 어기지 않는지 하나씩 대조한다. 판정 근거는 렌더러 파일의 줄 번호로 적는다.` },
75
- { key: 'facts', prompt: `${CTX}\n[사실 대조] ① 직전 판과 새 판의 JSON 을 diff 해 스크립트의 put/sub·삽입·삭제가 바꾼 자리가 빠짐없이 보이는지, 기록되지 않은 변경이 없는지 자리로 센다(삽입·삭제가 섞이면 diff 줄 수는 호출 수와 다르다). 수치(인원·kg·cm·날짜)가 한 곳도 안 바뀌었는지도 센다. ② 변경 자리마다 새 문구가 옛 문구의 사실을 바꾸지 않았는지 원고 안에서 대조한다 — 가리키는 부록·표·용어가 실제로 있는가, 이름으로 바꾼 위치가 같은 대상인가, 사실문으로 고친 문장이 요지·다른 절과 같은 사실인가. 다른 문서를 인용한 자리는 그 문서를 열 수 있으면 열어 대조하고 못 열면 「확인 불가」로 적는다. 바뀐 사실이 있으면 severity 4 이상.` },
117
+ { key: 'facts', prompt: `${CTX}\n[사실 대조] ${REC.count} ② 변경 자리마다 새 문구가 옛 문구의 사실을 바꾸지 않았는지 원고 안에서 대조한다 — 가리키는 부록·표·용어가 실제로 있는가, 이름으로 바꾼 위치가 같은 대상인가, 사실문으로 고친 문장이 요지·다른 절과 같은 사실인가. 다른 문서를 인용한 자리는 그 문서를 열 수 있으면 열어 대조하고 못 열면 「확인 불가」로 적는다. 바뀐 사실이 있으면 severity 4 이상.` },
76
118
  ]
77
119
  const reads = await pipeline(LENSES,
78
120
  (l) => agent(l.prompt, { label: `review:${l.key}`, phase: 'Review', schema: l.text ? FINDINGS_TEXT : FINDINGS, ...(l.agentType ? { agentType: l.agentType } : {}) }).then((r) => r && { lens: l.key, ...r }))
@@ -83,7 +125,7 @@ const strongAll = all.flatMap((r) => r.findings.filter((f) => f.severity >= 3).m
83
125
  const strong = strongAll.slice(0, 6)
84
126
  log(`발견 ${all.reduce((n, r) => n + r.findings.length, 0)} · 심각도≥3 ${strongAll.length}(반박 ${strong.length}) · passed ${all.reduce((n, r) => n + r.passed.length, 0)}`)
85
127
  const verdicts = await parallel(strong.map((f) => () =>
86
- agent(`${CTX}\n[반박] 다음 발견을 원고·스크립트·렌더러를 직접 읽어 반박해 본다. 발견: (${f.lens}) ${f.where} — ${f.claim}. 근거: ${f.evidence}. 처방: ${f.fix || '(없음)'}.${f.lens === 'linear' ? ` 이 발견은 렌더본 텍스트 ${A.text} 만 읽은 독자가 냈다 — 독자에게 보이는 글로 판정한다. 문서가 붙인 이름을 짚은 발견이면 문서 안의 정의·캡션은 반증이 아니다 — 독자가 문서 밖에서 본 글자인지를 판정한다.` : ''}
128
+ agent(`${CTX}\n[반박] 다음 발견을 원고·${REC.src}·렌더러(${RENDERER_PATH})를 직접 읽어 반박해 본다. 발견: (${f.lens}) ${f.where} — ${f.claim}. 근거: ${f.evidence}. 처방: ${f.fix || '(없음)'}.${f.lens === 'linear' ? ` 이 발견은 렌더본 텍스트 ${A.text} 만 읽은 독자가 냈다 — 독자에게 보이는 글로 판정한다. 문서가 붙인 이름을 짚은 발견이면 문서 안의 정의·캡션은 반증이 아니다 — 독자가 문서 밖에서 본 글자인지를 판정한다.` : ''}
87
129
  diagnosis(진단): 발견이 참/부분/거짓/미확인. 사실이 아니거나 규칙상 문제가 아니면 거짓. 확신이 없으면 미확인.
88
130
  진단과 처방은 따로 판정한다 — 처방이 다른 규칙을 어겨도(포인터·원래 이름 대신 새 이름 짓기·만든 이름에 뜻풀이만 더하기 포함) diagnosis 는 그대로 두고 rx_fit=다시, rx_why 에 걸린 규칙과 까닭. 규칙에 맞으면 rx_fit=맞음, rx_why 는 빈 문자열.
89
131
  reason 은 파일·줄·문구 인용으로.`, { label: `verify:${f.lens}`, phase: 'Verify', schema: VERDICT }).then((v) => ({ ...f, verdict: v }))))