leerness 1.36.176 → 1.36.178

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.178 — 2026-08-29
4
+
5
+ - CI: env encoding-check --apply 전체 E2E를 Windows BOM 교체와 POSIX byte-exact no-op 계약으로 분기해 v1.36.177 Linux/Node 22의 466/467 실패를 수정.
6
+
7
+ ## 1.36.177 — 2026-08-29
8
+
9
+ - T-0022 데이터 무결성: env/drift/session BOM 수정을 공용 CAS·Windows ReplaceFile 경계로 통합하고 CP949·모호 인코딩·손상 BOM·메타데이터·동시 writer/ADS·부분진행 복구를 실패-폐쇄 처리. auto-fix 전 단계 JSON/exit 정직화, 스캔 I/O 오류 및 unborn/실행불가 Git 구분, OS 매트릭스 mutation-integrity 회귀 추가.
10
+
3
11
  ## 1.36.176 — 2026-08-29
4
12
 
5
13
  - T-0089: agents bench가 활성화되지 않은 provider의 버전 명령까지 실행하던 환경 의존성을 제거했습니다. bench 후보를 opt-in provider로 먼저 제한하고, E2E는 레지스트리의 10개 플래그를 모두 비활성화한 뒤 PATH 선두 실행 마커가 생성되지 않는지 검증합니다. 수정 후 동일 Windows Node 26 환경 직접 프로브 3회는 394/362/342ms(최대 394ms)였으며, 기존 15초 제한은 최댓값 대비 약 38.1배 여유라 불필요한 상향을 하지 않습니다.
package/README.md CHANGED
@@ -138,7 +138,7 @@ MIT
138
138
  <!-- leerness:project-readme:start -->
139
139
  ## Leerness Project Harness
140
140
 
141
- 이 프로젝트는 Leerness v1.36.176 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
141
+ 이 프로젝트는 Leerness v1.36.178 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
142
142
 
143
143
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
144
144
 
@@ -192,7 +192,7 @@ leerness memory restore decision <date|title>
192
192
 
193
193
  ### MCP server (외부 AI 통합)
194
194
 
195
- Leerness v1.36.176는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
195
+ Leerness v1.36.178는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
196
196
 
197
197
  ```jsonc
198
198
  // 카테고리별
@@ -213,7 +213,7 @@ Leerness v1.36.176는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
213
213
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
214
214
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
215
215
 
216
- 현재 누적: **v1.9.x → 1.36.176 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
216
+ 현재 누적: **v1.9.x → 1.36.178 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
217
217
 
218
218
  ### 성능 가이드
219
219
 
@@ -251,5 +251,5 @@ leerness release pack --close --auto-main-push
251
251
  - `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
