leerness 1.36.170 → 1.36.172

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.172 — 2026-08-27
4
+
5
+ - GitHub CI에서 재현된 동시 최초 .harness→.leerness 마이그레이션 TOCTOU를 수정했습니다. 라이브 마이그레이션이 두 번째 상태 검사와 충돌 스캔 전에 프로젝트 잠금을 획득하며, 실제 peer EEXIST와 잠금 해제 직전까지의 대기를 검증하는 결정론적 회귀를 fast/core/full 게이트에 연결했습니다.
6
+
7
+ ## 1.36.171 — 2026-08-27
8
+
9
+ - MCP handoff/session-close가 호출별 sessionKey로 presence를 등록·종료하고, Windows 환경변수 casing·일회성 marker·legacy case variant·손상 closedAt를 안전하게 처리하도록 개선했습니다. 전용 MCP presence 회귀 22/22와 전체 E2E 467/467를 추가·통과했습니다.
10
+
3
11
  ## 1.36.170 — 2026-08-27
4
12
 
5
13
  - next-action의 정규화 계약을 모든 읽기 표면으로 확장했습니다. release-time auto-resume plan 생성과 외부 에이전트용 `context`가 legacy `--filter`, 자동 완료 명령, 시간 기반 중복, 존재하지 않는 stress 실행을 다시 노출하지 않습니다.
package/README.md CHANGED
@@ -138,7 +138,7 @@ MIT
138
138
  <!-- leerness:project-readme:start -->
139
139
  ## Leerness Project Harness
140
140
 
141
- 이 프로젝트는 Leerness v1.36.170 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
141
+ 이 프로젝트는 Leerness v1.36.172 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
142
142
 
143
143
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
144
144
 
@@ -192,7 +192,7 @@ leerness memory restore decision <date|title>
192
192
 
193
193
  ### MCP server (외부 AI 통합)
194
194
 
195
- Leerness v1.36.170는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
195
+ Leerness v1.36.172는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
196
196
 
197
197
  ```jsonc
198
198
  // 카테고리별
@@ -213,7 +213,7 @@ Leerness v1.36.170는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
213
213
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
214
214
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
215
215
 
216
- 현재 누적: **v1.9.x → 1.36.170 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
216
+ 현재 누적: **v1.9.x → 1.36.172 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
217
217
 
218
218
  ### 성능 가이드
219
219
 
@@ -251,5 +251,5 @@ leerness release pack --close --auto-main-push
251
251
  - `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
252
252
  - `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
253
253
 
254
- Last synced by Leerness v1.36.170: 2026-08-27
254
+ Last synced by Leerness v1.36.172: 2026-08-27
255
255
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -48,13 +48,33 @@ const {
48
48
  migrateLegacyWorkspace,
49
49
  } = require('../lib/workspace-dir');
50
50
 
51
- const VERSION = '1.36.170';
51
+ const VERSION = '1.36.172';
52
+
53
+ // MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
54
+ // 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
55
+ let _directMcpPresenceMarkerPending = false;
56
+ function _deleteEnvKeyCaseInsensitive(env, canonical) {
57
+ const folded = String(canonical).toLowerCase();
58
+ for (const key of Object.keys(env || {})) {
59
+ if (key.toLowerCase() === folded) delete env[key];
60
+ }
61
+ }
62
+ function _captureDirectMcpPresenceMarker() {
63
+ const markerKey = process.platform === 'win32'
64
+ ? Object.keys(process.env).find(key => key.toLowerCase() === 'leerness_mcp_address_explicit')
65
+ : (Object.prototype.hasOwnProperty.call(process.env, 'LEERNESS_MCP_ADDRESS_EXPLICIT')
66
+ ? 'LEERNESS_MCP_ADDRESS_EXPLICIT' : null);
67
+ if (markerKey && process.env[markerKey] === '1') _directMcpPresenceMarkerPending = true;
68
+ // 인식 여부와 무관하게 모든 casing을 떼어 자식 프로세스에 일회성 marker가 새지 않게 한다.
69
+ _deleteEnvKeyCaseInsensitive(process.env, 'LEERNESS_MCP_ADDRESS_EXPLICIT');
70
+ }
52
71
 
53
72
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
54
73
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
55
74
  // 해결: 부작용을 _cliBootstrap() 으로 묶고 파일 끝 require.main 가드에서만 호출. import/단위테스트는 부작용 0.
56
75
  // 1.9.249 (UR-0018): 터미널 출력 한국어 깨짐 사전 방지 (stdout UTF-8 + Windows 비-65001 시 chcp 65001 자동, opt-out LEERNESS_NO_AUTOCHCP=1).
57
76
  function _ensureStdoutEncoding() {
77
+ _captureDirectMcpPresenceMarker();
58
78
  try {
59
79
  if (process.stdout && process.stdout.setEncoding) process.stdout.setEncoding('utf8');
60
80
  if (process.stderr && process.stderr.setEncoding) process.stderr.setEncoding('utf8');
@@ -7491,15 +7511,22 @@ function _selfTestCases() {
7491
7511
  if (SP.suppressionReason(Object.assign({}, base, { LEERNESS_NO_SESSION_PRESENCE: '1' }), true) !== 'opt-out') return false;
7492
7512
  if (SP.suppressionReason(base, false) !== 'not-a-leerness-project') return false;
7493
7513
  if (SP.suppressionReason({}, true) !== 'not-identifiable') return false;
7494
- // ⑤ 프루닝 — 입력은 {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' } } } },