genetics-mcp 2.12.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
package/bin/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "aarch64-apple-darwin": "120fb6e8d92e357896bd9aae54e331d6357ba0e58180e07627790b0f7f80205c",
3
- "aarch64-unknown-linux-musl": "afb8f44bab3d89afc13d48e73a289a3350220eb1f972fc6d2dc3b056a0f08f03",
4
- "x86_64-apple-darwin": "bd98d24f456e3f86b2dfa3fe160cee5e9f6cf5a4058935a96b3fcf9ca5f6e599",
5
- "x86_64-unknown-linux-musl": "575d69b978238775553d42341bac96ca2769ce61d67d740bc6fc6b855df7ac8f"
2
+ "aarch64-apple-darwin": "50e3f5feb6b5f3820063b9698bd947c5e12ac526434c0dd9373981b0e38abb58",
3
+ "aarch64-unknown-linux-musl": "f6d1966397744977074fbbd912850eafd6f31a3f7adc560a06aa88cf070e94ea",
4
+ "x86_64-apple-darwin": "29484f84cd380dc8612880e657bc8d10b3e36b37d3e5a34ce204becaec1dd378",
5
+ "x86_64-unknown-linux-musl": "39b43a90e274a7c8d911c5179d3ef538983968af600f0c35230d32be8bb9353b"
6
6
  }
Binary file
Binary file
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.
@@ -700,6 +700,7 @@ function limMeta(h) {
700
700
  head_id: h.get('x-lim-head-id'), head_ms: num('x-lim-head-ms'),
701
701
  lines: num('x-lim-lines'), bytes: num('x-lim-bytes'),
702
702
  redacted: num('x-lim-redacted'), tenant_id: num('x-lim-tenant-id'),
703
+ tenant_slug: h.get('x-lim-tenant-slug'),
703
704
  };
704
705
  }
705
706
 
