leerness 1.36.170 → 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 CHANGED
@@ -1,5 +1,9 @@
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
+
3
7
  ## 1.36.170 — 2026-08-27
4
8
 
5
9
  - 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.170 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
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.170는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
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.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.170 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
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.170: 2026-08-27
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.170';
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
- // ⑤ 프루닝 — 입력은 {name, record}, 반환은 **파일명**. 상한을 넘기면 자르되 자기 것은 안 자르고,
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.includes('key00000.json')) return false; // 자기 것은 안 자른다
7501
- if ((30 - pruned.length) > SP.SESSIONS_KEEP + 1) return false; // 상한이 실제로 걸린다
7502
- if (!pruned.length) return false; // 계측 생존
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 entries = [];
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) entries.push({ name: n, record: j }); else unreadable++; }
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(process.env, hasHarness);
13325
+ const reason = SP.suppressionReason(presenceEnv, hasHarness);
13237
13326
  if (reason) return { written: false, reason };
13238
- const key = SP.deriveSessionKey(process.env);
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(process.env);
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) && process.env.LEERNESS_NO_LOCK_WARN !== '1') {
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(process.env), // 1.36.132: 3종(LEERNESS_SESSION_ID 우선) — 손으로 적은 삼항은 새 출처를 놓친다
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 doomed = SP.selectPrunable(snap.entries, { selfKey: key, nowMs: Date.now() });
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
- snap.entries.filter(e => e.name !== name)), { selfKey: key, nowMs: Date.now() });
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 전용 클라이언트(LEERNESS_INTERNAL)',
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: !!r.closedAt,
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('MCP 도구만 쓰는 클라이언트', 'clients that only use MCP tools'),
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
- log(` ${who} ${String(r.sessionKey).slice(0, 8)}… ${String(r.outermostAgent).padEnd(13)} ${when}`);
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: { ...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', ...(extraEnv || {}) }
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' && !TOOLS.some(t => t.name === name)) {
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 _td = TOOLS.find(t => t.name === name);
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 (_td.inputSchema.required || [])) {
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
- // 세션 주소를 그대로 전달한다(문자열일 때만). 형식이 안 맞으면 CLI 가 무시하고 그 사실을 말한다.
25771
- const _sk = (args && typeof args.sessionKey === 'string' && args.sessionKey.trim()) ? args.sessionKey.trim() : null;
25772
- const r = callLeerness(cliArgs, _sk ? { LEERNESS_SESSION_ID: _sk } : null);
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) 적재', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
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). 외부 AI가 마감 통계 자동 회수', 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). 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' } } } },
@@ -12,10 +12,11 @@
12
12
  //
13
13
  // 이 모듈은 순수 함수만 담는다(스폰·IO 없음) — 가드가 자기 소스를 읽지 않고 행위로 검증할 수 있게.
14
14
 
15
- const SESSIONS_KEEP = 20; // 임의의 수 — 아래 주석 참조
15
+ const SESSIONS_KEEP = 20; // 보존할 최근 closed tombstone 수 — 아래 주석 참조
16
16
  const SESSIONS_CLOSED_TTL_MS = 7 * 24 * 3600 * 1000; // 임의의 수 — 아래 주석 참조
17
- // ⚠ 두 값은 **임의**다. 측정으로 유도한 것은 "상한이 있어야 한다" 는 사실뿐이고, 그 근거는 실측이다 —
18
- // 이 저장소의 `.leerness/runs/` 는 프루닝이 없어 909개 파일까지 자랐다. 값 자체는 근거가 없으므로 그렇게 적는다.
17
+ // ⚠ 두 값은 **임의**다. 측정으로 유도한 것은 closed tombstone에 상한이 있어야 한다는 사실뿐이고,
18
+ // 그 근거는 실측이다 — `.leerness/runs/` 는 프루닝이 없어 909개 파일까지 자랐다. 열린 peer 기록은
19
+ // 충돌 회피의 유일한 흔적일 수 있어 이 상한으로 삭제하지 않는다. 값 자체는 근거가 없으므로 그렇게 적는다.
19
20
 
20
21
  const KEY_RE = /^[A-Za-z0-9_-]{8,64}$/;
21
22
  const FILE_RE = /^[A-Za-z0-9_-]{8,64}\.json$/;
@@ -25,11 +26,65 @@ const RESERVED_KEYS = new Set(['unaddressed']);
25
26
  // 자체 점검이 쓰는 격리 marker는 실제 세션 주소가 될 수 없어야 한다. 그렇지 않으면 enforce install이
26
27
  // 같은 주소의 실제 handoff를 저장/복원 사이에 되돌려 버릴 수 있다.
27
28
  const RESERVED_PREFIXES = ['leerness-internal-'];
29
+ // Windows의 process.env는 키 대소문자를 구분하지 않지만, `{ ...process.env }`로 복사한
30
+ // 평범한 객체는 구분한다. presence 판정 직전에 아래 키만 OS 의미에 맞춰 canonical casing으로
31
+ // 올린다. POSIX에서는 원래의 case-sensitive 계약을 유지하고, 순수 회귀 테스트는 옵션으로
32
+ // Windows 의미를 강제할 수 있다.
33
+ const PRESENCE_ENV_KEYS = [
34
+ 'LEERNESS_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID',
35
+ 'CLAUDE_CODE_CHILD_SESSION', 'LEERNESS_MCP_ADDRESS_EXPLICIT', 'LEERNESS_INTERNAL', 'LEERNESS_HOOK',
36
+ 'LEERNESS_NO_SESSION_PRESENCE', 'CI', 'GITHUB_ACTIONS', 'CLAUDECODE', 'CURSOR_AGENT',
37
+ 'CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_BY_PNPM', 'CODEX_MANAGED_BY_BUN',
38
+ 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE',
39
+ ];
40
+ function normalizePresenceEnv(env, options) {
41
+ const source = env || {};
42
+ const out = { ...source };
43
+ const caseInsensitive = options && Object.prototype.hasOwnProperty.call(options, 'caseInsensitive')
44
+ ? options.caseInsensitive === true
45
+ : process.platform === 'win32';
46
+ if (!caseInsensitive) return out;
47
+ const keys = Object.keys(source);
48
+ for (const canonical of PRESENCE_ENV_KEYS) {
49
+ const variants = keys.filter(key => key.toLowerCase() === canonical.toLowerCase());
50
+ if (!variants.length) continue;
51
+ const selected = Object.prototype.hasOwnProperty.call(source, canonical) ? canonical : variants[0];
52
+ for (const key of variants) delete out[key];
53
+ out[canonical] = source[selected];
54
+ }
55
+ return out;
56
+ }
28
57
  function isValidKey(s) {
29
58
  if (typeof s !== 'string' || !KEY_RE.test(s)) return false;
30
59
  const folded = s.toLowerCase();
31
60
  return !RESERVED_KEYS.has(folded) && !RESERVED_PREFIXES.some(prefix => folded.startsWith(prefix));
32
61
  }
