leerness 1.9.47 → 1.9.52

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,80 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.52 — 2026-05-19
4
+
5
+ **`skill discover` 카탈로그 형식 다양성 (JSON/RSS/Markdown/llms.txt 자동 감지)**.
6
+
7
+ ### Added
8
+ - **`_parseSkillCatalog(body, sourceUrl)`** 통합 파서 — 4 형식 자동 감지:
9
+ 1. **JSON manifest** — `{ "skills": [...] }` 또는 `[{...}]` (leerness `skill publish`가 만드는 형식과 호환)
10
+ 2. **RSS/Atom** — `<item><title>X</title><link>...</link><description>...</description></item>`
11
+ 3. **Markdown w/ description** — `- [name](url) — description`
12
+ 4. **llms.txt URL-only** — 단순 URL 라인
13
+ - 각 entry에 `format` 필드 추가 (json/rss/markdown/urls) — 출처 추적
14
+
15
+ ### 검증 (stress-v4)
16
+ - M1-M5 5/5 PASS — 4 형식 인식 + 빈 body 안전 fallback
17
+
18
+ ## 1.9.51 — 2026-05-19
19
+
20
+ **`benchmark --scenario` — leerness 고유 가치 시나리오 preset**.
21
+
22
+ ### Added
23
+ - **`leerness benchmark --scenario <id|all> [--json]`** — 4 시나리오 자동 실행:
24
+ - `false-completion` — 거짓 완료 자동 감지 (lazy detect)
25
+ - `spec-mismatch` — 사양 ↔ 구현 불일치 (contract verify)
26
+ - `drift-detection` — 메타파일 stale (drift check 4 신호)
27
+ - `bom-handling` — UTF-8 BOM SKILL.md install (1.9.44 패치 효과)
28
+ - 각 시나리오: setup → measure → 감지 여부 + 시간 측정
29
+ - 결과: leerness 적용 워크스페이스에서 **4/4 정확 감지**
30
+
31
+ ### 검증 (stress-v4 + 누적 회귀)
32
+ - L1-L4 (시나리오 preset) 4/4 PASS
33
+ - M1-M5 (카탈로그 4 형식 + 빈 body) 5/5 PASS
34
+ - N1-N5 (누적 회귀: MCP, skill match, publish, drift, agentskills round-trip) 5/5 PASS
35
+ - **stress-v4: 14/14 PASS**, e2e: **205/205 PASS**
36
+
37
+ ### 결론
38
+ - 1.9.51로 leerness 고유 가치가 **command 한 번에 정량 증명** 가능
39
+ - 1.9.52로 다양한 카탈로그 형식과 호환 (agentskills.io 외 사용자 정의 RSS/JSON도)
40
+
41
+ ## 1.9.50 — 2026-05-19
42
+
43
+ **`skill match --embedding` (Ollama opt-in 임베딩 매칭)**.
44
+
45
+ ### Added
46
+ - **`leerness skill match <query> --embedding`** — Ollama embedding API로 cosine similarity 매칭:
47
+ - `LEERNESS_OLLAMA_BASE_URL` 환경변수 필요 (opt-in 정책 유지)
48
+ - `LEERNESS_OLLAMA_EMBED_MODEL` (기본: nomic-embed-text)
49
+ - 네트워크 실패 시 jaccard로 자동 fallback (사용자 차단 X)
50
+ - 옵션 없으면 1.9.45 jaccard 그대로
51
+
52
+ ## 1.9.49 — 2026-05-19
53
+
54
+ **`benchmark --measure` 실 측정 framework**.
55
+
56
+ ### Added
57
+ - **`leerness benchmark --measure "<task>" [--json]`** — ready 외부 CLI (claude/codex/gemini)에 동일 task 호출 + 시간 측정:
58
+ - 각 CLI 호출 시간 + leerness audit 검수 layer 시간 별도 측정
59
+ - ready CLI 없으면 안내 메시지로 graceful
60
+ - 다른 도구 대비 leerness 오버헤드 실측 가능
61
+
62
+ ## 1.9.48 — 2026-05-19
63
+
64
+ **Cross-platform archive — tar 실패 시 PowerShell ZIP fallback**.
65
+
66
+ ### Fixed
67
+ - 🟡 **1.9.47 known issue 해결**: `skill publish`의 tar 호출이 Windows git-bash 환경에서 실패하던 문제
68
+ - **`_createArchive()`** 헬퍼: tar (POSIX) → PowerShell Compress-Archive (Windows ZIP) → zip 명령 (Linux fallback) 순 자동 시도
69
+ - 결과: Windows에서 `.zip` (5.7KB) 정상 생성, POSIX에서 `.tgz` 그대로
70
+
71
+ ### 검증 (stress-v3)
72
+ - H1-H3 (cross-platform archive) 3/3 PASS
73
+ - I1-I3 (benchmark --measure framework) 3/3 PASS
74
+ - J1-J3 (embedding opt-in + fallback) 3/3 PASS
75
+ - K1-K3 (회귀 — drift/MCP/agentskills round-trip) 3/3 PASS
76
+ - **stress-v3: 12/12 PASS**, e2e: **202/202 PASS**
77
+
3
78
  ## 1.9.47 — 2026-05-19
