genetics-mcp 2.14.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 (34) 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 +202 -9
  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 +9 -7
  28. package/skills/genetics-report/SKILL.md +53 -6
  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
Binary file
package/bin/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "aarch64-apple-darwin": "50e3f5feb6b5f3820063b9698bd947c5e12ac526434c0dd9373981b0e38abb58",
3
- "aarch64-unknown-linux-musl": "f6d1966397744977074fbbd912850eafd6f31a3f7adc560a06aa88cf070e94ea",
4
- "x86_64-apple-darwin": "29484f84cd380dc8612880e657bc8d10b3e36b37d3e5a34ce204becaec1dd378",
5
- "x86_64-unknown-linux-musl": "39b43a90e274a7c8d911c5179d3ef538983968af600f0c35230d32be8bb9353b"
2
+ "aarch64-apple-darwin": "5a7f8fe339f57613e1a5c3aac3ea9419eef9b594dec90242f8ba10ecfd52f8dc",
3
+ "aarch64-unknown-linux-musl": "09f0484776de01c1d4152a02081d68dcf5db5dd9edd749b4e19304539208e714",
4
+ "x86_64-apple-darwin": "67fc347c2c744d50c034a8bb95a6693707bb3b44687c5f4ddb3b9ded7b3b6c1a",
5
+ "x86_64-unknown-linux-musl": "083bd1678726e3925c7658cfc51372053a295cc8db139eff5433ba88e1aae3b6"
6
6
  }
Binary file
Binary file
package/genetics.mjs CHANGED
@@ -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
  // 원격보다 **로컬이 이긴다** — 손에 든 실물이 주소보다 확실하다.
