genetics-mcp 2.12.0 → 2.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/aarch64-apple-darwin/gcell +0 -0
- package/bin/aarch64-unknown-linux-musl/gcell +0 -0
- package/bin/manifest.json +4 -4
- package/bin/x86_64-apple-darwin/gcell +0 -0
- package/bin/x86_64-unknown-linux-musl/gcell +0 -0
- package/genetics.mjs +363 -59
- package/package.json +1 -1
- package/skills/gazette-report/README.md +71 -0
- package/skills/gazette-report/SKILL.md +479 -0
- package/skills/gazette-report/scripts/boundary.py +97 -0
- package/skills/gazette-report/scripts/contentdiff.sh +36 -0
- package/skills/gazette-report/scripts/cssaudit.sh +78 -0
- package/skills/gazette-report/scripts/extract.sh +84 -0
- package/skills/gazette-report/scripts/flexcheck.mjs +28 -0
- package/skills/gazette-report/scripts/fontchk.mjs +62 -0
- package/skills/gazette-report/scripts/kcheck.mjs +146 -0
- package/skills/gazette-report/scripts/package.json +5 -0
- package/skills/gazette-report/scripts/pdfcheck.sh +34 -0
- package/skills/gazette-report/scripts/read.mjs +85 -0
- package/skills/gazette-report/scripts/sentbreak.py +142 -0
- package/skills/gazette-report/scripts/tone.mjs +172 -0
- package/skills/gazette-report/scripts/tone.test.mjs +118 -0
- package/skills/gazette-report/scripts/typesurvey.mjs +48 -0
- package/skills/gazette-report/template/README.md +89 -0
- package/skills/gazette-report/template/gazette.css +2120 -0
- package/skills/gazette-report/template/skeleton.html +477 -0
- package/skills/genetics-cell-id/SKILL.md +12 -10
- package/skills/genetics-report/SKILL.md +54 -7
- package/skills/genetics-report/assets/example/cases-briefing.json +32 -0
- package/skills/genetics-report/assets/example/report-briefing.json +271 -0
- package/skills/genetics-report/assets/example/report.json +1 -1
- package/skills/genetics-report/references/content-pipeline.md +20 -0
- package/skills/genetics-report/references/lim-portal-format.md +100 -7
- package/skills/genetics-report/references/process.md +11 -0
- package/skills/genetics-sync/SKILL.md +1 -1
package/genetics.mjs
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* 이 서버는 모양을 만들지 않는다. 서버(build.py)가 규격대로 렌더하므로,
|
|
14
14
|
* 여기서 보내는 것은 **내용뿐**이다. 그래서 CSS 가 제각각일 수 없다.
|
|
15
15
|
*
|
|
16
|
-
* **키 하나가 고객사 하나다.** 여기에 더해 발행 도구는 `tenant`(대상 고객사
|
|
16
|
+
* **키 하나가 고객사 하나다.** 여기에 더해 발행 도구는 `tenant`(대상 고객사 slug — 이름도 받는다)를
|
|
17
17
|
* 반드시 받아 키와 대조한다 — 키만 믿었다가 실제로 남의 고객사에 발행한 적이 있다
|
|
18
18
|
* (2026-08-12). 키와 의도가 둘 다 맞아야 나간다.
|
|
19
19
|
* 키는 사람이 관리 화면에서 발급해 자격증명 파일(권한 600)에 둔다 — 경로는 아래 ENV_FILE.
|
|
@@ -524,7 +524,21 @@ async function materializeAssets(data) {
|
|
|
524
524
|
const { data: dropped, file: fpath, ...meta } = a ?? {};
|
|
525
525
|
if (!a?.source && !fpath) {
|
|
526
526
|
// 둘 다 없으면 예전 경로 그대로 — data 를 그대로 통과시킨다(판 A 하위호환).
|
|
527
|
-
|
|
527
|
+
// get_report 가 준 표식('(생략 — NB)')과 bytes 칸의 처리(2026-08-27, 운영 #34):
|
|
528
|
+
// brief 없는 자산 → data·bytes 를 빼고 **id 만** 보낸다. 서버가 같은 id 의 저장 바이트를 잇는다
|
|
529
|
+
// (report_carry_assets — 「빈 data」는 id 잇기 갈래다).
|
|
530
|
+
// brief 있는 자산 → **표식을 그대로** 싣는다. data 를 비우면 서버가 「brief 만 왔다 =
|
|
531
|
+
// 다시 그려라」로 읽어(carry 의 brief 갈래 → asset_jobs_plan) 저장 그림을 버리고 크레딧을
|
|
532
|
+
// 태운다 — 표식은 빈 값이 아니라서 carry 가 저장 바이트로 갈아끼우고 brief 는 남는다.
|
|
533
|
+
const { bytes: _b, ...rest } = meta;
|
|
534
|
+
const placeholder = typeof dropped === 'string' && dropped.startsWith('(생략');
|
|
535
|
+
if (placeholder) {
|
|
536
|
+
const briefed = typeof rest.brief === 'string' && rest.brief.trim() !== '';
|
|
537
|
+
if (briefed) out.push({ ...rest, data: dropped });
|
|
538
|
+
else { const { data: _d, ...idOnly } = rest; out.push(idOnly); }
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
out.push(dropped === undefined ? rest : { ...rest, data: dropped });
|
|
528
542
|
continue;
|
|
529
543
|
}
|
|
530
544
|
// 원격보다 **로컬이 이긴다** — 손에 든 실물이 주소보다 확실하다.
|
|
@@ -700,6 +714,7 @@ function limMeta(h) {
|
|
|
700
714
|
head_id: h.get('x-lim-head-id'), head_ms: num('x-lim-head-ms'),
|
|
701
715
|
lines: num('x-lim-lines'), bytes: num('x-lim-bytes'),
|
|
702
716
|
redacted: num('x-lim-redacted'), tenant_id: num('x-lim-tenant-id'),
|
|
717
|
+
tenant_slug: h.get('x-lim-tenant-slug'),
|
|
703
718
|
};
|
|
704
719
|
}
|
|
705
720
|
|
|
@@ -952,7 +967,8 @@ const REPORT_DATA = {
|
|
|
952
967
|
+ '로 두면 화질을 계단으로 내려 예산 안에 앉힌다. 그 도판은 `sections[].plates` 에 id 를 적어야 자리가 생긴다. '
|
|
953
968
|
+ '크기와 형식은 인코딩 결과에서 읽으므로 선언하지 않는다. 전부 data: URI 로 문서에 박히며 '
|
|
954
969
|
+ '(열람 CSP 가 외부 이미지를 막는다), 서버가 출처·매직바이트·예산(장당 80,000B·합계 400,000B)을 '
|
|
955
|
-
+ '다시 검증한다.'
|
|
970
|
+
+ '다시 검증한다. **갱신(update_report)에서 이 칸을 빼면 저장된 그림이 유지되고, `[]` 면 지워진다** — '
|
|
971
|
+
+ 'plates 가 가리키는 그림이 없으면 422.',
|
|
956
972
|
items: {
|
|
957
973
|
type: 'object',
|
|
958
974
|
properties: {
|
|
@@ -1257,14 +1273,111 @@ function findTranscript(uuid) {
|
|
|
1257
1273
|
const here = path.join(transcriptDir(process.cwd()), `${uuid}.jsonl`);
|
|
1258
1274
|
if (fs.existsSync(here)) return here;
|
|
1259
1275
|
const root = path.join(os.homedir(), '.claude', 'projects');
|
|
1276
|
+
// Claude Code 가 없는 기계(Cursor 만 깔린 곳)에는 이 디렉터리 자체가 없다 —
|
|
1277
|
+
// 「전사 없음」이지 오류가 아니다. 던지면 mail 등 도구가 통째로 죽는다(smoke 실측).
|
|
1278
|
+
if (!fs.existsSync(root)) return null;
|
|
1260
1279
|
for (const d of fs.readdirSync(root, { withFileTypes: true })) {
|
|
1261
1280
|
if (!d.isDirectory()) continue;
|
|
1262
1281
|
const f = path.join(root, d.name, `${uuid}.jsonl`);
|
|
1263
1282
|
if (fs.existsSync(f)) return f;
|
|
1264
1283
|
}
|
|
1284
|
+
return cursorTranscriptOf(uuid);
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/*
|
|
1288
|
+
* Cursor 대화의 전사(조각 3, 2026-08-28) — `~/.cursor/projects/<슬러그>/agent-transcripts/<uuid>/<uuid>.jsonl`.
|
|
1289
|
+
* 설계 문서 §1.2 의 sqlite 전제는 낡았다 — 훅 payload 의 `transcript_path` 가 가리키는 jsonl 이
|
|
1290
|
+
* 실재한다(이 기계 157개 실측). 슬러그는 워크스페이스 기준(앞 대시 없음)이라 cwd 로 못 짚고,
|
|
1291
|
+
* 자리가 몇 안 되니 **전부 센다**(세포 world.rs 의 cursor_transcript_dirs 와 같은 결정).
|
|
1292
|
+
* ⚠ 30일 지난 것은 Cursor 가 스스로 지운다(.agent-data-cleanup-*) — 없음은 오류가 아니다.
|
|
1293
|
+
*/
|
|
1294
|
+
function cursorTranscriptOf(uuid) {
|
|
1295
|
+
const base = path.join(os.homedir(), '.cursor', 'projects');
|
|
1296
|
+
let dirs;
|
|
1297
|
+
try { dirs = fs.readdirSync(base, { withFileTypes: true }); } catch { return null; }
|
|
1298
|
+
for (const d of dirs) {
|
|
1299
|
+
if (!d.isDirectory()) continue;
|
|
1300
|
+
const f = path.join(base, d.name, 'agent-transcripts', uuid, `${uuid}.jsonl`);
|
|
1301
|
+
if (fs.existsSync(f)) return f;
|
|
1302
|
+
}
|
|
1265
1303
|
return null;
|
|
1266
1304
|
}
|
|
1267
1305
|
|
|
1306
|
+
/* ── Cursor 신원 ──────────────────────────────────────────────────────────
|
|
1307
|
+
*
|
|
1308
|
+
* Cursor 는 MCP 자식에게 대화(세션) id 를 **안 준다** — 실측(2026-08-27, pid 98284 env):
|
|
1309
|
+
* `CODE_EDITOR=cursor` 와 `CURSOR_WORKSPACE_LABEL=<창 이름>` 뿐이고, MCP 프로세스는
|
|
1310
|
+
* 대화마다가 아니라 **창마다 하나**다. 대신 창의 워크스페이스 저장소가 「지금 포커스된
|
|
1311
|
+
* 대화」를 들고 있다:
|
|
1312
|
+
*
|
|
1313
|
+
* <Cursor>/User/workspaceStorage/<hash>/workspace.json → { folder: "file:///…" }
|
|
1314
|
+
* 같은 칸의 state.vscdb ItemTable 'composer.composerData'
|
|
1315
|
+
* → { lastFocusedComposerIds: ["<uuid4>"], selectedComposerIds: […] }
|
|
1316
|
+
*
|
|
1317
|
+
* composerId 는 uuid4 라 군체의 세션 열쇠와 같은 자리수다 — 그 uuid 를 「지금 이 대화」로
|
|
1318
|
+
* 쓴다. 단 **CLAUDE_CODE_SESSION_ID 가 있으면 언제나 그것이 정본**이다: Cursor 통합
|
|
1319
|
+
* 터미널 안 Claude Code 도 CURSOR_* env 를 물려받으므로(실측: pid 706 은 둘 다 가짐),
|
|
1320
|
+
* 순서를 바꾸면 Claude 세션이 Cursor 대화로 오인된다.
|
|
1321
|
+
*
|
|
1322
|
+
* 한계를 지어내지 않는다 — 이 uuid 로 되는 것은 **주소를 대는 것**까지다. 전사가
|
|
1323
|
+
* ~/.claude/projects 에 없으므로 올리기·머리 계산·자동 올리기는 각자의 전사 검사에서
|
|
1324
|
+
* 조용히 비켜난다(beatTargets:transcriptOf null → skip). 대화 본문을 올리는 길(두뇌
|
|
1325
|
+
* 파서까지)은 별도 단계다 — DESIGN-cursor-colony.md.
|
|
1326
|
+
*/
|
|
1327
|
+
const CURSOR_ROOT = process.env.GENETICS_CURSOR_ROOT
|
|
1328
|
+
|| path.join(os.homedir(), 'Library', 'Application Support', 'Cursor', 'User');
|
|
1329
|
+
|
|
1330
|
+
let cursorSeen = { at: 0, id: '', folder: '' }; // 15초 캐시 — 도구마다 sqlite 를 안 두드리게
|
|
1331
|
+
|
|
1332
|
+
function cursorActiveComposer() {
|
|
1333
|
+
if (process.env.CLAUDE_CODE_SESSION_ID) return '';
|
|
1334
|
+
const label = process.env.CURSOR_WORKSPACE_LABEL || '';
|
|
1335
|
+
if (!label) return ''; // 창을 못 고르면 추측하지 않는다
|
|
1336
|
+
const now = Date.now();
|
|
1337
|
+
if (now - cursorSeen.at < 15000) return cursorSeen.id;
|
|
1338
|
+
cursorSeen = { at: now, id: '', folder: '' };
|
|
1339
|
+
try {
|
|
1340
|
+
const wsRoot = path.join(CURSOR_ROOT, 'workspaceStorage');
|
|
1341
|
+
let best = null; // 같은 이름의 창이 여럿이면 가장 최근에 움직인 것
|
|
1342
|
+
for (const d of fs.readdirSync(wsRoot)) {
|
|
1343
|
+
const dir = path.join(wsRoot, d);
|
|
1344
|
+
let folder = '';
|
|
1345
|
+
try {
|
|
1346
|
+
folder = String(JSON.parse(fs.readFileSync(path.join(dir, 'workspace.json'), 'utf8')).folder || '');
|
|
1347
|
+
} catch { continue; }
|
|
1348
|
+
if (!folder.startsWith('file://')) continue;
|
|
1349
|
+
const wsPath = decodeURIComponent(folder.slice('file://'.length)).replace(/\/+$/, '');
|
|
1350
|
+
if (path.basename(wsPath) !== label) continue;
|
|
1351
|
+
let mt = 0;
|
|
1352
|
+
try { mt = fs.statSync(path.join(dir, 'state.vscdb')).mtimeMs; } catch { continue; }
|
|
1353
|
+
if (!best || mt > best.mt) best = { dir, wsPath, mt };
|
|
1354
|
+
}
|
|
1355
|
+
if (!best) return '';
|
|
1356
|
+
const r = spawnSync('/usr/bin/sqlite3',
|
|
1357
|
+
['-readonly', path.join(best.dir, 'state.vscdb'),
|
|
1358
|
+
"SELECT value FROM ItemTable WHERE key='composer.composerData'"],
|
|
1359
|
+
{ encoding: 'utf8', timeout: 2000 });
|
|
1360
|
+
if (r.status !== 0) return '';
|
|
1361
|
+
const j = JSON.parse((r.stdout || '').trim() || '{}');
|
|
1362
|
+
const v = j.lastFocusedComposerIds || j.selectedComposerIds;
|
|
1363
|
+
const id = Array.isArray(v) ? String(v[0] || '') : '';
|
|
1364
|
+
if (!/^[0-9a-f-]{36}$/i.test(id)) return '';
|
|
1365
|
+
cursorSeen = { at: now, id, folder: best.wsPath };
|
|
1366
|
+
return id;
|
|
1367
|
+
} catch { return ''; }
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/** 위 해석기가 고른 창의 **저장소 폴더** — 소속처 입양(같은 폴더 규칙)이 쓴다. */
|
|
1371
|
+
function cursorWorkspaceFolder() {
|
|
1372
|
+
if (!cursorSeen.folder) cursorActiveComposer();
|
|
1373
|
+
return cursorSeen.folder;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/** 「지금 이 세션」 — Claude Code env 가 정본, 없으면 Cursor 활성 대화. */
|
|
1377
|
+
function mySessionId() {
|
|
1378
|
+
return process.env.CLAUDE_CODE_SESSION_ID || cursorActiveComposer();
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1268
1381
|
function gitHere() {
|
|
1269
1382
|
try {
|
|
1270
1383
|
const r = spawnSync('git', ['-C', process.cwd(), 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
@@ -1355,9 +1468,16 @@ async function pushSession(tenant, uuid, title, dryRun) {
|
|
|
1355
1468
|
* 진짜 출처는 이미 손에 있었다: **본문 각 줄의 `cwd`**(scrubTranscript 가 센다).
|
|
1356
1469
|
* 없을 때만 옛 방식으로 물러선다 — 그래야 퇴보가 없다.
|
|
1357
1470
|
*/
|
|
1471
|
+
/*
|
|
1472
|
+
* ⚠ Cursor 전사(조각 3)에는 줄 cwd 가 아예 없다 — 그때 process.cwd() 로 물러서면
|
|
1473
|
+
* **올리는 프로세스의 자리**가 그 대화의 자리로 적힌다(위 scrubcheck 사고와 같은 병).
|
|
1474
|
+
* 같은 창의 Cursor MCP 면 워크스페이스 폴더가 진짜 자리다(cursorWorkspaceFolder) —
|
|
1475
|
+
* 그것도 모르면 **빈 값**으로 둔다. 모르는 것은 모른다고 적는다(자리 판정은 빈 자리를 안다).
|
|
1476
|
+
*/
|
|
1477
|
+
const isCursorBody = f.includes(`${path.sep}.cursor${path.sep}projects${path.sep}`);
|
|
1358
1478
|
const body = {
|
|
1359
1479
|
tenant, uuid,
|
|
1360
|
-
origin_cwd: sc.cwd || relHome(process.cwd()),
|
|
1480
|
+
origin_cwd: sc.cwd || (isCursorBody ? relHome(cursorWorkspaceFolder() || '') : relHome(process.cwd())),
|
|
1361
1481
|
origin_git: gitHere(), tools: toolsStamp(),
|
|
1362
1482
|
jsonl: sc.jsonl,
|
|
1363
1483
|
};
|
|
@@ -1382,7 +1502,7 @@ async function pushSession(tenant, uuid, title, dryRun) {
|
|
|
1382
1502
|
const parts = splitAtLines(sc.jsonl, PUSH_PART_MAX);
|
|
1383
1503
|
if (parts.length === 1) {
|
|
1384
1504
|
const r = await api('PUT', '/api/sessions', body);
|
|
1385
|
-
if (!dryRun) indexNote(uuid, { tenant: r.tenant || tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms }, 'tx');
|
|
1505
|
+
if (!dryRun) indexNote(uuid, { tenant: r.tenant || tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) }, 'tx');
|
|
1386
1506
|
return { r, before, parts: 1 };
|
|
1387
1507
|
}
|
|
1388
1508
|
|
|
@@ -1399,7 +1519,7 @@ async function pushSession(tenant, uuid, title, dryRun) {
|
|
|
1399
1519
|
process.stderr.write(`genetics: 조각 ${i + 1}/${parts.length} ${dryRun ? '시험' : '올림'} `
|
|
1400
1520
|
+ `(${(Buffer.byteLength(parts[i], 'utf8') / 1048576).toFixed(1)}MB)\n`);
|
|
1401
1521
|
}
|
|
1402
|
-
if (!dryRun) indexNote(uuid, { tenant: last.tenant || tenant, tenant_id: last.tenant_id, head_id: last.head_id, head_ms: last.head_ms }, 'tx');
|
|
1522
|
+
if (!dryRun) indexNote(uuid, { tenant: last.tenant || tenant, tenant_id: last.tenant_id, head_id: last.head_id, head_ms: last.head_ms, ...(last.tenant_slug ? { tenant_slug: last.tenant_slug } : {}) }, 'tx');
|
|
1403
1523
|
return { r: last, before, parts: parts.length };
|
|
1404
1524
|
}
|
|
1405
1525
|
|
|
@@ -1516,6 +1636,12 @@ function indexRead() {
|
|
|
1516
1636
|
* 말한 머리를 그대로 적을 뿐이고, 등록(:1676)은 아무것도 주고받지 않는다. 그 둘은 계기가
|
|
1517
1637
|
* 없다. 그리고 커서 쓰기는 **아무것도 안 왔을 때도** 도므로 부르는 쪽이 조건을 건다.
|
|
1518
1638
|
*/
|
|
1639
|
+
/** 색인 유래로 서버를 부를 때의 `tenant` 인자 — slug 가 있으면 slug(유일·불변), 없으면 옛 이름.
|
|
1640
|
+
* 색인의 `tenant` 칸은 계속 **이름**이다(표시용) — 인자로 나갈 때만 여기서 갈아탄다. */
|
|
1641
|
+
function tenantArgOf(e) {
|
|
1642
|
+
return (e && typeof e.tenant_slug === 'string' && e.tenant_slug !== '') ? e.tenant_slug : (e?.tenant || '');
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1519
1645
|
function indexNote(uuid, entry, flow = '') {
|
|
1520
1646
|
if (!uuid || !entry?.tenant_id) return;
|
|
1521
1647
|
try {
|
|
@@ -1618,7 +1744,7 @@ function transcriptOf(uuid) {
|
|
|
1618
1744
|
* 지어낸 출처 한 줄이 뒤에서 회수 보호의 근거가 되면, 걷어야 할 것을 안 걷게 된다.
|
|
1619
1745
|
*/
|
|
1620
1746
|
function myCitation() {
|
|
1621
|
-
const uuid =
|
|
1747
|
+
const uuid = mySessionId();
|
|
1622
1748
|
if (!/^[0-9a-f-]{36}$/i.test(uuid)) return {};
|
|
1623
1749
|
const f = transcriptOf(uuid);
|
|
1624
1750
|
if (!f) return {};
|
|
@@ -1660,18 +1786,29 @@ function adoptTenantOf(myDir, rows) {
|
|
|
1660
1786
|
let adoptSaid = false;
|
|
1661
1787
|
/** 위 규칙을 이 기계에 적용한다 — 색인에 한 줄을 더할 뿐, **올리지는 않는다.** */
|
|
1662
1788
|
function indexAdoptCurrent() {
|
|
1663
|
-
const mine =
|
|
1789
|
+
const mine = mySessionId();
|
|
1664
1790
|
if (!mine) return;
|
|
1665
1791
|
const idx = indexRead();
|
|
1666
1792
|
if (idx[mine]?.tenant_id) return; // 이미 등록돼 있다
|
|
1667
1793
|
const f = transcriptOf(mine);
|
|
1668
|
-
|
|
1669
|
-
|
|
1794
|
+
/*
|
|
1795
|
+
* 폴더 열쇠 — 입양 규칙(같은 폴더 = 같은 소속처)의 「폴더」는 전사 디렉터리다.
|
|
1796
|
+
* Cursor 대화는 ~/.claude/projects 에 전사가 없지만 **창의 저장소 폴더**는 아니까,
|
|
1797
|
+
* 같은 슬러그 규칙(transcriptDir)으로 열쇠를 만들면 그 저장소의 Claude 세션들과
|
|
1798
|
+
* 같은 칸에 선다 — report-portal 창의 Cursor 대화는 report-portal 세션들을 따라간다.
|
|
1799
|
+
*/
|
|
1800
|
+
let myDir = '';
|
|
1801
|
+
if (f) myDir = path.dirname(f);
|
|
1802
|
+
else if (mine && mine === cursorActiveComposer()) {
|
|
1803
|
+
const folder = cursorWorkspaceFolder();
|
|
1804
|
+
if (folder) myDir = transcriptDir(folder);
|
|
1805
|
+
}
|
|
1806
|
+
if (!myDir) return; // 이 기계에 사본도, 창 귀속도 없다
|
|
1670
1807
|
const rows = [];
|
|
1671
1808
|
for (const [u, e] of Object.entries(idx)) {
|
|
1672
1809
|
if (!e?.tenant_id) continue;
|
|
1673
1810
|
const p = transcriptOf(u);
|
|
1674
|
-
if (p) rows.push({ dir: path.dirname(p), tenant: e.tenant, tenant_id: e.tenant_id });
|
|
1811
|
+
if (p) rows.push({ dir: path.dirname(p), tenant: e.tenant, tenant_id: e.tenant_id, ...(e.tenant_slug ? { tenant_slug: e.tenant_slug } : {}) });
|
|
1675
1812
|
}
|
|
1676
1813
|
const pick = adoptTenantOf(myDir, rows);
|
|
1677
1814
|
// 이 폴더의 **첫 세션**이면 근거가 없다 — 여기서 조용히 되돌아가는 것은 의도이고,
|
|
@@ -1686,14 +1823,14 @@ function indexAdoptCurrent() {
|
|
|
1686
1823
|
}
|
|
1687
1824
|
return;
|
|
1688
1825
|
}
|
|
1689
|
-
indexNote(mine, { tenant: pick.tenant, tenant_id: pick.tenant_id, head_id: '', head_ms: 0 });
|
|
1826
|
+
indexNote(mine, { tenant: pick.tenant, tenant_id: pick.tenant_id, head_id: '', head_ms: 0, ...(pick.tenant_slug ? { tenant_slug: pick.tenant_slug } : {}) });
|
|
1690
1827
|
process.stderr.write('genetics: 이 세션을 «' + pick.tenant
|
|
1691
1828
|
+ '» 로 등록했다 — 같은 폴더의 앞 세션과 같은 곳이다. 이제 자동으로 올라간다.\n');
|
|
1692
1829
|
}
|
|
1693
1830
|
|
|
1694
1831
|
function beatTargets() {
|
|
1695
1832
|
const idx = indexRead();
|
|
1696
|
-
const mine =
|
|
1833
|
+
const mine = mySessionId();
|
|
1697
1834
|
const now = Date.now();
|
|
1698
1835
|
const out = [];
|
|
1699
1836
|
for (const [uuid, e] of Object.entries(idx)) {
|
|
@@ -1711,7 +1848,7 @@ function beatTargets() {
|
|
|
1711
1848
|
lines: head.lines, tenant: e.tenant_id, flags: 0,
|
|
1712
1849
|
// 방금 움직였으면 「말하는 중」이다 — 화면의 맥박이 여기서 뛴다.
|
|
1713
1850
|
state: now - mtime < BEAT_EVERY_MS ? BEAT_STATE.WORKING : BEAT_STATE.OPEN,
|
|
1714
|
-
|
|
1851
|
+
_tenant_arg: tenantArgOf(e),
|
|
1715
1852
|
});
|
|
1716
1853
|
if (out.length >= BEAT.MAX) break;
|
|
1717
1854
|
}
|
|
@@ -1882,11 +2019,11 @@ async function autoPushIfAhead(reply, recs) {
|
|
|
1882
2019
|
for (const v of reply) {
|
|
1883
2020
|
if (v.verdict !== BEAT_VERDICT.AHEAD || !(v.flags & BEAT_WANT_PUSH)) continue;
|
|
1884
2021
|
const rec = recs.find((r) => r.uuid === v.uuid);
|
|
1885
|
-
if (!rec?.
|
|
2022
|
+
if (!rec?._tenant_arg) continue;
|
|
1886
2023
|
if (now - (autoPushAt.get(v.uuid) || 0) < AUTO_PUSH_MIN_MS) continue;
|
|
1887
2024
|
autoPushAt.set(v.uuid, now);
|
|
1888
2025
|
try {
|
|
1889
|
-
const { r } = await pushSession(rec.
|
|
2026
|
+
const { r } = await pushSession(rec._tenant_arg, v.uuid, '');
|
|
1890
2027
|
process.stderr.write(`genetics: ${v.uuid.slice(0, 8)} 를 자동으로 올렸다 — ${r.lines}줄(서버가 낡아 있었다).\n`);
|
|
1891
2028
|
} catch (e) {
|
|
1892
2029
|
// 실패는 알리되 멈추지 않는다. 다음 심박이 다시 시도한다.
|
|
@@ -1975,10 +2112,10 @@ function observeTick() {
|
|
|
1975
2112
|
/** 이 세션의 색인 항목이 아는 소속처 이름 — 없으면 ''. `indexAdoptCurrent()` 가 같은 폴더 규칙으로 채운다. */
|
|
1976
2113
|
function observeTenant() {
|
|
1977
2114
|
try {
|
|
1978
|
-
const mine =
|
|
2115
|
+
const mine = mySessionId();
|
|
1979
2116
|
if (!mine) return '';
|
|
1980
2117
|
const e = indexRead()[mine];
|
|
1981
|
-
return (e && e.tenant_id
|
|
2118
|
+
return (e && e.tenant_id) ? tenantArgOf(e) : '';
|
|
1982
2119
|
} catch { return ''; }
|
|
1983
2120
|
}
|
|
1984
2121
|
|
|
@@ -2060,8 +2197,10 @@ const TOOLS = [
|
|
|
2060
2197
|
{
|
|
2061
2198
|
name: 'list_my_tenants',
|
|
2062
2199
|
description:
|
|
2063
|
-
'**내가 속한 곳 목록.** 다른 도구가 요구하는 `tenant`
|
|
2064
|
-
+ '
|
|
2200
|
+
'**내가 속한 곳 목록.** 다른 도구가 요구하는 `tenant` 값(slug, 이름도 받는다)을 여기서 얻는다.\n'
|
|
2201
|
+
+ '값을 **아직 모를 때만** 이것부터 부른다. 사용자 글에 `tenant:` 줄, `/t/<slug>/`, 또는 세션 uuid 가 있으면 '
|
|
2202
|
+
+ '그것을 쓰고 **되묻지 마라** — 고르지 말라는 것은 없는 값을 추측하지 말라는 뜻이다.\n'
|
|
2203
|
+
+ '여럿인데 사용자 글에 값이 없으면 사용자에게 고르게 한다.\n'
|
|
2065
2204
|
+ '지금 폴더에 대해 서버가 **본 것**을 함께 준다 — 「이 자리는 지금까지 어디에서 올라왔나」. '
|
|
2066
2205
|
+ '그것은 **기록이지 근거가 아니다**: 서버가 한 곳으로 좁히지 못하거나 자리를 못 읽으면 '
|
|
2067
2206
|
+ '그렇게 말한다. 좁혀지지 않으면 **사용자에게 물어라.**',
|
|
@@ -2127,7 +2266,7 @@ const TOOLS = [
|
|
|
2127
2266
|
inputSchema: {
|
|
2128
2267
|
type: 'object',
|
|
2129
2268
|
properties: {
|
|
2130
|
-
tenant: { type: 'string', description: '문서를 받을 소속처
|
|
2269
|
+
tenant: { type: 'string', description: '문서를 받을 소속처 slug(이름도 받는다 — slug 는 list_my_tenants 가 알려 준다). 키가 가리키는 곳과 다르면 거부된다(오발행 방지).' },
|
|
2131
2270
|
slug: { type: 'string', description: '파일 이름이 될 식별자. 영문 소문자·숫자·하이픈 (예: fixes-20260812)' },
|
|
2132
2271
|
published_at: { type: 'string', description: 'YYYY-MM-DD (생략하면 오늘)' },
|
|
2133
2272
|
publish: {
|
|
@@ -2151,7 +2290,7 @@ const TOOLS = [
|
|
|
2151
2290
|
inputSchema: {
|
|
2152
2291
|
type: 'object',
|
|
2153
2292
|
properties: {
|
|
2154
|
-
tenant: { type: 'string', description: '대상 소속처
|
|
2293
|
+
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다). 키와 다르면 거부된다.' },
|
|
2155
2294
|
id: { type: 'integer' },
|
|
2156
2295
|
published_at: { type: 'string', description: 'YYYY-MM-DD (생략하면 오늘)' },
|
|
2157
2296
|
},
|
|
@@ -2166,7 +2305,7 @@ const TOOLS = [
|
|
|
2166
2305
|
inputSchema: {
|
|
2167
2306
|
type: 'object',
|
|
2168
2307
|
properties: {
|
|
2169
|
-
tenant: { type: 'string', description: '대상 소속처
|
|
2308
|
+
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다). 키와 다르면 거부된다.' },
|
|
2170
2309
|
id: { type: 'integer' },
|
|
2171
2310
|
reason: { type: 'string', description: '왜 내리는지. 서버 로그에 남아 다음 사람이 이유를 안다.' },
|
|
2172
2311
|
},
|
|
@@ -2190,12 +2329,14 @@ const TOOLS = [
|
|
|
2190
2329
|
name: 'update_report',
|
|
2191
2330
|
description:
|
|
2192
2331
|
'기존 보고서의 내용을 교체한다. 보고서 id·주소가 그대로라 **이미 고객에게 나간 열람 링크가 새 내용을 가리킨다**. ' +
|
|
2193
|
-
'사람이
|
|
2194
|
-
'
|
|
2332
|
+
'사람이 편집기로 고친 문서도 교체할 수 있다(2026-08-25) — 대신 무엇을 덮게 되는지 목록이 먼저 말하고, 덮은 뒤 되돌릴 판이 응답에 온다. ' +
|
|
2333
|
+
'**그림 계약**: `assets` 를 **빼고** 보내면 서버가 저장된 그림을 그대로 유지한다. 전부 지우려면 `assets: []` 를 명시하고 ' +
|
|
2334
|
+
'`sections[].plates`·`chapters[].asset` 에서도 그 id 를 빼야 한다 — 가리키는 곳이 없는 id 는 422 로 거절된다. ' +
|
|
2335
|
+
'한 장만 바꾸려면 그 id 에 새 `source`/`file` 을 주고 나머지는 get_report 가 준 표식 그대로 두면 된다.',
|
|
2195
2336
|
inputSchema: {
|
|
2196
2337
|
type: 'object',
|
|
2197
2338
|
properties: {
|
|
2198
|
-
tenant: { type: 'string', description: '대상 소속처
|
|
2339
|
+
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다). 키와 다르면 거부된다(오발행 방지).' },
|
|
2199
2340
|
id: { type: 'integer' },
|
|
2200
2341
|
dry_run: {
|
|
2201
2342
|
type: 'boolean',
|
|
@@ -2228,7 +2369,7 @@ const TOOLS = [
|
|
|
2228
2369
|
inputSchema: {
|
|
2229
2370
|
type: 'object',
|
|
2230
2371
|
properties: {
|
|
2231
|
-
tenant: { type: 'string', description: '대상 소속처
|
|
2372
|
+
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
2232
2373
|
title: { type: 'string', description: '한 줄 제목. 파일 이름이 된다(같은 제목이면 덮어쓴다).' },
|
|
2233
2374
|
symptom: { type: 'string', description: '겪은 현상. 원인이 아니라 **눈에 보인 것**을 그대로.' },
|
|
2234
2375
|
cause: { type: 'string', description: '원인. 모르면 비운다 — 지어내지 않는다.' },
|
|
@@ -2245,6 +2386,54 @@ const TOOLS = [
|
|
|
2245
2386
|
required: ['tenant', 'title', 'symptom', 'fix'],
|
|
2246
2387
|
},
|
|
2247
2388
|
},
|
|
2389
|
+
{
|
|
2390
|
+
name: 'remember_decision',
|
|
2391
|
+
description:
|
|
2392
|
+
'**정한 것을 팀의 기억에 적는다** — 무엇을·누가·언제·왜·무슨 조건이면 다시 여나. '
|
|
2393
|
+
+ '그 소속처 지식의 `decisions/` 에 적힌다.\n'
|
|
2394
|
+
+ '언제 부르는가 — 사람이 이런 뜻으로 말할 때:\n'
|
|
2395
|
+
+ ' "이렇게 가기로 했다" · "이 안으로 정했다" · "그건 안 하기로 했다" · "반려했다" · '
|
|
2396
|
+
+ '"이 방향으로 합의했다" · "결정 남겨줘" · "왜 이렇게 정했는지 적어둬"\n'
|
|
2397
|
+
+ '★ **`remember_case` 와 무엇이 다른가**: 사례는 **겪은 문제**(증상이 있다), 결정은 **정한 것**'
|
|
2398
|
+
+ '(증상이 없다). 기획 결정·디자인 근거·반려 이유처럼 **고장이 아닌 일**은 전부 이쪽이다. '
|
|
2399
|
+
+ '증상을 지어내 사례로 적지 말 것 — 그러면 다음 사람이 증상으로 찾다가 못 찾는다.\n'
|
|
2400
|
+
+ '무엇을 적는가: **왜**가 이 장의 값이다(그때의 근거·수치·관찰). 그리고 **무슨 조건이면 다시 여나** — '
|
|
2401
|
+
+ '이것을 비우면 그 결정은 「아직 열린 것」으로 읽힌다(닫혔는지 아무도 모르므로). '
|
|
2402
|
+
+ '끝난 결정이면 "조건 없음(종결)"이라고 적을 것.\n'
|
|
2403
|
+
+ '모르는 칸은 **비운다** — 지어내면 「적히지 않음」보다 나쁘다.',
|
|
2404
|
+
inputSchema: {
|
|
2405
|
+
type: 'object',
|
|
2406
|
+
properties: {
|
|
2407
|
+
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
2408
|
+
title: { type: 'string', description: '한 줄 제목. 파일 이름이 된다. ⚠ 같은 제목이면 덮어쓴다 — **다른 결정이면 제목을 달리할 것**(서로 다른 결정은 합치지 않는다).' },
|
|
2409
|
+
what: { type: 'string', description: '무엇을 정했나 — 한 문장.' },
|
|
2410
|
+
who: { type: 'string', description: '정한 사람 또는 그 자리에 있던 사람. 모르면 비운다.' },
|
|
2411
|
+
when: { type: 'string', description: '정한 날짜(예: 2026-08-27). 모르면 비운다 — 오늘로 지어내지 말 것.' },
|
|
2412
|
+
why: { type: 'string', description: '그때의 근거 — 수치·관찰. 이 칸이 이 장의 값이다.' },
|
|
2413
|
+
reopen: { type: 'string', description: '무슨 조건이면 다시 여나. **비우면 「열린 결정」으로 읽힌다.** 끝났으면 "조건 없음(종결)".' },
|
|
2414
|
+
status: { type: 'string', description: '상태 — "유효"(기본) · "뒤집힘 → decisions/<그 결정>" · "조건 충족 → <무엇이> (날짜)".' },
|
|
2415
|
+
tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8). ★ 이것이 있어야 이 결정이 「일」 후보 계산에 들어간다.' },
|
|
2416
|
+
},
|
|
2417
|
+
required: ['tenant', 'title', 'what'],
|
|
2418
|
+
},
|
|
2419
|
+
},
|
|
2420
|
+
{
|
|
2421
|
+
name: 'list_breaks',
|
|
2422
|
+
description:
|
|
2423
|
+
'**대화에서 정한 것이 문서에 닿았나** — 연결된 채널(Slack)에서 「~하기로 했다」가 선 뒤 '
|
|
2424
|
+
+ '그 소속처의 결정 문서가 움직이지 않은 것을 센다.\n'
|
|
2425
|
+
+ '언제 부르는가: 일을 시작할 때(「지금 걸려 있는 게 뭐지」) · 사람이 "요즘 대화로만 정하고 '
|
|
2426
|
+
+ '문서가 안 남는다"고 할 때 · 결정을 적기 전에(이미 신호가 서 있으면 그것부터).\n'
|
|
2427
|
+
+ '**0 이면 0 이라고 말하고, 창구가 안 붙은 것(채널 0)과 신호 없음을 갈라 말한다.** '
|
|
2428
|
+
+ '자동 처방은 없다 — 열린 신호를 닫는 길은 사람이 결정을 적는 것(remember_decision)이다.',
|
|
2429
|
+
inputSchema: {
|
|
2430
|
+
type: 'object',
|
|
2431
|
+
properties: {
|
|
2432
|
+
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
2433
|
+
},
|
|
2434
|
+
required: ['tenant'],
|
|
2435
|
+
},
|
|
2436
|
+
},
|
|
2248
2437
|
{
|
|
2249
2438
|
name: 'search_cases',
|
|
2250
2439
|
description:
|
|
@@ -2260,7 +2449,7 @@ const TOOLS = [
|
|
|
2260
2449
|
inputSchema: {
|
|
2261
2450
|
type: 'object',
|
|
2262
2451
|
properties: {
|
|
2263
|
-
tenant: { type: 'string', description: '대상 소속처
|
|
2452
|
+
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
2264
2453
|
q: { type: 'string', description: '증상 문장. 원인이 아니라 현상으로.' },
|
|
2265
2454
|
limit: { type: 'integer', minimum: 1, maximum: 20, description: '몇 건까지(기본 5).' },
|
|
2266
2455
|
fast: { type: 'boolean', description: '의미 확장을 건너뛴다(빠르지만 덜 찾는다). 기본은 확장 켬 — '
|
|
@@ -2278,7 +2467,7 @@ const TOOLS = [
|
|
|
2278
2467
|
inputSchema: {
|
|
2279
2468
|
type: 'object',
|
|
2280
2469
|
properties: {
|
|
2281
|
-
tenant: { type: 'string', description: '대상 소속처
|
|
2470
|
+
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
2282
2471
|
path: { type: 'string', description: 'search_cases 결과의 path 그대로(예: cases/….md).' },
|
|
2283
2472
|
},
|
|
2284
2473
|
required: ['tenant', 'path'],
|
|
@@ -2294,7 +2483,7 @@ const TOOLS = [
|
|
|
2294
2483
|
inputSchema: {
|
|
2295
2484
|
type: 'object',
|
|
2296
2485
|
properties: {
|
|
2297
|
-
tenant: { type: 'string', description: '대상 소속처
|
|
2486
|
+
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
2298
2487
|
path: { type: 'string', description: '사례 경로(cases/….md).' },
|
|
2299
2488
|
verdict: { type: 'string', enum: ['applied', 'refuted', 'irrelevant'], description: '사람이 말한 판정.' },
|
|
2300
2489
|
note: { type: 'string', description: '한 줄 메모(선택, 200자).' },
|
|
@@ -2312,7 +2501,7 @@ const TOOLS = [
|
|
|
2312
2501
|
inputSchema: {
|
|
2313
2502
|
type: 'object',
|
|
2314
2503
|
properties: {
|
|
2315
|
-
tenant: { type: 'string', description: '대상 소속처
|
|
2504
|
+
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
2316
2505
|
title: { type: 'string', description: '검토 제목 — 메일 제목 「충돌 — <제목>」의 <제목> 그대로.' },
|
|
2317
2506
|
verdict: { type: 'string', enum: ['applied', 'refuted'], description: '이 세션의 판정.' },
|
|
2318
2507
|
type: { type: 'string', enum: ['contradiction', 'duplicate', 'missing-page'], description: '검토 종류(선택 — 충돌=contradiction·중복=duplicate·문서 필요=missing-page). 비우면 서버가 셋 중 있는 것을 고른다.' },
|
|
@@ -2335,7 +2524,7 @@ const TOOLS = [
|
|
|
2335
2524
|
inputSchema: {
|
|
2336
2525
|
type: 'object',
|
|
2337
2526
|
properties: {
|
|
2338
|
-
tenant: { type: 'string', description: '대상 소속처
|
|
2527
|
+
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
2339
2528
|
file: { type: 'integer', minimum: 1, description: '문서 번호(목록의 #). 없으면 목록.' },
|
|
2340
2529
|
},
|
|
2341
2530
|
required: ['tenant'],
|
|
@@ -2350,7 +2539,7 @@ const TOOLS = [
|
|
|
2350
2539
|
inputSchema: {
|
|
2351
2540
|
type: 'object',
|
|
2352
2541
|
properties: {
|
|
2353
|
-
tenant: { type: 'string', description: '대상 소속처
|
|
2542
|
+
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
2354
2543
|
page: {
|
|
2355
2544
|
type: 'integer', minimum: 1, maximum: 1000,
|
|
2356
2545
|
description: '목록 page(1부터, 최대 1000). 응답이 알려 준 next를 넣는다.',
|
|
@@ -2375,7 +2564,7 @@ const TOOLS = [
|
|
|
2375
2564
|
inputSchema: {
|
|
2376
2565
|
type: 'object',
|
|
2377
2566
|
properties: {
|
|
2378
|
-
tenant: { type: 'string', description: '대상 소속처
|
|
2567
|
+
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
2379
2568
|
uuid: { type: 'string', description: '세션 id. `--resume` 에 쓰는 그 값.' },
|
|
2380
2569
|
title: { type: 'string', description: '목록에서 알아볼 한 줄. 생략하면 빈 값.' },
|
|
2381
2570
|
dry_run: { type: 'boolean', description: '참이면 아무것도 저장하지 않고 검열 결과만 돌려준다.' },
|
|
@@ -2391,6 +2580,7 @@ const TOOLS = [
|
|
|
2391
2580
|
name: 'resume_session',
|
|
2392
2581
|
description:
|
|
2393
2582
|
'서버에 있는 대화를 **지금 이 창이 이어받는다.** 포털의 「이어받기」 버튼이 복사해 주는 문구가 이 도구를 부른다.\n' +
|
|
2583
|
+
'붙여넣기에 tenant·uuid 가 있으면 **지금 바로 이 도구를 부른다.** list_my_tenants 로 소속처를 되묻지 마라.\n' +
|
|
2394
2584
|
'한 번에 세 가지를 한다 — ① 그 대화의 요지를 받아 **여기서 바로 읽는다**(창을 새로 열 필요가 없다), ' +
|
|
2395
2585
|
'② 원본 jsonl 을 이 기계의 제자리에 놓는다(나중에 `claude --resume <uuid>` 로 원본을 열 수 있다), ' +
|
|
2396
2586
|
'③ 이 기계가 그 대화를 쥐었다고 서버에 알린다(다른 기계가 「누가 이어가는지」를 본다).\n' +
|
|
@@ -2399,7 +2589,7 @@ const TOOLS = [
|
|
|
2399
2589
|
inputSchema: {
|
|
2400
2590
|
type: 'object',
|
|
2401
2591
|
properties: {
|
|
2402
|
-
tenant: { type: 'string', description: '대상 소속처
|
|
2592
|
+
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다). 사용자 글의 tenant: 줄을 그대로 쓴다. 값이 있을 때 list_my_tenants 로 되묻지 않는다.' },
|
|
2403
2593
|
uuid: { type: 'string', description: '이어받을 세션 id.' },
|
|
2404
2594
|
into: { type: 'string', description: '원본 파일을 놓을 자리(생략하면 지금 폴더). '
|
|
2405
2595
|
+ '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 원본을 열 자리다.' },
|
|
@@ -2419,7 +2609,7 @@ const TOOLS = [
|
|
|
2419
2609
|
inputSchema: {
|
|
2420
2610
|
type: 'object',
|
|
2421
2611
|
properties: {
|
|
2422
|
-
tenant: { type: 'string', description: '대상 소속처
|
|
2612
|
+
tenant: { type: 'string', description: '대상 소속처 slug(이름도 받는다 — list_my_tenants 참조).' },
|
|
2423
2613
|
uuid: { type: 'string', description: '받을 세션 id.' },
|
|
2424
2614
|
into: { type: 'string', description: '어느 디렉터리에서 이어 갈 것인가(생략하면 지금 폴더). '
|
|
2425
2615
|
+ '`--resume` 은 그 폴더의 트랜스크립트를 찾으므로 이 값이 곧 이어 갈 자리다.' },
|
|
@@ -2447,7 +2637,7 @@ const TOOLS = [
|
|
|
2447
2637
|
inputSchema: {
|
|
2448
2638
|
type: 'object',
|
|
2449
2639
|
properties: {
|
|
2450
|
-
tenant: { type: 'string', description: '소속처
|
|
2640
|
+
tenant: { type: 'string', description: '소속처 slug(이름도 받는다). 모르면 list_my_tenants 로 먼저 확인한다' },
|
|
2451
2641
|
to: { type: 'string', description: '받는 쪽 — 세포 이름 · 세션 uuid · 또는 brain' },
|
|
2452
2642
|
body: { type: 'string', description: '할 말' },
|
|
2453
2643
|
subject: { type: 'string', description: '한 줄 제목(선택)' },
|
|
@@ -2693,7 +2883,7 @@ let tenantSaidAt = 0;
|
|
|
2693
2883
|
* **한 번도 안 돌았다**(`world-<root>.json` 의 `cursor` 가 null 이었다).
|
|
2694
2884
|
*/
|
|
2695
2885
|
function tenantNotice() {
|
|
2696
|
-
const mine =
|
|
2886
|
+
const mine = mySessionId();
|
|
2697
2887
|
if (!mine) return '';
|
|
2698
2888
|
try {
|
|
2699
2889
|
if (indexRead()[mine]?.tenant_id) return ''; // 붙어 있다 — 조용하다
|
|
@@ -2797,7 +2987,7 @@ let brainCursor = null; // null = 아직 색인에서 안 읽었다
|
|
|
2797
2987
|
const brainQueue = []; // 아직 안 말한 모순
|
|
2798
2988
|
|
|
2799
2989
|
async function brainPull() {
|
|
2800
|
-
const mine =
|
|
2990
|
+
const mine = mySessionId();
|
|
2801
2991
|
if (!mine) return;
|
|
2802
2992
|
const e = indexRead()[mine];
|
|
2803
2993
|
if (!e?.tenant || !e?.tenant_id) return; // 아직 어느 소속처인지 모른다
|
|
@@ -2812,7 +3002,7 @@ async function brainPull() {
|
|
|
2812
3002
|
const cwd = relHome(process.cwd());
|
|
2813
3003
|
const fresh = [];
|
|
2814
3004
|
for (let page = 0; page < 10; page++) {
|
|
2815
|
-
const r = await api('GET', '/api/world?tenant=' + encodeURIComponent(e
|
|
3005
|
+
const r = await api('GET', '/api/world?tenant=' + encodeURIComponent(tenantArgOf(e))
|
|
2816
3006
|
+ '&since=' + brainCursor + '&cwd=' + encodeURIComponent(cwd) + '&limit=' + BRAIN_PAGE);
|
|
2817
3007
|
const evs = Array.isArray(r?.events) ? r.events : [];
|
|
2818
3008
|
for (const ev of evs) if (ev?.kind === 'review' && ev?.ref) fresh.push(ev);
|
|
@@ -2960,10 +3150,18 @@ function introNameOf(line) {
|
|
|
2960
3150
|
}
|
|
2961
3151
|
let nameCache = null; // { file, size, name }
|
|
2962
3152
|
function myAgentName() {
|
|
2963
|
-
const uuid =
|
|
3153
|
+
const uuid = mySessionId();
|
|
2964
3154
|
if (!/^[0-9a-f-]{36}$/i.test(uuid)) return null;
|
|
2965
3155
|
const f = transcriptOf(uuid);
|
|
2966
|
-
if (!f)
|
|
3156
|
+
if (!f) {
|
|
3157
|
+
/*
|
|
3158
|
+
* Cursor 활성 대화 — 전사도 ListAgents 도 없다. 이름은 별칭일 뿐 열쇠는 uuid 이므로
|
|
3159
|
+
* (mail 레일 계약), 출처가 읽히는 결정적 별칭을 낸다: cursor-<uuid 앞 2자>.
|
|
3160
|
+
* 하네스 이름(app-2c)과 같은 문법이라 받는 쪽 화면·회신 규칙이 그대로 통한다.
|
|
3161
|
+
*/
|
|
3162
|
+
if (uuid === cursorActiveComposer()) return 'cursor-' + uuid.slice(0, 2);
|
|
3163
|
+
return null;
|
|
3164
|
+
}
|
|
2967
3165
|
let st;
|
|
2968
3166
|
try { st = fs.statSync(f); } catch { return null; }
|
|
2969
3167
|
if (nameCache?.file === f && nameCache.size === st.size) return nameCache.name;
|
|
@@ -3013,15 +3211,15 @@ function myAgentName() {
|
|
|
3013
3211
|
*/
|
|
3014
3212
|
const mailTenants = new Map(); // tenant 이름 → tenant_id(모르면 0). mail 도구가 성공할 때 적는다
|
|
3015
3213
|
function mailReady() {
|
|
3016
|
-
const mine =
|
|
3214
|
+
const mine = mySessionId();
|
|
3017
3215
|
if (!mine) return null;
|
|
3018
3216
|
const cred = credential();
|
|
3019
3217
|
if (!cred || cred.kind !== 'user') return null; // 세계 장부와 같다 — 서버가 사람 토큰만 받는다
|
|
3020
3218
|
const e = indexRead()[mine];
|
|
3021
3219
|
const set = new Map(mailTenants);
|
|
3022
|
-
|
|
3220
|
+
{ const a = tenantArgOf(e); if (a && !set.has(a)) set.set(a, Number(e.tenant_id) || 0); }
|
|
3023
3221
|
if (!set.size) return null; // 아직 어느 소속처에도 주소를 안 댔다
|
|
3024
|
-
return { mine, tenants: set, indexTenant: e
|
|
3222
|
+
return { mine, tenants: set, indexTenant: tenantArgOf(e), indexEntry: e };
|
|
3025
3223
|
}
|
|
3026
3224
|
|
|
3027
3225
|
/**
|
|
@@ -3037,7 +3235,8 @@ async function mailRegister(tenant, mine, force = false) {
|
|
|
3037
3235
|
tenant,
|
|
3038
3236
|
session: mine,
|
|
3039
3237
|
name: name || '',
|
|
3040
|
-
|
|
3238
|
+
// 신원이 어디서 왔는지 그대로 말한다 — Cursor 대화에 claude-code 이름표를 달지 않는다.
|
|
3239
|
+
client: mine === cursorActiveComposer() ? 'cursor' : 'claude-code',
|
|
3041
3240
|
cwd: relHome(process.cwd()),
|
|
3042
3241
|
});
|
|
3043
3242
|
}
|
|
@@ -3078,7 +3277,7 @@ async function mailPull() {
|
|
|
3078
3277
|
const r = await api('GET', '/api/sessions/' + encodeURIComponent(mine)
|
|
3079
3278
|
+ '?tenant=' + encodeURIComponent(indexTenant) + '&no_body=1');
|
|
3080
3279
|
if (r?.bake) {
|
|
3081
|
-
indexNote(mine, { tenant:
|
|
3280
|
+
indexNote(mine, { tenant: indexEntry.tenant, tenant_id: indexEntry.tenant_id, bake: r.bake });
|
|
3082
3281
|
}
|
|
3083
3282
|
} catch { /* 옛 서버는 이 칸이 없다 — 없으면 상태바가 「모름」으로 둔다 */ }
|
|
3084
3283
|
}
|
|
@@ -3106,7 +3305,7 @@ async function mailPullOne(tenant, mine, indexEntry) {
|
|
|
3106
3305
|
// 색인에는 색인의 소속처 것만 적는다(다른 곳의 커서는 프로세스 안에서만 — 잃어도 ack 가 중복을 막는다).
|
|
3107
3306
|
// 빈 폴링은 수신이 아니다 — 위 백신 커서와 같은 규율.
|
|
3108
3307
|
if (indexEntry?.tenant_id) {
|
|
3109
|
-
indexNote(mine, { tenant, tenant_id: indexEntry.tenant_id, mail_cursor: cursor },
|
|
3308
|
+
indexNote(mine, { tenant: indexEntry.tenant, tenant_id: indexEntry.tenant_id, mail_cursor: cursor },
|
|
3110
3309
|
rows.length ? 'rx' : '');
|
|
3111
3310
|
}
|
|
3112
3311
|
|
|
@@ -3337,15 +3536,20 @@ async function callTool(name, args = {}) {
|
|
|
3337
3536
|
const mark = (t) => (t.has_logo === false ? ' · 로고 없음' : '');
|
|
3338
3537
|
|
|
3339
3538
|
if (list.length === 1) {
|
|
3340
|
-
|
|
3341
|
-
|
|
3539
|
+
// 옛 서버는 slug 칸이 없다 — 그때는 이름만 말한다(없는 값을 지어내지 않는다).
|
|
3540
|
+
const slug0 = typeof list[0].slug === 'string' && list[0].slug !== '' ? list[0].slug : '';
|
|
3541
|
+
return `속한 곳이 하나입니다 — **${list[0].name}**${slug0 ? ` (\`${slug0}\`)` : ''}${mark(list[0])}\n`
|
|
3542
|
+
+ (slug0 ? '다른 도구의 `tenant` 에는 slug 를 쓰세요(이름도 받는다).'
|
|
3543
|
+
: '다른 도구의 `tenant` 에 이 이름을 그대로 쓰세요.')
|
|
3342
3544
|
+ (says ? `\n\n${says}` : '');
|
|
3343
3545
|
}
|
|
3344
3546
|
const lines = list.map((t) => {
|
|
3345
3547
|
const when = t.last_used_at ? String(t.last_used_at).slice(0, 10) : '기록 없음';
|
|
3346
|
-
|
|
3548
|
+
const sl = typeof t.slug === 'string' && t.slug !== '' ? ` (\`${t.slug}\`)` : '';
|
|
3549
|
+
return ` · ${t.name}${sl}${seen(t)}${mark(t)} · 최근 ${when}`;
|
|
3347
3550
|
});
|
|
3348
3551
|
return `속한 곳 ${list.length}곳입니다. **사용자에게 어디로 할지 물어보세요** — 고르지 마세요.\n`
|
|
3552
|
+
+ '이미 이 턴의 사용자 글에 slug 나 uuid 가 있으면 그것이 답입니다. 묻는 것은 값이 없을 때뿐입니다.\n'
|
|
3349
3553
|
+ `${lines.join('\n')}\n`
|
|
3350
3554
|
+ (says ? `\n${says}` : '');
|
|
3351
3555
|
}
|
|
@@ -3444,6 +3648,13 @@ async function callTool(name, args = {}) {
|
|
|
3444
3648
|
head.push(`[정본 ${owner} · 본문 판 body_rev=${r.body_rev ?? '?'}`
|
|
3445
3649
|
+ (r.sha256 ? ` · 지금 발행물 ${r.bytes}B sha256:${String(r.sha256).slice(0, 12)}…` : '')
|
|
3446
3650
|
+ `] — 고칠 때는 update_report 에 body_rev 를 함께 실으면, 그 사이 누가 고쳤을 때 409 로 멈춘다.`);
|
|
3651
|
+
// 그림은 서버가 보관한다(바이트는 에이전트를 안 지난다) — 되돌려 보낼 때의 계약을 여기서 말한다(#34).
|
|
3652
|
+
const kept = Array.isArray(r.data?.assets)
|
|
3653
|
+
? r.data.assets.filter((a) => typeof a?.data === 'string' && a.data.startsWith('(생략')).length : 0;
|
|
3654
|
+
if (kept > 0) {
|
|
3655
|
+
head.push(`그림 ${kept}장은 서버가 보관 중 — update_report 에 assets 를 **빼고** 보내면 그대로 유지, `
|
|
3656
|
+
+ '전부 지우려면 `assets: []`(sections[].plates·chapters[].asset 에서도 빼야 한다), 바꿀 장만 새 source/file 로.');
|
|
3657
|
+
}
|
|
3447
3658
|
// 데이터가 없으면 서버가 outline 을 함께 준다 — 그것이 지금 고객이 보는 것의 유일한 요약이다.
|
|
3448
3659
|
if (r.data === null && r.outline) head.push(outlineText(r.outline));
|
|
3449
3660
|
return `${head.join('\n')}\n${JSON.stringify(r.data, null, 2)}`;
|
|
@@ -3517,6 +3728,39 @@ async function callTool(name, args = {}) {
|
|
|
3517
3728
|
if (!facts.length) return '이 저장소에서 최근에 남이 바꾼 것은 없다.';
|
|
3518
3729
|
return `이 저장소에서 최근에 바뀐 것:\n${facts.map((f) => ` · ${f}`).join('\n')}`;
|
|
3519
3730
|
}
|
|
3731
|
+
case 'list_breaks': {
|
|
3732
|
+
const r = await api('GET', '/api/breaks?tenant=' + encodeURIComponent(args.tenant));
|
|
3733
|
+
const c = r.counts || {};
|
|
3734
|
+
const lines = [`${r.tenant} — 대화에서 정한 것이 문서에 닿았나`];
|
|
3735
|
+
if (!r.wired) {
|
|
3736
|
+
// 창구가 안 붙은 것과 신호 0 은 다른 말이다 — 0 을 「깨끗함」으로 읽게 두지 않는다.
|
|
3737
|
+
lines.push('아직 연결된 채널이 없다(신호 0 과 다른 상태) — 관리 화면에서 채널을 소속처에 연결해야 세기 시작한다.');
|
|
3738
|
+
return lines.join('\n');
|
|
3739
|
+
}
|
|
3740
|
+
lines.push(`열림 ${c.open ?? 0} · 문서에 닿음 ${c.answered ?? 0} · 사람 판정 맞다 ${c.confirmed ?? 0}/아니다 ${c.dismissed ?? 0} (채널 ${r.channels}곳)`);
|
|
3741
|
+
for (const b of r.open || []) {
|
|
3742
|
+
lines.push(` · [${String(b.signal_at).slice(0, 10)} ${b.channel}] ${b.excerpt} (문서 마감 ${String(b.deadline_at).slice(0, 10)})`);
|
|
3743
|
+
}
|
|
3744
|
+
if (!(r.open || []).length) lines.push('지금 열린 신호는 없다 — 0건은 0건이다.');
|
|
3745
|
+
else lines.push('여는 길: 그 결정을 remember_decision 으로 적으면 다음 읽기에서 닫힌다.');
|
|
3746
|
+
return lines.join('\n');
|
|
3747
|
+
}
|
|
3748
|
+
case 'remember_decision': {
|
|
3749
|
+
const r = await api('POST', '/api/wiki/decision?tenant=' + encodeURIComponent(args.tenant), {
|
|
3750
|
+
title: args.title, what: args.what,
|
|
3751
|
+
who: args.who || '', when: args.when || '', why: args.why || '',
|
|
3752
|
+
reopen: args.reopen || '', status: args.status || '', tags: args.tags || [],
|
|
3753
|
+
});
|
|
3754
|
+
const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
|
|
3755
|
+
// 안 적은 것을 **그렇게 말한다** — 조용히 열린 결정을 만들거나 계산 밖에 두지 않는다.
|
|
3756
|
+
if (!args.reopen) {
|
|
3757
|
+
lines.push('재개 조건을 안 적어 이 결정은 「아직 열린 것」으로 읽힌다. 끝난 결정이면 reopen 에 "조건 없음(종결)" 을 적을 것.');
|
|
3758
|
+
}
|
|
3759
|
+
if (!args.tags || !args.tags.length) {
|
|
3760
|
+
lines.push('갈래(tags)가 없어 이 결정은 「일」 후보 계산에 안 들어간다.');
|
|
3761
|
+
}
|
|
3762
|
+
return lines.join('\n');
|
|
3763
|
+
}
|
|
3520
3764
|
case 'remember_case': {
|
|
3521
3765
|
const r = await api('POST', '/api/wiki/case?tenant=' + encodeURIComponent(args.tenant), {
|
|
3522
3766
|
title: args.title, symptom: args.symptom, cause: args.cause || '', fix: args.fix,
|
|
@@ -3572,7 +3816,8 @@ async function callTool(name, args = {}) {
|
|
|
3572
3816
|
+ r.hits.map((h) => `· ${h.path} [${h.score}점]${h.status ? `\n ⚠ 상태: ${h.status}` : ''}`
|
|
3573
3817
|
+ `\n ${h.excerpt}`).join('\n')
|
|
3574
3818
|
+ '\n전문은 get_case(tenant, path) — 처방·상태까지 그대로 읽는다.'
|
|
3575
|
-
+ (
|
|
3819
|
+
+ (typeof r.human_url === 'string' && r.human_url !== '' ? ` 사람 화면: ${r.human_url}?p=<path>`
|
|
3820
|
+
: Number.isInteger(r.tenant_id) ? ` 사람 화면: wiki?t=${r.tenant_id}&p=<path>` : '');
|
|
3576
3821
|
}
|
|
3577
3822
|
case 'get_case': {
|
|
3578
3823
|
// 서버 창구(/api/wiki/page)는 이미 있었다 — MCP 가 안 부르고 있었을 뿐이다(2026-08-25).
|
|
@@ -3662,7 +3907,7 @@ async function callTool(name, args = {}) {
|
|
|
3662
3907
|
// **본 것을 적어 둔다.** 이 짝(uuid → 고객사 id)이 없으면 심박을 보낼 수가 없다 —
|
|
3663
3908
|
// 프레임에는 고객사 이름이 아니라 id 가 실린다.
|
|
3664
3909
|
for (const s of sessions) {
|
|
3665
|
-
if (!s.deleted) indexNote(s.uuid, { tenant: r.tenant, tenant_id: s.tenant_id, head_id: s.head_id, head_ms: s.head_ms });
|
|
3910
|
+
if (!s.deleted) indexNote(s.uuid, { tenant: r.tenant, tenant_id: s.tenant_id, head_id: s.head_id, head_ms: s.head_ms, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) });
|
|
3666
3911
|
}
|
|
3667
3912
|
const p = r.pagination && typeof r.pagination === 'object' ? r.pagination : null;
|
|
3668
3913
|
let count = `세션 ${sessions.length}건`;
|
|
@@ -3738,7 +3983,7 @@ async function callTool(name, args = {}) {
|
|
|
3738
3983
|
const q = `?tenant=${encodeURIComponent(args.tenant)}`;
|
|
3739
3984
|
const r = await api('GET',
|
|
3740
3985
|
`/api/sessions/${encodeURIComponent(args.uuid)}${q}&no_body=1&digest=1&digest_chars=${chars}`);
|
|
3741
|
-
indexNote(args.uuid, { tenant: r.tenant || args.tenant, tenant_id: r.tenant_id, head_id: r.head_id, head_ms: r.head_ms }, 'rx');
|
|
3986
|
+
indexNote(args.uuid, { 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');
|
|
3742
3987
|
|
|
3743
3988
|
// 원본도 제자리에 놓는다 — **덮지 않는다**(pull_session 과 같은 이유).
|
|
3744
3989
|
const into = args.into || process.cwd();
|
|
@@ -3799,7 +4044,7 @@ async function callTool(name, args = {}) {
|
|
|
3799
4044
|
`/api/sessions/${encodeURIComponent(args.uuid)}?tenant=${encodeURIComponent(args.tenant)}&raw=1`,
|
|
3800
4045
|
target);
|
|
3801
4046
|
const m = limMeta(headers);
|
|
3802
|
-
indexNote(args.uuid, { tenant: args.tenant, tenant_id: m.tenant_id, head_id: m.head_id, head_ms: m.head_ms }, 'rx');
|
|
4047
|
+
indexNote(args.uuid, { tenant: args.tenant, tenant_id: m.tenant_id, head_id: m.head_id, head_ms: m.head_ms, ...(m.tenant_slug ? { tenant_slug: m.tenant_slug } : {}) }, 'rx');
|
|
3803
4048
|
// 헤더를 안 주는 옛 서버면 **지어내지 않는다** — 받은 바이트만 말한다.
|
|
3804
4049
|
const size = `${((m.bytes ?? bytes) / 1024).toFixed(0)}KB`;
|
|
3805
4050
|
return `받았습니다 — ${args.uuid.slice(0, 8)} · ${m.lines === null ? '' : `${m.lines}줄 · `}${size}\n`
|
|
@@ -3808,10 +4053,10 @@ async function callTool(name, args = {}) {
|
|
|
3808
4053
|
+ (m.redacted ? `※ 가린 자리 ${m.redacted}곳 — 도구 실행 결과는 안 실려 옵니다. 필요하면 다시 실행하세요.` : '');
|
|
3809
4054
|
}
|
|
3810
4055
|
case 'mail': {
|
|
3811
|
-
const mine =
|
|
4056
|
+
const mine = mySessionId();
|
|
3812
4057
|
if (!mine) {
|
|
3813
4058
|
return '이 세션의 번호를 모른다 — 주소를 댈 수 없어 보낼 수 없다'
|
|
3814
|
-
+ '(CLAUDE_CODE_SESSION_ID
|
|
4059
|
+
+ '(CLAUDE_CODE_SESSION_ID 도, Cursor 활성 대화도 없는 환경이다).';
|
|
3815
4060
|
}
|
|
3816
4061
|
/*
|
|
3817
4062
|
* 주소가 없으면 먼저 댄다. **이름이 없으면 서버가 409 로 거절**하므로,
|
|
@@ -3853,7 +4098,7 @@ async function callTool(name, args = {}) {
|
|
|
3853
4098
|
// ⚠ 서버가 tenant_id 를 아직 안 주는 판(재배포 전)이어도 **받기는 이름만으로 된다** —
|
|
3854
4099
|
// GET /api/mail 은 tenant 이름으로 묻는다. 색인(indexNote)만 id 가 있어야 적힌다.
|
|
3855
4100
|
mailTenants.set(args.tenant, r?.tenant_id || 0); // 이 소속처에서도 받는다 — 합집합, 폴백 아님
|
|
3856
|
-
if (r?.tenant_id && !indexRead()[mine]?.tenant) indexNote(mine, { tenant: args.tenant, tenant_id: r.tenant_id });
|
|
4101
|
+
if (r?.tenant_id && !indexRead()[mine]?.tenant) indexNote(mine, { tenant: args.tenant, tenant_id: r.tenant_id, ...(r.tenant_slug ? { tenant_slug: r.tenant_slug } : {}) });
|
|
3857
4102
|
return heldNote + `«${r.to}» 앞으로 보냈다 — 실 ${r.thread} · ${r.turn}번째.\n`
|
|
3858
4103
|
+ `받는 쪽이 보게 될 글자는 이것이다:\n${r.preview}\n`
|
|
3859
4104
|
+ (r.says ? `${r.says}\n` : '')
|
|
@@ -3971,13 +4216,15 @@ async function handle(line) {
|
|
|
3971
4216
|
'',
|
|
3972
4217
|
'「남겨줘 / 기억해줘 / 업데이트 / 갱신 / 동기화 / 올려둬」는 **무엇을 남기느냐로 갈린다**:',
|
|
3973
4218
|
'· 알아낸 것 하나(증상과 처방) → remember_case (같은 제목이면 덮어쓰므로 갱신도 이것)',
|
|
4219
|
+
'· 정한 것(증상이 없다 — 기획·디자인의 판단, 반려, "이렇게 가기로 했다") → remember_decision',
|
|
3974
4220
|
'· 지금 이 대화 전체 → push_session (다른 기계가 이어받게)',
|
|
3975
4221
|
'· 고객에게 나갈 문서 → publish_report / update_report',
|
|
3976
4222
|
'무엇인지 모호하면 묻기 전에 search_cases 로 먼저 뒤져 본다 — 이미 있으면 갱신이 답이다.',
|
|
3977
4223
|
'',
|
|
3978
4224
|
'자격은 **`login` 으로 받는 SSO 하나면 된다** — 그것으로 내가 속한 소속처 전부에 닿는다.',
|
|
3979
4225
|
'키(rpk_)도 쓸 수 있지만 **한 소속처에만** 매이고, 발행할 때만 그쪽이 먼저 쓰인다.',
|
|
3980
|
-
'어디로 갈지는 tenant 에 소속처
|
|
4226
|
+
'어디로 갈지는 tenant 에 소속처 **slug** 로 적는다(이름도 받는다 — 모를 때만 list_my_tenants).',
|
|
4227
|
+
'사용자 글에 tenant·uuid 가 있으면 그대로 쓰고 되묻지 않는다. 값을 모르면 지어내지 말고 사용자에게 묻는다 —',
|
|
3981
4228
|
'내 소속이 아닌 곳은 존재도 안 보이므로, 404 는 "없다"가 아니라 "내 것이 아니다"일 수 있다.',
|
|
3982
4229
|
].join('\n'),
|
|
3983
4230
|
};
|
|
@@ -4043,6 +4290,58 @@ const IS_MAIN = (() => {
|
|
|
4043
4290
|
* 자리는 판이 바뀌어도 안 움직이는 `~/.config/genetics/bin/gcell` 이다. 훅이 그 경로를
|
|
4044
4291
|
* 가리키므로, 갈아 끼워도 훅을 다시 걸 필요가 없다.
|
|
4045
4292
|
*/
|
|
4293
|
+
/*
|
|
4294
|
+
* ── `gcell` 을 PATH 에 건다 (078 문턱 ④) ──────────────────────────────────
|
|
4295
|
+
* 바이너리는 `~/.config/genetics/bin` 에 깔리는데 그 폴더는 어느 셸의 PATH 에도 없다. 그래서
|
|
4296
|
+
* 사람에게 안내하는 실행법이 「api.env 를 grep 해 cut 한 경로」 두 줄이었다 — 파이프를 읽을 줄
|
|
4297
|
+
* 아는 사람만 세포에게 물을 수 있었다(SKILL.md 실측 2026-08-27). 심링크 하나면 `gcell` 로 끝난다.
|
|
4298
|
+
*
|
|
4299
|
+
* 자리 고르기는 **순수 함수**로 뺀다(smoke 가 떼어내 잰다): PATH 에 있고 · 실재하고 · 쓸 수 있는
|
|
4300
|
+
* 첫 후보. 없는 폴더를 만들거나 rc 파일을 고치지 않는다 — 남의 셸 설정에 손대는 설치는 못 믿는다.
|
|
4301
|
+
*/
|
|
4302
|
+
const CELL_LINK_CANDIDATES = ['/opt/homebrew/bin', '/usr/local/bin', path.join(os.homedir(), '.local/bin')];
|
|
4303
|
+
function cellLinkDir(pathEnv, candidates, exists, writable) {
|
|
4304
|
+
const onPath = new Set(String(pathEnv || '').split(path.delimiter).filter(Boolean));
|
|
4305
|
+
for (const d of candidates) {
|
|
4306
|
+
if (onPath.has(d) && exists(d) && writable(d)) return d;
|
|
4307
|
+
}
|
|
4308
|
+
return null;
|
|
4309
|
+
}
|
|
4310
|
+
function cellLinkOnPath() {
|
|
4311
|
+
const dir = cellLinkDir(process.env.PATH, CELL_LINK_CANDIDATES,
|
|
4312
|
+
(d) => { try { return fs.statSync(d).isDirectory(); } catch { return false; } },
|
|
4313
|
+
(d) => { try { fs.accessSync(d, fs.constants.W_OK); return true; } catch { return false; } });
|
|
4314
|
+
if (!dir) {
|
|
4315
|
+
return `PATH 에 걸 자리를 못 찾았다(${CELL_LINK_CANDIDATES.join(' · ')} 중 PATH 에 있고 쓸 수 있는 곳이 없다) — 전체 경로 ${CELL_PATH} 로 부를 것.`;
|
|
4316
|
+
}
|
|
4317
|
+
const link = path.join(dir, 'gcell');
|
|
4318
|
+
try {
|
|
4319
|
+
const cur = fs.lstatSync(link);
|
|
4320
|
+
if (cur.isSymbolicLink() && fs.readlinkSync(link) === CELL_PATH) return ''; // 이미 걸려 있다 — 매 부팅 말하면 소음이다
|
|
4321
|
+
if (!cur.isSymbolicLink()) return `${link} 에 다른 것이 있어 안 건드렸다 — 전체 경로 ${CELL_PATH} 로 부를 것.`;
|
|
4322
|
+
fs.unlinkSync(link); // 우리 것이 아닌 심링크가 아니라 **옛 자리를 가리키는 우리 심링크**다 — 갈아 끼운다
|
|
4323
|
+
} catch { /* 없다 — 새로 건다 */ }
|
|
4324
|
+
try {
|
|
4325
|
+
fs.symlinkSync(CELL_PATH, link);
|
|
4326
|
+
return `\`gcell\` 을 PATH 에 걸었다(${link}).`;
|
|
4327
|
+
} catch (e) {
|
|
4328
|
+
return `\`gcell\` 을 PATH 에 못 걸었다(${link}): ${e.message} — 전체 경로 ${CELL_PATH} 로 부를 것.`;
|
|
4329
|
+
}
|
|
4330
|
+
}
|
|
4331
|
+
function cellUnlinkOnPath() {
|
|
4332
|
+
const out = [];
|
|
4333
|
+
for (const d of CELL_LINK_CANDIDATES) {
|
|
4334
|
+
const link = path.join(d, 'gcell');
|
|
4335
|
+
try {
|
|
4336
|
+
if (fs.lstatSync(link).isSymbolicLink() && fs.readlinkSync(link) === CELL_PATH) {
|
|
4337
|
+
fs.unlinkSync(link);
|
|
4338
|
+
out.push(`PATH 의 심링크를 걷었다: ${link}`);
|
|
4339
|
+
}
|
|
4340
|
+
} catch { /* 없다 */ }
|
|
4341
|
+
}
|
|
4342
|
+
return out;
|
|
4343
|
+
}
|
|
4344
|
+
|
|
4046
4345
|
function cellInstall({ hooks = true, ifRepo = false } = {}) {
|
|
4047
4346
|
const out = [];
|
|
4048
4347
|
const t = cellTarget();
|
|
@@ -4067,6 +4366,9 @@ function cellInstall({ hooks = true, ifRepo = false } = {}) {
|
|
|
4067
4366
|
if (got !== want) return { ok: false, out: ['세포를 깔았는데 지문이 안 맞는다 — 쓰지 않는다.'] };
|
|
4068
4367
|
out.push(`세포를 ${CELL_PATH} 에 깔았다(${VERSION} · ${want.slice(0, 12)}…).`);
|
|
4069
4368
|
}
|
|
4369
|
+
// 판이 같아도 링크는 맞춘다 — 옛 판이 링크 없이 깔린 기계가 있다(이 기계가 그랬다).
|
|
4370
|
+
const linked = cellLinkOnPath();
|
|
4371
|
+
if (linked) out.push(linked);
|
|
4070
4372
|
if (hooks) {
|
|
4071
4373
|
// `ifRepo` 는 **자동으로 부를 때만** 준다 — 사람이 `install_cell` 을 부른 자리는 자기가
|
|
4072
4374
|
// 정한 곳이지만, 부팅마다 도는 걸음은 git 저장소가 아닌 폴더에 설정을 만들면 안 된다.
|
|
@@ -4099,6 +4401,8 @@ function cellUninstall() {
|
|
|
4099
4401
|
} else {
|
|
4100
4402
|
out.push('깔린 세포가 없다.');
|
|
4101
4403
|
}
|
|
4404
|
+
// PATH 에 건 심링크도 걷는다 — 남겨 두면 `gcell` 이 없는 파일을 가리킨다(dangling).
|
|
4405
|
+
out.push(...cellUnlinkOnPath());
|
|
4102
4406
|
// 자격 파일 옆의 경로 줄도 걷는다 — 남겨 두면 다음 판이 없는 것을 가리킨다.
|
|
4103
4407
|
try {
|
|
4104
4408
|
const env = path.join(os.homedir(), '.config/genetics/api.env');
|