genetics-mcp 2.16.7 → 2.17.1

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,6 @@
1
1
  {
2
- "aarch64-apple-darwin": "63e898d47b3b413f61241079a865f465ccb66c9c4936bdae25bd8396c7f491f0",
2
+ "aarch64-apple-darwin": "1e93db10afd8d6125508d20af650c290c6967f19ac4e24543dae2a42ced1fb7f",
3
3
  "aarch64-unknown-linux-musl": "aa25f20daefda931d8497d346bd47fb9b8a2acc3136ec2b9eeff18773a789e8c",
4
- "x86_64-apple-darwin": "d0c4964c81f9cb015eb41e577dd4ae81b853696c5f79cc1592dad9b25a1a73e3",
4
+ "x86_64-apple-darwin": "84b539085af2ad89018296d54fe0a89ec932cae1ee0b67a445684ddbb8dfb8fb",
5
5
  "x86_64-unknown-linux-musl": "c97bcded75ec34cdb5a448159e7f057a756deb6a45fa7793b299d0040c19be93"
6
6
  }
Binary file
package/genetics.mjs CHANGED
@@ -1140,6 +1140,34 @@ const MASK = '[가림: 서버에 올리며 지움]';
1140
1140
  const DROP_TYPES = new Set(['file-history-snapshot', 'file-history-delta', 'attachment',
1141
1141
  'last-prompt', 'queue-operation', 'system']);
1142
1142
 
1143
+ /**
1144
+ * 남기는 attachment(2026-09-03, 오너 결정 「브라우저 픽은 세션과 지식에 실린다」) — 사용자가 브라우저에서
1145
+ * 집은 요소(iab 픽)는 훅이 프롬프트에 끼운 「[iab 픽 N] …」 줄로만 존재하고, 전사에는 attachment
1146
+ * (hook_additional_context) 레코드로 남는다. 위 집합이 그것을 통째로 버려 다른 기계의 resume·두뇌 장·
1147
+ * 세션 요지에 픽이 0건이었다(실측 2026-09-03: 한 세션에 113건 중 8건이 픽). 그래서 **그 줄만** 남긴다.
1148
+ * ⚠ 서버(agent_session.php 의 agent_session_keep_attachment)와 **같은 술어·같은 접두 리터럴**이어야 한다 —
1149
+ * smoke 가 두 벌을 대조한다. 큐가 남은 판(「[iab 새 픽 N / 큐 M]」)은 안 남긴다(그 줄은 매 프롬프트 반복이라 잡음).
1150
+ * content 는 실측상 **문자열 하나짜리 배열**이고 그 안에 \n 으로 줄이 들어 있다 — 원소가 아니라 줄로 본다.
1151
+ */
1152
+ const KEEP_ATTACHMENT_PREFIX = '[iab 픽';
1153
+ function isKeptAttachment(r) {
1154
+ if (!r || r.type !== 'attachment') return false;
1155
+ const a = r.attachment;
1156
+ if (!a || typeof a !== 'object' || a.type !== 'hook_additional_context') return false;
1157
+ const parts = Array.isArray(a.content) ? a.content : [a.content];
1158
+ return parts.some((s) => typeof s === 'string' && s.split('\n').some((line) => line.startsWith(KEEP_ATTACHMENT_PREFIX)));
1159
+ }
1160
+ /** 남긴 픽 줄의 홈 경로 상대화 — 서버 agent_session_mask_home_paths 와 같은 뜻(/Users/<x>·/home/<x>·/root·C:\Users\<x> → ~).
1161
+ * relHome 은 cwd 칸의 접두만 보므로 줄 가운데 박힌 절대경로(훅 shortPath 가 못 자른 것)는 여기서 걷는다. */
1162
+ function maskHomeInPick(content) {
1163
+ const one = (s) => (typeof s === 'string'
1164
+ ? s.replace(/(?<![\p{L}\p{N}_])\/(?:Users|home)\/[^/\s]+/gu, '~')
1165
+ .replace(/(?<![\p{L}\p{N}_])\/root(?=\/|\s|$)/gu, '~')
1166
+ .replace(/(?<![\p{L}\p{N}_])[A-Za-z]:[\\/]Users[\\/][^\\/\s]+/giu, '~')
1167
+ : s);
1168
+ return Array.isArray(content) ? content.map(one) : one(content);
1169
+ }
1170
+
1143
1171
  function maskSecrets(text) {
1144
1172
  let n = 0;
1145
1173
  for (const [, re] of SECRET_SHAPES) {
@@ -1223,7 +1251,8 @@ function scrubTranscript(raw) {
1223
1251
  const k = relHome(r.cwd);
1224
1252
  cwds.set(k, (cwds.get(k) || 0) + 1);
1225
1253
  }
1226
- if (DROP_TYPES.has(r.type)) { dropped++; continue; }
1254
+ if (DROP_TYPES.has(r.type) && !isKeptAttachment(r)) { dropped++; continue; }
1255
+ if (r.type === 'attachment') r.attachment.content = maskHomeInPick(r.attachment.content); // 남긴 픽 줄뿐이다(위 술어)
1227
1256
  if ('toolUseResult' in r) { r.toolUseResult = MASK; redacted++; }
1228
1257
  if (typeof r.cwd === 'string') r.cwd = relHome(r.cwd);
1229
1258
  const c = r?.message?.content;
@@ -2518,6 +2547,13 @@ const TOOLS = [
2518
2547
  description: '이 사례가 걸린 **명령 문구**(예: "INSERT INTO rp_agent_presence"). 다른 세포가 그 문구가 든 명령을 치기 직전에 이 사례를 본다.' },
2519
2548
  errors: { type: 'array', items: { type: 'string' },
2520
2549
  description: '이 사례의 **오류 문자열**(예: "CONSTRAINT ck_tree_machine failed"). 지금은 적어 두기만 한다.' },
2550
+ picks: { type: 'array', maxItems: 20, items: { type: 'object', required: ['selector', 'text'], properties: {
2551
+ selector: { type: 'string', description: '페이지에서 다시 잴 자리 — 훅 줄의 sel `…` 그대로' },
2552
+ text: { type: 'string', description: '사람이 본 글자 — 훅 줄의 "…" 그대로' },
2553
+ source: { type: 'string', description: '고칠 자리 — 훅 줄의 <저장소 상대 경로>:<줄> 그대로(없으면 비움). 경로는 paths 트리거에 자동으로 들어간다' },
2554
+ url: { type: 'string', description: '그 화면 주소(알면)' },
2555
+ } }, description: '사용자가 브라우저에서 **집은 요소**(iab 픽). 사용자가 "iab N" 이라 했으면 훅이 끼운 「#N <tag> · 경로:줄 · sel `…` · "글자"」 줄에서 selector·text·source 를 **그대로** 옮긴다 — 지어내지 않는다. '
2556
+ + '픽은 이 세션 밖으로 안 넘어가므로(다른 기계·다른 사람은 못 본다) 이 칸이 유일한 기록이다. 장에 「집은 자리」 절로 선다(최대 20, 칸당 300자).' },
2521
2557
  },
2522
2558
  required: ['tenant', 'title', 'symptom', 'fix'],
2523
2559
  },
