leerness 1.9.32 → 1.9.34

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/bin/harness.js CHANGED
@@ -6,7 +6,7 @@ const path = require('path');
6
6
  const cp = require('child_process');
7
7
  const readline = require('readline');
8
8
 
9
- const VERSION = '1.9.32';
9
+ const VERSION = '1.9.34';
10
10
  const MARK = '<!-- leerness:managed -->';
11
11
  const README_START = '<!-- leerness:project-readme:start -->';
12
12
  const README_END = '<!-- leerness:project-readme:end -->';
@@ -363,23 +363,51 @@ async function resolveInstallOptions(root, opts = {}) {
363
363
  let lang = explicitLang ? detectLanguageValue(root, explicitLang) : detectLanguageValue(root, 'auto');
364
364
  let skills = explicitSkills ? parseSkillsValue(explicitSkills) : [];
365
365
  const shouldAsk = !has('--yes') && !opts.nonInteractive && process.stdin.isTTY && process.stdout.isTTY && !opts.migration;
366
+ // 1.9.34: 인터랙티브 multi-select (방향키 + Space + Enter) — 기존 숫자 선택 폴백 유지
367
+ // --no-interactive-select 또는 LEERNESS_NO_INTERACTIVE=1 → 구식 숫자 선택
368
+ const useInteractive = shouldAsk && !has('--no-interactive-select') && process.env.LEERNESS_NO_INTERACTIVE !== '1';
366
369
  if (shouldAsk && !explicitLang) {
367
- log('\n설치 언어를 선택하세요.');
368
- log('1) 자동 감지'); log('2) 한국어'); log('3) English');
369
- const a = await ask('선택 [1]: ');
370
- lang = a === '2' ? 'ko' : a === '3' ? 'en' : detectLanguageValue(root, 'auto');
370
+ if (useInteractive) {
371
+ const langOpt = await _selectOne('설치 언어를 선택하세요', [
372
+ { label: '자동 감지', description: '디렉토리/파일 분석 (한국어/영어 자동 판별)', id: 'auto' },
373
+ { label: '한국어', description: '모든 인스트럭션을 한국어로 생성', id: 'ko' },
374
+ { label: 'English', description: '모든 인스트럭션을 영어로 생성', id: 'en' }
375
+ ], { defaultIndex: 0 });
376
+ lang = langOpt && langOpt.id ? detectLanguageValue(root, langOpt.id) : detectLanguageValue(root, 'auto');
377
+ } else {
378
+ log('\n설치 언어를 선택하세요.');
379
+ log('1) 자동 감지'); log('2) 한국어'); log('3) English');
380
+ const a = await ask('선택 [1]: ');
381
+ lang = a === '2' ? 'ko' : a === '3' ? 'en' : detectLanguageValue(root, 'auto');
382
+ }
371
383
  }
372
384
  if (shouldAsk && !explicitSkills) {
373
- log('\n설치할 스킬 라이브러리를 선택하세요.');
374
- log('0) 기본 하네스만 설치');
375
- log('1) 추천: office, commerce-api, ai-verified-skill-publisher, feature-implementation');
376
- log('2) 전체 스킬 설치'); log('3) 직접 입력');
377
- skillList();
378
- const a = await ask('선택 [1]: ');
379
- if (!a || a === '1') skills = parseSkillsValue('recommended');
380
- else if (a === '2') skills = parseSkillsValue('all');
381
- else if (a === '3') skills = parseSkillsValue(await ask('스킬 ID를 쉼표로 입력: '));
382
- else if (a === '0') skills = [];
385
+ if (useInteractive) {
386
+ // 카탈로그에서 옵션 생성
387
+ const cat = Object.entries(skillCatalog).map(([id, meta]) => ({
388
+ id, label: id, description: (meta.displayNameKo || id).slice(0, 50)
389
+ }));
390
+ // 추천 4개의 인덱스 계산
391
+ const recommended = ['office', 'commerce-api', 'ai-verified-skill-publisher', 'feature-implementation'];
392
+ const defaults = recommended.map(id => cat.findIndex(c => c.id === id)).filter(i => i >= 0);
393
+ const picked = await _selectMany(
394
+ '설치할 스킬 라이브러리 (Space=토글, a=전체, n=해제, Enter=확정)',
395
+ cat,
396
+ { defaults }
397
+ );
398
+ skills = picked.map(p => p.id);
399
+ } else {
400
+ log('\n설치할 스킬 라이브러리를 선택하세요.');
401
+ log('0) 기본 하네스만 설치');
402
+ log('1) 추천: office, commerce-api, ai-verified-skill-publisher, feature-implementation');
403
+ log('2) 전체 스킬 설치'); log('3) 직접 입력');
404
+ skillList();
405
+ const a = await ask('선택 [1]: ');
406
+ if (!a || a === '1') skills = parseSkillsValue('recommended');
407
+ else if (a === '2') skills = parseSkillsValue('all');
408
+ else if (a === '3') skills = parseSkillsValue(await ask('스킬 ID를 쉼표로 입력: '));
409
+ else if (a === '0') skills = [];
410
+ }
383
411
  }
384
412
  return { lang, skills };
385
413
  }
@@ -388,6 +416,10 @@ async function install(root, opts = {}) {
388
416
  root = absRoot(root); mkdirp(root);
389
417
  // 1.9.32: init 시 ASCII 배너 + 빠른 시작 가이드 (migrate는 quiet)
390
418
  if (!opts.migration && !has('--no-banner')) _banner({ quickStart: !opts.dry });
419
+ // 1.9.33: npx 캐시로 옛 버전이 실행될 때 경고 (migrate/--no-stale-check 시 스킵)
420
+ if (!opts.migration && !has('--no-stale-check') && !opts.nonInteractive) {
421
+ try { await _warnIfStale(root); } catch {}
422
+ }
391
423
  const resolved = await resolveInstallOptions(root, opts);
392
424
  const lang = resolved.lang;
393
425
  const skills = resolved.skills;
@@ -2320,49 +2352,112 @@ function _checkAgent(agent, opts = {}) {
2320
2352
  };
2321
2353
  }
