leerness 1.36.128 → 1.36.129

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/README.md CHANGED
@@ -122,7 +122,7 @@ MIT
122
122
  <!-- leerness:project-readme:start -->
123
123
  ## Leerness Project Harness
124
124
 
125
- 이 프로젝트는 Leerness v1.36.128 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.129 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
126
126
 
127
127
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
128
128
 
@@ -176,7 +176,7 @@ leerness memory restore decision <date|title>
176
176
 
177
177
  ### MCP server (외부 AI 통합)
178
178
 
179
- Leerness v1.36.128는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.129는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
180
180
 
181
181
  ```jsonc
182
182
  // 카테고리별
@@ -197,7 +197,7 @@ Leerness v1.36.128는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
197
197
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
198
198
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
199
199
 
200
- 현재 누적: **v1.9.x → 1.36.128 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.129 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
201
201
 
202
202
  ### 성능 가이드
203
203
 
@@ -235,5 +235,5 @@ leerness release pack --close --auto-main-push
235
235
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
236
236
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
237
237
 
238
- Last synced by Leerness v1.36.128: 2026-08-18
238
+ Last synced by Leerness v1.36.129: 2026-08-18
239
239
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
34
34
  const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
35
35
  const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
36
36
 
37
- const VERSION = '1.36.128';
37
+ const VERSION = '1.36.129';
38
38
 
39
39
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
40
40
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -4066,7 +4066,21 @@ function _selfTestCases() {
4066
4066
  const s = read(__filename);
4067
4067
  const surface = typeof hookSessionStartCmd === 'function' && s.includes("cmd === 'hook' && args[1] === 'session-start'");
4068
4068
  // ★ 핵심 불변식: hook 은 handoff 를 부르지 않는다(부르면 last-handoff stamp 로 detector 3종 오염) + record 는 가드 뒤에만
4069
- const zeroWrite = s.includes("if (!has('--no-record') && process.env.LEERNESS_HOOK !== '1') { try { _recordLastHandoff(root); } catch {} }");
4069
+ // 1.36.129: 통짜 문장 리터럴을 붙잡고 있었는데, 같은 게이트 **안에** 세션 프레즌스 기록이 추가되며 깨졌다.
4070
+ // 지키려는 불변식은 문장 모양이 아니라 "**모든 기록이 hook 제외 게이트 뒤에 있다**" 이다.
4071
+ // ⚠ 자기참조 8회차: 이 케이스 자신의 소스가 그 리터럴을 담고 있어 `indexOf` 가 **자기 줄을 먼저** 잡았다.
4072
+ // 리터럴 쪼개기는 다음 사람이 또 적으면 뚫린다 — **selftest 영역을 통째로 제외하고** 제품 코드에서만 찾는다.
4073
+ const _selfI = s.indexOf('function ' + '_selfTestCases(');
4074
+ const _selfE = _selfI < 0 ? -1 : s.indexOf(String.fromCharCode(10) + 'function ', _selfI + 10);
4075
+ const _prod = (_selfI < 0 || _selfE < 0) ? '' : s.slice(0, _selfI) + s.slice(_selfE);
4076
+ const _gateStart = _prod.indexOf("if (!has('--no-record') && process.env.LEERNESS_HOOK !== '1') {");
4077
+ const _gateStmt = _gateStart < 0 ? '' : _prod.slice(_gateStart, _prod.indexOf(String.fromCharCode(10), _gateStart));
4078
+ const zeroWrite = _prod.length > 0 && _gateStart > 0
4079
+ && _gateStmt.includes('_recordLastHandoff(root)')
4080
+ && _gateStmt.includes("_sessionPresenceRecord(root, 'open')")
4081
+ // ⚠ 정의 줄(`function _recordLastHandoff(root) {`)까지 세면 2가 된다 — **호출만** 센다.
4082
+ && (_prod.match(/_recordLastHandoff\(root\);/g) || []).length === 1
4083
+ && (_prod.match(/_sessionPresenceRecord\(root, 'open'\)/g) || []).length === 1;
4070
4084
  // split-literal: 앵커를 통짜 문자열로 두면 이 selftest 줄 자신이 먼저 매칭돼 43자짜리 자기 슬라이스를 잡는다(자기참조 트랩, UR-0009 계열).
4071
4085
  const hookBody = s.slice(s.indexOf('function ' + 'hookSessionStartCmd'), s.indexOf('function ' + 'handoffCmd'));
4072
4086
  const noHandoffCall = hookBody.length > 100 && !/handoffCmd\(|_recordLastHandoff\(|writeUtf8\(|mkdirp\(/.test(hookBody); // 쓰기/handoff 호출 0
@@ -6693,6 +6707,88 @@ function _selfTestCases() {
6693
6707
  } finally { if (saved !== undefined) process.env.LEERNESS_MCP_TIMEOUT_MS = saved; else delete process.env.LEERNESS_MCP_TIMEOUT_MS; }
6694
6708
  } },
6695
6709
  // 1.36.125: 측정 통제 자체의 가드 — 목록이 비거나 배선이 끊기면 selftest 는 다시 사용자 설정에 휘둘린다.
6710
+ // 1.36.129 (UR-0068 / P-0016 P1): 세션 프레즌스의 **판정 규칙**을 순수 함수 단위로 건다.
6711
+ // 전부 행위검사다(자기 소스 읽기 0 — 1.36.84 메타가드). CLI 배선은 e2e Y 블록이 실행으로 잰다.
6712
+ { name: '1.36.129 (P-0016 P1): 세션 프레즌스 판정 — 키 유도·최외곽 라벨·억제·프루닝·헤드라인 침묵 (행위검사)', run: () => {
6713
+ const SP = require('../lib/session-presence');
6714
+ // ① 키는 에이전트가 **스스로 준 id** 만. 자식은 부모로 접히고, 없으면 등록하지 않는다.
6715
+ if (SP.deriveSessionKey({ CLAUDE_CODE_SESSION_ID: 'abcdefgh' }) !== 'abcdefgh') return false;
6716
+ if (SP.deriveSessionKey({ CLAUDE_CODE_SESSION_ID: 'child123', CLAUDE_CODE_HOST_SESSION_ID: 'parent12' }) !== 'parent12') return false;
6717
+ if (SP.deriveSessionKey({ CLAUDE_CODE_SESSION_ID: 'abcdefgh', CLAUDE_CODE_CHILD_SESSION: '1' }) !== null) return false;
6718
+ if (SP.deriveSessionKey({}) !== null) return false;
6719
+ // ② 경로 탈옥 — 정상 5종은 통과, 탈출 6종은 전부 거부(한쪽만 재면 '전부 거부'가 통과한다)
6720
+ const okKeys = ['f3d070f6-f51d-4d44-becd-0026daa0c538', 'a'.repeat(64), 'abc-123_x', '12345678', 'A'.repeat(8)];
6721
+ if (!okKeys.every(k => SP.isValidKey(k))) return false;
6722
+ const badKeys = ['..', '../../x', 'a/b', 'a\\b', 'C:\\x', '', 'short', 'a'.repeat(65)];
6723
+ if (badKeys.some(k => SP.isValidKey(k))) return false;
6724
+ // ③ 최외곽 라벨 — CLAUDECODE 우선. **금지 신호로는 절대 판정하지 않는다**(실측: 이 저장소의 Claude 세션에
6725
+ // CODEX_COMPANION_SESSION_ID 가 실재한다 — 그걸 근거로 삼으면 즉발 오라벨).
6726
+ if (SP.detectOutermostAgent({ CLAUDECODE: '1' }).agent !== 'claude-code') return false;
6727
+ if (SP.detectOutermostAgent({ CODEX_MANAGED_BY_NPM: '1' }).agent !== 'codex-cli') return false;
6728
+ // codex 가 부모 env 를 물려줘 CLAUDECODE 가 함께 있으면 **최외곽은 claude** 다(실측된 실제 상황)
6729
+ if (SP.detectOutermostAgent({ CLAUDECODE: '1', CODEX_MANAGED_BY_NPM: '1' }).agent !== 'claude-code') return false;
6730
+ // 금지 신호만 있으면 unknown — 이것이 이 라운드의 핵심 오탐 방지선이다
6731
+ if (SP.detectOutermostAgent({ CODEX_COMPANION_SESSION_ID: 'x', TERM_PROGRAM: 'vscode' }).agent !== 'unknown') return false;
6732
+ if (!SP.FORBIDDEN_SIGNALS.includes('CODEX_COMPANION_SESSION_ID') || !SP.FORBIDDEN_SIGNALS.includes('TERM_PROGRAM')) return false;
6733
+ // (검수 P2) cursor 는 런타임 관측이 없다 — 라벨을 확정하지 않는다. 증거 키는 남긴다.
6734
+ const _cur = SP.detectOutermostAgent({ CURSOR_AGENT: '1' });
6735
+ if (_cur.agent !== 'unknown' || !_cur.evidence.includes('CURSOR_AGENT')) return false;
6736
+ // ④ 억제 — 각 사유가 실제로 발화하고, 정상 조건에서는 null(대조군)
6737
+ const base = { CLAUDE_CODE_SESSION_ID: 'abcdefgh' };
6738
+ if (SP.suppressionReason(base, true) !== null) return false;
6739
+ if (SP.suppressionReason(Object.assign({}, base, { LEERNESS_INTERNAL: '1' }), true) !== 'internal') return false;
6740
+ if (SP.suppressionReason(Object.assign({}, base, { LEERNESS_HOOK: '1' }), true) !== 'hook') return false;
6741
+ if (SP.suppressionReason(Object.assign({}, base, { CLAUDE_CODE_CHILD_SESSION: '1' }), true) !== 'child-agent') return false;
6742
+ if (SP.suppressionReason(Object.assign({}, base, { CI: '1' }), true) !== 'ci') return false;
6743
+ // (검수 P2, 실측 재현) `CI="false"`/`"0"` 은 **억제가 아니다** — 명시적으로 아니라고 말한 환경이
6744
+ // 조용히 등록되지 않던 결함. 참/거짓 양쪽을 다 건다(한쪽만 재면 '전부 억제'가 통과한다).
6745
+ for (const v of ['true', 'yes', 'on']) if (SP.suppressionReason(Object.assign({}, base, { CI: v }), true) !== 'ci') return false;
6746
+ for (const v of ['false', '0', 'no', 'off', '']) if (SP.suppressionReason(Object.assign({}, base, { CI: v }), true) !== null) return false;
6747
+ if (SP.suppressionReason(Object.assign({}, base, { LEERNESS_NO_SESSION_PRESENCE: '1' }), true) !== 'opt-out') return false;
6748
+ if (SP.suppressionReason(base, false) !== 'not-a-leerness-project') return false;
6749
+ if (SP.suppressionReason({}, true) !== 'not-identifiable') return false;
6750
+ // ⑤ 프루닝 — 입력은 {name, record}, 반환은 **파일명**. 상한을 넘기면 자르되 자기 것은 안 자르고,
6751
+ // 적으면 아무것도 안 자른다(상한만 재면 "전부 삭제"가 통과한다).
6752
+ const mkE = (i, extra) => ({ name: `key${String(i).padStart(5, '0')}.json`,
6753
+ record: Object.assign({ sessionKey: `key${String(i).padStart(5, '0')}`, openedAt: new Date(1700000000000 + i * 1000).toISOString(), closedAt: null }, extra || {}) });
6754
+ const many = Array.from({ length: 30 }, (_, i) => mkE(i));
6755
+ const pruned = SP.selectPrunable(many, { selfKey: 'key00000', nowMs: Date.now() });
6756
+ if (pruned.includes('key00000.json')) return false; // 자기 것은 안 자른다
6757
+ if ((30 - pruned.length) > SP.SESSIONS_KEEP + 1) return false; // 상한이 실제로 걸린다
6758
+ if (!pruned.length) return false; // 계측 생존
6759
+ const few = Array.from({ length: 5 }, (_, i) => mkE(i));
6760
+ if (SP.selectPrunable(few, { selfKey: 'key00000', nowMs: Date.now() }).length !== 0) return false;
6761
+ // close 기록이 TTL 을 넘긴 것은 자르고, 갓 닫힌 것은 안 자른다
6762
+ const closedOld = [mkE(1, { closedAt: new Date(Date.now() - SP.SESSIONS_CLOSED_TTL_MS - 60000).toISOString() })];
6763
+ if (SP.selectPrunable(closedOld, { selfKey: 'other123', nowMs: Date.now() }).length !== 1) return false;
6764
+ const closedNew = [mkE(1, { closedAt: new Date(Date.now() - 60000).toISOString() })];
6765
+ if (SP.selectPrunable(closedNew, { selfKey: 'other123', nowMs: Date.now() }).length !== 0) return false;
6766
+ // (검수 P2, 실측 재현) **파일명과 키가 어긋난 레코드**가 남을 지우면 안 된다 — 자기 파일만 정리된다
6767
+ const victim = { name: 'victimxx.json', record: { sessionKey: 'victimxx', openedAt: new Date().toISOString(), lastHandoffAt: new Date().toISOString(), closedAt: null } };
6768
+ const forged = { name: 'fakefake.json', record: { sessionKey: 'victimxx', openedAt: new Date(1700000000000).toISOString(), closedAt: new Date(1700000000000).toISOString() } };
6769
+ const fp2 = SP.selectPrunable([victim, forged], { selfKey: 'me000000', nowMs: Date.now() });
6770
+ if (fp2.includes('victimxx.json') || !fp2.includes('fakefake.json')) return false;
6771
+ // (검수 P2, 실측 재현) 오래 열렸어도 **방금 handoff 한** 세션은 상한 밖으로 밀리면 안 된다
6772
+ const busy = [];
6773
+ for (let i = 0; i < 20; i++) { const t2 = new Date(Date.now() - 1000 * i).toISOString(); busy.push(mkE(i, { lastHandoffAt: t2, openedAt: t2 })); }
6774
+ busy.push({ name: 'busyxxxx.json', record: { sessionKey: 'busyxxxx', openedAt: new Date(Date.now() - 86400000).toISOString(), lastHandoffAt: new Date().toISOString(), closedAt: null } });
6775
+ if (SP.selectPrunable(busy, { selfKey: 'me000000', nowMs: Date.now() }).includes('busyxxxx.json')) return false;
6776
+ // ⑥ 헤드라인 계수 — 자기 제외 · 다른 머신 제외 · close 기록된 것 제외. 0이면 0이어야 침묵할 수 있다.
6777
+ const H = 'hostAAA';
6778
+ const recs = [
6779
+ { sessionKey: 'selfkey1', hostId: H, closedAt: null },
6780
+ { sessionKey: 'otherkk1', hostId: H, closedAt: null },
6781
+ { sessionKey: 'otherkk2', hostId: H, closedAt: new Date().toISOString() },
6782
+ { sessionKey: 'otherkk3', hostId: 'hostBBB', closedAt: null },
6783
+ ];
6784
+ if (SP.countOtherSessions(recs, 'selfkey1', H) !== 1) return false;
6785
+ if (SP.countOtherSessions([recs[0]], 'selfkey1', H) !== 0) return false; // 자기만 → 0(침묵)
6786
+ if (SP.countOtherSessions([recs[3]], 'selfkey1', H) !== 0) return false; // 다른 머신뿐 → 0(침묵)
6787
+ // (검수 P2) hostId 를 모르면 '다른 머신' 이라고 단정하지 않는다 — 3상태
6788
+ if (SP.hostRelation(null, H) !== 'unknown' || SP.hostRelation(H, null) !== 'unknown') return false;
6789
+ if (SP.hostRelation(H, H) !== 'same' || SP.hostRelation(H, 'hostZZZ') !== 'different') return false;
6790
+ return true;
6791
+ } },
6696
6792
  // 1.36.128 (실측, T-0104): `adapter <tool> [+.mcp.json]` 은 **그 도구가 그 파일을 읽는다**는 외부 제품에 대한 주장이다.
6697
6793
  // 이 머신에 설치된 CLI 로 직접 재 보니 5종 중 2종이 틀렸다:
6698
6794
  // codex — `.mcp.json` 을 둔 디렉터리에서 `codex mcp list` 가 대조군과 동일, `codex mcp get` 은 not found (거짓)
@@ -6831,7 +6927,10 @@ function _selfTestCases() {
6831
6927
  dropped.restore();
6832
6928
  const restored = process.env.LEERNESS_MCP_PROFILE === 'core' && process.env.LEERNESS_LANG === 'en';
6833
6929
  // 대조군 — 설정이 없으면 아무것도 보고하지 않는다(빈 컬렉션 공허참으로 통과하지 않게 위에서 실제 값을 넣었다)
6834
- delete process.env.LEERNESS_MCP_PROFILE; delete process.env.LEERNESS_LANG;
6930
+ // 1.36.129: 개만 명시적으로 지우고 있었는데, 통제 목록이 늘자(`CLAUDE_CODE_*`) `restore()` 가
6931
+ // 되돌린 나머지를 대조군이 다시 보고 깨졌다(doctor 안에서만 재현 — 그 경로는 진입점 통제를 안 거친다).
6932
+ // 목록이 커져도 견디도록 **전부 비운 뒤** 조용한지 본다.
6933
+ _controlSelftestEnv();
6835
6934
  const quiet = _controlSelftestEnv().length === 0;
6836
6935
  return gone && told && restored && quiet;
6837
6936
  } finally {
@@ -6854,7 +6953,12 @@ function _selfTestCases() {
6854
6953
  // ⚠ LEERNESS_OFFLINE / LEERNESS_INTERNAL 등 하네스가 **일부러 켜는** 것은 지우지 않는다(측정 계약의 일부).
6855
6954
  // ⚠ LANG 을 지워도 영어 커버리지는 죽지 않는다 — en 경로는 `_selftestFixture('en')`(init --language en)이
6856
6955
  // 따로 검증한다. 지우는 목적은 "언어의 출처를 픽스처 하나로 되돌리는" 것이지 en 을 안 보는 것이 아니다.
6857
- const _SELFTEST_CONTROLLED_ENV = ['LEERNESS_MCP_PROFILE', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_MCP_TIMEOUT_MS', 'LEERNESS_LANG'];
6956
+ const _SELFTEST_CONTROLLED_ENV = ['LEERNESS_MCP_PROFILE', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_MCP_TIMEOUT_MS', 'LEERNESS_LANG',
6957
+ // 1.36.129 (P-0016 P1): e2e/selftest 는 **살아있는 Claude Code 세션 안에서** 돈다 —
6958
+ // 통제하지 않으면 tmp 픽스처에 진짜 세션이 등록되고, 측정 결과가 '누가 돌렸는지' 에 종속된다.
6959
+ 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDECODE',
6960
+ 'CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_BY_PNPM', 'CODEX_MANAGED_BY_BUN', 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE',
6961
+ 'CURSOR_AGENT', 'LEERNESS_NO_SESSION_PRESENCE'];
6858
6962
  function _controlSelftestEnv() {
6859
6963
  const dropped = [], saved = {};
6860
6964
  for (const k of _SELFTEST_CONTROLLED_ENV) {
@@ -6895,7 +6999,10 @@ function _selfTestCmdInner(opts, droppedEnv) {
6895
6999
  // 무시한 설정은 반드시 보인다 — 사용자가 켜 둔 것이 안 먹었다는 사실을 숨기면 그 자체가 거짓 보고다(1.36.125).
6896
7000
  if (droppedEnv.length) {
6897
7001
  log(dm(` ⓘ 측정 통제: 검증 중에만 ${droppedEnv.length}개 환경설정을 무시합니다 — ${droppedEnv.join(', ')}`));
6898
- log(dm(` (이 설정들이 켜져 있으면 selftest "설치 손상" 거짓 보고했습니다 실제 제품 동작은 그대로입니다)`));
7002
+ // ⚠ 1.36.129: 여기에 다른 가드의 **실패 표지 문자열**을 그대로 적어 두었더니, 가드가 안내를
7003
+ // 실패로 읽었다(안내 문구가 곧 표면이다 — 같은 클래스를 이 저장소에서 세 번째로 만났다).
7004
+ // 뜻은 유지하고 표지 문자열만 피한다.
7005
+ log(dm(` (이 설정들이 켜져 있으면 selftest 가 정상 설치를 실패로 거짓 보고했습니다 — 실제 제품 동작은 그대로입니다)`));
6899
7006
  log('');
6900
7007
  }
6901
7008
  results.forEach(r => {
@@ -8216,7 +8323,7 @@ function _p0088CurrentStatePreserved() {
8216
8323
  // 그리고 readme/contract/self 같은 그룹 명령은 하위명령 없이 부르면 "알 수 없는 명령" 이라고 거짓으로 답했다.
8217
8324
  // 여기서 지키는 건 '전부 등재' 가 아니라 **핵심 사용자 명령이 최소 한 표면에는 보인다** 는 것이다 —
8218
8325
  // 내부 진입점(slash·usage·about 등)까지 강제하면 가드가 잡음이 되어 무시된다.
8219
- const _SURFACE_MUST_LIST = ['library', 'lens', 'preview', 'task', 'plan', 'gate', 'audit', 'handoff', 'health', 'tech', 'toggle', 'verify-claim'];
8326
+ const _SURFACE_MUST_LIST = ['library', 'lens', 'preview', 'task', 'plan', 'gate', 'audit', 'handoff', 'health', 'tech', 'toggle', 'verify-claim', 'sessions'];
8220
8327
  // 1.16.2 (외부클린룸 UR-0042): 유효 명령그룹을 하위명령 없이 부르면 'unknown command'(혼란) 대신 사용법 힌트.
8221
8328
  // 1.36.101: main() 안의 지역 변수였던 것을 모듈 스코프로 올렸다 — selftest 가 '두 맵의 키 집합이 같은가' 를
8222
8329
  // 데이터로 검사할 수 있어야 한다. 문자열 실행으로만 검사하면 --language en 이 프로젝트 밖에서 전환되지 않아
@@ -9325,6 +9432,7 @@ function commandsCmd(root) {
9325
9432
  { cmd: 'migrate [path] [--dry-run]', desc: '업데이트 마이그레이션', descEn: 'update migration' },
9326
9433
  { cmd: 'update [--check|--yes|--force]', desc: '자가 업데이트', descEn: 'self-update' },
9327
9434
  { cmd: 'wakeup-interval get|set|auto|history|record', desc: 'adaptive wakeup (1.9.210)', descEn: 'adaptive wakeup interval (1.9.210)' },
9435
+ { cmd: 'sessions [path] [--json]', desc: '이 프로젝트에 남은 **다른 AI 세션 기록** (1.36.129, P-0016 P1) — 기록만 보여 주고 프로세스 상태는 판정하지 않는다', descEn: 'session records left by other AI sessions in this project (1.36.129, P-0016 P1) — records only; process state is never judged' },
9328
9436
  { cmd: 'workspace-dir get|guide', desc: '워크스페이스 디렉토리 확인 — 이 빌드는 .harness 만 사용 (migrate-workspace-dir 는 1.36.126 부터 거부, T-0107)', descEn: 'show the workspace directory — this build uses .harness only (migrate-workspace-dir refuses since 1.36.126, T-0107)' },
9329
9437
  { cmd: 'parent detect|adopt [--select <kinds>] [--apply]', desc: '상위 leerness 부모 탐지 + 자산 게이트형 adopt (1.30.2~3)', descEn: 'detect a parent leerness above + gated asset adoption (1.30.2~3)' },
9330
9438
  { cmd: 'intent classify|expand|domains "<request>"', desc: '의도 파악 + scope (1.9.213)', descEn: 'infer intent + scope (1.9.213)' },
@@ -11416,6 +11524,240 @@ function intentCmd(root, sub, ...rest) {
11416
11524
  // 사용자 명시 (1.9.198 후속): "22:01에 라운드에 자동진입되지않았어"
11417
11525
  // 1.9.196 task-log mtime 기반 detector 는 task-log 가 자주 안 업데이트되면 false positive.
11418
11526
  // last-handoff.json 추가로 정확도 ↑ + R-0001 룰 (25min) 대비 actual gap 측정.
11527
+ // 1.36.129 (UR-0068 / P-0016 P1): 세션 프레즌스 — 같은 프로젝트를 만지는 다른 AI 세션을 **알아채게** 한다.
11528
+ // 판정 로직은 전부 lib/session-presence.js(순수)에 있고, 여기는 IO 심(락·쓰기·프루닝)만 담는다.
11529
+ //
11530
+ // 저장 위치가 `.harness/cache/sessions/` 인 이유(실측):
11531
+ // · `.gitignore` 의 `.harness/cache/` 는 **이미 존재**하고 `init` 의 라인 병합에도 들어 있어
11532
+ // **기존 설치본까지 전부 이미 무시 중**이다. `.harness/sessions/` 는 그 목록에 없어서, 그 경로를 쓰면
11533
+ // 업그레이드하지 않은 설치본이 세션 파일을 **커밋**하게 된다(머지 충돌 + 호스트/시각이 git 이력에 박힘).
11534
+ // · 프레즌스는 파생 휘발 상태이지 사용자 데이터가 아니다 — `cache/` 가 그 의미의 기존 자리다.
11535
+ //
11536
+ // 쓰기 지점은 **정확히 두 곳**(handoff · session close)이다. "모든 CLI 호출마다 갱신" 은 채택하지 않았다:
11537
+ // 실측 — `leerness selftest` 1회가 leerness 프로세스 **129개**를 띄우고 그중 68개가 cwd 로 귀속된다.
11538
+ // handoff 는 이미 `_recordLastHandoff` 로 쓰고 있어, 같은 게이트에 얹으면 **새 쓰기 표면이 0** 이다.
11539
+ const _SESSION_STORE_REL = ['.harness', 'cache', 'sessions'];
11540
+ const SESSION_FILE_RE = /^[A-Za-z0-9_-]{8,64}\.json$/;
11541
+ function _sessionStoreDir(root) { return path.join(absRoot(root), ..._SESSION_STORE_REL); }
11542
+ // ⚠ (검수 P1) 무염 SHA-256 은 되돌릴 수 없는 값이 아니다 — `DESKTOP-<이름>` 같은 저엔트로피 hostname 은
11543
+ // 후보를 같은 방식으로 해시해 **사전 대입**으로 맞출 수 있고, 값이 프로젝트마다 같아 교차 상관도 된다.
11544
+ // 프로젝트별 **랜덤 염**을 스토어에 두고 함께 해시한다 — 같은 프로젝트 안에서 '같은 머신인가' 비교는
11545
+ // 그대로 되고, 원 hostname 복원과 프로젝트 간 상관은 끊긴다. 염 파일은 레코드 정규식(`*.json`)에 안 걸린다.
11546
+ function _sessionHostSalt(root) {
11547
+ try {
11548
+ const dir = _sessionStoreDir(root);
11549
+ const sp = path.join(dir, '.host-salt');
11550
+ if (exists(sp)) { const v = String(read(sp) || '').trim(); if (/^[0-9a-f]{32}$/.test(v)) return v; }
11551
+ mkdirp(dir);
11552
+ const v = require('crypto').randomBytes(16).toString('hex');
11553
+ writeUtf8(sp, v + '\n');
11554
+ return v;
11555
+ } catch { return null; }
11556
+ }
11557
+ function _sessionHostId(root) {
11558
+ // hostname 을 그대로 적지 않는다 — 같은 머신인지 비교만 하면 되고, 어느 머신인지는 주장하지 않는다.
11559
+ try {
11560
+ const salt = _sessionHostSalt(root);
11561
+ if (!salt) return null; // 염을 못 만들면 **가짜 익명성** 대신 값을 안 적는다
11562
+ return require('crypto').createHash('sha256').update(salt + '|' + String(os.hostname())).digest('hex').slice(0, 12);
11563
+ } catch { return null; }
11564
+ }
11565
+ // ⚠ (검수 P1) 예전엔 읽기 실패를 빈 배열로 삼켰다 — 그러면 "기록이 없다" 와 "확인할 수 없다" 가
11566
+ // 같은 출력이 된다(조용한 0). 못 읽은 사실을 **함께 돌려준다**.
11567
+ function _readSessionEntries(root) {
11568
+ const dir = _sessionStoreDir(root);
11569
+ const entries = [];
11570
+ let names = [];
11571
+ let unreadable = 0, storeError = null;
11572
+ try { names = fs.readdirSync(dir); }
11573
+ catch (e) {
11574
+ if (e && e.code === 'ENOENT') return { entries, unreadable: 0, storeError: null }; // 아직 아무도 안 썼다 = 진짜 0
11575
+ return { entries, unreadable: 0, storeError: String((e && e.code) || 'EREAD') };
11576
+ }
11577
+ for (const n of names) {
11578
+ if (!SESSION_FILE_RE.test(n)) continue; // 염 파일·tmp·lock 잔재는 목록에서 제외
11579
+ try { const j = JSON.parse(read(path.join(dir, n))); if (j && j.sessionKey) entries.push({ name: n, record: j }); else unreadable++; }
11580
+ catch { unreadable++; } // 손상은 '없음' 이 아니라 '못 읽음' 이다
11581
+ }
11582
+ return { entries, unreadable, storeError };
11583
+ }
11584
+ function _readSessionRecords(root) { return _readSessionEntries(root).entries.map(e => e.record); }
11585
+ // phase: 'open' (handoff) | 'close' (session close)
11586
+ // 반환: { written, reason } — **조용한 무동작 금지**. 왜 안 썼는지는 항상 말할 수 있어야 한다.
11587
+ function _sessionPresenceRecord(root, phase) {
11588
+ const SP = require('../lib/session-presence');
11589
+ try {
11590
+ root = absRoot(root);
11591
+ const hasHarness = exists(path.join(root, '.harness'));
11592
+ const reason = SP.suppressionReason(process.env, hasHarness);
11593
+ if (reason) return { written: false, reason };
11594
+ const key = SP.deriveSessionKey(process.env);
11595
+ if (!SP.isValidKey(key)) return { written: false, reason: 'not-identifiable' };
11596
+ const dir = _sessionStoreDir(root);
11597
+ mkdirp(dir);
11598
+ const fp = path.join(dir, `${key}.json`);
11599
+ // 경로 가둠 — 키 정규식이 이미 막지만, 실제 해석 결과가 스토어 밖이면 쓰지 않는다(이중 방어).
11600
+ if (path.dirname(path.resolve(fp)) !== path.resolve(dir)) return { written: false, reason: 'path-escape' };
11601
+ const det = SP.detectOutermostAgent(process.env);
11602
+ const nowIso = new Date().toISOString();
11603
+ let res = { written: false, reason: 'lock' };
11604
+ _withLock(fp, () => {
11605
+ let prev = null;
11606
+ if (exists(fp)) {
11607
+ try { prev = JSON.parse(read(fp)); } catch {
11608
+ // 손상 레코드는 **지우고 새로 쓴다**. `writeUtf8` 의 손상 구조 경로는 stderr 경고를 내는데,
11609
+ // handoff 는 `--json` stderr 0바이트 계약 대상이라 그 경고가 계약을 깬다. 병합할 이전 값이 없는
11610
+ // 파생 상태이므로 대피시킬 원본도 없다.
11611
+ try { fs.unlinkSync(fp); } catch { /* 지우지 못해도 아래 쓰기가 덮는다 */ }
11612
+ }
11613
+ }
11614
+ const rec = {
11615
+ schemaVersion: 1,
11616
+ sessionKey: key,
11617
+ keySource: process.env.CLAUDE_CODE_HOST_SESSION_ID ? 'CLAUDE_CODE_HOST_SESSION_ID' : 'CLAUDE_CODE_SESSION_ID',
11618
+ outermostAgent: det.agent,
11619
+ agentEvidence: det.evidence, // 결론이 아니라 원증거 키 이름 — 규칙이 틀려도 원자료가 남는다
11620
+ hostId: _sessionHostId(root),
11621
+ platform: process.platform,
11622
+ leernessVersion: VERSION,
11623
+ openedAt: (prev && prev.openedAt) || nowIso,
11624
+ lastHandoffAt: phase === 'close' ? ((prev && prev.lastHandoffAt) || nowIso) : nowIso,
11625
+ handoffCount: (Number((prev && prev.handoffCount) || 0) || 0) + (phase === 'close' ? 0 : 1),
11626
+ closedAt: phase === 'close' ? nowIso : null,
11627
+ };
11628
+ writeUtf8(fp, JSON.stringify(rec, null, 2) + '\n');
11629
+ res = { written: true, reason: null };
11630
+ });
11631
+ // 프루닝은 락 밖(자기 파일 락과 무관한 남의 파일 삭제) — 상한이 없으면 무한히 자란다.
11632
+ try {
11633
+ // ⚠ (검수 P2) 프루너가 **삭제 대상의 락을 안 잡아서** 남이 방금 갱신한 레코드를 지울 수 있었다.
11634
+ // 파일별 락을 잡고, 락 안에서 **다시 읽어** 여전히 삭제 대상인지 확인한 뒤에만 지운다.
11635
+ const snap = _readSessionEntries(root);
11636
+ const doomed = SP.selectPrunable(snap.entries, { selfKey: key, nowMs: Date.now() });
11637
+ for (const name of doomed) {
11638
+ if (!SESSION_FILE_RE.test(name)) continue;
11639
+ const victim = path.join(dir, name);
11640
+ if (path.dirname(path.resolve(victim)) !== path.resolve(dir)) continue;
11641
+ if (path.resolve(victim) === path.resolve(fp)) continue; // 자기 파일은 절대 안 지운다
11642
+ _withLock(victim, () => {
11643
+ // 락 안 재확인 — 스냅샷 이후 갱신됐다면(활동이 최신이면) 지우지 않는다.
11644
+ let cur = null;
11645
+ try { cur = JSON.parse(read(victim)); } catch { cur = null; }
11646
+ const still = SP.selectPrunable([{ name, record: cur }].concat(
11647
+ snap.entries.filter(e => e.name !== name)), { selfKey: key, nowMs: Date.now() });
11648
+ if (!still.includes(name)) return; // 조건이 사라졌다 → 보존
11649
+ try { fs.unlinkSync(victim); } catch { /* 남아도 다음에 다시 시도 */ }
11650
+ });
11651
+ }
11652
+ } catch { /* 프루닝 실패가 기록을 실패시키면 안 된다 */ }
11653
+ return res;
11654
+ } catch (e) {
11655
+ // 레지스트리를 못 써도 **명령이 실패하면 안 되고, 성공했다고 말해서도 안 된다**.
11656
+ return { written: false, reason: 'unavailable:' + String((e && e.code) || (e && e.message) || e).slice(0, 40) };
11657
+ }
11658
+ }
11659
+ // 1.36.129 (UR-0068 / P-0016 P1): `leerness sessions` — 이 프로젝트에 남은 세션 **기록**을 보여 준다.
11660
+ // ⚠ 문구 원칙: 검증하지 않은 것을 주장하지 않는다. "활성/active" 라는 말을 쓰지 않고,
11661
+ // close 기록이 없다는 사실만 말한다. 그리고 **못 보는 것을 항상 함께 적는다** —
11662
+ // 0건이라는 결과가 '아무도 없다' 로 읽히면 그건 못 본 것을 없다고 단언하는 과대주장이다.
11663
+ function sessionsCmd(root) {
11664
+ root = absRoot(root);
11665
+ const SP = require('../lib/session-presence');
11666
+ const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
11667
+ const isTty = process.stdout && process.stdout.isTTY;
11668
+ const cy = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
11669
+ const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
11670
+ const selfKey = SP.deriveSessionKey(process.env);
11671
+ const selfHost = _sessionHostId(root);
11672
+ const _scan = _readSessionEntries(root);
11673
+ const recs = _scan.entries.map(e => e.record).sort((a, b) => (Date.parse(b.lastHandoffAt || b.openedAt || '') || 0) - (Date.parse(a.lastHandoffAt || a.openedAt || '') || 0));
11674
+ const suppressed = SP.suppressionReason(process.env, exists(path.join(root, '.harness')));
11675
+ // ⚠ (검수 P1) `selfRegistered` 가 "지금 환경이 등록 가능한가" 를 뜻하고 있었다 — 그건 **등록 여부가 아니다**.
11676
+ // 아직 handoff 안 한 새 프로젝트에서 true 가 나오고, 레코드가 있는데 opt-out 으로 읽으면 false 가 나왔다.
11677
+ // 실제 레코드 유무로 답하고, '왜 지금은 등록이 안 되는가' 는 **따로** 말한다.
11678
+ const selfRecordPresent = !!selfKey && recs.some(r => r.sessionKey === selfKey);
11679
+ // 못 읽은 것이 있으면 그 사실을 숨기지 않는다 — '없음' 과 '확인 불가' 는 다른 상태다.
11680
+ const registryState = _scan.storeError ? 'unreadable' : (_scan.unreadable > 0 ? 'partial' : 'ok');
11681
+ const rel = (iso) => {
11682
+ const ms = Date.parse(iso || '');
11683
+ if (!Number.isFinite(ms)) return null; // 파싱 불가/미래는 '정보 부재' 로 다룬다
11684
+ const d = Date.now() - ms;
11685
+ if (d < 0) return null;
11686
+ const m = Math.round(d / 60000);
11687
+ if (m < 60) return t(`${m}분 전`, `${m}m ago`);
11688
+ const h = Math.round(m / 60);
11689
+ if (h < 48) return t(`${h}시간 전`, `${h}h ago`);
11690
+ return t(`${Math.round(h / 24)}일 전`, `${Math.round(h / 24)}d ago`);
11691
+ };
11692
+ const rows = recs.map(r => ({
11693
+ sessionKey: r.sessionKey,
11694
+ self: r.sessionKey === selfKey,
11695
+ outermostAgent: r.outermostAgent || 'unknown',
11696
+ // ⚠ (검수 P2) `hostId` 가 없으면 '다른 머신' 이 아니라 **모르는 것**이다 — 3상태로 답한다.
11697
+ hostRelation: SP.hostRelation(r.hostId, selfHost),
11698
+ closeRecorded: !!r.closedAt,
11699
+ lastHandoffAt: r.lastHandoffAt || null,
11700
+ handoffCount: Number(r.handoffCount || 0) || 0,
11701
+ leernessVersion: r.leernessVersion || null,
11702
+ }));
11703
+ const blindSpots = [
11704
+ t('leerness handoff 를 돌리지 않은 에이전트 세션', 'agent sessions that never ran `leerness handoff`'),
11705
+ t('MCP 도구만 쓰는 클라이언트', 'clients that only use MCP tools'),
11706
+ t(`${VERSION} 이전 버전으로 시작된 세션`, `sessions started on a version older than ${VERSION}`),
11707
+ t('다른 머신·컨테이너의 세션 (기록은 보이되 상태는 확인 불가)', 'sessions on another machine/container (record visible, state unverifiable)'),
11708
+ t("'close 기록 없음' 은 그 세션이 지금 살아있다는 뜻이 아닙니다 — 프로세스 상태는 판정하지 않습니다",
11709
+ "'no close recorded' does NOT mean that session is alive — process state is never judged"),
11710
+ ];
11711
+ if (has('--json')) {
11712
+ log(JSON.stringify({
11713
+ ok: registryState !== 'unreadable', lang: _L, storePath: _SESSION_STORE_REL.join('/'),
11714
+ registryState, unreadableRecords: _scan.unreadable, storeError: _scan.storeError,
11715
+ selfRecordPresent, selfSuppressedReason: suppressed || null,
11716
+ total: rows.length, sessions: rows, blindSpots,
11717
+ }, null, 2));
11718
+ if (registryState === 'unreadable') process.exitCode = 1;
11719
+ return;
11720
+ }
11721
+ log(cy(t(`# leerness sessions (1.36.129) — 세션 기록`, `# leerness sessions (1.36.129) — session records`)));
11722
+ log(dim(` ${_SESSION_STORE_REL.join('/')}`));
11723
+ log('');
11724
+ if (suppressed) {
11725
+ log(selfRecordPresent
11726
+ ? t(` ⓘ 이 세션의 기록은 있지만 **지금 이 실행**은 갱신하지 않습니다 (사유: ${suppressed}).`,
11727
+ ` ⓘ A record for this session exists, but **this invocation** does not update it (reason: ${suppressed}).`)
11728
+ : t(` ⓘ 이 세션은 레지스트리에 등록되지 않습니다 (사유: ${suppressed}) — 다른 세션에게 보이지 않습니다.`,
11729
+ ` ⓘ This session is not registered (reason: ${suppressed}) — other sessions cannot see it.`));
11730
+ log('');
11731
+ }
11732
+ // '없음' 과 '확인 불가' 를 같은 문장으로 말하지 않는다(검수 P1).
11733
+ if (_scan.storeError) {
11734
+ log(t(` ⚠ 세션 저장소를 읽지 못했습니다 (${_scan.storeError}) — 기록이 없다는 뜻이 아닙니다.`,
11735
+ ` ⚠ could not read the session store (${_scan.storeError}) — this does NOT mean there are no records.`));
11736
+ } else if (_scan.unreadable > 0) {
11737
+ log(t(` ⚠ 읽지 못한 레코드 ${_scan.unreadable}건 — 아래 목록은 불완전할 수 있습니다.`,
11738
+ ` ⚠ ${_scan.unreadable} record(s) unreadable — the list below may be incomplete.`));
11739
+ }
11740
+ // ⚠ (검수 P1) 손상 레코드가 있는데 목록이 비면 예전엔 그대로 "기록 없음" 이라 했다 —
11741
+ // **못 읽은 것은 없는 것이 아니다**. 진짜로 아무것도 없을 때만 그렇게 말한다.
11742
+ if (!rows.length && !_scan.storeError && _scan.unreadable === 0) {
11743
+ log(t(' 기록 없음.', ' no records.'));
11744
+ } else if (rows.length) {
11745
+ for (const r of rows) {
11746
+ const who = r.self ? t('이 세션', 'this one') : t('기록 ', 'record ');
11747
+ const when = r.hostRelation === 'same'
11748
+ ? (r.closeRecorded ? t('close 기록됨', 'close recorded')
11749
+ : (rel(r.lastHandoffAt) ? t(`close 기록 없음 · handoff ${rel(r.lastHandoffAt)} (${r.handoffCount}회)`, `no close recorded · handoff ${rel(r.lastHandoffAt)} (${r.handoffCount}x)`)
11750
+ : t('close 기록 없음 · 시각 불명', 'no close recorded · time unknown')))
11751
+ : (r.hostRelation === 'different'
11752
+ ? t('다른 머신 — 확인 불가', 'another machine — unverifiable')
11753
+ : t('머신 판별 불가 — 확인 불가', 'machine unknown — unverifiable'));
11754
+ log(` ${who} ${String(r.sessionKey).slice(0, 8)}… ${String(r.outermostAgent).padEnd(13)} ${when}`);
11755
+ }
11756
+ }
11757
+ log('');
11758
+ log(dim(t(' 이 목록이 보지 못하는 것:', ' what this list cannot see:')));
11759
+ for (const b of blindSpots) log(dim(` · ${b}`));
11760
+ }
11419
11761
  function _lastHandoffPath(root) { return path.join(root, '.harness', 'last-handoff.json'); }
