genetics-mcp 2.24.0 → 2.25.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": "b3e457bc632fa02bd4df2cfe828e8fa04c1288b7553cb9b77b6dbd98a2df748d",
3
- "aarch64-unknown-linux-musl": "8e174b10e0ff47263e57663204cd39557e8f6e8898d28b4583e2571466487d95",
4
- "x86_64-apple-darwin": "fc4bf9c575465f0222c89ba0b7c4d8f1945b18172fa803c8ae31e74b39a1523d",
5
- "x86_64-pc-windows-gnu": "247ed5e4ccacbbdd1a81783a99296f4f5541d9060079547af85d187746b8c187",
6
- "x86_64-unknown-linux-musl": "0b93a8e653fd2993bd9e37c79119fbc4f4b4446c55023fa060b529d9a06a80ad"
2
+ "aarch64-apple-darwin": "373bf4961f501e0423387e70a9c119d60ee92f09195b0f958d87be10e0a39429",
3
+ "aarch64-unknown-linux-musl": "7c0284e65d5aae4543f8b7b84c8f08047985b0d15d429d0fc4745ba87e041215",
4
+ "x86_64-apple-darwin": "b3f77b1ec64d2a68fa8f94976f1afabd2cb374b0bd93f8fe2298bc4c2c971c44",
5
+ "x86_64-pc-windows-gnu": "fa35abad22a18a7cc0e8c52694550016a3b94dda2881074428d4c3eb7647ae0e",
6
+ "x86_64-unknown-linux-musl": "eb9bcaa435884b2c0f598cf2a8a40f6bb559a3bf99ba1424e18afbfde2ca374a"
7
7
  }
Binary file
Binary file
Binary file
package/genetics.mjs CHANGED
@@ -3556,12 +3556,12 @@ function indexTenant(uuid, t) {
3556
3556
  try { return indexWrite(uuid, t, {}, ''); } catch { return false; }
3557
3557
  }
3558
3558
 
3559
- /** 옛 부름 자리의 껍데기 — 소속 칸(서버 응답)과 나머지를 갈라 **한 번의 잠금으로** 적는다. */
3559
+ /** 옛 부름 자리의 껍데기 — 소속 칸과 나머지를 한 번에 적고, 쓰기 수락 여부를 돌려준다. */
3560
3560
  function indexNote(uuid, entry, flow = '') {
3561
- if (!uuid || !entry?.tenant_id) return;
3561
+ if (!uuid || !entry?.tenant_id) return false;
3562
3562
  const { tenant_id, tenant_slug, tenant, src, resolved_at, ...rest } = entry;
3563
- try { indexWrite(uuid, { tenant_id, tenant_slug, tenant, src }, rest, flow); }
3564
- catch { /* 색인은 있으면 좋은 것이지 도구가 실패할 이유가 아니다 */ }
3563
+ try { return indexWrite(uuid, { tenant_id, tenant_slug, tenant, src }, rest, flow); }
3564
+ catch { return false; /* 색인은 있으면 좋은 것이지 도구가 실패할 이유가 아니다 */ }
3565
3565
  }
3566
3566
 
3567
3567
  /**
@@ -5961,9 +5961,11 @@ const TOOLS = [
5961
5961
  '**노출 상태는 안 바꾼다**(2026-09-14) — 초안(release 전)을 고치면 초안 그대로고, 열린 문서를 고치면 열린 그대로다. ' +
5962
5962
  '초안을 여는 동사는 release_report 하나다. get_report 로 되읽은 데이터에 섞여 오는 publish·published_at 은 이 도구가 떼고 보낸다 — 그 값으로는 안 열린다. ' +
5963
5963
  '사람이 편집기로 고친 문서도 교체할 수 있다(2026-08-25) — 대신 무엇을 덮게 되는지 목록이 먼저 말하고, 덮은 뒤 되돌릴 판이 응답에 온다. ' +
5964
- '**그림 계약**: `assets` 를 **빼고** 보내면 서버가 저장된 그림을 그대로 유지한다. 전부 지우려면 `assets: []` 를 명시하고 ' +
5964
+ '**그림 계약**: `assets` 를 **빼고** 보내면 서버가 저장된 그림을 그대로 유지한다 — 캡션·출처 줄·alt 같은 도판 글 칸도 저장값 그대로라, 글 칸을 고쳤으면 `assets` 를 get_report 표식째 목록으로 보낸다. 전부 지우려면 `assets: []` 를 명시하고 ' +
5965
5965
  '`sections[].plates`·`chapters[].asset` 에서도 그 id 를 빼야 한다 — 가리키는 곳이 없는 id 는 422 로 거절된다. ' +
5966
- '한 장만 바꾸려면 그 id 에 새 `source`/`file` 을 주고 나머지는 get_report 가 준 표식 그대로 두면 된다.\n' +
5966
+ '한 장만 바꾸려면 그 id 에 새 `source`/`file` 을 주고 나머지는 get_report 가 준 표식 그대로 두면 된다. ' +
5967
+ '**받은 판 잠금**(2026-09-30): get_report 머리줄의 body_rev 를 `body_rev` 로 실으면, 받은 뒤 본문 판이 바뀌었을 때 서버가 409 로 멈추고 아무것도 안 쓴다. ' +
5968
+ 'dry_run 에도 같이 실으면 같은 409 를 덮기 전에 받는다. 서버가 이 잠금을 모르는 옛 판이면 출력이 「판을 견주지 않았다」고 알린다.\n' +
5967
5969
  '**문체는 주어와 자리가 정한다** — 요청 자리 밖에는 우리·사물·화면이 주어인 문장만 개조식(~함/~했음/~임)으로 쓴다. 독자가 할 일은 요청 자리(open_items·cta·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지 하십시오체로 맺는다(「회신 부탁드립니다」. 제목은 검수·보고면 「~ 요청」+기한 괄호, 결정 문서의 판단 항목은 물음). 그 밖의 칸에는 사실만 적는다(「기준 수치는 미정」). dry_run 은 일부만 센다.\n',
5968
5970
  inputSchema: {
5969
5971
  type: 'object',
@@ -5975,6 +5977,11 @@ const TOOLS = [
5975
5977
  description: '참이면 아무것도 바꾸지 않고 렌더 결과와 경고만 돌려준다. '
5976
5978
  + '이 문서는 이미 고객에게 나간 링크를 갖고 있으니 덮기 전에 한 번 볼 수 있다.',
5977
5979
  },
5980
+ body_rev: {
5981
+ type: 'integer', minimum: 0,
5982
+ description: 'get_report **머리줄**의 body_rev=N(받은 판) — 시험 실행이 알려 주는 「지금 판」이 아니다. 주면 받은 뒤 본문 판이 바뀌었을 때(다른 교체·편집기 발행·그림이 앉아 다시 구운 것) '
5983
+ + '서버가 409 로 멈추고 아무것도 안 쓴다. 안 주면 지금처럼 덮는다. 교체 응답이 새 판을 알려 주니 연속 교체에는 그 값을 싣는다.',
5984
+ },
5978
5985
  data: REPORT_DATA,
5979
5986
  },
5980
5987
  required: ['tenant', 'id', 'data'],
@@ -7818,7 +7825,7 @@ async function callTool(name, args = {}) {
7818
7825
  const owner = r.body_owner === 'html' ? '발행물(HTML)' : 'JSON';
7819
7826
  head.push(`[정본 ${owner} · 본문 판 body_rev=${r.body_rev ?? '?'}`
7820
7827
  + (r.sha256 ? ` · 지금 발행물 ${r.bytes}B sha256:${String(r.sha256).slice(0, 12)}…` : '')
7821
- + `] — 고칠 때는 update_report 에 body_rev 를 함께 실으면, 그 사이 누가 고쳤을 때 409 로 멈춘다.`);
7828
+ + `] — 고칠 때는 update_report 에 이 body_rev 를 함께 실으면, 받은 뒤 본문 판이 바뀌었을 때(다른 교체·편집기 발행·그림이 앉아 다시 구운 것) 409 로 멈춘다.`);
7822
7829
  // 열린 검토 회신 — 고치기 전에 읽어야 한다(판단 항목의 답이 거기 있다). 첫 줄 가까이에 둔다.
7823
7830
  if (Number.isInteger(r.open_replies) && r.open_replies > 0) {
7824
7831
  head.push(`검토 회신 ${r.open_replies}건이 열려 있다 — 고치기 전에 list_notes(id: ${Number(args.id)}) 로 본문을 읽는다.`);
@@ -7827,12 +7834,19 @@ async function callTool(name, args = {}) {
7827
7834
  const kept = Array.isArray(r.data?.assets)
7828
7835
  ? r.data.assets.filter((a) => typeof a?.data === 'string' && a.data.startsWith('(생략')).length : 0;
7829
7836
  if (kept > 0) {
7830
- head.push(`그림 ${kept}장은 서버가 보관 중 — update_report 에 assets 를 **빼고** 보내면 그대로 유지, `
7837
+ head.push(`그림 ${kept}장은 서버가 보관 중 — update_report 에 assets 를 **빼고** 보내면 그대로 유지(캡션·출처 줄·alt 도 저장값 — 글 칸을 고쳤으면 표식째 목록으로), `
7831
7838
  + '전부 지우려면 `assets: []`(sections[].plates·chapters[].asset 에서도 빼야 한다), 바꿀 장만 새 source/file 로.');
7832
7839
  }
7833
7840
  // 데이터가 없으면 서버가 outline 을 함께 준다 — 그것이 지금 고객이 보는 것의 유일한 요약이다.
7834
7841
  if (r.data === null && r.outline) head.push(outlineText(r.outline));
7835
- return `${head.join('\n')}\n${JSON.stringify(r.data, null, 2)}`;
7842
+ // 원고에 남은 판 메아리는 떼고 준다 — 옛 클라이언트가 몸통에 실어 보낸 `body_rev` 가 저장본에 남은 문서가 있다(실측 #31: 머리줄 74 ·
7843
+ // 원고 안 67). 그 칸을 「받은 판」으로 읽으면 아무도 안 고쳤는데 409 가 되풀이된다. 받은 판은 위 머리줄의 값 하나다(2026-09-30 검토).
7844
+ let data = r.data;
7845
+ if (data && typeof data === 'object' && !Array.isArray(data) && 'body_rev' in data) {
7846
+ data = { ...data };
7847
+ delete data.body_rev;
7848
+ }
7849
+ return `${head.join('\n')}\n${JSON.stringify(data, null, 2)}`;
7836
7850
  }
7837
7851
  // 구조만 본다 — 데이터는 이미 다른 호출로 받을 수 있고, 둘을 같이 실으면
7838
7852
  // 문맥이 두 배가 된다. (같은 말을 데이터 없는 문서에도 쓰므로 함수로 뺐다.)
@@ -7926,6 +7940,7 @@ async function callTool(name, args = {}) {
7926
7940
  // 여기서 뒤집는다 — 확인 전에 고객이 먼저 보는 일을 막는 것은 도구의 몫이다.
7927
7941
  const publish = args.publish === true;
7928
7942
  const body = { ...data, tenant: args.tenant, slug: args.slug, publish };
7943
+ delete body.body_rev; // 본문 판은 서버가 센다 — 다른 문서를 되읽은 원고로 새로 발행할 때 섞여 온 값이 새 문서의 원고에 메아리로 남지 않게(update_report 와 같다)
7929
7944
  if (args.published_at) body.published_at = args.published_at;
7930
7945
  if (args.dry_run) body.dry_run = true;
7931
7946
  const r = await api('POST', '/api/reports', body, PUBLISH);
@@ -7967,6 +7982,12 @@ async function callTool(name, args = {}) {
7967
7982
  return `${r.note}\n${lines.join('\n')}`;
7968
7983
  }
7969
7984
  case 'update_report': {
7985
+ // (2026-09-30) 받은 판 잠금 — 판은 몸통이 아니라 질의(?body_rev=)로 보낸다: 몸통은 통째로 원고(source_json)가 되어
7986
+ // 판이 메아리로 저장되고, 그 원고를 되받아 보내는 쪽이 거짓 409 를 맞는다(서버 api.php PUT 머리말). 값은 그림을 올리기 전에 잰다.
7987
+ const baseRev = args.body_rev;
7988
+ if (baseRev !== undefined && baseRev !== null && !(Number.isInteger(baseRev) && baseRev >= 0)) {
7989
+ throw new Error('body_rev 는 get_report 머리줄의 body_rev=N 값(0 이상의 정수)이어야 합니다.');
7990
+ }
7970
7991
  const data = await materializeAssets(args.data, args.tenant);
7971
7992
  const payload = { ...data, tenant: args.tenant };
7972
7993
  // 노출 손잡이는 떼고 보낸다(2026-09-14). get_report 가 돌려주는 data 에는 발행 때 보낸 몸통이
@@ -7974,6 +7995,7 @@ async function callTool(name, args = {}) {
7974
7995
  // 서버의 `publish: true` 손잡이를 당기면 초안이 열린다. 여는 동사는 release_report 하나다.
7975
7996
  delete payload.publish;
7976
7997
  delete payload.published_at;
7998
+ delete payload.body_rev; // 몸통의 판은 서버가 안 읽는다 — 섞여 오면 떼고 보낸다(원고에 메아리로 남지 않게)
7977
7999
  if (args.dry_run) payload.dry_run = true;
7978
8000
  /*
7979
8001
  * (판 9 · 개인정보 ①) 서버는 읽고 굽는 사이 사람이 공개 상태를 바꿨으면 **아무것도 안 쓰고** 409 를 낸다
@@ -7983,7 +8005,8 @@ async function callTool(name, args = {}) {
7983
8005
  */