2322
2354
 
2323
- // 1.9.32: ASCII 배너init/version출력
2355
+ // 1.9.33: npx 캐시 함정 방지 install 진입 npm latest와 비교, stale이면 경고
2356
+ async function _warnIfStale(root, opts = {}) {
2357
+ if (process.env.LEERNESS_NO_STALE_CHECK === '1') return null;
2358
+ const offline = process.env.LEERNESS_OFFLINE === '1';
2359
+ // 24h 캐시: .harness/cache/update-check.json 재사용 — 캐시 fresh면 OFFLINE이어도 비교는 수행
2360
+ try {
2361
+ let latest = null;
2362
+ const cached = readUpdateCache(root);
2363
+ if (cacheFresh(cached, 24) && cached.nextLeerness) {
2364
+ latest = cached.nextLeerness;
2365
+ } else if (!offline) {
2366
+ // 캐시 없음 + 온라인 → npm view 호출 (timeout 8초 — 네트워크 끊겼어도 init 진행 차단 X)
2367
+ latest = await Promise.race([
2368
+ fetchNpmLatest('leerness'),
2369
+ new Promise(resolve => setTimeout(() => resolve(null), 8000))
2370
+ ]);
2371
+ if (latest) {
2372
+ try { writeUpdateCache(root, { nextLeerness: latest, runningCli: VERSION }); } catch {}
2373
+ }
2374
+ }
2375
+ // offline + 캐시 없으면 비교 스킵 (네트워크 차단 환경)
2376
+ if (!latest) return null;
2377
+ if (compareVer(latest, VERSION) > 0) {
2378
+ // 옛 버전이 실행 중. ANSI 노란/빨강.
2379
+ const isTty = process.stdout && process.stdout.isTTY;
2380
+ const C = isTty ? { y: s => `\x1b[33m${s}\x1b[0m`, r: s => `\x1b[31m${s}\x1b[0m`, b: s => `\x1b[1m${s}\x1b[0m`, d: s => `\x1b[2m${s}\x1b[0m` }
2381
+ : { y: s => s, r: s => s, b: s => s, d: s => s };
2382
+ log('');
2383
+ log(C.y(' ⚠ ') + C.b(C.r(`옛 버전이 실행 중입니다 — v${VERSION} → v${latest} (npm 최신)`)));
2384
+ log('');
2385
+ log(C.d(' npm registry latest: ') + C.b(`v${latest}`));
2386
+ log(C.d(' 이 CLI가 실행한 버전: ') + C.b(`v${VERSION}`) + C.d(' (npx 캐시 또는 글로벌 설치 stale)'));
2387
+ log('');
2388
+ log(C.d(' 해결 — 둘 중 하나 실행 후 다시 시도:'));
2389
+ log(' ' + C.b('npx --yes clear-npx-cache && npx leerness@latest init .'));
2390
+ log(' ' + C.b('npm i -g leerness@latest → leerness init .'));
2391
+ log('');
2392
+ log(C.d(' (이 경고는 LEERNESS_NO_STALE_CHECK=1 또는 --no-stale-check로 끌 수 있습니다)'));
2393
+ log('');
2394
+ return { stale: true, current: VERSION, latest };
2395
+ }
2396
+ return { stale: false, current: VERSION, latest };
2397
+ } catch (e) {
2398
+ // 어떤 이유로든 실패해도 init 진행 차단 X
2399
+ return null;
2400
+ }
2401
+ }
2402
+
2403
+ // 1.9.32/1.9.34: ASCII 배너 — init/version 시 출력 (그라데이션 다중 색상 강화)
2324
2404
  function _banner(opts = {}) {
2325
2405
  const v = `v${VERSION}`;
2326
- // 사용자 콘솔이 너무 좁을 때(<70) 또는 LEERNESS_NO_BANNER=1이면 스킵
2327
2406
  const cols = process.stdout && process.stdout.columns ? process.stdout.columns : 80;
2328
2407
  if (process.env.LEERNESS_NO_BANNER === '1') return;
2329
2408
  if (cols < 70) {
2330
2409
  log(`Leerness ${v} — 한국어 우선 AI 개발 하네스`);
2331
2410
  return;
2332
2411
  }
2333
- // ANSI 색상 (TTY일 때만)
2334
2412
  const isTty = process.stdout && process.stdout.isTTY;
2335
- const C = isTty ? {
2336
- cyan: s => `\x1b[36m${s}\x1b[0m`, dim: s => `\x1b[2m${s}\x1b[0m`,
2337
- bold: s => `\x1b[1m${s}\x1b[0m`, green: s => `\x1b[32m${s}\x1b[0m`
2338
- } : { cyan: s => s, dim: s => s, bold: s => s, green: s => s };
2339
- // 박스 안쪽 너비 60칸 고정 (좌측 2칸 들여쓰기 포함 전체 64칸)
2413
+ // 1.9.34: ANSI 256색 그라데이션 (cyan → magenta) + 굵게
2414
+ // 색상 안전 fallback (Windows 구버전 cmd는 256색 불가 시 그냥 기본색)
2415
+ const mk = (code) => isTty ? `\x1b[38;5;${code}m` : '';
2416
+ const reset = isTty ? '\x1b[0m' : '';
2417
+ const bold = isTty ? '\x1b[1m' : '';
2418
+ // 그라데이션 색상 (cyan/teal/blue/purple/magenta): 6 LEERNESS 라인 × 단색씩
2419
+ const grad = [51, 45, 39, 33, 99, 165]; // cyan → magenta
2420
+ const C = {
2421
+ cyan: s => isTty ? `\x1b[36m${s}\x1b[0m` : s,
2422
+ dim: s => isTty ? `\x1b[2m${s}\x1b[0m` : s,
2423
+ bold: s => isTty ? `\x1b[1m${s}\x1b[0m` : s,
2424
+ green: s => isTty ? `\x1b[32m${s}\x1b[0m` : s,
2425
+ yel: s => isTty ? `\x1b[33m${s}\x1b[0m` : s,
2426
+ mag: s => isTty ? `\x1b[35m${s}\x1b[0m` : s,
2427
+ g: (s, code) => isTty ? `${mk(code)}${bold}${s}${reset}` : s
2428
+ };
2429
+ // 박스 외곽선 + ASCII 본문 그라데이션
2430
+ const asciiLines = [
2431
+ '██╗ ███████╗███████╗██████╗ ███╗ ██╗███████╗███████╗',
2432
+ '██║ ██╔════╝██╔════╝██╔══██╗████╗ ██║██╔════╝██╔════╝',
2433
+ '██║ █████╗ █████╗ ██████╔╝██╔██╗ ██║█████╗ ███████╗',
2434
+ '██║ ██╔══╝ ██╔══╝ ██╔══██╗██║╚██╗██║██╔══╝ ╚════██║',
2435
+ '███████╗███████╗███████╗██║ ██║██║ ╚████║███████╗███████║',
2436
+ '╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝'
2437
+ ];
2438
+ const border = C.cyan;
2340
2439
  const lines = [
2341
2440
  '',
2342
- C.cyan(' ╔══════════════════════════════════════════════════════════════╗'),
2343
- C.cyan(' ║ ║'),
2344
- C.cyan(' ║ ') + C.bold('██╗ ███████╗███████╗██████╗ ███╗ ██╗███████╗███████╗') + C.cyan(' ║'),
2345
- C.cyan(' ║ ') + C.bold('██║ ██╔════╝██╔════╝██╔══██╗████╗ ██║██╔════╝██╔════╝') + C.cyan(' ║'),
2346
- C.cyan(' ║ ') + C.bold('██║ █████╗ █████╗ ██████╔╝██╔██╗ ██║█████╗ ███████╗') + C.cyan(' ║'),
2347
- C.cyan(' ║ ') + C.bold('██║ ██╔══╝ ██╔══╝ ██╔══██╗██║╚██╗██║██╔══╝ ╚════██║') + C.cyan(' ║'),
2348
- C.cyan(' ║ ') + C.bold('███████╗███████╗███████╗██║ ██║██║ ╚████║███████╗███████║') + C.cyan(' ║'),
2349
- C.cyan(' ║ ') + C.bold('╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝') + C.cyan(' ║'),
2350
- C.cyan(' ║ ║'),
2351
- // ASCII-only 라인은 정확히 60칸 (좌2 + 본문56 + 우2)
2352
- C.cyan(' ║ ') + C.green(`${v.padEnd(10)}`) + C.dim('Korean-first AI Development Harness') + C.cyan(' ║'),
2353
- C.cyan(' ║ ') + C.dim('verify · reuse-map · handoff · agents · orchestrate') + C.cyan(' ║'),
2354
- C.cyan(' ║ ║'),
2355
- C.cyan(' ╚══════════════════════════════════════════════════════════════╝'),
2356
- ' ' + C.dim('한국어 우선 AI 개발 하네스 — verify · reuse-map · handoff · agents'),
2357
- ''
2441
+ border(' ╔══════════════════════════════════════════════════════════════╗'),
2442
+ border(' ║ ║'),
2358
2443
  ];
2444
+ for (let i = 0; i < asciiLines.length; i++) {
2445
+ lines.push(border(' ║ ') + C.g(asciiLines[i], grad[i]) + border(' ║'));
2446
+ }
2447
+ lines.push(border(' ║ ║'));
2448
+ lines.push(border(' ║ ') + C.green(`${v.padEnd(10)}`) + C.dim('Korean-first AI Development Harness') + border(' ║'));
2449
+ lines.push(border(' ║ ') + C.yel('★ ') + C.dim('verify · reuse-map · handoff · agents · orchestrate') + border(' ║'));
2450
+ lines.push(border(' ║ ║'));
2451
+ lines.push(border(' ╚══════════════════════════════════════════════════════════════╝'));
2452
+ lines.push(' ' + C.dim('한국어 우선 AI 개발 하네스 — ') + C.mag('verify') + C.dim(' · ') + C.mag('reuse-map') + C.dim(' · ') + C.mag('handoff') + C.dim(' · ') + C.mag('agents'));
2453
+ lines.push('');
2359
2454
  for (const ln of lines) log(ln);
2360
2455
  if (opts.quickStart) {
2361
- log(C.dim(' 빠른 시작:'));
2362
- log(C.dim(' npx leerness@latest init . # 신규 프로젝트'));
2363
- log(C.dim(' npx leerness@latest setup-agents . # 외부 AI CLI 설정'));
2364
- log(C.dim(' npx leerness handoff . # 컨텍스트 적재'));
2365
- log(C.dim(' npx leerness verify-claim T-0001 --run-tests # 자동 검증'));
2456
+ log(C.bold(C.cyan(' 빠른 시작')));
2457
+ log(' ' + C.green('npx leerness@latest init .') + C.dim(' # 신규 프로젝트'));
2458
+ log(' ' + C.green('npx leerness@latest setup-agents .') + C.dim(' # 외부 AI CLI 설정'));
2459
+ log(' ' + C.green('npx leerness handoff .') + C.dim(' # 컨텍스트 적재'));
2460
+ log(' ' + C.green('npx leerness verify-claim T-0001 --run-tests') + C.dim(' # 자동 검증'));
2366
2461
  log('');
2367
2462
  }
2368
2463
  }
