leerness 1.9.35 → 1.9.37

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,67 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.37 — 2026-05-18
4
+
5
+ **메인 에이전트의 "leerness 점점 안 쓰는" drift 현상 자동 감지·경고**.
6
+
7
+ ### 배경
8
+ 실 워크스페이스 분석 결과: 라운드가 길어질수록 메인 에이전트가 `session close` / `task add` 등을 점점 잊는 패턴 발견.
9
+ - session-handoff.md 4.6일 stale
10
+ - task-log.md 4.6일 stale
11
+ - progress-tracker T-row 3일간 0건 업데이트
12
+ - 신규 sub-app 4개에 task 0건 등록
13
+
14
+ → **drift score 100/200 (🔴 critical) 등급**. 사용자 우려 사실 확인.
15
+
16
+ ### Added
17
+
18
+ - **`leerness drift check [path]`** 신규 명령:
19
+ - 4개 신호 측정: session-handoff.md, current-state.md, progress-tracker.md, task-log.md의 staleness
20
+ - 추가 신호: `_apps/*` 중 task 0건인 sub-project 수
21
+ - 가중치 합계 → 4단계 레벨 (🟢 healthy / 🟠 attention / 🟡 warning / 🔴 critical)
22
+ - 임계 0/20/50/100. 점수 ≥100 시 exit 1 (CI 친화)
23
+ - `--json` 출력 지원
24
+ - 권장 조치 자동 안내 (`session close` / `audit --fix` / `task add`)
25
+ - **`handoff` 자동 drift 경고** — handoff 호출 시 빠른 inline check (전체 `drift check` 안 호출). session-handoff/progress-tracker 중 하나라도 2일 이상 stale이면 노랑색 경고 + 권장 명령 안내.
26
+ - **스킵 옵션**: `--no-drift-check` 플래그 + `LEERNESS_NO_DRIFT_CHECK=1` 환경변수
27
+
28
+ ### 실측 (이번 라운드)
29
+ - 실 워크스페이스: drift 100/200 (critical) → `session close` 1회 후 30/200 (attention)
30
+ - e2e: 170/170 PASS (1.9.36 166 + 신규 4)
31
+
32
+ ### 정책
33
+ - ✅ drift 경고는 *알림만* — 자동 실행 금지 (사용자/메인이 명시적 선택)
34
+ - ✅ 빠른 inline check (handoff) vs 상세 보고 (`drift check`) 분리
35
+ - ✅ CI 친화: `--no-drift-check` 또는 env로 끄기 가능
36
+
37
+ ## 1.9.36 — 2026-05-18
38
+
39
+ **외부 AI CLI 오케스트레이션 강화: dispatch 안전 모드 + agents bench + 작업 유형 추천 + stress test에서 발견한 2 BUG 즉시 수정**.
40
+
41
+ ### Added
42
+
43
+ - **`leerness agents bench "<task>" [--write] [--timeout N]`** — 활성/설치된 모든 ready CLI에 같은 task를 동시 호출. 결과: 시간/exit/응답길이/마지막 라인 비교 매트릭스 + 🏆 가장 빠른 CLI 자동 표시. `--json` 출력 지원.
44
+ - **`agents dispatch`에 `--write` 모드 추가** — 기본은 read-only (안전). `--write` 명시 시 각 CLI에 위험 플래그 자동 첨부:
45
+ - claude → `--print --dangerously-skip-permissions`
46
+ - codex → `exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox`
47
+ - gemini → `-p --yolo`
48
+ - **`_recommendAgent()` 작업 유형 기반 CLI 추천** — task 키워드 분석:
49
+ - 번역/요약/분석/review → **claude** (1.7× 빠름)
50
+ - 아키텍처/리팩터/복잡 → **codex** (가장 상세)
51
+ - 생성/작성/수정/구현 → **gemini --yolo** (직접 수정 정확)
52
+ - ready 체크 전에 출력 → 비활성이어도 추천 안내
53
+ - **`dispatch` 출력에 CLI별 안내 추가** — codex의 POSIX path 변환 차이, gemini의 yolo 위험성 등.
54
+
55
+ ### Fixed (stress test에서 발견된 진짜 BUG)
56
+
57
+ - 🔴 **`contract verify` require() side-effect 제거** — `require(implFile)`가 스크립트 본문 실행 → 18초 소요 + 임의 코드 실행 위험. **정적 소스 분석** (`module.exports = {...}` / `exports.foo =` 패턴 grep)으로 교체. 18,245ms → **705ms (25.9× 빠름)** + 보안 위험 제거.
58
+ - 🟡 **`reuse autodetect` 디렉토리 제한 해제** — `src/`만 스캔 → **src/, bin/, lib/, app/ 4개 디렉토리** 스캔. require → 정적 분석.
59
+
60
+ ### Verified
61
+ - 신규 프로젝트 `_apps/leerness-stress` 생성 + 31개 leerness 명령 자동 호출 stress test
62
+ - 결과: 28 PASS / 3 의도된 BUG 감지 (false positive 0건)
63
+ - e2e: 166/166 PASS (1.9.35 161 + 신규 5)
64
+
3
65
  ## 1.9.35 — 2026-05-17
4
66
 
5
67
  **파이프라인 메타-감사에서 도출된 5개 개선 사항 통합**.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **AI 에이전트 검수·다중 협업 CLI 하네스** — 거짓 완료 차단, 멀티 에이전트 오케스트레이션, 사양 ↔ 구현 일치 검증, 워크스페이스 통합 가시성. **한국어 우선**.
4
4
 
