leerness 1.29.0 → 1.31.0

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/lib/audit.js CHANGED
@@ -8,7 +8,7 @@ const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBu
8
8
  const { SECRET_PATTERNS } = require('./catalogs');
9
9
 
10
10
  function audit(root, opts = {}, deps = {}) {
11
- const { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills } = deps;
11
+ const { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills, _collectSecretFindings } = deps;
12
12
  root = absRoot(root);
13
13
  let warnings = 0, failures = 0;
14
14
  // 1.9.35 개선 #5: --fix 옵션 — 자동 수정 가능한 항목 적용
@@ -192,6 +192,22 @@ function audit(root, opts = {}, deps = {}) {
192
192
  }
193
193
  } catch {}
194
194
  }
195
+ // 1.30.1 (14th 외부리뷰 F1): 커밋된 시크릿(_collectSecretFindings.committed)을 failure 로 승격 — scan secrets 와 일관.
196
+ // 기존엔 .gitignore 패턴/.env 동기화만 검사해 소스에 노출된 실 시크릿(AWS/GitHub 등)을 통과시키고 healthy:true 를 반환하던 정직성 갭
197
+ // (audit 기반 CI 게이트가 노출 시크릿을 통과). gitignored 보관 시크릿은 _collectSecretFindings 가 committed 에서 제외(FP 0). 끄기: --no-secret-scan.
198
+ if (!has('--no-secret-scan') && typeof _collectSecretFindings === 'function') {
199
+ try {
200
+ const { committed } = _collectSecretFindings(root);
201
+ if (committed && committed.length) {
202
+ failures++;
203
+ fail(`커밋된 시크릿 ${committed.length}건 발견 (소스 노출) — leerness scan secrets 로 상세 확인`);
204
+ committed.slice(0, 4).forEach(f => log(` ${f.file}:${f.line} ${f.name}`));
205
+ _finding('committed_secret', 'fail', '커밋된 시크릿 발견 (소스 노출)', { count: committed.length, sample: committed.slice(0, 10).map(f => ({ file: f.file, line: f.line, name: f.name })) });
206
+ } else {
207
+ ok('커밋된 시크릿 없음 (소스 스캔, 1.30.1)');
208
+ }
209
+ } catch {}
210
+ }
195
211
  // 1.9.71: .env / .env.example 동기화 감사 (--no-env-check로 끄기)