@@ -2390,6 +2485,132 @@ async function _confirm(question, defaultYes = false) {
2390
2485
  return /^(y|yes|예|네|ㅇ|1|true)$/i.test(ans.trim());
2391
2486
  }
2392
2487
 
2488
+ // 1.9.34: 방향키 + 스페이스 + Enter 인터랙티브 single-select prompt (raw mode)
2489
+ // 비-TTY 또는 LEERNESS_NO_PROMPT=1 → 첫 옵션 반환
2490
+ async function _selectOne(question, options, opts = {}) {
2491
+ if (!process.stdin.isTTY || process.env.LEERNESS_NO_PROMPT === '1' || has('--yes') || has('-y')) {
2492
+ return opts.defaultIndex != null ? options[opts.defaultIndex] : options[0];
2493
+ }
2494
+ const stdin = process.stdin;
2495
+ const stdout = process.stdout;
2496
+ const isTty = stdout.isTTY;
2497
+ const C = isTty ? {
2498
+ cyan: s => `\x1b[36m${s}\x1b[0m`, dim: s => `\x1b[2m${s}\x1b[0m`,
2499
+ bold: s => `\x1b[1m${s}\x1b[0m`, green: s => `\x1b[32m${s}\x1b[0m`,
2500
+ inv: s => `\x1b[7m${s}\x1b[0m`, mag: s => `\x1b[35m${s}\x1b[0m`
2501
+ } : { cyan: s => s, dim: s => s, bold: s => s, green: s => s, inv: s => s, mag: s => s };
2502
+ return new Promise(resolve => {
2503
+ let idx = opts.defaultIndex || 0;
2504
+ if (idx < 0 || idx >= options.length) idx = 0;
2505
+ const render = (first) => {
2506
+ if (!first) {
2507
+ // 이전 출력 지우기: options.length + 2줄 (제목 + 안내)
2508
+ stdout.write(`\x1b[${options.length + 2}A`);
2509
+ }
2510
+ stdout.write(`\r${C.bold(question)}\n`);
2511
+ stdout.write(`${C.dim(' ↑↓ 이동, Enter 확정, q 취소')}\n`);
2512
+ for (let i = 0; i < options.length; i++) {
2513
+ const label = typeof options[i] === 'string' ? options[i] : (options[i].label || String(options[i]));
2514
+ const desc = typeof options[i] === 'object' && options[i].description ? C.dim(' — ' + options[i].description) : '';
2515
+ const cursor = i === idx ? C.cyan('❯') : ' ';
2516
+ const text = i === idx ? C.bold(C.green(label)) : label;
2517
+ stdout.write(`\x1b[2K\r ${cursor} ${text}${desc}\n`);
2518
+ }
2519
+ };
2520
+ render(true);
2521
+ stdin.setRawMode && stdin.setRawMode(true);
2522
+ stdin.resume(); stdin.setEncoding('utf8');
2523
+ const onData = (buf) => {
2524
+ const key = String(buf);
2525
+ // 화살표는 ESC [ A/B
2526
+ if (key === '' || key === 'k') { idx = (idx - 1 + options.length) % options.length; render(false); }
2527
+ else if (key === '' || key === 'j') { idx = (idx + 1) % options.length; render(false); }
2528
+ else if (key === '\r' || key === '\n') {
2529
+ cleanup();
2530
+ stdout.write('\n');
2531
+ resolve(options[idx]);
2532
+ } else if (key === '' || key === 'q' || key === '') {
2533
+ cleanup();
2534
+ stdout.write('\n' + C.dim(' 취소됨') + '\n');
2535
+ resolve(opts.defaultIndex != null ? options[opts.defaultIndex] : null);
2536
+ }
2537
+ };
2538
+ const cleanup = () => {
2539
+ stdin.setRawMode && stdin.setRawMode(false);
2540
+ stdin.removeListener('data', onData);
2541
+ stdin.pause();
2542
+ };
2543
+ stdin.on('data', onData);
2544
+ });
2545
+ }
2546
+
2547
+ // 1.9.34: 방향키 + 스페이스 + Enter 인터랙티브 multi-select prompt (raw mode)
2548
+ // 비-TTY/--yes → opts.defaults 또는 빈 배열 반환
2549
+ async function _selectMany(question, options, opts = {}) {
2550
+ if (!process.stdin.isTTY || process.env.LEERNESS_NO_PROMPT === '1' || has('--yes') || has('-y')) {
2551
+ return (opts.defaults || []).map(d => typeof d === 'number' ? options[d] : d).filter(Boolean);
2552
+ }
2553
+ const stdin = process.stdin;
2554
+ const stdout = process.stdout;
2555
+ const isTty = stdout.isTTY;
2556
+ const C = isTty ? {
2557
+ cyan: s => `\x1b[36m${s}\x1b[0m`, dim: s => `\x1b[2m${s}\x1b[0m`,
2558
+ bold: s => `\x1b[1m${s}\x1b[0m`, green: s => `\x1b[32m${s}\x1b[0m`,
2559
+ inv: s => `\x1b[7m${s}\x1b[0m`, mag: s => `\x1b[35m${s}\x1b[0m`,
2560
+ yel: s => `\x1b[33m${s}\x1b[0m`
2561
+ } : { cyan: s => s, dim: s => s, bold: s => s, green: s => s, inv: s => s, mag: s => s, yel: s => s };
2562
+ return new Promise(resolve => {
2563
+ let idx = 0;
2564
+ const selected = new Set((opts.defaults || []).map(d => typeof d === 'number' ? d : options.findIndex(o => o === d || (o && o.id === d))).filter(i => i >= 0));
2565
+ const render = (first) => {
2566
+ if (!first) stdout.write(`\x1b[${options.length + 2}A`);
2567
+ stdout.write(`\r${C.bold(question)}\n`);
2568
+ stdout.write(`${C.dim(' ↑↓ 이동, Space 토글, a 전체, n 해제, Enter 확정, q 취소')}\n`);
2569
+ for (let i = 0; i < options.length; i++) {
2570
+ const opt = options[i];
2571
+ const label = typeof opt === 'string' ? opt : (opt.label || String(opt));
2572
+ const desc = typeof opt === 'object' && opt.description ? C.dim(' — ' + opt.description) : '';
2573
+ const mark = selected.has(i) ? C.green('◉') : C.dim('◯');
2574
+ const cursor = i === idx ? C.cyan('❯') : ' ';
2575
+ const text = i === idx ? C.bold(label) : label;
2576
+ stdout.write(`\x1b[2K\r ${cursor} ${mark} ${text}${desc}\n`);
2577
+ }
2578
+ };
2579
+ render(true);
2580
+ stdin.setRawMode && stdin.setRawMode(true);
2581
+ stdin.resume(); stdin.setEncoding('utf8');
2582
+ const onData = (buf) => {
2583
+ const key = String(buf);
2584
+ if (key === '' || key === 'k') { idx = (idx - 1 + options.length) % options.length; render(false); }
2585
+ else if (key === '' || key === 'j') { idx = (idx + 1) % options.length; render(false); }
2586
+ else if (key === ' ') {
2587
+ if (selected.has(idx)) selected.delete(idx); else selected.add(idx);
2588
+ render(false);
2589
+ } else if (key === 'a' || key === 'A') {
2590
+ for (let i = 0; i < options.length; i++) selected.add(i);
2591
+ render(false);
2592
+ } else if (key === 'n' || key === 'N') {
2593
+ selected.clear();
2594
+ render(false);
2595
+ } else if (key === '\r' || key === '\n') {
2596
+ cleanup();
2597
+ stdout.write('\n');
2598
+ resolve([...selected].sort((a, b) => a - b).map(i => options[i]));
2599
+ } else if (key === '' || key === 'q' || key === '') {
2600
+ cleanup();
2601
+ stdout.write('\n' + C.dim(' 취소됨 (기본값 사용)') + '\n');
2602
+ resolve((opts.defaults || []).map(d => typeof d === 'number' ? options[d] : d).filter(Boolean));
2603
+ }
2604
+ };
2605
+ const cleanup = () => {
2606
+ stdin.setRawMode && stdin.setRawMode(false);
2607
+ stdin.removeListener('data', onData);
2608
+ stdin.pause();
2609
+ };
2610
+ stdin.on('data', onData);
2611
+ });
2612
+ }
2613
+
2393
2614
  // 1.9.32: .env 파일에 KEY=value 라인 누적/갱신 (이미 키가 있으면 값 교체, 없으면 append)