252
252
  - `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
253
253
 
254
- Last synced by Leerness v1.36.176: 2026-08-29
254
+ Last synced by Leerness v1.36.178: 2026-08-29
255
255
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -7,6 +7,7 @@ const cp = require('child_process');
7
7
  const { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel, setDryRunGuard, mkdirpRaw } = require('../lib/io'); // 1.9.382/383 (UR-0025): 출력/시간/파일 프리미티브 공유 모듈 · 1.10.2 (UR-0146): setQuiet
8
8
  const { resolveNpmCliPath, spawnNpmSync } = require('../lib/npm-process');
9
9
  const { resolvePortableLaunch, spawnPortable, spawnPortableSync } = require('../lib/portable-process');
10
+ const { scanShellScriptsEncoding, applyShellScriptUtf8Bom } = require('../lib/shell-encoding');
10
11
  const os = require('os'); // 1.9.178: _publishToNpm 에서 os.tmpdir() 사용 (전역 import)
11
12
  const readline = require('readline');
12
13
  // 1.9.274 (UR-0025 1단계): 순수 유틸 함수 모듈 분리 (require-based, 비파괴). selftest 7종이 동작 검증.
@@ -48,7 +49,7 @@ const {
48
49
  migrateLegacyWorkspace,
49
50
  } = require('../lib/workspace-dir');
50
51
 
51
- const VERSION = '1.36.176';
52
+ const VERSION = '1.36.178';
52
53
 
53
54
  // MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
54
55
  // 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
@@ -3798,52 +3799,7 @@ function _terminalEncodingNotice(opts = {}) {
3798
3799
  // 셸 스크립트 (.ps1/.bat/.cmd/.sh) 인코딩 위험 감지
3799
3800
  // 위험 시그널: 한국어 (또는 비-ASCII) 문자 + BOM 없음 + Windows 환경
3800
3801
  function _scanShellScriptsEncoding(root) {
3801
- const result = { scanned: 0, atRisk: [], notes: [] };
3802
- function walk(dir, depth = 0) {
3803
- if (depth > 3) return; // 너무 깊이 들어가지 않음
3804
- let entries;
3805
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
3806
- for (const e of entries) {
3807
- const fp = path.join(dir, e.name);
3808
- if (e.isDirectory()) {
3809
- if (/^(node_modules|\.git|__pycache__|venv|\.venv|dist|build)$/.test(e.name)) continue;
3810
- walk(fp, depth + 1);
3811
- } else if (e.isFile() && /\.(ps1|bat|cmd|sh)$/i.test(e.name)) {
3812
- result.scanned++;
3813
- try {
3814
- const buf = fs.readFileSync(fp);
3815
- const hasBOM = buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF;
3816
- // 비-ASCII (≥ 0x80) 바이트 존재 여부 검사
3817
- let nonAscii = false;
3818
- for (let i = 0; i < Math.min(buf.length, 4096); i++) {
3819
- if (buf[i] >= 0x80) { nonAscii = true; break; }
3820
- }
3821
- if (nonAscii && !hasBOM) {
3822
- // 1.9.243: CJK 분류 추가
3823
- const cjk = _classifyCJK(buf, 4096);
3824
- const label = _riskLabel(cjk);
3825
- result.atRisk.push({
3826
- file: path.relative(root, fp),
3827
- ext: path.extname(fp),
3828
- hasBOM: false,
3829
- nonAscii: true,
3830
- riskType: label.type,
3831
- cjk,
3832
- risk: label.risk
3833
- });
3834
- }
3835
- } catch {}
3836
- }
3837
- }
3838
- }
3839
- walk(root);
3840
- if (result.atRisk.length > 0) {
3841
- result.notes.push('해결: 파일 시작에 UTF-8 BOM 추가 (EF BB BF) 또는 .ps1 첫 줄에 $OutputEncoding/[Console]::OutputEncoding 설정');
3842
- }
3843
- // 1.9.243: 분류별 통계 요약
3844
- const types = result.atRisk.reduce((m, r) => { m[r.riskType] = (m[r.riskType] || 0) + 1; return m; }, {});
3845
- result.riskTypeCounts = types;
3846
- return result;
3802
+ return scanShellScriptsEncoding(root, { classifyCJK: _classifyCJK, riskLabel: _riskLabel });
3847
3803
  }
3848
3804
 
3849
3805
  function envCmd(root, sub) {
@@ -3863,40 +3819,21 @@ function envCmd(root, sub) {
3863
3819
  for (const r of result.atRisk) {
3864
3820
  try {
3865
3821
  const fullPath = path.join(root, r.file);
3866
- const orig = fs.readFileSync(fullPath);
3867
- // 1.9.409 (8번째 버그헌트, UR-0113): .sh / shebang(#!) 파일은 BOM 추가 금지.
3868
- // BOM(EF BB BF)이 shebang 앞에 붙으면 커널이 '#!' 를 인식 못 해 스크립트 실행 불가 → encoding check 가 자기 도구로 .sh 를 깨뜨리던 모순 차단.
3869
- const isShebang = orig.length >= 2 && orig[0] === 0x23 && orig[1] === 0x21; // '#!'
3870
- if (/\.sh$/i.test(r.file) || isShebang) {
3871
- result.applied.push({ file: r.file, action: 'skipped-shebang (BOM은 shebang을 깨뜨림 — .sh는 no-BOM UTF-8 유지)' });
3872
- continue;
3873
- }
3874
- // 1.35.15 (encoding 헌트, codex #2): .bat/.cmd 는 BOM 추가 금지 — cmd.exe 는 BOM 으로 코드페이지를 바꾸지 않음(첫 줄 'chcp 65001' 이 올바른 해법). BOM 은 무익하고 일부 Windows 에선 첫 명령을 깨뜨림 → 거짓 수정 대신 chcp 안내.
3875
- if (/\.(bat|cmd)$/i.test(r.file)) {
3876
- result.applied.push({ file: r.file, action: 'skipped-batch (cmd.exe: 첫 줄에 chcp 65001 추가가 정답 — BOM은 코드페이지를 안 바꿈)' });
3877
- continue;
3878
- }
3879
- // 1.35.15 (encoding 헌트, codex #4 DESTRUCTIVE): 본문이 유효 UTF-8 일 때만 BOM 추가. CP949/Latin-1 등 비-UTF-8 본문에 BOM 만 붙이면 'UTF-8 이라 주장하는 CP949' 손상본이 됨(transcode 없이 파괴적) → skip + 수동 transcode 안내. (mojibake 를 더 악화시키던 자기-파괴 차단, .sh 가드와 동류.)
3880
- const isValidUtf8 = Buffer.from(orig.toString('utf8'), 'utf8').equals(orig);
3881
- if (!isValidUtf8) {
3882
- result.applied.push({ file: r.file, action: 'skipped-nonutf8 (본문이 비-UTF-8(CP949 등) — BOM 추가는 손상. UTF-8 로 먼저 transcode 필요)' });
3883
- continue;
3884
- }
3885
- const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
3886
- const fixed = Buffer.concat([bom, orig]);
3887
- fs.writeFileSync(fullPath, fixed);
3888
- result.applied.push({ file: r.file, action: 'utf8-bom-added' });
3822
+ // 1.36.177 (T-0022): drift/session-close와 같은 최종 사전조건 및
3823
+ // 원자적 binary write를 공유한다. 스캔 뒤 파일이 바뀌어도 쓰기 직전 재검사한다.
3824
+ result.applied.push(applyShellScriptUtf8Bom(fullPath, r.file));
3889
3825
  } catch (e) {
3890
- result.applied.push({ file: r.file, action: 'failed', error: e.message });
3826
+ result.applied.push({ file: r.file, action: 'failed', code: e.code || 'error', error: e.message, ...(e.backupFile ? { backupFile: e.backupFile } : {}), ...(e.recoveryArtifacts ? { recoveryArtifacts: e.recoveryArtifacts } : {}) });
3891
3827
  }
3892
3828
  }
3893
3829
  }
3894
3830
  // 1.36.20 (codex fresh-QA #7): apply 실패(read-only EPERM 등)를 exit code 에 반영 — 종전엔 action:'failed' 를 기록만 하고 exit 0.
3895
- if (apply && (result.applied || []).some(a => a.action === 'failed')) process.exitCode = 1;
3831
+ if (result.scanErrors.length > 0 || (apply && (result.applied || []).some(a => a.action === 'failed'))) process.exitCode = 1;
3896
3832
  if (has('--json')) { log(JSON.stringify(result, null, 2)); return; }
3897
3833
  log(cy(`# leerness env encoding-check (1.9.241/242, UR-0014) — 셸 스크립트 인코딩 위험 감지`));