@@ -1257,6 +1258,9 @@ function findTranscript(uuid) {
1257
1258
  const here = path.join(transcriptDir(process.cwd()), `${uuid}.jsonl`);
1258
1259
  if (fs.existsSync(here)) return here;
1259
1260
  const root = path.join(os.homedir(), '.claude', 'projects');
1261
+ // Claude Code 가 없는 기계(Cursor 만 깔린 곳)에는 이 디렉터리 자체가 없다 —
1262
+ // 「전사 없음」이지 오류가 아니다. 던지면 mail 등 도구가 통째로 죽는다(smoke 실측).
1263
+ if (!fs.existsSync(root)) return null;
1260
1264
  for (const d of fs.readdirSync(root, { withFileTypes: true })) {
1261
1265
  if (!d.isDirectory()) continue;
1262
1266
  const f = path.join(root, d.name, `${uuid}.jsonl`);
@@ -1265,6 +1269,81 @@ function findTranscript(uuid) {
1265
1269
  return null;
1266
1270
  }
1267
1271
 
1272
+ /* ── Cursor 신원 ──────────────────────────────────────────────────────────
1273
+ *
1274
+ * Cursor 는 MCP 자식에게 대화(세션) id 를 **안 준다** — 실측(2026-08-27, pid 98284 env):
1275
+ * `CODE_EDITOR=cursor` 와 `CURSOR_WORKSPACE_LABEL=<창 이름>` 뿐이고, MCP 프로세스는
1276
+ * 대화마다가 아니라 **창마다 하나**다. 대신 창의 워크스페이스 저장소가 「지금 포커스된
1277
+ * 대화」를 들고 있다:
1278
+ *
1279
+ * <Cursor>/User/workspaceStorage/<hash>/workspace.json → { folder: "file:///…" }
1280
+ * 같은 칸의 state.vscdb ItemTable 'composer.composerData'
1281
+ * → { lastFocusedComposerIds: ["<uuid4>"], selectedComposerIds: […] }
1282
+ *
1283
+ * composerId 는 uuid4 라 군체의 세션 열쇠와 같은 자리수다 — 그 uuid 를 「지금 이 대화」로
1284
+ * 쓴다. 단 **CLAUDE_CODE_SESSION_ID 가 있으면 언제나 그것이 정본**이다: Cursor 통합
1285
+ * 터미널 안 Claude Code 도 CURSOR_* env 를 물려받으므로(실측: pid 706 은 둘 다 가짐),
1286
+ * 순서를 바꾸면 Claude 세션이 Cursor 대화로 오인된다.
1287
+ *
1288
+ * 한계를 지어내지 않는다 — 이 uuid 로 되는 것은 **주소를 대는 것**까지다. 전사가
1289
+ * ~/.claude/projects 에 없으므로 올리기·머리 계산·자동 올리기는 각자의 전사 검사에서
1290
+ * 조용히 비켜난다(beatTargets:transcriptOf null → skip). 대화 본문을 올리는 길(두뇌
1291
+ * 파서까지)은 별도 단계다 — DESIGN-cursor-colony.md.
1292
+ */
1293
+ const CURSOR_ROOT = process.env.GENETICS_CURSOR_ROOT
1294
+ || path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User');
1295
+
1296
+ let cursorSeen = { at: 0, id: '', folder: '' }; // 15초 캐시 — 도구마다 sqlite 를 안 두드리게
1297
+
1298
+ function cursorActiveComposer() {
1299
+ if (process.env.CLAUDE_CODE_SESSION_ID) return '';
1300
+ const label = process.env.CURSOR_WORKSPACE_LABEL || '';
1301
+ if (!label) return ''; // 창을 못 고르면 추측하지 않는다
1302
+ const now = Date.now();
1303
+ if (now - cursorSeen.at < 15000) return cursorSeen.id;
1304
+ cursorSeen = { at: now, id: '', folder: '' };
1305
+ try {
1306
+ const wsRoot = path.join(CURSOR_ROOT, 'workspaceStorage');
1307
+ let best = null; // 같은 이름의 창이 여럿이면 가장 최근에 움직인 것
1308
+ for (const d of fs.readdirSync(wsRoot)) {
1309
+ const dir = path.join(wsRoot, d);
1310
+ let folder = '';
1311
+ try {
1312
+ folder = String(JSON.parse(fs.readFileSync(path.join(dir, 'workspace.json'), 'utf8')).folder || '');
1313
+ } catch { continue; }
1314
+ if (!folder.startsWith('file://')) continue;
1315
+ const wsPath = decodeURIComponent(folder.slice('file://'.length)).replace(/\/+$/, '');
1316
+ if (path.basename(wsPath) !== label) continue;
1317
+ let mt = 0;
1318
+ try { mt = fs.statSync(path.join(dir, 'state.vscdb')).mtimeMs; } catch { continue; }
1319
+ if (!best || mt > best.mt) best = { dir, wsPath, mt };
1320
+ }
1321
+ if (!best) return '';
1322
+ const r = spawnSync('/usr/bin/sqlite3',
1323
+ ['-readonly', path.join(best.dir, 'state.vscdb'),
1324
+ "SELECT value FROM ItemTable WHERE key='composer.composerData'"],
1325
+ { encoding: 'utf8', timeout: 2000 });
1326
+ if (r.status !== 0) return '';
1327
+ const j = JSON.parse((r.stdout || '').trim() || '{}');
1328
+ const v = j.lastFocusedComposerIds || j.selectedComposerIds;
1329
+ const id = Array.isArray(v) ? String(v[0] || '') : '';
1330
+ if (!/^[0-9a-f-]{36}$/i.test(id)) return '';
1331
+ cursorSeen = { at: now, id, folder: best.wsPath };
1332
+ return id;
1333
+ } catch { return ''; }
1334
+ }
1335
+
1336
+ /** 위 해석기가 고른 창의 **저장소 폴더** — 소속처 입양(같은 폴더 규칙)이 쓴다. */
1337
+ function cursorWorkspaceFolder() {
1338
+ if (!cursorSeen.folder) cursorActiveComposer();
1339
+ return cursorSeen.folder;
1340
+ }
1341
+
1342
+ /** 「지금 이 세션」 — Claude Code env 가 정본, 없으면 Cursor 활성 대화. */
1343
+ function mySessionId() {
1344
+ return process.env.CLAUDE_CODE_SESSION_ID || cursorActiveComposer();
1345
+ }
1346
+
1268
1347
  function gitHere() {
1269
1348
  try {
1270
1349
  const r = spawnSync('git', ['-C', process.cwd(), 'rev-parse', '--abbrev-ref', 'HEAD'],
@@ -1382,7 +1461,7 @@ async function pushSession(tenant, uuid, title, dryRun) {
1382
1461
  const parts = splitAtLines(sc.jsonl, PUSH_PART_MAX);
1383
1462
  if (parts.length === 1) {
1384
1463
  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');
1464
+ 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
1465
  return { r, before, parts: 1 };
1387
1466
  }
1388
1467
 
@@ -1399,7 +1478,7 @@ async function pushSession(tenant, uuid, title, dryRun) {
1399
1478
  process.stderr.write(`genetics: 조각 ${i + 1}/${parts.length} ${dryRun ? '시험' : '올림'} `
1400
1479
  + `(${(Buffer.byteLength(parts[i], 'utf8') / 1048576).toFixed(1)}MB)\n`);
1401
1480
  }
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');
1481
+ 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
1482
  return { r: last, before, parts: parts.length };
1404
1483
  }
1405
1484
 
@@ -1516,6 +1595,12 @@ function indexRead() {
1516
1595
  * 말한 머리를 그대로 적을 뿐이고, 등록(:1676)은 아무것도 주고받지 않는다. 그 둘은 계기가
1517
1596
  * 없다. 그리고 커서 쓰기는 **아무것도 안 왔을 때도** 도므로 부르는 쪽이 조건을 건다.
1518
1597
  */
1598
+ /** 색인 유래로 서버를 부를 때의 `tenant` 인자 — slug 가 있으면 slug(유일·불변), 없으면 옛 이름.
1599
+ * 색인의 `tenant` 칸은 계속 **이름**이다(표시용) — 인자로 나갈 때만 여기서 갈아탄다. */
1600
+ function tenantArgOf(e) {
1601
+ return (e && typeof e.tenant_slug === 'string' && e.tenant_slug !== '') ? e.tenant_slug : (e?.tenant || '');
1602
+ }
1603
+
1519
1604
  function indexNote(uuid, entry, flow = '') {
1520
1605
  if (!uuid || !entry?.tenant_id) return;
1521
1606
  try {
@@ -1618,7 +1703,7 @@ function transcriptOf(uuid) {
1618
1703
  * 지어낸 출처 한 줄이 뒤에서 회수 보호의 근거가 되면, 걷어야 할 것을 안 걷게 된다.
1619
1704
  */
1620
1705
  function myCitation() {
1621
- const uuid = process.env.CLAUDE_CODE_SESSION_ID || '';
1706
+ const uuid = mySessionId();
1622
1707
  if (!/^[0-9a-f-]{36}$/i.test(uuid)) return {};
1623
1708
  const f = transcriptOf(uuid);
1624
1709
  if (!f) return {};
@@ -1660,18 +1745,29 @@ function adoptTenantOf(myDir, rows) {
1660
1745
  let adoptSaid = false;
1661
1746
  /** 위 규칙을 이 기계에 적용한다 — 색인에 한 줄을 더할 뿐, **올리지는 않는다.** */
1662
1747
  function indexAdoptCurrent() {
1663
- const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
1748
+ const mine = mySessionId();
1664
1749
  if (!mine) return;
1665
1750
  const idx = indexRead();
1666
1751
  if (idx[mine]?.tenant_id) return; // 이미 등록돼 있다
1667
1752
  const f = transcriptOf(mine);
1668
- if (!f) return; // 이 기계에 사본이 없다
1669
- const myDir = path.dirname(f);
1753
+ /*
1754
+ * 폴더 열쇠 — 입양 규칙(같은 폴더 = 같은 소속처)의 「폴더」는 전사 디렉터리다.
1755
+ * Cursor 대화는 ~/.claude/projects 에 전사가 없지만 **창의 저장소 폴더**는 아니까,
1756
+ * 같은 슬러그 규칙(transcriptDir)으로 열쇠를 만들면 그 저장소의 Claude 세션들과
1757
+ * 같은 칸에 선다 — report-portal 창의 Cursor 대화는 report-portal 세션들을 따라간다.
1758
+ */
1759
+ let myDir = '';
1760
+ if (f) myDir = path.dirname(f);
1761
+ else if (mine && mine === cursorActiveComposer()) {
1762
+ const folder = cursorWorkspaceFolder();
1763
+ if (folder) myDir = transcriptDir(folder);
1764
+ }
1765
+ if (!myDir) return; // 이 기계에 사본도, 창 귀속도 없다
1670
1766
  const rows = [];
1671
1767
  for (const [u, e] of Object.entries(idx)) {
1672
1768
  if (!e?.tenant_id) continue;
1673
1769
  const p = transcriptOf(u);
1674
- if (p) rows.push({ dir: path.dirname(p), tenant: e.tenant, tenant_id: e.tenant_id });
1770
+ 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
1771
  }
1676
1772
  const pick = adoptTenantOf(myDir, rows);
1677
1773
  // 이 폴더의 **첫 세션**이면 근거가 없다 — 여기서 조용히 되돌아가는 것은 의도이고,
@@ -1686,14 +1782,14 @@ function indexAdoptCurrent() {
1686
1782
  }
1687
1783
  return;
1688
1784
  }
1689
- indexNote(mine, { tenant: pick.tenant, tenant_id: pick.tenant_id, head_id: '', head_ms: 0 });
1785
+ indexNote(mine, { tenant: pick.tenant, tenant_id: pick.tenant_id, head_id: '', head_ms: 0, ...(pick.tenant_slug ? { tenant_slug: pick.tenant_slug } : {}) });
1690
1786
  process.stderr.write('genetics: 이 세션을 «' + pick.tenant
1691
1787
  + '» 로 등록했다 — 같은 폴더의 앞 세션과 같은 곳이다. 이제 자동으로 올라간다.\n');
1692
1788
  }
1693
1789
 
1694
1790
  function beatTargets() {
1695
1791
  const idx = indexRead();
1696
- const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
1792
+ const mine = mySessionId();
1697
1793
  const now = Date.now();
1698
1794
  const out = [];
1699
1795
  for (const [uuid, e] of Object.entries(idx)) {
@@ -1711,7 +1807,7 @@ function beatTargets() {
1711
1807
  lines: head.lines, tenant: e.tenant_id, flags: 0,
1712
1808
  // 방금 움직였으면 「말하는 중」이다 — 화면의 맥박이 여기서 뛴다.
1713
1809
  state: now - mtime < BEAT_EVERY_MS ? BEAT_STATE.WORKING : BEAT_STATE.OPEN,
1714
- _tenant_name: e.tenant,
1810
+ _tenant_arg: tenantArgOf(e),
1715
1811
  });
1716
1812
  if (out.length >= BEAT.MAX) break;
1717
1813
  }
@@ -1882,11 +1978,11 @@ async function autoPushIfAhead(reply, recs) {
1882
1978
  for (const v of reply) {
1883
1979
  if (v.verdict !== BEAT_VERDICT.AHEAD || !(v.flags & BEAT_WANT_PUSH)) continue;
1884
1980
  const rec = recs.find((r) => r.uuid === v.uuid);
1885
- if (!rec?._tenant_name) continue;
1981
+ if (!rec?._tenant_arg) continue;
1886
1982
  if (now - (autoPushAt.get(v.uuid) || 0) < AUTO_PUSH_MIN_MS) continue;
1887
1983
  autoPushAt.set(v.uuid, now);
1888
1984
  try {
1889
- const { r } = await pushSession(rec._tenant_name, v.uuid, '');
1985
+ const { r } = await pushSession(rec._tenant_arg, v.uuid, '');
1890
1986
  process.stderr.write(`genetics: ${v.uuid.slice(0, 8)} 를 자동으로 올렸다 — ${r.lines}줄(서버가 낡아 있었다).\n`);
1891
1987
  } catch (e) {
1892
1988
  // 실패는 알리되 멈추지 않는다. 다음 심박이 다시 시도한다.
@@ -1975,10 +2071,10 @@ function observeTick() {
1975
2071
  /** 이 세션의 색인 항목이 아는 소속처 이름 — 없으면 ''. `indexAdoptCurrent()` 가 같은 폴더 규칙으로 채운다. */
1976
2072
  function observeTenant() {
1977
2073
  try {
1978
- const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
2074
+ const mine = mySessionId();
1979
2075
  if (!mine) return '';
1980
2076
  const e = indexRead()[mine];
1981
- return (e && e.tenant_id && typeof e.tenant === 'string') ? e.tenant : '';
2077
+ return (e && e.tenant_id) ? tenantArgOf(e) : '';
1982
2078
  } catch { return ''; }
1983
2079
  }
1984
2080
 
@@ -2060,8 +2156,8 @@ const TOOLS = [
2060
2156
  {
2061
2157
  name: 'list_my_tenants',
2062
2158
  description:
2063
- '**내가 속한 곳 목록.** 다른 도구가 요구하는 `tenant` 이름을 여기서 얻는다.\n'
2064
- + '이름을 모르는 채로 `tenant` 를 **지어내지 말 것** — 이것부터 부르고, 여럿이면 사용자에게 고르게 한다.\n'
2159
+ '**내가 속한 곳 목록.** 다른 도구가 요구하는 `tenant` 값(slug, 이름도 받는다)을 여기서 얻는다.\n'
2160
+ + '값을 모르는 채로 `tenant` 를 **지어내지 말 것** — 이것부터 부르고, 여럿이면 사용자에게 고르게 한다.\n'
2065
2161
  + '지금 폴더에 대해 서버가 **본 것**을 함께 준다 — 「이 자리는 지금까지 어디에서 올라왔나」. '
2066
2162
  + '그것은 **기록이지 근거가 아니다**: 서버가 한 곳으로 좁히지 못하거나 자리를 못 읽으면 '
2067
2163
  + '그렇게 말한다. 좁혀지지 않으면 **사용자에게 물어라.**',
@@ -2127,7 +2223,7 @@ const TOOLS = [
2127
2223
  inputSchema: {
2128
2224
  type: 'object',
2129
2225
  properties: {
2130
- tenant: { type: 'string', description: '문서를 받을 소속처 이름. 키가 가리키는 곳과 다르면 거부된다(오발행 방지).' },
2226
+ tenant: { type: 'string', description: '문서를 받을 소속처 slug(이름도 받는다 — slug 는 list_my_tenants 가 알려 준다). 키가 가리키는 곳과 다르면 거부된다(오발행 방지).' },
2131
2227
  slug: { type: 'string', description: '파일 이름이 될 식별자. 영문 소문자·숫자·하이픈 (예: fixes-20260812)' },
2132
2228
  published_at: { type: 'string', description: 'YYYY-MM-DD (생략하면 오늘)' },
2133
2229
  publish: {
@@ -2151,7 +2247,7 @@ const TOOLS = [
2151
2247
  inputSchema: {
2152
2248
  type: 'object',
2153
2249
  properties: {
2154
- tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다.' },
2250
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다). 키와 다르면 거부된다.' },
2155
2251
  id: { type: 'integer' },
2156
2252
  published_at: { type: 'string', description: 'YYYY-MM-DD (생략하면 오늘)' },
2157
2253
  },
@@ -2166,7 +2262,7 @@ const TOOLS = [
2166
2262
  inputSchema: {
2167
2263
  type: 'object',
2168
2264
  properties: {
2169
- tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다.' },
2265
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다). 키와 다르면 거부된다.' },
2170
2266
  id: { type: 'integer' },
2171
2267
  reason: { type: 'string', description: '왜 내리는지. 서버 로그에 남아 다음 사람이 이유를 안다.' },
2172
2268
  },
@@ -2195,7 +2291,7 @@ const TOOLS = [
2195
2291
  inputSchema: {
2196
2292
  type: 'object',
2197
2293
  properties: {
2198
- tenant: { type: 'string', description: '대상 소속처 이름. 키와 다르면 거부된다(오발행 방지).' },
2294
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다). 키와 다르면 거부된다(오발행 방지).' },
2199
2295
  id: { type: 'integer' },
2200
2296
  dry_run: {
2201
2297
  type: 'boolean',
@@ -2228,7 +2324,7 @@ const TOOLS = [
2228
2324
  inputSchema: {
2229
2325
  type: 'object',
2230
2326
  properties: {
2231
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2327
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2232
2328
  title: { type: 'string', description: '한 줄 제목. 파일 이름이 된다(같은 제목이면 덮어쓴다).' },
2233
2329
  symptom: { type: 'string', description: '겪은 현상. 원인이 아니라 **눈에 보인 것**을 그대로.' },
2234
2330
  cause: { type: 'string', description: '원인. 모르면 비운다 — 지어내지 않는다.' },
@@ -2260,7 +2356,7 @@ const TOOLS = [
2260
2356
  inputSchema: {
2261
2357
  type: 'object',
2262
2358
  properties: {
2263
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2359
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2264
2360
  q: { type: 'string', description: '증상 문장. 원인이 아니라 현상으로.' },
2265
2361
  limit: { type: 'integer', minimum: 1, maximum: 20, description: '몇 건까지(기본 5).' },
2266
2362
  fast: { type: 'boolean', description: '의미 확장을 건너뛴다(빠르지만 덜 찾는다). 기본은 확장 켬 — '
@@ -2278,7 +2374,7 @@ const TOOLS = [
2278
2374
  inputSchema: {
2279
2375
  type: 'object',
2280
2376
  properties: {
2281
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2377
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2282
2378
  path: { type: 'string', description: 'search_cases 결과의 path 그대로(예: cases/….md).' },
2283
2379
  },
2284
2380
  required: ['tenant', 'path'],
@@ -2294,7 +2390,7 @@ const TOOLS = [
2294
2390
  inputSchema: {
2295
2391
  type: 'object',
2296
2392
  properties: {
2297
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2393
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2298
2394
  path: { type: 'string', description: '사례 경로(cases/….md).' },
2299
2395
  verdict: { type: 'string', enum: ['applied', 'refuted', 'irrelevant'], description: '사람이 말한 판정.' },
2300
2396
  note: { type: 'string', description: '한 줄 메모(선택, 200자).' },
@@ -2312,7 +2408,7 @@ const TOOLS = [
2312
2408
  inputSchema: {
2313
2409
  type: 'object',
2314
2410
  properties: {
2315
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2411
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2316
2412
  title: { type: 'string', description: '검토 제목 — 메일 제목 「충돌 — <제목>」의 <제목> 그대로.' },
2317
2413
  verdict: { type: 'string', enum: ['applied', 'refuted'], description: '이 세션의 판정.' },
2318
2414
  type: { type: 'string', enum: ['contradiction', 'duplicate', 'missing-page'], description: '검토 종류(선택 — 충돌=contradiction·중복=duplicate·문서 필요=missing-page). 비우면 서버가 셋 중 있는 것을 고른다.' },
@@ -2335,7 +2431,7 @@ const TOOLS = [
2335
2431
  inputSchema: {
2336
2432
  type: 'object',
2337
2433
  properties: {
2338
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2434
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2339
2435
  file: { type: 'integer', minimum: 1, description: '문서 번호(목록의 #). 없으면 목록.' },
2340
2436
  },
2341
2437
  required: ['tenant'],
@@ -2350,7 +2446,7 @@ const TOOLS = [
2350
2446
  inputSchema: {
2351
2447
  type: 'object',
2352
2448
  properties: {
2353
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2449
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2354
2450
  page: {
2355
2451
  type: 'integer', minimum: 1, maximum: 1000,
2356
2452
  description: '목록 page(1부터, 최대 1000). 응답이 알려 준 next를 넣는다.',
@@ -2375,7 +2471,7 @@ const TOOLS = [
2375
2471
  inputSchema: {
2376
2472
  type: 'object',
2377
2473
  properties: {
2378
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2474
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2379
2475
  uuid: { type: 'string', description: '세션 id. `--resume` 에 쓰는 그 값.' },
2380
2476
  title: { type: 'string', description: '목록에서 알아볼 한 줄. 생략하면 빈 값.' },
2381
2477
  dry_run: { type: 'boolean', description: '참이면 아무것도 저장하지 않고 검열 결과만 돌려준다.' },
@@ -2399,7 +2495,7 @@ const TOOLS = [
2399
2495
  inputSchema: {
2400
2496
  type: 'object',
2401
2497
  properties: {
2402
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2498
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2403
2499
  uuid: { type: 'string', description: '이어받을 세션 id.' },
2404
2500
  into: { type: 'string', description: '원본 파일을 놓을 자리(생략하면 지금 폴더). '
2405
2501
  + '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 원본을 열 자리다.' },
@@ -2419,7 +2515,7 @@ const TOOLS = [
2419
2515
  inputSchema: {
2420
2516
  type: 'object',
2421
2517
  properties: {
2422
- tenant: { type: 'string', description: '대상 소속처 이름.' },
2518
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2423
2519
  uuid: { type: 'string', description: '받을 세션 id.' },
2424
2520
  into: { type: 'string', description: '어느 디렉터리에서 이어 갈 것인가(생략하면 지금 폴더). '
2425
2521
  + '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 이어 갈 자리다.' },
@@ -2447,7 +2543,7 @@ const TOOLS = [
2447
2543
  inputSchema: {
2448
2544
  type: 'object',
2449
2545
  properties: {
2450
- tenant: { type: 'string', description: '소속처 이름. 모르면 list_my_tenants 로 먼저 확인한다' },
2546
+ tenant: { type: 'string', description: '소속처 slug(이름도 받는다). 모르면 list_my_tenants 로 먼저 확인한다' },
2451
2547
  to: { type: 'string', description: '받는 쪽 — 세포 이름 · 세션 uuid · 또는 brain' },
2452
2548
  body: { type: 'string', description: '할 말' },
2453
2549
  subject: { type: 'string', description: '한 줄 제목(선택)' },
@@ -2693,7 +2789,7 @@ let tenantSaidAt = 0;
2693
2789
  * **한 번도 안 돌았다**(`world-<root>.json` 의 `cursor` 가 null 이었다).
2694
2790
  */
2695
2791
  function tenantNotice() {
2696
- const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
2792
+ const mine = mySessionId();
2697
2793
  if (!mine) return '';
2698
2794
  try {
2699
2795
  if (indexRead()[mine]?.tenant_id) return ''; // 붙어 있다 — 조용하다
@@ -2797,7 +2893,7 @@ let brainCursor = null; // null = 아직 색인에서 안 읽었다
2797
2893
  const brainQueue = []; // 아직 안 말한 모순
2798
2894
 
2799
2895
  async function brainPull() {
2800
- const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
2896
+ const mine = mySessionId();
2801
2897
  if (!mine) return;
2802
2898
  const e = indexRead()[mine];
2803
2899
  if (!e?.tenant || !e?.tenant_id) return; // 아직 어느 소속처인지 모른다
@@ -2812,7 +2908,7 @@ async function brainPull() {
2812
2908
  const cwd = relHome(process.cwd());
2813
2909
  const fresh = [];
2814
2910
  for (let page = 0; page < 10; page++) {
2815
- const r = await api('GET', '/api/world?tenant=' + encodeURIComponent(e.tenant)
2911
+ const r = await api('GET', '/api/world?tenant=' + encodeURIComponent(tenantArgOf(e))
2816
2912
  + '&since=' + brainCursor + '&cwd=' + encodeURIComponent(cwd) + '&limit=' + BRAIN_PAGE);
2817
2913
  const evs = Array.isArray(r?.events) ? r.events : [];
2818
2914
  for (const ev of evs) if (ev?.kind === 'review' && ev?.ref) fresh.push(ev);
@@ -2960,10 +3056,18 @@ function introNameOf(line) {
2960
3056
  }
2961
3057
  let nameCache = null; // { file, size, name }
2962
3058
  function myAgentName() {
2963
- const uuid = process.env.CLAUDE_CODE_SESSION_ID || '';
3059
+ const uuid = mySessionId();
2964
3060
  if (!/^[0-9a-f-]{36}$/i.test(uuid)) return null;
2965
3061
  const f = transcriptOf(uuid);
2966
- if (!f) return null;
3062
+ if (!f) {
3063
+ /*
3064
+ * Cursor 활성 대화 — 전사도 ListAgents 도 없다. 이름은 별칭일 뿐 열쇠는 uuid 이므로
3065
+ * (mail 레일 계약), 출처가 읽히는 결정적 별칭을 낸다: cursor-<uuid 앞 2자>.
3066
+ * 하네스 이름(app-2c)과 같은 문법이라 받는 쪽 화면·회신 규칙이 그대로 통한다.
3067
+ */
3068
+ if (uuid === cursorActiveComposer()) return 'cursor-' + uuid.slice(0, 2);
3069
+ return null;
3070
+ }
2967
3071
  let st;
2968
3072
  try { st = fs.statSync(f); } catch { return null; }
2969
3073
  if (nameCache?.file === f && nameCache.size === st.size) return nameCache.name;
@@ -3013,15 +3117,15 @@ function myAgentName() {
3013
3117
  */
3014
3118
  const mailTenants = new Map(); // tenant 이름 → tenant_id(모르면 0). mail 도구가 성공할 때 적는다
3015
3119
  function mailReady() {
3016
- const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
3120
+ const mine = mySessionId();
3017
3121
  if (!mine) return null;
3018
3122
  const cred = credential();
3019
3123
  if (!cred || cred.kind !== 'user') return null; // 세계 장부와 같다 — 서버가 사람 토큰만 받는다
3020
3124
  const e = indexRead()[mine];
3021
3125
  const set = new Map(mailTenants);
3022
- if (e?.tenant && !set.has(e.tenant)) set.set(e.tenant, Number(e.tenant_id) || 0);
3126
+ { const a = tenantArgOf(e); if (a && !set.has(a)) set.set(a, Number(e.tenant_id) || 0); }
3023
3127
  if (!set.size) return null; // 아직 어느 소속처에도 주소를 안 댔다
3024
- return { mine, tenants: set, indexTenant: e?.tenant || '' , indexEntry: e };
3128
+ return { mine, tenants: set, indexTenant: tenantArgOf(e), indexEntry: e };
3025
3129
  }
3026
3130
 
3027
3131
  /**
@@ -3037,7 +3141,8 @@ async function mailRegister(tenant, mine, force = false) {
3037
3141
  tenant,
3038
3142
  session: mine,
3039
3143
  name: name || '',
3040
- client: 'claude-code',
3144
+ // 신원이 어디서 왔는지 그대로 말한다 — Cursor 대화에 claude-code 이름표를 달지 않는다.
3145
+ client: mine === cursorActiveComposer() ? 'cursor' : 'claude-code',
3041
3146
  cwd: relHome(process.cwd()),
3042
3147
  });
3043
3148
  }
@@ -3078,7 +3183,7 @@ async function mailPull() {
3078
3183
  const r = await api('GET', '/api/sessions/' + encodeURIComponent(mine)
3079
3184
  + '?tenant=' + encodeURIComponent(indexTenant) + '&no_body=1');
3080
3185
  if (r?.bake) {
3081
- indexNote(mine, { tenant: indexTenant, tenant_id: indexEntry.tenant_id, bake: r.bake });
3186
+ indexNote(mine, { tenant: indexEntry.tenant, tenant_id: indexEntry.tenant_id, bake: r.bake });
3082
3187
  }
3083
3188
  } catch { /* 옛 서버는 이 칸이 없다 — 없으면 상태바가 「모름」으로 둔다 */ }
3084
3189
  }
@@ -3106,7 +3211,7 @@ async function mailPullOne(tenant, mine, indexEntry) {
3106
3211
  // 색인에는 색인의 소속처 것만 적는다(다른 곳의 커서는 프로세스 안에서만 — 잃어도 ack 가 중복을 막는다).
3107
3212
  // 빈 폴링은 수신이 아니다 — 위 백신 커서와 같은 규율.
3108
3213
  if (indexEntry?.tenant_id) {
3109
- indexNote(mine, { tenant, tenant_id: indexEntry.tenant_id, mail_cursor: cursor },
3214
+ indexNote(mine, { tenant: indexEntry.tenant, tenant_id: indexEntry.tenant_id, mail_cursor: cursor },
3110
3215
  rows.length ? 'rx' : '');
3111
3216
  }
3112
3217
 
@@ -3337,13 +3442,17 @@ async function callTool(name, args = {}) {
3337
3442
  const mark = (t) => (t.has_logo === false ? ' · 로고 없음' : '');
3338
3443
 
3339
3444
  if (list.length === 1) {
3340
- return `속한 곳이 하나입니다 — **${list[0].name}**${mark(list[0])}\n`
3341
- + '다른 도구의 `tenant` 에 이 이름을 그대로 쓰세요.'
3445
+ // 옛 서버는 slug 칸이 없다 — 그때는 이름만 말한다(없는 값을 지어내지 않는다).
3446
+ const slug0 = typeof list[0].slug === 'string' && list[0].slug !== '' ? list[0].slug : '';
3447
+ return `속한 곳이 하나입니다 — **${list[0].name}**${slug0 ? ` (\`${slug0}\`)` : ''}${mark(list[0])}\n`
3448
+ + (slug0 ? '다른 도구의 `tenant` 에는 slug 를 쓰세요(이름도 받는다).'
3449
+ : '다른 도구의 `tenant` 에 이 이름을 그대로 쓰세요.')
3342
3450
  + (says ? `\n\n${says}` : '');
3343
3451
  }
3344
3452
  const lines = list.map((t) => {
3345
3453
  const when = t.last_used_at ? String(t.last_used_at).slice(0, 10) : '기록 없음';
3346
- return ` · ${t.name}${seen(t)}${mark(t)} · 최근 ${when}`;
3454
+ const sl = typeof t.slug === 'string' && t.slug !== '' ? ` (\`${t.slug}\`)` : '';
3455
+ return ` · ${t.name}${sl}${seen(t)}${mark(t)} · 최근 ${when}`;
3347
3456
  });
3348
3457
  return `속한 곳 ${list.length}곳입니다. **사용자에게 어디로 할지 물어보세요** — 고르지 마세요.\n`
3349
3458
  + `${lines.join('\n')}\n`
@@ -3572,7 +3681,8 @@ async function callTool(name, args = {}) {
3572
3681
  + r.hits.map((h) => `· ${h.path} [${h.score}점]${h.status ? `\n ⚠ 상태: ${h.status}` : ''}`
3573
3682
  + `\n ${h.excerpt}`).join('\n')
3574
3683
  + '\n전문은 get_case(tenant, path) — 처방·상태까지 그대로 읽는다.'
3575
- + (Number.isInteger(r.tenant_id) ? ` 사람 화면: wiki?t=${r.tenant_id}&p=<path>` : '');
3684
+ + (typeof r.human_url === 'string' && r.human_url !== '' ? ` 사람 화면: ${r.human_url}?p=<path>`
3685
+ : Number.isInteger(r.tenant_id) ? ` 사람 화면: wiki?t=${r.tenant_id}&p=<path>` : '');
3576
3686
  }
3577
3687
  case 'get_case': {
3578
3688
  // 서버 창구(/api/wiki/page)는 이미 있었다 — MCP 가 안 부르고 있었을 뿐이다(2026-08-25).
@@ -3662,7 +3772,7 @@ async function callTool(name, args = {}) {
3662
3772
  // **본 것을 적어 둔다.** 이 짝(uuid → 고객사 id)이 없으면 심박을 보낼 수가 없다 —
3663
3773
  // 프레임에는 고객사 이름이 아니라 id 가 실린다.
3664
3774
  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 });
3775
+ 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
3776
  }
3667
3777
  const p = r.pagination && typeof r.pagination === 'object' ? r.pagination : null;
3668
3778
  let count = `세션 ${sessions.length}건`;
@@ -3738,7 +3848,7 @@ async function callTool(name, args = {}) {
3738
3848
  const q = `?tenant=${encodeURIComponent(args.tenant)}`;
3739
3849
  const r = await api('GET',
3740
3850
  `/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');
3851
+ 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
3852
 
3743
3853
  // 원본도 제자리에 놓는다 — **덮지 않는다**(pull_session 과 같은 이유).
3744
3854
  const into = args.into || process.cwd();
@@ -3799,7 +3909,7 @@ async function callTool(name, args = {}) {
3799
3909
  `/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}&raw=1`,
3800
3910
  target);
3801
3911
  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');
3912
+ 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
3913
  // 헤더를 안 주는 옛 서버면 **지어내지 않는다** — 받은 바이트만 말한다.
3804
3914
  const size = `${((m.bytes ?? bytes) / 1024).toFixed(0)}KB`;
3805
3915
  return `받았습니다 — ${args.uuid.slice(0, 8)} · ${m.lines === null ? '' : `${m.lines}줄 · `}${size}\n`
@@ -3808,10 +3918,10 @@ async function callTool(name, args = {}) {
3808
3918
  + (m.redacted ? `※ 가린 자리 ${m.redacted}곳 — 도구 실행 결과는 안 실려 옵니다. 필요하면 다시 실행하세요.` : '');
3809
3919
  }
3810
3920
  case 'mail': {
3811
- const mine = process.env.CLAUDE_CODE_SESSION_ID || '';
3921
+ const mine = mySessionId();
3812
3922
  if (!mine) {
3813
3923
  return '이 세션의 번호를 모른다 — 주소를 댈 수 없어 보낼 수 없다'
3814
- + '(CLAUDE_CODE_SESSION_ID 가 없는 환경이다).';
3924
+ + '(CLAUDE_CODE_SESSION_ID 도, Cursor 활성 대화도 없는 환경이다).';
3815
3925
  }
3816
3926
  /*
3817
3927
  * 주소가 없으면 먼저 댄다. **이름이 없으면 서버가 409 로 거절**하므로,
@@ -3853,7 +3963,7 @@ async function callTool(name, args = {}) {
3853
3963
  // ⚠ 서버가 tenant_id 를 아직 안 주는 판(재배포 전)이어도 **받기는 이름만으로 된다** —
3854
3964
  // GET /api/mail 은 tenant 이름으로 묻는다. 색인(indexNote)만 id 가 있어야 적힌다.
3855
3965
  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 });
3966
+ 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
3967
  return heldNote + `«${r.to}» 앞으로 보냈다 — 실 ${r.thread} · ${r.turn}번째.\n`
3858
3968
  + `받는 쪽이 보게 될 글자는 이것이다:\n${r.preview}\n`
3859
3969
  + (r.says ? `${r.says}\n` : '')
@@ -3977,7 +4087,8 @@ async function handle(line) {
3977
4087
  '',
3978
4088
  '자격은 **`login` 으로 받는 SSO 하나면 된다** — 그것으로 내가 속한 소속처 전부에 닿는다.',
3979
4089
  '키(rpk_)도 쓸 수 있지만 **한 소속처에만** 매이고, 발행할 때만 그쪽이 먼저 쓰인다.',
3980
- '어디로 갈지는 tenant 에 소속처 **이름**으로 적는다. 이름을 모르면 지어내지 말고 사용자에게 묻는다 —',
4090
+ '어디로 갈지는 tenant 에 소속처 **slug** 로 적는다(이름도 받는다 — list_my_tenants 가 둘 다 알려 준다).',
4091
+ '값을 모르면 지어내지 말고 사용자에게 묻는다 —',
3981
4092
  '내 소속이 아닌 곳은 존재도 안 보이므로, 404 는 "없다"가 아니라 "내 것이 아니다"일 수 있다.',
3982
4093
  ].join('\n'),
3983
4094
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
5
5
  "author": "주식회사 LIM (LIM Inc.)",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -138,7 +138,7 @@ ls -t ~/.claude/projects/<프로젝트-슬러그>/*.jsonl | head -1
138
138
  |---|---|
139
139
  | 내 세션 uuid | 전사 파일 이름 |
140
140
  | 내가 어느 사람·어느 권한으로 붙어 있나 | `whoami` 도구 |
141
- | 내가 닿는 소속처 목록 | `list_my_tenants` 도구 |
141
+ | 내가 닿는 소속처 목록(slug 포함) | `list_my_tenants` 도구 |
142
142
  | 내 세션이 서버에 올라가 있나 | `list_sessions` 도구 — 목록에 내 앞자리가 있는지 본다 |
143
143
 
144
144
  ## 말 걸기
@@ -155,13 +155,13 @@ ls -t ~/.claude/projects/<프로젝트-슬러그>/*.jsonl | head -1
155
155
 
156
156
  ```
157
157
  SendMessage({to: "app-2f", message: "…"}) # 같은 기계
158
- mail({tenant: "<소속처>", to: "brain", body: "…"}) # 기계를 넘어서
158
+ mail({tenant: "<소속처 slug>", to: "brain", body: "…"}) # 기계를 넘어서
159
159
  ```
160
160
 
161
161
  받은 것에 답할 때 `SendMessage` 는 그 메시지의 `from` 을 그대로 `to` 에 쓰고, `mail` 은 `thread` 를 씁니다.
162
162
 
163
163
  `mail` 의 `to` 에 올 수 있는 것은 **셋뿐**입니다. **지어내지 마세요** — 셋 중 하나가 아니면
164
- 사용자에게 묻습니다. 소속처 이름을 모르면 `list_my_tenants` 로 먼저 확인합니다.
164
+ 사용자에게 묻습니다. 소속처 값(slug — 이름도 받는다)을 모르면 `list_my_tenants` 로 먼저 확인합니다.
165
165
 
166
166
  | `to` | 언제 쓰나 |
167
167
  |---|---|
@@ -70,7 +70,7 @@ version: "4.0.0"
70
70
  **Lim 발행 규칙** (어겨도 서버가 거부하지만, 알고 시작할 것):
71
71
  - **톤은 개조식이다** — `~함 / ~했음 / ~필요 / ~한 상황`. `~합니다 / ~해 주세요` 아님.
72
72
  본문에 `습니다|십시오|합니다|입니다|됩니다|주세요` 가 **0건**이어야 한다.
73
- - `tenant` 는 키가 가리키는 고객사와 일치해야 한다(오발행 방지 가드).
73
+ - `tenant` 는 키가 가리키는 고객사와 일치해야 한다(오발행 방지 가드). 값은 소속처 slug 우선·이름 폴백 — 아래 :228 의 `slug` 는 **보고서** slug 라 다른 것이다.
74
74
  - `sections[].kind` 는 `summary|status|cases|asks|note` 다섯 가지뿐 — 그 밖의 값은 절이 통째로 사라지므로 서버가 거절한다.
75
75
  - `cases[].grade` 는 `grades` 에 정의된 이름이어야 한다.
76
76
  - **`update_report` 는 같은 주소를 덮어쓴다** — 이미 고객에게 나간 링크가 새 내용을 가리키므로, 발행 후 수정은 재발행이 아니라 update 로 한다. 기존 문서는 `list_reports`/`get_report` 로 받아 고쳐 되돌려 보낸다.
@@ -57,7 +57,7 @@ version: "1.0.0"
57
57
  적혀 있으면(자동 올리기가 켜진 세션) 저절로 섭니다. 둘 다 아니면 사람이 한 번 칩니다:
58
58
  ```bash
59
59
  GC=$(grep -m1 '^GENETICS_CELL_BIN=' ~/.config/genetics/api.env | cut -d= -f2-); GC=${GC:-~/.config/genetics/bin/gcell}
60
- "$GC" sync --tenant <소속처 이름>
60
+ "$GC" sync --tenant <소속처 slug|이름>
61
61
  ```
62
62
  2. **내 세션이 서버에 올라가 있습니다.** `list_sessions` 목록에 내 앞자리가 있는지 봅니다.
63
63
  소속은 **올라온 세션에만** 붙습니다 — 안 올라간 세션은 어느 일에도 속할 수 없습니다.