leerness 1.36.169 → 1.36.171
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 +10 -0
- package/README.md +4 -4
- package/bin/leerness.js +222 -38
- package/lib/mcp-tools.js +2 -2
- package/lib/session-presence.js +100 -27
- package/package.json +5 -4
- package/scripts/e2e.js +2 -0
- package/scripts/mcp-presence-probe.js +394 -0
- package/scripts/next-action-suggestion-probe.js +48 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.171 — 2026-08-27
|
|
4
|
+
|
|
5
|
+
- MCP handoff/session-close가 호출별 sessionKey로 presence를 등록·종료하고, Windows 환경변수 casing·일회성 marker·legacy case variant·손상 closedAt를 안전하게 처리하도록 개선했습니다. 전용 MCP presence 회귀 22/22와 전체 E2E 467/467를 추가·통과했습니다.
|
|
6
|
+
|
|
7
|
+
## 1.36.170 — 2026-08-27
|
|
8
|
+
|
|
9
|
+
- next-action의 정규화 계약을 모든 읽기 표면으로 확장했습니다. release-time auto-resume plan 생성과 외부 에이전트용 `context`가 legacy `--filter`, 자동 완료 명령, 시간 기반 중복, 존재하지 않는 stress 실행을 다시 노출하지 않습니다.
|
|
10
|
+
- 1.36.169 이전에 저장된 `auto-resume-plan.json`도 `resume`/`handoff`에서 지연 정규화하며, pre-wake audit가 폐기된 `pending` 필드 대신 canonical queue를 세어 대기 작업을 정확히 보고합니다.
|
|
11
|
+
- 회귀 probe가 `next-action list/take`뿐 아니라 release plan, 기존 plan 재개, context JSON, pre-wake pending count까지 실제 CLI로 검증합니다. 외부 Codex 교차 리뷰 결과 P0/P1/P2는 없었습니다.
|
|
12
|
+
|
|
3
13
|
## 1.36.169 — 2026-08-27
|
|
4
14
|
|
|
5
15
|
- 멀티세션 lost-update 방지: plan/anchors canonical lock, decisions·lessons migration transaction, rule baseline verification serialization, 11-pair forced-interleaving regressions
|
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.171 하네스를 사용합니다. 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.171는 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.169는 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.171 릴리스 태그 이력** (수백 라운드) · _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.171: 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.171';
|
|
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상태
|
|
@@ -11400,12 +11443,14 @@ function _runPreWakeAudit(root) {
|
|
|
11400
11443
|
|
|
11401
11444
|
// 6) next-action queue 잔여 (1.9.201)
|
|
11402
11445
|
try {
|
|
11403
|
-
|
|
11404
|
-
|
|
11446
|
+
// loader 계약은 { queue, at }이다. 과거 `pending`을 읽어 이 검사가 항상 무언
|
|
11447
|
+
// no-op이었고, raw 큐 수를 쓰면 legacy 중복까지 과대 계수된다.
|
|
11448
|
+
const q = _normalizedNextActionState(root);
|
|
11449
|
+
if (q.queue.length > 0) {
|
|
11405
11450
|
audit.findings.info.push({
|
|
11406
11451
|
kind: 'next-action-pending',
|
|
11407
|
-
count: q.
|
|
11408
|
-
detail: `${q.
|
|
11452
|
+
count: q.queue.length,
|
|
11453
|
+
detail: `${q.queue.length}건 next-action 대기 — leerness next-action take 로 즉시 처리`
|
|
11409
11454
|
});
|
|
11410
11455
|
}
|
|
11411
11456
|
} catch {}
|
|
@@ -11908,7 +11953,11 @@ function _loadAutoResumePlan(root) {
|
|
|
11908
11953
|
if (!j.savedAt) return null;
|
|
11909
11954
|
const ageMs = Date.now() - new Date(j.savedAt).getTime();
|
|
11910
11955
|
const expiredMs = j.expectedFireAt ? Date.now() - new Date(j.expectedFireAt).getTime() : 0;
|
|
11911
|
-
|
|
11956
|
+
// 1.36.170: 1.36.169 이전에 저장한 plan도 resume/handoff에서 곧바로 현재 명령 계약으로
|
|
11957
|
+
// 읽는다. 큐 list/take만 정규화하면 release 직후 저장된 plan이 legacy --filter,
|
|
11958
|
+
// 자동 done 명령, 존재하지 않는 stress 파일 실행을 다시 노출할 수 있다.
|
|
11959
|
+
const nextActions = _compactNextActionQueue(Array.isArray(j.nextActions) ? j.nextActions : []);
|
|
11960
|
+
return { ...j, nextActions, ageMs, ageMin: Math.floor(ageMs / 60000), elapsedFromExpected: Math.floor(expiredMs / 60000) };
|
|
11912
11961
|
} catch { return null; }
|
|
11913
11962
|
}
|
|
11914
11963
|
function _writeAutoResumePlan(root, plan) {
|
|
@@ -11931,7 +11980,7 @@ function _buildAutoResumePlan(root, opts) {
|
|
|
11931
11980
|
// 현재 VERSION
|
|
11932
11981
|
const currentVersion = VERSION;
|
|
11933
11982
|
// next-action queue snapshot
|
|
11934
|
-
const queueState =
|
|
11983
|
+
const queueState = _normalizedNextActionState(root);
|
|
11935
11984
|
const queueTop = queueState.queue.length > 0 ? queueState.queue[queueState.queue.length - 1] : null;
|
|
11936
11985
|
// memory surface counts
|
|
11937
11986
|
const memorySurface = {
|
|
@@ -13200,24 +13249,62 @@ function _sessionHostId(root) {
|
|
|
13200
13249
|
return require('crypto').createHash('sha256').update(salt + '|' + String(os.hostname())).digest('hex').slice(0, 12);
|
|
13201
13250
|
} catch { return null; }
|
|
13202
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
|
+
}
|
|
13203
13290
|
// ⚠ (검수 P1) 예전엔 읽기 실패를 빈 배열로 삼켰다 — 그러면 "기록이 없다" 와 "확인할 수 없다" 가
|
|
13204
13291
|
// 같은 출력이 된다(조용한 0). 못 읽은 사실을 **함께 돌려준다**.
|
|
13205
13292
|
function _readSessionEntries(root) {
|
|
13206
13293
|
const dir = _sessionStoreDir(root);
|
|
13207
|
-
const
|
|
13294
|
+
const rawEntries = [];
|
|
13208
13295
|
let names = [];
|
|
13209
13296
|
let unreadable = 0, storeError = null;
|
|
13210
13297
|
try { names = fs.readdirSync(dir); }
|
|
13211
13298
|
catch (e) {
|
|
13212
|
-
if (e && e.code === 'ENOENT') return { entries, unreadable: 0, storeError: null }; // 아직 아무도 안 썼다 = 진짜 0
|
|
13213
|
-
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') };
|
|
13214
13301
|
}
|
|
13215
13302
|
for (const n of names) {
|
|
13216
13303
|
if (!SESSION_FILE_RE.test(n)) continue; // 염 파일·tmp·lock 잔재는 목록에서 제외
|
|
13217
|
-
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++; }
|
|
13218
13305
|
catch { unreadable++; } // 손상은 '없음' 이 아니라 '못 읽음' 이다
|
|
13219
13306
|
}
|
|
13220
|
-
return { entries, unreadable, storeError };
|
|
13307
|
+
return { entries: _collapseSessionEntries(rawEntries), rawEntries, unreadable, storeError };
|
|
13221
13308
|
}
|
|
13222
13309
|
function _readSessionRecords(root) { return _readSessionEntries(root).entries.map(e => e.record); }
|
|
13223
13310
|
// phase: 'open' (handoff) | 'close' (session close)
|
|
@@ -13226,21 +13313,30 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
13226
13313
|
const SP = require('../lib/session-presence');
|
|
13227
13314
|
try {
|
|
13228
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
|
+
}
|
|
13229
13324
|
const hasHarness = exists(path.join(root, '.leerness'));
|
|
13230
|
-
const reason = SP.suppressionReason(
|
|
13325
|
+
const reason = SP.suppressionReason(presenceEnv, hasHarness);
|
|
13231
13326
|
if (reason) return { written: false, reason };
|
|
13232
|
-
const key = SP.deriveSessionKey(
|
|
13327
|
+
const key = SP.deriveSessionKey(presenceEnv);
|
|
13233
13328
|
if (!SP.isValidKey(key)) return { written: false, reason: 'not-identifiable' };
|
|
13234
13329
|
const dir = _sessionStoreDir(root);
|
|
13235
13330
|
mkdirp(dir);
|
|
13236
13331
|
const fp = path.join(dir, `${key}.json`);
|
|
13237
13332
|
// 경로 가둠 — 키 정규식이 이미 막지만, 실제 해석 결과가 스토어 밖이면 쓰지 않는다(이중 방어).
|
|
13238
13333
|
if (path.dirname(path.resolve(fp)) !== path.resolve(dir)) return { written: false, reason: 'path-escape' };
|
|
13239
|
-
const det = SP.detectOutermostAgent(
|
|
13334
|
+
const det = SP.detectOutermostAgent(presenceEnv);
|
|
13240
13335
|
const nowIso = new Date().toISOString();
|
|
13241
13336
|
let res = { written: false, reason: 'lock' };
|
|
13242
13337
|
_withLock(fp, () => {
|
|
13243
13338
|
let prev = null;
|
|
13339
|
+
const legacyVariants = [];
|
|
13244
13340
|
if (exists(fp)) {
|
|
13245
13341
|
try { prev = JSON.parse(read(fp)); } catch {
|
|
13246
13342
|
// 손상 레코드는 **지우고 새로 쓴다**. `writeUtf8` 의 손상 구조 경로는 stderr 경고를 내는데,
|
|
@@ -13249,12 +13345,45 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
13249
13345
|
try { fs.unlinkSync(fp); } catch { /* 지우지 못해도 아래 쓰기가 덮는다 */ }
|
|
13250
13346
|
}
|
|
13251
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
|
+
}
|
|
13252
13381
|
const _prevAgent = prev && prev.outermostAgent;
|
|
13253
13382
|
// 1.36.132 (검수 P2): 라벨 순서를 안 맞추면 `A<->B` 와 `B<->A` 가 번갈아 기록되며 **매 handoff 마다** 새 경고가 났다.
|
|
13254
13383
|
// 같은 두 에이전트는 같은 표식이어야 한다 — 정렬해서 순서를 없앤다.
|
|
13255
13384
|
const _sharedAgents = (_prevAgent && _prevAgent !== 'unknown' && det.agent !== 'unknown' && _prevAgent !== det.agent)
|
|
13256
13385
|
? [_prevAgent, det.agent].sort().join('<->') : ((prev && prev.sharedKeyAgents) || null);
|
|
13257
|
-
if (_sharedAgents && !(prev && prev.sharedKeyAgents === _sharedAgents) &&
|
|
13386
|
+
if (_sharedAgents && !(prev && prev.sharedKeyAgents === _sharedAgents) && presenceEnv.LEERNESS_NO_LOCK_WARN !== '1') {
|
|
13258
13387
|
try { process.stderr.write(`⚠ 세션 주소 공유 감지: ${key} 를 서로 다른 에이전트(${_sharedAgents})가 쓰고 있습니다 — 두 세션이 한 기록으로 합쳐져 서로를 못 봅니다. 세션마다 다른 LEERNESS_SESSION_ID 를 쓰세요.\n`); } catch {}
|
|
13259
13388
|
}
|
|
13260
13389
|
const _handoffHistory = Array.isArray(prev && prev.handoffHistory) ? prev.handoffHistory.slice(-10) : [];
|
|
@@ -13262,7 +13391,7 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
13262
13391
|
const rec = {
|
|
13263
13392
|
schemaVersion: 1,
|
|
13264
13393
|
sessionKey: key,
|
|
13265
|
-
keySource: SP.keySource(
|
|
13394
|
+
keySource: SP.keySource(presenceEnv), // 1.36.132: 3종(LEERNESS_SESSION_ID 우선) — 손으로 적은 삼항은 새 출처를 놓친다
|
|
13266
13395
|
outermostAgent: det.agent,
|
|
13267
13396
|
agentEvidence: det.evidence, // 결론이 아니라 원증거 키 이름 — 규칙이 틀려도 원자료가 남는다
|
|
13268
13397
|
hostId: _sessionHostId(root),
|
|
@@ -13279,6 +13408,7 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
13279
13408
|
// 라벨이 마지막 기록자로 덮임). 라벨이 **둘 다 unknown 이 아니면서** 바뀌었다면 그건 공유의 증거다.
|
|
13280
13409
|
// unknown 을 제외하는 이유: 셸마다 env 가시성이 달라 라벨이 흔들릴 때 오탐이 난다(실측).
|
|
13281
13410
|
sharedKeyAgents: _sharedAgents,
|
|
13411
|
+
caseVariantConflict: caseConflict,
|
|
13282
13412
|
};
|
|
13283
13413
|
// 1.36.133 (정찰 실측): `rec` 는 닫힌 리터럴이라 **우리가 모르는 키는 다음 handoff 에서 사라졌다** —
|
|
13284
13414
|
// 주입한 `scope`/`futureField` 가 handoff 한 번에 소멸, exit 0, **stderr 0바이트**. 버전 혼재가 이 기계에 실재한다
|
|
@@ -13309,6 +13439,17 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
13309
13439
|
if (_carried.length) rec.carriedUnknownKeys = _carried;
|
|
13310
13440
|
if (_dropped.length) rec.droppedUnknownKeys = _dropped.slice(0, 10);
|
|
13311
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
|
+
}
|
|
13312
13453
|
res = { written: true, reason: null };
|
|
13313
13454
|
});
|
|
13314
13455
|
// 프루닝은 락 밖(자기 파일 락과 무관한 남의 파일 삭제) — 상한이 없으면 무한히 자란다.
|
|
@@ -13316,7 +13457,8 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
13316
13457
|
// ⚠ (검수 P2) 프루너가 **삭제 대상의 락을 안 잡아서** 남이 방금 갱신한 레코드를 지울 수 있었다.
|
|
13317
13458
|
// 파일별 락을 잡고, 락 안에서 **다시 읽어** 여전히 삭제 대상인지 확인한 뒤에만 지운다.
|
|
13318
13459
|
const snap = _readSessionEntries(root);
|
|
13319
|
-
const
|
|
13460
|
+
const physical = snap.rawEntries || snap.entries;
|
|
13461
|
+
const doomed = SP.selectPrunable(physical, { selfKey: key, nowMs: Date.now() });
|
|
13320
13462
|
for (const name of doomed) {
|
|
13321
13463
|
if (!SESSION_FILE_RE.test(name)) continue;
|
|
13322
13464
|
const victim = path.join(dir, name);
|
|
@@ -13327,7 +13469,7 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
13327
13469
|
let cur = null;
|
|
13328
13470
|
try { cur = JSON.parse(read(victim)); } catch { cur = null; }
|
|
13329
13471
|
const still = SP.selectPrunable([{ name, record: cur }].concat(
|
|
13330
|
-
|
|
13472
|
+
physical.filter(e => e.name !== name)), { selfKey: key, nowMs: Date.now() });
|
|
13331
13473
|
if (!still.includes(name)) return; // 조건이 사라졌다 → 보존
|
|
13332
13474
|
try { fs.unlinkSync(victim); } catch { /* 남아도 다음에 다시 시도 */ }
|
|
13333
13475
|
});
|
|
@@ -13368,9 +13510,11 @@ function _sessionSignal(root, opts = {}) {
|
|
|
13368
13510
|
let mine = null;
|
|
13369
13511
|
try { mine = selfKey ? (snap.entries.find(e => e.record && e.record.sessionKey === selfKey) || null) : null; } catch {}
|
|
13370
13512
|
const sharedKeyAgents = (mine && mine.record && mine.record.sharedKeyAgents) || null;
|
|
13513
|
+
const caseVariantConflict = (mine && mine.record && mine.record.caseVariantConflict) || null;
|
|
13371
13514
|
return {
|
|
13372
13515
|
others, // 다른 세션 **기록** 수(활성 여부는 판정하지 않는다)
|
|
13373
13516
|
sharedKeyAgents,
|
|
13517
|
+
caseVariantConflict,
|
|
13374
13518
|
// 1.36.132 (검수 P1): 명시 주소가 형식에 안 맞으면 종전엔 조용히 다음 출처로 넘어갔다(오타를 안 알림).
|
|
13375
13519
|
invalidExplicitKey: SP.invalidExplicitKey(process.env),
|
|
13376
13520
|
// 1.36.132 (검수 P1): 저장소를 못 읽은 것을 `others:0` 으로 축약하지 않는다 — 이 저장소의 규율이다.
|
|
@@ -13384,7 +13528,7 @@ function _sessionSignal(root, opts = {}) {
|
|
|
13384
13528
|
// 1.36.133: **서브에이전트가 빠져 있었다.** 이 프로젝트의 session-workflow 는 파일 경로를 나눠 주는
|
|
13385
13529
|
// 병렬 sub-agent 를 의무화하는데, 그들은 `child-agent` 로 억제돼 레지스트리에 아예 안 나타난다 —
|
|
13386
13530
|
// 즉 겹침이 가장 잘 나는 주체가 구조적으로 안 보인다. 못 보는 것을 안 적으면 "0건" 이 거짓말이 된다.
|
|
13387
|
-
blindSpots: ['handoff 를 부르지 않는 세션', 'MCP
|
|
13531
|
+
blindSpots: ['handoff 를 부르지 않는 세션', 'sessionKey 없이 lifecycle handoff/close 를 호출하는 MCP 클라이언트',
|
|
13388
13532
|
'병렬 sub-agent (CLAUDE_CODE_CHILD_SESSION — 억제되어 등록되지 않음)',
|
|
13389
13533
|
'주소가 없는 에이전트(cursor/vscode/grok 등 · codex 는 CODEX_THREAD_ID 가 없는 실행에서만, TUI 미검증)',
|
|
13390
13534
|
'다른 머신', '구버전 설치본'],
|
|
@@ -13400,6 +13544,7 @@ function _sessionSignalFlags(ss) {
|
|
|
13400
13544
|
out.push(`🙈 나는 안 보임(${ss.selfSuppressedReason})`);
|
|
13401
13545
|
}
|
|
13402
13546
|
if (ss.sharedKeyAgents) out.push(`⚠ 주소 공유(${ss.sharedKeyAgents})`);
|
|
13547
|
+
if (ss.caseVariantConflict) out.push(`⚠ 대소문자 세션 충돌(${ss.caseVariantConflict.count || '?'})`);
|
|
13403
13548
|
if (ss.storeError) out.push(`⚠ 세션저장소 읽기실패(${ss.storeError})`);
|
|
13404
13549
|
if (ss.invalidExplicitKey) out.push(`⚠ LEERNESS_SESSION_ID 형식 오류("${ss.invalidExplicitKey}") — 무시됨`);
|
|
13405
13550
|
return out;
|
|
@@ -13474,14 +13619,15 @@ function sessionsCmd(root) {
|
|
|
13474
13619
|
outermostAgent: r.outermostAgent || 'unknown',
|
|
13475
13620
|
// ⚠ (검수 P2) `hostId` 가 없으면 '다른 머신' 이 아니라 **모르는 것**이다 — 3상태로 답한다.
|
|
13476
13621
|
hostRelation: SP.hostRelation(r.hostId, selfHost),
|
|
13477
|
-
closeRecorded:
|
|
13622
|
+
closeRecorded: SP.hasConfirmedClose(r),
|
|
13478
13623
|
lastHandoffAt: r.lastHandoffAt || null,
|
|
13479
13624
|
handoffCount: Number(r.handoffCount || 0) || 0,
|
|
13480
13625
|
leernessVersion: r.leernessVersion || null,
|
|
13626
|
+
caseVariantConflict: r.caseVariantConflict || null,
|
|
13481
13627
|
}));
|
|
13482
13628
|
const blindSpots = [
|
|
13483
13629
|
t('leerness handoff 를 돌리지 않은 에이전트 세션', 'agent sessions that never ran `leerness handoff`'),
|
|
13484
|
-
t('
|
|
13630
|
+
t('sessionKey 없이 lifecycle handoff/close를 호출하는 MCP 클라이언트', 'MCP clients calling lifecycle handoff/close without a sessionKey'),
|
|
13485
13631
|
t(`${VERSION} 이전 버전으로 시작된 세션`, `sessions started on a version older than ${VERSION}`),
|
|
13486
13632
|
// 1.36.132: 종전엔 '세션키가 없는 에이전트(codex/cursor 등)' 이라고 적었는데 codex 는 이제 주소가 있다.
|
|
13487
13633
|
// 자기 도구의 능력을 과소·과대 주장하지 않는다 — 실측 범위를 그대로 적는다(codex exec 경로에서 확인).
|
|
@@ -13540,7 +13686,10 @@ function sessionsCmd(root) {
|
|
|
13540
13686
|
: (r.hostRelation === 'different'
|
|
13541
13687
|
? t('다른 머신 — 확인 불가', 'another machine — unverifiable')
|
|
13542
13688
|
: t('머신 판별 불가 — 확인 불가', 'machine unknown — unverifiable'));
|
|
13543
|
-
|
|
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}`);
|
|
13544
13693
|
}
|
|
13545
13694
|
}
|
|
13546
13695
|
log('');
|
|
@@ -16239,6 +16388,8 @@ function memorySearch(root, query) {
|
|
|
16239
16388
|
|
|
16240
16389
|
function handoff(root) {
|
|
16241
16390
|
root = absRoot(root);
|
|
16391
|
+
// hook/--no-record 경로도 marker를 child env로 넘기지 않도록 기록 gate보다 먼저 소비한다.
|
|
16392
|
+
_captureDirectMcpPresenceMarker();
|
|
16242
16393
|
const _versionSkew = _handoffVersionSkew(root);
|
|
16243
16394
|
// 1.9.199: handoff 진입 시 1) 이전 timestamp 보존 → 2) 현재 timestamp 기록
|
|
16244
16395
|
// detector 가 prior gap 을 정확히 측정 (overwrite 전 값 필요). 함수 최상단에 선언.
|
|
@@ -21262,6 +21413,7 @@ const _sessionClose = require('../lib/session-close');
|
|
|
21262
21413
|
// 1.9.425 (UR-0025/UR-0125 큰 핸들러 모듈화 10번째): sessionClose → lib/session-close.js (DI 위임)
|
|
21263
21414
|
function sessionClose(root, opts = {}) { // 1.36.132 (검수): handoff 는 --no-record/LEERNESS_HOOK 게이트 뒤에서 기록하는데 여기는 **게이트 없이** 기록했다
|
|
21264
21415
|
// (실측: handoff --no-record → 0건, session close --no-record → 1건). 같은 스토어의 같은 계약은 같은 문장으로.
|
|
21416
|
+
_captureDirectMcpPresenceMarker(); // hook/--no-record여도 nested skill/drift child에는 일회성 MCP marker를 물려주지 않는다
|
|
21265
21417
|
if (!has('--no-record') && process.env.LEERNESS_HOOK !== '1') { try { _sessionPresenceRecord(root, 'close'); } catch {} } // 1.36.129 (P-0016 P1): close 는 '기록됨' 일 뿐 '프로세스가 죽었다' 가 아니다
|
|
21266
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)
|
|
21267
21419
|
|
|
@@ -25655,10 +25807,20 @@ function mcpServeCmd(root) {
|
|
|
25655
25807
|
// 자식 프로세스로 돌리므로 **env 로 주소를 넘길 수 있다** — 호출마다 다른 주소를 준다.
|
|
25656
25808
|
function callLeerness(cliArgs, extraEnv) {
|
|
25657
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 || {});
|
|
25658
25820
|
const r = cp.spawnSync(process.execPath, [__filename, ...cliArgs], {
|
|
25659
25821
|
encoding: 'utf8',
|
|
25660
25822
|
timeout: budget,
|
|
25661
|
-
env:
|
|
25823
|
+
env: childEnv
|
|
25662
25824
|
});
|
|
25663
25825
|
const cmd = `leerness ${cliArgs.map(_shQuote).join(' ')}`;
|
|
25664
25826
|
const partial = ((r.stdout || '') + (r.stderr || '')).trim();
|
|
@@ -25714,14 +25876,14 @@ function mcpServeCmd(root) {
|
|
|
25714
25876
|
return send({ jsonrpc: '2.0', id, error: { code: -32602, message: 'Invalid params: arguments must be an object' } });
|
|
25715
25877
|
}
|
|
25716
25878
|
const args = _rawArgs || {};
|
|
25879
|
+
const _toolDef = TOOLS.find(t => t.name === name) || null;
|
|
25717
25880
|
// 1.36.58 (F-09): core 프로필에서는 노출 집합 = 호출 가능 집합 (광고 안 한 도구 호출은 unknown 과 동일 처리)
|
|
25718
|
-
if (_profile === 'core' && !
|
|
25881
|
+
if (_profile === 'core' && !_toolDef) {
|
|
25719
25882
|
return send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Unknown tool: ${name} (core profile — full: leerness mcp serve --profile full)` } });
|
|
25720
25883
|
}
|
|
25721
25884
|
// 1.36.39 (#3): 도구 inputSchema 기반 타입 검증 — {"title":{...}} 같은 비문자열이 "[object Object]" 로 영속되던 것 차단.
|
|
25722
25885
|
{
|
|
25723
|
-
const
|
|
25724
|
-
const _props = _td && _td.inputSchema && _td.inputSchema.properties || null;
|
|
25886
|
+
const _props = _toolDef && _toolDef.inputSchema && _toolDef.inputSchema.properties || null;
|
|
25725
25887
|
if (_props) {
|
|
25726
25888
|
for (const [k, spec] of Object.entries(_props)) {
|
|
25727
25889
|
const v = args[k];
|
|
@@ -25730,11 +25892,28 @@ function mcpServeCmd(root) {
|
|
|
25730
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` } });
|
|
25731
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` } });
|
|
25732
25894
|
}
|
|
25733
|
-
for (const rk of (
|
|
25895
|
+
for (const rk of (_toolDef.inputSchema.required || [])) {
|
|
25734
25896
|
if (args[rk] === undefined || args[rk] === null) return send({ jsonrpc: '2.0', id, error: { code: -32602, message: `Invalid params: missing required '${rk}'` } });
|
|
25735
25897
|
}
|
|
25736
25898
|
}
|
|
25737
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
|
+
}
|
|
25738
25917
|
// 1.12.2 (14th 버그헌트, UR-0181): MCP 서버는 generic 설계 — cwd 에서 실행 후 각 호출의 path 로 대상 프로젝트 지정(의도된 기능, e2e 가 검증). 즉 path-타게팅 자체는 취약점 아님(신뢰된 로컬 MCP). policy 도 대상-프로젝트별로 path 에서 로드(올바름).
|
|
25739
25918
|
// 진짜 버그는 unknown tool 도 _bumpMcpUsage 가 임의 경로에 무조건 쓰던 것 → 사용 통계를 unknown-tool 검증 "후"로 이동. (프로젝트-scoped 가둠은 opt-in 플래그로 별도 검토 — UR backlog)
|
|
25740
25919
|
// 1.36.146 (재검수 P1, 재현): 빈 `path` 가 falsy 라 **서버 root** 로 떨어져
|
|
@@ -25755,15 +25934,19 @@ function mcpServeCmd(root) {
|
|
|
25755
25934
|
// cliArgs(실제 실행 명령) 로 required tier 판정 → enforce ON 이고 초과 시 JSON-RPC error 반환(실행 안 함).
|
|
25756
25935
|
try {
|
|
25757
25936
|
// 1.9.301 (UR-0041): 도구 선언 requiredTier(메타데이터) + cliArgs regex 중 더 엄격한 tier 로 판정 (under-classify 갭 차단).
|
|
25758
|
-
const _toolDef = TOOLS.find(t => t.name === name);
|
|
25759
25937
|
const pol = _policyEnforce(targetPath, cliArgs.join(' '), _toolDef && _toolDef.requiredTier);
|
|
25760
25938
|
if (!pol.allowed) {
|
|
25761
25939
|
return send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `정책 차단(policy): ${pol.reason}` }], isError: true } });
|
|
25762
25940
|
}
|
|
25763
25941
|
} catch {}
|
|
25764
|
-
//
|
|
25765
|
-
|
|
25766
|
-
|
|
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);
|
|
25767
25950
|
// 1.9.61: cursor 기반 페이지네이션 — 긴 출력은 cursor offset로 다음 청크
|
|
25768
25951
|
const fullText = r.stdout || r.stderr || '(no output)';
|
|
25769
25952
|
// 1.12.4 (15th 버그헌트 P2, UR-0016): _chunkSize 를 양의 정수로 클램프 — 음수/소수면 slice 빈 출력 + nextCursor 음수 → 무한 빈-루프(데이터 손실)였음.
|
|
@@ -27306,7 +27489,8 @@ function contextCmd(root, opts = {}) {
|
|
|
27306
27489
|
const _decs = _loadDecisions(root);
|
|
27307
27490
|
const decisionCount = _decs.length;
|
|
27308
27491
|
const recentDecisions = _decs.slice(-3).reverse().map(d => ({ date: d.date || null, title: (d.title || '').slice(0, 100) }));
|
|
27309
|
-
|
|
27492
|
+
// 외부 에이전트 context도 next-action list/take와 같은 정규화 계약을 공유한다.
|
|
27493
|
+
const queueState = _normalizedNextActionState(root);
|
|
27310
27494
|
const nextActions = (queueState.queue || []).slice(-3).reverse().map(a => ({ title: a.title, command: a.command || null }));
|
|
27311
27495
|
const memory = {
|
|
27312
27496
|
tasksInProgress: rows.filter(r => r.status === 'in-progress').length,
|
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' } } } },
|