62
+ // `closedAt` 은 truthy 여부나 Date.parse()의 관대한 정규화가 아니라, 이 제품이 실제로 쓰는
63
+ // `Date#toISOString()` 형식과 달력 값이 정확히 일치해야 종료 증거다. 예를 들어 Date.parse는
64
+ // `0`과 `2024-02-30T00:00:00.000Z`도 시각으로 받아들이므로 그것만 믿으면 손상 레코드를 지운다.
65
+ // 손상/미래 스키마 값은 "열림"으로 단정하지도 않지만, 적어도 삭제 가능한 tombstone으로 취급하지 않는다.
66
+ const CANONICAL_ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
67
+ function confirmedCloseMs(record) {
68
+ const value = record && record.closedAt;
69
+ if (typeof value !== 'string' || !CANONICAL_ISO_UTC_RE.test(value)) return null;
70
+ const ms = Date.parse(value);
71
+ if (!Number.isFinite(ms)) return null;
72
+ try { return new Date(ms).toISOString() === value ? ms : null; } catch { return null; }
73
+ }
74
+ function hasConfirmedClose(record) { return confirmedCloseMs(record) !== null; }
75
+
76
+ // 구버전의 case-sensitive 저장소에서는 대소문자만 다른 주소가 서로 다른 열린 세션이었을 수 있다.
77
+ // 둘 이상이면 어느 하나를 canonical 주소로 합칠 근거가 없으므로 보존하고, 읽기/쓰기 표면에 충돌을 표시한다.
78
+ function caseVariantConflict(entries) {
79
+ const open = (entries || []).filter(e => e && e.record && !hasConfirmedClose(e.record));
80
+ if (open.length < 2) return null;
81
+ const variants = open.map(e => String(e.name || '')).filter(name => FILE_RE.test(name)).slice(0, 10);
82
+ return {
83
+ reason: 'ambiguous-open-case-variants',
84
+ count: open.length,
85
+ variants,
86
+ };
87
+ }
33
88
  // env 값의 참/거짓 — CI 계열은 `"false"`/`"0"` 을 **명시적으로** 넘기는 환경이 흔하다.