@@ -2551,10 +2587,63 @@ const TOOLS = [
2551
2587
  tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8). ★ 이것이 있어야 이 결정이 「일」 후보 계산에 들어간다.' },
2552
2588
  work: { type: 'string', description: '**이 결정이 곧 과업이면** 그 일의 열쇠(영문 소문자·숫자·붙임표, 예: hnl-wait). 적으면 다음 바퀴에 그 이름의 일이 「계획」으로 선다 — 착수 전에 세우는 정문이다. 이미 열린 일의 열쇠면 이 결정이 그 일의 **항목**(작업 리스트 한 줄)으로 서고 간트에 저장 즉시 보인다 — 항목 여럿이면 결정을 여러 번 적는다(항목마다 제 근거를 갖는다).' },
2553
2589
  order: { type: 'array', items: { type: 'string' }, description: '일 사이 **순서**를 정하는 결정이면 일의 열쇠를 앞선 것부터(최대 24). 표에 저장하지 않고 일 목록이 이 결정을 읽어 그 순서로 선다 — 순서가 바뀌면 새 결정을 적는다.' },
2590
+ plan: { type: 'array', maxItems: 64, items: { type: 'object', required: ['item', 'from', 'to'], properties: {
2591
+ item: { type: 'string', description: '항목의 결정 장 경로(예 decisions/W-01-….md) — works 가 보여 주는 항목 열쇠 그대로' },
2592
+ from: { type: 'string', description: '계획 시작일 YYYY-MM-DD' },
2593
+ to: { type: 'string', description: '계획 종료일 YYYY-MM-DD (시작 이상)' },
2594
+ } }, description: '항목별 **계획 기간**을 정하는 결정이면 항목마다 {item, from, to}(최대 64). 표에 저장하지 않고 간트가 이 결정을 읽어 「계획」 열과 점선 막대로 그린다 — 관측(막대)과 다른 축이다. 항목 하나만 고치려면 그 항목만 적은 새 결정을 남기면 된다(항목 단위로 가장 새 결정이 이긴다).' },
2595
+ picks: { type: 'array', maxItems: 20, items: { type: 'object', required: ['selector', 'text'], properties: {
2596
+ selector: { type: 'string', description: '페이지에서 다시 잴 자리 — 훅 줄의 sel `…` 그대로' },
2597
+ text: { type: 'string', description: '사람이 본 글자 — 훅 줄의 "…" 그대로' },
2598
+ source: { type: 'string', description: '고칠 자리 — 훅 줄의 <저장소 상대 경로>:<줄> 그대로(없으면 비움)' },
2599
+ url: { type: 'string', description: '그 화면 주소(알면)' },
2600
+ } }, description: '사용자가 브라우저에서 **집은 요소**(iab 픽) — remember_case 의 같은 칸. 사용자가 "iab N" 이라 했으면 훅 줄의 #N 에서 그대로 옮긴다, 지어내지 않는다. 장에 「집은 자리」 절로 선다(최대 20).' },
2554
2601
  },
