genetics-mcp 2.16.2 → 2.16.5
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 +297 -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
|
@@ -109,7 +109,15 @@ Object.assign(cfg, Object.fromEntries(
|
|
|
109
109
|
|
|
110
110
|
// **기본 주소를 두지 않는다.** 값을 박아 두면 출하물이 "그 서버를 아는 클라이언트" 가 되고,
|
|
111
111
|
// 공개 배포 시 운영 엔드포인트가 그대로 공개된다. 주소는 쓰는 쪽이 정한다.
|
|
112
|
-
|
|
112
|
+
// 로그인 자격이 이미 주소를 들고 있으면(`credentials.json` 의 `base`) 그것을 쓴다 —
|
|
113
|
+
// 사람이 승인한 그 서버다. 출하 기본값이 아니다.
|
|
114
|
+
const BASE = (cfg.GENETICS_API_BASE || '').replace(/\/$/, '')
|
|
115
|
+
|| (() => {
|
|
116
|
+
try {
|
|
117
|
+
const p = cfg0.GENETICS_CRED || path.join(path.dirname(ENV_FILE), 'credentials.json');
|
|
118
|
+
return String(JSON.parse(fs.readFileSync(p, 'utf8')).base || '').replace(/\/$/, '');
|
|
119
|
+
} catch { return ''; }
|
|
120
|
+
})();
|
|
113
121
|
const KEY = cfg.GENETICS_API_KEY || '';
|
|
114
122
|
|
|
115
123
|
/* ── 자격은 두 갈래다 ────────────────────────────────────────────────────
|
|
@@ -1073,6 +1081,13 @@ const REPORT_DATA = {
|
|
|
1073
1081
|
how_to_check: { type: 'string' },
|
|
1074
1082
|
remaining: { type: 'string', description: '고객 확인이 필요한 사항(선택)' },
|
|
1075
1083
|
source_url: { type: 'string', description: 'http(s) 제보 원본 링크(선택)' },
|
|
1084
|
+
uid: {
|
|
1085
|
+
type: 'string',
|
|
1086
|
+
description: '이 건의 **불변 이름**(영소문자·숫자 6자). 안 주면 서버가 발급한다. '
|
|
1087
|
+
+ '★ get_report 가 준 값이 있으면 **그대로 되돌려 보내라** — 절 순서를 바꾸거나 '
|
|
1088
|
+
+ '축을 갈아엎어도 이 이름이 같으면 그 건에 달린 메모가 안 떨어진다. '
|
|
1089
|
+
+ '흘리면 제목으로 다시 잇고, 그것도 안 되면 새 이름이 발급된다.',
|
|
1090
|
+
},
|
|
1076
1091
|
},
|
|
1077
1092
|
// group 만 필수다 — 없는 그룹에 속한 건은 어디에도 안 실려 조용히 사라진다.
|
|
1078
1093
|
required: ['group'],
|
|
@@ -1303,6 +1318,46 @@ function cursorTranscriptOf(uuid) {
|
|
|
1303
1318
|
return null;
|
|
1304
1319
|
}
|
|
1305
1320
|
|
|
1321
|
+
function isCursorTranscript(f) {
|
|
1322
|
+
return typeof f === 'string' && f.includes(`${path.sep}agent-transcripts${path.sep}`);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function cursorAliasOf(uuid) {
|
|
1326
|
+
return 'cursor-' + String(uuid).slice(0, 2);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/** 훅이 적어 둔 (세션, 뿌리). 라벨 없는 MCP 헬퍼가 sqlite 를 추측하지 않게. */
|
|
1330
|
+
function cursorActiveFromLedger() {
|
|
1331
|
+
const dir = process.env.GCELL_STATE_DIR || path.join(os.homedir(), '.config', 'genetics', 'cell');
|
|
1332
|
+
let cwd = process.cwd();
|
|
1333
|
+
try { cwd = fs.realpathSync(cwd); } catch { /* 없으면 준 값 */ }
|
|
1334
|
+
let home = os.homedir();
|
|
1335
|
+
try { home = fs.realpathSync(home); } catch { /* */ }
|
|
1336
|
+
// 전역 `npx genetics-mcp` 헬퍼는 cwd 가 집 또는 `/` 다(2026-08-31 pid 28342 실측).
|
|
1337
|
+
// 그때는 뿌리 일치가 원천적으로 불가능하니, 6시간 안 장부 중 **가장 최근 훅**을 쓴다.
|
|
1338
|
+
// 창이 여럿이면 마지막에 훅이 돈 대화다 — 지금 도구를 부른 창과 같다.
|
|
1339
|
+
const vague = !cwd || cwd === '/' || cwd === home;
|
|
1340
|
+
let names;
|
|
1341
|
+
try { names = fs.readdirSync(dir); } catch { return { id: '', folder: '' }; }
|
|
1342
|
+
const matched = [];
|
|
1343
|
+
const recent = [];
|
|
1344
|
+
for (const name of names) {
|
|
1345
|
+
if (!name.startsWith('active-cursor-') || !name.endsWith('.json')) continue;
|
|
1346
|
+
let v;
|
|
1347
|
+
try { v = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')); } catch { continue; }
|
|
1348
|
+
const root = String(v.root || '');
|
|
1349
|
+
const id = String(v.session || '');
|
|
1350
|
+
if (!root || !/^[0-9a-f-]{36}$/i.test(id)) continue;
|
|
1351
|
+
const at = Number(v.at || 0);
|
|
1352
|
+
if (at && (Date.now() / 1000) - at > 6 * 3600) continue;
|
|
1353
|
+
const row = { id, folder: root, at };
|
|
1354
|
+
recent.push(row);
|
|
1355
|
+
if (cwd === root || cwd.startsWith(root + path.sep)) matched.push(row);
|
|
1356
|
+
}
|
|
1357
|
+
const pick = (rows) => rows.sort((a, b) => b.at - a.at)[0] || null;
|
|
1358
|
+
return pick(matched) || (vague ? pick(recent) : null) || { id: '', folder: '' };
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1306
1361
|
/* ── Cursor 신원 ──────────────────────────────────────────────────────────
|
|
1307
1362
|
*
|
|
1308
1363
|
* Cursor 는 MCP 자식에게 대화(세션) id 를 **안 준다** — 실측(2026-08-27, pid 98284 env):
|
|
@@ -1331,40 +1386,60 @@ let cursorSeen = { at: 0, id: '', folder: '' }; // 15초 캐시 — 도구마
|
|
|
1331
1386
|
|
|
1332
1387
|
function cursorActiveComposer() {
|
|
1333
1388
|
if (process.env.CLAUDE_CODE_SESSION_ID) return '';
|
|
1334
|
-
const label = process.env.CURSOR_WORKSPACE_LABEL || '';
|
|
1335
|
-
if (!label) return ''; // 창을 못 고르면 추측하지 않는다
|
|
1336
1389
|
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
|
-
|
|
1390
|
+
if (now - cursorSeen.at < 15000 && cursorSeen.id) return cursorSeen.id;
|
|
1391
|
+
|
|
1392
|
+
// 에이전트 실행 env — 라벨·sqlite 보다 먼저. 36자 uuid 가 아니면 버린다.
|
|
1393
|
+
const envId = process.env.CURSOR_CONVERSATION_ID || '';
|
|
1394
|
+
if (/^[0-9a-f-]{36}$/i.test(envId)) {
|
|
1395
|
+
let folder = '';
|
|
1396
|
+
try { folder = fs.realpathSync(process.cwd()); } catch { folder = process.cwd(); }
|
|
1397
|
+
cursorSeen = { at: now, id: envId, folder };
|
|
1398
|
+
return envId;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const label = process.env.CURSOR_WORKSPACE_LABEL || '';
|
|
1402
|
+
if (label) {
|
|
1403
|
+
try {
|
|
1404
|
+
const wsRoot = path.join(CURSOR_ROOT, 'workspaceStorage');
|
|
1405
|
+
let best = null; // 같은 이름의 창이 여럿이면 가장 최근에 움직인 것
|
|
1406
|
+
for (const d of fs.readdirSync(wsRoot)) {
|
|
1407
|
+
const dir = path.join(wsRoot, d);
|
|
1408
|
+
let folder = '';
|
|
1409
|
+
try {
|
|
1410
|
+
folder = String(JSON.parse(fs.readFileSync(path.join(dir, 'workspace.json'), 'utf8')).folder || '');
|
|
1411
|
+
} catch { continue; }
|
|
1412
|
+
if (!folder.startsWith('file://')) continue;
|
|
1413
|
+
const wsPath = decodeURIComponent(folder.slice('file://'.length)).replace(/\/+$/, '');
|
|
1414
|
+
if (path.basename(wsPath) !== label) continue;
|
|
1415
|
+
let mt = 0;
|
|
1416
|
+
try { mt = fs.statSync(path.join(dir, 'state.vscdb')).mtimeMs; } catch { continue; }
|
|
1417
|
+
if (!best || mt > best.mt) best = { dir, wsPath, mt };
|
|
1418
|
+
}
|
|
1419
|
+
if (best) {
|
|
1420
|
+
const r = spawnSync('/usr/bin/sqlite3',
|
|
1421
|
+
['-readonly', path.join(best.dir, 'state.vscdb'),
|
|
1422
|
+
"SELECT value FROM ItemTable WHERE key='composer.composerData'"],
|
|
1423
|
+
{ encoding: 'utf8', timeout: 2000 });
|
|
1424
|
+
if (r.status === 0) {
|
|
1425
|
+
const j = JSON.parse((r.stdout || '').trim() || '{}');
|
|
1426
|
+
const v = j.lastFocusedComposerIds || j.selectedComposerIds;
|
|
1427
|
+
const id = Array.isArray(v) ? String(v[0] || '') : '';
|
|
1428
|
+
if (/^[0-9a-f-]{36}$/i.test(id)) {
|
|
1429
|
+
cursorSeen = { at: now, id, folder: best.wsPath };
|
|
1430
|
+
return id;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
} catch { /* 아래 장부로 */ }
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const led = cursorActiveFromLedger();
|
|
1438
|
+
if (led.id) {
|
|
1439
|
+
cursorSeen = { at: now, id: led.id, folder: led.folder };
|
|
1440
|
+
return led.id;
|
|
1441
|
+
}
|
|
1442
|
+
return '';
|
|
1368
1443
|
}
|
|
1369
1444
|
|
|
1370
1445
|
/** 위 해석기가 고른 창의 **저장소 폴더** — 소속처 입양(같은 폴더 규칙)이 쓴다. */
|
|
@@ -1792,16 +1867,16 @@ function indexAdoptCurrent() {
|
|
|
1792
1867
|
if (idx[mine]?.tenant_id) return; // 이미 등록돼 있다
|
|
1793
1868
|
const f = transcriptOf(mine);
|
|
1794
1869
|
/*
|
|
1795
|
-
* 폴더 열쇠 — 입양 규칙(같은 폴더 = 같은 소속처)의 「폴더」는 전사 디렉터리다.
|
|
1796
|
-
* Cursor
|
|
1797
|
-
*
|
|
1798
|
-
* 같은 칸에 선다 — report-portal 창의 Cursor 대화는 report-portal 세션들을 따라간다.
|
|
1870
|
+
* 폴더 열쇠 — 입양 규칙(같은 폴더 = 같은 소속처)의 「폴더」는 Claude 전사 디렉터리다.
|
|
1871
|
+
* Cursor jsonl 의 dirname 은 `…/agent-transcripts/<uuid>` 라 Claude 칸과 안 맞는다
|
|
1872
|
+
* (조각 3 회귀, 2026-08-31). 창의 저장소 폴더를 같은 슬러그 규칙으로 만든다.
|
|
1799
1873
|
*/
|
|
1800
1874
|
let myDir = '';
|
|
1801
|
-
if (f)
|
|
1802
|
-
else if (mine && mine === cursorActiveComposer()) {
|
|
1875
|
+
if (isCursorTranscript(f) || (!f && mine === cursorActiveComposer())) {
|
|
1803
1876
|
const folder = cursorWorkspaceFolder();
|
|
1804
1877
|
if (folder) myDir = transcriptDir(folder);
|
|
1878
|
+
} else if (f) {
|
|
1879
|
+
myDir = path.dirname(f);
|
|
1805
1880
|
}
|
|
1806
1881
|
if (!myDir) return; // 이 기계에 사본도, 창 귀속도 없다
|
|
1807
1882
|
const rows = [];
|
|
@@ -2255,6 +2330,59 @@ const TOOLS = [
|
|
|
2255
2330
|
required: ['id'],
|
|
2256
2331
|
},
|
|
2257
2332
|
},
|
|
2333
|
+
{
|
|
2334
|
+
name: 'list_notes',
|
|
2335
|
+
description:
|
|
2336
|
+
'보고서에 **저자가 달아 둔 메모**를 읽는다. 고객에게는 안 보이는 저자↔에이전트 전용 글이다.\n'
|
|
2337
|
+
+ '각 메모의 `anchor.path`(예 `case:k7m2p9/fix`)가 **발행 데이터의 어느 칸을 고치라는 것인지**를 '
|
|
2338
|
+
+ '그대로 말한다 — 그 칸을 고쳐 update_report 로 되돌려 보내고, resolve_note 로 반영을 표시한다.\n'
|
|
2339
|
+
+ '`anchor_state` 가 `unmoored`·`reparented` 면 문서가 바뀌어 원래 자리를 잃은 것이다 — '
|
|
2340
|
+
+ '그래도 메모는 남아 있고, `anchor.quote_exact` 가 저자가 무엇을 보고 썼는지 말한다.',
|
|
2341
|
+
inputSchema: {
|
|
2342
|
+
type: 'object',
|
|
2343
|
+
properties: {
|
|
2344
|
+
id: { type: 'integer', description: '보고서 id' },
|
|
2345
|
+
state: { type: 'string', description: '`open`(기본, 아직 반영 안 한 것) 또는 `all`' },
|
|
2346
|
+
uid: { type: 'string', description: '그 건·절의 uid 에 달린 것만(선택)' },
|
|
2347
|
+
},
|
|
2348
|
+
required: ['id'],
|
|
2349
|
+
},
|
|
2350
|
+
},
|
|
2351
|
+
{
|
|
2352
|
+
name: 'reply_note',
|
|
2353
|
+
description:
|
|
2354
|
+
'메모에 **답글로** 회신한다. 이견이 있거나 물을 것이 있으면 대화창이 아니라 여기에 쓴다 — '
|
|
2355
|
+
+ '저자는 자기가 메모를 단 그 자리에서 답을 본다.\n'
|
|
2356
|
+
+ '고칠 수 없는 이유, 다르게 본 근거, 되묻는 것이 여기 온다. '
|
|
2357
|
+
+ '고쳤으면 답글이 아니라 resolve_note 다.',
|
|
2358
|
+
inputSchema: {
|
|
2359
|
+
type: 'object',
|
|
2360
|
+
properties: {
|
|
2361
|
+
id: { type: 'integer', description: '보고서 id' },
|
|
2362
|
+
note: { type: 'integer', description: '답할 메모 id(list_notes 가 준 값)' },
|
|
2363
|
+
body: { type: 'string', description: '회신. 개조식으로 쓴다' },
|
|
2364
|
+
},
|
|
2365
|
+
required: ['id', 'note', 'body'],
|
|
2366
|
+
},
|
|
2367
|
+
},
|
|
2368
|
+
{
|
|
2369
|
+
name: 'resolve_note',
|
|
2370
|
+
description:
|
|
2371
|
+
'그 메모를 **반영했다**고 표시한다. 반영이 실린 판번호(body_rev)가 함께 남아 '
|
|
2372
|
+
+ '「무엇에 반영됐는지」를 나중에도 안다.\n'
|
|
2373
|
+
+ '★ 고치기 **전에** 부르지 말 것 — 이 표시가 곧 「이 메모는 끝났다」이고, '
|
|
2374
|
+
+ '표시하는 순간 미반영 목록에서 빠져 다시 눈에 안 띈다.',
|
|
2375
|
+
inputSchema: {
|
|
2376
|
+
type: 'object',
|
|
2377
|
+
properties: {
|
|
2378
|
+
id: { type: 'integer', description: '보고서 id' },
|
|
2379
|
+
note: { type: 'integer', description: '메모 id' },
|
|
2380
|
+
rev: { type: 'integer', description: '반영이 실린 body_rev. 안 주면 지금 판' },
|
|
2381
|
+
resolved: { type: 'boolean', description: '거짓이면 미반영으로 되돌린다' },
|
|
2382
|
+
},
|
|
2383
|
+
required: ['id', 'note'],
|
|
2384
|
+
},
|
|
2385
|
+
},
|
|
2258
2386
|
{
|
|
2259
2387
|
name: 'publish_report',
|
|
2260
2388
|
description:
|
|
@@ -2413,6 +2541,8 @@ const TOOLS = [
|
|
|
2413
2541
|
reopen: { type: 'string', description: '무슨 조건이면 다시 여나. **비우면 「열린 결정」으로 읽힌다.** 끝났으면 "조건 없음(종결)".' },
|
|
2414
2542
|
status: { type: 'string', description: '상태 — "유효"(기본) · "뒤집힘 → decisions/<그 결정>" · "조건 충족 → <무엇이> (날짜)".' },
|
|
2415
2543
|
tags: { type: 'array', items: { type: 'string' }, description: '갈래(최대 8). ★ 이것이 있어야 이 결정이 「일」 후보 계산에 들어간다.' },
|
|
2544
|
+
work: { type: 'string', description: '**이 결정이 곧 과업이면** 그 일의 열쇠(영문 소문자·숫자·붙임표, 예: hnl-wait). 적으면 다음 바퀴에 그 이름의 일이 「계획」으로 선다 — 착수 전에 세우는 정문이다. 이미 있는 이름이면 조용히 그대로 둔다.' },
|
|
2545
|
+
order: { type: 'array', items: { type: 'string' }, description: '일 사이 **순서**를 정하는 결정이면 일의 열쇠를 앞선 것부터(최대 24). 표에 저장하지 않고 일 목록이 이 결정을 읽어 그 순서로 선다 — 순서가 바뀌면 새 결정을 적는다.' },
|
|
2416
2546
|
},
|
|
2417
2547
|
required: ['tenant', 'title', 'what'],
|
|
2418
2548
|
},
|
|
@@ -2440,7 +2570,7 @@ const TOOLS = [
|
|
|
2440
2570
|
'**이 소속처에 지금 어떤 일이 있나** — 열린 일의 목록·상태(계획/진행 중/멈춤)·세션 수.\n'
|
|
2441
2571
|
+ '언제 부르는가: 작업을 시작할 때(「내가 붙을 일이 이미 있나」 — 새로 벌이기 전에 먼저) · '
|
|
2442
2572
|
+ '사람이 「지금 뭐가 진행 중이지」라고 물을 때 · 세션 요지에 「어느 일에도 속해 있지 않다」가 왔을 때.\n'
|
|
2443
|
-
+ '「계획」 =
|
|
2573
|
+
+ '「계획」 = 착수 전에 선 일(세션 0) — 신호(태그 있는 결정·세션 무리)가 쌓이면 AI 가 세운다. 이 목록에서 이름을 보고 그 방향의 작업이면 '
|
|
2444
2574
|
+ 'claim 으로 **착수를 선언**한다(즉시 잠정 효력 — 세션 요지·화면에 「선언(잠정)」이 선다. '
|
|
2445
2575
|
+ '사실이 되는 것은 사람이 화면에서 확정할 때고, 판정기는 증거로 따로 검증한다).\n'
|
|
2446
2576
|
+ '요지 전문은 get_case(tenant, "work/<slug>.md") 로 읽는다. **0 이면 0 이라고 말한다.**',
|
|
@@ -3192,9 +3322,10 @@ function myAgentName() {
|
|
|
3192
3322
|
/*
|
|
3193
3323
|
* Cursor 활성 대화 — 전사도 ListAgents 도 없다. 이름은 별칭일 뿐 열쇠는 uuid 이므로
|
|
3194
3324
|
* (mail 레일 계약), 출처가 읽히는 결정적 별칭을 낸다: cursor-<uuid 앞 2자>.
|
|
3195
|
-
* 하네스 이름(app-2c)과 같은 문법이라 받는 쪽 화면·회신 규칙이 그대로 통한다.
|
|
3196
3325
|
*/
|
|
3197
|
-
if (uuid === cursorActiveComposer()
|
|
3326
|
+
if (uuid === cursorActiveComposer() || process.env.CURSOR_CONVERSATION_ID === uuid) {
|
|
3327
|
+
return cursorAliasOf(uuid);
|
|
3328
|
+
}
|
|
3198
3329
|
return null;
|
|
3199
3330
|
}
|
|
3200
3331
|
let st;
|
|
@@ -3219,11 +3350,19 @@ function myAgentName() {
|
|
|
3219
3350
|
const n = introNameOf(line);
|
|
3220
3351
|
if (n) found = n;
|
|
3221
3352
|
}
|
|
3353
|
+
if (!found && (isCursorTranscript(f) || uuid === cursorActiveComposer()
|
|
3354
|
+
|| process.env.CURSOR_CONVERSATION_ID === uuid)) {
|
|
3355
|
+
found = cursorAliasOf(uuid);
|
|
3356
|
+
}
|
|
3222
3357
|
nameCache = { file: f, size: from + Buffer.byteLength(whole, 'utf8'), name: found };
|
|
3223
3358
|
return found;
|
|
3224
3359
|
}
|
|
3225
3360
|
} finally { fs.closeSync(fd); }
|
|
3226
3361
|
} catch { /* 못 읽으면 이름이 없는 것이지 고장이 아니다 */ }
|
|
3362
|
+
if (!found && (isCursorTranscript(f) || uuid === cursorActiveComposer()
|
|
3363
|
+
|| process.env.CURSOR_CONVERSATION_ID === uuid)) {
|
|
3364
|
+
found = cursorAliasOf(uuid);
|
|
3365
|
+
}
|
|
3227
3366
|
nameCache = { file: f, size: st.size, name: found };
|
|
3228
3367
|
return found;
|
|
3229
3368
|
}
|
|
@@ -3382,6 +3521,42 @@ function mailSays() {
|
|
|
3382
3521
|
return out.join('\n\n');
|
|
3383
3522
|
}
|
|
3384
3523
|
|
|
3524
|
+
/*
|
|
3525
|
+
* ── 미반영 메모 — **부르지 않아도 눈앞에** ────────────────────────────────
|
|
3526
|
+
*
|
|
3527
|
+
* 저자가 보고서에 남긴 메모는 「물어봐야 아는 것」이면 안 된다. 물어볼 생각을 하려면
|
|
3528
|
+
* 있는 줄 알아야 하는데, 그걸 알려면 물어봐야 한다 — 그 고리를 여기서 끊는다.
|
|
3529
|
+
*
|
|
3530
|
+
* ★ **여기서 서버를 부르지 않는다.** 건수는 이미 받는 응답(get_report·update_report·
|
|
3531
|
+
* list_notes)에 실려 온다. 꼬리마다 한 번씩 물으면 모든 도구가 느려진다.
|
|
3532
|
+
*
|
|
3533
|
+
* ★ 같은 말을 계속 하지 않되 **영영 입 닫지도 않는다.** 델타는 한 번 말하고 마는데
|
|
3534
|
+
* (배경이니까), 메모는 **할 일**이라 잊히면 안 된다 — 도구를 여덟 번 부르는 동안
|
|
3535
|
+
* 안 고쳤으면 다시 말한다.
|
|
3536
|
+
*/
|
|
3537
|
+
const NOTES_OPEN = new Map(); // 보고서 id → 미반영 건수
|
|
3538
|
+
let NOTES_LAST = '';
|
|
3539
|
+
let NOTES_QUIET = 0;
|
|
3540
|
+
const NOTES_REMIND_EVERY = 8;
|
|
3541
|
+
|
|
3542
|
+
/** 응답에 실려 온 건수를 기억한다. 서버가 그 칸을 모르는 판이면 아무 일도 안 한다. */
|
|
3543
|
+
function notesSeen(id, n) {
|
|
3544
|
+
if (!Number.isInteger(id) || id <= 0 || !Number.isInteger(n)) return;
|
|
3545
|
+
if (n > 0) NOTES_OPEN.set(id, n); else NOTES_OPEN.delete(id);
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
function notesSays() {
|
|
3549
|
+
const rows = [];
|
|
3550
|
+
NOTES_OPEN.forEach((n, id) => { if (n > 0) rows.push(`#${id} ${n}건`); });
|
|
3551
|
+
if (!rows.length) { NOTES_LAST = ''; return ''; }
|
|
3552
|
+
const t = `저자 메모 미반영 — ${rows.join(' · ')} · list_notes(id) 로 읽는다. `
|
|
3553
|
+
+ '고칠 자리는 각 메모의 anchor.path 가 말한다. 이견이 있으면 여기서 말하지 말고 reply_note 로 그 메모에 단다.';
|
|
3554
|
+
if (t === NOTES_LAST && ++NOTES_QUIET < NOTES_REMIND_EVERY) return '';
|
|
3555
|
+
NOTES_LAST = t;
|
|
3556
|
+
NOTES_QUIET = 0;
|
|
3557
|
+
return t;
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3385
3560
|
function worldAppend(result) {
|
|
3386
3561
|
/*
|
|
3387
3562
|
* ★ 백신이 **먼저**다. 세계 델타는 「누가 무엇을 썼다」는 배경이고, 모순은 지금 하는 일을
|
|
@@ -3401,6 +3576,12 @@ function worldAppend(result) {
|
|
|
3401
3576
|
// 그것이 안 풀리면 위의 메일·백신도 이 저장소에서는 영영 안 온다.
|
|
3402
3577
|
const tn = tenantNotice();
|
|
3403
3578
|
if (tn) result.content.push({ type: 'text', text: tn });
|
|
3579
|
+
/*
|
|
3580
|
+
* ★ 메모는 **델타보다 앞**이다. 델타는 「누가 무엇을 썼다」는 배경인데, 메모는
|
|
3581
|
+
* 저자가 **이 문서의 이 자리**를 짚어 남긴 할 일이다. 뒤에 두면 배경에 묻힌다.
|
|
3582
|
+
*/
|
|
3583
|
+
const nn = notesSays();
|
|
3584
|
+
if (nn) result.content.push({ type: 'text', text: nn });
|
|
3404
3585
|
const t = worldDelta();
|
|
3405
3586
|
if (!t) return;
|
|
3406
3587
|
/*
|
|
@@ -3666,6 +3847,7 @@ async function callTool(name, args = {}) {
|
|
|
3666
3847
|
case 'get_report': {
|
|
3667
3848
|
const q = args.outline ? '?outline=1' : '';
|
|
3668
3849
|
const r = await api('GET', `/api/reports/${Number(args.id)}${q}`);
|
|
3850
|
+
notesSeen(Number(args.id), r.open_notes);
|
|
3669
3851
|
if (!args.outline) {
|
|
3670
3852
|
/*
|
|
3671
3853
|
* ⚠ 전에는 여기서 **`r.data` 만** 돌려줬다. 서버가 정성껏 붙인 것이 이 한 줄에서
|
|
@@ -3698,6 +3880,62 @@ async function callTool(name, args = {}) {
|
|
|
3698
3880
|
// 문맥이 두 배가 된다. (같은 말을 데이터 없는 문서에도 쓰므로 함수로 뺐다.)
|
|
3699
3881
|
return outlineText(r.outline);
|
|
3700
3882
|
}
|
|
3883
|
+
case 'list_notes': {
|
|
3884
|
+
const id = Number(args.id);
|
|
3885
|
+
const qs = new URLSearchParams();
|
|
3886
|
+
if (args.state) qs.set('state', String(args.state));
|
|
3887
|
+
if (args.uid) qs.set('uid', String(args.uid));
|
|
3888
|
+
const r = await api('GET', `/api/reports/${id}/notes${qs.toString() ? '?' + qs : ''}`);
|
|
3889
|
+
notesSeen(id, r.open);
|
|
3890
|
+
if (!r.notes || !r.notes.length) {
|
|
3891
|
+
return `보고서 #${id} — ${r.note || '메모가 없다.'}`;
|
|
3892
|
+
}
|
|
3893
|
+
/*
|
|
3894
|
+
* 답글을 부모 밑에 붙여 낸다. 평평하게 내면 에이전트가 **자기가 이미 한 말**을
|
|
3895
|
+
* 못 보고 같은 답을 다시 단다.
|
|
3896
|
+
*/
|
|
3897
|
+
const kids = new Map();
|
|
3898
|
+
for (const n of r.notes) {
|
|
3899
|
+
if (n.parent_id !== null) {
|
|
3900
|
+
if (!kids.has(n.parent_id)) kids.set(n.parent_id, []);
|
|
3901
|
+
kids.get(n.parent_id).push(n);
|
|
3902
|
+
}
|
|
3903
|
+
}
|
|
3904
|
+
const STATE = {
|
|
3905
|
+
exact: '', moved: '(옮겨 붙음)', drifted: '(원문이 바뀜 — 저자가 본 것과 지금이 다르다)',
|
|
3906
|
+
reparented: '(문장을 잃어 상위로)', unmoored: '(자리를 잃음 — 문서 어디인지 모른다)',
|
|
3907
|
+
};
|
|
3908
|
+
const lines = [`보고서 #${id} · 본문 판 body_rev=${r.body_rev} · ${r.note}`, ''];
|
|
3909
|
+
for (const n of r.notes) {
|
|
3910
|
+
if (n.parent_id !== null) continue;
|
|
3911
|
+
const at = n.anchor.path || n.anchor.uid || '문서 전체';
|
|
3912
|
+
lines.push(`[메모 ${n.id}] ${at} ${STATE[n.anchor_state] || ''}${n.resolved ? ' · 반영됨' : ''}`);
|
|
3913
|
+
if (n.anchor.quote_exact) lines.push(` 저자가 짚은 글: 「${n.anchor.quote_exact}」`);
|
|
3914
|
+
lines.push(` ${n.author || ''}: ${n.body}`);
|
|
3915
|
+
for (const k of (kids.get(n.id) || [])) {
|
|
3916
|
+
lines.push(` ↳ ${k.author_kind === 'agent' ? '내 답글' : k.author}: ${k.body}`);
|
|
3917
|
+
}
|
|
3918
|
+
lines.push('');
|
|
3919
|
+
}
|
|
3920
|
+
return lines.join('\n').trimEnd();
|
|
3921
|
+
}
|
|
3922
|
+
case 'reply_note': {
|
|
3923
|
+
const id = Number(args.id);
|
|
3924
|
+
const r = await api('POST', `/api/reports/${id}/notes`,
|
|
3925
|
+
{ note: Number(args.note), body: String(args.body ?? '') });
|
|
3926
|
+
return `${r.says} (메모 ${args.note} · 답글 ${r.note.id})`;
|
|
3927
|
+
}
|
|
3928
|
+
case 'resolve_note': {
|
|
3929
|
+
const id = Number(args.id);
|
|
3930
|
+
const body = { note: Number(args.note) };
|
|
3931
|
+
if (args.rev !== undefined) body.rev = Number(args.rev);
|
|
3932
|
+
if (args.resolved !== undefined) body.resolved = !!args.resolved;
|
|
3933
|
+
const r = await api('PATCH', `/api/reports/${id}/notes`, body);
|
|
3934
|
+
// 표시했으니 꼬리의 셈도 줄인다 — 안 줄이면 「고쳤는데 계속 재촉하는」 꼴이 된다.
|
|
3935
|
+
const cur = NOTES_OPEN.get(id) || 0;
|
|
3936
|
+
notesSeen(id, Math.max(0, cur - 1));
|
|
3937
|
+
return r.says;
|
|
3938
|
+
}
|
|
3701
3939
|
case 'publish_report': {
|
|
3702
3940
|
const data = await materializeAssets(args.data);
|
|
3703
3941
|
// **기본은 초안이다.** 서버 기본값은 참(다른 호출자의 동작을 말없이 바꾸지 않는다)이라
|
|
@@ -3749,6 +3987,8 @@ async function callTool(name, args = {}) {
|
|
|
3749
3987
|
const payload = { ...data, tenant: args.tenant };
|
|
3750
3988
|
if (args.dry_run) payload.dry_run = true;
|
|
3751
3989
|
const r = await api('PUT', `/api/reports/${Number(args.id)}`, payload, PUBLISH);
|
|
3990
|
+
// 고친 직후가 「무엇을 아직 안 고쳤나」를 볼 가장 좋은 순간이다.
|
|
3991
|
+
if (!r.dry_run) notesSeen(Number(args.id), r.open_notes);
|
|
3752
3992
|
if (r.dry_run) {
|
|
3753
3993
|
return `시험 실행 — 아무것도 바꾸지 않았습니다.\n${r.tenant} · 건 ${r.cases}` + warnText(r);
|
|
3754
3994
|
}
|
|
@@ -3781,7 +4021,7 @@ async function callTool(name, args = {}) {
|
|
|
3781
4021
|
+ (w.stale ? ` · 낡음 ${w.stale}일째(AI 관측 — 활동이 멎었다: 사람이 닫거나 다시 살릴 일)` : '')
|
|
3782
4022
|
+ (w.doc ? ` · 요지: get_case(tenant, "${w.doc}")` : ' · 요지 문서 아직 없음'));
|
|
3783
4023
|
}
|
|
3784
|
-
if (!rows.length) lines.push('열린 일이 없다 — 0건은 0건이다. 착수 전에 세우려면
|
|
4024
|
+
if (!rows.length) lines.push('열린 일이 없다 — 0건은 0건이다. 착수 전에 세우려면 그 과업의 결정을 remember_decision 으로 남기되 **work 에 일의 열쇠를 적어라** — 다음 바퀴에 「계획」으로 선다. 표지 없이 tags 만 달면 신호가 쌓일 때까지 AI 가 기다린다.');
|
|
3785
4025
|
else lines.push('붙을 일이 보이면 works(tenant, claim: "<slug>") 로 선언한다 — 확정은 사람이 화면에서 한다.');
|
|
3786
4026
|
return lines.join('\n');
|
|
3787
4027
|
}
|
|
@@ -3807,6 +4047,7 @@ async function callTool(name, args = {}) {
|
|
|
3807
4047
|
title: args.title, what: args.what,
|
|
3808
4048
|
who: args.who || '', when: args.when || '', why: args.why || '',
|
|
3809
4049
|
reopen: args.reopen || '', status: args.status || '', tags: args.tags || [],
|
|
4050
|
+
work: args.work || '', order: args.order || [],
|
|
3810
4051
|
});
|
|
3811
4052
|
const lines = [`${r.note} — ${r.tenant} · ${r.path}`];
|
|
3812
4053
|
// 안 적은 것을 **그렇게 말한다** — 조용히 열린 결정을 만들거나 계산 밖에 두지 않는다.
|
|
@@ -3822,6 +4063,18 @@ async function callTool(name, args = {}) {
|
|
|
3822
4063
|
} else if (r.insights_queued === false) {
|
|
3823
4064
|
lines.push('전체 맥락 정리 줄은 안 섰다 — 재료가 아직 문턱 아래(배경 문서 3장 또는 결정·사례 5장)거나 이미 줄이 서 있다.');
|
|
3824
4065
|
}
|
|
4066
|
+
// 표지(work:)의 앞날 — 서버가 저장 시점에 판정한 것을 그대로 옮긴다(지어내지 않는다).
|
|
4067
|
+
if (r.work_mark === 'will-open') {
|
|
4068
|
+
lines.push(`다음 바퀴(1분 안)에 일 «${args.work}» 가 「계획」으로 섭니다 — works 로 확인한다.`);
|
|
4069
|
+
} else if (r.work_mark === 'exists-open') {
|
|
4070
|
+
lines.push(`일 «${args.work}» 는 이미 열려 있다 — 이 결정은 그 일의 재료가 된다.`);
|
|
4071
|
+
} else if (r.work_mark === 'exists-closed') {
|
|
4072
|
+
lines.push(`일 «${args.work}» 는 닫혀 있다 — 다시 열려면 사람이 화면에서 재개한다(표지로는 안 열린다).`);
|
|
4073
|
+
} else if (r.work_mark === 'vetoed') {
|
|
4074
|
+
lines.push(`일 «${args.work}» 는 전에 사람이 닫은 이름이라 안 선다 — 다른 이름을 쓰거나 화면에서 재개한다.`);
|
|
4075
|
+
} else if (args.work) {
|
|
4076
|
+
lines.push('표지(work)를 적었지만 이 서버는 아직 그 칸을 모른다 — 판이 낡았다.');
|
|
4077
|
+
}
|
|
3825
4078
|
return lines.join('\n');
|
|
3826
4079
|
}
|
|
3827
4080
|
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. 절 단위 키 — 전량
|