genetics-mcp 2.9.0 → 2.9.2

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,6 @@
1
1
  {
2
- "aarch64-apple-darwin": "3f5fdf3c6029d94eafdb4ecf34f502676b3262f15de05eb54ecf6483d2d0f829",
3
- "aarch64-unknown-linux-musl": "5588db11a4aad41a65609d4cf14f389d106fb8b74023aa745ae4765738243006",
4
- "x86_64-apple-darwin": "8c440a19d2aee09dc8e2c3fe0a1b513563a7ab023d97722b99e5bae8eeb2cc62",
5
- "x86_64-unknown-linux-musl": "f01bf90ab7014764487773b9ac91ad8532ac6a727eedd6ff50151c3e6eab127e"
2
+ "aarch64-apple-darwin": "c9af6ff2133d88bf2576298182127763179e9537a7b8639e4e0c94cdaa42885c",
3
+ "aarch64-unknown-linux-musl": "2062f902b62c4029458a42b55d4adce84a7806f9b7786e4cad2850e4411c9219",
4
+ "x86_64-apple-darwin": "9cbbaa748df9a96cf7a818c242bbe1686372cd2875d9adaeecfbb71926773b3a",
5
+ "x86_64-unknown-linux-musl": "c62081a202c23fd8c816d4312741f6ba2408598f0cb8da7dafc5740cd48b85f6"
6
6
  }
Binary file
Binary file
package/genetics.mjs CHANGED
@@ -328,6 +328,50 @@ function imageDims(b) {
328
328
  return null;
329
329
  }
330
330
 
