leerness 1.36.54 → 1.36.56

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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.56 — 2026-07-21 — 시크릿 스캐너 F-06 (외부감사 P1) + 매 라운드 codex 검수 룰 (R-0001, 사용자 지시)
4
+
5
+ 사용자 지시로 영구 룰 R-0001(every-round: 출하 전 codex 교차 검수) 등록 — 이 릴리스가 첫 적용: 1차 구현 → codex 검수가 4건 지적 → 전건 재현 확인 후 보정 → 재검.
6
+
7
+ - **무명 확장자 소형 텍스트 스캔**: `config.custom`/확장자 없는 credential 파일 미탐 해소 — ≤64KB + NUL 스니핑 + 이진 확장자 deny(검수 #4: .bin/.dat 등 추가). (검수 #2) 확장자 없는 파일('')이 기존 무제한 경로로 새던 것을 가드 경로로 통일 — 대형/이진 무명 파일도 이제 안전장치 적용.
8
+ - **중복 보고 dedupe**: (검수 #1) 시작-오프셋 키는 실제 중복(컨텍스트 패턴=키 시작 vs 직접 패턴=값 시작)을 못 접었다 — **값 스팬(시작+길이) 키**로 보정. 실측: `api_key = "npm_…"` 2건→1건, 서로 다른 토큰 2건 유지.
9
+ - **성능**: (검수 #3) 파일당 stat 2회+.gitignore 재읽기(5천 파일 실측 5천회) — stat 1회 + .gitignore 수집당 1회 캐시.
10
+ - 검증: selftest 329(+1: 동일토큰 1건/통제군/무확장 64KB·NUL 가드), 게이트, R-0001 검수 사이클 1회전 완료.
11
+
12
+ ## 1.36.55 — 2026-07-21 — E2E 아티팩트 이식성: 게시본에서 release gate 재현 (외부 GPT 감사 P0-2)
13
+
14
+ 감사 지적: 게시 npm 아티팩트에서 e2e 6건이 저장소-전용 픽스처로 실패 — "release gate 가 아티팩트에서 재현 안 되는 것은 릴리스 엔지니어링 결함". 6건 전부 자기완결화.
15
+
16
+ - **.gitignore/.npmignore 검사**: npm files 화이트리스트가 의도적으로 제외하는 저장소-전용 파일 — 부재 시 명시 `⊘ SKIP(아티팩트 프로필)` (조용한 누락 아님, total 미포함).
17
+ - **skillpack 동적 로드**: 형제 디렉토리(../leerness-skillpack) 가정 제거 — 없으면 최소 catalog.json 픽스처를 테스트가 직접 생성.
18
+ - **Codex 수렴(require-evidence) · capabilities/team 영어화**: auto 로케일(en 환경)에서 ko 정규식 단언이 깨지던 비결정성 — 모든 i18n 케이스에 `--language ko|en` 명시 고정.
19
+ - **agents dispatch flag bleed**: 실제 codex CLI 설치 가정 제거 — 즉시 종료하는 fake codex 실행파일(sh/cmd)을 temp PATH 앞에 주입.
20
+ - 검증: `npm pack` 아티팩트를 풀어 그 안에서 e2e 전체 실행(감사와 동일 방식) — 저장소 실행과 양쪽 그린.
21
+
3
22
  ## 1.36.54 — 2026-07-21 — codex 7차 헌트: 신작 표면(1.36.49~53) 12건 전수 수정 (High 2 · Med 7 · Low 3)
4
23
 
5
24
  직전 3개 릴리스에서 신설한 기능(clarify/preview/tech/그래프 탭/io v2)을 외부 적대 검증 — 12건 발견, 전수 독립 재현 후 전부 수정 (반박 0).
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.54 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.56 하네스를 사용합니다. 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.54는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
179
+ Leerness v1.36.56는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
180
180
 
181
181
  ```jsonc
182
182
  // 카테고리별
@@ -197,7 +197,7 @@ Leerness v1.36.54는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
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.54 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.56 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
201
201
 
202
202
  ### 성능 가이드
203
203
 
@@ -235,6 +235,6 @@ 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.54: 2026-07-21
238
+ Last synced by Leerness v1.36.56: 2026-07-21
239
239
  <!-- leerness:project-readme:end -->
240
240
 
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.54';
37
+ const VERSION = '1.36.56';
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') 시 호스트 프로세스 오염.
@@ -5126,6 +5126,33 @@ function _selfTestCases() {
5126
5126
  && io2.includes('!_jsonErrEmitted && t && (t[0]') && /process\.on\('exit', \(code\) => \{ if \(code !== 0/.test(io2) && io2.includes('.corrupt-${Date.now()}-${process.pid}-')
5127
5127
  && s.includes('declaredTestCount != null && runResult && !runResult.skipped && runResult.allPassed');
5128
5128
  return clar && shape && noDbUrl && reqOk && guards;
5129
+ } },
5130
+ { name: '시크릿 스캐너 F-06 (1.36.56, 외부감사): 무명 확장자 소형 텍스트 스캔(이진 NUL 제외) + 같은 토큰 중복 보고 dedupe — 행위검사', run: () => {
5131
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sc56_'));
5132
+ try {
5133
+ fs.writeFileSync(path.join(tmp, 'config.custom'), 'SERVICE_TOKEN = "npm_abcdefghijklmnopqrstuvwxyz0123456789"\n');
5134
+ fs.writeFileSync(path.join(tmp, 'CREDENTIALS'), 'password=hunter2realsecret\n');
5135
+ fs.writeFileSync(path.join(tmp, 'blob.custom'), Buffer.from([0, 1, 2, 3, 0, 5])); // 이진 → 제외
5136
+ fs.writeFileSync(path.join(tmp, 'sample.custom'), 'API_KEY=your-key-here\n'); // placeholder → 제외
5137
+ const r = _collectSecretFindings(tmp);
5138
+ const files = r.committed.map(f => f.file);
5139
+ const unknownOk = files.includes('config.custom') && files.includes('CREDENTIALS') && !files.includes('blob.custom') && !files.includes('sample.custom');
5140
+ // dedupe: 같은 토큰이 직접 패턴(npm 접두)과 컨텍스트 패턴(할당) 둘 다에 걸려도 1건 (검수 #1 실측 케이스)
5141
+ fs.writeFileSync(path.join(tmp, 'same.js'), 'api_key = "npm_a1B2a1B2a1B2a1B2a1B2a1B2a1B2a1B2a1B2"\n');
5142
+ const rSame = _collectSecretFindings(path.join(tmp, 'same.js'));
5143
+ const sameTokenOnce = rSame.committed.length === 1;
5144
+ // 서로 다른 토큰 2개는 유지 (통제군)
5145
+ fs.writeFileSync(path.join(tmp, 'dup.js'), 'const s = { password: "realpw123456", api_key: "realkey7890123" };\n');
5146
+ const r2 = _collectSecretFindings(path.join(tmp, 'dup.js'));
5147
+ const distinctKept = r2.committed.length === 2 && new Set(r2.committed.map(f => f.snippet)).size === 2;
5148
+ // 검수 #2: 확장자 없는 파일도 64KB/NUL 가드 적용 (대형/이진 무명 파일 제외)
5149
+ fs.writeFileSync(path.join(tmp, 'BIGNOEXT'), 'x'.repeat(64 * 1024 + 1) + ' password=hunterreal99\n');
5150
+ fs.writeFileSync(path.join(tmp, 'NULNOEXT'), Buffer.concat([Buffer.from([65, 66, 0, 67]), Buffer.from('password=hunterreal99\n')]));
5151
+ const r3 = _collectSecretFindings(tmp);
5152
+ const extlessGuard = !r3.committed.some(f => f.file === 'BIGNOEXT' || f.file === 'NULNOEXT');
5153
+ const srcOk = read(__filename).includes('_seenAt.has(_valKey)') && read(__filename).includes('_giMatch(fileRel)');
5154
+ return unknownOk && sameTokenOnce && distinctKept && extlessGuard && srcOk;
5155
+ } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
5129
5156
  } }
5130
5157
  ];
5131
5158
  }
@@ -9763,6 +9790,9 @@ function _isLikelyGitignored(root, fileRel) {
9763
9790
  function _collectSecretFindings(root) {
9764
9791
  root = absRoot(root);
9765
9792
  const findings = [];
9793
+ // 1.36.56 (검수 #3): .gitignore 는 수집당 1회만 로드 — 종전엔 적격 파일마다 재읽기(5천 파일 실측 5천회)
9794
+ let _giText = null; try { _giText = read(path.join(root, '.gitignore')); } catch {}
9795
+ const _giMatch = (fileRel) => _giText != null && _gitignoreMatch(_giText, fileRel);
9766
9796
  // 1.9.354 (UR-0072 외부리뷰): root 가 파일이면 그 파일만 스캔. 디렉토리면 walk.
9767
9797
  let _iter;
9768
9798
  try { _iter = fs.statSync(root).isFile() ? [root] : walk(root); } catch { _iter = walk(root); }
@@ -9772,16 +9802,32 @@ function _collectSecretFindings(root) {
9772
9802
  const isEnvFamily = /^\.env(\.|$)/.test(path.basename(file));
9773
9803
  // 1.26.1 (13번째 외부리뷰 P2): 개인키/인증서 파일(.pem/.key/.crt/.p8 …)은 확장자 allow-list 에 없어 스캔 누락 → 커밋된 개인키 미탐 + handoff 'OK' 거짓보증. basename 으로 강제 포함('Generic private key' 정규식이 실제로 돌도록).
9774
9804
  const isKeyFile = /\.(?:pem|key|crt|cer|der|p8|p12|pfx|pkcs8|ppk|asc|gpg|keystore|jks)$/i.test(path.basename(file));
9775
- if (!SCAN_TEXT_EXT.has(ext) && !isEnvFamily && !isKeyFile) continue;
9805
+ // 1.36.56 (외부 GPT 감사 F-06): 없는 확장자도 "작고(≤64KB) 텍스트인" 파일은 스캔 —
9806
+ // config.custom / 확장자 없는 credential 파일 미탐을 막는다. 이진 파일은 확장자 deny + NUL 스니핑으로 제외.
9807
+ // (검수 #2) 확장자 없는 파일('')은 SCAN_TEXT_EXT 의 '' 무제한 경로가 아니라 이 가드 경로로 — 64KB/NUL 안전장치가 실제 적용되게.
9808
+ // (검수 #3) 확장자 deny 를 stat 보다 먼저, stat 은 1회만.
9809
+ const _binExt = /\.(?:png|jpe?g|gif|webp|ico|bmp|pdf|zip|gz|tgz|7z|rar|exe|dll|so|dylib|woff2?|ttf|eot|otf|mp[34]|wav|mov|avi|wasm|jar|class|pyc|db|sqlite3?|bin|dat|pak|iso|img)$/i.test(ext);
9810
+ const _known = (ext !== '' && SCAN_TEXT_EXT.has(ext)) || isEnvFamily || isKeyFile;
9811
+ if (!_known && _binExt) continue;
9812
+ let st; try { st = fs.statSync(file); } catch { continue; } // 1.12.5: stat-before-read (1회)
9813
+ if (st.size > 1024 * 1024) continue;
9776
9814
  let text;
9777
- // 1.12.5 (15th 버그헌트 P2, UR-0019): stat-before-read — 1MB 초과 파일은 읽지 않고 건너뜀(이전엔 read 후 검사라 대형 파일 통째 로드).
9778
- try { if (fs.statSync(file).size > 1024 * 1024) continue; } catch { continue; }
9779
- try { text = read(file); } catch { continue; }
9815
+ if (!_known) {
9816
+ if (st.size === 0 || st.size > 64 * 1024) continue; // 소형 파일만 (감사 권고)
9817
+ let buf; try { buf = fs.readFileSync(file); } catch { continue; }
9818
+ if (buf.subarray(0, 512).includes(0)) continue; // NUL = 이진
9819
+ text = buf.toString('utf8'); if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
9820
+ } else {
9821
+ try { text = read(file); } catch { continue; }
9822
+ }
9780
9823
  if (text.length > 1024 * 1024) continue;
9781
9824
  const fileRel = (file === root) ? path.basename(file) : rel(root, file);
9782
9825
  // 1.9.350 (UR-0060): leerness 자기 harness.js + secret-policy 템플릿만 제외.
9783
9826
  if (path.resolve(file) === path.resolve(__filename) || /(^|[\\/])\.(?:harness|leerness)[\\/]secret-policy\.md$/.test(fileRel)) continue;
9784
- const gitignored = _isLikelyGitignored(root, fileRel); // 1.9.365 CV-6: gitignored 면 info 강등
9827
+ const gitignored = _giMatch(fileRel); // 1.9.365 CV-6: gitignored 면 info 강등 (1.36.56 검수 #3: 캐시 사용)
9828
+ // 1.36.56 (F-06, 검수 #1 보정): 같은 "값 스팬"이 복수 패턴에 걸리면 1건만 — 컨텍스트 패턴(키에서 시작)과
9829
+ // 직접 패턴(값에서 시작)이 같은 토큰을 이중 보고하던 실측 케이스를 값 위치 기준으로 접는다. 서로 다른 토큰은 유지.
9830
+ const _seenAt = new Set();
9785
9831
  for (const { name, re, valueGroup, requireSecretLike } of SECRET_PATTERNS) {
9786
9832
  re.lastIndex = 0;
9787
9833
  let m;
@@ -9793,6 +9839,11 @@ function _collectSecretFindings(root) {
9793
9839
  if (_isPlaceholderSecret(val)) { if (re.lastIndex === m.index) re.lastIndex++; continue; }
9794
9840
  if (valueGroup != null && requireSecretLike && !_looksSecretLike(val)) { if (re.lastIndex === m.index) re.lastIndex++; continue; }
9795
9841
  const line = text.slice(0, m.index).split('\n').length;
9842
+ // 1.36.56 (F-06, 검수 #1): dedupe 키 = 값 스팬(시작+길이) — valueGroup 없으면 전체 매치 스팬
9843
+ const _valOff = (valueGroup != null && m[valueGroup]) ? m.index + m[0].indexOf(m[valueGroup]) : m.index;
9844
+ const _valKey = `${_valOff}:${String(val || '').length}`;
9845
+ if (_seenAt.has(_valKey)) { if (re.lastIndex === m.index) re.lastIndex++; continue; }
9846
+ _seenAt.add(_valKey);
9796
9847
  findings.push({ file: fileRel, line, name, snippet: m[0].slice(0, 32), gitignored });
9797
9848
  // 16th 버그헌트 F1: break 제거 — 같은 패턴이 한 파일에 여러 번(예: secret: + api_key: 둘 다 'Hardcoded password') 나오면 모두 보고(보안 FN 차단). zero-width 매치는 lastIndex 전진으로 무한루프 방지.
9798
9849
  if (re.lastIndex === m.index) re.lastIndex++;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.54",
3
+ "version": "1.36.56",
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
@@ -1350,24 +1350,30 @@ total++;
1350
1350
  if (!ok) { failed++; console.log(r.stdout.slice(0, 400)); }
1351
1351
  }
1352
1352
 
1353
- total++;
1353
+ // 1.36.55 (외부감사 이식성): .gitignore/.npmignore 는 npm files 화이트리스트에서 의도적으로 제외되는 저장소-전용 파일 —
1354
+ // 게시 아티팩트에서는 검사 자체가 성립 안 하므로 명시 SKIP (조용한 누락이 아니라 사유 로그).
1354
1355
  {
1355
- // .gitignore에 _reports/ 포함 — leerness-pkg
1356
1356
  const giPath = path.join(__dirname, '..', '.gitignore');
1357
- const body = fs.existsSync(giPath) ? fs.readFileSync(giPath, 'utf8') : '';
1358
- const ok = /_reports\//.test(body) && /\*\.private\.md/.test(body);
1359
- console.log(ok ? '✓ B(1.9.43) leerness-pkg/.gitignore: _reports/ + *.private.md 차단' : `✗ .gitignore 실패`);
1360
- if (!ok) { failed++; console.log(body.slice(0, 300)); }
1357
+ if (!fs.existsSync(giPath)) console.log('⊘ SKIP(아티팩트 프로필): leerness-pkg/.gitignore 검사 — 저장소 체크아웃 전용');
1358
+ else {
1359
+ total++;
1360
+ const body = fs.readFileSync(giPath, 'utf8');
1361
+ const ok = /_reports\//.test(body) && /\*\.private\.md/.test(body);
1362
+ console.log(ok ? '✓ B(1.9.43) leerness-pkg/.gitignore: _reports/ + *.private.md 차단' : `✗ .gitignore 실패`);
1363
+ if (!ok) { failed++; console.log(body.slice(0, 300)); }
1364
+ }
1361
1365
  }
1362
1366
 
1363
- total++;
1364
1367
  {
1365
- // .npmignore에 _reports/ + 보고서 차단 명시
1366
1368
  const niPath = path.join(__dirname, '..', '.npmignore');
1367
- const body = fs.existsSync(niPath) ? fs.readFileSync(niPath, 'utf8') : '';
1368
- const ok = /_reports\//.test(body) && /\*\.private/.test(body);
1369
- console.log(ok ? '✓ B(1.9.43) leerness-pkg/.npmignore: _reports/ 차단' : `✗ .npmignore 실패`);
1370
- if (!ok) { failed++; console.log(body.slice(0, 300)); }
1369
+ if (!fs.existsSync(niPath)) console.log('⊘ SKIP(아티팩트 프로필): leerness-pkg/.npmignore 검사 — 저장소 체크아웃 전용');
1370
+ else {
1371
+ total++;
1372
+ const body = fs.readFileSync(niPath, 'utf8');
1373
+ const ok = /_reports\//.test(body) && /\*\.private/.test(body);
1374
+ console.log(ok ? '✓ B(1.9.43) leerness-pkg/.npmignore: _reports/ 차단' : `✗ .npmignore 실패`);
1375
+ if (!ok) { failed++; console.log(body.slice(0, 300)); }
1376
+ }
1371
1377
  }
1372
1378
 
1373
1379
  // 1.9.42 회귀: agentskills.io 표준 호환 (skill install/discover/export + .env opt-in)
@@ -2347,9 +2353,17 @@ total++;
2347
2353
  }
2348
2354
 
2349
2355
  // 1.9.10 A: skillpack 동적 로드 (LEERNESS_SKILLPACK_PATH로 시뮬)
2356
+ // 1.36.55 (외부감사 이식성): 형제 디렉토리 가정 제거 — 없으면 최소 catalog.json 픽스처를 테스트가 직접 생성 (자기완결)
2350
2357
  total++;
2351
2358
  {
2352
- const skillpackDir = path.resolve(__dirname, '..', '..', 'leerness-skillpack');
2359
+ let skillpackDir = path.resolve(__dirname, '..', '..', 'leerness-skillpack');
2360
+ if (!fs.existsSync(path.join(skillpackDir, 'catalog.json'))) {
2361
+ skillpackDir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-spfix-'));
2362
+ fs.writeFileSync(path.join(skillpackDir, 'catalog.json'), JSON.stringify({
2363
+ name: 'leerness-skillpack', version: '1.0.0', leernessCompat: '>=1.9.10', updatedAt: '2026-01-01',
2364
+ skills: [{ id: 'fixture-skill', displayNameKo: '픽스처 스킬', version: '1.0.0', lastUpdated: '2026-01-01', verification: 'passed', capabilities: ['e2e portable fixture'] }]
2365
+ }, null, 2));
2366
+ }
2353
2367
  const r = cp.spawnSync(process.execPath, [CLI, 'skill', 'list', '--path', tmp], {
2354
2368
  encoding: 'utf8',
2355
2369
  env: Object.assign({}, process.env, { LEERNESS_SKILLPACK_PATH: skillpackDir })
@@ -3014,7 +3028,7 @@ total++;
3014
3028
  {
3015
3029
  const cDir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-codex-'));
3016
3030
  const env = { ...process.env, LEERNESS_OFFLINE: '1' };
3017
- cp.spawnSync(process.execPath, [CLI, 'init', cDir, '--minimal', '--no-env', '--yes'], { encoding: 'utf8', timeout: 30000, env });
3031
+ cp.spawnSync(process.execPath, [CLI, 'init', cDir, '--minimal', '--no-env', '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000, env }); // 1.36.55: 로케일 고정 — auto(en 환경)에서 ko 정규식 단언이 깨지던 이식성 결함
3018
3032
  // A) 허위 완료(테스트 통과만) → --require-evidence FAIL (exit 1)
3019
3033
  cp.spawnSync(process.execPath, [CLI, 'task', 'add', 'fake done', '--path', cDir], { encoding: 'utf8', timeout: 15000, env });
3020
3034
  cp.spawnSync(process.execPath, [CLI, 'task', 'update', 'T-0002', '--status', 'done', '--evidence', '테스트 통과함', '--path', cDir], { encoding: 'utf8', timeout: 15000, env });
@@ -6315,7 +6329,7 @@ total++;
6315
6329
  const H = /[가-힣]/;
6316
6330
  const cap = (args) => (cp.spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', timeout: 15000 }).stdout) || '';
6317
6331
  const capEn = cap(['capabilities', '--language', 'en']);
6318
- const capKo = cap(['capabilities']);
6332
+ const capKo = cap(['capabilities', '--language', 'ko']); // 1.36.55: ko 도 명시 — auto 로케일 환경 비결정성 제거
6319
6333
  const capJsonEn = cap(['capabilities', '--json', '--language', 'en']);
6320
6334
  // EN: zero Hangul + English catalog desc/note + English labels; KO: preserved; JSON: English principles
6321
6335
  const capOk = !H.test(capEn) && /metadata files|external AI CLIs|mouse\/keyboard/.test(capEn)
@@ -6324,7 +6338,7 @@ total++;
6324
6338
  && /0 runtime dependencies/.test(capJsonEn);
6325
6339
  // team reminder body en/ko via handoff (full wiring: pure lang + caller _uiLang)
6326
6340
  const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-team-i18n-'));
6327
- cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { encoding: 'utf8', timeout: 30000 });
6341
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 }); // 1.36.55: 로케일 고정
6328
6342
  cp.spawnSync(process.execPath, [CLI, 'team', 'add', 'nightly', '--members', 'a,b', '--schedule', 'every-session', '--path', d], { encoding: 'utf8', timeout: 15000 });
6329
6343
  const hoEn = (cp.spawnSync(process.execPath, [CLI, 'handoff', d, '--language', 'en'], { encoding: 'utf8', timeout: 30000 }).stdout) || '';
6330
6344
  const hoKo = (cp.spawnSync(process.execPath, [CLI, 'handoff', d], { encoding: 'utf8', timeout: 30000 }).stdout) || '';
@@ -6593,7 +6607,11 @@ total++;
6593
6607
  try {
6594
6608
  const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-disp-'));
6595
6609
  cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6596
- const env = { ...process.env, LEERNESS_ENABLE_CODEX: '1' };
6610
+ // 1.36.55 (외부감사 이식성): 실제 codex 미설치 환경 자기완결 — 즉시 종료하는 fake codex 실행파일을 PATH 앞에 주입
6611
+ const fbin = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-fakecodex-'));
6612
+ if (process.platform === 'win32') fs.writeFileSync(path.join(fbin, 'codex.cmd'), '@echo fake-codex\r\n');
6613
+ else { fs.writeFileSync(path.join(fbin, 'codex'), '#!/bin/sh\necho fake-codex\n'); fs.chmodSync(path.join(fbin, 'codex'), 0o755); }
6614
+ const env = { ...process.env, LEERNESS_ENABLE_CODEX: '1', PATH: fbin + path.delimiter + process.env.PATH };
6597
6615
  const r = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', 'REVIEWTASK', '--to', 'codex', '--path', d], { encoding: 'utf8', timeout: 20000, env });
6598
6616
  const out = r.stdout || '';
6599
6617
  // 1.35.6: 기본은 harness 브리프가 접두되므로 task 는 "... 작업: REVIEWTASK" 끝에 그대로(코덱스/경로 흡수 없음).