leerness 1.9.0 → 1.9.3

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,68 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.3 — 2026-05-08
4
+
5
+ 이번 릴리스는 "이전 작업과 새 작업의 인과관계·재귀 안내·디자인 일관성"을 자동화합니다.
6
+
7
+ ### Added — 인과관계·재사용·일관성
8
+
9
+ - `leerness impact <target>` — 변경 전 영향 분석. `<target>`을 `import/require/href/src/@import/url()`로 참조하는 모든 파일을 단일 패스로 식별.
10
+ - `leerness reuse find <query>` — `reuse-map.md`, `design-system.md`, `feature-contracts.md`, `plan/progress`, 그리고 코드의 export/식별자에서 기존 자원을 통합 검색.
11
+ - `leerness reuse register <name> --where <path> --kind component|hook|util|api [--note ...]` — `reuse-map.md`에 자동 row 추가.
12
+ - `leerness ui consistency [path] [--strict] [--fail-on-violation]` — `design-system.md`의 토큰 표를 파싱해 코드의 hex 색상이 토큰에 등록되어 있는지 검사. `--strict`는 px/rem 사이즈도, `--fail-on-violation`은 비-제로 종료.
13
+ - `leerness graph [path] [--out <file>]` — 의존성 그래프를 mermaid 형식으로 출력하거나 파일로 저장.
14
+ - `leerness guide [target]` — 위 4개를 한 번에 실행하는 변경 전 통합 가이드.
15
+
16
+ ### Migration
17
+
18
+ 기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다.
19
+
20
+ ## 1.9.2 — 2026-05-08
21
+
22
+ 스킬을 살아 있는 학습 사이클로 끌어올린 릴리스. 동일 API 작업이 반복될 때 기존 패턴을 발견·재사용하고, 더 나은 방법이 생기면 최적화 이력으로 누적합니다.
23
+
24
+ ### Added — 스킬 학습 사이클
25
+
26
+ - `leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]`
27
+ - 새 스킬을 `.harness/skills/<id>/skill.json`에 생성하거나, 카탈로그 스킬을 로컬에 materialize.
28
+ - `--doc` / `--capability`는 반복 가능 (n번 적으면 모두 누적).
29
+ - `skill.json` 스키마 확장: `sources[]`, `patterns[]`, `optimizations[]`, `usage{count,lastUsed,lastNote}`.
30
+ - `leerness skill use <id> [--note ...]`: 사용 횟수+1, lastUsed 갱신.
31
+ - `leerness skill optimize <id> --before "..." --after "..." [--note ...]`: 최적화 이력 누적.
32
+ - `leerness skill remove <id>`: 사용자 정의 스킬 삭제 (카탈로그 스킬은 로컬 메타만 정리).
33
+ - `leerness skill consolidate [--threshold 0.3]`: 모든 스킬의 capability 토큰 jaccard 비교로 통합 후보 자동 발견.
34
+ - `leerness skill list`가 카탈로그 + 사용자 스킬을 합쳐 출력 (출처/사용횟수/최종 컬럼 추가).
35
+ - `leerness skill info <id>`가 sources/patterns/optimizations까지 모두 표시.
36
+
37
+ ### Added — 게이트 통합
38
+
39
+ - `leerness gate [path]` — `verify + audit + scan secrets + encoding check + lazy detect`을 한번에 실행해 단일 요약을 출력. 한 단계라도 실패하면 비-제로 종료.
40
+ - `leerness self check`을 `leerness update --check`의 thin wrapper로 통합. 단일 출처(npm view + 캐시)로 일원화하면서 1.8.0과의 호환을 위해 명령 자체는 유지.
41
+
42
+ ### Migration
43
+
44
+ 기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다. 카탈로그 스킬에 대한 사용 기록은 처음 `skill use`/`skill optimize` 시점부터 누적되기 시작합니다.
45
+
46
+ ## 1.9.1 — 2026-05-08
47
+
48
+ 1.9.0을 실 프로젝트(memo-cli)에서 운영하며 발견한 **5개 메타 감사 사항**을 패치합니다.
49
+
50
+ ### Fixed
51
+
52
+ - **P1**: `autoUpdateInstall`이 legacy `leerness-plus update --check` SessionStart hook을 자동 정리. fork 시절 잔재로 인해 매 세션 npm 호출이 2회 발생하던 문제 해소.
53
+ - **P2**: `managedMerge`에 `MERGE_OVERWRITE_FILES` 화이트리스트 추가 (`skill-index.md`, `manifest.json`, `skills-lock.json`, `HARNESS_VERSION`, `LANGUAGE`, `context-routing.md`). 다단계 migrate를 거쳐도 표/메타데이터가 누적되지 않음.
54
+ - **P4**: `audit`이 `<!-- leerness:na <reason> -->` 마커를 인식. CLI 패키지 등 디자인 토큰/재사용 맵이 NA인 프로젝트에서 영구 경고가 사라짐.
55
+ - **P6**: `lazy detect`의 evidence 정규식을 `/^plan:M-\d{4}\s*$/`로 좁힘. `plan:M-XXXX` 단독은 부족 판정, `tests:32/32 (plan:M-0002)`처럼 검증 키워드 동반 시 통과.
56
+ - **P7**: `install`이 끝날 때 디폴트 `M-0001`이 plan에 있는데 progress에 row가 없으면 `T-XXXX` 자동 생성. audit "milestones without progress entry: M-0001" 경고가 init 직후 사라짐.
57
+
58
+ ### Added
59
+
60
+ - `leerness skill list [path]` 출력에 **설치됨** 컬럼 추가 (root가 인자/현재 디렉토리에 있을 때).
61
+
62
+ ### Migration
63
+
64
+ 기존 1.9.0 설치본은 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다.
65
+
3
66
  ## 1.9.0 — 2026-05-08
4
67
 
5
68
  이번 minor 릴리스는 1.8.0의 6개 결함을 수정하고, 자동 감지·자동 업데이트·핸드오프 자동 작성·게으름/시크릿/인코딩 자동 가드를 흡수한 큰 강화입니다. 기존 `npx leerness init` 흐름은 그대로 유지됩니다.
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.0';
9
+ const VERSION = '1.9.3';
10
10
  const MARK = '<!-- leerness:managed -->';
11
11
  const README_START = '<!-- leerness:project-readme:start -->';
12
12
  const README_END = '<!-- leerness:project-readme:end -->';
