genetics-mcp 2.16.2 → 2.16.3

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": "cb4ce550b4ce5e37a8f575280536b5983042572d6e83854c0e67a5c9eee0a030",
3
- "aarch64-unknown-linux-musl": "27ac0333d512f9cdff5901852b629c9c845e5998807c5be7474d3b5d152fb94a",
4
- "x86_64-apple-darwin": "db3920a2b802ff637f6e6b7f857aec5db825fc90159adca78fa2f1bad7c7f284",
5
- "x86_64-unknown-linux-musl": "31351ff121ee16e70825379b81398fb23d3614e27e7cc59cf7c0385ae98e1a1d"
2
+ "aarch64-apple-darwin": "d13eb43d93b2c5551620bde8f25caaaeb0e7952a00e96add790999e537496e88",
3
+ "aarch64-unknown-linux-musl": "d834aba463313110483e1b6d6f5a204093d82846372e6a9dcd54e7f0019b1c66",
4
+ "x86_64-apple-darwin": "11a0aa685c3d687b8a6c2a51377a4a70a5c6c6527a43d7d58c62231622d54160",
5
+ "x86_64-unknown-linux-musl": "4ac0ef43c50a5c1e70832c86cc98f34411e8e63d011601bb09aaf62967f4018d"
6
6
  }
Binary file
Binary file
package/genetics.mjs CHANGED
@@ -1073,6 +1073,13 @@ const REPORT_DATA = {
1073
1073
  how_to_check: { type: 'string' },
1074
1074
  remaining: { type: 'string', description: '고객 확인이 필요한 사항(선택)' },
1075
1075
  source_url: { type: 'string', description: 'http(s) 제보 원본 링크(선택)' },
1076
+ uid: {
1077
+ type: 'string',
1078
+ description: '이 건의 **불변 이름**(영소문자·숫자 6자). 안 주면 서버가 발급한다. '
1079
+ + '★ get_report 가 준 값이 있으면 **그대로 되돌려 보내라** — 절 순서를 바꾸거나 '
1080
+ + '축을 갈아엎어도 이 이름이 같으면 그 건에 달린 메모가 안 떨어진다. '
1081
+ + '흘리면 제목으로 다시 잇고, 그것도 안 되면 새 이름이 발급된다.',
1082
+ },
1076
1083
  },
1077
1084
  // group 만 필수다 — 없는 그룹에 속한 건은 어디에도 안 실려 조용히 사라진다.
1078
1085
  required: ['group'],
@@ -1303,6 +1310,37 @@ function cursorTranscriptOf(uuid) {
1303
1310
  return null;
1304
1311
  }
1305
1312
 
