genetics-mcp 2.21.0 → 2.23.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 -4
- 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 +355 -35
- package/package.json +3 -2
- package/skills/genetics-report/references/lim-portal-format.md +1 -0
|
Binary file
|
|
Binary file
|
package/bin/manifest.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"aarch64-apple-darwin": "
|
|
3
|
-
"aarch64-unknown-linux-musl": "
|
|
4
|
-
"x86_64-apple-darwin": "
|
|
5
|
-
"x86_64-
|
|
2
|
+
"aarch64-apple-darwin": "e1a2b90dba750b351681467c79e08892e1e3194e7ad17942d7a1e27df4466222",
|
|
3
|
+
"aarch64-unknown-linux-musl": "8f92a5b9acdc4d03efdbe1acd579fad475626e95d0e8d9e10acabe0c3dbb74a4",
|
|
4
|
+
"x86_64-apple-darwin": "e231aeeb459fb5fc36aa877dd78d505f8327c02fe30e74305b6bf4a72540aabb",
|
|
5
|
+
"x86_64-pc-windows-gnu": "8b57d84c62233951f36b612901aea40db6fe775fefb571f34b4896e1440894fc",
|
|
6
|
+
"x86_64-unknown-linux-musl": "4fb2c81fe250bfbc6ccef94bd44c1326646ae3e93b83990feaaf846b80582439"
|
|
6
7
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/genetics.mjs
CHANGED
|
@@ -49,6 +49,22 @@ if (process.argv.includes('--version') || process.argv.includes('-v')) {
|
|
|
49
49
|
process.exit(0);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/*
|
|
53
|
+
* **낡은 Node 는 여기서 말하고 멈춘다.** 서버가 쓰는 전역 `fetch` 는 Node 18 부터다.
|
|
54
|
+
* 그 아래에서는 첫 도구 호출에서 `fetch is not defined` 가 나는데, 그 글자는 클라이언트의
|
|
55
|
+
* 로그 구석에만 남아 **「도구가 그냥 안 된다」로 보인다.** 뜰 때 한 줄로 말하는 편이 낫다.
|
|
56
|
+
* Windows 에서 특히 중요하다 — 옛 판(7·8)에는 Node 18 이 아예 없다(공식 지원표: Node 14·16 은
|
|
57
|
+
* Windows 8.1 부터, 지금 판은 Windows 10 부터).
|
|
58
|
+
*/
|
|
59
|
+
if (typeof fetch !== 'function') {
|
|
60
|
+
process.stderr.write(
|
|
61
|
+
`genetics-mcp ${VERSION}: 이 Node(${process.version})에는 전역 fetch 가 없다 — Node 18 이상이 필요하다.\n`
|
|
62
|
+
+ (process.platform === 'win32'
|
|
63
|
+
? ' Windows 는 Node 18+ 가 Windows 10 이상에서만 돈다(공식 지원표). 7·8 에서는 포털의 브라우저 커넥터로 연결할 것.\n'
|
|
64
|
+
: ' nodejs.org 의 LTS 를 깔고 다시 열 것.\n'));
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
52
68
|
/*
|
|
53
69
|
* 짝인 스킬을 편다.
|
|
54
70
|
*
|
|
@@ -706,13 +722,33 @@ async function fetchImage(url) {
|
|
|
706
722
|
* 방어를 늘려야 하기 때문이다. 그래서 굳히는 것은 여기서 한다: 계약 §7.1 ④ 의
|
|
707
723
|
* 「발행 도구의 프로세스가 원본을 받아 포맷 규정대로 굳힌다」가 정확히 이 자리다.
|
|
708
724
|
*/
|
|
725
|
+
/*
|
|
726
|
+
* 「이것을 까세요」는 **그 사람의 기계에서 되는 말**이어야 한다. 예전에는 brew·apt 만
|
|
727
|
+
* 적었는데, 그 두 줄은 Windows 에서 칠 수가 없다 — 안내가 아니라 막다른 길이 된다.
|
|
728
|
+
*/
|
|
729
|
+
function installHint(kind) {
|
|
730
|
+
const P = {
|
|
731
|
+
webp: { win: 'winget install Google.LibWebp', mac: 'brew install webp', apt: 'apt install webp' },
|
|
732
|
+
librsvg:{ win: 'winget install --id=GNOME.librsvg -e 또는 scoop install librsvg',
|
|
733
|
+
mac: 'brew install librsvg', apt: 'apt install librsvg2-bin' },
|
|
734
|
+
}[kind];
|
|
735
|
+
return process.platform === 'win32' ? P.win : `${P.mac} · ${P.apt}`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** npx 가 옛 판을 물고 있을 때 비우는 한 줄 — 셸마다 다르다. */
|
|
739
|
+
function npxCacheResetHint(platform = process.platform) {
|
|
740
|
+
return platform === 'win32'
|
|
741
|
+
? 'rmdir /s /q "%LOCALAPPDATA%\\npm-cache\\_npx" && npx -y genetics-mcp@latest'
|
|
742
|
+
: 'rm -rf ~/.npm/_npx && npx -y genetics-mcp@latest';
|
|
743
|
+
}
|
|
744
|
+
|
|
709
745
|
function rasterizeSvg(buf) {
|
|
710
746
|
const r = spawnSync('rsvg-convert',
|
|
711
747
|
['-w', String(ASSET_LONG_EDGE), '-b', 'white', '-f', 'png'],
|
|
712
748
|
{ input: buf, maxBuffer: IMAGE_MAX_DOWNLOAD });
|
|
713
749
|
if (r.error && r.error.code === 'ENOENT') {
|
|
714
750
|
throw new Error('rsvg-convert 가 없어 벡터 원본을 래스터로 굳힐 수 없습니다.\n'
|
|
715
|
-
+
|
|
751
|
+
+ `librsvg 를 설치하세요(${installHint('librsvg')}). `
|
|
716
752
|
+ 'SVG 를 그대로 싣지 않습니다 — 계약이 발행 데이터로 받는 형식을 두 래스터로 못박았습니다.');
|
|
717
753
|
}
|
|
718
754
|
if (r.status !== 0) throw new Error(`벡터 래스터화 실패: ${String(r.stderr).slice(0, 200)}`);
|
|
@@ -765,7 +801,7 @@ function encodeShot(buf) {
|
|
|
765
801
|
|
|
766
802
|
function cwebpMissing() {
|
|
767
803
|
return new Error('cwebp 가 없어 자산을 계약 규격(WebP)으로 굳힐 수 없습니다.\n'
|
|
768
|
-
+
|
|
804
|
+
+ `libwebp 를 설치하세요(${installHint('webp')}). `
|
|
769
805
|
+ '다른 포맷으로 우회하지 않습니다 — 계약이 포맷을 규정으로 못박았습니다.');
|
|
770
806
|
}
|
|
771
807
|
|
|
@@ -799,7 +835,7 @@ function encodeAsset(buf, role, provider) {
|
|
|
799
835
|
const r = spawnSync('cwebp', args, { input: buf, maxBuffer: IMAGE_MAX_DOWNLOAD });
|
|
800
836
|
if (r.error && r.error.code === 'ENOENT') {
|
|
801
837
|
throw new Error('cwebp 가 없어 자산을 계약 규격(WebP q78 · 긴 변 1400px)으로 굳힐 수 없습니다.\n'
|
|
802
|
-
+
|
|
838
|
+
+ `libwebp 를 설치하세요(${installHint('webp')}). `
|
|
803
839
|
+ '다른 포맷으로 우회하지 않습니다 — 계약이 포맷을 규정으로 못박았습니다.');
|
|
804
840
|
}
|
|
805
841
|
if (r.status !== 0) throw new Error(`자산 인코딩 실패: ${String(r.stderr).slice(0, 200)}`);
|
|
@@ -819,12 +855,75 @@ function encodeAsset(buf, role, provider) {
|
|
|
819
855
|
* 운영 문서가 1400x934 그림을 1400x1050 이라 선언해 브라우저가 세로로 12.4% 늘여
|
|
820
856
|
* 그리고 있었다(2026-08-16 실측). 선언을 안 받으면 그 결함이 구조적으로 안 생긴다.
|
|
821
857
|
*/
|
|
822
|
-
|
|
858
|
+
/** 영상 한 편의 상한 — 서버 R2_VIDEO_MAX_BYTES 와 **같은 값**이다(갈리면 올리다 막힌다). */
|
|
859
|
+
const VIDEO_MAX_BYTES = 200_000_000;
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* 로컬 영상 파일 하나. 확장자를 안 믿고 **매직바이트**로 판정한다(서버도 같은 것을 다시 잰다).
|
|
863
|
+
* ISO BMFF 는 [4바이트 크기][ftyp] 라 4바이트째부터 본다.
|
|
864
|
+
*/
|
|
865
|
+
function readLocalVideo(p) {
|
|
866
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(p)) throw new Error(`file 에는 주소가 아니라 파일 경로를 줍니다: ${p}`);
|
|
867
|
+
if (!path.isAbsolute(p)) throw new Error(`file 은 절대경로여야 합니다: ${p}`);
|
|
868
|
+
let st;
|
|
869
|
+
try { st = fs.statSync(p); } catch { throw new Error(`file 을 찾을 수 없습니다: ${p}`); }
|
|
870
|
+
if (!st.isFile()) throw new Error(`file 이 일반 파일이 아닙니다: ${p}`);
|
|
871
|
+
if (st.size > VIDEO_MAX_BYTES) {
|
|
872
|
+
throw new Error(`영상이 ${st.size.toLocaleString()}B 로 상한 ${VIDEO_MAX_BYTES.toLocaleString()}B 를 넘습니다: ${p}\n`
|
|
873
|
+
+ '길이를 줄이거나 화질을 낮춰 다시 인코딩하세요(ffmpeg -crf 를 올리면 작아집니다).');
|
|
874
|
+
}
|
|
875
|
+
const buf = fs.readFileSync(p);
|
|
876
|
+
if (buf.toString('ascii', 4, 8) !== 'ftyp') {
|
|
877
|
+
throw new Error(`file 이 mp4(ISO BMFF)가 아닙니다(매직바이트 기준): ${p}`);
|
|
878
|
+
}
|
|
879
|
+
return buf;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* 영상을 저장소에 올리고 **참조 번호**를 받는다.
|
|
884
|
+
*
|
|
885
|
+
* 바이트는 이 기계에서 저장소로 **직접** 간다 — 포털을 거치지 않는다(포털은 메모리가 좁고
|
|
886
|
+
* Range 를 모른다). 포털이 하는 일은 「올릴 자리 하나를 원장에 세우고 서명해 주는 것」뿐이다.
|
|
887
|
+
* `headers` 는 **그대로** 실어야 한다 — 그 값이 서명에 들어가 있어서, 하나라도 다르면 저장소가 거절한다.
|
|
888
|
+
*/
|
|
889
|
+
async function uploadVideo(tenant, buf, filePath) {
|
|
890
|
+
if (!tenant) {
|
|
891
|
+
throw new Error('영상을 올리려면 tenant 가 필요합니다 — publish_report·update_report 의 tenant 를 씁니다.');
|
|
892
|
+
}
|
|
893
|
+
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
|
894
|
+
const pre = await api('POST', '/api/media/presign', { tenant, bytes: buf.length, sha256 });
|
|
895
|
+
const res = await fetch(pre.url, { method: 'PUT', body: buf, headers: { ...(pre.headers || {}) } });
|
|
896
|
+
if (!res.ok) {
|
|
897
|
+
const why = (await res.text().catch(() => '')).slice(0, 200);
|
|
898
|
+
throw new Error(`영상을 저장소에 올리지 못했습니다(${res.status}): ${filePath}\n${why}\n`
|
|
899
|
+
+ '설정 화면의 「연결 시험」이 통과하는지 먼저 확인하세요.');
|
|
900
|
+
}
|
|
901
|
+
ASSET_HINTS.push(`영상 「${path.basename(filePath)}」 ${(buf.length / 1048576).toFixed(1)}MB 를 올렸습니다(참조 ${pre.ref}).`);
|
|
902
|
+
return pre.ref;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async function materializeAssets(data, tenant) {
|
|
823
906
|
ASSET_HINTS = [];
|
|
824
907
|
if (!data || !Array.isArray(data.assets) || !data.assets.length) return data;
|
|
825
908
|
const out = [];
|
|
826
909
|
for (const a of data.assets) {
|
|
827
910
|
const { data: dropped, file: fpath, ...meta } = a ?? {};
|
|
911
|
+
/*
|
|
912
|
+
* 영상(138) — 바이트를 문서에 싣지 않는다. `file` 에 로컬 mp4 경로를 주면 여기서 올리고
|
|
913
|
+
* **참조 번호만** 문서에 남긴다. 이미 `ref` 가 있으면 올린 것이므로 그대로 둔다
|
|
914
|
+
* (되읽은 판을 고쳐 되돌려 보내는 왕복에서 같은 영상을 두 번 올리지 않는다).
|
|
915
|
+
*/
|
|
916
|
+
if (String(a?.role ?? '') === 'video' || String(a?.provider ?? '') === 'r2') {
|
|
917
|
+
const { data: _vd, ...rest } = meta;
|
|
918
|
+
if (Number(rest.ref) > 0) { out.push({ ...rest, role: 'video', provider: 'r2' }); continue; }
|
|
919
|
+
if (!fpath) {
|
|
920
|
+
throw new Error(`영상 「${rest.id ?? '(id 없음)'}」 에 file(로컬 mp4 경로)도 ref 도 없습니다.\n`
|
|
921
|
+
+ 'file 에 절대경로를 주면 올리고 번호를 받아 싣습니다.');
|
|
922
|
+
}
|
|
923
|
+
const ref = await uploadVideo(tenant, readLocalVideo(String(fpath)), String(fpath));
|
|
924
|
+
out.push({ ...rest, role: 'video', provider: 'r2', ref, mime: 'video/mp4' });
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
828
927
|
if (!a?.source && !fpath) {
|
|
829
928
|
// 둘 다 없으면 예전 경로 그대로 — data 를 그대로 통과시킨다(판 A 하위호환).
|
|
830
929
|
// get_report 가 준 표식('(생략 — NB)')과 bytes 칸의 처리(2026-08-27, 운영 #34):
|
|
@@ -1338,18 +1437,42 @@ const REPORT_DATA = {
|
|
|
1338
1437
|
+ '예: 「2026. 8. 24. Galaxy S22+ 실기기 홈 화면」.',
|
|
1339
1438
|
},
|
|
1340
1439
|
role: {
|
|
1341
|
-
type: 'string', enum: ['hero', 'chapter', 'figure', 'eof', 'logo'],
|
|
1440
|
+
type: 'string', enum: ['hero', 'chapter', 'figure', 'eof', 'logo', 'video'],
|
|
1342
1441
|
description: '`logo` 는 **표지 우상단 고객사 마크**다 — provider 는 user-provided, '
|
|
1343
1442
|
+ 'origin 에 제공자를 적는다. 못 받았으면 넣지 말고 data.client 에 이름만 적어라 '
|
|
1344
1443
|
+ '(렌더러가 활자로 세운다).\n'
|
|
1345
1444
|
+ '`eof` 는 **문서 끝 꼬리 카드**다. 안 줘도 된다 — 서버가 기본 카드를 '
|
|
1346
1445
|
+ '끼운다(계약 9절). 이 문서만의 카드가 있을 때만 준다. 그때는 무손실(PNG 또는 '
|
|
1347
|
-
+ '무손실 WebP)이어야 하고 줄이지 않고 원본 크기로
|
|
1446
|
+
+ '무손실 WebP)이어야 하고 줄이지 않고 원본 크기로 실린다.\n'
|
|
1447
|
+
+ '`video` 는 **절 본문 영상**이다(138). 바이트를 여기 싣지 않는다 — 먼저 '
|
|
1448
|
+
+ '`POST /api/media/presign` 으로 올릴 자리를 받아 그 URL 에 PUT 하고, 받은 번호를 '
|
|
1449
|
+
+ '`ref` 에 적는다. 자리는 `figure` 와 같다(sections[].plates·cases[].plates). '
|
|
1450
|
+
+ '영상 저장 연동이 꺼진 환경에서는 발행이 거절된다 — 다른 형식으로 우회하지 않는다.',
|
|
1348
1451
|
},
|
|
1349
1452
|
provider: {
|
|
1350
|
-
type: 'string', enum: ['higgsfield', 'deterministic', 'user-provided'],
|
|
1453
|
+
type: 'string', enum: ['higgsfield', 'deterministic', 'user-provided', 'r2'],
|
|
1351
1454
|
description: 'hero·chapter 는 higgsfield 여야 한다. deterministic 은 role: figure 에 한한다. '
|
|
1352
|
-
+ 'user-provided 는 origin 이 필수다. sample-exception 은 발행 거절
|
|
1455
|
+
+ 'user-provided 는 origin 이 필수다. sample-exception 은 발행 거절 값. '
|
|
1456
|
+
+ '`r2` 는 role: video 와 **짝**이다 — 바이트 대신 ref 로 온다',
|
|
1457
|
+
},
|
|
1458
|
+
// ⚠ 한 이름에 **자리에 따라 다른 두 뜻**이 얹혀 있다. 둘은 provider 로 서로 배타적이고 독자도 키를 만지기
|
|
1459
|
+
// 전에 provider/role 로 먼저 가른다(report_publish.php:544·1642 · report_media.php · gazette.py).
|
|
1460
|
+
// 2026-09-19 에 영상 갈래가 이 정수 선언을 따로 적으면서 아래쪽 불리언 선언과 **같은 객체에 두 번**
|
|
1461
|
+
// 들어갔고, JS 는 뒤엣것을 써서 정수 쪽이 조용히 사라졌다 — 게시 스키마가 `ref` 를 불리언이라고
|
|
1462
|
+
// 광고하는 동안 영상 자산은 `ref: 12` 를 실어 보내고 있었다(2026-09-20 관문이 짚음).
|
|
1463
|
+
// 개명은 하지 않는다: 서버 창구가 응답 본문으로 `ref` 를 안내하고(api.php:3252) 이미 발행된 문서의
|
|
1464
|
+
// source_json 에도 그 이름으로 저장돼 있어, 옮기면 report_media_refs() 가 빈 배열을 내고 청소가
|
|
1465
|
+
// R2 객체를 지운다(되돌릴 수 없다 — 실측으로 확인된 실패 양식). 그래서 **한 벌로 합쳐** 두 뜻을 적는다.
|
|
1466
|
+
ref: {
|
|
1467
|
+
type: ['integer', 'boolean'],
|
|
1468
|
+
description: '자리에 따라 뜻이 갈린다 — **`provider: r2` 이면 영상 자리 번호(정수)**, '
|
|
1469
|
+
+ '그 밖의 그림 자산이면 **세트 참조 스위치(불리언)** 다.\n'
|
|
1470
|
+
+ '· 정수: 영상 자리 번호. **보통은 직접 적지 않는다** — `file` 에 로컬 mp4 절대경로를 주면 '
|
|
1471
|
+
+ '이 도구가 올리고 번호를 채운다. 되읽은 판을 고쳐 되돌려 보낼 때는 받은 값을 그대로 두면 '
|
|
1472
|
+
+ '다시 안 올린다(빼면 서버가 저장된 값을 잇는다).\n'
|
|
1473
|
+
+ '· 불리언: 거짓이면 **세트 참조 없이 단독으로** 그린다. 기본은 참 — hero 를 먼저 그려 그것을 '
|
|
1474
|
+
+ '참조로 나머지를 이어야 한 세트로 보인다(참조 없이 내면 장마다 인물이 달라진다). '
|
|
1475
|
+
+ 'hero 자신에게는 뜻이 없다.',
|
|
1353
1476
|
},
|
|
1354
1477
|
alt: { type: 'string', description: '설명적 대체 텍스트 — 접근성 계약. 그림과 함께 다닌다' },
|
|
1355
1478
|
origin: { type: 'string', description: '생성 job 식별자·.mmd 원본 경로·제공자 이름' },
|
|
@@ -1388,12 +1511,7 @@ const REPORT_DATA = {
|
|
|
1388
1511
|
type: 'string', enum: ['16:9', '4:3', '3:4', '1:1', '3:2', '2:3', '9:16'],
|
|
1389
1512
|
description: '생략하면 역할이 정한다 — hero 16:9 · 그 밖 4:3.',
|
|
1390
1513
|
},
|
|
1391
|
-
ref
|
|
1392
|
-
type: 'boolean',
|
|
1393
|
-
description: '거짓이면 **세트 참조 없이 단독으로** 그린다. 기본은 참 — hero 를 먼저 '
|
|
1394
|
-
+ '그려 그것을 참조로 나머지를 이어야 한 세트로 보인다(참조 없이 내면 장마다 '
|
|
1395
|
-
+ '인물이 달라진다). hero 자신에게는 뜻이 없다.',
|
|
1396
|
-
},
|
|
1514
|
+
// (`ref` 는 위 provider 옆으로 합쳤다 — 한 객체에 같은 키를 두 번 적으면 JS 가 앞엣것을 조용히 버린다)
|
|
1397
1515
|
source: {
|
|
1398
1516
|
type: 'string',
|
|
1399
1517
|
description: '이미 그린 그림의 원본 https 주소(허용 호스트 목록 안). **이 도구의 프로세스가** '
|
|
@@ -1674,11 +1792,46 @@ function scrubTranscript(raw) {
|
|
|
1674
1792
|
return { jsonl: out.join('\n') + '\n', lines: out.length, dropped, redacted, cwd: cwdMode(cwds), cwd_raw: cwdMode(cwdsRaw), cwds_raw: rawAll };
|
|
1675
1793
|
}
|
|
1676
1794
|
|
|
1677
|
-
|
|
1795
|
+
/*
|
|
1796
|
+
* cwd → 트랜스크립트 디렉터리. **해석된 경로**로 슬러그를 만든다(심볼릭 링크 함정).
|
|
1797
|
+
*
|
|
1798
|
+
* ★ **이 규칙은 우리 것이 아니라 Claude Code 의 것이다.** 우리가 정하는 자리가 아니라
|
|
1799
|
+
* **맞춰야 하는 자리**다 — 어긋나면 우리가 놓은 파일을 `claude --resume` 이 못 찾는다.
|
|
1800
|
+
* 그래서 기억으로 쓰지 않고 **바이너리에서 떠 왔다**(2.1.274, 2026-09-18):
|
|
1801
|
+
*
|
|
1802
|
+
* var jY = 200;
|
|
1803
|
+
* function JY(e){ let r=0; for(let n=0;n<e.length;n++) r=(r<<5)-r+e.charCodeAt(n)|0; return r }
|
|
1804
|
+
* function Le(e){ return Math.abs(JY(e)).toString(36) }
|
|
1805
|
+
* function k(e){ return e.replace(/[^a-zA-Z0-9]/g, "-") }
|
|
1806
|
+
* function eR(e){ let n=k(e); if(n.length<=jY) return n; return `${n.slice(0,jY)}-${Le(e)}` }
|
|
1807
|
+
*
|
|
1808
|
+
* 옛 코드는 `/` 와 `_` 만 바꿨다. 그러면 **점 하나만 들어가도 어긋난다** — 이 기계 실측:
|
|
1809
|
+
* `…/spreadjs-v19-0-3_/.vendor-src` 의 실재 폴더는 `…-v19-0-3---vendor-src` 인데 옛 규칙은
|
|
1810
|
+
* `…-v19-0-3--.vendor-src` 를 냈다(실재 0건). Windows 는 더 나쁘다 — `\` 와 `:` 가 그대로
|
|
1811
|
+
* 남아 `C:\…\projects\C:\Users\…` 라는 **만들 수 없는 경로**가 나왔다(path.win32 로 실측).
|
|
1812
|
+
* 비영숫자를 전부 바꾸면 드라이브 문자도 `C-` 가 되어 Windows 에서도 유효한 이름이 된다.
|
|
1813
|
+
*/
|
|
1814
|
+
const TRANSCRIPT_SLUG_MAX = 200; // Claude Code 의 jY
|
|
1815
|
+
|
|
1816
|
+
/** Claude Code 의 `JY` — 자바 문자열 해시와 같은 셈(32비트 랩어라운드). */
|
|
1817
|
+
function transcriptSlugHash(s) {
|
|
1818
|
+
let r = 0;
|
|
1819
|
+
for (let i = 0; i < s.length; i += 1) r = (((r << 5) - r) + s.charCodeAt(i)) | 0;
|
|
1820
|
+
return Math.abs(r).toString(36);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/** Claude Code 의 `eR` — 경로 하나를 폴더 이름 하나로. */
|
|
1824
|
+
function transcriptSlug(real) {
|
|
1825
|
+
const n = String(real).replace(/[^a-zA-Z0-9]/g, '-');
|
|
1826
|
+
return n.length <= TRANSCRIPT_SLUG_MAX
|
|
1827
|
+
? n
|
|
1828
|
+
: `${n.slice(0, TRANSCRIPT_SLUG_MAX)}-${transcriptSlugHash(real)}`;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1678
1831
|
function transcriptDir(dir) {
|
|
1679
1832
|
let real = dir;
|
|
1680
1833
|
try { real = fs.realpathSync(dir); } catch { /* 없으면 준 값 그대로 */ }
|
|
1681
|
-
return path.join(os.homedir(), '.claude', 'projects', real
|
|
1834
|
+
return path.join(os.homedir(), '.claude', 'projects', transcriptSlug(real));
|
|
1682
1835
|
}
|
|
1683
1836
|
|
|
1684
1837
|
/** uuid 로 이 기계의 트랜스크립트를 찾는다. 지금 프로젝트를 먼저 보고, 없으면 전체를 훑는다. */
|
|
@@ -1777,8 +1930,33 @@ function cursorActiveFromLedger() {
|
|
|
1777
1930
|
* 조용히 비켜난다(beatTargets:transcriptOf null → skip). 대화 본문을 올리는 길(두뇌
|
|
1778
1931
|
* 파서까지)은 별도 단계다 — DESIGN-cursor-colony.md.
|
|
1779
1932
|
*/
|
|
1780
|
-
|
|
1781
|
-
|
|
1933
|
+
/*
|
|
1934
|
+
* Cursor 의 사용자 데이터 자리는 **플랫폼마다 다르다**(VS Code 계열 공통 규약).
|
|
1935
|
+
* 여기 macOS 자리만 박혀 있었다 — Windows·Linux 에서는 `workspaceStorage` 를 영영 못 찾고
|
|
1936
|
+
* 조용히 빈 값으로 떨어졌다(try/catch 안이라 아무도 못 봤다).
|
|
1937
|
+
* darwin ~/Library/Application Support/Cursor/User
|
|
1938
|
+
* win32 %APPDATA%\Cursor\User (APPDATA 가 없으면 ~/AppData/Roaming 로 잇는다)
|
|
1939
|
+
* linux $XDG_CONFIG_HOME/Cursor/User → 없으면 ~/.config/Cursor/User
|
|
1940
|
+
*/
|
|
1941
|
+
function cursorUserRoot(platform = process.platform, env = process.env, home = os.homedir()) {
|
|
1942
|
+
if (env.GENETICS_CURSOR_ROOT) return env.GENETICS_CURSOR_ROOT;
|
|
1943
|
+
if (platform === 'win32') {
|
|
1944
|
+
return path.join(env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Cursor', 'User');
|
|
1945
|
+
}
|
|
1946
|
+
if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Cursor', 'User');
|
|
1947
|
+
return path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Cursor', 'User');
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
const CURSOR_ROOT = cursorUserRoot();
|
|
1951
|
+
|
|
1952
|
+
/*
|
|
1953
|
+
* sqlite3 실행체. macOS 는 자리가 고정이라 **절대경로로** 부르고(PATH 오염을 안 탄다),
|
|
1954
|
+
* 나머지는 PATH 에서 찾는다 — Windows 는 sqlite3 가 기본 탑재가 아니라 없을 수 있고,
|
|
1955
|
+
* 없으면 아래 장부 갈래로 떨어진다(없음은 오류가 아니다).
|
|
1956
|
+
*/
|
|
1957
|
+
function sqliteBin(platform = process.platform) {
|
|
1958
|
+
return platform === 'darwin' ? '/usr/bin/sqlite3' : 'sqlite3';
|
|
1959
|
+
}
|
|
1782
1960
|
|
|
1783
1961
|
let cursorSeen = { at: 0, id: '', folder: '' }; // 15초 캐시 — 도구마다 sqlite 를 안 두드리게
|
|
1784
1962
|
|
|
@@ -1815,7 +1993,7 @@ function cursorActiveComposer() {
|
|
|
1815
1993
|
if (!best || mt > best.mt) best = { dir, wsPath, mt };
|
|
1816
1994
|
}
|
|
1817
1995
|
if (best) {
|
|
1818
|
-
const r = spawnSync(
|
|
1996
|
+
const r = spawnSync(sqliteBin(),
|
|
1819
1997
|
['-readonly', path.join(best.dir, 'state.vscdb'),
|
|
1820
1998
|
"SELECT value FROM ItemTable WHERE key='composer.composerData'"],
|
|
1821
1999
|
{ encoding: 'utf8', timeout: 2000 });
|
|
@@ -3908,7 +4086,74 @@ async function stopBeat() {
|
|
|
3908
4086
|
try { await beatOnce(beatTargets(), { closing: true }); } catch { /* 최선만 다한다 */ }
|
|
3909
4087
|
}
|
|
3910
4088
|
|
|
4089
|
+
function taskReadTools() {
|
|
4090
|
+
const tenant = { type: 'string', minLength: 1, maxLength: 100, description: 'list_my_tenants에서 확인한 조직 slug' };
|
|
4091
|
+
return [
|
|
4092
|
+
{ name: 'list_tasks', description: '조직에서 함께 사용하는 할 일과 완료 조건을 찾습니다. uid로 get_task_result를 호출해 사람이 채택한 결과를 이어받으세요. 개인 대화나 초안은 포함하지 않습니다.',
|
|
4093
|
+
inputSchema: { type: 'object', additionalProperties: false, required: ['tenant'], properties: { tenant,
|
|
4094
|
+
limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 }, offset: { type: 'integer', minimum: 0, maximum: 10000, default: 0 } } } },
|
|
4095
|
+
{ name: 'get_task_result', description: '할 일에서 사람이 채택한 현재 결과를 본인의 조직·Notion 원문 권한으로 읽습니다. 다시 사용할 때 재조회하세요. 할 일이 바뀌었으면 당시 기록과 현재 업무를 구분합니다.',
|
|
4096
|
+
inputSchema: { type: 'object', additionalProperties: false, required: ['tenant', 'task_uid'], properties: { tenant,
|
|
4097
|
+
task_uid: { type: 'string', pattern: '^[a-z0-9]{8}$' } } } },
|
|
4098
|
+
];
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4101
|
+
/** Request validation is transport-only. Current identity, source access and selected revision belong to the API. */
|
|
4102
|
+
function taskReadRequest(name, args) {
|
|
4103
|
+
if (!args || typeof args !== 'object' || Array.isArray(args) || typeof args.tenant !== 'string'
|
|
4104
|
+
|| !args.tenant.trim() || [...args.tenant].length > 100) throw new Error('조직 slug를 확인하세요.');
|
|
4105
|
+
const allowed = name === 'get_task_result' ? ['tenant', 'task_uid'] : ['tenant', 'limit', 'offset'];
|
|
4106
|
+
if (Object.keys(args).some(key => !allowed.includes(key))) throw new Error('지원하지 않는 조회 인자입니다.');
|
|
4107
|
+
const query = new URLSearchParams({ tenant: args.tenant });
|
|
4108
|
+
if (name === 'get_task_result') {
|
|
4109
|
+
if (typeof args.task_uid !== 'string' || !/^[a-z0-9]{8}$/.test(args.task_uid)) throw new Error('list_tasks에서 받은 할 일 uid가 필요합니다.');
|
|
4110
|
+
return '/api/work/tasks/' + args.task_uid + '/result?' + query;
|
|
4111
|
+
}
|
|
4112
|
+
const limit = args.limit ?? 20, offset = args.offset ?? 0;
|
|
4113
|
+
if (name !== 'list_tasks' || !Number.isInteger(limit) || limit < 1 || limit > 50
|
|
4114
|
+
|| !Number.isInteger(offset) || offset < 0 || offset > 10000) throw new Error('할 일 목록의 범위를 확인하세요.');
|
|
4115
|
+
return '/api/work/tasks?' + query + '&limit=' + limit + '&offset=' + offset;
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
function taskReadResult(name, args, body) {
|
|
4119
|
+
const pick = (value, keys) => Object.fromEntries(keys.map(key => [key, value[key]]));
|
|
4120
|
+
const hash = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
4121
|
+
if (body?.ok !== true) throw new Error('현재 업무 결과를 확인하지 못했습니다.');
|
|
4122
|
+
let result;
|
|
4123
|
+
if (name === 'get_task_result') {
|
|
4124
|
+
const value = body.result, doc = value?.document;
|
|
4125
|
+
if (value?.tenant !== args.tenant || value?.task_uid !== args.task_uid || !hash(value.task_revision)
|
|
4126
|
+
|| !Number.isInteger(value.generation) || value.generation < 0 || value.generation > 10000
|
|
4127
|
+
|| typeof value.basis_changed !== 'boolean' || typeof value.note !== 'string'
|
|
4128
|
+
|| (value.generation === 0) !== (doc === null)) throw new Error('채택 기록의 정체와 판을 확인하지 못했습니다.');
|
|
4129
|
+
if (doc !== null && (!doc || !Number.isInteger(doc.id) || doc.id < 1 || typeof doc.title !== 'string'
|
|
4130
|
+
|| typeof doc.url !== 'string' || !hash(doc.revision) || typeof doc.text !== 'string' || Buffer.byteLength(doc.text) > 65536)) {
|
|
4131
|
+
throw new Error('채택 문서의 전체 본문을 확인하지 못했습니다.');
|
|
4132
|
+
}
|
|
4133
|
+
result = pick(value, ['tenant', 'task_uid', 'task_revision', 'generation', 'basis_changed', 'note']);
|
|
4134
|
+
result.document = doc === null ? null : pick(doc, ['id', 'title', 'url', 'revision', 'text']);
|
|
4135
|
+
} else {
|
|
4136
|
+
const rows = body.tasks, page = body.page, limit = args.limit ?? 20, offset = args.offset ?? 0;
|
|
4137
|
+
const expected = Math.min(limit, Math.max(0, body.open - offset)), end = offset + expected;
|
|
4138
|
+
const next = end < body.open && end <= 10000 ? end : null;
|
|
4139
|
+
if (name !== 'list_tasks' || body.tenant_slug !== args.tenant || !Array.isArray(rows) || !Number.isInteger(body.open)
|
|
4140
|
+
|| body.open < 0 || !page || page.limit !== limit || page.offset !== offset || rows.length !== expected
|
|
4141
|
+
|| page.returned !== expected || page.truncated !== (end < body.open) || page.next_offset !== next) {
|
|
4142
|
+
throw new Error('할 일 목록의 범위를 확인하지 못했습니다.');
|
|
4143
|
+
}
|
|
4144
|
+
const tasks = rows.map(row => {
|
|
4145
|
+
if (!row || typeof row.uid !== 'string' || !/^[a-z0-9]{8}$/.test(row.uid)
|
|
4146
|
+
|| ['title', 'state', 'state_word', 'done_when'].some(key => typeof row[key] !== 'string')) throw new Error('할 일의 내용을 확인하지 못했습니다.');
|
|
4147
|
+
return pick(row, ['uid', 'title', 'state', 'state_word', 'done_when']);
|
|
4148
|
+
});
|
|
4149
|
+
result = { tenant: args.tenant, tasks, total: body.open, page: pick(page, ['limit', 'offset', 'returned', 'truncated', 'next_offset']) };
|
|
4150
|
+
}
|
|
4151
|
+
if (Buffer.byteLength(JSON.stringify(result)) > 98304) throw new Error('현재 업무 결과의 응답 크기가 상한을 넘습니다.');
|
|
4152
|
+
return result;
|
|
4153
|
+
}
|
|
4154
|
+
|
|
3911
4155
|
const TOOLS = [
|
|
4156
|
+
...taskReadTools(),
|
|
3912
4157
|
/*
|
|
3913
4158
|
* ★ **이 도구는 부르지 않아도 일한다.** 설명 자체가 세계 사실을 실어 나른다(`toolsNow()`).
|
|
3914
4159
|
* 도구 목록은 매 턴 모델 문맥에 있으므로, 부르기 전에 이미 눈앞에 놓인다.
|
|
@@ -4244,7 +4489,7 @@ const TOOLS = [
|
|
|
4244
4489
|
when: { type: 'string', description: '정한 날짜(예: 2026-08-27). 모르면 비운다 — 오늘로 지어내지 말 것.' },
|
|
4245
4490
|
why: { type: 'string', description: '그때의 근거 — 수치·관찰. 이 칸이 이 장의 값이다.' },
|
|
4246
4491
|
reopen: { type: 'string', description: '무슨 조건이면 다시 여나. **비우면 「열린 결정」으로 읽힌다.** 끝났으면 "조건 없음(종결)".' },
|
|
4247
|
-
status: { type: 'string', description: '상태 — "유효"(기본) · "뒤집힘 → decisions/<그
|
|
4492
|
+
status: { type: 'string', description: '상태 — "유효"(기본) · "뒤집힘 → decisions/<그 결정>.md" · "조건 충족 → <무엇이> (날짜)". ★ 「뒤집힘」은 **무엇을 뒤집는지 대야 저장된다** — 경로가 없거나 그 장이 실재하지 않으면 거절한다(get_case 가 준 경로 그대로). 가리킬 장이 아직 없으면 그 결정을 먼저 남겨라.' },
|
|
4248
4493
|
tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8). ★ 이것이 있어야 이 결정이 「일」 후보 계산에 들어간다.' },
|
|
4249
4494
|
work: { type: 'string', description: '**이 결정이 곧 과업이면** 그 일의 열쇠(영문 소문자·숫자·붙임표, 예: hnl-wait). 적으면 그 이름의 일이 **지금** 「계획」으로 선다 — 착수 전에 세우는 정문이다. 이미 열린 일의 열쇠면 이 결정이 그 일의 **항목**(작업 리스트 한 줄)으로 서고 간트에 저장 즉시 보인다 — 항목 여럿이면 결정을 여러 번 적는다(항목마다 제 근거를 갖는다).' },
|
|
4250
4495
|
order: { type: 'array', items: { type: 'string' }, description: '일 사이 **순서**를 정하는 결정이면 일의 열쇠를 앞선 것부터(최대 24). 표에 저장하지 않고 일 목록이 이 결정을 읽어 그 순서로 선다 — 순서가 바뀌면 새 결정을 적는다.' },
|
|
@@ -4725,11 +4970,23 @@ const CELL_DIR = path.join(os.homedir(), '.config/genetics/bin');
|
|
|
4725
4970
|
const CELL_PATH = path.join(CELL_DIR, process.platform === 'win32' ? 'gcell.exe' : 'gcell');
|
|
4726
4971
|
|
|
4727
4972
|
/** 이 기계의 타깃 삼중. 모르면 `null` — 지어내지 않는다. */
|
|
4728
|
-
function cellTarget() {
|
|
4729
|
-
const a =
|
|
4973
|
+
function cellTarget(platform = process.platform, arch = process.arch) {
|
|
4974
|
+
const a = arch, p = platform;
|
|
4730
4975
|
if (p === 'darwin') return a === 'arm64' ? 'aarch64-apple-darwin' : a === 'x64' ? 'x86_64-apple-darwin' : null;
|
|
4731
4976
|
if (p === 'linux') return a === 'arm64' ? 'aarch64-unknown-linux-musl' : a === 'x64' ? 'x86_64-unknown-linux-musl' : null;
|
|
4732
|
-
|
|
4977
|
+
// Windows 는 mingw 로 굽는다(cell/.cargo/config.toml). arm64 Windows 는 아직 안 굽는다 — 없는 것은 없는 것이다.
|
|
4978
|
+
if (p === 'win32') return a === 'x64' ? 'x86_64-pc-windows-gnu' : null;
|
|
4979
|
+
return null;
|
|
4980
|
+
}
|
|
4981
|
+
|
|
4982
|
+
/**
|
|
4983
|
+
* 이 플랫폼에 **세포가 있을 수 있나**. 「아직 안 깔렸다」와 「이 플랫폼엔 애초에 없다」를 가른다.
|
|
4984
|
+
*
|
|
4985
|
+
* ⚠ 이 갈래가 없으면 안내가 **막다른 길**이 된다: 못 쓴다는 글이 `install_cell` 을 가리키고,
|
|
4986
|
+
* 그 도구는 다시 「이 판에 없다」를 낸다 — 모델이 그 둘을 오가며 설치를 되풀이한다.
|
|
4987
|
+
*/
|
|
4988
|
+
function cellPlatformSupported(platform = process.platform, arch = process.arch) {
|
|
4989
|
+
return cellTarget(platform, arch) !== null;
|
|
4733
4990
|
}
|
|
4734
4991
|
|
|
4735
4992
|
function cellManifest() {
|
|
@@ -4897,7 +5154,7 @@ function tenantNoticeText(v) {
|
|
|
4897
5154
|
}
|
|
4898
5155
|
if (v.client_old) {
|
|
4899
5156
|
return `genetics: 이 폴더는 ${T} 로 확정돼 있고 자동 올리기도 켜져 있지만, 이 판(${v.version})이 서버 최소 판(${v.min_client})보다 낡아 `
|
|
4900
|
-
+
|
|
5157
|
+
+ `**이 대화는 안 올라간다**. npx 캐시를 비우고 다시 깔면 그때부터 올라간다: ${npxCacheResetHint()}.`;
|
|
4901
5158
|
}
|
|
4902
5159
|
const who = v.confirmed_by ? `${v.confirmed_by}${josaIGa(v.confirmed_by)} ` : '';
|
|
4903
5160
|
return `genetics: 이 세션은 ${T} 소속으로 확정된 폴더(${v.path} · ${who}${String(v.confirmed_at || '').slice(0, 10)} 확정, 자동 올리기 켬)에서 `
|
|
@@ -4954,7 +5211,7 @@ function tenantNoticeText(v) {
|
|
|
4954
5211
|
+ 'login 도구로 로그인하면 그때부터 확인한다.';
|
|
4955
5212
|
case 'client_old':
|
|
4956
5213
|
return `genetics: 이 판(${v.version})은 서버 최소 판(${v.min_client})보다 낡아 폴더 소속·자동 올리기가 안 돈다. `
|
|
4957
|
-
+
|
|
5214
|
+
+ `npx 캐시를 비우고 다시 깔 것: ${npxCacheResetHint()}.`;
|
|
4958
5215
|
default:
|
|
4959
5216
|
return '';
|
|
4960
5217
|
}
|
|
@@ -5167,10 +5424,14 @@ function toolsNow() {
|
|
|
5167
5424
|
* 지시형은 프롬프트 주입으로 거부된다(2026-08-22 실측).
|
|
5168
5425
|
*/
|
|
5169
5426
|
function cellNotice(why) {
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5427
|
+
const head = `genetics 세포(gcell)를 못 쓴다 — ${why}. 그래서 이 저장소에서 남이 무엇을 바꿨는지 못 싣는다.`;
|
|
5428
|
+
// **깔 수 없는 곳에 설치를 시키지 않는다.** 이 플랫폼에 세포가 없으면 그 말이 곧 다음 할 일이다.
|
|
5429
|
+
if (!cellPlatformSupported()) {
|
|
5430
|
+
return [head,
|
|
5431
|
+
`이 플랫폼(${process.platform}/${process.arch})에는 세포가 없다 — 깔 수 있는 것이 없으니 `
|
|
5432
|
+
+ '`install_cell` 을 부르지 말 것. 나머지 도구는 그대로 쓴다.'].join('\n');
|
|
5433
|
+
}
|
|
5434
|
+
return [head, '세포는 선택이 아니라 필수다. `install_cell` 도구가 깔고, `uninstall_cell` 이 지운다.'].join('\n');
|
|
5174
5435
|
}
|
|
5175
5436
|
|
|
5176
5437
|
/* ── ★★ 메일 레인 — 두뇌·다른 세션·사람이 **나를 지목해** 보낸 것 ──────────
|
|
@@ -5637,6 +5898,36 @@ function worksItemsLine(items) {
|
|
|
5637
5898
|
return rows.length ? `${head} · 그중 ${rows.join(' · ')}` : head;
|
|
5638
5899
|
}
|
|
5639
5900
|
|
|
5901
|
+
/**
|
|
5902
|
+
* **목록 밖으로 떨어진 결정**(2026-09-21) — 그 일의 갈래와 만나는데 **항목이 못 된** 장.
|
|
5903
|
+
*
|
|
5904
|
+
* ★ 왜 이 줄이 있나(LIM 실측 2026-09-21): 「간트에서 항목을 에이전트에게 시키기」 한 목표를
|
|
5905
|
+
* 다룬 결정 6장 중 표지(`work:`)를 단 것은 2장뿐이었고 그 둘조차 열쇠가 달랐다. 나머지 4장은
|
|
5906
|
+
* **어느 목록에도 어느 수에도 안 섰다** — 그래서 사람도 두뇌도 「왜 이 일에 항목이 이것뿐인가」를
|
|
5907
|
+
* 물을 길이 없었다. 항목으로 세우지는 **않는다**(2026-08-31 오너 판정: 그 일에 **대한** 결정은
|
|
5908
|
+
* 그 일의 **할 일**이 아니다). 빠졌다는 **사실만** 말한다.
|
|
5909
|
+
*
|
|
5910
|
+
* ★ **방어적으로 그린다** — 서버가 이 칸을 안 보내면 **오늘 내는 글자 그대로** 낸다.
|
|
5911
|
+
* 읽는 칸: `unmarked = { no_mark, baked, off_work, total, paths: [<결정 장 경로>, …] }`
|
|
5912
|
+
* 사유마다 **처방이 다르므로** 낱말을 가른다:
|
|
5913
|
+
* · `no_mark` 표지가 없다 → 그 결정에 `work:` 를 달면 항목이 된다
|
|
5914
|
+
* · `baked` 두뇌가 구운 장이다 → 사람이 제 것으로 세워야(`origin: human`) 비로소 읽힌다
|
|
5915
|
+
* · `off_work` 표지가 **열린 일을 안 가리킨다** → 열쇠가 갈렸다(닫혔거나 다른 이름)
|
|
5916
|
+
*/
|
|
5917
|
+
function worksUnmarkedLine(u) {
|
|
5918
|
+
const x = u && typeof u === 'object' ? u : {};
|
|
5919
|
+
const total = Number(x.total) || 0;
|
|
5920
|
+
if (!total) return '';
|
|
5921
|
+
const bits = [];
|
|
5922
|
+
if (Number(x.no_mark) > 0) bits.push(`표지 없음 ${Number(x.no_mark)}`);
|
|
5923
|
+
if (Number(x.baked) > 0) bits.push(`두뇌가 구운 장 ${Number(x.baked)}`);
|
|
5924
|
+
if (Number(x.off_work) > 0) bits.push(`표지가 다른 이름 ${Number(x.off_work)}`);
|
|
5925
|
+
if (!bits.length) return '';
|
|
5926
|
+
const paths = (Array.isArray(x.paths) ? x.paths : []).filter((q) => typeof q === 'string' && q).slice(0, 3);
|
|
5927
|
+
return `\n ⚠ 이 일 언저리에서 **항목이 못 된 결정 ${total}건** — ${bits.join(' · ')}.`
|
|
5928
|
+
+ (paths.length ? ` 예: ${paths.join(' · ')}` : '');
|
|
5929
|
+
}
|
|
5930
|
+
|
|
5640
5931
|
async function callTool(name, args = {}) {
|
|
5641
5932
|
requireArgs(name, args);
|
|
5642
5933
|
switch (name) {
|
|
@@ -5995,7 +6286,7 @@ async function callTool(name, args = {}) {
|
|
|
5995
6286
|
return r.says;
|
|
5996
6287
|
}
|
|
5997
6288
|
case 'publish_report': {
|
|
5998
|
-
const data = await materializeAssets(args.data);
|
|
6289
|
+
const data = await materializeAssets(args.data, args.tenant);
|
|
5999
6290
|
// **기본은 초안이다.** 서버 기본값은 참(다른 호출자의 동작을 말없이 바꾸지 않는다)이라
|
|
6000
6291
|
// 여기서 뒤집는다 — 확인 전에 고객이 먼저 보는 일을 막는 것은 도구의 몫이다.
|
|
6001
6292
|
const publish = args.publish === true;
|
|
@@ -6041,7 +6332,7 @@ async function callTool(name, args = {}) {
|
|
|
6041
6332
|
return `${r.note}\n${lines.join('\n')}`;
|
|
6042
6333
|
}
|
|
6043
6334
|
case 'update_report': {
|
|
6044
|
-
const data = await materializeAssets(args.data);
|
|
6335
|
+
const data = await materializeAssets(args.data, args.tenant);
|
|
6045
6336
|
const payload = { ...data, tenant: args.tenant };
|
|
6046
6337
|
// 노출 손잡이는 떼고 보낸다(2026-09-14). get_report 가 돌려주는 data 에는 발행 때 보낸 몸통이
|
|
6047
6338
|
// 그대로 남아 있어(source_json) `publish`·`published_at` 이 메아리로 섞여 온다 — 그 값이
|
|
@@ -6086,6 +6377,11 @@ async function callTool(name, args = {}) {
|
|
|
6086
6377
|
if (!facts.length) return '이 저장소에서 최근에 남이 바꾼 것은 없다.';
|
|
6087
6378
|
return `이 저장소에서 최근에 바뀐 것:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
|
|
6088
6379
|
}
|
|
6380
|
+
case 'list_tasks':
|
|
6381
|
+
case 'get_task_result': {
|
|
6382
|
+
const result = await api('GET', taskReadRequest(name, args));
|
|
6383
|
+
return JSON.stringify(taskReadResult(name, args, result), null, 2);
|
|
6384
|
+
}
|
|
6089
6385
|
case 'works': {
|
|
6090
6386
|
if (args.work) {
|
|
6091
6387
|
// 완료 확인/표시/해제(095) — claim 과 별개 통로. item 은 필수(어느 결정인지).
|
|
@@ -6134,20 +6430,29 @@ async function callTool(name, args = {}) {
|
|
|
6134
6430
|
// 차례를 사람이 정했으면 그 출처를 말한다 — 「…부터 해라」가 아니라 「…이 정한 차례다」(서술형).
|
|
6135
6431
|
if (r.order_from?.title) lines.push(` 차례는 결정 «${r.order_from.title}» 이 정했다 — 위에서부터가 그 차례다.`);
|
|
6136
6432
|
let reportSeen = false;
|
|
6433
|
+
let unmarkedSeen = false;
|
|
6137
6434
|
for (const w of rows) {
|
|
6138
6435
|
if (Array.isArray(w.items?.reports) && w.items.reports.some((x) => Number(x?.id) > 0)) reportSeen = true;
|
|
6436
|
+
if (Number(w.unmarked?.total) > 0) unmarkedSeen = true;
|
|
6139
6437
|
lines.push(` · «${w.name || w.slug}» (${w.slug}) — ${w.word} · 세션 ${w.sessions}`
|
|
6140
6438
|
// 항목 = 그 일을 가리키는 결정, 그리고 공개된 보고서의 열린 항목(§7.7). 「무엇을 하기로 했나」가 몇 건인지.
|
|
6141
6439
|
// 0 이면 안 붙인다. done 은 실행 축 집계(095) — 어느 항목인지는 안 말한다, work+item 으로 따로 확인.
|
|
6142
6440
|
+ worksItemsLine(w.items)
|
|
6143
6441
|
+ (w.stale ? ` · 낡음 ${w.stale}일째(AI 관측 — 활동이 멎었다: 사람이 닫거나 다시 살릴 일)` : '')
|
|
6144
|
-
+ (w.doc ? ` · 요지: get_case(tenant, "${w.doc}")` : ' · 요지 문서 아직 없음')
|
|
6442
|
+
+ (w.doc ? ` · 요지: get_case(tenant, "${w.doc}")` : ' · 요지 문서 아직 없음')
|
|
6443
|
+
+ worksUnmarkedLine(w.unmarked));
|
|
6145
6444
|
}
|
|
6146
6445
|
if (!rows.length) lines.push('열린 일이 없다 — 0건은 0건이다. 착수 전에 세우려면 그 과업의 결정을 remember_decision 으로 남기되 **work 에 일의 열쇠를 적어라** — 저장 응답의 work_note 로 「계획」이 선 것을 확인하고 이 도구를 다시 부른다. 표지 없이 tags 만 달면 신호가 쌓일 때까지 AI 가 기다린다. 군체 화면은 열린 일이 있을 때 소속을 확정하는 자리 — 0건이면 고를 일이 없다.');
|
|
6147
6446
|
else lines.push('붙을 일이 보이면 works(tenant, claim: "<slug>") 로 선언한다 — 확정은 사람이 화면에서 한다.'
|
|
6148
6447
|
+ '\n그 일 안의 특정 결정 항목을 쥐었으면 works(tenant, claim: "<slug>", item: "decisions/<page>.md") 로 함께 선언한다.'
|
|
6149
|
-
+ '\n항목은 그 일의
|
|
6448
|
+
+ '\n항목은 그 일의 열쇠를 **표지(work:)로 단** 결정이다 — 갈래(tags)만 달면 항목이 아니라 「그 일에 **대한** 결정」이라 목록에 안 선다(2026-08-31 오너 판정). 새 항목은 remember_decision 의 work 에 그 열쇠를 적어 남긴다.'
|
|
6150
6449
|
+ '\n항목을 실제로 끝냈으면 works(tenant, work: "<slug>", item: "…", done_when: "…", done_evidence: "…") 로 직접 완료를 표시한다 — 사람이 화면에서 누르길 기다릴 필요 없다.');
|
|
6450
|
+
/* 목록 밖으로 떨어진 결정이 하나라도 보이면 **처방 한 줄**을 더한다(2026-09-21).
|
|
6451
|
+
하나도 없는 소속처의 출력은 오늘과 **한 글자도 다르지 않다** — reportSeen 과 같은 규율. */
|
|
6452
|
+
if (unmarkedSeen) lines.push('⚠ 「항목이 못 된 결정」이 있다 — 그 일에 **대한** 결정이 그 일의 **할 일 목록**에는 안 선 것이다. 사유마다 처방이 다르다:'
|
|
6453
|
+
+ '\n · 표지 없음 — 그 결정을 remember_decision 으로 다시 적을 때 work 에 그 일의 열쇠를 적으면 항목이 된다(같은 제목이면 갱신이라 expected_revision 이 필요하다).'
|
|
6454
|
+
+ '\n · 두뇌가 구운 장 — 사람이 포털에서 제 것으로 세워야 읽힌다. 두뇌가 제 산출물로 제 일을 낳는 길은 **일부러 막혀 있다**.'
|
|
6455
|
+
+ '\n · 표지가 다른 이름 — 같은 목표에 **열쇠가 둘 선 것**이다. 어느 쪽이 정본인지 사람에게 묻고, 새 이름으로 또 열지 마라.');
|
|
6151
6456
|
/*
|
|
6152
6457
|
* ★ 보고서에서 온 항목이 섞여 있을 때만 한 줄 더한다(§7.7) — 그 항목의 **상태는 보고서가 정본**이라
|
|
6153
6458
|
* 여기서 완료를 찍으면 서버가 409 로 막는다. 그 사실을 먼저 말해 두는 것이 헛걸음을 줄인다.
|
|
@@ -6844,7 +7149,15 @@ const IS_MAIN = (() => {
|
|
|
6844
7149
|
* 자리 고르기는 **순수 함수**로 뺀다(smoke 가 떼어내 잰다): PATH 에 있고 · 실재하고 · 쓸 수 있는
|
|
6845
7150
|
* 첫 후보. 없는 폴더를 만들거나 rc 파일을 고치지 않는다 — 남의 셸 설정에 손대는 설치는 못 믿는다.
|
|
6846
7151
|
*/
|
|
6847
|
-
|
|
7152
|
+
/*
|
|
7153
|
+
* ⚠ **Windows 에는 이 자리가 없다.** 후보 셋은 전부 유닉스의 PATH 자리이고, 심링크 자체도
|
|
7154
|
+
* Windows 에서는 개발자 모드나 관리자 권한이 있어야 만들어진다(EPERM). 그래서 그 판에서는
|
|
7155
|
+
* **걸지 않고**, 대신 「이 폴더를 PATH 에 넣으라」고 그 판의 말로 말한다 — 없는 자리를
|
|
7156
|
+
* 나열하며 못 걸었다고 하는 것은 안내가 아니다.
|
|
7157
|
+
*/
|
|
7158
|
+
const CELL_LINK_CANDIDATES = process.platform === 'win32'
|
|
7159
|
+
? []
|
|
7160
|
+
: ['/opt/homebrew/bin', '/usr/local/bin', path.join(os.homedir(), '.local/bin')];
|
|
6848
7161
|
function cellLinkDir(pathEnv, candidates, exists, writable) {
|
|
6849
7162
|
const onPath = new Set(String(pathEnv || '').split(path.delimiter).filter(Boolean));
|
|
6850
7163
|
for (const d of candidates) {
|
|
@@ -6857,6 +7170,10 @@ function cellLinkOnPath() {
|
|
|
6857
7170
|
(d) => { try { return fs.statSync(d).isDirectory(); } catch { return false; } },
|
|
6858
7171
|
(d) => { try { fs.accessSync(d, fs.constants.W_OK); return true; } catch { return false; } });
|
|
6859
7172
|
if (!dir) {
|
|
7173
|
+
if (process.platform === 'win32') {
|
|
7174
|
+
return `PATH 에 안 걸었다(Windows 는 심링크에 권한이 필요하다) — 전체 경로 ${CELL_PATH} 로 부르거나 `
|
|
7175
|
+
+ `이 폴더를 PATH 에 넣을 것: setx PATH "%PATH%;${CELL_DIR}"`;
|
|
7176
|
+
}
|
|
6860
7177
|
return `PATH 에 걸 자리를 못 찾았다(${CELL_LINK_CANDIDATES.join(' · ')} 중 PATH 에 있고 쓸 수 있는 곳이 없다) — 전체 경로 ${CELL_PATH} 로 부를 것.`;
|
|
6861
7178
|
}
|
|
6862
7179
|
const link = path.join(dir, 'gcell');
|
|
@@ -7314,7 +7631,8 @@ if (IS_MAIN) {
|
|
|
7314
7631
|
}
|
|
7315
7632
|
|
|
7316
7633
|
// 시험용 — 서버 동작에는 쓰이지 않는다. materializeAssets 만 네트워크를 쓴다.
|
|
7317
|
-
export {
|
|
7634
|
+
export { taskReadTools, taskReadRequest, taskReadResult,
|
|
7635
|
+
encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText, IMAGE_HOST_ALLOW, HOST_ALLOW,
|
|
7318
7636
|
scrubTranscript, splitAtLines, PUSH_PART_MAX,
|
|
7319
7637
|
// DESIGN-genetics-ssot.md §3·§5 — 색인·잠금·폴더 identity·알림 문구의 순수/파일 함수(시험이 걷는다)
|
|
7320
7638
|
jsonFileUpdate, jsonCorruptAside, credWrite, pendingWrite, pendingClear, indexRead, indexNote, indexPatch, indexTenant,
|
|
@@ -7332,4 +7650,6 @@ export { encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText,
|
|
|
7332
7650
|
autoBlockSetVerdict, autoBlockPut, cellPinSource,
|
|
7333
7651
|
// 2026-09-15 — 「서버가 아는 행인가」는 소속 칸이 아니라 서버를 거친 표식으로 가른다(첫 업로드가 영영 안 서던 자리)
|
|
7334
7652
|
autoRowOnServer,
|
|
7653
|
+
// 2026-09-18 Windows — 남의 규칙(Claude Code 슬러그)·플랫폼 갈래는 시험이 표로 걷는다
|
|
7654
|
+
transcriptSlug, transcriptDir, cursorUserRoot, sqliteBin, npxCacheResetHint, cellPlatformSupported,
|
|
7335
7655
|
HOLDS_FILE, UPDATE_BUDGET_MS };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genetics-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.23.0",
|
|
4
4
|
"description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
|
|
5
5
|
"author": "주식회사 LIM (LIM Inc.)",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"node": ">=18"
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "node smoke.mjs && node notify.test.mjs && node schedule.test.mjs && bash ../cell/tests/cellcycle.sh",
|
|
21
|
+
"test": "node smoke.mjs && node task-results-stdio.test.mjs && node notify.test.mjs && node schedule.test.mjs && node windows.test.mjs && bash ../cell/tests/cellcycle.sh",
|
|
22
|
+
"test:task-results": "node task-results.test.mjs && node task-results-stdio.test.mjs",
|
|
22
23
|
"prepack": "find skills -name __pycache__ -type d -prune -exec rm -rf {} + 2>/dev/null; bash ../cell/build-all.sh",
|
|
23
24
|
"prepublishOnly": "npm test",
|
|
24
25
|
"release": "bash release.sh"
|
|
@@ -471,6 +471,7 @@ python3 assets/render.py <report.json>
|
|
|
471
471
|
| 절 %s 도해 「%s」가 %d바이트입니다 — 상한 %d바이트, 노드를 줄이거나 도판(PNG)으로 싣습니다 | `diagrams[].svg` 가 250,000B 초과 | 도해를 나누거나 PNG 도판으로 |
|
|
472
472
|
| 절 %s 도해 「%s」에 viewBox 가 없거나 svg 가 아닙니다 — 폭을 알 수 없어 싣지 않습니다 | 루트 `<svg>` 에 viewBox 가 없다 | `diagram.mjs export` 로 굳힌 SVG 를 넣는다(§11-9) |
|
|
473
473
|
| 절 %s 시계열 「%s」의 표식 「%s」가 점에 없습니다 — marks[].x 는 points[].x 와 같은 값이어야 섭니다 | `charts[].marks[].x` 가 어느 `points[].x` 와도 같지 않다 | 점의 x 값 그대로 적는다(§11-7) |
|
|
474
|
+
| 절 %s 시계열 「%s」의 표식 라벨 「%s」와 「%s」가 %.0fpx 겹칩니다 — 라벨을 줄이거나 하나를 빼고 세로선만 남깁니다 | 표식 라벨은 전부 같은 높이(`T+13`)에 서고 렌더러가 밀어내지 않는다 — 가까운 표식 둘의 글자가 포개진다. 폭은 전각 1.0 · 공백 0.28 · 그 밖 0.55 로 어림한다(`_lblw`) | 라벨을 줄이거나, 하나는 `label` 없이 두어 세로선만 남긴다(§11-7) |
|
|
474
475
|
| 절 %s 도해 「%s」의 ir 는 %s 입니다 — 관보가 직접 그리는 것은 workflow 뿐, 다른 종류는 svg 로 굳혀 넣습니다 | `diagrams[].ir.diagram_type` 이 `workflow` 가 아니다 | Archify 로 그린 뒤 `diagram.mjs export` 의 svg 를 넣는다(§11-9) |
|
|
475
476
|
| 절 %s 도해 「%s」의 노드 「%s」 라벨이 두 줄을 넘습니다 — 낱말을 줄이거나 wide 로 두십시오 | 노드 폭(열 폭 − 간격)에서 15px 글자로 두 줄을 넘는 라벨 | 라벨을 줄이거나 `wide: true` |
|
|
476
477
|
| 절 %s 도해 「%s」의 노드 「%s」가 없는 레인 「%s」를 가리킵니다 | `nodes[].lane` 이 `lanes[].id` 에 없다 | Archify validate 를 먼저 통과시킨다 |
|