leerness 1.9.65 → 1.9.67

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,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.67 — 2026-05-19
4
+
5
+ **handoff 자동 skill 추천 default ON + lessons 인덱스에 task-log 통합**.
6
+
7
+ ### Added — handoff 자동 skill match (default ON)
8
+ - 매 `leerness handoff` 시 **현재 in-progress task와 매칭되는 설치된 skill을 자동 추천** (점수 + skill id + description 미리보기).
9
+ - 1.9.45의 `LEERNESS_SKILL_AUTO_DISCOVER=1` opt-in 환경변수 의존성 제거 → default 활성.
10
+ - 끄기: `--no-skill-suggest` 또는 `LEERNESS_NO_SKILL_SUGGEST=1`.
11
+ - 매칭 알고리즘: `_jaccard(task.request_tokens, skill.name+description_tokens)`, top 3.
12
+ - 매칭 점수 0이면 출력 안 함 (잡음 최소화).
13
+
14
+ ### Improved — lessons 인덱스 확장
15
+ - `_loadLessonsIndex`에 **task-log.md 실패 라인** 추가 (mtime 기반 invalidation).
16
+ - `_lidx.taskLogFails: [{title, block}]` 새 필드.
17
+ - handoff lessons 자동 재상기에서 task-log fuzzy 매칭도 가능.
18
+ - `leerness lessons` 명령도 같은 인덱스 사용 (split 1회).
19
+
20
+ ### Updated
21
+ - `_banner` quickStart: "13 도구 노출 (task_export 포함)" + "매칭 skill 자동 추천" 안내.
22
+ - `.harness/session-workflow.md` 템플릿: 1.9.67 라인 추가.
23
+
24
+ ### Verified
25
+ - stress-v13 (1.9.67 검증) — handoff skill match default + --no-skill-suggest + lessons task-log fuzzy.
26
+ - e2e 회귀: 219/219 PASS 유지.
27
+
28
+ ---
29
+
30
+ ## 1.9.66 — 2026-05-19
31
+
32
+ **성능 최적화 2차 + MCP 13번째 도구**.
33
+
34
+ ### Performance
35
+ - **`listAllSkills` 메모리 캐시 (`_SKILLS_LIST_CACHE`)** — userSkillsDir mtime 기반 캐시. `skill list/info/match/discover/suggest` 가 같은 인덱스 공유.
36
+ - `saveUserSkill`/`skillRemove`에서 캐시 invalidate — skill 추가/제거 즉시 반영.
37
+
38
+ ### MCP server — 13번째 도구
39
+ - **`leerness_task_export`** — 1.9.60 TodoWrite 호환 JSON을 외부 에이전트(Claude Code, Cursor 등)에 노출. `to: <path>` 또는 stdout JSON 모두 지원.
40
+ - MCP server 도구 카운트: 12 → **13**.
41
+
42
+ ### Verified
43
+ - stress-v12 (1.9.66 검증) — listAllSkills 캐시 정합성 + MCP 13 도구 + warm-up 1회 시나리오 보강.
44
+ - e2e 회귀: 219/219 PASS 유지.
45
+
46
+ ---
47
+
3
48
  ## 1.9.65 — 2026-05-19
4
49
 
5
50
  **성능 최적화 1차 — usage-stats 메모리 캐시 + lessons 인덱스 캐시**.
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.65-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.67-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.65 AI Agent Reliability Harness ║
15
+ ║ v1.9.67 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.67** — **handoff 자동 skill 추천 default ON** (jaccard 매칭) + lessons 인덱스에 task-log.md 실패 라인 통합 (회수 범위 확장).
437
+ - **1.9.66** — **성능 최적화 2차 + MCP 13번째 도구**. `listAllSkills` 메모리 캐시 (skill list/info/match/discover/suggest 공유) + MCP `leerness_task_export` 추가 (TodoWrite 양방향 sync 외부 노출).
436
438
  - **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