1313
+ function isCursorTranscript(f) {
1314
+ return typeof f === 'string' && f.includes(`${path.sep}agent-transcripts${path.sep}`);
1315
+ }
1316
+
1317
+ function cursorAliasOf(uuid) {
1318
+ return 'cursor-' + String(uuid).slice(0, 2);
1319
+ }
1320
+
1321
+ /** 훅이 적어 둔 (세션, 뿌리). 라벨 없는 MCP 헬퍼가 sqlite 를 추측하지 않게. */
1322
+ function cursorActiveFromLedger() {
1323
+ const dir = process.env.GCELL_STATE_DIR || path.join(os.homedir(), '.config', 'genetics', 'cell');
1324
+ let cwd = process.cwd();
1325
+ try { cwd = fs.realpathSync(cwd); } catch { /* 없으면 준 값 */ }
1326
+ let names;
1327
+ try { names = fs.readdirSync(dir); } catch { return { id: '', folder: '' }; }
1328
+ let best = null;
1329
+ for (const name of names) {
1330
+ if (!name.startsWith('active-cursor-') || !name.endsWith('.json')) continue;
1331
+ let v;
1332
+ try { v = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')); } catch { continue; }
1333
+ const root = String(v.root || '');
1334
+ const id = String(v.session || '');
1335
+ if (!root || !/^[0-9a-f-]{36}$/i.test(id)) continue;
1336
+ const at = Number(v.at || 0);
1337
+ if (at && (Date.now() / 1000) - at > 6 * 3600) continue;
1338
+ if (!(cwd === root || cwd.startsWith(root + path.sep))) continue;
1339
+ if (!best || at > best.at) best = { id, folder: root, at };
1340
+ }
1341
+ return best || { id: '', folder: '' };
1342
+ }
1343
+
1306
1344
  /* ── Cursor 신원 ──────────────────────────────────────────────────────────
1307
1345
  *
1308
1346
  * Cursor 는 MCP 자식에게 대화(세션) id 를 **안 준다** — 실측(2026-08-27, pid 98284 env):
@@ -1331,40 +1369,60 @@ let cursorSeen = { at: 0, id: '', folder: '' }; // 15초 캐시 — 도구마
1331
1369
 
1332
1370
  function cursorActiveComposer() {
1333
1371
  if (process.env.CLAUDE_CODE_SESSION_ID) return '';
1334
- const label = process.env.CURSOR_WORKSPACE_LABEL || '';
1335
- if (!label) return ''; // 창을 못 고르면 추측하지 않는다
1336
1372
  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 ''; }
1373
+ if (now - cursorSeen.at < 15000 && cursorSeen.id) return cursorSeen.id;
1374
+
1375
+ // 에이전트 실행 env — 라벨·sqlite 보다 먼저. 36자 uuid 가 아니면 버린다.
1376
+ const envId = process.env.CURSOR_CONVERSATION_ID || '';
1377
+ if (/^[0-9a-f-]{36}$/i.test(envId)) {
1378
+ let folder = '';
1379
+ try { folder = fs.realpathSync(process.cwd()); } catch { folder = process.cwd(); }
1380
+ cursorSeen = { at: now, id: envId, folder };
1381
+ return envId;
1382
+ }
1383
+
1384
+ const label = process.env.CURSOR_WORKSPACE_LABEL || '';
1385
+ if (label) {
1386
+ try {
1387
+ const wsRoot = path.join(CURSOR_ROOT, 'workspaceStorage');
1388
+ let best = null; // 같은 이름의 창이 여럿이면 가장 최근에 움직인 것
1389
+ for (const d of fs.readdirSync(wsRoot)) {
1390
+ const dir = path.join(wsRoot, d);
1391
+ let folder = '';
1392
+ try {
1393
+ folder = String(JSON.parse(fs.readFileSync(path.join(dir, 'workspace.json'), 'utf8')).folder || '');
1394
+ } catch { continue; }
1395
+ if (!folder.startsWith('file://')) continue;
1396
+ const wsPath = decodeURIComponent(folder.slice('file://'.length)).replace(/\/+$/, '');
1397
+ if (path.basename(wsPath) !== label) continue;
1398
+ let mt = 0;
1399
+ try { mt = fs.statSync(path.join(dir, 'state.vscdb')).mtimeMs; } catch { continue; }
1400
+ if (!best || mt > best.mt) best = { dir, wsPath, mt };
1401
+ }
1402
+ if (best) {
1403
+ const r = spawnSync('/usr/bin/sqlite3',
1404
+ ['-readonly', path.join(best.dir, 'state.vscdb'),
1405
+ "SELECT value FROM ItemTable WHERE key='composer.composerData'"],
1406
+ { encoding: 'utf8', timeout: 2000 });
1407
+ if (r.status === 0) {
1408
+ const j = JSON.parse((r.stdout || '').trim() || '{}');
1409
+ const v = j.lastFocusedComposerIds || j.selectedComposerIds;
1410
+ const id = Array.isArray(v) ? String(v[0] || '') : '';
1411
+ if (/^[0-9a-f-]{36}$/i.test(id)) {
1412
+ cursorSeen = { at: now, id, folder: best.wsPath };
1413
+ return id;
1414
+ }
1415
+ }
1416
+ }
1417
+ } catch { /* 아래 장부로 */ }
1418
+ }
1419
+
1420
+ const led = cursorActiveFromLedger();
1421
+ if (led.id) {
1422
+ cursorSeen = { at: now, id: led.id, folder: led.folder };
1423
+ return led.id;
1424
+ }
1425
+ return '';
1368
1426
  }
1369
1427
 
1370
1428
  /** 위 해석기가 고른 창의 **저장소 폴더** — 소속처 입양(같은 폴더 규칙)이 쓴다. */