2555
2602
  required: ['tenant', 'title', 'what'],
2556
2603
  },
2557
2604
  },
2605
+ {
2606
+ name: 'forget_decision',
2607
+ description:
2608
+ '**결정 장 하나를 잊는다** — 그 소속처 지식의 `decisions/` 에서 걷어내 휴지통(trash/decisions, 시각 붙은 사본)으로 옮긴다. '
2609
+ + '검색·목록·일 항목·굽기에서 빠진다.\n'
2610
+ + '언제 부르는가: 잘못 만든 장을 걷어낼 때 — 제목 오타, 원문자 같은 특수문자가 경로에 들어가 get_case 가 못 여는 장, '
2611
+ + '합본 뒤 남은 조각, 같은 결정의 중복. 사람이 "지워줘 / 걷어내 / 스텁 치워" 라고 할 때.\n'
2612
+ + '★ **뒤집힌 결정은 잊지 않는다** — status 를 「뒤집힘 → …」로 적어 남긴다. 왜 그렇게 정했었는지가 값이다.\n'
2613
+ + '★ 되돌리는 도구는 없다 — 복구는 서버 휴지통에서 사람이 한다. 지우기 전에 get_case 로 전문을 한 번 읽어라.',
2614
+ inputSchema: {
2615
+ type: 'object',
2616
+ properties: {
2617
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2618
+ path: { type: 'string', description: 'search_cases·remember_decision 이 준 path 그대로(decisions/….md). 제목을 줘도 저장과 같은 자로 찾는다.' },
2619
+ },
2620
+ required: ['tenant', 'path'],
2621
+ },
2622
+ },
2623
+ {
2624
+ // 사례 잊기(2026-09-03, 오너 결정 (e)) — 결정 잊기와 **같은 자**(wiki_page_forget, kind='cases'),
2625
+ // 다른 폴더. 서버 창구는 DELETE /api/wiki/case(scope decision.write).
2626
+ name: 'forget_case',
2627
+ description:
2628
+ '**사례 장 하나를 잊는다** — 그 소속처 지식의 `cases/` 에서 걷어내 휴지통(trash/cases, 시각 붙은 사본)으로 옮긴다. '
2629
+ + '검색·트리거(도구 직전 알림)·목록에서 빠진다.\n'
2630
+ + '언제 부르는가: 잘못 만든 장을 걷어낼 때 — 제목 오타, 원문자 같은 특수문자가 경로에 들어가 get_case 가 못 여는 장, '
2631
+ + '증상을 지어내 사례로 잘못 적은 것(정한 것이면 remember_decision 으로 다시), 같은 문제의 중복. '
2632
+ + '사람이 "지워줘 / 걷어내 / 그 사례 치워" 라고 할 때.\n'
2633
+ + '★ **「같은 제목이면 덮어쓴다」와 다르다** — 덮어쓰기는 장을 고치는 것(제목이 같아야 한다), 잊기는 장을 없애는 것. '
2634
+ + '틀린 내용을 고칠 것이면 remember_case 로 같은 제목에 다시 적는 쪽이 맞다. '
2635
+ + '잊은 뒤 같은 제목으로 remember_case 하면 **새 장**으로 다시 선다(옛 본문은 휴지통에만).\n'
2636
+ + '★ 두뇌 장이 이 사례를 가리키고 있으면 감사에 고아로 선다 — 잊기는 그 링크를 안 고친다.\n'
2637
+ + '★ 되돌리는 도구는 없다 — 복구는 서버 휴지통에서 사람이 한다. 지우기 전에 get_case 로 전문을 한 번 읽어라.',
2638
+ inputSchema: {
2639
+ type: 'object',
2640
+ properties: {
2641
+ tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
2642
+ path: { type: 'string', description: 'search_cases·remember_case 가 준 path 그대로(cases/….md). 제목을 줘도 저장과 같은 자로 찾는다.' },
2643
+ },
2644
+ required: ['tenant', 'path'],
2645
+ },
2646
+ },
2558
2647
  {
2559
2648
  name: 'list_breaks',
2560
2649
  description:
@@ -4112,12 +4201,14 @@ async function callTool(name, args = {}) {
4112
4201
  title: args.title, what: args.what,
4113
4202
  who: args.who || '', when: args.when || '', why: args.why || '',
4114
4203
  reopen: args.reopen || '', status: args.status || '', tags: args.tags || [],
4115
- work: args.work || '', order: args.order || [],
4204
+ work: args.work || '', order: args.order || [], plan: args.plan || [],
4205
+ picks: Array.isArray(args.picks) ? args.picks : [], // 집은 자리(2026-09-03)
4116
4206
  // ★ 출처는 우리가 채운다 — 모델에게 안 묻는다(remember_case 와 같은 규율). 표지가
4117
4207
  // 열린 일과 만나 항목으로 설 때 「누가 심었나」(간트의 세션 자국)가 이것이다.
4118
4208
  ...myCitation(),
4119
4209
  });