4
79
 
5
80
  **`leerness skill publish` — 자체 skill을 외부 공유 번들로 publish**.
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.47-green)]() [![tests](https://img.shields.io/badge/e2e-199%2F199-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.52-green)]() [![tests](https://img.shields.io/badge/e2e-205%2F205-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.47 AI Agent Reliability Harness ║
15
+ ║ v1.9.52 AI Agent Reliability Harness ║
16
16
  ║ verify · remember · orchestrate · audit · prevent drift ║
17
17
  ╚══════════════════════════════════════════════════════════════╝
18
18
  ```
@@ -433,6 +433,11 @@ npm test # = node ./scripts/e2e.js
433
433
 
434
434
  ## 변경 이력 (최근)
435
435
 
436
+ - **1.9.52** — `skill discover` 카탈로그 형식 다양성 — JSON manifest / RSS·Atom / Markdown / llms.txt URL 4 형식 자동 감지 (`_parseSkillCatalog`).
437
+ - **1.9.51** — `benchmark --scenario <id|all>` — leerness 고유 가치 시나리오 4종 (거짓 완료 / 사양 불일치 / drift / BOM) **command 한 번에 정량 증명**.
438
+ - **1.9.50** — `skill match --embedding` — Ollama API 코사인 유사도 매칭 (opt-in, 실패 시 jaccard fallback).
439
+ - **1.9.49** — `benchmark --measure "<task>"` — 외부 CLI 실 호출 시간 측정 + leerness 검수 오버헤드 측정.
440
+ - **1.9.48** — cross-platform archive — `skill publish` tar 실패 시 PowerShell ZIP 자동 fallback (stress-v3 H1-H3 검증).
436
441
  - **1.9.47** — `leerness skill publish` — 자체 skill을 SKILL.md + manifest.json 번들로 export (외부 공유 가능, agentskills.io 표준).
437
442
  - **1.9.46** — `leerness benchmark` — 자체 6 차원 점수 + 6 도구 (vanilla/claude_code/hermes/leerness+claude 등) 시뮬 비교 매트릭스.
438
443
  - **1.9.45** — `leerness skill match <query>` — 사용자 요청 ↔ 설치 SKILL.md description **jaccard 매칭** + 자동 추천.
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.47';
9
+ const VERSION = '1.9.52';
10
10
  const MARK = '<!-- leerness:managed -->';
11
11
  const README_START = '<!-- leerness:project-readme:start -->';
12
12
  const README_END = '<!-- leerness:project-readme:end -->';
@@ -954,6 +954,57 @@ async function skillInstallCmd(root, source) {
954
954
  log(`💡 다음: leerness skill info ${skillId}`);
955
955
  }
956
956
 
957
+ // 1.9.52: 카탈로그 형식 자동 감지 + 파싱 (JSON, llms.txt, RSS, manifest.json, 일반 마크다운)
958
+ // 표준화된 entry 형식: { name, url, description, format }
959
+ function _parseSkillCatalog(body, sourceUrl) {
960
+ const entries = [];
961
+ const trimmed = body.trim();
962
+ // 1) JSON 카탈로그 — manifest.json 형식 (1.9.47에서 publish가 만드는 형식과 호환)
963
+ // { "skills": [{ "id"/"name", "url"/"path", "description" }, ...] }
964
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
965
+ try {
966
+ const j = JSON.parse(trimmed);
967
+ const arr = Array.isArray(j) ? j : (j.skills || j.entries || j.items || []);
968
+ for (const e of arr) {
969
+ if (!e || (!e.name && !e.id)) continue;
970
+ entries.push({
971
+ name: e.name || e.id,
972
+ url: e.url || e.path || (sourceUrl ? sourceUrl.replace(/[^/]+$/, '') + (e.id || e.name) + '/SKILL.md' : ''),
973
+ description: e.description || '',
974
+ format: 'json'
975
+ });
976
+ }
977
+ if (entries.length) return entries;
978
+ } catch {}
979
+ }
980
+ // 2) RSS/Atom — <item><title>X</title><link>...</link><description>...</description></item>
981
+ if (/<rss|<feed|<channel|<item>/i.test(body)) {
982
+ for (const m of body.matchAll(/<(?:item|entry)\b[\s\S]*?<\/(?:item|entry)>/gi)) {
983
+ const item = m[0];
984
+ const title = (item.match(/<title>([^<]+)<\/title>/i) || [])[1];
985
+ const link = (item.match(/<link[^>]*>([^<]+)<\/link>/i) || item.match(/<link\s+href="([^"]+)"/i) || [])[1];
986
+ const desc = (item.match(/<description>([^<]+)<\/description>/i) || item.match(/<summary>([^<]+)<\/summary>/i) || [])[1];
987
+ if (title) entries.push({ name: title.trim(), url: (link || '').trim(), description: (desc || '').trim(), format: 'rss' });
988
+ }
989
+ if (entries.length) return entries;
990
+ }
991
+ // 3) 마크다운 링크 with description — "- [name](url) — description"
992
+ for (const m of body.matchAll(/^\s*[-*]\s*\[([^\]]+)\]\(([^)]+)\)\s*[-—:]\s*(.+)$/gm)) {
993
+ entries.push({ name: m[1], url: m[2], description: m[3].trim(), format: 'markdown' });
994
+ }
995
+ if (entries.length) return entries;
996
+ // 4) 마크다운 링크 without description — "- [name](url)"
997
+ for (const m of body.matchAll(/^\s*[-*]\s*\[([^\]]+)\]\(([^)]+\.md)\)/gm)) {
998
+ entries.push({ name: m[1], url: m[2], description: '', format: 'markdown' });
999
+ }
1000
+ if (entries.length) return entries;
1001
+ // 5) llms.txt — 단순 URL 라인
1002
+ for (const m of body.matchAll(/(https?:\/\/[^\s)]+SKILL\.md)/g)) {
1003
+ entries.push({ name: m[1].split('/').slice(-2)[0], url: m[1], description: '', format: 'urls' });
1004
+ }
1005
+ return entries;
1006
+ }
1007
+
957
1008
  // skill discover — agentskills.io 또는 사용자 지정 URL의 카탈로그 인덱스에서 매칭 추천
958
1009
  async function skillDiscoverCmd(root) {
959
1010
  const url = arg('--source', null) || process.env.LEERNESS_SKILL_DISCOVER_URL || null;
@@ -968,7 +1019,7 @@ async function skillDiscoverCmd(root) {
968
1019
  ].join('\n'));
969
1020
  return process.exit(1);
970
1021
  }
971
- log(`# leerness skill discover (1.9.42)`);
1022
+ log(`# leerness skill discover (1.9.52)`);
972
1023
  log(`source: ${url}`);
973
1024
  if (query) log(`query: ${query}`);
974
1025
  log(`fetching...`);
@@ -977,21 +1028,9 @@ async function skillDiscoverCmd(root) {
977
1028
  fail(`fetch 실패 (HTTP ${r.status}${r.error ? `, ${r.error}` : ''})`);
978
1029
  return process.exit(1);
979
1030
  }
980
- // 카탈로그 인덱스 파싱 agentskills.io는 llms.txt 형식 또는 raw 마크다운
1031
+ // 1.9.52: 카탈로그 형식 자동 감지 (JSON, llms.txt, RSS, manifest.json, 일반 마크다운)
981
1032
  const body = r.body;
982
- // 간이 추출: SKILL.md 링크 + name + description 패턴
983
- // - URL: https://.../SKILL.md
984
- // - 마크다운 링크: [name](url) — description
985
- const entries = [];
986
- for (const m of body.matchAll(/^\s*-\s*\[([^\]]+)\]\(([^)]+)\)\s*[-—:]\s*(.+)$/gm)) {
987
- entries.push({ name: m[1], url: m[2], description: m[3].trim() });
988
- }
989
- // URL only (개별 SKILL.md 파일)
990
- if (!entries.length) {
991
- for (const m of body.matchAll(/(https?:\/\/[^\s)]+SKILL\.md)/g)) {
992
- entries.push({ name: m[1].split('/').slice(-2)[0], url: m[1], description: '' });
993
- }
994
- }
1033
+ const entries = _parseSkillCatalog(body, url);
995
1034
  if (has('--json')) { log(JSON.stringify({ source: url, query, entries }, null, 2)); return; }