@@ -1792,16 +1850,16 @@ function indexAdoptCurrent() {
1792
1850
  if (idx[mine]?.tenant_id) return; // 이미 등록돼 있다
1793
1851
  const f = transcriptOf(mine);
1794
1852
  /*
1795
- * 폴더 열쇠 — 입양 규칙(같은 폴더 = 같은 소속처)의 「폴더」는 전사 디렉터리다.
1796
- * Cursor 대화는 ~/.claude/projects 에 전사가 없지만 **창의 저장소 폴더**는 아니까,
1797
- * 같은 슬러그 규칙(transcriptDir)으로 열쇠를 만들면 그 저장소의 Claude 세션들과
1798
- * 같은 칸에 선다 — report-portal 창의 Cursor 대화는 report-portal 세션들을 따라간다.
1853
+ * 폴더 열쇠 — 입양 규칙(같은 폴더 = 같은 소속처)의 「폴더」는 Claude 전사 디렉터리다.
1854
+ * Cursor jsonl 의 dirname 은 `…/agent-transcripts/<uuid>` 라 Claude 칸과 안 맞는다
1855
+ * (조각 3 회귀, 2026-08-31). 창의 저장소 폴더를 같은 슬러그 규칙으로 만든다.
1799
1856
  */
1800
1857
  let myDir = '';
1801
- if (f) myDir = path.dirname(f);
1802
- else if (mine && mine === cursorActiveComposer()) {
1858
+ if (isCursorTranscript(f) || (!f && mine === cursorActiveComposer())) {
1803
1859
  const folder = cursorWorkspaceFolder();
1804
1860
  if (folder) myDir = transcriptDir(folder);
1861
+ } else if (f) {
1862
+ myDir = path.dirname(f);
1805
1863
  }
1806
1864
  if (!myDir) return; // 이 기계에 사본도, 창 귀속도 없다
1807
1865
  const rows = [];
@@ -2255,6 +2313,59 @@ const TOOLS = [
2255
2313
  required: ['id'],
2256
2314
  },
2257
2315
  },
2316
+ {
2317
+ name: 'list_notes',
2318
+ description:
2319
+ '보고서에 **저자가 달아 둔 메모**를 읽는다. 고객에게는 안 보이는 저자↔에이전트 전용 글이다.\n'
2320
+ + '각 메모의 `anchor.path`(예 `case:k7m2p9/fix`)가 **발행 데이터의 어느 칸을 고치라는 것인지**를 '
2321
+ + '그대로 말한다 — 그 칸을 고쳐 update_report 로 되돌려 보내고, resolve_note 로 반영을 표시한다.\n'
2322
+ + '`anchor_state` 가 `unmoored`·`reparented` 면 문서가 바뀌어 원래 자리를 잃은 것이다 — '
2323
+ + '그래도 메모는 남아 있고, `anchor.quote_exact` 가 저자가 무엇을 보고 썼는지 말한다.',
2324
+ inputSchema: {
2325
+ type: 'object',
2326
+ properties: {
2327
+ id: { type: 'integer', description: '보고서 id' },
2328
+ state: { type: 'string', description: '`open`(기본, 아직 반영 안 한 것) 또는 `all`' },
2329
+ uid: { type: 'string', description: '그 건·절의 uid 에 달린 것만(선택)' },
2330
+ },
2331
+ required: ['id'],
2332
+ },
2333
+ },
2334
+ {
2335
+ name: 'reply_note',
2336
+ description:
2337
+ '메모에 **답글로** 회신한다. 이견이 있거나 물을 것이 있으면 대화창이 아니라 여기에 쓴다 — '
2338
+ + '저자는 자기가 메모를 단 그 자리에서 답을 본다.\n'
2339
+ + '고칠 수 없는 이유, 다르게 본 근거, 되묻는 것이 여기 온다. '
2340
+ + '고쳤으면 답글이 아니라 resolve_note 다.',
2341
+ inputSchema: {
2342
+ type: 'object',
2343
+ properties: {
2344
+ id: { type: 'integer', description: '보고서 id' },
2345
+ note: { type: 'integer', description: '답할 메모 id(list_notes 가 준 값)' },
2346
+ body: { type: 'string', description: '회신. 개조식으로 쓴다' },
2347
+ },
2348
+ required: ['id', 'note', 'body'],
2349
+ },
2350
+ },
2351
+ {
2352
+ name: 'resolve_note',
2353
+ description:
2354
+ '그 메모를 **반영했다**고 표시한다. 반영이 실린 판번호(body_rev)가 함께 남아 '
2355
+ + '「무엇에 반영됐는지」를 나중에도 안다.\n'
2356
+ + '★ 고치기 **전에** 부르지 말 것 — 이 표시가 곧 「이 메모는 끝났다」이고, '
2357
+ + '표시하는 순간 미반영 목록에서 빠져 다시 눈에 안 띈다.',
2358
+ inputSchema: {
2359
+ type: 'object',
2360
+ properties: {
2361
+ id: { type: 'integer', description: '보고서 id' },
2362
+ note: { type: 'integer', description: '메모 id' },
2363
+ rev: { type: 'integer', description: '반영이 실린 body_rev. 안 주면 지금 판' },
2364
+ resolved: { type: 'boolean', description: '거짓이면 미반영으로 되돌린다' },
2365
+ },
2366
+ required: ['id', 'note'],
2367
+ },
2368
+ },
2258
2369
  {
2259
2370
  name: 'publish_report',
2260
2371
  description:
@@ -2413,6 +2524,8 @@ const TOOLS = [
2413
2524
  reopen: { type: 'string', description: '무슨 조건이면 다시 여나. **비우면 「열린 결정」으로 읽힌다.** 끝났으면 "조건 없음(종결)".' },
2414
2525
  status: { type: 'string', description: '상태 — "유효"(기본) · "뒤집힘 → decisions/<그 결정>" · "조건 충족 → <무엇이> (날짜)".' },
2415
2526
  tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8). ★ 이것이 있어야 이 결정이 「일」 후보 계산에 들어간다.' },
2527
+ work: { type: 'string', description: '**이 결정이 곧 과업이면** 그 일의 열쇠(영문 소문자·숫자·붙임표, 예: hnl-wait). 적으면 다음 바퀴에 그 이름의 일이 「계획」으로 선다 — 착수 전에 세우는 정문이다. 이미 있는 이름이면 조용히 그대로 둔다.' },
2528
+ order: { type: 'array', items: { type: 'string' }, description: '일 사이 **순서**를 정하는 결정이면 일의 열쇠를 앞선 것부터(최대 24). 표에 저장하지 않고 일 목록이 이 결정을 읽어 그 순서로 선다 — 순서가 바뀌면 새 결정을 적는다.' },
2416
2529
  },
2417
2530
  required: ['tenant', 'title', 'what'],
2418
2531
  },
