leerness 1.9.50 → 1.9.53

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,74 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.53 — 2026-05-19
4
+
5
+ **`leerness skill suggest` — Hermes-style 자동 학습 (사용 패턴 → skill 후보 자동 제안)**.
6
+
7
+ ### 배경
8
+ 1.9.2부터 `skill learn` / `skill use` / `skill optimize` / `skill consolidate` / `lessons` / `rule add` 등 자체 학습 인프라가 있었으나, **모두 명시 호출 필요**. Hermes처럼 *사용 중* 자동으로 새 skill을 만들지 못함.
9
+
10
+ ### Added
11
+ - **`leerness skill suggest [--min N] [--days N] [--json]`** — Hermes-style 자동 학습의 leerness 버전:
12
+ - **task-log.md** — `` `leerness X` `` 명령 인용 패턴 감지
13
+ - **progress-tracker.md** — request/nextAction 컬럼의 4자+ 키워드
14
+ - **usage-stats.json** — 명령별 누적 카운트
15
+ - 임계 (`--min`, 기본 3회) 이상 + **기존 skill에 없는** 키워드만 후보로
16
+ - `--days N` lookback (기본 30일)
17
+ - 출처 (`task-log` / `progress` / `usage`) 자동 분류
18
+ - 실 워크스페이스 검증: 본 프로젝트에서 6 후보 자동 감지 (leerness 22회, publish 14회, github 5회 등)
19
+
20
+ ### Hermes vs leerness 학습 비교 (1.9.53 후)
21
+ | 영역 | Hermes | leerness |
22
+ |---|---|---|
23
+ | 새 skill 자동 생성 | ✅ LLM 기반 | ⚠ 후보 제안만 (수동 등록 권장) |
24
+ | **반복 패턴 감지** | ✅ | ✅ **1.9.53 신규** |
25
+ | 사용 카운트 추적 | ✅ | ✅ 1.9.38 |
26
+ | 중복 자동 통합 | ✅ | ✅ 1.9.2 `skill consolidate` |
27
+ | 외부 docs 학습 | ✅ | ✅ 1.9.2 `skill learn --doc` |
28
+
29
+ ### 검증 (필수 stress-v5)
30
+ - O1-O5 (skill suggest 시나리오) 5/5 PASS
31
+ - P1-P5 (1.9.43~52 누적 회귀) 5/5 PASS
32
+ - **stress-v5: 10/10 PASS** + e2e: **206/206 PASS**
33
+
34
+ ## 1.9.52 — 2026-05-19
35
+
36
+ **`skill discover` 카탈로그 형식 다양성 (JSON/RSS/Markdown/llms.txt 자동 감지)**.
37
+
38
+ ### Added
39
+ - **`_parseSkillCatalog(body, sourceUrl)`** 통합 파서 — 4 형식 자동 감지:
40
+ 1. **JSON manifest** — `{ "skills": [...] }` 또는 `[{...}]` (leerness `skill publish`가 만드는 형식과 호환)
41
+ 2. **RSS/Atom** — `<item><title>X</title><link>...</link><description>...</description></item>`
42
+ 3. **Markdown w/ description** — `- [name](url) — description`
43
+ 4. **llms.txt URL-only** — 단순 URL 라인
44
+ - 각 entry에 `format` 필드 추가 (json/rss/markdown/urls) — 출처 추적
45
+
46
+ ### 검증 (stress-v4)
47
+ - M1-M5 5/5 PASS — 4 형식 인식 + 빈 body 안전 fallback
48
+
49
+ ## 1.9.51 — 2026-05-19
50
+
51
+ **`benchmark --scenario` — leerness 고유 가치 시나리오 preset**.
52
+
53
+ ### Added
54
+ - **`leerness benchmark --scenario <id|all> [--json]`** — 4 시나리오 자동 실행:
55
+ - `false-completion` — 거짓 완료 자동 감지 (lazy detect)
56
+ - `spec-mismatch` — 사양 ↔ 구현 불일치 (contract verify)
57
+ - `drift-detection` — 메타파일 stale (drift check 4 신호)
58
+ - `bom-handling` — UTF-8 BOM SKILL.md install (1.9.44 패치 효과)
59
+ - 각 시나리오: setup → measure → 감지 여부 + 시간 측정
60
+ - 결과: leerness 적용 워크스페이스에서 **4/4 정확 감지**
61
+
62
+ ### 검증 (stress-v4 + 누적 회귀)
63
+ - L1-L4 (시나리오 preset) 4/4 PASS
64
+ - M1-M5 (카탈로그 4 형식 + 빈 body) 5/5 PASS
65
+ - N1-N5 (누적 회귀: MCP, skill match, publish, drift, agentskills round-trip) 5/5 PASS
66
+ - **stress-v4: 14/14 PASS**, e2e: **205/205 PASS**
67
+
68
+ ### 결론
69
+ - 1.9.51로 leerness 고유 가치가 **command 한 번에 정량 증명** 가능
70
+ - 1.9.52로 다양한 카탈로그 형식과 호환 (agentskills.io 외 사용자 정의 RSS/JSON도)
71
+
3
72
  ## 1.9.50 — 2026-05-19
