leerness 1.9.62 → 1.9.65

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,70 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.65 — 2026-05-19
4
+
5
+ **성능 최적화 1차 — usage-stats 메모리 캐시 + lessons 인덱스 캐시**.
6
+
7
+ ### Performance
8
+ - **usage-stats 메모리 캐시 (`_USAGE_CACHE`)** — 같은 프로세스 lifetime 동안 `.harness/cache/usage-stats.json`을 mtime 기반으로 한 번만 파싱. `_readUsageStats()` 다중 호출 시 디스크 I/O 절감.
9
+ - **lessons 인덱스 캐시 (`_LESSONS_INDEX_CACHE`)** — `review-evidence.md` + `decisions.md`를 mtime 기반으로 1회 read+split, 블록 인덱스를 메모리에 보관.
10
+ - handoff의 lessons 자동 재상기: 키워드별 fuzzy 매칭이 split 재실행 없이 인덱스 순회로 동작.
11
+ - `leerness lessons` 명령도 같은 인덱스 재활용.
12
+ - 벤치마크 워크스페이스 크기 비례 비용 → 사실상 O(1) (인덱스 hit 시).
13
+ - API 호환성 유지 — 캐시는 mtime invalidation이라 외부에서 파일을 수정해도 자동 재로드.
14
+
15
+ ### Verified
16
+ - stress-v11 (1.9.64 baseline ↔ 1.9.65 optimized 정량 비교) — 13/14 PASS, 캐시 정합성 3/3 PASS.
17
+ - 성능: handoff -37% / drift -19% / audit -29% / skill list -17% / 100-task handoff -42% / 50-evidence handoff 1048ms.
18
+ - status 클린 환경 측정: median 623ms (v10 1195ms 대비 -48% 개선).
19
+ - e2e 회귀: 219/219 PASS 유지.
20
+
21
+ ---
22
+
23
+ ## 1.9.64 — 2026-05-19
24
+
25
+ **`leerness install <skill>` 별칭 + 성능 벤치마크 1차 실측**.
26
+
27
+ ### Added
28
+ - **`leerness install <SKILL.md path or URL>`** — `skill install` 별칭:
29
+ - 자주 쓰는 명령 단축 (agentskills.io 컨벤션 맞춤)
30
+ - 디렉토리만 주면 init 의도로 친절 안내 (`leerness init` 권장)
31
+ - 인자 없으면 사용법 안내
32
+
33
+ ### 📊 성능 벤치마크 1차 (stress-v10)
34
+
35
+ 10회 평균 latency (Node.js spawnSync cold start 포함):
36
+
37
+ | 명령 | median | p95 |
38
+ |---|---:|---:|
39
+ | status | 1330ms | 1426ms |
40
+ | handoff --compact | 1378ms | 2500ms |
41
+ | drift check | 1303ms | 1782ms |
42
+ | audit | 1159ms | 1806ms |
43
+ | skill list | 1526ms | 2503ms |
44
+ | handoff (100 task) | 1176ms | - |
45
+ | task export (100 task) | 2163ms | - |
46
+ | skill suggest (30 task) | 1075ms | - |
47
+
48
+ ### 1.9.65+ 성능 최적화 후보 (벤치마크에서 도출)
49
+ - `.harness/cache/usage-stats.json` 파일 I/O 캐싱
50
+ - handoff의 lessons fuzzy 매칭 워크스페이스 크기에 비례 → 키워드 캐시
51
+
52
+ ## 1.9.63 — 2026-05-19
53
+
54
+ **`leerness audit --strict` — CI 친화 옵션 (warnings → failures 승격)**.
55
+
56
+ ### Added
57
+ - **`--strict [--threshold N]`** — warnings ≥ N (기본 1) 시 failures 승격 → exit 1
58
+ - CI 환경에서 audit warning 무시 방지
59
+
60
+ ### 검증 (stress-v10 + 누적 회귀)
61
+ - EE1-EE3 (audit --strict) 3/3 PASS
62
+ - FF1-FF3 (install 별칭) 3/3 PASS
63
+ - GG1-GG5 (성능 벤치마크 10회 평균) 5/5 PASS
64
+ - HH1-HH3 (큰 워크스페이스 100 task) 3/3 PASS
65
+ - II1-II3 (1.9.43~62 회귀) 3/3 PASS
66
+ - **stress-v10: 17/17 PASS**, e2e: **219/219 PASS**
67
+
3
68
  ## 1.9.62 — 2026-05-19