@@ -2440,7 +2553,7 @@ const TOOLS = [
2440
2553
  '**이 소속처에 지금 어떤 일이 있나** — 열린 일의 목록·상태(계획/진행 중/멈춤)·세션 수.\n'
2441
2554
  + '언제 부르는가: 작업을 시작할 때(「내가 붙을 일이 이미 있나」 — 새로 벌이기 전에 먼저) · '
2442
2555
  + '사람이 「지금 뭐가 진행 중이지」라고 물을 때 · 세션 요지에 「어느 일에도 속해 있지 않다」가 왔을 때.\n'
2443
- + '「계획」 = 사람이 착수 전에 세운 일(세션 0) — 이 목록에서 이름을 보고 그 방향의 작업이면 '
2556
+ + '「계획」 = 착수 전에 선 일(세션 0) — 신호(태그 있는 결정·세션 무리)가 쌓이면 AI 가 세운다. 이 목록에서 이름을 보고 그 방향의 작업이면 '
2444
2557
  + 'claim 으로 **착수를 선언**한다(즉시 잠정 효력 — 세션 요지·화면에 「선언(잠정)」이 선다. '
2445
2558
  + '사실이 되는 것은 사람이 화면에서 확정할 때고, 판정기는 증거로 따로 검증한다).\n'
2446
2559
  + '요지 전문은 get_case(tenant, "work/<slug>.md") 로 읽는다. **0 이면 0 이라고 말한다.**',
@@ -3192,9 +3305,10 @@ function myAgentName() {
3192
3305
  /*
3193
3306
  * Cursor 활성 대화 — 전사도 ListAgents 도 없다. 이름은 별칭일 뿐 열쇠는 uuid 이므로
3194
3307
  * (mail 레일 계약), 출처가 읽히는 결정적 별칭을 낸다: cursor-<uuid 앞 2자>.
3195
- * 하네스 이름(app-2c)과 같은 문법이라 받는 쪽 화면·회신 규칙이 그대로 통한다.
3196
3308
  */
3197
- if (uuid === cursorActiveComposer()) return 'cursor-' + uuid.slice(0, 2);
3309
+ if (uuid === cursorActiveComposer() || process.env.CURSOR_CONVERSATION_ID === uuid) {
3310
+ return cursorAliasOf(uuid);
3311
+ }
3198
3312
  return null;
3199
3313
  }
3200
3314
  let st;
@@ -3219,11 +3333,19 @@ function myAgentName() {
3219
3333
  const n = introNameOf(line);
3220
3334
  if (n) found = n;
3221
3335
  }
3336
+ if (!found && (isCursorTranscript(f) || uuid === cursorActiveComposer()
3337
+ || process.env.CURSOR_CONVERSATION_ID === uuid)) {
3338
+ found = cursorAliasOf(uuid);
3339
+ }
3222
3340
  nameCache = { file: f, size: from + Buffer.byteLength(whole, 'utf8'), name: found };
3223
3341
  return found;
3224
3342
  }