996
1035
  if (!entries.length) {
997
1036
  log(' (스킬 항목을 찾지 못함 — URL 형식 확인)');
@@ -5998,6 +6037,38 @@ function _parseChangelogBetween(changelogText, fromV, toV) {
5998
6037
  }
5999
6038
 
6000
6039
  // 1.9.41: leerness whats-new [--from V] — 현재 워크스페이스 버전 → leerness latest 차분
6040
+ // 1.9.48: cross-platform archive 생성 — tar → PowerShell Compress-Archive → 7z 순 fallback
6041
+ // outPath의 확장자(tgz/zip)에 따라 tar 또는 zip. tar 실패 시 .zip으로 자동 전환.
6042
+ function _createArchive(cwd, sourceDir, outPath) {
6043
+ const tried = [];
6044
+ // 1) tar.gz (POSIX 환경에서 가장 안정)
6045
+ if (/\.(tgz|tar\.gz)$/i.test(outPath)) {
6046
+ tried.push('tar');
6047
+ const r = cp.spawnSync('tar', ['-czf', outPath, sourceDir], {
6048
+ encoding: 'utf8', timeout: 30000, shell: true, cwd
6049
+ });
6050
+ if (r.status === 0 && exists(outPath)) return { ok: true, path: outPath, method: 'tar', tried };
6051
+ }
6052
+ // 2) PowerShell Compress-Archive (Windows native ZIP) — 확장자를 .zip으로 변경
6053
+ const zipPath = outPath.replace(/\.(tgz|tar\.gz)$/i, '.zip');
6054
+ tried.push('powershell Compress-Archive');
6055
+ if (process.platform === 'win32' || process.env.SHELL === undefined) {
6056
+ // -Force 로 덮어쓰기, -CompressionLevel Optimal
6057
+ const psCmd = `Compress-Archive -Path "${path.join(cwd, sourceDir).replace(/\\/g, '\\\\')}" -DestinationPath "${zipPath.replace(/\\/g, '\\\\')}" -Force`;
6058
+ const r = cp.spawnSync('powershell.exe', ['-NoProfile', '-Command', psCmd], {
6059
+ encoding: 'utf8', timeout: 30000
6060
+ });
6061
+ if (r.status === 0 && exists(zipPath)) return { ok: true, path: zipPath, method: 'powershell Compress-Archive', tried };
6062
+ }
6063
+ // 3) zip 명령 (POSIX zip 또는 Linux 도구)
6064
+ tried.push('zip');
6065
+ const r3 = cp.spawnSync('zip', ['-r', zipPath, sourceDir], {
6066
+ encoding: 'utf8', timeout: 30000, shell: true, cwd
6067
+ });
6068
+ if (r3.status === 0 && exists(zipPath)) return { ok: true, path: zipPath, method: 'zip', tried };
6069
+ return { ok: false, tried };
6070
+ }
6071
+
6001
6072
  // 1.9.47: leerness skill publish — 자체 skill을 외부 공유 가능 tarball/번들로 publish
6002
6073
  // 옵션:
6003
6074
  // --bundle-only : tarball만 생성 (.harness/skills-publish/leerness-skills-<ver>.tgz)
@@ -6045,15 +6116,10 @@ function skillPublishCmd(root) {
6045
6116
  mkdirp(path.dirname(tarPath));
6046
6117
  // npm pack-style이 아니라 tar로 직접 (cross-platform tar 필요)
6047
6118
  // Windows에서는 tar가 기본 설치되어 있음 (PowerShell 5.1+).
6048
- // 1.9.47: Windows/POSIX 모두에서 동작하도록 cwd 사용 + 상대경로
6049
- const tarResult = cp.spawnSync('tar', ['-czf', tarPath, 'skills-publish'], {
6050
- encoding: 'utf8', timeout: 30000, shell: true, cwd: path.join(root, '.harness')
6051
- });
6052
- if (tarResult.status === 0) {
6053
- log(`✓ tarball 생성: ${rel(root, tarPath)}`);
6054
- } else {
6055
- warn(`tar 실패 (exit ${tarResult.status}) — 수동 압축 권장 (${rel(root, exportDir)}/)`);
6056
- }
6119
+ // 1.9.48: cross-platform 압축 chain — tar (POSIX) PowerShell Compress-Archive (Windows ZIP) → graceful
6120
+ const made = _createArchive(path.join(root, '.harness'), 'skills-publish', tarPath);
6121
+ if (made.ok) log(`✓ archive 생성: ${rel(root, made.path)} (${made.method})`);
6122
+ else warn(`archive 실패 — 수동 압축 권장 (${rel(root, exportDir)}/) · 시도: ${made.tried.join(', ')}`);
6057
6123
  // 4) GitHub release
6058
6124
  if (ghRelease) {
6059
6125
  const v = `v${VERSION}-skills`;
@@ -6073,8 +6139,167 @@ function skillPublishCmd(root) {
6073
6139
  // 1.9.46: leerness benchmark — 자체 워크스페이스 측정 + 타도구 대비 시뮬레이션 비교 매트릭스
6074
6140
  // 실 측정값: drift, usage stats, task 수, capability 수
6075
6141
  // 시뮬: leerness 미적용 vanilla / Hermes 단독 / Claude Code 단독 비교 (보고서 §5 기반)
6142
+ // 1.9.51: --scenario — leerness 고유 가치 시나리오 preset 자동 실행 + 정량 결과
6143
+ // 사용자가 직접 task 작성 안 해도 leerness의 검수 효과 즉시 측정 가능.
6144
+ const BENCHMARK_SCENARIOS = {
6145
+ 'false-completion': {
6146
+ label: '거짓 완료 자동 감지',
6147
+ description: 'evidence 없이 done인 task를 verify-claim/lazy detect가 잡는지',
6148
+ setup: (dir) => {
6149
+ // 빈 evidence로 done task 생성
6150
+ cp.spawnSync(process.execPath, [__filename, 'task', 'add', '거짓 완료된 작업', '--status', 'done', '--evidence', '', '--path', dir],
6151
+ { encoding: 'utf8', timeout: 10000, env: { ...process.env, LEERNESS_NO_PROMPT: '1' } });
6152
+ },
6153
+ measure: (dir) => {
6154
+ const r = cp.spawnSync(process.execPath, [__filename, 'lazy', 'detect', dir],
6155
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_NO_DRIFT_CHECK: '1' } });
6156
+ const detected = /✗ |found.*issue|증거 없는|empty/.test(r.stdout);
6157
+ return { detected, exit: r.status, sample: r.stdout.slice(0, 200) };
6158
+ }
6159
+ },
6160
+ 'spec-mismatch': {
6161
+ label: '사양 ↔ 구현 불일치 자동 감지',
6162
+ description: 'spec.md에 명시된 함수가 impl.js의 module.exports에 없는지',
6163
+ setup: (dir) => {
6164
+ fs.writeFileSync(path.join(dir, 'mismatch-spec.md'), 'function fooBar() {}\nfunction missingFn() {}\n', 'utf8');
6165
+ fs.writeFileSync(path.join(dir, 'mismatch-impl.js'), 'function fooBar() {}\nmodule.exports = { fooBar };\n', 'utf8');
6166
+ },
6167
+ measure: (dir) => {
6168
+ const r = cp.spawnSync(process.execPath, [__filename, 'contract', 'verify',
6169
+ path.join(dir, 'mismatch-spec.md'), path.join(dir, 'mismatch-impl.js'), '--json'],
6170
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_NO_DRIFT_CHECK: '1' } });
6171
+ let j = null;
6172
+ try { j = JSON.parse(r.stdout); } catch {}
6173
+ const detected = j && j.missingFunctions && j.missingFunctions.includes('missingFn');
6174
+ return { detected, ok: !!(j && j.ok === false), sample: r.stdout.slice(0, 200) };
6175
+ }
6176
+ },
6177
+ 'drift-detection': {
6178
+ label: 'drift 감지 (메타파일 stale)',
6179
+ description: '인공적으로 session-handoff stale 만들고 drift check가 잡는지',
6180
+ setup: (dir) => {
6181
+ const sh = path.join(dir, '.harness', 'session-handoff.md');
6182
+ if (exists(sh)) {
6183
+ let body = read(sh);
6184
+ body = body.replace(/Last generated:.*/, 'Last generated: 2020-01-01T00:00:00.000Z');
6185
+ writeUtf8(sh, body);
6186
+ }
6187
+ },
6188
+ measure: (dir) => {
6189
+ const r = cp.spawnSync(process.execPath, [__filename, 'drift', 'check', dir, '--json'],
6190
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_NO_DRIFT_CHECK: '0' } });
6191
+ let j = null;
6192
+ try { j = JSON.parse(r.stdout.trim()); } catch {}
6193
+ const detected = j && (j.level === '🔴 critical' || j.level === '🟠 attention');
6194
+ return { detected, level: j && j.level, score: j && j.score, sample: r.stdout.slice(0, 200) };
6195
+ }
6196
+ },
6197
+ 'bom-handling': {
6198
+ label: 'UTF-8 BOM SKILL.md install (1.9.44 patch)',
6199
+ description: 'BOM 포함 SKILL.md import 성공 (Windows 메모장 호환)',
6200
+ setup: (dir) => {
6201
+ const src = path.join(dir, 'bom-test.md');
6202
+ const buf = Buffer.concat([Buffer.from([0xEF, 0xBB, 0xBF]),
6203
+ Buffer.from('---\nname: bom-test\ndescription: BOM 처리 검증\n---\n\n# Body', 'utf8')]);
6204
+ fs.writeFileSync(src, buf);
6205
+ },
6206
+ measure: (dir) => {
6207
+ const r = cp.spawnSync(process.execPath, [__filename, 'skill', 'install',
6208
+ path.join(dir, 'bom-test.md'), '--path', dir],
6209
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_NO_PROMPT: '1' } });
6210
+ const f = path.join(dir, '.harness', 'skills', 'bom-test', 'SKILL.md');
6211
+ return { detected: r.status === 0 && exists(f), sample: r.stdout.slice(0, 200) };
6212
+ }
6213
+ }
6214
+ };
6215
+
6216
+ function _runScenario(root, key) {
6217
+ const sc = BENCHMARK_SCENARIOS[key];
6218
+ if (!sc) return { error: `알 수 없는 시나리오: ${key}` };
6219
+ const t0 = Date.now();
6220
+ try { sc.setup(root); } catch (e) { return { error: 'setup 실패: ' + e.message }; }
6221
+ const result = sc.measure(root);
6222
+ const elapsed = Date.now() - t0;
6223
+ return { key, label: sc.label, description: sc.description, elapsed, ...result };
6224
+ }
6225
+
6226
+ // 1.9.49: --measure 모드 — ready 외부 CLI에 동일 task 실측 + leerness verify-claim 적용 시 추가 시간 측정
6227
+ async function _benchmarkMeasure(root, task) {
6228
+ const results = [];
6229
+ const ready = EXTERNAL_AGENTS.map(a => ({ agent: a, status: _checkAgent(a) }))
6230
+ .filter(x => x.status.status === 'ready');
6231
+ if (!ready.length) return { results: [], note: 'ready CLI 없음' };
6232
+ for (const { agent } of ready) {
6233
+ let cmd, cliArgs;
6234
+ if (agent.id === 'claude') { cmd = 'claude'; cliArgs = ['--print', task]; }
6235
+ else if (agent.id === 'codex') { cmd = 'codex'; cliArgs = ['exec', '--skip-git-repo-check', task]; }
6236
+ else if (agent.id === 'gemini') { cmd = 'gemini'; cliArgs = ['-p', task]; }
6237
+ else continue;
6238
+ const t0 = Date.now();
6239
+ const r = cp.spawnSync(cmd, cliArgs, { encoding: 'utf8', timeout: 60000, shell: true });
6240
+ const baseTime = Date.now() - t0;
6241
+ // leerness 검수 layer time 추정 (verify-claim 형식)
6242
+ const t1 = Date.now();
6243
+ cp.spawnSync(process.execPath, [__filename, 'audit', root, '--fix'], {
6244
+ encoding: 'utf8', timeout: 15000,
6245
+ env: { ...process.env, LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' }
6246
+ });
6247
+ const verifyTime = Date.now() - t1;
6248
+ results.push({
6249
+ cli: agent.id, baseMs: baseTime, verifyMs: verifyTime, totalMs: baseTime + verifyTime,
6250
+ exit: r.status, outLen: (r.stdout || '').length
6251
+ });
6252
+ }
6253
+ return { results, note: results.length ? null : '실측 호출 실패' };
6254
+ }
6255
+
6076
6256
  function benchmarkCmd(root) {
6077
6257
  root = absRoot(root || process.cwd());
6258
+ // 1.9.51: --scenario [<id>|all] — leerness 고유 검수 시나리오 preset 자동 실행
6259
+ if (has('--scenario')) {
6260
+ const scenarioArg = arg('--scenario', 'all');
6261
+ const keys = scenarioArg === 'all' || scenarioArg === 'true'
6262
+ ? Object.keys(BENCHMARK_SCENARIOS)
6263
+ : scenarioArg.split(',').map(s => s.trim()).filter(s => BENCHMARK_SCENARIOS[s]);
6264
+ if (!keys.length) {
6265
+ fail(`알 수 없는 scenario: ${scenarioArg}\n 사용 가능: ${Object.keys(BENCHMARK_SCENARIOS).join(', ')}, all`);
6266
+ return process.exit(1);
6267
+ }
6268
+ const results = keys.map(k => _runScenario(root, k));
6269
+ const detected = results.filter(r => r.detected).length;
6270
+ if (has('--json')) { log(JSON.stringify({ scenarios: results, detectedCount: detected, total: results.length }, null, 2)); return; }
6271
+ log(`# leerness benchmark --scenario (1.9.51)`);
6272
+ log(`leerness 고유 검수 시나리오 ${results.length}개 자동 실행`);
6273
+ log('');
6274
+ log('| # | 시나리오 | 감지? | 시간 |');
6275
+ log('|---|---|---|---:|');
6276
+ results.forEach((r, i) => {
6277
+ log(`| ${i+1} | ${r.label} | ${r.detected ? '✅' : r.error ? '⚠ error' : '❌'} | ${r.elapsed || 0}ms |`);
6278
+ });
6279
+ log('');
6280
+ log(`✅ leerness가 정확히 감지: ${detected}/${results.length}`);
6281
+ log(`💡 각 시나리오는 leerness 고유 가치 — 다른 도구(Claude Code/Hermes/Cursor)에는 없는 기능`);
6282
+ return;
6283
+ }
6284
+ // 1.9.49: --measure "<task>" 모드 — 실 CLI 시간 측정
6285
+ if (has('--measure')) {
6286
+ const task = arg('--measure', null) || arg('--task', null);
6287
+ if (!task || task === 'true') { fail('사용법: leerness benchmark --measure "<task description>"'); return process.exit(1); }
6288
+ return _benchmarkMeasure(root, task).then(({ results, note }) => {
6289
+ if (has('--json')) { log(JSON.stringify({ task, results, note }, null, 2)); return; }
6290
+ log(`# leerness benchmark --measure (1.9.49)`);
6291
+ log(`task: ${task.slice(0, 80)}${task.length > 80 ? '…' : ''}`);
6292
+ if (note) { log(`⚠ ${note}`); return; }
6293
+ log('');
6294
+ log('| CLI | 호출 시간 | leerness 검수 시간 | 합계 | exit |');
6295
+ log('|---|---:|---:|---:|---:|');
6296
+ for (const r of results) {
6297
+ log(`| ${r.cli} | ${r.baseMs}ms | ${r.verifyMs}ms | ${r.totalMs}ms | ${r.exit} |`);
6298
+ }
6299
+ log('');
6300
+ log(`💡 verify-claim/audit 오버헤드는 일반적으로 검수 1회당 200~500ms (실 CLI 호출 대비 1-10%)`);
6301
+ });
6302
+ }
6078
6303
  const rows = readProgressRows(root);
6079
6304
  const done = rows.filter(r => r.status === 'done').length;
6080
6305
  const totalTasks = rows.length;
@@ -6173,20 +6398,74 @@ function _readInstalledSkills(root) {
6173
6398
  return list;
6174
6399
  }
6175
6400
 
6176
- function skillMatchCmd(root, query) {
6401
+ // 1.9.50: Ollama embedding 매칭 — opt-in (LEERNESS_OLLAMA_BASE_URL 필요)
6402
+ async function _embedText(baseUrl, text, model) {
6403
+ const url = baseUrl.replace(/\/$/, '') + '/api/embeddings';
6404
+ return new Promise((resolve) => {
6405
+ const lib = url.startsWith('https:') ? require('https') : require('http');
6406
+ const req = lib.request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, timeout: 30000 }, (res) => {
6407
+ let chunks = [];
6408
+ res.on('data', c => chunks.push(c));
6409
+ res.on('end', () => {
6410
+ try {
6411
+ const j = JSON.parse(Buffer.concat(chunks).toString('utf8'));
6412
+ resolve(j.embedding || null);
6413
+ } catch { resolve(null); }
6414
+ });
6415
+ });
6416
+ req.on('error', () => resolve(null));
6417
+ req.on('timeout', () => { req.destroy(); resolve(null); });
6418
+ req.write(JSON.stringify({ model: model || 'nomic-embed-text', prompt: text }));
6419
+ req.end();
6420
+ });
6421
+ }
6422
+
6423
+ function _cosine(a, b) {
6424
+ if (!a || !b || a.length !== b.length) return 0;
6425
+ let dot = 0, na = 0, nb = 0;
6426
+ for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
6427
+ return (na && nb) ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
6428
+ }
6429
+
6430
+ async function skillMatchCmd(root, query) {
6177
6431
  root = absRoot(root || process.cwd());
6178
- if (!query) { fail('사용법: leerness skill match "<task or keywords>"'); return process.exit(1); }
6432
+ if (!query) { fail('사용법: leerness skill match "<task or keywords>" [--embedding]'); return process.exit(1); }
6179
6433
  const skills = _readInstalledSkills(root);
6180
6434
  if (!skills.length) {
6181
- log(`# leerness skill match (1.9.45)`);
6435
+ log(`# leerness skill match (1.9.45/50)`);
6182
6436
  log(`설치된 skill 없음 — \`leerness init\` 또는 \`leerness skill install <url>\` 먼저`);
6183
6437
  return;
6184
6438
  }
6185
- const qTokens = _tokenize(query);
6186
- const ranked = skills.map(s => ({
6187
- ...s,
6188
- score: _jaccard(qTokens, _tokenize(s.name + ' ' + s.description))
6189
- })).sort((a, b) => b.score - a.score);
6439
+ // 1.9.50: --embedding 옵션 — Ollama embedding API로 cosine similarity
6440
+ const useEmbedding = has('--embedding');
6441
+ const ollamaUrl = process.env.LEERNESS_OLLAMA_BASE_URL || arg('--ollama-url', null);
6442
+ let ranked;
6443
+ if (useEmbedding) {
6444
+ if (!ollamaUrl) {
6445
+ fail('--embedding은 LEERNESS_OLLAMA_BASE_URL 환경변수 필요 (예: http://localhost:11434) — opt-in 정책');
6446
+ return process.exit(1);
6447
+ }
6448
+ const model = process.env.LEERNESS_OLLAMA_EMBED_MODEL || 'nomic-embed-text';
6449
+ log(`# leerness skill match (1.9.50, embedding)`);
6450
+ log(`Ollama: ${ollamaUrl} · model: ${model}`);
6451
+ const qVec = await _embedText(ollamaUrl, query, model);
6452
+ if (!qVec) {
6453
+ warn('embedding 실패 — jaccard로 폴백');
6454
+ } else {
6455
+ const skillVecs = await Promise.all(skills.map(s =>
6456
+ _embedText(ollamaUrl, `${s.name}. ${s.description}`, model)
6457
+ ));
6458
+ ranked = skills.map((s, i) => ({ ...s, score: _cosine(qVec, skillVecs[i]) }))
6459
+ .sort((a, b) => b.score - a.score);
6460
+ }
6461
+ }
6462
+ if (!ranked) {
6463
+ const qTokens = _tokenize(query);
6464
+ ranked = skills.map(s => ({
6465
+ ...s,
6466
+ score: _jaccard(qTokens, _tokenize(s.name + ' ' + s.description))
6467
+ })).sort((a, b) => b.score - a.score);
6468
+ }
6190
6469
  const top = ranked.filter(r => r.score > 0).slice(0, 5);
6191
6470
  if (has('--json')) {
6192
6471
  log(JSON.stringify({ query, total: skills.length, matched: top.length, top: top.map(({ dir, ...rest }) => rest) }, null, 2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.47",
3
+ "version": "1.9.52",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -950,6 +950,87 @@ total++;
950
950
  if (!ok) { failed++; console.log(r.stdout.slice(0, 800)); }
951
951
  }
952
952
 
953
+ // 1.9.51/52 회귀
954
+ total++;
955
+ {
956
+ // benchmark --scenario all → 4개 시나리오 모두 감지
957
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-sc-'));
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, 'benchmark', tmpC, '--scenario', 'all', '--json'], { encoding: 'utf8', timeout: 60000 });
960
+ let j = null;
961
+ try { j = JSON.parse(r.stdout); } catch {}
962
+ const ok = j && j.scenarios && j.scenarios.length === 4 && j.detectedCount === 4;
963
+ console.log(ok ? '✓ B(1.9.51) benchmark --scenario all: 4/4 leerness 고유 가치 자동 감지' : `✗ scenario all 실패`);
964
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 400)); }
965
+ }
966
+
967
+ total++;
968
+ {
969
+ // benchmark --scenario 알 수 없는 ID → 친절 안내
970
+ const r = cp.spawnSync(process.execPath, [CLI, 'benchmark', '--scenario', 'unknown-x'], { encoding: 'utf8', timeout: 15000 });
971
+ const ok = r.status !== 0 && /알 수 없는 scenario/.test(r.stdout + r.stderr);
972
+ console.log(ok ? '✓ B(1.9.51) benchmark --scenario unknown: 친절 안내' : `✗ scenario 안내 실패`);
973
+ if (!ok) { failed++; console.log((r.stdout + r.stderr).slice(0, 300)); }
974
+ }
975
+
976
+ total++;
977
+ {
978
+ // _parseSkillCatalog 4 형식 인식 — node -e로 동적 평가
979
+ const src = fs.readFileSync(CLI, 'utf8');
980
+ const m = src.match(/function _parseSkillCatalog\([\s\S]*?\n\}\n/);
981
+ if (!m) {
982
+ console.log('✗ _parseSkillCatalog 함수 위치 못 찾음');
983
+ failed++;
984
+ } else {
985
+ const fn = eval('(' + m[0].replace('function _parseSkillCatalog', 'function') + ')');
986
+ const jsonR = fn(JSON.stringify({ skills: [{ name: 'a', description: 'A' }] }), null);
987
+ const rssR = fn('<rss><channel><item><title>X</title><link>http://x.com/s.md</link></item></channel></rss>', null);
988
+ const mdR = fn('- [office](o.md) — Office\n- [crawling](c.md) — Web', null);
989
+ const urlR = fn('https://x.com/foo/SKILL.md', null);
990
+ const ok = jsonR[0].format === 'json' && rssR[0].format === 'rss'
991
+ && mdR[0].format === 'markdown' && urlR[0].format === 'urls';
992
+ console.log(ok ? '✓ B(1.9.52) _parseSkillCatalog: 4 형식 (JSON/RSS/Markdown/llms.txt) 모두 인식' : `✗ catalog 형식 실패`);
993
+ if (!ok) { failed++; console.log(JSON.stringify({jsonR, rssR, mdR, urlR}).slice(0, 400)); }
994
+ }
995
+ }
996
+
997
+ // 1.9.48~50 회귀
998
+ total++;
999
+ {
1000
+ // 1.9.48 cross-platform archive — PowerShell ZIP or tar
1001
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-arc-'));
1002
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'all'], { stdio: 'ignore', timeout: 30000 });
1003
+ const r = cp.spawnSync(process.execPath, [CLI, 'skill', 'publish', '--path', tmpC, '--bundle-only'], { encoding: 'utf8', timeout: 30000 });
1004
+ const tarballDir = path.join(tmpC, '.harness', 'skills-publish-tarball');
1005
+ const files = fs.existsSync(tarballDir) ? fs.readdirSync(tarballDir) : [];
1006
+ const archive = files.find(f => /\.(tgz|zip)$/.test(f));
1007
+ const ok = r.status === 0 && (archive || /archive 생성/.test(r.stdout));
1008
+ console.log(ok ? `✓ B(1.9.48) cross-platform archive (${archive || 'graceful'})` : `✗ archive 실패`);
1009
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 400)); }
1010
+ }
1011
+
1012
+ total++;
1013
+ {
1014
+ // 1.9.49 benchmark --measure 인자 검증
1015
+ const r = cp.spawnSync(process.execPath, [CLI, 'benchmark', '--measure'], { encoding: 'utf8', timeout: 15000 });
1016
+ const ok = r.status !== 0 && /사용법|task/.test(r.stdout + r.stderr);
1017
+ console.log(ok ? '✓ B(1.9.49) benchmark --measure: 인자 누락 친절 안내' : `✗ --measure 인자 실패`);
1018
+ if (!ok) { failed++; console.log((r.stdout + r.stderr).slice(0, 300)); }
1019
+ }
1020
+
1021
+ total++;
1022
+ {
1023
+ // 1.9.50 skill match --embedding (Ollama URL 없을 때 거부)
1024
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-emb-'));
1025
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
1026
+ const r = cp.spawnSync(process.execPath, [CLI, 'skill', 'match', 'test query', '--path', tmpC, '--embedding'], {
1027
+ encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_OLLAMA_BASE_URL: '' }
1028
+ });
1029
+ const ok = r.status !== 0 && /LEERNESS_OLLAMA_BASE_URL.*필요|opt-in/.test(r.stdout + r.stderr);
1030
+ console.log(ok ? '✓ B(1.9.50) skill match --embedding: Ollama URL 없으면 opt-in 거부' : `✗ --embedding 거부 실패`);
1031
+ if (!ok) { failed++; console.log((r.stdout + r.stderr).slice(0, 300)); }
1032
+ }
1033
+
953
1034
  // 1.9.45 회귀: skill match — 키워드 매칭 추천 (jaccard)
954
1035
  total++;
955
1036
  {