genetics-mcp 2.19.3 → 2.21.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.
- package/bin/aarch64-apple-darwin/gcell +0 -0
- package/bin/aarch64-unknown-linux-musl/gcell +0 -0
- package/bin/manifest.json +4 -4
- package/bin/x86_64-apple-darwin/gcell +0 -0
- package/bin/x86_64-unknown-linux-musl/gcell +0 -0
- package/genetics.mjs +245 -168
- package/package.json +2 -2
- package/skills/genetics-report/SKILL.md +40 -4
- package/skills/genetics-report/references/history.md +13 -0
- package/skills/genetics-report/references/lim-portal-format.md +57 -11
|
Binary file
|
|
Binary file
|
package/bin/manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"aarch64-apple-darwin": "
|
|
3
|
-
"aarch64-unknown-linux-musl": "
|
|
4
|
-
"x86_64-apple-darwin": "
|
|
5
|
-
"x86_64-unknown-linux-musl": "
|
|
2
|
+
"aarch64-apple-darwin": "70aa0772b147db518b10f22ab6a027a3b7b6a98bbc6d477505bf69cb9d9b447c",
|
|
3
|
+
"aarch64-unknown-linux-musl": "87fecda2d2348a0403957b3ab7b7ed55dff72560d6768ad241d93861ab5c56d0",
|
|
4
|
+
"x86_64-apple-darwin": "4877439d3b788c303fced3dd458331f21d3661f4eb463572c260e2c82888c165",
|
|
5
|
+
"x86_64-unknown-linux-musl": "58e7821dd31b81232175023a86db8659cb2f5c5d47620e7b939df4e155f93b98"
|
|
6
6
|
}
|
|
Binary file
|
|
Binary file
|
package/genetics.mjs
CHANGED
|
@@ -1426,6 +1426,25 @@ const REPORT_DATA = {
|
|
|
1426
1426
|
how_to_check: { type: 'string' },
|
|
1427
1427
|
remaining: { type: 'string', description: '고객 확인이 필요한 사항(선택)' },
|
|
1428
1428
|
source_url: { type: 'string', description: 'http(s) 제보 원본 링크(선택)' },
|
|
1429
|
+
quotes: {
|
|
1430
|
+
type: 'array',
|
|
1431
|
+
description: '**이 건에 묶인** 원문 인용 — 절의 `quotes` 와 같은 모양({text, lang?, source_text, source_url?, verdict?, translation?}). '
|
|
1432
|
+
+ '건 필드표 바로 아래 그 건 안에 선다. 건이 여럿인 절에서 근거는 절이 아니라 건에 묶는다 — '
|
|
1433
|
+
+ '절에 두면 건 목록 뒤에 한꺼번에 서서 어느 건의 근거인지 캡션 글자로만 이어진다(2026-09-17).',
|
|
1434
|
+
items: { type: 'object' },
|
|
1435
|
+
},
|
|
1436
|
+
tables: {
|
|
1437
|
+
type: 'array',
|
|
1438
|
+
description: '**이 건에 묶인** 자료표 — 절의 `tables` 와 같은 모양({caption, cols[], rows[[]], align?, note?}). '
|
|
1439
|
+
+ '번호는 절과 한 셈(건의 것이 먼저).',
|
|
1440
|
+
items: { type: 'object' },
|
|
1441
|
+
},
|
|
1442
|
+
plates: {
|
|
1443
|
+
type: 'array',
|
|
1444
|
+
description: '**이 건에 묶인** 도판의 `assets[].id` 목록. 건 안이라 늘 본문 단 폭이다 — 전폭(wide) 자산은 절 `plates` 에. '
|
|
1445
|
+
+ '그림 번호는 절과 한 셈(건의 것이 먼저). 없는 id 는 발행 거절.',
|
|
1446
|
+
items: { type: 'string' },
|
|
1447
|
+
},
|
|
1429
1448
|
uid: {
|
|
1430
1449
|
type: 'string',
|
|
1431
1450
|
description: '이 건의 **불변 이름**(영소문자·숫자 6자). 안 주면 서버가 발급한다. '
|
|
@@ -1832,6 +1851,28 @@ function mySessionId() {
|
|
|
1832
1851
|
return process.env.CLAUDE_CODE_SESSION_ID || cursorActiveComposer();
|
|
1833
1852
|
}
|
|
1834
1853
|
|
|
1854
|
+
/**
|
|
1855
|
+
* 이 세션에 **훅 레인**이 있는가 — MCP 가 관측·메일 폴을 대신 돌 이유가 있는지를 가른다(자기 일정 표 `SCHEDULE`).
|
|
1856
|
+
*
|
|
1857
|
+
* 설계(cell/README 「훅 없이 관측한다」 · 결정 2026-09-17): 저장소 관측은 세포 훅의 일이고, MCP 의 20초
|
|
1858
|
+
* `gcell observe` 는 **훅이 없는 클라이언트(Codex)만을 위한 예외**다. 그런데 클라이언트를 안 가리고 모든 창에서
|
|
1859
|
+
* 돌아, 창 26개가 20초마다 세포를 둘씩 띄웠다(초당 2.6 fork — 2026-09-17 실측).
|
|
1860
|
+
*
|
|
1861
|
+
* 판정은 **부팅 때부터 있는 사실**로 한다: Claude Code 는 env, Cursor 는 활성 대화 원장으로 자기를 안다 —
|
|
1862
|
+
* 둘 다 세포 훅이 거는 클라이언트다. 둘 다 없으면(Codex·미지) 훅이 없다. `initialize` 의 clientInfo 는 뜬 뒤에야
|
|
1863
|
+
* 오고 이름이 실측되지 않아 판정에 안 쓴다(그 줄에서 로그로만 남긴다). `GENETICS_HOOK_LANE=0|1` 은 시험·수동 강제.
|
|
1864
|
+
* 한 번 참이면 끝까지 참이다(세션이 훅을 잃지 않는다). 거짓은 60초만 기억한다 — Cursor 원장은 창이 뜬 뒤에 생긴다.
|
|
1865
|
+
*/
|
|
1866
|
+
let hookLaneMemo = { v: false, at: 0 };
|
|
1867
|
+
function hookLane() {
|
|
1868
|
+
if (cfg.GENETICS_HOOK_LANE === '1') return true;
|
|
1869
|
+
if (cfg.GENETICS_HOOK_LANE === '0') return false;
|
|
1870
|
+
if (hookLaneMemo.v) return true;
|
|
1871
|
+
if (Date.now() - hookLaneMemo.at < 60_000) return false;
|
|
1872
|
+
hookLaneMemo = { v: !!(process.env.CLAUDE_CODE_SESSION_ID || cursorActiveComposer()), at: Date.now() };
|
|
1873
|
+
return hookLaneMemo.v;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1835
1876
|
function gitHere() {
|
|
1836
1877
|
try {
|
|
1837
1878
|
const r = spawnSync('git', ['-C', process.cwd(), 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
@@ -2324,17 +2365,11 @@ function transcriptHead(file) {
|
|
|
2324
2365
|
const cut = from > 0 ? text.indexOf('\n') : -1;
|
|
2325
2366
|
const head = jsonlHead(from > 0 ? (cut < 0 ? '' : text.slice(cut + 1)) : text);
|
|
2326
2367
|
|
|
2327
|
-
// 줄 수 — 늘어난 만큼만 센다.
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
lineCount = was.head.lines + countNl(grow);
|
|
2333
|
-
} else {
|
|
2334
|
-
const all = Buffer.alloc(st.size);
|
|
2335
|
-
fs.readSync(fd, all, 0, all.length, 0);
|
|
2336
|
-
lineCount = countNl(all);
|
|
2337
|
-
}
|
|
2368
|
+
// 줄 수 — 늘어난 만큼만 센다. 처음 보는 파일·줄어든 파일은 처음부터 세되 **파일을 통째로 올리지 않는다**
|
|
2369
|
+
// (2026-09-17: 38MB 전사를 `Buffer.alloc(size)` 로 올려 바이트 루프로 세던 자리가 부팅·압축 뒤의 버스트였다).
|
|
2370
|
+
const lineCount = (was && st.size >= was.size)
|
|
2371
|
+
? was.head.lines + countNlRange(fd, was.size, st.size)
|
|
2372
|
+
: countNlRange(fd, 0, st.size);
|
|
2338
2373
|
head.lines = lineCount;
|
|
2339
2374
|
headCache.set(file, { size: st.size, mtimeMs: st.mtimeMs, head });
|
|
2340
2375
|
return head;
|
|
@@ -2366,9 +2401,18 @@ function jsonlHead(text) {
|
|
|
2366
2401
|
}
|
|
2367
2402
|
return { id: '', ms: 0 };
|
|
2368
2403
|
}
|
|
2369
|
-
|
|
2404
|
+
/** `[from, to)` 구간의 개행 수 — 1MB 조각으로 읽어 `indexOf` 로 센다. 메모리는 파일 크기와 무관하게 1MB 다. */
|
|
2405
|
+
function countNlRange(fd, from, to) {
|
|
2406
|
+
const NL_CHUNK = 1024 * 1024;
|
|
2370
2407
|
let n = 0;
|
|
2371
|
-
|
|
2408
|
+
if (to <= from) return 0;
|
|
2409
|
+
const buf = Buffer.allocUnsafe(Math.min(NL_CHUNK, to - from));
|
|
2410
|
+
for (let pos = from; pos < to;) {
|
|
2411
|
+
const got = fs.readSync(fd, buf, 0, Math.min(buf.length, to - pos), pos);
|
|
2412
|
+
if (got <= 0) break;
|
|
2413
|
+
for (let i = buf.indexOf(0x0a); i !== -1 && i < got; i = buf.indexOf(0x0a, i + 1)) n++;
|
|
2414
|
+
pos += got;
|
|
2415
|
+
}
|
|
2372
2416
|
return n;
|
|
2373
2417
|
}
|
|
2374
2418
|
|
|
@@ -3775,8 +3819,6 @@ async function beatTick() {
|
|
|
3775
3819
|
// 옮겨진 세션은 resolve 가 새 자리를 준다(심박 프레임은 그대로 — §4.5).
|
|
3776
3820
|
try { await resolveMoved(reply); } catch { /* 다음 바퀴에 다시 */ }
|
|
3777
3821
|
try { await autoUploadRound(reply, recs, cands); } catch { /* 한 바퀴가 실패해도 심박은 계속 뛴다 */ }
|
|
3778
|
-
// 두뇌가 짚은 모순을 긷는다 — 실패해도 심박을 안 멈춘다(안 오는 것이 고장은 아니다).
|
|
3779
|
-
try { await brainPull(); } catch { /* 그물·자격 문제는 다음 바퀴에 다시 */ }
|
|
3780
3822
|
} finally {
|
|
3781
3823
|
beatRunning = false;
|
|
3782
3824
|
}
|
|
@@ -3801,7 +3843,6 @@ async function beatTick() {
|
|
|
3801
3843
|
// 시험은 20초를 못 기다린다. **갈아 끼울 수 있게** 둔다(`GCELL_STATE_DIR` 과 같은 규율) —
|
|
3802
3844
|
// 다만 하한을 둬서 실수로 0.01초가 들어가 기계를 태우는 일은 막는다.
|
|
3803
3845
|
const OBSERVE_EVERY_MS = Math.max(200, Number(cfg.GENETICS_OBSERVE_MS) || 20_000);
|
|
3804
|
-
let observeTimer = null;
|
|
3805
3846
|
|
|
3806
3847
|
function observeTick() {
|
|
3807
3848
|
const { bin } = cellCheck();
|
|
@@ -3834,27 +3875,19 @@ function observeTenant() {
|
|
|
3834
3875
|
} catch { return ''; }
|
|
3835
3876
|
}
|
|
3836
3877
|
|
|
3837
|
-
function startObserve() {
|
|
3838
|
-
if (cfg.GENETICS_NO_BEAT) return; // 심박을 끄는 사람은 이것도 안 원한다
|
|
3839
|
-
observeTick();
|
|
3840
|
-
observeTimer = setInterval(observeTick, OBSERVE_EVERY_MS);
|
|
3841
|
-
observeTimer.unref?.(); // 창을 닫으면 노드가 남지 않게
|
|
3842
|
-
}
|
|
3843
|
-
|
|
3844
3878
|
/*
|
|
3845
|
-
* ──
|
|
3879
|
+
* ── 메일은 **심박에 얹지 않는다** (2026-08-25 운영 첫 왕복 실측) ─────────────────
|
|
3846
3880
|
* 전에는 beatTick 꼬리에 얹었는데, beatTick 은 색인에 올린 세션이 없으면 **일찍 빠져나간다**
|
|
3847
3881
|
* (`if (!recs.length) return`). cell/README 가 observeTick 에 대해 경고한 바로 그 무늬를
|
|
3848
|
-
* 밟았다 — 새로 뜬 세션은 색인 밖이라 메일을 영영 안 길었다.
|
|
3882
|
+
* 밟았다 — 새로 뜬 세션은 색인 밖이라 메일을 영영 안 길었다.
|
|
3883
|
+
*
|
|
3884
|
+
* 긷는 자리는 둘이다(자기 일정 표, 2026-09-17): 훅 레인이 있는 세션은 **도구를 부를 때**(60초 상한) —
|
|
3885
|
+
* 설계의 도구응답 레인 그대로다(DESIGN-mail §6.3 「세션이 도구를 불러야 붙는다 — 깨우지 못한다」).
|
|
3886
|
+
* 훅이 없는 세션만 60초 타이머로 긷는다. 어느 쪽이든 서버는 사람 토큰만 받는다.
|
|
3849
3887
|
*/
|
|
3850
|
-
|
|
3851
|
-
function startMailPoll() {
|
|
3852
|
-
if (cfg.GENETICS_NO_BEAT) return;
|
|
3888
|
+
function mailPollReady() {
|
|
3853
3889
|
const cred = credential();
|
|
3854
|
-
|
|
3855
|
-
mailPull().catch(() => {});
|
|
3856
|
-
mailTimer = setInterval(() => { mailPull().catch(() => {}); }, MAIL_POLL_MS);
|
|
3857
|
-
mailTimer.unref?.();
|
|
3890
|
+
return !!(BASE && cred && cred.kind === 'user');
|
|
3858
3891
|
}
|
|
3859
3892
|
|
|
3860
3893
|
function startBeat() {
|
|
@@ -3979,13 +4012,17 @@ const TOOLS = [
|
|
|
3979
4012
|
+ '각 메모의 `anchor.path`(예 `case:k7m2p9/fix`)가 **발행 데이터의 어느 칸을 고치라는 것인지**를 '
|
|
3980
4013
|
+ '그대로 말한다 — 그 칸을 고쳐 update_report 로 되돌려 보내고, resolve_note 로 반영을 표시한다.\n'
|
|
3981
4014
|
+ '`anchor_state` 가 `unmoored`·`reparented` 면 문서가 바뀌어 원래 자리를 잃은 것이다 — '
|
|
3982
|
-
+ '그래도 메모는 남아 있고, `anchor.quote_exact` 가 저자가 무엇을 보고 썼는지
|
|
4015
|
+
+ '그래도 메모는 남아 있고, `anchor.quote_exact` 가 저자가 무엇을 보고 썼는지 말한다.\n'
|
|
4016
|
+
+ '같은 호출이 **검토 회신**(고객에게 보이는 글 — 판단 항목의 답이 여기로 온다)도 함께 보여 준다. '
|
|
4017
|
+
+ '문서를 고치기 **전에** 회신의 본문을 읽는다: 각 회신의 where 가 어느 물음·건·도판에 대한 말인지 말한다. '
|
|
4018
|
+
+ '회신대로 고친 뒤의 「원문이 바뀜」은 정상이다 — 핀을 살리려고 문장을 되돌리지 않는다.',
|
|
3983
4019
|
inputSchema: {
|
|
3984
4020
|
type: 'object',
|
|
3985
4021
|
properties: {
|
|
3986
4022
|
id: { type: 'integer', description: '보고서 id' },
|
|
3987
4023
|
state: { type: 'string', description: '`open`(기본, 아직 반영 안 한 것) 또는 `all`' },
|
|
3988
4024
|
uid: { type: 'string', description: '그 건·절의 uid 에 달린 것만(선택)' },
|
|
4025
|
+
channel: { type: 'string', description: '`all`(기본 — 메모와 검토 회신 둘 다) · `notes`(저자 메모만) · `replies`(검토 회신만)' },
|
|
3989
4026
|
},
|
|
3990
4027
|
required: ['id'],
|
|
3991
4028
|
},
|
|
@@ -3996,7 +4033,8 @@ const TOOLS = [
|
|
|
3996
4033
|
'메모에 **답글로** 회신한다. 이견이 있거나 물을 것이 있으면 대화창이 아니라 여기에 쓴다 — '
|
|
3997
4034
|
+ '저자는 자기가 메모를 단 그 자리에서 답을 본다.\n'
|
|
3998
4035
|
+ '고칠 수 없는 이유, 다르게 본 근거, 되묻는 것이 여기 온다. '
|
|
3999
|
-
+ '고쳤으면 답글이 아니라 resolve_note
|
|
4036
|
+
+ '고쳤으면 답글이 아니라 resolve_note 다.\n'
|
|
4037
|
+
+ '★ **검토 회신에는 이 도구로 답하지 않는다** — 그쪽은 고객에게 보이는 글이라 사람이 포털에서 쓴다. 할 말은 작성자에게 대화로 전한다.',
|
|
4000
4038
|
inputSchema: {
|
|
4001
4039
|
type: 'object',
|
|
4002
4040
|
properties: {
|
|
@@ -4010,19 +4048,22 @@ const TOOLS = [
|
|
|
4010
4048
|
{
|
|
4011
4049
|
name: 'resolve_note',
|
|
4012
4050
|
description:
|
|
4013
|
-
'그
|
|
4051
|
+
'그 메모(또는 검토 회신)를 **반영했다**고 표시한다. 반영이 실린 판번호(body_rev)가 함께 남아 '
|
|
4014
4052
|
+ '「무엇에 반영됐는지」를 나중에도 안다.\n'
|
|
4015
4053
|
+ '★ 고치기 **전에** 부르지 말 것 — 이 표시가 곧 「이 메모는 끝났다」이고, '
|
|
4016
|
-
+ '표시하는 순간 미반영 목록에서 빠져 다시 눈에 안
|
|
4054
|
+
+ '표시하는 순간 미반영 목록에서 빠져 다시 눈에 안 띈다.\n'
|
|
4055
|
+
+ '검토 회신은 `note` 대신 `reply` 로 가리킨다 — 표시해도 회신은 고객에게 그대로 보이고, 그 회신이 집은 문장이 바뀐 것을 update_report 가 더는 경고하지 않는다. '
|
|
4056
|
+
+ '문서에 반영할 것이 없는 회신(인사·이미 답한 물음)도 읽었으면 표시한다.',
|
|
4017
4057
|
inputSchema: {
|
|
4018
4058
|
type: 'object',
|
|
4019
4059
|
properties: {
|
|
4020
4060
|
id: { type: 'integer', description: '보고서 id' },
|
|
4021
|
-
note: { type: 'integer', description: '메모 id' },
|
|
4061
|
+
note: { type: 'integer', description: '메모 id(`reply` 와 둘 중 하나)' },
|
|
4062
|
+
reply: { type: 'integer', description: '검토 회신 id — list_notes 의 [회신 N]. 뿌리 회신만 표시할 수 있다' },
|
|
4022
4063
|
rev: { type: 'integer', description: '반영이 실린 body_rev. 안 주면 지금 판' },
|
|
4023
4064
|
resolved: { type: 'boolean', description: '거짓이면 미반영으로 되돌린다' },
|
|
4024
4065
|
},
|
|
4025
|
-
required: ['id'
|
|
4066
|
+
required: ['id'],
|
|
4026
4067
|
},
|
|
4027
4068
|
},
|
|
4028
4069
|
{
|
|
@@ -4594,6 +4635,14 @@ const TOOLS = [
|
|
|
4594
4635
|
* 찍는 자리가 흩어져 있으면 또 한 곳이 빠지므로 여기 하나로 모은다. */
|
|
4595
4636
|
// 자산을 굳히며 이쪽(작성자 기계)에서만 알 수 있는 것 — 서버 경고와 한 목록으로 낸다(readability.md R8).
|
|
4596
4637
|
let ASSET_HINTS = [];
|
|
4638
|
+
/* 회신 핀 예보(update_report) — 서버가 핀마다 낸 판정(`pins[]`)을 한 줄로 센다. 경고는 약해지는 핀만 말하므로,
|
|
4639
|
+
* 이 줄이 없으면 「전부 제자리」와 「핀이 없다」와 「예보가 안 돌았다(옛 서버)」를 에이전트가 못 가른다. 핀이 없으면 안 찍는다. */
|
|
4640
|
+
function pinsText(r) {
|
|
4641
|
+
const p = Array.isArray(r?.pins) ? r.pins : [];
|
|
4642
|
+
if (!p.length) return '';
|
|
4643
|
+
const n = (s) => p.filter((x) => x?.state === s).length;
|
|
4644
|
+
return `\n회신 핀 ${p.length}건 — 제자리 ${n('exact')} · 옮겨 붙음 ${n('moved')} · 원문이 바뀜 ${n('drifted')} · 상위로 ${n('reparented')} · 자리 잃음 ${n('unmoored')}`;
|
|
4645
|
+
}
|
|
4597
4646
|
function warnText(r) {
|
|
4598
4647
|
const w = [...(r?.warnings ?? []), ...ASSET_HINTS];
|
|
4599
4648
|
return w.length
|
|
@@ -5124,116 +5173,6 @@ function cellNotice(why) {
|
|
|
5124
5173
|
].join('\n');
|
|
5125
5174
|
}
|
|
5126
5175
|
|
|
5127
|
-
/* ── ★★ 백신 받기 — 두뇌가 짚은 **모순**을 이 세션 눈앞에 놓는다 ──────────────
|
|
5128
|
-
*
|
|
5129
|
-
* 사용자의 진단(2026-08-24): 여러 세포가 한 군체를 다룰 때 **정상적으로 일하는 사람이
|
|
5130
|
-
* 두뇌에겐 바이러스가 된다.** 그러면 두뇌가 그 세션에게 **되돌려 줄 줄 알아야** 한다.
|
|
5131
|
-
*
|
|
5132
|
-
* ── 왜 세포(gcell)가 아니라 여기서 긷나 ────────────────────────────────────
|
|
5133
|
-
* 세포도 같은 장부를 긷지만 `kind != "write"` 를 **버린다**(`cell/src/server.rs:254`).
|
|
5134
|
-
* 통과시켜도 `root.join(ref)` 로 ref 를 경로 취급해 **없는 파일**을 「최근에 바뀐 것」으로
|
|
5135
|
-
* 만든다 — 그 파일의 주석이 바로 그 해악을 경고한다. 세포를 다시 굽는 것은 별건이라,
|
|
5136
|
-
* 검토는 이 자리에서 **직접** 긷는다(사람 자격으로 열리는 창구다).
|
|
5137
|
-
*
|
|
5138
|
-
* ── 처음 한 바퀴에 옛날 것을 읊지 않는다 ────────────────────────────────────
|
|
5139
|
-
* 커서가 없으면 지난 것을 전부 「지금 일」로 읽는다. 그래서 첫 바퀴는 **커서만 맞추고**
|
|
5140
|
-
* 최근 10분 것만 말한다. 커서는 색인에 적어 두므로 이 프로세스가 죽었다 살아나도 안 잃는다.
|
|
5141
|
-
*/
|
|
5142
|
-
const BRAIN_POLL_MS = 120_000; // 굽기는 분 단위다 — 20초 심박마다 두드릴 이유가 없다
|
|
5143
|
-
const BRAIN_FRESH_MS = 600_000; // 첫 바퀴에 「방금 것」으로 볼 나이
|
|
5144
|
-
const BRAIN_PAGE = 200; // 서버 상한(WORLD_PAGE_MAX)과 같다
|
|
5145
|
-
let brainAt = 0;
|
|
5146
|
-
let brainCursor = null; // null = 아직 색인에서 안 읽었다
|
|
5147
|
-
const brainQueue = []; // 아직 안 말한 모순
|
|
5148
|
-
|
|
5149
|
-
async function brainPull() {
|
|
5150
|
-
const mine = mySessionId();
|
|
5151
|
-
if (!mine) return;
|
|
5152
|
-
const e = indexRead()[mine];
|
|
5153
|
-
if (!e?.tenant || !e?.tenant_id) return; // 아직 어느 소속처인지 모른다
|
|
5154
|
-
const cred = credential();
|
|
5155
|
-
// **키로는 세계 장부를 못 연다** — 서버가 사람 토큰만 받는다(`api.php` 의 world 라우트).
|
|
5156
|
-
if (!cred || cred.kind !== 'user') return;
|
|
5157
|
-
if (Date.now() - brainAt < BRAIN_POLL_MS) return;
|
|
5158
|
-
brainAt = Date.now();
|
|
5159
|
-
|
|
5160
|
-
const first = brainCursor === null;
|
|
5161
|
-
if (first) brainCursor = Number(e.brain_cursor || 0);
|
|
5162
|
-
const cwd = relHome(process.cwd());
|
|
5163
|
-
const fresh = [];
|
|
5164
|
-
for (let page = 0; page < 10; page++) {
|
|
5165
|
-
const r = await api('GET', '/api/world?tenant=' + encodeURIComponent(tenantArgOf(e))
|
|
5166
|
-
+ '&since=' + brainCursor + '&cwd=' + encodeURIComponent(cwd) + '&limit=' + BRAIN_PAGE);
|
|
5167
|
-
const evs = Array.isArray(r?.events) ? r.events : [];
|
|
5168
|
-
for (const ev of evs) if (ev?.kind === 'review' && ev?.ref) fresh.push(ev);
|
|
5169
|
-
if (typeof r?.cursor === 'number' && r.cursor > brainCursor) brainCursor = r.cursor;
|
|
5170
|
-
if (!r?.truncated) break;
|
|
5171
|
-
}
|
|
5172
|
-
// 빈 폴링은 수신이 아니다 — 실제로 온 것이 있을 때만 도장을 찍는다.
|
|
5173
|
-
// 커서는 소속 칸을 안 건드린다(§5.1 P1-a) — 읽어 둔 `e` 가 그 사이 낡았을 수 있다.
|
|
5174
|
-
indexPatch(mine, { brain_cursor: brainCursor }, fresh.length ? 'rx' : '');
|
|
5175
|
-
|
|
5176
|
-
const now = Date.now();
|
|
5177
|
-
for (const ev of fresh) {
|
|
5178
|
-
if (!brainFresh(ev, first, now)) continue;
|
|
5179
|
-
brainQueue.push(String(ev.ref));
|
|
5180
|
-
}
|
|
5181
|
-
}
|
|
5182
|
-
|
|
5183
|
-
/**
|
|
5184
|
-
* 이 사건을 **지금 일로 말해도 되나.**
|
|
5185
|
-
*
|
|
5186
|
-
* 커서가 있는 바퀴(=이 프로세스가 이미 따라잡은 뒤)라면 내려온 것은 정의상 새것이라 전부 참.
|
|
5187
|
-
* **첫 바퀴만** 나이를 본다 — 커서가 0이면 지난 것이 통째로 내려오는데, 그것을 읊으면
|
|
5188
|
-
* 이미 끝난 이야기를 **지금 하는 일로** 읽는다. 그게 이 물건이 막으려던 바로 그 병이다.
|
|
5189
|
-
*
|
|
5190
|
-
* ⚠ 시각을 못 읽으면 **안 말한다.** 모르는 것을 「방금」으로 치는 쪽이 더 위험하다.
|
|
5191
|
-
*/
|
|
5192
|
-
function brainFresh(ev, first, now) {
|
|
5193
|
-
if (!first) return true;
|
|
5194
|
-
const at = brainAtMs(ev);
|
|
5195
|
-
return at > 0 && now - at <= BRAIN_FRESH_MS;
|
|
5196
|
-
}
|
|
5197
|
-
|
|
5198
|
-
/**
|
|
5199
|
-
* 서버가 준 `at` 을 밀리초로. **서버는 시간대 표식 없는 UTC 로 준다.**
|
|
5200
|
-
*
|
|
5201
|
-
* ⚠⚠ 맨몸 `Date.parse("2026-08-24 01:50:30")` 은 그 문자열을 **로컬 시각**으로 읽는다.
|
|
5202
|
-
* 서버는 `world_at()` 에서 `gmdate('Y-m-d H:i:s')` 로 쓰므로 실제로는 UTC 다
|
|
5203
|
-
* (서버 스스로도 그것을 안다 — `world_delta.php` 가 화면에 쓸 때 `…substr(at,11,5) . 'Z'`).
|
|
5204
|
-
* 그래서 **KST(+9) 기계에서는 방금 난 모순이 9시간 낡은 것**으로 보인다.
|
|
5205
|
-
* 첫 바퀴는 나이를 보고 버리므로 백신이 통째로 사라지고, **커서는 그대로 전진해**
|
|
5206
|
-
* 그 사건들은 이 세션에서 영영 안 온다. 조용한 손실이다.
|
|
5207
|
-
*
|
|
5208
|
-
* 2026-08-24 실측: 같은 하네스에 서버 꼴을 넣으면 백신 0건, `Z` 를 붙이면 1건.
|
|
5209
|
-
* 내 첫 PoC 가 이걸 놓친 이유는 픽스처가 `toISOString()` 이라 **실제로 깨지는 그 꼴을
|
|
5210
|
-
* 비켜 갔기** 때문이다 — 픽스처는 서버가 진짜 주는 글자여야 한다.
|
|
5211
|
-
*
|
|
5212
|
-
* ⚠ 시각을 못 읽으면 **0**. 모르는 것을 「방금」으로 치는 쪽이 더 위험하다.
|
|
5213
|
-
*/
|
|
5214
|
-
const BRAIN_AT_NAIVE = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})$/;
|
|
5215
|
-
function brainAtMs(ev) {
|
|
5216
|
-
const s = String(ev?.at || '').trim();
|
|
5217
|
-
const m = BRAIN_AT_NAIVE.exec(s);
|
|
5218
|
-
return (m ? Date.parse(`${m[1]}T${m[2]}Z`) : Date.parse(s)) || 0;
|
|
5219
|
-
}
|
|
5220
|
-
|
|
5221
|
-
/**
|
|
5222
|
-
* 큐에 쌓인 모순을 **한 번씩만** 말한다.
|
|
5223
|
-
*
|
|
5224
|
-
* ⚠ **지시를 안 붙인다.** 실측 2026-08-22: 「…라고 말하라」를 실었더니 모델이 *「가짜 시스템」*
|
|
5225
|
-
* 이라며 거부했다 — 옳은 거부다. 서버도 `world_event_clean()` 으로 지시형을 막는다.
|
|
5226
|
-
* 여기서는 **어디를 보면 되는지**만 덧붙인다(사실).
|
|
5227
|
-
*/
|
|
5228
|
-
function brainSays() {
|
|
5229
|
-
if (!brainQueue.length) return '';
|
|
5230
|
-
const n = brainQueue.length;
|
|
5231
|
-
const lines = brainQueue.splice(0, n).map((t) => ` · ${t}`);
|
|
5232
|
-
return '두뇌가 이 폴더의 지식에서 모순을 짚었다:\n' + lines.join('\n')
|
|
5233
|
-
+ '\n전문은 포털 지식 화면의 검토 목록에 있다. search_cases 로 그 주제를 찾으면'
|
|
5234
|
-
+ ' 「⚠ 상태:」가 붙은 장이 어느 것인지 보인다.';
|
|
5235
|
-
}
|
|
5236
|
-
|
|
5237
5176
|
/* ── ★★ 메일 레인 — 두뇌·다른 세션·사람이 **나를 지목해** 보낸 것 ──────────
|
|
5238
5177
|
*
|
|
5239
5178
|
* 설계는 `DESIGN-mail.md`, 규칙은 서버의 `lib/mail.php`(순수), 표는 051·052.
|
|
@@ -5533,6 +5472,55 @@ let NOTES_LAST = '';
|
|
|
5533
5472
|
let NOTES_QUIET = 0;
|
|
5534
5473
|
const NOTES_REMIND_EVERY = 8;
|
|
5535
5474
|
|
|
5475
|
+
/*
|
|
5476
|
+
* 열린 검토 회신(125) — 메모와 같은 방식으로 기억하고 꼬리에서 말한다. 회신은 **판단 항목의 답이 오는 자리**라
|
|
5477
|
+
* 문서를 고치기 전에 읽혀야 한다: 2026-09-17 에는 이 통로가 없어 답이 와 있는 항을 다섯 판 동안 다시 물었다.
|
|
5478
|
+
*/
|
|
5479
|
+
const REPLIES_OPEN = new Map(); // 보고서 id → 열린 회신 건수
|
|
5480
|
+
let REPLIES_LAST = '';
|
|
5481
|
+
let REPLIES_QUIET = 0;
|
|
5482
|
+
function repliesSeen(id, n) {
|
|
5483
|
+
if (!Number.isInteger(id) || id <= 0 || !Number.isInteger(n)) return;
|
|
5484
|
+
if (n > 0) REPLIES_OPEN.set(id, n); else REPLIES_OPEN.delete(id);
|
|
5485
|
+
}
|
|
5486
|
+
function repliesSays() {
|
|
5487
|
+
const rows = [];
|
|
5488
|
+
REPLIES_OPEN.forEach((n, id) => { if (n > 0) rows.push(`#${id} ${n}건`); });
|
|
5489
|
+
if (!rows.length) { REPLIES_LAST = ''; return ''; }
|
|
5490
|
+
const t = `검토 회신 미반영 — ${rows.join(' · ')} · list_notes(id) 가 본문과 함께 보여 준다. `
|
|
5491
|
+
+ '문서를 고치기 전에 읽고, 반영했으면 resolve_note(id, reply). 고객에게 보이는 답글은 사람이 포털에서 쓴다.';
|
|
5492
|
+
if (t === REPLIES_LAST && ++REPLIES_QUIET < NOTES_REMIND_EVERY) return '';
|
|
5493
|
+
REPLIES_LAST = t;
|
|
5494
|
+
REPLIES_QUIET = 0;
|
|
5495
|
+
return t;
|
|
5496
|
+
}
|
|
5497
|
+
|
|
5498
|
+
/** 검토 회신 목록을 사람이 읽는 줄로. 답글은 뿌리 밑에 붙인다(메모 목록과 같은 규율). */
|
|
5499
|
+
function repliesText(id, r) {
|
|
5500
|
+
if (!r.replies || !r.replies.length) return `검토 회신 — 보고서 #${id} · ${r.note || '열린 검토 회신이 없다.'}`;
|
|
5501
|
+
const STATE = { exact: '', moved: '(옮겨 붙음)', drifted: '(원문이 바뀜)', reparented: '(문장을 잃어 상위로)', unmoored: '(자리를 잃음)' };
|
|
5502
|
+
const side = (c) => (c.side === 'client' ? '고객' : '우리');
|
|
5503
|
+
const kids = new Map();
|
|
5504
|
+
for (const c of r.replies) {
|
|
5505
|
+
if (c.parent_id !== null) {
|
|
5506
|
+
if (!kids.has(c.parent_id)) kids.set(c.parent_id, []);
|
|
5507
|
+
kids.get(c.parent_id).push(c);
|
|
5508
|
+
}
|
|
5509
|
+
}
|
|
5510
|
+
const lines = [`검토 회신(고객에게 보이는 글) · 보고서 #${id} · 본문 판 body_rev=${r.body_rev} · ${r.note}`, ''];
|
|
5511
|
+
if (r.capped) lines.splice(1, 0, '⚠ 목록이 잘렸다 — 아래가 전부가 아니다.');
|
|
5512
|
+
for (const c of r.replies) {
|
|
5513
|
+
if (c.parent_id !== null) continue;
|
|
5514
|
+
lines.push(`[회신 ${c.id}] ${c.where || '문서 전체'} ${STATE[c.state_now] || ''}${c.resolved ? ` · 반영됨(판 ${c.resolved_rev ?? '?'})` : ''}`.trimEnd());
|
|
5515
|
+
if (c.anchor?.quote_exact) lines.push(` 집은 글: 「${c.anchor.quote_exact}」`);
|
|
5516
|
+
lines.push(` ${c.author || ''}(${side(c)}) ${String(c.created_at || '').slice(0, 10)}: ${c.body}`);
|
|
5517
|
+
for (const k of (kids.get(c.id) || [])) lines.push(` ↳ ${k.author || ''}(${side(k)}): ${k.body}`);
|
|
5518
|
+
lines.push('');
|
|
5519
|
+
}
|
|
5520
|
+
lines.push('반영했으면 resolve_note(id, reply: N). 회신대로 고친 뒤의 「원문이 바뀜」은 정상이다. 고객에게 보이는 답글은 이 도구로 달지 않는다.');
|
|
5521
|
+
return lines.join('\n').trimEnd();
|
|
5522
|
+
}
|
|
5523
|
+
|
|
5536
5524
|
/** 응답에 실려 온 건수를 기억한다. 서버가 그 칸을 모르는 판이면 아무 일도 안 한다. */
|
|
5537
5525
|
function notesSeen(id, n) {
|
|
5538
5526
|
if (!Number.isInteger(id) || id <= 0 || !Number.isInteger(n)) return;
|
|
@@ -5553,21 +5541,15 @@ function notesSays() {
|
|
|
5553
5541
|
|
|
5554
5542
|
function worldAppend(result) {
|
|
5555
5543
|
/*
|
|
5556
|
-
*
|
|
5557
|
-
*
|
|
5558
|
-
*
|
|
5559
|
-
|
|
5560
|
-
/*
|
|
5561
|
-
* ★★ 메일이 **가장 앞**이다. 백신은 주소 없는 방송이고 델타는 배경인데, 메일은
|
|
5562
|
-
* 누군가가 **나를 지목해** 보낸 것이다. 그리고 만료가 있어 늦으면 되돌아간다 —
|
|
5563
|
-
* 셋 중 유일하게 **시간이 걸려 있는** 것이라 순서에서 앞선다.
|
|
5544
|
+
* ★★ 메일이 **가장 앞**이다. 델타는 배경인데, 메일은 누군가가 **나를 지목해** 보낸 것이다.
|
|
5545
|
+
* 그리고 만료가 있어 늦으면 되돌아간다 — **시간이 걸려 있는** 것이라 순서에서 앞선다.
|
|
5546
|
+
* (두뇌 백신 줄은 2026-09-17 에 걷었다 — 서버가 kind='review' 를 더는 안 만들어
|
|
5547
|
+
* 2026-08-25 부터 사문이었고, 120초마다 /api/world 를 열 쪽씩 두드리던 폴도 함께 없앴다.)
|
|
5564
5548
|
*/
|
|
5565
5549
|
const mm = mailSays();
|
|
5566
5550
|
if (mm) result.content.push({ type: 'text', text: mm });
|
|
5567
|
-
const v = brainSays();
|
|
5568
|
-
if (v) result.content.push({ type: 'text', text: v });
|
|
5569
5551
|
// ★ 소속처 미등록은 **델타보다 앞**이다. 델타가 비어도(조용한 구간) 나가야 하고,
|
|
5570
|
-
// 그것이 안 풀리면 위의
|
|
5552
|
+
// 그것이 안 풀리면 위의 메일도 이 저장소에서는 영영 안 온다.
|
|
5571
5553
|
const tn = tenantNotice();
|
|
5572
5554
|
if (tn) result.content.push({ type: 'text', text: tn });
|
|
5573
5555
|
/*
|
|
@@ -5576,6 +5558,8 @@ function worldAppend(result) {
|
|
|
5576
5558
|
*/
|
|
5577
5559
|
const nn = notesSays();
|
|
5578
5560
|
if (nn) result.content.push({ type: 'text', text: nn });
|
|
5561
|
+
const rr = repliesSays();
|
|
5562
|
+
if (rr) result.content.push({ type: 'text', text: rr });
|
|
5579
5563
|
const t = worldDelta();
|
|
5580
5564
|
if (!t) return;
|
|
5581
5565
|
/*
|
|
@@ -5829,6 +5813,17 @@ async function callTool(name, args = {}) {
|
|
|
5829
5813
|
if (x.overwrites && x.overwrites !== 'none') bits.push(OVERWRITES[x.overwrites] || `덮음: ${x.overwrites}`);
|
|
5830
5814
|
// 옛 서버와도 물린다 — 그쪽은 아직 거절하던 판이다.
|
|
5831
5815
|
else if (x.can_write === false) bits.push('API 로 수정 불가(옛 서버)');
|
|
5816
|
+
// 미반영 첨삭 메모 — 사람이 「메모 해놨다」고만 해도 어느 문서인지 목록에서 보인다.
|
|
5817
|
+
// 서버가 모르는 판(칸 없음)·볼 자격 없음(null)이면 아무것도 안 찍는다. 기억해 두면 뒤 도구 꼬리가 다시 알린다.
|
|
5818
|
+
if (Number.isInteger(x.open_notes)) {
|
|
5819
|
+
notesSeen(Number(x.id), x.open_notes);
|
|
5820
|
+
if (x.open_notes > 0) bits.push(`첨삭 메모 ${x.open_notes}건 미반영`);
|
|
5821
|
+
}
|
|
5822
|
+
// 열린 검토 회신(125) — 판단 항목의 답이 와 있는 문서를 목록에서 알아보게. 옛 서버·자격 없음이면 안 찍는다.
|
|
5823
|
+
if (Number.isInteger(x.open_replies)) {
|
|
5824
|
+
repliesSeen(Number(x.id), x.open_replies);
|
|
5825
|
+
if (x.open_replies > 0) bits.push(`검토 회신 ${x.open_replies}건 미반영`);
|
|
5826
|
+
}
|
|
5832
5827
|
const tag = bits.length ? ` [${bits.join(' · ')}]` : '';
|
|
5833
5828
|
// slug 를 싣는다. 중복 slug 는 409 로 잘 막히지만 **미리 알 수가 없었다** —
|
|
5834
5829
|
// 목록에 이름표가 없어 새 이름을 고를 때 참고할 것이 없었다(2026-08-18 검토).
|
|
@@ -5880,6 +5875,7 @@ async function callTool(name, args = {}) {
|
|
|
5880
5875
|
return JSON.stringify(r.current_text, null, 2);
|
|
5881
5876
|
}
|
|
5882
5877
|
notesSeen(Number(args.id), r.open_notes);
|
|
5878
|
+
repliesSeen(Number(args.id), r.open_replies);
|
|
5883
5879
|
if (!args.outline) {
|
|
5884
5880
|
/*
|
|
5885
5881
|
* ⚠ 전에는 여기서 **`r.data` 만** 돌려줬다. 서버가 정성껏 붙인 것이 이 한 줄에서
|
|
@@ -5897,6 +5893,10 @@ async function callTool(name, args = {}) {
|
|
|
5897
5893
|
head.push(`[정본 ${owner} · 본문 판 body_rev=${r.body_rev ?? '?'}`
|
|
5898
5894
|
+ (r.sha256 ? ` · 지금 발행물 ${r.bytes}B sha256:${String(r.sha256).slice(0, 12)}…` : '')
|
|
5899
5895
|
+ `] — 고칠 때는 update_report 에 body_rev 를 함께 실으면, 그 사이 누가 고쳤을 때 409 로 멈춘다.`);
|
|
5896
|
+
// 열린 검토 회신 — 고치기 전에 읽어야 한다(판단 항목의 답이 거기 있다). 첫 줄 가까이에 둔다.
|
|
5897
|
+
if (Number.isInteger(r.open_replies) && r.open_replies > 0) {
|
|
5898
|
+
head.push(`검토 회신 ${r.open_replies}건이 열려 있다 — 고치기 전에 list_notes(id: ${Number(args.id)}) 로 본문을 읽는다.`);
|
|
5899
|
+
}
|
|
5900
5900
|
// 그림은 서버가 보관한다(바이트는 에이전트를 안 지난다) — 되돌려 보낼 때의 계약을 여기서 말한다(#34).
|
|
5901
5901
|
const kept = Array.isArray(r.data?.assets)
|
|
5902
5902
|
? r.data.assets.filter((a) => typeof a?.data === 'string' && a.data.startsWith('(생략')).length : 0;
|
|
@@ -5914,13 +5914,29 @@ async function callTool(name, args = {}) {
|
|
|
5914
5914
|
}
|
|
5915
5915
|
case 'list_notes': {
|
|
5916
5916
|
const id = Number(args.id);
|
|
5917
|
+
const channel = args.channel === undefined ? 'all' : String(args.channel);
|
|
5918
|
+
if (!['all', 'notes', 'replies'].includes(channel)) throw new Error('channel 은 all · notes · replies 중 하나입니다.');
|
|
5917
5919
|
const qs = new URLSearchParams();
|
|
5918
5920
|
if (args.state) qs.set('state', String(args.state));
|
|
5919
5921
|
if (args.uid) qs.set('uid', String(args.uid));
|
|
5920
|
-
const
|
|
5922
|
+
const q = qs.toString() ? '?' + qs : '';
|
|
5923
|
+
// 검토 회신(125) — 메모 뒤에 붙인다. 옛 서버(창구 없음)는 메모만 낸다; 회신만 달라고 했으면 그 실패를 그대로 말한다.
|
|
5924
|
+
let repliesPart = '';
|
|
5925
|
+
if (channel !== 'notes') {
|
|
5926
|
+
try {
|
|
5927
|
+
const rr = await api('GET', `/api/reports/${id}/replies${q}`);
|
|
5928
|
+
repliesSeen(id, rr.open);
|
|
5929
|
+
repliesPart = repliesText(id, rr);
|
|
5930
|
+
} catch (e) {
|
|
5931
|
+
if (channel === 'replies' || Number(e?.status) !== 404) throw e;
|
|
5932
|
+
}
|
|
5933
|
+
}
|
|
5934
|
+
if (channel === 'replies') return repliesPart;
|
|
5935
|
+
const tail = repliesPart ? `\n\n${repliesPart}` : '';
|
|
5936
|
+
const r = await api('GET', `/api/reports/${id}/notes${q}`);
|
|
5921
5937
|
notesSeen(id, r.open);
|
|
5922
5938
|
if (!r.notes || !r.notes.length) {
|
|
5923
|
-
return `보고서 #${id} — ${r.note || '메모가 없다.'}`;
|
|
5939
|
+
return `보고서 #${id} — ${r.note || '메모가 없다.'}${tail}`;
|
|
5924
5940
|
}
|
|
5925
5941
|
/*
|
|
5926
5942
|
* 답글을 부모 밑에 붙여 낸다. 평평하게 내면 에이전트가 **자기가 이미 한 말**을
|
|
@@ -5950,7 +5966,7 @@ async function callTool(name, args = {}) {
|
|
|
5950
5966
|
}
|
|
5951
5967
|
lines.push('');
|
|
5952
5968
|
}
|
|
5953
|
-
return lines.join('\n').trimEnd();
|
|
5969
|
+
return lines.join('\n').trimEnd() + tail;
|
|
5954
5970
|
}
|
|
5955
5971
|
case 'reply_note': {
|
|
5956
5972
|
const id = Number(args.id);
|
|
@@ -5960,6 +5976,15 @@ async function callTool(name, args = {}) {
|
|
|
5960
5976
|
}
|
|
5961
5977
|
case 'resolve_note': {
|
|
5962
5978
|
const id = Number(args.id);
|
|
5979
|
+
if ((args.note === undefined) === (args.reply === undefined)) throw new Error('note(메모 id) 와 reply(검토 회신 id) 중 **하나만** 주세요.');
|
|
5980
|
+
if (args.reply !== undefined) {
|
|
5981
|
+
const rb = { reply: Number(args.reply) };
|
|
5982
|
+
if (args.rev !== undefined) rb.rev = Number(args.rev);
|
|
5983
|
+
if (args.resolved !== undefined) rb.resolved = !!args.resolved;
|
|
5984
|
+
const rr = await api('PATCH', `/api/reports/${id}/replies`, rb);
|
|
5985
|
+
if (Number.isInteger(rr.open)) repliesSeen(id, rr.open);
|
|
5986
|
+
return rr.says;
|
|
5987
|
+
}
|
|
5963
5988
|
const body = { note: Number(args.note) };
|
|
5964
5989
|
if (args.rev !== undefined) body.rev = Number(args.rev);
|
|
5965
5990
|
if (args.resolved !== undefined) body.resolved = !!args.resolved;
|
|
@@ -6044,16 +6069,16 @@ async function callTool(name, args = {}) {
|
|
|
6044
6069
|
throw e;
|
|
6045
6070
|
}
|
|
6046
6071
|
// 고친 직후가 「무엇을 아직 안 고쳤나」를 볼 가장 좋은 순간이다.
|
|
6047
|
-
if (!r.dry_run) notesSeen(Number(args.id), r.open_notes);
|
|
6072
|
+
if (!r.dry_run) { notesSeen(Number(args.id), r.open_notes); repliesSeen(Number(args.id), r.open_replies); }
|
|
6048
6073
|
if (r.dry_run) {
|
|
6049
|
-
return `시험 실행 — 아무것도 바꾸지 않았습니다.\n${r.tenant} · 건 ${r.cases}` + warnText(r);
|
|
6074
|
+
return `시험 실행 — 아무것도 바꾸지 않았습니다.\n${r.tenant} · 건 ${r.cases}` + pinsText(r) + warnText(r);
|
|
6050
6075
|
}
|
|
6051
6076
|
// 상태를 함께 알린다 — 이 문서를 다음에도 API 로 고칠 수 있는지가 여기서 갈린다.
|
|
6052
6077
|
const st = r.state ? ` [${r.state}]` : '';
|
|
6053
6078
|
// 서버가 초안이라고 답하면 이 도구의 동사로 다음 할 일을 잇는다(서버 note 는 HTTP 경로로 말한다).
|
|
6054
6079
|
const open = r.published === false ? ` 이 도구에서는 release_report(id: ${Number(args.id)}) 가 그 일입니다.` : '';
|
|
6055
6080
|
return `교체 완료 — ${r.tenant} · 건 ${r.cases}${st}\n열람 주소: ${r.url}\n${r.note || ''}${open}`
|
|
6056
|
-
+ pendingText(r) + warnText(r);
|
|
6081
|
+
+ pendingText(r) + pinsText(r) + warnText(r);
|
|
6057
6082
|
}
|
|
6058
6083
|
case 'world': {
|
|
6059
6084
|
const facts = worldFacts(8);
|
|
@@ -6696,6 +6721,10 @@ async function handle(line) {
|
|
|
6696
6721
|
* initialize 의 clientInfo 로만 온다. 놓치면 다시 물어볼 자리가 없다.
|
|
6697
6722
|
*/
|
|
6698
6723
|
CLIENT = req.params?.clientInfo || null;
|
|
6724
|
+
// 어느 레인으로 떴는가 — clientInfo 이름은 아직 판정에 안 쓴다(실측 전). 이 줄이 그 실측이다.
|
|
6725
|
+
try {
|
|
6726
|
+
process.stderr.write(`genetics: lane=${hookLane() ? 'hooked' : 'hookless'} client=${CLIENT?.name || '?'}/${CLIENT?.version || '?'} sid=${mySessionId() ? 'yes' : 'no'}\n`);
|
|
6727
|
+
} catch { /* 로그는 최선만 */ }
|
|
6699
6728
|
result = {
|
|
6700
6729
|
protocolVersion: req.params?.protocolVersion || '2024-11-05',
|
|
6701
6730
|
// ★ `listChanged` 를 **선언해야** 알림을 보낼 자격이 생긴다. 명세상 안 알리고
|
|
@@ -6744,6 +6773,10 @@ async function handle(line) {
|
|
|
6744
6773
|
} else if (req.method === 'tools/list') {
|
|
6745
6774
|
result = { tools: toolsNow() };
|
|
6746
6775
|
} else if (req.method === 'tools/call') {
|
|
6776
|
+
lastToolAt = Date.now(); // 유휴 조절기의 시계(자기 일정 표)
|
|
6777
|
+
// 도구응답 레인의 긷기 — **기다리지 않는다**(도구 지연은 그대로). 60초 상한은 mailPull 안의 mailAt 검사.
|
|
6778
|
+
// 도구가 망을 도는 동안 도착한 통은 이번 응답 꼬리에, 아니면 다음 응답에 실린다.
|
|
6779
|
+
if (hookLane() && mailPollReady()) mailPull().catch(() => {});
|
|
6747
6780
|
const text = await callTool(req.params?.name, req.params?.arguments || {});
|
|
6748
6781
|
result = { content: [{ type: 'text', text }] };
|
|
6749
6782
|
worldAppend(result);
|
|
@@ -7208,6 +7241,52 @@ function ensureSkillOnBoot() {
|
|
|
7208
7241
|
}
|
|
7209
7242
|
}
|
|
7210
7243
|
|
|
7244
|
+
/*
|
|
7245
|
+
* ══ 자기 일정 표 — 이 프로세스가 **스스로** 도는 일의 전부 (2026-09-17) ══════════════════
|
|
7246
|
+
*
|
|
7247
|
+
* 의도(오너 확정): **MCP 는 세션의 심박과 도구 응답만 맡는다. 저장소 관측은 세포 훅의 것이다.**
|
|
7248
|
+
* 타이머는 이 표에만 적는다 — 표가 없어서 08-23~09-15 에 타이머가 하나씩 얹혔고(관측·메일·두뇌),
|
|
7249
|
+
* 어느 것도 클라이언트를 안 가리고 유휴에도 안 멈췄다. 새 일을 얹으려면 이 표에 행을 더하고
|
|
7250
|
+
* `lane`(누구에게 도는가)·`idle`(조용할 때 어떻게 하는가)을 적는다. 기존 틱에 얹지 않는다.
|
|
7251
|
+
*
|
|
7252
|
+
* lane 'all' = 모든 클라이언트 · 'hookless' = 훅 레인이 없는 클라이언트만(`hookLane()` 이 거짓 — Codex)
|
|
7253
|
+
* idle 'never' = 늘 같은 주기(심박 — 살아 있음이 신호다, 서버는 90초 침묵을 죽음으로 본다)
|
|
7254
|
+
* 'slow' = 마지막 도구 호출 뒤 IDLE_AFTER_MS 가 지나면 IDLE_EVERY_MS 에 한 번만. 도구를 부르면 곧 복귀
|
|
7255
|
+
*
|
|
7256
|
+
* 타이머가 아닌 것(심박 바퀴 안에서 사건·시각으로 도는 것): resolve(10분 TTL·사건 기반) · 판 비교(30분) ·
|
|
7257
|
+
* 되찾기 훑기(5분) · 자동 올리기(90초 하한, 동의 관문). 메일은 훅 레인이 있으면 도구 호출 때 긷는다(60초 상한).
|
|
7258
|
+
*/
|
|
7259
|
+
const IDLE_AFTER_MS = Math.max(1000, Number(cfg.GENETICS_IDLE_AFTER_MS) || 30 * 60_000);
|
|
7260
|
+
const IDLE_EVERY_MS = Math.max(1000, Number(cfg.GENETICS_IDLE_EVERY_MS) || 5 * 60_000);
|
|
7261
|
+
let lastToolAt = Date.now();
|
|
7262
|
+
const SCHEDULE = [
|
|
7263
|
+
{ name: 'beat', every: BEAT_EVERY_MS, lane: 'all', idle: 'never', start: startBeat,
|
|
7264
|
+
why: '세션이 살아 있음을 서버에 알린다 — 생사 판정은 서버 심박이 소유한다(결정 2026-08-27)' },
|
|
7265
|
+
{ name: 'observe', every: OBSERVE_EVERY_MS, lane: 'hookless', idle: 'slow', tick: observeTick,
|
|
7266
|
+
why: '훅이 없는 클라이언트(Codex)의 전사를 세포가 밖에서 훑게 한다 — 훅이 있으면 훅이 한다' },
|
|
7267
|
+
{ name: 'mail', every: MAIL_POLL_MS, lane: 'hookless', idle: 'slow', tick: () => { mailPull().catch(() => {}); },
|
|
7268
|
+
ready: mailPollReady,
|
|
7269
|
+
why: '훅 레인이 없는 세션의 편지함 — 훅 레인이 있으면 도구 호출 때 긷는다' },
|
|
7270
|
+
];
|
|
7271
|
+
|
|
7272
|
+
function startSchedule() {
|
|
7273
|
+
if (cfg.GENETICS_NO_BEAT) return; // 심박을 끄는 사람은 나머지도 안 원한다
|
|
7274
|
+
for (const row of SCHEDULE) {
|
|
7275
|
+
if (row.start) { row.start(); continue; }
|
|
7276
|
+
if (row.ready && !row.ready()) continue;
|
|
7277
|
+
const run = () => {
|
|
7278
|
+
if (row.lane === 'hookless' && hookLane()) return;
|
|
7279
|
+
const now = Date.now();
|
|
7280
|
+
if (row.idle === 'slow' && now - lastToolAt > IDLE_AFTER_MS && now - (row.lastRun || 0) < IDLE_EVERY_MS) return;
|
|
7281
|
+
row.lastRun = now;
|
|
7282
|
+
row.tick();
|
|
7283
|
+
};
|
|
7284
|
+
run();
|
|
7285
|
+
row.timer = setInterval(run, row.every);
|
|
7286
|
+
row.timer.unref?.(); // 창을 닫으면 노드가 남지 않게
|
|
7287
|
+
}
|
|
7288
|
+
}
|
|
7289
|
+
|
|
7211
7290
|
if (IS_MAIN) {
|
|
7212
7291
|
ensureCellOnBoot(); // 세포가 먼저다 — 스킬보다 이쪽이 「무엇을 못 하는가」를 정한다
|
|
7213
7292
|
ensureSkillOnBoot();
|
|
@@ -7216,9 +7295,7 @@ if (IS_MAIN) {
|
|
|
7216
7295
|
* 동안」이라, 시작·끝을 따로 알릴 필요가 없다. 자격이나 주소가 없으면 조용히 안 뛴다 —
|
|
7217
7296
|
* 꺼진 것이지 고장이 아니다(그림 자격이 없을 때와 같은 태도).
|
|
7218
7297
|
*/
|
|
7219
|
-
|
|
7220
|
-
startMailPoll(); // 메일 폴은 심박의 조기 반환에 매이지 않는다
|
|
7221
|
-
startObserve(); // 훅이 안 걸리는 도구(Codex)도 관측을 올린다 — 위 머리말 참조
|
|
7298
|
+
startSchedule();
|
|
7222
7299
|
process.stdin.setEncoding('utf8');
|
|
7223
7300
|
process.stdin.on('data', (chunk) => { buf += chunk; pump(); });
|
|
7224
7301
|
process.stdin.on('end', () => { pump(true); stopBeat().catch(() => {}); });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "genetics-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.21.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 && bash ../cell/tests/cellcycle.sh",
|
|
21
|
+
"test": "node smoke.mjs && node notify.test.mjs && node schedule.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,13 +43,15 @@ 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단계 — 검수」 사람의 눈 표 | 사람 | — |
|
|
50
50
|
| 8 초안 발행·수정 | 초안 링크 | 새 문서 `publish_report` · 고치면 8단계 「수정 절차」 | 같은 링크 · 사람이 고친 초안을 모르고 덮지 않음 | 사람 — `update_report` 에 `body_rev` 잠금 칸이 아직 없음 | 사람이 고친 초안이면 덮어도 되는가 |
|
|
51
51
|
| 9 발행 뒤 대조 | 블록 목록 | `get_report(id, outline: true)` | `aside.marg` 수 = 절 수 · 옛 서식 블록 0 · 빈 블록 0 | 사람(블록 목록을 읽음) — 대조 기계 없음 | — |
|
|
52
52
|
| 9 열람 확인 | 캡처 | 열람 주소를 연다 | 칩·굵게·링크가 원문 글자가 아니라 서식으로 섬 · 도판 쌍·판단 절이 섬 | 사람 눈 — 못 열면 까닭을 핸드오프에 | — |
|
|
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`)를 목록·되읽기·교체 응답에 싣고, 열린 회신의 문장이 바뀌면 교체 경고로 말함 · 본문 읽기와 대조는 사람·에이전트 | 고객에게 보이는 답글 문안(에이전트는 달지 않는다) |
|
|
53
55
|
| 10 공개 직전 날짜 | `report.json` | `update_report` 한 번(날짜만 고치는 이 판은 독자 검토 없이 로컬 렌더·dry_run 만) | 작성일 세 자리가 한 값이고 공개하는 날(3c) | `gazette.py` `date_scan()` ⚠(세 자리가 다르면) · 공개하는 날인지는 사람 | 공개일 |
|
|
54
56
|
| 10 공개 | 공개된 링크 | `release_report(id)` | 사용자 승인 | 사람(승인 행위) | **반드시** 공개 승인 |
|
|
55
57
|
|
|
@@ -335,6 +337,36 @@ report.json + cases.json 을 하나로 합쳐 `publish_report` 에 준다
|
|
|
335
337
|
**「그림 N(표지마크 M)」은 진짜 셈이다.** 표지 마크는 고객사에 로고가 등록돼 있으면 **서버가 넣으므로** `M = 1` 이 정상이고 본문 도판은 `N − M` 이다.
|
|
336
338
|
**`그림 0` 을 합격 기준으로 삼지 마라** — 도판을 실은 문서에서 그 기준은 자기 근거를 지운다.
|
|
337
339
|
|
|
340
|
+
### 9a단계 — 첨삭 메모 반영: 「메모 해놨다」를 들으면
|
|
341
|
+
|
|
342
|
+
사람은 초안을 읽으면서 머리 도구줄 **「첨삭 메모」**로 자리를 집어 고칠 곳을 적는다. 우리 팀(admin·editor)만 달고 보며, **고객에게는 공개 뒤에도 안 보인다**(검토 회신 핀과 다른 점). 사람이 「메모 해놨다」「첨삭 달아 놨다」고 하면 아래 순서로 반영한다 — **무엇을 고칠지 대화창에서 되묻지 않는다.** 메모가 이미 말한다.
|
|
343
|
+
|
|
344
|
+
| 순서 | 도구 | 할 일 |
|
|
345
|
+
|---|---|---|
|
|
346
|
+
| 1 | `list_reports` | 줄 끝에 `첨삭 메모 N건 미반영` 이 붙은 문서를 찾는다. 문서 번호를 들었으면 건너뛴다 |
|
|
347
|
+
| 2 | `list_notes(id)` | 미반영 메모를 읽는다. `anchor.path`(예 `case:k7m2p9/fix`)가 **고칠 칸**, `anchor.quote_exact` 가 사람이 **본 글**이다. 답글은 사람이 덧붙인 설명이다 |
|
|
348
|
+
| 3 | `get_report(id)` | 발행 데이터를 받는다. 머리 두 줄의 정본이 발행물이면 덮기 전에 무엇을 덮는지 본다 |
|
|
349
|
+
| 4 | `update_report(dry_run: true)` → `update_report` | 메모마다 그 칸을 고쳐 **한 번에** 보낸다. `uid` 는 받은 값 그대로 되돌린다 — 흘리면 메모가 자리를 잃는다. 초안은 초안 그대로 남는다 |
|
|
350
|
+
| 5 | `resolve_note(id, note, rev)` | **고친 뒤에만.** 반영이 실린 `body_rev` 를 함께 준다. 먼저 부르면 미반영 목록에서 빠져 다시 안 보인다 |
|
|
351
|
+
| 6 | `reply_note(id, note, body)` | 못 고치거나 다르게 본 메모는 **닫지 말고** 그 메모에 이유를 답글로 단다 |
|
|
352
|
+
|
|
353
|
+
`anchor_state` 가 `unmoored`·`reparented` 면 문서가 바뀌어 자리를 잃은 메모다 — `quote_exact` 로 본문에서 찾아 고치고, 못 찾으면 답글로 묻는다. 끝나면 사람에게 **한 줄로** 알린다: 반영 N건 · 답글로 되물은 N건 · 문서 링크.
|
|
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
|
+
|
|
338
370
|
### 8단계′ — 전달: 단일 HTML (Lim 밖 고객·메일 첨부)
|
|
339
371
|
|
|
340
372
|
```bash
|
|
@@ -515,13 +547,17 @@ render·css·폰트를 함께 얹는다. 직접 부르면 옛 서식 시트가
|
|
|
515
547
|
**공급사 용어도 같다** — AWS 의 Extended Support 를 「연장 지원료」로 직역하면 「지원해 주는 돈」으로 읽힌다(같은 날 반려). 요금·기능은 그것이 무엇인지로 부르고(「구버전 유지 요금」),
|
|
516
548
|
공급사 이름은 첫 등장 자리나 용어표에 한 번만 붙인다. **이 스킬의 규칙 문장도 원고 말투가 아니다** — 캡션 규칙 「무엇을 보라는 것인지」를 베껴 캡션이 「…을 보라.」가 됐다.
|
|
517
549
|
독자에게 말 거는 자리는 하십시오체, 기록 자리는 개조식이고, 해라체 명령은 어디에도 없다.
|
|
518
|
-
집행자: `gazette.py` `text_scan()` ⚠ 「선택지를 「길」로 부른 곳」·「해라체 명령이 N곳」(두 경로 —
|
|
550
|
+
집행자: `gazette.py` `text_scan()` ⚠ 「선택지를 「길」로 부른 곳」·「해라체 명령이 N곳」(두 경로 — 운영 반영 확인 2026-09-17) · 비유·조어·직역 일반은 **없음 — 사람의 눈**(경위: `references/history.md` §13).
|
|
519
551
|
13. **우리 쪽 보안·운영 실수와 자격증명 취급 사실을 문서에 적지 않는다 — 구두로 전한다.**
|
|
520
552
|
「비밀번호가 메신저로 오갔음」「키를 메일로 보냈음」은 교체 권고의 근거로 옳아도 고객 문서의 자리가 아니다 —
|
|
521
553
|
박제되고 책임 소재를 글로 남긴다. 권고는 사용자에게 채팅으로만, 문서에는 그 사건의 원인·조치·확인만(`references/content-pipeline.md` 유형 L).
|
|
522
554
|
경계: 그 사건 범위 안의 조언(심사에 넘긴 시험 계정을 「외부에 알려진 것으로 봄」)은 남긴다.
|
|
523
555
|
집행자: `gazette.py` `text_scan()` ⚠ 「자격증명 노출」 — 자격증명 명사와 노출 경로·동사(메신저·메일·평문·오갔·보냈…)가 한 문장 30자 안에 같이 선 곳만. 명사 하나(「비밀번호 재설정」)는 안 셈 ·
|
|
524
556
|
「우리 실수」 일반(「늦게 알았음」 류)은 **없음 — 사람 눈 · 독자 검토 decider 관점**(경위: `references/history.md` §14).
|
|
557
|
+
14. **건이 둘 이상인 절의 표·도판·인용은 절이 아니라 그 건에 묶는다** — `cases[].tables`·`plates`·`quotes`(`references/lim-portal-format.md` §11-10).
|
|
558
|
+
절 칸에 두면 렌더러가 건 목록 **뒤**에 한꺼번에 세워, 독자는 건 다섯을 글로만 읽고 나서 표 다섯·그림 넷을 만나고 어느 건의 근거인지는 캡션 글자로만 잇는다.
|
|
559
|
+
절 칸은 절 전체에 걸치는 근거(조치 전후 대조표)만. 전폭 도판은 건 안에 못 서니 절에 둔다(건에 두면 ⚠).
|
|
560
|
+
집행자: 렌더러 `gazette.py` `case_evidence()` — 자리를 만들 뿐 「절에 둔 것이 사실은 한 건의 것」인지는 **사람 눈**(경위: `references/history.md` §15).
|
|
525
561
|
|
|
526
562
|
## 흔한 실패 모드
|
|
527
563
|
|
|
@@ -564,8 +600,8 @@ doc-pptx 의 슬롭 3종 중 **대시 대비 헤드라인**("복붙에서 위임
|
|
|
564
600
|
| 권고 낱말(제목·표 열·절 이름·여백 주석·요약면)(10번) | `gazette.py` ⚠ **W1** — 이름에 「의견」이 든 절은 안 셈 | **두 경로 다** |
|
|
565
601
|
| 고객·제3자 자료에서 낸 도판(`user-provided`)의 출처 줄에 원본 위치(쪽·행·주소·건 §) 누락(11번) | `gazette.py` ⚠ **W2** | **두 경로 다** |
|
|
566
602
|
| 도판 출처 줄의 제작 설명·그림 번호 · 출처 줄 길이(0.5단계 5a행) | `gazette.py` `text_scan()` ⚠ — 캡션은 안 봄 | **두 경로 다** |
|
|
567
|
-
| 선택지를 「길」로 부른 곳(스킬 장치 이름의 유출, 12번) | `gazette.py` `text_scan()` ⚠ — 「막대 길이」「길게」는 안 세고 「어느 길이」·열 머리 「길」은 셈. 「사진」「단추」 같은 비유는 **없음 — 사람의 눈** | **두 경로 다**
|
|
568
|
-
| 해라체 명령(「…을 보라」「…하라」, 12번) | `gazette.py` `text_scan()` ⚠ — 「보라색」「정할 것」은 안 셈. 공급사 용어 직역(「지원료」)은 **없음 — 사람의 눈** | **두 경로 다**
|
|
603
|
+
| 선택지를 「길」로 부른 곳(스킬 장치 이름의 유출, 12번) | `gazette.py` `text_scan()` ⚠ — 「막대 길이」「길게」는 안 세고 「어느 길이」·열 머리 「길」은 셈. 「사진」「단추」 같은 비유는 **없음 — 사람의 눈** | **두 경로 다** |
|
|
604
|
+
| 해라체 명령(「…을 보라」「…하라」, 12번) | `gazette.py` `text_scan()` ⚠ — 「보라색」「정할 것」은 안 셈. 공급사 용어 직역(「지원료」)은 **없음 — 사람의 눈** | **두 경로 다** |
|
|
569
605
|
| 건 행 이름(`labels`) 낱말이 다른 절 부제·표 제목·열 머리에 다시 섬 · 선택지 기호 한 글자에 이름 둘(0.5단계 3행) | `gazette.py` `text_scan()` ⚠ · 뜻이 같은지는 사람 눈 | **두 경로 다** |
|
|
570
606
|
| 그림·표 번호 가리킴 — 없는 번호 · 조사 · 서지 못한/서지 않는 칸의 토큰 · `plates` 순서(0.5단계 6행) | `gazette.py` `text_scan()`·`build()` ⚠ — 번호 셈은 `fig_order()` 한 곳 | **두 경로 다** |
|
|
571
607
|
| 괄호 안 부연 · 흐름도 칸 `note` · 도판 캡션 문장 수(readability R11) | `gazette.py` `text_scan()` ⚠ — 한 줄 45자·요약·도입문 문장 수는 사람 눈 | **두 경로 다** |
|
|
@@ -150,3 +150,16 @@
|
|
|
150
150
|
실측(문장 11개): 노출 문장 4/4 잡음 · 정상 문장 6/7 통과 · 오탐 1(「메일 발송 기능이 계정 정보와 무관」 — 경고라 사람이 넘긴다).
|
|
151
151
|
「우리 실수」 일반(「늦게 알았음」 류)은 기계가 못 재 사람 눈과 독자 검토 decider 관점에 둔다.
|
|
152
152
|
운영 반영 확인(2026-09-17): 서버 배포(HEAD 2301f523) 뒤 운영 `dry_run` 탐침이 「자격증명 노출 서술이 1곳 … 「비밀번호가 메신저」」를 냈다 — 두 경로 다 산다. genetics-mcp 2.19.2 로 게시.
|
|
153
|
+
|
|
154
|
+
## 15. 「이 스킬 병신이라서 이딴 허접한 관보가 탄생하는 거임?」 (2026-09-17) — 렌더러 건별 근거 · SKILL 반드시 지키는 것 14 · lim-portal-format §11-10
|
|
155
|
+
|
|
156
|
+
HUnion 사후 보고 초안 182, 3절(건 다섯). 사용자가 브라우저에서 집은 것은 3절 머리의 인용 「Guideline 2.1(a) … Unable to connect」 —
|
|
157
|
+
파생 앱(3.5 건)의 거절문인데 절 인용이라 건 목록 뒤에 서서 3.1 건의 거절문처럼 읽혔고, 출처 줄에 어느 앱인지·날짜(9. 16.)도 틀렸다.
|
|
158
|
+
검토해 보니 스킬이 아니라 렌더러였다: `gazette.py Doc.chapter()` 가 배치 순서의 유일한 정본이고 상수·설정·시험이 없으며,
|
|
159
|
+
cases 절의 DOM 은 간지 → 여백 주석 → 건 전부 → 인용 → 자료표 → 차트 → 흐름도 → 도해 → 도판 → (그리드 밖) 전폭. 모든 첨부는
|
|
160
|
+
절(`c['sec']`)에서만 읽고 건(`sheets_block`)은 여덟 칸만 읽었다. 문서 §11-8 「인용은 절 본문 맨 앞」도 코드와 달랐다(kind 블록 뒤).
|
|
161
|
+
운영 초안 182 의 본문 텍스트로 잰 순서: 건 03-1~03-5 → 인용 → 표 3-1~3-5 → 그림 3-1~3-4 — 표 다섯의 주인은 3.1·3.2·3.3·3.1·3.5 건.
|
|
162
|
+
처방 — 렌더러 `case_evidence()`(건 `quotes`·`tables`·`plates` 를 건 article 안에 인용 → 표 → 도판 순으로, 번호는 절과 한 셈·건이 먼저),
|
|
163
|
+
절 인용을 문서대로 kind 블록 앞으로, `_plate_ids()`·`_tables_of()` 로 경고 스캔(세로 긴 도판·앞 절 가리킴·표 번호·안 걸린 도판)이 건의 것도 세게,
|
|
164
|
+
건 안의 전폭 자산 ⚠, 서버 `report_data_validate()` 건 `plates` 게이트, `api.php` 자리 잡은 도판 셈, MCP 스키마 세 칸, CSS `.sheet__ev`,
|
|
165
|
+
시험 「건별 근거」. 집필 규칙은 SKILL 14 · §4 주석 · §11-1 「놓을 절」 · §11-10. 편집기는 이 칸을 아직 모른다(절 `quotes`·`tables` 와 같은 처지).
|
|
@@ -189,7 +189,7 @@ python3 assets/render.py <report.json>
|
|
|
189
189
|
| `stats[]` | `value` · `unit` · `label` · `accent` · `href` · `go` | 첫 칸만 `accent: true` |
|
|
190
190
|
| `meta[]` | `label` · `value` | `[k, v]` 배열도 받는다 |
|
|
191
191
|
| `open_items[]` | `title` · `priority`(`must` 면 「가장 시급」) · `body` · `refs[]` | `refs` 는 **첫 하나만** 「근거」 단추가 된다 |
|
|
192
|
-
| `cases[]` | `group` · `title` · `grade` · `symptom` · `cause` · `fix` · `how_to_check` · `remaining` · `source_url` · `source_text` · **`uid`** | 번호(`anchor`)는 **렌더러가 매기고**, `uid` 는 **서버가
|
|
192
|
+
| `cases[]` | `group` · `title` · `grade` · `symptom` · `cause` · `fix` · `how_to_check` · `remaining` · `source_url` · `source_text` · **`uid`** · `quotes` · `tables` · `plates` | 번호(`anchor`)는 **렌더러가 매기고**, `uid` 는 **서버가 발급한다**. `quotes`·`tables`·`plates` 는 **그 건에 묶인 근거**(11-10절) — 절의 같은 칸과 모양이 같고 건 필드표 바로 아래 선다 |
|
|
193
193
|
| `appendix[]` | `no` · `title` · `rows[{label,value}]` · `note` | `rows` 가 비면 그 항목이 통째로 빠진다 |
|
|
194
194
|
| `colophon` | `title` · `sub` · `rows[]` · `mark` · `badge` · `note` · `tail` | 넷(`title`·`rows`·`mark`·`note`)이 다 비면 판권지가 안 선다 |
|
|
195
195
|
| `grades{}` | `키: [클래스, 설명, lvl?, tag?]` | 7절 |
|
|
@@ -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
|
|
|
@@ -265,6 +276,9 @@ python3 assets/render.py <report.json>
|
|
|
265
276
|
| `quotes` | 원문 인용 목록(11-8절). `kind` 와 무관하다 | 인용이 빠진다 |
|
|
266
277
|
| `diagrams` | 도해(인라인 SVG) 목록(11-9절). `kind` 와 무관하다 | 도해가 빠진다 |
|
|
267
278
|
|
|
279
|
+
`kind:"cases"` 절에서 **건이 둘 이상이면 표·도판·인용은 절이 아니라 건에 묶는다**(`cases[].tables`·`plates`·`quotes`, 11-10절).
|
|
280
|
+
절 칸에 두면 건 목록 **뒤**에 한꺼번에 서서 어느 건의 근거인지 캡션 글자로만 이어진다. 절 칸은 절 전체에 걸치는 근거의 자리다.
|
|
281
|
+
|
|
268
282
|
### ⚠ `no` 와 `id` 는 **안 읽힌다**
|
|
269
283
|
|
|
270
284
|
절 번호와 앵커는 **배열 순서**가 정한다 — `sections[i]` 가 `ch = "%02d" % (i+1)` · `id = "sec-(i+1)"`.
|
|
@@ -396,8 +410,10 @@ python3 assets/render.py <report.json>
|
|
|
396
410
|
| 절에 실을 내용이 없습니다 | `kind` 가 가리키는 데이터가 비었다 |
|
|
397
411
|
| 그룹에 배정되지 않은 건 N개 | `cases[].group` 이 어긋났다(그룹이 아예 없을 때) |
|
|
398
412
|
| 그림 N장은 놓을 자리가 없어 | `logo`·`figure` 밖의 `role`(`hero`·`chapter`·`eof`) |
|
|
399
|
-
| 도판 N장이 어느 절에도 걸리지 않아 | `sections[].plates` 에 그 id 를 안 적었다 — 11-1절 |
|
|
400
|
-
|
|
|
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). 핀을 살리려고 문장을 되돌리지 않는다 |
|
|
415
|
+
| 절 …의 도판 「…」를 assets 에서 못 찾았습니다 | `plates` 가 없는 `assets[].id` 를 가리킨다(건 `plates` 면 「건 NN-n 의 도판」) |
|
|
416
|
+
| 건 NN-n 에 묶인 도판 「…」는 전폭(wide)인데 건 안이라 본문 단 폭으로 섭니다 | `cases[].plates` 가 `wide:true` 자산을 가리킨다 — 건 안은 두 단 그리드라 전폭이 못 선다. 절 `plates` 로 옮기거나 `wide` 를 끈다(11-10절) |
|
|
401
417
|
| 도판 「…」에 그림 바이트가 없습니다 | `file`·`source` 없이 이름만 왔다. 로컬 `render.py` 는 `file` 을 바이트로 굳히지 않아 `file` 이 있어도 뜬다 — 로컬에서는 이 줄을 빼고 나머지 ⚠ 를 읽는다 |
|
|
402
418
|
| `chapters`·`figure`(**최상위 칸**) 는 자리가 없어 | 옛 서식 전용 칸. `assets[].role:"figure"` 와 다른 것이다 |
|
|
403
419
|
| 절 …의 `principles`/`logic` 은 자리가 없어 | 같음 |
|
|
@@ -450,7 +466,7 @@ python3 assets/render.py <report.json>
|
|
|
450
466
|
| ⚠ | 문턱 | 처방 |
|
|
451
467
|
|---|---|---|
|
|
452
468
|
| 절 N 「이름」이 글로만 서 있습니다(M자) | 산문 400자 이상인데 표·흐름도·차트·도판·인용·도해 0 | 그 절의 요지를 표·흐름도·차트·도판·인용·도해 중 하나로 세운다(§11-1~11-9) |
|
|
453
|
-
|
|
|
469
|
+
| %s 인용 %d 에 출처 줄이 없습니다 — 원본 위치 한 구절(주소·쪽·행)을 적습니다 | `quotes[]` 에 `source_text`·`source_url` 둘 다 없다(`%s` 는 「절 N」 또는 「건 NN-n」) | 원문의 위치 한 구절(주소·쪽·행)을 적는다(§11-8) |
|
|
454
470
|
| 절 %s 도해 「%s」의 글자가 표시 %dpx 로 섭니다 — %dpx 이상이 되게 wide 로 두거나 노드를 줄입니다 | 도해 SVG 의 가장 작은 font-size × (표시 폭 ÷ viewBox 폭) < 15px(readability R1 과 같은 잣대) | `wide: true` 로 전폭에 세우거나 노드·라벨을 줄인다(§11-9) |
|
|
455
471
|
| 절 %s 도해 「%s」가 %d바이트입니다 — 상한 %d바이트, 노드를 줄이거나 도판(PNG)으로 싣습니다 | `diagrams[].svg` 가 250,000B 초과 | 도해를 나누거나 PNG 도판으로 |
|
|
456
472
|
| 절 %s 도해 「%s」에 viewBox 가 없거나 svg 가 아닙니다 — 폭을 알 수 없어 싣지 않습니다 | 루트 `<svg>` 에 viewBox 가 없다 | `diagram.mjs export` 로 굳힌 SVG 를 넣는다(§11-9) |
|
|
@@ -556,7 +572,7 @@ CSS 로는 못 한다. 우측 판독부에 남는 것은 문서 전체의 사실
|
|
|
556
572
|
| 그림 바이트 | `file`(**로컬 절대경로**) 또는 `source`(허용 호스트 https). 둘 다 주면 `file` 이 이긴다 |
|
|
557
573
|
| 캡션 | `assets[].caption` — 번호 뒤에 붙는다. **번호는 적지 않는다**(자리가 매긴다). **한 문장으로** — 마침표가 둘이면 문장 분리가 캡션을 줄로 가르고 렌더러가 ⚠ 로 센다(부연은 괄호·가운뎃점으로). 정직성은 끝의 한 낱말(「— 도안」·「— 재현」·「— 합성」)이면 된다. 판정 칩은 렌더러가 문장 블록 밖에 붙인다(2026-09-09) |
|
|
558
574
|
| 출처 | `assets[].source_text` — 독자가 원본에 닿는 **위치 한 구절**(쪽·행·주소·건 § 가운데 하나, readability R12e). 제작 메타는 `origin` 에만, 바깥 원본이 없는 우리 도안·흐름은 비운다 |
|
|
559
|
-
| 놓을 절 | `sections[].plates: ["그-id", ...]` — **여기 안 적으면 자리가 없어 안 실린다**(⚠). 본문 단 도판을 먼저, 전폭을 뒤에 적는다(순서가 번호와 갈리면 ⚠) |
|
|
575
|
+
| 놓을 절 | `sections[].plates: ["그-id", ...]` — **여기 안 적으면 자리가 없어 안 실린다**(⚠). 본문 단 도판을 먼저, 전폭을 뒤에 적는다(순서가 번호와 갈리면 ⚠). 한 건의 근거인 화면은 절이 아니라 **그 건**에 적는다 — `cases[].plates`(11-10절). 건 안은 늘 본문 단 폭이라 전폭 자산은 절에 둔다(건에 두면 ⚠) |
|
|
560
576
|
| 전폭 | `assets[].wide: true` — 두 단 밖, 절 끝에 문서 폭(readability §1 기준 창의 전폭)으로 선다. 그 절의 그림 번호는 본문 단 도판 뒤다 |
|
|
561
577
|
|
|
562
578
|
**전폭(`wide: true`)은 렌더러 `7f1cc25` 부터 그리고, 2026-09-12 운영 배포(`3a9fe3f`)에 들어 있다.** 두 단 밖에 전폭 폭으로 서고
|
|
@@ -566,7 +582,7 @@ CSS 로는 못 한다. 우측 판독부에 남는 것은 문서 전체의 사실
|
|
|
566
582
|
→ 그런 화면을 근거로 실어야 하면 **글자를 도판에만 맡기지 말고 부록에 전사(轉寫)하라.**
|
|
567
583
|
도판은 「그 화면이 실재한다」를 맡고, 읽히는 것은 부록이 맡는다.
|
|
568
584
|
|
|
569
|
-
**그림 번호의 순서**는 절마다 차트 → 흐름도 → 본문 단 도판(`plates` 순) → 전폭 도판이다(`gazette.py Doc.fig_order()` 한 벌 —
|
|
585
|
+
**그림 번호의 순서**는 절마다 건에 묶인 도판(건 순 · `cases[].plates` 순) → 차트 → 흐름도 → 본문 단 도해 → 본문 단 도판(`plates` 순) → 전폭 도해 → 전폭 도판이다(`gazette.py Doc.fig_order()` 한 벌 —
|
|
570
586
|
그리는 쪽·가리킴 토큰·번호 대조 ⚠ 가 같은 셈을 읽는다). 표는 따로 센다. 셈은 서버 기준이고 **바이트와 무관**하다 —
|
|
571
587
|
그림 바이트가 없어 로컬에서 안 그려지는 도판도 자리를 차지하므로 로컬 번호가 운영 번호와 같다. `assets` 에 없는 id 는 세지 않는다.
|
|
572
588
|
본문에서 그림을 가리킬 때는 번호를 손으로 적지 않고 `{{그림:<id>}}` 로 쓴다(§11-6 — ⚠ **운영 반영 확인 전**이라 배포 뒤 열람 DOM 확인 전에는 쓰지 않는다).
|
|
@@ -878,7 +894,8 @@ AWS 요금 조사(2026-09)에서 트래픽·로그·DB 용량이 8월 1일 앞
|
|
|
878
894
|
| `translation` | 우리말 풀이 한 문장(선택). 우리 글이므로 표기를 푼다 |
|
|
879
895
|
|
|
880
896
|
인용은 절 본문의 **맨 앞**에 선다(원문이 가장 강한 근거이고 표·도해는 그것을 줄인 것). `kind` 와 무관하다.
|
|
881
|
-
번호는 없다 — 그림·표가 아니다.
|
|
897
|
+
번호는 없다 — 그림·표가 아니다. (2026-09-17 까지 렌더러는 주석과 달리 kind 블록 **뒤**에 붙였다 — 같은 날 코드를 이 문장에 맞췄다.)
|
|
898
|
+
한 건에 딸린 인용(그 건의 거절문·회신)은 `cases[].quotes` 로 **그 건 안**에 둔다(11-10절) — 절에 두면 첫 건의 것처럼 읽힌다.
|
|
882
899
|
|
|
883
900
|
## 11-9. 도해 — Archify 를 흡수한다 (2026-09-16)
|
|
884
901
|
|
|
@@ -919,6 +936,35 @@ AWS 요금 조사(2026-09)에서 트래픽·로그·DB 용량이 8월 1일 앞
|
|
|
919
936
|
관문(⚠, §10-1): 바이트 250,000 초과 · viewBox 없음 · **가장 작은 글자 × (표시 폭 ÷ viewBox 폭) < 15px**(readability R1, `svg` 길에만).
|
|
920
937
|
`diagram.mjs` 가 같은 잣대로 먼저 재므로 렌더러 ⚠ 는 마지막 그물이다. 그림 번호는 차트 → 흐름도 → 도해 → 도판 순.
|
|
921
938
|
|
|
939
|
+
## 11-10. 건에 묶는 근거 — `cases[].quotes` · `tables` · `plates` (2026-09-17)
|
|
940
|
+
|
|
941
|
+
건이 여럿인 절에서 표·도판·인용을 절 칸에 두면 렌더러는 건 목록을 다 그린 **뒤**에 인용 → 표 → 그림을 한꺼번에 세운다
|
|
942
|
+
(`gazette.py Doc.chapter()` 의 순서). HUnion 사후 보고(초안 182)에서 건 다섯 뒤에 인용 하나·표 다섯·그림 넷이 서서,
|
|
943
|
+
3.5 건의 애플 거절 인용이 3.1 건의 거절문처럼 읽혀 반려됐다. 근거는 **그 건의 필드표 바로 아래** 서야 한다.
|
|
944
|
+
|
|
945
|
+
```json
|
|
946
|
+
"cases": [
|
|
947
|
+
{ "group": "c", "title": "파생 앱도 같은 원인으로 탈락함", "grade": "해외 측정 확인",
|
|
948
|
+
"symptom": "…", "cause": "…", "fix": "…", "how_to_check": "…",
|
|
949
|
+
"quotes": [{ "text": "Guideline 2.1(a) … Unable to connect", "source_text": "파생 앱 심사 메시지 · App Store Connect · 2026. 9. 16.", "verdict": "직접 확인" }],
|
|
950
|
+
"tables": [{ "caption": "파생 앱 사이트 해외 측정", "cols": ["경로", "국내", "미국 두 곳"], "rows": [["첫 화면", "{{ok|실제 페이지}}", "{{bad|검사 화면}}"]] }],
|
|
951
|
+
"plates": ["apple-403"] }
|
|
952
|
+
]
|
|
953
|
+
```
|
|
954
|
+
|
|
955
|
+
| 칸 | 모양 | 어디에 서나 |
|
|
956
|
+
|---|---|---|
|
|
957
|
+
| `cases[].quotes` | 절의 `quotes` 와 같다(11-8절) | 그 건 article 안, 필드표 아래 첫 자리 |
|
|
958
|
+
| `cases[].tables` | 절의 `tables` 와 같다(11-3절) | 인용 다음. 표 번호는 절과 **한 셈** — 건의 표가 먼저 |
|
|
959
|
+
| `cases[].plates` | `assets[].id` 목록(11-1절) | 표 다음. 그림 번호도 절과 한 셈 — 건의 도판이 먼저. **늘 본문 단 폭** — 전폭(`wide`) 자산은 절 `plates` 에(건에 두면 ⚠ 로 세고 본문 단 폭으로 그린다) |
|
|
960
|
+
|
|
961
|
+
- 절 칸(`sections[].tables`·`plates`·`quotes`)은 **절 전체에 걸치는 근거**의 자리로 남는다 — 조치 전후 대조표처럼 여러 건이 함께 읽는 표.
|
|
962
|
+
- 없는 id 를 `cases[].plates` 에 적으면 서버가 발행을 거절한다(절 `plates` 와 같은 게이트). 「어느 절에도 안 걸린 도판」 ⚠ 는 건 `plates` 도 자리로 센다.
|
|
963
|
+
- `{{그림:<id>}}` 토큰은 건 도판도 가리킨다(번호 셈이 한 벌).
|
|
964
|
+
- 편집기(`lim-report` 서식)는 절의 `quotes`·`tables` 와 마찬가지로 이 칸을 아직 모른다 — 편집기로 고쳐 발행하면 빠진다(발행 데이터 정본일 때만 산다).
|
|
965
|
+
|
|
966
|
+
집행자: 렌더러 `gazette.py Doc.case_evidence()` · 서버 `report_publish.php report_data_validate()`(건 `plates` 게이트) · 시험 `tests/builder.test.py` 「건별 근거」.
|
|
967
|
+
|
|
922
968
|
## 12. 판정 밖의 두 모드 — 가르치는 문서 · R&D 브리핑
|
|
923
969
|
|
|
924
970
|
독자가 **판정**하는 문서(검수 보고)와 **배우는** 문서, **결정하는** 문서는 뼈대가 다르다.
|