@@ -56,7 +56,7 @@ function arg(name, def = null) { const i = process.argv.indexOf(name); return i
56
56
  function has(name) { return process.argv.includes(name); }
57
57
  function nonFlagArgs() {
58
58
  const out = [];
59
- const withValue = new Set(['--language','--skills','--path','--status','--progress','--goal','--reason','--next','--target','--token-env','--package','--out','--from','--repo','--id','--note','--evidence','--query','--limit','--action','--agent','--tool']);
59
+ const withValue = new Set(['--language','--skills','--path','--status','--progress','--goal','--reason','--next','--target','--token-env','--package','--out','--from','--repo','--id','--note','--evidence','--query','--limit','--action','--agent','--tool','--doc','--command','--capability','--before','--after','--display','--threshold']);
60
60
  const a = process.argv.slice(2);
61
61
  for (let i = 0; i < a.length; i++) {
62
62
  const x = a[i];
@@ -65,6 +65,11 @@ function nonFlagArgs() {
65
65
  }
66
66
  return out;
67
67
  }
68
+ function argAll(name) {
69
+ const out = []; const a = process.argv;
70
+ for (let i = 0; i < a.length; i++) if (a[i] === name && a[i+1] && !a[i+1].startsWith('-')) out.push(a[++i]);
71
+ return out;
72
+ }
68
73
  function detectProjectName(root) { try { const pkg = JSON.parse(read(path.join(root, 'package.json'))); if (pkg.name) return pkg.name; } catch {} return path.basename(root); }
69
74
  function detectLanguageValue(root, value = 'auto') {
70
75
  const v = String(value || 'auto').toLowerCase();
@@ -236,10 +241,20 @@ function createBackup(root, reason, files, dry = false) {
236
241
  return { archiveDir: ar, candidates };
237
242
  }
238
243
 
244
+ // 1.9.1 P2: 데이터/인덱스 파일은 preserved 블록 없이 overwrite (누적 방지).
245
+ const MERGE_OVERWRITE_FILES = new Set([
246
+ '.harness/skill-index.md',
247
+ '.harness/manifest.json',
248
+ '.harness/skills-lock.json',
249
+ '.harness/HARNESS_VERSION',
250
+ '.harness/LANGUAGE',
251
+ '.harness/context-routing.md'
252
+ ]);
239
253
  function managedMerge(file, next, previous, archiveDir) {
240
254
  if (!previous || previous.trim() === next.trim()) return next;
241
255
  const tag = '<!-- leerness:migration-preserved -->';
242
256
  if (previous.includes(tag)) return next;
257
+ if (MERGE_OVERWRITE_FILES.has(file.replace(/\\/g, '/'))) return next;
243
258
  return next.trimEnd() + `\n\n---\n${tag}\n## Preserved previous content\n\nPrevious content was backed up before migration. Archive reference:\n\n\`${archiveDir ? path.relative(process.cwd(), archiveDir).replace(/\\/g, '/') : '.harness/archive'}\`\n\n<details>\n<summary>Previous ${file}</summary>\n\n\`\`\`md\n${previous.replace(/```/g, '\\`\\`\\`')}\n\`\`\`\n\n</details>\n`;
244
259
  }
245
260
 
@@ -366,6 +381,18 @@ async function install(root, opts = {}) {
366
381
  syncReadme(root);
367
382
  installSkills(root, skills);
368
383
  writeMigrationReport(root, backup, actions);
384
+ // 1.9.1 P7: 디폴트 M-0001이 plan에 있고 progress에 row가 없으면 자동 추가
385
+ try {
386
+ const planText = exists(planPath(root)) ? read(planPath(root)) : '';
387
+ if (/### M-0001\./.test(planText)) {
388
+ const rows = readProgressRows(root);
389
+ const linked = rows.some(r => /M-0001/.test(r.evidence));
390
+ if (!linked) {
391
+ const tid = nextId(root, 'T');
392
+ upsertProgress(root, { id: tid, status: 'planned', request: '프로젝트 계획 정리', evidence: 'init default plan:M-0001', nextAction: 'project-brief.md를 실제 목적으로 업데이트' });
393
+ }
394
+ }
395
+ } catch {}
369
396
  if (!has('--no-auto-update')) {
370
397
  try { autoUpdateInstall(root); } catch (e) { warn('auto-update hook install skipped: ' + (e && e.message)); }
371
398
  }
@@ -382,17 +409,162 @@ function addSkill(root, name, silent = false) {
382
409
  if (!silent) ok(`skill installed: ${name}`);
383
410
  }
384
411
 
385
- function skillList() {
386
- log('| ID | 한글명 | 가능한 작업 | 최종 업데이트 | 검증 |');
387
- log('|---|---|---|---|---|');
388
- for (const [k, v] of Object.entries(skillCatalog)) log(`| ${k} | ${v.displayNameKo} | ${v.capabilities.join('<br>')} | ${v.lastUpdated} | ${v.verification} |`);
412
+ // ===== Skill registry (catalog + user-defined merged) =====
413
+ function userSkillsDir(root) { return path.join(absRoot(root), '.harness/skills'); }
414
+ function userSkillFile(root, id) { return path.join(userSkillsDir(root), id, 'skill.json'); }
415
+
416
+ function loadUserSkill(root, id) {
417
+ const f = userSkillFile(root, id);
418
+ if (!exists(f)) return null;
419
+ try { return JSON.parse(read(f)); } catch { return null; }
389
420
  }
390
- function skillInfo(name) {
391
- const v = skillCatalog[name];
421
+ function saveUserSkill(root, id, data) {
422
+ const dir = path.join(userSkillsDir(root), id); mkdirp(dir);
423
+ writeUtf8(path.join(dir, 'skill.json'), JSON.stringify(data, null, 2) + '\n');
424
+ // README mirror
425
+ const usage = data.usage || { count: 0 };
426
+ 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`;
427
+ writeUtf8(path.join(dir, 'README.md'), readme);
428
+ }
429
+
430
+ function listAllSkills(root) {
431
+ const out = {};
432
+ for (const [k, v] of Object.entries(skillCatalog)) out[k] = { ...v, _source: 'catalog' };
433
+ if (root) {
434
+ const dir = userSkillsDir(root);
435
+ if (exists(dir)) {
436
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
437
+ if (!e.isDirectory()) continue;
438
+ const data = loadUserSkill(root, e.name);
439
+ if (!data) continue;
440
+ if (out[e.name]) out[e.name] = { ...out[e.name], ...data, _source: 'catalog+local' };
441
+ else out[e.name] = { ...data, _source: 'user' };
442
+ }
443
+ }
444
+ }
445
+ return out;
446
+ }
447
+
448
+ function skillList(root) {
449
+ const all = listAllSkills(root);
450
+ log('| ID | 한글명 | 출처 | 능력(요약) | 사용횟수 | 최종 |');
451
+ log('|---|---|---|---|---|---|');
452
+ for (const [id, v] of Object.entries(all)) {
453
+ const cap = (v.capabilities || []).slice(0, 3).join(' / ') + ((v.capabilities || []).length > 3 ? ' …' : '');
454
+ const usage = v.usage?.count || 0;
455
+ const last = v.usage?.lastUsed?.slice(0, 10) || v.lastUpdated || '-';
456
+ log(`| ${id} | ${v.displayNameKo || id} | ${v._source} | ${cap} | ${usage} | ${last} |`);
457
+ }
458
+ }
459
+
460
+ function skillInfo(name, root) {
461
+ const all = listAllSkills(root);
462
+ const v = all[name];
392
463
  if (!v) return fail(`Unknown skill: ${name}`);
393
- log(`${name}`); log(`한글명: ${v.displayNameKo}`); log(`버전: ${v.version}`);
394
- log(`최종 업데이트: ${v.lastUpdated}`); log(`검증: ${v.verification}`);
395
- log('가능한 작업:'); v.capabilities.forEach(x => log('- ' + x));
464
+ log(`# ${name} (${v._source})`);
465
+ log(`한글명: ${v.displayNameKo || name}`);
466
+ log(`버전: ${v.version || '-'} / 최종: ${v.lastUpdated || '-'} / 검증: ${typeof v.verification === 'object' ? v.verification.status : v.verification || '-'}`);
467
+ log(`사용: ${v.usage?.count || 0}회 / 마지막: ${v.usage?.lastUsed || '-'}`);
468
+ log('Capabilities:'); (v.capabilities || []).forEach(x => log('- ' + x));
469
+ if ((v.sources || []).length) { log('Sources:'); v.sources.forEach(s => log('- ' + (s.url || s))); }
470
+ if ((v.patterns || []).length) { log('Patterns:'); v.patterns.forEach(p => log(`- \`${p.command}\` — ${p.note || ''}`)); }
471
+ if ((v.optimizations || []).length) { log('Optimizations:'); v.optimizations.forEach(o => log(`- ${o.at}: ${o.note || ''}`)); }
472
+ }
473
+
474
+ function skillLearn(root, id) {
475
+ if (!id) return fail('id required (e.g., skill learn open-meteo --command "..." --doc URL)');
476
+ const docs = argAll('--doc');
477
+ const command = arg('--command', null);
478
+ const note = arg('--note', null);
479
+ const caps = argAll('--capability');
480
+ const display = arg('--display', null);
481
+ let data = loadUserSkill(root, id);
482
+ if (!data) {
483
+ // start from catalog if exists
484
+ const base = skillCatalog[id];
485
+ data = base ? { name: id, ...base, sources: [], patterns: [], optimizations: [], usage: { count: 0, lastUsed: null } }
486
+ : { name: id, displayNameKo: display || id, version: '1.0.0', lastUpdated: today(), verification: 'unverified', capabilities: [], sources: [], patterns: [], optimizations: [], usage: { count: 0, lastUsed: null } };
487
+ }
488
+ if (display) data.displayNameKo = display;
489
+ for (const d of docs) if (!data.sources.some(s => (s.url || s) === d)) data.sources.push({ at: now(), url: d });
490
+ for (const c of caps) if (!data.capabilities.includes(c)) data.capabilities.push(c);
491
+ if (command) data.patterns.push({ at: now(), command, note: note || '' });
492
+ data.lastUpdated = today();
493
+ saveUserSkill(root, id, data);
494
+ ok(`skill learned: ${id} (sources=${data.sources.length}, patterns=${data.patterns.length}, capabilities=${data.capabilities.length})`);
495
+ }
496
+
497
+ function skillUse(root, id) {
498
+ if (!id) return fail('id required');
499
+ let data = loadUserSkill(root, id);
500
+ if (!data) {
501
+ const base = skillCatalog[id];
502
+ if (!base) return fail(`skill not found: ${id}`);
503
+ data = { name: id, ...base, sources: [], patterns: [], optimizations: [], usage: { count: 0, lastUsed: null } };
504
+ }
505
+ data.usage = data.usage || { count: 0, lastUsed: null };
506
+ data.usage.count++;
507
+ data.usage.lastUsed = now();
508
+ if (arg('--note', null)) data.usage.lastNote = arg('--note', null);
509
+ saveUserSkill(root, id, data);
510
+ ok(`skill used: ${id} (count=${data.usage.count})`);
511
+ }
512
+
513
+ function skillOptimize(root, id) {
514
+ if (!id) return fail('id required');
515
+ let data = loadUserSkill(root, id);
516
+ if (!data) {
517
+ const base = skillCatalog[id];
518
+ if (!base) return fail(`skill not found: ${id}`);
519
+ data = { name: id, ...base, sources: [], patterns: [], optimizations: [], usage: { count: 0, lastUsed: null } };
520
+ }
521
+ data.optimizations = data.optimizations || [];
522
+ data.optimizations.push({ at: now(), before: arg('--before', '') || '', after: arg('--after', '') || '', note: arg('--note', '') || '' });
523
+ data.lastUpdated = today();
524
+ saveUserSkill(root, id, data);
525
+ ok(`skill optimized: ${id} (total=${data.optimizations.length})`);
526
+ }
527
+
528
+ function skillRemove(root, id) {
529
+ if (!id) return fail('id required');
530
+ const dir = path.join(userSkillsDir(root), id);
531
+ if (!exists(dir)) return fail(`skill folder not found: ${id}`);
532
+ if (skillCatalog[id]) {
533
+ // catalog 스킬은 로컬 메타만 제거 (카탈로그는 패키지 내장이라 영구 제거 불가)
534
+ fs.rmSync(dir, { recursive: true, force: true });
535
+ ok(`local meta removed for catalog skill: ${id} (catalog 자체는 패키지에 내장)`);
536
+ } else {
537
+ fs.rmSync(dir, { recursive: true, force: true });
538
+ ok(`user skill removed: ${id}`);
539
+ }
540
+ }
541
+
542
+ function skillConsolidate(root) {
543
+ const all = listAllSkills(root);
544
+ const ids = Object.keys(all);
545
+ function tokens(v) {
546
+ return new Set((v.capabilities || []).join(' ').toLowerCase().split(/[\s,/·.()[\]]+/).filter(t => t.length >= 2));
547
+ }
548
+ function jaccard(a, b) {
549
+ const inter = new Set([...a].filter(x => b.has(x))).size;
550
+ const uni = new Set([...a, ...b]).size;
551
+ return uni ? inter / uni : 0;
552
+ }
553
+ const tokenized = {};
554
+ for (const id of ids) tokenized[id] = tokens(all[id]);
555
+ const threshold = parseFloat(arg('--threshold', '0.3'));
556
+ const candidates = [];
557
+ for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) {
558
+ const a = ids[i], b = ids[j];
559
+ const s = jaccard(tokenized[a], tokenized[b]);
560
+ if (s >= threshold) candidates.push({ a, b, score: s });
561
+ }
562
+ if (!candidates.length) return ok(`no consolidation candidates (jaccard < ${threshold})`);
563
+ candidates.sort((x, y) => y.score - x.score);
564
+ log(`# Consolidation candidates (jaccard >= ${threshold})`);
565
+ log('| A | B | score | 권장 |');
566
+ log('|---|---|---|---|');
567
+ for (const c of candidates) log(`| ${c.a} | ${c.b} | ${c.score.toFixed(2)} | \`leerness skill learn <new> --capability ...\` 후 \`leerness skill remove <old>\` |`);
396
568
  }
397
569
 
398
570
  const planPath = root => path.join(root, '.harness/plan.md');
@@ -556,12 +728,16 @@ function audit(root) {
556
728
  const dups = designCands.filter(f => exists(path.join(root,f)));
557
729
  if (dups.length) { warnings++; warn(`design guide duplicates outside canonical: ${dups.join(', ')} (run: leerness consistency merge-design-guide)`); }
558
730
  else ok('no duplicate design guide candidates');
731
+ // 1.9.1 P4: <!-- leerness:na --> 마커가 있는 파일은 placeholder 경고 스킵.
732
+ const naMarker = '<!-- leerness:na';
559
733
  const ds = exists(path.join(root,'.harness/design-system.md')) ? read(path.join(root,'.harness/design-system.md')) : '';
560
- if (!/\| color\.primary \|/.test(ds) || /\(실제 값으로 업데이트\)/.test(ds)) { warnings++; warn('design-system.md tokens not customized'); }
734
+ if (ds.includes(naMarker)) ok('design-system.md marked NA (skipped)');
735
+ else if (!/\| color\.primary \|/.test(ds) || /\(실제 값으로 업데이트\)/.test(ds)) { warnings++; warn('design-system.md tokens not customized'); }
561
736
  else ok('design-system tokens populated');
562
737
  const reuse = exists(path.join(root,'.harness/reuse-map.md')) ? read(path.join(root,'.harness/reuse-map.md')) : '';
563
738
  const reuseLines = reuse.split('\n').filter(l => l.startsWith('|') && !/Capability|---/.test(l)).length;
564
- if (reuseLines === 0) { warnings++; warn('reuse-map.md is empty (consider populating known reusable elements)'); }
739
+ if (reuse.includes(naMarker)) ok('reuse-map.md marked NA (skipped)');
740
+ else if (reuseLines === 0) { warnings++; warn('reuse-map.md is empty (consider populating known reusable elements)'); }
565
741
  else ok(`reuse-map.md has ${reuseLines} entries`);
566
742
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
567
743
  const milestoneIds = Array.from(planText.matchAll(/^### (M-\d{4})\./gm)).map(m => m[1]);
@@ -679,7 +855,9 @@ function lazyDetect(root) {
679
855
  root = absRoot(root);
680
856
  let issues = 0;
681
857
  const rows = readProgressRows(root);
682
- for (const r of rows) if (r.status === 'done' && (!r.evidence || /^(\s*|user-request|-)$/.test(r.evidence) || /^plan:/.test(r.evidence))) {
858
+ // 1.9.1 P6: evidence가 단독 plan:M-XXXX 줄일 때만 검증 부족 처리.
859
+ // "tests:32/32 (plan:M-0002)" 같이 검증 키워드를 같이 적은 경우는 통과.
860
+ for (const r of rows) if (r.status === 'done' && (!r.evidence || /^(\s*|user-request|-)$/.test(r.evidence) || /^plan:M-\d{4}\s*$/.test(r.evidence))) {
683
861
  issues++; warn(`done row without verifiable evidence: ${r.id} (${r.request})`);
684
862
  }
685
863
  if (rows.length === 0) { issues++; warn('progress-tracker is empty (no tasks tracked)'); }
@@ -857,13 +1035,224 @@ function mergeDesign(root) {
857
1035
  ok(merged ? 'design guides merged into .harness/design-system.md' : 'nothing to merge');
858
1036
  }
859
1037
 
860
- function selfCheck(root) {
861
- let latest = 'unknown';
862
- try { latest = cp.execSync('npm view leerness version', { encoding:'utf8', stdio:['ignore','pipe','ignore'], timeout:10000 }).trim(); }
863
- catch { latest = 'npm registry unavailable'; }
864
- const verF = path.join(root,'.harness/HARNESS_VERSION');
865
- const local = exists(verF) ? read(verF).trim() : 'not installed';
866
- log(`Leerness CLI: ${VERSION}`); log(`Project: ${local}`); log(`NPM latest: ${latest}`);
1038
+ // 1.9.2: self check를 update --check의 thin wrapper로 통합 (단일 출처).
1039
+ async function selfCheck(root) {
1040
+ return await updateCmd(root, { checkOnly: true });
1041
+ }
1042
+
1043
+ // 1.9.2: 게이트 5종 한번에 실행 (verify + audit + scan secrets + encoding check + lazy detect).
1044
+ function gate(root) {
1045
+ root = absRoot(root);
1046
+ log('# leerness gate (5 checks)');
1047
+ let bad = 0;
1048
+ function step(label, fn) {
1049
+ log(`\n## ${label}`);
1050
+ const code0 = process.exitCode || 0;
1051
+ try { fn(); } catch (e) { fail(`${label} threw: ${e.message}`); bad++; }
1052
+ if (process.exitCode && process.exitCode !== code0) bad++;
1053
+ process.exitCode = 0;
1054
+ }
1055
+ step('verify', () => verify(root));
1056
+ step('audit', () => audit(root));
1057
+ step('scan secrets', () => scanSecrets(root));
1058
+ step('encoding check', () => encodingCheck(root));
1059
+ step('lazy detect', () => lazyDetect(root));
1060
+ log(`\n# gate summary: ${bad} 단계 실패`);
1061
+ if (bad) process.exitCode = 1;
1062
+ else ok('all gates passed');
1063
+ }
1064
+
1065
+ // ===== 1.9.3: Causal / reuse / consistency =====
1066
+ const CODE_EXT = new Set(['.js','.ts','.jsx','.tsx','.mjs','.cjs','.css','.scss','.sass','.less','.html','.htm','.vue','.svelte','.md','.json','.py','.rb','.go','.rs','.java','.kt','.swift','.cs','.php']);
1067
+ function* walkCode(root, base = root, depth = 0) {
1068
+ if (depth > 12) return;
1069
+ for (const e of fs.readdirSync(base, { withFileTypes: true })) {
1070
+ const p = path.join(base, e.name);
1071
+ const r = path.relative(root, p).replace(/\\/g, '/');
1072
+ if (Array.from(SCAN_SKIP_DIRS).some(d => r === d || r.startsWith(d + '/'))) continue;
1073
+ if (e.isDirectory()) yield* walkCode(root, p, depth + 1);
1074
+ else if (CODE_EXT.has(path.extname(p).toLowerCase())) yield p;
1075
+ }
1076
+ }
1077
+ function escapeRegex(s) { return String(s).replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); }
1078
+
1079
+ function impactCmd(root, target) {
1080
+ if (!target) return fail('target required (e.g., impact src/components/card.html)');
1081
+ root = absRoot(root);
1082
+ const abs = path.isAbsolute(target) ? target : path.resolve(root, target);
1083
+ const base = path.basename(target);
1084
+ const noext = path.basename(target, path.extname(target));
1085
+ const direct = [];
1086
+ const targetRel = rel(root, abs);
1087
+ for (const f of walkCode(root)) {
1088
+ if (path.resolve(f) === path.resolve(abs)) continue;
1089
+ let text; try { text = read(f); } catch { continue; }
1090
+ const patterns = [
1091
+ new RegExp(`(?:import\\s+[^;\\n]*?from\\s+['"]|require\\(['"]|@import\\s+['"]|href=["']|src=["']|url\\(\\s*['"]?|include\\(['"]?)[^'")\\s]*${escapeRegex(base)}`),
1092
+ // bare reference to filename without extension (e.g., 'card' alias)
1093
+ new RegExp(`['"][^'"\\n]*${escapeRegex(noext)}\\b[^'"\\n]*['"]`)
1094
+ ];
1095
+ if (patterns.some(re => re.test(text))) direct.push(rel(root, f));
1096
+ }
1097
+ log(`# impact: ${targetRel}`);
1098
+ if (direct.length === 0) ok('영향 범위 없음 (참조하는 파일 없음)');
1099
+ else {
1100
+ log(`참조하는 파일 ${direct.length}개:`);
1101
+ direct.forEach(d => log('- ' + d));
1102
+ }
1103
+ return { target: targetRel, direct };
1104
+ }
1105
+
1106
+ function reuseMapPath(root) { return path.join(root, '.harness/reuse-map.md'); }
1107
+ function designSystemPath(root) { return path.join(root, '.harness/design-system.md'); }
1108
+ function featureContractsPath(root) { return path.join(root, '.harness/feature-contracts.md'); }
1109
+
1110
+ function reuseFind(root, query) {
1111
+ if (!query) return fail('query required');
1112
+ root = absRoot(root);
1113
+ const re = new RegExp(escapeRegex(query), 'i');
1114
+ const matches = [];
1115
+ for (const src of [reuseMapPath(root), designSystemPath(root), featureContractsPath(root), planPath(root), progressPath(root)]) {
1116
+ if (!exists(src)) continue;
1117
+ const text = read(src);
1118
+ const lines = text.split('\n');
1119
+ for (let i = 0; i < lines.length; i++) {
1120
+ if (re.test(lines[i])) matches.push({ source: rel(root, src), line: i + 1, text: lines[i].trim() });
1121
+ }
1122
+ }
1123
+ // 코드 export/식별자 검색
1124
+ for (const f of walkCode(root)) {
1125
+ if (rel(root, f).startsWith('.harness/')) continue;
1126
+ let text; try { text = read(f); } catch { continue; }
1127
+ const lines = text.split('\n');
1128
+ const exportRe = new RegExp(`(?:export\\s+(?:default\\s+)?(?:async\\s+)?(?:function|const|class|let|var)\\s+(\\w*${escapeRegex(query)}\\w*)|class\\s+(\\w*${escapeRegex(query)}\\w*)|<(\\w*${escapeRegex(query)}\\w*)\\b)`, 'i');
1129
+ for (let i = 0; i < lines.length; i++) {
1130
+ if (exportRe.test(lines[i])) matches.push({ source: rel(root, f), line: i + 1, text: lines[i].trim().slice(0, 120) });
1131
+ }
1132
+ }
1133
+ log(`# reuse find: "${query}"`);
1134
+ if (!matches.length) return ok('기존 자원 없음 — 새로 만드는 것이 최선의 선택일 수 있음');
1135
+ log(`${matches.length}개 후보:`);
1136
+ for (const m of matches.slice(0, parseInt(arg('--limit', '20'), 10))) log(`- ${m.source}:${m.line} ${m.text}`);
1137
+ log(`\n💡 새로 만들기 전에 위 자원을 재사용/확장 가능한지 확인하세요.`);
1138
+ }
1139
+
1140
+ function reuseRegister(root, name) {
1141
+ if (!name) return fail('name required (e.g., reuse register Card --where components/card.html --kind component --note "기본 카드")');
1142
+ root = absRoot(root);
1143
+ const where = arg('--where', '?');
1144
+ const kind = arg('--kind', 'component');
1145
+ const note = arg('--note', '-');
1146
+ const file = reuseMapPath(root);
1147
+ const text = exists(file) ? read(file) : '';
1148
+ if (text.includes(`| ${name} |`)) return warn(`already registered: ${name}`);
1149
+ const newRow = `| ${name} | ${where} | ${kind} | ${note} |`;
1150
+ const lines = text.split('\n');
1151
+ const headerIdx = lines.findIndex(l => /^\|\s*-+\s*\|/.test(l));
1152
+ if (headerIdx >= 0) {
1153
+ lines.splice(headerIdx + 1, 0, newRow);
1154
+ writeUtf8(file, lines.join('\n'));
1155
+ } else {
1156
+ append(file, '\n' + newRow + '\n');
1157
+ }
1158
+ ok(`reuse registered: ${name} (${kind}) → ${where}`);
1159
+ }
1160
+
1161
+ function uiConsistency(root) {
1162
+ root = absRoot(root);
1163
+ // 1) design-system.md에서 토큰 값 추출
1164
+ const ds = exists(designSystemPath(root)) ? read(designSystemPath(root)) : '';
1165
+ const tokens = {};
1166
+ for (const line of ds.split('\n')) {
1167
+ const m = line.match(/^\|\s*([\w.\-]+)\s*\|\s*([^|]+?)\s*\|/);
1168
+ if (!m) continue;
1169
+ const key = m[1].trim();
1170
+ const val = m[2].trim();
1171
+ if (key === 'Token' || /^-+$/.test(key) || val === 'Value' || /실제 값으로 업데이트/.test(val) || !val) continue;
1172
+ tokens[key] = val;
1173
+ }
1174
+ const tokenSet = new Set(Object.values(tokens).map(v => v.toLowerCase()));
1175
+ if (Object.keys(tokens).length === 0) {
1176
+ warn('design-system.md에 토큰이 등록되지 않음 (Tokens 표를 채우면 일관성 검사 가능)');
1177
+ return;
1178
+ }
1179
+ ok(`등록된 디자인 토큰: ${Object.keys(tokens).length}개`);
1180
+ const findings = [];
1181
+ for (const f of walkCode(root)) {
1182
+ if (rel(root, f).startsWith('.harness/')) continue;
1183
+ if (!/\.(css|scss|sass|less|html|jsx|tsx|vue|svelte|js|ts)$/i.test(f)) continue;
1184
+ let text; try { text = read(f); } catch { continue; }
1185
+ const hexes = [...text.matchAll(/#[0-9a-fA-F]{3,8}\b/g)];
1186
+ for (const h of hexes) {
1187
+ const v = h[0].toLowerCase();
1188
+ if (!tokenSet.has(v)) {
1189
+ const line = text.slice(0, h.index).split('\n').length;
1190
+ findings.push({ file: rel(root, f), line, value: h[0], type: 'hex' });
1191
+ }
1192
+ }
1193
+ // px/rem 휴리스틱은 false positive가 많아 옵션
1194
+ if (has('--strict')) {
1195
+ const sizes = [...text.matchAll(/\b(\d+)(px|rem)\b/g)];
1196
+ for (const s of sizes) {
1197
+ const v = `${s[1]}${s[2]}`;
1198
+ if (!tokenSet.has(v)) {
1199
+ const line = text.slice(0, s.index).split('\n').length;
1200
+ findings.push({ file: rel(root, f), line, value: v, type: 'size' });
1201
+ }
1202
+ }
1203
+ }
1204
+ }
1205
+ if (!findings.length) return ok('UI consistency 통과 (모든 색상이 토큰)');
1206
+ warn(`토큰 외 값 ${findings.length}개:`);
1207
+ for (const f of findings.slice(0, 30)) log(` ${f.file}:${f.line} ${f.value} (${f.type})`);
1208
+ if (findings.length > 30) log(` ... +${findings.length - 30}개`);
1209
+ if (has('--fail-on-violation')) process.exitCode = 1;
1210
+ }
1211
+
1212
+ function graphCmd(root) {
1213
+ root = absRoot(root);
1214
+ const edges = [];
1215
+ for (const f of walkCode(root)) {
1216
+ if (rel(root, f).startsWith('.harness/')) continue;
1217
+ let text; try { text = read(f); } catch { continue; }
1218
+ const re = /(?:import\s+[^;\n]*?from\s+['"]|require\(['"]|@import\s+['"]|href=["']|src=["'])([^'")\s]+)/g;
1219
+ let m;
1220
+ while ((m = re.exec(text))) {
1221
+ edges.push({ src: rel(root, f), dst: m[1] });
1222
+ }
1223
+ }
1224
+ const out = arg('--out', null);
1225
+ const lines = ['```mermaid', 'graph TD'];
1226
+ const nodeSet = new Set();
1227
+ for (const e of edges) { nodeSet.add(e.src); nodeSet.add(e.dst); }
1228
+ for (const e of edges) lines.push(` "${e.src}" --> "${e.dst}"`);
1229
+ lines.push('```');
1230
+ const md = `# Code dependency graph\n\n생성: ${now()}\n노드: ${nodeSet.size}, 엣지: ${edges.length}\n\n` + lines.join('\n') + '\n';
1231
+ if (out) {
1232
+ writeUtf8(path.resolve(root, out), md);
1233
+ ok(`graph 저장: ${out}`);
1234
+ } else {
1235
+ log(md);
1236
+ }
1237
+ }
1238
+
1239
+ function guideCmd(root, target) {
1240
+ root = absRoot(root);
1241
+ log(`# 변경 전 가이드 ${target ? `(target: ${target})` : ''}`);
1242
+ log(`Date: ${today()}\n`);
1243
+ if (target) {
1244
+ log('## 1. Impact — 변경하면 영향받는 파일');
1245
+ impactCmd(root, target);
1246
+ log('');
1247
+ }
1248
+ log('## 2. Reuse — 기존 자원 검색');
1249
+ const q = target ? path.basename(target, path.extname(target)) : arg('--query', '');
1250
+ if (q) reuseFind(root, q);
1251
+ else log('(target 또는 --query 없음 — reuse 검색 스킵)');
1252
+ log('');
1253
+ log('## 3. UI consistency — 디자인 토큰 일치');
1254
+ uiConsistency(root);
1255
+ log('\n💡 다음 단계: 위 결과를 바탕으로 작업 계획을 plan/progress에 기록 후 진행하세요.');
867
1256
  }
868
1257
 
869
1258
  // ===== Auto update =====
@@ -978,7 +1367,12 @@ function autoUpdateInstall(root) {
978
1367
  let settings = {};
979
1368
  if (exists(settingsFile)) { try { settings = JSON.parse(read(settingsFile)); } catch {} }
980
1369
  settings.hooks = settings.hooks || {};
981
- if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
1370
+ // 1.9.1 P1: legacy 'leerness-plus update' hook 자동 제거 (이전 fork 시절 잔재).
1371
+ let removedLegacy = 0;
1372
+ settings.hooks.SessionStart = (settings.hooks.SessionStart || []).filter(h => {
1373
+ if (h && h.command && /\bleerness-plus update\b/.test(h.command)) { removedLegacy++; return false; }
1374
+ return true;
1375
+ });
982
1376
  if (!settings.hooks.SessionStart.some(h => h.command && h.command.includes('leerness update'))) {
983
1377
  settings.hooks.SessionStart.push({ matcher: '*', command: 'leerness update --check' });
984
1378
  }
@@ -986,6 +1380,7 @@ function autoUpdateInstall(root) {
986
1380
  writeUtf8(path.join(root, '.claude/commands/update.md'),
987
1381
  `# /update\n\nleerness 자동 업데이트 (감지 → 마이그레이션 → 검증).\n\n\`\`\`\n!leerness update --yes\n\`\`\`\n\n체크만:\n\n\`\`\`\n!leerness update --check\n\`\`\`\n`);
988
1382
  ok('auto-update SessionStart hook installed (.claude/settings.local.json)');
1383
+ if (removedLegacy) ok(`legacy hook 제거: ${removedLegacy}건 (leerness-plus → leerness 통합)`);
989
1384
  ok('/update slash command added');
990
1385
  }
991
1386
 
@@ -1033,7 +1428,7 @@ function viewworkInstall(root) {
1033
1428
  }
1034
1429
 
1035
1430
  function help() {
1036
- log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path]\n leerness session close [path]\n leerness viewwork install [path]\n leerness viewwork emit [path] [--action a] [--note n] [--agent x] [--tool t]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop [args]\n leerness skill list|info|add <name>\n`);
1431
+ log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path]\n leerness session close [path]\n leerness viewwork install [path]\n leerness viewwork emit [path] [--action a] [--note n] [--agent x] [--tool t]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy\n leerness impact <target> # 변경 전 영향 분석\n leerness reuse find <query> # 기존 자원 검색 (재귀 안내)\n leerness reuse register <name> --where <p> --kind component|hook|util|api [--note ...]\n leerness ui consistency [path] [--strict] [--fail-on-violation]\n leerness graph [path] [--out <file>] # mermaid 의존성 그래프\n leerness guide [target] # impact + reuse + ui consistency 통합 가이드\n`);
1037
1432
  }
1038
1433
 
1039
1434
  async function main() {
@@ -1058,12 +1453,27 @@ async function main() {
1058
1453
  if (cmd === 'viewwork' && args[1] === 'install') return viewworkInstall(args[2] || process.cwd());
1059
1454
  if (cmd === 'viewwork' && args[1] === 'emit') return viewworkEmit(args[2] || process.cwd(), { action: arg('--action','task'), note: arg('--note',''), agent: arg('--agent','leerness'), tool: arg('--tool','leerness-cli') });
1060
1455
  if (cmd === 'route') return route(args[1] || 'planning');
1061
- if (cmd === 'self' && args[1] === 'check') return selfCheck(absRoot(args[2] || process.cwd()));
1456
+ if (cmd === 'self' && args[1] === 'check') return await selfCheck(absRoot(args[2] || process.cwd()));
1062
1457
  if (cmd === 'self' && args[1] === 'migrate') return log('Run: npx --yes leerness@latest migrate . --dry-run, then migrate without --dry-run after review.');
1063
1458
  if (cmd === 'readme' && args[1] === 'sync') return readmeCmd(args[2] || process.cwd());
1064
1459
  if (cmd === 'consistency' && args[1] === 'check') return consistencyCheck(args[2] || process.cwd());
1065
1460
  if (cmd === 'consistency' && args[1] === 'merge-design-guide') return mergeDesign(args[2] || process.cwd());
1066
- if (cmd === 'skill' && args[1] === 'list') return skillList();
1461
+ if (cmd === 'skill' && args[1] === 'list') return skillList(args[2] || arg('--path', process.cwd()));
1462
+ if (cmd === 'skill' && args[1] === 'info') return skillInfo(args[2], absRoot(arg('--path', process.cwd())));
1463
+ if (cmd === 'skill' && args[1] === 'add') return addSkill(absRoot(arg('--path', process.cwd())), args[2]);
1464
+ if (cmd === 'skill' && args[1] === 'learn') return skillLearn(absRoot(arg('--path', process.cwd())), args[2]);
1465
+ if (cmd === 'skill' && args[1] === 'use') return skillUse(absRoot(arg('--path', process.cwd())), args[2]);
1466
+ if (cmd === 'skill' && args[1] === 'optimize') return skillOptimize(absRoot(arg('--path', process.cwd())), args[2]);
1467
+ if (cmd === 'skill' && args[1] === 'remove') return skillRemove(absRoot(arg('--path', process.cwd())), args[2]);
1468
+ if (cmd === 'skill' && args[1] === 'consolidate') return skillConsolidate(absRoot(arg('--path', process.cwd())));
1469
+ if (cmd === 'gate') return gate(args[1] || process.cwd());
1470
+ if (cmd === 'impact') return impactCmd(arg('--path', process.cwd()), args[1]);
1471
+ if (cmd === 'reuse' && args[1] === 'find') return reuseFind(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
1472
+ if (cmd === 'reuse' && args[1] === 'register') return reuseRegister(arg('--path', process.cwd()), args[2]);
1473
+ if (cmd === 'ui' && args[1] === 'consistency') return uiConsistency(args[2] || process.cwd());
1474
+ if (cmd === 'graph') return graphCmd(args[1] || process.cwd());
1475
+ if (cmd === 'guide') return guideCmd(arg('--path', process.cwd()), args[1]);
1476
+ // legacy duplicate routing removed below (was: skill list/info/add)
1067
1477
  if (cmd === 'skill' && args[1] === 'info') return skillInfo(args[2]);
1068
1478
  if (cmd === 'skill' && args[1] === 'add') return addSkill(absRoot(arg('--path', process.cwd())), args[2]);
1069
1479
  if (cmd === 'plan') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.0",
3
+ "version": "1.9.3",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -77,12 +77,155 @@ total++;
77
77
  } else { failed++; console.log('✗ .claude/settings.local.json missing'); }
78
78
  }
79
79
 
80
+ // 1.9.1 P6 회귀: evidence가 plan:M-XXXX + 검증 키워드면 lazy detect가 통과해야 한다.
81
+ total++;
82
+ {
83
+ const trackerPath = path.join(tmp, '.harness/progress-tracker.md');
84
+ let cur = fs.readFileSync(trackerPath, 'utf8');
85
+ cur = cur.replace(/^\| T-0001 \|.*$/m, '| T-0001 | done | mile A | tests:32/32 (plan:M-0002) | next | 2026-05-08 |');
86
+ fs.writeFileSync(trackerPath, cur, 'utf8');
87
+ const r = cp.spawnSync(process.execPath, [CLI, 'lazy', 'detect', tmp], { encoding: 'utf8' });
88
+ const ok = r.status === 0;
89
+ console.log(ok ? '✓ B(P6) lazy detect: plan:M-XXXX + 검증 키워드 → pass' : `✗ B(P6) FAIL exit=${r.status}\n${r.stdout}`);
90
+ if (!ok) failed++;
91
+ }
92
+ // 1.9.1 P6 negative: plan:M-XXXX 단독은 여전히 경고
93
+ total++;
94
+ {
95
+ const trackerPath = path.join(tmp, '.harness/progress-tracker.md');
96
+ let cur = fs.readFileSync(trackerPath, 'utf8');
97
+ cur = cur.replace(/^\| T-0001 \|.*$/m, '| T-0001 | done | mile A | plan:M-0002 | next | 2026-05-08 |');
98
+ fs.writeFileSync(trackerPath, cur, 'utf8');
99
+ const r = cp.spawnSync(process.execPath, [CLI, 'lazy', 'detect', tmp], { encoding: 'utf8' });
100
+ const ok = r.status === 1 && /done row without verifiable evidence/.test(r.stdout);
101
+ console.log(ok ? '✓ B(P6 neg) lazy detect: plan:M-XXXX 단독 → warn 유지' : `✗ B(P6 neg) FAIL exit=${r.status}`);
102
+ if (!ok) failed++;
103
+ // restore
104
+ cur = cur.replace(/^\| T-0001 \|.*$/m, '| T-0001 | done | mile A | tests:32/32 (plan:M-0002) | next | 2026-05-08 |');
105
+ fs.writeFileSync(trackerPath, cur, 'utf8');
106
+ }
107
+
108
+ // 1.9.1 P1 회귀: legacy leerness-plus hook이 있으면 auto-update install이 정리해야 한다.
109
+ total++;
110
+ {
111
+ const settingsFile = path.join(tmp, '.claude/settings.local.json');
112
+ const s = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
113
+ s.hooks.SessionStart = [{ matcher: '*', command: 'leerness-plus update --check' }];
114
+ fs.writeFileSync(settingsFile, JSON.stringify(s, null, 2));
115
+ cp.spawnSync(process.execPath, [CLI, 'auto-update', 'install', tmp], { encoding: 'utf8' });
116
+ const after = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
117
+ const hasLegacy = (after.hooks?.SessionStart || []).some(h => /leerness-plus update/.test(h.command || ''));
118
+ const hasNew = (after.hooks?.SessionStart || []).some(h => /\bleerness update\b/.test(h.command || ''));
119
+ const ok = !hasLegacy && hasNew;
120
+ console.log(ok ? '✓ B(P1) legacy leerness-plus hook 자동 제거' : '✗ B(P1) legacy hook 남음');
121
+ if (!ok) failed++;
122
+ }
123
+
124
+ // 1.9.1 P4 회귀: NA 마커가 있으면 audit이 placeholder 경고를 스킵.
125
+ total++;
126
+ {
127
+ const ds = path.join(tmp, '.harness/design-system.md');
128
+ fs.appendFileSync(ds, '\n<!-- leerness:na CLI 프로젝트라 디자인 토큰 없음 -->\n');
129
+ const r = cp.spawnSync(process.execPath, [CLI, 'audit', tmp], { encoding: 'utf8' });
130
+ const ok = !/design-system\.md tokens not customized/.test(r.stdout) && /marked NA/.test(r.stdout);
131
+ console.log(ok ? '✓ B(P4) NA 마커 인식: design-system 경고 스킵' : '✗ B(P4) NA 마커 미작동');
132
+ if (!ok) failed++;
133
+ }
134
+
135
+ // 1.9.1 P7 회귀: init 직후 progress에 M-0001 연결 row가 자동으로 있다.
136
+ total++;
137
+ {
138
+ const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-e2e-p7-'));
139
+ cp.spawnSync(process.execPath, [CLI, 'init', tmp2, '--yes', '--language', 'ko', '--skills', 'recommended'], { encoding: 'utf8' });
140
+ const tracker = fs.readFileSync(path.join(tmp2, '.harness/progress-tracker.md'), 'utf8');
141
+ const ok = /M-0001/.test(tracker);
142
+ console.log(ok ? '✓ B(P7) init: M-0001 → progress row 자동 생성' : '✗ B(P7) progress에 M-0001 row 없음');
143
+ if (!ok) failed++;
144
+ }
145
+
80
146
  run('viewwork install', ['viewwork', 'install', tmp]);
81
147
  run('viewwork emit', ['viewwork', 'emit', tmp, '--action', 'note', '--note', 'e2e ping']);
82
148
  run('route planning', ['route', 'planning']);
83
149
  run('route bugfix', ['route', 'bugfix']);
84
150
  run('skill list', ['skill', 'list']);
85
151
  run('skill info', ['skill', 'info', 'office']);
152
+
153
+ // 1.9.2: 스킬 학습 사이클 회귀
154
+ run('skill learn (new)', ['skill', 'learn', 'open-meteo', '--doc', 'https://open-meteo.com/en/docs', '--command', 'fetch hourly+daily JSON', '--capability', 'http fetch', '--capability', 'cache', '--note', 'e2e learn', '--display', 'Open-Meteo 날씨 스킬', '--path', tmp]);
155
+ run('skill use (new)', ['skill', 'use', 'open-meteo', '--note', 'first call', '--path', tmp]);
156
+ run('skill use (catalog)', ['skill', 'use', 'office', '--note', 'catalog skill materialize', '--path', tmp]);
157
+ run('skill optimize', ['skill', 'optimize', 'open-meteo', '--before', 'no cache', '--after', 'If-Modified-Since', '--note', 'e2e opt', '--path', tmp]);
158
+
159
+ total++;
160
+ {
161
+ const f = path.join(tmp, '.harness/skills/open-meteo/skill.json');
162
+ const ok = fs.existsSync(f) && JSON.parse(fs.readFileSync(f, 'utf8')).optimizations.length === 1;
163
+ console.log(ok ? '✓ skill.json optimizations 누적' : '✗ skill.json optimizations 누적 실패');
164
+ if (!ok) failed++;
165
+ }
166
+ total++;
167
+ {
168
+ const data = JSON.parse(fs.readFileSync(path.join(tmp, '.harness/skills/open-meteo/skill.json'), 'utf8'));
169
+ const ok = data.usage.count === 1 && data.sources.length >= 1 && data.patterns.length >= 1;
170
+ console.log(ok ? '✓ skill usage/sources/patterns 누적' : '✗ skill usage/sources/patterns 누적 실패');
171
+ if (!ok) failed++;
172
+ }
173
+
174
+ run('skill consolidate', ['skill', 'consolidate', '--threshold', '0.1', '--path', tmp]);
175
+ run('skill remove (user)', ['skill', 'remove', 'open-meteo', '--path', tmp]);
176
+ total++;
177
+ {
178
+ const ok = !fs.existsSync(path.join(tmp, '.harness/skills/open-meteo'));
179
+ console.log(ok ? '✓ skill remove: 디렉토리 삭제' : '✗ skill remove: 디렉토리 잔존');
180
+ if (!ok) failed++;
181
+ }
182
+
183
+ // 1.9.3 회귀: impact / reuse / ui consistency / graph / guide
184
+ // 가짜 페이지/컴포넌트/스타일 작성
185
+ fs.mkdirSync(path.join(tmp, 'src/components'), { recursive: true });
186
+ fs.mkdirSync(path.join(tmp, 'src/pages'), { recursive: true });
187
+ fs.mkdirSync(path.join(tmp, 'src/styles'), { recursive: true });
188
+ fs.writeFileSync(path.join(tmp, 'src/styles/tokens.css'), `:root { --color-primary: #ff5722; --color-text: #222222; }\n`);
189
+ fs.writeFileSync(path.join(tmp, 'src/components/Card.html'), `<div class="card"><slot/></div>\n`);
190
+ fs.writeFileSync(path.join(tmp, 'src/pages/home.html'), `<link href="../styles/tokens.css"><include src="../components/Card.html"/>\n`);
191
+ fs.writeFileSync(path.join(tmp, 'src/pages/about.html'), `<link href="../styles/tokens.css"><include src="../components/Card.html"/>\n`);
192
+ // design-system 토큰 채우기 (#ff5722 / #222222 등록)
193
+ const dsPath = path.join(tmp, '.harness/design-system.md');
194
+ let dsText = fs.readFileSync(dsPath, 'utf8');
195
+ dsText = dsText.replace('| color.primary | (실제 값으로 업데이트) | |', '| color.primary | #ff5722 | 메인 컬러 |');
196
+ dsText = dsText.replace('| color.surface | | |', '| color.surface | #222222 | 본문 텍스트 |');
197
+ fs.writeFileSync(dsPath, dsText);
198
+
199
+ run('impact: Card.html → home/about', ['impact', 'src/components/Card.html', '--path', tmp]);
200
+ run('reuse find: Card 후보', ['reuse', 'find', 'Card', '--path', tmp]);
201
+ run('reuse register: Card', ['reuse', 'register', 'Card', '--where', 'src/components/Card.html', '--kind', 'component', '--note', 'e2e card', '--path', tmp]);
202
+
203
+ total++;
204
+ {
205
+ const reuse = fs.readFileSync(path.join(tmp, '.harness/reuse-map.md'), 'utf8');
206
+ const ok = /\| Card \| src\/components\/Card\.html \| component \|/.test(reuse);
207
+ console.log(ok ? '✓ reuse-map.md에 Card row 자동 추가' : '✗ Card row 미등록');
208
+ if (!ok) failed++;
209
+ }
210
+
211
+ run('ui consistency: pass (토큰 외 색상 없음)', ['ui', 'consistency', tmp]);
212
+
213
+ // 의도적 일탈: 토큰 외 색상 추가 → ui consistency 경고
214
+ fs.writeFileSync(path.join(tmp, 'src/pages/contact.html'), `<style>.box { color: #abcdef; background: #123456; }</style>\n`);
215
+ total++;
216
+ {
217
+ const r = cp.spawnSync(process.execPath, [CLI, 'ui', 'consistency', tmp, '--fail-on-violation'], { encoding: 'utf8' });
218
+ const ok = r.status === 1 && /토큰 외 값/.test(r.stdout) && /#abcdef/i.test(r.stdout);
219
+ console.log(ok ? '✓ ui consistency: 일탈 색상 #abcdef 검출 + 비0 종료' : '✗ ui consistency 일탈 미검출');
220
+ if (!ok) { failed++; console.log(r.stdout); }
221
+ }
222
+
223
+ run('graph: mermaid 출력', ['graph', tmp]);
224
+ run('guide: 통합 가이드', ['guide', 'src/components/Card.html', '--path', tmp]);
225
+
226
+ run('gate (all checks)', ['gate', tmp]);
227
+
228
+ run('self check (= update --check)', ['self', 'check', tmp], { });
86
229
  run('readme sync', ['readme', 'sync', tmp]);
87
230
  run('consistency check', ['consistency', 'check', tmp]);
88
231
  run('--version', ['--version']);