genetics-mcp 2.17.1 → 2.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/bin/aarch64-apple-darwin/gcell +0 -0
  2. package/bin/manifest.json +2 -2
  3. package/bin/x86_64-apple-darwin/gcell +0 -0
  4. package/genetics.mjs +71 -16
  5. package/package.json +1 -1
  6. package/skills/genetics-report/SKILL.md +146 -131
  7. package/skills/genetics-report/assets/example/cases-briefing-designs.json +32 -0
  8. package/skills/genetics-report/assets/example/plate-pair-after.png +0 -0
  9. package/skills/genetics-report/assets/example/plate-pair-before.png +0 -0
  10. package/skills/genetics-report/assets/example/plate-pair.html +25 -0
  11. package/skills/genetics-report/assets/example/report-briefing-designs.json +736 -0
  12. package/skills/genetics-report/assets/example/report-briefing.json +27 -22
  13. package/skills/genetics-report/assets/example/report.json +5 -5
  14. package/skills/genetics-report/assets/figure.mjs +390 -63
  15. package/skills/genetics-report/assets/mockshot.mjs +3 -2
  16. package/skills/genetics-report/assets/pair.mjs +138 -0
  17. package/skills/genetics-report/assets/shot.mjs +87 -19
  18. package/skills/genetics-report/references/content-pipeline.md +34 -33
  19. package/skills/genetics-report/references/history.md +117 -0
  20. package/skills/genetics-report/references/illustrations.md +16 -12
  21. package/skills/genetics-report/references/lim-portal-format.md +175 -91
  22. package/skills/genetics-report/references/linear-read-test.workflow.js +47 -0
  23. package/skills/genetics-report/references/process.md +152 -90
  24. package/skills/genetics-report/references/readability.md +48 -24
  25. package/skills/genetics-report/references/reader-review.workflow.js +160 -27
  26. package/skills/genetics-report/references/visual-first.md +100 -35
Binary file
package/bin/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "aarch64-apple-darwin": "1e93db10afd8d6125508d20af650c290c6967f19ac4e24543dae2a42ced1fb7f",
2
+ "aarch64-apple-darwin": "2589e4eda175ae7f1158a3dd4b31b327fd8af85ea82bbb661fac7990567286af",
3
3
  "aarch64-unknown-linux-musl": "aa25f20daefda931d8497d346bd47fb9b8a2acc3136ec2b9eeff18773a789e8c",
4
- "x86_64-apple-darwin": "84b539085af2ad89018296d54fe0a89ec932cae1ee0b67a445684ddbb8dfb8fb",
4
+ "x86_64-apple-darwin": "4de0feadcb7fe644c8004b76e0015518ffb330c6f13304684fb85aa2c99caa30",
5
5
  "x86_64-unknown-linux-musl": "c97bcded75ec34cdb5a448159e7f057a756deb6a45fa7793b299d0040c19be93"
6
6
  }