34
89
  function isTruthyEnv(v) {
35
90
  const t = String(v == null ? '' : v).trim().toLowerCase();
@@ -59,7 +114,7 @@ const FORBIDDEN_SIGNALS = ['CODEX_COMPANION_SESSION_ID', 'CODEX_COMPANION_TRANSC
59
114
  // 물려받으므로(실측) 부모 세션으로 접히는 지금 동작을 유지한다. 독립 실행 codex 만 자기 주소를 얻는다.
60
115
  const KEY_SOURCES = ['LEERNESS_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID'];
61
116
  function keySource(env) {
62
- const e = env || {};
117
+ const e = normalizePresenceEnv(env);
63
118
  // 1.36.148 (재검수 P1, 재현): child 억제가 **명시 주소보다 앞**에 있어,
64
119
  // MCP 서버가 `CLAUDE_CODE_CHILD_SESSION=1` 을 상속하면 호출별 `sessionKey` 가 통째로 버려졌다
65
120
  // — 1.36.145 에서 넣은 주소 전달이 조용히 무력화되고 전역 슬롯 오귀속이 돌아온다.
@@ -75,13 +130,14 @@ function keySource(env) {
75
130
  // `CaseKey01ABCD` 와 `casekey01abcd` 가 **다른 주소인 척하다가 같은 파일 하나로 조용히 병합**됐다(실측 2→1).
76
131
  // 주소를 소문자로 접어 **하나의 규칙**으로 만든다: 같은 글자면 같은 주소다(그리고 파일명과 항상 일치한다).
77
132
  function deriveSessionKey(env) {
78
- const src = keySource(env);
79
- return src ? String((env || {})[src]).toLowerCase() : null;
133
+ const e = normalizePresenceEnv(env);
134
+ const src = keySource(e);
135
+ return src ? String(e[src]).toLowerCase() : null;
80
136
  }
81
137
  // 1.36.132 (검수 P1): 사용자가 **명시한** 주소가 형식에 안 맞으면 조용히 다음 출처로 넘어갔다 —
82
138
  // 오타를 알리지 않고 설정을 뒤집는 것이다. 무엇이 왜 무시됐는지 말할 수 있게 사유를 돌려준다.
83
139
  function invalidExplicitKey(env) {
84
- const v = (env || {}).LEERNESS_SESSION_ID;
140
+ const v = normalizePresenceEnv(env).LEERNESS_SESSION_ID;
85
141
  if (v === undefined || v === null || v === '') return null;
86
142
  return isValidKey(v) ? null : String(v).slice(0, 40);
87
143
  }
@@ -90,7 +146,7 @@ function invalidExplicitKey(env) {
90
146
  // 보고했다 — codex 가 부모 env 를 통째로 물려주고, 그 codex 자신이 Claude 세션에서 시작됐기 때문이다.
91
147
  // env 가 가리키는 것은 **체인의 최외곽**이다. 이름을 그렇게 붙이면 그 라벨은 참이 된다.
92
148
  function detectOutermostAgent(env) {
93
- const e = env || {};
149
+ const e = normalizePresenceEnv(env);
94
150
  if (e.CLAUDECODE === '1') return { agent: 'claude-code', evidence: ['CLAUDECODE'] };
95
151
  if (!e.CLAUDECODE) {
96
152
  for (const k of ['CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_BY_PNPM', 'CODEX_MANAGED_BY_BUN']) {
@@ -109,13 +165,19 @@ function detectOutermostAgent(env) {
109
165
 
110
166
  // 억제 판정은 **한 곳**에서 한다. 호출부마다 조건을 복붙하면 새 호출부가 빠진다(1.36.108 이 고친 형태).
111
167
  function suppressionReason(env, hasHarness) {
112
- const e = env || {};
113
- if (e.LEERNESS_INTERNAL === '1') return 'internal'; // leerness 가 자기를 스폰하는 20여 지점
168
+ const e = normalizePresenceEnv(env);
169
+ // T-0110: MCP 서버도 자식 CLI를 `LEERNESS_INTERNAL=1` 로 실행한다. 그 값만 보면 호출자가
170
+ // 명시한 sessionKey까지 일반 내부 호출로 오인해 lifecycle handoff/close가 영원히 등록되지 않는다.
171
+ // 서버가 **호출별 유효 주소를 검증해 붙인 marker**만 예외로 삼는다. 단순히 부모 env에서 물려받은
172
+ // LEERNESS_SESSION_ID나 일반 내부 스폰은 이 marker가 없으므로 종전처럼 억제된다.
173
+ const addressedMcp = e.LEERNESS_MCP_ADDRESS_EXPLICIT === '1' && isValidKey(e.LEERNESS_SESSION_ID);
174
+ if (e.LEERNESS_INTERNAL === '1' && !addressedMcp) return 'internal';
114
175
  if (e.LEERNESS_HOOK === '1') return 'hook';
115
176
  // child는 부모의 LEERNESS_SESSION_ID도 그대로 상속할 수 있다. env 값만 보고 호출자가 새 주소를
116
177
  // 지정했다고 단정하면 자식 활동이 부모 presence에 합쳐진다. 상태 MCP의 호출별 sessionKey는
117
- // deriveSessionKey 경로에서 별도로 귀속되므로, presence 자체는 child를 계속 억제한다.
118
- if (e.CLAUDE_CODE_CHILD_SESSION === '1') return 'child-agent';
178
+ // deriveSessionKey 경로에서 별도로 귀속된다. presence 예외는 MCP 서버가 호출별 주소를 검증한
179
+ // lifecycle handoff/close에만 marker로 열고, 그 외 child는 계속 억제한다.
180
+ if (e.CLAUDE_CODE_CHILD_SESSION === '1' && !addressedMcp) return 'child-agent';
119
181
  // ⚠ (검수 P2, 실측 재현) `e.CI` 만 보면 `CI="false"`·`CI="0"` 도 억제했다 —
120
182
  // 명시적으로 '아니다' 라고 말한 정상 로컬 환경이 조용히 등록되지 않는다. **참인 값만** 본다.
121
183
  if (isTruthyEnv(e.CI) || isTruthyEnv(e.GITHUB_ACTIONS)) return 'ci';
@@ -137,34 +199,44 @@ function selectPrunable(records, opts) {
137
199
  const selfKey = o.selfKey || null;
138
200
  const list = (records || []).filter(e => e && typeof e.name === 'string' && FILE_RE.test(e.name));
139
201
  const keyOf = (e) => String(e.name).replace(/\.json$/, '');
140
- const trusted = list.filter(e => e.record && e.record.sessionKey === keyOf(e));
202
+ const foldedKeyOf = (e) => keyOf(e).toLowerCase();
203
+ const foldedSelf = typeof selfKey === 'string' ? selfKey.toLowerCase() : null;
204
+ // 1.36.171: 1.36.132 이전 POSIX 레코드는 파일명과 payload가 혼합 대소문자일 수 있다.
205
+ // 정규화 뒤 같은 주소라면 신뢰하되, 실제 삭제 대상은 언제나 원래 파일명으로 돌려준다.
206
+ const trusted = list.filter(e => e.record && isValidKey(e.record.sessionKey)
207
+ && String(e.record.sessionKey).toLowerCase() === foldedKeyOf(e));
141
208
  const doomed = new Set();
142
- // 이름과 키가 어긋난 것은 **그 파일만** 정리 후보로 본다(그 레코드의 말을 믿고 남을 지우지 않는다).
143
- for (const e of list) { if (!trusted.includes(e) && keyOf(e) !== selfKey) doomed.add(e.name); }
209
+ // 이름과 키가 어긋나도 열린 기록은 다른 에이전트의 마지막 흔적일 수 있다. close/tombstone이
210
+ // 확인된 파일만 정리한다. 저장소 상한 때문에 **열린 동료 기록을 삭제하지 않는다**.
211
+ for (const e of list) {
212
+ if (!trusted.includes(e) && foldedKeyOf(e) !== foldedSelf && hasConfirmedClose(e.record)) doomed.add(e.name);
213
+ }
144
214
  for (const e of trusted) {
145
- if (keyOf(e) === selfKey) continue;
146
- const c = Date.parse((e.record && e.record.closedAt) || '');
147
- if (Number.isFinite(c) && nowMs && (nowMs - c) > ttl) doomed.add(e.name);
215
+ if (foldedKeyOf(e) === foldedSelf) continue;
216
+ const c = confirmedCloseMs(e.record);
217
+ if (c !== null && nowMs && (nowMs - c) > ttl) doomed.add(e.name);
148
218
  }
149
- const alive = trusted.filter(e => !doomed.has(e.name));
150
- // ⚠ (검수 P2, 실측 재현) `openedAt` 만으로 줄 세우면 **오래 열려 있었지만 방금 handoff 한 세션**이
151
- // 상한 밖으로 밀려 삭제됐다. 지키려는 것은 '최근에 살아 있던 기록' 이므로 최근 활동을 함께 본다.
219
+ // 상한은 닫힌 tombstone에만 적용한다. 열린 기록은 라이브니스가 아니라도 충돌 회피를 위한 유일한
220
+ // 증거라서, 임의의 20개 제한으로 버리면 안 된다.
221
+ const closed = trusted.filter(e => !doomed.has(e.name) && hasConfirmedClose(e.record));
152
222
  const activity = (e) => Math.max(Date.parse((e.record && e.record.lastHandoffAt) || '') || 0,
223
+ Date.parse((e.record && e.record.closedAt) || '') || 0,
153
224
  Date.parse((e.record && e.record.openedAt) || '') || 0);
154
- const ranked = alive.slice().sort((a, b) => activity(b) - activity(a));
155
- for (const e of ranked.slice(keep)) { if (keyOf(e) !== selfKey) doomed.add(e.name); }
225
+ const ranked = closed.slice().sort((a, b) => activity(b) - activity(a));
226
+ for (const e of ranked.slice(keep)) { if (foldedKeyOf(e) !== foldedSelf) doomed.add(e.name); }
156
227
  return [...doomed];
157
228
  }
158
229
 
159
230
  // 헤드라인은 **0이면 침묵**한다. "다른 세션 0건" 은 '아무도 없다' 로 읽히는데, 이 레지스트리는
160
- // (a) handoff 를 안 부르는 에이전트 (b) 업그레이드 전에 시작된 세션 (c) 다른 머신 (d) MCP 전용 클라이언트를
231
+ // (a) handoff 를 안 부르는 에이전트 (b) 업그레이드 전에 시작된 세션 (c) 다른 머신 (d) sessionKey 없는 MCP 클라이언트를
161
232
  // **구조적으로 못 본다**. 못 본 것을 없다고 단언하지 않는다.
162
233
  // ⚠ hostId 를 모르면(둘 중 하나가 null) '같은 머신' 이라고도 '다른 머신' 이라고도 하지 않는다 — 세지 않는다.
163
234
  function countOtherSessions(records, selfKey, selfHostId) {
235
+ const foldedSelf = typeof selfKey === 'string' ? selfKey.toLowerCase() : null;
164
236
  return (records || []).filter(r => r && isValidKey(r.sessionKey)
165
- && r.sessionKey !== selfKey
237
+ && String(r.sessionKey).toLowerCase() !== foldedSelf
166
238
  && r.hostId && selfHostId && r.hostId === selfHostId
167
- && !r.closedAt).length;
239
+ && !hasConfirmedClose(r)).length;
168
240
  }
169
241
 
170
242
  // 같은 머신인지 **모를 수 있다** — 3상태로 답한다(검수 P2: null 을 '다른 머신' 으로 단정했다).
@@ -176,6 +248,7 @@ function hostRelation(recordHostId, selfHostId) {
176
248
  module.exports = {
177
249
  KEY_SOURCES, keySource, invalidExplicitKey,
178
250
  SESSIONS_KEEP, SESSIONS_CLOSED_TTL_MS, KEY_RE, FILE_RE, RESERVED_KEYS, RESERVED_PREFIXES, FORBIDDEN_SIGNALS,
179
- isValidKey, isTruthyEnv, deriveSessionKey, detectOutermostAgent, suppressionReason,
251
+ PRESENCE_ENV_KEYS, normalizePresenceEnv, isValidKey, confirmedCloseMs, hasConfirmedClose, caseVariantConflict,
252
+ isTruthyEnv, deriveSessionKey, detectOutermostAgent, suppressionReason,
180
253
  selectPrunable, countOtherSessions, hostRelation,
181
254
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.170",
3
+ "version": "1.36.171",
4
4
  "description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
5
5
  "keywords": [
6
6
  "leerness",
@@ -48,13 +48,14 @@
48
48
  "LICENSE"
49
49
  ],
50
50
  "scripts": {
51
- "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
52
- "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js",
51
+ "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
52
+ "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js",
53
53
  "test:commands": "node ./scripts/command-flags-probe.js && node ./scripts/next-action-suggestion-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js",
54
54
  "test:next-actions": "node ./scripts/next-action-suggestion-probe.js",
55
55
  "test:installed": "node ./scripts/installed-cleanroom-probe.js",
56
56
  "test:handoff": "node ./scripts/handoff-readonly-probe.js",
57
- "test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/smoke.js",
57
+ "test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/smoke.js",
58
+ "test:mcp-presence": "node ./scripts/mcp-presence-probe.js",
58
59
  "test:smoke": "node ./scripts/smoke.js",
59
60
  "lint": "node ./scripts/lint.js",
60
61
  "prepack": "node ./bin/leerness.js readme sync . && node ./bin/leerness.js --version"
package/scripts/e2e.js CHANGED
@@ -15029,6 +15029,7 @@ total++;
15029
15029
  if (!s1 || !s2 || s1.started === s2.started) bad.push('①MCP 두 세션이 같은 run 을 받음 — 판별 불가');
15030
15030
  else {
15031
15031
  if (!rec || rec.recorded !== s1.started) bad.push(`①MCP: A 의 증거가 A 의 run 이 아닌 곳에 붙음(${rec && rec.recorded})`);
15032
+ if (!ver || ver.result !== 'pass' || !ver.run || ver.run.verification_result !== 'pass') bad.push(`①MCP: B verify pass 응답이 없거나 실패(${JSON.stringify(ver)})`);
15032
15033
  const allowed = ver && ver.completion_claim_allowed && ver.completion_claim_allowed.allowed;
15033
15034
  if (allowed) bad.push('①MCP: 일하지 않은 세션이 완료 허용을 받음 — 증거 게이트가 뒤집힘');
15034
15035
  }
@@ -15345,6 +15346,7 @@ total++;
15345
15346
  if (!s1 || !s2 || s1.started === s2.started) bad.push('②자식 env 에서 두 세션이 같은 run — 판별 불가');
15346
15347
  else {
15347
15348
  if (!rec || rec.recorded !== s1.started) bad.push(`②자식 env 에서 증거가 엉뚱한 run 에 붙음(${rec && rec.recorded})`);
15349
+ if (!ver || ver.result !== 'pass' || !ver.run || ver.run.verification_result !== 'pass') bad.push(`②자식 env verify pass 응답이 없거나 실패(${JSON.stringify(ver)})`);
15348
15350
  if (ver && ver.completion_claim_allowed && ver.completion_claim_allowed.allowed) bad.push('②자식 env 에서 놀던 세션이 완료 허용을 받음');
15349
15351
  }
15350
15352
  dbg.mcpChild = { A: s1 && s1.started, recordedTo: rec && rec.recorded,
@@ -0,0 +1,394 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // T-0110: MCP lifecycle calls with an explicit sessionKey must participate in
5
+ // the same presence registry as direct CLI handoff/session-close calls. Generic
6
+ // internal subprocesses and unaddressed MCP calls must remain invisible.
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const cp = require('child_process');
12
+ const SP = require('../lib/session-presence');
13
+
14
+ const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
15
+ const roots = [];
16
+ let total = 0;
17
+ let failed = 0;
18
+
19
+ const baseEnv = {
20
+ ...process.env,
21
+ LEERNESS_OFFLINE: '1',
22
+ LEERNESS_NO_PROMPT: '1',
23
+ LEERNESS_NO_AUTOCHCP: '1',
24
+ };
25
+ const controlledEnvKeys = new Set([
26
+ 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
27
+ 'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID',
28
+ 'LEERNESS_MCP_ADDRESS_EXPLICIT', 'LEERNESS_MCP_PROFILE', 'LEERNESS_NO_SESSION_PRESENCE',
29
+ 'LEERNESS_NO_SUGGEST', 'LEERNESS_NO_SKILL_SUGGEST', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG',
30
+ 'CI', 'GITHUB_ACTIONS', 'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM',
31
+ ].map(key => key.toLowerCase()));
32
+ // Windows에서 실제로 발생한 lower-case 상속 오염을 모든 OS에서 재현한다. scrub이 다시
33
+ // exact-case delete로 퇴행하면 첫 assertion이 실패하므로, 뒤의 "파일 없음" 검사가 거짓 통과하지 않는다.
34
+ baseEnv.leerness_no_session_presence = '1';
35
+ for (const key of Object.keys(baseEnv)) {
36
+ if (controlledEnvKeys.has(key.toLowerCase())) delete baseEnv[key];
37
+ }
38
+ const baseEnvIsolated = !Object.keys(baseEnv).some(key => controlledEnvKeys.has(key.toLowerCase()));
39
+
40
+ function check(label, condition, detail = '') {
41
+ total++;
42
+ const ok = !!condition;
43
+ process.stdout.write(`${ok ? '✓' : '✗'} ${label}${!ok && detail ? `\n ${detail}` : ''}\n`);
44
+ if (!ok) failed++;
45
+ }
46
+
47
+ function fresh(name) {
48
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `leerness-mcp-presence-${name}-`));
49
+ roots.push(root);
50
+ const result = cp.spawnSync(process.execPath, [CLI, 'init', root, '--yes', '--minimal'], {
51
+ cwd: root,
52
+ encoding: 'utf8',
53
+ timeout: 120000,
54
+ env: baseEnv,
55
+ });
56
+ if (result.status !== 0) throw new Error(`init failed (${result.status}): ${result.stderr || result.stdout}`);
57
+ return root;
58
+ }
59
+
60
+ function rpcCall(name, args, id) {
61
+ return JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: args } });
62
+ }
63
+
64
+ function runMcp(root, calls, extraEnv = {}) {
65
+ const input = [
66
+ JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'initialize', params: {
67
+ protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'presence-probe', version: '1' },
68
+ } }),
69
+ JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }),
70
+ ...calls,
71
+ ].join('\n') + '\n';
72
+ const result = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve', '--profile', 'full'], {
73
+ cwd: root,
74
+ input,
75
+ encoding: 'utf8',
76
+ timeout: 300000,
77
+ maxBuffer: 32 * 1024 * 1024,
78
+ env: { ...baseEnv, ...extraEnv },
79
+ });
80
+ const messages = String(result.stdout || '').split(/\r?\n/).filter(Boolean).map((line) => {
81
+ try { return JSON.parse(line); } catch { return null; }
82
+ }).filter(Boolean);
83
+ return { result, messages, byId: (id) => messages.find((message) => message.id === id) || null };
84
+ }
85
+
86
+ function sessionDir(root) {
87
+ return path.join(root, '.leerness', 'cache', 'sessions');
88
+ }
89
+
90
+ function sessionFiles(root) {
91
+ try { return fs.readdirSync(sessionDir(root)).filter((name) => /^[A-Za-z0-9_-]{8,64}\.json$/.test(name)); }
92
+ catch { return []; }
93
+ }
94
+
95
+ function readSession(root, key) {
96
+ try { return JSON.parse(fs.readFileSync(path.join(sessionDir(root), `${key}.json`), 'utf8')); }
97
+ catch { return null; }
98
+ }
99
+
100
+ function runFiles(root) {
101
+ try { return fs.readdirSync(path.join(root, '.leerness', 'runs')).filter((name) => /^run-\d+\.json$/.test(name)); }
102
+ catch { return []; }
103
+ }
104
+
105
+ function rpcErrorCode(batch, id) {
106
+ return batch.byId(id) && batch.byId(id).error && batch.byId(id).error.code;
107
+ }
108
+
109
+ try {
110
+ check('probe environment cleanup is case-insensitive', baseEnvIsolated,
111
+ JSON.stringify(Object.keys(baseEnv).filter(key => controlledEnvKeys.has(key.toLowerCase()))));
112
+ const mixedCaseOptOut = SP.normalizePresenceEnv({
113
+ LeErNeSs_No_SeSsIoN_PrEsEnCe: '1',
114
+ LeErNeSs_SeSsIoN_Id: 'mixed-case-session',
115
+ }, { caseInsensitive: true });
116
+ check('presence evaluation preserves Windows case-insensitive env semantics',
117
+ SP.suppressionReason(mixedCaseOptOut, true) === 'opt-out'
118
+ && SP.deriveSessionKey(mixedCaseOptOut) === 'mixed-case-session',
119
+ JSON.stringify(mixedCaseOptOut));
120
+
121
+ const alpha = 'mcp-alpha-session';
122
+ const bravo = 'mcp-bravo-session';
123
+ const root = fresh('lifecycle');
124
+ const batch = runMcp(root, [
125
+ rpcCall('leerness_handoff', { path: root, sessionKey: alpha }, 2),
126
+ rpcCall('leerness_handoff', { path: root, sessionKey: bravo }, 3),
127
+ rpcCall('leerness_handoff', { path: root, sessionKey: alpha }, 4),
128
+ rpcCall('leerness_session_close', { path: root, sessionKey: alpha }, 5),
129
+ ]);
130
+ check('MCP lifecycle batch exits successfully', batch.result.status === 0,
131
+ `exit=${batch.result.status} stderr=${String(batch.result.stderr || '').slice(0, 300)}`);
132
+
133
+ const listed = batch.byId(1);
134
+ const tools = listed && listed.result && Array.isArray(listed.result.tools) ? listed.result.tools : [];
135
+ for (const name of ['leerness_handoff', 'leerness_session_close']) {
136
+ const tool = tools.find((entry) => entry.name === name);
137
+ const hasSessionKey = !!(tool && tool.inputSchema && tool.inputSchema.properties
138
+ && tool.inputSchema.properties.sessionKey);
139
+ check(`${name} advertises sessionKey`, hasSessionKey);
140
+ }
141
+
142
+ const files = sessionFiles(root);
143
+ check('addressed MCP handoffs register both sessions', files.includes(`${alpha}.json`) && files.includes(`${bravo}.json`),
144
+ `files=${JSON.stringify(files)}`);
145
+ const thirdText = batch.byId(4)?.result?.content?.[0]?.text || '';
146
+ check('addressed MCP handoff sees the other session', /다른 세션 1/.test(thirdText), thirdText.slice(0, 300));
147
+ const alphaRecord = readSession(root, alpha);
148
+ const bravoRecord = readSession(root, bravo);
149
+ check('MCP session_close closes only its addressed session',
150
+ !!(alphaRecord && alphaRecord.closedAt) && !!(bravoRecord && !bravoRecord.closedAt),
151
+ `alpha=${JSON.stringify(alphaRecord)} bravo=${JSON.stringify(bravoRecord)}`);
152
+
153
+ // 외부 Codex P2: lifecycle이 아닌 도구에 숨은 sessionKey를 주면 marker가 agent-mode의
154
+ // nested handoff까지 전파됐다. 광고하지 않은 주소 입력은 실행 전에 거절해야 한다.
155
+ const unsupportedRoot = fresh('unsupported-address');
156
+ const unsupported = runMcp(unsupportedRoot, [
157
+ rpcCall('leerness_agent_mode', { path: unsupportedRoot, sub: 'start', sessionKey: 'mcp-hidden-address' }, 2),
158
+ ]);
159
+ check('non-addressable MCP tools reject hidden sessionKey without side effects',
160
+ rpcErrorCode(unsupported, 2) === -32602 && sessionFiles(unsupportedRoot).length === 0,
161
+ `reply=${JSON.stringify(unsupported.byId(2))} files=${JSON.stringify(sessionFiles(unsupportedRoot))}`);
162
+
163
+ const anonymousRoot = fresh('anonymous');
164
+ const anonymous = runMcp(anonymousRoot, [rpcCall('leerness_handoff', { path: anonymousRoot }, 2)]);
165
+ check('unaddressed MCP handoff stays unregistered', anonymous.result.status === 0 && sessionFiles(anonymousRoot).length === 0,
166
+ `exit=${anonymous.result.status} files=${JSON.stringify(sessionFiles(anonymousRoot))}`);
167
+
168
+ const internalRoot = fresh('internal');
169
+ const internal = cp.spawnSync(process.execPath, [CLI, 'handoff', internalRoot, '--compact'], {
170
+ cwd: internalRoot,
171
+ encoding: 'utf8',
172
+ timeout: 120000,
173
+ env: { ...baseEnv, LEERNESS_INTERNAL: '1', LEERNESS_SESSION_ID: 'generic-internal-session' },
174
+ });
175
+ check('generic internal subprocess stays unregistered', internal.status === 0 && sessionFiles(internalRoot).length === 0,
176
+ `exit=${internal.status} files=${JSON.stringify(sessionFiles(internalRoot))}`);
177
+
178
+ const childRoot = fresh('child');
179
+ const child = runMcp(childRoot, [
180
+ rpcCall('leerness_handoff', { path: childRoot, sessionKey: 'mcp-child-addressed' }, 2),
181
+ ], { CLAUDE_CODE_CHILD_SESSION: '1' });
182
+ check('per-call MCP sessionKey survives inherited child marker',
183
+ child.result.status === 0 && sessionFiles(childRoot).includes('mcp-child-addressed.json'),
184
+ `exit=${child.result.status} files=${JSON.stringify(sessionFiles(childRoot))}`);
185
+
186
+ // 공백이 있는 임시 경로에서도 NODE_OPTIONS preload가 한 토큰으로 전달되어야 한다.
187
+ const suppressedRoot = fresh('hook ci suppression');
188
+ const childEnvLog = path.join(suppressedRoot, 'child-env.ndjson');
189
+ const noRecordEnvLog = path.join(suppressedRoot, 'no-record-child-env.ndjson');
190
+ const preloadPath = path.join(suppressedRoot, 'capture-child-env.js');
191
+ // NODE_OPTIONS 파서는 인용된 Windows 역슬래시도 escape로 소비한다. Node가 Windows에서도
192
+ // 절대 경로로 이해하는 forward-slash 표기로 바꾼 뒤 한 인자로 묶는다.
193
+ const preloadModule = preloadPath.replace(/\\/g, '/');
194
+ const preloadOption = `--require="${preloadModule.replace(/"/g, '\\"')}"`;
195
+ fs.writeFileSync(preloadPath, [
196
+ "'use strict';",
197
+ "const fs = require('fs');",
198
+ "const row = { argv: process.argv.slice(1, 8), marker: process.env.LEERNESS_MCP_ADDRESS_EXPLICIT || null };",
199
+ "process.on('exit', () => { try { fs.appendFileSync(process.env.LEERNESS_TEST_CHILD_ENV_LOG, JSON.stringify(row) + '\\n'); } catch {} });",
200
+ ].join('\n') + '\n', 'utf8');
201
+ const hookSuppressed = runMcp(suppressedRoot, [
202
+ rpcCall('leerness_handoff', { path: suppressedRoot, sessionKey: 'mcp-hook-address' }, 2),
203
+ ], { LEERNESS_HOOK: '1' });
204
+ const hookClose = runMcp(suppressedRoot, [
205
+ rpcCall('leerness_session_close', { path: suppressedRoot, sessionKey: 'mcp-hook-close' }, 2),
206
+ ], {
207
+ LEERNESS_HOOK: '1',
208
+ NODE_OPTIONS: preloadOption,
209
+ LEERNESS_TEST_CHILD_ENV_LOG: childEnvLog,
210
+ });
211
+ const ciSuppressed = runMcp(suppressedRoot, [
212
+ rpcCall('leerness_handoff', { path: suppressedRoot, sessionKey: 'mcp-ci-addressed' }, 2),
213
+ ], { CI: '1' });
214
+ check('hook and CI suppression still win over lifecycle marker',
215
+ hookSuppressed.result.status === 0 && ciSuppressed.result.status === 0 && sessionFiles(suppressedRoot).length === 0,
216
+ `files=${JSON.stringify(sessionFiles(suppressedRoot))}`);
217
+ let childEnvRows = [];
218
+ try { childEnvRows = fs.readFileSync(childEnvLog, 'utf8').split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)); } catch {}
219
+ const nestedRows = childEnvRows.filter(row => Array.isArray(row.argv)
220
+ && (row.argv.includes('skill') || row.argv.includes('drift')));
221
+ check('hook-suppressed MCP session_close strips marker before nested children',
222
+ hookClose.result.status === 0 && nestedRows.length >= 2 && nestedRows.every(row => row.marker === null),
223
+ `exit=${hookClose.result.status} rows=${JSON.stringify(childEnvRows)}`);
224
+
225
+ const noRecordClose = cp.spawnSync(process.execPath,
226
+ [CLI, 'session', 'close', suppressedRoot, '--no-record'], {
227
+ cwd: suppressedRoot,
228
+ encoding: 'utf8',
229
+ timeout: 120000,
230
+ env: {
231
+ ...baseEnv,
232
+ LEERNESS_INTERNAL: '1',
233
+ LEERNESS_SESSION_ID: 'mcp-no-record-close',
234
+ LEERNESS_MCP_ADDRESS_EXPLICIT: '1',
235
+ NODE_OPTIONS: preloadOption,
236
+ LEERNESS_TEST_CHILD_ENV_LOG: noRecordEnvLog,
237
+ },
238
+ });
239
+ let noRecordRows = [];
240
+ try { noRecordRows = fs.readFileSync(noRecordEnvLog, 'utf8').split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)); } catch {}
241
+ const noRecordNestedRows = noRecordRows.filter(row => Array.isArray(row.argv)
242
+ && (row.argv.includes('skill') || row.argv.includes('drift')));
243
+ check('marker-bearing session_close --no-record strips marker before nested children',
244
+ noRecordClose.status === 0 && noRecordNestedRows.length >= 2
245
+ && noRecordNestedRows.every(row => row.marker === null) && sessionFiles(suppressedRoot).length === 0,
246
+ `exit=${noRecordClose.status} stderr=${String(noRecordClose.stderr || '').slice(0, 300)} rows=${JSON.stringify(noRecordRows)}`);
247
+
248
+ // T-0110의 옛 currentRunId 단일 슬롯 결함은 1.36.145 이후 이미 sessionKey별 run으로
249
+ // 격리돼 있다. lifecycle presence 수정이 그 상태 귀속을 되돌리지 않는지 같은 MCP 경계에서 확인한다.
250
+ const stateRoot = fresh('state-isolation');
251
+ const state = runMcp(stateRoot, [
252
+ rpcCall('leerness_state_start', { path: stateRoot, goal: 'alpha', sessionKey: alpha }, 2),
253
+ rpcCall('leerness_state_start', { path: stateRoot, goal: 'bravo', sessionKey: bravo }, 3),
254
+ rpcCall('leerness_state_record', { path: stateRoot, filesChanged: 'alpha.js', sessionKey: alpha }, 4),
255
+ rpcCall('leerness_state_verify', { path: stateRoot, result: 'pass', sessionKey: bravo }, 5),
256
+ ]);
257
+ const resultJson = (id) => {
258
+ try { return JSON.parse(state.byId(id).result.content[0].text); } catch { return null; }
259
+ };
260
+ const startedAlpha = resultJson(2);
261
+ const startedBravo = resultJson(3);
262
+ const recordedAlpha = resultJson(4);
263
+ const verifiedBravo = resultJson(5);
264
+ check('MCP state runs remain isolated by sessionKey',
265
+ !!(startedAlpha && startedBravo && startedAlpha.started !== startedBravo.started
266
+ && recordedAlpha && recordedAlpha.recorded === startedAlpha.started
267
+ && verifiedBravo && verifiedBravo.verified === startedBravo.started
268
+ && verifiedBravo.result === 'pass' && verifiedBravo.run
269
+ && verifiedBravo.run.verification_result === 'pass'),
270
+ `alpha=${JSON.stringify(startedAlpha)} bravo=${JSON.stringify(startedBravo)} record=${JSON.stringify(recordedAlpha)} verify=${JSON.stringify(verifiedBravo)}`);
271
+ check('one session cannot borrow another session evidence',
272
+ !!(verifiedBravo && verifiedBravo.completion_claim_allowed
273
+ && verifiedBravo.completion_claim_allowed.allowed === false),
274
+ JSON.stringify(verifiedBravo));
275
+ check('state-only MCP calls do not create presence records', sessionFiles(stateRoot).length === 0,
276
+ `files=${JSON.stringify(sessionFiles(stateRoot))}`);
277
+
278
+ // 외부 Codex P1: 명시된 잘못된 값이 서버의 CODEX_THREAD_ID로 폴백하면 호출자는 주소가
279
+ // 거절된 줄 모르고 공유 run 슬롯을 다시 쓴다. 세 형태 모두 -32602이며 파일을 만들지 않아야 한다.
280
+ const invalidRoot = fresh('invalid-address');
281
+ const beforeRuns = runFiles(invalidRoot);
282
+ const invalid = runMcp(invalidRoot, [
283
+ rpcCall('leerness_state_start', { path: invalidRoot, goal: 'bad-space', sessionKey: 'bad key' }, 2),
284
+ rpcCall('leerness_state_start', { path: invalidRoot, goal: 'blank', sessionKey: ' ' }, 3),
285
+ rpcCall('leerness_state_start', { path: invalidRoot, goal: 'reserved', sessionKey: 'unaddressed' }, 4),
286
+ rpcCall('leerness_state_start', { path: invalidRoot, goal: 'trim-bypass', sessionKey: ' mcp-alpha-session ' }, 5),
287
+ ], { CODEX_THREAD_ID: 'server-fallback-session' });
288
+ check('invalid MCP sessionKey values are rejected before inherited fallback',
289
+ [2, 3, 4, 5].every((id) => rpcErrorCode(invalid, id) === -32602)
290
+ && JSON.stringify(runFiles(invalidRoot)) === JSON.stringify(beforeRuns)
291
+ && sessionFiles(invalidRoot).length === 0,
292
+ `replies=${JSON.stringify([invalid.byId(2), invalid.byId(3), invalid.byId(4), invalid.byId(5)])} runs=${JSON.stringify(runFiles(invalidRoot))}`);
293
+
294
+ const malformedValues = [
295
+ 'not-a-date', '0', '1', '2024-02-30T00:00:00.000Z',
296
+ '2024-02-30T00:00:00Z', ' 2024-01-01T00:00:00.000Z ',
297
+ ];
298
+ const malformed = Array.from({ length: 21 }, (_, i) => {
299
+ const key = `broken${String(i).padStart(3, '0')}`;
300
+ return { name: `${key}.json`, record: { sessionKey: key, hostId: 'same-host', closedAt: malformedValues[i % malformedValues.length] } };
301
+ });
302
+ malformed.push({ name: 'wrongpeer.json', record: { sessionKey: 'otherpeer', hostId: 'same-host', closedAt: 'not-a-date' } });
303
+ const validClose = new Date(1700000000000).toISOString();
304
+ check('only canonical real closedAt values are confirmed; malformed values stay unconfirmed',
305
+ SP.selectPrunable(malformed, { selfKey: 'selfpeer', nowMs: Date.now() }).length === 0
306
+ && malformed.every(entry => !SP.hasConfirmedClose(entry.record))
307
+ && SP.countOtherSessions([malformed[0].record], 'selfpeer', 'same-host') === 1
308
+ && SP.confirmedCloseMs({ closedAt: validClose }) === 1700000000000,
309
+ JSON.stringify(SP.selectPrunable(malformed, { selfKey: 'selfpeer', nowMs: Date.now() })));
310
+
311
+ // POSIX legacy case variants are logically collapsed by read-only sessions, then migrated on the
312
+ // next canonical write. Windows cannot hold two case variants, so only the logical assertion applies.
313
+ const legacyRoot = fresh('legacy-case');
314
+ fs.mkdirSync(sessionDir(legacyRoot), { recursive: true });
315
+ const legacyName = 'CaseGhost01';
316
+ const foldedLegacy = legacyName.toLowerCase();
317
+ const legacyPath = path.join(sessionDir(legacyRoot), `${legacyName}.json`);
318
+ fs.writeFileSync(legacyPath, JSON.stringify({
319
+ schemaVersion: 1, sessionKey: legacyName, openedAt: new Date().toISOString(),
320
+ lastHandoffAt: new Date().toISOString(), closedAt: null,
321
+ }, null, 2) + '\n', 'utf8');
322
+ const caseSensitive = !fs.existsSync(path.join(sessionDir(legacyRoot), `${foldedLegacy}.json`));
323
+ const listedLegacy = cp.spawnSync(process.execPath, [CLI, 'sessions', legacyRoot, '--json'], {
324
+ cwd: legacyRoot, encoding: 'utf8', timeout: 120000,
325
+ env: { ...baseEnv, LEERNESS_SESSION_ID: foldedLegacy },
326
+ });
327
+ let legacyJson = null;
328
+ try { legacyJson = JSON.parse(listedLegacy.stdout); } catch {}
329
+ const migratedLegacy = cp.spawnSync(process.execPath, [CLI, 'handoff', legacyRoot, '--compact'], {
330
+ cwd: legacyRoot, encoding: 'utf8', timeout: 120000,
331
+ env: { ...baseEnv, LEERNESS_SESSION_ID: foldedLegacy },
332
+ });
333
+ const migratedFiles = sessionFiles(legacyRoot);
334
+ check('legacy mixed-case presence is one self record and migrates without a ghost',
335
+ listedLegacy.status === 0 && legacyJson && legacyJson.total === 1 && legacyJson.selfRecordPresent === true
336
+ && legacyJson.sessions[0].sessionKey === foldedLegacy && migratedLegacy.status === 0
337
+ && (!caseSensitive || (migratedFiles.includes(`${foldedLegacy}.json`) && !migratedFiles.includes(`${legacyName}.json`))),
338
+ `caseSensitive=${caseSensitive} listed=${JSON.stringify(legacyJson)} files=${JSON.stringify(migratedFiles)}`);
339
+
340
+ // 대소문자 구분 저장소의 두 열린 legacy variant는 서로 다른 구버전 세션일 수 있다.
341
+ // 자동 병합/삭제하지 않고 canonical record와 조회 표면에 충돌을 남긴다.
342
+ const ambiguousRoot = fresh('legacy-case-conflict');
343
+ fs.mkdirSync(sessionDir(ambiguousRoot), { recursive: true });
344
+ const variantA = 'CaseTwin01';
345
+ const variantB = 'CASETWIN01';
346
+ const canonicalTwin = variantA.toLowerCase();
347
+ const openRecord = (sessionKey) => ({
348
+ schemaVersion: 1, sessionKey, openedAt: new Date().toISOString(),
349
+ lastHandoffAt: new Date().toISOString(), closedAt: null,
350
+ });
351
+ fs.writeFileSync(path.join(sessionDir(ambiguousRoot), `${variantA}.json`),
352
+ JSON.stringify(openRecord(variantA), null, 2) + '\n', 'utf8');
353
+ const ambiguousCaseSensitive = !fs.existsSync(path.join(sessionDir(ambiguousRoot), `${canonicalTwin}.json`));
354
+ const pureConflict = SP.caseVariantConflict([
355
+ { name: `${variantA}.json`, record: openRecord(variantA) },
356
+ { name: `${variantB}.json`, record: openRecord(variantB) },
357
+ ]);
358
+ let physicalConflictOk = true;
359
+ let physicalDetail = { caseSensitive: ambiguousCaseSensitive };
360
+ if (ambiguousCaseSensitive) {
361
+ fs.writeFileSync(path.join(sessionDir(ambiguousRoot), `${variantB}.json`),
362
+ JSON.stringify(openRecord(variantB), null, 2) + '\n', 'utf8');
363
+ const wrote = cp.spawnSync(process.execPath, [CLI, 'handoff', ambiguousRoot, '--compact'], {
364
+ cwd: ambiguousRoot, encoding: 'utf8', timeout: 120000,
365
+ env: { ...baseEnv, LEERNESS_SESSION_ID: canonicalTwin },
366
+ });
367
+ const filesAfter = sessionFiles(ambiguousRoot);
368
+ const canonicalRecord = readSession(ambiguousRoot, canonicalTwin);
369
+ const listed = cp.spawnSync(process.execPath, [CLI, 'sessions', ambiguousRoot, '--json'], {
370
+ cwd: ambiguousRoot, encoding: 'utf8', timeout: 120000,
371
+ env: { ...baseEnv, LEERNESS_SESSION_ID: canonicalTwin },
372
+ });
373
+ let listedJson = null;
374
+ try { listedJson = JSON.parse(listed.stdout); } catch {}
375
+ const row = listedJson && listedJson.sessions && listedJson.sessions.find(item => item.sessionKey === canonicalTwin);
376
+ physicalConflictOk = wrote.status === 0
377
+ && filesAfter.includes(`${variantA}.json`) && filesAfter.includes(`${variantB}.json`)
378
+ && canonicalRecord && canonicalRecord.caseVariantConflict && canonicalRecord.caseVariantConflict.count === 2
379
+ && row && row.caseVariantConflict && row.caseVariantConflict.count === 3;
380
+ physicalDetail = { caseSensitive: true, filesAfter, canonicalRecord, row };
381
+ }
382
+ check('ambiguous open case variants are preserved and explicitly flagged',
383
+ pureConflict && pureConflict.count === 2 && physicalConflictOk,
384
+ JSON.stringify(physicalDetail));
385
+ } catch (error) {
386
+ check('probe completed without exception', false, error && error.stack ? error.stack : String(error));
387
+ } finally {
388
+ for (const root of roots) {
389
+ try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
390
+ }
391
+ }
392
+
393
+ process.stdout.write(`MCP presence probe: ${total - failed}/${total} passed\n`);
394
+ process.exitCode = failed ? 1 : 0;