genetics-mcp 2.23.0 → 2.24.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 (31) 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 +2019 -227
  8. package/package.json +2 -2
  9. package/skills/gazette-report/SKILL.md +11 -10
  10. package/skills/gazette-report/scripts/tone.mjs +17 -5
  11. package/skills/gazette-report/scripts/tone.test.mjs +15 -2
  12. package/skills/gazette-report/template/skeleton.html +10 -10
  13. package/skills/genetics-define/SKILL.md +2 -0
  14. package/skills/genetics-report/SKILL.md +93 -87
  15. package/skills/genetics-report/assets/example/cases-briefing.json +1 -1
  16. package/skills/genetics-report/assets/example/cases.json +3 -3
  17. package/skills/genetics-report/assets/example/report-briefing-designs.json +26 -26
  18. package/skills/genetics-report/assets/example/report-briefing.json +3 -3
  19. package/skills/genetics-report/assets/example/report.json +11 -11
  20. package/skills/genetics-report/assets/mcp-call.mjs +109 -0
  21. package/skills/genetics-report/assets/patch.py +196 -0
  22. package/skills/genetics-report/assets/render.py +172 -0
  23. package/skills/genetics-report/assets/xlsx-plate.py +203 -0
  24. package/skills/genetics-report/references/content-pipeline.md +26 -26
  25. package/skills/genetics-report/references/edition-review.workflow.js +70 -15
  26. package/skills/genetics-report/references/history.md +76 -2
  27. package/skills/genetics-report/references/lim-portal-format.md +100 -45
  28. package/skills/genetics-report/references/linear-read-test.workflow.js +50 -14
  29. package/skills/genetics-report/references/process.md +47 -43
  30. package/skills/genetics-report/references/reader-review.workflow.js +105 -28
  31. package/skills/genetics-report/references/visual-first.md +29 -12
@@ -26,6 +26,8 @@ python3 ~/.claude/skills/genetics-report/assets/render.py report.json
26
26
  ```
27
27
 
28
28
  `render.py` 가 서버의 `REPORT_TEMPLATES` 를 읽어 `render`·`css`·폰트를 설정에 얹는다.
29
+ 소속처 문서면 그 소속처가 반려한 이름도 함께 센다 — `--tenant <slug>`(원고에 `tenant` 가 있으면 생략)로 MCP `list_rejected_names` 를 불러 받은 목록을 얹는다.
30
+ 못 받으면 ⚠ 한 줄을 내고 반려 이름만 빼고 굽는다(`lim-portal-format.md` §10-1 「목록을 얻는 길」).
29
31
  **`report.json` 에 `css`·`render`·`palette` 를 적지 마라** — 서버와 갈릴 수 있고, 갈리면 로컬에서 본 것과 고객이 본 것이 다르다.
30
32
 
31
33
  **`build.py` 를 직접 부르지 마라.**
@@ -46,23 +48,25 @@ python3 ~/.claude/skills/genetics-report/assets/render.py report.json
46
48
  | 질문 | 왜 이걸 묻는가 | 예시 답 |
47
49
  |---|---|---|
48
50
  | 이 문서를 **읽고 판정하는 사람**은 누구인가 — 직함이 아니라 *하는 일*로 | 직함(「팀장」)에서는 어휘 정책이 안 나온다. 행위(「검증한다」)에서는 나온다 | 「고객사 검수 담당 — 우리 작업이 실제로 해결됐는지 확인하는 사람」 |
49
- | 읽고 나서 **해야 할 행동**은 무엇인가 — 할 일이 둘이면 둘 다 목록으로 | `cta`·`open_items`·건별 `how_to_check` 가 여기서 결정된다. 할 일 목록은 독자 검토 `args.reader` 에 그대로 싣는다 | 「① 결정 물음에 회신한다 ② 시험판에서 확인한다」 |
51
+ | 읽고 나서 **해야 할 행동**은 무엇인가 — 할 일이 둘이면 둘 다 목록으로 | `cta`·`open_items`·건별 `how_to_check` 가 여기서 결정된다. 할 일 목록은 독자 검토 `args.reader` 에 그대로 싣는다 | 「① 결정 물음에 회신한다 ② <독자 화면의 채널 이름> 앱에서 확인한다」 |
50
52
  | 문서에 실어도 되는 **참조 체계**는 무엇인가 | 독자가 확인할 수 없는 참조는 신뢰에 기여하지 않고 노이즈만 된다 | 「본인이 제보한 원본 링크만. 내부 티켓 번호는 안 됨」 |
51
53
  | 전달 경로 — 포털 링크인가, 파일 하나인가, 인쇄도 도는가 | 포털은 셸이 문서 둘레에 요약·댓글을 놓는다. 파일 전달은 그것이 없다 | 「포털 링크 + 인쇄 가능해야 함」 |
52
54
  | 읽고 나서 **무엇을 결정하는가** — 선택지·선택지마다 달라지는 것·기한. 우리 의견을 낼지는 따로 묻는다 | 답이 있으면 「결정한다」 모드다. `open_items` 가 고객이 고를 **물음**이 되고 기한 ⚠ 가 걸린다. 의견은 내면 한 절 한 자리(lim-portal-format §12.3b) | 「[방식 A] 를 들일지, 그대로 둘지 — 12월 31일까지」 |
53
55
  | **이미 정해진 것**은 무엇이고 **누가·어디서** 정했는가(체크리스트 0a) | 정해진 것은 사실로 배경에 서고 결정 요청에서 빠진다 — 주체와 기록을 확인하지 않고 쓰면 열린 항이 닫힌 사실로 실리거나, FCG 가 대화에서 정한 것이 고객 결정처럼 읽힌다(SKILL 3b 「결정 주체」). 작업 폴더 `decisions.json`(발행 안 함): 항 · 내용 · 결정권(고객/FCG) · 정한 주체 · 기록(회신 원문·회의록·결정문의 이름과 날짜) · 사용자 확인 여부. 미확인 항은 원고에 쓰기 전에 한 번에 묻는다 | 「화면 문구 교체 — 결정권 고객 · 고객 회신 메일(날짜) · 확인함」 |
56
+ | 원고에서 부를 대상(판·환경·인스턴스·설정·요금·문서)마다 **독자가 보는 이름**은 무엇인가(SKILL 0단계 물음 5) | 작업하며 지은 이름은 문서 안에서 정의해도 독자가 모른다 — 대응표 없이 쓰기 시작하면 설명형 새 이름을 짓게 된다(SKILL 반드시 지키는 것 12). 작업 폴더 `names.md`(발행 안 함): 우리가 부르던 이름 / 독자 화면·문서의 이름 / 그 이름이 보이는 자리. 자료(화면 캡처·콘솔·원본 문서)에서 먼저 찾고 못 찾은 것만 한 번에 묻는다 | 「예비판 → <독자 화면의 채널 이름> 앱 · 고객 관리자 화면의 채널 목록」 |
54
57
 
55
58
  ### 이 답에서 곧바로 정해지는 것
56
59
 
57
60
  - **모드** — 판정한다 / 배운다 / 결정한다 셋 중 하나(SKILL.md 「세 가지 독자」). 결정한다면 `doc_type` 에 「브리핑」을 적어 렌더러 ⚠ 를 켠다.
58
61
  - **금지 어휘 목록** — 독자가 검증할 수 없는 내부 어휘와 식별자.
59
- 로컬 렌더라면 `forbid` 정규식으로 적고, 포털 발행이라면 손으로 지킨다(7단계 참조).
62
+ `forbid` 정규식으로 적는다 — 로컬 렌더는 걸리면 멈추고, 포털 발행은 경고로 센다(6단계 「`forbid`」).
63
+ - **부를 이름** — 원고에서 부를 대상마다 독자 화면·문서의 이름(위 표 마지막 물음의 대응표). 원고와 용어 잠금 표는 이 이름만 쓴다.
60
64
  - **PART 구성** — 독자가 바뀌면 읽기 순서가 바뀐다.
61
65
  검수자에게는 「지금 부탁드리는 것」이 PART 1 이지만, 사후 열람자에게는 「경과」가 앞이다.
62
66
  - **절 구성(`sections`)** — 독자가 바뀌면 절이 통째로 없어진다.
63
67
  전 건 검수자에게는 `status` 절의 대조표가 핵심이지만, 결정권자 독자에게는 요약면 상태 막대만 남기고 대조표를 빼는 것이 맞다.
64
68
  - **말을 거는 자리의 종결** — 독자가 **사람**이면 그 사람에게 직접 하는 말은 하십시오체로 맺는다
65
- (`cta`·`open_items`·PART I 간지·판권지 회신 안내). 우리가 한 일을 적는 기록 자리만 개조식이다.
69
+ (`cta`·`open_items`·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄 — 사실 문장까지, 목록의 정본은 SKILL 5). 그 밖의 자리에는 우리·사물이 주어인 개조식만 쓰고, 독자가 할 일은 두지 않는다(SKILL 5).
66
70
  독자를 사람으로 정해 놓고 문체는 상수로 두면, 문서가 사람이 아니라 대장에게 말한다.
67
71
  자세한 것은 SKILL 5·5a(경위: `references/history.md` §5).
68
72
 
@@ -79,7 +83,7 @@ python3 ~/.claude/skills/genetics-report/assets/render.py report.json
79
83
 
80
84
  | 규칙 | 근거 | 어기면 |
81
85
  |---|---|---|
82
- | 티켓 본문만 읽지 않는다. **코멘트·스레드까지** 읽는다 | 실제 조치 내용은 본문이 아니라 코멘트에 적히는 것이 보통이다 | `fix` 칸이 통째로 빈다. 제목만 보고 쓴 건은 전부 「확인 필요」가 된다 |
86
+ | 티켓 본문만 읽지 않는다. **코멘트·스레드까지** 읽는다 | 실제 조치 내용은 본문이 아니라 코멘트에 적히는 것이 보통이다 | `fix` 칸이 통째로 빈다. 제목만 보고 쓴 건은 전부 「[미확인]」이 된다 |
83
87
  | 건이 많으면 N건씩 묶어 병렬로 읽는다 | 품질이 아니라 속도 문제다 | (느릴 뿐) |
84
88
  | 제보 출처 링크가 있으면 **그대로** 보존한다 | `cases[].source_url` → 건 필드표의 제보 원본 링크 | 접수 건과 자체 발견 건을 가르는 근거가 사라진다 |
85
89
  | 링크는 **독자가 아는 것만** 남긴다 | 독자가 조회할 수 없는 주소는 확인 수단이 아니라 노이즈다 | 문서가 독자가 아니라 자기 조직을 향해 말한다 |
@@ -103,8 +107,8 @@ grade grades 키 중 하나
103
107
  symptom 무엇이 잘못 보였는가
104
108
  cause 왜 그랬는가 (한 문장)
105
109
  fix 이제 어떻게 되는가
106
- how_to_check 고객이 자기 화면에서 밟을 수 있는 순서
107
- remaining 우리가 못 닫은 것 (없으면 생략)
110
+ how_to_check 고객이 자기 화면에서 볼 수 있는 결과 (주어는 화면·값)
111
+ remaining 우리가 못 닫은 것 — 미확인·미결인 사실 (부탁은 요청 절. 없으면 생략)
108
112
  source_url 제보 원본 링크 (없으면 생략. http(s) 필수)
109
113
  ```
110
114
 
@@ -116,15 +120,15 @@ source_url 제보 원본 링크 (없으면 생략. http(s) 필수)
116
120
  > · 파일명·함수명·커밋해시·SQL·클래스명을 쓰지 마라. 고객은 모른다.
117
121
  > · 「무엇이 잘못 보였는가 → 왜 그랬는가 → 이제 어떻게 되는가」 순서로 쓴다.