3225
3343
  } finally { fs.closeSync(fd); }
3226
3344
  } catch { /* 못 읽으면 이름이 없는 것이지 고장이 아니다 */ }
3345
+ if (!found && (isCursorTranscript(f) || uuid === cursorActiveComposer()
3346
+ || process.env.CURSOR_CONVERSATION_ID === uuid)) {
3347
+ found = cursorAliasOf(uuid);
3348
+ }
3227
3349
  nameCache = { file: f, size: st.size, name: found };
3228
3350
  return found;
3229
3351
  }
@@ -3382,6 +3504,42 @@ function mailSays() {
3382
3504
  return out.join('\n\n');
3383
3505
  }
3384
3506
 
3507
+ /*
3508
+ * ── 미반영 메모 — **부르지 않아도 눈앞에** ────────────────────────────────
3509
+ *
3510
+ * 저자가 보고서에 남긴 메모는 「물어봐야 아는 것」이면 안 된다. 물어볼 생각을 하려면
3511
+ * 있는 줄 알아야 하는데, 그걸 알려면 물어봐야 한다 — 그 고리를 여기서 끊는다.
3512
+ *
3513
+ * ★ **여기서 서버를 부르지 않는다.** 건수는 이미 받는 응답(get_report·update_report·
3514
+ * list_notes)에 실려 온다. 꼬리마다 한 번씩 물으면 모든 도구가 느려진다.
3515
+ *
3516
+ * ★ 같은 말을 계속 하지 않되 **영영 입 닫지도 않는다.** 델타는 한 번 말하고 마는데
3517
+ * (배경이니까), 메모는 **할 일**이라 잊히면 안 된다 — 도구를 여덟 번 부르는 동안
3518
+ * 안 고쳤으면 다시 말한다.
3519
+ */
3520
+ const NOTES_OPEN = new Map(); // 보고서 id → 미반영 건수
3521
+ let NOTES_LAST = '';
3522
+ let NOTES_QUIET = 0;
3523
+ const NOTES_REMIND_EVERY = 8;
3524
+
3525
+ /** 응답에 실려 온 건수를 기억한다. 서버가 그 칸을 모르는 판이면 아무 일도 안 한다. */
3526
+ function notesSeen(id, n) {
3527
+ if (!Number.isInteger(id) || id <= 0 || !Number.isInteger(n)) return;
3528
+ if (n > 0) NOTES_OPEN.set(id, n); else NOTES_OPEN.delete(id);
3529
+ }
3530
+
3531
+ function notesSays() {
3532
+ const rows = [];
3533
+ NOTES_OPEN.forEach((n, id) => { if (n > 0) rows.push(`#${id} ${n}건`); });
3534
+ if (!rows.length) { NOTES_LAST = ''; return ''; }
3535
+ const t = `저자 메모 미반영 — ${rows.join(' · ')} · list_notes(id) 로 읽는다. `
3536
+ + '고칠 자리는 각 메모의 anchor.path 가 말한다. 이견이 있으면 여기서 말하지 말고 reply_note 로 그 메모에 단다.';
3537
+ if (t === NOTES_LAST && ++NOTES_QUIET < NOTES_REMIND_EVERY) return '';
3538
+ NOTES_LAST = t;
3539
+ NOTES_QUIET = 0;
3540
+ return t;
3541
+ }
3542
+
3385
3543
  function worldAppend(result) {
3386
3544
  /*
3387
3545
  * ★ 백신이 **먼저**다. 세계 델타는 「누가 무엇을 썼다」는 배경이고, 모순은 지금 하는 일을
@@ -3401,6 +3559,12 @@ function worldAppend(result) {
3401
3559
  // 그것이 안 풀리면 위의 메일·백신도 이 저장소에서는 영영 안 온다.
3402
3560
  const tn = tenantNotice();
3403
3561
  if (tn) result.content.push({ type: 'text', text: tn });
3562
+ /*
3563
+ * ★ 메모는 **델타보다 앞**이다. 델타는 「누가 무엇을 썼다」는 배경인데, 메모는
3564
+ * 저자가 **이 문서의 이 자리**를 짚어 남긴 할 일이다. 뒤에 두면 배경에 묻힌다.
3565
+ */
3566
+ const nn = notesSays();
3567
+ if (nn) result.content.push({ type: 'text', text: nn });
3404
3568
  const t = worldDelta();
3405
3569
  if (!t) return;
3406
3570
  /*
@@ -3666,6 +3830,7 @@ async function callTool(name, args = {}) {
3666
3830
  case 'get_report': {
3667
3831
  const q = args.outline ? '?outline=1' : '';
3668
3832
  const r = await api('GET', `/api/reports/${Number(args.id)}${q}`);
3833
+ notesSeen(Number(args.id), r.open_notes);
3669
3834
  if (!args.outline) {
3670
3835
  /*
3671
3836
  * ⚠ 전에는 여기서 **`r.data` 만** 돌려줬다. 서버가 정성껏 붙인 것이 이 한 줄에서
@@ -3698,6 +3863,62 @@ async function callTool(name, args = {}) {
3698
3863
  // 문맥이 두 배가 된다. (같은 말을 데이터 없는 문서에도 쓰므로 함수로 뺐다.)
3699
3864
  return outlineText(r.outline);
3700
3865
  }
3866
+ case 'list_notes': {
3867
+ const id = Number(args.id);
3868
+ const qs = new URLSearchParams();
3869
+ if (args.state) qs.set('state', String(args.state));
3870
+ if (args.uid) qs.set('uid', String(args.uid));
3871
+ const r = await api('GET', `/api/reports/${id}/notes${qs.toString() ? '?' + qs : ''}`);
3872
+ notesSeen(id, r.open);
3873
+ if (!r.notes || !r.notes.length) {
3874
+ return `보고서 #${id} — ${r.note || '메모가 없다.'}`;
3875
+ }
3876
+ /*
3877
+ * 답글을 부모 밑에 붙여 낸다. 평평하게 내면 에이전트가 **자기가 이미 한 말**을
3878
+ * 못 보고 같은 답을 다시 단다.
3879
+ */
3880
+ const kids = new Map();
3881
+ for (const n of r.notes) {
3882
+ if (n.parent_id !== null) {
3883
+ if (!kids.has(n.parent_id)) kids.set(n.parent_id, []);
3884
+ kids.get(n.parent_id).push(n);
3885
+ }
3886
+ }
3887
+ const STATE = {
3888
+ exact: '', moved: '(옮겨 붙음)', drifted: '(원문이 바뀜 — 저자가 본 것과 지금이 다르다)',
3889
+ reparented: '(문장을 잃어 상위로)', unmoored: '(자리를 잃음 — 문서 어디인지 모른다)',
3890
+ };
3891
+ const lines = [`보고서 #${id} · 본문 판 body_rev=${r.body_rev} · ${r.note}`, ''];
3892
+ for (const n of r.notes) {
3893
+ if (n.parent_id !== null) continue;
3894
+ const at = n.anchor.path || n.anchor.uid || '문서 전체';
3895
+ lines.push(`[메모 ${n.id}] ${at} ${STATE[n.anchor_state] || ''}${n.resolved ? ' · 반영됨' : ''}`);
3896
+ if (n.anchor.quote_exact) lines.push(` 저자가 짚은 글: 「${n.anchor.quote_exact}」`);
3897
+ lines.push(` ${n.author || ''}: ${n.body}`);
3898
+ for (const k of (kids.get(n.id) || [])) {
3899
+ lines.push(` ↳ ${k.author_kind === 'agent' ? '내 답글' : k.author}: ${k.body}`);
3900
+ }
3901
+ lines.push('');
3902
+ }
3903
+ return lines.join('\n').trimEnd();
3904
+ }
3905
+ case 'reply_note': {
3906
+ const id = Number(args.id);
3907
+ const r = await api('POST', `/api/reports/${id}/notes`,
3908
+ { note: Number(args.note), body: String(args.body ?? '') });
3909
+ return `${r.says} (메모 ${args.note} · 답글 ${r.note.id})`;
3910
+ }
3911
+ case 'resolve_note': {
3912
+ const id = Number(args.id);
3913
+ const body = { note: Number(args.note) };
3914
+ if (args.rev !== undefined) body.rev = Number(args.rev);
3915
+ if (args.resolved !== undefined) body.resolved = !!args.resolved;
3916
+ const r = await api('PATCH', `/api/reports/${id}/notes`, body);
3917
+ // 표시했으니 꼬리의 셈도 줄인다 — 안 줄이면 「고쳤는데 계속 재촉하는」 꼴이 된다.
3918
+ const cur = NOTES_OPEN.get(id) || 0;
3919
+ notesSeen(id, Math.max(0, cur - 1));
3920
+ return r.says;
3921
+ }
3701
3922
  case 'publish_report': {
3702
3923
  const data = await materializeAssets(args.data);
3703
3924
  // **기본은 초안이다.** 서버 기본값은 참(다른 호출자의 동작을 말없이 바꾸지 않는다)이라
@@ -3749,6 +3970,8 @@ async function callTool(name, args = {}) {
3749
3970
  const payload = { ...data, tenant: args.tenant };
3750
3971
  if (args.dry_run) payload.dry_run = true;
3751
3972
  const r = await api('PUT', `/api/reports/${Number(args.id)}`, payload, PUBLISH);
3973
+ // 고친 직후가 「무엇을 아직 안 고쳤나」를 볼 가장 좋은 순간이다.
3974
+ if (!r.dry_run) notesSeen(Number(args.id), r.open_notes);
3752
3975
  if (r.dry_run) {
3753
3976
  return `시험 실행 — 아무것도 바꾸지 않았습니다.\n${r.tenant} · 건 ${r.cases}` + warnText(r);
3754
3977
  }
@@ -3781,7 +4004,7 @@ async function callTool(name, args = {}) {
3781
4004
  + (w.stale ? ` · 낡음 ${w.stale}일째(AI 관측 — 활동이 멎었다: 사람이 닫거나 다시 살릴 일)` : '')
3782
4005
  + (w.doc ? ` · 요지: get_case(tenant, "${w.doc}")` : ' · 요지 문서 아직 없음'));
3783
4006
  }
3784
- if (!rows.length) lines.push('열린 일이 없다 — 0건은 0건이다. 착수 전에 세우려면 사람이 군체 화면의 「일 열기」로 연다.');
4007
+ if (!rows.length) lines.push('열린 일이 없다 — 0건은 0건이다. 착수 전에 세우려면 그 과업의 결정을 remember_decision 으로 남기되 **work 에 일의 열쇠를 적어라** — 다음 바퀴에 「계획」으로 선다. 표지 없이 tags 만 달면 신호가 쌓일 때까지 AI 가 기다린다.');
3785
4008
  else lines.push('붙을 일이 보이면 works(tenant, claim: "<slug>") 로 선언한다 — 확정은 사람이 화면에서 한다.');
3786
4009
  return lines.join('\n');
3787
4010
  }
