leerness 1.36.141 → 1.36.144

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.141 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.144 하네스를 사용합니다. 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.141는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.144는 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.141는 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.141 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.144 릴리스 태그 이력** (수백 라운드) · _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.141: 2026-08-20
238
+ Last synced by Leerness v1.36.144: 2026-08-21
239
239
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -4,7 +4,7 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const cp = require('child_process');
7
- const { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('../lib/io'); // 1.9.382/383 (UR-0025): 출력/시간/파일 프리미티브 공유 모듈 · 1.10.2 (UR-0146): setQuiet
7
+ const { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel, setDryRunGuard, mkdirpRaw } = require('../lib/io'); // 1.9.382/383 (UR-0025): 출력/시간/파일 프리미티브 공유 모듈 · 1.10.2 (UR-0146): setQuiet
8
8
  const os = require('os'); // 1.9.178: _publishToNpm 에서 os.tmpdir() 사용 (전역 import)
9
9
  const readline = require('readline');
10
10
  // 1.9.274 (UR-0025 1단계): 순수 유틸 함수 모듈 분리 (require-based, 비파괴). selftest 7종이 동작 검증.
@@ -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.141';
37
+ const VERSION = '1.36.144';
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') 시 호스트 프로세스 오염.
@@ -180,6 +180,9 @@ const _lockFailOpen = []; // 보호 없이 진행한 기록(관측용) — 조
180
180
  for (const _st of [process.stderr, process.stdout]) { try { _st.on('error', (e) => { if (!e || e.code !== 'EPIPE') return; }); } catch {} }
181
181
  function _withLock(targetPath, fn, opts = {}) {
182
182
  const lockPath = targetPath + '.lock';
183
+ // 1.36.144 (T-0117): dry-run 이면 **락을 잡지 않는다** — 쓰기가 어차피 가드에 막히므로 지킬 것이 없고,
184
+ // 락 디렉토리를 만드는 것만으로 "변경 없음" 이 거짓이 된다(실측: `state *` 8건이 빈 `.leerness/` 를 남겼다).
185
+ if (has('--dry-run')) return fn();
183
186
  let bailCode = null, lastCode = null;
184
187
  if (_heldLocks.has(lockPath)) return fn(); // 이미 이 프로세스가 보유 → 재진입(중첩 호출 안전)
185
188
  const maxWaitMs = opts.maxWaitMs || 10000; // 1.9.375 (UR-0084): 5s→10s — 고부하 경합 시 fail-open(락 없이 진행) 전 인내 상향, lost-update 창 축소
@@ -187,7 +190,7 @@ function _withLock(targetPath, fn, opts = {}) {
187
190
  // ⚠ 여기서 mkdirp 를 빼면 안 된다(1.36.108 에서 한 번 뺐다가 게이트가 5건을 잡았다).
188
191
  // `state`·hunts 등 여러 호출부가 **이 mkdirp 가 만들어 주는 디렉토리에 기대어** 곧바로 파일을 쓴다 —
189
192
  // 공유 헬퍼의 부작용을 없애면 그 의존이 한꺼번에 끊긴다. 미초기화 디렉토리 문제는 락을 다는 쪽에서 막는다.
190
- try { mkdirp(path.dirname(lockPath)); } catch {}
193
+ try { mkdirpRaw(path.dirname(lockPath)); } catch {} // 1.36.144: 락은 dry-run 가드 예외(막으면 무보호 진행으로 떨어진다)
191
194
  const start = Date.now();
192
195
  let held = false;
193
196
  while (Date.now() - start <= maxWaitMs) {
@@ -1332,7 +1335,10 @@ async function resolveInstallOptions(root, opts = {}) {
1332
1335
  }
1333
1336
 
1334
1337
  async function install(root, opts = {}) {
1335
- root = absRoot(root); mkdirp(root);
1338
+ // 1.36.144 (게이트가 잡음): `--dry-run` 인데 **여기서 프로젝트 디렉토리를 먼저 만들었다** —
1339
+ // enforce 에서 고쳤던 것과 같은 순서 결함이다(거절/미리보기는 어떤 변경보다 앞서야 한다).
1340
+ // dry 모드에서는 만들지 않는다. 아래 흐름은 존재하지 않는 root 에서도 요약까지 도달한다.
1341
+ root = absRoot(root); if (!opts.dry) mkdirp(root);
1336
1342
  // 1.9.184+1.9.190: 설치 도중 Ctrl+C 시 즉시 종료 (사용자 명시 BUG fix — npx 설치 진행 차단).
1337
1343
  // 1.9.184 의 2단계 confirm 은 readline raw mode 가 SIGINT 가로채서 호출 안 됨 → 1.9.190 즉시 종료로 단순화.
1338
1344
  // _selectOne/_selectMany 는 stdin '\x03' 직접 처리, ask() 는 rl.on('SIGINT') 처리, 이 핸들러는 fallback.
@@ -7275,16 +7281,40 @@ function enforceCmd(root, sub) {
7275
7281
  // 1.36.140 (재검수 P1, 재현): 연결된 worktree 들은 `core.hooksPath` 설정이 없어도 **같은 훅 파일**을 쓴다 —
7276
7282
  // 한 worktree 에 설치하면 형제 worktree 의 커밋까지 차단된다. `hooksPathShared` 는 설정에 관한 사실이므로
7277
7283
  // 그 뜻을 늘리지 않고, **별도 사실**로 센다(모르면 주장하지 않는다).
7284
+ // ⚠ 1.36.143 (재검수 P1): 첫 판은 "worktree 범위 설정이면 형제는 안 막힌다" 고 **추정**해 `null` 을 냈다.
7285
+ // 그런데 worktree 범위 설정이 그냥 공용 `.git/hooks` 를 가리키면 형제는 **실제로 막힌다** — 사실을 숨긴 것이다.
7286
+ // 추정하지 말고 **각 worktree 에게 자기 훅 경로를 직접 묻는다.** 우리가 쓰는 파일과 같은 곳을 보는 수를 센다.
7278
7287
  const _worktreeCount = (() => {
7279
7288
  try {
7280
- // ⚠ `core.hooksPath` 가 **worktree 범위**면 그 worktree 전용 훅이라 형제는 안 막힌다 —
7281
- // 그때 "형제도 차단된다" 고 말하면 거짓 경고다(재검수 P2). 모르면 주장하지 않는다.
7282
- const wsc = _gitSpawn(['-C', root, 'config', '--show-scope', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: 5000 });
7283
- if (wsc.status === 0 && String(wsc.stdout || '').trim().split(/\s+/)[0] === 'worktree') return null;
7284
7289
  const wl = _gitSpawn(['-C', root, 'worktree', 'list', '--porcelain'], { encoding: 'utf8', timeout: 5000 });
7285
7290
  if (wl.status !== 0) return null;
7286
- const n = String(wl.stdout || '').split('\n').filter((l) => l.startsWith('worktree ')).length;
7287
- return n > 0 ? n : null;
7291
+ const dirs = String(wl.stdout || '').split('\n')
7292
+ .filter((l) => l.startsWith('worktree '))
7293
+ .map((l) => l.slice('worktree '.length).trim())
7294
+ .filter(Boolean);
7295
+ if (dirs.length <= 1) return dirs.length || null;
7296
+ const norm = (q) => {
7297
+ let r = q;
7298
+ try { r = fs.realpathSync.native ? fs.realpathSync.native(q) : fs.realpathSync(q); } catch {}
7299
+ r = path.resolve(r);
7300
+ return process.platform === 'win32' ? r.toLowerCase() : r;
7301
+ };
7302
+ const mine = norm(hookP);
7303
+ let sharing = 0;
7304
+ // ⚠ 1.36.144 (재검수 P1): "하나라도 모르면 null" 로 했더니 **사라진(prunable) worktree 하나**가
7305
+ // 살아 있는 형제들의 실제 차단까지 통째로 숨겼다. 모르는 것은 세지 않되, **아는 것은 그대로 센다**.
7306
+ // 디렉토리가 없어진 worktree 는 애초에 커밋하지 않으므로 셈에서 빠지는 것이 옳다.
7307
+ let unknown = 0;
7308
+ for (const wd of dirs) {
7309
+ if (!exists(wd)) { unknown++; continue; } // prunable — 실재하지 않는다
7310
+ const hp = _gitSpawn(['-C', wd, 'rev-parse', '--git-path', 'hooks/pre-commit'], { encoding: 'utf8', timeout: 5000 });
7311
+ if (hp.status !== 0 || !String(hp.stdout || '').trim()) { unknown++; continue; }
7312
+ const raw = String(hp.stdout).trim();
7313
+ if (norm(path.isAbsolute(raw) ? raw : path.join(wd, raw)) === mine) sharing++;
7314
+ }
7315
+ // 아무것도 못 알아냈을 때만 "모른다" 고 한다.
7316
+ if (sharing === 0 && unknown > 0) return null;
7317
+ return sharing > 0 ? sharing : null;
7288
7318
  } catch { return null; }
7289
7319
  })();
7290
7320
  const chainedP = hookP + '.pre-leerness';
@@ -7351,7 +7381,9 @@ function enforceCmd(root, sub) {
7351
7381
  } catch { /* 제자리 쓰기가 막히면 아래 지우고-쓰기로 넘어간다 */ }
7352
7382
  }
7353
7383
  }
7354
- if (_lexists(p)) { try { fs.unlinkSync(p); } catch {} }
7384
+ // ⚠ 1.36.143 (재검수 P1): 삭제 실패를 삼키면 **우리 차단 훅이 살아있는데** 되돌렸다고 말하게 된다.
7385
+ // 되돌리기 실패는 밖으로 던져 `_rbFail` 에 잡힌다.
7386
+ if (_lexists(p)) fs.unlinkSync(p);
7355
7387
  if (snap.kind === 'none') return;
7356
7388
  if (snap.kind === 'link') { fs.symlinkSync(snap.target, p); return; }
7357
7389
  fs.writeFileSync(p, snap.buf);
@@ -7368,12 +7400,9 @@ function enforceCmd(root, sub) {
7368
7400
  };
7369
7401
  const _rbNote = () => (_rbFail.length ? ` ⚠ 되돌리지 못한 것: ${_rbFail.join(' · ')}` : '');
7370
7402
  try {
7371
- mkdirp(path.dirname(hookP));
7372
- // 기존 훅 보존: 우리 것이 아니면 체인 파일로 이동 후 호출
7373
- if (exists(hookP) && !read(hookP).includes(_ENFORCE_MARK)) {
7374
- fs.copyFileSync(hookP, chainedP);
7375
- try { fs.chmodSync(chainedP, 0o755); } catch {}
7376
- }
7403
+ // 1.36.143 (재검수 P1): 경로 검사를 **어떤 변경보다도 앞**으로 옮겼다.
7404
+ // 종전엔 체인 백업을 이미 덮어쓴 뒤에 `unsafe_path` 로 반환해 **백업이 파괴된 채로 남았다**.
7405
+ // 거절할 입력이면 아무것도 건드리기 전에 거절한다.
7377
7406
  // 훅은 자기완결 sh — 설치된 leerness 버전/네트워크/PATH 에 무의존(구버전 전역 CLI 가 enforce 를 몰라 오차단하던 실측 함정 회피).
7378
7407
  // 핵심 체크 = last-handoff.json 의 mtime 이 window 안인지 (find -mmin, git bash 표준).
7379
7408
  // 1.36.134: git 이 훅을 부를 때의 작업 디렉토리 = toplevel. harness 위치를 그 기준 상대경로로 고정한다.
@@ -7390,6 +7419,18 @@ function enforceCmd(root, sub) {
7390
7419
  try { _top = fs.realpathSync(_top); } catch {}
7391
7420
  let harnessRel = (path.relative(_top, _hDir) || '.harness').replace(/\\/g, '/');
7392
7421
  if (harnessRel.startsWith('..')) harnessRel = _hDir.replace(/\\/g, '/');
7422
+ // ⚠ 1.36.143 (재검수 P1): 이 거부가 **체인 백업 복사 뒤**에 있었다 — 거절하면서 백업을 파괴했다.
7423
+ // 계산은 읽기뿐이므로 여기까지 올려도 안전하고, 거절은 **아무것도 쓰기 전에** 끝난다.
7424
+ if (/[\r\n]/.test(harnessRel)) {
7425
+ failJson(json, 'unsafe_path', `경로에 개행이 있어 훅 스크립트에 넣을 수 없습니다: ${JSON.stringify(harnessRel)}`);
7426
+ return; // ← 아직 아무것도 쓰지 않았다
7427
+ }
7428
+ mkdirp(path.dirname(hookP));
7429
+ // 기존 훅 보존: 우리 것이 아니면 체인 파일로 이동 후 호출
7430
+ if (exists(hookP) && !read(hookP).includes(_ENFORCE_MARK)) {
7431
+ fs.copyFileSync(hookP, chainedP);
7432
+ try { fs.chmodSync(chainedP, 0o755); } catch {}
7433
+ }
7393
7434
  // 1.36.134 (검수 P1, 재현): 이 값을 훅 셸 스크립트에 그대로 박았다 — 디렉토리 이름이 `pkg$(touch PWNED)` 면
7394
7435
  // **설치만으로 임의 코드가 실행**됐다(실측: PWNED 생성). 값을 신뢰하지 않는다.
7395
7436
  // ① 셸에서 특별한 뜻을 갖는 문자가 하나라도 있으면 설치를 거부한다(조용히 이스케이프하고 넘어가지 않는다 —
@@ -7398,10 +7439,6 @@ function enforceCmd(root, sub) {
7398
7439
  // 둘 다 `unsafe_path` 로 막혔다. 한국어 환경에서 흔한 경로다. 거부가 아니라 **제대로 인용**하는 것이 답이다.
7399
7440
  // POSIX sh 의 작은따옴표 안에서는 `'` 를 제외한 모든 문자가 리터럴이다 — `'` 만 표준 관용구로 탈출시킨다.
7400
7441
  const shQuote = (v) => "'" + String(v).replace(/'/g, "'\''") + "'";
7401
- if (/[\r\n]/.test(harnessRel)) {
7402
- failJson(json, 'unsafe_path', `경로에 개행이 있어 훅 스크립트에 넣을 수 없습니다: ${JSON.stringify(harnessRel)}`);
7403
- return;
7404
- }
7405
7442
  const hook = [
7406
7443
  '#!/bin/sh',
7407
7444
  _ENFORCE_MARK + ' — leerness 사용 강제 (제거: leerness enforce remove)',
@@ -21545,7 +21582,10 @@ async function releasePackCmd(root) {
21545
21582
  }
21546
21583
 
21547
21584
  // 4. 자동 task add — 매 release 라운드가 progress-tracker에 흔적 남도록
21548
- if (taskTitle) {
21585
+ // ⚠ 1.36.144 (게이트가 잡음): 이 단계가 `dryRun` 을 안 봐서 **"mode: dry-run" 이라 찍고는
21586
+ // progress-tracker 에 실제로 썼다**. dry 면 무엇을 할지만 말한다.
21587
+ if (taskTitle && dryRun) log(` (dry-run) task add 스킵: ${taskTitle}`);
21588
+ if (taskTitle && !dryRun) {
21549
21589
  const v = getCurrentVersion(root) || VERSION;
21550
21590
  const id = nextId(root, 'T');
21551
21591
  upsertProgress(root, {
@@ -21559,7 +21599,9 @@ async function releasePackCmd(root) {
21559
21599
  }
21560
21600
 
21561
21601
  // 5. session close
21562
- if (close) {
21602
+ // ⚠ 같은 이유로 dry 면 건너뛴다 — 마감은 보존 파일을 새로 쓴다.
21603
+ if (close && dryRun) log('\n (dry-run) session close 스킵');
21604
+ if (close && !dryRun) {
21563
21605
  log('\n[session close]');
21564
21606
  try {
21565
21607
  const r = sessionClose(root);
@@ -22437,6 +22479,9 @@ function _readUsageStats(root) {
22437
22479
  } catch { return { commands: {}, drift: {}, since: today() }; }
22438
22480
  }
22439
22481
  function _bumpUsage(root, cmdName) {
22482
+ // 1.36.144 (T-0117): dry-run 은 아무것도 바꾸지 않는다 — 사용량 카운터도 예외가 아니다.
22483
+ // (전수 실측에서 378 호출 중 155건이 이 카운터 하나 때문에 "썼다" 로 잡혔다.)
22484
+ if (has('--dry-run')) return;
22440
22485
  // 가벼운 카운터 — 명령 실행마다 호출 (sync write로 작은 파일)
22441
22486
  // 1.36.137 (동시성 감사 실측): 이 함수는 **모든 명령이 공유하는 단 하나의 파일**을 락 없이 RMW 했다 —
22442
22487
  // 2 프로세스부터 카운터가 38~46% 사라졌다(8프로세스×15회=120 실행 → 74 계수). exit 0 · 경고 0 · 손상 0.
@@ -23357,7 +23402,10 @@ function _mcpToCliArgs(name, args, targetPath) {
23357
23402
  break;
23358
23403
  case 'leerness_release_cleanup':
23359
23404
  // 1.9.236 (1.9.235): local release/* branches 정리
23360
- cliArgs = ['release', 'cleanup', '--json'];
23405
+ // ⚠ 1.36.144 (재검수 P1, 재현): 여기서 `targetPath` 를 빠뜨려 요청한 저장소가 아니라
23406
+ // **MCP 서버의 cwd** 저장소 브랜치를 지웠다(실측: 요청 path=target 인데 victim 60 → 10, 50건 삭제 + 성공 보고).
23407
+ // `drift --auto-fix` 때와 같은 클래스가 MCP 표면에 남아 있었다 — 가드는 자기가 밟는 경로만 지킨다.
23408
+ cliArgs = ['release', 'cleanup', '--path', targetPath, '--json'];
23361
23409
  if (args.apply === true) cliArgs.push('--apply');
23362
23410
  if (typeof args.keep === 'number') cliArgs.push('--keep', String(args.keep));
23363
23411
  break;
@@ -24907,7 +24955,13 @@ function _loadLeernessState(root) {
24907
24955
  // 단, runs 디렉토리가 비어 있으면(진짜 신규/빈 상태) 관대하게 기본값 — 없는 데이터를 지킬 필요는 없다.
24908
24956
  const _shapeOk = _st && typeof _st === 'object' && !Array.isArray(_st)
24909
24957
  && Number.isInteger(_st.runCounter) && _st.runCounter >= 0
24910
- && (_st.currentRunId === null || typeof _st.currentRunId === 'string');
24958
+ && (_st.currentRunId === null || typeof _st.currentRunId === 'string')
24959
+ // 1.36.144 (재검수 P2): `runsBySession` 이 형태 검증에서 빠져 배열·문자열·null 이 와도 통과했고,
24960
+ // 그 경우 소유권이 통째로 '없음' 이 되어 **다른 세션의 run 을 인수**하는 fail-open 이 됐다.
24961
+ // 모양이 아니면 손상으로 다룬다(없는 것은 정상 — 업그레이드 전 상태).
24962
+ && (_st.runsBySession === undefined
24963
+ || (_st.runsBySession && typeof _st.runsBySession === 'object' && !Array.isArray(_st.runsBySession)
24964
+ && Object.values(_st.runsBySession).every((v) => typeof v === 'string' && v)));
24911
24965
  // 디스크의 최대 run 번호 — 카운터의 진실성 대조 기준
24912
24966
  let _maxRun = 0;
24913
24967
  try {
@@ -25165,6 +25219,50 @@ function stateCmd(root, sub, ...args) {
25165
25219
  }
25166
25220
  const json = has('--json');
25167
25221
  sub = sub || 'show';
25222
+ // 1.36.142 (T-0116, 재현): `currentRunId` 가 **전역 슬롯 하나**라 두 세션이 같은 프로젝트에서
25223
+ // 일하면 뒤사람이 앞사람의 슬롯을 덮어쓴다. 실측: A 의 증거(files_changed · tests)가
25224
+ // **B 의 run 에 붙고**, 일하지 않은 B 가 `completion_claim_allowed: true` 를 받았으며,
25225
+ // A 는 `state show` 로 자기 run 을 볼 수조차 없었다. 증거 게이트가 그대로 뒤집힌다.
25226
+ // 락으로 막힐 문제가 아니다(직렬로 돌려도 재현된다) — **주소가 없는 것**이 문제다.
25227
+ const _SPst = require('../lib/session-presence');
25228
+ const _sessKey = _SPst.deriveSessionKey(process.env);
25229
+ // ⚠ 명시한 주소가 형식에 안 맞으면 조용히 전역 슬롯으로 떨어진다 — 그게 바로 이 버그다.
25230
+ // 사용자는 주소가 있다고 믿는데 없는 상태가 되므로, 무시했다면 반드시 말한다.
25231
+ const _badKey = _SPst.invalidExplicitKey(process.env);
25232
+ if (_badKey && !json) warn(`⚠ LEERNESS_SESSION_ID 가 형식에 맞지 않아 무시됩니다("${_badKey}") — 이 세션은 주소 없이 전역 슬롯을 씨니다`);
25233
+ // 1.36.144 (재검수 P2): `__proto__` 같은 키는 plain object 에 넣으면 프로토타입에 먹혀
25234
+ // `start` 는 성공하는데 소유가 사라지고 `record` 가 실패했다. 소유 맵은 **프로토타입 없는 객체**로 다룬다.
25235
+ const _ownedBy = (st) => {
25236
+ const raw = (st && st.runsBySession && typeof st.runsBySession === 'object' && !Array.isArray(st.runsBySession)) ? st.runsBySession : {};
25237
+ const o = Object.create(null);
25238
+ for (const k of Object.keys(raw)) if (typeof raw[k] === 'string' && raw[k]) o[k] = raw[k];
25239
+ return o;
25240
+ };
25241
+ // 이 세션의 run 을 고른다. 없으면 **다른 세션이 소유하지 않은** 전역 슬롯만 인수한다
25242
+ // (업그레이드 전에 시작한 run · 세션 키가 없는 단일 에이전트는 종전처럼 동작한다).
25243
+ const _resolveRunId = (st) => {
25244
+ const owned = _ownedBy(st);
25245
+ if (_sessKey && owned[_sessKey]) return owned[_sessKey];
25246
+ const others = new Set(Object.entries(owned).filter(([k]) => k !== _sessKey).map(([, v]) => v));
25247
+ if (st.currentRunId && !others.has(st.currentRunId)) return st.currentRunId;
25248
+ return null;
25249
+ };
25250
+ const _claimRun = (st, runId) => {
25251
+ if (_sessKey) {
25252
+ const o = _ownedBy(st); o[_sessKey] = runId;
25253
+ // 직렬화 전에 평범한 객체로 되돌린다(프로토타입 없는 객체도 JSON 은 그대로 쓴다 — 명시적으로 복사한다).
25254
+ st.runsBySession = Object.fromEntries(Object.keys(o).map((k) => [k, o[k]]));
25255
+ }
25256
+ st.currentRunId = runId; // 기존 소비자 계약 유지(마지막 시작된 run)
25257
+ };
25258
+ const _releaseRun = (st, runId) => {
25259
+ if (_sessKey && _ownedBy(st)[_sessKey] === runId) {
25260
+ const o = _ownedBy(st); delete o[_sessKey];
25261
+ st.runsBySession = Object.fromEntries(Object.keys(o).map((k) => [k, o[k]]));
25262
+ }
25263
+ // ⚠ 전역 슬롯은 **내 run 일 때만** 비운다 — 남의 run 을 끊지 않는다.
25264
+ if (st.currentRunId === runId) st.currentRunId = null;
25265
+ };
25168
25266
 
25169
25267
  if (sub === 'start') {
25170
25268
  // 1.9.311 (UR-0047): state 는 .leerness substrate(standalone) — .harness init 가드 미적용 (회귀 방지)
@@ -25179,9 +25277,9 @@ function stateCmd(root, sub, ...args) {
25179
25277
  model_name: arg('--model', null)
25180
25278
  });
25181
25279
  _saveRun(root, rec);
25182
- state.currentRunId = run_id;
25280
+ _claimRun(state, run_id);
25183
25281
  _saveLeernessState(root, state);
25184
- if (json) { log(JSON.stringify({ started: run_id, run: rec }, null, 2)); return; }
25282
+ if (json) { log(JSON.stringify({ started: run_id, run: rec, sessionKey: _sessKey || null, keySource: _SPst.keySource(process.env) || null }, null, 2)); return; }
25185
25283
  ok(`run 시작: ${run_id}${rec.agent_name ? ' · agent=' + rec.agent_name : ''}${rec.model_name ? ' · model=' + rec.model_name : ''}`);
25186
25284
  if (goal) log(` goal: ${goal}`);
25187
25285
  log(` → 기록: leerness state record --files-changed "a,b" --commands "npm test" --decision "..."`);
@@ -25189,8 +25287,14 @@ function stateCmd(root, sub, ...args) {
25189
25287
  }
25190
25288
 
25191
25289
  const state = _loadLeernessState(root);
25192
- const curId = state.currentRunId;
25290
+ const curId = _resolveRunId(state);
25193
25291
  if ((sub === 'record' || sub === 'verify' || sub === 'handoff') && !curId) {
25292
+ // 다른 세션이 쓰는 run 이 있으면 그 사실을 말한다 — 조용히 남의 run 에 쓰지 않는다.
25293
+ const _others = Object.entries(_ownedBy(state)).filter(([k]) => k !== _sessKey);
25294
+ if (_others.length) {
25295
+ return fail(`이 세션의 run 이 없습니다 — 다른 세션이 진행 중입니다(${_others.map(([k, v]) => `${String(k).slice(0, 8)}…:${v}`).join(', ')}). `
25296
+ + `남의 run 에 증거를 붙이지 않습니다 — 먼저 leerness state start "<goal>"`);
25297
+ }
25194
25298
  return fail('진행 중 run 없음 — 먼저 leerness state start "<goal>"');
25195
25299
  }
25196
25300
 
@@ -25236,7 +25340,7 @@ function stateCmd(root, sub, ...args) {
25236
25340
  writeUtf8(path.join(hdir, 'latest.json'), JSON.stringify({ ...rec, completion_claim_allowed: _ccaH }, null, 2) + '\n');
25237
25341
  writeUtf8(path.join(hdir, 'latest.md'),
25238
25342
  `# Handoff — ${rec.run_id}\n\n- task: ${rec.task_id || '-'}\n- agent: ${rec.agent_name || '-'} · model: ${rec.model_name || '-'}\n- goal: ${rec.goal || '-'}\n- files_changed: ${rec.files_changed.join(', ') || '-'}\n- tests_run: ${rec.tests_run.join(', ') || '-'}\n- verification: ${rec.verification_result || '-'}\n- completion_claim_allowed: ${_ccaH.allowed ? 'yes' : 'no (' + _ccaH.reasons.join(', ') + ')'}\n\n## Summary\n${rec.handoff_summary}\n`);
25239
- state.currentRunId = null; // 다음 에이전트가 새 run 시작
25343
+ _releaseRun(state, rec.run_id); // 내 소유만 놓는다(남의 run 은 건드리지 않는다)
25240
25344
  _saveLeernessState(root, state);
25241
25345
  if (json) { log(JSON.stringify({ handoff: rec.run_id, run: rec, completion_claim_allowed: _ccaH }, null, 2)); return; }
25242
25346
  ok(`handoff 작성: ${rec.run_id} → .leerness/handoff/latest.{md,json}`);
@@ -25246,7 +25350,18 @@ function stateCmd(root, sub, ...args) {
25246
25350
  // default: show
25247
25351
  const rec = curId ? _loadRun(root, curId) : null;
25248
25352
  // 1.9.443 (UR-0153): evidence-first 완료 게이트 파생 노출.
25249
- if (json) { log(JSON.stringify({ state, currentRun: rec, completion_claim_allowed: rec ? _completionClaimAllowed(rec) : null }, null, 2)); return; }
25353
+ if (json) {
25354
+ log(JSON.stringify({
25355
+ state, currentRun: rec, completion_claim_allowed: rec ? _completionClaimAllowed(rec) : null,
25356
+ sessionKey: _sessKey || null, keySource: _SPst.keySource(process.env) || null,
25357
+ // 1.36.144 (재검수 P2): 남의 세션 키를 그대로 내보내면 **그 키를 흉내내 남의 run 을 고칠 수 있다**.
25358
+ // 몇 개가 진행 중인지와 내 것이 무엇인지는 말하되, 남의 주소는 접두만 보인다.
25359
+ runsBySession: Object.fromEntries(Object.keys(_ownedBy(state)).map((k) => [
25360
+ k === _sessKey ? k : (String(k).slice(0, 6) + '…'), _ownedBy(state)[k]])),
25361
+ resolvedRunId: curId,
25362
+ }, null, 2));
25363
+ return;
25364
+ }
25250
25365
  log(`# leerness state (1.9.278, UR-0032) — .leerness/ 구조화 상태`);
25251
25366
  log(`project: ${state.project} · runs 누적: ${state.runCounter || 0} · 현재 run: ${curId || '(없음)'}`);
25252
25367
  if (rec) {
@@ -27765,6 +27880,9 @@ async function main() {
27765
27880
  return log(VERSION);
27766
27881
  }
27767
27882
  if (has('--help') || has('-h')) return help();
27883
+ // 1.36.144 (T-0117, 전수 실측): `--dry-run` 을 받았으면 쓰기를 막는다 — 구현한 명령은 애초에 안 쓰므로
27884
+ // 영향이 없고, 무시하던 명령은 첫 쓰기에서 멈춰 **아무것도 바꾸지 않은 채** 사유를 말한다.
27885
+ if (has('--dry-run')) setDryRunGuard(true);
27768
27886
  // 1.36.103: 실재하지 않는 플래그(오타)를 조용히 삼키지 않는다.
27769
27887
  // 값-플래그 미등록 45종은 _VALUE_FLAGS 등록으로 닫았지만, 사용자 오타(--prio, --resaon)는 여전히
27770
27888
  // 그 값이 본문에 붙는다(실측: `decision add "use redis" --resaon faster` → 저장된 제목 "use redis faster").
@@ -27854,6 +27972,37 @@ async function main() {
27854
27972
  }
27855
27973
  }
27856
27974
  }
27975
+ // 1.36.143 (전수 스윕): 위 가드는 **이름 목록**(_strict)이라 소비자를 못 따라갔다 — 검수는 3건을 지목했지만
27976
+ // 모든 명령에 없는 `--path` 를 주고 실측하니 **33건**이 그 경로를 만들었다(`.harness` 25 · `.leerness` 7 · 기타).
27977
+ // 그중 다수는 **성공이라 말하면서** 만든다(`provider add` · `team add` · `skill learn` · `glossary` · `wakeup-interval`).
27978
+ // `roles` · `decision drop` · `lesson drop` 을 개별 등록한 게 세 번째였다 — 네 번째를 만들지 않으려면 이름이 아니라
27979
+ // **성질**로 막아야 한다: 사용자가 `--path` 로 **명시한** 경로는 존재해야 한다. 부재가 정상 입력인 명령만 뺀다.
27980
+ // (위치인자 경로 해석은 명령마다 계약이 달라 건드리지 않는다 — 명시 플래그만 다룬다.)
27981
+ {
27982
+ const _PATH_MAY_BE_ABSENT = new Set(['init', 'migrate', 'migrate-workspace-dir', 'update', 'auto-update', 'adapter', 'ci']);
27983
+ const _p = arg('--path', null);
27984
+ // 1.36.144 (재검수 P1, 재현): `--path=` 는 빈 문자열(falsy), 값 없는 `--path` 는 boolean true 가 된다 —
27985
+ // 둘 다 이 가드를 그냥 지나쳐 **cwd 에 쓰고 성공을 보고**했다(실측: `decision add SNEAKY --path=` → ✓ 기록됨).
27986
+ // 플래그를 적었는데 값이 없으면 그건 오타다. 조용히 cwd 로 떨어뜨리지 않는다.
27987
+ const _pathTyped = process.argv.slice(2).some((t) => t === '--path' || (typeof t === 'string' && t.startsWith('--path=')));
27988
+ if (_pathTyped && (typeof _p !== 'string' || !_p.trim())) {
27989
+ const _en = _uiLang(process.cwd()) === 'en';
27990
+ failJson(has('--json'), 'path_missing_value', _en
27991
+ ? '--path was given without a value — nothing was done'
27992
+ : '--path 에 값이 없습니다 — 아무것도 하지 않았습니다');
27993
+ return;
27994
+ }
27995
+ if (_p && !_PATH_MAY_BE_ABSENT.has(cmd)) {
27996
+ const _abs = absRoot(_p);
27997
+ if (!exists(_abs)) {
27998
+ const _en = _uiLang(process.cwd()) === 'en';
27999
+ failJson(has('--json'), 'path_not_found', _en
28000
+ ? `path not found: ${_abs} — no directory was created`
28001
+ : `경로 없음: ${_abs} — 디렉토리를 새로 만들지 않았습니다`);
28002
+ return;
28003
+ }
28004
+ }
28005
+ }
27857
28006
  // 1.13.1 (15th 블라인드 리뷰 P1, codex gpt-5.5): 무명령 + 옵션만(예: leerness --json) 은 묵시적 init 쓰기를 하지 않음 — cwd 에 .harness 가 의도치 않게 생성되던 부작용 차단. 명시 명령 없이 옵션만이면 help (bare leerness 온보딩 init 은 유지).
27858
28007
  if (!args[0] && process.argv.slice(2).some(a => a.startsWith('-'))) { help(); return; }
27859
28008
  // 1.9.38 (B): 사용 통계 카운터 — usage stats 명령 자체와 비차단 경로는 제외
@@ -28815,6 +28964,12 @@ if (require.main === module) {
28815
28964
  main()
28816
28965
  .then(() => { if (process.exitCode && process.exitCode !== 0) process.exit(process.exitCode); })
28817
28966
  .catch(err => {
28967
+ // 1.36.144 (T-0117): dry-run 인데 쓰려다 멈춘 것은 크래시가 아니라 **계약 위반 보고**다.
28968
+ if (err && err.code === 'E_DRY_RUN_WRITE') {
28969
+ failJson(process.argv.includes('--json'), 'dry_run_unsupported',
28970
+ `이 명령은 --dry-run 을 구현하지 않았습니다 — ${err.file} 을(를) 쓰려다 멈췄습니다. 변경된 것은 없습니다(--dry-run 을 빼고 실행하세요).`);
28971
+ process.exit(1);
28972
+ }
28818
28973
  fail(err && err.message ? err.message : String(err));
28819
28974
  // 1.36.88: 완료 게이트는 upsertProgress 에서 던진다 — taskUpdate/taskSync 외의 경로로 올라와도
28820
28975
  // 스택 트레이스 대신 탈출구를 보여준다(막힌 사용자가 빠져나갈 방법을 알아야 한다).
package/lib/git.js CHANGED
@@ -30,7 +30,11 @@ const cp = require('child_process');
30
30
  // `not-a-repo` 라고 정직하게 답한다.
31
31
  const GIT_LOCATION_ENV = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR',
32
32
  'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_NAMESPACE',
33
- 'GIT_CONFIG', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_PARAMETERS'];
33
+ 'GIT_CONFIG', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_PARAMETERS',
34
+ // 1.36.143 (재검수 P1): `GIT_CONFIG_NOSYSTEM=1` 은 값을 주지 않고 **시스템 설정을 감춘다** —
35
+ // 시스템 `core.hooksPath` 가 있는 환경에서 우리만 그것을 못 보고 `.git/hooks` 에 설치하면,
36
+ // 사용자의 실제 커밋은 시스템 훅을 써서 강제가 **그냥 우회**된다. 같은 클래스다.
37
+ 'GIT_CONFIG_NOSYSTEM'];
34
38
  const _DROP = new Set(GIT_LOCATION_ENV);
35
39
  // `GIT_CONFIG_KEY_0`, `GIT_CONFIG_VALUE_0`, … 은 개수가 정해져 있지 않으므로 접두로 판별한다.
36
40
  const _DROP_PREFIX = /^GIT_CONFIG_(KEY|VALUE)_\d+$/;
package/lib/io.js CHANGED
@@ -118,7 +118,32 @@ function read(p) {
118
118
  return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
119
119
  }
120
120
  function readBuf(p) { return fs.readFileSync(p); }
121
- function mkdirp(p) { fs.mkdirSync(p, { recursive: true }); }
121
+ // 1.36.144 (T-0117, 전수 실측): `--dry-run` 은 **등록된 전역 플래그**인데 많은 명령이 그냥 무시하고 썼다.
122
+ // 378 호출을 전후 트리 비교로 재니 183건이 썼고, 그중 **28건이 사용자 데이터**를 바꿨다 —
123
+ // `session close --dry-run` 이 current-state·session-handoff·task-log 를 다시 쓰고,
124
+ // `task add` · `decision add` · `rule add` · `plan add` · `lesson save` · `state start` · `ci init` 은 **실제로 추가**했다.
125
+ // 명령마다 dry-run 분기를 심으면 16개의 새 실수 자리가 생긴다. 그래서 **쓰기 시점 하나**에서 막는다:
126
+ // dry-run 인데 쓰려 하면 그 자리에서 멈추고 무엇을 쓰려 했는지 말한다(조용히 쓰지도, 조용히 넘어가지도 않는다).
127
+ // ⚠ 락/임시 파일과 사용량 카운터는 사용자 데이터가 아니므로 면제한다 — 안 그러면 정직하게 dry-run 을
128
+ // 구현한 명령이 락을 잡는 것만으로 걸린다(가드가 정상 동작을 막으면 그건 새 결함이다).
129
+ let _dryGuard = false;
130
+ function setDryRunGuard(on) { _dryGuard = !!on; }
131
+ const _DRY_EXEMPT = /(\.lock$|\.tmp-\d+-\d+$|[\\/]cache[\\/])/;
132
+ function _dryCheck(p) {
133
+ if (!_dryGuard) return;
134
+ const s = String(p);
135
+ if (_DRY_EXEMPT.test(s)) return;
136
+ throw Object.assign(new Error(`--dry-run 인데 쓰려고 했습니다: ${s}`), { code: 'E_DRY_RUN_WRITE', file: s });
137
+ }
138
+ // ⚠ `mkdirp` 은 가드하지 **않는다**. 한 번 넣었다가 부작용을 재서 뺀다 —
139
+ // `_withLock` 이 락 디렉토리를 만드는 것까지 막혀 락 획득이 실패하고 **무보호 진행**으로 떨어졌다
140
+ // (실측: `state start --dry-run` → "락 획득 실패 — 보호 없이 진행합니다"). 빈 디렉토리는 사용자 데이터가
141
+ // 아니고, 의미 있는 쓰기는 전부 `writeUtf8`/`append` 를 지나므로 거기서 막는 것으로 충분하다.
142
+ // 디렉토리 생성도 막는다 — 빈 디렉토리라도 dry-run 이 남기면 "변경 없음" 이 거짓이 된다.
143
+ // ⚠ 단, **락 디렉토리는 예외**다. 한 번 전부 막았다가 `_withLock` 의 락 획득이 실패해
144
+ // **무보호 진행**으로 떨어졌다(실측). 락은 `mkdirpRaw` 로 우회한다 — 인프라지 사용자 데이터가 아니다.
145
+ function mkdirp(p) { _dryCheck(path.join(String(p), '_')); fs.mkdirSync(p, { recursive: true }); }
146
+ function mkdirpRaw(p) { fs.mkdirSync(p, { recursive: true }); }
122
147
  // 1.9.298 (UR-0038, 외부리뷰 3중수렴): 원자적 쓰기 — temp 파일에 기록 후 rename(원자 교체)으로 부분쓰기 손상 방지.
123
148
  // temp 이름은 .tmp-PID-SEQ 라 *.md glob 에 안 걸림. 실패 시 temp 정리.
124
149
  let _writeSeq = 0;
@@ -138,6 +163,7 @@ function _rescueCorruptJson(p, s) {
138
163
  try { fs.renameSync(p, bak); console.error(`⚠ 손상 JSON 감지 — 원본 대피 후 저장: ${path.basename(bak)} (수동 복구용)`); } catch {}
139
164
  }
140
165
  function writeUtf8(p, s) {
166
+ _dryCheck(p);
141
167
  // 1.36.65 (외부감사 F-08): 동일-내용 재기록 스킵 — 무변경 재설치/조정기의 mtime·diff churn 을 전역 해소.
142
168
  // 1.36.66 (8차 헌트 F13): 비교는 디코딩 텍스트가 아니라 "바이트 동일"로 — 손상 바이트(FF)/BOM 파일에
143
169
  // 유효 UTF-8 을 쓸 때 read() 의 BOM-strip·치환문자가 텍스트를 같게 보이게 해 정화 write 가 억제됐다.
@@ -175,7 +201,7 @@ function _renameWithRetry(from, to) {
175
201
  }
176
202
  }
177
203
  }
178
- function append(p, s) { mkdirp(path.dirname(p)); fs.appendFileSync(p, s, 'utf8'); }
204
+ function append(p, s) { _dryCheck(p); mkdirp(path.dirname(p)); fs.appendFileSync(p, s, 'utf8'); }
179
205
  function rel(root, p) { return path.relative(root, p).replace(/\\/g, '/') || '.'; }
180
206
 
181
- module.exports = { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel };
207
+ module.exports = { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel, setDryRunGuard, mkdirpRaw };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.141",
3
+ "version": "1.36.144",
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
@@ -1514,13 +1514,26 @@ total++;
1514
1514
  // init 후 가벼운 package.json 흉내 (release pack은 npm pack 시도하므로 dry-run으로 우회)
1515
1515
  cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
1516
1516
  fs.writeFileSync(path.join(tmpC, 'package.json'), JSON.stringify({ name: 'rp-test', version: '0.0.1' }), 'utf8');
1517
+ // ⚠ 1.36.144: 이 축의 **판정을 바꿨다**. 종전엔 `--dry-run` 인데 `/task added/` 를, 즉
1518
+ // **dry-run 이 실제로 progress-tracker 에 쓰는 것**을 단언하고 있었다. 그것이 T-0117 의 그 결함이다
1519
+ // (같은 실행이 "mode: dry-run" 과 "(dry-run) npm pack 스킵" 을 찍으면서 task 는 진짜로 추가했다).
1520
+ // dry 는 "하겠다" 고만 말해야 한다. 그리고 **플래그 없는 실행은 여전히 등록해야** 하므로 대조군으로 같이 잰다
1521
+ // (한쪽만 재면 "아무것도 안 하는 release pack" 이 통과한다).
1522
+ const _tracker = path.join(tmpC, '.harness', 'progress-tracker.md');
1523
+ const _before = (() => { try { return fs.readFileSync(_tracker, 'utf8'); } catch { return ''; } })();
1517
1524
  const r = cp.spawnSync(process.execPath, [CLI, 'release', 'pack', tmpC, '--dry-run', '--task-add', '1.9.40 e2e 검증', '--no-readme-sync'], { encoding: 'utf8', timeout: 30000 });
1525
+ const _afterDry = (() => { try { return fs.readFileSync(_tracker, 'utf8'); } catch { return ''; } })();
1526
+ const r2 = cp.spawnSync(process.execPath, [CLI, 'release', 'pack', tmpC, '--task-add', '1.9.40 e2e 검증', '--no-readme-sync'], { encoding: 'utf8', timeout: 60000 });
1527
+ const _afterReal = (() => { try { return fs.readFileSync(_tracker, 'utf8'); } catch { return ''; } })();
1528
+ const dryQuiet = _afterDry === _before && !/task added/.test(r.stdout) && /\(dry-run\) task add 스킵/.test(r.stdout);
1529
+ const realWrote = _afterReal !== _before && /task added/.test(r2.stdout);
1518
1530
  const ok = r.status === 0
1519
1531
  && /release pack \(1\.9\.40\)/.test(r.stdout)
1520
- && /task added/.test(r.stdout)
1521
- && /dry-run/.test(r.stdout);
1522
- console.log(ok ? '✓ B(1.9.40) release pack: --dry-run + --task-add 자동 등록' : `✗ release pack 실패`);
1523
- if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
1532
+ && /dry-run/.test(r.stdout)
1533
+ && dryQuiet && realWrote;
1534
+ console.log(ok ? '✓ B(1.9.40/1.36.144) release pack: --dry-run 은 task 를 **쓰지 않고 말만** 하고, 플래그 없는 실행은 그대로 등록(대조군)'
1535
+ : `✗ release pack 실패 ${JSON.stringify({ dryQuiet, realWrote, dryExit: r.status, realExit: r2.status })}`);
1536
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 400)); console.log('--- real ---'); console.log(r2.stdout.slice(0, 400)); }
1524
1537
  }
1525
1538
 
1526
1539
  total++;
@@ -6103,18 +6116,26 @@ total++;
6103
6116
  if (!ok) failed++;
6104
6117
  }
6105
6118
 
6106
- // 1.9.410 회귀 (8번째 버그헌트, UR-0114): 값 없는 --path (boolean true) 가 raw TypeError 크래시 안 함(cwd 폴백)
6119
+ // 1.9.410 회귀 (8번째 버그헌트, UR-0114): 값 없는 --path (boolean true) 가 raw TypeError 로 크래시하지 않는다.
6120
+ // ⚠ 1.36.144 에서 이 축의 **판정을 바꿨다**. 종전엔 "cwd 로 폴백해 정상 실행" 을 단언했는데,
6121
+ // 그 폴백이 바로 이번에 고친 결함이다 — `decision add SNEAKY --path=` 가 cwd 에 쓰고 "✓ 기록됨" 을 냈다(실측).
6122
+ // 플래그를 적었는데 값이 없으면 그건 오타다. 원래 목적(**raw TypeError 를 흘리지 않는다**)은 그대로 두고,
6123
+ // "조용한 폴백" 대신 "사유를 말하는 거절" 을 단언한다. 그리고 값을 준 경우가 여전히 도는지 대조군으로 잰다.
6107
6124
  total++;
6108
6125
  {
6109
6126
  let ok = false;
6110
6127
  try {
6111
6128
  const r = cp.spawnSync(process.execPath, [CLI, 'status', '--path'], { encoding: 'utf8', timeout: 15000 });
6112
6129
  const out = (r.stdout || '') + (r.stderr || '');
6113
- const noCrash = !/paths\[0\].*argument must be of type string|Received type boolean/.test(out); // raw TypeError 누출 안 됨
6114
- const ran = /Leerness:/.test(out) || /Files:/.test(out); // cwd 로 폴백해 정상 실행
6115
- ok = noCrash && ran;
6130
+ const noCrash = !/paths\[0\].*argument must be of type string|Received type boolean|at Object\.<anonymous>/.test(out);
6131
+ const saysWhy = /--path 에 값이 없습니다|--path was given without a value/.test(out);
6132
+ const refused = r.status !== 0;
6133
+ // 대조군 — 값을 준 `--path` 는 그대로 동작해야 한다(거절이 과차단으로 번지면 여기서 잡힌다).
6134
+ const r2 = cp.spawnSync(process.execPath, [CLI, 'status', '--path', tmp], { encoding: 'utf8', timeout: 15000 });
6135
+ const stillWorks = /Leerness:|Files:/.test((r2.stdout || '') + (r2.stderr || ''));
6136
+ ok = noCrash && saysWhy && refused && stillWorks;
6116
6137
  } catch {}
6117
- console.log(ok ? '✓ B(1.9.410) 8th버그헌트: 값없는 --path raw TypeError 차단(cwd 폴백) (UR-0114)' : '✗ --path 크래시 가드 실패');
6138
+ console.log(ok ? '✓ B(1.9.410/1.36.144) 값없는 --path: raw TypeError 없이 **사유를 말하는 거절**(조용한 cwd 폴백 제거) + 값 준 경우 무회귀 (UR-0114)' : '✗ --path 크래시 가드 실패');
6118
6139
  if (!ok) failed++;
6119
6140
  }
6120
6141
 
@@ -14288,21 +14309,30 @@ total++;
14288
14309
  if (restored.length !== 1) bad.push(`③복원이 ${restored.length}건을 만듦(기대 1)`);
14289
14310
  dbg.dupDrop = { exits: codes, archiveBlocks: blocks, restoredCount: restored.length };
14290
14311
  }
14291
- // ④ 없는 경로의 **쓰기**는 거절하고 디렉토리를 만들지 않는다 —
14292
- // 그러나 **조회는 그대로 돌아야 한다**. 첫 판은 검사를 명령 진입부에 둬서
14293
- // 없는 경로에서도 잘 돌던 `roles catalog`(정적) · `roles list`(읽기) 를 exit 1 로 만들었다(회귀).
14294
- // 한쪽만 재면 과차단으로 되돌아가도 통과한다 — 양쪽을 같이 잰다.
14312
+ // ④ 없는 경로는 **아무것도 만들지 않고 거절**한다 — 그러나 **동작하던 사용법은 깨지 않는다**.
14313
+ // ⚠ 이 축은 한 번 뒤집혔다. 처음엔 "없는 경로에서도 `roles catalog`/`list` 는 exit 0 이어야 한다" 고 적었다 —
14314
+ // 1.36.139 가 그랬기 때문이다. 그런데 1.36.143 이 `--path` 규칙을 **모든 명령에 일관되게** 적용하자
14315
+ // 이 단언이 게이트를 깨뜨렸다. 다시 재보니 옛 동작이 지키던 것은 **오타를 조용히 성공시키는 것**이었다:
14316
+ // `--path` 없음 → 동작(경로 무관 사용) · `--path <실재>` → 동작 · `--path <오타>` → exit 0 (조용한 성공)
14317
+ // 마지막 칸만 거절로 바뀌었고 그게 맞다. 그래서 축을 "오타에도 성공" 이 아니라
14318
+ // **"실제로 쓰던 두 사용법이 살아 있다"** 로 다시 쓴다 — 과차단 보호는 그대로 유지된다.
14295
14319
  {
14296
14320
  const ghost = path.join(sb, 'ghost-parent', 'nope');
14297
14321
  const w1 = RP(sb, ['roles', 'unset', 'coder', '--path', ghost]);
14298
14322
  const w2 = RP(sb, ['roles', 'set', 'coder', '--provider', 'codex', '--force', '--path', ghost]);
14299
14323
  const created = fs.existsSync(ghost);
14300
- const rd = ['catalog', 'list'].map((s) => RP(sb, ['roles', s, '--path', ghost]).status);
14324
+ const r1 = RP(sb, ['roles', 'catalog', '--path', ghost]);
14325
+ // 과차단 대조군 — 경로를 안 주는 사용과 실재 경로를 주는 사용은 **둘 다 살아 있어야 한다**.
14326
+ const real = mk('roles-real');
14327
+ const noPath = RP(real, ['roles', 'catalog']).status;
14328
+ const realPath = ['catalog', 'list'].map((s) => RP(real, ['roles', s, '--path', real]).status);
14301
14329
  if (w1.status === 0) bad.push('④없는 경로인데 unset 이 성공이라 보고');
14302
14330
  if (w2.status === 0) bad.push('④없는 경로인데 set 이 성공이라 보고');
14303
14331
  if (created) bad.push('④실패하면서 없는 경로를 만들었다');
14304
- if (rd.some((s) => s !== 0)) bad.push(`④조회 하위명령까지 막았다(catalog/list exit=${JSON.stringify(rd)}) — 과차단 회귀`);
14305
- dbg.ghostPath = { unset: w1.status, set: w2.status, created, readExits: rd };
14332
+ if (r1.status === 0) bad.push('④오타 난 --path 를 조용히 성공시킴');
14333
+ if (noPath !== 0) bad.push(`④--path 없는 사용을 막았다(exit=${noPath}) — 과차단 회귀`);
14334
+ if (realPath.some((s) => s !== 0)) bad.push(`④실재 경로 조회를 막았다(exit=${JSON.stringify(realPath)}) — 과차단 회귀`);
14335
+ dbg.ghostPath = { unset: w1.status, set: w2.status, created, ghostRead: r1.status, noPath, realPath };
14306
14336
  }
14307
14337
  // ⑤ 한 번짜리 설정 주입(`GIT_CONFIG_COUNT`)이 **남의 저장소**에 훅을 쓰지 못한다 + 지목한 저장소에는 쓴다.
14308
14338
  {
@@ -14348,6 +14378,389 @@ total++;
14348
14378
  if (!ok) failed++;
14349
14379
  }
14350
14380
 
14381
+ total++;
14382
+ // AF(1.36.142/T-0116) — **증거는 일한 세션에 붙어야 한다.**
14383
+ // `.leerness/state.json` 의 `currentRunId` 는 전역 슬롯 하나였다. 두 세션이 같은 프로젝트에서 일하면
14384
+ // 뒤에 `state start` 한 쪽이 앞사람 슬롯을 덮어썼고, 실측으로:
14385
+ // · A 의 증거(files_changed · tests)가 **B 의 run 에 붙었고**
14386
+ // · 일하지 않은 B 가 `completion_claim_allowed: true` 를 받았고
14387
+ // · A 는 `state show` 로 **자기 run 을 볼 수조차 없었다**.
14388
+ // 락 문제가 아니다(직렬로 돌려도 재현된다) — 주소가 없는 것이 문제다.
14389
+ {
14390
+ let ok = false; const bad = []; const dbg = {};
14391
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-142-'));
14392
+ const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
14393
+ for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
14394
+ 'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
14395
+ 'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG']) delete envP[k];
14396
+ const R = (d, env, a) => cp.spawnSync(process.execPath, [CLI, ...a],
14397
+ { cwd: d, encoding: 'utf8', timeout: 300000, env, maxBuffer: 32 * 1024 * 1024 });
14398
+ const J = (r) => { try { return JSON.parse(String(r.stdout || '')); } catch { return null; } };
14399
+ const mk = (name) => {
14400
+ const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
14401
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
14402
+ const ri = R(d, envP, ['init', d, '--yes', '--minimal']);
14403
+ if (ri.status !== 0) bad.push(`${name}:init exit=${ri.status}`);
14404
+ return d;
14405
+ };
14406
+ // ⚠ 세션 키는 제품의 유효성 규칙을 통과해야 한다 — 짧은 이름은 `deriveSessionKey` 가 null 을 준다.
14407
+ // (한 번 그렇게 재서 "제품이 아니라 픽스처" 를 측정했다. 여기서 먼저 확인하고 시작한다.)
14408
+ const SP = require(path.resolve(__dirname, '..', 'lib', 'session-presence'));
14409
+ const KA = 'session-alpha01', KB = 'session-bravo01';
14410
+ if (!SP.deriveSessionKey({ LEERNESS_SESSION_ID: KA }) || !SP.deriveSessionKey({ LEERNESS_SESSION_ID: KB })) {
14411
+ bad.push('픽스처 세션 키가 무효 — 이 블록은 아무것도 재지 못한다');
14412
+ }
14413
+ const eA = Object.assign({}, envP, { LEERNESS_SESSION_ID: KA });
14414
+ const eB = Object.assign({}, envP, { LEERNESS_SESSION_ID: KB });
14415
+ try {
14416
+ // ① 증거 귀속 + 완료 게이트 — A 가 일하고 B 는 놀았다.
14417
+ {
14418
+ const d = mk('attrib');
14419
+ const sA = J(R(d, eA, ['state', 'start', 'A 목표', '--agent', 'agentA', '--json']));
14420
+ const sB = J(R(d, eB, ['state', 'start', 'B 목표', '--agent', 'agentB', '--json']));
14421
+ const rA = J(R(d, eA, ['state', 'record', '--files-changed', 'a.js', '--tests', 'npm test: 10/10', '--json']));
14422
+ const vB = J(R(d, eB, ['state', 'verify', '--result', 'pass', '--json']));
14423
+ if (!sA || !sB || sA.started === sB.started) bad.push('①두 세션이 같은 run 을 받았다 — 판별 불가');
14424
+ if (!rA || rA.recorded !== (sA && sA.started)) bad.push(`①A 의 증거가 A 의 run 이 아닌 곳에 붙음(${rA && rA.recorded})`);
14425
+ const cca = vB && vB.completion_claim_allowed && vB.completion_claim_allowed.allowed;
14426
+ if (cca) bad.push('①일하지 않은 세션이 완료 허용을 받았다 — 증거 게이트가 뒤집힘');
14427
+ const showA = J(R(d, eA, ['state', 'show', '--json']));
14428
+ if (!showA || showA.resolvedRunId !== (sA && sA.started)) bad.push(`①A 가 자기 run 을 못 본다(${showA && showA.resolvedRunId})`);
14429
+ dbg.attrib = { A: sA && sA.started, B: sB && sB.started, recordedTo: rA && rA.recorded, bAllowed: !!cca, aSees: showA && showA.resolvedRunId };
14430
+ }
14431
+ // ② 남의 run 에 조용히 쓰지 않고 **사유를 말한다**(증거는 그대로 남는다).
14432
+ {
14433
+ const d = mk('refuse');
14434
+ R(d, eA, ['state', 'start', 'A 목표', '--json']);
14435
+ const rB = R(d, eB, ['state', 'record', '--files-changed', 'stolen.js']);
14436
+ const msg = String(rB.stdout || '') + String(rB.stderr || '');
14437
+ let runA = null; try { runA = JSON.parse(fs.readFileSync(path.join(d, '.leerness', 'runs', 'run-0001.json'), 'utf8')); } catch {}
14438
+ if (rB.status === 0) bad.push('②남의 run 인데 성공이라 보고');
14439
+ if (!/다른 세션이 진행 중/.test(msg)) bad.push('②거절 사유를 말하지 않음');
14440
+ if (!runA || runA.files_changed.length !== 0) bad.push('②거절했는데 남의 증거가 오염됨');
14441
+ dbg.refuse = { exit: rB.status, saysWhy: /다른 세션이 진행 중/.test(msg), aFiles: runA && runA.files_changed.length };
14442
+ }
14443
+ // ③ 남의 handoff 가 내 run 을 끊지 않는다.
14444
+ {
14445
+ const d = mk('release');
14446
+ R(d, eA, ['state', 'start', 'A', '--json']);
14447
+ R(d, eB, ['state', 'start', 'B', '--json']);
14448
+ R(d, eB, ['state', 'handoff', 'B 마감', '--json']);
14449
+ const aShow = J(R(d, eA, ['state', 'show', '--json']));
14450
+ const aRec = J(R(d, eA, ['state', 'record', '--files-changed', 'a2.js', '--json']));
14451
+ if (!aShow || aShow.resolvedRunId !== 'run-0001') bad.push(`③남의 handoff 뒤 내 run 이 사라짐(${aShow && aShow.resolvedRunId})`);
14452
+ if (!aRec || aRec.recorded !== 'run-0001') bad.push(`③남의 handoff 뒤 기록 불가(${aRec && aRec.recorded})`);
14453
+ dbg.release = { aResolved: aShow && aShow.resolvedRunId, aRecorded: aRec && aRec.recorded };
14454
+ }
14455
+ // ④ **대조군 — 기존 단일 사용자(세션 키 없음)는 종전 그대로.**
14456
+ // 한쪽만 재면 "주소 없는 사람을 막아 버리는" 회귀가 통과한다.
14457
+ {
14458
+ const d = mk('single');
14459
+ const st = J(R(d, envP, ['state', 'start', '목표', '--json']));
14460
+ const rc = J(R(d, envP, ['state', 'record', '--files-changed', 'x.js', '--tests', 'npm test: 3/3', '--json']));
14461
+ const vf = J(R(d, envP, ['state', 'verify', '--result', 'pass', '--json']));
14462
+ const ho = J(R(d, envP, ['state', 'handoff', '요약', '--json']));
14463
+ const after = J(R(d, envP, ['state', 'show', '--json']));
14464
+ const chain = [rc && rc.recorded, vf && vf.verified, ho && ho.handoff];
14465
+ if (!st || chain.some((x) => x !== st.started)) bad.push(`④세션키 없는 단일 흐름이 깨짐(${JSON.stringify(chain)})`);
14466
+ if (!after || after.resolvedRunId !== null) bad.push(`④handoff 뒤에도 run 이 남음(${after && after.resolvedRunId})`);
14467
+ if (!vf || !vf.completion_claim_allowed || vf.completion_claim_allowed.allowed !== true) {
14468
+ bad.push('④일한 단일 사용자가 완료 허용을 못 받음 — 게이트가 과차단');
14469
+ }
14470
+ dbg.single = { started: st && st.started, chain, after: after && after.resolvedRunId,
14471
+ allowed: vf && vf.completion_claim_allowed && vf.completion_claim_allowed.allowed };
14472
+ }
14473
+ // ⑤ 업그레이드 이전 상태(전역 슬롯만)를 **인수**한다 — 쓰던 사람이 멈추지 않는다.
14474
+ {
14475
+ const d = mk('migrate');
14476
+ R(d, envP, ['state', 'start', '업그레이드 전', '--json']);
14477
+ const stFile = path.join(d, '.leerness', 'state.json');
14478
+ const before = JSON.parse(fs.readFileSync(stFile, 'utf8'));
14479
+ delete before.runsBySession;
14480
+ fs.writeFileSync(stFile, JSON.stringify(before, null, 2) + '\n');
14481
+ const rc = J(R(d, eA, ['state', 'record', '--files-changed', 'y.js', '--json']));
14482
+ if (!rc || rc.recorded !== before.currentRunId) bad.push(`⑤예전 run 을 인수하지 못함(${rc && rc.recorded} vs ${before.currentRunId})`);
14483
+ dbg.migrate = { old: before.currentRunId, recorded: rc && rc.recorded };
14484
+ }
14485
+ // ⑥ 무효한 명시 주소를 **조용히 무시하지 않는다**(무시하면 전역 슬롯으로 떨어져 이 버그가 되살아난다).
14486
+ {
14487
+ const d = mk('badkey');
14488
+ const eBad = Object.assign({}, envP, { LEERNESS_SESSION_ID: 'x' });
14489
+ const r = R(d, eBad, ['state', 'start', '목표']);
14490
+ const msg = String(r.stdout || '') + String(r.stderr || '');
14491
+ if (SP.deriveSessionKey({ LEERNESS_SESSION_ID: 'x' })) bad.push('⑥픽스처 키가 유효해져 이 축이 공허해졌다');
14492
+ else if (!/형식에 맞지 않아 무시/.test(msg)) bad.push('⑥무효한 세션 주소를 조용히 무시');
14493
+ dbg.badKey = { warned: /형식에 맞지 않아 무시/.test(msg) };
14494
+ }
14495
+ ok = bad.length === 0;
14496
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
14497
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
14498
+ console.log(ok ? `✓ AF(1.36.142/T-0116) 증거 귀속: 두 세션이 같은 프로젝트에서 일해도 증거가 **일한 세션**에 붙고 놀던 세션은 완료 허용을 못 받음 · 남의 run 거절+사유 · 남의 handoff 가 내 run 을 안 끊음 · 세션키 없는 기존 흐름 무회귀 · 예전 상태 인수 · 무효 주소 고지 ${JSON.stringify(dbg)}`
14499
+ : '✗ 1.36.142 증거 귀속 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
14500
+ if (!ok) failed++;
14501
+ }
14502
+
14503
+ total++;
14504
+ // AG(1.36.143) — **없는 `--path` 는 아무것도 만들지 않는다** + 공유 판정은 추정하지 않는다.
14505
+ // 검수는 `provider add` · `team add` · `creds register` 3건을 지목했다. 이름을 더 넣는 대신
14506
+ // 모든 명령에 없는 `--path` 를 주고 전수로 재니 **33건**이 그 경로를 만들었다 —
14507
+ // 다수는 **성공이라 말하면서**(`provider add` · `team add` · `skill learn` · `glossary` · `wakeup-interval`),
14508
+ // 그중엔 직전 라운드에 내가 넣은 `state start` 도 있었다. 개별 등록은 이미 세 번째였다.
14509
+ // 그래서 이름 목록이 아니라 **성질**로 막고, 여기서도 이름이 아니라 **열거**로 잰다.
14510
+ {
14511
+ let ok = false; const bad = []; const dbg = {};
14512
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-143-'));
14513
+ const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
14514
+ for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
14515
+ 'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
14516
+ 'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG',
14517
+ 'GIT_DIR', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_NOSYSTEM']) delete envP[k];
14518
+ envP.GIT_CONFIG_GLOBAL = path.join(sb, 'gitconfig-global');
14519
+ envP.GIT_CONFIG_SYSTEM = path.join(sb, 'gitconfig-system');
14520
+ try { fs.writeFileSync(envP.GIT_CONFIG_GLOBAL, ''); fs.writeFileSync(envP.GIT_CONFIG_SYSTEM, ''); } catch {}
14521
+ const G = (d, a) => cp.spawnSync('git', a, { cwd: d, encoding: 'utf8', timeout: 60000, env: envP });
14522
+ const RP = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a],
14523
+ { cwd: d, encoding: 'utf8', timeout: 60000, env: envP, maxBuffer: 32 * 1024 * 1024 });
14524
+ const J = (r) => { try { return JSON.parse(String(r.stdout || '')); } catch { return null; } };
14525
+ const cwd = path.join(sb, 'cwd'); fs.mkdirSync(cwd, { recursive: true });
14526
+ try {
14527
+ // ① 전수 — 제품 자신에게 명령 목록을 묻고, 하위명령까지 펴서 없는 `--path` 로 부른다.
14528
+ {
14529
+ let usages = [];
14530
+ const lr = RP(cwd, ['commands', '--json']);
14531
+ try {
14532
+ const collect = (v) => {
14533
+ if (Array.isArray(v)) for (const x of v) collect(x);
14534
+ else if (v && typeof v === 'object') { if (typeof v.cmd === 'string') usages.push(v.cmd); else if (typeof v.name === 'string') usages.push(v.name); else for (const k of Object.keys(v)) collect(v[k]); }
14535
+ else if (typeof v === 'string') usages.push(v);
14536
+ };
14537
+ collect(JSON.parse(lr.stdout));
14538
+ } catch (e) { bad.push('①명령 목록을 얻지 못함: ' + String(e.message).slice(0, 60)); }
14539
+ // 부재가 정상 입력인 명령(경로를 만드는 것이 그 일)은 대상이 아니다.
14540
+ const CREATORS = /^(init|migrate|migrate-workspace-dir|update|auto-update|adapter|ci)\b/;
14541
+ const invocations = []; const seen = new Set();
14542
+ for (const raw of Array.from(new Set(usages))) {
14543
+ const u = String(raw).replace(/^leerness\s+/, '').trim();
14544
+ if (!u || CREATORS.test(u)) continue;
14545
+ const toks = u.split(/\s+/); const head = toks[0];
14546
+ if (!/^[a-z][a-z0-9-]*$/.test(head)) continue;
14547
+ const second = toks[1] || '';
14548
+ let subs = [null];
14549
+ if (second && second.includes('|') && !/^[[<]/.test(second)) subs = second.split('|').map((x) => x.replace(/[[\]<>"]/g, '')).filter((x) => /^[a-z][a-z0-9-]*$/.test(x));
14550
+ else if (second && /^[a-z][a-z0-9-]*$/.test(second)) subs = [second];
14551
+ for (const sub of subs) {
14552
+ for (const extra of [[], ['zzprobe']]) {
14553
+ const argv = sub ? [head, sub, ...extra] : [head, ...extra];
14554
+ const label = argv.join(' ');
14555
+ if (!seen.has(label)) { seen.add(label); invocations.push({ label, argv }); }
14556
+ }
14557
+ }
14558
+ }
14559
+ // 열거가 붕괴하면 "0건" 은 아무 뜻이 없다 — 크기를 함께 단언한다.
14560
+ if (invocations.length < 100) bad.push(`①열거가 ${invocations.length}건 — 계측 붕괴(0건 통과가 무의미해진다)`);
14561
+ const leaks = [];
14562
+ for (let i = 0; i < invocations.length; i++) {
14563
+ const ghost = path.join(sb, 'g' + i, 'nope');
14564
+ const r = RP(cwd, [...invocations[i].argv, '--path', ghost]);
14565
+ if (fs.existsSync(ghost)) leaks.push(`${invocations[i].label}(exit=${r.status})`);
14566
+ }
14567
+ if (leaks.length) bad.push(`①없는 경로를 만든 호출 ${leaks.length}건: ${leaks.slice(0, 6).join(' · ')}`);
14568
+ dbg.ghostSweep = { invocations: invocations.length, leaks: leaks.length };
14569
+ }
14570
+ // ② 대조군 — 존재하는 프로젝트에서는 그대로 동작해야 한다(과차단으로 통과하면 안 된다).
14571
+ {
14572
+ const d = path.join(sb, 'real'); fs.mkdirSync(d, { recursive: true });
14573
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
14574
+ RP(d, ['init', d, '--yes', '--minimal']);
14575
+ const exits = ['handoff', 'status', 'audit'].map((c) => RP(d, [c, '--path', d]).status);
14576
+ if (exits.some((x) => x !== 0)) bad.push(`②정상 프로젝트에서 조회가 막힘(exits=${JSON.stringify(exits)})`);
14577
+ dbg.realProject = { exits };
14578
+ }
14579
+ // ③ worktree 형제 차단을 **추정하지 않고 각 worktree 에게 묻는다**.
14580
+ {
14581
+ const mkRepo = (name) => {
14582
+ const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
14583
+ G(d, ['init', '-q', '-b', 'main', '.']); G(d, ['config', 'user.email', 't@t']); G(d, ['config', 'user.name', 't']);
14584
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
14585
+ fs.writeFileSync(path.join(d, 'a.txt'), 'a'); G(d, ['add', '-A']); G(d, ['commit', '-qm', 'i']);
14586
+ RP(d, ['init', d, '--yes', '--minimal']); RP(d, ['handoff', d]);
14587
+ return d;
14588
+ };
14589
+ const m1 = mkRepo('wt-shared');
14590
+ const add1 = G(m1, ['worktree', 'add', '-qb', 'lk', path.join(sb, 'wt-shared-linked')]);
14591
+ const j1 = J(RP(m1, ['enforce', 'install', '--path', m1, '--json', '--skip-verify']));
14592
+ const m2 = mkRepo('wt-own');
14593
+ G(m2, ['config', 'extensions.worktreeConfig', 'true']);
14594
+ const add2 = G(m2, ['worktree', 'add', '-qb', 'lk2', path.join(sb, 'wt-own-linked')]);
14595
+ fs.mkdirSync(path.join(m2, '.git', 'wt-hooks'), { recursive: true });
14596
+ const cfg = G(m2, ['config', '--worktree', 'core.hooksPath', '.git/wt-hooks']);
14597
+ const j2 = J(RP(m2, ['enforce', 'status', '--path', m2, '--json']));
14598
+ if (add1.status !== 0 || add2.status !== 0 || cfg.status !== 0) bad.push('③worktree 픽스처를 만들지 못함 — 판별 불가');
14599
+ else {
14600
+ if (!j1 || j1.worktrees !== 2) bad.push(`③공용 훅인데 형제 차단을 말하지 않음(worktrees=${j1 && j1.worktrees})`);
14601
+ if (!j2 || j2.worktrees !== 1) bad.push(`③worktree 전용 훅인데 형제도 막힌다고 함(worktrees=${j2 && j2.worktrees})`);
14602
+ }
14603
+ dbg.worktree = { shared: j1 && j1.worktrees, perWorktree: j2 && j2.worktrees };
14604
+ }
14605
+ // ④ 세척 클래스의 **경계** — 지워야 할 것과 남겨야 할 것을 같이 잰다(한쪽만 재면 과세척이 통과한다).
14606
+ {
14607
+ const g = require(path.resolve(__dirname, '..', 'lib', 'git.js'));
14608
+ const mustDrop = ['GIT_DIR', 'git_dir', 'GIT_CONFIG_NOSYSTEM', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_7', 'GIT_CONFIG_VALUE_7'];
14609
+ const mustKeep = ['GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CEILING_DIRECTORIES', 'GIT_AUTHOR_NAME', 'GIT_SSH_COMMAND', 'GIT_EDITOR'];
14610
+ const droppedNot = mustDrop.filter((k) => !g._shouldDrop(k));
14611
+ const keptNot = mustKeep.filter((k) => g._shouldDrop(k));
14612
+ if (droppedNot.length) bad.push(`④세척해야 하는데 안 함: ${droppedNot.join(', ')}`);
14613
+ if (keptNot.length) bad.push(`④남겨야 하는데 세척함(과세척): ${keptNot.join(', ')}`);
14614
+ dbg.scrub = { droppedNot, keptNot, total: g.GIT_LOCATION_ENV.length };
14615
+ }
14616
+ ok = bad.length === 0;
14617
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
14618
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
14619
+ console.log(ok ? `✓ AG(1.36.143) 없는 --path 는 아무것도 만들지 않음(전수 열거, 고치기 전 33건) · 정상 프로젝트 무회귀 · worktree 형제 차단을 추정 대신 **각 worktree 에게 물어** 판정 · 세척 클래스 경계(지울 것/남길 것 양방향) ${JSON.stringify(dbg)}`
14620
+ : '✗ 1.36.143 경로/공유 판정 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
14621
+ if (!ok) failed++;
14622
+ }
14623
+
14624
+ total++;
14625
+ // AH(1.36.144/T-0117) — **`--dry-run` 은 아무것도 쓰지 않는다** + 재검수 P1/P2.
14626
+ // `--dry-run` 은 등록된 전역 플래그인데 많은 명령이 그냥 무시하고 썼다. 378 호출을 전후 트리 비교로 재니
14627
+ // 183건이 썼고 그중 28건이 **사용자 데이터**를 바꿨다(`session close` 가 실제 마감을 수행, `task/decision/rule/plan add`
14628
+ // 가 실제로 추가). 명령마다 분기를 심으면 16개의 새 실수 자리가 생기므로 **쓰기 시점 하나**에서 막는다.
14629
+ {
14630
+ let ok = false; const bad = []; const dbg = {};
14631
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-144-'));
14632
+ const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
14633
+ for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
14634
+ 'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
14635
+ 'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG',
14636
+ 'GIT_DIR', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_NOSYSTEM']) delete envP[k];
14637
+ envP.GIT_CONFIG_GLOBAL = path.join(sb, 'gcg'); envP.GIT_CONFIG_SYSTEM = path.join(sb, 'gcs');
14638
+ try { fs.writeFileSync(envP.GIT_CONFIG_GLOBAL, ''); fs.writeFileSync(envP.GIT_CONFIG_SYSTEM, ''); } catch {}
14639
+ const G = (d, a) => cp.spawnSync('git', a, { cwd: d, encoding: 'utf8', timeout: 60000, env: envP });
14640
+ const RP = (d, a, env) => cp.spawnSync(process.execPath, [CLI, ...a],
14641
+ { cwd: d, encoding: 'utf8', timeout: 60000, env: env || envP, maxBuffer: 32 * 1024 * 1024 });
14642
+ const J = (r) => { try { return JSON.parse(String(r.stdout || '')); } catch { return null; } };
14643
+ const snap = (d) => {
14644
+ const out = {};
14645
+ const walk = (q) => {
14646
+ let ents = []; try { ents = fs.readdirSync(q, { withFileTypes: true }); } catch { return; }
14647
+ for (const e of ents) {
14648
+ const full = path.join(q, e.name);
14649
+ if (e.isDirectory()) { out[path.relative(d, full) + '/'] = 'DIR'; walk(full); }
14650
+ else { let h = 'ERR'; try { h = String(fs.readFileSync(full).length); } catch {} out[path.relative(d, full)] = h; }
14651
+ }
14652
+ };
14653
+ walk(d); return out;
14654
+ };
14655
+ const diffKeys = (a, b) => Array.from(new Set([...Object.keys(a), ...Object.keys(b)])).filter((k) => a[k] !== b[k]);
14656
+ const mkGit = (name) => {
14657
+ const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
14658
+ G(d, ['init', '-q', '-b', 'main', '.']); G(d, ['config', 'user.email', 't@t']); G(d, ['config', 'user.name', 't']);
14659
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
14660
+ fs.writeFileSync(path.join(d, 'a.txt'), 'a'); G(d, ['add', '-A']); G(d, ['commit', '-qm', 'i']);
14661
+ RP(d, ['init', d, '--yes', '--minimal']); RP(d, ['handoff', d]);
14662
+ return d;
14663
+ };
14664
+ try {
14665
+ // ① 실제로 썼던 명령들이 이제 **아무것도 바꾸지 않고** 거절한다 + 실행하면 동작한다(대조군).
14666
+ {
14667
+ const cases = [['task', 'add', '작업'], ['decision', 'add', '결정', '--reason', 'r'], ['rule', 'add', '룰'],
14668
+ ['plan', 'add', '계획'], ['lesson', 'save', '교훈'], ['session', 'close'], ['state', 'start', '목표']];
14669
+ const wrote = [], notRefused = [];
14670
+ for (let i = 0; i < cases.length; i++) {
14671
+ const d = mkGit('dry' + i);
14672
+ const before = snap(d);
14673
+ const r = RP(d, [...cases[i], '--dry-run', '--path', d]);
14674
+ const changed = diffKeys(before, snap(d));
14675
+ if (changed.length) wrote.push(`${cases[i].join(' ')}→${changed.slice(0, 3).join(',')}`);
14676
+ if (r.status === 0) notRefused.push(cases[i].join(' '));
14677
+ }
14678
+ if (wrote.length) bad.push(`①--dry-run 인데 바뀐 것: ${wrote.join(' · ')}`);
14679
+ if (notRefused.length) bad.push(`①--dry-run 을 구현 안 했는데 성공이라 보고: ${notRefused.join(' · ')}`);
14680
+ // 대조군 — 같은 명령을 플래그 없이 돌리면 **실제로 동작해야** 한다(과차단으로 통과하면 안 된다).
14681
+ const d2 = mkGit('real');
14682
+ const okExits = [RP(d2, ['task', 'add', '작업', '--path', d2]).status,
14683
+ RP(d2, ['decision', 'add', '결정', '--reason', 'r', '--path', d2]).status];
14684
+ if (okExits.some((x) => x !== 0)) bad.push(`①플래그 없는 정상 실행이 막힘(exits=${JSON.stringify(okExits)})`);
14685
+ dbg.dryRun = { wrote: wrote.length, notRefused: notRefused.length, realExits: okExits };
14686
+ }
14687
+ // ② `--path=` / 값 없는 `--path` 가 가드를 우회해 cwd 에 쓰지 않는다.
14688
+ {
14689
+ const d = mkGit('emptypath');
14690
+ const before = snap(d);
14691
+ const r1 = RP(d, ['decision', 'add', 'SNEAKY1', '--reason', 'r', '--path=']);
14692
+ const r2 = RP(d, ['decision', 'add', 'SNEAKY2', '--reason', 'r', '--path']);
14693
+ const changed = diffKeys(before, snap(d));
14694
+ if (r1.status === 0 || r2.status === 0) bad.push(`②빈 --path 인데 성공이라 보고(${r1.status}/${r2.status})`);
14695
+ if (changed.length) bad.push(`②빈 --path 가 cwd 에 씀: ${changed.slice(0, 3).join(',')}`);
14696
+ dbg.emptyPath = { exits: [r1.status, r2.status], changed: changed.length };
14697
+ }
14698
+ // ③ MCP `release_cleanup` 이 **요청한 저장소**만 건드린다(서버 cwd 가 아니라).
14699
+ {
14700
+ const victim = mkGit('mcp-victim');
14701
+ for (let i = 0; i < 60; i++) G(victim, ['branch', 'release/1.0.' + i]);
14702
+ const target = mkGit('mcp-target');
14703
+ const cnt = () => (G(victim, ['branch', '--list', 'release/*']).stdout || '').split('\n').filter((l) => l.trim()).length;
14704
+ const before = cnt();
14705
+ const req = [
14706
+ JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '1' } } }),
14707
+ JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'leerness_release_cleanup', arguments: { path: target, apply: true, keep: 10 } } }),
14708
+ ].join('\n') + '\n';
14709
+ cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], { cwd: victim, input: req, encoding: 'utf8', env: envP, timeout: 180000 });
14710
+ const after = cnt();
14711
+ if (after !== before) bad.push(`③MCP 가 서버 cwd 저장소의 브랜치 ${before - after}개를 지움(${before}→${after})`);
14712
+ dbg.mcpCleanup = { before, after };
14713
+ }
14714
+ // ④ 세션 소유가 **손상값 · `__proto__` · 노출**에 견딘다.
14715
+ {
14716
+ const eA = Object.assign({}, envP, { LEERNESS_SESSION_ID: 'session-alpha01' });
14717
+ const eB = Object.assign({}, envP, { LEERNESS_SESSION_ID: 'session-bravo01' });
14718
+ const d = mkGit('own');
14719
+ RP(d, ['state', 'start', 'A', '--json'], eA);
14720
+ const stf = path.join(d, '.leerness', 'state.json');
14721
+ const st = JSON.parse(fs.readFileSync(stf, 'utf8'));
14722
+ st.runsBySession = ['corrupt'];
14723
+ fs.writeFileSync(stf, JSON.stringify(st, null, 2) + '\n');
14724
+ const rB = RP(d, ['state', 'record', '--files-changed', 'stolen.js'], eB);
14725
+ const runA = JSON.parse(fs.readFileSync(path.join(d, '.leerness', 'runs', 'run-0001.json'), 'utf8'));
14726
+ if (rB.status === 0) bad.push('④손상된 소유맵에서 남의 run 에 기록 성공(fail-open)');
14727
+ if (runA.files_changed.length) bad.push('④손상된 소유맵에서 남의 증거가 오염됨');
14728
+ // `__proto__` 키
14729
+ const d2 = mkGit('own2');
14730
+ const eP = Object.assign({}, envP, { LEERNESS_SESSION_ID: '__proto__aaaa' });
14731
+ const s1 = J(RP(d2, ['state', 'start', 'P', '--json'], eP));
14732
+ const r1 = J(RP(d2, ['state', 'record', '--files-changed', 'p.js', '--json'], eP));
14733
+ if (!s1 || !r1 || r1.recorded !== s1.started) bad.push(`④__proto__ 키에서 소유가 사라짐(${r1 && r1.recorded})`);
14734
+ // 남의 키 노출
14735
+ const d3 = mkGit('own3');
14736
+ RP(d3, ['state', 'start', 'A', '--json'], eA);
14737
+ RP(d3, ['state', 'start', 'B', '--json'], eB);
14738
+ const show = J(RP(d3, ['state', 'show', '--json'], eB));
14739
+ const keys = Object.keys((show && show.runsBySession) || {});
14740
+ if (keys.includes('session-alpha01')) bad.push('④남의 세션 키를 그대로 노출(그 키로 남의 run 을 고칠 수 있다)');
14741
+ if (!keys.includes('session-bravo01')) bad.push('④내 세션 키를 못 보여줌 — 내 것을 확인할 수 없다');
14742
+ dbg.ownership = { corruptExit: rB.status, protoRecorded: r1 && r1.recorded, keys };
14743
+ }
14744
+ // ⑤ 사라진(prunable) worktree 하나가 **살아 있는 형제의 차단을 숨기지 않는다**.
14745
+ {
14746
+ const m = mkGit('wt');
14747
+ const live = path.join(sb, 'wt-live'), gone = path.join(sb, 'wt-gone');
14748
+ const a1 = G(m, ['worktree', 'add', '-qb', 'live', live]);
14749
+ const a2 = G(m, ['worktree', 'add', '-qb', 'gone', gone]);
14750
+ fs.rmSync(gone, { recursive: true, force: true });
14751
+ const j = J(RP(m, ['enforce', 'install', '--path', m, '--json', '--skip-verify']));
14752
+ if (a1.status !== 0 || a2.status !== 0) bad.push('⑤worktree 픽스처 실패 — 판별 불가');
14753
+ else if (!j || j.worktrees !== 2) bad.push(`⑤prunable 하나가 전체 판정을 가림(worktrees=${j && j.worktrees}, 기대 2)`);
14754
+ dbg.prunable = { worktrees: j && j.worktrees };
14755
+ }
14756
+ ok = bad.length === 0;
14757
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
14758
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
14759
+ console.log(ok ? `✓ AH(1.36.144/T-0117) --dry-run 이 아무것도 안 씀(고치기 전 378중 183) + 구현 안 한 명령은 거절 · 플래그 없는 실행은 그대로 · 빈 --path 가 cwd 에 못 씀 · MCP 가 서버 cwd 저장소를 안 지움 · 소유맵이 손상/__proto__/노출에 견딤 · prunable 이 형제 차단을 안 숨김 ${JSON.stringify(dbg)}`
14760
+ : '✗ 1.36.144 dry-run/소유 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
14761
+ if (!ok) failed++;
14762
+ }
14763
+
14351
14764
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
14352
14765
  if (failed > 0) process.exit(1);
14353
14766