196
212
  if (!has('--no-env-check')) {
197
213
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.29.0",
3
+ "version": "1.31.0",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -830,13 +830,14 @@ total++;
830
830
  {
831
831
  // agents dispatch — 활성 미충족 시 거부
832
832
  const env = { ...process.env, LEERNESS_ENABLE_CODEX: '0' };
833
- const r = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', 'test task', '--to', 'codex'], { encoding: 'utf8', timeout: 10000, env });
833
+ // 1.30.2: timeout 10s→30s flake 하드닝(1.9.375 계열) 전체 e2e 부하(수백 spawn) 하에서 짧은 타임아웃이 간헐 빈-stdout→오판.
834
+ const r = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', 'test task', '--to', 'codex'], { encoding: 'utf8', timeout: 30000, env });
834
835
  const okBlocked = r.status !== 0 && /비활성|disabled|not-installed/i.test(r.stdout);
835
836
  // --to 누락 거부
836
- const r2 = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', 'test'], { encoding: 'utf8', timeout: 10000 });
837
+ const r2 = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', 'test'], { encoding: 'utf8', timeout: 30000 });
837
838
  const okNoTarget = r2.status !== 0 && /--to.*필요/.test(r2.stdout + r2.stderr);
838
839
  // 알 수 없는 agent 거부
839
- const r3 = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', 'test', '--to', 'jedi'], { encoding: 'utf8', timeout: 10000 });
840
+ const r3 = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', 'test', '--to', 'jedi'], { encoding: 'utf8', timeout: 30000 });
840
841
  const okBadAgent = r3.status !== 0 && /알 수 없는 agent/.test(r3.stdout + r3.stderr);
841
842
  const ok = okBlocked && okNoTarget && okBadAgent;
842
843
  console.log(ok ? '✓ B(1.9.30) agents dispatch: env=0/--to 누락/잘못된 agent 모두 거부' : `✗ dispatch 실패 (block=${okBlocked} noT=${okNoTarget} bad=${okBadAgent})`);
@@ -882,7 +883,7 @@ total++;
882
883
  total++;
883
884
  {
884
885
  // --version --banner: LEERNESS ASCII + 신규 슬로건 (1.9.144+ "AI 에이전트 검수·기억·드리프트 방지 하네스")
885
- const r = cp.spawnSync(process.execPath, [CLI, '--version', '--banner'], { encoding: 'utf8', timeout: 10000, env: { ...process.env, TERM: 'dumb' } });
886
+ const r = cp.spawnSync(process.execPath, [CLI, '--version', '--banner'], { encoding: 'utf8', timeout: 30000, env: { ...process.env, TERM: 'dumb' } });
886
887
  const ok = r.status === 0
887
888
  && /╔═+╗/.test(r.stdout)
888
889
  && /███████╗/.test(r.stdout)
@@ -6041,6 +6042,152 @@ total++;
6041
6042
  if (!ok) failed++;
6042
6043
  }
6043
6044
 
6045
+ // 1.30.1 회귀 (14th 외부리뷰 F1+F2): audit/handoff 보안요약이 커밋된 시크릿을 정직하게 노출.
6046
+ // F1: audit 가 _collectSecretFindings 콘텐츠 스캔을 돌려 committed 시크릿을 failure 로 승격(scan secrets 와 일관) — gitignored 는 무영향(FP 0).
6047
+ // F2: handoff 🔒 보안요약 섹션이 .env 없어도 committed 시크릿을 노출(envExists 단독 게이팅 제거).
6048
+ total++;
6049
+ {
6050
+ let ok = false;
6051
+ try {
6052
+ const H = /[가-힣]/;
6053
+ // (F1) un-gitignored .env + 실 시크릿 → audit healthy:false exit1
6054
+ const d1 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-f1bad-'));
6055
+ cp.spawnSync(process.execPath, [CLI, 'init', d1, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6056
+ fs.writeFileSync(path.join(d1, '.gitignore'), 'node_modules/\n');
6057
+ fs.writeFileSync(path.join(d1, '.env'), 'AWS=AKIAJQXMP7RZ2KL9WXYZ\nGH=ghp_aZ9bY8cX7dW6eV5fU4gT3hS2iR1jQ0kP9oN8\n');
6058
+ const a1 = cp.spawnSync(process.execPath, [CLI, 'audit', d1, '--json'], { encoding: 'utf8', timeout: 20000 });
6059
+ let f1bad = false; try { const j = JSON.parse(a1.stdout); f1bad = j.healthy === false && a1.status === 1 && j.findings.some(x => x.kind === 'committed_secret'); } catch {}
6060
+ // (F1-noFP) gitignored .env + 시크릿 → audit healthy:true (no false-positive)
6061
+ const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-f1ok-'));
6062
+ cp.spawnSync(process.execPath, [CLI, 'init', d2, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6063
+ fs.writeFileSync(path.join(d2, '.gitignore'), '.env\n.env.local\n.env.production\n.env.*.local\n*.pem\ncredentials.json\nnode_modules/\n');
6064
+ fs.writeFileSync(path.join(d2, '.env'), 'AWS=AKIAJQXMP7RZ2KL9WXYZ\n');
6065
+ const a2 = cp.spawnSync(process.execPath, [CLI, 'audit', d2, '--json'], { encoding: 'utf8', timeout: 20000 });
6066
+ let f1ok = false; try { const j = JSON.parse(a2.stdout); f1ok = j.healthy === true && a2.status === 0; } catch {}
6067
+ // (F2) committed secret in config.js, NO .env → handoff 보안요약 섹션이 노출(ko) + en 영어(섹션 한글 0)
6068
+ const d3 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-f2-'));
6069
+ cp.spawnSync(process.execPath, [CLI, 'init', d3, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6070
+ fs.writeFileSync(path.join(d3, '.gitignore'), 'node_modules/\n');
6071
+ fs.writeFileSync(path.join(d3, 'config.js'), 'const k="AKIAJQXMP7RZ2KL9WXYZ";\nconst g="ghp_aZ9bY8cX7dW6eV5fU4gT3hS2iR1jQ0kP9oN8";\n');
6072
+ const hoKo = (cp.spawnSync(process.execPath, [CLI, 'handoff', d3], { encoding: 'utf8', timeout: 25000 }).stdout) || '';
6073
+ const f2ko = /🔒\s*보안 요약/.test(hoKo) && /커밋된 시크릿/.test(hoKo) && /config\.js/.test(hoKo);
6074
+ const d4 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-f2en-'));
6075
+ cp.spawnSync(process.execPath, [CLI, 'init', d4, '--yes', '--language', 'en'], { encoding: 'utf8', timeout: 30000 });
6076
+ fs.writeFileSync(path.join(d4, '.gitignore'), 'node_modules/\n');
6077
+ fs.writeFileSync(path.join(d4, 'config.js'), 'const k="AKIAJQXMP7RZ2KL9WXYZ";\n');
6078
+ const hoEn = (cp.spawnSync(process.execPath, [CLI, 'handoff', d4], { encoding: 'utf8', timeout: 25000 }).stdout) || '';
6079
+ const enSecLines = hoEn.split('\n').filter(l => /Security summary|committed secret/i.test(l));
6080
+ const f2en = /Security summary/.test(hoEn) && /committed secret/i.test(hoEn) && enSecLines.length >= 1 && !enSecLines.some(l => H.test(l));
6081
+ [d1, d2, d3, d4].forEach(d => { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} });
6082
+ ok = f1bad && f1ok && f2ko && f2en;
6083
+ } catch {}
6084
+ console.log(ok ? '✓ B(1.30.1) 14th외부리뷰 F1+F2: audit committed-secret→failure(scan 일관, gitignored FP0) + handoff 보안요약이 committed 시크릿 노출(ko/en)' : '✗ 보안 정직성 F1+F2 가드 실패');
6085
+ if (!ok) failed++;
6086
+ }
6087
+
6088
+ // 1.30.2 회귀 (#157 사용자명시, 하위 프로젝트 방향 — 외부AI+Claude 교차검토 → 방향 C): parent detect 가 상위 leerness 부모를 탐지(read-only) + handoff 헤드라인 노출 + 자동 적용 안 함.
6089
+ total++;
6090
+ {
6091
+ let ok = false;
6092
+ try {
6093
+ const par = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-par-'));
6094
+ cp.spawnSync(process.execPath, [CLI, 'init', par, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6095
+ const sub = path.join(par, 'sub');
6096
+ cp.spawnSync(process.execPath, [CLI, 'init', sub, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6097
+ // (1) parent detect --json from sub → parent detected, applied:false, assetCount≥1
6098
+ const pj = cp.spawnSync(process.execPath, [CLI, 'parent', 'detect', '--path', sub, '--json'], { encoding: 'utf8', timeout: 15000 });
6099
+ let detectOk = false; try { const j = JSON.parse(pj.stdout); detectOk = j.applied === false && j.parent && j.parent.workspaceDir === '.harness' && j.parent.assetCount >= 1; } catch {}
6100
+ // (2) parent detect from standalone → null
6101
+ const alone = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-alone-'));
6102
+ cp.spawnSync(process.execPath, [CLI, 'init', alone, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6103
+ const aj = cp.spawnSync(process.execPath, [CLI, 'parent', 'detect', '--path', alone, '--json'], { encoding: 'utf8', timeout: 15000 });
6104
+ let aloneOk = false; try { const j = JSON.parse(aj.stdout); aloneOk = j.parent === null; } catch {}
6105
+ // (3) handoff headline from sub shows 🔗 부모 프로젝트 (미적용); en shows "not applied"
6106
+ const hoKo = (cp.spawnSync(process.execPath, [CLI, 'handoff', '--path', sub], { encoding: 'utf8', timeout: 25000 }).stdout) || '';
6107
+ const hoEn = (cp.spawnSync(process.execPath, [CLI, 'handoff', '--path', sub, '--language', 'en'], { encoding: 'utf8', timeout: 25000 }).stdout) || '';
6108
+ const headlineOk = /🔗 부모 프로젝트.*미적용/.test(hoKo) && /🔗 parent project.*not applied/.test(hoEn);
6109
+ // (4) read-only: parent detect 가 sub 에 아무 파일도 쓰지 않음(adopt 미구현)
6110
+ const before = fs.readdirSync(sub).sort().join(',');
6111
+ cp.spawnSync(process.execPath, [CLI, 'parent', 'detect', '--path', sub], { encoding: 'utf8', timeout: 15000 });
6112
+ const after = fs.readdirSync(sub).sort().join(',');
6113
+ const readOnlyOk = before === after;
6114
+ [par, alone].forEach(d => { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} });
6115
+ ok = detectOk && aloneOk && headlineOk && readOnlyOk;
6116
+ } catch {}
6117
+ console.log(ok ? '✓ B(1.30.2) #157 하위프로젝트: parent detect(상위 leerness 탐지·--json applied:false) + 독립 null + handoff 헤드라인 🔗(ko/en, 미적용) + read-only' : '✗ parent detect 가드 실패');
6118
+ if (!ok) failed++;
6119
+ }
6120
+
6121
+ // 1.30.3 회귀 (#158 사용자명시): parent adopt 게이트형 적용 — dry-run 기본(쓰기 0) + --apply(사용자 명시) 시에만 자식-로컬 참조 기록 + 자식 design-system.md 무변경(비파괴) + handoff 헤드라인 adopted 반영.
6122
+ total++;
6123
+ {
6124
+ let ok = false;
6125
+ try {
6126
+ const par = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-adopt-'));
6127
+ cp.spawnSync(process.execPath, [CLI, 'init', par, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6128
+ const sub = path.join(par, 'sub');
6129
+ cp.spawnSync(process.execPath, [CLI, 'init', sub, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6130
+ const childDs = path.join(sub, '.harness', 'design-system.md');
6131
+ const childDsBefore = fs.readFileSync(childDs, 'utf8');
6132
+ const inherited = path.join(sub, '.harness', 'inherited-from-parent.md');
6133
+ const link = path.join(sub, '.harness', 'PARENT_LINK.json');
6134
+ // (1) DRY-RUN: 쓰기 0
6135
+ cp.spawnSync(process.execPath, [CLI, 'parent', 'adopt', '--path', sub], { encoding: 'utf8', timeout: 15000 });
6136
+ const dryNoWrite = !fs.existsSync(inherited) && !fs.existsSync(link);
6137
+ // (2) --apply: 참조파일+마커 기록, 자식 design-system.md 무변경
6138
+ cp.spawnSync(process.execPath, [CLI, 'parent', 'adopt', '--apply', '--path', sub], { encoding: 'utf8', timeout: 15000 });
6139
+ const wrote = fs.existsSync(inherited) && fs.existsSync(link);
6140
+ const childUnchanged = fs.readFileSync(childDs, 'utf8') === childDsBefore;
6141
+ let linkOk = false; try { const j = JSON.parse(fs.readFileSync(link, 'utf8')); linkOk = !!j.parentRoot && Array.isArray(j.adoptedKinds) && j.adoptedKinds.length >= 1; } catch {}
6142
+ // (3) handoff 헤드라인 adopted 반영(ko/en)
6143
+ const hoKo = (cp.spawnSync(process.execPath, [CLI, 'handoff', '--path', sub], { encoding: 'utf8', timeout: 25000 }).stdout) || '';
6144
+ const hoEn = (cp.spawnSync(process.execPath, [CLI, 'handoff', '--path', sub, '--language', 'en'], { encoding: 'utf8', timeout: 25000 }).stdout) || '';
6145
+ const headlineOk = /🔗 부모 프로젝트.*adopted/.test(hoKo) && /🔗 parent project.*adopted/.test(hoEn);
6146
+ // (4) --json applied:true on apply
6147
+ const aj = cp.spawnSync(process.execPath, [CLI, 'parent', 'adopt', '--apply', '--json', '--path', sub], { encoding: 'utf8', timeout: 15000 });
6148
+ let jsonOk = false; try { const j = JSON.parse(aj.stdout); jsonOk = j.applied === true && typeof j.inheritedPath === 'string'; } catch {}
6149
+ fs.rmSync(par, { recursive: true, force: true });
6150
+ ok = dryNoWrite && wrote && childUnchanged && linkOk && headlineOk && jsonOk;
6151
+ } catch {}
6152
+ console.log(ok ? '✓ B(1.30.3) #158 parent adopt: dry-run 쓰기0 + --apply 참조파일/마커 + 자식 design-system 무변경(비파괴) + handoff adopted(ko/en) + --json applied:true' : '✗ parent adopt 가드 실패');
6153
+ if (!ok) failed++;
6154
+ }
6155
+
6156
+ // 1.30.4 회귀 (#155 / 14th리뷰 F5+F6+F7): add류 cli-ux 일관성 — decision/lesson dedup + rule/lesson 빈입력 --json 구조화 + task/rule bogus subcommand 토큰 명시.
6157
+ total++;
6158
+ {
6159
+ let ok = false;
6160
+ try {
6161
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-f567-'));
6162
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6163
+ const run = (args) => cp.spawnSync(process.execPath, [CLI, ...args, '--path', d], { encoding: 'utf8', timeout: 15000 });
6164
+ const isJson = (s) => { try { JSON.parse((s||'').trim()); return true; } catch { return false; } };
6165
+ // F5 dedup: decision/lesson 동일 입력 2회 → 1 copy, --force → 2
6166
+ run(['decision', 'add', 'dupdec']); run(['decision', 'add', 'dupdec']);
6167
+ const decCount = ((run(['decision', 'list']).stdout || '').match(/dupdec/g) || []).length;
6168
+ run(['lesson', 'save', 'duples']); run(['lesson', 'save', 'duples']);
6169
+ const lesCount = ((run(['lesson', 'list']).stdout || '').match(/duples/g) || []).length;
6170
+ run(['decision', 'add', 'dupdec', '--force']);
6171
+ const decForce = ((run(['decision', 'list']).stdout || '').match(/dupdec/g) || []).length;
6172
+ const f5 = decCount === 1 && lesCount === 1 && decForce === 2;
6173
+ // F6 빈입력 --json 구조화 + exit1 (성공경로도 JSON 유지)
6174
+ const ra = run(['rule', 'add', '', '--json']); const ls = run(['lesson', 'save', '', '--json']);
6175
+ const raOk = run(['rule', 'add', '룰F6', '--json']); const lsOk = run(['lesson', 'save', '레슨F6', '--json']);
6176
+ const f6 = isJson(ra.stdout) && /empty_title/.test(ra.stdout) && ra.status === 1
6177
+ && isJson(ls.stdout) && /empty_text/.test(ls.stdout) && ls.status === 1
6178
+ && isJson(raOk.stdout) && isJson(lsOk.stdout);
6179
+ // F7 bogus subcommand → 잘못된 토큰 명시 + exit1 (유효 하위명령 무회귀)
6180
+ const tf = run(['task', 'frobnicate']); const rf = run(['rule', 'frobnicate']);
6181
+ const f7 = /task 하위명령: frobnicate/.test(tf.stdout + tf.stderr) && tf.status === 1
6182
+ && /rule 하위명령: frobnicate/.test(rf.stdout + rf.stderr) && rf.status === 1
6183
+ && run(['task', 'list']).status === 0 && run(['rule', 'list']).status === 0;
6184
+ fs.rmSync(d, { recursive: true, force: true });
6185
+ ok = f5 && f6 && f7;
6186
+ } catch {}
6187
+ console.log(ok ? '✓ B(1.30.4) #155 cli-ux 일관성: decision/lesson dedup(--force 우회) + rule/lesson 빈입력 --json 구조화(exit1) + task/rule bogus subcommand 토큰 명시' : '✗ cli-ux 일관성 F5+F6+F7 가드 실패');
6188
+ if (!ok) failed++;
6189
+ }
6190
+
6044
6191
  // 1.9.430 (10th 외부평가 UR-0130): health 보안 CRITICAL(커밋 시크릿)은 --strict 없이도 exit 1(CI 게이트). 클린은 exit 0.
6045
6192
  total++;
6046
6193
  {
@@ -6239,9 +6386,80 @@ total++;
6239
6386
  const docKo = out(cp.spawnSync(process.execPath, [CLI, 'doctor'], { encoding: 'utf8', timeout: 20000, cwd: d }));
6240
6387
  const doctorOk = /install\/environment diagnosis/.test(docEn) && !H.test(docEn) && /설치\/환경 진단/.test(docKo);
6241
6388
  fs.rmSync(d, { recursive: true, force: true });
6242
- ok = lensKoOk && lensEnOk && noLeak && stOk && healthOk && driftOk && doctorOk;
6243
- } catch {}
6244
- console.log(ok ? '✓ B(1.25.1/1.25.2/1.27.2/1.28.2) i18n 행위: --language en 런타임 영어(lens/health/drift/doctor) + ko 기본 보존 + --language positional 무누출 + status 에러 en/ko (UR-0010)' : '✗ i18n 행위 회귀 가드 실패');
6389
+ // (1.29.1) handoff 보안 요약 섹션: .env + 미흡한 .gitignore en 영어(섹션 라인 한글 0) + ko 기본 한글.
6390
+ // 소스가드만으로는 잡는 회귀를 e2e 로 보강: 보안 요약 블록은 headline 의 t() 스코프 밖이라,
6391
+ // 로컬 t() 누락 ReferenceError try 삼켜져 섹션 전체가 (양 언어 모두) 사라진다. 가드가 그걸 잡는다.
6392
+ const dh = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-i18n-ho-'));
6393
+ cp.spawnSync(process.execPath, [CLI, 'init', dh, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6394
+ fs.writeFileSync(path.join(dh, '.env'), 'API_KEY=sk-test-abc123def456ghi789jkl012mno345\n');
6395
+ fs.writeFileSync(path.join(dh, '.gitignore'), 'node_modules/\n');
6396
+ const hoEn = out(cp.spawnSync(process.execPath, [CLI, 'handoff', dh, '--language', 'en'], { encoding: 'utf8', timeout: 25000 }));
6397
+ const hoKo = out(cp.spawnSync(process.execPath, [CLI, 'handoff', dh], { encoding: 'utf8', timeout: 25000 }));
6398
+ const enSecLines = hoEn.split('\n').filter(l => /Security summary|auto-fix:|CRITICAL|auto-fix option|recover|missing secret/i.test(l));
6399
+ const hoEnOk = /Security summary/.test(hoEn) && enSecLines.length >= 2 && !enSecLines.some(l => H.test(l));
6400
+ const hoKoOk = /보안 요약/.test(hoKo);
6401
+ fs.rmSync(dh, { recursive: true, force: true });
6402
+ // ⑨ (1.29.2) handoff env-detect 블록: 환경 스냅샷 변동 시 → en 영어(블록 라인 한글 0) + ko 기본 한글.
6403
+ // 블록은 첫 핸드오프 후 .harness/environment.json 변동이 있어야 발동 → 스냅샷의 node.version 을 인위 변경해 강제.
6404
+ // (1.29.1 과 같은 블록-스코프 t 함정 가드 — env-detect 도 headline t() 스코프 밖이라 로컬 t() 필요.)
6405
+ const de = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-i18n-env-'));
6406
+ cp.spawnSync(process.execPath, [CLI, 'init', de, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6407
+ const snap = path.join(de, '.harness', 'environment.json');
6408
+ const forceEnvChange = () => { try { const s = JSON.parse(fs.readFileSync(snap, 'utf8')); if (s.node) s.node.version = 'v0.0.0-test'; fs.writeFileSync(snap, JSON.stringify(s, null, 2) + '\n'); } catch {} };
6409
+ cp.spawnSync(process.execPath, [CLI, 'handoff', de], { encoding: 'utf8', timeout: 25000 }); // 첫 캡처(silent)
6410
+ forceEnvChange();
6411
+ const edEn = out(cp.spawnSync(process.execPath, [CLI, 'handoff', de, '--language', 'en'], { encoding: 'utf8', timeout: 25000 }));
6412
+ forceEnvChange(); // en 실행이 스냅샷 갱신 → ko 위해 재변경
6413
+ const edKo = out(cp.spawnSync(process.execPath, [CLI, 'handoff', de], { encoding: 'utf8', timeout: 25000 }));
6414
+ const edEnLines = edEn.split('\n').filter(l => /Runtime environment|env detect|change\(s\) detected/i.test(l));
6415
+ const edEnOk = /Runtime environment/.test(edEn) && edEnLines.length >= 1 && !edEnLines.some(l => H.test(l));
6416
+ const edKoOk = /실행 환경/.test(edKo);
6417
+ fs.rmSync(de, { recursive: true, force: true });
6418
+ // ⑩ (1.29.3) handoff shell-guard 블록: 셸 실패 기록 + 환경 스냅샷 변동 → en 영어(블록 라인 한글 0) + ko 기본 한글.
6419
+ // 블록은 hasFailures(.harness/shell-failures.json) 또는 hasDrift(스냅샷 변동) 시 발동. 둘 다 인위 구성.
6420
+ const ds = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-i18n-sh-'));
6421
+ cp.spawnSync(process.execPath, [CLI, 'init', ds, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6422
+ const ssnap = path.join(ds, '.harness', 'environment.json');
6423
+ const sfail = path.join(ds, '.harness', 'shell-failures.json');
6424
+ const seedSh = () => {
6425
+ try { const s = JSON.parse(fs.readFileSync(ssnap, 'utf8')); if (s.node) s.node.version = 'v0.0.0-test'; fs.writeFileSync(ssnap, JSON.stringify(s, null, 2) + '\n'); } catch {}
6426
+ fs.writeFileSync(sfail, JSON.stringify({ failures: [{ cmd: 'ls && pwd', exitCode: 1, shell: 'powershell-5.1', issues: ['ps5-chain'] }] }, null, 2) + '\n');
6427
+ };
6428
+ cp.spawnSync(process.execPath, [CLI, 'handoff', ds], { encoding: 'utf8', timeout: 25000 }); // 첫 캡처(silent)
6429
+ seedSh();
6430
+ const shEn = out(cp.spawnSync(process.execPath, [CLI, 'handoff', ds, '--language', 'en'], { encoding: 'utf8', timeout: 25000 }));
6431
+ seedSh(); // en 실행이 스냅샷 갱신 → ko 위해 재구성
6432
+ const shKo = out(cp.spawnSync(process.execPath, [CLI, 'handoff', ds], { encoding: 'utf8', timeout: 25000 }));
6433
+ const shEnLines = shEn.split('\n').filter(l => /shell guard|shell failure|review past shell|shell-guard|check before running/i.test(l));
6434
+ const shEnOk = /Terminal shell guard/.test(shEn) && shEnLines.length >= 2 && !shEnLines.some(l => H.test(l));
6435
+ const shKoOk = /셸 가드/.test(shKo);
6436
+ fs.rmSync(ds, { recursive: true, force: true });
6437
+ // ⑪ (1.29.4) handoff CLI 에이전트 슬래시 블록: 외부 에이전트 env flag 활성 시 → en 영어(블록 라인 한글 0) + ko 기본 한글.
6438
+ const da = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-i18n-ag-'));
6439
+ cp.spawnSync(process.execPath, [CLI, 'init', da, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6440
+ const agEnv = { ...process.env, LEERNESS_ENABLE_CODEX: '1', LEERNESS_ENABLE_CLAUDE: '1' };
6441
+ const agEn = out(cp.spawnSync(process.execPath, [CLI, 'handoff', da, '--language', 'en'], { encoding: 'utf8', timeout: 25000, env: agEnv }));
6442
+ const agKo = out(cp.spawnSync(process.execPath, [CLI, 'handoff', da], { encoding: 'utf8', timeout: 25000, env: agEnv }));
6443
+ const agEnLines = agEn.split('\n').filter(l => /agent slash|active agent|slash-commands|full list/i.test(l));
6444
+ const agEnOk = /CLI agent slash commands/.test(agEn) && agEnLines.length >= 2 && !agEnLines.some(l => H.test(l));
6445
+ const agKoOk = /에이전트 슬래시/.test(agKo);
6446
+ fs.rmSync(da, { recursive: true, force: true });
6447
+ // ⑫ (1.30.5 #156 F3+F4) handoff 본문 워크플로 가이드 + 메모리 변동 en 영어(섹션 한글 0) + ko 보존 · verify-claim/optimism-check 미입력 에러 en/ko.
6448
+ const df3 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-i18n-f3-'));
6449
+ cp.spawnSync(process.execPath, [CLI, 'init', df3, '--yes', '--language', 'en'], { encoding: 'utf8', timeout: 30000 });
6450
+ const hf3En = out(cp.spawnSync(process.execPath, [CLI, 'handoff', df3], { encoding: 'utf8', timeout: 25000 }));
6451
+ const wfLines = hf3En.split('\n').filter(l => /Session workflow|Analyze request|sub-agent work|to disable:/.test(l));
6452
+ const f3En = /Session workflow/.test(hf3En) && wfLines.length >= 3 && !wfLines.some(l => H.test(l));
6453
+ const df3ko = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-i18n-f3k-'));
6454
+ cp.spawnSync(process.execPath, [CLI, 'init', df3ko, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6455
+ const f3Ko = /세션 워크플로 6단계/.test(out(cp.spawnSync(process.execPath, [CLI, 'handoff', df3ko], { encoding: 'utf8', timeout: 25000 })));
6456
+ const vcEn = out(cp.spawnSync(process.execPath, [CLI, 'verify-claim', '--path', df3, '--language', 'en'], { encoding: 'utf8', timeout: 15000 }));
6457
+ const vcKo = out(cp.spawnSync(process.execPath, [CLI, 'verify-claim', '--path', df3ko], { encoding: 'utf8', timeout: 15000 }));
6458
+ const f4 = /required\. ex:/.test(vcEn) && !H.test(vcEn) && /필요\. 예:/.test(vcKo);
6459
+ [df3, df3ko].forEach(d => { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} });
6460
+ ok = lensKoOk && lensEnOk && noLeak && stOk && healthOk && driftOk && doctorOk && hoEnOk && hoKoOk && edEnOk && edKoOk && shEnOk && shKoOk && agEnOk && agKoOk && f3En && f3Ko && f4;
6461
+ } catch {}
6462
+ console.log(ok ? '✓ B(1.25.1/1.25.2/1.27.2/1.28.2/1.29.1/1.29.2/1.29.3/1.29.4/1.30.5) i18n 행위: --language en 런타임 영어(lens/health/drift/doctor/handoff보안요약/env-detect/shell-guard/agent-slash/워크플로가이드/verify-claim) + ko 기본 보존 + --language positional 무누출 + status 에러 en/ko (UR-0010)' : '✗ i18n 행위 회귀 가드 실패');
6245
6463
  if (!ok) failed++;
6246
6464
  }
6247
6465