genetics-mcp 2.12.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/bin/aarch64-apple-darwin/gcell +0 -0
  2. package/bin/aarch64-unknown-linux-musl/gcell +0 -0
  3. package/bin/manifest.json +4 -4
  4. package/bin/x86_64-apple-darwin/gcell +0 -0
  5. package/bin/x86_64-unknown-linux-musl/gcell +0 -0
  6. package/genetics.mjs +363 -59
  7. package/package.json +1 -1
  8. package/skills/gazette-report/README.md +71 -0
  9. package/skills/gazette-report/SKILL.md +479 -0
  10. package/skills/gazette-report/scripts/boundary.py +97 -0
  11. package/skills/gazette-report/scripts/contentdiff.sh +36 -0
  12. package/skills/gazette-report/scripts/cssaudit.sh +78 -0
  13. package/skills/gazette-report/scripts/extract.sh +84 -0
  14. package/skills/gazette-report/scripts/flexcheck.mjs +28 -0
  15. package/skills/gazette-report/scripts/fontchk.mjs +62 -0
  16. package/skills/gazette-report/scripts/kcheck.mjs +146 -0
  17. package/skills/gazette-report/scripts/package.json +5 -0
  18. package/skills/gazette-report/scripts/pdfcheck.sh +34 -0
  19. package/skills/gazette-report/scripts/read.mjs +85 -0
  20. package/skills/gazette-report/scripts/sentbreak.py +142 -0
  21. package/skills/gazette-report/scripts/tone.mjs +172 -0
  22. package/skills/gazette-report/scripts/tone.test.mjs +118 -0
  23. package/skills/gazette-report/scripts/typesurvey.mjs +48 -0
  24. package/skills/gazette-report/template/README.md +89 -0
  25. package/skills/gazette-report/template/gazette.css +2120 -0
  26. package/skills/gazette-report/template/skeleton.html +477 -0
  27. package/skills/genetics-cell-id/SKILL.md +12 -10
  28. package/skills/genetics-report/SKILL.md +54 -7
  29. package/skills/genetics-report/assets/example/cases-briefing.json +32 -0
  30. package/skills/genetics-report/assets/example/report-briefing.json +271 -0
  31. package/skills/genetics-report/assets/example/report.json +1 -1
  32. package/skills/genetics-report/references/content-pipeline.md +20 -0
  33. package/skills/genetics-report/references/lim-portal-format.md +100 -7
  34. package/skills/genetics-report/references/process.md +11 -0
  35. package/skills/genetics-sync/SKILL.md +1 -1
package/genetics.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  * 이 서버는 모양을 만들지 않는다. 서버(build.py)가 규격대로 렌더하므로,
14
14
  * 여기서 보내는 것은 **내용뿐**이다. 그래서 CSS 가 제각각일 수 없다.
15
15
  *
16
- * **키 하나가 고객사 하나다.** 여기에 더해 발행 도구는 `tenant`(대상 고객사 이름)를
16
+ * **키 하나가 고객사 하나다.** 여기에 더해 발행 도구는 `tenant`(대상 고객사 slug — 이름도 받는다)를
17
17
  * 반드시 받아 키와 대조한다 — 키만 믿었다가 실제로 남의 고객사에 발행한 적이 있다
18
18
  * (2026-08-12). 키와 의도가 둘 다 맞아야 나간다.
19
19
  * 키는 사람이 관리 화면에서 발급해 자격증명 파일(권한 600)에 둔다 — 경로는 아래 ENV_FILE.
@@ -524,7 +524,21 @@ async function materializeAssets(data) {
524
524
  const { data: dropped, file: fpath, ...meta } = a ?? {};
525
525
  if (!a?.source && !fpath) {
526
526
  // 둘 다 없으면 예전 경로 그대로 — data 를 그대로 통과시킨다(판 A 하위호환).
527
- out.push(dropped === undefined ? meta : { ...meta, data: dropped });
527
+ // get_report 가 준 표식('(생략 — NB)')과 bytes 칸의 처리(2026-08-27, 운영 #34):
528
+ // brief 없는 자산 → data·bytes 를 빼고 **id 만** 보낸다. 서버가 같은 id 의 저장 바이트를 잇는다
529
+ // (report_carry_assets — 「빈 data」는 id 잇기 갈래다).
530
+ // brief 있는 자산 → **표식을 그대로** 싣는다. data 를 비우면 서버가 「brief 만 왔다 =
531
+ // 다시 그려라」로 읽어(carry 의 brief 갈래 → asset_jobs_plan) 저장 그림을 버리고 크레딧을
532
+ // 태운다 — 표식은 빈 값이 아니라서 carry 가 저장 바이트로 갈아끼우고 brief 는 남는다.
533
+ const { bytes: _b, ...rest } = meta;
534
+ const placeholder = typeof dropped === 'string' && dropped.startsWith('(생략');
535
+ if (placeholder) {
536
+ const briefed = typeof rest.brief === 'string' && rest.brief.trim() !== '';
537
+ if (briefed) out.push({ ...rest, data: dropped });
538
+ else { const { data: _d, ...idOnly } = rest; out.push(idOnly); }
539
+ continue;
540
+ }
541
+ out.push(dropped === undefined ? rest : { ...rest, data: dropped });
528
542
  continue;
529
543
  }
530
544
  // 원격보다 **로컬이 이긴다** — 손에 든 실물이 주소보다 확실하다.
@@ -700,6 +714,7 @@ function limMeta(h) {
700
714
  head_id: h.get('x-lim-head-id'), head_ms: num('x-lim-head-ms'),
701
715
  lines: num('x-lim-lines'), bytes: num('x-lim-bytes'),
702
716
  redacted: num('x-lim-redacted'), tenant_id: num('x-lim-tenant-id'),
717
+ tenant_slug: h.get('x-lim-tenant-slug'),
703
718
  };
704
719
  }
705
720
 