@@ -3807,6 +4030,7 @@ async function callTool(name, args = {}) {
3807
4030
  title: args.title, what: args.what,
3808
4031
  who: args.who || '', when: args.when || '', why: args.why || '',
3809
4032
  reopen: args.reopen || '', status: args.status || '', tags: args.tags || [],
4033
+ work: args.work || '', order: args.order || [],
3810
4034
  });
3811
4035
  const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
3812
4036
  // 안 적은 것을 **그렇게 말한다** — 조용히 열린 결정을 만들거나 계산 밖에 두지 않는다.
@@ -3822,6 +4046,18 @@ async function callTool(name, args = {}) {
3822
4046
  } else if (r.insights_queued === false) {
3823
4047
  lines.push('전체 맥락 정리 줄은 안 섰다 — 재료가 아직 문턱 아래(배경 문서 3장 또는 결정·사례 5장)거나 이미 줄이 서 있다.');
3824
4048
  }
4049
+ // 표지(work:)의 앞날 — 서버가 저장 시점에 판정한 것을 그대로 옮긴다(지어내지 않는다).
4050
+ if (r.work_mark === 'will-open') {
4051
+ lines.push(`다음 바퀴(1분 안)에 일 «${args.work}» 가 「계획」으로 섭니다 — works 로 확인한다.`);
4052
+ } else if (r.work_mark === 'exists-open') {
4053
+ lines.push(`일 «${args.work}» 는 이미 열려 있다 — 이 결정은 그 일의 재료가 된다.`);
4054
+ } else if (r.work_mark === 'exists-closed') {
4055
+ lines.push(`일 «${args.work}» 는 닫혀 있다 — 다시 열려면 사람이 화면에서 재개한다(표지로는 안 열린다).`);
4056
+ } else if (r.work_mark === 'vetoed') {
4057
+ lines.push(`일 «${args.work}» 는 전에 사람이 닫은 이름이라 안 선다 — 다른 이름을 쓰거나 화면에서 재개한다.`);
4058
+ } else if (args.work) {
4059
+ lines.push('표지(work)를 적었지만 이 서버는 아직 그 칸을 모른다 — 판이 낡았다.');
4060
+ }
3825
4061
  return lines.join('\n');
