genetics-mcp 2.22.0 → 2.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
package/bin/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
- "aarch64-apple-darwin": "cb352eda0bbcf7419a7ca7243be783dfd3fece7a7e5fc3b5574474c92ac349f3",
3
- "aarch64-unknown-linux-musl": "43a93d3366f4700a74564adca0531312d4ef2079eb3d38ae5617e9c2199806de",
4
- "x86_64-apple-darwin": "c21172e1519287d0990149b668fc595be8f656d39fa30f5b811963d492c9e8d4",
5
- "x86_64-pc-windows-gnu": "10cdaa9e0f06b35843fe65006dbaed70e382ee61a1a86f0627d9e9df87557df3",
6
- "x86_64-unknown-linux-musl": "72e65bdd62e2afc7de01be2311ecfe55bc3b656764e09df4e382782ba66981c4"
2
+ "aarch64-apple-darwin": "e1a2b90dba750b351681467c79e08892e1e3194e7ad17942d7a1e27df4466222",
3
+ "aarch64-unknown-linux-musl": "8f92a5b9acdc4d03efdbe1acd579fad475626e95d0e8d9e10acabe0c3dbb74a4",
4
+ "x86_64-apple-darwin": "e231aeeb459fb5fc36aa877dd78d505f8327c02fe30e74305b6bf4a72540aabb",
5
+ "x86_64-pc-windows-gnu": "8b57d84c62233951f36b612901aea40db6fe775fefb571f34b4896e1440894fc",
6
+ "x86_64-unknown-linux-musl": "4fb2c81fe250bfbc6ccef94bd44c1326646ae3e93b83990feaaf846b80582439"
7
7
  }