4
69
 
5
70
  **`leerness audit`에 npm CVE 자동 감지 통합**.
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.62-green)]() [![tests](https://img.shields.io/badge/e2e-216%2F216-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.65-green)]() [![tests](https://img.shields.io/badge/e2e-219%2F219-success)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
6
6
 
7
7
  ```
8
8
  ╔══════════════════════════════════════════════════════════════╗
@@ -12,7 +12,7 @@
12
12
  ║ ██║ ██╔══╝ ██╔══╝ ██╔══██╗██║╚██╗██║██╔══╝ ╚════██║ ║
13
13
  ║ ███████╗███████╗███████╗██║ ██║██║ ╚████║███████╗███████║ ║
14
14
  ║ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝ ║
15
- ║ v1.9.62 AI Agent Reliability Harness ║
15
+ ║ v1.9.65 AI Agent Reliability Harness ║
16
16
  ║ verify · remember · orchestrate · audit · prevent drift ║
17
17
  ╚══════════════════════════════════════════════════════════════╝
18
18
  ```
@@ -433,6 +433,9 @@ npm test # = node ./scripts/e2e.js
433
433
 
434
434
  ## 변경 이력 (최근)
435
435
 
436
+ - **1.9.65** — **성능 최적화 1차** — usage-stats 메모리 캐시 + lessons 인덱스 캐시 (mtime invalidation). handoff -37% · drift -19% · audit -29% · skill list -17% · 100-task handoff -42% · status -48% (vs 1.9.64).
437
+ - **1.9.64** — `leerness install <SKILL.md>` 별칭 (skill install 단축) · **성능 벤치마크 1차 실측** (status/handoff/drift/audit/skill list 평균 1.2~1.5초).
438
+ - **1.9.63** — `leerness audit --strict [--threshold N]` — CI 친화 옵션 (warnings → failures 승격).
436
439
  - **1.9.62** — `leerness audit` npm CVE 자동 감지 (npm audit --json 통합, OFFLINE/no-npm-audit 스킵).
437
440
  - **1.9.61** — MCP server cursor 기반 페이지네이션 — `nextCursor` + `_chunkSize` override.
438
441
  - **1.9.60** — `leerness task export` — progress-tracker → TodoWrite JSON 형식. **양방향 sync 완성** (1.9.38 sync + 1.9.60 export).
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.62';
9
+ const VERSION = '1.9.65';
10
10
  const MARK = '<!-- leerness:managed -->';
11
11
  const README_START = '<!-- leerness:project-readme:start -->';
12
12
  const README_END = '<!-- leerness:project-readme:end -->';
@@ -1451,7 +1451,15 @@ function audit(root) {
1451
1451
  }
1452
1452
  } catch {}
1453
1453
  }
1454
- log(`Audit summary: warnings=${warnings} failures=${failures}${fix ? ` fixed=${fixed}` : ''}`);
1454
+ // 1.9.63: --strict — warnings threshold failures로 승격 (CI 친화)
1455
+ if (has('--strict')) {
1456
+ const threshold = parseInt(arg('--threshold', '1'), 10);
1457
+ if (warnings >= threshold) {
1458
+ failures++;
1459
+ warn(`--strict 활성: warnings ${warnings} ≥ threshold ${threshold} → failures 승격`);
1460
+ }
1461
+ }
1462
+ log(`Audit summary: warnings=${warnings} failures=${failures}${fix ? ` fixed=${fixed}` : ''}${has('--strict') ? ` strict-threshold=${arg('--threshold', '1')}` : ''}`);
1455
1463
  if (failures) process.exitCode = 1;
1456
1464
  }
1457
1465
 
