genetics-mcp 2.28.0 → 2.29.1

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.
Files changed (30) hide show
  1. package/genetics.mjs +133 -449
  2. package/package.json +4 -5
  3. package/skills/genetics-cell-id/SKILL.md +4 -5
  4. package/skills/genetics-report/SKILL.md +58 -38
  5. package/skills/genetics-report/assets/example/cases-briefing-designs.json +11 -11
  6. package/skills/genetics-report/assets/example/cases-briefing.json +3 -3
  7. package/skills/genetics-report/assets/example/cases.json +3 -3
  8. package/skills/genetics-report/assets/example/decision.json +40 -42
  9. package/skills/genetics-report/assets/example/report-briefing-designs.json +61 -61
  10. package/skills/genetics-report/assets/example/report-briefing.json +54 -54
  11. package/skills/genetics-report/assets/example/report.json +34 -45
  12. package/skills/genetics-report/assets/render.py +4 -6
  13. package/skills/genetics-report/assets/subset_font.py +1 -1
  14. package/skills/genetics-report/references/content-pipeline.md +1 -1
  15. package/skills/genetics-report/references/decision-format.md +87 -22
  16. package/skills/genetics-report/references/font-embedding.md +1 -1
  17. package/skills/genetics-report/references/history.md +29 -5
  18. package/skills/genetics-report/references/lim-portal-format.md +47 -24
  19. package/skills/genetics-report/references/process.md +26 -25
  20. package/skills/genetics-report/references/readability.md +5 -5
  21. package/skills/genetics-report/references/visual-first.md +11 -74
  22. package/bin/aarch64-apple-darwin/gcell +0 -0
  23. package/bin/aarch64-unknown-linux-musl/gcell +0 -0
  24. package/bin/manifest.json +0 -7
  25. package/bin/x86_64-apple-darwin/gcell +0 -0
  26. package/bin/x86_64-pc-windows-gnu/gcell.exe +0 -0
  27. package/bin/x86_64-unknown-linux-musl/gcell +0 -0
  28. package/skills/genetics-report/references/edition-review.workflow.js +0 -196
  29. package/skills/genetics-report/references/linear-read-test.workflow.js +0 -106
  30. package/skills/genetics-report/references/reader-review.workflow.js +0 -404
package/genetics.mjs CHANGED
@@ -1325,9 +1325,11 @@ const DEC_SENTENCE = {
1325
1325
  { type: 'object', properties: { empty: { type: 'string', enum: ['na', 'undecided', 'elsewhere', 'unverified'] } }, required: ['empty'] },
1326
1326
  ],
1327
1327
  };
1328
+ // 주체 값 — sender(보내는 쪽)가 새 이름이다(2026-10-15 — 계약 열쇠에 운영사 이름을 박지 않는다). 옛 값 fcg 는 되읽은 옛 원고가 그대로
1329
+ // 돌아와도 받히게 무늬에 남긴다(서버 report_decision_who · 렌더러 who_text 가 sender 로 맞춘다). 가르치는 말은 sender 뿐이다(DECISION_BRIEF 설명).
1328
1330
  const DEC_WHO = {
1329
1331
  oneOf: [
1330
- { type: 'string', pattern: '^(customer|fcg|party:[a-z0-9-]{1,32})$' },
1332
+ { type: 'string', pattern: '^(customer|sender|fcg|party:[a-z0-9-]{1,32})$' },
1331
1333
  DEC_SENTENCE.oneOf[1],
1332
1334
  ],
1333
1335
  };