118
122
  > · 원인은 한 문장으로, 비유 없이, 사실만. 변명하지 마라.
119
- > · 고객이 **직접 확인할 수 있는 방법**이 있으면 한 줄 덧붙인다.
123
+ > · 모든 건에 고객이 **자기 화면에서 볼 수 있는 결과**를 한 줄 적는다 — 어디를 열면 무엇이 보이는지, 주어는 화면·값.
120
124
  > · 아직 안 끝난 게 있으면 숨기지 말고 「남은 것」으로 적는다.
121
125
  > · 과장 금지. 「완벽히 해결」 대신 무엇을 확인했는지 쓴다.
122
126
  >
123
- > 근거가 없으면 지어내지 말고 그 필드를 **「확인 필요」** 로 남긴다.
127
+ > 근거가 없으면 지어내지 말고 그 필드를 **「[미확인]」** 으로 남기고 사용자에게 되묻는다(발행 전 0 — 문서 `forbid` 에 `\[미확인\]` 을 넣으면 로컬 렌더가 멈춘다).
124
128
 
125
129
  | 규칙 | 근거 | 어기면 |
126
130
  |---|---|---|
127
- | 근거 없는 필드는 「확인 필요」로 남기고 사용자에게 되돌린다 | 추측이 사실 진술의 활자로 인쇄되면 회복 불가능하다 | 발송 후에는 회수할 수 없다 |
131
+ | 근거 없는 필드는 「[미확인]」으로 남기고 사용자에게 되돌린다 | 추측이 사실 진술의 활자로 인쇄되면 회복 불가능하다 | 발송 후에는 회수할 수 없다 |
128
132
  | `how_to_check` 는 **전 건 필수** | 없으면 검수 요청이 아니라 통보다 | 기계가 안 잡는다. 사람이 건 수와 대조한다 |
129
133
 
130
134
  ### `title` 은 티켓 제목이 아니다
@@ -143,7 +147,7 @@ source_url 제보 원본 링크 (없으면 생략. http(s) 필수)
143
147
  실측(2026-08-24): `{"from":"레코드","to":"항목"}` 을 넣고 원고에 「레코드」를 심어 렌더했더니 **「레코드」가 그대로 산출물에 남았다.**
144
148
 
145
149
  그러므로 **고객 언어 치환은 원고에서 직접 한다.**
146
- 치환 누락을 잡는 그물은 `forbid` 하나뿐이고, 그것도 로컬 렌더에서만 돈다(6단계).
150
+ 치환 누락을 잡는 그물은 `forbid` 하나뿐이다 — 로컬 렌더는 걸리면 멈추고, 포털 발행은 경고로 센다(6단계 「`forbid`」).
147
151
 
148
152
  ---
149
153
 
@@ -208,25 +212,24 @@ RANK_TAG = ['live', 'data', 'done', 'soon'] # 배지 종류
208
212
  원고를 쓰기 전에 핸드오프에 **상태 표**(무엇 · 코드 반영 · 시험판 전달 · 실기기 · 운영, 칸마다 근거)를 만들고, 문서의 동사는 이 표의 확인 수준을 넘지 않게 고른다.
209
213
  반영 단계 어휘는 `references/content-pipeline.md` 「4.2 치환 유형」 B 행을 따른다. 실기기 확인이 없으면 같은 문장에 그렇다고 적는다.
210
214
  판정 규칙은 이용자가 확인할 수 있는 동작으로만 적고, 구현 규칙(어느 키로 판정하는지)은 싣지 않는다.
211
- **커밋·브랜치·파일·시험 도구·시험 건수는 이 표에만** 둔다 — 원고에 새면 `gazette.py` `text_scan()` 개발 식별자 ⚠(두 경로 — ⚠ 이 행은 운영 반영 확인 전이라 배포 전에는 로컬 렌더에만 있다, lim-portal-format §10-1 머리).
215
+ **커밋·브랜치·파일·시험 도구·시험 건수는 이 표에만** 둔다 — 원고에 새면 `gazette.py` `text_scan()` 개발 식별자 ⚠(두 경로).
212
216
  확인 수준보다 앞선 완료형 동사는 기계가 못 가린다 — 독자 검토에 `args.state` 로 이 표를 주면 state 관점이 대조하고, 나머지는 사람의 눈(SKILL 반드시 지키는 것 3).
213
217
 
214
218
  ### 문체
215
219
 