439
  - **1.9.64** — `leerness install <SKILL.md>` 별칭 (skill install 단축) · **성능 벤치마크 1차 실측** (status/handoff/drift/audit/skill list 평균 1.2~1.5초).
438
440
  - **1.9.63** — `leerness audit --strict [--threshold N]` — CI 친화 옵션 (warnings → failures 승격).
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.65';
9
+ const VERSION = '1.9.67';
10
10
  const MARK = '<!-- leerness:managed -->';
11
11
  const README_START = '<!-- leerness:project-readme:start -->';
12
12
  const README_END = '<!-- leerness:project-readme:end -->';
@@ -289,6 +289,8 @@ leerness audit . --fix # 누락 메타 자동 보강
289
289
  - session close가 누락되면 다음 세션 시작 시 drift critical 발생.
290
290
  - 자동 회복 옵션: \`drift check --auto-fix\` (critical 시 session close 자동 실행).
291
291
  - 1.9.56+ handoff가 매 세션 시작 시 **과거 lessons 자동 재상기** (현재 task 키워드 기준).
292
+ - 1.9.67+ handoff가 현재 task와 매칭되는 **설치된 skill을 자동 추천** (jaccard 기반, default ON, \`--no-skill-suggest\`로 끄기).
293
+ - 1.9.67+ lessons 인덱스에 \`task-log.md\` 실패 라인까지 포함 → 회수 범위 확장.
292
294
 
293
295
  ---
294
296
 
@@ -715,13 +717,34 @@ function loadUserSkill(root, id) {
715
717
  function saveUserSkill(root, id, data) {
716
718
  const dir = path.join(userSkillsDir(root), id); mkdirp(dir);
717
719
  writeUtf8(path.join(dir, 'skill.json'), JSON.stringify(data, null, 2) + '\n');
720
+ // 1.9.66: 캐시 invalidate (skill 추가/변경 즉시 반영)
721
+ try { _SKILLS_LIST_CACHE.delete(absRoot(root)); } catch {}
718
722
  // README mirror
719
723
  const usage = data.usage || { count: 0 };
720
724
  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
725
  writeUtf8(path.join(dir, 'README.md'), readme);
722
726
  }
723
727
 
728
+ // 1.9.66: listAllSkills 메모리 캐시 — skill list/info/match/discover/suggest 가 공유
729
+ // key: root → { mtime(skillsDir), out }
730
+ const _SKILLS_LIST_CACHE = new Map();
724
731
  function listAllSkills(root) {
732
+ // 캐시 hit 확인: userSkillsDir mtime 동일 시 재구성 skip
733
+ if (root) {
734
+ try {
735
+ const dir = userSkillsDir(root);
736
+ const dirMtime = exists(dir) ? fs.statSync(dir).mtimeMs : 0;
737
+ const key = absRoot(root);
738
+ const cached = _SKILLS_LIST_CACHE.get(key);
739
+ if (cached && cached.dirMtime === dirMtime) return cached.out;
740
+ const out = _buildAllSkills(root);
741
+ _SKILLS_LIST_CACHE.set(key, { dirMtime, out });
742
+ return out;
743
+ } catch { return _buildAllSkills(root); }
744
+ }
745
+ return _buildAllSkills(root);
746
+ }
747
+ function _buildAllSkills(root) {
725
748
  const out = {};
726
749
  // 1.9.10: skillCatalog의 _source('skillpack' 또는 'builtin')를 보존
727
750
  for (const [k, v] of Object.entries(skillCatalog)) out[k] = { ...v, _source: v._source || 'builtin' };
@@ -739,6 +762,10 @@ function listAllSkills(root) {
739
762
  }
740
763
  return out;
741
764
  }
765
+ // 1.9.66: skill 추가/제거 시 캐시 invalidate (외부 helper)
766
+ function _invalidateSkillsCache(root) {
767
+ try { _SKILLS_LIST_CACHE.delete(absRoot(root)); } catch {}
768
+ }
742
769
 
743
770
  function skillList(root) {
744
771
  const all = listAllSkills(root);
@@ -826,6 +853,8 @@ function skillRemove(root, id) {
826
853
  if (!id) return fail('id required');
827
854
  const dir = path.join(userSkillsDir(root), id);
828
855
  if (!exists(dir)) return fail(`skill folder not found: ${id}`);
856
+ // 1.9.66: 캐시 invalidate
857
+ try { _SKILLS_LIST_CACHE.delete(absRoot(root)); } catch {}
829
858
  if (skillCatalog[id]) {
830
859
  // catalog 스킬은 로컬 메타만 제거 (카탈로그는 패키지 내장이라 영구 제거 불가)
831
860
  fs.rmSync(dir, { recursive: true, force: true });
@@ -1775,6 +1804,12 @@ function handoff(root) {
1775
1804
  matches.push({ source: 'decisions.md', title: d.title, block: d.block });
1776
1805
  }
1777
1806
  }
1807
+ // 1.9.67: task-log.md 실패 라인도 fuzzy 매칭 (회수 범위 확장)
1808
+ for (const t of (idx.taskLogFails || [])) {
1809
+ if (fuzzyRe.test(t.block)) {
1810
+ matches.push({ source: 'task-log.md', title: t.title, block: t.block });
1811
+ }
1812
+ }
1778
1813
  if (matches.length) {
1779
1814
  const isTty = process.stdout && process.stdout.isTTY;
1780
1815
  const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
@@ -1788,6 +1823,30 @@ function handoff(root) {
1788
1823
  log(dim(` → 전체: leerness lessons --auto --path .`));
1789
1824
  log('');
1790
1825
  }
1826
+ // 1.9.67: 현재 task와 관련된 skill 자동 추천 (default ON, 1.9.45 opt-in → default)
1827
+ // 끄려면: --no-skill-suggest 또는 LEERNESS_NO_SKILL_SUGGEST=1
1828
+ if (!has('--no-skill-suggest') && process.env.LEERNESS_NO_SKILL_SUGGEST !== '1') {
1829
+ try {
1830
+ const installed = _readInstalledSkills(root);
1831
+ if (installed.length) {
1832
+ const qTokens = _tokenize(String(latestRow.request));
1833
+ const ranked = installed.map(s => ({
1834
+ ...s, score: _jaccard(qTokens, _tokenize(s.name + ' ' + s.description))
1835
+ })).filter(s => s.score > 0).sort((a, b) => b.score - a.score).slice(0, 3);
1836
+ if (ranked.length) {
1837
+ const isTty = process.stdout && process.stdout.isTTY;
1838
+ const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
1839
+ const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
1840
+ log(grn(`## 🎯 현재 task와 매칭되는 skill 자동 추천 (1.9.67) — 키워드 "${keyword}"`));
1841
+ for (const r of ranked) {
1842
+ log(dim(` • [${r.score.toFixed(2)}] ${r.id} — ${(r.description || '').slice(0, 60)}`));
1843
+ }
1844
+ log(dim(` → 전체: leerness skill match "${String(latestRow.request).slice(0, 60)}"`));
1845
+ log('');
1846
+ }
1847
+ }
1848
+ } catch {}
1849
+ }
1791
1850
  }
