genetics-mcp 2.16.1 → 2.16.3
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 +296 -44
- package/package.json +1 -1
- package/skills/genetics-report/references/lim-portal-format.md +25 -1
|
Binary file
|
|
Binary file
|
package/bin/manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"aarch64-apple-darwin": "
|
|
3
|
-
"aarch64-unknown-linux-musl": "
|
|
4
|
-
"x86_64-apple-darwin": "
|
|
5
|
-
"x86_64-unknown-linux-musl": "
|
|
2
|
+
"aarch64-apple-darwin": "d13eb43d93b2c5551620bde8f25caaaeb0e7952a00e96add790999e537496e88",
|
|
3
|
+
"aarch64-unknown-linux-musl": "d834aba463313110483e1b6d6f5a204093d82846372e6a9dcd54e7f0019b1c66",
|
|
4
|
+
"x86_64-apple-darwin": "11a0aa685c3d687b8a6c2a51377a4a70a5c6c6527a43d7d58c62231622d54160",
|
|
5
|
+
"x86_64-unknown-linux-musl": "4ac0ef43c50a5c1e70832c86cc98f34411e8e63d011601bb09aaf62967f4018d"
|
|
6
6
|
}
|
|
Binary file
|
|
Binary file
|
package/genetics.mjs
CHANGED
|
@@ -1073,6 +1073,13 @@ const REPORT_DATA = {
|
|
|
1073
1073
|
how_to_check: { type: 'string' },
|
|
1074
1074
|
remaining: { type: 'string', description: '고객 확인이 필요한 사항(선택)' },
|
|
1075
1075
|
source_url: { type: 'string', description: 'http(s) 제보 원본 링크(선택)' },
|
|
1076
|
+
uid: {
|
|
1077
|
+
type: 'string',
|
|
1078
|
+
description: '이 건의 **불변 이름**(영소문자·숫자 6자). 안 주면 서버가 발급한다. '
|
|
1079
|
+
+ '★ get_report 가 준 값이 있으면 **그대로 되돌려 보내라** — 절 순서를 바꾸거나 '
|
|
1080
|
+
+ '축을 갈아엎어도 이 이름이 같으면 그 건에 달린 메모가 안 떨어진다. '
|
|
1081
|
+
+ '흘리면 제목으로 다시 잇고, 그것도 안 되면 새 이름이 발급된다.',
|
|
1082
|
+
},
|
|
1076
1083
|
},
|
|
1077
1084
|
// group 만 필수다 — 없는 그룹에 속한 건은 어디에도 안 실려 조용히 사라진다.
|
|
1078
1085
|
required: ['group'],
|
|
@@ -1303,6 +1310,37 @@ function cursorTranscriptOf(uuid) {
|
|
|
1303
1310
|
return null;
|
|
1304
1311
|
}
|
|
1305
1312
|
|
|
1313
|
+
function isCursorTranscript(f) {
|
|
1314
|
+
return typeof f === 'string' && f.includes(`${path.sep}agent-transcripts${path.sep}`);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function cursorAliasOf(uuid) {
|
|
1318
|
+
return 'cursor-' + String(uuid).slice(0, 2);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/** 훅이 적어 둔 (세션, 뿌리). 라벨 없는 MCP 헬퍼가 sqlite 를 추측하지 않게. */
|
|
1322
|
+
function cursorActiveFromLedger() {
|
|
1323
|
+
const dir = process.env.GCELL_STATE_DIR || path.join(os.homedir(), '.config', 'genetics', 'cell');
|
|
1324
|
+
let cwd = process.cwd();
|
|
1325
|
+
try { cwd = fs.realpathSync(cwd); } catch { /* 없으면 준 값 */ }
|
|
1326
|
+
let names;
|
|
1327
|
+
try { names = fs.readdirSync(dir); } catch { return { id: '', folder: '' }; }
|
|
1328
|
+
let best = null;
|
|
1329
|
+
for (const name of names) {
|
|
1330
|
+
if (!name.startsWith('active-cursor-') || !name.endsWith('.json')) continue;
|
|
1331
|
+
let v;
|
|
1332
|
+
try { v = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')); } catch { continue; }
|
|
1333
|
+
const root = String(v.root || '');
|
|
1334
|
+
const id = String(v.session || '');
|
|
1335
|
+
if (!root || !/^[0-9a-f-]{36}$/i.test(id)) continue;
|
|
1336
|
+
const at = Number(v.at || 0);
|
|
1337
|
+
if (at && (Date.now() / 1000) - at > 6 * 3600) continue;
|
|
1338
|
+
if (!(cwd === root || cwd.startsWith(root + path.sep))) continue;
|
|
1339
|
+
if (!best || at > best.at) best = { id, folder: root, at };
|
|
1340
|
+
}
|
|
1341
|
+
return best || { id: '', folder: '' };
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1306
1344
|
/* ── Cursor 신원 ──────────────────────────────────────────────────────────
|
|
1307
1345
|
*
|
|
1308
1346
|
* Cursor 는 MCP 자식에게 대화(세션) id 를 **안 준다** — 실측(2026-08-27, pid 98284 env):
|
|
@@ -1331,40 +1369,60 @@ let cursorSeen = { at: 0, id: '', folder: '' }; // 15초 캐시 — 도구마
|
|
|
1331
1369
|
|
|
1332
1370
|
function cursorActiveComposer() {
|
|
1333
1371
|
if (process.env.CLAUDE_CODE_SESSION_ID) return '';
|
|
1334
|
-
const label = process.env.CURSOR_WORKSPACE_LABEL || '';
|
|
1335
|
-
if (!label) return ''; // 창을 못 고르면 추측하지 않는다
|
|
1336
1372
|
const now = Date.now();
|
|
1337
|
-
if (now - cursorSeen.at < 15000) return cursorSeen.id;
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1373
|
+
if (now - cursorSeen.at < 15000 && cursorSeen.id) return cursorSeen.id;
|
|
1374
|
+
|
|
1375
|
+
// 에이전트 실행 env — 라벨·sqlite 보다 먼저. 36자 uuid 가 아니면 버린다.
|
|
1376
|
+
const envId = process.env.CURSOR_CONVERSATION_ID || '';
|
|
1377
|
+
if (/^[0-9a-f-]{36}$/i.test(envId)) {
|
|
1378
|
+
let folder = '';
|
|
1379
|
+
try { folder = fs.realpathSync(process.cwd()); } catch { folder = process.cwd(); }
|
|
1380
|
+
cursorSeen = { at: now, id: envId, folder };
|
|
1381
|
+
return envId;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
const label = process.env.CURSOR_WORKSPACE_LABEL || '';
|
|
1385
|
+
if (label) {
|
|
1386
|
+
try {
|
|
1387
|
+
const wsRoot = path.join(CURSOR_ROOT, 'workspaceStorage');
|
|
1388
|
+
let best = null; // 같은 이름의 창이 여럿이면 가장 최근에 움직인 것
|
|
1389
|
+
for (const d of fs.readdirSync(wsRoot)) {
|
|
1390
|
+
const dir = path.join(wsRoot, d);
|
|
1391
|
+
let folder = '';
|
|
1392
|
+
try {
|
|
1393
|
+
folder = String(JSON.parse(fs.readFileSync(path.join(dir, 'workspace.json'), 'utf8')).folder || '');
|
|
1394
|
+
} catch { continue; }
|
|
1395
|
+
if (!folder.startsWith('file://')) continue;
|
|
1396
|
+
const wsPath = decodeURIComponent(folder.slice('file://'.length)).replace(/\/+$/, '');
|
|
1397
|
+
if (path.basename(wsPath) !== label) continue;
|
|
1398
|
+
let mt = 0;
|
|
1399
|
+
try { mt = fs.statSync(path.join(dir, 'state.vscdb')).mtimeMs; } catch { continue; }
|
|
1400
|
+
if (!best || mt > best.mt) best = { dir, wsPath, mt };
|
|
1401
|
+
}
|
|
1402
|
+
if (best) {
|
|
1403
|
+
const r = spawnSync('/usr/bin/sqlite3',
|
|
1404
|
+
['-readonly', path.join(best.dir, 'state.vscdb'),
|
|
1405
|
+
"SELECT value FROM ItemTable WHERE key='composer.composerData'"],
|
|
1406
|
+
{ encoding: 'utf8', timeout: 2000 });
|
|
1407
|
+
if (r.status === 0) {
|
|
1408
|
+
const j = JSON.parse((r.stdout || '').trim() || '{}');
|
|
1409
|
+
const v = j.lastFocusedComposerIds || j.selectedComposerIds;
|
|
1410
|
+
const id = Array.isArray(v) ? String(v[0] || '') : '';
|
|
1411
|
+
if (/^[0-9a-f-]{36}$/i.test(id)) {
|
|
1412
|
+
cursorSeen = { at: now, id, folder: best.wsPath };
|
|
1413
|
+
return id;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
} catch { /* 아래 장부로 */ }
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const led = cursorActiveFromLedger();
|
|
1421
|
+
if (led.id) {
|
|
1422
|
+
cursorSeen = { at: now, id: led.id, folder: led.folder };
|
|
1423
|
+
return led.id;
|
|
1424
|
+
}
|
|
1425
|
+
return '';
|
|
1368
1426
|
}
|
|
1369
1427
|
|
|
1370
1428
|
/** 위 해석기가 고른 창의 **저장소 폴더** — 소속처 입양(같은 폴더 규칙)이 쓴다. */
|
|
@@ -1792,16 +1850,16 @@ function indexAdoptCurrent() {
|
|
|
1792
1850
|
if (idx[mine]?.tenant_id) return; // 이미 등록돼 있다
|
|
1793
1851
|
const f = transcriptOf(mine);
|
|
1794
1852
|
/*
|
|
1795
|
-
* 폴더 열쇠 — 입양 규칙(같은 폴더 = 같은 소속처)의 「폴더」는 전사 디렉터리다.
|
|
1796
|
-
* Cursor
|
|
1797
|
-
*
|
|
1798
|
-
* 같은 칸에 선다 — report-portal 창의 Cursor 대화는 report-portal 세션들을 따라간다.
|
|
1853
|
+
* 폴더 열쇠 — 입양 규칙(같은 폴더 = 같은 소속처)의 「폴더」는 Claude 전사 디렉터리다.
|
|
1854
|
+
* Cursor jsonl 의 dirname 은 `…/agent-transcripts/<uuid>` 라 Claude 칸과 안 맞는다
|
|
1855
|
+
* (조각 3 회귀, 2026-08-31). 창의 저장소 폴더를 같은 슬러그 규칙으로 만든다.
|
|
1799
1856
|
*/
|
|
1800
1857
|
let myDir = '';
|
|
1801
|
-
if (f)
|
|
1802
|
-
else if (mine && mine === cursorActiveComposer()) {
|
|
1858
|
+
if (isCursorTranscript(f) || (!f && mine === cursorActiveComposer())) {
|
|
1803
1859
|
const folder = cursorWorkspaceFolder();
|
|
1804
1860
|
if (folder) myDir = transcriptDir(folder);
|
|
1861
|
+
} else if (f) {
|
|
1862
|
+
myDir = path.dirname(f);
|
|
1805
1863
|
}
|
|
1806
1864
|
if (!myDir) return; // 이 기계에 사본도, 창 귀속도 없다
|
|
1807
1865
|
const rows = [];
|
|
@@ -2255,6 +2313,59 @@ const TOOLS = [
|
|
|
2255
2313
|
required: ['id'],
|
|
2256
2314
|
},
|
|
2257
2315
|
},
|
|
2316
|
+
{
|
|
2317
|
+
name: 'list_notes',
|
|
2318
|
+
description:
|
|
2319
|
+
'보고서에 **저자가 달아 둔 메모**를 읽는다. 고객에게는 안 보이는 저자↔에이전트 전용 글이다.\n'
|
|
2320
|
+
+ '각 메모의 `anchor.path`(예 `case:k7m2p9/fix`)가 **발행 데이터의 어느 칸을 고치라는 것인지**를 '
|
|
2321
|
+
+ '그대로 말한다 — 그 칸을 고쳐 update_report 로 되돌려 보내고, resolve_note 로 반영을 표시한다.\n'
|
|
2322
|
+
+ '`anchor_state` 가 `unmoored`·`reparented` 면 문서가 바뀌어 원래 자리를 잃은 것이다 — '
|
|
2323
|
+
+ '그래도 메모는 남아 있고, `anchor.quote_exact` 가 저자가 무엇을 보고 썼는지 말한다.',
|
|
2324
|
+
inputSchema: {
|
|
2325
|
+
type: 'object',
|
|
2326
|
+
properties: {
|
|
2327
|
+
id: { type: 'integer', description: '보고서 id' },
|
|
2328
|
+
state: { type: 'string', description: '`open`(기본, 아직 반영 안 한 것) 또는 `all`' },
|
|
2329
|
+
uid: { type: 'string', description: '그 건·절의 uid 에 달린 것만(선택)' },
|
|
2330
|
+
},
|
|
2331
|
+
required: ['id'],
|
|
2332
|
+
},
|
|
2333
|
+
},
|
|
2334
|
+
{
|
|
2335
|
+
name: 'reply_note',
|
|
2336
|
+
description:
|
|
2337
|
+
'메모에 **답글로** 회신한다. 이견이 있거나 물을 것이 있으면 대화창이 아니라 여기에 쓴다 — '
|
|
2338
|
+
+ '저자는 자기가 메모를 단 그 자리에서 답을 본다.\n'
|
|
2339
|
+
+ '고칠 수 없는 이유, 다르게 본 근거, 되묻는 것이 여기 온다. '
|
|
2340
|
+
+ '고쳤으면 답글이 아니라 resolve_note 다.',
|
|
2341
|
+
inputSchema: {
|
|
2342
|
+
type: 'object',
|
|
2343
|
+
properties: {
|
|
2344
|
+
id: { type: 'integer', description: '보고서 id' },
|
|
2345
|
+
note: { type: 'integer', description: '답할 메모 id(list_notes 가 준 값)' },
|
|
2346
|
+
body: { type: 'string', description: '회신. 개조식으로 쓴다' },
|
|
2347
|
+
},
|
|
2348
|
+
required: ['id', 'note', 'body'],
|
|
2349
|
+
},
|
|
2350
|
+
},
|
|
2351
|
+
{
|
|
2352
|
+
name: 'resolve_note',
|
|
2353
|
+
description:
|
|
2354
|
+
'그 메모를 **반영했다**고 표시한다. 반영이 실린 판번호(body_rev)가 함께 남아 '
|
|
2355
|
+
+ '「무엇에 반영됐는지」를 나중에도 안다.\n'
|
|
2356
|
+
+ '★ 고치기 **전에** 부르지 말 것 — 이 표시가 곧 「이 메모는 끝났다」이고, '
|
|
2357
|
+
+ '표시하는 순간 미반영 목록에서 빠져 다시 눈에 안 띈다.',
|
|
2358
|
+
inputSchema: {
|
|
2359
|
+
type: 'object',
|
|
2360
|
+
properties: {
|
|
2361
|
+
id: { type: 'integer', description: '보고서 id' },
|
|
2362
|
+
note: { type: 'integer', description: '메모 id' },
|
|
2363
|
+
rev: { type: 'integer', description: '반영이 실린 body_rev. 안 주면 지금 판' },
|
|
2364
|
+
resolved: { type: 'boolean', description: '거짓이면 미반영으로 되돌린다' },
|
|
2365
|
+
},
|
|
2366
|
+
required: ['id', 'note'],
|
|
2367
|
+
},
|
|
2368
|
+
},
|
|
2258
2369
|
{
|
|
2259
2370
|
name: 'publish_report',
|
|
2260
2371
|
description:
|
|
@@ -2413,6 +2524,8 @@ const TOOLS = [
|
|
|
2413
2524
|
reopen: { type: 'string', description: '무슨 조건이면 다시 여나. **비우면 「열린 결정」으로 읽힌다.** 끝났으면 "조건 없음(종결)".' },
|
|
2414
2525
|
status: { type: 'string', description: '상태 — "유효"(기본) · "뒤집힘 → decisions/<그 결정>" · "조건 충족 → <무엇이> (날짜)".' },
|
|
2415
2526
|
tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8). ★ 이것이 있어야 이 결정이 「일」 후보 계산에 들어간다.' },
|
|
2527
|
+
work: { type: 'string', description: '**이 결정이 곧 과업이면** 그 일의 열쇠(영문 소문자·숫자·붙임표, 예: hnl-wait). 적으면 다음 바퀴에 그 이름의 일이 「계획」으로 선다 — 착수 전에 세우는 정문이다. 이미 있는 이름이면 조용히 그대로 둔다.' },
|
|
2528
|
+
order: { type: 'array', items: { type: 'string' }, description: '일 사이 **순서**를 정하는 결정이면 일의 열쇠를 앞선 것부터(최대 24). 표에 저장하지 않고 일 목록이 이 결정을 읽어 그 순서로 선다 — 순서가 바뀌면 새 결정을 적는다.' },
|
|
2416
2529
|
},
|
|
2417
2530
|
required: ['tenant', 'title', 'what'],
|
|
2418
2531
|
},
|
|
@@ -2440,7 +2553,7 @@ const TOOLS = [
|
|
|
2440
2553
|
'**이 소속처에 지금 어떤 일이 있나** — 열린 일의 목록·상태(계획/진행 중/멈춤)·세션 수.\n'
|
|
2441
2554
|
+ '언제 부르는가: 작업을 시작할 때(「내가 붙을 일이 이미 있나」 — 새로 벌이기 전에 먼저) · '
|
|
2442
2555
|
+ '사람이 「지금 뭐가 진행 중이지」라고 물을 때 · 세션 요지에 「어느 일에도 속해 있지 않다」가 왔을 때.\n'
|
|
2443
|
-
+ '「계획」 =
|
|
2556
|
+
+ '「계획」 = 착수 전에 선 일(세션 0) — 신호(태그 있는 결정·세션 무리)가 쌓이면 AI 가 세운다. 이 목록에서 이름을 보고 그 방향의 작업이면 '
|
|
2444
2557
|
+ 'claim 으로 **착수를 선언**한다(즉시 잠정 효력 — 세션 요지·화면에 「선언(잠정)」이 선다. '
|
|
2445
2558
|
+ '사실이 되는 것은 사람이 화면에서 확정할 때고, 판정기는 증거로 따로 검증한다).\n'
|
|
2446
2559
|
+ '요지 전문은 get_case(tenant, "work/<slug>.md") 로 읽는다. **0 이면 0 이라고 말한다.**',
|
|
@@ -2911,9 +3024,25 @@ function tenantNotice() {
|
|
|
2911
3024
|
const now = Date.now();
|
|
2912
3025
|
if (now - tenantSaidAt < TENANT_NOTICE_MS) return '';
|
|
2913
3026
|
tenantSaidAt = now;
|
|
3027
|
+
// 관측 밖 자백(2026-08-31) — 비-git·비앵커 폴더는 훅이 자동으로 안 걸리는데 그 침묵의 값을
|
|
3028
|
+
// HNL 모노폴더에서 치렀다(소속처 전체 세션 0건이 사람이 물어봐서야 드러났다). 조용히 건너뛰는
|
|
3029
|
+
// 것이 가장 나쁘다 — 같은 알림에 한 문장으로 잇는다(앵커되면 다음 부팅에 저절로 걸린다).
|
|
3030
|
+
let tail = '';
|
|
3031
|
+
try {
|
|
3032
|
+
const cwd = process.cwd();
|
|
3033
|
+
if (!fs.existsSync(path.join(cwd, '.git'))) {
|
|
3034
|
+
let hooked = false;
|
|
3035
|
+
try { hooked = fs.readFileSync(path.join(cwd, '.claude/settings.json'), 'utf8').includes('gcell'); }
|
|
3036
|
+
catch { /* 파일이 없으면 안 걸린 것 */ }
|
|
3037
|
+
if (!hooked) {
|
|
3038
|
+
tail = ' 지금 이 폴더는 세포 관측 밖이기도 하다(비-git·비앵커 폴더는 훅이 자동으로 안 걸린다) — '
|
|
3039
|
+
+ '앵커되면 다음 부팅부터 훅·메일·트리거가 자동으로 돈다.';
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
} catch { /* 알림은 최선일 뿐 — 실패해도 본문은 나간다 */ }
|
|
2914
3043
|
return 'genetics: 이 세션은 아직 어느 소속처에도 안 붙어 있다 — 이 폴더에 앞 세션이 없어 '
|
|
2915
3044
|
+ '자동 등록이 안 됐다. `push_session` 으로 한 번 정하면 그 뒤는 자동이고, '
|
|
2916
|
-
+ '그래야 메일·보유자·「이 세션이 속한 일」이 이 저장소에서 돈다.';
|
|
3045
|
+
+ '그래야 메일·보유자·「이 세션이 속한 일」이 이 저장소에서 돈다.' + tail;
|
|
2917
3046
|
}
|
|
2918
3047
|
|
|
2919
3048
|
function worldDelta() {
|
|
@@ -3176,9 +3305,10 @@ function myAgentName() {
|
|
|
3176
3305
|
/*
|
|
3177
3306
|
* Cursor 활성 대화 — 전사도 ListAgents 도 없다. 이름은 별칭일 뿐 열쇠는 uuid 이므로
|
|
3178
3307
|
* (mail 레일 계약), 출처가 읽히는 결정적 별칭을 낸다: cursor-<uuid 앞 2자>.
|
|
3179
|
-
* 하네스 이름(app-2c)과 같은 문법이라 받는 쪽 화면·회신 규칙이 그대로 통한다.
|
|
3180
3308
|
*/
|
|
3181
|
-
if (uuid === cursorActiveComposer()
|
|
3309
|
+
if (uuid === cursorActiveComposer() || process.env.CURSOR_CONVERSATION_ID === uuid) {
|
|
3310
|
+
return cursorAliasOf(uuid);
|
|
3311
|
+
}
|
|
3182
3312
|
return null;
|
|
3183
3313
|
}
|
|
3184
3314
|
let st;
|
|
@@ -3203,11 +3333,19 @@ function myAgentName() {
|
|
|
3203
3333
|
const n = introNameOf(line);
|
|
3204
3334
|
if (n) found = n;
|
|
3205
3335
|
}
|
|
3336
|
+
if (!found && (isCursorTranscript(f) || uuid === cursorActiveComposer()
|
|
3337
|
+
|| process.env.CURSOR_CONVERSATION_ID === uuid)) {
|
|
3338
|
+
found = cursorAliasOf(uuid);
|
|
3339
|
+
}
|
|
3206
3340
|
nameCache = { file: f, size: from + Buffer.byteLength(whole, 'utf8'), name: found };
|
|
3207
3341
|
return found;
|
|
3208
3342
|
}
|
|
3209
3343
|
} finally { fs.closeSync(fd); }
|
|
3210
3344
|
} catch { /* 못 읽으면 이름이 없는 것이지 고장이 아니다 */ }
|
|
3345
|
+
if (!found && (isCursorTranscript(f) || uuid === cursorActiveComposer()
|
|
3346
|
+
|| process.env.CURSOR_CONVERSATION_ID === uuid)) {
|
|
3347
|
+
found = cursorAliasOf(uuid);
|
|
3348
|
+
}
|
|
3211
3349
|
nameCache = { file: f, size: st.size, name: found };
|
|
3212
3350
|
return found;
|
|
3213
3351
|
}
|
|
@@ -3366,6 +3504,42 @@ function mailSays() {
|
|
|
3366
3504
|
return out.join('\n\n');
|
|
3367
3505
|
}
|
|
3368
3506
|
|
|
3507
|
+
/*
|
|
3508
|
+
* ── 미반영 메모 — **부르지 않아도 눈앞에** ────────────────────────────────
|
|
3509
|
+
*
|
|
3510
|
+
* 저자가 보고서에 남긴 메모는 「물어봐야 아는 것」이면 안 된다. 물어볼 생각을 하려면
|
|
3511
|
+
* 있는 줄 알아야 하는데, 그걸 알려면 물어봐야 한다 — 그 고리를 여기서 끊는다.
|
|
3512
|
+
*
|
|
3513
|
+
* ★ **여기서 서버를 부르지 않는다.** 건수는 이미 받는 응답(get_report·update_report·
|
|
3514
|
+
* list_notes)에 실려 온다. 꼬리마다 한 번씩 물으면 모든 도구가 느려진다.
|
|
3515
|
+
*
|
|
3516
|
+
* ★ 같은 말을 계속 하지 않되 **영영 입 닫지도 않는다.** 델타는 한 번 말하고 마는데
|
|
3517
|
+
* (배경이니까), 메모는 **할 일**이라 잊히면 안 된다 — 도구를 여덟 번 부르는 동안
|
|
3518
|
+
* 안 고쳤으면 다시 말한다.
|
|
3519
|
+
*/
|
|
3520
|
+
const NOTES_OPEN = new Map(); // 보고서 id → 미반영 건수
|
|
3521
|
+
let NOTES_LAST = '';
|
|
3522
|
+
let NOTES_QUIET = 0;
|
|
3523
|
+
const NOTES_REMIND_EVERY = 8;
|
|
3524
|
+
|
|
3525
|
+
/** 응답에 실려 온 건수를 기억한다. 서버가 그 칸을 모르는 판이면 아무 일도 안 한다. */
|
|
3526
|
+
function notesSeen(id, n) {
|
|
3527
|
+
if (!Number.isInteger(id) || id <= 0 || !Number.isInteger(n)) return;
|
|
3528
|
+
if (n > 0) NOTES_OPEN.set(id, n); else NOTES_OPEN.delete(id);
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
function notesSays() {
|
|
3532
|
+
const rows = [];
|
|
3533
|
+
NOTES_OPEN.forEach((n, id) => { if (n > 0) rows.push(`#${id} ${n}건`); });
|
|
3534
|
+
if (!rows.length) { NOTES_LAST = ''; return ''; }
|
|
3535
|
+
const t = `저자 메모 미반영 — ${rows.join(' · ')} · list_notes(id) 로 읽는다. `
|
|
3536
|
+
+ '고칠 자리는 각 메모의 anchor.path 가 말한다. 이견이 있으면 여기서 말하지 말고 reply_note 로 그 메모에 단다.';
|
|
3537
|
+
if (t === NOTES_LAST && ++NOTES_QUIET < NOTES_REMIND_EVERY) return '';
|
|
3538
|
+
NOTES_LAST = t;
|
|
3539
|
+
NOTES_QUIET = 0;
|
|
3540
|
+
return t;
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3369
3543
|
function worldAppend(result) {
|
|
3370
3544
|
/*
|
|
3371
3545
|
* ★ 백신이 **먼저**다. 세계 델타는 「누가 무엇을 썼다」는 배경이고, 모순은 지금 하는 일을
|
|
@@ -3385,6 +3559,12 @@ function worldAppend(result) {
|
|
|
3385
3559
|
// 그것이 안 풀리면 위의 메일·백신도 이 저장소에서는 영영 안 온다.
|
|
3386
3560
|
const tn = tenantNotice();
|
|
3387
3561
|
if (tn) result.content.push({ type: 'text', text: tn });
|
|
3562
|
+
/*
|
|
3563
|
+
* ★ 메모는 **델타보다 앞**이다. 델타는 「누가 무엇을 썼다」는 배경인데, 메모는
|
|
3564
|
+
* 저자가 **이 문서의 이 자리**를 짚어 남긴 할 일이다. 뒤에 두면 배경에 묻힌다.
|
|
3565
|
+
*/
|
|
3566
|
+
const nn = notesSays();
|
|
3567
|
+
if (nn) result.content.push({ type: 'text', text: nn });
|
|
3388
3568
|
const t = worldDelta();
|
|
3389
3569
|
if (!t) return;
|
|
3390
3570
|
/*
|
|
@@ -3650,6 +3830,7 @@ async function callTool(name, args = {}) {
|
|
|
3650
3830
|
case 'get_report': {
|
|
3651
3831
|
const q = args.outline ? '?outline=1' : '';
|
|
3652
3832
|
const r = await api('GET', `/api/reports/${Number(args.id)}${q}`);
|
|
3833
|
+
notesSeen(Number(args.id), r.open_notes);
|
|
3653
3834
|
if (!args.outline) {
|
|
3654
3835
|
/*
|
|
3655
3836
|
* ⚠ 전에는 여기서 **`r.data` 만** 돌려줬다. 서버가 정성껏 붙인 것이 이 한 줄에서
|
|
@@ -3682,6 +3863,62 @@ async function callTool(name, args = {}) {
|
|
|
3682
3863
|
// 문맥이 두 배가 된다. (같은 말을 데이터 없는 문서에도 쓰므로 함수로 뺐다.)
|
|
3683
3864
|
return outlineText(r.outline);
|
|
3684
3865
|
}
|
|
3866
|
+
case 'list_notes': {
|
|
3867
|
+
const id = Number(args.id);
|
|
3868
|
+
const qs = new URLSearchParams();
|
|
3869
|
+
if (args.state) qs.set('state', String(args.state));
|
|
3870
|
+
if (args.uid) qs.set('uid', String(args.uid));
|
|
3871
|
+
const r = await api('GET', `/api/reports/${id}/notes${qs.toString() ? '?' + qs : ''}`);
|
|
3872
|
+
notesSeen(id, r.open);
|
|
3873
|
+
if (!r.notes || !r.notes.length) {
|
|
3874
|
+
return `보고서 #${id} — ${r.note || '메모가 없다.'}`;
|
|
3875
|
+
}
|
|
3876
|
+
/*
|
|
3877
|
+
* 답글을 부모 밑에 붙여 낸다. 평평하게 내면 에이전트가 **자기가 이미 한 말**을
|
|
3878
|
+
* 못 보고 같은 답을 다시 단다.
|
|
3879
|
+
*/
|
|
3880
|
+
const kids = new Map();
|
|
3881
|
+
for (const n of r.notes) {
|
|
3882
|
+
if (n.parent_id !== null) {
|
|
3883
|
+
if (!kids.has(n.parent_id)) kids.set(n.parent_id, []);
|
|
3884
|
+
kids.get(n.parent_id).push(n);
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
const STATE = {
|
|
3888
|
+
exact: '', moved: '(옮겨 붙음)', drifted: '(원문이 바뀜 — 저자가 본 것과 지금이 다르다)',
|
|
3889
|
+
reparented: '(문장을 잃어 상위로)', unmoored: '(자리를 잃음 — 문서 어디인지 모른다)',
|
|
3890
|
+
};
|
|
3891
|
+
const lines = [`보고서 #${id} · 본문 판 body_rev=${r.body_rev} · ${r.note}`, ''];
|
|
3892
|
+
for (const n of r.notes) {
|
|
3893
|
+
if (n.parent_id !== null) continue;
|
|
3894
|
+
const at = n.anchor.path || n.anchor.uid || '문서 전체';
|
|
3895
|
+
lines.push(`[메모 ${n.id}] ${at} ${STATE[n.anchor_state] || ''}${n.resolved ? ' · 반영됨' : ''}`);
|
|
3896
|
+
if (n.anchor.quote_exact) lines.push(` 저자가 짚은 글: 「${n.anchor.quote_exact}」`);
|
|
3897
|
+
lines.push(` ${n.author || ''}: ${n.body}`);
|
|
3898
|
+
for (const k of (kids.get(n.id) || [])) {
|
|
3899
|
+
lines.push(` ↳ ${k.author_kind === 'agent' ? '내 답글' : k.author}: ${k.body}`);
|
|
3900
|
+
}
|
|
3901
|
+
lines.push('');
|
|
3902
|
+
}
|
|
3903
|
+
return lines.join('\n').trimEnd();
|
|
3904
|
+
}
|
|
3905
|
+
case 'reply_note': {
|
|
3906
|
+
const id = Number(args.id);
|
|
3907
|
+
const r = await api('POST', `/api/reports/${id}/notes`,
|
|
3908
|
+
{ note: Number(args.note), body: String(args.body ?? '') });
|
|
3909
|
+
return `${r.says} (메모 ${args.note} · 답글 ${r.note.id})`;
|
|
3910
|
+
}
|
|
3911
|
+
case 'resolve_note': {
|
|
3912
|
+
const id = Number(args.id);
|
|
3913
|
+
const body = { note: Number(args.note) };
|
|
3914
|
+
if (args.rev !== undefined) body.rev = Number(args.rev);
|
|
3915
|
+
if (args.resolved !== undefined) body.resolved = !!args.resolved;
|
|
3916
|
+
const r = await api('PATCH', `/api/reports/${id}/notes`, body);
|
|
3917
|
+
// 표시했으니 꼬리의 셈도 줄인다 — 안 줄이면 「고쳤는데 계속 재촉하는」 꼴이 된다.
|
|
3918
|
+
const cur = NOTES_OPEN.get(id) || 0;
|
|
3919
|
+
notesSeen(id, Math.max(0, cur - 1));
|
|
3920
|
+
return r.says;
|
|
3921
|
+
}
|
|
3685
3922
|
case 'publish_report': {
|
|
3686
3923
|
const data = await materializeAssets(args.data);
|
|
3687
3924
|
// **기본은 초안이다.** 서버 기본값은 참(다른 호출자의 동작을 말없이 바꾸지 않는다)이라
|
|
@@ -3733,6 +3970,8 @@ async function callTool(name, args = {}) {
|
|
|
3733
3970
|
const payload = { ...data, tenant: args.tenant };
|
|
3734
3971
|
if (args.dry_run) payload.dry_run = true;
|
|
3735
3972
|
const r = await api('PUT', `/api/reports/${Number(args.id)}`, payload, PUBLISH);
|
|
3973
|
+
// 고친 직후가 「무엇을 아직 안 고쳤나」를 볼 가장 좋은 순간이다.
|
|
3974
|
+
if (!r.dry_run) notesSeen(Number(args.id), r.open_notes);
|
|
3736
3975
|
if (r.dry_run) {
|
|
3737
3976
|
return `시험 실행 — 아무것도 바꾸지 않았습니다.\n${r.tenant} · 건 ${r.cases}` + warnText(r);
|
|
3738
3977
|
}
|
|
@@ -3765,7 +4004,7 @@ async function callTool(name, args = {}) {
|
|
|
3765
4004
|
+ (w.stale ? ` · 낡음 ${w.stale}일째(AI 관측 — 활동이 멎었다: 사람이 닫거나 다시 살릴 일)` : '')
|
|
3766
4005
|
+ (w.doc ? ` · 요지: get_case(tenant, "${w.doc}")` : ' · 요지 문서 아직 없음'));
|
|
3767
4006
|
}
|
|
3768
|
-
if (!rows.length) lines.push('열린 일이 없다 — 0건은 0건이다. 착수 전에 세우려면
|
|
4007
|
+
if (!rows.length) lines.push('열린 일이 없다 — 0건은 0건이다. 착수 전에 세우려면 그 과업의 결정을 remember_decision 으로 남기되 **work 에 일의 열쇠를 적어라** — 다음 바퀴에 「계획」으로 선다. 표지 없이 tags 만 달면 신호가 쌓일 때까지 AI 가 기다린다.');
|
|
3769
4008
|
else lines.push('붙을 일이 보이면 works(tenant, claim: "<slug>") 로 선언한다 — 확정은 사람이 화면에서 한다.');
|
|
3770
4009
|
return lines.join('\n');
|
|
3771
4010
|
}
|
|
@@ -3791,6 +4030,7 @@ async function callTool(name, args = {}) {
|
|
|
3791
4030
|
title: args.title, what: args.what,
|
|
3792
4031
|
who: args.who || '', when: args.when || '', why: args.why || '',
|
|
3793
4032
|
reopen: args.reopen || '', status: args.status || '', tags: args.tags || [],
|
|
4033
|
+
work: args.work || '', order: args.order || [],
|
|
3794
4034
|
});
|
|
3795
4035
|
const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
|
|
3796
4036
|
// 안 적은 것을 **그렇게 말한다** — 조용히 열린 결정을 만들거나 계산 밖에 두지 않는다.
|
|
@@ -3806,6 +4046,18 @@ async function callTool(name, args = {}) {
|
|
|
3806
4046
|
} else if (r.insights_queued === false) {
|
|
3807
4047
|
lines.push('전체 맥락 정리 줄은 안 섰다 — 재료가 아직 문턱 아래(배경 문서 3장 또는 결정·사례 5장)거나 이미 줄이 서 있다.');
|
|
3808
4048
|
}
|
|
4049
|
+
// 표지(work:)의 앞날 — 서버가 저장 시점에 판정한 것을 그대로 옮긴다(지어내지 않는다).
|
|
4050
|
+
if (r.work_mark === 'will-open') {
|
|
4051
|
+
lines.push(`다음 바퀴(1분 안)에 일 «${args.work}» 가 「계획」으로 섭니다 — works 로 확인한다.`);
|
|
4052
|
+
} else if (r.work_mark === 'exists-open') {
|
|
4053
|
+
lines.push(`일 «${args.work}» 는 이미 열려 있다 — 이 결정은 그 일의 재료가 된다.`);
|
|
4054
|
+
} else if (r.work_mark === 'exists-closed') {
|
|
4055
|
+
lines.push(`일 «${args.work}» 는 닫혀 있다 — 다시 열려면 사람이 화면에서 재개한다(표지로는 안 열린다).`);
|
|
4056
|
+
} else if (r.work_mark === 'vetoed') {
|
|
4057
|
+
lines.push(`일 «${args.work}» 는 전에 사람이 닫은 이름이라 안 선다 — 다른 이름을 쓰거나 화면에서 재개한다.`);
|
|
4058
|
+
} else if (args.work) {
|
|
4059
|
+
lines.push('표지(work)를 적었지만 이 서버는 아직 그 칸을 모른다 — 판이 낡았다.');
|
|
4060
|
+
}
|
|
3809
4061
|
return lines.join('\n');
|
|
3810
4062
|
}
|
|
3811
4063
|
case 'remember_case': {
|
package/package.json
CHANGED
|
@@ -188,7 +188,7 @@ python3 assets/render.py <report.json>
|
|
|
188
188
|
| `stats[]` | `value` · `unit` · `label` · `accent` · `href` · `go` | 첫 칸만 `accent: true` |
|
|
189
189
|
| `meta[]` | `label` · `value` | `[k, v]` 배열도 받는다 |
|
|
190
190
|
| `open_items[]` | `title` · `priority`(`must` 면 「가장 시급」) · `body` · `refs[]` | `refs` 는 **첫 하나만** 「근거」 단추가 된다 |
|
|
191
|
-
| `cases[]` | `group` · `title` · `grade` · `symptom` · `cause` · `fix` · `how_to_check` · `remaining` · `source_url` · `source_text` | 번호(`anchor`)는 **렌더러가
|
|
191
|
+
| `cases[]` | `group` · `title` · `grade` · `symptom` · `cause` · `fix` · `how_to_check` · `remaining` · `source_url` · `source_text` · **`uid`** | 번호(`anchor`)는 **렌더러가 매기고**, `uid` 는 **서버가 발급한다** |
|
|
192
192
|
| `appendix[]` | `no` · `title` · `rows[{label,value}]` · `note` | `rows` 가 비면 그 항목이 통째로 빠진다 |
|
|
193
193
|
| `colophon` | `title` · `sub` · `rows[]` · `mark` · `badge` · `note` · `tail` | 넷(`title`·`rows`·`mark`·`note`)이 다 비면 판권지가 안 선다 |
|
|
194
194
|
| `grades{}` | `키: [클래스, 설명, lvl?, tag?]` | 7절 |
|
|
@@ -196,6 +196,30 @@ python3 assets/render.py <report.json>
|
|
|
196
196
|
`cases[].remaining` 이 있으면 그 건은 대조표에서 **미결**로 선다.
|
|
197
197
|
그 행이 독자에게 넘어가는 몫이라 다른 행과 시각적으로 갈린다.
|
|
198
198
|
|
|
199
|
+
### `cases[].uid` · `sections[].uid` — 건과 절의 **불변 이름**
|
|
200
|
+
|
|
201
|
+
**적지 않아도 된다. 다만 받은 것은 돌려보내라.**
|
|
202
|
+
|
|
203
|
+
번호와 앵커는 **자리가 매긴다**(`case-03-1` = 3번째 절의 1번째 건). 그래서 절 하나를 앞에
|
|
204
|
+
끼우거나 축을 갈아엎으면 **뒤의 앵커가 전부 밀린다** — 실측(2026-08-31, #32): 축을
|
|
205
|
+
「조치 회차」에서 「검수 단위」로 바꾸자 건 19개 중 거의 전부의 앵커가 바뀌었다.
|
|
206
|
+
|
|
207
|
+
`uid` 는 그 자리와 무관한 이름이다(영소문자·숫자 6자). **서버가 발급하고 `get_report` 가
|
|
208
|
+
돌려준다.** 렌더러는 `data-uid` 속성으로만 내보내고 **보이는 번호는 안 건드린다.**
|
|
209
|
+
|
|
210
|
+
| 이럴 때 | 이렇게 된다 |
|
|
211
|
+
|---|---|
|
|
212
|
+
| `get_report` 가 준 `uid` 를 **그대로 실어** `update_report` | 그 건의 이름이 유지된다 ✔ |
|
|
213
|
+
| `uid` 를 빼고 보냈는데 **제목이 같다** | 제목으로 다시 잇는다 |
|
|
214
|
+
| `uid` 를 빼고 제목도 바꿨다 | **새 이름이 발급된다** |
|
|
215
|
+
| 처음 발행(`publish_report`) | 전부 새로 발급된다 — 적을 필요 없다 |
|
|
216
|
+
|
|
217
|
+
**왜 지켜야 하나**: 저자가 그 건에 달아 둔 메모(`list_notes`)가 이 이름을 문다.
|
|
218
|
+
이름이 바뀌면 메모가 사라지지는 않지만(인용문으로 다시 찾는다) 「자리를 잃음」으로 떨어져
|
|
219
|
+
저자가 다시 붙여야 한다.
|
|
220
|
+
|
|
221
|
+
★ **같은 제목을 두 건에 쓰지 마라.** 위 2단이 어느 쪽인지 못 갈라 둘 다 새 이름을 받는다.
|
|
222
|
+
|
|
199
223
|
---
|
|
200
224
|
|
|
201
225
|
## 4. 절 단위 키 — 전량
|