leerness 1.36.170 → 1.36.172
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/CHANGELOG.md +8 -0
- package/README.md +4 -4
- package/bin/leerness.js +208 -31
- package/lib/mcp-tools.js +2 -2
- package/lib/session-presence.js +100 -27
- package/lib/workspace-dir.js +82 -14
- package/package.json +6 -4
- package/scripts/e2e.js +2 -0
- package/scripts/mcp-presence-probe.js +394 -0
- package/scripts/workspace-dir-lock-order-probe.js +206 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.172 — 2026-08-27
|
|
4
|
+
|
|
5
|
+
- GitHub CI에서 재현된 동시 최초 .harness→.leerness 마이그레이션 TOCTOU를 수정했습니다. 라이브 마이그레이션이 두 번째 상태 검사와 충돌 스캔 전에 프로젝트 잠금을 획득하며, 실제 peer EEXIST와 잠금 해제 직전까지의 대기를 검증하는 결정론적 회귀를 fast/core/full 게이트에 연결했습니다.
|
|
6
|
+
|
|
7
|
+
## 1.36.171 — 2026-08-27
|
|
8
|
+
|
|
9
|
+
- MCP handoff/session-close가 호출별 sessionKey로 presence를 등록·종료하고, Windows 환경변수 casing·일회성 marker·legacy case variant·손상 closedAt를 안전하게 처리하도록 개선했습니다. 전용 MCP presence 회귀 22/22와 전체 E2E 467/467를 추가·통과했습니다.
|
|
10
|
+
|
|
3
11
|
## 1.36.170 — 2026-08-27
|
|
4
12
|
|
|
5
13
|
- next-action의 정규화 계약을 모든 읽기 표면으로 확장했습니다. release-time auto-resume plan 생성과 외부 에이전트용 `context`가 legacy `--filter`, 자동 완료 명령, 시간 기반 중복, 존재하지 않는 stress 실행을 다시 노출하지 않습니다.
|
package/README.md
CHANGED
|
@@ -138,7 +138,7 @@ MIT
|
|
|
138
138
|
<!-- leerness:project-readme:start -->
|
|
139
139
|
## Leerness Project Harness
|
|
140
140
|
|
|
141
|
-
이 프로젝트는 Leerness v1.36.
|
|
141
|
+
이 프로젝트는 Leerness v1.36.172 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
|
|
142
142
|
|
|
143
143
|
### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
|
|
144
144
|
|
|
@@ -192,7 +192,7 @@ leerness memory restore decision <date|title>
|
|
|
192
192
|
|
|
193
193
|
### MCP server (외부 AI 통합)
|
|
194
194
|
|
|
195
|
-
Leerness v1.36.
|
|
195
|
+
Leerness v1.36.172는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
|
|
196
196
|
|
|
197
197
|
```jsonc
|
|
198
198
|
// 카테고리별
|
|
@@ -213,7 +213,7 @@ Leerness v1.36.170는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
|
|
|
213
213
|
`<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
|
|
214
214
|
1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
|
|
215
215
|
|
|
216
|
-
현재 누적: **v1.9.x → 1.36.
|
|
216
|
+
현재 누적: **v1.9.x → 1.36.172 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
|
|
217
217
|
|
|
218
218
|
### 성능 가이드
|
|
219
219
|
|
|
@@ -251,5 +251,5 @@ leerness release pack --close --auto-main-push
|
|
|
251
251
|
- `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
|
|
252
252
|
- `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
|
|
253
253
|
|
|
254
|
-
Last synced by Leerness v1.36.
|
|
254
|
+
Last synced by Leerness v1.36.172: 2026-08-27
|
|
255
255
|
<!-- leerness:project-readme:end -->
|
package/bin/leerness.js
CHANGED
|
@@ -48,13 +48,33 @@ const {
|
|
|
48
48
|
migrateLegacyWorkspace,
|
|
49
49
|
} = require('../lib/workspace-dir');
|
|
50
50
|
|
|
51
|
-
const VERSION = '1.36.
|
|
51
|
+
const VERSION = '1.36.172';
|
|
52
|
+
|
|
53
|
+
// MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
|
|
54
|
+
// 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
|
|
55
|
+
let _directMcpPresenceMarkerPending = false;
|
|
56
|
+
function _deleteEnvKeyCaseInsensitive(env, canonical) {
|
|
57
|
+
const folded = String(canonical).toLowerCase();
|
|
58
|
+
for (const key of Object.keys(env || {})) {
|
|
59
|
+
if (key.toLowerCase() === folded) delete env[key];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function _captureDirectMcpPresenceMarker() {
|
|
63
|
+
const markerKey = process.platform === 'win32'
|
|
64
|
+
? Object.keys(process.env).find(key => key.toLowerCase() === 'leerness_mcp_address_explicit')
|
|
65
|
+
: (Object.prototype.hasOwnProperty.call(process.env, 'LEERNESS_MCP_ADDRESS_EXPLICIT')
|
|
66
|
+
? 'LEERNESS_MCP_ADDRESS_EXPLICIT' : null);
|
|
67
|
+
if (markerKey && process.env[markerKey] === '1') _directMcpPresenceMarkerPending = true;
|
|
68
|
+
// 인식 여부와 무관하게 모든 casing을 떼어 자식 프로세스에 일회성 marker가 새지 않게 한다.
|
|
69
|
+
_deleteEnvKeyCaseInsensitive(process.env, 'LEERNESS_MCP_ADDRESS_EXPLICIT');
|
|
70
|
+
}
|
|
52
71
|
|
|
53
72
|
// 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
|
|
54
73
|
// 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
|
|
55
74
|
// 해결: 부작용을 _cliBootstrap() 으로 묶고 파일 끝 require.main 가드에서만 호출. import/단위테스트는 부작용 0.
|
|
56
75
|
// 1.9.249 (UR-0018): 터미널 출력 한국어 깨짐 사전 방지 (stdout UTF-8 + Windows 비-65001 시 chcp 65001 자동, opt-out LEERNESS_NO_AUTOCHCP=1).
|
|
57
76
|
function _ensureStdoutEncoding() {
|
|
77
|
+
_captureDirectMcpPresenceMarker();
|
|
58
78
|
try {
|
|
59
79
|
if (process.stdout && process.stdout.setEncoding) process.stdout.setEncoding('utf8');
|
|
60
80
|
if (process.stderr && process.stderr.setEncoding) process.stderr.setEncoding('utf8');
|
|
@@ -7491,15 +7511,22 @@ function _selfTestCases() {
|
|
|
7491
7511
|
if (SP.suppressionReason(Object.assign({}, base, { LEERNESS_NO_SESSION_PRESENCE: '1' }), true) !== 'opt-out') return false;
|
|
7492
7512
|
if (SP.suppressionReason(base, false) !== 'not-a-leerness-project') return false;
|
|
7493
7513
|
if (SP.suppressionReason({}, true) !== 'not-identifiable') return false;
|
|
7494
|
-
|
|
7495
|
-
|
|
7514
|
+
const addressedMcp = Object.assign({}, base, { LEERNESS_INTERNAL: '1', LEERNESS_SESSION_ID: 'mcpdirect01', LEERNESS_MCP_ADDRESS_EXPLICIT: '1' });
|
|
7515
|
+
if (SP.suppressionReason(addressedMcp, true) !== null) return false;
|
|
7516
|
+
if (SP.suppressionReason(Object.assign({}, addressedMcp, { LEERNESS_HOOK: '1' }), true) !== 'hook') return false;
|
|
7517
|
+
if (SP.suppressionReason(Object.assign({}, addressedMcp, { CI: '1' }), true) !== 'ci') return false;
|
|
7518
|
+
// ⑤ 프루닝 — 입력은 {name, record}, 반환은 **파일명**. 열린 동료 레코드는 상한을 넘어도
|
|
7519
|
+
// 절대 자르지 않고, 닫힌 tombstone에만 상한/TTL을 적용한다.
|
|
7496
7520
|
const mkE = (i, extra) => ({ name: `key${String(i).padStart(5, '0')}.json`,
|
|
7497
7521
|
record: Object.assign({ sessionKey: `key${String(i).padStart(5, '0')}`, openedAt: new Date(1700000000000 + i * 1000).toISOString(), closedAt: null }, extra || {}) });
|
|
7498
7522
|
const many = Array.from({ length: 30 }, (_, i) => mkE(i));
|
|
7499
7523
|
const pruned = SP.selectPrunable(many, { selfKey: 'key00000', nowMs: Date.now() });
|
|
7500
|
-
if (pruned.
|
|
7501
|
-
|
|
7502
|
-
|
|
7524
|
+
if (pruned.length !== 0) return false; // 열린 peer 30건 모두 보존
|
|
7525
|
+
const manyClosed = many.map((e, i) => mkE(i, { closedAt: new Date(Date.now() - i * 1000).toISOString() }));
|
|
7526
|
+
const prunedClosed = SP.selectPrunable(manyClosed, { selfKey: 'key00000', nowMs: Date.now() });
|
|
7527
|
+
if (prunedClosed.includes('key00000.json')) return false; // 자기 것은 안 자른다
|
|
7528
|
+
if ((30 - prunedClosed.length) > SP.SESSIONS_KEEP + 1) return false; // 닫힌 레코드 상한은 실제로 걸린다
|
|
7529
|
+
if (!prunedClosed.length) return false;
|
|
7503
7530
|
const few = Array.from({ length: 5 }, (_, i) => mkE(i));
|
|
7504
7531
|
if (SP.selectPrunable(few, { selfKey: 'key00000', nowMs: Date.now() }).length !== 0) return false;
|
|
7505
7532
|
// close 기록이 TTL 을 넘긴 것은 자르고, 갓 닫힌 것은 안 자른다
|
|
@@ -7507,6 +7534,20 @@ function _selfTestCases() {
|
|
|
7507
7534
|
if (SP.selectPrunable(closedOld, { selfKey: 'other123', nowMs: Date.now() }).length !== 1) return false;
|
|
7508
7535
|
const closedNew = [mkE(1, { closedAt: new Date(Date.now() - 60000).toISOString() })];
|
|
7509
7536
|
if (SP.selectPrunable(closedNew, { selfKey: 'other123', nowMs: Date.now() }).length !== 0) return false;
|
|
7537
|
+
// canonical toISOString이 아닌 close 값은 종료 증거가 아니다. Date.parse가 관대하게 받는
|
|
7538
|
+
// 숫자/불가능한 달력 날짜도 20개 상한이나 payload mismatch 정리로 지우지 않고,
|
|
7539
|
+
// 같은 host의 다른 세션 기록으로 계속 센다.
|
|
7540
|
+
const malformedValues = ['not-a-date', '0', '1', '2024-02-30T00:00:00.000Z', '2024-02-30T00:00:00Z'];
|
|
7541
|
+
const malformed = Array.from({ length: 21 }, (_, i) => mkE(i, { closedAt: malformedValues[i % malformedValues.length] }));
|
|
7542
|
+
malformed.push({ name: 'wrongpeer.json', record: { sessionKey: 'otherpeer', hostId: 'hostAAA', closedAt: 'not-a-date' } });
|
|
7543
|
+
if (SP.selectPrunable(malformed, { selfKey: 'me000000', nowMs: Date.now() }).length !== 0) return false;
|
|
7544
|
+
if (malformed.some(e => SP.hasConfirmedClose(e.record))) return false;
|
|
7545
|
+
if (SP.confirmedCloseMs({ closedAt: new Date(1700000000000).toISOString() }) !== 1700000000000) return false;
|
|
7546
|
+
const caseConflict = SP.caseVariantConflict([
|
|
7547
|
+
Object.assign({}, { name: 'CasePeer01.json', record: { sessionKey: 'CasePeer01', closedAt: null } }),
|
|
7548
|
+
Object.assign({}, { name: 'casepeer01.json', record: { sessionKey: 'casepeer01', closedAt: 'not-a-date' } }),
|
|
7549
|
+
]);
|
|
7550
|
+
if (!caseConflict || caseConflict.count !== 2 || caseConflict.reason !== 'ambiguous-open-case-variants') return false;
|
|
7510
7551
|
// (검수 P2, 실측 재현) **파일명과 키가 어긋난 레코드**가 남을 지우면 안 된다 — 자기 파일만 정리된다
|
|
7511
7552
|
const victim = { name: 'victimxx.json', record: { sessionKey: 'victimxx', openedAt: new Date().toISOString(), lastHandoffAt: new Date().toISOString(), closedAt: null } };
|
|
7512
7553
|
const forged = { name: 'fakefake.json', record: { sessionKey: 'victimxx', openedAt: new Date(1700000000000).toISOString(), closedAt: new Date(1700000000000).toISOString() } };
|
|
@@ -7526,6 +7567,8 @@ function _selfTestCases() {
|
|
|
7526
7567
|
{ sessionKey: 'otherkk3', hostId: 'hostBBB', closedAt: null },
|
|
7527
7568
|
];
|
|
7528
7569
|
if (SP.countOtherSessions(recs, 'selfkey1', H) !== 1) return false;
|
|
7570
|
+
if (SP.countOtherSessions([{ sessionKey: 'otherkk1', hostId: H, closedAt: 'not-a-date' }], 'selfkey1', H) !== 1) return false;
|
|
7571
|
+
if (SP.countOtherSessions([{ sessionKey: 'SelfKey1', hostId: H, closedAt: null }], 'selfkey1', H) !== 0) return false;
|
|
7529
7572
|
if (SP.countOtherSessions([recs[0]], 'selfkey1', H) !== 0) return false; // 자기만 → 0(침묵)
|
|
7530
7573
|
if (SP.countOtherSessions([recs[3]], 'selfkey1', H) !== 0) return false; // 다른 머신뿐 → 0(침묵)
|
|
7531
7574
|
// (검수 P2) hostId 를 모르면 '다른 머신' 이라고 단정하지 않는다 — 3상태
|
|
@@ -13206,24 +13249,62 @@ function _sessionHostId(root) {
|
|
|
13206
13249
|
return require('crypto').createHash('sha256').update(salt + '|' + String(os.hostname())).digest('hex').slice(0, 12);
|
|
13207
13250
|
} catch { return null; }
|
|
13208
13251
|
}
|
|
13252
|
+
function _sessionRecordActivityMs(record) {
|
|
13253
|
+
const r = record || {};
|
|
13254
|
+
return Math.max(Date.parse(r.lastHandoffAt || '') || 0, Date.parse(r.closedAt || '') || 0,
|
|
13255
|
+
Date.parse(r.openedAt || '') || 0);
|
|
13256
|
+
}
|
|
13257
|
+
// 1.36.171 (외부 Codex P2): 1.36.132 이전 POSIX 저장소에는 CaseKey.json/`CaseKey`와
|
|
13258
|
+
// casekey.json/`casekey`가 동시에 남을 수 있다. 읽기 명령은 파일을 바꾸지 않되, 같은 case-fold 주소를
|
|
13259
|
+
// 하나의 논리 레코드로 접어 자기 자신을 '다른 세션'으로 세지 않는다.
|
|
13260
|
+
function _collapseSessionEntries(rawEntries) {
|
|
13261
|
+
const SP = require('../lib/session-presence');
|
|
13262
|
+
const passthrough = [];
|
|
13263
|
+
const grouped = new Map();
|
|
13264
|
+
for (const entry of (rawEntries || [])) {
|
|
13265
|
+
const fileKey = String(entry && entry.name || '').replace(/\.json$/, '');
|
|
13266
|
+
const recordKey = entry && entry.record && entry.record.sessionKey;
|
|
13267
|
+
if (!SP.isValidKey(recordKey) || fileKey.toLowerCase() !== String(recordKey).toLowerCase()) {
|
|
13268
|
+
passthrough.push(entry); continue;
|
|
13269
|
+
}
|
|
13270
|
+
const folded = String(recordKey).toLowerCase();
|
|
13271
|
+
const normalizedRecord = Object.assign({}, entry.record, { sessionKey: folded });
|
|
13272
|
+
// 저장된 표식은 현재 물리 파일 집합으로 다시 계산한다. variant가 사라졌는데 경고만 남는 stale 상태를 막는다.
|
|
13273
|
+
delete normalizedRecord.caseVariantConflict;
|
|
13274
|
+
const candidate = { name: `${folded}.json`, record: normalizedRecord };
|
|
13275
|
+
const prev = grouped.get(folded);
|
|
13276
|
+
if (!prev) { grouped.set(folded, { logical: candidate, sourceName: entry.name, sources: [entry] }); continue; }
|
|
13277
|
+
prev.sources.push(entry);
|
|
13278
|
+
const pa = _sessionRecordActivityMs(prev.logical.record), ca = _sessionRecordActivityMs(candidate.record);
|
|
13279
|
+
const candidateWins = ca > pa
|
|
13280
|
+
|| (ca === pa && !SP.hasConfirmedClose(candidate.record) && SP.hasConfirmedClose(prev.logical.record))
|
|
13281
|
+
|| (ca === pa && SP.hasConfirmedClose(candidate.record) === SP.hasConfirmedClose(prev.logical.record) && entry.name === `${folded}.json`);
|
|
13282
|
+
if (candidateWins) { prev.logical = candidate; prev.sourceName = entry.name; }
|
|
13283
|
+
}
|
|
13284
|
+
return passthrough.concat([...grouped.values()].map((group) => {
|
|
13285
|
+
const conflict = SP.caseVariantConflict(group.sources);
|
|
13286
|
+
if (conflict) group.logical.record.caseVariantConflict = conflict;
|
|
13287
|
+
return group.logical;
|
|
13288
|
+
}));
|
|
13289
|
+
}
|
|
13209
13290
|
// ⚠ (검수 P1) 예전엔 읽기 실패를 빈 배열로 삼켰다 — 그러면 "기록이 없다" 와 "확인할 수 없다" 가
|
|
13210
13291
|
// 같은 출력이 된다(조용한 0). 못 읽은 사실을 **함께 돌려준다**.
|
|
13211
13292
|
function _readSessionEntries(root) {
|
|
13212
13293
|
const dir = _sessionStoreDir(root);
|
|
13213
|
-
const
|
|
13294
|
+
const rawEntries = [];
|
|
13214
13295
|
let names = [];
|
|
13215
13296
|
let unreadable = 0, storeError = null;
|
|
13216
13297
|
try { names = fs.readdirSync(dir); }
|
|
13217
13298
|
catch (e) {
|
|
13218
|
-
if (e && e.code === 'ENOENT') return { entries, unreadable: 0, storeError: null }; // 아직 아무도 안 썼다 = 진짜 0
|
|
13219
|
-
return { entries, unreadable: 0, storeError: String((e && e.code) || 'EREAD') };
|
|
13299
|
+
if (e && e.code === 'ENOENT') return { entries: [], rawEntries: [], unreadable: 0, storeError: null }; // 아직 아무도 안 썼다 = 진짜 0
|
|
13300
|
+
return { entries: [], rawEntries: [], unreadable: 0, storeError: String((e && e.code) || 'EREAD') };
|
|
13220
13301
|
}
|
|
13221
13302
|
for (const n of names) {
|
|
13222
13303
|
if (!SESSION_FILE_RE.test(n)) continue; // 염 파일·tmp·lock 잔재는 목록에서 제외
|
|
13223
|
-
try { const j = JSON.parse(read(path.join(dir, n))); if (j && j.sessionKey)
|
|
13304
|
+
try { const j = JSON.parse(read(path.join(dir, n))); if (j && j.sessionKey) rawEntries.push({ name: n, record: j }); else unreadable++; }
|
|
13224
13305
|
catch { unreadable++; } // 손상은 '없음' 이 아니라 '못 읽음' 이다
|
|
13225
13306
|
}
|
|
13226
|
-
return { entries, unreadable, storeError };
|
|
13307
|
+
return { entries: _collapseSessionEntries(rawEntries), rawEntries, unreadable, storeError };
|
|
13227
13308
|
}
|
|
13228
13309
|
function _readSessionRecords(root) { return _readSessionEntries(root).entries.map(e => e.record); }
|
|
13229
13310
|
// phase: 'open' (handoff) | 'close' (session close)
|
|
@@ -13232,21 +13313,30 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
13232
13313
|
const SP = require('../lib/session-presence');
|
|
13233
13314
|
try {
|
|
13234
13315
|
root = absRoot(root);
|
|
13316
|
+
// CLI bootstrap에서 떼어 둔 MCP direct-lifecycle marker는 이 기록 시도 한 번에만 복원한다.
|
|
13317
|
+
// require 기반 호출도 안전하도록 여기서 한 번 더 capture한다.
|
|
13318
|
+
_captureDirectMcpPresenceMarker();
|
|
13319
|
+
const presenceEnv = SP.normalizePresenceEnv(process.env);
|
|
13320
|
+
if (_directMcpPresenceMarkerPending) {
|
|
13321
|
+
presenceEnv.LEERNESS_MCP_ADDRESS_EXPLICIT = '1';
|
|
13322
|
+
_directMcpPresenceMarkerPending = false;
|
|
13323
|
+
}
|
|
13235
13324
|
const hasHarness = exists(path.join(root, '.leerness'));
|
|
13236
|
-
const reason = SP.suppressionReason(
|
|
13325
|
+
const reason = SP.suppressionReason(presenceEnv, hasHarness);
|
|
13237
13326
|
if (reason) return { written: false, reason };
|
|
13238
|
-
const key = SP.deriveSessionKey(
|
|
13327
|
+
const key = SP.deriveSessionKey(presenceEnv);
|
|
13239
13328
|
if (!SP.isValidKey(key)) return { written: false, reason: 'not-identifiable' };
|
|
13240
13329
|
const dir = _sessionStoreDir(root);
|
|
13241
13330
|
mkdirp(dir);
|
|
13242
13331
|
const fp = path.join(dir, `${key}.json`);
|
|
13243
13332
|
// 경로 가둠 — 키 정규식이 이미 막지만, 실제 해석 결과가 스토어 밖이면 쓰지 않는다(이중 방어).
|
|
13244
13333
|
if (path.dirname(path.resolve(fp)) !== path.resolve(dir)) return { written: false, reason: 'path-escape' };
|
|
13245
|
-
const det = SP.detectOutermostAgent(
|
|
13334
|
+
const det = SP.detectOutermostAgent(presenceEnv);
|
|
13246
13335
|
const nowIso = new Date().toISOString();
|
|
13247
13336
|
let res = { written: false, reason: 'lock' };
|
|
13248
13337
|
_withLock(fp, () => {
|
|
13249
13338
|
let prev = null;
|
|
13339
|
+
const legacyVariants = [];
|
|
13250
13340
|
if (exists(fp)) {
|
|
13251
13341
|
try { prev = JSON.parse(read(fp)); } catch {
|
|
13252
13342
|
// 손상 레코드는 **지우고 새로 쓴다**. `writeUtf8` 의 손상 구조 경로는 stderr 경고를 내는데,
|
|
@@ -13255,12 +13345,45 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
13255
13345
|
try { fs.unlinkSync(fp); } catch { /* 지우지 못해도 아래 쓰기가 덮는다 */ }
|
|
13256
13346
|
}
|
|
13257
13347
|
}
|
|
13348
|
+
const canonicalPrev = prev;
|
|
13349
|
+
// OS 이름으로 case sensitivity를 추측하지 않는다. 디렉터리의 exact canonical entry가 없는데
|
|
13350
|
+
// canonical path가 이미 resolve되면 같은 물리 파일을 가리키는 case-insensitive 저장소다.
|
|
13351
|
+
// 반대로 exact entry가 있거나 canonical path가 resolve되지 않으면 variant는 별도 파일이다.
|
|
13352
|
+
try {
|
|
13353
|
+
const storedNames = fs.readdirSync(dir);
|
|
13354
|
+
const canonicalName = `${key}.json`;
|
|
13355
|
+
const distinctCaseEntries = storedNames.includes(canonicalName) || !exists(fp);
|
|
13356
|
+
if (distinctCaseEntries) {
|
|
13357
|
+
for (const name of storedNames) {
|
|
13358
|
+
if (!SESSION_FILE_RE.test(name) || name === `${key}.json`
|
|
13359
|
+
|| name.replace(/\.json$/, '').toLowerCase() !== key) continue;
|
|
13360
|
+
const variantPath = path.join(dir, name);
|
|
13361
|
+
try {
|
|
13362
|
+
const rawText = read(variantPath);
|
|
13363
|
+
const record = JSON.parse(rawText);
|
|
13364
|
+
if (!record || !record.sessionKey || String(record.sessionKey).toLowerCase() !== key) continue;
|
|
13365
|
+
legacyVariants.push({ name, path: variantPath, rawText, record });
|
|
13366
|
+
} catch { /* 손상 variant는 읽기 표면에서 unreadable로 남기며 여기서 지우지 않는다 */ }
|
|
13367
|
+
}
|
|
13368
|
+
}
|
|
13369
|
+
} catch { /* 저장소 열거 실패는 본 canonical 기록을 막지 않는다 */ }
|
|
13370
|
+
const caseConflict = SP.caseVariantConflict([
|
|
13371
|
+
...(canonicalPrev ? [{ name: `${key}.json`, record: canonicalPrev }] : []),
|
|
13372
|
+
...legacyVariants,
|
|
13373
|
+
]);
|
|
13374
|
+
// 열린 variant가 둘 이상이면 서로 다른 구버전 세션이었을 수 있다. 어느 하나도 병합 근거로
|
|
13375
|
+
// 사용하지 않고 물리 파일도 보존한다. 충돌이 없을 때만 최신 legacy 상태를 이월한다.
|
|
13376
|
+
if (!caseConflict) {
|
|
13377
|
+
for (const variant of legacyVariants) {
|
|
13378
|
+
if (!prev || _sessionRecordActivityMs(variant.record) > _sessionRecordActivityMs(prev)) prev = variant.record;
|
|
13379
|
+
}
|
|
13380
|
+
}
|
|
13258
13381
|
const _prevAgent = prev && prev.outermostAgent;
|
|
13259
13382
|
// 1.36.132 (검수 P2): 라벨 순서를 안 맞추면 `A<->B` 와 `B<->A` 가 번갈아 기록되며 **매 handoff 마다** 새 경고가 났다.
|
|
13260
13383
|
// 같은 두 에이전트는 같은 표식이어야 한다 — 정렬해서 순서를 없앤다.
|
|
13261
13384
|
const _sharedAgents = (_prevAgent && _prevAgent !== 'unknown' && det.agent !== 'unknown' && _prevAgent !== det.agent)
|
|
13262
13385
|
? [_prevAgent, det.agent].sort().join('<->') : ((prev && prev.sharedKeyAgents) || null);
|
|
13263
|
-
if (_sharedAgents && !(prev && prev.sharedKeyAgents === _sharedAgents) &&
|
|
13386
|
+
if (_sharedAgents && !(prev && prev.sharedKeyAgents === _sharedAgents) && presenceEnv.LEERNESS_NO_LOCK_WARN !== '1') {
|
|
13264
13387
|
try { process.stderr.write(`⚠ 세션 주소 공유 감지: ${key} 를 서로 다른 에이전트(${_sharedAgents})가 쓰고 있습니다 — 두 세션이 한 기록으로 합쳐져 서로를 못 봅니다. 세션마다 다른 LEERNESS_SESSION_ID 를 쓰세요.\n`); } catch {}
|
|
13265
13388
|
}
|
|
13266
13389
|
const _handoffHistory = Array.isArray(prev && prev.handoffHistory) ? prev.handoffHistory.slice(-10) : [];
|
|
@@ -13268,7 +13391,7 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
13268
13391
|
const rec = {
|
|
13269
13392
|
schemaVersion: 1,
|
|
13270
13393
|
sessionKey: key,
|
|
13271
|
-
keySource: SP.keySource(
|
|
13394
|
+
keySource: SP.keySource(presenceEnv), // 1.36.132: 3종(LEERNESS_SESSION_ID 우선) — 손으로 적은 삼항은 새 출처를 놓친다
|
|
13272
13395
|
outermostAgent: det.agent,
|
|
13273
13396
|
agentEvidence: det.evidence, // 결론이 아니라 원증거 키 이름 — 규칙이 틀려도 원자료가 남는다
|
|
13274
13397
|
hostId: _sessionHostId(root),
|
|
@@ -13285,6 +13408,7 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
13285
13408
|
// 라벨이 마지막 기록자로 덮임). 라벨이 **둘 다 unknown 이 아니면서** 바뀌었다면 그건 공유의 증거다.
|
|
13286
13409
|
// unknown 을 제외하는 이유: 셸마다 env 가시성이 달라 라벨이 흔들릴 때 오탐이 난다(실측).
|
|
13287
13410
|
sharedKeyAgents: _sharedAgents,
|
|
13411
|
+
caseVariantConflict: caseConflict,
|
|
13288
13412
|
};
|
|
13289
13413
|
// 1.36.133 (정찰 실측): `rec` 는 닫힌 리터럴이라 **우리가 모르는 키는 다음 handoff 에서 사라졌다** —
|
|
13290
13414
|
// 주입한 `scope`/`futureField` 가 handoff 한 번에 소멸, exit 0, **stderr 0바이트**. 버전 혼재가 이 기계에 실재한다
|
|
@@ -13315,6 +13439,17 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
13315
13439
|
if (_carried.length) rec.carriedUnknownKeys = _carried;
|
|
13316
13440
|
if (_dropped.length) rec.droppedUnknownKeys = _dropped.slice(0, 10);
|
|
13317
13441
|
writeUtf8(fp, JSON.stringify(rec, null, 2) + '\n');
|
|
13442
|
+
for (const variant of legacyVariants) {
|
|
13443
|
+
if (caseConflict && !SP.hasConfirmedClose(variant.record)) continue; // 모호한 열린 세션은 절대 자동 삭제하지 않는다
|
|
13444
|
+
try {
|
|
13445
|
+
_withLock(variant.path, () => {
|
|
13446
|
+
let current = null;
|
|
13447
|
+
try { current = read(variant.path); } catch { return; }
|
|
13448
|
+
if (current !== variant.rawText) return; // 구버전 writer가 갱신함 — 다음 회차로 미룬다
|
|
13449
|
+
try { fs.unlinkSync(variant.path); } catch { /* canonical은 이미 안전하게 기록됨 */ }
|
|
13450
|
+
}, { maxWaitMs: 250 });
|
|
13451
|
+
} catch { /* 잠금을 못 얻으면 variant를 보존한다 */ }
|
|
13452
|
+
}
|
|
13318
13453
|
res = { written: true, reason: null };
|
|
13319
13454
|
});
|
|
13320
13455
|
// 프루닝은 락 밖(자기 파일 락과 무관한 남의 파일 삭제) — 상한이 없으면 무한히 자란다.
|
|
@@ -13322,7 +13457,8 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
13322
13457
|
// ⚠ (검수 P2) 프루너가 **삭제 대상의 락을 안 잡아서** 남이 방금 갱신한 레코드를 지울 수 있었다.
|
|
13323
13458
|
// 파일별 락을 잡고, 락 안에서 **다시 읽어** 여전히 삭제 대상인지 확인한 뒤에만 지운다.
|
|
13324
13459
|
const snap = _readSessionEntries(root);
|
|
13325
|
-
const
|
|
13460
|
+
const physical = snap.rawEntries || snap.entries;
|
|
13461
|
+
const doomed = SP.selectPrunable(physical, { selfKey: key, nowMs: Date.now() });
|
|
13326
13462
|
for (const name of doomed) {
|
|
13327
13463
|
if (!SESSION_FILE_RE.test(name)) continue;
|
|
13328
13464
|
const victim = path.join(dir, name);
|
|
@@ -13333,7 +13469,7 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
13333
13469
|
let cur = null;
|
|
13334
13470
|
try { cur = JSON.parse(read(victim)); } catch { cur = null; }
|
|
13335
13471
|
const still = SP.selectPrunable([{ name, record: cur }].concat(
|
|
13336
|
-
|
|
13472
|
+
physical.filter(e => e.name !== name)), { selfKey: key, nowMs: Date.now() });
|
|
13337
13473
|
if (!still.includes(name)) return; // 조건이 사라졌다 → 보존
|
|
13338
13474
|
try { fs.unlinkSync(victim); } catch { /* 남아도 다음에 다시 시도 */ }
|
|
13339
13475
|
});
|
|
@@ -13374,9 +13510,11 @@ function _sessionSignal(root, opts = {}) {
|
|
|
13374
13510
|
let mine = null;
|
|
13375
13511
|
try { mine = selfKey ? (snap.entries.find(e => e.record && e.record.sessionKey === selfKey) || null) : null; } catch {}
|
|
13376
13512
|
const sharedKeyAgents = (mine && mine.record && mine.record.sharedKeyAgents) || null;
|
|
13513
|
+
const caseVariantConflict = (mine && mine.record && mine.record.caseVariantConflict) || null;
|
|
13377
13514
|
return {
|
|
13378
13515
|
others, // 다른 세션 **기록** 수(활성 여부는 판정하지 않는다)
|
|
13379
13516
|
sharedKeyAgents,
|
|
13517
|
+
caseVariantConflict,
|
|
13380
13518
|
// 1.36.132 (검수 P1): 명시 주소가 형식에 안 맞으면 종전엔 조용히 다음 출처로 넘어갔다(오타를 안 알림).
|
|
13381
13519
|
invalidExplicitKey: SP.invalidExplicitKey(process.env),
|
|
13382
13520
|
// 1.36.132 (검수 P1): 저장소를 못 읽은 것을 `others:0` 으로 축약하지 않는다 — 이 저장소의 규율이다.
|
|
@@ -13390,7 +13528,7 @@ function _sessionSignal(root, opts = {}) {
|
|
|
13390
13528
|
// 1.36.133: **서브에이전트가 빠져 있었다.** 이 프로젝트의 session-workflow 는 파일 경로를 나눠 주는
|
|
13391
13529
|
// 병렬 sub-agent 를 의무화하는데, 그들은 `child-agent` 로 억제돼 레지스트리에 아예 안 나타난다 —
|
|
13392
13530
|
// 즉 겹침이 가장 잘 나는 주체가 구조적으로 안 보인다. 못 보는 것을 안 적으면 "0건" 이 거짓말이 된다.
|
|
13393
|
-
blindSpots: ['handoff 를 부르지 않는 세션', 'MCP
|
|
13531
|
+
blindSpots: ['handoff 를 부르지 않는 세션', 'sessionKey 없이 lifecycle handoff/close 를 호출하는 MCP 클라이언트',
|
|
13394
13532
|
'병렬 sub-agent (CLAUDE_CODE_CHILD_SESSION — 억제되어 등록되지 않음)',
|
|
13395
13533
|
'주소가 없는 에이전트(cursor/vscode/grok 등 · codex 는 CODEX_THREAD_ID 가 없는 실행에서만, TUI 미검증)',
|
|
13396
13534
|
'다른 머신', '구버전 설치본'],
|
|
@@ -13406,6 +13544,7 @@ function _sessionSignalFlags(ss) {
|
|
|
13406
13544
|
out.push(`🙈 나는 안 보임(${ss.selfSuppressedReason})`);
|
|
13407
13545
|
}
|
|
13408
13546
|
if (ss.sharedKeyAgents) out.push(`⚠ 주소 공유(${ss.sharedKeyAgents})`);
|
|
13547
|
+
if (ss.caseVariantConflict) out.push(`⚠ 대소문자 세션 충돌(${ss.caseVariantConflict.count || '?'})`);
|
|
13409
13548
|
if (ss.storeError) out.push(`⚠ 세션저장소 읽기실패(${ss.storeError})`);
|
|
13410
13549
|
if (ss.invalidExplicitKey) out.push(`⚠ LEERNESS_SESSION_ID 형식 오류("${ss.invalidExplicitKey}") — 무시됨`);
|
|
13411
13550
|
return out;
|
|
@@ -13480,14 +13619,15 @@ function sessionsCmd(root) {
|
|
|
13480
13619
|
outermostAgent: r.outermostAgent || 'unknown',
|
|
13481
13620
|
// ⚠ (검수 P2) `hostId` 가 없으면 '다른 머신' 이 아니라 **모르는 것**이다 — 3상태로 답한다.
|
|
13482
13621
|
hostRelation: SP.hostRelation(r.hostId, selfHost),
|
|
13483
|
-
closeRecorded:
|
|
13622
|
+
closeRecorded: SP.hasConfirmedClose(r),
|
|
13484
13623
|
lastHandoffAt: r.lastHandoffAt || null,
|
|
13485
13624
|
handoffCount: Number(r.handoffCount || 0) || 0,
|
|
13486
13625
|
leernessVersion: r.leernessVersion || null,
|
|
13626
|
+
caseVariantConflict: r.caseVariantConflict || null,
|
|
13487
13627
|
}));
|
|
13488
13628
|
const blindSpots = [
|
|
13489
13629
|
t('leerness handoff 를 돌리지 않은 에이전트 세션', 'agent sessions that never ran `leerness handoff`'),
|
|
13490
|
-
t('
|
|
13630
|
+
t('sessionKey 없이 lifecycle handoff/close를 호출하는 MCP 클라이언트', 'MCP clients calling lifecycle handoff/close without a sessionKey'),
|
|
13491
13631
|
t(`${VERSION} 이전 버전으로 시작된 세션`, `sessions started on a version older than ${VERSION}`),
|
|
13492
13632
|
// 1.36.132: 종전엔 '세션키가 없는 에이전트(codex/cursor 등)' 이라고 적었는데 codex 는 이제 주소가 있다.
|
|
13493
13633
|
// 자기 도구의 능력을 과소·과대 주장하지 않는다 — 실측 범위를 그대로 적는다(codex exec 경로에서 확인).
|
|
@@ -13546,7 +13686,10 @@ function sessionsCmd(root) {
|
|
|
13546
13686
|
: (r.hostRelation === 'different'
|
|
13547
13687
|
? t('다른 머신 — 확인 불가', 'another machine — unverifiable')
|
|
13548
13688
|
: t('머신 판별 불가 — 확인 불가', 'machine unknown — unverifiable'));
|
|
13549
|
-
|
|
13689
|
+
const collision = r.caseVariantConflict
|
|
13690
|
+
? t(` · ⚠ 대소문자 충돌 ${r.caseVariantConflict.count || '?'}건`, ` · ⚠ ${r.caseVariantConflict.count || '?'} case variants conflict`)
|
|
13691
|
+
: '';
|
|
13692
|
+
log(` ${who} ${String(r.sessionKey).slice(0, 8)}… ${String(r.outermostAgent).padEnd(13)} ${when}${collision}`);
|
|
13550
13693
|
}
|
|
13551
13694
|
}
|
|
13552
13695
|
log('');
|
|
@@ -16245,6 +16388,8 @@ function memorySearch(root, query) {
|
|
|
16245
16388
|
|
|
16246
16389
|
function handoff(root) {
|
|
16247
16390
|
root = absRoot(root);
|
|
16391
|
+
// hook/--no-record 경로도 marker를 child env로 넘기지 않도록 기록 gate보다 먼저 소비한다.
|
|
16392
|
+
_captureDirectMcpPresenceMarker();
|
|
16248
16393
|
const _versionSkew = _handoffVersionSkew(root);
|
|
16249
16394
|
// 1.9.199: handoff 진입 시 1) 이전 timestamp 보존 → 2) 현재 timestamp 기록
|
|
16250
16395
|
// detector 가 prior gap 을 정확히 측정 (overwrite 전 값 필요). 함수 최상단에 선언.
|
|
@@ -21268,6 +21413,7 @@ const _sessionClose = require('../lib/session-close');
|
|
|
21268
21413
|
// 1.9.425 (UR-0025/UR-0125 큰 핸들러 모듈화 10번째): sessionClose → lib/session-close.js (DI 위임)
|
|
21269
21414
|
function sessionClose(root, opts = {}) { // 1.36.132 (검수): handoff 는 --no-record/LEERNESS_HOOK 게이트 뒤에서 기록하는데 여기는 **게이트 없이** 기록했다
|
|
21270
21415
|
// (실측: handoff --no-record → 0건, session close --no-record → 1건). 같은 스토어의 같은 계약은 같은 문장으로.
|
|
21416
|
+
_captureDirectMcpPresenceMarker(); // hook/--no-record여도 nested skill/drift child에는 일회성 MCP marker를 물려주지 않는다
|
|
21271
21417
|
if (!has('--no-record') && process.env.LEERNESS_HOOK !== '1') { try { _sessionPresenceRecord(root, 'close'); } catch {} } // 1.36.129 (P-0016 P1): close 는 '기록됨' 일 뿐 '프로세스가 죽었다' 가 아니다
|
|
21272
21418
|
return _sessionClose.sessionClose(root, opts, { VERSION, STATUSES, MARK, has, arg, uiLang: _uiLang(root), harnessPath: __filename, readProgressRows, evidencePath, handoffPath, currentStatePath, taskLogPath, verifyRules, _autoRoadmap, _readUsageStats, readSessionCounter, writeSessionCounter, _retroAggregate, _retroOneLine, retroCmd, _loadDecisions, readRules, planPath, _loadLessons, _readFeatureGraph, _auditUserRequests, _detectDeliveredRequests, _computeRoundHistory, _computeMilestones, _computeRecentChanges, _collectPyFiles, _analyzePyFile, _collectRuntimeEnv, _scanShellScriptsEncoding, _listAPISkills, _matchAPISkills, _loadShellFailures, _shellEnvDrift, _runPreWakeAudit, _saveAndAppendPreWakeReport, _runIdempotencyAudit, _detectAbnormalShutdown, _updateUserRequest, _detectOptimism, _scanCodeForPatterns, _collectSecretFindings, _withLock }); } // 1.17.6 (UR-0049): 마감 정합 — done 낙관 재확인 + 시크릿 재확인 · 1.36.108: 락 주입(T-0097)
|
|
21273
21419
|
|
|
@@ -25661,10 +25807,20 @@ function mcpServeCmd(root) {
|
|
|
25661
25807
|
// 자식 프로세스로 돌리므로 **env 로 주소를 넘길 수 있다** — 호출마다 다른 주소를 준다.
|
|
25662
25808
|
function callLeerness(cliArgs, extraEnv) {
|
|
25663
25809
|
const budget = _mcpTimeoutFor(cliArgs);
|
|
25810
|
+
const childEnv = { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_STALE_CHECK: '1', LEERNESS_NO_DRIFT_CHECK: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_WORKFLOW_GUIDE: '1' };
|
|
25811
|
+
// MCP 서버 프로세스가 바깥 호출의 일회성 presence marker를 물려받았더라도 다음 호출로
|
|
25812
|
+
// 재사용하지 않는다. 검증된 direct lifecycle 호출만 아래 extraEnv에서 다시 부여한다.
|
|
25813
|
+
_deleteEnvKeyCaseInsensitive(childEnv, 'LEERNESS_MCP_ADDRESS_EXPLICIT');
|
|
25814
|
+
// Windows child_process는 env 이름을 대소문자 비구분으로 하나만 전달한다. 호출별 주소를
|
|
25815
|
+
// 덮을 때 상속된 다른 casing이 남아 있으면 어느 값이 선택될지 정렬 순서에 의존한다.
|
|
25816
|
+
if (extraEnv && Object.prototype.hasOwnProperty.call(extraEnv, 'LEERNESS_SESSION_ID')) {
|
|
25817
|
+
_deleteEnvKeyCaseInsensitive(childEnv, 'LEERNESS_SESSION_ID');
|
|
25818
|
+
}
|
|
25819
|
+
Object.assign(childEnv, extraEnv || {});
|
|
25664
25820
|
const r = cp.spawnSync(process.execPath, [__filename, ...cliArgs], {
|
|
25665
25821
|
encoding: 'utf8',
|
|
25666
25822
|
timeout: budget,
|
|
25667
|
-
env:
|
|
25823
|
+
env: childEnv
|
|
25668
25824
|
});
|
|
25669
25825
|
const cmd = `leerness ${cliArgs.map(_shQuote).join(' ')}`;
|
|
25670
25826
|
const partial = ((r.stdout || '') + (r.stderr || '')).trim();
|
|
@@ -25720,14 +25876,14 @@ function mcpServeCmd(root) {
|
|
|
25720
25876
|
return send({ jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params: arguments must be an object' } });
|
|
25721
25877
|
}
|
|
25722
25878
|
const args = _rawArgs || {};
|
|
25879
|
+
const _toolDef = TOOLS.find(t => t.name === name) || null;
|
|
25723
25880
|
// 1.36.58 (F-09): core 프로필에서는 노출 집합 = 호출 가능 집합 (광고 안 한 도구 호출은 unknown 과 동일 처리)
|
|
25724
|
-
if (_profile === 'core' && !
|
|
25881
|
+
if (_profile === 'core' && !_toolDef) {
|
|
25725
25882
|
return send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Unknown tool: ${name} (core profile — full: leerness mcp serve --profile full)` } });
|
|
25726
25883
|
}
|
|
25727
25884
|
// 1.36.39 (#3): 도구 inputSchema 기반 타입 검증 — {"title":{...}} 같은 비문자열이 "[object Object]" 로 영속되던 것 차단.
|
|
25728
25885
|
{
|
|
25729
|
-
const
|
|
25730
|
-
const _props = _td && _td.inputSchema && _td.inputSchema.properties || null;
|
|
25886
|
+
const _props = _toolDef && _toolDef.inputSchema && _toolDef.inputSchema.properties || null;
|
|
25731
25887
|
if (_props) {
|
|
25732
25888
|
for (const [k, spec] of Object.entries(_props)) {
|
|
25733
25889
|
const v = args[k];
|
|
@@ -25736,11 +25892,28 @@ function mcpServeCmd(root) {
|
|
|
25736
25892
|
if (spec.type === 'number' && typeof v !== 'number') return send({ jsonrpc: '2.0', id, error: { code: -32602, message: `Invalid params: '${k}' must be a number` } });
|
|
25737
25893
|
if (spec.type === 'boolean' && typeof v !== 'boolean') return send({ jsonrpc: '2.0', id, error: { code: -32602, message: `Invalid params: '${k}' must be a boolean` } });
|
|
25738
25894
|
}
|
|
25739
|
-
for (const rk of (
|
|
25895
|
+
for (const rk of (_toolDef.inputSchema.required || [])) {
|
|
25740
25896
|
if (args[rk] === undefined || args[rk] === null) return send({ jsonrpc: '2.0', id, error: { code: -32602, message: `Invalid params: missing required '${rk}'` } });
|
|
25741
25897
|
}
|
|
25742
25898
|
}
|
|
25743
25899
|
}
|
|
25900
|
+
// 1.36.171 (외부 Codex P1/P2): sessionKey는 광고한 도구에서만 받으며, 명시된 값은
|
|
25901
|
+
// MCP 경계에서 완전히 검증한다. 잘못된 값을 CLI가 무시해 서버의 CODEX_THREAD_ID나 전역
|
|
25902
|
+
// currentRunId로 떨어뜨리면 서로 다른 에이전트의 증거가 다시 섞인다.
|
|
25903
|
+
let _sk = null;
|
|
25904
|
+
const _hasSessionKey = Object.prototype.hasOwnProperty.call(args, 'sessionKey');
|
|
25905
|
+
if (_hasSessionKey && _toolDef) {
|
|
25906
|
+
const _props = _toolDef.inputSchema && _toolDef.inputSchema.properties || {};
|
|
25907
|
+
if (!Object.prototype.hasOwnProperty.call(_props, 'sessionKey')) {
|
|
25908
|
+
return send({ jsonrpc: '2.0', id, error: { code: -32602, message: `Invalid params: '${name}' does not accept 'sessionKey'` } });
|
|
25909
|
+
}
|
|
25910
|
+
const _candidate = typeof args.sessionKey === 'string' ? args.sessionKey : '';
|
|
25911
|
+
const _SPmcp = require('../lib/session-presence');
|
|
25912
|
+
if (!_SPmcp.isValidKey(_candidate)) {
|
|
25913
|
+
return send({ jsonrpc: '2.0', id, error: { code: -32602, message: "Invalid params: 'sessionKey' must be 8-64 letters, numbers, '_' or '-' and not a reserved key" } });
|
|
25914
|
+
}
|
|
25915
|
+
_sk = _candidate.toLowerCase();
|
|
25916
|
+
}
|
|
25744
25917
|
// 1.12.2 (14th 버그헌트, UR-0181): MCP 서버는 generic 설계 — cwd 에서 실행 후 각 호출의 path 로 대상 프로젝트 지정(의도된 기능, e2e 가 검증). 즉 path-타게팅 자체는 취약점 아님(신뢰된 로컬 MCP). policy 도 대상-프로젝트별로 path 에서 로드(올바름).
|
|
25745
25918
|
// 진짜 버그는 unknown tool 도 _bumpMcpUsage 가 임의 경로에 무조건 쓰던 것 → 사용 통계를 unknown-tool 검증 "후"로 이동. (프로젝트-scoped 가둠은 opt-in 플래그로 별도 검토 — UR backlog)
|
|
25746
25919
|
// 1.36.146 (재검수 P1, 재현): 빈 `path` 가 falsy 라 **서버 root** 로 떨어져
|
|
@@ -25761,15 +25934,19 @@ function mcpServeCmd(root) {
|
|
|
25761
25934
|
// cliArgs(실제 실행 명령) 로 required tier 판정 → enforce ON 이고 초과 시 JSON-RPC error 반환(실행 안 함).
|
|
25762
25935
|
try {
|
|
25763
25936
|
// 1.9.301 (UR-0041): 도구 선언 requiredTier(메타데이터) + cliArgs regex 중 더 엄격한 tier 로 판정 (under-classify 갭 차단).
|
|
25764
|
-
const _toolDef = TOOLS.find(t => t.name === name);
|
|
25765
25937
|
const pol = _policyEnforce(targetPath, cliArgs.join(' '), _toolDef && _toolDef.requiredTier);
|
|
25766
25938
|
if (!pol.allowed) {
|
|
25767
25939
|
return send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `정책 차단(policy): ${pol.reason}` }], isError: true } });
|
|
25768
25940
|
}
|
|
25769
25941
|
} catch {}
|
|
25770
|
-
//
|
|
25771
|
-
|
|
25772
|
-
|
|
25942
|
+
// T-0110: lifecycle MCP 호출은 명시 sessionKey가 있어도 callLeerness의
|
|
25943
|
+
// LEERNESS_INTERNAL=1 때문에 presence에서 억제됐다. 이 marker는 서버가 호출별 주소를
|
|
25944
|
+
// 실제로 받은 **직접 handoff/close**에만 붙인다. state/agent-mode/일반 내부 스폰은
|
|
25945
|
+
// presence 억제 예외가 아니며, sessionKey 자체는 상태 귀속용 env로만 전달한다.
|
|
25946
|
+
const _lifecycle = name === 'leerness_handoff' || name === 'leerness_session_close';
|
|
25947
|
+
const _mcpEnv = _sk ? { LEERNESS_SESSION_ID: _sk } : null;
|
|
25948
|
+
if (_mcpEnv && _lifecycle) _mcpEnv.LEERNESS_MCP_ADDRESS_EXPLICIT = '1';
|
|
25949
|
+
const r = callLeerness(cliArgs, _mcpEnv);
|
|
25773
25950
|
// 1.9.61: cursor 기반 페이지네이션 — 긴 출력은 cursor offset로 다음 청크
|
|
25774
25951
|
const fullText = r.stdout || r.stderr || '(no output)';
|
|
25775
25952
|
// 1.12.4 (15th 버그헌트 P2, UR-0016): _chunkSize 를 양의 정수로 클램프 — 음수/소수면 slice 빈 출력 + nextCursor 음수 → 무한 빈-루프(데이터 손실)였음.
|
package/lib/mcp-tools.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
'use strict';
|
|
5
5
|
|
|
6
6
|
module.exports = [
|
|
7
|
-
{ name: 'leerness_handoff', requiredTier: 'safe-write', description: '워크스페이스 컨텍스트(plan/progress/decisions)
|
|
7
|
+
{ name: 'leerness_handoff', requiredTier: 'safe-write', description: '워크스페이스 컨텍스트(plan/progress/decisions) 적재. sessionKey를 주면 해당 MCP 세션의 presence도 등록한다.', inputSchema: { type: 'object', properties: { sessionKey: { type: 'string', description: '이 MCP 세션의 고유 주소(8~64자 영문/숫자/_/-). 동시 에이전트가 서로를 보려면 세션마다 다른 값을 사용한다.' }, path: { type: 'string' } } } },
|
|
8
8
|
{ name: 'leerness_drift_check', requiredTier: 'read-only', description: '1.9.136 — AI 에이전트 leerness 미사용 drift 자동 감지 JSON ({ root, score, level, signals[], healthy }). 5+ 신호 + 4단계 레벨 (🟢 healthy / 🟡 warning / 🟠 caution / 🔴 critical). 보안 신호 통합 (1.9.78)', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
|
|
9
9
|
{ name: 'leerness_audit', requiredTier: 'safe-write', description: '1.9.102 — 워크스페이스 일관성 감사 JSON (warnings/failures/fixed/healthy + findings[]. kind 11종: design_dup/design_system_default/reuse_map_empty/milestone_unlinked/handoff_not_generated/current_state_stale/readme_version_mismatch/npm_cve/gitignore_missing_secrets/env_keys_missing/strict_promoted)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, fix: { type: 'boolean' }, strict: { type: 'boolean' } } } },
|
|
10
10
|
{ name: 'leerness_verify_claim', requiredTier: 'read-only', description: 'AI 거짓 완료 자동 검증 (evidence 파일 + 실 테스트 실행). 1.9.309(UR-0048): done/완료 주장은 evidence(수정파일+테스트) 기본 강제 — 증거 없는 done 은 FAIL(exit 1). lenient=true 로 완화, runTests/strictClaims 추가 가능. 응답 verdict.evidenceComplete 포함.', inputSchema: { type: 'object', properties: { taskId: { type: 'string' }, path: { type: 'string' }, runTests: { type: 'boolean' }, strictClaims: { type: 'boolean' }, lenient: { type: 'boolean' }, refereeId: { type: 'string' } }, required: ['taskId'] } },
|
|
@@ -14,7 +14,7 @@ module.exports = [
|
|
|
14
14
|
{ name: 'leerness_reuse_map', requiredTier: 'read-only', description: '워크스페이스 중복 함수/capability 자동 감지 (--all-apps + fuzzy 매칭)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, allApps: { type: 'boolean' }, strictElements: { type: 'boolean' } } } },
|
|
15
15
|
{ name: 'leerness_whats_new', requiredTier: 'read-only', description: 'CHANGELOG 차분 자동 추출 (from → to 사이 신규 명령/플래그/파일). 1.36.69: 기본 최신 30개 버전만 반환(응답 폭주 방지) — 응답의 totalVersions/shown/truncated 로 절단 여부 확인, 더 필요하면 limit(개수) 또는 all=true(전체, 대용량 주의) 지정.', inputSchema: { type: 'object', properties: { from: { type: 'string' }, to: { type: 'string' }, limit: { type: 'number' }, all: { type: 'boolean' } } } },
|
|
16
16
|
{ name: 'leerness_usage_stats', requiredTier: 'read-only', description: 'leerness 명령별 누적 호출 통계 + drift 통계', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
|
|
17
|
-
{ name: 'leerness_session_close', requiredTier: 'safe-write', description: '1.9.103 — 세션 마감 JSON (handoff/current-state/task-log 갱신 + taskCounts + rules + skillCandidates + drift + topCommands + mcpStats).
|
|
17
|
+
{ name: 'leerness_session_close', requiredTier: 'safe-write', description: '1.9.103 — 세션 마감 JSON (handoff/current-state/task-log 갱신 + taskCounts + rules + skillCandidates + drift + topCommands + mcpStats). sessionKey를 주면 그 MCP presence만 닫는다.', inputSchema: { type: 'object', properties: { sessionKey: { type: 'string', description: '마감할 MCP 세션의 고유 주소(8~64자 영문/숫자/_/-).' }, path: { type: 'string' } } } },
|
|
18
18
|
{ name: 'leerness_skill_suggest', requiredTier: 'read-only', description: '1.9.53 — 사용 패턴 자동 분석 → 새 skill 후보 제안 (Hermes-style 자동 학습)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, min: { type: 'number' }, days: { type: 'number' } } } },
|
|
19
19
|
{ name: 'leerness_lessons', requiredTier: 'read-only', description: '1.9.7/54 — 과거 결정·실수 자동 회수 (--auto: 현재 task 키워드 자동 추출)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, query: { type: 'string' }, auto: { type: 'boolean' }, limit: { type: 'number' } } } },
|
|
20
20
|
{ name: 'leerness_task_export', requiredTier: 'project-write', description: '1.9.60/66 — leerness task → Claude Code TodoWrite 호환 JSON (외부 AI 양방향 sync)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, to: { type: 'string' } } } },
|