4
73
 
5
74
  **`skill match --embedding` (Ollama opt-in 임베딩 매칭)**.
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.50-green)]() [![tests](https://img.shields.io/badge/e2e-202%2F202-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.53-green)]() [![tests](https://img.shields.io/badge/e2e-206%2F206-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.50 AI Agent Reliability Harness ║
15
+ ║ v1.9.53 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.53** — `leerness skill suggest` — task-log / progress-tracker / usage-stats에서 반복 패턴 **자동 감지 → 새 skill 후보 제안** (Hermes-style 자동 학습의 leerness 버전).
437
+ - **1.9.52** — `skill discover` 카탈로그 형식 다양성 — JSON manifest / RSS·Atom / Markdown / llms.txt URL 4 형식 자동 감지 (`_parseSkillCatalog`).
438
+ - **1.9.51** — `benchmark --scenario <id|all>` — leerness 고유 가치 시나리오 4종 (거짓 완료 / 사양 불일치 / drift / BOM) **command 한 번에 정량 증명**.
436
439
  - **1.9.50** — `skill match --embedding` — Ollama API 코사인 유사도 매칭 (opt-in, 실패 시 jaccard fallback).
437
440
  - **1.9.49** — `benchmark --measure "<task>"` — 외부 CLI 실 호출 시간 측정 + leerness 검수 오버헤드 측정.
438
441
  - **1.9.48** — cross-platform archive — `skill publish` tar 실패 시 PowerShell ZIP 자동 fallback (stress-v3 H1-H3 검증).
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.50';
9
+ const VERSION = '1.9.53';
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 형식 확인)');
@@ -6100,6 +6139,90 @@ function skillPublishCmd(root) {
6100
6139
  // 1.9.46: leerness benchmark — 자체 워크스페이스 측정 + 타도구 대비 시뮬레이션 비교 매트릭스
6101
6140
  // 실 측정값: drift, usage stats, task 수, capability 수
6102
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
+
6103
6226
  // 1.9.49: --measure 모드 — ready 외부 CLI에 동일 task 실측 + leerness verify-claim 적용 시 추가 시간 측정
6104
6227
  async function _benchmarkMeasure(root, task) {
6105
6228
  const results = [];
@@ -6132,6 +6255,32 @@ async function _benchmarkMeasure(root, task) {
6132
6255
 
6133
6256
  function benchmarkCmd(root) {
6134
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
+ }
6135
6284
  // 1.9.49: --measure "<task>" 모드 — 실 CLI 시간 측정
6136
6285
  if (has('--measure')) {
6137
6286
  const task = arg('--measure', null) || arg('--task', null);
@@ -6212,6 +6361,83 @@ function benchmarkCmd(root) {
6212
6361
  log('💡 시뮬레이션은 정성적 추정 — 실 측정은 별도 환경 필요 (사용자 환경)');
6213
6362
  }
6214
6363
 
6364
+ // 1.9.53: leerness skill suggest — task-log + usage-stats에서 반복 패턴 감지 → 새 skill 후보 제안
6365
+ // Hermes-style 자동 학습의 leerness 버전. 명시적 `skill learn` 호출 없이도 패턴 추출.
6366
+ function skillSuggestCmd(root) {
6367
+ root = absRoot(root || process.cwd());
6368
+ const minOccurrence = parseInt(arg('--min', '3'), 10);
6369
+ const lookbackDays = parseInt(arg('--days', '30'), 10);
6370
+ const cutoff = Date.now() - lookbackDays * 86400000;
6371
+ const seen = {}; // keyword → { count, samples, files }
6372
+ // 1) task-log.md 라인 분석
6373
+ const taskLog = taskLogPath(root);
6374
+ if (exists(taskLog)) {
6375
+ const body = read(taskLog);
6376
+ // 날짜 헤더 ## YYYY-MM-DD 안의 라인들
6377
+ const blocks = body.split(/^## \d{4}-\d{2}-\d{2}/m);
6378
+ for (const block of blocks) {
6379
+ // 명령 인용 `leerness X` 또는 키워드 (3+ chars)
6380
+ for (const m of block.matchAll(/`leerness\s+([a-z][\w-]+(?:\s+[a-z][\w-]+)?)`/g)) {
6381
+ const cmd = m[1].trim();
6382
+ seen[cmd] = seen[cmd] || { count: 0, samples: [], source: 'task-log' };
6383
+ seen[cmd].count++;
6384
+ if (seen[cmd].samples.length < 3) seen[cmd].samples.push(block.slice(0, 80).replace(/\n/g, ' '));
6385
+ }
6386
+ }
6387
+ }
6388
+ // 2) progress-tracker request 컬럼 분석
6389
+ const rows = readProgressRows(root);
6390
+ for (const row of rows) {
6391
+ const text = (row.request || '') + ' ' + (row.nextAction || '');
6392
+ // 도메인 키워드 (한글 + 영어 단어, 3자 이상)
6393
+ for (const m of text.toLowerCase().matchAll(/[\w가-힣]{4,}/g)) {
6394
+ const kw = m[0];
6395
+ if (/^\d+$/.test(kw)) continue;
6396
+ if (['이런', '저런', '하다', '하고', '있는', '하지', '에서'].includes(kw)) continue;
6397
+ seen[kw] = seen[kw] || { count: 0, samples: [], source: 'progress' };
6398
+ seen[kw].count++;
6399
+ if (seen[kw].samples.length < 3) seen[kw].samples.push((row.request || '').slice(0, 60));
6400
+ }
6401
+ }
6402
+ // 3) usage-stats의 명령 카운트
6403
+ try {
6404
+ const stats = _readUsageStats(root);
6405
+ for (const [cmd, n] of Object.entries(stats.commands || {})) {
6406
+ if (n >= minOccurrence) {
6407
+ seen[`cmd:${cmd}`] = seen[`cmd:${cmd}`] || { count: 0, samples: [], source: 'usage' };
6408
+ seen[`cmd:${cmd}`].count = n;
6409
+ }
6410
+ }
6411
+ } catch {}
6412
+ // 4) 임계 이상 + 기존 skill에 없는 키워드만 필터
6413
+ const existing = new Set(Object.keys(listAllSkills(root)));
6414
+ const installed = _readInstalledSkills(root);
6415
+ const installedTokens = new Set(installed.flatMap(s => [..._tokenize(s.name + ' ' + s.description)]));
6416
+ const candidates = Object.entries(seen)
6417
+ .filter(([kw, info]) => info.count >= minOccurrence)
6418
+ .filter(([kw]) => !existing.has(kw) && !installedTokens.has(kw.replace(/^cmd:/, '')))
6419
+ .map(([kw, info]) => ({ keyword: kw, ...info }))
6420
+ .sort((a, b) => b.count - a.count);
6421
+ if (has('--json')) { log(JSON.stringify({ minOccurrence, lookbackDays, candidates: candidates.slice(0, 20) }, null, 2)); return; }
6422
+ log(`# leerness skill suggest (1.9.53)`);
6423
+ log(`반복 패턴 자동 감지 (최소 ${minOccurrence}회, ${lookbackDays}일 이내)`);
6424
+ log('');
6425
+ if (!candidates.length) {
6426
+ log(' (아직 패턴 부족 — task-log/progress-tracker에 작업이 더 누적되면 자동 감지)');
6427
+ return;
6428
+ }
6429
+ log(`발견된 후보: ${candidates.length}건`);
6430
+ log('');
6431
+ log('| 키워드/명령 | 출처 | 등장 횟수 | 예시 |');
6432
+ log('|---|---|---:|---|');
6433
+ for (const c of candidates.slice(0, 10)) {
6434
+ log(`| ${c.keyword} | ${c.source} | ${c.count} | ${(c.samples[0] || '').replace(/\|/g, '\\|').slice(0, 50)} |`);
6435
+ }
6436
+ log('');
6437
+ log(`💡 신규 skill로 등록 권장:`);
6438
+ log(` leerness skill learn <id> --capability "${candidates[0].keyword}" --note "1.9.53 auto-suggest"`);
6439
+ }
6440
+
6215
6441
  // 1.9.45: skill match <query> — 설치된 SKILL.md description ↔ 사용자 요청 키워드 매칭 추천
6216
6442
  // jaccard similarity (단어 집합 교집합/합집합).
6217
6443
  function _tokenize(s) {
@@ -6811,6 +7037,7 @@ async function main() {
6811
7037
  if (cmd === 'skill' && args[1] === 'match') return skillMatchCmd(absRoot(arg('--path', process.cwd())), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
6812
7038
  if (cmd === 'benchmark') return benchmarkCmd(absRoot(args[1] || arg('--path', process.cwd())));
6813
7039
  if (cmd === 'skill' && args[1] === 'publish') return skillPublishCmd(absRoot(arg('--path', process.cwd())));
7040
+ if (cmd === 'skill' && args[1] === 'suggest') return skillSuggestCmd(absRoot(arg('--path', process.cwd())));
6814
7041
  if (cmd === 'mcp' && args[1] === 'serve') return mcpServeCmd(absRoot(arg('--path', process.cwd())));
6815
7042
  if (cmd === 'gate') return gate(args[1] || process.cwd());
6816
7043
  if (cmd === 'verify-code') return verifyCodeCmd(args[1] || process.cwd());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.50",
3
+ "version": "1.9.53",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -950,6 +950,67 @@ total++;
950
950
  if (!ok) { failed++; console.log(r.stdout.slice(0, 800)); }
951
951
  }
952
952
 
953
+ // 1.9.53 회귀: skill suggest 자동 학습
954
+ total++;
955
+ {
956
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-sg-'));
957
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
958
+ // 반복 키워드 6회
959
+ for (let i = 0; i < 6; i++) {
960
+ cp.spawnSync(process.execPath, [CLI, 'task', 'add', `autosuggestkw 작업 ${i}`, '--path', tmpC], { stdio: 'ignore', timeout: 10000 });
961
+ }
962
+ const r = cp.spawnSync(process.execPath, [CLI, 'skill', 'suggest', '--path', tmpC, '--min', '3', '--json'], { encoding: 'utf8', timeout: 15000 });
963
+ let j = null;
964
+ try { j = JSON.parse(r.stdout); } catch {}
965
+ const ok = j && j.candidates && j.candidates.some(c => /autosuggestkw/.test(c.keyword) && c.count >= 3);
966
+ console.log(ok ? '✓ B(1.9.53) skill suggest: progress-tracker 반복 패턴 자동 감지 (Hermes-style)' : `✗ suggest 실패`);
967
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 400)); }
968
+ }
969
+
970
+ // 1.9.51/52 회귀
971
+ total++;
972
+ {
973
+ // benchmark --scenario all → 4개 시나리오 모두 감지
974
+ const tmpC = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-sc-'));
975
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
976
+ const r = cp.spawnSync(process.execPath, [CLI, 'benchmark', tmpC, '--scenario', 'all', '--json'], { encoding: 'utf8', timeout: 60000 });
977
+ let j = null;
978
+ try { j = JSON.parse(r.stdout); } catch {}
979
+ const ok = j && j.scenarios && j.scenarios.length === 4 && j.detectedCount === 4;
980
+ console.log(ok ? '✓ B(1.9.51) benchmark --scenario all: 4/4 leerness 고유 가치 자동 감지' : `✗ scenario all 실패`);
981
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 400)); }
982
+ }
983
+
984
+ total++;
985
+ {
986
+ // benchmark --scenario 알 수 없는 ID → 친절 안내
987
+ const r = cp.spawnSync(process.execPath, [CLI, 'benchmark', '--scenario', 'unknown-x'], { encoding: 'utf8', timeout: 15000 });
988
+ const ok = r.status !== 0 && /알 수 없는 scenario/.test(r.stdout + r.stderr);
989
+ console.log(ok ? '✓ B(1.9.51) benchmark --scenario unknown: 친절 안내' : `✗ scenario 안내 실패`);
990
+ if (!ok) { failed++; console.log((r.stdout + r.stderr).slice(0, 300)); }
991
+ }
992
+
993
+ total++;
994
+ {
995
+ // _parseSkillCatalog 4 형식 인식 — node -e로 동적 평가
996
+ const src = fs.readFileSync(CLI, 'utf8');
997
+ const m = src.match(/function _parseSkillCatalog\([\s\S]*?\n\}\n/);
998
+ if (!m) {
999
+ console.log('✗ _parseSkillCatalog 함수 위치 못 찾음');
1000
+ failed++;
1001
+ } else {
1002
+ const fn = eval('(' + m[0].replace('function _parseSkillCatalog', 'function') + ')');
1003
+ const jsonR = fn(JSON.stringify({ skills: [{ name: 'a', description: 'A' }] }), null);
1004
+ const rssR = fn('<rss><channel><item><title>X</title><link>http://x.com/s.md</link></item></channel></rss>', null);
1005
+ const mdR = fn('- [office](o.md) — Office\n- [crawling](c.md) — Web', null);
1006
+ const urlR = fn('https://x.com/foo/SKILL.md', null);
1007
+ const ok = jsonR[0].format === 'json' && rssR[0].format === 'rss'
1008
+ && mdR[0].format === 'markdown' && urlR[0].format === 'urls';
1009
+ console.log(ok ? '✓ B(1.9.52) _parseSkillCatalog: 4 형식 (JSON/RSS/Markdown/llms.txt) 모두 인식' : `✗ catalog 형식 실패`);
1010
+ if (!ok) { failed++; console.log(JSON.stringify({jsonR, rssR, mdR, urlR}).slice(0, 400)); }
1011
+ }
1012
+ }
1013
+
953
1014
  // 1.9.48~50 회귀
954
1015
  total++;
955
1016
  {