@@ -1402,13 +1404,34 @@ const DECISION_BRIEF = {
1402
1404
  description: '결정 블록 — decisions(정할 것)가 하나 이상이면 문서가 결정 모드로 선다(서식은 lim-report 하나 · template 은 안 적어도 된다). '
1403
1405
  + '결정이 없으면 이 칸을 빼고 보고로 낸다(decisions 가 빈 decision_brief 는 거절). '
1404
1406
  + '필수(거절)는 뼈대뿐 — decisions[].name · question · options(둘 이상)[].name · line, 공개 때 reply_to.who. '
1405
- + '권함(없어도 거절 아님 — 렌더러가 ⚠): due · now · 결정에 닿는 facts · no_reply. 선택(쓰면 서고 안 쓰면 그리지 않음): options[].rows 전부 · cost · '
1407
+ + '권함(없어도 거절 아님 — 렌더러가 ⚠): due · now · 결정에 닿는 facts · no_reply(갈래 {kind, text}) · 의견이 있으면 opinion_pick. '
1408
+ + '기호(= 지금 그대로인 길 · ◎ 권하는 길 · ‖ 회신이 없으면 다시 여쭘)와 회신 쪽지는 칸 셋(options[].is_current · opinion_pick · no_reply.kind)만 읽는다 — 글에서 짐작하지 않는다. '
1409
+ + '거시 뼈대(권함 — 없으면 ⚠): frame(받는 쪽이 이미 가진 뼈대 — 보낸 문서의 장 · 회의 안건 · 문의의 갈래)과 결정마다 from(그 결정을 낳은 자리와 말). '
1410
+ + '뼈대가 있으면 그 차례로 결정 장이 서고, 첫 종이에 개념 뷰(자리 | 그 자리의 말 | 정하실 것) · 다음 종이에 정하는 차례가 선다. '
1411
+ + '선택(쓰면 서고 안 쓰면 그리지 않음): options[].rows 전부 · cost · '
1406
1412
  + 'opinion · confirms · risks · unknowns · names 와 그 밖의 칸. 대칭 — 한 선택지에 쓴 행·돈은 모든 선택지가 쓴다. '
1407
1413
  + '문장 칸은 글자, 또는 비우는 까닭을 밝히는 빈 값 {empty, …}: na+why(해당 없음) · undecided+until(아직 안 정해짐 — 정해지는 때) · '
1408
1414
  + 'elsewhere+who+when(이 문서에 없음 — 누가 언제 정하나) · unverified+fill(확인하지 못함 — 채울 수단). 「없음」「-」 같은 맨 글자는 거절된다. '
1409
- + '누가 칸(by·who·payer·to)은 customer(받는 쪽, 화면에는 client) · fcg(보내는 쪽, 화면 말은 labels.dc_who_fcg) · party:<names 의 id>(그 밖의 쪽), 또는 빈 값. '
1415
+ + '누가 칸(by·who·payer·to)은 customer(받는 쪽, 화면에는 client) · sender(보내는 쪽 — 화면 말은 labels.dc_who_sender, 없으면 issuer 첫 칸, 그도 없으면 「당사」. 보내는 쪽 이름은 issuer 첫 칸 한 곳에 적고 dc_who_sender 에 다른 이름을 넣지 않는다 — 넣으면 한 문서에 두 이름이 선다) · party:<names 의 id>(그 밖의 쪽), 또는 빈 값. '
1416
+ + '옛 이름(주체 fcg · 행 fcg_does · 라벨 dc_who_fcg · dc_row_fcg_does)도 받는다 — 새로 쓰는 원고는 새 이름으로. '
1410
1417
  + 'id 는 영소문자·숫자·하이픈 1~32자, 문서 전체가 한 이름 공간이다. 글 안에서 {{결정:id}}·{{선택:id}}·{{사실:id}}·{{확인:id}}·{{위험:id}}·{{이름:id}} 로 다른 물건을 부른다.',
1411
1418
  properties: {
1419
+ frame: {
1420
+ type: 'object',
1421
+ description: '받는 쪽이 이미 가진 뼈대 — 문서가 이 뼈대를 그대로 진다(합의 착각을 막는 동형). 고객이 보낸 문서라면 그 장 차례, 회의라면 안건 차례, '
1422
+ + '문의라면 갈래. 우리가 묶은 차례(원고 id · 확인 묶음)가 아니라 받는 쪽의 차례로 적는다. 쓰면 name · parts 필수.',
1423
+ properties: {
1424
+ name: { type: 'string', minLength: 1, description: '받는 쪽이 부르는 뼈대 이름(「0910 문서」「10. 2. 회의」) — 개념 뷰 머리 · 출처 줄에 그대로 찍힌다.' },
1425
+ title: { type: 'string', description: '그 뼈대의 원래 제목(선택 — 개념 뷰 첫 줄).' },
1426
+ parts: {
1427
+ type: 'array', minItems: 1, maxItems: 20,
1428
+ description: '뼈대 차례 — 받는 쪽의 차례 그대로. 결정 장이 이 차례로 선다.',
1429
+ items: { type: 'object', properties: { id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' }, label: { type: 'string', minLength: 1, description: '자리 이름(「6장」「안건 2」)' } },
1430
+ required: ['id', 'label'] },
1431
+ },
1432
+ },
1433
+ required: ['name', 'parts'],
1434
+ },
1412
1435
  scope: { type: 'string', description: '점검 범위 한 문장 — 무엇을, 어느 계정·환경에서, 언제(선택).' },
1413
1436
  reply_to: {
1414
1437
  type: 'object',
@@ -1424,7 +1447,7 @@ const DECISION_BRIEF = {
1424
1447
  id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
1425
1448
  uid: { type: 'string', pattern: '^[a-z0-9]{6}$', description: '서버가 발급하는 불변 이름. get_report 가 준 값은 그대로 되돌려 보낸다.' },
1426
1449
  name: { type: 'string', description: '짧은 이름 — 첫 화면·장 머리·회신에 같은 글자로 선다.' },
1427
- question: { ...DEC_SENTENCE, description: '물음 하나(25자 안팎). 원고에 물음이 없으면 빈 값.' },
1450
+ question: { ...DEC_SENTENCE, description: '정할 것 하나(25자 안팎) — 명사형으로: 가부면 「~ 여부」, 고르는 것이면 「~ 기준」. 「~할까?」 같은 물음 꼴은 쓰지 않는다. 원고에 물음이 없으면 빈 값.' },
1428
1451
  due: { ...DEC_DUE, description: '결정 기한(권함 — 없으면 거절 대신 렌더러가 ⚠). ' + DEC_DUE.description },
1429
1452
  now: { ...DEC_PROSE, description: '지금 상태 — 대상과 문제를 쉬운 말로(권함). ' + DEC_PROSE.description },
1430
1453
  figures: {
@@ -1460,14 +1483,14 @@ const DECISION_BRIEF = {
1460
1483
  name: { type: 'string', description: '선택지 이름 — 표 열 머리·첫 화면·회신 말에 같은 글자로.' },
1461
1484
  line: { ...DEC_SENTENCE, description: '고르면 무엇이 달라지나 한 줄(35자 안팎). 없으면 빈 값.' },
1462
1485
  what: { ...DEC_PROSE, description: '실제로 하는 일 — 끝나거나 풀리는 조건까지(선택). ' + DEC_PROSE.description },
1463
- is_current: { type: 'boolean', description: '지금 상태를 지키는 선택지 — 결정마다 많아야 하나, 늘 첫째로 선다.' },
1486
+ is_current: { type: 'boolean', description: '지금 상태를 지키는 선택지 — 결정마다 많아야 하나, 늘 첫째로 선다. 기호 「=」 · 「지금 그대로인 길」 표지가 이 칸을 읽는다.' },
1464
1487
  rows: {
1465
1488
  type: 'object',
1466
1489
  description: '대칭 표의 값(선택 — 쓰면 행이 서고 안 쓰면 그 행이 없다). 한 선택지에 쓴 행은 모든 선택지가 문장이나 빈 값으로 채운다. 이 밖의 키는 거절.',
1467
1490
  properties: {
1468
1491
  for_users: { ...DEC_SENTENCE, description: '이용자가 겪는 일.' },
1469
1492
  client_does: { ...DEC_TEXT_WHO, description: '고객 쪽이 하실 일 — 요청 자리, text 는 하십시오체 요청.' },
1470
- fcg_does: { ...DEC_SENTENCE, description: '보내는 쪽이 할 일 — 화면 행 이름은 원고 issuer 첫 이름으로 「{발행처} 가 할 일」(labels.dc_row_fcg_does 로 덮음).' },
1493
+ sender_does: { ...DEC_SENTENCE, description: '보내는 쪽이 할 일 — 화면 행 이름은 「당사가 할 일」(본문은 발행처 1인칭 — 보내는 쪽 이름 issuer 첫 칸 → labels.dc_who_sender 는 표지 「보낸 곳」에만 선다 · labels.dc_row_sender_does 로 덮음). 글도 발행처를 「당사」로 부른다 — 이름을 3인칭으로 쓰면 발행이 거절된다.' },
1471
1494
  risk_left: { ...DEC_SENTENCE, description: '남는 위험.' },
1472
1495
  settles_today: { ...DEC_SENTENCE, description: '그날 정해지는 것.' },
1473
1496
  others_do: { ...DEC_TEXT_WHO, description: '그 밖의 쪽이 할 일.' },
@@ -1498,7 +1521,14 @@ const DECISION_BRIEF = {
1498
1521
  },
1499
1522
  },
1500
1523
  no_current_why: { ...DEC_SENTENCE, description: '그대로 두는 길(is_current)이 선택지에 없을 때 그 까닭.' },
1501
- no_reply: { ...DEC_SENTENCE, description: '기한까지 회신이 없으면 무엇이 그대로이고 누가 무엇을 하나(권함).' },
1524
+ no_reply: {
1525
+ oneOf: [
1526
+ ...DEC_SENTENCE.oneOf,
1527
+ { type: 'object', properties: { kind: { type: 'string', enum: ['keep', 'hold', 'part'] }, text: DEC_SENTENCE }, required: ['kind', 'text'] },
1528
+ ],
1529
+ description: '기한까지 회신이 없으면(권함) — {kind, text}. kind 는 keep(지금 그대로 둠) · hold(시작하지 않고 다시 여쭘) · part(공통 부분만 짓고 다시 여쭘), '
1530
+ + 'text 는 무엇이 그대로이고 누가 무엇을 하는지 한 문장. 문장만 써도 받는다 — 기호 한 줄에서 갈래만 빠진다(⚠).',
1531
+ },
1502
1532
  facts_lead: { ...DEC_PROSE, description: '이 결정에 닿는 사실이 둘 이상이면 — 사실들이 선택지를 어떻게 가르는지 잇는 글. ' + DEC_PROSE.description },
1503
1533
  reply: {
1504
1534
  type: 'object',
@@ -1506,6 +1536,23 @@ const DECISION_BRIEF = {
1506
1536
  properties: { attach: { type: 'array', items: { type: 'string' } }, conditions: { type: 'boolean' } },
1507
1537
  },
1508
1538
  opinion: { ...DEC_PROSE, description: '보내는 쪽 의견 — 판단은 이 칸에만 둔다(선택 — 의견이 없으면 칸을 뺀다). ' + DEC_PROSE.description },
1539
+ from: {
1540
+ oneOf: [
1541
+ {
1542
+ type: 'array', minItems: 1, maxItems: 6,
1543
+ items: { type: 'object', properties: { part: { type: 'string', pattern: '^[a-z0-9-]{1,32}$', description: 'frame.parts 의 자리 id — 자리를 모르면 뺀다' },
1544
+ said: DEC_SENTENCE }, required: ['said'] },
1545
+ },
1546
+ DEC_SENTENCE.oneOf[1],
1547
+ ],
1548
+ description: '이 결정을 낳은 뼈대의 말(frame 이 있을 때 — 권함) — [{part, said}]: 자리 id 와 그 자리에서 받는 쪽이 한 말 한 문장. '
1549
+ + '뼈대 밖에서 생긴 물음(구현하다 생김)이면 빈 값 {"empty": "na", "why": 까닭} — 개념 뷰가 따로 묶어 보인다(합의 착각이 숨는 자리).',
1550
+ },
1551
+ opinion_pick: {
1552
+ type: 'string', pattern: '^[a-z0-9-]{1,32}$',
1553
+ description: '의견이 권하는 선택지의 id — 기호 「◎」 · 선택지 표지 · 회신 쪽지가 이 칸만 읽는다(의견 글에서 짐작하지 않는다). '
1554
+ + '쓰면 그 결정의 선택지 id 여야 하고 의견(opinion)이 함께 있어야 한다. 의견이 한쪽을 고르지 않으면 뺀다.',
1555
+ },
1509
1556
  },
1510
1557
  required: ['id', 'name', 'question', 'options'],
1511
1558
  },
@@ -1518,7 +1565,7 @@ const DECISION_BRIEF = {
1518
1565
  properties: {
1519
1566
  id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
1520
1567
  uid: { type: 'string', pattern: '^[a-z0-9]{6}$', description: '서버가 발급하는 불변 이름. get_report 가 준 값은 그대로 되돌려 보낸다.' },
1521
- name: { type: 'string', description: '무엇을 확인하나 — 물음 꼴(「…가 되는가」).' },
1568
+ name: { type: 'string', description: '무엇을 확인하나 — 명사형(「…가 되는지 여부」). 「…가 되는가」 같은 물음 꼴은 쓰지 않는다.' },
1522
1569
  to: { ...DEC_WHO, description: '누구에게 묻나·누가 하나.' },
1523
1570
  due: DEC_DUE,
1524
1571
  relation: { type: 'string', enum: ['sways', 'after'], description: 'sways(결과가 결정을 바꿀 수 있음) · after(결정 뒤 이행).' },
@@ -1665,7 +1712,7 @@ const REPORT_DATA = {
1665
1712
  description: '표지 대제목 **둘째 행** — 회차·부제. "1차 보고" 처럼 **짧게**. '
1666
1713
  + '여기는 42px 표제 자리라 문장을 넣으면 조판이 무너진다. 한 문단 요약은 subtitle 로.',
1667
1714
  },
1668
- kicker: { type: 'string', description: '표지 윗줄 (예: "회사명 · 프로젝트")' },
1715
+ kicker: { type: 'string', description: '표지 윗줄 (예: "프로젝트 · 1차") — 회사 이름 · 약호를 지어 넣지 않는다(보내는 쪽은 issuer 첫 칸).' },
1669
1716
  subtitle: { type: 'string', description: '표지 한 문단 요약 — 대제목 **아래**에 앉는다' },
1670
1717
  intro: {
1671
1718
  type: 'string',
@@ -1688,7 +1735,7 @@ const REPORT_DATA = {
1688
1735
  + '「먼저 볼 것」을 한두 줄로 말한다. 없으면 강조문 없이 항목만 늘어선다. 결정 모드에서는 받지 않는다(거절).',
1689
1736
  },
1690
1737
  description: { type: 'string' },
1691
- doc_no: { type: 'string', description: '문서번호. 표지 머리 왼쪽 박스 (예: "LIM-2026-0824-01")' },
1738
+ doc_no: { type: 'string', description: '문서번호. 표지 머리 왼쪽 박스 (예: "DOC-2026-0824-01") — 사용자가 쓰는 번호 체계가 있을 때만 적는다(회사 약호를 지어 넣지 않는다).' },
1692
1739
  client: {
1693
1740
  type: 'string',
1694
1741
  description: '고객사명. 표지 머리 **오른쪽**은 고객사 마크의 자리다 — 로고 파일을 '
@@ -1697,7 +1744,11 @@ const REPORT_DATA = {
1697
1744
  },
1698
1745
  issuer: {
1699
1746
  type: 'array',
1700
- description: '표지 맨 아래 발행처 스트립 ["발행처", "대상", "2026. 8."] — 마지막 칸이 날짜다.',
1747
+ description: '표지 맨 아래 발행처 스트립 ["발행처", "대상", "2026. 8."] — 마지막 칸이 날짜다. '
1748
+ + '첫 칸(발행처 · 결정 모드의 보낸 곳)은 whoami 가 보이는 운영사 이름이다 — 대화 맥락에서 회사 이름을 짐작해 적지 않는다. '
1749
+ + 'whoami 에 운영사 줄이 없거나(키 자격 · 구성원 · 옛 서버) 「운영사 이름 미설정」이면 사용자에게 한 번 묻고, '
1750
+ + '「전체 관리자가 설정 › 보고서 브랜드 › 운영사 이름에 넣으면 다음부터 whoami 에 섭니다」라고 사용자에게 알린다. '
1751
+ + '이름을 모르면 첫 칸을 빈 글자 "" 로 두고 칸을 빼지 않는다 — 빼면 대상(받는 쪽)이 첫 칸으로 당겨져 보내는 쪽으로 선다.',
1701
1752
  },
1702
1753
  meta: { type: 'array', description: '표지 메타 [[라벨, 값], ...] · 4칸 권장' },
1703
1754
  stats: {
@@ -1749,7 +1800,7 @@ const REPORT_DATA = {
1749
1800
  colophon: {
1750
1801
  type: 'object',
1751
1802
  description: '판권지 {title?, sub?, rows?, mark?, badge?, note?, tail?} — 표지와 같은 문법으로 '
1752
- + '문서를 닫는다. badge 는 「내부 공유용」 같은 표시.',
1803
+ + '문서를 닫는다. badge 는 「내부 공유용」 같은 표시. 회사 이름 · 약호를 지어 넣지 않는다(작성처는 issuer 첫 칸).',
1753
1804
  },
1754
1805
  remain: { type: 'string', description: '머리띠 우측 판독부의 수치(요청 잔여 건수). 생략하면 open_items 개수.' },
1755
1806
  running_footer: { type: 'string', description: '인쇄 러닝 푸터의 뒷말. 생략하면 client.' },
@@ -1770,11 +1821,30 @@ const REPORT_DATA = {
1770
1821
  + '덮을 수 있는 키: apx · as_of · ask · bar_alt · basis · client · col_grade · col_state · '
1771
1822
  + 'col_title · cta · doc_no · go · guide · hist · hist_date · hist_no · hist_note · marg · '
1772
1823
  + 'nograde · open · pt_ask · refs · remain · remain_unit · source · source_row · sum · toc · urgent.\n'
1824
+ + '거시 뼈대(frame)를 쓴 문서의 글자: frame_head(머리말) · frame_k(표 앞 줄의 첫 문장) · frame_cap(표 캡션) · frame_hint · frame_col(첫 칸 머리) · '
1825
+ + 'frame_ask_src(하실 일 출처 줄) · frame_outside_s(「{뼈대} 밖」).\n'
1773
1826
  + '없는 키를 주면 조용히 무시된다 — 오타가 안 걸리니 위 목록에서 골라 써라.\n'
1774
1827
  + '결정 모드의 고정 문구 키(dc_…)는 skills/genetics-report/references/decision-format.md 의 「바꿀 수 있는 글자」 표.',
1775
1828
  },
1776
1829
  grades: { type: 'object', description: '확인 등급 정의 {이름: [클래스, 설명]}' },
1777
- groups: { type: 'array', description: '건 묶음 [{key, no}]' },
1830
+ groups: {
1831
+ type: 'array',
1832
+ description: '건 묶음 [{key, no, name, outside?}] — 보고 모드의 묶음은 받는 쪽 뼈대(frame)의 항목이다(「QA 자료 3쪽 ①」). '
1833
+ + 'outside: 뼈대 밖 묶음의 까닭 한 줄(「검수하며 함께 찾아 고친 것」) — frame 이 있을 때만, 뼈대 안 묶음이면 뺀다.',
1834
+ },
1835
+ frame: {
1836
+ type: 'object',
1837
+ description: '보고 모드의 거시 뼈대(권함) {name, title?} — 받는 쪽이 이미 가진 뼈대(보낸 QA 자료 · 접수하신 건)의 이름. '
1838
+ + '쓰면 요약 종이에 「{name}와 한 일」 뼈대 표(묶음 차례 그대로 → 건 · 확인 방법 · 상세 — 칸은 렌더러의 칸 규칙 표가 고른다)가 서고, '
1839
+ + '한눈에 절(kind status)의 지도 표는 그리로 옮겨지므로 새 원고에는 status 절을 두지 않는다(그 절의 표 · 그림은 알맞은 절에). 하실 일은 refs(근거 건 「11.1」)로 그 일이 나온 뼈대의 항목에 잇는다. '
1840
+ + '결정 모드의 뼈대는 decision_brief.frame 이다(최상위 frame 은 결정 모드에서 거절).',
1841
+ properties: {
1842
+ name: { type: 'string', description: '받는 쪽이 부르는 뼈대 이름(「QA 자료」) — 머리말 「QA 자료와 한 일」' },
1843
+ title: { type: 'string', description: '원래 제목(「QA 자료 1~9쪽」) — 표 캡션 「QA 자료 1~9쪽과 한 일」. 없으면 name' },
1844
+ },
1845
+ required: ['name'],
1846
+ additionalProperties: false,
1847
+ },
1778
1848
  sections: {
1779
1849
  type: 'array',
1780
1850
  description: '절 구성 [{no, id, name, kind, group?, chapter?, principles?, logic?}] — 보고 모드는 절 구성이 본체다. '
@@ -1936,7 +2006,10 @@ const REPORT_DATA = {
1936
2006
  + '상대의 판단이 필요한 것만. `priority: "must"` 인 항목은 「가장 시급한 요청」으로 '
1937
2007
  + '따로 서고 나머지와 괘선으로 갈린다 — **먼저 정해져야 하는 것에만** 붙여라. '
1938
2008
  + '전부에 붙이면 갈림이 사라져 안 붙인 것과 같아진다. '
1939
- + '`refs` 는 근거 사례 번호이고 **첫 하나만** 「근거」 단추가 된다. 결정 모드에서는 받지 않는다(거절).',
2009
+ + '`refs` 는 근거 사례 번호이고 **첫 하나만** 「근거」 단추가 된다. 결정 모드에서는 받지 않는다(거절). '
2010
+ + '**회신 꼴을 밝히면 결정 단위로 선다**(관보 v4): `kind`(pick 하나 고르기 · review 표 훑고 이의) · `due`(기한 글자) · '
2011
+ + '`table`(이 절 tables[] 의 자리, 0부터 — 그 표가 항 안으로 옮겨 선다) · `options[{name, is_current}]`(pick) · '
2012
+ + '`no_reply{kind: keep|hold|part, text}` · `reply`(회신 문장 하나, 하십시오체) · `body`(남은 풀이). 칸 뜻은 lim-portal-format.md `open_items[]`.',
1940
2013
  },
1941
2014
  footer: { type: 'array' },
1942
2015
  assets: {
@@ -6236,7 +6309,8 @@ const TOOLS = [
6236
6309
  },
6237
6310
  {
6238
6311
  name: 'whoami',
6239
- description: '지금 무엇으로 붙어 있는지 — 사람 로그인인지 소속처 키인지, 사람이면 누구인지.',
6312
+ description: '지금 무엇으로 붙어 있는지 — 사람 로그인인지 소속처 키인지, 사람이면 누구인지. '
6313
+ + '우리 팀(관리자 · 편집자) 로그인이면 이 포털의 운영사 이름(보고서 발행처 — issuer 첫 칸 · 결정 문서 보낸 곳에 적는 이름)도 함께(구성원 · 키 자격에는 없다).',
6240
6314
  inputSchema: { type: 'object', properties: {} },
6241
6315
  },
6242
6316
  {
@@ -6373,7 +6447,7 @@ const TOOLS = [
6373
6447
  'HTML 을 만들지 말 것 — 내용만 주면 서버가 렌더한다.\n' +
6374
6448
  '**기본은 초안이다** — 만들어도 고객에게는 보이지 않는다. 눈으로 확인한 뒤 ' +
6375
6449
  'release_report 로 열어 준다. 바로 열려면 publish: true 를 명시할 것.\n' +
6376
- '**공개 승인**(publish: true · release_report)을 사용자에게 물을 때는 그 물음과 답에 이 판에서 건너뛴 단계(0단계 사용자 확인·독자 검토·열람 확인 등)와 증거 없는 관찰 주장 수(렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」 — 포털 dry_run 에 이 경고가 아직 없으면 스킬의 로컬 render.py 로 잰 곳 수)를 싣는다 — 「간단히」는 분량 지시이지 단계 면제가 아니다.\n' +
6450
+ '**공개 승인**(publish: true · release_report)을 사용자에게 물을 때는 그 물음과 답에 이 판에서 건너뛴 단계(0단계 사용자 확인·열람 확인 등)와 증거 없는 관찰 주장 수(렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」 — 포털 dry_run 에 이 경고가 아직 없으면 스킬의 로컬 render.py 로 잰 곳 수)를 싣는다 — 「간단히」는 분량 지시이지 단계 면제가 아니다.\n' +
6377
6451
  '**문체는 주어와 자리가 정한다** — 요청 자리 밖에는 우리·사물·화면이 주어인 문장만 개조식(~함/~했음/~임)으로 쓴다. 독자가 할 일은 요청 자리(open_items·cta·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지 하십시오체로 맺는다(「회신 부탁드립니다」. 제목은 검수·보고면 「~ 요청」+기한 괄호, 고객이 정할 것은 data.decision_brief.decisions 에 — 서식은 lim-report 하나이고 결정 블록이 있으면 결정 모드로 선다(lim-decision 은 옛 이름)). 그 밖의 칸에는 사실만 적는다(「기준 수치는 미정」). dry_run 은 일부만 센다.\n' +
6378
6452
  '쓰기 전에 결과만 보고 싶으면 dry_run: true — 렌더까지 돌려 경고를 주고 아무것도 저장하지 않는다.',
6379
6453
  inputSchema: {
@@ -6401,7 +6475,7 @@ const TOOLS = [
6401
6475
  '초안을 고객에게 연다. 이 순간부터 고객 목록과 열람에 뜬다. ' +
6402
6476
  '이미 열려 있으면 아무 일도 하지 않고 그렇다고 답한다(다시 불러도 안전하다).\n' +
6403
6477
  '사람 자격으로 공개해야 보고서의 열린 항목이 일감으로 선다.\n' +
6404
- '**공개 승인**을 사용자에게 물을 때는 그 물음과 답에 이 판에서 건너뛴 단계(0단계 사용자 확인·독자 검토·열람 확인 등)와 증거 없는 관찰 주장 수(렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」 — 포털 dry_run 에 이 경고가 아직 없으면 스킬의 로컬 render.py 로 잰 곳 수)를 싣는다 — 「간단히」는 분량 지시이지 단계 면제가 아니다.',
6478
+ '**공개 승인**을 사용자에게 물을 때는 그 물음과 답에 이 판에서 건너뛴 단계(0단계 사용자 확인·열람 확인 등)와 증거 없는 관찰 주장 수(렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」 — 포털 dry_run 에 이 경고가 아직 없으면 스킬의 로컬 render.py 로 잰 곳 수)를 싣는다 — 「간단히」는 분량 지시이지 단계 면제가 아니다.',
6405
6479
  inputSchema: {
6406
6480
  type: 'object',
6407
6481
  properties: {
@@ -6452,7 +6526,7 @@ const TOOLS = [
6452
6526
  '한 장만 바꾸려면 그 id 에 새 `source`/`file` 을 주고 나머지는 get_report 가 준 표식 그대로 두면 된다. ' +
6453
6527
  '**받은 판 잠금**(2026-09-30): get_report 머리줄의 body_rev 를 `body_rev` 로 실으면, 받은 뒤 본문 판이 바뀌었을 때 서버가 409 로 멈추고 아무것도 안 쓴다. ' +
6454
6528
  'dry_run 에도 같이 실으면 같은 409 를 덮기 전에 받는다. 서버가 이 잠금을 모르는 옛 판이면 출력이 「판을 견주지 않았다」고 알린다.\n' +
6455
- '**교체 승인**을 사용자에게 물을 때는 그 물음과 답에 이 판에서 건너뛴 단계(0단계 사용자 확인·독자 검토·열람 확인 등)와 증거 없는 관찰 주장 수(렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」 — 포털 dry_run 에 이 경고가 아직 없으면 스킬의 로컬 render.py 로 잰 곳 수)를 싣는다 — 「간단히」는 분량 지시이지 단계 면제가 아니다.\n' +
6529
+ '**교체 승인**을 사용자에게 물을 때는 그 물음과 답에 이 판에서 건너뛴 단계(0단계 사용자 확인·열람 확인 등)와 증거 없는 관찰 주장 수(렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」 — 포털 dry_run 에 이 경고가 아직 없으면 스킬의 로컬 render.py 로 잰 곳 수)를 싣는다 — 「간단히」는 분량 지시이지 단계 면제가 아니다.\n' +
6456
6530
  '**문체는 주어와 자리가 정한다** — 요청 자리 밖에는 우리·사물·화면이 주어인 문장만 개조식(~함/~했음/~임)으로 쓴다. 독자가 할 일은 요청 자리(open_items·cta·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지 하십시오체로 맺는다(「회신 부탁드립니다」. 제목은 검수·보고면 「~ 요청」+기한 괄호, 고객이 정할 것은 data.decision_brief.decisions 에 — 서식은 lim-report 하나이고 결정 블록이 있으면 결정 모드로 선다(lim-decision 은 옛 이름)). 그 밖의 칸에는 사실만 적는다(「기준 수치는 미정」). dry_run 은 일부만 센다.\n',
6457
6531
  inputSchema: {
6458
6532
  type: 'object',
@@ -6935,30 +7009,6 @@ const TOOLS = [
6935
7009
  additionalProperties: false,
6936
7010
  },
6937
7011
  },
6938
- {
6939
- name: 'install_cell',
6940
- description:
6941
- '이 기계에 **genetics 세포(gcell)** 를 깐다. 세포는 선택이 아니라 **필수**다 — ' +
6942
- '없으면 「이 저장소에서 남이 무엇을 바꿨나」를 아무도 못 말한다.\n' +
6943
- '사람이 안 시켜도 부를 때: 도구 응답에 「세포를 못 쓴다」가 붙었을 때.\n' +
6944
- '판올림은 저절로 된다(서버가 뜰 때 지문을 대조해 갈아 끼운다) — 이 도구는 **처음 깔 때**와 ' +
6945
- '**훅을 다시 걸 때** 쓴다.',
6946
- inputSchema: {
6947
- type: 'object',
6948
- properties: {
6949
- hooks: { type: 'boolean', description: '훅까지 걸 것인가(기본 true). '
6950
- + '훅은 이 저장소의 .claude/settings.json 에만 쓴다 — 전역은 안 건드린다.' },
6951
- },
6952
- },
6953
- },
6954
- {
6955
- name: 'uninstall_cell',
6956
- description:
6957
- 'genetics 세포를 **지운다** — 훅·바이너리·이 기계의 관측 상태까지. ' +
6958
- '무엇을 지웠는지 하나하나 말한다.\n' +
6959
- '사람이 「세포 지워」 「gcell 제거」라고 할 때 쓴다. 되돌리려면 `install_cell`.',
6960
- inputSchema: { type: 'object', properties: {} },
6961
- },
6962
7012
  {
6963
7013
  /*
6964
7014
  * 자동 올리기에서 **대화 하나를 뺀다**(DESIGN-genetics-ssot.md §5.8). 확정 폴더는 사람이 켠 뒤로 저절로 올라가므로
@@ -7070,83 +7120,27 @@ function pendingText(r) {
7070
7120
  */
7071
7121
  let WORLD_LAST = '';
7072
7122
  /*
7073
- * ⚠ **세포가 없으면 조용히 넘어가지 않는다.** 세포는 **선택이 아니라 필수**다 —
7074
- * 없으면 이 서버는 「이 저장소에서 무슨 일이 있었나」를 영영 말할 수 없고, 그게 바로
7075
- * 이 물건이 있는 이유다. 그래서 없으면 **없다고 말하고 까는 길을 알려 준다.**
7076
- * (처음엔 한 번 실패하면 끄는 쪽으로 짰다가 고쳤다 — 그건 「없는데 없다고 안 하는」
7077
- * 설계이고, 이 저장소가 반복해서 경계해 온 **조용한 실패**다.)
7123
+ * ⚠ **세포가 없으면 조용히 넘어가지 않는다.** 없으면 **없다고 말하고 누가 맡는지 알려 준다** —
7124
+ * 「이 저장소에서 무슨 일이 있었나」를 못 싣는 것은 조용한 실패가 아니라 말해야 할 사실이다.
7125
+ * (처음엔 한 번 실패하면 끄는 쪽으로 짰다가 고쳤다 — 이 저장소가 반복해서 경계해 온 **조용한 실패**다.)
7078
7126
  */
7079
7127
  /*
7080
- * ── 세포의 무결성 ─────────────────────────────────────────────────
7081
- *
7082
- * **세포의 출력은 모델의 문맥에 그대로 들어간다.** 그래서 바꿔치기당한 세포는 「도구 하나가
7083
- * 이상해지는 것」이 아니라 **프롬프트 주입 통로**다. 부르기 **전에** 지문을 대조한다.
7084
- *
7085
- * 매 호출 검사한다 — 477KB sha256 이 **0.27ms** 다(실측 2026-08-23, 표본 9).
7086
- * mtime·크기 캐시를 안 쓴다: 파일을 쓸 수 있는 상대는 그 둘을 맞춰 놓을 수 있다.
7087
- *
7088
- * ⚠ **지키는 것과 못 지키는 것을 분명히 한다.**
7089
- * 지킨다 — 깐 자리의 바이너리가 이 판의 것과 다르면 **안 쓴다**(바꿔치기·변조·옛 판).
7090
- * 못 지킨다 — 패키지 자체가 위조된 경우. 그건 npm 과 TLS 가 지키는 층이고,
7091
- * MCP 가 스스로를 검사하는 것은 **연극이다**(고친 쪽이 검사도 지운다).
7092
- * 그래서 방어를 하나 더 둔다 — 아래 `cellClean()` 이 세포가 낸 **모양 자체를 제한한다.**
7093
- */
7094
- const CELL_DIR = path.join(os.homedir(), '.config/genetics/bin');
7095
- const CELL_PATH = path.join(CELL_DIR, process.platform === 'win32' ? 'gcell.exe' : 'gcell');
7096
-
7097
- /** 이 기계의 타깃 삼중. 모르면 `null` — 지어내지 않는다. */
7098
- function cellTarget(platform = process.platform, arch = process.arch) {
7099
- const a = arch, p = platform;
7100
- if (p === 'darwin') return a === 'arm64' ? 'aarch64-apple-darwin' : a === 'x64' ? 'x86_64-apple-darwin' : null;
7101
- if (p === 'linux') return a === 'arm64' ? 'aarch64-unknown-linux-musl' : a === 'x64' ? 'x86_64-unknown-linux-musl' : null;
7102
- // Windows 는 mingw 로 굽는다(cell/.cargo/config.toml). arm64 Windows 는 아직 안 굽는다 — 없는 것은 없는 것이다.
7103
- if (p === 'win32') return a === 'x64' ? 'x86_64-pc-windows-gnu' : null;
7104
- return null;
7105
- }
7106
-
7107
- /**
7108
- * 이 플랫폼에 **세포가 있을 수 있나**. 「아직 안 깔렸다」와 「이 플랫폼엔 애초에 없다」를 가른다.
7128
+ * ── 세포의 신뢰 ──────────────────────────────────────────────────
7109
7129
  *
7110
- * ⚠ 이 갈래가 없으면 안내가 **막다른 길**이 된다: 못 쓴다는 글이 `install_cell` 을 가리키고,
7111
- * 그 도구는 다시 「이 판에 없다」를 낸다 — 모델이 그 둘을 오가며 설치를 되풀이한다.
7112
- */
7113
- function cellPlatformSupported(platform = process.platform, arch = process.arch) {
7114
- return cellTarget(platform, arch) !== null;
7115
- }
7116
-
7117
- function cellManifest() {
7118
- try { return JSON.parse(fs.readFileSync(path.join(HERE, 'bin/manifest.json'), 'utf8')); }
7119
- catch { return null; }
7120
- }
7121
-
7122
- /**
7123
- * 파일의 sha256. 못 읽으면 `null`.
7130
+ * **세포의 출력은 모델의 문맥에 그대로 들어간다.** 바꿔치기당한 세포는 「도구 하나가 이상해지는 것」이
7131
+ * 아니라 **프롬프트 주입 통로**다. 그래서 이 MCP 가 부르는 세포는 **사람이 `GENETICS_CELL_BIN` 으로 지정한
7132
+ * 바이너리 하나뿐**이다 — 지정한 사람의 책임이다(개발 기계는 레포 빌드를, gcell 앱이 깔린 기계는 앱의 훅을 건다).
7124
7133
  *
7125
- * ⚠ **`catch` 가 코딩 실수를 삼키지 않게 한다.** 처음엔 이 함수가 맨 `createHash(...)` 를
7126
- * 불렀는데(이 파일은 `import crypto from 'node:crypto'` 라 그 이름이 없다) `ReferenceError`
7127
- * 가 `catch` 에 먹혀 **언제나 `null`** 이 나왔다. 그러면 지문 대조가 늘 어긋나
7128
- * **무결성 검사가 「항상 거절」로 굳는다** — 거절 시험은 전부 초록이고 대조군 하나만
7129
- * 빨개서, 대조군이 없었으면 「검사가 작동한다」로 읽고 게시했을 것이다(2026-08-23).
7130
- * 그래서 여기서는 **없는 파일만** 조용히 넘기고, 나머지 예외는 위로 올린다.
7134
+ * 2.29.0(2026-10-16 · 세포 설치 멈춤): 전에는 이 MCP 가 세포를 npm 에 실어 뜰 때마다 `~/.config/genetics/bin/gcell` 에
7135
+ * 깔고 지문(sha256)으로 대조했다. 기계 쪽 일은 gcell 앱이 맡는다(대표 결정 2026-10-16 「gcell 은 개인, genetics 는 조직」 —
7136
+ * lim decisions/) — MCP 는 세포를 깔지도 싣지도 않고, 옛 정본 자리에 무엇이 남아 있어도 지정이 없으면 **부르지 않는다**.
7137
+ * 방어를 하나 더 둔다 — 아래 `cellClean()` 이 세포가 낸 **모양 자체를 제한한다.**
7131
7138
  */
7132
- function sha256File(f) {
7133
- if (!fs.existsSync(f)) return null;
7134
- return crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');
7135
- }
7136
-
7137
- /** 깐 세포가 **이 판의 것**인가. 맞으면 경로, 아니면 이유. */
7139
+ /** 쓸 세포 — 사람이 지정한 바이너리뿐. 맞으면 경로, 아니면 이유. */
7138
7140
  function cellCheck() {
7139
7141
  const forced = cfg.GENETICS_CELL_BIN;
7140
- // 사람이 손으로 지정했으면 그 사람 책임이다 — 개발 기계가 레포 빌드를 걸어 두는 자리다.
7141
- if (forced) return fs.existsSync(forced) ? { bin: forced, why: '' } : { bin: null, why: `지정한 세포가 없다: ${forced}` };
7142
- const t = cellTarget();
7143
- if (!t) return { bin: null, why: `이 플랫폼(${process.platform}/${process.arch})용 세포가 이 판에 없다` };
7144
- const want = cellManifest()?.[t];
7145
- if (!want) return { bin: null, why: '이 판에 세포 지문이 없다(패키지가 반쪽이다)' };
7146
- if (!fs.existsSync(CELL_PATH)) return { bin: null, why: '세포가 아직 안 깔렸다' };
7147
- const got = sha256File(CELL_PATH);
7148
- if (got !== want) return { bin: null, why: '깐 세포의 지문이 이 판과 다르다(바꿔치기·변조·옛 판)' };
7149
- return { bin: CELL_PATH, why: '' };
7142
+ if (!forced) return { bin: null, why: '지정된 세포가 없다(GENETICS_CELL_BIN)' };
7143
+ return fs.existsSync(forced) ? { bin: forced, why: '' } : { bin: null, why: `지정한 세포가 없다: ${forced}` };
7150
7144
  }
7151
7145
 
7152
7146
  /**
@@ -7347,6 +7341,18 @@ function tenantNoticeText(v) {
7347
7341
  }
7348
7342
  }
7349
7343
 
7344
+ /*
7345
+ * whoami 의 운영사 한 줄(2026-10-15 대표 「설정 화면에 한 번」) — `/api/me` 의 operator.name 은 설정 › 보고서 브랜드의 운영사 이름이다.
7346
+ * 작성자는 이 이름을 원고의 보내는 쪽(issuer 첫 칸)으로 적는다 — 대화 맥락에서 회사 이름을 짐작해 적지 않게. 비었으면 비었다고 말하고
7347
+ * 넣을 자리를 가리킨다. 옛 서버(칸이 없음)에는 **아무 말도 안 한다** — 없는 값을 「미설정」으로 옮기면 그것이 또 근거로 읽힌다. 순수.
7348
+ */
7349
+ function operatorLine(me) {
7350
+ const op = me && typeof me === 'object' ? me.operator : undefined;
7351
+ if (!op || typeof op !== 'object' || typeof op.name !== 'string') return '';
7352
+ const name = op.name.trim();
7353
+ return name !== '' ? `운영사: ${name}` : '운영사 이름 미설정 — 전체 관리자가 설정 › 보고서 브랜드에서 넣습니다';
7354
+ }
7355
+
7350
7356
  /** 지금 이 창의 상태 — 아직 답을 모르면 null. */
7351
7357
  function tenantNoticeView() {
7352
7358
  if (!userCred()) return { state: 'no_human' };
@@ -7552,18 +7558,13 @@ function toolsNow() {
7552
7558
  }
7553
7559
 
7554
7560
  /**
7555
- * 세포가 없거나 못 믿을 때 하는 말. **사실 + 고치는 길**이고 지시형이 아니다 —
7561
+ * 세포가 없거나 못 믿을 때 하는 말. **사실 + 누가 맡는지**이고 지시형이 아니다 —
7556
7562
  * 지시형은 프롬프트 주입으로 거부된다(2026-08-22 실측).
7557
7563
  */
7558
7564
  function cellNotice(why) {
7559
7565
  const head = `genetics 세포(gcell)를 못 쓴다 — ${why}. 그래서 이 저장소에서 남이 무엇을 바꿨는지 못 싣는다.`;
7560
- // **깔 수 없는 곳에 설치를 시키지 않는다.** 이 플랫폼에 세포가 없으면 그 말이 곧 다음 할 일이다.
7561
- if (!cellPlatformSupported()) {
7562
- return [head,
7563
- `이 플랫폼(${process.platform}/${process.arch})에는 세포가 없다 — 깔 수 있는 것이 없으니 `
7564
- + '`install_cell` 을 부르지 말 것. 나머지 도구는 그대로 쓴다.'].join('\n');
7565
- }
7566
- return [head, '세포는 선택이 아니라 필수다. `install_cell` 도구가 깔고, `uninstall_cell` 이 지운다.'].join('\n');
7566
+ return [head, 'MCP 는 세포를 깔지 않는다(2.29.0) — 기계 쪽 일은 gcell 앱이 맡고, 앱이 깐 훅(gcell-hook)을 '
7567
+ + '`GENETICS_CELL_BIN` 으로 지정하면 그것을 쓴다. 나머지 도구는 그대로 쓴다.'].join('\n');
7567
7568
  }
7568
7569
 
7569
7570
  /* ── ★★ 메일 레인 — 두뇌·다른 세션·사람이 **나를 지목해** 보낸 것 ──────────
@@ -8099,7 +8100,7 @@ function selfAddressJs(env = process.env, probe = harnessProbe(), intro = (sid)
8099
8100
  * 함께 준다: 옛 세포는 0분 창으로 곧바로 「최근 0분 안에 … 없다」 한 줄을 내고(0.18초 실측), 그 글은 계약 꼴이 아니니 「못 알아듣는다」로
8100
8101
  * 적힌다. 새 세포는 `--self` 를 `--since` 보다 먼저 가르고 돌아가 그 깃발을 안 본다(cmd_agents). cwd 는 임시 폴더 — `--self` 는 저장소가
8101
8102
  * 아니라 프로세스 열쇠로 찾는다.
8102
- * ⚠ 못 알아들은 세포는 그 바이너리(경로·크기·mtime)가 바뀌기 전엔 다시 안 묻는다 — 판올림(ensureCellOnBoot)이 갈아 끼우면 다시 묻는다.
8103
+ * ⚠ 못 알아들은 세포는 그 바이너리(경로·크기·mtime)가 바뀌기 전엔 다시 안 묻는다 — 사람이 지정한 바이너리를 갈아 끼우면 다시 묻는다.
8103
8104
  * 시간 초과는 「느리다」일 수 있어 5분만 쉰다.
8104
8105
  */
8105
8106
  const CELL_SELF_TIMEOUT_MS = 1000;
@@ -9245,8 +9246,9 @@ async function callTool(name, args = {}) {
9245
9246
  + '이 기계의 세션은 서버에 안 쌓이고, 두뇌가 그 대화로 팀 기억을 굽지 않습니다.'
9246
9247
  // 스위치는 심박마다 다시 읽는다 — 창을 다시 열 필요가 없다.
9247
9248
  + '\n (지금 켜시면 다음 심박에 스스로 반영됩니다 — 창을 다시 열 필요 없습니다.)';
9249
+ const op = operatorLine(me);
9248
9250
  return `${me.user.name}(${me.user.email}) · ${me.user.role} 으로 붙어 있습니다.\n`
9249
- + `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${alsoKey}${pushOff}`;
9251
+ + `기계: ${me.device_label || '(이름 없음)'} · 만료 ${c.expires_at}${op ? `\n${op}` : ''}${alsoKey}${pushOff}`;
9250
9252
  }
9251
9253
 
9252
9254
  case 'list_my_tenants': {
@@ -10326,24 +10328,6 @@ async function callTool(name, args = {}) {
10326
10328
  + (r.says ? `${r.says}\n` : '')
10327
10329
  + '답이 오면 다른 도구를 부를 때 응답 꼬리에 저절로 실린다 — 따로 확인할 필요 없다.';
10328
10330
  }
10329
- case 'install_cell': {
10330
- const { ok, out } = cellInstall({ hooks: args.hooks !== false });
10331
- const now = cellCheck();
10332
- return [
10333
- ok ? '세포를 맞췄습니다.' : '세포를 못 깔았습니다.',
10334
- ...out.map((l) => ` · ${l}`),
10335
- now.bin ? `지금 상태: 쓸 수 있습니다(${now.bin})`
10336
- : `지금 상태: 아직 못 씁니다 — ${now.why}`,
10337
- '판올림은 저절로 됩니다 — 서버가 뜰 때마다 지문을 대조해 갈아 끼웁니다.',
10338
- ].join('\n');
10339
- }
10340
-
10341
- case 'uninstall_cell': {
10342
- const out = cellUninstall();
10343
- return ['세포를 지웠습니다.', ...out.map((l) => ` · ${l}`),
10344
- '다시 깔려면 `install_cell`.'].join('\n');
10345
- }
10346
-
10347
10331
  case 'no_auto_push': {
10348
10332
  /*
10349
10333
  * 대화 하나를 자동 올리기에서 뺀다(§5.8). **이 기계의 약속 먼저**(색인 auto.hold) — 서버에 못 닿아도 이 클라이언트는
@@ -10554,305 +10538,6 @@ const IS_MAIN = (() => {
10554
10538
  * · **심볼릭 링크면 손대지 않는다.** 개발 기계가 레포를 걸어 둔 자리다 — 덮으면 레포가 끊긴다.
10555
10539
  * · **실패해도 서버는 산다.** 스킬을 못 펴는 것은 발행을 못 할 이유가 아니다.
10556
10540
  */
10557
- /**
10558
- * 세포를 깐다 — 또는 이미 이 판의 것이면 아무것도 안 한다.
10559
- *
10560
- * **OTA 는 새 기계가 아니다.** `npx -y genetics-mcp@latest` 는 매 실행 레지스트리에서 다시
10561
- * 풀리고(실측), 이 함수는 뜰 때마다 지문을 대조한다. 그래서 세포를 고쳐 게시하면
10562
- * **다음 세션에 저절로 갈린다** — 스킬이 이미 그렇게 하고 있다(`ensureSkillOnBoot`).
10563
- * 자리는 판이 바뀌어도 안 움직이는 `~/.config/genetics/bin/gcell` 이다. 훅이 그 경로를
10564
- * 가리키므로, 갈아 끼워도 훅을 다시 걸 필요가 없다.
10565
- */
10566
- /*
10567
- * ── `gcell` 을 PATH 에 건다 (078 문턱 ④) ──────────────────────────────────
10568
- * 바이너리는 `~/.config/genetics/bin` 에 깔리는데 그 폴더는 어느 셸의 PATH 에도 없다. 그래서
10569
- * 사람에게 안내하는 실행법이 「api.env 를 grep 해 cut 한 경로」 두 줄이었다 — 파이프를 읽을 줄
10570
- * 아는 사람만 세포에게 물을 수 있었다(SKILL.md 실측 2026-08-27). 심링크 하나면 `gcell` 로 끝난다.
10571
- *
10572
- * 자리 고르기는 **순수 함수**로 뺀다(smoke 가 떼어내 잰다): PATH 에 있고 · 실재하고 · 쓸 수 있는
10573
- * 첫 후보. 없는 폴더를 만들거나 rc 파일을 고치지 않는다 — 남의 셸 설정에 손대는 설치는 못 믿는다.
10574
- */
10575
- /*
10576
- * ⚠ **Windows 에는 이 자리가 없다.** 후보 셋은 전부 유닉스의 PATH 자리이고, 심링크 자체도
10577
- * Windows 에서는 개발자 모드나 관리자 권한이 있어야 만들어진다(EPERM). 그래서 그 판에서는
10578
- * **걸지 않고**, 대신 「이 폴더를 PATH 에 넣으라」고 그 판의 말로 말한다 — 없는 자리를
10579
- * 나열하며 못 걸었다고 하는 것은 안내가 아니다.
10580
- */
10581
- const CELL_LINK_CANDIDATES = process.platform === 'win32'
10582
- ? []
10583
- : ['/opt/homebrew/bin', '/usr/local/bin', path.join(os.homedir(), '.local/bin')];
10584
- function cellLinkDir(pathEnv, candidates, exists, writable) {
10585
- const onPath = new Set(String(pathEnv || '').split(path.delimiter).filter(Boolean));
10586
- for (const d of candidates) {
10587
- if (onPath.has(d) && exists(d) && writable(d)) return d;
10588
- }
10589
- return null;
10590
- }
10591
- /*
10592
- * ★★ **남의 HOME 으로 뜬 프로세스는 전역 PATH 자리를 안 건드린다**(C7 · 2026-09-21 실측 누수).
10593
- * `CELL_PATH` 는 `os.homedir()` 를 따른다(posix 는 HOME 환경). 시험이 가짜 HOME 으로 MCP 를 띄우면 그 HOME 의 세포가
10594
- * 생기는데, 전역 자리(/opt/homebrew/bin 등)의 `gcell` 이 「옛 자리를 가리키는 우리 심링크」로 읽혀 **가짜 HOME 쪽으로
10595
- * 갈아 끼워졌다** — 시험이 끝나 그 HOME 이 지워지면 이 기계의 `gcell` 이 통째로 dangling 이 된다(C6 검토 때 실제로 났다).
10596
- * 그래서: 고른 자리가 이 HOME 밖(전역)이고, 이 프로세스의 HOME 이 **이 사용자의 실제 집**(passwd — `os.userInfo().homedir`)이
10597
- * 아니면 걸지도 걷지도 않고 말만 한다. HOME 안의 자리(~/.local/bin)는 그 HOME 의 것이라 그대로 건다.
10598
- * 실제 집을 모르면(userInfo 가 던짐) 닫는 쪽 — 전역 자리를 안 건드린다. **순수 함수**(smoke 가 표로 잰다).
10599
- */
10600
- function cellLinkForeign(dir, home, realHome) {
10601
- const norm = (p) => { const s = String(p || ''); try { return fs.realpathSync(s); } catch { return s; } };
10602
- const h = norm(home);
10603
- const d = norm(dir);
10604
- if (h && (d === h || d.startsWith(h.endsWith(path.sep) ? h : h + path.sep))) return false; // 이 HOME 의 자리
10605
- return !realHome || norm(realHome) !== h;
10606
- }
10607
- function cellRealHome() {
10608
- try { return os.userInfo().homedir || ''; } catch { return ''; }
10609
- }
10610
- function cellLinkOnPath() {
10611
- const dir = cellLinkDir(process.env.PATH, CELL_LINK_CANDIDATES,
10612
- (d) => { try { return fs.statSync(d).isDirectory(); } catch { return false; } },
10613
- (d) => { try { fs.accessSync(d, fs.constants.W_OK); return true; } catch { return false; } });
10614
- if (dir && cellLinkForeign(dir, os.homedir(), cellRealHome())) {
10615
- // ⚠ 까닭을 **그대로** 적는다(반박 compat 2-B) — 「시험용 HOME」이라고만 하면 `sudo -E`·passwd 항목이 없는 컨테이너처럼
10616
- // HOME 이 정당하게 다른 사람이 원인을 못 찾는다. 그 사람들은 이 한 줄을 보고 손으로 걸면 된다.
10617
- return `전역 자리(${dir})에는 \`gcell\` 을 안 걸었다 — 이 프로세스의 HOME(${os.homedir()})이 passwd 의 집`
10618
- + `(${cellRealHome() || '알 수 없음'})과 달라, 남의 HOME 으로 뜬 프로세스가 기계 공용 자리를 갈아 끼우지 않게 막았다`
10619
- + `(시험용 HOME·sudo -E·컨테이너). 전체 경로 ${CELL_PATH} 로 부르거나, 직접 걸려면 ln -sfn ${CELL_PATH} ${path.join(dir, 'gcell')}.`;
10620
- }
10621
- if (!dir) {
10622
- if (process.platform === 'win32') {
10623
- return `PATH 에 안 걸었다(Windows 는 심링크에 권한이 필요하다) — 전체 경로 ${CELL_PATH} 로 부르거나 `
10624
- + `이 폴더를 PATH 에 넣을 것: setx PATH "%PATH%;${CELL_DIR}"`;
10625
- }
10626
- return `PATH 에 걸 자리를 못 찾았다(${CELL_LINK_CANDIDATES.join(' · ')} 중 PATH 에 있고 쓸 수 있는 곳이 없다) — 전체 경로 ${CELL_PATH} 로 부를 것.`;
10627
- }
10628
- const link = path.join(dir, 'gcell');
10629
- try {
10630
- const cur = fs.lstatSync(link);
10631
- if (cur.isSymbolicLink() && fs.readlinkSync(link) === CELL_PATH) return ''; // 이미 걸려 있다 — 매 부팅 말하면 소음이다
10632
- if (!cur.isSymbolicLink()) return `${link} 에 다른 것이 있어 안 건드렸다 — 전체 경로 ${CELL_PATH} 로 부를 것.`;
10633
- fs.unlinkSync(link); // 우리 것이 아닌 심링크가 아니라 **옛 자리를 가리키는 우리 심링크**다 — 갈아 끼운다
10634
- } catch { /* 없다 — 새로 건다 */ }
10635
- try {
10636
- fs.symlinkSync(CELL_PATH, link);
10637
- return `\`gcell\` 을 PATH 에 걸었다(${link}).`;
10638
- } catch (e) {
10639
- return `\`gcell\` 을 PATH 에 못 걸었다(${link}): ${e.message} — 전체 경로 ${CELL_PATH} 로 부를 것.`;
10640
- }
10641
- }
10642
- function cellUnlinkOnPath() {
10643
- const out = [];
10644
- for (const d of CELL_LINK_CANDIDATES) {
10645
- if (cellLinkForeign(d, os.homedir(), cellRealHome())) continue; // 거는 쪽과 같은 가드 — 남의 HOME 은 전역 자리를 안 만진다
10646
- const link = path.join(d, 'gcell');
10647
- try {
10648
- if (fs.lstatSync(link).isSymbolicLink() && fs.readlinkSync(link) === CELL_PATH) {
10649
- fs.unlinkSync(link);
10650
- out.push(`PATH 의 심링크를 걷었다: ${link}`);
10651
- }
10652
- } catch { /* 없다 */ }
10653
- }
10654
- return out;
10655
- }
10656
-
10657
- /**
10658
- * `src` 를 `dst` 자리에 **원자적으로** 놓는다 — 같은 디렉터리에 임시파일로 쓰고 `rename`.
10659
- *
10660
- * 왜 필요한가(2026-09-02 실측, 이 기기) — 세포가 30여 개 동시에 떠 있는 채로
10661
- * `fs.copyFileSync(src, dst)` 를 직접 쓰면, `dst` 를 그 순간 `exec()` 하려던 다른 세션의
10662
- * 훅이 **반쪽만 써진 바이너리**를 붙잡고 걸린다(`gcell --version` 이 107초 뒤 SIGKILL,
10663
- * `sample` 로 보면 `_dyld_start` 조차 못 지나 죽는다). `copyFileSync` 는 목적지를 직접
10664
- * 열어 쓰는 것이라 그 창이 열려 있다.
10665
- *
10666
- * POSIX `rename` 은 같은 파일시스템 안에서 원자적이다 — 이미 그 경로를 열어 실행 중인
10667
- * 프로세스는 옛 inode 를 계속 보고(unlink-after-open 은 안전하다는 유닉스 불변식), 새로
10668
- * 여는 프로세스만 새 파일을 본다. 반쪽짜리를 볼 수 있는 창이 사라진다.
10669
- */
10670
- function copyFileAtomic(src, dst) {
10671
- const tmp = `${dst}.tmp-${process.pid}-${Date.now()}`;
10672
- fs.copyFileSync(src, tmp);
10673
- fs.chmodSync(tmp, 0o755);
10674
- fs.renameSync(tmp, dst);
10675
- }
10676
-
10677
- /*
10678
- * 【선언】 **(호환 L17) MCP 가 세포를 붙잡아 쓰면(`GENETICS_CELL_BIN`) 훅도 그 바이너리를 부르게 한다.**
10679
- * 세포의 `hook_bin()` 순서는 `GCELL_HOOK_BIN` → 정본 자리(`~/.config/genetics/bin/gcell`, 있으면) → 도는 것이다
10680
- * (`cell/src/main.rs` · §9.5 — 그 순서는 안 바꾼다). 그런데 붙잡은 갈래(`cellInstall()` 의 `forced`)는 **정본 자리를
10681
- * 아예 안 갈아 끼우므로**, 그 기계에 옛 정본이 남아 있으면 훅은 영영 그 옛 것을 부르고 MCP 는 붙잡은 것을 부른다 —
10682
- * 세포가 두 벌이다(세포가 `hook_bin_split_notice` 로 알리기만 하던 자리). 그래서 붙잡은 갈래에서만 `GCELL_HOOK_BIN` 을
10683
- * **함께** 넘긴다. ⚠ 사람이 이미 `GCELL_HOOK_BIN` 을 줬으면 그 값이 이긴다(안 덮는다). ⚠ 경로는 절대 경로로 —
10684
- * 훅은 다른 자리에서 불린다. 이 줄을 `false` 로 두면 옛 동작(정본 자리 우선)으로 돌아간다.
10685
- */
10686
- const CELL_PIN_MOVES_HOOKS = true;
10687
-
10688
- /*
10689
- * 【선언】 **(판 8 — 호환 F4) 훅을 옮기는 붙잡음은 환경변수의 것뿐이다 — `api.env` 의 줄은 안 옮긴다.**
10690
- * `gcell install-hooks` 는 **언제나** `api.env` 에 `GENETICS_CELL_BIN=<지금 도는 바이너리>` 를 적는다(`cell/src/main.rs`,
10691
- * 「MCP 가 찾을 수 있게」). 그 파일을 `cfg` 로 합치므로, 워크트리 빌드로 `install-hooks --apply` 를 **한 번** 돌린 기계는
10692
- * 부팅마다 붙잡은 갈래로 가고, L17 은 그 빌드를 Claude·Cursor·Codex 훅에 `GCELL_HOOK_BIN` 으로 박았다 — 빌드를 지우면
10693
- * **모든 훅이 없는 파일을 가리켰다.** 그래서 핀은 `process.env` 로 붙잡았을 때만 넘긴다(`cellPinSource() === 'env'`).
10694
- * `api.env` 에서만 온 붙잡음은 판 6 처럼 세포의 `hook_bin()` 순서(정본 자리 우선)를 따른다.
10695
- * 【선언】 **붙잡은 세포가 없으면 훅을 정본 자리(`CELL_PATH`)로 다시 건다** — 전에는 「지정한 세포가 없다」만 말하고 훅을
10696
- * 부서진 채로 뒀다. 정본 자리에 세포가 있으면 그것으로 `install-hooks` 를 부르고(핀 없이 — 그 세포가 `api.env` 의 줄도
10697
- * 자기 경로로 고쳐 적는다), 없으면 아래 정본 갈래가 이 판의 세포를 깔고 그것으로 건다. 이 프로세스의 `cellCheck()` 는
10698
- * 여전히 「지정한 세포가 없다」다 — `cfg` 는 뜰 때 한 번 읽는다(다음 부팅부터 고친 줄을 읽는다).
10699
- */
10700
- /** `GENETICS_CELL_BIN` 이 어디서 왔나 — `'env'`(process.env) · `'file'`(api.env 에서만) · `''`(없음). **순수 함수.** */
10701
- function cellPinSource(procEnv = process.env, merged = cfg) {
10702
- if (String(procEnv?.GENETICS_CELL_BIN || '') !== '') return 'env';
10703
- if (String(merged?.GENETICS_CELL_BIN || '') !== '') return 'file';
10704
- return '';
10705
- }
10706
-
10707
- /** 붙잡은 세포로 훅을 걸 때의 환경(호환 L17). 순수 — `env` 를 안 고치고 새 객체를 낸다. */
10708
- function cellHookEnv(bin, env = process.env, pinned = CELL_PIN_MOVES_HOOKS) {
10709
- if (!pinned || !bin || String(env.GCELL_HOOK_BIN || '') !== '') return env;
10710
- return { ...env, GCELL_HOOK_BIN: path.resolve(String(bin)) };
10711
- }
10712
-
10713
- /** `install-hooks --apply` 를 지정한 바이너리로 돌린다. 조용해야 할 때는 빈 문자열. `pinned` 면 훅도 그 바이너리로(L17). */
10714
- function cellInstallHooksVia(bin, ifRepo, pinned = false) {
10715
- // `ifRepo` 는 **자동으로 부를 때만** 준다 — 사람이 `install_cell` 을 부른 자리는 자기가
10716
- // 정한 곳이지만, 부팅마다 도는 걸음은 git 저장소가 아닌 폴더에 설정을 만들면 안 된다.
10717
- const a = ['install-hooks', '--apply'];
10718
- if (ifRepo) a.push('--if-repo');
10719
- const r = spawnSync(bin, a, { encoding: 'utf8', timeout: 10_000, env: pinned ? cellHookEnv(bin) : process.env });
10720
- // 세포의 `HOOKS_UNCHANGED` 와 **글자 그대로** 맞춘다 — smoke 가 두 파일을 대조한다.
10721
- const so = r.stdout || '';
10722
- const quiet = r.status === 0 && (so.includes('바뀐 것 없다.') || so.includes('git 저장소가 아니다'));
10723
- if (quiet) return '';
10724
- return r.status === 0 ? '훅을 걸었다(Claude 는 이 저장소, Cursor 는 ~/.cursor/hooks.json).'
10725
- : `훅을 못 걸었다: ${cellClean(r.stderr || r.stdout || '이유 없음')}`;
10726
- }
10727
-
10728
- function cellInstall({ hooks = true, ifRepo = false } = {}) {
10729
- const out = [];
10730
- const forced = cfg.GENETICS_CELL_BIN;
10731
-
10732
- // 사람이 손으로 지정한 자리(개발 기계) — **그 파일은 절대 열어 쓰지 않는다.**
10733
- // 예전엔 이 분기가 없어 `ensureCellOnBoot` 의 주석("바이너리는 안 건드리되")과 실제
10734
- // 동작이 어긋났다 — 지정해 놔도 아래 공식판 지문 대조·`copyFileSync` 를 그대로 타
10735
- // 매 부팅마다 지정한 파일을 덮어썼다(2026-09-02 사고의 원인). 지정이 있으면 지문
10736
- // 대조·CELL_PATH 자체를 안 본다 — 훅만 **그 바이너리 기준**으로 맞춘다.
10737
- const pinFrom = cellPinSource();
10738
- if (forced && fs.existsSync(forced)) {
10739
- if (hooks) {
10740
- // 훅도 붙잡은 그 바이너리로 — **환경변수로 붙잡았을 때만**(호환 L17 · 판 8 호환 F4 · `CELL_PIN_MOVES_HOOKS`)
10741
- const msg = cellInstallHooksVia(forced, ifRepo, pinFrom === 'env');
10742
- if (msg) out.push(msg);
10743
- }
10744
- return { ok: true, out };
10745
- }
10746
- if (forced) {
10747
- // (판 8 — 호환 F4) 없는 핀을 가리키는 훅을 남기지 않는다 — 정본 자리로 다시 건다(위 【선언】).
10748
- out.push(`지정한 세포가 없다: ${forced}(${pinFrom === 'env' ? '환경변수' : 'api.env'}) — GENETICS_CELL_BIN 을 확인하라.`
10749
- + (hooks ? ` 훅은 정본 자리(${CELL_PATH})로 다시 건다.` : ''));
10750
- if (fs.existsSync(CELL_PATH)) {
10751
- if (hooks) {
10752
- const msg = cellInstallHooksVia(CELL_PATH, ifRepo);
10753
- if (msg) out.push(msg);
10754
- }
10755
- return { ok: false, out };
10756
- }
10757
- // 정본 자리도 비었으면 아래 정본 갈래가 이 판의 세포를 깔고 그것으로 훅을 건다.
10758
- }
10759
-
10760
- const t = cellTarget();
10761
- if (!t) {
10762
- // **없는 것을 있는 척하지 않는다.** Windows 는 아직 못 굽는다(cell/.cargo/config.toml).
10763
- return { ok: false, out: [`이 플랫폼(${process.platform}/${process.arch})용 세포가 이 판에 없다.`,
10764
- '소스에서 직접 빌드하려면: cell/ 에서 `cargo build --release`.'] };
10765
- }
10766
- const want = cellManifest()?.[t];
10767
- const src = path.join(HERE, 'bin', t, process.platform === 'win32' ? 'gcell.exe' : 'gcell');
10768
- if (!want || !fs.existsSync(src)) {
10769
- return { ok: false, out: ['이 판에 세포가 안 실려 있다(패키지가 반쪽이다).'] };
10770
- }
10771
- if (sha256File(CELL_PATH) === want) {
10772
- if (!ifRepo) out.push(`세포는 이미 이 판이다(${VERSION}).`); // 자동 걸음은 조용하다 — 매 부팅 말하면 그것이 소음이다
10773
- } else {
10774
- fs.mkdirSync(CELL_DIR, { recursive: true });
10775
- copyFileAtomic(src, CELL_PATH);
10776
- // **깐 뒤에 다시 잰다.** 복사가 반쯤 됐거나 남이 끼어들었으면 여기서 걸린다.
10777
- const got = sha256File(CELL_PATH);
10778
- if (got !== want) return { ok: false, out: ['세포를 깔았는데 지문이 안 맞는다 — 쓰지 않는다.'] };
10779
- out.push(`세포를 ${CELL_PATH} 에 깔았다(${VERSION} · ${want.slice(0, 12)}…).`);
10780
- }
10781
- // 판이 같아도 링크는 맞춘다 — 옛 판이 링크 없이 깔린 기계가 있다(이 기계가 그랬다).
10782
- const linked = cellLinkOnPath();
10783
- if (linked) out.push(linked);
10784
- if (hooks) {
10785
- const msg = cellInstallHooksVia(CELL_PATH, ifRepo);
10786
- if (msg) out.push(msg);
10787
- }
10788
- return { ok: true, out };
10789
- }
10790
-
10791
- /**
10792
- * 세포를 지운다 — **깐 것을 전부, 그리고 무엇을 지웠는지 말하면서.**
10793
- * 조용한 삭제는 삭제가 아니다: 무엇이 남았는지 모르면 사람이 다시 깔 수도 없다.
10794
- */
10795
- function cellUninstall() {
10796
- const out = [];
10797
- if (fs.existsSync(CELL_PATH)) {
10798
- const r = spawnSync(CELL_PATH, ['uninstall', '--apply'], { encoding: 'utf8', timeout: 10_000 });
10799
- out.push(...String(r.stdout || '').split('\n').filter(Boolean).map(cellClean));
10800
- if (r.status !== 0) out.push(`세포가 스스로 치우지 못했다: ${cellClean(r.stderr || '이유 없음')}`);
10801
- try { fs.rmSync(CELL_PATH); out.push(`바이너리를 지웠다: ${CELL_PATH}`); }
10802
- catch (e) { out.push(`바이너리를 못 지웠다: ${e.message}`); }
10803
- } else {
10804
- out.push('깔린 세포가 없다.');
10805
- }
10806
- // PATH 에 건 심링크도 걷는다 — 남겨 두면 `gcell` 이 없는 파일을 가리킨다(dangling).
10807
- out.push(...cellUnlinkOnPath());
10808
- // 자격 파일 옆의 경로 줄도 걷는다 — 남겨 두면 다음 판이 없는 것을 가리킨다.
10809
- try {
10810
- const env = path.join(os.homedir(), '.config/genetics/api.env');
10811
- if (fs.existsSync(env)) {
10812
- const before = fs.readFileSync(env, 'utf8').split('\n');
10813
- const after = before.filter((l) => !l.startsWith('GENETICS_CELL_BIN='));
10814
- if (after.length !== before.length) {
10815
- fs.writeFileSync(env, after.join('\n'));
10816
- out.push('api.env 의 GENETICS_CELL_BIN 줄을 걷었다.');
10817
- }
10818
- }
10819
- } catch { /* 없으면 할 일 없다 */ }
10820
- return out;
10821
- }
10822
-
10823
- /** 뜰 때 세포를 이 판에 맞춘다. 실패해도 서버는 산다 — 대신 **말한다.** */
10824
- /*
10825
- * ★★ **훅은 바이너리 판과 무관하게 매 부팅 맞춘다**(2026-08-26, 사용자 결정).
10826
- *
10827
- * 전에는 `hooks: false` 였고, 판이 같으면 아예 일찍 되돌아갔다. 그 둘이 겹쳐 두 가지가 샜다 —
10828
- * ① **새 이벤트가 어디서도 저절로 안 걸린다.** SessionStart 를 실어 보내도 기존 설정에는
10829
- * 그 칸이 없어, 사람이 저장소마다 `install_cell` 을 불러야 했다.
10830
- * ② **설정이 옛 경로를 가리키면 판올림이 무의미하다.** 세포만 갈아 끼우고 설정을 두면
10831
- * 훅은 계속 옛 바이너리를 부른다(실측: report-portal 이 13시간 전 것으로 돌고 있었다).
10832
- *
10833
- * 매 부팅 쓰지 않는다 — 세포가 **같으면 안 쓴다**(`HOOKS_UNCHANGED`). 안 그러면 mtime 만
10834
- * 흔들려 「누군가 settings.json 을 썼다」가 매번 뜬다.
10835
- * 그리고 자동 걸음은 `--if-repo` 를 태운다 — git 저장소가 아닌 폴더에 설정을 만들지 않는다.
10836
- */
10837
- function ensureCellOnBoot() {
10838
- if (process.env.GENETICS_NO_CELL) return; // 자동 설치를 원치 않는 사람의 탈출구
10839
- const hooks = !process.env.GENETICS_NO_HOOKS; // 훅만 따로 끄는 탈출구
10840
- try {
10841
- // 지정(`GENETICS_CELL_BIN`)이 있는지, 이 플랫폼용 공식 세포가 있는지는 이제
10842
- // `cellInstall` 하나가 안다 — 여기서 갈라 적으면(예전엔 그랬다) 그 갈래가
10843
- // 서로 어긋날 수 있다(2026-09-02 사고: 여기 주석은 "안 건드린다"였는데
10844
- // `cellInstall` 은 안 가려 지정한 파일을 덮어썼다). 지정도 없고 이 플랫폼용
10845
- // 세포도 없으면 `cellTarget()` 이 걸러 `cellInstall` 이 조용히 실패로 답한다.
10846
- if (!cfg.GENETICS_CELL_BIN && !cellTarget()) return; // 그 플랫폼은 도구 응답이 말한다(cellNotice)
10847
- const { ok, out } = cellInstall({ hooks, ifRepo: true });
10848
- if (!ok || out.length) {
10849
- process.stderr.write(`genetics: ${ok ? '' : '세포 설치 실패 — '}${out.join(' ')}\n`);
10850
- }
10851
- } catch (e) {
10852
- process.stderr.write(`genetics: 세포를 못 맞췄다 — ${e.message} (서버는 계속 돈다)\n`);
10853
- }
10854
- }
10855
-
10856
10541
  /*
10857
10542
  * ── 스킬 사본의 도장 — **내용 해시** (DESIGN-genetics-ssot.md §9.3) ─────────────────────────────
10858
10543
  *
@@ -11062,8 +10747,7 @@ function startSchedule() {
11062
10747
 
11063
10748
  if (IS_MAIN) {
11064
10749
  if (!sessionToolMode()) {
11065
- ensureCellOnBoot(); // 세포가 먼저다 — 스킬보다 이쪽이 「무엇을 못 하는가」를 정한다
11066
- ensureSkillOnBoot();
10750
+ ensureSkillOnBoot(); // 세포는 깔지 않는다(2.29.0 · 세포 설치 멈춤 — 기계 쪽 일은 gcell 앱 몫, 대표 결정 2026-10-16)
11067
10751
  }
11068
10752
  /*
11069
10753
  * **뜰 때 심박을 시작한다.** 이 프로세스가 사는 동안이 곧 「이 기계가 이 창을 열고 있는
@@ -11090,7 +10774,7 @@ if (IS_MAIN) {
11090
10774
 
11091
10775
  // 시험용 — 서버 동작에는 쓰이지 않는다. materializeAssets 만 네트워크를 쓴다.
11092
10776
  export { videoThumbs, taskReadTools, taskReadRequest, taskReadResult, taskWriteResult, taskCodeOf,
11093
- encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText, IMAGE_HOST_ALLOW, HOST_ALLOW,
10777
+ encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText, operatorLine, IMAGE_HOST_ALLOW, HOST_ALLOW,
11094
10778
  scrubTranscript, splitAtLines, PUSH_PART_MAX,
11095
10779
  // DESIGN-genetics-ssot.md §3·§5 — 색인·잠금·폴더 identity·알림 문구의 순수/파일 함수(시험이 걷는다)
11096
10780
  jsonFileUpdate, jsonCorruptAside, credWrite, pendingWrite, pendingClear, indexRead, indexNote, indexPatch, indexTenant,
@@ -11102,23 +10786,23 @@ export { videoThumbs, taskReadTools, taskReadRequest, taskReadResult, taskWriteR
11102
10786
  autoFloorMs, autoContext, autoHoldsRead, autoHoldSet, autoNoticeIsConsent, autoConsentKey, autoPrivateAnyCwd,
11103
10787
  // §5.5 7 — 자라면 다시 후보(호환 C2) · 사람이 뺀 대화의 붙박이 표(개인정보 NEW-4) · §7.7 works 항목 토막
11104
10788
  autoTooOldRefresh, AUTO_STICKY_409, AUTO_CLEAR_ON_PUSH, worksItemsLine,
11105
- // 라운드 5 — too_old mtime 표시(호환 N8) · 막음 표는 그 소속처만 푼다(N9) · resolve 의 uuid 상태(개인정보 N10) · 훅 핀(L17)
11106
- autoTooOldMark, autoBlockClearedBy, autoClearOnPush, resolveSessionSays, autoBlockFromResolve, cellHookEnv, CELL_PIN_MOVES_HOOKS,
11107
- // 판 8 — 다른 소속처의 막음 표를 안 덮는다(적합성 F7) · 훅 핀은 환경변수의 붙잡음만, 없는 핀은 정본으로(호환 F4)
11108
- autoBlockSetVerdict, autoBlockPut, cellPinSource,
10789
+ // 라운드 5 — too_old mtime 표시(호환 N8) · 막음 표는 그 소속처만 푼다(N9) · resolve 의 uuid 상태(개인정보 N10)
10790
+ autoTooOldMark, autoBlockClearedBy, autoClearOnPush, resolveSessionSays, autoBlockFromResolve,
10791
+ // 판 8 — 다른 소속처의 막음 표를 안 덮는다(적합성 F7)
10792
+ autoBlockSetVerdict, autoBlockPut,
11109
10793
  // 2026-09-15 — 「서버가 아는 행인가」는 소속 칸이 아니라 서버를 거친 표식으로 가른다(첫 업로드가 영영 안 서던 자리)
11110
10794
  autoRowOnServer,
11111
10795
  // 2026-09-18 Windows — 남의 규칙(Claude Code 슬러그)·플랫폼 갈래는 시험이 표로 걷는다
11112
- transcriptSlug, transcriptDir, cursorUserRoot, sqliteBin, npxCacheResetHint, cellPlatformSupported,
10796
+ transcriptSlug, transcriptDir, cursorUserRoot, sqliteBin, npxCacheResetHint,
11113
10797
  mySessionId, refreshCodexSessions, autoCandidates, autoSyncRound, callTool,
11114
10798
  HOLDS_FILE, UPDATE_BUDGET_MS,
11115
10799
  // DESIGN-session-delta-sync C6 — 규칙판·좌표·기계 키·대응표·머리 끊기·private 판정(시험이 걷는다)
11116
10800
  SCRUB_REV, CWD_HIST_MAX, PUSH_MAC_WIN, pushDir, pushKey, pushKeyId, pushMac, pushCoords,
11117
10801
  ledgerRead, ledgerWrite, ledgerAfterFull, autoPrepare, transcriptHead,
11118
- // DESIGN-session-delta-sync C7 — 한 입구(갈래·임대·임대 안 판정·닫힘) · 요청 상한(바이트에 비례) · 전역 링크 가드(시험이 걷는다)
10802
+ // DESIGN-session-delta-sync C7 — 한 입구(갈래·임대·임대 안 판정·닫힘) · 요청 상한(바이트에 비례)(시험이 걷는다)
11119
10803
  autoPolicyOf, autoIntervalOf, autoGateOf, autoBlockedFor, autoLegacyCheck, autoHistOverflow, gitRootAbove,
11120
10804
  leaseFile, leaseTake, LEASE_QUIET_MS, HUMAN_LEASE_WAIT_MS, API_TIMEOUT_MS, apiTimeoutFor, API_MIN_BPS, AUTO_CLOSED_CODES,
11121
- AUTO_BACKOFF_LONG_MS, AUTO_PUSH_MIN_MS, cellLinkForeign,
10805
+ AUTO_BACKOFF_LONG_MS, AUTO_PUSH_MIN_MS,
11122
10806
  // DESIGN-session-delta-sync C8 — 갈래 고르기·능력 광고·조각 나누기·접기·FULL 예산·사본 재정의(시험이 걷는다)
11123
10807
  planPush, pushSession, pushAppend, capsNote, capsAppend, capsScrubRev, capsFile, capsAppendGone, CAPS_FALSE_TTL_MS,
11124
10808
  readAt, lastNewlineAt, countLines, appendScan, splitSeg, foldHist, scrubDelta, hashRange, pushTenantId,