genetics-mcp 2.25.0 → 2.27.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/NATIVE-CODEX-SESSION.md +182 -0
- 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/codex-session-cli.mjs +82 -0
- package/codex-session.mjs +353 -0
- package/genetics.mjs +1865 -228
- package/package.json +5 -2
- package/skills/gazette-report/SKILL.md +11 -1
- package/skills/gazette-report/scripts/tone.mjs +45 -9
- package/skills/gazette-report/scripts/tone.test.mjs +70 -0
- package/skills/genetics-cell-id/SKILL.md +73 -54
- package/skills/genetics-define/SKILL.md +34 -1
- package/skills/genetics-report/SKILL.md +68 -70
- 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 +313 -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 -52
- package/skills/genetics-report/references/content-pipeline.md +9 -10
- package/skills/genetics-report/references/decision-format.md +112 -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 +35 -0
- package/skills/genetics-report/references/lim-portal-format.md +120 -253
- package/skills/genetics-report/references/linear-read-test.workflow.js +33 -10
- package/skills/genetics-report/references/process.md +34 -27
- 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 +54 -42
- package/skills/genetics-report/assets/diagram.mjs +0 -208
package/genetics.mjs
CHANGED
|
@@ -21,13 +21,15 @@
|
|
|
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';
|
|
28
28
|
import path from 'node:path';
|
|
29
29
|
import { pipeline as streamPipeline } from 'node:stream/promises';
|
|
30
30
|
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { codexSessionId, codexRollouts, codexMetadata, codexHumanRoot, findCodexRollout,
|
|
32
|
+
prepareCodexProjection, cachedCodexProjection, CODEX_CODEC } from './codex-session.mjs';
|
|
31
33
|
|
|
32
34
|
/**
|
|
33
35
|
* 판을 **package.json 에서 읽는다.** 전에는 serverInfo 에 '1.0.0' 이 상수로 박혀 있어서
|
|
@@ -122,6 +124,10 @@ try {
|
|
|
122
124
|
Object.assign(cfg, Object.fromEntries(
|
|
123
125
|
Object.entries(process.env).filter(([k]) => k.startsWith('GENETICS_'))
|
|
124
126
|
));
|
|
127
|
+
const SESSION_WATCH_UUID = String(cfg.GENETICS_SESSION_WATCH_UUID || '').toLowerCase();
|
|
128
|
+
if (SESSION_WATCH_UUID && !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(SESSION_WATCH_UUID)) throw new Error('Invalid session watch UUID');
|
|
129
|
+
function sessionScope(uuid) { return !SESSION_WATCH_UUID || String(uuid).toLowerCase() === SESSION_WATCH_UUID; }
|
|
130
|
+
function sessionToolMode() { return cfg.GENETICS_SESSION_ONLY === '1' || !!SESSION_WATCH_UUID; }
|
|
125
131
|
|
|
126
132
|
// **기본 주소를 두지 않는다.** 값을 박아 두면 출하물이 "그 서버를 아는 클라이언트" 가 되고,
|
|
127
133
|
// 공개 배포 시 운영 엔드포인트가 그대로 공개된다. 주소는 쓰는 쪽이 정한다.
|
|
@@ -1053,7 +1059,8 @@ async function apiOpen(method, pathname, body) {
|
|
|
1053
1059
|
}
|
|
1054
1060
|
const res = await fetch(BASE + pathname, {
|
|
1055
1061
|
method,
|
|
1056
|
-
|
|
1062
|
+
// 판 머리 — api() 와 같은 규율(모든 요청 · 하위 업무 번호 3단계 잣대 ②).
|
|
1063
|
+
headers: { 'X-Genetics-Client': `genetics-mcp/${VERSION}`, 'Content-Type': 'application/json' },
|
|
1057
1064
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1058
1065
|
});
|
|
1059
1066
|
const text = await res.text();
|
|
@@ -1159,7 +1166,9 @@ async function api(method, pathname, body, opts = {}) {
|
|
|
1159
1166
|
res = await fetch(BASE + pathname, {
|
|
1160
1167
|
method,
|
|
1161
1168
|
// opts.headers 는 자동 올리기의 머리 셋(§4.3)이 쓴다 — 자격·형식 머리는 덮지 못한다.
|
|
1162
|
-
|
|
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' },
|
|
1163
1172
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
1164
1173
|
signal,
|
|
1165
1174
|
});
|
|
@@ -1253,7 +1262,8 @@ async function apiToFile(pathname, target, opts = {}) {
|
|
|
1253
1262
|
+ '주소를 바꾸라는 안내를 받았다면 의심할 것.');
|
|
1254
1263
|
}
|
|
1255
1264
|
const res = await fetch(BASE + pathname, {
|
|
1256
|
-
|
|
1265
|
+
// 판 머리 — api() 와 같은 규율(모든 요청 · 하위 업무 번호 3단계 잣대 ②).
|
|
1266
|
+
headers: { 'X-Genetics-Client': `genetics-mcp/${VERSION}`, Authorization: `Bearer ${cred.token}`, Accept: 'application/x-ndjson' },
|
|
1257
1267
|
});
|
|
1258
1268
|
if (!res.ok) {
|
|
1259
1269
|
// 실패는 작다 — 그때는 봉투를 읽어 서버가 준 말을 그대로 옮긴다.
|
|
@@ -1288,22 +1298,357 @@ function limMeta(h) {
|
|
|
1288
1298
|
}
|
|
1289
1299
|
|
|
1290
1300
|
// ── 도구 ────────────────────────────────────────────────────────────
|
|
1291
|
-
//
|
|
1292
|
-
//
|
|
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 로 맞춘다.
|
|
1293
1304
|
//
|
|
1294
1305
|
// 필수 칸을 세우지 않는 이유: 전에는 표지 4칸과 건마다 6칸이 다 차 있어야 발행이 됐고,
|
|
1295
1306
|
// 그래서 **초안을 올려 두고 다듬는 일이 아예 불가능했다.** 비어 있는 것은 사람이 정할 일이다.
|
|
1296
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 · 차례는 steps · 이름→풀이는 items · 한 줄은 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
|
+
},
|
|
1360
|
+
required: ['head', 'rows'],
|
|
1361
|
+
},
|
|
1362
|
+
steps: {
|
|
1363
|
+
type: 'array', minItems: 2, maxItems: 12,
|
|
1364
|
+
items: { type: 'object', properties: { text: DEC_SENTENCE, who: DEC_WHO }, required: ['text'] },
|
|
1365
|
+
},
|
|
1366
|
+
items: {
|
|
1367
|
+
type: 'array', minItems: 1, maxItems: 12,
|
|
1368
|
+
items: { type: 'object', properties: { k: { type: 'string', minLength: 1, maxLength: 40 }, v: DEC_SENTENCE }, required: ['k', 'v'] },
|
|
1369
|
+
},
|
|
1370
|
+
},
|
|
1371
|
+
},
|
|
1372
|
+
},
|
|
1373
|
+
],
|
|
1374
|
+
};
|
|
1375
|
+
const DEC_DUE = {
|
|
1376
|
+
description: '기한 — date(YYYY-MM-DD) 또는 days_after_release(공개일부터 1~365일) 가운데 꼭 하나와 why(그 까닭 — 선택). '
|
|
1377
|
+
+ '기한이 아직 없으면 이 칸을 빈 값으로.',
|
|
1378
|
+
oneOf: [
|
|
1379
|
+
{ type: 'object', properties: { date: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$' }, why: DEC_SENTENCE }, required: ['date'] },
|
|
1380
|
+
{ type: 'object', properties: { days_after_release: { type: 'integer', minimum: 1, maximum: 365 }, why: DEC_SENTENCE },
|
|
1381
|
+
required: ['days_after_release'] },
|
|
1382
|
+
DEC_SENTENCE.oneOf[1],
|
|
1383
|
+
],
|
|
1384
|
+
};
|
|
1385
|
+
const DEC_TEXT_WHO = {
|
|
1386
|
+
oneOf: [
|
|
1387
|
+
{ type: 'object', properties: { who: DEC_WHO, text: DEC_SENTENCE }, required: ['who', 'text'] },
|
|
1388
|
+
DEC_SENTENCE.oneOf[1],
|
|
1389
|
+
],
|
|
1390
|
+
};
|
|
1391
|
+
const DECISION_BRIEF = {
|
|
1392
|
+
type: 'object',
|
|
1393
|
+
description: '결정 블록 — decisions(정할 것)가 하나 이상이면 문서가 결정 모드로 선다(서식은 lim-report 하나 · template 은 안 적어도 된다). '
|
|
1394
|
+
+ '결정이 없으면 이 칸을 빼고 보고로 낸다(decisions 가 빈 decision_brief 는 거절). '
|
|
1395
|
+
+ '필수(거절)는 뼈대뿐 — decisions[].name · question · options(둘 이상)[].name · line, 공개 때 reply_to.who. '
|
|
1396
|
+
+ '권함(없어도 거절 아님 — 렌더러가 ⚠): due · now · 결정에 닿는 facts · no_reply. 선택(쓰면 서고 안 쓰면 그리지 않음): options[].rows 전부 · cost · '
|
|
1397
|
+
+ 'opinion · confirms · risks · unknowns · names 와 그 밖의 칸. 대칭 — 한 선택지에 쓴 행·돈은 모든 선택지가 쓴다. '
|
|
1398
|
+
+ '문장 칸은 글자, 또는 비우는 까닭을 밝히는 빈 값 {empty, …}: na+why(해당 없음) · undecided+until(아직 안 정해짐 — 정해지는 때) · '
|
|
1399
|
+
+ 'elsewhere+who+when(이 문서에 없음 — 누가 언제 정하나) · unverified+fill(확인하지 못함 — 채울 수단). 「없음」「-」 같은 맨 글자는 거절된다. '
|
|
1400
|
+
+ '누가 칸(by·who·payer·to)은 customer(받는 쪽, 화면에는 client) · fcg(보내는 쪽, 화면 말은 labels.dc_who_fcg) · party:<names 의 id>(그 밖의 쪽), 또는 빈 값. '
|
|
1401
|
+
+ 'id 는 영소문자·숫자·하이픈 1~32자, 문서 전체가 한 이름 공간이다. 글 안에서 {{결정:id}}·{{선택:id}}·{{사실:id}}·{{확인:id}}·{{위험:id}}·{{이름:id}} 로 다른 물건을 부른다.',
|
|
1402
|
+
properties: {
|
|
1403
|
+
scope: { type: 'string', description: '점검 범위 한 문장 — 무엇을, 어느 계정·환경에서, 언제(선택).' },
|
|
1404
|
+
reply_to: {
|
|
1405
|
+
type: 'object',
|
|
1406
|
+
description: '회신 받는 사람. who 가 비면 공개되지 않는다(초안·dry_run 은 알림만).',
|
|
1407
|
+
properties: { who: { type: 'string' }, contact: { type: 'string' } },
|
|
1408
|
+
},
|
|
1409
|
+
decisions: {
|
|
1410
|
+
type: 'array', minItems: 1,
|
|
1411
|
+
description: '지금 정하실 결정 — 결정마다 한 장으로, 기한 순으로 선다. 하나 이상이면 결정 모드.',
|
|
1412
|
+
items: {
|
|
1413
|
+
type: 'object',
|
|
1414
|
+
properties: {
|
|
1415
|
+
id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
|
|
1416
|
+
uid: { type: 'string', pattern: '^[a-z0-9]{6}$', description: '서버가 발급하는 불변 이름. get_report 가 준 값은 그대로 되돌려 보낸다.' },
|
|
1417
|
+
name: { type: 'string', description: '짧은 이름 — 첫 화면·장 머리·회신에 같은 글자로 선다.' },
|
|
1418
|
+
question: { ...DEC_SENTENCE, description: '물음 하나(25자 안팎). 원고에 물음이 없으면 빈 값.' },
|
|
1419
|
+
due: { ...DEC_DUE, description: '결정 기한(권함 — 없으면 거절 대신 렌더러가 ⚠). ' + DEC_DUE.description },
|
|
1420
|
+
now: { ...DEC_PROSE, description: '지금 상태 — 대상과 문제를 쉬운 말로(권함). ' + DEC_PROSE.description },
|
|
1421
|
+
figures: {
|
|
1422
|
+
type: 'array',
|
|
1423
|
+
description: '그림 칸 — 「지금 상태」 바로 아래에 서는 이 결정의 그림(선택). 항목마다 diagram(설계 데이터 — Archify IR 객체, '
|
|
1424
|
+
+ 'sections[].diagrams[].ir 와 같은 꼴) · chart(차트 한 벌 — sections[].charts[] 와 같은 꼴: 막대 rows 또는 kind:"series"+points, ref·form 포함) · '
|
|
1425
|
+
+ 'plate(도판 assets[].id — role figure·video) 가운데 꼭 하나와 caption · source(출처 한 줄). 그 밖의 키는 거절. '
|
|
1426
|
+
+ '그림 번호는 문서 전체 한 셈이라 적지 않는다(같은 그림은 같은 번호).',
|
|
1427
|
+
items: {
|
|
1428
|
+
type: 'object',
|
|
1429
|
+
properties: {
|
|
1430
|
+
diagram: { type: 'object', description: '설계 데이터(Archify IR) — 서버가 관보 그림으로 굽는다.' },
|
|
1431
|
+
chart: { type: 'object', description: '차트 한 벌 — 그릴 행(rows)이나 점(points)이 있어야 한다.' },
|
|
1432
|
+
plate: { type: 'string', description: '도판 id(assets[].id).' },
|
|
1433
|
+
caption: { type: 'string', description: '그림 캡션 — 번호 없이.' },
|
|
1434
|
+
source: { type: 'string', description: '출처 한 줄.' },
|
|
1435
|
+
},
|
|
1436
|
+
minProperties: 1,
|
|
1437
|
+
},
|
|
1438
|
+
},
|
|
1439
|
+
settled: {
|
|
1440
|
+
type: 'array',
|
|
1441
|
+
description: '고객이 이미 정하신 것 — 정하신 기록(record, 화면에 안 찍힘)이 있는 것만.',
|
|
1442
|
+
items: { type: 'object', properties: { text: { type: 'string' }, by: DEC_WHO, record: { type: 'string' } }, required: ['text', 'by', 'record'] },
|
|
1443
|
+
},
|
|
1444
|
+
options: {
|
|
1445
|
+
type: 'array', minItems: 2,
|
|
1446
|
+
description: '선택지 — 그대로 두는 길을 포함해 둘 이상(이름과 한 줄 뜻이 필수 · 나머지는 선택).',
|
|
1447
|
+
items: {
|
|
1448
|
+
type: 'object',
|
|
1449
|
+
properties: {
|
|
1450
|
+
id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
|
|
1451
|
+
name: { type: 'string', description: '선택지 이름 — 표 열 머리·첫 화면·회신 말에 같은 글자로.' },
|
|
1452
|
+
line: { ...DEC_SENTENCE, description: '고르면 무엇이 달라지나 한 줄(35자 안팎). 없으면 빈 값.' },
|
|
1453
|
+
what: { ...DEC_PROSE, description: '실제로 하는 일 — 끝나거나 풀리는 조건까지(선택). ' + DEC_PROSE.description },
|
|
1454
|
+
is_current: { type: 'boolean', description: '지금 상태를 지키는 선택지 — 결정마다 많아야 하나, 늘 첫째로 선다.' },
|
|
1455
|
+
rows: {
|
|
1456
|
+
type: 'object',
|
|
1457
|
+
description: '대칭 표의 값(선택 — 쓰면 행이 서고 안 쓰면 그 행이 없다). 한 선택지에 쓴 행은 모든 선택지가 문장이나 빈 값으로 채운다. 이 밖의 키는 거절.',
|
|
1458
|
+
properties: {
|
|
1459
|
+
for_users: { ...DEC_SENTENCE, description: '이용자가 겪는 일.' },
|
|
1460
|
+
client_does: { ...DEC_TEXT_WHO, description: '고객 쪽이 하실 일 — 요청 자리, text 는 하십시오체 요청.' },
|
|
1461
|
+
fcg_does: { ...DEC_SENTENCE, description: '보내는 쪽이 할 일 — 화면 행 이름은 원고 issuer 첫 이름으로 「{발행처} 가 할 일」(labels.dc_row_fcg_does 로 덮음).' },
|
|
1462
|
+
risk_left: { ...DEC_SENTENCE, description: '남는 위험.' },
|
|
1463
|
+
settles_today: { ...DEC_SENTENCE, description: '그날 정해지는 것.' },
|
|
1464
|
+
others_do: { ...DEC_TEXT_WHO, description: '그 밖의 쪽이 할 일.' },
|
|
1465
|
+
},
|
|
1466
|
+
},
|
|
1467
|
+
cost: {
|
|
1468
|
+
type: 'object',
|
|
1469
|
+
description: '돈(선택 — 한 선택지에 쓰면 모든 선택지가) — 금액은 적지 않는다. 쓰면 state: yes(듦)·no(안 듦)·unknown(모름)는 필수, '
|
|
1470
|
+
+ 'known_when(언제 아나)·payer(누가 내나)는 아는 것만.',
|
|
1471
|
+
properties: {
|
|
1472
|
+
state: { type: 'string', enum: ['yes', 'no', 'unknown'] },
|
|
1473
|
+
known_when: DEC_SENTENCE,
|
|
1474
|
+
payer: DEC_WHO,
|
|
1475
|
+
},
|
|
1476
|
+
required: ['state'],
|
|
1477
|
+
},
|
|
1478
|
+
then: {
|
|
1479
|
+
type: 'array', description: '회신 뒤 일.',
|
|
1480
|
+
items: { type: 'object', properties: { what: { type: 'string' }, who: DEC_WHO, when: { type: 'string' } }, required: ['what', 'who', 'when'] },
|
|
1481
|
+
},
|
|
1482
|
+
extra_rows: {
|
|
1483
|
+
type: 'array',
|
|
1484
|
+
description: '대장 「선택지 전체 비교」에만 서는 잣대 — 한 결정의 모든 선택지가 같은 label 을 갖는다.',
|
|
1485
|
+
items: { type: 'object', properties: { label: { type: 'string' }, text: DEC_SENTENCE }, required: ['label', 'text'] },
|
|
1486
|
+
},
|
|
1487
|
+
},
|
|
1488
|
+
required: ['id', 'name', 'line'],
|
|
1489
|
+
},
|
|
1490
|
+
},
|
|
1491
|
+
no_current_why: { ...DEC_SENTENCE, description: '그대로 두는 길(is_current)이 선택지에 없을 때 그 까닭.' },
|
|
1492
|
+
no_reply: { ...DEC_SENTENCE, description: '기한까지 회신이 없으면 무엇이 그대로이고 누가 무엇을 하나(권함).' },
|
|
1493
|
+
facts_lead: { ...DEC_PROSE, description: '이 결정에 닿는 사실이 둘 이상이면 — 사실들이 선택지를 어떻게 가르는지 잇는 글. ' + DEC_PROSE.description },
|
|
1494
|
+
reply: {
|
|
1495
|
+
type: 'object',
|
|
1496
|
+
description: '회신에 덧붙일 값 — attach(함께 적어 주실 것) · conditions(조건 안내, 기본 참). {} 도 받는다.',
|
|
1497
|
+
properties: { attach: { type: 'array', items: { type: 'string' } }, conditions: { type: 'boolean' } },
|
|
1498
|
+
},
|
|
1499
|
+
opinion: { ...DEC_PROSE, description: '보내는 쪽 의견 — 판단은 이 칸에만 둔다(선택 — 의견이 없으면 칸을 뺀다). ' + DEC_PROSE.description },
|
|
1500
|
+
},
|
|
1501
|
+
required: ['id', 'name', 'question', 'options'],
|
|
1502
|
+
},
|
|
1503
|
+
},
|
|
1504
|
+
confirms: {
|
|
1505
|
+
type: 'array',
|
|
1506
|
+
description: '확인 요청 — 결정을 바꿀 수 있거나 결정 뒤에 할 확인.',
|
|
1507
|
+
items: {
|
|
1508
|
+
type: 'object',
|
|
1509
|
+
properties: {
|
|
1510
|
+
id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
|
|
1511
|
+
uid: { type: 'string', pattern: '^[a-z0-9]{6}$', description: '서버가 발급하는 불변 이름. get_report 가 준 값은 그대로 되돌려 보낸다.' },
|
|
1512
|
+
name: { type: 'string', description: '무엇을 확인하나 — 물음 꼴(「…가 되는가」).' },
|
|
1513
|
+
to: { ...DEC_WHO, description: '누구에게 묻나·누가 하나.' },
|
|
1514
|
+
due: DEC_DUE,
|
|
1515
|
+
relation: { type: 'string', enum: ['sways', 'after'], description: 'sways(결과가 결정을 바꿀 수 있음) · after(결정 뒤 이행).' },
|
|
1516
|
+
decisions: { type: 'array', minItems: 1, items: { type: 'string' }, description: '닿는 결정의 id.' },
|
|
1517
|
+
ask: { type: 'string', description: '물으실 말 — 요청 자리, 하십시오체. ask·steps 가운데 하나 이상.' },
|
|
1518
|
+
steps: { type: 'array', minItems: 1, items: { type: 'string' }, description: '하실 일 목록 — 요청 자리, 하십시오체.' },
|
|
1519
|
+
why: { ...DEC_SENTENCE, description: '이 확인이 결정에 무엇을 바꾸나.' },
|
|
1520
|
+
},
|
|
1521
|
+
required: ['id', 'name', 'to', 'due', 'relation', 'decisions'],
|
|
1522
|
+
},
|
|
1523
|
+
},
|
|
1524
|
+
facts: {
|
|
1525
|
+
type: 'array',
|
|
1526
|
+
description: '확인한 사실 — 사실 줄 하나에 근거 하나 이상.',
|
|
1527
|
+
items: {
|
|
1528
|
+
type: 'object',
|
|
1529
|
+
properties: {
|
|
1530
|
+
id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
|
|
1531
|
+
text: { type: 'string', description: '사실 한 문장 — 방법 서술이 아니다.' },
|
|
1532
|
+
basis: {
|
|
1533
|
+
type: 'string', enum: ['seen', 'counted', 'tested', 'read', 'computed'],
|
|
1534
|
+
description: '근거 종류 — seen(직접 봄) · counted(운영 기록에서 셈) · tested(시험에서 잼) · read(설정·코드를 읽음) · computed(계산).',
|
|
1535
|
+
},
|
|
1536
|
+
where: {
|
|
1537
|
+
type: 'string', enum: ['production', 'replica', 'simulated'],
|
|
1538
|
+
description: 'basis 가 tested 일 때만 — simulated 면 「운영과 같은 조건 아님」이 붙는다.',
|
|
1539
|
+
},
|
|
1540
|
+
source: {
|
|
1541
|
+
type: 'array', minItems: 1,
|
|
1542
|
+
description: '근거 — 항목마다 키 하나: case(건 uid) · plate(도판 id) · quote(원문 한 줄) · url · ledger(대장 「절.문단」) · fact(계산에 쓴 사실 id). '
|
|
1543
|
+
+ 'seen 은 plate·url·case, read 는 quote, computed 는 fact 가 하나 이상.',
|
|
1544
|
+
items: {
|
|
1545
|
+
type: 'object', minProperties: 1, maxProperties: 1,
|
|
1546
|
+
properties: {
|
|
1547
|
+
case: { type: 'string' }, plate: { type: 'string' }, quote: { type: 'string' },
|
|
1548
|
+
url: { type: 'string' }, ledger: { type: 'string' }, fact: { type: 'string' },
|
|
1549
|
+
},
|
|
1550
|
+
},
|
|
1551
|
+
},
|
|
1552
|
+
bears_on: {
|
|
1553
|
+
type: 'array', minItems: 1,
|
|
1554
|
+
description: '사실이 서는 자리 — 항목마다 decision(+ 그 결정의 option) · risk · unknown 가운데 하나.',
|
|
1555
|
+
items: {
|
|
1556
|
+
type: 'object',
|
|
1557
|
+
properties: { decision: { type: 'string' }, option: { type: 'string' }, risk: { type: 'string' }, unknown: { type: 'string' } },
|
|
1558
|
+
},
|
|
1559
|
+
},
|
|
1560
|
+
},
|
|
1561
|
+
required: ['id', 'text', 'basis', 'source', 'bears_on'],
|
|
1562
|
+
},
|
|
1563
|
+
},
|
|
1564
|
+
risks: {
|
|
1565
|
+
type: 'array',
|
|
1566
|
+
description: '어느 선택지를 고르든 지금 열려 있는 위험.',
|
|
1567
|
+
items: {
|
|
1568
|
+
type: 'object',
|
|
1569
|
+
properties: {
|
|
1570
|
+
id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
|
|
1571
|
+
what: { type: 'string' },
|
|
1572
|
+
size: {
|
|
1573
|
+
type: 'object', description: '크기 — text 와 basis(measured 잼 · estimated 가늠).',
|
|
1574
|
+
properties: { text: { type: 'string' }, basis: { type: 'string', enum: ['measured', 'estimated'] } },
|
|
1575
|
+
required: ['text', 'basis'],
|
|
1576
|
+
},
|
|
1577
|
+
blocked_by: {
|
|
1578
|
+
type: 'object',
|
|
1579
|
+
description: '이 위험을 막는 것 — decision(결정 id) · schedule(일정 글) · none:true + note(막을 결정·일정이 없는 까닭) 가운데 꼭 하나.',
|
|
1580
|
+
properties: { decision: { type: 'string' }, schedule: { type: 'string' }, none: { type: 'boolean' }, note: { type: 'string' } },
|
|
1581
|
+
},
|
|
1582
|
+
},
|
|
1583
|
+
required: ['id', 'what', 'blocked_by'],
|
|
1584
|
+
},
|
|
1585
|
+
},
|
|
1586
|
+
unknowns: {
|
|
1587
|
+
type: 'array',
|
|
1588
|
+
description: '확인하지 못한 것.',
|
|
1589
|
+
items: {
|
|
1590
|
+
type: 'object',
|
|
1591
|
+
properties: {
|
|
1592
|
+
id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' },
|
|
1593
|
+
what: { type: 'string' },
|
|
1594
|
+
sways: { type: 'array', items: { type: 'string' }, description: '이것이 바꿀 수 있는 결정의 id.' },
|
|
1595
|
+
size: {
|
|
1596
|
+
type: 'object', description: 'risks[].size 와 같은 꼴.',
|
|
1597
|
+
properties: { text: { type: 'string' }, basis: { type: 'string', enum: ['measured', 'estimated'] } },
|
|
1598
|
+
required: ['text', 'basis'],
|
|
1599
|
+
},
|
|
1600
|
+
fill: {
|
|
1601
|
+
description: '채울 수단 — 없으면 {none: true}.',
|
|
1602
|
+
oneOf: [
|
|
1603
|
+
{ type: 'string', minLength: 1 },
|
|
1604
|
+
{ type: 'object', properties: { none: { type: 'boolean', enum: [true] } }, required: ['none'] },
|
|
1605
|
+
],
|
|
1606
|
+
},
|
|
1607
|
+
},
|
|
1608
|
+
required: ['id', 'what', 'fill'],
|
|
1609
|
+
},
|
|
1610
|
+
},
|
|
1611
|
+
names: {
|
|
1612
|
+
type: 'array',
|
|
1613
|
+
description: '이 문서에 나오는 낯선 말과 숫자 — 결정 장마다 처음 나오는 자리에 풀이가 선다. 선택지 이름은 두지 않는다.',
|
|
1614
|
+
items: {
|
|
1615
|
+
type: 'object',
|
|
1616
|
+
properties: { id: { type: 'string', pattern: '^[a-z0-9-]{1,32}$' }, name: { type: 'string' }, plain: { type: 'string', description: '쉬운 말 풀이.' } },
|
|
1617
|
+
required: ['id', 'name', 'plain'],
|
|
1618
|
+
},
|
|
1619
|
+
},
|
|
1620
|
+
later: {
|
|
1621
|
+
type: 'array',
|
|
1622
|
+
description: '나중에 정해도 되는 것 — 회신·핀을 받지 않는다.',
|
|
1623
|
+
items: {
|
|
1624
|
+
type: 'object',
|
|
1625
|
+
properties: { question: { type: 'string' }, options: { type: 'array', minItems: 1, items: { type: 'string' } }, revisit: { type: 'string', description: '다시 볼 때.' } },
|
|
1626
|
+
required: ['question', 'options', 'revisit'],
|
|
1627
|
+
},
|
|
1628
|
+
},
|
|
1629
|
+
},
|
|
1630
|
+
required: ['decisions'],
|
|
1631
|
+
};
|
|
1297
1632
|
const REPORT_DATA = {
|
|
1298
1633
|
type: 'object',
|
|
1299
1634
|
description: '보고서 내용. 짝인 스킬의 report.json + cases 를 하나로 합친 형태.',
|
|
1300
1635
|
properties: {
|
|
1301
1636
|
/*
|
|
1302
|
-
*
|
|
1303
|
-
*
|
|
1304
|
-
*
|
|
1305
|
-
*
|
|
1637
|
+
* 서식 칸(2026-10-06 관보 버전2 에 열었다) — 관보 v3(2026-10-07 ⑨)부터 서식은 lim-report 하나이고 모드는 원고 내용
|
|
1638
|
+
* (decision_brief.decisions)이 정한다. 옛 이름 lim-decision 은 그 이름으로 쓴 원고 · 옛 판 도구가 보내도 받히게 enum 에 남긴다 —
|
|
1639
|
+
* 서버가 lim-report 로 맞춘다(REPORT_TEMPLATE_ALIASES). 이름의 정본은 서버 REPORT_TEMPLATES 다.
|
|
1640
|
+
* 팔레트는 여전히 **여기 없다** — 서식이 강제하고 서버가 박는다(report_publish.php 의 REPORT_TEMPLATE_PALETTE).
|
|
1306
1641
|
*/
|
|
1642
|
+
template: {
|
|
1643
|
+
type: 'string', enum: ['lim-report', 'lim-decision'],
|
|
1644
|
+
description: '서식 — lim-report 하나(안 적어도 된다). 결정 블록(decision_brief.decisions)이 있으면 결정 모드, 없으면 보고 모드로 선다. '
|
|
1645
|
+
+ 'lim-decision 은 옛 이름 — 서버가 lim-report 로 맞추고, 그 이름으로 왔는데 결정이 없으면 거절한다.',
|
|
1646
|
+
},
|
|
1647
|
+
decision_brief: DECISION_BRIEF,
|
|
1648
|
+
released_on: {
|
|
1649
|
+
type: 'string',
|
|
1650
|
+
description: '서버가 적는 공개일(YYYY-MM-DD, 결정 모드) — 「받으신 날부터 N일」을 날짜로 푸는 값. 보내도 서버가 공개일로 덮는다.',
|
|
1651
|
+
},
|
|
1307
1652
|
title_tag: { type: 'string', description: '브라우저 탭 제목' },
|
|
1308
1653
|
title: { type: 'string', description: '표지 대제목 **첫 행** (굵게)' },
|
|
1309
1654
|
title_sub: {
|
|
@@ -1331,7 +1676,7 @@ const REPORT_DATA = {
|
|
|
1331
1676
|
cta: {
|
|
1332
1677
|
type: 'array', items: { type: 'string' },
|
|
1333
1678
|
description: '`kind:"asks"` 절 머리의 강조문(문자열 목록). 요청 항목들 **위**에 서서 '
|
|
1334
|
-
+ '「먼저 볼 것」을 한두 줄로 말한다. 없으면 강조문 없이 항목만 늘어선다.',
|
|
1679
|
+
+ '「먼저 볼 것」을 한두 줄로 말한다. 없으면 강조문 없이 항목만 늘어선다. 결정 모드에서는 받지 않는다(거절).',
|
|
1335
1680
|
},
|
|
1336
1681
|
description: { type: 'string' },
|
|
1337
1682
|
doc_no: { type: 'string', description: '문서번호. 표지 머리 왼쪽 박스 (예: "LIM-2026-0824-01")' },
|
|
@@ -1351,7 +1696,7 @@ const REPORT_DATA = {
|
|
|
1351
1696
|
description: '요약면 수치 칸 [{value, unit, label, accent?, href?, go?}] · accent:true 는 '
|
|
1352
1697
|
+ '**독자 몫**인 첫 칸. `href` 를 주면 그 칸이 단추가 되고, `go` 는 그 단추의 '
|
|
1353
1698
|
+ '글자다(생략하면 「해당 절로」). 어디로 가는지를 `go` 에 적어 두면 '
|
|
1354
|
-
+ '독자가 누르기 전에 안다 — 예: "02절로".',
|
|
1699
|
+
+ '독자가 누르기 전에 안다 — 예: "02절로". 결정 모드에서는 받지 않는다(거절).',
|
|
1355
1700
|
},
|
|
1356
1701
|
history: {
|
|
1357
1702
|
type: 'array',
|
|
@@ -1361,7 +1706,7 @@ const REPORT_DATA = {
|
|
|
1361
1706
|
type: 'array',
|
|
1362
1707
|
description: '이 문서 읽는 법 [{q, a: [문장, ...]}] — **독자가 실제로 던질 질문**만. '
|
|
1363
1708
|
+ '「시간이 없다면 어디를 봐야 하는가?」 「어디까지 확인한 것인가?」 처럼. '
|
|
1364
|
-
+ '채우려고 넣으면 안 넣느니만 못하다.',
|
|
1709
|
+
+ '채우려고 넣으면 안 넣느니만 못하다. 결정 모드에서는 받지 않는다(거절).',
|
|
1365
1710
|
},
|
|
1366
1711
|
parts: {
|
|
1367
1712
|
type: 'array',
|
|
@@ -1370,12 +1715,19 @@ const REPORT_DATA = {
|
|
|
1370
1715
|
+ 'tone 은 you(독자 몫)·neutral(기록)·wait(대기)·hold(유보) 중 하나. '
|
|
1371
1716
|
+ 'questions 는 「이 PART 가 답하는 질문」. '
|
|
1372
1717
|
+ '`roman` 은 간지·차례·머리띠에 서는 로마 숫자 — 생략하면 자리 순서대로 '
|
|
1373
|
-
+ 'Ⅰ·Ⅱ·Ⅲ 이 매겨지므로 **보통 적지 않는다**(적으면 순서와 어긋날 수 있다).',
|
|
1718
|
+
+ 'Ⅰ·Ⅱ·Ⅲ 이 매겨지므로 **보통 적지 않는다**(적으면 순서와 어긋날 수 있다). 결정 모드에서는 받지 않는다(거절).',
|
|
1374
1719
|
},
|
|
1375
1720
|
notice: {
|
|
1376
1721
|
type: 'object',
|
|
1377
1722
|
description: '요약면 현황 줄 {state, text} — state 는 critical|info|success. '
|
|
1378
|
-
+ '**상태에 따라 바뀌는 유일한 줄**이다. 떠 있는 알림 배너를 따로 두지 마라.',
|
|
1723
|
+
+ '**상태에 따라 바뀌는 유일한 줄**이다. 떠 있는 알림 배너를 따로 두지 마라. 결정 모드에서는 받지 않는다(거절).',
|
|
1724
|
+
},
|
|
1725
|
+
summary_position: {
|
|
1726
|
+
type: 'string', enum: ['front'],
|
|
1727
|
+
description: '선택해서 `front` 를 주면 최상위 `summary[]` 를 문서 앞 요약면에도 싣는다. '
|
|
1728
|
+
+ '생략하면 기존 요약면(현황·수치)과 kind:"summary" 절의 배치를 유지한다. '
|
|
1729
|
+
+ '앞 요약만 필요하면 kind:"summary" 절을 따로 두지 마라 — 두 곳에 같은 요지가 실린다. '
|
|
1730
|
+
+ '요약의 refs 는 실제 사례나 presentation:"prose" 절의 문단을 가리켜야 한다.',
|
|
1379
1731
|
},
|
|
1380
1732
|
bar: { type: 'array', description: '요약면 상태 막대 [{value, label, tone}] · tone 은 done|wait' },
|
|
1381
1733
|
bar_note: { type: 'string', description: '막대가 뜻하는 바의 한정 조건 (※ 각주)' },
|
|
@@ -1409,13 +1761,16 @@ const REPORT_DATA = {
|
|
|
1409
1761
|
+ '덮을 수 있는 키: apx · as_of · ask · bar_alt · basis · client · col_grade · col_state · '
|
|
1410
1762
|
+ 'col_title · cta · doc_no · go · guide · hist · hist_date · hist_no · hist_note · marg · '
|
|
1411
1763
|
+ 'nograde · open · pt_ask · refs · remain · remain_unit · source · source_row · sum · toc · urgent.\n'
|
|
1412
|
-
+ '없는 키를 주면 조용히 무시된다 — 오타가 안 걸리니 위 목록에서 골라
|
|
1764
|
+
+ '없는 키를 주면 조용히 무시된다 — 오타가 안 걸리니 위 목록에서 골라 써라.\n'
|
|
1765
|
+
+ '결정 모드의 고정 문구 키(dc_…)는 skills/genetics-report/references/decision-format.md 의 「바꿀 수 있는 글자」 표.',
|
|
1413
1766
|
},
|
|
1414
1767
|
grades: { type: 'object', description: '확인 등급 정의 {이름: [클래스, 설명]}' },
|
|
1415
1768
|
groups: { type: 'array', description: '건 묶음 [{key, no}]' },
|
|
1416
1769
|
sections: {
|
|
1417
1770
|
type: 'array',
|
|
1418
|
-
description: '절 구성 [{no, id, name, kind, group?, chapter?, principles?, logic?}]
|
|
1771
|
+
description: '절 구성 [{no, id, name, kind, group?, chapter?, principles?, logic?}] — 보고 모드는 절 구성이 본체다. '
|
|
1772
|
+
+ '결정 모드는 대장(근거 자료) 절(kind note·cases)만, 없어도 된다.\n'
|
|
1773
|
+
+ 'kind 는 summary|status|cases|asks|note '
|
|
1419
1774
|
+ '중 하나여야 한다 — 그 밖의 값은 렌더러가 절을 통째로 빠뜨리므로 서버가 거절한다. '
|
|
1420
1775
|
+ 'chapter 에 아래 chapters 의 id 를 적으면 그 절 앞에 챕터 오프닝이 펼쳐진다. '
|
|
1421
1776
|
+ 'name 은 목차에 쓰는 **짧은 이름**이고, 본문의 큰 제목은 챕터의 title_lines 가 낸다.\n'
|
|
@@ -1433,7 +1788,13 @@ const REPORT_DATA = {
|
|
|
1433
1788
|
+ 'rows[].no 가 `4.1`·`04-1` 모양이면 그 사례로 링크가 된다. '
|
|
1434
1789
|
+ '그림 번호(`그림 5-1`)는 적지 않는다 — 자리가 매기고 `plates` 와 한 셈을 쓴다.\n'
|
|
1435
1790
|
+ '· `plates`: [assets[].id, …] — 절 본문 도판.\n'
|
|
1436
|
-
|
|
1791
|
+
// ── 장치 목록에서 굽는 글 «tool-devices» — 손으로 고치지 않는다: python3 tools/contract-devices.py --write ──
|
|
1792
|
+
+ '· 시각 장치(정본 private/builder/devices.py — 둘 수 있는 칸 · 한도 · 만드는 법은 스킬 references/visual-first.md §4 표): 구조 → 구성도·도판(개념도) · 흐름 → 흐름도·절차도·데이터 흐름도 · 순서 → 순서도 · 상태 → 상태 변화도 · 변화 → 두 판 비교·도판(두 컷 비교) · 분포 → 막대 차트 · 시간에 따른 값 → 시계열 · 견주기 → 표·점 차트 · 원문 → 원문 인용·도판 · 화면 → 도판·화면 녹화. '
|
|
1793
|
+
+ '구성도·절차도·순서도·데이터 흐름도·상태 변화도·두 판 비교는 도해 굽기가 켜진 서버에서만 새로 굽는다(포털 private/archify.on — 로컬 render.py 는 켜고 부른다) — 꺼진 서버는 굽혀 둔 그림만 싣고 새 그림은 같은 번호의 대기 상자로 둔다(「서버에서 실패한 그림이 N장」 — 원고를 고칠 일이 아니다). '
|
|
1794
|
+
+ '화면 녹화는 영상 저장 연동(r2)이 켜진 서버에서만 — 꺼진 환경은 발행을 거절한다. '
|
|
1795
|
+
+ '시각 우선(dry_run 이 ⚠ 로 센다 — 서식 문서 §10-1): 산문 400자 넘는 절에 표·흐름도·차트·원문 인용·도해·도판 가운데 하나도 없으면 ⚠, 문단 220자 안, 산문의 참조 번호 3개 이하, 판단 요청 6항 이상이면 한눈표+관문 흐름도, 3,000자 넘는 문서엔 핵심 굵게. '
|
|
1796
|
+
+ '집필 공식(독자 한 명·예시 한 명·한 장 이야기·용어 잠금·그림 먼저)은 스킬 references/visual-first.md.\n'
|
|
1797
|
+
// ── /장치 목록 «tool-devices» ──
|
|
1437
1798
|
+ '· 인라인 표기(본문 문자열 어디서나 — 문단·도입문·여백 주석·표 셀·흐름도 칸·주석·부록 행): `**굵게**` → 핵심 구절, `==강조==` → 놓치면 안 되는 한 구절(노란 밑면), `{{ok|보통}}` → 상태 칩(톤 ok·wait·bad·info·muted 다섯뿐, 글자 40자 안), `[글자](https://…)` → 새 창으로 여는 하이퍼링크, 맨 `https://…` 주소도 그대로 링크가 됨(http(s) 만, 꼬리 문장부호는 링크 밖). 표기가 없으면 한 글자도 안 바뀐다. 핵심 한두 구절에만 — 문단 절반이 굵으면 굵기가 위계를 못 나른다. 출처는 부록 글자에만 두지 말고 건의 `source_url` 에도 실어라(「출처 원문」 행이 된다).\n'
|
|
1438
1799
|
+ '· `tables`: [{caption, cols[], rows[[]], align?, hint?, note?}] — **관보의 자료표.** '
|
|
1439
1800
|
+ '여러 항목을 축으로 견주는 자리다. 불릿 목록에 수를 눌러 담고 있으면 그건 표여야 한다. '
|
|
@@ -1444,22 +1805,26 @@ const REPORT_DATA = {
|
|
|
1444
1805
|
+ '표 번호(`표 5-1`)는 적지 않는다 — 자리가 매긴다. '
|
|
1445
1806
|
+ '⚠ caption 은 **라벨이지 산문이 아니다** — 20자 안쪽으로 두고 설명은 note 로 내려라 '
|
|
1446
1807
|
+ '(길면 캡션 바가 글줄 상한을 넘어 두 줄로 접힌다).\n'
|
|
1447
|
-
+ '· `charts`: [{caption, unit?, rows:[{label, value|a+b, note?}], toggle?{a,b}, note?}] — '
|
|
1448
|
-
+ '
|
|
1449
|
-
+ '
|
|
1450
|
-
+ '곳에서도 문서가 성립한다. value 는 **표기 그대로 적는다**(`"118,716"`) — 콤마는 셈에서 걷어내므로 '
|
|
1451
|
-
+ '같은 문서의 표와 표기가 갈리지 않게 하려면 데이터가 표기를 정해야 한다. '
|
|
1808
|
+
+ '· `charts`: [{caption, unit?, rows:[{label, value|a+b, note?, tone?, partial?}], toggle?{a,b}, form?, ref?, note?}] — '
|
|
1809
|
+
+ '**차트(막대 · 점).** 렌더러가 정지 SVG 로 그린다(스크립트 0 — 열람 CSP 가 script-src 를 안 연다). 값 · 이름 · 설명은 원고 글자 그대로 '
|
|
1810
|
+
+ '그림에 서고 화면 낭독 목록으로도 한 번 실린다. value 는 **표기 그대로 적는다**(`"118,716"`) — 같은 문서의 표와 표기가 갈리지 않게 데이터가 표기를 정한다. '
|
|
1452
1811
|
+ 'rows[].tone(ok·wait·bad·info·muted) 을 주면 그 행의 막대와 값에 뜻 색이 붙는다 — 예: 처리 완료 행은 muted, 위험 행은 bad. '
|
|
1453
|
-
+ 'toggle{a,b} 를 주면 rows 는 value 대신 a·b 를 갖고 두 축을 `:checked` 로 갈아 낀다 '
|
|
1454
|
-
+ '
|
|
1455
|
-
+ '
|
|
1456
|
-
+ '
|
|
1457
|
-
+ '
|
|
1812
|
+
+ 'toggle{a,b} 를 주면 rows 는 value 대신 a·b 를 갖고 두 축을 `:checked` 로 갈아 낀다(두 축이 한 축척 · 체크박스는 스크립트가 아니라 상태다). '
|
|
1813
|
+
+ '그림 번호는 `flow`·`plates` 와 **한 셈**을 쓴다. 데이터가 있을 때만 그려진다 — 없는 문구를 지어내지 말 것.\n'
|
|
1814
|
+
+ '· `charts[].ref`: [{value, label}] — **기준선**(점선). value 는 수(쉼표 표기 글자도 받음), label 은 원고 글자(「3초」「60초 한도」). 막대·시계열 모두. '
|
|
1815
|
+
+ '`charts[].rows[].partial`: 참이면 그 행은 일부만 잰 값이라 막대 속을 비운다(행 이름 글자로 짐작하지 않는다). '
|
|
1816
|
+
+ '`charts[].form`: length(길이 — 0 기준선에서 뻗는 공통 축척 막대) · position(위치 — 같은 축 위의 점) — 없으면 막대는 length · 시계열은 position. '
|
|
1817
|
+
+ '셋 다 꼴이 틀리면 서버가 거절한다.\n'
|
|
1818
|
+
+ '· `charts[].kind:"series"`: {kind:"series", caption, unit?, points:[{x,y}], marks?:[{x,label}], summary?:[{label,value,note?}], form?, ref?, note?} — '
|
|
1819
|
+
+ '**시계열.** 하루·달 단위 값이 언제 계단을 만드는지 정지 SVG 로 보이고(기본은 점과 선 · form length 면 날마다 막대), 달마다 평균·최대를 글자 행으로 남긴다(그림이 안 보여도 문서가 선다). '
|
|
1458
1820
|
+ 'x 는 YYYY-MM-DD 가 기본. marks[].x 는 어느 points[].x 와 같은 값이어야 선다(사건 세로선, 아니면 ⚠).\n'
|
|
1459
1821
|
+ '· `quotes`: [{text, lang?, source_text, source_url?, verdict?, translation?}] — **원문 인용.** 공식 문서·회신 문구를 원문 그대로 싣는다(표기 안 품, 줄바꿈 유지). '
|
|
1460
1822
|
+ 'source_text·source_url 둘 중 하나는 있어야 한다(없으면 ⚠). verdict 는 도판과 같은 축(직접 확인·조사 보고·미확인). 절 본문 맨 앞에 선다.\n'
|
|
1461
|
-
+ '· `diagrams`: [{id?, caption, ir
|
|
1462
|
-
+ '
|
|
1823
|
+
+ '· `diagrams`: [{id?, caption, ir? | compare?, alt?, source_text?, verdict?, note?, wide?, origin?}] — **도해.** 설계 데이터를 넣으면 서버가 '
|
|
1824
|
+
+ '고정 판 Archify 로 정지 SVG 를 굽는다(관보 글자 기준 15px). `ir` 은 Archify 설계 데이터 하나 — diagram_type architecture·workflow(schema_version 2)·sequence·dataflow·lifecycle, '
|
|
1825
|
+
+ '`compare` 는 {base, head} — 두 판 다 구성도 설계 데이터(두 판 비교: 겹친 그림 + 바뀐 항목 표). 미리 구운 `svg` 칸은 받지 않는다(⚠ · 번호도 안 잡는다). '
|
|
1826
|
+
+ '어느 서버에서 새로 굽는지 · 종류별 한도는 위 시각 장치 줄. 못 구우면 같은 번호의 대기 상자와 ⚠ 사유(고칠 값). 우리가 그린 도해에는 verdict · 출처 줄을 비운다. '
|
|
1827
|
+
+ '그림 번호는 차트·흐름도·도판과 한 셈. 쓰는 법은 스킬 references/diagrams.md.\n'
|
|
1463
1828
|
+ '· `flow.mode:"cause"`: 원인 사슬 — 첫 칸의 「해야 할 것」 강조 면을 걷어 「사실 → 우리 상태 → 결과」가 같은 무게로 선다.\n'
|
|
1464
1829
|
+ '\n'
|
|
1465
1830
|
+ '관보 문법이 절마다 요구하는 칸:\n'
|
|
@@ -1478,7 +1843,29 @@ const REPORT_DATA = {
|
|
|
1478
1843
|
+ '· `tone`: 장 색을 뜻으로 고른다 — you(독자 몫)·deep(요지)·neutral(허브)·'
|
|
1479
1844
|
+ 'done(끝난 것)·wait(대기)·hold(유보). 생략하면 kind 가 정한다.\n'
|
|
1480
1845
|
+ '· kind:status 전용 — `board_title`(대조표 제목) · `board_hint`(표 아래 안내).\n'
|
|
1481
|
-
+ '· kind:note 전용 — `paragraphs`(
|
|
1846
|
+
+ '· kind:note 전용 — `paragraphs`(문자열 목록) · `limit_label`(기본 강조 박스 제목). '
|
|
1847
|
+
+ 'presentation:"prose" 를 선택하면 paragraphs 를 항목 상자 대신 실제 산문 문단으로 싣는다. '
|
|
1848
|
+
+ '문단 번호는 절 배열 순번·문단 배열 순번이며 §1.2 는 1번째 절의 2번째 문단 앵커를 가리킨다. '
|
|
1849
|
+
+ 'paragraph_kinds 는 paragraphs 와 같은 길이·순서의 역할 목록이다(아래 items 계약).',
|
|
1850
|
+
items: {
|
|
1851
|
+
type: 'object',
|
|
1852
|
+
properties: {
|
|
1853
|
+
presentation: {
|
|
1854
|
+
type: 'string', enum: ['prose'],
|
|
1855
|
+
description: 'kind:"note" 에서만 선택하는 산문 표시. paragraphs 를 실제 <p> 문단으로 '
|
|
1856
|
+
+ '싣고 요약 refs 가 가리키는 case-<두 자리 절 순번>-<문단 순번> 앵커를 유지한다. '
|
|
1857
|
+
+ '생략하면 기존 강조 박스·limit_label 을 쓴다. 다른 kind 의 본문 장치는 바뀌지 않는다.',
|
|
1858
|
+
},
|
|
1859
|
+
paragraph_kinds: {
|
|
1860
|
+
type: 'array', maxItems: 120,
|
|
1861
|
+
items: { type: 'string', enum: ['fact', 'context', 'analysis', 'proposal', 'unknown'] },
|
|
1862
|
+
description: 'presentation:"prose" 문단 역할. paragraphs 와 같은 길이·순서로 둔다. '
|
|
1863
|
+
+ 'fact(확인 사실)·context(맥락)는 별도 표식 없이 읽히고, analysis·proposal·unknown 은 '
|
|
1864
|
+
+ '각각 「해석」·「제안」·「추가 확인 필요」로 표시된다. 생략하면 역할 표식이 없다. '
|
|
1865
|
+
+ '역할은 글의 구분이며 사실 검증이나 접근 권한을 대신하지 않는다.',
|
|
1866
|
+
},
|
|
1867
|
+
},
|
|
1868
|
+
},
|
|
1482
1869
|
},
|
|
1483
1870
|
chapters: {
|
|
1484
1871
|
type: 'array',
|
|
@@ -1523,9 +1910,10 @@ const REPORT_DATA = {
|
|
|
1523
1910
|
},
|
|
1524
1911
|
summary: {
|
|
1525
1912
|
type: 'array',
|
|
1526
|
-
description: '
|
|
1527
|
-
+ '
|
|
1528
|
-
+ '
|
|
1913
|
+
description: '요지 [{lead, body, refs?}] — kind:"summary" 절, summary_position:"front" 이면 앞 요약면에도 선다. '
|
|
1914
|
+
+ '각 문단은 근거로 삼은 실제 사례 또는 kind:"note", presentation:"prose" 의 문단 번호를 '
|
|
1915
|
+
+ '최소 하나 밝혀야 한다(예: 본문에 "§3.1" 이라 쓰거나 refs:["3.1"]). '
|
|
1916
|
+
+ '산문이면 3번째 절의 1번째 문단이다. 없는 번호나 근거 표기 누락은 서버가 거절한다. 결정 모드에서는 받지 않는다(거절).',
|
|
1529
1917
|
},
|
|
1530
1918
|
open_items: {
|
|
1531
1919
|
type: 'array',
|
|
@@ -1533,7 +1921,7 @@ const REPORT_DATA = {
|
|
|
1533
1921
|
+ '상대의 판단이 필요한 것만. `priority: "must"` 인 항목은 「가장 시급한 요청」으로 '
|
|
1534
1922
|
+ '따로 서고 나머지와 괘선으로 갈린다 — **먼저 정해져야 하는 것에만** 붙여라. '
|
|
1535
1923
|
+ '전부에 붙이면 갈림이 사라져 안 붙인 것과 같아진다. '
|
|
1536
|
-
+ '`refs` 는 근거 사례 번호이고 **첫 하나만** 「근거」 단추가 된다.',
|
|
1924
|
+
+ '`refs` 는 근거 사례 번호이고 **첫 하나만** 「근거」 단추가 된다. 결정 모드에서는 받지 않는다(거절).',
|
|
1537
1925
|
},
|
|
1538
1926
|
footer: { type: 'array' },
|
|
1539
1927
|
assets: {
|
|
@@ -1618,11 +2006,11 @@ const REPORT_DATA = {
|
|
|
1618
2006
|
},
|
|
1619
2007
|
alt: { type: 'string', description: '설명적 대체 텍스트 — 접근성 계약. 그림과 함께 다닌다' },
|
|
1620
2008
|
origin: { type: 'string', description: '생성 job 식별자·.mmd 원본 경로·제공자 이름' },
|
|
1621
|
-
caption: { type: 'string', description: '도판 캡션. 그림 번호는 자리가 매기므로 적지
|
|
1622
|
-
source_text: { type: 'string', description: '캡션 아래 「출처」 줄 — 이 그림이 어디서
|
|
2009
|
+
caption: { type: 'string', description: '도판 캡션. 그림 번호는 자리가 매기므로 적지 않는다. 캡처·영상은 끝에 증거 범위 한 구절(찍은 환경·날짜·옮겨 적은 부분, 예 「… · 운영 관리자 웹 · 8. 23. 촬영」)' },
|
|
2010
|
+
source_text: { type: 'string', description: '캡션 아래 「출처」 줄 — 이 그림이 어디서 왔는지. 캡처는 「화면 이름 · 날짜」를 위치로 적는다(예 「주문 목록 화면 · 8. 23.」)' },
|
|
1623
2011
|
verdict: {
|
|
1624
2012
|
type: 'string', enum: ['직접 확인', '조사 보고', '미확인'],
|
|
1625
|
-
description: '이 근거를 **얼마나 믿어도 되는지**. 캡션 끝에 칩으로
|
|
2013
|
+
description: '이 근거를 **얼마나 믿어도 되는지**. 캡션 끝에 칩으로 선다. 우리가 그린 도해·도판에는 붙이지 않는다.\n'
|
|
1626
2014
|
+ '· 직접 확인 — 원문을 직접 열어 그 수치를 확인함\n'
|
|
1627
2015
|
+ '· 조사 보고 — 2차 출처를 경유해 확인함. 원문 대조는 안 함\n'
|
|
1628
2016
|
+ '· 미확인 — 널리 쓰이나 1차 출처를 특정하지 못함\n'
|
|
@@ -1636,7 +2024,7 @@ const REPORT_DATA = {
|
|
|
1636
2024
|
description: '**전폭 도판.** 두 단 그리드 밖, 절 끝에 문서 폭(약 1030px)으로 선다. '
|
|
1637
2025
|
+ '나란히 둔 화면 둘처럼 본문 단(760px)에서 절반으로 줄면 글자가 안 읽히는 그림에 켠다. '
|
|
1638
2026
|
+ '켜면 그 절의 그림 번호는 마지막이 된다. 켜든 말든 모든 도판은 눌러서 크게 보기(원본 '
|
|
1639
|
-
+ '긴 변 1400px)가 열린다. 집필 때 figure.mjs --display 로 표시 글자 크기를 먼저
|
|
2027
|
+
+ '긴 변 1400px)가 열린다. 집필 때 figure.mjs --display 로 표시 글자 크기를 먼저 잰다(하한 15px — 스킬 references/readability.md R1).',
|
|
1640
2028
|
},
|
|
1641
2029
|
brief: {
|
|
1642
2030
|
type: 'string',
|
|
@@ -1683,8 +2071,8 @@ const REPORT_DATA = {
|
|
|
1683
2071
|
symptom: { type: 'string' },
|
|
1684
2072
|
cause: { type: 'string' },
|
|
1685
2073
|
fix: { type: 'string' },
|
|
1686
|
-
how_to_check: { type: 'string', description: '
|
|
1687
|
-
remaining: { type: 'string', description: '아직 닫히지 않은 것 — 미확인·미결인 사실과 운영에 아직 안 나간 우리 작업. 독자에게 부탁할 것은 open_items 로(선택)' },
|
|
2074
|
+
how_to_check: { type: 'string', description: '독자가 그 결과를 **볼 자리** + **우리가 그것을 본 때·환경**. 볼 자리는 이 문서의 그림·영상·원문 인용(`{{그림:id}}`·quotes) 또는 독자 자기 화면(어디를 열면) — 자기 화면만 볼 자리로 쓴 확인 방법도 되지만 렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」은 남고 그 수가 공개·교체 승인 물음에 실린다(「…에서 봄」 꼬리는 증거로 치지 않는다). 우리가 안 본 결과는 여기 쓰지 않고 remaining 에만 둔다 — 동사는 우리가 본 수준까지(예 「주문서를 작성해 저장하면 새로고침 없이 그 주문이 목록 첫 줄에 나타남({{그림:order-saved}}), 8. 23. 운영 관리자 웹에서 봄」). 주어는 화면·값·우리(독자에게 시키는 문장 아님)' },
|
|
2075
|
+
remaining: { type: 'string', description: '아직 닫히지 않은 것 — 미확인·미결인 사실과 운영에 아직 안 나간 우리 작업. 우리가 안 본 결과는 여기에만 적는다. 독자에게 부탁할 것은 open_items 로(선택)' },
|
|
1688
2076
|
source_url: { type: 'string', description: 'http(s) 제보 원본 링크(선택)' },
|
|
1689
2077
|
quotes: {
|
|
1690
2078
|
type: 'array',
|
|
@@ -1719,7 +2107,9 @@ const REPORT_DATA = {
|
|
|
1719
2107
|
},
|
|
1720
2108
|
},
|
|
1721
2109
|
// 문서가 서려면 있어야 하는 것만. 나머지는 비어 있어도 발행된다.
|
|
1722
|
-
|
|
2110
|
+
// `sections` 는 필수에서 뺐다(2026-10-06) — 결정 모드는 대장 절이 없어도 선다. 서버는 없으면 빈 목록으로 다룬다.
|
|
2111
|
+
// 모드별 규칙(결정 블록의 뼈대 · 결정 모드가 안 받는 칸)은 스키마 if/then 으로 쓰지 않고 서버가 거절한다(K1~K4 · V-*).
|
|
2112
|
+
required: ['title_tag'],
|
|
1723
2113
|
};
|
|
1724
2114
|
|
|
1725
2115
|
/* ── 세션 이식 ───────────────────────────────────────────────────────
|
|
@@ -2002,12 +2392,14 @@ function transcriptDir(dir) {
|
|
|
2002
2392
|
|
|
2003
2393
|
/** uuid 로 이 기계의 트랜스크립트를 찾는다. 지금 프로젝트를 먼저 보고, 없으면 전체를 훑는다. */
|
|
2004
2394
|
function findTranscript(uuid) {
|
|
2395
|
+
const native = findCodexRollout(uuid);
|
|
2396
|
+
if (native) return cachedCodexProjection(native, codexProjectionRoot()) || null;
|
|
2005
2397
|
const here = path.join(transcriptDir(process.cwd()), `${uuid}.jsonl`);
|
|
2006
2398
|
if (fs.existsSync(here)) return here;
|
|
2007
2399
|
const root = path.join(os.homedir(), '.claude', 'projects');
|
|
2008
2400
|
// Claude Code 가 없는 기계(Cursor 만 깔린 곳)에는 이 디렉터리 자체가 없다 —
|
|
2009
2401
|
// 「전사 없음」이지 오류가 아니다. 던지면 mail 등 도구가 통째로 죽는다(smoke 실측).
|
|
2010
|
-
if (!fs.existsSync(root)) return
|
|
2402
|
+
if (!fs.existsSync(root)) return cursorTranscriptOf(uuid);
|
|
2011
2403
|
for (const d of fs.readdirSync(root, { withFileTypes: true })) {
|
|
2012
2404
|
if (!d.isDirectory()) continue;
|
|
2013
2405
|
const f = path.join(root, d.name, `${uuid}.jsonl`);
|
|
@@ -2190,9 +2582,9 @@ function cursorWorkspaceFolder() {
|
|
|
2190
2582
|
return cursorSeen.folder;
|
|
2191
2583
|
}
|
|
2192
2584
|
|
|
2193
|
-
/**
|
|
2585
|
+
/** Harness identity precedes UI discovery. Native Codex THREAD_ID identifies the current child. */
|
|
2194
2586
|
function mySessionId() {
|
|
2195
|
-
return process.env.CLAUDE_CODE_SESSION_ID || cursorActiveComposer();
|
|
2587
|
+
return process.env.CLAUDE_CODE_SESSION_ID || codexSessionId() || cursorActiveComposer();
|
|
2196
2588
|
}
|
|
2197
2589
|
|
|
2198
2590
|
/**
|
|
@@ -2217,12 +2609,12 @@ function hookLane() {
|
|
|
2217
2609
|
return hookLaneMemo.v;
|
|
2218
2610
|
}
|
|
2219
2611
|
|
|
2220
|
-
function gitHere() {
|
|
2612
|
+
function gitHere(dir = process.cwd()) {
|
|
2221
2613
|
try {
|
|
2222
|
-
const r = spawnSync('git', ['-C',
|
|
2614
|
+
const r = spawnSync('git', ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
2223
2615
|
{ encoding: 'utf8' });
|
|
2224
2616
|
const b = (r.stdout || '').trim();
|
|
2225
|
-
const c = spawnSync('git', ['-C',
|
|
2617
|
+
const c = spawnSync('git', ['-C', dir, 'rev-parse', '--short', 'HEAD'],
|
|
2226
2618
|
{ encoding: 'utf8' });
|
|
2227
2619
|
return [b, (c.stdout || '').trim()].filter(Boolean).join('@');
|
|
2228
2620
|
} catch { return ''; }
|
|
@@ -3037,12 +3429,28 @@ async function pushAppend(a) {
|
|
|
3037
3429
|
* 이 대화를 **무엇이** 만들었는가. 판번호만으로는 세션끼리 구별이 안 되므로 클라이언트를 함께 싣는다.
|
|
3038
3430
|
* 값이 없으면 키를 안 만든다 — 없는 것을 「알 수 없음」으로 적으면 그 값이 다시 상수가 된다.
|
|
3039
3431
|
*/
|
|
3040
|
-
function toolsStamp() {
|
|
3432
|
+
function toolsStamp(native = null) {
|
|
3041
3433
|
const t = { mcp: VERSION };
|
|
3042
|
-
if (
|
|
3434
|
+
if (native) { t.client = `codex/${native.cli_version}`; t.source_format = 'codex-rollout'; t.codec = CODEX_CODEC; }
|
|
3435
|
+
else if (CLIENT?.name) t.client = CLIENT.version ? `${CLIENT.name}/${CLIENT.version}` : String(CLIENT.name);
|
|
3043
3436
|
return t;
|
|
3044
3437
|
}
|
|
3045
3438
|
|
|
3439
|
+
function codexProjectionRoot() { return path.join(path.dirname(CRED_FILE), 'codex-projections'); }
|
|
3440
|
+
function isNativeProjection(file) { return typeof file === 'string' && path.dirname(file) === codexProjectionRoot(); }
|
|
3441
|
+
function nativeSessionTools(tools) {
|
|
3442
|
+
if (typeof tools === 'string') { try { tools = JSON.parse(tools); } catch { return false; } }
|
|
3443
|
+
return tools?.source_format === 'codex-rollout' || /^codex\//.test(String(tools?.client || ''));
|
|
3444
|
+
}
|
|
3445
|
+
|
|
3446
|
+
async function prepareNativeSession(uuid) {
|
|
3447
|
+
const native = findCodexRollout(uuid);
|
|
3448
|
+
if (!native) return null;
|
|
3449
|
+
const prepared = await prepareCodexProjection(native, { cacheRoot: codexProjectionRoot(), scrub: scrubTranscript, scrubRevision: SCRUB_REV });
|
|
3450
|
+
pathCache.set(uuid, { file: prepared.projection, at: Date.now() });
|
|
3451
|
+
return prepared;
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3046
3454
|
/** 개행 없는 끝 줄이 **다 쓴 줄**인가 — 온전한 JSON 객체로 읽히면 그렇다(반쪽 줄은 객체로 안 읽힌다). 공백뿐이면 아니다. */
|
|
3047
3455
|
function cursorTailDone(tail) {
|
|
3048
3456
|
const s = tail.toString('utf8');
|
|
@@ -3058,16 +3466,23 @@ function cursorTailDone(tail) {
|
|
|
3058
3466
|
* `auto` 와 달리 머리·folder 를 안 싣는다) · `progress` 는 요청 하나(조각 하나)가 끝날 때마다 — 임대의 묵음 시계를 민다(§3.2).
|
|
3059
3467
|
*/
|
|
3060
3468
|
async function pushSession(tenant, uuid, title, dryRun, bind = 'skip', auto = null, hooks = null, tenantIdHint = 0) {
|
|
3061
|
-
const
|
|
3469
|
+
const native = await prepareNativeSession(uuid);
|
|
3470
|
+
const f = native?.projection || findTranscript(uuid);
|
|
3062
3471
|
if (!f) throw new Error(`이 기계에 그 세션이 없습니다: ${uuid}`);
|
|
3063
3472
|
// 한 fd 로 stat 과 읽기를 한다 — 대응표의 ino·dev 가 읽은 그 파일의 것이어야 한다(§2.11).
|
|
3064
3473
|
// ★ **덧붙임은 이 fd 를 자리 읽기로만 쓴다**(C8) — 전사를 통째로 안 읽는 것이 이 커밋의 요지다.
|
|
3065
3474
|
const fd = fs.openSync(f, 'r');
|
|
3066
|
-
try {
|
|
3475
|
+
try {
|
|
3476
|
+
const result = await pushSessionFd(fd, f, tenant, uuid, title, dryRun, bind, auto, hooks, tenantIdHint, native);
|
|
3477
|
+
return native ? { ...result, before: native.source_bytes, source_client: 'codex', source: {
|
|
3478
|
+
codec: native.codec, raw_bytes: native.source_bytes, complete_bytes: native.raw_off,
|
|
3479
|
+
records: native.records, kept: native.kept, deferred_bytes: native.deferred_bytes, projection_bytes: native.projection_bytes,
|
|
3480
|
+
omitted_images: native.omitted_images, omitted_large: native.omitted_large } } : result;
|
|
3481
|
+
}
|
|
3067
3482
|
finally { fs.closeSync(fd); }
|
|
3068
3483
|
}
|
|
3069
3484
|
|
|
3070
|
-
async function pushSessionFd(fd, f, tenant, uuid, title, dryRun, bind, auto, hooks, tenantIdHint) {
|
|
3485
|
+
async function pushSessionFd(fd, f, tenant, uuid, title, dryRun, bind, auto, hooks, tenantIdHint, native = null) {
|
|
3071
3486
|
const st = fs.fstatSync(fd, { bigint: true });
|
|
3072
3487
|
const size = Number(st.size);
|
|
3073
3488
|
const isCursorBody = f.includes(`${path.sep}.cursor${path.sep}projects${path.sep}`);
|
|
@@ -3153,7 +3568,7 @@ async function pushSessionFd(fd, f, tenant, uuid, title, dryRun, bind, auto, hoo
|
|
|
3153
3568
|
const body = {
|
|
3154
3569
|
tenant, uuid,
|
|
3155
3570
|
origin_cwd: sc.cwd || (isCursorBody ? relHome(cursorWorkspaceFolder() || '') : relHome(process.cwd())),
|
|
3156
|
-
origin_git: gitHere(), tools: toolsStamp(),
|
|
3571
|
+
origin_git: gitHere(native ? (sc.cwd_raw || native.cwd) : process.cwd()), tools: toolsStamp(native),
|
|
3157
3572
|
jsonl: sc.jsonl,
|
|
3158
3573
|
};
|
|
3159
3574
|
if (typeof title === 'string' && title.trim() !== '') body.title = title.trim();
|
|
@@ -3914,7 +4329,8 @@ async function resolveFetch(cred, body) {
|
|
|
3914
4329
|
try {
|
|
3915
4330
|
const res = await fetch(`${BASE}/api/folders/resolve`, {
|
|
3916
4331
|
method: 'POST',
|
|
3917
|
-
|
|
4332
|
+
// 판 머리 — api() 와 같은 규율(모든 요청 · 하위 업무 번호 3단계 잣대 ②).
|
|
4333
|
+
headers: { 'X-Genetics-Client': `genetics-mcp/${VERSION}`, Authorization: `Bearer ${cred.token}`, 'Content-Type': 'application/json' },
|
|
3918
4334
|
body: JSON.stringify(body),
|
|
3919
4335
|
signal: ac.signal,
|
|
3920
4336
|
});
|
|
@@ -4162,6 +4578,7 @@ function beatTargets(extra = []) {
|
|
|
4162
4578
|
const now = Date.now();
|
|
4163
4579
|
const out = [];
|
|
4164
4580
|
for (const [uuid, e] of Object.entries(idx)) {
|
|
4581
|
+
if (!sessionScope(uuid)) continue;
|
|
4165
4582
|
if (!e?.tenant_id) continue;
|
|
4166
4583
|
const f = transcriptOf(uuid);
|
|
4167
4584
|
if (!f) continue; // 이 기계에 사본이 없으면 「쥐고 있다」가 아니다
|
|
@@ -4233,6 +4650,7 @@ async function beatOnce(recs, { closing = false } = {}) {
|
|
|
4233
4650
|
const res = await fetch(`${BASE}/api/sessions/beat`, {
|
|
4234
4651
|
method: 'POST',
|
|
4235
4652
|
headers: {
|
|
4653
|
+
'X-Genetics-Client': `genetics-mcp/${VERSION}`, // 판 머리 — api() 와 같은 규율(모든 요청 · 하위 업무 번호 3단계 잣대 ②)
|
|
4236
4654
|
Authorization: `Bearer ${cred.token}`,
|
|
4237
4655
|
'Content-Type': 'application/x-lim-beat',
|
|
4238
4656
|
/*
|
|
@@ -4742,6 +5160,40 @@ function autoFolderOf(real, now = Date.now()) {
|
|
|
4742
5160
|
let autoScan = { at: 0, rows: [] }; // 되찾기 훑기의 마지막 결과 — [{ uuid, file, mtime }]
|
|
4743
5161
|
const autoSettled = new Map(); // uuid → 'size:mtime' — 서버가 SAME·BEHIND 라 한 사본(파일이 바뀌면 다시 본다)
|
|
4744
5162
|
const autoTenantWait = new Map(); // tenant_id → epoch ms — 429 auto_rate 의 retry_after(그 상한은 소속처 단위다)
|
|
5163
|
+
let nativeInventory = { at: 0, rows: [] };
|
|
5164
|
+
const nativeAutoRows = new Map();
|
|
5165
|
+
|
|
5166
|
+
/** Native date directories carry no tenant. Only validated source cwd and an effective confirmed binding do. */
|
|
5167
|
+
async function refreshCodexSessions(now = Date.now()) {
|
|
5168
|
+
const byDir = autoFolderMap(resolveCacheRead(), now), idx = indexRead(), mine = mySessionId();
|
|
5169
|
+
if (now - nativeInventory.at >= AUTO_SCAN_EVERY_MS) {
|
|
5170
|
+
nativeInventory = { at: now, rows: codexRollouts().filter(r => sessionScope(r.uuid)).map(r => codexMetadata(r.file, r.uuid)).filter(Boolean)
|
|
5171
|
+
.sort((a, b) => b.mtime - a.mtime) };
|
|
5172
|
+
}
|
|
5173
|
+
const perFolder = new Map();
|
|
5174
|
+
nativeAutoRows.clear();
|
|
5175
|
+
for (const meta of nativeInventory.rows) {
|
|
5176
|
+
// Background child transcripts are separate conversations, never implicit root-session backfill.
|
|
5177
|
+
if (!codexHumanRoot(meta)) continue;
|
|
5178
|
+
const known = autoRowOnServer(idx[meta.uuid]);
|
|
5179
|
+
let real;
|
|
5180
|
+
try { real = fs.realpathSync(meta.cwd); } catch { continue; }
|
|
5181
|
+
const hit = byDir.get(transcriptDir(real));
|
|
5182
|
+
const permitted = autoPushOn() && hit?.entry.state === 'bound' && hit.entry.auto_upload === true && !hit.entry.stale;
|
|
5183
|
+
if (!known && !permitted) continue;
|
|
5184
|
+
if (!known && meta.uuid !== mine && (now - meta.mtime > AUTO_SCAN_MAX_AGE_MS || meta.mtime < autoFloorMs(hit.entry))) continue;
|
|
5185
|
+
const count = perFolder.get(real) || 0;
|
|
5186
|
+
if (!known && meta.uuid !== mine && count >= AUTO_SCAN_PER_FOLDER) continue;
|
|
5187
|
+
perFolder.set(real, count + 1);
|
|
5188
|
+
try {
|
|
5189
|
+
const fresh = codexMetadata(meta.file, meta.uuid);
|
|
5190
|
+
if (!fresh) continue;
|
|
5191
|
+
const prepared = await prepareCodexProjection(fresh, { cacheRoot: codexProjectionRoot(), scrub: scrubTranscript, scrubRevision: SCRUB_REV });
|
|
5192
|
+
pathCache.set(meta.uuid, { file: prepared.projection, at: now });
|
|
5193
|
+
if (hit) nativeAutoRows.set(meta.uuid, { ...prepared, hit });
|
|
5194
|
+
} catch (e) { warnOnce(`codex-projection-${meta.uuid}`, `native Codex 세션 ${meta.uuid.slice(0, 8)}을 준비하지 못했다: ${e.message}`); }
|
|
5195
|
+
}
|
|
5196
|
+
}
|
|
4745
5197
|
|
|
4746
5198
|
/** 전사 자리(transcriptDir) → 그 자리를 물었던 폴더의 답. resolve 캐시의 `by_path` 가 유일한 재료다. */
|
|
4747
5199
|
function autoFolderMap(cache = resolveCacheRead(), now = Date.now()) {
|
|
@@ -4765,8 +5217,8 @@ function autoCandidates(now = Date.now()) {
|
|
|
4765
5217
|
const mine = mySessionId();
|
|
4766
5218
|
const seen = new Map();
|
|
4767
5219
|
const add = (uuid, file) => {
|
|
4768
|
-
if (!file || seen.has(uuid) || isCursorTranscript(file)) return;
|
|
4769
|
-
const hit = byDir.get(path.dirname(file));
|
|
5220
|
+
if (!sessionScope(uuid) || !file || seen.has(uuid) || isCursorTranscript(file)) return;
|
|
5221
|
+
const hit = nativeAutoRows.get(uuid)?.hit || byDir.get(path.dirname(file));
|
|
4770
5222
|
if (!hit) return;
|
|
4771
5223
|
let st;
|
|
4772
5224
|
try { st = fs.statSync(file); } catch { return; }
|
|
@@ -4801,6 +5253,7 @@ function autoCandidates(now = Date.now()) {
|
|
|
4801
5253
|
autoScan = { at: now, rows };
|
|
4802
5254
|
}
|
|
4803
5255
|
for (const r of autoScan.rows) if (!idx[r.uuid]) add(r.uuid, r.file);
|
|
5256
|
+
for (const [uuid, native] of nativeAutoRows) add(uuid, native.projection);
|
|
4804
5257
|
const out = [...seen.values()];
|
|
4805
5258
|
for (const c of out) {
|
|
4806
5259
|
if (c.entry.state !== 'bound' || c.entry.auto_upload !== true) continue;
|
|
@@ -5162,6 +5615,8 @@ function autoPolicyOf(t, ctx) {
|
|
|
5162
5615
|
* ⚠ 자동이 **꺼졌거나·낡았거나·ambiguous** 인 폴더는 아래 null ① 그대로다(privacy B5) — 오너가 끈 동의를 옛 길이 안 거스른다.
|
|
5163
5616
|
*/
|
|
5164
5617
|
const boundOn = st === 'bound' && e.auto_upload === true && !e.stale;
|
|
5618
|
+
// Native support starts with explicit effective binding consent; it has no historical legacy-upload lane.
|
|
5619
|
+
if (t.native && !(boundOn && t.c)) return no('not_bound_auto');
|
|
5165
5620
|
// ── bound ──
|
|
5166
5621
|
if (boundOn && t.c) {
|
|
5167
5622
|
if (ctx.client_old) return no('client_old'); // ① 만 끈다(서버가 426 으로 막을 자리)
|
|
@@ -5421,18 +5876,19 @@ async function autoSyncRound(reply, recs, cands, ctx0 = null) {
|
|
|
5421
5876
|
const seen = new Set();
|
|
5422
5877
|
for (const c of autoOrder(cands)) {
|
|
5423
5878
|
seen.add(c.uuid);
|
|
5424
|
-
list.push({ uuid: c.uuid, c, file: c.file, size: c.size, mtime: c.mtime, entry: c.entry, row: c.row, rec: recOf.get(c.uuid) || null });
|
|
5879
|
+
list.push({ uuid: c.uuid, c, file: c.file, native: isNativeProjection(c.file), size: c.size, mtime: c.mtime, entry: c.entry, row: c.row, rec: recOf.get(c.uuid) || null });
|
|
5425
5880
|
}
|
|
5426
5881
|
for (const v of reply) {
|
|
5882
|
+
if (!sessionScope(v.uuid)) continue;
|
|
5427
5883
|
if (seen.has(v.uuid) || v.verdict !== BEAT_VERDICT.AHEAD) continue;
|
|
5428
5884
|
seen.add(v.uuid);
|
|
5429
5885
|
const file = transcriptOf(v.uuid);
|
|
5430
5886
|
if (!file) continue;
|
|
5431
5887
|
let st;
|
|
5432
5888
|
try { st = fs.statSync(file); } catch { continue; }
|
|
5433
|
-
const hit = isCursorTranscript(file) ? null : byDir.get(path.dirname(file));
|
|
5889
|
+
const hit = isCursorTranscript(file) ? null : (nativeAutoRows.get(v.uuid)?.hit || byDir.get(path.dirname(file)));
|
|
5434
5890
|
const row = ctx.idx?.[v.uuid] && typeof ctx.idx[v.uuid] === 'object' ? ctx.idx[v.uuid] : null;
|
|
5435
|
-
list.push({ uuid: v.uuid, c: null, file, size: st.size, mtime: st.mtimeMs, entry: hit ? hit.entry : null, row, rec: recOf.get(v.uuid) || null });
|
|
5891
|
+
list.push({ uuid: v.uuid, c: null, file, native: isNativeProjection(file), size: st.size, mtime: st.mtimeMs, entry: hit ? hit.entry : null, row, rec: recOf.get(v.uuid) || null });
|
|
5436
5892
|
}
|
|
5437
5893
|
for (const t of list) {
|
|
5438
5894
|
t.v = verdict.get(t.uuid) || null;
|
|
@@ -5453,7 +5909,7 @@ function autoHoldTenants(uuid) {
|
|
|
5453
5909
|
const hits = [];
|
|
5454
5910
|
const f = transcriptOf(uuid);
|
|
5455
5911
|
if (f && !isCursorTranscript(f)) {
|
|
5456
|
-
const hit = autoFolderMap(cache).get(path.dirname(f));
|
|
5912
|
+
const hit = nativeAutoRows.get(uuid)?.hit || autoFolderMap(cache).get(path.dirname(f));
|
|
5457
5913
|
if (hit) hits.push(hit.entry);
|
|
5458
5914
|
}
|
|
5459
5915
|
if (uuid === mySessionId()) {
|
|
@@ -5485,6 +5941,7 @@ async function beatTick() {
|
|
|
5485
5941
|
try {
|
|
5486
5942
|
// 폴더 소속은 서버가 정한다 — 때가 되면 resolve 캐시를 갱신한다(§5.3). 조기 반환보다 **앞**이다.
|
|
5487
5943
|
try { await resolveTick(); } catch { /* 소속 확인 실패가 심박을 멈추면 안 된다 */ }
|
|
5944
|
+
try { await refreshCodexSessions(); } catch (e) { warnOnce('codex-discovery', `native Codex 후보를 확인하지 못했다: ${e.message}`); }
|
|
5488
5945
|
minClientTick(); // 판 비교 — 30분에 한 번(§5.8). 조기 반환보다 **앞**이다
|
|
5489
5946
|
// 확정·자동 폴더의 후보(§5.5) — 서버 소속을 이 기계가 모르는 사본도 판정을 받게 심박에 싣는다.
|
|
5490
5947
|
let cands = [];
|
|
@@ -5649,7 +6106,8 @@ function taskReadResult(name, args, body) {
|
|
|
5649
6106
|
const tasks = rows.map(row => {
|
|
5650
6107
|
if (!row || typeof row.uid !== 'string' || !/^[a-z0-9]{8}$/.test(row.uid)
|
|
5651
6108
|
|| ['title', 'state', 'state_word', 'done_when'].some(key => typeof row[key] !== 'string')) throw new Error('할 일의 내용을 확인하지 못했습니다.');
|
|
5652
|
-
|
|
6109
|
+
// 하위 업무 번호 — 사람이 부르는 이름(승인 설계 4-1). 할 일이 번호를 들 때만 서버가 싣는다(0단계 소속처는 칸이 없다 — 결과 그대로).
|
|
6110
|
+
return Object.assign(pick(row, ['uid', 'title', 'state', 'state_word', 'done_when']), taskCodeOf(row) ? { origin_code: row.origin_code } : {});
|
|
5653
6111
|
});
|
|
5654
6112
|
result = { tenant: args.tenant, tasks, total: body.open, page: pick(page, ['limit', 'offset', 'returned', 'truncated', 'next_offset']) };
|
|
5655
6113
|
}
|
|
@@ -5657,6 +6115,13 @@ function taskReadResult(name, args, body) {
|
|
|
5657
6115
|
return result;
|
|
5658
6116
|
}
|
|
5659
6117
|
|
|
6118
|
+
/** 할 일의 하위 업무 번호(서버 응답의 origin_code — 서버가 정한 정본 모양일 때만). 없거나 정본이 아니면 '' — 지어내지 않는다. 정본은 서버 work_item_code_ok 와
|
|
6119
|
+
* 같은 자다(일련은 두 자리 채움 · 그 밖 앞 0 없음 · 자식은 1 부터 앞 0 없음 — W-004 · W-01#04 는 아니다): 원격(mcp_task_results.php)과 stdio 가 같은 답. */
|
|
6120
|
+
function taskCodeOf(task) {
|
|
6121
|
+
const code = task && typeof task.origin_code === 'string' ? task.origin_code : '';
|
|
6122
|
+
return /^[A-Z]{1,4}-(?:0[0-9]|[1-9][0-9]{1,3})(?:#[1-9][0-9]{0,2})?$/.test(code) ? code : '';
|
|
6123
|
+
}
|
|
6124
|
+
|
|
5660
6125
|
/**
|
|
5661
6126
|
* 할 일에 **완료 보고**를 올리는 문(M1 · 2026-09-26) — 사람이 화면에서 쓰는 것과 **같은 서버 함수**(`work_task_do_report`)다.
|
|
5662
6127
|
*
|
|
@@ -5717,7 +6182,8 @@ function taskWriteResult(uid, r) {
|
|
|
5717
6182
|
const counts = t.evidence_counts || {};
|
|
5718
6183
|
const n = Number(counts.total) || (Array.isArray(c.evidence) ? c.evidence.length : 0);
|
|
5719
6184
|
const v = Number(counts.usable) || 0;
|
|
5720
|
-
const
|
|
6185
|
+
const code = taskCodeOf(t); // 하위 업무 번호가 있으면 사람이 부르는 이름으로 앞에(승인 설계 4-1) — 없으면 예전 그대로
|
|
6186
|
+
const head = `«${code ? code + ' · ' + uid : uid}» — ${c.kind_word || '완료 보고'} #${c.id ?? '?'} 올림 · 근거 ${n}(검증 ${v})`;
|
|
5721
6187
|
const state = t.state_word ? ` · 상태: ${t.state_word}` : '';
|
|
5722
6188
|
return head + state + '.' + (r?.says ? ' ' + r.says : '');
|
|
5723
6189
|
}
|
|
@@ -5849,7 +6315,9 @@ const TOOLS = [
|
|
|
5849
6315
|
'메모에 **답글로** 회신한다. 이견이 있거나 물을 것이 있으면 대화창이 아니라 여기에 쓴다 — '
|
|
5850
6316
|
+ '저자는 자기가 메모를 단 그 자리에서 답을 본다.\n'
|
|
5851
6317
|
+ '고칠 수 없는 이유, 다르게 본 근거, 되묻는 것이 여기 온다. '
|
|
5852
|
-
+ '
|
|
6318
|
+
+ '단 **접근·권한 요청**(계정·기기·창·메일함·자동 조작 허락)은 답글이 아니라 대화창에서 사용자에게 청한다 — '
|
|
6319
|
+
+ '증빙(화면·영상·근거)을 요구한 메모를 못 찍었으면 「못 함」 답글 전에 그 접근부터 청한다.\n'
|
|
6320
|
+
+ '고쳤으면 답글이 아니라 resolve_note 다 — 증빙을 요구한 메모는 실물(캡처·영상·원문)이 실린 판에서만 닫는다(도해·설명을 더한 것으로 닫지 않는다).\n'
|
|
5853
6321
|
+ '★ **검토 회신에는 이 도구로 답하지 않는다** — 그쪽은 고객에게 보이는 글이라 사람이 포털에서 쓴다. 할 말은 작성자에게 대화로 전한다.',
|
|
5854
6322
|
inputSchema: {
|
|
5855
6323
|
type: 'object',
|
|
@@ -5868,6 +6336,7 @@ const TOOLS = [
|
|
|
5868
6336
|
+ '「무엇에 반영됐는지」를 나중에도 안다.\n'
|
|
5869
6337
|
+ '★ 고치기 **전에** 부르지 말 것 — 이 표시가 곧 「이 메모는 끝났다」이고, '
|
|
5870
6338
|
+ '표시하는 순간 미반영 목록에서 빠져 다시 눈에 안 띈다.\n'
|
|
6339
|
+
+ '증빙(화면·영상·근거)을 요구한 메모는 실물이 실린 판의 rev 로만 닫는다 — 도해·설명을 더한 판으로 닫지 않는다.\n'
|
|
5871
6340
|
+ '검토 회신은 `note` 대신 `reply` 로 가리킨다 — 표시해도 회신은 고객에게 그대로 보이고, 그 회신이 집은 문장이 바뀐 것을 update_report 가 더는 경고하지 않는다. '
|
|
5872
6341
|
+ '문서에 반영할 것이 없는 회신(인사·이미 답한 물음)도 읽었으면 표시한다.',
|
|
5873
6342
|
inputSchema: {
|
|
@@ -5889,7 +6358,8 @@ const TOOLS = [
|
|
|
5889
6358
|
'HTML 을 만들지 말 것 — 내용만 주면 서버가 렌더한다.\n' +
|
|
5890
6359
|
'**기본은 초안이다** — 만들어도 고객에게는 보이지 않는다. 눈으로 확인한 뒤 ' +
|
|
5891
6360
|
'release_report 로 열어 준다. 바로 열려면 publish: true 를 명시할 것.\n' +
|
|
5892
|
-
'
|
|
6361
|
+
'**공개 승인**(publish: true · release_report)을 사용자에게 물을 때는 그 물음과 답에 이 판에서 건너뛴 단계(0단계 사용자 확인·독자 검토·열람 확인 등)와 증거 없는 관찰 주장 수(렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」 — 포털 dry_run 에 이 경고가 아직 없으면 스킬의 로컬 render.py 로 잰 곳 수)를 싣는다 — 「간단히」는 분량 지시이지 단계 면제가 아니다.\n' +
|
|
6362
|
+
'**문체는 주어와 자리가 정한다** — 요청 자리 밖에는 우리·사물·화면이 주어인 문장만 개조식(~함/~했음/~임)으로 쓴다. 독자가 할 일은 요청 자리(open_items·cta·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지 하십시오체로 맺는다(「회신 부탁드립니다」. 제목은 검수·보고면 「~ 요청」+기한 괄호, 고객이 정할 것은 data.decision_brief.decisions 에 — 서식은 lim-report 하나이고 결정 블록이 있으면 결정 모드로 선다(lim-decision 은 옛 이름)). 그 밖의 칸에는 사실만 적는다(「기준 수치는 미정」). dry_run 은 일부만 센다.\n' +
|
|
5893
6363
|
'쓰기 전에 결과만 보고 싶으면 dry_run: true — 렌더까지 돌려 경고를 주고 아무것도 저장하지 않는다.',
|
|
5894
6364
|
inputSchema: {
|
|
5895
6365
|
type: 'object',
|
|
@@ -5915,7 +6385,8 @@ const TOOLS = [
|
|
|
5915
6385
|
description:
|
|
5916
6386
|
'초안을 고객에게 연다. 이 순간부터 고객 목록과 열람에 뜬다. ' +
|
|
5917
6387
|
'이미 열려 있으면 아무 일도 하지 않고 그렇다고 답한다(다시 불러도 안전하다).\n' +
|
|
5918
|
-
'사람 자격으로 공개해야 보고서의 열린 항목이 일감으로
|
|
6388
|
+
'사람 자격으로 공개해야 보고서의 열린 항목이 일감으로 선다.\n' +
|
|
6389
|
+
'**공개 승인**을 사용자에게 물을 때는 그 물음과 답에 이 판에서 건너뛴 단계(0단계 사용자 확인·독자 검토·열람 확인 등)와 증거 없는 관찰 주장 수(렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」 — 포털 dry_run 에 이 경고가 아직 없으면 스킬의 로컬 render.py 로 잰 곳 수)를 싣는다 — 「간단히」는 분량 지시이지 단계 면제가 아니다.',
|
|
5919
6390
|
inputSchema: {
|
|
5920
6391
|
type: 'object',
|
|
5921
6392
|
properties: {
|
|
@@ -5966,7 +6437,8 @@ const TOOLS = [
|
|
|
5966
6437
|
'한 장만 바꾸려면 그 id 에 새 `source`/`file` 을 주고 나머지는 get_report 가 준 표식 그대로 두면 된다. ' +
|
|
5967
6438
|
'**받은 판 잠금**(2026-09-30): get_report 머리줄의 body_rev 를 `body_rev` 로 실으면, 받은 뒤 본문 판이 바뀌었을 때 서버가 409 로 멈추고 아무것도 안 쓴다. ' +
|
|
5968
6439
|
'dry_run 에도 같이 실으면 같은 409 를 덮기 전에 받는다. 서버가 이 잠금을 모르는 옛 판이면 출력이 「판을 견주지 않았다」고 알린다.\n' +
|
|
5969
|
-
'
|
|
6440
|
+
'**교체 승인**을 사용자에게 물을 때는 그 물음과 답에 이 판에서 건너뛴 단계(0단계 사용자 확인·독자 검토·열람 확인 등)와 증거 없는 관찰 주장 수(렌더러 경고 「화면을 말했는데 독자가 볼 증거가 없는 곳」 — 포털 dry_run 에 이 경고가 아직 없으면 스킬의 로컬 render.py 로 잰 곳 수)를 싣는다 — 「간단히」는 분량 지시이지 단계 면제가 아니다.\n' +
|
|
6441
|
+
'**문체는 주어와 자리가 정한다** — 요청 자리 밖에는 우리·사물·화면이 주어인 문장만 개조식(~함/~했음/~임)으로 쓴다. 독자가 할 일은 요청 자리(open_items·cta·PART I 간지(요청·결정 PART 일 때)·판권지 회신 안내·요청 절 항을 부탁하는 읽는 법 한 줄)에만 두고 사실 문장까지 하십시오체로 맺는다(「회신 부탁드립니다」. 제목은 검수·보고면 「~ 요청」+기한 괄호, 고객이 정할 것은 data.decision_brief.decisions 에 — 서식은 lim-report 하나이고 결정 블록이 있으면 결정 모드로 선다(lim-decision 은 옛 이름)). 그 밖의 칸에는 사실만 적는다(「기준 수치는 미정」). dry_run 은 일부만 센다.\n',
|
|
5970
6442
|
inputSchema: {
|
|
5971
6443
|
type: 'object',
|
|
5972
6444
|
properties: {
|
|
@@ -6055,12 +6527,20 @@ const TOOLS = [
|
|
|
6055
6527
|
+ '무엇을 적는가: **왜**가 이 장의 값이다(그때의 근거·수치·관찰). 그리고 **무슨 조건이면 다시 여나** — '
|
|
6056
6528
|
+ '이것을 비우면 그 결정은 「아직 열린 것」으로 읽힌다(닫혔는지 아무도 모르므로). '
|
|
6057
6529
|
+ '끝난 결정이면 "조건 없음(종결)"이라고 적을 것.\n'
|
|
6058
|
-
+ '모르는 칸은 **비운다** — 지어내면 「적히지 않음」보다
|
|
6530
|
+
+ '모르는 칸은 **비운다** — 지어내면 「적히지 않음」보다 나쁘다.\n'
|
|
6531
|
+
+ '★ **하위 업무 번호를 쓰는 소속처**(works 가 항목마다 W-04 같은 번호를 보여 주는 곳)에서는 번호를 서버가 매긴다 — '
|
|
6532
|
+
+ '그곳에서는 제목에 [W-02] · W-02 같은 번호를 쓰지 말 것(응답이 번호를 알려 준다). '
|
|
6533
|
+
+ '있는 하위 업무를 고쳐 적으면 item 에 그 번호, 하위로 나누면 parent 에 부모 번호를 준다. '
|
|
6534
|
+
+ '아직 번호를 안 쓰는 소속처는 item · parent 를 422 로 돌려준다 — 그곳의 장은 get_case 로 읽은 revision 을 expected_revision 으로 대 고쳐 적는다.',
|
|
6059
6535
|
inputSchema: {
|
|
6060
6536
|
type: 'object',
|
|
6061
6537
|
properties: {
|
|
6062
6538
|
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
6063
|
-
title: { type: 'string', description: '한 줄 제목. 다른 결정은 제목을 달리한다. 기존 결정은 expected_revision을 전달해야 수정된다.' },
|
|
6539
|
+
title: { type: 'string', description: '한 줄 제목. 다른 결정은 제목을 달리한다. 기존 결정은 expected_revision을 전달해야 수정된다. 하위 업무 번호를 쓰는 소속처에서는 번호를 제목에 적지 않는다(서버가 매긴다).' },
|
|
6540
|
+
item: { type: 'string', description: '고쳐 적을 **하위 업무 번호**(예 W-04 — works 목록의 번호 그대로). 그 장에 새 판으로 선다(제목이 바뀌어도 번호 · 경로 그대로). 번호를 쓰는 소속처만 받는다(아직 안 쓰는 곳은 422).' },
|
|
6541
|
+
parent: { type: 'string', description: '하위로 나누기 — 부모 하위 업무 번호(예 R-01). 새 번호는 그 아래 자식(R-01#5)으로 선다. 깊이는 한 겹. 번호를 쓰는 소속처만 받는다(아직 안 쓰는 곳은 422).' },
|
|
6542
|
+
branch: { type: 'string', description: '갈래를 가진 업무(hnl-wrm 등)의 갈래 머리글자(W · R · M). 비우면 tags 의 갈래 태그(예 「대기 개선(W)」)로 고른다.' },
|
|
6543
|
+
prefix: { type: 'string', description: '새 업무를 여는 결정이면 그 업무의 머리글자(대문자 1~4). 비우면 서버가 제안한 것으로 선다 — 한 번 서면 못 바꾼다.' },
|
|
6064
6544
|
expected_revision: { type: 'string', pattern: '^[a-f0-9]{64}$', description: '기존 결정 수정 시 get_case에서 읽은 revision. 신규 생성은 생략. 충돌하면 원문을 다시 읽고 변경을 재검토한다.' },
|
|
6065
6545
|
commit_id: { type: 'string', pattern: '^[a-zA-Z0-9][a-zA-Z0-9-]{15,79}$', description: '이 저장 작업의 고유 UUID. 첫 호출 전에 생성하고 응답 유실·재시도에는 같은 값과 같은 본문을 유지한다. 별도 수정은 새 값을 쓴다.' },
|
|
6066
6546
|
what: { type: 'string', description: '무엇을 정했나 — 한 문장.' },
|
|
@@ -6126,7 +6606,7 @@ const TOOLS = [
|
|
|
6126
6606
|
type: 'object',
|
|
6127
6607
|
properties: {
|
|
6128
6608
|
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
6129
|
-
path: { type: 'string', description: 'search_cases·remember_decision 이 준 path 그대로(decisions/….md). 제목을 줘도 저장과 같은 자로 찾는다.' },
|
|
6609
|
+
path: { type: 'string', description: 'search_cases·remember_decision 이 준 path 그대로(decisions/….md). 제목을 줘도 저장과 같은 자로 찾는다. 번호를 쓰는 소속처는 하위 업무 번호(W-04)도 받는다.' },
|
|
6130
6610
|
expected_revision: { type: 'string', pattern: '^[a-f0-9]{64}$', description: 'get_case로 읽은 삭제 대상의 revision.' },
|
|
6131
6611
|
},
|
|
6132
6612
|
required: ['tenant', 'path', 'expected_revision'],
|
|
@@ -6200,7 +6680,7 @@ const TOOLS = [
|
|
|
6200
6680
|
properties: {
|
|
6201
6681
|
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
6202
6682
|
claim: { type: 'string', description: '이 일(slug)에 붙겠다고 선언한다 — 잠정이며, 다른 일을 선언하면 앞 선언은 대체된다. 비우면 목록만 읽는다.' },
|
|
6203
|
-
item: { type: 'string', description: 'claim 과 함께 쓰면 착수 선언(잠정). work 와 함께 쓰면 완료 확인/표시/해제 대상 — 이 일 안의 결정 항목(item_key, 예: decisions/foo.md).' },
|
|
6683
|
+
item: { type: 'string', description: 'claim 과 함께 쓰면 착수 선언(잠정). work 와 함께 쓰면 완료 확인/표시/해제 대상 — 이 일 안의 결정 항목(item_key, 예: decisions/foo.md). 번호를 쓰는 소속처는 하위 업무 번호(예 W-04 — 목록의 번호 그대로)도 받는다 — 그 일의 번호가 아니면 서버가 거절한다.' },
|
|
6204
6684
|
work: { type: 'string', description: '이 일(slug). **혼자 주면 그 일의 항목 목록**(번호·제목·계획 기간·상태·item_key)을 읽는다(부작용 없음). item 과 함께 주면 그 항목의 완료 확인·표시·해제 — claim 과 별개(착수 선언 없이도 완료를 다룰 수 있다).' },
|
|
6205
6685
|
done_when: { type: 'string', description: '이 항목의 완료 조건 — 무엇이 되면 끝인가. done_evidence 와 반드시 함께 채운다(work+item과 함께).' },
|
|
6206
6686
|
done_evidence: { type: 'string', description: 'done_when 과 함께 쓴다 — 그 조건이 실제로 충족됐다는 구체적 관측(파일·시험 결과·커밋 등). 비우면 완료로 못 간다 — 지어내지 말 것, 스스로 확인한 것만.' },
|
|
@@ -6330,8 +6810,8 @@ const TOOLS = [
|
|
|
6330
6810
|
{
|
|
6331
6811
|
name: 'push_session',
|
|
6332
6812
|
description:
|
|
6333
|
-
'이 기계의 대화 하나를 **검열해서** 서버에 올린다.
|
|
6334
|
-
'
|
|
6813
|
+
'이 기계의 대화 하나를 **검열해서** 서버에 올린다. Native Codex 원본도 지원하며 source client를 보존한다. ' +
|
|
6814
|
+
'다른 기계는 resume_session으로 작업 맥락을 읽는다. Claude 원본은 pull_session 뒤 claude --resume으로 열 수 있다.\n' +
|
|
6335
6815
|
'올라가지 않는 것: 도구 실행 결과·첨부·파일 이력·집 경로·비밀 모양(토큰·비밀번호·개인키). ' +
|
|
6336
6816
|
'**도구 호출의 짝과 인자는 남는다** — 그게 없으면 대화가 안 열린다.\n' +
|
|
6337
6817
|
'긴 대화는 **알아서 조각으로** 나눠 보내고 서버가 잇는다 — 크기 때문에 사람이 할 일은 없다.\n' +
|
|
@@ -6404,12 +6884,18 @@ const TOOLS = [
|
|
|
6404
6884
|
+ '다른 기계·두뇌에 닿는다 — 서버를 지나기 때문이다.\n'
|
|
6405
6885
|
+ '`SendMessage` 는 **이 기계의 Claude Code 끼리만** 가는 지름길이다. `ListAgents` 에 없다고 '
|
|
6406
6886
|
+ '해서 세션이 없는 것이 아니다 — 발견은 `gcell agents`, 주소는 여기 `to` 다.\n'
|
|
6887
|
+
+ '★ 받는 쪽 주소는 **세션 uuid(36자)가 정본**이다 — `gcell agents` 가 주소 칸에 내는 그 값을 쓴다. '
|
|
6888
|
+
+ '세션 이름(예: report-portal-37)은 프로세스가 다시 뜰 때마다 새로 뽑히는 **그 순간의 별칭**이라, 적어 둔 이름은 '
|
|
6889
|
+
+ '죽거나 같은 이름을 새로 받은 다른 세션에게 간다. 이름으로 부쳤으면 결과의 「받는 세션」 uuid 로 이어 간다.\n'
|
|
6890
|
+
+ '한 대화를 창 둘 이상이 함께 띄우면(`gcell agents` 의 「창 N곳」) 그 uuid 앞 통은 어느 창이 받을지 정해지지 않는다 — '
|
|
6891
|
+
+ '한 창에 닿으려면 `SendMessage` 에 그 창의 지금 이름을 쓴다.\n'
|
|
6892
|
+
+ '이 세션에 이름이 없어도 보낸다 — 그때 받는 쪽에는 이 세션 uuid 앞 8자가 발신자로 찍힌다.\n'
|
|
6407
6893
|
+ '무엇을 해 달라고 **부탁해도 된다** — 이 창구는 받는 쪽 눈앞에 「누가 보냈는지」를 서버가 '
|
|
6408
6894
|
+ '반드시 붙여 주므로, 세계 장부와 달리 지시형이 막히지 않는다.\n'
|
|
6409
|
-
+ '받는 것은 부를 필요가 없다 — 온 것은 다른 도구를 부를 때 응답 꼬리에 저절로
|
|
6895
|
+
+ '받는 것은 부를 필요가 없다 — 온 것은 다른 도구를 부를 때 응답 꼬리에 저절로 실린다(답할 주소 uuid 가 함께 온다).\n'
|
|
6410
6896
|
+ '**사람에게는 이메일로** 보낸다(그 소속처 담당만) — 포털 벨이 울린다.\n'
|
|
6411
|
-
+ '⚠ 상대
|
|
6412
|
-
+ '
|
|
6897
|
+
+ '⚠ 상대 주소는 **지어내지 말 것.** 세션 uuid, 방금 확인한 세션 이름, 담당의 이메일, 또는 `brain` 중 하나다. '
|
|
6898
|
+
+ '못 찾으면 사용자에게 묻는다.\n'
|
|
6413
6899
|
+ '⚠ 한 실(thread)에는 상한이 있다 — **왕복**(한 쪽이 보내고 상대가 답하면 1왕복)은 사람이 낀 실 5회·기계끼리 3회, '
|
|
6414
6900
|
+ '답 없이 같은 쪽이 연달아 부치는 것은 3통. 넘으면 409 로 거절된다. '
|
|
6415
6901
|
+ '**사람에게 저절로 알리는 기능은 아직 없다** — 사람의 판단이 필요하면 이 세션의 사용자에게 말하거나 '
|
|
@@ -6418,7 +6904,7 @@ const TOOLS = [
|
|
|
6418
6904
|
type: 'object',
|
|
6419
6905
|
properties: {
|
|
6420
6906
|
tenant: { type: 'string', description: '소속처 slug(이름도 받는다). 모르면 list_my_tenants 로 먼저 확인한다' },
|
|
6421
|
-
to: { type: 'string', description: '받는 쪽 —
|
|
6907
|
+
to: { type: 'string', description: '받는 쪽 — **세션 uuid(36자 · 정본 — `gcell agents` 의 주소 칸)** · 방금 확인한 세션 이름(그 순간의 별칭 — 바뀌면 남에게 간다) · 담당의 이메일(사람에게) · 또는 brain' },
|
|
6422
6908
|
body: { type: 'string', description: '할 말' },
|
|
6423
6909
|
subject: { type: 'string', description: '한 줄 제목(선택)' },
|
|
6424
6910
|
artifact: { type: 'string', description: '무엇에 대한 이야기인가 — 파일·문서 경로(선택)' },
|
|
@@ -6497,9 +6983,26 @@ function pinsText(r) {
|
|
|
6497
6983
|
}
|
|
6498
6984
|
function warnText(r) {
|
|
6499
6985
|
const w = [...(r?.warnings ?? []), ...ASSET_HINTS];
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6986
|
+
if (!w.length) return '';
|
|
6987
|
+
/*
|
|
6988
|
+
* 결정 모드(원고에 결정 블록 — 관보 버전2 → v3 ⑨)는 **개수를 안 찍는다**(설계안 8.3) — 「경고 N건」 합계는 고친 횟수를 목표로
|
|
6989
|
+
* 돌게 하는 손잡이였다(lim 위키 사례 #212). 렌더러가 결정에 속한 알림을 「결정 「X」 — 」 로 시작하게 내므로(계약 3.4),
|
|
6990
|
+
* 그 줄은 결정 이름 아래로 모아 들여 찍는다. 처음 나온 차례를 지키고, 결정에 안 속한 줄은 제자리에 그대로 둔다.
|
|
6991
|
+
* 모드는 응답의 `mode` 로 가른다(관보 v3 서버가 dry_run·발행·교체 응답에 싣는다 — 서식은 하나라 template 은 늘 lim-report).
|
|
6992
|
+
* 옛 서버(관보 버전2)는 mode 없이 template:'lim-decision' 을 보낸다 — 그것도 결정 모드로 읽는다. 없거나 report 면 지금 그대로.
|
|
6993
|
+
*/
|
|
6994
|
+
if (r?.mode === 'decision' || r?.template === 'lim-decision') {
|
|
6995
|
+
const out = [], groups = new Map();
|
|
6996
|
+
for (const line of w) {
|
|
6997
|
+
const m = /^결정 「([^」]*)」 — ([\s\S]*)$/.exec(String(line));
|
|
6998
|
+
if (!m) { out.push(`· ${line}`); continue; }
|
|
6999
|
+
if (!groups.has(m[1])) { const g = []; groups.set(m[1], g); out.push([m[1], g]); }
|
|
7000
|
+
groups.get(m[1]).push(m[2]);
|
|
7001
|
+
}
|
|
7002
|
+
const text = out.map((x) => (typeof x === 'string' ? x : `결정 「${x[0]}」\n${x[1].map((t) => ` · ${t}`).join('\n')}`));
|
|
7003
|
+
return `\n\n알림 — 결정 장 이름 아래 자리 글:\n${text.join('\n')}`;
|
|
7004
|
+
}
|
|
7005
|
+
return `\n\n경고 ${w.length}건:\n· ${w.join('\n· ')}`;
|
|
6503
7006
|
}
|
|
6504
7007
|
|
|
6505
7008
|
/* 발행물의 **구조**를 사람이 읽는 줄로 옮긴다.
|
|
@@ -6769,7 +7272,9 @@ function tenantNoticeText(v) {
|
|
|
6769
7272
|
const who = v.confirmed_by ? `${v.confirmed_by}${josaIGa(v.confirmed_by)} ` : '';
|
|
6770
7273
|
return `genetics: 이 세션은 ${T} 소속으로 확정된 폴더(${v.path} · ${who}${String(v.confirmed_at || '').slice(0, 10)} 확정, 자동 올리기 켬)에서 `
|
|
6771
7274
|
+ '돌고 있어, 이 대화가 서버에 자동으로 올라간다. 도구 실행 결과·첨부·파일 이력은 빠지고 비밀 모양은 가려지지만, '
|
|
6772
|
-
+
|
|
7275
|
+
+ (v.source_client === 'codex'
|
|
7276
|
+
? `**주고받은 글과 도구 호출 인자**는 ${T} 운영진에게 보인다. 내부 추론은 제외한다. `
|
|
7277
|
+
: `**주고받은 글과 생각(thinking), 도구 호출 인자는 그대로** ${T} 운영진에게 보인다. `)
|
|
6773
7278
|
+ '끄는 길 — 포털 「폴더」에서 자동 올리기 끄기(서버가 강제), 이 폴더의 .genetics-private 파일 또는 '
|
|
6774
7279
|
+ 'GENETICS_NO_AUTO_PUSH=1(이 기계의 클라이언트 약속), 이 대화만 빼려면 no_auto_push. '
|
|
6775
7280
|
+ '이미 올라간 것은 꺼도 안 사라진다 — 포털 「폴더」에서 지운다.';
|
|
@@ -6843,7 +7348,8 @@ function tenantNoticeView() {
|
|
|
6843
7348
|
const e = v.entry || {};
|
|
6844
7349
|
const row = indexRead()[mine];
|
|
6845
7350
|
const says = resolveSessionSays(it.session); // 그 uuid 에 대한 서버의 사실(N10) — bound 갈래가 읽는다
|
|
6846
|
-
const base = { state: it.state, tenant: e.tenant, path: v.fo.path,
|
|
7351
|
+
const base = { state: it.state, tenant: e.tenant, path: v.fo.path,
|
|
7352
|
+
source_client: mine && mine === codexSessionId() ? 'codex' : '', confirmed_by: e.confirmed_by,
|
|
6847
7353
|
confirmed_at: e.confirmed_at, auto_upload: e.auto_upload };
|
|
6848
7354
|
switch (it.state) {
|
|
6849
7355
|
case 'bound':
|
|
@@ -7023,6 +7529,7 @@ function notifyToolsChanged(sendFn) {
|
|
|
7023
7529
|
}
|
|
7024
7530
|
|
|
7025
7531
|
function toolsNow() {
|
|
7532
|
+
if (sessionToolMode()) return TOOLS.filter(t => ['whoami', 'list_sessions', 'push_session'].includes(t.name));
|
|
7026
7533
|
const facts = worldFacts(3);
|
|
7027
7534
|
if (!facts || !facts.length) return TOOLS; // 조용한 구간은 조용하다
|
|
7028
7535
|
const head = `\n\n지금 이 저장소:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
|
|
@@ -7072,19 +7579,30 @@ const mailQueue = []; // 아직 안 말한 통(서버가 렌더한 글
|
|
|
7072
7579
|
const mailBack = []; // 내가 부쳤다 만료된 것
|
|
7073
7580
|
const mailBackSeen = new Set();
|
|
7074
7581
|
|
|
7075
|
-
|
|
7076
|
-
* 이 세션의
|
|
7077
|
-
*
|
|
7078
|
-
*
|
|
7079
|
-
*
|
|
7080
|
-
*
|
|
7081
|
-
*
|
|
7082
|
-
*
|
|
7083
|
-
*
|
|
7084
|
-
*
|
|
7085
|
-
*
|
|
7086
|
-
*
|
|
7087
|
-
*
|
|
7582
|
+
/*
|
|
7583
|
+
* ══ 이 세션의 주소 — **세션 uuid 가 정본, 이름은 보내기 직전에 푼다** (주소 계약 2 · 2026-10-07) ══════════════════════════
|
|
7584
|
+
*
|
|
7585
|
+
* 오래 들고 있을 주소는 세션 uuid(36자)다. 하네스 이름(예: report-portal-37)은 프로세스가 뜰 때마다 00~ff 로 다시 뽑히는
|
|
7586
|
+
* 표시값이라 적어 두면 죽거나 남에게 간다 — 469cd2a5 의 이름은 5일 사이 eb → 26 → 37 이었고, 7afd1d02 의 옛 이름 hnl-73 은
|
|
7587
|
+
* 지금 산 세션 110cd958 의 이름이다(검증 워크플로 2026-10-07 실측). 그래서 이 MCP 는
|
|
7588
|
+
* ① **이름이 없어도 보낸다.** 서버는 2026-08-25 부터 이름 없는 발신을 uuid 앞 8자로 받는다(8ec9b061:www/portal/lib/mail_store.php:96-105 —
|
|
7589
|
+
* mail_store_sender_session). 전에는
|
|
7590
|
+
* 여기 문지기(「이름 없으면 못 보냄」)가 서버보다 엄해서, 등록부를 못 읽은 세션이 전체 uuid 앞으로 보낸 통까지 거절됐다
|
|
7591
|
+
* (2026-10-06T15:49:24Z, to=469cd2a5-…-f8751b9dcd18 → 15:49:25Z 거절).
|
|
7592
|
+
* ② 서버 주소록에 대는 이름은 **대기 직전에** 푼다 — 세포(`gcell agents --self --json`)에 먼저 묻고(`cellSelf`), 세포가 없거나
|
|
7593
|
+
* 못 알아들으면 아래 JS 사본(`selfAddressJs`)으로. 같은 규칙을 두 자로 재면 같은 세션을 다르게 부른다 — 9aedc967 에서 등록부
|
|
7594
|
+
* pid 가 문자열이면 세포는 그 줄을 버리고 MCP 는 받았다(9aedc967:cell/src/world.rs:684 대 mcp/genetics.mjs:7570). 그래서 JS 사본은 세포와 같은 계약을 진다.
|
|
7595
|
+
* ③ 대는 이름은 **지금 이 프로세스의 이름이라고 확인된 것**뿐이다(`myAgentName`). 확인 못 한 이름(다시 뜨기 전의 자기소개)을 대면
|
|
7596
|
+
* 서버가 그 이름을 이 세션으로 풀어(052 의 최신-승) 그 이름의 지금 임자에게 갈 통이 여기로 온다.
|
|
7597
|
+
*/
|
|
7598
|
+
/*
|
|
7599
|
+
* ── 전사의 자기소개 ─────────────────────────────────────────────────────────────────────────────
|
|
7600
|
+
* `ListAgents` 를 부르면 하네스가 「This session is <이름> [<id>]」를 **전사에 적는다.** 모델이 지어낸 값이 아니라 하네스가 쓴
|
|
7601
|
+
* 사실이지만, 그 이름은 **그때의** 이름이다 — 다시 뜬 뒤에는 옛 이름이다. 그래서 등록부 다음 차례이고, 그마저 이 MCP 가 뜬 뒤에
|
|
7602
|
+
* 적힌 것만 지금 이름으로 친다(`myAgentName`).
|
|
7603
|
+
* ⚠ **마지막 것**을 쓴다 — 끝에서 거슬러 읽는다(`lastIntroInRange`).
|
|
7604
|
+
* ⚠ **늘어난 부분만 다시 읽는다.** 그림이 base64 로 실려 전사가 253MB 까지 간다(2026-10-07 실측 — 옛 주석의 「7MB」는 틀렸다).
|
|
7605
|
+
* ⚠ 못 찾으면 `null` — **지어내지 않는다.**
|
|
7088
7606
|
*/
|
|
7089
7607
|
/*
|
|
7090
7608
|
* ⚠⚠ **줄 안의 글자가 아니라 레코드의 모양으로 읽는다** (2026-08-25 실측).
|
|
@@ -7103,7 +7621,37 @@ const mailBackSeen = new Set();
|
|
|
7103
7621
|
*/
|
|
7104
7622
|
const INTRO_HEAD = 'This session is ';
|
|
7105
7623
|
const INTRO_TAIL = 'the name other sessions use';
|
|
7106
|
-
|
|
7624
|
+
/**
|
|
7625
|
+
* 자기소개 글 한 덩이 → `{ name, id }`(세포 `agent_name_in` 과 한 벌). 못 읽으면 null.
|
|
7626
|
+
* ⚠ 이름 글자는 **등록부와 같은 꼴**(`harnessNameClean` — 제어문자만 걷고 65자까지)이다(2026-10-07). 전에는 [A-Za-z0-9._-]{1,60} 만
|
|
7627
|
+
* 받았는데, 실제 ListAgents 목록에 「atflee Graph」·「홈 변화 그래프 — 최신 기록이 …」(47자) 같은 이름이 선다(469cd2a5 전사 실측 —
|
|
7628
|
+
* 세포 쪽 조사). 그래서 식별자는 꼬리(INTRO_TAIL) 앞의 **마지막** 「 [<16진>]」이고 — 이름 안에 「 — 」나 「 [」가 있어도 안 갈린다 —
|
|
7629
|
+
* 본문의 첫 줄만 본다(그 아래 「Peer sessions」 목록의 이름들은 이 세션의 이름이 아니다).
|
|
7630
|
+
*/
|
|
7631
|
+
function introNameIn(text) {
|
|
7632
|
+
const t = String(text ?? '');
|
|
7633
|
+
const i = t.indexOf(INTRO_HEAD);
|
|
7634
|
+
if (i < 0) return null;
|
|
7635
|
+
const rest = t.slice(i + INTRO_HEAD.length).split('\n')[0];
|
|
7636
|
+
const tail = rest.indexOf(INTRO_TAIL);
|
|
7637
|
+
const head = tail >= 0 ? rest.slice(0, tail) : rest;
|
|
7638
|
+
const open = head.lastIndexOf(' [');
|
|
7639
|
+
if (open < 0) return null;
|
|
7640
|
+
const name = harnessNameClean(head.slice(0, open));
|
|
7641
|
+
if (!name) return null;
|
|
7642
|
+
const after = head.slice(open + 2);
|
|
7643
|
+
const close = after.indexOf(']');
|
|
7644
|
+
if (close < 0) return null;
|
|
7645
|
+
const id = after.slice(0, close);
|
|
7646
|
+
return /^[0-9A-Fa-f]+$/.test(id) ? { name, id } : null;
|
|
7647
|
+
}
|
|
7648
|
+
/**
|
|
7649
|
+
* 전사의 레코드(JSONL 한 줄) → 그 자기소개의 이름(세포 `agent_name_in_record` 와 한 벌). 아니면 null.
|
|
7650
|
+
* ★ 레코드에 하네스의 짝 표식 `toolUseResult.listing`(ListAgents 결과의 구조화 칸 — 본문과 같은 글)이 있어야 한다(2026-10-07 검증 SEC-6).
|
|
7651
|
+
* Bash 결과는 `{stdout, stderr, …}` 라, `echo "This session is <남의 이름> [ab12] — the name other sessions use"` 한 줄이 전에는 이름으로
|
|
7652
|
+
* 읽혀 아래 myAgentName ②(뜬 뒤 자기소개)로 서버에 이 세션의 주소로 섰다 — 웹 문서에 숨은 지시 하나로. 실측: 전사 154개의 자기소개 211건이
|
|
7653
|
+
* 하네스 2.1.239~2.1.290 내내 이 칸을 가졌고 본문과 같았다.
|
|
7654
|
+
*/
|
|
7107
7655
|
function introNameOf(line) {
|
|
7108
7656
|
if (!line.includes(INTRO_HEAD)) return null; // 값싼 검사로 먼저 거른다
|
|
7109
7657
|
let d;
|
|
@@ -7113,63 +7661,765 @@ function introNameOf(line) {
|
|
|
7113
7661
|
const first = Array.isArray(c) ? c[0] : null;
|
|
7114
7662
|
if (!first || first.type !== 'tool_result') return null;
|
|
7115
7663
|
let body = first.content;
|
|
7116
|
-
if (Array.isArray(body)) body = body.map((x) => (x && x.type === 'text'
|
|
7664
|
+
if (Array.isArray(body)) body = body.map((x) => (x && x.type === 'text' && typeof x.text === 'string' ? x.text : '')).join('');
|
|
7117
7665
|
if (typeof body !== 'string' || !body.startsWith(INTRO_HEAD) || !body.includes(INTRO_TAIL)) return null;
|
|
7118
|
-
const
|
|
7119
|
-
|
|
7666
|
+
const listing = d?.toolUseResult?.listing;
|
|
7667
|
+
if (typeof listing !== 'string') return null; // ★ 하네스 ListAgents 의 짝 표식이 없다 — Bash·MCP 가 찍은 같은 글
|
|
7668
|
+
const a = introNameIn(body);
|
|
7669
|
+
const b = introNameIn(listing);
|
|
7670
|
+
return a && b && a.name === b.name && a.id === b.id ? a.name : null;
|
|
7120
7671
|
}
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7672
|
+
/*
|
|
7673
|
+
* ── 하네스 등록부 — JS 사본(계약 1 의 의미 그대로 · 세포가 없거나 못 알아들을 때만 쓴다) ─────────────────────────────────
|
|
7674
|
+
* 하네스는 이름을 **프로세스가 뜰 때마다 새로 짓고** `<설정 폴더>/sessions/<pid>.json` 의 `name` 에 적는다 — ListAgents·SendMessage 가
|
|
7675
|
+
* 쓰는 바로 그 이름이다(`nameSource:"derived"` · `nameSince` = 프로세스 시작). 실측(2026-10-07): 469cd2a5 는 등록부 「report-portal-37」,
|
|
7676
|
+
* 전사 「report-portal-26」.
|
|
7677
|
+
*
|
|
7678
|
+
* ★ 세포(`cell/src/world.rs` 「주소는 세션 uuid 다」 머리말 · registry_rows_in·row_live·self_row·whereabouts_of)와 **한 벌**이다.
|
|
7679
|
+
* 두 자를 맞대는 정본은 둘이다 — 칸 규칙표 `cell/tests/fixtures/registry/cases.json`(smoke 3-2)과, 경우 통째(등록부 줄·전사·env·
|
|
7680
|
+
* 프로세스 표 → 계약 1 의 답)를 맞대는 **이름 관문** `cell/tests/fixtures/agent-names/`(smoke 3-4 · 세포는 cargo 시험 둘이 같은 경우를 문다).
|
|
7681
|
+
* 관문이 처음 돌 때(2026-10-07) 이 사본이 세 자리에서 세포와 달랐다 — 산 줄이면 옛 이름을 안 읽음 · 옛 이름을 설정 폴더가 아니라
|
|
7682
|
+
* findTranscript(~/.claude/projects 고정)로 찾음 · 1MiB 넘는 줄을 자기소개로 읽음. 셋 다 세포 쪽으로 맞췄다.
|
|
7683
|
+
* 9aedc967 에서 둘이 갈렸다 — pid 가 문자열 "32054" 면 세포는 그 줄을 버리고 MCP 는 받아, 같은 세션을 세포는 옛 자기소개로 ·
|
|
7684
|
+
* MCP 는 등록부 이름으로 불렀다(검증 조사 픽스처 A). 그래서 아래 규칙은 글자 하나까지 세포를 따른다:
|
|
7685
|
+
* · 파일은 `<숫자>.json` 만(세션 사이 인증 열쇠 `.key` 는 안 읽는다) · 64KiB 를 넘으면 안 읽는다.
|
|
7686
|
+
* · 숫자 칸(pid·updatedAt)은 JSON 수(정수 — 소수부 0 인 실수 포함) 또는 **ASCII 숫자 1~16자** 문자열(`harnessInt`).
|
|
7687
|
+
* `Number()` 를 그대로 쓰지 않는다 — `Number(" 7")`·`Number("0x7")` 도 7 이라 두 자가 갈린다.
|
|
7688
|
+
* · 이름은 제어문자(Cc)만 걷고 앞뒤 공백을 다듬어 1~65자(코드 포인트, `harnessNameClean`). 넘으면 자르지 않고 버린다(줄은 남는다 —
|
|
7689
|
+
* 이름 없이 떠 있는 세션). 하네스는 `/rename` 이름을 제어문자만 걷고 받고, 안정 주소 스위치가 켜지면 이름이 65자까지 간다 —
|
|
7690
|
+
* 옛 [A-Za-z0-9._-]{1,60} 은 「atflee Graph」·47자 한글 이름의 줄을 말없이 버렸다.
|
|
7691
|
+
* · 떠 있나(`harnessRowLife`)는 셋 중 하나 — 'live' · 'gone'(재서 아니라고 나왔다) · 못 잰 까닭('platform'·'domain'·'start').
|
|
7692
|
+
* 떠 있다 = pidDomain 이 이 판과 같고(하네스와 같은 꼴로 짓는다 — `harnessPidDomain`, F1) · pid 가 있고(kill 0 · EPERM 도 있는 것) ·
|
|
7693
|
+
* procStart 가 있으면 그 pid 의 실제 시작 시각과 2초 안. procStart 를 못 대조하면(꼴을 못 읽음 · 시작 시각을 못 잼) **산 것으로 안
|
|
7694
|
+
* 친다** — 끝난 세션의 옛 이름을 「지금 이름」으로 대면 남의 통이 여기로 온다. 이름 없이 uuid 로 닿는 쪽이 안전하다. 그러나 「끝났다」고도
|
|
7695
|
+
* 안 한다(F5 — 계약 칸 measured 거짓). procStart 칸이 없는 옛 하네스 줄은 kill 0 만.
|
|
7696
|
+
* · 같은 조건의 산 줄이 둘이면 procStart 가 늦은 줄(가장 나중에 뜬 프로세스), 같으면 pid 가 큰 줄(readdir 차례에 안 기댄다). updatedAt 은
|
|
7697
|
+
* 안 쓴다 — 상태가 바뀔 때마다 움직여 창 둘이 띄운 대화는 물을 때마다 다른 이름이 나왔다(L1). 산 줄 수는 live_rows 로 낸다(SEC-1).
|
|
7698
|
+
* · 자기 줄은 **프로세스 열쇠** 차례로 찾는다: env `CLAUDE_CODE_MESSAGING_SOCKET` == `messagingSocketPath` → `CLAUDE_PID` == `pid` →
|
|
7699
|
+
* `CLAUDE_CODE_SESSION_ID` == `sessionId`. 세션 id 만으로 찾으면 /clear 뒤에는 MCP env 의 옛 id 로 줄을 못 찾는다(MCP 는 뜰 때
|
|
7700
|
+
* env 를 한 번 받는다 — 2.1.290 @217810326 · /clear 는 등록부 줄의 sessionId 만 바꾼다). MCP 의 process.ppid 는 claude 가 아니라
|
|
7701
|
+
* volta 심이라 열쇠가 못 된다(ps 실측 MCP 18936 → 심 18901 → claude 18159). MCP env 에는 CLAUDE_PID 가 없다(Bash·훅의 것).
|
|
7702
|
+
*/
|
|
7703
|
+
/**
|
|
7704
|
+
* 하네스의 집 — 세포 `crate::home_dir()` 와 같은 차례(유닉스: HOME → USERPROFILE · Windows: 거꾸로), 둘 다 없으면 os.homedir().
|
|
7705
|
+
* ⚠ `env` 에서 읽는다(전에는 os.homedir() 고정) — 이름 관문이 경우마다 임시 집을 주고, 세포도 그 env 의 HOME 을 읽는다.
|
|
7706
|
+
*/
|
|
7707
|
+
function harnessHome(env = process.env) {
|
|
7708
|
+
for (const k of process.platform === 'win32' ? ['USERPROFILE', 'HOME'] : ['HOME', 'USERPROFILE']) {
|
|
7709
|
+
const v = env?.[k];
|
|
7710
|
+
if (typeof v === 'string' && v !== '') return v;
|
|
7711
|
+
}
|
|
7712
|
+
return os.homedir();
|
|
7713
|
+
}
|
|
7714
|
+
/** 하네스 설정 폴더 — CLAUDE_CONFIG_DIR(비어 있지 않을 때) 아니면 <집>/.claude(세포 `harness_config_dir`). NFC 는 하네스가 하는 대로. */
|
|
7715
|
+
function harnessConfigDir(env = process.env) {
|
|
7716
|
+
return String(env?.CLAUDE_CONFIG_DIR || path.join(harnessHome(env), '.claude')).normalize('NFC');
|
|
7717
|
+
}
|
|
7718
|
+
function harnessSessionsDir(env = process.env) {
|
|
7719
|
+
return path.join(harnessConfigDir(env), 'sessions');
|
|
7720
|
+
}
|
|
7721
|
+
/**
|
|
7722
|
+
* Claude Code 전사를 찾는 뿌리 — <설정 폴더>/projects, 그다음 <집>/.claude/projects(설정 폴더가 그것이 아닐 때만) · 세포 `claude_project_bases`.
|
|
7723
|
+
* ⚠ 옛 이름(자기소개)은 이 규칙으로만 찾는다. 전에는 `introOf`(findTranscript — Codex 먼저 · cwd 폴더 · ~/.claude/projects 고정 · Cursor)를 써서
|
|
7724
|
+
* CLAUDE_CONFIG_DIR 로 뜬 세션의 옛 이름을 세포와 다르게 냈다(이름 관문 2026-10-07 — 픽스처 10-config-dir 가 미끼를 냈다).
|
|
7725
|
+
*/
|
|
7726
|
+
function claudeProjectBases(env = process.env) {
|
|
7727
|
+
const bases = [path.join(harnessConfigDir(env), 'projects')];
|
|
7728
|
+
const home = path.join(path.join(harnessHome(env), '.claude').normalize('NFC'), 'projects');
|
|
7729
|
+
if (!bases.includes(home)) bases.push(home);
|
|
7730
|
+
return bases;
|
|
7731
|
+
}
|
|
7732
|
+
/**
|
|
7733
|
+
* `bases` 의 프로젝트 폴더들에서 `<uuid>.jsonl`(본 전사)을 — 뿌리 차례, 그 안에서는 폴더 이름의 바이트 차례(libuv 가 strcmp 로 늘어놓는다 ·
|
|
7734
|
+
* 세포도 같은 차례로 맞췄다)로 처음 것. 하위 에이전트 전사(`<uuid>/subagents/…/agent-*.jsonl`)는 이 꼴이 아니라 안 잡힌다(세포 `claude_transcript_in`).
|
|
7735
|
+
*/
|
|
7736
|
+
function claudeTranscriptIn(bases, sid) {
|
|
7737
|
+
const s = String(sid ?? '');
|
|
7738
|
+
if (!AUTO_UUID_RE.test(s)) return null;
|
|
7739
|
+
for (const base of bases) {
|
|
7740
|
+
let names;
|
|
7741
|
+
try { names = fs.readdirSync(base); } catch { continue; }
|
|
7742
|
+
for (const n of names) {
|
|
7743
|
+
const f = path.join(base, n, `${s}.jsonl`);
|
|
7744
|
+
try { if (fs.statSync(f).isFile()) return f; } catch { /* 이 폴더엔 없다 */ }
|
|
7745
|
+
}
|
|
7746
|
+
}
|
|
7747
|
+
return null;
|
|
7748
|
+
}
|
|
7749
|
+
/** 그 pid 의 프로세스가 지금 있나 — 신호 0 은 보내지 않고 묻기만 한다. 남의 프로세스(EPERM)도 있는 것이다. */
|
|
7750
|
+
function pidAlive(pid) {
|
|
7751
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
7752
|
+
try { process.kill(pid, 0); return true; } catch (e) { return e?.code === 'EPERM'; }
|
|
7753
|
+
}
|
|
7754
|
+
/** 등록부 숫자 칸 → 정수(세포 `reg_int`): JSON 수는 안전 정수만, 문자열은 ASCII 숫자 1~16자만. 그 밖(부호·공백·소수점·16진·지수·불·null)은 null. */
|
|
7755
|
+
function harnessInt(v) {
|
|
7756
|
+
if (typeof v === 'number') return Number.isSafeInteger(v) ? v : null;
|
|
7757
|
+
if (typeof v === 'string' && /^[0-9]{1,16}$/.test(v)) return Number(v);
|
|
7758
|
+
return null;
|
|
7759
|
+
}
|
|
7760
|
+
const HARNESS_NAME_MAX = 65;
|
|
7761
|
+
// 유니코드 White_Space 중 제어문자(Cc)가 아닌 것 — 세포 name_clean 의 trim() 과 같은 자리를 다듬는다(JS 의 \s 는 U+FEFF 도 걷어 다르다).
|
|
7762
|
+
const HARNESS_TRIM = /^[\u0020\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+|[\u0020\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+$/g;
|
|
7763
|
+
/** 이름 꼴(세포 `name_clean`) — 제어문자(Cc)만 걷고 앞뒤 공백을 다듬은 뒤 1~65자(코드 포인트)면 그 글, 아니면 null(자르지 않는다). */
|
|
7764
|
+
function harnessNameClean(s) {
|
|
7765
|
+
if (typeof s !== 'string') return null;
|
|
7766
|
+
const t = s.replace(/[\u0000-\u001F\u007F-\u009F]/g, '').replace(HARNESS_TRIM, '');
|
|
7767
|
+
const n = [...t].length;
|
|
7768
|
+
return n >= 1 && n <= HARNESS_NAME_MAX ? t : null;
|
|
7769
|
+
}
|
|
7770
|
+
/** 이미 깨끗한 이름인가 — 세포가 낸 이름(이미 name_clean 을 지났다)을 받을 때 모양을 다시 잰다. */
|
|
7771
|
+
function harnessNameOk(s) {
|
|
7772
|
+
return typeof s === 'string' && harnessNameClean(s) === s;
|
|
7773
|
+
}
|
|
7774
|
+
const PROC_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
7775
|
+
/** `ps -o lstart` 꼴(「Www Mmm d HH:MM:SS YYYY」, UTC 로 읽는다) → 유닉스 초. 아니면 null(세포 `lstart_epoch`). */
|
|
7776
|
+
function lstartEpoch(s) {
|
|
7777
|
+
const p = String(s).split(/\s+/).filter(Boolean);
|
|
7778
|
+
if (p.length !== 5 || Buffer.byteLength(p[0]) !== 3) return null;
|
|
7779
|
+
const mo = PROC_MONTHS.indexOf(p[1]);
|
|
7780
|
+
if (mo < 0) return null;
|
|
7781
|
+
const num = (x, lo, hi) => {
|
|
7782
|
+
if (!/^[0-9]{1,4}$/.test(x)) return null;
|
|
7783
|
+
const v = Number(x);
|
|
7784
|
+
return v >= lo && v <= hi ? v : null;
|
|
7785
|
+
};
|
|
7786
|
+
const d = num(p[2], 1, 31);
|
|
7787
|
+
const hms = p[3].split(':');
|
|
7788
|
+
if (d === null || hms.length !== 3) return null;
|
|
7789
|
+
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);
|
|
7790
|
+
if (h === null || mi === null || se === null || y === null) return null;
|
|
7791
|
+
return Date.UTC(y, mo, d, h, mi, se) / 1000;
|
|
7792
|
+
}
|
|
7793
|
+
/** ISO 꼴(세포 `transcript::iso_epoch` 와 같은 너그러움 — 앞 19자만 본다). 못 읽으면 0. */
|
|
7794
|
+
function isoEpochLoose(s) {
|
|
7795
|
+
if (s.length < 19 || s[4] !== '-' || s[7] !== '-' || (s[10] !== 'T' && s[10] !== ' ')) return 0;
|
|
7796
|
+
const n = (a, z) => { const v = s.slice(a, z); return /^[+-]?[0-9]+$/.test(v) ? parseInt(v, 10) : 0; };
|
|
7797
|
+
const y = n(0, 4), mo = n(5, 7);
|
|
7798
|
+
if (y < 1970 || mo < 1 || mo > 12) return 0;
|
|
7799
|
+
return Date.UTC(y, mo - 1, n(8, 10), n(11, 13), n(14, 16), n(17, 19)) / 1000;
|
|
7800
|
+
}
|
|
7801
|
+
/**
|
|
7802
|
+
* 등록부 `procStart` → 유닉스 초(세포 `proc_start_epoch`). 하네스 2.1.290 은 `TZ=UTC ps -o lstart` 꼴로 적는다 — 「Tue Oct 6 04:41:55
|
|
7803
|
+
* 2026」(2026-10-07 실측 13/13 이 그 pid 의 `TZ=UTC ps -o lstart=` 와 같은 글자). 꼴이 바뀌어도 흔한 둘(epoch 수 — 1e11 을 넘으면 ms,
|
|
7804
|
+
* ISO-8601 `…Z`)은 받는다. 그 밖은 null — 로캘을 탄 ps 꼴(「2026년 10월 6일 화요일 …」)도 못 읽는 것으로 친다(세포와 같게).
|
|
7805
|
+
*/
|
|
7806
|
+
function procStartEpoch(v) {
|
|
7807
|
+
if (typeof v === 'string') {
|
|
7808
|
+
const l = lstartEpoch(v);
|
|
7809
|
+
if (l !== null) return l;
|
|
7810
|
+
const t = v.trim();
|
|
7811
|
+
if (!/^[\x00-\x7F]*$/.test(t) || !t.endsWith('Z')) return null;
|
|
7812
|
+
const e = isoEpochLoose(t);
|
|
7813
|
+
return e > 0 ? e : null;
|
|
7814
|
+
}
|
|
7815
|
+
if (typeof v === 'number') {
|
|
7816
|
+
const n = harnessInt(v);
|
|
7817
|
+
if (n === null || n <= 0) return null;
|
|
7818
|
+
return n > 100_000_000_000 ? Math.trunc(n / 1000) : n;
|
|
7819
|
+
}
|
|
7820
|
+
return null;
|
|
7821
|
+
}
|
|
7822
|
+
/** 등록부 JSON 한 개 → 줄(세포 `reg_row`). pid(1..2^31-1)·sessionId(uuid)가 없으면 null. procStart 는 absent · at · unreadable. */
|
|
7823
|
+
function harnessRowOf(d) {
|
|
7824
|
+
if (!d || typeof d !== 'object' || Array.isArray(d)) return null;
|
|
7825
|
+
const pid = harnessInt(d.pid);
|
|
7826
|
+
if (pid === null || pid <= 0 || pid > 2147483647) return null;
|
|
7827
|
+
if (typeof d.sessionId !== 'string' || !AUTO_UUID_RE.test(d.sessionId)) return null;
|
|
7828
|
+
const text = (k) => (typeof d[k] === 'string' && d[k] !== '' ? d[k] : null);
|
|
7829
|
+
const ps = d.procStart === undefined || d.procStart === null ? { kind: 'absent' }
|
|
7830
|
+
: (() => { const t = procStartEpoch(d.procStart); return t === null ? { kind: 'unreadable' } : { kind: 'at', t }; })();
|
|
7831
|
+
return { pid, session: d.sessionId.toLowerCase(), name: harnessNameClean(d.name), updatedAt: harnessInt(d.updatedAt) ?? 0,
|
|
7832
|
+
procStart: ps, socket: text('messagingSocketPath'), domain: text('pidDomain') };
|
|
7833
|
+
}
|
|
7834
|
+
const HARNESS_FILE_MAX = 64 * 1024;
|
|
7835
|
+
/** 등록부 폴더의 줄들(살았든 죽었든 — 판정은 `harnessRowLive`). 폴더가 없으면 빈 목록. 차례는 (pid, updatedAt, session). */
|
|
7836
|
+
function harnessRowsIn(dir) {
|
|
7837
|
+
let names;
|
|
7838
|
+
try { names = fs.readdirSync(dir); } catch { return []; }
|
|
7839
|
+
const rows = [];
|
|
7840
|
+
for (const n of names) {
|
|
7841
|
+
if (!/^[0-9]+\.json$/.test(n)) continue; // `<pid>.json` 만
|
|
7842
|
+
let d;
|
|
7843
|
+
try {
|
|
7844
|
+
const p = path.join(dir, n);
|
|
7845
|
+
const st = fs.statSync(p);
|
|
7846
|
+
if (!st.isFile() || st.size > HARNESS_FILE_MAX) continue;
|
|
7847
|
+
const raw = fs.readFileSync(p);
|
|
7848
|
+
if (raw.length > HARNESS_FILE_MAX) continue; // 잰 뒤 읽기 전에 자랐다
|
|
7849
|
+
d = JSON.parse(raw.toString('utf8'));
|
|
7850
|
+
} catch { continue; }
|
|
7851
|
+
const r = harnessRowOf(d);
|
|
7852
|
+
if (r) rows.push(r);
|
|
7853
|
+
}
|
|
7854
|
+
return rows.sort((a, b) => (a.pid - b.pid) || (a.updatedAt - b.updatedAt) || (a.session < b.session ? -1 : a.session > b.session ? 1 : 0));
|
|
7855
|
+
}
|
|
7856
|
+
/** procStart 와 실제 시작 시각의 허용 차(초) — lstart 는 초에서 자르고 Linux 의 btime 도 초 단위라 1초가 흔들릴 수 있다(세포와 같은 값). */
|
|
7857
|
+
const PROC_START_SLACK_SECS = 2;
|
|
7858
|
+
/**
|
|
7859
|
+
* 그 줄의 세션이 **지금 떠 있나**(위 머리말 · 세포 `row_life`) — 'live' · 'gone' · 못 잰 까닭('platform'·'domain'·'start').
|
|
7860
|
+
* `probe` = { alive(pid), started(pid) → 초|null, domain, measurable }. measurable 이 거짓(Windows — 세포가 pid 를 못 잰다)이면 모든 줄이 'platform'.
|
|
7861
|
+
* 칸이 없는 옛 probe(시험)는 잴 수 있는 것으로 친다.
|
|
7862
|
+
*/
|
|
7863
|
+
function harnessRowLife(r, probe) {
|
|
7864
|
+
if (probe.measurable === false) return 'platform';
|
|
7865
|
+
if (r.domain && r.domain !== probe.domain) return 'domain'; // 다른 pid 공간의 pid 는 이 기계의 프로세스 표 값이 아니다 — 못 잰다
|
|
7866
|
+
if (!probe.alive(r.pid)) return 'gone'; // 하네스가 끝내며 못 지운 줄 — 그 이름은 아무도 안 받는다
|
|
7867
|
+
if (r.procStart.kind === 'absent') return 'live'; // 옛 하네스 줄 — 견줄 칸이 없다(kill 0 만)
|
|
7868
|
+
if (r.procStart.kind !== 'at') return 'start'; // 꼴을 못 읽었다 — 대조를 못 하니 산 것으로 안 친다(끝난 것도 아니다)
|
|
7869
|
+
const s = probe.started(r.pid);
|
|
7870
|
+
if (s === null || s === undefined) return 'start'; // 시작 시각을 못 쟀다(ps 실패 · 시간 초과)
|
|
7871
|
+
return Math.abs(s - r.procStart.t) <= PROC_START_SLACK_SECS ? 'live' : 'gone';
|
|
7872
|
+
}
|
|
7873
|
+
/** 떠 있다고 **재서** 나왔나(세포 `row_live`). */
|
|
7874
|
+
function harnessRowLive(r, probe) { return harnessRowLife(r, probe) === 'live'; }
|
|
7875
|
+
/** 고르는 열쇠 — procStart(가장 나중에 뜬 프로세스), 같으면 pid(세포 `pick_key` · 칸이 없는 옛 줄은 가장 이르다). a ≥ b 면 참. */
|
|
7876
|
+
function harnessPickGe(a, b) {
|
|
7877
|
+
const ta = a.procStart.kind === 'at' ? a.procStart.t : -Infinity;
|
|
7878
|
+
const tb = b.procStart.kind === 'at' ? b.procStart.t : -Infinity;
|
|
7879
|
+
return ta > tb || (ta === tb && a.pid >= b.pid);
|
|
7880
|
+
}
|
|
7881
|
+
/**
|
|
7882
|
+
* 조건에 맞는 줄들을 잰다(세포 `lookup`) — `{ row, lives, blind }`: 산 줄 중 고른 것 · 산 줄 전부 · 못 잰 줄 중 고른 것과 까닭(`{ why, row }`).
|
|
7883
|
+
* 같은 열쇠면 나중 줄 — Rust 는 `>=` 로 같은 최댓값 중 마지막을 고르고, 두 자 모두 (pid, updatedAt, session) 차례로 걷는다.
|
|
7884
|
+
*/
|
|
7885
|
+
function harnessLookup(rows, probe, pred) {
|
|
7886
|
+
const out = { row: null, lives: [], blind: null };
|
|
7887
|
+
for (const r of rows) {
|
|
7888
|
+
if (!pred(r)) continue;
|
|
7889
|
+
const life = harnessRowLife(r, probe);
|
|
7890
|
+
if (life === 'live') {
|
|
7891
|
+
out.lives.push(r);
|
|
7892
|
+
if (!out.row || harnessPickGe(r, out.row)) out.row = r;
|
|
7893
|
+
} else if (life !== 'gone') {
|
|
7894
|
+
if (!out.blind || harnessPickGe(r, out.blind.row)) out.blind = { why: life, row: r };
|
|
7895
|
+
}
|
|
7896
|
+
}
|
|
7897
|
+
return out;
|
|
7898
|
+
}
|
|
7899
|
+
/** 조건에 맞는 **산** 줄 중 고른 것(`harnessLookup` 의 row). 없으면 null. */
|
|
7900
|
+
function harnessLiveRow(rows, probe, pred) {
|
|
7901
|
+
return harnessLookup(rows, probe, pred).row;
|
|
7902
|
+
}
|
|
7903
|
+
/** 나를 찾는 프로세스 열쇠(세포 `SelfKeys::from_vars`) — 빈 값은 없는 것. 소켓은 글자 그대로, CLAUDE_PID 는 다듬어 숫자 규칙, 세션 id 는 uuid 꼴만(소문자). */
|
|
7904
|
+
function harnessSelfKeys(env) {
|
|
7905
|
+
const get = (k) => { const v = env?.[k]; return typeof v === 'string' && v.trim() !== '' ? v : null; };
|
|
7906
|
+
const p = get('CLAUDE_PID');
|
|
7907
|
+
const pid = p === null ? null : harnessInt(p.trim());
|
|
7908
|
+
const sid = get('CLAUDE_CODE_SESSION_ID');
|
|
7909
|
+
const session = sid === null ? null : sid.trim().toLowerCase();
|
|
7910
|
+
return { socket: get('CLAUDE_CODE_MESSAGING_SOCKET'),
|
|
7911
|
+
pid: pid !== null && pid > 0 && pid <= 2147483647 ? pid : null,
|
|
7912
|
+
session: session && AUTO_UUID_RE.test(session) ? session : null };
|
|
7913
|
+
}
|
|
7914
|
+
/**
|
|
7915
|
+
* 등록부의 **내 줄**을 찾는다(세포 `self_lookup`) — 열쇠 차례: 소켓 → CLAUDE_PID → 세션 id. 앞 열쇠가 **산 줄이나 못 잰 줄**을 찾으면 뒤 열쇠는
|
|
7916
|
+
* 안 본다(못 잰 줄이 내 줄일 수 있는데 세션 id 로 넘어가면 같은 대화를 띄운 **다른 창**을 나로 고른다 — SEC-1). 끝난 줄만 찾았으면 다음 열쇠로.
|
|
7917
|
+
* `{ by: 'socket'|'pid'|'session', lk }` 또는 null.
|
|
7918
|
+
*/
|
|
7919
|
+
function harnessSelfLookup(rows, keys, probe) {
|
|
7920
|
+
const tries = [['socket', keys.socket, (r) => r.socket === keys.socket], ['pid', keys.pid, (r) => r.pid === keys.pid],
|
|
7921
|
+
['session', keys.session, (r) => r.session === keys.session]];
|
|
7922
|
+
for (const [by, key, pred] of tries) {
|
|
7923
|
+
if (key === null || key === undefined) continue;
|
|
7924
|
+
const lk = harnessLookup(rows, probe, pred);
|
|
7925
|
+
if (lk.row || lk.blind) return { by, lk };
|
|
7926
|
+
}
|
|
7927
|
+
return null;
|
|
7928
|
+
}
|
|
7929
|
+
/** 등록부의 **내 산 줄**(세포 `self_row` — `harnessSelfLookup` 이 산 줄을 찾았을 때만). */
|
|
7930
|
+
function harnessSelfRow(rows, keys, probe) {
|
|
7931
|
+
return harnessSelfLookup(rows, keys, probe)?.lk.row ?? null;
|
|
7932
|
+
}
|
|
7933
|
+
const procStartSeen = new Map(); // pid → { at, v } — 같은 pid 를 1분 안에 다시 안 잰다(ps 한 번이 수 ms)
|
|
7934
|
+
/**
|
|
7935
|
+
* 그 pid 프로세스가 **시작한 때**(유닉스 초 · 세포 `proc_started_at` 과 같은 값). 못 재면 null.
|
|
7936
|
+
* · macOS: `ps -o lstart=` 를 UTC·C 로캘로 — 세포가 sysctl 로 읽는 p_starttime 과 같은 초다(2026-10-07 실측: pid 32054 →
|
|
7937
|
+
* 1791261715 = 「Tue Oct 6 04:41:55 2026」). 로캘을 물려받으면 macOS ps 는 날짜를 한국어로 써서(LANG=ko_KR.UTF-8 실측) 못 읽는다.
|
|
7938
|
+
* · Linux: `/proc/<pid>/stat` 의 22번째 칸(부팅 뒤 틱) ÷ 100 + `/proc/stat` 의 btime — 못 읽으면 ps. ⚠ USER_HZ 를 100 으로 둔다
|
|
7939
|
+
* (Linux 의 사용자 공간 틱은 아키텍처와 상관없이 100 이다 — 세포는 sysconf 로 묻는다). 이 기계에서는 못 돌렸다.
|
|
7940
|
+
* · 그 밖(Windows): null — procStart 가 있는 줄은 산 것으로 안 친다(세포도 같다).
|
|
7941
|
+
*/
|
|
7942
|
+
function procStartOf(pid) {
|
|
7943
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
7944
|
+
const c = procStartSeen.get(pid);
|
|
7945
|
+
if (c && Date.now() - c.at < 60_000) return c.v;
|
|
7946
|
+
let v = null;
|
|
7947
|
+
try {
|
|
7948
|
+
if (process.platform === 'linux') {
|
|
7949
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
7950
|
+
const ticks = Number(stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/)[19]);
|
|
7951
|
+
const btime = Number((/^btime (\d+)$/m.exec(fs.readFileSync('/proc/stat', 'utf8')) || [])[1]);
|
|
7952
|
+
if (Number.isFinite(ticks) && Number.isFinite(btime) && btime > 0) v = btime + Math.floor(ticks / 100);
|
|
7953
|
+
}
|
|
7954
|
+
} catch { /* 아래 ps 로 */ }
|
|
7955
|
+
if (v === null && (process.platform === 'darwin' || process.platform === 'linux')) {
|
|
7956
|
+
try {
|
|
7957
|
+
const r = spawnSync(fs.existsSync('/bin/ps') ? '/bin/ps' : 'ps', ['-o', 'lstart=', '-p', String(pid)],
|
|
7958
|
+
{ encoding: 'utf8', timeout: 1000, env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' } });
|
|
7959
|
+
if (!r.error && r.status === 0) v = lstartEpoch(r.stdout.trim());
|
|
7960
|
+
} catch { /* 못 잰다 */ }
|
|
7961
|
+
}
|
|
7962
|
+
// ⚠ 못 잰 답(null)은 기억하지 않는다(2026-10-07 검증 SEC-5). 부하에 ps 가 1초를 넘기면 null 이 60초 동안 남아, 그동안 이 프로세스의 줄이
|
|
7963
|
+
// 「산 줄 없음」이 되고 서버 이름이 비워졌다(남이 그 이름을 가져갈 틈). 잰 값은 pid 가 살아 있는 동안 바뀌지 않으니 기억해도 된다.
|
|
7964
|
+
if (v !== null) procStartSeen.set(pid, { at: Date.now(), v });
|
|
7965
|
+
return v;
|
|
7966
|
+
}
|
|
7967
|
+
/**
|
|
7968
|
+
* pidDomain 짓기(세포 `pid_domain_of` 와 한 벌 — 공동 표 cell/tests/fixtures/registry/cases.json 의 pid_domain). 하네스 2.1.291 `oas` 의 꼴:
|
|
7969
|
+
* macOS `darwin` · Linux·WSL `linux:<machine-id 다듬음>:<readlink /proc/self/ns/pid>`(못 읽으면 빈 글) · Windows `win32:<호스트 이름 소문자>`.
|
|
7970
|
+
* `platform` 은 Node `process.platform` 낱말.
|
|
7971
|
+
*/
|
|
7972
|
+
function pidDomainOf(platform, machineId, pidNs, host) {
|
|
7973
|
+
if (platform === 'linux') return `linux:${typeof machineId === 'string' ? machineId.trim() : ''}:${typeof pidNs === 'string' ? pidNs : ''}`;
|
|
7974
|
+
if (platform === 'win32') return `win32:${String(host ?? '').toLowerCase()}`;
|
|
7975
|
+
return String(platform);
|
|
7976
|
+
}
|
|
7977
|
+
let pidDomainMemo = null;
|
|
7978
|
+
/**
|
|
7979
|
+
* 이 판의 pidDomain — **하네스가 등록부에 적는 값과 같은 꼴**(한 번 재고 기억한다 · 세포 `pid_domain`).
|
|
7980
|
+
* ⚠ 전에는 `process.platform`(맨 'linux')과 견줘, Linux·WSL 에서는 등록부의 모든 줄이 「떠 있지 않음」이었다 — MCP 는 그 판에서 등록부 이름을
|
|
7981
|
+
* 영영 못 댔고, 이름이 서는 길은 「뜬 뒤 ListAgents」 하나뿐이었다(검증 F1 — 이 맥의 줄은 다 'darwin' 이라 못 봤다). Windows 의 호스트 이름은
|
|
7982
|
+
* 하네스와 같은 `os.hostname()` 이다(세포는 env COMPUTERNAME 으로 어림 — 그 판은 어차피 못 잰다).
|
|
7983
|
+
*/
|
|
7984
|
+
function harnessPidDomain() {
|
|
7985
|
+
if (pidDomainMemo === null) {
|
|
7986
|
+
const p = process.platform;
|
|
7987
|
+
let mid = null, ns = null, host = null;
|
|
7988
|
+
if (p === 'linux') {
|
|
7989
|
+
try { mid = fs.readFileSync('/etc/machine-id', 'utf8'); } catch { mid = null; }
|
|
7990
|
+
try { ns = fs.readlinkSync('/proc/self/ns/pid'); } catch { ns = null; }
|
|
7991
|
+
}
|
|
7992
|
+
if (p === 'win32') { try { host = os.hostname(); } catch { host = null; } }
|
|
7993
|
+
pidDomainMemo = pidDomainOf(p, mid, ns, host);
|
|
7994
|
+
}
|
|
7995
|
+
return pidDomainMemo;
|
|
7996
|
+
}
|
|
7997
|
+
/**
|
|
7998
|
+
* 이 기계의 프로세스 표 — 시험은 픽스처의 probe 를 대신 준다. pidDomain 은 하네스와 같은 꼴(`harnessPidDomain`).
|
|
7999
|
+
* ⚠ Windows 에서는 **못 잰다**(measurable 거짓) — 세포의 pid_alive 가 unix 밖에서는 없어서(world.rs) 같은 줄을 두 자가 다르게 부르지 않게
|
|
8000
|
+
* 맞춘다(그 판에는 procStart 를 잴 ps 도 없다). 「끝났다」가 아니라 「못 잰다」다(F5). 이름이 없으면 uuid 로 닿는다.
|
|
8001
|
+
*/
|
|
8002
|
+
function harnessProbe() {
|
|
8003
|
+
return { alive: pidAlive, started: procStartOf, domain: harnessPidDomain(), measurable: process.platform !== 'win32' };
|
|
8004
|
+
}
|
|
8005
|
+
/** epoch 초 → `2026-10-07T03:12:45Z`(세포 `iso_utc` 와 같은 꼴 — 밀리초 없음). */
|
|
8006
|
+
function isoUtc(secs) {
|
|
8007
|
+
return new Date(secs * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
8008
|
+
}
|
|
8009
|
+
/**
|
|
8010
|
+
* 계약 1 의 JSON 한 줄과 같은 꼴(세포 `whereabouts_of`) — `{ session, name, live, pid, source, stale_name, checked_at, matched_by, live_rows,
|
|
8011
|
+
* measured }`(칸 차례도 같다 — 뒤 셋은 2026-10-07 덧붙인 칸이고 기본값은 「열쇠 모름 · 산 줄이면 1줄 · 재서 나온 답」, `harnessWithRows` 가 채운다).
|
|
8012
|
+
* 산 줄이 있으면 source 'registry' · name 은 등록부 이름(꼴 밖이면 null — 그래도 떠 있다) · stale_name 은 전사의 마지막 자기소개가
|
|
8013
|
+
* 지금 이름과 **다를 때만**(다시 뜨며 바뀐 옛 이름).
|
|
8014
|
+
* 없으면 name·pid 는 null 이고, 전사의 마지막 자기소개가 있으면 source 'transcript' · stale_name 에 그 이름(지금 이름이 아니다).
|
|
8015
|
+
* `introName` 은 이름 꼴(`harnessNameClean`)을 다시 지난다 — 시험이 주는 글도 세포의 이름 꼴로만 받는다.
|
|
8016
|
+
*/
|
|
8017
|
+
function harnessWhereaboutsOf(session, row, introName, now) {
|
|
8018
|
+
const checked_at = isoUtc(now);
|
|
8019
|
+
const stale = harnessNameClean(introName);
|
|
8020
|
+
if (row) {
|
|
8021
|
+
return { session, name: row.name, live: true, pid: row.pid, source: 'registry',
|
|
8022
|
+
stale_name: stale !== null && stale !== row.name ? stale : null, checked_at, matched_by: null, live_rows: 1, measured: true };
|
|
8023
|
+
}
|
|
8024
|
+
return { session, name: null, live: false, pid: null, source: stale !== null ? 'transcript' : 'none', stale_name: stale, checked_at,
|
|
8025
|
+
matched_by: null, live_rows: 0, measured: true };
|
|
8026
|
+
}
|
|
8027
|
+
/**
|
|
8028
|
+
* 답에 덧붙인 칸 셋을 채운다(세포 `with_rows`) — 찾은 열쇠(matched_by) · 그 세션의 산 줄 수(live_rows — `same` 은 같은 세션 uuid 로 잰 판정) ·
|
|
8029
|
+
* 재서 나온 답인가(measured — `blind` 는 못 잰 까닭, 떠 있으면 무시).
|
|
8030
|
+
*/
|
|
8031
|
+
function harnessWithRows(w, same, blind, by) {
|
|
8032
|
+
w.matched_by = by ?? null;
|
|
8033
|
+
w.live_rows = w.live ? Math.max(1, same.lives.length) : 0;
|
|
8034
|
+
w.measured = w.live ? true : !blind;
|
|
8035
|
+
// 창 여럿이 한 전사에 함께 쓰면 마지막 자기소개가 다른 창의 **지금** 이름일 수 있다 — 옛 이름이 아니다(세포 `with_rows` 와 같다).
|
|
8036
|
+
if (w.live && w.stale_name !== null && same.lives.some((r) => r.name === w.stale_name)) w.stale_name = null;
|
|
8037
|
+
return w;
|
|
8038
|
+
}
|
|
8039
|
+
/**
|
|
8040
|
+
* `gcell agents --session <uuid>` 의 사본(세포 `session_whereabouts`) — 그 세션의 산 줄 중 고른 것 · 옛 이름은 그 세션 본 전사에서.
|
|
8041
|
+
* MCP 는 /clear 앞 대화의 uuid 가 다른 창에 떠 있는지 볼 때 이 판정(harnessLookup)을 쓴다. 이름 관문이 세포 `--session` 과 같은 픽스처에
|
|
8042
|
+
* 대 본다 — 몸은 `--self` 와 같은 블록이다. `intro` = 세션 uuid → 마지막 자기소개 이름(없으면 null).
|
|
8043
|
+
*/
|
|
8044
|
+
function harnessWhereabouts(sid, rows, probe, intro, now) {
|
|
8045
|
+
const s = String(sid ?? '').toLowerCase();
|
|
8046
|
+
const lk = harnessLookup(rows, probe, (r) => r.session === s);
|
|
8047
|
+
return harnessWithRows(harnessWhereaboutsOf(s, lk.row, intro(s), now), lk, lk.blind ? lk.blind.why : null, null);
|
|
8048
|
+
}
|
|
8049
|
+
/**
|
|
8050
|
+
* `gcell agents --self` 의 사본(세포 `self_whereabouts`) — 프로세스 열쇠로 내 줄을 찾고, 옛 이름은 **그 줄의 세션** 본 전사에서
|
|
8051
|
+
* (/clear 뒤에는 env 의 세션 id 가 아니라 등록부 줄의 세션이 나다). 열쇠가 못 잰 줄만 찾았으면 이름 칸은 비고 measured 거짓 — 세션은
|
|
8052
|
+
* 그 줄의 세션(이 프로세스의 열쇠로 찾은 줄이다). 아무것도 못 찾았으면 env 의 세션 id 로 옛 이름만 — 이름 칸은 빈다.
|
|
8053
|
+
*/
|
|
8054
|
+
function harnessSelfWhereabouts(rows, keys, probe, intro, now) {
|
|
8055
|
+
const f = harnessSelfLookup(rows, keys, probe);
|
|
8056
|
+
if (f?.lk.row) {
|
|
8057
|
+
const r = f.lk.row;
|
|
8058
|
+
const same = harnessLookup(rows, probe, (x) => x.session === r.session);
|
|
8059
|
+
return harnessWithRows(harnessWhereaboutsOf(r.session, r, intro(r.session), now), same, null, f.by);
|
|
8060
|
+
}
|
|
8061
|
+
if (f?.lk.blind) {
|
|
8062
|
+
const s = f.lk.blind.row.session;
|
|
8063
|
+
return harnessWithRows(harnessWhereaboutsOf(s, null, intro(s), now), f.lk, f.lk.blind.why, f.by);
|
|
8064
|
+
}
|
|
8065
|
+
return harnessWhereaboutsOf(keys.session, null, keys.session ? intro(keys.session) : null, now);
|
|
8066
|
+
}
|
|
8067
|
+
/**
|
|
8068
|
+
* 이 프로세스의 지금 주소 — JS 사본(세포가 없거나 못 알아들을 때 `selfAddress` 가 쓴다). 세포 `gcell agents --self --json` 과 **같은 답**이다:
|
|
8069
|
+
* 이름 관문(cell/tests/fixtures/agent-names/ · smoke 3-4)이 두 자에 같은 경우를 먹여 맞댄다.
|
|
8070
|
+
* ⚠ 전에는 산 줄이 있으면 전사를 안 읽어 stale_name 이 늘 null 이었다(「참고 칸이라 253MB 를 거슬러 읽는 값을 안 낸다」). 같은 입력에 세포와
|
|
8071
|
+
* 다른 답이라 관문이 그 자리에서 빨갰다(픽스처 02-restarted). 이제 읽는다 — 값: 자기소개가 끝 가까이 있으면 조각 하나, 없으면 한 번 끝까지
|
|
8072
|
+
* (실측 세포 101MB 0.10초), 그 뒤로는 `introOfFile` 의 한 칸 캐시가 늘어난 부분만 읽는다(등록은 60초에 한 번 · 세포가 있으면 이 길을 안 탄다).
|
|
8073
|
+
*/
|
|
8074
|
+
function selfAddressJs(env = process.env, probe = harnessProbe(), intro = (sid) => claudeIntroName(sid, env), now = Math.floor(Date.now() / 1000)) {
|
|
8075
|
+
return harnessSelfWhereabouts(harnessRowsIn(harnessSessionsDir(env)), harnessSelfKeys(env), probe, intro, now);
|
|
8076
|
+
}
|
|
8077
|
+
|
|
8078
|
+
/*
|
|
8079
|
+
* ── 세포에게 묻는다 — `gcell agents --self --json` (계약 1·2) ───────────────────────────────────────────────────
|
|
8080
|
+
* 세포 경로는 기존 규칙(`cellCheck` — GENETICS_CELL_BIN · 꾸러미 지문 · 설치본) 그대로다. 1초 안에 계약 꼴의 JSON 한 줄이 아니면 JS 사본으로.
|
|
8081
|
+
* ⚠ **기다리되 막지 않는다** — execFile 이라 그 1초 동안에도 다른 요청이 돈다(spawnSync 였으면 도구 응답이 통째로 선다).
|
|
8082
|
+
* ⚠ 옛 세포(--self 를 모르는 판 — 설치본 f0b68dc)는 모르는 깃발을 무시하고 `agents` 를 통째로 돈다. 임시 폴더에서도 전사를 다 걸어
|
|
8083
|
+
* 15.2초가 걸렸다(2026-10-07 실측 — 저장소를 못 찾으면 전사 폴더 전부를 걷는다, world.rs transcript_dirs_for). 그래서 `--since 0` 을
|
|
8084
|
+
* 함께 준다: 옛 세포는 0분 창으로 곧바로 「최근 0분 안에 … 없다」 한 줄을 내고(0.18초 실측), 그 글은 계약 꼴이 아니니 「못 알아듣는다」로
|
|
8085
|
+
* 적힌다. 새 세포는 `--self` 를 `--since` 보다 먼저 가르고 돌아가 그 깃발을 안 본다(cmd_agents). cwd 는 임시 폴더 — `--self` 는 저장소가
|
|
8086
|
+
* 아니라 프로세스 열쇠로 찾는다.
|
|
8087
|
+
* ⚠ 못 알아들은 세포는 그 바이너리(경로·크기·mtime)가 바뀌기 전엔 다시 안 묻는다 — 판올림(ensureCellOnBoot)이 갈아 끼우면 다시 묻는다.
|
|
8088
|
+
* 시간 초과는 「느리다」일 수 있어 5분만 쉰다.
|
|
8089
|
+
*/
|
|
8090
|
+
const CELL_SELF_TIMEOUT_MS = 1000;
|
|
8091
|
+
let cellSelfDeaf = ''; // --self 를 못 알아들은 세포의 지문
|
|
8092
|
+
let cellSelfRestUntil = 0; // 시간 초과 뒤 쉬는 끝
|
|
8093
|
+
function cellPrint(bin) {
|
|
8094
|
+
try { const st = fs.statSync(bin); return `${bin}\0${st.size}\0${st.mtimeMs}`; } catch { return ''; }
|
|
8095
|
+
}
|
|
8096
|
+
/**
|
|
8097
|
+
* 세포가 낸 글 → 계약 1 의 꼴, 아니면 null. **모양을 제한한다** — 지문 검사가 뚫려도 문맥에 이상한 글자가 안 들어가게(cellClean 과 같은 방어).
|
|
8098
|
+
* 숫자 칸(pid)은 등록부와 같이 숫자 문자열도 받는다.
|
|
8099
|
+
*/
|
|
8100
|
+
function cellSelfParse(stdout) {
|
|
8101
|
+
const t = String(stdout ?? '').trim();
|
|
8102
|
+
if (!t.startsWith('{') || t.includes('\n') || t.length > 4096) return null;
|
|
8103
|
+
let d;
|
|
8104
|
+
try { d = JSON.parse(t); } catch { return null; }
|
|
8105
|
+
if (!d || typeof d !== 'object' || Array.isArray(d)) return null;
|
|
8106
|
+
if (!['registry', 'transcript', 'none'].includes(d.source) || typeof d.live !== 'boolean') return null;
|
|
8107
|
+
if (d.name !== null && d.name !== undefined && !harnessNameOk(d.name)) return null;
|
|
8108
|
+
const pid = d.pid === null || d.pid === undefined ? null : harnessInt(d.pid);
|
|
8109
|
+
// 덧붙인 칸(2026-10-07) — 모르는 값이면 「모른다」 쪽으로: 열쇠 null(이름을 제 것으로 안 댄다) · 산 줄 수는 산가에서 · measured 는 칸이 있을 때만 거짓.
|
|
8110
|
+
const rows = harnessInt(d.live_rows);
|
|
8111
|
+
return {
|
|
8112
|
+
session: typeof d.session === 'string' && AUTO_UUID_RE.test(d.session) ? d.session.toLowerCase() : null,
|
|
8113
|
+
name: d.name ?? null,
|
|
8114
|
+
live: d.live,
|
|
8115
|
+
pid: pid !== null && pid > 0 ? pid : null,
|
|
8116
|
+
source: d.source,
|
|
8117
|
+
stale_name: harnessNameOk(d.stale_name) ? d.stale_name : null,
|
|
8118
|
+
checked_at: typeof d.checked_at === 'string' ? cellClean(d.checked_at).slice(0, 40) : '',
|
|
8119
|
+
matched_by: ['socket', 'pid', 'session'].includes(d.matched_by) ? d.matched_by : null,
|
|
8120
|
+
live_rows: rows !== null && rows >= 0 && rows < 1000 ? rows : (d.live ? 1 : 0),
|
|
8121
|
+
measured: d.measured !== false,
|
|
8122
|
+
};
|
|
8123
|
+
}
|
|
8124
|
+
/** 세포의 답 — `{ ok: true, v }` 또는 `{ ok: false, why }`(why: none·deaf·rest·timeout·bad·spawn). */
|
|
8125
|
+
function cellSelf() {
|
|
8126
|
+
const { bin } = cellCheck();
|
|
8127
|
+
if (!bin) return Promise.resolve({ ok: false, why: 'none' });
|
|
8128
|
+
const print = cellPrint(bin);
|
|
8129
|
+
if (print && print === cellSelfDeaf) return Promise.resolve({ ok: false, why: 'deaf' });
|
|
8130
|
+
if (Date.now() < cellSelfRestUntil) return Promise.resolve({ ok: false, why: 'rest' });
|
|
8131
|
+
return new Promise((resolve) => {
|
|
8132
|
+
let done = false;
|
|
8133
|
+
let timer = null;
|
|
8134
|
+
const finish = (v) => { if (done) return; done = true; if (timer) clearTimeout(timer); resolve(v); };
|
|
8135
|
+
let child;
|
|
8136
|
+
try {
|
|
8137
|
+
child = execFile(bin, ['agents', '--self', '--json', '--since', '0'],
|
|
8138
|
+
{ encoding: 'utf8', maxBuffer: 64 * 1024, cwd: os.tmpdir(), windowsHide: true },
|
|
8139
|
+
(err, stdout) => {
|
|
8140
|
+
/*
|
|
8141
|
+
* ⚠ 실행 자체가 안 된 것(EAGAIN·ENOMEM·EACCES·ENOENT … — err.code 가 글)은 「못 알아들었다」가 아니다(2026-10-07 검증 SEC-5).
|
|
8142
|
+
* 전에는 이것도 deaf 로 적어, fork 한 번 실패에 그 세포를 판올림 전까지 다시 안 물었다. 잠깐(30초) 쉬고 다시 묻는다.
|
|
8143
|
+
*/
|
|
8144
|
+
if (err && typeof err.code === 'string' && err.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
|
|
8145
|
+
if (!done) cellSelfRestUntil = Date.now() + 30_000;
|
|
8146
|
+
finish({ ok: false, why: 'spawn' });
|
|
8147
|
+
return;
|
|
8148
|
+
}
|
|
8149
|
+
// 끝난 코드가 0 이 아니어도 계약 꼴의 한 줄이면 받는다 — 판정은 글이 한다(못 알아들은 판은 글이 계약 꼴이 아니다).
|
|
8150
|
+
const v = err?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' ? null : cellSelfParse(stdout);
|
|
8151
|
+
if (!v) { if (!done) cellSelfDeaf = print; finish({ ok: false, why: 'bad' }); return; }
|
|
8152
|
+
finish({ ok: true, v });
|
|
8153
|
+
});
|
|
8154
|
+
} catch { cellSelfRestUntil = Date.now() + 30_000; finish({ ok: false, why: 'spawn' }); return; }
|
|
7127
8155
|
/*
|
|
7128
|
-
*
|
|
7129
|
-
*
|
|
8156
|
+
* 1초는 **자리를 비우는 시각**이다 — execFile 의 timeout 은 죽인 뒤에도 그 자식이 물려준 stdout 이 닫혀야 답을 준다(손자 프로세스가
|
|
8157
|
+
* 파이프를 쥐면 그만큼 늦는다). 그래서 따로 잰다: 넘으면 죽이고 곧바로 JS 사본으로 간다.
|
|
7130
8158
|
*/
|
|
7131
|
-
|
|
7132
|
-
|
|
8159
|
+
timer = setTimeout(() => {
|
|
8160
|
+
try { child.kill('SIGKILL'); } catch { /* 이미 끝났다 */ }
|
|
8161
|
+
cellSelfRestUntil = Date.now() + 5 * 60_000;
|
|
8162
|
+
finish({ ok: false, why: 'timeout' });
|
|
8163
|
+
}, CELL_SELF_TIMEOUT_MS);
|
|
8164
|
+
timer.unref?.();
|
|
8165
|
+
});
|
|
8166
|
+
}
|
|
8167
|
+
/** 이 프로세스의 지금 주소(계약 1 의 꼴 + `via`) — 세포가 먼저, 못 하면 JS 사본. **부를 때마다 새로 푼다**(이름은 보내기 직전에). */
|
|
8168
|
+
async function selfAddress() {
|
|
8169
|
+
const c = await cellSelf();
|
|
8170
|
+
if (c.ok) return { ...c.v, via: 'cell' };
|
|
8171
|
+
return { ...selfAddressJs(), via: 'js', cell: c.why };
|
|
8172
|
+
}
|
|
8173
|
+
|
|
8174
|
+
/**
|
|
8175
|
+
* 전사의 `[from, size)` 에서 **마지막** 자기소개를 끝에서부터 거슬러 찾는다. `{ name, end, at }` — `end` 는 마지막 개행 바로 뒤
|
|
8176
|
+
* (쓰는 중인 반쪽 줄은 다음 차례에 그 자리부터 다시 읽는다), `at` 은 그 레코드의 timestamp(ms · 없거나 못 읽으면 0).
|
|
8177
|
+
* `at` 은 「그 자기소개를 지금 프로세스가 냈나」를 가르는 데 쓴다(`myAgentName` ②) — 0 이면 가를 수 없으니 지금 것으로 안 친다.
|
|
8178
|
+
* ⚠ 전에는 읽을 양이 64MB 이상이면 **통째로 건너뛰었다**(전사 최대 7MB 가정). 그런데 그림이 base64 로 실려 전사가 253MB
|
|
8179
|
+
* 까지 간다(2026-10-07 실측 — 이 MCP 를 띄운 세션). 첫 읽기가 늘 건너뛰어져 이름이 영영 「없음」이 됐고, mail 이 「이 세션에는
|
|
8180
|
+
* 말 걸 수 있는 이름이 없다」로 거절했다 — ListAgents 를 이미 부른 뒤였는데도. 마지막 것만 필요하니 끝에서 거스르면 된다.
|
|
8181
|
+
* ⚠ 줄 고르기는 세포 `last_intro_in` 과 **글자 하나까지** 같다(이름 관문 2026-10-07 — 픽스처 11-giant-line): 1MiB 를 넘는 줄은 자기소개로
|
|
8182
|
+
* 안 치고 이어 붙이지도 않는다. 전에는 그 줄을 읽어(세포는 건너뛰어) 같은 전사에 두 자가 다른 옛 이름을 냈고, 큰 줄을 조각마다 다시
|
|
8183
|
+
* 이어 붙여 복사가 제곱으로 늘었다. 조각은 쌓아 두었다가 줄이 온전해질 때 한 번 잇는다. 맨 끝의 반쪽 줄은 안 쌓는다(어차피 안 읽는다).
|
|
8184
|
+
*/
|
|
8185
|
+
function lastIntroInRange(fd, from, size, chunk = 8 * 1024 * 1024) {
|
|
8186
|
+
chunk = Math.max(1, chunk);
|
|
8187
|
+
let pos = size;
|
|
8188
|
+
let end = -1;
|
|
8189
|
+
let carry = []; // 뒤에서 이어지는 줄의 조각들(파일에서 뒤쪽 조각이 앞에) — 그 줄의 머리는 아직 안 읽은 앞쪽에 있다
|
|
8190
|
+
let carryLen = 0;
|
|
8191
|
+
let skipping = true; // 지금 거슬러 가는 줄을 버리는 중인가 — 처음엔 맨 끝의 쓰는 중인 반쪽 줄, 그 뒤엔 상한을 넘은 큰 줄
|
|
8192
|
+
const found = (h) => ({ name: h.name, end, at: h.at });
|
|
8193
|
+
while (pos > from) {
|
|
8194
|
+
const start = Math.max(from, pos - chunk);
|
|
8195
|
+
const buf = Buffer.allocUnsafe(pos - start);
|
|
8196
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
8197
|
+
pos = start;
|
|
8198
|
+
const lastNl = buf.lastIndexOf(0x0a);
|
|
8199
|
+
if (lastNl < 0) { // 조각 전체가 한 줄의 가운데
|
|
8200
|
+
if (!skipping) {
|
|
8201
|
+
carry.push(buf);
|
|
8202
|
+
carryLen += buf.length;
|
|
8203
|
+
if (carryLen > INTRO_LINE_MAX) { carry = []; carryLen = 0; skipping = true; }
|
|
8204
|
+
}
|
|
8205
|
+
continue;
|
|
7133
8206
|
}
|
|
7134
|
-
|
|
8207
|
+
if (end < 0) end = start + lastNl + 1; // 마지막 개행 바로 뒤 — 다음 이어 읽기의 자리
|
|
8208
|
+
// ① 마지막 개행 뒤 + 뒤에서 이어지던 조각들 = 이제 머리까지 다 읽은 한 줄
|
|
8209
|
+
if (!skipping && buf.length - (lastNl + 1) + carryLen <= INTRO_LINE_MAX) {
|
|
8210
|
+
const h = introOfLine(Buffer.concat([buf.subarray(lastNl + 1), ...carry.reverse()]));
|
|
8211
|
+
if (h) return found(h);
|
|
8212
|
+
}
|
|
8213
|
+
carry = [];
|
|
8214
|
+
carryLen = 0;
|
|
8215
|
+
skipping = false;
|
|
8216
|
+
// ② 이 조각 안의 온전한 줄들 — 끝에서부터
|
|
8217
|
+
const firstNl = buf.indexOf(0x0a);
|
|
8218
|
+
const h2 = introInLines(buf.subarray(firstNl + 1, lastNl + 1));
|
|
8219
|
+
if (h2) return found(h2);
|
|
8220
|
+
// ③ 맨 앞 줄(첫 개행까지) — 출발점(파일 맨 앞 · 이어 읽기의 자리)이면 온전하고, 아니면 머리가 더 앞 조각에 있다
|
|
8221
|
+
const head = buf.subarray(0, firstNl + 1);
|
|
8222
|
+
if (start === from) {
|
|
8223
|
+
const h3 = introInLines(head);
|
|
8224
|
+
if (h3) return found(h3);
|
|
8225
|
+
} else if (head.length > INTRO_LINE_MAX) {
|
|
8226
|
+
skipping = true;
|
|
8227
|
+
} else {
|
|
8228
|
+
carry.push(head);
|
|
8229
|
+
carryLen = head.length;
|
|
8230
|
+
}
|
|
8231
|
+
}
|
|
8232
|
+
// 출발점의 첫 줄이 조각보다 길었다 — 그 머리가 출발점이다
|
|
8233
|
+
if (!skipping && carry.length) {
|
|
8234
|
+
const h = introOfLine(Buffer.concat(carry.reverse()));
|
|
8235
|
+
if (h) return found(h);
|
|
8236
|
+
}
|
|
8237
|
+
return { name: null, end: end < 0 ? from : end, at: 0 };
|
|
8238
|
+
}
|
|
8239
|
+
/** 자기소개 레코드 한 줄의 상한(세포 `INTRO_LINE_MAX`) — 실제 자기소개 줄은 2.7~4.5KB(2026-10-07 실측 35건). 더 긴 줄(그림 base64)은 자기소개가 아니다. */
|
|
8240
|
+
const INTRO_LINE_MAX = 1 << 20;
|
|
8241
|
+
/** 한 줄(바이트)이 하네스가 낸 자기소개 레코드면 `{ name, at }`(세포 `intro_of_line` — 상한 · 머리 글자 · 레코드 모양 차례 그대로). */
|
|
8242
|
+
function introOfLine(line) {
|
|
8243
|
+
if (!line.length || line.length > INTRO_LINE_MAX || !line.includes(INTRO_HEAD)) return null;
|
|
8244
|
+
const s = line.toString('utf8'); // 깨진 바이트는 U+FFFD — 세포의 from_utf8_lossy 와 같다
|
|
8245
|
+
const name = introNameOf(s);
|
|
8246
|
+
if (!name) return null;
|
|
8247
|
+
let at = 0;
|
|
8248
|
+
try { at = Date.parse(JSON.parse(s).timestamp) || 0; } catch { /* 시각이 없으면 0 */ }
|
|
8249
|
+
return { name, at };
|
|
8250
|
+
}
|
|
8251
|
+
/** 개행으로 끝나는 줄 묶음에서 **끝에서부터** 자기소개를 찾는다(세포 `intro_in_lines`). 줄은 바이트로 자른다 — 상한도 바이트다. */
|
|
8252
|
+
function introInLines(body) {
|
|
8253
|
+
if (!body.includes(INTRO_HEAD)) return null; // 값싼 검사로 먼저 거른다
|
|
8254
|
+
let e = body.length;
|
|
8255
|
+
if (e > 0 && body[e - 1] === 0x0a) e--;
|
|
8256
|
+
for (;;) {
|
|
8257
|
+
const s = e > 0 ? body.lastIndexOf(0x0a, e - 1) + 1 : 0;
|
|
8258
|
+
const h = introOfLine(body.subarray(s, e));
|
|
8259
|
+
if (h) return h;
|
|
8260
|
+
if (s === 0) return null;
|
|
8261
|
+
e = s - 1;
|
|
7135
8262
|
}
|
|
8263
|
+
}
|
|
8264
|
+
|
|
8265
|
+
let nameCache = null; // { file, size, name, at }
|
|
8266
|
+
/** 세션 uuid 의 전사(findTranscript — Codex·Cursor 포함)의 마지막 자기소개. 전사가 없으면 null. `myAgentName` 이 쓴다(Cursor 판정에 file 이 든다). */
|
|
8267
|
+
function introOf(uuid) {
|
|
8268
|
+
const f = transcriptOf(uuid);
|
|
8269
|
+
return f ? introOfFile(f) : null;
|
|
8270
|
+
}
|
|
8271
|
+
/**
|
|
8272
|
+
* 세션 uuid → 그 세션 **본 전사**(세포의 규칙 — 설정 폴더 먼저 · `claudeTranscriptIn`)의 마지막 자기소개 이름. 없으면 null.
|
|
8273
|
+
* 세포 `claude_transcript_of` + `agent_name_of` 의 사본 — `selfAddressJs` 의 옛 이름 칸이 이것을 쓴다(이름 관문).
|
|
8274
|
+
*/
|
|
8275
|
+
function claudeIntroName(sid, env = process.env) {
|
|
8276
|
+
const f = claudeTranscriptIn(claudeProjectBases(env), sid);
|
|
8277
|
+
return f ? (introOfFile(f)?.name ?? null) : null;
|
|
8278
|
+
}
|
|
8279
|
+
/** 전사 파일 하나의 마지막 자기소개 `{ name, at, file }`(name 없으면 null · at = 그 레코드의 timestamp ms). 못 읽으면 null. 늘어난 부분만 다시 읽는다(한 칸 캐시). */
|
|
8280
|
+
function introOfFile(f) {
|
|
7136
8281
|
let st;
|
|
7137
8282
|
try { st = fs.statSync(f); } catch { return null; }
|
|
7138
|
-
if (nameCache?.file === f && nameCache.size === st.size) return nameCache.name;
|
|
8283
|
+
if (nameCache?.file === f && nameCache.size === st.size) return { name: nameCache.name, at: nameCache.at, file: f };
|
|
7139
8284
|
const from = (nameCache?.file === f && st.size > nameCache.size) ? nameCache.size : 0;
|
|
7140
|
-
let found =
|
|
8285
|
+
let found = from > 0 ? nameCache.name : null;
|
|
8286
|
+
let at = from > 0 ? nameCache.at : 0;
|
|
8287
|
+
let end = from;
|
|
7141
8288
|
try {
|
|
7142
8289
|
const fd = fs.openSync(f, 'r');
|
|
7143
8290
|
try {
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
// 줄 단위 — 마지막 것이 이긴다(옛 이름은 죽은 주소다).
|
|
7150
|
-
// ⚠ 쓰는 중인 마지막 줄(개행 없음)은 이번에 안 읽고 다음에 그 자리부터 다시 읽는다 —
|
|
7151
|
-
// 캐시 크기를 마지막 개행까지만 전진시킨다. 안 그러면 잘린 줄이 영영 사라진다.
|
|
7152
|
-
const cut = txt.lastIndexOf('\n');
|
|
7153
|
-
const whole = cut >= 0 ? txt.slice(0, cut + 1) : '';
|
|
7154
|
-
for (const line of whole.split('\n')) {
|
|
7155
|
-
const n = introNameOf(line);
|
|
7156
|
-
if (n) found = n;
|
|
7157
|
-
}
|
|
7158
|
-
if (!found && (isCursorTranscript(f) || uuid === cursorActiveComposer()
|
|
7159
|
-
|| process.env.CURSOR_CONVERSATION_ID === uuid)) {
|
|
7160
|
-
found = cursorAliasOf(uuid);
|
|
7161
|
-
}
|
|
7162
|
-
nameCache = { file: f, size: from + Buffer.byteLength(whole, 'utf8'), name: found };
|
|
7163
|
-
return found;
|
|
7164
|
-
}
|
|
8291
|
+
// 마지막 것이 이긴다(옛 이름은 죽은 주소다) — 그래서 끝에서 거슬러 첫 자기소개에서 멈춘다(크기 상한 없음).
|
|
8292
|
+
// ⚠ 쓰는 중인 마지막 줄(개행 없음)은 이번에 안 읽고 다음에 그 자리부터 다시 읽는다(`end` 가 마지막 개행 바로 뒤).
|
|
8293
|
+
const r = lastIntroInRange(fd, from, st.size);
|
|
8294
|
+
if (r.name) { found = r.name; at = r.at || 0; }
|
|
8295
|
+
end = r.end;
|
|
7165
8296
|
} finally { fs.closeSync(fd); }
|
|
7166
8297
|
} catch { /* 못 읽으면 이름이 없는 것이지 고장이 아니다 */ }
|
|
7167
|
-
|
|
7168
|
-
|
|
7169
|
-
|
|
8298
|
+
nameCache = { file: f, size: end, name: found, at };
|
|
8299
|
+
return { name: found, at, file: f };
|
|
8300
|
+
}
|
|
8301
|
+
|
|
8302
|
+
/** 이 MCP 프로세스가 뜬 때(ms) — 하네스가 띄우므로 그 claude 프로세스보다 늦다. 그 뒤에 적힌 자기소개는 지금 프로세스가 낸 것이다. */
|
|
8303
|
+
const BOOT_MS = Math.floor(performance.timeOrigin);
|
|
8304
|
+
|
|
8305
|
+
/**
|
|
8306
|
+
* 서버 메일 주소록이 받는 이름인가 — 서버 `mail_name_ok`(www/portal/lib/mail.php)와 같은 규칙: 1~60자 · 공백류·제어문자·[ ] → < > 없음 ·
|
|
8307
|
+
* **이름 없는 세션의 발신 표시 꼴(uuid 앞 8자 — 16진 8자)과 uuid 꼴이 아니다**(2026-10-07 검증 SEC-3: 남이 그 꼴을 이름으로 대면 이름 없는
|
|
8308
|
+
* 세션과 머리 줄이 같아져 사칭이 되고, 그 표시로 한 답장이 사칭한 쪽으로 풀렸다).
|
|
8309
|
+
*/
|
|
8310
|
+
function mailNameOk(s) {
|
|
8311
|
+
return typeof s === 'string' && s !== '' && [...s].length <= 60
|
|
8312
|
+
&& !/[\s\u0000-\u001F\u007F-\u009F\u2028\u2029[\]→<>]/u.test(s)
|
|
8313
|
+
&& !/^[0-9a-f]{8}$/i.test(s) && !/^[0-9a-f-]{36}$/i.test(s);
|
|
8314
|
+
}
|
|
8315
|
+
|
|
8316
|
+
/** ①로 확인한 이 프로세스의 마지막 이름(이 MCP 프로세스 안에서만) — ④가 쓴다. */
|
|
8317
|
+
const mailNameSeen = { session: '', name: '' };
|
|
8318
|
+
|
|
8319
|
+
/**
|
|
8320
|
+
* 서버 주소록에 댈 이름과 이 프로세스의 지금 메일 주소 — `{ name, why, addr, cur }`. name '' 은 「이름 없이 댄다」(받기는 된다 · 보낼 때
|
|
8321
|
+
* 발신 표시는 uuid 앞 8자).
|
|
8322
|
+
* cur — 이 프로세스의 지금 세션 uuid. 세포(또는 JS 사본)가 **소켓 열쇠로** 찾은 산 줄의 세션이다 — 하네스가 이 MCP 에게만 준 소켓이니 그
|
|
8323
|
+
* 줄이 이 프로세스다. `/clear` 뒤에는 env 의 세션 id(mine — 뜰 때 값)와 다르다: 하네스는 /clear 에 등록부 줄의 sessionId 만 바꾼다
|
|
8324
|
+
* (검증 F2·SEC-7). 소켓으로 못 찾았으면 mine.
|
|
8325
|
+
* 대는 이름은 **지금 이 프로세스의 이름이라고 확인된 것**뿐이다:
|
|
8326
|
+
* ① 등록부의 산 줄 — 그 줄을 **이 프로세스의 열쇠**로 찾았을 때만: 소켓이거나, 그 줄의 세션이 mine 이다. CLAUDE_PID 로만 찾은 남의 세션
|
|
8327
|
+
* 줄은 아니다 — 하네스는 MCP 에 CLAUDE_PID 를 안 주니(Bash·훅에만 준다) MCP env 의 그 값은 조상 claude 의 Bash 에서 물려받은 것이다
|
|
8328
|
+
* (SEC-2: Bash 에서 띄운 `claude -p` 의 MCP 가 부모의 이름을 제 uuid 에 댈 뻔했다).
|
|
8329
|
+
* 그 세션 uuid 의 산 줄이 **둘 이상**이면(한 대화를 창 여럿이 띄움) 이름 없이 댄다 — 창마다 이름이 달라 두 MCP 가 같은 서버 줄에 번갈아
|
|
8330
|
+
* 이름을 대고(L1), 그 uuid 앞 통은 한 편지함이라 어느 창이 받을지 정해지지 않는다(SEC-1). 두 MCP 가 같은 판정을 내니 이름이 안 뒤집힌다.
|
|
8331
|
+
* ② 전사의 자기소개 — **이 MCP 가 뜬 뒤에** 적힌 하네스 ListAgents 결과만(`introNameOf` 가 짝 표식 toolUseResult.listing 을 본다 — SEC-6).
|
|
8332
|
+
* MCP 는 claude 프로세스가 띄우므로 그 뒤에 적힌 자기소개는 지금 프로세스가 낸 것이다. 그 전 것은 다시 뜨기 전 이름일 수 있고, 그
|
|
8333
|
+
* 이름은 지금 남의 것일 수 있다(7afd1d02 의 hnl-73 = 지금 110cd958 — 검증 2026-10-07). 등록부가 없는 옛 하네스·잴 수 없는 판
|
|
8334
|
+
* (Windows)에서 ListAgents 를 부르면 이름이 서는 길이다.
|
|
8335
|
+
* ③ Cursor 대화의 별칭 cursor-<uuid 앞 2자> — uuid 에서 나오는 값이라 낡지 않는다. 전사도 ListAgents 도 없는 자리다.
|
|
8336
|
+
* ④ 이번에 **못 쟀으면**(measured 거짓 — 세포 시간 초과 뒤 JS 사본의 ps 가 실패 · 시작 시각을 못 잼) 이 MCP 가 ①로 마지막에 확인한 이름을
|
|
8337
|
+
* 다시 댄다(SEC-5 — 일시 실패가 서버의 이름 비움 → 같은 이름을 대는 남이 가져감 → 다음 등록은 held 로 번지지 않게). 이름은 프로세스마다
|
|
8338
|
+
* 정해지니 같은 MCP 프로세스(= 같은 claude 프로세스) 안의 확인은 유효하다. 확인한 적이 없으면 이름 없이.
|
|
8339
|
+
* 그리고 서버가 받는 꼴(`mailNameOk`)이어야 한다 — 아니면 이름 없이 댄다(꼴 때문에 등록이 400 이면 보내기까지 막힌다).
|
|
8340
|
+
* Native Codex 는 늘 이름 없이 댄다 — 서버가 등록된 uuid 로 발신 표시를 정한다(이름을 지어내지 않는다).
|
|
8341
|
+
*/
|
|
8342
|
+
async function myAgentName(mine = mySessionId()) {
|
|
8343
|
+
if (!AUTO_UUID_RE.test(String(mine || ''))) return { name: '', why: '이 세션의 번호를 모른다', addr: null, cur: mine };
|
|
8344
|
+
if (!process.env.CLAUDE_CODE_SESSION_ID && mine === codexSessionId()) {
|
|
8345
|
+
return { name: '', why: 'Codex 세션은 이름 없이 댄다', addr: null, cur: mine };
|
|
8346
|
+
}
|
|
8347
|
+
const addr = await selfAddress();
|
|
8348
|
+
const rowSession = typeof addr.session === 'string' && AUTO_UUID_RE.test(addr.session) ? addr.session.toLowerCase() : '';
|
|
8349
|
+
const liveRow = addr.source === 'registry' && addr.live && !!rowSession;
|
|
8350
|
+
const own = liveRow && (addr.matched_by === 'socket' || rowSession === mine);
|
|
8351
|
+
// 소켓은 하네스가 이 MCP 에게만 준 열쇠다 — 그 줄이 이 프로세스다. 떠 있는지 이번에 못 쟀어도(우리는 떠 있다) 지금 세션은 그 줄의 것이다.
|
|
8352
|
+
const cur = addr.matched_by === 'socket' && rowSession ? rowSession : mine;
|
|
8353
|
+
let name = '';
|
|
8354
|
+
let why = '';
|
|
8355
|
+
if (own) {
|
|
8356
|
+
const windows = Number(addr.live_rows) || 1;
|
|
8357
|
+
if (windows >= 2) {
|
|
8358
|
+
why = `이 세션 uuid 를 지금 창 ${windows}곳이 함께 띄우고 있다 — 서버 주소록은 uuid 하나에 이름 하나라 창마다 다른 이름이 번갈아 서고, `
|
|
8359
|
+
+ '그 uuid 앞 통은 한 편지함이라 어느 창이 받을지 정해지지 않는다(한 창에 닿으려면 SendMessage 에 그 창의 이름)';
|
|
8360
|
+
} else if (addr.name) {
|
|
8361
|
+
name = addr.name;
|
|
8362
|
+
mailNameSeen.session = cur;
|
|
8363
|
+
mailNameSeen.name = name;
|
|
8364
|
+
} else {
|
|
8365
|
+
why = '하네스 등록부에 이 프로세스의 산 줄은 있는데 이름이 비었거나 꼴 밖이다(제어문자만 걷고 65자까지)';
|
|
8366
|
+
}
|
|
8367
|
+
} else {
|
|
8368
|
+
const intro = introOf(mine);
|
|
8369
|
+
if (intro?.name && intro.at >= BOOT_MS) {
|
|
8370
|
+
name = intro.name;
|
|
8371
|
+
} else if (isCursorTranscript(intro?.file) || mine === cursorActiveComposer() || process.env.CURSOR_CONVERSATION_ID === mine) {
|
|
8372
|
+
name = cursorAliasOf(mine);
|
|
8373
|
+
} else if (addr.measured === false && mailNameSeen.session === cur && mailNameSeen.name) {
|
|
8374
|
+
name = mailNameSeen.name; // ④ — 이번엔 못 쟀을 뿐이다(조용히)
|
|
8375
|
+
} else if (liveRow) {
|
|
8376
|
+
why = `등록부에서 찾은 산 줄(세션 ${rowSession.slice(0, 8)})은 이 MCP 의 세션이 아니고 CLAUDE_PID 로만 찾았다 — 그 값은 하네스가 MCP 에 `
|
|
8377
|
+
+ '주지 않는 값(조상 프로세스의 것)이라 그 줄의 이름을 이 세션의 이름으로 대지 않는다';
|
|
8378
|
+
} else if (addr.measured === false) {
|
|
8379
|
+
why = `하네스 등록부에서 이 프로세스의 줄이 떠 있는지 이번에 못 쟀다(${process.platform === 'win32' ? '이 판은 pid 를 못 잰다' : '시작 시각을 못 맞췄다'})`;
|
|
8380
|
+
} else if (intro?.name) {
|
|
8381
|
+
why = `전사의 마지막 자기소개 «${cellClean(intro.name)}» 는 이 프로세스가 뜨기 전 것이라 지금 이름이라고 못 한다(다시 뜨면 이름이 바뀐다)`;
|
|
8382
|
+
} else {
|
|
8383
|
+
why = `하네스 등록부(${harnessSessionsDir()})에 이 프로세스의 산 줄이 없고 전사에도 자기소개가 없다`;
|
|
8384
|
+
}
|
|
8385
|
+
}
|
|
8386
|
+
if (name && !mailNameOk(name)) {
|
|
8387
|
+
why = `지금 이름 «${cellClean(name)}» 은 메일 주소록이 받는 꼴이 아니다(60자까지 · 공백·[ ] → < > 없음 · 16진 8자·uuid 꼴 아님)`;
|
|
8388
|
+
name = '';
|
|
7170
8389
|
}
|
|
7171
|
-
|
|
7172
|
-
|
|
8390
|
+
return { name, why, addr, cur };
|
|
8391
|
+
}
|
|
8392
|
+
|
|
8393
|
+
/**
|
|
8394
|
+
* 이 프로세스의 메일 주소 한 벌을 **한 번** 푼다(주소 계약 2 · 2026-10-07 검증 F2·SEC-7) — `{ pick, cur, old }`.
|
|
8395
|
+
* pick — `myAgentName(mine)` 의 답(이름·까닭·세포 답). cur — 지금 세션(위 cur — /clear 를 따라간다). 보내기(session)·주소 대기·받기의 주인이다.
|
|
8396
|
+
* old — /clear 앞의 env 세션(mine — cur 과 같으면 null). 이미 나간 답장 주소(from_session)·받는 세션 줄(to_session)이 그 uuid 라, 이름 없이
|
|
8397
|
+
* 주소를 유지하고 긷는다 — 단 **다른 창이 그 대화를 이어 열었으면**(등록부에 old 의 산 줄이 있다) 그 창의 MCP 가 주인이라 손을 뗀다
|
|
8398
|
+
* (두 프로세스가 한 편지함을 긷지 않게 — SEC-1). 하네스가 env 를 안 바꾸니 이 MCP 가 아는 것은 이 둘뿐이다.
|
|
8399
|
+
*/
|
|
8400
|
+
async function mailSelf(mine = mySessionId()) {
|
|
8401
|
+
const pick = await myAgentName(mine);
|
|
8402
|
+
const cur = AUTO_UUID_RE.test(String(pick.cur || '')) ? String(pick.cur).toLowerCase() : mine;
|
|
8403
|
+
const old = cur !== mine && AUTO_UUID_RE.test(String(mine || '')) && !mailOldHeldElsewhere(mine) ? mine : null;
|
|
8404
|
+
const v = { pick, cur, old };
|
|
8405
|
+
mailSelfMemo = { at: Date.now(), mine, v };
|
|
8406
|
+
return v;
|
|
8407
|
+
}
|
|
8408
|
+
/**
|
|
8409
|
+
* 받기 바퀴(mailPull)가 쓰는 주소 — 30초 안에 푼 것이 있으면 그대로 쓴다. 주소 대기(mailRegister)는 소속처마다 60초에 한 번이라 받기 바퀴마다
|
|
8410
|
+
* 세포를 새로 부를 까닭이 없다(세포의 --self 는 그 세션 전사의 끝을 거슬러 읽는다 — 자기소개가 없는 큰 전사는 끝까지 간다). 보내기(mail 도구)는
|
|
8411
|
+
* 늘 새로 푼다(mailSelf). /clear 를 알아채는 것은 길어야 30초 늦다.
|
|
8412
|
+
*/
|
|
8413
|
+
let mailSelfMemo = { at: 0, mine: '', v: null };
|
|
8414
|
+
async function mailSelfRecent(mine = mySessionId()) {
|
|
8415
|
+
if (mailSelfMemo.v && mailSelfMemo.mine === mine && Date.now() - mailSelfMemo.at < 30_000) return mailSelfMemo.v;
|
|
8416
|
+
return mailSelf(mine);
|
|
8417
|
+
}
|
|
8418
|
+
/** 그 세션 uuid 를 지금 **다른 프로세스**가 띄우고 있나 — 등록부의 산 줄(JS 사본으로 잰다 · 못 재면 「아니다」). */
|
|
8419
|
+
function mailOldHeldElsewhere(sid) {
|
|
8420
|
+
try {
|
|
8421
|
+
return !!harnessLiveRow(harnessRowsIn(harnessSessionsDir()), harnessProbe(), (r) => r.session === sid);
|
|
8422
|
+
} catch { return false; }
|
|
7173
8423
|
}
|
|
7174
8424
|
|
|
7175
8425
|
/** 이 세션이 메일을 다룰 조건을 갖췄나. 못 갖췄으면 `null` — 조용히 안 한다. */
|
|
@@ -7204,20 +8454,132 @@ function mailReady() {
|
|
|
7204
8454
|
/**
|
|
7205
8455
|
* 주소를 **스스로 댄다.** 관측 추정이 아니다(052 머리말의 세 실측).
|
|
7206
8456
|
*
|
|
7207
|
-
* 이름이 없어도 등록한다 — 받을 수는 있어야
|
|
7208
|
-
|
|
7209
|
-
|
|
8457
|
+
* 이름이 없어도 등록한다 — 받을 수는 있어야 하고, 이제 보낼 수도 있다(발신 표시는 서버가 uuid 앞 8자로 세운다).
|
|
8458
|
+
* 이름은 **대기 직전에** 푼다(`mailSelf` → `myAgentName` — 세포 먼저, 못 하면 JS 사본). 돌려주는 것은 서버 응답에 `sentName`(실제로 댄 이름,
|
|
8459
|
+
* 없으면 '')·`nameWhy`(이름 없이 댄 까닭)·`cur`(댄 세션)·`old`(/clear 앞 세션 — 이름 없이 함께 댄 것)를 얹은 것이다 — 보낸 결과의 안내가
|
|
8460
|
+
* 그 사실대로 말한다(`mailAddrNote`).
|
|
8461
|
+
* ★ 대는 세션은 **지금 세션(cur)** 이다 — /clear 뒤에는 env 의 세션 id(mine)가 아니다(F2: 세포·세션 시작 안내가 남에게 주는 uuid 가 cur 인데
|
|
8462
|
+
* 주소록에는 mine 만 있어 그 uuid 앞 통이 404 였다). /clear 앞 세션(old)은 **먼저** 이름 없이 댄다 — 그 줄에 이 프로세스의 이름이 남아
|
|
8463
|
+
* 있으면 cur 의 같은 이름이 「살아 있는 다른 세션이 쥐었다」(held)로 막힌다(같은 사람의 두 줄도 서버는 다른 세션으로 본다).
|
|
8464
|
+
* ⚠ 이름 때문에 거절되면(400) **이름 없이 한 번 더** 댄다 — 등록이 막히면 보내기도 막힌다(서버 이름 규칙이 바뀌어도 벙어리가 안 되게).
|
|
8465
|
+
* `ident` 는 부르는 쪽이 이미 푼 `mailSelf()` 답(한 바퀴에 세포를 한 번만 부르려고) — 없으면 여기서 푼다.
|
|
8466
|
+
*/
|
|
8467
|
+
async function mailRegister(tenant, mine, force = false, ident = null) {
|
|
7210
8468
|
if (!force && Date.now() - (mailAddrAt.get(tenant) || 0) < MAIL_POLL_MS) return null;
|
|
7211
8469
|
mailAddrAt.set(tenant, Date.now());
|
|
7212
|
-
const
|
|
7213
|
-
|
|
8470
|
+
const id = ident || await mailSelf(mine);
|
|
8471
|
+
const body = (session, name) => ({
|
|
7214
8472
|
tenant,
|
|
7215
|
-
session
|
|
7216
|
-
name
|
|
8473
|
+
session,
|
|
8474
|
+
name,
|
|
7217
8475
|
// 신원이 어디서 왔는지 그대로 말한다 — Cursor 대화에 claude-code 이름표를 달지 않는다.
|
|
7218
|
-
client: mine === cursorActiveComposer() ? 'cursor' : 'claude-code',
|
|
8476
|
+
client: mine === codexSessionId() ? 'codex' : (mine === cursorActiveComposer() ? 'cursor' : 'claude-code'),
|
|
7219
8477
|
cwd: relHome(process.cwd()),
|
|
7220
8478
|
});
|
|
8479
|
+
if (id.old) {
|
|
8480
|
+
try { await api('POST', '/api/mail/register', body(id.old, '')); } catch { /* 옛 대화의 주소가 막혀도 지금 세션은 댄다 */ }
|
|
8481
|
+
}
|
|
8482
|
+
let sent = id.pick.name;
|
|
8483
|
+
let why = id.pick.why;
|
|
8484
|
+
let r;
|
|
8485
|
+
try {
|
|
8486
|
+
r = await api('POST', '/api/mail/register', body(id.cur, sent));
|
|
8487
|
+
} catch (e) {
|
|
8488
|
+
if (!(sent && e?.status === 400)) throw e;
|
|
8489
|
+
why = `지금 이름 «${cellClean(sent)}» 을 서버가 받지 않았다(${cellClean(e.message).slice(0, 120)})`;
|
|
8490
|
+
sent = '';
|
|
8491
|
+
r = await api('POST', '/api/mail/register', body(id.cur, ''));
|
|
8492
|
+
}
|
|
8493
|
+
return { ...(r && typeof r === 'object' ? r : {}), sentName: sent, nameWhy: why, cur: id.cur, old: id.old };
|
|
8494
|
+
}
|
|
8495
|
+
|
|
8496
|
+
/**
|
|
8497
|
+
* 뜰 때 **아는 소속처에 주소를 한 번 댄다**(계약 2 · 2026-10-07). 전에는 genetics 도구를 불러야만 댔다(tools/call 의 mailPull →
|
|
8498
|
+
* mailRegister) — 다시 뜬 세션은 첫 호출 전까지 서버 주소록에 옛 이름으로 남아, 새 이름 앞 통은 404 였고 옛 이름 앞 통은 이 세션으로
|
|
8499
|
+
* 왔다(검증 2026-10-07 remaining_failures — 9592 · 4163). 아는 소속처 = `mailReady()` 의 것(뜰 때는 색인의 소속처뿐이다).
|
|
8500
|
+
* Native Codex 는 뺀다 — 자기 일정 표의 'mail' 행(codexMailPullOne)이 뜰 때 이미 이름 없이 댄다. 주소(이름)는 한 번만 푼다.
|
|
8501
|
+
*/
|
|
8502
|
+
async function mailRegisterBoot() {
|
|
8503
|
+
const r0 = mailReady();
|
|
8504
|
+
if (!r0) return 0;
|
|
8505
|
+
if (!process.env.CLAUDE_CODE_SESSION_ID && r0.mine === codexSessionId()) return 0;
|
|
8506
|
+
const ident = await mailSelf(r0.mine);
|
|
8507
|
+
let n = 0;
|
|
8508
|
+
for (const [tenant] of r0.tenants) {
|
|
8509
|
+
try { await mailRegister(tenant, r0.mine, true, ident); n += 1; }
|
|
8510
|
+
catch { /* 한 소속처가 막혀도 나머지는 댄다 — 도구를 부르면 다시 댄다 */ }
|
|
8511
|
+
}
|
|
8512
|
+
return n;
|
|
8513
|
+
}
|
|
8514
|
+
|
|
8515
|
+
// 서버 lib/mail.php 의 MAIL_NAME_STATES 와 같은 낱말 — 늘리면 세 구현을 같이 고친다(그쪽 머리말).
|
|
8516
|
+
const MAIL_NAME_STATES = ['set', 'kept', 'held', 'cleared'];
|
|
8517
|
+
/**
|
|
8518
|
+
* 주소 등록이 **실제로 어떻게 됐나**를 한 줄로(할 말이 없으면 '') — 서버 응답의 사실대로.
|
|
8519
|
+
* 새 서버: `name_state`('set'|'kept'|'held'|'cleared') · `name`(그 줄에 실제로 남은 이름 — 서버가 표에서 다시 읽은 값) · `prev_name`.
|
|
8520
|
+
* 옛 서버: `held_by` 만 — 그때 서버 줄에는 예전 이름이 남을 수 있어(api.php 의 `IF(VALUES(name) IS NULL, name, …)`) 단정하지 않는다.
|
|
8521
|
+
* ⚠ 전에는 held 면 「이 통은 «uuid 앞 8자» 이름으로 나간다」고 단정했는데, 옛 서버는 그 줄의 옛 이름을 지켜 실제 발신 표시는 옛 이름이었다
|
|
8522
|
+
* (검증 2026-10-07 — 8ec9b061:www/portal/api.php:4163·4183·4208 · 서버는 이제 mail_store_register 에서 비운다). 발신 표시의 정본은 보낸 결과의 미리보기 첫 줄이다 — 서버가 받는 쪽에 그릴 그대로다.
|
|
8523
|
+
*/
|
|
8524
|
+
function mailAddrNote(reg, mine) {
|
|
8525
|
+
if (!reg || typeof reg !== 'object') return '';
|
|
8526
|
+
// 남이 닿을 주소 — 이 프로세스의 지금 세션(cur · /clear 를 따라간다). 옛 등록 응답(cur 칸 없음)이면 부르는 쪽의 mine.
|
|
8527
|
+
const me = typeof reg.cur === 'string' && AUTO_UUID_RE.test(reg.cur) ? reg.cur : mine;
|
|
8528
|
+
const old = typeof reg.old === 'string' && AUTO_UUID_RE.test(reg.old) ? reg.old : '';
|
|
8529
|
+
// /clear 로 대화가 바뀐 창 — 그 사실과 두 uuid 를 한 번 말한다(F2: 옛 uuid 로 부친 답장도 다른 창이 그 대화를 이어 열기 전까지 이 창이 긷는다).
|
|
8530
|
+
const moved = old ? `(이 창은 /clear 로 대화가 바뀌었다 — 이제 이 세션의 주소는 uuid ${me} 이다. 옛 uuid ${old} 앞 통도 다른 창이 그 대화를 `
|
|
8531
|
+
+ '이어 열기 전까지는 이 창이 긷는다.)\n' : '';
|
|
8532
|
+
const sent = typeof reg.sentName === 'string' ? reg.sentName : '';
|
|
8533
|
+
const why = typeof reg.nameWhy === 'string' ? reg.nameWhy : '';
|
|
8534
|
+
const state = MAIL_NAME_STATES.includes(reg.name_state) ? reg.name_state : '';
|
|
8535
|
+
const stored = state ? (typeof reg.name === 'string' ? cellClean(reg.name) : '') : null; // null = 모른다(옛 서버)
|
|
8536
|
+
const prev = state && typeof reg.prev_name === 'string' && reg.prev_name ? cellClean(reg.prev_name) : '';
|
|
8537
|
+
const held = state ? state === 'held' : !!reg.held_by;
|
|
8538
|
+
const holder = reg.held_by ? cellClean(String(reg.held_by)).slice(0, 8) : '';
|
|
8539
|
+
const book = (stored === null ? '서버 주소록에는 이 세션의 예전 이름이 남았을 수 있다(이 서버는 남은 이름을 안 알려 준다)'
|
|
8540
|
+
: (stored ? `서버 주소록에 남은 이 세션의 이름은 «${stored}» 이다` : '서버 주소록에는 이름 없이 섰다'))
|
|
8541
|
+
+ (prev ? ` · 옛 이름 «${prev}» 은 이제 이 세션으로 안 풀린다` : '');
|
|
8542
|
+
const show = '발신 표시는 아래 미리보기 첫 줄 그대로다';
|
|
8543
|
+
if (held) {
|
|
8544
|
+
return moved + `(이 세션이 댄 이름 «${cellClean(sent)}» 은 살아 있는 다른 세션${holder ? ` ${holder}` : ''} 이 쥐고 있어 못 댔다 — ${book}. `
|
|
8545
|
+
+ `${show}. 남이 이 세션에 닿으려면 uuid ${me}.)\n`;
|
|
8546
|
+
}
|
|
8547
|
+
if (!sent) {
|
|
8548
|
+
return moved + `(이 세션은 말 걸 수 있는 이름 없이 주소를 댔다${why ? ` — ${cellClean(why)}` : ''}. ${book}. ${show}. `
|
|
8549
|
+
+ `남이 이 세션에 닿으려면 uuid ${me}.)\n`;
|
|
8550
|
+
}
|
|
8551
|
+
if (stored !== null && stored !== sent) {
|
|
8552
|
+
return moved + `(이 세션이 댄 이름은 «${cellClean(sent)}» 인데 ${book}(name_state ${state}). ${show}.)\n`;
|
|
8553
|
+
}
|
|
8554
|
+
if (prev) return moved + `(이 세션의 이름은 이제 «${cellClean(sent)}» 이다 — 옛 이름 «${prev}» 은 이 세션으로 안 풀린다.)\n`;
|
|
8555
|
+
return moved;
|
|
8556
|
+
}
|
|
8557
|
+
|
|
8558
|
+
/** 보낸 결과의 **실제 받는 세션 uuid**(서버의 `to_session` — 같은 소속처 안의 값만 온다). 없거나 꼴이 아니면 ''(옛 서버). */
|
|
8559
|
+
function mailToSession(r) {
|
|
8560
|
+
const s = typeof r?.to_session === 'string' ? r.to_session.trim() : '';
|
|
8561
|
+
return AUTO_UUID_RE.test(s) ? s.toLowerCase() : '';
|
|
8562
|
+
}
|
|
8563
|
+
|
|
8564
|
+
/**
|
|
8565
|
+
* 받은 통의 **답장 주소**(보낸 세션 uuid) — 서버가 GET /api/mail 항목에 `from_session` 을 실어 주면 한 줄씩 덧붙인다(계약 3).
|
|
8566
|
+
* 발신 표시(uuid 앞 8자·그때의 이름)는 mail 의 to 로 안 풀리거나 남에게 간다 — 36자 uuid 만 그 세션으로 확실히 풀린다(mail_store_recipient — 8ec9b061:www/portal/lib/mail_store.php:171-172).
|
|
8567
|
+
* ⚠ 서버가 쓴 글자(`says`)는 손대지 않고 **뒤에** 붙인다 — 발신 표시를 짓는 자리는 서버의 mail_render() 하나뿐이다(아래 mailPullOne 머리말).
|
|
8568
|
+
* 이미 says 에 그 uuid 가 있으면 안 붙인다. 옛 서버(칸 없음)면 ''.
|
|
8569
|
+
*/
|
|
8570
|
+
function mailReplyHints(rows, says = '') {
|
|
8571
|
+
const seen = new Set();
|
|
8572
|
+
const low = String(says || '').toLowerCase();
|
|
8573
|
+
const out = [];
|
|
8574
|
+
for (const m of Array.isArray(rows) ? rows : []) {
|
|
8575
|
+
const sid = typeof m?.from_session === 'string' ? m.from_session.trim().toLowerCase() : '';
|
|
8576
|
+
if (!AUTO_UUID_RE.test(sid) || seen.has(sid) || low.includes(sid)) continue;
|
|
8577
|
+
seen.add(sid);
|
|
8578
|
+
const who = cellClean(m.from_name || '').slice(0, 60) || sid.slice(0, 8);
|
|
8579
|
+
out.push(` ↩ «${who}» 에게 답할 주소(보낸 세션 uuid — 이름은 다시 뜨면 바뀐다): ${sid}`);
|
|
8580
|
+
if (out.length >= 5) break;
|
|
8581
|
+
}
|
|
8582
|
+
return out.length ? `\n${out.join('\n')}` : '';
|
|
7221
8583
|
}
|
|
7222
8584
|
|
|
7223
8585
|
/**
|
|
@@ -7234,8 +8596,10 @@ async function mailPull() {
|
|
|
7234
8596
|
if (Date.now() - mailAt < MAIL_POLL_MS) return;
|
|
7235
8597
|
mailAt = Date.now();
|
|
7236
8598
|
|
|
7237
|
-
|
|
7238
|
-
|
|
8599
|
+
// 주소(지금 세션 cur · /clear 앞 세션 old · 댈 이름)는 한 바퀴에 한 번 푼다 — 세포를 소속처마다 부르지 않게.
|
|
8600
|
+
const ident = await mailSelfRecent(mine).catch(() => null);
|
|
8601
|
+
for (const [tenant, tenantId] of tenants) {
|
|
8602
|
+
try { await mailPullOne(tenant, mine, tenant === indexTenant ? indexEntry : null, tenantId, ident); }
|
|
7239
8603
|
catch { /* 한 소속처가 막혀도 나머지는 돈다 — 다음 바퀴에 다시 */ }
|
|
7240
8604
|
}
|
|
7241
8605
|
|
|
@@ -7262,46 +8626,234 @@ async function mailPull() {
|
|
|
7262
8626
|
}
|
|
7263
8627
|
}
|
|
7264
8628
|
|
|
7265
|
-
|
|
7266
|
-
|
|
7267
|
-
|
|
7268
|
-
|
|
7269
|
-
|
|
7270
|
-
|
|
7271
|
-
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
|
|
7275
|
-
|
|
7276
|
-
|
|
7277
|
-
|
|
7278
|
-
|
|
7279
|
-
|
|
7280
|
-
|
|
7281
|
-
|
|
7282
|
-
const
|
|
7283
|
-
|
|
7284
|
-
|
|
7285
|
-
|
|
7286
|
-
|
|
7287
|
-
|
|
7288
|
-
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
|
|
7292
|
-
|
|
7293
|
-
|
|
7294
|
-
|
|
8629
|
+
/* Native Codex shares this exact journal/lease with cell/src/codex_mail.rs.
|
|
8630
|
+
* No live writer is stolen by age. Interrupted reaper/malformed owner fails closed.
|
|
8631
|
+
* Claude/Cursor retain their existing mailbox contract below. */
|
|
8632
|
+
const CODEX_MAIL_STATE_MAX = 524288;
|
|
8633
|
+
const CODEX_MAIL_SAYS_MAX = 65536;
|
|
8634
|
+
function codexMailPrivate(file, directory = false) {
|
|
8635
|
+
try {
|
|
8636
|
+
const st = fs.lstatSync(file);
|
|
8637
|
+
return !st.isSymbolicLink() && (directory ? st.isDirectory() : st.isFile())
|
|
8638
|
+
&& (st.mode & 0o077) === 0 && (!process.getuid || st.uid === process.getuid());
|
|
8639
|
+
} catch { return false; }
|
|
8640
|
+
}
|
|
8641
|
+
function codexMailExists(file) {
|
|
8642
|
+
try { fs.lstatSync(file); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; }
|
|
8643
|
+
}
|
|
8644
|
+
function codexMailRead(file, max = CODEX_MAIL_STATE_MAX) {
|
|
8645
|
+
if (!codexMailPrivate(file)) throw new Error('mail_state');
|
|
8646
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
|
|
8647
|
+
try {
|
|
8648
|
+
const st = fs.fstatSync(fd);
|
|
8649
|
+
if (!st.isFile() || (st.mode & 0o077) || (process.getuid && st.uid !== process.getuid()) || st.size > max) throw new Error('mail_state_bound');
|
|
8650
|
+
const raw = Buffer.alloc(max + 1); let offset = 0;
|
|
8651
|
+
for (let chunks = 0; chunks <= max + 1; chunks++) {
|
|
8652
|
+
const n = fs.readSync(fd, raw, offset, raw.length - offset, null);
|
|
8653
|
+
if (!n) return new TextDecoder('utf-8', {fatal:true}).decode(raw.subarray(0,offset));
|
|
8654
|
+
offset += n;
|
|
8655
|
+
if (offset > max) throw new Error('mail_state_bound');
|
|
8656
|
+
}
|
|
8657
|
+
throw new Error('mail_state_bound');
|
|
8658
|
+
} finally { fs.closeSync(fd); }
|
|
8659
|
+
}
|
|
8660
|
+
function codexMailKeys(v, keys) {
|
|
8661
|
+
return v && typeof v === 'object' && !Array.isArray(v)
|
|
8662
|
+
&& Object.keys(v).length === keys.length && keys.every(k => Object.hasOwn(v, k));
|
|
8663
|
+
}
|
|
8664
|
+
function codexMailPending(v) {
|
|
8665
|
+
return v === null || (codexMailKeys(v, ['ids','says']) && Array.isArray(v.ids)
|
|
8666
|
+
&& v.ids.length > 0 && v.ids.length <= 20 && new Set(v.ids).size === v.ids.length
|
|
8667
|
+
&& v.ids.every(n => Number.isSafeInteger(n) && n > 0)
|
|
8668
|
+
&& typeof v.says === 'string' && v.says.length > 0 && Buffer.byteLength(v.says) <= CODEX_MAIL_SAYS_MAX);
|
|
8669
|
+
}
|
|
8670
|
+
function codexMailAtomic(file, text) {
|
|
8671
|
+
const raw = Buffer.from(text), temp = `${file}.tmp-${process.pid}-${crypto.randomBytes(16).toString('hex')}`;
|
|
8672
|
+
if (raw.length > CODEX_MAIL_STATE_MAX) throw new Error('mail_state_bound');
|
|
8673
|
+
let fd = null;
|
|
8674
|
+
try {
|
|
8675
|
+
fd = fs.openSync(temp,'wx',0o600); fs.fchmodSync(fd,0o600);
|
|
8676
|
+
let offset = 0;
|
|
8677
|
+
for (let writes = 0; offset < raw.length; writes++) {
|
|
8678
|
+
if (writes >= raw.length) throw new Error('mail_write_bound');
|
|
8679
|
+
const n = fs.writeSync(fd,raw,offset,raw.length-offset,null);
|
|
8680
|
+
if (!Number.isInteger(n) || n < 1 || n > raw.length-offset) throw new Error('mail_write');
|
|
8681
|
+
offset += n;
|
|
8682
|
+
}
|
|
8683
|
+
fs.fsyncSync(fd); fs.closeSync(fd); fd = null; fs.renameSync(temp,file);
|
|
8684
|
+
} finally {
|
|
8685
|
+
if (fd !== null) fs.closeSync(fd);
|
|
8686
|
+
try { fs.unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
8687
|
+
}
|
|
8688
|
+
}
|
|
8689
|
+
function codexMailStore(file, value) {
|
|
8690
|
+
const unlock = codexMailLease(path.join(path.dirname(file), '.inventory'));
|
|
8691
|
+
if (!unlock) throw new Error('mail_inventory_busy');
|
|
8692
|
+
try {
|
|
8693
|
+
const raw = JSON.stringify(value);
|
|
8694
|
+
codexMailInventory(path.dirname(file), Buffer.byteLength(raw), file);
|
|
8695
|
+
if (Buffer.byteLength(raw) > CODEX_MAIL_STATE_MAX || (codexMailExists(file) && !codexMailPrivate(file))) throw new Error('mail_state');
|
|
8696
|
+
codexMailAtomic(file, raw);
|
|
8697
|
+
const fd = fs.openSync(path.dirname(file), 'r');
|
|
8698
|
+
try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
8699
|
+
} finally { unlock(); }
|
|
8700
|
+
}
|
|
8701
|
+
function codexMailInventory(dir, incoming, target) {
|
|
8702
|
+
const scan = fs.opendirSync(dir); let entries = 0, journals = 0, bytes = 0;
|
|
8703
|
+
try {
|
|
8704
|
+
for (;;) {
|
|
8705
|
+
const next = scan.readSync(); if (!next) break;
|
|
8706
|
+
if (++entries >= 510) throw new Error('mail_inventory_bound');
|
|
8707
|
+
const file = path.join(dir,next.name);
|
|
8708
|
+
if (!codexMailPrivate(file)) throw new Error('mail_inventory');
|
|
8709
|
+
bytes += fs.lstatSync(file).size;
|
|
8710
|
+
if (bytes > 16777216) throw new Error('mail_inventory_bound');
|
|
8711
|
+
if (next.name.startsWith('v1-') && next.name.endsWith('.json')) journals++;
|
|
7295
8712
|
}
|
|
8713
|
+
if (journals > 128 || (!codexMailExists(target) && journals >= 128) || bytes + incoming > 16777216) throw new Error('mail_inventory_bound');
|
|
8714
|
+
} finally { scan.closeSync(); }
|
|
8715
|
+
}
|
|
8716
|
+
function codexMailLease(file) {
|
|
8717
|
+
const lock = file + '.lock', reap = lock + '.reap';
|
|
8718
|
+
const token = JSON.stringify({version:1,pid:process.pid,host:os.hostname(),nonce:crypto.randomBytes(16).toString('hex')});
|
|
8719
|
+
const create = name => { const fd = fs.openSync(name, 'wx', 0o600); try { fs.fchmodSync(fd, 0o600); fs.writeFileSync(fd, token); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } };
|
|
8720
|
+
try { create(lock); } catch (e) {
|
|
8721
|
+
if (e.code !== 'EEXIST') return null;
|
|
8722
|
+
try { create(reap); } catch { return null; }
|
|
8723
|
+
try {
|
|
8724
|
+
const owner = JSON.parse(codexMailRead(lock, 1024));
|
|
8725
|
+
if (!codexMailKeys(owner, ['version','pid','host','nonce']) || owner.version !== 1
|
|
8726
|
+
|| owner.host !== os.hostname() || typeof owner.nonce !== 'string' || !owner.nonce || Buffer.byteLength(owner.nonce) > 128 || !Number.isSafeInteger(owner.pid) || owner.pid < 1 || owner.pid > 2147483647) return null;
|
|
8727
|
+
try { process.kill(owner.pid, 0); return null; } catch (dead) { if (dead.code !== 'ESRCH') return null; }
|
|
8728
|
+
fs.unlinkSync(lock); create(lock);
|
|
8729
|
+
} catch { return null; } finally { try { fs.unlinkSync(reap); } catch {} }
|
|
8730
|
+
}
|
|
8731
|
+
return () => { try { if (codexMailRead(lock, 1024) === token) fs.unlinkSync(lock); } catch {} };
|
|
8732
|
+
}
|
|
8733
|
+
async function codexMailApi(method, pathname, body, cred, deadline) {
|
|
8734
|
+
const remaining = Math.ceil(deadline - performance.now());
|
|
8735
|
+
if (remaining <= 0 || !hostAllowed(BASE)) throw new Error('mail_deadline');
|
|
8736
|
+
const response = await fetch(BASE.replace(/\/+$/, '') + pathname, {method, headers:{Authorization:`Bearer ${cred.token}`,'Content-Type':'application/json'},
|
|
8737
|
+
body:body === undefined ? undefined : JSON.stringify(body), signal:AbortSignal.timeout(remaining)});
|
|
8738
|
+
const reader = response.body?.getReader();
|
|
8739
|
+
if (!reader) throw new Error('mail_response');
|
|
8740
|
+
const raw = Buffer.alloc(262144); let bytes = 0, chunks = 0;
|
|
8741
|
+
try {
|
|
8742
|
+
for (;;) {
|
|
8743
|
+
if (++chunks > 8192 || performance.now() >= deadline) throw new Error('mail_response_bound');
|
|
8744
|
+
const {value,done} = await reader.read(); if (done) break;
|
|
8745
|
+
if (bytes + value.byteLength > raw.length) throw new Error('mail_response_bound');
|
|
8746
|
+
raw.set(value,bytes); bytes += value.byteLength;
|
|
8747
|
+
}
|
|
8748
|
+
} finally { await reader.cancel().catch(() => {}); }
|
|
8749
|
+
if (performance.now() > deadline) throw new Error('mail_deadline');
|
|
8750
|
+
const result = JSON.parse(new TextDecoder('utf-8', {fatal:true}).decode(raw.subarray(0,bytes)));
|
|
8751
|
+
if (!response.ok || result?.ok !== true) throw new Error('mail_refused');
|
|
8752
|
+
return result;
|
|
8753
|
+
}
|
|
8754
|
+
async function codexMailPullOne(tenant, mine, tenantId) {
|
|
8755
|
+
if (!Number.isSafeInteger(tenantId) || tenantId < 1 || tenantId > 4294967295) return;
|
|
8756
|
+
const cred = credential(); if (!cred || cred.kind !== 'user') return;
|
|
8757
|
+
const dir = path.join(path.dirname(CRED_FILE), 'mail-receive');
|
|
8758
|
+
try { fs.mkdirSync(dir, {mode:0o700}); } catch (e) { if (e.code !== 'EEXIST') throw e; }
|
|
8759
|
+
if (!codexMailPrivate(dir,true)) throw new Error('mail_directory');
|
|
8760
|
+
const tokenHash = crypto.createHash('sha256').update(cred.token).digest('hex');
|
|
8761
|
+
const key = crypto.createHash('sha256').update(`${BASE.replace(/\/+$/, '')}\0${tenantId}\0${mine}\0${tokenHash}`).digest('hex');
|
|
8762
|
+
const file = path.join(dir, `v1-${key}.json`);
|
|
8763
|
+
const admission = codexMailLease(path.join(dir, '.inventory')); if (!admission) return;
|
|
8764
|
+
let unlock;
|
|
8765
|
+
try { codexMailInventory(dir, 0, file); unlock = codexMailLease(file); } finally { admission(); }
|
|
8766
|
+
if (!unlock) return;
|
|
8767
|
+
try {
|
|
8768
|
+
let state = {version:1,last_checked_ms:0,pending:null};
|
|
8769
|
+
if (codexMailExists(file)) state = JSON.parse(codexMailRead(file));
|
|
8770
|
+
if (!codexMailKeys(state,['version','last_checked_ms','pending']) || state.version !== 1
|
|
8771
|
+
|| !Number.isSafeInteger(state.last_checked_ms) || state.last_checked_ms < 0 || state.last_checked_ms > Date.now()
|
|
8772
|
+
|| !codexMailPending(state.pending)) throw new Error('mail_state');
|
|
8773
|
+
if (!state.pending && Date.now() - state.last_checked_ms < 8000) return;
|
|
8774
|
+
const deadline = performance.now() + 3000;
|
|
8775
|
+
// Successful server registration is required; no caller-provided sender label.
|
|
8776
|
+
const registered = await codexMailApi('POST','/api/mail/register',{tenant,session:mine,name:'',client:'codex',cwd:relHome(process.cwd())},cred,deadline);
|
|
8777
|
+
if (registered.tenant_id !== tenantId || registered.session !== mine) throw new Error('mail_registration_changed');
|
|
8778
|
+
if (!state.pending) {
|
|
8779
|
+
const r = await codexMailApi('GET','/api/mail?tenant='+encodeURIComponent(tenant)+'&session='+encodeURIComponent(mine)+'&limit=20',undefined,cred,deadline);
|
|
8780
|
+
if (!Array.isArray(r.mail)) throw new Error('mail_response');
|
|
8781
|
+
// Reply address (sender session UUID) comes from the server's from_session field and is appended after its wording.
|
|
8782
|
+
const told = typeof r.says === 'string' && r.says ? r.says + mailReplyHints(r.mail, r.says) : r.says;
|
|
8783
|
+
const batch = r.mail.length ? {ids:r.mail.map(m => m.id),says:told} : null;
|
|
8784
|
+
if (!codexMailPending(batch)) throw new Error('mail_response');
|
|
8785
|
+
mailCollectReturned(r);
|
|
8786
|
+
state.last_checked_ms = Date.now(); state.pending = batch; codexMailStore(file,state);
|
|
8787
|
+
}
|
|
8788
|
+
if (!state.pending) return;
|
|
8789
|
+
const ack = await codexMailApi('POST','/api/mail/ack',{tenant,ids:state.pending.ids},cred,deadline);
|
|
8790
|
+
if (ack.asked !== state.pending.ids.length || !Number.isSafeInteger(ack.stamped) || ack.stamped < 0 || ack.stamped > ack.asked) throw new Error('mail_ack');
|
|
8791
|
+
const says = state.pending.says;
|
|
8792
|
+
state.pending = null; codexMailStore(file,state);
|
|
8793
|
+
mailQueue.push(says); // Never fabricate provenance; preserve server-rendered wording.
|
|
8794
|
+
} finally { unlock(); }
|
|
8795
|
+
}
|
|
8796
|
+
|
|
8797
|
+
/**
|
|
8798
|
+
* 한 소속처의 내 편지함을 긷고 **받았다고 도장을 찍는다**(도장은 큐에 넣기 **전에** — 재점화 방지).
|
|
8799
|
+
* ★ 긷는 편지함은 **지금 세션(cur)** 과, /clear 뒤면 그 앞 세션(old — 다른 창이 그 대화를 이어 열지 않았을 때만)이다(F2·SEC-7 — `mailSelf`).
|
|
8800
|
+
* 커서는 (소속처, 세션)마다 따로 — 한 커서로 두 세션을 긷으면 앞 세션이 커서를 밀어 뒤 세션의 통을 건너뛴다(그 통은 open 으로 남다 만료된다).
|
|
8801
|
+
* 색인의 mail_cursor 는 env 세션(mine)의 것이라 그 세션을 긷을 때만 적는다.
|
|
8802
|
+
*/
|
|
8803
|
+
async function mailPullOne(tenant, mine, indexEntry, tenantId = 0, ident = null) {
|
|
8804
|
+
if (process.platform !== 'win32' && !process.env.CLAUDE_CODE_SESSION_ID && mine === codexSessionId()) return await codexMailPullOne(tenant, mine, tenantId || Number(indexEntry?.tenant_id));
|
|
8805
|
+
const id = ident || await mailSelf(mine);
|
|
8806
|
+
try { await mailRegister(tenant, mine, false, id); } catch { /* 등록 실패가 받기를 막지 않는다 */ }
|
|
8807
|
+
|
|
8808
|
+
for (const sess of [id.cur, id.old]) {
|
|
8809
|
+
if (!sess) continue;
|
|
8810
|
+
const key = sess === mine ? tenant : `${tenant}\0${sess}`;
|
|
8811
|
+
if (!mailCursors.has(key)) mailCursors.set(key, sess === mine ? Number(indexEntry?.mail_cursor || 0) : 0);
|
|
8812
|
+
let cursor = mailCursors.get(key);
|
|
7296
8813
|
/*
|
|
7297
|
-
*
|
|
7298
|
-
*
|
|
7299
|
-
*
|
|
7300
|
-
* 이 레일이 명령형을 허용하는 근거가 바로 그 보증이다.
|
|
8814
|
+
* ⚠ **자리(`cwd`)를 안 보낸다.** 창구가 그것으로 거르던 동안 주소 있는 메일이 조용히
|
|
8815
|
+
* 사라졌다(실측 2026-08-25: cwd 없이 1통 · cwd 있게 0통). 메일은 폴더가 아니라
|
|
8816
|
+
* 세션에 부친 것이라 **주소가 이미 필터**다.
|
|
7301
8817
|
*/
|
|
7302
|
-
|
|
8818
|
+
const q = '?tenant=' + encodeURIComponent(tenant)
|
|
8819
|
+
+ '&since=' + cursor
|
|
8820
|
+
+ '&session=' + encodeURIComponent(sess)
|
|
8821
|
+
+ '&limit=' + MAIL_PAGE;
|
|
8822
|
+
const r = await api('GET', '/api/mail' + q);
|
|
8823
|
+
|
|
8824
|
+
const rows = Array.isArray(r?.mail) ? r.mail : [];
|
|
8825
|
+
if (typeof r?.cursor === 'number' && r.cursor > cursor) { cursor = r.cursor; mailCursors.set(key, cursor); }
|
|
8826
|
+
// 색인에는 색인의 소속처 · env 세션의 것만 적는다(다른 곳의 커서는 프로세스 안에서만 — 잃어도 ack 가 중복을 막는다).
|
|
8827
|
+
// 빈 폴링은 수신이 아니다 — 위 백신 커서와 같은 규율.
|
|
8828
|
+
if (sess === mine && indexEntry?.tenant_id) {
|
|
8829
|
+
indexPatch(mine, { mail_cursor: cursor }, rows.length ? 'rx' : ''); // 소속 칸은 안 건드린다(§5.1 P1-a)
|
|
8830
|
+
}
|
|
8831
|
+
|
|
8832
|
+
if (rows.length) {
|
|
8833
|
+
const ids = rows.map((m) => Number(m.id)).filter((n) => n > 0);
|
|
8834
|
+
if (ids.length) {
|
|
8835
|
+
try { await api('POST', '/api/mail/ack', { tenant, ids }); }
|
|
8836
|
+
catch { /* 도장을 못 찍어도 말은 한다 — 못 찍은 것은 다음 바퀴에 또 온다 */ }
|
|
8837
|
+
}
|
|
8838
|
+
/*
|
|
8839
|
+
* ★ **서버가 쓴 글자를 그대로 싣는다.** 발신자 표시(`[누구 → 너]`)를 붙이는 자리는
|
|
8840
|
+
* 서버의 `mail_render()` 하나뿐이고, 여기서 다시 지으면 두 자가 갈린다.
|
|
8841
|
+
* 그리고 갈리는 순간 「누가 말하는지」가 보증이 아니라 장식이 된다 —
|
|
8842
|
+
* 이 레일이 명령형을 허용하는 근거가 바로 그 보증이다.
|
|
8843
|
+
* 답장 주소(보낸 세션 uuid)는 서버가 준 칸(`from_session`)으로 그 **뒤에** 붙인다 — 표시 줄은 안 건드린다(`mailReplyHints`).
|
|
8844
|
+
* /clear 앞 대화 앞으로 온 통이면 그 사실을 한 줄 앞에 둔다 — 받는 쪽(이 창의 새 대화)은 그 실의 맥락이 없다.
|
|
8845
|
+
*/
|
|
8846
|
+
if (typeof r?.says === 'string' && r.says) {
|
|
8847
|
+
const lead = sess === id.cur ? '' : `(아래는 /clear 앞 대화(uuid ${sess}) 앞으로 온 통이다 — 이 창의 지금 대화는 그 실의 맥락이 없다.)\n`;
|
|
8848
|
+
mailQueue.push(lead + r.says + mailReplyHints(rows, r.says));
|
|
8849
|
+
}
|
|
8850
|
+
}
|
|
8851
|
+
|
|
8852
|
+
mailCollectReturned(r);
|
|
7303
8853
|
}
|
|
8854
|
+
}
|
|
7304
8855
|
|
|
8856
|
+
function mailCollectReturned(r) {
|
|
7305
8857
|
for (const b of (Array.isArray(r?.returned) ? r.returned : [])) {
|
|
7306
8858
|
const id = Number(b?.id) || 0;
|
|
7307
8859
|
if (!id || mailBackSeen.has(id)) continue; // 서버가 1시간 동안 되풀이해 준다 — 접는 것은 우리 몫
|
|
@@ -7480,6 +9032,13 @@ function requireArgs(name, args) {
|
|
|
7480
9032
|
+ `받는 이름은 이것뿐입니다 — ${all}`);
|
|
7481
9033
|
}
|
|
7482
9034
|
|
|
9035
|
+
/** 창구가 돌려준 항목 짚개 한 줄 — 번호가 있으면(하위 업무 번호 2단계) 「W-04 · decisions/…」, 없으면 예전처럼 경로 하나(옛 서버 · 0단계). */
|
|
9036
|
+
function worksItemRef(item) {
|
|
9037
|
+
const key = String(item?.key ?? '');
|
|
9038
|
+
const code = typeof item?.code === 'string' ? item.code : '';
|
|
9039
|
+
return code ? `${code} · ${key}` : key;
|
|
9040
|
+
}
|
|
9041
|
+
|
|
7483
9042
|
/**
|
|
7484
9043
|
* ── `works(tenant, work)` — 그 일의 항목 한 줄씩(GET /api/work/items, 2026-09-27) ─────────────
|
|
7485
9044
|
*
|
|
@@ -7487,16 +9046,26 @@ function requireArgs(name, args) {
|
|
|
7487
9046
|
* 결정 장을 한 장씩 열었다(같은 날 hnl 세션: 배정안 + 항목 장 일곱). 서버가 간트와 같은 자로 낸 줄을
|
|
7488
9047
|
* 그대로 옮긴다 — 번호(code)는 제목 머리의 [ … ] 이고, 없으면 제목만 쓴다(지어내지 않는다).
|
|
7489
9048
|
* ⚠ **서버가 준 칸만 쓴다** — 계획·상태 칸이 없거나 비면 그 토막을 안 그린다(옛 서버·빈 관측).
|
|
9049
|
+
* ★ 하위 업무 번호(DESIGN-item-codes.md · 설계 4-1 MCP works 줄): 번호를 쓰는 소속처(2단계)의 서버는 줄마다 role · parent 를 더 준다 —
|
|
9050
|
+
* (짚개 한 줄은 worksItemRef — 창구가 번호를 돌려주면 「W-04 · decisions/…」, 아니면 예전처럼 경로 하나.)
|
|
9051
|
+
* 머리 장(role head — `WRM-00`)은 하위 업무 줄 · 수에서 빼 일 머리 줄에 싣고, 자식(parent 가 이 목록에 있는 줄)은 부모 밑에 들여 쓴다.
|
|
9052
|
+
* 번호 글자를 쪼개 부모를 읽지 않는다(서버 parent 칸만). 그 칸이 없는 서버(0단계 · 옛 서버)의 출력은 예전과 한 글자도 같다.
|
|
7490
9053
|
*/
|
|
7491
9054
|
function worksItemsList(r) {
|
|
7492
9055
|
const works = Array.isArray(r?.works) ? r.works : [];
|
|
7493
9056
|
if (!works.length) return `${r?.tenant || ''} — 열린 일이 없다. 0건은 0건이다.`;
|
|
7494
9057
|
const md = (d) => String(d || '').slice(5); // 2026-09-14 → 09-14
|
|
9058
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
7495
9059
|
const out = [];
|
|
9060
|
+
let coded = false;
|
|
7496
9061
|
for (const w of works) {
|
|
7497
|
-
const
|
|
7498
|
-
|
|
7499
|
-
|
|
9062
|
+
const all = Array.isArray(w?.items) ? w.items : [];
|
|
9063
|
+
if (all.some((it) => it && typeof it.role === 'string')) coded = true;
|
|
9064
|
+
const headRow = all.find((it) => it && it.role === 'head') || null;
|
|
9065
|
+
const items = headRow ? all.filter((it) => it !== headRow) : all;
|
|
9066
|
+
out.push(`«${w.name || w.slug}» (${w.slug}) — 항목 ${items.length}개`
|
|
9067
|
+
+ (headRow ? ` · 머리 ${str(headRow.code)} — ${headRow.key}` : ''));
|
|
9068
|
+
const line = (it, depth) => {
|
|
7500
9069
|
const code = typeof it.code === 'string' && it.code !== '' ? it.code : '';
|
|
7501
9070
|
const title = code ? String(it.title || '').replace(/^\[[^\]]*\]\s*/, '') : String(it.title || '');
|
|
7502
9071
|
// 계획 끝 말(plan_word)·고유 번호(uid)도 서버가 준 것만 — 할 일 화면과 같은 자(work_task_due_word) · 할 일 표의 uid.
|
|
@@ -7504,11 +9073,26 @@ function worksItemsList(r) {
|
|
|
7504
9073
|
const plan = it.plan && it.plan.from ? ` · 계획 ${md(it.plan.from)}~${md(it.plan.to)}${due}` : '';
|
|
7505
9074
|
const state = it.state_word ? ` · ${it.state_word}` : '';
|
|
7506
9075
|
const uid = typeof it.uid === 'string' && it.uid !== '' ? ` · uid ${it.uid}` : '';
|
|
7507
|
-
|
|
9076
|
+
return `${' '.repeat(depth + 1)}· ${code ? code + ' ' : ''}${title}${plan}${state} — ${it.key}${uid}`;
|
|
9077
|
+
};
|
|
9078
|
+
// 자식 — 서버 parent 가 이 목록의 번호일 때만 들여 쓴다(부모가 목록에 없으면 제자리 한 줄 — 지어내지 않는다).
|
|
9079
|
+
const codes = new Set(items.map((it) => str(it.code)).filter(Boolean));
|
|
9080
|
+
const parentOf = (it) => { const p = str(it.parent); return p !== '' && p !== str(it.code) && codes.has(p) ? p : ''; };
|
|
9081
|
+
const kids = new Map();
|
|
9082
|
+
for (const it of items) {
|
|
9083
|
+
const p = parentOf(it);
|
|
9084
|
+
if (p) { if (!kids.has(p)) kids.set(p, []); kids.get(p).push(it); }
|
|
9085
|
+
}
|
|
9086
|
+
for (const it of items) {
|
|
9087
|
+
if (parentOf(it)) continue;
|
|
9088
|
+
out.push(line(it, 0));
|
|
9089
|
+
for (const k of kids.get(str(it.code)) || []) out.push(line(k, 1));
|
|
7508
9090
|
}
|
|
7509
9091
|
if (Number(w.dropped) > 0) out.push(` (지시형 제목이라 뺀 결정 ${Number(w.dropped)}건)`);
|
|
7510
9092
|
}
|
|
7511
|
-
out.push(
|
|
9093
|
+
out.push(coded
|
|
9094
|
+
? '항목을 건드리기 전에는 works(tenant, work, item) 로 이미 끝났는지 먼저 확인한다 — item 은 번호(예 W-04)나 item_key 그대로. 전문은 get_case(tenant, item_key).'
|
|
9095
|
+
: '항목을 건드리기 전에는 works(tenant, work, item) 로 이미 끝났는지 먼저 확인한다 — 전문은 get_case(tenant, item_key).');
|
|
7512
9096
|
return out.join('\n');
|
|
7513
9097
|
}
|
|
7514
9098
|
|
|
@@ -7995,6 +9579,8 @@ async function callTool(name, args = {}) {
|
|
|
7995
9579
|
// 서버의 `publish: true` 손잡이를 당기면 초안이 열린다. 여는 동사는 release_report 하나다.
|
|
7996
9580
|
delete payload.publish;
|
|
7997
9581
|
delete payload.published_at;
|
|
9582
|
+
// 결정 모드 문서의 공개일은 서버가 원고에 적는다(released_on) — 되읽은 판에 섞여 오는 값은 서버가 어차피 덮지만 메아리를 줄인다.
|
|
9583
|
+
delete payload.released_on;
|
|
7998
9584
|
delete payload.body_rev; // 몸통의 판은 서버가 안 읽는다 — 섞여 오면 떼고 보낸다(원고에 메아리로 남지 않게)
|
|
7999
9585
|
if (args.dry_run) payload.dry_run = true;
|
|
8000
9586
|
/*
|
|
@@ -8098,7 +9684,7 @@ async function callTool(name, args = {}) {
|
|
|
8098
9684
|
}
|
|
8099
9685
|
if (args.undone) {
|
|
8100
9686
|
const r = await api('POST', '/api/work/done', { tenant: args.tenant, work: wSlug, item, undone: true });
|
|
8101
|
-
return `${r.item
|
|
9687
|
+
return `${worksItemRef(r.item)} — 완료 표시를 되돌렸다(사후 정정, 원장에 남는다). 지금 상태: ${r.word}.`;
|
|
8102
9688
|
}
|
|
8103
9689
|
if (args.done_when || args.done_evidence) {
|
|
8104
9690
|
if (!args.done_when || !args.done_evidence) {
|
|
@@ -8110,7 +9696,7 @@ async function callTool(name, args = {}) {
|
|
|
8110
9696
|
done_when: String(args.done_when), done_evidence: String(args.done_evidence),
|
|
8111
9697
|
session: mine || '',
|
|
8112
9698
|
});
|
|
8113
|
-
return `${r.item
|
|
9699
|
+
return `${worksItemRef(r.item)} — ${r.word}(으)로 표시했다.\n 조건: ${args.done_when}\n 근거: ${args.done_evidence}`;
|
|
8114
9700
|
}
|
|
8115
9701
|
// 아무 표시 인자도 없으면 조회 전용(부작용 없음) — 건드리기 전에 먼저 확인하는 자리.
|
|
8116
9702
|
const r = await api('GET', '/api/work/done?tenant=' + encodeURIComponent(args.tenant)
|
|
@@ -8118,7 +9704,7 @@ async function callTool(name, args = {}) {
|
|
|
8118
9704
|
const doneLine = r.state === 'done'
|
|
8119
9705
|
? `이미 끝난 항목이다 — 근거: ${r.done_evidence || '(비어 있음)'}. 다시 진행하지 말 것.`
|
|
8120
9706
|
: '아직 완료가 아니다.';
|
|
8121
|
-
return `${r.item
|
|
9707
|
+
return `${worksItemRef(r.item)} — ${r.word}\n${doneLine}`;
|
|
8122
9708
|
}
|
|
8123
9709
|
if (args.claim) {
|
|
8124
9710
|
// 착수 선언(088) — 세션 이름은 이 자리(로컬)가 안다. mail 도구의 그 관용구다.
|
|
@@ -8129,7 +9715,7 @@ async function callTool(name, args = {}) {
|
|
|
8129
9715
|
};
|
|
8130
9716
|
if (args.item) body.item = String(args.item);
|
|
8131
9717
|
const r = await api('POST', '/api/work/claim', body);
|
|
8132
|
-
const target = r.item?.key ? `${r.item
|
|
9718
|
+
const target = r.item?.key ? `${worksItemRef(r.item)} @ «${r.work?.name || r.work?.slug}»` : `«${r.work?.name || r.work?.slug}» (${r.work?.slug})`;
|
|
8133
9719
|
return `${target} — ${r.says}`;
|
|
8134
9720
|
}
|
|
8135
9721
|
const r = await api('GET', '/api/work/list?tenant=' + encodeURIComponent(args.tenant));
|
|
@@ -8201,6 +9787,9 @@ async function callTool(name, args = {}) {
|
|
|
8201
9787
|
rejected_names: Array.isArray(args.rejected_names) ? args.rejected_names : [],
|
|
8202
9788
|
// 옛 결정 뒤집기(2026-09-21) — 댈 때만 싣는다(안 대면 요청이 예전과 같다).
|
|
8203
9789
|
...(args.overturns ? { overturns: args.overturns, overturns_revision: args.overturns_revision || '' } : {}),
|
|
9790
|
+
// 하위 업무 번호 네 칸(DESIGN-item-codes.md · 설계 4-1) — 댄 칸만 싣는다(안 대면 요청이 예전과 같다). 판정 · 발급은 창구 한 자리.
|
|
9791
|
+
...Object.fromEntries(['item', 'parent', 'branch', 'prefix']
|
|
9792
|
+
.filter((k) => typeof args[k] === 'string' && args[k].trim() !== '').map((k) => [k, args[k].trim()])),
|
|
8204
9793
|
// ★ 출처는 우리가 채운다 — 모델에게 안 묻는다(remember_case 와 같은 규율). 표지가
|
|
8205
9794
|
// 열린 일과 만나 항목으로 설 때 「누가 심었나」(간트의 세션 자국)가 이것이다.
|
|
8206
9795
|
...myCitation(),
|
|
@@ -8251,6 +9840,11 @@ async function callTool(name, args = {}) {
|
|
|
8251
9840
|
} else if (args.work && !r.effects_pending) {
|
|
8252
9841
|
lines.push('표지(work)를 적었지만 이 서버는 아직 그 칸을 모른다 — 판이 낡았다.');
|
|
8253
9842
|
}
|
|
9843
|
+
// 하위 업무 번호 — 문장의 정본은 서버(work_items_code_note → work_note)다. 그 문장에 번호가 없을 때만(서버 문장이 빠진 판)
|
|
9844
|
+
// 칸 값 그대로 한 줄 — 프로즈를 짓지 않는다. 번호가 없는 저장(0단계 · 표지 없는 결정)의 출력은 예전과 같다.
|
|
9845
|
+
if (typeof r.code === 'string' && r.code !== '' && !String(r.work_note || '').includes(r.code)) {
|
|
9846
|
+
lines.push(`code: ${r.code}${typeof r.parent === 'string' && r.parent !== '' ? `\nparent: ${r.parent}` : ''}`);
|
|
9847
|
+
}
|
|
8254
9848
|
return lines.join('\n');
|
|
8255
9849
|
}
|
|
8256
9850
|
case 'list_rejected_names': {
|
|
@@ -8489,7 +10083,7 @@ async function callTool(name, args = {}) {
|
|
|
8489
10083
|
}
|
|
8490
10084
|
return `${r.tenant} — ${count}\n${rows.join('\n')}\n\n`
|
|
8491
10085
|
+ '이어받으려면 `resume_session` — 지금 이 창이 그 대화를 읽고 이어서 일합니다.\n'
|
|
8492
|
-
+ '원본
|
|
10086
|
+
+ 'Claude 원본 복원은 `pull_session` 뒤 `claude --resume <uuid>`. Native Codex는 업무 맥락만 읽습니다.'
|
|
8493
10087
|
+ (follow.length ? `\n\n${follow.join('\n')}` : '');
|
|
8494
10088
|
}
|
|
8495
10089
|
case 'push_session': {
|
|
@@ -8529,6 +10123,9 @@ async function callTool(name, args = {}) {
|
|
|
8529
10123
|
throw e;
|
|
8530
10124
|
} finally { fileUnlock(lease); }
|
|
8531
10125
|
const { r, before, parts, plan, appended } = pushed;
|
|
10126
|
+
const sourceNote = pushed.source ? `\nCodex 원본 ${pushed.source.records}레코드 → ${pushed.source.kept}발화·도구 레코드; `
|
|
10127
|
+
+ `이미지 값 ${pushed.source.omitted_images || 0}개·큰 내부/도구 레코드 ${pushed.source.omitted_large || 0}개 생략; `
|
|
10128
|
+
+ `미완료 끝줄 ${pushed.source.deferred_bytes}B는 다음 갱신으로 넘겼습니다.` : '';
|
|
8532
10129
|
// 표시를 **덧붙임 X B / 전체 Y B** 로(§3.4) — 통째로 갔으면 전처럼 「전 → 후」다.
|
|
8533
10130
|
const cut = plan?.mode === 'APPEND' || plan?.mode === 'ASK'
|
|
8534
10131
|
? `덧붙임 ${Number(appended || 0).toLocaleString()}B / 전체 ${r.bytes.toLocaleString()}B`
|
|
@@ -8539,7 +10136,7 @@ async function callTool(name, args = {}) {
|
|
|
8539
10136
|
const how = parts > 1 ? ` · ${parts}조각으로` : '';
|
|
8540
10137
|
if (r.dry_run) {
|
|
8541
10138
|
return `시험 실행 — 아무것도 저장하지 않았습니다.\n`
|
|
8542
|
-
+ `${r.tenant} · ${r.lines}줄 · 뺀 레코드 ${r.dropped} · 가린 자리 ${r.redacted} · ${cut}${how}
|
|
10139
|
+
+ `${r.tenant} · ${r.lines}줄 · 뺀 레코드 ${r.dropped} · 가린 자리 ${r.redacted} · ${cut}${how}` + sourceNote;
|
|
8543
10140
|
}
|
|
8544
10141
|
/*
|
|
8545
10142
|
* ★ **두뇌 큐에 들어갔는지 말한다.** 서버는 `wiki`(queued|not-queued)와 `note` 를
|
|
@@ -8554,7 +10151,8 @@ async function callTool(name, args = {}) {
|
|
|
8554
10151
|
+ `${r.lines}줄 · 뺀 레코드 ${r.dropped}(첨부·파일이력) · 가린 자리 ${r.redacted} · ${cut}${how}\n`
|
|
8555
10152
|
+ (queued ? queued + '\n' : '')
|
|
8556
10153
|
+ bindNote
|
|
8557
|
-
+ '다른 기계에서:
|
|
10154
|
+
+ (pushed.source_client === 'codex' ? '다른 기계에서: resume_session 으로 이 작업 맥락을 읽을 수 있습니다. Codex 원본 복원은 하지 않습니다.'
|
|
10155
|
+
: '다른 기계에서: pull_session 으로 받은 뒤 그 폴더에서 `claude --resume ' + args.uuid + '`') + sourceNote;
|
|
8558
10156
|
}
|
|
8559
10157
|
case 'resume_session': {
|
|
8560
10158
|
/*
|
|
@@ -8580,6 +10178,7 @@ async function callTool(name, args = {}) {
|
|
|
8580
10178
|
const q = `?tenant=${encodeURIComponent(args.tenant)}`;
|
|
8581
10179
|
const r = await api('GET',
|
|
8582
10180
|
`/api/sessions/${encodeURIComponent(args.uuid)}${q}&no_body=1&digest=1&digest_chars=${chars}`);
|
|
10181
|
+
const native = nativeSessionTools(r.tools);
|
|
8583
10182
|
indexNote(args.uuid, { src: 'resume', tenant: r.tenant || args.tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) }, 'rx');
|
|
8584
10183
|
|
|
8585
10184
|
// 원본도 제자리에 놓는다 — **덮지 않는다**(pull_session 과 같은 이유).
|
|
@@ -8587,7 +10186,9 @@ async function callTool(name, args = {}) {
|
|
|
8587
10186
|
const dir = transcriptDir(into);
|
|
8588
10187
|
const target = path.join(dir, `${args.uuid}.jsonl`);
|
|
8589
10188
|
let placed = target;
|
|
8590
|
-
if (
|
|
10189
|
+
if (native) {
|
|
10190
|
+
placed = 'Codex 업무 맥락만 읽었습니다 — 원본 전사 파일을 만들지 않았습니다';
|
|
10191
|
+
} else if (fs.existsSync(target)) {
|
|
8591
10192
|
placed = `${target} (이미 있어 그대로 두었습니다 — 덮으면 이 기계에서 하던 대화가 사라집니다)`;
|
|
8592
10193
|
} else {
|
|
8593
10194
|
await apiToFile(`/api/sessions/${encodeURIComponent(args.uuid)}${q}&raw=1`, target);
|
|
@@ -8600,7 +10201,7 @@ async function callTool(name, args = {}) {
|
|
|
8600
10201
|
* 최대 20초 동안 다른 사람 화면에는 이 기계가 안 보인다 — 이어받은 직후가 바로
|
|
8601
10202
|
* 「누가 잡았나」를 알아야 하는 순간이라 여기서 한 번 즉시 뛴다.
|
|
8602
10203
|
*/
|
|
8603
|
-
beatTick().catch(() => {});
|
|
10204
|
+
if (!native) beatTick().catch(() => {});
|
|
8604
10205
|
|
|
8605
10206
|
// **받기 전에 알아야 할 것을 먼저 쓴다.** 요지 뒤에 붙이면 긴 글에 묻힌다.
|
|
8606
10207
|
const others = (r.devices || []).filter((d) => d.device !== deviceId());
|
|
@@ -8619,7 +10220,7 @@ async function callTool(name, args = {}) {
|
|
|
8619
10220
|
|
|
8620
10221
|
return `이어받았습니다 — ${r.tenant} · ${args.uuid.slice(0, 8)} · ${r.turns || 0}턴 · ${trimmed}\n`
|
|
8621
10222
|
+ `원본을 놓은 자리: ${placed}\n`
|
|
8622
|
-
+ `원본 그대로 열려면: cd ${into} && claude --resume ${args.uuid}\n`
|
|
10223
|
+
+ (native ? 'Native Codex 원본 복원은 지원하지 않습니다. 아래 맥락으로 이어서 작업합니다.\n' : `원본 그대로 열려면: cd ${into} && claude --resume ${args.uuid}\n`)
|
|
8623
10224
|
+ (r.redacted ? `※ 가린 자리 ${r.redacted}곳 — 도구 실행 결과는 안 실려 옵니다. 필요하면 다시 실행하세요.\n` : '')
|
|
8624
10225
|
+ warn
|
|
8625
10226
|
+ '\n다음에 할 일: 아래 대화를 읽고, 사용자에게 「어디까지 되어 있고 다음이 무엇인지」를 '
|
|
@@ -8638,6 +10239,8 @@ async function callTool(name, args = {}) {
|
|
|
8638
10239
|
if (fs.existsSync(target)) {
|
|
8639
10240
|
throw new Error(`이미 있습니다: ${target}\n덮지 않았습니다 — 이 기계에서 하던 대화가 사라집니다.`);
|
|
8640
10241
|
}
|
|
10242
|
+
const meta = await api('GET', `/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}&no_body=1`);
|
|
10243
|
+
if (nativeSessionTools(meta.tools)) throw new Error('Native Codex는 portal 업무 맥락 투영입니다. resume_session으로 읽으세요 — Claude/native 원본 파일은 만들지 않습니다.');
|
|
8641
10244
|
// 봉투를 안 만든다 — 메타는 헤더로 온다(apiToFile 주석).
|
|
8642
10245
|
const { headers, bytes } = await apiToFile(
|
|
8643
10246
|
`/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}&raw=1`,
|
|
@@ -8655,31 +10258,33 @@ async function callTool(name, args = {}) {
|
|
|
8655
10258
|
const mine = mySessionId();
|
|
8656
10259
|
if (!mine) {
|
|
8657
10260
|
return '이 세션의 번호를 모른다 — 주소를 댈 수 없어 보낼 수 없다'
|
|
8658
|
-
+ '(
|
|
10261
|
+
+ '(Claude 세션 번호·유효한 Codex 세션 UUID·Cursor 활성 대화가 없는 환경이다).';
|
|
8659
10262
|
}
|
|
8660
10263
|
/*
|
|
8661
|
-
*
|
|
8662
|
-
*
|
|
8663
|
-
*
|
|
10264
|
+
* ★ **이름이 없어도 보낸다**(2026-10-07 — 「이름 없으면 못 보냄」 문지기를 걷었다). 가드의 보증(「받는 쪽이 누가 말하는지
|
|
10265
|
+
* 본다」)은 이름이 아니라 **서버가 등록된 세션에서 발신자를 박는다**는 사실에서 온다 — 서버는 2026-08-25 부터 이름 없는 발신을
|
|
10266
|
+
* uuid 앞 8자로 받는다(mail_store_sender_session — 8ec9b061:www/portal/lib/mail_store.php:96-105). 그런데 이 문지기가 서버보다 엄해서 등록부를 못 읽은
|
|
10267
|
+
* 세션은 전체 uuid 앞으로 보낸 통까지 거절됐고, 문지기가 등록보다 앞이라 주소도 안 생겼다(2026-10-06T15:49:24Z 실측).
|
|
10268
|
+
* ★ **대고 나서 보낸다** — 등록(mailRegister)이 보내기 앞이다. 서버는 등록된 주소에서만 발신을 받는다(403).
|
|
10269
|
+
* 이름은 대기 직전에 푼다(myAgentName — 세포 먼저, 못 하면 JS 사본). Native Codex 는 늘 이름 없이 댄다 — 이름을 지어내거나
|
|
10270
|
+
* 요청 인자의 발신자를 쓰지 않는다. 등록 거절은 아래에서 전송 전에 멈춘다.
|
|
10271
|
+
*/
|
|
10272
|
+
const nativeCodex = !process.env.CLAUDE_CODE_SESSION_ID && mine === codexSessionId();
|
|
10273
|
+
/*
|
|
10274
|
+
* ★ 보내는 세션은 **지금 세션(cur)** 이다 — /clear 뒤에는 env 의 세션 id(mine)가 아니다(F2·SEC-7). 받는 쪽의 답장 주소(from_session)가
|
|
10275
|
+
* 그 uuid 가 되고, 이 창은 그 편지함을 긷는다(mailPullOne). /clear 앞 세션은 이름 없이 주소만 유지한다(mailRegister).
|
|
8664
10276
|
*/
|
|
8665
|
-
if (!myAgentName()) {
|
|
8666
|
-
return '이 세션에는 아직 말 걸 수 있는 이름이 없어 **보낼 수가 없다**'
|
|
8667
|
-
+ '(받는 쪽이 누가 말하는지 못 보면 그것은 메시지가 아니라 주입이다).\n'
|
|
8668
|
-
+ '이름은 `ListAgents` 를 한 번 부르면 생긴다 — 하네스가 「This session is …」를 '
|
|
8669
|
-
+ '전사에 적고, 이 도구가 그것을 읽는다. 부른 뒤 다시 보내면 된다.';
|
|
8670
|
-
}
|
|
8671
10277
|
let reg;
|
|
8672
|
-
|
|
10278
|
+
let ident;
|
|
10279
|
+
try { ident = await mailSelf(mine); reg = await mailRegister(args.tenant, mine, true, ident); }
|
|
8673
10280
|
catch (e) { return `주소를 대지 못했다 — ${e.message}`; }
|
|
10281
|
+
const sender = ident?.cur || mine;
|
|
8674
10282
|
/*
|
|
8675
|
-
*
|
|
8676
|
-
*
|
|
8677
|
-
*
|
|
10283
|
+
* 이름을 남이 쥐고 있어도(held)·이름이 없어도 **보낸다**(침묵 금지). 무엇이 됐는지는 서버 응답의 사실대로 말한다 —
|
|
10284
|
+
* 발신 표시를 단정하지 않는다(mailAddrNote 머리말: 옛 서버는 held 일 때 그 줄의 옛 이름을 지켰다).
|
|
10285
|
+
* (하네스가 짧은 이름을 재사용한다 — 2026-08-25 운영 실측, app-4a 둘.) Native Codex 는 늘 이름이 없어 말할 것이 없다.
|
|
8678
10286
|
*/
|
|
8679
|
-
|
|
8680
|
-
const heldNote = reg?.held_by
|
|
8681
|
-
? `(이 세션의 이름은 살아 있는 다른 세션 ${String(reg.held_by).slice(0, 8)} 이 쥐고 있어 이 통은 «${mine.slice(0, 8)}» 이름으로 나간다 — 남이 이 세션에 닿으려면 uuid ${mine} 로.)\n`
|
|
8682
|
-
: '';
|
|
10287
|
+
const addrNote = nativeCodex ? '' : mailAddrNote(reg, mine);
|
|
8683
10288
|
|
|
8684
10289
|
const r = await api('POST', '/api/mail', {
|
|
8685
10290
|
tenant: args.tenant,
|
|
@@ -8691,14 +10296,17 @@ async function callTool(name, args = {}) {
|
|
|
8691
10296
|
cwd: relHome(process.cwd()),
|
|
8692
10297
|
...(args.thread ? { thread: args.thread } : {}),
|
|
8693
10298
|
// ★ **출처는 우리가 채운다 — 모델에게 안 묻는다**(remember_case 의 myCitation 과 같은 규율).
|
|
8694
|
-
session:
|
|
10299
|
+
session: sender,
|
|
8695
10300
|
});
|
|
8696
10301
|
// ★ 받을 곳을 기억한다 — 색인 밖 세션도 이 소속처에서 받는다(mailReady 힌트 + 색인).
|
|
8697
10302
|
// ⚠ 서버가 tenant_id 를 아직 안 주는 판(재배포 전)이어도 **받기는 이름만으로 된다** —
|
|
8698
10303
|
// GET /api/mail 은 tenant 이름으로 묻는다. 색인(indexNote)만 id 가 있어야 적힌다.
|
|
8699
10304
|
mailTenants.set(args.tenant, r?.tenant_id || 0); // 이 소속처에서도 받는다 — 합집합, 폴백 아님
|
|
8700
10305
|
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 } : {}) });
|
|
8701
|
-
|
|
10306
|
+
// 실제로 받은 세션(서버가 푼 uuid) — 이름으로 부쳤으면 그 이름이 그 순간 누구였는지가 여기 남는다(오배달을 알아챌 자리).
|
|
10307
|
+
const toSession = mailToSession(r);
|
|
10308
|
+
return addrNote + `«${r.to}» 앞으로 보냈다 — 실 ${r.thread} · 이 실의 ${r.turn}번째 통.\n`
|
|
10309
|
+
+ (toSession ? `받는 세션: ${toSession} — 이 실을 이어 가거나 다시 부칠 때 이 uuid 가 주소다(이름은 다시 뜨면 바뀐다).\n` : '')
|
|
8702
10310
|
+ `받는 쪽이 보게 될 글자는 이것이다:\n${r.preview}\n`
|
|
8703
10311
|
+ (r.says ? `${r.says}\n` : '')
|
|
8704
10312
|
+ '답이 오면 다른 도구를 부를 때 응답 꼬리에 저절로 실린다 — 따로 확인할 필요 없다.';
|
|
@@ -8869,12 +10477,22 @@ async function handle(line) {
|
|
|
8869
10477
|
result = { tools: toolsNow() };
|
|
8870
10478
|
} else if (req.method === 'tools/call') {
|
|
8871
10479
|
lastToolAt = Date.now(); // 유휴 조절기의 시계(자기 일정 표)
|
|
10480
|
+
const sessionOnly = sessionToolMode();
|
|
10481
|
+
if (sessionOnly && !['whoami', 'list_sessions', 'push_session'].includes(req.params?.name))
|
|
10482
|
+
throw new Error('Session-only client permits whoami, list_sessions, and explicit push_session only');
|
|
10483
|
+
if (SESSION_WATCH_UUID && req.params?.name === 'push_session' && !sessionScope(req.params?.arguments?.uuid))
|
|
10484
|
+
throw new Error('Session watch permits writes to its explicitly selected UUID only');
|
|
8872
10485
|
// 도구응답 레인의 긷기 — **기다리지 않는다**(도구 지연은 그대로). 60초 상한은 mailPull 안의 mailAt 검사.
|
|
8873
10486
|
// 도구가 망을 도는 동안 도착한 통은 이번 응답 꼬리에, 아니면 다음 응답에 실린다.
|
|
8874
|
-
if (hookLane() && mailPollReady()) mailPull().catch(() => {});
|
|
10487
|
+
if (!sessionOnly && hookLane() && mailPollReady()) mailPull().catch(() => {});
|
|
8875
10488
|
const text = await callTool(req.params?.name, req.params?.arguments || {});
|
|
8876
10489
|
result = { content: [{ type: 'text', text }] };
|
|
8877
|
-
worldAppend(result);
|
|
10490
|
+
if (!sessionOnly) worldAppend(result);
|
|
10491
|
+
else if (SESSION_WATCH_UUID) {
|
|
10492
|
+
// Publishing still requires the existing visible folder consent notice.
|
|
10493
|
+
const notice = tenantNotice();
|
|
10494
|
+
if (notice) result.content.push({ type: 'text', text: notice });
|
|
10495
|
+
}
|
|
8878
10496
|
} else if (isNotification) {
|
|
8879
10497
|
return;
|
|
8880
10498
|
} else {
|
|
@@ -8888,7 +10506,7 @@ async function handle(line) {
|
|
|
8888
10506
|
// 그래야 에이전트가 메시지를 읽고 고쳐서 다시 시도한다.
|
|
8889
10507
|
if (req.method === 'tools/call') {
|
|
8890
10508
|
const failed = { content: [{ type: 'text', text: `실패: ${e.message}` }], isError: true };
|
|
8891
|
-
worldAppend(failed); // 실패한 턴에도 세계는 움직인다.
|
|
10509
|
+
if (!sessionToolMode()) worldAppend(failed); // 실패한 턴에도 세계는 움직인다.
|
|
8892
10510
|
send({ jsonrpc: '2.0', id: req.id, result: failed });
|
|
8893
10511
|
} else {
|
|
8894
10512
|
send({ jsonrpc: '2.0', id: req.id, error: { code: -32603, message: String(e.message || e) } });
|
|
@@ -9386,6 +11004,7 @@ function ensureSkillOnBoot() {
|
|
|
9386
11004
|
* lane 'all' = 모든 클라이언트 · 'hookless' = 훅 레인이 없는 클라이언트만(`hookLane()` 이 거짓 — Codex)
|
|
9387
11005
|
* idle 'never' = 늘 같은 주기(심박 — 살아 있음이 신호다, 서버는 90초 침묵을 죽음으로 본다)
|
|
9388
11006
|
* 'slow' = 마지막 도구 호출 뒤 IDLE_AFTER_MS 가 지나면 IDLE_EVERY_MS 에 한 번만. 도구를 부르면 곧 복귀
|
|
11007
|
+
* once true = 타이머 없이 뜰 때 한 번만 돈다(주소 등록 — 2026-10-07). 그 뒤는 도구 호출·메일 폴이 맡는다
|
|
9389
11008
|
*
|
|
9390
11009
|
* 타이머가 아닌 것(심박 바퀴 안에서 사건·시각으로 도는 것): resolve(10분 TTL·사건 기반) · 판 비교(30분) ·
|
|
9391
11010
|
* 되찾기 훑기(5분) · 자동 올리기(90초 하한, 동의 관문). 메일은 훅 레인이 있으면 도구 호출 때 긷는다(60초 상한).
|
|
@@ -9401,13 +11020,18 @@ const SCHEDULE = [
|
|
|
9401
11020
|
{ name: 'mail', every: MAIL_POLL_MS, lane: 'hookless', idle: 'slow', tick: () => { mailPull().catch(() => {}); },
|
|
9402
11021
|
ready: mailPollReady,
|
|
9403
11022
|
why: '훅 레인이 없는 세션의 편지함 — 훅 레인이 있으면 도구 호출 때 긷는다' },
|
|
11023
|
+
{ name: 'address', every: 0, lane: 'all', idle: 'never', once: true, tick: () => { mailRegisterBoot().catch(() => {}); },
|
|
11024
|
+
ready: mailPollReady,
|
|
11025
|
+
why: '뜰 때 아는 소속처에 주소를 한 번 댄다 — 도구를 불러야만 대면 다시 뜬 세션이 첫 호출까지 서버에 옛 이름으로 남는다(계약 2)' },
|
|
9404
11026
|
];
|
|
9405
11027
|
|
|
9406
11028
|
function startSchedule() {
|
|
9407
11029
|
if (cfg.GENETICS_NO_BEAT) return; // 심박을 끄는 사람은 나머지도 안 원한다
|
|
9408
11030
|
for (const row of SCHEDULE) {
|
|
11031
|
+
if (SESSION_WATCH_UUID && row.name !== 'beat') continue;
|
|
9409
11032
|
if (row.start) { row.start(); continue; }
|
|
9410
11033
|
if (row.ready && !row.ready()) continue;
|
|
11034
|
+
if (row.once) { row.lastRun = Date.now(); row.tick(); continue; } // 타이머를 안 건다 — 뜰 때 한 번
|
|
9411
11035
|
const run = () => {
|
|
9412
11036
|
if (row.lane === 'hookless' && hookLane()) return;
|
|
9413
11037
|
const now = Date.now();
|
|
@@ -9422,17 +11046,19 @@ function startSchedule() {
|
|
|
9422
11046
|
}
|
|
9423
11047
|
|
|
9424
11048
|
if (IS_MAIN) {
|
|
9425
|
-
|
|
9426
|
-
|
|
11049
|
+
if (!sessionToolMode()) {
|
|
11050
|
+
ensureCellOnBoot(); // 세포가 먼저다 — 스킬보다 이쪽이 「무엇을 못 하는가」를 정한다
|
|
11051
|
+
ensureSkillOnBoot();
|
|
11052
|
+
}
|
|
9427
11053
|
/*
|
|
9428
11054
|
* **뜰 때 심박을 시작한다.** 이 프로세스가 사는 동안이 곧 「이 기계가 이 창을 열고 있는
|
|
9429
11055
|
* 동안」이라, 시작·끝을 따로 알릴 필요가 없다. 자격이나 주소가 없으면 조용히 안 뛴다 —
|
|
9430
11056
|
* 꺼진 것이지 고장이 아니다(그림 자격이 없을 때와 같은 태도).
|
|
9431
11057
|
*/
|
|
9432
|
-
startSchedule();
|
|
11058
|
+
if (cfg.GENETICS_SESSION_ONLY !== '1') startSchedule();
|
|
9433
11059
|
process.stdin.setEncoding('utf8');
|
|
9434
11060
|
process.stdin.on('data', (chunk) => { buf += chunk; pump(); });
|
|
9435
|
-
process.stdin.on('end', () => { pump(true); stopBeat().catch(() => {}); });
|
|
11061
|
+
process.stdin.on('end', () => { pump(true); if (cfg.GENETICS_SESSION_ONLY !== '1') stopBeat().catch(() => {}); });
|
|
9436
11062
|
// 창을 닫으면 목록에서 **즉시** 빠진다. 못 알려도 90초 뒤 서버가 스스로 지운다.
|
|
9437
11063
|
/*
|
|
9438
11064
|
* ★ **SIGHUP 이 빠져 있었다.** 사람이 「대화창을 닫는다」고 할 때 실제로 오는 신호가 그것인데
|
|
@@ -9443,12 +11069,12 @@ if (IS_MAIN) {
|
|
|
9443
11069
|
* 「창 닫기」만은 이 한 줄로 잡힌다.
|
|
9444
11070
|
*/
|
|
9445
11071
|
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
9446
|
-
process.on(sig, () => { stopBeat().finally(() => process.exit(0)); });
|
|
11072
|
+
process.on(sig, () => { if (cfg.GENETICS_SESSION_ONLY === '1') process.exit(0); else stopBeat().finally(() => process.exit(0)); });
|
|
9447
11073
|
}
|
|
9448
11074
|
}
|
|
9449
11075
|
|
|
9450
11076
|
// 시험용 — 서버 동작에는 쓰이지 않는다. materializeAssets 만 네트워크를 쓴다.
|
|
9451
|
-
export { videoThumbs, taskReadTools, taskReadRequest, taskReadResult,
|
|
11077
|
+
export { videoThumbs, taskReadTools, taskReadRequest, taskReadResult, taskWriteResult, taskCodeOf,
|
|
9452
11078
|
encodeAsset, imageDims, imageHostAllowed, materializeAssets, warnText, IMAGE_HOST_ALLOW, HOST_ALLOW,
|
|
9453
11079
|
scrubTranscript, splitAtLines, PUSH_PART_MAX,
|
|
9454
11080
|
// DESIGN-genetics-ssot.md §3·§5 — 색인·잠금·폴더 identity·알림 문구의 순수/파일 함수(시험이 걷는다)
|
|
@@ -9469,6 +11095,7 @@ export { videoThumbs, taskReadTools, taskReadRequest, taskReadResult,
|
|
|
9469
11095
|
autoRowOnServer,
|
|
9470
11096
|
// 2026-09-18 Windows — 남의 규칙(Claude Code 슬러그)·플랫폼 갈래는 시험이 표로 걷는다
|
|
9471
11097
|
transcriptSlug, transcriptDir, cursorUserRoot, sqliteBin, npxCacheResetHint, cellPlatformSupported,
|
|
11098
|
+
mySessionId, refreshCodexSessions, autoCandidates, autoSyncRound, callTool,
|
|
9472
11099
|
HOLDS_FILE, UPDATE_BUDGET_MS,
|
|
9473
11100
|
// DESIGN-session-delta-sync C6 — 규칙판·좌표·기계 키·대응표·머리 끊기·private 판정(시험이 걷는다)
|
|
9474
11101
|
SCRUB_REV, CWD_HIST_MAX, PUSH_MAC_WIN, pushDir, pushKey, pushKeyId, pushMac, pushCoords,
|
|
@@ -9481,4 +11108,14 @@ export { videoThumbs, taskReadTools, taskReadRequest, taskReadResult,
|
|
|
9481
11108
|
planPush, pushSession, pushAppend, capsNote, capsAppend, capsScrubRev, capsFile, capsAppendGone, CAPS_FALSE_TTL_MS,
|
|
9482
11109
|
readAt, lastNewlineAt, countLines, appendScan, splitSeg, foldHist, scrubDelta, hashRange, pushTenantId,
|
|
9483
11110
|
APPEND_ENV_MAX, APPEND_RAW_STEP, APPEND_ROUND_MAX,
|
|
9484
|
-
autoFullBudgetOf, autoFullCounts, autoDupOf, PUSH_FULL_BUDGET, PUSH_FULL_WINDOW_MS, PUSH_FULL_REBASE, PUSH_FULL_LOG_MAX
|
|
11111
|
+
autoFullBudgetOf, autoFullCounts, autoDupOf, PUSH_FULL_BUDGET, PUSH_FULL_WINDOW_MS, PUSH_FULL_REBASE, PUSH_FULL_LOG_MAX,
|
|
11112
|
+
// 주소 계약 2(2026-10-07) — 등록부 JS 사본(세포와 같은 계약)·세포 답의 모양·서버 이름 꼴·보낸 결과 안내·답장 주소(시험이 표로 걷는다)
|
|
11113
|
+
harnessSessionsDir, harnessInt, harnessNameClean, harnessNameOk, procStartEpoch, harnessRowOf, harnessRowsIn, harnessRowLive,
|
|
11114
|
+
harnessLiveRow, harnessSelfKeys, harnessSelfRow, harnessProbe, procStartOf, selfAddressJs, introNameIn, introNameOf,
|
|
11115
|
+
lastIntroInRange, cellSelfParse, mailNameOk, mailAddrNote, mailToSession, mailReplyHints,
|
|
11116
|
+
// 이름 관문(2026-10-07) — 세포 `self_whereabouts`·`session_whereabouts`·`claude_transcript_in` 의 사본과 세포에 묻는 길(smoke 3-4 가 같은 픽스처에 댄다)
|
|
11117
|
+
harnessHome, harnessConfigDir, claudeProjectBases, claudeTranscriptIn, claudeIntroName, introOfFile, INTRO_LINE_MAX,
|
|
11118
|
+
harnessWhereaboutsOf, harnessWhereabouts, harnessSelfWhereabouts, isoUtc, selfAddress,
|
|
11119
|
+
// 검증 반영(2026-10-07) — pidDomain(F1) · 세 갈래 산가(F5) · 고르기·산 줄 수(L1·SEC-1) · 찾은 열쇠(SEC-2) · /clear 따라가기(F2) · 이름 확인(SEC-5)
|
|
11120
|
+
pidDomainOf, harnessPidDomain, harnessRowLife, harnessLookup, harnessSelfLookup, harnessWithRows,
|
|
11121
|
+
myAgentName, mailSelf, mailRegister, mailNameSeen };
|