leerness 1.36.143 → 1.36.146

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.143 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.146 하네스를 사용합니다. 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.143는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.146는 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.143는 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.143 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.146 릴리스 태그 이력** (수백 라운드) · _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.143: 2026-08-21
238
+ Last synced by Leerness v1.36.146: 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.143';
37
+ const VERSION = '1.36.146';
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.
@@ -7295,12 +7301,19 @@ function enforceCmd(root, sub) {
7295
7301
  };
7296
7302
  const mine = norm(hookP);
7297
7303
  let sharing = 0;
7304
+ // ⚠ 1.36.144 (재검수 P1): "하나라도 모르면 null" 로 했더니 **사라진(prunable) worktree 하나**가
7305
+ // 살아 있는 형제들의 실제 차단까지 통째로 숨겼다. 모르는 것은 세지 않되, **아는 것은 그대로 센다**.
7306
+ // 디렉토리가 없어진 worktree 는 애초에 커밋하지 않으므로 셈에서 빠지는 것이 옳다.
7307
+ let unknown = 0;
7298
7308
  for (const wd of dirs) {
7309
+ if (!exists(wd)) { unknown++; continue; } // prunable — 실재하지 않는다
7299
7310
  const hp = _gitSpawn(['-C', wd, 'rev-parse', '--git-path', 'hooks/pre-commit'], { encoding: 'utf8', timeout: 5000 });
7300
- if (hp.status !== 0 || !String(hp.stdout || '').trim()) return null; // 하나라도 모르면 주장하지 않는다
7311
+ if (hp.status !== 0 || !String(hp.stdout || '').trim()) { unknown++; continue; }
7301
7312
  const raw = String(hp.stdout).trim();
7302
7313
  if (norm(path.isAbsolute(raw) ? raw : path.join(wd, raw)) === mine) sharing++;
7303
7314
  }
7315
+ // 아무것도 못 알아냈을 때만 "모른다" 고 한다.
7316
+ if (sharing === 0 && unknown > 0) return null;
7304
7317
  return sharing > 0 ? sharing : null;
7305
7318
  } catch { return null; }
7306
7319
  })();
@@ -21569,7 +21582,10 @@ async function releasePackCmd(root) {
21569
21582
  }
21570
21583
 
21571
21584
  // 4. 자동 task add — 매 release 라운드가 progress-tracker에 흔적 남도록
21572
- 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) {
21573
21589
  const v = getCurrentVersion(root) || VERSION;
21574
21590
  const id = nextId(root, 'T');
21575
21591
  upsertProgress(root, {
@@ -21583,7 +21599,9 @@ async function releasePackCmd(root) {
21583
21599
  }
21584
21600
 
21585
21601
  // 5. session close
21586
- if (close) {
21602
+ // ⚠ 같은 이유로 dry 면 건너뛴다 — 마감은 보존 파일을 새로 쓴다.
21603
+ if (close && dryRun) log('\n (dry-run) session close 스킵');
21604
+ if (close && !dryRun) {
21587
21605
  log('\n[session close]');
21588
21606
  try {
21589
21607
  const r = sessionClose(root);
@@ -22461,6 +22479,9 @@ function _readUsageStats(root) {
22461
22479
  } catch { return { commands: {}, drift: {}, since: today() }; }
22462
22480
  }
22463
22481
  function _bumpUsage(root, cmdName) {
22482
+ // 1.36.144 (T-0117): dry-run 은 아무것도 바꾸지 않는다 — 사용량 카운터도 예외가 아니다.
22483
+ // (전수 실측에서 378 호출 중 155건이 이 카운터 하나 때문에 "썼다" 로 잡혔다.)
22484
+ if (has('--dry-run')) return;
22464
22485
  // 가벼운 카운터 — 명령 실행마다 호출 (sync write로 작은 파일)
22465
22486
  // 1.36.137 (동시성 감사 실측): 이 함수는 **모든 명령이 공유하는 단 하나의 파일**을 락 없이 RMW 했다 —
22466
22487
  // 2 프로세스부터 카운터가 38~46% 사라졌다(8프로세스×15회=120 실행 → 74 계수). exit 0 · 경고 0 · 손상 0.
@@ -23381,7 +23402,10 @@ function _mcpToCliArgs(name, args, targetPath) {
23381
23402
  break;
23382
23403
  case 'leerness_release_cleanup':
23383
23404
  // 1.9.236 (1.9.235): local release/* branches 정리
23384
- 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'];
23385
23409
  if (args.apply === true) cliArgs.push('--apply');
23386
23410
  if (typeof args.keep === 'number') cliArgs.push('--keep', String(args.keep));
23387
23411
  break;
@@ -23564,12 +23588,16 @@ function mcpServeCmd(root) {
23564
23588
  // 예산 표(MCP_LONG_PATHS)와 _mcpTimeoutFor 는 모듈 스코프에 있다 — selftest 가 소스 grep 이 아니라
23565
23589
  // **실제 호출**로 `budget > 내부한도` 를 단언할 수 있게 하기 위함(1.36.125).
23566
23590
  const _shQuote = (s) => (/[\s"']/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
23567
- function callLeerness(cliArgs) {
23591
+ // 1.36.145 (재검수 P1, 재현): T-0116 을 CLI 에서만 고쳤다 — MCP 도구에는 **세션 주소 입력이 없어**
23592
+ // 한 서버의 모든 호출이 같은(=무)주소로 접혀 전역 슬롯 오귀속이 그대로 남아 있었다.
23593
+ // 실측: A 의 증거가 B 의 run 에 붙고, 일하지 않은 B 가 `completion_claim_allowed: true` 를 받았다.
23594
+ // 자식 프로세스로 돌리므로 **env 로 주소를 넘길 수 있다** — 호출마다 다른 주소를 준다.
23595
+ function callLeerness(cliArgs, extraEnv) {
23568
23596
  const budget = _mcpTimeoutFor(cliArgs);
23569
23597
  const r = cp.spawnSync(process.execPath, [__filename, ...cliArgs], {
23570
23598
  encoding: 'utf8',
23571
23599
  timeout: budget,
23572
- env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_STALE_CHECK: '1', LEERNESS_NO_DRIFT_CHECK: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_WORKFLOW_GUIDE: '1' }
23600
+ env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_STALE_CHECK: '1', LEERNESS_NO_DRIFT_CHECK: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_WORKFLOW_GUIDE: '1', ...(extraEnv || {}) }
23573
23601
  });
23574
23602
  const cmd = `leerness ${cliArgs.map(_shQuote).join(' ')}`;
23575
23603
  const partial = ((r.stdout || '') + (r.stderr || '')).trim();
@@ -23648,6 +23676,13 @@ function mcpServeCmd(root) {
23648
23676
  }
23649
23677
  // 1.12.2 (14th 버그헌트, UR-0181): MCP 서버는 generic 설계 — cwd 에서 실행 후 각 호출의 path 로 대상 프로젝트 지정(의도된 기능, e2e 가 검증). 즉 path-타게팅 자체는 취약점 아님(신뢰된 로컬 MCP). policy 도 대상-프로젝트별로 path 에서 로드(올바름).
23650
23678
  // 진짜 버그는 unknown tool 도 _bumpMcpUsage 가 임의 경로에 무조건 쓰던 것 → 사용 통계를 unknown-tool 검증 "후"로 이동. (프로젝트-scoped 가둠은 opt-in 플래그로 별도 검토 — UR backlog)
23679
+ // 1.36.146 (재검수 P1, 재현): 빈 `path` 가 falsy 라 **서버 root** 로 떨어져
23680
+ // CLI 의 `path_missing_value` 가드를 우회하고 엉뚜한 저장소에 썼다(실측: MCPSNEAK 기록됨).
23681
+ // 키를 준 이상 값은 문자열이어야 한다 — 아니면 그렇다고 말한다.
23682
+ if (args && Object.prototype.hasOwnProperty.call(args, 'path')
23683
+ && (typeof args.path !== 'string' || !args.path.trim())) {
23684
+ return send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: 'path 에 값이 없습니다 — 아무것도 하지 않았습니다(서버 작업디렉토리로 대체하지 않음)' }], isError: true } });
23685
+ }
23651
23686
  const targetPath = args.path || root;
23652
23687
  let cliArgs;
23653
23688
  try {
@@ -23665,7 +23700,9 @@ function mcpServeCmd(root) {
23665
23700
  return send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `정책 차단(policy): ${pol.reason}` }], isError: true } });
23666
23701
  }
23667
23702
  } catch {}
