genetics-mcp 2.16.3 → 2.16.6

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": "d13eb43d93b2c5551620bde8f25caaaeb0e7952a00e96add790999e537496e88",
3
- "aarch64-unknown-linux-musl": "d834aba463313110483e1b6d6f5a204093d82846372e6a9dcd54e7f0019b1c66",
4
- "x86_64-apple-darwin": "11a0aa685c3d687b8a6c2a51377a4a70a5c6c6527a43d7d58c62231622d54160",
5
- "x86_64-unknown-linux-musl": "4ac0ef43c50a5c1e70832c86cc98f34411e8e63d011601bb09aaf62967f4018d"
2
+ "aarch64-apple-darwin": "6d2937c7f2569a5c4af1bd51324bfec057d17056d6d5af7c2442b6bf93bd042d",
3
+ "aarch64-unknown-linux-musl": "9ad56880eff20bcc337b1859cb84b60464c6b17d87c7d2861ae615ab62903e00",
4
+ "x86_64-apple-darwin": "52de9a078db4db1e85546d7fadc862a7fe723609021f7c5d60706c78f13ff618",
5
+ "x86_64-unknown-linux-musl": "645f9551c097b9af9c9d10e34c0280251d9de230a9daf798bec314bb0c1cb63e"
6
6
  }
