genetics-mcp 2.22.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.
- package/bin/aarch64-apple-darwin/gcell +0 -0
- package/bin/aarch64-unknown-linux-musl/gcell +0 -0
- package/bin/manifest.json +5 -5
- package/bin/x86_64-apple-darwin/gcell +0 -0
- package/bin/x86_64-pc-windows-gnu/gcell.exe +0 -0
- package/bin/x86_64-unknown-linux-musl/gcell +0 -0
- package/genetics.mjs +2224 -238
- package/package.json +3 -2
- package/skills/gazette-report/SKILL.md +11 -10
- package/skills/gazette-report/scripts/tone.mjs +17 -5
- package/skills/gazette-report/scripts/tone.test.mjs +15 -2
- package/skills/gazette-report/template/skeleton.html +10 -10
- package/skills/genetics-define/SKILL.md +2 -0
- package/skills/genetics-report/SKILL.md +93 -87
- package/skills/genetics-report/assets/example/cases-briefing.json +1 -1
- package/skills/genetics-report/assets/example/cases.json +3 -3
- package/skills/genetics-report/assets/example/report-briefing-designs.json +26 -26
- package/skills/genetics-report/assets/example/report-briefing.json +3 -3
- package/skills/genetics-report/assets/example/report.json +11 -11
- package/skills/genetics-report/assets/mcp-call.mjs +109 -0
- package/skills/genetics-report/assets/patch.py +196 -0
- package/skills/genetics-report/assets/render.py +172 -0
- package/skills/genetics-report/assets/xlsx-plate.py +203 -0
- package/skills/genetics-report/references/content-pipeline.md +26 -26
- package/skills/genetics-report/references/edition-review.workflow.js +70 -15
- package/skills/genetics-report/references/history.md +76 -2
- package/skills/genetics-report/references/lim-portal-format.md +101 -45
- package/skills/genetics-report/references/linear-read-test.workflow.js +50 -14
- package/skills/genetics-report/references/process.md +47 -43
- package/skills/genetics-report/references/reader-review.workflow.js +105 -28
- package/skills/genetics-report/references/visual-first.md +29 -12
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"lang": "ko",
|
|
5
5
|
"doc_type": "도안 결재 브리핑",
|
|
6
6
|
"title_tag": "설정 변경 안내 도안 결재 · 예시 고객사",
|
|
7
|
-
"description": "알림 설정을 바꾼 이용자에게 보여 줄 안내를 세 도안 가운데 하나로 정하기 위한 브리핑. 세 도안의 화면과 견줌, 근거,
|
|
7
|
+
"description": "알림 설정을 바꾼 이용자에게 보여 줄 안내를 세 도안 가운데 하나로 정하기 위한 브리핑. 세 도안의 화면과 견줌, 근거, <독자 화면의 채널 이름> 앱 확인을 정리함",
|
|
8
8
|
"doc_no": "LIM-2026-0901-03",
|
|
9
9
|
"client": "예시 고객사",
|
|
10
10
|
"kicker": "우리회사 · 예시 고객사",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
],
|
|
26
26
|
[
|
|
27
27
|
"확인 요청",
|
|
28
|
-
"
|
|
28
|
+
"<독자 화면의 채널 이름> 앱 화면 1건"
|
|
29
29
|
],
|
|
30
30
|
[
|
|
31
31
|
"회신 기한",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
{
|
|
45
45
|
"no": 1,
|
|
46
46
|
"date": "2026. 9. 1.",
|
|
47
|
-
"note": "최초 발행. 세 도안의 화면과 견줌,
|
|
47
|
+
"note": "최초 발행. 세 도안의 화면과 견줌, <독자 화면의 채널 이름> 앱 확인을 정리함."
|
|
48
48
|
}
|
|
49
49
|
],
|
|
50
50
|
"guide": [
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
{
|
|
66
66
|
"q": "회신 뒤에 할 일이 또 있는가?",
|
|
67
67
|
"a": [
|
|
68
|
-
"
|
|
68
|
+
"<독자 화면의 채널 이름> 앱에서 고른 도안의 화면을 확인하는 일 하나임.",
|
|
69
69
|
"기한은 01절 둘째 항목, 볼 경우는 07절 표 7-2."
|
|
70
70
|
]
|
|
71
71
|
},
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
],
|
|
87
87
|
"notice": {
|
|
88
88
|
"state": "info",
|
|
89
|
-
"text": "정할 것은 안내 도안 1건, 2026. 9. 30.까지임.
|
|
89
|
+
"text": "정할 것은 안내 도안 1건, 2026. 9. 30.까지임. <독자 화면의 채널 이름> 앱 확인은 받으신 뒤 7일 안임."
|
|
90
90
|
},
|
|
91
91
|
"stats": [
|
|
92
92
|
{
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
],
|
|
128
128
|
"추정": [
|
|
129
129
|
"m-est",
|
|
130
|
-
"사례·전망에 기댄 값임.
|
|
130
|
+
"사례·전망에 기댄 값임. <독자 화면의 채널 이름> 앱 실측으로 갱신될 때까지 잠정."
|
|
131
131
|
]
|
|
132
132
|
},
|
|
133
133
|
"labels": {
|
|
@@ -164,10 +164,10 @@
|
|
|
164
164
|
"no": "3",
|
|
165
165
|
"title": "이행",
|
|
166
166
|
"tone": "wait",
|
|
167
|
-
"lede": "고른 도안의 위험과 회신 뒤 일정,
|
|
167
|
+
"lede": "고른 도안의 위험과 회신 뒤 일정, <독자 화면의 채널 이름> 앱에서 볼 경우.",
|
|
168
168
|
"questions": [
|
|
169
169
|
"고른 도안에서 무엇이 잘못될 수 있는가?",
|
|
170
|
-
"
|
|
170
|
+
"<독자 화면의 채널 이름> 앱에서 무엇을 보면 되는가?"
|
|
171
171
|
]
|
|
172
172
|
}
|
|
173
173
|
],
|
|
@@ -185,10 +185,10 @@
|
|
|
185
185
|
"part": "1",
|
|
186
186
|
"short": "결정 요청",
|
|
187
187
|
"sub": "회신이 필요한 도안 1건과 확인 1건",
|
|
188
|
-
"lede": "**세 도안 가운데 하나**를 고르는 일과, 고른 도안을
|
|
188
|
+
"lede": "**세 도안 가운데 하나**를 고르는 일과, 고른 도안을 <독자 화면의 채널 이름> 앱에서 확인하는 일 두 가지.",
|
|
189
189
|
"marg": {
|
|
190
190
|
"why": "무엇을 정해 달라는 것인가?",
|
|
191
|
-
"key": "안내에 쓸 도안 하나와
|
|
191
|
+
"key": "안내에 쓸 도안 하나와 <독자 화면의 채널 이름> 앱 확인 하나.",
|
|
192
192
|
"advice": "세 도안을 견준 표 1-1, 뜨는 조건은 표 1-2."
|
|
193
193
|
},
|
|
194
194
|
"tables": [
|
|
@@ -376,7 +376,7 @@
|
|
|
376
376
|
"tone": "hold",
|
|
377
377
|
"short": "의견",
|
|
378
378
|
"sub": "사실과 갈라 둔 의견",
|
|
379
|
-
"lede": "03·04절의 사실을 보고 우리회사가 맞다고 보는 도안. 의견이며,
|
|
379
|
+
"lede": "03·04절의 사실을 보고 우리회사가 맞다고 보는 도안. 의견이며, 어느 도안으로 갈지는 01절 회신이 정함.",
|
|
380
380
|
"marg": {
|
|
381
381
|
"why": "우리회사는 어느 도안이 맞다고 보는가?",
|
|
382
382
|
"key": "의견은 이 표 한 장에만 둠.",
|
|
@@ -431,7 +431,7 @@
|
|
|
431
431
|
"marg": {
|
|
432
432
|
"why": "고른 도안에서 무엇이 잘못될 수 있는가?",
|
|
433
433
|
"key": "첫 화면을 막는 도안 A 는 닫기 단추 크기로 줄임.",
|
|
434
|
-
"advice": "근거 등급이 「추정」인 행은
|
|
434
|
+
"advice": "근거 등급이 「추정」인 행은 <독자 화면의 채널 이름> 앱에서 갱신."
|
|
435
435
|
},
|
|
436
436
|
"tables": [
|
|
437
437
|
{
|
|
@@ -477,11 +477,11 @@
|
|
|
477
477
|
"part": "3",
|
|
478
478
|
"tone": "neutral",
|
|
479
479
|
"short": "일정",
|
|
480
|
-
"sub": "누가 무엇을 언제,
|
|
481
|
-
"lede": "회신에 따라 우리회사가 할 일과,
|
|
480
|
+
"sub": "누가 무엇을 언제, <독자 화면의 채널 이름> 앱에서 볼 경우",
|
|
481
|
+
"lede": "회신에 따라 우리회사가 할 일과, <독자 화면의 채널 이름> 앱에서 확인할 경우.",
|
|
482
482
|
"marg": {
|
|
483
483
|
"why": "회신하면 무엇이 언제 일어나는가?",
|
|
484
|
-
"key": "회신 후 14일 안에
|
|
484
|
+
"key": "회신 후 14일 안에 <독자 화면의 채널 이름> 앱을 전달함.",
|
|
485
485
|
"advice": "일정의 주어는 우리회사임."
|
|
486
486
|
},
|
|
487
487
|
"tables": [
|
|
@@ -501,23 +501,23 @@
|
|
|
501
501
|
[
|
|
502
502
|
"도안 A 확인 창",
|
|
503
503
|
"우리회사",
|
|
504
|
-
"회신 후 14일 안
|
|
504
|
+
"회신 후 14일 안 <독자 화면의 채널 이름> 앱"
|
|
505
505
|
],
|
|
506
506
|
[
|
|
507
507
|
"도안 B 홈 카드",
|
|
508
508
|
"우리회사",
|
|
509
|
-
"회신 후 14일 안
|
|
509
|
+
"회신 후 14일 안 <독자 화면의 채널 이름> 앱"
|
|
510
510
|
],
|
|
511
511
|
[
|
|
512
512
|
"도안 C 상단 띠",
|
|
513
513
|
"우리회사",
|
|
514
|
-
"회신 후 21일 안
|
|
514
|
+
"회신 후 21일 안 <독자 화면의 채널 이름> 앱"
|
|
515
515
|
]
|
|
516
516
|
],
|
|
517
517
|
"note": "도안 C 는 띠 모양을 새로 그려 일주일이 더 걸림."
|
|
518
518
|
},
|
|
519
519
|
{
|
|
520
|
-
"caption": "
|
|
520
|
+
"caption": "<독자 화면의 채널 이름> 앱 확인 프로필",
|
|
521
521
|
"cols": [
|
|
522
522
|
"확인 프로필",
|
|
523
523
|
"만드는 법",
|
|
@@ -582,8 +582,8 @@
|
|
|
582
582
|
},
|
|
583
583
|
{
|
|
584
584
|
"label": "정할 것",
|
|
585
|
-
"lead": "정할 것은 도안
|
|
586
|
-
"body": "회신 후 14일 안에
|
|
585
|
+
"lead": "정할 것은 도안 하나임. 고른 도안의 <독자 화면의 채널 이름> 앱 확인은 01절 둘째 항목에서 요청함.",
|
|
586
|
+
"body": "회신 후 14일 안에 <독자 화면의 채널 이름> 앱을 전달함(도안 C 는 21일). 확인할 네 경우는 07절 표 7-2 에 있음(§4.2).",
|
|
587
587
|
"refs": [
|
|
588
588
|
"4.2"
|
|
589
589
|
]
|
|
@@ -609,8 +609,8 @@
|
|
|
609
609
|
]
|
|
610
610
|
},
|
|
611
611
|
{
|
|
612
|
-
"title": "
|
|
613
|
-
"body": "
|
|
612
|
+
"title": "<독자 화면의 채널 이름> 앱에서 고른 도안의 화면 확인 요청",
|
|
613
|
+
"body": "<독자 화면의 채널 이름> 앱을 받으신 뒤 7일 안에 확인 프로필 표(회신 뒤 일정 절)의 네 경우대로 보이는지 확인해 주시기 바랍니다. 표와 다르게 보이면 그 화면을 회신해 주시기 바랍니다.",
|
|
614
614
|
"refs": [
|
|
615
615
|
"4.2"
|
|
616
616
|
]
|
|
@@ -682,7 +682,7 @@
|
|
|
682
682
|
],
|
|
683
683
|
[
|
|
684
684
|
"추정",
|
|
685
|
-
"사례·전망에 기댄 값임.
|
|
685
|
+
"사례·전망에 기댄 값임. <독자 화면의 채널 이름> 앱 실측으로 갱신될 때까지 잠정."
|
|
686
686
|
]
|
|
687
687
|
],
|
|
688
688
|
"note": "직접 확인이 아니면 한 단 내리고, 이해관계가 있는 출처면 또 한 단 내림. 올려 적지 않음."
|
|
@@ -731,8 +731,8 @@
|
|
|
731
731
|
],
|
|
732
732
|
"mark": "이 브리핑의 사실은 2026. 9. 1. 기준임.",
|
|
733
733
|
"badge": "고객 공유용",
|
|
734
|
-
"note": "※ 이 문서에는 입력·저장 기능이
|
|
735
|
-
"tail": "회신 뒤 일정은 표 7-1 대로 진행하고,
|
|
734
|
+
"note": "※ 이 문서에는 입력·저장 기능이 없습니다. 회신은 이 문서를 보낸 담당자에게 보내 주시기 바랍니다.",
|
|
735
|
+
"tail": "회신 뒤 일정은 표 7-1 대로 진행하고, <독자 화면의 채널 이름> 앱 확인으로 이어짐."
|
|
736
736
|
},
|
|
737
737
|
"forbid": [
|
|
738
738
|
"(은|는|을|를) 제외(함|됨|했음|했다|한다|하였음|하였다)",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"q": "우리회사 의견은 어디에 있는가?",
|
|
74
74
|
"a": [
|
|
75
75
|
"05절 한 곳에만 둠.",
|
|
76
|
-
"
|
|
76
|
+
"판단 항목은 01절 한 곳에 둠."
|
|
77
77
|
]
|
|
78
78
|
}
|
|
79
79
|
],
|
|
@@ -345,7 +345,7 @@
|
|
|
345
345
|
"tone": "hold",
|
|
346
346
|
"short": "의견",
|
|
347
347
|
"sub": "사실과 갈라 둔 의견",
|
|
348
|
-
"lede": "03·04절의 사실을 보고 우리회사가 맞다고 보는 쪽. 의견이며,
|
|
348
|
+
"lede": "03·04절의 사실을 보고 우리회사가 맞다고 보는 쪽. 의견이며, 어느 쪽으로 갈지는 01절 회신이 정함.",
|
|
349
349
|
"marg": {
|
|
350
350
|
"why": "우리회사는 어느 쪽이 맞다고 보는가?",
|
|
351
351
|
"key": "의견은 이 표 한 장에만 둠.",
|
|
@@ -647,7 +647,7 @@
|
|
|
647
647
|
],
|
|
648
648
|
"mark": "이 브리핑의 수치는 2026년 8월 27일 기준임.",
|
|
649
649
|
"badge": "고객 공유용",
|
|
650
|
-
"note": "※ 이 문서에는 입력·저장 기능이
|
|
650
|
+
"note": "※ 이 문서에는 입력·저장 기능이 없습니다. 회신은 이 문서를 보낸 담당자에게 보내 주시기 바랍니다.",
|
|
651
651
|
"tail": "회신 뒤 일정은 07절 표대로 진행하고, 첫 분기 실측 보고로 이어짐."
|
|
652
652
|
},
|
|
653
653
|
"forbid": [
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"kicker": "우리회사 · 예시 고객사",
|
|
12
12
|
"title": "시스템 수정 내역",
|
|
13
13
|
"title_sub": "1차 보고",
|
|
14
|
-
"subtitle": "처리한 2건과, 확인이 필요한 1건을 정리한 것임.
|
|
14
|
+
"subtitle": "처리한 2건과, 확인이 필요한 1건을 정리한 것임.",
|
|
15
15
|
"cover_date": "2026. 8. 24.",
|
|
16
16
|
"issuer": ["우리회사", "예시 고객사", "2026. 8."],
|
|
17
17
|
"running_footer": "예시 고객사 · LIM-2026-0824-01",
|
|
@@ -32,14 +32,14 @@
|
|
|
32
32
|
{ "q": "시간이 없다면 어디를 봐야 하는가?",
|
|
33
33
|
"a": ["01절 확인 요청 1건이 조치 사항의 전부임.", "나머지 절은 그 근거."] },
|
|
34
34
|
{ "q": "어디까지 확인한 것인가?",
|
|
35
|
-
"a": ["03절 대조표의
|
|
35
|
+
"a": ["「확인 방법」은 03절 대조표의 열에 있음.", "판정 기준은 부록 1.", "확인하지 않은 범위는 06절에 따로 적어 둠."] },
|
|
36
36
|
{ "q": "각 절 왼쪽 여백의 표식은?",
|
|
37
37
|
"a": ["Why 는 그 절이 답하는 질문.", "Key Point 는 이 절에서 하나만 가져간다면 그것.", "Advice 는 어디를 더 볼 것인가."] },
|
|
38
38
|
{ "q": "인쇄하려면?",
|
|
39
|
-
"a": ["
|
|
39
|
+
"a": ["「머리글 및 바닥글」이 켜져 있으면 브라우저 주소가 페이지마다 찍힘.", "인쇄 대화상자에서 그 칸을 끄면 주소 없이 인쇄됨."] }
|
|
40
40
|
],
|
|
41
41
|
|
|
42
|
-
"notice": { "state": "info", "text": "접수 1건 처리 완료. 확인 요청 1건이 남아
|
|
42
|
+
"notice": { "state": "info", "text": "접수 1건 처리 완료. 확인 요청 1건이 남아 있음(8월 31일까지)." },
|
|
43
43
|
"bar": [
|
|
44
44
|
{ "value": 2, "label": "처리 완료", "tone": "done" },
|
|
45
45
|
{ "value": 1, "label": "확인 요청", "tone": "wait" }
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
|
|
60
60
|
"parts": [
|
|
61
61
|
{ "no": "1", "title": "요청", "tone": "you",
|
|
62
|
-
"lede": "
|
|
62
|
+
"lede": "확인·처리를 부탁드리는 사항입니다. 이 PART 가 수신 측에서 하실 일의 전부입니다.",
|
|
63
63
|
"questions": ["무엇을 확인해야 하는가?", "그 판단의 근거는 어디에 있는가?"] },
|
|
64
64
|
{ "no": "2", "title": "경과", "tone": "neutral",
|
|
65
65
|
"lede": "처리한 것과 그 근거.",
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
"marg": {
|
|
127
127
|
"why": "요청하지 않은 것을 왜 고쳤는가?",
|
|
128
128
|
"key": "같은 화면에서 함께 드러난 건이라 함께 처리함.",
|
|
129
|
-
"advice": "03절
|
|
129
|
+
"advice": "03절 대조표."
|
|
130
130
|
} },
|
|
131
131
|
|
|
132
132
|
{ "no": "06", "id": "sec-6", "label": "06", "name": "이번 확인의 한계", "kind": "note", "part": "3",
|
|
@@ -158,15 +158,15 @@
|
|
|
158
158
|
{ "lead": "두 건은 같은 화면에서 함께 드러난 것임.",
|
|
159
159
|
"body": "목록을 그리는 자리가 하나라, 저장 반영과 표시 속도가 같은 지점에서 갈렸음(§4.1). 그래서 함께 처리함.",
|
|
160
160
|
"refs": ["4.1"] },
|
|
161
|
-
{ "lead": "
|
|
162
|
-
"body": "총 건수 표시가 실제와 다르게 나올 수 있는 상황임(§5.1).
|
|
161
|
+
{ "lead": "확인 요청은 1건임.",
|
|
162
|
+
"body": "총 건수 표시가 실제와 다르게 나올 수 있는 상황임(§5.1). 요청은 01절에 있고 기한은 2026년 8월 31일임.",
|
|
163
163
|
"refs": ["5.1"] }
|
|
164
164
|
],
|
|
165
165
|
|
|
166
166
|
"open_items": [
|
|
167
|
-
{ "title": "목록 위 총 건수 표시 확인 요청",
|
|
167
|
+
{ "title": "목록 위 총 건수 표시 확인 요청(2026. 8. 31.까지)",
|
|
168
168
|
"priority": "must",
|
|
169
|
-
"body": "총 건수가 예전 데이터 때문에 실제보다 몇 건 많게 나올 수
|
|
169
|
+
"body": "총 건수가 예전 데이터 때문에 실제보다 몇 건 많게 나올 수 있습니다. 목록에 표시되는 내용 자체는 정상입니다. 실제 수치와 대조해 알려 주시면 기준을 맞추겠습니다.",
|
|
170
170
|
"refs": ["5.1"] }
|
|
171
171
|
],
|
|
172
172
|
|
|
@@ -192,7 +192,7 @@
|
|
|
192
192
|
],
|
|
193
193
|
"mark": "이 문서는 2026년 8월 24일 기준임.",
|
|
194
194
|
"badge": "고객 공유용",
|
|
195
|
-
"note": "※ 이 문서에는 입력·저장 기능이
|
|
195
|
+
"note": "※ 이 문서에는 입력·저장 기능이 없습니다. 검수 결과는 이 문서를 보낸 담당자에게 회신해 주시기 바랍니다.",
|
|
196
196
|
"tail": "이후 진행 사항은 다음 보고로 공유함."
|
|
197
197
|
},
|
|
198
198
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// 큰 원고를 MCP 도구에 **파일로** 넘긴다 — 채팅의 도구 인자로 싣지 않는다.
|
|
2
|
+
//
|
|
3
|
+
// ## 왜 이 파일이 생겼나 (2026-09-22)
|
|
4
|
+
//
|
|
5
|
+
// 관보 원고는 자산 바이트를 품어 쉽게 100~200KB 가 된다(한 회차 실측: 177,573B).
|
|
6
|
+
// 그대로 도구 인자에 실으면 대화 문맥을 통째로 쓰고, 그 뒤 작업이 못 돈다.
|
|
7
|
+
// 그래서 회차마다 고객사 문서 폴더에 이 우회를 다시 짰고 — 실측 2026-09-22 기준
|
|
8
|
+
// **5벌**(20·20·20·50·36줄)이 제각각 갈라져 있었다. 없애야 할 중복이 아니라
|
|
9
|
+
// **genetics 에 없던 기능**이라 그렇게 된 것이므로, 여기 한 벌로 둔다.
|
|
10
|
+
//
|
|
11
|
+
// 자격·검증은 채팅에서 부를 때와 **같다** — 서버가 SSO 자격을 스스로 읽는다.
|
|
12
|
+
// 이 파일은 전송만 바꾼다(stdio JSON-RPC). 도구의 판단을 흉내 내지 않는다.
|
|
13
|
+
//
|
|
14
|
+
// ## 쓰는 법
|
|
15
|
+
//
|
|
16
|
+
// node assets/mcp-call.mjs [--no-mail] <도구 이름> <인자.json> [결과.txt]
|
|
17
|
+
//
|
|
18
|
+
// node assets/mcp-call.mjs update_report args-dry.json out-dry.txt
|
|
19
|
+
// node assets/mcp-call.mjs get_report '{"id":<id>,"tenant":"<tenant>"}' out.txt # 작은 인자는 인라인도 받는다
|
|
20
|
+
//
|
|
21
|
+
// `--no-mail` — 프로그램이 한 번 읽고 버리는 호출용(render.py·발행 직전 훅). 부르는 쪽 세션의 신원을 자식에게
|
|
22
|
+
// 안 물려준다. 물려주면 genetics.mjs 가 그 세션으로 알고 도구를 부를 때마다 그 세션 앞 메일을 긷고
|
|
23
|
+
// 「받음」 도장(/api/mail/ack)을 찍는데, 이 파일은 응답을 쓰고 서버를 죽이므로 그 메일은 아무도 못 읽는다.
|
|
24
|
+
// 옵션이 없으면 지금과 같다(부모 env 를 그대로 물려준다).
|
|
25
|
+
//
|
|
26
|
+
// 서버 위치는 이 파일의 자리에서 찾고(스킬은 레포 안에 산다), `GENETICS_MCP` 로 덮어쓸 수 있다.
|
|
27
|
+
// 종료 코드: 0 성공 · 1 도구·프로토콜 오류 · 2 쓰는 법 틀림.
|
|
28
|
+
import { spawn } from 'node:child_process';
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
|
|
33
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
// assets/ → genetics-report/ → skills/ → mcp/genetics.mjs
|
|
35
|
+
const SERVER = process.env.GENETICS_MCP || path.resolve(HERE, '../../../genetics.mjs');
|
|
36
|
+
|
|
37
|
+
const argv = process.argv.slice(2);
|
|
38
|
+
const noMail = argv.includes('--no-mail');
|
|
39
|
+
const [tool, argsArg, outPath] = argv.filter((a) => a !== '--no-mail');
|
|
40
|
+
if (!tool || !argsArg) {
|
|
41
|
+
console.error('쓰기: node mcp-call.mjs [--no-mail] <도구 이름> <인자.json|인라인 JSON> [결과.txt]');
|
|
42
|
+
process.exit(2);
|
|
43
|
+
}
|
|
44
|
+
if (!fs.existsSync(SERVER)) {
|
|
45
|
+
console.error(`✗ MCP 서버를 못 찾았습니다: ${SERVER}\n GENETICS_MCP=/경로/genetics.mjs 로 알려 주세요.`);
|
|
46
|
+
process.exit(2);
|
|
47
|
+
}
|
|
48
|
+
// 인자는 파일이 기본이다 — 큰 원고가 이 도구의 존재 이유다. 인라인은 작은 호출의 편의.
|
|
49
|
+
let args;
|
|
50
|
+
try {
|
|
51
|
+
args = JSON.parse(argsArg.trimStart().startsWith('{') ? argsArg : fs.readFileSync(argsArg, 'utf8'));
|
|
52
|
+
} catch (e) {
|
|
53
|
+
console.error(`✗ 인자를 못 읽었습니다: ${e.message}`);
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
* --no-mail 의 자식 env. 끄는 문은 genetics.mjs 가 가진 것만 쓴다(여기서 판정을 흉내 내지 않는다):
|
|
59
|
+
* 세션 env 셋을 지운다 — mySessionId()·hookLane() 이 「지금 이 세션」으로 읽는 값(CLAUDE_CODE_SESSION_ID, Cursor 둘).
|
|
60
|
+
* GENETICS_HOOK_LANE=0 — 도구 호출 때 긷는 문(hookLane() && mailPollReady())을 닫는다. env 를 지워도 Cursor 원장
|
|
61
|
+
* 파일(active-cursor-*.json)만으로 hookLane 이 참이 되어 그 대화 앞 메일을 긷는다(격리 실측 2026-09-26).
|
|
62
|
+
* GENETICS_NO_BEAT=1 — 훅 레인이 없는 세션의 60초 긷기 타이머(startSchedule)를 닫는다. 위 줄만 두면 뜨자마자 돈다.
|
|
63
|
+
*/
|
|
64
|
+
const SESSION_ENV = ['CLAUDE_CODE_SESSION_ID', 'CURSOR_CONVERSATION_ID', 'CURSOR_WORKSPACE_LABEL'];
|
|
65
|
+
function noMailEnv() {
|
|
66
|
+
const env = { ...process.env, GENETICS_HOOK_LANE: '0', GENETICS_NO_BEAT: '1' };
|
|
67
|
+
for (const k of SESSION_ENV) delete env[k];
|
|
68
|
+
return env;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const srv = spawn('node', [SERVER], { stdio: ['pipe', 'pipe', 'inherit'], ...(noMail ? { env: noMailEnv() } : {}) });
|
|
72
|
+
srv.on('error', (e) => { console.error(`✗ 서버를 못 띄웠습니다: ${e.message}`); process.exit(1); });
|
|
73
|
+
|
|
74
|
+
let buf = '';
|
|
75
|
+
const pending = new Map();
|
|
76
|
+
srv.stdout.on('data', (d) => {
|
|
77
|
+
buf += d;
|
|
78
|
+
let i;
|
|
79
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
80
|
+
const line = buf.slice(0, i);
|
|
81
|
+
buf = buf.slice(i + 1);
|
|
82
|
+
if (!line.trim()) continue;
|
|
83
|
+
let msg;
|
|
84
|
+
try { msg = JSON.parse(line); } catch { continue; } // 서버가 섞어 내는 사람 글은 건너뛴다
|
|
85
|
+
if (msg.id != null && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
let nextId = 1;
|
|
90
|
+
const call = (method, params) => new Promise((resolve) => {
|
|
91
|
+
const id = nextId++;
|
|
92
|
+
pending.set(id, resolve);
|
|
93
|
+
srv.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await call('initialize', {
|
|
97
|
+
protocolVersion: '2025-06-18', capabilities: {},
|
|
98
|
+
clientInfo: { name: 'mcp-call', version: '1' },
|
|
99
|
+
});
|
|
100
|
+
srv.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }) + '\n');
|
|
101
|
+
|
|
102
|
+
const r = await call('tools/call', { name: tool, arguments: args });
|
|
103
|
+
const text = (r.result?.content || []).map((c) => c.text || '').join('\n');
|
|
104
|
+
const out = r.error
|
|
105
|
+
? 'ERROR ' + JSON.stringify(r.error)
|
|
106
|
+
: (r.result?.isError ? 'TOOL_ERROR\n' : '') + text;
|
|
107
|
+
if (outPath) fs.writeFileSync(outPath, out); else process.stdout.write(out + '\n');
|
|
108
|
+
srv.kill();
|
|
109
|
+
process.exit(r.error || r.result?.isError ? 1 : 0);
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""원고 부분 고치기 — **멱등**. 회차마다 스크립트를 다시 짓지 않게 한다.
|
|
3
|
+
|
|
4
|
+
## 왜 이 파일이 생겼나 (2026-09-22)
|
|
5
|
+
|
|
6
|
+
포털은 「통째로 바꾸기」(`update_report`)만 준다. 그래서 회차마다 「이 문장을 저 문장으로」를
|
|
7
|
+
적용하는 파이썬을 고객사 문서 폴더에 새로 지었고, 그것이 쌓였다 — 실측: 앳플리 #31 의
|
|
8
|
+
문서 폴더에 20개 2,563줄, RDS 8.4 폴더에 46개 4,285줄(`edit-<날짜><글자>.py` 만 34개).
|
|
9
|
+
매번 같은 뼈대(`rep`·`setv`·`droprows`·멱등·금지어 쓸기)를 다시 썼고, 다시 쓸 때마다 같은
|
|
10
|
+
결함을 다시 만들었다(2026-09-21: `zip` 이 짧은 쪽에 맞춰 잘리는 탓에 `droprows` 가 두 번째
|
|
11
|
+
실행에서 남은 줄 하나를 지웠다 — 여기서는 길이 검사로 막는다).
|
|
12
|
+
|
|
13
|
+
**고치는 내용은 그 회차의 것이지만, 고치는 방법은 아니다.** 방법만 여기 두고,
|
|
14
|
+
회차 폴더에는 **데이터 파일 하나**(edits.json)만 남긴다.
|
|
15
|
+
|
|
16
|
+
## 쓰는 법
|
|
17
|
+
|
|
18
|
+
python3 patch.py report.json edits.json # 적용
|
|
19
|
+
python3 patch.py report.json edits.json --dry # 재 보기만(파일 안 건드림)
|
|
20
|
+
|
|
21
|
+
`edits.json` 은 목록이고, 각 항목은 `op` 하나다. `path` 는 원고 JSON 의 자리를
|
|
22
|
+
`["sections", 1, "lede"]` 처럼 적는다(문자열 `"sections[1].lede"` 도 받는다).
|
|
23
|
+
|
|
24
|
+
| op | 칸 | 하는 일 |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `set` | `path` · `old` · `new` | 그 자리 값이 `old` 면 `new` 로. 이미 `new` 면 건너뛴다 |
|
|
27
|
+
| `replace` | `path` · `old` · `new` | 그 자리 **문자열 안**의 조각을 바꾼다 |
|
|
28
|
+
| `drop_rows` | `path` · `labels` | 앞줄들을 뺀다. 첫 칸 글자가 `labels` 와 **정확히** 같을 때만 |
|
|
29
|
+
| `append_row` | `path` · `row` | 없으면 뒤에 붙인다(있으면 건너뛴다) |
|
|
30
|
+
| `forbid` | `words` | 원고 전체에서 그 낱말이 남았는지 **센다**(안 고친다) |
|
|
31
|
+
|
|
32
|
+
모든 op 는 **멱등**이다 — 두 번 돌리면 두 번째는 「바꿈 0」이어야 하고, 그것이 이 도구의 계약이다.
|
|
33
|
+
값이 `old` 도 `new` 도 아니면 **멈춘다** — 그 사이에 누가 고쳤다는 뜻이고, 조용히 덮으면 안 된다.
|
|
34
|
+
"""
|
|
35
|
+
import argparse
|
|
36
|
+
import json
|
|
37
|
+
import re
|
|
38
|
+
import sys
|
|
39
|
+
|
|
40
|
+
# 자산 바이트·도해 SVG 는 글자가 아니다 — `forbid` 가 훑지 않는다.
|
|
41
|
+
SKIP_KEYS = {'data', 'file', 'ref', 'mime', 'provider', 'svg'}
|
|
42
|
+
_TOK = re.compile(r'([A-Za-z_]\w*)|\[(\d+)\]|\[id=([^\]]+)\]|\.')
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_path(p):
|
|
46
|
+
"""`["sections", 1, "lede"]` 또는 `"sections[1].lede"` → 자리 목록.
|
|
47
|
+
|
|
48
|
+
`assets[id=fig-a]` 꼴도 받는다 — 배열에서 `id` 로 찾는다(자리 번호가 흔들려도 안 깨진다).
|
|
49
|
+
"""
|
|
50
|
+
if isinstance(p, list):
|
|
51
|
+
return list(p)
|
|
52
|
+
out = []
|
|
53
|
+
for name, idx, aid in _TOK.findall(str(p)):
|
|
54
|
+
if name:
|
|
55
|
+
out.append(name)
|
|
56
|
+
elif idx:
|
|
57
|
+
out.append(int(idx))
|
|
58
|
+
elif aid:
|
|
59
|
+
out.append({'id': aid})
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def locate(doc, path):
|
|
64
|
+
"""자리 목록 → (부모, 열쇠). 마지막 한 칸은 남긴다."""
|
|
65
|
+
cur = doc
|
|
66
|
+
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
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _label(r):
|
|
78
|
+
"""줄을 알아보는 글자 — 표는 첫 칸, 흐름도는 첫 칸의 글."""
|
|
79
|
+
if isinstance(r, dict):
|
|
80
|
+
cells = r.get('cells') or []
|
|
81
|
+
return (cells[0] or {}).get('text', '') if cells else str(r.get('no', ''))
|
|
82
|
+
return r[0] if r else ''
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def walk_strings(o, path=''):
|
|
86
|
+
if isinstance(o, dict):
|
|
87
|
+
for k, v in o.items():
|
|
88
|
+
if k in SKIP_KEYS:
|
|
89
|
+
continue
|
|
90
|
+
yield from walk_strings(v, '%s.%s' % (path, k) if path else k)
|
|
91
|
+
elif isinstance(o, list):
|
|
92
|
+
for i, v in enumerate(o):
|
|
93
|
+
yield from walk_strings(v, '%s[%d]' % (path, i))
|
|
94
|
+
elif isinstance(o, str):
|
|
95
|
+
yield path, o
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Run:
|
|
99
|
+
def __init__(self, doc):
|
|
100
|
+
self.doc, self.done, self.skipped, self.left = doc, 0, 0, []
|
|
101
|
+
|
|
102
|
+
def _where(self, e):
|
|
103
|
+
return e.get('why') or e.get('path') or e.get('op')
|
|
104
|
+
|
|
105
|
+
def set(self, e):
|
|
106
|
+
parent, key = locate(self.doc, parse_path(e['path']))
|
|
107
|
+
cur = parent[key]
|
|
108
|
+
if cur == e['new']:
|
|
109
|
+
self.skipped += 1
|
|
110
|
+
return
|
|
111
|
+
if cur != e['old']:
|
|
112
|
+
sys.exit('✗ 값이 다릅니다 @ %s\n 지금: %r\n 기대: %r\n'
|
|
113
|
+
' (그 사이에 누가 고쳤습니다 — 덮지 않고 멈춥니다)'
|
|
114
|
+
% (self._where(e), cur, e['old']))
|
|
115
|
+
parent[key] = e['new']
|
|
116
|
+
self.done += 1
|
|
117
|
+
|
|
118
|
+
def replace(self, e):
|
|
119
|
+
parent, key = locate(self.doc, parse_path(e['path']))
|
|
120
|
+
cur = parent[key]
|
|
121
|
+
if not isinstance(cur, str):
|
|
122
|
+
sys.exit('✗ 문자열이 아닙니다 @ %s — %r' % (self._where(e), type(cur).__name__))
|
|
123
|
+
if e['old'] in cur:
|
|
124
|
+
parent[key] = cur.replace(e['old'], e['new'])
|
|
125
|
+
self.done += 1
|
|
126
|
+
elif e['new'] in cur:
|
|
127
|
+
self.skipped += 1
|
|
128
|
+
else:
|
|
129
|
+
sys.exit('✗ 옛 조각도 새 조각도 없습니다 @ %s\n 지금: %r\n 찾던 것: %r'
|
|
130
|
+
% (self._where(e), cur, e['old']))
|
|
131
|
+
|
|
132
|
+
def drop_rows(self, e):
|
|
133
|
+
"""⚠ 길이를 **먼저** 잰다 — zip 은 짧은 쪽에 맞춰 잘리므로, 이 검사가 없으면
|
|
134
|
+
두 번째 실행이 남은 줄을 지운다(2026-09-21 실측 사고)."""
|
|
135
|
+
parent, key = locate(self.doc, parse_path(e['path']))
|
|
136
|
+
rows, labels = parent[key], e['labels']
|
|
137
|
+
if len(rows) < len(labels) or [_label(r) for r in rows[:len(labels)]] != labels:
|
|
138
|
+
self.skipped += 1
|
|
139
|
+
return
|
|
140
|
+
del rows[:len(labels)]
|
|
141
|
+
self.done += 1
|
|
142
|
+
|
|
143
|
+
def append_row(self, e):
|
|
144
|
+
parent, key = locate(self.doc, parse_path(e['path']))
|
|
145
|
+
rows = parent[key]
|
|
146
|
+
if e['row'] in rows:
|
|
147
|
+
self.skipped += 1
|
|
148
|
+
return
|
|
149
|
+
rows.append(e['row'])
|
|
150
|
+
self.done += 1
|
|
151
|
+
|
|
152
|
+
def forbid(self, e):
|
|
153
|
+
for w in e['words']:
|
|
154
|
+
for p, s in walk_strings(self.doc):
|
|
155
|
+
if w in s:
|
|
156
|
+
i = s.find(w)
|
|
157
|
+
self.left.append((p, w, s[max(0, i - 25):i + 25]))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
OPS = {'set': 'set', 'replace': 'replace', 'drop_rows': 'drop_rows',
|
|
161
|
+
'append_row': 'append_row', 'forbid': 'forbid'}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main():
|
|
165
|
+
ap = argparse.ArgumentParser(description='원고 부분 고치기(멱등) — 회차 데이터는 edits.json 에')
|
|
166
|
+
ap.add_argument('report', help='원고 JSON (get_report 가 준 발행 데이터)')
|
|
167
|
+
ap.add_argument('edits', help='고칠 목록 JSON')
|
|
168
|
+
ap.add_argument('--dry', action='store_true', help='재 보기만 — 파일을 안 건드린다')
|
|
169
|
+
a = ap.parse_args()
|
|
170
|
+
|
|
171
|
+
doc = json.load(open(a.report, encoding='utf-8'))
|
|
172
|
+
edits = json.load(open(a.edits, encoding='utf-8'))
|
|
173
|
+
if isinstance(edits, dict):
|
|
174
|
+
edits = edits.get('edits') or []
|
|
175
|
+
run = Run(doc)
|
|
176
|
+
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)
|
|
181
|
+
|
|
182
|
+
if not a.dry:
|
|
183
|
+
json.dump(doc, open(a.report, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
|
|
184
|
+
print('바꿈 %d · 이미 같음 %d%s' % (run.done, run.skipped, ' (dry)' if a.dry else ''))
|
|
185
|
+
if run.left:
|
|
186
|
+
print('남은 금지어 %d곳' % len(run.left))
|
|
187
|
+
for p, w, ctx in run.left[:20]:
|
|
188
|
+
print(' 「%s」 @ %s — …%s…' % (w, p, ctx.replace('\n', ' ')))
|
|
189
|
+
# 두 번째 실행이 0 이어야 한다는 계약을 사람이 눈으로 확인할 수 있게 한 줄 남긴다.
|
|
190
|
+
if run.done and not a.dry:
|
|
191
|
+
print('(멱등 확인: 한 번 더 돌려 「바꿈 0」이면 맞습니다)')
|
|
192
|
+
sys.exit(1 if run.left else 0)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == '__main__':
|
|
196
|
+
main()
|