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.
Binary file
package/bin/manifest.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
- "aarch64-apple-darwin": "70aa0772b147db518b10f22ab6a027a3b7b6a98bbc6d477505bf69cb9d9b447c",
3
- "aarch64-unknown-linux-musl": "87fecda2d2348a0403957b3ab7b7ed55dff72560d6768ad241d93861ab5c56d0",
4
- "x86_64-apple-darwin": "4877439d3b788c303fced3dd458331f21d3661f4eb463572c260e2c82888c165",
5
- "x86_64-unknown-linux-musl": "58e7821dd31b81232175023a86db8659cb2f5c5d47620e7b939df4e155f93b98"
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
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
- + 'librsvg 를 설치하세요(brew install librsvg · apt install librsvg2-bin). '
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
- + 'libwebp 를 설치하세요(brew install webp · apt install webp). '
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
- + 'libwebp 를 설치하세요(brew install webp · apt install webp). '
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
- async function materializeAssets(data) {
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
- /** cwd → 트랜스크립트 디렉터리. **해석된 경로**로 슬러그를 만든다(심볼릭 링크 함정). */
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.replace(/[/_]/g, '-'));
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
- const CURSOR_ROOT = process.env.GENETICS_CURSOR_ROOT
1781
- || path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User');
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('/usr/bin/sqlite3',
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 = process.arch, p = process.platform;
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
- return null; // Windows 는 아직 못 굽는다(cell/.cargo/config.toml)
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
- + '**이 대화는 안 올라간다**. npx 캐시를 비우고 다시 깔면 그때부터 올라간다: rm -rf ~/.npm/_npx && npx -y genetics-mcp@latest.';
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
- + 'npx 캐시를 비우고 다시 깔 것: rm -rf ~/.npm/_npx && npx -y genetics-mcp@latest.';
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
- return [
5171
- `genetics 세포(gcell)를 못 쓴다 — ${why}. 그래서 이 저장소에서 남이 무엇을 바꿨는지 못 싣는다.`,
5172
- '세포는 선택이 아니라 필수다. `install_cell` 도구가 깔고, `uninstall_cell` 이 지운다.',
5173
- ].join('\n');
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항목은 그 일의 열쇠·갈래를 단 **결정**이다 — 무엇을 하기로 했는지는 지식의 결정 장에 있고, 새 항목은 remember_decision 으로 남긴다.'
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
- const CELL_LINK_CANDIDATES = ['/opt/homebrew/bin', '/usr/local/bin', path.join(os.homedir(), '.local/bin')];
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 { encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText, IMAGE_HOST_ALLOW, HOST_ALLOW,
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.21.0",
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 를 먼저 통과시킨다 |