genetics-mcp 2.26.0 → 2.27.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 +5 -5
  4. package/bin/x86_64-apple-darwin/gcell +0 -0
  5. package/bin/x86_64-pc-windows-gnu/gcell.exe +0 -0
  6. package/bin/x86_64-unknown-linux-musl/gcell +0 -0
  7. package/genetics.mjs +1527 -191
  8. package/package.json +1 -1
  9. package/skills/gazette-report/SKILL.md +11 -1
  10. package/skills/gazette-report/scripts/tone.mjs +30 -3
  11. package/skills/gazette-report/scripts/tone.test.mjs +31 -0
  12. package/skills/genetics-cell-id/SKILL.md +73 -54
  13. package/skills/genetics-define/SKILL.md +34 -1
  14. package/skills/genetics-report/SKILL.md +67 -69
  15. package/skills/genetics-report/assets/example/cases-briefing-designs.json +15 -5
  16. package/skills/genetics-report/assets/example/cases-briefing.json +14 -5
  17. package/skills/genetics-report/assets/example/cases.json +25 -9
  18. package/skills/genetics-report/assets/example/decision.json +313 -0
  19. package/skills/genetics-report/assets/example/report-briefing-designs.json +2 -2
  20. package/skills/genetics-report/assets/example/report-briefing.json +1 -1
  21. package/skills/genetics-report/assets/example/report.json +65 -21
  22. package/skills/genetics-report/assets/render.py +204 -49
  23. package/skills/genetics-report/references/content-pipeline.md +6 -6
  24. package/skills/genetics-report/references/decision-format.md +112 -0
  25. package/skills/genetics-report/references/diagrams.md +52 -94
  26. package/skills/genetics-report/references/edition-review.workflow.js +81 -31
  27. package/skills/genetics-report/references/history.md +35 -0
  28. package/skills/genetics-report/references/lim-portal-format.md +92 -236
  29. package/skills/genetics-report/references/linear-read-test.workflow.js +33 -10
  30. package/skills/genetics-report/references/process.md +32 -25
  31. package/skills/genetics-report/references/readability.md +9 -8
  32. package/skills/genetics-report/references/reader-review.workflow.js +167 -25
  33. package/skills/genetics-report/references/visual-first.md +54 -42
  34. package/skills/genetics-report/assets/diagram.mjs +0 -208
package/genetics.mjs CHANGED
@@ -21,7 +21,7 @@
21
21
  * 의존성 0 — MCP 는 stdio 위의 JSON-RPC 2.0 이라 표준 라이브러리로 충분하다.
22
22
  */
23
23
 
24
- import { spawn, spawnSync } from 'node:child_process';
24
+ import { execFile, spawn, spawnSync } from 'node:child_process';
25
25
  import crypto from 'node:crypto';
26
26
  import fs from 'node:fs';
27
27
  import os from 'node:os';
@@ -1059,7 +1059,8 @@ async function apiOpen(method, pathname, body) {
1059
1059
  }
1060
1060
  const res = await fetch(BASE + pathname, {
1061
1061
  method,
1062
- headers: { 'Content-Type': 'application/json' },
1062
+ // 판 머리 — api() 와 같은 규율(모든 요청 · 하위 업무 번호 3단계 잣대 ②).
1063
+ headers: { 'X-Genetics-Client': `genetics-mcp/${VERSION}`, 'Content-Type': 'application/json' },
1063
1064
  body: body === undefined ? undefined : JSON.stringify(body),
1064
1065
  });
1065
1066
  const text = await res.text();
@@ -1165,7 +1166,9 @@ async function api(method, pathname, body, opts = {}) {
1165
1166
  res = await fetch(BASE + pathname, {
1166
1167
  method,
1167
1168
  // opts.headers 는 자동 올리기의 머리 셋(§4.3)이 쓴다 — 자격·형식 머리는 덮지 못한다.
1168
- headers: { ...(opts.headers || {}), Authorization: `Bearer ${c.token}`, 'Content-Type': 'application/json' },
1169
+ // ★ 판 머리(X-Genetics-Client)는 **모든 요청**에 싣는다(하위 업무 번호 DESIGN-item-codes.md · R16 ③) — 서버가 「경로만 아는 판」의
1170
+ // 요청을 판별로 센다(3단계로 넘어가는 잣대 ②). 전에는 자동 올리기 길에만 있었다. 자동 올리기의 같은 값은 그대로 덮어 쓴다.
1171
+ headers: { 'X-Genetics-Client': `genetics-mcp/${VERSION}`, ...(opts.headers || {}), Authorization: `Bearer ${c.token}`, 'Content-Type': 'application/json' },
1169
1172
  body: body === undefined ? undefined : JSON.stringify(body),
1170
1173
  signal,
1171
1174
  });
@@ -1259,7 +1262,8 @@ async function apiToFile(pathname, target, opts = {}) {
1259
1262
  + '주소를 바꾸라는 안내를 받았다면 의심할 것.');
1260
1263
  }
1261
1264
  const res = await fetch(BASE + pathname, {
1262
- headers: { Authorization: `Bearer ${cred.token}`, Accept: 'application/x-ndjson' },
1265
+ // 판 머리 — api() 와 같은 규율(모든 요청 · 하위 업무 번호 3단계 잣대 ②).
1266
+ headers: { 'X-Genetics-Client': `genetics-mcp/${VERSION}`, Authorization: `Bearer ${cred.token}`, Accept: 'application/x-ndjson' },
1263
1267
  });