Binary file
Binary file
package/genetics.mjs CHANGED
@@ -109,7 +109,15 @@ Object.assign(cfg, Object.fromEntries(
109
109
 
110
110
  // **기본 주소를 두지 않는다.** 값을 박아 두면 출하물이 "그 서버를 아는 클라이언트" 가 되고,
111
111
  // 공개 배포 시 운영 엔드포인트가 그대로 공개된다. 주소는 쓰는 쪽이 정한다.
112
- const BASE = (cfg.GENETICS_API_BASE || '').replace(/\/$/, '');
112
+ // 로그인 자격이 이미 주소를 들고 있으면(`credentials.json` 의 `base`) 그것을 쓴다 —
113
+ // 사람이 승인한 그 서버다. 출하 기본값이 아니다.
114
+ const BASE = (cfg.GENETICS_API_BASE || '').replace(/\/$/, '')
115
+ || (() => {
116
+ try {
117
+ const p = cfg0.GENETICS_CRED || path.join(path.dirname(ENV_FILE), 'credentials.json');
118
+ return String(JSON.parse(fs.readFileSync(p, 'utf8')).base || '').replace(/\/$/, '');
119
+ } catch { return ''; }
120
+ })();
113
121
  const KEY = cfg.GENETICS_API_KEY || '';
114
122
 
115
123
  /* ── 자격은 두 갈래다 ────────────────────────────────────────────────────
@@ -1323,9 +1331,16 @@ function cursorActiveFromLedger() {
1323
1331
  const dir = process.env.GCELL_STATE_DIR || path.join(os.homedir(), '.config', 'genetics', 'cell');
1324
1332
  let cwd = process.cwd();
1325
1333
  try { cwd = fs.realpathSync(cwd); } catch { /* 없으면 준 값 */ }
1334
+ let home = os.homedir();
1335
+ try { home = fs.realpathSync(home); } catch { /* */ }
1336
+ // 전역 `npx genetics-mcp` 헬퍼는 cwd 가 집 또는 `/` 다(2026-08-31 pid 28342 실측).
1337
+ // 그때는 뿌리 일치가 원천적으로 불가능하니, 6시간 안 장부 중 **가장 최근 훅**을 쓴다.
1338
+ // 창이 여럿이면 마지막에 훅이 돈 대화다 — 지금 도구를 부른 창과 같다.
1339
+ const vague = !cwd || cwd === '/' || cwd === home;
1326
1340
  let names;
1327
1341
  try { names = fs.readdirSync(dir); } catch { return { id: '', folder: '' }; }
1328
- let best = null;
1342
+ const matched = [];
1343
+ const recent = [];
1329
1344
  for (const name of names) {
1330
1345
  if (!name.startsWith('active-cursor-') || !name.endsWith('.json')) continue;
1331
1346
  let v;
@@ -1335,10 +1350,12 @@ function cursorActiveFromLedger() {
1335
1350
  if (!root || !/^[0-9a-f-]{36}$/i.test(id)) continue;
1336
1351
  const at = Number(v.at || 0);
1337
1352
  if (at && (Date.now() / 1000) - at > 6 * 3600) continue;
1338
- if (!(cwd === root || cwd.startsWith(root + path.sep))) continue;
1339
- if (!best || at > best.at) best = { id, folder: root, at };
1353
+ const row = { id, folder: root, at };
1354
+ recent.push(row);
1355
+ if (cwd === root || cwd.startsWith(root + path.sep)) matched.push(row);
1340
1356
  }
1341
- return best || { id: '', folder: '' };
1357
+ const pick = (rows) => rows.sort((a, b) => b.at - a.at)[0] || null;
1358
+ return pick(matched) || (vague ? pick(recent) : null) || { id: '', folder: '' };
1342
1359
  }
1343
1360
 
1344
1361
  /* ── Cursor 신원 ──────────────────────────────────────────────────────────
@@ -2373,6 +2390,7 @@ const TOOLS = [
2373
2390
  'HTML 을 만들지 말 것 — 내용만 주면 서버가 렌더한다.\n' +
2374
2391
  '**기본은 초안이다** — 만들어도 고객에게는 보이지 않는다. 눈으로 확인한 뒤 ' +
2375
2392
  'release_report 로 열어 준다. 바로 열려면 publish: true 를 명시할 것.\n' +
2393
+ '**문체는 두 면이다** — 우리가 한 일은 개조식(~함/~했음/~임), **상대가 할 일에는 높임 어간을 살린다**. 「회신 필요」가 아니라 「회신 주시면 됨」, 「~해 주셔야 함」이 아니라 명사형 「~ 요청」. 개조식을 상대에게 쓰면 명령형이 된다. dry_run 이 경고로 센다.\n' +
2376
2394
  '쓰기 전에 결과만 보고 싶으면 dry_run: true — 렌더까지 돌려 경고를 주고 아무것도 저장하지 않는다.',
2377
2395
  inputSchema: {
2378
2396
  type: 'object',
@@ -2443,7 +2461,8 @@ const TOOLS = [
2443
2461
  '사람이 편집기로 고친 문서도 교체할 수 있다(2026-08-25) — 대신 무엇을 덮게 되는지 목록이 먼저 말하고, 덮은 뒤 되돌릴 판이 응답에 온다. ' +
2444
2462
  '**그림 계약**: `assets` 를 **빼고** 보내면 서버가 저장된 그림을 그대로 유지한다. 전부 지우려면 `assets: []` 를 명시하고 ' +
2445
2463
  '`sections[].plates`·`chapters[].asset` 에서도 그 id 를 빼야 한다 — 가리키는 곳이 없는 id 는 422 로 거절된다. ' +
2446
- '한 장만 바꾸려면 그 id 에 새 `source`/`file` 을 주고 나머지는 get_report 가 준 표식 그대로 두면 된다.',
2464
+ '한 장만 바꾸려면 그 id 에 새 `source`/`file` 을 주고 나머지는 get_report 가 준 표식 그대로 두면 된다.\n' +
2465
+ '**문체는 두 면이다** — 우리가 한 일은 개조식(~함/~했음/~임), **상대가 할 일에는 높임 어간을 살린다**. 「회신 필요」가 아니라 「회신 주시면 됨」, 「~해 주셔야 함」이 아니라 명사형 「~ 요청」. 개조식을 상대에게 쓰면 명령형이 된다. dry_run 이 경고로 센다.\n',
2447
2466
  inputSchema: {
2448
2467
  type: 'object',
2449
2468
  properties: {
@@ -2476,7 +2495,13 @@ const TOOLS = [
2476
2495
  + '처방은 다시 하면 되는 동작, 근거는 실측치·커밋·로그. 원인과 근거는 비워도 되지만 '
2477
2496
  + '비면 그렇게 적힌다 — 없는 확신을 지어내지 말 것.\n'
2478
2497
  + '근거에 **저장소 상대 경로**(예: www/portal/lib/x.php:12)를 적으면 그 파일을 읽은 채로 든 '
2479
- + '다른 세션에게 이 사례가 메일로 간다 — 검색하지 않아도 닿는다.',
2498
+ + '다른 세션에게 이 사례가 메일로 간다 — 검색하지 않아도 닿는다.\n'
2499
+ + '★ **어느 소속처인지 폴더가 아니라 주제로 정한다.** 지금 열린 폴더가 곧 이 사례의 주인은 '
2500
+ + '아니다 — A 폴더에 앉아 B 제품을 고쳤으면 이 사례는 **B 의 소속처** 것이다. 남의 소속처에 '
2501
+ + '적힌 지식은 그쪽 판단을 흐리고, 나중에 사람이 하나씩 골라내야 한다.\n'
2502
+ + '★ **경로를 반드시 남긴다.** paths(또는 근거의 경로)가 비면 이 장이 어느 저장소의 일인지 '
2503
+ + '기계도 사람도 못 가린다 — 실측(2026-08-31): 한 소속처의 사례 10장 중 다른 제품 이야기 '
2504
+ + '5장이 전부 경로 0이었고, 그래서 소속을 잘못 적은 것을 아무도 못 짚었다.',
2480
2505
  inputSchema: {
2481
2506
  type: 'object',
2482
2507
  properties: {
@@ -2505,7 +2530,7 @@ const TOOLS = [
2505
2530
  + '언제 부르는가 — 사람이 이런 뜻으로 말할 때:\n'
2506
2531
  + ' "이렇게 가기로 했다" · "이 안으로 정했다" · "그건 안 하기로 했다" · "반려했다" · '
2507
2532
  + '"이 방향으로 합의했다" · "결정 남겨줘" · "왜 이렇게 정했는지 적어둬"\n'
2508
- + '★ **`remember_case` 와 무엇이 다른가**: 사례는 **겪은 문제**(증상이 있다), 결정은 **정한 것**'
2533
+ + '★ **어느 소속처인지 폴더가 아니라 주제로 정한다** — A 폴더에 앉아 B 제품을 정했으면 B 의 소속처 것이다.\n★ **`remember_case` 와 무엇이 다른가**: 사례는 **겪은 문제**(증상이 있다), 결정은 **정한 것**'
2509
2534
  + '(증상이 없다). 기획 결정·디자인 근거·반려 이유처럼 **고장이 아닌 일**은 전부 이쪽이다. '
2510
2535
  + '증상을 지어내 사례로 적지 말 것 — 그러면 다음 사람이 증상으로 찾다가 못 찾는다.\n'
2511
2536
  + '무엇을 적는가: **왜**가 이 장의 값이다(그때의 근거·수치·관찰). 그리고 **무슨 조건이면 다시 여나** — '
@@ -2524,7 +2549,7 @@ const TOOLS = [
2524
2549
  reopen: { type: 'string', description: '무슨 조건이면 다시 여나. **비우면 「열린 결정」으로 읽힌다.** 끝났으면 "조건 없음(종결)".' },
2525
2550
  status: { type: 'string', description: '상태 — "유효"(기본) · "뒤집힘 → decisions/<그 결정>" · "조건 충족 → <무엇이> (날짜)".' },
2526
2551
  tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8). ★ 이것이 있어야 이 결정이 「일」 후보 계산에 들어간다.' },
2527
- work: { type: 'string', description: '**이 결정이 곧 과업이면** 그 일의 열쇠(영문 소문자·숫자·붙임표, 예: hnl-wait). 적으면 다음 바퀴에 그 이름의 일이 「계획」으로 선다 — 착수 전에 세우는 정문이다. 이미 있는 이름이면 조용히 그대로 둔다.' },
2552
+ work: { type: 'string', description: '**이 결정이 곧 과업이면** 그 일의 열쇠(영문 소문자·숫자·붙임표, 예: hnl-wait). 적으면 다음 바퀴에 그 이름의 일이 「계획」으로 선다 — 착수 전에 세우는 정문이다. 이미 열린 일의 열쇠면 이 결정이 그 일의 **항목**(작업 리스트 한 줄)으로 서고 간트에 저장 즉시 보인다 — 항목 여럿이면 결정을 여러 번 적는다(항목마다 제 근거를 갖는다).' },
2528
2553
  order: { type: 'array', items: { type: 'string' }, description: '일 사이 **순서**를 정하는 결정이면 일의 열쇠를 앞선 것부터(최대 24). 표에 저장하지 않고 일 목록이 이 결정을 읽어 그 순서로 선다 — 순서가 바뀌면 새 결정을 적는다.' },
2529
2554
  },
2530
2555
  required: ['tenant', 'title', 'what'],
@@ -2555,13 +2580,27 @@ const TOOLS = [
2555
2580
  + '사람이 「지금 뭐가 진행 중이지」라고 물을 때 · 세션 요지에 「어느 일에도 속해 있지 않다」가 왔을 때.\n'
2556
2581
  + '「계획」 = 착수 전에 선 일(세션 0) — 신호(태그 있는 결정·세션 무리)가 쌓이면 AI 가 세운다. 이 목록에서 이름을 보고 그 방향의 작업이면 '
2557
2582
  + 'claim 으로 **착수를 선언**한다(즉시 잠정 효력 — 세션 요지·화면에 「선언(잠정)」이 선다. '
2558
- + '사실이 되는 것은 사람이 화면에서 확정할 때고, 판정기는 증거로 따로 검증한다).\n'
2583
+ + '사실이 되는 것은 사람이 화면에서 확정할 때고, 판정기는 증거로 따로 검증한다). '
2584
+ + '그 일 안의 결정 항목을 쥐었으면 item 으로 함께 말한다 — 이것도 잠정이며 항목 상태를 저장하지 않는다.\n'
2585
+ + '항목 「목록」의 개수(items.done)는 그 일 전체의 완료 수만 세지, **어느 항목인지는 안 말한다** '
2586
+ + '— 특정 결정 항목을 건드리기 **전에는 반드시** work(slug)+item(item_key)만 주고 불러 '
2587
+ + '이미 끝났는지 먼저 확인한다(부작용 없음). 실제로 끝냈으면(파일·시험·명령으로 스스로 확인한 뒤) '
2588
+ + 'work+item+done_when(무엇이 완료 조건인가)+done_evidence(그 조건이 충족됐다는 구체적 관측 — '
2589
+ + '파일 경로·시험 이름·커밋 등)를 **넷 다** 채워 부른다. 근거 없이 "됐다"만 보내면 거절된다. '
2590
+ + '잘못 표시했으면 work+item+undone:true 로 되돌린다(사후 정정 — 조용히 지워지지 않고 원장에 남는다).\n'
2591
+ + '**사람이 화면에서 체크박스로 표시하는 것은 이것의 별도 경로일 뿐, 필수 경로가 아니다** — '
2592
+ + '두뇌(에이전트) 스스로 완료를 확인했으면 사람이 누르기를 기다리지 않고 바로 이 도구로 표시한다.\n'
2559
2593
  + '요지 전문은 get_case(tenant, "work/<slug>.md") 로 읽는다. **0 이면 0 이라고 말한다.**',
2560
2594
  inputSchema: {
2561
2595
  type: 'object',
2562
2596
  properties: {
2563
2597
  tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2564
2598
  claim: { type: 'string', description: '이 일(slug)에 붙겠다고 선언한다 — 잠정이며, 다른 일을 선언하면 앞 선언은 대체된다. 비우면 목록만 읽는다.' },
2599
+ item: { type: 'string', description: 'claim 과 함께 쓰면 착수 선언(잠정). work 와 함께 쓰면 완료 확인/표시/해제 대상 — 이 일 안의 결정 항목(item_key, 예: decisions/foo.md).' },
2600
+ work: { type: 'string', description: '이 일(slug)의 특정 항목을 완료로 확인·표시·해제할 때 쓴다 — claim 과 별개(착수 선언 없이도 완료를 다룰 수 있다). item 과 함께 쓴다.' },
2601
+ done_when: { type: 'string', description: '이 항목의 완료 조건 — 무엇이 되면 끝인가. done_evidence 와 반드시 함께 채운다(work+item과 함께).' },
2602
+ done_evidence: { type: 'string', description: 'done_when 과 함께 쓴다 — 그 조건이 실제로 충족됐다는 구체적 관측(파일·시험 결과·커밋 등). 비우면 완료로 못 간다 — 지어내지 말 것, 스스로 확인한 것만.' },
2603
+ undone: { type: 'boolean', description: '이 항목의 완료 표시를 되돌린다(사후 정정) — work+item 과 함께 쓴다.' },
2565
2604
  },
2566
2605
  required: ['tenant'],
2567
2606
  },
@@ -2757,8 +2796,10 @@ const TOOLS = [
2757
2796
  {
2758
2797
  name: 'mail',
2759
2798
  description:
2760
- '**다른 세션이나 두뇌에게 말을 건다.** 이 기계의 옆 창뿐 아니라 **다른 기계·Codex 세션에도 닿는다** '
2761
- + '— 서버를 지나기 때문이다(`SendMessage` 는 이 기계의 Claude Code 만 본다).\n'
2799
+ '**다른 세션이나 두뇌에게 말을 건다.** 말걸기의 정본이다. 같은 기계의 옆 창·Cursor·Codex·'
2800
+ + '다른 기계·두뇌에 닿는다 — 서버를 지나기 때문이다.\n'
2801
+ + '`SendMessage` 는 **이 기계의 Claude Code 끼리만** 가는 지름길이다. `ListAgents` 에 없다고 '
2802
+ + '해서 세션이 없는 것이 아니다 — 발견은 `gcell agents`, 주소는 여기 `to` 다.\n'
2762
2803
  + '무엇을 해 달라고 **부탁해도 된다** — 이 창구는 받는 쪽 눈앞에 「누가 보냈는지」를 서버가 '
2763
2804
  + '반드시 붙여 주므로, 세계 장부와 달리 지시형이 막히지 않는다.\n'
2764
2805
  + '받는 것은 부를 필요가 없다 — 온 것은 다른 도구를 부를 때 응답 꼬리에 저절로 실린다.\n'
@@ -3888,7 +3929,8 @@ async function callTool(name, args = {}) {
3888
3929
  exact: '', moved: '(옮겨 붙음)', drifted: '(원문이 바뀜 — 저자가 본 것과 지금이 다르다)',
3889
3930
  reparented: '(문장을 잃어 상위로)', unmoored: '(자리를 잃음 — 문서 어디인지 모른다)',
3890
3931
  };
3891
- const lines = [`보고서 #${id} · 본문 판 body_rev=${r.body_rev} · ${r.note}`, ''];
3932
+ const lines = [`보고서 #${id} · 본문 판 body_rev=${r.body_rev} · ${r.note}`, '']
3933
+ if (r.capped) lines.splice(1, 0, '⚠ 목록이 잘렸다 — 아래가 전부가 아니다.');
3892
3934
  for (const n of r.notes) {
3893
3935
  if (n.parent_id !== null) continue;
3894
3936
  const at = n.anchor.path || n.anchor.uid || '문서 전체';
@@ -3987,25 +4029,65 @@ async function callTool(name, args = {}) {
3987
4029
  return `이 저장소에서 최근에 바뀐 것:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
3988
4030
  }
3989
4031
  case 'works': {
4032
+ if (args.work) {
4033
+ // 완료 확인/표시/해제(095) — claim 과 별개 통로. item 은 필수(어느 결정인지).
4034
+ const wSlug = String(args.work);
4035
+ const item = args.item ? String(args.item) : '';
4036
+ if (!item) return "완료 확인/표시/해제에는 item(item_key)이 있어야 한다 — work만으로는 어느 결정인지 모른다.";
4037
+ if (args.undone) {
4038
+ const r = await api('POST', '/api/work/done', { tenant: args.tenant, work: wSlug, item, undone: true });
4039
+ return `${r.item.key} — 완료 표시를 되돌렸다(사후 정정, 원장에 남는다). 지금 상태: ${r.word}.`;
4040
+ }
4041
+ if (args.done_when || args.done_evidence) {
4042
+ if (!args.done_when || !args.done_evidence) {
4043
+ return '완료로 표시하려면 done_when(조건)과 done_evidence(그 조건이 실제로 충족됐다는 관측) 가 둘 다 있어야 한다 — 근거 없는 판정은 안 받는다. 지어내지 말고 스스로 확인한 것만 적을 것.';
4044
+ }
4045
+ const mine = mySessionId();
4046
+ const r = await api('POST', '/api/work/done', {
4047
+ tenant: args.tenant, work: wSlug, item,
4048
+ done_when: String(args.done_when), done_evidence: String(args.done_evidence),
4049
+ session: mine || '',
4050
+ });
4051
+ return `${r.item.key} — ${r.word}(으)로 표시했다.\n 조건: ${args.done_when}\n 근거: ${args.done_evidence}`;
4052
+ }
4053
+ // 아무 표시 인자도 없으면 조회 전용(부작용 없음) — 건드리기 전에 먼저 확인하는 자리.
4054
+ const r = await api('GET', '/api/work/done?tenant=' + encodeURIComponent(args.tenant)
4055
+ + '&work=' + encodeURIComponent(wSlug) + '&item=' + encodeURIComponent(item));
4056
+ const doneLine = r.state === 'done'
4057
+ ? `이미 끝난 항목이다 — 근거: ${r.done_evidence || '(비어 있음)'}. 다시 진행하지 말 것.`
4058
+ : '아직 완료가 아니다.';
4059
+ return `${r.item.key} — ${r.word}\n${doneLine}`;
4060
+ }
3990
4061
  if (args.claim) {
3991
4062
  // 착수 선언(088) — 세션 이름은 이 자리(로컬)가 안다. mail 도구의 그 관용구다.
3992
4063
  const mine = mySessionId();
3993
4064
  if (!mine) return '이 자리의 세션 이름을 못 찾아 선언할 수 없다 — 목록만 읽으려면 claim 없이 다시 부른다.';
3994
- const r = await api('POST', '/api/work/claim', {
4065
+ const body = {
3995
4066
  tenant: args.tenant, work: String(args.claim), session: mine,
3996
- });
3997
- return `«${r.work?.name || r.work?.slug}» (${r.work?.slug}) — ${r.says}`;
4067
+ };
4068
+ if (args.item) body.item = String(args.item);
4069
+ const r = await api('POST', '/api/work/claim', body);
4070
+ const target = r.item?.key ? `${r.item.key} @ «${r.work?.name || r.work?.slug}»` : `«${r.work?.name || r.work?.slug}» (${r.work?.slug})`;
4071
+ return `${target} — ${r.says}`;
3998
4072
  }
3999
4073
  const r = await api('GET', '/api/work/list?tenant=' + encodeURIComponent(args.tenant));
4000
4074
  const rows = r.works || [];
4001
4075
  const lines = [`${r.tenant} — 열린 일 ${r.open ?? rows.length}개` + ((r.closed ?? 0) > 0 ? ` (지난 일 ${r.closed}개)` : '')];
4076
+ // 차례를 사람이 정했으면 그 출처를 말한다 — 「…부터 해라」가 아니라 「…이 정한 차례다」(서술형).
4077
+ if (r.order_from?.title) lines.push(` 차례는 결정 «${r.order_from.title}» 이 정했다 — 위에서부터가 그 차례다.`);
4002
4078
  for (const w of rows) {
4003
4079
  lines.push(` · «${w.name || w.slug}» (${w.slug}) — ${w.word} · 세션 ${w.sessions}`
4080
+ // 항목 = 그 일을 가리키는 결정. 「무엇을 하기로 했나」가 몇 건인지. 0 이면 안 붙인다.
4081
+ // done 은 실행 축 집계(095) — 어느 항목인지는 안 말한다, work+item 으로 따로 확인.
4082
+ + (w.items?.total ? ` · 항목 ${w.items.total}(열림 ${w.items.open}${w.items.done ? `·완료 ${w.items.done}` : ''})` : '')
4004
4083
  + (w.stale ? ` · 낡음 ${w.stale}일째(AI 관측 — 활동이 멎었다: 사람이 닫거나 다시 살릴 일)` : '')
4005
4084
  + (w.doc ? ` · 요지: get_case(tenant, "${w.doc}")` : ' · 요지 문서 아직 없음'));
4006
4085
  }
4007
4086
  if (!rows.length) lines.push('열린 일이 없다 — 0건은 0건이다. 착수 전에 세우려면 그 과업의 결정을 remember_decision 으로 남기되 **work 에 일의 열쇠를 적어라** — 다음 바퀴에 「계획」으로 선다. 표지 없이 tags 만 달면 신호가 쌓일 때까지 AI 가 기다린다.');
4008
- else lines.push('붙을 일이 보이면 works(tenant, claim: "<slug>") 로 선언한다 — 확정은 사람이 화면에서 한다.');
4087
+ else lines.push('붙을 일이 보이면 works(tenant, claim: "<slug>") 로 선언한다 — 확정은 사람이 화면에서 한다.'
4088
+ + '\n그 일 안의 특정 결정 항목을 쥐었으면 works(tenant, claim: "<slug>", item: "decisions/<page>.md") 로 함께 선언한다.'
4089
+ + '\n항목은 그 일의 열쇠·갈래를 단 **결정**이다 — 무엇을 하기로 했는지는 지식의 결정 장에 있고, 새 항목은 remember_decision 으로 남긴다.'
4090
+ + '\n항목을 실제로 끝냈으면 works(tenant, work: "<slug>", item: "…", done_when: "…", done_evidence: "…") 로 직접 완료를 표시한다 — 사람이 화면에서 누르길 기다릴 필요 없다.');
4009
4091
  return lines.join('\n');
4010
4092
  }
4011
4093
  case 'list_breaks': {
@@ -4031,6 +4113,9 @@ async function callTool(name, args = {}) {
4031
4113
  who: args.who || '', when: args.when || '', why: args.why || '',
4032
4114
  reopen: args.reopen || '', status: args.status || '', tags: args.tags || [],
4033
4115
  work: args.work || '', order: args.order || [],
4116
+ // ★ 출처는 우리가 채운다 — 모델에게 안 묻는다(remember_case 와 같은 규율). 표지가
4117
+ // 열린 일과 만나 항목으로 설 때 「누가 심었나」(간트의 세션 자국)가 이것이다.
4118
+ ...myCitation(),
4034
4119
  });
4035
4120
  const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
4036
4121
  // 안 적은 것을 **그렇게 말한다** — 조용히 열린 결정을 만들거나 계산 밖에 두지 않는다.
@@ -4046,15 +4131,15 @@ async function callTool(name, args = {}) {
4046
4131
  } else if (r.insights_queued === false) {
4047
4132
  lines.push('전체 맥락 정리 줄은 안 섰다 — 재료가 아직 문턱 아래(배경 문서 3장 또는 결정·사례 5장)거나 이미 줄이 서 있다.');
4048
4133
  }
4049
- // 표지(work:)의 앞날 — 서버가 저장 시점에 판정한 것을 그대로 옮긴다(지어내지 않는다).
4050
- if (r.work_mark === 'will-open') {
4051
- lines.push(`다음 바퀴(1분 안)에 일 «${args.work}» 가 「계획」으로 섭니다 — works 로 확인한다.`);
4052
- } else if (r.work_mark === 'exists-open') {
4053
- lines.push(`일 «${args.work}» 는 이미 열려 있다 — 이 결정은 그 일의 재료가 된다.`);
4054
- } else if (r.work_mark === 'exists-closed') {
4055
- lines.push(`일 «${args.work}» 는 닫혀 있다 — 다시 열려면 사람이 화면에서 재개한다(표지로는 안 열린다).`);
4056
- } else if (r.work_mark === 'vetoed') {
4057
- lines.push(`일 «${args.work}» 는 전에 사람이 닫은 이름이라 안 선다 — 다른 이름을 쓰거나 화면에서 재개한다.`);
4134
+ // 표지(work:)의 앞날 — 문장의 정본은 서버(work_decl_mark_note → 응답 work_note)다.
4135
+ // 여기서 프로즈를 다시 지으면 두 벌이 되어 한쪽이 낡는다(2026-09-01 에 두 벌을 실제로
4136
+ // 걷었다 — mcp.php 와 이 파일이 같은 문장을 복제하고 있었다).
4137
+ if (typeof r.work_note === 'string' && r.work_note !== '') {
4138
+ lines.push(r.work_note);
4139
+ } else if (r.work_mark) {
4140
+ // 구서버(work_note 이전 판) — 판정값만 기계적으로 옮긴다. 안내문을 여기서 지어내면
4141
+ // 새 서버의 문장과 두 벌이 된다(위와 같은 병).
4142
+ lines.push(`일 표지 «${args.work}» 판정: ${r.work_mark} — 서버 판이 낡아 안내문이 없다(새 판은 문장을 함께 보낸다).`);
4058
4143
  } else if (args.work) {
4059
4144
  lines.push('표지(work)를 적었지만 이 서버는 아직 그 칸을 모른다 — 판이 낡았다.');
4060
4145
  }
@@ -4071,9 +4156,11 @@ async function callTool(name, args = {}) {
4071
4156
  });
4072
4157
  // 밀기 결과도 그대로 옮긴다 — 몇에게 갔고 누가 왜 못 받았는지(구서버는 필드가 없어 침묵).
4073
4158
  const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
4159
+ // ★ 창구가 낸 경고를 **맨 앞에** 옮긴다 — 쓰는 그 순간이 고치기 가장 싼 자리다(2026-09-01).
4160
+ if (r.warn) lines.push('⚠ ' + r.warn);
4074
4161
  if (Array.isArray(r.matched_paths)) {
4075
4162
  if (!r.matched_paths.length) {
4076
- lines.push('경로가 없어 아무에게도 안 갔다 — 근거에 저장소 상대 경로(예: www/portal/lib/x.php:12)를 적으면 그 파일을 읽은 채로 든 세션에게 간다.');
4163
+ lines.push('경로가 없어 아무에게도 안 갔다 — 그리고 **이 장이 어느 저장소의 일인지도 안 남았다**. 근거에 저장소 상대 경로(예: www/portal/lib/x.php:12)를 적으면 그 파일을 읽은 채로 든 세션에게 가고, 소속을 잘못 적었을 때 기계가 짚을 수 있다.');
4077
4164
  } else if (r.pushed > 0) {
4078
4165
  lines.push(`→ ${r.matched_paths.join(' · ')} 을(를) 들고 있던 세션 ${r.pushed}곳에 보냈다.`);
4079
4166
  } else {
@@ -4641,8 +4728,61 @@ function cellUnlinkOnPath() {
4641
4728
  return out;
4642
4729
  }
4643
4730
 
4731
+ /**
4732
+ * `src` 를 `dst` 자리에 **원자적으로** 놓는다 — 같은 디렉터리에 임시파일로 쓰고 `rename`.
4733
+ *
4734
+ * 왜 필요한가(2026-09-02 실측, 이 기기) — 세포가 30여 개 동시에 떠 있는 채로
4735
+ * `fs.copyFileSync(src, dst)` 를 직접 쓰면, `dst` 를 그 순간 `exec()` 하려던 다른 세션의
4736
+ * 훅이 **반쪽만 써진 바이너리**를 붙잡고 걸린다(`gcell --version` 이 107초 뒤 SIGKILL,
4737
+ * `sample` 로 보면 `_dyld_start` 조차 못 지나 죽는다). `copyFileSync` 는 목적지를 직접
4738
+ * 열어 쓰는 것이라 그 창이 열려 있다.
4739
+ *
4740
+ * POSIX `rename` 은 같은 파일시스템 안에서 원자적이다 — 이미 그 경로를 열어 실행 중인
4741
+ * 프로세스는 옛 inode 를 계속 보고(unlink-after-open 은 안전하다는 유닉스 불변식), 새로
4742
+ * 여는 프로세스만 새 파일을 본다. 반쪽짜리를 볼 수 있는 창이 사라진다.
4743
+ */
4744
+ function copyFileAtomic(src, dst) {
4745
+ const tmp = `${dst}.tmp-${process.pid}-${Date.now()}`;
4746
+ fs.copyFileSync(src, tmp);
4747
+ fs.chmodSync(tmp, 0o755);
4748
+ fs.renameSync(tmp, dst);
4749
+ }
4750
+
4751
+ /** `install-hooks --apply` 를 지정한 바이너리로 돌린다. 조용해야 할 때는 빈 문자열. */
4752
+ function cellInstallHooksVia(bin, ifRepo) {
4753
+ // `ifRepo` 는 **자동으로 부를 때만** 준다 — 사람이 `install_cell` 을 부른 자리는 자기가
4754
+ // 정한 곳이지만, 부팅마다 도는 걸음은 git 저장소가 아닌 폴더에 설정을 만들면 안 된다.
4755
+ const a = ['install-hooks', '--apply'];
4756
+ if (ifRepo) a.push('--if-repo');
4757
+ const r = spawnSync(bin, a, { encoding: 'utf8', timeout: 10_000 });
4758
+ // 세포의 `HOOKS_UNCHANGED` 와 **글자 그대로** 맞춘다 — smoke 가 두 파일을 대조한다.
4759
+ const so = r.stdout || '';
4760
+ const quiet = r.status === 0 && (so.includes('바뀐 것 없다.') || so.includes('git 저장소가 아니다'));
4761
+ if (quiet) return '';
4762
+ return r.status === 0 ? '훅을 걸었다(Claude 는 이 저장소, Cursor 는 ~/.cursor/hooks.json).'
4763
+ : `훅을 못 걸었다: ${cellClean(r.stderr || r.stdout || '이유 없음')}`;
4764
+ }
4765
+
4644
4766
  function cellInstall({ hooks = true, ifRepo = false } = {}) {
4645
4767
  const out = [];
4768
+ const forced = cfg.GENETICS_CELL_BIN;
4769
+
4770
+ // 사람이 손으로 지정한 자리(개발 기계) — **그 파일은 절대 열어 쓰지 않는다.**
4771
+ // 예전엔 이 분기가 없어 `ensureCellOnBoot` 의 주석("바이너리는 안 건드리되")과 실제
4772
+ // 동작이 어긋났다 — 지정해 놔도 아래 공식판 지문 대조·`copyFileSync` 를 그대로 타
4773
+ // 매 부팅마다 지정한 파일을 덮어썼다(2026-09-02 사고의 원인). 지정이 있으면 지문
4774
+ // 대조·CELL_PATH 자체를 안 본다 — 훅만 **그 바이너리 기준**으로 맞춘다.
4775
+ if (forced) {
4776
+ if (!fs.existsSync(forced)) {
4777
+ return { ok: false, out: [`지정한 세포가 없다: ${forced} — GENETICS_CELL_BIN 을 확인하라.`] };
4778
+ }
4779
+ if (hooks) {
4780
+ const msg = cellInstallHooksVia(forced, ifRepo);
4781
+ if (msg) out.push(msg);
4782
+ }
4783
+ return { ok: true, out };
4784
+ }
4785
+
4646
4786
  const t = cellTarget();
4647
4787
  if (!t) {
4648
4788
  // **없는 것을 있는 척하지 않는다.** Windows 는 아직 못 굽는다(cell/.cargo/config.toml).
@@ -4658,8 +4798,7 @@ function cellInstall({ hooks = true, ifRepo = false } = {}) {
4658
4798
  if (!ifRepo) out.push(`세포는 이미 이 판이다(${VERSION}).`); // 자동 걸음은 조용하다 — 매 부팅 말하면 그것이 소음이다
4659
4799
  } else {
4660
4800
  fs.mkdirSync(CELL_DIR, { recursive: true });
4661
- fs.copyFileSync(src, CELL_PATH);
4662
- fs.chmodSync(CELL_PATH, 0o755);
4801
+ copyFileAtomic(src, CELL_PATH);
4663
4802
  // **깐 뒤에 다시 잰다.** 복사가 반쯤 됐거나 남이 끼어들었으면 여기서 걸린다.
4664
4803
  const got = sha256File(CELL_PATH);
4665
4804
  if (got !== want) return { ok: false, out: ['세포를 깔았는데 지문이 안 맞는다 — 쓰지 않는다.'] };
@@ -4669,18 +4808,8 @@ function cellInstall({ hooks = true, ifRepo = false } = {}) {
4669
4808
  const linked = cellLinkOnPath();
4670
4809
  if (linked) out.push(linked);
4671
4810
  if (hooks) {
4672
- // `ifRepo` 는 **자동으로 부를 때만** 준다 — 사람이 `install_cell` 을 부른 자리는 자기가
4673
- // 정한 곳이지만, 부팅마다 도는 걸음은 git 저장소가 아닌 폴더에 설정을 만들면 안 된다.
4674
- const a = ['install-hooks', '--apply'];
4675
- if (ifRepo) a.push('--if-repo');
4676
- const r = spawnSync(CELL_PATH, a, { encoding: 'utf8', timeout: 10_000 });
4677
- // 세포의 `HOOKS_UNCHANGED` 와 **글자 그대로** 맞춘다 — smoke 가 두 파일을 대조한다.
4678
- const so = r.stdout || '';
4679
- const quiet = r.status === 0 && (so.includes('바뀐 것 없다.') || so.includes('git 저장소가 아니다'));
4680
- if (!quiet) {
4681
- out.push(r.status === 0 ? '훅을 걸었다(이 저장소의 .claude/settings.json).'
4682
- : `훅을 못 걸었다: ${cellClean(r.stderr || r.stdout || '이유 없음')}`);
4683
- }
4811
+ const msg = cellInstallHooksVia(CELL_PATH, ifRepo);
4812
+ if (msg) out.push(msg);
4684
4813
  }
4685
4814
  return { ok: true, out };
4686
4815
  }
@@ -4735,15 +4864,12 @@ function ensureCellOnBoot() {
4735
4864
  if (process.env.GENETICS_NO_CELL) return; // 자동 설치를 원치 않는 사람의 탈출구
4736
4865
  const hooks = !process.env.GENETICS_NO_HOOKS; // 훅만 따로 끄는 탈출구
4737
4866
  try {
4738
- // 사람이 손으로 지정한 자리(개발 기계) — **바이너리는 안 건드리되 훅은 맞춘다.**
4739
- // 그 자리를 쓰는 이유는 「내가 굽는 세포를 쓰겠다」이지 「설정을 낡게 두겠다」가 아니다.
4740
- if (cfg.GENETICS_CELL_BIN) {
4741
- if (hooks) cellInstall({ hooks: true, ifRepo: true }).out
4742
- .forEach((l) => process.stderr.write(`genetics: ${l}\n`));
4743
- return;
4744
- }
4745
- const t = cellTarget();
4746
- if (!t) return; // 그 플랫폼은 도구 응답이 말한다(cellNotice)
4867
+ // 지정(`GENETICS_CELL_BIN`)이 있는지, 이 플랫폼용 공식 세포가 있는지는 이제
4868
+ // `cellInstall` 하나가 안다 — 여기서 갈라 적으면(예전엔 그랬다) 그 갈래가
4869
+ // 서로 어긋날 수 있다(2026-09-02 사고: 여기 주석은 "안 건드린다"였는데
4870
+ // `cellInstall` 은 안 가려 지정한 파일을 덮어썼다). 지정도 없고 이 플랫폼용
4871
+ // 세포도 없으면 `cellTarget()` 이 걸러 `cellInstall` 이 조용히 실패로 답한다.
4872
+ if (!cfg.GENETICS_CELL_BIN && !cellTarget()) return; // 그 플랫폼은 도구 응답이 말한다(cellNotice)
4747
4873
  const { ok, out } = cellInstall({ hooks, ifRepo: true });
4748
4874
  if (!ok || out.length) {
4749
4875
  process.stderr.write(`genetics: ${ok ? '' : '세포 설치 실패 — '}${out.join(' ')}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.16.3",
3
+ "version": "2.16.6",
4
4
  "description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
5
5
  "author": "주식회사 LIM (LIM Inc.)",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -15,7 +15,11 @@ SENT_END = re.compile(
15
15
  r'(?<=[가-힣0-9\)\]」』”\'"A-Za-z])' # 앞: 한글·숫자·닫는 괄호/따옴표·라틴
16
16
  r'([.!?])'
17
17
  r'(\s+)'
18
- r'(?=[가-힣0-9「『※(\[]|[A-Z])' # 뒤: 다음 문장의 첫 글자 (숫자 허용)
18
+ r'(?=[가-힣0-9「『※(\[\u2460-\u24FF]|[A-Z])' # 뒤: 다음 문장의 첫 글자
19
+ # ↑ 원문자 번호(①②③…). 없으면 그 앞 경계가 안 잡혀 "…확인함. ①…" 가
20
+ # 한 블록으로 붙는다 — 다음 ②·③ 앞 경계까지 통째로 삼킨다(실측 2026-09-01,
21
+ # FISOFT #32 09절: 문장 6개가 4블록으로 뭉침). U+2460–24FF 는 원문자 숫자·
22
+ # 로마자·괄호숫자 전부를 담는 「둘러싼 영숫자」블록이라 개별로 안 늘려도 된다.
19
23
  )
20
24
  # ③ 머리글자 배제: 앞이 「공백 뒤 한 글자짜리 라틴」이면 문장 끝이 아니다 (A. B. 등)
21
25
  INITIAL = re.compile(r'(?:^|[\s(\[])[A-Za-z]$')
@@ -86,6 +90,10 @@ if __name__ == '__main__':
86
90
  ("전환 완료. 4건은 앱 작업만으로 종료. 3건은 서버 준비 대기.", 2),
87
91
  ("<b>표 3-2</b> 참조함. 판정 기준은 <a href='#x'>부록 2</a>.", 1),
88
92
  ("판정 기준은 부록 2. 자세한 것은 06절 참조.", 1),
93
+ # 원문자 열거 — 2026-09-01 실측. 고치기 전엔 여기서 2 를 못 얻고 0 이 나왔다
94
+ # (① 앞의 경계가 안 잡혀 두 문장이 붙었다).
95
+ ("세 가지를 확인함. ① 정상 성립함. ② 본문이 열림.", 2),
96
+ ("확인함.\n① 성립함.\n② 열림.\n③ 일치함.", 3),
89
97
  ]
90
98
  bad = 0
91
99
  for s, want in T:
@@ -52,6 +52,30 @@ export function analyze(raw0) {
52
52
  lines.push(`[S1] 경어체 잔존: ${polite.length}건 (0 이어야 함 — 개조식은 ~함 / ~했음 / ~필요)`);
53
53
  polite.slice(0, 12).forEach(h => lines.push(' ✗ ' + h.trim().slice(0, 80)));
54
54
 
55
+ // ── ①b 상대에게 내리는 명령 (S1) — ① 의 **반대쪽 벽** ────────
56
+ // ① 만 지키면 상대가 할 일까지 개조식으로 맺혀 명령형이 된다. 2026-09-02 에 그렇게
57
+ // 나갔고 고객이 「이거 너무 싸가지없는 거 아니야?」로 반려했다. 둘은 한 규칙의 두 면이라
58
+ // **붙여 둔다** — 갈라 두면 또 한쪽만 지켜진다.
59
+ //
60
+ // 좁게 잡는다(발행 문서 39건 전수로 깎은 자국) —
61
+ // · 동사는 **상대의 동작**만. 「반영 필요」·「재산출 필요」는 우리 할 일이라 뺀다.
62
+ // · 활용형(`필요함`·`필요하므로`)은 서술이라 뺀다 — 「자격이 필요함」의 주어는 사물이다.
63
+ // · 관형형(`필요한`)도 뺀다 — 「회신이 필요한 것은 4가지임」의 주어는 「것」이지 고객이 아니다.
64
+ // · 명사형 「~ 요청」은 공문 표준이라 아예 안 잡는다.
65
+ // ⚠ 여기는 렌더된 글만 보므로 **자리를 모른다.** 조치 본문의 「확인 필요」(우리 할 일)가
66
+ // 섞일 수 있다 — 자리까지 보는 판정은 서버의 `lib/report_tone.php` 가 한다.
67
+ const ORDER = /(?:회신|답신|확인|승인|결정|지정|선택|공유|제출|설치|입력|기재|작성|전달|조정|첨부)\s*(?:필요|요망)(?![함하성치])/g;
68
+ // ⚠ 전방탐색에 `」` 를 넣지 마라 — 「~해 주셔야 함」처럼 **인용**한 것까지 잡는다.
69
+ // 고친 표현을 이력에 적는 것이 불가능해진다(자기 시험이 잡아낸 오탐).
70
+ const DUTY = /(?:주셔야|하셔야|주시어야|하시어야)\s*(?:함|됨)(?=[\s.。]|$)/g;
71
+ const order = txt.match(ORDER) || [];
72
+ const duty = txt.match(DUTY) || [];
73
+ lines.push(`\n[S1] 상대에게 내리는 명령: ${order.length + duty.length}건 (0 이어야 함 — 상대가 할 일에는 높임 어간을 살린다)`);
74
+ [...duty, ...order].slice(0, 12).forEach(h => lines.push(' ✗ ' + h.trim()));
75
+ if (order.length + duty.length) {
76
+ lines.push(' → 「회신 필요」 → 「회신 주시면 됨」 · 「~해 주셔야 함」 → 명사형 「~ 요청」');
77
+ }
78
+
55
79
  // ── ②b-1 문체 유형 (S1 — 한 번만 나와도 고친다) ─────────────
56
80
  // 「조사」 단독은 제외한다 — 「조사 결과」는 정상 낱말이다. 잡는 것은 서술형이다.
57
81
  const STUDENT = /(사료됨|사료된다|고찰하|살펴보(?:았|고자)|알아보(?:았|고자)|에 대해(?:서)? 조사하|조사하였)/g;
@@ -157,10 +181,10 @@ export function analyze(raw0) {
157
181
  lines.push(`\n[S1] 이유 없는 부정 종결: ${negation.length}건 (0 이어야 함 — 조건 주어 + 원인·기준·재검토·선택지)`);
158
182
  negation.slice(0, 8).forEach(h => lines.push(' ✗ ' + h));
159
183
 
160
- const s1extra = student.length + buffer.length + negation.length;
161
- lines.push(`\n경어체 ${polite.length}건 · 이어 붙인 블록 ${runOn.length}개 · 밀도 초과 ${over}항 · 학생투 ${student.length} · 완충어 ${buffer.length} · 이유 없는 부정 종결 ${negation.length}`);
184
+ const s1extra = student.length + buffer.length + negation.length + order.length + duty.length;
185
+ lines.push(`\n경어체 ${polite.length}건 · 상대에게 내리는 명령 ${order.length + duty.length}건 · 이어 붙인 블록 ${runOn.length}개 · 밀도 초과 ${over}항 · 학생투 ${student.length} · 완충어 ${buffer.length} · 이유 없는 부정 종결 ${negation.length}`);
162
186
  const fail = Boolean(polite.length || over || runOn.length || s1extra);
163
- return { polite, runOn, over, student, buffer, negation, inlineEmph, textLen: txt.length, lines, fail };
187
+ return { polite, order, duty, runOn, over, student, buffer, negation, inlineEmph, textLen: txt.length, lines, fail };
164
188
  }
165
189
 
166
190
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -112,7 +112,32 @@ test('CLI 계약 — lines 에 요약줄이 서고 fail 이 종료코드의 근
112
112
  assert.equal(r.fail, true);
113
113
  });
114
114
 
115
+ test('상대에게 내리는 명령 — 개조식의 반대쪽 벽 (2026-09-02)', () => {
116
+ /* 사고: 「경어체 0건」만 지키다 상대가 할 일까지 개조식으로 맺혀 명령형이 됐다.
117
+ 고객 반려 — "이거 너무 싸가지없는 거 아니야?" */
118
+ assert.equal(pos(analyze(wrap('검수 결과는 별도 회신 필요.')).order.length), 1);
119
+ assert.equal(pos(analyze(wrap('앱을 설치해 확인해 주셔야 함.')).duty.length), 1);
120
+ assert.equal(analyze(wrap('검수 결과는 별도 회신 필요.')).fail, true);
121
+
122
+ /* 오탐 대조군 — 발행 문서 39건 전수로 깎은 자국. 전부 0 이어야 한다. */
123
+ // 주어가 사물인 「필요」는 서술이다.
124
+ assert.equal(analyze(wrap('조합 명의 개발자 계정의 자격이 필요함.')).order.length, 0);
125
+ // 명사형 「~ 요청」은 공문 표준이고, 이것이 바로 고친 결과다.
126
+ assert.equal(analyze(wrap('스토어 등록명 방침 결정 요청')).order.length, 0);
127
+ // 관형 명사구의 주어는 고객이 아니라 「것」이다.
128
+ assert.equal(analyze(wrap('회신이 필요한 것은 4가지임.')).order.length, 0);
129
+ // 우리 할 일은 상대에게 하는 말이 아니다.
130
+ assert.equal(analyze(wrap('남성 부분은 재산출 필요.')).order.length, 0);
131
+ // 조건절은 의무 부과가 아니라 의존 관계 설명이다.
132
+ assert.equal(analyze(wrap('한 분을 정해 주셔야 기록이 쌓임.')).duty.length, 0);
133
+ // 인용은 잡지 않는다 — 이력에 고친 표현을 적을 수 있어야 한다.
134
+ assert.equal(analyze(wrap('「~해 주셔야 함」처럼 적은 것을 고침.')).duty.length, 0);
135
+
136
+ // 요약줄에 서고 불합격 판정에도 들어간다 — 세기만 하면 없는 검사다.
137
+ assert.match(analyze(wrap('검수 결과는 별도 회신 필요.')).lines.at(-1), /상대에게 내리는 명령 1건/);
138
+ });
139
+
115
140
  test('양성 표본 수 — 0표본 통과 방지', () => {
116
141
  console.log(` 양성 표본 ${positives}건 (0 이면 이 시험은 아무것도 안 잰 것)`);
117
- assert.equal(positives >= 25, true, String(positives));
142
+ assert.equal(positives >= 28, true, String(positives));
118
143
  });
@@ -6,13 +6,13 @@ description: |
6
6
  다음과 같은 요청 시 사용하세요:
7
7
  - "너 세션번호가?" / "내 세션 id 뭐야" / "제네틱에서 나는 누구야"
8
8
  - "지금 이거 누가 만지고 있어?" / "이 파일 주인이 누구야" / "다른 창에서 뭐 하고 있어"
9
- - "옆 세션한테 물어봐" / "그쪽에 알려줘" (→ 이름을 먼저 알아내고 SendMessage)
9
+ - "옆 세션한테 물어봐" / "그쪽에 알려줘" (→ 이름을 먼저 알아내고 mail)
10
10
  - "내가 모르는 사이에 뭐 바뀌었어?" / "지금 알고 있는 게 아직 맞아?"
11
11
  그리고 사람이 안 시켜도 부를 때: **공유 트리에 커밋·배포하기 전**, 여러 세션이 도는
12
12
  저장소를 고치기 전, 그리고 「내 모델이 낡았나」가 의심될 때.
13
13
  [책임 경계] vs genetics-report: 저 스킬=고객에게 낼 문서, 이 스킬=지금 이 기계의 사람·세션 지형.
14
14
  [책임 경계] vs genetics-sync: 저 스킬=내 지식을 지금에 맞추는 절차(내 일·결정·바뀐 파일), 이 스킬=누가 어디 있나·나는 누구인가.
15
- version: "1.1.0"
15
+ version: "1.2.0"
16
16
  ---
17
17
 
18
18
  # 세포에게 신원을 묻는다 (genetics-cell-id)
@@ -84,8 +84,8 @@ PATH 에 있고 쓸 수 있는 첫 자리에 심링크). 전에는 안 걸어서
84
84
  · FCG-CX-Agent/frontend/src/shell/LlmStatusBadge.jsx
85
85
  ```
86
86
 
87
- 왼쪽의 `app-94` 가 **주소**입니다. 그대로 `SendMessage({to: "app-94", …})` 로 보냅니다.
88
- `ListAgents` 가 같은 이름을 보여주므로 둘을 교차 확인할 수 있습니다.
87
+ 왼쪽의 `app-94` 가 **주소**입니다. 그대로 `mail({to: "app-94", …})` 로 보냅니다.
88
+ 이름은 별칭이라 확실하면 세션 uuid 를 씁니다. `ListAgents` 는 **이 기계의 Claude Code 만** 보여 주므로, 목록에 없다고 해서 세션이 없는 것이 아닙니다 — 발견의 정본은 `agents` 입니다.
89
89
 
90
90
  ### 2. 이 파일의 주인이 누구인가 → `who --path`
91
91
 
@@ -145,22 +145,18 @@ ls -t ~/.claude/projects/<프로젝트-슬러그>/*.jsonl | head -1
145
145
 
146
146
  ## 말 걸기
147
147
 
148
- **상대가 어디 있느냐로 창구가 갈립니다.**
149
-
150
- | 상대 | 창구 |
151
- |---|---|
152
- | 이 기계의 옆 창 | `SendMessage` |
153
- | **다른 기계 · Codex 세션 · 두뇌** | **`mail` 도구** |
154
-
155
- `SendMessage` 는 **이 기계의 Claude Code 만 봅니다.** 다른 노트북에서 도는 세션은 이름을 알아도
156
- 닿지 않습니다 — 서버를 지나는 `mail` 이라야 넘어갑니다.
148
+ **말걸기의 정본은 `mail` 하나다.** 집계는 세션이고, 클라이언트가 Claude Code 인지 Cursor 인지
149
+ 창구를 가르지 않습니다. `SendMessage` 는 **양쪽이 이 기계의 Claude Code 이고 `ListAgents` 에
150
+ 있을 때만** 쓰는 임의 지름길입니다. 목록에 없다고 해서 안 닿는 것이 아닙니다.
157
151
 
158
152
  ```
159
- SendMessage({to: "app-2f", message: "…"}) # 같은 기계
160
- mail({tenant: "<소속처 slug>", to: "brain", body: "…"}) # 기계를 넘어서
153
+ mail({tenant: "<소속처 slug>", to: "app-2f", body: "…"}) # 정본 — 같은 기계·다른 기계·Cursor·Codex
154
+ mail({tenant: "<소속처 slug>", to: "brain", body: "…"}) # 두뇌
155
+ SendMessage({to: "app-2f", message: "…"}) # 지름길 — 이 기계 Claude Code 끼리만
161
156
  ```
162
157
 
163
- 받은 것에 답할 때 `SendMessage` 는 그 메시지의 `from` 을 그대로 `to` 에 쓰고, `mail` 은 `thread` 를 씁니다.
158
+ 받은 것에 답할 때 `mail` 은 `thread` 를 쓰고, SendMessage 지름길을 탔으면 그 메시지의 `from` 을
159
+ 그대로 `to` 에 씁니다.
164
160
 
165
161
  `mail` 의 `to` 에 올 수 있는 것은 **셋뿐**입니다. **지어내지 마세요** — 셋 중 하나가 아니면
166
162
  사용자에게 묻습니다. 소속처 값(slug — 이름도 받는다)을 모르면 `list_my_tenants` 로 먼저 확인합니다.
@@ -194,7 +190,7 @@ mail({tenant: "<소속처 slug>", to: "brain", body: "…"}) # 기계를 넘
194
190
 
195
191
  1. `agents --path <내가 만질 경로>` — 지금 그 자리에 누가 있나
196
192
  2. `git status` — **인덱스에 남의 스테이징이 있나**
197
- 3. 있으면 커밋하지 말고 그 세포에게 물어봅니다(`SendMessage`)
193
+ 3. 있으면 커밋하지 말고 그 세포에게 물어봅니다(`mail`)
198
194
  4. 내가 만든 것만 골라 커밋합니다
199
195
 
200
196
  「mtime 이 최근이니 내 것이겠지」는 근거가 아닙니다. 세포에게 물으면 **누가 언제 썼는지**가
@@ -212,7 +208,8 @@ HEAD 사본 검사)은 `genetics-sync` 스킬이 정본입니다.
212
208
  | 저장소 판정이 현재 폴더 기준 | 다른 저장소를 보려면 `--root <경로>` |
213
209
  | `who` 의 짧은 id 로는 말을 못 건다 | 주소는 `agents` 의 이름이다 |
214
210
  | 세포는 이 기계만 본다 | 다른 기계를 **보는** 것은 `world`, **말 거는** 것은 `mail` |
215
- | `SendMessage` 로 다른 기계에 보냈다 | **안 간다.** 그건 이 기계 전용이다 — `mail` 을 쓴다 |
211
+ | `ListAgents` 에 없어서 안 닿는다 했다 | 그 목록은 Claude Code 만 본다. 발견은 `agents`, 말걸기는 `mail` |
212
+ | `SendMessage` 로 Cursor·다른 기계에 보냈다 | **안 간다.** 지름길은 이 기계 Claude Code 끼리만 — `mail` 을 쓴다 |
216
213
  | 왕복이 길어진다 | 상한이 있다 — **사람이 낀 실 5회, 기계끼리 3회.** 넘으면 사람에게 올라간다 |
217
214
  | 이름으로 부쳤는데 딴 세션이 받았다 | 같은 이름의 **산 세션**이 있다. 이름은 별칭이니 **uuid** 로 부친다 |
218
215
  | 보냈는데 답이 없다 | 받는 쪽 꼬리는 **그쪽이 도구를 불러야** 붙는다 — 폴 60초 + 그 간격이다(실측 47·39초) |