1792
1851
  }
1793
1852
  } catch {}
@@ -3137,14 +3196,14 @@ function _banner(opts = {}) {
3137
3196
  lines.push('');
3138
3197
  for (const ln of lines) log(ln);
3139
3198
  if (opts.quickStart) {
3140
- log(C.bold(C.cyan(' ✨ 빠른 시작 (1.9.57+ 워크플로)')));
3199
+ log(C.bold(C.cyan(' ✨ 빠른 시작 (1.9.67+ 워크플로)')));
3141
3200
  log(' ' + C.green('npx leerness@latest init .') + C.dim(' # 신규 프로젝트 + 외부 AI CLI 설정'));
3142
- log(' ' + C.green('npx leerness handoff .') + C.dim(' # 컨텍스트 적재 + 과거 lessons 자동 재상기'));
3201
+ log(' ' + C.green('npx leerness handoff .') + C.dim(' # 컨텍스트 + lessons 재상기 + 매칭 skill 자동 추천'));
3143
3202
  log(' ' + C.green('npx leerness verify-claim T-0001 --run-tests') + C.dim(' # AI 거짓 완료 자동 검증'));
3144
3203
  log(' ' + C.green('npx leerness session close .') + C.dim(' # 마감 + 다음 라운드 추천 (default)'));
3145
3204
  log('');
3146
3205
  log(C.bold(C.cyan(' 🤖 메인 에이전트 (Claude/Cursor/Copilot)용')));
3147
- log(' ' + C.green('npx leerness mcp serve') + C.dim(' # MCP 서버 — 12 도구 노출'));
3206
+ log(' ' + C.green('npx leerness mcp serve') + C.dim(' # MCP 서버 — 13 도구 노출 (task_export 포함)'));
3148
3207
  log(' ' + C.green('npx leerness agents bench "<task>"') + C.dim(' # 3 CLI 동시 비교'));
3149
3208
  log('');
3150
3209
  }
@@ -5518,10 +5577,9 @@ function lessonsCmd(root) {
5518
5577
  }
5519
5578
  log(`# Lessons --auto (1.9.54): 추출 키워드 "${query}"`);
5520
5579
  }
