genetics-mcp 2.9.2 → 2.9.3
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 +4 -4
- package/bin/x86_64-apple-darwin/gcell +0 -0
- package/bin/x86_64-unknown-linux-musl/gcell +0 -0
- package/genetics.mjs +531 -12
- package/package.json +1 -1
- package/skills/genetics-cell-id/SKILL.md +23 -5
- package/skills/genetics-report/SKILL.md +7 -2
- package/skills/genetics-report/references/lim-portal-format.md +141 -5
|
Binary file
|
|
Binary file
|
package/bin/manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"aarch64-apple-darwin": "
|
|
3
|
-
"aarch64-unknown-linux-musl": "
|
|
4
|
-
"x86_64-apple-darwin": "
|
|
5
|
-
"x86_64-unknown-linux-musl": "
|
|
2
|
+
"aarch64-apple-darwin": "221ace8f89b6dc3f28879c02c2ca0a324ec2a9ba5ba3c295305e8b2d43d1a396",
|
|
3
|
+
"aarch64-unknown-linux-musl": "168621db08da7e63cc00dafe8847f65cba7db61ccbef61daa9d7322fd6b717e2",
|
|
4
|
+
"x86_64-apple-darwin": "28eb940e42721bba6d83dde101be4bbe39bfb12250e5bc45bde7db15810378e9",
|
|
5
|
+
"x86_64-unknown-linux-musl": "f221f2087e5f29c2a485e27b41a1c1bf6ddebcd62963952cc5e066cca04da110"
|
|
6
6
|
}
|
|
Binary file
|
|
Binary file
|
package/genetics.mjs
CHANGED
|
@@ -729,6 +729,26 @@ const REPORT_DATA = {
|
|
|
729
729
|
},
|
|
730
730
|
kicker: { type: 'string', description: '표지 윗줄 (예: "회사명 · 프로젝트")' },
|
|
731
731
|
subtitle: { type: 'string', description: '표지 한 문단 요약 — 대제목 **아래**에 앉는다' },
|
|
732
|
+
intro: {
|
|
733
|
+
type: 'string',
|
|
734
|
+
description: '표지 한 문단 요약. `subtitle` 과 **같은 자리**이고 이쪽이 먼저 쓰인다 — '
|
|
735
|
+
+ '둘 다 주면 `subtitle` 은 안 나온다. 하나만 써라.',
|
|
736
|
+
},
|
|
737
|
+
doc_type: {
|
|
738
|
+
type: 'string',
|
|
739
|
+
description: '문서 종류(「검수 보고」·「사후 보고」 등). `issuer` 를 안 주면 '
|
|
740
|
+
+ '`doc_type` · `cover_date` 를 이어 표지 발행처 스트립을 세운다.',
|
|
741
|
+
},
|
|
742
|
+
cover_date: {
|
|
743
|
+
type: 'string',
|
|
744
|
+
description: '기준 날짜. 두 자리에 선다 — `issuer` 폴백 조각, 그리고 **요약면 제목 옆의 '
|
|
745
|
+
+ '「기준」 표기**. 안 주면 요약면에 기준일이 없어 「언제 기준의 수인가」가 사라진다.',
|
|
746
|
+
},
|
|
747
|
+
cta: {
|
|
748
|
+
type: 'array', items: { type: 'string' },
|
|
749
|
+
description: '`kind:"asks"` 절 머리의 강조문(문자열 목록). 요청 항목들 **위**에 서서 '
|
|
750
|
+
+ '「먼저 볼 것」을 한두 줄로 말한다. 없으면 강조문 없이 항목만 늘어선다.',
|
|
751
|
+
},
|
|
732
752
|
description: { type: 'string' },
|
|
733
753
|
doc_no: { type: 'string', description: '문서번호. 표지 머리 왼쪽 박스 (예: "LIM-2026-0824-01")' },
|
|
734
754
|
client: {
|
|
@@ -742,7 +762,13 @@ const REPORT_DATA = {
|
|
|
742
762
|
description: '표지 맨 아래 발행처 스트립 ["발행처", "대상", "2026. 8."] — 마지막 칸이 날짜다.',
|
|
743
763
|
},
|
|
744
764
|
meta: { type: 'array', description: '표지 메타 [[라벨, 값], ...] · 4칸 권장' },
|
|
745
|
-
stats: {
|
|
765
|
+
stats: {
|
|
766
|
+
type: 'array',
|
|
767
|
+
description: '요약면 수치 칸 [{value, unit, label, accent?, href?, go?}] · accent:true 는 '
|
|
768
|
+
+ '**독자 몫**인 첫 칸. `href` 를 주면 그 칸이 단추가 되고, `go` 는 그 단추의 '
|
|
769
|
+
+ '글자다(생략하면 「해당 절로」). 어디로 가는지를 `go` 에 적어 두면 '
|
|
770
|
+
+ '독자가 누르기 전에 안다 — 예: "02절로".',
|
|
771
|
+
},
|
|
746
772
|
history: {
|
|
747
773
|
type: 'array',
|
|
748
774
|
description: '발행 이력 [{no, date, note}] — 회차 문서임을 문서가 스스로 밝히는 자리.',
|
|
@@ -758,7 +784,9 @@ const REPORT_DATA = {
|
|
|
758
784
|
description: 'PART 묶음 [{no, title, lede?, tone?, questions?}] — 절만 늘어놓으면 '
|
|
759
785
|
+ '「먼저 읽을 것 / 경과 / 남은 것」이라는 독자의 읽기 순서가 사라진다. '
|
|
760
786
|
+ 'tone 은 you(독자 몫)·neutral(기록)·wait(대기)·hold(유보) 중 하나. '
|
|
761
|
-
+ 'questions 는 「이 PART 가 답하는 질문」.'
|
|
787
|
+
+ 'questions 는 「이 PART 가 답하는 질문」. '
|
|
788
|
+
+ '`roman` 은 간지·차례·머리띠에 서는 로마 숫자 — 생략하면 자리 순서대로 '
|
|
789
|
+
+ 'Ⅰ·Ⅱ·Ⅲ 이 매겨지므로 **보통 적지 않는다**(적으면 순서와 어긋날 수 있다).',
|
|
762
790
|
},
|
|
763
791
|
notice: {
|
|
764
792
|
type: 'object',
|
|
@@ -780,6 +808,25 @@ const REPORT_DATA = {
|
|
|
780
808
|
},
|
|
781
809
|
remain: { type: 'string', description: '머리띠 우측 판독부의 수치(요청 잔여 건수). 생략하면 open_items 개수.' },
|
|
782
810
|
running_footer: { type: 'string', description: '인쇄 러닝 푸터의 뒷말. 생략하면 client.' },
|
|
811
|
+
wide: {
|
|
812
|
+
type: 'string',
|
|
813
|
+
description: '문서 폭 CSS 값(`--wide`). **길이 단위여야 한다**(예: `1080px`) — '
|
|
814
|
+
+ '아니면 싣지 않고 ⚠ 로 센다. 시트 기본값으로 충분하니 손대지 마라.',
|
|
815
|
+
},
|
|
816
|
+
marg_width: {
|
|
817
|
+
type: 'string',
|
|
818
|
+
description: '좌측 여백 주석 단의 폭 CSS 값(`--marg`). 길이 단위여야 한다(예: `240px`). '
|
|
819
|
+
+ '관보 여백 단은 12~19자가 적정이라 기본값을 바꿀 이유가 거의 없다.',
|
|
820
|
+
},
|
|
821
|
+
labels: {
|
|
822
|
+
type: 'object',
|
|
823
|
+
description: '렌더러 기본 라벨 덮어쓰기 `{키: 글자}`. 문서가 「건」이 아닌 것을 다루거나 '
|
|
824
|
+
+ '다른 말을 써야 할 때만 쓴다.\n'
|
|
825
|
+
+ '덮을 수 있는 키: apx · as_of · ask · bar_alt · basis · client · col_grade · col_state · '
|
|
826
|
+
+ 'col_title · cta · doc_no · go · guide · hist · hist_date · hist_no · hist_note · marg · '
|
|
827
|
+
+ 'nograde · open · pt_ask · refs · remain · remain_unit · source · source_row · sum · toc · urgent.\n'
|
|
828
|
+
+ '없는 키를 주면 조용히 무시된다 — 오타가 안 걸리니 위 목록에서 골라 써라.',
|
|
829
|
+
},
|
|
783
830
|
grades: { type: 'object', description: '확인 등급 정의 {이름: [클래스, 설명]}' },
|
|
784
831
|
groups: { type: 'array', description: '건 묶음 [{key, no}]' },
|
|
785
832
|
sections: {
|
|
@@ -802,6 +849,22 @@ const REPORT_DATA = {
|
|
|
802
849
|
+ 'rows[].no 가 `4.1`·`04-1` 모양이면 그 사례로 링크가 된다. '
|
|
803
850
|
+ '그림 번호(`그림 5-1`)는 적지 않는다 — 자리가 매기고 `plates` 와 한 셈을 쓴다.\n'
|
|
804
851
|
+ '· `plates`: [assets[].id, …] — 절 본문 도판.\n'
|
|
852
|
+
+ '· `tables`: [{caption, cols[], rows[[]], align?, hint?, note?}] — **관보의 자료표.** '
|
|
853
|
+
+ '여러 항목을 축으로 견주는 자리다. 불릿 목록에 수를 눌러 담고 있으면 그건 표여야 한다. '
|
|
854
|
+
+ 'kind 와 무관하게 쓴다(kind:status 의 `board_title` 대조표와는 다른 것 — 저건 건 전용이다). '
|
|
855
|
+
+ 'cols 는 열 제목, rows 는 행마다 cols 와 **같은 길이의 배열**(어긋나면 잘라 맞추고 ⚠ 로 센다). '
|
|
856
|
+
+ 'align 은 열마다 `l`·`r`·`c` — 수는 `r` 로 두어야 자릿수가 세로로 맞는다. '
|
|
857
|
+
+ 'note 는 표 아래 ※ 한정 조건, hint 는 그 아래 안내 한 줄. '
|
|
858
|
+
+ '표 번호(`표 5-1`)는 적지 않는다 — 자리가 매긴다. '
|
|
859
|
+
+ '⚠ caption 은 **라벨이지 산문이 아니다** — 20자 안쪽으로 두고 설명은 note 로 내려라 '
|
|
860
|
+
+ '(길면 캡션 바가 글줄 상한을 넘어 두 줄로 접힌다).\n'
|
|
861
|
+
+ '· `charts`: [{caption, unit?, rows:[{label, value|a+b, note?}], toggle?{a,b}, note?}] — '
|
|
862
|
+
+ '**분포 막대.** 수를 눈으로 견주게 한다. 열람 CSP 가 `script-src` 를 안 열어 캔버스를 쓸 수 없으므로 '
|
|
863
|
+
+ 'HTML 막대와 CSS 로 짓는다. 값은 **본문 글자로 남고** 막대는 그 옆에 서므로 막대가 안 보이는 '
|
|
864
|
+
+ '곳에서도 문서가 성립한다. value 는 **표기 그대로 적는다**(`"118,716"`) — 콤마는 셈에서 걷어내므로 '
|
|
865
|
+
+ '같은 문서의 표와 표기가 갈리지 않게 하려면 데이터가 표기를 정해야 한다. '
|
|
866
|
+
+ 'toggle{a,b} 를 주면 rows 는 value 대신 a·b 를 갖고 두 축을 `:checked` 로 갈아 낀다 '
|
|
867
|
+
+ '(체크박스는 스크립트가 아니라 상태다). 그림 번호는 `flow`·`plates` 와 **한 셈**을 쓴다.\n'
|
|
805
868
|
+ '데이터가 있을 때만 그려진다 — 없는 문구를 지어내지 말 것.\n'
|
|
806
869
|
+ '\n'
|
|
807
870
|
+ '관보 문법이 절마다 요구하는 칸:\n'
|
|
@@ -812,7 +875,11 @@ const REPORT_DATA = {
|
|
|
812
875
|
+ 'key 는 이 절에서 하나만 가져간다면 그것, advice 는 어디를 참조하라는 것. '
|
|
813
876
|
+ '채우려고 넣으면 안 넣느니만 못하다.\n'
|
|
814
877
|
+ '· `lede`: 절 간지의 도입문 — 다루는 범위와 **다루지 않는 범위**.\n'
|
|
815
|
-
+ '· `sub`: 차례 행에 붙는 한 줄 설명. `short`: 측면 색인 레일용 짧은 이름.\n
|
|
878
|
+
+ '· `sub`: 차례 행에 붙는 한 줄 설명. `short`: 측면 색인 레일용 짧은 이름.\n'
|
|
879
|
+
+ '· `count`·`count_unit`: 절 간지와 차례에 서는 배지(「9건」·「11항」). '
|
|
880
|
+
+ '`count` 가 비면 배지 자체가 안 선다. `count_unit` 은 생략하면 「건」이다 — '
|
|
881
|
+
+ '건이 아닌 것(항·축·구간)을 세는 절이면 반드시 적어라. '
|
|
882
|
+
+ '⚠ **손으로 적는 수라 본문과 어긋날 수 있다** — 절 내용을 고치면 같이 고쳐라.\n· `plates`: 이 절에 놓을 **도판**의 id 목록(`assets[].id`). 관보의 그림 문법으로 서고, 번호는 자리가 매긴다(그림 <절번호>-<순번>). 어느 절에도 안 걸린 도판은 싣지 않고 경고로 센다.\n'
|
|
816
883
|
+ '· `tone`: 장 색을 뜻으로 고른다 — you(독자 몫)·deep(요지)·neutral(허브)·'
|
|
817
884
|
+ 'done(끝난 것)·wait(대기)·hold(유보). 생략하면 kind 가 정한다.\n'
|
|
818
885
|
+ '· kind:status 전용 — `board_title`(대조표 제목) · `board_hint`(표 아래 안내).\n'
|
|
@@ -865,7 +932,14 @@ const REPORT_DATA = {
|
|
|
865
932
|
+ '최소 하나 밝혀야 한다(예: 본문에 "§3.1" 이라 쓰거나 refs:["3.1"]). 절 번호가 아니라 '
|
|
866
933
|
+ '사례 번호다. 근거가 없으면 서버가 발행을 거절한다.',
|
|
867
934
|
},
|
|
868
|
-
open_items: {
|
|
935
|
+
open_items: {
|
|
936
|
+
type: 'array',
|
|
937
|
+
description: 'asks 절 내용 [{title, body, refs?, priority?}] — 우리가 끝낼 수 없어 '
|
|
938
|
+
+ '상대의 판단이 필요한 것만. `priority: "must"` 인 항목은 「가장 시급한 요청」으로 '
|
|
939
|
+
+ '따로 서고 나머지와 괘선으로 갈린다 — **먼저 정해져야 하는 것에만** 붙여라. '
|
|
940
|
+
+ '전부에 붙이면 갈림이 사라져 안 붙인 것과 같아진다. '
|
|
941
|
+
+ '`refs` 는 근거 사례 번호이고 **첫 하나만** 「근거」 단추가 된다.',
|
|
942
|
+
},
|
|
869
943
|
footer: { type: 'array' },
|
|
870
944
|
assets: {
|
|
871
945
|
type: 'array',
|
|
@@ -917,6 +991,19 @@ const REPORT_DATA = {
|
|
|
917
991
|
},
|
|
918
992
|
alt: { type: 'string', description: '설명적 대체 텍스트 — 접근성 계약. 그림과 함께 다닌다' },
|
|
919
993
|
origin: { type: 'string', description: '생성 job 식별자·.mmd 원본 경로·제공자 이름' },
|
|
994
|
+
caption: { type: 'string', description: '도판 캡션. 그림 번호는 자리가 매기므로 적지 않는다' },
|
|
995
|
+
source_text: { type: 'string', description: '캡션 아래 「출처」 줄 — 이 그림이 어디서 왔는지' },
|
|
996
|
+
verdict: {
|
|
997
|
+
type: 'string', enum: ['직접 확인', '조사 보고', '미확인'],
|
|
998
|
+
description: '이 근거를 **얼마나 믿어도 되는지**. 캡션 끝에 칩으로 선다.\n'
|
|
999
|
+
+ '· 직접 확인 — 원문을 직접 열어 그 수치를 확인함\n'
|
|
1000
|
+
+ '· 조사 보고 — 2차 출처를 경유해 확인함. 원문 대조는 안 함\n'
|
|
1001
|
+
+ '· 미확인 — 널리 쓰이나 1차 출처를 특정하지 못함\n'
|
|
1002
|
+
+ '`grades`(건의 확인 강도)와 **다른 축이다** — 저건 「우리가 무엇을 확인했나」이고 '
|
|
1003
|
+
+ '이건 「이 근거를 얼마나 믿어도 되나」다. 실사례(2026-08-24): 원판이 3단으로 나눠 둔 '
|
|
1004
|
+
+ '이 축을 포털본이 통째로 버려 2차 출처와 미확인 주장이 **사실로 단언돼 나갔다.** '
|
|
1005
|
+
+ '증거를 실으면서 등급을 안 붙이면 문서가 실제보다 확실해 보인다.',
|
|
1006
|
+
},
|
|
920
1007
|
brief: {
|
|
921
1008
|
type: 'string',
|
|
922
1009
|
description: '**무엇을 그릴지 — 장면 한두 문장.** 이것만 주면 서버가 그린다(그리는 동안 '
|
|
@@ -1873,6 +1960,22 @@ function startObserve() {
|
|
|
1873
1960
|
observeTimer.unref?.(); // 창을 닫으면 노드가 남지 않게
|
|
1874
1961
|
}
|
|
1875
1962
|
|
|
1963
|
+
/*
|
|
1964
|
+
* ── 메일 폴은 **심박과 따로** 돈다 (2026-08-25 운영 첫 왕복 실측) ─────────────────
|
|
1965
|
+
* 전에는 beatTick 꼬리에 얹었는데, beatTick 은 색인에 올린 세션이 없으면 **일찍 빠져나간다**
|
|
1966
|
+
* (`if (!recs.length) return`). cell/README 가 observeTick 에 대해 경고한 바로 그 무늬를
|
|
1967
|
+
* 밟았다 — 새로 뜬 세션은 색인 밖이라 메일을 영영 안 길었다. 자기 타이머로 뗀다.
|
|
1968
|
+
*/
|
|
1969
|
+
let mailTimer = null;
|
|
1970
|
+
function startMailPoll() {
|
|
1971
|
+
if (cfg.GENETICS_NO_BEAT) return;
|
|
1972
|
+
const cred = credential();
|
|
1973
|
+
if (!BASE || !cred || cred.kind !== 'user') return; // 서버가 사람 토큰만 받는다
|
|
1974
|
+
mailPull().catch(() => {});
|
|
1975
|
+
mailTimer = setInterval(() => { mailPull().catch(() => {}); }, MAIL_POLL_MS);
|
|
1976
|
+
mailTimer.unref?.();
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1876
1979
|
function startBeat() {
|
|
1877
1980
|
if (cfg.GENETICS_NO_BEAT) return; // 심박을 원치 않는 사람의 탈출구
|
|
1878
1981
|
const cred = credential();
|
|
@@ -2086,7 +2189,9 @@ const TOOLS = [
|
|
|
2086
2189
|
+ '**알아낸 것 하나**면 이 도구다.\n'
|
|
2087
2190
|
+ '무엇을 적는가: 증상은 **겪은 현상 그대로**(다음 사람은 원인을 모르는 채 이 낱말로 찾는다), '
|
|
2088
2191
|
+ '처방은 다시 하면 되는 동작, 근거는 실측치·커밋·로그. 원인과 근거는 비워도 되지만 '
|
|
2089
|
-
+ '비면 그렇게 적힌다 — 없는 확신을 지어내지 말
|
|
2192
|
+
+ '비면 그렇게 적힌다 — 없는 확신을 지어내지 말 것.\n'
|
|
2193
|
+
+ '근거에 **저장소 상대 경로**(예: www/portal/lib/x.php:12)를 적으면 그 파일을 읽은 채로 든 '
|
|
2194
|
+
+ '다른 세션에게 이 사례가 메일로 간다 — 검색하지 않아도 닿는다.',
|
|
2090
2195
|
inputSchema: {
|
|
2091
2196
|
type: 'object',
|
|
2092
2197
|
properties: {
|
|
@@ -2097,6 +2202,12 @@ const TOOLS = [
|
|
|
2097
2202
|
fix: { type: 'string', description: '처방. 다시 만나면 그대로 하면 되는 동작.' },
|
|
2098
2203
|
evidence: { type: 'string', description: '근거 — 실측치·커밋 해시·로그 한 줄. 비면 "안 적혔다"로 남는다.' },
|
|
2099
2204
|
tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8).' },
|
|
2205
|
+
paths: { type: 'array', items: { type: 'string' },
|
|
2206
|
+
description: '이 사례가 걸린 **저장소 상대 경로**(예: www/portal/lib/x.php). 본문에 적힌 경로는 자동으로 들어간다.' },
|
|
2207
|
+
commands: { type: 'array', items: { type: 'string' },
|
|
2208
|
+
description: '이 사례가 걸린 **명령 문구**(예: "INSERT INTO rp_agent_presence"). 다른 세포가 그 문구가 든 명령을 치기 직전에 이 사례를 본다.' },
|
|
2209
|
+
errors: { type: 'array', items: { type: 'string' },
|
|
2210
|
+
description: '이 사례의 **오류 문자열**(예: "CONSTRAINT ck_tree_machine failed"). 지금은 적어 두기만 한다.' },
|
|
2100
2211
|
},
|
|
2101
2212
|
required: ['tenant', 'title', 'symptom', 'fix'],
|
|
2102
2213
|
},
|
|
@@ -2125,6 +2236,39 @@ const TOOLS = [
|
|
|
2125
2236
|
required: ['tenant', 'q'],
|
|
2126
2237
|
},
|
|
2127
2238
|
},
|
|
2239
|
+
{
|
|
2240
|
+
name: 'get_case',
|
|
2241
|
+
description:
|
|
2242
|
+
'**사례·지식 장의 전문을 읽는다** — 증상·원인·처방·근거, 결정문이면 `상태:`(뒤집힘·조건 충족)까지. '
|
|
2243
|
+
+ 'search_cases 가 준 path 를 그대로 넣는다. 검색 미리보기는 200자라 처방이 잘려 있다 — '
|
|
2244
|
+
+ '처방대로 하기 전에 이것으로 전문을 읽는다. 사례 메일의 「전문: get_case(…)」도 여기다.',
|
|
2245
|
+
inputSchema: {
|
|
2246
|
+
type: 'object',
|
|
2247
|
+
properties: {
|
|
2248
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
2249
|
+
path: { type: 'string', description: 'search_cases 결과의 path 그대로(예: cases/….md).' },
|
|
2250
|
+
},
|
|
2251
|
+
required: ['tenant', 'path'],
|
|
2252
|
+
},
|
|
2253
|
+
},
|
|
2254
|
+
{
|
|
2255
|
+
name: 'case_verdict',
|
|
2256
|
+
description:
|
|
2257
|
+
'**사람이 말한 판정을 사례에 적는다** — 「그 처방대로 했더니 맞았다(applied) / 안 맞았다(refuted) / '
|
|
2258
|
+
+ '이 자리와 무관했다(irrelevant)」. 사람이 그렇게 **말했을 때만** 부른다 — 스스로 판단해 적지 않는다'
|
|
2259
|
+
+ '(이것은 세션이 중계한 판정으로 기록·표시되고, 학습에 세는 것은 사람이 지식 화면에서 누른 판정뿐이다). '
|
|
2260
|
+
+ 'path 는 search_cases·get_case 의 path 그대로.',
|
|
2261
|
+
inputSchema: {
|
|
2262
|
+
type: 'object',
|
|
2263
|
+
properties: {
|
|
2264
|
+
tenant: { type: 'string', description: '대상 소속처 이름.' },
|
|
2265
|
+
path: { type: 'string', description: '사례 경로(cases/….md).' },
|
|
2266
|
+
verdict: { type: 'string', enum: ['applied', 'refuted', 'irrelevant'], description: '사람이 말한 판정.' },
|
|
2267
|
+
note: { type: 'string', description: '한 줄 메모(선택, 200자).' },
|
|
2268
|
+
},
|
|
2269
|
+
required: ['tenant', 'path', 'verdict'],
|
|
2270
|
+
},
|
|
2271
|
+
},
|
|
2128
2272
|
{
|
|
2129
2273
|
name: 'list_sessions',
|
|
2130
2274
|
description:
|
|
@@ -2210,6 +2354,43 @@ const TOOLS = [
|
|
|
2210
2354
|
required: ['tenant', 'uuid'],
|
|
2211
2355
|
},
|
|
2212
2356
|
},
|
|
2357
|
+
/*
|
|
2358
|
+
* ★ **보내는 쪽만 도구다.** 받는 쪽은 도구가 아니라 도구 응답 꼬리로 온다 —
|
|
2359
|
+
* `README.md:26-34` 의 실측 때문이다: `search_cases` 가 실시간으로 있었는데 **0번 불렀다.**
|
|
2360
|
+
* 부를 생각이 나려면 이미 「내가 뭘 놓쳤나」를 알아야 하는데, 놓친 순간에는 그것을 모른다.
|
|
2361
|
+
*/
|
|
2362
|
+
{
|
|
2363
|
+
name: 'mail',
|
|
2364
|
+
description:
|
|
2365
|
+
'**다른 세션이나 두뇌에게 말을 건다.** 이 기계의 옆 창뿐 아니라 **다른 기계·Codex 세션에도 닿는다** '
|
|
2366
|
+
+ '— 서버를 지나기 때문이다(`SendMessage` 는 이 기계의 Claude Code 만 본다).\n'
|
|
2367
|
+
+ '무엇을 해 달라고 **부탁해도 된다** — 이 창구는 받는 쪽 눈앞에 「누가 보냈는지」를 서버가 '
|
|
2368
|
+
+ '반드시 붙여 주므로, 세계 장부와 달리 지시형이 막히지 않는다.\n'
|
|
2369
|
+
+ '받는 것은 부를 필요가 없다 — 온 것은 다른 도구를 부를 때 응답 꼬리에 저절로 실린다.\n'
|
|
2370
|
+
+ '**사람에게는 이메일로** 보낸다(그 소속처 담당만) — 포털 벨이 울린다.\n'
|
|
2371
|
+
+ '⚠ 상대 이름은 **지어내지 말 것.** `world` 나 `gcell agents` 가 아는 이름, 세션 uuid, '
|
|
2372
|
+
+ '또는 `brain` 중 하나다. 못 찾으면 사용자에게 묻는다.\n'
|
|
2373
|
+
+ '⚠ 왕복에는 상한이 있다(사람이 낀 실 5회, 기계끼리 3회) — 끝나면 사람에게 올라간다.',
|
|
2374
|
+
inputSchema: {
|
|
2375
|
+
type: 'object',
|
|
2376
|
+
properties: {
|
|
2377
|
+
tenant: { type: 'string', description: '소속처 이름. 모르면 list_my_tenants 로 먼저 확인한다' },
|
|
2378
|
+
to: { type: 'string', description: '받는 쪽 — 세포 이름 · 세션 uuid · 또는 brain' },
|
|
2379
|
+
body: { type: 'string', description: '할 말' },
|
|
2380
|
+
subject: { type: 'string', description: '한 줄 제목(선택)' },
|
|
2381
|
+
artifact: { type: 'string', description: '무엇에 대한 이야기인가 — 파일·문서 경로(선택)' },
|
|
2382
|
+
done_when: { type: 'string', description: '무엇이 되면 끝인가(선택)' },
|
|
2383
|
+
thread: { type: 'string', description: '답장이면 받은 통의 thread. 새 이야기면 넣지 않는다' },
|
|
2384
|
+
},
|
|
2385
|
+
/*
|
|
2386
|
+
* ⚠⚠ **발신자를 여기 안 적는다.** `DESIGN-session-as-vcs.md` §5.1 —
|
|
2387
|
+
* 「노출하면 그 값은 모델이 **지어낸다**」. `session` 은 아래 배선이 로컬 환경에서
|
|
2388
|
+
* 채우고, 서버는 그것이 이 자격으로 등록된 주소인지 다시 확인한다.
|
|
2389
|
+
*/
|
|
2390
|
+
required: ['tenant', 'to', 'body'],
|
|
2391
|
+
additionalProperties: false,
|
|
2392
|
+
},
|
|
2393
|
+
},
|
|
2213
2394
|
{
|
|
2214
2395
|
name: 'install_cell',
|
|
2215
2396
|
description:
|
|
@@ -2578,12 +2759,238 @@ function brainSays() {
|
|
|
2578
2759
|
+ ' 「⚠ 상태:」가 붙은 장이 어느 것인지 보인다.';
|
|
2579
2760
|
}
|
|
2580
2761
|
|
|
2762
|
+
/* ── ★★ 메일 레인 — 두뇌·다른 세션·사람이 **나를 지목해** 보낸 것 ──────────
|
|
2763
|
+
*
|
|
2764
|
+
* 설계는 `DESIGN-mail.md`, 규칙은 서버의 `lib/mail.php`(순수), 표는 051·052.
|
|
2765
|
+
*
|
|
2766
|
+
* ── 왜 백신과 따로인가 ────────────────────────────────────────────────────
|
|
2767
|
+
* 백신은 **주소가 없다** — 두뇌가 이 폴더에 대고 말하는 방송이고, 아무도 안 들어도
|
|
2768
|
+
* 아무 일도 안 난다. 메일은 반대다: 발신자와 수신자가 있고, 못 받으면 만료되어
|
|
2769
|
+
* **발신자에게 되돌아간다.** 두 가지를 한 큐에 담으면 그 차이가 사라진다.
|
|
2770
|
+
*
|
|
2771
|
+
* ⚠ **첫 바퀴 나이 검사를 안 한다.** 백신은 커서가 0일 때 지난 것이 통째로 내려와
|
|
2772
|
+
* 「끝난 이야기를 지금 하는 일로」 읽을 위험이 있어 10분 안쪽만 말한다. 메일은 다르다 —
|
|
2773
|
+
* 사람이 보낸 것일 수 있고, 오래됐다고 버리면 그것이 곧 조용한 손실이다.
|
|
2774
|
+
* 대신 **서버의 `expires_at` 이 판정한다**(만료된 것은 애초에 안 내려온다).
|
|
2775
|
+
*
|
|
2776
|
+
* ⚠ **받는 도구를 안 만든다.** `README.md:26-34` 의 실측 그대로다 — `search_cases` 가
|
|
2777
|
+
* 실시간으로 있었는데 **0번 불렀다.** 부를 생각이 나려면 이미 「내가 뭘 놓쳤나」를
|
|
2778
|
+
* 알아야 하는데, 놓친 순간에는 그것을 모른다. 그래서 받는 쪽은 **도구가 아니라
|
|
2779
|
+
* 도구 응답 꼬리**다(`worldAppend`). 보내는 쪽만 도구로 둔다.
|
|
2780
|
+
*/
|
|
2781
|
+
const MAIL_POLL_MS = 60_000; // 사람이 기다린다 — 백신(120초)보다 자주 본다
|
|
2782
|
+
const MAIL_PAGE = 20;
|
|
2783
|
+
let mailAt = 0;
|
|
2784
|
+
let mailCursor = null; // null = 아직 색인에서 안 읽었다
|
|
2785
|
+
let mailAddrAt = 0; // 마지막으로 주소를 댄 때
|
|
2786
|
+
const mailQueue = []; // 아직 안 말한 통(서버가 렌더한 글자를 그대로 싣는다)
|
|
2787
|
+
const mailBack = []; // 내가 부쳤다 만료된 것
|
|
2788
|
+
const mailBackSeen = new Set();
|
|
2789
|
+
|
|
2790
|
+
/**
|
|
2791
|
+
* 이 세션의 **말 걸 수 있는 이름.** 전사에서 마지막 자기소개를 캔다.
|
|
2792
|
+
*
|
|
2793
|
+
* ★ 왜 전사인가: `ListAgents` 를 부르면 하네스가 「This session is <이름> [<id>]」를
|
|
2794
|
+
* **전사에 적는다.** 그것은 모델이 지어낸 값이 아니라 하네스가 쓴 사실이고,
|
|
2795
|
+
* 전사 파일 이름이 곧 세션 uuid 라 두 체계가 거기서 만난다.
|
|
2796
|
+
* 세포의 `agent_name_in`(`cell/src/world.rs:443`)과 **같은 규칙**을 쓴다 —
|
|
2797
|
+
* 두 자로 재면 같은 세션을 다르게 부르게 된다.
|
|
2798
|
+
*
|
|
2799
|
+
* ⚠ **마지막 것**을 쓴다. 이름은 세션 도중에 바뀌고, 옛 이름은 죽은 주소다.
|
|
2800
|
+
* ⚠ **늘어난 부분만 다시 읽는다.** 전사가 7MB 까지 가므로(실측) 매번 통독하면
|
|
2801
|
+
* 그 자체가 부하다. 이름은 앞으로만 생기니 뒤만 보면 된다.
|
|
2802
|
+
* ⚠ 못 찾으면 `null` — **지어내지 않는다.** 이름 없는 주소는 받을 수는 있어도 못 보낸다.
|
|
2803
|
+
*/
|
|
2804
|
+
/*
|
|
2805
|
+
* ⚠⚠ **줄 안의 글자가 아니라 레코드의 모양으로 읽는다** (2026-08-25 실측).
|
|
2806
|
+
* 처음엔 전사 바이트 전체에 정규식을 대고 마지막 일치를 이름으로 삼았다. 그런데 이
|
|
2807
|
+
* 세션이 smoke 픽스처로 `This session is report-portal-17 [e51bae]` 를 **자기 전사에
|
|
2808
|
+
* 썼고**(assistant 레코드), 세포가 같은 규칙으로 그 이름을 이 세션에 달았다. 이 함수가
|
|
2809
|
+
* 그대로였다면 MCP 가 그 이름으로 자기 등록해 **이름 최신-승(052)으로 진짜 17 의 주소를
|
|
2810
|
+
* 뺏었을 것**이다. 세포 명의 오귀속의 다섯 번째 무늬 — 이번엔 이름 쪽이다.
|
|
2811
|
+
*
|
|
2812
|
+
* 진짜 자기소개는 하네스(ListAgents)만 낸다: `type:"user"` 레코드의 `content[0]` 이
|
|
2813
|
+
* `tool_result` 이고 본문이 **「This session is …」로 시작**하며 꼬리에 INTRO_TAIL 이 있다.
|
|
2814
|
+
* 세포의 `agent_name_in_record`(cell/src/world.rs)와 **같은 규칙** — 두 자로 읽으면
|
|
2815
|
+
* 같은 세션을 다르게 부른다.
|
|
2816
|
+
* ⚠ 남는 구멍: Bash 로 남의 전사 줄을 통째로 찍어 tool_result 가 정확히 그 꼴로 시작하면
|
|
2817
|
+
* 속는다 — 그건 사람이 일부러 한 것이고, 여기 적어 둔다.
|
|
2818
|
+
*/
|
|
2819
|
+
const INTRO_HEAD = 'This session is ';
|
|
2820
|
+
const INTRO_TAIL = 'the name other sessions use';
|
|
2821
|
+
const AGENT_NAME_RE = /^This session is ([A-Za-z0-9._-]{1,60}) \[([0-9a-fA-F]+)\]/;
|
|
2822
|
+
function introNameOf(line) {
|
|
2823
|
+
if (!line.includes(INTRO_HEAD)) return null; // 값싼 검사로 먼저 거른다
|
|
2824
|
+
let d;
|
|
2825
|
+
try { d = JSON.parse(line); } catch { return null; }
|
|
2826
|
+
if (d?.type !== 'user') return null; // ★ 모델이 쓴 글(assistant)은 이름이 아니다
|
|
2827
|
+
const c = d?.message?.content;
|
|
2828
|
+
const first = Array.isArray(c) ? c[0] : null;
|
|
2829
|
+
if (!first || first.type !== 'tool_result') return null;
|
|
2830
|
+
let body = first.content;
|
|
2831
|
+
if (Array.isArray(body)) body = body.map((x) => (x && x.type === 'text' ? String(x.text || '') : '')).join('');
|
|
2832
|
+
if (typeof body !== 'string' || !body.startsWith(INTRO_HEAD) || !body.includes(INTRO_TAIL)) return null;
|
|
2833
|
+
const m = AGENT_NAME_RE.exec(body);
|
|
2834
|
+
return m ? m[1] : null;
|
|
2835
|
+
}
|
|
2836
|
+
let nameCache = null; // { file, size, name }
|
|
2837
|
+
function myAgentName() {
|
|
2838
|
+
const uuid = process.env.CLAUDE_CODE_SESSION_ID || '';
|
|
2839
|
+
if (!/^[0-9a-f-]{36}$/i.test(uuid)) return null;
|
|
2840
|
+
const f = transcriptOf(uuid);
|
|
2841
|
+
if (!f) return null;
|
|
2842
|
+
let st;
|
|
2843
|
+
try { st = fs.statSync(f); } catch { return null; }
|
|
2844
|
+
if (nameCache?.file === f && nameCache.size === st.size) return nameCache.name;
|
|
2845
|
+
const from = (nameCache?.file === f && st.size > nameCache.size) ? nameCache.size : 0;
|
|
2846
|
+
let found = (from > 0 ? nameCache.name : null);
|
|
2847
|
+
try {
|
|
2848
|
+
const fd = fs.openSync(f, 'r');
|
|
2849
|
+
try {
|
|
2850
|
+
const len = st.size - from;
|
|
2851
|
+
if (len > 0 && len < 64 * 1024 * 1024) {
|
|
2852
|
+
const buf = Buffer.allocUnsafe(len);
|
|
2853
|
+
fs.readSync(fd, buf, 0, len, from);
|
|
2854
|
+
const txt = buf.toString('utf8');
|
|
2855
|
+
// 줄 단위 — 마지막 것이 이긴다(옛 이름은 죽은 주소다).
|
|
2856
|
+
// ⚠ 쓰는 중인 마지막 줄(개행 없음)은 이번에 안 읽고 다음에 그 자리부터 다시 읽는다 —
|
|
2857
|
+
// 캐시 크기를 마지막 개행까지만 전진시킨다. 안 그러면 잘린 줄이 영영 사라진다.
|
|
2858
|
+
const cut = txt.lastIndexOf('\n');
|
|
2859
|
+
const whole = cut >= 0 ? txt.slice(0, cut + 1) : '';
|
|
2860
|
+
for (const line of whole.split('\n')) {
|
|
2861
|
+
const n = introNameOf(line);
|
|
2862
|
+
if (n) found = n;
|
|
2863
|
+
}
|
|
2864
|
+
nameCache = { file: f, size: from + Buffer.byteLength(whole, 'utf8'), name: found };
|
|
2865
|
+
return found;
|
|
2866
|
+
}
|
|
2867
|
+
} finally { fs.closeSync(fd); }
|
|
2868
|
+
} catch { /* 못 읽으면 이름이 없는 것이지 고장이 아니다 */ }
|
|
2869
|
+
nameCache = { file: f, size: st.size, name: found };
|
|
2870
|
+
return found;
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
/** 이 세션이 메일을 다룰 조건을 갖췄나. 못 갖췄으면 `null` — 조용히 안 한다. */
|
|
2874
|
+
/*
|
|
2875
|
+
* ⚠ **받기는 색인에 매이면 안 된다** (2026-08-25 운영 첫 왕복 실측). 이 세션은 push/list_sessions 를
|
|
2876
|
+
* 한 번도 안 해 색인에 없었고, 그래서 mail 도구로 **보낼 수는 있는데 받지는 못했다** —
|
|
2877
|
+
* 0f 의 답장이 꼬리에 영영 안 실렸다. 색인이 없으면 mail 도구가 마지막으로 쓴 소속처를
|
|
2878
|
+
* 힌트로 쓴다(그 소속처에 주소를 댔으니 받을 곳도 거기다).
|
|
2879
|
+
*/
|
|
2880
|
+
let mailTenantHint = null; // { tenant, tenant_id } — mail 도구가 성공할 때 적는다
|
|
2881
|
+
function mailReady() {
|
|
2882
|
+
const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
|
|
2883
|
+
if (!mine) return null;
|
|
2884
|
+
let e = indexRead()[mine];
|
|
2885
|
+
if (!e?.tenant && mailTenantHint) e = { ...(e || {}), ...mailTenantHint };
|
|
2886
|
+
if (!e?.tenant) return null; // 아직 어느 소속처인지 모른다 — 받기엔 이름이면 족하다
|
|
2887
|
+
const cred = credential();
|
|
2888
|
+
// 세계 장부와 같다 — 서버가 사람 토큰만 받는다.
|
|
2889
|
+
if (!cred || cred.kind !== 'user') return null;
|
|
2890
|
+
return { mine, e };
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
/**
|
|
2894
|
+
* 주소를 **스스로 댄다.** 관측 추정이 아니다(052 머리말의 세 실측).
|
|
2895
|
+
*
|
|
2896
|
+
* 이름이 없어도 등록한다 — 받을 수는 있어야 하기 때문이다. 이름이 생기면 다시 댄다.
|
|
2897
|
+
*/
|
|
2898
|
+
async function mailRegister(tenant, mine, force = false) {
|
|
2899
|
+
if (!force && Date.now() - mailAddrAt < MAIL_POLL_MS) return;
|
|
2900
|
+
mailAddrAt = Date.now();
|
|
2901
|
+
const name = myAgentName();
|
|
2902
|
+
await api('POST', '/api/mail/register', {
|
|
2903
|
+
tenant,
|
|
2904
|
+
session: mine,
|
|
2905
|
+
name: name || '',
|
|
2906
|
+
client: 'claude-code',
|
|
2907
|
+
cwd: relHome(process.cwd()),
|
|
2908
|
+
});
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
/**
|
|
2912
|
+
* 내 앞으로 온 것을 긷고, **받았다고 도장을 찍는다.**
|
|
2913
|
+
*
|
|
2914
|
+
* ⚠ 도장을 큐에 넣기 **전에** 찍는다. 뒤에 찍으면 프로세스가 그 사이 죽었을 때 같은 통을
|
|
2915
|
+
* 다음 판에서 또 말한다 — 그리고 그건 조용하다. 반대로 먼저 찍고 죽으면 한 통을 잃는데,
|
|
2916
|
+
* 그쪽은 **발신자가 만료로 알게 된다.** 두 손실 중 알 수 있는 쪽을 고른다.
|
|
2917
|
+
*/
|
|
2918
|
+
async function mailPull() {
|
|
2919
|
+
const r0 = mailReady();
|
|
2920
|
+
if (!r0) return;
|
|
2921
|
+
const { mine, e } = r0;
|
|
2922
|
+
if (Date.now() - mailAt < MAIL_POLL_MS) return;
|
|
2923
|
+
mailAt = Date.now();
|
|
2924
|
+
|
|
2925
|
+
try { await mailRegister(e.tenant, mine); } catch { /* 등록 실패가 받기를 막지 않는다 */ }
|
|
2926
|
+
|
|
2927
|
+
if (mailCursor === null) mailCursor = Number(e.mail_cursor || 0);
|
|
2928
|
+
/*
|
|
2929
|
+
* ⚠ **자리(`cwd`)를 안 보낸다.** 창구가 그것으로 거르던 동안 주소 있는 메일이 조용히
|
|
2930
|
+
* 사라졌다(실측 2026-08-25: cwd 없이 1통 · cwd 있게 0통). 메일은 폴더가 아니라
|
|
2931
|
+
* 세션에 부친 것이라 **주소가 이미 필터**다.
|
|
2932
|
+
*/
|
|
2933
|
+
const q = '?tenant=' + encodeURIComponent(e.tenant)
|
|
2934
|
+
+ '&since=' + mailCursor
|
|
2935
|
+
+ '&session=' + encodeURIComponent(mine)
|
|
2936
|
+
+ '&limit=' + MAIL_PAGE;
|
|
2937
|
+
const r = await api('GET', '/api/mail' + q);
|
|
2938
|
+
|
|
2939
|
+
const rows = Array.isArray(r?.mail) ? r.mail : [];
|
|
2940
|
+
if (typeof r?.cursor === 'number' && r.cursor > mailCursor) mailCursor = r.cursor;
|
|
2941
|
+
indexNote(mine, { tenant: e.tenant, tenant_id: e.tenant_id, mail_cursor: mailCursor });
|
|
2942
|
+
|
|
2943
|
+
if (rows.length) {
|
|
2944
|
+
const ids = rows.map((m) => Number(m.id)).filter((n) => n > 0);
|
|
2945
|
+
if (ids.length) {
|
|
2946
|
+
try { await api('POST', '/api/mail/ack', { tenant: e.tenant, ids }); }
|
|
2947
|
+
catch { /* 도장을 못 찍어도 말은 한다 — 못 찍은 것은 다음 바퀴에 또 온다 */ }
|
|
2948
|
+
}
|
|
2949
|
+
/*
|
|
2950
|
+
* ★ **서버가 쓴 글자를 그대로 싣는다.** 발신자 표시(`[누구 → 너]`)를 붙이는 자리는
|
|
2951
|
+
* 서버의 `mail_render()` 하나뿐이고, 여기서 다시 지으면 두 자가 갈린다.
|
|
2952
|
+
* 그리고 갈리는 순간 「누가 말하는지」가 보증이 아니라 장식이 된다 —
|
|
2953
|
+
* 이 레일이 명령형을 허용하는 근거가 바로 그 보증이다.
|
|
2954
|
+
*/
|
|
2955
|
+
if (typeof r?.says === 'string' && r.says) mailQueue.push(r.says);
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
for (const b of (Array.isArray(r?.returned) ? r.returned : [])) {
|
|
2959
|
+
const id = Number(b?.id) || 0;
|
|
2960
|
+
if (!id || mailBackSeen.has(id)) continue; // 서버가 1시간 동안 되풀이해 준다 — 접는 것은 우리 몫
|
|
2961
|
+
mailBackSeen.add(id);
|
|
2962
|
+
mailBack.push(` · ${cellClean(b.to_name || b.to_session || '')} 앞 «${cellClean(b.subject || b.body || '')}»`);
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
/**
|
|
2967
|
+
* 쌓인 것을 **한 번씩만** 말한다. 없으면 빈 문자열 — 조용한 구간은 조용해야 한다.
|
|
2968
|
+
*
|
|
2969
|
+
* ⚠ 여기서 지시를 붙이지 않는다. 붙일 필요도 없다 — 본문 자체가 사람이나 다른 세션이
|
|
2970
|
+
* 쓴 말이고, 그 앞에 **누가 말하는지가 이미 서버 손으로 박혀 있다.**
|
|
2971
|
+
*/
|
|
2972
|
+
function mailSays() {
|
|
2973
|
+
const out = [];
|
|
2974
|
+
if (mailQueue.length) out.push(...mailQueue.splice(0, mailQueue.length));
|
|
2975
|
+
if (mailBack.length) {
|
|
2976
|
+
out.push('내가 부친 것이 못 닿고 만료됐다:\n' + mailBack.splice(0, mailBack.length).join('\n'));
|
|
2977
|
+
}
|
|
2978
|
+
return out.join('\n\n');
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2581
2981
|
function worldAppend(result) {
|
|
2582
2982
|
/*
|
|
2583
2983
|
* ★ 백신이 **먼저**다. 세계 델타는 「누가 무엇을 썼다」는 배경이고, 모순은 지금 하는 일을
|
|
2584
2984
|
* 바꿀 수 있는 사실이다. 그리고 델타가 비어도(조용한 구간) 백신은 나가야 한다 —
|
|
2585
2985
|
* 아래 `if (!t) return;` 뒤에 두면 **조용한 날엔 영영 안 간다.**
|
|
2586
2986
|
*/
|
|
2987
|
+
/*
|
|
2988
|
+
* ★★ 메일이 **가장 앞**이다. 백신은 주소 없는 방송이고 델타는 배경인데, 메일은
|
|
2989
|
+
* 누군가가 **나를 지목해** 보낸 것이다. 그리고 만료가 있어 늦으면 되돌아간다 —
|
|
2990
|
+
* 셋 중 유일하게 **시간이 걸려 있는** 것이라 순서에서 앞선다.
|
|
2991
|
+
*/
|
|
2992
|
+
const mm = mailSays();
|
|
2993
|
+
if (mm) result.content.push({ type: 'text', text: mm });
|
|
2587
2994
|
const v = brainSays();
|
|
2588
2995
|
if (v) result.content.push({ type: 'text', text: v });
|
|
2589
2996
|
const t = worldDelta();
|
|
@@ -2746,15 +3153,23 @@ async function callTool(name, args = {}) {
|
|
|
2746
3153
|
const d = Number(p.attested_days) > 0 ? ` ${p.attested_days}일` : '';
|
|
2747
3154
|
return ` · ${rel}${d}`;
|
|
2748
3155
|
};
|
|
3156
|
+
/*
|
|
3157
|
+
* 표지 마크가 **없는** 곳만 말한다. 있는 곳은 서버가 알아서 끼우므로 할 말이 없다.
|
|
3158
|
+
*
|
|
3159
|
+
* ⚠ `=== false` 여야 한다. `!t.has_logo` 로 쓰면 이 칸이 없는 **옛 서버**에서
|
|
3160
|
+
* undefined 가 falsy 라 **모든 고객사에 「로고 없음」이 붙는다** — 「모르는 것을
|
|
3161
|
+
* 지어내지 않는다」는 이 도구의 규약을 정면으로 깬다(smoke 가 그것을 잡는다).
|
|
3162
|
+
*/
|
|
3163
|
+
const mark = (t) => (t.has_logo === false ? ' · 로고 없음' : '');
|
|
2749
3164
|
|
|
2750
3165
|
if (list.length === 1) {
|
|
2751
|
-
return `속한 곳이 하나입니다 — **${list[0].name}
|
|
3166
|
+
return `속한 곳이 하나입니다 — **${list[0].name}**${mark(list[0])}\n`
|
|
2752
3167
|
+ '다른 도구의 `tenant` 에 이 이름을 그대로 쓰세요.'
|
|
2753
3168
|
+ (says ? `\n\n${says}` : '');
|
|
2754
3169
|
}
|
|
2755
3170
|
const lines = list.map((t) => {
|
|
2756
3171
|
const when = t.last_used_at ? String(t.last_used_at).slice(0, 10) : '기록 없음';
|
|
2757
|
-
return ` · ${t.name}${seen(t)} · 최근 ${when}`;
|
|
3172
|
+
return ` · ${t.name}${seen(t)}${mark(t)} · 최근 ${when}`;
|
|
2758
3173
|
});
|
|
2759
3174
|
return `속한 곳 ${list.length}곳입니다. **사용자에게 어디로 할지 물어보세요** — 고르지 마세요.\n`
|
|
2760
3175
|
+ `${lines.join('\n')}\n`
|
|
@@ -2833,7 +3248,8 @@ async function callTool(name, args = {}) {
|
|
|
2833
3248
|
const lines = (o.blocks || []).map((b) =>
|
|
2834
3249
|
` ${b.tag}.${b.class}${b.side ? ` [${b.side}]` : ''}${b.text ? ` — ${b.text}` : ''}`);
|
|
2835
3250
|
return `발행물 구조 — 절 ${c.sections ?? '?'} · 건 ${c.cases ?? '?'} · 장 ${c.chapters ?? '?'}`
|
|
2836
|
-
+ ` · 그림 ${c.images ?? '?'}
|
|
3251
|
+
+ ` · 그림 ${c.images ?? '?'}(표지마크 ${c.cover_mark ?? 0})`
|
|
3252
|
+
+ ` · 좌우 ${c.side_l ?? 0}/${c.side_r ?? 0}`
|
|
2837
3253
|
+ ` · 도해 ${c.logic ?? 0} · 원칙카드 ${c.principles ?? 0}\n`
|
|
2838
3254
|
+ lines.join('\n')
|
|
2839
3255
|
+ (o.empty?.length ? `\n\n⚠ 빈 블록 ${o.empty.length}건: ${o.empty.join(', ')}` : '\n\n빈 블록 없음');
|
|
@@ -2907,12 +3323,32 @@ async function callTool(name, args = {}) {
|
|
|
2907
3323
|
const r = await api('POST', '/api/wiki/case?tenant=' + encodeURIComponent(args.tenant), {
|
|
2908
3324
|
title: args.title, symptom: args.symptom, cause: args.cause || '', fix: args.fix,
|
|
2909
3325
|
evidence: args.evidence || '', tags: args.tags || [],
|
|
3326
|
+
paths: args.paths || [], commands: args.commands || [], errors: args.errors || [],
|
|
2910
3327
|
// ★ **출처는 우리가 채운다 — 모델에게 안 묻는다.** 스키마에 노출하면 지어낸다
|
|
2911
3328
|
// (DESIGN-session-as-vcs.md:136-141). 모르면 안 보내고, 서버는 안 적는다.
|
|
2912
3329
|
...myCitation(),
|
|
2913
3330
|
});
|
|
2914
|
-
|
|
2915
|
-
|
|
3331
|
+
// 밀기 결과도 그대로 옮긴다 — 몇에게 갔고 누가 왜 못 받았는지(구서버는 필드가 없어 침묵).
|
|
3332
|
+
const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
|
|
3333
|
+
if (Array.isArray(r.matched_paths)) {
|
|
3334
|
+
if (!r.matched_paths.length) {
|
|
3335
|
+
lines.push('경로가 없어 아무에게도 안 갔다 — 근거에 저장소 상대 경로(예: www/portal/lib/x.php:12)를 적으면 그 파일을 읽은 채로 든 세션에게 간다.');
|
|
3336
|
+
} else if (r.pushed > 0) {
|
|
3337
|
+
lines.push(`→ ${r.matched_paths.join(' · ')} 을(를) 들고 있던 세션 ${r.pushed}곳에 보냈다.`);
|
|
3338
|
+
} else {
|
|
3339
|
+
lines.push(`→ ${r.matched_paths.join(' · ')} — 지금 그 파일을 든 살아 있는 세션이 없어 아무에게도 안 갔다.`);
|
|
3340
|
+
}
|
|
3341
|
+
if (Array.isArray(r.skipped) && r.skipped.length) lines.push('못 보낸 곳: ' + r.skipped.join(' / '));
|
|
3342
|
+
}
|
|
3343
|
+
if (r.triggers && typeof r.triggers === 'object') {
|
|
3344
|
+
const t = r.triggers;
|
|
3345
|
+
const n = (t.paths?.length || 0) + (t.commands?.length || 0) + (t.errors?.length || 0);
|
|
3346
|
+
lines.push(n
|
|
3347
|
+
? `트리거 ${n}개 — 경로 ${t.paths?.length || 0} · 명령 ${t.commands?.length || 0} · 오류 ${t.errors?.length || 0}: 다른 세포가 그 자리를 만지기 직전에 이 사례를 본다.`
|
|
3348
|
+
: '트리거 0 — 경로·명령 문구가 없어 도구 직전 매칭은 안 된다(commands 에 명령 문구를 적으면 걸린다).');
|
|
3349
|
+
}
|
|
3350
|
+
lines.push('다음에 같은 증상을 만나면 search_cases 로 이 장을 먼저 찾는다.');
|
|
3351
|
+
return lines.join('\n');
|
|
2916
3352
|
}
|
|
2917
3353
|
case 'search_cases': {
|
|
2918
3354
|
/*
|
|
@@ -2937,7 +3373,24 @@ async function callTool(name, args = {}) {
|
|
|
2937
3373
|
*/
|
|
2938
3374
|
+ r.hits.map((h) => `· ${h.path} [${h.score}점]${h.status ? `\n ⚠ 상태: ${h.status}` : ''}`
|
|
2939
3375
|
+ `\n ${h.excerpt}`).join('\n')
|
|
2940
|
-
+ '\n전문은
|
|
3376
|
+
+ '\n전문은 get_case(tenant, path) — 처방·상태까지 그대로 읽는다.'
|
|
3377
|
+
+ (Number.isInteger(r.tenant_id) ? ` 사람 화면: wiki?t=${r.tenant_id}&p=<path>` : '');
|
|
3378
|
+
}
|
|
3379
|
+
case 'get_case': {
|
|
3380
|
+
// 서버 창구(/api/wiki/page)는 이미 있었다 — MCP 가 안 부르고 있었을 뿐이다(2026-08-25).
|
|
3381
|
+
// 세션을 실어 보낸다(myCitation 과 같은 규율 — 모델에게 안 묻는다) → 서버가 「읽혔다」를 남긴다(062).
|
|
3382
|
+
const mine = myCitation().session_uuid || '';
|
|
3383
|
+
const r = await api('GET', '/api/wiki/page?tenant=' + encodeURIComponent(args.tenant)
|
|
3384
|
+
+ '&path=' + encodeURIComponent(args.path) + (mine ? '&session=' + mine : ''));
|
|
3385
|
+
return `${r.path}${r.signals ? '\n' + r.signals : ''}\n\n${r.markdown}`;
|
|
3386
|
+
}
|
|
3387
|
+
case 'case_verdict': {
|
|
3388
|
+
const r = await api('POST', '/api/wiki/verdict?tenant=' + encodeURIComponent(args.tenant), {
|
|
3389
|
+
path: args.path, verdict: args.verdict, note: args.note || '',
|
|
3390
|
+
...myCitation(), // 세션 uuid 는 우리가 채운다 — 없으면 서버가 안 적는다
|
|
3391
|
+
});
|
|
3392
|
+
return `${r.says || '판정을 적었다.'}\n`
|
|
3393
|
+
+ '(세션이 중계한 판정이다 — 사람이 지식 화면에서 누른 판정만 도구 직전 알림·검색에 반영된다.)';
|
|
2941
3394
|
}
|
|
2942
3395
|
case 'list_sessions': {
|
|
2943
3396
|
const query = new URLSearchParams({ tenant: String(args.tenant) });
|
|
@@ -2946,10 +3399,25 @@ async function callTool(name, args = {}) {
|
|
|
2946
3399
|
const r = await api('GET', `/api/sessions?${query.toString()}`);
|
|
2947
3400
|
const sessions = Array.isArray(r.sessions) ? r.sessions : [];
|
|
2948
3401
|
if (!sessions.length) return `${r.tenant} — 올라온 세션이 없습니다.`;
|
|
3402
|
+
// 생사는 서버가 판정해 실어 준다(`beat` — rp_agent_presence · 화면 맥박과 같은 문턱).
|
|
3403
|
+
// 구판 서버는 이 필드가 없다 — 그때는 아무 말도 덧붙이지 않는다(모르면 침묵).
|
|
3404
|
+
const beatMark = (s) => {
|
|
3405
|
+
const b = s.beat;
|
|
3406
|
+
if (!b || typeof b !== 'object') return '';
|
|
3407
|
+
if (b.state === 'alive') return ` ● 살아 있음(기기 ${b.devices})`;
|
|
3408
|
+
if (b.state === 'dead') {
|
|
3409
|
+
const at = String(b.last_beat || '').slice(5, 16); // 'MM-DD HH:MM'
|
|
3410
|
+
// 좌초(5단계) — 마지막 심박이 서버보다 앞선 채 죽었다: 그 줄들은 그 기계에만 있다.
|
|
3411
|
+
const stranded = Number.isInteger(b.stranded) && b.stranded > 0
|
|
3412
|
+
? ` · 미전송 ${b.stranded}줄 — 이어받기 후보(그 기계에서 push_session 하면 산다)` : '';
|
|
3413
|
+
return (b.closed ? ` ○ 닫힘 ${at}` : ` ○ 심박 끊김 ${at}`) + stranded;
|
|
3414
|
+
}
|
|
3415
|
+
return ' · 심박 기록 없음';
|
|
3416
|
+
};
|
|
2949
3417
|
const rows = sessions.map((s) => {
|
|
2950
3418
|
const mark = s.deleted ? ' (가려짐)' : '';
|
|
2951
3419
|
return ` ${s.uuid.slice(0, 8)} ${s.updated} ${s.lines}줄 ${(s.bytes / 1024).toFixed(0)}KB`
|
|
2952
|
-
+ ` ${s.title || '(제목 없음)'}${mark}`;
|
|
3420
|
+
+ ` ${s.title || '(제목 없음)'}${mark}${beatMark(s)}`;
|
|
2953
3421
|
});
|
|
2954
3422
|
// **본 것을 적어 둔다.** 이 짝(uuid → 고객사 id)이 없으면 심박을 보낼 수가 없다 —
|
|
2955
3423
|
// 프레임에는 고객사 이름이 아니라 id 가 실린다.
|
|
@@ -2962,6 +3430,14 @@ async function callTool(name, args = {}) {
|
|
|
2962
3430
|
&& Number.isInteger(p.range.last)) {
|
|
2963
3431
|
count = `세션 ${p.total}건 중 ${p.range.first}–${p.range.last}`;
|
|
2964
3432
|
}
|
|
3433
|
+
// 요약 맥박 — 목록을 다 읽기 전에 「몇이 살아 있나」부터 답한다. 이 page 에 보이는 만큼만 센다.
|
|
3434
|
+
if (sessions.some((s) => s.beat && typeof s.beat === 'object')) {
|
|
3435
|
+
const alive = sessions.filter((s) => s.beat?.state === 'alive').length;
|
|
3436
|
+
const dead = sessions.filter((s) => s.beat?.state === 'dead').length;
|
|
3437
|
+
const dark = sessions.length - alive - dead;
|
|
3438
|
+
const stranded = sessions.filter((s) => Number.isInteger(s.beat?.stranded) && s.beat.stranded > 0).length;
|
|
3439
|
+
count += ` · ● ${alive} ○ ${dead}${dark ? ` · 기록없음 ${dark}` : ''}${stranded ? ` · ⚠ 좌초 ${stranded}` : ''}`;
|
|
3440
|
+
}
|
|
2965
3441
|
const follow = [];
|
|
2966
3442
|
if (p && Number.isInteger(p.next)) {
|
|
2967
3443
|
follow.push(`다음 목록: list_sessions(tenant: ${JSON.stringify(args.tenant)}, `
|
|
@@ -3081,6 +3557,48 @@ async function callTool(name, args = {}) {
|
|
|
3081
3557
|
+ `이어서 말하려면: cd ${into} && claude --resume ${args.uuid}\n`
|
|
3082
3558
|
+ (m.redacted ? `※ 가린 자리 ${m.redacted}곳 — 도구 실행 결과는 안 실려 옵니다. 필요하면 다시 실행하세요.` : '');
|
|
3083
3559
|
}
|
|
3560
|
+
case 'mail': {
|
|
3561
|
+
const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
|
|
3562
|
+
if (!mine) {
|
|
3563
|
+
return '이 세션의 번호를 모른다 — 주소를 댈 수 없어 보낼 수 없다'
|
|
3564
|
+
+ '(CLAUDE_CODE_SESSION_ID 가 없는 환경이다).';
|
|
3565
|
+
}
|
|
3566
|
+
/*
|
|
3567
|
+
* 주소가 없으면 먼저 댄다. **이름이 없으면 서버가 409 로 거절**하므로,
|
|
3568
|
+
* 그 경우를 여기서 미리 사람 말로 바꿔 준다 — 서버 오류문만 보면 무엇을 해야
|
|
3569
|
+
* 하는지 알 수 없다.
|
|
3570
|
+
*/
|
|
3571
|
+
if (!myAgentName()) {
|
|
3572
|
+
return '이 세션에는 아직 말 걸 수 있는 이름이 없어 **보낼 수가 없다**'
|
|
3573
|
+
+ '(받는 쪽이 누가 말하는지 못 보면 그것은 메시지가 아니라 주입이다).\n'
|
|
3574
|
+
+ '이름은 `ListAgents` 를 한 번 부르면 생긴다 — 하네스가 「This session is …」를 '
|
|
3575
|
+
+ '전사에 적고, 이 도구가 그것을 읽는다. 부른 뒤 다시 보내면 된다.';
|
|
3576
|
+
}
|
|
3577
|
+
try { await mailRegister(args.tenant, mine, true); }
|
|
3578
|
+
catch (e) { return `주소를 대지 못했다 — ${e.message}`; }
|
|
3579
|
+
|
|
3580
|
+
const r = await api('POST', '/api/mail', {
|
|
3581
|
+
tenant: args.tenant,
|
|
3582
|
+
to: args.to,
|
|
3583
|
+
body: args.body,
|
|
3584
|
+
subject: args.subject || '',
|
|
3585
|
+
artifact: args.artifact || '',
|
|
3586
|
+
done_when: args.done_when || '',
|
|
3587
|
+
cwd: relHome(process.cwd()),
|
|
3588
|
+
...(args.thread ? { thread: args.thread } : {}),
|
|
3589
|
+
// ★ **출처는 우리가 채운다 — 모델에게 안 묻는다**(remember_case 의 myCitation 과 같은 규율).
|
|
3590
|
+
session: mine,
|
|
3591
|
+
});
|
|
3592
|
+
// ★ 받을 곳을 기억한다 — 색인 밖 세션도 이 소속처에서 받는다(mailReady 힌트 + 색인).
|
|
3593
|
+
// ⚠ 서버가 tenant_id 를 아직 안 주는 판(재배포 전)이어도 **받기는 이름만으로 된다** —
|
|
3594
|
+
// GET /api/mail 은 tenant 이름으로 묻는다. 색인(indexNote)만 id 가 있어야 적힌다.
|
|
3595
|
+
mailTenantHint = { tenant: args.tenant, tenant_id: r?.tenant_id || 0 };
|
|
3596
|
+
if (r?.tenant_id) indexNote(mine, { tenant: args.tenant, tenant_id: r.tenant_id });
|
|
3597
|
+
return `«${r.to}» 앞으로 보냈다 — 실 ${r.thread} · ${r.turn}번째.\n`
|
|
3598
|
+
+ `받는 쪽이 보게 될 글자는 이것이다:\n${r.preview}\n`
|
|
3599
|
+
+ (r.says ? `${r.says}\n` : '')
|
|
3600
|
+
+ '답이 오면 다른 도구를 부를 때 응답 꼬리에 저절로 실린다 — 따로 확인할 필요 없다.';
|
|
3601
|
+
}
|
|
3084
3602
|
case 'install_cell': {
|
|
3085
3603
|
const { ok, out } = cellInstall({ hooks: args.hooks !== false });
|
|
3086
3604
|
const now = cellCheck();
|
|
@@ -3396,6 +3914,7 @@ if (IS_MAIN) {
|
|
|
3396
3914
|
* 꺼진 것이지 고장이 아니다(그림 자격이 없을 때와 같은 태도).
|
|
3397
3915
|
*/
|
|
3398
3916
|
startBeat();
|
|
3917
|
+
startMailPoll(); // 메일 폴은 심박의 조기 반환에 매이지 않는다
|
|
3399
3918
|
startObserve(); // 훅이 안 걸리는 도구(Codex)도 관측을 올린다 — 위 머리말 참조
|
|
3400
3919
|
process.stdin.setEncoding('utf8');
|
|
3401
3920
|
process.stdin.on('data', (chunk) => { buf += chunk; pump(); });
|
package/package.json
CHANGED
|
@@ -140,16 +140,32 @@ ls -t ~/.claude/projects/<프로젝트-슬러그>/*.jsonl | head -1
|
|
|
140
140
|
|
|
141
141
|
## 말 걸기
|
|
142
142
|
|
|
143
|
-
|
|
143
|
+
**상대가 어디 있느냐로 창구가 갈립니다.**
|
|
144
|
+
|
|
145
|
+
| 상대 | 창구 |
|
|
146
|
+
|---|---|
|
|
147
|
+
| 이 기계의 옆 창 | `SendMessage` |
|
|
148
|
+
| **다른 기계 · Codex 세션 · 두뇌** | **`mail` 도구** |
|
|
149
|
+
|
|
150
|
+
`SendMessage` 는 **이 기계의 Claude Code 만 봅니다.** 다른 노트북에서 도는 세션은 이름을 알아도
|
|
151
|
+
닿지 않습니다 — 서버를 지나는 `mail` 이라야 넘어갑니다.
|
|
144
152
|
|
|
145
153
|
```
|
|
146
|
-
SendMessage({to: "app-2f", message: "…"})
|
|
154
|
+
SendMessage({to: "app-2f", message: "…"}) # 같은 기계
|
|
155
|
+
mail({tenant: "<소속처>", to: "brain", body: "…"}) # 기계를 넘어서
|
|
147
156
|
```
|
|
148
157
|
|
|
149
|
-
받은
|
|
158
|
+
받은 것에 답할 때 `SendMessage` 는 그 메시지의 `from` 을 그대로 `to` 에 쓰고, `mail` 은 `thread` 를 씁니다.
|
|
159
|
+
|
|
160
|
+
`mail` 의 `to` 에 올 수 있는 것은 **셋뿐**입니다 — `gcell agents` 나 `world` 가 아는 **세포 이름**,
|
|
161
|
+
**세션 uuid**, 또는 두뇌를 뜻하는 **`brain`**. **지어내지 마세요.** 셋 중 하나가 아니면 사용자에게
|
|
162
|
+
묻습니다. 소속처 이름을 모르면 `list_my_tenants` 로 먼저 확인합니다.
|
|
163
|
+
|
|
164
|
+
받는 것은 부를 필요가 없습니다 — 온 것은 **다른 도구를 부를 때 응답 꼬리에 저절로 실립니다.**
|
|
150
165
|
|
|
151
166
|
**권한은 세션마다 따로입니다.** 내 세션에서 막힌 일을 옆 세션에 부탁하지 마세요 —
|
|
152
|
-
사용자의 권한 결정을 우회하는 것이 됩니다.
|
|
167
|
+
사용자의 권한 결정을 우회하는 것이 됩니다. **창구가 넓어져도 같습니다**: `mail` 로 다른 기계에
|
|
168
|
+
부탁하는 것도 같은 우회입니다. 막힌 일은 사용자에게 되돌립니다.
|
|
153
169
|
|
|
154
170
|
## 공유 트리 규율 — 실사고에서 나온 규칙
|
|
155
171
|
|
|
@@ -174,7 +190,9 @@ SendMessage({to: "app-2f", message: "…"})
|
|
|
174
190
|
| `--since` 기본이 360분 | 그보다 오래된 작업은 **안 보인다**. 「아무도 없다」는 6시간 안의 이야기다 |
|
|
175
191
|
| 저장소 판정이 현재 폴더 기준 | 다른 저장소를 보려면 `--root <경로>` |
|
|
176
192
|
| `who` 의 짧은 id 로는 말을 못 건다 | 주소는 `agents` 의 이름이다 |
|
|
177
|
-
| 세포는 이 기계만 본다 | 다른
|
|
193
|
+
| 세포는 이 기계만 본다 | 다른 기계를 **보는** 것은 `world`, **말 거는** 것은 `mail` |
|
|
194
|
+
| `SendMessage` 로 다른 기계에 보냈다 | **안 간다.** 그건 이 기계 전용이다 — `mail` 을 쓴다 |
|
|
195
|
+
| 왕복이 길어진다 | 상한이 있다 — **사람이 낀 실 5회, 기계끼리 3회.** 넘으면 사람에게 올라간다 |
|
|
178
196
|
| `status` 에 내 줄이 표시되지 않는다 | 내 uuid 는 전사 파일 이름에서 얻는다 |
|
|
179
197
|
|
|
180
198
|
## 무엇을 하지 않는가
|
|
@@ -248,10 +248,15 @@ report.json + cases.json 을 하나로 합쳐 `publish_report` 에 준다
|
|
|
248
248
|
|
|
249
249
|
`aside.marg` 개수가 **절 수와 같아야 한다.** 모자라면 그만큼의 절이 한 단으로 서 있다.
|
|
250
250
|
|
|
251
|
-
⚠ **머리줄의 `절 0 · 건 0 · 장 0
|
|
252
|
-
(`h2.sec`·`.case`·`.ch-open
|
|
251
|
+
⚠ **머리줄의 `절 0 · 건 0 · 장 0` 은 정상값이다.** MCP 요약 줄이 옛 서식 클래스
|
|
252
|
+
(`h2.sec`·`.case`·`.ch-open`)만 세는데 관보는 그중 무엇도 안 쓴다. 실측(예시 문서):
|
|
253
253
|
전부 0 이면서 실제로는 `ask` 1 · `sheet` 2 · `board` 1 이었다. **0 을 「비었다」로 읽지 마라.**
|
|
254
254
|
|
|
255
|
+
**「그림 N(표지마크 M)」은 다르다 — 이건 진짜 셈이다**(2026-08-25). 위 62줄대로 관보에는
|
|
256
|
+
비트맵이 두 자리 실리고, 그중 표지 마크는 **서버가 넣는다**(고객사에 로고가 등록돼 있으면).
|
|
257
|
+
그래서 로고를 등록한 고객사의 문서는 `M = 1` 이 정상이고, 본문 도판은 `N − M` 이다.
|
|
258
|
+
**`그림 0` 을 합격 기준으로 삼지 마라** — 도판을 실은 문서에서 그 기준은 자기 근거를 지운다.
|
|
259
|
+
|
|
255
260
|
### 5′. 전달 — 단일 HTML (Lim 밖 고객·메일 첨부)
|
|
256
261
|
|
|
257
262
|
```bash
|
|
@@ -401,9 +401,19 @@ python3 assets/render.py <report.json>
|
|
|
401
401
|
인라인 스크립트를 여는 분기는 **문서 안 편집기가 실제로 들어 있을 때만** 열리는데,
|
|
402
402
|
관보 렌더러는 그 레이어를 아예 안 싣는다(`build.py:439·442`) — 그 분기에 걸릴 일이 없다.
|
|
403
403
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
404
|
+
**그러나 「그래서 머리띠는 지금 어디인가를 주장하지 않는다」는 틀린 결론이었다.**
|
|
405
|
+
표현 수단이 스크립트뿐이라는 전제에서 나온 말이고, 그 사이 머리띠와 측면 레일은
|
|
406
|
+
좌표 장치인데 좌표를 안 알려 주는 물건으로 굳어 있었다(2026-08-24 지적).
|
|
407
|
+
|
|
408
|
+
지금은 **CSS 스크롤 구동 타임라인**이 켠다 — 절마다 `view-timeline` 을 걸고, 그 절이
|
|
409
|
+
화면에 걸친 동안만 해당 칸에 활성 스타일을 얹는다. 스크립트가 없어도 어느 절을 읽는
|
|
410
|
+
중인지 칸이 스스로 밝히고, 진척 바도 `scroll(root)` 로 민다. 못 하는 브라우저는
|
|
411
|
+
`@supports` 밖에서 지금까지처럼 정적으로 선다. 데이터로 조절할 것은 없다 —
|
|
412
|
+
렌더러가 절 수를 알아 스스로 낸다(`gazette.py` 의 `track_css()`).
|
|
413
|
+
|
|
414
|
+
여전히 **글자로 쓰는 위치 판독부(「N / M」)는 안 낸다.** 그건 값을 바꿔 써야 하는데
|
|
415
|
+
CSS 로는 못 한다. 우측 판독부에 남는 것은 문서 전체의 사실(요청 잔여 N건)뿐이다 —
|
|
416
|
+
못 지킬 약속은 적지 않는다.
|
|
407
417
|
|
|
408
418
|
---
|
|
409
419
|
|
|
@@ -499,6 +509,126 @@ python3 assets/render.py <report.json>
|
|
|
499
509
|
`그림 N-1`·`그림 N-2` 가 된다. 따로 세면 같은 번호가 두 번 나와 상호참조가 무엇을 가리키는지
|
|
500
510
|
알 수 없게 된다.
|
|
501
511
|
|
|
512
|
+
## 11-3. 자료표 — 여러 항목을 축으로 견줄 때
|
|
513
|
+
|
|
514
|
+
**흐름도와 같은 사연이다.** 조판(`board-wrap`/`board`/`board__cap`/`board__band`)은 건 대조표
|
|
515
|
+
(`kind:"status"`)를 위해 들어와 있었는데, 그 문법을 **일반 절에서 쓸 길이 없었다.** 그래서
|
|
516
|
+
코호트 8행·확정 필요 10행 같은 표가 전부 불릿 목록으로 눌려 나갔다 — 수가 세로로 안 맞으니
|
|
517
|
+
독자가 견줄 수 없다. (2026-08-24, 원판 대조에서 드러남.)
|
|
518
|
+
|
|
519
|
+
```json
|
|
520
|
+
"tables": [
|
|
521
|
+
{
|
|
522
|
+
"caption": "기기별 영향도",
|
|
523
|
+
"cols": ["기기", "대상자", "방향 반전", "평균 권고량 변화"],
|
|
524
|
+
"align": ["l", "r", "r", "r"],
|
|
525
|
+
"rows": [
|
|
526
|
+
["체중계 T8", "246,562명", "32.8%", "-0.90 → -4.18kg"],
|
|
527
|
+
["체중계 T9 · TX", "115,085명", "28.8%", "-0.51 → -3.68kg"]
|
|
528
|
+
],
|
|
529
|
+
"note": "영향의 93%는 체중계 이용자임."
|
|
530
|
+
}
|
|
531
|
+
]
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
| 칸 | 뜻 |
|
|
535
|
+
|---|---|
|
|
536
|
+
| `caption` | 표 제목. 번호(`표 5-1`)는 **자리가 매긴다** — 적지 않는다 |
|
|
537
|
+
| `cols` | 열 제목. **필수** |
|
|
538
|
+
| `rows` | 행마다 `cols` 와 **같은 길이의 배열**. 어긋나면 잘라 맞추고 ⚠ 로 센다 |
|
|
539
|
+
| `align` | 열마다 `l`·`r`·`c`. 생략하면 전부 좌측 |
|
|
540
|
+
| `note` | 표 아래 `※` 한정 조건 |
|
|
541
|
+
| `hint` | `note` 아래 안내 한 줄 |
|
|
542
|
+
|
|
543
|
+
**수 열은 `r` 로 둔다.** 우측 정렬 + 자릿수 고정(`tabular-nums`)이 함께 걸려야 세로로 견줄 수
|
|
544
|
+
있다. 좌측 정렬한 수는 표에 넣으나 마나다.
|
|
545
|
+
|
|
546
|
+
**`kind:"status"` 의 `board_title` 대조표와는 다른 것이다.** 저건 **건 전용**이고 확인 강도
|
|
547
|
+
표식·상세 링크가 붙는다. `tables` 는 kind 와 무관하게 아무 절에나 선다.
|
|
548
|
+
|
|
549
|
+
### 캡션은 라벨이지 산문이 아니다
|
|
550
|
+
|
|
551
|
+
**20자 안쪽으로 둔다.** 캡션 바는 전폭 색 밴드라 글줄 상한(`--measure`)을 안 받는다 — 길게
|
|
552
|
+
쓰면 두 줄로 접히고 행간이 좁아 읽기 검사(`read.mjs`)가 「줄길다 행간좁다」로 센다.
|
|
553
|
+
실측(2026-08-24): 34자 캡션 하나가 지적 1건을 만들었고, 그 설명을 `note` 로 내려 18자로 줄이자
|
|
554
|
+
0건이 됐다. 설명하고 싶은 것은 전부 `note` 의 몫이다.
|
|
555
|
+
|
|
556
|
+
## 11-4. 분포 막대 — 수를 눈으로 견주게 한다
|
|
557
|
+
|
|
558
|
+
**캔버스는 쓸 수 없다.** 열람 CSP 가 `default-src 'none'` 이고 `script-src` 를 안 연다. 그 이유가
|
|
559
|
+
정당하다 — 발행물은 내용을 검사하지 않는 업로드물이고, 포털과 **같은 오리진**에 전 고객사의
|
|
560
|
+
열람 링크를 쥔 관리 화면이 함께 산다. 차트 하나 때문에 그 문을 열 값이 아니다.
|
|
561
|
+
실측(2026-08-24): 같은 CSP 아래에서 인라인 스크립트는 차단되고 인라인 SVG 와 `:checked` CSS
|
|
562
|
+
인터랙션은 그대로 돈다.
|
|
563
|
+
|
|
564
|
+
그래서 **HTML 막대 + CSS** 로 짓는다. SVG 로 그리지 않는 이유는 접근성이다 — SVG 안의 글자는
|
|
565
|
+
활자·글줄 검사기가 못 읽고 인쇄에서도 다르게 뜬다. **값을 본문 글자로 남기고** 막대를 그 옆에
|
|
566
|
+
두면, 막대가 안 보이는 곳에서도 문서가 성립한다.
|
|
567
|
+
|
|
568
|
+
```json
|
|
569
|
+
"charts": [
|
|
570
|
+
{
|
|
571
|
+
"caption": "개선안 적용 시 권장량 분포",
|
|
572
|
+
"unit": "명",
|
|
573
|
+
"rows": [
|
|
574
|
+
{ "label": "감량 10~20kg", "value": "48,537", "note": "12.5%" },
|
|
575
|
+
{ "label": "감량 5kg 미만", "value": "100,957", "note": "25.9%" }
|
|
576
|
+
],
|
|
577
|
+
"note": "10kg 이상 감량이 안내되는 이용자가 3,199명에서 72,660명으로 늘어남."
|
|
578
|
+
}
|
|
579
|
+
]
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
| 칸 | 뜻 |
|
|
583
|
+
|---|---|
|
|
584
|
+
| `caption` | 그림 제목. 번호는 **자리가 매기고 `flow`·`plates` 와 한 셈을 쓴다** |
|
|
585
|
+
| `unit` | 값 뒤에 붙는 단위(`명`·`건`) |
|
|
586
|
+
| `rows[].label` | 막대 이름 |
|
|
587
|
+
| `rows[].value` | 값. **표기 그대로 적는다** |
|
|
588
|
+
| `rows[].a` · `.b` | `toggle` 이 있을 때 두 축의 값 |
|
|
589
|
+
| `rows[].note` | 값 아래 작은 글자 |
|
|
590
|
+
| `toggle` | `{a, b}` — 두 축 이름. 주면 `:checked` 로 갈아 낀다 |
|
|
591
|
+
|
|
592
|
+
**값은 표기 그대로 적는다.** `118716` 이 아니라 `"118,716"` 이다. 셈에서는 콤마를 걷어내므로
|
|
593
|
+
폭 계산에는 지장이 없고, **데이터가 표기를 정해야** 같은 문서의 표와 막대가 서로 다른 표기로
|
|
594
|
+
갈리지 않는다. 실측(2026-08-24): 원시 정수로 넣었더니 위의 표는 `356,895명`, 바로 아래 막대는
|
|
595
|
+
`198430명` 으로 한 절 안에서 표기가 갈렸다.
|
|
596
|
+
|
|
597
|
+
**`toggle` 의 체크박스는 스크립트가 아니라 상태다.** 라벨이 그 상태의 스위치가 되므로
|
|
598
|
+
키보드로도 닿는다. 두 축을 견줄 일이 없으면 넣지 않는다.
|
|
599
|
+
|
|
600
|
+
## 11-5. 증거 등급 — 실었으면 얼마나 믿어도 되는지 함께 적는다
|
|
601
|
+
|
|
602
|
+
`assets[].verdict` 는 **`grades` 와 다른 축이다.**
|
|
603
|
+
|
|
604
|
+
| 축 | 무엇을 말하는가 | 어디에 |
|
|
605
|
+
|---|---|---|
|
|
606
|
+
| `grades` | **우리가** 무엇을 어디까지 확인했나 | 건마다(`cases[].grade`), 제3절 대조표 |
|
|
607
|
+
| `verdict` | **이 근거를** 얼마나 믿어도 되나 | 도판 캡션 끝의 칩 |
|
|
608
|
+
|
|
609
|
+
값은 셋뿐이다.
|
|
610
|
+
|
|
611
|
+
| 값 | 뜻 |
|
|
612
|
+
|---|---|
|
|
613
|
+
| `직접 확인` | 원문을 직접 열어 그 수치를 확인함 |
|
|
614
|
+
| `조사 보고` | 2차 출처를 경유해 확인함. 원문 대조는 안 함 |
|
|
615
|
+
| `미확인` | 널리 쓰이나 1차 출처를 특정하지 못함 |
|
|
616
|
+
|
|
617
|
+
**왜 강제하는가.** 실사례(2026-08-24): 원판이 3단으로 나눠 둔 이 축을 포털본이 통째로 버렸고,
|
|
618
|
+
그 결과 2차 출처와 미확인 주장이 **사실로 단언돼** 고객에게 나갔다. 심지어 원 문서가 이미
|
|
619
|
+
철회한 인용 하나가 그대로 남아 있었다. 증거를 실으면서 등급을 안 붙이면 문서가 실제보다
|
|
620
|
+
확실해 보인다 — 도판이 늘수록 그 착시가 커진다.
|
|
621
|
+
|
|
622
|
+
**등급은 도판만의 것이 아니다.** 본문이 출처에 기대는 주장을 여럿 하면 `tables` 로 주장별 표를
|
|
623
|
+
세우고 등급 열을 둔다. 그 표가 있으면 독자가 「이 문서의 말이 어디서 왔는지」를 한 번에 본다.
|
|
624
|
+
|
|
625
|
+
### 도판이 본문을 반박하면 본문을 고친다
|
|
626
|
+
|
|
627
|
+
실사례(2026-08-24): 도판을 실은 뒤 캡처를 눈으로 보다가 본문의 오류 둘을 잡았다.
|
|
628
|
+
하나는 체질량지수 값(22·21·21.5)을 체지방률로 적은 것이고, 다른 하나는 매뉴얼에 **명시된**
|
|
629
|
+
수치를 「1차 문헌 미확인」으로 낮춰 적은 것이다. 둘 다 검사기가 못 잡는다 — **도판을 실었으면
|
|
630
|
+
그 도판을 실제로 열어 본문과 대조해라.**
|
|
631
|
+
|
|
502
632
|
## 12. 가르치는 문서 — 기술보고서·인수인계·포스트모템
|
|
503
633
|
|
|
504
634
|
독자가 **판정**하는 문서(검수 보고)와 **배우는** 문서는 뼈대가 다르다.
|
|
@@ -633,10 +763,16 @@ python3 assets/render.py <report.json>
|
|
|
633
763
|
`section.pt` · `section.part` · `aside.marg` · `section.apx` · `div.colophon`
|
|
634
764
|
- **구버전** — `figure.eof-endcard` · `div.ch-open` · `h2.sec`
|
|
635
765
|
|
|
636
|
-
⚠ 요약 줄의 「절 N · 건 N · 장 N
|
|
637
|
-
관보 문서에서는
|
|
766
|
+
⚠ 요약 줄의 「절 N · 건 N · 장 N」은 **옛 서식의 셈이다.**
|
|
767
|
+
관보 문서에서는 0으로 나오는 것이 정상이다 — 「문서가 비었다」가 아니라 다른 것으로 서 있다는 뜻이다.
|
|
638
768
|
판정은 **블록 목록**으로 한다.
|
|
639
769
|
|
|
770
|
+
「그림 N(표지마크 M)」만 다르다. 서버가 고객사 로고를 표지에 싣기 때문에,
|
|
771
|
+
로고를 등록한 고객사의 문서는 **M 이 1 인 것이 정상**이다.
|
|
772
|
+
본문 도판은 `N - M` 이다 — 도판을 안 실었으면 0, 실었으면 그 장수다(11-1 참조).
|
|
773
|
+
**0 을 합격 기준으로 쓰지 마라.** 관보에도 그림 문법이 있고, 검수 보고에서 근거 화면은
|
|
774
|
+
싣는 것이 맞다.
|
|
775
|
+
|
|
640
776
|
`aside.marg` 가 하나도 없으면 두 단 본문이 한 단으로 선 것이다 — 그 서식의 본체가 빠졌다.
|
|
641
777
|
`⚠ 빈 블록` 이 뜨면 렌더러가 조각을 빠뜨린 것이다.
|
|
642
778
|
|