7984
8006
  let r;
7985
8007
  try {
7986
- r = await api('PUT', `/api/reports/${Number(args.id)}`, payload, RELEASE);
8008
+ const q = baseRev !== undefined && baseRev !== null ? `?body_rev=${baseRev}` : '';
8009
+ r = await api('PUT', `/api/reports/${Number(args.id)}${q}`, payload, RELEASE);
7987
8010
  } catch (e) {
7988
8011
  if (Number(e?.status) === 409 && String(e?.code || '') === 'report_publish_changed') {
7989
8012
  // ⚠ **상태 확인까지만 잇는다**(판 9 · 오너가 고른 규약) — 재전송을 권하지 않는다. 지금 몸통은 이미 낡았고,
@@ -7992,18 +8015,32 @@ async function callTool(name, args = {}) {
7992
8015
  // 서버가 모르는 것(어느 id 로 부르나)만 보탠다. 행이 사라진 409(`report_gone`)에는 안 붙인다 — 볼 문서가 없다.
7993
8016
  throw new Error(`${e.message}\n→ get_report(id: ${Number(args.id)})`);
7994
8017
  }
8018
+ // (2026-09-30) 받은 판 잠금 409 — 같은 규약으로 상태 확인 동사만 잇는다. 새로 받은 판에 편집을 다시 얹는 것은 스킬 절차 몫이다.
8019
+ if (Number(e?.status) === 409 && String(e?.code || '') === 'report_body_changed') {
8020
+ throw new Error(`${e.message}\n→ get_report(id: ${Number(args.id)})`);
8021
+ }
7995
8022
  throw e;
7996
8023
  }
7997
8024
  // 고친 직후가 「무엇을 아직 안 고쳤나」를 볼 가장 좋은 순간이다.
7998
8025
  if (!r.dry_run) { notesSeen(Number(args.id), r.open_notes); repliesSeen(Number(args.id), r.open_replies); }
8026
+ // 받은 판을 실었는데 응답에 판이 없으면 그 서버는 잠금을 모른다(잠금을 아는 서버는 교체·시험 실행 응답에 늘 판을 싣는다) —
8027
+ // 판을 견주지 않고 받은 것이다. 「409 로 멈춘다」는 약속이 조용히 빠지지 않게 그 사실을 말한다(2026-09-30 검토).
8028
+ const sentRev = baseRev !== undefined && baseRev !== null;
8029
+ const blind = sentRev && !Number.isInteger(r.body_rev);
7999
8030
  if (r.dry_run) {
8000
- return `시험 실행 — 아무것도 바꾸지 않았습니다.\n${r.tenant} · 건 ${r.cases}` + pinsText(r) + warnText(r);
8031
+ const now = Number.isInteger(r.body_rev) ? ` · 지금 본문 판 body_rev=${r.body_rev}` : '';
8032
+ return `시험 실행 — 아무것도 바꾸지 않았습니다.\n${r.tenant} · 건 ${r.cases}${now}`
8033
+ + (blind ? '\n⚠ 이 서버는 받은 판 잠금(?body_rev=)을 모릅니다 — 교체해도 판을 견주지 않고 덮습니다.' : '')
8034
+ + pinsText(r) + warnText(r);
8001
8035
  }
8002
8036
  // 상태를 함께 알린다 — 이 문서를 다음에도 API 로 고칠 수 있는지가 여기서 갈린다.
8003
8037
  const st = r.state ? ` [${r.state}]` : '';
8004
8038
  // 서버가 초안이라고 답하면 이 도구의 동사로 다음 할 일을 잇는다(서버 note 는 HTTP 경로로 말한다).
8005
8039
  const open = r.published === false ? ` 이 도구에서는 release_report(id: ${Number(args.id)}) 가 그 일입니다.` : '';
8006
- return `교체 완료 — ${r.tenant} · 건 ${r.cases}${st}\n열람 주소: ${r.url}\n${r.note || ''}${open}`
8040
+ // 새 판 — 연속 교체의 다음 호출에 body_rev 로 되싣는다(안 알리면 두 번째 교체가 늘 409).
8041
+ const rev = Number.isInteger(r.body_rev) ? ` · 본문 판 body_rev=${r.body_rev}` : '';
8042
+ return `교체 완료 — ${r.tenant} · 건 ${r.cases}${st}${rev}\n열람 주소: ${r.url}\n${r.note || ''}${open}`
8043
+ + (blind ? '\n⚠ 이 서버는 받은 판 잠금(?body_rev=)을 모릅니다 — 받은 뒤 본문이 바뀌었는지 견주지 않고 덮었습니다.' : '')
8007
8044
  + pendingText(r) + pinsText(r) + warnText(r);
8008
8045
  }
8009
8046
  case 'world': {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.24.0",
3
+ "version": "2.25.0",
4
4
  "description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
5
5
  "author": "주식회사 LIM (LIM Inc.)",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -18,7 +18,7 @@
18
18
  "node": ">=18"
19
19
  },