3826
4062
  }
3827
4063
  case 'remember_case': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.16.2",
3
+ "version": "2.16.3",
4
4
  "description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
5
5
  "author": "주식회사 LIM (LIM Inc.)",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -188,7 +188,7 @@ python3 assets/render.py <report.json>
188
188
  | `stats[]` | `value` · `unit` · `label` · `accent` · `href` · `go` | 첫 칸만 `accent: true` |
189
189
  | `meta[]` | `label` · `value` | `[k, v]` 배열도 받는다 |
190
190
  | `open_items[]` | `title` · `priority`(`must` 면 「가장 시급」) · `body` · `refs[]` | `refs` 는 **첫 하나만** 「근거」 단추가 된다 |
191
- | `cases[]` | `group` · `title` · `grade` · `symptom` · `cause` · `fix` · `how_to_check` · `remaining` · `source_url` · `source_text` | 번호(`anchor`)는 **렌더러가 매긴다** |
191
+ | `cases[]` | `group` · `title` · `grade` · `symptom` · `cause` · `fix` · `how_to_check` · `remaining` · `source_url` · `source_text` · **`uid`** | 번호(`anchor`)는 **렌더러가 매기고**, `uid` 는 **서버가 발급한다** |
192
192
  | `appendix[]` | `no` · `title` · `rows[{label,value}]` · `note` | `rows` 가 비면 그 항목이 통째로 빠진다 |