Binary file
Binary file
Binary file
package/genetics.mjs CHANGED
@@ -855,12 +855,75 @@ function encodeAsset(buf, role, provider) {
855
855
  * 운영 문서가 1400x934 그림을 1400x1050 이라 선언해 브라우저가 세로로 12.4% 늘여
856
856
  * 그리고 있었다(2026-08-16 실측). 선언을 안 받으면 그 결함이 구조적으로 안 생긴다.
857
857
  */
858
- async function materializeAssets(data) {
858
+ /** 영상 한 편의 상한 — 서버 R2_VIDEO_MAX_BYTES 와 **같은 값**이다(갈리면 올리다 막힌다). */
859
+ const VIDEO_MAX_BYTES = 200_000_000;
860
+
861
+ /**
862
+ * 로컬 영상 파일 하나. 확장자를 안 믿고 **매직바이트**로 판정한다(서버도 같은 것을 다시 잰다).
863
+ * ISO BMFF 는 [4바이트 크기][ftyp] 라 4바이트째부터 본다.
864
+ */
865
+ function readLocalVideo(p) {
866
+ if (/^[a-z][a-z0-9+.-]*:/i.test(p)) throw new Error(`file 에는 주소가 아니라 파일 경로를 줍니다: ${p}`);
867
+ if (!path.isAbsolute(p)) throw new Error(`file 은 절대경로여야 합니다: ${p}`);
868
+ let st;
869
+ try { st = fs.statSync(p); } catch { throw new Error(`file 을 찾을 수 없습니다: ${p}`); }
870
+ if (!st.isFile()) throw new Error(`file 이 일반 파일이 아닙니다: ${p}`);
871
+ if (st.size > VIDEO_MAX_BYTES) {
872
+ throw new Error(`영상이 ${st.size.toLocaleString()}B 로 상한 ${VIDEO_MAX_BYTES.toLocaleString()}B 를 넘습니다: ${p}\n`
873
+ + '길이를 줄이거나 화질을 낮춰 다시 인코딩하세요(ffmpeg -crf 를 올리면 작아집니다).');
874
+ }
875
+ const buf = fs.readFileSync(p);
876
+ if (buf.toString('ascii', 4, 8) !== 'ftyp') {
877
+ throw new Error(`file 이 mp4(ISO BMFF)가 아닙니다(매직바이트 기준): ${p}`);
878
+ }
879
+ return buf;
880
+ }
881
+
882
+ /**
883
+ * 영상을 저장소에 올리고 **참조 번호**를 받는다.
884
+ *
885
+ * 바이트는 이 기계에서 저장소로 **직접** 간다 — 포털을 거치지 않는다(포털은 메모리가 좁고
886
+ * Range 를 모른다). 포털이 하는 일은 「올릴 자리 하나를 원장에 세우고 서명해 주는 것」뿐이다.
887
+ * `headers` 는 **그대로** 실어야 한다 — 그 값이 서명에 들어가 있어서, 하나라도 다르면 저장소가 거절한다.
888
+ */
889
+ async function uploadVideo(tenant, buf, filePath) {
890
+ if (!tenant) {
891
+ throw new Error('영상을 올리려면 tenant 가 필요합니다 — publish_report·update_report 의 tenant 를 씁니다.');
892
+ }
893
+ const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
894
+ const pre = await api('POST', '/api/media/presign', { tenant, bytes: buf.length, sha256 });
895
+ const res = await fetch(pre.url, { method: 'PUT', body: buf, headers: { ...(pre.headers || {}) } });
896
+ if (!res.ok) {
897
+ const why = (await res.text().catch(() => '')).slice(0, 200);
898
+ throw new Error(`영상을 저장소에 올리지 못했습니다(${res.status}): ${filePath}\n${why}\n`
899
+ + '설정 화면의 「연결 시험」이 통과하는지 먼저 확인하세요.');
900
+ }
901
+ ASSET_HINTS.push(`영상 「${path.basename(filePath)}」 ${(buf.length / 1048576).toFixed(1)}MB 를 올렸습니다(참조 ${pre.ref}).`);
902
+ return pre.ref;
903
+ }
904
+
905
+ async function materializeAssets(data, tenant) {
859
906
  ASSET_HINTS = [];
860
907
  if (!data || !Array.isArray(data.assets) || !data.assets.length) return data;
861
908
  const out = [];
862
909
  for (const a of data.assets) {
863
910
  const { data: dropped, file: fpath, ...meta } = a ?? {};
911
+ /*
912
+ * 영상(138) — 바이트를 문서에 싣지 않는다. `file` 에 로컬 mp4 경로를 주면 여기서 올리고
913
+ * **참조 번호만** 문서에 남긴다. 이미 `ref` 가 있으면 올린 것이므로 그대로 둔다
914
+ * (되읽은 판을 고쳐 되돌려 보내는 왕복에서 같은 영상을 두 번 올리지 않는다).
915
+ */
916
+ if (String(a?.role ?? '') === 'video' || String(a?.provider ?? '') === 'r2') {
917
+ const { data: _vd, ...rest } = meta;
918
+ if (Number(rest.ref) > 0) { out.push({ ...rest, role: 'video', provider: 'r2' }); continue; }
919
+ if (!fpath) {
920
+ throw new Error(`영상 「${rest.id ?? '(id 없음)'}」 에 file(로컬 mp4 경로)도 ref 도 없습니다.\n`
921
+ + 'file 에 절대경로를 주면 올리고 번호를 받아 싣습니다.');
922
+ }
923
+ const ref = await uploadVideo(tenant, readLocalVideo(String(fpath)), String(fpath));
924
+ out.push({ ...rest, role: 'video', provider: 'r2', ref, mime: 'video/mp4' });
925
+ continue;
926
+ }
864
927
  if (!a?.source && !fpath) {
865
928
  // 둘 다 없으면 예전 경로 그대로 — data 를 그대로 통과시킨다(판 A 하위호환).
866
929
  // get_report 가 준 표식('(생략 — NB)')과 bytes 칸의 처리(2026-08-27, 운영 #34):
@@ -1374,18 +1437,42 @@ const REPORT_DATA = {
1374
1437
  + '예: 「2026. 8. 24. Galaxy S22+ 실기기 홈 화면」.',
1375
1438
  },
1376
1439
  role: {
1377
- type: 'string', enum: ['hero', 'chapter', 'figure', 'eof', 'logo'],
1440
+ type: 'string', enum: ['hero', 'chapter', 'figure', 'eof', 'logo', 'video'],
1378
1441
  description: '`logo` 는 **표지 우상단 고객사 마크**다 — provider 는 user-provided, '
1379
1442
  + 'origin 에 제공자를 적는다. 못 받았으면 넣지 말고 data.client 에 이름만 적어라 '
1380
1443
  + '(렌더러가 활자로 세운다).\n'
1381
1444
  + '`eof` 는 **문서 끝 꼬리 카드**다. 안 줘도 된다 — 서버가 기본 카드를 '
1382
1445
  + '끼운다(계약 9절). 이 문서만의 카드가 있을 때만 준다. 그때는 무손실(PNG 또는 '
1383
- + '무손실 WebP)이어야 하고 줄이지 않고 원본 크기로 실린다.',
1446
+ + '무손실 WebP)이어야 하고 줄이지 않고 원본 크기로 실린다.\n'
1447
+ + '`video` 는 **절 본문 영상**이다(138). 바이트를 여기 싣지 않는다 — 먼저 '
1448
+ + '`POST /api/media/presign` 으로 올릴 자리를 받아 그 URL 에 PUT 하고, 받은 번호를 '
1449
+ + '`ref` 에 적는다. 자리는 `figure` 와 같다(sections[].plates·cases[].plates). '
1450
+ + '영상 저장 연동이 꺼진 환경에서는 발행이 거절된다 — 다른 형식으로 우회하지 않는다.',
1384
1451
  },
1385
1452
  provider: {
1386
- type: 'string', enum: ['higgsfield', 'deterministic', 'user-provided'],
1453
+ type: 'string', enum: ['higgsfield', 'deterministic', 'user-provided', 'r2'],
1387
1454
  description: 'hero·chapter 는 higgsfield 여야 한다. deterministic 은 role: figure 에 한한다. '
1388
- + 'user-provided 는 origin 이 필수다. sample-exception 은 발행 거절 값',
1455
+ + 'user-provided 는 origin 이 필수다. sample-exception 은 발행 거절 값. '
1456
+ + '`r2` 는 role: video 와 **짝**이다 — 바이트 대신 ref 로 온다',
1457
+ },
1458
+ // ⚠ 한 이름에 **자리에 따라 다른 두 뜻**이 얹혀 있다. 둘은 provider 로 서로 배타적이고 독자도 키를 만지기
1459
+ // 전에 provider/role 로 먼저 가른다(report_publish.php:544·1642 · report_media.php · gazette.py).
1460
+ // 2026-09-19 에 영상 갈래가 이 정수 선언을 따로 적으면서 아래쪽 불리언 선언과 **같은 객체에 두 번**
1461
+ // 들어갔고, JS 는 뒤엣것을 써서 정수 쪽이 조용히 사라졌다 — 게시 스키마가 `ref` 를 불리언이라고
1462
+ // 광고하는 동안 영상 자산은 `ref: 12` 를 실어 보내고 있었다(2026-09-20 관문이 짚음).
1463
+ // 개명은 하지 않는다: 서버 창구가 응답 본문으로 `ref` 를 안내하고(api.php:3252) 이미 발행된 문서의
1464
+ // source_json 에도 그 이름으로 저장돼 있어, 옮기면 report_media_refs() 가 빈 배열을 내고 청소가
1465
+ // R2 객체를 지운다(되돌릴 수 없다 — 실측으로 확인된 실패 양식). 그래서 **한 벌로 합쳐** 두 뜻을 적는다.
1466
+ ref: {
1467
+ type: ['integer', 'boolean'],
1468
+ description: '자리에 따라 뜻이 갈린다 — **`provider: r2` 이면 영상 자리 번호(정수)**, '
1469
+ + '그 밖의 그림 자산이면 **세트 참조 스위치(불리언)** 다.\n'
1470
+ + '· 정수: 영상 자리 번호. **보통은 직접 적지 않는다** — `file` 에 로컬 mp4 절대경로를 주면 '
1471
+ + '이 도구가 올리고 번호를 채운다. 되읽은 판을 고쳐 되돌려 보낼 때는 받은 값을 그대로 두면 '
1472
+ + '다시 안 올린다(빼면 서버가 저장된 값을 잇는다).\n'
1473
+ + '· 불리언: 거짓이면 **세트 참조 없이 단독으로** 그린다. 기본은 참 — hero 를 먼저 그려 그것을 '
1474
+ + '참조로 나머지를 이어야 한 세트로 보인다(참조 없이 내면 장마다 인물이 달라진다). '
1475
+ + 'hero 자신에게는 뜻이 없다.',
1389
1476
  },
1390
1477
  alt: { type: 'string', description: '설명적 대체 텍스트 — 접근성 계약. 그림과 함께 다닌다' },
1391
1478
  origin: { type: 'string', description: '생성 job 식별자·.mmd 원본 경로·제공자 이름' },
@@ -1424,12 +1511,7 @@ const REPORT_DATA = {
1424
1511
  type: 'string', enum: ['16:9', '4:3', '3:4', '1:1', '3:2', '2:3', '9:16'],
1425
1512
  description: '생략하면 역할이 정한다 — hero 16:9 · 그 밖 4:3.',
1426
1513
  },
1427
- ref: {
1428
- type: 'boolean',
1429
- description: '거짓이면 **세트 참조 없이 단독으로** 그린다. 기본은 참 — hero 를 먼저 '
1430
- + '그려 그것을 참조로 나머지를 이어야 한 세트로 보인다(참조 없이 내면 장마다 '
1431
- + '인물이 달라진다). hero 자신에게는 뜻이 없다.',
1432
- },
1514
+ // (`ref` 는 위 provider 옆으로 합쳤다 — 한 객체에 같은 키를 두 번 적으면 JS 가 앞엣것을 조용히 버린다)
1433
1515
  source: {
1434
1516
  type: 'string',
1435
1517
  description: '이미 그린 그림의 원본 https 주소(허용 호스트 목록 안). **이 도구의 프로세스가** '
@@ -4004,7 +4086,74 @@ async function stopBeat() {
4004
4086
  try { await beatOnce(beatTargets(), { closing: true }); } catch { /* 최선만 다한다 */ }
4005
4087
  }
4006
4088
 
4089
+ function taskReadTools() {
4090
+ const tenant = { type: 'string', minLength: 1, maxLength: 100, description: 'list_my_tenants에서 확인한 조직 slug' };
4091
+ return [
4092
+ { name: 'list_tasks', description: '조직에서 함께 사용하는 할 일과 완료 조건을 찾습니다. uid로 get_task_result를 호출해 사람이 채택한 결과를 이어받으세요. 개인 대화나 초안은 포함하지 않습니다.',
4093
+ inputSchema: { type: 'object', additionalProperties: false, required: ['tenant'], properties: { tenant,
4094
+ limit: { type: 'integer', minimum: 1, maximum: 50, default: 20 }, offset: { type: 'integer', minimum: 0, maximum: 10000, default: 0 } } } },
4095
+ { name: 'get_task_result', description: '할 일에서 사람이 채택한 현재 결과를 본인의 조직·Notion 원문 권한으로 읽습니다. 다시 사용할 때 재조회하세요. 할 일이 바뀌었으면 당시 기록과 현재 업무를 구분합니다.',
4096
+ inputSchema: { type: 'object', additionalProperties: false, required: ['tenant', 'task_uid'], properties: { tenant,
4097
+ task_uid: { type: 'string', pattern: '^[a-z0-9]{8}$' } } } },
4098
+ ];
4099
+ }
4100
+
4101
+ /** Request validation is transport-only. Current identity, source access and selected revision belong to the API. */
4102
+ function taskReadRequest(name, args) {
4103
+ if (!args || typeof args !== 'object' || Array.isArray(args) || typeof args.tenant !== 'string'
4104
+ || !args.tenant.trim() || [...args.tenant].length > 100) throw new Error('조직 slug를 확인하세요.');
4105
+ const allowed = name === 'get_task_result' ? ['tenant', 'task_uid'] : ['tenant', 'limit', 'offset'];
4106
+ if (Object.keys(args).some(key => !allowed.includes(key))) throw new Error('지원하지 않는 조회 인자입니다.');
4107
+ const query = new URLSearchParams({ tenant: args.tenant });
4108
+ if (name === 'get_task_result') {
4109
+ if (typeof args.task_uid !== 'string' || !/^[a-z0-9]{8}$/.test(args.task_uid)) throw new Error('list_tasks에서 받은 할 일 uid가 필요합니다.');
4110
+ return '/api/work/tasks/' + args.task_uid + '/result?' + query;
4111
+ }
4112
+ const limit = args.limit ?? 20, offset = args.offset ?? 0;
4113
+ if (name !== 'list_tasks' || !Number.isInteger(limit) || limit < 1 || limit > 50
4114
+ || !Number.isInteger(offset) || offset < 0 || offset > 10000) throw new Error('할 일 목록의 범위를 확인하세요.');
4115
+ return '/api/work/tasks?' + query + '&limit=' + limit + '&offset=' + offset;
4116
+ }
4117
+
4118
+ function taskReadResult(name, args, body) {
4119
+ const pick = (value, keys) => Object.fromEntries(keys.map(key => [key, value[key]]));
4120
+ const hash = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
4121
+ if (body?.ok !== true) throw new Error('현재 업무 결과를 확인하지 못했습니다.');
4122
+ let result;
4123
+ if (name === 'get_task_result') {
4124
+ const value = body.result, doc = value?.document;
4125
+ if (value?.tenant !== args.tenant || value?.task_uid !== args.task_uid || !hash(value.task_revision)
4126
+ || !Number.isInteger(value.generation) || value.generation < 0 || value.generation > 10000
4127
+ || typeof value.basis_changed !== 'boolean' || typeof value.note !== 'string'
4128
+ || (value.generation === 0) !== (doc === null)) throw new Error('채택 기록의 정체와 판을 확인하지 못했습니다.');
4129
+ if (doc !== null && (!doc || !Number.isInteger(doc.id) || doc.id < 1 || typeof doc.title !== 'string'
4130
+ || typeof doc.url !== 'string' || !hash(doc.revision) || typeof doc.text !== 'string' || Buffer.byteLength(doc.text) > 65536)) {
4131
+ throw new Error('채택 문서의 전체 본문을 확인하지 못했습니다.');
4132
+ }
4133
+ result = pick(value, ['tenant', 'task_uid', 'task_revision', 'generation', 'basis_changed', 'note']);
4134
+ result.document = doc === null ? null : pick(doc, ['id', 'title', 'url', 'revision', 'text']);
4135
+ } else {
4136
+ const rows = body.tasks, page = body.page, limit = args.limit ?? 20, offset = args.offset ?? 0;
4137
+ const expected = Math.min(limit, Math.max(0, body.open - offset)), end = offset + expected;
4138
+ const next = end < body.open && end <= 10000 ? end : null;
4139
+ if (name !== 'list_tasks' || body.tenant_slug !== args.tenant || !Array.isArray(rows) || !Number.isInteger(body.open)
4140
+ || body.open < 0 || !page || page.limit !== limit || page.offset !== offset || rows.length !== expected
4141
+ || page.returned !== expected || page.truncated !== (end < body.open) || page.next_offset !== next) {
4142
+ throw new Error('할 일 목록의 범위를 확인하지 못했습니다.');
4143
+ }
4144
+ const tasks = rows.map(row => {
4145
+ if (!row || typeof row.uid !== 'string' || !/^[a-z0-9]{8}$/.test(row.uid)
4146
+ || ['title', 'state', 'state_word', 'done_when'].some(key => typeof row[key] !== 'string')) throw new Error('할 일의 내용을 확인하지 못했습니다.');
4147
+ return pick(row, ['uid', 'title', 'state', 'state_word', 'done_when']);
4148
+ });
4149
+ result = { tenant: args.tenant, tasks, total: body.open, page: pick(page, ['limit', 'offset', 'returned', 'truncated', 'next_offset']) };
4150
+ }
4151
+ if (Buffer.byteLength(JSON.stringify(result)) > 98304) throw new Error('현재 업무 결과의 응답 크기가 상한을 넘습니다.');
4152
+ return result;
4153
+ }
4154
+
4007
4155
  const TOOLS = [
4156
+ ...taskReadTools(),
4008
4157
  /*
4009
4158
  * ★ **이 도구는 부르지 않아도 일한다.** 설명 자체가 세계 사실을 실어 나른다(`toolsNow()`).
4010
4159
  * 도구 목록은 매 턴 모델 문맥에 있으므로, 부르기 전에 이미 눈앞에 놓인다.
@@ -4340,7 +4489,7 @@ const TOOLS = [
4340
4489
  when: { type: 'string', description: '정한 날짜(예: 2026-08-27). 모르면 비운다 — 오늘로 지어내지 말 것.' },
4341
4490
  why: { type: 'string', description: '그때의 근거 — 수치·관찰. 이 칸이 이 장의 값이다.' },
4342
4491
  reopen: { type: 'string', description: '무슨 조건이면 다시 여나. **비우면 「열린 결정」으로 읽힌다.** 끝났으면 "조건 없음(종결)".' },
4343
- status: { type: 'string', description: '상태 — "유효"(기본) · "뒤집힘 → decisions/<그 결정>" · "조건 충족 → <무엇이> (날짜)".' },
4492
+ status: { type: 'string', description: '상태 — "유효"(기본) · "뒤집힘 → decisions/<그 결정>.md" · "조건 충족 → <무엇이> (날짜)". ★ 「뒤집힘」은 **무엇을 뒤집는지 대야 저장된다** — 경로가 없거나 그 장이 실재하지 않으면 거절한다(get_case 가 준 경로 그대로). 가리킬 장이 아직 없으면 그 결정을 먼저 남겨라.' },
4344
4493
  tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8). ★ 이것이 있어야 이 결정이 「일」 후보 계산에 들어간다.' },
4345
4494
  work: { type: 'string', description: '**이 결정이 곧 과업이면** 그 일의 열쇠(영문 소문자·숫자·붙임표, 예: hnl-wait). 적으면 그 이름의 일이 **지금** 「계획」으로 선다 — 착수 전에 세우는 정문이다. 이미 열린 일의 열쇠면 이 결정이 그 일의 **항목**(작업 리스트 한 줄)으로 서고 간트에 저장 즉시 보인다 — 항목 여럿이면 결정을 여러 번 적는다(항목마다 제 근거를 갖는다).' },
4346
4495
  order: { type: 'array', items: { type: 'string' }, description: '일 사이 **순서**를 정하는 결정이면 일의 열쇠를 앞선 것부터(최대 24). 표에 저장하지 않고 일 목록이 이 결정을 읽어 그 순서로 선다 — 순서가 바뀌면 새 결정을 적는다.' },
@@ -5749,6 +5898,36 @@ function worksItemsLine(items) {
5749
5898
  return rows.length ? `${head} · 그중 ${rows.join(' · ')}` : head;
5750
5899
  }
5751
5900
 
5901
+ /**
5902
+ * **목록 밖으로 떨어진 결정**(2026-09-21) — 그 일의 갈래와 만나는데 **항목이 못 된** 장.
5903
+ *
5904
+ * ★ 왜 이 줄이 있나(LIM 실측 2026-09-21): 「간트에서 항목을 에이전트에게 시키기」 한 목표를
5905
+ * 다룬 결정 6장 중 표지(`work:`)를 단 것은 2장뿐이었고 그 둘조차 열쇠가 달랐다. 나머지 4장은
5906
+ * **어느 목록에도 어느 수에도 안 섰다** — 그래서 사람도 두뇌도 「왜 이 일에 항목이 이것뿐인가」를
5907
+ * 물을 길이 없었다. 항목으로 세우지는 **않는다**(2026-08-31 오너 판정: 그 일에 **대한** 결정은
5908
+ * 그 일의 **할 일**이 아니다). 빠졌다는 **사실만** 말한다.
5909
+ *
5910
+ * ★ **방어적으로 그린다** — 서버가 이 칸을 안 보내면 **오늘 내는 글자 그대로** 낸다.
5911
+ * 읽는 칸: `unmarked = { no_mark, baked, off_work, total, paths: [<결정 장 경로>, …] }`
5912
+ * 사유마다 **처방이 다르므로** 낱말을 가른다:
5913
+ * · `no_mark` 표지가 없다 → 그 결정에 `work:` 를 달면 항목이 된다
5914
+ * · `baked` 두뇌가 구운 장이다 → 사람이 제 것으로 세워야(`origin: human`) 비로소 읽힌다
5915
+ * · `off_work` 표지가 **열린 일을 안 가리킨다** → 열쇠가 갈렸다(닫혔거나 다른 이름)
5916
+ */
5917
+ function worksUnmarkedLine(u) {
5918
+ const x = u && typeof u === 'object' ? u : {};
5919
+ const total = Number(x.total) || 0;
5920
+ if (!total) return '';
5921
+ const bits = [];
5922
+ if (Number(x.no_mark) > 0) bits.push(`표지 없음 ${Number(x.no_mark)}`);
5923
+ if (Number(x.baked) > 0) bits.push(`두뇌가 구운 장 ${Number(x.baked)}`);
5924
+ if (Number(x.off_work) > 0) bits.push(`표지가 다른 이름 ${Number(x.off_work)}`);
5925
+ if (!bits.length) return '';
5926
+ const paths = (Array.isArray(x.paths) ? x.paths : []).filter((q) => typeof q === 'string' && q).slice(0, 3);
5927
+ return `\n ⚠ 이 일 언저리에서 **항목이 못 된 결정 ${total}건** — ${bits.join(' · ')}.`
5928
+ + (paths.length ? ` 예: ${paths.join(' · ')}` : '');
5929
+ }
5930
+
5752
5931
  async function callTool(name, args = {}) {
5753
5932
  requireArgs(name, args);
5754
5933
  switch (name) {
@@ -6107,7 +6286,7 @@ async function callTool(name, args = {}) {
6107
6286
  return r.says;
6108
6287
  }
6109
6288
  case 'publish_report': {
6110
- const data = await materializeAssets(args.data);
6289
+ const data = await materializeAssets(args.data, args.tenant);
6111
6290
  // **기본은 초안이다.** 서버 기본값은 참(다른 호출자의 동작을 말없이 바꾸지 않는다)이라
6112
6291
  // 여기서 뒤집는다 — 확인 전에 고객이 먼저 보는 일을 막는 것은 도구의 몫이다.
6113
6292
  const publish = args.publish === true;
@@ -6153,7 +6332,7 @@ async function callTool(name, args = {}) {
6153
6332
  return `${r.note}\n${lines.join('\n')}`;
6154
6333
  }
6155
6334
  case 'update_report': {
6156
- const data = await materializeAssets(args.data);
6335
+ const data = await materializeAssets(args.data, args.tenant);
6157
6336
  const payload = { ...data, tenant: args.tenant };
6158
6337
  // 노출 손잡이는 떼고 보낸다(2026-09-14). get_report 가 돌려주는 data 에는 발행 때 보낸 몸통이
6159
6338
  // 그대로 남아 있어(source_json) `publish`·`published_at` 이 메아리로 섞여 온다 — 그 값이
@@ -6198,6 +6377,11 @@ async function callTool(name, args = {}) {
6198
6377
  if (!facts.length) return '이 저장소에서 최근에 남이 바꾼 것은 없다.';
6199
6378
  return `이 저장소에서 최근에 바뀐 것:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
6200
6379
  }
6380
+ case 'list_tasks':
6381
+ case 'get_task_result': {
6382
+ const result = await api('GET', taskReadRequest(name, args));
6383
+ return JSON.stringify(taskReadResult(name, args, result), null, 2);
6384
+ }
6201
6385
  case 'works': {
6202
6386
  if (args.work) {
6203
6387
  // 완료 확인/표시/해제(095) — claim 과 별개 통로. item 은 필수(어느 결정인지).
@@ -6246,20 +6430,29 @@ async function callTool(name, args = {}) {
6246
6430
  // 차례를 사람이 정했으면 그 출처를 말한다 — 「…부터 해라」가 아니라 「…이 정한 차례다」(서술형).
6247
6431
  if (r.order_from?.title) lines.push(` 차례는 결정 «${r.order_from.title}» 이 정했다 — 위에서부터가 그 차례다.`);
6248
6432
  let reportSeen = false;
6433
+ let unmarkedSeen = false;
6249
6434
  for (const w of rows) {
6250
6435
  if (Array.isArray(w.items?.reports) && w.items.reports.some((x) => Number(x?.id) > 0)) reportSeen = true;
6436
+ if (Number(w.unmarked?.total) > 0) unmarkedSeen = true;
6251
6437
  lines.push(` · «${w.name || w.slug}» (${w.slug}) — ${w.word} · 세션 ${w.sessions}`
6252
6438
  // 항목 = 그 일을 가리키는 결정, 그리고 공개된 보고서의 열린 항목(§7.7). 「무엇을 하기로 했나」가 몇 건인지.
6253
6439
  // 0 이면 안 붙인다. done 은 실행 축 집계(095) — 어느 항목인지는 안 말한다, work+item 으로 따로 확인.
6254
6440
  + worksItemsLine(w.items)
6255
6441
  + (w.stale ? ` · 낡음 ${w.stale}일째(AI 관측 — 활동이 멎었다: 사람이 닫거나 다시 살릴 일)` : '')
6256
- + (w.doc ? ` · 요지: get_case(tenant, "${w.doc}")` : ' · 요지 문서 아직 없음'));
6442
+ + (w.doc ? ` · 요지: get_case(tenant, "${w.doc}")` : ' · 요지 문서 아직 없음')
6443
+ + worksUnmarkedLine(w.unmarked));
6257
6444
  }
6258
6445
  if (!rows.length) lines.push('열린 일이 없다 — 0건은 0건이다. 착수 전에 세우려면 그 과업의 결정을 remember_decision 으로 남기되 **work 에 일의 열쇠를 적어라** — 저장 응답의 work_note 로 「계획」이 선 것을 확인하고 이 도구를 다시 부른다. 표지 없이 tags 만 달면 신호가 쌓일 때까지 AI 가 기다린다. 군체 화면은 열린 일이 있을 때 소속을 확정하는 자리 — 0건이면 고를 일이 없다.');
6259
6446
  else lines.push('붙을 일이 보이면 works(tenant, claim: "<slug>") 로 선언한다 — 확정은 사람이 화면에서 한다.'
6260
6447
  + '\n그 일 안의 특정 결정 항목을 쥐었으면 works(tenant, claim: "<slug>", item: "decisions/<page>.md") 로 함께 선언한다.'
6261
- + '\n항목은 그 일의 열쇠·갈래를 단 **결정**이다 — 무엇을 하기로 했는지는 지식의 결정 장에 있고, 새 항목은 remember_decision 으로 남긴다.'
6448
+ + '\n항목은 그 일의 열쇠를 **표지(work:)로 단** 결정이다 — 갈래(tags)만 달면 항목이 아니라 「그 일에 **대한** 결정」이라 목록에 안 선다(2026-08-31 오너 판정). 새 항목은 remember_decision 의 work 에 그 열쇠를 적어 남긴다.'
6262
6449
  + '\n항목을 실제로 끝냈으면 works(tenant, work: "<slug>", item: "…", done_when: "…", done_evidence: "…") 로 직접 완료를 표시한다 — 사람이 화면에서 누르길 기다릴 필요 없다.');
6450
+ /* 목록 밖으로 떨어진 결정이 하나라도 보이면 **처방 한 줄**을 더한다(2026-09-21).
6451
+ 하나도 없는 소속처의 출력은 오늘과 **한 글자도 다르지 않다** — reportSeen 과 같은 규율. */
6452
+ if (unmarkedSeen) lines.push('⚠ 「항목이 못 된 결정」이 있다 — 그 일에 **대한** 결정이 그 일의 **할 일 목록**에는 안 선 것이다. 사유마다 처방이 다르다:'
6453
+ + '\n · 표지 없음 — 그 결정을 remember_decision 으로 다시 적을 때 work 에 그 일의 열쇠를 적으면 항목이 된다(같은 제목이면 갱신이라 expected_revision 이 필요하다).'
6454
+ + '\n · 두뇌가 구운 장 — 사람이 포털에서 제 것으로 세워야 읽힌다. 두뇌가 제 산출물로 제 일을 낳는 길은 **일부러 막혀 있다**.'
6455
+ + '\n · 표지가 다른 이름 — 같은 목표에 **열쇠가 둘 선 것**이다. 어느 쪽이 정본인지 사람에게 묻고, 새 이름으로 또 열지 마라.');
6263
6456
  /*
6264
6457
  * ★ 보고서에서 온 항목이 섞여 있을 때만 한 줄 더한다(§7.7) — 그 항목의 **상태는 보고서가 정본**이라
6265
6458
  * 여기서 완료를 찍으면 서버가 409 로 막는다. 그 사실을 먼저 말해 두는 것이 헛걸음을 줄인다.
@@ -7438,7 +7631,8 @@ if (IS_MAIN) {
7438
7631
  }
7439
7632
 
7440
7633
  // 시험용 — 서버 동작에는 쓰이지 않는다. materializeAssets 만 네트워크를 쓴다.
7441
- export { encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText, IMAGE_HOST_ALLOW, HOST_ALLOW,
7634
+ export { taskReadTools, taskReadRequest, taskReadResult,
7635
+ encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText, IMAGE_HOST_ALLOW, HOST_ALLOW,
7442
7636
  scrubTranscript, splitAtLines, PUSH_PART_MAX,
7443
7637
  // DESIGN-genetics-ssot.md §3·§5 — 색인·잠금·폴더 identity·알림 문구의 순수/파일 함수(시험이 걷는다)
7444
7638
  jsonFileUpdate, jsonCorruptAside, credWrite, pendingWrite, pendingClear, indexRead, indexNote, indexPatch, indexTenant,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.22.0",
3
+ "version": "2.23.0",
4
4
  "description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
5
5
  "author": "주식회사 LIM (LIM Inc.)",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -18,7 +18,8 @@
18
18
  "node": ">=18"
19
19
  },
20
20
  "scripts": {
21
- "test": "node smoke.mjs && node notify.test.mjs && node schedule.test.mjs && node windows.test.mjs && bash ../cell/tests/cellcycle.sh",
21
+ "test": "node smoke.mjs && node task-results-stdio.test.mjs && node notify.test.mjs && node schedule.test.mjs && node windows.test.mjs && bash ../cell/tests/cellcycle.sh",
22
+ "test:task-results": "node task-results.test.mjs && node task-results-stdio.test.mjs",
22
23
  "prepack": "find skills -name __pycache__ -type d -prune -exec rm -rf {} + 2>/dev/null; bash ../cell/build-all.sh",
23
24
  "prepublishOnly": "npm test",
24
25
  "release": "bash release.sh"
@@ -471,6 +471,7 @@ python3 assets/render.py <report.json>
471
471
  | 절 %s 도해 「%s」가 %d바이트입니다 — 상한 %d바이트, 노드를 줄이거나 도판(PNG)으로 싣습니다 | `diagrams[].svg` 가 250,000B 초과 | 도해를 나누거나 PNG 도판으로 |
472
472
  | 절 %s 도해 「%s」에 viewBox 가 없거나 svg 가 아닙니다 — 폭을 알 수 없어 싣지 않습니다 | 루트 `<svg>` 에 viewBox 가 없다 | `diagram.mjs export` 로 굳힌 SVG 를 넣는다(§11-9) |
473
473
  | 절 %s 시계열 「%s」의 표식 「%s」가 점에 없습니다 — marks[].x 는 points[].x 와 같은 값이어야 섭니다 | `charts[].marks[].x` 가 어느 `points[].x` 와도 같지 않다 | 점의 x 값 그대로 적는다(§11-7) |
474
+ | 절 %s 시계열 「%s」의 표식 라벨 「%s」와 「%s」가 %.0fpx 겹칩니다 — 라벨을 줄이거나 하나를 빼고 세로선만 남깁니다 | 표식 라벨은 전부 같은 높이(`T+13`)에 서고 렌더러가 밀어내지 않는다 — 가까운 표식 둘의 글자가 포개진다. 폭은 전각 1.0 · 공백 0.28 · 그 밖 0.55 로 어림한다(`_lblw`) | 라벨을 줄이거나, 하나는 `label` 없이 두어 세로선만 남긴다(§11-7) |
474
475
  | 절 %s 도해 「%s」의 ir 는 %s 입니다 — 관보가 직접 그리는 것은 workflow 뿐, 다른 종류는 svg 로 굳혀 넣습니다 | `diagrams[].ir.diagram_type` 이 `workflow` 가 아니다 | Archify 로 그린 뒤 `diagram.mjs export` 의 svg 를 넣는다(§11-9) |
475
476
  | 절 %s 도해 「%s」의 노드 「%s」 라벨이 두 줄을 넘습니다 — 낱말을 줄이거나 wide 로 두십시오 | 노드 폭(열 폭 − 간격)에서 15px 글자로 두 줄을 넘는 라벨 | 라벨을 줄이거나 `wide: true` |
476
477
  | 절 %s 도해 「%s」의 노드 「%s」가 없는 레인 「%s」를 가리킵니다 | `nodes[].lane` 이 `lanes[].id` 에 없다 | Archify validate 를 먼저 통과시킨다 |