genetics-mcp 2.26.0 → 2.28.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 +5 -5
- package/bin/x86_64-apple-darwin/gcell +0 -0
- package/bin/x86_64-pc-windows-gnu/gcell.exe +0 -0
- package/bin/x86_64-unknown-linux-musl/gcell +0 -0
- package/genetics.mjs +1543 -192
- package/package.json +1 -1
- package/skills/gazette-report/SKILL.md +11 -1
- package/skills/gazette-report/scripts/boundary.py +8 -1
- package/skills/gazette-report/scripts/tone.mjs +31 -4
- package/skills/gazette-report/scripts/tone.test.mjs +34 -0
- package/skills/genetics-cell-id/SKILL.md +73 -54
- package/skills/genetics-define/SKILL.md +34 -1
- package/skills/genetics-report/SKILL.md +67 -69
- package/skills/genetics-report/assets/example/cases-briefing-designs.json +15 -5
- package/skills/genetics-report/assets/example/cases-briefing.json +14 -5
- package/skills/genetics-report/assets/example/cases.json +25 -9
- package/skills/genetics-report/assets/example/decision.json +315 -0
- package/skills/genetics-report/assets/example/report-briefing-designs.json +2 -2
- package/skills/genetics-report/assets/example/report-briefing.json +1 -1
- package/skills/genetics-report/assets/example/report.json +65 -21
- package/skills/genetics-report/assets/render.py +204 -49
- package/skills/genetics-report/references/content-pipeline.md +6 -6
- package/skills/genetics-report/references/decision-format.md +113 -0
- package/skills/genetics-report/references/diagrams.md +52 -94
- package/skills/genetics-report/references/edition-review.workflow.js +81 -31
- package/skills/genetics-report/references/history.md +44 -0
- package/skills/genetics-report/references/lim-portal-format.md +93 -236
- package/skills/genetics-report/references/linear-read-test.workflow.js +33 -10
- package/skills/genetics-report/references/process.md +34 -26
- package/skills/genetics-report/references/readability.md +9 -8
- package/skills/genetics-report/references/reader-review.workflow.js +167 -25
- package/skills/genetics-report/references/visual-first.md +56 -42
- package/skills/genetics-report/assets/diagram.mjs +0 -208
package/genetics.mjs
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* 의존성 0 — MCP 는 stdio 위의 JSON-RPC 2.0 이라 표준 라이브러리로 충분하다.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { spawn, spawnSync } from 'node:child_process';
|
|
24
|
+
import { execFile, spawn, spawnSync } from 'node:child_process';
|
|
25
25
|
import crypto from 'node:crypto';
|
|
26
26
|
import fs from 'node:fs';
|
|
27
27
|
import os from 'node:os';
|
|
@@ -1059,7 +1059,8 @@ async function apiOpen(method, pathname, body) {
|
|
|
1059
1059
|
}
|
|
1060
1060
|
const res = await fetch(BASE + pathname, {
|
|
1061
1061
|
method,
|
|
1062
|
-
|
|
1062
|
+
// 판 머리 — api() 와 같은 규율(모든 요청 · 하위 업무 번호 3단계 잣대 ②).
|
|
1063
|
+
headers: { 'X-Genetics-Client': `genetics-mcp/${VERSION}`, 'Content-Type': 'application/json' },
|
|
1063
1064
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1064
1065
|
});
|
|
1065
1066
|
const text = await res.text();
|
|
@@ -1165,7 +1166,9 @@ async function api(method, pathname, body, opts = {}) {
|
|
|
1165
1166
|
res = await fetch(BASE + pathname, {
|
|
1166
1167
|
method,
|
|
1167
1168
|
// opts.headers 는 자동 올리기의 머리 셋(§4.3)이 쓴다 — 자격·형식 머리는 덮지 못한다.
|
|
1168
|
-
|
|
1169
|
+
// ★ 판 머리(X-Genetics-Client)는 **모든 요청**에 싣는다(하위 업무 번호 DESIGN-item-codes.md · R16 ③) — 서버가 「경로만 아는 판」의
|
|
1170
|
+
// 요청을 판별로 센다(3단계로 넘어가는 잣대 ②). 전에는 자동 올리기 길에만 있었다. 자동 올리기의 같은 값은 그대로 덮어 쓴다.
|
|
1171
|
+
headers: { 'X-Genetics-Client': `genetics-mcp/${VERSION}`, ...(opts.headers || {}), Authorization: `Bearer ${c.token}`, 'Content-Type': 'application/json' },
|
|
1169
1172
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1170
1173
|
signal,
|
|
1171
1174
|
});
|
|
@@ -1259,7 +1262,8 @@ async function apiToFile(pathname, target, opts = {}) {
|
|
|
1259
1262
|
+ '주소를 바꾸라는 안내를 받았다면 의심할 것.');
|
|
1260
1263
|
}
|
|
1261
1264
|
const res = await fetch(BASE + pathname, {
|
|
1262
|
-
|
|
1265
|
+
// 판 머리 — api() 와 같은 규율(모든 요청 · 하위 업무 번호 3단계 잣대 ②).
|
|
1266
|
+
headers: { 'X-Genetics-Client': `genetics-mcp/${VERSION}`, Authorization: `Bearer ${cred.token}`, Accept: 'application/x-ndjson' },
|
|
1263
1267
|
});
|
|
1264
1268
|
if (!res.ok) {
|
|
1265
1269
|
// 실패는 작다 — 그때는 봉투를 읽어 서버가 준 말을 그대로 옮긴다.
|
|
@@ -1294,22 +1298,366 @@ function limMeta(h) {
|
|
|
1294
1298
|
}
|
|
1295
1299
|
|
|
1296
1300
|
// ── 도구 ────────────────────────────────────────────────────────────
|
|
1297
|
-
//
|
|
1298
|
-
//
|
|
1301
|
+
// 데이터로 발행하는 서식은 **하나**다(서버 report_publish.php 의 REPORT_TEMPLATES — 관보 v3 2026-10-07 ⑨) — lim-report(관보).
|
|
1302
|
+
// 고객이 고를 결정이 있는 문서는 같은 서식에 결정 블록(`decision_brief.decisions`)을 적는다 — 결정 블록이 있으면 결정 모드, 없으면
|
|
1303
|
+
// 보고 모드다(모드는 서버 report_mode · 렌더러 build.py report_mode 가 원고 내용으로 고른다). 옛 이름 lim-decision 은 서버가 lim-report 로 맞춘다.
|
|
1299
1304
|
//
|
|
1300
1305
|
// 필수 칸을 세우지 않는 이유: 전에는 표지 4칸과 건마다 6칸이 다 차 있어야 발행이 됐고,
|
|
1301
1306
|
// 그래서 **초안을 올려 두고 다듬는 일이 아예 불가능했다.** 비어 있는 것은 사람이 정할 일이다.
|
|
1302
1307
|
// 서버가 여전히 막는 것은 **조용히 사라지는 것**뿐이다 — 모르는 kind 의 절, 없는 그룹의 건.
|
|
1308
|
+
// (결정 모드는 하나 더 막는다 — 이대로 그리면 비대칭·헛고리·거짓이 되는 원고. 필수는 결정의 뼈대뿐이다 — 아래 정의가 말한다.)
|
|
1309
|
+
|
|
1310
|
+
// ── 원고 칸 정의 시작 — tools/contract-drift.py 가 여기서부터 REPORT_DATA 끝까지 읽는다 ──
|
|
1311
|
+
/*
|
|
1312
|
+
* 결정 블록 원고 칸(관보 버전2 2026-10-06 → v3 2026-10-07: 서식 하나 · 결정 블록이 있으면 결정 모드) — 전부 `data.decision_brief`
|
|
1313
|
+
* 하나에 묶는다. 결정 블록을 안 쓰는 세션이
|
|
1314
|
+
* 읽을 몫을 줄이려는 것이다(설계안 7.1·A-10). 칸 설명은 칸마다 「뜻 한 줄 + 빈 값을 쓰는 때」만 둔다 —
|
|
1315
|
+
* 스킬에서 걷어 낸 규칙 글을 여기로 옮겨 오면 도구를 부를 때마다 읽는 글이 그만큼 는다.
|
|
1316
|
+
* 다시 쓰는 꼴은 아래 다섯의 상수로 끼워 넣는다. `$ref`·`$defs` 는 쓰지 않는다 — 도구 스키마는 평평한 JSON 으로
|
|
1317
|
+
* 나가고, 클라이언트마다 다른 `$ref` 해석에 기대지 않는다. 그래서 끼워 넣는 꼴은 **설명 없이 뼈대만** 둔다 —
|
|
1318
|
+
* 빈 값 객체 넷은 한 꼴(`empty` 열거)로 접고, 빈 값의 부속 칸과 주체 꼴의 뜻은 DECISION_BRIEF 설명에 한 번만 적는다.
|
|
1319
|
+
* 끼워 넣는 서른여 자리마다 되풀이되면 도구 목록(발행·교체 두 도구가 같은 객체를 품는다)이 그만큼 커진다.
|
|
1320
|
+
* 부속 칸의 짝(na+why …)은 서버가 잰다(V-EMPTY).
|
|
1321
|
+
*/
|
|
1322
|
+
const DEC_SENTENCE = {
|
|
1323
|
+
oneOf: [
|
|
1324
|
+
{ type: 'string', minLength: 1 },
|
|
1325
|
+
{ type: 'object', properties: { empty: { type: 'string', enum: ['na', 'undecided', 'elsewhere', 'unverified'] } }, required: ['empty'] },
|
|
1326
|
+
],
|
|
1327
|
+
};
|
|
1328
|
+
const DEC_WHO = {
|
|
1329
|
+
oneOf: [
|
|
1330
|
+
{ type: 'string', pattern: '^(customer|fcg|party:[a-z0-9-]{1,32})$' },
|
|
1331
|
+
DEC_SENTENCE.oneOf[1],
|
|
1332
|
+
],
|
|
1333
|
+
};
|
|
1334
|
+
/*
|
|
1335
|
+
* 문장 또는 블록 목록(2026-10-08) — now · options[].what · facts_lead · opinion 넷만 쓴다. 문장 칸(글자 · 빈 값)에 더해 블록 1~12개의
|
|
1336
|
+
* 목록을 받는다 — 쓰는 사람이 글 벽 대신 지식의 구조(견주기 · 차례 · 이름→풀이)를 찾아 적게 하려는 것이다. 블록은 키 하나:
|
|
1337
|
+
* p · note(문장 칸) · table{caption?, head[2~5], rows[[칸 × head 수] 1~12]} · steps[{text, who?} 2~12] · items[{k ≤40자, v} 1~12].
|
|
1338
|
+
* 칸의 글은 문장 칸 · who 는 주체 꼴이다. 행의 칸 수 · 그 밖의 키 · 맨 「없음」은 서버가 잰다(report_publish.php report_decision_prose).
|
|
1339
|
+
* description 은 네 칸이 제 뜻 한 줄 뒤에 붙이는 한 문장이다(이 상수만 따로 나가는 자리는 없다 — 고칠 곳은 여기 한 곳).
|
|
1340
|
+
*/
|
|
1341
|
+
const DEC_PROSE = {
|
|
1342
|
+
description: '구조부터 — 견주기는 table(칸 크기를 견주게 하려면 bar{col: 열 이름}) · 차례는 steps · 이름→풀이는 items · 한 예는 strip(칸 둘~셋 — 마지막이 결과) · 한 줄은 p · 한계는 note(블록 목록 — 블록마다 키 하나).',
|
|
1343
|
+
oneOf: [
|
|
1344
|
+
DEC_SENTENCE.oneOf[0],
|
|
1345
|
+
DEC_SENTENCE.oneOf[1],
|
|
1346
|
+
{
|
|
1347
|
+
type: 'array', minItems: 1, maxItems: 12,
|
|
1348
|
+
items: {
|
|
1349
|
+
type: 'object', minProperties: 1, maxProperties: 1,
|
|
1350
|
+
properties: {
|
|
1351
|
+
p: DEC_SENTENCE,
|
|
1352
|
+
note: DEC_SENTENCE,
|
|
1353
|
+
table: {
|
|
1354
|
+
type: 'object',
|
|
1355
|
+
properties: {
|
|
1356
|
+
caption: { type: 'string', minLength: 1, maxLength: 80 },
|
|
1357
|
+
head: { type: 'array', minItems: 2, maxItems: 5, items: { type: 'string', minLength: 1 } },
|
|
1358
|
+
rows: { type: 'array', minItems: 1, maxItems: 12, items: { type: 'array', minItems: 2, maxItems: 5, items: DEC_SENTENCE } },
|
|
1359
|
+
bar: {
|
|
1360
|
+
type: 'object', properties: { col: { type: 'string', minLength: 1 }, max: { type: 'number', exclusiveMinimum: 0 } }, required: ['col'],
|
|
1361
|
+
description: '칸 막대 — 그 열(head 의 이름 그대로 · 첫 칸 빼고) 칸 글 앞의 수로 막대. 끝은 max, 없으면 칸이 모두 %면 100 · 아니면 그 열의 가장 큰 수.',
|
|
1362
|
+
},
|
|
1363
|
+
},
|
|
1364
|
+
required: ['head', 'rows'],
|
|
1365
|
+
},
|
|
1366
|
+
steps: {
|
|
1367
|
+
type: 'array', minItems: 2, maxItems: 12,
|
|
1368
|
+
items: { type: 'object', properties: { text: DEC_SENTENCE, who: DEC_WHO }, required: ['text'] },
|
|
1369
|
+
},
|
|
1370
|
+
items: {
|
|
1371
|
+
type: 'array', minItems: 1, maxItems: 12,
|
|
1372
|
+
items: { type: 'object', properties: { k: { type: 'string', minLength: 1, maxLength: 40 }, v: DEC_SENTENCE }, required: ['k', 'v'] },
|
|
1373
|
+
},
|
|
1374
|
+
strip: {
|
|
1375
|
+
type: 'array', minItems: 2, maxItems: 3,
|
|
1376
|
+
description: '예시 띠 — 한 예를 칸 둘~셋과 화살표로(「이 기간으로 배워 → 그다음 달을 맞혀 봄」). 칸은 k(머리 — 언제·무엇) · v(값 — 굵게) · s(곁말). 마지막 칸이 결과.',
|
|
1377
|
+
items: { type: 'object', properties: { k: DEC_SENTENCE, v: DEC_SENTENCE, s: DEC_SENTENCE }, required: ['v'] },
|
|
1378
|
+
},
|
|
1379
|
+
},
|
|
1380
|
+
},
|
|
1381
|
+
},
|
|
1382
|
+
],
|
|
1383
|
+
};
|
|
1384
|
+
const DEC_DUE = {
|
|
1385
|
+
description: '기한 — date(YYYY-MM-DD) 또는 days_after_release(공개일부터 1~365일) 가운데 꼭 하나와 why(그 까닭 — 선택). '
|
|
1386
|
+
+ '기한이 아직 없으면 이 칸을 빈 값으로.',
|
|
1387
|
+
oneOf: [
|
|
1388
|
+
{ type: 'object', properties: { date: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, why: DEC_SENTENCE }, required: ['date'] },
|
|
1389
|
+
{ type: 'object', properties: { days_after_release: { type: 'integer', minimum: 1, maximum: 365 }, why: DEC_SENTENCE },
|
|
1390
|
+
required: ['days_after_release'] },
|
|
1391
|
+
DEC_SENTENCE.oneOf[1],
|
|
1392
|
+
],
|
|
1393
|
+
};
|
|
1394
|
+
const DEC_TEXT_WHO = {
|
|
1395
|
+
oneOf: [
|
|
1396
|
+
{ type: 'object', properties: { who: DEC_WHO, text: DEC_SENTENCE }, required: ['who', 'text'] },
|
|
1397
|
+
DEC_SENTENCE.oneOf[1],
|
|
1398
|
+
],
|
|
1399
|
+
};
|
|
1400
|
+
const DECISION_BRIEF = {
|
|
1401
|
+
type: 'object',
|
|
1402
|
+
description: '결정 블록 — decisions(정할 것)가 하나 이상이면 문서가 결정 모드로 선다(서식은 lim-report 하나 · template 은 안 적어도 된다). '
|
|
1403
|
+
+ '결정이 없으면 이 칸을 빼고 보고로 낸다(decisions 가 빈 decision_brief 는 거절). '
|
|
1404
|
+
+ '필수(거절)는 뼈대뿐 — decisions[].name · question · options(둘 이상)[].name · line, 공개 때 reply_to.who. '
|
|
1405
|
+
+ '권함(없어도 거절 아님 — 렌더러가 ⚠): due · now · 결정에 닿는 facts · no_reply. 선택(쓰면 서고 안 쓰면 그리지 않음): options[].rows 전부 · cost · '
|
|
1406
|
+
+ 'opinion · confirms · risks · unknowns · names 와 그 밖의 칸. 대칭 — 한 선택지에 쓴 행·돈은 모든 선택지가 쓴다. '
|
|
1407
|
+
+ '문장 칸은 글자, 또는 비우는 까닭을 밝히는 빈 값 {empty, …}: na+why(해당 없음) · undecided+until(아직 안 정해짐 — 정해지는 때) · '
|
|
1408
|
+
+ 'elsewhere+who+when(이 문서에 없음 — 누가 언제 정하나) · unverified+fill(확인하지 못함 — 채울 수단). 「없음」「-」 같은 맨 글자는 거절된다. '
|
|
1409
|
+
+ '누가 칸(by·who·payer·to)은 customer(받는 쪽, 화면에는 client) · fcg(보내는 쪽, 화면 말은 labels.dc_who_fcg) · party:<names 의 id>(그 밖의 쪽), 또는 빈 값. '
|
|
1410
|
+
+ 'id 는 영소문자·숫자·하이픈 1~32자, 문서 전체가 한 이름 공간이다. 글 안에서 {{결정:id}}·{{선택:id}}·{{사실:id}}·{{확인:id}}·{{위험:id}}·{{이름:id}} 로 다른 물건을 부른다.',
|
|
1411
|
+
properties: {
|
|
1412
|
+
scope: { type: 'string', description: '점검 범위 한 문장 — 무엇을, 어느 계정·환경에서, 언제(선택).' },
|
|
1413
|
+
reply_to: {
|
|
1414
|
+
type: 'object',
|
|
1415
|
+
description: '회신 받는 사람. who 가 비면 공개되지 않는다(초안·dry_run 은 알림만).',
|
|
1416
|
+
properties: { who: { type: 'string' }, contact: { type: 'string' } },
|
|
1417
|
+
},
|
|
1418
|
+
decisions: {
|
|
1419
|
+
type: 'array', minItems: 1,
|
|
1420
|
+
description: '지금 정하실 결정 — 결정마다 한 장으로, 기한 순으로 선다. 하나 이상이면 결정 모드.',
|
|
1421
|
+
items: {
|
|
1422
|
+
type: 'object',
|
|
1423
|
+
properties: {
|
|
1424
|
+
id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
|
|
1425
|
+
uid: { type: 'string', pattern: '^[a-z0-9]{6}$', description: '서버가 발급하는 불변 이름. get_report 가 준 값은 그대로 되돌려 보낸다.' },
|
|
1426
|
+
name: { type: 'string', description: '짧은 이름 — 첫 화면·장 머리·회신에 같은 글자로 선다.' },
|
|
1427
|
+
question: { ...DEC_SENTENCE, description: '물음 하나(25자 안팎). 원고에 물음이 없으면 빈 값.' },
|
|
1428
|
+
due: { ...DEC_DUE, description: '결정 기한(권함 — 없으면 거절 대신 렌더러가 ⚠). ' + DEC_DUE.description },
|
|
1429
|
+
now: { ...DEC_PROSE, description: '지금 상태 — 대상과 문제를 쉬운 말로(권함). ' + DEC_PROSE.description },
|
|
1430
|
+
figures: {
|
|
1431
|
+
type: 'array',
|
|
1432
|
+
description: '그림 칸 — 「지금 상태」 바로 아래에 서는 이 결정의 그림(선택). 항목마다 diagram(설계 데이터 — Archify IR 객체, '
|
|
1433
|
+
+ 'sections[].diagrams[].ir 와 같은 꼴) · chart(차트 한 벌 — sections[].charts[] 와 같은 꼴: 막대 rows 또는 kind:"series"+points, ref·form 포함) · '
|
|
1434
|
+
+ 'plate(도판 assets[].id — role figure·video) 가운데 꼭 하나와 caption · source(출처 한 줄). 그 밖의 키는 거절. '
|
|
1435
|
+
+ '그림 번호는 문서 전체 한 셈이라 적지 않는다(같은 그림은 같은 번호).',
|
|
1436
|
+
items: {
|
|
1437
|
+
type: 'object',
|
|
1438
|
+
properties: {
|
|
1439
|
+
diagram: { type: 'object', description: '설계 데이터(Archify IR) — 서버가 관보 그림으로 굽는다.' },
|
|
1440
|
+
chart: { type: 'object', description: '차트 한 벌 — 그릴 행(rows)이나 점(points)이 있어야 한다. 절 charts[] 와 같은 꼴(범위 · 기울기 · 구성 띠 · 쌓은 막대 포함).' },
|
|
1441
|
+
plate: { type: 'string', description: '도판 id(assets[].id).' },
|
|
1442
|
+
caption: { type: 'string', description: '그림 캡션 — 번호 없이.' },
|
|
1443
|
+
source: { type: 'string', description: '출처 한 줄.' },
|
|
1444
|
+
},
|
|
1445
|
+
minProperties: 1,
|
|
1446
|
+
},
|
|
1447
|
+
},
|
|
1448
|
+
settled: {
|
|
1449
|
+
type: 'array',
|
|
1450
|
+
description: '고객이 이미 정하신 것 — 정하신 기록(record, 화면에 안 찍힘)이 있는 것만.',
|
|
1451
|
+
items: { type: 'object', properties: { text: { type: 'string' }, by: DEC_WHO, record: { type: 'string' } }, required: ['text', 'by', 'record'] },
|
|
1452
|
+
},
|
|
1453
|
+
options: {
|
|
1454
|
+
type: 'array', minItems: 2,
|
|
1455
|
+
description: '선택지 — 그대로 두는 길을 포함해 둘 이상(이름과 한 줄 뜻이 필수 · 나머지는 선택).',
|
|
1456
|
+
items: {
|
|
1457
|
+
type: 'object',
|
|
1458
|
+
properties: {
|
|
1459
|
+
id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
|
|
1460
|
+
name: { type: 'string', description: '선택지 이름 — 표 열 머리·첫 화면·회신 말에 같은 글자로.' },
|
|
1461
|
+
line: { ...DEC_SENTENCE, description: '고르면 무엇이 달라지나 한 줄(35자 안팎). 없으면 빈 값.' },
|
|
1462
|
+
what: { ...DEC_PROSE, description: '실제로 하는 일 — 끝나거나 풀리는 조건까지(선택). ' + DEC_PROSE.description },
|
|
1463
|
+
is_current: { type: 'boolean', description: '지금 상태를 지키는 선택지 — 결정마다 많아야 하나, 늘 첫째로 선다.' },
|
|
1464
|
+
rows: {
|
|
1465
|
+
type: 'object',
|
|
1466
|
+
description: '대칭 표의 값(선택 — 쓰면 행이 서고 안 쓰면 그 행이 없다). 한 선택지에 쓴 행은 모든 선택지가 문장이나 빈 값으로 채운다. 이 밖의 키는 거절.',
|
|
1467
|
+
properties: {
|
|
1468
|
+
for_users: { ...DEC_SENTENCE, description: '이용자가 겪는 일.' },
|
|
1469
|
+
client_does: { ...DEC_TEXT_WHO, description: '고객 쪽이 하실 일 — 요청 자리, text 는 하십시오체 요청.' },
|
|
1470
|
+
fcg_does: { ...DEC_SENTENCE, description: '보내는 쪽이 할 일 — 화면 행 이름은 원고 issuer 첫 이름으로 「{발행처} 가 할 일」(labels.dc_row_fcg_does 로 덮음).' },
|
|
1471
|
+
risk_left: { ...DEC_SENTENCE, description: '남는 위험.' },
|
|
1472
|
+
settles_today: { ...DEC_SENTENCE, description: '그날 정해지는 것.' },
|
|
1473
|
+
others_do: { ...DEC_TEXT_WHO, description: '그 밖의 쪽이 할 일.' },
|
|
1474
|
+
},
|
|
1475
|
+
},
|
|
1476
|
+
cost: {
|
|
1477
|
+
type: 'object',
|
|
1478
|
+
description: '돈(선택 — 한 선택지에 쓰면 모든 선택지가) — 금액은 적지 않는다. 쓰면 state: yes(듦)·no(안 듦)·unknown(모름)는 필수, '
|
|
1479
|
+
+ 'known_when(언제 아나)·payer(누가 내나)는 아는 것만.',
|
|
1480
|
+
properties: {
|
|
1481
|
+
state: { type: 'string', enum: ['yes', 'no', 'unknown'] },
|
|
1482
|
+
known_when: DEC_SENTENCE,
|
|
1483
|
+
payer: DEC_WHO,
|
|
1484
|
+
},
|
|
1485
|
+
required: ['state'],
|
|
1486
|
+
},
|
|
1487
|
+
then: {
|
|
1488
|
+
type: 'array', description: '회신 뒤 일.',
|
|
1489
|
+
items: { type: 'object', properties: { what: { type: 'string' }, who: DEC_WHO, when: { type: 'string' } }, required: ['what', 'who', 'when'] },
|
|
1490
|
+
},
|
|
1491
|
+
extra_rows: {
|
|
1492
|
+
type: 'array',
|
|
1493
|
+
description: '대장 「선택지 전체 비교」에만 서는 잣대 — 한 결정의 모든 선택지가 같은 label 을 갖는다.',
|
|
1494
|
+
items: { type: 'object', properties: { label: { type: 'string' }, text: DEC_SENTENCE }, required: ['label', 'text'] },
|
|
1495
|
+
},
|
|
1496
|
+
},
|
|
1497
|
+
required: ['id', 'name', 'line'],
|
|
1498
|
+
},
|
|
1499
|
+
},
|
|
1500
|
+
no_current_why: { ...DEC_SENTENCE, description: '그대로 두는 길(is_current)이 선택지에 없을 때 그 까닭.' },
|
|
1501
|
+
no_reply: { ...DEC_SENTENCE, description: '기한까지 회신이 없으면 무엇이 그대로이고 누가 무엇을 하나(권함).' },
|
|
1502
|
+
facts_lead: { ...DEC_PROSE, description: '이 결정에 닿는 사실이 둘 이상이면 — 사실들이 선택지를 어떻게 가르는지 잇는 글. ' + DEC_PROSE.description },
|
|
1503
|
+
reply: {
|
|
1504
|
+
type: 'object',
|
|
1505
|
+
description: '회신에 덧붙일 값 — attach(함께 적어 주실 것) · conditions(조건 안내, 기본 참). {} 도 받는다.',
|
|
1506
|
+
properties: { attach: { type: 'array', items: { type: 'string' } }, conditions: { type: 'boolean' } },
|
|
1507
|
+
},
|
|
1508
|
+
opinion: { ...DEC_PROSE, description: '보내는 쪽 의견 — 판단은 이 칸에만 둔다(선택 — 의견이 없으면 칸을 뺀다). ' + DEC_PROSE.description },
|
|
1509
|
+
},
|
|
1510
|
+
required: ['id', 'name', 'question', 'options'],
|
|
1511
|
+
},
|
|
1512
|
+
},
|
|
1513
|
+
confirms: {
|
|
1514
|
+
type: 'array',
|
|
1515
|
+
description: '확인 요청 — 결정을 바꿀 수 있거나 결정 뒤에 할 확인.',
|
|
1516
|
+
items: {
|
|
1517
|
+
type: 'object',
|
|
1518
|
+
properties: {
|
|
1519
|
+
id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
|
|
1520
|
+
uid: { type: 'string', pattern: '^[a-z0-9]{6}$', description: '서버가 발급하는 불변 이름. get_report 가 준 값은 그대로 되돌려 보낸다.' },
|
|
1521
|
+
name: { type: 'string', description: '무엇을 확인하나 — 물음 꼴(「…가 되는가」).' },
|
|
1522
|
+
to: { ...DEC_WHO, description: '누구에게 묻나·누가 하나.' },
|
|
1523
|
+
due: DEC_DUE,
|
|
1524
|
+
relation: { type: 'string', enum: ['sways', 'after'], description: 'sways(결과가 결정을 바꿀 수 있음) · after(결정 뒤 이행).' },
|
|
1525
|
+
decisions: { type: 'array', minItems: 1, items: { type: 'string' }, description: '닿는 결정의 id.' },
|
|
1526
|
+
ask: { type: 'string', description: '물으실 말 — 요청 자리, 하십시오체. ask·steps 가운데 하나 이상.' },
|
|
1527
|
+
steps: { type: 'array', minItems: 1, items: { type: 'string' }, description: '하실 일 목록 — 요청 자리, 하십시오체.' },
|
|
1528
|
+
why: { ...DEC_SENTENCE, description: '이 확인이 결정에 무엇을 바꾸나.' },
|
|
1529
|
+
},
|
|
1530
|
+
required: ['id', 'name', 'to', 'due', 'relation', 'decisions'],
|
|
1531
|
+
},
|
|
1532
|
+
},
|
|
1533
|
+
facts: {
|
|
1534
|
+
type: 'array',
|
|
1535
|
+
description: '확인한 사실 — 사실 줄 하나에 근거 하나 이상.',
|
|
1536
|
+
items: {
|
|
1537
|
+
type: 'object',
|
|
1538
|
+
properties: {
|
|
1539
|
+
id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
|
|
1540
|
+
text: { type: 'string', description: '사실 한 문장 — 방법 서술이 아니다.' },
|
|
1541
|
+
basis: {
|
|
1542
|
+
type: 'string', enum: ['seen', 'counted', 'tested', 'read', 'computed'],
|
|
1543
|
+
description: '근거 종류 — seen(직접 봄) · counted(운영 기록에서 셈) · tested(시험에서 잼) · read(설정·코드를 읽음) · computed(계산).',
|
|
1544
|
+
},
|
|
1545
|
+
where: {
|
|
1546
|
+
type: 'string', enum: ['production', 'replica', 'simulated'],
|
|
1547
|
+
description: 'basis 가 tested 일 때만 — simulated 면 「운영과 같은 조건 아님」이 붙는다.',
|
|
1548
|
+
},
|
|
1549
|
+
source: {
|
|
1550
|
+
type: 'array', minItems: 1,
|
|
1551
|
+
description: '근거 — 항목마다 키 하나: case(건 uid) · plate(도판 id) · quote(원문 한 줄) · url · ledger(대장 「절.문단」) · fact(계산에 쓴 사실 id). '
|
|
1552
|
+
+ 'seen 은 plate·url·case, read 는 quote, computed 는 fact 가 하나 이상.',
|
|
1553
|
+
items: {
|
|
1554
|
+
type: 'object', minProperties: 1, maxProperties: 1,
|
|
1555
|
+
properties: {
|
|
1556
|
+
case: { type: 'string' }, plate: { type: 'string' }, quote: { type: 'string' },
|
|
1557
|
+
url: { type: 'string' }, ledger: { type: 'string' }, fact: { type: 'string' },
|
|
1558
|
+
},
|
|
1559
|
+
},
|
|
1560
|
+
},
|
|
1561
|
+
bears_on: {
|
|
1562
|
+
type: 'array', minItems: 1,
|
|
1563
|
+
description: '사실이 서는 자리 — 항목마다 decision(+ 그 결정의 option) · risk · unknown 가운데 하나.',
|
|
1564
|
+
items: {
|
|
1565
|
+
type: 'object',
|
|
1566
|
+
properties: { decision: { type: 'string' }, option: { type: 'string' }, risk: { type: 'string' }, unknown: { type: 'string' } },
|
|
1567
|
+
},
|
|
1568
|
+
},
|
|
1569
|
+
},
|
|
1570
|
+
required: ['id', 'text', 'basis', 'source', 'bears_on'],
|
|
1571
|
+
},
|
|
1572
|
+
},
|
|
1573
|
+
risks: {
|
|
1574
|
+
type: 'array',
|
|
1575
|
+
description: '어느 선택지를 고르든 지금 열려 있는 위험.',
|
|
1576
|
+
items: {
|
|
1577
|
+
type: 'object',
|
|
1578
|
+
properties: {
|
|
1579
|
+
id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
|
|
1580
|
+
what: { type: 'string' },
|
|
1581
|
+
size: {
|
|
1582
|
+
type: 'object', description: '크기 — text 와 basis(measured 잼 · estimated 가늠).',
|
|
1583
|
+
properties: { text: { type: 'string' }, basis: { type: 'string', enum: ['measured', 'estimated'] } },
|
|
1584
|
+
required: ['text', 'basis'],
|
|
1585
|
+
},
|
|
1586
|
+
blocked_by: {
|
|
1587
|
+
type: 'object',
|
|
1588
|
+
description: '이 위험을 막는 것 — decision(결정 id) · schedule(일정 글) · none:true + note(막을 결정·일정이 없는 까닭) 가운데 꼭 하나.',
|
|
1589
|
+
properties: { decision: { type: 'string' }, schedule: { type: 'string' }, none: { type: 'boolean' }, note: { type: 'string' } },
|
|
1590
|
+
},
|
|
1591
|
+
},
|
|
1592
|
+
required: ['id', 'what', 'blocked_by'],
|
|
1593
|
+
},
|
|
1594
|
+
},
|
|
1595
|
+
unknowns: {
|
|
1596
|
+
type: 'array',
|
|
1597
|
+
description: '확인하지 못한 것.',
|
|
1598
|
+
items: {
|
|
1599
|
+
type: 'object',
|
|
1600
|
+
properties: {
|
|
1601
|
+
id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
|
|
1602
|
+
what: { type: 'string' },
|
|
1603
|
+
sways: { type: 'array', items: { type: 'string' }, description: '이것이 바꿀 수 있는 결정의 id.' },
|
|
1604
|
+
size: {
|
|
1605
|
+
type: 'object', description: 'risks[].size 와 같은 꼴.',
|
|
1606
|
+
properties: { text: { type: 'string' }, basis: { type: 'string', enum: ['measured', 'estimated'] } },
|
|
1607
|
+
required: ['text', 'basis'],
|
|
1608
|
+
},
|
|
1609
|
+
fill: {
|
|
1610
|
+
description: '채울 수단 — 없으면 {none: true}.',
|
|
1611
|
+
oneOf: [
|
|
1612
|
+
{ type: 'string', minLength: 1 },
|
|
1613
|
+
{ type: 'object', properties: { none: { type: 'boolean', enum: [true] } }, required: ['none'] },
|
|
1614
|
+
],
|
|
1615
|
+
},
|
|
1616
|
+
},
|
|
1617
|
+
required: ['id', 'what', 'fill'],
|
|
1618
|
+
},
|
|
1619
|
+
},
|
|
1620
|
+
names: {
|
|
1621
|
+
type: 'array',
|
|
1622
|
+
description: '이 문서에 나오는 낯선 말과 숫자 — 결정 장마다 처음 나오는 자리에 풀이가 선다. 선택지 이름은 두지 않는다.',
|
|
1623
|
+
items: {
|
|
1624
|
+
type: 'object',
|
|
1625
|
+
properties: { id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' }, name: { type: 'string' }, plain: { type: 'string', description: '쉬운 말 풀이.' } },
|
|
1626
|
+
required: ['id', 'name', 'plain'],
|
|
1627
|
+
},
|
|
1628
|
+
},
|
|
1629
|
+
later: {
|
|
1630
|
+
type: 'array',
|
|
1631
|
+
description: '나중에 정해도 되는 것 — 회신·핀을 받지 않는다.',
|
|
1632
|
+
items: {
|
|
1633
|
+
type: 'object',
|
|
1634
|
+
properties: { question: { type: 'string' }, options: { type: 'array', minItems: 1, items: { type: 'string' } }, revisit: { type: 'string', description: '다시 볼 때.' } },
|
|
1635
|
+
required: ['question', 'options', 'revisit'],
|
|
1636
|
+
},
|
|
1637
|
+
},
|
|
1638
|
+
},
|
|
1639
|
+
required: ['decisions'],
|
|
1640
|
+
};
|
|
1303
1641
|
const REPORT_DATA = {
|
|
1304
1642
|
type: 'object',
|
|
1305
1643
|
description: '보고서 내용. 짝인 스킬의 report.json + cases 를 하나로 합친 형태.',
|
|
1306
1644
|
properties: {
|
|
1307
1645
|
/*
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1311
|
-
*
|
|
1646
|
+
* 서식 칸(2026-10-06 관보 버전2 에 열었다) — 관보 v3(2026-10-07 ⑨)부터 서식은 lim-report 하나이고 모드는 원고 내용
|
|
1647
|
+
* (decision_brief.decisions)이 정한다. 옛 이름 lim-decision 은 그 이름으로 쓴 원고 · 옛 판 도구가 보내도 받히게 enum 에 남긴다 —
|
|
1648
|
+
* 서버가 lim-report 로 맞춘다(REPORT_TEMPLATE_ALIASES). 이름의 정본은 서버 REPORT_TEMPLATES 다.
|
|
1649
|
+
* 팔레트는 여전히 **여기 없다** — 서식이 강제하고 서버가 박는다(report_publish.php 의 REPORT_TEMPLATE_PALETTE).
|
|
1312
1650
|
*/
|
|
1651
|
+
template: {
|
|
1652
|
+
type: 'string', enum: ['lim-report', 'lim-decision'],
|
|
1653
|
+
description: '서식 — lim-report 하나(안 적어도 된다). 결정 블록(decision_brief.decisions)이 있으면 결정 모드, 없으면 보고 모드로 선다. '
|
|
1654
|
+
+ 'lim-decision 은 옛 이름 — 서버가 lim-report 로 맞추고, 그 이름으로 왔는데 결정이 없으면 거절한다.',
|
|
1655
|
+
},
|
|
1656
|
+
decision_brief: DECISION_BRIEF,
|
|
1657
|
+
released_on: {
|
|
1658
|
+
type: 'string',
|
|
1659
|
+
description: '서버가 적는 공개일(YYYY-MM-DD, 결정 모드) — 「받으신 날부터 N일」을 날짜로 푸는 값. 보내도 서버가 공개일로 덮는다.',
|
|
1660
|
+
},
|
|
1313
1661
|
title_tag: { type: 'string', description: '브라우저 탭 제목' },
|
|
1314
1662
|
title: { type: 'string', description: '표지 대제목 **첫 행** (굵게)' },
|
|
1315
1663
|
title_sub: {
|
|
@@ -1337,7 +1685,7 @@ const REPORT_DATA = {
|
|
|
1337
1685
|
cta: {
|
|
1338
1686
|
type: 'array', items: { type: 'string' },
|
|
1339
1687
|
description: '`kind:"asks"` 절 머리의 강조문(문자열 목록). 요청 항목들 **위**에 서서 '
|
|
1340
|
-
+ '「먼저 볼 것」을 한두 줄로 말한다. 없으면 강조문 없이 항목만 늘어선다.',
|
|
1688
|
+
+ '「먼저 볼 것」을 한두 줄로 말한다. 없으면 강조문 없이 항목만 늘어선다. 결정 모드에서는 받지 않는다(거절).',
|
|
1341
1689
|
},
|
|
1342
1690
|
description: { type: 'string' },
|
|
1343
1691
|
doc_no: { type: 'string', description: '문서번호. 표지 머리 왼쪽 박스 (예: "LIM-2026-0824-01")' },
|
|
@@ -1357,7 +1705,7 @@ const REPORT_DATA = {
|
|
|
1357
1705
|
description: '요약면 수치 칸 [{value, unit, label, accent?, href?, go?}] · accent:true 는 '
|
|
1358
1706
|
+ '**독자 몫**인 첫 칸. `href` 를 주면 그 칸이 단추가 되고, `go` 는 그 단추의 '
|
|
1359
1707
|
+ '글자다(생략하면 「해당 절로」). 어디로 가는지를 `go` 에 적어 두면 '
|
|
1360
|
-
+ '독자가 누르기 전에 안다 — 예: "02절로".',
|
|
1708
|
+
+ '독자가 누르기 전에 안다 — 예: "02절로". 결정 모드에서는 받지 않는다(거절).',
|
|
1361
1709
|
},
|
|
1362
1710
|
history: {
|
|
1363
1711
|
type: 'array',
|
|
@@ -1367,7 +1715,7 @@ const REPORT_DATA = {
|
|
|
1367
1715
|
type: 'array',
|
|
1368
1716
|
description: '이 문서 읽는 법 [{q, a: [문장, ...]}] — **독자가 실제로 던질 질문**만. '
|
|
1369
1717
|
+ '「시간이 없다면 어디를 봐야 하는가?」 「어디까지 확인한 것인가?」 처럼. '
|
|
1370
|
-
+ '채우려고 넣으면 안 넣느니만 못하다.',
|
|
1718
|
+
+ '채우려고 넣으면 안 넣느니만 못하다. 결정 모드에서는 받지 않는다(거절).',
|
|
1371
1719
|
},
|
|
1372
1720
|
parts: {
|
|
1373
1721
|
type: 'array',
|
|
@@ -1376,12 +1724,12 @@ const REPORT_DATA = {
|
|
|
1376
1724
|
+ 'tone 은 you(독자 몫)·neutral(기록)·wait(대기)·hold(유보) 중 하나. '
|
|
1377
1725
|
+ 'questions 는 「이 PART 가 답하는 질문」. '
|
|
1378
1726
|
+ '`roman` 은 간지·차례·머리띠에 서는 로마 숫자 — 생략하면 자리 순서대로 '
|
|
1379
|
-
+ 'Ⅰ·Ⅱ·Ⅲ 이 매겨지므로 **보통 적지 않는다**(적으면 순서와 어긋날 수 있다).',
|
|
1727
|
+
+ 'Ⅰ·Ⅱ·Ⅲ 이 매겨지므로 **보통 적지 않는다**(적으면 순서와 어긋날 수 있다). 결정 모드에서는 받지 않는다(거절).',
|
|
1380
1728
|
},
|
|
1381
1729
|
notice: {
|
|
1382
1730
|
type: 'object',
|
|
1383
1731
|
description: '요약면 현황 줄 {state, text} — state 는 critical|info|success. '
|
|
1384
|
-
+ '**상태에 따라 바뀌는 유일한 줄**이다. 떠 있는 알림 배너를 따로 두지 마라.',
|
|
1732
|
+
+ '**상태에 따라 바뀌는 유일한 줄**이다. 떠 있는 알림 배너를 따로 두지 마라. 결정 모드에서는 받지 않는다(거절).',
|
|
1385
1733
|
},
|
|
1386
1734
|
summary_position: {
|
|
1387
1735
|
type: 'string', enum: ['front'],
|
|
@@ -1422,13 +1770,16 @@ const REPORT_DATA = {
|
|
|
1422
1770
|
+ '덮을 수 있는 키: apx · as_of · ask · bar_alt · basis · client · col_grade · col_state · '
|
|
1423
1771
|
+ 'col_title · cta · doc_no · go · guide · hist · hist_date · hist_no · hist_note · marg · '
|
|
1424
1772
|
+ 'nograde · open · pt_ask · refs · remain · remain_unit · source · source_row · sum · toc · urgent.\n'
|
|
1425
|
-
+ '없는 키를 주면 조용히 무시된다 — 오타가 안 걸리니 위 목록에서 골라
|
|
1773
|
+
+ '없는 키를 주면 조용히 무시된다 — 오타가 안 걸리니 위 목록에서 골라 써라.\n'
|
|
1774
|
+
+ '결정 모드의 고정 문구 키(dc_…)는 skills/genetics-report/references/decision-format.md 의 「바꿀 수 있는 글자」 표.',
|
|
1426
1775
|
},
|
|
1427
1776
|
grades: { type: 'object', description: '확인 등급 정의 {이름: [클래스, 설명]}' },
|
|
1428
1777
|
groups: { type: 'array', description: '건 묶음 [{key, no}]' },
|
|
1429
1778
|
sections: {
|
|
1430
1779
|
type: 'array',
|
|
1431
|
-
description: '절 구성 [{no, id, name, kind, group?, chapter?, principles?, logic?}]
|
|
1780
|
+
description: '절 구성 [{no, id, name, kind, group?, chapter?, principles?, logic?}] — 보고 모드는 절 구성이 본체다. '
|
|
1781
|
+
+ '결정 모드는 대장(근거 자료) 절(kind note·cases)만, 없어도 된다.\n'
|
|
1782
|
+
+ 'kind 는 summary|status|cases|asks|note '
|
|
1432
1783
|
+ '중 하나여야 한다 — 그 밖의 값은 렌더러가 절을 통째로 빠뜨리므로 서버가 거절한다. '
|
|
1433
1784
|
+ 'chapter 에 아래 chapters 의 id 를 적으면 그 절 앞에 챕터 오프닝이 펼쳐진다. '
|
|
1434
1785
|
+ 'name 은 목차에 쓰는 **짧은 이름**이고, 본문의 큰 제목은 챕터의 title_lines 가 낸다.\n'
|
|
@@ -1446,33 +1797,49 @@ const REPORT_DATA = {
|
|
|
1446
1797
|
+ 'rows[].no 가 `4.1`·`04-1` 모양이면 그 사례로 링크가 된다. '
|
|
1447
1798
|
+ '그림 번호(`그림 5-1`)는 적지 않는다 — 자리가 매기고 `plates` 와 한 셈을 쓴다.\n'
|
|
1448
1799
|
+ '· `plates`: [assets[].id, …] — 절 본문 도판.\n'
|
|
1449
|
-
|
|
1800
|
+
// ── 장치 목록에서 굽는 글 «tool-devices» — 손으로 고치지 않는다: python3 tools/contract-devices.py --write ──
|
|
1801
|
+
+ '· 시각 장치(정본 private/builder/devices.py — 둘 수 있는 칸 · 한도 · 만드는 법은 스킬 references/visual-first.md §4 표): 구조 → 구성도·도판(개념도) · 흐름 → 흐름도·절차도·데이터 흐름도 · 순서 → 표(사건 경과)·순서도 · 상태 → 상태 변화도 · 변화 → 기울기·두 판 비교·도판(두 컷 비교) · 분포 → 막대 차트·구성 띠 · 시간에 따른 값 → 시계열 · 견주기 → 막대 차트(수일 때)·점 차트·표(글이 섞일 때) · 원문 → 원문 인용·도판 · 화면 → 도판·화면 녹화. '
|
|
1802
|
+
+ '구성도·절차도·순서도·데이터 흐름도·상태 변화도·두 판 비교는 도해 굽기가 켜진 서버에서만 새로 굽는다(포털 private/archify.on — 로컬 render.py 는 켜고 부른다) — 꺼진 서버는 굽혀 둔 그림만 싣고 새 그림은 같은 번호의 대기 상자로 둔다(「서버에서 실패한 그림이 N장」 — 원고를 고칠 일이 아니다). '
|
|
1803
|
+
+ '화면 녹화는 영상 저장 연동(r2)이 켜진 서버에서만 — 꺼진 환경은 발행을 거절한다. '
|
|
1804
|
+
+ '시각 우선(dry_run 이 ⚠ 로 센다 — 서식 문서 §10-1): 산문 400자 넘는 절에 표·흐름도·차트·원문 인용·도해·도판 가운데 하나도 없으면 ⚠, 문단 220자 안, 산문의 참조 번호 3개 이하, 판단 요청 6항 이상이면 한눈표+관문 흐름도, 3,000자 넘는 문서엔 핵심 굵게. '
|
|
1805
|
+
+ '집필 공식(독자 한 명·예시 한 명·한 장 이야기·용어 잠금·그림 먼저)은 스킬 references/visual-first.md.\n'
|
|
1806
|
+
// ── /장치 목록 «tool-devices» ──
|
|
1450
1807
|
+ '· 인라인 표기(본문 문자열 어디서나 — 문단·도입문·여백 주석·표 셀·흐름도 칸·주석·부록 행): `**굵게**` → 핵심 구절, `==강조==` → 놓치면 안 되는 한 구절(노란 밑면), `{{ok|보통}}` → 상태 칩(톤 ok·wait·bad·info·muted 다섯뿐, 글자 40자 안), `[글자](https://…)` → 새 창으로 여는 하이퍼링크, 맨 `https://…` 주소도 그대로 링크가 됨(http(s) 만, 꼬리 문장부호는 링크 밖). 표기가 없으면 한 글자도 안 바뀐다. 핵심 한두 구절에만 — 문단 절반이 굵으면 굵기가 위계를 못 나른다. 출처는 부록 글자에만 두지 말고 건의 `source_url` 에도 실어라(「출처 원문」 행이 된다).\n'
|
|
1451
1808
|
+ '· `tables`: [{caption, cols[], rows[[]], align?, hint?, note?}] — **관보의 자료표.** '
|
|
1452
1809
|
+ '여러 항목을 축으로 견주는 자리다. 불릿 목록에 수를 눌러 담고 있으면 그건 표여야 한다. '
|
|
1453
1810
|
+ 'kind 와 무관하게 쓴다(kind:status 의 `board_title` 대조표와는 다른 것 — 저건 건 전용이다). '
|
|
1454
1811
|
+ 'cols 는 열 제목, rows 는 행마다 cols 와 **같은 길이의 배열**(어긋나면 잘라 맞추고 ⚠ 로 센다). '
|
|
1455
1812
|
+ 'align 은 열마다 `l`·`r`·`c` — 수는 `r` 로 두어야 자릿수가 세로로 맞는다. '
|
|
1813
|
+
+ 'bar{col: 열 이름, max?} 를 주면 그 열 칸 글 앞의 수로 칸 막대를 세운다(끝은 max, 없으면 칸이 모두 %면 100 · 아니면 그 열의 가장 큰 수) — 행끼리 크기를 견주는 열에만. '
|
|
1456
1814
|
+ 'note 는 표 아래 ※ 한정 조건, hint 는 그 아래 안내 한 줄. '
|
|
1457
1815
|
+ '표 번호(`표 5-1`)는 적지 않는다 — 자리가 매긴다. '
|
|
1458
1816
|
+ '⚠ caption 은 **라벨이지 산문이 아니다** — 20자 안쪽으로 두고 설명은 note 로 내려라 '
|
|
1459
1817
|
+ '(길면 캡션 바가 글줄 상한을 넘어 두 줄로 접힌다).\n'
|
|
1460
|
-
+ '· `charts`: [{caption, unit?, rows:[{label, value|a+b, note?}], toggle?{a,b}, note?}] — '
|
|
1461
|
-
+ '
|
|
1462
|
-
+ '
|
|
1463
|
-
+ '곳에서도 문서가 성립한다. value 는 **표기 그대로 적는다**(`"118,716"`) — 콤마는 셈에서 걷어내므로 '
|
|
1464
|
-
+ '같은 문서의 표와 표기가 갈리지 않게 하려면 데이터가 표기를 정해야 한다. '
|
|
1818
|
+
+ '· `charts`: [{caption, unit?, rows:[{label, value|a+b, note?, tone?, partial?}], toggle?{a,b}, form?, ref?, note?}] — '
|
|
1819
|
+
+ '**차트(막대 · 점).** 렌더러가 정지 SVG 로 그린다(스크립트 0 — 열람 CSP 가 script-src 를 안 연다). 값 · 이름 · 설명은 원고 글자 그대로 '
|
|
1820
|
+
+ '그림에 서고 화면 낭독 목록으로도 한 번 실린다. value 는 **표기 그대로 적는다**(`"118,716"`) — 같은 문서의 표와 표기가 갈리지 않게 데이터가 표기를 정한다. '
|
|
1465
1821
|
+ 'rows[].tone(ok·wait·bad·info·muted) 을 주면 그 행의 막대와 값에 뜻 색이 붙는다 — 예: 처리 완료 행은 muted, 위험 행은 bad. '
|
|
1466
|
-
+ 'toggle{a,b} 를 주면 rows 는 value 대신 a·b 를 갖고 두 축을 `:checked` 로 갈아 낀다 '
|
|
1467
|
-
+ '
|
|
1468
|
-
+ '
|
|
1469
|
-
+ '
|
|
1470
|
-
+ '
|
|
1471
|
-
+ '
|
|
1822
|
+
+ 'toggle{a,b} 를 주면 rows 는 value 대신 a·b 를 갖고 두 축을 `:checked` 로 갈아 낀다(두 축이 한 축척 · 체크박스는 스크립트가 아니라 상태다). '
|
|
1823
|
+
+ '그림 번호는 `flow`·`plates` 와 **한 셈**을 쓴다. 데이터가 있을 때만 그려진다 — 없는 문구를 지어내지 말 것.\n'
|
|
1824
|
+
+ '· `charts[].ref`: [{value, label}] — **기준선**(점선). value 는 수(쉼표 표기 글자도 받음), label 은 원고 글자(「3초」「60초 한도」). 막대·시계열 모두. '
|
|
1825
|
+
+ '`charts[].rows[].partial`: 참이면 그 행은 일부만 잰 값이라 막대 속을 비운다(행 이름 글자로 짐작하지 않는다). '
|
|
1826
|
+
+ '`charts[].form`: length(길이 — 0 기준선에서 뻗는 공통 축척 막대) · position(위치 — 같은 축 위의 점) · slope(기울기 — toggle{a,b} 두 축을 잇는 선, 묶음마다 a → b) · '
|
|
1827
|
+
+ 'share(구성 띠 — rows[].value 가 한 덩이의 조각, 100% 띠 하나와 비율) — 없으면 막대는 length · 시계열은 position. '
|
|
1828
|
+
+ '`charts[].rows[].range`: [아래, 위] — 점 차트(position)에서 값 둘레의 범위(옅은 막대). 범위를 쓰면 `range_key`(옅은 막대가 무엇인지 — 「달마다 오르내린 폭(95%)」) · '
|
|
1829
|
+
+ '`value_key`(점이 무엇인지)를 함께 적는다 — 그림 위 한 줄로 서고, 값은 오른쪽 한 열에 세로로 맞는다. 0 이 기준이면 ref [{value: 0, label}] 로 「여기 닿으면 …」을 단다. '
|
|
1830
|
+
+ '꼴이 틀리면 서버가 거절한다.\n'
|
|
1831
|
+
+ '· `charts[].kind:"series"`: {kind:"series", caption, unit?, points:[{x,y}], marks?:[{x,label}], summary?:[{label,value,note?}], form?, ref?, note?} — '
|
|
1832
|
+
+ '**시계열.** 하루·달 단위 값이 언제 계단을 만드는지 정지 SVG 로 보이고(기본은 점과 선 · form length 면 날마다 막대), 달마다 평균·최대를 글자 행으로 남긴다(그림이 안 보여도 문서가 선다). '
|
|
1833
|
+
+ 'x 는 YYYY-MM-DD 가 기본. marks[].x 는 어느 points[].x 와 같은 값이어야 선다(사건 세로선, 아니면 ⚠). '
|
|
1834
|
+
+ '쌓은 막대: points[].parts(조각 수 — 아래부터, y 는 그 합) + parts:[{label, tone?}](조각 이름 — 그림 위 한 줄) · form "length". x 가 YYYY-MM 이면 막대마다 「3월」, 해가 바뀌는 자리에 「2026년」. '
|
|
1835
|
+
+ 'marks[].detail: 사건 이름 줄 1~3(표식 이름 아래 — 「「네이버 로그인 버튼 클릭 오류」 394건」).\n'
|
|
1472
1836
|
+ '· `quotes`: [{text, lang?, source_text, source_url?, verdict?, translation?}] — **원문 인용.** 공식 문서·회신 문구를 원문 그대로 싣는다(표기 안 품, 줄바꿈 유지). '
|
|
1473
1837
|
+ 'source_text·source_url 둘 중 하나는 있어야 한다(없으면 ⚠). verdict 는 도판과 같은 축(직접 확인·조사 보고·미확인). 절 본문 맨 앞에 선다.\n'
|
|
1474
|
-
+ '· `diagrams`: [{id?, caption, ir
|
|
1475
|
-
+ '
|
|
1838
|
+
+ '· `diagrams`: [{id?, caption, ir? | compare?, alt?, source_text?, verdict?, note?, wide?, origin?}] — **도해.** 설계 데이터를 넣으면 서버가 '
|
|
1839
|
+
+ '고정 판 Archify 로 정지 SVG 를 굽는다(관보 글자 기준 15px). `ir` 은 Archify 설계 데이터 하나 — diagram_type architecture·workflow(schema_version 2)·sequence·dataflow·lifecycle, '
|
|
1840
|
+
+ '`compare` 는 {base, head} — 두 판 다 구성도 설계 데이터(두 판 비교: 겹친 그림 + 바뀐 항목 표). 미리 구운 `svg` 칸은 받지 않는다(⚠ · 번호도 안 잡는다). '
|
|
1841
|
+
+ '어느 서버에서 새로 굽는지 · 종류별 한도는 위 시각 장치 줄. 못 구우면 같은 번호의 대기 상자와 ⚠ 사유(고칠 값). 우리가 그린 도해에는 verdict · 출처 줄을 비운다. '
|
|
1842
|
+
+ '그림 번호는 차트·흐름도·도판과 한 셈. 쓰는 법은 스킬 references/diagrams.md.\n'
|
|
1476
1843
|
+ '· `flow.mode:"cause"`: 원인 사슬 — 첫 칸의 「해야 할 것」 강조 면을 걷어 「사실 → 우리 상태 → 결과」가 같은 무게로 선다.\n'
|
|
1477
1844
|
+ '\n'
|
|
1478
1845
|
+ '관보 문법이 절마다 요구하는 칸:\n'
|
|
@@ -1561,7 +1928,7 @@ const REPORT_DATA = {
|
|
|
1561
1928
|
description: '요지 [{lead, body, refs?}] — kind:"summary" 절, summary_position:"front" 이면 앞 요약면에도 선다. '
|
|
1562
1929
|
+ '각 문단은 근거로 삼은 실제 사례 또는 kind:"note", presentation:"prose" 의 문단 번호를 '
|
|
1563
1930
|
+ '최소 하나 밝혀야 한다(예: 본문에 "§3.1" 이라 쓰거나 refs:["3.1"]). '
|
|
1564
|
-
+ '산문이면 3번째 절의 1번째 문단이다. 없는 번호나 근거 표기 누락은 서버가 거절한다.',
|
|
1931
|
+
+ '산문이면 3번째 절의 1번째 문단이다. 없는 번호나 근거 표기 누락은 서버가 거절한다. 결정 모드에서는 받지 않는다(거절).',
|
|
1565
1932
|
},
|
|
1566
1933
|
open_items: {
|
|
1567
1934
|
type: 'array',
|
|
@@ -1569,7 +1936,7 @@ const REPORT_DATA = {
|
|
|
1569
1936
|
+ '상대의 판단이 필요한 것만. `priority: "must"` 인 항목은 「가장 시급한 요청」으로 '
|
|
1570
1937
|
+ '따로 서고 나머지와 괘선으로 갈린다 — **먼저 정해져야 하는 것에만** 붙여라. '
|
|
1571
1938
|
+ '전부에 붙이면 갈림이 사라져 안 붙인 것과 같아진다. '
|
|
1572
|
-
+ '`refs` 는 근거 사례 번호이고 **첫 하나만** 「근거」 단추가 된다.',
|
|
1939
|
+
+ '`refs` 는 근거 사례 번호이고 **첫 하나만** 「근거」 단추가 된다. 결정 모드에서는 받지 않는다(거절).',
|
|
1573
1940
|
},
|
|
1574
1941
|
footer: { type: 'array' },
|
|
1575
1942
|
assets: {
|
|
@@ -1654,11 +2021,11 @@ const REPORT_DATA = {
|
|
|
1654
2021
|
},
|
|
1655
2022
|
alt: { type: 'string', description: '설명적 대체 텍스트 — 접근성 계약. 그림과 함께 다닌다' },
|
|
1656
2023
|
origin: { type: 'string', description: '생성 job 식별자·.mmd 원본 경로·제공자 이름' },
|
|
1657
|
-
caption: { type: 'string', description: '도판 캡션. 그림 번호는 자리가 매기므로 적지
|
|
1658
|
-
source_text: { type: 'string', description: '캡션 아래 「출처」 줄 — 이 그림이 어디서
|
|
2024
|
+
caption: { type: 'string', description: '도판 캡션. 그림 번호는 자리가 매기므로 적지 않는다. 캡처·영상은 끝에 증거 범위 한 구절(찍은 환경·날짜·옮겨 적은 부분, 예 「… · 운영 관리자 웹 · 8. 23. 촬영」)' },
|
|
2025
|
+
source_text: { type: 'string', description: '캡션 아래 「출처」 줄 — 이 그림이 어디서 왔는지. 캡처는 「화면 이름 · 날짜」를 위치로 적는다(예 「주문 목록 화면 · 8. 23.」)' },
|
|
1659
2026
|
verdict: {
|
|
1660
2027
|
type: 'string', enum: ['직접 확인', '조사 보고', '미확인'],
|
|
1661
|
-
description: '이 근거를 **얼마나 믿어도 되는지**. 캡션 끝에 칩으로
|
|
2028
|
+
description: '이 근거를 **얼마나 믿어도 되는지**. 캡션 끝에 칩으로 선다. 우리가 그린 도해·도판에는 붙이지 않는다.\n'
|
|
1662
2029
|
+ '· 직접 확인 — 원문을 직접 열어 그 수치를 확인함\n'
|
|
1663
2030
|
+ '· 조사 보고 — 2차 출처를 경유해 확인함. 원문 대조는 안 함\n'
|
|
1664
2031
|
+ '· 미확인 — 널리 쓰이나 1차 출처를 특정하지 못함\n'
|
|
@@ -1672,7 +2039,7 @@ const REPORT_DATA = {
|
|
|
1672
2039
|
description: '**전폭 도판.** 두 단 그리드 밖, 절 끝에 문서 폭(약 1030px)으로 선다. '
|
|
1673
2040
|
+ '나란히 둔 화면 둘처럼 본문 단(760px)에서 절반으로 줄면 글자가 안 읽히는 그림에 켠다. '
|
|
1674
2041
|
+ '켜면 그 절의 그림 번호는 마지막이 된다. 켜든 말든 모든 도판은 눌러서 크게 보기(원본 '
|
|
1675
|
-
+ '긴 변 1400px)가 열린다. 집필 때 figure.mjs --display 로 표시 글자 크기를 먼저
|
|
2042
|
+
+ '긴 변 1400px)가 열린다. 집필 때 figure.mjs --display 로 표시 글자 크기를 먼저 잰다(하한 15px — 스킬 references/readability.md R1).',
|
|
1676
2043
|
},
|
|
1677
2044
|
brief: {
|
|
1678
2045
|
type: 'string',
|
|
@@ -1719,8 +2086,8 @@ const REPORT_DATA = {
|
|
|
1719
2086
|
symptom: { type: 'string' },
|
|
1720
2087
|
cause: { type: 'string' },
|
|
1721
2088
|
fix: { type: 'string' },
|
|
1722
|
-
how_to_check: { type: 'string', description: '
|
|
1723
|
-
remaining: { type: 'string', description: '아직 닫히지 않은 것 — 미확인·미결인 사실과 운영에 아직 안 나간 우리 작업. 독자에게 부탁할 것은 open_items 로(선택)' },
|
|
2089
|
+
how_to_check: { type: 'string', description: '독자가 그 결과를 **볼 자리** + **우리가 그것을 본 때·환경**. 볼 자리는 이 문서의 그림·영상·원문 인용(`{{그림:id}}`·quotes) 또는 독자 자기 화면(어디를 열면) — 자기 화면만 볼 자리로 쓴 확인 방법도 되지만 렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」은 남고 그 수가 공개·교체 승인 물음에 실린다(「…에서 봄」 꼬리는 증거로 치지 않는다). 우리가 안 본 결과는 여기 쓰지 않고 remaining 에만 둔다 — 동사는 우리가 본 수준까지(예 「주문서를 작성해 저장하면 새로고침 없이 그 주문이 목록 첫 줄에 나타남({{그림:order-saved}}), 8. 23. 운영 관리자 웹에서 봄」). 주어는 화면·값·우리(독자에게 시키는 문장 아님)' },
|
|
2090
|
+
remaining: { type: 'string', description: '아직 닫히지 않은 것 — 미확인·미결인 사실과 운영에 아직 안 나간 우리 작업. 우리가 안 본 결과는 여기에만 적는다. 독자에게 부탁할 것은 open_items 로(선택)' },
|
|
1724
2091
|
source_url: { type: 'string', description: 'http(s) 제보 원본 링크(선택)' },
|
|
1725
2092
|
quotes: {
|
|
1726
2093
|
type: 'array',
|
|
@@ -1755,7 +2122,9 @@ const REPORT_DATA = {
|
|
|
1755
2122
|
},
|
|
1756
2123
|
},
|
|
1757
2124
|
// 문서가 서려면 있어야 하는 것만. 나머지는 비어 있어도 발행된다.
|
|
1758
|
-
|
|
2125
|
+
// `sections` 는 필수에서 뺐다(2026-10-06) — 결정 모드는 대장 절이 없어도 선다. 서버는 없으면 빈 목록으로 다룬다.
|
|
2126
|
+
// 모드별 규칙(결정 블록의 뼈대 · 결정 모드가 안 받는 칸)은 스키마 if/then 으로 쓰지 않고 서버가 거절한다(K1~K4 · V-*).
|
|
2127
|
+
required: ['title_tag'],
|
|
1759
2128
|
};
|
|
1760
2129
|
|
|
1761
2130
|
/* ── 세션 이식 ───────────────────────────────────────────────────────
|
|
@@ -3975,7 +4344,8 @@ async function resolveFetch(cred, body) {
|
|
|
3975
4344
|
try {
|
|
3976
4345
|
const res = await fetch(`${BASE}/api/folders/resolve`, {
|
|
3977
4346
|
method: 'POST',
|
|
3978
|
-
|
|
4347
|
+
// 판 머리 — api() 와 같은 규율(모든 요청 · 하위 업무 번호 3단계 잣대 ②).
|
|
4348
|
+
headers: { 'X-Genetics-Client': `genetics-mcp/${VERSION}`, Authorization: `Bearer ${cred.token}`, 'Content-Type': 'application/json' },
|
|
3979
4349
|
body: JSON.stringify(body),
|
|
3980
4350
|
signal: ac.signal,
|
|
3981
4351
|
});
|
|
@@ -4295,6 +4665,7 @@ async function beatOnce(recs, { closing = false } = {}) {
|
|
|
4295
4665
|
const res = await fetch(`${BASE}/api/sessions/beat`, {
|
|
4296
4666
|
method: 'POST',
|
|
4297
4667
|
headers: {
|
|
4668
|
+
'X-Genetics-Client': `genetics-mcp/${VERSION}`, // 판 머리 — api() 와 같은 규율(모든 요청 · 하위 업무 번호 3단계 잣대 ②)
|
|
4298
4669
|
Authorization: `Bearer ${cred.token}`,
|
|
4299
4670
|
'Content-Type': 'application/x-lim-beat',
|
|
4300
4671
|
/*
|
|
@@ -5750,7 +6121,8 @@ function taskReadResult(name, args, body) {
|
|
|
5750
6121
|
const tasks = rows.map(row => {
|
|
5751
6122
|
if (!row || typeof row.uid !== 'string' || !/^[a-z0-9]{8}$/.test(row.uid)
|
|
5752
6123
|
|| ['title', 'state', 'state_word', 'done_when'].some(key => typeof row[key] !== 'string')) throw new Error('할 일의 내용을 확인하지 못했습니다.');
|
|
5753
|
-
|
|
6124
|
+
// 하위 업무 번호 — 사람이 부르는 이름(승인 설계 4-1). 할 일이 번호를 들 때만 서버가 싣는다(0단계 소속처는 칸이 없다 — 결과 그대로).
|
|
6125
|
+
return Object.assign(pick(row, ['uid', 'title', 'state', 'state_word', 'done_when']), taskCodeOf(row) ? { origin_code: row.origin_code } : {});
|
|
5754
6126
|
});
|
|
5755
6127
|
result = { tenant: args.tenant, tasks, total: body.open, page: pick(page, ['limit', 'offset', 'returned', 'truncated', 'next_offset']) };
|
|
5756
6128
|
}
|
|
@@ -5758,6 +6130,13 @@ function taskReadResult(name, args, body) {
|
|
|
5758
6130
|
return result;
|
|
5759
6131
|
}
|
|
5760
6132
|
|
|
6133
|
+
/** 할 일의 하위 업무 번호(서버 응답의 origin_code — 서버가 정한 정본 모양일 때만). 없거나 정본이 아니면 '' — 지어내지 않는다. 정본은 서버 work_item_code_ok 와
|
|
6134
|
+
* 같은 자다(일련은 두 자리 채움 · 그 밖 앞 0 없음 · 자식은 1 부터 앞 0 없음 — W-004 · W-01#04 는 아니다): 원격(mcp_task_results.php)과 stdio 가 같은 답. */
|
|
6135
|
+
function taskCodeOf(task) {
|
|
6136
|
+
const code = task && typeof task.origin_code === 'string' ? task.origin_code : '';
|
|
6137
|
+
return /^[A-Z]{1,4}-(?:0[0-9]|[1-9][0-9]{1,3})(?:#[1-9][0-9]{0,2})?$/.test(code) ? code : '';
|
|
6138
|
+
}
|
|
6139
|
+
|
|
5761
6140
|
/**
|
|
5762
6141
|
* 할 일에 **완료 보고**를 올리는 문(M1 · 2026-09-26) — 사람이 화면에서 쓰는 것과 **같은 서버 함수**(`work_task_do_report`)다.
|
|
5763
6142
|
*
|
|
@@ -5818,7 +6197,8 @@ function taskWriteResult(uid, r) {
|
|
|
5818
6197
|
const counts = t.evidence_counts || {};
|
|
5819
6198
|
const n = Number(counts.total) || (Array.isArray(c.evidence) ? c.evidence.length : 0);
|
|
5820
6199
|
const v = Number(counts.usable) || 0;
|
|
5821
|
-
const
|
|
6200
|
+
const code = taskCodeOf(t); // 하위 업무 번호가 있으면 사람이 부르는 이름으로 앞에(승인 설계 4-1) — 없으면 예전 그대로
|
|
6201
|
+
const head = `«${code ? code + ' · ' + uid : uid}» — ${c.kind_word || '완료 보고'} #${c.id ?? '?'} 올림 · 근거 ${n}(검증 ${v})`;
|
|
5822
6202
|
const state = t.state_word ? ` · 상태: ${t.state_word}` : '';
|
|
5823
6203
|
return head + state + '.' + (r?.says ? ' ' + r.says : '');
|
|
5824
6204
|
}
|
|
@@ -5950,7 +6330,9 @@ const TOOLS = [
|
|
|
5950
6330
|
'메모에 **답글로** 회신한다. 이견이 있거나 물을 것이 있으면 대화창이 아니라 여기에 쓴다 — '
|
|
5951
6331
|
+ '저자는 자기가 메모를 단 그 자리에서 답을 본다.\n'
|
|
5952
6332
|
+ '고칠 수 없는 이유, 다르게 본 근거, 되묻는 것이 여기 온다. '
|
|
5953
|
-
+ '
|
|
6333
|
+
+ '단 **접근·권한 요청**(계정·기기·창·메일함·자동 조작 허락)은 답글이 아니라 대화창에서 사용자에게 청한다 — '
|
|
6334
|
+
+ '증빙(화면·영상·근거)을 요구한 메모를 못 찍었으면 「못 함」 답글 전에 그 접근부터 청한다.\n'
|
|
6335
|
+
+ '고쳤으면 답글이 아니라 resolve_note 다 — 증빙을 요구한 메모는 실물(캡처·영상·원문)이 실린 판에서만 닫는다(도해·설명을 더한 것으로 닫지 않는다).\n'
|
|
5954
6336
|
+ '★ **검토 회신에는 이 도구로 답하지 않는다** — 그쪽은 고객에게 보이는 글이라 사람이 포털에서 쓴다. 할 말은 작성자에게 대화로 전한다.',
|
|
5955
6337
|
inputSchema: {
|
|
5956
6338
|
type: 'object',
|
|
@@ -5969,6 +6351,7 @@ const TOOLS = [
|
|
|
5969
6351
|
+ '「무엇에 반영됐는지」를 나중에도 안다.\n'
|
|
5970
6352
|
+ '★ 고치기 **전에** 부르지 말 것 — 이 표시가 곧 「이 메모는 끝났다」이고, '
|
|
5971
6353
|
+ '표시하는 순간 미반영 목록에서 빠져 다시 눈에 안 띈다.\n'
|
|
6354
|
+
+ '증빙(화면·영상·근거)을 요구한 메모는 실물이 실린 판의 rev 로만 닫는다 — 도해·설명을 더한 판으로 닫지 않는다.\n'
|
|
5972
6355
|
+ '검토 회신은 `note` 대신 `reply` 로 가리킨다 — 표시해도 회신은 고객에게 그대로 보이고, 그 회신이 집은 문장이 바뀐 것을 update_report 가 더는 경고하지 않는다. '
|
|
5973
6356
|
+ '문서에 반영할 것이 없는 회신(인사·이미 답한 물음)도 읽었으면 표시한다.',
|
|
5974
6357
|
inputSchema: {
|
|
@@ -5990,7 +6373,8 @@ const TOOLS = [
|
|
|
5990
6373
|
'HTML 을 만들지 말 것 — 내용만 주면 서버가 렌더한다.\n' +
|
|
5991
6374
|
'**기본은 초안이다** — 만들어도 고객에게는 보이지 않는다. 눈으로 확인한 뒤 ' +
|
|
5992
6375
|
'release_report 로 열어 준다. 바로 열려면 publish: true 를 명시할 것.\n' +
|
|
5993
|
-
'
|
|
6376
|
+
'**공개 승인**(publish: true · release_report)을 사용자에게 물을 때는 그 물음과 답에 이 판에서 건너뛴 단계(0단계 사용자 확인·독자 검토·열람 확인 등)와 증거 없는 관찰 주장 수(렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」 — 포털 dry_run 에 이 경고가 아직 없으면 스킬의 로컬 render.py 로 잰 곳 수)를 싣는다 — 「간단히」는 분량 지시이지 단계 면제가 아니다.\n' +
|
|
6377
|
+
'**문체는 주어와 자리가 정한다** — 요청 자리 밖에는 우리·사물·화면이 주어인 문장만 개조식(~함/~했음/~임)으로 쓴다. 독자가 할 일은 요청 자리(open_items·cta·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지 하십시오체로 맺는다(「회신 부탁드립니다」. 제목은 검수·보고면 「~ 요청」+기한 괄호, 고객이 정할 것은 data.decision_brief.decisions 에 — 서식은 lim-report 하나이고 결정 블록이 있으면 결정 모드로 선다(lim-decision 은 옛 이름)). 그 밖의 칸에는 사실만 적는다(「기준 수치는 미정」). dry_run 은 일부만 센다.\n' +
|
|
5994
6378
|
'쓰기 전에 결과만 보고 싶으면 dry_run: true — 렌더까지 돌려 경고를 주고 아무것도 저장하지 않는다.',
|
|
5995
6379
|
inputSchema: {
|
|
5996
6380
|
type: 'object',
|
|
@@ -6016,7 +6400,8 @@ const TOOLS = [
|
|
|
6016
6400
|
description:
|
|
6017
6401
|
'초안을 고객에게 연다. 이 순간부터 고객 목록과 열람에 뜬다. ' +
|
|
6018
6402
|
'이미 열려 있으면 아무 일도 하지 않고 그렇다고 답한다(다시 불러도 안전하다).\n' +
|
|
6019
|
-
'사람 자격으로 공개해야 보고서의 열린 항목이 일감으로
|
|
6403
|
+
'사람 자격으로 공개해야 보고서의 열린 항목이 일감으로 선다.\n' +
|
|
6404
|
+
'**공개 승인**을 사용자에게 물을 때는 그 물음과 답에 이 판에서 건너뛴 단계(0단계 사용자 확인·독자 검토·열람 확인 등)와 증거 없는 관찰 주장 수(렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」 — 포털 dry_run 에 이 경고가 아직 없으면 스킬의 로컬 render.py 로 잰 곳 수)를 싣는다 — 「간단히」는 분량 지시이지 단계 면제가 아니다.',
|
|
6020
6405
|
inputSchema: {
|
|
6021
6406
|
type: 'object',
|
|
6022
6407
|
properties: {
|
|
@@ -6067,7 +6452,8 @@ const TOOLS = [
|
|
|
6067
6452
|
'한 장만 바꾸려면 그 id 에 새 `source`/`file` 을 주고 나머지는 get_report 가 준 표식 그대로 두면 된다. ' +
|
|
6068
6453
|
'**받은 판 잠금**(2026-09-30): get_report 머리줄의 body_rev 를 `body_rev` 로 실으면, 받은 뒤 본문 판이 바뀌었을 때 서버가 409 로 멈추고 아무것도 안 쓴다. ' +
|
|
6069
6454
|
'dry_run 에도 같이 실으면 같은 409 를 덮기 전에 받는다. 서버가 이 잠금을 모르는 옛 판이면 출력이 「판을 견주지 않았다」고 알린다.\n' +
|
|
6070
|
-
'
|
|
6455
|
+
'**교체 승인**을 사용자에게 물을 때는 그 물음과 답에 이 판에서 건너뛴 단계(0단계 사용자 확인·독자 검토·열람 확인 등)와 증거 없는 관찰 주장 수(렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」 — 포털 dry_run 에 이 경고가 아직 없으면 스킬의 로컬 render.py 로 잰 곳 수)를 싣는다 — 「간단히」는 분량 지시이지 단계 면제가 아니다.\n' +
|
|
6456
|
+
'**문체는 주어와 자리가 정한다** — 요청 자리 밖에는 우리·사물·화면이 주어인 문장만 개조식(~함/~했음/~임)으로 쓴다. 독자가 할 일은 요청 자리(open_items·cta·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지 하십시오체로 맺는다(「회신 부탁드립니다」. 제목은 검수·보고면 「~ 요청」+기한 괄호, 고객이 정할 것은 data.decision_brief.decisions 에 — 서식은 lim-report 하나이고 결정 블록이 있으면 결정 모드로 선다(lim-decision 은 옛 이름)). 그 밖의 칸에는 사실만 적는다(「기준 수치는 미정」). dry_run 은 일부만 센다.\n',
|
|
6071
6457
|
inputSchema: {
|
|
6072
6458
|
type: 'object',
|
|
6073
6459
|
properties: {
|
|
@@ -6156,12 +6542,20 @@ const TOOLS = [
|
|
|
6156
6542
|
+ '무엇을 적는가: **왜**가 이 장의 값이다(그때의 근거·수치·관찰). 그리고 **무슨 조건이면 다시 여나** — '
|
|
6157
6543
|
+ '이것을 비우면 그 결정은 「아직 열린 것」으로 읽힌다(닫혔는지 아무도 모르므로). '
|
|
6158
6544
|
+ '끝난 결정이면 "조건 없음(종결)"이라고 적을 것.\n'
|
|
6159
|
-
+ '모르는 칸은 **비운다** — 지어내면 「적히지 않음」보다
|
|
6545
|
+
+ '모르는 칸은 **비운다** — 지어내면 「적히지 않음」보다 나쁘다.\n'
|
|
6546
|
+
+ '★ **하위 업무 번호를 쓰는 소속처**(works 가 항목마다 W-04 같은 번호를 보여 주는 곳)에서는 번호를 서버가 매긴다 — '
|
|
6547
|
+
+ '그곳에서는 제목에 [W-02] · W-02 같은 번호를 쓰지 말 것(응답이 번호를 알려 준다). '
|
|
6548
|
+
+ '있는 하위 업무를 고쳐 적으면 item 에 그 번호, 하위로 나누면 parent 에 부모 번호를 준다. '
|
|
6549
|
+
+ '아직 번호를 안 쓰는 소속처는 item · parent 를 422 로 돌려준다 — 그곳의 장은 get_case 로 읽은 revision 을 expected_revision 으로 대 고쳐 적는다.',
|
|
6160
6550
|
inputSchema: {
|
|
6161
6551
|
type: 'object',
|
|
6162
6552
|
properties: {
|
|
6163
6553
|
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
6164
|
-
title: { type: 'string', description: '한 줄 제목. 다른 결정은 제목을 달리한다. 기존 결정은 expected_revision을 전달해야 수정된다.' },
|
|
6554
|
+
title: { type: 'string', description: '한 줄 제목. 다른 결정은 제목을 달리한다. 기존 결정은 expected_revision을 전달해야 수정된다. 하위 업무 번호를 쓰는 소속처에서는 번호를 제목에 적지 않는다(서버가 매긴다).' },
|
|
6555
|
+
item: { type: 'string', description: '고쳐 적을 **하위 업무 번호**(예 W-04 — works 목록의 번호 그대로). 그 장에 새 판으로 선다(제목이 바뀌어도 번호 · 경로 그대로). 번호를 쓰는 소속처만 받는다(아직 안 쓰는 곳은 422).' },
|
|
6556
|
+
parent: { type: 'string', description: '하위로 나누기 — 부모 하위 업무 번호(예 R-01). 새 번호는 그 아래 자식(R-01#5)으로 선다. 깊이는 한 겹. 번호를 쓰는 소속처만 받는다(아직 안 쓰는 곳은 422).' },
|
|
6557
|
+
branch: { type: 'string', description: '갈래를 가진 업무(hnl-wrm 등)의 갈래 머리글자(W · R · M). 비우면 tags 의 갈래 태그(예 「대기 개선(W)」)로 고른다.' },
|
|
6558
|
+
prefix: { type: 'string', description: '새 업무를 여는 결정이면 그 업무의 머리글자(대문자 1~4). 비우면 서버가 제안한 것으로 선다 — 한 번 서면 못 바꾼다.' },
|
|
6165
6559
|
expected_revision: { type: 'string', pattern: '^[a-f0-9]{64}$', description: '기존 결정 수정 시 get_case에서 읽은 revision. 신규 생성은 생략. 충돌하면 원문을 다시 읽고 변경을 재검토한다.' },
|
|
6166
6560
|
commit_id: { type: 'string', pattern: '^[a-zA-Z0-9][a-zA-Z0-9-]{15,79}$', description: '이 저장 작업의 고유 UUID. 첫 호출 전에 생성하고 응답 유실·재시도에는 같은 값과 같은 본문을 유지한다. 별도 수정은 새 값을 쓴다.' },
|
|
6167
6561
|
what: { type: 'string', description: '무엇을 정했나 — 한 문장.' },
|
|
@@ -6227,7 +6621,7 @@ const TOOLS = [
|
|
|
6227
6621
|
type: 'object',
|
|
6228
6622
|
properties: {
|
|
6229
6623
|
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
6230
|
-
path: { type: 'string', description: 'search_cases·remember_decision 이 준 path 그대로(decisions/….md). 제목을 줘도 저장과 같은 자로 찾는다.' },
|
|
6624
|
+
path: { type: 'string', description: 'search_cases·remember_decision 이 준 path 그대로(decisions/….md). 제목을 줘도 저장과 같은 자로 찾는다. 번호를 쓰는 소속처는 하위 업무 번호(W-04)도 받는다.' },
|
|
6231
6625
|
expected_revision: { type: 'string', pattern: '^[a-f0-9]{64}$', description: 'get_case로 읽은 삭제 대상의 revision.' },
|
|
6232
6626
|
},
|
|
6233
6627
|
required: ['tenant', 'path', 'expected_revision'],
|
|
@@ -6301,7 +6695,7 @@ const TOOLS = [
|
|
|
6301
6695
|
properties: {
|
|
6302
6696
|
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
6303
6697
|
claim: { type: 'string', description: '이 일(slug)에 붙겠다고 선언한다 — 잠정이며, 다른 일을 선언하면 앞 선언은 대체된다. 비우면 목록만 읽는다.' },
|
|
6304
|
-
item: { type: 'string', description: 'claim 과 함께 쓰면 착수 선언(잠정). work 와 함께 쓰면 완료 확인/표시/해제 대상 — 이 일 안의 결정 항목(item_key, 예: decisions/foo.md).' },
|
|
6698
|
+
item: { type: 'string', description: 'claim 과 함께 쓰면 착수 선언(잠정). work 와 함께 쓰면 완료 확인/표시/해제 대상 — 이 일 안의 결정 항목(item_key, 예: decisions/foo.md). 번호를 쓰는 소속처는 하위 업무 번호(예 W-04 — 목록의 번호 그대로)도 받는다 — 그 일의 번호가 아니면 서버가 거절한다.' },
|
|
6305
6699
|
work: { type: 'string', description: '이 일(slug). **혼자 주면 그 일의 항목 목록**(번호·제목·계획 기간·상태·item_key)을 읽는다(부작용 없음). item 과 함께 주면 그 항목의 완료 확인·표시·해제 — claim 과 별개(착수 선언 없이도 완료를 다룰 수 있다).' },
|
|
6306
6700
|
done_when: { type: 'string', description: '이 항목의 완료 조건 — 무엇이 되면 끝인가. done_evidence 와 반드시 함께 채운다(work+item과 함께).' },
|
|
6307
6701
|
done_evidence: { type: 'string', description: 'done_when 과 함께 쓴다 — 그 조건이 실제로 충족됐다는 구체적 관측(파일·시험 결과·커밋 등). 비우면 완료로 못 간다 — 지어내지 말 것, 스스로 확인한 것만.' },
|
|
@@ -6505,12 +6899,18 @@ const TOOLS = [
|
|
|
6505
6899
|
+ '다른 기계·두뇌에 닿는다 — 서버를 지나기 때문이다.\n'
|
|
6506
6900
|
+ '`SendMessage` 는 **이 기계의 Claude Code 끼리만** 가는 지름길이다. `ListAgents` 에 없다고 '
|
|
6507
6901
|
+ '해서 세션이 없는 것이 아니다 — 발견은 `gcell agents`, 주소는 여기 `to` 다.\n'
|
|
6902
|
+
+ '★ 받는 쪽 주소는 **세션 uuid(36자)가 정본**이다 — `gcell agents` 가 주소 칸에 내는 그 값을 쓴다. '
|
|
6903
|
+
+ '세션 이름(예: report-portal-37)은 프로세스가 다시 뜰 때마다 새로 뽑히는 **그 순간의 별칭**이라, 적어 둔 이름은 '
|
|
6904
|
+
+ '죽거나 같은 이름을 새로 받은 다른 세션에게 간다. 이름으로 부쳤으면 결과의 「받는 세션」 uuid 로 이어 간다.\n'
|
|
6905
|
+
+ '한 대화를 창 둘 이상이 함께 띄우면(`gcell agents` 의 「창 N곳」) 그 uuid 앞 통은 어느 창이 받을지 정해지지 않는다 — '
|
|
6906
|
+
+ '한 창에 닿으려면 `SendMessage` 에 그 창의 지금 이름을 쓴다.\n'
|
|
6907
|
+
+ '이 세션에 이름이 없어도 보낸다 — 그때 받는 쪽에는 이 세션 uuid 앞 8자가 발신자로 찍힌다.\n'
|
|
6508
6908
|
+ '무엇을 해 달라고 **부탁해도 된다** — 이 창구는 받는 쪽 눈앞에 「누가 보냈는지」를 서버가 '
|
|
6509
6909
|
+ '반드시 붙여 주므로, 세계 장부와 달리 지시형이 막히지 않는다.\n'
|
|
6510
|
-
+ '받는 것은 부를 필요가 없다 — 온 것은 다른 도구를 부를 때 응답 꼬리에 저절로
|
|
6910
|
+
+ '받는 것은 부를 필요가 없다 — 온 것은 다른 도구를 부를 때 응답 꼬리에 저절로 실린다(답할 주소 uuid 가 함께 온다).\n'
|
|
6511
6911
|
+ '**사람에게는 이메일로** 보낸다(그 소속처 담당만) — 포털 벨이 울린다.\n'
|
|
6512
|
-
+ '⚠ 상대
|
|
6513
|
-
+ '
|
|
6912
|
+
+ '⚠ 상대 주소는 **지어내지 말 것.** 세션 uuid, 방금 확인한 세션 이름, 담당의 이메일, 또는 `brain` 중 하나다. '
|
|
6913
|
+
+ '못 찾으면 사용자에게 묻는다.\n'
|
|
6514
6914
|
+ '⚠ 한 실(thread)에는 상한이 있다 — **왕복**(한 쪽이 보내고 상대가 답하면 1왕복)은 사람이 낀 실 5회·기계끼리 3회, '
|
|
6515
6915
|
+ '답 없이 같은 쪽이 연달아 부치는 것은 3통. 넘으면 409 로 거절된다. '
|
|
6516
6916
|
+ '**사람에게 저절로 알리는 기능은 아직 없다** — 사람의 판단이 필요하면 이 세션의 사용자에게 말하거나 '
|
|
@@ -6519,7 +6919,7 @@ const TOOLS = [
|
|
|
6519
6919
|
type: 'object',
|
|
6520
6920
|
properties: {
|
|
6521
6921
|
tenant: { type: 'string', description: '소속처 slug(이름도 받는다). 모르면 list_my_tenants 로 먼저 확인한다' },
|
|
6522
|
-
to: { type: 'string', description: '받는 쪽 —
|
|
6922
|
+
to: { type: 'string', description: '받는 쪽 — **세션 uuid(36자 · 정본 — `gcell agents` 의 주소 칸)** · 방금 확인한 세션 이름(그 순간의 별칭 — 바뀌면 남에게 간다) · 담당의 이메일(사람에게) · 또는 brain' },
|
|
6523
6923
|
body: { type: 'string', description: '할 말' },
|
|
6524
6924
|
subject: { type: 'string', description: '한 줄 제목(선택)' },
|
|
6525
6925
|
artifact: { type: 'string', description: '무엇에 대한 이야기인가 — 파일·문서 경로(선택)' },
|
|
@@ -6598,9 +6998,26 @@ function pinsText(r) {
|
|
|
6598
6998
|
}
|
|
6599
6999
|
function warnText(r) {
|
|
6600
7000
|
const w = [...(r?.warnings ?? []), ...ASSET_HINTS];
|
|
6601
|
-
|
|
6602
|
-
|
|
6603
|
-
|
|
7001
|
+
if (!w.length) return '';
|
|
7002
|
+
/*
|
|
7003
|
+
* 결정 모드(원고에 결정 블록 — 관보 버전2 → v3 ⑨)는 **개수를 안 찍는다**(설계안 8.3) — 「경고 N건」 합계는 고친 횟수를 목표로
|
|
7004
|
+
* 돌게 하는 손잡이였다(lim 위키 사례 #212). 렌더러가 결정에 속한 알림을 「결정 「X」 — 」 로 시작하게 내므로(계약 3.4),
|
|
7005
|
+
* 그 줄은 결정 이름 아래로 모아 들여 찍는다. 처음 나온 차례를 지키고, 결정에 안 속한 줄은 제자리에 그대로 둔다.
|
|
7006
|
+
* 모드는 응답의 `mode` 로 가른다(관보 v3 서버가 dry_run·발행·교체 응답에 싣는다 — 서식은 하나라 template 은 늘 lim-report).
|
|
7007
|
+
* 옛 서버(관보 버전2)는 mode 없이 template:'lim-decision' 을 보낸다 — 그것도 결정 모드로 읽는다. 없거나 report 면 지금 그대로.
|
|
7008
|
+
*/
|
|
7009
|
+
if (r?.mode === 'decision' || r?.template === 'lim-decision') {
|
|
7010
|
+
const out = [], groups = new Map();
|
|
7011
|
+
for (const line of w) {
|
|
7012
|
+
const m = /^결정 「([^」]*)」 — ([\s\S]*)$/.exec(String(line));
|
|
7013
|
+
if (!m) { out.push(`· ${line}`); continue; }
|
|
7014
|
+
if (!groups.has(m[1])) { const g = []; groups.set(m[1], g); out.push([m[1], g]); }
|
|
7015
|
+
groups.get(m[1]).push(m[2]);
|
|
7016
|
+
}
|
|
7017
|
+
const text = out.map((x) => (typeof x === 'string' ? x : `결정 「${x[0]}」\n${x[1].map((t) => ` · ${t}`).join('\n')}`));
|
|
7018
|
+
return `\n\n알림 — 결정 장 이름 아래 자리 글:\n${text.join('\n')}`;
|
|
7019
|
+
}
|
|
7020
|
+
return `\n\n경고 ${w.length}건:\n· ${w.join('\n· ')}`;
|
|
6604
7021
|
}
|
|
6605
7022
|
|
|
6606
7023
|
/* 발행물의 **구조**를 사람이 읽는 줄로 옮긴다.
|
|
@@ -7177,19 +7594,30 @@ const mailQueue = []; // 아직 안 말한 통(서버가 렌더한 글
|
|
|
7177
7594
|
const mailBack = []; // 내가 부쳤다 만료된 것
|
|
7178
7595
|
const mailBackSeen = new Set();
|
|
7179
7596
|
|
|
7180
|
-
|
|
7181
|
-
* 이 세션의
|
|
7182
|
-
*
|
|
7183
|
-
*
|
|
7184
|
-
*
|
|
7185
|
-
*
|
|
7186
|
-
*
|
|
7187
|
-
*
|
|
7188
|
-
*
|
|
7189
|
-
*
|
|
7190
|
-
*
|
|
7191
|
-
*
|
|
7192
|
-
*
|
|
7597
|
+
/*
|
|
7598
|
+
* ══ 이 세션의 주소 — **세션 uuid 가 정본, 이름은 보내기 직전에 푼다** (주소 계약 2 · 2026-10-07) ══════════════════════════
|
|
7599
|
+
*
|
|
7600
|
+
* 오래 들고 있을 주소는 세션 uuid(36자)다. 하네스 이름(예: report-portal-37)은 프로세스가 뜰 때마다 00~ff 로 다시 뽑히는
|
|
7601
|
+
* 표시값이라 적어 두면 죽거나 남에게 간다 — 469cd2a5 의 이름은 5일 사이 eb → 26 → 37 이었고, 7afd1d02 의 옛 이름 hnl-73 은
|
|
7602
|
+
* 지금 산 세션 110cd958 의 이름이다(검증 워크플로 2026-10-07 실측). 그래서 이 MCP 는
|
|
7603
|
+
* ① **이름이 없어도 보낸다.** 서버는 2026-08-25 부터 이름 없는 발신을 uuid 앞 8자로 받는다(8ec9b061:www/portal/lib/mail_store.php:96-105 —
|
|
7604
|
+
* mail_store_sender_session). 전에는
|
|
7605
|
+
* 여기 문지기(「이름 없으면 못 보냄」)가 서버보다 엄해서, 등록부를 못 읽은 세션이 전체 uuid 앞으로 보낸 통까지 거절됐다
|
|
7606
|
+
* (2026-10-06T15:49:24Z, to=469cd2a5-…-f8751b9dcd18 → 15:49:25Z 거절).
|
|
7607
|
+
* ② 서버 주소록에 대는 이름은 **대기 직전에** 푼다 — 세포(`gcell agents --self --json`)에 먼저 묻고(`cellSelf`), 세포가 없거나
|
|
7608
|
+
* 못 알아들으면 아래 JS 사본(`selfAddressJs`)으로. 같은 규칙을 두 자로 재면 같은 세션을 다르게 부른다 — 9aedc967 에서 등록부
|
|
7609
|
+
* pid 가 문자열이면 세포는 그 줄을 버리고 MCP 는 받았다(9aedc967:cell/src/world.rs:684 대 mcp/genetics.mjs:7570). 그래서 JS 사본은 세포와 같은 계약을 진다.
|
|
7610
|
+
* ③ 대는 이름은 **지금 이 프로세스의 이름이라고 확인된 것**뿐이다(`myAgentName`). 확인 못 한 이름(다시 뜨기 전의 자기소개)을 대면
|
|
7611
|
+
* 서버가 그 이름을 이 세션으로 풀어(052 의 최신-승) 그 이름의 지금 임자에게 갈 통이 여기로 온다.
|
|
7612
|
+
*/
|
|
7613
|
+
/*
|
|
7614
|
+
* ── 전사의 자기소개 ─────────────────────────────────────────────────────────────────────────────
|
|
7615
|
+
* `ListAgents` 를 부르면 하네스가 「This session is <이름> [<id>]」를 **전사에 적는다.** 모델이 지어낸 값이 아니라 하네스가 쓴
|
|
7616
|
+
* 사실이지만, 그 이름은 **그때의** 이름이다 — 다시 뜬 뒤에는 옛 이름이다. 그래서 등록부 다음 차례이고, 그마저 이 MCP 가 뜬 뒤에
|
|
7617
|
+
* 적힌 것만 지금 이름으로 친다(`myAgentName`).
|
|
7618
|
+
* ⚠ **마지막 것**을 쓴다 — 끝에서 거슬러 읽는다(`lastIntroInRange`).
|
|
7619
|
+
* ⚠ **늘어난 부분만 다시 읽는다.** 그림이 base64 로 실려 전사가 253MB 까지 간다(2026-10-07 실측 — 옛 주석의 「7MB」는 틀렸다).
|
|
7620
|
+
* ⚠ 못 찾으면 `null` — **지어내지 않는다.**
|
|
7193
7621
|
*/
|
|
7194
7622
|
/*
|
|
7195
7623
|
* ⚠⚠ **줄 안의 글자가 아니라 레코드의 모양으로 읽는다** (2026-08-25 실측).
|
|
@@ -7208,7 +7636,37 @@ const mailBackSeen = new Set();
|
|
|
7208
7636
|
*/
|
|
7209
7637
|
const INTRO_HEAD = 'This session is ';
|
|
7210
7638
|
const INTRO_TAIL = 'the name other sessions use';
|
|
7211
|
-
|
|
7639
|
+
/**
|
|
7640
|
+
* 자기소개 글 한 덩이 → `{ name, id }`(세포 `agent_name_in` 과 한 벌). 못 읽으면 null.
|
|
7641
|
+
* ⚠ 이름 글자는 **등록부와 같은 꼴**(`harnessNameClean` — 제어문자만 걷고 65자까지)이다(2026-10-07). 전에는 [A-Za-z0-9._-]{1,60} 만
|
|
7642
|
+
* 받았는데, 실제 ListAgents 목록에 「atflee Graph」·「홈 변화 그래프 — 최신 기록이 …」(47자) 같은 이름이 선다(469cd2a5 전사 실측 —
|
|
7643
|
+
* 세포 쪽 조사). 그래서 식별자는 꼬리(INTRO_TAIL) 앞의 **마지막** 「 [<16진>]」이고 — 이름 안에 「 — 」나 「 [」가 있어도 안 갈린다 —
|
|
7644
|
+
* 본문의 첫 줄만 본다(그 아래 「Peer sessions」 목록의 이름들은 이 세션의 이름이 아니다).
|
|
7645
|
+
*/
|
|
7646
|
+
function introNameIn(text) {
|
|
7647
|
+
const t = String(text ?? '');
|
|
7648
|
+
const i = t.indexOf(INTRO_HEAD);
|
|
7649
|
+
if (i < 0) return null;
|
|
7650
|
+
const rest = t.slice(i + INTRO_HEAD.length).split('\n')[0];
|
|
7651
|
+
const tail = rest.indexOf(INTRO_TAIL);
|
|
7652
|
+
const head = tail >= 0 ? rest.slice(0, tail) : rest;
|
|
7653
|
+
const open = head.lastIndexOf(' [');
|
|
7654
|
+
if (open < 0) return null;
|
|
7655
|
+
const name = harnessNameClean(head.slice(0, open));
|
|
7656
|
+
if (!name) return null;
|
|
7657
|
+
const after = head.slice(open + 2);
|
|
7658
|
+
const close = after.indexOf(']');
|
|
7659
|
+
if (close < 0) return null;
|
|
7660
|
+
const id = after.slice(0, close);
|
|
7661
|
+
return /^[0-9A-Fa-f]+$/.test(id) ? { name, id } : null;
|
|
7662
|
+
}
|
|
7663
|
+
/**
|
|
7664
|
+
* 전사의 레코드(JSONL 한 줄) → 그 자기소개의 이름(세포 `agent_name_in_record` 와 한 벌). 아니면 null.
|
|
7665
|
+
* ★ 레코드에 하네스의 짝 표식 `toolUseResult.listing`(ListAgents 결과의 구조화 칸 — 본문과 같은 글)이 있어야 한다(2026-10-07 검증 SEC-6).
|
|
7666
|
+
* Bash 결과는 `{stdout, stderr, …}` 라, `echo "This session is <남의 이름> [ab12] — the name other sessions use"` 한 줄이 전에는 이름으로
|
|
7667
|
+
* 읽혀 아래 myAgentName ②(뜬 뒤 자기소개)로 서버에 이 세션의 주소로 섰다 — 웹 문서에 숨은 지시 하나로. 실측: 전사 154개의 자기소개 211건이
|
|
7668
|
+
* 하네스 2.1.239~2.1.290 내내 이 칸을 가졌고 본문과 같았다.
|
|
7669
|
+
*/
|
|
7212
7670
|
function introNameOf(line) {
|
|
7213
7671
|
if (!line.includes(INTRO_HEAD)) return null; // 값싼 검사로 먼저 거른다
|
|
7214
7672
|
let d;
|
|
@@ -7218,63 +7676,765 @@ function introNameOf(line) {
|
|
|
7218
7676
|
const first = Array.isArray(c) ? c[0] : null;
|
|
7219
7677
|
if (!first || first.type !== 'tool_result') return null;
|
|
7220
7678
|
let body = first.content;
|
|
7221
|
-
if (Array.isArray(body)) body = body.map((x) => (x && x.type === 'text'
|
|
7679
|
+
if (Array.isArray(body)) body = body.map((x) => (x && x.type === 'text' && typeof x.text === 'string' ? x.text : '')).join('');
|
|
7222
7680
|
if (typeof body !== 'string' || !body.startsWith(INTRO_HEAD) || !body.includes(INTRO_TAIL)) return null;
|
|
7223
|
-
const
|
|
7224
|
-
|
|
7681
|
+
const listing = d?.toolUseResult?.listing;
|
|
7682
|
+
if (typeof listing !== 'string') return null; // ★ 하네스 ListAgents 의 짝 표식이 없다 — Bash·MCP 가 찍은 같은 글
|
|
7683
|
+
const a = introNameIn(body);
|
|
7684
|
+
const b = introNameIn(listing);
|
|
7685
|
+
return a && b && a.name === b.name && a.id === b.id ? a.name : null;
|
|
7225
7686
|
}
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7687
|
+
/*
|
|
7688
|
+
* ── 하네스 등록부 — JS 사본(계약 1 의 의미 그대로 · 세포가 없거나 못 알아들을 때만 쓴다) ─────────────────────────────────
|
|
7689
|
+
* 하네스는 이름을 **프로세스가 뜰 때마다 새로 짓고** `<설정 폴더>/sessions/<pid>.json` 의 `name` 에 적는다 — ListAgents·SendMessage 가
|
|
7690
|
+
* 쓰는 바로 그 이름이다(`nameSource:"derived"` · `nameSince` = 프로세스 시작). 실측(2026-10-07): 469cd2a5 는 등록부 「report-portal-37」,
|
|
7691
|
+
* 전사 「report-portal-26」.
|
|
7692
|
+
*
|
|
7693
|
+
* ★ 세포(`cell/src/world.rs` 「주소는 세션 uuid 다」 머리말 · registry_rows_in·row_live·self_row·whereabouts_of)와 **한 벌**이다.
|
|
7694
|
+
* 두 자를 맞대는 정본은 둘이다 — 칸 규칙표 `cell/tests/fixtures/registry/cases.json`(smoke 3-2)과, 경우 통째(등록부 줄·전사·env·
|
|
7695
|
+
* 프로세스 표 → 계약 1 의 답)를 맞대는 **이름 관문** `cell/tests/fixtures/agent-names/`(smoke 3-4 · 세포는 cargo 시험 둘이 같은 경우를 문다).
|
|
7696
|
+
* 관문이 처음 돌 때(2026-10-07) 이 사본이 세 자리에서 세포와 달랐다 — 산 줄이면 옛 이름을 안 읽음 · 옛 이름을 설정 폴더가 아니라
|
|
7697
|
+
* findTranscript(~/.claude/projects 고정)로 찾음 · 1MiB 넘는 줄을 자기소개로 읽음. 셋 다 세포 쪽으로 맞췄다.
|
|
7698
|
+
* 9aedc967 에서 둘이 갈렸다 — pid 가 문자열 "32054" 면 세포는 그 줄을 버리고 MCP 는 받아, 같은 세션을 세포는 옛 자기소개로 ·
|
|
7699
|
+
* MCP 는 등록부 이름으로 불렀다(검증 조사 픽스처 A). 그래서 아래 규칙은 글자 하나까지 세포를 따른다:
|
|
7700
|
+
* · 파일은 `<숫자>.json` 만(세션 사이 인증 열쇠 `.key` 는 안 읽는다) · 64KiB 를 넘으면 안 읽는다.
|
|
7701
|
+
* · 숫자 칸(pid·updatedAt)은 JSON 수(정수 — 소수부 0 인 실수 포함) 또는 **ASCII 숫자 1~16자** 문자열(`harnessInt`).
|
|
7702
|
+
* `Number()` 를 그대로 쓰지 않는다 — `Number(" 7")`·`Number("0x7")` 도 7 이라 두 자가 갈린다.
|
|
7703
|
+
* · 이름은 제어문자(Cc)만 걷고 앞뒤 공백을 다듬어 1~65자(코드 포인트, `harnessNameClean`). 넘으면 자르지 않고 버린다(줄은 남는다 —
|
|
7704
|
+
* 이름 없이 떠 있는 세션). 하네스는 `/rename` 이름을 제어문자만 걷고 받고, 안정 주소 스위치가 켜지면 이름이 65자까지 간다 —
|
|
7705
|
+
* 옛 [A-Za-z0-9._-]{1,60} 은 「atflee Graph」·47자 한글 이름의 줄을 말없이 버렸다.
|
|
7706
|
+
* · 떠 있나(`harnessRowLife`)는 셋 중 하나 — 'live' · 'gone'(재서 아니라고 나왔다) · 못 잰 까닭('platform'·'domain'·'start').
|
|
7707
|
+
* 떠 있다 = pidDomain 이 이 판과 같고(하네스와 같은 꼴로 짓는다 — `harnessPidDomain`, F1) · pid 가 있고(kill 0 · EPERM 도 있는 것) ·
|
|
7708
|
+
* procStart 가 있으면 그 pid 의 실제 시작 시각과 2초 안. procStart 를 못 대조하면(꼴을 못 읽음 · 시작 시각을 못 잼) **산 것으로 안
|
|
7709
|
+
* 친다** — 끝난 세션의 옛 이름을 「지금 이름」으로 대면 남의 통이 여기로 온다. 이름 없이 uuid 로 닿는 쪽이 안전하다. 그러나 「끝났다」고도
|
|
7710
|
+
* 안 한다(F5 — 계약 칸 measured 거짓). procStart 칸이 없는 옛 하네스 줄은 kill 0 만.
|
|
7711
|
+
* · 같은 조건의 산 줄이 둘이면 procStart 가 늦은 줄(가장 나중에 뜬 프로세스), 같으면 pid 가 큰 줄(readdir 차례에 안 기댄다). updatedAt 은
|
|
7712
|
+
* 안 쓴다 — 상태가 바뀔 때마다 움직여 창 둘이 띄운 대화는 물을 때마다 다른 이름이 나왔다(L1). 산 줄 수는 live_rows 로 낸다(SEC-1).
|
|
7713
|
+
* · 자기 줄은 **프로세스 열쇠** 차례로 찾는다: env `CLAUDE_CODE_MESSAGING_SOCKET` == `messagingSocketPath` → `CLAUDE_PID` == `pid` →
|
|
7714
|
+
* `CLAUDE_CODE_SESSION_ID` == `sessionId`. 세션 id 만으로 찾으면 /clear 뒤에는 MCP env 의 옛 id 로 줄을 못 찾는다(MCP 는 뜰 때
|
|
7715
|
+
* env 를 한 번 받는다 — 2.1.290 @217810326 · /clear 는 등록부 줄의 sessionId 만 바꾼다). MCP 의 process.ppid 는 claude 가 아니라
|
|
7716
|
+
* volta 심이라 열쇠가 못 된다(ps 실측 MCP 18936 → 심 18901 → claude 18159). MCP env 에는 CLAUDE_PID 가 없다(Bash·훅의 것).
|
|
7717
|
+
*/
|
|
7718
|
+
/**
|
|
7719
|
+
* 하네스의 집 — 세포 `crate::home_dir()` 와 같은 차례(유닉스: HOME → USERPROFILE · Windows: 거꾸로), 둘 다 없으면 os.homedir().
|
|
7720
|
+
* ⚠ `env` 에서 읽는다(전에는 os.homedir() 고정) — 이름 관문이 경우마다 임시 집을 주고, 세포도 그 env 의 HOME 을 읽는다.
|
|
7721
|
+
*/
|
|
7722
|
+
function harnessHome(env = process.env) {
|
|
7723
|
+
for (const k of process.platform === 'win32' ? ['USERPROFILE', 'HOME'] : ['HOME', 'USERPROFILE']) {
|
|
7724
|
+
const v = env?.[k];
|
|
7725
|
+
if (typeof v === 'string' && v !== '') return v;
|
|
7726
|
+
}
|
|
7727
|
+
return os.homedir();
|
|
7728
|
+
}
|
|
7729
|
+
/** 하네스 설정 폴더 — CLAUDE_CONFIG_DIR(비어 있지 않을 때) 아니면 <집>/.claude(세포 `harness_config_dir`). NFC 는 하네스가 하는 대로. */
|
|
7730
|
+
function harnessConfigDir(env = process.env) {
|
|
7731
|
+
return String(env?.CLAUDE_CONFIG_DIR || path.join(harnessHome(env), '.claude')).normalize('NFC');
|
|
7732
|
+
}
|
|
7733
|
+
function harnessSessionsDir(env = process.env) {
|
|
7734
|
+
return path.join(harnessConfigDir(env), 'sessions');
|
|
7735
|
+
}
|
|
7736
|
+
/**
|
|
7737
|
+
* Claude Code 전사를 찾는 뿌리 — <설정 폴더>/projects, 그다음 <집>/.claude/projects(설정 폴더가 그것이 아닐 때만) · 세포 `claude_project_bases`.
|
|
7738
|
+
* ⚠ 옛 이름(자기소개)은 이 규칙으로만 찾는다. 전에는 `introOf`(findTranscript — Codex 먼저 · cwd 폴더 · ~/.claude/projects 고정 · Cursor)를 써서
|
|
7739
|
+
* CLAUDE_CONFIG_DIR 로 뜬 세션의 옛 이름을 세포와 다르게 냈다(이름 관문 2026-10-07 — 픽스처 10-config-dir 가 미끼를 냈다).
|
|
7740
|
+
*/
|
|
7741
|
+
function claudeProjectBases(env = process.env) {
|
|
7742
|
+
const bases = [path.join(harnessConfigDir(env), 'projects')];
|
|
7743
|
+
const home = path.join(path.join(harnessHome(env), '.claude').normalize('NFC'), 'projects');
|
|
7744
|
+
if (!bases.includes(home)) bases.push(home);
|
|
7745
|
+
return bases;
|
|
7746
|
+
}
|
|
7747
|
+
/**
|
|
7748
|
+
* `bases` 의 프로젝트 폴더들에서 `<uuid>.jsonl`(본 전사)을 — 뿌리 차례, 그 안에서는 폴더 이름의 바이트 차례(libuv 가 strcmp 로 늘어놓는다 ·
|
|
7749
|
+
* 세포도 같은 차례로 맞췄다)로 처음 것. 하위 에이전트 전사(`<uuid>/subagents/…/agent-*.jsonl`)는 이 꼴이 아니라 안 잡힌다(세포 `claude_transcript_in`).
|
|
7750
|
+
*/
|
|
7751
|
+
function claudeTranscriptIn(bases, sid) {
|
|
7752
|
+
const s = String(sid ?? '');
|
|
7753
|
+
if (!AUTO_UUID_RE.test(s)) return null;
|
|
7754
|
+
for (const base of bases) {
|
|
7755
|
+
let names;
|
|
7756
|
+
try { names = fs.readdirSync(base); } catch { continue; }
|
|
7757
|
+
for (const n of names) {
|
|
7758
|
+
const f = path.join(base, n, `${s}.jsonl`);
|
|
7759
|
+
try { if (fs.statSync(f).isFile()) return f; } catch { /* 이 폴더엔 없다 */ }
|
|
7760
|
+
}
|
|
7761
|
+
}
|
|
7762
|
+
return null;
|
|
7763
|
+
}
|
|
7764
|
+
/** 그 pid 의 프로세스가 지금 있나 — 신호 0 은 보내지 않고 묻기만 한다. 남의 프로세스(EPERM)도 있는 것이다. */
|
|
7765
|
+
function pidAlive(pid) {
|
|
7766
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
7767
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e?.code === 'EPERM'; }
|
|
7768
|
+
}
|
|
7769
|
+
/** 등록부 숫자 칸 → 정수(세포 `reg_int`): JSON 수는 안전 정수만, 문자열은 ASCII 숫자 1~16자만. 그 밖(부호·공백·소수점·16진·지수·불·null)은 null. */
|
|
7770
|
+
function harnessInt(v) {
|
|
7771
|
+
if (typeof v === 'number') return Number.isSafeInteger(v) ? v : null;
|
|
7772
|
+
if (typeof v === 'string' && /^[0-9]{1,16}$/.test(v)) return Number(v);
|
|
7773
|
+
return null;
|
|
7774
|
+
}
|
|
7775
|
+
const HARNESS_NAME_MAX = 65;
|
|
7776
|
+
// 유니코드 White_Space 중 제어문자(Cc)가 아닌 것 — 세포 name_clean 의 trim() 과 같은 자리를 다듬는다(JS 의 \s 는 U+FEFF 도 걷어 다르다).
|
|
7777
|
+
const HARNESS_TRIM = /^[\u0020\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+|[\u0020\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+$/g;
|
|
7778
|
+
/** 이름 꼴(세포 `name_clean`) — 제어문자(Cc)만 걷고 앞뒤 공백을 다듬은 뒤 1~65자(코드 포인트)면 그 글, 아니면 null(자르지 않는다). */
|
|
7779
|
+
function harnessNameClean(s) {
|
|
7780
|
+
if (typeof s !== 'string') return null;
|
|
7781
|
+
const t = s.replace(/[\u0000-\u001F\u007F-\u009F]/g, '').replace(HARNESS_TRIM, '');
|
|
7782
|
+
const n = [...t].length;
|
|
7783
|
+
return n >= 1 && n <= HARNESS_NAME_MAX ? t : null;
|
|
7784
|
+
}
|
|
7785
|
+
/** 이미 깨끗한 이름인가 — 세포가 낸 이름(이미 name_clean 을 지났다)을 받을 때 모양을 다시 잰다. */
|
|
7786
|
+
function harnessNameOk(s) {
|
|
7787
|
+
return typeof s === 'string' && harnessNameClean(s) === s;
|
|
7788
|
+
}
|
|
7789
|
+
const PROC_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
7790
|
+
/** `ps -o lstart` 꼴(「Www Mmm d HH:MM:SS YYYY」, UTC 로 읽는다) → 유닉스 초. 아니면 null(세포 `lstart_epoch`). */
|
|
7791
|
+
function lstartEpoch(s) {
|
|
7792
|
+
const p = String(s).split(/\s+/).filter(Boolean);
|
|
7793
|
+
if (p.length !== 5 || Buffer.byteLength(p[0]) !== 3) return null;
|
|
7794
|
+
const mo = PROC_MONTHS.indexOf(p[1]);
|
|
7795
|
+
if (mo < 0) return null;
|
|
7796
|
+
const num = (x, lo, hi) => {
|
|
7797
|
+
if (!/^[0-9]{1,4}$/.test(x)) return null;
|
|
7798
|
+
const v = Number(x);
|
|
7799
|
+
return v >= lo && v <= hi ? v : null;
|
|
7800
|
+
};
|
|
7801
|
+
const d = num(p[2], 1, 31);
|
|
7802
|
+
const hms = p[3].split(':');
|
|
7803
|
+
if (d === null || hms.length !== 3) return null;
|
|
7804
|
+
const h = num(hms[0], 0, 23), mi = num(hms[1], 0, 59), se = num(hms[2], 0, 60), y = num(p[4], 1970, 9999);
|
|
7805
|
+
if (h === null || mi === null || se === null || y === null) return null;
|
|
7806
|
+
return Date.UTC(y, mo, d, h, mi, se) / 1000;
|
|
7807
|
+
}
|
|
7808
|
+
/** ISO 꼴(세포 `transcript::iso_epoch` 와 같은 너그러움 — 앞 19자만 본다). 못 읽으면 0. */
|
|
7809
|
+
function isoEpochLoose(s) {
|
|
7810
|
+
if (s.length < 19 || s[4] !== '-' || s[7] !== '-' || (s[10] !== 'T' && s[10] !== ' ')) return 0;
|
|
7811
|
+
const n = (a, z) => { const v = s.slice(a, z); return /^[+-]?[0-9]+$/.test(v) ? parseInt(v, 10) : 0; };
|
|
7812
|
+
const y = n(0, 4), mo = n(5, 7);
|
|
7813
|
+
if (y < 1970 || mo < 1 || mo > 12) return 0;
|
|
7814
|
+
return Date.UTC(y, mo - 1, n(8, 10), n(11, 13), n(14, 16), n(17, 19)) / 1000;
|
|
7815
|
+
}
|
|
7816
|
+
/**
|
|
7817
|
+
* 등록부 `procStart` → 유닉스 초(세포 `proc_start_epoch`). 하네스 2.1.290 은 `TZ=UTC ps -o lstart` 꼴로 적는다 — 「Tue Oct 6 04:41:55
|
|
7818
|
+
* 2026」(2026-10-07 실측 13/13 이 그 pid 의 `TZ=UTC ps -o lstart=` 와 같은 글자). 꼴이 바뀌어도 흔한 둘(epoch 수 — 1e11 을 넘으면 ms,
|
|
7819
|
+
* ISO-8601 `…Z`)은 받는다. 그 밖은 null — 로캘을 탄 ps 꼴(「2026년 10월 6일 화요일 …」)도 못 읽는 것으로 친다(세포와 같게).
|
|
7820
|
+
*/
|
|
7821
|
+
function procStartEpoch(v) {
|
|
7822
|
+
if (typeof v === 'string') {
|
|
7823
|
+
const l = lstartEpoch(v);
|
|
7824
|
+
if (l !== null) return l;
|
|
7825
|
+
const t = v.trim();
|
|
7826
|
+
if (!/^[\x00-\x7F]*$/.test(t) || !t.endsWith('Z')) return null;
|
|
7827
|
+
const e = isoEpochLoose(t);
|
|
7828
|
+
return e > 0 ? e : null;
|
|
7829
|
+
}
|
|
7830
|
+
if (typeof v === 'number') {
|
|
7831
|
+
const n = harnessInt(v);
|
|
7832
|
+
if (n === null || n <= 0) return null;
|
|
7833
|
+
return n > 100_000_000_000 ? Math.trunc(n / 1000) : n;
|
|
7834
|
+
}
|
|
7835
|
+
return null;
|
|
7836
|
+
}
|
|
7837
|
+
/** 등록부 JSON 한 개 → 줄(세포 `reg_row`). pid(1..2^31-1)·sessionId(uuid)가 없으면 null. procStart 는 absent · at · unreadable. */
|
|
7838
|
+
function harnessRowOf(d) {
|
|
7839
|
+
if (!d || typeof d !== 'object' || Array.isArray(d)) return null;
|
|
7840
|
+
const pid = harnessInt(d.pid);
|
|
7841
|
+
if (pid === null || pid <= 0 || pid > 2147483647) return null;
|
|
7842
|
+
if (typeof d.sessionId !== 'string' || !AUTO_UUID_RE.test(d.sessionId)) return null;
|
|
7843
|
+
const text = (k) => (typeof d[k] === 'string' && d[k] !== '' ? d[k] : null);
|
|
7844
|
+
const ps = d.procStart === undefined || d.procStart === null ? { kind: 'absent' }
|
|
7845
|
+
: (() => { const t = procStartEpoch(d.procStart); return t === null ? { kind: 'unreadable' } : { kind: 'at', t }; })();
|
|
7846
|
+
return { pid, session: d.sessionId.toLowerCase(), name: harnessNameClean(d.name), updatedAt: harnessInt(d.updatedAt) ?? 0,
|
|
7847
|
+
procStart: ps, socket: text('messagingSocketPath'), domain: text('pidDomain') };
|
|
7848
|
+
}
|
|
7849
|
+
const HARNESS_FILE_MAX = 64 * 1024;
|
|
7850
|
+
/** 등록부 폴더의 줄들(살았든 죽었든 — 판정은 `harnessRowLive`). 폴더가 없으면 빈 목록. 차례는 (pid, updatedAt, session). */
|
|
7851
|
+
function harnessRowsIn(dir) {
|
|
7852
|
+
let names;
|
|
7853
|
+
try { names = fs.readdirSync(dir); } catch { return []; }
|
|
7854
|
+
const rows = [];
|
|
7855
|
+
for (const n of names) {
|
|
7856
|
+
if (!/^[0-9]+\.json$/.test(n)) continue; // `<pid>.json` 만
|
|
7857
|
+
let d;
|
|
7858
|
+
try {
|
|
7859
|
+
const p = path.join(dir, n);
|
|
7860
|
+
const st = fs.statSync(p);
|
|
7861
|
+
if (!st.isFile() || st.size > HARNESS_FILE_MAX) continue;
|
|
7862
|
+
const raw = fs.readFileSync(p);
|
|
7863
|
+
if (raw.length > HARNESS_FILE_MAX) continue; // 잰 뒤 읽기 전에 자랐다
|
|
7864
|
+
d = JSON.parse(raw.toString('utf8'));
|
|
7865
|
+
} catch { continue; }
|
|
7866
|
+
const r = harnessRowOf(d);
|
|
7867
|
+
if (r) rows.push(r);
|
|
7868
|
+
}
|
|
7869
|
+
return rows.sort((a, b) => (a.pid - b.pid) || (a.updatedAt - b.updatedAt) || (a.session < b.session ? -1 : a.session > b.session ? 1 : 0));
|
|
7870
|
+
}
|
|
7871
|
+
/** procStart 와 실제 시작 시각의 허용 차(초) — lstart 는 초에서 자르고 Linux 의 btime 도 초 단위라 1초가 흔들릴 수 있다(세포와 같은 값). */
|
|
7872
|
+
const PROC_START_SLACK_SECS = 2;
|
|
7873
|
+
/**
|
|
7874
|
+
* 그 줄의 세션이 **지금 떠 있나**(위 머리말 · 세포 `row_life`) — 'live' · 'gone' · 못 잰 까닭('platform'·'domain'·'start').
|
|
7875
|
+
* `probe` = { alive(pid), started(pid) → 초|null, domain, measurable }. measurable 이 거짓(Windows — 세포가 pid 를 못 잰다)이면 모든 줄이 'platform'.
|
|
7876
|
+
* 칸이 없는 옛 probe(시험)는 잴 수 있는 것으로 친다.
|
|
7877
|
+
*/
|
|
7878
|
+
function harnessRowLife(r, probe) {
|
|
7879
|
+
if (probe.measurable === false) return 'platform';
|
|
7880
|
+
if (r.domain && r.domain !== probe.domain) return 'domain'; // 다른 pid 공간의 pid 는 이 기계의 프로세스 표 값이 아니다 — 못 잰다
|
|
7881
|
+
if (!probe.alive(r.pid)) return 'gone'; // 하네스가 끝내며 못 지운 줄 — 그 이름은 아무도 안 받는다
|
|
7882
|
+
if (r.procStart.kind === 'absent') return 'live'; // 옛 하네스 줄 — 견줄 칸이 없다(kill 0 만)
|
|
7883
|
+
if (r.procStart.kind !== 'at') return 'start'; // 꼴을 못 읽었다 — 대조를 못 하니 산 것으로 안 친다(끝난 것도 아니다)
|
|
7884
|
+
const s = probe.started(r.pid);
|
|
7885
|
+
if (s === null || s === undefined) return 'start'; // 시작 시각을 못 쟀다(ps 실패 · 시간 초과)
|
|
7886
|
+
return Math.abs(s - r.procStart.t) <= PROC_START_SLACK_SECS ? 'live' : 'gone';
|
|
7887
|
+
}
|
|
7888
|
+
/** 떠 있다고 **재서** 나왔나(세포 `row_live`). */
|
|
7889
|
+
function harnessRowLive(r, probe) { return harnessRowLife(r, probe) === 'live'; }
|
|
7890
|
+
/** 고르는 열쇠 — procStart(가장 나중에 뜬 프로세스), 같으면 pid(세포 `pick_key` · 칸이 없는 옛 줄은 가장 이르다). a ≥ b 면 참. */
|
|
7891
|
+
function harnessPickGe(a, b) {
|
|
7892
|
+
const ta = a.procStart.kind === 'at' ? a.procStart.t : -Infinity;
|
|
7893
|
+
const tb = b.procStart.kind === 'at' ? b.procStart.t : -Infinity;
|
|
7894
|
+
return ta > tb || (ta === tb && a.pid >= b.pid);
|
|
7895
|
+
}
|
|
7896
|
+
/**
|
|
7897
|
+
* 조건에 맞는 줄들을 잰다(세포 `lookup`) — `{ row, lives, blind }`: 산 줄 중 고른 것 · 산 줄 전부 · 못 잰 줄 중 고른 것과 까닭(`{ why, row }`).
|
|
7898
|
+
* 같은 열쇠면 나중 줄 — Rust 는 `>=` 로 같은 최댓값 중 마지막을 고르고, 두 자 모두 (pid, updatedAt, session) 차례로 걷는다.
|
|
7899
|
+
*/
|
|
7900
|
+
function harnessLookup(rows, probe, pred) {
|
|
7901
|
+
const out = { row: null, lives: [], blind: null };
|
|
7902
|
+
for (const r of rows) {
|
|
7903
|
+
if (!pred(r)) continue;
|
|
7904
|
+
const life = harnessRowLife(r, probe);
|
|
7905
|
+
if (life === 'live') {
|
|
7906
|
+
out.lives.push(r);
|
|
7907
|
+
if (!out.row || harnessPickGe(r, out.row)) out.row = r;
|
|
7908
|
+
} else if (life !== 'gone') {
|
|
7909
|
+
if (!out.blind || harnessPickGe(r, out.blind.row)) out.blind = { why: life, row: r };
|
|
7910
|
+
}
|
|
7911
|
+
}
|
|
7912
|
+
return out;
|
|
7913
|
+
}
|
|
7914
|
+
/** 조건에 맞는 **산** 줄 중 고른 것(`harnessLookup` 의 row). 없으면 null. */
|
|
7915
|
+
function harnessLiveRow(rows, probe, pred) {
|
|
7916
|
+
return harnessLookup(rows, probe, pred).row;
|
|
7917
|
+
}
|
|
7918
|
+
/** 나를 찾는 프로세스 열쇠(세포 `SelfKeys::from_vars`) — 빈 값은 없는 것. 소켓은 글자 그대로, CLAUDE_PID 는 다듬어 숫자 규칙, 세션 id 는 uuid 꼴만(소문자). */
|
|
7919
|
+
function harnessSelfKeys(env) {
|
|
7920
|
+
const get = (k) => { const v = env?.[k]; return typeof v === 'string' && v.trim() !== '' ? v : null; };
|
|
7921
|
+
const p = get('CLAUDE_PID');
|
|
7922
|
+
const pid = p === null ? null : harnessInt(p.trim());
|
|
7923
|
+
const sid = get('CLAUDE_CODE_SESSION_ID');
|
|
7924
|
+
const session = sid === null ? null : sid.trim().toLowerCase();
|
|
7925
|
+
return { socket: get('CLAUDE_CODE_MESSAGING_SOCKET'),
|
|
7926
|
+
pid: pid !== null && pid > 0 && pid <= 2147483647 ? pid : null,
|
|
7927
|
+
session: session && AUTO_UUID_RE.test(session) ? session : null };
|
|
7928
|
+
}
|
|
7929
|
+
/**
|
|
7930
|
+
* 등록부의 **내 줄**을 찾는다(세포 `self_lookup`) — 열쇠 차례: 소켓 → CLAUDE_PID → 세션 id. 앞 열쇠가 **산 줄이나 못 잰 줄**을 찾으면 뒤 열쇠는
|
|
7931
|
+
* 안 본다(못 잰 줄이 내 줄일 수 있는데 세션 id 로 넘어가면 같은 대화를 띄운 **다른 창**을 나로 고른다 — SEC-1). 끝난 줄만 찾았으면 다음 열쇠로.
|
|
7932
|
+
* `{ by: 'socket'|'pid'|'session', lk }` 또는 null.
|
|
7933
|
+
*/
|
|
7934
|
+
function harnessSelfLookup(rows, keys, probe) {
|
|
7935
|
+
const tries = [['socket', keys.socket, (r) => r.socket === keys.socket], ['pid', keys.pid, (r) => r.pid === keys.pid],
|
|
7936
|
+
['session', keys.session, (r) => r.session === keys.session]];
|
|
7937
|
+
for (const [by, key, pred] of tries) {
|
|
7938
|
+
if (key === null || key === undefined) continue;
|
|
7939
|
+
const lk = harnessLookup(rows, probe, pred);
|
|
7940
|
+
if (lk.row || lk.blind) return { by, lk };
|
|
7941
|
+
}
|
|
7942
|
+
return null;
|
|
7943
|
+
}
|
|
7944
|
+
/** 등록부의 **내 산 줄**(세포 `self_row` — `harnessSelfLookup` 이 산 줄을 찾았을 때만). */
|
|
7945
|
+
function harnessSelfRow(rows, keys, probe) {
|
|
7946
|
+
return harnessSelfLookup(rows, keys, probe)?.lk.row ?? null;
|
|
7947
|
+
}
|
|
7948
|
+
const procStartSeen = new Map(); // pid → { at, v } — 같은 pid 를 1분 안에 다시 안 잰다(ps 한 번이 수 ms)
|
|
7949
|
+
/**
|
|
7950
|
+
* 그 pid 프로세스가 **시작한 때**(유닉스 초 · 세포 `proc_started_at` 과 같은 값). 못 재면 null.
|
|
7951
|
+
* · macOS: `ps -o lstart=` 를 UTC·C 로캘로 — 세포가 sysctl 로 읽는 p_starttime 과 같은 초다(2026-10-07 실측: pid 32054 →
|
|
7952
|
+
* 1791261715 = 「Tue Oct 6 04:41:55 2026」). 로캘을 물려받으면 macOS ps 는 날짜를 한국어로 써서(LANG=ko_KR.UTF-8 실측) 못 읽는다.
|
|
7953
|
+
* · Linux: `/proc/<pid>/stat` 의 22번째 칸(부팅 뒤 틱) ÷ 100 + `/proc/stat` 의 btime — 못 읽으면 ps. ⚠ USER_HZ 를 100 으로 둔다
|
|
7954
|
+
* (Linux 의 사용자 공간 틱은 아키텍처와 상관없이 100 이다 — 세포는 sysconf 로 묻는다). 이 기계에서는 못 돌렸다.
|
|
7955
|
+
* · 그 밖(Windows): null — procStart 가 있는 줄은 산 것으로 안 친다(세포도 같다).
|
|
7956
|
+
*/
|
|
7957
|
+
function procStartOf(pid) {
|
|
7958
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
7959
|
+
const c = procStartSeen.get(pid);
|
|
7960
|
+
if (c && Date.now() - c.at < 60_000) return c.v;
|
|
7961
|
+
let v = null;
|
|
7962
|
+
try {
|
|
7963
|
+
if (process.platform === 'linux') {
|
|
7964
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
7965
|
+
const ticks = Number(stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/)[19]);
|
|
7966
|
+
const btime = Number((/^btime (\d+)$/m.exec(fs.readFileSync('/proc/stat', 'utf8')) || [])[1]);
|
|
7967
|
+
if (Number.isFinite(ticks) && Number.isFinite(btime) && btime > 0) v = btime + Math.floor(ticks / 100);
|
|
7968
|
+
}
|
|
7969
|
+
} catch { /* 아래 ps 로 */ }
|
|
7970
|
+
if (v === null && (process.platform === 'darwin' || process.platform === 'linux')) {
|
|
7971
|
+
try {
|
|
7972
|
+
const r = spawnSync(fs.existsSync('/bin/ps') ? '/bin/ps' : 'ps', ['-o', 'lstart=', '-p', String(pid)],
|
|
7973
|
+
{ encoding: 'utf8', timeout: 1000, env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' } });
|
|
7974
|
+
if (!r.error && r.status === 0) v = lstartEpoch(r.stdout.trim());
|
|
7975
|
+
} catch { /* 못 잰다 */ }
|
|
7976
|
+
}
|
|
7977
|
+
// ⚠ 못 잰 답(null)은 기억하지 않는다(2026-10-07 검증 SEC-5). 부하에 ps 가 1초를 넘기면 null 이 60초 동안 남아, 그동안 이 프로세스의 줄이
|
|
7978
|
+
// 「산 줄 없음」이 되고 서버 이름이 비워졌다(남이 그 이름을 가져갈 틈). 잰 값은 pid 가 살아 있는 동안 바뀌지 않으니 기억해도 된다.
|
|
7979
|
+
if (v !== null) procStartSeen.set(pid, { at: Date.now(), v });
|
|
7980
|
+
return v;
|
|
7981
|
+
}
|
|
7982
|
+
/**
|
|
7983
|
+
* pidDomain 짓기(세포 `pid_domain_of` 와 한 벌 — 공동 표 cell/tests/fixtures/registry/cases.json 의 pid_domain). 하네스 2.1.291 `oas` 의 꼴:
|
|
7984
|
+
* macOS `darwin` · Linux·WSL `linux:<machine-id 다듬음>:<readlink /proc/self/ns/pid>`(못 읽으면 빈 글) · Windows `win32:<호스트 이름 소문자>`.
|
|
7985
|
+
* `platform` 은 Node `process.platform` 낱말.
|
|
7986
|
+
*/
|
|
7987
|
+
function pidDomainOf(platform, machineId, pidNs, host) {
|
|
7988
|
+
if (platform === 'linux') return `linux:${typeof machineId === 'string' ? machineId.trim() : ''}:${typeof pidNs === 'string' ? pidNs : ''}`;
|
|
7989
|
+
if (platform === 'win32') return `win32:${String(host ?? '').toLowerCase()}`;
|
|
7990
|
+
return String(platform);
|
|
7991
|
+
}
|
|
7992
|
+
let pidDomainMemo = null;
|
|
7993
|
+
/**
|
|
7994
|
+
* 이 판의 pidDomain — **하네스가 등록부에 적는 값과 같은 꼴**(한 번 재고 기억한다 · 세포 `pid_domain`).
|
|
7995
|
+
* ⚠ 전에는 `process.platform`(맨 'linux')과 견줘, Linux·WSL 에서는 등록부의 모든 줄이 「떠 있지 않음」이었다 — MCP 는 그 판에서 등록부 이름을
|
|
7996
|
+
* 영영 못 댔고, 이름이 서는 길은 「뜬 뒤 ListAgents」 하나뿐이었다(검증 F1 — 이 맥의 줄은 다 'darwin' 이라 못 봤다). Windows 의 호스트 이름은
|
|
7997
|
+
* 하네스와 같은 `os.hostname()` 이다(세포는 env COMPUTERNAME 으로 어림 — 그 판은 어차피 못 잰다).
|
|
7998
|
+
*/
|
|
7999
|
+
function harnessPidDomain() {
|
|
8000
|
+
if (pidDomainMemo === null) {
|
|
8001
|
+
const p = process.platform;
|
|
8002
|
+
let mid = null, ns = null, host = null;
|
|
8003
|
+
if (p === 'linux') {
|
|
8004
|
+
try { mid = fs.readFileSync('/etc/machine-id', 'utf8'); } catch { mid = null; }
|
|
8005
|
+
try { ns = fs.readlinkSync('/proc/self/ns/pid'); } catch { ns = null; }
|
|
8006
|
+
}
|
|
8007
|
+
if (p === 'win32') { try { host = os.hostname(); } catch { host = null; } }
|
|
8008
|
+
pidDomainMemo = pidDomainOf(p, mid, ns, host);
|
|
8009
|
+
}
|
|
8010
|
+
return pidDomainMemo;
|
|
8011
|
+
}
|
|
8012
|
+
/**
|
|
8013
|
+
* 이 기계의 프로세스 표 — 시험은 픽스처의 probe 를 대신 준다. pidDomain 은 하네스와 같은 꼴(`harnessPidDomain`).
|
|
8014
|
+
* ⚠ Windows 에서는 **못 잰다**(measurable 거짓) — 세포의 pid_alive 가 unix 밖에서는 없어서(world.rs) 같은 줄을 두 자가 다르게 부르지 않게
|
|
8015
|
+
* 맞춘다(그 판에는 procStart 를 잴 ps 도 없다). 「끝났다」가 아니라 「못 잰다」다(F5). 이름이 없으면 uuid 로 닿는다.
|
|
8016
|
+
*/
|
|
8017
|
+
function harnessProbe() {
|
|
8018
|
+
return { alive: pidAlive, started: procStartOf, domain: harnessPidDomain(), measurable: process.platform !== 'win32' };
|
|
8019
|
+
}
|
|
8020
|
+
/** epoch 초 → `2026-10-07T03:12:45Z`(세포 `iso_utc` 와 같은 꼴 — 밀리초 없음). */
|
|
8021
|
+
function isoUtc(secs) {
|
|
8022
|
+
return new Date(secs * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
8023
|
+
}
|
|
8024
|
+
/**
|
|
8025
|
+
* 계약 1 의 JSON 한 줄과 같은 꼴(세포 `whereabouts_of`) — `{ session, name, live, pid, source, stale_name, checked_at, matched_by, live_rows,
|
|
8026
|
+
* measured }`(칸 차례도 같다 — 뒤 셋은 2026-10-07 덧붙인 칸이고 기본값은 「열쇠 모름 · 산 줄이면 1줄 · 재서 나온 답」, `harnessWithRows` 가 채운다).
|
|
8027
|
+
* 산 줄이 있으면 source 'registry' · name 은 등록부 이름(꼴 밖이면 null — 그래도 떠 있다) · stale_name 은 전사의 마지막 자기소개가
|
|
8028
|
+
* 지금 이름과 **다를 때만**(다시 뜨며 바뀐 옛 이름).
|
|
8029
|
+
* 없으면 name·pid 는 null 이고, 전사의 마지막 자기소개가 있으면 source 'transcript' · stale_name 에 그 이름(지금 이름이 아니다).
|
|
8030
|
+
* `introName` 은 이름 꼴(`harnessNameClean`)을 다시 지난다 — 시험이 주는 글도 세포의 이름 꼴로만 받는다.
|
|
8031
|
+
*/
|
|
8032
|
+
function harnessWhereaboutsOf(session, row, introName, now) {
|
|
8033
|
+
const checked_at = isoUtc(now);
|
|
8034
|
+
const stale = harnessNameClean(introName);
|
|
8035
|
+
if (row) {
|
|
8036
|
+
return { session, name: row.name, live: true, pid: row.pid, source: 'registry',
|
|
8037
|
+
stale_name: stale !== null && stale !== row.name ? stale : null, checked_at, matched_by: null, live_rows: 1, measured: true };
|
|
8038
|
+
}
|
|
8039
|
+
return { session, name: null, live: false, pid: null, source: stale !== null ? 'transcript' : 'none', stale_name: stale, checked_at,
|
|
8040
|
+
matched_by: null, live_rows: 0, measured: true };
|
|
8041
|
+
}
|
|
8042
|
+
/**
|
|
8043
|
+
* 답에 덧붙인 칸 셋을 채운다(세포 `with_rows`) — 찾은 열쇠(matched_by) · 그 세션의 산 줄 수(live_rows — `same` 은 같은 세션 uuid 로 잰 판정) ·
|
|
8044
|
+
* 재서 나온 답인가(measured — `blind` 는 못 잰 까닭, 떠 있으면 무시).
|
|
8045
|
+
*/
|
|
8046
|
+
function harnessWithRows(w, same, blind, by) {
|
|
8047
|
+
w.matched_by = by ?? null;
|
|
8048
|
+
w.live_rows = w.live ? Math.max(1, same.lives.length) : 0;
|
|
8049
|
+
w.measured = w.live ? true : !blind;
|
|
8050
|
+
// 창 여럿이 한 전사에 함께 쓰면 마지막 자기소개가 다른 창의 **지금** 이름일 수 있다 — 옛 이름이 아니다(세포 `with_rows` 와 같다).
|
|
8051
|
+
if (w.live && w.stale_name !== null && same.lives.some((r) => r.name === w.stale_name)) w.stale_name = null;
|
|
8052
|
+
return w;
|
|
8053
|
+
}
|
|
8054
|
+
/**
|
|
8055
|
+
* `gcell agents --session <uuid>` 의 사본(세포 `session_whereabouts`) — 그 세션의 산 줄 중 고른 것 · 옛 이름은 그 세션 본 전사에서.
|
|
8056
|
+
* MCP 는 /clear 앞 대화의 uuid 가 다른 창에 떠 있는지 볼 때 이 판정(harnessLookup)을 쓴다. 이름 관문이 세포 `--session` 과 같은 픽스처에
|
|
8057
|
+
* 대 본다 — 몸은 `--self` 와 같은 블록이다. `intro` = 세션 uuid → 마지막 자기소개 이름(없으면 null).
|
|
8058
|
+
*/
|
|
8059
|
+
function harnessWhereabouts(sid, rows, probe, intro, now) {
|
|
8060
|
+
const s = String(sid ?? '').toLowerCase();
|
|
8061
|
+
const lk = harnessLookup(rows, probe, (r) => r.session === s);
|
|
8062
|
+
return harnessWithRows(harnessWhereaboutsOf(s, lk.row, intro(s), now), lk, lk.blind ? lk.blind.why : null, null);
|
|
8063
|
+
}
|
|
8064
|
+
/**
|
|
8065
|
+
* `gcell agents --self` 의 사본(세포 `self_whereabouts`) — 프로세스 열쇠로 내 줄을 찾고, 옛 이름은 **그 줄의 세션** 본 전사에서
|
|
8066
|
+
* (/clear 뒤에는 env 의 세션 id 가 아니라 등록부 줄의 세션이 나다). 열쇠가 못 잰 줄만 찾았으면 이름 칸은 비고 measured 거짓 — 세션은
|
|
8067
|
+
* 그 줄의 세션(이 프로세스의 열쇠로 찾은 줄이다). 아무것도 못 찾았으면 env 의 세션 id 로 옛 이름만 — 이름 칸은 빈다.
|
|
8068
|
+
*/
|
|
8069
|
+
function harnessSelfWhereabouts(rows, keys, probe, intro, now) {
|
|
8070
|
+
const f = harnessSelfLookup(rows, keys, probe);
|
|
8071
|
+
if (f?.lk.row) {
|
|
8072
|
+
const r = f.lk.row;
|
|
8073
|
+
const same = harnessLookup(rows, probe, (x) => x.session === r.session);
|
|
8074
|
+
return harnessWithRows(harnessWhereaboutsOf(r.session, r, intro(r.session), now), same, null, f.by);
|
|
8075
|
+
}
|
|
8076
|
+
if (f?.lk.blind) {
|
|
8077
|
+
const s = f.lk.blind.row.session;
|
|
8078
|
+
return harnessWithRows(harnessWhereaboutsOf(s, null, intro(s), now), f.lk, f.lk.blind.why, f.by);
|
|
8079
|
+
}
|
|
8080
|
+
return harnessWhereaboutsOf(keys.session, null, keys.session ? intro(keys.session) : null, now);
|
|
8081
|
+
}
|
|
8082
|
+
/**
|
|
8083
|
+
* 이 프로세스의 지금 주소 — JS 사본(세포가 없거나 못 알아들을 때 `selfAddress` 가 쓴다). 세포 `gcell agents --self --json` 과 **같은 답**이다:
|
|
8084
|
+
* 이름 관문(cell/tests/fixtures/agent-names/ · smoke 3-4)이 두 자에 같은 경우를 먹여 맞댄다.
|
|
8085
|
+
* ⚠ 전에는 산 줄이 있으면 전사를 안 읽어 stale_name 이 늘 null 이었다(「참고 칸이라 253MB 를 거슬러 읽는 값을 안 낸다」). 같은 입력에 세포와
|
|
8086
|
+
* 다른 답이라 관문이 그 자리에서 빨갰다(픽스처 02-restarted). 이제 읽는다 — 값: 자기소개가 끝 가까이 있으면 조각 하나, 없으면 한 번 끝까지
|
|
8087
|
+
* (실측 세포 101MB 0.10초), 그 뒤로는 `introOfFile` 의 한 칸 캐시가 늘어난 부분만 읽는다(등록은 60초에 한 번 · 세포가 있으면 이 길을 안 탄다).
|
|
8088
|
+
*/
|
|
8089
|
+
function selfAddressJs(env = process.env, probe = harnessProbe(), intro = (sid) => claudeIntroName(sid, env), now = Math.floor(Date.now() / 1000)) {
|
|
8090
|
+
return harnessSelfWhereabouts(harnessRowsIn(harnessSessionsDir(env)), harnessSelfKeys(env), probe, intro, now);
|
|
8091
|
+
}
|
|
8092
|
+
|
|
8093
|
+
/*
|
|
8094
|
+
* ── 세포에게 묻는다 — `gcell agents --self --json` (계약 1·2) ───────────────────────────────────────────────────
|
|
8095
|
+
* 세포 경로는 기존 규칙(`cellCheck` — GENETICS_CELL_BIN · 꾸러미 지문 · 설치본) 그대로다. 1초 안에 계약 꼴의 JSON 한 줄이 아니면 JS 사본으로.
|
|
8096
|
+
* ⚠ **기다리되 막지 않는다** — execFile 이라 그 1초 동안에도 다른 요청이 돈다(spawnSync 였으면 도구 응답이 통째로 선다).
|
|
8097
|
+
* ⚠ 옛 세포(--self 를 모르는 판 — 설치본 f0b68dc)는 모르는 깃발을 무시하고 `agents` 를 통째로 돈다. 임시 폴더에서도 전사를 다 걸어
|
|
8098
|
+
* 15.2초가 걸렸다(2026-10-07 실측 — 저장소를 못 찾으면 전사 폴더 전부를 걷는다, world.rs transcript_dirs_for). 그래서 `--since 0` 을
|
|
8099
|
+
* 함께 준다: 옛 세포는 0분 창으로 곧바로 「최근 0분 안에 … 없다」 한 줄을 내고(0.18초 실측), 그 글은 계약 꼴이 아니니 「못 알아듣는다」로
|
|
8100
|
+
* 적힌다. 새 세포는 `--self` 를 `--since` 보다 먼저 가르고 돌아가 그 깃발을 안 본다(cmd_agents). cwd 는 임시 폴더 — `--self` 는 저장소가
|
|
8101
|
+
* 아니라 프로세스 열쇠로 찾는다.
|
|
8102
|
+
* ⚠ 못 알아들은 세포는 그 바이너리(경로·크기·mtime)가 바뀌기 전엔 다시 안 묻는다 — 판올림(ensureCellOnBoot)이 갈아 끼우면 다시 묻는다.
|
|
8103
|
+
* 시간 초과는 「느리다」일 수 있어 5분만 쉰다.
|
|
8104
|
+
*/
|
|
8105
|
+
const CELL_SELF_TIMEOUT_MS = 1000;
|
|
8106
|
+
let cellSelfDeaf = ''; // --self 를 못 알아들은 세포의 지문
|
|
8107
|
+
let cellSelfRestUntil = 0; // 시간 초과 뒤 쉬는 끝
|
|
8108
|
+
function cellPrint(bin) {
|
|
8109
|
+
try { const st = fs.statSync(bin); return `${bin}\0${st.size}\0${st.mtimeMs}`; } catch { return ''; }
|
|
8110
|
+
}
|
|
8111
|
+
/**
|
|
8112
|
+
* 세포가 낸 글 → 계약 1 의 꼴, 아니면 null. **모양을 제한한다** — 지문 검사가 뚫려도 문맥에 이상한 글자가 안 들어가게(cellClean 과 같은 방어).
|
|
8113
|
+
* 숫자 칸(pid)은 등록부와 같이 숫자 문자열도 받는다.
|
|
8114
|
+
*/
|
|
8115
|
+
function cellSelfParse(stdout) {
|
|
8116
|
+
const t = String(stdout ?? '').trim();
|
|
8117
|
+
if (!t.startsWith('{') || t.includes('\n') || t.length > 4096) return null;
|
|
8118
|
+
let d;
|
|
8119
|
+
try { d = JSON.parse(t); } catch { return null; }
|
|
8120
|
+
if (!d || typeof d !== 'object' || Array.isArray(d)) return null;
|
|
8121
|
+
if (!['registry', 'transcript', 'none'].includes(d.source) || typeof d.live !== 'boolean') return null;
|
|
8122
|
+
if (d.name !== null && d.name !== undefined && !harnessNameOk(d.name)) return null;
|
|
8123
|
+
const pid = d.pid === null || d.pid === undefined ? null : harnessInt(d.pid);
|
|
8124
|
+
// 덧붙인 칸(2026-10-07) — 모르는 값이면 「모른다」 쪽으로: 열쇠 null(이름을 제 것으로 안 댄다) · 산 줄 수는 산가에서 · measured 는 칸이 있을 때만 거짓.
|
|
8125
|
+
const rows = harnessInt(d.live_rows);
|
|
8126
|
+
return {
|
|
8127
|
+
session: typeof d.session === 'string' && AUTO_UUID_RE.test(d.session) ? d.session.toLowerCase() : null,
|
|
8128
|
+
name: d.name ?? null,
|
|
8129
|
+
live: d.live,
|
|
8130
|
+
pid: pid !== null && pid > 0 ? pid : null,
|
|
8131
|
+
source: d.source,
|
|
8132
|
+
stale_name: harnessNameOk(d.stale_name) ? d.stale_name : null,
|
|
8133
|
+
checked_at: typeof d.checked_at === 'string' ? cellClean(d.checked_at).slice(0, 40) : '',
|
|
8134
|
+
matched_by: ['socket', 'pid', 'session'].includes(d.matched_by) ? d.matched_by : null,
|
|
8135
|
+
live_rows: rows !== null && rows >= 0 && rows < 1000 ? rows : (d.live ? 1 : 0),
|
|
8136
|
+
measured: d.measured !== false,
|
|
8137
|
+
};
|
|
8138
|
+
}
|
|
8139
|
+
/** 세포의 답 — `{ ok: true, v }` 또는 `{ ok: false, why }`(why: none·deaf·rest·timeout·bad·spawn). */
|
|
8140
|
+
function cellSelf() {
|
|
8141
|
+
const { bin } = cellCheck();
|
|
8142
|
+
if (!bin) return Promise.resolve({ ok: false, why: 'none' });
|
|
8143
|
+
const print = cellPrint(bin);
|
|
8144
|
+
if (print && print === cellSelfDeaf) return Promise.resolve({ ok: false, why: 'deaf' });
|
|
8145
|
+
if (Date.now() < cellSelfRestUntil) return Promise.resolve({ ok: false, why: 'rest' });
|
|
8146
|
+
return new Promise((resolve) => {
|
|
8147
|
+
let done = false;
|
|
8148
|
+
let timer = null;
|
|
8149
|
+
const finish = (v) => { if (done) return; done = true; if (timer) clearTimeout(timer); resolve(v); };
|
|
8150
|
+
let child;
|
|
8151
|
+
try {
|
|
8152
|
+
child = execFile(bin, ['agents', '--self', '--json', '--since', '0'],
|
|
8153
|
+
{ encoding: 'utf8', maxBuffer: 64 * 1024, cwd: os.tmpdir(), windowsHide: true },
|
|
8154
|
+
(err, stdout) => {
|
|
8155
|
+
/*
|
|
8156
|
+
* ⚠ 실행 자체가 안 된 것(EAGAIN·ENOMEM·EACCES·ENOENT … — err.code 가 글)은 「못 알아들었다」가 아니다(2026-10-07 검증 SEC-5).
|
|
8157
|
+
* 전에는 이것도 deaf 로 적어, fork 한 번 실패에 그 세포를 판올림 전까지 다시 안 물었다. 잠깐(30초) 쉬고 다시 묻는다.
|
|
8158
|
+
*/
|
|
8159
|
+
if (err && typeof err.code === 'string' && err.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
|
|
8160
|
+
if (!done) cellSelfRestUntil = Date.now() + 30_000;
|
|
8161
|
+
finish({ ok: false, why: 'spawn' });
|
|
8162
|
+
return;
|
|
8163
|
+
}
|
|
8164
|
+
// 끝난 코드가 0 이 아니어도 계약 꼴의 한 줄이면 받는다 — 판정은 글이 한다(못 알아들은 판은 글이 계약 꼴이 아니다).
|
|
8165
|
+
const v = err?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ? null : cellSelfParse(stdout);
|
|
8166
|
+
if (!v) { if (!done) cellSelfDeaf = print; finish({ ok: false, why: 'bad' }); return; }
|
|
8167
|
+
finish({ ok: true, v });
|
|
8168
|
+
});
|
|
8169
|
+
} catch { cellSelfRestUntil = Date.now() + 30_000; finish({ ok: false, why: 'spawn' }); return; }
|
|
7232
8170
|
/*
|
|
7233
|
-
*
|
|
7234
|
-
*
|
|
8171
|
+
* 1초는 **자리를 비우는 시각**이다 — execFile 의 timeout 은 죽인 뒤에도 그 자식이 물려준 stdout 이 닫혀야 답을 준다(손자 프로세스가
|
|
8172
|
+
* 파이프를 쥐면 그만큼 늦는다). 그래서 따로 잰다: 넘으면 죽이고 곧바로 JS 사본으로 간다.
|
|
7235
8173
|
*/
|
|
7236
|
-
|
|
7237
|
-
|
|
8174
|
+
timer = setTimeout(() => {
|
|
8175
|
+
try { child.kill('SIGKILL'); } catch { /* 이미 끝났다 */ }
|
|
8176
|
+
cellSelfRestUntil = Date.now() + 5 * 60_000;
|
|
8177
|
+
finish({ ok: false, why: 'timeout' });
|
|
8178
|
+
}, CELL_SELF_TIMEOUT_MS);
|
|
8179
|
+
timer.unref?.();
|
|
8180
|
+
});
|
|
8181
|
+
}
|
|
8182
|
+
/** 이 프로세스의 지금 주소(계약 1 의 꼴 + `via`) — 세포가 먼저, 못 하면 JS 사본. **부를 때마다 새로 푼다**(이름은 보내기 직전에). */
|
|
8183
|
+
async function selfAddress() {
|
|
8184
|
+
const c = await cellSelf();
|
|
8185
|
+
if (c.ok) return { ...c.v, via: 'cell' };
|
|
8186
|
+
return { ...selfAddressJs(), via: 'js', cell: c.why };
|
|
8187
|
+
}
|
|
8188
|
+
|
|
8189
|
+
/**
|
|
8190
|
+
* 전사의 `[from, size)` 에서 **마지막** 자기소개를 끝에서부터 거슬러 찾는다. `{ name, end, at }` — `end` 는 마지막 개행 바로 뒤
|
|
8191
|
+
* (쓰는 중인 반쪽 줄은 다음 차례에 그 자리부터 다시 읽는다), `at` 은 그 레코드의 timestamp(ms · 없거나 못 읽으면 0).
|
|
8192
|
+
* `at` 은 「그 자기소개를 지금 프로세스가 냈나」를 가르는 데 쓴다(`myAgentName` ②) — 0 이면 가를 수 없으니 지금 것으로 안 친다.
|
|
8193
|
+
* ⚠ 전에는 읽을 양이 64MB 이상이면 **통째로 건너뛰었다**(전사 최대 7MB 가정). 그런데 그림이 base64 로 실려 전사가 253MB
|
|
8194
|
+
* 까지 간다(2026-10-07 실측 — 이 MCP 를 띄운 세션). 첫 읽기가 늘 건너뛰어져 이름이 영영 「없음」이 됐고, mail 이 「이 세션에는
|
|
8195
|
+
* 말 걸 수 있는 이름이 없다」로 거절했다 — ListAgents 를 이미 부른 뒤였는데도. 마지막 것만 필요하니 끝에서 거스르면 된다.
|
|
8196
|
+
* ⚠ 줄 고르기는 세포 `last_intro_in` 과 **글자 하나까지** 같다(이름 관문 2026-10-07 — 픽스처 11-giant-line): 1MiB 를 넘는 줄은 자기소개로
|
|
8197
|
+
* 안 치고 이어 붙이지도 않는다. 전에는 그 줄을 읽어(세포는 건너뛰어) 같은 전사에 두 자가 다른 옛 이름을 냈고, 큰 줄을 조각마다 다시
|
|
8198
|
+
* 이어 붙여 복사가 제곱으로 늘었다. 조각은 쌓아 두었다가 줄이 온전해질 때 한 번 잇는다. 맨 끝의 반쪽 줄은 안 쌓는다(어차피 안 읽는다).
|
|
8199
|
+
*/
|
|
8200
|
+
function lastIntroInRange(fd, from, size, chunk = 8 * 1024 * 1024) {
|
|
8201
|
+
chunk = Math.max(1, chunk);
|
|
8202
|
+
let pos = size;
|
|
8203
|
+
let end = -1;
|
|
8204
|
+
let carry = []; // 뒤에서 이어지는 줄의 조각들(파일에서 뒤쪽 조각이 앞에) — 그 줄의 머리는 아직 안 읽은 앞쪽에 있다
|
|
8205
|
+
let carryLen = 0;
|
|
8206
|
+
let skipping = true; // 지금 거슬러 가는 줄을 버리는 중인가 — 처음엔 맨 끝의 쓰는 중인 반쪽 줄, 그 뒤엔 상한을 넘은 큰 줄
|
|
8207
|
+
const found = (h) => ({ name: h.name, end, at: h.at });
|
|
8208
|
+
while (pos > from) {
|
|
8209
|
+
const start = Math.max(from, pos - chunk);
|
|
8210
|
+
const buf = Buffer.allocUnsafe(pos - start);
|
|
8211
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
8212
|
+
pos = start;
|
|
8213
|
+
const lastNl = buf.lastIndexOf(0x0a);
|
|
8214
|
+
if (lastNl < 0) { // 조각 전체가 한 줄의 가운데
|
|
8215
|
+
if (!skipping) {
|
|
8216
|
+
carry.push(buf);
|
|
8217
|
+
carryLen += buf.length;
|
|
8218
|
+
if (carryLen > INTRO_LINE_MAX) { carry = []; carryLen = 0; skipping = true; }
|
|
8219
|
+
}
|
|
8220
|
+
continue;
|
|
7238
8221
|
}
|
|
7239
|
-
|
|
8222
|
+
if (end < 0) end = start + lastNl + 1; // 마지막 개행 바로 뒤 — 다음 이어 읽기의 자리
|
|
8223
|
+
// ① 마지막 개행 뒤 + 뒤에서 이어지던 조각들 = 이제 머리까지 다 읽은 한 줄
|
|
8224
|
+
if (!skipping && buf.length - (lastNl + 1) + carryLen <= INTRO_LINE_MAX) {
|
|
8225
|
+
const h = introOfLine(Buffer.concat([buf.subarray(lastNl + 1), ...carry.reverse()]));
|
|
8226
|
+
if (h) return found(h);
|
|
8227
|
+
}
|
|
8228
|
+
carry = [];
|
|
8229
|
+
carryLen = 0;
|
|
8230
|
+
skipping = false;
|
|
8231
|
+
// ② 이 조각 안의 온전한 줄들 — 끝에서부터
|
|
8232
|
+
const firstNl = buf.indexOf(0x0a);
|
|
8233
|
+
const h2 = introInLines(buf.subarray(firstNl + 1, lastNl + 1));
|
|
8234
|
+
if (h2) return found(h2);
|
|
8235
|
+
// ③ 맨 앞 줄(첫 개행까지) — 출발점(파일 맨 앞 · 이어 읽기의 자리)이면 온전하고, 아니면 머리가 더 앞 조각에 있다
|
|
8236
|
+
const head = buf.subarray(0, firstNl + 1);
|
|
8237
|
+
if (start === from) {
|
|
8238
|
+
const h3 = introInLines(head);
|
|
8239
|
+
if (h3) return found(h3);
|
|
8240
|
+
} else if (head.length > INTRO_LINE_MAX) {
|
|
8241
|
+
skipping = true;
|
|
8242
|
+
} else {
|
|
8243
|
+
carry.push(head);
|
|
8244
|
+
carryLen = head.length;
|
|
8245
|
+
}
|
|
8246
|
+
}
|
|
8247
|
+
// 출발점의 첫 줄이 조각보다 길었다 — 그 머리가 출발점이다
|
|
8248
|
+
if (!skipping && carry.length) {
|
|
8249
|
+
const h = introOfLine(Buffer.concat(carry.reverse()));
|
|
8250
|
+
if (h) return found(h);
|
|
8251
|
+
}
|
|
8252
|
+
return { name: null, end: end < 0 ? from : end, at: 0 };
|
|
8253
|
+
}
|
|
8254
|
+
/** 자기소개 레코드 한 줄의 상한(세포 `INTRO_LINE_MAX`) — 실제 자기소개 줄은 2.7~4.5KB(2026-10-07 실측 35건). 더 긴 줄(그림 base64)은 자기소개가 아니다. */
|
|
8255
|
+
const INTRO_LINE_MAX = 1 << 20;
|
|
8256
|
+
/** 한 줄(바이트)이 하네스가 낸 자기소개 레코드면 `{ name, at }`(세포 `intro_of_line` — 상한 · 머리 글자 · 레코드 모양 차례 그대로). */
|
|
8257
|
+
function introOfLine(line) {
|
|
8258
|
+
if (!line.length || line.length > INTRO_LINE_MAX || !line.includes(INTRO_HEAD)) return null;
|
|
8259
|
+
const s = line.toString('utf8'); // 깨진 바이트는 U+FFFD — 세포의 from_utf8_lossy 와 같다
|
|
8260
|
+
const name = introNameOf(s);
|
|
8261
|
+
if (!name) return null;
|
|
8262
|
+
let at = 0;
|
|
8263
|
+
try { at = Date.parse(JSON.parse(s).timestamp) || 0; } catch { /* 시각이 없으면 0 */ }
|
|
8264
|
+
return { name, at };
|
|
8265
|
+
}
|
|
8266
|
+
/** 개행으로 끝나는 줄 묶음에서 **끝에서부터** 자기소개를 찾는다(세포 `intro_in_lines`). 줄은 바이트로 자른다 — 상한도 바이트다. */
|
|
8267
|
+
function introInLines(body) {
|
|
8268
|
+
if (!body.includes(INTRO_HEAD)) return null; // 값싼 검사로 먼저 거른다
|
|
8269
|
+
let e = body.length;
|
|
8270
|
+
if (e > 0 && body[e - 1] === 0x0a) e--;
|
|
8271
|
+
for (;;) {
|
|
8272
|
+
const s = e > 0 ? body.lastIndexOf(0x0a, e - 1) + 1 : 0;
|
|
8273
|
+
const h = introOfLine(body.subarray(s, e));
|
|
8274
|
+
if (h) return h;
|
|
8275
|
+
if (s === 0) return null;
|
|
8276
|
+
e = s - 1;
|
|
7240
8277
|
}
|
|
8278
|
+
}
|
|
8279
|
+
|
|
8280
|
+
let nameCache = null; // { file, size, name, at }
|
|
8281
|
+
/** 세션 uuid 의 전사(findTranscript — Codex·Cursor 포함)의 마지막 자기소개. 전사가 없으면 null. `myAgentName` 이 쓴다(Cursor 판정에 file 이 든다). */
|
|
8282
|
+
function introOf(uuid) {
|
|
8283
|
+
const f = transcriptOf(uuid);
|
|
8284
|
+
return f ? introOfFile(f) : null;
|
|
8285
|
+
}
|
|
8286
|
+
/**
|
|
8287
|
+
* 세션 uuid → 그 세션 **본 전사**(세포의 규칙 — 설정 폴더 먼저 · `claudeTranscriptIn`)의 마지막 자기소개 이름. 없으면 null.
|
|
8288
|
+
* 세포 `claude_transcript_of` + `agent_name_of` 의 사본 — `selfAddressJs` 의 옛 이름 칸이 이것을 쓴다(이름 관문).
|
|
8289
|
+
*/
|
|
8290
|
+
function claudeIntroName(sid, env = process.env) {
|
|
8291
|
+
const f = claudeTranscriptIn(claudeProjectBases(env), sid);
|
|
8292
|
+
return f ? (introOfFile(f)?.name ?? null) : null;
|
|
8293
|
+
}
|
|
8294
|
+
/** 전사 파일 하나의 마지막 자기소개 `{ name, at, file }`(name 없으면 null · at = 그 레코드의 timestamp ms). 못 읽으면 null. 늘어난 부분만 다시 읽는다(한 칸 캐시). */
|
|
8295
|
+
function introOfFile(f) {
|
|
7241
8296
|
let st;
|
|
7242
8297
|
try { st = fs.statSync(f); } catch { return null; }
|
|
7243
|
-
if (nameCache?.file === f && nameCache.size === st.size) return nameCache.name;
|
|
8298
|
+
if (nameCache?.file === f && nameCache.size === st.size) return { name: nameCache.name, at: nameCache.at, file: f };
|
|
7244
8299
|
const from = (nameCache?.file === f && st.size > nameCache.size) ? nameCache.size : 0;
|
|
7245
|
-
let found =
|
|
8300
|
+
let found = from > 0 ? nameCache.name : null;
|
|
8301
|
+
let at = from > 0 ? nameCache.at : 0;
|
|
8302
|
+
let end = from;
|
|
7246
8303
|
try {
|
|
7247
8304
|
const fd = fs.openSync(f, 'r');
|
|
7248
8305
|
try {
|
|
7249
|
-
|
|
7250
|
-
|
|
7251
|
-
|
|
7252
|
-
|
|
7253
|
-
|
|
7254
|
-
// 줄 단위 — 마지막 것이 이긴다(옛 이름은 죽은 주소다).
|
|
7255
|
-
// ⚠ 쓰는 중인 마지막 줄(개행 없음)은 이번에 안 읽고 다음에 그 자리부터 다시 읽는다 —
|
|
7256
|
-
// 캐시 크기를 마지막 개행까지만 전진시킨다. 안 그러면 잘린 줄이 영영 사라진다.
|
|
7257
|
-
const cut = txt.lastIndexOf('\n');
|
|
7258
|
-
const whole = cut >= 0 ? txt.slice(0, cut + 1) : '';
|
|
7259
|
-
for (const line of whole.split('\n')) {
|
|
7260
|
-
const n = introNameOf(line);
|
|
7261
|
-
if (n) found = n;
|
|
7262
|
-
}
|
|
7263
|
-
if (!found && (isCursorTranscript(f) || uuid === cursorActiveComposer()
|
|
7264
|
-
|| process.env.CURSOR_CONVERSATION_ID === uuid)) {
|
|
7265
|
-
found = cursorAliasOf(uuid);
|
|
7266
|
-
}
|
|
7267
|
-
nameCache = { file: f, size: from + Buffer.byteLength(whole, 'utf8'), name: found };
|
|
7268
|
-
return found;
|
|
7269
|
-
}
|
|
8306
|
+
// 마지막 것이 이긴다(옛 이름은 죽은 주소다) — 그래서 끝에서 거슬러 첫 자기소개에서 멈춘다(크기 상한 없음).
|
|
8307
|
+
// ⚠ 쓰는 중인 마지막 줄(개행 없음)은 이번에 안 읽고 다음에 그 자리부터 다시 읽는다(`end` 가 마지막 개행 바로 뒤).
|
|
8308
|
+
const r = lastIntroInRange(fd, from, st.size);
|
|
8309
|
+
if (r.name) { found = r.name; at = r.at || 0; }
|
|
8310
|
+
end = r.end;
|
|
7270
8311
|
} finally { fs.closeSync(fd); }
|
|
7271
8312
|
} catch { /* 못 읽으면 이름이 없는 것이지 고장이 아니다 */ }
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
|
|
8313
|
+
nameCache = { file: f, size: end, name: found, at };
|
|
8314
|
+
return { name: found, at, file: f };
|
|
8315
|
+
}
|
|
8316
|
+
|
|
8317
|
+
/** 이 MCP 프로세스가 뜬 때(ms) — 하네스가 띄우므로 그 claude 프로세스보다 늦다. 그 뒤에 적힌 자기소개는 지금 프로세스가 낸 것이다. */
|
|
8318
|
+
const BOOT_MS = Math.floor(performance.timeOrigin);
|
|
8319
|
+
|
|
8320
|
+
/**
|
|
8321
|
+
* 서버 메일 주소록이 받는 이름인가 — 서버 `mail_name_ok`(www/portal/lib/mail.php)와 같은 규칙: 1~60자 · 공백류·제어문자·[ ] → < > 없음 ·
|
|
8322
|
+
* **이름 없는 세션의 발신 표시 꼴(uuid 앞 8자 — 16진 8자)과 uuid 꼴이 아니다**(2026-10-07 검증 SEC-3: 남이 그 꼴을 이름으로 대면 이름 없는
|
|
8323
|
+
* 세션과 머리 줄이 같아져 사칭이 되고, 그 표시로 한 답장이 사칭한 쪽으로 풀렸다).
|
|
8324
|
+
*/
|
|
8325
|
+
function mailNameOk(s) {
|
|
8326
|
+
return typeof s === 'string' && s !== '' && [...s].length <= 60
|
|
8327
|
+
&& !/[\s\u0000-\u001F\u007F-\u009F\u2028\u2029[\]→<>]/u.test(s)
|
|
8328
|
+
&& !/^[0-9a-f]{8}$/i.test(s) && !/^[0-9a-f-]{36}$/i.test(s);
|
|
8329
|
+
}
|
|
8330
|
+
|
|
8331
|
+
/** ①로 확인한 이 프로세스의 마지막 이름(이 MCP 프로세스 안에서만) — ④가 쓴다. */
|
|
8332
|
+
const mailNameSeen = { session: '', name: '' };
|
|
8333
|
+
|
|
8334
|
+
/**
|
|
8335
|
+
* 서버 주소록에 댈 이름과 이 프로세스의 지금 메일 주소 — `{ name, why, addr, cur }`. name '' 은 「이름 없이 댄다」(받기는 된다 · 보낼 때
|
|
8336
|
+
* 발신 표시는 uuid 앞 8자).
|
|
8337
|
+
* cur — 이 프로세스의 지금 세션 uuid. 세포(또는 JS 사본)가 **소켓 열쇠로** 찾은 산 줄의 세션이다 — 하네스가 이 MCP 에게만 준 소켓이니 그
|
|
8338
|
+
* 줄이 이 프로세스다. `/clear` 뒤에는 env 의 세션 id(mine — 뜰 때 값)와 다르다: 하네스는 /clear 에 등록부 줄의 sessionId 만 바꾼다
|
|
8339
|
+
* (검증 F2·SEC-7). 소켓으로 못 찾았으면 mine.
|
|
8340
|
+
* 대는 이름은 **지금 이 프로세스의 이름이라고 확인된 것**뿐이다:
|
|
8341
|
+
* ① 등록부의 산 줄 — 그 줄을 **이 프로세스의 열쇠**로 찾았을 때만: 소켓이거나, 그 줄의 세션이 mine 이다. CLAUDE_PID 로만 찾은 남의 세션
|
|
8342
|
+
* 줄은 아니다 — 하네스는 MCP 에 CLAUDE_PID 를 안 주니(Bash·훅에만 준다) MCP env 의 그 값은 조상 claude 의 Bash 에서 물려받은 것이다
|
|
8343
|
+
* (SEC-2: Bash 에서 띄운 `claude -p` 의 MCP 가 부모의 이름을 제 uuid 에 댈 뻔했다).
|
|
8344
|
+
* 그 세션 uuid 의 산 줄이 **둘 이상**이면(한 대화를 창 여럿이 띄움) 이름 없이 댄다 — 창마다 이름이 달라 두 MCP 가 같은 서버 줄에 번갈아
|
|
8345
|
+
* 이름을 대고(L1), 그 uuid 앞 통은 한 편지함이라 어느 창이 받을지 정해지지 않는다(SEC-1). 두 MCP 가 같은 판정을 내니 이름이 안 뒤집힌다.
|
|
8346
|
+
* ② 전사의 자기소개 — **이 MCP 가 뜬 뒤에** 적힌 하네스 ListAgents 결과만(`introNameOf` 가 짝 표식 toolUseResult.listing 을 본다 — SEC-6).
|
|
8347
|
+
* MCP 는 claude 프로세스가 띄우므로 그 뒤에 적힌 자기소개는 지금 프로세스가 낸 것이다. 그 전 것은 다시 뜨기 전 이름일 수 있고, 그
|
|
8348
|
+
* 이름은 지금 남의 것일 수 있다(7afd1d02 의 hnl-73 = 지금 110cd958 — 검증 2026-10-07). 등록부가 없는 옛 하네스·잴 수 없는 판
|
|
8349
|
+
* (Windows)에서 ListAgents 를 부르면 이름이 서는 길이다.
|
|
8350
|
+
* ③ Cursor 대화의 별칭 cursor-<uuid 앞 2자> — uuid 에서 나오는 값이라 낡지 않는다. 전사도 ListAgents 도 없는 자리다.
|
|
8351
|
+
* ④ 이번에 **못 쟀으면**(measured 거짓 — 세포 시간 초과 뒤 JS 사본의 ps 가 실패 · 시작 시각을 못 잼) 이 MCP 가 ①로 마지막에 확인한 이름을
|
|
8352
|
+
* 다시 댄다(SEC-5 — 일시 실패가 서버의 이름 비움 → 같은 이름을 대는 남이 가져감 → 다음 등록은 held 로 번지지 않게). 이름은 프로세스마다
|
|
8353
|
+
* 정해지니 같은 MCP 프로세스(= 같은 claude 프로세스) 안의 확인은 유효하다. 확인한 적이 없으면 이름 없이.
|
|
8354
|
+
* 그리고 서버가 받는 꼴(`mailNameOk`)이어야 한다 — 아니면 이름 없이 댄다(꼴 때문에 등록이 400 이면 보내기까지 막힌다).
|
|
8355
|
+
* Native Codex 는 늘 이름 없이 댄다 — 서버가 등록된 uuid 로 발신 표시를 정한다(이름을 지어내지 않는다).
|
|
8356
|
+
*/
|
|
8357
|
+
async function myAgentName(mine = mySessionId()) {
|
|
8358
|
+
if (!AUTO_UUID_RE.test(String(mine || ''))) return { name: '', why: '이 세션의 번호를 모른다', addr: null, cur: mine };
|
|
8359
|
+
if (!process.env.CLAUDE_CODE_SESSION_ID && mine === codexSessionId()) {
|
|
8360
|
+
return { name: '', why: 'Codex 세션은 이름 없이 댄다', addr: null, cur: mine };
|
|
8361
|
+
}
|
|
8362
|
+
const addr = await selfAddress();
|
|
8363
|
+
const rowSession = typeof addr.session === 'string' && AUTO_UUID_RE.test(addr.session) ? addr.session.toLowerCase() : '';
|
|
8364
|
+
const liveRow = addr.source === 'registry' && addr.live && !!rowSession;
|
|
8365
|
+
const own = liveRow && (addr.matched_by === 'socket' || rowSession === mine);
|
|
8366
|
+
// 소켓은 하네스가 이 MCP 에게만 준 열쇠다 — 그 줄이 이 프로세스다. 떠 있는지 이번에 못 쟀어도(우리는 떠 있다) 지금 세션은 그 줄의 것이다.
|
|
8367
|
+
const cur = addr.matched_by === 'socket' && rowSession ? rowSession : mine;
|
|
8368
|
+
let name = '';
|
|
8369
|
+
let why = '';
|
|
8370
|
+
if (own) {
|
|
8371
|
+
const windows = Number(addr.live_rows) || 1;
|
|
8372
|
+
if (windows >= 2) {
|
|
8373
|
+
why = `이 세션 uuid 를 지금 창 ${windows}곳이 함께 띄우고 있다 — 서버 주소록은 uuid 하나에 이름 하나라 창마다 다른 이름이 번갈아 서고, `
|
|
8374
|
+
+ '그 uuid 앞 통은 한 편지함이라 어느 창이 받을지 정해지지 않는다(한 창에 닿으려면 SendMessage 에 그 창의 이름)';
|
|
8375
|
+
} else if (addr.name) {
|
|
8376
|
+
name = addr.name;
|
|
8377
|
+
mailNameSeen.session = cur;
|
|
8378
|
+
mailNameSeen.name = name;
|
|
8379
|
+
} else {
|
|
8380
|
+
why = '하네스 등록부에 이 프로세스의 산 줄은 있는데 이름이 비었거나 꼴 밖이다(제어문자만 걷고 65자까지)';
|
|
8381
|
+
}
|
|
8382
|
+
} else {
|
|
8383
|
+
const intro = introOf(mine);
|
|
8384
|
+
if (intro?.name && intro.at >= BOOT_MS) {
|
|
8385
|
+
name = intro.name;
|
|
8386
|
+
} else if (isCursorTranscript(intro?.file) || mine === cursorActiveComposer() || process.env.CURSOR_CONVERSATION_ID === mine) {
|
|
8387
|
+
name = cursorAliasOf(mine);
|
|
8388
|
+
} else if (addr.measured === false && mailNameSeen.session === cur && mailNameSeen.name) {
|
|
8389
|
+
name = mailNameSeen.name; // ④ — 이번엔 못 쟀을 뿐이다(조용히)
|
|
8390
|
+
} else if (liveRow) {
|
|
8391
|
+
why = `등록부에서 찾은 산 줄(세션 ${rowSession.slice(0, 8)})은 이 MCP 의 세션이 아니고 CLAUDE_PID 로만 찾았다 — 그 값은 하네스가 MCP 에 `
|
|
8392
|
+
+ '주지 않는 값(조상 프로세스의 것)이라 그 줄의 이름을 이 세션의 이름으로 대지 않는다';
|
|
8393
|
+
} else if (addr.measured === false) {
|
|
8394
|
+
why = `하네스 등록부에서 이 프로세스의 줄이 떠 있는지 이번에 못 쟀다(${process.platform === 'win32' ? '이 판은 pid 를 못 잰다' : '시작 시각을 못 맞췄다'})`;
|
|
8395
|
+
} else if (intro?.name) {
|
|
8396
|
+
why = `전사의 마지막 자기소개 «${cellClean(intro.name)}» 는 이 프로세스가 뜨기 전 것이라 지금 이름이라고 못 한다(다시 뜨면 이름이 바뀐다)`;
|
|
8397
|
+
} else {
|
|
8398
|
+
why = `하네스 등록부(${harnessSessionsDir()})에 이 프로세스의 산 줄이 없고 전사에도 자기소개가 없다`;
|
|
8399
|
+
}
|
|
8400
|
+
}
|
|
8401
|
+
if (name && !mailNameOk(name)) {
|
|
8402
|
+
why = `지금 이름 «${cellClean(name)}» 은 메일 주소록이 받는 꼴이 아니다(60자까지 · 공백·[ ] → < > 없음 · 16진 8자·uuid 꼴 아님)`;
|
|
8403
|
+
name = '';
|
|
7275
8404
|
}
|
|
7276
|
-
|
|
7277
|
-
|
|
8405
|
+
return { name, why, addr, cur };
|
|
8406
|
+
}
|
|
8407
|
+
|
|
8408
|
+
/**
|
|
8409
|
+
* 이 프로세스의 메일 주소 한 벌을 **한 번** 푼다(주소 계약 2 · 2026-10-07 검증 F2·SEC-7) — `{ pick, cur, old }`.
|
|
8410
|
+
* pick — `myAgentName(mine)` 의 답(이름·까닭·세포 답). cur — 지금 세션(위 cur — /clear 를 따라간다). 보내기(session)·주소 대기·받기의 주인이다.
|
|
8411
|
+
* old — /clear 앞의 env 세션(mine — cur 과 같으면 null). 이미 나간 답장 주소(from_session)·받는 세션 줄(to_session)이 그 uuid 라, 이름 없이
|
|
8412
|
+
* 주소를 유지하고 긷는다 — 단 **다른 창이 그 대화를 이어 열었으면**(등록부에 old 의 산 줄이 있다) 그 창의 MCP 가 주인이라 손을 뗀다
|
|
8413
|
+
* (두 프로세스가 한 편지함을 긷지 않게 — SEC-1). 하네스가 env 를 안 바꾸니 이 MCP 가 아는 것은 이 둘뿐이다.
|
|
8414
|
+
*/
|
|
8415
|
+
async function mailSelf(mine = mySessionId()) {
|
|
8416
|
+
const pick = await myAgentName(mine);
|
|
8417
|
+
const cur = AUTO_UUID_RE.test(String(pick.cur || '')) ? String(pick.cur).toLowerCase() : mine;
|
|
8418
|
+
const old = cur !== mine && AUTO_UUID_RE.test(String(mine || '')) && !mailOldHeldElsewhere(mine) ? mine : null;
|
|
8419
|
+
const v = { pick, cur, old };
|
|
8420
|
+
mailSelfMemo = { at: Date.now(), mine, v };
|
|
8421
|
+
return v;
|
|
8422
|
+
}
|
|
8423
|
+
/**
|
|
8424
|
+
* 받기 바퀴(mailPull)가 쓰는 주소 — 30초 안에 푼 것이 있으면 그대로 쓴다. 주소 대기(mailRegister)는 소속처마다 60초에 한 번이라 받기 바퀴마다
|
|
8425
|
+
* 세포를 새로 부를 까닭이 없다(세포의 --self 는 그 세션 전사의 끝을 거슬러 읽는다 — 자기소개가 없는 큰 전사는 끝까지 간다). 보내기(mail 도구)는
|
|
8426
|
+
* 늘 새로 푼다(mailSelf). /clear 를 알아채는 것은 길어야 30초 늦다.
|
|
8427
|
+
*/
|
|
8428
|
+
let mailSelfMemo = { at: 0, mine: '', v: null };
|
|
8429
|
+
async function mailSelfRecent(mine = mySessionId()) {
|
|
8430
|
+
if (mailSelfMemo.v && mailSelfMemo.mine === mine && Date.now() - mailSelfMemo.at < 30_000) return mailSelfMemo.v;
|
|
8431
|
+
return mailSelf(mine);
|
|
8432
|
+
}
|
|
8433
|
+
/** 그 세션 uuid 를 지금 **다른 프로세스**가 띄우고 있나 — 등록부의 산 줄(JS 사본으로 잰다 · 못 재면 「아니다」). */
|
|
8434
|
+
function mailOldHeldElsewhere(sid) {
|
|
8435
|
+
try {
|
|
8436
|
+
return !!harnessLiveRow(harnessRowsIn(harnessSessionsDir()), harnessProbe(), (r) => r.session === sid);
|
|
8437
|
+
} catch { return false; }
|
|
7278
8438
|
}
|
|
7279
8439
|
|
|
7280
8440
|
/** 이 세션이 메일을 다룰 조건을 갖췄나. 못 갖췄으면 `null` — 조용히 안 한다. */
|
|
@@ -7309,20 +8469,132 @@ function mailReady() {
|
|
|
7309
8469
|
/**
|
|
7310
8470
|
* 주소를 **스스로 댄다.** 관측 추정이 아니다(052 머리말의 세 실측).
|
|
7311
8471
|
*
|
|
7312
|
-
* 이름이 없어도 등록한다 — 받을 수는 있어야
|
|
7313
|
-
|
|
7314
|
-
|
|
8472
|
+
* 이름이 없어도 등록한다 — 받을 수는 있어야 하고, 이제 보낼 수도 있다(발신 표시는 서버가 uuid 앞 8자로 세운다).
|
|
8473
|
+
* 이름은 **대기 직전에** 푼다(`mailSelf` → `myAgentName` — 세포 먼저, 못 하면 JS 사본). 돌려주는 것은 서버 응답에 `sentName`(실제로 댄 이름,
|
|
8474
|
+
* 없으면 '')·`nameWhy`(이름 없이 댄 까닭)·`cur`(댄 세션)·`old`(/clear 앞 세션 — 이름 없이 함께 댄 것)를 얹은 것이다 — 보낸 결과의 안내가
|
|
8475
|
+
* 그 사실대로 말한다(`mailAddrNote`).
|
|
8476
|
+
* ★ 대는 세션은 **지금 세션(cur)** 이다 — /clear 뒤에는 env 의 세션 id(mine)가 아니다(F2: 세포·세션 시작 안내가 남에게 주는 uuid 가 cur 인데
|
|
8477
|
+
* 주소록에는 mine 만 있어 그 uuid 앞 통이 404 였다). /clear 앞 세션(old)은 **먼저** 이름 없이 댄다 — 그 줄에 이 프로세스의 이름이 남아
|
|
8478
|
+
* 있으면 cur 의 같은 이름이 「살아 있는 다른 세션이 쥐었다」(held)로 막힌다(같은 사람의 두 줄도 서버는 다른 세션으로 본다).
|
|
8479
|
+
* ⚠ 이름 때문에 거절되면(400) **이름 없이 한 번 더** 댄다 — 등록이 막히면 보내기도 막힌다(서버 이름 규칙이 바뀌어도 벙어리가 안 되게).
|
|
8480
|
+
* `ident` 는 부르는 쪽이 이미 푼 `mailSelf()` 답(한 바퀴에 세포를 한 번만 부르려고) — 없으면 여기서 푼다.
|
|
8481
|
+
*/
|
|
8482
|
+
async function mailRegister(tenant, mine, force = false, ident = null) {
|
|
7315
8483
|
if (!force && Date.now() - (mailAddrAt.get(tenant) || 0) < MAIL_POLL_MS) return null;
|
|
7316
8484
|
mailAddrAt.set(tenant, Date.now());
|
|
7317
|
-
const
|
|
7318
|
-
|
|
8485
|
+
const id = ident || await mailSelf(mine);
|
|
8486
|
+
const body = (session, name) => ({
|
|
7319
8487
|
tenant,
|
|
7320
|
-
session
|
|
7321
|
-
name
|
|
8488
|
+
session,
|
|
8489
|
+
name,
|
|
7322
8490
|
// 신원이 어디서 왔는지 그대로 말한다 — Cursor 대화에 claude-code 이름표를 달지 않는다.
|
|
7323
8491
|
client: mine === codexSessionId() ? 'codex' : (mine === cursorActiveComposer() ? 'cursor' : 'claude-code'),
|
|
7324
8492
|
cwd: relHome(process.cwd()),
|
|
7325
8493
|
});
|
|
8494
|
+
if (id.old) {
|
|
8495
|
+
try { await api('POST', '/api/mail/register', body(id.old, '')); } catch { /* 옛 대화의 주소가 막혀도 지금 세션은 댄다 */ }
|
|
8496
|
+
}
|
|
8497
|
+
let sent = id.pick.name;
|
|
8498
|
+
let why = id.pick.why;
|
|
8499
|
+
let r;
|
|
8500
|
+
try {
|
|
8501
|
+
r = await api('POST', '/api/mail/register', body(id.cur, sent));
|
|
8502
|
+
} catch (e) {
|
|
8503
|
+
if (!(sent && e?.status === 400)) throw e;
|
|
8504
|
+
why = `지금 이름 «${cellClean(sent)}» 을 서버가 받지 않았다(${cellClean(e.message).slice(0, 120)})`;
|
|
8505
|
+
sent = '';
|
|
8506
|
+
r = await api('POST', '/api/mail/register', body(id.cur, ''));
|
|
8507
|
+
}
|
|
8508
|
+
return { ...(r && typeof r === 'object' ? r : {}), sentName: sent, nameWhy: why, cur: id.cur, old: id.old };
|
|
8509
|
+
}
|
|
8510
|
+
|
|
8511
|
+
/**
|
|
8512
|
+
* 뜰 때 **아는 소속처에 주소를 한 번 댄다**(계약 2 · 2026-10-07). 전에는 genetics 도구를 불러야만 댔다(tools/call 의 mailPull →
|
|
8513
|
+
* mailRegister) — 다시 뜬 세션은 첫 호출 전까지 서버 주소록에 옛 이름으로 남아, 새 이름 앞 통은 404 였고 옛 이름 앞 통은 이 세션으로
|
|
8514
|
+
* 왔다(검증 2026-10-07 remaining_failures — 9592 · 4163). 아는 소속처 = `mailReady()` 의 것(뜰 때는 색인의 소속처뿐이다).
|
|
8515
|
+
* Native Codex 는 뺀다 — 자기 일정 표의 'mail' 행(codexMailPullOne)이 뜰 때 이미 이름 없이 댄다. 주소(이름)는 한 번만 푼다.
|
|
8516
|
+
*/
|
|
8517
|
+
async function mailRegisterBoot() {
|
|
8518
|
+
const r0 = mailReady();
|
|
8519
|
+
if (!r0) return 0;
|
|
8520
|
+
if (!process.env.CLAUDE_CODE_SESSION_ID && r0.mine === codexSessionId()) return 0;
|
|
8521
|
+
const ident = await mailSelf(r0.mine);
|
|
8522
|
+
let n = 0;
|
|
8523
|
+
for (const [tenant] of r0.tenants) {
|
|
8524
|
+
try { await mailRegister(tenant, r0.mine, true, ident); n += 1; }
|
|
8525
|
+
catch { /* 한 소속처가 막혀도 나머지는 댄다 — 도구를 부르면 다시 댄다 */ }
|
|
8526
|
+
}
|
|
8527
|
+
return n;
|
|
8528
|
+
}
|
|
8529
|
+
|
|
8530
|
+
// 서버 lib/mail.php 의 MAIL_NAME_STATES 와 같은 낱말 — 늘리면 세 구현을 같이 고친다(그쪽 머리말).
|
|
8531
|
+
const MAIL_NAME_STATES = ['set', 'kept', 'held', 'cleared'];
|
|
8532
|
+
/**
|
|
8533
|
+
* 주소 등록이 **실제로 어떻게 됐나**를 한 줄로(할 말이 없으면 '') — 서버 응답의 사실대로.
|
|
8534
|
+
* 새 서버: `name_state`('set'|'kept'|'held'|'cleared') · `name`(그 줄에 실제로 남은 이름 — 서버가 표에서 다시 읽은 값) · `prev_name`.
|
|
8535
|
+
* 옛 서버: `held_by` 만 — 그때 서버 줄에는 예전 이름이 남을 수 있어(api.php 의 `IF(VALUES(name) IS NULL, name, …)`) 단정하지 않는다.
|
|
8536
|
+
* ⚠ 전에는 held 면 「이 통은 «uuid 앞 8자» 이름으로 나간다」고 단정했는데, 옛 서버는 그 줄의 옛 이름을 지켜 실제 발신 표시는 옛 이름이었다
|
|
8537
|
+
* (검증 2026-10-07 — 8ec9b061:www/portal/api.php:4163·4183·4208 · 서버는 이제 mail_store_register 에서 비운다). 발신 표시의 정본은 보낸 결과의 미리보기 첫 줄이다 — 서버가 받는 쪽에 그릴 그대로다.
|
|
8538
|
+
*/
|
|
8539
|
+
function mailAddrNote(reg, mine) {
|
|
8540
|
+
if (!reg || typeof reg !== 'object') return '';
|
|
8541
|
+
// 남이 닿을 주소 — 이 프로세스의 지금 세션(cur · /clear 를 따라간다). 옛 등록 응답(cur 칸 없음)이면 부르는 쪽의 mine.
|
|
8542
|
+
const me = typeof reg.cur === 'string' && AUTO_UUID_RE.test(reg.cur) ? reg.cur : mine;
|
|
8543
|
+
const old = typeof reg.old === 'string' && AUTO_UUID_RE.test(reg.old) ? reg.old : '';
|
|
8544
|
+
// /clear 로 대화가 바뀐 창 — 그 사실과 두 uuid 를 한 번 말한다(F2: 옛 uuid 로 부친 답장도 다른 창이 그 대화를 이어 열기 전까지 이 창이 긷는다).
|
|
8545
|
+
const moved = old ? `(이 창은 /clear 로 대화가 바뀌었다 — 이제 이 세션의 주소는 uuid ${me} 이다. 옛 uuid ${old} 앞 통도 다른 창이 그 대화를 `
|
|
8546
|
+
+ '이어 열기 전까지는 이 창이 긷는다.)\n' : '';
|
|
8547
|
+
const sent = typeof reg.sentName === 'string' ? reg.sentName : '';
|
|
8548
|
+
const why = typeof reg.nameWhy === 'string' ? reg.nameWhy : '';
|
|
8549
|
+
const state = MAIL_NAME_STATES.includes(reg.name_state) ? reg.name_state : '';
|
|
8550
|
+
const stored = state ? (typeof reg.name === 'string' ? cellClean(reg.name) : '') : null; // null = 모른다(옛 서버)
|
|
8551
|
+
const prev = state && typeof reg.prev_name === 'string' && reg.prev_name ? cellClean(reg.prev_name) : '';
|
|
8552
|
+
const held = state ? state === 'held' : !!reg.held_by;
|
|
8553
|
+
const holder = reg.held_by ? cellClean(String(reg.held_by)).slice(0, 8) : '';
|
|
8554
|
+
const book = (stored === null ? '서버 주소록에는 이 세션의 예전 이름이 남았을 수 있다(이 서버는 남은 이름을 안 알려 준다)'
|
|
8555
|
+
: (stored ? `서버 주소록에 남은 이 세션의 이름은 «${stored}» 이다` : '서버 주소록에는 이름 없이 섰다'))
|
|
8556
|
+
+ (prev ? ` · 옛 이름 «${prev}» 은 이제 이 세션으로 안 풀린다` : '');
|
|
8557
|
+
const show = '발신 표시는 아래 미리보기 첫 줄 그대로다';
|
|
8558
|
+
if (held) {
|
|
8559
|
+
return moved + `(이 세션이 댄 이름 «${cellClean(sent)}» 은 살아 있는 다른 세션${holder ? ` ${holder}` : ''} 이 쥐고 있어 못 댔다 — ${book}. `
|
|
8560
|
+
+ `${show}. 남이 이 세션에 닿으려면 uuid ${me}.)\n`;
|
|
8561
|
+
}
|
|
8562
|
+
if (!sent) {
|
|
8563
|
+
return moved + `(이 세션은 말 걸 수 있는 이름 없이 주소를 댔다${why ? ` — ${cellClean(why)}` : ''}. ${book}. ${show}. `
|
|
8564
|
+
+ `남이 이 세션에 닿으려면 uuid ${me}.)\n`;
|
|
8565
|
+
}
|
|
8566
|
+
if (stored !== null && stored !== sent) {
|
|
8567
|
+
return moved + `(이 세션이 댄 이름은 «${cellClean(sent)}» 인데 ${book}(name_state ${state}). ${show}.)\n`;
|
|
8568
|
+
}
|
|
8569
|
+
if (prev) return moved + `(이 세션의 이름은 이제 «${cellClean(sent)}» 이다 — 옛 이름 «${prev}» 은 이 세션으로 안 풀린다.)\n`;
|
|
8570
|
+
return moved;
|
|
8571
|
+
}
|
|
8572
|
+
|
|
8573
|
+
/** 보낸 결과의 **실제 받는 세션 uuid**(서버의 `to_session` — 같은 소속처 안의 값만 온다). 없거나 꼴이 아니면 ''(옛 서버). */
|
|
8574
|
+
function mailToSession(r) {
|
|
8575
|
+
const s = typeof r?.to_session === 'string' ? r.to_session.trim() : '';
|
|
8576
|
+
return AUTO_UUID_RE.test(s) ? s.toLowerCase() : '';
|
|
8577
|
+
}
|
|
8578
|
+
|
|
8579
|
+
/**
|
|
8580
|
+
* 받은 통의 **답장 주소**(보낸 세션 uuid) — 서버가 GET /api/mail 항목에 `from_session` 을 실어 주면 한 줄씩 덧붙인다(계약 3).
|
|
8581
|
+
* 발신 표시(uuid 앞 8자·그때의 이름)는 mail 의 to 로 안 풀리거나 남에게 간다 — 36자 uuid 만 그 세션으로 확실히 풀린다(mail_store_recipient — 8ec9b061:www/portal/lib/mail_store.php:171-172).
|
|
8582
|
+
* ⚠ 서버가 쓴 글자(`says`)는 손대지 않고 **뒤에** 붙인다 — 발신 표시를 짓는 자리는 서버의 mail_render() 하나뿐이다(아래 mailPullOne 머리말).
|
|
8583
|
+
* 이미 says 에 그 uuid 가 있으면 안 붙인다. 옛 서버(칸 없음)면 ''.
|
|
8584
|
+
*/
|
|
8585
|
+
function mailReplyHints(rows, says = '') {
|
|
8586
|
+
const seen = new Set();
|
|
8587
|
+
const low = String(says || '').toLowerCase();
|
|
8588
|
+
const out = [];
|
|
8589
|
+
for (const m of Array.isArray(rows) ? rows : []) {
|
|
8590
|
+
const sid = typeof m?.from_session === 'string' ? m.from_session.trim().toLowerCase() : '';
|
|
8591
|
+
if (!AUTO_UUID_RE.test(sid) || seen.has(sid) || low.includes(sid)) continue;
|
|
8592
|
+
seen.add(sid);
|
|
8593
|
+
const who = cellClean(m.from_name || '').slice(0, 60) || sid.slice(0, 8);
|
|
8594
|
+
out.push(` ↩ «${who}» 에게 답할 주소(보낸 세션 uuid — 이름은 다시 뜨면 바뀐다): ${sid}`);
|
|
8595
|
+
if (out.length >= 5) break;
|
|
8596
|
+
}
|
|
8597
|
+
return out.length ? `\n${out.join('\n')}` : '';
|
|
7326
8598
|
}
|
|
7327
8599
|
|
|
7328
8600
|
/**
|
|
@@ -7339,8 +8611,10 @@ async function mailPull() {
|
|
|
7339
8611
|
if (Date.now() - mailAt < MAIL_POLL_MS) return;
|
|
7340
8612
|
mailAt = Date.now();
|
|
7341
8613
|
|
|
8614
|
+
// 주소(지금 세션 cur · /clear 앞 세션 old · 댈 이름)는 한 바퀴에 한 번 푼다 — 세포를 소속처마다 부르지 않게.
|
|
8615
|
+
const ident = await mailSelfRecent(mine).catch(() => null);
|
|
7342
8616
|
for (const [tenant, tenantId] of tenants) {
|
|
7343
|
-
try { await mailPullOne(tenant, mine, tenant === indexTenant ? indexEntry : null, tenantId); }
|
|
8617
|
+
try { await mailPullOne(tenant, mine, tenant === indexTenant ? indexEntry : null, tenantId, ident); }
|
|
7344
8618
|
catch { /* 한 소속처가 막혀도 나머지는 돈다 — 다음 바퀴에 다시 */ }
|
|
7345
8619
|
}
|
|
7346
8620
|
|
|
@@ -7519,7 +8793,9 @@ async function codexMailPullOne(tenant, mine, tenantId) {
|
|
|
7519
8793
|
if (!state.pending) {
|
|
7520
8794
|
const r = await codexMailApi('GET','/api/mail?tenant='+encodeURIComponent(tenant)+'&session='+encodeURIComponent(mine)+'&limit=20',undefined,cred,deadline);
|
|
7521
8795
|
if (!Array.isArray(r.mail)) throw new Error('mail_response');
|
|
7522
|
-
|
|
8796
|
+
// Reply address (sender session UUID) comes from the server's from_session field and is appended after its wording.
|
|
8797
|
+
const told = typeof r.says === 'string' && r.says ? r.says + mailReplyHints(r.mail, r.says) : r.says;
|
|
8798
|
+
const batch = r.mail.length ? {ids:r.mail.map(m => m.id),says:told} : null;
|
|
7523
8799
|
if (!codexMailPending(batch)) throw new Error('mail_response');
|
|
7524
8800
|
mailCollectReturned(r);
|
|
7525
8801
|
state.last_checked_ms = Date.now(); state.pending = batch; codexMailStore(file,state);
|
|
@@ -7533,48 +8809,63 @@ async function codexMailPullOne(tenant, mine, tenantId) {
|
|
|
7533
8809
|
} finally { unlock(); }
|
|
7534
8810
|
}
|
|
7535
8811
|
|
|
7536
|
-
/**
|
|
7537
|
-
|
|
8812
|
+
/**
|
|
8813
|
+
* 한 소속처의 내 편지함을 긷고 **받았다고 도장을 찍는다**(도장은 큐에 넣기 **전에** — 재점화 방지).
|
|
8814
|
+
* ★ 긷는 편지함은 **지금 세션(cur)** 과, /clear 뒤면 그 앞 세션(old — 다른 창이 그 대화를 이어 열지 않았을 때만)이다(F2·SEC-7 — `mailSelf`).
|
|
8815
|
+
* 커서는 (소속처, 세션)마다 따로 — 한 커서로 두 세션을 긷으면 앞 세션이 커서를 밀어 뒤 세션의 통을 건너뛴다(그 통은 open 으로 남다 만료된다).
|
|
8816
|
+
* 색인의 mail_cursor 는 env 세션(mine)의 것이라 그 세션을 긷을 때만 적는다.
|
|
8817
|
+
*/
|
|
8818
|
+
async function mailPullOne(tenant, mine, indexEntry, tenantId = 0, ident = null) {
|
|
7538
8819
|
if (process.platform !== 'win32' && !process.env.CLAUDE_CODE_SESSION_ID && mine === codexSessionId()) return await codexMailPullOne(tenant, mine, tenantId || Number(indexEntry?.tenant_id));
|
|
7539
|
-
|
|
7540
|
-
|
|
7541
|
-
|
|
7542
|
-
|
|
7543
|
-
|
|
7544
|
-
|
|
7545
|
-
|
|
7546
|
-
|
|
7547
|
-
*/
|
|
7548
|
-
const q = '?tenant=' + encodeURIComponent(tenant)
|
|
7549
|
-
+ '&since=' + cursor
|
|
7550
|
-
+ '&session=' + encodeURIComponent(mine)
|
|
7551
|
-
+ '&limit=' + MAIL_PAGE;
|
|
7552
|
-
const r = await api('GET', '/api/mail' + q);
|
|
7553
|
-
|
|
7554
|
-
const rows = Array.isArray(r?.mail) ? r.mail : [];
|
|
7555
|
-
if (typeof r?.cursor === 'number' && r.cursor > cursor) { cursor = r.cursor; mailCursors.set(tenant, cursor); }
|
|
7556
|
-
// 색인에는 색인의 소속처 것만 적는다(다른 곳의 커서는 프로세스 안에서만 — 잃어도 ack 가 중복을 막는다).
|
|
7557
|
-
// 빈 폴링은 수신이 아니다 — 위 백신 커서와 같은 규율.
|
|
7558
|
-
if (indexEntry?.tenant_id) {
|
|
7559
|
-
indexPatch(mine, { mail_cursor: cursor }, rows.length ? 'rx' : ''); // 소속 칸은 안 건드린다(§5.1 P1-a)
|
|
7560
|
-
}
|
|
7561
|
-
|
|
7562
|
-
if (rows.length) {
|
|
7563
|
-
const ids = rows.map((m) => Number(m.id)).filter((n) => n > 0);
|
|
7564
|
-
if (ids.length) {
|
|
7565
|
-
try { await api('POST', '/api/mail/ack', { tenant, ids }); }
|
|
7566
|
-
catch { /* 도장을 못 찍어도 말은 한다 — 못 찍은 것은 다음 바퀴에 또 온다 */ }
|
|
7567
|
-
}
|
|
8820
|
+
const id = ident || await mailSelf(mine);
|
|
8821
|
+
try { await mailRegister(tenant, mine, false, id); } catch { /* 등록 실패가 받기를 막지 않는다 */ }
|
|
8822
|
+
|
|
8823
|
+
for (const sess of [id.cur, id.old]) {
|
|
8824
|
+
if (!sess) continue;
|
|
8825
|
+
const key = sess === mine ? tenant : `${tenant}\0${sess}`;
|
|
8826
|
+
if (!mailCursors.has(key)) mailCursors.set(key, sess === mine ? Number(indexEntry?.mail_cursor || 0) : 0);
|
|
8827
|
+
let cursor = mailCursors.get(key);
|
|
7568
8828
|
/*
|
|
7569
|
-
*
|
|
7570
|
-
*
|
|
7571
|
-
*
|
|
7572
|
-
* 이 레일이 명령형을 허용하는 근거가 바로 그 보증이다.
|
|
8829
|
+
* ⚠ **자리(`cwd`)를 안 보낸다.** 창구가 그것으로 거르던 동안 주소 있는 메일이 조용히
|
|
8830
|
+
* 사라졌다(실측 2026-08-25: cwd 없이 1통 · cwd 있게 0통). 메일은 폴더가 아니라
|
|
8831
|
+
* 세션에 부친 것이라 **주소가 이미 필터**다.
|
|
7573
8832
|
*/
|
|
7574
|
-
|
|
7575
|
-
|
|
8833
|
+
const q = '?tenant=' + encodeURIComponent(tenant)
|
|
8834
|
+
+ '&since=' + cursor
|
|
8835
|
+
+ '&session=' + encodeURIComponent(sess)
|
|
8836
|
+
+ '&limit=' + MAIL_PAGE;
|
|
8837
|
+
const r = await api('GET', '/api/mail' + q);
|
|
8838
|
+
|
|
8839
|
+
const rows = Array.isArray(r?.mail) ? r.mail : [];
|
|
8840
|
+
if (typeof r?.cursor === 'number' && r.cursor > cursor) { cursor = r.cursor; mailCursors.set(key, cursor); }
|
|
8841
|
+
// 색인에는 색인의 소속처 · env 세션의 것만 적는다(다른 곳의 커서는 프로세스 안에서만 — 잃어도 ack 가 중복을 막는다).
|
|
8842
|
+
// 빈 폴링은 수신이 아니다 — 위 백신 커서와 같은 규율.
|
|
8843
|
+
if (sess === mine && indexEntry?.tenant_id) {
|
|
8844
|
+
indexPatch(mine, { mail_cursor: cursor }, rows.length ? 'rx' : ''); // 소속 칸은 안 건드린다(§5.1 P1-a)
|
|
8845
|
+
}
|
|
8846
|
+
|
|
8847
|
+
if (rows.length) {
|
|
8848
|
+
const ids = rows.map((m) => Number(m.id)).filter((n) => n > 0);
|
|
8849
|
+
if (ids.length) {
|
|
8850
|
+
try { await api('POST', '/api/mail/ack', { tenant, ids }); }
|
|
8851
|
+
catch { /* 도장을 못 찍어도 말은 한다 — 못 찍은 것은 다음 바퀴에 또 온다 */ }
|
|
8852
|
+
}
|
|
8853
|
+
/*
|
|
8854
|
+
* ★ **서버가 쓴 글자를 그대로 싣는다.** 발신자 표시(`[누구 → 너]`)를 붙이는 자리는
|
|
8855
|
+
* 서버의 `mail_render()` 하나뿐이고, 여기서 다시 지으면 두 자가 갈린다.
|
|
8856
|
+
* 그리고 갈리는 순간 「누가 말하는지」가 보증이 아니라 장식이 된다 —
|
|
8857
|
+
* 이 레일이 명령형을 허용하는 근거가 바로 그 보증이다.
|
|
8858
|
+
* 답장 주소(보낸 세션 uuid)는 서버가 준 칸(`from_session`)으로 그 **뒤에** 붙인다 — 표시 줄은 안 건드린다(`mailReplyHints`).
|
|
8859
|
+
* /clear 앞 대화 앞으로 온 통이면 그 사실을 한 줄 앞에 둔다 — 받는 쪽(이 창의 새 대화)은 그 실의 맥락이 없다.
|
|
8860
|
+
*/
|
|
8861
|
+
if (typeof r?.says === 'string' && r.says) {
|
|
8862
|
+
const lead = sess === id.cur ? '' : `(아래는 /clear 앞 대화(uuid ${sess}) 앞으로 온 통이다 — 이 창의 지금 대화는 그 실의 맥락이 없다.)\n`;
|
|
8863
|
+
mailQueue.push(lead + r.says + mailReplyHints(rows, r.says));
|
|
8864
|
+
}
|
|
8865
|
+
}
|
|
7576
8866
|
|
|
7577
|
-
|
|
8867
|
+
mailCollectReturned(r);
|
|
8868
|
+
}
|
|
7578
8869
|
}
|
|
7579
8870
|
|
|
7580
8871
|
function mailCollectReturned(r) {
|
|
@@ -7756,6 +9047,13 @@ function requireArgs(name, args) {
|
|
|
7756
9047
|
+ `받는 이름은 이것뿐입니다 — ${all}`);
|
|
7757
9048
|
}
|
|
7758
9049
|
|
|
9050
|
+
/** 창구가 돌려준 항목 짚개 한 줄 — 번호가 있으면(하위 업무 번호 2단계) 「W-04 · decisions/…」, 없으면 예전처럼 경로 하나(옛 서버 · 0단계). */
|
|
9051
|
+
function worksItemRef(item) {
|
|
9052
|
+
const key = String(item?.key ?? '');
|
|
9053
|
+
const code = typeof item?.code === 'string' ? item.code : '';
|
|
9054
|
+
return code ? `${code} · ${key}` : key;
|
|
9055
|
+
}
|
|
9056
|
+
|
|
7759
9057
|
/**
|
|
7760
9058
|
* ── `works(tenant, work)` — 그 일의 항목 한 줄씩(GET /api/work/items, 2026-09-27) ─────────────
|
|
7761
9059
|
*
|
|
@@ -7763,16 +9061,26 @@ function requireArgs(name, args) {
|
|
|
7763
9061
|
* 결정 장을 한 장씩 열었다(같은 날 hnl 세션: 배정안 + 항목 장 일곱). 서버가 간트와 같은 자로 낸 줄을
|
|
7764
9062
|
* 그대로 옮긴다 — 번호(code)는 제목 머리의 [ … ] 이고, 없으면 제목만 쓴다(지어내지 않는다).
|
|
7765
9063
|
* ⚠ **서버가 준 칸만 쓴다** — 계획·상태 칸이 없거나 비면 그 토막을 안 그린다(옛 서버·빈 관측).
|
|
9064
|
+
* ★ 하위 업무 번호(DESIGN-item-codes.md · 설계 4-1 MCP works 줄): 번호를 쓰는 소속처(2단계)의 서버는 줄마다 role · parent 를 더 준다 —
|
|
9065
|
+
* (짚개 한 줄은 worksItemRef — 창구가 번호를 돌려주면 「W-04 · decisions/…」, 아니면 예전처럼 경로 하나.)
|
|
9066
|
+
* 머리 장(role head — `WRM-00`)은 하위 업무 줄 · 수에서 빼 일 머리 줄에 싣고, 자식(parent 가 이 목록에 있는 줄)은 부모 밑에 들여 쓴다.
|
|
9067
|
+
* 번호 글자를 쪼개 부모를 읽지 않는다(서버 parent 칸만). 그 칸이 없는 서버(0단계 · 옛 서버)의 출력은 예전과 한 글자도 같다.
|
|
7766
9068
|
*/
|
|
7767
9069
|
function worksItemsList(r) {
|
|
7768
9070
|
const works = Array.isArray(r?.works) ? r.works : [];
|
|
7769
9071
|
if (!works.length) return `${r?.tenant || ''} — 열린 일이 없다. 0건은 0건이다.`;
|
|
7770
9072
|
const md = (d) => String(d || '').slice(5); // 2026-09-14 → 09-14
|
|
9073
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
7771
9074
|
const out = [];
|
|
9075
|
+
let coded = false;
|
|
7772
9076
|
for (const w of works) {
|
|
7773
|
-
const
|
|
7774
|
-
|
|
7775
|
-
|
|
9077
|
+
const all = Array.isArray(w?.items) ? w.items : [];
|
|
9078
|
+
if (all.some((it) => it && typeof it.role === 'string')) coded = true;
|
|
9079
|
+
const headRow = all.find((it) => it && it.role === 'head') || null;
|
|
9080
|
+
const items = headRow ? all.filter((it) => it !== headRow) : all;
|
|
9081
|
+
out.push(`«${w.name || w.slug}» (${w.slug}) — 항목 ${items.length}개`
|
|
9082
|
+
+ (headRow ? ` · 머리 ${str(headRow.code)} — ${headRow.key}` : ''));
|
|
9083
|
+
const line = (it, depth) => {
|
|
7776
9084
|
const code = typeof it.code === 'string' && it.code !== '' ? it.code : '';
|
|
7777
9085
|
const title = code ? String(it.title || '').replace(/^\[[^\]]*\]\s*/, '') : String(it.title || '');
|
|
7778
9086
|
// 계획 끝 말(plan_word)·고유 번호(uid)도 서버가 준 것만 — 할 일 화면과 같은 자(work_task_due_word) · 할 일 표의 uid.
|
|
@@ -7780,11 +9088,26 @@ function worksItemsList(r) {
|
|
|
7780
9088
|
const plan = it.plan && it.plan.from ? ` · 계획 ${md(it.plan.from)}~${md(it.plan.to)}${due}` : '';
|
|
7781
9089
|
const state = it.state_word ? ` · ${it.state_word}` : '';
|
|
7782
9090
|
const uid = typeof it.uid === 'string' && it.uid !== '' ? ` · uid ${it.uid}` : '';
|
|
7783
|
-
|
|
9091
|
+
return `${' '.repeat(depth + 1)}· ${code ? code + ' ' : ''}${title}${plan}${state} — ${it.key}${uid}`;
|
|
9092
|
+
};
|
|
9093
|
+
// 자식 — 서버 parent 가 이 목록의 번호일 때만 들여 쓴다(부모가 목록에 없으면 제자리 한 줄 — 지어내지 않는다).
|
|
9094
|
+
const codes = new Set(items.map((it) => str(it.code)).filter(Boolean));
|
|
9095
|
+
const parentOf = (it) => { const p = str(it.parent); return p !== '' && p !== str(it.code) && codes.has(p) ? p : ''; };
|
|
9096
|
+
const kids = new Map();
|
|
9097
|
+
for (const it of items) {
|
|
9098
|
+
const p = parentOf(it);
|
|
9099
|
+
if (p) { if (!kids.has(p)) kids.set(p, []); kids.get(p).push(it); }
|
|
9100
|
+
}
|
|
9101
|
+
for (const it of items) {
|
|
9102
|
+
if (parentOf(it)) continue;
|
|
9103
|
+
out.push(line(it, 0));
|
|
9104
|
+
for (const k of kids.get(str(it.code)) || []) out.push(line(k, 1));
|
|
7784
9105
|
}
|
|
7785
9106
|
if (Number(w.dropped) > 0) out.push(` (지시형 제목이라 뺀 결정 ${Number(w.dropped)}건)`);
|
|
7786
9107
|
}
|
|
7787
|
-
out.push(
|
|
9108
|
+
out.push(coded
|
|
9109
|
+
? '항목을 건드리기 전에는 works(tenant, work, item) 로 이미 끝났는지 먼저 확인한다 — item 은 번호(예 W-04)나 item_key 그대로. 전문은 get_case(tenant, item_key).'
|
|
9110
|
+
: '항목을 건드리기 전에는 works(tenant, work, item) 로 이미 끝났는지 먼저 확인한다 — 전문은 get_case(tenant, item_key).');
|
|
7788
9111
|
return out.join('\n');
|
|
7789
9112
|
}
|
|
7790
9113
|
|
|
@@ -8271,6 +9594,8 @@ async function callTool(name, args = {}) {
|
|
|
8271
9594
|
// 서버의 `publish: true` 손잡이를 당기면 초안이 열린다. 여는 동사는 release_report 하나다.
|
|
8272
9595
|
delete payload.publish;
|
|
8273
9596
|
delete payload.published_at;
|
|
9597
|
+
// 결정 모드 문서의 공개일은 서버가 원고에 적는다(released_on) — 되읽은 판에 섞여 오는 값은 서버가 어차피 덮지만 메아리를 줄인다.
|
|
9598
|
+
delete payload.released_on;
|
|
8274
9599
|
delete payload.body_rev; // 몸통의 판은 서버가 안 읽는다 — 섞여 오면 떼고 보낸다(원고에 메아리로 남지 않게)
|
|
8275
9600
|
if (args.dry_run) payload.dry_run = true;
|
|
8276
9601
|
/*
|
|
@@ -8374,7 +9699,7 @@ async function callTool(name, args = {}) {
|
|
|
8374
9699
|
}
|
|
8375
9700
|
if (args.undone) {
|
|
8376
9701
|
const r = await api('POST', '/api/work/done', { tenant: args.tenant, work: wSlug, item, undone: true });
|
|
8377
|
-
return `${r.item
|
|
9702
|
+
return `${worksItemRef(r.item)} — 완료 표시를 되돌렸다(사후 정정, 원장에 남는다). 지금 상태: ${r.word}.`;
|
|
8378
9703
|
}
|
|
8379
9704
|
if (args.done_when || args.done_evidence) {
|
|
8380
9705
|
if (!args.done_when || !args.done_evidence) {
|
|
@@ -8386,7 +9711,7 @@ async function callTool(name, args = {}) {
|
|
|
8386
9711
|
done_when: String(args.done_when), done_evidence: String(args.done_evidence),
|
|
8387
9712
|
session: mine || '',
|
|
8388
9713
|
});
|
|
8389
|
-
return `${r.item
|
|
9714
|
+
return `${worksItemRef(r.item)} — ${r.word}(으)로 표시했다.\n 조건: ${args.done_when}\n 근거: ${args.done_evidence}`;
|
|
8390
9715
|
}
|
|
8391
9716
|
// 아무 표시 인자도 없으면 조회 전용(부작용 없음) — 건드리기 전에 먼저 확인하는 자리.
|
|
8392
9717
|
const r = await api('GET', '/api/work/done?tenant=' + encodeURIComponent(args.tenant)
|
|
@@ -8394,7 +9719,7 @@ async function callTool(name, args = {}) {
|
|
|
8394
9719
|
const doneLine = r.state === 'done'
|
|
8395
9720
|
? `이미 끝난 항목이다 — 근거: ${r.done_evidence || '(비어 있음)'}. 다시 진행하지 말 것.`
|
|
8396
9721
|
: '아직 완료가 아니다.';
|
|
8397
|
-
return `${r.item
|
|
9722
|
+
return `${worksItemRef(r.item)} — ${r.word}\n${doneLine}`;
|
|
8398
9723
|
}
|
|
8399
9724
|
if (args.claim) {
|
|
8400
9725
|
// 착수 선언(088) — 세션 이름은 이 자리(로컬)가 안다. mail 도구의 그 관용구다.
|
|
@@ -8405,7 +9730,7 @@ async function callTool(name, args = {}) {
|
|
|
8405
9730
|
};
|
|
8406
9731
|
if (args.item) body.item = String(args.item);
|
|
8407
9732
|
const r = await api('POST', '/api/work/claim', body);
|
|
8408
|
-
const target = r.item?.key ? `${r.item
|
|
9733
|
+
const target = r.item?.key ? `${worksItemRef(r.item)} @ «${r.work?.name || r.work?.slug}»` : `«${r.work?.name || r.work?.slug}» (${r.work?.slug})`;
|
|
8409
9734
|
return `${target} — ${r.says}`;
|
|
8410
9735
|
}
|
|
8411
9736
|
const r = await api('GET', '/api/work/list?tenant=' + encodeURIComponent(args.tenant));
|
|
@@ -8477,6 +9802,9 @@ async function callTool(name, args = {}) {
|
|
|
8477
9802
|
rejected_names: Array.isArray(args.rejected_names) ? args.rejected_names : [],
|
|
8478
9803
|
// 옛 결정 뒤집기(2026-09-21) — 댈 때만 싣는다(안 대면 요청이 예전과 같다).
|
|
8479
9804
|
...(args.overturns ? { overturns: args.overturns, overturns_revision: args.overturns_revision || '' } : {}),
|
|
9805
|
+
// 하위 업무 번호 네 칸(DESIGN-item-codes.md · 설계 4-1) — 댄 칸만 싣는다(안 대면 요청이 예전과 같다). 판정 · 발급은 창구 한 자리.
|
|
9806
|
+
...Object.fromEntries(['item', 'parent', 'branch', 'prefix']
|
|
9807
|
+
.filter((k) => typeof args[k] === 'string' && args[k].trim() !== '').map((k) => [k, args[k].trim()])),
|
|
8480
9808
|
// ★ 출처는 우리가 채운다 — 모델에게 안 묻는다(remember_case 와 같은 규율). 표지가
|
|
8481
9809
|
// 열린 일과 만나 항목으로 설 때 「누가 심었나」(간트의 세션 자국)가 이것이다.
|
|
8482
9810
|
...myCitation(),
|
|
@@ -8527,6 +9855,11 @@ async function callTool(name, args = {}) {
|
|
|
8527
9855
|
} else if (args.work && !r.effects_pending) {
|
|
8528
9856
|
lines.push('표지(work)를 적었지만 이 서버는 아직 그 칸을 모른다 — 판이 낡았다.');
|
|
8529
9857
|
}
|
|
9858
|
+
// 하위 업무 번호 — 문장의 정본은 서버(work_items_code_note → work_note)다. 그 문장에 번호가 없을 때만(서버 문장이 빠진 판)
|
|
9859
|
+
// 칸 값 그대로 한 줄 — 프로즈를 짓지 않는다. 번호가 없는 저장(0단계 · 표지 없는 결정)의 출력은 예전과 같다.
|
|
9860
|
+
if (typeof r.code === 'string' && r.code !== '' && !String(r.work_note || '').includes(r.code)) {
|
|
9861
|
+
lines.push(`code: ${r.code}${typeof r.parent === 'string' && r.parent !== '' ? `\nparent: ${r.parent}` : ''}`);
|
|
9862
|
+
}
|
|
8530
9863
|
return lines.join('\n');
|
|
8531
9864
|
}
|
|
8532
9865
|
case 'list_rejected_names': {
|
|
@@ -8943,30 +10276,30 @@ async function callTool(name, args = {}) {
|
|
|
8943
10276
|
+ '(Claude 세션 번호·유효한 Codex 세션 UUID·Cursor 활성 대화가 없는 환경이다).';
|
|
8944
10277
|
}
|
|
8945
10278
|
/*
|
|
8946
|
-
*
|
|
8947
|
-
*
|
|
8948
|
-
*
|
|
8949
|
-
*
|
|
10279
|
+
* ★ **이름이 없어도 보낸다**(2026-10-07 — 「이름 없으면 못 보냄」 문지기를 걷었다). 가드의 보증(「받는 쪽이 누가 말하는지
|
|
10280
|
+
* 본다」)은 이름이 아니라 **서버가 등록된 세션에서 발신자를 박는다**는 사실에서 온다 — 서버는 2026-08-25 부터 이름 없는 발신을
|
|
10281
|
+
* uuid 앞 8자로 받는다(mail_store_sender_session — 8ec9b061:www/portal/lib/mail_store.php:96-105). 그런데 이 문지기가 서버보다 엄해서 등록부를 못 읽은
|
|
10282
|
+
* 세션은 전체 uuid 앞으로 보낸 통까지 거절됐고, 문지기가 등록보다 앞이라 주소도 안 생겼다(2026-10-06T15:49:24Z 실측).
|
|
10283
|
+
* ★ **대고 나서 보낸다** — 등록(mailRegister)이 보내기 앞이다. 서버는 등록된 주소에서만 발신을 받는다(403).
|
|
10284
|
+
* 이름은 대기 직전에 푼다(myAgentName — 세포 먼저, 못 하면 JS 사본). Native Codex 는 늘 이름 없이 댄다 — 이름을 지어내거나
|
|
10285
|
+
* 요청 인자의 발신자를 쓰지 않는다. 등록 거절은 아래에서 전송 전에 멈춘다.
|
|
8950
10286
|
*/
|
|
8951
10287
|
const nativeCodex = !process.env.CLAUDE_CODE_SESSION_ID && mine === codexSessionId();
|
|
8952
|
-
|
|
8953
|
-
|
|
8954
|
-
|
|
8955
|
-
|
|
8956
|
-
+ '전사에 적고, 이 도구가 그것을 읽는다. 부른 뒤 다시 보내면 된다.';
|
|
8957
|
-
}
|
|
10288
|
+
/*
|
|
10289
|
+
* ★ 보내는 세션은 **지금 세션(cur)** 이다 — /clear 뒤에는 env 의 세션 id(mine)가 아니다(F2·SEC-7). 받는 쪽의 답장 주소(from_session)가
|
|
10290
|
+
* 그 uuid 가 되고, 이 창은 그 편지함을 긷는다(mailPullOne). /clear 앞 세션은 이름 없이 주소만 유지한다(mailRegister).
|
|
10291
|
+
*/
|
|
8958
10292
|
let reg;
|
|
8959
|
-
|
|
10293
|
+
let ident;
|
|
10294
|
+
try { ident = await mailSelf(mine); reg = await mailRegister(args.tenant, mine, true, ident); }
|
|
8960
10295
|
catch (e) { return `주소를 대지 못했다 — ${e.message}`; }
|
|
10296
|
+
const sender = ident?.cur || mine;
|
|
8961
10297
|
/*
|
|
8962
|
-
*
|
|
8963
|
-
*
|
|
8964
|
-
*
|
|
10298
|
+
* 이름을 남이 쥐고 있어도(held)·이름이 없어도 **보낸다**(침묵 금지). 무엇이 됐는지는 서버 응답의 사실대로 말한다 —
|
|
10299
|
+
* 발신 표시를 단정하지 않는다(mailAddrNote 머리말: 옛 서버는 held 일 때 그 줄의 옛 이름을 지켰다).
|
|
10300
|
+
* (하네스가 짧은 이름을 재사용한다 — 2026-08-25 운영 실측, app-4a 둘.) Native Codex 는 늘 이름이 없어 말할 것이 없다.
|
|
8965
10301
|
*/
|
|
8966
|
-
|
|
8967
|
-
const heldNote = reg?.held_by
|
|
8968
|
-
? `(이 세션의 이름은 살아 있는 다른 세션 ${String(reg.held_by).slice(0, 8)} 이 쥐고 있어 이 통은 «${mine.slice(0, 8)}» 이름으로 나간다 — 남이 이 세션에 닿으려면 uuid ${mine} 로.)\n`
|
|
8969
|
-
: '';
|
|
10302
|
+
const addrNote = nativeCodex ? '' : mailAddrNote(reg, mine);
|
|
8970
10303
|
|
|
8971
10304
|
const r = await api('POST', '/api/mail', {
|
|
8972
10305
|
tenant: args.tenant,
|
|
@@ -8978,14 +10311,17 @@ async function callTool(name, args = {}) {
|
|
|
8978
10311
|
cwd: relHome(process.cwd()),
|
|
8979
10312
|
...(args.thread ? { thread: args.thread } : {}),
|
|
8980
10313
|
// ★ **출처는 우리가 채운다 — 모델에게 안 묻는다**(remember_case 의 myCitation 과 같은 규율).
|
|
8981
|
-
session:
|
|
10314
|
+
session: sender,
|
|
8982
10315
|
});
|
|
8983
10316
|
// ★ 받을 곳을 기억한다 — 색인 밖 세션도 이 소속처에서 받는다(mailReady 힌트 + 색인).
|
|
8984
10317
|
// ⚠ 서버가 tenant_id 를 아직 안 주는 판(재배포 전)이어도 **받기는 이름만으로 된다** —
|
|
8985
10318
|
// GET /api/mail 은 tenant 이름으로 묻는다. 색인(indexNote)만 id 가 있어야 적힌다.
|
|
8986
10319
|
mailTenants.set(args.tenant, r?.tenant_id || 0); // 이 소속처에서도 받는다 — 합집합, 폴백 아님
|
|
8987
10320
|
if (r?.tenant_id && !indexRead()[mine]?.tenant) indexNote(mine, { src: 'mail', tenant: args.tenant, tenant_id: r.tenant_id, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) });
|
|
8988
|
-
|
|
10321
|
+
// 실제로 받은 세션(서버가 푼 uuid) — 이름으로 부쳤으면 그 이름이 그 순간 누구였는지가 여기 남는다(오배달을 알아챌 자리).
|
|
10322
|
+
const toSession = mailToSession(r);
|
|
10323
|
+
return addrNote + `«${r.to}» 앞으로 보냈다 — 실 ${r.thread} · 이 실의 ${r.turn}번째 통.\n`
|
|
10324
|
+
+ (toSession ? `받는 세션: ${toSession} — 이 실을 이어 가거나 다시 부칠 때 이 uuid 가 주소다(이름은 다시 뜨면 바뀐다).\n` : '')
|
|
8989
10325
|
+ `받는 쪽이 보게 될 글자는 이것이다:\n${r.preview}\n`
|
|
8990
10326
|
+ (r.says ? `${r.says}\n` : '')
|
|
8991
10327
|
+ '답이 오면 다른 도구를 부를 때 응답 꼬리에 저절로 실린다 — 따로 확인할 필요 없다.';
|
|
@@ -9683,6 +11019,7 @@ function ensureSkillOnBoot() {
|
|
|
9683
11019
|
* lane 'all' = 모든 클라이언트 · 'hookless' = 훅 레인이 없는 클라이언트만(`hookLane()` 이 거짓 — Codex)
|
|
9684
11020
|
* idle 'never' = 늘 같은 주기(심박 — 살아 있음이 신호다, 서버는 90초 침묵을 죽음으로 본다)
|
|
9685
11021
|
* 'slow' = 마지막 도구 호출 뒤 IDLE_AFTER_MS 가 지나면 IDLE_EVERY_MS 에 한 번만. 도구를 부르면 곧 복귀
|
|
11022
|
+
* once true = 타이머 없이 뜰 때 한 번만 돈다(주소 등록 — 2026-10-07). 그 뒤는 도구 호출·메일 폴이 맡는다
|
|
9686
11023
|
*
|
|
9687
11024
|
* 타이머가 아닌 것(심박 바퀴 안에서 사건·시각으로 도는 것): resolve(10분 TTL·사건 기반) · 판 비교(30분) ·
|
|
9688
11025
|
* 되찾기 훑기(5분) · 자동 올리기(90초 하한, 동의 관문). 메일은 훅 레인이 있으면 도구 호출 때 긷는다(60초 상한).
|
|
@@ -9698,6 +11035,9 @@ const SCHEDULE = [
|
|
|
9698
11035
|
{ name: 'mail', every: MAIL_POLL_MS, lane: 'hookless', idle: 'slow', tick: () => { mailPull().catch(() => {}); },
|
|
9699
11036
|
ready: mailPollReady,
|
|
9700
11037
|
why: '훅 레인이 없는 세션의 편지함 — 훅 레인이 있으면 도구 호출 때 긷는다' },
|
|
11038
|
+
{ name: 'address', every: 0, lane: 'all', idle: 'never', once: true, tick: () => { mailRegisterBoot().catch(() => {}); },
|
|
11039
|
+
ready: mailPollReady,
|
|
11040
|
+
why: '뜰 때 아는 소속처에 주소를 한 번 댄다 — 도구를 불러야만 대면 다시 뜬 세션이 첫 호출까지 서버에 옛 이름으로 남는다(계약 2)' },
|
|
9701
11041
|
];
|
|
9702
11042
|
|
|
9703
11043
|
function startSchedule() {
|
|
@@ -9706,6 +11046,7 @@ function startSchedule() {
|
|
|
9706
11046
|
if (SESSION_WATCH_UUID && row.name !== 'beat') continue;
|
|
9707
11047
|
if (row.start) { row.start(); continue; }
|
|
9708
11048
|
if (row.ready && !row.ready()) continue;
|
|
11049
|
+
if (row.once) { row.lastRun = Date.now(); row.tick(); continue; } // 타이머를 안 건다 — 뜰 때 한 번
|
|
9709
11050
|
const run = () => {
|
|
9710
11051
|
if (row.lane === 'hookless' && hookLane()) return;
|
|
9711
11052
|
const now = Date.now();
|
|
@@ -9748,7 +11089,7 @@ if (IS_MAIN) {
|
|
|
9748
11089
|
}
|
|
9749
11090
|
|
|
9750
11091
|
// 시험용 — 서버 동작에는 쓰이지 않는다. materializeAssets 만 네트워크를 쓴다.
|
|
9751
|
-
export { videoThumbs, taskReadTools, taskReadRequest, taskReadResult,
|
|
11092
|
+
export { videoThumbs, taskReadTools, taskReadRequest, taskReadResult, taskWriteResult, taskCodeOf,
|
|
9752
11093
|
encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText, IMAGE_HOST_ALLOW, HOST_ALLOW,
|
|
9753
11094
|
scrubTranscript, splitAtLines, PUSH_PART_MAX,
|
|
9754
11095
|
// DESIGN-genetics-ssot.md §3·§5 — 색인·잠금·폴더 identity·알림 문구의 순수/파일 함수(시험이 걷는다)
|
|
@@ -9782,4 +11123,14 @@ export { videoThumbs, taskReadTools, taskReadRequest, taskReadResult,
|
|
|
9782
11123
|
planPush, pushSession, pushAppend, capsNote, capsAppend, capsScrubRev, capsFile, capsAppendGone, CAPS_FALSE_TTL_MS,
|
|
9783
11124
|
readAt, lastNewlineAt, countLines, appendScan, splitSeg, foldHist, scrubDelta, hashRange, pushTenantId,
|
|
9784
11125
|
APPEND_ENV_MAX, APPEND_RAW_STEP, APPEND_ROUND_MAX,
|
|
9785
|
-
autoFullBudgetOf, autoFullCounts, autoDupOf, PUSH_FULL_BUDGET, PUSH_FULL_WINDOW_MS, PUSH_FULL_REBASE, PUSH_FULL_LOG_MAX
|
|
11126
|
+
autoFullBudgetOf, autoFullCounts, autoDupOf, PUSH_FULL_BUDGET, PUSH_FULL_WINDOW_MS, PUSH_FULL_REBASE, PUSH_FULL_LOG_MAX,
|
|
11127
|
+
// 주소 계약 2(2026-10-07) — 등록부 JS 사본(세포와 같은 계약)·세포 답의 모양·서버 이름 꼴·보낸 결과 안내·답장 주소(시험이 표로 걷는다)
|
|
11128
|
+
harnessSessionsDir, harnessInt, harnessNameClean, harnessNameOk, procStartEpoch, harnessRowOf, harnessRowsIn, harnessRowLive,
|
|
11129
|
+
harnessLiveRow, harnessSelfKeys, harnessSelfRow, harnessProbe, procStartOf, selfAddressJs, introNameIn, introNameOf,
|
|
11130
|
+
lastIntroInRange, cellSelfParse, mailNameOk, mailAddrNote, mailToSession, mailReplyHints,
|
|
11131
|
+
// 이름 관문(2026-10-07) — 세포 `self_whereabouts`·`session_whereabouts`·`claude_transcript_in` 의 사본과 세포에 묻는 길(smoke 3-4 가 같은 픽스처에 댄다)
|
|
11132
|
+
harnessHome, harnessConfigDir, claudeProjectBases, claudeTranscriptIn, claudeIntroName, introOfFile, INTRO_LINE_MAX,
|
|
11133
|
+
harnessWhereaboutsOf, harnessWhereabouts, harnessSelfWhereabouts, isoUtc, selfAddress,
|
|
11134
|
+
// 검증 반영(2026-10-07) — pidDomain(F1) · 세 갈래 산가(F5) · 고르기·산 줄 수(L1·SEC-1) · 찾은 열쇠(SEC-2) · /clear 따라가기(F2) · 이름 확인(SEC-5)
|
|
11135
|
+
pidDomainOf, harnessPidDomain, harnessRowLife, harnessLookup, harnessSelfLookup, harnessWithRows,
|
|
11136
|
+
myAgentName, mailSelf, mailRegister, mailNameSeen };
|