genetics-mcp 2.20.0 → 2.22.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,6 +1,7 @@
1
1
  {
2
- "aarch64-apple-darwin": "1db100bb09a127f3895fbe833dd494072cb831a89b90b6044dbcd0d3b0d2eb00",
3
- "aarch64-unknown-linux-musl": "731f44a4ae57e624312e0c7e76def96d977cbf18993f87677913bb6af7537caf",
4
- "x86_64-apple-darwin": "a1b59a18e1a7f1d79085213d11f7370a433d21742f36536db6c7aa65f8e0b5aa",
5
- "x86_64-unknown-linux-musl": "e3f0f1394760b39435dd7fa8af6e992cdc43d345236b7472bed8cf99a6939fd2"
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"
6
7
  }
Binary file
Binary file
package/genetics.mjs CHANGED
@@ -49,6 +49,22 @@ if (process.argv.includes('--version') || process.argv.includes('-v')) {
49
49
  process.exit(0);
50
50
  }
51
51
 
52
+ /*
53
+ * **낡은 Node 는 여기서 말하고 멈춘다.** 서버가 쓰는 전역 `fetch` 는 Node 18 부터다.
54
+ * 그 아래에서는 첫 도구 호출에서 `fetch is not defined` 가 나는데, 그 글자는 클라이언트의
55
+ * 로그 구석에만 남아 **「도구가 그냥 안 된다」로 보인다.** 뜰 때 한 줄로 말하는 편이 낫다.
56
+ * Windows 에서 특히 중요하다 — 옛 판(7·8)에는 Node 18 이 아예 없다(공식 지원표: Node 14·16 은
57
+ * Windows 8.1 부터, 지금 판은 Windows 10 부터).
58
+ */
59
+ if (typeof fetch !== 'function') {
60
+ process.stderr.write(
61
+ `genetics-mcp ${VERSION}: 이 Node(${process.version})에는 전역 fetch 가 없다 — Node 18 이상이 필요하다.\n`
62
+ + (process.platform === 'win32'
63
+ ? ' Windows 는 Node 18+ 가 Windows 10 이상에서만 돈다(공식 지원표). 7·8 에서는 포털의 브라우저 커넥터로 연결할 것.\n'
64
+ : ' nodejs.org 의 LTS 를 깔고 다시 열 것.\n'));
65
+ process.exit(1);
66
+ }
67
+
52
68
  /*
53
69
  * 짝인 스킬을 편다.
54
70
  *
@@ -706,13 +722,33 @@ async function fetchImage(url) {
706
722
  * 방어를 늘려야 하기 때문이다. 그래서 굳히는 것은 여기서 한다: 계약 §7.1 ④ 의
707
723
  * 「발행 도구의 프로세스가 원본을 받아 포맷 규정대로 굳힌다」가 정확히 이 자리다.
708
724
  */