5
- [![npm](https://img.shields.io/badge/npm-leerness-blue)](https://www.npmjs.com/package/leerness) [![version](https://img.shields.io/badge/version-1.9.35-green)]() [![tests](https://img.shields.io/badge/e2e-161%2F161-success)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
5
+ [![npm](https://img.shields.io/badge/npm-leerness-blue)](https://www.npmjs.com/package/leerness) [![version](https://img.shields.io/badge/version-1.9.37-green)]() [![tests](https://img.shields.io/badge/e2e-170%2F170-success)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
6
6
 
7
7
  > Claude Code · Cursor · Copilot · Codex · Gemini CLI — 어떤 AI 에이전트로 코드를 짜든, leerness는 **"진짜로 했나? 중복 아닌가? 합의된 사양인가?"** 를 자동으로 검수합니다.
8
8
 
@@ -42,6 +42,7 @@ AI 에이전트(Claude Code, Cursor, Copilot, Codex, Gemini CLI 등)는 **빠르
42
42
  | 🚨 신규 모듈의 capability가 reuse-map에 등록 안 됨 | **`leerness reuse autodetect`** — `module.exports` 스캔 + 자동 등록 (1.9.35) |
43
43
  | 🚨 신규 디렉토리에서 sub-agent가 컨텍스트 없이 작업 시작 | `handoff` 호출 시 **.harness 부재 자동 경고** (1.9.35) |
44
44
  | 🚨 audit warning이 쌓이지만 수동 fix가 번거로움 | **`audit --fix`** — session-handoff/current-state 자동 갱신 (1.9.35) |
45
+ | 🚨 **프로젝트가 길어질수록 메인 에이전트가 leerness를 점점 안 씀** (메타파일 stale, session close 누락) | **`leerness drift check`** — 4개 신호로 staleness 측정 + handoff 시 자동 경고 (1.9.37) |
45
46
 
46
47
  ---
47
48
 
@@ -275,9 +276,10 @@ leerness deps <capability> --run-tests # 영향 추적 + 자동 회귀
275
276
  leerness setup-agents . # 인터랙티브 활성화 + 자동 설치
276
277
  leerness agents list # 4 CLI 상태표
277
278
  leerness agents quota # 사용량 추정
278
- leerness agents dispatch "<task>" --to gemini # 명령 생성 (안전 규칙 포함)
279
- leerness contract verify <spec.md> <impl.js> # 1.9.35 명세 구현 일치 검사
280
- leerness reuse autodetect [path] [--apply] # 1.9.35 capability 자동 등록
279
+ leerness agents dispatch "<task>" --to gemini --write # 1.9.36 --write: --yolo 자동 첨부
280
+ leerness agents bench "<task>" [--timeout N] # 1.9.36 ready CLI 모두 병렬 호출 + 비교표
281
+ leerness contract verify <spec.md> <impl.js> # 1.9.35/36 명세 구현 일치 검사 (정적 분석)
282
+ leerness reuse autodetect [path] [--apply] # 1.9.35/36 capability 자동 등록 (src/bin/lib/app)
281
283
  leerness audit . --fix # 1.9.35 누락 메타 자동 갱신
282
284
  ```
283
285
 
@@ -470,6 +472,8 @@ npm test # = node ./scripts/e2e.js
470
472
 
471
473
  ## 📜 변경 이력 (최근)
472
474
 
475
+ - **1.9.37** — `leerness drift check` — 라운드가 길어질수록 메인 에이전트가 leerness를 점점 안 쓰는 현상 자동 감지. handoff 시 자동 경고 + 4단계 레벨 (healthy/attention/warning/critical) + 권장 조치 안내. 실 워크스페이스에서 4.6일 stale, drift 100/200 (critical) 발견 → session close 1회로 30/200 (attention) 회복 실증.
476
+ - **1.9.36** — 외부 AI CLI 오케스트레이션 강화: `agents bench` (3 CLI 동시 비교) + `dispatch --write` 자동 권장 플래그 + 작업 유형 키워드 추천. stress test에서 발견한 `contract verify` require() side-effect (보안 위험 + 25× 속도 회복) 즉시 수정.
473
477
  - **1.9.35** — 파이프라인 메타-감사에서 도출된 5개 개선 통합: `contract verify` · `reuse autodetect` · `audit --fix` · `handoff` init 부재 경고 · `agents dispatch` 안전 규칙 안내.
474
478
  - **1.9.34** — 방향키/스페이스 인터랙티브 multi-select (`_selectOne`/`_selectMany`) + 256색 그라데이션 배너 + 3단계 sub-agent 오케스트레이션 검증 (2.2× 효율 실측).
475
479
  - **1.9.33** — `_warnIfStale()` — `npx leerness init` 시 옛 버전 자동 경고 + 해결 안내.
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.35';
9
+ const VERSION = '1.9.37';
10
10
  const MARK = '<!-- leerness:managed -->';
11
11
  const README_START = '<!-- leerness:project-readme:start -->';
12
12
  const README_END = '<!-- leerness:project-readme:end -->';
@@ -1432,6 +1432,38 @@ function handoffCmd(root) {
1432
1432
  if (has('--all-apps') || arg('--include', null)) {
1433
1433
  return _handoffWorkspace(absRoot(root));
1434
1434
  }
1435
+ // 1.9.37: drift 자동 경고 (메인 에이전트가 leerness를 점점 안 쓰는 현상 감지)
1436
+ const absR0 = absRoot(root || process.cwd());
1437
+ if (exists(path.join(absR0, '.harness')) && !has('--no-drift-check') && process.env.LEERNESS_NO_DRIFT_CHECK !== '1') {
1438
+ try {
1439
+ const isTty = process.stdout && process.stdout.isTTY;
1440
+ const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
1441
+ const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
1442
+ const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
1443
+ // 간이 drift 계산 (전체 driftCheckCmd 호출 안 함 — 빠른 inline check)
1444
+ const now = Date.now();
1445
+ const shPath = handoffPath(absR0);
1446
+ let shAge = null;
1447
+ if (exists(shPath)) {
1448
+ const m = read(shPath).match(/Last generated:\s*([\d\-T:.Z]+)/);
1449
+ if (m) shAge = (now - new Date(m[1]).getTime()) / 86400000;
1450
+ }
1451
+ const rows = readProgressRows(absR0);
1452
+ let ptAge = null;
1453
+ if (rows.length) {
1454
+ const dates = rows.map(r => (r.updated || '').match(/\d{4}-\d{2}-\d{2}/)).filter(Boolean).map(m => m[0]).sort();
1455
+ if (dates.length) ptAge = (now - new Date(dates[dates.length - 1]).getTime()) / 86400000;
1456
+ }
1457
+ if ((shAge !== null && shAge > 2) || (ptAge !== null && ptAge > 2)) {
1458
+ log('');
1459
+ log(yel(' ⚠ leerness drift 감지 — 메타파일이 stale합니다'));
1460
+ if (shAge !== null && shAge > 2) log(dim(` session-handoff.md: ${shAge.toFixed(1)}일 stale`));
1461
+ if (ptAge !== null && ptAge > 2) log(dim(` progress-tracker: ${ptAge.toFixed(1)}일 stale`));
1462
+ log(dim(` → 권장: ${red('leerness session close .')} 또는 ${red('leerness drift check .')} 로 상세 보기`));
1463
+ log('');
1464
+ }
1465
+ } catch {}
1466
+ }
1435
1467
  // 1.9.35 개선 #1: .harness 부재 시 즉시 경고 (자동 init 권장)
1436
1468
  // 사용자가 신규 디렉토리에서 handoff 호출 시 sub-agent 작업이 길을 잃지 않도록.
1437
1469
  const absR = absRoot(root || process.cwd());
@@ -2369,6 +2401,30 @@ const EXTERNAL_AGENTS = [
2369
2401
  installCmd: 'gh extension install github/gh-copilot', installHint: 'https://github.com/github/gh-copilot (gh CLI 선행 설치 필요)' }
2370
2402
  ];
2371
2403
 
2404
+ // 1.9.36: 작업 키워드 분석으로 최적 CLI 추천
2405
+ // \b는 ASCII word boundary만 인식 → 한글 키워드는 단순 substring 검사 사용.
2406
+ function _recommendAgent(task) {
2407
+ if (!task || typeof task !== 'string') return { target: null, reason: '' };
2408
+ const t = task.toLowerCase();
2409
+ const hasAny = (keywords) => keywords.some(k => t.includes(k));
2410
+ // 텍스트 분석/번역 → claude (가장 빠름, 1.7×)
2411
+ if (hasAny(['translate', 'summary', 'explain', 'describe', 'analyze', 'review',
2412
+ '번역', '요약', '설명', '분석', '리뷰'])) {
2413
+ return { target: 'claude', reason: '텍스트 분석·요약·번역은 claude가 1.7× 빠름' };
2414
+ }
2415
+ // 깊은 코드 추론
2416
+ if (hasAny(['architecture', 'design pattern', 'refactor', 'trace', 'complex', 'critical path',
2417
+ '아키텍처', '리팩터', '복잡'])) {
2418
+ return { target: 'codex', reason: '깊은 코드 추론은 codex가 가장 상세' };
2419
+ }
2420
+ // 파일 작성·수정·생성
2421
+ if (hasAny(['create', 'write', 'generate', 'patch', 'fix', 'implement', 'edit',
2422
+ '구현', '생성', '작성', '수정', '추가'])) {
2423
+ return { target: 'gemini', reason: '워크스페이스 직접 수정은 gemini --yolo가 정확' };
2424
+ }
2425
+ return { target: null, reason: '' };
2426
+ }
2427
+
2372
2428
  function _checkAgent(agent, opts = {}) {
2373
2429
  const enabled = process.env[agent.envFlag] === '1';
2374
2430
  // PATH 존재 확인 (which / where)
@@ -2806,32 +2862,51 @@ function agentsCmd(root, sub, ...args) {
2806
2862
  if (!target) { fail('--to <agent_id> 필요 (claude/codex/gemini/copilot)'); return process.exit(1); }
2807
2863
  const agentDef = EXTERNAL_AGENTS.find(a => a.id === target);
2808
2864
  if (!agentDef) { fail(`알 수 없는 agent: ${target}`); return process.exit(1); }
2865
+ // 1.9.36: 작업 유형 키워드 분석 → 최적 CLI 추천 (ready 체크 전에 출력 — 비활성이어도 추천)
2866
+ const recommendation = _recommendAgent(task);
2867
+ const recommended = recommendation.target;
2868
+ if (recommended && recommended !== target) {
2869
+ log(`💡 추천: 이 작업은 ${recommended}가 더 적합 (${recommendation.reason})`);
2870
+ }
2809
2871
  const status = _checkAgent(agentDef);
2810
2872
  if (status.status !== 'ready') {
2811
2873
  fail(`${target} 비활성 (${status.status}). 환경변수 ${agentDef.envFlag}=1 + CLI 설치 필요.`);
2812
2874
  return process.exit(1);
2813
2875
  }
2876
+ // 1.9.36: --write 시 파일 수정 가능 권장 플래그 자동 첨부, 미명시 시 read-only 안전 모드
2877
+ const writeMode = has('--write');
2878
+ const readOnly = has('--readonly') || !writeMode;
2814
2879
  // 실제 호출은 안 함 — 프롬프트만 생성 (사용자가 명시적으로 실행)
2815
- log(`# leerness agents dispatch (1.9.30)`);
2880
+ log(`# leerness agents dispatch (1.9.36)`);
2816
2881
  log(`대상: ${target} (${agentDef.bin})`);
2817
2882
  log(`상태: 🟢 ready, 버전 ${status.version || '?'}`);
2883
+ log(`모드: ${writeMode ? '✏ write (파일 수정 가능)' : '🔒 read-only (분석 전용, 안전)'}`);
2818
2884
  log('');
2819
2885
  log(`## 실행 명령 (사용자가 복사해서 실행)`);
2820
2886
  log('');
2887
+ const q = task.replace(/"/g, '\\"');
2821
2888
  if (target === 'claude') {
2822
- log(`claude "${task.replace(/"/g, '\\"')}"`);
2889
+ const flags = writeMode ? '--print --dangerously-skip-permissions' : '--print';
2890
+ log(`claude ${flags} "${q}"`);
2891
+ if (writeMode) log(`# ⚠ --dangerously-skip-permissions: 도구 권한 자동 승인 (파일 수정 가능)`);
2823
2892
  } else if (target === 'codex') {
2824
- log(`codex exec "${task.replace(/"/g, '\\"')}"`);
2893
+ const flags = writeMode ? 'exec --skip-git-repo-check --dangerously-bypass-approvals-and-sandbox' : 'exec --skip-git-repo-check';
2894
+ log(`codex ${flags} "${q}"`);
2895
+ log(`# ℹ codex는 PowerShell 경유 — POSIX /tmp 경로는 C:\\tmp\\로 해석됨`);
2896
+ if (writeMode) log(`# ⚠ --dangerously-bypass-approvals-and-sandbox: sandbox 우회`);
2825
2897
  } else if (target === 'gemini') {
2826
- log(`gemini -p "${task.replace(/"/g, '\\"')}" --yolo # yolo는 워크스페이스 직접 수정 가능`);
2898
+ const flags = writeMode ? '-p --yolo' : '-p';
2899
+ log(`gemini ${flags} "${q}"`);
2900
+ if (writeMode) log(`# ⚠ --yolo: 워크스페이스 파일 직접 수정 가능`);
2827
2901
  } else if (target === 'copilot') {
2828
- log(`gh copilot suggest "${task.replace(/"/g, '\\"')}"`);
2902
+ log(`gh copilot suggest "${q}"`);
2829
2903
  }
2830
2904
  log('');
2831
- log(`## 정책 (1.9.30)`);
2905
+ log(`## 정책 (1.9.36)`);
2832
2906
  log(` - leerness는 외부 CLI를 자동 호출하지 않음 (사용자 명시적 실행)`);
2833
2907
  log(` - 메인 에이전트(Claude)가 위 명령을 보고 sub-agent로 spawn 가능`);
2834
2908
  log(` - quota 체크: \`leerness agents quota\` (1.9.31+)`);
2909
+ log(` - 동시 호출 시: \`leerness agents bench "<task>"\` (1.9.36)`);
2835
2910
  log('');
2836
2911
  log(`## 분배 시 안전 규칙 (1.9.35)`);
2837
2912
  log(` - sub-agent 프롬프트에 "당신만 수정할 파일 경로"를 명시 (파일 경로 격리)`);
@@ -2841,6 +2916,85 @@ function agentsCmd(root, sub, ...args) {
2841
2916
  return;
2842
2917
  }
2843
2918
 
2919
+ if (sub === 'bench') {
2920
+ // 1.9.36: 같은 prompt를 ready CLI 모두에 동시 호출 + 시간/응답 길이/exit code 비교
2921
+ const task = args.filter(x => !x.startsWith('-')).join(' ').trim() || arg('--task', null);
2922
+ if (!task) { fail('bench "<task>" 필요'); return process.exit(1); }
2923
+ const timeoutS = parseInt(arg('--timeout', '60'), 10);
2924
+ const writeMode = has('--write');
2925
+ const ready = EXTERNAL_AGENTS.map(a => ({ agent: a, status: _checkAgent(a) }))
2926
+ .filter(x => x.status.status === 'ready');
2927
+ if (!ready.length) {
2928
+ fail('ready CLI 없음 — leerness setup-agents 또는 .env에 LEERNESS_ENABLE_X=1 설정 필요');
2929
+ return process.exit(1);
2930
+ }
2931
+ log(`# leerness agents bench (1.9.36)`);
2932
+ log(`task: ${task.slice(0, 80)}${task.length > 80 ? '…' : ''}`);
2933
+ log(`참여 CLI: ${ready.map(r => r.agent.id).join(', ')} (${ready.length}개)`);
2934
+ log(`타임아웃: ${timeoutS}s/CLI · 모드: ${writeMode ? 'write' : 'read-only'}`);
2935
+ log('');
2936
+ log('병렬 호출 중... (병렬 fork 후 wait)');
2937
+ log('');
2938
+ const results = [];
2939
+ const promises = ready.map(({ agent, status }) => new Promise((resolve) => {
2940
+ const t0 = Date.now();
2941
+ let cmd, cmdArgs;
2942
+ if (agent.id === 'claude') {
2943
+ cmdArgs = writeMode ? ['--print', '--dangerously-skip-permissions', task] : ['--print', task];
2944
+ cmd = 'claude';
2945
+ } else if (agent.id === 'codex') {
2946
+ cmdArgs = writeMode
2947
+ ? ['exec', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', task]
2948
+ : ['exec', '--skip-git-repo-check', task];
2949
+ cmd = 'codex';
2950
+ } else if (agent.id === 'gemini') {
2951
+ cmdArgs = writeMode ? ['-p', task, '--yolo'] : ['-p', task];
2952
+ cmd = 'gemini';
2953
+ } else if (agent.id === 'copilot') {
2954
+ cmdArgs = ['copilot', 'suggest', task];
2955
+ cmd = 'gh';
2956
+ }
2957
+ const r = cp.spawn(cmd, cmdArgs, { shell: true });
2958
+ let stdout = '', stderr = '';
2959
+ r.stdout.on('data', d => { stdout += d; });
2960
+ r.stderr.on('data', d => { stderr += d; });
2961
+ const timer = setTimeout(() => { r.kill(); }, timeoutS * 1000);
2962
+ r.on('close', (code) => {
2963
+ clearTimeout(timer);
2964
+ const elapsed = Date.now() - t0;
2965
+ results.push({
2966
+ id: agent.id, exit: code, elapsed,
2967
+ stdout: stdout.trim().split('\n').slice(-3).join('\n'),
2968
+ stderrLen: stderr.length,
2969
+ ok: code === 0 && stdout.trim().length > 0
2970
+ });
2971
+ resolve();
2972
+ });
2973
+ r.on('error', (err) => {
2974
+ clearTimeout(timer);
2975
+ results.push({ id: agent.id, exit: -1, elapsed: Date.now() - t0, stdout: '', stderrLen: 0, error: err.message, ok: false });
2976
+ resolve();
2977
+ });
2978
+ }));
2979
+ return Promise.all(promises).then(() => {
2980
+ if (has('--json')) { log(JSON.stringify({ task, results }, null, 2)); return; }
2981
+ log(`| CLI | 시간 | exit | 응답 길이 | 마지막 라인 |`);
2982
+ log(`|---|---:|---:|---:|---|`);
2983
+ // sort by elapsed
2984
+ results.sort((a, b) => a.elapsed - b.elapsed);
2985
+ for (const r of results) {
2986
+ const respLen = (r.stdout || '').length;
2987
+ const last = (r.stdout || '').split('\n').pop().slice(0, 50);
2988
+ log(`| ${r.id} | ${r.elapsed}ms | ${r.exit} | ${respLen} | ${last.replace(/\|/g, '\\|')} |`);
2989
+ }
2990
+ log('');
2991
+ const okCount = results.filter(r => r.ok).length;
2992
+ log(`결과: ${okCount}/${results.length} 성공`);
2993
+ const fastest = results.filter(r => r.ok).sort((a, b) => a.elapsed - b.elapsed)[0];
2994
+ if (fastest) log(`🏆 가장 빠름: ${fastest.id} (${fastest.elapsed}ms)`);
2995
+ });
2996
+ }
2997
+
2844
2998
  if (sub === 'quota') {
2845
2999
  // 1.9.31: 각 CLI 사용량/쿼터 추정 + provider 대시보드 링크
2846
3000
  const results = [];
@@ -2903,7 +3057,7 @@ function agentsCmd(root, sub, ...args) {
2903
3057
  return;
2904
3058
  }
2905
3059
 
2906
- fail('사용법: leerness agents list|check|quota|dispatch "<task>" --to <id>');
3060
+ fail('사용법: leerness agents list|check|quota|dispatch|bench [--write] "<task>" [--to <id>]');
2907
3061
  return process.exit(1);
2908
3062
  }
2909
3063
 
@@ -5096,6 +5250,122 @@ function viewworkInstall(root) {
5096
5250
  ok('claude .claude/settings.local.json updated (Stop hook adds a viewwork event)');
5097
5251
  }
5098
5252
 
5253
+ // 1.9.37: drift detection — 메타파일 staleness 측정으로 "leerness 점점 안 쓰는" 현상 감지
5254
+ function driftCheckCmd(root, opts = {}) {
5255
+ root = absRoot(root || process.cwd());
5256
+ const now = Date.now();
5257
+ const _ageDays = (p) => {
5258
+ if (!exists(p)) return null;
5259
+ return (now - fs.statSync(p).mtimeMs) / 86400000;
5260
+ };
5261
+ // 각 메타파일의 마지막 갱신
5262
+ const signals = [];
5263
+ // 1. session-handoff.md - "Last generated" 라인 우선, 없으면 mtime
5264
+ const shPath = handoffPath(root);
5265
+ if (exists(shPath)) {
5266
+ const txt = read(shPath);
5267
+ const m = txt.match(/Last generated:\s*([\d\-T:.Z]+)/);
5268
+ let ageDays;
5269
+ if (m) {
5270
+ ageDays = (now - new Date(m[1]).getTime()) / 86400000;
5271
+ } else {
5272
+ ageDays = _ageDays(shPath);
5273
+ }
5274
+ signals.push({ file: 'session-handoff.md', ageDays, threshold: 1, weight: 30, label: 'session close 누락' });
5275
+ }
5276
+ // 2. current-state.md - "Updated: YYYY-MM-DD" 라인
5277
+ const csPath = currentStatePath(root);
5278
+ if (exists(csPath)) {
5279
+ const m = read(csPath).match(/Updated:\s*(\d{4}-\d{2}-\d{2})/);
5280
+ const ageDays = m ? (now - new Date(m[1]).getTime()) / 86400000 : _ageDays(csPath);
5281
+ signals.push({ file: 'current-state.md', ageDays, threshold: 2, weight: 20, label: 'current-state 갱신 없음' });
5282
+ }
5283
+ // 3. progress-tracker.md 마지막 row의 updated 컬럼
5284
+ const rows = readProgressRows(root);
5285
+ if (rows.length) {
5286
+ const dates = rows.map(r => (r.updated || '').match(/\d{4}-\d{2}-\d{2}/)).filter(Boolean).map(m => m[0]);
5287
+ if (dates.length) {
5288
+ dates.sort();
5289
+ const latest = dates[dates.length - 1];
5290
+ const ageDays = (now - new Date(latest).getTime()) / 86400000;
5291
+ signals.push({ file: 'progress-tracker.md', ageDays, threshold: 1, weight: 30, label: 'task update 없음' });
5292
+ }
5293
+ } else {
5294
+ signals.push({ file: 'progress-tracker.md', ageDays: 999, threshold: 1, weight: 25, label: 'progress-tracker 비어있음' });
5295
+ }
5296
+ // 4. task-log.md 마지막 entry "## YYYY-MM-DD"
5297
+ const tlPath = taskLogPath(root);
5298
+ if (exists(tlPath)) {
5299
+ const dates = Array.from(read(tlPath).matchAll(/^## (\d{4}-\d{2}-\d{2})/gm)).map(m => m[1]);
5300
+ if (dates.length) {
5301
+ dates.sort();
5302
+ const latest = dates[dates.length - 1];
5303
+ const ageDays = (now - new Date(latest).getTime()) / 86400000;
5304
+ signals.push({ file: 'task-log.md', ageDays, threshold: 2, weight: 20, label: 'task-log 갱신 없음' });
5305
+ }
5306
+ }
5307
+ // 점수 계산
5308
+ let totalScore = 0;
5309
+ const fired = [];
5310
+ for (const s of signals) {
5311
+ if (s.ageDays > s.threshold) {
5312
+ totalScore += s.weight;
5313
+ fired.push(s);
5314
+ }
5315
+ }
5316
+ // 신규 _apps/* 에서 task 0건도 신호로
5317
+ const appsDir = path.join(root, '_apps');
5318
+ let appsZeroTask = [];
5319
+ if (exists(appsDir)) {
5320
+ for (const d of fs.readdirSync(appsDir)) {
5321
+ const sub = path.join(appsDir, d);
5322
+ if (!exists(path.join(sub, '.harness'))) continue;
5323
+ const subRows = readProgressRows(sub);
5324
+ if (!subRows.length) appsZeroTask.push(d);
5325
+ }
5326
+ if (appsZeroTask.length) {
5327
+ const w = Math.min(50, appsZeroTask.length * 10);
5328
+ totalScore += w;
5329
+ fired.push({ file: `_apps/* (${appsZeroTask.length}개)`, ageDays: null, threshold: 0, weight: w, label: `task 0건 sub-app: ${appsZeroTask.slice(0, 3).join(', ')}${appsZeroTask.length > 3 ? '...' : ''}` });
5330
+ }
5331
+ }
5332
+ // 레벨 판정
5333
+ let level = '🟢 healthy';
5334
+ if (totalScore >= 100) level = '🔴 critical';
5335
+ else if (totalScore >= 50) level = '🟡 warning';
5336
+ else if (totalScore >= 20) level = '🟠 attention';
5337
+
5338
+ if (has('--json')) {
5339
+ log(JSON.stringify({ root, score: totalScore, level, signals, fired, appsZeroTask }, null, 2));
5340
+ return;
5341
+ }
5342
+ log(`# leerness drift check (1.9.37)`);
5343
+ log(`경로: ${root}`);
5344
+ log('');
5345
+ log(`상태: ${level} · 점수 ${totalScore}/200`);
5346
+ log('');
5347
+ log(`| 신호 | age | 임계 | 가중치 | 발화 |`);
5348
+ log(`|---|---:|---:|---:|---|`);
5349
+ for (const s of signals) {
5350
+ const fire = s.ageDays > s.threshold ? '🔥' : '✓';
5351
+ const age = s.ageDays === null ? '-' : `${s.ageDays.toFixed(1)}d`;
5352
+ log(`| ${s.label} | ${age} | ${s.threshold}d | ${s.weight} | ${fire} |`);
5353
+ }
5354
+ if (appsZeroTask.length) {
5355
+ log('');
5356
+ log(`task 0건 sub-app (${appsZeroTask.length}개): ${appsZeroTask.join(', ')}`);
5357
+ }
5358
+ if (totalScore >= 50) {
5359
+ log('');
5360
+ log(`💡 권장 조치:`);
5361
+ log(` - 즉시: leerness session close . (handoff/current-state 갱신)`);
5362
+ log(` - 또는: leerness audit . --fix (자동 갱신 가능 항목 적용)`);
5363
+ log(` - sub-app에 task 등록: cd _apps/X && leerness task add "..."`);
5364
+ log(` - 이 검사 끄기: --no-drift-check 또는 LEERNESS_NO_DRIFT_CHECK=1`);
5365
+ }
5366
+ if (level === '🔴 critical') process.exitCode = 1;
5367
+ }
5368
+
5099
5369
  // 1.9.35 개선 #3: contract verify <spec.md> <impl.js>
5100
5370
  // 사양 문서(spec.md)에 명시된 함수 이름이 실제 module.exports에 모두 있는지 검사.
5101
5371
  // 사용 예: leerness contract verify TICK_SPEC.md src/format.js
@@ -5119,28 +5389,27 @@ function contractVerifyCmd(specPath, implPath) {
5119
5389
  for (const m of specText.matchAll(/`([A-Za-z_$][\w$]*)\s*\(/g)) fnSpec.add(m[1]);
5120
5390
  // 필드: tick.<name>
5121
5391
  for (const m of specText.matchAll(/tick\.([A-Za-z_$][\w$]*)/g)) fieldSpec.add(m[1]);
5122
- // impl require exports 추출
5123
- let exports;
5124
- try {
5125
- // 캐시 우회: delete from cache
5126
- delete require.cache[implFile];
5127
- exports = require(implFile);
5128
- } catch (e) { fail(`impl 로드 실패: ${e.message}`); return process.exit(1); }
5392
+ // 1.9.36 BUG-fix: require()는 side-effect 실행 위험 (CLI 스크립트는 require로 실행됨).
5393
+ // 대신 정적 소스 분석 — module.exports = { foo, bar } / exports.foo = ... / module.exports.foo = ... 패턴 grep.
5394
+ const implSrc = read(implFile);
5129
5395
  const implExports = new Set();
5130
- if (exports && typeof exports === 'object') {
5131
- for (const k of Object.keys(exports)) implExports.add(k);
5396
+ // pattern 1: module.exports = { foo, bar, baz }
5397
+ for (const m of implSrc.matchAll(/module\.exports\s*=\s*\{([^}]+)\}/g)) {
5398
+ for (const k of m[1].split(',')) {
5399
+ const name = k.replace(/:.*/, '').trim();
5400
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) implExports.add(name);
5401
+ }
5132
5402
  }
5133
- // 검사: spec에 명시된 함수 impl에 없는 것
5403
+ // pattern 2: exports.foo = / module.exports.foo =
5404
+ for (const m of implSrc.matchAll(/(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=/g)) implExports.add(m[1]);
5405
+ // pattern 3: function foo + module.exports에 포함되었는지는 위에서 처리됨
5406
+ // 검사: spec에 명시된 함수 중 impl exports에 없는 것
5134
5407
  const missing = [];
5135
5408
  for (const fn of fnSpec) {
5136
- // common function name (typeof === 'function'인 export만 비교)
5137
- if (typeof exports[fn] === 'function') continue;
5409
+ if (implExports.has(fn)) continue;
5138
5410
  // spec에 'function fnName('이 있지만 impl exports에 없으면 미구현
5139
- // 단, 헬퍼 함수(internal _)는 spec에 없을 수 있으니 단방향만
5140
5411
  if (specText.includes(`function ${fn}`) && !implExports.has(fn)) missing.push(fn);
5141
5412
  }
5142
- // 필드 검증: impl 소스 파일에 spec 필드명이 있는지 grep
5143
- const implSrc = read(implFile);
5144
5413
  const fieldMissing = [];
5145
5414
  for (const f of fieldSpec) {
5146
5415
  if (!new RegExp(`\\b${f}\\b`).test(implSrc)) fieldMissing.push(f);
@@ -5182,20 +5451,28 @@ function contractVerifyCmd(specPath, implPath) {
5182
5451
  // src/*.js의 module.exports를 스캔해서 reuse-map.md에 capability 후보 등록.
5183
5452
  function reuseAutodetectCmd(root) {
5184
5453
  root = absRoot(root || process.cwd());
5185
- const srcDir = path.join(root, 'src');
5186
- if (!exists(srcDir)) { fail(`src/ 디렉토리 없음: ${srcDir}`); return process.exit(1); }
5187
- const files = fs.readdirSync(srcDir).filter(f => f.endsWith('.js'));
5454
+ // 1.9.36 BUG-fix: src/만이 아니라 bin/, lib/, app/도 스캔. require() 대신 정적 분석 (side-effect 차단).
5455
+ const candidateDirs = ['src', 'bin', 'lib', 'app'].filter(d => exists(path.join(root, d)));
5456
+ if (!candidateDirs.length) { fail(`스캔할 디렉토리 없음 (src/, bin/, lib/, app/ 중 하나 필요): ${root}`); return process.exit(1); }
5188
5457
  const found = [];
5189
- for (const f of files) {
5190
- const full = path.join(srcDir, f);
5191
- let mod;
5192
- try { delete require.cache[full]; mod = require(full); } catch { continue; }
5193
- if (!mod || typeof mod !== 'object') continue;
5194
- const exports = Object.keys(mod).filter(k => typeof mod[k] === 'function');
5195
- if (!exports.length) continue;
5196
- for (const e of exports) {
5197
- if (e.startsWith('_')) continue; // internal helpers 제외
5198
- found.push({ file: `src/${f}`, name: e });
5458
+ for (const dir of candidateDirs) {
5459
+ const files = fs.readdirSync(path.join(root, dir)).filter(f => f.endsWith('.js'));
5460
+ for (const f of files) {
5461
+ const full = path.join(root, dir, f);
5462
+ const src = read(full);
5463
+ // 정적 분석: module.exports = { foo, bar } / exports.foo = / module.exports.foo =
5464
+ const names = new Set();
5465
+ for (const m of src.matchAll(/module\.exports\s*=\s*\{([^}]+)\}/g)) {
5466
+ for (const k of m[1].split(',')) {
5467
+ const name = k.replace(/:.*/, '').trim();
5468
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
5469
+ }
5470
+ }
5471
+ for (const m of src.matchAll(/(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=/g)) names.add(m[1]);
5472
+ for (const name of names) {
5473
+ if (name.startsWith('_')) continue; // internal helpers 제외
5474
+ found.push({ file: `${dir}/${f}`, name });
5475
+ }
5199
5476
  }
5200
5477
  }
5201
5478
  if (has('--json')) {
@@ -5281,6 +5558,7 @@ async function main() {
5281
5558
  if (cmd === 'review') return reviewCmd(arg('--path', process.cwd()), args[1]);
5282
5559
  if (cmd === 'agents') return agentsCmd(arg('--path', process.cwd()), args[1], ...args.slice(2));
5283
5560
  if (cmd === 'contract' && args[1] === 'verify') return contractVerifyCmd(args[2], args[3]);
5561
+ if (cmd === 'drift' && (args[1] === 'check' || !args[1])) return driftCheckCmd(args[2] || arg('--path', process.cwd()));
5284
5562
  if (cmd === 'reuse' && args[1] === 'autodetect') return reuseAutodetectCmd(args[2] || arg('--path', process.cwd()));
5285
5563
  if (cmd === 'setup-agents' || cmd === 'setup' && args[1] === 'agents') return await setupAgentsCmd(args[1] && args[1] !== 'agents' ? args[1] : (args[2] || process.cwd()));
5286
5564
  if (cmd === 'session' && args[1] === 'close') { const r = sessionClose(args[2] || process.cwd()); viewworkEmit(args[2] || process.cwd(), { action: 'task', tool: 'session-close', note: 'session close' }); return r; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.35",
3
+ "version": "1.9.37",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -950,6 +950,133 @@ total++;
950
950
  if (!ok) { failed++; console.log(r.stdout.slice(0, 800)); }
951
951
  }
952
952
 
953
+ // 1.9.37 회귀: drift detection
954
+ total++;
955
+ {
956
+ // drift check: 신규 init 직후 (메타파일은 fresh) → healthy 또는 attention
957
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-drift-'));
958
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
959
+ const r = cp.spawnSync(process.execPath, [CLI, 'drift', 'check', tmpC], { encoding: 'utf8', timeout: 15000 });
960
+ const ok = r.status === 0
961
+ && /leerness drift check \(1\.9\.37\)/.test(r.stdout)
962
+ && /(healthy|attention|warning)/.test(r.stdout); // 막 init이라 critical은 안 됨
963
+ console.log(ok ? '✓ B(1.9.37) drift check: 신규 init → healthy/attention 등급' : `✗ drift check 실패`);
964
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
965
+ }
966
+
967
+ total++;
968
+ {
969
+ // drift check --json: 점수/신호 구조 검증
970
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-drift2-'));
971
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
972
+ // 인공적으로 progress-tracker를 옛날 날짜로 만들기 어려우니 신호 갯수만 검증
973
+ const r = cp.spawnSync(process.execPath, [CLI, 'drift', 'check', tmpC, '--json'], { encoding: 'utf8', timeout: 15000 });
974
+ let parsed = null;
975
+ try { parsed = JSON.parse(r.stdout); } catch {}
976
+ const ok = parsed
977
+ && typeof parsed.score === 'number'
978
+ && typeof parsed.level === 'string'
979
+ && Array.isArray(parsed.signals)
980
+ && parsed.signals.length >= 3; // session-handoff/current-state/progress-tracker 최소
981
+ console.log(ok ? '✓ B(1.9.37) drift check --json: 점수/레벨/신호 구조' : `✗ drift --json 실패`);
982
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
983
+ }
984
+
985
+ total++;
986
+ {
987
+ // handoff 자동 drift 경고 — 인공 stale 시뮬 (session-handoff.md의 Last generated를 옛 날짜로)
988
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-drift3-'));
989
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
990
+ // session-handoff.md에 옛 날짜 주입
991
+ const shPath = path.join(tmpC, '.harness', 'session-handoff.md');
992
+ if (fs.existsSync(shPath)) {
993
+ let body = fs.readFileSync(shPath, 'utf8');
994
+ const oldDate = new Date(Date.now() - 5 * 86400000).toISOString();
995
+ body = body.replace(/Last generated:.*/, `Last generated: ${oldDate}`);
996
+ if (!/Last generated:/.test(body)) body = `Last generated: ${oldDate}\n\n` + body;
997
+ fs.writeFileSync(shPath, body, 'utf8');
998
+ }
999
+ const r = cp.spawnSync(process.execPath, [CLI, 'handoff', tmpC, '--compact'], { encoding: 'utf8', timeout: 15000 });
1000
+ const ok = /leerness drift 감지/.test(r.stdout) && /session close/.test(r.stdout);
1001
+ console.log(ok ? '✓ B(1.9.37) handoff 자동 drift 경고: 5일 stale → 알림 표시' : `✗ handoff drift 경고 실패`);
1002
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
1003
+ }
1004
+
1005
+ total++;
1006
+ {
1007
+ // LEERNESS_NO_DRIFT_CHECK=1: 자동 경고 스킵
1008
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-drift4-'));
1009
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
1010
+ const shPath = path.join(tmpC, '.harness', 'session-handoff.md');
1011
+ if (fs.existsSync(shPath)) {
1012
+ const oldDate = new Date(Date.now() - 5 * 86400000).toISOString();
1013
+ let body = fs.readFileSync(shPath, 'utf8');
1014
+ body = body.replace(/Last generated:.*/, `Last generated: ${oldDate}`);
1015
+ if (!/Last generated:/.test(body)) body = `Last generated: ${oldDate}\n\n` + body;
1016
+ fs.writeFileSync(shPath, body, 'utf8');
1017
+ }
1018
+ const env = { ...process.env, LEERNESS_NO_DRIFT_CHECK: '1' };
1019
+ const r = cp.spawnSync(process.execPath, [CLI, 'handoff', tmpC, '--compact'], { encoding: 'utf8', timeout: 15000, env });
1020
+ const ok = !/leerness drift 감지/.test(r.stdout);
1021
+ console.log(ok ? '✓ B(1.9.37) LEERNESS_NO_DRIFT_CHECK=1: 경고 스킵' : `✗ drift skip 실패`);
1022
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 400)); }
1023
+ }
1024
+
1025
+ // 1.9.36 회귀: dispatch 권장 플래그 + bench + 작업 유형 추천
1026
+ total++;
1027
+ {
1028
+ // dispatch --write 시 gemini --yolo 자동 추가
1029
+ const env = { ...process.env, LEERNESS_ENABLE_GEMINI: '1' };
1030
+ const r = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', '코드 분석해서 요약', '--to', 'gemini', '--write'], { encoding: 'utf8', timeout: 15000, env });
1031
+ // gemini가 ready면 명령 출력에 --yolo 포함, 비-ready면 거부 — 둘 다 OK
1032
+ const ok = (r.status === 0 && /--yolo/.test(r.stdout) && /write \(파일 수정 가능\)/.test(r.stdout))
1033
+ || (r.status !== 0 && /비활성|disabled|not-installed/.test(r.stdout));
1034
+ console.log(ok ? '✓ B(1.9.36) dispatch --write: gemini --yolo 자동 첨부 또는 비활성 거부' : `✗ dispatch --write 실패`);
1035
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
1036
+ }
1037
+
1038
+ total++;
1039
+ {
1040
+ // dispatch read-only (기본) — --yolo/--dangerously 같은 위험 플래그 없음
1041
+ const env = { ...process.env, LEERNESS_ENABLE_CLAUDE: '1' };
1042
+ const r = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', '번역해줘', '--to', 'claude'], { encoding: 'utf8', timeout: 15000, env });
1043
+ // claude가 ready면 read-only 표시 + dangerously 플래그 없음
1044
+ const ok = (r.status === 0 && /read-only/.test(r.stdout) && !/--dangerously-skip-permissions/.test(r.stdout))
1045
+ || (r.status !== 0 && /비활성|disabled|not-installed/.test(r.stdout));
1046
+ console.log(ok ? '✓ B(1.9.36) dispatch read-only 기본: 위험 플래그 미첨부 또는 비활성 거부' : `✗ dispatch read-only 실패`);
1047
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
1048
+ }
1049
+
1050
+ total++;
1051
+ {
1052
+ // 작업 유형 추천 — 비활성 CLI에도 추천 메시지 우선 출력
1053
+ const env = { ...process.env, LEERNESS_ENABLE_GEMINI: '0', LEERNESS_ENABLE_CLAUDE: '0' };
1054
+ const r = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', '번역해줘 한국어를 영어로', '--to', 'gemini'], { encoding: 'utf8', timeout: 15000, env });
1055
+ // 번역 → claude 추천. ready 체크 전에 추천 출력 → stdout에 "추천...claude" 포함
1056
+ const ok = /추천.*claude/.test(r.stdout);
1057
+ console.log(ok ? '✓ B(1.9.36) 작업 유형 추천: 번역→claude 추천 (비활성이어도 출력)' : `✗ 추천 실패`);
1058
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
1059
+ }
1060
+
1061
+ total++;
1062
+ {
1063
+ // bench 명령: ready CLI 없을 때 거부
1064
+ const env = { ...process.env, LEERNESS_ENABLE_CLAUDE: '0', LEERNESS_ENABLE_CODEX: '0', LEERNESS_ENABLE_GEMINI: '0', LEERNESS_ENABLE_COPILOT: '0' };
1065
+ const r = cp.spawnSync(process.execPath, [CLI, 'agents', 'bench', 'test'], { encoding: 'utf8', timeout: 15000, env });
1066
+ const ok = r.status !== 0 && /ready CLI 없음/.test(r.stdout);
1067
+ console.log(ok ? '✓ B(1.9.36) agents bench: ready 없을 때 거부' : `✗ bench 거부 실패`);
1068
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 300)); }
1069
+ }
1070
+
1071
+ total++;
1072
+ {
1073
+ // 사용법 메시지에 bench 포함
1074
+ const r = cp.spawnSync(process.execPath, [CLI, 'agents', 'unknown'], { encoding: 'utf8', timeout: 10000 });
1075
+ const ok = r.status !== 0 && /bench/.test(r.stdout + r.stderr);
1076
+ console.log(ok ? '✓ B(1.9.36) agents 사용법에 bench 명시' : `✗ usage bench 실패`);
1077
+ if (!ok) { failed++; console.log((r.stdout + r.stderr).slice(0, 300)); }
1078
+ }
1079
+
953
1080
  // 1.9.35 회귀: 5개 신규 기능
954
1081
  total++;
955
1082
  {