5521
- // 1.9.65: 인덱스 캐시 활용 (decisions/evidence split 1회만)
5580
+ // 1.9.65/67: 인덱스 캐시 활용 (decisions/evidence/task-log split 1회만)
5522
5581
  const _lidx = _loadLessonsIndex(root);
5523
5582
  const decisions = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
5524
- const tlog = exists(taskLogPath(root)) ? read(taskLogPath(root)) : '';
5525
5583
  const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
5526
5584
  const lessons = [];
5527
5585
  // decisions: ### 블록 전체 (1.9.14: 코드블록/Template 제외)
@@ -5536,11 +5594,9 @@ function lessonsCmd(root) {
5536
5594
  lessons.push({ source: 'review-evidence.md', title: e.title, block: e.block });
5537
5595
  }
5538
5596
  }
5539
- // task-log: 실패 키워드 라인
5540
- for (const line of tlog.split('\n')) {
5541
- if (line.length > 4 && /✗|\bfail|롤백|재발|incomplete|버그/i.test(line)) {
5542
- lessons.push({ source: 'task-log.md', title: line.replace(/^[-*]\s*/, '').slice(0, 80), block: line });
5543
- }
5597
+ // task-log: 실패 키워드 라인 (1.9.67: 인덱스 재활용)
5598
+ for (const t of (_lidx.taskLogFails || [])) {
5599
+ lessons.push({ source: 'task-log.md', title: t.title, block: t.block });
5544
5600
  }
5545
5601
  // handoff: 미완료/블로커 항목
5546
5602
  if (handoff) {
@@ -6135,16 +6191,19 @@ function driftCheckCmd(root, opts = {}) {
6135
6191
  }
6136
6192
 
6137
6193
  // 1.9.65: lessons blocks 인덱스 — evidence/decisions 파일 read + split을 1회로
6138
- // key: root { evidenceMtime, decisionsMtime, evidence: [{title, block}], decisions: [{title, block}] }
6194
+ // 1.9.67: task-log.md 실패 라인도 인덱스에 포함 (mtime 기반 invalidation)
6195
+ // key: root → { evidenceMtime, decisionsMtime, taskLogMtime, evidence/decisions/taskLogFails: [{title, block}] }
6139
6196
  const _LESSONS_INDEX_CACHE = new Map();
6140
6197
  function _loadLessonsIndex(root) {
6141
6198
  const ep = evidencePath(root);
6142
6199
  const dp = decisionsPath(root);
6200
+ const tp = taskLogPath(root);
6143
6201
  const em = exists(ep) ? (() => { try { return fs.statSync(ep).mtimeMs; } catch { return 0; } })() : 0;
6144
6202
  const dm = exists(dp) ? (() => { try { return fs.statSync(dp).mtimeMs; } catch { return 0; } })() : 0;
6203
+ const tm = exists(tp) ? (() => { try { return fs.statSync(tp).mtimeMs; } catch { return 0; } })() : 0;
6145
6204
  const cacheKey = absRoot(root);
6146
6205
  const cached = _LESSONS_INDEX_CACHE.get(cacheKey);
6147
- if (cached && cached.evidenceMtime === em && cached.decisionsMtime === dm) return cached;
6206
+ if (cached && cached.evidenceMtime === em && cached.decisionsMtime === dm && cached.taskLogMtime === tm) return cached;
6148
6207
  const evidence = [];
6149
6208
  if (em) {
6150
6209
  const txt = read(ep);
@@ -6163,7 +6222,17 @@ function _loadLessonsIndex(root) {
6163
6222
  if (t) decisions.push({ title: t[1].trim(), block });
6164
6223
  }
6165
6224
  }
6166
- const idx = { evidenceMtime: em, decisionsMtime: dm, evidence, decisions };
6225
+ // 1.9.67: task-log.md 라인 실패/롤백 표지가 있는 라인만 인덱스
6226
+ const taskLogFails = [];
6227
+ if (tm) {
6228
+ const txt = read(tp);
6229
+ for (const line of txt.split('\n')) {
6230
+ if (line.length > 4 && /✗|\bfail|롤백|재발|incomplete|버그/i.test(line)) {
6231
+ taskLogFails.push({ title: line.replace(/^[-*]\s*/, '').slice(0, 100), block: line });
6232
+ }
6233
+ }
6234
+ }
6235
+ const idx = { evidenceMtime: em, decisionsMtime: dm, taskLogMtime: tm, evidence, decisions, taskLogFails };
6167
6236
  _LESSONS_INDEX_CACHE.set(cacheKey, idx);
6168
6237
  return idx;
6169
6238
  }
@@ -6815,7 +6884,8 @@ function mcpServeCmd(root) {
6815
6884
  { name: 'leerness_usage_stats', description: 'leerness 명령별 누적 호출 통계 + drift 통계', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
6816
6885
  { name: 'leerness_session_close', description: '세션 마감 — handoff/current-state/task-log 자동 갱신', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
6817
6886
  { name: 'leerness_skill_suggest', description: '1.9.53 — 사용 패턴 자동 분석 → 새 skill 후보 제안 (Hermes-style 자동 학습)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, min: { type: 'number' }, days: { type: 'number' } } } },
6818
- { 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' } } } }
6887
+ { 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' } } } },
6888
+ { 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' } } } }
6819
6889
  ];
6820
6890
 
6821
6891
  function send(obj) {
@@ -6857,6 +6927,7 @@ function mcpServeCmd(root) {
6857
6927
  case 'leerness_session_close': cliArgs = ['session', 'close', targetPath]; break;
6858
6928
  case 'leerness_skill_suggest': cliArgs = ['skill', 'suggest', '--path', targetPath, '--json', ...(args.min ? ['--min', String(args.min)] : []), ...(args.days ? ['--days', String(args.days)] : [])]; break;
6859
6929
  case 'leerness_lessons': cliArgs = ['lessons', '--path', targetPath, ...(args.auto ? ['--auto'] : []), ...(args.query ? ['--query', args.query] : []), ...(args.limit ? ['--limit', String(args.limit)] : [])]; break;
6930
+ case 'leerness_task_export': cliArgs = ['task', 'export', '--path', targetPath, ...(args.to ? ['--to', args.to] : ['--json'])]; break;
6860
6931
  default:
6861
6932
  return send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Unknown tool: ${name}` } });
6862
6933
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.65",
3
+ "version": "1.9.67",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",