leerness 1.9.64 → 1.9.66

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,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.66 — 2026-05-19
4
+
5
+ **성능 최적화 2차 + MCP 13번째 도구**.
6
+
7
+ ### Performance
8
+ - **`listAllSkills` 메모리 캐시 (`_SKILLS_LIST_CACHE`)** — userSkillsDir mtime 기반 캐시. `skill list/info/match/discover/suggest` 가 같은 인덱스 공유.
9
+ - `saveUserSkill`/`skillRemove`에서 캐시 invalidate — skill 추가/제거 즉시 반영.
10
+
11
+ ### MCP server — 13번째 도구
12
+ - **`leerness_task_export`** — 1.9.60 TodoWrite 호환 JSON을 외부 에이전트(Claude Code, Cursor 등)에 노출. `to: <path>` 또는 stdout JSON 모두 지원.
13
+ - MCP server 도구 카운트: 12 → **13**.
14
+
15
+ ### Verified
16
+ - stress-v12 (1.9.66 검증) — listAllSkills 캐시 정합성 + MCP 13 도구 + warm-up 1회 시나리오 보강.
17
+ - e2e 회귀: 219/219 PASS 유지.
18
+
19
+ ---
20
+
21
+ ## 1.9.65 — 2026-05-19
22
+
23
+ **성능 최적화 1차 — usage-stats 메모리 캐시 + lessons 인덱스 캐시**.
24
+
25
+ ### Performance
26
+ - **usage-stats 메모리 캐시 (`_USAGE_CACHE`)** — 같은 프로세스 lifetime 동안 `.harness/cache/usage-stats.json`을 mtime 기반으로 한 번만 파싱. `_readUsageStats()` 다중 호출 시 디스크 I/O 절감.
27
+ - **lessons 인덱스 캐시 (`_LESSONS_INDEX_CACHE`)** — `review-evidence.md` + `decisions.md`를 mtime 기반으로 1회 read+split, 블록 인덱스를 메모리에 보관.
28
+ - handoff의 lessons 자동 재상기: 키워드별 fuzzy 매칭이 split 재실행 없이 인덱스 순회로 동작.
29
+ - `leerness lessons` 명령도 같은 인덱스 재활용.
30
+ - 벤치마크 워크스페이스 크기 비례 비용 → 사실상 O(1) (인덱스 hit 시).
31
+ - API 호환성 유지 — 캐시는 mtime invalidation이라 외부에서 파일을 수정해도 자동 재로드.
32
+
33
+ ### Verified
34
+ - stress-v11 (1.9.64 baseline ↔ 1.9.65 optimized 정량 비교) — 13/14 PASS, 캐시 정합성 3/3 PASS.
35
+ - 성능: handoff -37% / drift -19% / audit -29% / skill list -17% / 100-task handoff -42% / 50-evidence handoff 1048ms.
36
+ - status 클린 환경 측정: median 623ms (v10 1195ms 대비 -48% 개선).
37
+ - e2e 회귀: 219/219 PASS 유지.
38
+
39
+ ---
40
+
3
41
  ## 1.9.64 — 2026-05-19
4
42
 