4120
4210
  const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
4211
+ if (Number(r.picks) > 0) lines.push(`집은 자리 ${r.picks}건 실림 — 다른 기계·다른 사람도 그 요소를 본다.`);
4121
4212
  // 안 적은 것을 **그렇게 말한다** — 조용히 열린 결정을 만들거나 계산 밖에 두지 않는다.
4122
4213
  if (!args.reopen) {
4123
4214
  lines.push('재개 조건을 안 적어 이 결정은 「아직 열린 것」으로 읽힌다. 끝난 결정이면 reopen 에 "조건 없음(종결)" 을 적을 것.');
@@ -4125,6 +4216,9 @@ async function callTool(name, args = {}) {
4125
4216
  if (!args.tags || !args.tags.length) {
4126
4217
  lines.push('갈래(tags)가 없어 이 결정은 「일」 후보 계산에 안 들어간다.');
4127
4218
  }
4219
+ if (Array.isArray(args.plan) && args.plan.length) {
4220
+ lines.push(`계획 기간 ${args.plan.length}건을 적었다 — 그 항목의 간트 「계획」 열·점선 막대에 바로 선다(항목 단위로 가장 새 결정이 이긴다).`);
4221
+ }
4128
4222
  // 전체 맥락 정리 줄이 섰는지도 말한다 — 결정 저장이 종합의 박자가 됐다(2026-08-31). 옛 서버(칸 없음)는 침묵.
4129
4223
  if (r.insights_queued === true) {
4130
4224
  lines.push('이 결정으로 전체 맥락 정리가 다시 돕니다(몇 분 뒤 지식 화면에).');
@@ -4145,17 +4239,41 @@ async function callTool(name, args = {}) {
4145
4239
  }
4146
4240
  return lines.join('\n');
4147
4241
  }
4242
+ case 'forget_decision': {
4243
+ const r = await api('DELETE', '/api/wiki/decision?tenant=' + encodeURIComponent(args.tenant)
4244
+ + '&path=' + encodeURIComponent(String(args.path || '')));
4245
+ const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
4246
+ if (r.trashed_to) lines.push(`휴지통: ${r.trashed_to} (복구는 서버에서 사람이 파일을 되돌린다)`);
4247
+ if (typeof r.index_dropped === 'number') {
4248
+ lines.push(r.index_dropped ? `index.md 에서 ${r.index_dropped}줄 걷었다.` : 'index.md 에는 이 장의 줄이 없었다.');
4249
+ }
4250
+ return lines.join('\n');
4251
+ }
4252
+ case 'forget_case': {
4253
+ // forget_decision 과 같은 꼴(2026-09-03) — 서버 note·trashed_to·index_dropped 를 **그대로** 옮긴다.
4254
+ // 프로즈를 여기서 다시 지으면 두 벌이 된다(remember_decision 의 work_note 교훈, 2026-09-01).
4255
+ const r = await api('DELETE', '/api/wiki/case?tenant=' + encodeURIComponent(args.tenant)
4256
+ + '&path=' + encodeURIComponent(String(args.path || '')));
4257
+ const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
4258
+ if (r.trashed_to) lines.push(`휴지통: ${r.trashed_to} (복구는 서버에서 사람이 파일을 되돌린다)`);
4259
+ if (typeof r.index_dropped === 'number') {
4260
+ lines.push(r.index_dropped ? `index.md 에서 ${r.index_dropped}줄 걷었다.` : 'index.md 에는 이 장의 줄이 없었다.');
4261
+ }
4262
+ return lines.join('\n');
4263
+ }
4148
4264
  case 'remember_case': {
4149
4265
  const r = await api('POST', '/api/wiki/case?tenant=' + encodeURIComponent(args.tenant), {
4150
4266
  title: args.title, symptom: args.symptom, cause: args.cause || '', fix: args.fix,
4151
4267
  evidence: args.evidence || '', tags: args.tags || [],
4152
4268
  paths: args.paths || [], commands: args.commands || [], errors: args.errors || [],
4269
+ picks: Array.isArray(args.picks) ? args.picks : [], // 집은 자리(2026-09-03) — 그대로 넘긴다, 모양은 서버가 검사
4153
4270
  // ★ **출처는 우리가 채운다 — 모델에게 안 묻는다.** 스키마에 노출하면 지어낸다
4154
4271
  // (DESIGN-session-as-vcs.md:136-141). 모르면 안 보내고, 서버는 안 적는다.
4155
4272
  ...myCitation(),
4156
4273
  });
4157
4274
  // 밀기 결과도 그대로 옮긴다 — 몇에게 갔고 누가 왜 못 받았는지(구서버는 필드가 없어 침묵).
4158
4275
  const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
4276
+ if (Number(r.picks) > 0) lines.push(`집은 자리 ${r.picks}건 실림 — 다른 기계·다른 사람도 그 요소를 본다.`);
4159
4277
  // ★ 창구가 낸 경고를 **맨 앞에** 옮긴다 — 쓰는 그 순간이 고치기 가장 싼 자리다(2026-09-01).
4160
4278
  if (r.warn) lines.push('⚠ ' + r.warn);
4161
4279
  if (Array.isArray(r.matched_paths)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genetics-mcp",
3
- "version": "2.16.7",
3
+ "version": "2.17.1",
4
4
  "description": "문서 포털 발행·세션 이식·사례 기억 MCP — 데이터를 주면 규격 문서가 나온다 (짝인 스킬 동봉)",
5
5
  "author": "주식회사 LIM (LIM Inc.)",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -144,7 +144,8 @@ report-v2 144종을 이렇게 훑어 4건이 나왔다 — CSS 만 봐서는 하
144
144
  | 층 | 무엇 | 빠뜨리면 |
145
145
  |---|---|---|
146
146
  | **가구** | 표지 · 차례 · 간지 · 측면 색인 · 표 캡션 · 판권지 | 웹페이지로 읽힘 |
147
- | **페이지 문법** | **두 단 본문 + 여백 주석 단** · 위치 브레드크럼 머리띠 · PART 층위 · 개조식 톤 | 「문서처럼 꾸민 웹페이지」에서 멈춤 |
147
+ | **페이지 문법** | **두 단 본문 + 여백 주석 단** · 위치 브레드크럼 머리띠 · PART 층위 | 「문서처럼 꾸민 웹페이지」에서 멈춤 |
148
+ | **자리별 톤** | 기록 자리는 개조식, **요청 자리는 하십시오체**(톤 절) | 기록이 겉돌거나, 요청이 하대로 읽힘 |
148
149
 
149
150
  본문 쪽의 실제 골격:
150
151
 
@@ -291,7 +292,17 @@ PDF의 파스텔을 그대로 옮기면 「로봇처럼 베낀 것」이 된다.
291
292
 
292
293
  질문형(Why 블록, 활용법 라벨)은 `~하는가?` / `~인가?`. `~합니까?` 아님.
293
294
 
294
- 검사: 본문에서 `습니다|십시오|합니다|입니다|됩니다|주세요` 정규식이 **0건**이어야 한다(스크립트 태그 제외).
295
+ 검사: **기록 자리**에서 `습니다|십시오|합니다|입니다|됩니다|주세요` 정규식이 **0건**이어야 한다(스크립트 태그 제외).
296
+
297
+ ⚠ **요청 자리는 반대다 — 하십시오체로 맺는다.** 상대에게 직접 말을 거는 자리
298
+ (요청 절의 강조문·판단 항목, PART I 간지, 판권지 회신 안내)는 종결까지 존댓말이어야 한다 —
299
+ 「확인 **부탁드립니다**」·「회신 주시면 **반영하겠습니다**」·「열어 **주시기 바랍니다**」.
300
+ 명사형 종결 `-ㅁ/-음` 은 한국어 상대높임 등급 **밖**이라 청자 높임이 0 이고, 사람에게 직접 쓰면
301
+ 하대로 읽힌다 — 겸양 어간이 든 「부탁드림」도 존대가 아니다. 제목은 명사형 「~ 요청」(검수·보고) 또는 물음(결정 문서의 판단 항목)으로 둔다.
302
+ `tone.mjs` 는 판단 항목 본문(`p.ask__body`)과 「」 인용만 빼고 센다(2026-09-12, 그 파일 ① 경어체 절) — **그 밖의 요청 자리
303
+ (강조문·간지·판권지 회신 안내)에서 난 S1 경어체 지적은 사람이 걸러 읽고 그대로 둔다.** 이것을 몰라 말투 반려가 네 번 났다
304
+ (2026-09-02 · 09-03 ×2 · 09-11). 정본은 genetics(hnl)
305
+ `decisions/고객-문서-요청-자리는-하십시오체로-맺는다-명사형-종결은-하대로-읽힌다.md`.
295
306
 
296
307
  ### 한 문장 한 줄 — 한글은 마침표에서 줄을 바꾼다
297
308
 
@@ -437,7 +448,7 @@ flush」로 두 방식이 섞인다 — 실측: 여백 주석 단에서 Key Poin
437
448
  | `read.mjs` | 읽는 글 전수의 **글자/줄 · 행간 · 대비** | 43자 이하 · 행간 1.6+ · 지적 0 |
438
449
  | `flexcheck.mjs` | flex/grid 안에서 **문장이 토막 나는 곳** | 0건 |
439
450
  | `fontchk.mjs` | **렌더된** 활자 크기·굵기 분포를 4개 폭(1440·1100·900·390)에서. CSS 토큰화로는 안 잡히는 것 | 스케일 밖 **0** · 굵기 **3종** · 굵게 **≤55%** |
440
- | `tone.mjs` | ① 본문 **경어체 잔존**(S1) ② **늘어짐 지표 9종**(S2, 천 자당 밀도) — 문두 접속사 · 추측 종결 · 이중 완곡 · 「이는 ~」 · 형식명사 결합 · 괄호 부연 · 낫표 · 대시 · 인라인 강조 ③ **한 문장 한 줄**(S1) — 마침표로 이어 붙인 블록 | 경어체 **0건** · 이어 붙인 블록 **0개** · 밀도 초과 **0항** |
451
+ | `tone.mjs` | ① 본문 **경어체 잔존**(S1) ② **늘어짐 지표 9종**(S2, 천 자당 밀도) — 문두 접속사 · 추측 종결 · 이중 완곡 · 「이는 ~」 · 형식명사 결합 · 괄호 부연 · 낫표 · 대시 · 인라인 강조 ③ **한 문장 한 줄**(S1) — 마침표로 이어 붙인 블록 | 경어체 **기록 자리 0건**(요청 자리 지적은 의도 — 위 톤 절) · 이어 붙인 블록 **0개** · 밀도 초과 **0항** |
441
452
  | `sentbreak.py` | 검사가 아니라 **적용** — 문장마다 `<span class="sn">` 을 새긴다. 멱등 | 돌린 뒤 `contentdiff.sh` 통과 |
442
453
  | `contentdiff.sh` | **내용 불변 게이트** — 조판이 본문 낱말 · `id` · `data-*` · 앵커를 바꿨는지 전후 대조 | 전 항목 차이 **0** |
443
454
  | `pdfcheck.sh` | 실제 PDF 생성 → 쪽수 · 절별 새 쪽 시작 · 쪽번호 · 러닝 푸터 | 표지 단독 · 절마다 새 쪽 · 푸터 = 쪽수-1 |
@@ -6,8 +6,23 @@
6
6
  """
7
7
  import re
8
8
 
9
- # ① 날짜 꼴을 먼저 가린다: 2026. 8. 21. / 2026. 8.
10
- DATE = re.compile(r'\d{4}\.\s*\d{1,2}\.(?:\s*\d{1,2}\.)?')
9
+ # ① 날짜 꼴을 먼저 가린다: 2026. 8. 21. / 2026. 8. / 9. 8.
10
+ #
11
+ # ⚠ 연도 없는 「달. 일.」을 오래 안 가려서 사고가 났다(2026-09-11, FISOFT #176 표 6-1).
12
+ # 관보 표는 연도를 열 머리나 기준일로 빼고 칸에는 `9. 8.` 만 적는 것이 표준인데,
13
+ # 그 꼴이 마스크에 없어 `9. 8. 출시됨` 이 `9.` / `8.` / `출시됨` **세 줄로 쪼개졌다.**
14
+ # 같은 칸의 아이폰 쪽은 마침표가 없어 한 줄로 멀쩡히 서는 바람에, 안드로이드만
15
+ # 파편으로 보여 「아직 심사 안 된 것」으로 읽혔다.
16
+ #
17
+ # 좁게 잡는다 — 두 수가 **마침표와 공백만 사이에 두고 맞붙은** 꼴만 가린다.
18
+ # · `부록 2. 자세한 …` 은 뒤가 한글이라 안 걸린다.
19
+ # · `전환 완료. 4건은 …` 은 `4` 뒤가 마침표가 아니라 안 걸린다.
20
+ # · `1. 첫째. 2. 둘째.` 처럼 사이에 글이 낀 열거도 안 걸린다.
21
+ # · 앞이 숫자·마침표면 제외해 온전한 연도 꼴의 뒷부분을 물지 않는다(연도 꼴이 먼저 걸리므로 이중 방어).
22
+ DATE = re.compile(
23
+ r'\d{4}\.\s*\d{1,2}\.(?:\s*\d{1,2}\.)?' # 2026. 8. 21. / 2026. 8.
24
+ r'|(?<![\d.])\d{1,2}\.\s*\d{1,2}\.(?!\s*\d)' # 9. 8. (연도 없는 달·일)
25
+ )
11
26
  TAG = re.compile(r'<[^>]*>')
12
27
 
13
28
  # ② 문장 끝
@@ -94,6 +109,20 @@ if __name__ == '__main__':
94
109
  # (① 앞의 경계가 안 잡혀 두 문장이 붙었다).
95
110
  ("세 가지를 확인함. ① 정상 성립함. ② 본문이 열림.", 2),
96
111
  ("확인함.\n① 성립함.\n② 열림.\n③ 일치함.", 3),
112
+ # 연도 없는 날짜 — 2026-09-11 실측. 고치기 전엔 여기서 0 대신 2 가 나왔다
113
+ # (FISOFT #176 표 6-1 의 안드로이드 칸이 세 줄로 쪼개진 자국).
114
+ ("9. 8. 출시됨", 0),
115
+ ("9. 10. 출시됨", 0),
116
+ ("9. 8.", 0),
117
+ ("전체 출시가 9. 10. 에 끝났음. 아이폰은 대기임.", 1),
118
+ # 그 마스크가 삼키면 안 되는 것들 — 위 넷을 고치면서 깨지기 쉬운 자리.
119
+ # 둘 다 옛 정규식과 **경계 수가 같음**을 대조로 확인함(2026-09-11, 8표본 중
120
+ # 달라진 것은 연도 없는 날짜 하나뿐).
121
+ # ⚠ 아래 3 은 숫자 열거 머리(`1.` `2.`)를 문장 끝으로 세는 **원래 동작**이다.
122
+ # 머리글자 배제(INITIAL)가 라틴 한 글자만 보기 때문인데, 관보 본문 열거는
123
+ # 원문자(①②)를 쓰므로 여태 문제가 안 됐다. 이번 수정과는 무관하다.
124
+ ("1. 첫째 항목임. 2. 둘째 항목임.", 3),
125
+ ("확인한 절은 6. 자세한 것은 부록 2.", 1),
97
126
  ]
98
127
  bad = 0
99
128
  for s, want in T:
@@ -48,7 +48,18 @@ export function analyze(raw0) {
48
48
 
49
49
  // ── ① 경어체 (S1) ────────────────────────────────────────────
50
50
  const POLITE = /[^<>]{4,}?(?:습니다|십시오|합니다|입니다|됩니다|드립니다|주세요|하세요|해요)[.。]?/g;
51
- const polite = txt.match(POLITE) || [];
51
+ // 2026-09-12 — 두 자리는 세지 않는다. ⑴ 판단 항목 본문(`p.ask__body`)은 상대에게 말을 거는 **요청 자리**라
52
+ // 종결까지 하십시오체가 규칙이다(genetics-report SKILL 5·5a). 여기서 세면 규칙을 지킨 문서가 떨어지고,
53
+ // 떨어지지 않으려고 「회신 주시면 됨」 같은 하대로 돌아간다 — 2026-09-03 에 그렇게 반려됐다.
54
+ // ⑵ 「」 안은 원문 인용이다(SKILL 11 — 인용은 원문 문구 그대로). 앱 문구 「다시 입력해 주세요」를 인용한
55
+ // 표가 경어체로 잡히면 인용을 비틀게 된다. 기록 자리(문단·표·요약)의 우리 문장은 그대로 센다.
56
+ const politeTxt = raw
57
+ .replace(/<p class="ask__body"[^>]*>[\s\S]*?<\/p>/g, ' ')
58
+ .replace(/<[^>]+>/g, ' ')
59
+ .replace(/&#x27;/g, "'").replace(/&amp;/g, '&').replace(/&nbsp;/g, ' ')
60
+ .replace(/「[^「」]{0,200}」/g, ' ')
61
+ .replace(/\s+/g, ' ').trim();
62
+ const polite = politeTxt.match(POLITE) || [];
52
63
  lines.push(`[S1] 경어체 잔존: ${polite.length}건 (0 이어야 함 — 개조식은 ~함 / ~했음 / ~필요)`);
53
64
  polite.slice(0, 12).forEach(h => lines.push(' ✗ ' + h.trim().slice(0, 80)));
54
65
 
@@ -73,7 +84,12 @@ export function analyze(raw0) {
73
84
  lines.push(`\n[S1] 상대에게 내리는 명령: ${order.length + duty.length}건 (0 이어야 함 — 상대가 할 일에는 높임 어간을 살린다)`);
74
85
  [...duty, ...order].slice(0, 12).forEach(h => lines.push(' ✗ ' + h.trim()));
75
86
  if (order.length + duty.length) {
76
- lines.push(' → 「회신 필요」 → 「회신 주시면 됨」 · 「~해 주셔야 함」 → 명사형 「~ 요청」');
87
+ // ⚠ 2026-09-11 교체. 옛 처방은 「회신 주시면 됨」이었는데 그 표현 자체가 2026-09-03 에
88
+ // 반려됐다(「이런 말투 존나 싸가지 없지 않아?」). 명사형 종결 `-ㅁ/-음` 은 한국어
89
+ // 상대높임 등급 밖이라 청자 높임이 0 이고, 사람에게 직접 쓰면 하대로 읽힌다 —
90
+ // 겸양 어간이 든 「부탁드림」도 마찬가지다. 요청 자리는 종결까지 존댓말로 맺는다.
91
+ lines.push(' → 「회신 필요」 → 「회신 부탁드립니다」 · 「~해 주셔야 함」 → 「~해 주시기 바랍니다」');
92
+ lines.push(' → 제목은 명사형 「~ 요청」 그대로. 「부탁드림·바람·하시면 됨」은 존대가 아니다.');
77
93
  }
78
94
 
79
95
  // ── ②b-1 문체 유형 (S1 — 한 번만 나와도 고친다) ─────────────
@@ -137,6 +137,19 @@ test('상대에게 내리는 명령 — 개조식의 반대쪽 벽 (2026-09-02)'
137
137
  assert.match(analyze(wrap('검수 결과는 별도 회신 필요.')).lines.at(-1), /상대에게 내리는 명령 1건/);
138
138
  });
139
139
 
140
+ test('경어체 — 판단 항목 본문(요청 자리)과 「」 인용은 세지 않는다, 기록 자리는 센다(2026-09-12)', () => {
141
+ const ask = '<ol class="asks"><li class="ask" id="case-01"><div class="ask__head"><h4 class="ask__title">방식 A 를 들일 것인가</h4></div>'
142
+ + '<p class="ask__body"><span class="sn">2026년 9월 30일까지 회신해 주시기 바랍니다.</span> <span class="sn">고른 길은 다음 작업에 넣겠습니다.</span></p></li></ol>';
143
+ const r = analyze(ask + wrap('앱 안내문의 맺음은 「주소를 다시 입력해 주세요」임.'));
144
+ assert.equal(r.polite.length, 0, r.polite.join(' | '));
145
+ // 대조군 — 같은 문장이 기록 자리(문단)에 서면 센다. 뮤테이션: 요청 자리 제외를 문단까지 넓히면 여기서 죽는다.
146
+ const c = analyze(wrap('고른 길은 다음 작업에 넣겠습니다.'));
147
+ assert.equal(pos(c.polite.length), 1);
148
+ // 대조군 — 인용 괘 밖의 경어체는 센다(괘 안만 뺀다).
149
+ const q = analyze(wrap('앱 문구는 「다시 입력」이며 이대로 두면 됩니다.'));
150
+ assert.equal(pos(q.polite.length) >= 1, true);
151
+ });
152
+
140
153
  test('양성 표본 수 — 0표본 통과 방지', () => {
141
154
  console.log(` 양성 표본 ${positives}건 (0 이면 이 시험은 아무것도 안 잰 것)`);
142
155
  assert.equal(positives >= 28, true, String(positives));