3898
3834
  log('');
3899
3835
  log(` 스캔: ${gr(String(result.scanned))} 파일 (.ps1/.bat/.cmd/.sh)`);
3836
+ if (result.scanErrors.length > 0) log(rd(` ⚠ 읽기 실패 ${result.scanErrors.length}건 — 검사가 불완전함`));
3900
3837
  if (result.atRisk.length === 0) {
3901
3838
  log(gr(` ✓ 인코딩 위험 없음`));
3902
3839
  } else {
@@ -3908,8 +3845,10 @@ function envCmd(root, sub) {
3908
3845
  log('');
3909
3846
  if (apply) {
3910
3847
  const okCnt = (result.applied || []).filter(a => a.action === 'utf8-bom-added').length;
3848
+ const skipCnt = (result.applied || []).filter(a => a.action.startsWith('skipped-')).length;
3911
3849
  const failCnt = (result.applied || []).filter(a => a.action === 'failed').length;
3912
- log(gr(` ✓ UTF-8 BOM 추가 ${okCnt}건${failCnt > 0 ? ` (실패 ${failCnt}건)` : ''} (1.9.242 --apply)`));
3850
+ log(gr(` ✓ UTF-8 BOM 추가 ${okCnt}건${skipCnt > 0 ? ` · 안전상 건너뜀 ${skipCnt}건` : ''}${failCnt > 0 ? ` · 실패 ${failCnt}건` : ''} (1.9.242 --apply)`));
3851
+ for (const a of (result.applied || []).filter(a => a.backupFile)) log(yl(` ↳ 원본/동시 편집 복구본 보존: ${a.backupFile}`));
3913
3852
  } else {
3914
3853
  result.notes.forEach(n => log(yl(` 💡 ${n}`)));
3915
3854
  log(dm(` → 자동 수정: leerness env encoding --apply (BOM 자동 추가)`));
@@ -4345,7 +4284,7 @@ function _selfTestCases() {
4345
4284
  const s = read(__filename);
4346
4285
  const reuseOk = s.includes('project: path.basename(root), found, applied, added') && s.includes("failJson(has('--json'), 'no_reuse_map'"); // reuse: apply 먼저 → json 에 applied/added
4347
4286
  const releaseOk = s.includes('deleteCount: toDelete.length, deleted, deleteFailed') && s.includes('if (deleteFailed > 0) process.exitCode = 1'); // release: 삭제 먼저 → json 에 deleted
4348
- const encOk = s.includes("(result.applied || []).some(a => a.action === 'failed')) process.exitCode = 1"); // encoding: 실패 exit1
4287
+ const encOk = s.includes("result.scanErrors.length > 0 || (apply && (result.applied || []).some(a => a.action === 'failed'))"); // encoding: 스캔/적용 실패 exit1
4349
4288
  return reuseOk && releaseOk && encOk;
4350
4289
  } },
4351
4290
  { name: '미검토표면 헌트 (1.36.28, codex): #8 impact 역의존 전이 + #10 alias 단어경계 + #3 손상스토어 클로버 차단 + #4 URL 해시 — 순수/소스 행위검사', run: () => {
@@ -5188,7 +5127,31 @@ function _selfTestCases() {
5188
5127
  { name: '8번째 버그헌트 (UR-0110): rule/decision/lesson add 동시쓰기 _withLock 직렬화 (UR-0043 갭 메움) (1.9.406)', run: () => { const src = read(__filename); const L = '_withLock('; const ruleLock = src.includes(L + 'rulesPath' + '(root), () =>'); const decLock = src.includes(L + 'decisionsJsonPath' + '(root), () =>'); const lesLock = src.includes(L + 'lessonsJsonPath' + '(root), () =>'); return ruleLock && decLock && lesLock; } },
5189
5128
  { name: '8번째 버그헌트 (UR-0111): MCP feature_link safe-write tier(권한경계) + _parseLimit NaN/음수 가드 (1.9.407)', run: () => { const m = require('../lib/pure-utils'); const pl = m._parseLimit('abc', 10) === 10 && m._parseLimit('-5', 10) === 10 && m._parseLimit('0', 10) === 10 && m._parseLimit('3', 10) === 3 && m._parseLimit('7.9', 10) === 7; const tools = require('../lib/mcp-tools'); const arr = Array.isArray(tools) ? tools : (tools.MCP_TOOLS || []); const fl = arr.find(x => x.name === 'leerness_feature_link'); const tierOk = !!fl && fl.requiredTier === 'safe-write'; return pl && tierOk; } },
5190
5129
  { name: '8번째 버그헌트 (UR-0112): _parseSkillMd CRLF/CR 줄바꿈 정규화 (Windows SKILL.md meta 소실 차단) (1.9.408)', run: () => { const m = require('../lib/pure-utils'); const lf = m._parseSkillMd('---\nname: s\ndescription: d\n---\nbody'); const crlf = m._parseSkillMd('---\r\nname: s\r\ndescription: d\r\n---\r\nbody'); const cr = m._parseSkillMd('---\rname: s\rdescription: d\r---\rbody'); const bom = m._parseSkillMd('---\r\nname: s\r\n---\r\nbody'); return lf.meta.name === 's' && crlf.meta.name === 's' && crlf.meta.description === 'd' && cr.meta.name === 's' && bom.meta.name === 's'; } },
5191
- { name: '8번째 버그헌트 (UR-0113): env encoding-check --apply 가 .sh/shebang 에 BOM 미추가(shebang 보존) (1.9.409)', run: () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_bom_')); try { const sh = path.join(tmp, 's.sh'); fs.writeFileSync(sh, '#!/bin/bash\n# 한글\necho hi\n'); const ps = path.join(tmp, 's.ps1'); fs.writeFileSync(ps, '# 한글\nWrite-Host hi\n'); const save = process.argv; let out = ''; const _w = process.stdout.write; try { process.argv = ['node', 'h', 'env', 'encoding-check', '--path', tmp, '--apply', '--json']; process.stdout.write = s => { out += s; return true; }; envCmd(tmp, 'encoding-check'); } catch {} finally { process.stdout.write = _w; process.argv = save; } const shBuf = fs.readFileSync(sh); const psBuf = fs.readFileSync(ps); const shNoBom = !(shBuf[0] === 0xEF && shBuf[1] === 0xBB && shBuf[2] === 0xBF) && shBuf[0] === 0x23 && shBuf[1] === 0x21; const psBom = psBuf[0] === 0xEF && psBuf[1] === 0xBB && psBuf[2] === 0xBF; return shNoBom && psBom; } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } } },
5130
+ { name: '8번째 버그헌트 (UR-0113): env encoding-check --apply 셸/플랫폼/모호 인코딩 안전계약 (1.9.409/T-0022)', run: () => {
5131
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_bom_'));
5132
+ try {
5133
+ const sh = path.join(tmp, 's.sh');
5134
+ const ps = path.join(tmp, 's.ps1');
5135
+ const ambiguous = path.join(tmp, 'ambiguous.ps1');
5136
+ fs.writeFileSync(sh, '#!/bin/bash\n# 한글\necho hi\n');
5137
+ fs.writeFileSync(ps, 'Write-Host "😀"\n');
5138
+ fs.writeFileSync(ambiguous, Buffer.from([0x41, 0xC2, 0xA1, 0x42]));
5139
+ const save = process.argv; let out = ''; const _w = process.stdout.write;
5140
+ try {
5141
+ process.argv = ['node', 'h', 'env', 'encoding-check', '--path', tmp, '--apply', '--json'];
5142
+ process.stdout.write = s => { out += s; return true; };
5143
+ envCmd(tmp, 'encoding-check');
5144
+ } catch {} finally { process.stdout.write = _w; process.argv = save; }
5145
+ const shBuf = fs.readFileSync(sh);
5146
+ const psBuf = fs.readFileSync(ps);
5147
+ const ambiguousBuf = fs.readFileSync(ambiguous);
5148
+ const shNoBom = !shBuf.subarray(0, 3).equals(Buffer.from([0xEF, 0xBB, 0xBF])) && shBuf[0] === 0x23 && shBuf[1] === 0x21;
5149
+ const platformOk = process.platform === 'win32'
5150
+ ? psBuf.subarray(0, 3).equals(Buffer.from([0xEF, 0xBB, 0xBF]))
5151
+ : psBuf.equals(Buffer.from('Write-Host "😀"\n'));
5152
+ return shNoBom && platformOk && ambiguousBuf.equals(Buffer.from([0x41, 0xC2, 0xA1, 0x42]));
5153
+ } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
5154
+ } },
5192
5155
  { name: '8번째 버그헌트 (UR-0114): absRoot 비문자열(--path 값없음 boolean true) → cwd 폴백(raw TypeError 차단) (1.9.410)', run: () => { const io = require('../lib/io'); const cwd = process.cwd(); const tBool = io.absRoot(true) === cwd; const tEmpty = io.absRoot('') === cwd; const tUndef = io.absRoot(undefined) === cwd; const tSpace = io.absRoot(' ') === cwd; const tReal = io.absRoot(os.tmpdir()) === path.resolve(os.tmpdir()); return tBool && tEmpty && tUndef && tSpace && tReal; } },
5193
5156
  { name: '8번째 버그헌트 (UR-0115): lazy detect --auto-track 단일 RMW 배치(O(T×N)→O(N+T)) (1.9.411)', run: () => { const src = read(__filename); const batched = src.includes("8번째 버그헌트, UR-0115") && /has\('--auto-track'\)[\s\S]{0,500}?_withLock\(progressPath\(root\), \(\) => \{[\s\S]{0,1200}?writeProgressRows/.test(src); const noPerTodoUpsert = !/for \(const t of newTodos\) \{\s*const id = nextId\(root, 'T'\);/.test(src); return batched && noPerTodoUpsert; } },
5194
5157
  { name: '6번째 외부평가 Opus P1 (UR-0100): list-family(decision/feature/plan/runs/team list) positional path 지원 (조용한 cwd 오독 차단) (1.9.412)', run: () => { const src = read(__filename); const L = '_resolveRoot('; const decOk = src.includes("decisionListCmd(absRoot(" + L + "args[2]))"); const planOk = src.includes("planListCmd(absRoot(" + L + "args[2]))"); const featOk = src.includes("featureListCmd(absRoot(" + L + "args[2]))"); const runsOk = src.includes("runsListCmd(absRoot(" + L + "args[2]))"); const teamOk = src.includes(L + "args[1] === 'list' ? args[2] : null)"); return decOk && planOk && featOk && runsOk && teamOk; } },
@@ -5381,7 +5344,7 @@ function _selfTestCases() {
5381
5344
  const delegated = src.includes("require('../lib/drift')") && src.includes('_drift.driftCheckCmd(root, opts,');
5382
5345
  const modSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'drift.js'));
5383
5346
  const bodyMarker = 'auto-fix 활성 ' + '(1.9.82)'; // drift 본문 고유(split-literal 자기참조 회피)
5384
- const recursionWired = modSrc.includes('driftCheckCmd(root, { ...opts, _noAutoFix: true }, deps)'); // 재귀에 deps 전달 + depth 가드(1.9.432)
5347
+ const recursionWired = /driftCheckCmd\(root,\s*\{\s*\.\.\.opts,\s*_noAutoFix:\s*true,\s*_encodingFixErrors:\s*encodingFixErrors,\s*_encodingAutoFix:\s*encodingAutoFix,\s*_autoFixSummary:\s*autoFixSummary\s*\},\s*deps\)/.test(modSrc); // 재귀에 deps + 전체 단계 실패/요약 누적 전달 + depth 가드
5385
5348
  const movedToLib = modSrc.includes("require('./io')") && recursionWired && modSrc.includes(bodyMarker) && !src.includes(bodyMarker);
5386
5349
  let behavOk = false;
5387
5350
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_drift_'));
@@ -5498,7 +5461,7 @@ function _selfTestCases() {
5498
5461
  { name: '10th 외부평가 UR-0131 잔여: drift --auto-fix 재귀 depth 가드(_noAutoFix) — 무한재귀 방지 (1.9.432)', run: () => {
5499
5462
  const modSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'drift.js'));
5500
5463
  const guard = modSrc.includes("has('--auto-fix') && !opts._noAutoFix");
5501
- const recurseGuarded = (modSrc.match(/driftCheckCmd\(root, \{ \.\.\.opts, _noAutoFix: true \}, deps\)/g) || []).length === 2;
5464
+ const recurseGuarded = (modSrc.match(/driftCheckCmd\(root,\s*\{\s*\.\.\.opts,\s*_noAutoFix:\s*true,\s*_encodingFixErrors:\s*encodingFixErrors,\s*_encodingAutoFix:\s*encodingAutoFix,\s*_autoFixSummary:\s*autoFixSummary\s*\},\s*deps\)/g) || []).length === 1;
5502
5465
  return guard && recurseGuarded;
5503
5466
  } },
5504
5467
  { name: '11th 외부평가 Codex/Opus P1: contract spec backtick-bullet 함수/필드 강선언 인식 (1.9.433)', run: () => {
@@ -16876,6 +16839,8 @@ function handoff(root) {
16876
16839
  shellScriptsScanned: encScan.scanned,
16877
16840
  encodingRiskCount: encScan.atRisk.length,
16878
16841
  encodingRiskFiles: encScan.atRisk.slice(0, 5).map(r => r.file),
16842
+ encodingScanErrorCount: (encScan.scanErrors || []).length,
16843
+ encodingScanErrors: (encScan.scanErrors || []).slice(0, 5),
16879
16844
  // 1.9.249 (UR-0018): 터미널 출력 인코딩 안전 여부 + 자동 회복 결과
16880
16845
  terminalEncodingOk: runtimeEnv.locale.codepage === 65001 || !runtimeEnv.locale.isKoreanWindows,
16881
16846
  autoChcpApplied: process.env._LEERNESS_AUTOCHCP_APPLIED || null,
@@ -17434,6 +17399,11 @@ function handoff(root) {
17434
17399
  // 한국어 Windows + .ps1/.bat 비-ASCII + BOM 없음 → CP949 오인식 위험
17435
17400
  try {
17436
17401
  const scan = _scanShellScriptsEncoding(root);
17402
+ if (scan.scanErrors && scan.scanErrors.length > 0) {
17403
+ log('');
17404
+ log(`## ⚠ 셸 스크립트 인코딩 스캔 읽기 실패 ${scan.scanErrors.length}건 — 결과 불완전`);
17405
+ scan.scanErrors.slice(0, 3).forEach(e => log(` • ${e.file}: ${e.code}`));
17406
+ }
17437
17407
  if (scan.atRisk && scan.atRisk.length > 0) {
17438
17408
  const isTty = process.stdout && process.stdout.isTTY;
17439
17409
  const rd5 = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;