2394
2615
  function _upsertEnvLine(envPath, key, value) {
2395
2616
  let body = exists(envPath) ? read(envPath) : '';
@@ -2410,7 +2631,8 @@ function _tryInstallAgent(agent) {
2410
2631
  return { ok: false, message: `exit ${r.status}` + (r.error ? ` (${r.error.code || r.error.message})` : '') };
2411
2632
  }
2412
2633
 
2413
- // 1.9.32: setup-agents 워크플로 — init 직후 또는 단독 명령
2634
+ // 1.9.32/1.9.34: setup-agents 워크플로 — init 직후 또는 단독 명령
2635
+ // 1.9.34: 방향키/스페이스 multi-select 도입 (LEERNESS_NO_INTERACTIVE=1 → 기존 yes/no 폴백)
2414
2636
  async function setupAgentsCmd(root, opts = {}) {
2415
2637
  root = absRoot(root || process.cwd());
2416
2638
  _loadEnvFile(root);
@@ -2418,12 +2640,11 @@ async function setupAgentsCmd(root, opts = {}) {
2418
2640
  const envPath = path.join(root, '.env');
2419
2641
 
2420
2642
  log('');
2421
- log('# 외부 AI CLI 설정 (1.9.32)');
2643
+ log('# 외부 AI CLI 설정 (1.9.34)');
2422
2644
  log('메인 에이전트가 작업을 분배할 sub-agent 후보를 선택하세요.');
2423
2645
  log('각 CLI는 *환경변수 활성화 + PATH 존재* 둘 다 충족할 때 ready 상태가 됩니다.');
2424
2646
  log('');
2425
2647
 
2426
- // 비대화형(--yes 또는 비-TTY)이면 모든 CLI를 기존 값 유지 + 안내만
2427
2648
  const interactive = !!process.stdin.isTTY && !has('--yes') && !has('-y') && process.env.LEERNESS_NO_PROMPT !== '1';
2428
2649
  if (!interactive) {
2429
2650
  log(' 비대화형 모드 — 환경변수는 변경하지 않습니다. 수동 편집:');
@@ -2432,38 +2653,57 @@ async function setupAgentsCmd(root, opts = {}) {
2432
2653
  return;
2433
2654
  }
2434
2655
 
2435
- for (const agent of EXTERNAL_AGENTS) {
2436
- const status = _checkAgent(agent);
2437
- const isReady = status.installed && status.enabled;
2438
- log(`---`);
2439
- log(`▸ ${agent.id} — ${agent.desc}`);
2440
- log(` 설치 상태: ${status.installed ? '🟢 설치됨 (' + (status.version || '?') + ')' : '⚪ 미설치'}`);
2441
- log(` 활성 상태: ${status.enabled ? '🟢 ' + agent.envFlag + '=1' : '🟡 ' + agent.envFlag + '=0 또는 미설정'}`);
2656
+ // 1.9.34: multi-select로 활성화할 CLI 일괄 선택
2657
+ const useInteractive = process.env.LEERNESS_NO_INTERACTIVE !== '1';
2658
+ const statuses = EXTERNAL_AGENTS.map(a => ({ agent: a, status: _checkAgent(a) }));
2442
2659
 
2443
- const wantEnable = await _confirm(` ${agent.id}를 sub-agent로 활성화하시겠습니까?`, isReady || agent.id === 'claude');
2444
- if (!wantEnable) {
2445
- _upsertEnvLine(envPath, agent.envFlag, '0');
2446
- log(` ✗ ${agent.envFlag}=0 (비활성)`);
2447
- continue;
2660
+ let toEnable = new Set();
2661
+ if (useInteractive) {
2662
+ const options = statuses.map(({ agent, status }) => {
2663
+ const inst = status.installed ? '🟢 설치됨' : '⚪ 미설치';
2664
+ const desc = `${inst} · ${agent.desc.slice(0, 50)}`;
2665
+ return { id: agent.id, label: agent.id.padEnd(8), description: desc };
2666
+ });
2667
+ // 기본 선택: 이미 활성화된 것 + claude (기본 활성)
2668
+ const defaults = statuses
2669
+ .map((s, i) => (s.status.enabled || s.agent.id === 'claude') ? i : -1)
2670
+ .filter(i => i >= 0);
2671
+ const picked = await _selectMany(
2672
+ '활성화할 sub-agent CLI를 선택하세요 (Space=토글, a=전체, n=해제, Enter=확정)',
2673
+ options,
2674
+ { defaults }
2675
+ );
2676
+ toEnable = new Set(picked.map(p => p.id));
2677
+ } else {
2678
+ // 폴백: 기존 yes/no
2679
+ for (const { agent, status } of statuses) {
2680
+ const isReady = status.installed && status.enabled;
2681
+ log(`▸ ${agent.id} — ${agent.desc}`);
2682
+ log(` ${status.installed ? '🟢 설치됨' : '⚪ 미설치'} / ${status.enabled ? '🟢 활성' : '🟡 비활성'}`);
2683
+ const wantEnable = await _confirm(` ${agent.id}를 sub-agent로 활성화?`, isReady || agent.id === 'claude');
2684
+ if (wantEnable) toEnable.add(agent.id);
2448
2685
  }
2449
- _upsertEnvLine(envPath, agent.envFlag, '1');
2450
- log(` ✓ ${agent.envFlag}=1 (활성)`);
2686
+ }
2687
+
2688
+ // 선택 결과 적용
2689
+ for (const { agent, status } of statuses) {
2690
+ const enable = toEnable.has(agent.id);
2691
+ _upsertEnvLine(envPath, agent.envFlag, enable ? '1' : '0');
2692
+ log(enable ? ` ✓ ${agent.envFlag}=1 (활성)` : ` ✗ ${agent.envFlag}=0 (비활성)`);
2451
2693
 
2452
- if (!status.installed) {
2694
+ // 활성화했지만 미설치 → 자동 설치 prompt
2695
+ if (enable && !status.installed) {
2453
2696
  log(` ⚠ ${agent.bin}이(가) 설치되어 있지 않습니다.`);
2454
2697
  log(` 설치 명령: ${agent.installCmd}`);
2455
- log(` 상세 안내: ${agent.installHint}`);
2456
2698
  const doInstall = await _confirm(` 지금 자동 설치를 시도할까요?`, false);
2457
2699
  if (doInstall) {
2458
2700
  const r = _tryInstallAgent(agent);
2459
2701
  if (r.ok) {
2460
- log(` ✓ 설치 성공 — 재확인: ${agent.bin} ${agent.versionArgs.join(' ')}`);
2461
2702
  const after = _checkAgent(agent);
2462
2703
  if (after.installed) log(` 🟢 ${agent.id} 설치 확인 (${after.version || '?'})`);
2463
2704
  else log(` ⚠ 설치 후에도 PATH에서 찾지 못함 — 새 셸을 열어주세요`);
2464
2705
  } else {
2465
2706
  log(` ✗ 설치 실패: ${r.message}`);
2466
- log(` 수동 설치 후 \`leerness agents list\`로 재확인하세요.`);
2467
2707
  }
2468
2708
  } else {
2469
2709
  log(` → 나중에 직접 설치 후 \`leerness setup-agents\` 재실행 가능`);
@@ -4812,7 +5052,7 @@ function viewworkInstall(root) {
4812
5052
  }
4813
5053
 
4814
5054
  function help() {
4815
- log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path] [--all-apps] [--include p1,p2] [--since 24h|3d] [--compact] [--json] # 1.9.17-22 워크스페이스 (--compact: LLM 시스템 프롬프트용 1줄 요약)\n leerness orchestrate "<목표>" [--agents N] [--model qwen2.5:7b-instruct] [--retry-on-fail K] # 1.9.22 Ollama opt-in (LEERNESS_OLLAMA_BASE_URL 필요)\n leerness llm-bench record --score N --model X [--label L] [--tokens T] # 1.9.22 LLM 벤치 히스토리 누적\n leerness deps <capability> [--run-tests] [--json] # 1.9.24 depends-on 역방향 추적 + 자동 회귀 sweep\n leerness memory search "키" [--include-code] # 1.9.25 소스 코드 본문도 검색 (모순 감지 핵심)\n leerness brainstorm "주제" [--include-code] # 1.9.25 코드 본문 hits 포함\n leerness register-pending "<요청>" [--agent X] [--note Y] # 1.9.25 다중 세션 in-progress 즉시 등록\n leerness optimism-check <T-ID> [--json] # 1.9.26/27 낙관적 표시 감지 (1.9.27: 10 카테고리 + URL/메서드 매핑 + 신뢰도 점수)\n leerness persona list|show <id>|add <id> # 1.9.29 페르소나 카탈로그 (보안/성능/UX/testing/docs 5종 내장)\n leerness review <file> --persona <id1,id2,...> # 1.9.29 도메인 페르소나 리뷰 프롬프트 자동 생성\n leerness agents list|check|quota # 1.9.30/31 외부 AI CLI 가용성 + quota 추정 (claude/codex/gemini/copilot)\n leerness agents dispatch "<task>" --to <id> # 1.9.30 활성 CLI 대상 실행 명령 생성 (실 호출 X, 사용자 실행)\n leerness setup-agents [path] [--yes|--no-setup-agents] # 1.9.32 sub-agent CLI 인터랙티브 설정 (.env + 미설치 자동 설치)\n leerness verify-claim <T-ID> ... [--strict-claims] # 1.9.26 verify-claim에 낙관적 표시 자동 검사 통합\n leerness reuse-map [path] [--all-apps] [--include p1,p2] [--strict-elements] [--json] # 1.9.18 중복/잠재중복/depends-on\n leerness verify-claim <T-ID> [--path .] [--run-tests] [--json] # 1.9.18-20 evidence 자동 검증 (1.9.20: scenes/scripts 등 도메인 폴더 + jest/mocha 파싱)\n leerness verify-code [path] [--build] [--bench] # 1.9.20 --bench: scripts.bench 추가 실행 + evidence 누적\n leerness session close [path]\n leerness viewwork install [path]\n leerness viewwork emit [path] [--action a] [--note n] [--agent x] [--tool t]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop|fix-evidence|relink [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy
5055
+ log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path] [--all-apps] [--include p1,p2] [--since 24h|3d] [--compact] [--json] # 1.9.17-22 워크스페이스 (--compact: LLM 시스템 프롬프트용 1줄 요약)\n leerness orchestrate "<목표>" [--agents N] [--model qwen2.5:7b-instruct] [--retry-on-fail K] # 1.9.22 Ollama opt-in (LEERNESS_OLLAMA_BASE_URL 필요)\n leerness llm-bench record --score N --model X [--label L] [--tokens T] # 1.9.22 LLM 벤치 히스토리 누적\n leerness deps <capability> [--run-tests] [--json] # 1.9.24 depends-on 역방향 추적 + 자동 회귀 sweep\n leerness memory search "키" [--include-code] # 1.9.25 소스 코드 본문도 검색 (모순 감지 핵심)\n leerness brainstorm "주제" [--include-code] # 1.9.25 코드 본문 hits 포함\n leerness register-pending "<요청>" [--agent X] [--note Y] # 1.9.25 다중 세션 in-progress 즉시 등록\n leerness optimism-check <T-ID> [--json] # 1.9.26/27 낙관적 표시 감지 (1.9.27: 10 카테고리 + URL/메서드 매핑 + 신뢰도 점수)\n leerness persona list|show <id>|add <id> # 1.9.29 페르소나 카탈로그 (보안/성능/UX/testing/docs 5종 내장)\n leerness review <file> --persona <id1,id2,...> # 1.9.29 도메인 페르소나 리뷰 프롬프트 자동 생성\n leerness agents list|check|quota # 1.9.30/31 외부 AI CLI 가용성 + quota 추정 (claude/codex/gemini/copilot)\n leerness agents dispatch "<task>" --to <id> # 1.9.30 활성 CLI 대상 실행 명령 생성 (실 호출 X, 사용자 실행)\n leerness setup-agents [path] [--yes|--no-setup-agents] # 1.9.32 sub-agent CLI 인터랙티브 설정 (.env + 미설치 자동 설치)\n leerness init [path] [--no-stale-check] # 1.9.33 npx 캐시 함정 — 옛 버전 자동 경고 (끄려면 --no-stale-check)\n leerness verify-claim <T-ID> ... [--strict-claims] # 1.9.26 verify-claim에 낙관적 표시 자동 검사 통합\n leerness reuse-map [path] [--all-apps] [--include p1,p2] [--strict-elements] [--json] # 1.9.18 중복/잠재중복/depends-on\n leerness verify-claim <T-ID> [--path .] [--run-tests] [--json] # 1.9.18-20 evidence 자동 검증 (1.9.20: scenes/scripts 등 도메인 폴더 + jest/mocha 파싱)\n leerness verify-code [path] [--build] [--bench] # 1.9.20 --bench: scripts.bench 추가 실행 + evidence 누적\n leerness session close [path]\n leerness viewwork install [path]\n leerness viewwork emit [path] [--action a] [--note n] [--agent x] [--tool t]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop|fix-evidence|relink [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy
4816
5056
  leerness retro [path] [--days 7] [--all-apps] [--include p1,p2] [--json] # 회고 (1.9.13~1.9.16)
4817
5057
  leerness insights [path] [--all-apps] [--include p1,p2] [--json] # 누적 통계 (1.9.13~1.9.16)
4818
5058
  leerness brainstorm "<주제>" [--all-apps] [--include p1,p2] [--json] # 브레인스토밍 (1.9.13~1.9.16)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.32",
3
+ "version": "1.9.34",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -898,7 +898,7 @@ total++;
898
898
  cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--language', 'ko', '--skills', 'recommended', '--no-setup-agents'], { stdio: 'ignore', timeout: 30000 });
899
899
  const r = cp.spawnSync(process.execPath, [CLI, 'setup-agents', tmpC, '--yes'], { encoding: 'utf8', timeout: 15000 });
900
900
  const ok = r.status === 0
901
- && /외부 AI CLI 설정 \(1\.9\.32\)/.test(r.stdout)
901
+ && /외부 AI CLI 설정 \(1\.9\.3\d\)/.test(r.stdout)
902
902
  && /(비대화형|leerness agents list)/.test(r.stdout);
903
903
  console.log(ok ? '✓ B(1.9.32) setup-agents 비대화형: 안내만 출력 (.env 미변경)' : `✗ setup-agents 실패`);
904
904
  if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
@@ -931,6 +931,84 @@ total++;
931
931
  if (!ok) { failed++; console.log(after); }
932
932
  }
933
933
 
934
+ // 1.9.33 회귀: npx 캐시 함정 — stale 버전 실행 시 경고
935
+ total++;
936
+ {
937
+ // 캐시에 미래 버전을 심어 stale 시뮬레이션 → 경고 출력 + init은 계속 진행
938
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-stale-'));
939
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--language', 'ko', '--skills', 'recommended', '--no-stale-check'], { stdio: 'ignore', timeout: 30000 });
940
+ const cacheDir = path.join(tmpC, '.harness', 'cache');
941
+ fs.mkdirSync(cacheDir, { recursive: true });
942
+ fs.writeFileSync(path.join(cacheDir, 'update-check.json'), JSON.stringify({ at: Date.now(), nextLeerness: '99.99.99', runningCli: require('../package.json').version }), 'utf8');
943
+ const r = cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--language', 'ko', '--skills', 'recommended'], { encoding: 'utf8', timeout: 30000 });
944
+ const ok = r.status === 0
945
+ && /옛 버전이 실행 중입니다/.test(r.stdout)
946
+ && /v99\.99\.99/.test(r.stdout)
947
+ && /clear-npx-cache/.test(r.stdout)
948
+ && /Leerness v/.test(r.stdout); // init도 계속 진행
949
+ console.log(ok ? '✓ B(1.9.33) npx stale 경고: 미래 latest 캐시 시 경고 + init 계속' : `✗ stale 경고 실패`);
950
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 800)); }
951
+ }
952
+
953
+ // 1.9.34 회귀: 인터랙티브 multi-select (방향키/스페이스) — 비-TTY 폴백
954
+ total++;
955
+ {
956
+ // 비-TTY에서는 _selectOne/_selectMany가 defaults 또는 첫 옵션 반환
957
+ // → init이 --yes 없이도 비대화형이면 기본 스킬셋(recommended)로 진행
958
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-multi-'));
959
+ const r = cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { encoding: 'utf8', timeout: 30000 });
960
+ // 비-TTY + --yes 시 multi-select prompt 안 띄움 → 통상 init 흐름
961
+ const ok = r.status === 0
962
+ && /Leerness v/.test(r.stdout)
963
+ && /Skills: office/.test(r.stdout);
964
+ console.log(ok ? '✓ B(1.9.34) multi-select 비-TTY 폴백: --yes로 default 사용' : `✗ multi-select 폴백 실패`);
965
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
966
+ }
967
+
968
+ total++;
969
+ {
970
+ // 1.9.34 배너 256색 그라데이션 — TTY 강제 + --banner
971
+ // 비-TTY일 때는 ANSI 코드 없이 순수 텍스트만 출력
972
+ const r = cp.spawnSync(process.execPath, [CLI, '--version', '--banner'], { encoding: 'utf8', timeout: 10000 });
973
+ const ok = r.status === 0
974
+ && /███████╗/.test(r.stdout)
975
+ && /verify · reuse-map/.test(r.stdout)
976
+ && /한국어 우선 AI 개발 하네스/.test(r.stdout)
977
+ && /v1\.9\.34/.test(r.stdout);
978
+ console.log(ok ? '✓ B(1.9.34) 배너 색상 + ASCII + 한국어' : `✗ 배너 색상 실패`);
979
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
980
+ }
981
+
982
+ total++;
983
+ {
984
+ // 1.9.34 LEERNESS_NO_INTERACTIVE=1: 구식 숫자 prompt 사용 (TTY 일 때만 의미 있음; 비-TTY는 어차피 --yes 폴백)
985
+ // 검증: --no-interactive-select 플래그가 인식되고 에러 안 남
986
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-noint-'));
987
+ const r = cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--no-interactive-select', '--language', 'ko', '--skills', 'recommended'], { encoding: 'utf8', timeout: 30000 });
988
+ const ok = r.status === 0 && /Leerness v/.test(r.stdout);
989
+ console.log(ok ? '✓ B(1.9.34) --no-interactive-select 플래그 인식' : `✗ --no-interactive-select 실패`);
990
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 300)); }
991
+ }
992
+
993
+ total++;
994
+ {
995
+ // --no-stale-check / LEERNESS_NO_STALE_CHECK=1: 경고 스킵
996
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-stale2-'));
997
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--language', 'ko', '--skills', 'recommended', '--no-stale-check'], { stdio: 'ignore', timeout: 30000 });
998
+ const cacheDir = path.join(tmpC, '.harness', 'cache');
999
+ fs.mkdirSync(cacheDir, { recursive: true });
1000
+ fs.writeFileSync(path.join(cacheDir, 'update-check.json'), JSON.stringify({ at: Date.now(), nextLeerness: '99.99.99' }), 'utf8');
1001
+ // --no-stale-check
1002
+ const r1 = cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { encoding: 'utf8', timeout: 30000 });
1003
+ // env flag
1004
+ const r2 = cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--language', 'ko', '--skills', 'recommended'], { encoding: 'utf8', timeout: 30000, env: { ...process.env, LEERNESS_NO_STALE_CHECK: '1' } });
1005
+ const ok = r1.status === 0 && r2.status === 0
1006
+ && !/옛 버전이 실행 중입니다/.test(r1.stdout)
1007
+ && !/옛 버전이 실행 중입니다/.test(r2.stdout);
1008
+ console.log(ok ? '✓ B(1.9.33) stale 스킵: --no-stale-check + LEERNESS_NO_STALE_CHECK=1' : `✗ stale skip 실패`);
1009
+ if (!ok) { failed++; console.log(r1.stdout.slice(0, 400)); }
1010
+ }
1011
+
934
1012
  // 1.9.22 회귀: handoff --compact + orchestrate opt-in 정책 + llm-bench record
935
1013
  total++;
936
1014
  {