leerness 1.9.36 → 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,39 @@
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
+
3
37
  ## 1.9.36 — 2026-05-18
4
38
 
5
39
  **외부 AI CLI 오케스트레이션 강화: dispatch 안전 모드 + agents bench + 작업 유형 추천 + stress test에서 발견한 2 BUG 즉시 수정**.
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.36-green)]() [![tests](https://img.shields.io/badge/e2e-166%2F166-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
 
@@ -471,6 +472,7 @@ npm test # = node ./scripts/e2e.js
471
472
 
472
473
  ## 📜 변경 이력 (최근)
473
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) 회복 실증.
474
476
  - **1.9.36** — 외부 AI CLI 오케스트레이션 강화: `agents bench` (3 CLI 동시 비교) + `dispatch --write` 자동 권장 플래그 + 작업 유형 키워드 추천. stress test에서 발견한 `contract verify` require() side-effect (보안 위험 + 25× 속도 회복) 즉시 수정.
475
477
  - **1.9.35** — 파이프라인 메타-감사에서 도출된 5개 개선 통합: `contract verify` · `reuse autodetect` · `audit --fix` · `handoff` init 부재 경고 · `agents dispatch` 안전 규칙 안내.
476
478
  - **1.9.34** — 방향키/스페이스 인터랙티브 multi-select (`_selectOne`/`_selectMany`) + 256색 그라데이션 배너 + 3단계 sub-agent 오케스트레이션 검증 (2.2× 효율 실측).
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.36';
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());
@@ -5218,6 +5250,122 @@ function viewworkInstall(root) {
5218
5250
  ok('claude .claude/settings.local.json updated (Stop hook adds a viewwork event)');
5219
5251
  }
5220
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
+
5221
5369
  // 1.9.35 개선 #3: contract verify <spec.md> <impl.js>
5222
5370
  // 사양 문서(spec.md)에 명시된 함수 이름이 실제 module.exports에 모두 있는지 검사.
5223
5371
  // 사용 예: leerness contract verify TICK_SPEC.md src/format.js
@@ -5410,6 +5558,7 @@ async function main() {
5410
5558
  if (cmd === 'review') return reviewCmd(arg('--path', process.cwd()), args[1]);
5411
5559
  if (cmd === 'agents') return agentsCmd(arg('--path', process.cwd()), args[1], ...args.slice(2));
5412
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()));
5413
5562
  if (cmd === 'reuse' && args[1] === 'autodetect') return reuseAutodetectCmd(args[2] || arg('--path', process.cwd()));
5414
5563
  if (cmd === 'setup-agents' || cmd === 'setup' && args[1] === 'agents') return await setupAgentsCmd(args[1] && args[1] !== 'agents' ? args[1] : (args[2] || process.cwd()));
5415
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.36",
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,78 @@ 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
+
953
1025
  // 1.9.36 회귀: dispatch 권장 플래그 + bench + 작업 유형 추천
954
1026
  total++;
955
1027
  {