725
+ /*
726
+ * 「이것을 까세요」는 **그 사람의 기계에서 되는 말**이어야 한다. 예전에는 brew·apt 만
727
+ * 적었는데, 그 두 줄은 Windows 에서 칠 수가 없다 — 안내가 아니라 막다른 길이 된다.
728
+ */
729
+ function installHint(kind) {
730
+ const P = {
731
+ webp: { win: 'winget install Google.LibWebp', mac: 'brew install webp', apt: 'apt install webp' },
732
+ librsvg:{ win: 'winget install --id=GNOME.librsvg -e 또는 scoop install librsvg',
733
+ mac: 'brew install librsvg', apt: 'apt install librsvg2-bin' },
734
+ }[kind];
735
+ return process.platform === 'win32' ? P.win : `${P.mac} · ${P.apt}`;
736
+ }
737
+
738
+ /** npx 가 옛 판을 물고 있을 때 비우는 한 줄 — 셸마다 다르다. */
739
+ function npxCacheResetHint(platform = process.platform) {
740
+ return platform === 'win32'
741
+ ? 'rmdir /s /q "%LOCALAPPDATA%\\npm-cache\\_npx" && npx -y genetics-mcp@latest'
742
+ : 'rm -rf ~/.npm/_npx && npx -y genetics-mcp@latest';
743
+ }
744
+
709
745
  function rasterizeSvg(buf) {
710
746
  const r = spawnSync('rsvg-convert',
711
747
  ['-w', String(ASSET_LONG_EDGE), '-b', 'white', '-f', 'png'],
712
748
  { input: buf, maxBuffer: IMAGE_MAX_DOWNLOAD });
713
749
  if (r.error && r.error.code === 'ENOENT') {
714
750
  throw new Error('rsvg-convert 가 없어 벡터 원본을 래스터로 굳힐 수 없습니다.\n'
715
- + 'librsvg 를 설치하세요(brew install librsvg · apt install librsvg2-bin). '
751
+ + `librsvg 를 설치하세요(${installHint('librsvg')}). `
716
752
  + 'SVG 를 그대로 싣지 않습니다 — 계약이 발행 데이터로 받는 형식을 두 래스터로 못박았습니다.');
717
753
  }
718
754
  if (r.status !== 0) throw new Error(`벡터 래스터화 실패: ${String(r.stderr).slice(0, 200)}`);
@@ -765,7 +801,7 @@ function encodeShot(buf) {
765
801
 
766
802
  function cwebpMissing() {
767
803
  return new Error('cwebp 가 없어 자산을 계약 규격(WebP)으로 굳힐 수 없습니다.\n'
768
- + 'libwebp 를 설치하세요(brew install webp · apt install webp). '
804
+ + `libwebp 를 설치하세요(${installHint('webp')}). `
769
805
  + '다른 포맷으로 우회하지 않습니다 — 계약이 포맷을 규정으로 못박았습니다.');
770
806
  }
771
807
 
@@ -799,7 +835,7 @@ function encodeAsset(buf, role, provider) {
799
835
  const r = spawnSync('cwebp', args, { input: buf, maxBuffer: IMAGE_MAX_DOWNLOAD });
800
836
  if (r.error && r.error.code === 'ENOENT') {
801
837
  throw new Error('cwebp 가 없어 자산을 계약 규격(WebP q78 · 긴 변 1400px)으로 굳힐 수 없습니다.\n'
802
- + 'libwebp 를 설치하세요(brew install webp · apt install webp). '
838
+ + `libwebp 를 설치하세요(${installHint('webp')}). `
803
839
  + '다른 포맷으로 우회하지 않습니다 — 계약이 포맷을 규정으로 못박았습니다.');
804
840
  }
805
841
  if (r.status !== 0) throw new Error(`자산 인코딩 실패: ${String(r.stderr).slice(0, 200)}`);
@@ -1674,11 +1710,46 @@ function scrubTranscript(raw) {
1674
1710
  return { jsonl: out.join('\n') + '\n', lines: out.length, dropped, redacted, cwd: cwdMode(cwds), cwd_raw: cwdMode(cwdsRaw), cwds_raw: rawAll };
1675
1711
  }
1676
1712
 
1677
- /** cwd → 트랜스크립트 디렉터리. **해석된 경로**로 슬러그를 만든다(심볼릭 링크 함정). */
1713
+ /*
1714
+ * cwd → 트랜스크립트 디렉터리. **해석된 경로**로 슬러그를 만든다(심볼릭 링크 함정).
1715
+ *
1716
+ * ★ **이 규칙은 우리 것이 아니라 Claude Code 의 것이다.** 우리가 정하는 자리가 아니라
1717
+ * **맞춰야 하는 자리**다 — 어긋나면 우리가 놓은 파일을 `claude --resume` 이 못 찾는다.
1718
+ * 그래서 기억으로 쓰지 않고 **바이너리에서 떠 왔다**(2.1.274, 2026-09-18):
1719
+ *
1720
+ * var jY = 200;
1721
+ * function JY(e){ let r=0; for(let n=0;n<e.length;n++) r=(r<<5)-r+e.charCodeAt(n)|0; return r }
1722
+ * function Le(e){ return Math.abs(JY(e)).toString(36) }
1723
+ * function k(e){ return e.replace(/[^a-zA-Z0-9]/g, "-") }
1724
+ * function eR(e){ let n=k(e); if(n.length<=jY) return n; return `${n.slice(0,jY)}-${Le(e)}` }
1725
+ *
1726
+ * 옛 코드는 `/` 와 `_` 만 바꿨다. 그러면 **점 하나만 들어가도 어긋난다** — 이 기계 실측:
1727
+ * `…/spreadjs-v19-0-3_/.vendor-src` 의 실재 폴더는 `…-v19-0-3---vendor-src` 인데 옛 규칙은
1728
+ * `…-v19-0-3--.vendor-src` 를 냈다(실재 0건). Windows 는 더 나쁘다 — `\` 와 `:` 가 그대로
1729
+ * 남아 `C:\…\projects\C:\Users\…` 라는 **만들 수 없는 경로**가 나왔다(path.win32 로 실측).
1730
+ * 비영숫자를 전부 바꾸면 드라이브 문자도 `C-` 가 되어 Windows 에서도 유효한 이름이 된다.
1731
+ */
1732
+ const TRANSCRIPT_SLUG_MAX = 200; // Claude Code 의 jY
1733
+
1734
+ /** Claude Code 의 `JY` — 자바 문자열 해시와 같은 셈(32비트 랩어라운드). */
1735
+ function transcriptSlugHash(s) {
1736
+ let r = 0;
1737
+ for (let i = 0; i < s.length; i += 1) r = (((r << 5) - r) + s.charCodeAt(i)) | 0;
1738
+ return Math.abs(r).toString(36);
1739
+ }
1740
+
1741
+ /** Claude Code 의 `eR` — 경로 하나를 폴더 이름 하나로. */
1742
+ function transcriptSlug(real) {
1743
+ const n = String(real).replace(/[^a-zA-Z0-9]/g, '-');
1744
+ return n.length <= TRANSCRIPT_SLUG_MAX
1745
+ ? n
1746
+ : `${n.slice(0, TRANSCRIPT_SLUG_MAX)}-${transcriptSlugHash(real)}`;
1747
+ }
1748
+
1678
1749
  function transcriptDir(dir) {
1679
1750
  let real = dir;
1680
1751
  try { real = fs.realpathSync(dir); } catch { /* 없으면 준 값 그대로 */ }
1681
- return path.join(os.homedir(), '.claude', 'projects', real.replace(/[/_]/g, '-'));
1752
+ return path.join(os.homedir(), '.claude', 'projects', transcriptSlug(real));
1682
1753
  }
1683
1754
 
1684
1755
  /** uuid 로 이 기계의 트랜스크립트를 찾는다. 지금 프로젝트를 먼저 보고, 없으면 전체를 훑는다. */
@@ -1777,8 +1848,33 @@ function cursorActiveFromLedger() {
1777
1848
  * 조용히 비켜난다(beatTargets:transcriptOf null → skip). 대화 본문을 올리는 길(두뇌
1778
1849
  * 파서까지)은 별도 단계다 — DESIGN-cursor-colony.md.
1779
1850
  */
1780
- const CURSOR_ROOT = process.env.GENETICS_CURSOR_ROOT
1781
- || path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User');
1851
+ /*
1852
+ * Cursor 의 사용자 데이터 자리는 **플랫폼마다 다르다**(VS Code 계열 공통 규약).
1853
+ * 여기 macOS 자리만 박혀 있었다 — Windows·Linux 에서는 `workspaceStorage` 를 영영 못 찾고
1854
+ * 조용히 빈 값으로 떨어졌다(try/catch 안이라 아무도 못 봤다).
1855
+ * darwin ~/Library/Application Support/Cursor/User
1856
+ * win32 %APPDATA%\Cursor\User (APPDATA 가 없으면 ~/AppData/Roaming 로 잇는다)
1857
+ * linux $XDG_CONFIG_HOME/Cursor/User → 없으면 ~/.config/Cursor/User
1858
+ */
1859
+ function cursorUserRoot(platform = process.platform, env = process.env, home = os.homedir()) {
1860
+ if (env.GENETICS_CURSOR_ROOT) return env.GENETICS_CURSOR_ROOT;
1861
+ if (platform === 'win32') {
1862
+ return path.join(env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Cursor', 'User');
1863
+ }
1864
+ if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Cursor', 'User');
1865
+ return path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Cursor', 'User');
1866
+ }
1867
+
1868
+ const CURSOR_ROOT = cursorUserRoot();
1869
+
1870
+ /*
1871
+ * sqlite3 실행체. macOS 는 자리가 고정이라 **절대경로로** 부르고(PATH 오염을 안 탄다),
1872
+ * 나머지는 PATH 에서 찾는다 — Windows 는 sqlite3 가 기본 탑재가 아니라 없을 수 있고,
1873
+ * 없으면 아래 장부 갈래로 떨어진다(없음은 오류가 아니다).
1874
+ */
1875
+ function sqliteBin(platform = process.platform) {
1876
+ return platform === 'darwin' ? '/usr/bin/sqlite3' : 'sqlite3';
1877
+ }
1782
1878
 
1783
1879
  let cursorSeen = { at: 0, id: '', folder: '' }; // 15초 캐시 — 도구마다 sqlite 를 안 두드리게
1784
1880
 
@@ -1815,7 +1911,7 @@ function cursorActiveComposer() {
1815
1911
  if (!best || mt > best.mt) best = { dir, wsPath, mt };
1816
1912
  }
1817
1913
  if (best) {
1818
- const r = spawnSync('/usr/bin/sqlite3',
1914
+ const r = spawnSync(sqliteBin(),
1819
1915
  ['-readonly', path.join(best.dir, 'state.vscdb'),
1820
1916
  "SELECT value FROM ItemTable WHERE key='composer.composerData'"],
1821
1917
  { encoding: 'utf8', timeout: 2000 });
@@ -4012,13 +4108,17 @@ const TOOLS = [
4012
4108
  + '각 메모의 `anchor.path`(예 `case:k7m2p9/fix`)가 **발행 데이터의 어느 칸을 고치라는 것인지**를 '
4013
4109
  + '그대로 말한다 — 그 칸을 고쳐 update_report 로 되돌려 보내고, resolve_note 로 반영을 표시한다.\n'
4014
4110
  + '`anchor_state` 가 `unmoored`·`reparented` 면 문서가 바뀌어 원래 자리를 잃은 것이다 — '
4015
- + '그래도 메모는 남아 있고, `anchor.quote_exact` 가 저자가 무엇을 보고 썼는지 말한다.',
4111
+ + '그래도 메모는 남아 있고, `anchor.quote_exact` 가 저자가 무엇을 보고 썼는지 말한다.\n'
4112
+ + '같은 호출이 **검토 회신**(고객에게 보이는 글 — 판단 항목의 답이 여기로 온다)도 함께 보여 준다. '
4113
+ + '문서를 고치기 **전에** 회신의 본문을 읽는다: 각 회신의 where 가 어느 물음·건·도판에 대한 말인지 말한다. '
4114
+ + '회신대로 고친 뒤의 「원문이 바뀜」은 정상이다 — 핀을 살리려고 문장을 되돌리지 않는다.',
4016
4115
  inputSchema: {
4017
4116
  type: 'object',
4018
4117
  properties: {
4019
4118
  id: { type: 'integer', description: '보고서 id' },
4020
4119
  state: { type: 'string', description: '`open`(기본, 아직 반영 안 한 것) 또는 `all`' },
4021
4120
  uid: { type: 'string', description: '그 건·절의 uid 에 달린 것만(선택)' },
4121
+ channel: { type: 'string', description: '`all`(기본 — 메모와 검토 회신 둘 다) · `notes`(저자 메모만) · `replies`(검토 회신만)' },
4022
4122
  },
4023
4123
  required: ['id'],
4024
4124
  },
@@ -4029,7 +4129,8 @@ const TOOLS = [
4029
4129
  '메모에 **답글로** 회신한다. 이견이 있거나 물을 것이 있으면 대화창이 아니라 여기에 쓴다 — '
4030
4130
  + '저자는 자기가 메모를 단 그 자리에서 답을 본다.\n'
4031
4131
  + '고칠 수 없는 이유, 다르게 본 근거, 되묻는 것이 여기 온다. '
4032
- + '고쳤으면 답글이 아니라 resolve_note 다.',
4132
+ + '고쳤으면 답글이 아니라 resolve_note 다.\n'
4133
+ + '★ **검토 회신에는 이 도구로 답하지 않는다** — 그쪽은 고객에게 보이는 글이라 사람이 포털에서 쓴다. 할 말은 작성자에게 대화로 전한다.',
4033
4134
  inputSchema: {
4034
4135
  type: 'object',
4035
4136
  properties: {
@@ -4043,19 +4144,22 @@ const TOOLS = [
4043
4144
  {
4044
4145
  name: 'resolve_note',
4045
4146
  description:
4046
- '그 메모를 **반영했다**고 표시한다. 반영이 실린 판번호(body_rev)가 함께 남아 '
4147
+ '그 메모(또는 검토 회신)를 **반영했다**고 표시한다. 반영이 실린 판번호(body_rev)가 함께 남아 '
4047
4148
  + '「무엇에 반영됐는지」를 나중에도 안다.\n'
4048
4149
  + '★ 고치기 **전에** 부르지 말 것 — 이 표시가 곧 「이 메모는 끝났다」이고, '
4049
- + '표시하는 순간 미반영 목록에서 빠져 다시 눈에 안 띈다.',
4150
+ + '표시하는 순간 미반영 목록에서 빠져 다시 눈에 안 띈다.\n'
4151
+ + '검토 회신은 `note` 대신 `reply` 로 가리킨다 — 표시해도 회신은 고객에게 그대로 보이고, 그 회신이 집은 문장이 바뀐 것을 update_report 가 더는 경고하지 않는다. '
4152
+ + '문서에 반영할 것이 없는 회신(인사·이미 답한 물음)도 읽었으면 표시한다.',
4050
4153
  inputSchema: {
4051
4154
  type: 'object',
4052
4155
  properties: {
4053
4156
  id: { type: 'integer', description: '보고서 id' },
4054
- note: { type: 'integer', description: '메모 id' },
4157
+ note: { type: 'integer', description: '메모 id(`reply` 와 둘 중 하나)' },
4158
+ reply: { type: 'integer', description: '검토 회신 id — list_notes 의 [회신 N]. 뿌리 회신만 표시할 수 있다' },
4055
4159
  rev: { type: 'integer', description: '반영이 실린 body_rev. 안 주면 지금 판' },
4056
4160
  resolved: { type: 'boolean', description: '거짓이면 미반영으로 되돌린다' },
4057
4161
  },
4058
- required: ['id', 'note'],
4162
+ required: ['id'],
4059
4163
  },
4060
4164
  },
4061
4165
  {
@@ -4627,6 +4731,14 @@ const TOOLS = [
4627
4731
  * 찍는 자리가 흩어져 있으면 또 한 곳이 빠지므로 여기 하나로 모은다. */
4628
4732
  // 자산을 굳히며 이쪽(작성자 기계)에서만 알 수 있는 것 — 서버 경고와 한 목록으로 낸다(readability.md R8).
4629
4733
  let ASSET_HINTS = [];
4734
+ /* 회신 핀 예보(update_report) — 서버가 핀마다 낸 판정(`pins[]`)을 한 줄로 센다. 경고는 약해지는 핀만 말하므로,
4735
+ * 이 줄이 없으면 「전부 제자리」와 「핀이 없다」와 「예보가 안 돌았다(옛 서버)」를 에이전트가 못 가른다. 핀이 없으면 안 찍는다. */
4736
+ function pinsText(r) {
4737
+ const p = Array.isArray(r?.pins) ? r.pins : [];
4738
+ if (!p.length) return '';
4739
+ const n = (s) => p.filter((x) => x?.state === s).length;
4740
+ return `\n회신 핀 ${p.length}건 — 제자리 ${n('exact')} · 옮겨 붙음 ${n('moved')} · 원문이 바뀜 ${n('drifted')} · 상위로 ${n('reparented')} · 자리 잃음 ${n('unmoored')}`;
4741
+ }
4630
4742
  function warnText(r) {
4631
4743
  const w = [...(r?.warnings ?? []), ...ASSET_HINTS];
4632
4744
  return w.length
@@ -4709,11 +4821,23 @@ const CELL_DIR = path.join(os.homedir(), '.config/genetics/bin');
4709
4821
  const CELL_PATH = path.join(CELL_DIR, process.platform === 'win32' ? 'gcell.exe' : 'gcell');
4710
4822
 
4711
4823
  /** 이 기계의 타깃 삼중. 모르면 `null` — 지어내지 않는다. */
4712
- function cellTarget() {
4713
- const a = process.arch, p = process.platform;
4824
+ function cellTarget(platform = process.platform, arch = process.arch) {
4825
+ const a = arch, p = platform;
4714
4826
  if (p === 'darwin') return a === 'arm64' ? 'aarch64-apple-darwin' : a === 'x64' ? 'x86_64-apple-darwin' : null;
4715
4827
  if (p === 'linux') return a === 'arm64' ? 'aarch64-unknown-linux-musl' : a === 'x64' ? 'x86_64-unknown-linux-musl' : null;
4716
- return null; // Windows 는 아직 못 굽는다(cell/.cargo/config.toml)
4828
+ // Windows 는 mingw 로 굽는다(cell/.cargo/config.toml). arm64 Windows 는 아직 안 굽는다 — 없는 것은 없는 것이다.
4829
+ if (p === 'win32') return a === 'x64' ? 'x86_64-pc-windows-gnu' : null;
4830
+ return null;
4831
+ }
4832
+
4833
+ /**
4834
+ * 이 플랫폼에 **세포가 있을 수 있나**. 「아직 안 깔렸다」와 「이 플랫폼엔 애초에 없다」를 가른다.
4835
+ *
4836
+ * ⚠ 이 갈래가 없으면 안내가 **막다른 길**이 된다: 못 쓴다는 글이 `install_cell` 을 가리키고,
4837
+ * 그 도구는 다시 「이 판에 없다」를 낸다 — 모델이 그 둘을 오가며 설치를 되풀이한다.
4838
+ */
4839
+ function cellPlatformSupported(platform = process.platform, arch = process.arch) {
4840
+ return cellTarget(platform, arch) !== null;
4717
4841
  }
4718
4842
 
4719
4843
  function cellManifest() {
@@ -4881,7 +5005,7 @@ function tenantNoticeText(v) {
4881
5005
  }
4882
5006
  if (v.client_old) {
4883
5007
  return `genetics: 이 폴더는 ${T} 로 확정돼 있고 자동 올리기도 켜져 있지만, 이 판(${v.version})이 서버 최소 판(${v.min_client})보다 낡아 `
4884
- + '**이 대화는 안 올라간다**. npx 캐시를 비우고 다시 깔면 그때부터 올라간다: rm -rf ~/.npm/_npx && npx -y genetics-mcp@latest.';
5008
+ + `**이 대화는 안 올라간다**. npx 캐시를 비우고 다시 깔면 그때부터 올라간다: ${npxCacheResetHint()}.`;
4885
5009
  }
4886
5010
  const who = v.confirmed_by ? `${v.confirmed_by}${josaIGa(v.confirmed_by)} ` : '';
4887
5011
  return `genetics: 이 세션은 ${T} 소속으로 확정된 폴더(${v.path} · ${who}${String(v.confirmed_at || '').slice(0, 10)} 확정, 자동 올리기 켬)에서 `
@@ -4938,7 +5062,7 @@ function tenantNoticeText(v) {
4938
5062
  + 'login 도구로 로그인하면 그때부터 확인한다.';
4939
5063
  case 'client_old':
4940
5064
  return `genetics: 이 판(${v.version})은 서버 최소 판(${v.min_client})보다 낡아 폴더 소속·자동 올리기가 안 돈다. `
4941
- + 'npx 캐시를 비우고 다시 깔 것: rm -rf ~/.npm/_npx && npx -y genetics-mcp@latest.';
5065
+ + `npx 캐시를 비우고 다시 깔 것: ${npxCacheResetHint()}.`;
4942
5066
  default:
4943
5067
  return '';
4944
5068
  }
@@ -5151,10 +5275,14 @@ function toolsNow() {
5151
5275
  * 지시형은 프롬프트 주입으로 거부된다(2026-08-22 실측).
5152
5276
  */
5153
5277
  function cellNotice(why) {
5154
- return [
5155
- `genetics 세포(gcell)를 못 쓴다 — ${why}. 그래서 이 저장소에서 남이 무엇을 바꿨는지 못 싣는다.`,
5156
- '세포는 선택이 아니라 필수다. `install_cell` 도구가 깔고, `uninstall_cell` 이 지운다.',
5157
- ].join('\n');
5278
+ const head = `genetics 세포(gcell)를 못 쓴다 — ${why}. 그래서 이 저장소에서 남이 무엇을 바꿨는지 못 싣는다.`;
5279
+ // **깔 수 없는 곳에 설치를 시키지 않는다.** 이 플랫폼에 세포가 없으면 그 말이 곧 다음 할 일이다.
5280
+ if (!cellPlatformSupported()) {
5281
+ return [head,
5282
+ `이 플랫폼(${process.platform}/${process.arch})에는 세포가 없다 — 깔 수 있는 것이 없으니 `
5283
+ + '`install_cell` 을 부르지 말 것. 나머지 도구는 그대로 쓴다.'].join('\n');
5284
+ }
5285
+ return [head, '세포는 선택이 아니라 필수다. `install_cell` 도구가 깔고, `uninstall_cell` 이 지운다.'].join('\n');
5158
5286
  }
5159
5287
 
5160
5288
  /* ── ★★ 메일 레인 — 두뇌·다른 세션·사람이 **나를 지목해** 보낸 것 ──────────
@@ -5456,6 +5584,55 @@ let NOTES_LAST = '';
5456
5584
  let NOTES_QUIET = 0;
5457
5585
  const NOTES_REMIND_EVERY = 8;
5458
5586
 
5587
+ /*
5588
+ * 열린 검토 회신(125) — 메모와 같은 방식으로 기억하고 꼬리에서 말한다. 회신은 **판단 항목의 답이 오는 자리**라
5589
+ * 문서를 고치기 전에 읽혀야 한다: 2026-09-17 에는 이 통로가 없어 답이 와 있는 항을 다섯 판 동안 다시 물었다.
5590
+ */
5591
+ const REPLIES_OPEN = new Map(); // 보고서 id → 열린 회신 건수
5592
+ let REPLIES_LAST = '';
5593
+ let REPLIES_QUIET = 0;
5594
+ function repliesSeen(id, n) {
5595
+ if (!Number.isInteger(id) || id <= 0 || !Number.isInteger(n)) return;
5596
+ if (n > 0) REPLIES_OPEN.set(id, n); else REPLIES_OPEN.delete(id);
5597
+ }
5598
+ function repliesSays() {
5599
+ const rows = [];
5600
+ REPLIES_OPEN.forEach((n, id) => { if (n > 0) rows.push(`#${id} ${n}건`); });
5601
+ if (!rows.length) { REPLIES_LAST = ''; return ''; }
5602
+ const t = `검토 회신 미반영 — ${rows.join(' · ')} · list_notes(id) 가 본문과 함께 보여 준다. `
5603
+ + '문서를 고치기 전에 읽고, 반영했으면 resolve_note(id, reply). 고객에게 보이는 답글은 사람이 포털에서 쓴다.';
5604
+ if (t === REPLIES_LAST && ++REPLIES_QUIET < NOTES_REMIND_EVERY) return '';
5605
+ REPLIES_LAST = t;
5606
+ REPLIES_QUIET = 0;
5607
+ return t;
5608
+ }
5609
+
5610
+ /** 검토 회신 목록을 사람이 읽는 줄로. 답글은 뿌리 밑에 붙인다(메모 목록과 같은 규율). */
5611
+ function repliesText(id, r) {
5612
+ if (!r.replies || !r.replies.length) return `검토 회신 — 보고서 #${id} · ${r.note || '열린 검토 회신이 없다.'}`;
5613
+ const STATE = { exact: '', moved: '(옮겨 붙음)', drifted: '(원문이 바뀜)', reparented: '(문장을 잃어 상위로)', unmoored: '(자리를 잃음)' };
5614
+ const side = (c) => (c.side === 'client' ? '고객' : '우리');
5615
+ const kids = new Map();
5616
+ for (const c of r.replies) {
5617
+ if (c.parent_id !== null) {
5618
+ if (!kids.has(c.parent_id)) kids.set(c.parent_id, []);
5619
+ kids.get(c.parent_id).push(c);
5620
+ }
5621
+ }
5622
+ const lines = [`검토 회신(고객에게 보이는 글) · 보고서 #${id} · 본문 판 body_rev=${r.body_rev} · ${r.note}`, ''];
5623
+ if (r.capped) lines.splice(1, 0, '⚠ 목록이 잘렸다 — 아래가 전부가 아니다.');
5624
+ for (const c of r.replies) {
5625
+ if (c.parent_id !== null) continue;
5626
+ lines.push(`[회신 ${c.id}] ${c.where || '문서 전체'} ${STATE[c.state_now] || ''}${c.resolved ? ` · 반영됨(판 ${c.resolved_rev ?? '?'})` : ''}`.trimEnd());
5627
+ if (c.anchor?.quote_exact) lines.push(` 집은 글: 「${c.anchor.quote_exact}」`);
5628
+ lines.push(` ${c.author || ''}(${side(c)}) ${String(c.created_at || '').slice(0, 10)}: ${c.body}`);
5629
+ for (const k of (kids.get(c.id) || [])) lines.push(` ↳ ${k.author || ''}(${side(k)}): ${k.body}`);
5630
+ lines.push('');
5631
+ }
5632
+ lines.push('반영했으면 resolve_note(id, reply: N). 회신대로 고친 뒤의 「원문이 바뀜」은 정상이다. 고객에게 보이는 답글은 이 도구로 달지 않는다.');
5633
+ return lines.join('\n').trimEnd();
5634
+ }
5635
+
5459
5636
  /** 응답에 실려 온 건수를 기억한다. 서버가 그 칸을 모르는 판이면 아무 일도 안 한다. */
5460
5637
  function notesSeen(id, n) {
5461
5638
  if (!Number.isInteger(id) || id <= 0 || !Number.isInteger(n)) return;
@@ -5493,6 +5670,8 @@ function worldAppend(result) {
5493
5670
  */
5494
5671
  const nn = notesSays();
5495
5672
  if (nn) result.content.push({ type: 'text', text: nn });
5673
+ const rr = repliesSays();
5674
+ if (rr) result.content.push({ type: 'text', text: rr });
5496
5675
  const t = worldDelta();
5497
5676
  if (!t) return;
5498
5677
  /*
@@ -5752,6 +5931,11 @@ async function callTool(name, args = {}) {
5752
5931
  notesSeen(Number(x.id), x.open_notes);
5753
5932
  if (x.open_notes > 0) bits.push(`첨삭 메모 ${x.open_notes}건 미반영`);
5754
5933
  }
5934
+ // 열린 검토 회신(125) — 판단 항목의 답이 와 있는 문서를 목록에서 알아보게. 옛 서버·자격 없음이면 안 찍는다.
5935
+ if (Number.isInteger(x.open_replies)) {
5936
+ repliesSeen(Number(x.id), x.open_replies);
5937
+ if (x.open_replies > 0) bits.push(`검토 회신 ${x.open_replies}건 미반영`);
5938
+ }
5755
5939
  const tag = bits.length ? ` [${bits.join(' · ')}]` : '';
5756
5940
  // slug 를 싣는다. 중복 slug 는 409 로 잘 막히지만 **미리 알 수가 없었다** —
5757
5941
  // 목록에 이름표가 없어 새 이름을 고를 때 참고할 것이 없었다(2026-08-18 검토).
@@ -5803,6 +5987,7 @@ async function callTool(name, args = {}) {
5803
5987
  return JSON.stringify(r.current_text, null, 2);
5804
5988
  }
5805
5989
  notesSeen(Number(args.id), r.open_notes);
5990
+ repliesSeen(Number(args.id), r.open_replies);
5806
5991
  if (!args.outline) {
5807
5992
  /*
5808
5993
  * ⚠ 전에는 여기서 **`r.data` 만** 돌려줬다. 서버가 정성껏 붙인 것이 이 한 줄에서
@@ -5820,6 +6005,10 @@ async function callTool(name, args = {}) {
5820
6005
  head.push(`[정본 ${owner} · 본문 판 body_rev=${r.body_rev ?? '?'}`
5821
6006
  + (r.sha256 ? ` · 지금 발행물 ${r.bytes}B sha256:${String(r.sha256).slice(0, 12)}…` : '')
5822
6007
  + `] — 고칠 때는 update_report 에 body_rev 를 함께 실으면, 그 사이 누가 고쳤을 때 409 로 멈춘다.`);
6008
+ // 열린 검토 회신 — 고치기 전에 읽어야 한다(판단 항목의 답이 거기 있다). 첫 줄 가까이에 둔다.
6009
+ if (Number.isInteger(r.open_replies) && r.open_replies > 0) {
6010
+ head.push(`검토 회신 ${r.open_replies}건이 열려 있다 — 고치기 전에 list_notes(id: ${Number(args.id)}) 로 본문을 읽는다.`);
6011
+ }
5823
6012
  // 그림은 서버가 보관한다(바이트는 에이전트를 안 지난다) — 되돌려 보낼 때의 계약을 여기서 말한다(#34).
5824
6013
  const kept = Array.isArray(r.data?.assets)
5825
6014
  ? r.data.assets.filter((a) => typeof a?.data === 'string' && a.data.startsWith('(생략')).length : 0;
@@ -5837,13 +6026,29 @@ async function callTool(name, args = {}) {
5837
6026
  }
5838
6027
  case 'list_notes': {
5839
6028
  const id = Number(args.id);
6029
+ const channel = args.channel === undefined ? 'all' : String(args.channel);
6030
+ if (!['all', 'notes', 'replies'].includes(channel)) throw new Error('channel 은 all · notes · replies 중 하나입니다.');
5840
6031
  const qs = new URLSearchParams();
5841
6032
  if (args.state) qs.set('state', String(args.state));
5842
6033
  if (args.uid) qs.set('uid', String(args.uid));
5843
- const r = await api('GET', `/api/reports/${id}/notes${qs.toString() ? '?' + qs : ''}`);
6034
+ const q = qs.toString() ? '?' + qs : '';
6035
+ // 검토 회신(125) — 메모 뒤에 붙인다. 옛 서버(창구 없음)는 메모만 낸다; 회신만 달라고 했으면 그 실패를 그대로 말한다.
6036
+ let repliesPart = '';
6037
+ if (channel !== 'notes') {
6038
+ try {
6039
+ const rr = await api('GET', `/api/reports/${id}/replies${q}`);
6040
+ repliesSeen(id, rr.open);
6041
+ repliesPart = repliesText(id, rr);
6042
+ } catch (e) {
6043
+ if (channel === 'replies' || Number(e?.status) !== 404) throw e;
6044
+ }
6045
+ }
6046
+ if (channel === 'replies') return repliesPart;
6047
+ const tail = repliesPart ? `\n\n${repliesPart}` : '';
6048
+ const r = await api('GET', `/api/reports/${id}/notes${q}`);
5844
6049
  notesSeen(id, r.open);
5845
6050
  if (!r.notes || !r.notes.length) {
5846
- return `보고서 #${id} — ${r.note || '메모가 없다.'}`;
6051
+ return `보고서 #${id} — ${r.note || '메모가 없다.'}${tail}`;
5847
6052
  }
5848
6053
  /*
5849
6054
  * 답글을 부모 밑에 붙여 낸다. 평평하게 내면 에이전트가 **자기가 이미 한 말**을
@@ -5873,7 +6078,7 @@ async function callTool(name, args = {}) {
5873
6078
  }
5874
6079
  lines.push('');
5875
6080
  }
5876
- return lines.join('\n').trimEnd();
6081
+ return lines.join('\n').trimEnd() + tail;
5877
6082
  }
5878
6083
  case 'reply_note': {
5879
6084
  const id = Number(args.id);
@@ -5883,6 +6088,15 @@ async function callTool(name, args = {}) {
5883
6088
  }
5884
6089
  case 'resolve_note': {
5885
6090
  const id = Number(args.id);
6091
+ if ((args.note === undefined) === (args.reply === undefined)) throw new Error('note(메모 id) 와 reply(검토 회신 id) 중 **하나만** 주세요.');
6092
+ if (args.reply !== undefined) {
6093
+ const rb = { reply: Number(args.reply) };
6094
+ if (args.rev !== undefined) rb.rev = Number(args.rev);
6095
+ if (args.resolved !== undefined) rb.resolved = !!args.resolved;
6096
+ const rr = await api('PATCH', `/api/reports/${id}/replies`, rb);
6097
+ if (Number.isInteger(rr.open)) repliesSeen(id, rr.open);
6098
+ return rr.says;
6099
+ }
5886
6100
  const body = { note: Number(args.note) };
5887
6101
  if (args.rev !== undefined) body.rev = Number(args.rev);
5888
6102
  if (args.resolved !== undefined) body.resolved = !!args.resolved;
@@ -5967,16 +6181,16 @@ async function callTool(name, args = {}) {
5967
6181
  throw e;
5968
6182
  }
5969
6183
  // 고친 직후가 「무엇을 아직 안 고쳤나」를 볼 가장 좋은 순간이다.
5970
- if (!r.dry_run) notesSeen(Number(args.id), r.open_notes);
6184
+ if (!r.dry_run) { notesSeen(Number(args.id), r.open_notes); repliesSeen(Number(args.id), r.open_replies); }
5971
6185
  if (r.dry_run) {
5972
- return `시험 실행 — 아무것도 바꾸지 않았습니다.\n${r.tenant} · 건 ${r.cases}` + warnText(r);
6186
+ return `시험 실행 — 아무것도 바꾸지 않았습니다.\n${r.tenant} · 건 ${r.cases}` + pinsText(r) + warnText(r);
5973
6187
  }
5974
6188
  // 상태를 함께 알린다 — 이 문서를 다음에도 API 로 고칠 수 있는지가 여기서 갈린다.
5975
6189
  const st = r.state ? ` [${r.state}]` : '';
5976
6190
  // 서버가 초안이라고 답하면 이 도구의 동사로 다음 할 일을 잇는다(서버 note 는 HTTP 경로로 말한다).
5977
6191
  const open = r.published === false ? ` 이 도구에서는 release_report(id: ${Number(args.id)}) 가 그 일입니다.` : '';
5978
6192
  return `교체 완료 — ${r.tenant} · 건 ${r.cases}${st}\n열람 주소: ${r.url}\n${r.note || ''}${open}`
5979
- + pendingText(r) + warnText(r);
6193
+ + pendingText(r) + pinsText(r) + warnText(r);
5980
6194
  }
5981
6195
  case 'world': {
5982
6196
  const facts = worldFacts(8);
@@ -6742,7 +6956,15 @@ const IS_MAIN = (() => {
6742
6956
  * 자리 고르기는 **순수 함수**로 뺀다(smoke 가 떼어내 잰다): PATH 에 있고 · 실재하고 · 쓸 수 있는
6743
6957
  * 첫 후보. 없는 폴더를 만들거나 rc 파일을 고치지 않는다 — 남의 셸 설정에 손대는 설치는 못 믿는다.
6744
6958
  */
6745
- const CELL_LINK_CANDIDATES = ['/opt/homebrew/bin', '/usr/local/bin', path.join(os.homedir(), '.local/bin')];
6959
+ /*
6960
+ * ⚠ **Windows 에는 이 자리가 없다.** 후보 셋은 전부 유닉스의 PATH 자리이고, 심링크 자체도
6961
+ * Windows 에서는 개발자 모드나 관리자 권한이 있어야 만들어진다(EPERM). 그래서 그 판에서는
6962
+ * **걸지 않고**, 대신 「이 폴더를 PATH 에 넣으라」고 그 판의 말로 말한다 — 없는 자리를
6963
+ * 나열하며 못 걸었다고 하는 것은 안내가 아니다.
6964
+ */
6965
+ const CELL_LINK_CANDIDATES = process.platform === 'win32'
6966
+ ? []
6967
+ : ['/opt/homebrew/bin', '/usr/local/bin', path.join(os.homedir(), '.local/bin')];
6746
6968
  function cellLinkDir(pathEnv, candidates, exists, writable) {
6747
6969
  const onPath = new Set(String(pathEnv || '').split(path.delimiter).filter(Boolean));
6748
6970
  for (const d of candidates) {
@@ -6755,6 +6977,10 @@ function cellLinkOnPath() {
6755
6977
  (d) => { try { return fs.statSync(d).isDirectory(); } catch { return false; } },
6756
6978
  (d) => { try { fs.accessSync(d, fs.constants.W_OK); return true; } catch { return false; } });
6757
6979
  if (!dir) {
6980
+ if (process.platform === 'win32') {
6981
+ return `PATH 에 안 걸었다(Windows 는 심링크에 권한이 필요하다) — 전체 경로 ${CELL_PATH} 로 부르거나 `
6982
+ + `이 폴더를 PATH 에 넣을 것: setx PATH "%PATH%;${CELL_DIR}"`;
6983
+ }
6758
6984
  return `PATH 에 걸 자리를 못 찾았다(${CELL_LINK_CANDIDATES.join(' · ')} 중 PATH 에 있고 쓸 수 있는 곳이 없다) — 전체 경로 ${CELL_PATH} 로 부를 것.`;
6759
6985
  }
6760
6986
  const link = path.join(dir, 'gcell');
@@ -7230,4 +7456,6 @@ export { encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText,
7230
7456
  autoBlockSetVerdict, autoBlockPut, cellPinSource,
7231
7457
  // 2026-09-15 — 「서버가 아는 행인가」는 소속 칸이 아니라 서버를 거친 표식으로 가른다(첫 업로드가 영영 안 서던 자리)
7232
7458
  autoRowOnServer,
7459
+ // 2026-09-18 Windows — 남의 규칙(Claude Code 슬러그)·플랫폼 갈래는 시험이 표로 걷는다
7460
+ transcriptSlug, transcriptDir, cursorUserRoot, sqliteBin, npxCacheResetHint, cellPlatformSupported,
7233
7461
  HOLDS_FILE, UPDATE_BUDGET_MS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.20.0",
3
+ "version": "2.22.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 notify.test.mjs && node schedule.test.mjs && bash ../cell/tests/cellcycle.sh",
21
+ "test": "node smoke.mjs && node notify.test.mjs && node schedule.test.mjs && node windows.test.mjs && bash ../cell/tests/cellcycle.sh",
22
22
  "prepack": "find skills -name __pycache__ -type d -prune -exec rm -rf {} + 2>/dev/null; bash ../cell/build-all.sh",
23
23
  "prepublishOnly": "npm test",
24
24
  "release": "bash release.sh"
@@ -43,7 +43,7 @@ version: "4.0.0"
43
43
  | 4~6 원고 | `report.json` · 코드 작업을 녹이면 원고 밖 상태 표(반드시 지키는 것 3) | — | 요약 근거 · 요지 칸 수(4~6단계) · 절마다 `marg` | 서버 요약 근거 게이트(거절) · 사람 | 공통 원인이 실재하는가 |
44
44
  | 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` · 칩 문구의 뜻은 사람 눈 | — |
45
45
  | 7 로컬 렌더 | 단일 HTML | `python3 assets/render.py report.json` | ⚠0 · `forbid` 적중 0 | `gazette.py` `text_scan()`·`brief_checks()`·`tally_scan()` · `build.py guard()` | — |
46
- | 7 dry_run | 경고 목록 | `publish_report`/`update_report` 에 `dry_run: true` | 작성자가 고칠 수 있는 ⚠0 — 못 없애는 경고는 까닭을 핸드오프에 | `report_publish.php report_build_checked()`(렌더러·입고·그림·문체) · 경고 기준선 대조는 사람 | — |
46
+ | 7 dry_run | 경고 목록 | `publish_report`/`update_report` 에 `dry_run: true` | 작성자가 고칠 수 있는 ⚠0 — 못 없애는 경고는 까닭을 핸드오프에 | `report_publish.php report_build_checked()`(렌더러·입고·그림·문체) · `update_report` 는 `comments.php comment_anchor_forecast()`(회신 핀 자리) · 경고 기준선 대조는 사람 | — |
47
47
  | 7 독자 검토 | 워크플로 결과(`merged`·`questions`·`dropped`) | `references/reader-review.workflow.js`(0.5단계 7행) · 구조를 안 바꾼 낱말 손질 판은 `references/edition-review.workflow.js`(결과 `kept`·`weak`) | 사실 틀림 반영 · 「⚠ 도판 N장 미열람」 없음 · 반영 뒤 로컬 렌더·dry_run 다시 | 워크플로 관점 + JS 거름 · `questions`·`dropped` 는 사람 | `questions[]` |
48
48
  | 7 조판 검사 | 검사기 출력 | `gazette-report` 검사기 8종(단일 HTML 전달일 때) | 각 0건 · **표본 수를 함께 본다**(0표본 통과는 통과가 아니다) | 검사기 — 실행법은 `gazette-report` 스킬 | — |
49
49
  | 7 사람 눈 | — | 인쇄 미리보기까지 연다 | `references/process.md` 「7단계 — 검수」 사람의 눈 표 | 사람 | — |
@@ -51,6 +51,7 @@ version: "4.0.0"
51
51
  | 9 발행 뒤 대조 | 블록 목록 | `get_report(id, outline: true)` | `aside.marg` 수 = 절 수 · 옛 서식 블록 0 · 빈 블록 0 | 사람(블록 목록을 읽음) — 대조 기계 없음 | — |
52
52
  | 9 열람 확인 | 캡처 | 열람 주소를 연다 | 칩·굵게·링크가 원문 글자가 아니라 서식으로 섬 · 도판 쌍·판단 절이 섬 | 사람 눈 — 못 열면 까닭을 핸드오프에 | — |
53
53
  | 9a 첨삭 메모 반영 | 반영된 판 · 닫힌 메모 · 까닭을 단 답글 | `list_reports` → `list_notes` → `update_report` → `resolve_note`·`reply_note`(9a단계) | 미반영 메모 0 — 남은 것은 전부 답글로 까닭이 달림 | 사람이 메모를 닮 · 서버는 미반영 수만 셈(`open_notes`, 우리 팀에게만) | 사람이 「메모 해놨다」고 할 때만 돈다 — 무엇을 고칠지 되묻지 않음 |
54
+ | 9b 검토 회신 반영 | 회신마다 「어느 물음의 답인가」 대조 · 반영된 판 · 반영 표시 | **개정 전에** `list_notes(id)`(검토 회신이 본문과 함께 실린다) → 원고 반영 → `update_report` → `resolve_note(id, reply, rev)`(9b단계) | 열린 회신 0 — 남은 것은 사람에게 넘긴 까닭이 있음 · 원고에 「회신 없음」이라고 쓴 자리가 회신과 어긋나지 않음 | 서버는 열린 회신 수(`open_replies`)를 목록·되읽기·교체 응답에 싣고, 열린 회신의 문장이 바뀌면 교체 경고로 말함 · 본문 읽기와 대조는 사람·에이전트 | 고객에게 보이는 답글 문안(에이전트는 달지 않는다) |
54
55
  | 10 공개 직전 날짜 | `report.json` | `update_report` 한 번(날짜만 고치는 이 판은 독자 검토 없이 로컬 렌더·dry_run 만) | 작성일 세 자리가 한 값이고 공개하는 날(3c) | `gazette.py` `date_scan()` ⚠(세 자리가 다르면) · 공개하는 날인지는 사람 | 공개일 |
55
56
  | 10 공개 | 공개된 링크 | `release_report(id)` | 사용자 승인 | 사람(승인 행위) | **반드시** 공개 승인 |
56
57
 
@@ -351,6 +352,21 @@ report.json + cases.json 을 하나로 합쳐 `publish_report` 에 준다
351
352
 
352
353
  `anchor_state` 가 `unmoored`·`reparented` 면 문서가 바뀌어 자리를 잃은 메모다 — `quote_exact` 로 본문에서 찾아 고치고, 못 찾으면 답글로 묻는다. 끝나면 사람에게 **한 줄로** 알린다: 반영 N건 · 답글로 되물은 N건 · 문서 링크.
353
354
 
355
+ ### 9b단계 — 검토 회신 반영: 개정하기 전에 회신의 **본문**부터
356
+
357
+ 검토 회신(문서의 한 자리에 핀으로 붙는 글)은 **판단 항목의 답이 오는 자리**다 — 고객도 쓰고 우리 담당자도 쓴다. 이미 나간 문서를 고칠 때는 고치기 **전에** 회신을 읽는다. `list_reports` 줄의 `검토 회신 N건 미반영`, `get_report` 머리의 「검토 회신 N건이 열려 있다」가 읽을 것이 있다는 신호다.
358
+
359
+ | 순서 | 도구 | 할 일 |
360
+ |---|---|---|
361
+ | 1 | `list_notes(id)` | 검토 회신이 **본문과 함께** 실린다. `[회신 N]` 줄의 자리(「판단 항 「…」」·건·절·도판)가 **어느 물음에 대한 말인지**, 그 아래가 누가(고객/우리) 무슨 말을 했는지다. 답글은 뿌리 밑에 붙는다 |
362
+ | 2 | (대조) | 회신마다 원고와 맞춰 본다 — 이미 답이 온 판단 항을 **열린 항으로 다시 묻고 있지 않은가**, 원고가 「회신에 언급이 없어 미결」이라고 쓴 자리가 회신과 어긋나지 않는가, 회신의 낱말과 정본(고객 문서)의 낱말이 갈리면 정본 낱말로 맞췄는가 |
363
+ | 3 | `update_report(dry_run: true)` → `update_report` | 반영한다. 답이 온 판단 항은 닫힌 항 표로 옮기고 출처에 회신 날짜를 적는다. 교체 경고의 「열린 검토 회신 …」 줄은 회신의 본문과 함께 온다 — 그 회신을 이 판이 반영한 것이면 그대로 발행한다 |
364
+ | 4 | `resolve_note(id, reply, rev)` | **반영한 뒤에만.** 표시해도 회신은 고객에게 그대로 보인다. 문서에 반영할 것이 없는 회신(인사·이미 답한 물음)도 읽었으면 표시한다 |
365
+
366
+ - **회신대로 고치면 그 회신이 집은 문장은 바뀐다. 그것이 정상이다**(열람 화면의 「원문이 바뀜」). 핀을 「제자리」로 돌리려고 발행 문장을 옛 표기로 되돌리지 않는다 — 실제로 그렇게 해서 렌더러 경고만 얹은 판이 있었다.
367
+ - **고객에게 보이는 답글은 에이전트가 달지 않는다.** 창구에 쓰는 길이 없다. 고객에게 할 말(「N차에 반영했습니다」)은 문안을 사람에게 넘기고, 사람이 포털에서 쓴다. 우리 팀끼리의 고칠 지시는 검토 회신이 아니라 첨삭 메모(9a)다.
368
+ - 닫힌 판단 항은 원고에서 뺄 수밖에 없다(요청은 그려지는 한 열려 있다). 그 항에 달린 회신은 상위 절로 올라가 붙으니, 빼는 판에서 그 회신을 반영 표시한다.
369
+
354
370
  ### 8단계′ — 전달: 단일 HTML (Lim 밖 고객·메일 첨부)
355
371
 
356
372
  ```bash
@@ -197,7 +197,7 @@ python3 assets/render.py <report.json>
197
197
  `cases[].remaining` 이 있으면 그 건은 대조표에서 **미결**로 선다.
198
198
  그 행이 독자에게 넘어가는 몫이라 다른 행과 시각적으로 갈린다.
199
199
 
200
- ### `cases[].uid` · `sections[].uid` — 건과 절의 **불변 이름**
200
+ ### `cases[].uid` · `sections[].uid` · `open_items[].uid` — 건·절·판단 항목의 **불변 이름**
201
201
 
202
202
  **적지 않아도 된다. 다만 받은 것은 돌려보내라.**
203
203
 
@@ -215,9 +215,20 @@ python3 assets/render.py <report.json>
215
215
  | `uid` 를 빼고 제목도 바꿨다 | **새 이름이 발급된다** |
216
216
  | 처음 발행(`publish_report`) | 전부 새로 발급된다 — 적을 필요 없다 |
217
217
 
218
- **왜 지켜야 하나**: 저자가 그 건에 달아 둔 메모(`list_notes`)가 이 이름을 문다.
219
- 이름이 바뀌면 메모가 사라지지는 않지만(인용문으로 다시 찾는다) 「자리를 잃음」으로 떨어져
220
- 저자가 다시 붙여야 한다.
218
+ **왜 지켜야 하나**: 저자가 그 건에 달아 둔 메모(`list_notes`)와 **고객이 문장에 달아 둔 검토 회신 핀**이
219
+ 이 이름을 문다. 이름이 바뀌면 사라지지는 않지만(인용문으로 다시 찾는다) 「자리를 잃음」으로 떨어져
220
+ 저자가 다시 붙여야 한다. 판단 항목(`open_items[]`)도 같은 이름을 들고(2026-09-17), 렌더러가 그 항의
221
+ `<li>` 에 `data-uid` 로 내보낸다 — 항을 고치거나 순서를 바꿔도 핀이 그 항에 남는다.
222
+
223
+ **검토 회신은 자리 표식이 아니라 판단 항목의 답이다 — 개정 전에 본문부터 읽는다**(2026-09-17~18 사고). 한 보고서를
224
+ 다시 쓰며 핀의 자리(앵커)만 재고 본문을 안 읽었더니, 이미 답이 달린 판단 항을 다섯 판 동안 열린 항으로 다시 물었고
225
+ 「회신에 언급이 없어 미결」이라고 사실과 다르게 적었고, 핀을 「제자리」로 돌리려고 발행 문장을 옛 표기로 되돌려 렌더러 경고만
226
+ 얹었다. 그래서 서버는 셋을 한다 — ① `list_reports`·`get_report`·`update_report` 응답이 **열린 검토 회신 수**(`open_replies`)를
227
+ 싣고 ② `list_notes(id)` 가 회신을 **본문·자리(어느 판단 항·건·도판인가)·지금 발행물에서의 자리 판정**과 함께 보여 주고
228
+ ③ `update_report` 는 `dry_run`·실전 둘 다에서 **열린 회신**의 문장이 이 원문에 없으면 경고한다(회신 본문과 함께).
229
+ 회신대로 고쳐 문장이 바뀌는 것은 정상이다 — 반영한 뒤 `resolve_note(id, reply, rev)` 로 표시하면 그 회신은 더는 경고하지 않는다.
230
+ 대조는 열람 셸과 같은 엄격 일치라 띄어쓰기 하나만 고쳐도 「원문이 바뀜」이다. 판정 전체는 응답 `pins[]`(도구 출력의
231
+ 「회신 핀 N건 — …」 줄). 절차는 SKILL 9b단계.
221
232
 
222
233
  ★ **같은 제목을 두 건에 쓰지 마라.** 위 2단이 어느 쪽인지 못 갈라 둘 다 새 이름을 받는다.
223
234
 
@@ -400,6 +411,7 @@ python3 assets/render.py <report.json>
400
411
  | 그룹에 배정되지 않은 건 N개 | `cases[].group` 이 어긋났다(그룹이 아예 없을 때) |
401
412
  | 그림 N장은 놓을 자리가 없어 | `logo`·`figure` 밖의 `role`(`hero`·`chapter`·`eof`) |
402
413
  | 도판 N장이 어느 절에도 걸리지 않아 | `sections[].plates` 나 `cases[].plates` 에 그 id 를 안 적었다 — 11-1절·11-10절 |
414
+ | 열린 검토 회신 #N(이름 · 날짜) 「회신 본문」 — 집은 문장 「…」이 이 원문에 없습니다 — 원문이 바뀜 / 문장을 잃어 상위로 / 자리를 잃음 | `update_report` 만. **아직 반영 표시가 안 된** 회신이 집은 문장이 새 원문에 없다(3.5절 uid 항) — 이 판이 그 회신을 반영한 것이면 발행 뒤 `resolve_note(id, reply, rev)`, 아니면 회신 본문부터 읽는다(SKILL 9b). 핀을 살리려고 문장을 되돌리지 않는다 |
403
415
  | 절 …의 도판 「…」를 assets 에서 못 찾았습니다 | `plates` 가 없는 `assets[].id` 를 가리킨다(건 `plates` 면 「건 NN-n 의 도판」) |
404
416
  | 건 NN-n 에 묶인 도판 「…」는 전폭(wide)인데 건 안이라 본문 단 폭으로 섭니다 | `cases[].plates` 가 `wide:true` 자산을 가리킨다 — 건 안은 두 단 그리드라 전폭이 못 선다. 절 `plates` 로 옮기거나 `wide` 를 끈다(11-10절) |
405
417
  | 도판 「…」에 그림 바이트가 없습니다 | `file`·`source` 없이 이름만 왔다. 로컬 `render.py` 는 `file` 을 바이트로 굳히지 않아 `file` 이 있어도 뜬다 — 로컬에서는 이 줄을 빼고 나머지 ⚠ 를 읽는다 |