216
- **자리가 정한다.** 요약·건·절 도입문처럼 **우리가 한 일을 적는 기록 자리**는 개조식
217
- (`~함` / `~했음` / `~임`). **요청 자리**(`cta`·`open_items`·PART I 간지·판권지 회신 안내)는
220
+ **주어와 자리가 정한다.** 요청 자리 밖(요약·건·절 도입문·여백 주석·문단·표·부록)에는 우리·사물·화면이 주어인 문장만 두고 개조식
221
+ (`~함` / `~했음` / `~임`). 독자가 할 일은 **요청 자리**(`cta`·`open_items`·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지
218
222
  **하십시오체**(`~하여 주시기 바랍니다` / `~부탁드립니다` / `~하겠습니다`).
219
- 자리별 규율은 SKILL 5·5a, 대응표는 `references/lim-portal-format.md` §14-1·§14-2.
223
+ 규율은 SKILL 5·5a, 대응표는 `references/lim-portal-format.md` §14-1·§14-2.
220
224
 
221
225
  > · 개발 용어(파일명·커밋해시·SQL·클래스명) 금지. **배포 단계 용어(스테이징/테스트환경) 금지** — 반영 단계는 위 「코드 작업을 녹일 때」의 어휘로.
222
226
  > · 결론을 먼저 쓴다. 각 문단은 리드 문장 하나로 시작한다.
223
227
  > · 과장 금지. 확인한 것과 확인하지 못한 것을 구분해서 쓴다.
224
228
 
225
- 기록 자리에서 문체를 안 바꾸면 낱말이 아니라 **조판의 문제**로 남는다 — 경어체 서술문은
229
+ 요청 자리 밖에서 문체를 안 바꾸면 낱말이 아니라 **조판의 문제**로 남는다 — 경어체 서술문은
226
230
  관보 문법 위에서 겉돈다. 반대로 요청 자리에서 개조식을 고집하면 **하대로 읽힌다** — 이쪽이
227
- 네 번 반려된 자리다(SKILL 5a). 서버 `report_tone.php report_tone_warnings()` 는 요청 자리(`open_items` 본문·`cta`·판권지 `note`·`asks` 절 여백 주석)와 「」 인용을 안 세고,
228
- 로컬 `tone.mjs` 는 판단 항목 본문(`p.ask__body`)과 「」 인용만 뺀다. 그래서 PART I 간지의 요청 문장(두 경로)과 로컬의 `cta`·판권지 회신 안내에서 나는 경어체 경고는 **사람이 걸러 읽고 그대로 둔다**.
229
- ⚠ 서버 쪽 자리 가림은 **운영 반영 확인 전**이다 — 배포 전의 운영 `dry_run` 은 요청 자리에서도 경어체를 세므로, 그 경고는 자리를 확인하고 넘긴다(lim-portal-format §10 통과 기준 표 셋째 행).
231
+ 거듭 반려된 자리다(SKILL 5·5a · 경위 history.md §5). 서버 `report_tone.php report_tone_warnings()` 는 요청 자리(`open_items` 본문·`cta`·판권지 `note`·`asks` 절 여백 주석)와 「」 인용을 안 세고,
232
+ 로컬 `tone.mjs` 는 요청 자리 가운데 넷(판단 항목 본문·`cta`·요청 PART 의 PART I 간지·판권지 회신 안내)과 「」 인용을 뺀다(2026-09-26). 읽는 법(`guide`)의 요청 한 줄은 두 경로 모두 센다. 그래서 남는 경어체 경고는 서버의 PART I 간지(요청 PART 일 때) 문장과 두 경로의 읽는 법 요청 한 줄이고, 둘 다 의도이므로 **사람이 걸러 읽고 그대로 둔다**.
230
233
 
231
234
  ### `summary` — 검수·보고판 4개의 `{lead, body, refs}`
232
235
 
@@ -237,7 +240,7 @@ RANK_TAG = ['live', 'data', 'done', 'soon'] # 배지 종류
237
240
  1. 접수된 N건이 어떤 성격이었고 지금 어떤 상태인가
238
241
  2. 확인 과정에서 함께 발견해 고친 것 중 영향이 컸던 것
239
242
  3. 여러 건을 관통하는 **공통 원인**이 있다면 그것 — **실재할 때만 쓴다.** 없는데 만들어 내면 그게 이 문서에서 가장 나쁜 슬롭이다
240
- 4. 검토 요청 — 무엇을 봐 주면 되는지
243
+ 4. 검토 요청 — 요청 절에 남은 항 이름과 기한
241
244
 
242
245
  「결정한다」판은 4~6문단이고 순서가 다르다 — 무엇을 언제까지 결정하는가 / 검토한 선택지와 편익·비용·불확실성 /
243
246
  확인한 사실과 근거의 등급·성숙도(권고 없음 — 의견은 별도 절 한 자리) / 결정 뒤 이어지는 것(SKILL.md 4~6단계 브리핑판).
@@ -275,19 +278,18 @@ RANK_TAG = ['live', 'data', 'done', 'soon'] # 배지 종류
275
278
  요청 절이 없는 문서가 확인 못 한 것을 `note` 절에 모을 때도 같다 — 건의 `remaining` 을 문단마다 옮겨 적지 않고
276
279
  성격(작업자 미상 · 기록 없음 · 현장에서만 가려짐 같은)으로 묶고, 건은 번호로 가리킨다.
277
280
 
278
- > 이건 고객 숙제 목록이 아니라 **우리가 확정하지 못한 것을 밝히는** 절이다.
279
- > 주어를 우리로 써라.
281
+ > 요청 절은 독자가 할 일이 서는 유일한 자리다. 검수·보고 문서의 확인 요청 항은 **우리가 확정하지 못한 사실을 먼저** 적고, 부탁은 하십시오체로 맺는다(결정 문서의 판단 항목은 본문 첫 문장이 기한 — SKILL 0.5단계 5행).
280
282
 
281
- 이 한 줄이 문서의 성격을 바꾼다.
282
- 같은 데이터를 「고객이 해야 할 일」로 쓰면 책임 전가 문서가 되고, 「우리가 아직 못 닫은 것」으로 쓰면 신뢰 문서가 된다.
283
- 그래서 검수·보고 문서의 `open_items[].title` 은 전부 1인칭이다 — `검증에 쓴 표본이 작음`, `같은 전화번호를 쓰는 계정의 주인은 우리가 판단할 수 없음`.
283
+ 이 순서가 문서의 성격을 바꾼다.
284
+ 같은 데이터를 「고객이 해야 할 일」만으로 쓰면 책임 전가 문서가 되고, 「우리가 아직 못 닫은 것」을 먼저 밝히면 신뢰 문서가 된다.
285
+ 검수·보고 문서의 `open_items[].title` 은 「~ 요청」+기한 괄호(SKILL 5a)이고, 본문 첫 문장이 우리가 못 닫은 사실이다 — 「검증에 쓴 표본이 작습니다」·「같은 전화번호를 쓰는 계정의 주인은 저희가 판단할 수 없습니다」.
284
286
  결정 문서(브리핑)의 판단 항목은 다르다 — 고객이 고를 **물음**이다(SKILL 0.5단계 5행 · lim-portal-format §12.5b).
285
287
  독자의 할 일이 둘이면(결정 + 시험판 확인) 둘째는 `open_items` 두 번째 건 「… 확인 요청」으로 두고 본문 첫 문장에 상대 기한을 넣는다 —
286
- 표지·현황 줄에는 사실문으로만 올린다. 읽는 법(`guide`)에는 요청 절의 확인 요청을 가리키는 요청 한 줄을 둘 수 있다(2026-09-15 사용자 결정) — 그 밖의 요청문은 두지 않는다(보고 자리, SKILL 5a). 집행자: `gazette.py` `brief_checks()` 기한 ⚠(「확인 요청」 문구로 갈림) · 독자 검토 decider 관점.
288
+ 표지·현황 줄에는 사실문으로만 올린다. 읽는 법(`guide`)에는 요청 절의 확인 요청을 가리키는 요청 한 줄을 둘 수 있다(2026-09-15 사용자 결정) — 그 밖의 요청문은 두지 않는다(요청 자리 밖, SKILL 5). 집행자: `gazette.py` `brief_checks()` 기한 ⚠(「확인 요청」 문구로 갈림) · 독자 검토 decider 관점.
287
289
 
288
290
  | 규칙 | 어기면 |
289
291
  |---|---|
290
- | 확인 요청 절의 주어는 **우리**다 | 마무리 못 한 쪽이 상대에게 일을 넘기는 문서가 된다 |
292
+ | 검수·보고 문서의 확인 요청 항은 **우리가 못 닫은 사실**을 먼저 적는다 | 까닭 없이 할 일만 넘기면 마무리 못 한 쪽이 상대에게 일을 넘기는 문서가 된다 |
291
293
  | 확인한 것과 확인 못 한 것을 **문장 안에서 구분**한다 | 등급 축과 본문이 어긋나고, 검수 독자는 그 어긋남을 가장 먼저 찾아낸다 |
292
294
  | 파괴적 동작은 결과를 같이 적고 **두 곳에 중복**한다(요약 + `asks`) | 한 곳에만 있으면 훑어 읽는 독자가 놓친다. 이 중복은 실수가 아니라 설계다 |
293
295
 
@@ -324,7 +326,7 @@ RANK_TAG = ['live', 'data', 'done', 'soon'] # 배지 종류
324
326
  |---|---|---|
325
327
  | `why` | 그 절을 **왜** 읽는가. 독자가 실제로 던질 질문 그대로 | `~하는가?` / `~인가?` (`~합니까?` 아님) |
326
328
  | `key` | 이 절에서 하나만 가져간다면 그것 | 개조식 단문 |
327
- | `advice` | 어디를 참조해야 하는지. 근거의 출처를 가리킨다 | 개조식 단문 |
329
+ | `advice` | 더 볼 곳. 근거의 출처를 가리킨다 | 명사구(「03절 대조표」) 또는 사물이 주어인 단문(「판정 기준은 부록 1에 있음」) — 「~할 것」「~ 참조」 아님 |
328
330
 
329
331
  셋 중 채운 것만 그려진다.
330
332
  하나도 없으면 `<aside class="marg">` 자체가 안 나오고 본문이 한 단이 된다.
@@ -431,20 +433,21 @@ RANK_TAG = ['live', 'data', 'done', 'soon'] # 배지 종류
431
433
  | 층 | 무엇을 잡는가 | 결과 |
432
434
  |---|---|---|
433
435
  | 서버 검증 | 모르는 `kind` · 없는 그룹 · 없는 PART · 요약 근거 · `source_url` 모양 · 목록/객체 모양 | **발행 거절** |
434
- | 렌더러 assert | `link()` 를 안 거친 `§n.n` · `forbid` 정규식 적중 | **렌더 중단(파일 안 씀)** |
435
- | 렌더러 ⚠ | `marg` 없음 · `parts` 없음 · 그룹 미배정 건 · 자리 없는 그림 · 시각 우선·글 층·도판 출처 줄·그림 번호·개발 식별자(`lim-portal-format.md` 「10. 경고 조건」) | 경고만 |
436
+ | 렌더러 assert(로컬 렌더) | `link()` 를 안 거친 `§n.n` · `forbid` 정규식 적중 | **렌더 중단(파일 안 씀)** |
437
+ | 서버 ⚠(포털 발행) | 문서의 `forbid` 적중 · 세지 못한 패턴(`report_publish.php report_forbid_warnings()`) · 소속처 반려 이름 목록을 못 읽음·깨진 결정 장·상한 초과(`report_publish.php report_rejected_names()`) | 경고만 |
438
+ | 렌더러 ⚠ | `marg` 없음 · `parts` 없음 · 그룹 미배정 건 · 자리 없는 그림 · 시각 우선·글 층·도판 출처 줄·그림 번호·개발 식별자 · 대화 이력·「탓」·소속처 반려 이름(목록은 포털이면 서버가, 로컬이면 `render.py` 가 `list_rejected_names` 로 받아 얹는다 — `lim-portal-format.md` 「10. 경고 조건」) | 경고만 |
436
439
 
437
- ### ⚠ `forbid` 는 **로컬 렌더에서만** 돈다 (코드 확인)
440
+ ### ⚠ `forbid` — 로컬 렌더는 멈추고, 포털 발행은 경고로 센다 (코드 확인)
438
441
 
439
442
  `build.py guard()` 가 `cfg['forbid']` 를 읽어 산출물을 훑고, 걸리면 assert 로 죽는다.
440
443
  실측(2026-08-24): 금지어 「티켓」을 원고에 심고 `render.py` 를 돌리면 `AssertionError: 금지 표현 유출: 티켓 → ['티켓']` 으로 멈추고 파일이 안 써진다.
441
444
 
442
- 그런데 포털 발행에서는 이 그물이 없다.
443
- `forbid` 는 `REPORT_CLIENT_KEYS` 에 없어서 `report_publish.php report_client_fields()` 가 **깎아 낸다**(주석이 「서버만 정한다」고 명시).
444
- 서버는 그 값을 따로 안 채운다 — 빈 채로 렌더러에 닿는다.
445
+ 포털 발행에서는 이 칸이 렌더러에 안 넘어간다 — `REPORT_CLIENT_KEYS` 에 없어 `report_publish.php report_client_fields()` 가 렌더러 몫에서 뺀다.
446
+ 대신 그 앞에서 `report_publish.php report_forbid_warnings()` 가 깎기 전 목록을 떠서 발행 데이터의 글자 칸마다 맞추고, 걸린 자리를 5곳까지 짚어 **경고**로 싣는다 — 모의 발행과 실발행이 같이 지나고, 막지 않는다(2026-09-26).
447
+ 두 경로의 판정은 갈릴 수 있다 — 로컬은 렌더된 HTML 을, 포털은 원고 글자 칸(`data` 한 칸만 뺌 — 렌더러가 안 그리는 `generated_by`·`work` 포함)을 본다.
448
+ 포털은 PCRE 로 맞추고, 세지 못한 패턴(정규식이 아님·빈 문자열에 맞음·실행 상한·200자 넘음·50개 넘음)은 「세지 못한 패턴이 N개」 경고로 알린다 — 그 패턴의 적중은 0 이 아니라 미확인이다.
445
449
 
446
- 포털 발행이 최종 경로라도 **발행 전에 같은 데이터를 로컬로 한 번 구워서 `forbid` 그물을 통과시켜라.**
447
- 고객사·제품명처럼 문서마다 다른 금칙 값의 자동 관문은 지금 이것뿐이다 — 개발 식별자·과정 어휘는 두 경로 다 `gazette.py` `text_scan()` ⚠ 가 센다(⚠ 2026-09-14 에 더한 행은 운영 반영 확인 전 — 배포 전에는 로컬 렌더만, lim-portal-format §10-1 머리).
450
+ 고객사·제품명처럼 문서마다 다른 금칙 값의 자동 관문은 이 목록뿐이다 — 개발 식별자·과정 어휘는 두 경로 다 `gazette.py` `text_scan()` ⚠ 가 센다.
448
451
 
449
452
  ---
450
453
 
@@ -467,7 +470,7 @@ RANK_TAG = ['live', 'data', 'done', 'soon'] # 배지 종류
467
470
  1. **렌더러가 센 것** — `marg` 없음 · `parts` 없음 · 그룹 미배정 건 · 자리 없는 그림 · `text_scan()`·`brief_checks()`·`tally_scan()` 경고. 이것이 먼저 온다.
468
471
  2. **입고 검사** — 렌더 산출물이 발행 규격을 지켰는가(본문 링크의 바깥 호스트도 여기서 센다).
469
472
  3. **그림 관문** — 자산 재사용 등.
470
- 4. **문체 관문** — `report_tone.php report_tone_warnings()`: 기록 자리 경어체 · 상대 행위의 명령형 · 보고 칸의 요청문. 맨 뒤에 온다.
473
+ 4. **문체 관문** — `report_tone.php report_tone_warnings()`: 요청 자리 밖 경어체 · 상대 행위의 명령형 · 보고 칸의 요청문. 맨 뒤에 온다.
471
474
 
472
475
  2026-08-18 이전에는 이 값이 **한 번도 도구에 도달하지 않았다**(서버가 `error_log` 로만 흘렸다) — 에이전트는 「경고 없음」으로 읽었고 확인할 방법이 없었다.
473
476
  지금은 dry_run 과 실제 발행 응답 **양쪽에** 실린다.
@@ -530,8 +533,8 @@ RANK_TAG = ['live', 'data', 'done', 'soon'] # 배지 종류
530
533
  | 여백 주석 | 본문 첫 문장의 복사본이 아닌가. 셋을 채우려고 넣은 줄이 없는가 |
531
534
  | 건 필드표 | 증상→원인→조치→확인 방법 순서가 전 건 같은가 |
532
535
  | 대조표 | 등급 표식이 강한 순으로 서는가. 5번째 등급이 눌려 있지 않은가 |
533
- | 기록 자리 | 경어체 잔존 0건인가(`습니다|십시오|합니다|입니다|됩니다|주세요`) |
534
- | 요청 자리 | 반대다 — `cta`·`open_items`·PART I 간지·판권지 회신 안내가 **하십시오체로 맺는가**. 명사형 종결(`부탁드림`·`바람`·`하시면 됨`)이 남아 있지 않은가 |
536
+ | 요청 자리 밖 | 경어체 잔존 0건인가(`습니다|십시오|합니다|입니다|됩니다|주세요`). 독자가 주어인 문장(「~할 것」「~해야 함」「~면 됨」「~몫임」「확인 필요」「~ 참조」)이 없는가 — 기계는 `how_to_check` 의 명령·당위까지만(SKILL 5) |
537
+ | 요청 자리 | 반대다 — `cta`·`open_items`·PART I 간지·판권지 회신 안내가 **사실 문장까지 하십시오체로 맺는가**. 명사형 종결(`부탁드림`·`바람`·`하시면 됨`)이 남아 있지 않은가 |
535
538
  | 본문 전체 | 낱말 중간에서 줄이 바뀐 곳이 없는가 |
536
539
  | 본문 전체 | 독자가 조회할 수 없는 식별자가 남아 있지 않은가 — 해시·코드 파일·개발 도구·커밋·브랜치 같은 개발 식별자는 `gazette.py` `text_scan()` ⚠(두 경로), 고객사·제품명 같은 문서마다 다른 금칙 값은 포털 발행에 그물이 없어 **여기가 마지막 관문이다** |
537
540
  | 본문 전체 | (코드 작업을 녹인 문서) 「반영함·보이지 않음·빠짐」 같은 완료형 동사가 원고 밖 상태 표의 확인 수준(코드·시험판·실기기·운영)을 넘지 않는가 — 기계 없음, 독자 검토 state 관점(`args.state`) · 사람의 눈(4단계 「코드 작업을 녹일 때」) |
@@ -701,14 +704,15 @@ MCP 가 찍는 요약 줄은 **옛 서식의 셈**만 읽는다(`genetics.mjs ou
701
704
  |---|---|---|---|
702
705
  | 1 | 독자 문장 1개 (하는 일로) + 할 일 목록 | (문서 아님 — 작업 전제) | 0 |
703
706
  | 1a | 정해진 것의 결정권·정한 주체·기록·확인 여부 | 작업 폴더 `decisions.json`(발행 안 함) | 0a |
704
- | 2 | 금지 어휘·식별자 정규식 | `forbid` (로컬 렌더에서만 돈다) | 0·6 |
707
+ | 2 | 금지 어휘·식별자 정규식 | `forbid` (로컬 렌더는 멈춤 · 포털 발행은 경고) | 0·6 |
708
+ | 2a | 부를 이름 대응표(우리가 부르던 이름 / 독자 화면·문서의 이름 / 보이는 자리) | 작업 폴더 `names.md`(발행 안 함) | 0 |
705
709
  | 3 | PART 구성 | `parts` | 0·6 |
706
710
  | 4 | 절 구성과 순서 | `sections` + `groups` | 0·6 |
707
711
  | 5 | 건별 필드 | `cases` | 1·2 |
708
712
  | 6 | 보고서 표제로 재작성한 전 건 제목 | `cases[].title` | 2 |
709
713
  | 7 | 등급 이름·정의문 (**4단 이하** · 최약 등급에 한계 명시) | `grades` | 3 |
710
714
  | 8 | 요지(칸 수는 SKILL 4~6단계) + **문단마다 근거** | `summary` (+ `refs`) | 4 |
711
- | 9 | 확인 요청 3~6개 (주어는 우리) | `open_items` | 4 |
715
+ | 9 | 확인 요청 3~6개(검수·보고는 우리가 못 닫은 사실을 먼저, 부탁은 하십시오체) | `open_items` | 4 |
712
716
  | 10 | **절마다 여백 주석** | `sections[].marg` | 5 |
713
717
  | 11 | 표지·발행 이력·활용법 | `doc_no`·`client`·`title`·`title_sub`·`subtitle`·`meta`·`issuer`·`history`·`guide` | 6 |
714
718
  | 12 | 요약면 | `notice`·`bar`·`bar_note`·`stats` | 6 |
@@ -734,9 +738,9 @@ MCP 가 찍는 요약 줄은 **옛 서식의 셈**만 읽는다(`genetics.mjs ou
734
738
  3. **관보 셈(`ask`·`sheet`·`board`·`marg` 등)을 MCP `outline` 요약 줄이 안 찍는 것.**
735
739
  서버는 계산해서 실어 준다.
736
740
  도구를 고칠지, 블록 목록으로 판정하는 지금 방식을 유지할지 판단하지 않았다.
737
- 4. **포털 발행에 `forbid` 그물이 없는 것.**
738
- 서버가 의도적으로 뺀 키인지(파일을 읽는 키와 같은 취급), 관보 전환에서 빠진 것인지 확인 못 했다.
739
- 현재 처방은 「발행 전에 로컬로 한 번 굽는다」이고, 이것은 우회이지 해결이 아니다.
741
+ 4. **포털 발행의 `forbid` 는 경고이고 로컬은 멈춘다.**
742
+ 2026-09-26 전에는 포털 입구가 이 칸을 깎기만 해서 그물이 없었다. 지금은 `report_publish.php report_forbid_warnings()` 가 경고로 센다.
743
+ 두 경로의 태도(멈춤 · 경고)와 훑는 자리(렌더된 HTML · 원고 글자 칸)가 달라 판정이 갈릴 수 있다. 하나로 맞출지는 판단하지 않았다.
740
744
  5. **`text_fix` 를 서버가 계속 받는 것.**
741
745
  관보 렌더러가 안 읽는데 화이트리스트에 남아 있다.
742
746
  지울지, 관보 경로에 붙일지 판단하지 않았다.
@@ -1,6 +1,7 @@
1
1
  // 독자 검토 워크플로 — 발행 전에 원고를 여덟 관점(상태 표가 있으면 아홉)으로 읽히고, 발견을 반박 검증해 거른 뒤 합친다(visual-first.md §7).
2
2
  // 실행: Workflow 도구에 이 파일을 scriptPath 로 주고 args 를 넘긴다. 배열·객체는 JSON 값 그대로 넘긴다(문자열로 싸면 한 덩어리가 된다).
3
3
  // args = { file: "<report.json 절대경로>",
4
+ // text: "<렌더본 텍스트 절대경로(필수) — `python3 assets/render.py <report.json> --text <원고 폴더 밖 빈 폴더>/본문.txt`>",
4
5
  // reader: "<독자 한 줄: 누구이며 무엇을 하나 — 할 일이 둘이면 둘 다>",
5
6
  // example: "<예시 한 명 한 줄: 번호·측정일·실제 값>", bitmapBudget: 3,
6
7
  // scratch: "<측정 스크립트·풀어낸 도판을 둘 스크래치 절대경로>",
@@ -9,13 +10,21 @@
9
10
  // sources: [{ name: "<자료 이름>", path: "<절대경로>", kind: "code|doc|pdf" }, …],
10
11
  // decisions: "<0단계 결정 장부 decisions.json 경로(선택)>",
11
12
  // state: "<코드 작업 상태 표 경로(선택) — 주면 state 관점이 켜진다>" }
12
- // 단계: Read(관점 병렬) → Verify(severity≥3 최대 12건 반박) → JS 거름(dropped·questions·사실 틀림 가르기) → Merge(합쳐 상위 10).
13
- // 돌아오는 것: { merged: { findings[](ids·where·problem·severity·class·evidence·rewrite·visual·verdicts), overall },
13
+ // 단계: Read(관점 병렬) → Verify(severity≥3 최대 12건 반박 — 읽기·구조 발견에 4칸 이상) → JS 거름(dropped·questions·사실 틀림 가르기) → Merge(합쳐 상위 10).
14
+ // 돌아오는 것: { merged: { findings[](ids·where·problem·severity·class·evidence·rewrite·visual·rx·verdicts), overall },
14
15
  // questions[](작성자가 사람에게 물을 것), passed[](관점별로 재서 통과한 것 — 「안 봄」과 가른다),
15
16
  // dropped[](버린 발견 + reasons — 사람이 되살릴 수 있게), jumps[](linear 관점의 뜀 지점 전부 — 0 이 목표),
17
+ // names[](class 이름 발견 전부 — 문서가 붙인 이름·정의 없는 이름, 0 이 목표),
16
18
  // raw: { reads, verdicts, unverified } }
19
+ // 검증이 1건이라도 실패하거나 관점이 결과를 못 내면 맨 앞에 head 「검토 미완 — 검증 N건 실패」(관점 실패는 뒤에 이어 적는다)와
20
+ // failed: true 가 붙고, merged.overall 첫 줄도 같은 문장이다 — 실패를 「발견 0」으로 읽지 않게.
21
+ // 렌더본 관점(first-time·linear)이 파일을 못 열었다고 답하면(opened=false) 그 관점의 발견은 쓰지 않고 head 에 「렌더본 못 엶」을 더한다.
22
+ // 시작 전 검사(Check): args.file · args.text 가 없거나, 렌더본 파일이 없거나 비었거나 원고보다 오래되면 읽기 전에 멈추고 무엇이 필요한지 말한다.
23
+ // 워크플로 스크립트는 파일을 못 연다(Workflow 런타임 — 파일 시스템·Node API 없음). 그래서 에이전트 하나가 정해진 명령 한 줄을 돌려
24
+ // 표준 출력을 글자 그대로 옮기고, 판정은 JS 가 한다. 멈춘 뒤 렌더본을 다시 뽑았으면 resumeFromRunId 없이 새로 띄운다(검사 결과도 캐시를 탄다).
17
25
  // merged.findings 앞에는 사실 틀림(class 사실틀림) 가운데 **Verify 가 참·부분으로 받은 것**이 합치지 않은 독립 항목으로 서고 개수 제한 밖이다.
18
26
  // 미확인으로 판정된 사실 틀림은 합치는 쪽으로 가 problem 끝에 「(미확인)」이 붙는다. 뒤는 합친 상위 10.
27
+ // 발견을 버리는 조건은 진단 거짓 하나다. 처방이 규칙에 걸리면(정해진 것 되돌리기·노이즈·포인터·새 이름 짓기) 발견은 남기고 rx 에 「처방 다시 — 까닭」.
19
28
  // 못 연 도판이 있으면 merged.overall 머리 줄이 「⚠ 도판 N장 미열람」이다. severity<3·상한 초과·검증 실패는 반영하지 않고 raw.unverified 로.
20
29
  // 2026-09-08 고객사 검수 보고 4차에서 이 검토가 용어 충돌(한 낱말 두 값)·수치 불일치·참조 오류·반올림 설명 오류를 잡았다.
21
30
  // 2026-09-11 실사례(고객사 결정 브리핑) 반려 둘에서 더한 것 — decider 관점에 「권고가 제목·열·색·요약에 배어 동의만 받는 틀인지」,
@@ -31,20 +40,48 @@
31
40
  // 2026-09-14 실사례(결정 브리핑 재발행) 반려 둘에서 더한 것 — 새 관점 figures(PNG 를 Read 로 열고 두 칸 틀을 잰다)·facts(내부 1차 자료)·state,
32
41
  // Verify 단계와 JS 거름. 캡션만 읽은 검토가 틀 대칭 결함을 0건 짚었고, 검토자의 틀린 주장·정해진 것을 되돌리는 처방·
33
42
  // 제작 방식·결정 날짜·판 이력을 더하라는 노이즈 처방이 거르지 않은 채 작성자에게 올라갔었다.
43
+ // 2026-09-26 스킬 검토(반려 「독자가 이해 못 함」 계열 19건)에서 바꾼 것 —
44
+ // ① 「처음 읽는 사람」·「선형 읽기」가 처음 읽는 사람이 아니었다: 공통 머리말(정해진 것·결정 장부·사용자 최신 지적·상태 표)을 받았고,
45
+ // 13/23·12/16 이 원고 밖 과정 파일(decisions.json·HANDOFF·build_*.py)을 스스로 열었고, JSON 으로 읽어 독자에게 안 보이는 origin 을
46
+ // 근거로 판정했다. 그래서 두 관점은 렌더본 텍스트 하나만 받고(args.text), CLAUDE.md 를 싣지 않는 Explore 유형으로 돈다.
47
+ // 판정 기준이 「정의가 앞에 있나」뿐이라 문서 안에서 정의한 조어(인스턴스·설정에 지어 붙인 이름)가 통과했다 — 문서가 붙인 이름을 묻는 물음과 class 이름을 더했다.
48
+ // ② 발견이 사람에게 닿지 않았다: 검증 12칸이 사실 틀림(+10 가중)으로 차 읽기 발견이 늘 검증 밖이었고(15회 중 14회),
49
+ // 거를 사유가 하나만 있어도 버려 진단이 참인 발견 11건이 기각됐고, 사용량 한도로 검증 12건이 전부 실패한 실행이 「반영할 발견 0」으로 찍혔다.
50
+ // 그래서 읽기·구조에 4칸을 떼어 두고, 진단(참/부분/거짓/미확인)과 처방 적합을 따로 판정해 거짓만 버리고, 실패는 머리에 적는다.
34
51
  // 집행자: 이 워크플로의 agent 읽기(스크립트) + 거름 규칙(JS, 결정적). 렌더러 ⚠ 는 없음 — 링크·도판·코드를 못 여는 환경이면 그 판정은 「미확인」,
35
52
  // 못 연 도판 수는 overall 머리에 박히고, questions[]·dropped[] 는 사람의 눈으로 넘긴다.
36
53
  // PART·절 사이 되풀이도 렌더러·서버 dry_run 이 세지 않는다 — structure 관점이 유일한 기계 쪽 읽기다(readability R12g).
37
54
  export const meta = {
38
55
  name: 'report-reader-review',
39
- description: '보고서 원고(JSON)를 처음 읽는 사람·결정권자·정보 디자이너·원본 대조·구조·선형 읽기·도판·사실 대조(상태 표가 있으면 상태 대조까지) 관점으로 읽고, 발견을 반박 검증해 틀린 주장·정해진 것 되돌리기·노이즈 처방·포인터 처방을 거른 뒤 합친다',
56
+ description: '보고서를 처음 읽는 사람·선형 읽기(두 관점은 렌더본 텍스트만)·결정권자·정보 디자이너·원본 대조·구조·도판·사실 대조(상태 표가 있으면 상태 대조까지) 관점으로 읽고, 발견을 반박 검증해 진단이 거짓인 것만 거르고(처방이 규칙에 걸리면 「처방 다시」) 합친다',
40
57
  phases: [
58
+ { title: 'Check', detail: '렌더본이 있고 비지 않았고 원고보다 새로운지 — 명령 한 줄' },
41
59
  { title: 'Read', detail: '독자 8관점 병렬(상태 표가 있으면 9)' },
42
- { title: 'Verify', detail: 'severity≥3 최대 12건 반박 · JS 거름' },
60
+ { title: 'Verify', detail: 'severity≥3 최대 12건(읽기·구조 4칸 이상) 반박 · 진단·처방 따로 · JS 거름' },
43
61
  { title: 'Merge', detail: '사실 틀림 독립 · 나머지 상위 10 · 구조 빼기 우선' },
44
62
  ],
45
63
  }
46
64
  const A = args || {}
65
+ const MAKE_TEXT = 'python3 assets/render.py <report.json> --text <원고 폴더 밖 빈 폴더>/본문.txt'
66
+ if (!A.text) throw new Error(`args.text 가 필요하다 — 렌더본 텍스트 파일 절대경로. 「처음 읽는 사람」·「선형 읽기」 관점은 이 파일 하나만 읽는다. 만드는 법: ${MAKE_TEXT}`)
67
+ if (!A.file) throw new Error('args.file 이 필요하다 — 원고 report.json 절대경로. 나머지 관점이 읽고, 렌더본이 이 원고보다 새로운지 잰다')
47
68
  const FILE = A.file
69
+ const TEXT = A.text
70
+
71
+ phase('Check')
72
+ // 렌더본이 있고 비지 않았고 원고보다 새로운지 — 파일 상태는 에이전트가 옮긴 명령 출력으로만 읽고 판정은 여기서 한다(머리 주석 「시작 전 검사」).
73
+ const shq = (p) => `'${String(p).replace(/'/g, `'\\''`)}'`
74
+ 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(TEXT)} ${shq(FILE)}`
75
+ const PRE = { type: 'object', properties: { stdout: { type: 'string', description: '명령의 표준 출력 한 줄 — 글자 그대로' }, exit: { type: 'integer' } }, required: ['stdout', 'exit'] }
76
+ const pre = await agent(`아래 명령을 Bash 로 **한 번만, 글자 그대로** 실행하고 표준 출력 한 줄을 stdout 에 고치지 말고 옮긴다. 종료 코드는 exit 에. 명령을 바꾸거나 다른 명령·파일을 열지 않는다.\n${STAT_CMD}`,
77
+ { label: 'check:files', phase: 'Check', schema: PRE, effort: 'low' })
78
+ let st = null
79
+ try { st = pre && pre.exit === 0 ? JSON.parse(pre.stdout) : null } catch (e) { st = null }
80
+ if (!Array.isArray(st) || st.length !== 2) throw new Error(`시작 전 검사 실패 — 렌더본·원고 파일 상태를 못 읽었다(${pre ? `exit ${pre.exit} · ${String(pre.stdout).slice(0, 200)}` : '검사 에이전트 결과 없음'}). resumeFromRunId 없이 새로 띄운다`)
81
+ if (!st[1].f) throw new Error(`args.file 파일이 없다 — ${FILE}. 원고 report.json 절대경로를 준다`)
82
+ if (!st[0].f) throw new Error(`args.text 파일이 없다 — ${TEXT}. 만드는 법: ${MAKE_TEXT}`)
83
+ if (st[0].b <= 0) throw new Error(`args.text 파일이 비었다 — ${TEXT}. 렌더가 실패했을 수 있다 — 다시 뽑고 출력의 ⚠·오류를 본다: ${MAKE_TEXT}`)
84
+ if (st[0].m < st[1].m) throw new Error(`렌더본이 원고보다 오래됐다 — ${TEXT} 가 ${FILE} 보다 ${Math.round(st[1].m - st[0].m)}초 앞선다. 원고를 고친 뒤 다시 뽑는다: ${MAKE_TEXT} (그다음 resumeFromRunId 없이 새로 띄운다)`)
48
85
  const READER = A.reader || '고객사 기획 담당(비개발자). 판단 요청에 회신해야 한다'
49
86
  const EXAMPLE = A.example || '(예시 한 명 미지정 — 원고에서 찾아 쓰라)'
50
87
  const BUDGET = Number(A.bitmapBudget ?? 3)
@@ -68,13 +105,24 @@ ${LOCKED.length ? LOCKED.map((s) => `- ${s}`).join('\n') : '(지정 없음)'}
68
105
  내부 1차 자료: ${SOURCES.length ? SOURCES.map((s) => `${s.name} [${s.kind}] ${s.path}`).join(' · ') : '(지정 없음 — 앱 동작·내부 수치 판정은 「미확인」)'}
69
106
  ${STATE ? `상태 표: ${STATE}\n` : ''}작성자 기준: 「글은 직관적이고 흐름을 이해하기 쉽게, 보충 설명은 글이 아니라 그림 같은 시각 자료로. 핵심은 굵게, 상태는 칩, 색은 뜻으로」.
70
107
  표기: **굵게** · ==강조== · {{ok|칩}}(톤 ok·wait·bad·info·muted) · 차트 rows[].tone.`
108
+ // 「처음 읽는 사람」·「선형 읽기」 관점의 머리말 — 위 CTX(정해진 것·결정 장부·사용자 최신 지적·내부 자료·상태 표)를 받지 않는다.
109
+ // 독자가 보는 것은 렌더본뿐이라 검토자도 렌더본에 보이는 글만 받는다(render.py --text — origin·alt·스크립트는 빠져 있다).
110
+ const TEXT_LENSES = new Set(['first-time', 'linear'])
111
+ const TEXT_CTX = `[읽기 전용. 파일을 수정하지 말 것. 한글로 답할 것. 철저함: very thorough — 발췌하지 말고 파일 전체를 처음부터 끝까지 읽는다.]
112
+ 읽을 것: ${TEXT} — 보고서 렌더본에서 화면에 보이는 글만 화면 순서대로 뽑은 텍스트(그림·영상 자리는 「[그림]」「[영상]」, 표 칸은 「 | 」).
113
+ 이 파일 하나만 연다. 원고에 없는 지식으로 뜻을 메웠다면 그것 자체가 발견이다. 원고 JSON·같은 폴더의 다른 파일·저장소·메모·다른 문서는 열지 않는다.
114
+ 이 파일을 끝까지 읽었으면 opened=true. 열지 못했거나 비어 있으면 opened=false 로 두고 findings·passed 는 비운 채 overall 에 까닭을 적는다 — 다른 파일로 대신 읽지 않는다.
115
+ 독자: ${READER}
116
+ where 는 이 파일에 보이는 절 번호·절 이름·건 번호·표 이름으로, evidence 는 이 파일의 줄 번호와 그 줄 글자로 적는다.`
117
+ // 문서가 붙인 이름을 묻는다 — 판정 기준이 「정의가 앞에 있나」뿐이면 문서 안에서 정의한 조어가 통과한다(2026-09-26 스킬 검토).
118
+ const NAME_Q = '그리고 묻는다: 대상·판·시험·상태·선택지를 부르는 낱말마다, 독자가 문서 밖(자기 화면 글자, 원본 문서, 공급사 공식 명칭)에서 본 글자인가? 이 문서가 붙인 이름이면 정의가 있어도 발견이다. 처방은 뜻풀이가 아니라 원래 이름으로 바꾸는 것이다. 이 발견은 class 이름으로 단다. 원래 이름을 이 파일만으로 알 수 없으면 rewrite 에 이름을 지어 넣지 말고 「원래 이름으로 바꾼다(이 파일에 없음)」라고 적는다.'
71
119
  const FINDING = { type: 'object', properties: {
72
120
  where: { type: 'string', description: '절·건·항목·도판 id 와 자리' },
73
121
  problem: { type: 'string', description: '독자가 왜 막히는가, 또는 원고가 원본과 어떻게 다른가 — 한 문장' },
74
122
  severity: { type: 'integer', minimum: 1, maximum: 5 },
75
- class: { type: 'string', enum: ['사실틀림', '구조', '읽기', '시각', '묻기'], description: '사실틀림=원고의 수치·인용·앱 동작·상태·그림↔본문이 원본과 다르다 · 구조=PART·절 합치기·빼기 · 읽기=용어·흐름·문장 · 시각=그림·표·흐름도로 바꿈 · 묻기=원고와 자료만으로 못 정해 작성자가 사람에게 물어야 한다(rewrite 에 물을 문장)' },
123
+ class: { type: 'string', enum: ['사실틀림', '구조', '읽기', '이름', '시각', '묻기'], description: '사실틀림=원고의 수치·인용·앱 동작·상태·그림↔본문이 원본과 다르다 · 구조=PART·절 합치기·빼기 · 읽기=용어·흐름·문장 · 이름=대상·판·시험·상태·선택지를 이 문서가 붙인 이름(정의가 있어도)이나 정의 없는 이름으로 부른다(처방은 원래 이름) · 시각=그림·표·흐름도로 바꿈 · 묻기=원고와 자료만으로 못 정해 작성자가 사람에게 물어야 한다(rewrite 에 물을 문장)' },
76
124
  evidence: { type: 'string', description: '연 것·잰 것 — 파일:줄 · 쪽(행) · px 값 · 주소 · 다시 한 계산. 원고 글만 읽고 낸 판단이면 「추정 — 」으로 시작' },
77
- rewrite: { type: 'string', description: '글로 고친다면(개조식 2~4문장). 필요 없으면 빈 문자열' },
125
+ rewrite: { type: 'string', description: '글로 고친다면 그 자리의 문체로 2~4문장 — 요청 자리(open_items·cta·PART I 간지·판권지 회신 안내)는 사실 문장까지 하십시오체, 그 밖은 주어가 우리·사물·화면인 개조식(독자를 주어로 쓰지 않음). 필요 없으면 빈 문자열' },
78
126
  visual: { type: 'string', description: '그림/표/flow/chart 로 바꾼다면 — 종류(bitmap|flow|table|chart|none) + 축·칸·숫자·라벨. 예시 한 명의 숫자로' } },
79
127
  required: ['where', 'problem', 'severity', 'class', 'evidence', 'rewrite', 'visual'] }
80
128
  const PASSED = { type: 'array', description: '재서 통과한 것 — 안 본 것은 넣지 않는다', items: { type: 'object', properties: {
@@ -82,33 +130,44 @@ const PASSED = { type: 'array', description: '재서 통과한 것 — 안 본
82
130
  const FIND = { type: 'object', properties: { findings: { type: 'array', items: FINDING }, passed: PASSED, overall: { type: 'string' } }, required: ['findings', 'passed', 'overall'] }
83
131
  const FIND_FIG = { type: 'object', properties: { ...FIND.properties, unopened: { type: 'array', description: '열지 못한 도판', items: { type: 'object', properties: {
84
132
  id: { type: 'string' }, why: { type: 'string' } }, required: ['id', 'why'] } } }, required: [...FIND.required, 'unopened'] }
133
+ // 렌더본 관점은 파일을 열었는지부터 답한다 — 못 연 관점의 「발견 0」을 「문제 없음」으로 읽지 않게(머리 주석).
134
+ const FIND_TEXT = { type: 'object', properties: { ...FIND.properties, opened: { type: 'boolean', description: '렌더본 파일을 열어 끝까지 읽었으면 true, 못 열었거나 비었으면 false' } }, required: [...FIND.required, 'opened'] }
85
135
  const VERDICT = { type: 'object', properties: {
86
136
  verdict: { type: 'string', enum: ['참', '부분', '거짓', '미확인'] },
87
137
  finding_basis: { type: 'string', enum: ['확인', '추정'], description: '발견의 evidence 가 원본·코드·잰 값에 닿으면 확인, 원고 글만 읽은 짐작이면 추정' },
88
138
  checked: { type: 'string', description: '이번에 직접 연 원본과 자리 — 파일:줄 · 쪽 · px · 주소. 못 열었으면 까닭' },
89
139
  touches_locked: { type: 'boolean' },
90
140
  noise_kind: { type: 'string', enum: ['없음', '제작', '날짜경로', '판이력'] },
141
+ rx_fit: { type: 'string', enum: ['맞음', '다시'], description: '처방(rewrite·visual)이 규칙에 맞나 — 진단(verdict)과 따로 판정한다' },
142
+ rx_why: { type: 'string', description: '다시면 걸린 규칙과 까닭 한 줄, 맞음이면 빈 문자열' },
91
143
  note: { type: 'string', description: '판정 까닭 한두 문장. 부분이면 맞는 쪽과 틀린 쪽' } },
92
- required: ['verdict', 'finding_basis', 'checked', 'touches_locked', 'noise_kind', 'note'] }
144
+ required: ['verdict', 'finding_basis', 'checked', 'touches_locked', 'noise_kind', 'rx_fit', 'rx_why', 'note'] }
93
145
  const MERGED = { type: 'object', properties: { findings: { type: 'array', items: { type: 'object', properties: {
94
- ...FINDING.properties, ids: { type: 'array', items: { type: 'string' }, description: '합친 발견 id 전부' } },
95
- required: ['ids', ...FINDING.required] } }, overall: { type: 'string' } }, required: ['findings', 'overall'] }
146
+ ...FINDING.properties, ids: { type: 'array', items: { type: 'string' }, description: '합친 발견 id 전부' },
147
+ rx: { type: 'string', description: '처방을 다시 써야 하면 「처방 다시 — 까닭」, 아니면 빈 문자열' } },
148
+ required: ['ids', ...FINDING.required, 'rx'] } }, overall: { type: 'string' } }, required: ['findings', 'overall'] }
96
149
  const LENSES = [
97
- ['first-time', '처음 읽는 사람 — 배경을 모른다. 정의 없이 나오는 용어, 앞 절을 안 읽으면 못 따라가는 곳, 한 낱말이 두 값을 가리키는 곳, 문단이 길어 요지가 묻히는 곳, 산문 속 참조 번호 때문에 앞뒤로 오가야 하는 곳. 처방은 그 자리에서 이해되게 하는 것 — 낱말·규칙·예시 선언은 첫 등장 자리에서 한 구절(20자 안)로 풀고, 뒤 절의 번호·표 이름·§ 를 앞 절에서 쓰지 않는다(뒤 절이 앞 절을 되짚는 방향만). 「N절을 보라」「부록에 있다」는 처방이 아니다 — 그것이 시험지를 만든다(2026-09-14).'],
150
+ ['first-time', '처음 읽는 사람 — 배경을 모른다. 정의 없이 나오는 용어, 앞 절을 안 읽으면 못 따라가는 곳, 한 낱말이 두 값을 가리키는 곳, 문단이 길어 요지가 묻히는 곳, 산문 속 참조 번호 때문에 앞뒤로 오가야 하는 곳. 처방은 그 자리에서 이해되게 하는 것 — 낱말은 원래 이름으로 부르고, 필요하면 첫 등장 자리에서 괄호로 한 번 풀고(20자 안), 규칙·예시 선언은 첫 등장 자리에서 한 구절로 두고, 뒤 절의 번호·표 이름·§ 를 앞 절에서 쓰지 않는다(뒤 절이 앞 절을 되짚는 방향만). 「N절을 보라」「부록에 있다」는 처방이 아니다 — 그것이 시험지를 만든다(2026-09-14). ' + NAME_Q],
98
151
  ['decider', '결정권자 — 회신해야 한다. 판단 요청 각 항에서 「무엇을 고르는지(물음) / 두 길이 각각 무엇을 바꾸는지(그대로 두면 · 바꾸면) / 기한」이 3초 안에 읽히는지. 안 읽히는 항의 재구성안과, 항들을 한눈에 묶는 표·관문 흐름도. 수치·링크·건수가 절마다 어긋나는 곳(신뢰를 깨는 것). 권고가 제목·표 열·색(칩·톤)·요약면에 배어 독자가 동의만 하게 되는 틀(답정너)인지 — 배인 자리마다 짚고, 물음과 두 길 대칭으로 되돌린 재구성안. 그리고 둘을 더 본다: (1) 독자의 **할 일마다**(위 「독자」 줄의 할 일 목록) 그것이 표지 meta · 요약면 현황 줄(notice) · 읽는 법(guide) 첫 물음에 서는지 대조한다 — 한 자리라도 빠지면 severity 4 로 짚고 어느 자리에 무엇을 넣을지 rewrite 에 적는다(요청문이 아니라 사실문으로). (2) 요약면 수치 칸(stats)끼리 **겹치는 집합**인지 — 한 수가 다른 수에 포함되면(전체 건 수와 그중 일부) 하나를 빼거나 라벨에 관계를 드러내는(「그중 …」) 재구성안을 적는다. (3) 고객 자리에서 읽어 **우리를 어떻게 보이게 하는 문장**(자격증명이 오간 경로, 우리가 늦게 안 것, 우리 절차 미비)이 있으면 severity 4 로 짚고 rewrite 는 「그 문장 삭제, 권고만」 — 그 사건 범위 안의 조언은 남긴다.'],
99
152
  ['visual', '정보 디자이너 — 글로 설명하는데 그림이면 한 번에 보일 곳. 원리·갈림·타임라인·분포·자리별 상태. 각 그림의 축·칸·숫자·라벨을 구체로. 표의 숫자를 문장으로 되풀이하는 문단, 굵게·칩·톤을 둘 자리.'],
100
153
  ['source', '원본 대조 — 고객이 누를 수 있는 원본과 원고 인용의 일치를 본다. 원고가 가리키는 원본을 실제로 연다: 건 cases[].source_url, 도판 출처 줄 assets[].source_text 안의 주소·쪽, 부록 출처 목록과 본문 글자 안의 맨 주소·[글자](주소). assets[].origin 은 렌더되지 않는 제공자 기록이라 대조 대상이 아니다. 인용 문구·쪽(행)·값을 원문과 대조한다. 어긋나는 인용, 원문에 없는 문장, 쪽·절 번호 오기, 고객이 눌러도 안 열리는 링크를 발견으로(원고가 원문과 다르면 class 사실틀림). 「흐리다」「못 연다」는 주장은 원본 해상도로 재측정해(PDF 는 pdfimages -list 전체, 웹은 실제로 열어) 잰 값을 evidence 에 적고, 못 열었으면 「미확인」이라 적는다 — 짐작으로 판정하지 않는다. 앱 동작·내부 수치·결정의 주체는 facts 관점 몫이라 여기서 판정하지 않는다 — 다른 문서에 그 말이 없다고 앱 동작을 거짓이라 하지 않는다. rewrite 에는 원문 문구 그대로 + 원본 이름·쪽(행)·주소로 고친 출처 줄.'],
101
154
  ['structure', '구조 검토자 — parts[] 의 title·lede·questions 와 그 PART 에 속한 절(sections[].part)을 맞대어 읽는다. 짚을 곳: 앞 PART 의 요지 lead·marg.key·표 행이 뒤 PART 의 물음에 먼저 답하는 곳 / 같은 사실(시각·금액·건 제목·핵심 명제)이 두 PART 이상에 서는 곳 — 선 자리를 전부 세어 적는다 / 같은 건의 세 배율(summary·status·cases)이 asks 없이 서로 다른 PART 로 갈린 곳 / 두 절의 marg.key 가 낱말만 다르고 같은 명제인 곳 / 어느 PART 도 선언하지 않은 물음에 답하는 절 / 결정권자 독자인데 status 대조표가 건 제목을 한 번 더 세우는 곳 / 요지 문단(summary)·읽는 법(guide) 물음이 건의 관찰·판단을 바꿔 말한 곳 — 문장 쌍(요지·읽는 법 쪽 ↔ 건 쪽)으로 전수해 evidence 에 적는다. 요약면 수치·요지 lead 가 문서 전체를 줄여 말하는 것은 설계라 결함이 아니다 — 단 요지 문단이 건을 하나씩 옮겨 적으면 결함. 요지(summary)의 모양도 여기서 본다: 이야기 순서(문제 → 원인 → 해법 → 정할 것)대로 서는지, 결정 문서면 칸이 4~6문단인지, 결정에 직접 닿지 않는 **근거 문단의 body 가 비어 있는지**(lead 한 줄 + refs 만 — 본문은 건이 가진다). rewrite 에는 합칠 절·지울 문장을 적는다 — 더하기가 아니라 빼기로. 단 뺀 자리에 뒤 절 번호 포인터를 남기지 않는다: 그 자리에서 뜻이 서게 하는 한 구절(낱말 정의·조건 이름)은 남긴다.'],
102
- ['linear', '선형 읽기 — 렌더 순서(표지 → 이력 → 차례 → 활용법 → 요약면 → PART 간지 → 절 → 부록 → 판권지)대로 앞에서 뒤로 딱 한 번 읽는다. 「이 낱말/번호/표가 뭐지, 앞에서 봤나, 뒤에 있나」 하고 멈추는 지점을 남김없이 적는다 — 뒤에 있는 것을 앞에서 전제하거나(선참조: 요지 본문 안의 §n.n, 흐름도 「건」 열의 건 번호, 도판 출처 줄의 §. refs[] 는 「근거」 단추라 뜀이 아니되 문단 내용과 맞는 건이어야 함), 앞의 정의를 뒤에서 다시 찾아 올라가야 하거나, 정의가 부록·원고 밖(앞 회차 보고)에만 있으면 전부 뜀이다. 표 한 칸·흐름도 칸·캡션·여백 주석의 낱말도 본다. 결정권자 자리에서는 판단 항목 각각이 그 항목 안의 글만으로 두 길 중 하나를 고를 수 있는지, 항목과 한눈표가 서로를 전제하는지 본다. where 에 읽던 자리, problem 에 필요했던 것과 그것이 있는 자리(없으면 「원고 어디에도 없음」), rewrite 에 그 자리에 넣거나 뺄 문장 — 「다른 절을 보라」는 처방 금지. class 는 읽기, severity 5=이해 불가 · 3=되짚어야 함 · 1=사소. 절 순서가 이야기처럼 이어지는지도 overall 에 적는다.'],
155
+ ['linear', '선형 읽기 — 렌더 순서(표지 → 이력 → 차례 → 활용법 → 요약면 → PART 간지 → 절 → 부록 → 판권지)대로 앞에서 뒤로 딱 한 번 읽는다. 「이 낱말/번호/표가 뭐지, 앞에서 봤나, 뒤에 있나」 하고 멈추는 지점을 남김없이 적는다 — 뒤에 있는 것을 앞에서 전제하거나(선참조: 요지 본문 안의 §n.n, 흐름도 「건」 열의 건 번호, 도판 출처 줄의 §. refs[] 는 「근거」 단추라 뜀이 아니되 문단 내용과 맞는 건이어야 함), 앞의 정의를 뒤에서 다시 찾아 올라가야 하거나, 정의가 부록·원고 밖(앞 회차 보고)에만 있으면 전부 뜀이다. 표 한 칸·흐름도 칸·캡션·여백 주석의 낱말도 본다. 결정권자 자리에서는 판단 항목 각각이 그 항목 안의 글만으로 두 길 중 하나를 고를 수 있는지, 항목과 한눈표가 서로를 전제하는지 본다. where 에 읽던 자리, problem 에 필요했던 것과 그것이 있는 자리(없으면 「원고 어디에도 없음」), rewrite 에 그 자리에 넣거나 뺄 문장 — 「다른 절을 보라」는 처방 금지. class 는 읽기(아래 물음의 발견은 이름), severity 5=이해 불가 · 3=되짚어야 함 · 1=사소. 절 순서가 이야기처럼 이어지는지도 overall 에 적는다. ' + NAME_Q],
103
156
  ['figures', `도판 검토자 — 캡션·alt 만으로 판정하지 않는다. 먼저 readability.md §1 R13 행과 visual-first.md §4 를 읽는다. assets[] 마다 file(PNG)을 Read 로 열어 눈으로 보고, 같은 폴더에 같은 이름 .html 이 있으면 머리 주석과 CSS 를 읽는다. 두 칸(비교 컷)이면 칸마다 틀 윗선·아랫선 y 를 재어 px 차를 evidence 에 적는다 — .html 이 있으면 헤드리스 크롬에서 틀 요소의 getBoundingClientRect, 없으면 PNG 픽셀 스캔으로. 측정 스크립트는 ${SCRATCH}/figures/ 아래에만 둔다. 짚을 곳: R13 위반(좌우 배치·틀 높이·배율·틀 안 글자·표식 색·칩 이름표) / 틀에 잘림(윗선 여백 0 포함) / 선택지 도안 가운데 하나만 합성·실사·크게·먼저 선 곳 / 조건표·캡션이 말한 동작이 그림에 없거나 그림이 본문을 반박하는 곳 — class 사실틀림 / 캡션 「왼쪽은…」과 실제 칸의 불일치 / 칩 이름과 캡션·표 이름이 다른 곳. 열어서 잰 값이 기준 안인 도판은 passed 에 id 와 잰 값을. 못 연 도판은 unopened 에 id 와 까닭을 적고, 그 도판에 대한 판정은 「미확인」이다.`],
104
157
  ['facts', '사실 대조 — FCG 내부 1차 자료(내부 1차 자료 목록)와 결정 장부로 원고의 수치·앱 동작·결정의 주체와 시점을 판정한다. 수치는 원고의 입력값에서 다시 계산한다(반올림·날짜 산술 포함). 앱 동작은 kind=code 파일을 열어 파일:줄로만 판정한다 — 다른 문서에 그 말이 없다는 것은 반증이 아니다. 결정은 누가·어디서 정했는지 결정 장부·정해진 것과 대조한다(주체 없는 「정한 사항」, 장부와 다른 차수·자리). 판정 못 하면 「미확인」이라 적는다. source 관점과의 경계: 고객이 누를 수 있는 원본(링크·쪽)과 인용의 일치는 source 몫이고, 여기는 고객에게 보이지 않는 내부 자료 몫이다. 원고가 틀린 발견은 class 사실틀림. 다시 계산해 맞은 수치·코드와 맞은 동작은 passed 에 파일:줄·계산식과 함께 적는다.'],
105
158
  ...(STATE ? [['state', '상태 대조 — 상태 표를 연다. 원고에서 「반영·적용·고침·보이지 않음·빠짐」 같은 완료형 동사가 선 문장마다 상태 표의 실제 확인 수준(코드 · 시험판 · 실기기 · 운영)과 대조한다. 확인 수준보다 앞선 시제·단정(코드만 고쳤는데 앱에서 보이지 않는다고 쓴 문장)은 class 사실틀림으로 두고, rewrite 에 확인 수준에 맞춘 문장을 적는다. 수준과 맞는 문장은 passed 에.']] : []),
106
159
  ]
107
160
  phase('Read')
108
- const res = await parallel(LENSES.map(([k, inst]) => () => agent(`${CTX}\n[관점: ${k}] ${inst}\n원고 전체를 읽고 findings 를 ${k === 'linear' ? '남김없이(0개면 0개)' : '8~14개'}, severity 로 정직하게 매겨라. 발견마다 class 와 evidence 를 단다. 재서 통과한 것은 passed 에. overall 에 이 원고의 흐름이 어디서 끊기는지 3문장.`, { label: `read:${k}`, phase: 'Read', schema: k === 'figures' ? FIND_FIG : FIND })))
161
+ // 렌더본 관점(TEXT_LENSES)은 CLAUDE.md 를 싣지 않는 내장 유형 Explore 로 돈다 — 기본 워크플로 에이전트는 CLAUDE.md 가 첨부로 실려
162
+ // 프롬프트만 고쳐서는 0 상태가 안 된다(2026-09-26 스킬 검토: 첨부에 MEMORY.md 가 실린 에이전트 22/23·15/16).
163
+ // Explore 는 읽기 전용이고 모델은 세션 모델을 물려받는다(Claude Code 2.1.278 내장 정의 omitClaudeMd·model inherit).
164
+ // 확인 전: MEMORY.md(자동 메모리)와 하네스가 전하는 사용자 요청까지 빠지는지는 첫 실행 전사의 첨부로 확인해야 한다.
165
+ const res = await parallel(LENSES.map(([k, inst]) => () => agent(`${TEXT_LENSES.has(k) ? TEXT_CTX : CTX}\n[관점: ${k}] ${inst}\n${TEXT_LENSES.has(k) ? '이 파일 전체' : '원고 전체'}를 읽고 findings 를 ${k === 'linear' ? '남김없이(0개면 0개)' : '8~14개'}, severity 로 정직하게 매겨라. 발견마다 class 와 evidence 를 단다. 재서 통과한 것은 passed 에. overall 에 이 원고의 흐름이 어디서 끊기는지 3문장.`, { label: `read:${k}`, phase: 'Read', schema: k === 'figures' ? FIND_FIG : (TEXT_LENSES.has(k) ? FIND_TEXT : FIND), ...(TEXT_LENSES.has(k) ? { agentType: 'Explore' } : {}) })))
109
166
  const failed = LENSES.filter((_, i) => !res[i]).map(([k]) => k)
110
- const reads = res.map((r, i) => r && { lens: LENSES[i][0], ...r }).filter(Boolean)
111
- log(`독자 관점 ${reads.length}/${LENSES.length} · 발견 ${reads.reduce((n, r) => n + r.findings.length, 0)}${failed.length ? ` · 실패 ${failed.join(', ')}` : ''}`)
167
+ // 렌더본을 못 열었다고 답한 관점 — 그 발견은 쓰지 않고 머리에 적는다(검사를 통과한 뒤에도 권한·경로 문제로 못 열 수 있다).
168
+ const unread = LENSES.filter(([k], i) => TEXT_LENSES.has(k) && res[i] && res[i].opened !== true).map(([k]) => k)
169
+ const reads = res.map((r, i) => r && { lens: LENSES[i][0], ...r }).filter((r) => r && !unread.includes(r.lens))
170
+ log(`독자 관점 ${reads.length}/${LENSES.length} · 발견 ${reads.reduce((n, r) => n + r.findings.length, 0)}${failed.length ? ` · 실패 ${failed.join(', ')}` : ''}${unread.length ? ` · 렌더본 못 엶 ${unread.join(', ')}` : ''}`)
112
171
  const fig = reads.find((r) => r.lens === 'figures')
113
172
  const figHead = !fig ? '⚠ 도판 관점 실패 — 도판 전부 미열람' : (fig.unopened.length ? `⚠ 도판 ${fig.unopened.length}장 미열람(${fig.unopened.map((u) => u.id).join(', ')})` : '')
114
173
  if (figHead) log(figHead)
@@ -128,35 +187,51 @@ all.filter((f) => f.severity >= 3).sort((a, b) => rank(b) - rank(a)).forEach((f)
128
187
  turn.set(f, seen[k])
129
188
  })
130
189
  const eligible = [...turn.keys()].sort((a, b) => rank(b) - rank(a) || turn.get(a) - turn.get(b) || LENS_AT[a.lens] - LENS_AT[b.lens])
131
- const toVerify = eligible.slice(0, VERIFY_MAX)
132
- const unverified = [...all.filter((f) => f.severity < 3).map((f) => ({ ...f, why: 'severity<3' })), ...eligible.slice(VERIFY_MAX).map((f) => ({ ...f, why: `검증 상한 ${VERIFY_MAX} 초과` }))]
190
+ // 검증 칸을 등급별로 나눈다 — 읽기·구조 발견에 READ_MIN 칸을 먼저 떼어 둔다. 사실 틀림 +10 가중 때문에 나누지 않으면
191
+ // 12칸이 사실 틀림으로 다 차 읽기 발견이 늘 검증 밖이었다(2026-09-26 스킬 검토: 15회 중 14회).
192
+ // 이름 발견은 칸과 별개로 names[] 에 전부 돌아간다.
193
+ const READ_MIN = 4
194
+ const readSlots = eligible.filter((f) => f.class === '읽기' || f.class === '구조').slice(0, READ_MIN)
195
+ const toVerify = [...readSlots, ...eligible.filter((f) => !readSlots.includes(f)).slice(0, VERIFY_MAX - readSlots.length)]
196
+ const unverified = [...all.filter((f) => f.severity < 3).map((f) => ({ ...f, why: 'severity<3' })), ...eligible.filter((f) => !toVerify.includes(f)).map((f) => ({ ...f, why: `검증 상한 ${VERIFY_MAX} 초과` }))]
133
197
  log(`검증 ${toVerify.length}건 · 검증 밖 ${unverified.length}건(raw.unverified — 반영하지 않음)`)
134
198
  const vs = await parallel(toVerify.map((f) => () => agent(`${CTX}\n[반박 검증 — 발견 ${f.id}] 아래 발견은 ${f.lens} 관점 검토자가 냈다. 검토자 주장도 틀린다. 기본 자세는 반박이다 — 발견이 가리킨 원본(코드 파일:줄 · PDF 쪽 · PNG · 주소)과 원고의 그 자리를 직접 열어 판정한다. 열지 못했으면 verdict 는 미확인이다. 원고 글만 읽고 참이라 하지 않는다.
135
- verdict: 발견의 주장(problem)이 참/부분/거짓/미확인. class 가 묻기면 「원고와 자료만으로 정말 못 정하는가」를 판정한다 — 자료에 답이 있으면 거짓.
199
+ 렌더본 텍스트: ${TEXT} — first-time·linear 관점은 이 파일만 읽은 독자다. 그 관점의 읽기·이름 발견은 이 파일(독자에게 보이는 글)로 판정한다 — 원고 JSON 의 origin·결정 장부·사용자 지적은 독자가 못 보는 것이라 반증이 아니다.
200
+ class 가 이름이면 판정 물음은 「이 낱말을 독자가 문서 밖(자기 화면 글자·원본 문서·공급사 공식 명칭)에서 본 적이 있는가」다 — 문서 안의 정의·캡션·용어표는 반증이 아니다(2026-09-26: 「거울 정의 없음」 같은 발견이 「표 캡션에 정의 있음」으로 기각됐었다). 원래 이름이 정말 없는 새 개념이면 verdict 는 참으로 두고 note 를 「원래 이름 없음」으로 시작한다.
201
+ verdict(진단): 발견의 주장(problem)이 참/부분/거짓/미확인. 거짓은 반증을 열어 확인했을 때만 — 확신이 없으면 미확인이다. class 가 묻기면 「원고와 자료만으로 정말 못 정하는가」를 판정한다 — 자료에 답이 있으면 거짓.
202
+ 진단과 처방은 따로 판정한다 — 처방이 틀려도 주장이 맞으면 verdict 는 참이다. 처방 적합은 아래 세 칸이 잰다.
203
+ rx_fit: 처방이 「N절을 보라」「부록에 있다」 같은 포인터이거나, 원래 이름 대신 이 문서가 새 이름·설명형 이름을 붙이거나, 만든 이름은 두고 뜻풀이만 더하면 다시. 그 밖에 규칙에 맞으면 맞음. rx_why 에 걸린 규칙과 까닭.
136
204
  touches_locked: 처방(rewrite·visual)이 정해진 것이나 결정 장부의 확인된 결정을 되돌리면 true.
137
205
  noise_kind: 처방이 본문·캡션·출처 줄에 제작 방식(합성·재현·캡처 도구·그린 방법)을 더하면 제작, 결정 날짜·결정 경로(몇 차·어느 회의에서)를 더하면 날짜경로, 판 이력(몇 판에서 바뀜)을 더하면 판이력, 아니면 없음. 원본 위치 한 구절(원본 이름·쪽·행·주소·건 §)이 빠졌다는 처방은 노이즈가 아니라 SKILL.md 5a 결함이다 — 없음.
138
206
  ${JSON.stringify(f)}`, { label: `verify:${f.id}`, phase: 'Verify', schema: VERDICT })))
139
- // JS 거름(결정적) — 거짓·정해진 것 되돌리기·노이즈 처방·추정뿐인 근거는 dropped(사유 포함), 묻기는 questions, 사실 틀림은 합치지 않는 독립 항목.
207
+ // JS 거름(결정적) — 진단 거짓만 dropped(사유 포함), 묻기는 questions, 사실 틀림은 합치지 않는 독립 항목.
208
+ // 정해진 것 되돌리기·노이즈·규칙에 걸린 처방은 발견을 버리지 않고 rx 「처방 다시 — 까닭」을 단다. 전에는 사유 하나로 발견째 버려
209
+ // 진단이 참인 발견 11건이 기각됐다(2026-09-26 스킬 검토 — 그중 지어 붙인 인스턴스 이름이 정의 없이 나온다는 발견). 근거가 추정뿐인지는 verify.finding_basis 에 남는다.
140
210
  const verdicts = toVerify.map((f, i) => ({ id: f.id, ...(vs[i] || { verdict: '검증 실패' }) }))
141
211
  const verdictOf = Object.fromEntries(verdicts.map((v) => [v.id, v.verdict]))
142
- const dropped = [], questions = [], errors = [], candidates = []
212
+ const dropped = [], questions = [], errors = [], candidates = [], vfail = []
143
213
  toVerify.forEach((f, i) => {
144
214
  const v = vs[i]
145
- if (!v) { unverified.push({ ...f, why: '검증 실패' }); return }
146
- const reasons = [v.verdict === '거짓' && '거짓', v.touches_locked && '정해진 것을 되돌림', v.noise_kind !== '없음' && `노이즈:${v.noise_kind}`, v.finding_basis === '추정' && '근거 추정뿐'].filter(Boolean)
147
- const g = { ...f, verdict: v.verdict, verify: v }
148
- if (reasons.length) dropped.push({ ...g, reasons })
215
+ if (!v) { vfail.push(f.id); unverified.push({ ...f, why: '검증 실패' }); return }
216
+ const rxWhy = [v.touches_locked && '정해진 것을 되돌림', v.noise_kind !== '없음' && `노이즈:${v.noise_kind}`, v.rx_fit === '다시' && (v.rx_why || '규칙에 걸림')].filter(Boolean)
217
+ const g = { ...f, verdict: v.verdict, verify: v, rx: rxWhy.length ? `처방 다시 — ${rxWhy.join(' · ')}` : '' }
218
+ if (v.verdict === '거짓') dropped.push({ ...g, reasons: ['거짓', ...rxWhy] })
149
219
  else if (f.class === '묻기') questions.push(g)
150
220
  // 사실 틀림의 독립 자리(개수 제한 밖·맨 앞)는 **Verify 가 참·부분으로 받은 것**만이다.
151
221
  // 미확인(근거를 못 연 것)까지 그 자리에 두면 확인되지 않은 주장이 개수 제한 없이 맨 앞에 서고 「(미확인)」 표시도 안 붙는다(2026-09-14 검증).
152
222
  else if (f.class === '사실틀림' && (v.verdict === '참' || v.verdict === '부분')) errors.push(g)
153
223
  else candidates.push(g)
154
224
  })
155
- log(`거름: 버림 ${dropped.length} · 묻기 ${questions.length} · 사실 틀림(참·부분) ${errors.length} · 합칠 것 ${candidates.length}`)
225
+ log(`거름: 버림 ${dropped.length} · 묻기 ${questions.length} · 사실 틀림(참·부분) ${errors.length} · 합칠 것 ${candidates.length}${vfail.length ? ` · 검증 실패 ${vfail.length}` : ''}`)
156
226
  if (!dropped.length) log('버림 0 — 표본 3건 재반박 권함')
227
+ // 실패는 결과 머리에 적는다 — 사용량 한도로 검증 12건이 전부 실패한 실행이 「반영할 발견 0」으로 찍혔다(2026-09-26 스킬 검토:
228
+ // wf_01b3c88d-58b·wf_8d4619aa-bfb). 관점이 결과를 못 낸 것도 같다 — linear 가 죽으면 jumps 가 비어 「뜀 0」으로 읽힌다.
229
+ const failHead = (vfail.length || failed.length || unread.length) ? `검토 미완 — ${[vfail.length && `검증 ${vfail.length}건 실패`, failed.length && `관점 ${failed.length}개 실패(${failed.join(', ')})`, unread.length && `렌더본 못 엶 ${unread.length}(${unread.join(', ')})`].filter(Boolean).join(' · ')}` : ''
230
+ if (failHead) log(failHead)
157
231
 
158
232
  phase('Merge')
159
- const m = (candidates.length || errors.length) ? await agent(`${CTX}\n[합치기] 아래 「합칠 것」은 Verify 를 통과한 발견이다(verify 에 판정). 같은 자리끼리 합치고(중복 제거 — ids 에 합친 발견 id 를 모두 적는다), severity 합산으로 정렬해 상위 10개만 남겨라. 각 항목에 가장 구체적인 rewrite 와 visual 을 고르되, 비트맵은 ${BUDGET}장까지만 'bitmap' 으로 두고 나머지는 flow/table/chart 로 바꿔 적어라. 다른 관점의 더하기 처방(표·문장·행 추가)이 structure 관점의 빼기 처방과 부딪히면 빼기가 이긴다 — 같은 사실을 다른 PART·절에 또 세우는 처방은 버린다. 단 빼기가 뒤 절 번호 포인터를 남기면 안 된다 — linear 관점의 뜀 지점과 부딪히는 「N절·건 §·부록을 보라」 처방은 「그 자리에 한 구절」로 바꾼다. linear 관점의 뜀 지점은 상위 10 과 별개로 전부(jumps) 남는다. verdict 가 미확인인 발견을 합치면 problem 끝에 「(미확인)」을 남긴다.
233
+ const m = (candidates.length || errors.length) ? await agent(`${CTX}\n[합치기] 아래 「합칠 것」은 Verify 를 통과한 발견이다(verify 에 판정). 같은 자리끼리 합치고(중복 제거 — ids 에 합친 발견 id 를 모두 적는다), severity 합산으로 정렬해 상위 10개만 남겨라. 각 항목에 가장 구체적인 rewrite 와 visual 을 고르되, 비트맵은 ${BUDGET}장까지만 'bitmap' 으로 두고 나머지는 flow/table/chart 로 바꿔 적어라. 다른 관점의 더하기 처방(표·문장·행 추가)이 structure 관점의 빼기 처방과 부딪히면 빼기가 이긴다 — 같은 사실을 다른 PART·절에 또 세우는 처방은 버린다. 단 빼기가 뒤 절 번호 포인터를 남기면 안 된다 — linear 관점의 뜀 지점과 부딪히는 「N절·건 §·부록을 보라」 처방은 「그 자리에 한 구절」로 바꾼다. linear 관점의 뜀 지점과 class 이름 발견은 상위 10 과 별개로 전부(jumps·names) 남는다. verdict 가 미확인인 발견을 합치면 problem 끝에 「(미확인)」을 남긴다.
234
+ rx 가 빈 문자열이 아닌 발견은 진단은 맞고 처방이 규칙에 걸린 것이다 — 그 rewrite·visual 을 고르지 말고 규칙에 맞는 처방을 새로 적되(이름 발견은 원래 이름으로 — 새 이름·뜻풀이 금지), 못 적으면 rewrite 를 rx 문장 그대로 둔다. 합친 항목의 rx 칸에는 처방을 다시 써야 하면 「처방 다시 — 까닭」, 아니면 빈 문자열.
160
235
  「사실 틀림」은 원고가 원본과 다른 곳(그림↔본문 반박 포함)이다 — 사실 대조에서 틀림·부분으로 판정된 발견은 다른 발견에 합쳐 넣지 않고 독립 항목으로 이미 앞에 서며 개수 제한 밖이다. findings 에 다시 적지 말고 overall 순서에만 반영하라. overall 에는 「작성자가 지금 바로 할 일 순서」를 5줄로 — 사실 틀림 고침이 맨 먼저, 그다음 구조(PART·절 합치기·빼기), 용어 잠금과 수치·참조·원본 대조, 그림은 마지막이다.
161
236
  [사실 틀림 — 참고만]
162
237
  ${JSON.stringify(errors)}
@@ -166,12 +241,14 @@ const pick = ({ where, problem, severity, class: c, evidence, rewrite, visual })
166
241
  const body = m ? m.overall : ((candidates.length || errors.length) ? '합치기 실패 — raw.reads 를 사람이 훑는다' : '반영할 발견 0 — passed·dropped 를 사람이 훑는다')
167
242
  const merged = {
168
243
  findings: [
169
- ...errors.map((e) => ({ ids: [e.id], ...pick(e), verdicts: [`${e.id}:${e.verdict}`] })),
244
+ ...errors.map((e) => ({ ids: [e.id], ...pick(e), rx: e.rx, verdicts: [`${e.id}:${e.verdict}`] })),
170
245
  ...(m ? m.findings : []).map((x) => ({ ...x, verdicts: x.ids.map((id) => `${id}:${verdictOf[id] || '?'}`) })),
171
246
  ],
172
- overall: figHead ? `${figHead}\n${body}` : body,
247
+ overall: [failHead, figHead, body].filter(Boolean).join('\n'),
173
248
  }
174
249
  const passed = reads.flatMap((r) => r.passed.map((p) => ({ lens: r.lens, ...p })))
175
250
  // linear 관점의 뜀 지점은 상위 10·검증 상한과 별개로 전부 돌려준다 — 0 이 목표라 하나도 숨기지 않는다(linear-read-test.workflow.js 와 같은 셈).
176
251
  const jumps = (reads.find((r) => r.lens === 'linear') || {}).findings || []
177
- return { merged, questions, passed, dropped, jumps, raw: { reads, verdicts, unverified } }
252
+ // 이름 발견(문서가 붙인 이름·정의 없는 이름)도 관점·검증 상한과 별개로 전부 돌려준다 — 검증 단계까지 간 것이 164건 중 4건이었다(2026-09-26 스킬 검토).
253
+ const names = all.filter((f) => f.class === '이름')
254
+ return { ...(failHead ? { head: failHead, failed: true } : {}), merged, questions, passed, dropped, jumps, names, raw: { reads, verdicts, unverified } }