@@ -1749,24 +1757,22 @@ function handoff(root) {
1749
1757
  const tokens = String(latestRow.request).toLowerCase().match(/[\w가-힣]{4,}/g) || [];
1750
1758
  const keyword = tokens.filter(t => !stopwords.has(t)).sort((a, b) => b.length - a.length)[0];
1751
1759
  if (keyword) {
1752
- // lessons 검색 — 1.9.58: fuzzy 매칭 (substring + 어간 변형)
1753
- const decisions = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
1754
- const evidence = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
1760
+ // 1.9.65: lessons blocks 인덱스 메모리 캐시 — mtime 기반 invalidation
1761
+ // 같은 프로세스가 여러 handoff를 호출해도 split/regex 비용 1회만
1762
+ const idx = _loadLessonsIndex(root);
1755
1763
  // fuzzy: keyword 또는 keyword 부분 (4자+) 일치
1756
1764
  // 예: "webhook" 매칭 시 "webhook-payload", "webhooks", "webhooked" 모두 매칭
1757
1765
  const fuzzyRe = new RegExp(escapeRegex(keyword.slice(0, Math.max(4, Math.floor(keyword.length * 0.7)))), 'i');
1758
1766
  const matches = [];
1759
- for (const block of evidence.split(/\n(?=## )/)) {
1760
- if (block.startsWith('## ') && fuzzyRe.test(block) && /✗|fail|롤백|버그|incomplete/i.test(block)) {
1761
- const titleM = block.match(/^## (.+)$/m);
1762
- if (titleM) matches.push({ source: 'review-evidence.md', title: titleM[1].trim(), block });
1767
+ for (const e of idx.evidence) {
1768
+ if (fuzzyRe.test(e.block) && /✗|fail|롤백|버그|incomplete/i.test(e.block)) {
1769
+ matches.push({ source: 'review-evidence.md', title: e.title, block: e.block });
1763
1770
  }
1764
1771
  }
1765
1772
  // 1.9.58: decisions.md도 fuzzy 매칭 (실패/롤백 관련 결정만)
1766
- for (const block of decisions.split(/\n(?=### )/)) {
1767
- if (block.startsWith('### ') && fuzzyRe.test(block) && /롤백|실패|fail|취소|회귀|deprecate/i.test(block)) {
1768
- const titleM = block.match(/^### (.+)$/m);
1769
- if (titleM) matches.push({ source: 'decisions.md', title: titleM[1].trim(), block });
1773
+ for (const d of idx.decisions) {
1774
+ if (fuzzyRe.test(d.block) && /롤백|실패|fail|취소|회귀|deprecate/i.test(d.block)) {
1775
+ matches.push({ source: 'decisions.md', title: d.title, block: d.block });
1770
1776
  }
1771
1777
  }
1772
1778
  if (matches.length) {
@@ -5512,8 +5518,9 @@ function lessonsCmd(root) {
5512
5518
  }
5513
5519
  log(`# Lessons --auto (1.9.54): 추출 키워드 "${query}"`);
5514
5520
  }
5521
+ // 1.9.65: 인덱스 캐시 활용 (decisions/evidence split 1회만)
5522
+ const _lidx = _loadLessonsIndex(root);
5515
5523
  const decisions = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
5516
- const evidence = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
5517
5524
  const tlog = exists(taskLogPath(root)) ? read(taskLogPath(root)) : '';
5518
5525
  const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
5519
5526
  const lessons = [];
@@ -5523,12 +5530,10 @@ function lessonsCmd(root) {
5523
5530
  if (!m) continue;
5524
5531
  lessons.push({ source: 'decisions.md', title: m[1].trim(), block });
5525
5532
  }
5526
- // evidence: ## 블록 중 실패/롤백/버그 표지가 있는 것
5527
- for (const block of evidence.split(/\n(?=## )/)) {
5528
- if (!block.startsWith('## ')) continue;
5529
- if (/✗|\bfail(ed)?\b|롤백|재발|incomplete|\bbug\b|버그|warning/i.test(block)) {
5530
- const m = block.match(/^## (.+)$/m);
5531
- if (m) lessons.push({ source: 'review-evidence.md', title: m[1].trim(), block });
5533
+ // evidence: ## 블록 중 실패/롤백/버그 표지가 있는 것 (1.9.65: 인덱스 재활용)
5534
+ for (const e of _lidx.evidence) {
5535
+ if (/✗|\bfail(ed)?\b|롤백|재발|incomplete|\bbug\b|버그|warning/i.test(e.block)) {
5536
+ lessons.push({ source: 'review-evidence.md', title: e.title, block: e.block });
5532
5537
  }
5533
5538
  }
5534
5539
  // task-log: 실패 키워드 라인
@@ -6129,12 +6134,56 @@ function driftCheckCmd(root, opts = {}) {
6129
6134
  if (level === '🔴 critical') process.exitCode = 1;
6130
6135
  }
6131
6136
 
6137
+ // 1.9.65: lessons blocks 인덱스 — evidence/decisions 파일 read + split을 1회로
6138
+ // key: root → { evidenceMtime, decisionsMtime, evidence: [{title, block}], decisions: [{title, block}] }
6139
+ const _LESSONS_INDEX_CACHE = new Map();
6140
+ function _loadLessonsIndex(root) {
6141
+ const ep = evidencePath(root);
6142
+ const dp = decisionsPath(root);
6143
+ const em = exists(ep) ? (() => { try { return fs.statSync(ep).mtimeMs; } catch { return 0; } })() : 0;
6144
+ const dm = exists(dp) ? (() => { try { return fs.statSync(dp).mtimeMs; } catch { return 0; } })() : 0;
6145
+ const cacheKey = absRoot(root);
6146
+ const cached = _LESSONS_INDEX_CACHE.get(cacheKey);
6147
+ if (cached && cached.evidenceMtime === em && cached.decisionsMtime === dm) return cached;
6148
+ const evidence = [];
6149
+ if (em) {
6150
+ const txt = read(ep);
6151
+ for (const block of txt.split(/\n(?=## )/)) {
6152
+ if (!block.startsWith('## ')) continue;
6153
+ const t = block.match(/^## (.+)$/m);
6154
+ if (t) evidence.push({ title: t[1].trim(), block });
6155
+ }
6156
+ }
6157
+ const decisions = [];
6158
+ if (dm) {
6159
+ const txt = read(dp);
6160
+ for (const block of txt.split(/\n(?=### )/)) {
6161
+ if (!block.startsWith('### ')) continue;
6162
+ const t = block.match(/^### (.+)$/m);
6163
+ if (t) decisions.push({ title: t[1].trim(), block });
6164
+ }
6165
+ }
6166
+ const idx = { evidenceMtime: em, decisionsMtime: dm, evidence, decisions };
6167
+ _LESSONS_INDEX_CACHE.set(cacheKey, idx);
6168
+ return idx;
6169
+ }
6170
+
6132
6171
  // 1.9.38: 사용 통계 (cumulative count, command별)
6172
+ // 1.9.65: 같은 프로세스 lifetime 메모리 캐시 — 다중 호출 시 디스크 I/O 절감
6173
+ const _USAGE_CACHE = new Map(); // root → { stats, mtime }
6133
6174
  function _usageStatsPath(root) { return path.join(absRoot(root), '.harness', 'cache', 'usage-stats.json'); }
6134
6175
  function _readUsageStats(root) {
6135
6176
  const p = _usageStatsPath(root);
6136
6177
  if (!exists(p)) return { commands: {}, drift: { criticalSeen: 0, skipped: 0, autoResolved: 0 }, since: today() };
6137
- try { return JSON.parse(read(p)); } catch { return { commands: {}, drift: {}, since: today() }; }
6178
+ // 1.9.65: 캐시 hit mtime 동일 재파싱 skip
6179
+ try {
6180
+ const mtime = fs.statSync(p).mtimeMs;
6181
+ const cached = _USAGE_CACHE.get(p);
6182
+ if (cached && cached.mtime === mtime) return cached.stats;
6183
+ const stats = JSON.parse(read(p));
6184
+ _USAGE_CACHE.set(p, { stats, mtime });
6185
+ return stats;
6186
+ } catch { return { commands: {}, drift: {}, since: today() }; }
6138
6187
  }
6139
6188
  function _bumpUsage(root, cmdName) {
6140
6189
  // 가벼운 카운터 — 명령 실행마다 호출 (sync write로 작은 파일)
@@ -6148,6 +6197,8 @@ function _bumpUsage(root, cmdName) {
6148
6197
  const p = _usageStatsPath(root);
6149
6198
  mkdirp(path.dirname(p));
6150
6199
  writeUtf8(p, JSON.stringify(stats, null, 2) + '\n');
6200
+ // 1.9.65: 쓰기 후 캐시 invalidate (다음 read에서 새 mtime으로 재로드)
6201
+ try { _USAGE_CACHE.set(p, { stats, mtime: fs.statSync(p).mtimeMs }); } catch {}
6151
6202
  } catch {}
6152
6203
  }
6153
6204
 
@@ -7185,6 +7236,23 @@ async function main() {
7185
7236
  } catch {}
7186
7237
  }
7187
7238
  if (cmd === 'init') return await install(args[1] || process.cwd(), { force:false, dry:false, migration:false });
7239
+ // 1.9.64: install <skill-id-or-url> 별칭 (= skill install). 자주 쓰는 명령 단축형.
7240
+ // 단, init이 leerness install . 같은 형태로도 동작하던 옛 호환은 유지 — args[1]이 디렉토리면 init으로 라우팅.
7241
+ if (cmd === 'install') {
7242
+ const arg1 = args[1];
7243
+ // skill source는 .md 파일 또는 URL 또는 skill id. 디렉토리면 init으로.
7244
+ if (!arg1) { fail('사용법: leerness install <skill SKILL.md path or URL>'); return process.exit(1); }
7245
+ if (/^https?:\/\//.test(arg1) || /\.md$/.test(arg1) || exists(path.join(arg1, 'SKILL.md'))) {
7246
+ return await skillInstallCmd(absRoot(arg('--path', process.cwd())), arg1);
7247
+ }
7248
+ // 디렉토리면 안내
7249
+ if (exists(arg1) && fs.statSync(arg1).isDirectory() && !exists(path.join(arg1, 'SKILL.md'))) {
7250
+ fail(`디렉토리에 SKILL.md 없음: ${arg1}\n init 의도였다면: leerness init "${arg1}"`);
7251
+ return process.exit(1);
7252
+ }
7253
+ fail(`알 수 없는 install 대상: ${arg1}\n SKILL.md 파일/URL/SKILL.md 포함 디렉토리 필요`);
7254
+ return process.exit(1);
7255
+ }
7188
7256
  if (cmd === 'migrate') return await install(args[1] || process.cwd(), { force:has('--force'), dry:has('--dry-run'), migration:true });
7189
7257
  if (cmd === 'update') return await updateCmd(args[1] || process.cwd(), { checkOnly: has('--check'), yes: has('--yes'), force: has('--force') });
7190
7258
  if (cmd === 'auto-update' && args[1] === 'install') return autoUpdateInstall(args[2] || process.cwd());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.62",
3
+ "version": "1.9.65",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -950,6 +950,43 @@ total++;
950
950
  if (!ok) { failed++; console.log(r.stdout.slice(0, 800)); }
951
951
  }
952
952
 
953
+ // 1.9.63/64 회귀
954
+ total++;
955
+ {
956
+ // audit --strict
957
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-st-'));
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, 'audit', tmpC, '--strict'], { encoding: 'utf8', timeout: 15000 });
960
+ const ok = r.status !== 0 && /strict.*승격|failures=1/.test(r.stdout);
961
+ console.log(ok ? '✓ B(1.9.63) audit --strict: warnings → failures 승격' : `✗ --strict 실패`);
962
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 400)); }
963
+ }
964
+
965
+ total++;
966
+ {
967
+ // audit --strict --threshold 10 → exit 0
968
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-st2-'));
969
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
970
+ const r = cp.spawnSync(process.execPath, [CLI, 'audit', tmpC, '--strict', '--threshold', '10'], { encoding: 'utf8', timeout: 15000 });
971
+ const ok = r.status === 0;
972
+ console.log(ok ? '✓ B(1.9.63) audit --strict --threshold 10: warnings 적으면 exit 0' : `✗ threshold 실패`);
973
+ if (!ok) { failed++; console.log(r.stdout.slice(-300)); }
974
+ }
975
+
976
+ total++;
977
+ {
978
+ // install 별칭
979
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-iv-'));
980
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
981
+ const src = path.join(tmpC, 'sk.md');
982
+ fs.writeFileSync(src, '---\nname: install-alias-test\ndescription: 별칭 검증\n---\n# Body\n', 'utf8');
983
+ const r = cp.spawnSync(process.execPath, [CLI, 'install', src, '--path', tmpC], { encoding: 'utf8', timeout: 15000 });
984
+ const f = path.join(tmpC, '.harness', 'skills', 'install-alias-test', 'SKILL.md');
985
+ const ok = r.status === 0 && fs.existsSync(f);
986
+ console.log(ok ? '✓ B(1.9.64) install <SKILL.md>: skill install 별칭 동작' : `✗ install 별칭 실패`);
987
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 300)); }
988
+ }
989
+
953
990
  // 1.9.60/61/62 회귀
954
991
  total++;
955
992
  {