331
+ /* ── 자산 원본 취득 ② 로컬 파일 ─────────────────────────────────────────
332
+ *
333
+ * `source` 는 **원격에서 받아오는** 입구라 SSRF 방어(허용 호스트·리다이렉트 금지·IP 리터럴
334
+ * 거절)가 걸려 있다. 그 방어는 「에이전트가 고른 주소로 이 프로세스가 어디까지 손을 뻗을 수
335
+ * 있는가」를 막는 것이다.
336
+ *
337
+ * `file` 은 **다른 기전**이다. 이 프로세스를 돌리는 사람의 디스크에서 파일 하나를 읽는 것이고,
338
+ * 그 사람은 이미 그 파일을 갖고 있다. 네트워크로 나가지 않으므로 SSRF 면이 없고, 새로 생기는
339
+ * 권한도 없다. 그래서 **허용 호스트 목록을 로컬로 넓히지 않고 입구를 따로 낸다** — 합치면
340
+ * 위 주석이 경고한 바로 그 사고(이미지 호스트를 넓히다 내부망이 열리는 것)가 난다.
341
+ *
342
+ * 계약 §7 의 본뜻인 「이미지 바이트를 도구 호출 인자로 실어 나르지 않는다」는 그대로다.
343
+ * 바이트는 여기서 처음 생긴다 — 경로만 건네받는다.
344
+ *
345
+ * 이 입구가 없으면 **직접 촬영한 화면을 문서에 실을 방법이 아예 없다.** 검수 보고에서
346
+ * 그것은 「보여 주는」 수단을 통째로 잃는 것과 같다.
347
+ */
348
+ function readLocalImage(p) {
349
+ if (/^[a-z][a-z0-9+.-]*:/i.test(p)) {
350
+ throw new Error(`file 에는 주소가 아니라 파일 경로를 줍니다: ${p}\n`
351
+ + '원격 주소는 source 로 주세요(허용 호스트만).');
352
+ }
353
+ if (!path.isAbsolute(p)) {
354
+ throw new Error(`file 은 절대경로여야 합니다: ${p}\n`
355
+ + '이 프로세스의 작업 디렉터리는 호출자와 다를 수 있어 상대경로는 뜻이 달라집니다.');
356
+ }
357
+ let st;
358
+ try { st = fs.statSync(p); }
359
+ catch { throw new Error(`file 을 찾을 수 없습니다: ${p}`); }
360
+ if (!st.isFile()) throw new Error(`file 이 일반 파일이 아닙니다: ${p}`);
361
+ if (st.size > IMAGE_MAX_DOWNLOAD) {
362
+ throw new Error(`원본이 ${st.size.toLocaleString()}B 로 상한 `
363
+ + `${IMAGE_MAX_DOWNLOAD.toLocaleString()}B 를 넘습니다: ${p}`);
364
+ }
365
+ const buf = fs.readFileSync(p);
366
+ // 확장자를 안 믿는다 — 매직바이트로 판정한다(서버도 같은 것을 다시 잰다).
367
+ const ok = isSvg(buf)
368
+ || (buf[0] === 0x89 && buf.toString('ascii', 1, 4) === 'PNG')
369
+ || (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP')
370
+ || (buf[0] === 0xff && buf[1] === 0xd8);
371
+ if (!ok) throw new Error(`file 이 PNG·WebP·JPEG·SVG 가 아닙니다(매직바이트 기준): ${p}`);
372
+ return buf;
373
+ }
374
+
331
375
  /** 원본을 **스트림으로** 받으며 상한을 넘는 순간 끊는다. */
332
376
  async function fetchImage(url) {
333
377
  if (!imageHostAllowed(url)) {
@@ -387,10 +431,51 @@ const isSvg = (b) => {
387
431
  return head.startsWith('<svg') || (head.startsWith('<?xml') && head.includes('<svg'));
388
432
  };
389
433
 
390
- function encodeAsset(buf, role) {
434
+ /**
435
+ * 실촬 화면(스크린샷 등)을 예산 안에 앉힌다.
436
+ *
437
+ * 무손실로 굳히면 화면 캡처는 대개 장당 상한을 몇 배로 넘는다. 그렇다고 「그림을 단순하게」
438
+ * 할 수는 없다 — 그것이 증거의 내용이기 때문이다. 그래서 **화질을 계단으로 내려** 예산에
439
+ * 맞춘다. 글자가 읽히는 것이 이 그림의 목적이라 `-sharp_yuv` 를 끝까지 유지한다.
440
+ */
441
+ const SHOT_QUALITY_LADDER = [82, 72, 62, 52];
442
+
443
+ function encodeShot(buf) {
444
+ const d = imageDims(buf);
445
+ const resize = (d && Math.max(d.w, d.h) > ASSET_LONG_EDGE)
446
+ ? (d.h > d.w ? ['-resize', '0', String(ASSET_LONG_EDGE)]
447
+ : ['-resize', String(ASSET_LONG_EDGE), '0'])
448
+ : [];
449
+ let last = null;
450
+ for (const q of SHOT_QUALITY_LADDER) {
451
+ const r = spawnSync('cwebp',
452
+ ['-quiet', '-q', String(q), '-sharp_yuv', ...resize, '-o', '-', '--', '-'],
453
+ { input: buf, maxBuffer: IMAGE_MAX_DOWNLOAD });
454
+ if (r.error && r.error.code === 'ENOENT') throw cwebpMissing();
455
+ if (r.status !== 0) throw new Error(`자산 인코딩 실패: ${String(r.stderr).slice(0, 200)}`);
456
+ last = r.stdout;
457
+ if (r.stdout.length <= ASSET_MAX_ONE) return r.stdout;
458
+ }
459
+ throw new Error(`화면 캡처가 최저 화질에서도 ${last.length.toLocaleString()}B 로 장당 상한 `
460
+ + `${ASSET_MAX_ONE.toLocaleString()}B 를 넘습니다. 화질이 아니라 **담은 화면 수**를 줄이세요 — `
461
+ + '여러 장을 한 판에 몰아 넣으면 어차피 문서에서 글자가 안 읽힙니다.');
462
+ }
463
+
464
+ function cwebpMissing() {
465
+ return new Error('cwebp 가 없어 자산을 계약 규격(WebP)으로 굳힐 수 없습니다.\n'
466
+ + 'libwebp 를 설치하세요(brew install webp · apt install webp). '
467
+ + '다른 포맷으로 우회하지 않습니다 — 계약이 포맷을 규정으로 못박았습니다.');
468
+ }
469
+
470
+ function encodeAsset(buf, role, provider) {
391
471
  // 벡터가 오면 먼저 래스터로 굳힌다. 그 뒤는 래스터와 완전히 같은 길을 간다.
392
472
  if (isSvg(buf)) buf = rasterizeSvg(buf);
393
- const lossless = role === 'eof' || role === 'figure';
473
+ // 무손실은 **도형**의 몫이다 — 평면 색과 선이라 무손실이 작고 선이 안 뭉갠다.
474
+ // 실촬 화면(user-provided)은 사진에 가까워 무손실이 예산을 몇 배로 넘긴다.
475
+ // 같은 role 이라도 출처가 다르면 다른 길이다 — 그 갈림을 provider 가 이미 들고 있다.
476
+ const shot = role === 'figure' && provider === 'user-provided';
477
+ const lossless = role === 'eof' || (role === 'figure' && !shot);
478
+ if (shot) return encodeShot(buf);
394
479
  let args;
395
480
  if (lossless) {
396
481
  args = ['-quiet', '-lossless', '-exact', '-o', '-', '--', '-'];
@@ -436,14 +521,15 @@ async function materializeAssets(data) {
436
521
  if (!data || !Array.isArray(data.assets) || !data.assets.length) return data;
437
522
  const out = [];
438
523
  for (const a of data.assets) {
439
- const { data: dropped, ...meta } = a ?? {};
440
- if (!a?.source) {
441
- // source 가 없으면 예전 경로 그대로 — data 를 그대로 통과시킨다(판 A 하위호환).
524
+ const { data: dropped, file: fpath, ...meta } = a ?? {};
525
+ if (!a?.source && !fpath) {
526
+ // 둘 다 없으면 예전 경로 그대로 — data 를 그대로 통과시킨다(판 A 하위호환).
442
527
  out.push(dropped === undefined ? meta : { ...meta, data: dropped });
443
528
  continue;
444
529
  }
445
- const raw = await fetchImage(String(a.source));
446
- const enc = encodeAsset(raw, String(a.role ?? ''));
530
+ // 원격보다 **로컬이 이긴다** — 손에 든 실물이 주소보다 확실하다.
531
+ const raw = fpath ? readLocalImage(String(fpath)) : await fetchImage(String(a.source));
532
+ const enc = encodeAsset(raw, String(a.role ?? ''), String(a.provider ?? ''));
447
533
  const d = imageDims(enc);
448
534
  out.push({
449
535
  ...meta,
@@ -703,13 +789,20 @@ const REPORT_DATA = {
703
789
  + 'chapter 에 아래 chapters 의 id 를 적으면 그 절 앞에 챕터 오프닝이 펼쳐진다. '
704
790
  + 'name 은 목차에 쓰는 **짧은 이름**이고, 본문의 큰 제목은 챕터의 title_lines 가 낸다.\n'
705
791
  + '\n'
706
- + '절 앞에 세울 수 있는 블록 둘(선택):\n'
707
- + '· `principles`: [[짧은제목, 설명], …] — 3열 카드. 번호는 자리로 매겨지니 주지 않는다. '
708
- + '개선 원칙처럼 **셋 안팎으로 나뉘는 규칙**에 쓴다. 긴 문단은 그대로 paragraphs 에 둔다.\n'
709
- + '· `logic`: {kicker, title_lines[], lede, models:[{label, flow[], body}, …]} — 대조 도해. '
710
- + '`flow` 는 「현재 상태 → 인접 등급 → 도착점」 같은 단계이고 **마지막 칸이 강조**된다. '
711
- + '첫 모델이 「현재」, 그다음이 「개선」으로 그려진다(자리로 정한다).\n'
712
- + '둘 다 **데이터가 있을 때만** 그려진다 — 없는 문구를 지어내지 말 것.\n'
792
+ + '⚠ `principles`·`logic`·`chapter` 는 **옛 서식 전용**이다. 관보(기본 서식)에서는 '
793
+ + '자리가 없어 렌더러가 버리고 ⚠ 로 센다 — 관보로 낼 문서에는 넣지 말 것.\n'
794
+ + '\n'
795
+ + '· `flow`: {caption, heads[4], rows:[{no, cells[3]}]} — **관보의 의존 흐름도.** '
796
+ + '「A 가 되면 B 가 되고 C 가 된다」를 글로 되풀이하지 않게 한다. kind 와 무관하게 쓴다. '
797
+ + '위 `logic` 안의 flow 와는 **다른 것이다**(그쪽은 옛 서식). '
798
+ + '열은 넷으로 고정 — heads 는 정확히 4개(첫째가 좌표 열 머리), 각 행 cells 는 정확히 3개. '
799
+ + '칸 성격은 **자리가 정한다**: cells[0] 해야 할 것(강조 면) → cells[1] 이미 된 것(흐린 글자) '
800
+ + '→ cells[2] 그러면(결과). 읽는 순서가 그와 안 맞으면 **열 제목을 그 순서로 바꿔 적는다**. '
801
+ + 'cells[] 는 {text, note?, path?} — note 는 둘째 줄, path 는 경로·기술 문자열. '
802
+ + 'rows[].no 가 `4.1`·`04-1` 모양이면 그 사례로 링크가 된다. '
803
+ + '그림 번호(`그림 5-1`)는 적지 않는다 — 자리가 매기고 `plates` 와 한 셈을 쓴다.\n'
804
+ + '· `plates`: [assets[].id, …] — 절 본문 도판.\n'
805
+ + '데이터가 있을 때만 그려진다 — 없는 문구를 지어내지 말 것.\n'
713
806
  + '\n'
714
807
  + '관보 문법이 절마다 요구하는 칸:\n'
715
808
  + '· `part`: 이 절이 속한 PART 의 no. 안 주면 첫 묶음으로 떨어진다.\n'
@@ -719,7 +812,7 @@ const REPORT_DATA = {
719
812
  + 'key 는 이 절에서 하나만 가져간다면 그것, advice 는 어디를 참조하라는 것. '
720
813
  + '채우려고 넣으면 안 넣느니만 못하다.\n'
721
814
  + '· `lede`: 절 간지의 도입문 — 다루는 범위와 **다루지 않는 범위**.\n'
722
- + '· `sub`: 차례 행에 붙는 한 줄 설명. `short`: 측면 색인 레일용 짧은 이름.\n'
815
+ + '· `sub`: 차례 행에 붙는 한 줄 설명. `short`: 측면 색인 레일용 짧은 이름.\n· `plates`: 이 절에 놓을 **도판**의 id 목록(`assets[].id`). 관보의 그림 문법으로 서고, 번호는 자리가 매긴다(그림 <절번호>-<순번>). 어느 절에도 안 걸린 도판은 싣지 않고 경고로 센다.\n'
723
816
  + '· `tone`: 장 색을 뜻으로 고른다 — you(독자 몫)·deep(요지)·neutral(허브)·'
724
817
  + 'done(끝난 것)·wait(대기)·hold(유보). 생략하면 kind 가 정한다.\n'
725
818
  + '· kind:status 전용 — `board_title`(대조표 제목) · `board_hint`(표 아래 안내).\n'
@@ -780,7 +873,9 @@ const REPORT_DATA = {
780
873
  + '적으면 서버가 그려서 나중에 채운다(문서는 그 자리를 잡은 채로 즉시 발행된다). '
781
874
  + '이미 그린 그림이 있으면 `source` 에 원본 주소를 준다. '
782
875
  + '**바이트를 직접 싣지 말 것** — `source` 를 주면 이 도구가 '
783
- + '받아와 규격대로 굳혀 보낸다(hero·chapter 는 WebP q78 긴 변 1400px, eof·figure 는 무손실). '
876
+ + '받아와 규격대로 굳혀 보낸다(hero·chapter 는 WebP q78 긴 변 1400px, eof·도형 figure 는 무손실). '
877
+ + '**직접 촬영한 화면은 `file` 에 로컬 절대경로를 준다** — role: figure · provider: user-provided '
878
+ + '로 두면 화질을 계단으로 내려 예산 안에 앉힌다. 그 도판은 `sections[].plates` 에 id 를 적어야 자리가 생긴다. '
784
879
  + '크기와 형식은 인코딩 결과에서 읽으므로 선언하지 않는다. 전부 data: URI 로 문서에 박히며 '
785
880
  + '(열람 CSP 가 외부 이미지를 막는다), 서버가 출처·매직바이트·예산(장당 80,000B·합계 400,000B)을 '
786
881
  + '다시 검증한다.',
@@ -788,6 +883,24 @@ const REPORT_DATA = {
788
883
  type: 'object',
789
884
  properties: {
790
885
  id: { type: 'string', description: '영소문자·숫자·하이픈 1~40자. 문서 안 참조 이름' },
886
+ file: {
887
+ type: 'string',
888
+ description: '**로컬 파일의 절대경로.** 직접 촬영한 화면처럼 인터넷에 없는 그림을 '
889
+ + '실을 때 쓴다 — 이 도구가 읽어 규격대로 굳혀 보낸다(바이트를 인자로 싣지 않는 것은 '
890
+ + '`source` 와 같다). `source` 와 함께 주면 **file 이 이긴다.** 주소를 넣지 말 것 — '
891
+ + '원격은 `source` 의 몫이고 허용 호스트가 따로 있다.',
892
+ },
893
+ caption: {
894
+ type: 'string',
895
+ description: 'role: figure 의 **그림 캡션.** 「그림 4-1」 번호 뒤에 붙는다(번호는 자리가 '
896
+ + '매기므로 적지 않는다). 무엇을 보이는 화면인지 한 문장으로 — 본문이 이미 말한 것을 '
897
+ + '되풀이하지 말고 **이 화면에서 무엇을 보라는 것인지**를 적는다.',
898
+ },
899
+ source_text: {
900
+ type: 'string',
901
+ description: 'role: figure 의 출처 한 줄(캡션 아래). 언제·어디서 얻은 화면인가. '
902
+ + '예: 「2026. 8. 24. Galaxy S22+ 실기기 홈 화면」.',
903
+ },
791
904
  role: {
792
905
  type: 'string', enum: ['hero', 'chapter', 'figure', 'eof', 'logo'],
793
906
  description: '`logo` 는 **표지 우상단 고객사 마크**다 — provider 는 user-provided, '
@@ -2252,6 +2365,23 @@ function cellClean(s) {
2252
2365
  * `gcell delta` 의 글 출력은 2026-08-23 에 고쳤는데, MCP 는 `--json` 을 **자기가 포맷**하고
2253
2366
  * 있어서 여기만 여전히 시각을 버리고 있었다. 같은 결함이 두 자리에 있었던 것이다.
2254
2367
  */
2368
+ /**
2369
+ * 사건 한 줄을 **누구**로 부를까. 서버의 `world_who()`(`lib/world_delta.php`)와 **같은 규칙**이다 —
2370
+ * 두 자로 말하면 같은 사실을 화면과 도구가 다르게 말한다.
2371
+ *
2372
+ * ⚠ **`unknown` 은 이름이 아니다.** 전에는 `client` 하나만 찍어 「unknown 세션이 썼다」로
2373
+ * 나갔고, 사람이 읽으면 「unknown 이라는 세포」로 보였다(사용자 지적 2026-08-24).
2374
+ * 세포는 세션을 알고 있었다 — 아는 것을 안 쓰고 모르는 것을 이름 자리에 놓고 있었다.
2375
+ */
2376
+ function worldWho(c) {
2377
+ const client = cellClean(c.client || '').trim();
2378
+ const sid = cellClean(c.session || c.session_uuid || '').trim();
2379
+ const short = sid ? sid.slice(0, 8) : '';
2380
+ if (client && client !== 'unknown') return short ? `${client}(${short}) 세션이` : `${client} 세션이`;
2381
+ if (short) return `${short} 세션이`;
2382
+ return '누군가';
2383
+ }
2384
+
2255
2385
  function worldFacts(max = 8) {
2256
2386
  const { bin } = cellCheck();
2257
2387
  if (!bin) return null; // 세포가 없다 — 「없다」와 구별한다
@@ -2266,7 +2396,7 @@ function worldFacts(max = 8) {
2266
2396
  const t = at > 0
2267
2397
  ? new Date(at * 1000).toTimeString().slice(0, 5)
2268
2398
  : '시각 미상';
2269
- return `${cellClean(c.path)} — ${cellClean(c.client)} 세션이 ${t}에 썼다`;
2399
+ return `${cellClean(c.path)} — ${worldWho(c)} ${t}에 썼다`;
2270
2400
  });
2271
2401
  if (ch.length > max) out.push(`그 밖 ${ch.length - max}건`);
2272
2402
  return out;
@@ -2405,10 +2535,33 @@ async function brainPull() {
2405
2535
  */
2406
2536
  function brainFresh(ev, first, now) {
2407
2537
  if (!first) return true;
2408
- const at = Date.parse(String(ev?.at || '')) || 0;
2538
+ const at = brainAtMs(ev);
2409
2539
  return at > 0 && now - at <= BRAIN_FRESH_MS;
2410
2540
  }
2411
2541
 
2542
+ /**
2543
+ * 서버가 준 `at` 을 밀리초로. **서버는 시간대 표식 없는 UTC 로 준다.**
2544
+ *
2545
+ * ⚠⚠ 맨몸 `Date.parse("2026-08-24 01:50:30")` 은 그 문자열을 **로컬 시각**으로 읽는다.
2546
+ * 서버는 `world_at()` 에서 `gmdate('Y-m-d H:i:s')` 로 쓰므로 실제로는 UTC 다
2547
+ * (서버 스스로도 그것을 안다 — `world_delta.php` 가 화면에 쓸 때 `…substr(at,11,5) . 'Z'`).
2548
+ * 그래서 **KST(+9) 기계에서는 방금 난 모순이 9시간 낡은 것**으로 보인다.
2549
+ * 첫 바퀴는 나이를 보고 버리므로 백신이 통째로 사라지고, **커서는 그대로 전진해**
2550
+ * 그 사건들은 이 세션에서 영영 안 온다. 조용한 손실이다.
2551
+ *
2552
+ * 2026-08-24 실측: 같은 하네스에 서버 꼴을 넣으면 백신 0건, `Z` 를 붙이면 1건.
2553
+ * 내 첫 PoC 가 이걸 놓친 이유는 픽스처가 `toISOString()` 이라 **실제로 깨지는 그 꼴을
2554
+ * 비켜 갔기** 때문이다 — 픽스처는 서버가 진짜 주는 글자여야 한다.
2555
+ *
2556
+ * ⚠ 시각을 못 읽으면 **0**. 모르는 것을 「방금」으로 치는 쪽이 더 위험하다.
2557
+ */
2558
+ const BRAIN_AT_NAIVE = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})$/;
2559
+ function brainAtMs(ev) {
2560
+ const s = String(ev?.at || '').trim();
2561
+ const m = BRAIN_AT_NAIVE.exec(s);
2562
+ return (m ? Date.parse(`${m[1]}T${m[2]}Z`) : Date.parse(s)) || 0;
2563
+ }
2564
+
2412
2565
  /**
2413
2566
  * 큐에 쌓인 모순을 **한 번씩만** 말한다.
2414
2567
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.9.0",
3
+ "version": "2.9.2",
4
4
  "description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
5
5
  "author": "주식회사 LIM (LIM Inc.)",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -0,0 +1,186 @@
1
+ ---
2
+ name: genetics-cell-id
3
+ description: |
4
+ 이 기계에서 **누가 무엇을 만지고 있는지** 세포(gcell)에게 물어 답합니다 —
5
+ 내 세션 번호, 옆 세션의 말 걸 수 있는 이름, 이 파일의 주인, 내가 모르는 사이 바뀐 것.
6
+ 다음과 같은 요청 시 사용하세요:
7
+ - "너 세션번호가?" / "내 세션 id 뭐야" / "제네틱에서 나는 누구야"
8
+ - "지금 이거 누가 만지고 있어?" / "이 파일 주인이 누구야" / "다른 창에서 뭐 하고 있어"
9
+ - "옆 세션한테 물어봐" / "그쪽에 알려줘" (→ 이름을 먼저 알아내고 SendMessage)
10
+ - "내가 모르는 사이에 뭐 바뀌었어?" / "지금 알고 있는 게 아직 맞아?"
11
+ 그리고 사람이 안 시켜도 부를 때: **공유 트리에 커밋·배포하기 전**, 여러 세션이 도는
12
+ 저장소를 고치기 전, 그리고 「내 모델이 낡았나」가 의심될 때.
13
+ [책임 경계] vs genetics-report: 저 스킬=고객에게 낼 문서, 이 스킬=지금 이 기계의 사람·세션 지형.
14
+ version: "1.0.0"
15
+ ---
16
+
17
+ # 세포에게 신원을 묻는다 (genetics-cell-id)
18
+
19
+ ## 개요
20
+
21
+ 에이전트는 자기가 누구인지, 옆에 누가 있는지 모릅니다. 그래서 두 가지가 반복됩니다 —
22
+ 「세션번호가 뭐냐」에 답하느라 경로를 뒤지고, **남의 작업분을 자기 것으로 착각해 삼킵니다.**
23
+
24
+ 세포(`gcell`)가 그 답을 갖고 있습니다. 이 기계에서 도는 에이전트들이 무엇을 읽고 썼는지
25
+ 전사에서 관측하기 때문입니다. 이 스킬은 **그것을 묻는 법**입니다.
26
+
27
+ ## 세포는 무엇인가
28
+
29
+ `gcell` 은 이 기계의 관측자입니다. 스스로 이렇게 적어 두었습니다.
30
+
31
+ > genetics-cell (`gcell`) — 이 기계의 세포.
32
+ > 이 기계에서 도는 에이전트들이 무엇을 읽었는지 전사에서 관측하고, 그 믿음이 깨지면 턴을 연다.
33
+ > 서버가 유전체라면 각 기계가 세포다.
34
+ > — `cell/src/main.rs` 머리
35
+
36
+ **서버(`world` 도구)와 다릅니다.** 세포는 **이 기계**를 보고, `world` 는 **서버에 올라온 다른
37
+ 기계까지** 봅니다. 옆 창을 찾을 때는 세포, 다른 노트북·클라우드를 찾을 때는 `world` 입니다.
38
+
39
+ ## 실행 경로 — PATH 에 없습니다
40
+
41
+ ```bash
42
+ ~/.config/genetics/bin/gcell --version # → gcell 0.1.0
43
+ ```
44
+
45
+ `gcell` 은 PATH 에 안 걸립니다. MCP 는 `api.env` 의 `GENETICS_CELL_BIN` 으로 자리를 찾습니다.
46
+ 그래서 명령을 안내할 때 **`gcell` 이라고만 쓰면 사람 손에서 「command not found」가 됩니다.**
47
+ 전체 경로를 쓰거나 그 줄을 읽어 쓰세요.
48
+
49
+ ```bash
50
+ GC=$(grep -m1 '^GENETICS_CELL_BIN=' ~/.config/genetics/api.env | cut -d= -f2-)
51
+ GC=${GC:-~/.config/genetics/bin/gcell}
52
+ ```
53
+
54
+ ## 묻는 법 — 명령 넷
55
+
56
+ ### 1. 여기 누가 있는가 · 뭐라고 부르는가 → `agents`
57
+
58
+ **옆 세션에게 말을 걸어야 할 때 이것부터입니다.** 세션 uuid 를 **말 걸 수 있는 이름**으로
59
+ 바꿔 줍니다.
60
+
61
+ ```bash
62
+ "$GC" agents # 현재 폴더의 저장소 · 최근 360분
63
+ "$GC" agents --since 60 # 최근 1시간
64
+ "$GC" agents --path frontend # 그 경로를 만진 세포만
65
+ ```
66
+
67
+ 실측 출력:
68
+
69
+ ```text
70
+ 최근 360분 · 세포 3곳
71
+
72
+ app-94 [8f0646]
73
+ 세션 85f8b79f-b34a-4e16-888f-9b48b54bd67a · claude-code · 마지막 05:52Z
74
+ · FCG-CX-Agent/frontend/src/ui/tokens.css
75
+ · FCG-CX-Agent/src/agent_ax_config/run_failures.py
76
+
77
+ app-2f [812eb5]
78
+ 세션 065131a7-4e15-467a-b159-63eb727e7269 · claude-code · 마지막 12:52Z
79
+ · FCG-CX-Agent/frontend/src/shell/LlmStatusBadge.jsx
80
+ ```
81
+
82
+ 왼쪽의 `app-94` 가 **주소**입니다. 그대로 `SendMessage({to: "app-94", …})` 로 보냅니다.
83
+ `ListAgents` 가 같은 이름을 보여주므로 둘을 교차 확인할 수 있습니다.
84
+
85
+ ### 2. 이 파일의 주인이 누구인가 → `who --path`
86
+
87
+ 고치기 전에 묻습니다. 파일 중심으로 답합니다.
88
+
89
+ ```bash
90
+ "$GC" who --path www/portal
91
+ ```
92
+
93
+ ```text
94
+ 최근 360분 · 다른 세션 4곳이 파일 8개를 만졌다
95
+ claude-code (4e421bf2) — 2개
96
+ claude-code (c5ec0c2c) — 1개
97
+
98
+ · www/portal/lib/genetics_weights.php — claude-code 04:50Z
99
+ ```
100
+
101
+ 여기 나오는 것은 **짧은 id** 입니다. 말을 걸려면 `agents` 로 이름을 받으세요.
102
+
103
+ ### 3. 내가 모르는 사이 무엇이 바뀌었는가 → `delta`
104
+
105
+ 「내가 알고 있는 게 아직 맞나」에 답합니다. 오래 도는 세션에서 특히 그렇습니다.
106
+
107
+ ```bash
108
+ "$GC" delta
109
+ ```
110
+
111
+ ```text
112
+ · www/portal/session.php — claude-code 세션이 06:38Z에 썼다
113
+ · www/portal/lib/layout.php — claude-code 세션이 06:37Z에 썼다
114
+ ```
115
+
116
+ ### 4. 세포가 무엇을 쥐고 있는가 → `status`
117
+
118
+ 세션별 믿음·깨움·전사 줄수를 냅니다. 진단용이고, 신원 확인용은 아닙니다 —
119
+ **어느 줄이 나인지 표시해 주지 않습니다.**
120
+
121
+ ## 나는 누구인가
122
+
123
+ 세포는 「나」를 따로 표시해 주지 않습니다. 내 세션 uuid 는 **전사 파일 이름**입니다.
124
+
125
+ ```bash
126
+ ls -t ~/.claude/projects/<프로젝트-슬러그>/*.jsonl | head -1
127
+ ```
128
+
129
+ 프로젝트 슬러그는 작업 폴더 경로를 `-` 로 바꾼 것입니다(예: `-Users-me-project-app`).
130
+ 스크래치패드 경로에도 같은 uuid 가 들어 있습니다.
131
+
132
+ **사람·소속처는 세포가 아니라 MCP 에 묻습니다.**
133
+
134
+ | 무엇 | 어디에 |
135
+ |---|---|
136
+ | 내 세션 uuid | 전사 파일 이름 |
137
+ | 내가 어느 사람·어느 권한으로 붙어 있나 | `whoami` 도구 |
138
+ | 내가 닿는 소속처 목록 | `list_my_tenants` 도구 |
139
+ | 내 세션이 서버에 올라가 있나 | `list_sessions` 도구 — 목록에 내 앞자리가 있는지 본다 |
140
+
141
+ ## 말 걸기
142
+
143
+ 이름을 알아낸 뒤 보냅니다. 상대의 답신은 자동으로 들어옵니다.
144
+
145
+ ```
146
+ SendMessage({to: "app-2f", message: "…"})
147
+ ```
148
+
149
+ 받은 메시지에 답할 때는 그 메시지의 `from` 을 그대로 `to` 에 씁니다.
150
+
151
+ **권한은 세션마다 따로입니다.** 내 세션에서 막힌 일을 옆 세션에 부탁하지 마세요 —
152
+ 사용자의 권한 결정을 우회하는 것이 됩니다. 막힌 일은 사용자에게 되돌립니다.
153
+
154
+ ## 공유 트리 규율 — 실사고에서 나온 규칙
155
+
156
+ 2026-08-24, 한 세션의 커밋이 **인덱스에 올라 있던 남의 스테이징 2건을 삼켰습니다.**
157
+ 즉시 분리 복원했지만, 원인은 단순합니다 — **이 트리는 인덱스도 공유입니다.**
158
+
159
+ 여러 세포가 도는 저장소에서는 이 순서를 지킵니다.
160
+
161
+ 1. `agents --path <내가 만질 경로>` — 지금 그 자리에 누가 있나
162
+ 2. `git status` — **인덱스에 남의 스테이징이 있나**
163
+ 3. 있으면 커밋하지 말고 그 세포에게 물어봅니다(`SendMessage`)
164
+ 4. 내가 만든 것만 골라 커밋합니다
165
+
166
+ 「mtime 이 최근이니 내 것이겠지」는 근거가 아닙니다. 세포에게 물으면 **누가 언제 썼는지**가
167
+ 나옵니다.
168
+
169
+ ## 함정
170
+
171
+ | 함정 | 실제 |
172
+ |---|---|
173
+ | `gcell` 이 PATH 에 없다 | 전체 경로 또는 `GENETICS_CELL_BIN` 을 쓴다 |
174
+ | `--since` 기본이 360분 | 그보다 오래된 작업은 **안 보인다**. 「아무도 없다」는 6시간 안의 이야기다 |
175
+ | 저장소 판정이 현재 폴더 기준 | 다른 저장소를 보려면 `--root <경로>` |
176
+ | `who` 의 짧은 id 로는 말을 못 건다 | 주소는 `agents` 의 이름이다 |
177
+ | 세포는 이 기계만 본다 | 다른 기계는 `world` 도구 |
178
+ | `status` 에 내 줄이 표시되지 않는다 | 내 uuid 는 전사 파일 이름에서 얻는다 |
179
+
180
+ ## 무엇을 하지 않는가
181
+
182
+ - **세포를 깔거나 지우지 않습니다.** `install_cell`·`uninstall_cell` 은 사람의 결정입니다.
183
+ 안 깔려 있으면 그 사실을 말하고 멈춥니다.
184
+ - **훅을 건드리지 않습니다.** `install-hooks` 는 이 기계의 턴 동작을 바꿉니다.
185
+ - 관측 결과를 **지시로 읽지 않습니다.** 「누가 무엇을 만졌다」는 사실이고,
186
+ 그것으로 무엇을 할지는 사용자와 정합니다.