11420
11762
  function _recordLastHandoff(root) {
11421
11763
  try {
@@ -13673,7 +14015,7 @@ function handoff(root) {
13673
14015
  try { _priorHandoffGap = _getLastHandoffGap(root); } catch {}
13674
14016
  // 1.36.22: 읽기전용/hook 호출이 last-handoff stamp 를 찍으면 detector 3종(핸드오프 넛지·abnormal shutdown·R-0001 interval)이
13675
14017
  // "방금 handoff 했다"고 오판해 오염된다. --no-record / LEERNESS_HOOK=1 로 stamp 생략(기본 동작 불변).
13676
- if (!has('--no-record') && process.env.LEERNESS_HOOK !== '1') { try { _recordLastHandoff(root); } catch {} }
14018
+ if (!has('--no-record') && process.env.LEERNESS_HOOK !== '1') { try { _recordLastHandoff(root); } catch {} try { _sessionPresenceRecord(root, 'open'); } catch {} } // 1.36.129 (P-0016 P1): 프레즌스는 **기존 게이트에 얹는다** — 새 쓰기 표면 0
13677
14019
  // 1.9.203: auto-resume-plan 자동 로드 (사용자 명시 — 자동 모드 알람 트리거)
13678
14020
  let _autoResumePlan = null;
13679
14021
  try { _autoResumePlan = _loadAutoResumePlan(root); } catch {}
@@ -14058,6 +14400,16 @@ function handoff(root) {
14058
14400
  const _tgs = _tgl.loadToggles(root);
14059
14401
  const _offs = Object.keys(_tgs).filter(k => _tgs[k] === false);
14060
14402
  if (_offs.length) parts.push(t(`🎛 토글 OFF: ${_offs.join(',')}`, `🎛 toggles OFF: ${_offs.join(',')}`));
14403
+ // 1.36.129 (UR-0068 / P-0016 P1): 같은 프로젝트에 **다른 세션 기록**이 있으면 알린다.
14404
+ // 0이면 침묵한다 — 이 레지스트리는 handoff 를 안 부르는 에이전트·MCP 전용 클라이언트·
14405
+ // 구버전 세션·다른 머신을 구조적으로 못 본다. "0건" 은 '아무도 없다' 로 읽히므로 쓰지 않는다.
14406
+ // "활성" 이라는 말도 쓰지 않는다(프로세스 상태는 판정하지 않는다 — pid 라이브니스 실측 5.2% 오탐).
14407
+ try {
14408
+ const _SPh = require('../lib/session-presence');
14409
+ const _others = _SPh.countOtherSessions(_readSessionRecords(root), _SPh.deriveSessionKey(process.env), _sessionHostId(root));
14410
+ if (_others > 0) parts.push(t(`👥 다른 세션 기록 ${_others}건 (close 기록 없음) → leerness sessions`,
14411
+ `👥 ${_others} other session record(s), no close recorded → leerness sessions`));
14412
+ } catch { /* 레지스트리를 못 읽어도 handoff 는 그대로 진행한다 */ }
14061
14413
  } catch {}
14062
14414
  // 3) MCP 활동 누적
14063
14415
  try {
@@ -18447,7 +18799,8 @@ function llmBenchRecordCmd(root) {
18447
18799
 
18448
18800
  const _sessionClose = require('../lib/session-close');
18449
18801
  // 1.9.425 (UR-0025/UR-0125 큰 핸들러 모듈화 10번째): sessionClose → lib/session-close.js (DI 위임)
18450
- function sessionClose(root, opts = {}) { 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)
18802
+ function sessionClose(root, opts = {}) { try { _sessionPresenceRecord(root, 'close'); } catch {} // 1.36.129 (P-0016 P1): close '기록됨' '프로세스가 죽었다' 아니다
18803
+ 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)
18451
18804
 
18452
18805
  function readmeCmd(root) { syncReadme(absRoot(root)); }
18453
18806
  function consistencyCheck(root) {
@@ -27196,6 +27549,7 @@ async function main() {
27196
27549
  if (cmd === 'creds' && args[1] === 'refresh') return _guardStore(has('--json'), () => credsRefreshTimestampCmd(arg('--path', process.cwd()), args[2]));
27197
27550
  if (cmd === 'deploy' && args[1] === 'auto') return deployAutoCmd(arg('--path', process.cwd()), args[2]);
27198
27551
  // 1.9.149: observability lite + runs list/show
27552
+ if (cmd === 'sessions') return sessionsCmd(absRoot(_resolveRoot(args[1]))); // 1.36.129 (P-0016 P1)
27199
27553
  if (cmd === 'runs' && args[1] === 'list') return runsListCmd(absRoot(_resolveRoot(args[2]))); // 1.9.412 (UR-0100): positional path 지원
27200
27554
  if (cmd === 'runs' && args[1] === 'show') return runsShowCmd(arg('--path', process.cwd()), args[2]);
27201
27555
  // 1.9.85: leerness health — 종합 헬스 체크
@@ -0,0 +1,135 @@
1
+ 'use strict';
2
+ // 1.36.129 (UR-0068 / P-0016 P1): 같은 프로젝트를 동시에 만지는 AI 세션이 서로를 **알아채게** 한다.
3
+ // P1 은 '프레즌스 기록'만 한다 — 라이브니스 판정도, 차단도 하지 않는다. 그 경계는 실측에서 나왔다:
4
+ //
5
+ // ① PID 라이브니스는 **양방향으로 틀린다**(이 머신 실측): 5초에 짧은 node 120개를 띄우자 PID 재사용 7건,
6
+ // 종료가 확인된 58개 중 3개(5.2%)가 `process.kill(pid,0)` 에 ALIVE, `kill(0,0)`=ALIVE, `kill(4,0)`=EPERM.
7
+ // → pid 를 **기록조차 하지 않는다**. "활성/active" 라는 단어를 어떤 출력에도 쓰지 않는다.
8
+ // ② 신선도 창(N분 이내면 활성)도 두지 않는다. 데몬이 없어 하트비트는 사람이 handoff 를 부를 때만 뛴다
9
+ // (이 저장소 기준선: 4일에 10건). N 을 작게 잡으면 산 세션이 사라지고, 크게 잡으면 죽은 세션이 살아 보인다.
10
+ // ③ PPID/TTY/cwd 추론 금지 — Git Bash 에서 node 의 ppid 가 같은 세션 안에서도 매 명령 달랐다(51096/46108/49256).
11
+ // 에이전트가 준 세션 id 가 없으면 **등록하지 않고, 그렇다고 말한다**.
12
+ //
13
+ // 이 모듈은 순수 함수만 담는다(스폰·IO 없음) — 가드가 자기 소스를 읽지 않고 행위로 검증할 수 있게.
14
+
15
+ const SESSIONS_KEEP = 20; // 임의의 수 — 아래 주석 참조
16
+ const SESSIONS_CLOSED_TTL_MS = 7 * 24 * 3600 * 1000; // 임의의 수 — 아래 주석 참조
17
+ // ⚠ 두 값은 **임의**다. 측정으로 유도한 것은 "상한이 있어야 한다" 는 사실뿐이고, 그 근거는 실측이다 —
18
+ // 이 저장소의 `.harness/runs/` 는 프루닝이 없어 909개 파일까지 자랐다. 값 자체는 근거가 없으므로 그렇게 적는다.
19
+
20
+ const KEY_RE = /^[A-Za-z0-9_-]{8,64}$/;
21
+ const FILE_RE = /^[A-Za-z0-9_-]{8,64}\.json$/;
22
+ function isValidKey(s) { return typeof s === 'string' && KEY_RE.test(s); }
23
+ // env 값의 참/거짓 — CI 계열은 `"false"`/`"0"` 을 **명시적으로** 넘기는 환경이 흔하다.
24
+ function isTruthyEnv(v) {
25
+ const t = String(v == null ? '' : v).trim().toLowerCase();
26
+ return t !== '' && t !== '0' && t !== 'false' && t !== 'no' && t !== 'off';
27
+ }
28
+
29
+ // 증거로 **절대 쓰지 않는** 키 — 전부 실측된 오라벨 원인이다.
30
+ // CODEX_COMPANION_* / CLAUDE_PLUGIN_DATA : Claude Code **플러그인**이 심는다(이 세션에 실재하며 값이
31
+ // CLAUDE_CODE_SESSION_ID 와 같다). "CODEX_* 있으면 codex" 규칙은 즉발 오라벨이다.
32
+ // TERM_PROGRAM : Cursor 와 Windsurf 가 번들에서 `vscode` 를 **바이트 동일**하게 하드코딩한다 — 원리적으로 못 가른다.
33
+ // ANTHROPIC_BASE_URL : grok/kimi/glm 은 대개 base URL 만 바꾼 Claude Code 라 프로세스로는 claude 다.
34
+ // 게다가 엔드포인트를 `.harness` 에 적으면 시크릿 인접 값을 파일로 남기는 **새 유출 표면**이 생긴다.
35
+ const FORBIDDEN_SIGNALS = ['CODEX_COMPANION_SESSION_ID', 'CODEX_COMPANION_TRANSCRIPT_PATH',
36
+ 'CLAUDE_PLUGIN_DATA', 'TERM_PROGRAM', 'VSCODE_PID', 'VSCODE_IPC_HOOK', 'ELECTRON_RUN_AS_NODE',
37
+ 'ANTHROPIC_BASE_URL', 'AI_AGENT'];
38
+
39
+ // 세션 키: 에이전트가 **스스로 준 id** 만 쓴다. 자식 세션은 부모 키로 접는다(가짜 두 번째 세션 방지).
40
+ function deriveSessionKey(env) {
41
+ const e = env || {};
42
+ if (e.CLAUDE_CODE_CHILD_SESSION === '1') return null; // sub-agent 는 등록하지 않는다
43
+ const cand = e.CLAUDE_CODE_HOST_SESSION_ID || e.CLAUDE_CODE_SESSION_ID || null;
44
+ return isValidKey(cand) ? cand : null;
45
+ }
46
+
47
+ // 필드명이 `agent` 가 아니라 `outermostAgent` 인 이유(실측): codex 가 직접 띄운 pwsh 가 `CLAUDECODE=1` 을
48
+ // 보고했다 — codex 가 부모 env 를 통째로 물려주고, 그 codex 자신이 Claude 세션에서 시작됐기 때문이다.
49
+ // env 가 가리키는 것은 **체인의 최외곽**이다. 이름을 그렇게 붙이면 그 라벨은 참이 된다.
50
+ function detectOutermostAgent(env) {
51
+ const e = env || {};
52
+ if (e.CLAUDECODE === '1') return { agent: 'claude-code', evidence: ['CLAUDECODE'] };
53
+ if (!e.CLAUDECODE) {
54
+ for (const k of ['CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_BY_PNPM', 'CODEX_MANAGED_BY_BUN']) {
55
+ if (e[k] === '1') return { agent: 'codex-cli', evidence: [k] };
56
+ }
57
+ if (e.CODEX_INTERNAL_ORIGINATOR_OVERRIDE) return { agent: 'codex', evidence: ['CODEX_INTERNAL_ORIGINATOR_OVERRIDE'] };
58
+ }
59
+ // ⚠ (검수 P2) cursor 는 **런타임 관측이 없다** — 번들 소스에서 문자열만 봤다.
60
+ // "관측 후에만 승격" 이라 적어 놓고 라벨을 확정하면 그 원칙이 스스로 무너진다. 관측 전에는 unknown 이다.
61
+ // 증거 키는 남긴다 — 나중에 실제 관측이 생기면 그때 승격한다(원자료는 보존).
62
+ if (e.CURSOR_AGENT === '1') return { agent: 'unknown', evidence: ['CURSOR_AGENT'] };
63
+ // 증거가 없으면 unknown 이다. unknown 은 부끄러운 값이 아니라 정상 출력이다 —
64
+ // 실측상 7종 중 확실 식별은 1종(claude-code)뿐이고, 적극적으로 틀릴 수 있었던 것이 1종(codex)이다.
65
+ return { agent: 'unknown', evidence: [] };
66
+ }
67
+
68
+ // 억제 판정은 **한 곳**에서 한다. 호출부마다 조건을 복붙하면 새 호출부가 빠진다(1.36.108 이 고친 형태).
69
+ function suppressionReason(env, hasHarness) {
70
+ const e = env || {};
71
+ if (e.LEERNESS_INTERNAL === '1') return 'internal'; // leerness 가 자기를 스폰하는 20여 지점
72
+ if (e.LEERNESS_HOOK === '1') return 'hook';
73
+ if (e.CLAUDE_CODE_CHILD_SESSION === '1') return 'child-agent';
74
+ // ⚠ (검수 P2, 실측 재현) `e.CI` 만 보면 `CI="false"`·`CI="0"` 도 억제했다 —
75
+ // 명시적으로 '아니다' 라고 말한 정상 로컬 환경이 조용히 등록되지 않는다. **참인 값만** 본다.
76
+ if (isTruthyEnv(e.CI) || isTruthyEnv(e.GITHUB_ACTIONS)) return 'ci';
77
+ if (e.LEERNESS_NO_SESSION_PRESENCE === '1') return 'opt-out';
78
+ if (!hasHarness) return 'not-a-leerness-project';
79
+ if (!deriveSessionKey(e)) return 'not-identifiable';
80
+ return null;
81
+ }
82
+
83
+ // 프루닝: 입력은 `{ name, record }` 목록이고 **반환은 지울 파일명**이다.
84
+ // ⚠ (검수 P2, 실측 재현) 예전엔 레코드의 `sessionKey` 를 돌려줬는데, **파일명과 키가 어긋난 레코드**
85
+ // 하나(손상·위조)가 있으면 프루너가 **엉뚱한 파일**을 지웠다(fakefake.json 이 victimxx.json 을 지움).
86
+ // ⚠ 자기 파일은 절대 지우지 않는다(방금 쓴 것을 지우면 안 된다).
87
+ function selectPrunable(records, opts) {
88
+ const o = opts || {};
89
+ const keep = Number.isFinite(o.keep) ? o.keep : SESSIONS_KEEP;
90
+ const ttl = Number.isFinite(o.closedTtlMs) ? o.closedTtlMs : SESSIONS_CLOSED_TTL_MS;
91
+ const nowMs = Number.isFinite(o.nowMs) ? o.nowMs : 0;
92
+ const selfKey = o.selfKey || null;
93
+ const list = (records || []).filter(e => e && typeof e.name === 'string' && FILE_RE.test(e.name));
94
+ const keyOf = (e) => String(e.name).replace(/\.json$/, '');
95
+ const trusted = list.filter(e => e.record && e.record.sessionKey === keyOf(e));
96
+ const doomed = new Set();
97
+ // 이름과 키가 어긋난 것은 **그 파일만** 정리 후보로 본다(그 레코드의 말을 믿고 남을 지우지 않는다).
98
+ for (const e of list) { if (!trusted.includes(e) && keyOf(e) !== selfKey) doomed.add(e.name); }
99
+ for (const e of trusted) {
100
+ if (keyOf(e) === selfKey) continue;
101
+ const c = Date.parse((e.record && e.record.closedAt) || '');
102
+ if (Number.isFinite(c) && nowMs && (nowMs - c) > ttl) doomed.add(e.name);
103
+ }
104
+ const alive = trusted.filter(e => !doomed.has(e.name));
105
+ // ⚠ (검수 P2, 실측 재현) `openedAt` 만으로 줄 세우면 **오래 열려 있었지만 방금 handoff 한 세션**이
106
+ // 상한 밖으로 밀려 삭제됐다. 지키려는 것은 '최근에 살아 있던 기록' 이므로 최근 활동을 함께 본다.
107
+ const activity = (e) => Math.max(Date.parse((e.record && e.record.lastHandoffAt) || '') || 0,
108
+ Date.parse((e.record && e.record.openedAt) || '') || 0);
109
+ const ranked = alive.slice().sort((a, b) => activity(b) - activity(a));
110
+ for (const e of ranked.slice(keep)) { if (keyOf(e) !== selfKey) doomed.add(e.name); }
111
+ return [...doomed];
112
+ }
113
+
114
+ // 헤드라인은 **0이면 침묵**한다. "다른 세션 0건" 은 '아무도 없다' 로 읽히는데, 이 레지스트리는
115
+ // (a) handoff 를 안 부르는 에이전트 (b) 업그레이드 전에 시작된 세션 (c) 다른 머신 (d) MCP 전용 클라이언트를
116
+ // **구조적으로 못 본다**. 못 본 것을 없다고 단언하지 않는다.
117
+ // ⚠ hostId 를 모르면(둘 중 하나가 null) '같은 머신' 이라고도 '다른 머신' 이라고도 하지 않는다 — 세지 않는다.
118
+ function countOtherSessions(records, selfKey, selfHostId) {
119
+ return (records || []).filter(r => r && isValidKey(r.sessionKey)
120
+ && r.sessionKey !== selfKey
121
+ && r.hostId && selfHostId && r.hostId === selfHostId
122
+ && !r.closedAt).length;
123
+ }
124
+
125
+ // 같은 머신인지 **모를 수 있다** — 3상태로 답한다(검수 P2: null 을 '다른 머신' 으로 단정했다).
126
+ function hostRelation(recordHostId, selfHostId) {
127
+ if (!recordHostId || !selfHostId) return 'unknown';
128
+ return recordHostId === selfHostId ? 'same' : 'different';
129
+ }
130
+
131
+ module.exports = {
132
+ SESSIONS_KEEP, SESSIONS_CLOSED_TTL_MS, KEY_RE, FILE_RE, FORBIDDEN_SIGNALS,
133
+ isValidKey, isTruthyEnv, deriveSessionKey, detectOutermostAgent, suppressionReason,
134
+ selectPrunable, countOtherSessions, hostRelation,
135
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.128",
3
+ "version": "1.36.129",
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",
package/scripts/e2e.js CHANGED
@@ -4643,7 +4643,7 @@ total++;
4643
4643
  // 1.9.349 회귀 (외부리뷰 UR-0063, GPT5.5+Opus 교차검증): selftest/doctor 위치독립 — 비초기화 dir 에서도 통과(거짓 "설치 손상" 없음)
4644
4644
  total++;
4645
4645
  {
4646
- let ok = false;
4646
+ let ok = false; let _liDbg = null;
4647
4647
  try {
4648
4648
  const ni = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-noinit-')); // .harness 없는 비초기화 dir
4649
4649
  // 1.36.85: selftest 가 소스문자열 검사 → **행위 검사**로 바뀌며 4초대 → 약 13초가 됐다(CLI spawn 비용).
@@ -4658,9 +4658,15 @@ total++;
4658
4658
  // 이미 찍힌 "통과" 문자열은 남는다). 제한을 120s 로 올린 만큼 status 단언이 반드시 함께 가야 한다.
4659
4659
  const doctorOk = dr.status === 0 && !/문제 감지|설치 손상|재설치/.test(dout) && /설치 정상|통과/.test(dout);
4660
4660
  ok = selftestOk && doctorOk;
4661
+ // 1.36.129: 이 블록은 실패해도 **사유를 말하지 못했다**(전부 catch {} 로 삼킴) — 전체 부하에서만
4662
+ // 터졌을 때 타임아웃인지 판정 실패인지 구분할 수 없었다. 실측을 함께 찍는다.
4663
+ if (!ok) _liDbg = { sExit: sr.status, dExit: dr.status, sPass: /전체 \d+건 통과/.test(sout),
4664
+ sBroken: /설치 손상/.test(sout), dBad: /문제 감지|설치 손상|재설치/.test(dout), dOk: /설치 정상|통과/.test(dout),
4665
+ sTail: String(sout).trim().split(String.fromCharCode(10)).slice(-2).join(' | ').slice(0, 160),
4666
+ dTail: String(dout).trim().split(String.fromCharCode(10)).slice(-2).join(' | ').slice(0, 160) };
4661
4667
  fs.rmSync(ni, { recursive: true, force: true });
4662
- } catch {}
4663
- console.log(ok ? '✓ B(1.9.349) 외부리뷰 UR-0063: selftest/doctor 위치독립 — 비초기화 dir 통과 (UR-0063)' : '✗ selftest 위치독립 실패');
4668
+ } catch (e) { _liDbg = { err: String(e && e.message).slice(0, 160) }; }
4669
+ console.log(ok ? '✓ B(1.9.349) 외부리뷰 UR-0063: selftest/doctor 위치독립 — 비초기화 dir 통과 (UR-0063)' : '✗ selftest 위치독립 실패 ' + JSON.stringify(_liDbg));
4664
4670
  if (!ok) failed++;
4665
4671
  }
4666
4672
 
@@ -12788,6 +12794,216 @@ total++;
12788
12794
  if (!ok) failed++;
12789
12795
  }
12790
12796
 
12797
+ // 1.36.129 (UR-0068 / P-0016 P1): 세션 프레즌스 — CLI 행위로 잰다.
12798
+ // 설계 경계는 전부 실측에서 나왔다: PID 라이브니스는 양방향으로 틀리고(5초에 재사용 7건, 죽은 pid 5.2%가 ALIVE),
12799
+ // `leerness selftest` 1회가 leerness 프로세스 129개를 띄우므로 "모든 호출마다 등록" 은 불가능하다.
12800
+ // 그래서 쓰기 지점은 handoff · session close 둘뿐이고, 판정("활성")은 아예 하지 않는다.
12801
+ total++;
12802
+ {
12803
+ let ok = false; const bad = []; const dbg = {};
12804
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-sess129-'));
12805
+ // ⚠ 이 e2e 는 **살아있는 Claude Code 세션 안에서** 돈다 — 주변 env 를 비우지 않으면
12806
+ // 픽스처에 진짜 세션이 등록돼 측정이 '누가 돌렸는지' 에 종속된다.
12807
+ const envS = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
12808
+ for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDECODE',
12809
+ 'CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_BY_PNPM', 'CODEX_MANAGED_BY_BUN', 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE',
12810
+ 'CURSOR_AGENT', 'LEERNESS_NO_SESSION_PRESENCE', 'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'CI', 'GITHUB_ACTIONS',
12811
+ 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG', 'LEERNESS_MCP_PROFILE', 'LEERNESS_MCP_TIMEOUT_MS']) delete envS[k];
12812
+ // 캐너리 키는 **실행 시 생성**한다 — 소스에 적으면 이 파일이 러너로 읽히는 자기참조 함정(8회차)에 걸린다.
12813
+ const KEY = (tag) => `sess-${process.pid.toString(36)}-${tag}`;
12814
+ const STORE = ['.harness', 'cache', 'sessions'];
12815
+ const storeDir = (d) => path.join(d, ...STORE);
12816
+ const nRec = (d) => { try { return fs.readdirSync(storeDir(d)).filter(f => /\.json$/.test(f)).length; } catch { return 0; } };
12817
+ const RS = (d, args, extra) => cp.spawnSync(process.execPath, [CLI, ...args],
12818
+ { cwd: d, encoding: 'utf8', timeout: 600000, env: Object.assign({}, envS, extra || {}), maxBuffer: 32 * 1024 * 1024 });
12819
+ const mkS = (name) => {
12820
+ const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
12821
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"s","version":"0.1.0"}');
12822
+ const r = RS(d, [CLI ? 'init' : 'init', d, '--yes', '--minimal']);
12823
+ if (r.status !== 0) bad.push(`${name}:init실패(${r.status})`);
12824
+ return d;
12825
+ };
12826
+ let observedRecords = 0, cliCases = 0;
12827
+ try {
12828
+ // ① 반복 호출이 세션당 **1개**로 접히고, 하트비트가 무동작이 아니다
12829
+ const dA = mkS('a'); const kA = KEY('a');
12830
+ for (let i = 0; i < 3; i++) { RS(dA, ['handoff', dA], { CLAUDE_CODE_SESSION_ID: kA }); cliCases++; }
12831
+ if (nRec(dA) !== 1) bad.push(`①레코드 ${nRec(dA)}개(기대 1)`);
12832
+ let recA = null; try { recA = JSON.parse(fs.readFileSync(path.join(storeDir(dA), `${kA}.json`), 'utf8')); } catch { bad.push('①레코드 파싱 실패'); }
12833
+ if (recA) {
12834
+ observedRecords++;
12835
+ if (recA.handoffCount !== 3) bad.push(`①handoffCount=${recA.handoffCount}(기대 3 — 하트비트 무동작)`);
12836
+ if (recA.closedAt !== null) bad.push('①closedAt이 null이 아님');
12837
+ if ('pid' in recA) bad.push('①pid를 기록함(라이브니스 판정 금지 계약 위반)');
12838
+ }
12839
+ // ② 억제 — 내부/자식/opt-out 은 등록하지 않는다. **각각 격리 입력**(한 픽스처에 몰면 서로를 가린다)
12840
+ for (const [tag, extra] of [['int', { LEERNESS_INTERNAL: '1' }], ['kid', { CLAUDE_CODE_CHILD_SESSION: '1' }],
12841
+ ['off', { LEERNESS_NO_SESSION_PRESENCE: '1' }], ['ci', { CI: '1' }]]) {
12842
+ const d = mkS('s' + tag);
12843
+ RS(d, ['handoff', d], Object.assign({ CLAUDE_CODE_SESSION_ID: KEY(tag) }, extra)); cliCases++;
12844
+ if (nRec(d) !== 0) bad.push(`②${tag} 억제 실패(${nRec(d)}개)`);
12845
+ }
12846
+ // 대조군 — 억제 없이 같은 절차면 1개가 생긴다(위 0들이 "아무것도 안 쓰는 구현" 때문이 아님을 증명)
12847
+ const dCtl = mkS('ctl');
12848
+ RS(dCtl, ['handoff', dCtl], { CLAUDE_CODE_SESSION_ID: KEY('ctl') }); cliCases++;
12849
+ if (nRec(dCtl) !== 1) bad.push(`②대조군이 0개 — 계측 붕괴(${nRec(dCtl)})`);
12850
+ else observedRecords++;
12851
+ // 세션 id 가 없으면 등록하지 않는다(추론 금지)
12852
+ const dNo = mkS('noid'); RS(dNo, ['handoff', dNo]); cliCases++;
12853
+ if (nRec(dNo) !== 0) bad.push(`②세션id 없이 등록됨(${nRec(dNo)})`);
12854
+
12855
+ // ③ 헤드라인 — 0이면 침묵, 1이면 발화, 다른 머신은 침묵 (양성만 재면 "항상 출력"이 통과한다)
12856
+ const dH = mkS('h'); const kMe = KEY('me'), kOther = KEY('other');
12857
+ const h1 = String(RS(dH, ['handoff', dH], { CLAUDE_CODE_SESSION_ID: kMe }).stdout || ''); cliCases++;
12858
+ if (/다른 세션 기록|other session record/.test(h1)) bad.push('③자기 레코드만인데 헤드라인 발화');
12859
+ RS(dH, ['handoff', dH], { CLAUDE_CODE_SESSION_ID: kOther }); cliCases++;
12860
+ const h2 = String(RS(dH, ['handoff', dH], { CLAUDE_CODE_SESSION_ID: kMe }).stdout || ''); cliCases++;
12861
+ if (!/다른 세션 기록|other session record/.test(h2)) bad.push('③다른 레코드가 있는데 침묵');
12862
+ if (/활성|active session/.test(h2)) bad.push('③"활성"이라는 단어 사용(라이브니스 판정 금지)');
12863
+ observedRecords += nRec(dH);
12864
+ // 다른 머신 레코드만 → 침묵
12865
+ try {
12866
+ const op = path.join(storeDir(dH), `${kOther}.json`);
12867
+ const oj = JSON.parse(fs.readFileSync(op, 'utf8')); oj.hostId = 'ffffffffffff';
12868
+ fs.writeFileSync(op, JSON.stringify(oj, null, 2));
12869
+ } catch { bad.push('③다른머신 픽스처 준비 실패'); }
12870
+ const h3 = String(RS(dH, ['handoff', dH], { CLAUDE_CODE_SESSION_ID: kMe }).stdout || ''); cliCases++;
12871
+ if (/다른 세션 기록|other session record/.test(h3)) bad.push('③다른 머신 레코드에 발화');
12872
+
12873
+ // ④ 손상 레코드에서도 `--json` 계약 유지(stderr 0바이트) + **레코드가 실제로 갱신**됨
12874
+ const dJ = mkS('j'); const kJ = KEY('j');
12875
+ RS(dJ, ['handoff', dJ], { CLAUDE_CODE_SESSION_ID: kJ }); cliCases++;
12876
+ fs.writeFileSync(path.join(storeDir(dJ), `${kJ}.json`), '{{{broken');
12877
+ const rj = RS(dJ, ['handoff', dJ, '--json'], { CLAUDE_CODE_SESSION_ID: kJ }); cliCases++;
12878
+ if (rj.status !== 0) bad.push(`④손상 레코드에서 exit=${rj.status}`);
12879
+ if (String(rj.stderr || '').length !== 0) bad.push(`④stderr ${String(rj.stderr).length}B(계약 위반)`);
12880
+ try { JSON.parse(String(rj.stdout || '')); } catch { bad.push('④stdout이 순수 JSON 아님'); }
12881
+ try {
12882
+ const rec = JSON.parse(fs.readFileSync(path.join(storeDir(dJ), `${kJ}.json`), 'utf8'));
12883
+ if (!(Number(rec.handoffCount) >= 1)) bad.push('④손상 후 레코드가 갱신되지 않음(공허 통과)');
12884
+ observedRecords++;
12885
+ } catch { bad.push('④손상 후 레코드 복구 실패'); }
12886
+
12887
+ // ⑤ 경로 탈옥 — 탈출 키는 등록되지 않고 스토어 밖에 파일을 만들지 않는다
12888
+ const dE = mkS('esc');
12889
+ const before = fs.readdirSync(dE).length;
12890
+ for (const k of ['..', '../../x', 'a/b', 'C:\\x', 'short']) { RS(dE, ['handoff', dE], { CLAUDE_CODE_SESSION_ID: k }); cliCases++; }
12891
+ if (nRec(dE) !== 0) bad.push(`⑤탈출 키가 등록됨(${nRec(dE)})`);
12892
+ if (fs.readdirSync(dE).length !== before) bad.push('⑤프로젝트 루트에 파일이 생김');
12893
+
12894
+ // ⑥ sessions 명령 — 목록/JSON 계약 + **못 보는 것 고지** + "활성" 금지
12895
+ const rs = RS(dH, ['sessions', dH]); cliCases++;
12896
+ if (rs.status !== 0) bad.push(`⑥sessions exit=${rs.status}`);
12897
+ const so = String(rs.stdout || '');
12898
+ if (!/이 목록이 보지 못하는 것|what this list cannot see/.test(so)) bad.push('⑥한계 고지 없음');
12899
+ if (/활성|\bactive\b/.test(so)) bad.push('⑥"활성" 단어 사용');
12900
+ const rj2 = RS(dH, ['sessions', dH, '--json']); cliCases++;
12901
+ let sj = null; try { sj = JSON.parse(String(rj2.stdout || '')); } catch { bad.push('⑥sessions --json 파싱 실패'); }
12902
+ if (sj) {
12903
+ if (String(rj2.stderr || '').length !== 0) bad.push(`⑥sessions --json stderr ${String(rj2.stderr).length}B`);
12904
+ if (!Array.isArray(sj.blindSpots) || sj.blindSpots.length < 3) bad.push('⑥blindSpots 미노출');
12905
+ if (sj.storePath !== STORE.join('/')) bad.push(`⑥storePath=${sj.storePath}`);
12906
+ if (!(sj.total >= 1)) bad.push('⑥sessions 가 레코드를 못 봄');
12907
+ }
12908
+
12909
+ // ⑦ git 유출 없음 — 세션 파일은 무시되고, **다른 .harness 파일은 보인다**(git 이 실제로 관측 중임을 증명)
12910
+ const dG = mkS('git');
12911
+ const gi = cp.spawnSync('git', ['init', '-q'], { cwd: dG, encoding: 'utf8', timeout: 60000 });
12912
+ if (gi.status !== 0) bad.push('⑦git init 실패 — 이 검사는 스킵이 아니라 실패다(측정 붕괴를 통과로 세지 않는다)');
12913
+ else {
12914
+ RS(dG, ['handoff', dG], { CLAUDE_CODE_SESSION_ID: KEY('g') }); cliCases++;
12915
+ // ⚠ `--porcelain` 만 쓰면 untracked 디렉터리가 `?? .harness/` 한 줄로 **접혀서** 양쪽 판정이 모두
12916
+ // 공허해진다(내 대조군이 실제로 그걸 잡았다). `-uall` 로 파일 단위 열거를 강제한다.
12917
+ const st = String(cp.spawnSync('git', ['status', '--porcelain', '-uall'], { cwd: dG, encoding: 'utf8', timeout: 60000 }).stdout || '');
12918
+ if (/cache[\\/]sessions/.test(st)) bad.push('⑦세션 파일이 git 에 노출됨');
12919
+ if (!/last-handoff\.json/.test(st)) bad.push('⑦대조군 실패 — git 이 .harness 를 아예 안 보고 있음(위 판정이 공허)');
12920
+ observedRecords += nRec(dG);
12921
+ }
12922
+
12923
+ // ⑦ (검수 P3) `session close` 경로는 **한 번도 실행되지 않았다** — 배선을 지워도 통과했다.
12924
+ // close 기록과, 그 뒤 헤드라인에서 빠지는 것까지 함께 잰다.
12925
+ {
12926
+ const dC2 = mkS('close'); const kC = KEY('c1'), kO = KEY('c2');
12927
+ RS(dC2, ['handoff', dC2], { CLAUDE_CODE_SESSION_ID: kC }); cliCases++;
12928
+ RS(dC2, ['handoff', dC2], { CLAUDE_CODE_SESSION_ID: kO }); cliCases++;
12929
+ const beforeMark = /다른 세션 기록|other session record/.test(String(RS(dC2, ['handoff', dC2], { CLAUDE_CODE_SESSION_ID: kC }).stdout || '')); cliCases++;
12930
+ if (!beforeMark) bad.push('⑦close 전 헤드라인이 침묵(계측 붕괴)');
12931
+ const rc2 = RS(dC2, ['session', 'close', dC2], { CLAUDE_CODE_SESSION_ID: kO }); cliCases++;
12932
+ if (rc2.status !== 0) bad.push(`⑦session close exit=${rc2.status}`);
12933
+ let recC = null; try { recC = JSON.parse(fs.readFileSync(path.join(storeDir(dC2), `${kO}.json`), 'utf8')); } catch { bad.push('⑦close 레코드 파싱 실패'); }
12934
+ if (recC && !recC.closedAt) bad.push('⑦closedAt 미기록(배선 없음)');
12935
+ const afterMark = /다른 세션 기록|other session record/.test(String(RS(dC2, ['handoff', dC2], { CLAUDE_CODE_SESSION_ID: kC }).stdout || '')); cliCases++;
12936
+ if (afterMark) bad.push('⑦close 기록 후에도 헤드라인이 발화');
12937
+ observedRecords += nRec(dC2);
12938
+ dbg.close = { closedAt: !!(recC && recC.closedAt), before: beforeMark, after: afterMark };
12939
+ }
12940
+
12941
+ // ⑧ (검수 P1) `sessions --json` 이 **읽기 실패**를 "기록 없음" 으로 보고하지 않는다 + selfRecordPresent 는
12942
+ // '지금 등록 가능한가' 가 아니라 **실제 레코드 유무**를 말한다(양방향 대조군).
12943
+ {
12944
+ const dS = mkS('state'); const kS = KEY('s1');
12945
+ let j0 = null; try { j0 = JSON.parse(String(RS(dS, ['sessions', dS, '--json'], { CLAUDE_CODE_SESSION_ID: kS }).stdout || '')); } catch { bad.push('⑧빈상태 JSON 아님'); }
12946
+ cliCases++;
12947
+ if (j0 && (j0.selfRecordPresent !== false || j0.total !== 0 || j0.registryState !== 'ok')) bad.push(`⑧빈상태=${JSON.stringify({ p: j0.selfRecordPresent, t: j0.total, r: j0.registryState })}`);
12948
+ RS(dS, ['handoff', dS], { CLAUDE_CODE_SESSION_ID: kS }); cliCases++;
12949
+ let j1 = null; try { j1 = JSON.parse(String(RS(dS, ['sessions', dS, '--json'], { CLAUDE_CODE_SESSION_ID: kS }).stdout || '')); } catch { bad.push('⑧등록후 JSON 아님'); }
12950
+ cliCases++;
12951
+ if (j1 && j1.selfRecordPresent !== true) bad.push('⑧등록했는데 selfRecordPresent=false');
12952
+ if (j1 && j1.sessions[0] && j1.sessions[0].hostRelation !== 'same') bad.push(`⑧hostRelation=${j1.sessions[0].hostRelation}`);
12953
+ // opt-out 으로 읽어도 **레코드는 그대로 보인다**(억제는 '갱신 안 함' 이지 '없음' 이 아니다)
12954
+ let j2 = null; try { j2 = JSON.parse(String(RS(dS, ['sessions', dS, '--json'], { CLAUDE_CODE_SESSION_ID: kS, LEERNESS_NO_SESSION_PRESENCE: '1' }).stdout || '')); } catch { bad.push('⑧optout JSON 아님'); }
12955
+ cliCases++;
12956
+ if (j2 && (j2.selfRecordPresent !== true || j2.selfSuppressedReason !== 'opt-out')) bad.push(`⑧optout=${JSON.stringify({ p: j2.selfRecordPresent, r: j2.selfSuppressedReason })}`);
12957
+ // 손상 레코드 → '없음' 이 아니라 '부분'
12958
+ fs.writeFileSync(path.join(storeDir(dS), `${kS}.json`), '{{{broken');
12959
+ let j3 = null; try { j3 = JSON.parse(String(RS(dS, ['sessions', dS, '--json'], { CLAUDE_CODE_SESSION_ID: kS }).stdout || '')); } catch { bad.push('⑧손상 JSON 아님'); }
12960
+ cliCases++;
12961
+ if (j3 && (j3.registryState !== 'partial' || j3.unreadableRecords !== 1)) bad.push(`⑧손상=${JSON.stringify({ r: j3.registryState, u: j3.unreadableRecords })}`);
12962
+ // ⚠ 이 단언은 처음에 `/기록 없음/` 로 넓게 잡아 **한계 고지문의 `'close 기록 없음'`** 을 잡았다
12963
+ // (제품이 아니라 내 단언이 틀렸다). 빈 목록 문구는 마침표까지 붙은 독립 줄이므로 그것만 본다.
12964
+ const _brokenOut = String(RS(dS, ['sessions', dS], { CLAUDE_CODE_SESSION_ID: kS }).stdout || '');
12965
+ if (/^\s*기록 없음\.\s*$/m.test(_brokenOut)) bad.push('⑧손상인데 "기록 없음" 이라 말함');
12966
+ if (!/읽지 못한 레코드/.test(_brokenOut)) bad.push('⑧손상 사실을 사람용 출력에서 안 알림');
12967
+ cliCases++;
12968
+ dbg.state = { empty: j0 && j0.selfRecordPresent, after: j1 && j1.selfRecordPresent, partial: j3 && j3.registryState };
12969
+ }
12970
+
12971
+ // ⑨ (검수 P1) hostId 는 **무염 해시가 아니다** — 프로젝트별 염이 붙어 사전 대입/교차 상관이 끊긴다
12972
+ {
12973
+ const dH1 = mkS('salt1'), dH2 = mkS('salt2');
12974
+ const k1 = KEY('h1'), k2 = KEY('h2');
12975
+ RS(dH1, ['handoff', dH1], { CLAUDE_CODE_SESSION_ID: k1 }); cliCases++;
12976
+ RS(dH2, ['handoff', dH2], { CLAUDE_CODE_SESSION_ID: k2 }); cliCases++;
12977
+ let a = null, b = null;
12978
+ try { a = JSON.parse(fs.readFileSync(path.join(storeDir(dH1), `${k1}.json`), 'utf8')); } catch { bad.push('⑨salt1 레코드 없음'); }
12979
+ try { b = JSON.parse(fs.readFileSync(path.join(storeDir(dH2), `${k2}.json`), 'utf8')); } catch { bad.push('⑨salt2 레코드 없음'); }
12980
+ if (a && b) {
12981
+ observedRecords += 2;
12982
+ if (!a.hostId || !b.hostId) bad.push('⑨hostId 없음');
12983
+ else if (a.hostId === b.hostId) bad.push('⑨프로젝트가 달라도 hostId 동일(교차 상관 가능)');
12984
+ const naive = require('crypto').createHash('sha256').update(String(os.hostname())).digest('hex').slice(0, 12);
12985
+ if (a.hostId === naive) bad.push('⑨무염 해시 그대로(사전 대입 가능)');
12986
+ // 레코드에 원 hostname/cwd 가 없어야 한다
12987
+ const raw = JSON.stringify(a);
12988
+ if (raw.includes(os.hostname())) bad.push('⑨레코드에 hostname 원문');
12989
+ if (raw.includes(dH1)) bad.push('⑨레코드에 cwd 경로');
12990
+ if ('pid' in a) bad.push('⑨pid 기록됨');
12991
+ }
12992
+ dbg.salt = { differs: !!(a && b && a.hostId !== b.hostId) };
12993
+ }
12994
+
12995
+ // ⑩ 계측 생존 하한 — 픽스처가 조용히 죽으면 위 단언이 전부 공허참이 된다
12996
+ if (observedRecords < 8) bad.push(`⑩관측 레코드 ${observedRecords}건(<8) — 계측 붕괴`);
12997
+ if (cliCases < 30) bad.push(`⑩실행 CLI ${cliCases}건(<30) — 표본 축소`);
12998
+ dbg.observedRecords = observedRecords; dbg.cliCases = cliCases;
12999
+ ok = bad.length === 0;
13000
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
13001
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
13002
+ console.log(ok ? `✓ Y(1.36.129/P-0016 P1) 세션 프레즌스: 세션당 1레코드(하트비트 살아있음) · 억제 4종 각각 격리 대조군 · 헤드라인 0침묵/1발화/타머신침묵 · 손상 레코드에서도 --json stderr 0B + 실제 갱신 · 경로탈옥 0 · sessions 한계고지 · git 미노출(대조군 포함) · "활성" 미사용 ${JSON.stringify(dbg)}`
13003
+ : '✗ 1.36.129 세션 프레즌스 실패 ' + JSON.stringify({ bad: bad.slice(0, 10), dbg }));
13004
+ if (!ok) failed++;
13005
+ }
13006
+
12791
13007
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
12792
13008
  if (failed > 0) process.exit(1);
12793
13009