leerness 1.9.36 → 1.9.38

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.36';
9
+ const VERSION = '1.9.38';
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,78 @@ 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
+ // 1.9.38 (A): drift 임계 시 .harness/agent-reminders.md 자동 생성 — 메인 에이전트 프롬프트에 표시되도록.
1437
+ // 1.9.38 (D): skip 횟수 학습 — --no-drift-check 빈도 ≥5 시 임계 완화 (1d → 2d).
1438
+ const absR0 = absRoot(root || process.cwd());
1439
+ if (exists(path.join(absR0, '.harness')) && process.env.LEERNESS_NO_DRIFT_CHECK !== '1') {
1440
+ // skip 카운트
1441
+ if (has('--no-drift-check')) {
1442
+ try {
1443
+ const stats = _readUsageStats(absR0);
1444
+ stats.drift = stats.drift || {};
1445
+ stats.drift.skipped = (stats.drift.skipped || 0) + 1;
1446
+ const p = _usageStatsPath(absR0);
1447
+ mkdirp(path.dirname(p));
1448
+ writeUtf8(p, JSON.stringify(stats, null, 2) + '\n');
1449
+ } catch {}
1450
+ } else {
1451
+ try {
1452
+ const isTty = process.stdout && process.stdout.isTTY;
1453
+ const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
1454
+ const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
1455
+ const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
1456
+ // 1.9.38 (D): 학습된 임계 (skip 빈도 높으면 임계 완화)
1457
+ const stats = _readUsageStats(absR0);
1458
+ const skipCount = (stats.drift && stats.drift.skipped) || 0;
1459
+ const threshold = skipCount >= 5 ? 4 : 2; // 5회 이상 끄면 2일 → 4일로 완화
1460
+ // 간이 drift 계산
1461
+ const now = Date.now();
1462
+ const shPath = handoffPath(absR0);
1463
+ let shAge = null;
1464
+ if (exists(shPath)) {
1465
+ const m = read(shPath).match(/Last generated:\s*([\d\-T:.Z]+)/);
1466
+ if (m) shAge = (now - new Date(m[1]).getTime()) / 86400000;
1467
+ }
1468
+ const rows = readProgressRows(absR0);
1469
+ let ptAge = null;
1470
+ if (rows.length) {
1471
+ const dates = rows.map(r => (r.updated || '').match(/\d{4}-\d{2}-\d{2}/)).filter(Boolean).map(m => m[0]).sort();
1472
+ if (dates.length) ptAge = (now - new Date(dates[dates.length - 1]).getTime()) / 86400000;
1473
+ }
1474
+ const sevStale = (shAge !== null && shAge > 3) || (ptAge !== null && ptAge > 3);
1475
+ if ((shAge !== null && shAge > threshold) || (ptAge !== null && ptAge > threshold)) {
1476
+ log('');
1477
+ log(yel(' ⚠ leerness drift 감지 — 메타파일이 stale합니다'));
1478
+ if (shAge !== null && shAge > threshold) log(dim(` session-handoff.md: ${shAge.toFixed(1)}일 stale`));
1479
+ if (ptAge !== null && ptAge > threshold) log(dim(` progress-tracker: ${ptAge.toFixed(1)}일 stale`));
1480
+ log(dim(` → 권장: ${red('leerness session close .')} 또는 ${red('leerness drift check .')} 로 상세 보기`));
1481
+ if (skipCount >= 5) log(dim(` (학습: skip ${skipCount}회 누적 → 임계 ${threshold}일로 완화)`));
1482
+ log('');
1483
+ // 1.9.38 (A): critical 시 .harness/agent-reminders.md 자동 생성 — 다음 세션 시작 시 메인 에이전트가 읽도록.
1484
+ if (sevStale) {
1485
+ try {
1486
+ const remPath = path.join(absR0, '.harness', 'agent-reminders.md');
1487
+ const body = `<!-- leerness:managed:auto -->\n# 🔔 메인 에이전트용 자동 reminder\n\n_생성: ${new Date().toISOString()}_\n\n## drift critical 감지\n현재 워크스페이스의 메타파일이 매우 stale합니다. 이번 라운드 작업 끝에 반드시 다음 명령을 호출하세요:\n\n\`\`\`bash\nleerness session close .\n\`\`\`\n\n또는 상세 점검:\n\`\`\`bash\nleerness drift check .\n\`\`\`\n\nstale 신호:\n${shAge !== null ? `- session-handoff.md: ${shAge.toFixed(1)}일 stale\n` : ''}${ptAge !== null ? `- progress-tracker: ${ptAge.toFixed(1)}일 stale\n` : ''}\n\n_이 파일은 leerness 1.9.38+가 자동 갱신합니다. session close 후 자동 삭제.\n_사용자가 이 파일을 보고 메인 에이전트에 reminder 전달 가능._\n`;
1488
+ writeUtf8(remPath, body);
1489
+ } catch {}
1490
+ } else {
1491
+ // attention 등급으로 회복했으면 reminder 파일 삭제
1492
+ try {
1493
+ const remPath = path.join(absR0, '.harness', 'agent-reminders.md');
1494
+ if (exists(remPath)) fs.unlinkSync(remPath);
1495
+ } catch {}
1496
+ }
1497
+ } else {
1498
+ // healthy → reminder 파일 자동 청소
1499
+ try {
1500
+ const remPath = path.join(absR0, '.harness', 'agent-reminders.md');
1501
+ if (exists(remPath)) fs.unlinkSync(remPath);
1502
+ } catch {}
1503
+ }
1504
+ } catch {}
1505
+ }
1506
+ }
1435
1507
  // 1.9.35 개선 #1: .harness 부재 시 즉시 경고 (자동 init 권장)
