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/CHANGELOG.md +69 -0
- package/README.md +245 -338
- package/bin/harness.js +296 -1
- package/package.json +1 -1
- package/scripts/e2e.js +149 -0
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.
|
|
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
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
|
{
|