1264
1268
  if (!res.ok) {
1265
1269
  // 실패는 작다 — 그때는 봉투를 읽어 서버가 준 말을 그대로 옮긴다.
@@ -1294,22 +1298,357 @@ function limMeta(h) {
1294
1298
  }
1295
1299
 
1296
1300
  // ── 도구 ────────────────────────────────────────────────────────────
1297
- // 이 도구가 발행하는 것은 **"수정 내역 보고" 서식 하나**다(서버의 build.py 가 그것만 안다).
1298
- // 포털 자체는 문서 종류를 가리지 않지만, 데이터로 발행하는 길은 아직 이 서식뿐이다.
1301
+ // 데이터로 발행하는 서식은 **하나**다(서버 report_publish.php 의 REPORT_TEMPLATES — 관보 v3 2026-10-07 ⑨) — lim-report(관보).
1302
+ // 고객이 고를 결정이 있는 문서는 같은 서식에 결정 블록(`decision_brief.decisions`)을 적는다 — 결정 블록이 있으면 결정 모드, 없으면
1303
+ // 보고 모드다(모드는 서버 report_mode · 렌더러 build.py report_mode 가 원고 내용으로 고른다). 옛 이름 lim-decision 은 서버가 lim-report 로 맞춘다.
1299
1304
  //
1300
1305
  // 필수 칸을 세우지 않는 이유: 전에는 표지 4칸과 건마다 6칸이 다 차 있어야 발행이 됐고,
1301
1306
  // 그래서 **초안을 올려 두고 다듬는 일이 아예 불가능했다.** 비어 있는 것은 사람이 정할 일이다.
1302
1307
  // 서버가 여전히 막는 것은 **조용히 사라지는 것**뿐이다 — 모르는 kind 의 절, 없는 그룹의 건.
1308
+ // (결정 모드는 하나 더 막는다 — 이대로 그리면 비대칭·헛고리·거짓이 되는 원고. 필수는 결정의 뼈대뿐이다 — 아래 정의가 말한다.)
1309
+
1310
+ // ── 원고 칸 정의 시작 — tools/contract-drift.py 가 여기서부터 REPORT_DATA 끝까지 읽는다 ──
1311
+ /*
1312
+ * 결정 블록 원고 칸(관보 버전2 2026-10-06 → v3 2026-10-07: 서식 하나 · 결정 블록이 있으면 결정 모드) — 전부 `data.decision_brief`
1313
+ * 하나에 묶는다. 결정 블록을 안 쓰는 세션이
1314
+ * 읽을 몫을 줄이려는 것이다(설계안 7.1·A-10). 칸 설명은 칸마다 「뜻 한 줄 + 빈 값을 쓰는 때」만 둔다 —
1315
+ * 스킬에서 걷어 낸 규칙 글을 여기로 옮겨 오면 도구를 부를 때마다 읽는 글이 그만큼 는다.
1316
+ * 다시 쓰는 꼴은 아래 다섯의 상수로 끼워 넣는다. `$ref`·`$defs` 는 쓰지 않는다 — 도구 스키마는 평평한 JSON 으로
1317
+ * 나가고, 클라이언트마다 다른 `$ref` 해석에 기대지 않는다. 그래서 끼워 넣는 꼴은 **설명 없이 뼈대만** 둔다 —
1318
+ * 빈 값 객체 넷은 한 꼴(`empty` 열거)로 접고, 빈 값의 부속 칸과 주체 꼴의 뜻은 DECISION_BRIEF 설명에 한 번만 적는다.
1319
+ * 끼워 넣는 서른여 자리마다 되풀이되면 도구 목록(발행·교체 두 도구가 같은 객체를 품는다)이 그만큼 커진다.
1320
+ * 부속 칸의 짝(na+why …)은 서버가 잰다(V-EMPTY).
1321
+ */
1322
+ const DEC_SENTENCE = {
1323
+ oneOf: [
1324
+ { type: 'string', minLength: 1 },
1325
+ { type: 'object', properties: { empty: { type: 'string', enum: ['na', 'undecided', 'elsewhere', 'unverified'] } }, required: ['empty'] },
1326
+ ],
1327
+ };
1328
+ const DEC_WHO = {
1329
+ oneOf: [
1330
+ { type: 'string', pattern: '^(customer|fcg|party:[a-z0-9-]{1,32})$' },
1331
+ DEC_SENTENCE.oneOf[1],
1332
+ ],
1333
+ };
1334
+ /*
1335
+ * 문장 또는 블록 목록(2026-10-08) — now · options[].what · facts_lead · opinion 넷만 쓴다. 문장 칸(글자 · 빈 값)에 더해 블록 1~12개의
1336
+ * 목록을 받는다 — 쓰는 사람이 글 벽 대신 지식의 구조(견주기 · 차례 · 이름→풀이)를 찾아 적게 하려는 것이다. 블록은 키 하나:
1337
+ * p · note(문장 칸) · table{caption?, head[2~5], rows[[칸 × head 수] 1~12]} · steps[{text, who?} 2~12] · items[{k ≤40자, v} 1~12].
1338
+ * 칸의 글은 문장 칸 · who 는 주체 꼴이다. 행의 칸 수 · 그 밖의 키 · 맨 「없음」은 서버가 잰다(report_publish.php report_decision_prose).
1339
+ * description 은 네 칸이 제 뜻 한 줄 뒤에 붙이는 한 문장이다(이 상수만 따로 나가는 자리는 없다 — 고칠 곳은 여기 한 곳).
1340
+ */
1341
+ const DEC_PROSE = {
1342
+ description: '구조부터 — 견주기는 table · 차례는 steps · 이름→풀이는 items · 한 줄은 p · 한계는 note(블록 목록 — 블록마다 키 하나).',
1343
+ oneOf: [
1344
+ DEC_SENTENCE.oneOf[0],
1345
+ DEC_SENTENCE.oneOf[1],
1346
+ {
1347
+ type: 'array', minItems: 1, maxItems: 12,
1348
+ items: {
1349
+ type: 'object', minProperties: 1, maxProperties: 1,
1350
+ properties: {
1351
+ p: DEC_SENTENCE,
1352
+ note: DEC_SENTENCE,
1353
+ table: {
1354
+ type: 'object',
1355
+ properties: {
1356
+ caption: { type: 'string', minLength: 1, maxLength: 80 },
1357
+ head: { type: 'array', minItems: 2, maxItems: 5, items: { type: 'string', minLength: 1 } },
1358
+ rows: { type: 'array', minItems: 1, maxItems: 12, items: { type: 'array', minItems: 2, maxItems: 5, items: DEC_SENTENCE } },
1359
+ },
1360
+ required: ['head', 'rows'],
1361
+ },
1362
+ steps: {
1363
+ type: 'array', minItems: 2, maxItems: 12,
1364
+ items: { type: 'object', properties: { text: DEC_SENTENCE, who: DEC_WHO }, required: ['text'] },
1365
+ },
1366
+ items: {
1367
+ type: 'array', minItems: 1, maxItems: 12,
1368
+ items: { type: 'object', properties: { k: { type: 'string', minLength: 1, maxLength: 40 }, v: DEC_SENTENCE }, required: ['k', 'v'] },
1369
+ },
1370
+ },
1371
+ },
1372
+ },
1373
+ ],
1374
+ };
1375
+ const DEC_DUE = {
1376
+ description: '기한 — date(YYYY-MM-DD) 또는 days_after_release(공개일부터 1~365일) 가운데 꼭 하나와 why(그 까닭 — 선택). '
1377
+ + '기한이 아직 없으면 이 칸을 빈 값으로.',
1378
+ oneOf: [
1379
+ { type: 'object', properties: { date: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, why: DEC_SENTENCE }, required: ['date'] },
1380
+ { type: 'object', properties: { days_after_release: { type: 'integer', minimum: 1, maximum: 365 }, why: DEC_SENTENCE },
1381
+ required: ['days_after_release'] },
1382
+ DEC_SENTENCE.oneOf[1],
1383
+ ],
1384
+ };
1385
+ const DEC_TEXT_WHO = {
1386
+ oneOf: [
1387
+ { type: 'object', properties: { who: DEC_WHO, text: DEC_SENTENCE }, required: ['who', 'text'] },
1388
+ DEC_SENTENCE.oneOf[1],
1389
+ ],
1390
+ };
1391
+ const DECISION_BRIEF = {
1392
+ type: 'object',
1393
+ description: '결정 블록 — decisions(정할 것)가 하나 이상이면 문서가 결정 모드로 선다(서식은 lim-report 하나 · template 은 안 적어도 된다). '
1394
+ + '결정이 없으면 이 칸을 빼고 보고로 낸다(decisions 가 빈 decision_brief 는 거절). '
1395
+ + '필수(거절)는 뼈대뿐 — decisions[].name · question · options(둘 이상)[].name · line, 공개 때 reply_to.who. '
1396
+ + '권함(없어도 거절 아님 — 렌더러가 ⚠): due · now · 결정에 닿는 facts · no_reply. 선택(쓰면 서고 안 쓰면 그리지 않음): options[].rows 전부 · cost · '
1397
+ + 'opinion · confirms · risks · unknowns · names 와 그 밖의 칸. 대칭 — 한 선택지에 쓴 행·돈은 모든 선택지가 쓴다. '
1398
+ + '문장 칸은 글자, 또는 비우는 까닭을 밝히는 빈 값 {empty, …}: na+why(해당 없음) · undecided+until(아직 안 정해짐 — 정해지는 때) · '
1399
+ + 'elsewhere+who+when(이 문서에 없음 — 누가 언제 정하나) · unverified+fill(확인하지 못함 — 채울 수단). 「없음」「-」 같은 맨 글자는 거절된다. '
1400
+ + '누가 칸(by·who·payer·to)은 customer(받는 쪽, 화면에는 client) · fcg(보내는 쪽, 화면 말은 labels.dc_who_fcg) · party:<names 의 id>(그 밖의 쪽), 또는 빈 값. '
1401
+ + 'id 는 영소문자·숫자·하이픈 1~32자, 문서 전체가 한 이름 공간이다. 글 안에서 {{결정:id}}·{{선택:id}}·{{사실:id}}·{{확인:id}}·{{위험:id}}·{{이름:id}} 로 다른 물건을 부른다.',
1402
+ properties: {
1403
+ scope: { type: 'string', description: '점검 범위 한 문장 — 무엇을, 어느 계정·환경에서, 언제(선택).' },
1404
+ reply_to: {
1405
+ type: 'object',
1406
+ description: '회신 받는 사람. who 가 비면 공개되지 않는다(초안·dry_run 은 알림만).',
1407
+ properties: { who: { type: 'string' }, contact: { type: 'string' } },
1408
+ },
1409
+ decisions: {
1410
+ type: 'array', minItems: 1,
1411
+ description: '지금 정하실 결정 — 결정마다 한 장으로, 기한 순으로 선다. 하나 이상이면 결정 모드.',
1412
+ items: {
1413
+ type: 'object',
1414
+ properties: {
1415
+ id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
1416
+ uid: { type: 'string', pattern: '^[a-z0-9]{6}$', description: '서버가 발급하는 불변 이름. get_report 가 준 값은 그대로 되돌려 보낸다.' },
1417
+ name: { type: 'string', description: '짧은 이름 — 첫 화면·장 머리·회신에 같은 글자로 선다.' },
1418
+ question: { ...DEC_SENTENCE, description: '물음 하나(25자 안팎). 원고에 물음이 없으면 빈 값.' },
1419
+ due: { ...DEC_DUE, description: '결정 기한(권함 — 없으면 거절 대신 렌더러가 ⚠). ' + DEC_DUE.description },
1420
+ now: { ...DEC_PROSE, description: '지금 상태 — 대상과 문제를 쉬운 말로(권함). ' + DEC_PROSE.description },
1421
+ figures: {
1422
+ type: 'array',
1423
+ description: '그림 칸 — 「지금 상태」 바로 아래에 서는 이 결정의 그림(선택). 항목마다 diagram(설계 데이터 — Archify IR 객체, '
1424
+ + 'sections[].diagrams[].ir 와 같은 꼴) · chart(차트 한 벌 — sections[].charts[] 와 같은 꼴: 막대 rows 또는 kind:"series"+points, ref·form 포함) · '
1425
+ + 'plate(도판 assets[].id — role figure·video) 가운데 꼭 하나와 caption · source(출처 한 줄). 그 밖의 키는 거절. '
1426
+ + '그림 번호는 문서 전체 한 셈이라 적지 않는다(같은 그림은 같은 번호).',
1427
+ items: {
1428
+ type: 'object',
1429
+ properties: {
1430
+ diagram: { type: 'object', description: '설계 데이터(Archify IR) — 서버가 관보 그림으로 굽는다.' },
1431
+ chart: { type: 'object', description: '차트 한 벌 — 그릴 행(rows)이나 점(points)이 있어야 한다.' },
1432
+ plate: { type: 'string', description: '도판 id(assets[].id).' },
1433
+ caption: { type: 'string', description: '그림 캡션 — 번호 없이.' },
1434
+ source: { type: 'string', description: '출처 한 줄.' },
1435
+ },
1436
+ minProperties: 1,
1437
+ },
1438
+ },
1439
+ settled: {
1440
+ type: 'array',
1441
+ description: '고객이 이미 정하신 것 — 정하신 기록(record, 화면에 안 찍힘)이 있는 것만.',
1442
+ items: { type: 'object', properties: { text: { type: 'string' }, by: DEC_WHO, record: { type: 'string' } }, required: ['text', 'by', 'record'] },
1443
+ },
1444
+ options: {
1445
+ type: 'array', minItems: 2,
1446
+ description: '선택지 — 그대로 두는 길을 포함해 둘 이상(이름과 한 줄 뜻이 필수 · 나머지는 선택).',
1447
+ items: {
1448
+ type: 'object',
1449
+ properties: {
1450
+ id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
1451
+ name: { type: 'string', description: '선택지 이름 — 표 열 머리·첫 화면·회신 말에 같은 글자로.' },
1452
+ line: { ...DEC_SENTENCE, description: '고르면 무엇이 달라지나 한 줄(35자 안팎). 없으면 빈 값.' },
1453
+ what: { ...DEC_PROSE, description: '실제로 하는 일 — 끝나거나 풀리는 조건까지(선택). ' + DEC_PROSE.description },
1454
+ is_current: { type: 'boolean', description: '지금 상태를 지키는 선택지 — 결정마다 많아야 하나, 늘 첫째로 선다.' },
1455
+ rows: {
1456
+ type: 'object',
1457
+ description: '대칭 표의 값(선택 — 쓰면 행이 서고 안 쓰면 그 행이 없다). 한 선택지에 쓴 행은 모든 선택지가 문장이나 빈 값으로 채운다. 이 밖의 키는 거절.',
1458
+ properties: {
1459
+ for_users: { ...DEC_SENTENCE, description: '이용자가 겪는 일.' },
1460
+ client_does: { ...DEC_TEXT_WHO, description: '고객 쪽이 하실 일 — 요청 자리, text 는 하십시오체 요청.' },
1461
+ fcg_does: { ...DEC_SENTENCE, description: '보내는 쪽이 할 일 — 화면 행 이름은 원고 issuer 첫 이름으로 「{발행처} 가 할 일」(labels.dc_row_fcg_does 로 덮음).' },
1462
+ risk_left: { ...DEC_SENTENCE, description: '남는 위험.' },
1463
+ settles_today: { ...DEC_SENTENCE, description: '그날 정해지는 것.' },
1464
+ others_do: { ...DEC_TEXT_WHO, description: '그 밖의 쪽이 할 일.' },
1465
+ },
1466
+ },
1467
+ cost: {
1468
+ type: 'object',
1469
+ description: '돈(선택 — 한 선택지에 쓰면 모든 선택지가) — 금액은 적지 않는다. 쓰면 state: yes(듦)·no(안 듦)·unknown(모름)는 필수, '
1470
+ + 'known_when(언제 아나)·payer(누가 내나)는 아는 것만.',
1471
+ properties: {
1472
+ state: { type: 'string', enum: ['yes', 'no', 'unknown'] },
1473
+ known_when: DEC_SENTENCE,
1474
+ payer: DEC_WHO,
1475
+ },
1476
+ required: ['state'],
1477
+ },
1478
+ then: {
1479
+ type: 'array', description: '회신 뒤 일.',
1480
+ items: { type: 'object', properties: { what: { type: 'string' }, who: DEC_WHO, when: { type: 'string' } }, required: ['what', 'who', 'when'] },
1481
+ },
1482
+ extra_rows: {
1483
+ type: 'array',
1484
+ description: '대장 「선택지 전체 비교」에만 서는 잣대 — 한 결정의 모든 선택지가 같은 label 을 갖는다.',
1485
+ items: { type: 'object', properties: { label: { type: 'string' }, text: DEC_SENTENCE }, required: ['label', 'text'] },
1486
+ },
1487
+ },
1488
+ required: ['id', 'name', 'line'],
1489
+ },
1490
+ },
1491
+ no_current_why: { ...DEC_SENTENCE, description: '그대로 두는 길(is_current)이 선택지에 없을 때 그 까닭.' },
1492
+ no_reply: { ...DEC_SENTENCE, description: '기한까지 회신이 없으면 무엇이 그대로이고 누가 무엇을 하나(권함).' },
1493
+ facts_lead: { ...DEC_PROSE, description: '이 결정에 닿는 사실이 둘 이상이면 — 사실들이 선택지를 어떻게 가르는지 잇는 글. ' + DEC_PROSE.description },
1494
+ reply: {
1495
+ type: 'object',
1496
+ description: '회신에 덧붙일 값 — attach(함께 적어 주실 것) · conditions(조건 안내, 기본 참). {} 도 받는다.',
1497
+ properties: { attach: { type: 'array', items: { type: 'string' } }, conditions: { type: 'boolean' } },
1498
+ },
1499
+ opinion: { ...DEC_PROSE, description: '보내는 쪽 의견 — 판단은 이 칸에만 둔다(선택 — 의견이 없으면 칸을 뺀다). ' + DEC_PROSE.description },
1500
+ },
1501
+ required: ['id', 'name', 'question', 'options'],
1502
+ },
1503
+ },
1504
+ confirms: {
1505
+ type: 'array',
1506
+ description: '확인 요청 — 결정을 바꿀 수 있거나 결정 뒤에 할 확인.',
1507
+ items: {
1508
+ type: 'object',
1509
+ properties: {
1510
+ id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
1511
+ uid: { type: 'string', pattern: '^[a-z0-9]{6}$', description: '서버가 발급하는 불변 이름. get_report 가 준 값은 그대로 되돌려 보낸다.' },
1512
+ name: { type: 'string', description: '무엇을 확인하나 — 물음 꼴(「…가 되는가」).' },
1513
+ to: { ...DEC_WHO, description: '누구에게 묻나·누가 하나.' },
1514
+ due: DEC_DUE,
1515
+ relation: { type: 'string', enum: ['sways', 'after'], description: 'sways(결과가 결정을 바꿀 수 있음) · after(결정 뒤 이행).' },
1516
+ decisions: { type: 'array', minItems: 1, items: { type: 'string' }, description: '닿는 결정의 id.' },
1517
+ ask: { type: 'string', description: '물으실 말 — 요청 자리, 하십시오체. ask·steps 가운데 하나 이상.' },
1518
+ steps: { type: 'array', minItems: 1, items: { type: 'string' }, description: '하실 일 목록 — 요청 자리, 하십시오체.' },
1519
+ why: { ...DEC_SENTENCE, description: '이 확인이 결정에 무엇을 바꾸나.' },
1520
+ },
1521
+ required: ['id', 'name', 'to', 'due', 'relation', 'decisions'],
1522
+ },
1523
+ },
1524
+ facts: {
1525
+ type: 'array',
1526
+ description: '확인한 사실 — 사실 줄 하나에 근거 하나 이상.',
1527
+ items: {
1528
+ type: 'object',
1529
+ properties: {
1530
+ id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
1531
+ text: { type: 'string', description: '사실 한 문장 — 방법 서술이 아니다.' },
1532
+ basis: {
1533
+ type: 'string', enum: ['seen', 'counted', 'tested', 'read', 'computed'],
1534
+ description: '근거 종류 — seen(직접 봄) · counted(운영 기록에서 셈) · tested(시험에서 잼) · read(설정·코드를 읽음) · computed(계산).',
1535
+ },
1536
+ where: {
1537
+ type: 'string', enum: ['production', 'replica', 'simulated'],
1538
+ description: 'basis 가 tested 일 때만 — simulated 면 「운영과 같은 조건 아님」이 붙는다.',
1539
+ },
1540
+ source: {
1541
+ type: 'array', minItems: 1,
1542
+ description: '근거 — 항목마다 키 하나: case(건 uid) · plate(도판 id) · quote(원문 한 줄) · url · ledger(대장 「절.문단」) · fact(계산에 쓴 사실 id). '
1543
+ + 'seen 은 plate·url·case, read 는 quote, computed 는 fact 가 하나 이상.',
1544
+ items: {
1545
+ type: 'object', minProperties: 1, maxProperties: 1,
1546
+ properties: {
1547
+ case: { type: 'string' }, plate: { type: 'string' }, quote: { type: 'string' },
1548
+ url: { type: 'string' }, ledger: { type: 'string' }, fact: { type: 'string' },
1549
+ },
1550
+ },
1551
+ },
1552
+ bears_on: {
1553
+ type: 'array', minItems: 1,
1554
+ description: '사실이 서는 자리 — 항목마다 decision(+ 그 결정의 option) · risk · unknown 가운데 하나.',
1555
+ items: {
1556
+ type: 'object',
1557
+ properties: { decision: { type: 'string' }, option: { type: 'string' }, risk: { type: 'string' }, unknown: { type: 'string' } },
1558
+ },
1559
+ },
1560
+ },
1561
+ required: ['id', 'text', 'basis', 'source', 'bears_on'],
1562
+ },
1563
+ },
1564
+ risks: {
1565
+ type: 'array',
1566
+ description: '어느 선택지를 고르든 지금 열려 있는 위험.',
1567
+ items: {
1568
+ type: 'object',
1569
+ properties: {
1570
+ id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
1571
+ what: { type: 'string' },
1572
+ size: {
1573
+ type: 'object', description: '크기 — text 와 basis(measured 잼 · estimated 가늠).',
1574
+ properties: { text: { type: 'string' }, basis: { type: 'string', enum: ['measured', 'estimated'] } },
1575
+ required: ['text', 'basis'],
1576
+ },
1577
+ blocked_by: {
1578
+ type: 'object',
1579
+ description: '이 위험을 막는 것 — decision(결정 id) · schedule(일정 글) · none:true + note(막을 결정·일정이 없는 까닭) 가운데 꼭 하나.',
1580
+ properties: { decision: { type: 'string' }, schedule: { type: 'string' }, none: { type: 'boolean' }, note: { type: 'string' } },
1581
+ },
1582
+ },
1583
+ required: ['id', 'what', 'blocked_by'],
1584
+ },
1585
+ },
1586
+ unknowns: {
1587
+ type: 'array',
1588
+ description: '확인하지 못한 것.',
1589
+ items: {
1590
+ type: 'object',
1591
+ properties: {
1592
+ id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
1593
+ what: { type: 'string' },
1594
+ sways: { type: 'array', items: { type: 'string' }, description: '이것이 바꿀 수 있는 결정의 id.' },
1595
+ size: {
1596
+ type: 'object', description: 'risks[].size 와 같은 꼴.',
1597
+ properties: { text: { type: 'string' }, basis: { type: 'string', enum: ['measured', 'estimated'] } },
1598
+ required: ['text', 'basis'],
1599
+ },
1600
+ fill: {
1601
+ description: '채울 수단 — 없으면 {none: true}.',
1602
+ oneOf: [
1603
+ { type: 'string', minLength: 1 },
1604
+ { type: 'object', properties: { none: { type: 'boolean', enum: [true] } }, required: ['none'] },
1605
+ ],
1606
+ },
1607
+ },
1608
+ required: ['id', 'what', 'fill'],
1609
+ },
1610
+ },
1611
+ names: {
1612
+ type: 'array',
1613
+ description: '이 문서에 나오는 낯선 말과 숫자 — 결정 장마다 처음 나오는 자리에 풀이가 선다. 선택지 이름은 두지 않는다.',
1614
+ items: {
1615
+ type: 'object',
1616
+ properties: { id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' }, name: { type: 'string' }, plain: { type: 'string', description: '쉬운 말 풀이.' } },
1617
+ required: ['id', 'name', 'plain'],
1618
+ },
1619
+ },
1620
+ later: {
1621
+ type: 'array',
1622
+ description: '나중에 정해도 되는 것 — 회신·핀을 받지 않는다.',
1623
+ items: {
1624
+ type: 'object',
1625
+ properties: { question: { type: 'string' }, options: { type: 'array', minItems: 1, items: { type: 'string' } }, revisit: { type: 'string', description: '다시 볼 때.' } },
1626
+ required: ['question', 'options', 'revisit'],
1627
+ },
1628
+ },
1629
+ },
1630
+ required: ['decisions'],
1631
+ };
1303
1632
  const REPORT_DATA = {
1304
1633
  type: 'object',
1305
1634
  description: '보고서 내용. 짝인 스킬의 report.json + cases 를 하나로 합친 형태.',
1306
1635
  properties: {
1307
1636
  /*
1308
- * 서식·팔레트 인자는 **여기 없다.** 서버가 정한다(report_publish.php 의
1309
- * REPORT_TEMPLATE_DEFAULT, 그리고 서식이 팔레트를 강제하는 결합).
1310
- * 값이 하나뿐이고 서버가 덮어쓰는 것을 도구 표면에 올려 두면, 부르는 쪽은 고를 수 있다고
1311
- * 믿는데 실제로는 무시된다 — 거짓말하는 인자다. 고를 것이 생기면 그때 연다.
1637
+ * 서식 칸(2026-10-06 관보 버전2 에 열었다) — 관보 v3(2026-10-07 ⑨)부터 서식은 lim-report 하나이고 모드는 원고 내용
1638
+ * (decision_brief.decisions)이 정한다. 옛 이름 lim-decision 은 그 이름으로 쓴 원고 · 옛 판 도구가 보내도 받히게 enum 에 남긴다 —
1639
+ * 서버가 lim-report 로 맞춘다(REPORT_TEMPLATE_ALIASES). 이름의 정본은 서버 REPORT_TEMPLATES 다.
1640
+ * 팔레트는 여전히 **여기 없다** — 서식이 강제하고 서버가 박는다(report_publish.php 의 REPORT_TEMPLATE_PALETTE).
1312
1641
  */
1642
+ template: {
1643
+ type: 'string', enum: ['lim-report', 'lim-decision'],
1644
+ description: '서식 — lim-report 하나(안 적어도 된다). 결정 블록(decision_brief.decisions)이 있으면 결정 모드, 없으면 보고 모드로 선다. '
1645
+ + 'lim-decision 은 옛 이름 — 서버가 lim-report 로 맞추고, 그 이름으로 왔는데 결정이 없으면 거절한다.',
1646
+ },
1647
+ decision_brief: DECISION_BRIEF,
1648
+ released_on: {
1649
+ type: 'string',
1650
+ description: '서버가 적는 공개일(YYYY-MM-DD, 결정 모드) — 「받으신 날부터 N일」을 날짜로 푸는 값. 보내도 서버가 공개일로 덮는다.',
1651
+ },
1313
1652
  title_tag: { type: 'string', description: '브라우저 탭 제목' },
1314
1653
  title: { type: 'string', description: '표지 대제목 **첫 행** (굵게)' },
1315
1654
  title_sub: {
@@ -1337,7 +1676,7 @@ const REPORT_DATA = {
1337
1676
  cta: {
1338
1677
  type: 'array', items: { type: 'string' },
1339
1678
  description: '`kind:"asks"` 절 머리의 강조문(문자열 목록). 요청 항목들 **위**에 서서 '
1340
- + '「먼저 볼 것」을 한두 줄로 말한다. 없으면 강조문 없이 항목만 늘어선다.',
1679
+ + '「먼저 볼 것」을 한두 줄로 말한다. 없으면 강조문 없이 항목만 늘어선다. 결정 모드에서는 받지 않는다(거절).',
1341
1680
  },
1342
1681
  description: { type: 'string' },
1343
1682
  doc_no: { type: 'string', description: '문서번호. 표지 머리 왼쪽 박스 (예: "LIM-2026-0824-01")' },
@@ -1357,7 +1696,7 @@ const REPORT_DATA = {
1357
1696
  description: '요약면 수치 칸 [{value, unit, label, accent?, href?, go?}] · accent:true 는 '
1358
1697
  + '**독자 몫**인 첫 칸. `href` 를 주면 그 칸이 단추가 되고, `go` 는 그 단추의 '
1359
1698
  + '글자다(생략하면 「해당 절로」). 어디로 가는지를 `go` 에 적어 두면 '
1360
- + '독자가 누르기 전에 안다 — 예: "02절로".',
1699
+ + '독자가 누르기 전에 안다 — 예: "02절로". 결정 모드에서는 받지 않는다(거절).',
1361
1700
  },
1362
1701
  history: {
1363
1702
  type: 'array',
@@ -1367,7 +1706,7 @@ const REPORT_DATA = {
1367
1706
  type: 'array',
1368
1707
  description: '이 문서 읽는 법 [{q, a: [문장, ...]}] — **독자가 실제로 던질 질문**만. '
1369
1708
  + '「시간이 없다면 어디를 봐야 하는가?」 「어디까지 확인한 것인가?」 처럼. '
1370
- + '채우려고 넣으면 안 넣느니만 못하다.',
1709
+ + '채우려고 넣으면 안 넣느니만 못하다. 결정 모드에서는 받지 않는다(거절).',
1371
1710
  },
1372
1711
  parts: {
1373
1712
  type: 'array',
@@ -1376,12 +1715,12 @@ const REPORT_DATA = {
1376
1715
  + 'tone 은 you(독자 몫)·neutral(기록)·wait(대기)·hold(유보) 중 하나. '
1377
1716
  + 'questions 는 「이 PART 가 답하는 질문」. '
1378
1717
  + '`roman` 은 간지·차례·머리띠에 서는 로마 숫자 — 생략하면 자리 순서대로 '
1379
- + 'Ⅰ·Ⅱ·Ⅲ 이 매겨지므로 **보통 적지 않는다**(적으면 순서와 어긋날 수 있다).',
1718
+ + 'Ⅰ·Ⅱ·Ⅲ 이 매겨지므로 **보통 적지 않는다**(적으면 순서와 어긋날 수 있다). 결정 모드에서는 받지 않는다(거절).',
1380
1719
  },
1381
1720
  notice: {
1382
1721
  type: 'object',
1383
1722
  description: '요약면 현황 줄 {state, text} — state 는 critical|info|success. '
1384
- + '**상태에 따라 바뀌는 유일한 줄**이다. 떠 있는 알림 배너를 따로 두지 마라.',
1723
+ + '**상태에 따라 바뀌는 유일한 줄**이다. 떠 있는 알림 배너를 따로 두지 마라. 결정 모드에서는 받지 않는다(거절).',
1385
1724
  },
1386
1725
  summary_position: {
1387
1726
  type: 'string', enum: ['front'],
@@ -1422,13 +1761,16 @@ const REPORT_DATA = {
1422
1761
  + '덮을 수 있는 키: apx · as_of · ask · bar_alt · basis · client · col_grade · col_state · '
1423
1762
  + 'col_title · cta · doc_no · go · guide · hist · hist_date · hist_no · hist_note · marg · '
1424
1763
  + 'nograde · open · pt_ask · refs · remain · remain_unit · source · source_row · sum · toc · urgent.\n'
1425
- + '없는 키를 주면 조용히 무시된다 — 오타가 안 걸리니 위 목록에서 골라 써라.',
1764
+ + '없는 키를 주면 조용히 무시된다 — 오타가 안 걸리니 위 목록에서 골라 써라.\n'
1765
+ + '결정 모드의 고정 문구 키(dc_…)는 skills/genetics-report/references/decision-format.md 의 「바꿀 수 있는 글자」 표.',
1426
1766
  },
1427
1767
  grades: { type: 'object', description: '확인 등급 정의 {이름: [클래스, 설명]}' },
1428
1768
  groups: { type: 'array', description: '건 묶음 [{key, no}]' },
1429
1769
  sections: {
1430
1770
  type: 'array',
1431
- description: '절 구성 [{no, id, name, kind, group?, chapter?, principles?, logic?}] · kind 는 summary|status|cases|asks|note '
1771
+ description: '절 구성 [{no, id, name, kind, group?, chapter?, principles?, logic?}] — 보고 모드는 절 구성이 본체다. '
1772
+ + '결정 모드는 대장(근거 자료) 절(kind note·cases)만, 없어도 된다.\n'
1773
+ + 'kind 는 summary|status|cases|asks|note '
1432
1774
  + '중 하나여야 한다 — 그 밖의 값은 렌더러가 절을 통째로 빠뜨리므로 서버가 거절한다. '
1433
1775
  + 'chapter 에 아래 chapters 의 id 를 적으면 그 절 앞에 챕터 오프닝이 펼쳐진다. '
1434
1776
  + 'name 은 목차에 쓰는 **짧은 이름**이고, 본문의 큰 제목은 챕터의 title_lines 가 낸다.\n'
@@ -1446,7 +1788,13 @@ const REPORT_DATA = {
1446
1788
  + 'rows[].no 가 `4.1`·`04-1` 모양이면 그 사례로 링크가 된다. '
1447
1789
  + '그림 번호(`그림 5-1`)는 적지 않는다 — 자리가 매기고 `plates` 와 한 셈을 쓴다.\n'
1448
1790
  + '· `plates`: [assets[].id, …] — 절 본문 도판.\n'
1449
- + '· 시각 우선(dry_run 이 ⚠ 로 센다 — 서식 문서 §10-1): 절마다 표·흐름도·차트·도판 중 하나, 문단 220자 안, 산문의 참조 번호 3개 이하, 판단 요청 6항 이상이면 한눈표+관문 흐름도, 3,000자 넘는 문서엔 핵심 굵게. 집필 공식(독자 한 명·예시 한 명·한 장 이야기·용어 잠금·그림 먼저)은 스킬 references/visual-first.md.\n'
1791
+ // ── 장치 목록에서 굽는 글 «tool-devices» — 손으로 고치지 않는다: python3 tools/contract-devices.py --write ──
1792
+ + '· 시각 장치(정본 private/builder/devices.py — 둘 수 있는 칸 · 한도 · 만드는 법은 스킬 references/visual-first.md §4 표): 구조 → 구성도·도판(개념도) · 흐름 → 흐름도·절차도·데이터 흐름도 · 순서 → 순서도 · 상태 → 상태 변화도 · 변화 → 두 판 비교·도판(두 컷 비교) · 분포 → 막대 차트 · 시간에 따른 값 → 시계열 · 견주기 → 표·점 차트 · 원문 → 원문 인용·도판 · 화면 → 도판·화면 녹화. '
1793
+ + '구성도·절차도·순서도·데이터 흐름도·상태 변화도·두 판 비교는 도해 굽기가 켜진 서버에서만 새로 굽는다(포털 private/archify.on — 로컬 render.py 는 켜고 부른다) — 꺼진 서버는 굽혀 둔 그림만 싣고 새 그림은 같은 번호의 대기 상자로 둔다(「서버에서 실패한 그림이 N장」 — 원고를 고칠 일이 아니다). '
1794
+ + '화면 녹화는 영상 저장 연동(r2)이 켜진 서버에서만 — 꺼진 환경은 발행을 거절한다. '
1795
+ + '시각 우선(dry_run 이 ⚠ 로 센다 — 서식 문서 §10-1): 산문 400자 넘는 절에 표·흐름도·차트·원문 인용·도해·도판 가운데 하나도 없으면 ⚠, 문단 220자 안, 산문의 참조 번호 3개 이하, 판단 요청 6항 이상이면 한눈표+관문 흐름도, 3,000자 넘는 문서엔 핵심 굵게. '
1796
+ + '집필 공식(독자 한 명·예시 한 명·한 장 이야기·용어 잠금·그림 먼저)은 스킬 references/visual-first.md.\n'
1797
+ // ── /장치 목록 «tool-devices» ──
1450
1798
  + '· 인라인 표기(본문 문자열 어디서나 — 문단·도입문·여백 주석·표 셀·흐름도 칸·주석·부록 행): `**굵게**` → 핵심 구절, `==강조==` → 놓치면 안 되는 한 구절(노란 밑면), `{{ok|보통}}` → 상태 칩(톤 ok·wait·bad·info·muted 다섯뿐, 글자 40자 안), `[글자](https://…)` → 새 창으로 여는 하이퍼링크, 맨 `https://…` 주소도 그대로 링크가 됨(http(s) 만, 꼬리 문장부호는 링크 밖). 표기가 없으면 한 글자도 안 바뀐다. 핵심 한두 구절에만 — 문단 절반이 굵으면 굵기가 위계를 못 나른다. 출처는 부록 글자에만 두지 말고 건의 `source_url` 에도 실어라(「출처 원문」 행이 된다).\n'
1451
1799
  + '· `tables`: [{caption, cols[], rows[[]], align?, hint?, note?}] — **관보의 자료표.** '
1452
1800
  + '여러 항목을 축으로 견주는 자리다. 불릿 목록에 수를 눌러 담고 있으면 그건 표여야 한다. '
@@ -1457,22 +1805,26 @@ const REPORT_DATA = {
1457
1805
  + '표 번호(`표 5-1`)는 적지 않는다 — 자리가 매긴다. '
1458
1806
  + '⚠ caption 은 **라벨이지 산문이 아니다** — 20자 안쪽으로 두고 설명은 note 로 내려라 '
1459
1807
  + '(길면 캡션 바가 글줄 상한을 넘어 두 줄로 접힌다).\n'
1460
- + '· `charts`: [{caption, unit?, rows:[{label, value|a+b, note?}], toggle?{a,b}, note?}] — '
1461
- + '**분포 막대.** 수를 눈으로 견주게 한다. 열람 CSP 가 `script-src` 를 안 열어 캔버스를 쓸 수 없으므로 '
1462
- + 'HTML 막대와 CSS 로 짓는다. 값은 **본문 글자로 남고** 막대는 그 옆에 서므로 막대가 안 보이는 '
1463
- + '곳에서도 문서가 성립한다. value 는 **표기 그대로 적는다**(`"118,716"`) — 콤마는 셈에서 걷어내므로 '
1464
- + '같은 문서의 표와 표기가 갈리지 않게 하려면 데이터가 표기를 정해야 한다. '
1808
+ + '· `charts`: [{caption, unit?, rows:[{label, value|a+b, note?, tone?, partial?}], toggle?{a,b}, form?, ref?, note?}] — '
1809
+ + '**차트(막대 · 점).** 렌더러가 정지 SVG 로 그린다(스크립트 0 — 열람 CSP 가 script-src 를 안 연다). 값 · 이름 · 설명은 원고 글자 그대로 '
1810
+ + '그림에 서고 화면 낭독 목록으로도 한 번 실린다. value 는 **표기 그대로 적는다**(`"118,716"`) — 같은 문서의 표와 표기가 갈리지 않게 데이터가 표기를 정한다. '
1465
1811
  + 'rows[].tone(ok·wait·bad·info·muted) 을 주면 그 행의 막대와 값에 뜻 색이 붙는다 — 예: 처리 완료 행은 muted, 위험 행은 bad. '
1466
- + 'toggle{a,b} 를 주면 rows 는 value 대신 a·b 를 갖고 두 축을 `:checked` 로 갈아 낀다 '
1467
- + '(체크박스는 스크립트가 아니라 상태다). 그림 번호는 `flow`·`plates` 와 **한 셈**을 쓴다.\n'
1468
- + '데이터가 있을 때만 그려진다 — 없는 문구를 지어내지 말 것.\n'
1469
- + '· `charts[].kind:"series"`: {kind:"series", caption, unit?, points:[{x,y}], marks?:[{x,label}], summary?:[{label,value,note?}], note?} — '
1470
- + '**시계열.** 하루·달 단위 값이 언제 계단을 만드는지 인라인 SVG 선으로 보이고, 달마다 평균·최대를 글자 행으로 남긴다(그림이 안 보여도 문서가 선다). '
1812
+ + 'toggle{a,b} 를 주면 rows 는 value 대신 a·b 를 갖고 두 축을 `:checked` 로 갈아 낀다(두 축이 한 축척 · 체크박스는 스크립트가 아니라 상태다). '
1813
+ + '그림 번호는 `flow`·`plates` 와 **한 셈**을 쓴다. 데이터가 있을 때만 그려진다 — 없는 문구를 지어내지 말 것.\n'
1814
+ + '· `charts[].ref`: [{value, label}] — **기준선**(점선). value 는 수(쉼표 표기 글자도 받음), label 은 원고 글자(「3초」「60초 한도」). 막대·시계열 모두. '
1815
+ + '`charts[].rows[].partial`: 참이면 그 행은 일부만 잰 값이라 막대 속을 비운다(행 이름 글자로 짐작하지 않는다). '
1816
+ + '`charts[].form`: length(길이 — 0 기준선에서 뻗는 공통 축척 막대) · position(위치 — 같은 축 위의 점) — 없으면 막대는 length · 시계열은 position. '
1817
+ + '셋 다 꼴이 틀리면 서버가 거절한다.\n'
1818
+ + '· `charts[].kind:"series"`: {kind:"series", caption, unit?, points:[{x,y}], marks?:[{x,label}], summary?:[{label,value,note?}], form?, ref?, note?} — '
1819
+ + '**시계열.** 하루·달 단위 값이 언제 계단을 만드는지 정지 SVG 로 보이고(기본은 점과 선 · form length 면 날마다 막대), 달마다 평균·최대를 글자 행으로 남긴다(그림이 안 보여도 문서가 선다). '
1471
1820
  + 'x 는 YYYY-MM-DD 가 기본. marks[].x 는 어느 points[].x 와 같은 값이어야 선다(사건 세로선, 아니면 ⚠).\n'
1472
1821
  + '· `quotes`: [{text, lang?, source_text, source_url?, verdict?, translation?}] — **원문 인용.** 공식 문서·회신 문구를 원문 그대로 싣는다(표기 안 품, 줄바꿈 유지). '
1473
1822
  + 'source_text·source_url 둘 중 하나는 있어야 한다(없으면 ⚠). verdict 는 도판과 같은 축(직접 확인·조사 보고·미확인). 절 본문 맨 앞에 선다.\n'
1474
- + '· `diagrams`: [{id?, caption, ir?, svg?, alt?, source_text?, verdict?, note?, wide?, origin?}] — **도해.** 기본은 `ir`: Archify 가 검증한 **workflow IR 그대로**(lanes·phases·nodes·edges·mainPath)를 넣으면 관보가 자기 활자(15px)·장 색으로 그린다. 다른 종류는 `svg`: 스킬 `assets/diagram.mjs` 로 굳힌 SVG 문자열 '
1475
- + '(서버가 스크립트·스타일·외부 참조를 다시 걷는다). ir 에서 읽는 칸: diagram_type(workflow) · lanes[].id·label · phases[].label·fromCol·toCol · nodes[].id·lane·col·type·label·sublabel·variant · edges[].from·to·label·variant · mainPath. 그림 번호는 차트·흐름도·도판과 한 셈. 글자가 표시 15px 미만이면 ⚠ — 넓은 도해는 wide:true(전폭 1030px). 상한 250,000B. 절차는 스킬 references/diagrams.md.\n'
1823
+ + '· `diagrams`: [{id?, caption, ir? | compare?, alt?, source_text?, verdict?, note?, wide?, origin?}] — **도해.** 설계 데이터를 넣으면 서버가 '
1824
+ + '고정 판 Archify 로 정지 SVG 를 굽는다(관보 글자 기준 15px). `ir` 은 Archify 설계 데이터 하나 — diagram_type architecture·workflow(schema_version 2)·sequence·dataflow·lifecycle, '
1825
+ + '`compare` 는 {base, head} — 두 판 다 구성도 설계 데이터(두 판 비교: 겹친 그림 + 바뀐 항목 표). 미리 구운 `svg` 칸은 받지 않는다(⚠ · 번호도 안 잡는다). '
1826
+ + '어느 서버에서 새로 굽는지 · 종류별 한도는 위 시각 장치 줄. 못 구우면 같은 번호의 대기 상자와 ⚠ 사유(고칠 값). 우리가 그린 도해에는 verdict · 출처 줄을 비운다. '
1827
+ + '그림 번호는 차트·흐름도·도판과 한 셈. 쓰는 법은 스킬 references/diagrams.md.\n'
1476
1828
  + '· `flow.mode:"cause"`: 원인 사슬 — 첫 칸의 「해야 할 것」 강조 면을 걷어 「사실 → 우리 상태 → 결과」가 같은 무게로 선다.\n'
1477
1829
  + '\n'
1478
1830
  + '관보 문법이 절마다 요구하는 칸:\n'
@@ -1561,7 +1913,7 @@ const REPORT_DATA = {
1561
1913
  description: '요지 [{lead, body, refs?}] — kind:"summary" 절, summary_position:"front" 이면 앞 요약면에도 선다. '
1562
1914
  + '각 문단은 근거로 삼은 실제 사례 또는 kind:"note", presentation:"prose" 의 문단 번호를 '
1563
1915
  + '최소 하나 밝혀야 한다(예: 본문에 "§3.1" 이라 쓰거나 refs:["3.1"]). '
1564
- + '산문이면 3번째 절의 1번째 문단이다. 없는 번호나 근거 표기 누락은 서버가 거절한다.',
1916
+ + '산문이면 3번째 절의 1번째 문단이다. 없는 번호나 근거 표기 누락은 서버가 거절한다. 결정 모드에서는 받지 않는다(거절).',
1565
1917
  },
1566
1918
  open_items: {
1567
1919
  type: 'array',
@@ -1569,7 +1921,7 @@ const REPORT_DATA = {
1569
1921
  + '상대의 판단이 필요한 것만. `priority: "must"` 인 항목은 「가장 시급한 요청」으로 '
1570
1922
  + '따로 서고 나머지와 괘선으로 갈린다 — **먼저 정해져야 하는 것에만** 붙여라. '
1571
1923
  + '전부에 붙이면 갈림이 사라져 안 붙인 것과 같아진다. '
1572
- + '`refs` 는 근거 사례 번호이고 **첫 하나만** 「근거」 단추가 된다.',
1924
+ + '`refs` 는 근거 사례 번호이고 **첫 하나만** 「근거」 단추가 된다. 결정 모드에서는 받지 않는다(거절).',
1573
1925
  },
1574
1926
  footer: { type: 'array' },
1575
1927
  assets: {
@@ -1654,11 +2006,11 @@ const REPORT_DATA = {
1654
2006
  },
1655
2007
  alt: { type: 'string', description: '설명적 대체 텍스트 — 접근성 계약. 그림과 함께 다닌다' },
1656
2008
  origin: { type: 'string', description: '생성 job 식별자·.mmd 원본 경로·제공자 이름' },
1657
- caption: { type: 'string', description: '도판 캡션. 그림 번호는 자리가 매기므로 적지 않는다' },
1658
- source_text: { type: 'string', description: '캡션 아래 「출처」 줄 — 이 그림이 어디서 왔는지' },
2009
+ caption: { type: 'string', description: '도판 캡션. 그림 번호는 자리가 매기므로 적지 않는다. 캡처·영상은 끝에 증거 범위 한 구절(찍은 환경·날짜·옮겨 적은 부분, 예 「… · 운영 관리자 웹 · 8. 23. 촬영」)' },
2010
+ source_text: { type: 'string', description: '캡션 아래 「출처」 줄 — 이 그림이 어디서 왔는지. 캡처는 「화면 이름 · 날짜」를 위치로 적는다(예 「주문 목록 화면 · 8. 23.」)' },
1659
2011
  verdict: {
1660
2012
  type: 'string', enum: ['직접 확인', '조사 보고', '미확인'],
1661
- description: '이 근거를 **얼마나 믿어도 되는지**. 캡션 끝에 칩으로 선다.\n'
2013
+ description: '이 근거를 **얼마나 믿어도 되는지**. 캡션 끝에 칩으로 선다. 우리가 그린 도해·도판에는 붙이지 않는다.\n'
1662
2014
  + '· 직접 확인 — 원문을 직접 열어 그 수치를 확인함\n'
1663
2015
  + '· 조사 보고 — 2차 출처를 경유해 확인함. 원문 대조는 안 함\n'
1664
2016
  + '· 미확인 — 널리 쓰이나 1차 출처를 특정하지 못함\n'
@@ -1672,7 +2024,7 @@ const REPORT_DATA = {
1672
2024
  description: '**전폭 도판.** 두 단 그리드 밖, 절 끝에 문서 폭(약 1030px)으로 선다. '
1673
2025
  + '나란히 둔 화면 둘처럼 본문 단(760px)에서 절반으로 줄면 글자가 안 읽히는 그림에 켠다. '
1674
2026
  + '켜면 그 절의 그림 번호는 마지막이 된다. 켜든 말든 모든 도판은 눌러서 크게 보기(원본 '
1675
- + '긴 변 1400px)가 열린다. 집필 때 figure.mjs --display 로 표시 글자 크기를 먼저 재라(하한 14px).',
2027
+ + '긴 변 1400px)가 열린다. 집필 때 figure.mjs --display 로 표시 글자 크기를 먼저 잰다(하한 15px — 스킬 references/readability.md R1).',
1676
2028
  },
1677
2029
  brief: {
1678
2030
  type: 'string',
@@ -1719,8 +2071,8 @@ const REPORT_DATA = {
1719
2071
  symptom: { type: 'string' },
1720
2072
  cause: { type: 'string' },
1721
2073
  fix: { type: 'string' },
1722
- how_to_check: { type: 'string', description: '고객이 화면에서 볼 수 있는 결과 — 어디를 열면 무엇이 보이는지(주어는 화면·값, 독자에게 시키는 문장 아님)' },
1723
- remaining: { type: 'string', description: '아직 닫히지 않은 것 — 미확인·미결인 사실과 운영에 아직 안 나간 우리 작업. 독자에게 부탁할 것은 open_items 로(선택)' },
2074
+ how_to_check: { type: 'string', description: '독자가 그 결과를 **볼 자리** + **우리가 그것을 본 때·환경**. 볼 자리는 이 문서의 그림·영상·원문 인용(`{{그림:id}}`·quotes) 또는 독자 자기 화면(어디를 열면) — 자기 화면만 볼 자리로 쓴 확인 방법도 되지만 렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」은 남고 그 수가 공개·교체 승인 물음에 실린다(「…에서 봄」 꼬리는 증거로 치지 않는다). 우리가 안 본 결과는 여기 쓰지 않고 remaining 에만 둔다 — 동사는 우리가 본 수준까지(예 「주문서를 작성해 저장하면 새로고침 없이 그 주문이 목록 첫 줄에 나타남({{그림:order-saved}}), 8. 23. 운영 관리자 웹에서 봄」). 주어는 화면·값·우리(독자에게 시키는 문장 아님)' },
2075
+ remaining: { type: 'string', description: '아직 닫히지 않은 것 — 미확인·미결인 사실과 운영에 아직 안 나간 우리 작업. 우리가 안 본 결과는 여기에만 적는다. 독자에게 부탁할 것은 open_items 로(선택)' },
1724
2076
  source_url: { type: 'string', description: 'http(s) 제보 원본 링크(선택)' },
1725
2077
  quotes: {
1726
2078
  type: 'array',
@@ -1755,7 +2107,9 @@ const REPORT_DATA = {
1755
2107
  },
1756
2108
  },
1757
2109
  // 문서가 서려면 있어야 하는 것만. 나머지는 비어 있어도 발행된다.
1758
- required: ['title_tag', 'sections'],
2110
+ // `sections` 는 필수에서 뺐다(2026-10-06) — 결정 모드는 대장 절이 없어도 선다. 서버는 없으면 빈 목록으로 다룬다.
2111
+ // 모드별 규칙(결정 블록의 뼈대 · 결정 모드가 안 받는 칸)은 스키마 if/then 으로 쓰지 않고 서버가 거절한다(K1~K4 · V-*).
2112
+ required: ['title_tag'],
1759
2113
  };
1760
2114
 
1761
2115
  /* ── 세션 이식 ───────────────────────────────────────────────────────
@@ -3975,7 +4329,8 @@ async function resolveFetch(cred, body) {
3975
4329
  try {
3976
4330
  const res = await fetch(`${BASE}/api/folders/resolve`, {
3977
4331
  method: 'POST',
3978
- headers: { Authorization: `Bearer ${cred.token}`, 'Content-Type': 'application/json' },
4332
+ // 판 머리 — api() 와 같은 규율(모든 요청 · 하위 업무 번호 3단계 잣대 ②).
4333
+ headers: { 'X-Genetics-Client': `genetics-mcp/${VERSION}`, Authorization: `Bearer ${cred.token}`, 'Content-Type': 'application/json' },
3979
4334
  body: JSON.stringify(body),
3980
4335
  signal: ac.signal,
3981
4336
  });
@@ -4295,6 +4650,7 @@ async function beatOnce(recs, { closing = false } = {}) {
4295
4650
  const res = await fetch(`${BASE}/api/sessions/beat`, {
4296
4651
  method: 'POST',
4297
4652
  headers: {
4653
+ 'X-Genetics-Client': `genetics-mcp/${VERSION}`, // 판 머리 — api() 와 같은 규율(모든 요청 · 하위 업무 번호 3단계 잣대 ②)
4298
4654
  Authorization: `Bearer ${cred.token}`,
4299
4655
  'Content-Type': 'application/x-lim-beat',
4300
4656
  /*
@@ -5750,7 +6106,8 @@ function taskReadResult(name, args, body) {
5750
6106
  const tasks = rows.map(row => {
5751
6107
  if (!row || typeof row.uid !== 'string' || !/^[a-z0-9]{8}$/.test(row.uid)
5752
6108
  || ['title', 'state', 'state_word', 'done_when'].some(key => typeof row[key] !== 'string')) throw new Error('할 일의 내용을 확인하지 못했습니다.');
5753
- return pick(row, ['uid', 'title', 'state', 'state_word', 'done_when']);
6109
+ // 하위 업무 번호 — 사람이 부르는 이름(승인 설계 4-1). 할 일이 번호를 들 때만 서버가 싣는다(0단계 소속처는 칸이 없다 — 결과 그대로).
6110
+ return Object.assign(pick(row, ['uid', 'title', 'state', 'state_word', 'done_when']), taskCodeOf(row) ? { origin_code: row.origin_code } : {});
5754
6111
  });
5755
6112
  result = { tenant: args.tenant, tasks, total: body.open, page: pick(page, ['limit', 'offset', 'returned', 'truncated', 'next_offset']) };
5756
6113
  }
@@ -5758,6 +6115,13 @@ function taskReadResult(name, args, body) {
5758
6115
  return result;
5759
6116
  }
5760
6117
 
6118
+ /** 할 일의 하위 업무 번호(서버 응답의 origin_code — 서버가 정한 정본 모양일 때만). 없거나 정본이 아니면 '' — 지어내지 않는다. 정본은 서버 work_item_code_ok 와
6119
+ * 같은 자다(일련은 두 자리 채움 · 그 밖 앞 0 없음 · 자식은 1 부터 앞 0 없음 — W-004 · W-01#04 는 아니다): 원격(mcp_task_results.php)과 stdio 가 같은 답. */
6120
+ function taskCodeOf(task) {
6121
+ const code = task && typeof task.origin_code === 'string' ? task.origin_code : '';
6122
+ return /^[A-Z]{1,4}-(?:0[0-9]|[1-9][0-9]{1,3})(?:#[1-9][0-9]{0,2})?$/.test(code) ? code : '';
6123
+ }
6124
+
5761
6125
  /**
5762
6126
  * 할 일에 **완료 보고**를 올리는 문(M1 · 2026-09-26) — 사람이 화면에서 쓰는 것과 **같은 서버 함수**(`work_task_do_report`)다.
5763
6127
  *
@@ -5818,7 +6182,8 @@ function taskWriteResult(uid, r) {
5818
6182
  const counts = t.evidence_counts || {};
5819
6183
  const n = Number(counts.total) || (Array.isArray(c.evidence) ? c.evidence.length : 0);
5820
6184
  const v = Number(counts.usable) || 0;
5821
- const head = `«${uid}» — ${c.kind_word || '완료 보고'} #${c.id ?? '?'} 올림 · 근거 ${n}(검증 ${v})`;
6185
+ const code = taskCodeOf(t); // 하위 업무 번호가 있으면 사람이 부르는 이름으로 앞에(승인 설계 4-1) — 없으면 예전 그대로
6186
+ const head = `«${code ? code + ' · ' + uid : uid}» — ${c.kind_word || '완료 보고'} #${c.id ?? '?'} 올림 · 근거 ${n}(검증 ${v})`;
5822
6187
  const state = t.state_word ? ` · 상태: ${t.state_word}` : '';
5823
6188
  return head + state + '.' + (r?.says ? ' ' + r.says : '');
5824
6189
  }
@@ -5950,7 +6315,9 @@ const TOOLS = [
5950
6315
  '메모에 **답글로** 회신한다. 이견이 있거나 물을 것이 있으면 대화창이 아니라 여기에 쓴다 — '
5951
6316
  + '저자는 자기가 메모를 단 그 자리에서 답을 본다.\n'
5952
6317
  + '고칠 수 없는 이유, 다르게 본 근거, 되묻는 것이 여기 온다. '
5953
- + '고쳤으면 답글이 아니라 resolve_note 다.\n'
6318
+ + '단 **접근·권한 요청**(계정·기기·창·메일함·자동 조작 허락)은 답글이 아니라 대화창에서 사용자에게 청한다 — '
6319
+ + '증빙(화면·영상·근거)을 요구한 메모를 못 찍었으면 「못 함」 답글 전에 그 접근부터 청한다.\n'
6320
+ + '고쳤으면 답글이 아니라 resolve_note 다 — 증빙을 요구한 메모는 실물(캡처·영상·원문)이 실린 판에서만 닫는다(도해·설명을 더한 것으로 닫지 않는다).\n'
5954
6321
  + '★ **검토 회신에는 이 도구로 답하지 않는다** — 그쪽은 고객에게 보이는 글이라 사람이 포털에서 쓴다. 할 말은 작성자에게 대화로 전한다.',
5955
6322
  inputSchema: {
5956
6323
  type: 'object',
@@ -5969,6 +6336,7 @@ const TOOLS = [
5969
6336
  + '「무엇에 반영됐는지」를 나중에도 안다.\n'
5970
6337
  + '★ 고치기 **전에** 부르지 말 것 — 이 표시가 곧 「이 메모는 끝났다」이고, '
5971
6338
  + '표시하는 순간 미반영 목록에서 빠져 다시 눈에 안 띈다.\n'
6339
+ + '증빙(화면·영상·근거)을 요구한 메모는 실물이 실린 판의 rev 로만 닫는다 — 도해·설명을 더한 판으로 닫지 않는다.\n'
5972
6340
  + '검토 회신은 `note` 대신 `reply` 로 가리킨다 — 표시해도 회신은 고객에게 그대로 보이고, 그 회신이 집은 문장이 바뀐 것을 update_report 가 더는 경고하지 않는다. '
5973
6341
  + '문서에 반영할 것이 없는 회신(인사·이미 답한 물음)도 읽었으면 표시한다.',
5974
6342
  inputSchema: {
@@ -5990,7 +6358,8 @@ const TOOLS = [
5990
6358
  'HTML 을 만들지 말 것 — 내용만 주면 서버가 렌더한다.\n' +
5991
6359
  '**기본은 초안이다** — 만들어도 고객에게는 보이지 않는다. 눈으로 확인한 뒤 ' +
5992
6360
  'release_report 로 열어 준다. 바로 열려면 publish: true 를 명시할 것.\n' +
5993
- '**문체는 주어와 자리가 정한다** — 요청 자리 밖에는 우리·사물·화면이 주어인 문장만 개조식(~함/~했음/~임)으로 쓴다. 독자가 할 일은 요청 자리(open_items·cta·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지 하십시오체로 맺는다(「회신 부탁드립니다」. 제목은 검수·보고면 「~ 요청」+기한 괄호, 결정 문서의 판단 항목은 물음). 그 밖의 칸에는 사실만 적는다(「기준 수치는 미정」). dry_run 은 일부만 센다.\n' +
6361
+ '**공개 승인**(publish: true · release_report)을 사용자에게 물을 때는 그 물음과 답에 이 판에서 건너뛴 단계(0단계 사용자 확인·독자 검토·열람 확인 등)와 증거 없는 관찰 주장 수(렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」 — 포털 dry_run 에 이 경고가 아직 없으면 스킬의 로컬 render.py 로 잰 곳 수)를 싣는다 — 「간단히」는 분량 지시이지 단계 면제가 아니다.\n' +
6362
+ '**문체는 주어와 자리가 정한다** — 요청 자리 밖에는 우리·사물·화면이 주어인 문장만 개조식(~함/~했음/~임)으로 쓴다. 독자가 할 일은 요청 자리(open_items·cta·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지 하십시오체로 맺는다(「회신 부탁드립니다」. 제목은 검수·보고면 「~ 요청」+기한 괄호, 고객이 정할 것은 data.decision_brief.decisions 에 — 서식은 lim-report 하나이고 결정 블록이 있으면 결정 모드로 선다(lim-decision 은 옛 이름)). 그 밖의 칸에는 사실만 적는다(「기준 수치는 미정」). dry_run 은 일부만 센다.\n' +
5994
6363
  '쓰기 전에 결과만 보고 싶으면 dry_run: true — 렌더까지 돌려 경고를 주고 아무것도 저장하지 않는다.',
5995
6364
  inputSchema: {
5996
6365
  type: 'object',
@@ -6016,7 +6385,8 @@ const TOOLS = [
6016
6385
  description:
6017
6386
  '초안을 고객에게 연다. 이 순간부터 고객 목록과 열람에 뜬다. ' +
6018
6387
  '이미 열려 있으면 아무 일도 하지 않고 그렇다고 답한다(다시 불러도 안전하다).\n' +
6019
- '사람 자격으로 공개해야 보고서의 열린 항목이 일감으로 선다.',
6388
+ '사람 자격으로 공개해야 보고서의 열린 항목이 일감으로 선다.\n' +
6389
+ '**공개 승인**을 사용자에게 물을 때는 그 물음과 답에 이 판에서 건너뛴 단계(0단계 사용자 확인·독자 검토·열람 확인 등)와 증거 없는 관찰 주장 수(렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」 — 포털 dry_run 에 이 경고가 아직 없으면 스킬의 로컬 render.py 로 잰 곳 수)를 싣는다 — 「간단히」는 분량 지시이지 단계 면제가 아니다.',
6020
6390
  inputSchema: {
6021
6391
  type: 'object',
6022
6392
  properties: {
@@ -6067,7 +6437,8 @@ const TOOLS = [
6067
6437
  '한 장만 바꾸려면 그 id 에 새 `source`/`file` 을 주고 나머지는 get_report 가 준 표식 그대로 두면 된다. ' +
6068
6438
  '**받은 판 잠금**(2026-09-30): get_report 머리줄의 body_rev 를 `body_rev` 로 실으면, 받은 뒤 본문 판이 바뀌었을 때 서버가 409 로 멈추고 아무것도 안 쓴다. ' +
6069
6439
  'dry_run 에도 같이 실으면 같은 409 를 덮기 전에 받는다. 서버가 이 잠금을 모르는 옛 판이면 출력이 「판을 견주지 않았다」고 알린다.\n' +
6070
- '**문체는 주어와 자리가 정한다** — 요청 자리 밖에는 우리·사물·화면이 주어인 문장만 개조식(~함/~했음/~임)으로 쓴다. 독자가 할 일은 요청 자리(open_items·cta·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지 하십시오체로 맺는다(「회신 부탁드립니다」. 제목은 검수·보고면 「~ 요청」+기한 괄호, 결정 문서의 판단 항목은 물음). 그 밖의 칸에는 사실만 적는다(「기준 수치는 미정」). dry_run 은 일부만 센다.\n',
6440
+ '**교체 승인**을 사용자에게 물을 때는 그 물음과 답에 이 판에서 건너뛴 단계(0단계 사용자 확인·독자 검토·열람 확인 등)와 증거 없는 관찰 주장 수(렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」 — 포털 dry_run 에 이 경고가 아직 없으면 스킬의 로컬 render.py 로 잰 곳 수)를 싣는다 — 「간단히」는 분량 지시이지 단계 면제가 아니다.\n' +
6441
+ '**문체는 주어와 자리가 정한다** — 요청 자리 밖에는 우리·사물·화면이 주어인 문장만 개조식(~함/~했음/~임)으로 쓴다. 독자가 할 일은 요청 자리(open_items·cta·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지 하십시오체로 맺는다(「회신 부탁드립니다」. 제목은 검수·보고면 「~ 요청」+기한 괄호, 고객이 정할 것은 data.decision_brief.decisions 에 — 서식은 lim-report 하나이고 결정 블록이 있으면 결정 모드로 선다(lim-decision 은 옛 이름)). 그 밖의 칸에는 사실만 적는다(「기준 수치는 미정」). dry_run 은 일부만 센다.\n',
6071
6442
  inputSchema: {
6072
6443
  type: 'object',
6073
6444
  properties: {
@@ -6156,12 +6527,20 @@ const TOOLS = [
6156
6527
  + '무엇을 적는가: **왜**가 이 장의 값이다(그때의 근거·수치·관찰). 그리고 **무슨 조건이면 다시 여나** — '
6157
6528
  + '이것을 비우면 그 결정은 「아직 열린 것」으로 읽힌다(닫혔는지 아무도 모르므로). '
6158
6529
  + '끝난 결정이면 "조건 없음(종결)"이라고 적을 것.\n'
6159
- + '모르는 칸은 **비운다** — 지어내면 「적히지 않음」보다 나쁘다.',
6530
+ + '모르는 칸은 **비운다** — 지어내면 「적히지 않음」보다 나쁘다.\n'
6531
+ + '★ **하위 업무 번호를 쓰는 소속처**(works 가 항목마다 W-04 같은 번호를 보여 주는 곳)에서는 번호를 서버가 매긴다 — '
6532
+ + '그곳에서는 제목에 [W-02] · W-02 같은 번호를 쓰지 말 것(응답이 번호를 알려 준다). '
6533
+ + '있는 하위 업무를 고쳐 적으면 item 에 그 번호, 하위로 나누면 parent 에 부모 번호를 준다. '
6534
+ + '아직 번호를 안 쓰는 소속처는 item · parent 를 422 로 돌려준다 — 그곳의 장은 get_case 로 읽은 revision 을 expected_revision 으로 대 고쳐 적는다.',
6160
6535
  inputSchema: {
6161
6536
  type: 'object',
6162
6537
  properties: {
6163
6538
  tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
6164
- title: { type: 'string', description: '한 줄 제목. 다른 결정은 제목을 달리한다. 기존 결정은 expected_revision을 전달해야 수정된다.' },
6539
+ title: { type: 'string', description: '한 줄 제목. 다른 결정은 제목을 달리한다. 기존 결정은 expected_revision을 전달해야 수정된다. 하위 업무 번호를 쓰는 소속처에서는 번호를 제목에 적지 않는다(서버가 매긴다).' },
6540
+ item: { type: 'string', description: '고쳐 적을 **하위 업무 번호**(예 W-04 — works 목록의 번호 그대로). 그 장에 새 판으로 선다(제목이 바뀌어도 번호 · 경로 그대로). 번호를 쓰는 소속처만 받는다(아직 안 쓰는 곳은 422).' },
6541
+ parent: { type: 'string', description: '하위로 나누기 — 부모 하위 업무 번호(예 R-01). 새 번호는 그 아래 자식(R-01#5)으로 선다. 깊이는 한 겹. 번호를 쓰는 소속처만 받는다(아직 안 쓰는 곳은 422).' },
6542
+ branch: { type: 'string', description: '갈래를 가진 업무(hnl-wrm 등)의 갈래 머리글자(W · R · M). 비우면 tags 의 갈래 태그(예 「대기 개선(W)」)로 고른다.' },
6543
+ prefix: { type: 'string', description: '새 업무를 여는 결정이면 그 업무의 머리글자(대문자 1~4). 비우면 서버가 제안한 것으로 선다 — 한 번 서면 못 바꾼다.' },
6165
6544
  expected_revision: { type: 'string', pattern: '^[a-f0-9]{64}$', description: '기존 결정 수정 시 get_case에서 읽은 revision. 신규 생성은 생략. 충돌하면 원문을 다시 읽고 변경을 재검토한다.' },
6166
6545
  commit_id: { type: 'string', pattern: '^[a-zA-Z0-9][a-zA-Z0-9-]{15,79}$', description: '이 저장 작업의 고유 UUID. 첫 호출 전에 생성하고 응답 유실·재시도에는 같은 값과 같은 본문을 유지한다. 별도 수정은 새 값을 쓴다.' },
6167
6546
  what: { type: 'string', description: '무엇을 정했나 — 한 문장.' },
@@ -6227,7 +6606,7 @@ const TOOLS = [
6227
6606
  type: 'object',
6228
6607
  properties: {
6229
6608
  tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
6230
- path: { type: 'string', description: 'search_cases·remember_decision 이 준 path 그대로(decisions/….md). 제목을 줘도 저장과 같은 자로 찾는다.' },
6609
+ path: { type: 'string', description: 'search_cases·remember_decision 이 준 path 그대로(decisions/….md). 제목을 줘도 저장과 같은 자로 찾는다. 번호를 쓰는 소속처는 하위 업무 번호(W-04)도 받는다.' },
6231
6610
  expected_revision: { type: 'string', pattern: '^[a-f0-9]{64}$', description: 'get_case로 읽은 삭제 대상의 revision.' },
6232
6611
  },
6233
6612
  required: ['tenant', 'path', 'expected_revision'],
@@ -6301,7 +6680,7 @@ const TOOLS = [
6301
6680
  properties: {
6302
6681
  tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
6303
6682
  claim: { type: 'string', description: '이 일(slug)에 붙겠다고 선언한다 — 잠정이며, 다른 일을 선언하면 앞 선언은 대체된다. 비우면 목록만 읽는다.' },
6304
- item: { type: 'string', description: 'claim 과 함께 쓰면 착수 선언(잠정). work 와 함께 쓰면 완료 확인/표시/해제 대상 — 이 일 안의 결정 항목(item_key, 예: decisions/foo.md).' },
6683
+ item: { type: 'string', description: 'claim 과 함께 쓰면 착수 선언(잠정). work 와 함께 쓰면 완료 확인/표시/해제 대상 — 이 일 안의 결정 항목(item_key, 예: decisions/foo.md). 번호를 쓰는 소속처는 하위 업무 번호(예 W-04 — 목록의 번호 그대로)도 받는다 — 그 일의 번호가 아니면 서버가 거절한다.' },
6305
6684
  work: { type: 'string', description: '이 일(slug). **혼자 주면 그 일의 항목 목록**(번호·제목·계획 기간·상태·item_key)을 읽는다(부작용 없음). item 과 함께 주면 그 항목의 완료 확인·표시·해제 — claim 과 별개(착수 선언 없이도 완료를 다룰 수 있다).' },
6306
6685
  done_when: { type: 'string', description: '이 항목의 완료 조건 — 무엇이 되면 끝인가. done_evidence 와 반드시 함께 채운다(work+item과 함께).' },
6307
6686
  done_evidence: { type: 'string', description: 'done_when 과 함께 쓴다 — 그 조건이 실제로 충족됐다는 구체적 관측(파일·시험 결과·커밋 등). 비우면 완료로 못 간다 — 지어내지 말 것, 스스로 확인한 것만.' },
@@ -6505,12 +6884,18 @@ const TOOLS = [
6505
6884
  + '다른 기계·두뇌에 닿는다 — 서버를 지나기 때문이다.\n'
6506
6885
  + '`SendMessage` 는 **이 기계의 Claude Code 끼리만** 가는 지름길이다. `ListAgents` 에 없다고 '
6507
6886
  + '해서 세션이 없는 것이 아니다 — 발견은 `gcell agents`, 주소는 여기 `to` 다.\n'
6887
+ + '★ 받는 쪽 주소는 **세션 uuid(36자)가 정본**이다 — `gcell agents` 가 주소 칸에 내는 그 값을 쓴다. '
6888
+ + '세션 이름(예: report-portal-37)은 프로세스가 다시 뜰 때마다 새로 뽑히는 **그 순간의 별칭**이라, 적어 둔 이름은 '
6889
+ + '죽거나 같은 이름을 새로 받은 다른 세션에게 간다. 이름으로 부쳤으면 결과의 「받는 세션」 uuid 로 이어 간다.\n'
6890
+ + '한 대화를 창 둘 이상이 함께 띄우면(`gcell agents` 의 「창 N곳」) 그 uuid 앞 통은 어느 창이 받을지 정해지지 않는다 — '
6891
+ + '한 창에 닿으려면 `SendMessage` 에 그 창의 지금 이름을 쓴다.\n'
6892
+ + '이 세션에 이름이 없어도 보낸다 — 그때 받는 쪽에는 이 세션 uuid 앞 8자가 발신자로 찍힌다.\n'
6508
6893
  + '무엇을 해 달라고 **부탁해도 된다** — 이 창구는 받는 쪽 눈앞에 「누가 보냈는지」를 서버가 '
6509
6894
  + '반드시 붙여 주므로, 세계 장부와 달리 지시형이 막히지 않는다.\n'
6510
- + '받는 것은 부를 필요가 없다 — 온 것은 다른 도구를 부를 때 응답 꼬리에 저절로 실린다.\n'
6895
+ + '받는 것은 부를 필요가 없다 — 온 것은 다른 도구를 부를 때 응답 꼬리에 저절로 실린다(답할 주소 uuid 가 함께 온다).\n'
6511
6896
  + '**사람에게는 이메일로** 보낸다(그 소속처 담당만) — 포털 벨이 울린다.\n'
6512
- + '⚠ 상대 이름은 **지어내지 말 것.** `world` 나 `gcell agents` 가 아는 이름, 세션 uuid, '
6513
- + '담당의 이메일, 또는 `brain` 중 하나다. 못 찾으면 사용자에게 묻는다.\n'
6897
+ + '⚠ 상대 주소는 **지어내지 말 것.** 세션 uuid, 방금 확인한 세션 이름, 담당의 이메일, 또는 `brain` 중 하나다. '
6898
+ + '못 찾으면 사용자에게 묻는다.\n'
6514
6899
  + '⚠ 한 실(thread)에는 상한이 있다 — **왕복**(한 쪽이 보내고 상대가 답하면 1왕복)은 사람이 낀 실 5회·기계끼리 3회, '
6515
6900
  + '답 없이 같은 쪽이 연달아 부치는 것은 3통. 넘으면 409 로 거절된다. '
6516
6901
  + '**사람에게 저절로 알리는 기능은 아직 없다** — 사람의 판단이 필요하면 이 세션의 사용자에게 말하거나 '
@@ -6519,7 +6904,7 @@ const TOOLS = [
6519
6904
  type: 'object',
6520
6905
  properties: {
6521
6906
  tenant: { type: 'string', description: '소속처 slug(이름도 받는다). 모르면 list_my_tenants 로 먼저 확인한다' },
6522
- to: { type: 'string', description: '받는 쪽 — 세포 이름 · 세션 uuid · 담당의 이메일(사람에게) · 또는 brain' },
6907
+ to: { type: 'string', description: '받는 쪽 — **세션 uuid(36자 · 정본 — `gcell agents` 의 주소 칸)** · 방금 확인한 세션 이름(그 순간의 별칭 — 바뀌면 남에게 간다) · 담당의 이메일(사람에게) · 또는 brain' },
6523
6908
  body: { type: 'string', description: '할 말' },
6524
6909
  subject: { type: 'string', description: '한 줄 제목(선택)' },
6525
6910
  artifact: { type: 'string', description: '무엇에 대한 이야기인가 — 파일·문서 경로(선택)' },
@@ -6598,9 +6983,26 @@ function pinsText(r) {
6598
6983
  }
6599
6984
  function warnText(r) {
6600
6985
  const w = [...(r?.warnings ?? []), ...ASSET_HINTS];
6601
- return w.length
6602
- ? `\n\n경고 ${w.length}건:\n· ${w.join('\n· ')}`
6603
- : '';
6986
+ if (!w.length) return '';
6987
+ /*
6988
+ * 결정 모드(원고에 결정 블록 — 관보 버전2 → v3 ⑨)는 **개수를 안 찍는다**(설계안 8.3) — 「경고 N건」 합계는 고친 횟수를 목표로
6989
+ * 돌게 하는 손잡이였다(lim 위키 사례 #212). 렌더러가 결정에 속한 알림을 「결정 「X」 — 」 로 시작하게 내므로(계약 3.4),
6990
+ * 그 줄은 결정 이름 아래로 모아 들여 찍는다. 처음 나온 차례를 지키고, 결정에 안 속한 줄은 제자리에 그대로 둔다.
6991
+ * 모드는 응답의 `mode` 로 가른다(관보 v3 서버가 dry_run·발행·교체 응답에 싣는다 — 서식은 하나라 template 은 늘 lim-report).
6992
+ * 옛 서버(관보 버전2)는 mode 없이 template:'lim-decision' 을 보낸다 — 그것도 결정 모드로 읽는다. 없거나 report 면 지금 그대로.
6993
+ */
6994
+ if (r?.mode === 'decision' || r?.template === 'lim-decision') {
6995
+ const out = [], groups = new Map();
6996
+ for (const line of w) {
6997
+ const m = /^결정 「([^」]*)」 — ([\s\S]*)$/.exec(String(line));
6998
+ if (!m) { out.push(`· ${line}`); continue; }
6999
+ if (!groups.has(m[1])) { const g = []; groups.set(m[1], g); out.push([m[1], g]); }
7000
+ groups.get(m[1]).push(m[2]);
7001
+ }
7002
+ const text = out.map((x) => (typeof x === 'string' ? x : `결정 「${x[0]}」\n${x[1].map((t) => ` · ${t}`).join('\n')}`));
7003
+ return `\n\n알림 — 결정 장 이름 아래 자리 글:\n${text.join('\n')}`;
7004
+ }
7005
+ return `\n\n경고 ${w.length}건:\n· ${w.join('\n· ')}`;
6604
7006
  }
6605
7007
 
6606
7008
  /* 발행물의 **구조**를 사람이 읽는 줄로 옮긴다.
@@ -7177,19 +7579,30 @@ const mailQueue = []; // 아직 안 말한 통(서버가 렌더한 글
7177
7579
  const mailBack = []; // 내가 부쳤다 만료된 것
7178
7580
  const mailBackSeen = new Set();
7179
7581
 
7180
- /**
7181
- * 이 세션의 **말 걸 수 있는 이름.** 전사에서 마지막 자기소개를 캔다.
7182
- *
7183
- * ★ 왜 전사인가: `ListAgents` 를 부르면 하네스가 「This session is <이름> [<id>]」를
7184
- * **전사에 적는다.** 그것은 모델이 지어낸 값이 아니라 하네스가 쓴 사실이고,
7185
- * 전사 파일 이름이 곧 세션 uuid 라 두 체계가 거기서 만난다.
7186
- * 세포의 `agent_name_in`(`cell/src/world.rs:443`)과 **같은 규칙**을 쓴다 —
7187
- * 두 자로 재면 같은 세션을 다르게 부르게 된다.
7188
- *
7189
- * ⚠ **마지막 것**을 쓴다. 이름은 세션 도중에 바뀌고, 옛 이름은 죽은 주소다.
7190
- * ⚠ **늘어난 부분만 다시 읽는다.** 전사가 7MB 까지 가므로(실측) 매번 통독하면
7191
- * 그 자체가 부하다. 이름은 앞으로만 생기니 뒤만 보면 된다.
7192
- * ⚠ 못 찾으면 `null` — **지어내지 않는다.** 이름 없는 주소는 받을 수는 있어도 못 보낸다.
7582
+ /*
7583
+ * ══ 이 세션의 주소 — **세션 uuid 가 정본, 이름은 보내기 직전에 푼다** (주소 계약 2 · 2026-10-07) ══════════════════════════
7584
+ *
7585
+ * 오래 들고 있을 주소는 세션 uuid(36자)다. 하네스 이름(예: report-portal-37)은 프로세스가 뜰 때마다 00~ff 로 다시 뽑히는
7586
+ * 표시값이라 적어 두면 죽거나 남에게 간다 — 469cd2a5 의 이름은 5일 사이 eb → 26 → 37 이었고, 7afd1d02 의 옛 이름 hnl-73 은
7587
+ * 지금 산 세션 110cd958 의 이름이다(검증 워크플로 2026-10-07 실측). 그래서 이 MCP 는
7588
+ * ① **이름이 없어도 보낸다.** 서버는 2026-08-25 부터 이름 없는 발신을 uuid 앞 8자로 받는다(8ec9b061:www/portal/lib/mail_store.php:96-105 —
7589
+ * mail_store_sender_session). 전에는
7590
+ * 여기 문지기(「이름 없으면 못 보냄」)가 서버보다 엄해서, 등록부를 못 읽은 세션이 전체 uuid 앞으로 보낸 통까지 거절됐다
7591
+ * (2026-10-06T15:49:24Z, to=469cd2a5-…-f8751b9dcd18 → 15:49:25Z 거절).
7592
+ * ② 서버 주소록에 대는 이름은 **대기 직전에** 푼다 — 세포(`gcell agents --self --json`)에 먼저 묻고(`cellSelf`), 세포가 없거나
7593
+ * 못 알아들으면 아래 JS 사본(`selfAddressJs`)으로. 같은 규칙을 두 자로 재면 같은 세션을 다르게 부른다 — 9aedc967 에서 등록부
7594
+ * pid 가 문자열이면 세포는 그 줄을 버리고 MCP 는 받았다(9aedc967:cell/src/world.rs:684 대 mcp/genetics.mjs:7570). 그래서 JS 사본은 세포와 같은 계약을 진다.
7595
+ * ③ 대는 이름은 **지금 이 프로세스의 이름이라고 확인된 것**뿐이다(`myAgentName`). 확인 못 한 이름(다시 뜨기 전의 자기소개)을 대면
7596
+ * 서버가 그 이름을 이 세션으로 풀어(052 의 최신-승) 그 이름의 지금 임자에게 갈 통이 여기로 온다.
7597
+ */
7598
+ /*
7599
+ * ── 전사의 자기소개 ─────────────────────────────────────────────────────────────────────────────
7600
+ * `ListAgents` 를 부르면 하네스가 「This session is <이름> [<id>]」를 **전사에 적는다.** 모델이 지어낸 값이 아니라 하네스가 쓴
7601
+ * 사실이지만, 그 이름은 **그때의** 이름이다 — 다시 뜬 뒤에는 옛 이름이다. 그래서 등록부 다음 차례이고, 그마저 이 MCP 가 뜬 뒤에
7602
+ * 적힌 것만 지금 이름으로 친다(`myAgentName`).
7603
+ * ⚠ **마지막 것**을 쓴다 — 끝에서 거슬러 읽는다(`lastIntroInRange`).
7604
+ * ⚠ **늘어난 부분만 다시 읽는다.** 그림이 base64 로 실려 전사가 253MB 까지 간다(2026-10-07 실측 — 옛 주석의 「7MB」는 틀렸다).
7605
+ * ⚠ 못 찾으면 `null` — **지어내지 않는다.**
7193
7606
  */
7194
7607
  /*
7195
7608
  * ⚠⚠ **줄 안의 글자가 아니라 레코드의 모양으로 읽는다** (2026-08-25 실측).
@@ -7208,7 +7621,37 @@ const mailBackSeen = new Set();
7208
7621
  */
7209
7622
  const INTRO_HEAD = 'This session is ';
7210
7623
  const INTRO_TAIL = 'the name other sessions use';
7211
- const AGENT_NAME_RE = /^This session is ([A-Za-z0-9._-]{1,60}) \[([0-9a-fA-F]+)\]/;
7624
+ /**
7625
+ * 자기소개 글 한 덩이 → `{ name, id }`(세포 `agent_name_in` 과 한 벌). 못 읽으면 null.
7626
+ * ⚠ 이름 글자는 **등록부와 같은 꼴**(`harnessNameClean` — 제어문자만 걷고 65자까지)이다(2026-10-07). 전에는 [A-Za-z0-9._-]{1,60} 만
7627
+ * 받았는데, 실제 ListAgents 목록에 「atflee Graph」·「홈 변화 그래프 — 최신 기록이 …」(47자) 같은 이름이 선다(469cd2a5 전사 실측 —
7628
+ * 세포 쪽 조사). 그래서 식별자는 꼬리(INTRO_TAIL) 앞의 **마지막** 「 [<16진>]」이고 — 이름 안에 「 — 」나 「 [」가 있어도 안 갈린다 —
7629
+ * 본문의 첫 줄만 본다(그 아래 「Peer sessions」 목록의 이름들은 이 세션의 이름이 아니다).
7630
+ */
7631
+ function introNameIn(text) {
7632
+ const t = String(text ?? '');
7633
+ const i = t.indexOf(INTRO_HEAD);
7634
+ if (i < 0) return null;
7635
+ const rest = t.slice(i + INTRO_HEAD.length).split('\n')[0];
7636
+ const tail = rest.indexOf(INTRO_TAIL);
7637
+ const head = tail >= 0 ? rest.slice(0, tail) : rest;
7638
+ const open = head.lastIndexOf(' [');
7639
+ if (open < 0) return null;
7640
+ const name = harnessNameClean(head.slice(0, open));
7641
+ if (!name) return null;
7642
+ const after = head.slice(open + 2);
7643
+ const close = after.indexOf(']');
7644
+ if (close < 0) return null;
7645
+ const id = after.slice(0, close);
7646
+ return /^[0-9A-Fa-f]+$/.test(id) ? { name, id } : null;
7647
+ }
7648
+ /**
7649
+ * 전사의 레코드(JSONL 한 줄) → 그 자기소개의 이름(세포 `agent_name_in_record` 와 한 벌). 아니면 null.
7650
+ * ★ 레코드에 하네스의 짝 표식 `toolUseResult.listing`(ListAgents 결과의 구조화 칸 — 본문과 같은 글)이 있어야 한다(2026-10-07 검증 SEC-6).
7651
+ * Bash 결과는 `{stdout, stderr, …}` 라, `echo "This session is <남의 이름> [ab12] — the name other sessions use"` 한 줄이 전에는 이름으로
7652
+ * 읽혀 아래 myAgentName ②(뜬 뒤 자기소개)로 서버에 이 세션의 주소로 섰다 — 웹 문서에 숨은 지시 하나로. 실측: 전사 154개의 자기소개 211건이
7653
+ * 하네스 2.1.239~2.1.290 내내 이 칸을 가졌고 본문과 같았다.
7654
+ */
7212
7655
  function introNameOf(line) {
7213
7656
  if (!line.includes(INTRO_HEAD)) return null; // 값싼 검사로 먼저 거른다
7214
7657
  let d;
@@ -7218,63 +7661,765 @@ function introNameOf(line) {
7218
7661
  const first = Array.isArray(c) ? c[0] : null;
7219
7662
  if (!first || first.type !== 'tool_result') return null;
7220
7663
  let body = first.content;
7221
- if (Array.isArray(body)) body = body.map((x) => (x && x.type === 'text' ? String(x.text || '') : '')).join('');
7664
+ if (Array.isArray(body)) body = body.map((x) => (x && x.type === 'text' && typeof x.text === 'string' ? x.text : '')).join('');
7222
7665
  if (typeof body !== 'string' || !body.startsWith(INTRO_HEAD) || !body.includes(INTRO_TAIL)) return null;
7223
- const m = AGENT_NAME_RE.exec(body);
7224
- return m ? m[1] : null;
7666
+ const listing = d?.toolUseResult?.listing;
7667
+ if (typeof listing !== 'string') return null; // ★ 하네스 ListAgents 의 짝 표식이 없다 — Bash·MCP 가 찍은 같은 글
7668
+ const a = introNameIn(body);
7669
+ const b = introNameIn(listing);
7670
+ return a && b && a.name === b.name && a.id === b.id ? a.name : null;
7225
7671
  }
7226
- let nameCache = null; // { file, size, name }
7227
- function myAgentName() {
7228
- const uuid = mySessionId();
7229
- if (!/^[0-9a-f-]{36}$/i.test(uuid)) return null;
7230
- const f = transcriptOf(uuid);
7231
- if (!f) {
7672
+ /*
7673
+ * ── 하네스 등록부 — JS 사본(계약 1 의 의미 그대로 · 세포가 없거나 못 알아들을 때만 쓴다) ─────────────────────────────────
7674
+ * 하네스는 이름을 **프로세스가 뜰 때마다 새로 짓고** `<설정 폴더>/sessions/<pid>.json` 의 `name` 에 적는다 — ListAgents·SendMessage 가
7675
+ * 쓰는 바로 그 이름이다(`nameSource:"derived"` · `nameSince` = 프로세스 시작). 실측(2026-10-07): 469cd2a5 는 등록부 「report-portal-37」,
7676
+ * 전사 「report-portal-26」.
7677
+ *
7678
+ * ★ 세포(`cell/src/world.rs` 「주소는 세션 uuid 다」 머리말 · registry_rows_in·row_live·self_row·whereabouts_of)와 **한 벌**이다.
7679
+ * 두 자를 맞대는 정본은 둘이다 — 칸 규칙표 `cell/tests/fixtures/registry/cases.json`(smoke 3-2)과, 경우 통째(등록부 줄·전사·env·
7680
+ * 프로세스 표 → 계약 1 의 답)를 맞대는 **이름 관문** `cell/tests/fixtures/agent-names/`(smoke 3-4 · 세포는 cargo 시험 둘이 같은 경우를 문다).
7681
+ * 관문이 처음 돌 때(2026-10-07) 이 사본이 세 자리에서 세포와 달랐다 — 산 줄이면 옛 이름을 안 읽음 · 옛 이름을 설정 폴더가 아니라
7682
+ * findTranscript(~/.claude/projects 고정)로 찾음 · 1MiB 넘는 줄을 자기소개로 읽음. 셋 다 세포 쪽으로 맞췄다.
7683
+ * 9aedc967 에서 둘이 갈렸다 — pid 가 문자열 "32054" 면 세포는 그 줄을 버리고 MCP 는 받아, 같은 세션을 세포는 옛 자기소개로 ·
7684
+ * MCP 는 등록부 이름으로 불렀다(검증 조사 픽스처 A). 그래서 아래 규칙은 글자 하나까지 세포를 따른다:
7685
+ * · 파일은 `<숫자>.json` 만(세션 사이 인증 열쇠 `.key` 는 안 읽는다) · 64KiB 를 넘으면 안 읽는다.
7686
+ * · 숫자 칸(pid·updatedAt)은 JSON 수(정수 — 소수부 0 인 실수 포함) 또는 **ASCII 숫자 1~16자** 문자열(`harnessInt`).
7687
+ * `Number()` 를 그대로 쓰지 않는다 — `Number(" 7")`·`Number("0x7")` 도 7 이라 두 자가 갈린다.
7688
+ * · 이름은 제어문자(Cc)만 걷고 앞뒤 공백을 다듬어 1~65자(코드 포인트, `harnessNameClean`). 넘으면 자르지 않고 버린다(줄은 남는다 —
7689
+ * 이름 없이 떠 있는 세션). 하네스는 `/rename` 이름을 제어문자만 걷고 받고, 안정 주소 스위치가 켜지면 이름이 65자까지 간다 —
7690
+ * 옛 [A-Za-z0-9._-]{1,60} 은 「atflee Graph」·47자 한글 이름의 줄을 말없이 버렸다.
7691
+ * · 떠 있나(`harnessRowLife`)는 셋 중 하나 — 'live' · 'gone'(재서 아니라고 나왔다) · 못 잰 까닭('platform'·'domain'·'start').
7692
+ * 떠 있다 = pidDomain 이 이 판과 같고(하네스와 같은 꼴로 짓는다 — `harnessPidDomain`, F1) · pid 가 있고(kill 0 · EPERM 도 있는 것) ·
7693
+ * procStart 가 있으면 그 pid 의 실제 시작 시각과 2초 안. procStart 를 못 대조하면(꼴을 못 읽음 · 시작 시각을 못 잼) **산 것으로 안
7694
+ * 친다** — 끝난 세션의 옛 이름을 「지금 이름」으로 대면 남의 통이 여기로 온다. 이름 없이 uuid 로 닿는 쪽이 안전하다. 그러나 「끝났다」고도
7695
+ * 안 한다(F5 — 계약 칸 measured 거짓). procStart 칸이 없는 옛 하네스 줄은 kill 0 만.
7696
+ * · 같은 조건의 산 줄이 둘이면 procStart 가 늦은 줄(가장 나중에 뜬 프로세스), 같으면 pid 가 큰 줄(readdir 차례에 안 기댄다). updatedAt 은
7697
+ * 안 쓴다 — 상태가 바뀔 때마다 움직여 창 둘이 띄운 대화는 물을 때마다 다른 이름이 나왔다(L1). 산 줄 수는 live_rows 로 낸다(SEC-1).
7698
+ * · 자기 줄은 **프로세스 열쇠** 차례로 찾는다: env `CLAUDE_CODE_MESSAGING_SOCKET` == `messagingSocketPath` → `CLAUDE_PID` == `pid` →
7699
+ * `CLAUDE_CODE_SESSION_ID` == `sessionId`. 세션 id 만으로 찾으면 /clear 뒤에는 MCP env 의 옛 id 로 줄을 못 찾는다(MCP 는 뜰 때
7700
+ * env 를 한 번 받는다 — 2.1.290 @217810326 · /clear 는 등록부 줄의 sessionId 만 바꾼다). MCP 의 process.ppid 는 claude 가 아니라
7701
+ * volta 심이라 열쇠가 못 된다(ps 실측 MCP 18936 → 심 18901 → claude 18159). MCP env 에는 CLAUDE_PID 가 없다(Bash·훅의 것).
7702
+ */
7703
+ /**
7704
+ * 하네스의 집 — 세포 `crate::home_dir()` 와 같은 차례(유닉스: HOME → USERPROFILE · Windows: 거꾸로), 둘 다 없으면 os.homedir().
7705
+ * ⚠ `env` 에서 읽는다(전에는 os.homedir() 고정) — 이름 관문이 경우마다 임시 집을 주고, 세포도 그 env 의 HOME 을 읽는다.
7706
+ */
7707
+ function harnessHome(env = process.env) {
7708
+ for (const k of process.platform === 'win32' ? ['USERPROFILE', 'HOME'] : ['HOME', 'USERPROFILE']) {
7709
+ const v = env?.[k];
7710
+ if (typeof v === 'string' && v !== '') return v;
7711
+ }
7712
+ return os.homedir();
7713
+ }
7714
+ /** 하네스 설정 폴더 — CLAUDE_CONFIG_DIR(비어 있지 않을 때) 아니면 <집>/.claude(세포 `harness_config_dir`). NFC 는 하네스가 하는 대로. */
7715
+ function harnessConfigDir(env = process.env) {
7716
+ return String(env?.CLAUDE_CONFIG_DIR || path.join(harnessHome(env), '.claude')).normalize('NFC');
7717
+ }
7718
+ function harnessSessionsDir(env = process.env) {
7719
+ return path.join(harnessConfigDir(env), 'sessions');
7720
+ }
7721
+ /**
7722
+ * Claude Code 전사를 찾는 뿌리 — <설정 폴더>/projects, 그다음 <집>/.claude/projects(설정 폴더가 그것이 아닐 때만) · 세포 `claude_project_bases`.
7723
+ * ⚠ 옛 이름(자기소개)은 이 규칙으로만 찾는다. 전에는 `introOf`(findTranscript — Codex 먼저 · cwd 폴더 · ~/.claude/projects 고정 · Cursor)를 써서
7724
+ * CLAUDE_CONFIG_DIR 로 뜬 세션의 옛 이름을 세포와 다르게 냈다(이름 관문 2026-10-07 — 픽스처 10-config-dir 가 미끼를 냈다).
7725
+ */
7726
+ function claudeProjectBases(env = process.env) {
7727
+ const bases = [path.join(harnessConfigDir(env), 'projects')];
7728
+ const home = path.join(path.join(harnessHome(env), '.claude').normalize('NFC'), 'projects');
7729
+ if (!bases.includes(home)) bases.push(home);
7730
+ return bases;
7731
+ }
7732
+ /**
7733
+ * `bases` 의 프로젝트 폴더들에서 `<uuid>.jsonl`(본 전사)을 — 뿌리 차례, 그 안에서는 폴더 이름의 바이트 차례(libuv 가 strcmp 로 늘어놓는다 ·
7734
+ * 세포도 같은 차례로 맞췄다)로 처음 것. 하위 에이전트 전사(`<uuid>/subagents/…/agent-*.jsonl`)는 이 꼴이 아니라 안 잡힌다(세포 `claude_transcript_in`).
7735
+ */
7736
+ function claudeTranscriptIn(bases, sid) {
7737
+ const s = String(sid ?? '');
7738
+ if (!AUTO_UUID_RE.test(s)) return null;
7739
+ for (const base of bases) {
7740
+ let names;
7741
+ try { names = fs.readdirSync(base); } catch { continue; }
7742
+ for (const n of names) {
7743
+ const f = path.join(base, n, `${s}.jsonl`);
7744
+ try { if (fs.statSync(f).isFile()) return f; } catch { /* 이 폴더엔 없다 */ }
7745
+ }
7746
+ }
7747
+ return null;
7748
+ }
7749
+ /** 그 pid 의 프로세스가 지금 있나 — 신호 0 은 보내지 않고 묻기만 한다. 남의 프로세스(EPERM)도 있는 것이다. */
7750
+ function pidAlive(pid) {
7751
+ if (!Number.isInteger(pid) || pid <= 0) return false;
7752
+ try { process.kill(pid, 0); return true; } catch (e) { return e?.code === 'EPERM'; }
7753
+ }
7754
+ /** 등록부 숫자 칸 → 정수(세포 `reg_int`): JSON 수는 안전 정수만, 문자열은 ASCII 숫자 1~16자만. 그 밖(부호·공백·소수점·16진·지수·불·null)은 null. */
7755
+ function harnessInt(v) {
7756
+ if (typeof v === 'number') return Number.isSafeInteger(v) ? v : null;
7757
+ if (typeof v === 'string' && /^[0-9]{1,16}$/.test(v)) return Number(v);
7758
+ return null;
7759
+ }
7760
+ const HARNESS_NAME_MAX = 65;
7761
+ // 유니코드 White_Space 중 제어문자(Cc)가 아닌 것 — 세포 name_clean 의 trim() 과 같은 자리를 다듬는다(JS 의 \s 는 U+FEFF 도 걷어 다르다).
7762
+ const HARNESS_TRIM = /^[\u0020\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+|[\u0020\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+$/g;
7763
+ /** 이름 꼴(세포 `name_clean`) — 제어문자(Cc)만 걷고 앞뒤 공백을 다듬은 뒤 1~65자(코드 포인트)면 그 글, 아니면 null(자르지 않는다). */
7764
+ function harnessNameClean(s) {
7765
+ if (typeof s !== 'string') return null;
7766
+ const t = s.replace(/[\u0000-\u001F\u007F-\u009F]/g, '').replace(HARNESS_TRIM, '');
7767
+ const n = [...t].length;
7768
+ return n >= 1 && n <= HARNESS_NAME_MAX ? t : null;
7769
+ }
7770
+ /** 이미 깨끗한 이름인가 — 세포가 낸 이름(이미 name_clean 을 지났다)을 받을 때 모양을 다시 잰다. */
7771
+ function harnessNameOk(s) {
7772
+ return typeof s === 'string' && harnessNameClean(s) === s;
7773
+ }
7774
+ const PROC_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
7775
+ /** `ps -o lstart` 꼴(「Www Mmm d HH:MM:SS YYYY」, UTC 로 읽는다) → 유닉스 초. 아니면 null(세포 `lstart_epoch`). */
7776
+ function lstartEpoch(s) {
7777
+ const p = String(s).split(/\s+/).filter(Boolean);
7778
+ if (p.length !== 5 || Buffer.byteLength(p[0]) !== 3) return null;
7779
+ const mo = PROC_MONTHS.indexOf(p[1]);
7780
+ if (mo < 0) return null;
7781
+ const num = (x, lo, hi) => {
7782
+ if (!/^[0-9]{1,4}$/.test(x)) return null;
7783
+ const v = Number(x);
7784
+ return v >= lo && v <= hi ? v : null;
7785
+ };
7786
+ const d = num(p[2], 1, 31);
7787
+ const hms = p[3].split(':');
7788
+ if (d === null || hms.length !== 3) return null;
7789
+ const h = num(hms[0], 0, 23), mi = num(hms[1], 0, 59), se = num(hms[2], 0, 60), y = num(p[4], 1970, 9999);
7790
+ if (h === null || mi === null || se === null || y === null) return null;
7791
+ return Date.UTC(y, mo, d, h, mi, se) / 1000;
7792
+ }
7793
+ /** ISO 꼴(세포 `transcript::iso_epoch` 와 같은 너그러움 — 앞 19자만 본다). 못 읽으면 0. */
7794
+ function isoEpochLoose(s) {
7795
+ if (s.length < 19 || s[4] !== '-' || s[7] !== '-' || (s[10] !== 'T' && s[10] !== ' ')) return 0;
7796
+ const n = (a, z) => { const v = s.slice(a, z); return /^[+-]?[0-9]+$/.test(v) ? parseInt(v, 10) : 0; };
7797
+ const y = n(0, 4), mo = n(5, 7);
7798
+ if (y < 1970 || mo < 1 || mo > 12) return 0;
7799
+ return Date.UTC(y, mo - 1, n(8, 10), n(11, 13), n(14, 16), n(17, 19)) / 1000;
7800
+ }
7801
+ /**
7802
+ * 등록부 `procStart` → 유닉스 초(세포 `proc_start_epoch`). 하네스 2.1.290 은 `TZ=UTC ps -o lstart` 꼴로 적는다 — 「Tue Oct 6 04:41:55
7803
+ * 2026」(2026-10-07 실측 13/13 이 그 pid 의 `TZ=UTC ps -o lstart=` 와 같은 글자). 꼴이 바뀌어도 흔한 둘(epoch 수 — 1e11 을 넘으면 ms,
7804
+ * ISO-8601 `…Z`)은 받는다. 그 밖은 null — 로캘을 탄 ps 꼴(「2026년 10월 6일 화요일 …」)도 못 읽는 것으로 친다(세포와 같게).
7805
+ */
7806
+ function procStartEpoch(v) {
7807
+ if (typeof v === 'string') {
7808
+ const l = lstartEpoch(v);
7809
+ if (l !== null) return l;
7810
+ const t = v.trim();
7811
+ if (!/^[\x00-\x7F]*$/.test(t) || !t.endsWith('Z')) return null;
7812
+ const e = isoEpochLoose(t);
7813
+ return e > 0 ? e : null;
7814
+ }
7815
+ if (typeof v === 'number') {
7816
+ const n = harnessInt(v);
7817
+ if (n === null || n <= 0) return null;
7818
+ return n > 100_000_000_000 ? Math.trunc(n / 1000) : n;
7819
+ }
7820
+ return null;
7821
+ }
7822
+ /** 등록부 JSON 한 개 → 줄(세포 `reg_row`). pid(1..2^31-1)·sessionId(uuid)가 없으면 null. procStart 는 absent · at · unreadable. */
7823
+ function harnessRowOf(d) {
7824
+ if (!d || typeof d !== 'object' || Array.isArray(d)) return null;
7825
+ const pid = harnessInt(d.pid);
7826
+ if (pid === null || pid <= 0 || pid > 2147483647) return null;
7827
+ if (typeof d.sessionId !== 'string' || !AUTO_UUID_RE.test(d.sessionId)) return null;
7828
+ const text = (k) => (typeof d[k] === 'string' && d[k] !== '' ? d[k] : null);
7829
+ const ps = d.procStart === undefined || d.procStart === null ? { kind: 'absent' }
7830
+ : (() => { const t = procStartEpoch(d.procStart); return t === null ? { kind: 'unreadable' } : { kind: 'at', t }; })();
7831
+ return { pid, session: d.sessionId.toLowerCase(), name: harnessNameClean(d.name), updatedAt: harnessInt(d.updatedAt) ?? 0,
7832
+ procStart: ps, socket: text('messagingSocketPath'), domain: text('pidDomain') };
7833
+ }
7834
+ const HARNESS_FILE_MAX = 64 * 1024;
7835
+ /** 등록부 폴더의 줄들(살았든 죽었든 — 판정은 `harnessRowLive`). 폴더가 없으면 빈 목록. 차례는 (pid, updatedAt, session). */
7836
+ function harnessRowsIn(dir) {
7837
+ let names;
7838
+ try { names = fs.readdirSync(dir); } catch { return []; }
7839
+ const rows = [];
7840
+ for (const n of names) {
7841
+ if (!/^[0-9]+\.json$/.test(n)) continue; // `<pid>.json` 만
7842
+ let d;
7843
+ try {
7844
+ const p = path.join(dir, n);
7845
+ const st = fs.statSync(p);
7846
+ if (!st.isFile() || st.size > HARNESS_FILE_MAX) continue;
7847
+ const raw = fs.readFileSync(p);
7848
+ if (raw.length > HARNESS_FILE_MAX) continue; // 잰 뒤 읽기 전에 자랐다
7849
+ d = JSON.parse(raw.toString('utf8'));
7850
+ } catch { continue; }
7851
+ const r = harnessRowOf(d);
7852
+ if (r) rows.push(r);
7853
+ }
7854
+ return rows.sort((a, b) => (a.pid - b.pid) || (a.updatedAt - b.updatedAt) || (a.session < b.session ? -1 : a.session > b.session ? 1 : 0));
7855
+ }
7856
+ /** procStart 와 실제 시작 시각의 허용 차(초) — lstart 는 초에서 자르고 Linux 의 btime 도 초 단위라 1초가 흔들릴 수 있다(세포와 같은 값). */
7857
+ const PROC_START_SLACK_SECS = 2;
7858
+ /**
7859
+ * 그 줄의 세션이 **지금 떠 있나**(위 머리말 · 세포 `row_life`) — 'live' · 'gone' · 못 잰 까닭('platform'·'domain'·'start').
7860
+ * `probe` = { alive(pid), started(pid) → 초|null, domain, measurable }. measurable 이 거짓(Windows — 세포가 pid 를 못 잰다)이면 모든 줄이 'platform'.
7861
+ * 칸이 없는 옛 probe(시험)는 잴 수 있는 것으로 친다.
7862
+ */
7863
+ function harnessRowLife(r, probe) {
7864
+ if (probe.measurable === false) return 'platform';
7865
+ if (r.domain && r.domain !== probe.domain) return 'domain'; // 다른 pid 공간의 pid 는 이 기계의 프로세스 표 값이 아니다 — 못 잰다
7866
+ if (!probe.alive(r.pid)) return 'gone'; // 하네스가 끝내며 못 지운 줄 — 그 이름은 아무도 안 받는다
7867
+ if (r.procStart.kind === 'absent') return 'live'; // 옛 하네스 줄 — 견줄 칸이 없다(kill 0 만)
7868
+ if (r.procStart.kind !== 'at') return 'start'; // 꼴을 못 읽었다 — 대조를 못 하니 산 것으로 안 친다(끝난 것도 아니다)
7869
+ const s = probe.started(r.pid);
7870
+ if (s === null || s === undefined) return 'start'; // 시작 시각을 못 쟀다(ps 실패 · 시간 초과)
7871
+ return Math.abs(s - r.procStart.t) <= PROC_START_SLACK_SECS ? 'live' : 'gone';
7872
+ }
7873
+ /** 떠 있다고 **재서** 나왔나(세포 `row_live`). */
7874
+ function harnessRowLive(r, probe) { return harnessRowLife(r, probe) === 'live'; }
7875
+ /** 고르는 열쇠 — procStart(가장 나중에 뜬 프로세스), 같으면 pid(세포 `pick_key` · 칸이 없는 옛 줄은 가장 이르다). a ≥ b 면 참. */
7876
+ function harnessPickGe(a, b) {
7877
+ const ta = a.procStart.kind === 'at' ? a.procStart.t : -Infinity;
7878
+ const tb = b.procStart.kind === 'at' ? b.procStart.t : -Infinity;
7879
+ return ta > tb || (ta === tb && a.pid >= b.pid);
7880
+ }
7881
+ /**
7882
+ * 조건에 맞는 줄들을 잰다(세포 `lookup`) — `{ row, lives, blind }`: 산 줄 중 고른 것 · 산 줄 전부 · 못 잰 줄 중 고른 것과 까닭(`{ why, row }`).
7883
+ * 같은 열쇠면 나중 줄 — Rust 는 `>=` 로 같은 최댓값 중 마지막을 고르고, 두 자 모두 (pid, updatedAt, session) 차례로 걷는다.
7884
+ */
7885
+ function harnessLookup(rows, probe, pred) {
7886
+ const out = { row: null, lives: [], blind: null };
7887
+ for (const r of rows) {
7888
+ if (!pred(r)) continue;
7889
+ const life = harnessRowLife(r, probe);
7890
+ if (life === 'live') {
7891
+ out.lives.push(r);
7892
+ if (!out.row || harnessPickGe(r, out.row)) out.row = r;
7893
+ } else if (life !== 'gone') {
7894
+ if (!out.blind || harnessPickGe(r, out.blind.row)) out.blind = { why: life, row: r };
7895
+ }
7896
+ }
7897
+ return out;
7898
+ }
7899
+ /** 조건에 맞는 **산** 줄 중 고른 것(`harnessLookup` 의 row). 없으면 null. */
7900
+ function harnessLiveRow(rows, probe, pred) {
7901
+ return harnessLookup(rows, probe, pred).row;
7902
+ }
7903
+ /** 나를 찾는 프로세스 열쇠(세포 `SelfKeys::from_vars`) — 빈 값은 없는 것. 소켓은 글자 그대로, CLAUDE_PID 는 다듬어 숫자 규칙, 세션 id 는 uuid 꼴만(소문자). */
7904
+ function harnessSelfKeys(env) {
7905
+ const get = (k) => { const v = env?.[k]; return typeof v === 'string' && v.trim() !== '' ? v : null; };
7906
+ const p = get('CLAUDE_PID');
7907
+ const pid = p === null ? null : harnessInt(p.trim());
7908
+ const sid = get('CLAUDE_CODE_SESSION_ID');
7909
+ const session = sid === null ? null : sid.trim().toLowerCase();
7910
+ return { socket: get('CLAUDE_CODE_MESSAGING_SOCKET'),
7911
+ pid: pid !== null && pid > 0 && pid <= 2147483647 ? pid : null,
7912
+ session: session && AUTO_UUID_RE.test(session) ? session : null };
7913
+ }
7914
+ /**
7915
+ * 등록부의 **내 줄**을 찾는다(세포 `self_lookup`) — 열쇠 차례: 소켓 → CLAUDE_PID → 세션 id. 앞 열쇠가 **산 줄이나 못 잰 줄**을 찾으면 뒤 열쇠는
7916
+ * 안 본다(못 잰 줄이 내 줄일 수 있는데 세션 id 로 넘어가면 같은 대화를 띄운 **다른 창**을 나로 고른다 — SEC-1). 끝난 줄만 찾았으면 다음 열쇠로.
7917
+ * `{ by: 'socket'|'pid'|'session', lk }` 또는 null.
7918
+ */
7919
+ function harnessSelfLookup(rows, keys, probe) {
7920
+ const tries = [['socket', keys.socket, (r) => r.socket === keys.socket], ['pid', keys.pid, (r) => r.pid === keys.pid],
7921
+ ['session', keys.session, (r) => r.session === keys.session]];
7922
+ for (const [by, key, pred] of tries) {
7923
+ if (key === null || key === undefined) continue;
7924
+ const lk = harnessLookup(rows, probe, pred);
7925
+ if (lk.row || lk.blind) return { by, lk };
7926
+ }
7927
+ return null;
7928
+ }
7929
+ /** 등록부의 **내 산 줄**(세포 `self_row` — `harnessSelfLookup` 이 산 줄을 찾았을 때만). */
7930
+ function harnessSelfRow(rows, keys, probe) {
7931
+ return harnessSelfLookup(rows, keys, probe)?.lk.row ?? null;
7932
+ }
7933
+ const procStartSeen = new Map(); // pid → { at, v } — 같은 pid 를 1분 안에 다시 안 잰다(ps 한 번이 수 ms)
7934
+ /**
7935
+ * 그 pid 프로세스가 **시작한 때**(유닉스 초 · 세포 `proc_started_at` 과 같은 값). 못 재면 null.
7936
+ * · macOS: `ps -o lstart=` 를 UTC·C 로캘로 — 세포가 sysctl 로 읽는 p_starttime 과 같은 초다(2026-10-07 실측: pid 32054 →
7937
+ * 1791261715 = 「Tue Oct 6 04:41:55 2026」). 로캘을 물려받으면 macOS ps 는 날짜를 한국어로 써서(LANG=ko_KR.UTF-8 실측) 못 읽는다.
7938
+ * · Linux: `/proc/<pid>/stat` 의 22번째 칸(부팅 뒤 틱) ÷ 100 + `/proc/stat` 의 btime — 못 읽으면 ps. ⚠ USER_HZ 를 100 으로 둔다
7939
+ * (Linux 의 사용자 공간 틱은 아키텍처와 상관없이 100 이다 — 세포는 sysconf 로 묻는다). 이 기계에서는 못 돌렸다.
7940
+ * · 그 밖(Windows): null — procStart 가 있는 줄은 산 것으로 안 친다(세포도 같다).
7941
+ */
7942
+ function procStartOf(pid) {
7943
+ if (!Number.isInteger(pid) || pid <= 0) return null;
7944
+ const c = procStartSeen.get(pid);
7945
+ if (c && Date.now() - c.at < 60_000) return c.v;
7946
+ let v = null;
7947
+ try {
7948
+ if (process.platform === 'linux') {
7949
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
7950
+ const ticks = Number(stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/)[19]);
7951
+ const btime = Number((/^btime (\d+)$/m.exec(fs.readFileSync('/proc/stat', 'utf8')) || [])[1]);
7952
+ if (Number.isFinite(ticks) && Number.isFinite(btime) && btime > 0) v = btime + Math.floor(ticks / 100);
7953
+ }
7954
+ } catch { /* 아래 ps 로 */ }
7955
+ if (v === null && (process.platform === 'darwin' || process.platform === 'linux')) {
7956
+ try {
7957
+ const r = spawnSync(fs.existsSync('/bin/ps') ? '/bin/ps' : 'ps', ['-o', 'lstart=', '-p', String(pid)],
7958
+ { encoding: 'utf8', timeout: 1000, env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' } });
7959
+ if (!r.error && r.status === 0) v = lstartEpoch(r.stdout.trim());
7960
+ } catch { /* 못 잰다 */ }
7961
+ }
7962
+ // ⚠ 못 잰 답(null)은 기억하지 않는다(2026-10-07 검증 SEC-5). 부하에 ps 가 1초를 넘기면 null 이 60초 동안 남아, 그동안 이 프로세스의 줄이
7963
+ // 「산 줄 없음」이 되고 서버 이름이 비워졌다(남이 그 이름을 가져갈 틈). 잰 값은 pid 가 살아 있는 동안 바뀌지 않으니 기억해도 된다.
7964
+ if (v !== null) procStartSeen.set(pid, { at: Date.now(), v });
7965
+ return v;
7966
+ }
7967
+ /**
7968
+ * pidDomain 짓기(세포 `pid_domain_of` 와 한 벌 — 공동 표 cell/tests/fixtures/registry/cases.json 의 pid_domain). 하네스 2.1.291 `oas` 의 꼴:
7969
+ * macOS `darwin` · Linux·WSL `linux:<machine-id 다듬음>:<readlink /proc/self/ns/pid>`(못 읽으면 빈 글) · Windows `win32:<호스트 이름 소문자>`.
7970
+ * `platform` 은 Node `process.platform` 낱말.
7971
+ */
7972
+ function pidDomainOf(platform, machineId, pidNs, host) {
7973
+ if (platform === 'linux') return `linux:${typeof machineId === 'string' ? machineId.trim() : ''}:${typeof pidNs === 'string' ? pidNs : ''}`;
7974
+ if (platform === 'win32') return `win32:${String(host ?? '').toLowerCase()}`;
7975
+ return String(platform);
7976
+ }
7977
+ let pidDomainMemo = null;
7978
+ /**
7979
+ * 이 판의 pidDomain — **하네스가 등록부에 적는 값과 같은 꼴**(한 번 재고 기억한다 · 세포 `pid_domain`).
7980
+ * ⚠ 전에는 `process.platform`(맨 'linux')과 견줘, Linux·WSL 에서는 등록부의 모든 줄이 「떠 있지 않음」이었다 — MCP 는 그 판에서 등록부 이름을
7981
+ * 영영 못 댔고, 이름이 서는 길은 「뜬 뒤 ListAgents」 하나뿐이었다(검증 F1 — 이 맥의 줄은 다 'darwin' 이라 못 봤다). Windows 의 호스트 이름은
7982
+ * 하네스와 같은 `os.hostname()` 이다(세포는 env COMPUTERNAME 으로 어림 — 그 판은 어차피 못 잰다).
7983
+ */
7984
+ function harnessPidDomain() {
7985
+ if (pidDomainMemo === null) {
7986
+ const p = process.platform;
7987
+ let mid = null, ns = null, host = null;
7988
+ if (p === 'linux') {
7989
+ try { mid = fs.readFileSync('/etc/machine-id', 'utf8'); } catch { mid = null; }
7990
+ try { ns = fs.readlinkSync('/proc/self/ns/pid'); } catch { ns = null; }
7991
+ }
7992
+ if (p === 'win32') { try { host = os.hostname(); } catch { host = null; } }
7993
+ pidDomainMemo = pidDomainOf(p, mid, ns, host);
7994
+ }
7995
+ return pidDomainMemo;
7996
+ }
7997
+ /**
7998
+ * 이 기계의 프로세스 표 — 시험은 픽스처의 probe 를 대신 준다. pidDomain 은 하네스와 같은 꼴(`harnessPidDomain`).
7999
+ * ⚠ Windows 에서는 **못 잰다**(measurable 거짓) — 세포의 pid_alive 가 unix 밖에서는 없어서(world.rs) 같은 줄을 두 자가 다르게 부르지 않게
8000
+ * 맞춘다(그 판에는 procStart 를 잴 ps 도 없다). 「끝났다」가 아니라 「못 잰다」다(F5). 이름이 없으면 uuid 로 닿는다.
8001
+ */
8002
+ function harnessProbe() {
8003
+ return { alive: pidAlive, started: procStartOf, domain: harnessPidDomain(), measurable: process.platform !== 'win32' };
8004
+ }
8005
+ /** epoch 초 → `2026-10-07T03:12:45Z`(세포 `iso_utc` 와 같은 꼴 — 밀리초 없음). */
8006
+ function isoUtc(secs) {
8007
+ return new Date(secs * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
8008
+ }
8009
+ /**
8010
+ * 계약 1 의 JSON 한 줄과 같은 꼴(세포 `whereabouts_of`) — `{ session, name, live, pid, source, stale_name, checked_at, matched_by, live_rows,
8011
+ * measured }`(칸 차례도 같다 — 뒤 셋은 2026-10-07 덧붙인 칸이고 기본값은 「열쇠 모름 · 산 줄이면 1줄 · 재서 나온 답」, `harnessWithRows` 가 채운다).
8012
+ * 산 줄이 있으면 source 'registry' · name 은 등록부 이름(꼴 밖이면 null — 그래도 떠 있다) · stale_name 은 전사의 마지막 자기소개가
8013
+ * 지금 이름과 **다를 때만**(다시 뜨며 바뀐 옛 이름).
8014
+ * 없으면 name·pid 는 null 이고, 전사의 마지막 자기소개가 있으면 source 'transcript' · stale_name 에 그 이름(지금 이름이 아니다).
8015
+ * `introName` 은 이름 꼴(`harnessNameClean`)을 다시 지난다 — 시험이 주는 글도 세포의 이름 꼴로만 받는다.
8016
+ */
8017
+ function harnessWhereaboutsOf(session, row, introName, now) {
8018
+ const checked_at = isoUtc(now);
8019
+ const stale = harnessNameClean(introName);
8020
+ if (row) {
8021
+ return { session, name: row.name, live: true, pid: row.pid, source: 'registry',
8022
+ stale_name: stale !== null && stale !== row.name ? stale : null, checked_at, matched_by: null, live_rows: 1, measured: true };
8023
+ }
8024
+ return { session, name: null, live: false, pid: null, source: stale !== null ? 'transcript' : 'none', stale_name: stale, checked_at,
8025
+ matched_by: null, live_rows: 0, measured: true };
8026
+ }
8027
+ /**
8028
+ * 답에 덧붙인 칸 셋을 채운다(세포 `with_rows`) — 찾은 열쇠(matched_by) · 그 세션의 산 줄 수(live_rows — `same` 은 같은 세션 uuid 로 잰 판정) ·
8029
+ * 재서 나온 답인가(measured — `blind` 는 못 잰 까닭, 떠 있으면 무시).
8030
+ */
8031
+ function harnessWithRows(w, same, blind, by) {
8032
+ w.matched_by = by ?? null;
8033
+ w.live_rows = w.live ? Math.max(1, same.lives.length) : 0;
8034
+ w.measured = w.live ? true : !blind;
8035
+ // 창 여럿이 한 전사에 함께 쓰면 마지막 자기소개가 다른 창의 **지금** 이름일 수 있다 — 옛 이름이 아니다(세포 `with_rows` 와 같다).
8036
+ if (w.live && w.stale_name !== null && same.lives.some((r) => r.name === w.stale_name)) w.stale_name = null;
8037
+ return w;
8038
+ }
8039
+ /**
8040
+ * `gcell agents --session <uuid>` 의 사본(세포 `session_whereabouts`) — 그 세션의 산 줄 중 고른 것 · 옛 이름은 그 세션 본 전사에서.
8041
+ * MCP 는 /clear 앞 대화의 uuid 가 다른 창에 떠 있는지 볼 때 이 판정(harnessLookup)을 쓴다. 이름 관문이 세포 `--session` 과 같은 픽스처에
8042
+ * 대 본다 — 몸은 `--self` 와 같은 블록이다. `intro` = 세션 uuid → 마지막 자기소개 이름(없으면 null).
8043
+ */
8044
+ function harnessWhereabouts(sid, rows, probe, intro, now) {
8045
+ const s = String(sid ?? '').toLowerCase();
8046
+ const lk = harnessLookup(rows, probe, (r) => r.session === s);
8047
+ return harnessWithRows(harnessWhereaboutsOf(s, lk.row, intro(s), now), lk, lk.blind ? lk.blind.why : null, null);
8048
+ }
8049
+ /**
8050
+ * `gcell agents --self` 의 사본(세포 `self_whereabouts`) — 프로세스 열쇠로 내 줄을 찾고, 옛 이름은 **그 줄의 세션** 본 전사에서
8051
+ * (/clear 뒤에는 env 의 세션 id 가 아니라 등록부 줄의 세션이 나다). 열쇠가 못 잰 줄만 찾았으면 이름 칸은 비고 measured 거짓 — 세션은
8052
+ * 그 줄의 세션(이 프로세스의 열쇠로 찾은 줄이다). 아무것도 못 찾았으면 env 의 세션 id 로 옛 이름만 — 이름 칸은 빈다.
8053
+ */
8054
+ function harnessSelfWhereabouts(rows, keys, probe, intro, now) {
8055
+ const f = harnessSelfLookup(rows, keys, probe);
8056
+ if (f?.lk.row) {
8057
+ const r = f.lk.row;
8058
+ const same = harnessLookup(rows, probe, (x) => x.session === r.session);
8059
+ return harnessWithRows(harnessWhereaboutsOf(r.session, r, intro(r.session), now), same, null, f.by);
8060
+ }
8061
+ if (f?.lk.blind) {
8062
+ const s = f.lk.blind.row.session;
8063
+ return harnessWithRows(harnessWhereaboutsOf(s, null, intro(s), now), f.lk, f.lk.blind.why, f.by);
8064
+ }
8065
+ return harnessWhereaboutsOf(keys.session, null, keys.session ? intro(keys.session) : null, now);
8066
+ }
8067
+ /**
8068
+ * 이 프로세스의 지금 주소 — JS 사본(세포가 없거나 못 알아들을 때 `selfAddress` 가 쓴다). 세포 `gcell agents --self --json` 과 **같은 답**이다:
8069
+ * 이름 관문(cell/tests/fixtures/agent-names/ · smoke 3-4)이 두 자에 같은 경우를 먹여 맞댄다.
8070
+ * ⚠ 전에는 산 줄이 있으면 전사를 안 읽어 stale_name 이 늘 null 이었다(「참고 칸이라 253MB 를 거슬러 읽는 값을 안 낸다」). 같은 입력에 세포와
8071
+ * 다른 답이라 관문이 그 자리에서 빨갰다(픽스처 02-restarted). 이제 읽는다 — 값: 자기소개가 끝 가까이 있으면 조각 하나, 없으면 한 번 끝까지
8072
+ * (실측 세포 101MB 0.10초), 그 뒤로는 `introOfFile` 의 한 칸 캐시가 늘어난 부분만 읽는다(등록은 60초에 한 번 · 세포가 있으면 이 길을 안 탄다).
8073
+ */
8074
+ function selfAddressJs(env = process.env, probe = harnessProbe(), intro = (sid) => claudeIntroName(sid, env), now = Math.floor(Date.now() / 1000)) {
8075
+ return harnessSelfWhereabouts(harnessRowsIn(harnessSessionsDir(env)), harnessSelfKeys(env), probe, intro, now);
8076
+ }
8077
+
8078
+ /*
8079
+ * ── 세포에게 묻는다 — `gcell agents --self --json` (계약 1·2) ───────────────────────────────────────────────────
8080
+ * 세포 경로는 기존 규칙(`cellCheck` — GENETICS_CELL_BIN · 꾸러미 지문 · 설치본) 그대로다. 1초 안에 계약 꼴의 JSON 한 줄이 아니면 JS 사본으로.
8081
+ * ⚠ **기다리되 막지 않는다** — execFile 이라 그 1초 동안에도 다른 요청이 돈다(spawnSync 였으면 도구 응답이 통째로 선다).
8082
+ * ⚠ 옛 세포(--self 를 모르는 판 — 설치본 f0b68dc)는 모르는 깃발을 무시하고 `agents` 를 통째로 돈다. 임시 폴더에서도 전사를 다 걸어
8083
+ * 15.2초가 걸렸다(2026-10-07 실측 — 저장소를 못 찾으면 전사 폴더 전부를 걷는다, world.rs transcript_dirs_for). 그래서 `--since 0` 을
8084
+ * 함께 준다: 옛 세포는 0분 창으로 곧바로 「최근 0분 안에 … 없다」 한 줄을 내고(0.18초 실측), 그 글은 계약 꼴이 아니니 「못 알아듣는다」로
8085
+ * 적힌다. 새 세포는 `--self` 를 `--since` 보다 먼저 가르고 돌아가 그 깃발을 안 본다(cmd_agents). cwd 는 임시 폴더 — `--self` 는 저장소가
8086
+ * 아니라 프로세스 열쇠로 찾는다.
8087
+ * ⚠ 못 알아들은 세포는 그 바이너리(경로·크기·mtime)가 바뀌기 전엔 다시 안 묻는다 — 판올림(ensureCellOnBoot)이 갈아 끼우면 다시 묻는다.
8088
+ * 시간 초과는 「느리다」일 수 있어 5분만 쉰다.
8089
+ */
8090
+ const CELL_SELF_TIMEOUT_MS = 1000;
8091
+ let cellSelfDeaf = ''; // --self 를 못 알아들은 세포의 지문
8092
+ let cellSelfRestUntil = 0; // 시간 초과 뒤 쉬는 끝
8093
+ function cellPrint(bin) {
8094
+ try { const st = fs.statSync(bin); return `${bin}\0${st.size}\0${st.mtimeMs}`; } catch { return ''; }
8095
+ }
8096
+ /**
8097
+ * 세포가 낸 글 → 계약 1 의 꼴, 아니면 null. **모양을 제한한다** — 지문 검사가 뚫려도 문맥에 이상한 글자가 안 들어가게(cellClean 과 같은 방어).
8098
+ * 숫자 칸(pid)은 등록부와 같이 숫자 문자열도 받는다.
8099
+ */
8100
+ function cellSelfParse(stdout) {
8101
+ const t = String(stdout ?? '').trim();
8102
+ if (!t.startsWith('{') || t.includes('\n') || t.length > 4096) return null;
8103
+ let d;
8104
+ try { d = JSON.parse(t); } catch { return null; }
8105
+ if (!d || typeof d !== 'object' || Array.isArray(d)) return null;
8106
+ if (!['registry', 'transcript', 'none'].includes(d.source) || typeof d.live !== 'boolean') return null;
8107
+ if (d.name !== null && d.name !== undefined && !harnessNameOk(d.name)) return null;
8108
+ const pid = d.pid === null || d.pid === undefined ? null : harnessInt(d.pid);
8109
+ // 덧붙인 칸(2026-10-07) — 모르는 값이면 「모른다」 쪽으로: 열쇠 null(이름을 제 것으로 안 댄다) · 산 줄 수는 산가에서 · measured 는 칸이 있을 때만 거짓.
8110
+ const rows = harnessInt(d.live_rows);
8111
+ return {
8112
+ session: typeof d.session === 'string' && AUTO_UUID_RE.test(d.session) ? d.session.toLowerCase() : null,
8113
+ name: d.name ?? null,
8114
+ live: d.live,
8115
+ pid: pid !== null && pid > 0 ? pid : null,
8116
+ source: d.source,
8117
+ stale_name: harnessNameOk(d.stale_name) ? d.stale_name : null,
8118
+ checked_at: typeof d.checked_at === 'string' ? cellClean(d.checked_at).slice(0, 40) : '',
8119
+ matched_by: ['socket', 'pid', 'session'].includes(d.matched_by) ? d.matched_by : null,
8120
+ live_rows: rows !== null && rows >= 0 && rows < 1000 ? rows : (d.live ? 1 : 0),
8121
+ measured: d.measured !== false,
8122
+ };
8123
+ }
8124
+ /** 세포의 답 — `{ ok: true, v }` 또는 `{ ok: false, why }`(why: none·deaf·rest·timeout·bad·spawn). */
8125
+ function cellSelf() {
8126
+ const { bin } = cellCheck();
8127
+ if (!bin) return Promise.resolve({ ok: false, why: 'none' });
8128
+ const print = cellPrint(bin);
8129
+ if (print && print === cellSelfDeaf) return Promise.resolve({ ok: false, why: 'deaf' });
8130
+ if (Date.now() < cellSelfRestUntil) return Promise.resolve({ ok: false, why: 'rest' });
8131
+ return new Promise((resolve) => {
8132
+ let done = false;
8133
+ let timer = null;
8134
+ const finish = (v) => { if (done) return; done = true; if (timer) clearTimeout(timer); resolve(v); };
8135
+ let child;
8136
+ try {
8137
+ child = execFile(bin, ['agents', '--self', '--json', '--since', '0'],
8138
+ { encoding: 'utf8', maxBuffer: 64 * 1024, cwd: os.tmpdir(), windowsHide: true },
8139
+ (err, stdout) => {
8140
+ /*
8141
+ * ⚠ 실행 자체가 안 된 것(EAGAIN·ENOMEM·EACCES·ENOENT … — err.code 가 글)은 「못 알아들었다」가 아니다(2026-10-07 검증 SEC-5).
8142
+ * 전에는 이것도 deaf 로 적어, fork 한 번 실패에 그 세포를 판올림 전까지 다시 안 물었다. 잠깐(30초) 쉬고 다시 묻는다.
8143
+ */
8144
+ if (err && typeof err.code === 'string' && err.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
8145
+ if (!done) cellSelfRestUntil = Date.now() + 30_000;
8146
+ finish({ ok: false, why: 'spawn' });
8147
+ return;
8148
+ }
8149
+ // 끝난 코드가 0 이 아니어도 계약 꼴의 한 줄이면 받는다 — 판정은 글이 한다(못 알아들은 판은 글이 계약 꼴이 아니다).
8150
+ const v = err?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ? null : cellSelfParse(stdout);
8151
+ if (!v) { if (!done) cellSelfDeaf = print; finish({ ok: false, why: 'bad' }); return; }
8152
+ finish({ ok: true, v });
8153
+ });
8154
+ } catch { cellSelfRestUntil = Date.now() + 30_000; finish({ ok: false, why: 'spawn' }); return; }
7232
8155
  /*
7233
- * Cursor 활성 대화 — 전사도 ListAgents 도 없다. 이름은 별칭일 뿐 열쇠는 uuid 이므로
7234
- * (mail 레일 계약), 출처가 읽히는 결정적 별칭을 낸다: cursor-<uuid 앞 2자>.
8156
+ * 1초는 **자리를 비우는 시각**이다 — execFile 의 timeout 은 죽인 뒤에도 그 자식이 물려준 stdout 이 닫혀야 답을 준다(손자 프로세스가
8157
+ * 파이프를 쥐면 그만큼 늦는다). 그래서 따로 잰다: 넘으면 죽이고 곧바로 JS 사본으로 간다.
7235
8158
  */
7236
- if (uuid === cursorActiveComposer() || process.env.CURSOR_CONVERSATION_ID === uuid) {
7237
- return cursorAliasOf(uuid);
8159
+ timer = setTimeout(() => {
8160
+ try { child.kill('SIGKILL'); } catch { /* 이미 끝났다 */ }
8161
+ cellSelfRestUntil = Date.now() + 5 * 60_000;
8162
+ finish({ ok: false, why: 'timeout' });
8163
+ }, CELL_SELF_TIMEOUT_MS);
8164
+ timer.unref?.();
8165
+ });
8166
+ }
8167
+ /** 이 프로세스의 지금 주소(계약 1 의 꼴 + `via`) — 세포가 먼저, 못 하면 JS 사본. **부를 때마다 새로 푼다**(이름은 보내기 직전에). */
8168
+ async function selfAddress() {
8169
+ const c = await cellSelf();
8170
+ if (c.ok) return { ...c.v, via: 'cell' };
8171
+ return { ...selfAddressJs(), via: 'js', cell: c.why };
8172
+ }
8173
+
8174
+ /**
8175
+ * 전사의 `[from, size)` 에서 **마지막** 자기소개를 끝에서부터 거슬러 찾는다. `{ name, end, at }` — `end` 는 마지막 개행 바로 뒤
8176
+ * (쓰는 중인 반쪽 줄은 다음 차례에 그 자리부터 다시 읽는다), `at` 은 그 레코드의 timestamp(ms · 없거나 못 읽으면 0).
8177
+ * `at` 은 「그 자기소개를 지금 프로세스가 냈나」를 가르는 데 쓴다(`myAgentName` ②) — 0 이면 가를 수 없으니 지금 것으로 안 친다.
8178
+ * ⚠ 전에는 읽을 양이 64MB 이상이면 **통째로 건너뛰었다**(전사 최대 7MB 가정). 그런데 그림이 base64 로 실려 전사가 253MB
8179
+ * 까지 간다(2026-10-07 실측 — 이 MCP 를 띄운 세션). 첫 읽기가 늘 건너뛰어져 이름이 영영 「없음」이 됐고, mail 이 「이 세션에는
8180
+ * 말 걸 수 있는 이름이 없다」로 거절했다 — ListAgents 를 이미 부른 뒤였는데도. 마지막 것만 필요하니 끝에서 거스르면 된다.
8181
+ * ⚠ 줄 고르기는 세포 `last_intro_in` 과 **글자 하나까지** 같다(이름 관문 2026-10-07 — 픽스처 11-giant-line): 1MiB 를 넘는 줄은 자기소개로
8182
+ * 안 치고 이어 붙이지도 않는다. 전에는 그 줄을 읽어(세포는 건너뛰어) 같은 전사에 두 자가 다른 옛 이름을 냈고, 큰 줄을 조각마다 다시
8183
+ * 이어 붙여 복사가 제곱으로 늘었다. 조각은 쌓아 두었다가 줄이 온전해질 때 한 번 잇는다. 맨 끝의 반쪽 줄은 안 쌓는다(어차피 안 읽는다).
8184
+ */
8185
+ function lastIntroInRange(fd, from, size, chunk = 8 * 1024 * 1024) {
8186
+ chunk = Math.max(1, chunk);
8187
+ let pos = size;
8188
+ let end = -1;
8189
+ let carry = []; // 뒤에서 이어지는 줄의 조각들(파일에서 뒤쪽 조각이 앞에) — 그 줄의 머리는 아직 안 읽은 앞쪽에 있다
8190
+ let carryLen = 0;
8191
+ let skipping = true; // 지금 거슬러 가는 줄을 버리는 중인가 — 처음엔 맨 끝의 쓰는 중인 반쪽 줄, 그 뒤엔 상한을 넘은 큰 줄
8192
+ const found = (h) => ({ name: h.name, end, at: h.at });
8193
+ while (pos > from) {
8194
+ const start = Math.max(from, pos - chunk);
8195
+ const buf = Buffer.allocUnsafe(pos - start);
8196
+ fs.readSync(fd, buf, 0, buf.length, start);
8197
+ pos = start;
8198
+ const lastNl = buf.lastIndexOf(0x0a);
8199
+ if (lastNl < 0) { // 조각 전체가 한 줄의 가운데
8200
+ if (!skipping) {
8201
+ carry.push(buf);
8202
+ carryLen += buf.length;
8203
+ if (carryLen > INTRO_LINE_MAX) { carry = []; carryLen = 0; skipping = true; }
8204
+ }
8205
+ continue;
7238
8206
  }
7239
- return null;
8207
+ if (end < 0) end = start + lastNl + 1; // 마지막 개행 바로 뒤 — 다음 이어 읽기의 자리
8208
+ // ① 마지막 개행 뒤 + 뒤에서 이어지던 조각들 = 이제 머리까지 다 읽은 한 줄
8209
+ if (!skipping && buf.length - (lastNl + 1) + carryLen <= INTRO_LINE_MAX) {
8210
+ const h = introOfLine(Buffer.concat([buf.subarray(lastNl + 1), ...carry.reverse()]));
8211
+ if (h) return found(h);
8212
+ }
8213
+ carry = [];
8214
+ carryLen = 0;
8215
+ skipping = false;
8216
+ // ② 이 조각 안의 온전한 줄들 — 끝에서부터
8217
+ const firstNl = buf.indexOf(0x0a);
8218
+ const h2 = introInLines(buf.subarray(firstNl + 1, lastNl + 1));
8219
+ if (h2) return found(h2);
8220
+ // ③ 맨 앞 줄(첫 개행까지) — 출발점(파일 맨 앞 · 이어 읽기의 자리)이면 온전하고, 아니면 머리가 더 앞 조각에 있다
8221
+ const head = buf.subarray(0, firstNl + 1);
8222
+ if (start === from) {
8223
+ const h3 = introInLines(head);
8224
+ if (h3) return found(h3);
8225
+ } else if (head.length > INTRO_LINE_MAX) {
8226
+ skipping = true;
8227
+ } else {
8228
+ carry.push(head);
8229
+ carryLen = head.length;
8230
+ }
8231
+ }
8232
+ // 출발점의 첫 줄이 조각보다 길었다 — 그 머리가 출발점이다
8233
+ if (!skipping && carry.length) {
8234
+ const h = introOfLine(Buffer.concat(carry.reverse()));
8235
+ if (h) return found(h);
8236
+ }
8237
+ return { name: null, end: end < 0 ? from : end, at: 0 };
8238
+ }
8239
+ /** 자기소개 레코드 한 줄의 상한(세포 `INTRO_LINE_MAX`) — 실제 자기소개 줄은 2.7~4.5KB(2026-10-07 실측 35건). 더 긴 줄(그림 base64)은 자기소개가 아니다. */
8240
+ const INTRO_LINE_MAX = 1 << 20;
8241
+ /** 한 줄(바이트)이 하네스가 낸 자기소개 레코드면 `{ name, at }`(세포 `intro_of_line` — 상한 · 머리 글자 · 레코드 모양 차례 그대로). */
8242
+ function introOfLine(line) {
8243
+ if (!line.length || line.length > INTRO_LINE_MAX || !line.includes(INTRO_HEAD)) return null;
8244
+ const s = line.toString('utf8'); // 깨진 바이트는 U+FFFD — 세포의 from_utf8_lossy 와 같다
8245
+ const name = introNameOf(s);
8246
+ if (!name) return null;
8247
+ let at = 0;
8248
+ try { at = Date.parse(JSON.parse(s).timestamp) || 0; } catch { /* 시각이 없으면 0 */ }
8249
+ return { name, at };
8250
+ }
8251
+ /** 개행으로 끝나는 줄 묶음에서 **끝에서부터** 자기소개를 찾는다(세포 `intro_in_lines`). 줄은 바이트로 자른다 — 상한도 바이트다. */
8252
+ function introInLines(body) {
8253
+ if (!body.includes(INTRO_HEAD)) return null; // 값싼 검사로 먼저 거른다
8254
+ let e = body.length;
8255
+ if (e > 0 && body[e - 1] === 0x0a) e--;
8256
+ for (;;) {
8257
+ const s = e > 0 ? body.lastIndexOf(0x0a, e - 1) + 1 : 0;
8258
+ const h = introOfLine(body.subarray(s, e));
8259
+ if (h) return h;
8260
+ if (s === 0) return null;
8261
+ e = s - 1;
7240
8262
  }
8263
+ }
8264
+
8265
+ let nameCache = null; // { file, size, name, at }
8266
+ /** 세션 uuid 의 전사(findTranscript — Codex·Cursor 포함)의 마지막 자기소개. 전사가 없으면 null. `myAgentName` 이 쓴다(Cursor 판정에 file 이 든다). */
8267
+ function introOf(uuid) {
8268
+ const f = transcriptOf(uuid);
8269
+ return f ? introOfFile(f) : null;
8270
+ }
8271
+ /**
8272
+ * 세션 uuid → 그 세션 **본 전사**(세포의 규칙 — 설정 폴더 먼저 · `claudeTranscriptIn`)의 마지막 자기소개 이름. 없으면 null.
8273
+ * 세포 `claude_transcript_of` + `agent_name_of` 의 사본 — `selfAddressJs` 의 옛 이름 칸이 이것을 쓴다(이름 관문).
8274
+ */
8275
+ function claudeIntroName(sid, env = process.env) {
8276
+ const f = claudeTranscriptIn(claudeProjectBases(env), sid);
8277
+ return f ? (introOfFile(f)?.name ?? null) : null;
8278
+ }
8279
+ /** 전사 파일 하나의 마지막 자기소개 `{ name, at, file }`(name 없으면 null · at = 그 레코드의 timestamp ms). 못 읽으면 null. 늘어난 부분만 다시 읽는다(한 칸 캐시). */
8280
+ function introOfFile(f) {
7241
8281
  let st;
7242
8282
  try { st = fs.statSync(f); } catch { return null; }
7243
- if (nameCache?.file === f && nameCache.size === st.size) return nameCache.name;
8283
+ if (nameCache?.file === f && nameCache.size === st.size) return { name: nameCache.name, at: nameCache.at, file: f };
7244
8284
  const from = (nameCache?.file === f && st.size > nameCache.size) ? nameCache.size : 0;
7245
- let found = (from > 0 ? nameCache.name : null);
8285
+ let found = from > 0 ? nameCache.name : null;
8286
+ let at = from > 0 ? nameCache.at : 0;
8287
+ let end = from;
7246
8288
  try {
7247
8289
  const fd = fs.openSync(f, 'r');
7248
8290
  try {
7249
- const len = st.size - from;
7250
- if (len > 0 && len < 64 * 1024 * 1024) {
7251
- const buf = Buffer.allocUnsafe(len);
7252
- fs.readSync(fd, buf, 0, len, from);
7253
- const txt = buf.toString('utf8');
7254
- // 줄 단위 — 마지막 것이 이긴다(옛 이름은 죽은 주소다).
7255
- // ⚠ 쓰는 중인 마지막 줄(개행 없음)은 이번에 안 읽고 다음에 그 자리부터 다시 읽는다 —
7256
- // 캐시 크기를 마지막 개행까지만 전진시킨다. 안 그러면 잘린 줄이 영영 사라진다.
7257
- const cut = txt.lastIndexOf('\n');
7258
- const whole = cut >= 0 ? txt.slice(0, cut + 1) : '';
7259
- for (const line of whole.split('\n')) {
7260
- const n = introNameOf(line);
7261
- if (n) found = n;
7262
- }
7263
- if (!found && (isCursorTranscript(f) || uuid === cursorActiveComposer()
7264
- || process.env.CURSOR_CONVERSATION_ID === uuid)) {
7265
- found = cursorAliasOf(uuid);
7266
- }
7267
- nameCache = { file: f, size: from + Buffer.byteLength(whole, 'utf8'), name: found };
7268
- return found;
7269
- }
8291
+ // 마지막 것이 이긴다(옛 이름은 죽은 주소다) — 그래서 끝에서 거슬러 첫 자기소개에서 멈춘다(크기 상한 없음).
8292
+ // ⚠ 쓰는 중인 마지막 줄(개행 없음)은 이번에 안 읽고 다음에 그 자리부터 다시 읽는다(`end` 가 마지막 개행 바로 뒤).
8293
+ const r = lastIntroInRange(fd, from, st.size);
8294
+ if (r.name) { found = r.name; at = r.at || 0; }
8295
+ end = r.end;
7270
8296
  } finally { fs.closeSync(fd); }
7271
8297
  } catch { /* 못 읽으면 이름이 없는 것이지 고장이 아니다 */ }
7272
- if (!found && (isCursorTranscript(f) || uuid === cursorActiveComposer()
7273
- || process.env.CURSOR_CONVERSATION_ID === uuid)) {
7274
- found = cursorAliasOf(uuid);
8298
+ nameCache = { file: f, size: end, name: found, at };
8299
+ return { name: found, at, file: f };
8300
+ }
8301
+
8302
+ /** 이 MCP 프로세스가 뜬 때(ms) — 하네스가 띄우므로 그 claude 프로세스보다 늦다. 그 뒤에 적힌 자기소개는 지금 프로세스가 낸 것이다. */
8303
+ const BOOT_MS = Math.floor(performance.timeOrigin);
8304
+
8305
+ /**
8306
+ * 서버 메일 주소록이 받는 이름인가 — 서버 `mail_name_ok`(www/portal/lib/mail.php)와 같은 규칙: 1~60자 · 공백류·제어문자·[ ] → < > 없음 ·
8307
+ * **이름 없는 세션의 발신 표시 꼴(uuid 앞 8자 — 16진 8자)과 uuid 꼴이 아니다**(2026-10-07 검증 SEC-3: 남이 그 꼴을 이름으로 대면 이름 없는
8308
+ * 세션과 머리 줄이 같아져 사칭이 되고, 그 표시로 한 답장이 사칭한 쪽으로 풀렸다).
8309
+ */
8310
+ function mailNameOk(s) {
8311
+ return typeof s === 'string' && s !== '' && [...s].length <= 60
8312
+ && !/[\s\u0000-\u001F\u007F-\u009F\u2028\u2029[\]→<>]/u.test(s)
8313
+ && !/^[0-9a-f]{8}$/i.test(s) && !/^[0-9a-f-]{36}$/i.test(s);
8314
+ }
8315
+
8316
+ /** ①로 확인한 이 프로세스의 마지막 이름(이 MCP 프로세스 안에서만) — ④가 쓴다. */
8317
+ const mailNameSeen = { session: '', name: '' };
8318
+
8319
+ /**
8320
+ * 서버 주소록에 댈 이름과 이 프로세스의 지금 메일 주소 — `{ name, why, addr, cur }`. name '' 은 「이름 없이 댄다」(받기는 된다 · 보낼 때
8321
+ * 발신 표시는 uuid 앞 8자).
8322
+ * cur — 이 프로세스의 지금 세션 uuid. 세포(또는 JS 사본)가 **소켓 열쇠로** 찾은 산 줄의 세션이다 — 하네스가 이 MCP 에게만 준 소켓이니 그
8323
+ * 줄이 이 프로세스다. `/clear` 뒤에는 env 의 세션 id(mine — 뜰 때 값)와 다르다: 하네스는 /clear 에 등록부 줄의 sessionId 만 바꾼다
8324
+ * (검증 F2·SEC-7). 소켓으로 못 찾았으면 mine.
8325
+ * 대는 이름은 **지금 이 프로세스의 이름이라고 확인된 것**뿐이다:
8326
+ * ① 등록부의 산 줄 — 그 줄을 **이 프로세스의 열쇠**로 찾았을 때만: 소켓이거나, 그 줄의 세션이 mine 이다. CLAUDE_PID 로만 찾은 남의 세션
8327
+ * 줄은 아니다 — 하네스는 MCP 에 CLAUDE_PID 를 안 주니(Bash·훅에만 준다) MCP env 의 그 값은 조상 claude 의 Bash 에서 물려받은 것이다
8328
+ * (SEC-2: Bash 에서 띄운 `claude -p` 의 MCP 가 부모의 이름을 제 uuid 에 댈 뻔했다).
8329
+ * 그 세션 uuid 의 산 줄이 **둘 이상**이면(한 대화를 창 여럿이 띄움) 이름 없이 댄다 — 창마다 이름이 달라 두 MCP 가 같은 서버 줄에 번갈아
8330
+ * 이름을 대고(L1), 그 uuid 앞 통은 한 편지함이라 어느 창이 받을지 정해지지 않는다(SEC-1). 두 MCP 가 같은 판정을 내니 이름이 안 뒤집힌다.
8331
+ * ② 전사의 자기소개 — **이 MCP 가 뜬 뒤에** 적힌 하네스 ListAgents 결과만(`introNameOf` 가 짝 표식 toolUseResult.listing 을 본다 — SEC-6).
8332
+ * MCP 는 claude 프로세스가 띄우므로 그 뒤에 적힌 자기소개는 지금 프로세스가 낸 것이다. 그 전 것은 다시 뜨기 전 이름일 수 있고, 그
8333
+ * 이름은 지금 남의 것일 수 있다(7afd1d02 의 hnl-73 = 지금 110cd958 — 검증 2026-10-07). 등록부가 없는 옛 하네스·잴 수 없는 판
8334
+ * (Windows)에서 ListAgents 를 부르면 이름이 서는 길이다.
8335
+ * ③ Cursor 대화의 별칭 cursor-<uuid 앞 2자> — uuid 에서 나오는 값이라 낡지 않는다. 전사도 ListAgents 도 없는 자리다.
8336
+ * ④ 이번에 **못 쟀으면**(measured 거짓 — 세포 시간 초과 뒤 JS 사본의 ps 가 실패 · 시작 시각을 못 잼) 이 MCP 가 ①로 마지막에 확인한 이름을
8337
+ * 다시 댄다(SEC-5 — 일시 실패가 서버의 이름 비움 → 같은 이름을 대는 남이 가져감 → 다음 등록은 held 로 번지지 않게). 이름은 프로세스마다
8338
+ * 정해지니 같은 MCP 프로세스(= 같은 claude 프로세스) 안의 확인은 유효하다. 확인한 적이 없으면 이름 없이.
8339
+ * 그리고 서버가 받는 꼴(`mailNameOk`)이어야 한다 — 아니면 이름 없이 댄다(꼴 때문에 등록이 400 이면 보내기까지 막힌다).
8340
+ * Native Codex 는 늘 이름 없이 댄다 — 서버가 등록된 uuid 로 발신 표시를 정한다(이름을 지어내지 않는다).
8341
+ */
8342
+ async function myAgentName(mine = mySessionId()) {
8343
+ if (!AUTO_UUID_RE.test(String(mine || ''))) return { name: '', why: '이 세션의 번호를 모른다', addr: null, cur: mine };
8344
+ if (!process.env.CLAUDE_CODE_SESSION_ID && mine === codexSessionId()) {
8345
+ return { name: '', why: 'Codex 세션은 이름 없이 댄다', addr: null, cur: mine };
8346
+ }
8347
+ const addr = await selfAddress();
8348
+ const rowSession = typeof addr.session === 'string' && AUTO_UUID_RE.test(addr.session) ? addr.session.toLowerCase() : '';
8349
+ const liveRow = addr.source === 'registry' && addr.live && !!rowSession;
8350
+ const own = liveRow && (addr.matched_by === 'socket' || rowSession === mine);
8351
+ // 소켓은 하네스가 이 MCP 에게만 준 열쇠다 — 그 줄이 이 프로세스다. 떠 있는지 이번에 못 쟀어도(우리는 떠 있다) 지금 세션은 그 줄의 것이다.
8352
+ const cur = addr.matched_by === 'socket' && rowSession ? rowSession : mine;
8353
+ let name = '';
8354
+ let why = '';
8355
+ if (own) {
8356
+ const windows = Number(addr.live_rows) || 1;
8357
+ if (windows >= 2) {
8358
+ why = `이 세션 uuid 를 지금 창 ${windows}곳이 함께 띄우고 있다 — 서버 주소록은 uuid 하나에 이름 하나라 창마다 다른 이름이 번갈아 서고, `
8359
+ + '그 uuid 앞 통은 한 편지함이라 어느 창이 받을지 정해지지 않는다(한 창에 닿으려면 SendMessage 에 그 창의 이름)';
8360
+ } else if (addr.name) {
8361
+ name = addr.name;
8362
+ mailNameSeen.session = cur;
8363
+ mailNameSeen.name = name;
8364
+ } else {
8365
+ why = '하네스 등록부에 이 프로세스의 산 줄은 있는데 이름이 비었거나 꼴 밖이다(제어문자만 걷고 65자까지)';
8366
+ }
8367
+ } else {
8368
+ const intro = introOf(mine);
8369
+ if (intro?.name && intro.at >= BOOT_MS) {
8370
+ name = intro.name;
8371
+ } else if (isCursorTranscript(intro?.file) || mine === cursorActiveComposer() || process.env.CURSOR_CONVERSATION_ID === mine) {
8372
+ name = cursorAliasOf(mine);
8373
+ } else if (addr.measured === false && mailNameSeen.session === cur && mailNameSeen.name) {
8374
+ name = mailNameSeen.name; // ④ — 이번엔 못 쟀을 뿐이다(조용히)
8375
+ } else if (liveRow) {
8376
+ why = `등록부에서 찾은 산 줄(세션 ${rowSession.slice(0, 8)})은 이 MCP 의 세션이 아니고 CLAUDE_PID 로만 찾았다 — 그 값은 하네스가 MCP 에 `
8377
+ + '주지 않는 값(조상 프로세스의 것)이라 그 줄의 이름을 이 세션의 이름으로 대지 않는다';
8378
+ } else if (addr.measured === false) {
8379
+ why = `하네스 등록부에서 이 프로세스의 줄이 떠 있는지 이번에 못 쟀다(${process.platform === 'win32' ? '이 판은 pid 를 못 잰다' : '시작 시각을 못 맞췄다'})`;
8380
+ } else if (intro?.name) {
8381
+ why = `전사의 마지막 자기소개 «${cellClean(intro.name)}» 는 이 프로세스가 뜨기 전 것이라 지금 이름이라고 못 한다(다시 뜨면 이름이 바뀐다)`;
8382
+ } else {
8383
+ why = `하네스 등록부(${harnessSessionsDir()})에 이 프로세스의 산 줄이 없고 전사에도 자기소개가 없다`;
8384
+ }
8385
+ }
8386
+ if (name && !mailNameOk(name)) {
8387
+ why = `지금 이름 «${cellClean(name)}» 은 메일 주소록이 받는 꼴이 아니다(60자까지 · 공백·[ ] → < > 없음 · 16진 8자·uuid 꼴 아님)`;
8388
+ name = '';
7275
8389
  }
7276
- nameCache = { file: f, size: st.size, name: found };
7277
- return found;
8390
+ return { name, why, addr, cur };
8391
+ }
8392
+
8393
+ /**
8394
+ * 이 프로세스의 메일 주소 한 벌을 **한 번** 푼다(주소 계약 2 · 2026-10-07 검증 F2·SEC-7) — `{ pick, cur, old }`.
8395
+ * pick — `myAgentName(mine)` 의 답(이름·까닭·세포 답). cur — 지금 세션(위 cur — /clear 를 따라간다). 보내기(session)·주소 대기·받기의 주인이다.
8396
+ * old — /clear 앞의 env 세션(mine — cur 과 같으면 null). 이미 나간 답장 주소(from_session)·받는 세션 줄(to_session)이 그 uuid 라, 이름 없이
8397
+ * 주소를 유지하고 긷는다 — 단 **다른 창이 그 대화를 이어 열었으면**(등록부에 old 의 산 줄이 있다) 그 창의 MCP 가 주인이라 손을 뗀다
8398
+ * (두 프로세스가 한 편지함을 긷지 않게 — SEC-1). 하네스가 env 를 안 바꾸니 이 MCP 가 아는 것은 이 둘뿐이다.
8399
+ */
8400
+ async function mailSelf(mine = mySessionId()) {
8401
+ const pick = await myAgentName(mine);
8402
+ const cur = AUTO_UUID_RE.test(String(pick.cur || '')) ? String(pick.cur).toLowerCase() : mine;
8403
+ const old = cur !== mine && AUTO_UUID_RE.test(String(mine || '')) && !mailOldHeldElsewhere(mine) ? mine : null;
8404
+ const v = { pick, cur, old };
8405
+ mailSelfMemo = { at: Date.now(), mine, v };
8406
+ return v;
8407
+ }
8408
+ /**
8409
+ * 받기 바퀴(mailPull)가 쓰는 주소 — 30초 안에 푼 것이 있으면 그대로 쓴다. 주소 대기(mailRegister)는 소속처마다 60초에 한 번이라 받기 바퀴마다
8410
+ * 세포를 새로 부를 까닭이 없다(세포의 --self 는 그 세션 전사의 끝을 거슬러 읽는다 — 자기소개가 없는 큰 전사는 끝까지 간다). 보내기(mail 도구)는
8411
+ * 늘 새로 푼다(mailSelf). /clear 를 알아채는 것은 길어야 30초 늦다.
8412
+ */
8413
+ let mailSelfMemo = { at: 0, mine: '', v: null };
8414
+ async function mailSelfRecent(mine = mySessionId()) {
8415
+ if (mailSelfMemo.v && mailSelfMemo.mine === mine && Date.now() - mailSelfMemo.at < 30_000) return mailSelfMemo.v;
8416
+ return mailSelf(mine);
8417
+ }
8418
+ /** 그 세션 uuid 를 지금 **다른 프로세스**가 띄우고 있나 — 등록부의 산 줄(JS 사본으로 잰다 · 못 재면 「아니다」). */
8419
+ function mailOldHeldElsewhere(sid) {
8420
+ try {
8421
+ return !!harnessLiveRow(harnessRowsIn(harnessSessionsDir()), harnessProbe(), (r) => r.session === sid);
8422
+ } catch { return false; }
7278
8423
  }
7279
8424
 
7280
8425
  /** 이 세션이 메일을 다룰 조건을 갖췄나. 못 갖췄으면 `null` — 조용히 안 한다. */
@@ -7309,20 +8454,132 @@ function mailReady() {
7309
8454
  /**
7310
8455
  * 주소를 **스스로 댄다.** 관측 추정이 아니다(052 머리말의 세 실측).
7311
8456
  *
7312
- * 이름이 없어도 등록한다 — 받을 수는 있어야 하기 때문이다. 이름이 생기면 다시 댄다.
7313
- */
7314
- async function mailRegister(tenant, mine, force = false) {
8457
+ * 이름이 없어도 등록한다 — 받을 수는 있어야 하고, 이제 보낼 수도 있다(발신 표시는 서버가 uuid 앞 8자로 세운다).
8458
+ * 이름은 **대기 직전에** 푼다(`mailSelf` → `myAgentName` — 세포 먼저, 못 하면 JS 사본). 돌려주는 것은 서버 응답에 `sentName`(실제로 댄 이름,
8459
+ * 없으면 '')·`nameWhy`(이름 없이 댄 까닭)·`cur`(댄 세션)·`old`(/clear 앞 세션 — 이름 없이 함께 댄 것)를 얹은 것이다 — 보낸 결과의 안내가
8460
+ * 그 사실대로 말한다(`mailAddrNote`).
8461
+ * ★ 대는 세션은 **지금 세션(cur)** 이다 — /clear 뒤에는 env 의 세션 id(mine)가 아니다(F2: 세포·세션 시작 안내가 남에게 주는 uuid 가 cur 인데
8462
+ * 주소록에는 mine 만 있어 그 uuid 앞 통이 404 였다). /clear 앞 세션(old)은 **먼저** 이름 없이 댄다 — 그 줄에 이 프로세스의 이름이 남아
8463
+ * 있으면 cur 의 같은 이름이 「살아 있는 다른 세션이 쥐었다」(held)로 막힌다(같은 사람의 두 줄도 서버는 다른 세션으로 본다).
8464
+ * ⚠ 이름 때문에 거절되면(400) **이름 없이 한 번 더** 댄다 — 등록이 막히면 보내기도 막힌다(서버 이름 규칙이 바뀌어도 벙어리가 안 되게).
8465
+ * `ident` 는 부르는 쪽이 이미 푼 `mailSelf()` 답(한 바퀴에 세포를 한 번만 부르려고) — 없으면 여기서 푼다.
8466
+ */
8467
+ async function mailRegister(tenant, mine, force = false, ident = null) {
7315
8468
  if (!force && Date.now() - (mailAddrAt.get(tenant) || 0) < MAIL_POLL_MS) return null;
7316
8469
  mailAddrAt.set(tenant, Date.now());
7317
- const name = myAgentName();
7318
- return await api('POST', '/api/mail/register', {
8470
+ const id = ident || await mailSelf(mine);
8471
+ const body = (session, name) => ({
7319
8472
  tenant,
7320
- session: mine,
7321
- name: name || '',
8473
+ session,
8474
+ name,
7322
8475
  // 신원이 어디서 왔는지 그대로 말한다 — Cursor 대화에 claude-code 이름표를 달지 않는다.
7323
8476
  client: mine === codexSessionId() ? 'codex' : (mine === cursorActiveComposer() ? 'cursor' : 'claude-code'),
7324
8477
  cwd: relHome(process.cwd()),
7325
8478
  });
8479
+ if (id.old) {
8480
+ try { await api('POST', '/api/mail/register', body(id.old, '')); } catch { /* 옛 대화의 주소가 막혀도 지금 세션은 댄다 */ }
8481
+ }
8482
+ let sent = id.pick.name;
8483
+ let why = id.pick.why;
8484
+ let r;
8485
+ try {
8486
+ r = await api('POST', '/api/mail/register', body(id.cur, sent));
8487
+ } catch (e) {
8488
+ if (!(sent && e?.status === 400)) throw e;
8489
+ why = `지금 이름 «${cellClean(sent)}» 을 서버가 받지 않았다(${cellClean(e.message).slice(0, 120)})`;
8490
+ sent = '';
8491
+ r = await api('POST', '/api/mail/register', body(id.cur, ''));
8492
+ }
8493
+ return { ...(r && typeof r === 'object' ? r : {}), sentName: sent, nameWhy: why, cur: id.cur, old: id.old };
8494
+ }
8495
+
8496
+ /**
8497
+ * 뜰 때 **아는 소속처에 주소를 한 번 댄다**(계약 2 · 2026-10-07). 전에는 genetics 도구를 불러야만 댔다(tools/call 의 mailPull →
8498
+ * mailRegister) — 다시 뜬 세션은 첫 호출 전까지 서버 주소록에 옛 이름으로 남아, 새 이름 앞 통은 404 였고 옛 이름 앞 통은 이 세션으로
8499
+ * 왔다(검증 2026-10-07 remaining_failures — 9592 · 4163). 아는 소속처 = `mailReady()` 의 것(뜰 때는 색인의 소속처뿐이다).
8500
+ * Native Codex 는 뺀다 — 자기 일정 표의 'mail' 행(codexMailPullOne)이 뜰 때 이미 이름 없이 댄다. 주소(이름)는 한 번만 푼다.
8501
+ */
8502
+ async function mailRegisterBoot() {
8503
+ const r0 = mailReady();
8504
+ if (!r0) return 0;
8505
+ if (!process.env.CLAUDE_CODE_SESSION_ID && r0.mine === codexSessionId()) return 0;
8506
+ const ident = await mailSelf(r0.mine);
8507
+ let n = 0;
8508
+ for (const [tenant] of r0.tenants) {
8509
+ try { await mailRegister(tenant, r0.mine, true, ident); n += 1; }
8510
+ catch { /* 한 소속처가 막혀도 나머지는 댄다 — 도구를 부르면 다시 댄다 */ }
8511
+ }
8512
+ return n;
8513
+ }
8514
+
8515
+ // 서버 lib/mail.php 의 MAIL_NAME_STATES 와 같은 낱말 — 늘리면 세 구현을 같이 고친다(그쪽 머리말).
8516
+ const MAIL_NAME_STATES = ['set', 'kept', 'held', 'cleared'];
8517
+ /**
8518
+ * 주소 등록이 **실제로 어떻게 됐나**를 한 줄로(할 말이 없으면 '') — 서버 응답의 사실대로.
8519
+ * 새 서버: `name_state`('set'|'kept'|'held'|'cleared') · `name`(그 줄에 실제로 남은 이름 — 서버가 표에서 다시 읽은 값) · `prev_name`.
8520
+ * 옛 서버: `held_by` 만 — 그때 서버 줄에는 예전 이름이 남을 수 있어(api.php 의 `IF(VALUES(name) IS NULL, name, …)`) 단정하지 않는다.
8521
+ * ⚠ 전에는 held 면 「이 통은 «uuid 앞 8자» 이름으로 나간다」고 단정했는데, 옛 서버는 그 줄의 옛 이름을 지켜 실제 발신 표시는 옛 이름이었다
8522
+ * (검증 2026-10-07 — 8ec9b061:www/portal/api.php:4163·4183·4208 · 서버는 이제 mail_store_register 에서 비운다). 발신 표시의 정본은 보낸 결과의 미리보기 첫 줄이다 — 서버가 받는 쪽에 그릴 그대로다.
8523
+ */
8524
+ function mailAddrNote(reg, mine) {
8525
+ if (!reg || typeof reg !== 'object') return '';
8526
+ // 남이 닿을 주소 — 이 프로세스의 지금 세션(cur · /clear 를 따라간다). 옛 등록 응답(cur 칸 없음)이면 부르는 쪽의 mine.
8527
+ const me = typeof reg.cur === 'string' && AUTO_UUID_RE.test(reg.cur) ? reg.cur : mine;
8528
+ const old = typeof reg.old === 'string' && AUTO_UUID_RE.test(reg.old) ? reg.old : '';
8529
+ // /clear 로 대화가 바뀐 창 — 그 사실과 두 uuid 를 한 번 말한다(F2: 옛 uuid 로 부친 답장도 다른 창이 그 대화를 이어 열기 전까지 이 창이 긷는다).
8530
+ const moved = old ? `(이 창은 /clear 로 대화가 바뀌었다 — 이제 이 세션의 주소는 uuid ${me} 이다. 옛 uuid ${old} 앞 통도 다른 창이 그 대화를 `
8531
+ + '이어 열기 전까지는 이 창이 긷는다.)\n' : '';
8532
+ const sent = typeof reg.sentName === 'string' ? reg.sentName : '';
8533
+ const why = typeof reg.nameWhy === 'string' ? reg.nameWhy : '';
8534
+ const state = MAIL_NAME_STATES.includes(reg.name_state) ? reg.name_state : '';
8535
+ const stored = state ? (typeof reg.name === 'string' ? cellClean(reg.name) : '') : null; // null = 모른다(옛 서버)
8536
+ const prev = state && typeof reg.prev_name === 'string' && reg.prev_name ? cellClean(reg.prev_name) : '';
8537
+ const held = state ? state === 'held' : !!reg.held_by;
8538
+ const holder = reg.held_by ? cellClean(String(reg.held_by)).slice(0, 8) : '';
8539
+ const book = (stored === null ? '서버 주소록에는 이 세션의 예전 이름이 남았을 수 있다(이 서버는 남은 이름을 안 알려 준다)'
8540
+ : (stored ? `서버 주소록에 남은 이 세션의 이름은 «${stored}» 이다` : '서버 주소록에는 이름 없이 섰다'))
8541
+ + (prev ? ` · 옛 이름 «${prev}» 은 이제 이 세션으로 안 풀린다` : '');
8542
+ const show = '발신 표시는 아래 미리보기 첫 줄 그대로다';
8543
+ if (held) {
8544
+ return moved + `(이 세션이 댄 이름 «${cellClean(sent)}» 은 살아 있는 다른 세션${holder ? ` ${holder}` : ''} 이 쥐고 있어 못 댔다 — ${book}. `
8545
+ + `${show}. 남이 이 세션에 닿으려면 uuid ${me}.)\n`;
8546
+ }
8547
+ if (!sent) {
8548
+ return moved + `(이 세션은 말 걸 수 있는 이름 없이 주소를 댔다${why ? ` — ${cellClean(why)}` : ''}. ${book}. ${show}. `
8549
+ + `남이 이 세션에 닿으려면 uuid ${me}.)\n`;
8550
+ }
8551
+ if (stored !== null && stored !== sent) {
8552
+ return moved + `(이 세션이 댄 이름은 «${cellClean(sent)}» 인데 ${book}(name_state ${state}). ${show}.)\n`;
8553
+ }
8554
+ if (prev) return moved + `(이 세션의 이름은 이제 «${cellClean(sent)}» 이다 — 옛 이름 «${prev}» 은 이 세션으로 안 풀린다.)\n`;
8555
+ return moved;
8556
+ }
8557
+
8558
+ /** 보낸 결과의 **실제 받는 세션 uuid**(서버의 `to_session` — 같은 소속처 안의 값만 온다). 없거나 꼴이 아니면 ''(옛 서버). */
8559
+ function mailToSession(r) {
8560
+ const s = typeof r?.to_session === 'string' ? r.to_session.trim() : '';
8561
+ return AUTO_UUID_RE.test(s) ? s.toLowerCase() : '';
8562
+ }
8563
+
8564
+ /**
8565
+ * 받은 통의 **답장 주소**(보낸 세션 uuid) — 서버가 GET /api/mail 항목에 `from_session` 을 실어 주면 한 줄씩 덧붙인다(계약 3).
8566
+ * 발신 표시(uuid 앞 8자·그때의 이름)는 mail 의 to 로 안 풀리거나 남에게 간다 — 36자 uuid 만 그 세션으로 확실히 풀린다(mail_store_recipient — 8ec9b061:www/portal/lib/mail_store.php:171-172).
8567
+ * ⚠ 서버가 쓴 글자(`says`)는 손대지 않고 **뒤에** 붙인다 — 발신 표시를 짓는 자리는 서버의 mail_render() 하나뿐이다(아래 mailPullOne 머리말).
8568
+ * 이미 says 에 그 uuid 가 있으면 안 붙인다. 옛 서버(칸 없음)면 ''.
8569
+ */
8570
+ function mailReplyHints(rows, says = '') {
8571
+ const seen = new Set();
8572
+ const low = String(says || '').toLowerCase();
8573
+ const out = [];
8574
+ for (const m of Array.isArray(rows) ? rows : []) {
8575
+ const sid = typeof m?.from_session === 'string' ? m.from_session.trim().toLowerCase() : '';
8576
+ if (!AUTO_UUID_RE.test(sid) || seen.has(sid) || low.includes(sid)) continue;
8577
+ seen.add(sid);
8578
+ const who = cellClean(m.from_name || '').slice(0, 60) || sid.slice(0, 8);
8579
+ out.push(` ↩ «${who}» 에게 답할 주소(보낸 세션 uuid — 이름은 다시 뜨면 바뀐다): ${sid}`);
8580
+ if (out.length >= 5) break;
8581
+ }
8582
+ return out.length ? `\n${out.join('\n')}` : '';
7326
8583
  }
7327
8584
 
7328
8585
  /**
@@ -7339,8 +8596,10 @@ async function mailPull() {
7339
8596
  if (Date.now() - mailAt < MAIL_POLL_MS) return;
7340
8597
  mailAt = Date.now();
7341
8598
 
8599
+ // 주소(지금 세션 cur · /clear 앞 세션 old · 댈 이름)는 한 바퀴에 한 번 푼다 — 세포를 소속처마다 부르지 않게.
8600
+ const ident = await mailSelfRecent(mine).catch(() => null);
7342
8601
  for (const [tenant, tenantId] of tenants) {
7343
- try { await mailPullOne(tenant, mine, tenant === indexTenant ? indexEntry : null, tenantId); }
8602
+ try { await mailPullOne(tenant, mine, tenant === indexTenant ? indexEntry : null, tenantId, ident); }
7344
8603
  catch { /* 한 소속처가 막혀도 나머지는 돈다 — 다음 바퀴에 다시 */ }
7345
8604
  }
7346
8605
 
@@ -7519,7 +8778,9 @@ async function codexMailPullOne(tenant, mine, tenantId) {
7519
8778
  if (!state.pending) {
7520
8779
  const r = await codexMailApi('GET','/api/mail?tenant='+encodeURIComponent(tenant)+'&session='+encodeURIComponent(mine)+'&limit=20',undefined,cred,deadline);
7521
8780
  if (!Array.isArray(r.mail)) throw new Error('mail_response');
7522
- const batch = r.mail.length ? {ids:r.mail.map(m => m.id),says:r.says} : null;
8781
+ // Reply address (sender session UUID) comes from the server's from_session field and is appended after its wording.
8782
+ const told = typeof r.says === 'string' && r.says ? r.says + mailReplyHints(r.mail, r.says) : r.says;
8783
+ const batch = r.mail.length ? {ids:r.mail.map(m => m.id),says:told} : null;
7523
8784
  if (!codexMailPending(batch)) throw new Error('mail_response');
7524
8785
  mailCollectReturned(r);
7525
8786
  state.last_checked_ms = Date.now(); state.pending = batch; codexMailStore(file,state);
@@ -7533,48 +8794,63 @@ async function codexMailPullOne(tenant, mine, tenantId) {
7533
8794
  } finally { unlock(); }
7534
8795
  }
7535
8796
 
7536
- /** 한 소속처의 내 편지함을 긷고 **받았다고 도장을 찍는다**(도장은 큐에 넣기 **전에** — 재점화 방지). */
7537
- async function mailPullOne(tenant, mine, indexEntry, tenantId = 0) {
8797
+ /**
8798
+ * 한 소속처의 내 편지함을 긷고 **받았다고 도장을 찍는다**(도장은 큐에 넣기 **전에** — 재점화 방지).
8799
+ * ★ 긷는 편지함은 **지금 세션(cur)** 과, /clear 뒤면 그 앞 세션(old — 다른 창이 그 대화를 이어 열지 않았을 때만)이다(F2·SEC-7 — `mailSelf`).
8800
+ * 커서는 (소속처, 세션)마다 따로 — 한 커서로 두 세션을 긷으면 앞 세션이 커서를 밀어 뒤 세션의 통을 건너뛴다(그 통은 open 으로 남다 만료된다).
8801
+ * 색인의 mail_cursor 는 env 세션(mine)의 것이라 그 세션을 긷을 때만 적는다.
8802
+ */
8803
+ async function mailPullOne(tenant, mine, indexEntry, tenantId = 0, ident = null) {
7538
8804
  if (process.platform !== 'win32' && !process.env.CLAUDE_CODE_SESSION_ID && mine === codexSessionId()) return await codexMailPullOne(tenant, mine, tenantId || Number(indexEntry?.tenant_id));
7539
- try { await mailRegister(tenant, mine); } catch { /* 등록 실패가 받기를 막지 않는다 */ }
7540
-
7541
- if (!mailCursors.has(tenant)) mailCursors.set(tenant, Number(indexEntry?.mail_cursor || 0));
7542
- let cursor = mailCursors.get(tenant);
7543
- /*
7544
- * ⚠ **자리(`cwd`)를 안 보낸다.** 창구가 그것으로 거르던 동안 주소 있는 메일이 조용히
7545
- * 사라졌다(실측 2026-08-25: cwd 없이 1통 · cwd 있게 0통). 메일은 폴더가 아니라
7546
- * 세션에 부친 것이라 **주소가 이미 필터**다.
7547
- */
7548
- const q = '?tenant=' + encodeURIComponent(tenant)
7549
- + '&since=' + cursor
7550
- + '&session=' + encodeURIComponent(mine)
7551
- + '&limit=' + MAIL_PAGE;
7552
- const r = await api('GET', '/api/mail' + q);
7553
-
7554
- const rows = Array.isArray(r?.mail) ? r.mail : [];
7555
- if (typeof r?.cursor === 'number' && r.cursor > cursor) { cursor = r.cursor; mailCursors.set(tenant, cursor); }
7556
- // 색인에는 색인의 소속처 것만 적는다(다른 곳의 커서는 프로세스 안에서만 — 잃어도 ack 가 중복을 막는다).
7557
- // 빈 폴링은 수신이 아니다 — 위 백신 커서와 같은 규율.
7558
- if (indexEntry?.tenant_id) {
7559
- indexPatch(mine, { mail_cursor: cursor }, rows.length ? 'rx' : ''); // 소속 칸은 안 건드린다(§5.1 P1-a)
7560
- }
7561
-
7562
- if (rows.length) {
7563
- const ids = rows.map((m) => Number(m.id)).filter((n) => n > 0);
7564
- if (ids.length) {
7565
- try { await api('POST', '/api/mail/ack', { tenant, ids }); }
7566
- catch { /* 도장을 못 찍어도 말은 한다 — 못 찍은 것은 다음 바퀴에 또 온다 */ }
7567
- }
8805
+ const id = ident || await mailSelf(mine);
8806
+ try { await mailRegister(tenant, mine, false, id); } catch { /* 등록 실패가 받기를 막지 않는다 */ }
8807
+
8808
+ for (const sess of [id.cur, id.old]) {
8809
+ if (!sess) continue;
8810
+ const key = sess === mine ? tenant : `${tenant}\0${sess}`;
8811
+ if (!mailCursors.has(key)) mailCursors.set(key, sess === mine ? Number(indexEntry?.mail_cursor || 0) : 0);
8812
+ let cursor = mailCursors.get(key);
7568
8813
  /*
7569
- * ★ **서버가 쓴 글자를 그대로 싣는다.** 발신자 표시(`[누구 → 너]`)를 붙이는 자리는
7570
- * 서버의 `mail_render()` 하나뿐이고, 여기서 다시 지으면 두 자가 갈린다.
7571
- * 그리고 갈리는 순간 「누가 말하는지」가 보증이 아니라 장식이 된다 —
7572
- * 이 레일이 명령형을 허용하는 근거가 바로 그 보증이다.
8814
+ * ⚠ **자리(`cwd`)를 안 보낸다.** 창구가 그것으로 거르던 동안 주소 있는 메일이 조용히
8815
+ * 사라졌다(실측 2026-08-25: cwd 없이 1통 · cwd 있게 0통). 메일은 폴더가 아니라
8816
+ * 세션에 부친 것이라 **주소가 이미 필터**다.
7573
8817
  */
7574
- if (typeof r?.says === 'string' && r.says) mailQueue.push(r.says);
7575
- }
8818
+ const q = '?tenant=' + encodeURIComponent(tenant)
8819
+ + '&since=' + cursor
8820
+ + '&session=' + encodeURIComponent(sess)
8821
+ + '&limit=' + MAIL_PAGE;
8822
+ const r = await api('GET', '/api/mail' + q);
8823
+
8824
+ const rows = Array.isArray(r?.mail) ? r.mail : [];
8825
+ if (typeof r?.cursor === 'number' && r.cursor > cursor) { cursor = r.cursor; mailCursors.set(key, cursor); }
8826
+ // 색인에는 색인의 소속처 · env 세션의 것만 적는다(다른 곳의 커서는 프로세스 안에서만 — 잃어도 ack 가 중복을 막는다).
8827
+ // 빈 폴링은 수신이 아니다 — 위 백신 커서와 같은 규율.
8828
+ if (sess === mine && indexEntry?.tenant_id) {
8829
+ indexPatch(mine, { mail_cursor: cursor }, rows.length ? 'rx' : ''); // 소속 칸은 안 건드린다(§5.1 P1-a)
8830
+ }
8831
+
8832
+ if (rows.length) {
8833
+ const ids = rows.map((m) => Number(m.id)).filter((n) => n > 0);
8834
+ if (ids.length) {
8835
+ try { await api('POST', '/api/mail/ack', { tenant, ids }); }
8836
+ catch { /* 도장을 못 찍어도 말은 한다 — 못 찍은 것은 다음 바퀴에 또 온다 */ }
8837
+ }
8838
+ /*
8839
+ * ★ **서버가 쓴 글자를 그대로 싣는다.** 발신자 표시(`[누구 → 너]`)를 붙이는 자리는
8840
+ * 서버의 `mail_render()` 하나뿐이고, 여기서 다시 지으면 두 자가 갈린다.
8841
+ * 그리고 갈리는 순간 「누가 말하는지」가 보증이 아니라 장식이 된다 —
8842
+ * 이 레일이 명령형을 허용하는 근거가 바로 그 보증이다.
8843
+ * 답장 주소(보낸 세션 uuid)는 서버가 준 칸(`from_session`)으로 그 **뒤에** 붙인다 — 표시 줄은 안 건드린다(`mailReplyHints`).
8844
+ * /clear 앞 대화 앞으로 온 통이면 그 사실을 한 줄 앞에 둔다 — 받는 쪽(이 창의 새 대화)은 그 실의 맥락이 없다.
8845
+ */
8846
+ if (typeof r?.says === 'string' && r.says) {
8847
+ const lead = sess === id.cur ? '' : `(아래는 /clear 앞 대화(uuid ${sess}) 앞으로 온 통이다 — 이 창의 지금 대화는 그 실의 맥락이 없다.)\n`;
8848
+ mailQueue.push(lead + r.says + mailReplyHints(rows, r.says));
8849
+ }
8850
+ }
7576
8851
 
7577
- mailCollectReturned(r);
8852
+ mailCollectReturned(r);
8853
+ }
7578
8854
  }
7579
8855
 
7580
8856
  function mailCollectReturned(r) {
@@ -7756,6 +9032,13 @@ function requireArgs(name, args) {
7756
9032
  + `받는 이름은 이것뿐입니다 — ${all}`);
7757
9033
  }
7758
9034
 
9035
+ /** 창구가 돌려준 항목 짚개 한 줄 — 번호가 있으면(하위 업무 번호 2단계) 「W-04 · decisions/…」, 없으면 예전처럼 경로 하나(옛 서버 · 0단계). */
9036
+ function worksItemRef(item) {
9037
+ const key = String(item?.key ?? '');
9038
+ const code = typeof item?.code === 'string' ? item.code : '';
9039
+ return code ? `${code} · ${key}` : key;
9040
+ }
9041
+
7759
9042
  /**
7760
9043
  * ── `works(tenant, work)` — 그 일의 항목 한 줄씩(GET /api/work/items, 2026-09-27) ─────────────
7761
9044
  *
@@ -7763,16 +9046,26 @@ function requireArgs(name, args) {
7763
9046
  * 결정 장을 한 장씩 열었다(같은 날 hnl 세션: 배정안 + 항목 장 일곱). 서버가 간트와 같은 자로 낸 줄을
7764
9047
  * 그대로 옮긴다 — 번호(code)는 제목 머리의 [ … ] 이고, 없으면 제목만 쓴다(지어내지 않는다).
7765
9048
  * ⚠ **서버가 준 칸만 쓴다** — 계획·상태 칸이 없거나 비면 그 토막을 안 그린다(옛 서버·빈 관측).
9049
+ * ★ 하위 업무 번호(DESIGN-item-codes.md · 설계 4-1 MCP works 줄): 번호를 쓰는 소속처(2단계)의 서버는 줄마다 role · parent 를 더 준다 —
9050
+ * (짚개 한 줄은 worksItemRef — 창구가 번호를 돌려주면 「W-04 · decisions/…」, 아니면 예전처럼 경로 하나.)
9051
+ * 머리 장(role head — `WRM-00`)은 하위 업무 줄 · 수에서 빼 일 머리 줄에 싣고, 자식(parent 가 이 목록에 있는 줄)은 부모 밑에 들여 쓴다.
9052
+ * 번호 글자를 쪼개 부모를 읽지 않는다(서버 parent 칸만). 그 칸이 없는 서버(0단계 · 옛 서버)의 출력은 예전과 한 글자도 같다.
7766
9053
  */
7767
9054
  function worksItemsList(r) {
7768
9055
  const works = Array.isArray(r?.works) ? r.works : [];
7769
9056
  if (!works.length) return `${r?.tenant || ''} — 열린 일이 없다. 0건은 0건이다.`;
7770
9057
  const md = (d) => String(d || '').slice(5); // 2026-09-14 → 09-14
9058
+ const str = (v) => (typeof v === 'string' ? v : '');
7771
9059
  const out = [];
9060
+ let coded = false;
7772
9061
  for (const w of works) {
7773
- const items = Array.isArray(w?.items) ? w.items : [];
7774
- out.push(`«${w.name || w.slug}» (${w.slug}) — 항목 ${items.length}개`);
7775
- for (const it of items) {
9062
+ const all = Array.isArray(w?.items) ? w.items : [];
9063
+ if (all.some((it) => it && typeof it.role === 'string')) coded = true;
9064
+ const headRow = all.find((it) => it && it.role === 'head') || null;
9065
+ const items = headRow ? all.filter((it) => it !== headRow) : all;
9066
+ out.push(`«${w.name || w.slug}» (${w.slug}) — 항목 ${items.length}개`
9067
+ + (headRow ? ` · 머리 ${str(headRow.code)} — ${headRow.key}` : ''));
9068
+ const line = (it, depth) => {
7776
9069
  const code = typeof it.code === 'string' && it.code !== '' ? it.code : '';
7777
9070
  const title = code ? String(it.title || '').replace(/^\[[^\]]*\]\s*/, '') : String(it.title || '');
7778
9071
  // 계획 끝 말(plan_word)·고유 번호(uid)도 서버가 준 것만 — 할 일 화면과 같은 자(work_task_due_word) · 할 일 표의 uid.
@@ -7780,11 +9073,26 @@ function worksItemsList(r) {
7780
9073
  const plan = it.plan && it.plan.from ? ` · 계획 ${md(it.plan.from)}~${md(it.plan.to)}${due}` : '';
7781
9074
  const state = it.state_word ? ` · ${it.state_word}` : '';
7782
9075
  const uid = typeof it.uid === 'string' && it.uid !== '' ? ` · uid ${it.uid}` : '';
7783
- out.push(` · ${code ? code + ' ' : ''}${title}${plan}${state} — ${it.key}${uid}`);
9076
+ return `${' '.repeat(depth + 1)}· ${code ? code + ' ' : ''}${title}${plan}${state} — ${it.key}${uid}`;
9077
+ };
9078
+ // 자식 — 서버 parent 가 이 목록의 번호일 때만 들여 쓴다(부모가 목록에 없으면 제자리 한 줄 — 지어내지 않는다).
9079
+ const codes = new Set(items.map((it) => str(it.code)).filter(Boolean));
9080
+ const parentOf = (it) => { const p = str(it.parent); return p !== '' && p !== str(it.code) && codes.has(p) ? p : ''; };
9081
+ const kids = new Map();
9082
+ for (const it of items) {
9083
+ const p = parentOf(it);
9084
+ if (p) { if (!kids.has(p)) kids.set(p, []); kids.get(p).push(it); }
9085
+ }
9086
+ for (const it of items) {
9087
+ if (parentOf(it)) continue;
9088
+ out.push(line(it, 0));
9089
+ for (const k of kids.get(str(it.code)) || []) out.push(line(k, 1));
7784
9090
  }
7785
9091
  if (Number(w.dropped) > 0) out.push(` (지시형 제목이라 뺀 결정 ${Number(w.dropped)}건)`);
7786
9092
  }
7787
- out.push('항목을 건드리기 전에는 works(tenant, work, item) 로 이미 끝났는지 먼저 확인한다 — 전문은 get_case(tenant, item_key).');
9093
+ out.push(coded
9094
+ ? '항목을 건드리기 전에는 works(tenant, work, item) 로 이미 끝났는지 먼저 확인한다 — item 은 번호(예 W-04)나 item_key 그대로. 전문은 get_case(tenant, item_key).'
9095
+ : '항목을 건드리기 전에는 works(tenant, work, item) 로 이미 끝났는지 먼저 확인한다 — 전문은 get_case(tenant, item_key).');
7788
9096
  return out.join('\n');
7789
9097
  }
7790
9098
 
@@ -8271,6 +9579,8 @@ async function callTool(name, args = {}) {
8271
9579
  // 서버의 `publish: true` 손잡이를 당기면 초안이 열린다. 여는 동사는 release_report 하나다.
8272
9580
  delete payload.publish;
8273
9581
  delete payload.published_at;
9582
+ // 결정 모드 문서의 공개일은 서버가 원고에 적는다(released_on) — 되읽은 판에 섞여 오는 값은 서버가 어차피 덮지만 메아리를 줄인다.
9583
+ delete payload.released_on;
8274
9584
  delete payload.body_rev; // 몸통의 판은 서버가 안 읽는다 — 섞여 오면 떼고 보낸다(원고에 메아리로 남지 않게)
8275
9585
  if (args.dry_run) payload.dry_run = true;
8276
9586
  /*
@@ -8374,7 +9684,7 @@ async function callTool(name, args = {}) {
8374
9684
  }
8375
9685
  if (args.undone) {
8376
9686
  const r = await api('POST', '/api/work/done', { tenant: args.tenant, work: wSlug, item, undone: true });
8377
- return `${r.item.key} — 완료 표시를 되돌렸다(사후 정정, 원장에 남는다). 지금 상태: ${r.word}.`;
9687
+ return `${worksItemRef(r.item)} — 완료 표시를 되돌렸다(사후 정정, 원장에 남는다). 지금 상태: ${r.word}.`;
8378
9688
  }
8379
9689
  if (args.done_when || args.done_evidence) {
8380
9690
  if (!args.done_when || !args.done_evidence) {
@@ -8386,7 +9696,7 @@ async function callTool(name, args = {}) {
8386
9696
  done_when: String(args.done_when), done_evidence: String(args.done_evidence),
8387
9697
  session: mine || '',
8388
9698
  });
8389
- return `${r.item.key} — ${r.word}(으)로 표시했다.\n 조건: ${args.done_when}\n 근거: ${args.done_evidence}`;
9699
+ return `${worksItemRef(r.item)} — ${r.word}(으)로 표시했다.\n 조건: ${args.done_when}\n 근거: ${args.done_evidence}`;
8390
9700
  }
8391
9701
  // 아무 표시 인자도 없으면 조회 전용(부작용 없음) — 건드리기 전에 먼저 확인하는 자리.
8392
9702
  const r = await api('GET', '/api/work/done?tenant=' + encodeURIComponent(args.tenant)
@@ -8394,7 +9704,7 @@ async function callTool(name, args = {}) {
8394
9704
  const doneLine = r.state === 'done'
8395
9705
  ? `이미 끝난 항목이다 — 근거: ${r.done_evidence || '(비어 있음)'}. 다시 진행하지 말 것.`
8396
9706
  : '아직 완료가 아니다.';
8397
- return `${r.item.key} — ${r.word}\n${doneLine}`;
9707
+ return `${worksItemRef(r.item)} — ${r.word}\n${doneLine}`;
8398
9708
  }
8399
9709
  if (args.claim) {
8400
9710
  // 착수 선언(088) — 세션 이름은 이 자리(로컬)가 안다. mail 도구의 그 관용구다.
@@ -8405,7 +9715,7 @@ async function callTool(name, args = {}) {
8405
9715
  };
8406
9716
  if (args.item) body.item = String(args.item);
8407
9717
  const r = await api('POST', '/api/work/claim', body);
8408
- const target = r.item?.key ? `${r.item.key} @ «${r.work?.name || r.work?.slug}»` : `«${r.work?.name || r.work?.slug}» (${r.work?.slug})`;
9718
+ const target = r.item?.key ? `${worksItemRef(r.item)} @ «${r.work?.name || r.work?.slug}»` : `«${r.work?.name || r.work?.slug}» (${r.work?.slug})`;
8409
9719
  return `${target} — ${r.says}`;
8410
9720
  }
8411
9721
  const r = await api('GET', '/api/work/list?tenant=' + encodeURIComponent(args.tenant));
@@ -8477,6 +9787,9 @@ async function callTool(name, args = {}) {
8477
9787
  rejected_names: Array.isArray(args.rejected_names) ? args.rejected_names : [],
8478
9788
  // 옛 결정 뒤집기(2026-09-21) — 댈 때만 싣는다(안 대면 요청이 예전과 같다).
8479
9789
  ...(args.overturns ? { overturns: args.overturns, overturns_revision: args.overturns_revision || '' } : {}),
9790
+ // 하위 업무 번호 네 칸(DESIGN-item-codes.md · 설계 4-1) — 댄 칸만 싣는다(안 대면 요청이 예전과 같다). 판정 · 발급은 창구 한 자리.
9791
+ ...Object.fromEntries(['item', 'parent', 'branch', 'prefix']
9792
+ .filter((k) => typeof args[k] === 'string' && args[k].trim() !== '').map((k) => [k, args[k].trim()])),
8480
9793
  // ★ 출처는 우리가 채운다 — 모델에게 안 묻는다(remember_case 와 같은 규율). 표지가
8481
9794
  // 열린 일과 만나 항목으로 설 때 「누가 심었나」(간트의 세션 자국)가 이것이다.
8482
9795
  ...myCitation(),
@@ -8527,6 +9840,11 @@ async function callTool(name, args = {}) {
8527
9840
  } else if (args.work && !r.effects_pending) {
8528
9841
  lines.push('표지(work)를 적었지만 이 서버는 아직 그 칸을 모른다 — 판이 낡았다.');
8529
9842
  }
9843
+ // 하위 업무 번호 — 문장의 정본은 서버(work_items_code_note → work_note)다. 그 문장에 번호가 없을 때만(서버 문장이 빠진 판)
9844
+ // 칸 값 그대로 한 줄 — 프로즈를 짓지 않는다. 번호가 없는 저장(0단계 · 표지 없는 결정)의 출력은 예전과 같다.
9845
+ if (typeof r.code === 'string' && r.code !== '' && !String(r.work_note || '').includes(r.code)) {
9846
+ lines.push(`code: ${r.code}${typeof r.parent === 'string' && r.parent !== '' ? `\nparent: ${r.parent}` : ''}`);
9847
+ }
8530
9848
  return lines.join('\n');
8531
9849
  }
8532
9850
  case 'list_rejected_names': {
@@ -8943,30 +10261,30 @@ async function callTool(name, args = {}) {
8943
10261
  + '(Claude 세션 번호·유효한 Codex 세션 UUID·Cursor 활성 대화가 없는 환경이다).';
8944
10262
  }
8945
10263
  /*
8946
- * Native Codex에는 Claude ListAgents 자기소개가 없다. strict harness UUID로
8947
- * 주소를 등록하고 서버가 등록된 UUID로 발신 표시를 정한다(mail_store_sender_session).
8948
- * 이름을 지어내거나 요청 인자의 발신자를 쓰지 않는다. Claude·미지 클라이언트의
8949
- * 기존 이름 가드는 유지하고, 등록 거절은 아래에서 전송 전에 멈춘다.
10264
+ * ★ **이름이 없어도 보낸다**(2026-10-07 — 「이름 없으면 못 보냄」 문지기를 걷었다). 가드의 보증(「받는 쪽이 누가 말하는지
10265
+ * 본다」)은 이름이 아니라 **서버가 등록된 세션에서 발신자를 박는다**는 사실에서 온다 — 서버는 2026-08-25 부터 이름 없는 발신을
10266
+ * uuid 앞 8자로 받는다(mail_store_sender_session — 8ec9b061:www/portal/lib/mail_store.php:96-105). 그런데 이 문지기가 서버보다 엄해서 등록부를 못 읽은
10267
+ * 세션은 전체 uuid 앞으로 보낸 통까지 거절됐고, 문지기가 등록보다 앞이라 주소도 안 생겼다(2026-10-06T15:49:24Z 실측).
10268
+ * ★ **대고 나서 보낸다** — 등록(mailRegister)이 보내기 앞이다. 서버는 등록된 주소에서만 발신을 받는다(403).
10269
+ * 이름은 대기 직전에 푼다(myAgentName — 세포 먼저, 못 하면 JS 사본). Native Codex 는 늘 이름 없이 댄다 — 이름을 지어내거나
10270
+ * 요청 인자의 발신자를 쓰지 않는다. 등록 거절은 아래에서 전송 전에 멈춘다.
8950
10271
  */
8951
10272
  const nativeCodex = !process.env.CLAUDE_CODE_SESSION_ID && mine === codexSessionId();
8952
- if (!nativeCodex && !myAgentName()) {
8953
- return '이 세션에는 아직 말 걸 수 있는 이름이 없어 **보낼 수가 없다**'
8954
- + '(받는 쪽이 누가 말하는지 못 보면 그것은 메시지가 아니라 주입이다).\n'
8955
- + '이름은 `ListAgents` 를 한 번 부르면 생긴다 — 하네스가 「This session is …」를 '
8956
- + '전사에 적고, 이 도구가 그것을 읽는다. 부른 뒤 다시 보내면 된다.';
8957
- }
10273
+ /*
10274
+ * ★ 보내는 세션은 **지금 세션(cur)** 이다 — /clear 뒤에는 env 의 세션 id(mine)가 아니다(F2·SEC-7). 받는 쪽의 답장 주소(from_session)가
10275
+ * 그 uuid 가 되고, 이 창은 그 편지함을 긷는다(mailPullOne). /clear 앞 세션은 이름 없이 주소만 유지한다(mailRegister).
10276
+ */
8958
10277
  let reg;
8959
- try { reg = await mailRegister(args.tenant, mine, true); }
10278
+ let ident;
10279
+ try { ident = await mailSelf(mine); reg = await mailRegister(args.tenant, mine, true, ident); }
8960
10280
  catch (e) { return `주소를 대지 못했다 — ${e.message}`; }
10281
+ const sender = ident?.cur || mine;
8961
10282
  /*
8962
- * ⚠ 이름을 **남이 쥐고 있으면** 보내기가 409(이름 없음)로 막힌다 — 서버 오류문만 보면
8963
- * 무엇을 해야 하는지 모른다. 창구가 준 사실(held_by)을 여기서 사람 말로 앞세운다.
8964
- * (하네스가 짧은 이름을 재사용한다 — 2026-08-25 운영 실측, app-4a 둘.)
10283
+ * 이름을 남이 쥐고 있어도(held)·이름이 없어도 **보낸다**(침묵 금지). 무엇이 됐는지는 서버 응답의 사실대로 말한다 —
10284
+ * 발신 표시를 단정하지 않는다(mailAddrNote 머리말: 옛 서버는 held 일 때 그 줄의 옛 이름을 지켰다).
10285
+ * (하네스가 짧은 이름을 재사용한다 — 2026-08-25 운영 실측, app-4a 둘.) Native Codex 는 늘 이름이 없어 말할 것이 없다.
8965
10286
  */
8966
- // 이름을 남이 쥐고 있어도 **보낸다** — 발신자 표시는 서버가 uuid 앞 8자로 세운다(침묵 금지).
8967
- const heldNote = reg?.held_by
8968
- ? `(이 세션의 이름은 살아 있는 다른 세션 ${String(reg.held_by).slice(0, 8)} 이 쥐고 있어 이 통은 «${mine.slice(0, 8)}» 이름으로 나간다 — 남이 이 세션에 닿으려면 uuid ${mine} 로.)\n`
8969
- : '';
10287
+ const addrNote = nativeCodex ? '' : mailAddrNote(reg, mine);
8970
10288
 
8971
10289
  const r = await api('POST', '/api/mail', {
8972
10290
  tenant: args.tenant,
@@ -8978,14 +10296,17 @@ async function callTool(name, args = {}) {
8978
10296
  cwd: relHome(process.cwd()),
8979
10297
  ...(args.thread ? { thread: args.thread } : {}),
8980
10298
  // ★ **출처는 우리가 채운다 — 모델에게 안 묻는다**(remember_case 의 myCitation 과 같은 규율).
8981
- session: mine,
10299
+ session: sender,
8982
10300
  });
8983
10301
  // ★ 받을 곳을 기억한다 — 색인 밖 세션도 이 소속처에서 받는다(mailReady 힌트 + 색인).
8984
10302
  // ⚠ 서버가 tenant_id 를 아직 안 주는 판(재배포 전)이어도 **받기는 이름만으로 된다** —
8985
10303
  // GET /api/mail 은 tenant 이름으로 묻는다. 색인(indexNote)만 id 가 있어야 적힌다.
8986
10304
  mailTenants.set(args.tenant, r?.tenant_id || 0); // 이 소속처에서도 받는다 — 합집합, 폴백 아님
8987
10305
  if (r?.tenant_id && !indexRead()[mine]?.tenant) indexNote(mine, { src: 'mail', tenant: args.tenant, tenant_id: r.tenant_id, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) });
8988
- return heldNote + `«${r.to}» 앞으로 보냈다 — 실 ${r.thread} · 이 실의 ${r.turn}번째 통.\n`
10306
+ // 실제로 받은 세션(서버가 푼 uuid) — 이름으로 부쳤으면 그 이름이 그 순간 누구였는지가 여기 남는다(오배달을 알아챌 자리).
10307
+ const toSession = mailToSession(r);
10308
+ return addrNote + `«${r.to}» 앞으로 보냈다 — 실 ${r.thread} · 이 실의 ${r.turn}번째 통.\n`
10309
+ + (toSession ? `받는 세션: ${toSession} — 이 실을 이어 가거나 다시 부칠 때 이 uuid 가 주소다(이름은 다시 뜨면 바뀐다).\n` : '')
8989
10310
  + `받는 쪽이 보게 될 글자는 이것이다:\n${r.preview}\n`
8990
10311
  + (r.says ? `${r.says}\n` : '')
8991
10312
  + '답이 오면 다른 도구를 부를 때 응답 꼬리에 저절로 실린다 — 따로 확인할 필요 없다.';
@@ -9683,6 +11004,7 @@ function ensureSkillOnBoot() {
9683
11004
  * lane 'all' = 모든 클라이언트 · 'hookless' = 훅 레인이 없는 클라이언트만(`hookLane()` 이 거짓 — Codex)
9684
11005
  * idle 'never' = 늘 같은 주기(심박 — 살아 있음이 신호다, 서버는 90초 침묵을 죽음으로 본다)
9685
11006
  * 'slow' = 마지막 도구 호출 뒤 IDLE_AFTER_MS 가 지나면 IDLE_EVERY_MS 에 한 번만. 도구를 부르면 곧 복귀
11007
+ * once true = 타이머 없이 뜰 때 한 번만 돈다(주소 등록 — 2026-10-07). 그 뒤는 도구 호출·메일 폴이 맡는다
9686
11008
  *
9687
11009
  * 타이머가 아닌 것(심박 바퀴 안에서 사건·시각으로 도는 것): resolve(10분 TTL·사건 기반) · 판 비교(30분) ·
9688
11010
  * 되찾기 훑기(5분) · 자동 올리기(90초 하한, 동의 관문). 메일은 훅 레인이 있으면 도구 호출 때 긷는다(60초 상한).
@@ -9698,6 +11020,9 @@ const SCHEDULE = [
9698
11020
  { name: 'mail', every: MAIL_POLL_MS, lane: 'hookless', idle: 'slow', tick: () => { mailPull().catch(() => {}); },
9699
11021
  ready: mailPollReady,
9700
11022
  why: '훅 레인이 없는 세션의 편지함 — 훅 레인이 있으면 도구 호출 때 긷는다' },
11023
+ { name: 'address', every: 0, lane: 'all', idle: 'never', once: true, tick: () => { mailRegisterBoot().catch(() => {}); },
11024
+ ready: mailPollReady,
11025
+ why: '뜰 때 아는 소속처에 주소를 한 번 댄다 — 도구를 불러야만 대면 다시 뜬 세션이 첫 호출까지 서버에 옛 이름으로 남는다(계약 2)' },
9701
11026
  ];
9702
11027
 
9703
11028
  function startSchedule() {
@@ -9706,6 +11031,7 @@ function startSchedule() {
9706
11031
  if (SESSION_WATCH_UUID && row.name !== 'beat') continue;
9707
11032
  if (row.start) { row.start(); continue; }
9708
11033
  if (row.ready && !row.ready()) continue;
11034
+ if (row.once) { row.lastRun = Date.now(); row.tick(); continue; } // 타이머를 안 건다 — 뜰 때 한 번
9709
11035
  const run = () => {
9710
11036
  if (row.lane === 'hookless' && hookLane()) return;
9711
11037
  const now = Date.now();
@@ -9748,7 +11074,7 @@ if (IS_MAIN) {
9748
11074
  }
9749
11075
 
9750
11076
  // 시험용 — 서버 동작에는 쓰이지 않는다. materializeAssets 만 네트워크를 쓴다.
9751
- export { videoThumbs, taskReadTools, taskReadRequest, taskReadResult,
11077
+ export { videoThumbs, taskReadTools, taskReadRequest, taskReadResult, taskWriteResult, taskCodeOf,
9752
11078
  encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText, IMAGE_HOST_ALLOW, HOST_ALLOW,
9753
11079
  scrubTranscript, splitAtLines, PUSH_PART_MAX,
9754
11080
  // DESIGN-genetics-ssot.md §3·§5 — 색인·잠금·폴더 identity·알림 문구의 순수/파일 함수(시험이 걷는다)
@@ -9782,4 +11108,14 @@ export { videoThumbs, taskReadTools, taskReadRequest, taskReadResult,
9782
11108
  planPush, pushSession, pushAppend, capsNote, capsAppend, capsScrubRev, capsFile, capsAppendGone, CAPS_FALSE_TTL_MS,
9783
11109
  readAt, lastNewlineAt, countLines, appendScan, splitSeg, foldHist, scrubDelta, hashRange, pushTenantId,
9784
11110
  APPEND_ENV_MAX, APPEND_RAW_STEP, APPEND_ROUND_MAX,
9785
- autoFullBudgetOf, autoFullCounts, autoDupOf, PUSH_FULL_BUDGET, PUSH_FULL_WINDOW_MS, PUSH_FULL_REBASE, PUSH_FULL_LOG_MAX };
11111
+ autoFullBudgetOf, autoFullCounts, autoDupOf, PUSH_FULL_BUDGET, PUSH_FULL_WINDOW_MS, PUSH_FULL_REBASE, PUSH_FULL_LOG_MAX,
11112
+ // 주소 계약 2(2026-10-07) — 등록부 JS 사본(세포와 같은 계약)·세포 답의 모양·서버 이름 꼴·보낸 결과 안내·답장 주소(시험이 표로 걷는다)
11113
+ harnessSessionsDir, harnessInt, harnessNameClean, harnessNameOk, procStartEpoch, harnessRowOf, harnessRowsIn, harnessRowLive,
11114
+ harnessLiveRow, harnessSelfKeys, harnessSelfRow, harnessProbe, procStartOf, selfAddressJs, introNameIn, introNameOf,
11115
+ lastIntroInRange, cellSelfParse, mailNameOk, mailAddrNote, mailToSession, mailReplyHints,
11116
+ // 이름 관문(2026-10-07) — 세포 `self_whereabouts`·`session_whereabouts`·`claude_transcript_in` 의 사본과 세포에 묻는 길(smoke 3-4 가 같은 픽스처에 댄다)
11117
+ harnessHome, harnessConfigDir, claudeProjectBases, claudeTranscriptIn, claudeIntroName, introOfFile, INTRO_LINE_MAX,
11118
+ harnessWhereaboutsOf, harnessWhereabouts, harnessSelfWhereabouts, isoUtc, selfAddress,
11119
+ // 검증 반영(2026-10-07) — pidDomain(F1) · 세 갈래 산가(F5) · 고르기·산 줄 수(L1·SEC-1) · 찾은 열쇠(SEC-2) · /clear 따라가기(F2) · 이름 확인(SEC-5)
11120
+ pidDomainOf, harnessPidDomain, harnessRowLife, harnessLookup, harnessSelfLookup, harnessWithRows,
11121
+ myAgentName, mailSelf, mailRegister, mailNameSeen };