leerness 1.36.124 → 1.36.126

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.124 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.126 하네스를 사용합니다. 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.124는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.126는 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.124는 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.124 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.126 릴리스 태그 이력** (수백 라운드) · _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.124: 2026-08-16
238
+ Last synced by Leerness v1.36.126: 2026-08-16
239
239
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
34
34
  const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
35
35
  const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
36
36
 
37
- const VERSION = '1.36.124';
37
+ const VERSION = '1.36.126';
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') 시 호스트 프로세스 오염.
@@ -3748,7 +3748,8 @@ function _selfTestCases() {
3748
3748
  } catch {} // 심링크 생성 권한 없는 환경 → 소스 불변식만으로 판정(false-BLOCK 회피)
3749
3749
  if (_linked) {
3750
3750
  try {
3751
- const _rep = _migrateWorkspaceDir(_st, { dryRun: true });
3751
+ // 1.36.126: CLI fail-closed 지만 복사 구현의 안전 속성은 계속 시험한다(내부 통로).
3752
+ const _rep = _migrateWorkspaceDir(_st, { dryRun: true, allowUnsupported: true });
3752
3753
  const _skipped = _rep.skippedFiles.some(f => /^loop\b/.test(f) && f.includes('symlink'));
3753
3754
  const _notFollowed = !_rep.copiedFiles.some(f => /^loop[\\/]/.test(f));
3754
3755
  symlinkGuard = symlinkGuard && _skipped && _notFollowed;
@@ -4261,7 +4262,7 @@ function _selfTestCases() {
4261
4262
  { name: 'UR-0074: _cadenceAssessment 릴리스 빈도 평가 (임계값) 행위 (1.9.374)', run: () => { const m = require('../lib/pure-utils'); if (typeof _cadenceAssessment !== 'function' || m._cadenceAssessment !== _cadenceAssessment || typeof releaseCadenceCmd !== 'function') return false; return _cadenceAssessment(7, 1, 1).level === 'very-high' && _cadenceAssessment(3, 1, 1).level === 'high' && _cadenceAssessment(1, 1, 1).level === 'moderate' && _cadenceAssessment(0.2, 1, 1).level === 'healthy' && _cadenceAssessment(7, 1, 1).recommendation.length > 0; } },
4262
4263
  { name: 'UR-0084: _withLock 획득/재진입/해제 + maxWaitMs 하드닝(10s) 행위 (1.9.375)', run: () => { if (typeof _withLock !== 'function') return false; const src = read(__filename); const hardened = /maxWaitMs = opts\.maxWaitMs \|\| 10000/.test(src); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_lock_')); try { const target = path.join(tmp, 'f.md'); let reentrant = false; const lockSeen = _withLock(target, () => { const exists = fs.existsSync(target + '.lock'); reentrant = (_withLock(target, () => 42) === 42); return exists; }); const cleaned = !fs.existsSync(target + '.lock'); return hardened && lockSeen === true && reentrant === true && cleaned; } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } } },
4263
4264
  { name: 'UR-0073 Phase D: _teamDeployGate 이중 게이트 (dry-run 기본/env 게이트/실행) 행위 (1.9.376)', run: () => { const m = require('../lib/pure-utils'); if (typeof _teamDeployGate !== 'function' || m._teamDeployGate !== _teamDeployGate) return false; const team = { id: 'd', deployCommand: 'echo hi' }; const noCmd = _teamDeployGate({ id: 'x' }, { yes: true, envOn: true }).mode === 'no-command'; const dry = _teamDeployGate(team, { yes: false, envOn: true }).mode === 'dry-run'; const gated = _teamDeployGate(team, { yes: true, envOn: false }).mode === 'gated'; const exec = _teamDeployGate(team, { yes: true, envOn: true }).mode === 'execute'; return noCmd && dry && gated && exec; } },
4264
- { name: 'UR-0025: _renderWorkspaceReferenceGuide 모듈 분리 + 빌더 동작 (1.9.377)', run: () => { const m = require('../lib/pure-utils'); if (typeof _renderWorkspaceReferenceGuide !== 'function' || m._renderWorkspaceReferenceGuide !== _renderWorkspaceReferenceGuide) return false; const g = _renderWorkspaceReferenceGuide('.leerness', '9.9.9', '2026-01-01T00:00:00.000Z'); const wrapperThin = read(__filename).includes('return _renderWorkspaceReferenceGuide(dirName, VERSION, new Date().toISOString())'); return g.includes('.leerness/progress-tracker.md') && g.includes('9.9.9') && g.includes('자주 묻는 위치') && g.includes('마이그레이션 안내') && wrapperThin; } },
4265
+ { name: 'UR-0025: _renderWorkspaceReferenceGuide 모듈 분리 + 빌더 동작 (1.9.377)', run: () => { const m = require('../lib/pure-utils'); if (typeof _renderWorkspaceReferenceGuide !== 'function' || m._renderWorkspaceReferenceGuide !== _renderWorkspaceReferenceGuide) return false; const g = _renderWorkspaceReferenceGuide('.leerness', '9.9.9', '2026-01-01T00:00:00.000Z'); const wrapperThin = read(__filename).includes('return _renderWorkspaceReferenceGuide(dirName, VERSION, new Date().toISOString())'); /* 1.36.126 (T-0107): 제목 리터럴('마이그레이션 안내')을 붙잡던 자리 — 그 절이 바로 **AI 를 죽은 사본으로 안내**하고 있었다. 제목이 아니라 계약을 건다: ① `.leerness` 상황을 다루는 절이 여전히 있고 ② 그 절이 "우선 사용/가야 함" 같은 **지시**를 하지 않는다(생성 결과 문자열에 대한 행위 단언 — 소스 자기참조가 아니다). */ const guideSafe = /\.leerness/.test(g) && !/우선 사용/.test(g) && !/로 가야 함/.test(g) && /읽지 마십시오/.test(g); return g.includes('.leerness/progress-tracker.md') && g.includes('9.9.9') && g.includes('자주 묻는 위치') && guideSafe && wrapperThin; } },
4265
4266
  { name: 'UR-0073: team MCP 도구 2종(read-only) 정의 + dispatch 와이어 (1.9.378)', run: () => { const tools = require('../lib/mcp-tools'); const src = read(__filename); const tl = tools.find(t => t.name === 'leerness_team_list'); const tp = tools.find(t => t.name === 'leerness_team_preview'); const defsOk = tl && tl.requiredTier === 'read-only' && tp && tp.requiredTier === 'read-only' && tp.inputSchema.required && tp.inputSchema.required.includes('id'); const wired = src.includes("case " + "'leerness_team_list':") && src.includes("case " + "'leerness_team_preview':") && /cliArgs = \['team', 'list'/.test(src) && /cliArgs = \['team', 'preview'/.test(src); return !!defsOk && wired; } },
4266
4267
  { name: 'UR-0025 심화: pulse 렌더 코어 분리 — _memorySurface + _renderPulseLine 행위 (1.9.379)', run: () => { const m = require('../lib/pure-utils'); if (typeof _memorySurface !== 'function' || typeof _renderPulseLine !== 'function' || m._memorySurface !== _memorySurface || m._renderPulseLine !== _renderPulseLine) return false; const ms = _memorySurface({ tasks: 1, decisions: 2, rules: 3, milestones: 4, lessons: 5 }) === 'T1/D2/R3/P4/L5' && _memorySurface({}) === 'T0/D0/R0/P0/L0'; const base = _renderPulseLine({ version: '1.0.0', roundCount: 7, mcpTools: 85, memorySurface: 'T0/D1/R0/P2/L0' }); const ln = base.includes('v1.0.0') && base.includes('R7') && base.includes('MCP 85') && base.includes('T0/D1/R0/P2/L0') && !base.includes('🎯') && !base.includes('abnormal'); const full = _renderPulseLine({ version: '1.0.0', roundCount: 7, mcpTools: 85, memorySurface: 'x', nextMilestone: 400, etaDays: 6, abnormalShutdown: 'high' }); const ln2 = full.includes('🎯 R400 (6d)') && full.includes('abnormal:high'); const wired = read(__filename).includes('const line = _renderPulseLine(data)') && read(__filename).includes('data.memorySurface = _memorySurface('); return ms && ln && ln2 && wired; } },
4267
4268
  { name: 'UR-0025: REQUIRED_WORKSPACE_FILES 단일출처 — verify/migrate audit·apply 3중 중복 제거 (1.9.380)', run: () => { const c = require('../lib/catalogs'); if (REQUIRED_WORKSPACE_FILES !== c.REQUIRED_WORKSPACE_FILES) return false; const listOk = Array.isArray(c.REQUIRED_WORKSPACE_FILES) && c.REQUIRED_WORKSPACE_FILES.length === 9 && c.REQUIRED_WORKSPACE_FILES.includes('AGENTS.md') && c.REQUIRED_WORKSPACE_FILES.includes('.harness/plan.md'); const harnessUses = (read(__filename).match(/const required = REQUIRED_WORKSPACE_FILES;/g) || []).length >= 1; const migUses = (read(path.join(path.dirname(__filename), '..', 'lib', 'migrate.js')).match(/const required = REQUIRED_WORKSPACE_FILES;/g) || []).length >= 2; return listOk && harnessUses && migUses; } },
@@ -4920,7 +4921,12 @@ function _selfTestCases() {
4920
4921
  const sigOpts = /function verifyClaimCmd\(root, taskId, opts = \{\}\)/.test(src);
4921
4922
  const fn = /function verifyClaimAllCmd\(root\)/.test(src);
4922
4923
  const doneFilter = src.includes("rows.filter(r => /done|완료|completed/i.test(String(r.status || '')))");
4923
- const reuse = src.includes("doneRows.map(r => verifyClaimCmd(root, r.id, { collect: true }))");
4924
+ // 1.36.125: 여기에 `runMemo` 함께 넘어가는 것도 이제 **계약**이다 빠지면 같은 테스트 스위트를
4925
+ // done 행 수만큼 반복 실행한다(100주장 × 300초). 그래서 리터럴을 갱신만 하지 않고 메모 배선까지 건다.
4926
+ // ⚠ 실행 횟수 자체는 소스가 아니라 e2e U 블록이 **실제로 세어** 판정한다(소스가드는 배선 유무만).
4927
+ const reuse = src.includes("doneRows.map(r => verifyClaimCmd(root, r.id, { collect: true, runMemo }))")
4928
+ && src.includes('const runMemo = {};')
4929
+ && src.includes('if (_runMemo) _runMemo.result = runResult;');
4924
4930
  // collect 게이팅 부울이 --json/human 경로와 동일 출처(분기 없음) — 정밀 검사를 일괄 모드에서도 그대로 재사용
4925
4931
  const collectGate = src.includes('if (opts.collect) {') && src.includes("if (!filesAllExist) reasons.push('files-missing')") && src.includes("if (claimsChecked && !strictOk) reasons.push('optimism/honesty')") && src.includes("if (!gitClaimOk) reasons.push('git-mismatch')") && src.includes("if (claimsChecked && stubFiles.length > 0) reasons.push('stub-impl')");
4926
4932
  const routed = src.includes("if (args[1] === '--all' || has('--all')) return verifyClaimAllCmd(_p)");
@@ -6633,10 +6639,155 @@ function _selfTestCases() {
6633
6639
  && s.includes('const lang = detectLanguageValue(root, ') && s.includes("managedMerge(key, tmpl, cur, null, { lang, root })") // 1.36.124: 대상 루트 전달이 계약이다(검수 P1);
6634
6640
  return f13ok && skipOk && f7ok && f11ok && guards;
6635
6641
  } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
6636
- } }
6642
+ } },
6643
+ // 1.36.125 (검수 P2 클래스 스윕): MCP 호출 예산은 **그 명령이 스스로 거는 내부 타임아웃보다 커야** 한다.
6644
+ // 1.36.124 는 `selftest` 이름 하나만 구제했고, 그 사이 verify-claim --run-tests(내부 300초)·
6645
+ // referee verify(600초)·api-skill add(최대 110초)는 60초에 잘려 **정상 동작을 거짓 실패로** 보고했다.
6646
+ // 이 가드는 소스 문자열이 아니라 **실제 `_mcpTimeoutFor()` 호출**로 판정한다(문자열 가드는 표가 바뀌면 조용히 죽는다).
6647
+ // 그리고 내부 한도는 손으로 적은 숫자가 아니라 **해당 파일에서 실제로 읽어** 대조한다 — 내부 한도를
6648
+ // 올리고 표를 안 고치면 여기서 깨진다(허용목록이 소비자보다 뒤처지는 것을 막는 유일한 기제).
6649
+ { name: '1.36.125 (검수 P2): MCP 긴 예산 표 — 실제 예산 > 각 경로의 내부 타임아웃 + 대조군(무관 명령은 기본값)', run: () => {
6650
+ if (!Array.isArray(MCP_LONG_PATHS) || MCP_LONG_PATHS.length < 5) return false;
6651
+ const saved = process.env.LEERNESS_MCP_TIMEOUT_MS;
6652
+ delete process.env.LEERNESS_MCP_TIMEOUT_MS; // override 가 켜져 있으면 표를 재는 것이 아니다
6653
+ try {
6654
+ // ① 표의 각 경로가 실제로 그 예산을 받는가 + 예산이 내부 한도를 넘는가
6655
+ const probes = {
6656
+ 'selftest': ['selftest', '--json'],
6657
+ 'task-done-probe': ['task', 'update', 'T-1', '--status', 'done'],
6658
+ 'verify-claim-run-tests': ['verify-claim', 'T-1', '--path', '.', '--run-tests'],
6659
+ 'referee-verify': ['referee', 'verify', 'r1', '--path', '.', '--json'],
6660
+ 'api-skill-add': ['api-skill', 'add', '--path', '.', '--url', 'https://example.com', '--json'],
6661
+ };
6662
+ for (const p of MCP_LONG_PATHS) {
6663
+ const args = probes[p.id];
6664
+ if (!args) return false; // 표에 새 항목이 생기면 프로브도 함께 적어야 한다
6665
+ const got = _mcpTimeoutFor(args);
6666
+ if (got !== p.budgetMs) return false; // 술어가 그 인자를 실제로 집어내는가
6667
+ if (!(got > p.mustExceedMs)) return false; // 예산이 내부 한도를 넘는가
6668
+ if (p.src) { // 내부 한도를 **소스에서 실제로 읽어** 대조
6669
+ const raw = read(path.join(__dirname, '..', p.src.file));
6670
+ const m = p.src.re.exec(raw);
6671
+ if (!m) return false; // 리터럴을 못 찾으면 공허통과가 아니라 실패
6672
+ const actual = Number(m[1]) * (p.src.scale || 1);
6673
+ if (!Number.isFinite(actual) || actual !== p.mustExceedMs) return false; // 소스가 바뀌면 표도 바뀌어야
6674
+ if (!(got > actual)) return false;
6675
+ }
6676
+ }
6677
+ // ② 대조군 — 무관한 명령은 기본값을 받는다(긴 예산이 옆으로 새면 서버가 11분씩 얼어붙는다)
6678
+ const ctrl = [['health', '--json'], ['task', 'list'], ['task', 'update', 'T-1', '--status', 'in_progress'],
6679
+ ['verify-claim', 'T-1', '--path', '.'], ['referee', 'list', '--path', '.'], ['api-skill', 'list', '--path', '.']];
6680
+ if (!ctrl.every(a => _mcpTimeoutFor(a) === MCP_TIMEOUT_DEFAULT_MS)) return false;
6681
+ // ③ 타임아웃 사유 한 줄은 **그 호출에만** 붙고, 다른 경로의 사유가 새지 않는다
6682
+ const hintSelf = _mcpTimeoutHint(['selftest', '--json']);
6683
+ if (!/긴 예산\(300초\)/.test(hintSelf) || !hintSelf.includes('실측 116초')) return false;
6684
+ if (/실측 116초/.test(_mcpTimeoutHint(['referee', 'verify', 'r1']))) return false; // 옆 경로 사유가 새면 실패
6685
+ if (_mcpTimeoutHint(['health', '--json']) !== '') return false; // 무관한 도구엔 아무 사유도 없다
6686
+ // ④ 명시 override 는 여전히 최우선이고, 그때는 **표의 사유를 말하지 않는다**(5초에 잘린 사용자에게
6687
+ // "긴 예산 300초를 받습니다" 라고 하면 거짓말이다 — 1.36.125 에서 실제로 그렇게 쓸 뻔했다)
6688
+ process.env.LEERNESS_MCP_TIMEOUT_MS = '7000';
6689
+ const overridden = _mcpTimeoutFor(['selftest', '--json']) === 7000 && _mcpTimeoutFor(['health']) === 7000
6690
+ && _mcpTimeoutHint(['selftest', '--json']) === '';
6691
+ delete process.env.LEERNESS_MCP_TIMEOUT_MS;
6692
+ return overridden;
6693
+ } finally { if (saved !== undefined) process.env.LEERNESS_MCP_TIMEOUT_MS = saved; else delete process.env.LEERNESS_MCP_TIMEOUT_MS; }
6694
+ } },
6695
+ // 1.36.125: 측정 통제 자체의 가드 — 목록이 비거나 배선이 끊기면 selftest 는 다시 사용자 설정에 휘둘린다.
6696
+ // 1.36.126 (T-0107): `.leerness` 를 살아 있는 저장소로 **주장하지 않는다**.
6697
+ // ⚠ 이 케이스는 **행위검사만** 한다(자기 소스 읽기 금지 — 1.36.84 메타가드). 상수와 하드코딩 수의
6698
+ // 모순 금지 불변식은 e2e V 블록이 소스를 읽어 건다. 여기서는 실제 함수를 불러 결과를 본다.
6699
+ { name: '1.36.126 (T-0107): 워크스페이스 대체 디렉터리 — 해석기가 살아 있는 저장소를 말함 + 무시 고지 + 대조군 침묵 (행위검사)', run: () => {
6700
+ // 행위 ① 해석기는 env·마커가 있어도 살아 있는 저장소를 말한다(예전엔 `.leerness` 를 말해 미초기화 오판을 냈다)
6701
+ const arena = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ws126-'));
6702
+ const savedEnv = process.env.LEERNESS_WORKSPACE_DIR;
6703
+ try {
6704
+ fs.mkdirSync(path.join(arena, '.leerness'), { recursive: true });
6705
+ fs.writeFileSync(path.join(arena, '.leerness', 'MIGRATED_FROM_HARNESS'), 'x');
6706
+ fs.mkdirSync(path.join(arena, '.harness'), { recursive: true });
6707
+ fs.writeFileSync(path.join(arena, '.harness', 'HARNESS_VERSION'), VERSION);
6708
+ process.env.LEERNESS_WORKSPACE_DIR = '.leerness';
6709
+ if (_workspaceDirName(arena) !== '.harness') return false;
6710
+ if (!_workspaceDirAbs(arena).endsWith('.harness')) return false;
6711
+ // 마커가 있어도 초기화된 프로젝트로 인식해야 한다(예전엔 여기서 "미초기화" 오판이 났다)
6712
+ if (_isInitialized(arena) !== true) return false;
6713
+ // 행위 ② 무시한 설정은 **말한다** — env·마커 각각, 그리고 아무것도 없으면 침묵(오탐 0)
6714
+ const both = _workspaceDirAltIgnored(arena);
6715
+ if (!both || both.env !== '.leerness' || both.marker !== true || both.effective !== '.harness') return false;
6716
+ delete process.env.LEERNESS_WORKSPACE_DIR;
6717
+ const markerOnly = _workspaceDirAltIgnored(arena);
6718
+ if (!markerOnly || markerOnly.env !== null || markerOnly.marker !== true) return false;
6719
+ const clean = path.join(arena, 'clean'); fs.mkdirSync(clean, { recursive: true });
6720
+ if (_workspaceDirAltIgnored(clean) !== null) return false; // 대조군 — 평범한 프로젝트는 조용하다
6721
+ // 행위 ③ 코어가 실제로 거부하는가 — CLI 인자 없이 함수를 직접 불러 판정(문자열이 아니라 결과로)
6722
+ const blocked = _migrateWorkspaceDir(arena, { dryRun: true });
6723
+ if (blocked.blocked !== true || blocked.blockedReason !== 'workspace-dir-alt-unsupported') return false;
6724
+ if ((blocked.copiedFiles || []).length !== 0) return false;
6725
+ // 그리고 내부 시험 통로는 여전히 복사 구현을 태운다(안전 속성이 도달 불가로 썩지 않게)
6726
+ const seam = _migrateWorkspaceDir(arena, { dryRun: true, allowUnsupported: true });
6727
+ if (seam.blocked === true) return false;
6728
+ return true;
6729
+ } catch { return false; }
6730
+ finally {
6731
+ if (savedEnv !== undefined) process.env.LEERNESS_WORKSPACE_DIR = savedEnv; else delete process.env.LEERNESS_WORKSPACE_DIR;
6732
+ try { fs.rmSync(arena, { recursive: true, force: true }); } catch {}
6733
+ }
6734
+ } },
6735
+ { name: '1.36.125 (검수 P2): selftest 측정 통제 — 통제 목록 실재 + 실제 삭제 + 무시 사실 보고(조용한 무시 금지)', run: () => {
6736
+ const must = ['LEERNESS_MCP_PROFILE', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG', 'LEERNESS_MCP_TIMEOUT_MS'];
6737
+ if (!must.every(k => _SELFTEST_CONTROLLED_ENV.includes(k))) return false;
6738
+ // 하네스가 일부러 켜는 것은 통제 목록에 들어가면 안 된다(측정 계약을 스스로 지우는 셈)
6739
+ if (['LEERNESS_OFFLINE', 'LEERNESS_INTERNAL'].some(k => _SELFTEST_CONTROLLED_ENV.includes(k))) return false;
6740
+ const savedP = process.env.LEERNESS_MCP_PROFILE, savedL = process.env.LEERNESS_LANG;
6741
+ try {
6742
+ process.env.LEERNESS_MCP_PROFILE = 'core'; process.env.LEERNESS_LANG = 'en';
6743
+ const dropped = _controlSelftestEnv();
6744
+ const gone = process.env.LEERNESS_MCP_PROFILE === undefined && process.env.LEERNESS_LANG === undefined;
6745
+ const told = dropped.some(s => s.startsWith('LEERNESS_MCP_PROFILE=')) && dropped.some(s => s.startsWith('LEERNESS_LANG='));
6746
+ // 검수 P1: "검증 중에만" 이라고 말했으면 **끝나면 되돌려 놔야** 한다(export 된 함수라 호출자 설정이 영구히 사라졌다)
6747
+ dropped.restore();
6748
+ const restored = process.env.LEERNESS_MCP_PROFILE === 'core' && process.env.LEERNESS_LANG === 'en';
6749
+ // 대조군 — 설정이 없으면 아무것도 보고하지 않는다(빈 컬렉션 공허참으로 통과하지 않게 위에서 실제 값을 넣었다)
6750
+ delete process.env.LEERNESS_MCP_PROFILE; delete process.env.LEERNESS_LANG;
6751
+ const quiet = _controlSelftestEnv().length === 0;
6752
+ return gone && told && restored && quiet;
6753
+ } finally {
6754
+ if (savedP !== undefined) process.env.LEERNESS_MCP_PROFILE = savedP; else delete process.env.LEERNESS_MCP_PROFILE;
6755
+ if (savedL !== undefined) process.env.LEERNESS_LANG = savedL; else delete process.env.LEERNESS_LANG;
6756
+ }
6757
+ } },
6637
6758
  ];
6638
6759
  }
6760
+ // 1.36.125 (검수 P2 + 클래스 스윕 실측): selftest 는 **자기가 재는 환경을 스스로 통제해야** 한다.
6761
+ // 실측(대조군 무설정 343/343 통과) — 공개 설정 하나를 켜 두기만 해도 설치 검증 도구가 거짓 실패를 냈다:
6762
+ // LEERNESS_MCP_PROFILE=core → 342/343 (프로브가 프로필을 상속해 없는 도구를 호출)
6763
+ // LEERNESS_WORKSPACE_DIR=.leerness → 339/343 + stderr 722B ("미초기화 디렉토리" 오판)
6764
+ // LEERNESS_LANG=en → 341/343 (픽스처가 정한 언어를 env 가 덮어써 ko 기대값이 어긋남)
6765
+ // 즉 "설치가 손상됐다" 는 보고가 사실은 **사용자 설정 때문**이었다 — 진단 도구가 낼 수 있는 최악의 거짓말이다.
6766
+ // 같은 방식으로 잰 AUTONOMOUS·ENFORCE_POLICY·HOOK·SKILL_AUTO_INSTALL·TZ 5종은 **343/343 무해**여서 넣지 않는다
6767
+ // (손 분류로 목록을 부풀리지 않는다 — 깨지는 것만 넣고, 안 깨지는 것은 안 깨진다고 적는다).
6768
+ // 1.36.124 는 이 클래스를 프로브 2개에만(_probeEnv) 적용했다. 프로브마다 지우면 새 프로브가 빠지므로
6769
+ // 병목에서 한 번 지운다 — 여기서 지우면 이 프로세스가 띄우는 **모든 자식**이 통제된 환경을 물려받는다.
6770
+ // ⚠ LEERNESS_OFFLINE / LEERNESS_INTERNAL 등 하네스가 **일부러 켜는** 것은 지우지 않는다(측정 계약의 일부).
6771
+ // ⚠ LANG 을 지워도 영어 커버리지는 죽지 않는다 — en 경로는 `_selftestFixture('en')`(init --language en)이
6772
+ // 따로 검증한다. 지우는 목적은 "언어의 출처를 픽스처 하나로 되돌리는" 것이지 en 을 안 보는 것이 아니다.
6773
+ const _SELFTEST_CONTROLLED_ENV = ['LEERNESS_MCP_PROFILE', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_MCP_TIMEOUT_MS', 'LEERNESS_LANG'];
6774
+ function _controlSelftestEnv() {
6775
+ const dropped = [], saved = {};
6776
+ for (const k of _SELFTEST_CONTROLLED_ENV) {
6777
+ if (process.env[k] !== undefined) { dropped.push(`${k}=${process.env[k]}`); saved[k] = process.env[k]; delete process.env[k]; }
6778
+ }
6779
+ // 1.36.125 (검수 P1): `selfTestCmd` 는 **export 된 라이브러리 함수**다. CLI/MCP 는 자식이 끝나며 격리되지만
6780
+ // 직접 호출하면 호출자 프로세스의 설정이 **영구히** 사라진다(실측: en/.leerness → undefined).
6781
+ // 게다가 우리 출력은 "검증 중에만 무시" 라고 말한다 — 복원하지 않으면 그 안내 자체가 거짓이 된다.
6782
+ const restore = () => { for (const k of Object.keys(saved)) process.env[k] = saved[k]; };
6783
+ // 조용히 지우지 않는다 — 무엇을 무시했는지 사람과 JSON 소비자 모두에게 말한다.
6784
+ return Object.assign(dropped, { restore });
6785
+ }
6639
6786
  function selfTestCmd(opts = {}) {
6787
+ const droppedEnv = _controlSelftestEnv();
6788
+ try { return _selfTestCmdInner(opts, droppedEnv); } finally { droppedEnv.restore(); }
6789
+ }
6790
+ function _selfTestCmdInner(opts, droppedEnv) {
6640
6791
  const isTty = process.stdout && process.stdout.isTTY;
6641
6792
  const gr = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
6642
6793
  const rd = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
@@ -6651,12 +6802,18 @@ function selfTestCmd(opts = {}) {
6651
6802
  const pass = results.filter(r => r.ok).length;
6652
6803
  const fail = results.length - pass;
6653
6804
  if (opts.json) {
6654
- log(JSON.stringify({ version: VERSION, total: results.length, pass, fail, ok: fail === 0, results }, null, 2));
6805
+ log(JSON.stringify({ version: VERSION, total: results.length, pass, fail, ok: fail === 0, controlledEnv: droppedEnv, results }, null, 2));
6655
6806
  if (fail > 0) process.exitCode = 1;
6656
6807
  return;
6657
6808
  }
6658
6809
  log(cy(`# leerness selftest (1.9.258) — 코어 함수 무결성 검증 (v${VERSION})`));
6659
6810
  log('');
6811
+ // 무시한 설정은 반드시 보인다 — 사용자가 켜 둔 것이 안 먹었다는 사실을 숨기면 그 자체가 거짓 보고다(1.36.125).
6812
+ if (droppedEnv.length) {
6813
+ log(dm(` ⓘ 측정 통제: 검증 중에만 ${droppedEnv.length}개 환경설정을 무시합니다 — ${droppedEnv.join(', ')}`));
6814
+ log(dm(` (이 설정들이 켜져 있으면 selftest 가 "설치 손상" 을 거짓 보고했습니다 — 실제 제품 동작은 그대로입니다)`));
6815
+ log('');
6816
+ }
6660
6817
  results.forEach(r => {
6661
6818
  if (r.ok) log(` ${gr('✓')} ${r.name}`);
6662
6819
  else log(` ${rd('✗')} ${r.name}${r.err ? dm(' — ' + r.err) : ''}`);
@@ -8339,9 +8496,14 @@ function _p0097AsyncLockOk() {
8339
8496
  finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch {} }
8340
8497
  }
8341
8498
  // 1.36.124 (살아 있는 MCP 통신 실측): selftest 프로브가 `process.env` 를 그대로 상속했다 —
8342
- // MCP 래퍼는 `LEERNESS_NO_WORKFLOW_GUIDE` 등으로 **안내 출력을 일부러 끄고** 자식을 띄우므로,
8343
- // "안내가 뜬다" 를 단언하는 프로브가 MCP 경유에서만 실패했다. 그 결과 `leerness_selftest` 가
8499
+ // MCP 래퍼는 자식을 띄울 안내·점검 계열 변수를 **일부러 켜 두는데**, 그 환경에서 도는 프로브가
8500
+ // " 동작이 살아 있다" 를 단언하다 MCP 경유에서만 실패했다. 그 결과 `leerness_selftest` 가
8344
8501
  // AI 에게 "설치가 깨졌다" 고 **거짓 보고**했다(343 중 1 실패). 프로브는 억제 변수를 걷어낸 환경에서 돈다.
8502
+ // 1.36.125 (검수 P3, 주석 정정): 직접 원인은 workflow guide 의 stdout 이 아니다 — reminder 쪽은
8503
+ // `LEERNESS_NO_DRIFT_CHECK`, usage 쪽은 `LEERNESS_INTERNAL` 이었다. 원인을 잘못 적어 두면 다음 회귀
8504
+ // 분석이 엉뚱한 곳을 판다. 목록 전체를 걷어내는 처방 자체는 그대로 유효하다.
8505
+ // ⚠ 이 목록은 **프로브 단위** 처방이다. 프로필·워크스페이스·언어처럼 제품 의미를 바꾸는 설정은
8506
+ // 프로브마다 지우면 새 프로브가 빠지므로 selftest 진입점에서 통제한다(_SELFTEST_CONTROLLED_ENV).
8345
8507
  const _PROBE_SUPPRESS_ENV = ['LEERNESS_INTERNAL', 'LEERNESS_NO_BANNER', 'LEERNESS_NO_PROMPT',
8346
8508
  'LEERNESS_NO_WORKFLOW_GUIDE', 'LEERNESS_NO_STALE_CHECK', 'LEERNESS_NO_DRIFT_CHECK',
8347
8509
  'LEERNESS_NO_HEADLINE', 'LEERNESS_NO_LAZY_WARN'];
@@ -9079,7 +9241,7 @@ function commandsCmd(root) {
9079
9241
  { cmd: 'migrate [path] [--dry-run]', desc: '업데이트 마이그레이션', descEn: 'update migration' },
9080
9242
  { cmd: 'update [--check|--yes|--force]', desc: '자가 업데이트', descEn: 'self-update' },
9081
9243
  { cmd: 'wakeup-interval get|set|auto|history|record', desc: 'adaptive wakeup (1.9.210)', descEn: 'adaptive wakeup interval (1.9.210)' },
9082
- { cmd: 'workspace-dir get|guide', desc: '워크스페이스 디렉토리 (1.9.211)', descEn: 'workspace directory (1.9.211)' },
9244
+ { cmd: 'workspace-dir get|guide', desc: '워크스페이스 디렉토리 확인 — 이 빌드는 .harness 만 사용 (migrate-workspace-dir 는 1.36.126 부터 거부, T-0107)', descEn: 'show the workspace directory — this build uses .harness only (migrate-workspace-dir refuses since 1.36.126, T-0107)' },
9083
9245
  { cmd: 'parent detect|adopt [--select <kinds>] [--apply]', desc: '상위 leerness 부모 탐지 + 자산 게이트형 adopt (1.30.2~3)', descEn: 'detect a parent leerness above + gated asset adoption (1.30.2~3)' },
9084
9246
  { cmd: 'intent classify|expand|domains "<request>"', desc: '의도 파악 + scope (1.9.213)', descEn: 'infer intent + scope (1.9.213)' },
9085
9247
  { cmd: 'constraints list|check|add', desc: '플랫폼/API 제약 (1.9.208)', descEn: 'platform/API constraints (1.9.208)' },
@@ -9634,20 +9796,49 @@ function _computeAdaptiveInterval(root) {
9634
9796
  // - LEERNESS_WORKSPACE_DIR env 또는 .leerness/MARKER 존재 시 .leerness 사용
9635
9797
  // - leerness migrate-workspace-dir CLI: .harness → .leerness copy + AI reference guide 생성
9636
9798
  // - AI 참조 가이드: WHERE_TO_FIND.md (디렉토리 구조 + 파일별 역할 + 마이그레이션 history)
9637
- function _workspaceDirName(root) {
9638
- // 1) env override
9639
- if (process.env.LEERNESS_WORKSPACE_DIR) {
9640
- const v = process.env.LEERNESS_WORKSPACE_DIR.trim();
9641
- if (v) return v;
9642
- }
9643
- // 2) .leerness 마이그레이션 마커 존재 .leerness 우선
9799
+ // 1.36.126 (T-0107 실측): 이 해석기는 **소수파**였다. 실측 —
9800
+ // 해석기 소비처 9곳 vs `.harness` 경로 구성 252곳(bin 215 · lib 37) + 안내/템플릿 문자열 634곳.
9801
+ // 그래서 env 나 마커로 `.leerness` 를 고르게 해도 **쓰기는 전부 `.harness` 로 갔다**(실측:
9802
+ // migrate `task add` 가 `.leerness` 에 안 남음. env 를 켜도 동일). 결과는 두 가지 해악이었다:
9803
+ // ① `_isInitialized` 만 `.leerness` 를 봐서 정상 프로젝트를 "미초기화" 로 오판(실측 selftest 4건 실패)
9804
+ // ② `.leerness` 는 마이그레이션 시점에 얼어붙은 **죽은 사본**이 되고 WHERE_TO_FIND.md 가 AI 를 그리로 안내
9805
+ // 소수파를 따르는 것은 "다르게 동작" 아니라 **틀리게 동작**이었다. 살아 있는 저장소를 그대로 말한다.
9806
+ // ⚠ 이 상수를 true 로 바꾸려면 하드코딩 252곳을 먼저 없애야 한다 — selftest 가 그 순서를 강제한다.
9807
+ const WORKSPACE_DIR_ALT_SUPPORTED = false;
9808
+ // 거부 사유로 대는 숫자는 **실행 시** 센다 — 손으로 적으면 다음 편집에서 곧바로 낡는다(검수 P2 실측: 252→255).
9809
+ // 거부 경로에서만 불리므로 비용은 문제되지 않는다. 세다가 실패하면 숫자를 지어내지 말고 null 로 둔다.
9810
+ function _measureWorkspaceHardcoding() {
9811
+ const out = { resolverConsumers: null, hardcodedPathSites: null, hardcodedTextMentions: null };
9644
9812
  try {
9645
- if (root && exists(path.join(root, '.leerness', 'MIGRATED_FROM_HARNESS'))) {
9646
- return '.leerness';
9813
+ let src = read(__filename);
9814
+ const libDir = path.join(__dirname, '..', 'lib');
9815
+ for (const f of fs.readdirSync(libDir)) if (f.endsWith('.js')) src += '\n' + read(path.join(libDir, f));
9816
+ out.hardcodedPathSites = (src.match(/path\.join\([^)]*'\.harness'/g) || []).length;
9817
+ out.hardcodedTextMentions = (src.match(/\.harness\/[A-Za-z0-9_.-]+/g) || []).length;
9818
+ out.resolverConsumers = (src.match(/_workspaceDir(?:Name|Abs)\(/g) || []).length;
9819
+ } catch { /* 셀 수 없으면 null — 지어내지 않는다 */ }
9820
+ return out;
9821
+ }
9822
+ // 사용자가 켠 설정이 **안 먹는다는 사실**은 반드시 말해야 한다(조용한 무시 금지 — 1.36.125 와 같은 원칙).
9823
+ function _workspaceDirAltIgnored(root) {
9824
+ if (WORKSPACE_DIR_ALT_SUPPORTED) return null;
9825
+ const env = (process.env.LEERNESS_WORKSPACE_DIR || '').trim();
9826
+ let marker = false;
9827
+ try { marker = !!(root && exists(path.join(root, '.leerness', 'MIGRATED_FROM_HARNESS'))); } catch {}
9828
+ if (!env && !marker) return null;
9829
+ return { env: env || null, marker, effective: '.harness' };
9830
+ }
9831
+ function _workspaceDirName(root) {
9832
+ if (WORKSPACE_DIR_ALT_SUPPORTED) {
9833
+ if (process.env.LEERNESS_WORKSPACE_DIR) {
9834
+ const v = process.env.LEERNESS_WORKSPACE_DIR.trim();
9835
+ if (v) return v;
9647
9836
  }
9648
- } catch {}
9649
- // 3) default
9650
- return '.harness';
9837
+ try {
9838
+ if (root && exists(path.join(root, '.leerness', 'MIGRATED_FROM_HARNESS'))) return '.leerness';
9839
+ } catch {}
9840
+ }
9841
+ return '.harness'; // 살아 있는 저장소 — 위 주석의 실측대로 이 빌드에서는 항상 여기다
9651
9842
  }
9652
9843
  function _workspaceDirAbs(root) {
9653
9844
  return path.join(root, _workspaceDirName(root));
@@ -9666,7 +9857,12 @@ function _findParentWorkspace(root, opts = {}) {
9666
9857
  const hasHarness = exists(path.join(cur, '.harness'));
9667
9858
  const hasLeerness = exists(path.join(cur, '.leerness'));
9668
9859
  if (hasHarness || hasLeerness) {
9669
- const wd = (hasLeerness && exists(path.join(cur, '.leerness', 'MIGRATED_FROM_HARNESS'))) ? '.leerness' : (hasHarness ? '.harness' : '.leerness');
9860
+ // 1.36.126 (T-0107): 여기는 해석기를 쓰고 **자체 로직으로 `.leerness` 우선**하고 있었다
9861
+ // 부모가 옛 migrate 를 돌렸다면 그 `.leerness` 는 죽은 사본이므로, 자식이 **낡은 자산**
9862
+ // (design-system·reuse-map·skills)을 물려받는다. 살아 있는 저장소가 있으면 그쪽이 먼저다.
9863
+ // `.leerness` 만 있는 경우(사용자가 믿고 `.harness` 를 지운 상태)는 부모 탐지 자체를 잃는 것보다
9864
+ // 읽어 주는 편이 낫다 — 읽기 전용 자산 탐지이고, 그 프로젝트는 audit 이 미초기화로 따로 알린다.
9865
+ const wd = hasHarness ? '.harness' : '.leerness';
9670
9866
  const wsAbs = path.join(cur, wd);
9671
9867
  const assets = {
9672
9868
  designSystem: exists(path.join(wsAbs, 'design-system.md')),
@@ -9699,6 +9895,24 @@ function _migrateWorkspaceDir(root, opts = {}) {
9699
9895
  errors: [],
9700
9896
  dryRun
9701
9897
  };
9898
+ // 1.36.126 (T-0107): 이 명령은 **배달할 수 없는 것을 배달했다고 말하고 있었다**. 실측 —
9899
+ // 복사(58파일)는 성공하고 "✓ 마이그레이션 완료 → 다음 handoff부터 .leerness 우선 사용" 이라고 찍는데,
9900
+ // 그 직후 `task add` 는 여전히 `.harness` 에 쓴다. 결과물은 마이그레이션이 아니라 **얼어붙은 사본**이고,
9901
+ // 같이 만들어지는 WHERE_TO_FIND.md 는 AI 를 그 낡은 상태로 안내한다 — 우리가 막으려는 바로 그 실패다.
9902
+ // 그래서 복사를 하지 않는다. 반쯤 된 상태를 만들 바에는 **아무것도 만들지 않는 편이 낫다**(fail-closed).
9903
+ // 되살리려면 하드코딩 252곳을 해석기로 바꾸는 것이 선행 조건이다(T-0107).
9904
+ // ⚠ `allowUnsupported` 는 **내부 시험 전용 통로**다. 복사 구현(심링크 미추종 등 1.36.33 안전 속성)을
9905
+ // 도달 불가로 만들면 그 보호가 조용히 썩는다 — T-0107 이 풀리면 다시 살아날 코드이므로 계속 시험한다.
9906
+ // CLI 경로는 이 옵션을 절대 넘기지 않는다(e2e 가 호출부를 열거해 단언한다).
9907
+ if (!WORKSPACE_DIR_ALT_SUPPORTED && opts.allowUnsupported !== true) {
9908
+ report.blocked = true;
9909
+ report.blockedReason = 'workspace-dir-alt-unsupported';
9910
+ // ⚠ 이 숫자를 손으로 적어 뒀더니 곧바로 낡았다(적을 땐 252, 이 라운드 편집 후 실제 255 — 검수 P2).
9911
+ // "실측" 이라 말하면서 낡은 수를 보여 주는 것은 이 라운드가 고치는 결함과 같은 클래스다. 실행 시 센다.
9912
+ report.measured = _measureWorkspaceHardcoding();
9913
+ report.errors.push('이 빌드는 .leerness 를 살아 있는 저장소로 쓰지 못합니다 — 복사하면 죽은 사본이 됩니다');
9914
+ return report;
9915
+ }
9702
9916
  if (!report.srcExists) {
9703
9917
  report.errors.push('source .harness not found');
9704
9918
  return report;
@@ -10516,6 +10730,7 @@ function workspaceDirCmd(root, sub) {
10516
10730
  const isTty = process.stdout && process.stdout.isTTY;
10517
10731
  const cyan = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
10518
10732
  const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
10733
+ const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
10519
10734
  const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
10520
10735
 
10521
10736
  if (!sub || sub === 'get') {
@@ -10524,19 +10739,37 @@ function workspaceDirCmd(root, sub) {
10524
10739
  const hasHarness = exists(path.join(root, '.harness'));
10525
10740
  const hasLeerness = exists(path.join(root, '.leerness'));
10526
10741
  const migrated = exists(path.join(root, '.leerness', 'MIGRATED_FROM_HARNESS'));
10527
- const result = { current: dirName, abs: dirAbs, hasHarness, hasLeerness, migrated };
10742
+ // 1.36.126 (T-0107): `migrated: true` 그대로 보여 주면 "옮겨졌다" 는 **거짓 인상**을 준다 —
10743
+ // 실제로는 그 뒤 모든 쓰기가 .harness 로 간다. 마커의 의미를 사실대로 옮겨 적는다.
10744
+ const ignored = _workspaceDirAltIgnored(root);
10745
+ // 1.36.126 (검수 P2): 마커가 지워진 사본도 잡되, 남의 `.leerness` 는 건드리지 않는다 —
10746
+ // 두 번째 신호는 **우리 생성물의 서명**(가이드 헤더). audit 과 같은 술어를 쓴다.
10747
+ let ourGuide = false;
10748
+ try { ourGuide = exists(path.join(root, '.leerness', 'WHERE_TO_FIND.md'))
10749
+ && /by leerness \d+\.\d+\.\d+/.test(read(path.join(root, '.leerness', 'WHERE_TO_FIND.md'))); } catch {}
10750
+ const staleCopy = (migrated || ourGuide) && dirName === '.harness';
10751
+ const result = { current: dirName, abs: dirAbs, hasHarness, hasLeerness, migrated,
10752
+ altSupported: WORKSPACE_DIR_ALT_SUPPORTED, ignoredSetting: ignored, staleCopy };
10528
10753
  if (has('--json')) { log(JSON.stringify(result, null, 2)); return; }
10529
- log(cyan(`# leerness workspace-dir (1.9.211)`));
10530
- log(` 현재 디렉토리: ${grn(dirName + '/')}`);
10754
+ log(cyan(`# leerness workspace-dir (1.36.126)`));
10755
+ log(` 현재 디렉토리: ${grn(dirName + '/')} ${dim('(읽기·쓰기가 실제로 일어나는 곳)')}`);
10531
10756
  log(` abs: ${dirAbs}`);
10532
10757
  log(` .harness 존재: ${hasHarness ? '✓' : '✗'}`);
10533
10758
  log(` .leerness 존재: ${hasLeerness ? '✓' : '✗'}`);
10534
- log(` 마이그레이션 완료: ${migrated ? '✓' : '✗'}`);
10759
+ log(` .leerness 마커: ${migrated ? '✓' : '✗'}`);
10535
10760
  log('');
10536
- if (hasHarness && !hasLeerness) {
10537
- log(dim(` 마이그레이션: leerness migrate-workspace-dir`));
10538
- } else if (migrated) {
10539
- log(dim(` → AI 참조 가이드: leerness workspace-dir guide`));
10761
+ if (ignored) {
10762
+ log(yel(` ${ignored.env ? `LEERNESS_WORKSPACE_DIR=${ignored.env}` : '.leerness/MIGRATED_FROM_HARNESS 마커'} 는 이 빌드에서 적용되지 않습니다.`));
10763
+ log(` 실제 저장소는 ${grn(ignored.effective + '/')} 입니다 설정을 켜 두어도 동작은 바뀌지 않습니다(T-0107).`);
10764
+ }
10765
+ if (staleCopy) {
10766
+ log('');
10767
+ log(yel(` ⚠ .leerness/ 는 **죽은 사본**입니다 — 마이그레이션 시점에 멈춰 있고 이후 변경이 반영되지 않습니다.`));
10768
+ log(` .leerness/WHERE_TO_FIND.md 를 AI 에게 주지 마세요(낡은 상태로 안내합니다).`);
10769
+ log(dim(` 안전하게 지우려면: 내용을 .harness 와 비교해 필요한 것이 없는지 확인한 뒤 직접 삭제하세요.`));
10770
+ log(dim(` (leerness 는 사용자 데이터를 대신 지우지 않습니다)`));
10771
+ } else if (hasHarness && !hasLeerness) {
10772
+ log(dim(` → 이 빌드는 .harness 만 사용합니다(디렉터리 이름 변경은 T-0107 이후).`));
10540
10773
  }
10541
10774
  return;
10542
10775
  }
@@ -10679,7 +10912,29 @@ function migrateWorkspaceDirCmd(root) {
10679
10912
  const force = has('--force');
10680
10913
  const report = _migrateWorkspaceDir(root, { dryRun, force });
10681
10914
 
10682
- if (has('--json')) { log(JSON.stringify(report, null, 2)); return; }
10915
+ if (has('--json')) { log(JSON.stringify(report, null, 2)); if (report.blocked) process.exitCode = 1; return; }
10916
+ // 1.36.126: 못 하는 일을 했다고 말하지 않는다 — 사유와 **실측 근거**를 함께 준다.
10917
+ if (report.blocked) {
10918
+ const m = report.measured || {};
10919
+ log(cyan(`# leerness migrate-workspace-dir (1.36.126)`));
10920
+ log('');
10921
+ log(red(` ✗ 이 빌드는 .leerness 로 옮길 수 없습니다 — 복사만 하면 죽은 사본이 됩니다.`));
10922
+ log('');
10923
+ if (m.hardcodedPathSites != null) {
10924
+ log(` 왜: leerness 내부에서 워크스페이스 경로를 만드는 곳 ${m.hardcodedPathSites}곳이 '.harness' 를 그대로 쓰고,`);
10925
+ log(` 경로 해석기를 거치는 곳은 ${m.resolverConsumers}곳뿐입니다(안내 문구 ${m.hardcodedTextMentions}곳은 별도).`);
10926
+ } else {
10927
+ log(` 왜: leerness 내부 대부분이 '.harness' 경로를 그대로 쓰고 있어, 복사해도 그쪽으로는 아무것도 쓰이지 않습니다.`);
10928
+ log(` (이 실행에서는 소스를 세지 못해 정확한 수를 대지 못합니다 — 판정 자체는 바뀌지 않습니다.)`);
10929
+ }
10930
+ log(` 그래서 복사한 뒤에도 모든 쓰기는 .harness 로 갑니다 — .leerness 는 복사 시점에 멈춘 사본이 되고,`);
10931
+ log(` 함께 만들어지던 WHERE_TO_FIND.md 가 AI 를 그 낡은 상태로 안내합니다.`);
10932
+ log('');
10933
+ log(dim(` 지금 할 수 있는 것: 그대로 .harness 를 쓰시면 됩니다(기능 차이 없음 — 디렉터리 이름만 다릅니다).`));
10934
+ log(dim(` 진행 상황: 이 제약은 T-0107 로 등록돼 있습니다 — 해결되면 이 명령이 다시 동작합니다.`));
10935
+ process.exitCode = 1;
10936
+ return;
10937
+ }
10683
10938
  log(cyan(`# leerness migrate-workspace-dir (1.9.211)${dryRun ? ' [DRY-RUN]' : ''}`));
10684
10939
  log(` src: ${report.src} ${report.srcExists ? '✓' : red('✗ 없음')}`);
10685
10940
  log(` dst: ${report.dst} ${report.dstExists ? yel('⚠ 이미 존재') : '(생성)'}`);
@@ -15674,7 +15929,17 @@ function verifyClaimCmd(root, taskId, opts = {}) {
15674
15929
  // 이전엔 npm test 하드코딩 → 비-JS(파이썬 등) 프로젝트에서 npm init 잔재 placeholder("no test specified"&&exit 1)가 실행돼
15675
15930
  // 테스트 전부 통과한 작업을 "주장 불일치 FAIL"로 오판(5축 클린룸 실증 P1). placeholder 는 테스트가 아니므로 skip 처리.
15676
15931
  let runResult = null;
15677
- if (has('--run-tests')) {
15932
+ // 1.36.125 (검수 P2): `verify-claim --all --run-tests` 는 done 행마다 verifyClaimCmd 를 부르고
15933
+ // `--run-tests` 는 전역 플래그다 → **같은 테스트 스위트를 task 수만큼 반복 실행**했다.
15934
+ // 한 번의 호출 안에서 같은 root·같은 명령의 결과는 정의상 동일하다(주장별로 달라질 수 없다).
15935
+ // 그래서 --all 스윕이 넘겨준 메모를 재사용한다. 효과는 두 가지 —
15936
+ // ① N×300초가 1×300초가 되어 MCP 예산(660초) 안에 들어온다(거짓 타임아웃 제거)
15937
+ // ② 우리 자신처럼 스위트가 79분인 프로젝트에서 N번 도는 낭비가 사라진다.
15938
+ // ⚠ 메모는 **호출자가 명시로 넘길 때만** 쓴다(모듈 전역 캐시로 두면 장수 프로세스에서 낡은 결과를 재사용한다).
15939
+ const _runMemo = opts && opts.runMemo && typeof opts.runMemo === 'object' ? opts.runMemo : null;
15940
+ if (has('--run-tests') && _runMemo && Object.prototype.hasOwnProperty.call(_runMemo, 'result')) {
15941
+ runResult = _runMemo.result;
15942
+ } else if (has('--run-tests')) {
15678
15943
  let testCmd = arg('--test-cmd', null);
15679
15944
  if (!testCmd) {
15680
15945
  try { const cfg = JSON.parse(read(path.join(root, '.harness', 'leerness-config.json'))); if (cfg && typeof cfg.testCommand === 'string' && cfg.testCommand.trim()) testCmd = cfg.testCommand.trim(); } catch {}
@@ -15713,6 +15978,8 @@ function verifyClaimCmd(root, taskId, opts = {}) {
15713
15978
  }
15714
15979
  }
15715
15980
  }
15981
+ // 이 호출에서 실제로 실행한 결과를 메모에 남긴다 — 다음 주장부터는 재실행하지 않는다(검수 P2).
15982
+ if (_runMemo) _runMemo.result = runResult;
15716
15983
  }
15717
15984
 
15718
15985
  // 1.9.309 (UR-0048, 설치리뷰 Opus critical): done 주장은 evidence(파일+테스트) 기본 강제 — 거짓완료 차단을 기본값·MCP·json 모두에서 작동.
@@ -16020,7 +16287,10 @@ function _verifyClaimsAll(root) {
16020
16287
  root = absRoot(root);
16021
16288
  const rows = readProgressRows(root);
16022
16289
  const doneRows = rows.filter(r => /done|완료|completed/i.test(String(r.status || '')));
16023
- const results = doneRows.map(r => verifyClaimCmd(root, r.id, { collect: true }));
16290
+ // 1.36.125 (검수 P2): `--run-tests` 전역 플래그라 종전엔 스위트를 done 행 수만큼 반복 실행했다
16291
+ // (100개 주장 × 300초 = 8시간 — MCP 예산으로는 어떤 값도 못 덮는다). 한 스윕 = 한 번 실행.
16292
+ const runMemo = {};
16293
+ const results = doneRows.map(r => verifyClaimCmd(root, r.id, { collect: true, runMemo }));
16024
16294
  const failed = results.filter(x => x && !x.ok);
16025
16295
  return { ok: failed.length === 0, total: doneRows.length, failed: failed.length, results };
16026
16296
  }
@@ -22017,6 +22287,68 @@ function _mcpToCliArgs(name, args, targetPath) {
22017
22287
  }
22018
22288
 
22019
22289
  // 프로토콜: MCP 표준 (JSON-RPC 2.0). 메서드: initialize, tools/list, tools/call
22290
+ // 1.36.93 (헌트 #8): 래퍼 타임아웃(60s)이 bugfix probe 예산(600s)보다 짧아, 버그를 **진짜 고치고**
22291
+ // 영수증까지 채운 사용자가 MCP 표면에서는 done 을 영영 기록하지 못했다. 게다가 stdout 이 비어 있어
22292
+ // 응답이 `(no output)` 뿐 — 차단 사유도, 타임아웃이라는 사실도 전달되지 않았다(진단 단서 0).
22293
+ // ① 기본값을 probe 예산 위로 올리고 ② env 로 조절 가능하게 하며 ③ 타임아웃을 **사실대로** 보고한다.
22294
+ // (MCP 클라이언트 자체 타임아웃은 leerness 가 어쩌지 못한다 — 그래서 메시지에 CLI 대안을 함께 준다.)
22295
+ // codex 33차 #2: 한도를 **전역으로** 올리면 `spawnSync` 가 단일 스레드 서버를 그만큼 얼린다 —
22296
+ // 무관한 도구가 멎어도 11분간 ping·취소 통지조차 못 읽는다. 그래서 긴 예산은 그 예산이 필요한 호출에만 준다.
22297
+ const MCP_TIMEOUT_DEFAULT_MS = 60000;
22298
+ const MCP_TIMEOUT_PROBE_MS = 660000; // 제품 내부 최대 한도(600s) 위
22299
+ const MCP_TIMEOUT_LONG_MS = 300000;
22300
+ function _mcpTimeoutOverrideMs() {
22301
+ const v = parseInt(process.env.LEERNESS_MCP_TIMEOUT_MS || '', 10);
22302
+ return (Number.isFinite(v) && v >= 5000 && v <= 3600000) ? v : null;
22303
+ }
22304
+ // 1.36.125 (검수 P2 · 클래스 스윕): 1.36.124 는 `selftest` **한 이름만** 예산에서 구제했다 — 전형적인
22305
+ // "가드는 자기가 밟은 경로만 지킨다". 60초를 넘길 수 있는 경로는 그것만이 아니고, 판별 기준도 이름이 아니라
22306
+ // **그 명령이 스스로 거는 내부 타임아웃**이다. 예산이 내부 한도보다 짧으면 정상 동작을 잘라 놓고
22307
+ // AI 에게 거짓 실패를 보고한다(1.36.124 와 같은 클래스). 그래서 술어를 흩뿌리지 않고 한 표로 모으고,
22308
+ // 각 항목이 반드시 넘어야 할 내부 한도(mustExceedMs)를 함께 적는다 — selftest 가 그 숫자를 **소스에서
22309
+ // 실제로 읽어** `budget > 내부한도` 를 단언하므로, 내부 한도를 올리고 표를 안 고치면 게이트가 깨진다.
22310
+ // ⚠ 남는 한계(정직하게): referee verify 는 good/bad/check 세 번을 각각 내부 한도까지 쓸 수 있어
22311
+ // 최악(3×600초)은 어떤 예산으로도 못 덮는다 — 그 경우의 탈출구는 타임아웃 메시지가 안내하는
22312
+ // `LEERNESS_MCP_TIMEOUT_MS` 다. 예산을 3배로 키우면 이번엔 서버가 30분간 얼어붙어 더 나쁘다.
22313
+ const MCP_LONG_PATHS = [
22314
+ { id: 'selftest', budgetMs: MCP_TIMEOUT_LONG_MS, mustExceedMs: 116000,
22315
+ why: '실측 116초(1.36.124) — 소스 리터럴이 아니라 측정값',
22316
+ match: (a) => a[0] === 'selftest' },
22317
+ // bugfix probe 가 도는 경로 — done 전이(task update --status done)와 TodoWrite 왕복(task sync).
22318
+ { id: 'task-done-probe', budgetMs: MCP_TIMEOUT_PROBE_MS, mustExceedMs: 600000,
22319
+ why: 'lib/bugfix.js 의 probe 실행 한도', src: { file: 'lib/bugfix.js', re: /shell: true, encoding: 'utf8', timeout: (\d+)/ },
22320
+ match: (a) => a[0] === 'task' && (a[1] === 'sync'
22321
+ || (a[1] === 'update' && a.includes('--status') && ['done', 'completed', 'verified'].includes(a[a.indexOf('--status') + 1]))) },
22322
+ { id: 'verify-claim-run-tests', budgetMs: MCP_TIMEOUT_PROBE_MS, mustExceedMs: 300000,
22323
+ why: '프로젝트 테스트 실행 한도(runCommandSafe kind:verify_claim_test)',
22324
+ src: { file: 'bin/leerness.js', re: /timeout: (\d+) \* 60 \* 1000, kind: 'verify_claim_test'/, scale: 60000 },
22325
+ match: (a) => a[0] === 'verify-claim' && a.includes('--run-tests') },
22326
+ { id: 'referee-verify', budgetMs: MCP_TIMEOUT_PROBE_MS, mustExceedMs: 600000,
22327
+ why: 'lib/referee.js calibrate 기본 한도(good/bad/check 각각)',
22328
+ src: { file: 'lib/referee.js', re: /Number\(opts\.timeoutMs\) \|\| (\d+)/ },
22329
+ match: (a) => a[0] === 'referee' && a[1] === 'verify' },
22330
+ { id: 'api-skill-add', budgetMs: MCP_TIMEOUT_LONG_MS, mustExceedMs: 110000,
22331
+ why: '본문 1 + same-domain 링크 최대 10 요청 × 요청당 10초',
22332
+ match: (a) => a[0] === 'api-skill' && a[1] === 'add' },
22333
+ ];
22334
+ function _mcpLongPathFor(cliArgs) {
22335
+ const a = (cliArgs || []).map(String);
22336
+ return MCP_LONG_PATHS.find(p => { try { return p.match(a) === true; } catch { return false; } }) || null;
22337
+ }
22338
+ function _mcpTimeoutFor(cliArgs) {
22339
+ const ov = _mcpTimeoutOverrideMs();
22340
+ if (ov != null) return ov;
22341
+ const p = _mcpLongPathFor(cliArgs);
22342
+ return p ? p.budgetMs : MCP_TIMEOUT_DEFAULT_MS;
22343
+ }
22344
+ // 타임아웃 사유 한 줄 — **실제로 적용된 예산**과 어긋나면 안 된다. 명시 override 가 걸린 호출에
22345
+ // "이 호출은 긴 예산(300초)을 받습니다" 라고 말하면 5초에 잘린 사용자에게 거짓말을 하는 것이다.
22346
+ // 순수 함수로 두는 이유: 진짜 타임아웃을 기다리지 않고도 두 분기를 실행으로 단언할 수 있게(1.36.125).
22347
+ function _mcpTimeoutHint(cliArgs) {
22348
+ if (_mcpTimeoutOverrideMs() != null) return ''; // 예산을 정한 것은 표가 아니라 사용자다
22349
+ const p = _mcpLongPathFor(cliArgs);
22350
+ return p ? ` · 이 호출은 긴 예산(${Math.round(p.budgetMs / 1000)}초)을 받습니다 — ${p.why}.\n` : '';
22351
+ }
22020
22352
  function mcpServeCmd(root) {
22021
22353
  root = absRoot(root || process.cwd());
22022
22354
  // 노출할 leerness 도구 목록
@@ -22032,42 +22364,8 @@ function mcpServeCmd(root) {
22032
22364
  function send(obj) {
22033
22365
  process.stdout.write(JSON.stringify(obj) + '\n');
22034
22366
  }
22035
- // 1.36.93 (헌트 #8): 래퍼 타임아웃(60s)이 bugfix probe 예산(600s)보다 짧아, 버그를 **진짜 고치고**
22036
- // 영수증까지 채운 사용자가 MCP 표면에서는 done 영영 기록하지 못했다. 게다가 stdout 이 비어 있어
22037
- // 응답이 `(no output)` 뿐 — 차단 사유도, 타임아웃이라는 사실도 전달되지 않았다(진단 단서 0).
22038
- // ① 기본값을 probe 예산 위로 올리고 ② env 로 조절 가능하게 하며 ③ 타임아웃을 **사실대로** 보고한다.
22039
- // (MCP 클라이언트 자체 타임아웃은 leerness 가 어쩌지 못한다 — 그래서 메시지에 CLI 대안을 함께 준다.)
22040
- // codex 33차 #2: 한도를 **전역으로** 올리면 `spawnSync` 가 단일 스레드 서버를 그만큼 얼린다 —
22041
- // 무관한 도구가 멎어도 11분간 ping·취소 통지조차 못 읽는다. 그래서 긴 예산은 **probe 를 실행할 수 있는
22042
- // 호출에만** 준다(done 전이 · task sync). 나머지는 종전대로 60초.
22043
- const MCP_TIMEOUT_DEFAULT_MS = 60000;
22044
- const MCP_TIMEOUT_PROBE_MS = 660000; // probe 예산(600s) 위 — 이 값이 적용되는 호출은 아래 _needsProbeBudget 뿐
22045
- const MCP_TIMEOUT_OVERRIDE_MS = (() => {
22046
- const v = parseInt(process.env.LEERNESS_MCP_TIMEOUT_MS || '', 10);
22047
- return (Number.isFinite(v) && v >= 5000 && v <= 3600000) ? v : null;
22048
- })();
22049
- // 1.36.124 (살아 있는 MCP 통신 실측): `leerness_selftest` 는 **항상** 60초 한도를 넘겨 타임아웃만 돌려줬다.
22050
- // 실측 116초(우리 repo·빈 임시 프로젝트 둘 다) — 즉 광고된 89개 도구 중 **설치 검증 도구가 MCP 로는 무용**이었다.
22051
- // 전역 한도를 올리지 않는 이유는 위와 같다(무관한 도구가 서버를 그만큼 얼린다). 그래서 이 명령에만 준다.
22052
- const MCP_TIMEOUT_LONG_MS = 300000; // 실측 116초 대비 2.5배 여유
22053
- function _needsLongBudget(cliArgs) {
22054
- const a = (cliArgs || []).map(String);
22055
- return a[0] === 'selftest';
22056
- }
22057
- // bugfix probe 가 도는 경로만 긴 예산을 받는다 — done 전이(task update --status done)와 TodoWrite 왕복(task sync).
22058
- function _needsProbeBudget(cliArgs) {
22059
- const a = (cliArgs || []).map(String);
22060
- if (a[0] !== 'task') return false;
22061
- if (a[1] === 'sync') return true;
22062
- if (a[1] !== 'update') return false;
22063
- const i = a.indexOf('--status');
22064
- return i >= 0 && ['done', 'completed', 'verified'].includes(a[i + 1]);
22065
- }
22066
- function _mcpTimeoutFor(cliArgs) {
22067
- return MCP_TIMEOUT_OVERRIDE_MS != null ? MCP_TIMEOUT_OVERRIDE_MS
22068
- : (_needsProbeBudget(cliArgs) ? MCP_TIMEOUT_PROBE_MS
22069
- : (_needsLongBudget(cliArgs) ? MCP_TIMEOUT_LONG_MS : MCP_TIMEOUT_DEFAULT_MS));
22070
- }
22367
+ // 예산 (MCP_LONG_PATHS) _mcpTimeoutFor 모듈 스코프에 있다 selftest 소스 grep 이 아니라
22368
+ // **실제 호출**로 `budget > 내부한도` 단언할 있게 하기 위함(1.36.125).
22071
22369
  const _shQuote = (s) => (/[\s"']/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
22072
22370
  function callLeerness(cliArgs) {
22073
22371
  const budget = _mcpTimeoutFor(cliArgs);
@@ -22084,8 +22382,9 @@ function mcpServeCmd(root) {
22084
22382
  return {
22085
22383
  ok: false, exit: null, stdout: '',
22086
22384
  stderr: `⏱ ${Math.round(budget / 1000)}초 안에 끝나지 않아 중단했습니다: ${cmd}\n`
22087
- // probe 설명은 probe 실제로 있는 호출에만 — 무관한 도구에 엉뚱한 원인을 대지 않는다(#3).
22088
- + (_needsProbeBudget(cliArgs) ? ` · bugfix probe 가 등록된 task done 으로 바꾸면 probe(최대 600초)가 실행됩니다 그만큼 시간이 필요합니다.\n` : '')
22385
+ // 원인 설명은 호출에 실제로 해당하는 것만 — 무관한 도구에 엉뚱한 원인을 대지 않는다(#3).
22386
+ // 1.36.125: 표의 `why`그대로 쓴다(예산을 근거와 사용자에게 대는 이유가 갈라지지 않게).
22387
+ + _mcpTimeoutHint(cliArgs)
22089
22388
  + ` · 한도 조절: LEERNESS_MCP_TIMEOUT_MS=<밀리초> (5000~3600000)\n`
22090
22389
  + ` · 또는 터미널에서 직접: ${cmd}`
22091
22390
  + (partial ? `\n · 중단 전 출력:\n${partial.slice(0, 2000)}` : ''),
package/lib/audit.js CHANGED
@@ -264,6 +264,39 @@ function audit(root, opts = {}, deps = {}) {
264
264
  });
265
265
  }
266
266
  } catch {}
267
+ // 1.36.126 (T-0107): 예전 버전의 `migrate-workspace-dir` 는 `.harness` 를 `.leerness` 로 **복사만** 하고
268
+ // "마이그레이션 완료 → 다음 handoff부터 .leerness 우선 사용" 이라고 말했다. 실제로는 그 뒤 모든 쓰기가
269
+ // `.harness` 로 가므로 `.leerness` 는 복사 시점에 얼어붙는다. 문제는 디스크 낭비가 아니라
270
+ // 같이 만들어진 `WHERE_TO_FIND.md` 가 **AI 를 그 낡은 상태로 안내**한다는 것이다 — 우리가 막으려는 실패 그 자체.
271
+ // 1.36.126 부터 그 명령은 거부하지만, **이미 당한 프로젝트**는 스스로 알 수 없다. 여기서 알린다.
272
+ // ⚠ 지우지는 않는다 — 사용자 데이터를 대신 삭제하지 않는다는 원칙(편의 명령이 P1 5건을 냈던 전례).
273
+ try {
274
+ const staleMarker = path.join(root, '.leerness', 'MIGRATED_FROM_HARNESS');
275
+ const guide = path.join(root, '.leerness', 'WHERE_TO_FIND.md');
276
+ // 1.36.126 (검수 P2): 마커 하나만 믿으면 **마커가 지워진 사본**을 놓친다(수동 복구·복사 실패 등).
277
+ // 반대로 남의 도구가 만든 `.leerness` 를 "죽은 사본" 이라 부르면 오탐이 이 경고를 죽인다.
278
+ // 그래서 두 번째 신호는 **우리 생성물의 서명**으로 좁힌다 — 가이드 파일의 우리 헤더 문구.
279
+ // (파일명만 보면 남의 것과 구별할 수 없다. 내용 서명이 있어야 우리 것이라고 말할 수 있다.)
280
+ let ourGuide = false;
281
+ try { ourGuide = exists(guide) && /by leerness \d+\.\d+\.\d+/.test(read(guide)); } catch {}
282
+ if (exists(staleMarker) || ourGuide) {
283
+ let ageDays = null;
284
+ try {
285
+ const mt = exists(staleMarker) ? fs.statSync(staleMarker).mtimeMs : fs.statSync(guide).mtimeMs;
286
+ const live = fs.statSync(path.join(root, '.harness', 'progress-tracker.md')).mtimeMs;
287
+ ageDays = Math.max(0, Math.round((live - mt) / 86400000));
288
+ } catch {}
289
+ warnings++;
290
+ warn(`.leerness/ 는 죽은 사본입니다 — 실제 저장소는 .harness 이고, 이 사본은 복사 시점에 멈춰 있습니다`);
291
+ log(` 근거: ${exists(staleMarker) ? '.leerness/MIGRATED_FROM_HARNESS 마커' : '.leerness/WHERE_TO_FIND.md 가 leerness 생성물'} + 이 빌드는 .leerness 를 읽지 않음 (leerness workspace-dir)`);
292
+ if (ageDays != null) log(` 낡음 정도: 실제 저장소가 사본보다 약 ${ageDays}일 앞서 있습니다`);
293
+ if (exists(guide)) log(` ⚠ .leerness/WHERE_TO_FIND.md 를 AI 에게 주지 마세요 — 낡은 상태로 안내합니다`);
294
+ log(` → 내용을 .harness 와 비교해 필요한 것이 없는지 확인한 뒤 직접 삭제하세요 (leerness 는 대신 지우지 않습니다)`);
295
+ _finding('stale_workspace_copy', 'warn', '.leerness is a frozen copy; the live store is .harness', {
296
+ markerPresent: exists(staleMarker), guidePresent: exists(guide), guideIsOurs: ourGuide, liveAheadDays: ageDays,
297
+ });
298
+ }
299
+ } catch {}
267
300
  const milestoneIds = Array.from(planText.matchAll(/^### (M-\d{4,})\./gm)).map(m => m[1]);
268
301
  const rows = readProgressRows(root);
269
302
  // 1.9.6 수정: 한 row에 여러 plan:M-XXXX 링크가 있어도 모두 인식 (matchAll로 전부 추출)
package/lib/pure-utils.js CHANGED
@@ -1579,14 +1579,18 @@ function _renderWorkspaceReferenceGuide(dirName, version, generatedAt) {
1579
1579
  lines.push(`| API 제약 catalog | \`${dirName}/platform-constraints.json\` (1.9.208) |`);
1580
1580
  lines.push(`| 자동 wakeup 권장 간격 | \`${dirName}/wakeup-history.json\` (1.9.210) |`);
1581
1581
  lines.push('');
1582
- lines.push(`## 🔄 마이그레이션 안내`);
1582
+ // 1.36.126 (T-0107, 검수 P1): 이 절은 AI 에게 **"마커가 있으면 `.leerness` 를 쓰라"** 고 지시하고 있었다.
1583
+ // 메커니즘(해석기)은 고쳐 놓고 지시문을 남기면, 그 지시문이 같은 버그를 계속 재생산한다.
1584
+ // 실측: `.leerness` 는 옛 migrate 가 만든 **얼어붙은 사본**이고 이후 모든 쓰기는 `.harness` 로 간다.
1585
+ lines.push(`## 🔄 \`.leerness\` 디렉터리를 보셨다면`);
1583
1586
  lines.push('');
1584
- lines.push(`이 워크스페이스는 \`.harness\` \`.leerness\` 마이그레이션되었을 수 있습니다.`);
1585
- lines.push(`- \`.leerness/MIGRATED_FROM_HARNESS\` 존재 마이그레이션 완료, \`.leerness\` 우선 사용`);
1586
- lines.push(`- \`.harness/MIGRATED_TO_LEERNESS.md\` 존재 \`.leerness/\` 가야 함`);
1587
- lines.push(`- 양쪽 모두 없음 → 기본 \`.harness\` 사용 중`);
1587
+ lines.push(`이 빌드가 읽고 쓰는 워크스페이스는 **\`${dirName}/\` 하나뿐**입니다.`);
1588
+ lines.push(`- \`.leerness/\` 있어도 **읽지 마십시오** — 옛 \`migrate-workspace-dir\` 만든 사본이며 그 시점에 멈춰 있습니다.`);
1589
+ lines.push(`- \`.leerness/MIGRATED_FROM_HARNESS\` 마커도 마찬가지입니다 빌드에서는 아무 효력이 없습니다.`);
1590
+ lines.push(`- \`.harness/MIGRATED_TO_LEERNESS.md\` 안내문 역시 낡았습니다.`);
1591
+ lines.push(`- \`migrate-workspace-dir\` 는 1.36.126 부터 사유를 설명하고 거부합니다 (T-0107).`);
1588
1592
  lines.push('');
1589
- lines.push(`AI 에이전트는 \`leerness handoff .\` 결과를 신뢰하십시오 — 자동으로 올바른 디렉토리를 사용합니다.`);
1593
+ lines.push(`AI 에이전트는 \`leerness handoff .\` 결과를 신뢰하십시오 — 항상 살아 있는 워크스페이스를 씁니다.`);
1590
1594
  lines.push('');
1591
1595
  return lines.join('\n');
1592
1596
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.124",
3
+ "version": "1.36.126",
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
@@ -5147,34 +5147,83 @@ total++;
5147
5147
  let ok = false; const bad = [];
5148
5148
  const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-mcp124-'));
5149
5149
  const ENVm = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
5150
+ // 1.36.125 (검수 P3 + 실측): 이 블록은 **부모 환경을 상속하면 안 된다**. 사용자가 켜 둔 공개 설정 하나로
5151
+ // e2e 가 환경 의존적 거짓 실패를 낸다 — LEERNESS_MCP_TIMEOUT_MS 는 예산을, MCP_PROFILE 은 도구 수를,
5152
+ // WORKSPACE_DIR 은 상태 위치를, LANG 은 기대 문자열을 바꾼다(selftest 쪽 실측으로 확인된 같은 클래스).
5153
+ for (const k of ['LEERNESS_MCP_TIMEOUT_MS', 'LEERNESS_MCP_PROFILE', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG']) delete ENVm[k];
5150
5154
  try {
5151
5155
  const d = path.join(sb, 'p'); fs.mkdirSync(d, { recursive: true });
5152
5156
  fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
5153
- cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENVm });
5157
+ // 셋업 실패를 통과로 세지 않는다(검수 P1) init 죽으면 이후 단언은 무의미하다.
5158
+ const rInit = cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENVm });
5159
+ if (rInit.status !== 0) bad.push(`셋업init실패(exit=${rInit.status})`);
5154
5160
  const rpc = (id, method, params) => JSON.stringify({ jsonrpc: '2.0', id, method, ...(params ? { params } : {}) }) + '\n';
5161
+ // 1.36.125: 같은 세션에서 **오류 규격**도 함께 받는다(추가 spawn 없이).
5162
+ // 지난 라운드엔 도구 하나만 불러 봤다 — 한 곳에서 나온 결함(타임아웃·거짓 실패)이 클래스인지 보려면 폭이 필요하다.
5163
+ // 규격(2024-11-05 lifecycle)상 initialize 성공 뒤 클라이언트는 `notifications/initialized` 를 보낸다 —
5164
+ // 그걸 빼면 "실제 왕복" 이라면서 수명주기를 재현하지 않은 것이다(검수 P2). 알림은 id 가 없다.
5165
+ const notify = (method) => JSON.stringify({ jsonrpc: '2.0', method }) + '\n';
5155
5166
  const input = rpc(1, 'initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'e2e', version: '0' } })
5156
- + rpc(2, 'tools/list') + rpc(3, 'tools/call', { name: 'leerness_selftest', arguments: { path: d } });
5167
+ + notify('notifications/initialized')
5168
+ + rpc(2, 'tools/list') + rpc(3, 'tools/call', { name: 'leerness_selftest', arguments: { path: d } })
5169
+ + rpc(4, 'tools/call', { name: 'leerness_brainstorm', arguments: { path: d } }) // 필수 인자 누락 → -32602
5170
+ + rpc(5, 'tools/call', { name: 'leerness_does_not_exist', arguments: {} }) // 미지 도구 → -32601
5171
+ + rpc(6, 'unknown/method', {}); // 미지 메서드 → -32601
5157
5172
  const r = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve', '--path', d],
5158
5173
  { cwd: d, input, encoding: 'utf8', timeout: 900000, env: ENVm, maxBuffer: 64 * 1024 * 1024 });
5159
5174
  const msgs = [];
5160
5175
  for (const line of String(r.stdout || '').split('\n')) { const s = line.trim(); if (s) { try { msgs.push(JSON.parse(s)); } catch { bad.push('비JSON줄'); } } }
5161
5176
  const by = (i) => msgs.find(m => m.id === i);
5177
+ // 서버 자체의 종료 상태도 본다(검수 P1) — 응답만 보면 **비정상 종료·타임아웃**을 성공으로 센다.
5178
+ if (r.error) bad.push(`서버오류(${r.error.code || r.error.message})`);
5179
+ if (r.signal) bad.push(`서버시그널(${r.signal})`);
5180
+ if (r.status !== 0) bad.push(`서버exit=${r.status}`);
5162
5181
  if (String(r.stderr || '').length !== 0) bad.push(`stderr(${String(r.stderr).length}B)`);
5163
5182
  const init = by(1); if (!init || init.error || !(init.result && init.result.serverInfo)) bad.push('initialize실패');
5164
5183
  const tools = by(2) && by(2).result && by(2).result.tools;
5165
- if (!Array.isArray(tools) || tools.length < 50) bad.push(`tools/list(${Array.isArray(tools) ? tools.length : 'none'})`);
5184
+ // 1.36.125 (검수 P2): `>= 50` 도구가 39개 사라져도 통과한다. 등록부(lib/mcp-tools.js) **정확히** 대조한다
5185
+ // 서버가 걸러 내거나 프로필이 새면 그 자리에서 어긋난다. 그리고 실제로 부를 도구가 목록에 있는지도 본다
5186
+ // (dispatch 는 되는데 discoverability 에서 사라지는 경우가 있다).
5187
+ const REG = require(path.resolve(__dirname, '..', 'lib', 'mcp-tools.js'));
5188
+ if (!Array.isArray(tools) || tools.length !== REG.length) bad.push(`tools/list(${Array.isArray(tools) ? tools.length : 'none'}≠등록부 ${REG.length})`);
5166
5189
  else {
5167
5190
  if (tools.some(t => !t.description || !String(t.description).trim())) bad.push('도구설명없음');
5168
5191
  if (tools.some(t => !t.inputSchema || typeof t.inputSchema !== 'object')) bad.push('도구스키마없음');
5192
+ if (!tools.some(t => t.name === 'leerness_selftest')) bad.push('호출할도구가목록에없음');
5193
+ // 1.36.125 (검수 P2): 길이 + "등록부 밖 없음" 만으로는 **중복이 누락을 상쇄**한다(A 를 두 번 내고 B 를 빼면 통과).
5194
+ // 집합 동등으로 본다 — 이름 유일 + 등록부 전체 포함(양방향).
5195
+ const regNames = new Set(REG.map(t => t.name));
5196
+ const gotNames = tools.map(t => t.name);
5197
+ const uniq = new Set(gotNames);
5198
+ if (uniq.size !== gotNames.length) bad.push(`도구이름중복(${gotNames.length - uniq.size}건)`);
5199
+ const extra = gotNames.filter(n => !regNames.has(n));
5200
+ if (extra.length) bad.push(`목록에등록부밖도구(${extra.slice(0, 3).join(',')})`);
5201
+ const missing = [...regNames].filter(n => !uniq.has(n));
5202
+ if (missing.length) bad.push(`등록부인데목록에없음(${missing.slice(0, 3).join(',')})`);
5169
5203
  }
5170
5204
  const call = by(3);
5171
5205
  const txt = call && call.result && Array.isArray(call.result.content) ? String((call.result.content[0] || {}).text || '') : '';
5172
5206
  // ⚠ content 가 비어 있지 않다는 것만 보면 **타임아웃 문구도 성공**으로 센다(내 첫 프로브가 그랬다).
5173
5207
  if (!call || call.error) bad.push('tools/call오류');
5208
+ else if (call.result && call.result.isError) bad.push('tools/call_isError'); // 도구 실패를 성공으로 세던 자리(검수 P1)
5174
5209
  else if (/안에 끝나지 않아 중단/.test(txt)) bad.push('tools/call타임아웃');
5175
5210
  else if (!/"total"\s*:\s*\d+|전체 \d+건 통과/.test(txt)) bad.push('tools/call결과아님');
5176
5211
  // MCP 경유 selftest 가 **실패를 보고하면** 그건 거짓 보고다(같은 프로젝트에서 CLI 는 통과한다)
5177
- else { const m = /"fail"\s*:\s*(\d+)/.exec(txt); if (m && Number(m[1]) > 0) bad.push(`MCP경유_selftest_거짓실패(fail=${m[1]})`); }
5212
+ // ⚠ `fail` 필드가 **없으면** 통과시키던 자리 계약상 반드시 있고 0 이어야 하며 pass===total 이어야 한다(검수 P1).
5213
+ else {
5214
+ const m = /"fail"\s*:\s*(\d+)/.exec(txt);
5215
+ if (!m) bad.push('selftest_fail필드없음');
5216
+ else if (Number(m[1]) > 0) bad.push(`MCP경유_selftest_거짓실패(fail=${m[1]})`);
5217
+ const p = /"pass"\s*:\s*(\d+)/.exec(txt), t = /"total"\s*:\s*(\d+)/.exec(txt);
5218
+ if (!p || !t || Number(p[1]) !== Number(t[1])) bad.push(`selftest_pass≠total(${p && p[1]}/${t && t[1]})`);
5219
+ }
5220
+ // 오류 규격 — 잘못된 호출을 **JSON-RPC 오류 코드**로 거절해야 한다(조용한 성공/크래시 금지).
5221
+ for (const [id, code, label] of [[4, -32602, '필수인자누락'], [5, -32601, '미지도구'], [6, -32601, '미지메서드']]) {
5222
+ const m = by(id);
5223
+ if (!m) bad.push(`${label}:무응답`);
5224
+ else if (!m.error) bad.push(`${label}:오류아님`);
5225
+ else if (m.error.code !== code) bad.push(`${label}:코드=${m.error.code}(기대 ${code})`);
5226
+ }
5178
5227
  ok = bad.length === 0;
5179
5228
  } catch (e) { bad.push('ERR:' + String(e && e.message).slice(0, 80)); }
5180
5229
  finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
@@ -5433,7 +5482,11 @@ total++;
5433
5482
  const pu = require(path.resolve(__dirname, '..', 'lib', 'pure-utils'));
5434
5483
  const g = pu._renderWorkspaceReferenceGuide('.harness', '1.2.3', '2026-01-01T00:00:00.000Z');
5435
5484
  ok = typeof g === 'string' && g.includes('.harness/progress-tracker.md') && g.includes('by leerness 1.2.3')
5436
- && g.includes('## 📁 디렉토리 구조 (핵심)') && g.includes('## 🧭 자주 묻는 위치') && g.includes('## 🔄 마이그레이션 안내') && g.includes('plan.md');
5485
+ && g.includes('## 📁 디렉토리 구조 (핵심)') && g.includes('## 🧭 자주 묻는 위치') && g.includes('plan.md')
5486
+ // 1.36.126 (T-0107): 여기도 제목 리터럴('## 🔄 마이그레이션 안내')을 붙잡고 있었다 — selftest 쪽만 고치고
5487
+ // 이 **복제본**을 놓쳐 게이트가 깨졌다(같은 단언이 두 표면에 있으면 둘 다 고쳐야 한다).
5488
+ // 제목이 아니라 계약을 건다: 가이드는 `.leerness` 를 다루되 그리로 **가라고 지시하지 않는다**.
5489
+ && /\.leerness/.test(g) && !/우선 사용/.test(g) && !/로 가야 함/.test(g) && /읽지 마십시오/.test(g);
5437
5490
  } catch {}
5438
5491
  console.log(ok ? '✓ B(1.9.377) UR-0025: _renderWorkspaceReferenceGuide 모듈 분리 (빌더 동작 + 핵심 섹션)' : '✗ workspace guide 빌더 실패');
5439
5492
  if (!ok) failed++;
@@ -8310,16 +8363,21 @@ total++;
8310
8363
  // codex 33차 #5: 메시지의 초 값이 **실제 적용된 한도**여야 한다 — 소스 리터럴만 보면
8311
8364
  // `Math.min(MCP_TIMEOUT_MS, 60000)` 같은 변조가 그대로 통과한다. 5초 지정 → "5초" 로 찍혀야 한다.
8312
8365
  && /⏱ 5초 안에/.test(shortRes)
8313
- // codex 33차 #3: probe 설명은 probe 있는 호출에만 selftest 에는 붙으면 된다
8314
- && !/bugfix probe등록된 task/.test(shortRes);
8315
- // codex 33차 #2: 예산은 **done 전이/sync 에만** 준다 전역으로 올리면 단일 스레드 서버가 그만큼 얼어붙는다.
8366
+ // codex 33차 #3 1.36.125 개정: 사유 줄은 호출에만 붙는다. 예전 단언은 사라진 문구를 부정해
8367
+ // **공허참**이 있었다(문구를 지워도 초록). 이제 "명시 override 걸리면 표의 사유를 말하지 않는다"
8368
+ // 실제 계약을 건다 5초에 잘린 사용자에게 "긴 예산 300초" 라고 말하면 거짓말이기 때문.
8369
+ && !/긴 예산\(/.test(shortRes);
8370
+ // codex 33차 #2: 긴 예산은 그 예산이 필요한 호출에만 — 전역으로 올리면 단일 스레드 서버가 그만큼 얼어붙는다.
8316
8371
  const srcTxt = fs.readFileSync(CLI, 'utf8');
8317
8372
  const probeBudget = (srcTxt.match(/MCP_TIMEOUT_PROBE_MS = (\d+)/) || [])[1];
8318
8373
  const defBudget = (srcTxt.match(/MCP_TIMEOUT_DEFAULT_MS = (\d+)/) || [])[1];
8319
8374
  T3 = !!probeBudget && Number(probeBudget) > 600000 // probe 예산 위
8320
8375
  && !!defBudget && Number(defBudget) <= 120000 // 기본은 짧게(서버 정지 시간 제한)
8321
8376
  && /timeout: budget,/.test(srcTxt) // 배선: 계산된 예산이 실제로 쓰인다
8322
- && /_needsProbeBudget\(cliArgs\)/.test(srcTxt);
8377
+ // 1.36.125: 이름 술어(_needsProbeBudget)는 표(MCP_LONG_PATHS)로 대체됐다 — 배선은 그 표를 쓰는지로 본다.
8378
+ // ⚠ 정확한 예산 계산 자체는 selftest 가 **실행으로** 단언한다(소스 문자열은 배선 유무만).
8379
+ && /_mcpTimeoutHint\(cliArgs\)/.test(srcTxt)
8380
+ && /const MCP_LONG_PATHS = \[/.test(srcTxt);
8323
8381
  ok = T1 && T2 && T3;
8324
8382
  if (!ok) console.log(` [이월2 디버그] plan정규화=${T1}(done=${doneCount} vc=${vcTotal}) 타임아웃사유=${T2} 기본한도=${T3}(${srcTimeout})`);
8325
8383
  } catch (e) { console.log(' [이월2 디버그] 예외: ' + ((e && e.message) || e)); }
@@ -12303,6 +12361,223 @@ total++;
12303
12361
  if (!ok) failed++;
12304
12362
  }
12305
12363
 
12364
+ // 1.36.125 (검수 P2): `verify-claim --all --run-tests` 는 done 행마다 verifyClaimCmd 를 부르는데
12365
+ // `--run-tests` 는 전역 플래그다 → 같은 테스트 스위트를 **주장 수만큼** 반복 실행했다(100주장 × 300초 = 8시간).
12366
+ // 메모화로 1회로 줄였다. 그런데 "안 돌렸다" 와 "한 번 돌리고 결과를 나눠 줬다" 는 겉보기가 같다 —
12367
+ // 그래서 **실패하는 스위트**를 판별 케이스로 쓴다: 전파되면 N개 주장 **전부** 실패하고, 끊기면 첫 주장만 실패한다.
12368
+ // 대조군(통과 스위트)도 함께 둔다 — 메모가 없는 실패를 지어내지 않는지.
12369
+ total++;
12370
+ {
12371
+ let ok = false; const bad = []; const dbg = {};
12372
+ const _dirs = [];
12373
+ const N = 3;
12374
+ const scenario = (label, testBody, expectFail) => {
12375
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-memo125-')); _dirs.push(d);
12376
+ const env = Object.assign({}, process.env, { TMPDIR: d, TEMP: d, TMP: d, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
12377
+ const counter = path.join(d, 'runs.txt');
12378
+ fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'memo', version: '0.1.0', scripts: { test: 'node t.js' } }));
12379
+ fs.writeFileSync(path.join(d, 't.js'), `require('fs').appendFileSync(${JSON.stringify(counter)}, 'x\\n'); ${testBody}`);
12380
+ const ri = cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env });
12381
+ if (ri.status !== 0) { bad.push(`${label}:init실패(${ri.status})`); return; }
12382
+ for (let i = 0; i < N; i++) {
12383
+ const ra = cp.spawnSync(process.execPath, [CLI, 'task', 'add', `주장${i}`, '--path', d, '--json'], { cwd: d, encoding: 'utf8', timeout: 300000, env });
12384
+ let id = null; try { id = JSON.parse(ra.stdout || '{}').id; } catch {}
12385
+ if (!id) { bad.push(`${label}:task add 실패`); return; }
12386
+ const ru = cp.spawnSync(process.execPath, [CLI, 'task', 'update', id, '--status', 'done', '--evidence', 't.js 테스트 3건 통과', '--path', d],
12387
+ { cwd: d, encoding: 'utf8', timeout: 600000, env });
12388
+ if (ru.status !== 0) { bad.push(`${label}:task update 실패(${ru.status})`); return; }
12389
+ }
12390
+ // 셋업이 조용히 실패하면 아래 판정이 공허해진다 — done 행이 실제로 N개인지 먼저 확인한다.
12391
+ const tracker = fs.readFileSync(path.join(d, '.harness', 'progress-tracker.md'), 'utf8');
12392
+ const doneRows = tracker.split('\n').filter(l => /^\|\s*T-\d+/.test(l) && /\|\s*done\s*\|/.test(l)).length;
12393
+ if (doneRows !== N) { bad.push(`${label}:셋업done행 ${doneRows}(기대 ${N})`); return; }
12394
+ try { fs.unlinkSync(counter); } catch {}
12395
+ const r = cp.spawnSync(process.execPath, [CLI, 'verify-claim', '--all', '--path', d, '--json', '--run-tests'],
12396
+ { cwd: d, encoding: 'utf8', timeout: 900000, env, maxBuffer: 32 * 1024 * 1024 });
12397
+ let runs = 0; try { runs = fs.readFileSync(counter, 'utf8').trim().split('\n').filter(Boolean).length; } catch {}
12398
+ let j = null; try { j = JSON.parse(r.stdout); } catch {}
12399
+ if (!j || !Array.isArray(j.results)) { bad.push(`${label}:JSON아님`); return; }
12400
+ if (j.total !== N) bad.push(`${label}:total=${j.total}(기대 ${N})`);
12401
+ if (runs !== 1) bad.push(`${label}:스위트실행 ${runs}회(기대 1)`);
12402
+ const failedClaims = j.results.filter(x => x && x.ok === false).length;
12403
+ if (expectFail && failedClaims !== N) bad.push(`${label}:실패전파 ${failedClaims}/${N}`);
12404
+ if (!expectFail && failedClaims !== 0) bad.push(`${label}:거짓실패 ${failedClaims}건`);
12405
+ dbg[label] = { runs, total: j.total, failedClaims };
12406
+ };
12407
+ try {
12408
+ scenario('통과', "console.log('3 passed, 0 failed'); process.exit(0);", false);
12409
+ scenario('실패', "console.log('1 passed, 2 failed'); process.exit(1);", true);
12410
+ ok = bad.length === 0;
12411
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
12412
+ finally { _dirs.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
12413
+ console.log(ok ? `✓ U(1.36.125) verify-claim --all --run-tests: 스위트 1회만 실행(주장 수만큼 반복 금지) · 실패 결과가 ${N}개 주장 **전부**에 전파 · 통과 시 거짓 실패 0 ${JSON.stringify(dbg)}`
12414
+ : '✗ 1.36.125 --all 테스트 메모화 실패 ' + JSON.stringify({ bad: bad.slice(0, 6), dbg }));
12415
+ if (!ok) failed++;
12416
+ }
12417
+
12418
+ // 1.36.126 (T-0107): `migrate-workspace-dir` 는 **배달할 수 없는 것을 배달했다고** 말하고 있었다.
12419
+ // 실측: 58파일을 복사하고 "✓ 마이그레이션 완료 → 다음 handoff부터 .leerness 우선 사용" 이라 찍은 뒤,
12420
+ // 바로 이어지는 `task add` 는 여전히 `.harness` 에 썼다(env 를 켜도 동일). 즉 `.leerness` 는 복사 시점에
12421
+ // 얼어붙은 사본이 되고, 같이 생성되는 WHERE_TO_FIND.md 가 **AI 를 그 낡은 상태로 안내**한다.
12422
+ // 배선 비용 실측: 경로 구성 252곳 + 안내 문자열 634곳 vs 해석기 소비처 9곳 → 이번 라운드에 배선 불가.
12423
+ // 그래서 ① 함정을 만들지 않고(fail-closed) ② 이미 당한 프로젝트를 알리고 ③ 평범한 프로젝트는 조용하다.
12424
+ total++;
12425
+ {
12426
+ let ok = false; const bad = []; const dbg = {};
12427
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ws126-'));
12428
+ const envW = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
12429
+ for (const k of ['LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG', 'LEERNESS_MCP_PROFILE', 'LEERNESS_MCP_TIMEOUT_MS']) delete envW[k];
12430
+ const mkP = (name) => {
12431
+ const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
12432
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"w","version":"0.1.0"}');
12433
+ const r = cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 600000, env: envW });
12434
+ if (r.status !== 0) bad.push(`${name}:init실패(${r.status})`);
12435
+ return d;
12436
+ };
12437
+ const RW = (d, args, extra) => cp.spawnSync(process.execPath, [CLI, ...args],
12438
+ { cwd: d, encoding: 'utf8', timeout: 600000, env: Object.assign({}, envW, extra || {}), maxBuffer: 32 * 1024 * 1024 });
12439
+ try {
12440
+ // ① 함정을 만들지 않는다 — 복사 0 · 명시적 실패 · 사유와 실측 근거를 함께 말한다
12441
+ const A = mkP('A');
12442
+ const m = RW(A, ['migrate-workspace-dir', '--apply', '--path', A]);
12443
+ if (fs.existsSync(path.join(A, '.leerness'))) bad.push('①.leerness가생성됨');
12444
+ if (m.status === 0) bad.push('①조용한성공(exit 0)');
12445
+ if (!/죽은 사본|옮길 수 없습니다/.test(m.stdout || '')) bad.push('①사유없음');
12446
+ // ⚠ 여기에 리터럴(252)을 박아 뒀더니 다음 편집에서 곧바로 틀렸다 — 사유의 숫자는 **실측과 같은지**로 본다.
12447
+ // 실제 대조는 아래 ⑥(제품 보고 == e2e 실측). 여기서는 "숫자를 아예 안 대는" 경우만 잡는다.
12448
+ if (!/\d{2,}곳이 '\.harness'/.test(m.stdout || '')) bad.push('①실측근거없음');
12449
+ const mj = RW(A, ['migrate-workspace-dir', '--apply', '--path', A, '--json']);
12450
+ let mjj = null; try { mjj = JSON.parse(mj.stdout); } catch { bad.push('①JSON아님'); }
12451
+ if (mjj && mjj.blocked !== true) bad.push('①json:blocked아님');
12452
+ if (mjj && mjj.blockedReason !== 'workspace-dir-alt-unsupported') bad.push(`①json:reason=${mjj && mjj.blockedReason}`);
12453
+ if (mj.status === 0) bad.push('①json:exit 0');
12454
+ if (mjj && Array.isArray(mjj.copiedFiles) && mjj.copiedFiles.length) bad.push('①json:복사보고');
12455
+ dbg.A = { exit: m.status, created: fs.existsSync(path.join(A, '.leerness')) };
12456
+
12457
+ // ② 이미 당한 프로젝트 — 옛 버전이 남긴 상태를 재현(옛 바이너리가 없으므로 손으로 만든다)
12458
+ const B = mkP('B');
12459
+ fs.mkdirSync(path.join(B, '.leerness'), { recursive: true });
12460
+ fs.copyFileSync(path.join(B, '.harness', 'progress-tracker.md'), path.join(B, '.leerness', 'progress-tracker.md'));
12461
+ fs.writeFileSync(path.join(B, '.leerness', 'MIGRATED_FROM_HARNESS'), 'migrated');
12462
+ fs.writeFileSync(path.join(B, '.leerness', 'WHERE_TO_FIND.md'), '# 어디서 찾나\n');
12463
+ RW(B, ['task', 'add', '사본 이후 작업', '--path', B, '--json']);
12464
+ const au = RW(B, ['audit', '--path', B, '--json']);
12465
+ let aj = null; try { aj = JSON.parse(au.stdout); } catch { bad.push('②auditJSON아님'); }
12466
+ const fnd = aj && Array.isArray(aj.findings) ? aj.findings.find(x => x.kind === 'stale_workspace_copy') : null;
12467
+ if (!fnd) bad.push('②죽은사본_미탐지');
12468
+ else if (fnd.guidePresent !== true) bad.push('②가이드파일_미보고');
12469
+ const wdj = RW(B, ['workspace-dir', 'get', '--path', B, '--json']);
12470
+ let wj = null; try { wj = JSON.parse(wdj.stdout); } catch { bad.push('②wdJSON아님'); }
12471
+ if (wj && wj.current !== '.harness') bad.push(`②현재=${wj.current}`);
12472
+ if (wj && wj.staleCopy !== true) bad.push('②staleCopy아님');
12473
+ if (wj && wj.altSupported !== false) bad.push('②altSupported참');
12474
+ if (!/죽은 사본/.test(RW(B, ['workspace-dir', 'get', '--path', B]).stdout || '')) bad.push('②사람용경고없음');
12475
+ // 마커·env 가 있어도 **쓰기는 정상**이어야 한다(예전엔 _isInitialized 가 .leerness 를 봐서 "미초기화" 거부했다)
12476
+ const w = RW(B, ['task', 'add', '마커 있어도 쓰기', '--path', B, '--json'], { LEERNESS_WORKSPACE_DIR: '.leerness' });
12477
+ if (w.status !== 0) bad.push(`②마커상태쓰기실패(${w.status})`);
12478
+ if (!fs.readFileSync(path.join(B, '.harness', 'progress-tracker.md'), 'utf8').includes('마커 있어도 쓰기')) bad.push('②쓰기가.harness에없음');
12479
+ dbg.B = { finding: !!fnd, staleCopy: wj && wj.staleCopy, writeExit: w.status };
12480
+
12481
+ // ③ 대조군 — 평범한 프로젝트는 조용하다(오탐이 이 경고를 죽인다)
12482
+ const C = mkP('C');
12483
+ const au2 = RW(C, ['audit', '--path', C, '--json']);
12484
+ let aj2 = null; try { aj2 = JSON.parse(au2.stdout); } catch { bad.push('③auditJSON아님'); }
12485
+ const n2 = aj2 && Array.isArray(aj2.findings) ? aj2.findings.filter(x => x.kind === 'stale_workspace_copy').length : -1;
12486
+ if (n2 !== 0) bad.push(`③대조군오탐 ${n2}건`);
12487
+ const wd2 = RW(C, ['workspace-dir', 'get', '--path', C]).stdout || '';
12488
+ if (/죽은 사본|적용되지 않습니다/.test(wd2)) bad.push('③대조군에경고문구');
12489
+ dbg.C = { falsePositives: n2 };
12490
+
12491
+ // ③-c 마커가 지워진 사본도 잡는다(검수 P2) + **남의 `.leerness` 는 건드리지 않는다**(오탐이 경고를 죽인다)
12492
+ {
12493
+ const D = mkP('D'); // 마커 없음 · 우리 가이드 파일만 남음
12494
+ fs.mkdirSync(path.join(D, '.leerness'), { recursive: true });
12495
+ fs.writeFileSync(path.join(D, '.leerness', 'WHERE_TO_FIND.md'), 'Generated: 2026-01-01T00:00:00.000Z by leerness 1.36.100\n');
12496
+ const ad = RW(D, ['audit', '--path', D, '--json']);
12497
+ let adj = null; try { adj = JSON.parse(ad.stdout); } catch { bad.push('③c auditJSON아님'); }
12498
+ const fd = adj && Array.isArray(adj.findings) ? adj.findings.find(x => x.kind === 'stale_workspace_copy') : null;
12499
+ if (!fd) bad.push('③c마커없는사본_미탐지');
12500
+ else if (fd.markerPresent !== false || fd.guideIsOurs !== true) bad.push(`③c근거오류(${fd.markerPresent}/${fd.guideIsOurs})`);
12501
+
12502
+ const E = mkP('E'); // 남의 도구가 만든 .leerness — 우리 서명 없음
12503
+ fs.mkdirSync(path.join(E, '.leerness'), { recursive: true });
12504
+ fs.writeFileSync(path.join(E, '.leerness', 'WHERE_TO_FIND.md'), '# someone else\n');
12505
+ fs.writeFileSync(path.join(E, '.leerness', 'config.yml'), 'a: 1\n');
12506
+ const ae = RW(E, ['audit', '--path', E, '--json']);
12507
+ let aej = null; try { aej = JSON.parse(ae.stdout); } catch { bad.push('③c(대조)auditJSON아님'); }
12508
+ const fe = aej && Array.isArray(aej.findings) ? aej.findings.filter(x => x.kind === 'stale_workspace_copy').length : -1;
12509
+ if (fe !== 0) bad.push(`③c남의.leerness오탐 ${fe}건`);
12510
+ dbg.D = { detected: !!fd, foreignFalsePositives: fe };
12511
+ }
12512
+
12513
+ // ③-b 부모 자산 — 부모에 죽은 사본이 있어도 **살아 있는 저장소**의 자산을 물려받아야 한다.
12514
+ // (`_findParentWorkspace` 는 해석기를 안 쓰고 자체 로직으로 `.leerness` 를 우선하고 있었다 —
12515
+ // 같은 클래스가 다른 자리에 남아 있던 경우. 자식이 낡은 design-system 을 물려받는다.)
12516
+ {
12517
+ const P = mkP('P'); // 부모
12518
+ fs.writeFileSync(path.join(P, '.harness', 'design-system.md'), '# LIVE\n');
12519
+ fs.mkdirSync(path.join(P, '.leerness'), { recursive: true });
12520
+ fs.writeFileSync(path.join(P, '.leerness', 'MIGRATED_FROM_HARNESS'), 'x');
12521
+ fs.writeFileSync(path.join(P, '.leerness', 'design-system.md'), '# STALE\n');
12522
+ const kid = path.join(P, 'kid'); fs.mkdirSync(kid, { recursive: true });
12523
+ fs.writeFileSync(path.join(kid, 'package.json'), '{"name":"kid","version":"0.1.0"}');
12524
+ const pw = RW(kid, ['parent', 'detect', '--path', kid, '--json']);
12525
+ let pj = null; try { pj = JSON.parse(pw.stdout); } catch { bad.push('③b parentJSON아님'); }
12526
+ const par = pj && pj.parent;
12527
+ if (!par) bad.push('③b부모미탐지(셋업실패면 아래 단언이 공허해진다)');
12528
+ else {
12529
+ if (par.workspaceDir !== '.harness') bad.push(`③b부모워크스페이스=${par.workspaceDir}`);
12530
+ if (!String(par.workspaceAbs || '').endsWith('.harness')) bad.push('③b부모abs가.harness아님');
12531
+ if (par.assets && par.assets.designSystem !== true) bad.push('③b살아있는자산미탐지(계측붕괴)');
12532
+ // 판별: 실제로 읽히는 파일이 LIVE 인가(경로만 맞고 내용이 STALE 이면 의미 없다)
12533
+ const body = fs.readFileSync(path.join(par.workspaceAbs, 'design-system.md'), 'utf8');
12534
+ if (!/LIVE/.test(body)) bad.push('③b읽히는자산이 STALE');
12535
+ }
12536
+ dbg.P = { workspaceDir: par && par.workspaceDir, depth: par && par.depth };
12537
+ }
12538
+
12539
+ // ④ 순서 강제 — 하드코딩이 남아 있는 한 "지원함" 이라 말할 수 없다.
12540
+ // (selftest 는 자기 소스를 못 읽는다 — 1.36.84 메타가드. 그래서 이 불변식은 여기서 건다.)
12541
+ const binSrc = fs.readFileSync(CLI, 'utf8');
12542
+ const libDir = path.resolve(path.dirname(CLI), '..', 'lib'); // CLI 에서 유도 — 실행 위치에 의존하지 않는다
12543
+ let libSrc = '';
12544
+ for (const f of fs.readdirSync(libDir)) if (f.endsWith('.js')) libSrc += '\n' + fs.readFileSync(path.join(libDir, f), 'utf8');
12545
+ const hard = ((binSrc + libSrc).match(/path\.join\([^)]*'\.harness'/g) || []).length;
12546
+ if (hard < 50) bad.push(`④하드코딩계측붕괴(${hard})`); // 0건이면 측정이 깨진 것 — 공허통과 금지
12547
+ const claims = /const WORKSPACE_DIR_ALT_SUPPORTED = (true|false);/.exec(binSrc);
12548
+ if (!claims) bad.push('④지원상수없음');
12549
+ else if (claims[1] === 'true' && hard > 0) bad.push(`④하드코딩 ${hard}곳인데 지원함이라 주장`);
12550
+ // ⑤ 내부 시험 통로가 **제품 경로**로 새지 않는다.
12551
+ // (검수 P3) 정확 카운트는 안전한 리팩터만으로 깨진다 — 개수가 아니라 **어디에 있는가**로 본다:
12552
+ // selftest 케이스 영역(_selfTestCases) 밖에 이 옵션을 넘기는 곳이 있으면 제품 경로 누출이다.
12553
+ const selfStart = binSrc.indexOf('function _selfTestCases(');
12554
+ const selfEnd = selfStart < 0 ? -1 : binSrc.indexOf('\nfunction ', selfStart + 10);
12555
+ if (selfStart < 0 || selfEnd < 0) bad.push('⑤selftest영역경계못찾음');
12556
+ else {
12557
+ const outside = binSrc.slice(0, selfStart) + binSrc.slice(selfEnd);
12558
+ const inside = binSrc.slice(selfStart, selfEnd);
12559
+ // "언급" 이 아니라 **넘기는 것**만 센다 — 주석·선언(`opts.allowUnsupported !== true`)은 호출이 아니다.
12560
+ const passes = (s) => (s.match(/allowUnsupported\s*:/g) || []).length;
12561
+ const leaked = passes(outside);
12562
+ if (leaked > 0) bad.push(`⑤시험통로가 제품 경로에 ${leaked}곳`);
12563
+ if (passes(inside) < 1) bad.push('⑤시험통로를 아무도 안 태움(보호가 썩는다)');
12564
+ dbg.seam = { leaked, insideUses: passes(inside) };
12565
+ }
12566
+ dbg.src = { hard, claims: claims && claims[1] };
12567
+ // ⑥ 거부 사유의 숫자는 **실행 시 측정**이어야 한다(검수 P2: 손으로 적은 252가 곧 255로 낡았다).
12568
+ // 제품이 보고한 수와 여기서 센 수가 같아야 한다 — 다르면 둘 중 하나가 거짓말이다.
12569
+ if (mjj && mjj.measured) {
12570
+ if (mjj.measured.hardcodedPathSites !== hard) bad.push(`⑥보고=${mjj.measured.hardcodedPathSites} vs 실측=${hard}`);
12571
+ if (!(mjj.measured.resolverConsumers > 0)) bad.push('⑥해석기 소비처 0(계측붕괴)');
12572
+ } else bad.push('⑥measured 없음');
12573
+ ok = bad.length === 0;
12574
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
12575
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
12576
+ console.log(ok ? `✓ V(1.36.126/T-0107) 워크스페이스 대체 디렉터리: migrate 가 죽은 사본을 만들지 않고 명시적으로 실패(사유+실측) · 이미 당한 프로젝트를 audit/workspace-dir 가 알림 · 마커·env 가 있어도 쓰기 정상(.harness) · 대조군 오탐 0 ${JSON.stringify(dbg)}`
12577
+ : '✗ 1.36.126 워크스페이스 대체 디렉터리 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
12578
+ if (!ok) failed++;
12579
+ }
12580
+
12306
12581
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
12307
12582
  if (failed > 0) process.exit(1);
12308
12583