Binary file
package/genetics.mjs CHANGED
@@ -448,6 +448,15 @@ const isSvg = (b) => {
448
448
  */
449
449
  const SHOT_QUALITY_LADDER = [82, 72, 62, 52];
450
450
 
451
+ /** 무손실로 굳혔을 때의 크기만 잰다(바이트는 버림). cwebp 가 없거나 실패하면 null. */
452
+ function losslessSize(buf) {
453
+ try {
454
+ if (isSvg(buf)) return null;
455
+ const r = spawnSync('cwebp', ['-quiet', '-lossless', '-exact', '-o', '-', '--', '-'], { input: buf, maxBuffer: IMAGE_MAX_DOWNLOAD });
456
+ return r.status === 0 ? r.stdout.length : null;
457
+ } catch { return null; }
458
+ }
459
+
451
460
  function encodeShot(buf) {
452
461
  const d = imageDims(buf);
453
462
  const resize = (d && Math.max(d.w, d.h) > ASSET_LONG_EDGE)
@@ -526,6 +535,7 @@ function encodeAsset(buf, role, provider) {
526
535
  * 그리고 있었다(2026-08-16 실측). 선언을 안 받으면 그 결함이 구조적으로 안 생긴다.
527
536
  */
528
537
  async function materializeAssets(data) {
538
+ ASSET_HINTS = [];
529
539
  if (!data || !Array.isArray(data.assets) || !data.assets.length) return data;
530
540
  const out = [];
531
541
  for (const a of data.assets) {
@@ -552,6 +562,15 @@ async function materializeAssets(data) {
552
562
  // 원격보다 **로컬이 이긴다** — 손에 든 실물이 주소보다 확실하다.
553
563
  const raw = fpath ? readLocalImage(String(fpath)) : await fetchImage(String(a.source));
554
564
  const enc = encodeAsset(raw, String(a.role ?? ''), String(a.provider ?? ''));
565
+ // 평면 그림(HTML 개념도·도표)을 user-provided 로 올리면 손실 사다리가 선·글자를 뭉갠다(2026-09-09 앳플리 #173
566
+ // 그림 1-2 반려). 무손실이 손실보다 **작으면** 그 그림은 평면 그림이다 — 그때만 한 줄 알린다(계약은 안 바꾼다).
567
+ if (String(a.role) === 'figure' && String(a.provider) === 'user-provided') {
568
+ const ll = losslessSize(raw);
569
+ if (ll !== null && ll <= ASSET_MAX_ONE && ll <= enc.length) {
570
+ ASSET_HINTS.push(`도판 「${a.id}」: 무손실 ${ll.toLocaleString()}B 가 손실 ${enc.length.toLocaleString()}B 보다 작습니다 — `
571
+ + '평면 그림이면 provider: deterministic 으로(손실 사다리는 선·글자를 뭉갭니다). 사진에 가까운 화면 캡처면 그대로.');
572
+ }
573
+ }
555
574
  const d = imageDims(enc);
556
575
  out.push({
557
576
  ...meta,
@@ -872,6 +891,8 @@ const REPORT_DATA = {
872
891
  + 'rows[].no 가 `4.1`·`04-1` 모양이면 그 사례로 링크가 된다. '
873
892
  + '그림 번호(`그림 5-1`)는 적지 않는다 — 자리가 매기고 `plates` 와 한 셈을 쓴다.\n'
874
893
  + '· `plates`: [assets[].id, …] — 절 본문 도판.\n'
894
+ + '· 시각 우선(dry_run 이 ⚠ 로 센다 — 서식 문서 §10-1): 절마다 표·흐름도·차트·도판 중 하나, 문단 220자 안, 산문의 참조 번호 3개 이하, 판단 요청 6항 이상이면 한눈표+관문 흐름도, 3,000자 넘는 문서엔 핵심 굵게. 집필 공식(독자 한 명·예시 한 명·한 장 이야기·용어 잠금·그림 먼저)은 스킬 references/visual-first.md.\n'
895
+ + '· 인라인 표기(본문 문자열 어디서나 — 문단·도입문·여백 주석·표 셀·흐름도 칸·주석·부록 행): `**굵게**` → 핵심 구절, `==강조==` → 놓치면 안 되는 한 구절(노란 밑면), `{{ok|보통}}` → 상태 칩(톤 ok·wait·bad·info·muted 다섯뿐, 글자 40자 안), `[글자](https://…)` → 새 창으로 여는 하이퍼링크, 맨 `https://…` 주소도 그대로 링크가 됨(http(s) 만, 꼬리 문장부호는 링크 밖). 표기가 없으면 한 글자도 안 바뀐다. 핵심 한두 구절에만 — 문단 절반이 굵으면 굵기가 위계를 못 나른다. 출처는 부록 글자에만 두지 말고 건의 `source_url` 에도 실어라(「출처 원문」 행이 된다).\n'
875
896
  + '· `tables`: [{caption, cols[], rows[[]], align?, hint?, note?}] — **관보의 자료표.** '
876
897
  + '여러 항목을 축으로 견주는 자리다. 불릿 목록에 수를 눌러 담고 있으면 그건 표여야 한다. '
877
898
  + 'kind 와 무관하게 쓴다(kind:status 의 `board_title` 대조표와는 다른 것 — 저건 건 전용이다). '
@@ -886,6 +907,7 @@ const REPORT_DATA = {
886
907
  + 'HTML 막대와 CSS 로 짓는다. 값은 **본문 글자로 남고** 막대는 그 옆에 서므로 막대가 안 보이는 '
887
908
  + '곳에서도 문서가 성립한다. value 는 **표기 그대로 적는다**(`"118,716"`) — 콤마는 셈에서 걷어내므로 '
888
909
  + '같은 문서의 표와 표기가 갈리지 않게 하려면 데이터가 표기를 정해야 한다. '
910
+ + 'rows[].tone(ok·wait·bad·info·muted) 을 주면 그 행의 막대와 값에 뜻 색이 붙는다 — 예: 처리 완료 행은 muted, 위험 행은 bad. '
889
911
  + 'toggle{a,b} 를 주면 rows 는 value 대신 a·b 를 갖고 두 축을 `:checked` 로 갈아 낀다 '
890
912
  + '(체크박스는 스크립트가 아니라 상태다). 그림 번호는 `flow`·`plates` 와 **한 셈**을 쓴다.\n'
891
913
  + '데이터가 있을 때만 그려진다 — 없는 문구를 지어내지 말 것.\n'
@@ -1028,6 +1050,13 @@ const REPORT_DATA = {
1028
1050
  + '이 축을 포털본이 통째로 버려 2차 출처와 미확인 주장이 **사실로 단언돼 나갔다.** '
1029
1051
  + '증거를 실으면서 등급을 안 붙이면 문서가 실제보다 확실해 보인다.',
1030
1052
  },
1053
+ wide: {
1054
+ type: 'boolean',
1055
+ description: '**전폭 도판.** 두 단 그리드 밖, 절 끝에 문서 폭(약 1030px)으로 선다. '
1056
+ + '나란히 둔 화면 둘처럼 본문 단(760px)에서 절반으로 줄면 글자가 안 읽히는 그림에 켠다. '
1057
+ + '켜면 그 절의 그림 번호는 마지막이 된다. 켜든 말든 모든 도판은 눌러서 크게 보기(원본 '
1058
+ + '긴 변 1400px)가 열린다. 집필 때 figure.mjs --display 로 표시 글자 크기를 먼저 재라(하한 14px).',
1059
+ },
1031
1060
  brief: {
1032
1061
  type: 'string',
1033
1062
  description: '**무엇을 그릴지 — 장면 한두 문장.** 이것만 주면 서버가 그린다(그리는 동안 '
@@ -2535,7 +2564,9 @@ const TOOLS = [
2535
2564
  type: 'object',
2536
2565
  properties: {
2537
2566
  tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2538
- title: { type: 'string', description: '한 줄 제목. 파일 이름이 된다(같은 제목이면 덮어쓴다).' },
2567
+ title: { type: 'string', description: '한 줄 제목. 같은 경로가 있으면 expected_revision 없이는 저장되지 않는다.' },
2568
+ expected_revision: { type: 'string', pattern: '^[a-f0-9]{64}$', description: '기존 사례 수정 시 get_case에서 읽은 revision. 신규 생성은 생략. 충돌하면 원문을 다시 읽고 변경을 재검토한다.' },
2569
+ commit_id: { type: 'string', pattern: '^[a-zA-Z0-9][a-zA-Z0-9-]{15,79}$', description: '이 저장 작업의 고유 UUID. 첫 호출 전에 생성하고 응답 유실·재시도에는 같은 값과 같은 본문을 유지한다. 별도 수정은 새 값을 쓴다.' },
2539
2570
  symptom: { type: 'string', description: '겪은 현상. 원인이 아니라 **눈에 보인 것**을 그대로.' },
2540
2571
  cause: { type: 'string', description: '원인. 모르면 비운다 — 지어내지 않는다.' },
2541
2572
  fix: { type: 'string', description: '처방. 다시 만나면 그대로 하면 되는 동작.' },
@@ -2555,7 +2586,7 @@ const TOOLS = [
2555
2586
  } }, description: '사용자가 브라우저에서 **집은 요소**(iab 픽). 사용자가 "iab N" 이라 했으면 훅이 끼운 「#N <tag> · 경로:줄 · sel `…` · "글자"」 줄에서 selector·text·source 를 **그대로** 옮긴다 — 지어내지 않는다. '
2556
2587
  + '픽은 이 세션 밖으로 안 넘어가므로(다른 기계·다른 사람은 못 본다) 이 칸이 유일한 기록이다. 장에 「집은 자리」 절로 선다(최대 20, 칸당 300자).' },
2557
2588
  },
2558
- required: ['tenant', 'title', 'symptom', 'fix'],
2589
+ required: ['tenant', 'title', 'symptom', 'fix', 'commit_id'],
2559
2590
  },
2560
2591
  },
2561
2592
  {
@@ -2577,7 +2608,9 @@ const TOOLS = [
2577
2608
  type: 'object',
2578
2609
  properties: {
2579
2610
  tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2580
- title: { type: 'string', description: '한 줄 제목. 파일 이름이 된다. ⚠ 같은 제목이면 덮어쓴다 — **다른 결정이면 제목을 달리할 것**(서로 다른 결정은 합치지 않는다).' },
2611
+ title: { type: 'string', description: '한 줄 제목. 다른 결정은 제목을 달리한다. 기존 결정은 expected_revision을 전달해야 수정된다.' },
2612
+ expected_revision: { type: 'string', pattern: '^[a-f0-9]{64}$', description: '기존 결정 수정 시 get_case에서 읽은 revision. 신규 생성은 생략. 충돌하면 원문을 다시 읽고 변경을 재검토한다.' },
2613
+ commit_id: { type: 'string', pattern: '^[a-zA-Z0-9][a-zA-Z0-9-]{15,79}$', description: '이 저장 작업의 고유 UUID. 첫 호출 전에 생성하고 응답 유실·재시도에는 같은 값과 같은 본문을 유지한다. 별도 수정은 새 값을 쓴다.' },
2581
2614
  what: { type: 'string', description: '무엇을 정했나 — 한 문장.' },
2582
2615
  who: { type: 'string', description: '정한 사람 또는 그 자리에 있던 사람. 모르면 비운다.' },
2583
2616
  when: { type: 'string', description: '정한 날짜(예: 2026-08-27). 모르면 비운다 — 오늘로 지어내지 말 것.' },
@@ -2599,7 +2632,7 @@ const TOOLS = [
2599
2632
  url: { type: 'string', description: '그 화면 주소(알면)' },
2600
2633
  } }, description: '사용자가 브라우저에서 **집은 요소**(iab 픽) — remember_case 의 같은 칸. 사용자가 "iab N" 이라 했으면 훅 줄의 #N 에서 그대로 옮긴다, 지어내지 않는다. 장에 「집은 자리」 절로 선다(최대 20).' },
2601
2634
  },
2602
- required: ['tenant', 'title', 'what'],
2635
+ required: ['tenant', 'title', 'what', 'commit_id'],
2603
2636
  },
2604
2637
  },
2605
2638
  {
@@ -2616,8 +2649,9 @@ const TOOLS = [
2616
2649
  properties: {
2617
2650
  tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2618
2651
  path: { type: 'string', description: 'search_cases·remember_decision 이 준 path 그대로(decisions/….md). 제목을 줘도 저장과 같은 자로 찾는다.' },
2652
+ expected_revision: { type: 'string', pattern: '^[a-f0-9]{64}$', description: 'get_case로 읽은 삭제 대상의 revision.' },
2619
2653
  },
2620
- required: ['tenant', 'path'],
2654
+ required: ['tenant', 'path', 'expected_revision'],
2621
2655
  },
2622
2656
  },
2623
2657
  {
@@ -2640,8 +2674,9 @@ const TOOLS = [
2640
2674
  properties: {
2641
2675
  tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2642
2676
  path: { type: 'string', description: 'search_cases·remember_case 가 준 path 그대로(cases/….md). 제목을 줘도 저장과 같은 자로 찾는다.' },
2677
+ expected_revision: { type: 'string', pattern: '^[a-f0-9]{64}$', description: 'get_case로 읽은 삭제 대상의 revision.' },
2643
2678
  },
2644
- required: ['tenant', 'path'],
2679
+ required: ['tenant', 'path', 'expected_revision'],
2645
2680
  },
2646
2681
  },
2647
2682
  {
@@ -2947,9 +2982,12 @@ const TOOLS = [
2947
2982
  * `api.php` 에 'warnings' 는 0건이었다. 그런데 도구는 이 코드를 갖고 있었으니,
2948
2983
  * 에이전트는 「경고 없음」으로 읽었고 확인할 방법이 없었다. 전달 경로를 만든 뒤에도
2949
2984
  * 찍는 자리가 흩어져 있으면 또 한 곳이 빠지므로 여기 하나로 모은다. */
2985
+ // 자산을 굳히며 이쪽(작성자 기계)에서만 알 수 있는 것 — 서버 경고와 한 목록으로 낸다(readability.md R8).
2986
+ let ASSET_HINTS = [];
2950
2987
  function warnText(r) {
2951
- return r?.warnings?.length
2952
- ? `\n\n경고 ${r.warnings.length}건:\n· ${r.warnings.join('\n· ')}`
2988
+ const w = [...(r?.warnings ?? []), ...ASSET_HINTS];
2989
+ return w.length
2990
+ ? `\n\n경고 ${w.length}건:\n· ${w.join('\n· ')}`
2953
2991
  : '';
2954
2992
  }
2955
2993
 
@@ -4197,8 +4235,11 @@ async function callTool(name, args = {}) {
4197
4235
  return lines.join('\n');
4198
4236
  }
4199
4237
  case 'remember_decision': {
4238
+ if (typeof args.commit_id !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9-]{15,79}(?![\s\S])/.test(args.commit_id)) {
4239
+ throw new Error('commit_id는 영숫자로 시작하는 16~80자의 영숫자·하이픈이어야 합니다. 최초 요청의 키를 재시도에도 유지하세요.');
4240
+ }
4200
4241
  const r = await api('POST', '/api/wiki/decision?tenant=' + encodeURIComponent(args.tenant), {
4201
- title: args.title, what: args.what,
4242
+ title: args.title, what: args.what, expected_revision: args.expected_revision, commit_id: args.commit_id,
4202
4243
  who: args.who || '', when: args.when || '', why: args.why || '',
4203
4244
  reopen: args.reopen || '', status: args.status || '', tags: args.tags || [],
4204
4245
  work: args.work || '', order: args.order || [], plan: args.plan || [],
@@ -4208,6 +4249,10 @@ async function callTool(name, args = {}) {
4208
4249
  ...myCitation(),
4209
4250
  });
4210
4251
  const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
4252
+ if (r.revision) lines.push(`revision: ${r.revision}\ncommit_id: ${r.commit_id || ''}`);
4253
+ if (r.effects_pending) lines.push(r.effects_state === 'failed'
4254
+ ? '원문은 저장됐지만 후속 정리가 재시도 한도에 닿았습니다. 운영 확인이 필요합니다.'
4255
+ : '원문은 저장됐고 후속 정리는 대기 중입니다. 같은 내용을 새로 저장하지 마세요.');
4211
4256
  if (Number(r.picks) > 0) lines.push(`집은 자리 ${r.picks}건 실림 — 다른 기계·다른 사람도 그 요소를 본다.`);
4212
4257
  // 안 적은 것을 **그렇게 말한다** — 조용히 열린 결정을 만들거나 계산 밖에 두지 않는다.
4213
4258
  if (!args.reopen) {
@@ -4222,7 +4267,7 @@ async function callTool(name, args = {}) {
4222
4267
  // 전체 맥락 정리 줄이 섰는지도 말한다 — 결정 저장이 종합의 박자가 됐다(2026-08-31). 옛 서버(칸 없음)는 침묵.
4223
4268
  if (r.insights_queued === true) {
4224
4269
  lines.push('이 결정으로 전체 맥락 정리가 다시 돕니다(몇 분 뒤 지식 화면에).');
4225
- } else if (r.insights_queued === false) {
4270
+ } else if (r.insights_queued === false && !r.effects_pending) {
4226
4271
  lines.push('전체 맥락 정리 줄은 안 섰다 — 재료가 아직 문턱 아래(배경 문서 3장 또는 결정·사례 5장)거나 이미 줄이 서 있다.');
4227
4272
  }
4228
4273
  // 표지(work:)의 앞날 — 문장의 정본은 서버(work_decl_mark_note → 응답 work_note)다.
@@ -4234,14 +4279,15 @@ async function callTool(name, args = {}) {
4234
4279
  // 구서버(work_note 이전 판) — 판정값만 기계적으로 옮긴다. 안내문을 여기서 지어내면
4235
4280
  // 새 서버의 문장과 두 벌이 된다(위와 같은 병).
4236
4281
  lines.push(`일 표지 «${args.work}» 판정: ${r.work_mark} — 서버 판이 낡아 안내문이 없다(새 판은 문장을 함께 보낸다).`);
4237
- } else if (args.work) {
4282
+ } else if (args.work && !r.effects_pending) {
4238
4283
  lines.push('표지(work)를 적었지만 이 서버는 아직 그 칸을 모른다 — 판이 낡았다.');
4239
4284
  }
4240
4285
  return lines.join('\n');
4241
4286
  }
4242
4287
  case 'forget_decision': {
4243
4288
  const r = await api('DELETE', '/api/wiki/decision?tenant=' + encodeURIComponent(args.tenant)
4244
- + '&path=' + encodeURIComponent(String(args.path || '')));
4289
+ + '&path=' + encodeURIComponent(String(args.path || ''))
4290
+ + '&expected_revision=' + encodeURIComponent(String(args.expected_revision || '')));
4245
4291
  const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
4246
4292
  if (r.trashed_to) lines.push(`휴지통: ${r.trashed_to} (복구는 서버에서 사람이 파일을 되돌린다)`);
4247
4293
  if (typeof r.index_dropped === 'number') {
@@ -4253,7 +4299,8 @@ async function callTool(name, args = {}) {
4253
4299
  // forget_decision 과 같은 꼴(2026-09-03) — 서버 note·trashed_to·index_dropped 를 **그대로** 옮긴다.
4254
4300
  // 프로즈를 여기서 다시 지으면 두 벌이 된다(remember_decision 의 work_note 교훈, 2026-09-01).
4255
4301
  const r = await api('DELETE', '/api/wiki/case?tenant=' + encodeURIComponent(args.tenant)
4256
- + '&path=' + encodeURIComponent(String(args.path || '')));
4302
+ + '&path=' + encodeURIComponent(String(args.path || ''))
4303
+ + '&expected_revision=' + encodeURIComponent(String(args.expected_revision || '')));
4257
4304
  const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
4258
4305
  if (r.trashed_to) lines.push(`휴지통: ${r.trashed_to} (복구는 서버에서 사람이 파일을 되돌린다)`);
4259
4306
  if (typeof r.index_dropped === 'number') {
@@ -4262,8 +4309,12 @@ async function callTool(name, args = {}) {
4262
4309
  return lines.join('\n');
4263
4310
  }
4264
4311
  case 'remember_case': {
4312
+ if (typeof args.commit_id !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9-]{15,79}(?![\s\S])/.test(args.commit_id)) {
4313
+ throw new Error('commit_id는 영숫자로 시작하는 16~80자의 영숫자·하이픈이어야 합니다. 최초 요청의 키를 재시도에도 유지하세요.');
4314
+ }
4265
4315
  const r = await api('POST', '/api/wiki/case?tenant=' + encodeURIComponent(args.tenant), {
4266
4316
  title: args.title, symptom: args.symptom, cause: args.cause || '', fix: args.fix,
4317
+ expected_revision: args.expected_revision, commit_id: args.commit_id,
4267
4318
  evidence: args.evidence || '', tags: args.tags || [],
4268
4319
  paths: args.paths || [], commands: args.commands || [], errors: args.errors || [],
4269
4320
  picks: Array.isArray(args.picks) ? args.picks : [], // 집은 자리(2026-09-03) — 그대로 넘긴다, 모양은 서버가 검사
@@ -4273,10 +4324,14 @@ async function callTool(name, args = {}) {
4273
4324
  });
4274
4325
  // 밀기 결과도 그대로 옮긴다 — 몇에게 갔고 누가 왜 못 받았는지(구서버는 필드가 없어 침묵).
4275
4326
  const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
4327
+ if (r.revision) lines.push(`revision: ${r.revision}\ncommit_id: ${r.commit_id || ''}`);
4328
+ if (r.effects_pending) lines.push(r.effects_state === 'failed'
4329
+ ? '원문은 저장됐지만 후속 정리가 재시도 한도에 닿았습니다. 운영 확인이 필요합니다.'
4330
+ : '원문은 저장됐고 후속 정리는 대기 중입니다. 같은 내용을 새로 저장하지 마세요.');
4276
4331
  if (Number(r.picks) > 0) lines.push(`집은 자리 ${r.picks}건 실림 — 다른 기계·다른 사람도 그 요소를 본다.`);
4277
4332
  // ★ 창구가 낸 경고를 **맨 앞에** 옮긴다 — 쓰는 그 순간이 고치기 가장 싼 자리다(2026-09-01).
4278
4333
  if (r.warn) lines.push('⚠ ' + r.warn);
4279
- if (Array.isArray(r.matched_paths)) {
4334
+ if (Array.isArray(r.matched_paths) && !r.effects_pending) {
4280
4335
  if (!r.matched_paths.length) {
4281
4336
  lines.push('경로가 없어 아무에게도 안 갔다 — 그리고 **이 장이 어느 저장소의 일인지도 안 남았다**. 근거에 저장소 상대 경로(예: www/portal/lib/x.php:12)를 적으면 그 파일을 읽은 채로 든 세션에게 가고, 소속을 잘못 적었을 때 기계가 짚을 수 있다.');
4282
4337
  } else if (r.pushed > 0) {
@@ -4329,7 +4384,7 @@ async function callTool(name, args = {}) {
4329
4384
  const mine = myCitation().session_uuid || '';
4330
4385
  const r = await api('GET', '/api/wiki/page?tenant=' + encodeURIComponent(args.tenant)
4331
4386
  + '&path=' + encodeURIComponent(args.path) + (mine ? '&session=' + mine : ''));
4332
- return `${r.path}${r.signals ? '\n' + r.signals : ''}\n\n${r.markdown}`;
4387
+ return `${r.path}\nrevision: ${r.revision}${r.signals ? '\n' + r.signals : ''}\n\n${r.markdown}`;
4333
4388
  }
4334
4389
  case 'case_verdict': {
4335
4390
  const r = await api('POST', '/api/wiki/verdict?tenant=' + encodeURIComponent(args.tenant), {
@@ -4719,7 +4774,7 @@ async function handle(line) {
4719
4774
  ' 로컬 메모는 그 기계에서만 남는다. 다른 사람·다른 기계가 쓸 것이면 여기로 남긴다.',
4720
4775
  '',
4721
4776
  '「남겨줘 / 기억해줘 / 업데이트 / 갱신 / 동기화 / 올려둬」는 **무엇을 남기느냐로 갈린다**:',
4722
- '· 알아낸 것 하나(증상과 처방) → remember_case (같은 제목이면 덮어쓰므로 갱신도 이것)',
4777
+ '· 알아낸 것 하나(증상과 처방) → remember_case (갱신은 get_case의 revision을 expected_revision으로 전달)',
4723
4778
  '· 정한 것(증상이 없다 — 기획·디자인의 판단, 반려, "이렇게 가기로 했다") → remember_decision',
4724
4779
  '· 지금 이 대화 전체 → push_session (다른 기계가 이어받게)',
4725
4780
  '· 고객에게 나갈 문서 → publish_report / update_report',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.17.1",
3
+ "version": "2.18.1",
4
4
  "description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
5
5
  "author": "주식회사 LIM (LIM Inc.)",
6
6
  "license": "SEE LICENSE IN LICENSE",