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 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.169 하네스를 사용합니다. 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.169는 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.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.169 릴리스 태그 이력** (수백 라운드) · _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.169: 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.169';
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상태
@@ -11400,12 +11443,14 @@ function _runPreWakeAudit(root) {
11400
11443
 
11401
11444
  // 6) next-action queue 잔여 (1.9.201)
11402
11445
  try {
11403
- const q = _loadNextActionQueue(root);
11404
- if (q && q.pending && q.pending.length > 0) {
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.pending.length,
11408
- detail: `${q.pending.length}건 next-action 대기 — leerness next-action take 로 즉시 처리`
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
- return { ...j, ageMs, ageMin: Math.floor(ageMs / 60000), elapsedFromExpected: Math.floor(expiredMs / 60000) };
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 = _loadNextActionQueue(root);
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 entries = [];
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) 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++; }
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(process.env, hasHarness);
13325
+ const reason = SP.suppressionReason(presenceEnv, hasHarness);
13231
13326
  if (reason) return { written: false, reason };
13232
- const key = SP.deriveSessionKey(process.env);
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(process.env);
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) && process.env.LEERNESS_NO_LOCK_WARN !== '1') {
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(process.env), // 1.36.132: 3종(LEERNESS_SESSION_ID 우선) — 손으로 적은 삼항은 새 출처를 놓친다
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 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() });
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
- snap.entries.filter(e => e.name !== name)), { selfKey: key, nowMs: Date.now() });
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 전용 클라이언트(LEERNESS_INTERNAL)',
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: !!r.closedAt,
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('MCP 도구만 쓰는 클라이언트', 'clients that only use MCP tools'),
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
- 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}`);
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: { ...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
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' && !TOOLS.some(t => t.name === name)) {
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 _td = TOOLS.find(t => t.name === name);
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 (_td.inputSchema.required || [])) {
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
- // 세션 주소를 그대로 전달한다(문자열일 때만). 형식이 안 맞으면 CLI 가 무시하고 그 사실을 말한다.
25765
- const _sk = (args && typeof args.sessionKey === 'string' && args.sessionKey.trim()) ? args.sessionKey.trim() : null;
25766
- 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);
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
- const queueState = _loadNextActionQueue(root);
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) 적재', 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' } } } },