@@ -953,7 +967,8 @@ const REPORT_DATA = {
953
967
  + '로 두면 화질을 계단으로 내려 예산 안에 앉힌다. 그 도판은 `sections[].plates` 에 id 를 적어야 자리가 생긴다. '
954
968
  + '크기와 형식은 인코딩 결과에서 읽으므로 선언하지 않는다. 전부 data: URI 로 문서에 박히며 '
955
969
  + '(열람 CSP 가 외부 이미지를 막는다), 서버가 출처·매직바이트·예산(장당 80,000B·합계 400,000B)을 '
956
- + '다시 검증한다.',
970
+ + '다시 검증한다. **갱신(update_report)에서 이 칸을 빼면 저장된 그림이 유지되고, `[]` 면 지워진다** — '
971
+ + 'plates 가 가리키는 그림이 없으면 422.',
957
972
  items: {
958
973
  type: 'object',
959
974
  properties: {
@@ -1266,6 +1281,25 @@ function findTranscript(uuid) {
1266
1281
  const f = path.join(root, d.name, `${uuid}.jsonl`);
1267
1282
  if (fs.existsSync(f)) return f;
1268
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
+ }
1269
1303
  return null;
1270
1304
  }
1271
1305
 
@@ -1434,9 +1468,16 @@ async function pushSession(tenant, uuid, title, dryRun) {
1434
1468
  * 진짜 출처는 이미 손에 있었다: **본문 각 줄의 `cwd`**(scrubTranscript 가 센다).
1435
1469
  * 없을 때만 옛 방식으로 물러선다 — 그래야 퇴보가 없다.
1436
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}`);
1437
1478
  const body = {
1438
1479
  tenant, uuid,
1439
- origin_cwd: sc.cwd || relHome(process.cwd()),
1480
+ origin_cwd: sc.cwd || (isCursorBody ? relHome(cursorWorkspaceFolder() || '') : relHome(process.cwd())),
1440
1481
  origin_git: gitHere(), tools: toolsStamp(),
1441
1482
  jsonl: sc.jsonl,
1442
1483
  };
@@ -2157,7 +2198,9 @@ const TOOLS = [
2157
2198
  name: 'list_my_tenants',
2158
2199
  description:
2159
2200
  '**내가 속한 곳 목록.** 다른 도구가 요구하는 `tenant` 값(slug, 이름도 받는다)을 여기서 얻는다.\n'
2160
- + '값을 모르는 채로 `tenant` 를 **지어내지 말 것** — 이것부터 부르고, 여럿이면 사용자에게 고르게 한다.\n'
2201
+ + '값을 **아직 모를 때만** 이것부터 부른다. 사용자 글에 `tenant:` 줄, `/t/<slug>/`, 또는 세션 uuid 가 있으면 '
2202
+ + '그것을 쓰고 **되묻지 마라** — 고르지 말라는 것은 없는 값을 추측하지 말라는 뜻이다.\n'
2203
+ + '여럿인데 사용자 글에 값이 없으면 사용자에게 고르게 한다.\n'
2161
2204
  + '지금 폴더에 대해 서버가 **본 것**을 함께 준다 — 「이 자리는 지금까지 어디에서 올라왔나」. '
2162
2205
  + '그것은 **기록이지 근거가 아니다**: 서버가 한 곳으로 좁히지 못하거나 자리를 못 읽으면 '
2163
2206
  + '그렇게 말한다. 좁혀지지 않으면 **사용자에게 물어라.**',
@@ -2286,8 +2329,10 @@ const TOOLS = [
2286
2329
  name: 'update_report',
2287
2330
  description:
2288
2331
  '기존 보고서의 내용을 교체한다. 보고서 id·주소가 그대로라 **이미 고객에게 나간 열람 링크가 새 내용을 가리킨다**. ' +
2289
- '사람이 편집기에서 만진 문서는 서버가 거절한다 — 여기서 덮으면 다음 편집기 발행 때 그 변경이 조용히 사라지기 때문이다. ' +
2290
- '그때는 응답에 편집 주소가 실려 오니 사람에게 그 주소를 알려 주면 된다.',
2332
+ '사람이 편집기로 고친 문서도 교체할 수 있다(2026-08-25) — 대신 무엇을 덮게 되는지 목록이 먼저 말하고, 덮은 뒤 되돌릴 판이 응답에 온다. ' +
2333
+ '**그림 계약**: `assets` 를 **빼고** 보내면 서버가 저장된 그림을 그대로 유지한다. 전부 지우려면 `assets: []` 를 명시하고 ' +
2334
+ '`sections[].plates`·`chapters[].asset` 에서도 그 id 를 빼야 한다 — 가리키는 곳이 없는 id 는 422 로 거절된다. ' +
2335
+ '한 장만 바꾸려면 그 id 에 새 `source`/`file` 을 주고 나머지는 get_report 가 준 표식 그대로 두면 된다.',
2291
2336
  inputSchema: {
2292
2337
  type: 'object',
2293
2338
  properties: {
@@ -2341,6 +2386,54 @@ const TOOLS = [
2341
2386
  required: ['tenant', 'title', 'symptom', 'fix'],
2342
2387
  },
2343
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
+ },
2344
2437
  {
2345
2438
  name: 'search_cases',
2346
2439
  description:
@@ -2487,6 +2580,7 @@ const TOOLS = [
2487
2580
  name: 'resume_session',
2488
2581
  description:
2489
2582
  '서버에 있는 대화를 **지금 이 창이 이어받는다.** 포털의 「이어받기」 버튼이 복사해 주는 문구가 이 도구를 부른다.\n' +
2583
+ '붙여넣기에 tenant·uuid 가 있으면 **지금 바로 이 도구를 부른다.** list_my_tenants 로 소속처를 되묻지 마라.\n' +
2490
2584
  '한 번에 세 가지를 한다 — ① 그 대화의 요지를 받아 **여기서 바로 읽는다**(창을 새로 열 필요가 없다), ' +
2491
2585
  '② 원본 jsonl 을 이 기계의 제자리에 놓는다(나중에 `claude --resume <uuid>` 로 원본을 열 수 있다), ' +
2492
2586
  '③ 이 기계가 그 대화를 쥐었다고 서버에 알린다(다른 기계가 「누가 이어가는지」를 본다).\n' +
@@ -2495,7 +2589,7 @@ const TOOLS = [
2495
2589
  inputSchema: {
2496
2590
  type: 'object',
2497
2591
  properties: {
2498
- tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2592
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다). 사용자 글의 tenant: 줄을 그대로 쓴다. 값이 있을 때 list_my_tenants 로 되묻지 않는다.' },
2499
2593
  uuid: { type: 'string', description: '이어받을 세션 id.' },
2500
2594
  into: { type: 'string', description: '원본 파일을 놓을 자리(생략하면 지금 폴더). '
2501
2595
  + '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 원본을 열 자리다.' },
@@ -3455,6 +3549,7 @@ async function callTool(name, args = {}) {
3455
3549
  return ` · ${t.name}${sl}${seen(t)}${mark(t)} · 최근 ${when}`;
3456
3550
  });
3457
3551
  return `속한 곳 ${list.length}곳입니다. **사용자에게 어디로 할지 물어보세요** — 고르지 마세요.\n`
3552
+ + '이미 이 턴의 사용자 글에 slug 나 uuid 가 있으면 그것이 답입니다. 묻는 것은 값이 없을 때뿐입니다.\n'
3458
3553
  + `${lines.join('\n')}\n`
3459
3554
  + (says ? `\n${says}` : '');
3460
3555
  }
@@ -3553,6 +3648,13 @@ async function callTool(name, args = {}) {
3553
3648
  head.push(`[정본 ${owner} · 본문 판 body_rev=${r.body_rev ?? '?'}`
3554
3649
  + (r.sha256 ? ` · 지금 발행물 ${r.bytes}B sha256:${String(r.sha256).slice(0, 12)}…` : '')
3555
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
+ }
3556
3658
  // 데이터가 없으면 서버가 outline 을 함께 준다 — 그것이 지금 고객이 보는 것의 유일한 요약이다.
3557
3659
  if (r.data === null && r.outline) head.push(outlineText(r.outline));
3558
3660
  return `${head.join('\n')}\n${JSON.stringify(r.data, null, 2)}`;
@@ -3626,6 +3728,39 @@ async function callTool(name, args = {}) {
3626
3728
  if (!facts.length) return '이 저장소에서 최근에 남이 바꾼 것은 없다.';
3627
3729
  return `이 저장소에서 최근에 바뀐 것:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
3628
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
+ }
3629
3764
  case 'remember_case': {
3630
3765
  const r = await api('POST', '/api/wiki/case?tenant=' + encodeURIComponent(args.tenant), {
3631
3766
  title: args.title, symptom: args.symptom, cause: args.cause || '', fix: args.fix,
@@ -4081,14 +4216,15 @@ async function handle(line) {
4081
4216
  '',
4082
4217
  '「남겨줘 / 기억해줘 / 업데이트 / 갱신 / 동기화 / 올려둬」는 **무엇을 남기느냐로 갈린다**:',
4083
4218
  '· 알아낸 것 하나(증상과 처방) → remember_case (같은 제목이면 덮어쓰므로 갱신도 이것)',
4219
+ '· 정한 것(증상이 없다 — 기획·디자인의 판단, 반려, "이렇게 가기로 했다") → remember_decision',
4084
4220
  '· 지금 이 대화 전체 → push_session (다른 기계가 이어받게)',
4085
4221
  '· 고객에게 나갈 문서 → publish_report / update_report',
4086
4222
  '무엇인지 모호하면 묻기 전에 search_cases 로 먼저 뒤져 본다 — 이미 있으면 갱신이 답이다.',
4087
4223
  '',
4088
4224
  '자격은 **`login` 으로 받는 SSO 하나면 된다** — 그것으로 내가 속한 소속처 전부에 닿는다.',
4089
4225
  '키(rpk_)도 쓸 수 있지만 **한 소속처에만** 매이고, 발행할 때만 그쪽이 먼저 쓰인다.',
4090
- '어디로 갈지는 tenant 에 소속처 **slug** 로 적는다(이름도 받는다 — list_my_tenants 가 둘 다 알려 준다).',
4091
- '값을 모르면 지어내지 말고 사용자에게 묻는다 —',
4226
+ '어디로 갈지는 tenant 에 소속처 **slug** 로 적는다(이름도 받는다 — 모를 때만 list_my_tenants).',
4227
+ '사용자 글에 tenant·uuid 가 있으면 그대로 쓰고 되묻지 않는다. 값을 모르면 지어내지 말고 사용자에게 묻는다 —',
4092
4228
  '내 소속이 아닌 곳은 존재도 안 보이므로, 404 는 "없다"가 아니라 "내 것이 아니다"일 수 있다.',
4093
4229
  ].join('\n'),
4094
4230
  };
@@ -4154,6 +4290,58 @@ const IS_MAIN = (() => {
4154
4290
  * 자리는 판이 바뀌어도 안 움직이는 `~/.config/genetics/bin/gcell` 이다. 훅이 그 경로를
4155
4291
  * 가리키므로, 갈아 끼워도 훅을 다시 걸 필요가 없다.
4156
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
+
4157
4345
  function cellInstall({ hooks = true, ifRepo = false } = {}) {
4158
4346
  const out = [];
4159
4347
  const t = cellTarget();
@@ -4178,6 +4366,9 @@ function cellInstall({ hooks = true, ifRepo = false } = {}) {
4178
4366
  if (got !== want) return { ok: false, out: ['세포를 깔았는데 지문이 안 맞는다 — 쓰지 않는다.'] };
4179
4367
  out.push(`세포를 ${CELL_PATH} 에 깔았다(${VERSION} · ${want.slice(0, 12)}…).`);
4180
4368
  }
4369
+ // 판이 같아도 링크는 맞춘다 — 옛 판이 링크 없이 깔린 기계가 있다(이 기계가 그랬다).
4370
+ const linked = cellLinkOnPath();
4371
+ if (linked) out.push(linked);
4181
4372
  if (hooks) {
4182
4373
  // `ifRepo` 는 **자동으로 부를 때만** 준다 — 사람이 `install_cell` 을 부른 자리는 자기가
4183
4374
  // 정한 곳이지만, 부팅마다 도는 걸음은 git 저장소가 아닌 폴더에 설정을 만들면 안 된다.
@@ -4210,6 +4401,8 @@ function cellUninstall() {
4210
4401
  } else {
4211
4402
  out.push('깔린 세포가 없다.');
4212
4403
  }
4404
+ // PATH 에 건 심링크도 걷는다 — 남겨 두면 `gcell` 이 없는 파일을 가리킨다(dangling).
4405
+ out.push(...cellUnlinkOnPath());
4213
4406
  // 자격 파일 옆의 경로 줄도 걷는다 — 남겨 두면 다음 판이 없는 것을 가리킨다.
4214
4407
  try {
4215
4408
  const env = path.join(os.homedir(), '.config/genetics/api.env');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
5
5
  "author": "주식회사 LIM (LIM Inc.)",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -0,0 +1,71 @@
1
+ # gazette-report
2
+
3
+ 정적 HTML 보고서를 **한국 정부간행물(관보) 문서 문법**으로 조판하는 스킬.
4
+
5
+ `SKILL.md` 가 방법론, `scripts/` 가 검증 도구다.
6
+
7
+ ## scripts
8
+
9
+ | 파일 | 무엇 | 실행 |
10
+ |---|---|---|
11
+ | `kcheck.mjs` | 대비(알파 합성)·제목 레벨·표 접근명·앵커·넘침(7폭)·인쇄 숨김·reduced-motion | `node kcheck.mjs <URL>` |
12
+ | `read.mjs` | 가독성 — 한글 자폭을 실측해 글자/줄·행간·대비 | `node read.mjs <URL> [폭]` |
13
+ | `flexcheck.mjs` | flex/grid 안에서 문장이 토막 나는 곳 | `node flexcheck.mjs <URL>` |
14
+ | `tone.mjs` | 개조식 톤 — 경어체 잔존 · 늘어짐 지표 9종 · **한 문장 한 줄** — **의존 0**(`fs` 뿐, playwright 불필요) | `node tone.mjs <파일.html>` |
15
+ | `pdfcheck.sh` | 실제 PDF 생성 → 쪽 구조·쪽번호·러닝 푸터 | `./pdfcheck.sh <URL> [out.pdf]` |
16
+ | `cssaudit.sh` | 활자 스케일·간격 격자·라운드·상자·표 마감·미봉책 + **route 판정** | `./cssaudit.sh <file.css>` |
17
+ | `fontchk.mjs` | **렌더된** 활자 크기·굵기 분포 (4개 폭) | `node fontchk.mjs <URL> 12,14,17,…` |
18
+ | `contentdiff.sh` | **내용 불변 게이트** — 조판이 본문·id·data·앵커를 바꿨는지 | `./contentdiff.sh <before.html> <after.html>` |
19
+ | `typesurvey.mjs` | **어느 것이** 어느 크기인가 — 서명별 크기·굵기 **분포** + 평균 자수 | `node typesurvey.mjs <URL> [폭]` |
20
+ | `extract.sh` | 검사가 아님 — 레퍼런스 CSS 에서 `template/gazette.css` 재추출 | `./extract.sh <원본.css> <나갈.css>` |
21
+ | `sentbreak.py` | 검사가 아님 — **한 문장 한 줄** 조판을 마크업에 새긴다(멱등, 낱말 불변) | `python3 sentbreak.py <파일.html> --apply` |
22
+ | `boundary.py` | 문장 끝 마침표 판별 (날짜·버전·경로·머리글자 제외). 단독 실행 시 자체 시험 | `python3 boundary.py` |
23
+
24
+ **선택자를 하드코딩하지 않는다.** `kcheck.mjs`·`read.mjs`·`typesurvey.mjs` 는 DOM 에서 글자를
25
+ 가진 요소를 클래스 서명별로 자동 수집한다 — 어느 문서에나 그대로 쓸 수 있고, 새로 만든
26
+ 컴포넌트도 검사 목록에 손대지 않아도 자동으로 잡힌다.
27
+
28
+ **대표값 하나로 서명을 요약하지 않는다.** 크기·굵기는 분포로 모은다. 첫 표본을 대표로 찍으면
29
+ 같은 클래스가 변형이나 상속으로 갈리는 경우를 통째로 놓친다.
30
+
31
+ ## template — 뼈대
32
+
33
+ 새 문서는 `template/` 에서 시작한다. `gazette.css`(문서 무관 조판) + `skeleton.html`(가구 9요소 골격).
34
+ **뼈대 자체가 8종 검사를 통과한 상태**로 배포된다 — 자세한 것은 `template/README.md`.
35
+
36
+ ```bash
37
+ cp -r template 새문서 && cd 새문서 && python3 -m http.server 8781
38
+ ```
39
+
40
+ **뼈대를 손으로 고치지 마라.** `gazette.css` 는 레퍼런스 구현에서 뽑은 **추출물**이다.
41
+ 조판 규칙이 바뀌면 레퍼런스를 고치고 `scripts/extract.sh` 를 다시 돌린다 —
42
+ 손으로 따라 고쳤다가 레퍼런스에서 없앤 12px 등급이 뼈대에 47곳 남은 적이 있다.
43
+
44
+ ## 준비물
45
+
46
+ ```bash
47
+ cd scripts && npm i playwright-core # 브라우저 자동화 — **scripts/ 안에** 설치해야 한다.
48
+ # ESM 은 가져오는 모듈 위치에서 위로 훑는다. cwd 가 아니다.
49
+ brew install poppler # pdfinfo · pdftotext · pdftoppm (pdfcheck.sh)
50
+ export CHROME="/path/to/Google Chrome for Testing" # 없으면 ms-playwright 기본 경로
51
+ ```
52
+
53
+ ## 전형적 사용
54
+
55
+ ```bash
56
+ python3 -m http.server 8780 & # 문서를 띄우고
57
+ node kcheck.mjs http://localhost:8780/index.html
58
+ node read.mjs http://localhost:8780/index.html 1440
59
+ node read.mjs http://localhost:8780/index.html 390
60
+ node flexcheck.mjs http://localhost:8780/index.html
61
+ node tone.mjs index.html
62
+ ./pdfcheck.sh http://localhost:8780/index.html
63
+ ```
64
+
65
+ 전부 지적 0 이 될 때까지 고친다. **0표본 통과는 통과가 아니다** — 표본 수를 함께 본다.
66
+
67
+ ## 출처
68
+
69
+ 문체 검사(`tone.mjs`)의 늘어짐 지표 분류·처방, 심각도 3단계(S1/S2/S3), route 3경로는
70
+ [epoko77-ai/im-not-ai](https://github.com/epoko77-ai/im-not-ai) (MIT) 의 「AI 한글 티 분류 체계」에서
71
+ 관보 보고서에도 해당하는 항목만 골라 가져왔다. 번역투·영어 인용·리듬 균일성은 가져오지 않았다.