5
43
  **`leerness install <skill>` 별칭 + 성능 벤치마크 1차 실측**.
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.64-green)]() [![tests](https://img.shields.io/badge/e2e-219%2F219-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.66-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.64 AI Agent Reliability Harness ║
15
+ ║ v1.9.66 AI Agent Reliability Harness ║
16
16
  ║ verify · remember · orchestrate · audit · prevent drift ║
17
17
  ╚══════════════════════════════════════════════════════════════╝
18
18
  ```
@@ -433,6 +433,8 @@ npm test # = node ./scripts/e2e.js
433
433
 
434
434
  ## 변경 이력 (최근)
435
435
 
436
+ - **1.9.66** — **성능 최적화 2차 + MCP 13번째 도구**. `listAllSkills` 메모리 캐시 (skill list/info/match/discover/suggest 공유) + MCP `leerness_task_export` 추가 (TodoWrite 양방향 sync 외부 노출).
437
+ - **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).
436
438
  - **1.9.64** — `leerness install <SKILL.md>` 별칭 (skill install 단축) · **성능 벤치마크 1차 실측** (status/handoff/drift/audit/skill list 평균 1.2~1.5초).
437
439
  - **1.9.63** — `leerness audit --strict [--threshold N]` — CI 친화 옵션 (warnings → failures 승격).
438
440
  - **1.9.62** — `leerness audit` npm CVE 자동 감지 (npm audit --json 통합, OFFLINE/no-npm-audit 스킵).
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.64';
9
+ const VERSION = '1.9.66';
10
10
  const MARK = '<!-- leerness:managed -->';
11
11
  const README_START = '<!-- leerness:project-readme:start -->';
12
12
  const README_END = '<!-- leerness:project-readme:end -->';
@@ -715,13 +715,34 @@ function loadUserSkill(root, id) {
715
715
  function saveUserSkill(root, id, data) {
716
716
  const dir = path.join(userSkillsDir(root), id); mkdirp(dir);
717
717
  writeUtf8(path.join(dir, 'skill.json'), JSON.stringify(data, null, 2) + '\n');
718
+ // 1.9.66: 캐시 invalidate (skill 추가/변경 즉시 반영)
719
+ try { _SKILLS_LIST_CACHE.delete(absRoot(root)); } catch {}
718
720
  // README mirror
719
721
  const usage = data.usage || { count: 0 };
720
722
  const readme = `# ${data.displayNameKo || id}\n\n## Capabilities\n${(data.capabilities || []).map(c => '- ' + c).join('\n') || '-'}\n\n## Sources\n${(data.sources || []).map(s => `- ${s.url || s}`).join('\n') || '-'}\n\n## Patterns (성공 명령/접근)\n${(data.patterns || []).map(p => `- \`${p.command}\` — ${p.note || ''}`).join('\n') || '-'}\n\n## Optimization history\n${(data.optimizations || []).map(o => `- ${o.at}: ${o.note || ''}${o.before||o.after?` (${o.before||'?'} → ${o.after||'?'})`:''}`).join('\n') || '-'}\n\n## Usage\n${usage.count || 0}회 사용 / 마지막: ${usage.lastUsed || '-'}\n${usage.lastNote ? '\n마지막 노트: ' + usage.lastNote : ''}\n`;
721
723
  writeUtf8(path.join(dir, 'README.md'), readme);
722
724
  }
723
725
 
726
+ // 1.9.66: listAllSkills 메모리 캐시 — skill list/info/match/discover/suggest 가 공유
727
+ // key: root → { mtime(skillsDir), out }
728
+ const _SKILLS_LIST_CACHE = new Map();
724
729
  function listAllSkills(root) {
730
+ // 캐시 hit 확인: userSkillsDir mtime 동일 시 재구성 skip
731
+ if (root) {
732
+ try {
733
+ const dir = userSkillsDir(root);
734
+ const dirMtime = exists(dir) ? fs.statSync(dir).mtimeMs : 0;
735
+ const key = absRoot(root);
736
+ const cached = _SKILLS_LIST_CACHE.get(key);
737
+ if (cached && cached.dirMtime === dirMtime) return cached.out;
738
+ const out = _buildAllSkills(root);
739
+ _SKILLS_LIST_CACHE.set(key, { dirMtime, out });
740
+ return out;
741
+ } catch { return _buildAllSkills(root); }
742
+ }
743
+ return _buildAllSkills(root);
744
+ }
745
+ function _buildAllSkills(root) {
725
746
  const out = {};
726
747
  // 1.9.10: skillCatalog의 _source('skillpack' 또는 'builtin')를 보존
727
748
  for (const [k, v] of Object.entries(skillCatalog)) out[k] = { ...v, _source: v._source || 'builtin' };
@@ -739,6 +760,10 @@ function listAllSkills(root) {
739
760
  }
740
761
  return out;
741
762
  }
763
+ // 1.9.66: skill 추가/제거 시 캐시 invalidate (외부 helper)
764
+ function _invalidateSkillsCache(root) {
765
+ try { _SKILLS_LIST_CACHE.delete(absRoot(root)); } catch {}
766
+ }
742
767
 
743
768
  function skillList(root) {
744
769
  const all = listAllSkills(root);
@@ -826,6 +851,8 @@ function skillRemove(root, id) {
826
851
  if (!id) return fail('id required');
827
852
  const dir = path.join(userSkillsDir(root), id);
828
853
  if (!exists(dir)) return fail(`skill folder not found: ${id}`);
854
+ // 1.9.66: 캐시 invalidate
855
+ try { _SKILLS_LIST_CACHE.delete(absRoot(root)); } catch {}
829
856
  if (skillCatalog[id]) {
830
857
  // catalog 스킬은 로컬 메타만 제거 (카탈로그는 패키지 내장이라 영구 제거 불가)
831
858
  fs.rmSync(dir, { recursive: true, force: true });
@@ -1757,24 +1784,22 @@ function handoff(root) {
1757
1784
  const tokens = String(latestRow.request).toLowerCase().match(/[\w가-힣]{4,}/g) || [];
1758
1785
  const keyword = tokens.filter(t => !stopwords.has(t)).sort((a, b) => b.length - a.length)[0];
1759
1786
  if (keyword) {
1760
- // lessons 검색 — 1.9.58: fuzzy 매칭 (substring + 어간 변형)
1761
- const decisions = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
1762
- const evidence = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
1787
+ // 1.9.65: lessons blocks 인덱스 메모리 캐시 — mtime 기반 invalidation
1788
+ // 같은 프로세스가 여러 handoff를 호출해도 split/regex 비용 1회만
1789
+ const idx = _loadLessonsIndex(root);
1763
1790
  // fuzzy: keyword 또는 keyword 부분 (4자+) 일치
1764
1791
  // 예: "webhook" 매칭 시 "webhook-payload", "webhooks", "webhooked" 모두 매칭
1765
1792
  const fuzzyRe = new RegExp(escapeRegex(keyword.slice(0, Math.max(4, Math.floor(keyword.length * 0.7)))), 'i');
1766
1793
  const matches = [];
1767
- for (const block of evidence.split(/\n(?=## )/)) {
1768
- if (block.startsWith('## ') && fuzzyRe.test(block) && /✗|fail|롤백|버그|incomplete/i.test(block)) {
1769
- const titleM = block.match(/^## (.+)$/m);
1770
- if (titleM) matches.push({ source: 'review-evidence.md', title: titleM[1].trim(), block });
1794
+ for (const e of idx.evidence) {
1795
+ if (fuzzyRe.test(e.block) && /✗|fail|롤백|버그|incomplete/i.test(e.block)) {
1796
+ matches.push({ source: 'review-evidence.md', title: e.title, block: e.block });
1771
1797
  }
1772
1798
  }
1773
1799
  // 1.9.58: decisions.md도 fuzzy 매칭 (실패/롤백 관련 결정만)
1774
- for (const block of decisions.split(/\n(?=### )/)) {
1775
- if (block.startsWith('### ') && fuzzyRe.test(block) && /롤백|실패|fail|취소|회귀|deprecate/i.test(block)) {
1776
- const titleM = block.match(/^### (.+)$/m);
1777
- if (titleM) matches.push({ source: 'decisions.md', title: titleM[1].trim(), block });
1800
+ for (const d of idx.decisions) {
1801
+ if (fuzzyRe.test(d.block) && /롤백|실패|fail|취소|회귀|deprecate/i.test(d.block)) {
1802
+ matches.push({ source: 'decisions.md', title: d.title, block: d.block });
1778
1803
  }
1779
1804
  }
1780
1805
  if (matches.length) {
@@ -5520,8 +5545,9 @@ function lessonsCmd(root) {
5520
5545
  }
5521
5546
  log(`# Lessons --auto (1.9.54): 추출 키워드 "${query}"`);
5522
5547
  }
5548
+ // 1.9.65: 인덱스 캐시 활용 (decisions/evidence split 1회만)
5549
+ const _lidx = _loadLessonsIndex(root);
5523
5550
  const decisions = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
5524
- const evidence = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
5525
5551
  const tlog = exists(taskLogPath(root)) ? read(taskLogPath(root)) : '';
5526
5552
  const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
5527
5553
  const lessons = [];
@@ -5531,12 +5557,10 @@ function lessonsCmd(root) {
5531
5557
  if (!m) continue;
5532
5558
  lessons.push({ source: 'decisions.md', title: m[1].trim(), block });
5533
5559
  }
5534
- // evidence: ## 블록 중 실패/롤백/버그 표지가 있는 것
5535
- for (const block of evidence.split(/\n(?=## )/)) {
5536
- if (!block.startsWith('## ')) continue;
5537
- if (/✗|\bfail(ed)?\b|롤백|재발|incomplete|\bbug\b|버그|warning/i.test(block)) {
5538
- const m = block.match(/^## (.+)$/m);
5539
- if (m) lessons.push({ source: 'review-evidence.md', title: m[1].trim(), block });
5560
+ // evidence: ## 블록 중 실패/롤백/버그 표지가 있는 것 (1.9.65: 인덱스 재활용)
5561
+ for (const e of _lidx.evidence) {
5562
+ if (/✗|\bfail(ed)?\b|롤백|재발|incomplete|\bbug\b|버그|warning/i.test(e.block)) {
5563
+ lessons.push({ source: 'review-evidence.md', title: e.title, block: e.block });
5540
5564
  }
5541
5565
  }
5542
5566
  // task-log: 실패 키워드 라인
@@ -6137,12 +6161,56 @@ function driftCheckCmd(root, opts = {}) {
6137
6161
  if (level === '🔴 critical') process.exitCode = 1;
6138
6162
  }
6139
6163
 
6164
+ // 1.9.65: lessons blocks 인덱스 — evidence/decisions 파일 read + split을 1회로
6165
+ // key: root → { evidenceMtime, decisionsMtime, evidence: [{title, block}], decisions: [{title, block}] }
6166
+ const _LESSONS_INDEX_CACHE = new Map();
6167
+ function _loadLessonsIndex(root) {
6168
+ const ep = evidencePath(root);
6169
+ const dp = decisionsPath(root);
6170
+ const em = exists(ep) ? (() => { try { return fs.statSync(ep).mtimeMs; } catch { return 0; } })() : 0;
6171
+ const dm = exists(dp) ? (() => { try { return fs.statSync(dp).mtimeMs; } catch { return 0; } })() : 0;
6172
+ const cacheKey = absRoot(root);
6173
+ const cached = _LESSONS_INDEX_CACHE.get(cacheKey);
6174
+ if (cached && cached.evidenceMtime === em && cached.decisionsMtime === dm) return cached;
6175
+ const evidence = [];
6176
+ if (em) {
6177
+ const txt = read(ep);
6178
+ for (const block of txt.split(/\n(?=## )/)) {
6179
+ if (!block.startsWith('## ')) continue;
6180
+ const t = block.match(/^## (.+)$/m);
6181
+ if (t) evidence.push({ title: t[1].trim(), block });
6182
+ }
6183
+ }
6184
+ const decisions = [];
6185
+ if (dm) {
6186
+ const txt = read(dp);
6187
+ for (const block of txt.split(/\n(?=### )/)) {
6188
+ if (!block.startsWith('### ')) continue;
6189
+ const t = block.match(/^### (.+)$/m);
6190
+ if (t) decisions.push({ title: t[1].trim(), block });
6191
+ }
6192
+ }
6193
+ const idx = { evidenceMtime: em, decisionsMtime: dm, evidence, decisions };
6194
+ _LESSONS_INDEX_CACHE.set(cacheKey, idx);
6195
+ return idx;
6196
+ }
6197
+
6140
6198
  // 1.9.38: 사용 통계 (cumulative count, command별)
6199
+ // 1.9.65: 같은 프로세스 lifetime 메모리 캐시 — 다중 호출 시 디스크 I/O 절감
6200
+ const _USAGE_CACHE = new Map(); // root → { stats, mtime }
6141
6201
  function _usageStatsPath(root) { return path.join(absRoot(root), '.harness', 'cache', 'usage-stats.json'); }
6142
6202
  function _readUsageStats(root) {
6143
6203
  const p = _usageStatsPath(root);
6144
6204
  if (!exists(p)) return { commands: {}, drift: { criticalSeen: 0, skipped: 0, autoResolved: 0 }, since: today() };
6145
- try { return JSON.parse(read(p)); } catch { return { commands: {}, drift: {}, since: today() }; }
6205
+ // 1.9.65: 캐시 hit mtime 동일 재파싱 skip
6206
+ try {
6207
+ const mtime = fs.statSync(p).mtimeMs;
6208
+ const cached = _USAGE_CACHE.get(p);
6209
+ if (cached && cached.mtime === mtime) return cached.stats;
6210
+ const stats = JSON.parse(read(p));
6211
+ _USAGE_CACHE.set(p, { stats, mtime });
6212
+ return stats;
6213
+ } catch { return { commands: {}, drift: {}, since: today() }; }
6146
6214
  }
6147
6215
  function _bumpUsage(root, cmdName) {
6148
6216
  // 가벼운 카운터 — 명령 실행마다 호출 (sync write로 작은 파일)
@@ -6156,6 +6224,8 @@ function _bumpUsage(root, cmdName) {
6156
6224
  const p = _usageStatsPath(root);
6157
6225
  mkdirp(path.dirname(p));
6158
6226
  writeUtf8(p, JSON.stringify(stats, null, 2) + '\n');
6227
+ // 1.9.65: 쓰기 후 캐시 invalidate (다음 read에서 새 mtime으로 재로드)
6228
+ try { _USAGE_CACHE.set(p, { stats, mtime: fs.statSync(p).mtimeMs }); } catch {}
6159
6229
  } catch {}
6160
6230
  }
6161
6231
 
@@ -6772,7 +6842,8 @@ function mcpServeCmd(root) {
6772
6842
  { name: 'leerness_usage_stats', description: 'leerness 명령별 누적 호출 통계 + drift 통계', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
6773
6843
  { name: 'leerness_session_close', description: '세션 마감 — handoff/current-state/task-log 자동 갱신', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
6774
6844
  { name: 'leerness_skill_suggest', description: '1.9.53 — 사용 패턴 자동 분석 → 새 skill 후보 제안 (Hermes-style 자동 학습)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, min: { type: 'number' }, days: { type: 'number' } } } },
6775
- { name: 'leerness_lessons', description: '1.9.7/54 — 과거 결정·실수 자동 회수 (--auto: 현재 task 키워드 자동 추출)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, query: { type: 'string' }, auto: { type: 'boolean' }, limit: { type: 'number' } } } }
6845
+ { name: 'leerness_lessons', description: '1.9.7/54 — 과거 결정·실수 자동 회수 (--auto: 현재 task 키워드 자동 추출)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, query: { type: 'string' }, auto: { type: 'boolean' }, limit: { type: 'number' } } } },
6846
+ { name: 'leerness_task_export', description: '1.9.60/66 — leerness task → Claude Code TodoWrite 호환 JSON (외부 AI 양방향 sync)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, to: { type: 'string' } } } }
6776
6847
  ];
6777
6848
 
6778
6849
  function send(obj) {
@@ -6814,6 +6885,7 @@ function mcpServeCmd(root) {
6814
6885
  case 'leerness_session_close': cliArgs = ['session', 'close', targetPath]; break;
6815
6886
  case 'leerness_skill_suggest': cliArgs = ['skill', 'suggest', '--path', targetPath, '--json', ...(args.min ? ['--min', String(args.min)] : []), ...(args.days ? ['--days', String(args.days)] : [])]; break;
6816
6887
  case 'leerness_lessons': cliArgs = ['lessons', '--path', targetPath, ...(args.auto ? ['--auto'] : []), ...(args.query ? ['--query', args.query] : []), ...(args.limit ? ['--limit', String(args.limit)] : [])]; break;
6888
+ case 'leerness_task_export': cliArgs = ['task', 'export', '--path', targetPath, ...(args.to ? ['--to', args.to] : ['--json'])]; break;
6817
6889
  default:
6818
6890
  return send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Unknown tool: ${name}` } });
6819
6891
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.64",
3
+ "version": "1.9.66",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",