20
20
  "scripts": {
21
- "test": "node smoke.mjs && node append.test.mjs && node task-results-stdio.test.mjs && node notify.test.mjs && node schedule.test.mjs && node windows.test.mjs && bash ../cell/tests/cellcycle.sh",
21
+ "test": "node smoke.mjs && node append.test.mjs && node task-results-stdio.test.mjs && node notify.test.mjs && node schedule.test.mjs && node windows.test.mjs && bash ../cell/tests/cellcycle.sh && python3 ../tests/report-patch.test.py",
22
22
  "test:task-results": "node task-results.test.mjs && node task-results-stdio.test.mjs",
23
23
  "prepack": "find skills -name __pycache__ -type d -prune -exec rm -rf {} + 2>/dev/null; bash ../cell/build-all.sh",
24
24
  "prepublishOnly": "npm test",
@@ -48,17 +48,17 @@ version: "4.0.0"
48
48
  | 3 등급 | `grades` · 건별 `grade` | — | 4단 이하 · 최약 등급이 기본 | 사람 | 등급 이름·개수 |
49
49
  | 4~6 원고 | `report.json` · 코드 작업을 녹이면 원고 밖 상태 표(반드시 지키는 것 3) | — | 요약 근거 · 요지 칸 수(4~6단계) · 절마다 `marg` | 서버 요약 근거 게이트(거절) · 사람 | 공통 원인이 실재하는가 |
50
50
  | 6 도판 굽기 | 도판 HTML → PNG · `plates.json` | `node assets/figure.mjs --batch plates.json --report report.json` · 두 칸 비교는 `shot.mjs --after` → `pair.mjs` | exit 0(✗0 · 합계 2,400,000B 안) | `figure.mjs` R0~R10·R13 · `pair.mjs` · 칩 문구의 뜻은 사람 눈 | — |
51
- | 7 로컬 렌더 | 단일 HTML | `python3 assets/render.py report.json`(소속처 문서면 `--tenant <slug>` — 원고에 `tenant` 가 있으면 생략) | ⚠0 · `forbid` 적중 0 | `gazette.py` `text_scan()`·`brief_checks()`·`tally_scan()` · `build.py guard()` | — |
51
+ | 7 로컬 렌더 | 단일 HTML | `python3 assets/render.py report.json`(소속처 문서면 `--tenant <slug>` — 원고에 `tenant` 가 있으면 생략) | ⚠0 · `forbid` 적중 0 · 단일 HTML 로 전달할 판이면 「보관 그림 N장」 줄 0(그 HTML 은 그림 자리가 상자인 검토본 — 원고에 `out` 이 있으면 그 줄이 ⚠ 로 선다) | `gazette.py` `text_scan()`·`brief_checks()`·`tally_scan()` · `build.py guard()` · `render.py kept_stand_ins()` | — |
52
52
  | 7 dry_run | 경고 목록 | `publish_report`/`update_report` 에 `dry_run: true` | 작성자가 고칠 수 있는 ⚠0 — 못 없애는 경고는 까닭을 핸드오프에 | `report_publish.php report_build_checked()`(렌더러·입고·그림·문체 · 금칙어는 `report_publish.php report_forbid_warnings()`) · `update_report` 는 `comments.php comment_anchor_forecast()`(회신 핀 자리) · 경고 기준선 대조는 사람 | — |
53
- | 7 독자 검토 | 렌더본 텍스트(원고 폴더 밖 빈 폴더) · 워크플로 결과(`merged`·`questions`·`dropped`·`jumps`·`names` — 판 갱신 검토는 `kept`·`refuted`·`weak`·`unverified`) | `python3 assets/render.py report.json --text <원고 폴더 밖 빈 폴더>/본문.txt` → `references/reader-review.workflow.js`(`args.text` 필수, 0.5단계 7행) · 구조를 안 바꾼 낱말 손질 판은 `references/edition-review.workflow.js`(역시 `args.text`) | 사실 틀림 반영 · 선형 읽기 뜀(`jumps`) 가운데 심각도 3 이상 0건 · 만든 이름(`names`) 0건 — 못 없앤 것은 까닭을 핸드오프에 · 결과 머리 「검토 미완」 없음(있으면 다시 돌림) · 「⚠ 도판 N장 미열람」 없음 · 반영 뒤 로컬 렌더·dry_run 다시 | 워크플로 관점 + JS 거름 · `questions`·`dropped`·`refuted`·`rx`(처방 다시) 는 사람 | `questions[]` |
53
+ | 7 독자 검토 | 렌더본 텍스트(원고 폴더 밖 빈 폴더) · 워크플로 결과(`merged`·`questions`·`dropped`·`jumps`·`names` — 판 갱신 검토는 `kept`·`refuted`·`weak`·`unverified`) | `python3 assets/render.py report.json --text <원고 폴더 밖 빈 폴더>/본문.txt` → `references/reader-review.workflow.js`(`args.text` 필수, 0.5단계 7행) · 구조를 안 바꾼 낱말 손질 판은 `references/edition-review.workflow.js`(역시 `args.text`) | 사실 틀림 반영 · 선형 읽기 뜀(`jumps`) 가운데 심각도 3 이상 0건 · 만든 이름(`names`) 0건 — 못 없앤 것은 까닭을 핸드오프에 · 결과 머리 「검토 미완」 없음(있으면 다시 돌림) · 「⚠ 도판 N장 미열람」 없음 — 다만 미열람·「서버 보관」 줄에 찍힌 id 가 모두 원고에서 `data` 가 「(생략 — NB)」 표식인 장이면, 사람이 열람 주소에서 그 장을 열어 보고 id 를 핸드오프에 적으면 통과다(그 밖의 미열람은 다시 돌린다) · 반영 뒤 로컬 렌더·dry_run 다시 | 워크플로 관점 + JS 거름 · `questions`·`dropped`·`refuted`·`rx`(처방 다시) 는 사람 | `questions[]` |
54
54
  | 7 조판 검사 | 검사기 출력 | `gazette-report` 검사기 8종(단일 HTML 전달일 때) | 각 0건 · **표본 수를 함께 본다**(0표본 통과는 통과가 아니다) | 검사기 — 실행법은 `gazette-report` 스킬 | — |
55
55
  | 7 사람 눈 | — | 인쇄 미리보기까지 연다 | `references/process.md` 「7단계 — 검수」 사람의 눈 표 | 사람 | — |
56
- | 8 초안 발행·수정 | 초안 링크 | 새 문서 `publish_report` · 고치면 8단계 「수정 절차」 | 같은 링크 · 사람이 고친 초안을 모르고 덮지 않음 | 사람 — `update_report` 에 `body_rev` 잠금 칸이 아직 없음 | 사람이 고친 초안이면 덮어도 되는가 |
57
- | 9 발행 뒤 대조 | 블록 목록 | `get_report(id, outline: true)` | `aside.marg` 수 = 절 수 · 옛 서식 블록 0 · 빈 블록 0 | 사람(블록 목록을 읽음) — 대조 기계 없음 | — |
56
+ | 8 초안 발행·수정 | 초안 링크 · 고친 판이면 받은 판 JSON 과 `edits.json`(작업 폴더, 발행 안 함) | 새 문서 `publish_report` · 고치면 8단계 「수정 절차」 | 같은 링크 · `patch.py --freeze` 가 `edits.json` 을 씀(받은 판 사본에 두 번째 바꿈 0) · 교체와 그 앞 dry_run 에 받은 판의 `body_rev`(`get_report` 머리줄 값)를 실음 · 사람이 고친 초안을 모르고 덮지 않음 | `patch.py`(옛 값이 다르면 멈춤 · 굳힐 때 멱등 검사) · `update_report` 의 `body_rev` 잠금(받은 뒤 본문 판이 바뀌었으면 서버 409) · 편집기에 저장만 된 사람 초안은 판을 안 올려 잠금에 안 걸림 — 목록의 「덮음」을 사람이 본다 | 사람이 고친 초안이면 덮어도 되는가 |
57
+ | 9 발행 뒤 대조 | 블록 목록 · 고친 판이면 되읽은 JSON(`body_rev` 를 파일 이름에) | `get_report(id, outline: true)` · `get_report(id)` | `aside.marg` 수 = 절 수 · 옛 서식 블록 0 · 빈 블록 0 | 사람(블록 목록을 읽음) — 대조 기계 없음 | — |
58
58
  | 9 열람 확인 | 캡처 | 열람 주소를 연다 | 칩·굵게·링크가 원문 글자가 아니라 서식으로 섬 · 도판 쌍·판단 절이 섬 | 사람 눈 — 못 열면 까닭을 핸드오프에 | — |
59
- | 9a 첨삭 메모 반영 | 반영된 판 · 닫힌 메모 · 까닭을 단 답글 | `list_reports` → `list_notes` → `update_report` → `resolve_note`·`reply_note`(9a단계) | 미반영 메모 0 — 남은 것은 전부 답글로 까닭이 달림 | 사람이 메모를 닮 · 서버는 미반영 수만 셈(`open_notes`, 우리 팀에게만) | 사람이 「메모 해놨다」고 할 때만 돈다 — 무엇을 고칠지 되묻지 않음 |
60
- | 9b 검토 회신 반영 | 회신마다 「어느 물음의 답인가」 대조 · 반영된 판 · 반영 표시 | **개정 전에** `list_notes(id)`(검토 회신이 본문과 함께 실린다) → 원고 반영 → `update_report` → `resolve_note(id, reply, rev)`(9b단계) | 열린 회신 0 — 남은 것은 사람에게 넘긴 까닭이 있음 · 원고에 「회신 없음」이라고 쓴 자리가 회신과 어긋나지 않음 | 서버는 열린 회신 수(`open_replies`)를 목록·되읽기·교체 응답에 싣고, 열린 회신의 문장이 바뀌면 교체 경고로 말함 · 본문 읽기와 대조는 사람·에이전트 | 고객에게 보이는 답글 문안(에이전트는 달지 않는다) |
61
- | 10 공개 직전 날짜 | `report.json` | `update_report` 한 번(날짜만 고치는 이 판은 독자 검토 없이 로컬 렌더·dry_run 만) | 작성일 세 자리가 한 값이고 공개하는 날(3c) | `gazette.py` `date_scan()` ⚠(세 자리가 다르면) · 공개하는 날인지는 사람 | 공개일 |
59
+ | 9a 첨삭 메모 반영 | 반영된 판 · 닫힌 메모 · 까닭을 단 답글 | `list_reports` → `list_notes` → 8단계 「수정 절차」 → `resolve_note`·`reply_note`(9a단계) | 미반영 메모 0 — 남은 것은 전부 답글로 까닭이 달림 | 사람이 메모를 닮 · 서버는 미반영 수만 셈(`open_notes`, 우리 팀에게만) | 사람이 「메모 해놨다」고 할 때만 돈다 — 무엇을 고칠지 되묻지 않음 |
60
+ | 9b 검토 회신 반영 | 회신마다 「어느 물음의 답인가」 대조 · 반영된 판 · 반영 표시 | **개정 전에** `list_notes(id)`(검토 회신이 본문과 함께 실린다) → 8단계 「수정 절차」로 반영 → `resolve_note(id, reply, rev)`(9b단계) | 열린 회신 0 — 남은 것은 사람에게 넘긴 까닭이 있음 · 원고에 「회신 없음」이라고 쓴 자리가 회신과 어긋나지 않음 | 서버는 열린 회신 수(`open_replies`)를 목록·되읽기·교체 응답에 싣고, 열린 회신의 문장이 바뀌면 교체 경고로 말함 · 본문 읽기와 대조는 사람·에이전트 | 고객에게 보이는 답글 문안(에이전트는 달지 않는다) |
61
+ | 10 공개 직전 날짜 | 받은 판 JSON 과 `edits.json`(날짜 세 칸) | 8단계 「수정 절차」로 한 번 — 받은 판에서 날짜 세 칸만 고쳐 `body_rev` 를 실어 교체(날짜만 고치는 이 판은 독자 검토·판 갱신 검토 없이 로컬 렌더·dry_run 만) | 작성일 세 자리가 한 값이고 공개하는 날(3c) | `gazette.py` `date_scan()` ⚠(세 자리가 다르면) · 공개하는 날인지는 사람 | 공개일 |
62
62
  | 10 공개 | 공개된 링크 | `release_report(id)` | 사용자 승인 | 사람(승인 행위) | **반드시** 공개 승인 |
63
63
 
64
64
  ## 렌더러는 레포에 하나뿐이다
@@ -110,7 +110,7 @@ version: "4.0.0"
110
110
  - `sections[].kind` 는 `summary|status|cases|asks|note` 다섯 가지뿐 — 그 밖의 값은 절이 통째로 사라지므로 서버가 거절한다.
111
111
  - `cases[].grade` 는 `grades` 에 정의된 이름이어야 한다.
112
112
  - **`update_report` 는 같은 주소를 덮어쓴다** — 이미 고객에게 나간 링크가 새 내용을 가리키므로, 발행 후 수정은 재발행이 아니라 update 로 한다. 기존 문서는 `list_reports`/`get_report` 로 받아 고쳐 되돌려 보낸다.
113
- - **그림 계약**: `update_report` 에서 `assets` 를 **빼고** 보내면 서버가 저장된 그림을 그대로 유지한다. 전부 지우려면 `assets: []` 를 명시하고 `sections[].plates`·`chapters[].asset` 에서도 그 id 를 빼야 한다 — 가리키는 곳이 없는 id 는 422. 한 장만 바꾸려면 그 id 에 새 `source`/`file`, 나머지는 `get_report` 가 준 표식 그대로.
113
+ - **그림 계약**: `update_report` 에서 `assets` 를 **빼고** 보내면 서버가 저장된 그림을 그대로 유지한다 — 캡션·출처 줄·alt 같은 도판 글 칸도 저장값 그대로다. 글 칸을 고쳤으면 `assets` 를 `get_report` 표식째 목록으로 보낸다(서버가 id 로 바이트를 잇고 글 칸은 보낸 값을 쓴다). 전부 지우려면 `assets: []` 를 명시하고 `sections[].plates`·`chapters[].asset` 에서도 그 id 를 빼야 한다 — 가리키는 곳이 없는 id 는 422. 한 장만 바꾸려면 그 id 에 새 `source`/`file`, 나머지는 `get_report` 가 준 표식 그대로.
114
114
  - 초안 상태로 올려 두고 다듬어도 발행은 막히지 않는다. 단 초안 `update_report` 도 **본문 판(`body_rev`)** 을 올리고 사용자가 그 판을 읽으므로 **독자 검토를 통과한 본문 판만 올린다** — 다듬는 중간 판은 로컬 렌더·dry_run 으로 본다. 사용자가 「바로 반영」을 지시하면 먼저 올리고 검토는 곧바로 뒤따라 돌려 다음 판에 반영한다(2026-09-15 사용자 결정).
115
115
  **발행 이력 행(`history`)은 다른 것이다** — 공개 전 수정은 이력 행을 만들지 않는다(3c).
116
116
  - 내용 설계 규칙(아래 0~3단계, 고객 언어 치환, 카피 게이트)은 **전달 경로와 무관하게 동일하게** 적용된다 — 서버가 렌더한다고 해서 원고 품질 단계를 건너뛰지 않는다.
@@ -323,10 +323,23 @@ report.json + cases.json 을 하나로 합쳐 `publish_report` 에 준다
323
323
  **기본은 초안이다.** 도구가 `publish` 를 참으로 주지 않는 한 고객에게 안 보인다.
324
324
  공개 직전 날짜를 맞춘 뒤(3c) `release_report(id)` 로 연다 — 그것이 사람의 승인 행위다(체크리스트 10단계).
325
325
 
326
- **수정 절차** — `get_report` 머리줄에서 판번호(`body_rev`)와 사람 편집 초안 여부를 본다 → 받은 JSON 을 백업 → 자리마다 지금 값이
327
- 기대와 같은지 확인하며 고친다 → 로컬 렌더 ⚠0 → dry_run → 독자 검토 → `update_report` → 9단계 두 행. **같은 링크가 새 내용을 가리킨다.**
328
- 사람이 고친 초안이면 덮기 전에 묻는다 — `update_report` 에 `body_rev` 잠금 칸이 아직 없어 기계가 막지 않는다.
329
- 그림은 서버가 보관한다 — `assets` 를 빼고 보내면 유지, `assets: []` 면 삭제(plates 에서도 빼야 422 가 안 난다), 바꿀 장만 새 `source`/`file`.
326
+ **수정 절차** — 올라 있는 판에서 출발한다. **같은 링크가 새 내용을 가리킨다.**
327
+
328
+ 1. `list_reports` 줄에 「덮음: …」(사람의 편집 초안·사람이 발행한 본문)이 있으면 덮기 전에 묻는다 — 문서 번호를 들었어도 이 목록으로 본다(`get_report` 머리는 사람의 편집 초안을 안 보인다). 받은 판은
329
+ `node assets/mcp-call.mjs --no-mail --json get_report '{"id":<id>,"tenant":"<slug>"}' <파일>` 로 작업 폴더에 받는다 — 파일에는 JSON 한 덩어리만 쓰고 머리·꼬리 줄은 화면에 보인다(`patch.py`·`render.py` 는 머리줄 붙은 파일을 못 읽는다). 파일 이름에 화면에 보인 `body_rev` 를 넣는다 — 그 머리줄 값이 「받은 판」이고, 파일 안 JSON 에는 판이 없다(옛 저장본에 `body_rev` 칸이 메아리로 남은 문서가 있어 되읽기가 떼고 준다). 지난 교체 뒤 되읽어 둔 판이 있어도 다시 받는다 — `body_rev` 는 내용이 같아도 오르니, 그 사이 누가 고쳤는지는 둔 판과 내용으로 견준다.
330
+ 2. 고칠 자리를 draft 에 적는다 — `set` 은 `new` 만, `replace` 는 옛 조각·새 조각. `python3 assets/patch.py <1의 파일> draft.json --freeze edits.json` 이 `set` 의 `old` 를 1의 파일에서 채우고, 그 사본에 두 번 돌려 멱등일 때만 `edits.json` 을 쓴다. 새로 받은 판으로 굳히지 않는다.
331
+ 3. 새 판은 1의 파일을 복사해 `python3 assets/patch.py <새 판> edits.json` 으로 만든다 — `patch.py` 는 준 파일을 제자리에서 고친다. 새 판 이름에 예상 판번호를 쓰지 않는다(`rev` 번호는 서버에서 받은 판에만).
332
+ 4. `python3 assets/render.py <새 판> --text <원고 폴더 밖 빈 폴더>/본문.txt` 로 새 판을 그대로 굽는다(렌더 사본을 따로 짓지 않는다) — 보관 그림은 자리 상자로 서고 그 HTML 은 검토본이다. ⚠0 을 본다. 바꾸거나 더한 그림처럼 `file`·`source` 로 준 장은 로컬에서 「그림 바이트가 없습니다」 ⚠ 로 빠지고 그 캡션도 본문.txt 에서 빠진다 — 그 줄은 빼고 읽는다.
333
+ 5. 교체 인자는 새 판으로 만든다. `patch.py` 가 「도판 칸을 고친 편집」을 알리지 않았으면 `assets` 를 뺀다(서버가 저장된 도판을 그대로 잇는다). 알렸으면(캡션·출처 줄·alt 를 고쳤거나 그림을 바꿈) `assets` 를 새 판 그대로 싣는다 —
334
+ 표식은 표식째 두면 서버가 id 로 바이트를 잇고 글 칸은 보낸 값을 쓴다. 그림을 바꿀 장은 `set assets[id=…]` 에 객체를 통째로(`file` 포함) 준다(`file` 칸만 `set` 하면 없는 칸이라 멈춘다). 늘 싣지 않는 까닭: 목록으로 보내면 아직 그리는 중인 그림 자리가 처음부터 다시 걸린다.
335
+ → `update_report(dry_run: true, body_rev: <받은 판>)` — 받은 뒤 본문 판이 바뀌었으면 여기서 409 다(6단계의 409 절차로). 「이 서버는 받은 판 잠금을 모릅니다」 줄이 나오면 교체가 판을 견주지 않는다는 뜻이니 교체 전에 사용자에게 알린다.
336
+ → 판 갱신 검토(`edition-review.workflow.js` — `edits` 에 `edits.json`, `backup` 에 1의 파일, `file` 에 3의 새 판, `text` 에 4의 본문.txt, `patch` 에 2·3단계에서 쓴 그 `patch.py` 의 절대경로, 수치를 고친 판이면 `canon` 에 정본을 가리키는 한 줄 — 파일과 자리). 구조를 바꾼 판이면 독자 검토다.
337
+ 6. 교체는 `update_report` 에 받은 판의 `body_rev` 를 실어 한다(1에서 머리줄에 보이고 파일 이름에 넣은 값 — 시험 실행이 알려 주는 「지금 판」이 아니다) — 받은 뒤 본문 판이 바뀌었으면(다른 교체 · 편집기 발행 · 그림이 앉아 다시 구운 것) 서버가 409 로 멈추고 아무것도 안 쓴다(2026-09-30 받은 판 잠금).
338
+ 409 면 새로 받은 판을 1의 파일 자리에 두고(이름에 새 `body_rev`) `edits.json` 을 다시 적용해 3~5 를 다시 돈다(`backup` 도 그 판) — `set`·`replace` 자리는 남이 고쳤으면 멈추지만, 줄 op 는 남의 고침을 못 가린다(`drop_rows`·`remove_row` 는 라벨만 보고 남이 고친 행도 빼고, `append_row`·`insert_row` 는 남이 붙인 행 곁에 또 붙인다). 그래서 다시 적용하기 전에 옛 1의 파일과 새로 받은 판을 diff 해 그 표부터 본다.
339
+ 교체되면 출력에 「본문 판 body_rev=N」(새 판)이 찍혔는지 본다 — 없으면 판을 견주지 않고 덮인 것이니(잠금을 모르는 옛 서버이거나, `body_rev` 인자를 모르는 옛 MCP 가 떠 있다) 되읽어 1의 파일과 견준다.
340
+ → 9단계 두 행 → 되읽은 JSON 을 새 `body_rev` 로 둔다.
341
+
342
+ 그림은 서버가 보관한다 — `assets` 를 빼고 보내면 그림도 도판 글 칸(캡션·출처 줄·alt)도 저장된 그대로, `assets: []` 면 삭제(plates 에서도 빼야 422 가 안 난다), 바꿀 장만 새 `source`/`file`(나머지는 표식 그대로).
330
343
 
331
344
  ### 9단계 — 발행 뒤 판정: 블록 목록으로 본다
332
345
 
@@ -349,10 +362,10 @@ report.json + cases.json 을 하나로 합쳐 `publish_report` 에 준다
349
362
 
350
363
  | 순서 | 도구 | 할 일 |
351
364
  |---|---|---|
352
- | 1 | `list_reports` | 줄 끝에 `첨삭 메모 N건 미반영` 이 붙은 문서를 찾는다. 문서 번호를 들었으면 건너뛴다 |
365
+ | 1 | `list_reports` | 줄 끝에 `첨삭 메모 N건 미반영` 이 붙은 문서를 찾는다. 문서 번호를 들었어도 그 줄의 「덮음: …」은 본다(사람의 편집 초안은 이 목록만 알린다) |
353
366
  | 2 | `list_notes(id)` | 미반영 메모를 읽는다. `anchor.path`(예 `case:k7m2p9/fix`)가 **고칠 칸**, `anchor.quote_exact` 가 사람이 **본 글**이다. 답글은 사람이 덧붙인 설명이다 |
354
- | 3 | `get_report(id)` | 발행 데이터를 받는다. 머리 두 줄의 정본이 발행물이면 덮기 전에 무엇을 덮는지 본다 |
355
- | 4 | `update_report(dry_run: true)` → `update_report` | 메모마다 그 칸을 고쳐 **한 번에** 보낸다. `uid` 는 받은 값 그대로 되돌린다 — 흘리면 메모가 자리를 잃는다. 초안은 초안 그대로 남는다 |
367
+ | 3 | 8단계 「수정 절차」 1~5 | 받은 판을 받아 두고, 메모마다 고칠 칸을 draft 에 적어 `--freeze` 로 굳혀 새 판을 만들고, 로컬 렌더 · dry_run(받은 판의 `body_rev` 를 실어) · 판 갱신 검토까지 돈다 |
368
+ | 4 | 8단계 「수정 절차」 6 | 메모를 모두 반영한 판을 **한 번에** `update_report(body_rev: …)` 로 보낸다. `uid` 는 받은 값 그대로 남는다(`patch.py` 는 고친 칸만 바꾼다) — 흘리면 메모가 자리를 잃는다. 초안은 초안 그대로 남는다 |
356
369
  | 5 | `resolve_note(id, note, rev)` | **고친 뒤에만.** 반영이 실린 `body_rev` 를 함께 준다. 먼저 부르면 미반영 목록에서 빠져 다시 안 보인다 |
357
370
  | 6 | `reply_note(id, note, body)` | 못 고치거나 다르게 본 메모는 **닫지 말고** 그 메모에 이유를 답글로 단다 |
358
371
  | 7 | `remember_case`·`remember_decision`(메모가 반려일 때) | 반려를 팀 기억에 남길 때 「스킬 반영: `references/history.md` §n / 미반영(까닭)」 한 줄을 함께 적는다. 반려가 작업하며 지은 이름을 짚었으면 그 이름과 독자 화면의 원래 이름을 그 반려 결정(`remember_decision`)의 `rejected_names` 칸에 `{name, use}` 로 적는다(흔한 낱말이면 함께 설 낱말을 `near` 에) — 같은 소속처의 발행·모의 발행·로컬 렌더가 그 목록으로 센다. 확인은 `list_rejected_names`. 운영 반영 확인 전(2026-09-26) — 서버 배포 전에는 이 칸이 안 적힌다(응답이 「이 서버는 아직 그 칸을 모른다」고 말한다) |
@@ -367,7 +380,7 @@ report.json + cases.json 을 하나로 합쳐 `publish_report` 에 준다
367
380
  |---|---|---|
368
381
  | 1 | `list_notes(id)` | 검토 회신이 **본문과 함께** 실린다. `[회신 N]` 줄의 자리(「판단 항 「…」」·건·절·도판)가 **어느 물음에 대한 말인지**, 그 아래가 누가(고객/우리) 무슨 말을 했는지다. 답글은 뿌리 밑에 붙는다 |
369
382
  | 2 | (대조) | 회신마다 원고와 맞춰 본다 — 이미 답이 온 판단 항을 **열린 항으로 다시 묻고 있지 않은가**, 원고가 「회신에 언급이 없어 미결」이라고 쓴 자리가 회신과 어긋나지 않는가, 회신의 낱말과 정본(고객 문서)의 낱말이 갈리면 정본 낱말로 맞췄는가 |
370
- | 3 | `update_report(dry_run: true)` → `update_report` | 반영한다. 답이 온 판단 항은 닫힌 항 표로 옮기고 출처에 회신 날짜를 적는다. 교체 경고의 「열린 검토 회신 …」 줄은 회신의 본문과 함께 온다 — 그 회신을 이 판이 반영한 것이면 그대로 발행한다 |
383
+ | 3 | 8단계 「수정 절차」 1~6 | 반영한다(받은 판 → draft · `--freeze` → 새 판 → 로컬 렌더 · dry_run(`body_rev` 를 실어) · 판 갱신 검토 → `update_report(body_rev: …)`). 답이 온 판단 항은 닫힌 항 표로 옮기고 출처에 회신 날짜를 적는다. 교체 경고의 「열린 검토 회신 …」 줄은 회신의 본문과 함께 온다 — 그 회신을 이 판이 반영한 것이면 그대로 발행한다 |
371
384
  | 4 | `resolve_note(id, reply, rev)` | **반영한 뒤에만.** 표시해도 회신은 고객에게 그대로 보인다. 문서에 반영할 것이 없는 회신(인사·이미 답한 물음)도 읽었으면 표시한다 |
372
385
  | 5 | `remember_case`·`remember_decision`(회신이 반려일 때) | 9a단계 7행과 같다 — 팀 기억에 「스킬 반영: `references/history.md` §n / 미반영(까닭)」 한 줄, 작업하며 지은 이름을 짚은 반려면 그 반려 결정의 `rejected_names` 칸에 그 이름과 원래 이름을 적는다 |
373
386
 
@@ -390,6 +403,8 @@ render·css·폰트를 함께 얹는다. 직접 부르면 옛 서식 시트가
390
403
  눈은 "보이는 결함"을 잡는다. 실제로 자동 검수를 다 통과한 문서에서 눈으로만 잡힌 것들:
391
404
  안 보이는 배지, 빈 목록 항목, 한국어 낱말 중간 줄바꿈.
392
405
 
406
+ **보관 그림 상자가 있는 HTML 은 전달본이 아니다** — `render.py` 가 「보관 그림 N장」을 말했으면 그 파일은 검토본이다. 전달본은 그림 바이트(`data:`)가 실린 원고로 굽는다.
407
+
393
408
  파일 하나를 그대로 보낸다. 상대는 더블클릭만 하면 된다.
394
409
 
395
410
  ### 7단계 — 조판 검사: `gazette-report` 스킬의 8종
@@ -406,15 +421,15 @@ render·css·폰트를 함께 얹는다. 직접 부르면 옛 서식 시트가
406
421
 
407
422
  | 파일 | 역할 |
408
423
  |---|---|
409
- | `assets/render.py` | `report.json` → 단일 HTML. **서버 등록부에서 서식을 읽어** 레포 `private/builder/build.py` 를 부른다 — 여기 사본을 두지 마라. `--text <출력.txt>` 는 렌더가 성공한 뒤 화면에 보이는 글만 화면 순서대로 텍스트로 뽑는다(그림 자리 「[그림]」 · 표 칸 「 \| 」 · `origin`·alt·스크립트 없음) — 독자 검토의 입력이고, 원고 폴더 안에 쓰면 ⚠ 가 뜬다. `--tenant <slug>`(없으면 원고의 `tenant`)는 그 소속처가 반려한 이름을 옆 `mcp-call.mjs` 로 MCP `list_rejected_names` 를 불러 받아 함께 센다 — 못 받으면 ⚠ 한 줄을 내고 반려 이름만 빼고 굽는다(`references/lim-portal-format.md` §10-1) |
410
- | `assets/patch.py` | 원고 **부분 고치기(멱등)** — `python3 assets/patch.py report.json edits.json`. 회차마다 고치는 파이썬을 새로 짓지 말고 **데이터(edits.json)만** 남긴다. op 다섯: `set`·`replace`·`drop_rows`·`append_row`·`forbid`. 두 번 돌려 「바꿈 0」이 계약이고, 값이 `old` 도 `new` 도 아니면 **멈춘다**(그 사이 누가 고쳤다는 뜻). 회차 스크립트가 쌓여 고객사 문서 폴더가 플랫폼이 되던 것을 멈추려고 들였다(2026-09-22 — 한 폴더 2,563줄·다른 폴더 4,285줄 실측) |
424
+ | `assets/render.py` | `report.json` → 단일 HTML. **서버 등록부에서 서식을 읽어** 레포 `private/builder/build.py` 를 부른다 — 여기 사본을 두지 마라. `--text <출력.txt>` 는 렌더가 성공한 뒤 화면에 보이는 글만 화면 순서대로 텍스트로 뽑는다(그림 자리 「[그림]」 · 표 칸 「 \| 」 · `origin`·alt·스크립트 없음) — 독자 검토의 입력이고, 원고 폴더 안에 쓰면 ⚠ 가 뜬다. `get_report` 로 받은 원고도 그대로 굽는다(받을 때는 `mcp-call.mjs --json` — 머리줄이 붙으면 못 읽는다) — `out` 이 없으면 원고 이름 옆 `.html` 로 쓰고, 도판(figure) 표식은 서버 기록 치수의 자리 상자로 그린다(`render.py kept_stand_ins()` — ⚠ 대신 「보관 그림 N장」 한 줄, 그 HTML 은 검토본). 로고 표식은 표지에서 고객사 이름 활자로 선다. 원고에 `out` 이 있으면(작성 원고 — 전달 경로) 그 한 줄은 ⚠ 로 선다. `--tenant <slug>`(없으면 원고의 `tenant`)는 그 소속처가 반려한 이름을 옆 `mcp-call.mjs` 로 MCP `list_rejected_names` 를 불러 받아 함께 센다 — 못 받으면 ⚠ 한 줄을 내고 반려 이름만 빼고 굽는다(`references/lim-portal-format.md` §10-1) |
425
+ | `assets/patch.py` | 원고 **부분 고치기(멱등)** — `python3 assets/patch.py report.json edits.json`. 회차마다 고치는 파이썬을 새로 짓지 말고 **데이터(edits.json)만** 남긴다. op 일곱: `set`·`replace`·`drop_rows`·`remove_row`·`append_row`·`insert_row`·`forbid`(칸과 뜻은 머리 주석 op 표). 표 줄은 번호 대신 첫 칸 글자로 가리킨다 — `rows[label=…]` 는 앞 op 가 줄을 끼우거나 빼도 같은 줄을 찾고, 그 글자인 줄이 하나가 아니면 멈춘다(2026-09-30 — 9/29 수정 판에서 통째로 바꾼 표 여섯을 줄 op 로 다시 써도 공개본과 같았다). 그 줄의 **첫 칸을 바꾸는** 편집은 `[label=…]` 로 적지 않고 번호 경로로 적는다 — 첫 실행 뒤 그 글자가 없어져 두 번째 실행이 줄을 못 찾으므로 돌리기 전에 멈춘다. 배열 원소는 `assets[id=…]` 처럼 `id` 로도 가리키고(끝자리여도 된다), 없는 칸은 만들지 않고 멈춘다. 두 번 돌려 「바꿈 0」이 계약이고, 값이 `old` 도 `new` 도 아니면 **멈춘다**(그 사이 누가 고쳤다는 뜻). `set` 의 `old` 는 손으로 옮기지 않는다 — draft 에 `new` 만 적고 `python3 assets/patch.py <받은 판> draft.json --freeze edits.json` 이 받은 판에서 채운다. 받은 판 사본에 두 번 돌려 첫째 건너뜀 0 · 둘째 바꿈 0 일 때만 OUT 을 쓰고, 줄을 끼우거나 뺀 표의 칸을 번호로 가리키는 `old` 없는 `set` 은 굳히지 않으며(`[label=…]` 로 가리킨다), 적용에 `old` 없는 `set` 이 오면 멈춘다. 편집이 도판 칸(`assets…`)을 고치면 「교체 인자에 assets 를 표식째 싣는다」를 알린다(`patch.py freeze()` — 2026-09-29 수정 판에서 긴 표 행의 `old` 를 스크래치 스크립트로 채웠던 자리). 회차 스크립트가 쌓여 고객사 문서 폴더가 플랫폼이 되던 것을 멈추려고 들였다(2026-09-22 — 한 폴더 2,563줄·다른 폴더 4,285줄 실측) |
411
426
  | `assets/xlsx-plate.py` | 스프레드시트 칸을 **근거 도판**으로 — `python3 assets/xlsx-plate.py 파일.xlsx 시트 B40:E48 out.png --html wrap.html`. 원본은 읽기만 하고 사본에서 인쇄 범위를 잘라 LibreOffice → PDF → PNG. ⚠ **LibreOffice 가 시스템 폰트를 못 보는 기계가 있다** — 그때 한글만 빠진 그림이 조용히 나가므로, 렌더 뒤 PDF 에 한글이 실렸는지 스스로 확인하고 없으면 멈춘다(2026-09-22 macOS 실측). 나온 PNG 는 감싸개 HTML 로 `figure.mjs` 관문을 지나야 도판이 된다 |
412
427
  | `assets/subset_font.py` | 폰트 서브셋 생성 (보통 실행 불필요) |
413
428
  | `assets/example/report.json` · `cases.json` | 관보 필드를 전부 채운 본보기. 새 문서는 여기서 베낀다 |
414
429
  | `assets/diagram.mjs` | Archify 도해를 관보 `diagrams[].svg` 로 굳힌다 — `render <type> <ir.json> <out.svg> --wide --min-text 16 --tight`(Archify deliver → 계산된 스타일을 속성으로 → 클래스·스크립트·애니메이션 걷음 → 작은 글자 키움·빈 띠 조임·겹침 측정) · `export <html> <out.svg>` · `inspect <svg>`. 렌더러와 같은 잣대(바이트 250KB · viewBox · 표시 글자 15px)로 재고 ✗ 면 exit 3. Archify 자체는 동봉하지 않는다(`references/diagrams.md` §1) |
415
430
  | `assets/figure.mjs` · `shot.mjs` · `mockshot.mjs` · `pair.mjs` | 도판 도구 — 도판 HTML 을 굽고 가독성·틀 문법 관문(R0~R10·R13)을 잰다, `--batch plates.json` 은 일괄 판정과 예산 합계, `--spec` 은 계약 상수(`figure.mjs`) · 웹 화면을 검정 원 번호 표식과 함께 찍고 `--after` 로 블록을 숨긴 전후 두 장을 같은 구간으로 찍는다(`shot.mjs`) · 고객 앱 화면 도안을 2배 밀도로 찍는다(`mockshot.mjs`, `references/visual-first.md` 「화면 도안」) · 전후 두 컷을 좌우 같은 틀·같은 배율로 조립하고 판정까지 찍는다(`pair.mjs`, 본보기 `assets/example/plate-pair.html`) |
416
431
  | `references/linear-read-test.workflow.js` | 선형 읽기 **실측** — 처음 읽는 사람 셋 + 결정권자가 앞에서 뒤로 한 번 읽으며 뛰어야 하는 자리를 전부 센다(`args: {text, file, reader, round}` — `text`(필수)는 `render.py --text` 로 뽑은 렌더본 텍스트이고 읽는 넷은 이 파일 하나만 읽는다. `file`(필수)은 그 렌더본을 뽑은 `report.json` 이고 렌더본이 원고보다 새로운지 재는 데만 쓴다 — 읽는 넷에게는 주지 않는다. 둘 중 하나가 없거나 렌더본이 비었거나 원고보다 오래되면 읽기 전에 멈춘다. 재측정은 `round` 를 바꿔야 캐시를 안 탄다). 뜀 0 이 목표. 독자 검토(`reader-review.workflow.js`)의 linear 관점이 같은 읽기를 하나로 한다 |
417
- | `references/edition-review.workflow.js` | **판 갱신 검토** — 구조는 그대로이고 낱말·출처 줄·여백 주석만 고친 판의 **변경 자리**(edit-N.py 의 put/sub 호출)를 규칙 대조·사실 대조로, 새 판 렌더본 전체를 선형 독자로 읽고 심각도 3 이상은 진단과 처방을 따로 반박한다(`args: {file, backup, edit_script, doc, text}` — 결과 `kept`(진단이 거짓 아님, 처방이 규칙에 걸리면 `rx`)·`refuted`(진단 거짓)·`weak`·`unverified`(상한 밖·검증 실패), 실패가 있으면 `head` 「검토 미완」). 사실 관점이 백업↔새 판 JSON diff 에 스크립트가 바꾼 자리가 빠짐없이 있는지(삽입·삭제가 섞이면 줄 수가 아니라 자리로)·수치 무변경을 검산한다. 구조가 바뀐 판은 `reader-review.workflow.js`. 낱말 손질 판을 검토 없이 발행하자 다음 판 8자리 손질이 생겼다(2026-09-15) |
432
+ | `references/edition-review.workflow.js` | **판 갱신 검토** — 구조는 그대로이고 낱말·출처 줄·여백 주석만 고친 판의 **변경 자리**(`assets/patch.py` 의 편집 목록 `edits.json` — 옛 `edit-N.py` 의 put/sub 호출도 받는다)를 규칙 대조·사실 대조로, 새 판 렌더본 전체를 선형 독자로 읽고 심각도 3 이상은 진단과 처방을 따로 반박한다(`args: {file, backup, edits, doc, text}`, 편집 스크립트면 `edits` 대신 `edit_script` · `patch` 는 편집 목록을 굳힌 그 patch.py 의 경로(기본 스킬 설치본 — 없거나, 그 목록을 못 읽는 판이거나, 새 판에 편집이 아직 적용돼 있지 않으면 시작 전 검사에서 멈춘다) · `canon` 은 수치 편집을 대조할 정본을 가리키는 한 줄(안 주면 검토자가 `doc` 글·`handoff` 에서 찾고, 어디에도 없으면 「정본 없음」으로 적는다) · `renderer` 는 규칙 관점과 반박이 읽을 렌더러 — 결과 `kept`(진단이 거짓 아님, 처방이 규칙에 걸리면 `rx`)·`refuted`(진단 거짓)·`weak`·`unverified`(상한 밖·검증 실패), 실패가 있으면 `head` 「검토 미완」). 사실 관점이 백업↔새 판 JSON diff 에 바뀐 자리가 모두 기록에 있는지(줄 수가 아니라 자리로) 검산한다 — 편집 목록이면 백업 사본에 `patch.py` 를 적용한 결과가 새 판과 같은지와 수치를 바꾼 편집 전부, 편집 스크립트면 수치 무변경. 구조가 바뀐 판은 `reader-review.workflow.js`. 낱말 손질 판을 검토 없이 발행하자 다음 판 8자리 손질이 생겼다(2026-09-15) |
418
433
  | `assets/example/report-briefing.json` · `cases-briefing.json` | 「결정한다(R&D 브리핑)」 모드 본보기 — 물음 제목 · 두 길 대칭 한눈표 · 선택지 전치 행렬 · FCG 의견 표 한 장 · 바꾸는 길의 위험 · 고른 길별 일정 · `cta` 기본 `[]` |
419
434
  | `assets/example/report-briefing-designs.json` · `cases-briefing-designs.json` | 「결정한다」 도안 결재형 본보기 — 세 길 · 전폭 선택지 도판(자리 표시) · 조건표 · 선택지마다 한 행인 의견 표 · 판단 항목 둘(물음 + 「… 확인 요청」) · 확인 프로필 표(`references/lim-portal-format.md` §12.3b) |
420
435
  | 폰트 | **레포** `private/builder/fonts/pretendard-gazette.woff2` — 가변 1면 |
@@ -448,7 +463,7 @@ render·css·폰트를 함께 얹는다. 직접 부르면 옛 서식 시트가
448
463
  **결정 주체** — 결정권이 고객에게 있는 항은 고객이 정한 기록이 있고 그 항이 원문에서 실제로 닫혀 있을 때만(「정해야 함·차기」로 남아 있지 않을 때) 사실로 배경에 쓴다.
449
464
  기록이 없거나 FCG·내부 대화에서 정했으면 사실로 쓰지 않고 결정 물음이나 FCG 의견 절로 둔다. FCG 권한 안의 작업 결정(작업 순서·일정)은 「FCG 작업」으로 사실 서술한다.
450
465
  본문에는 결정 출처·날짜를 적지 않는다 — 확인은 결정 장부(0단계 물음 4)에서 한다. 집행자: 사람 눈(0a) · 독자 검토 facts 관점 · 주체 없는 결정 명사(「정한 사항」·「결정된 사항」·「결정임」)는 `text_scan()` 과정 어휘 ⚠.
451
- 3c. **공개 전 수정은 판이 아니다.** `history` 는 공개할 판 한 행(「최초 발행」)이고, `meta` 의 작성 · 판권지 작성 · `history` 마지막 `date` 는 **한 값**이며 공개하는 날로 맞춘다(체크리스트 10단계 — 공개 직전 `update_report` 한 번).
466
+ 3c. **공개 전 수정은 판이 아니다.** `history` 는 공개할 판 한 행(「최초 발행」)이고, `meta` 의 작성 · 판권지 작성 · `history` 마지막 `date` 는 **한 값**이며 공개하는 날로 맞춘다(체크리스트 10단계 — 공개 직전 8단계 「수정 절차」로 한 번).
452
467
  공개 뒤 내용이 바뀐 update 에만 `history` 행을 더한다. 문서 밖 결정의 날짜는 본문에 적지 않는다. `cover_date` 는 요약면 「기준」 날짜(수치의 기준일)라 이 셋과 대조하지 않는다.
453
468
  집행자: `gazette.py` `date_scan()` ⚠ — 세 자리를 년·월·일로 견줘 다르면 센다(두 경로 · `lim-portal-format.md` §10-1). 공개하는 날인지와 `history` 행 수는 사람(체크리스트 행) · 공개 때 날짜 안내(`release_report`)는 아직 기계가 없다(경위: `references/history.md` §12).
454
469
  4. **확인 못 한 범위를 반드시 적는다.** 감추면 다음 보고서까지 못 믿게 된다.
@@ -13,17 +13,25 @@
13
13
  //
14
14
  // ## 쓰는 법
15
15
  //
16
- // node assets/mcp-call.mjs [--no-mail] <도구 이름> <인자.json> [결과.txt]
16
+ // node assets/mcp-call.mjs [--no-mail] [--json] <도구 이름> <인자.json> [결과.txt]
17
17
  //
18
18
  // node assets/mcp-call.mjs update_report args-dry.json out-dry.txt
19
19
  // node assets/mcp-call.mjs get_report '{"id":<id>,"tenant":"<tenant>"}' out.txt # 작은 인자는 인라인도 받는다
20
+ // node assets/mcp-call.mjs --no-mail --json get_report '{"id":<id>,"tenant":"<tenant>"}' report.rev28.json # 받은 판 — JSON 만
21
+ //
22
+ // `--json` — 도구 출력에서 **줄 머리에서 시작하는 첫 JSON 값**만 결과 파일(없으면 표준 출력)에 쓰고, 머리줄·꼬리 줄(판번호·
23
+ // 그림 보관 안내·메일·세계 소식)은 표준 오류로 보인다. get_report 출력을 그대로 쓰면 patch.py·render.py 가 JSON 을 못 읽는다
24
+ // (머리만 떼도 꼬리 줄이 남는다 — 2026-09-30 적대 검토). JSON 이 없거나 도구가 오류를 내면 종료 1 이고 **결과 파일을 쓰지 않는다**
25
+ // (같은 이름으로 받아 둔 판을 오류 글로 덮지 않는다 — 까닭은 표준 오류로 보인다).
20
26
  //
21
27
  // `--no-mail` — 프로그램이 한 번 읽고 버리는 호출용(render.py·발행 직전 훅). 부르는 쪽 세션의 신원을 자식에게
22
28
  // 안 물려준다. 물려주면 genetics.mjs 가 그 세션으로 알고 도구를 부를 때마다 그 세션 앞 메일을 긷고
23
29
  // 「받음」 도장(/api/mail/ack)을 찍는데, 이 파일은 응답을 쓰고 서버를 죽이므로 그 메일은 아무도 못 읽는다.
24
30
  // 옵션이 없으면 지금과 같다(부모 env 를 그대로 물려준다).
25
31
  //
26
- // 서버 위치는 이 파일의 자리에서 찾고(스킬은 레포 안에 산다), `GENETICS_MCP` 로 덮어쓸 수 있다.
32
+ // 서버 위치(정본은 이 줄 — 다른 문서는 여기를 가리킨다): `GENETICS_MCP` → 이 파일의 자리(저장소 안의 스킬이면 ../../../genetics.mjs) → 없으면
33
+ // `npx -y genetics-mcp`(게시본). `GENETICS_MCP` 가 없는 파일을 가리키면 종료 2 로 멈춘다 — 다음 자리로 넘어가지 않는다.
34
+ // npm 설치본은 스킬을 ~/.claude/skills 로 **복사**해 깔아 옆에 genetics.mjs 가 없다 — 그 기계에서도 같은 명령이 돈다(2026-09-30 적대 검토).
27
35
  // 종료 코드: 0 성공 · 1 도구·프로토콜 오류 · 2 쓰는 법 틀림.
28
36
  import { spawn } from 'node:child_process';
29
37
  import fs from 'node:fs';
@@ -32,17 +40,20 @@ import { fileURLToPath } from 'node:url';
32
40
 
33
41
  const HERE = path.dirname(fileURLToPath(import.meta.url));
34
42
  // assets/ → genetics-report/ → skills/ → mcp/genetics.mjs
35
- const SERVER = process.env.GENETICS_MCP || path.resolve(HERE, '../../../genetics.mjs');
43
+ const LOCAL = path.resolve(HERE, '../../../genetics.mjs');
44
+ const SERVER = process.env.GENETICS_MCP || (fs.existsSync(LOCAL) ? LOCAL : null);
45
+ const SERVER_CMD = SERVER ? ['node', [SERVER]] : ['npx', ['-y', 'genetics-mcp']];
36
46
 
37
47
  const argv = process.argv.slice(2);
38
48
  const noMail = argv.includes('--no-mail');
39
- const [tool, argsArg, outPath] = argv.filter((a) => a !== '--no-mail');
49
+ const jsonOnly = argv.includes('--json');
50
+ const [tool, argsArg, outPath] = argv.filter((a) => a !== '--no-mail' && a !== '--json');
40
51
  if (!tool || !argsArg) {
41
- console.error('쓰기: node mcp-call.mjs [--no-mail] <도구 이름> <인자.json|인라인 JSON> [결과.txt]');
52
+ console.error('쓰기: node mcp-call.mjs [--no-mail] [--json] <도구 이름> <인자.json|인라인 JSON> [결과.txt]');
42
53
  process.exit(2);
43
54
  }
44
- if (!fs.existsSync(SERVER)) {
45
- console.error(`✗ MCP 서버를 못 찾았습니다: ${SERVER}\n GENETICS_MCP=/경로/genetics.mjs 로 알려 주세요.`);
55
+ if (process.env.GENETICS_MCP && !fs.existsSync(process.env.GENETICS_MCP)) {
56
+ console.error(`✗ MCP 서버를 못 찾았습니다: ${process.env.GENETICS_MCP}\n GENETICS_MCP=/경로/genetics.mjs 로 알려 주세요.`);
46
57
  process.exit(2);
47
58
  }
48
59
  // 인자는 파일이 기본이다 — 큰 원고가 이 도구의 존재 이유다. 인라인은 작은 호출의 편의.
@@ -68,11 +79,15 @@ function noMailEnv() {
68
79
  return env;
69
80
  }
70
81
 
71
- const srv = spawn('node', [SERVER], { stdio: ['pipe', 'pipe', 'inherit'], ...(noMail ? { env: noMailEnv() } : {}) });
82
+ const srv = spawn(SERVER_CMD[0], SERVER_CMD[1], { stdio: ['pipe', 'pipe', 'inherit'], ...(noMail ? { env: noMailEnv() } : {}) });
72
83
  srv.on('error', (e) => { console.error(`✗ 서버를 못 띄웠습니다: ${e.message}`); process.exit(1); });
73
84
 
74
85
  let buf = '';
75
86
  const pending = new Map();
87
+ // 파이프는 64KiB 조각으로 온다 — 조각마다 따로 글자로 풀면 경계에 걸친 한글 한 글자가 U+FFFD(�) 둘~셋으로 바뀐다(종료 0 · JSON 도 읽힌다).
88
+ // 받은 판이 새 판의 출발점이라 그 글자가 그대로 발행 인자에 실린다(2026-09-30 검토 — 9/14~9/22 의 받은 사본 7벌에 실제로 남아 있었다).
89
+ // 스트림에 디코더를 달아 조각 경계를 넘겨 푼다.
90
+ srv.stdout.setEncoding('utf8');
76
91
  srv.stdout.on('data', (d) => {
77
92
  buf += d;
78
93
  let i;
@@ -104,6 +119,42 @@ const text = (r.result?.content || []).map((c) => c.text || '').join('\n');
104
119
  const out = r.error
105
120
  ? 'ERROR ' + JSON.stringify(r.error)
106
121
  : (r.result?.isError ? 'TOOL_ERROR\n' : '') + text;
107
- if (outPath) fs.writeFileSync(outPath, out); else process.stdout.write(out + '\n');
108
122
  srv.kill();
109
- process.exit(r.error || r.result?.isError ? 1 : 0);
123
+ const failed = Boolean(r.error || r.result?.isError);
124
+ // 표준 출력으로 낼 때는 **쓰기가 끝난 뒤에** 끝낸다 — 곧바로 exit 하면 파이프로 받는 쪽(셸 파이프 · 파이썬 capture_output)이 64KiB 에서
125
+ // 잘린 출력을 종료 0 과 함께 받는다(2026-09-30 검토). 파일로 쓸 때는 동기 쓰기라 그대로 끝낸다.
126
+ const finish = (s, code) => {
127
+ if (outPath) { fs.writeFileSync(outPath, s); process.exit(code); }
128
+ process.stdout.write(s, () => process.exit(code));
129
+ };
130
+ if (!jsonOnly) finish(outPath ? out : out + '\n', failed ? 1 : 0);
131
+ else if (failed) {
132
+ // 오류면 결과 파일을 건드리지 않는다 — 같은 이름으로 받아 둔 판을 「TOOL_ERROR …」 글로 덮지 않게. 까닭은 화면(표준 오류)에 보인다.
133
+ console.error(`✗ 도구가 오류를 냈습니다 — 결과 파일은 쓰지 않았습니다\n${out}`);
134
+ process.exit(1);
135
+ } else {
136
+ const got = firstJson(text);
137
+ if (!got) { console.error(`✗ 도구 출력에 JSON 이 없습니다 — ${text.split('\n')[0].slice(0, 200)}`); process.exit(1); }
138
+ const rest = (got.before + got.after).trim();
139
+ if (rest) console.error(rest); // 머리·꼬리 줄(판번호 등)은 사람 눈에 — 결과 파일에는 JSON 만
140
+ finish(got.json + '\n', 0);
141
+ }
142
+
143
+ /** 줄 머리에서 시작하는 첫 JSON 값(객체·배열) — 문자열 안의 괄호는 안 센다. 못 읽으면 다음 후보로 넘어간다. */
144
+ function firstJson(s) {
145
+ for (let i = 0; i < s.length; i++) {
146
+ if ((s[i] !== '{' && s[i] !== '[') || (i > 0 && s[i - 1] !== '\n')) continue;
147
+ let depth = 0, inStr = false, esc = false;
148
+ for (let j = i; j < s.length; j++) {
149
+ const ch = s[j];
150
+ if (inStr) { if (esc) esc = false; else if (ch === '\\') esc = true; else if (ch === '"') inStr = false; continue; }
151
+ if (ch === '"') inStr = true;
152
+ else if (ch === '{' || ch === '[') depth++;
153
+ else if ((ch === '}' || ch === ']') && --depth === 0) {
154
+ const json = s.slice(i, j + 1);
155
+ try { JSON.parse(json); return { json, before: s.slice(0, i), after: s.slice(j + 1) }; } catch { break; }
156
+ }
157
+ }
158
+ }
159
+ return null;
160
+ }