leerness 1.36.129 → 1.36.132

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.129 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.132 하네스를 사용합니다. 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.129는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.132는 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.129는 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.129 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.132 릴리스 태그 이력** (수백 라운드) · _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.129: 2026-08-18
238
+ Last synced by Leerness v1.36.132: 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.129';
37
+ const VERSION = '1.36.132';
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') 시 호스트 프로세스 오염.
@@ -170,8 +170,17 @@ function _sleepSyncMs(ms) {
170
170
  // O_EXCL(wx) 원자적 생성으로 상호배제. 다른 프로세스가 보유 중이면 짧게 대기 후 재시도.
171
171
  // stale(보유 프로세스 crash) 은 staleMs 초과 시 탈취. 타임아웃(maxWaitMs) 시 락 없이 진행(원자쓰기로 손상은 이미 방지, lost-update 만 rare 노출).
172
172
  const _heldLocks = new Set(); // 프로세스 내 재진입 추적 (중첩 _withLock 데드락 방지)
173
+ // 1.36.132: 락 파일을 "지금은 못 만든다" 로 읽어야 하는 오류들. Windows 에서는 상대가 unlink 하는 순간에
174
+ // 열면 EPERM/EACCES 가, 파일 핸들이 아직 살아 있으면 EBUSY 가 난다 — 전부 **재시도 대상**이지 포기 사유가 아니다.
175
+ const _LOCK_RETRY_CODES = new Set(['EEXIST', 'EPERM', 'EACCES', 'EBUSY']);
176
+ const _lockFailOpen = []; // 보호 없이 진행한 기록(관측용) — 조용한 fail-open 을 막는다
177
+ // 1.36.132 (검수 P1, 재현): 부모가 stderr 파이프를 먼저 닫으면 우리 경고 한 줄이 **비동기 EPIPE** 로 올라와
178
+ // 완전한 JSON 을 이미 출력한 성공 명령이 exit 1 이 됐다. try/catch 는 동기 예외만 잡으므로 소용이 없다.
179
+ // 경고를 못 전달하는 것은 감수한다 — 그 때문에 성공을 실패로 보고하는 것은 감수하지 않는다.
180
+ for (const _st of [process.stderr, process.stdout]) { try { _st.on('error', (e) => { if (!e || e.code !== 'EPIPE') return; }); } catch {} }
173
181
  function _withLock(targetPath, fn, opts = {}) {
174
182
  const lockPath = targetPath + '.lock';
183
+ let bailCode = null, lastCode = null;
175
184
  if (_heldLocks.has(lockPath)) return fn(); // 이미 이 프로세스가 보유 → 재진입(중첩 호출 안전)
176
185
  const maxWaitMs = opts.maxWaitMs || 10000; // 1.9.375 (UR-0084): 5s→10s — 고부하 경합 시 fail-open(락 없이 진행) 전 인내 상향, lost-update 창 축소
177
186
  const staleMs = opts.staleMs || 30000;
@@ -184,16 +193,48 @@ function _withLock(targetPath, fn, opts = {}) {
184
193
  while (Date.now() - start <= maxWaitMs) {
185
194
  try { const fd = fs.openSync(lockPath, 'wx'); fs.closeSync(fd); held = true; break; } // 원자적 배타 생성
186
195
  catch (e) {
187
- if (e.code !== 'EEXIST') break; // 예기치 못한 오류 없이 진행
196
+ // 1.36.132 (P1, 실측): 종전엔 `EEXIST` 아니면 즉시 **락 없이 진행**했다. 그런데 Windows 에서
197
+ // 상대가 락을 지우는 그 순간에 열면 `EPERM` 이 난다 — 즉 **경합이 심할수록** 보호가 꺼졌다.
198
+ // 격리 실측(N=32, 획득 루프만): 현행 = 무보호 진행 1 · 유실 1행(3.1%) · EPERM 크래시 1,
199
+ // 재시도 술어만 바꾼 대조군 = 무보호 0 · 유실 0 · 크래시 0 (대기시간 동일 ~1.0s).
200
+ // 제품 경로(16 프로세스 동시 `task add`)는 파일 연산이 많아 6.3% 유실 + exit 1 이 2회 연속 재현됐다.
201
+ // 이 코드들은 전부 "지금은 못 잡는다" 이지 "잡을 수 없다" 가 아니다 → 기다린다.
202
+ lastCode = e.code || null; // 1.36.132 (검수 P2): 10초를 기다린 뒤 'timeout' 이라고만 하면 영구 권한 오류를 경합으로 오진한다
203
+ if (!_LOCK_RETRY_CODES.has(e.code)) { bailCode = e.code || 'unknown'; break; }
188
204
  try { const st = fs.statSync(lockPath); if (Date.now() - st.mtimeMs > staleMs) { fs.unlinkSync(lockPath); continue; } } catch {}
189
205
  _sleepSyncMs(15 + Math.floor((Date.now() - start) / 50)); // 점증 backoff
190
206
  }
191
207
  }
192
208
  if (held) _heldLocks.add(lockPath);
209
+ else {
210
+ // 1.36.132: fail-open 은 **정책**이라 유지하되(명령이 멈추면 안 된다), 조용히 하지는 않는다.
211
+ // 종전엔 보호 없이 진행하면서 아무 말도 안 했다 — 유실이 나도 사용자에겐 성공으로만 보였다.
212
+ // stdout 은 `--json` 계약이 있으므로 건드리지 않는다. 경고는 stderr 로만 낸다.
213
+ const _why = bailCode || (lastCode && lastCode !== 'EEXIST' ? `${lastCode} 지속(${maxWaitMs}ms)` : 'timeout');
214
+ _lockFailOpen.push({ file: path.basename(targetPath), reason: _why, waitedMs: Date.now() - start });
215
+ if (process.env.LEERNESS_NO_LOCK_WARN !== '1') {
216
+ try {
217
+ process.stderr.write(`⚠ 락 획득 실패 — 보호 없이 진행합니다: ${path.basename(targetPath)} (${_why}). 동시 실행 중인 다른 세션이 있으면 이 쓰기가 유실될 수 있습니다.\n`);
218
+ } catch { /* stderr 가 닫혀 있어도 진행은 막지 않는다 */ }
219
+ }
220
+ }
193
221
  // 1.36.108 (T-0097, 자체 적대 검사에서 발견): fn 이 **프로미스를 돌려주면** 동기 finally 가 그 즉시 락을 푼다 —
194
222
  // 비동기 본체(네트워크 fetch 등)는 락 없이 도는데 호출부는 보호받는다고 믿는다. 가장 나쁜 조합이다
195
223
  // (`provider sync` 를 감싸면서 실제로 이 형태를 만들었다). thenable 이면 **정착 후에** 푼다.
196
- const release = () => { if (held) { _heldLocks.delete(lockPath); try { fs.unlinkSync(lockPath); } catch {} } };
224
+ // 1.36.132 (검수 P1): 해제 실패를 조용히 삼키면 **고아 락**이 남고 뒤따르는 쓰기들이 10초씩 기다렸다가
225
+ // 한꺼번에 무보호로 진행한다(stale 30초 탈취 전까지). 잠깐 다시 시도하고, 그래도 안 되면 말한다.
226
+ const release = () => {
227
+ if (!held) return;
228
+ _heldLocks.delete(lockPath);
229
+ for (let i = 0; i < 6; i++) {
230
+ try { fs.unlinkSync(lockPath); return; }
231
+ catch (e) { if (e.code === 'ENOENT') return; _sleepSyncMs(10 + i * 10); }
232
+ }
233
+ _lockFailOpen.push({ file: path.basename(targetPath), reason: 'release-failed', waitedMs: 0 });
234
+ if (process.env.LEERNESS_NO_LOCK_WARN !== '1') {
235
+ try { process.stderr.write(`⚠ 락 파일을 지우지 못했습니다: ${path.basename(lockPath)} — 다음 쓰기가 최대 30초 기다린 뒤 진행합니다.\n`); } catch {}
236
+ }
237
+ };
197
238
  let out;
198
239
  try { out = fn(); }
199
240
  catch (e) { release(); throw e; }
@@ -6958,7 +6999,9 @@ const _SELFTEST_CONTROLLED_ENV = ['LEERNESS_MCP_PROFILE', 'LEERNESS_WORKSPACE_DI
6958
6999
  // 통제하지 않으면 tmp 픽스처에 진짜 세션이 등록되고, 측정 결과가 '누가 돌렸는지' 에 종속된다.
6959
7000
  'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDECODE',
6960
7001
  'CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_BY_PNPM', 'CODEX_MANAGED_BY_BUN', 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE',
6961
- 'CURSOR_AGENT', 'LEERNESS_NO_SESSION_PRESENCE'];
7002
+ 'CURSOR_AGENT', 'LEERNESS_NO_SESSION_PRESENCE',
7003
+ // 1.36.132: 새 키 출처 2종도 통제한다 — 통제 목록이 키 출처를 따라가지 않으면 '누가 돌렸는지' 가 측정에 샌다.
7004
+ 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID'];
6962
7005
  function _controlSelftestEnv() {
6963
7006
  const dropped = [], saved = {};
6964
7007
  for (const k of _SELFTEST_CONTROLLED_ENV) {
@@ -11304,6 +11347,13 @@ function _autoFixIdempotency(root) {
11304
11347
  try {
11305
11348
  const pt = path.join(root, '.harness', 'progress-tracker.md');
11306
11349
  if (exists(pt)) {
11350
+ // 1.36.130 (P2-a, 실측 3/3 유실): 이 구획은 **락 없이** 읽고 다시 썼다 — 그 사이에 착지한
11351
+ // 동시 `task add` 의 행이 통째로 사라졌고 양쪽 모두 exit 0 이었다(조용한 유실).
11352
+ // 진입점은 둘: `idempotency audit --auto-fix` 와 `drift check --auto-fix`
11353
+ // (후자는 이 프로젝트 CLAUDE.md 가 **직접 지시하는 경로**다).
11354
+ // 정상 경로(`upsertProgress`)가 이미 하는 형태를 그대로 따른다 — **락 안에서 읽고, 머지하고, 쓴다**.
11355
+ // ⚠ 이 구획은 순수 파일 작업뿐이라 락 보유 중 자식 프로세스를 띄우지 않는다.
11356
+ _withLock(progressPath(root), () => {
11307
11357
  const header = progressHeader(root);
11308
11358
  const rows = readProgressRows(root);
11309
11359
  const seenExact = new Set();
@@ -11330,6 +11380,7 @@ function _autoFixIdempotency(root) {
11330
11380
  writeProgressRows(root, header, kept, { expectedShrink: removedExact }); // 1.36.79 (검수 #1): 제거한 만큼만 축소 허용 — 손상 행은 여전히 차단
11331
11381
  fixed.push({ kind: 'task-duplicate-request', action: 'deduped', removedExact, droppedSameText });
11332
11382
  }
11383
+ });
11333
11384
  }
11334
11385
  } catch (e) { fixed.push({ kind: 'task-duplicate-request', action: 'error', detail: String(e.message || e) }); }
11335
11386
  // 2) user-requests.json open 중복 — 동일 텍스트 중 최초만 open 유지, 나머지 dropped(보존)
@@ -11379,6 +11430,8 @@ function idempotencyCmd(root, sub) {
11379
11430
  const fixes = autoFix ? _autoFixIdempotency(root) : [];
11380
11431
  const audit = _runIdempotencyAudit(root); // auto-fix 후 상태 반영
11381
11432
  if (autoFix) audit.autoFixed = fixes;
11433
+ // 1.36.131 (검수 P1): --json 도 같은 계약 — auto-fix 가 실패했으면 성공 종료코드로 나가지 않는다.
11434
+ if (autoFix && fixes.some(f => f.action === 'error')) { audit.autoFixFailed = fixes.filter(f => f.action === 'error').length; process.exitCode = 1; }
11382
11435
  if (has('--json')) { log(JSON.stringify(audit, null, 2)); return; }
11383
11436
  log(cyan(`# leerness idempotency audit (1.9.212)`));
11384
11437
  log(` audited at: ${audit.auditedAt}`);
@@ -11407,16 +11460,25 @@ function idempotencyCmd(root, sub) {
11407
11460
  }
11408
11461
  if (autoFix) {
11409
11462
  log('');
11410
- if (fixes.length) {
11411
- log(grn(`## 🔧 auto-fix 적용 (${fixes.length})`));
11412
- fixes.forEach(f => {
11463
+ // 1.36.131 (검수 P1): 종전엔 실패 항목(`action:'error'`)도 "auto-fix 적용 (N)" 에 섞여 세어졌고 exit 0 이었다 —
11464
+ // 중복이 그대로 남았는데 자동화는 고쳐졌다고 판단했다(거짓 성공). 적용과 실패를 갈라 세고, 실패는 exit 1 로 만든다.
11465
+ const _applied = fixes.filter(f => f.action !== 'error');
11466
+ const _failedFix = fixes.filter(f => f.action === 'error');
11467
+ if (_applied.length) {
11468
+ log(grn(`## 🔧 auto-fix 적용 (${_applied.length})`));
11469
+ _applied.forEach(f => {
11413
11470
  if (f.kind === 'task-duplicate-request') log(` - [tasks] 완전중복 ${f.removedExact || 0}행 제거 · 동일텍스트 ${f.droppedSameText || 0}건 dropped 처리`);
11414
11471
  else if (f.kind === 'user-request-duplicate') log(` - [user-requests] open 중복 ${f.count || 0}건 dropped 처리`);
11415
11472
  else log(dim(` - [${f.kind}] ${f.action}${f.detail ? ' — ' + f.detail : ''}`));
11416
11473
  });
11417
- } else {
11474
+ } else if (!_failedFix.length) {
11418
11475
  log(dim(' 🔧 auto-fix: 적용할 중복 없음 (이미 정합)'));
11419
11476
  }
11477
+ if (_failedFix.length) {
11478
+ log(red(`## ❌ auto-fix 실패 (${_failedFix.length}) — 고쳐지지 않았다. 수동 확인 필요`));
11479
+ _failedFix.forEach(f => log(` - [${f.kind}] ${f.detail || '원인 미상'}`));
11480
+ process.exitCode = 1;
11481
+ }
11420
11482
  }
11421
11483
  return;
11422
11484
  }
@@ -11611,10 +11673,18 @@ function _sessionPresenceRecord(root, phase) {
11611
11673
  try { fs.unlinkSync(fp); } catch { /* 지우지 못해도 아래 쓰기가 덮는다 */ }
11612
11674
  }
11613
11675
  }
11676
+ const _prevAgent = prev && prev.outermostAgent;
11677
+ // 1.36.132 (검수 P2): 라벨 순서를 안 맞추면 `A<->B` 와 `B<->A` 가 번갈아 기록되며 **매 handoff 마다** 새 경고가 났다.
11678
+ // 같은 두 에이전트는 같은 표식이어야 한다 — 정렬해서 순서를 없앤다.
11679
+ const _sharedAgents = (_prevAgent && _prevAgent !== 'unknown' && det.agent !== 'unknown' && _prevAgent !== det.agent)
11680
+ ? [_prevAgent, det.agent].sort().join('<->') : ((prev && prev.sharedKeyAgents) || null);
11681
+ if (_sharedAgents && !(prev && prev.sharedKeyAgents === _sharedAgents) && process.env.LEERNESS_NO_LOCK_WARN !== '1') {
11682
+ try { process.stderr.write(`⚠ 세션 주소 공유 감지: ${key} 를 서로 다른 에이전트(${_sharedAgents})가 쓰고 있습니다 — 두 세션이 한 기록으로 합쳐져 서로를 못 봅니다. 세션마다 다른 LEERNESS_SESSION_ID 를 쓰세요.\n`); } catch {}
11683
+ }
11614
11684
  const rec = {
11615
11685
  schemaVersion: 1,
11616
11686
  sessionKey: key,
11617
- keySource: process.env.CLAUDE_CODE_HOST_SESSION_ID ? 'CLAUDE_CODE_HOST_SESSION_ID' : 'CLAUDE_CODE_SESSION_ID',
11687
+ keySource: SP.keySource(process.env), // 1.36.132: 3종(LEERNESS_SESSION_ID 우선) 손으로 적은 삼항은 새 출처를 놓친다
11618
11688
  outermostAgent: det.agent,
11619
11689
  agentEvidence: det.evidence, // 결론이 아니라 원증거 키 이름 — 규칙이 틀려도 원자료가 남는다
11620
11690
  hostId: _sessionHostId(root),
@@ -11624,6 +11694,10 @@ function _sessionPresenceRecord(root, phase) {
11624
11694
  lastHandoffAt: phase === 'close' ? ((prev && prev.lastHandoffAt) || nowIso) : nowIso,
11625
11695
  handoffCount: (Number((prev && prev.handoffCount) || 0) || 0) + (phase === 'close' ? 0 : 1),
11626
11696
  closedAt: phase === 'close' ? nowIso : null,
11697
+ // 1.36.132 (검수 실측): 같은 주소를 두 세션이 쓰면 레코드가 조용히 병합됐다(others=0·헤드라인 침묵·
11698
+ // 라벨이 마지막 기록자로 덮임). 라벨이 **둘 다 unknown 이 아니면서** 바뀌었다면 그건 공유의 증거다.
11699
+ // unknown 을 제외하는 이유: 셸마다 env 가시성이 달라 라벨이 흔들릴 때 오탐이 난다(실측).
11700
+ sharedKeyAgents: _sharedAgents,
11627
11701
  };
11628
11702
  writeUtf8(fp, JSON.stringify(rec, null, 2) + '\n');
11629
11703
  res = { written: true, reason: null };
@@ -11656,6 +11730,95 @@ function _sessionPresenceRecord(root, phase) {
11656
11730
  return { written: false, reason: 'unavailable:' + String((e && e.code) || (e && e.message) || e).slice(0, 40) };
11657
11731
  }
11658
11732
  }
11733
+ // 1.36.132 (UR-0069): 세션 신호를 **한 곳에서** 만든다. 종전엔 헤드라인 한 군데에만 인라인으로 있었고
11734
+ // `--json`·`--compact` 에는 없었다 — 표면마다 따로 짜면 새 표면이 빠진다(1.36.108 이 고친 형태와 같다).
11735
+ // 1.36.132 (검수 P1, 재현): 첫 판은 `exists()` 만 보고 `_sessionHostId` 를 불렀는데, 그 함수는 염이
11736
+ // **형식에 안 맞으면 새로 만들어 덮어쓴다** — 실측: 훅(쓰기 0 계약)이 `INVALID_SALT` 를 새 32자 랜덤으로 교체했다.
11737
+ // 존재 확인이 아니라 **직접 읽고 직접 검증**한다. 쓰는 함수는 부르지 않는다.
11738
+ // 염이 없거나 깨졌으면 hostId 를 모른다 → `hostRelation` 이 3상태라 'unknown' 으로 정직하게 답한다.
11739
+ function _sessionHostIdIfPresent(root) {
11740
+ try {
11741
+ const sp = path.join(_sessionStoreDir(root), '.host-salt');
11742
+ if (!exists(sp)) return null;
11743
+ const v = String(read(sp) || '').trim();
11744
+ if (!/^[0-9a-f]{32}$/.test(v)) return null;
11745
+ return require('crypto').createHash('sha256').update(v + '|' + String(os.hostname())).digest('hex').slice(0, 12);
11746
+ } catch { return null; }
11747
+ }
11748
+ function _sessionSignal(root, opts = {}) {
11749
+ const SP = require('../lib/session-presence');
11750
+ const snap = _readSessionEntries(root);
11751
+ const selfKey = SP.deriveSessionKey(process.env);
11752
+ // 1.36.132 (검수 P1, 재현): 종전엔 readOnly 가 아닐 때 `_sessionHostId` 를 불러 **염을 만들었다** —
11753
+ // 그래서 `handoff --json --no-record` 가 파일 3개를 만들었다(기록하지 말라고 했는데 만들었다).
11754
+ // **신호를 재는 일은 어떤 경우에도 쓰기가 아니다.** 염 생성은 실제로 기록하는 `_sessionPresenceRecord` 만 한다.
11755
+ const others = SP.countOtherSessions(snap.entries.map(e => e.record), selfKey, _sessionHostIdIfPresent(root));
11756
+ // 내 레코드가 공유 주소로 표시돼 있으면 그것도 신호다(플래그 생성기가 짧은 표면에도 싣는다).
11757
+ // 1.36.132 (검수 P1, 재현): `selfRegistered: !!selfKey` 는 **주소가 있다**를 **등록됐다**로 보고했다 —
11758
+ // `--no-record`/훅에서는 레코드가 0개인데 true 였다. 키 유무가 아니라 **내 파일이 실제로 있는지**로 답한다.
11759
+ let mine = null;
11760
+ try { mine = selfKey ? (snap.entries.find(e => e.record && e.record.sessionKey === selfKey) || null) : null; } catch {}
11761
+ const sharedKeyAgents = (mine && mine.record && mine.record.sharedKeyAgents) || null;
11762
+ return {
11763
+ others, // 다른 세션 **기록** 수(활성 여부는 판정하지 않는다)
11764
+ sharedKeyAgents,
11765
+ // 1.36.132 (검수 P1): 명시 주소가 형식에 안 맞으면 종전엔 조용히 다음 출처로 넘어갔다(오타를 안 알림).
11766
+ invalidExplicitKey: SP.invalidExplicitKey(process.env),
11767
+ // 1.36.132 (검수 P1): 저장소를 못 읽은 것을 `others:0` 으로 축약하지 않는다 — 이 저장소의 규율이다.
11768
+ storeError: snap.storeError || null,
11769
+ selfRegistered: !!mine,
11770
+ selfAddressed: !!selfKey, // 주소는 있다(등록됐다와 다르다)
11771
+ selfSuppressedReason: SP.suppressionReason(process.env, exists(path.join(root, '.harness'))) || (selfKey ? null : 'not-identifiable'),
11772
+ unreadableRecords: snap.unreadable || 0,
11773
+ // 못 보는 것을 함께 적는다 — "0건" 이 '아무도 없다' 로 읽히면 그건 과대주장이다.
11774
+ // 1.36.132 (검수 P3): codex 는 이제 주소가 있다 — 사람 표면과 기계 표면이 같은 사실을 말해야 한다.
11775
+ blindSpots: ['handoff 를 부르지 않는 세션', 'MCP 전용 클라이언트(LEERNESS_INTERNAL)',
11776
+ '주소가 없는 에이전트(cursor/vscode/grok 등 · codex 는 CODEX_THREAD_ID 가 없는 실행에서만, TUI 미검증)',
11777
+ '다른 머신', '구버전 설치본'],
11778
+ };
11779
+ }
11780
+ // 1.36.132 (검수): 짧은 표면(--compact·훅)에 실을 세션 플래그를 **한 곳에서** 만든다.
11781
+ // 실측 비대칭: 👥(남이 보인다)는 4표면 전부에 갔는데 🙈(나는 안 보인다)는 평문 1곳뿐이었다.
11782
+ // 내가 안 보인다는 것은 남이 나를 피해 갈 수 없다는 뜻이라, 오히려 짧은 표면일수록 필요한 신호다.
11783
+ function _sessionSignalFlags(ss) {
11784
+ const out = [];
11785
+ if (ss.others > 0) out.push(`👥 다른 세션 ${ss.others}`);
11786
+ if (!ss.selfRegistered && ss.selfSuppressedReason !== 'internal' && ss.selfSuppressedReason !== 'child-agent') {
11787
+ out.push(`🙈 나는 안 보임(${ss.selfSuppressedReason})`);
11788
+ }
11789
+ if (ss.sharedKeyAgents) out.push(`⚠ 주소 공유(${ss.sharedKeyAgents})`);
11790
+ if (ss.storeError) out.push(`⚠ 세션저장소 읽기실패(${ss.storeError})`);
11791
+ if (ss.invalidExplicitKey) out.push(`⚠ LEERNESS_SESSION_ID 형식 오류("${ss.invalidExplicitKey}") — 무시됨`);
11792
+ return out;
11793
+ }
11794
+ // 1.36.132 (UR-0069): `.harness/agent-reminders.md` 는 leerness 안의 유일한 "에이전트에게 보내는 메시지" 파일인데
11795
+ // **읽는 배선이 없었다** — 실측: handoff 200줄에 0건, `hook session-start` 319바이트에 0건, 현재 관리 문서 지시 0건
11796
+ // (아카이브 README 에만 남아 있다). 파일 자신이 "사용자가 이 파일을 보고 메인 에이전트에 전달 가능" 이라고 적어
11797
+ // 배달을 사람에게 떠넘긴다. drift critical 때 만들어 놓고 아무도 보여주지 않으면 그것은 기능이 아니라 침묵이다.
11798
+ function _agentReminderSignal(root) {
11799
+ const p = path.join(root, '.harness', 'agent-reminders.md');
11800
+ if (!exists(p)) return null;
11801
+ // 1.36.132 (검수 P2): 통째로 읽으면 커진 파일에서 복제·분할 비용이 그대로 터진다(64MiB 에서 2.16초 실측).
11802
+ // 헤드라인에 쓰는 것은 앞 몇 줄뿐이니 앞부분만 읽는다. 줄 수는 '읽은 범위 기준' 이라고 표시한다.
11803
+ const CAP = 64 * 1024;
11804
+ let body = '', truncated = false;
11805
+ try {
11806
+ const st = fs.statSync(p);
11807
+ if (st.size > CAP) {
11808
+ const fd = fs.openSync(p, 'r'); const buf = Buffer.alloc(CAP);
11809
+ const got = fs.readSync(fd, buf, 0, CAP, 0); fs.closeSync(fd);
11810
+ body = buf.slice(0, got).toString('utf8'); truncated = true;
11811
+ } else { body = read(p); }
11812
+ } catch { return null; }
11813
+ if (!body.trim()) return null;
11814
+ const lines = body.replace(/\r\n/g, '\n').split('\n').filter(l => l.trim() && !/^<!--/.test(l.trim()));
11815
+ // 1.36.132 (검수 P1, 재현): 이 내용은 **사람이 편집하는 파일**인데 그대로 헤드라인·JSON 에 실렸다 —
11816
+ // 실제 ANSI 이스케이프·고립 CR·가짜 `=== 구획 ===`·비밀값 표식이 생바이트로 나갔다.
11817
+ // 제어문자를 지우고(터미널·문서 경계 위조 차단) 토큰을 마스킹한 뒤에만 싣는다. 기존 술어를 재사용한다.
11818
+ const _clean = (s) => require('../lib/pure-utils').redactSecrets(
11819
+ String(s).replace(new RegExp('[\u0000-\u001f\u007f]', 'g'), ' ').replace(/^[=\-]{3,}/, ''), 200).trim();
11820
+ return { path: rel(root, p), lines: lines.length, truncated, head: lines.slice(0, 6).map(l => _clean(l).slice(0, 200)).filter(Boolean) };
11821
+ }
11659
11822
  // 1.36.129 (UR-0068 / P-0016 P1): `leerness sessions` — 이 프로젝트에 남은 세션 **기록**을 보여 준다.
11660
11823
  // ⚠ 문구 원칙: 검증하지 않은 것을 주장하지 않는다. "활성/active" 라는 말을 쓰지 않고,
11661
11824
  // close 기록이 없다는 사실만 말한다. 그리고 **못 보는 것을 항상 함께 적는다** —
@@ -11668,7 +11831,10 @@ function sessionsCmd(root) {
11668
11831
  const cy = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
11669
11832
  const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
11670
11833
  const selfKey = SP.deriveSessionKey(process.env);
11671
- const selfHost = _sessionHostId(root);
11834
+ // 1.36.132 (검수): **조회 명령이 남의 디렉토리에 파일을 만들었다** — 빈 디렉토리에 `sessions --json` 1회로
11835
+ // .harness/ · cache/ · sessions/ · .host-salt 4개 생성(exit 0). 읽기는 읽기여야 한다.
11836
+ // 염이 없으면 hostId 를 모른다 → hostRelation 이 이미 3상태라 'unknown' 으로 정직하게 답한다.
11837
+ const selfHost = _sessionHostIdIfPresent(root);
11672
11838
  const _scan = _readSessionEntries(root);
11673
11839
  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
11840
  const suppressed = SP.suppressionReason(process.env, exists(path.join(root, '.harness')));
@@ -11704,6 +11870,10 @@ function sessionsCmd(root) {
11704
11870
  t('leerness handoff 를 돌리지 않은 에이전트 세션', 'agent sessions that never ran `leerness handoff`'),
11705
11871
  t('MCP 도구만 쓰는 클라이언트', 'clients that only use MCP tools'),
11706
11872
  t(`${VERSION} 이전 버전으로 시작된 세션`, `sessions started on a version older than ${VERSION}`),
11873
+ // 1.36.132: 종전엔 '세션키가 없는 에이전트(codex/cursor 등)' 이라고 적었는데 codex 는 이제 주소가 있다.
11874
+ // 자기 도구의 능력을 과소·과대 주장하지 않는다 — 실측 범위를 그대로 적는다(codex exec 경로에서 확인).
11875
+ t('주소가 없는 에이전트 — cursor/vscode/grok 등. codex 는 CODEX_THREAD_ID 가 없는 실행에서만 (TUI 는 미검증)',
11876
+ 'agents without an address - cursor/vscode/grok etc. codex only when CODEX_THREAD_ID is absent (TUI unverified)'),
11707
11877
  t('다른 머신·컨테이너의 세션 (기록은 보이되 상태는 확인 불가)', 'sessions on another machine/container (record visible, state unverifiable)'),
11708
11878
  t("'close 기록 없음' 은 그 세션이 지금 살아있다는 뜻이 아닙니다 — 프로세스 상태는 판정하지 않습니다",
11709
11879
  "'no close recorded' does NOT mean that session is alive — process state is never judged"),
@@ -11727,6 +11897,12 @@ function sessionsCmd(root) {
11727
11897
  ` ⓘ A record for this session exists, but **this invocation** does not update it (reason: ${suppressed}).`)
11728
11898
  : t(` ⓘ 이 세션은 레지스트리에 등록되지 않습니다 (사유: ${suppressed}) — 다른 세션에게 보이지 않습니다.`,
11729
11899
  ` ⓘ This session is not registered (reason: ${suppressed}) — other sessions cannot see it.`));
11900
+ // 1.36.132 (UR-0069): 사유만 말하고 **처방을 안 주면** 사용자는 여기서 막힌다.
11901
+ // `not-identifiable` 은 곧 "이 에이전트는 세션 식별자를 환경에 안 남긴다" 이고, 그건 주소를 주면 풀린다.
11902
+ if (suppressed === 'not-identifiable') {
11903
+ log(t(` → 이 세션을 주소로 만들려면: 에이전트를 띄울 때 LEERNESS_SESSION_ID 를 **세션마다 다른 값**으로 설정하세요 (8~64자, 영숫자/_/-).`,
11904
+ ` → to give this session an address: set LEERNESS_SESSION_ID to a **unique value per session** when launching the agent (8-64 chars, alphanumeric/_/-).`));
11905
+ }
11730
11906
  log('');
11731
11907
  }
11732
11908
  // '없음' 과 '확인 불가' 를 같은 문장으로 말하지 않는다(검수 P1).
@@ -14257,6 +14433,12 @@ function handoff(root) {
14257
14433
  resumeGuide: ad.resumeGuide
14258
14434
  };
14259
14435
  } catch {}
14436
+ // 1.36.132 (UR-0069 / P-0016 P3 선결): 세션 신호가 **기계 경로에 아예 없었다**.
14437
+ // 실측: `handoff --json` 17키에 headline·sessions 필드 0 · `--compact` 는 200줄→3줄로 헤드라인을 통째로 죽인다.
14438
+ // MCP `leerness_handoff` 와 내부 호출자들이 바로 그 두 경로를 쓴다 → 다른 세션이 있어도 **구조적으로 못 본다**.
14439
+ // 채널(메일박스)을 새로 만들기 전에, 이미 있는 신호가 도달하게 하는 것이 먼저다.
14440
+ try { result.sessions = _sessionSignal(root); } catch { /* 레지스트리를 못 읽어도 handoff 는 진행 */ }
14441
+ try { const _rem = _agentReminderSignal(root); if (_rem) result.agentReminders = _rem; } catch {}
14260
14442
  log(JSON.stringify(result, null, 2));
14261
14443
  return;
14262
14444
  }
@@ -14281,6 +14463,10 @@ function handoff(root) {
14281
14463
  const flags = [];
14282
14464
  if (openReq) flags.push(`📥 미답 ${openReq}`);
14283
14465
  if (blocked) flags.push(`🚫 blocked ${blocked}`);
14466
+ // 1.36.132 (UR-0069): 다른 세션 기록과 대기 중인 에이전트 메시지는 **압축해도 지우면 안 되는 신호**다.
14467
+ // 이 경로가 MCP handoff·REPL·agent-mode 가 실제로 쓰는 경로이고, 종전엔 여기서 헤드라인이 통째로 사라졌다(200줄→3줄).
14468
+ try { const _ss = _sessionSignal(root); flags.push(..._sessionSignalFlags(_ss)); } catch {}
14469
+ try { const _rm = _agentReminderSignal(root); if (_rm) flags.push(`🔔 리마인더 ${_rm.lines}줄`); } catch {}
14284
14470
  log(_lineSafe(`leerness compact: ${detectProjectName(root)} · ${done}/${rows.length}(${pct}%) done · WIP ${wip} · T${wip}/D${decN}/R${rulesActive}/P${mileN}/L${lessN}${flags.length ? ' · ' + flags.join(' · ') : ''}`)); // 1.36.18 (codex F4): projectName 개행 주입 차단
14285
14471
  if (nx) log(_lineSafe(`다음: ${nx.id} [${nx.status}] ${nx.nextAction || nx.request || ''}`).slice(0, 200));
14286
14472
  log('핵심 규칙: 의존성0 · 한국어주석 · UTF-8noBOM · reuse-map등록 · anti-lazy-work · verify-claim자동검수');
@@ -14328,6 +14514,9 @@ function handoff(root) {
14328
14514
  : '';
14329
14515
  sections.push(`\n=== ${label}${note} (${rel(root,p)}) ===\n${ls.join('\n')}`);
14330
14516
  }
14517
+ // 1.36.132 (UR-0069): 리마인더를 **본문 맨 앞**에 둔다 — 이 파일은 drift critical 때 "다음 세션이 읽도록" 만들어졌는데
14518
+ // 1.9.38 이후 지금까지 어떤 표면도 내용을 보여주지 않았다(handoff 200줄 0건 · hook 319B 0건 · 관리문서 지시 0건).
14519
+ block('🔔 Agent Reminders (pending)', path.join(root, '.harness', 'agent-reminders.md'));
14331
14520
  block('Session Handoff', handoffPath(root));
14332
14521
  block('Current State', currentStatePath(root));
14333
14522
  block('Plan', planPath(root));
@@ -14404,12 +14593,34 @@ function handoff(root) {
14404
14593
  // 0이면 침묵한다 — 이 레지스트리는 handoff 를 안 부르는 에이전트·MCP 전용 클라이언트·
14405
14594
  // 구버전 세션·다른 머신을 구조적으로 못 본다. "0건" 은 '아무도 없다' 로 읽히므로 쓰지 않는다.
14406
14595
  // "활성" 이라는 말도 쓰지 않는다(프로세스 상태는 판정하지 않는다 — pid 라이브니스 실측 5.2% 오탐).
14596
+ // 1.36.132: 계산을 `_sessionSignal` 한 곳으로 옮겼다 — 표면(헤드라인·--json·--compact)마다 따로 짜면 새 표면이 빠진다.
14407
14597
  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`));
14598
+ const _ss = _sessionSignal(root);
14599
+ if (_ss.others > 0) parts.push(t(`👥 다른 세션 기록 ${_ss.others}건 (close 기록 없음) → leerness sessions`,
14600
+ `👥 ${_ss.others} other session record(s), no close recorded → leerness sessions`));
14601
+ // 1.36.132 (검수 P2): 공유 주소 경고가 **평문에만** 없었다(compact·훅·--json 에는 있었다).
14602
+ // stderr 경고는 처음 한 번뿐이라 평문에서 빠지면 그 뒤로 영영 안 보인다.
14603
+ if (_ss.sharedKeyAgents) parts.push(t(`⚠ 세션 주소 공유(${_ss.sharedKeyAgents}) — 두 세션이 한 기록으로 합쳐집니다`,
14604
+ `⚠ session address shared (${_ss.sharedKeyAgents}) - two sessions collapse into one record`));
14605
+ // 저장소를 못 읽었으면 "0건" 이라 말하지 않는다.
14606
+ if (_ss.storeError) parts.push(t(`⚠ 세션 저장소를 읽지 못함(${_ss.storeError}) — 기록이 없다는 뜻이 아닙니다`,
14607
+ `⚠ session store unreadable (${_ss.storeError}) - this does NOT mean there are none`));
14608
+ if (_ss.invalidExplicitKey) parts.push(t(`⚠ LEERNESS_SESSION_ID 가 형식에 안 맞아 무시됐습니다("${_ss.invalidExplicitKey}") — 8~64자 영숫자/_/-`,
14609
+ `⚠ LEERNESS_SESSION_ID ignored, invalid format ("${_ss.invalidExplicitKey}") - 8-64 chars, alphanumeric/_/-`));
14610
+ // 1.36.132 (검수): **비대칭이 조용했다** — 등록되지 않은 세션도 "남이 N건 있다" 는 듣지만
14611
+ // "나는 그들에게 보이지 않는다" 는 못 들었다. 사용자가 이 사실을 발견할 경로가 없었다.
14612
+ // 내가 안 보인다는 것은 남이 나를 피해 갈 수 없다는 뜻이라 겹침 사고의 직접 원인이다. 그러니 말한다.
14613
+ if (!_ss.selfRegistered && _ss.selfSuppressedReason !== 'internal' && _ss.selfSuppressedReason !== 'child-agent') {
14614
+ parts.push(t(`🙈 이 세션은 다른 세션에게 보이지 않습니다 (${_ss.selfSuppressedReason}) → LEERNESS_SESSION_ID 를 세션마다 고유값으로 설정`,
14615
+ `🙈 this session is invisible to others (${_ss.selfSuppressedReason}) → set a unique LEERNESS_SESSION_ID per session`));
14616
+ }
14412
14617
  } catch { /* 레지스트리를 못 읽어도 handoff 는 그대로 진행한다 */ }
14618
+ // 1.36.132 (UR-0069): 대기 중인 에이전트 리마인더를 **처음으로** 노출한다(그전엔 만들어만 놓고 아무도 안 보여줬다).
14619
+ try {
14620
+ const _rm = _agentReminderSignal(root);
14621
+ if (_rm) parts.push(t(`🔔 에이전트 리마인더 대기 ${_rm.lines}줄 → ${_rm.path}`,
14622
+ `🔔 ${_rm.lines} line(s) of pending agent reminders → ${_rm.path}`));
14623
+ } catch {}
14413
14624
  } catch {}
14414
14625
  // 3) MCP 활동 누적
14415
14626
  try {
@@ -15770,6 +15981,11 @@ function hookSessionStartCmd(root) {
15770
15981
  const flags = [];
15771
15982
  if (openReq) flags.push(`📥 미답 요청 ${openReq}`);
15772
15983
  if (blocked) flags.push(`🚫 blocked ${blocked}`);
15984
+ // 1.36.132 (UR-0069, 검수 fatal #2): 세션 신호가 4표면 중 1곳(평문 헤드라인)에만 닿았고, 못 가는 3곳에
15985
+ // **유일한 강제 경로인 이 훅**이 들어 있었다. 여기서 안 보이면 에이전트는 끝내 모른다.
15986
+ // ⚠ 이 경로는 **쓰기 0** 계약이라 readOnly 로 읽는다(염 파일을 새로 만들지 않는다).
15987
+ try { const _ss = _sessionSignal(root, { readOnly: true }); flags.push(..._sessionSignalFlags(_ss)); } catch {}
15988
+ try { const _rm = _agentReminderSignal(root); if (_rm) flags.push(`🔔 리마인더 ${_rm.lines}줄`); } catch {}
15773
15989
  const lines = [_lineSafe(`[leerness] ${detectProjectName(root)} · ${done}/${rows.length}(${pct}%) done · WIP ${wip}${flags.length ? ' · ' + flags.join(' · ') : ''}`)];
15774
15990
  if (nx) lines.push(_lineSafe(`다음: ${nx.id} [${nx.status}] ${nx.nextAction || nx.request || ''}`).slice(0, 200));
15775
15991
  // 자기 라벨링(1.9.272 투명성 불변식): 요약임을 밝히고 전체 컨텍스트 경로와 끄는 법을 함께 준다.
@@ -18799,7 +19015,9 @@ function llmBenchRecordCmd(root) {
18799
19015
 
18800
19016
  const _sessionClose = require('../lib/session-close');
18801
19017
  // 1.9.425 (UR-0025/UR-0125 큰 핸들러 모듈화 10번째): sessionClose → lib/session-close.js (DI 위임)
18802
- function sessionClose(root, opts = {}) { try { _sessionPresenceRecord(root, 'close'); } catch {} // 1.36.129 (P-0016 P1): close'기록됨' '프로세스가 죽었다' 아니다
19018
+ function sessionClose(root, opts = {}) { // 1.36.132 (검수): handoff--no-record/LEERNESS_HOOK 게이트 뒤에서 기록하는데 여기는 **게이트 없이** 기록했다
19019
+ // (실측: handoff --no-record → 0건, session close --no-record → 1건). 같은 스토어의 같은 계약은 같은 문장으로.
19020
+ if (!has('--no-record') && process.env.LEERNESS_HOOK !== '1') { try { _sessionPresenceRecord(root, 'close'); } catch {} } // 1.36.129 (P-0016 P1): close 는 '기록됨' 일 뿐 '프로세스가 죽었다' 가 아니다
18803
19021
  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)
18804
19022
 
18805
19023
  function readmeCmd(root) { syncReadme(absRoot(root)); }
@@ -20061,7 +20279,78 @@ function writeRules(root, rules) {
20061
20279
  const tail = damaged.length ? '\n<!-- ⚠ 아래 행은 손상돼 파싱 불가 — 삭제하지 않고 보존함. 수동 복구 후 위 표로 병합하세요 -->\n' + damaged.join('\n') + '\n' : '';
20062
20280
  // 1.36.63: 저장 시 헤더 언어 = 프로젝트 언어 (en rules.md 가 rule add 로 ko 회귀하지 않게)
20063
20281
  let _rlLang = 'ko'; try { _rlLang = read(path.join(root, '.harness', 'LANGUAGE')).trim().toLowerCase() === 'en' ? 'en' : 'ko'; } catch {}
20064
- writeUtf8(rulesPath(root), _rulesHeader(_rlLang) + '\n' + body + (body ? '\n' : '') + tail);
20282
+ // 1.36.130 (P2-c, 실측 재현): 함수는 헤더+행+손상tail **전체를 재구성**해 왔다 표가 아닌
20283
+ // 사용자 텍스트(머리말·꼬리 절·메모)는 존재하지 않는 것과 같았고, `session close` 마다 조용히 사라졌다.
20284
+ // 실측: rules.md 에 머리말/꼬리말을 넣고 `session close` → 931B → 807B, 두 문구 모두 grep 0건.
20285
+ // 이 파일의 목적은 **사용자가 명시한 영구 룰**을 담는 것이다 — 그 파일에서 사용자 글을 지우는 것은
20286
+ // 기능이 아니라 데이터 유실이다. 우리가 소유한 구역(헤더 + 표 + 손상tail) 밖은 그대로 되돌려 놓는다.
20287
+ const _managed = _rulesHeader(_rlLang) + '\n' + body + (body ? '\n' : '') + tail;
20288
+ writeUtf8(rulesPath(root), _rulesPreserveOuter(root, _managed, rules));
20289
+ }
20290
+ // 1.36.131 (검수 P1 ×3 · P2 · P3): 첫 판은 **줄을 하나씩 분류**해 "우리 것 같으면 버린다" 였다.
20291
+ // 그 형태는 분류가 틀릴 때마다 사용자 데이터가 사라지고, 실제로 네 갈래로 틀렸다(전부 재현함):
20292
+ // · `/^\|/` 가 **사용자 마크다운 표**까지 지웠다(`| 김 | 백엔드 |` → 0행).
20293
+ // · 파일 어디에 있든 첫 `---` 를 frontmatter 시작으로 봐서 **그 뒤 전부**를 삼켰다(닫는 `---` 가 없으면 EOF 까지).
20294
+ // · 앞에 있던 사용자 글이 **표 뒤로 재배치**됐다(주석은 "앞은 앞에" 라고 적혀 있었다 — 안내와 구현 불일치).
20295
+ // · 언어를 바꾸면 이전 언어 헤더가 "사용자 글" 로 오인돼 남았다.
20296
+ // → 분류를 없앤다. 관리 구역을 **위치로** 잡고(마커 → 표 → 손상tail), 그 **바깥은 손대지 않고 그대로** 되돌린다.
20297
+ // 판정할 줄이 없으면 틀릴 줄도 없다.
20298
+ function _rulesManagedSpan(lines) {
20299
+ const mark = lines.findIndex(l => /^<!--\s*leerness:managed\s*-->/.test(l.trim()));
20300
+ if (mark < 0) return null;
20301
+ // 시작 — 마커 **바로 앞**에 붙어 있는 frontmatter 블록까지가 우리 구역이다.
20302
+ // (파일 어딘가의 `---` 를 찾지 않는다. 마커에 인접한 것만 본다.)
20303
+ let s = mark;
20304
+ {
20305
+ let i = mark - 1;
20306
+ while (i >= 0 && !lines[i].trim()) i--;
20307
+ if (i >= 0 && lines[i].replace(/^/, '').trim() === '---') {
20308
+ let j = i - 1;
20309
+ while (j >= 0 && lines[j].replace(/^/, '').trim() !== '---') j--;
20310
+ if (j >= 0) s = j;
20311
+ }
20312
+ }
20313
+ // 끝 — 마커 뒤 첫 표 구분선에서 시작하는 **연속된 표 행**, 그리고 붙어 있는 손상행 보존 블록까지.
20314
+ // 연속이 끊기는 순간 우리 구역도 끝난다 → 그 아래 사용자 표는 구역 밖이라 그대로 산다.
20315
+ const sep = lines.findIndex((l, i) => i > mark && /^\|\s*:?-{3,}/.test(l.trim()));
20316
+ if (sep < 0) return null;
20317
+ let e = sep;
20318
+ while (e + 1 < lines.length && /^\|/.test(lines[e + 1].trim())) e++;
20319
+ let k = e + 1;
20320
+ while (k < lines.length && !lines[k].trim()) k++;
20321
+ if (k < lines.length && /^<!--\s*⚠/.test(lines[k].trim())) {
20322
+ e = k;
20323
+ while (e + 1 < lines.length && /^\| R-\d/.test(lines[e + 1].trim())) e++;
20324
+ }
20325
+ return { s, e };
20326
+ }
20327
+ function _rulesPreserveOuter(root, managed, rules) {
20328
+ let prev = '';
20329
+ try { prev = exists(rulesPath(root)) ? read(rulesPath(root)) : ''; } catch { prev = ''; }
20330
+ if (!prev.trim()) return managed;
20331
+ const lines = prev.replace(/\r\n/g, '\n').split('\n');
20332
+ const span = _rulesManagedSpan(lines);
20333
+ let head, tail;
20334
+ if (span) {
20335
+ head = lines.slice(0, span.s).join('\n');
20336
+ tail = lines.slice(span.e + 1).join('\n');
20337
+ } else {
20338
+ // 마커/표를 못 찾았다 = 우리가 쓴 파일이 아니거나 사람이 구조를 바꿨다.
20339
+ // 이때 **지우는 쪽으로 실패하지 않는다** — 전체를 머리말로 살리고 관리 블록을 아래에 놓는다.
20340
+ // 다만 지금 다시 찍을 룰 행과 표 골격만 빼서 같은 룰이 두 번 파싱되는 것을 막는다.
20341
+ const ids = new Set((rules || []).map(r => String(r.id)));
20342
+ head = lines.filter(l => {
20343
+ const t = l.trim();
20344
+ const m = t.match(/^\|\s*(R-\d{4,})\s*\|/);
20345
+ if (m && ids.has(m[1])) return false;
20346
+ if (/^\|\s*:?-{3,}/.test(t)) return false;
20347
+ if (/^\|\s*(ID|아이디)\s*\|/.test(t)) return false;
20348
+ return true;
20349
+ }).join('\n');
20350
+ tail = '';
20351
+ }
20352
+ const H = head.replace(/\n+$/, ''), T = tail.replace(/^\n+/, '').replace(/\n+$/, '');
20353
+ return (H ? H + '\n\n' : '') + managed.replace(/\n*$/, '\n') + (T ? '\n' + T + '\n' : '');
20065
20354
  }
20066
20355
 
20067
20356
  function nextRuleId(root) {
@@ -20094,7 +20383,9 @@ function ruleAdd(root, description) {
20094
20383
  root = absRoot(root);
20095
20384
  if (!_requireInit(root, 'rule add')) return; // 1.9.311 (UR-0047): init 가드
20096
20385
  if (!description) return fail('rule description required (e.g., rule add "매 업데이트마다 버전 bump" --trigger every-update)');
20097
- if (!exists(rulesPath(root))) writeRules(root, []);
20386
+ // 1.36.131 (검수 P1 동류): 부트스트랩도 락 안에서 한다 — exists 확인과 쓰기 사이에 동시 `rule add` 가
20387
+ // 착지하면 빈 표로 덮어써 그 룰이 사라졌다(같은 락이라 아래 블록과 재진입 안전).
20388
+ _withLock(rulesPath(root), () => { if (!exists(rulesPath(root))) writeRules(root, []); });
20098
20389
  const trigger = arg('--trigger', 'every-session');
20099
20390
  // 1.9.310 (UR-0046): 무효 trigger 거부 (이전엔 warn 후 등록 → 오타 룰 등록됨). --force 우회.
20100
20391
  if (!_validateChoice(trigger, RULE_TRIGGERS, 'rule trigger')) { process.exitCode = 1; return; }
@@ -20281,11 +20572,11 @@ function verifyRules(root) {
20281
20572
  results.push({ ...r, verified, note, clauses: clauses.length > 1 ? clauses.map(c => ({ kind: c.kind, verified: c.verified })) : undefined });
20282
20573
  }
20283
20574
  // lastVerified 갱신 (pass인 경우만)
20284
- for (const r of rules) {
20285
- const m = results.find(x => x.id === r.id);
20286
- if (m && m.verified === 'pass') r.lastVerified = todayStr;
20287
- }
20288
- writeRules(root, rules);
20575
+ // 1.36.131 (검수 P1): 종전엔 위에서 읽은 배열을 그대로 `writeRules` 로 되썼다 — **락 밖 RMW**라
20576
+ // 그 사이 착지한 동시 `rule add` 의 룰이 통째로 사라지고 양쪽 다 성공했다(`session close` 경로를 탄다).
20577
+ // 1.36.108 만들어 초크포인트로 태운다: 락 안에서 **다시 읽고** id 로 lastVerified 병합한다.
20578
+ const _passIds = new Set(results.filter(x => x.verified === 'pass').map(x => x.id));
20579
+ _updateRules(root, (cur2) => { for (const r of cur2) if (_passIds.has(r.id)) r.lastVerified = todayStr; });
20289
20580
  writeUtf8(rulesCachePath(root), JSON.stringify(cur, null, 2));
20290
20581
  return results;
20291
20582
  }
package/lib/drift.js CHANGED
@@ -243,6 +243,14 @@ function driftCheckCmd(root, opts = {}, deps = {}) {
243
243
  try {
244
244
  const idemFixes = _autoFixIdempotency(root);
245
245
  const totalFixed = idemFixes.reduce((n, f) => n + (f.removedExact || 0) + (f.droppedSameText || 0) + (f.count || 0), 0);
246
+ // 1.36.131 (검수 P1): 실패 항목은 수치 합이 0 이라 종전엔 **조용히 지나갔다** — 중복이 남았는데 아무 말도 안 했다.
247
+ // 수치와 무관하게 실패는 말한다(이 명령은 회복 경로라 exit 를 바꾸지 않는다 — 대신 반드시 보인다).
248
+ const idemFailed = idemFixes.filter(f => f.action === 'error');
249
+ if (idemFailed.length) {
250
+ afLog('');
251
+ afLog(t(`❌ idempotency auto-fix 실패 ${idemFailed.length}건 — 중복이 남아 있다: ${idemFailed.map(f => f.detail || f.kind).join(' · ')}`,
252
+ `❌ idempotency auto-fix failed ${idemFailed.length} — duplicates remain: ${idemFailed.map(f => f.detail || f.kind).join(' · ')}`));
253
+ }
246
254
  if (totalFixed > 0) {
247
255
  afLog('');
248
256
  afLog(t(`🔁 --auto-fix 활성 (1.9.293) — idempotency 중복 ${totalFixed}건 자동 정리 (task/user-request dedup)`, `🔁 --auto-fix on — deduped ${totalFixed} idempotency duplicate(s) (task/user-request)`));
package/lib/io.js CHANGED
@@ -147,12 +147,34 @@ function writeUtf8(p, s) {
147
147
  const tmp = `${p}.tmp-${process.pid}-${++_writeSeq}`;
148
148
  try {
149
149
  fs.writeFileSync(tmp, s, { encoding: 'utf8' });
150
- fs.renameSync(tmp, p);
150
+ _renameWithRetry(tmp, p);
151
151
  } catch (e) {
152
- try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch {}
152
+ // 1.36.132 (검수 P2): 잠긴 tmp 번의 unlink 로는 안 지워져 `*.tmp-<pid>-<seq>` 가 영구 잔류했다.
153
+ // 짧게 다시 시도한다(인덱서·백신이 새 파일을 잡는 실제 Windows 조건).
154
+ for (let i = 0; i < 5; i++) {
155
+ try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); break; } catch { _sleepMsSync(20 + i * 20); }
156
+ }
153
157
  throw e;
154
158
  }
155
159
  }
160
+ // 1.36.132 (P1, 실측): Windows 에서 **락을 제대로 잡고 있어도** 원자적 교체가 실패한다 —
161
+ // 읽기는 락을 잡지 않으므로, 다른 프로세스가 대상 파일을 연 채로 있으면 rename 이 EPERM/EACCES 를 낸다.
162
+ // 실측: 32 프로세스 동시 `task add` → EPERM 3건, 그 3행이 통째로 유실되고 자식은 exit 1 로 죽었다
163
+ // (락 획득 결함을 고친 뒤에도 남았다 — 원인이 다르다).
164
+ // 전부 **찰나의 상태**라 짧게 재시도하면 사라진다. 다른 코드(권한 없음 등)는 즉시 던진다.
165
+ const _RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
166
+ function _sleepMsSync(ms) { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* 최후엔 그냥 다시 시도 */ } }
167
+ function _renameWithRetry(from, to) {
168
+ let waited = 0;
169
+ for (let i = 0; ; i++) {
170
+ try { fs.renameSync(from, to); return; }
171
+ catch (e) {
172
+ if (!_RENAME_RETRY_CODES.has(e.code) || waited >= 2000) throw e;
173
+ const d = Math.min(5 + i * 10, 80);
174
+ _sleepMsSync(d); waited += d;
175
+ }
176
+ }
177
+ }
156
178
  function append(p, s) { mkdirp(path.dirname(p)); fs.appendFileSync(p, s, 'utf8'); }
157
179
  function rel(root, p) { return path.relative(root, p).replace(/\\/g, '/') || '.'; }
158
180
 
@@ -37,11 +37,36 @@ const FORBIDDEN_SIGNALS = ['CODEX_COMPANION_SESSION_ID', 'CODEX_COMPANION_TRANSC
37
37
  'ANTHROPIC_BASE_URL', 'AI_AGENT'];
38
38
 
39
39
  // 세션 키: 에이전트가 **스스로 준 id** 만 쓴다. 자식 세션은 부모 키로 접는다(가짜 두 번째 세션 방지).
40
- function deriveSessionKey(env) {
40
+ // 1.36.132 (UR-0069, 검수 fatal #1): 종전엔 키가 `CLAUDE_CODE_*` 에서만 나왔다 — 즉 **claude 세션만**
41
+ // 레지스트리에 등록되고 codex·cursor·vscode·grok·kimi·glm 은 남을 보기만 하는 **단방향 거울**이었다.
42
+ // 실측: codex 형태 env 로 `handoff` → 레코드 파일 증가 0, `selfRecordPresent=false`. 그런데 같은 실행이
43
+ // "다른 세션 기록 2건" 은 출력한다. 사용자가 든 예시("codex 가 claude 에게 작업범위 전달")가 구조적으로 불가능했다.
44
+ // → 에이전트 무관한 **명시 주소**를 받는다. 세션마다 다른 값을 주면 그 세션이 곧 주소가 된다.
45
+ // 명시값을 앞에 둔다(사용자가 일부러 준 것이 추론보다 우선). child 억제는 그대로 — sub-agent 등록은 실측으로 기각됐다.
46
+ // `CODEX_THREAD_ID` 는 **설정 없이** 이미 거기 있다 — codex-cli 0.147.0 실측: 소문자 UUIDv7 36자,
47
+ // 한 세션의 여러 자식 호출에서 바이트 동일, 세션이 바뀌면 값이 바뀐다(01a01570-54a6-… vs 01a01570-b228-…),
48
+ // 기존 KEY_RE 를 변환 없이 통과. 순서상 CLAUDE_* 뒤에 둔다 — claude 가 띄운 codex 는 CLAUDE 변수를 통째로
49
+ // 물려받으므로(실측) 부모 세션으로 접히는 지금 동작을 유지한다. 독립 실행 codex 만 자기 주소를 얻는다.
50
+ const KEY_SOURCES = ['LEERNESS_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID'];
51
+ function keySource(env) {
41
52
  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;
53
+ if (e.CLAUDE_CODE_CHILD_SESSION === '1') return null;
54
+ for (const k of KEY_SOURCES) if (isValidKey(e[k])) return k;
55
+ return null;
56
+ }
57
+ // 1.36.132 (검수 P1, 재현): NTFS 는 파일명을 대소문자 비구분으로 다루는데 키 비교는 구분했다 —
58
+ // `CaseKey01ABCD` 와 `casekey01abcd` 가 **다른 주소인 척하다가 같은 파일 하나로 조용히 병합**됐다(실측 2→1).
59
+ // 주소를 소문자로 접어 **하나의 규칙**으로 만든다: 같은 글자면 같은 주소다(그리고 파일명과 항상 일치한다).
60
+ function deriveSessionKey(env) {
61
+ const src = keySource(env);
62
+ return src ? String((env || {})[src]).toLowerCase() : null;
63
+ }
64
+ // 1.36.132 (검수 P1): 사용자가 **명시한** 주소가 형식에 안 맞으면 조용히 다음 출처로 넘어갔다 —
65
+ // 오타를 알리지 않고 설정을 뒤집는 것이다. 무엇이 왜 무시됐는지 말할 수 있게 사유를 돌려준다.
66
+ function invalidExplicitKey(env) {
67
+ const v = (env || {}).LEERNESS_SESSION_ID;
68
+ if (v === undefined || v === null || v === '') return null;
69
+ return isValidKey(v) ? null : String(v).slice(0, 40);
45
70
  }
46
71
 
47
72
  // 필드명이 `agent` 가 아니라 `outermostAgent` 인 이유(실측): codex 가 직접 띄운 pwsh 가 `CLAUDECODE=1` 을
@@ -129,6 +154,7 @@ function hostRelation(recordHostId, selfHostId) {
129
154
  }
130
155
 
131
156
  module.exports = {
157
+ KEY_SOURCES, keySource, invalidExplicitKey,
132
158
  SESSIONS_KEEP, SESSIONS_CLOSED_TTL_MS, KEY_RE, FILE_RE, FORBIDDEN_SIGNALS,
133
159
  isValidKey, isTruthyEnv, deriveSessionKey, detectOutermostAgent, suppressionReason,
134
160
  selectPrunable, countOtherSessions, hostRelation,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.129",
3
+ "version": "1.36.132",
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
@@ -3311,7 +3311,10 @@ total++;
3311
3311
  const walk = (d) => fs.readdirSync(d, { withFileTypes: true }).flatMap(e => e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)]);
3312
3312
  const tmpLeftover = walk(path.join(aDir, '.harness')).filter(f => /\.tmp-\d+-\d+$/.test(f));
3313
3313
  // (3) 소스에 renameSync 원자 패턴 존재
3314
- const srcOk = /fs\.renameSync\(tmp, p\)/.test(fs.readFileSync(path.resolve(__dirname, '..', 'lib', 'io.js'), 'utf8')); // 1.9.383: writeUtf8 lib/io.js
3314
+ // 1.36.132: 종전엔 `fs.renameSync(tmp, p)` 리터럴을 붙잡았다 rename 재시도를 헬퍼로 빼자 그 자리에서 깨졌다.
3315
+ // 지키려는 것은 **임시파일에 쓰고 rename 으로 교체**하는 형태지 호출 문자열이 아니다 — 의도로 단언한다.
3316
+ const _ioSrc = fs.readFileSync(path.resolve(__dirname, '..', 'lib', 'io.js'), 'utf8'); // 1.9.383: writeUtf8 → lib/io.js
3317
+ const srcOk = /writeFileSync\(tmp,/.test(_ioSrc) && /renameSync\(/.test(_ioSrc) && !/writeFileSync\(p,\s*s/.test(_ioSrc);
3315
3318
  ok = tasksOk && tmpLeftover.length === 0 && srcOk;
3316
3319
  fs.rmSync(aDir, { recursive: true, force: true });
3317
3320
  } catch {}
@@ -13004,6 +13007,452 @@ total++;
13004
13007
  if (!ok) failed++;
13005
13008
  }
13006
13009
 
13010
+ // 1.36.131 (UR-0068 / P-0016 P2, 검수 P1 ×4 반영): 다중 세션 쓰기 보호 — **측정된 유실**만 막는다.
13011
+ // ⚠ 원래 계획(해시 기준선 compare-and-set)은 정찰 실측으로 폐기했다: 이미 안전한 경로에서 100% 발화하고
13012
+ // 실제로 파괴하는 경로에서는 침묵했다. 신호가 위험과 무관하면 차단은 게이트 없느니만 못하다.
13013
+ // ⚠ 1.36.130 의 첫 판은 두 군데가 공허했다 — 둘 다 검수가 잡았고 여기서 고쳤다:
13014
+ // · 락 대기를 setInterval 로 관측했는데 메인스레드가 막혀 콜백이 아예 안 돌았다(관측값이 상수 false).
13015
+ // → 이제 **별도 프로세스**가 해제 시각을 파일로 남기고, 대상 파일 **mtime 과 비교**한다.
13016
+ // "잠깐 기다렸다 무락으로 쓰기" 도 mtime < 해제시각 이라 걸린다.
13017
+ // · 사용자 글 보존을 `includes` 로만 봐서 **표 뒤로 재배치**해도 통과했다 → 이제 **위치**를 단언한다.
13018
+ total++;
13019
+ {
13020
+ let ok = false; const bad = []; const dbg = {};
13021
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-p2-131-'));
13022
+ const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
13023
+ for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
13024
+ 'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'CI', 'GITHUB_ACTIONS', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG']) delete envP[k];
13025
+ const RP = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: d, encoding: 'utf8', timeout: 600000, env: envP, maxBuffer: 32 * 1024 * 1024 });
13026
+ const mkP = (name, full) => {
13027
+ const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
13028
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
13029
+ const r = RP(d, ['init', d, '--yes'].concat(full ? [] : ['--minimal']));
13030
+ if (r.status !== 0) bad.push(`${name}:init실패(${r.status})`);
13031
+ return d;
13032
+ };
13033
+ // 락 준수 프로브 — 이벤트 루프에 의존하지 않는다. 외부에서 락을 잡고, **별도 프로세스**가 2.5초 뒤
13034
+ // 놓으면서 해제 시각을 남긴다. 제품은 spawnSync 로 돌린 뒤 대상 파일 mtime 과 해제 시각을 비교한다.
13035
+ const lockProbe = (tag, d, target, argv) => {
13036
+ const lock = target + '.lock', relFile = path.join(d, `released-${tag}.json`);
13037
+ fs.writeFileSync(lock, String(process.pid));
13038
+ const rs = path.join(sb, `rel-${tag}.js`);
13039
+ fs.writeFileSync(rs, 'const fs=require("fs");setTimeout(()=>{try{fs.unlinkSync(' + JSON.stringify(lock) + ')}catch{};fs.writeFileSync(' + JSON.stringify(relFile) + ',String(Date.now()))},2500);');
13040
+ const rel = cp.spawn(process.execPath, [rs], { detached: true, stdio: 'ignore' }); rel.unref();
13041
+ const t0 = Date.now();
13042
+ const r = RP(d, argv);
13043
+ const elapsed = Date.now() - t0;
13044
+ let tr = null; try { tr = Number(fs.readFileSync(relFile, 'utf8')); } catch {}
13045
+ let tw = null; try { tw = fs.statSync(target).mtimeMs; } catch {}
13046
+ try { fs.unlinkSync(lock); } catch {}
13047
+ // 해제 파일이 없다 = 우리가 놓기도 전에 제품이 끝났다 = 락을 무시했다(무락 절제가 정확히 여기서 걸린다).
13048
+ if (!tr) bad.push(`${tag}:해제 전에 끝남(${elapsed}ms) — 락 무시`);
13049
+ else if (!(tw >= tr - 150)) bad.push(`${tag}:락 보유 중에 씀(해제 대비 ${Math.round(tw - tr)}ms)`);
13050
+ else if (elapsed < 2000) bad.push(`${tag}:기다리지 않음(${elapsed}ms)`);
13051
+ return { exit: r.status, elapsed, lagMs: (tr && tw) ? Math.round(tw - tr) : null };
13052
+ };
13053
+ try {
13054
+ // ① rules.md — 사람이 쓴 텍스트가 **원래 자리 그대로** 살아남는다. 사용자 표도 산다. 표는 계속 파싱된다.
13055
+ {
13056
+ const d = mkP('rules', true);
13057
+ const ra = RP(d, ['rule', 'add', '매 세션 점검', '--trigger', 'every-session', '--path', d, '--json']);
13058
+ if (ra.status !== 0) bad.push(`①rule add exit=${ra.status}`);
13059
+ const rp = path.join(d, '.harness', 'rules.md');
13060
+ const HEAD = 'HEADNOTE' + process.pid.toString(36), TAIL = 'TAILNOTE' + process.pid.toString(36);
13061
+ fs.writeFileSync(rp, `# ${HEAD}\n\n` + fs.readFileSync(rp, 'utf8')
13062
+ + `\n\n## ${TAIL}\n자유 서술 문단.\n\n| 이름 | 담당 |\n|---|---|\n| 김 | 백엔드 |\n`);
13063
+ const rc = RP(d, ['session', 'close', d]);
13064
+ if (rc.status !== 0) bad.push(`①session close exit=${rc.status}`);
13065
+ const L = fs.readFileSync(rp, 'utf8').split('\n');
13066
+ const at = (s) => L.findIndex(l => l.includes(s));
13067
+ const iH = at(HEAD), iT = at(TAIL), iTable = L.findIndex(l => /^\| R-\d/.test(l.trim())), iUser = at('| 김 | 백엔드 |');
13068
+ if (iH < 0) bad.push('①머리말 유실');
13069
+ if (iT < 0) bad.push('①꼬리말 유실');
13070
+ // 위치 단언 — `includes` 만 보면 "표 뒤로 재배치" 를 통과시킨다(1.36.130 의 실제 결함).
13071
+ if (!(iH >= 0 && iTable >= 0 && iH < iTable)) bad.push(`①머리말이 앞자리를 잃음(head=${iH} table=${iTable})`);
13072
+ if (!(iT >= 0 && iTable >= 0 && iT > iTable)) bad.push(`①꼬리말이 뒷자리를 잃음(tail=${iT} table=${iTable})`);
13073
+ if (iUser < 0) bad.push('①사용자 마크다운 표가 지워짐');
13074
+ let rl = null; try { rl = JSON.parse(String(RP(d, ['rule', 'list', '--path', d, '--json']).stdout || '')); } catch { bad.push('①rule list JSON 아님'); }
13075
+ const nRules = rl ? (Array.isArray(rl) ? rl.length : (rl.rules || []).length) : -1;
13076
+ if (nRules !== 1) bad.push(`①룰 행 ${nRules}건(기대 1) — 보존이 표를 깼다`);
13077
+ for (let i = 0; i < 3; i++) RP(d, ['session', 'close', d]);
13078
+ const fin = fs.readFileSync(rp, 'utf8');
13079
+ const cH = (fin.match(new RegExp(HEAD, 'g')) || []).length, cT = (fin.match(new RegExp(TAIL, 'g')) || []).length;
13080
+ if (cH !== 1 || cT !== 1) bad.push(`①멱등 아님(머리 ${cH} 꼬리 ${cT})`);
13081
+ dbg.rules = { head: iH, table: iTable, tail: iT, userTable: iUser, rules: nRules, idem: cH === 1 && cT === 1 };
13082
+ }
13083
+
13084
+ // ①b 선두 frontmatter 가 없고 본문 중간에 `---` 가 있는 파일 — 첫 판은 그 뒤 전부를 삼켰다.
13085
+ {
13086
+ const d = mkP('rules-nofm', true);
13087
+ const rp = path.join(d, '.harness', 'rules.md');
13088
+ fs.writeFileSync(rp, '# 메모\n앞 문단 HEADSENT\n---\n뒤 문단 TAILSENT\n');
13089
+ const ra = RP(d, ['rule', 'add', '첫 룰', '--trigger', 'every-session', '--path', d, '--json']);
13090
+ if (ra.status !== 0) bad.push(`①b rule add exit=${ra.status}(셋업 실패 — 아래 단언이 공허해진다)`);
13091
+ const t = fs.readFileSync(rp, 'utf8');
13092
+ if (!t.includes('HEADSENT')) bad.push('①b 앞 문단 유실');
13093
+ if (!t.includes('TAILSENT')) bad.push('①b 중간 `---` 뒤 문단 유실(frontmatter 오인)');
13094
+ let rl = null; try { rl = JSON.parse(String(RP(d, ['rule', 'list', '--path', d, '--json']).stdout || '')); } catch {}
13095
+ const n2 = rl ? (Array.isArray(rl) ? rl.length : (rl.rules || []).length) : -1;
13096
+ if (n2 !== 1) bad.push(`①b 룰 ${n2}건(기대 1)`);
13097
+ dbg.noFm = { head: t.includes('HEADSENT'), tail: t.includes('TAILSENT'), rules: n2 };
13098
+ }
13099
+
13100
+ // ② `--auto-fix` 의 progress RMW 가 **락을 존중**한다 + 그러면서 dedup 기능은 살아 있다.
13101
+ // 두 축을 다 재야 한다: 락만 재면 "아무것도 안 하는 구현"이 통과하고, 기능만 재면 무락이 통과한다.
13102
+ {
13103
+ const d = mkP('autofix', false);
13104
+ const pt = path.join(d, '.harness', 'progress-tracker.md');
13105
+ const RID = 'T-7' + String(process.pid % 1000).padStart(3, '0');
13106
+ const row = `| ${RID} | requested | 중복행 | - | - | 2026-08-18 |`;
13107
+ fs.writeFileSync(pt, fs.readFileSync(pt, 'utf8').replace(/\n?$/, '\n') + row + '\n' + row + '\n');
13108
+ if ((fs.readFileSync(pt, 'utf8').match(new RegExp(RID, 'g')) || []).length !== 2) bad.push('②픽스처에 중복행이 없음(계측 붕괴)');
13109
+ const p = lockProbe('②autofix', d, pt, ['idempotency', 'audit', '--auto-fix', '--path', d]);
13110
+ const nAfter = (fs.readFileSync(pt, 'utf8').match(new RegExp(RID, 'g')) || []).length;
13111
+ if (nAfter !== 1) bad.push(`②dedup 안 됨(중복행 ${nAfter}, 기대 1) — 락이 기능을 죽였다`);
13112
+ dbg.autofix = Object.assign(p, { dupAfter: nAfter });
13113
+ }
13114
+
13115
+ // ③ `session close` 의 rules 쓰기(verifyRules)도 **락 안**이다. 종전엔 무락 RMW 라 동시 `rule add` 를 삼켰다.
13116
+ // 쓰기가 실제로 일어나는 상태를 만들어야 한다 — writeUtf8 은 동일 내용이면 쓰지 않는다(1.36.65).
13117
+ {
13118
+ const d = mkP('verifyrules', true);
13119
+ const rp = path.join(d, '.harness', 'rules.md');
13120
+ const ra = RP(d, ['rule', 'add', '매 커밋마다 버전 bump', '--trigger', 'every-commit', '--path', d, '--json']);
13121
+ if (ra.status !== 0) bad.push(`③rule add exit=${ra.status}`);
13122
+ const rv = RP(d, ['rule', 'verify', '--path', d]);
13123
+ if (rv.status !== 0) bad.push(`③rule verify exit=${rv.status}`);
13124
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.2.0"}'); // → version 절 pass → lastVerified 갱신 → 실제 쓰기
13125
+ const p = lockProbe('③rules', d, rp, ['session', 'close', d]);
13126
+ if (!/\| R-\d/.test(fs.readFileSync(rp, 'utf8'))) bad.push('③룰 표가 사라짐');
13127
+ dbg.verifyRules = p;
13128
+ }
13129
+
13130
+ // ④ auto-fix 가 **실패했는데 성공으로 보고**하지 않는다(고칠 수 없는 손상행이 섞인 경우).
13131
+ {
13132
+ const d = mkP('falsesuccess', false);
13133
+ const pt = path.join(d, '.harness', 'progress-tracker.md');
13134
+ const RID = 'T-8' + String(process.pid % 1000).padStart(3, '0');
13135
+ const row = `| ${RID} | requested | 중복행 | - | - | 2026-08-18 |`;
13136
+ fs.writeFileSync(pt, fs.readFileSync(pt, 'utf8').replace(/\n?$/, '\n') + row + '\n' + row + '\n| T-9999 | requested | 셀부족 |\n');
13137
+ const r = RP(d, ['idempotency', 'audit', '--auto-fix', '--path', d]);
13138
+ const outTxt = String(r.stdout || '') + String(r.stderr || '');
13139
+ const stillDup = (fs.readFileSync(pt, 'utf8').match(new RegExp(RID, 'g')) || []).length;
13140
+ if (stillDup < 2) bad.push(`④전제 붕괴: 중복이 실제로 고쳐졌다(${stillDup}) — 이 케이스는 실패 경로가 아니다`);
13141
+ else {
13142
+ if (r.status === 0) bad.push('④고치지 못했는데 exit 0 (거짓 성공)');
13143
+ if (/auto-fix 적용/.test(outTxt)) bad.push('④고치지 못했는데 "auto-fix 적용" 이라고 보고');
13144
+ if (!/auto-fix 실패/.test(outTxt)) bad.push('④실패를 말하지 않음(조용한 무동작)');
13145
+ }
13146
+ const dr = RP(d, ['drift', 'check', '--auto-fix', '--path', d]);
13147
+ if (!/auto-fix 실패/.test(String(dr.stdout || '') + String(dr.stderr || ''))) bad.push('④drift --auto-fix 가 실패를 감춤');
13148
+ dbg.falseSuccess = { exit: r.status, stillDup, driftLoud: /auto-fix 실패/.test(String(dr.stdout || '')) };
13149
+ }
13150
+
13151
+ // ⑤ 대조군 — 평범한 단일 에이전트 흐름에서 아무것도 느려지거나 실패하지 않는다(오탐/회귀 0)
13152
+ {
13153
+ const d = mkP('plain', false);
13154
+ const t0 = Date.now();
13155
+ for (const a of [['task', 'add', '보통 작업', '--path', d, '--json'], ['handoff', d], ['session', 'close', d]]) {
13156
+ const r = RP(d, a);
13157
+ if (r.status !== 0) bad.push(`⑤평범한 흐름 실패: ${a[0]} exit=${r.status}`);
13158
+ }
13159
+ let tl = null; try { tl = JSON.parse(String(RP(d, ['task', 'list', '--path', d, '--json']).stdout || '')); } catch { bad.push('⑤task list JSON 아님'); }
13160
+ const n = tl ? (Array.isArray(tl) ? tl.length : (tl.tasks || tl.rows || []).length) : -1;
13161
+ if (!(n >= 1)) bad.push(`⑤작업이 사라짐(${n})`);
13162
+ dbg.plain = { ms: Date.now() - t0, tasks: n };
13163
+ }
13164
+ ok = bad.length === 0;
13165
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
13166
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
13167
+ console.log(ok ? `✓ Z(1.36.131/P-0016 P2) 쓰기 유실 차단: rules.md 사람 글이 **자리까지** 보존(사용자 표·중간 --- 포함, 멱등) · progress/rules RMW 가 락 해제 뒤에만 씀(mtime 대조) · auto-fix 실패를 성공으로 보고하지 않음 · 평범한 단일 흐름 무회귀 ${JSON.stringify(dbg)}`
13168
+ : '✗ 1.36.131 쓰기 유실 차단 실패 ' + JSON.stringify({ bad: bad.slice(0, 10), dbg }));
13169
+ if (!ok) failed++;
13170
+ }
13171
+ // 1.36.132 (UR-0069 / P-0016 P3 선결): **채널을 만들기 전에, 쓰기가 실제로 잠기고 신호가 실제로 도달하는지**부터.
13172
+ // 정찰·적대검수가 P-0017(메일박스)을 build-modified 로 판정했고 fatal 3건이 전부 이 블록의 대상이다:
13173
+ // ① 주소가 claude 에만 있었다 — codex/cursor/vscode/grok/kimi/glm 은 **남을 보기만 하는 단방향 거울**이었다.
13174
+ // ② 세션 신호가 4표면 중 1곳에만 닿았고, 못 가는 3곳에 유일한 강제 경로(훅)·MCP 경로·기계 경로가 들어 있었다.
13175
+ // ③ 억제된 세션은 "남이 N건 있다" 는 듣지만 "나는 안 보인다" 는 못 들었다(비대칭이 조용했다).
13176
+ // 그리고 그 밑에서 락 자체가 새고 있었다(아래 ①).
13177
+ total++;
13178
+ {
13179
+ let ok = false; const bad = []; const dbg = {};
13180
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-132-'));
13181
+ const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
13182
+ for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
13183
+ 'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CI', 'GITHUB_ACTIONS', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG',
13184
+ 'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_BY_PNPM', 'CODEX_MANAGED_BY_BUN', 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE',
13185
+ // 1.36.132 (검수 P1): CODEX_THREAD_ID 를 안 지우면 **codex 안에서 돌릴 때** 상속된 값이 '주소 없는 세션' 을 등록시켜
13186
+ // 같은 코드가 실행 주체에 따라 통과/실패한다. 키 출처가 늘면 통제 목록도 같이 늘어야 한다.
13187
+ 'CODEX_THREAD_ID']) delete envP[k]; // CLAUDECODE 를 남기면 최외곽이 늘 claude 로 잡혀 ③이 거짓이 된다
13188
+ const RP = (d, a, extra) => cp.spawnSync(process.execPath, [CLI, ...a],
13189
+ { cwd: d, encoding: 'utf8', timeout: 600000, env: Object.assign({}, envP, extra || {}), maxBuffer: 32 * 1024 * 1024 });
13190
+ const mkP = (name, full) => {
13191
+ const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
13192
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
13193
+ const r = RP(d, ['init', d, '--yes'].concat(full ? [] : ['--minimal']));
13194
+ if (r.status !== 0) bad.push(`${name}:init실패(${r.status})`);
13195
+ return d;
13196
+ };
13197
+ try {
13198
+ // ① 동시 쓰기에서 **한 행도 잃지 않는다**. 종전엔 잃었다 — 원인이 둘이고 둘 다 실측했다:
13199
+ // (a) 락 획득이 Windows 의 EPERM 을 "예기치 못한 오류" 로 보고 **보호 없이 진행**했다
13200
+ // (격리 N=64 통제표본 14쌍: 현행 4/14 실행에서 무보호 7건·유실 7행 · 재시도 술어만 고친 대조군 0/14).
13201
+ // (b) 락을 제대로 잡아도 `writeUtf8` 의 rename 이 **동시 독자** 때문에 EPERM 으로 죽었다
13202
+ // (제품 N=32 통제표본 8쌍: 절제 7/8 실행에서 유실 11행·크래시 11 · 수정본 0/8).
13203
+ {
13204
+ const d = mkP('concurrent', false);
13205
+ const N = 24;
13206
+ const t0 = Date.now();
13207
+ // ⚠ 자식 종료를 'close' 이벤트로 세면 안 된다 — 이 e2e 는 동기라 이벤트 루프가 돌지 않는다
13208
+ // (1.36.130 이 정확히 그 함정에 빠져 공허한 단언을 만들었다). **마커 파일**로 세고 폴링은 블로킹 sleep 으로 한다.
13209
+ const mk = path.join(sb, 'markers'); fs.mkdirSync(mk, { recursive: true });
13210
+ const runner = path.join(sb, 'runner.js');
13211
+ fs.writeFileSync(runner, [
13212
+ 'const cp=require("child_process"),fs=require("fs");',
13213
+ 'const a=process.argv.slice(2);',
13214
+ 'const r=cp.spawnSync(process.execPath,[a[0],"task","add","동시-"+a[2]+"-xxxxxxxxxxxxxxxx","--path",a[1],"--json"],{cwd:a[1],encoding:"utf8",timeout:300000});',
13215
+ 'fs.writeFileSync(a[3]+"/"+a[2]+".done",JSON.stringify({code:r.status,err:String(r.stderr||"").slice(0,150)}));',
13216
+ ].join('\n'));
13217
+ for (let i = 0; i < N; i++) {
13218
+ const c = cp.spawn(process.execPath, [runner, CLI, d, String(i), mk], { cwd: d, env: envP, stdio: 'ignore', detached: true });
13219
+ c.unref();
13220
+ }
13221
+ const sleep = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch {} };
13222
+ const deadline = Date.now() + 300000;
13223
+ while (fs.readdirSync(mk).length < N && Date.now() < deadline) sleep(100);
13224
+ const marks = fs.readdirSync(mk).map(f => { try { return JSON.parse(fs.readFileSync(path.join(mk, f), 'utf8')); } catch { return { code: -1 }; } });
13225
+ const codes = marks.map(m => m.code); const errs = marks.map(m => m.err).filter(Boolean);
13226
+ const txt = fs.readFileSync(path.join(d, '.harness', 'progress-tracker.md'), 'utf8');
13227
+ let present = 0; for (let i = 0; i < N; i++) if (txt.includes(`동시-${i}-`)) present++;
13228
+ const crashed = codes.filter(c => c !== 0).length;
13229
+ if (codes.length < N) bad.push(`①자식 ${codes.length}/${N} 만 종료(계측 붕괴)`);
13230
+ if (present !== N) bad.push(`①동시 쓰기 유실 ${N - present}행 (${present}/${N} 생존)`);
13231
+ if (crashed > 0) bad.push(`①자식 크래시 ${crashed}건 — rename/락 경합에서 죽었다: ${errs[0] || '?'}`);
13232
+ // 1.36.132 (검수 P1): 유실 0 만 보면 "보호 없이 진행했는데 운 좋게 안 겹친" 경우를 통과시킨다.
13233
+ // fail-open 은 그 자체로 실패다 — 제품이 stderr 로 말하게 해 놨으니 그것을 단언한다.
13234
+ const failOpen = errs.filter(e => /락 획득 실패/.test(e)).length;
13235
+ if (failOpen > 0) bad.push(`①락 fail-open ${failOpen}건 — 보호 없이 진행했다(유실이 없어도 실패다)`);
13236
+ dbg.concurrent = { N, present, crashed, failOpen: errs.filter(e => /락 획득 실패/.test(e)).length, ms: Date.now() - t0 };
13237
+ }
13238
+
13239
+ // ② 배달 — 세션 신호가 **4표면 전부**에 나온다. 종전엔 평문 헤드라인 1곳뿐이었고,
13240
+ // 못 가는 3곳이 하필 (a) 유일한 강제 경로인 훅 (b) MCP 가 쓰는 --compact (c) 유일한 기계 경로 --json 이었다.
13241
+ {
13242
+ const d = mkP('delivery', true);
13243
+ // 다른 세션 기록을 실제 제품으로 만든다(픽스처 손조립 금지 — hostId 를 손으로 적으면 계측이 거짓이 된다).
13244
+ const ro = RP(d, ['handoff', d], { LEERNESS_SESSION_ID: 'peersession00001' });
13245
+ if (ro.status !== 0) bad.push(`②피어 handoff exit=${ro.status}`);
13246
+ fs.writeFileSync(path.join(d, '.harness', 'agent-reminders.md'), '# 리마인더\n- DELIVERTEST 이 줄이 보여야 한다\n');
13247
+ const me = { LEERNESS_SESSION_ID: 'mysession00000001' };
13248
+ const plain = RP(d, ['handoff', d], me);
13249
+ const compact = RP(d, ['handoff', d, '--compact'], me);
13250
+ const jsonR = RP(d, ['handoff', d, '--json'], me);
13251
+ // 1.36.132 (검수 P1): 최상위 엔트리만 세면 `.harness/cache/sessions/.host-salt` 생성을 놓친다 —
13252
+ // 실제로 그 회귀가 있었고 이 검사가 공허하게 통과했다. **재귀 전수 목록**으로 비교한다.
13253
+ const walk = (dir) => { const out = []; for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
13254
+ const fp = path.join(dir, e.name); if (e.isDirectory()) out.push(...walk(fp)); else out.push(fp); } return out.sort(); };
13255
+ const before = walk(path.join(d, '.harness'));
13256
+ const hook = RP(d, ['hook', 'session-start'], me);
13257
+ const after = walk(path.join(d, '.harness'));
13258
+ const P = String(plain.stdout || ''), C = String(compact.stdout || ''), H = String(hook.stdout || '');
13259
+ let J = null; try { J = JSON.parse(String(jsonR.stdout || '')); } catch { bad.push('②--json 이 JSON 이 아님'); }
13260
+ if (!/👥 다른 세션 기록 1건/.test(P)) bad.push('②평문 헤드라인에 다른 세션 없음');
13261
+ if (!/👥 다른 세션 1/.test(C)) bad.push('②--compact 에 다른 세션 없음(MCP 경로가 못 본다)');
13262
+ if (!J || !J.sessions || J.sessions.others !== 1) bad.push(`②--json.sessions.others=${J && J.sessions ? J.sessions.others : 'none'} (기대 1) — 기계 경로가 못 본다`);
13263
+ if (!/👥 다른 세션 1/.test(H)) bad.push('②훅에 다른 세션 없음(유일한 강제 경로가 못 본다)');
13264
+ // 죽은 채널 — agent-reminders.md 는 1.9.38 부터 만들어졌지만 어떤 표면도 내용을 보여주지 않았다.
13265
+ if (!/DELIVERTEST/.test(P)) bad.push('④리마인더 본문이 handoff 에 안 나옴(죽은 채널)');
13266
+ if (!J || !J.agentReminders) bad.push('④--json 에 리마인더 없음');
13267
+ if (!/🔔 리마인더/.test(H)) bad.push('④훅에 리마인더 없음');
13268
+ // 훅은 **쓰기 0** 계약이다 — 신호를 얹으면서 염 파일을 만들면 그 계약이 깨진다.
13269
+ if (after.length !== before.length) bad.push(`②훅이 파일을 만들었다(${after.filter(f => !before.includes(f)).map(f => path.basename(f)).join(',')}) — 쓰기 0 계약 위반`);
13270
+ // 내용까지 본다 — 개수가 같아도 덮어썼을 수 있다(무효 salt 가 새 랜덤으로 교체된 실제 회귀).
13271
+ const saltP = path.join(d, '.harness', 'cache', 'sessions', '.host-salt');
13272
+ if (fs.existsSync(saltP)) {
13273
+ const s0 = fs.readFileSync(saltP, 'utf8');
13274
+ RP(d, ['hook', 'session-start'], me);
13275
+ if (fs.readFileSync(saltP, 'utf8') !== s0) bad.push('②훅이 .host-salt 를 바꿨다 — 쓰기 0 계약 위반');
13276
+ }
13277
+ if (String(hook.stderr || '').length) bad.push('②훅 stderr 비어있지 않음');
13278
+ dbg.delivery = { plain: /👥/.test(P), compact: /👥/.test(C), json: J && J.sessions ? J.sessions.others : null, hook: /👥/.test(H), hookWrites: after.length - before.length };
13279
+ }
13280
+
13281
+ // ③ 주소 — claude 아닌 에이전트도 **자기 기록을 남기고 서로를 본다**. 그리고 주소가 없으면 그 사실을 말한다.
13282
+ {
13283
+ const d = mkP('address', true);
13284
+ const codexEnv = { CODEX_MANAGED_BY_NPM: '1' };
13285
+ const r1 = RP(d, ['handoff', d], codexEnv);
13286
+ const dir = path.join(d, '.harness', 'cache', 'sessions');
13287
+ const n1 = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => /\.json$/.test(f)).length : 0;
13288
+ if (n1 !== 0) bad.push(`③주소 없는 세션이 등록됨(${n1}) — 식별 불가인데 기록했다`);
13289
+ if (!/🙈/.test(String(r1.stdout || ''))) bad.push('③주소 없는 세션에게 "안 보인다"고 말하지 않음(비대칭이 조용하다)');
13290
+ // (a) codex 는 **설정 없이도** 주소를 갖는다 — codex-cli 가 CODEX_THREAD_ID 를 이미 환경에 둔다
13291
+ // (실측 codex-cli 0.147.0: 소문자 UUIDv7 36자, 한 세션의 자식 호출들에서 동일, 세션이 바뀌면 값이 바뀜).
13292
+ const TID = '01a01570-54a6-70e2-a002-55291c73a3ce';
13293
+ const rT = RP(d, ['handoff', d], Object.assign({}, codexEnv, { CODEX_THREAD_ID: TID }));
13294
+ if (rT.status !== 0) bad.push(`③codex(thread) handoff exit=${rT.status}`);
13295
+ const dirT = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => /[.]json$/.test(f)) : [];
13296
+ if (!dirT.includes(TID + '.json')) bad.push(`③CODEX_THREAD_ID 로 등록 안 됨(파일: ${dirT.join(',')}) — codex 는 설정 없이 주소를 가져야 한다`);
13297
+ let recT = null; try { recT = JSON.parse(fs.readFileSync(path.join(dir, TID + '.json'), 'utf8')); } catch {}
13298
+ if (!recT || recT.keySource !== 'CODEX_THREAD_ID') bad.push(`③thread keySource=${recT && recT.keySource}`);
13299
+ // (b) claude 가 띄운 codex 는 부모 키로 접힌다(기존 동작 유지 — 독립 세션이 아니라 중첩이다).
13300
+ const rN = RP(d, ['handoff', d], Object.assign({}, codexEnv, { CODEX_THREAD_ID: TID, CLAUDE_CODE_SESSION_ID: 'parentclaude0001' }));
13301
+ if (rN.status !== 0) bad.push(`③중첩 codex handoff exit=${rN.status}`);
13302
+ if (!fs.readdirSync(dir).includes('parentclaude0001.json')) bad.push('③claude 하위 codex 가 부모 키로 접히지 않음');
13303
+ // (c) 명시 주소는 무엇보다 우선한다.
13304
+ const r2 = RP(d, ['handoff', d], Object.assign({}, codexEnv, { CODEX_THREAD_ID: TID, LEERNESS_SESSION_ID: 'codexsession0001' }));
13305
+ if (r2.status !== 0) bad.push(`③codex handoff exit=${r2.status}`);
13306
+ const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => /\.json$/.test(f)) : [];
13307
+ if (!files.includes('codexsession0001.json')) bad.push(`③LEERNESS_SESSION_ID 로도 등록 안 됨(파일: ${files.join(',')})`);
13308
+ let rec = null; try { rec = JSON.parse(fs.readFileSync(path.join(dir, 'codexsession0001.json'), 'utf8')); } catch {}
13309
+ if (!rec || rec.keySource !== 'LEERNESS_SESSION_ID') bad.push(`③keySource=${rec && rec.keySource} (기대 LEERNESS_SESSION_ID)`);
13310
+ if (!rec || rec.outermostAgent !== 'codex-cli') bad.push(`③에이전트 라벨=${rec && rec.outermostAgent} (기대 codex-cli)`);
13311
+ // 반대 방향 — claude 세션이 codex 세션을 본다(사용자가 든 바로 그 시나리오).
13312
+ const r3 = RP(d, ['handoff', d, '--json'], { CLAUDE_CODE_SESSION_ID: 'claudesession0001' });
13313
+ let J3 = null; try { J3 = JSON.parse(String(r3.stdout || '')); } catch {}
13314
+ if (!J3 || !J3.sessions || J3.sessions.others !== 3) bad.push(`③claude 가 codex 세션들을 못 봄(others=${J3 && J3.sessions ? J3.sessions.others : 'none'}, 기대 3)`);
13315
+ if (!J3 || J3.sessions.selfRegistered !== true) bad.push('③claude 세션이 등록 안 됨(기존 동작 회귀)');
13316
+ dbg.address = { unaddressed: n1, threadKeySource: recT && recT.keySource, keySource: rec && rec.keySource, agent: rec && rec.outermostAgent, claudeSeesCodex: J3 && J3.sessions && J3.sessions.others };
13317
+ }
13318
+
13319
+ // ⑥ 선결 결함 3종 — 전부 이번 라운드가 만든/드러낸 것이고 전부 재현했다.
13320
+ {
13321
+ // (a) **조회 명령이 남의 디렉토리에 쓴다**: 빈 디렉토리에 `sessions --json` 1회 → .harness/·cache/·sessions/·.host-salt 4개 생성(exit 0).
13322
+ const empty = path.join(sb, 'empty-not-a-project'); fs.mkdirSync(empty, { recursive: true });
13323
+ const rq = RP(empty, ['sessions', empty, '--json']);
13324
+ const made = fs.readdirSync(empty).length;
13325
+ if (made !== 0) bad.push(`⑥조회 명령이 파일을 만들었다(${made}개) — 읽기는 읽기여야 한다`);
13326
+ if (rq.status !== 0) bad.push(`⑥sessions --json exit=${rq.status}`);
13327
+ // (b) **--no-record 비대칭**: handoff 는 0건인데 session close 는 1건을 남겼다.
13328
+ const d2 = mkP('norecord', false);
13329
+ const me2 = { LEERNESS_SESSION_ID: 'norecordsess0001' };
13330
+ RP(d2, ['handoff', d2, '--no-record'], me2);
13331
+ const dir2 = path.join(d2, '.harness', 'cache', 'sessions');
13332
+ const afterHandoff = fs.existsSync(dir2) ? fs.readdirSync(dir2).filter(f => /[.]json$/.test(f)).length : 0;
13333
+ RP(d2, ['session', 'close', d2, '--no-record'], me2);
13334
+ const afterClose = fs.existsSync(dir2) ? fs.readdirSync(dir2).filter(f => /[.]json$/.test(f)).length : 0;
13335
+ if (afterHandoff !== 0) bad.push(`⑥handoff --no-record 가 기록함(${afterHandoff})`);
13336
+ if (afterClose !== 0) bad.push(`⑥session close --no-record 가 기록함(${afterClose}) — 같은 스토어인데 계약이 갈렸다`);
13337
+ // (c) **🙈 가 짧은 표면에 없었다**: 👥 는 4/4 인데 🙈 는 평문 1곳뿐이었다(비대칭).
13338
+ const d3 = mkP('invisible', true);
13339
+ RP(d3, ['handoff', d3], { LEERNESS_SESSION_ID: 'visiblepeer00001' });
13340
+ const noAddr = { CODEX_MANAGED_BY_NPM: '1' };
13341
+ const iP = String(RP(d3, ['handoff', d3], noAddr).stdout || '');
13342
+ const iC = String(RP(d3, ['handoff', d3, '--compact'], noAddr).stdout || '');
13343
+ const iH = String(RP(d3, ['hook', 'session-start'], noAddr).stdout || '');
13344
+ if (!/🙈/.test(iP)) bad.push('⑥평문에 "나는 안 보임" 없음');
13345
+ if (!/🙈/.test(iC)) bad.push('⑥--compact 에 "나는 안 보임" 없음(짧은 표면일수록 필요한 신호다)');
13346
+ if (!/🙈/.test(iH)) bad.push('⑥훅에 "나는 안 보임" 없음');
13347
+ dbg.prereq = { readOnlyWrites: made, afterHandoff, afterClose, invisible: [/🙈/.test(iP), /🙈/.test(iC), /🙈/.test(iH)] };
13348
+ }
13349
+
13350
+ // ⑦ 주소 공유 — 두 세션이 같은 LEERNESS_SESSION_ID 를 쓰면 레코드가 **조용히 병합**됐다(실측: others=0·헤드라인 침묵·
13351
+ // 라벨이 마지막 기록자로 덮임). 명시 주소를 지원하는 대가로 이 실패는 **말할 수 있는 실패**여야 한다.
13352
+ {
13353
+ const d = mkP('sharedkey', true);
13354
+ const KEY = 'sharedaddr000001';
13355
+ RP(d, ['handoff', d], { CLAUDECODE: '1', LEERNESS_SESSION_ID: KEY });
13356
+ const r2 = RP(d, ['handoff', d], { CODEX_MANAGED_BY_NPM: '1', LEERNESS_SESSION_ID: KEY });
13357
+ const warned = /주소 공유/.test(String(r2.stderr || ''));
13358
+ if (!warned) bad.push('⑦같은 주소를 다른 에이전트가 써도 조용함(조용한 병합)');
13359
+ let rec = null; try { rec = JSON.parse(fs.readFileSync(path.join(d, '.harness', 'cache', 'sessions', KEY + '.json'), 'utf8')); } catch {}
13360
+ if (!rec || !rec.sharedKeyAgents) bad.push('⑦레코드에 공유 표식 없음');
13361
+ const shEnv = { CODEX_MANAGED_BY_NPM: '1', LEERNESS_SESSION_ID: KEY };
13362
+ const cOut = String(RP(d, ['handoff', d, '--compact'], shEnv).stdout || '');
13363
+ if (!/주소 공유/.test(cOut)) bad.push('⑦--compact 에 공유 경고 없음');
13364
+ const pOut = String(RP(d, ['handoff', d], shEnv).stdout || '');
13365
+ if (!/주소 공유/.test(pOut)) bad.push('⑦평문에 공유 경고 없음 — stderr 는 한 번뿐이라 여기서 빠지면 영영 안 보인다');
13366
+ const hOut = String(RP(d, ['hook', 'session-start'], shEnv).stdout || '');
13367
+ if (!/주소 공유/.test(hOut)) bad.push('⑦훅에 공유 경고 없음');
13368
+ // 대조군 — 서로 다른 주소면 경고가 뜨면 안 된다(오탐 0). 라벨이 unknown 인 경우도 발화하면 안 된다.
13369
+ const d2 = mkP('distinctkey', true);
13370
+ RP(d2, ['handoff', d2], { CLAUDECODE: '1', LEERNESS_SESSION_ID: 'distinctkeyA0001' });
13371
+ const rB = RP(d2, ['handoff', d2], { CODEX_MANAGED_BY_NPM: '1', LEERNESS_SESSION_ID: 'distinctkeyB0001' });
13372
+ if (/주소 공유/.test(String(rB.stderr || ''))) bad.push('⑦서로 다른 주소인데 공유 경고(오탐)');
13373
+ const d3 = mkP('unknownlabel', true);
13374
+ RP(d3, ['handoff', d3], { LEERNESS_SESSION_ID: 'unknownlabel0001' });
13375
+ const rU = RP(d3, ['handoff', d3], { CODEX_MANAGED_BY_NPM: '1', LEERNESS_SESSION_ID: 'unknownlabel0001' });
13376
+ if (/주소 공유/.test(String(rU.stderr || ''))) bad.push('⑦라벨이 unknown 이었는데 공유 경고(셸별 env 가시성 차이로 오탐)');
13377
+ dbg.sharedKey = { warned, tag: rec && rec.sharedKeyAgents };
13378
+ }
13379
+
13380
+ // ⑧ 정직성 3종 — 전부 "모르는 것을 아는 척하지 않는다" 계열이고 전부 재현했다.
13381
+ {
13382
+ const d = mkP('honesty', true);
13383
+ const me = { LEERNESS_SESSION_ID: 'honestysess00001' };
13384
+ // (a) `--no-record` 인데 selfRegistered=true 였다(주소 있음 ≠ 등록됨). 그리고 세션 파일·염을 만들면 안 된다.
13385
+ const jr = RP(d, ['handoff', d, '--json', '--no-record'], me);
13386
+ let J = null; try { J = JSON.parse(String(jr.stdout || '')); } catch { bad.push('⑧--no-record --json 파싱 실패'); }
13387
+ const sdir = path.join(d, '.harness', 'cache', 'sessions');
13388
+ const madeAny = fs.existsSync(sdir) ? fs.readdirSync(sdir).length : 0;
13389
+ if (madeAny !== 0) bad.push(`⑧--no-record 가 세션 스토어에 ${madeAny}개를 만들었다(염 포함)`);
13390
+ if (!J || J.sessions.selfRegistered !== false) bad.push('⑧--no-record 인데 selfRegistered=true (거짓 성공)');
13391
+ if (!J || J.sessions.selfAddressed !== true) bad.push('⑧주소는 있는데 selfAddressed=false');
13392
+ // (b) 저장소를 못 읽는 상태를 "0건" 으로 축약하지 않는다.
13393
+ fs.mkdirSync(path.dirname(sdir), { recursive: true });
13394
+ fs.writeFileSync(sdir, 'not-a-directory');
13395
+ const jr2 = RP(d, ['handoff', d, '--json'], me);
13396
+ let J2 = null; try { J2 = JSON.parse(String(jr2.stdout || '')); } catch {}
13397
+ if (!J2 || !J2.sessions.storeError) bad.push('⑧저장소 읽기 실패를 others:0 으로 축약(못 읽은 것은 없는 것이 아니다)');
13398
+ const p2 = String(RP(d, ['handoff', d], me).stdout || '');
13399
+ if (!/읽지 못함/.test(p2)) bad.push('⑧평문이 저장소 오류를 말하지 않음');
13400
+ try { fs.unlinkSync(sdir); } catch {}
13401
+ // (c) 명시 주소가 형식에 안 맞으면 조용히 다음 출처로 넘어가지 않는다.
13402
+ const p3 = String(RP(d, ['handoff', d], { LEERNESS_SESSION_ID: 'short', CLAUDE_CODE_SESSION_ID: 'fallbackkey00001' }).stdout || '');
13403
+ if (!/LEERNESS_SESSION_ID/.test(p3)) bad.push('⑧무효한 명시 주소를 조용히 무시(사용자 설정을 말없이 뒤집음)');
13404
+ // (d) 대소문자만 다른 주소가 서로 다른 척하다 한 파일로 병합되지 않는다 — 접어서 **한 주소**로 다룬다.
13405
+ const d2 = mkP('casefold', true);
13406
+ RP(d2, ['handoff', d2], { LEERNESS_SESSION_ID: 'CaseKey01ABCD' });
13407
+ RP(d2, ['handoff', d2], { LEERNESS_SESSION_ID: 'casekey01abcd' });
13408
+ const cdir = path.join(d2, '.harness', 'cache', 'sessions');
13409
+ const cf = fs.readdirSync(cdir).filter(f => /casekey/i.test(f));
13410
+ if (!(cf.length === 1 && cf[0] === 'casekey01abcd.json')) bad.push(`⑧대소문자 주소 처리 불일치(${cf.join(',')})`);
13411
+ let cr = null; try { cr = JSON.parse(fs.readFileSync(path.join(cdir, cf[0]), 'utf8')); } catch {}
13412
+ if (!cr || cr.sessionKey !== 'casekey01abcd') bad.push(`⑧레코드 키가 파일명과 불일치(${cr && cr.sessionKey})`);
13413
+ // (e) 리마인더 내용이 제어문자·비밀값 그대로 표면에 나가지 않는다.
13414
+ const d3 = mkP('sanitize', true);
13415
+ fs.writeFileSync(path.join(d3, '.harness', 'agent-reminders.md'),
13416
+ '# R\n- ' + String.fromCharCode(27) + '[2J' + ' token sk_live_' + 'ABCDEFGHIJKLMNOP1234\n');
13417
+ const jr3 = RP(d3, ['handoff', d3, '--json'], { LEERNESS_SESSION_ID: 'sanitizesess0001' });
13418
+ let J3 = null; try { J3 = JSON.parse(String(jr3.stdout || '')); } catch {}
13419
+ const headTxt = J3 && J3.agentReminders ? J3.agentReminders.head.join(' ') : '';
13420
+ if (!J3 || !J3.agentReminders) bad.push('⑧리마인더 신호 없음(셋업 실패)');
13421
+ else {
13422
+ if (headTxt.includes(String.fromCharCode(27))) bad.push('⑧제어문자(ESC)가 그대로 출력됨');
13423
+ if (/sk_live_[A-Z]/.test(headTxt)) bad.push('⑧비밀값 표식이 마스킹되지 않음');
13424
+ }
13425
+ dbg.honesty = { madeAny, selfRegistered: J && J.sessions.selfRegistered, storeError: J2 && J2.sessions.storeError, caseFiles: cf.length };
13426
+ }
13427
+
13428
+ // ⑤ 대조군 — 평범한 단일 흐름은 아무것도 달라지지 않는다(새 경고·새 필드가 오탐을 만들지 않는다).
13429
+ {
13430
+ const d = mkP('plain', false);
13431
+ const me = { LEERNESS_SESSION_ID: 'solosession000001' };
13432
+ for (const a of [['task', 'add', '보통 작업', '--path', d, '--json'], ['handoff', d], ['session', 'close', d]]) {
13433
+ const r = RP(d, a, me);
13434
+ if (r.status !== 0) bad.push(`⑤평범한 흐름 실패: ${a[0]} exit=${r.status}`);
13435
+ }
13436
+ const solo = RP(d, ['handoff', d], me);
13437
+ if (/👥/.test(String(solo.stdout || ''))) bad.push('⑤혼자인데 다른 세션이 있다고 말함(오탐)');
13438
+ if (/🙈/.test(String(solo.stdout || ''))) bad.push('⑤등록됐는데 안 보인다고 말함(오탐)');
13439
+ if (String(solo.stderr || '').includes('락 획득 실패')) bad.push('⑤경합 없는데 락 경고가 뜸(오탐)');
13440
+ if (String(solo.stderr || '').includes('주소 공유')) bad.push('⑤혼자인데 주소 공유 경고(오탐)');
13441
+ const soloOut = String(solo.stdout || '');
13442
+ for (const noise of ['읽지 못함', 'LEERNESS_SESSION_ID 가 형식', '주소 공유']) {
13443
+ if (soloOut.includes(noise)) bad.push(`⑤정상 상태인데 경고가 뜸(오탐): ${noise}`);
13444
+ }
13445
+ dbg.plain = { falsePositives: 0 };
13446
+ }
13447
+ ok = bad.length === 0;
13448
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
13449
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
13450
+ console.log(ok ? `✓ AA(1.36.132/UR-0069) 다중세션 토대: 동시 24쓰기 무유실·무크래시(락 EPERM 무보호진행 + rename 동시독자 EPERM 둘 다 차단) · 세션 신호가 4표면 전부에 도달(평문·--compact·--json·훅, 훅은 쓰기 0 유지) · claude 아닌 에이전트도 주소를 갖고 서로를 봄 + 주소 없으면 그 사실을 말함 · 죽은 채널(agent-reminders) 노출 · 단일 흐름 오탐 0 ${JSON.stringify(dbg)}`
13451
+ : '✗ 1.36.132 다중세션 토대 실패 ' + JSON.stringify({ bad: bad.slice(0, 10), dbg }));
13452
+ if (!ok) failed++;
13453
+ }
13454
+
13455
+
13007
13456
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
13008
13457
  if (failed > 0) process.exit(1);
13009
13458