@@ -952,7 +967,8 @@ const REPORT_DATA = {
952
967
  + '로 두면 화질을 계단으로 내려 예산 안에 앉힌다. 그 도판은 `sections[].plates` 에 id 를 적어야 자리가 생긴다. '
953
968
  + '크기와 형식은 인코딩 결과에서 읽으므로 선언하지 않는다. 전부 data: URI 로 문서에 박히며 '
954
969
  + '(열람 CSP 가 외부 이미지를 막는다), 서버가 출처·매직바이트·예산(장당 80,000B·합계 400,000B)을 '
955
- + '다시 검증한다.',
970
+ + '다시 검증한다. **갱신(update_report)에서 이 칸을 빼면 저장된 그림이 유지되고, `[]` 면 지워진다** — '
971
+ + 'plates 가 가리키는 그림이 없으면 422.',
956
972
  items: {
957
973
  type: 'object',
958
974
  properties: {
@@ -1257,14 +1273,111 @@ function findTranscript(uuid) {
1257
1273
  const here = path.join(transcriptDir(process.cwd()), `${uuid}.jsonl`);
1258
1274
  if (fs.existsSync(here)) return here;
1259
1275
  const root = path.join(os.homedir(), '.claude', 'projects');
1276
+ // Claude Code 가 없는 기계(Cursor 만 깔린 곳)에는 이 디렉터리 자체가 없다 —
1277
+ // 「전사 없음」이지 오류가 아니다. 던지면 mail 등 도구가 통째로 죽는다(smoke 실측).
1278
+ if (!fs.existsSync(root)) return null;
1260
1279
  for (const d of fs.readdirSync(root, { withFileTypes: true })) {
1261
1280
  if (!d.isDirectory()) continue;
1262
1281
  const f = path.join(root, d.name, `${uuid}.jsonl`);
1263
1282
  if (fs.existsSync(f)) return f;
1264
1283
  }
1284
+ return cursorTranscriptOf(uuid);
1285
+ }
1286
+
1287
+ /*
1288
+ * Cursor 대화의 전사(조각 3, 2026-08-28) — `~/.cursor/projects/<슬러그>/agent-transcripts/<uuid>/<uuid>.jsonl`.
1289
+ * 설계 문서 §1.2 의 sqlite 전제는 낡았다 — 훅 payload 의 `transcript_path` 가 가리키는 jsonl 이
1290
+ * 실재한다(이 기계 157개 실측). 슬러그는 워크스페이스 기준(앞 대시 없음)이라 cwd 로 못 짚고,
1291
+ * 자리가 몇 안 되니 **전부 센다**(세포 world.rs 의 cursor_transcript_dirs 와 같은 결정).
1292
+ * ⚠ 30일 지난 것은 Cursor 가 스스로 지운다(.agent-data-cleanup-*) — 없음은 오류가 아니다.
1293
+ */
1294
+ function cursorTranscriptOf(uuid) {
1295
+ const base = path.join(os.homedir(), '.cursor', 'projects');
1296
+ let dirs;
1297
+ try { dirs = fs.readdirSync(base, { withFileTypes: true }); } catch { return null; }
1298
+ for (const d of dirs) {
1299
+ if (!d.isDirectory()) continue;
1300
+ const f = path.join(base, d.name, 'agent-transcripts', uuid, `${uuid}.jsonl`);
1301
+ if (fs.existsSync(f)) return f;
1302
+ }
1265
1303
  return null;
1266
1304
  }
1267
1305
 
1306
+ /* ── Cursor 신원 ──────────────────────────────────────────────────────────
1307
+ *
1308
+ * Cursor 는 MCP 자식에게 대화(세션) id 를 **안 준다** — 실측(2026-08-27, pid 98284 env):
1309
+ * `CODE_EDITOR=cursor` 와 `CURSOR_WORKSPACE_LABEL=<창 이름>` 뿐이고, MCP 프로세스는
1310
+ * 대화마다가 아니라 **창마다 하나**다. 대신 창의 워크스페이스 저장소가 「지금 포커스된
1311
+ * 대화」를 들고 있다:
1312
+ *
1313
+ * <Cursor>/User/workspaceStorage/<hash>/workspace.json → { folder: "file:///…" }
1314
+ * 같은 칸의 state.vscdb ItemTable 'composer.composerData'
1315
+ * → { lastFocusedComposerIds: ["<uuid4>"], selectedComposerIds: […] }
1316
+ *
1317
+ * composerId 는 uuid4 라 군체의 세션 열쇠와 같은 자리수다 — 그 uuid 를 「지금 이 대화」로
1318
+ * 쓴다. 단 **CLAUDE_CODE_SESSION_ID 가 있으면 언제나 그것이 정본**이다: Cursor 통합
1319
+ * 터미널 안 Claude Code 도 CURSOR_* env 를 물려받으므로(실측: pid 706 은 둘 다 가짐),
1320
+ * 순서를 바꾸면 Claude 세션이 Cursor 대화로 오인된다.
1321
+ *
1322
+ * 한계를 지어내지 않는다 — 이 uuid 로 되는 것은 **주소를 대는 것**까지다. 전사가
1323
+ * ~/.claude/projects 에 없으므로 올리기·머리 계산·자동 올리기는 각자의 전사 검사에서
1324
+ * 조용히 비켜난다(beatTargets:transcriptOf null → skip). 대화 본문을 올리는 길(두뇌
1325
+ * 파서까지)은 별도 단계다 — DESIGN-cursor-colony.md.
1326
+ */
1327
+ const CURSOR_ROOT = process.env.GENETICS_CURSOR_ROOT
1328
+ || path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User');
1329
+
1330
+ let cursorSeen = { at: 0, id: '', folder: '' }; // 15초 캐시 — 도구마다 sqlite 를 안 두드리게
1331
+
1332
+ function cursorActiveComposer() {
1333
+ if (process.env.CLAUDE_CODE_SESSION_ID) return '';
1334
+ const label = process.env.CURSOR_WORKSPACE_LABEL || '';
1335
+ if (!label) return ''; // 창을 못 고르면 추측하지 않는다
1336
+ const now = Date.now();
1337
+ if (now - cursorSeen.at < 15000) return cursorSeen.id;
1338
+ cursorSeen = { at: now, id: '', folder: '' };
1339
+ try {
1340
+ const wsRoot = path.join(CURSOR_ROOT, 'workspaceStorage');
1341
+ let best = null; // 같은 이름의 창이 여럿이면 가장 최근에 움직인 것
1342
+ for (const d of fs.readdirSync(wsRoot)) {
1343
+ const dir = path.join(wsRoot, d);
1344
+ let folder = '';
1345
+ try {
1346
+ folder = String(JSON.parse(fs.readFileSync(path.join(dir, 'workspace.json'), 'utf8')).folder || '');
1347
+ } catch { continue; }
1348
+ if (!folder.startsWith('file://')) continue;
1349
+ const wsPath = decodeURIComponent(folder.slice('file://'.length)).replace(/\/+$/, '');
1350
+ if (path.basename(wsPath) !== label) continue;
1351
+ let mt = 0;
1352
+ try { mt = fs.statSync(path.join(dir, 'state.vscdb')).mtimeMs; } catch { continue; }
1353
+ if (!best || mt > best.mt) best = { dir, wsPath, mt };
1354
+ }
1355
+ if (!best) return '';
1356
+ const r = spawnSync('/usr/bin/sqlite3',
1357
+ ['-readonly', path.join(best.dir, 'state.vscdb'),
1358
+ "SELECT value FROM ItemTable WHERE key='composer.composerData'"],
1359
+ { encoding: 'utf8', timeout: 2000 });
1360
+ if (r.status !== 0) return '';
1361
+ const j = JSON.parse((r.stdout || '').trim() || '{}');
1362
+ const v = j.lastFocusedComposerIds || j.selectedComposerIds;
1363
+ const id = Array.isArray(v) ? String(v[0] || '') : '';
1364
+ if (!/^[0-9a-f-]{36}$/i.test(id)) return '';
1365
+ cursorSeen = { at: now, id, folder: best.wsPath };
1366
+ return id;
1367
+ } catch { return ''; }
1368
+ }
1369
+
1370
+ /** 위 해석기가 고른 창의 **저장소 폴더** — 소속처 입양(같은 폴더 규칙)이 쓴다. */
1371
+ function cursorWorkspaceFolder() {
1372
+ if (!cursorSeen.folder) cursorActiveComposer();
1373
+ return cursorSeen.folder;
1374
+ }
1375
+
1376
+ /** 「지금 이 세션」 — Claude Code env 가 정본, 없으면 Cursor 활성 대화. */
1377
+ function mySessionId() {
1378
+ return process.env.CLAUDE_CODE_SESSION_ID || cursorActiveComposer();
1379
+ }
1380
+
1268
1381
  function gitHere() {
1269
1382
  try {
1270
1383
  const r = spawnSync('git', ['-C', process.cwd(), 'rev-parse', '--abbrev-ref', 'HEAD'],
@@ -1355,9 +1468,16 @@ async function pushSession(tenant, uuid, title, dryRun) {
1355
1468
  * 진짜 출처는 이미 손에 있었다: **본문 각 줄의 `cwd`**(scrubTranscript 가 센다).
1356
1469
  * 없을 때만 옛 방식으로 물러선다 — 그래야 퇴보가 없다.
1357
1470
  */
1471
+ /*
1472
+ * ⚠ Cursor 전사(조각 3)에는 줄 cwd 가 아예 없다 — 그때 process.cwd() 로 물러서면
1473
+ * **올리는 프로세스의 자리**가 그 대화의 자리로 적힌다(위 scrubcheck 사고와 같은 병).
1474
+ * 같은 창의 Cursor MCP 면 워크스페이스 폴더가 진짜 자리다(cursorWorkspaceFolder) —
1475
+ * 그것도 모르면 **빈 값**으로 둔다. 모르는 것은 모른다고 적는다(자리 판정은 빈 자리를 안다).
1476
+ */
1477
+ const isCursorBody = f.includes(`${path.sep}.cursor${path.sep}projects${path.sep}`);
1358
1478
  const body = {
1359
1479
  tenant, uuid,
1360
- origin_cwd: sc.cwd || relHome(process.cwd()),
1480
+ origin_cwd: sc.cwd || (isCursorBody ? relHome(cursorWorkspaceFolder() || '') : relHome(process.cwd())),
1361
1481
  origin_git: gitHere(), tools: toolsStamp(),
1362
1482
  jsonl: sc.jsonl,
1363
1483
  };
@@ -1382,7 +1502,7 @@ async function pushSession(tenant, uuid, title, dryRun) {
1382
1502
  const parts = splitAtLines(sc.jsonl, PUSH_PART_MAX);
1383
1503
  if (parts.length === 1) {
1384
1504
  const r = await api('PUT', '/api/sessions', body);
1385
- if (!dryRun) indexNote(uuid, { tenant: r.tenant || tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms }, 'tx');
1505
+ if (!dryRun) indexNote(uuid, { tenant: r.tenant || tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) }, 'tx');
1386
1506
  return { r, before, parts: 1 };
1387
1507
  }
1388
1508
 
@@ -1399,7 +1519,7 @@ async function pushSession(tenant, uuid, title, dryRun) {
1399
1519
  process.stderr.write(`genetics: 조각 ${i + 1}/${parts.length} ${dryRun ? '시험' : '올림'} `
1400
1520
  + `(${(Buffer.byteLength(parts[i], 'utf8') / 1048576).toFixed(1)}MB)\n`);
1401
1521
  }
1402
- if (!dryRun) indexNote(uuid, { tenant: last.tenant || tenant, tenant_id: last.tenant_id, head_id: last.head_id, head_ms: last.head_ms }, 'tx');
1522
+ if (!dryRun) indexNote(uuid, { tenant: last.tenant || tenant, tenant_id: last.tenant_id, head_id: last.head_id, head_ms: last.head_ms, ...(last.tenant_slug ? { tenant_slug: last.tenant_slug } : {}) }, 'tx');
1403
1523
  return { r: last, before, parts: parts.length };
1404
1524
  }
1405
1525
 
@@ -1516,6 +1636,12 @@ function indexRead() {
1516
1636
  * 말한 머리를 그대로 적을 뿐이고, 등록(:1676)은 아무것도 주고받지 않는다. 그 둘은 계기가
1517
1637
  * 없다. 그리고 커서 쓰기는 **아무것도 안 왔을 때도** 도므로 부르는 쪽이 조건을 건다.
1518
1638
  */
1639
+ /** 색인 유래로 서버를 부를 때의 `tenant` 인자 — slug 가 있으면 slug(유일·불변), 없으면 옛 이름.
1640
+ * 색인의 `tenant` 칸은 계속 **이름**이다(표시용) — 인자로 나갈 때만 여기서 갈아탄다. */
1641
+ function tenantArgOf(e) {
1642
+ return (e && typeof e.tenant_slug === 'string' && e.tenant_slug !== '') ? e.tenant_slug : (e?.tenant || '');
1643
+ }
1644
+
1519
1645
  function indexNote(uuid, entry, flow = '') {
1520
1646
  if (!uuid || !entry?.tenant_id) return;
1521
1647
  try {
@@ -1618,7 +1744,7 @@ function transcriptOf(uuid) {
1618
1744
  * 지어낸 출처 한 줄이 뒤에서 회수 보호의 근거가 되면, 걷어야 할 것을 안 걷게 된다.
1619
1745
  */
1620
1746
  function myCitation() {
1621
- const uuid = process.env.CLAUDE_CODE_SESSION_ID || '';
1747
+ const uuid = mySessionId();
1622
1748
  if (!/^[0-9a-f-]{36}$/i.test(uuid)) return {};
1623
1749
  const f = transcriptOf(uuid);
1624
1750
  if (!f) return {};
@@ -1660,18 +1786,29 @@ function adoptTenantOf(myDir, rows) {
1660
1786
  let adoptSaid = false;
1661
1787
  /** 위 규칙을 이 기계에 적용한다 — 색인에 한 줄을 더할 뿐, **올리지는 않는다.** */
1662
1788
  function indexAdoptCurrent() {
1663
- const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
1789
+ const mine = mySessionId();
1664
1790
  if (!mine) return;
1665
1791
  const idx = indexRead();
1666
1792
  if (idx[mine]?.tenant_id) return; // 이미 등록돼 있다
1667
1793
  const f = transcriptOf(mine);
1668
- if (!f) return; // 이 기계에 사본이 없다
1669
- const myDir = path.dirname(f);
1794
+ /*
1795
+ * 폴더 열쇠 — 입양 규칙(같은 폴더 = 같은 소속처)의 「폴더」는 전사 디렉터리다.
1796
+ * Cursor 대화는 ~/.claude/projects 에 전사가 없지만 **창의 저장소 폴더**는 아니까,
1797
+ * 같은 슬러그 규칙(transcriptDir)으로 열쇠를 만들면 그 저장소의 Claude 세션들과
1798
+ * 같은 칸에 선다 — report-portal 창의 Cursor 대화는 report-portal 세션들을 따라간다.
1799
+ */
1800
+ let myDir = '';
1801
+ if (f) myDir = path.dirname(f);
1802
+ else if (mine && mine === cursorActiveComposer()) {
1803
+ const folder = cursorWorkspaceFolder();
1804
+ if (folder) myDir = transcriptDir(folder);
1805
+ }
1806
+ if (!myDir) return; // 이 기계에 사본도, 창 귀속도 없다
1670
1807
  const rows = [];
1671
1808
  for (const [u, e] of Object.entries(idx)) {
1672
1809
  if (!e?.tenant_id) continue;
1673
1810
  const p = transcriptOf(u);
1674
- if (p) rows.push({ dir: path.dirname(p), tenant: e.tenant, tenant_id: e.tenant_id });
1811
+ if (p) rows.push({ dir: path.dirname(p), tenant: e.tenant, tenant_id: e.tenant_id, ...(e.tenant_slug ? { tenant_slug: e.tenant_slug } : {}) });
1675
1812
  }
1676
1813
  const pick = adoptTenantOf(myDir, rows);
1677
1814
  // 이 폴더의 **첫 세션**이면 근거가 없다 — 여기서 조용히 되돌아가는 것은 의도이고,
@@ -1686,14 +1823,14 @@ function indexAdoptCurrent() {
1686
1823
  }
1687
1824
  return;
1688
1825
  }
1689
- indexNote(mine, { tenant: pick.tenant, tenant_id: pick.tenant_id, head_id: '', head_ms: 0 });
1826
+ indexNote(mine, { tenant: pick.tenant, tenant_id: pick.tenant_id, head_id: '', head_ms: 0, ...(pick.tenant_slug ? { tenant_slug: pick.tenant_slug } : {}) });
1690
1827
  process.stderr.write('genetics: 이 세션을 «' + pick.tenant
1691
1828
  + '» 로 등록했다 — 같은 폴더의 앞 세션과 같은 곳이다. 이제 자동으로 올라간다.\n');
1692
1829
  }
1693
1830
 
1694
1831
  function beatTargets() {
1695
1832
  const idx = indexRead();
1696
- const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
1833
+ const mine = mySessionId();
1697
1834
  const now = Date.now();
1698
1835
  const out = [];
1699
1836
  for (const [uuid, e] of Object.entries(idx)) {
@@ -1711,7 +1848,7 @@ function beatTargets() {
1711
1848
  lines: head.lines, tenant: e.tenant_id, flags: 0,
1712
1849
  // 방금 움직였으면 「말하는 중」이다 — 화면의 맥박이 여기서 뛴다.
1713
1850
  state: now - mtime < BEAT_EVERY_MS ? BEAT_STATE.WORKING : BEAT_STATE.OPEN,
1714
- _tenant_name: e.tenant,
1851
+ _tenant_arg: tenantArgOf(e),
1715
1852
  });
1716
1853
  if (out.length >= BEAT.MAX) break;
1717
1854
  }
@@ -1882,11 +2019,11 @@ async function autoPushIfAhead(reply, recs) {
1882
2019
  for (const v of reply) {
1883
2020
  if (v.verdict !== BEAT_VERDICT.AHEAD || !(v.flags & BEAT_WANT_PUSH)) continue;
1884
2021
  const rec = recs.find((r) => r.uuid === v.uuid);
1885
- if (!rec?._tenant_name) continue;
2022
+ if (!rec?._tenant_arg) continue;
1886
2023
  if (now - (autoPushAt.get(v.uuid) || 0) < AUTO_PUSH_MIN_MS) continue;
1887
2024
  autoPushAt.set(v.uuid, now);
1888
2025
  try {
1889
- const { r } = await pushSession(rec._tenant_name, v.uuid, '');
2026
+ const { r } = await pushSession(rec._tenant_arg, v.uuid, '');
1890
2027
  process.stderr.write(`genetics: ${v.uuid.slice(0, 8)} 를 자동으로 올렸다 — ${r.lines}줄(서버가 낡아 있었다).\n`);
1891
2028
  } catch (e) {
1892
2029
  // 실패는 알리되 멈추지 않는다. 다음 심박이 다시 시도한다.
@@ -1975,10 +2112,10 @@ function observeTick() {
1975
2112
  /** 이 세션의 색인 항목이 아는 소속처 이름 — 없으면 ''. `indexAdoptCurrent()` 가 같은 폴더 규칙으로 채운다. */
1976
2113
  function observeTenant() {
1977
2114
  try {
1978
- const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
2115
+ const mine = mySessionId();
1979
2116
  if (!mine) return '';
1980
2117
  const e = indexRead()[mine];
1981
- return (e && e.tenant_id && typeof e.tenant === 'string') ? e.tenant : '';
2118
+ return (e && e.tenant_id) ? tenantArgOf(e) : '';
1982
2119
  } catch { return ''; }
1983
2120
  }
1984
2121
 
@@ -2060,8 +2197,10 @@ const TOOLS = [
2060
2197
  {
2061
2198
  name: 'list_my_tenants',
2062
2199
  description:
2063
- '**내가 속한 곳 목록.** 다른 도구가 요구하는 `tenant` 이름을 여기서 얻는다.\n'
2064
- + '이름을 모르는 채로 `tenant` 를 **지어내지 말 것** — 이것부터 부르고, 여럿이면 사용자에게 고르게 한다.\n'
2200
+ '**내가 속한 곳 목록.** 다른 도구가 요구하는 `tenant` 값(slug, 이름도 받는다)을 여기서 얻는다.\n'
2201
+ + '값을 **아직 모를 때만** 이것부터 부른다. 사용자 글에 `tenant:` 줄, `/t/<slug>/`, 또는 세션 uuid 가 있으면 '
2202
+ + '그것을 쓰고 **되묻지 마라** — 고르지 말라는 것은 없는 값을 추측하지 말라는 뜻이다.\n'
2203
+ + '여럿인데 사용자 글에 값이 없으면 사용자에게 고르게 한다.\n'
2065
2204
  + '지금 폴더에 대해 서버가 **본 것**을 함께 준다 — 「이 자리는 지금까지 어디에서 올라왔나」. '
2066
2205
  + '그것은 **기록이지 근거가 아니다**: 서버가 한 곳으로 좁히지 못하거나 자리를 못 읽으면 '
2067
2206
  + '그렇게 말한다. 좁혀지지 않으면 **사용자에게 물어라.**',
@@ -2127,7 +2266,7 @@ const TOOLS = [
2127
2266
  inputSchema: {
2128
2267
  type: 'object',
2129
2268
  properties: {
2130
- tenant: { type: 'string', description: '문서를 받을 소속처 이름. 키가 가리키는 곳과 다르면 거부된다(오발행 방지).' },
2269
+ tenant: { type: 'string', description: '문서를 받을 소속처 slug(이름도 받는다 — slug 는 list_my_tenants 가 알려 준다). 키가 가리키는 곳과 다르면 거부된다(오발행 방지).' },
2131
2270
  slug: { type: 'string', description: '파일 이름이 될 식별자. 영문 소문자·숫자·하이픈 (예: fixes-20260812)' },
2132
2271
  published_at: { type: 'string', description: 'YYYY-MM-DD (생략하면 오늘)' },
2133
2272
  publish: {
@@ -2151,7 +2290,7 @@ const TOOLS = [
2151
2290
  inputSchema: {
2152
2291
  type: 'object',
2153
2292
  properties: {
2154
- tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다.' },
2293
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다). 키와 다르면 거부된다.' },
2155
2294
  id: { type: 'integer' },
2156
2295
  published_at: { type: 'string', description: 'YYYY-MM-DD (생략하면 오늘)' },
2157
2296
  },
@@ -2166,7 +2305,7 @@ const TOOLS = [
2166
2305
  inputSchema: {
2167
2306
  type: 'object',
2168
2307
  properties: {
2169
- tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다.' },
2308
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다). 키와 다르면 거부된다.' },
2170
2309
  id: { type: 'integer' },
2171
2310
  reason: { type: 'string', description: '왜 내리는지. 서버 로그에 남아 다음 사람이 이유를 안다.' },
2172
2311
  },
@@ -2190,12 +2329,14 @@ const TOOLS = [
2190
2329
  name: 'update_report',
2191
2330
  description:
2192
2331
  '기존 보고서의 내용을 교체한다. 보고서 id·주소가 그대로라 **이미 고객에게 나간 열람 링크가 새 내용을 가리킨다**. ' +
2193
- '사람이 편집기에서 만진 문서는 서버가 거절한다 — 여기서 덮으면 다음 편집기 발행 때 그 변경이 조용히 사라지기 때문이다. ' +
2194
- '그때는 응답에 편집 주소가 실려 오니 사람에게 그 주소를 알려 주면 된다.',
2332
+ '사람이 편집기로 고친 문서도 교체할 수 있다(2026-08-25) — 대신 무엇을 덮게 되는지 목록이 먼저 말하고, 덮은 뒤 되돌릴 판이 응답에 온다. ' +
2333
+ '**그림 계약**: `assets` 를 **빼고** 보내면 서버가 저장된 그림을 그대로 유지한다. 전부 지우려면 `assets: []` 를 명시하고 ' +
2334
+ '`sections[].plates`·`chapters[].asset` 에서도 그 id 를 빼야 한다 — 가리키는 곳이 없는 id 는 422 로 거절된다. ' +
2335
+ '한 장만 바꾸려면 그 id 에 새 `source`/`file` 을 주고 나머지는 get_report 가 준 표식 그대로 두면 된다.',
2195
2336
  inputSchema: {
2196
2337
  type: 'object',
2197
2338
  properties: {
2198
- tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다(오발행 방지).' },
2339
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다). 키와 다르면 거부된다(오발행 방지).' },
2199
2340
  id: { type: 'integer' },
2200
2341
  dry_run: {
2201
2342
  type: 'boolean',
@@ -2228,7 +2369,7 @@ const TOOLS = [
2228
2369
  inputSchema: {
2229
2370
  type: 'object',
2230
2371
  properties: {
2231
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2372
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2232
2373
  title: { type: 'string', description: '한 줄 제목. 파일 이름이 된다(같은 제목이면 덮어쓴다).' },
2233
2374
  symptom: { type: 'string', description: '겪은 현상. 원인이 아니라 **눈에 보인 것**을 그대로.' },
2234
2375
  cause: { type: 'string', description: '원인. 모르면 비운다 — 지어내지 않는다.' },
@@ -2245,6 +2386,54 @@ const TOOLS = [
2245
2386
  required: ['tenant', 'title', 'symptom', 'fix'],
2246
2387
  },
2247
2388
  },
2389
+ {
2390
+ name: 'remember_decision',
2391
+ description:
2392
+ '**정한 것을 팀의 기억에 적는다** — 무엇을·누가·언제·왜·무슨 조건이면 다시 여나. '
2393
+ + '그 소속처 지식의 `decisions/` 에 적힌다.\n'
2394
+ + '언제 부르는가 — 사람이 이런 뜻으로 말할 때:\n'
2395
+ + ' "이렇게 가기로 했다" · "이 안으로 정했다" · "그건 안 하기로 했다" · "반려했다" · '
2396
+ + '"이 방향으로 합의했다" · "결정 남겨줘" · "왜 이렇게 정했는지 적어둬"\n'
2397
+ + '★ **`remember_case` 와 무엇이 다른가**: 사례는 **겪은 문제**(증상이 있다), 결정은 **정한 것**'
2398
+ + '(증상이 없다). 기획 결정·디자인 근거·반려 이유처럼 **고장이 아닌 일**은 전부 이쪽이다. '
2399
+ + '증상을 지어내 사례로 적지 말 것 — 그러면 다음 사람이 증상으로 찾다가 못 찾는다.\n'
2400
+ + '무엇을 적는가: **왜**가 이 장의 값이다(그때의 근거·수치·관찰). 그리고 **무슨 조건이면 다시 여나** — '
2401
+ + '이것을 비우면 그 결정은 「아직 열린 것」으로 읽힌다(닫혔는지 아무도 모르므로). '
2402
+ + '끝난 결정이면 "조건 없음(종결)"이라고 적을 것.\n'
2403
+ + '모르는 칸은 **비운다** — 지어내면 「적히지 않음」보다 나쁘다.',
2404
+ inputSchema: {
2405
+ type: 'object',
2406
+ properties: {
2407
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2408
+ title: { type: 'string', description: '한 줄 제목. 파일 이름이 된다. ⚠ 같은 제목이면 덮어쓴다 — **다른 결정이면 제목을 달리할 것**(서로 다른 결정은 합치지 않는다).' },
2409
+ what: { type: 'string', description: '무엇을 정했나 — 한 문장.' },
2410
+ who: { type: 'string', description: '정한 사람 또는 그 자리에 있던 사람. 모르면 비운다.' },
2411
+ when: { type: 'string', description: '정한 날짜(예: 2026-08-27). 모르면 비운다 — 오늘로 지어내지 말 것.' },
2412
+ why: { type: 'string', description: '그때의 근거 — 수치·관찰. 이 칸이 이 장의 값이다.' },
2413
+ reopen: { type: 'string', description: '무슨 조건이면 다시 여나. **비우면 「열린 결정」으로 읽힌다.** 끝났으면 "조건 없음(종결)".' },
2414
+ status: { type: 'string', description: '상태 — "유효"(기본) · "뒤집힘 → decisions/<그 결정>" · "조건 충족 → <무엇이> (날짜)".' },
2415
+ tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8). ★ 이것이 있어야 이 결정이 「일」 후보 계산에 들어간다.' },
2416
+ },
2417
+ required: ['tenant', 'title', 'what'],
2418
+ },
2419
+ },
2420
+ {
2421
+ name: 'list_breaks',
2422
+ description:
2423
+ '**대화에서 정한 것이 문서에 닿았나** — 연결된 채널(Slack)에서 「~하기로 했다」가 선 뒤 '
2424
+ + '그 소속처의 결정 문서가 움직이지 않은 것을 센다.\n'
2425
+ + '언제 부르는가: 일을 시작할 때(「지금 걸려 있는 게 뭐지」) · 사람이 "요즘 대화로만 정하고 '
2426
+ + '문서가 안 남는다"고 할 때 · 결정을 적기 전에(이미 신호가 서 있으면 그것부터).\n'
2427
+ + '**0 이면 0 이라고 말하고, 창구가 안 붙은 것(채널 0)과 신호 없음을 갈라 말한다.** '
2428
+ + '자동 처방은 없다 — 열린 신호를 닫는 길은 사람이 결정을 적는 것(remember_decision)이다.',
2429
+ inputSchema: {
2430
+ type: 'object',
2431
+ properties: {
2432
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2433
+ },
2434
+ required: ['tenant'],
2435
+ },
2436
+ },
2248
2437
  {
2249
2438
  name: 'search_cases',
2250
2439
  description:
@@ -2260,7 +2449,7 @@ const TOOLS = [
2260
2449
  inputSchema: {
2261
2450
  type: 'object',
2262
2451
  properties: {
2263
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2452
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2264
2453
  q: { type: 'string', description: '증상 문장. 원인이 아니라 현상으로.' },
2265
2454
  limit: { type: 'integer', minimum: 1, maximum: 20, description: '몇 건까지(기본 5).' },
2266
2455
  fast: { type: 'boolean', description: '의미 확장을 건너뛴다(빠르지만 덜 찾는다). 기본은 확장 켬 — '
@@ -2278,7 +2467,7 @@ const TOOLS = [
2278
2467
  inputSchema: {
2279
2468
  type: 'object',
2280
2469
  properties: {
2281
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2470
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2282
2471
  path: { type: 'string', description: 'search_cases 결과의 path 그대로(예: cases/….md).' },
2283
2472
  },
2284
2473
  required: ['tenant', 'path'],
@@ -2294,7 +2483,7 @@ const TOOLS = [
2294
2483
  inputSchema: {
2295
2484
  type: 'object',
2296
2485
  properties: {
2297
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2486
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2298
2487
  path: { type: 'string', description: '사례 경로(cases/….md).' },
2299
2488
  verdict: { type: 'string', enum: ['applied', 'refuted', 'irrelevant'], description: '사람이 말한 판정.' },
2300
2489
  note: { type: 'string', description: '한 줄 메모(선택, 200자).' },
@@ -2312,7 +2501,7 @@ const TOOLS = [
2312
2501
  inputSchema: {
2313
2502
  type: 'object',
2314
2503
  properties: {
2315
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2504
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2316
2505
  title: { type: 'string', description: '검토 제목 — 메일 제목 「충돌 — <제목>」의 <제목> 그대로.' },
2317
2506
  verdict: { type: 'string', enum: ['applied', 'refuted'], description: '이 세션의 판정.' },
2318
2507
  type: { type: 'string', enum: ['contradiction', 'duplicate', 'missing-page'], description: '검토 종류(선택 — 충돌=contradiction·중복=duplicate·문서 필요=missing-page). 비우면 서버가 셋 중 있는 것을 고른다.' },
@@ -2335,7 +2524,7 @@ const TOOLS = [
2335
2524
  inputSchema: {
2336
2525
  type: 'object',
2337
2526
  properties: {
2338
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2527
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2339
2528
  file: { type: 'integer', minimum: 1, description: '문서 번호(목록의 #). 없으면 목록.' },
2340
2529
  },
2341
2530
  required: ['tenant'],
@@ -2350,7 +2539,7 @@ const TOOLS = [
2350
2539
  inputSchema: {
2351
2540
  type: 'object',
2352
2541
  properties: {
2353
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2542
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2354
2543
  page: {
2355
2544
  type: 'integer', minimum: 1, maximum: 1000,
2356
2545
  description: '목록 page(1부터, 최대 1000). 응답이 알려 준 next를 넣는다.',
@@ -2375,7 +2564,7 @@ const TOOLS = [
2375
2564
  inputSchema: {
2376
2565
  type: 'object',
2377
2566
  properties: {
2378
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2567
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2379
2568
  uuid: { type: 'string', description: '세션 id. `--resume` 에 쓰는 그 값.' },
2380
2569
  title: { type: 'string', description: '목록에서 알아볼 한 줄. 생략하면 빈 값.' },
2381
2570
  dry_run: { type: 'boolean', description: '참이면 아무것도 저장하지 않고 검열 결과만 돌려준다.' },
@@ -2391,6 +2580,7 @@ const TOOLS = [
2391
2580
  name: 'resume_session',
2392
2581
  description:
2393
2582
  '서버에 있는 대화를 **지금 이 창이 이어받는다.** 포털의 「이어받기」 버튼이 복사해 주는 문구가 이 도구를 부른다.\n' +
2583
+ '붙여넣기에 tenant·uuid 가 있으면 **지금 바로 이 도구를 부른다.** list_my_tenants 로 소속처를 되묻지 마라.\n' +
2394
2584
  '한 번에 세 가지를 한다 — ① 그 대화의 요지를 받아 **여기서 바로 읽는다**(창을 새로 열 필요가 없다), ' +
2395
2585
  '② 원본 jsonl 을 이 기계의 제자리에 놓는다(나중에 `claude --resume <uuid>` 로 원본을 열 수 있다), ' +
2396
2586
  '③ 이 기계가 그 대화를 쥐었다고 서버에 알린다(다른 기계가 「누가 이어가는지」를 본다).\n' +
@@ -2399,7 +2589,7 @@ const TOOLS = [
2399
2589
  inputSchema: {
2400
2590
  type: 'object',
2401
2591
  properties: {
2402
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2592
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다). 사용자 글의 tenant: 줄을 그대로 쓴다. 값이 있을 때 list_my_tenants 로 되묻지 않는다.' },
2403
2593
  uuid: { type: 'string', description: '이어받을 세션 id.' },
2404
2594
  into: { type: 'string', description: '원본 파일을 놓을 자리(생략하면 지금 폴더). '
2405
2595
  + '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 원본을 열 자리다.' },
@@ -2419,7 +2609,7 @@ const TOOLS = [
2419
2609
  inputSchema: {
2420
2610
  type: 'object',
2421
2611
  properties: {
2422
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2612
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2423
2613
  uuid: { type: 'string', description: '받을 세션 id.' },
2424
2614
  into: { type: 'string', description: '어느 디렉터리에서 이어 갈 것인가(생략하면 지금 폴더). '
2425
2615
  + '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 이어 갈 자리다.' },
@@ -2447,7 +2637,7 @@ const TOOLS = [
2447
2637
  inputSchema: {
2448
2638
  type: 'object',
2449
2639
  properties: {
2450
- tenant: { type: 'string', description: '소속처 이름. 모르면 list_my_tenants 로 먼저 확인한다' },
2640
+ tenant: { type: 'string', description: '소속처 slug(이름도 받는다). 모르면 list_my_tenants 로 먼저 확인한다' },
2451
2641
  to: { type: 'string', description: '받는 쪽 — 세포 이름 · 세션 uuid · 또는 brain' },
2452
2642
  body: { type: 'string', description: '할 말' },
2453
2643
  subject: { type: 'string', description: '한 줄 제목(선택)' },
@@ -2693,7 +2883,7 @@ let tenantSaidAt = 0;
2693
2883
  * **한 번도 안 돌았다**(`world-<root>.json` 의 `cursor` 가 null 이었다).
2694
2884
  */
2695
2885
  function tenantNotice() {
2696
- const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
2886
+ const mine = mySessionId();
2697
2887
  if (!mine) return '';
2698
2888
  try {
2699
2889
  if (indexRead()[mine]?.tenant_id) return ''; // 붙어 있다 — 조용하다
@@ -2797,7 +2987,7 @@ let brainCursor = null; // null = 아직 색인에서 안 읽었다
2797
2987
  const brainQueue = []; // 아직 안 말한 모순
2798
2988
 
2799
2989
  async function brainPull() {
2800
- const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
2990
+ const mine = mySessionId();
2801
2991
  if (!mine) return;
2802
2992
  const e = indexRead()[mine];
2803
2993
  if (!e?.tenant || !e?.tenant_id) return; // 아직 어느 소속처인지 모른다
@@ -2812,7 +3002,7 @@ async function brainPull() {
2812
3002
  const cwd = relHome(process.cwd());
2813
3003
  const fresh = [];
2814
3004
  for (let page = 0; page < 10; page++) {
2815
- const r = await api('GET', '/api/world?tenant=' + encodeURIComponent(e.tenant)
3005
+ const r = await api('GET', '/api/world?tenant=' + encodeURIComponent(tenantArgOf(e))
2816
3006
  + '&since=' + brainCursor + '&cwd=' + encodeURIComponent(cwd) + '&limit=' + BRAIN_PAGE);
2817
3007
  const evs = Array.isArray(r?.events) ? r.events : [];
2818
3008
  for (const ev of evs) if (ev?.kind === 'review' && ev?.ref) fresh.push(ev);
@@ -2960,10 +3150,18 @@ function introNameOf(line) {
2960
3150
  }
2961
3151
  let nameCache = null; // { file, size, name }
2962
3152
  function myAgentName() {
2963
- const uuid = process.env.CLAUDE_CODE_SESSION_ID || '';
3153
+ const uuid = mySessionId();
2964
3154
  if (!/^[0-9a-f-]{36}$/i.test(uuid)) return null;
2965
3155
  const f = transcriptOf(uuid);
2966
- if (!f) return null;
3156
+ if (!f) {
3157
+ /*
3158
+ * Cursor 활성 대화 — 전사도 ListAgents 도 없다. 이름은 별칭일 뿐 열쇠는 uuid 이므로
3159
+ * (mail 레일 계약), 출처가 읽히는 결정적 별칭을 낸다: cursor-<uuid 앞 2자>.
3160
+ * 하네스 이름(app-2c)과 같은 문법이라 받는 쪽 화면·회신 규칙이 그대로 통한다.
3161
+ */
3162
+ if (uuid === cursorActiveComposer()) return 'cursor-' + uuid.slice(0, 2);
3163
+ return null;
3164
+ }
2967
3165
  let st;
2968
3166
  try { st = fs.statSync(f); } catch { return null; }
2969
3167
  if (nameCache?.file === f && nameCache.size === st.size) return nameCache.name;
@@ -3013,15 +3211,15 @@ function myAgentName() {
3013
3211
  */
3014
3212
  const mailTenants = new Map(); // tenant 이름 → tenant_id(모르면 0). mail 도구가 성공할 때 적는다
3015
3213
  function mailReady() {
3016
- const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
3214
+ const mine = mySessionId();
3017
3215
  if (!mine) return null;
3018
3216
  const cred = credential();
3019
3217
  if (!cred || cred.kind !== 'user') return null; // 세계 장부와 같다 — 서버가 사람 토큰만 받는다
3020
3218
  const e = indexRead()[mine];
3021
3219
  const set = new Map(mailTenants);
3022
- if (e?.tenant && !set.has(e.tenant)) set.set(e.tenant, Number(e.tenant_id) || 0);
3220
+ { const a = tenantArgOf(e); if (a && !set.has(a)) set.set(a, Number(e.tenant_id) || 0); }
3023
3221
  if (!set.size) return null; // 아직 어느 소속처에도 주소를 안 댔다
3024
- return { mine, tenants: set, indexTenant: e?.tenant || '' , indexEntry: e };
3222
+ return { mine, tenants: set, indexTenant: tenantArgOf(e), indexEntry: e };
3025
3223
  }
3026
3224
 
3027
3225
  /**
@@ -3037,7 +3235,8 @@ async function mailRegister(tenant, mine, force = false) {
3037
3235
  tenant,
3038
3236
  session: mine,
3039
3237
  name: name || '',
3040
- client: 'claude-code',
3238
+ // 신원이 어디서 왔는지 그대로 말한다 — Cursor 대화에 claude-code 이름표를 달지 않는다.
3239
+ client: mine === cursorActiveComposer() ? 'cursor' : 'claude-code',
3041
3240
  cwd: relHome(process.cwd()),
3042
3241
  });
3043
3242
  }
@@ -3078,7 +3277,7 @@ async function mailPull() {
3078
3277
  const r = await api('GET', '/api/sessions/' + encodeURIComponent(mine)
3079
3278
  + '?tenant=' + encodeURIComponent(indexTenant) + '&no_body=1');
3080
3279
  if (r?.bake) {
3081
- indexNote(mine, { tenant: indexTenant, tenant_id: indexEntry.tenant_id, bake: r.bake });
3280
+ indexNote(mine, { tenant: indexEntry.tenant, tenant_id: indexEntry.tenant_id, bake: r.bake });
3082
3281
  }
3083
3282
  } catch { /* 옛 서버는 이 칸이 없다 — 없으면 상태바가 「모름」으로 둔다 */ }
3084
3283
  }
@@ -3106,7 +3305,7 @@ async function mailPullOne(tenant, mine, indexEntry) {
3106
3305
  // 색인에는 색인의 소속처 것만 적는다(다른 곳의 커서는 프로세스 안에서만 — 잃어도 ack 가 중복을 막는다).
3107
3306
  // 빈 폴링은 수신이 아니다 — 위 백신 커서와 같은 규율.
3108
3307
  if (indexEntry?.tenant_id) {
3109
- indexNote(mine, { tenant, tenant_id: indexEntry.tenant_id, mail_cursor: cursor },
3308
+ indexNote(mine, { tenant: indexEntry.tenant, tenant_id: indexEntry.tenant_id, mail_cursor: cursor },
3110
3309
  rows.length ? 'rx' : '');
3111
3310
  }
3112
3311
 
@@ -3337,15 +3536,20 @@ async function callTool(name, args = {}) {
3337
3536
  const mark = (t) => (t.has_logo === false ? ' · 로고 없음' : '');
3338
3537
 
3339
3538
  if (list.length === 1) {
3340
- return `속한 곳이 하나입니다 — **${list[0].name}**${mark(list[0])}\n`
3341
- + '다른 도구의 `tenant` 에 이 이름을 그대로 쓰세요.'
3539
+ // 옛 서버는 slug 칸이 없다 — 그때는 이름만 말한다(없는 값을 지어내지 않는다).
3540
+ const slug0 = typeof list[0].slug === 'string' && list[0].slug !== '' ? list[0].slug : '';
3541
+ return `속한 곳이 하나입니다 — **${list[0].name}**${slug0 ? ` (\`${slug0}\`)` : ''}${mark(list[0])}\n`
3542
+ + (slug0 ? '다른 도구의 `tenant` 에는 slug 를 쓰세요(이름도 받는다).'
3543
+ : '다른 도구의 `tenant` 에 이 이름을 그대로 쓰세요.')
3342
3544
  + (says ? `\n\n${says}` : '');
3343
3545
  }
3344
3546
  const lines = list.map((t) => {
3345
3547
  const when = t.last_used_at ? String(t.last_used_at).slice(0, 10) : '기록 없음';
3346
- return ` · ${t.name}${seen(t)}${mark(t)} · 최근 ${when}`;
3548
+ const sl = typeof t.slug === 'string' && t.slug !== '' ? ` (\`${t.slug}\`)` : '';
3549
+ return ` · ${t.name}${sl}${seen(t)}${mark(t)} · 최근 ${when}`;
3347
3550
  });
3348
3551
  return `속한 곳 ${list.length}곳입니다. **사용자에게 어디로 할지 물어보세요** — 고르지 마세요.\n`
3552
+ + '이미 이 턴의 사용자 글에 slug 나 uuid 가 있으면 그것이 답입니다. 묻는 것은 값이 없을 때뿐입니다.\n'
3349
3553
  + `${lines.join('\n')}\n`
3350
3554
  + (says ? `\n${says}` : '');
3351
3555
  }
@@ -3444,6 +3648,13 @@ async function callTool(name, args = {}) {
3444
3648
  head.push(`[정본 ${owner} · 본문 판 body_rev=${r.body_rev ?? '?'}`
3445
3649
  + (r.sha256 ? ` · 지금 발행물 ${r.bytes}B sha256:${String(r.sha256).slice(0, 12)}…` : '')
3446
3650
  + `] — 고칠 때는 update_report 에 body_rev 를 함께 실으면, 그 사이 누가 고쳤을 때 409 로 멈춘다.`);
3651
+ // 그림은 서버가 보관한다(바이트는 에이전트를 안 지난다) — 되돌려 보낼 때의 계약을 여기서 말한다(#34).
3652
+ const kept = Array.isArray(r.data?.assets)
3653
+ ? r.data.assets.filter((a) => typeof a?.data === 'string' && a.data.startsWith('(생략')).length : 0;
3654
+ if (kept > 0) {
3655
+ head.push(`그림 ${kept}장은 서버가 보관 중 — update_report 에 assets 를 **빼고** 보내면 그대로 유지, `
3656
+ + '전부 지우려면 `assets: []`(sections[].plates·chapters[].asset 에서도 빼야 한다), 바꿀 장만 새 source/file 로.');
3657
+ }
3447
3658
  // 데이터가 없으면 서버가 outline 을 함께 준다 — 그것이 지금 고객이 보는 것의 유일한 요약이다.
3448
3659
  if (r.data === null && r.outline) head.push(outlineText(r.outline));
3449
3660
  return `${head.join('\n')}\n${JSON.stringify(r.data, null, 2)}`;
@@ -3517,6 +3728,39 @@ async function callTool(name, args = {}) {
3517
3728
  if (!facts.length) return '이 저장소에서 최근에 남이 바꾼 것은 없다.';
3518
3729
  return `이 저장소에서 최근에 바뀐 것:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
3519
3730
  }
3731
+ case 'list_breaks': {
3732
+ const r = await api('GET', '/api/breaks?tenant=' + encodeURIComponent(args.tenant));
3733
+ const c = r.counts || {};
3734
+ const lines = [`${r.tenant} — 대화에서 정한 것이 문서에 닿았나`];
3735
+ if (!r.wired) {
3736
+ // 창구가 안 붙은 것과 신호 0 은 다른 말이다 — 0 을 「깨끗함」으로 읽게 두지 않는다.
3737
+ lines.push('아직 연결된 채널이 없다(신호 0 과 다른 상태) — 관리 화면에서 채널을 소속처에 연결해야 세기 시작한다.');
3738
+ return lines.join('\n');
3739
+ }
3740
+ lines.push(`열림 ${c.open ?? 0} · 문서에 닿음 ${c.answered ?? 0} · 사람 판정 맞다 ${c.confirmed ?? 0}/아니다 ${c.dismissed ?? 0} (채널 ${r.channels}곳)`);
3741
+ for (const b of r.open || []) {
3742
+ lines.push(` · [${String(b.signal_at).slice(0, 10)} ${b.channel}] ${b.excerpt} (문서 마감 ${String(b.deadline_at).slice(0, 10)})`);
3743
+ }
3744
+ if (!(r.open || []).length) lines.push('지금 열린 신호는 없다 — 0건은 0건이다.');
3745
+ else lines.push('여는 길: 그 결정을 remember_decision 으로 적으면 다음 읽기에서 닫힌다.');
3746
+ return lines.join('\n');
3747
+ }
3748
+ case 'remember_decision': {
3749
+ const r = await api('POST', '/api/wiki/decision?tenant=' + encodeURIComponent(args.tenant), {
3750
+ title: args.title, what: args.what,
3751
+ who: args.who || '', when: args.when || '', why: args.why || '',
3752
+ reopen: args.reopen || '', status: args.status || '', tags: args.tags || [],
3753
+ });
3754
+ const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
3755
+ // 안 적은 것을 **그렇게 말한다** — 조용히 열린 결정을 만들거나 계산 밖에 두지 않는다.
3756
+ if (!args.reopen) {
3757
+ lines.push('재개 조건을 안 적어 이 결정은 「아직 열린 것」으로 읽힌다. 끝난 결정이면 reopen 에 "조건 없음(종결)" 을 적을 것.');
3758
+ }
3759
+ if (!args.tags || !args.tags.length) {
3760
+ lines.push('갈래(tags)가 없어 이 결정은 「일」 후보 계산에 안 들어간다.');
3761
+ }
3762
+ return lines.join('\n');
3763
+ }
3520
3764
  case 'remember_case': {
3521
3765
  const r = await api('POST', '/api/wiki/case?tenant=' + encodeURIComponent(args.tenant), {
3522
3766
  title: args.title, symptom: args.symptom, cause: args.cause || '', fix: args.fix,
@@ -3572,7 +3816,8 @@ async function callTool(name, args = {}) {
3572
3816
  + r.hits.map((h) => `· ${h.path} [${h.score}점]${h.status ? `\n ⚠ 상태: ${h.status}` : ''}`
3573
3817
  + `\n ${h.excerpt}`).join('\n')
3574
3818
  + '\n전문은 get_case(tenant, path) — 처방·상태까지 그대로 읽는다.'
3575
- + (Number.isInteger(r.tenant_id) ? ` 사람 화면: wiki?t=${r.tenant_id}&p=<path>` : '');
3819
+ + (typeof r.human_url === 'string' && r.human_url !== '' ? ` 사람 화면: ${r.human_url}?p=<path>`
3820
+ : Number.isInteger(r.tenant_id) ? ` 사람 화면: wiki?t=${r.tenant_id}&p=<path>` : '');
3576
3821
  }
3577
3822
  case 'get_case': {
3578
3823
  // 서버 창구(/api/wiki/page)는 이미 있었다 — MCP 가 안 부르고 있었을 뿐이다(2026-08-25).
@@ -3662,7 +3907,7 @@ async function callTool(name, args = {}) {
3662
3907
  // **본 것을 적어 둔다.** 이 짝(uuid → 고객사 id)이 없으면 심박을 보낼 수가 없다 —
3663
3908
  // 프레임에는 고객사 이름이 아니라 id 가 실린다.
3664
3909
  for (const s of sessions) {
3665
- if (!s.deleted) indexNote(s.uuid, { tenant: r.tenant, tenant_id: s.tenant_id, head_id: s.head_id, head_ms: s.head_ms });
3910
+ if (!s.deleted) indexNote(s.uuid, { tenant: r.tenant, tenant_id: s.tenant_id, head_id: s.head_id, head_ms: s.head_ms, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) });
3666
3911
  }
3667
3912
  const p = r.pagination && typeof r.pagination === 'object' ? r.pagination : null;
3668
3913
  let count = `세션 ${sessions.length}건`;
@@ -3738,7 +3983,7 @@ async function callTool(name, args = {}) {
3738
3983
  const q = `?tenant=${encodeURIComponent(args.tenant)}`;
3739
3984
  const r = await api('GET',
3740
3985
  `/api/sessions/${encodeURIComponent(args.uuid)}${q}&no_body=1&digest=1&digest_chars=${chars}`);
3741
- indexNote(args.uuid, { tenant: r.tenant || args.tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms }, 'rx');
3986
+ indexNote(args.uuid, { tenant: r.tenant || args.tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) }, 'rx');
3742
3987
 
3743
3988
  // 원본도 제자리에 놓는다 — **덮지 않는다**(pull_session 과 같은 이유).
3744
3989
  const into = args.into || process.cwd();
@@ -3799,7 +4044,7 @@ async function callTool(name, args = {}) {
3799
4044
  `/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}&raw=1`,
3800
4045
  target);
3801
4046
  const m = limMeta(headers);
3802
- indexNote(args.uuid, { tenant: args.tenant, tenant_id: m.tenant_id, head_id: m.head_id, head_ms: m.head_ms }, 'rx');
4047
+ indexNote(args.uuid, { tenant: args.tenant, tenant_id: m.tenant_id, head_id: m.head_id, head_ms: m.head_ms, ...(m.tenant_slug ? { tenant_slug: m.tenant_slug } : {}) }, 'rx');
3803
4048
  // 헤더를 안 주는 옛 서버면 **지어내지 않는다** — 받은 바이트만 말한다.
3804
4049
  const size = `${((m.bytes ?? bytes) / 1024).toFixed(0)}KB`;
3805
4050
  return `받았습니다 — ${args.uuid.slice(0, 8)} · ${m.lines === null ? '' : `${m.lines}줄 · `}${size}\n`
@@ -3808,10 +4053,10 @@ async function callTool(name, args = {}) {
3808
4053
  + (m.redacted ? `※ 가린 자리 ${m.redacted}곳 — 도구 실행 결과는 안 실려 옵니다. 필요하면 다시 실행하세요.` : '');
3809
4054
  }
3810
4055
  case 'mail': {
3811
- const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
4056
+ const mine = mySessionId();
3812
4057
  if (!mine) {
3813
4058
  return '이 세션의 번호를 모른다 — 주소를 댈 수 없어 보낼 수 없다'
3814
- + '(CLAUDE_CODE_SESSION_ID 가 없는 환경이다).';
4059
+ + '(CLAUDE_CODE_SESSION_ID 도, Cursor 활성 대화도 없는 환경이다).';
3815
4060
  }
3816
4061
  /*
3817
4062
  * 주소가 없으면 먼저 댄다. **이름이 없으면 서버가 409 로 거절**하므로,
@@ -3853,7 +4098,7 @@ async function callTool(name, args = {}) {
3853
4098
  // ⚠ 서버가 tenant_id 를 아직 안 주는 판(재배포 전)이어도 **받기는 이름만으로 된다** —
3854
4099
  // GET /api/mail 은 tenant 이름으로 묻는다. 색인(indexNote)만 id 가 있어야 적힌다.
3855
4100
  mailTenants.set(args.tenant, r?.tenant_id || 0); // 이 소속처에서도 받는다 — 합집합, 폴백 아님
3856
- if (r?.tenant_id && !indexRead()[mine]?.tenant) indexNote(mine, { tenant: args.tenant, tenant_id: r.tenant_id });
4101
+ if (r?.tenant_id && !indexRead()[mine]?.tenant) indexNote(mine, { tenant: args.tenant, tenant_id: r.tenant_id, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) });
3857
4102
  return heldNote + `«${r.to}» 앞으로 보냈다 — 실 ${r.thread} · ${r.turn}번째.\n`
3858
4103
  + `받는 쪽이 보게 될 글자는 이것이다:\n${r.preview}\n`
3859
4104
  + (r.says ? `${r.says}\n` : '')
@@ -3971,13 +4216,15 @@ async function handle(line) {
3971
4216
  '',
3972
4217
  '「남겨줘 / 기억해줘 / 업데이트 / 갱신 / 동기화 / 올려둬」는 **무엇을 남기느냐로 갈린다**:',
3973
4218
  '· 알아낸 것 하나(증상과 처방) → remember_case (같은 제목이면 덮어쓰므로 갱신도 이것)',
4219
+ '· 정한 것(증상이 없다 — 기획·디자인의 판단, 반려, "이렇게 가기로 했다") → remember_decision',
3974
4220
  '· 지금 이 대화 전체 → push_session (다른 기계가 이어받게)',
3975
4221
  '· 고객에게 나갈 문서 → publish_report / update_report',
3976
4222
  '무엇인지 모호하면 묻기 전에 search_cases 로 먼저 뒤져 본다 — 이미 있으면 갱신이 답이다.',
3977
4223
  '',
3978
4224
  '자격은 **`login` 으로 받는 SSO 하나면 된다** — 그것으로 내가 속한 소속처 전부에 닿는다.',
3979
4225
  '키(rpk_)도 쓸 수 있지만 **한 소속처에만** 매이고, 발행할 때만 그쪽이 먼저 쓰인다.',
3980
- '어디로 갈지는 tenant 에 소속처 **이름**으로 적는다. 이름을 모르면 지어내지 말고 사용자에게 묻는다 —',
4226
+ '어디로 갈지는 tenant 에 소속처 **slug** 로 적는다(이름도 받는다 — 모를 때만 list_my_tenants).',
4227
+ '사용자 글에 tenant·uuid 가 있으면 그대로 쓰고 되묻지 않는다. 값을 모르면 지어내지 말고 사용자에게 묻는다 —',
3981
4228
  '내 소속이 아닌 곳은 존재도 안 보이므로, 404 는 "없다"가 아니라 "내 것이 아니다"일 수 있다.',
3982
4229
  ].join('\n'),
3983
4230
  };
@@ -4043,6 +4290,58 @@ const IS_MAIN = (() => {
4043
4290
  * 자리는 판이 바뀌어도 안 움직이는 `~/.config/genetics/bin/gcell` 이다. 훅이 그 경로를
4044
4291
  * 가리키므로, 갈아 끼워도 훅을 다시 걸 필요가 없다.
4045
4292
  */
4293
+ /*
4294
+ * ── `gcell` 을 PATH 에 건다 (078 문턱 ④) ──────────────────────────────────
4295
+ * 바이너리는 `~/.config/genetics/bin` 에 깔리는데 그 폴더는 어느 셸의 PATH 에도 없다. 그래서
4296
+ * 사람에게 안내하는 실행법이 「api.env 를 grep 해 cut 한 경로」 두 줄이었다 — 파이프를 읽을 줄
4297
+ * 아는 사람만 세포에게 물을 수 있었다(SKILL.md 실측 2026-08-27). 심링크 하나면 `gcell` 로 끝난다.
4298
+ *
4299
+ * 자리 고르기는 **순수 함수**로 뺀다(smoke 가 떼어내 잰다): PATH 에 있고 · 실재하고 · 쓸 수 있는
4300
+ * 첫 후보. 없는 폴더를 만들거나 rc 파일을 고치지 않는다 — 남의 셸 설정에 손대는 설치는 못 믿는다.
4301
+ */
4302
+ const CELL_LINK_CANDIDATES = ['/opt/homebrew/bin', '/usr/local/bin', path.join(os.homedir(), '.local/bin')];
4303
+ function cellLinkDir(pathEnv, candidates, exists, writable) {
4304
+ const onPath = new Set(String(pathEnv || '').split(path.delimiter).filter(Boolean));
4305
+ for (const d of candidates) {
4306
+ if (onPath.has(d) && exists(d) && writable(d)) return d;
4307
+ }
4308
+ return null;
4309
+ }
4310
+ function cellLinkOnPath() {
4311
+ const dir = cellLinkDir(process.env.PATH, CELL_LINK_CANDIDATES,
4312
+ (d) => { try { return fs.statSync(d).isDirectory(); } catch { return false; } },
4313
+ (d) => { try { fs.accessSync(d, fs.constants.W_OK); return true; } catch { return false; } });
4314
+ if (!dir) {
4315
+ return `PATH 에 걸 자리를 못 찾았다(${CELL_LINK_CANDIDATES.join(' · ')} 중 PATH 에 있고 쓸 수 있는 곳이 없다) — 전체 경로 ${CELL_PATH} 로 부를 것.`;
4316
+ }
4317
+ const link = path.join(dir, 'gcell');
4318
+ try {
4319
+ const cur = fs.lstatSync(link);
4320
+ if (cur.isSymbolicLink() && fs.readlinkSync(link) === CELL_PATH) return ''; // 이미 걸려 있다 — 매 부팅 말하면 소음이다
4321
+ if (!cur.isSymbolicLink()) return `${link} 에 다른 것이 있어 안 건드렸다 — 전체 경로 ${CELL_PATH} 로 부를 것.`;
4322
+ fs.unlinkSync(link); // 우리 것이 아닌 심링크가 아니라 **옛 자리를 가리키는 우리 심링크**다 — 갈아 끼운다
4323
+ } catch { /* 없다 — 새로 건다 */ }
4324
+ try {
4325
+ fs.symlinkSync(CELL_PATH, link);
4326
+ return `\`gcell\` 을 PATH 에 걸었다(${link}).`;
4327
+ } catch (e) {
4328
+ return `\`gcell\` 을 PATH 에 못 걸었다(${link}): ${e.message} — 전체 경로 ${CELL_PATH} 로 부를 것.`;
4329
+ }
4330
+ }
4331
+ function cellUnlinkOnPath() {
4332
+ const out = [];
4333
+ for (const d of CELL_LINK_CANDIDATES) {
4334
+ const link = path.join(d, 'gcell');
4335
+ try {
4336
+ if (fs.lstatSync(link).isSymbolicLink() && fs.readlinkSync(link) === CELL_PATH) {
4337
+ fs.unlinkSync(link);
4338
+ out.push(`PATH 의 심링크를 걷었다: ${link}`);
4339
+ }
4340
+ } catch { /* 없다 */ }
4341
+ }
4342
+ return out;
4343
+ }
4344
+
4046
4345
  function cellInstall({ hooks = true, ifRepo = false } = {}) {
4047
4346
  const out = [];
4048
4347
  const t = cellTarget();
@@ -4067,6 +4366,9 @@ function cellInstall({ hooks = true, ifRepo = false } = {}) {
4067
4366
  if (got !== want) return { ok: false, out: ['세포를 깔았는데 지문이 안 맞는다 — 쓰지 않는다.'] };
4068
4367
  out.push(`세포를 ${CELL_PATH} 에 깔았다(${VERSION} · ${want.slice(0, 12)}…).`);
4069
4368
  }
4369
+ // 판이 같아도 링크는 맞춘다 — 옛 판이 링크 없이 깔린 기계가 있다(이 기계가 그랬다).
4370
+ const linked = cellLinkOnPath();
4371
+ if (linked) out.push(linked);
4070
4372
  if (hooks) {
4071
4373
  // `ifRepo` 는 **자동으로 부를 때만** 준다 — 사람이 `install_cell` 을 부른 자리는 자기가
4072
4374
  // 정한 곳이지만, 부팅마다 도는 걸음은 git 저장소가 아닌 폴더에 설정을 만들면 안 된다.
@@ -4099,6 +4401,8 @@ function cellUninstall() {
4099
4401
  } else {
4100
4402
  out.push('깔린 세포가 없다.');
4101
4403
  }
4404
+ // PATH 에 건 심링크도 걷는다 — 남겨 두면 `gcell` 이 없는 파일을 가리킨다(dangling).
4405
+ out.push(...cellUnlinkOnPath());
4102
4406
  // 자격 파일 옆의 경로 줄도 걷는다 — 남겨 두면 다음 판이 없는 것을 가리킨다.
4103
4407
  try {
4104
4408
  const env = path.join(os.homedir(), '.config/genetics/api.env');