1436
1508
  // 사용자가 신규 디렉토리에서 handoff 호출 시 sub-agent 작업이 길을 잃지 않도록.
1437
1509
  const absR = absRoot(root || process.cwd());
@@ -5218,6 +5290,219 @@ function viewworkInstall(root) {
5218
5290
  ok('claude .claude/settings.local.json updated (Stop hook adds a viewwork event)');
5219
5291
  }
5220
5292
 
5293
+ // 1.9.37: drift detection — 메타파일 staleness 측정으로 "leerness 점점 안 쓰는" 현상 감지
5294
+ function driftCheckCmd(root, opts = {}) {
5295
+ root = absRoot(root || process.cwd());
5296
+ const now = Date.now();
5297
+ const _ageDays = (p) => {
5298
+ if (!exists(p)) return null;
5299
+ return (now - fs.statSync(p).mtimeMs) / 86400000;
5300
+ };
5301
+ // 각 메타파일의 마지막 갱신
5302
+ const signals = [];
5303
+ // 1. session-handoff.md - "Last generated" 라인 우선, 없으면 mtime
5304
+ const shPath = handoffPath(root);
5305
+ if (exists(shPath)) {
5306
+ const txt = read(shPath);
5307
+ const m = txt.match(/Last generated:\s*([\d\-T:.Z]+)/);
5308
+ let ageDays;
5309
+ if (m) {
5310
+ ageDays = (now - new Date(m[1]).getTime()) / 86400000;
5311
+ } else {
5312
+ ageDays = _ageDays(shPath);
5313
+ }
5314
+ signals.push({ file: 'session-handoff.md', ageDays, threshold: 1, weight: 30, label: 'session close 누락' });
5315
+ }
5316
+ // 2. current-state.md - "Updated: YYYY-MM-DD" 라인
5317
+ const csPath = currentStatePath(root);
5318
+ if (exists(csPath)) {
5319
+ const m = read(csPath).match(/Updated:\s*(\d{4}-\d{2}-\d{2})/);
5320
+ const ageDays = m ? (now - new Date(m[1]).getTime()) / 86400000 : _ageDays(csPath);
5321
+ signals.push({ file: 'current-state.md', ageDays, threshold: 2, weight: 20, label: 'current-state 갱신 없음' });
5322
+ }
5323
+ // 3. progress-tracker.md 마지막 row의 updated 컬럼
5324
+ const rows = readProgressRows(root);
5325
+ if (rows.length) {
5326
+ const dates = rows.map(r => (r.updated || '').match(/\d{4}-\d{2}-\d{2}/)).filter(Boolean).map(m => m[0]);
5327
+ if (dates.length) {
5328
+ dates.sort();
5329
+ const latest = dates[dates.length - 1];
5330
+ const ageDays = (now - new Date(latest).getTime()) / 86400000;
5331
+ signals.push({ file: 'progress-tracker.md', ageDays, threshold: 1, weight: 30, label: 'task update 없음' });
5332
+ }
5333
+ } else {
5334
+ signals.push({ file: 'progress-tracker.md', ageDays: 999, threshold: 1, weight: 25, label: 'progress-tracker 비어있음' });
5335
+ }
5336
+ // 4. task-log.md 마지막 entry "## YYYY-MM-DD"
5337
+ const tlPath = taskLogPath(root);
5338
+ if (exists(tlPath)) {
5339
+ const dates = Array.from(read(tlPath).matchAll(/^## (\d{4}-\d{2}-\d{2})/gm)).map(m => m[1]);
5340
+ if (dates.length) {
5341
+ dates.sort();
5342
+ const latest = dates[dates.length - 1];
5343
+ const ageDays = (now - new Date(latest).getTime()) / 86400000;
5344
+ signals.push({ file: 'task-log.md', ageDays, threshold: 2, weight: 20, label: 'task-log 갱신 없음' });
5345
+ }
5346
+ }
5347
+ // 점수 계산
5348
+ let totalScore = 0;
5349
+ const fired = [];
5350
+ for (const s of signals) {
5351
+ if (s.ageDays > s.threshold) {
5352
+ totalScore += s.weight;
5353
+ fired.push(s);
5354
+ }
5355
+ }
5356
+ // 신규 _apps/* 에서 task 0건도 신호로
5357
+ const appsDir = path.join(root, '_apps');
5358
+ let appsZeroTask = [];
5359
+ if (exists(appsDir)) {
5360
+ for (const d of fs.readdirSync(appsDir)) {
5361
+ const sub = path.join(appsDir, d);
5362
+ if (!exists(path.join(sub, '.harness'))) continue;
5363
+ const subRows = readProgressRows(sub);
5364
+ if (!subRows.length) appsZeroTask.push(d);
5365
+ }
5366
+ if (appsZeroTask.length) {
5367
+ const w = Math.min(50, appsZeroTask.length * 10);
5368
+ totalScore += w;
5369
+ 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 ? '...' : ''}` });
5370
+ }
5371
+ }
5372
+ // 레벨 판정
5373
+ let level = '🟢 healthy';
5374
+ if (totalScore >= 100) level = '🔴 critical';
5375
+ else if (totalScore >= 50) level = '🟡 warning';
5376
+ else if (totalScore >= 20) level = '🟠 attention';
5377
+
5378
+ // 1.9.38 (D): drift critical 등급은 누적 카운트 (학습 신호)
5379
+ try {
5380
+ if (level === '🔴 critical') {
5381
+ const stats = _readUsageStats(root);
5382
+ stats.drift = stats.drift || {};
5383
+ stats.drift.criticalSeen = (stats.drift.criticalSeen || 0) + 1;
5384
+ const p = _usageStatsPath(root);
5385
+ mkdirp(path.dirname(p));
5386
+ writeUtf8(p, JSON.stringify(stats, null, 2) + '\n');
5387
+ }
5388
+ } catch {}
5389
+ if (has('--json')) {
5390
+ log(JSON.stringify({ root, score: totalScore, level, signals, fired, appsZeroTask }, null, 2));
5391
+ return;
5392
+ }
5393
+ log(`# leerness drift check (1.9.37)`);
5394
+ log(`경로: ${root}`);
5395
+ log('');
5396
+ log(`상태: ${level} · 점수 ${totalScore}/200`);
5397
+ log('');
5398
+ log(`| 신호 | age | 임계 | 가중치 | 발화 |`);
5399
+ log(`|---|---:|---:|---:|---|`);
5400
+ for (const s of signals) {
5401
+ const fire = s.ageDays > s.threshold ? '🔥' : '✓';
5402
+ const age = s.ageDays === null ? '-' : `${s.ageDays.toFixed(1)}d`;
5403
+ log(`| ${s.label} | ${age} | ${s.threshold}d | ${s.weight} | ${fire} |`);
5404
+ }
5405
+ if (appsZeroTask.length) {
5406
+ log('');
5407
+ log(`task 0건 sub-app (${appsZeroTask.length}개): ${appsZeroTask.join(', ')}`);
5408
+ }
5409
+ if (totalScore >= 50) {
5410
+ log('');
5411
+ log(`💡 권장 조치:`);
5412
+ log(` - 즉시: leerness session close . (handoff/current-state 갱신)`);
5413
+ log(` - 또는: leerness audit . --fix (자동 갱신 가능 항목 적용)`);
5414
+ log(` - sub-app에 task 등록: cd _apps/X && leerness task add "..."`);
5415
+ log(` - 이 검사 끄기: --no-drift-check 또는 LEERNESS_NO_DRIFT_CHECK=1`);
5416
+ }
5417
+ if (level === '🔴 critical') process.exitCode = 1;
5418
+ }
5419
+
5420
+ // 1.9.38: 사용 통계 (cumulative count, command별)
5421
+ function _usageStatsPath(root) { return path.join(absRoot(root), '.harness', 'cache', 'usage-stats.json'); }
5422
+ function _readUsageStats(root) {
5423
+ const p = _usageStatsPath(root);
5424
+ if (!exists(p)) return { commands: {}, drift: { criticalSeen: 0, skipped: 0, autoResolved: 0 }, since: today() };
5425
+ try { return JSON.parse(read(p)); } catch { return { commands: {}, drift: {}, since: today() }; }
5426
+ }
5427
+ function _bumpUsage(root, cmdName) {
5428
+ // 가벼운 카운터 — 명령 실행마다 호출 (sync write로 작은 파일)
5429
+ try {
5430
+ const stats = _readUsageStats(root);
5431
+ if (!stats.commands) stats.commands = {};
5432
+ stats.commands[cmdName] = (stats.commands[cmdName] || 0) + 1;
5433
+ stats.lastCommand = cmdName;
5434
+ stats.lastAt = new Date().toISOString();
5435
+ if (!stats.since) stats.since = today();
5436
+ const p = _usageStatsPath(root);
5437
+ mkdirp(path.dirname(p));
5438
+ writeUtf8(p, JSON.stringify(stats, null, 2) + '\n');
5439
+ } catch {}
5440
+ }
5441
+
5442
+ function usageStatsCmd(root) {
5443
+ root = absRoot(root || process.cwd());
5444
+ const stats = _readUsageStats(root);
5445
+ if (has('--json')) { log(JSON.stringify(stats, null, 2)); return; }
5446
+ log(`# leerness usage stats (1.9.38)`);
5447
+ log(`since: ${stats.since || '(unknown)'} · last: ${stats.lastAt || '(none)'}`);
5448
+ log('');
5449
+ const entries = Object.entries(stats.commands || {}).sort((a, b) => b[1] - a[1]);
5450
+ if (!entries.length) {
5451
+ log(' (사용 기록 없음)');
5452
+ return;
5453
+ }
5454
+ log(`| 명령 | 호출 수 |`);
5455
+ log(`|---|---:|`);
5456
+ for (const [cmd, n] of entries.slice(0, 30)) log(`| ${cmd} | ${n} |`);
5457
+ const total = entries.reduce((s, [, n]) => s + n, 0);
5458
+ log('');
5459
+ log(`총 ${total} 회 호출 · 종류 ${entries.length} 가지`);
5460
+ if (stats.drift) {
5461
+ log('');
5462
+ log(`drift 통계: critical 발견 ${stats.drift.criticalSeen || 0} · skip ${stats.drift.skipped || 0} · 자동 해소 ${stats.drift.autoResolved || 0}`);
5463
+ if ((stats.drift.skipped || 0) > 5) {
5464
+ log(`💡 drift 경고 ${stats.drift.skipped}회 스킵 → 1.9.38 학습: 임계 자동 완화 (--no-drift-check 빈도 ≥5)`);
5465
+ }
5466
+ }
5467
+ }
5468
+
5469
+ // 1.9.38: task sync — TodoWrite/외부 JSON에서 leerness task로 mirror
5470
+ function taskSyncCmd(root) {
5471
+ root = absRoot(root || process.cwd());
5472
+ const file = arg('--from', null);
5473
+ if (!file) {
5474
+ fail('사용법: leerness task sync --from <todo.json>\n 파일 형식: [{"content":"...","status":"completed|in_progress|pending","activeForm":"..."}]');
5475
+ return process.exit(1);
5476
+ }
5477
+ const full = path.resolve(file);
5478
+ if (!exists(full)) { fail(`파일 없음: ${full}`); return process.exit(1); }
5479
+ let todos;
5480
+ try { todos = JSON.parse(read(full)); }
5481
+ catch (e) { fail(`JSON 파싱 실패: ${e.message}`); return process.exit(1); }
5482
+ if (!Array.isArray(todos)) { fail('JSON 최상위는 배열이어야 함'); return process.exit(1); }
5483
+ let imported = 0, updated = 0;
5484
+ for (const t of todos) {
5485
+ if (!t || !t.content) continue;
5486
+ const status = t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'in-progress' : 'planned';
5487
+ // 이미 같은 request 있는지
5488
+ const existing = readProgressRows(root).find(r => r.request === t.content);
5489
+ if (existing) {
5490
+ if (existing.status !== status) {
5491
+ upsertProgress(root, { id: existing.id, status });
5492
+ updated++;
5493
+ }
5494
+ } else {
5495
+ const id = nextId(root, 'T');
5496
+ upsertProgress(root, { id, status, request: t.content, evidence: 'todowrite-sync', nextAction: t.activeForm || '다음 액션' });
5497
+ imported++;
5498
+ }
5499
+ }
5500
+ log(`# leerness task sync (1.9.38)`);
5501
+ log(`from: ${full}`);
5502
+ log(`imported: ${imported} · updated: ${updated} · total in source: ${todos.length}`);
5503
+ if (has('--json')) log(JSON.stringify({ imported, updated, total: todos.length }, null, 2));
5504
+ }
5505
+
5221
5506
  // 1.9.35 개선 #3: contract verify <spec.md> <impl.js>
5222
5507
  // 사양 문서(spec.md)에 명시된 함수 이름이 실제 module.exports에 모두 있는지 검사.
5223
5508
  // 사용 예: leerness contract verify TICK_SPEC.md src/format.js
@@ -5385,6 +5670,13 @@ async function main() {
5385
5670
  return log(VERSION);
5386
5671
  }
5387
5672
  if (has('--help') || has('-h')) return help();
5673
+ // 1.9.38 (B): 사용 통계 카운터 — usage stats 명령 자체와 비차단 경로는 제외
5674
+ if (cmd !== 'usage' && cmd !== 'init' && cmd !== 'migrate' && cmd !== '--version' && cmd !== '--help') {
5675
+ try {
5676
+ const root = absRoot(arg('--path', args[1] && !args[1].startsWith('-') ? args[1] : process.cwd()));
5677
+ if (exists(path.join(root, '.harness'))) _bumpUsage(root, cmd);
5678
+ } catch {}
5679
+ }
5388
5680
  if (cmd === 'init') return await install(args[1] || process.cwd(), { force:false, dry:false, migration:false });
5389
5681
  if (cmd === 'migrate') return await install(args[1] || process.cwd(), { force:has('--force'), dry:has('--dry-run'), migration:true });
5390
5682
  if (cmd === 'update') return await updateCmd(args[1] || process.cwd(), { checkOnly: has('--check'), yes: has('--yes'), force: has('--force') });
@@ -5410,6 +5702,8 @@ async function main() {
5410
5702
  if (cmd === 'review') return reviewCmd(arg('--path', process.cwd()), args[1]);
5411
5703
  if (cmd === 'agents') return agentsCmd(arg('--path', process.cwd()), args[1], ...args.slice(2));
5412
5704
  if (cmd === 'contract' && args[1] === 'verify') return contractVerifyCmd(args[2], args[3]);
5705
+ if (cmd === 'drift' && (args[1] === 'check' || !args[1])) return driftCheckCmd(args[2] || arg('--path', process.cwd()));
5706
+ if (cmd === 'usage' && (args[1] === 'stats' || !args[1])) return usageStatsCmd(args[2] || arg('--path', process.cwd()));
5413
5707
  if (cmd === 'reuse' && args[1] === 'autodetect') return reuseAutodetectCmd(args[2] || arg('--path', process.cwd()));
5414
5708
  if (cmd === 'setup-agents' || cmd === 'setup' && args[1] === 'agents') return await setupAgentsCmd(args[1] && args[1] !== 'agents' ? args[1] : (args[2] || process.cwd()));
5415
5709
  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; }
@@ -5474,6 +5768,7 @@ async function main() {
5474
5768
  if (sub==='drop') return taskDrop(root, args[2]);
5475
5769
  if (sub==='fix-evidence') return taskFixEvidence(root);
5476
5770
  if (sub==='relink') return taskRelink(root);
5771
+ if (sub==='sync') return taskSyncCmd(root);
5477
5772
  }
5478
5773
  return help();
5479
5774
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.36",
3
+ "version": "1.9.38",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -950,6 +950,155 @@ total++;
950
950
  if (!ok) { failed++; console.log(r.stdout.slice(0, 800)); }
951
951
  }
952
952
 
953
+ // 1.9.38 회귀: usage stats, task sync, drift reminder, drift skip learning
954
+ total++;
955
+ {
956
+ // B. usage stats: 빈 상태 + 호출 후 카운터 증가
957
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-usage-'));
958
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
959
+ // 카운터 자극: status, handoff 호출
960
+ cp.spawnSync(process.execPath, [CLI, 'status', tmpC], { stdio: 'ignore', timeout: 10000 });
961
+ cp.spawnSync(process.execPath, [CLI, 'handoff', tmpC, '--compact', '--no-drift-check'], { stdio: 'ignore', timeout: 10000 });
962
+ const r = cp.spawnSync(process.execPath, [CLI, 'usage', 'stats', tmpC, '--json'], { encoding: 'utf8', timeout: 10000 });
963
+ let parsed = null;
964
+ try { parsed = JSON.parse(r.stdout); } catch {}
965
+ const ok = parsed
966
+ && parsed.commands
967
+ && (parsed.commands.status >= 1 || parsed.commands.handoff >= 1)
968
+ && parsed.drift
969
+ && typeof parsed.drift.skipped === 'number';
970
+ console.log(ok ? '✓ B(1.9.38) usage stats: 명령 카운터 누적 + drift 통계 구조' : `✗ usage stats 실패`);
971
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 400)); }
972
+ }
973
+
974
+ total++;
975
+ {
976
+ // C. task sync — TodoWrite JSON 임포트
977
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-tasksync-'));
978
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
979
+ const todoFile = path.join(tmpC, 'todo.json');
980
+ fs.writeFileSync(todoFile, JSON.stringify([
981
+ { content: 'sync 테스트 작업 A', status: 'completed', activeForm: 'syncA' },
982
+ { content: 'sync 테스트 작업 B', status: 'in_progress', activeForm: 'syncB' },
983
+ { content: 'sync 테스트 작업 C', status: 'pending', activeForm: 'syncC' }
984
+ ]), 'utf8');
985
+ const r = cp.spawnSync(process.execPath, [CLI, 'task', 'sync', '--from', todoFile, '--path', tmpC, '--json'], { encoding: 'utf8', timeout: 15000 });
986
+ let parsed = null;
987
+ try { parsed = JSON.parse(r.stdout.split('\n').filter(l => l.startsWith('{')).pop() || '{}'); } catch {}
988
+ const ok = r.status === 0 && /imported: 3/.test(r.stdout);
989
+ console.log(ok ? '✓ B(1.9.38) task sync: 3개 TodoWrite → progress-tracker import' : `✗ task sync 실패`);
990
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 400)); }
991
+ }
992
+
993
+ total++;
994
+ {
995
+ // A. drift reminder 파일 자동 생성 (인공 stale 시뮬)
996
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rem-'));
997
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
998
+ // session-handoff.md를 5일 전으로
999
+ const shPath = path.join(tmpC, '.harness', 'session-handoff.md');
1000
+ if (fs.existsSync(shPath)) {
1001
+ const oldDate = new Date(Date.now() - 5 * 86400000).toISOString();
1002
+ let body = fs.readFileSync(shPath, 'utf8');
1003
+ body = body.replace(/Last generated:.*/, `Last generated: ${oldDate}`);
1004
+ if (!/Last generated:/.test(body)) body = `Last generated: ${oldDate}\n` + body;
1005
+ fs.writeFileSync(shPath, body, 'utf8');
1006
+ }
1007
+ // handoff 호출 → reminder 자동 생성
1008
+ cp.spawnSync(process.execPath, [CLI, 'handoff', tmpC, '--compact'], { encoding: 'utf8', timeout: 10000 });
1009
+ const remPath = path.join(tmpC, '.harness', 'agent-reminders.md');
1010
+ const ok = fs.existsSync(remPath) && /drift critical/.test(fs.readFileSync(remPath, 'utf8'));
1011
+ console.log(ok ? '✓ B(1.9.38) drift critical → agent-reminders.md 자동 생성' : `✗ reminder 파일 실패`);
1012
+ if (!ok) { failed++; if (fs.existsSync(remPath)) console.log(fs.readFileSync(remPath, 'utf8').slice(0, 400)); else console.log('(reminder 파일 없음)'); }
1013
+ }
1014
+
1015
+ total++;
1016
+ {
1017
+ // D. drift 학습 — --no-drift-check 5회 호출 후 임계 완화
1018
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-learn-'));
1019
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
1020
+ // 5회 --no-drift-check 호출
1021
+ for (let i = 0; i < 5; i++) {
1022
+ cp.spawnSync(process.execPath, [CLI, 'handoff', tmpC, '--compact', '--no-drift-check'], { stdio: 'ignore', timeout: 10000 });
1023
+ }
1024
+ const stats = JSON.parse(fs.readFileSync(path.join(tmpC, '.harness', 'cache', 'usage-stats.json'), 'utf8'));
1025
+ const ok = stats.drift && stats.drift.skipped >= 5;
1026
+ console.log(ok ? '✓ B(1.9.38) drift 학습: --no-drift-check 5회 누적 (skipped≥5)' : `✗ drift 학습 실패`);
1027
+ if (!ok) { failed++; console.log(JSON.stringify(stats.drift || {})); }
1028
+ }
1029
+
1030
+ // 1.9.37 회귀: drift detection
1031
+ total++;
1032
+ {
1033
+ // drift check: 신규 init 직후 (메타파일은 fresh) → healthy 또는 attention
1034
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-drift-'));
1035
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
1036
+ const r = cp.spawnSync(process.execPath, [CLI, 'drift', 'check', tmpC], { encoding: 'utf8', timeout: 15000 });
1037
+ const ok = r.status === 0
1038
+ && /leerness drift check \(1\.9\.37\)/.test(r.stdout)
1039
+ && /(healthy|attention|warning)/.test(r.stdout); // 막 init이라 critical은 안 됨
1040
+ console.log(ok ? '✓ B(1.9.37) drift check: 신규 init → healthy/attention 등급' : `✗ drift check 실패`);
1041
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
1042
+ }
1043
+
1044
+ total++;
1045
+ {
1046
+ // drift check --json: 점수/신호 구조 검증
1047
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-drift2-'));
1048
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
1049
+ // 인공적으로 progress-tracker를 옛날 날짜로 만들기 어려우니 신호 갯수만 검증
1050
+ const r = cp.spawnSync(process.execPath, [CLI, 'drift', 'check', tmpC, '--json'], { encoding: 'utf8', timeout: 15000 });
1051
+ let parsed = null;
1052
+ try { parsed = JSON.parse(r.stdout); } catch {}
1053
+ const ok = parsed
1054
+ && typeof parsed.score === 'number'
1055
+ && typeof parsed.level === 'string'
1056
+ && Array.isArray(parsed.signals)
1057
+ && parsed.signals.length >= 3; // session-handoff/current-state/progress-tracker 최소
1058
+ console.log(ok ? '✓ B(1.9.37) drift check --json: 점수/레벨/신호 구조' : `✗ drift --json 실패`);
1059
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
1060
+ }
1061
+
1062
+ total++;
1063
+ {
1064
+ // handoff 자동 drift 경고 — 인공 stale 시뮬 (session-handoff.md의 Last generated를 옛 날짜로)
1065
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-drift3-'));
1066
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
1067
+ // session-handoff.md에 옛 날짜 주입
1068
+ const shPath = path.join(tmpC, '.harness', 'session-handoff.md');
1069
+ if (fs.existsSync(shPath)) {
1070
+ let body = fs.readFileSync(shPath, 'utf8');
1071
+ const oldDate = new Date(Date.now() - 5 * 86400000).toISOString();
1072
+ body = body.replace(/Last generated:.*/, `Last generated: ${oldDate}`);
1073
+ if (!/Last generated:/.test(body)) body = `Last generated: ${oldDate}\n\n` + body;
1074
+ fs.writeFileSync(shPath, body, 'utf8');
1075
+ }
1076
+ const r = cp.spawnSync(process.execPath, [CLI, 'handoff', tmpC, '--compact'], { encoding: 'utf8', timeout: 15000 });
1077
+ const ok = /leerness drift 감지/.test(r.stdout) && /session close/.test(r.stdout);
1078
+ console.log(ok ? '✓ B(1.9.37) handoff 자동 drift 경고: 5일 stale → 알림 표시' : `✗ handoff drift 경고 실패`);
1079
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
1080
+ }
1081
+
1082
+ total++;
1083
+ {
1084
+ // LEERNESS_NO_DRIFT_CHECK=1: 자동 경고 스킵
1085
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-drift4-'));
1086
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
1087
+ const shPath = path.join(tmpC, '.harness', 'session-handoff.md');
1088
+ if (fs.existsSync(shPath)) {
1089
+ const oldDate = new Date(Date.now() - 5 * 86400000).toISOString();
1090
+ let body = fs.readFileSync(shPath, 'utf8');
1091
+ body = body.replace(/Last generated:.*/, `Last generated: ${oldDate}`);
1092
+ if (!/Last generated:/.test(body)) body = `Last generated: ${oldDate}\n\n` + body;
1093
+ fs.writeFileSync(shPath, body, 'utf8');
1094
+ }
1095
+ const env = { ...process.env, LEERNESS_NO_DRIFT_CHECK: '1' };
1096
+ const r = cp.spawnSync(process.execPath, [CLI, 'handoff', tmpC, '--compact'], { encoding: 'utf8', timeout: 15000, env });
1097
+ const ok = !/leerness drift 감지/.test(r.stdout);
1098
+ console.log(ok ? '✓ B(1.9.37) LEERNESS_NO_DRIFT_CHECK=1: 경고 스킵' : `✗ drift skip 실패`);
1099
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 400)); }
1100
+ }
1101
+
953
1102
  // 1.9.36 회귀: dispatch 권장 플래그 + bench + 작업 유형 추천
954
1103
  total++;
955
1104
  {