23668
- const r = callLeerness(cliArgs);
23703
+ // 세션 주소를 그대로 전달한다(문자열일 때만). 형식이 안 맞으면 CLI 가 무시하고 그 사실을 말한다.
23704
+ const _sk = (args && typeof args.sessionKey === 'string' && args.sessionKey.trim()) ? args.sessionKey.trim() : null;
23705
+ const r = callLeerness(cliArgs, _sk ? { LEERNESS_SESSION_ID: _sk } : null);
23669
23706
  // 1.9.61: cursor 기반 페이지네이션 — 긴 출력은 cursor offset로 다음 청크
23670
23707
  const fullText = r.stdout || r.stderr || '(no output)';
23671
23708
  // 1.12.4 (15th 버그헌트 P2, UR-0016): _chunkSize 를 양의 정수로 클램프 — 음수/소수면 slice 빈 출력 + nextCursor 음수 → 무한 빈-루프(데이터 손실)였음.
@@ -24931,7 +24968,13 @@ function _loadLeernessState(root) {
24931
24968
  // 단, runs 디렉토리가 비어 있으면(진짜 신규/빈 상태) 관대하게 기본값 — 없는 데이터를 지킬 필요는 없다.
24932
24969
  const _shapeOk = _st && typeof _st === 'object' && !Array.isArray(_st)
24933
24970
  && Number.isInteger(_st.runCounter) && _st.runCounter >= 0
24934
- && (_st.currentRunId === null || typeof _st.currentRunId === 'string');
24971
+ && (_st.currentRunId === null || typeof _st.currentRunId === 'string')
24972
+ // 1.36.144 (재검수 P2): `runsBySession` 이 형태 검증에서 빠져 배열·문자열·null 이 와도 통과했고,
24973
+ // 그 경우 소유권이 통째로 '없음' 이 되어 **다른 세션의 run 을 인수**하는 fail-open 이 됐다.
24974
+ // 모양이 아니면 손상으로 다룬다(없는 것은 정상 — 업그레이드 전 상태).
24975
+ && (_st.runsBySession === undefined
24976
+ || (_st.runsBySession && typeof _st.runsBySession === 'object' && !Array.isArray(_st.runsBySession)
24977
+ && Object.values(_st.runsBySession).every((v) => typeof v === 'string' && v)));
24935
24978
  // 디스크의 최대 run 번호 — 카운터의 진실성 대조 기준
24936
24979
  let _maxRun = 0;
24937
24980
  try {
@@ -25200,7 +25243,14 @@ function stateCmd(root, sub, ...args) {
25200
25243
  // 사용자는 주소가 있다고 믿는데 없는 상태가 되므로, 무시했다면 반드시 말한다.
25201
25244
  const _badKey = _SPst.invalidExplicitKey(process.env);
25202
25245
  if (_badKey && !json) warn(`⚠ LEERNESS_SESSION_ID 가 형식에 맞지 않아 무시됩니다("${_badKey}") — 이 세션은 주소 없이 전역 슬롯을 씨니다`);
25203
- const _ownedBy = (st) => (st && st.runsBySession && typeof st.runsBySession === 'object' && !Array.isArray(st.runsBySession)) ? st.runsBySession : {};
25246
+ // 1.36.144 (재검수 P2): `__proto__` 같은 키는 plain object 에 넣으면 프로토타입에 먹혀
25247
+ // `start` 는 성공하는데 소유가 사라지고 `record` 가 실패했다. 소유 맵은 **프로토타입 없는 객체**로 다룬다.
25248
+ const _ownedBy = (st) => {
25249
+ const raw = (st && st.runsBySession && typeof st.runsBySession === 'object' && !Array.isArray(st.runsBySession)) ? st.runsBySession : {};
25250
+ const o = Object.create(null);
25251
+ for (const k of Object.keys(raw)) if (typeof raw[k] === 'string' && raw[k]) o[k] = raw[k];
25252
+ return o;
25253
+ };
25204
25254
  // 이 세션의 run 을 고른다. 없으면 **다른 세션이 소유하지 않은** 전역 슬롯만 인수한다
25205
25255
  // (업그레이드 전에 시작한 run · 세션 키가 없는 단일 에이전트는 종전처럼 동작한다).
25206
25256
  const _resolveRunId = (st) => {
@@ -25211,13 +25261,17 @@ function stateCmd(root, sub, ...args) {
25211
25261
  return null;
25212
25262
  };
25213
25263
  const _claimRun = (st, runId) => {
25214
- if (_sessKey) { st.runsBySession = Object.assign({}, _ownedBy(st)); st.runsBySession[_sessKey] = runId; }
25264
+ if (_sessKey) {
25265
+ const o = _ownedBy(st); o[_sessKey] = runId;
25266
+ // 직렬화 전에 평범한 객체로 되돌린다(프로토타입 없는 객체도 JSON 은 그대로 쓴다 — 명시적으로 복사한다).
25267
+ st.runsBySession = Object.fromEntries(Object.keys(o).map((k) => [k, o[k]]));
25268
+ }
25215
25269
  st.currentRunId = runId; // 기존 소비자 계약 유지(마지막 시작된 run)
25216
25270
  };
25217
25271
  const _releaseRun = (st, runId) => {
25218
25272
  if (_sessKey && _ownedBy(st)[_sessKey] === runId) {
25219
- st.runsBySession = Object.assign({}, _ownedBy(st));
25220
- delete st.runsBySession[_sessKey];
25273
+ const o = _ownedBy(st); delete o[_sessKey];
25274
+ st.runsBySession = Object.fromEntries(Object.keys(o).map((k) => [k, o[k]]));
25221
25275
  }
25222
25276
  // ⚠ 전역 슬롯은 **내 run 일 때만** 비운다 — 남의 run 을 끊지 않는다.
25223
25277
  if (st.currentRunId === runId) st.currentRunId = null;
@@ -25296,9 +25350,16 @@ function stateCmd(root, sub, ...args) {
25296
25350
  const hdir = path.join(_leernessStateDir(root), 'handoff'); mkdirp(hdir);
25297
25351
  // 1.9.443 (UR-0153): handoff evidence 에 completion_claim_allowed 포함 — 다음 에이전트/PR 이 증거 게이트를 직접 읽음.
25298
25352
  const _ccaH = _completionClaimAllowed(rec);
25299
- writeUtf8(path.join(hdir, 'latest.json'), JSON.stringify({ ...rec, completion_claim_allowed: _ccaH }, null, 2) + '\n');
25300
- writeUtf8(path.join(hdir, 'latest.md'),
25301
- `# 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`);
25353
+ const _payload = JSON.stringify({ ...rec, completion_claim_allowed: _ccaH }, null, 2) + '\n';
25354
+ const _md = `# 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`;
25355
+ // 1.36.145 (재검수 P2): run 은 세션별인데 인수인계 산출물은 **전역 파일 하나**였다 —
25356
+ // 다른 세션이 뒤이어 마감하면 앞사람의 인수인계가 읽히기 전에 덮였다.
25357
+ // run 별 사본을 먼저 남긴다(잃을 것이 없다). `latest.*` 는 계약대로 "가장 최근" 을 유지하되
25358
+ // 누구의 것인지 알 수 있게 run_id 를 본문에 싣는다(이미 rec 에 있다).
25359
+ writeUtf8(path.join(hdir, `${rec.run_id}.json`), _payload);
25360
+ writeUtf8(path.join(hdir, `${rec.run_id}.md`), _md);
25361
+ writeUtf8(path.join(hdir, 'latest.json'), _payload);
25362
+ writeUtf8(path.join(hdir, 'latest.md'), _md);
25302
25363
  _releaseRun(state, rec.run_id); // 내 소유만 놓는다(남의 run 은 건드리지 않는다)
25303
25364
  _saveLeernessState(root, state);
25304
25365
  if (json) { log(JSON.stringify({ handoff: rec.run_id, run: rec, completion_claim_allowed: _ccaH }, null, 2)); return; }
@@ -25313,7 +25374,11 @@ function stateCmd(root, sub, ...args) {
25313
25374
  log(JSON.stringify({
25314
25375
  state, currentRun: rec, completion_claim_allowed: rec ? _completionClaimAllowed(rec) : null,
25315
25376
  sessionKey: _sessKey || null, keySource: _SPst.keySource(process.env) || null,
25316
- runsBySession: _ownedBy(state), resolvedRunId: curId,
25377
+ // 1.36.144 (재검수 P2): 남의 세션 키를 그대로 내보내면 **그 키를 흉내내 남의 run 을 고칠 수 있다**.
25378
+ // 몇 개가 진행 중인지와 내 것이 무엇인지는 말하되, 남의 주소는 접두만 보인다.
25379
+ runsBySession: Object.fromEntries(Object.keys(_ownedBy(state)).map((k) => [
25380
+ k === _sessKey ? k : (String(k).slice(0, 6) + '…'), _ownedBy(state)[k]])),
25381
+ resolvedRunId: curId,
25317
25382
  }, null, 2));
25318
25383
  return;
25319
25384
  }
@@ -27835,6 +27900,9 @@ async function main() {
27835
27900
  return log(VERSION);
27836
27901
  }
27837
27902
  if (has('--help') || has('-h')) return help();
27903
+ // 1.36.144 (T-0117, 전수 실측): `--dry-run` 을 받았으면 쓰기를 막는다 — 구현한 명령은 애초에 안 쓰므로
27904
+ // 영향이 없고, 무시하던 명령은 첫 쓰기에서 멈춰 **아무것도 바꾸지 않은 채** 사유를 말한다.
27905
+ if (has('--dry-run')) { setDryRunGuard(true); try { require('../lib/git').setGitDryRun(true); } catch {} }
27838
27906
  // 1.36.103: 실재하지 않는 플래그(오타)를 조용히 삼키지 않는다.
27839
27907
  // 값-플래그 미등록 45종은 _VALUE_FLAGS 등록으로 닫았지만, 사용자 오타(--prio, --resaon)는 여전히
27840
27908
  // 그 값이 본문에 붙는다(실측: `decision add "use redis" --resaon faster` → 저장된 제목 "use redis faster").
@@ -27933,6 +28001,17 @@ async function main() {
27933
28001
  {
27934
28002
  const _PATH_MAY_BE_ABSENT = new Set(['init', 'migrate', 'migrate-workspace-dir', 'update', 'auto-update', 'adapter', 'ci']);
27935
28003
  const _p = arg('--path', null);
28004
+ // 1.36.144 (재검수 P1, 재현): `--path=` 는 빈 문자열(falsy), 값 없는 `--path` 는 boolean true 가 된다 —
28005
+ // 둘 다 이 가드를 그냥 지나쳐 **cwd 에 쓰고 성공을 보고**했다(실측: `decision add SNEAKY --path=` → ✓ 기록됨).
28006
+ // 플래그를 적었는데 값이 없으면 그건 오타다. 조용히 cwd 로 떨어뜨리지 않는다.
28007
+ const _pathTyped = process.argv.slice(2).some((t) => t === '--path' || (typeof t === 'string' && t.startsWith('--path=')));
28008
+ if (_pathTyped && (typeof _p !== 'string' || !_p.trim())) {
28009
+ const _en = _uiLang(process.cwd()) === 'en';
28010
+ failJson(has('--json'), 'path_missing_value', _en
28011
+ ? '--path was given without a value — nothing was done'
28012
+ : '--path 에 값이 없습니다 — 아무것도 하지 않았습니다');
28013
+ return;
28014
+ }
27936
28015
  if (_p && !_PATH_MAY_BE_ABSENT.has(cmd)) {
27937
28016
  const _abs = absRoot(_p);
27938
28017
  if (!exists(_abs)) {
@@ -28905,6 +28984,12 @@ if (require.main === module) {
28905
28984
  main()
28906
28985
  .then(() => { if (process.exitCode && process.exitCode !== 0) process.exit(process.exitCode); })
28907
28986
  .catch(err => {
28987
+ // 1.36.144 (T-0117): dry-run 인데 쓰려다 멈춘 것은 크래시가 아니라 **계약 위반 보고**다.
28988
+ if (err && err.code === 'E_DRY_RUN_WRITE') {
28989
+ failJson(process.argv.includes('--json'), 'dry_run_unsupported',
28990
+ `이 명령은 --dry-run 을 구현하지 않았습니다 — ${err.file} 을(를) 쓰려다 멈췄습니다. 변경된 것은 없습니다(--dry-run 을 빼고 실행하세요).`);
28991
+ process.exit(1);
28992
+ }
28908
28993
  fail(err && err.message ? err.message : String(err));
28909
28994
  // 1.36.88: 완료 게이트는 upsertProgress 에서 던진다 — taskUpdate/taskSync 외의 경로로 올라와도
28910
28995
  // 스택 트레이스 대신 탈출구를 보여준다(막힌 사용자가 빠져나갈 방법을 알아야 한다).
package/lib/git.js CHANGED
@@ -44,7 +44,38 @@ const _shouldDrop = (name) => { const u = String(name).toUpperCase(); return _DR
44
44
  // 이 함수가 자기 자신을 초크포인트 전환 대상으로 잡아 무한 재귀가 된다
45
45
  // (실제로 한 번 그렇게 만들어 스택 오버플로로 모든 git 조회가 'git-error' 가 됐다).
46
46
  // 가드도 이 형태를 세므로 주석에도 적지 않는다(자기참조 트랩).
47
+ // 1.36.146 (재검수 P1, 재현): dry-run 가드는 파일만 봤고 **git 부작용**은 그대로였다 —
48
+ // `release cleanup --apply --dry-run` 이 브랜치 60 → 10 으로 실제 삭제했다.
49
+ // git 도 초크포인트가 하나니, 상태를 바꾸는 하위명령을 여기서 거절한다.
50
+ const _GIT_MUTATING = new Set(['add', 'rm', 'mv', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch',
51
+ 'restore', 'branch', 'tag', 'push', 'fetch', 'pull', 'clone', 'init', 'worktree', 'stash', 'apply', 'cherry-pick',
52
+ 'revert', 'clean', 'gc', 'prune', 'update-ref', 'symbolic-ref', 'notes', 'submodule', 'config', 'remote']);
53
+ // 조회성 하위명령은 이름이 같아도 플래그로 갈라진다 — 읽기만 하는 형태는 통과시킨다.
54
+ const _GIT_READ_ONLY_FORM = (sub, rest) => {
55
+ if (sub === 'branch') return !rest.some((x) => /^-(d|D|m|M|c|C)$|^--(delete|move|copy|set-upstream|unset-upstream|edit-description)/.test(x));
56
+ if (sub === 'config') return rest.includes('--get') || rest.includes('--get-all') || rest.includes('--list') || rest.includes('--show-scope') || rest.includes('-l');
57
+ if (sub === 'symbolic-ref') return rest.includes('-q') || rest.includes('--short') || rest.length <= 1;
58
+ if (sub === 'worktree') return rest[0] === 'list';
59
+ if (sub === 'stash') return rest[0] === 'list' || rest[0] === 'show';
60
+ if (sub === 'tag') return !rest.some((x) => /^-(d|a|s|f)$|^--(delete|annotate|sign|force)/.test(x));
61
+ if (sub === 'notes') return rest[0] === 'list' || rest[0] === 'show';
62
+ if (sub === 'submodule') return rest[0] === 'status' || rest[0] === 'summary';
63
+ if (sub === 'remote') return rest.length === 0 || rest[0] === '-v' || rest[0] === 'show' || rest[0] === 'get-url';
64
+ return false;
65
+ };
66
+ let _gitDry = false;
67
+ function setGitDryRun(on) { _gitDry = !!on; }
47
68
  function gitSpawn(args, opts) {
69
+ if (_gitDry) {
70
+ // `-C <path>` 같은 선행 옵션을 건너뛰고 첫 하위명령을 찾는다.
71
+ const a = Array.isArray(args) ? args.map(String) : [];
72
+ let i = 0;
73
+ while (i < a.length && (a[i].startsWith('-') || (i > 0 && a[i - 1] === '-C'))) i++;
74
+ const sub = a[i] || '';
75
+ if (_GIT_MUTATING.has(sub) && !_GIT_READ_ONLY_FORM(sub, a.slice(i + 1))) {
76
+ throw Object.assign(new Error(`--dry-run 인데 git ${sub} 를 실행하려 했습니다`), { code: 'E_DRY_RUN_WRITE', file: `git ${sub}` });
77
+ }
78
+ }
48
79
  const _argv = ['--no-optional-locks', ...args]; // 사용자 저장소의 `.git/index` 재기록을 막는다(실측)
49
80
  // ⚠ 키를 **접어서** 비교한다 — Windows 환경변수는 대소문자를 구분하지 않는데 JS 객체 키는 구분해서,
50
81
  // 대문자만 지웠을 때 소문자 `git_dir` 로 그대로 우회됐다(실측: 남의 저장소 훅이 1 → 2).
@@ -53,4 +84,4 @@ function gitSpawn(args, opts) {
53
84
  return cp.spawnSync('git', _argv, Object.assign({}, opts, { env: _env }));
54
85
  }
55
86
 
56
- module.exports = { gitSpawn, GIT_LOCATION_ENV, _shouldDrop };
87
+ module.exports = { gitSpawn, GIT_LOCATION_ENV, _shouldDrop, setGitDryRun };
package/lib/io.js CHANGED
@@ -5,6 +5,7 @@
5
5
  'use strict';
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
+ const os = require('os');
8
9
 
9
10
  // 1.10.2 (UR-0146): quiet 모드 — 사람용 출력(log/ok/warn) 억제. init --json 등에서 큰 핸들러의 다수 log 를 비침투적으로 묵음 → 순수 JSON 1개만 출력.
10
11
  // fail/failJson(오류)은 묵음 대상 아님(에러는 항상 노출). setQuiet 로 토글, 호출부 finally 에서 반드시 복구.
@@ -118,7 +119,69 @@ function read(p) {
118
119
  return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
119
120
  }
120
121
  function readBuf(p) { return fs.readFileSync(p); }
121
- function mkdirp(p) { fs.mkdirSync(p, { recursive: true }); }
122
+ // 1.36.144 (T-0117, 전수 실측): `--dry-run` 은 **등록된 전역 플래그**인데 많은 명령이 그냥 무시하고 썼다.
123
+ // 378 호출을 전후 트리 비교로 재니 183건이 썼고, 그중 **28건이 사용자 데이터**를 바꿨다 —
124
+ // `session close --dry-run` 이 current-state·session-handoff·task-log 를 다시 쓰고,
125
+ // `task add` · `decision add` · `rule add` · `plan add` · `lesson save` · `state start` · `ci init` 은 **실제로 추가**했다.
126
+ // 명령마다 dry-run 분기를 심으면 16개의 새 실수 자리가 생긴다. 그래서 **쓰기 시점 하나**에서 막는다:
127
+ // dry-run 인데 쓰려 하면 그 자리에서 멈추고 무엇을 쓰려 했는지 말한다(조용히 쓰지도, 조용히 넘어가지도 않는다).
128
+ // ⚠ 락/임시 파일과 사용량 카운터는 사용자 데이터가 아니므로 면제한다 — 안 그러면 정직하게 dry-run 을
129
+ // 구현한 명령이 락을 잡는 것만으로 걸린다(가드가 정상 동작을 막으면 그건 새 결함이다).
130
+ let _dryGuard = false;
131
+ // ⚠ 1.36.146 (재검수 P1, 재현): 첫 면제 규칙은 `[\\/]cache[\\/]` 였다 — 경로 **어디에든**
132
+ // `cache` 디렉토리가 있으면 면제돼, `C:/Users/me/cache/myproject/` 에 사는 프로젝트는
133
+ // dry-run 가드가 **통째로 꺼졌다**(실측: `decision add --dry-run` 이 그대로 기록됨).
134
+ // 면제는 우리가 아는 자리로 한정한다: 임시 디렉토리 · 락/임시 파일 · harness 캐시.
135
+ const _DRY_EXEMPT_TAIL = /(\.lock$|\.tmp-\d+-\d+$)/;
136
+ const _DRY_EXEMPT_DIR = /[\\/]\.(harness|leerness)[\\/]cache[\\/]/;
137
+ // ⚠ 한 번 `os.tmpdir()` 전체를 면제했다가 뺐다 — 그러면 **내 테스트가 전부 임시 디렉토리에 있어서**
138
+ // 실사용에서는 막히는데 계측만 죽는다(실측: 같은 프로브가 "가드 무력" 이라 보고). 가장 나쁜 조합이다.
139
+ // 면제는 leerness **자신이 만드는 임시 산출물**의 이름 규칙으로만 한정한다.
140
+ const _DRY_EXEMPT_OURS = /leerness-probe-\d+-\d+\.sh$/;
141
+ function _dryExempt(q) {
142
+ const s = String(q);
143
+ if (_DRY_EXEMPT_TAIL.test(s)) return true;
144
+ if (_DRY_EXEMPT_DIR.test(s)) return true;
145
+ if (_DRY_EXEMPT_OURS.test(s)) return true;
146
+ return false;
147
+ }
148
+ function _dryCheck(p) {
149
+ if (!_dryGuard) return;
150
+ const s = String(p);
151
+ if (_dryExempt(s)) return;
152
+ throw Object.assign(new Error(`--dry-run 인데 쓰려고 했습니다: ${s}`), { code: 'E_DRY_RUN_WRITE', file: s });
153
+ }
154
+ // 1.36.146: 가드가 `writeUtf8`/`append`/`mkdirp` **세 함수만** 보고 있었다.
155
+ // 정적 열거: io 를 우회하는 `fs.*` 변이 호출이 **330건**(unlinkSync 15 · rmSync 85 · writeFileSync 125 …).
156
+ // 실측: `enforce remove --dry-run` 이 진짜 훅을 지우고, `release cleanup --apply --dry-run` 이 브랜치 50개를 지웠다.
157
+ // 330곳을 배선하면 330개의 새 실수 자리가 생긴다 — **프로세스 진입점에서 한 번** 막는다.
158
+ const _FS_MUTATORS = ['writeFileSync', 'appendFileSync', 'unlinkSync', 'rmSync', 'rmdirSync',
159
+ 'renameSync', 'copyFileSync', 'mkdirSync', 'chmodSync', 'utimesSync', 'symlinkSync', 'truncateSync'];
160
+ let _fsPatched = false;
161
+ function _patchFsForDryRun() {
162
+ if (_fsPatched) return;
163
+ _fsPatched = true;
164
+ for (const name of _FS_MUTATORS) {
165
+ const orig = fs[name];
166
+ if (typeof orig !== 'function') continue;
167
+ fs[name] = function (...a) {
168
+ // 첫 인자가 대상 경로다(rename/copy 는 둘 다 본다).
169
+ _dryCheck(a[0]);
170
+ if ((name === 'renameSync' || name === 'copyFileSync' || name === 'symlinkSync') && a[1] !== undefined) _dryCheck(a[1]);
171
+ return orig.apply(fs, a);
172
+ };
173
+ }
174
+ }
175
+ function setDryRunGuard(on) { _dryGuard = !!on; if (on) _patchFsForDryRun(); }
176
+ // ⚠ `mkdirp` 은 가드하지 **않는다**. 한 번 넣었다가 부작용을 재서 뺀다 —
177
+ // `_withLock` 이 락 디렉토리를 만드는 것까지 막혀 락 획득이 실패하고 **무보호 진행**으로 떨어졌다
178
+ // (실측: `state start --dry-run` → "락 획득 실패 — 보호 없이 진행합니다"). 빈 디렉토리는 사용자 데이터가
179
+ // 아니고, 의미 있는 쓰기는 전부 `writeUtf8`/`append` 를 지나므로 거기서 막는 것으로 충분하다.
180
+ // 디렉토리 생성도 막는다 — 빈 디렉토리라도 dry-run 이 남기면 "변경 없음" 이 거짓이 된다.
181
+ // ⚠ 단, **락 디렉토리는 예외**다. 한 번 전부 막았다가 `_withLock` 의 락 획득이 실패해
182
+ // **무보호 진행**으로 떨어졌다(실측). 락은 `mkdirpRaw` 로 우회한다 — 인프라지 사용자 데이터가 아니다.
183
+ function mkdirp(p) { _dryCheck(path.join(String(p), '_')); fs.mkdirSync(p, { recursive: true }); }
184
+ function mkdirpRaw(p) { fs.mkdirSync(p, { recursive: true }); }
122
185
  // 1.9.298 (UR-0038, 외부리뷰 3중수렴): 원자적 쓰기 — temp 파일에 기록 후 rename(원자 교체)으로 부분쓰기 손상 방지.
123
186
  // temp 이름은 .tmp-PID-SEQ 라 *.md glob 에 안 걸림. 실패 시 temp 정리.
124
187
  let _writeSeq = 0;
@@ -138,6 +201,7 @@ function _rescueCorruptJson(p, s) {
138
201
  try { fs.renameSync(p, bak); console.error(`⚠ 손상 JSON 감지 — 원본 대피 후 저장: ${path.basename(bak)} (수동 복구용)`); } catch {}
139
202
  }
140
203
  function writeUtf8(p, s) {
204
+ _dryCheck(p);
141
205
  // 1.36.65 (외부감사 F-08): 동일-내용 재기록 스킵 — 무변경 재설치/조정기의 mtime·diff churn 을 전역 해소.
142
206
  // 1.36.66 (8차 헌트 F13): 비교는 디코딩 텍스트가 아니라 "바이트 동일"로 — 손상 바이트(FF)/BOM 파일에
143
207
  // 유효 UTF-8 을 쓸 때 read() 의 BOM-strip·치환문자가 텍스트를 같게 보이게 해 정화 write 가 억제됐다.
@@ -175,7 +239,7 @@ function _renameWithRetry(from, to) {
175
239
  }
176
240
  }
177
241
  }
178
- function append(p, s) { mkdirp(path.dirname(p)); fs.appendFileSync(p, s, 'utf8'); }
242
+ function append(p, s) { _dryCheck(p); mkdirp(path.dirname(p)); fs.appendFileSync(p, s, 'utf8'); }
179
243
  function rel(root, p) { return path.relative(root, p).replace(/\\/g, '/') || '.'; }
180
244
 
181
- module.exports = { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel };
245
+ module.exports = { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel, setDryRunGuard, mkdirpRaw };
package/lib/mcp-tools.js CHANGED
@@ -82,11 +82,11 @@ module.exports = [
82
82
  { name: 'leerness_slash_commands', requiredTier: 'safe-write', description: '1.9.265/266 (사용자 명시 UR-0021) — CLI AI 에이전트별 슬래시 명령어 레지스트리. 외부 AI(메인)가 sub-agent(codex/agy/claude/grok/copilot)를 호출할 때 각 에이전트에 알맞는 슬래시 명령을 참조. 빌트인 + 사용자 .harness/agent-slash-commands.json override 병합. 응답: { agents: { <id>: { label, asOf, invoke(slash|subcommand), note, source, count, commands[{cmd,desc}] } } }. 인자: { path?, agent? (생략 시 전체), refresh? (1.9.267 — 설치된 CLI --help probe 자동 갱신), dryRun? }. agent 지정 시 단일.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, agent: { type: 'string' }, refresh: { type: 'boolean' }, dryRun: { type: 'boolean' } } } },
83
83
  { name: 'leerness_roles', requiredTier: 'safe-write', description: '1.9.270 (사용자 명시) — 모델별 역할 부여. 여러 AI 에이전트 활성 시 역할(commander/reviewer/coder/architect/designer/debugger/dispatcher)을 provider+model 에 매핑하고, agents dispatch --role 로 라우팅. sub=suggest 면 활성 에이전트 기반 최적 배치 + 근거 반환(방향성 판단). sub=set 시 role/provider/model 필요. 응답: list/suggest/verify 별 JSON. 인자: { path?, sub(list|set|unset|catalog|suggest|verify), role?, provider?, model?, apply? }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, sub: { type: 'string' }, role: { type: 'string' }, provider: { type: 'string' }, model: { type: 'string' }, apply: { type: 'boolean' } } } },
84
84
  // 1.9.279 (UR-0031, GPT-5.5 범용 하네스): 상태 substrate(.leerness/, 1.9.278)를 MCP 시맨틱 verb 로 노출 — 모든 에이전트 공통 호출 표면.
85
- { name: 'leerness_state_show', requiredTier: 'read-only', description: '1.9.279 (UR-0031) — get_project_context / get_current_task. .leerness/ 구조화 상태(현재 run + 누적)를 회수. 외부 에이전트가 작업 시작 전 "지금 무슨 작업이 진행 중인가 / 무엇을 읽고 무엇이 변경됐나"를 JSON 으로 파악. 응답: { state, currentRun }. 인자: { path? }.', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
86
- { name: 'leerness_state_start', requiredTier: 'safe-write', description: '1.9.279 (UR-0031) — start_task. 새 작업 run 시작 (.leerness/runs/run-NNNN.json 생성). 인자: { path?, goal (required), agent?, model?, task? }. 응답: { started, run }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, goal: { type: 'string' }, agent: { type: 'string' }, model: { type: 'string' }, task: { type: 'string' } }, required: ['goal'] } },
87
- { name: 'leerness_state_record', requiredTier: 'safe-write', description: '1.9.279 (UR-0031) — record_file_change / record_decision. 진행 중 run 에 변경 파일/명령/테스트/결정을 누적. 인자: { path?, filesChanged? (콤마), filesRead?, commands?, tests?, errors?, decision? }. 응답: { recorded, run }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, filesChanged: { type: 'string' }, filesRead: { type: 'string' }, commands: { type: 'string' }, tests: { type: 'string' }, errors: { type: 'string' }, decision: { type: 'string' } } } },
88
- { name: 'leerness_state_verify', requiredTier: 'safe-write', description: '1.9.279 (UR-0031) — request_verification / verify_done. 현재 run 의 검증 결과 기록. 인자: { path?, result (required: "pass"|"fail"), note? }. 응답: { verified, result, run }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, result: { type: 'string', enum: ['pass', 'fail'] }, note: { type: 'string' } }, required: ['result'] } },
89
- { name: 'leerness_state_handoff', requiredTier: 'safe-write', description: '1.9.279 (UR-0031) — create_handoff / make_handoff. 현재 run 을 마감하고 다음 에이전트용 .leerness/handoff/latest.{md,json} 작성 (Claude→Goose→Codex 인수인계 표준). 인자: { path?, summary (required) }. 응답: { handoff, run }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, summary: { type: 'string' } }, required: ['summary'] } },
85
+ { name: 'leerness_state_show', requiredTier: 'read-only', description: '1.9.279 (UR-0031) — get_project_context / get_current_task. .leerness/ 구조화 상태(현재 run + 누적)를 회수. 외부 에이전트가 작업 시작 전 "지금 무슨 작업이 진행 중인가 / 무엇을 읽고 무엇이 변경됐나"를 JSON 으로 파악. 응답: { state, currentRun }. 인자: { path? }.', inputSchema: { type: 'object', properties: { sessionKey: { type: 'string', description: '1.36.145 — 이 호출을 하는 세션의 주소. 다른 에이전트와 **같은 프로젝트**에서 일할 때 증거가 섞이지 않게 한다. 주지 않으면 서버 전체가 한 주소를 공유해 섞일 수 있다(응답의 keySource 로 확인).' }, path: { type: 'string' } } } },
86
+ { name: 'leerness_state_start', requiredTier: 'safe-write', description: '1.9.279 (UR-0031) — start_task. 새 작업 run 시작 (.leerness/runs/run-NNNN.json 생성). 인자: { path?, goal (required), agent?, model?, task? }. 응답: { started, run }.', inputSchema: { type: 'object', properties: { sessionKey: { type: 'string', description: '1.36.145 — 이 호출을 하는 세션의 주소. 다른 에이전트와 **같은 프로젝트**에서 일할 때 증거가 섞이지 않게 한다. 주지 않으면 서버 전체가 한 주소를 공유해 섞일 수 있다(응답의 keySource 로 확인).' }, path: { type: 'string' }, goal: { type: 'string' }, agent: { type: 'string' }, model: { type: 'string' }, task: { type: 'string' } }, required: ['goal'] } },
87
+ { name: 'leerness_state_record', requiredTier: 'safe-write', description: '1.9.279 (UR-0031) — record_file_change / record_decision. 진행 중 run 에 변경 파일/명령/테스트/결정을 누적. 인자: { path?, filesChanged? (콤마), filesRead?, commands?, tests?, errors?, decision? }. 응답: { recorded, run }.', inputSchema: { type: 'object', properties: { sessionKey: { type: 'string', description: '1.36.145 — 이 호출을 하는 세션의 주소. 다른 에이전트와 **같은 프로젝트**에서 일할 때 증거가 섞이지 않게 한다. 주지 않으면 서버 전체가 한 주소를 공유해 섞일 수 있다(응답의 keySource 로 확인).' }, path: { type: 'string' }, filesChanged: { type: 'string' }, filesRead: { type: 'string' }, commands: { type: 'string' }, tests: { type: 'string' }, errors: { type: 'string' }, decision: { type: 'string' } } } },
88
+ { name: 'leerness_state_verify', requiredTier: 'safe-write', description: '1.9.279 (UR-0031) — request_verification / verify_done. 현재 run 의 검증 결과 기록. 인자: { path?, result (required: "pass"|"fail"), note? }. 응답: { verified, result, run }.', inputSchema: { type: 'object', properties: { sessionKey: { type: 'string', description: '1.36.145 — 이 호출을 하는 세션의 주소. 다른 에이전트와 **같은 프로젝트**에서 일할 때 증거가 섞이지 않게 한다. 주지 않으면 서버 전체가 한 주소를 공유해 섞일 수 있다(응답의 keySource 로 확인).' }, path: { type: 'string' }, result: { type: 'string', enum: ['pass', 'fail'] }, note: { type: 'string' } }, required: ['result'] } },
89
+ { name: 'leerness_state_handoff', requiredTier: 'safe-write', description: '1.9.279 (UR-0031) — create_handoff / make_handoff. 현재 run 을 마감하고 다음 에이전트용 .leerness/handoff/latest.{md,json} 작성 (Claude→Goose→Codex 인수인계 표준). 인자: { path?, summary (required) }. 응답: { handoff, run }.', inputSchema: { type: 'object', properties: { sessionKey: { type: 'string', description: '1.36.145 — 이 호출을 하는 세션의 주소. 다른 에이전트와 **같은 프로젝트**에서 일할 때 증거가 섞이지 않게 한다. 주지 않으면 서버 전체가 한 주소를 공유해 섞일 수 있다(응답의 keySource 로 확인).' }, path: { type: 'string' }, summary: { type: 'string' } }, required: ['summary'] } },
90
90
  { name: 'leerness_get_project_context', requiredTier: 'read-only', description: '1.9.292 (UR-0031) — get_project_context. 외부 에이전트가 작업 시작 전 단 1콜로 "지금 무엇을 알아야 하는가"를 파악하는 집약 컨텍스트. 여러 소스(현재 작업/미답 사용자 요청/최근 결정/활성 룰/next-actions/memory surface/프로젝트 의도)를 한 번에 구조화 회수. read-only. 인자: { path? }. 응답: { version, project, currentTask, openRequests, recentDecisions, activeRules, nextActions, memory }.', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
91
91
  { name: 'leerness_about', requiredTier: 'read-only', description: '1.9.296 (UR-0030) — leerness 정체성/포지셔닝 회수. leerness 가 무엇인가: "AI 에이전트 운영 레이어"(실행기 아님) — 기억·정책·인수인계·검증·감사 5계층. read-only. 어떤 에이전트든 "이 도구가 무엇이고 무엇을 보완하는가"를 1콜로 파악. 응답: { identity, isNot, tagline, layers[], complements, entryPoints, surface }.', inputSchema: { type: 'object', properties: {} } },
92
92
  { name: 'leerness_honesty_check', requiredTier: 'read-only', description: '1.9.305 (사용자 명시) — AI 인식론적 정직성 점검. 주장/evidence 텍스트가 (1) 모르는 걸 아는 척(근거 없는 단정), (2) 검증 없는 섣부른 판단(추정+완료 결론), (3) 외부 정보 미수집(API/버전/스펙 언급+수집흔적 없음) 중 무엇에 해당하는지 휴리스틱 탐지. read-only. 에이전트가 자기 주장을 단언 전 self-check. 인자: { path?, taskId?, text? }. 응답: { ok, findings[], dimensions[], highCount }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, taskId: { type: 'string' }, text: { type: 'string' } } } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.143",
3
+ "version": "1.36.146",
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
 
@@ -14600,6 +14621,360 @@ total++;
14600
14621
  if (!ok) failed++;
14601
14622
  }
14602
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
+
14764
+ total++;
14765
+ // AI(1.36.145) — **MCP 표면에도 세션 주소가 있어야 한다.**
14766
+ // T-0116 을 CLI 에서만 고쳤다. MCP `state` 도구에는 주소 입력이 없어 한 서버의 모든 호출이
14767
+ // 같은(=무)주소로 접혀 전역 슬롯 오귀속이 그대로 남아 있었다 — 실측: A 의 증거가 B 의 run 에 붙고
14768
+ // 일하지 않은 B 가 `completion_claim_allowed: true` 를 받았다. 가드는 자기가 밟는 경로만 지킨다.
14769
+ {
14770
+ let ok = false; const bad = []; const dbg = {};
14771
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-145-'));
14772
+ const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
14773
+ for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
14774
+ 'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
14775
+ 'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG']) delete envP[k];
14776
+ const RP = (d, a, env) => cp.spawnSync(process.execPath, [CLI, ...a],
14777
+ { cwd: d, encoding: 'utf8', timeout: 120000, env: env || envP, maxBuffer: 32 * 1024 * 1024 });
14778
+ const mk = (name) => {
14779
+ const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
14780
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
14781
+ const ri = RP(d, ['init', d, '--yes', '--minimal']);
14782
+ if (ri.status !== 0) bad.push(`${name}:init exit=${ri.status}`);
14783
+ return d;
14784
+ };
14785
+ const KA = 'session-alpha01', KB = 'session-bravo01';
14786
+ try {
14787
+ // ① MCP 로 두 세션이 일해도 증거가 **일한 세션**에 붙는다.
14788
+ {
14789
+ const d = mk('mcp');
14790
+ const call = (name, a, id) => JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: a } });
14791
+ const req = [
14792
+ JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '1' } } }),
14793
+ call('leerness_state_start', { path: d, goal: 'A', sessionKey: KA }, 1),
14794
+ call('leerness_state_start', { path: d, goal: 'B', sessionKey: KB }, 2),
14795
+ call('leerness_state_record', { path: d, filesChanged: 'a.js', tests: 'npm test: 10/10', sessionKey: KA }, 3),
14796
+ call('leerness_state_verify', { path: d, result: 'pass', sessionKey: KB }, 4),
14797
+ ].join('\n') + '\n';
14798
+ const r = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], { cwd: d, input: req, encoding: 'utf8', env: envP, timeout: 300000 });
14799
+ const lines = String(r.stdout || '').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
14800
+ const jsonOf = (id) => { try { return JSON.parse(lines.find((x) => x.id === id).result.content[0].text); } catch { return null; } };
14801
+ const s1 = jsonOf(1), s2 = jsonOf(2), rec = jsonOf(3), ver = jsonOf(4);
14802
+ if (!s1 || !s2 || s1.started === s2.started) bad.push('①MCP 두 세션이 같은 run 을 받음 — 판별 불가');
14803
+ else {
14804
+ if (!rec || rec.recorded !== s1.started) bad.push(`①MCP: A 의 증거가 A 의 run 이 아닌 곳에 붙음(${rec && rec.recorded})`);
14805
+ const allowed = ver && ver.completion_claim_allowed && ver.completion_claim_allowed.allowed;
14806
+ if (allowed) bad.push('①MCP: 일하지 않은 세션이 완료 허용을 받음 — 증거 게이트가 뒤집힘');
14807
+ }
14808
+ // 도구 스키마가 실제로 주소를 받는다고 **광고**해야 한다(안 그러면 클라이언트가 줄 이유가 없다).
14809
+ const T = require(path.resolve(__dirname, '..', 'lib', 'mcp-tools'));
14810
+ const st = T.filter((t) => /^leerness_state_/.test(t.name));
14811
+ const withKey = st.filter((t) => t.inputSchema && t.inputSchema.properties && t.inputSchema.properties.sessionKey);
14812
+ if (st.length === 0 || withKey.length !== st.length) bad.push(`①state 도구 ${st.length}종 중 ${withKey.length}종만 sessionKey 를 광고`);
14813
+ dbg.mcp = { A: s1 && s1.started, B: s2 && s2.started, recordedTo: rec && rec.recorded,
14814
+ bAllowed: !!(ver && ver.completion_claim_allowed && ver.completion_claim_allowed.allowed),
14815
+ schemaTools: st.length, schemaWithKey: withKey.length };
14816
+ }
14817
+ // ② 인수인계 산출물이 **덮이지 않는다** — run 별 사본이 남고 `latest` 는 계약대로 최신.
14818
+ {
14819
+ const d = mk('handoff');
14820
+ const eA = Object.assign({}, envP, { LEERNESS_SESSION_ID: KA });
14821
+ const eB = Object.assign({}, envP, { LEERNESS_SESSION_ID: KB });
14822
+ RP(d, ['state', 'start', 'A', '--json'], eA);
14823
+ RP(d, ['state', 'start', 'B', '--json'], eB);
14824
+ RP(d, ['state', 'handoff', 'A 의 인수인계', '--json'], eA);
14825
+ RP(d, ['state', 'handoff', 'B 의 인수인계', '--json'], eB);
14826
+ const hd = path.join(d, '.leerness', 'handoff');
14827
+ const readJ = (f) => { try { return JSON.parse(fs.readFileSync(path.join(hd, f), 'utf8')); } catch { return null; } };
14828
+ const a = readJ('run-0001.json'), latest = readJ('latest.json');
14829
+ if (!a || a.handoff_summary !== 'A 의 인수인계') bad.push('②앞 세션의 인수인계가 덮여 사라짐');
14830
+ if (!latest || latest.run_id !== 'run-0002') bad.push(`②latest 가 최신이 아님(${latest && latest.run_id})`);
14831
+ dbg.handoff = { files: (() => { try { return fs.readdirSync(hd).length; } catch { return 0; } })(),
14832
+ aKept: !!(a && a.handoff_summary === 'A 의 인수인계'), latestRun: latest && latest.run_id };
14833
+ }
14834
+ // ③ 대조군 — 주소를 **안 주면** 종전처럼 동작한다(주소 없는 클라이언트를 막지 않는다).
14835
+ {
14836
+ const d = mk('nokey');
14837
+ const call = (name, a, id) => JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: a } });
14838
+ const req = [
14839
+ JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '1' } } }),
14840
+ call('leerness_state_start', { path: d, goal: '단독' }, 1),
14841
+ call('leerness_state_record', { path: d, filesChanged: 'x.js' }, 2),
14842
+ ].join('\n') + '\n';
14843
+ const r = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], { cwd: d, input: req, encoding: 'utf8', env: envP, timeout: 300000 });
14844
+ const lines = String(r.stdout || '').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
14845
+ const jsonOf = (id) => { try { return JSON.parse(lines.find((x) => x.id === id).result.content[0].text); } catch { return null; } };
14846
+ const s = jsonOf(1), rec = jsonOf(2);
14847
+ if (!s || !rec || rec.recorded !== s.started) bad.push(`③주소 없는 단독 흐름이 깨짐(${rec && rec.recorded} vs ${s && s.started})`);
14848
+ dbg.noKey = { started: s && s.started, recorded: rec && rec.recorded };
14849
+ }
14850
+ ok = bad.length === 0;
14851
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
14852
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
14853
+ console.log(ok ? `✓ AI(1.36.145) MCP 표면 증거 귀속: 도구가 세션 주소를 받아 **일한 세션**에 증거가 붙고 놀던 세션은 완료 허용을 못 받음(스키마 광고 포함) · 인수인계가 덮이지 않음(run 별 사본 + latest 최신) · 주소 없는 단독 클라이언트 무회귀 ${JSON.stringify(dbg)}`
14854
+ : '✗ 1.36.145 MCP 귀속 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
14855
+ if (!ok) failed++;
14856
+ }
14857
+
14858
+ total++;
14859
+ // AJ(1.36.146) — dry-run 가드가 **쓰기만** 보고 있었다.
14860
+ // 1.36.144 의 가드는 `writeUtf8`/`append`/`mkdirp` 세 함수만 봤다. io 를 우회하는 `fs.*` 변경 호출은
14861
+ // 정적으로 **330건**(unlinkSync 15 · rmSync 85 · writeFileSync 125 …) 이었고, 실측으로:
14862
+ // · `enforce remove --dry-run` 이 **진짜 pre-commit 훅을 삭제**했다
14863
+ // · `release cleanup --apply --dry-run` 이 **브랜치 60 → 10** 으로 지웠다
14864
+ // · 면제 정규식이 `cache` 를 경로 어디서나 매칭해 `…/cache/proj/` 프로젝트는 **가드가 통째로 꺼졌다**
14865
+ // · MCP 의 빈 `path` 가 서버 root 로 대체돼 엉뚱한 저장소에 썼다
14866
+ // 330곳을 배선하면 330개의 새 실수 자리가 생긴다 — 프로세스 진입점에서 한 번 막고, git 은 초크포인트에서 막는다.
14867
+ {
14868
+ let ok = false; const bad = []; const dbg = {};
14869
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-146-'));
14870
+ const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
14871
+ for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
14872
+ 'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
14873
+ 'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG', 'GIT_DIR']) delete envP[k];
14874
+ envP.GIT_CONFIG_GLOBAL = path.join(sb, 'gcg'); envP.GIT_CONFIG_SYSTEM = path.join(sb, 'gcs');
14875
+ try { fs.writeFileSync(envP.GIT_CONFIG_GLOBAL, ''); fs.writeFileSync(envP.GIT_CONFIG_SYSTEM, ''); } catch {}
14876
+ const G = (d, a) => cp.spawnSync('git', a, { cwd: d, encoding: 'utf8', timeout: 60000, env: envP });
14877
+ const RP = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a],
14878
+ { cwd: d, encoding: 'utf8', timeout: 120000, env: envP, maxBuffer: 32 * 1024 * 1024 });
14879
+ const mk = (dir, branches) => {
14880
+ fs.mkdirSync(dir, { recursive: true });
14881
+ G(dir, ['init', '-q', '-b', 'main', '.']); G(dir, ['config', 'user.email', 't@t']); G(dir, ['config', 'user.name', 't']);
14882
+ fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"p","version":"0.1.0"}');
14883
+ fs.writeFileSync(path.join(dir, 'a.txt'), 'a'); G(dir, ['add', '-A']); G(dir, ['commit', '-qm', 'i']);
14884
+ for (let i = 0; i < (branches || 0); i++) G(dir, ['branch', 'release/1.0.' + i]);
14885
+ RP(dir, ['init', dir, '--yes', '--minimal']); RP(dir, ['handoff', dir]);
14886
+ return dir;
14887
+ };
14888
+ try {
14889
+ // ① **삭제**도 막는다 — `enforce remove --dry-run` 이 진짜 훅을 지우지 않는다.
14890
+ {
14891
+ const d = mk(path.join(sb, 'enf'));
14892
+ RP(d, ['enforce', 'install', '--path', d, '--json', '--skip-verify']);
14893
+ const hook = path.join(d, '.git', 'hooks', 'pre-commit');
14894
+ const had = fs.existsSync(hook);
14895
+ const r = RP(d, ['enforce', 'remove', '--path', d, '--dry-run']);
14896
+ const still = fs.existsSync(hook);
14897
+ if (!had) bad.push('①설치가 안 돼 판별 조건이 서지 않음(공허)');
14898
+ else if (!still) bad.push('①--dry-run 인데 실제 훅을 삭제');
14899
+ else if (r.status === 0) bad.push('①삭제를 막았는데 성공이라 보고');
14900
+ // 대조군 — 플래그 없이는 실제로 제거돼야 한다.
14901
+ const r2 = RP(d, ['enforce', 'remove', '--path', d]);
14902
+ if (fs.existsSync(hook)) bad.push(`②플래그 없는 remove 가 훅을 못 지움(exit=${r2.status}) — 가드가 기능을 죽였다`);
14903
+ dbg.enforceRemove = { had, afterDry: still, dryExit: r.status, afterReal: fs.existsSync(hook) };
14904
+ }
14905
+ // ② **git 부작용**도 막는다 — `release cleanup --apply --dry-run` 이 브랜치를 지우지 않는다.
14906
+ {
14907
+ const d = mk(path.join(sb, 'rel'), 60);
14908
+ const cnt = () => (G(d, ['branch', '--list', 'release/*']).stdout || '').split('\n').filter((l) => l.trim()).length;
14909
+ const before = cnt();
14910
+ const r = RP(d, ['release', 'cleanup', '--apply', '--keep', '10', '--dry-run', '--path', d]);
14911
+ const afterDry = cnt();
14912
+ if (before < 50) bad.push(`②픽스처 브랜치 ${before}개 — 정리 임계값에 못 미쳐 공허`);
14913
+ else if (afterDry !== before) bad.push(`②--dry-run 인데 브랜치 ${before - afterDry}개 삭제`);
14914
+ // 대조군 — 플래그 없이는 실제로 정리돼야 한다.
14915
+ RP(d, ['release', 'cleanup', '--apply', '--keep', '10', '--path', d]);
14916
+ const afterReal = cnt();
14917
+ if (afterReal >= before) bad.push(`②플래그 없는 정리가 동작하지 않음(${before}→${afterReal}) — 가드가 기능을 죽였다`);
14918
+ dbg.releaseCleanup = { before, afterDry, afterReal, dryExit: r.status };
14919
+ }
14920
+ // ③ 면제 경계 — `cache` 가 **경로 어디에나** 있다고 가드가 꺼지면 안 된다.
14921
+ {
14922
+ const holder = path.join(sb, 'cache'); fs.mkdirSync(holder, { recursive: true });
14923
+ const d = path.join(holder, 'proj'); fs.mkdirSync(d, { recursive: true });
14924
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
14925
+ RP(d, ['init', d, '--yes', '--minimal']);
14926
+ const dj = path.join(d, '.harness', 'decisions.json');
14927
+ const before = (() => { try { return fs.readFileSync(dj, 'utf8'); } catch { return ''; } })();
14928
+ const r = RP(d, ['decision', 'add', 'CACHEPATH', '--reason', 'r', '--dry-run', '--path', d]);
14929
+ const after = (() => { try { return fs.readFileSync(dj, 'utf8'); } catch { return ''; } })();
14930
+ if (before !== after) bad.push('③cache/ 하위 프로젝트에서 가드가 통째로 꺼짐');
14931
+ if (r.status === 0) bad.push('③cache/ 하위인데 성공이라 보고');
14932
+ // 대조군 — 그 프로젝트도 플래그 없이는 정상 기록돼야 한다.
14933
+ RP(d, ['decision', 'add', 'CACHEPATH', '--reason', 'r', '--path', d]);
14934
+ const real = (() => { try { return fs.readFileSync(dj, 'utf8'); } catch { return ''; } })();
14935
+ if (!/CACHEPATH/.test(real)) bad.push('③cache/ 하위에서 정상 기록이 막힘 — 과차단');
14936
+ dbg.cacheDir = { dryChanged: before !== after, dryExit: r.status, realWrote: /CACHEPATH/.test(real) };
14937
+ }
14938
+ // ④ MCP 의 빈 `path` 가 서버 root 로 대체되지 않는다.
14939
+ {
14940
+ const server = mk(path.join(sb, 'mcpserver'));
14941
+ const dj = path.join(server, '.harness', 'decisions.json');
14942
+ const before = (() => { try { return fs.readFileSync(dj, 'utf8'); } catch { return ''; } })();
14943
+ const req = [
14944
+ JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '1' } } }),
14945
+ JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'leerness_decision_add', arguments: { path: '', title: 'MCPSNEAK', reason: 'r' } } }),
14946
+ ].join('\n') + '\n';
14947
+ cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], { cwd: server, input: req, encoding: 'utf8', env: envP, timeout: 180000 });
14948
+ const after = (() => { try { return fs.readFileSync(dj, 'utf8'); } catch { return ''; } })();
14949
+ if (/MCPSNEAK/.test(after) && !/MCPSNEAK/.test(before)) bad.push('④MCP 빈 path 가 서버 root 저장소에 씀');
14950
+ dbg.mcpEmptyPath = { wrote: /MCPSNEAK/.test(after) && !/MCPSNEAK/.test(before) };
14951
+ }
14952
+ // ⑤ **과차단 대조군** — 플래그 없는 평범한 흐름이 전부 그대로 돈다(fs 전면 패치의 최대 위험).
14953
+ {
14954
+ const d = path.join(sb, 'plain'); fs.mkdirSync(d, { recursive: true });
14955
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
14956
+ const exits = [
14957
+ RP(d, ['init', d, '--yes', '--minimal']).status,
14958
+ RP(d, ['handoff', d]).status,
14959
+ RP(d, ['task', 'add', '작업', '--path', d]).status,
14960
+ RP(d, ['decision', 'add', '결정', '--reason', 'r', '--path', d]).status,
14961
+ RP(d, ['audit', '--path', d]).status,
14962
+ ];
14963
+ if (exits.some((x) => x !== 0)) bad.push(`⑤플래그 없는 정상 흐름이 막힘(exits=${JSON.stringify(exits)})`);
14964
+ // dry-run 을 **구현한** 명령은 여전히 성공해야 한다.
14965
+ const impl = [RP(d, ['migrate', 'audit', '--path', d, '--dry-run']).status,
14966
+ RP(d, ['init', d, '--yes', '--minimal', '--dry-run']).status];
14967
+ if (impl.some((x) => x !== 0)) bad.push(`⑤dry-run 구현 명령이 막힘(exits=${JSON.stringify(impl)})`);
14968
+ dbg.noRegression = { plain: exits, dryImplemented: impl };
14969
+ }
14970
+ ok = bad.length === 0;
14971
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
14972
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
14973
+ console.log(ok ? `✓ AJ(1.36.146) dry-run 이 **삭제·git 부작용**까지 막음: enforce remove 가 훅을 안 지움 · release cleanup 이 브랜치를 안 지움(둘 다 플래그 없으면 동작) · cache/ 하위에서도 가드 유효 · MCP 빈 path 차단 · 평범한 흐름 무회귀 ${JSON.stringify(dbg)}`
14974
+ : '✗ 1.36.146 dry-run 전면가드 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
14975
+ if (!ok) failed++;
14976
+ }
14977
+
14603
14978
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
14604
14979
  if (failed > 0) process.exit(1);
14605
14980