193
193
  | `colophon` | `title` · `sub` · `rows[]` · `mark` · `badge` · `note` · `tail` | 넷(`title`·`rows`·`mark`·`note`)이 다 비면 판권지가 안 선다 |
194
194
  | `grades{}` | `키: [클래스, 설명, lvl?, tag?]` | 7절 |
@@ -196,6 +196,30 @@ python3 assets/render.py <report.json>
196
196
  `cases[].remaining` 이 있으면 그 건은 대조표에서 **미결**로 선다.
197
197
  그 행이 독자에게 넘어가는 몫이라 다른 행과 시각적으로 갈린다.
198
198
 
199
+ ### `cases[].uid` · `sections[].uid` — 건과 절의 **불변 이름**
200
+
201
+ **적지 않아도 된다. 다만 받은 것은 돌려보내라.**
202
+
203
+ 번호와 앵커는 **자리가 매긴다**(`case-03-1` = 3번째 절의 1번째 건). 그래서 절 하나를 앞에
204
+ 끼우거나 축을 갈아엎으면 **뒤의 앵커가 전부 밀린다** — 실측(2026-08-31, #32): 축을
205
+ 「조치 회차」에서 「검수 단위」로 바꾸자 건 19개 중 거의 전부의 앵커가 바뀌었다.
206
+
207
+ `uid` 는 그 자리와 무관한 이름이다(영소문자·숫자 6자). **서버가 발급하고 `get_report` 가
208
+ 돌려준다.** 렌더러는 `data-uid` 속성으로만 내보내고 **보이는 번호는 안 건드린다.**
209
+
210
+ | 이럴 때 | 이렇게 된다 |
211
+ |---|---|
212
+ | `get_report` 가 준 `uid` 를 **그대로 실어** `update_report` | 그 건의 이름이 유지된다 ✔ |
213
+ | `uid` 를 빼고 보냈는데 **제목이 같다** | 제목으로 다시 잇는다 |
214
+ | `uid` 를 빼고 제목도 바꿨다 | **새 이름이 발급된다** |
215
+ | 처음 발행(`publish_report`) | 전부 새로 발급된다 — 적을 필요 없다 |
216
+
217
+ **왜 지켜야 하나**: 저자가 그 건에 달아 둔 메모(`list_notes`)가 이 이름을 문다.
218
+ 이름이 바뀌면 메모가 사라지지는 않지만(인용문으로 다시 찾는다) 「자리를 잃음」으로 떨어져
219
+ 저자가 다시 붙여야 한다.
220
+
221
+ ★ **같은 제목을 두 건에 쓰지 마라.** 위 2단이 어느 쪽인지 못 갈라 둘 다 새 이름을 받는다.
222
+
199
223
  ---
200
224
 
201
225
  ## 4. 절 단위 키 — 전량