leerness 1.9.10 → 1.9.13

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,92 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.13 — 2026-05-13
4
+
5
+ **회고·통찰·브레인스토밍** — 누적된 leerness 데이터에서 자동으로 패턴/추세/주제별 자원을 추출.
6
+
7
+ ### Added — 3 신규 명령
8
+
9
+ - **`leerness retro [path] [--days 7]`** — 회고
10
+ - 작업 상태 분포 / 다음 우선 작업 / 스킬 활용 추세 / 최근 결정 / **검증 시간 추세** / 룰 검증률 / fix↔pass 시그널 비율 / 권장 다음 단계
11
+ - **`leerness insights [path]`** — 누적 통계
12
+ - 핵심 지표 / top 스킬 / 검증 시간 통계 / 안정성 (pass÷fix 비율) / 권장
13
+ - **`leerness brainstorm "<주제>"`** — 주제 기반 자원 회수
14
+ - decisions / skills / tasks / rules / evidence에서 매칭 → 관련 과거 실패(lessons) 포함 → 시작 전 권장 액션
15
+
16
+ ### Added — 자동 회고
17
+
18
+ - `session close`가 매번 끝에 **한 줄 요약** 자동 출력: `완료 N/M (X%) · 스킬 N종 사용 K회 · 검증 변화 ±X% · 결정 N건 누적`
19
+ - **5세션마다** 자동 깊은 회고 실행 (`.harness/cache/session-counter.json`로 카운팅)
20
+ - 다음 깊은 회고까지 남은 세션 수 안내
21
+
22
+ ### Added — 자연어 매핑 (AGENTS.md/CLAUDE.md)
23
+
24
+ | 사용자 발화 | 즉시 실행 |
25
+ |---|---|
26
+ | "회고해줘" / "돌아보자" | `leerness retro` |
27
+ | "통계 / 누적 지표" | `leerness insights` |
28
+ | "X 브레인스토밍 / X 검토" | `leerness brainstorm "X"` |
29
+
30
+ ### Migration
31
+
32
+ 기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 마이그레이션. 이후 session close부터 한 줄 요약 자동 출력.
33
+
34
+ ## 1.9.12 — 2026-05-13
35
+
36
+ **`leerness roadmap` 자동 생성·갱신** — 3개 트리거.
37
+
38
+ ### Added — 자동 roadmap
39
+
40
+ - **`install` 직후 자동 생성**: `npx leerness init .` 끝에 첫 `roadmap.html` 자동 생성. `--no-auto-roadmap`으로 끔.
41
+ - **`session close` 끝 자동 갱신**: `leerness session close .` 마지막에 자동 갱신 출력 라인(`✓ roadmap.html 자동 갱신 (session-close)`).
42
+ - **데이터 변경 즉시 갱신** (옵트인): `--on-every-change`로 켜면 `task add/update/drop`, `plan add`, `rule add/pause/resume` 등이 호출될 때마다 즉시 갱신.
43
+
44
+ ### Added — `leerness roadmap auto on|off|status`
45
+
46
+ - `roadmap auto on [--on-every-change] [--out file.html]` — 활성화 + 옵션 조정
47
+ - `roadmap auto off` — 비활성화 (수동 `leerness roadmap`만 작동)
48
+ - `roadmap auto status` — 현재 설정 표시 (enabled / onEveryChange / outFile / 트리거별 활성 여부)
49
+ - 설정 파일: `.harness/cache/auto-roadmap.json`
50
+ - 환경변수 옵트아웃: `LEERNESS_NO_AUTO_ROADMAP=1`
51
+
52
+ ### Default
53
+
54
+ 신규 init은 **enabled=true / onEveryChange=false**. 가장 자연스러운 워크플로우:
55
+ 1. `leerness init . --skills recommended` → 첫 roadmap.html 즉시 생성
56
+ 2. 작업 → session close → 자동 갱신
57
+ 3. 변경이 많아 즉시 갱신을 원하면 `roadmap auto on --on-every-change`
58
+
59
+ ### Migration
60
+
61
+ 기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 마이그레이션. 이후 첫 session close부터 자동 갱신.
62
+
63
+ ## 1.9.11 — 2026-05-12
64
+
65
+ **`leerness roadmap` 명령 통합 + `project-roadmap-generator` 스킬 기본 추천 + 화이트보드/토큰/상하 중앙정렬**.
66
+
67
+ ### Added — `leerness roadmap [path] [--out file.html]`
68
+
69
+ `project-roadmap-generator` 로직을 leerness 본 패키지에 통합. 외부 의존성 없이 즉시 사용 가능.
70
+
71
+ - 좌→우 수평 트리 (project → milestones → tasks → skills/rules)
72
+ - **상하 중앙정렬**: 각 column의 노드들이 캔버스 세로 중앙 기준으로 균등 분포
73
+ - **디자인 토큰 자동 주입**: `.harness/design-system.md`의 Tokens 표 + 프로젝트 `styles/tokens.css`의 CSS 변수를 HTML `:root`에 `--lr-*`로 주입 (h1·card·border·dot 색상이 사용자 토큰을 따름)
74
+ - **화이트보드**: 드래그 panning, 휠 zoom (마우스 포인터 중심), 더블클릭 reset, +/-/⟳ 컨트롤 버튼
75
+ - 7개 상태 (완료/진행/보류/검토/예정/미완료/오류) + 스킬/룰 색상
76
+ - Milestones, 예정 작업, 보유 스킬, 활성 룰, 최근 결정, 디자인 토큰 6개 섹션 통합
77
+
78
+ ### Changed — `recommended` 스킬에 자동 포함
79
+
80
+ `leerness init . --skills recommended` 호출 시 `project-roadmap-generator` 스킬이 기본으로 설치됩니다 (기존 4종 + 1). 별도 설치 불필요.
81
+
82
+ ```
83
+ recommended = ['office','commerce-api','ai-verified-skill-publisher','feature-implementation','project-roadmap-generator']
84
+ ```
85
+
86
+ ### Migration
87
+
88
+ 기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다. `leerness roadmap`이 바로 사용 가능합니다.
89
+
3
90
  ## 1.9.10 — 2026-05-12
4
91
 
5
92
  **leerness-skillpack 분리 + release publish 강화 (git remote 자동 감지 + GitHub Release + gh-pages 배포)**.
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.10';
9
+ const VERSION = '1.9.13';
10
10
  const MARK = '<!-- leerness:managed -->';
11
11
  const README_START = '<!-- leerness:project-readme:start -->';
12
12
  const README_END = '<!-- leerness:project-readme:end -->';
@@ -71,7 +71,9 @@ const BUILTIN_CATALOG = {
71
71
  'ads-analytics': { displayNameKo: '광고·GA4 분석 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['GA4 이벤트/전환 점검', '광고 데이터 수집 구조화', '소스/매체 분석', '리포트 자동화'] },
72
72
  'appstore-review': { displayNameKo: '앱스토어 심사 대응 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['심사 문구 분석', '개인정보 라벨 점검', '리젝 대응 초안', '웹뷰/앱 데이터 수집 구분'] },
73
73
  'ai-verified-skill-publisher': { displayNameKo: 'AI 검증 스킬 업로드·라이브러리화 스킬', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['검증된 스킬 정규화', '민감정보 스캔', 'AI 검증 메타데이터 작성', 'npm/git 업로드 dry-run 및 실행 게이트'] },
74
- 'feature-implementation': { displayNameKo: '기능 구현 표준 스킬', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['feature-contracts 작성', '재사용 우선 검사', '테스트 증거 수집', '핸드오프 트리거'] }
74
+ 'feature-implementation': { displayNameKo: '기능 구현 표준 스킬', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['feature-contracts 작성', '재사용 우선 검사', '테스트 증거 수집', '핸드오프 트리거'] },
75
+ // 1.9.11: 기본 내장 — 로드맵 자동 생성 스킬
76
+ 'project-roadmap-generator': { displayNameKo: '프로젝트 로드맵 자동 생성 스킬', version: '0.2.0', lastUpdated: '2026-05-12', verification: 'passed', capabilities: ['leerness .harness/* 통합 파싱 (plan/progress/skills/rules/decisions/handoff/current-state)', '좌→우 수평 트리 + 상하 중앙정렬 SVG', '7개 상태 색상 (완료/진행/보류/검토/예정/미완료/오류)', 'design-system + CSS variables 자동 주입', '화이트보드 panning/zoom + 더블클릭 reset', '단일 HTML 출력 (외부 의존성 0)'] }
75
77
  };
76
78
 
77
79
  // 1.9.10: skillCatalog는 skillpack 우선, fallback builtin. _loadSkillCatalog 호출은 BUILTIN_CATALOG 정의 후.
@@ -196,7 +198,7 @@ function coreFiles(root, lang = 'ko', selectedSkills = []) {
196
198
  const project = detectProjectName(root);
197
199
  const skillRows = Object.entries(skillCatalog).map(([k, v]) => `| ${k} | ${v.displayNameKo} | ${v.capabilities.join(', ')} | ${v.lastUpdated} | ${v.verification} |`).join('\n');
198
200
  return {
199
- 'AGENTS.md': `${MARK}\n# Leerness Agent Instructions\n\n## Mandatory read order (session start)\n1. .harness/context-routing.md\n2. .harness/session-handoff.md\n3. .harness/current-state.md\n4. .harness/plan.md\n5. .harness/progress-tracker.md\n6. .harness/guideline.md\n7. .harness/protected-files.md\n8. .harness/writeback-policy.md\n9. .harness/anti-lazy-work-policy.md\n10. **.harness/rules.md** (사용자 정의 영구 룰 — 매 세션 반드시 따름)\n\n## Required behavior\n- 작업 시작 시 \`leerness handoff .\`를 실행해 컨텍스트를 적재합니다 (handoff가 active rules를 자동 출력).\n- 작업 분류는 \`leerness route <task-type>\`로 확인합니다 (planning, feature, bugfix, refactor, research, consistency, release, migration, session-start, session-close, harness-maintenance).\n- 보호 파일/관리 섹션을 삭제하지 않습니다. 머지·아카이브·deprecated 표시를 사용합니다.\n- 의미 있는 변경 후 progress-tracker, current-state, task-log, session-handoff를 갱신합니다.\n- 완료 선언 전 \`leerness check .\` 또는 \`leerness lazy detect .\`로 자기검증합니다.\n- 변경 전 secret/encoding 가드: \`leerness scan secrets .\`, \`leerness encoding check .\`.\n- 같은 기능 중복 생성 전 design-system.md, consistency-policy.md, reuse-map.md를 확인합니다.\n- 매 세션 종료 시 \`leerness session close .\`로 9개 카테고리(완료/진행중/미완료/예정/대기/보류/차단/드랍/검증) + **활성 룰 검증 결과**를 보고합니다.\n- 업데이트는 \`leerness update --check\` (감지) → \`leerness update --yes\` (자동 마이그레이션).\n\n## 자연어 룰 처리 (1.9.8)\n사용자가 자연어로 영구 룰을 요청하면 즉시 leerness rule 명령으로 등록합니다.\n\n| 사용자 발화 (자연어) | 즉시 실행할 명령 |\n|---|---|\n| "매 업데이트마다 버전 bump해줘" | \`leerness rule add "버전을 patch로 bump" --trigger every-update\` |\n| "매 커밋마다 패치노트 추가해줘" | \`leerness rule add "패치노트 추가" --trigger every-commit\` |\n| "세션 종료마다 배포해줘" | \`leerness rule add "배포 (release publish)" --trigger session-close\` |\n| "X 룰 중지/그만/끄기" | \`leerness rule pause <ID>\` (해당 룰 ID는 list로 확인) |\n| "X 룰 제거/삭제" | \`leerness rule remove <ID>\` |\n| "모든 룰 중지" | \`leerness rule stop\` |\n| "룰 다시 켜줘" | \`leerness rule resume-all\` 또는 \`leerness rule resume <ID>\` |\n\n룰을 등록한 후 사용자에게 등록 결과(ID + trigger + 설명)를 보고하고, 그 이후 매 세션마다 자동 적용합니다. 사용자가 "중지" 또는 "제거"를 명시적으로 말하기 전까지는 룰을 비활성화하지 않습니다.\n\n## 룰 자동 적용 (1.9.8)\nleerness가 자동 검증 가능한 trigger:\n- **every-update / version bump 키워드 룰**: package.json의 version이 갱신됐는지 검사 (handoff/session close가 baseline 캐시와 비교).\n- **CHANGELOG / 패치노트 키워드 룰**: CHANGELOG.md의 mtime이 갱신됐는지 검사.\n- **test / 테스트 / verify 키워드 룰**: review-evidence.md에 오늘 verify-code 흔적이 있는지 검사.\n- **배포 / publish / push 키워드 룰**: 자동 검증 불가 → 사용자에게 release publish 명령을 안내.\n\n자동 검증 가능한 룰의 실행은 \`leerness release bump\`, \`leerness release note "..."\`, \`leerness release publish\`를 사용해 자동화합니다.\n`,
201
+ 'AGENTS.md': `${MARK}\n# Leerness Agent Instructions\n\n## Mandatory read order (session start)\n1. .harness/context-routing.md\n2. .harness/session-handoff.md\n3. .harness/current-state.md\n4. .harness/plan.md\n5. .harness/progress-tracker.md\n6. .harness/guideline.md\n7. .harness/protected-files.md\n8. .harness/writeback-policy.md\n9. .harness/anti-lazy-work-policy.md\n10. **.harness/rules.md** (사용자 정의 영구 룰 — 매 세션 반드시 따름)\n\n## Required behavior\n- 작업 시작 시 \`leerness handoff .\`를 실행해 컨텍스트를 적재합니다 (handoff가 active rules를 자동 출력).\n- 작업 분류는 \`leerness route <task-type>\`로 확인합니다 (planning, feature, bugfix, refactor, research, consistency, release, migration, session-start, session-close, harness-maintenance).\n- 보호 파일/관리 섹션을 삭제하지 않습니다. 머지·아카이브·deprecated 표시를 사용합니다.\n- 의미 있는 변경 후 progress-tracker, current-state, task-log, session-handoff를 갱신합니다.\n- 완료 선언 전 \`leerness check .\` 또는 \`leerness lazy detect .\`로 자기검증합니다.\n- 변경 전 secret/encoding 가드: \`leerness scan secrets .\`, \`leerness encoding check .\`.\n- 같은 기능 중복 생성 전 design-system.md, consistency-policy.md, reuse-map.md를 확인합니다.\n- 매 세션 종료 시 \`leerness session close .\`로 9개 카테고리(완료/진행중/미완료/예정/대기/보류/차단/드랍/검증) + **활성 룰 검증 결과**를 보고합니다.\n- 업데이트는 \`leerness update --check\` (감지) → \`leerness update --yes\` (자동 마이그레이션).\n\n## 자연어 회고/통찰/브레인스토밍 (1.9.13)\n사용자가 자연어로 회고/통찰/브레인스토밍을 요청하면 즉시 leerness 명령으로 호출합니다.\n\n| 사용자 발화 (자연어) | 즉시 실행할 명령 |\n|---|---|\n| "회고해줘 / 돌아보자 / 정리해줘" | \`leerness retro\` |\n| "최근 N일 회고" | \`leerness retro --days N\` |\n| "통계 / 누적 지표 / insights" | \`leerness insights\` |\n| "X에 대해 브레인스토밍 / X 관련 자료 / X 시작 전 검토" | \`leerness brainstorm "X"\` |\n\nsession close가 매번 자동으로 한 줄 요약을 출력하고, 5세션마다 자동 깊은 회고를 실행합니다. 사용자가 명시 요청 시 즉시 호출.\n\n## 자연어 룰 처리 (1.9.8)\n사용자가 자연어로 영구 룰을 요청하면 즉시 leerness rule 명령으로 등록합니다.\n\n| 사용자 발화 (자연어) | 즉시 실행할 명령 |\n|---|---|\n| "매 업데이트마다 버전 bump해줘" | \`leerness rule add "버전을 patch로 bump" --trigger every-update\` |\n| "매 커밋마다 패치노트 추가해줘" | \`leerness rule add "패치노트 추가" --trigger every-commit\` |\n| "세션 종료마다 배포해줘" | \`leerness rule add "배포 (release publish)" --trigger session-close\` |\n| "X 룰 중지/그만/끄기" | \`leerness rule pause <ID>\` (해당 룰 ID는 list로 확인) |\n| "X 룰 제거/삭제" | \`leerness rule remove <ID>\` |\n| "모든 룰 중지" | \`leerness rule stop\` |\n| "룰 다시 켜줘" | \`leerness rule resume-all\` 또는 \`leerness rule resume <ID>\` |\n\n룰을 등록한 후 사용자에게 등록 결과(ID + trigger + 설명)를 보고하고, 그 이후 매 세션마다 자동 적용합니다. 사용자가 "중지" 또는 "제거"를 명시적으로 말하기 전까지는 룰을 비활성화하지 않습니다.\n\n## 룰 자동 적용 (1.9.8)\nleerness가 자동 검증 가능한 trigger:\n- **every-update / version bump 키워드 룰**: package.json의 version이 갱신됐는지 검사 (handoff/session close가 baseline 캐시와 비교).\n- **CHANGELOG / 패치노트 키워드 룰**: CHANGELOG.md의 mtime이 갱신됐는지 검사.\n- **test / 테스트 / verify 키워드 룰**: review-evidence.md에 오늘 verify-code 흔적이 있는지 검사.\n- **배포 / publish / push 키워드 룰**: 자동 검증 불가 → 사용자에게 release publish 명령을 안내.\n\n자동 검증 가능한 룰의 실행은 \`leerness release bump\`, \`leerness release note "..."\`, \`leerness release publish\`를 사용해 자동화합니다.\n`,
200
202
  'CLAUDE.md': `${MARK}\n# Claude Code Instructions\n\nFollow AGENTS.md. Always run \`leerness handoff .\` at the start and \`leerness session close .\` before ending a session.\n\nProtected files must not be deleted. Read .harness/anti-lazy-work-policy.md before claiming completion.\n\n## 자연어 영구 룰 (1.9.8)\n사용자가 "매 X마다 Y를 해줘" 같은 자연어 룰을 말하면 즉시 \`leerness rule add "Y" --trigger every-X\`로 등록하세요. 등록된 룰은 매 세션 \`handoff\`가 자동 출력하고, \`session close\`가 자동 검증해 보고합니다. 사용자가 "중지" / "그만" / "끄기"를 명시할 때만 \`rule pause/remove\`를 호출합니다.\n\n자세한 매핑은 AGENTS.md의 "자연어 룰 처리" 표를 참고하세요.\n`,
201
203
  '.cursor/rules/leerness.mdc': `${MARK}\n---\nalwaysApply: true\n---\nFollow AGENTS.md and .harness/context-routing.md.\nRun: \`leerness handoff .\` at session start.\nRun: \`leerness session close .\` at session end.\nPreserve Leerness protected files.\n`,
202
204
  '.github/copilot-instructions.md': `${MARK}\n# Copilot Instructions\n\nUse AGENTS.md and .harness/ as project memory.\nDo not remove protected Leerness files.\nBefore completion, ensure plan.md, progress-tracker.md, current-state.md, session-handoff.md are updated.\n`,
@@ -350,7 +352,8 @@ function syncReadme(root) {
350
352
  function parseSkillsValue(v) {
351
353
  if (!v || v === true) return [];
352
354
  if (v === 'all') return Object.keys(skillCatalog);
353
- if (v === 'recommended') return ['office','commerce-api','ai-verified-skill-publisher','feature-implementation'];
355
+ // 1.9.11: recommended project-roadmap-generator 자동 포함
356
+ if (v === 'recommended') return ['office','commerce-api','ai-verified-skill-publisher','feature-implementation','project-roadmap-generator'];
354
357
  return String(v).split(',').map(s => s.trim()).filter(Boolean).filter(s => skillCatalog[s]);
355
358
  }
356
359
 
@@ -455,6 +458,10 @@ async function install(root, opts = {}) {
455
458
  if (!has('--no-auto-update')) {
456
459
  try { autoUpdateInstall(root); } catch (e) { warn('auto-update hook install skipped: ' + (e && e.message)); }
457
460
  }
461
+ // 1.9.12: install 직후 첫 roadmap.html 자동 생성
462
+ if (!has('--no-auto-roadmap')) {
463
+ try { _autoRoadmap(root, 'install'); } catch (e) { warn('auto-roadmap 실패: ' + (e && e.message)); }
464
+ }
458
465
  }
459
466
  }
460
467
 
@@ -686,6 +693,7 @@ function planAdd(root, text) {
686
693
  const tid = nextId(root, 'T');
687
694
  upsertProgress(root, { id: tid, status, request: text, evidence: `plan:${id}`, nextAction: arg('--next', '다음 액션 작성') });
688
695
  ok(`plan added: ${id} → progress: ${tid}`);
696
+ _autoRoadmap(absRoot(root), 'data-change');
689
697
  }
690
698
  function planDrop(root, text) {
691
699
  const id = nextId(root, 'D');
@@ -722,6 +730,7 @@ function taskAdd(root, text) {
722
730
  const id = nextId(root, 'T');
723
731
  upsertProgress(root, { id, status: arg('--status','requested'), request: text, evidence: arg('--evidence','user-request'), nextAction: arg('--next','다음 액션 작성') });
724
732
  ok(`task added: ${id}`);
733
+ _autoRoadmap(absRoot(root), 'data-change');
725
734
  }
726
735
  function taskUpdate(root, id) {
727
736
  if (!id) return fail('id required (e.g., task update T-0001 --status in-progress)');
@@ -734,11 +743,13 @@ function taskUpdate(root, id) {
734
743
  if (arg('--note')) patch.request = arg('--note');
735
744
  upsertProgress(root, patch);
736
745
  ok(`task updated: ${id}`);
746
+ _autoRoadmap(absRoot(root), 'data-change');
737
747
  }
738
748
  function taskDrop(root, id) {
739
749
  if (!id) return fail('id required');
740
750
  upsertProgress(root, { id, status: 'dropped', evidence: arg('--reason','사용자 요청으로 제외'), nextAction: '없음' });
741
751
  ok(`task dropped: ${id}`);
752
+ _autoRoadmap(absRoot(root), 'data-change');
742
753
  }
743
754
 
744
755
  // 1.9.6: 옛 link 손실 row를 plan.md milestone과 자동 매칭 제안/복구.
@@ -1266,6 +1277,29 @@ function sessionClose(root) {
1266
1277
  log('\n## Required final response sections');
1267
1278
  log('- 완료 작업\n- 진행 중 작업\n- 미완료/예정/대기/보류/차단/드랍 작업\n- 검증 결과\n- 추천 방향\n- 다음 정확한 작업\n- ⚡ 활성 룰별 검증 결과');
1268
1279
  ok(`session-handoff.md and current-state.md updated`);
1280
+ // 1.9.12: session close 끝에 roadmap.html 자동 갱신
1281
+ _autoRoadmap(root, 'session-close');
1282
+ // 1.9.13: 세션 카운터 + 자동 한 줄 요약 + 5세션마다 깊은 회고
1283
+ try {
1284
+ const sc = readSessionCounter(root);
1285
+ sc.count = (sc.count || 0) + 1;
1286
+ sc.lastCloseAt = now();
1287
+ writeSessionCounter(root, sc);
1288
+ const agg = _retroAggregate(root);
1289
+ log(`\n## 📈 진행 요약 (session #${sc.count})`);
1290
+ log(` ${_retroOneLine(agg)}`);
1291
+ if (sc.count % 5 === 0) {
1292
+ log(`\n## 🔄 ${sc.count}세션 마일스톤 — 자동 회고 (5세션마다)`);
1293
+ retroCmd(root);
1294
+ sc.lastDeepRetroAt = now();
1295
+ writeSessionCounter(root, sc);
1296
+ } else {
1297
+ const left = 5 - (sc.count % 5);
1298
+ log(` 💡 ${left}세션 후 자동 깊은 회고 — \`leerness retro\`로 즉시 실행 가능`);
1299
+ }
1300
+ } catch (e) {
1301
+ warn('retro 요약 실패: ' + (e && e.message ? e.message : e));
1302
+ }
1269
1303
  }
1270
1304
 
1271
1305
  function readmeCmd(root) { syncReadme(absRoot(root)); }
@@ -1314,6 +1348,629 @@ function gate(root) {
1314
1348
  else ok('all gates passed');
1315
1349
  }
1316
1350
 
1351
+ // ===== 1.9.13: Retrospective / Insights / Brainstorming =====
1352
+ function sessionCounterPath(root) { return path.join(root, '.harness/cache/session-counter.json'); }
1353
+ function readSessionCounter(root) {
1354
+ if (!exists(sessionCounterPath(root))) return { count: 0, lastCloseAt: null, lastDeepRetroAt: null };
1355
+ try { return JSON.parse(read(sessionCounterPath(root))); } catch { return { count: 0, lastCloseAt: null, lastDeepRetroAt: null }; }
1356
+ }
1357
+ function writeSessionCounter(root, c) { writeUtf8(sessionCounterPath(root), JSON.stringify(c, null, 2) + '\n'); }
1358
+
1359
+ function _retroAggregate(root) {
1360
+ root = absRoot(root);
1361
+ const rows = readProgressRows(root);
1362
+ const decisions = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
1363
+ const tlog = exists(taskLogPath(root)) ? read(taskLogPath(root)) : '';
1364
+ const evidence = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
1365
+ const handoff = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
1366
+
1367
+ // 1) 작업 상태 분포
1368
+ const statusCounts = {};
1369
+ for (const s of STATUSES) statusCounts[s] = 0;
1370
+ for (const r of rows) if (statusCounts[r.status] != null) statusCounts[r.status]++;
1371
+
1372
+ // 2) 결정 블록 수
1373
+ const decisionBlocks = decisions.split(/\n(?=### )/).filter(b => b.startsWith('### '));
1374
+ // recent decisions (날짜로 정렬 시 가장 최근)
1375
+ const recentDecisions = decisionBlocks.slice(-5).map(b => {
1376
+ const t = (b.match(/^### (.+)$/m) || [, ''])[1];
1377
+ return { title: t.trim(), block: b.slice(0, 200) };
1378
+ }).reverse();
1379
+
1380
+ // 3) 스킬 활용
1381
+ const skillsDir = path.join(root, '.harness/skills');
1382
+ const skillUsage = [];
1383
+ if (exists(skillsDir)) {
1384
+ for (const id of fs.readdirSync(skillsDir)) {
1385
+ const f = path.join(skillsDir, id, 'skill.json');
1386
+ if (!exists(f)) continue;
1387
+ try {
1388
+ const s = JSON.parse(read(f));
1389
+ skillUsage.push({
1390
+ id,
1391
+ displayNameKo: s.displayNameKo || id,
1392
+ count: s.usage?.count || 0,
1393
+ lastUsed: s.usage?.lastUsed || null,
1394
+ optimizations: (s.optimizations || []).length,
1395
+ capabilities: (s.capabilities || []).length
1396
+ });
1397
+ } catch {}
1398
+ }
1399
+ }
1400
+ skillUsage.sort((a, b) => (b.count - a.count) || (b.optimizations - a.optimizations));
1401
+
1402
+ // 4) 검증 시간 추세 — review-evidence.md에서 "exit=0 (Nms)" 또는 "(Nms)" 패턴
1403
+ const durations = [];
1404
+ for (const m of evidence.matchAll(/exit=\d+\s*\((\d+)ms\)/g)) durations.push(parseInt(m[1], 10));
1405
+
1406
+ // 5) 실패→성공 시그널 — task-log/evidence/decisions에서 "롤백" / "fail" / "재발" / "fix" / "수정" 등의 동시 등장 카운트
1407
+ const fixSignals = (tlog + evidence + decisions).match(/\b(fix|fixed|수정|롤백|재발|incomplete|bug)\b/gi) || [];
1408
+ const passSignals = (tlog + evidence + decisions).match(/(?:✓|pass(?:ed)?|통과|completed|done)/gi) || [];
1409
+
1410
+ // 6) 룰 활용
1411
+ const rules = exists(rulesPath(root)) ? readRules(root) : [];
1412
+ const activeRules = rules.filter(r => r.status === 'active');
1413
+ const verifiedRules = rules.filter(r => r.lastVerified && r.lastVerified !== '-');
1414
+
1415
+ // 7) 최근 in-progress / incomplete (우선 권장)
1416
+ const focusNext = rows.filter(r => r.status === 'in-progress')
1417
+ .concat(rows.filter(r => ['incomplete', 'blocked', 'waiting', 'on-hold'].includes(r.status)));
1418
+
1419
+ return {
1420
+ statusCounts,
1421
+ rows,
1422
+ totalTasks: rows.length,
1423
+ doneCount: statusCounts.done,
1424
+ decisionBlocks: decisionBlocks.length,
1425
+ recentDecisions,
1426
+ skillUsage,
1427
+ totalSkillUsage: skillUsage.reduce((a, b) => a + b.count, 0),
1428
+ totalOptimizations: skillUsage.reduce((a, b) => a + b.optimizations, 0),
1429
+ durations,
1430
+ fixSignals: fixSignals.length,
1431
+ passSignals: passSignals.length,
1432
+ activeRules: activeRules.length,
1433
+ verifiedRules: verifiedRules.length,
1434
+ focusNext
1435
+ };
1436
+ }
1437
+
1438
+ function _retroOneLine(agg) {
1439
+ const parts = [];
1440
+ const done = agg.statusCounts.done;
1441
+ const total = agg.totalTasks;
1442
+ if (total) parts.push(`완료 ${done}/${total} (${Math.round(done / total * 100)}%)`);
1443
+ if (agg.totalSkillUsage) parts.push(`스킬 ${agg.skillUsage.length}종 / 사용 ${agg.totalSkillUsage}회 / 최적화 ${agg.totalOptimizations}건`);
1444
+ if (agg.activeRules) parts.push(`룰 ${agg.activeRules}건 활성 (${agg.verifiedRules} 검증됨)`);
1445
+ if (agg.durations.length >= 4) {
1446
+ const mid = Math.floor(agg.durations.length / 2);
1447
+ const a = agg.durations.slice(0, mid).reduce((x, y) => x + y, 0) / mid;
1448
+ const b = agg.durations.slice(mid).reduce((x, y) => x + y, 0) / (agg.durations.length - mid);
1449
+ if (a > 0) {
1450
+ const delta = ((b - a) / a) * 100;
1451
+ const sign = delta > 0 ? '+' : '';
1452
+ parts.push(`검증 ${Math.round(a)}ms→${Math.round(b)}ms (${sign}${delta.toFixed(1)}%)`);
1453
+ }
1454
+ }
1455
+ parts.push(`결정 ${agg.decisionBlocks}건 누적`);
1456
+ return parts.join(' · ');
1457
+ }
1458
+
1459
+ function retroCmd(root) {
1460
+ root = absRoot(root);
1461
+ const days = parseInt(arg('--days', '7'), 10);
1462
+ const cutoff = new Date(Date.now() - days * 86400 * 1000).toISOString().slice(0, 10);
1463
+ const agg = _retroAggregate(root);
1464
+ log(`# 회고 (retro) — 최근 ${days}일 (since ${cutoff})`);
1465
+ log(`\n📈 한 줄 요약: ${_retroOneLine(agg)}`);
1466
+
1467
+ log(`\n## 작업 상태 분포`);
1468
+ for (const s of STATUSES) if (agg.statusCounts[s]) log(` - ${s}: ${agg.statusCounts[s]}`);
1469
+
1470
+ log(`\n## 🎯 다음 우선 작업 (top 5)`);
1471
+ if (!agg.focusNext.length) log(' (없음 — 새 plan add 권장)');
1472
+ else agg.focusNext.slice(0, 5).forEach(r => log(` - ${r.id} [${r.status}] ${r.request} → ${r.nextAction}`));
1473
+
1474
+ log(`\n## 📚 스킬 활용 추세 (top 5)`);
1475
+ if (!agg.skillUsage.length) log(' (등록된 스킬 없음)');
1476
+ else agg.skillUsage.slice(0, 5).forEach(s => log(` - ${s.id}: 사용 ${s.count}회, 최적화 ${s.optimizations}건, capabilities ${s.capabilities}개${s.lastUsed ? ' · 마지막 ' + s.lastUsed.slice(0, 10) : ''}`));
1477
+
1478
+ log(`\n## 🧠 최근 결정 (top 5)`);
1479
+ if (!agg.recentDecisions.length) log(' (없음)');
1480
+ else agg.recentDecisions.slice(0, 5).forEach(d => log(` - ${d.title}`));
1481
+
1482
+ if (agg.durations.length >= 4) {
1483
+ const mid = Math.floor(agg.durations.length / 2);
1484
+ const a = agg.durations.slice(0, mid).reduce((x, y) => x + y, 0) / mid;
1485
+ const b = agg.durations.slice(mid).reduce((x, y) => x + y, 0) / (agg.durations.length - mid);
1486
+ const delta = ((b - a) / a) * 100;
1487
+ log(`\n## ⏱ 검증 시간 추세 (review-evidence)`);
1488
+ log(` - 전반부 평균: ${Math.round(a)}ms`);
1489
+ log(` - 후반부 평균: ${Math.round(b)}ms`);
1490
+ log(` - 변화: ${delta > 0 ? '+' : ''}${delta.toFixed(1)}% ${delta < 0 ? '🚀 빨라짐' : delta > 10 ? '⚠ 느려짐' : ''}`);
1491
+ }
1492
+
1493
+ log(`\n## ⚡ 활성 룰 / 검증 비율`);
1494
+ log(` - 활성 ${agg.activeRules}건 · 검증됨 ${agg.verifiedRules}건 (${agg.activeRules ? Math.round(agg.verifiedRules / agg.activeRules * 100) : 0}%)`);
1495
+
1496
+ log(`\n## 🔁 fix/pass 시그널`);
1497
+ log(` - fix 시그널 (롤백/수정/bug/incomplete): ${agg.fixSignals}회`);
1498
+ log(` - pass 시그널 (통과/✓/completed): ${agg.passSignals}회`);
1499
+ if (agg.passSignals > agg.fixSignals * 2) log(' - 평가: 안정적 (pass >> fix)');
1500
+ else if (agg.fixSignals > agg.passSignals) log(' - 평가: 디버그 비중 높음 — verify-code 자동화 검토');
1501
+
1502
+ log(`\n## 💡 권장 다음 단계`);
1503
+ if (agg.focusNext.length) log(` 1. ${agg.focusNext[0].id} (${agg.focusNext[0].status}): ${agg.focusNext[0].nextAction}`);
1504
+ if (agg.skillUsage.length && agg.skillUsage[0].count > 0) log(` 2. 가장 활발한 스킬 "${agg.skillUsage[0].id}"의 패턴을 다른 작업에 재사용 가능`);
1505
+ if (agg.totalOptimizations > 0) log(` 3. 누적된 최적화 ${agg.totalOptimizations}건을 새 작업의 시작 전 참고 (\`leerness skill info <id>\`)`);
1506
+ log(` 4. \`leerness brainstorm <주제>\`로 누적 데이터 기반 컨텍스트 적재`);
1507
+ }
1508
+
1509
+ function insightsCmd(root) {
1510
+ root = absRoot(root);
1511
+ const agg = _retroAggregate(root);
1512
+ const sc = readSessionCounter(root);
1513
+ log(`# Insights — 누적 통계`);
1514
+ log(`\n## 📊 핵심 지표`);
1515
+ log(` - 누적 task: ${agg.totalTasks} (done ${agg.doneCount}, in-progress ${agg.statusCounts['in-progress']}, planned ${agg.statusCounts.planned})`);
1516
+ log(` - 누적 결정 (decisions.md): ${agg.decisionBlocks}건`);
1517
+ log(` - 누적 스킬: ${agg.skillUsage.length}종`);
1518
+ log(` - 총 스킬 사용: ${agg.totalSkillUsage}회`);
1519
+ log(` - 총 최적화 누적: ${agg.totalOptimizations}건`);
1520
+ log(` - 활성 룰: ${agg.activeRules}건 (검증 ${agg.verifiedRules}건)`);
1521
+ log(` - session close 횟수: ${sc.count}회${sc.lastCloseAt ? ' (마지막: ' + sc.lastCloseAt.slice(0, 16) + ')' : ''}`);
1522
+
1523
+ if (agg.skillUsage.length) {
1524
+ log(`\n## 🏆 가장 활용도 높은 스킬 (top 5)`);
1525
+ agg.skillUsage.slice(0, 5).forEach((s, i) => log(` ${i + 1}. ${s.id} (${s.displayNameKo}) — 사용 ${s.count}회, 최적화 ${s.optimizations}건`));
1526
+ }
1527
+
1528
+ if (agg.durations.length) {
1529
+ const total = agg.durations.reduce((a, b) => a + b, 0);
1530
+ log(`\n## ⏱ 검증 시간 (verify-code)`);
1531
+ log(` - 실행: ${agg.durations.length}회 / 총 ${total}ms / 평균 ${Math.round(total / agg.durations.length)}ms`);
1532
+ log(` - 최소 ${Math.min(...agg.durations)}ms / 최대 ${Math.max(...agg.durations)}ms`);
1533
+ }
1534
+
1535
+ log(`\n## 🔁 안정성 지표`);
1536
+ log(` - pass 시그널: ${agg.passSignals} · fix 시그널: ${agg.fixSignals}`);
1537
+ const ratio = agg.fixSignals > 0 ? (agg.passSignals / agg.fixSignals).toFixed(2) : '∞';
1538
+ log(` - pass/fix 비율: ${ratio}${ratio === '∞' || parseFloat(ratio) > 3 ? ' (안정)' : parseFloat(ratio) < 1 ? ' (디버그 위주)' : ' (보통)'}`);
1539
+
1540
+ log(`\n## 📈 권장`);
1541
+ if (agg.totalOptimizations === 0) log(` - 스킬에 최적화 누적 없음 — \`leerness skill optimize <id> --before --after\`로 더 나은 방법 기록`);
1542
+ if (sc.count >= 5 && sc.count % 5 === 0) log(` - 5세션마다 자동 깊은 회고가 예정되어 있습니다 — session close가 자동 호출`);
1543
+ if (agg.statusCounts.blocked > 0) log(` - blocked 작업 ${agg.statusCounts.blocked}건 — \`leerness lessons --query "blocked"\`로 과거 패턴 회수`);
1544
+ }
1545
+
1546
+ function brainstormCmd(root, topic) {
1547
+ root = absRoot(root);
1548
+ if (!topic) return fail('topic required (e.g., brainstorm "API rate limit")');
1549
+ log(`# Brainstorm — "${topic}"`);
1550
+ log(`\n누적된 leerness 데이터에서 주제 관련 자원을 회수합니다.`);
1551
+
1552
+ const re = new RegExp(escapeRegex(topic), 'i');
1553
+ const hits = { decisions: [], skills: [], tasks: [], rules: [], evidence: [], lessons: [] };
1554
+
1555
+ // decisions
1556
+ const dec = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
1557
+ for (const b of dec.split(/\n(?=### )/)) {
1558
+ if (!b.startsWith('### ')) continue;
1559
+ if (re.test(b)) {
1560
+ const t = (b.match(/^### (.+)$/m) || [, ''])[1];
1561
+ hits.decisions.push({ title: t, preview: b.slice(0, 200).replace(/\n+/g, ' ') });
1562
+ }
1563
+ }
1564
+ // skills
1565
+ const skillsDir = path.join(root, '.harness/skills');
1566
+ if (exists(skillsDir)) {
1567
+ for (const id of fs.readdirSync(skillsDir)) {
1568
+ const f = path.join(skillsDir, id, 'skill.json');
1569
+ if (!exists(f)) continue;
1570
+ try {
1571
+ const s = JSON.parse(read(f));
1572
+ const text = JSON.stringify(s);
1573
+ if (re.test(text)) hits.skills.push({ id, displayNameKo: s.displayNameKo, capabilities: s.capabilities, usage: s.usage });
1574
+ } catch {}
1575
+ }
1576
+ }
1577
+ // tasks
1578
+ const rows = readProgressRows(root);
1579
+ for (const r of rows) if (re.test(r.request) || re.test(r.evidence) || re.test(r.nextAction)) hits.tasks.push(r);
1580
+ // rules
1581
+ if (exists(rulesPath(root))) {
1582
+ for (const r of readRules(root)) if (re.test(r.rule)) hits.rules.push(r);
1583
+ }
1584
+ // evidence — lessons 키워드 (fail/롤백/incomplete) 동반
1585
+ const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
1586
+ for (const block of ev.split(/\n(?=## )/)) {
1587
+ if (!block.startsWith('## ')) continue;
1588
+ if (re.test(block)) {
1589
+ const t = (block.match(/^## (.+)$/m) || [, ''])[1];
1590
+ hits.evidence.push({ title: t.trim(), preview: block.slice(0, 200).replace(/\n+/g, ' ') });
1591
+ if (/✗|fail|롤백|incomplete|버그/i.test(block)) hits.lessons.push({ title: t.trim() });
1592
+ }
1593
+ }
1594
+
1595
+ const total = hits.decisions.length + hits.skills.length + hits.tasks.length + hits.rules.length + hits.evidence.length;
1596
+ log(`\n📦 총 ${total}건 발견 (decisions ${hits.decisions.length} · skills ${hits.skills.length} · tasks ${hits.tasks.length} · rules ${hits.rules.length} · evidence ${hits.evidence.length})`);
1597
+
1598
+ if (hits.decisions.length) {
1599
+ log(`\n## 🧠 관련 결정 (${hits.decisions.length})`);
1600
+ hits.decisions.slice(0, 5).forEach(d => log(` - ${d.title}`));
1601
+ }
1602
+ if (hits.skills.length) {
1603
+ log(`\n## 📚 관련 스킬 (${hits.skills.length}) — 시작 전 \`skill info <id>\` 권장`);
1604
+ hits.skills.forEach(s => log(` - ${s.id} (${s.displayNameKo}) · 사용 ${s.usage?.count || 0}회 · cap ${(s.capabilities || []).length}`));
1605
+ }
1606
+ if (hits.tasks.length) {
1607
+ log(`\n## 📌 관련 과거 task (${hits.tasks.length})`);
1608
+ hits.tasks.slice(0, 5).forEach(t => log(` - ${t.id} [${t.status}] ${t.request}`));
1609
+ }
1610
+ if (hits.rules.length) {
1611
+ log(`\n## ⚡ 관련 룰 (${hits.rules.length})`);
1612
+ hits.rules.forEach(r => log(` - ${r.id} [${r.trigger}] ${r.rule}`));
1613
+ }
1614
+ if (hits.evidence.length) {
1615
+ log(`\n## 🧪 관련 검증 기록 (${hits.evidence.length})`);
1616
+ hits.evidence.slice(0, 5).forEach(e => log(` - ${e.title}`));
1617
+ }
1618
+ if (hits.lessons.length) {
1619
+ log(`\n## ⚠ 같은 주제 과거 실패/롤백 (${hits.lessons.length}) — 같은 실수 방지`);
1620
+ hits.lessons.slice(0, 5).forEach(l => log(` - ${l.title}`));
1621
+ }
1622
+
1623
+ log(`\n## 💡 시작 전 권장 액션`);
1624
+ log(` 1. 위 자원을 모두 검토 후 plan add 또는 task add로 새 작업 등록`);
1625
+ log(` 2. 가장 비슷한 과거 스킬을 \`leerness skill use <id>\`로 활성화`);
1626
+ log(` 3. 작업 종료 시 새로 발견한 패턴을 \`skill optimize\`로 누적`);
1627
+ if (!total) log(` ⓘ 관련 자원 없음 — 새로운 영역. 첫 결정/스킬을 기록하면 다음 brainstorm이 더 풍부해짐.`);
1628
+ }
1629
+
1630
+ // ===== 1.9.11: Roadmap (project-roadmap-generator 통합) =====
1631
+ const ROADMAP_STATUS_LABEL = { done: '완료', 'in-progress': '진행', 'on-hold': '보류', waiting: '검토', incomplete: '미완료', planned: '예정', blocked: '오류', dropped: '취소', skill: '스킬', rule: '룰', meta: '프로젝트' };
1632
+ const ROADMAP_STATUS_COLOR = { done: '#16a34a', 'in-progress': '#2563eb', 'on-hold': '#6b7280', waiting: '#eab308', incomplete: '#f97316', planned: '#94a3b8', blocked: '#dc2626', dropped: '#9ca3af', skill: '#8b5cf6', rule: '#06b6d4', meta: '#0f172a' };
1633
+ const ROADMAP_NODE_W = 220, ROADMAP_NODE_H = 72, ROADMAP_COL_GAP = 70, ROADMAP_ROW_GAP = 14;
1634
+
1635
+ function _esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
1636
+ function _truncate(s, n) { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
1637
+
1638
+ function _roadmapMapStatus(s) {
1639
+ s = String(s || '').toLowerCase();
1640
+ if (s === 'done' || s === 'in-progress' || s === 'on-hold' || s === 'waiting' || s === 'incomplete' || s === 'blocked' || s === 'dropped') return s;
1641
+ if (s === 'planned' || s === 'requested') return 'planned';
1642
+ return 'planned';
1643
+ }
1644
+
1645
+ function _roadmapParseMilestones(text) {
1646
+ const out = [];
1647
+ for (const m of String(text || '').matchAll(/^### (M-\d{4})\.\s*(.+?)$/gm)) {
1648
+ const after = text.slice(m.index);
1649
+ const sm = after.match(/^Status:\s*(\S+)/m);
1650
+ const pm = after.match(/^Progress:\s*(\d+)%/m);
1651
+ out.push({ id: m[1], title: m[2].trim(), status: sm ? sm[1] : 'planned', progress: pm ? parseInt(pm[1], 10) : 0 });
1652
+ }
1653
+ return out;
1654
+ }
1655
+
1656
+ function _roadmapParseTokens(text) {
1657
+ const tokens = {};
1658
+ for (const line of String(text || '').split('\n')) {
1659
+ const m = line.match(/^\|\s*([\w.\-]+)\s*\|\s*([^|]+?)\s*\|/);
1660
+ if (!m) continue;
1661
+ const key = m[1].trim(), val = m[2].trim();
1662
+ if (!key || !val || key === 'Token' || /^-+$/.test(key) || val === 'Value' || /\(실제 값으로 업데이트\)/.test(val)) continue;
1663
+ if (val.length > 80) continue;
1664
+ tokens[key] = val;
1665
+ }
1666
+ return tokens;
1667
+ }
1668
+
1669
+ function _roadmapParseCssVars(root) {
1670
+ const out = {};
1671
+ const cands = ['src/styles/tokens.css', 'styles/tokens.css', 'src/styles.css', 'styles.css', 'src/styles/main.css', 'public/styles.css'];
1672
+ for (const c of cands) {
1673
+ const f = path.join(root, c);
1674
+ if (!exists(f)) continue;
1675
+ const text = read(f);
1676
+ const m = text.match(/:root\s*\{([\s\S]*?)\}/);
1677
+ if (!m) continue;
1678
+ for (const line of m[1].split('\n')) {
1679
+ const v = line.match(/--([\w-]+)\s*:\s*([^;]+);/);
1680
+ if (v) out[v[1].trim()] = v[2].trim();
1681
+ }
1682
+ }
1683
+ return out;
1684
+ }
1685
+
1686
+ function _roadmapData(root) {
1687
+ root = absRoot(root);
1688
+ const milestones = _roadmapParseMilestones(exists(planPath(root)) ? read(planPath(root)) : '');
1689
+ const tasks = readProgressRows(root).map(t => ({
1690
+ ...t,
1691
+ milestones: Array.from(String(t.evidence || '').matchAll(/M-\d{4}/g)).map(m => m[0])
1692
+ }));
1693
+ // skills
1694
+ const skills = [];
1695
+ const skillsDir = path.join(root, '.harness/skills');
1696
+ if (exists(skillsDir)) {
1697
+ for (const id of fs.readdirSync(skillsDir)) {
1698
+ const f = path.join(skillsDir, id, 'skill.json');
1699
+ if (!exists(f)) continue;
1700
+ try { skills.push(JSON.parse(read(f))); } catch {}
1701
+ }
1702
+ }
1703
+ // rules
1704
+ const rulesT = exists(rulesPath(root)) ? read(rulesPath(root)) : '';
1705
+ const rules = [];
1706
+ for (const line of rulesT.split('\n')) {
1707
+ if (!/^\| R-\d{4} \|/.test(line)) continue;
1708
+ const cells = line.split('|').slice(1, -1).map(s => s.trim());
1709
+ if (cells.length < 6) continue;
1710
+ rules.push({ id: cells[0], trigger: cells[1], rule: cells[2], status: cells[4], lastVerified: cells[5] });
1711
+ }
1712
+ // currentState
1713
+ const csT = exists(currentStatePath(root)) ? read(currentStatePath(root)) : '';
1714
+ const now = (csT.match(/## Now\n([\s\S]*?)(?=\n## )/) || [, ''])[1].trim();
1715
+ const next = (csT.match(/## Next\n([\s\S]*?)(?=\n## )/) || [, ''])[1].trim();
1716
+ const blockers = (csT.match(/## Blockers\n([\s\S]*?)$/) || [, ''])[1].trim();
1717
+ // decisions (top 6)
1718
+ const decT = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
1719
+ const decisions = [];
1720
+ for (const block of decT.split(/\n(?=### )/)) {
1721
+ if (!block.startsWith('### ')) continue;
1722
+ const tm = block.match(/^### (.+)$/m);
1723
+ if (tm) decisions.push({ title: tm[1].trim() });
1724
+ }
1725
+ return {
1726
+ project: path.basename(root),
1727
+ version: exists(path.join(root, '.harness/HARNESS_VERSION')) ? read(path.join(root, '.harness/HARNESS_VERSION')).trim() : 'unknown',
1728
+ milestones, tasks, skills, rules,
1729
+ currentState: { now, next, blockers },
1730
+ decisions,
1731
+ designTokens: _roadmapParseTokens(exists(path.join(root, '.harness/design-system.md')) ? read(path.join(root, '.harness/design-system.md')) : ''),
1732
+ cssVariables: _roadmapParseCssVars(root)
1733
+ };
1734
+ }
1735
+
1736
+ function _roadmapLayout(data) {
1737
+ const nodes = []; const edges = [];
1738
+ nodes.push({ id: 'project', kind: 'project', title: data.project, subtitle: `leerness ${data.version}`, meta: `M ${data.milestones.length} · T ${data.tasks.length} · S ${data.skills.length}`, status: 'meta', col: 0 });
1739
+ for (const m of data.milestones) {
1740
+ nodes.push({ id: m.id, kind: 'milestone', title: m.id, subtitle: m.title, meta: `${m.progress}% · ${m.status}`, status: _roadmapMapStatus(m.status), col: 1 });
1741
+ edges.push({ from: 'project', to: m.id });
1742
+ }
1743
+ for (const t of data.tasks) {
1744
+ nodes.push({ id: t.id, kind: 'task', title: t.id, subtitle: t.request, meta: t.evidence ? `evidence: ${t.evidence.slice(0, 40)}` : '', status: _roadmapMapStatus(t.status), col: 2 });
1745
+ if (t.milestones.length) for (const mid of t.milestones) edges.push({ from: mid, to: t.id });
1746
+ else edges.push({ from: 'project', to: t.id });
1747
+ }
1748
+ for (const s of data.skills) {
1749
+ nodes.push({ id: 'skill:' + s.name, kind: 'skill', title: s.name, subtitle: s.displayNameKo || s.name, meta: `사용 ${s.usage?.count || 0}회 · cap ${(s.capabilities || []).length}`, status: 'skill', col: 3 });
1750
+ edges.push({ from: 'project', to: 'skill:' + s.name });
1751
+ }
1752
+ for (const r of data.rules.filter(r => r.status === 'active')) {
1753
+ nodes.push({ id: 'rule:' + r.id, kind: 'rule', title: r.id, subtitle: r.rule, meta: r.trigger, status: 'rule', col: 3 });
1754
+ edges.push({ from: 'project', to: 'rule:' + r.id });
1755
+ }
1756
+ // 상하 중앙정렬 (1.9.11 v0.2)
1757
+ const byCol = {};
1758
+ for (const n of nodes) (byCol[n.col] = byCol[n.col] || []).push(n);
1759
+ const colH = {}; let maxColH = 0; let maxCol = 0;
1760
+ for (const c of Object.keys(byCol)) {
1761
+ const r = byCol[c]; const h = r.length * ROADMAP_NODE_H + Math.max(0, r.length - 1) * ROADMAP_ROW_GAP;
1762
+ colH[c] = h; maxColH = Math.max(maxColH, h); maxCol = Math.max(maxCol, parseInt(c, 10));
1763
+ }
1764
+ const padding = 40; const minHeight = 360;
1765
+ const canvasHeight = Math.max(maxColH, minHeight) + padding * 2;
1766
+ for (const c of Object.keys(byCol)) {
1767
+ const r = byCol[c]; const h = colH[c]; const startY = (canvasHeight - h) / 2;
1768
+ r.forEach((n, i) => {
1769
+ n.x = parseInt(c, 10) * (ROADMAP_NODE_W + ROADMAP_COL_GAP) + padding;
1770
+ n.y = startY + i * (ROADMAP_NODE_H + ROADMAP_ROW_GAP);
1771
+ });
1772
+ }
1773
+ return { nodes, edges, width: (maxCol + 1) * (ROADMAP_NODE_W + ROADMAP_COL_GAP) + padding * 2, height: canvasHeight };
1774
+ }
1775
+
1776
+ function _roadmapTokenStyles(designTokens, cssVariables) {
1777
+ const vars = {};
1778
+ const map = [
1779
+ ['color.primary', 'color-primary', 'lr-primary'], ['color.surface', 'color-surface', 'lr-surface'],
1780
+ ['color.text', 'color-text', 'lr-text'], ['color.muted', 'color-muted', 'lr-muted'],
1781
+ ['space.1', 'space-1', 'lr-space-1'], ['space.2', 'space-2', 'lr-space-2'],
1782
+ ['space.3', 'space-3', 'lr-space-3'], ['space.4', 'space-4', 'lr-space-4'],
1783
+ ['radius', 'radius', 'lr-radius']
1784
+ ];
1785
+ for (const [ds, css, vn] of map) { const v = cssVariables[css] || designTokens[ds]; if (v) vars[vn] = v; }
1786
+ for (const [k, v] of Object.entries(cssVariables)) if (!vars[`lr-${k}`]) vars[`lr-${k}`] = v;
1787
+ if (!vars['lr-card-bg']) vars['lr-card-bg'] = vars['lr-surface'] || '#ffffff';
1788
+ if (!vars['lr-edge']) vars['lr-edge'] = vars['lr-muted'] || '#cbd5e1';
1789
+ if (!vars['lr-page-bg']) vars['lr-page-bg'] = '#f8fafc';
1790
+ return ':root {\n' + Object.entries(vars).map(([k, v]) => ` --${k}: ${v};`).join('\n') + '\n }';
1791
+ }
1792
+
1793
+ function _roadmapHTML(data) {
1794
+ const g = _roadmapLayout(data);
1795
+ const edges = g.edges.map(e => {
1796
+ const f = g.nodes.find(n => n.id === e.from), t = g.nodes.find(n => n.id === e.to);
1797
+ if (!f || !t) return '';
1798
+ const x1 = f.x + ROADMAP_NODE_W, y1 = f.y + ROADMAP_NODE_H / 2, x2 = t.x, y2 = t.y + ROADMAP_NODE_H / 2, mid = (x1 + x2) / 2;
1799
+ return `<path d="M ${x1},${y1} C ${mid},${y1} ${mid},${y2} ${x2},${y2}" stroke="var(--lr-edge, #cbd5e1)" stroke-width="1.5" fill="none"/>`;
1800
+ }).join('\n');
1801
+ const nodes = g.nodes.map(n => {
1802
+ const c = ROADMAP_STATUS_COLOR[n.status] || 'var(--lr-text, #0f172a)';
1803
+ const lbl = ROADMAP_STATUS_LABEL[n.status] || n.status;
1804
+ return `<g class="node node-${n.kind} status-${n.status}" data-id="${_esc(n.id)}" transform="translate(${n.x},${n.y})">
1805
+ <rect width="${ROADMAP_NODE_W}" height="${ROADMAP_NODE_H}" rx="8" ry="8" fill="var(--lr-card-bg, #ffffff)" stroke="${c}" stroke-width="2"/>
1806
+ <rect width="5" height="${ROADMAP_NODE_H}" fill="${c}"/>
1807
+ <text x="14" y="22" font-size="12" fill="${c}" font-weight="600">${_esc(n.title)} · ${_esc(lbl)}</text>
1808
+ <text x="14" y="42" font-size="11" fill="var(--lr-text, #1f2937)" font-weight="500">${_esc(_truncate(n.subtitle, 30))}</text>
1809
+ <text x="14" y="60" font-size="10" fill="var(--lr-muted, #64748b)">${_esc(_truncate(n.meta, 36))}</text>
1810
+ <title>${_esc(n.id)} — ${_esc(n.subtitle)}${n.meta ? '\n' + _esc(n.meta) : ''}</title>
1811
+ </g>`;
1812
+ }).join('\n');
1813
+ const counts = {};
1814
+ for (const t of data.tasks) counts[t.status] = (counts[t.status] || 0) + 1;
1815
+ const legend = ['done', 'in-progress', 'on-hold', 'waiting', 'incomplete', 'planned', 'blocked', 'skill', 'rule']
1816
+ .map(s => `<span class="badge" style="border-color:${ROADMAP_STATUS_COLOR[s]};color:${ROADMAP_STATUS_COLOR[s]}">${ROADMAP_STATUS_LABEL[s]}</span>`).join(' ');
1817
+ const chips = ['done', 'in-progress', 'on-hold', 'waiting', 'incomplete', 'planned', 'blocked']
1818
+ .map(s => `<span class="chip" style="border-color:${ROADMAP_STATUS_COLOR[s]};color:${ROADMAP_STATUS_COLOR[s]}">${ROADMAP_STATUS_LABEL[s]} ${counts[s] || 0}</span>`).join(' ');
1819
+ const upcoming = data.tasks.filter(t => ['planned', 'requested', 'in-progress'].includes(t.status)).slice(0, 10);
1820
+ const upcomingBlock = upcoming.length ? upcoming.map(t => `<div class="row"><span class="dot" style="background:${ROADMAP_STATUS_COLOR[t.status] || '#000'}"></span><strong>${_esc(t.id)}</strong> <span class="meta">[${_esc(ROADMAP_STATUS_LABEL[t.status] || t.status)}]</span> ${_esc(t.request)} <span class="meta">→ ${_esc(t.nextAction)}</span></div>`).join('') : '<div class="empty">예정 작업 없음</div>';
1821
+ const milestoneBlock = data.milestones.length ? data.milestones.map(m => `<div class="row"><span class="dot" style="background:${ROADMAP_STATUS_COLOR[_roadmapMapStatus(m.status)] || ROADMAP_STATUS_COLOR.planned}"></span><strong>${_esc(m.id)}</strong> <span class="meta">[${_esc(m.status)} · ${m.progress}%]</span> ${_esc(m.title)}</div>`).join('') : '<div class="empty">마일스톤 없음</div>';
1822
+ const skillsBlock = data.skills.length ? data.skills.map(s => `<div class="row"><span class="dot" style="background:${ROADMAP_STATUS_COLOR.skill}"></span><strong>${_esc(s.name)}</strong> · ${_esc(s.displayNameKo || s.name)} <span class="meta">사용 ${s.usage?.count || 0}회 · cap ${(s.capabilities || []).length}</span></div>`).join('') : '<div class="empty">스킬 없음</div>';
1823
+ const activeRules = data.rules.filter(r => r.status === 'active');
1824
+ const rulesBlock = activeRules.length ? activeRules.map(r => `<div class="row"><span class="dot" style="background:${ROADMAP_STATUS_COLOR.rule}"></span><strong>${_esc(r.id)}</strong> <span class="meta">[${_esc(r.trigger)}]</span> ${_esc(r.rule)}</div>`).join('') : '<div class="empty">활성 룰 없음</div>';
1825
+ const decisionsBlock = data.decisions.length ? data.decisions.slice(0, 6).map(d => `<div class="row"><span class="dot" style="background:var(--lr-text, #0f172a)"></span>${_esc(d.title)}</div>`).join('') : '<div class="empty">결정 없음</div>';
1826
+ const tokensSection = (Object.keys(data.designTokens).length || Object.keys(data.cssVariables).length)
1827
+ ? [...Object.entries(data.designTokens).slice(0, 8), ...Object.entries(data.cssVariables).slice(0, 8)]
1828
+ .map(([k, v]) => `<div class="row"><span class="dot" style="background:${/#[0-9a-f]{3,8}/i.test(v) ? v : 'var(--lr-muted, #94a3b8)'}"></span><strong>${_esc(k)}</strong> <span class="meta">${_esc(v)}</span></div>`).join('')
1829
+ : '<div class="empty">디자인 토큰 없음</div>';
1830
+
1831
+ return `<!doctype html>
1832
+ <html lang="ko"><head><meta charset="utf-8"><title>${_esc(data.project)} — leerness 로드맵</title>
1833
+ <style>
1834
+ ${_roadmapTokenStyles(data.designTokens, data.cssVariables)}
1835
+ body { font-family: var(--lr-font-body, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans KR', sans-serif); margin: 0; padding: 20px; background: var(--lr-page-bg); color: var(--lr-text); }
1836
+ h1 { margin: 0 0 4px; font-size: 22px; color: var(--lr-primary, var(--lr-text, #0f172a)); }
1837
+ h2 { margin: 24px 0 8px; font-size: 16px; color: var(--lr-muted, #334155); }
1838
+ .meta { font-size: 11px; color: var(--lr-muted, #64748b); margin-left: 4px; }
1839
+ .summary { display: flex; gap: 16px; flex-wrap: wrap; background: var(--lr-card-bg); padding: 12px 16px; border-radius: var(--lr-radius, 8px); border: 1px solid var(--lr-muted, #e2e8f0); font-size: 13px; }
1840
+ .legend { display: flex; gap: 6px; flex-wrap: wrap; margin: 12px 0; }
1841
+ .badge, .chip { display: inline-block; padding: 2px 10px; border: 1.5px solid var(--lr-muted, #94a3b8); border-radius: 999px; font-size: 11px; font-weight: 500; background: var(--lr-card-bg); }
1842
+ .chip { padding: 3px 10px; }
1843
+ .block { background: var(--lr-card-bg); padding: 12px 16px; border-radius: var(--lr-radius, 8px); border: 1px solid var(--lr-muted, #e2e8f0); margin: 8px 0; }
1844
+ .row { font-size: 13px; padding: 4px 0; border-bottom: 1px dashed var(--lr-muted, #f1f5f9); display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
1845
+ .row:last-child { border-bottom: none; }
1846
+ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
1847
+ .empty { font-size: 12px; color: var(--lr-muted, #94a3b8); font-style: italic; padding: 4px 0; }
1848
+ .roadmap-wrap { position: relative; background: var(--lr-card-bg); border-radius: var(--lr-radius, 8px); border: 1px solid var(--lr-muted, #e2e8f0); height: 640px; overflow: hidden; cursor: grab; }
1849
+ .roadmap-wrap.grabbing { cursor: grabbing; }
1850
+ .roadmap-wrap svg { display: block; width: 100%; height: 100%; }
1851
+ .node:hover rect:first-of-type { fill: var(--lr-page-bg, #f1f5f9); cursor: pointer; }
1852
+ .node text { user-select: none; pointer-events: none; }
1853
+ .controls { position: absolute; top: 12px; right: 12px; display: flex; gap: 6px; background: var(--lr-card-bg); padding: 6px; border-radius: 8px; border: 1px solid var(--lr-muted, #e2e8f0); box-shadow: 0 1px 3px rgba(0,0,0,0.06); }
1854
+ .controls button { width: 32px; height: 32px; border: 1px solid var(--lr-muted, #cbd5e1); background: var(--lr-card-bg); color: var(--lr-text); border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 14px; }
1855
+ .controls button:hover { background: var(--lr-page-bg); }
1856
+ .footer { color: var(--lr-muted, #94a3b8); font-size: 11px; text-align: right; margin-top: 16px; }
1857
+ .columns { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
1858
+ @media (max-width: 900px) { .columns { grid-template-columns: 1fr; } }
1859
+ </style></head>
1860
+ <body>
1861
+ <h1>${_esc(data.project)} — leerness 로드맵</h1>
1862
+ <div class="meta">자동 생성 · ${new Date().toISOString().slice(0, 16).replace('T', ' ')} · leerness v${_esc(data.version)}</div>
1863
+ <div class="summary">
1864
+ <div><strong>milestones:</strong> ${data.milestones.length}</div>
1865
+ <div><strong>tasks:</strong> ${data.tasks.length}</div>
1866
+ <div><strong>skills:</strong> ${data.skills.length}</div>
1867
+ <div><strong>active rules:</strong> ${activeRules.length}</div>
1868
+ <div><strong>decisions:</strong> ${data.decisions.length}</div>
1869
+ <div><strong>design tokens:</strong> ${Object.keys(data.designTokens).length + Object.keys(data.cssVariables).length}</div>
1870
+ </div>
1871
+ <div class="legend">${legend}</div>
1872
+ <div class="legend">${chips}</div>
1873
+ <h2>📍 Current State</h2>
1874
+ <div class="block">
1875
+ <div class="row"><strong>Now:</strong> ${_esc(data.currentState.now || '-')}</div>
1876
+ <div class="row"><strong>Next:</strong> ${_esc(data.currentState.next || '-')}</div>
1877
+ <div class="row"><strong>Blockers:</strong> ${_esc(data.currentState.blockers || '-')}</div>
1878
+ </div>
1879
+ <h2>🗺️ Roadmap — 화이트보드 (드래그 panning · 휠 zoom · 더블클릭 reset)</h2>
1880
+ <div class="roadmap-wrap" id="roadmap-board">
1881
+ <svg id="roadmap-svg" viewBox="0 0 ${g.width} ${g.height}" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">
1882
+ <g class="viewport">
1883
+ <g class="edges">${edges}</g>
1884
+ <g class="nodes">${nodes}</g>
1885
+ </g>
1886
+ </svg>
1887
+ <div class="controls"><button onclick="lrZoom(0.9)">−</button><button onclick="lrZoom(1.1)">+</button><button onclick="lrReset()">⟳</button></div>
1888
+ </div>
1889
+ <div class="columns">
1890
+ <div>
1891
+ <h2>🎯 Milestones (${data.milestones.length})</h2><div class="block">${milestoneBlock}</div>
1892
+ <h2>📌 다음 예정 작업</h2><div class="block">${upcomingBlock}</div>
1893
+ <h2>📚 보유 스킬 (${data.skills.length})</h2><div class="block">${skillsBlock}</div>
1894
+ </div>
1895
+ <div>
1896
+ <h2>⚡ Active Rules (${activeRules.length})</h2><div class="block">${rulesBlock}</div>
1897
+ <h2>🧠 최근 결정</h2><div class="block">${decisionsBlock}</div>
1898
+ <h2>🎨 디자인 토큰</h2><div class="block">${tokensSection}</div>
1899
+ </div>
1900
+ </div>
1901
+ <div class="footer">leerness roadmap · v${_esc(data.version)} · 화이트보드 + 토큰 주입 + 상하 중앙정렬</div>
1902
+ <script>
1903
+ (function(){var svg=document.getElementById('roadmap-svg');var board=document.getElementById('roadmap-board');var vp=svg.querySelector('.viewport');var tx=0,ty=0,scale=1;var dragging=false,sx=0,sy=0;function apply(){vp.setAttribute('transform','translate('+tx+','+ty+') scale('+scale+')');}board.addEventListener('mousedown',function(e){if(e.target.closest&&(e.target.closest('.node')||e.target.closest('.controls')))return;dragging=true;sx=e.clientX-tx;sy=e.clientY-ty;board.classList.add('grabbing');e.preventDefault();});window.addEventListener('mousemove',function(e){if(!dragging)return;tx=e.clientX-sx;ty=e.clientY-sy;apply();});window.addEventListener('mouseup',function(){dragging=false;board.classList.remove('grabbing');});board.addEventListener('wheel',function(e){e.preventDefault();var d=e.deltaY>0?0.9:1.1;var rect=board.getBoundingClientRect();var cx=e.clientX-rect.left;var cy=e.clientY-rect.top;var ns=Math.max(0.3,Math.min(3.0,scale*d));var r=ns/scale;tx=cx-(cx-tx)*r;ty=cy-(cy-ty)*r;scale=ns;apply();},{passive:false});board.addEventListener('dblclick',function(){tx=0;ty=0;scale=1;apply();});window.lrZoom=function(d){scale=Math.max(0.3,Math.min(3.0,scale*d));apply();};window.lrReset=function(){tx=0;ty=0;scale=1;apply();};})();
1904
+ </script>
1905
+ </body></html>`;
1906
+ }
1907
+
1908
+ function roadmapCmd(root) {
1909
+ root = absRoot(root);
1910
+ if (!exists(path.join(root, '.harness'))) return fail(`leerness 미설치: ${root}/.harness 없음 — 먼저 \`leerness init .\``);
1911
+ const outFile = path.resolve(arg('--out', null) || path.join(root, 'roadmap.html'));
1912
+ const data = _roadmapData(root);
1913
+ writeUtf8(outFile, _roadmapHTML(data));
1914
+ ok(`로드맵 생성: ${rel(root, outFile)}`);
1915
+ log(` milestones: ${data.milestones.length} · tasks: ${data.tasks.length} (done ${data.tasks.filter(t => t.status === 'done').length}) · skills: ${data.skills.length} · active rules: ${data.rules.filter(r => r.status === 'active').length} · tokens: ${Object.keys(data.designTokens).length + Object.keys(data.cssVariables).length}`);
1916
+ }
1917
+
1918
+ // 1.9.12: auto roadmap (install / session-close / 옵트인 data-change 트리거)
1919
+ function _autoRoadmapConfigPath(root) { return path.join(root, '.harness/cache/auto-roadmap.json'); }
1920
+ function _autoRoadmapConfig(root) {
1921
+ const f = _autoRoadmapConfigPath(root);
1922
+ const def = { enabled: true, onEveryChange: false, outFile: null };
1923
+ if (!exists(f)) return def;
1924
+ try { return Object.assign(def, JSON.parse(read(f))); } catch { return def; }
1925
+ }
1926
+ function _saveAutoRoadmapConfig(root, cfg) {
1927
+ writeUtf8(_autoRoadmapConfigPath(root), JSON.stringify(cfg, null, 2) + '\n');
1928
+ }
1929
+ function _autoRoadmap(root, trigger) {
1930
+ try {
1931
+ if (process.env.LEERNESS_NO_AUTO_ROADMAP === '1') return false;
1932
+ if (!exists(path.join(root, '.harness'))) return false;
1933
+ const cfg = _autoRoadmapConfig(root);
1934
+ if (!cfg.enabled) return false;
1935
+ if (trigger === 'data-change' && !cfg.onEveryChange) return false;
1936
+ const outFile = path.resolve(cfg.outFile || path.join(root, 'roadmap.html'));
1937
+ const data = _roadmapData(root);
1938
+ writeUtf8(outFile, _roadmapHTML(data));
1939
+ log(`✓ roadmap.html 자동 갱신 (${trigger}) — ${rel(root, outFile)}`);
1940
+ return true;
1941
+ } catch (e) {
1942
+ warn('roadmap 자동 갱신 실패: ' + (e && e.message ? e.message : e));
1943
+ return false;
1944
+ }
1945
+ }
1946
+
1947
+ function roadmapAutoCmd(root, sub) {
1948
+ root = absRoot(root);
1949
+ if (!exists(path.join(root, '.harness'))) return fail(`leerness 미설치: ${root}/.harness 없음`);
1950
+ const cfg = _autoRoadmapConfig(root);
1951
+ if (sub === 'on') {
1952
+ cfg.enabled = true;
1953
+ if (has('--on-every-change')) cfg.onEveryChange = true;
1954
+ if (has('--no-on-every-change')) cfg.onEveryChange = false;
1955
+ if (arg('--out', null)) cfg.outFile = arg('--out', null);
1956
+ _saveAutoRoadmapConfig(root, cfg);
1957
+ ok(`auto-roadmap 활성화 (onEveryChange: ${cfg.onEveryChange}, outFile: ${cfg.outFile || './roadmap.html'})`);
1958
+ } else if (sub === 'off') {
1959
+ cfg.enabled = false;
1960
+ _saveAutoRoadmapConfig(root, cfg);
1961
+ ok('auto-roadmap 비활성화 — session close 시 갱신 안 됨');
1962
+ } else {
1963
+ log(`# auto-roadmap status`);
1964
+ log(`enabled: ${cfg.enabled}`);
1965
+ log(`onEveryChange: ${cfg.onEveryChange}`);
1966
+ log(`outFile: ${cfg.outFile || './roadmap.html'}`);
1967
+ log(`\n트리거:`);
1968
+ log(` install : ${cfg.enabled ? '✓ 자동 생성' : '✗ 비활성'}`);
1969
+ log(` session-close: ${cfg.enabled ? '✓ 자동 갱신' : '✗ 비활성'}`);
1970
+ log(` data-change : ${cfg.enabled && cfg.onEveryChange ? '✓ 즉시 갱신 (모든 task/plan/rule/skill 변경)' : '✗ 옵트인 필요 (--on-every-change)'}`);
1971
+ }
1972
+ }
1973
+
1317
1974
  // ===== 1.9.8: User Rules (자연어 등록 + 매 세션 자동 노출/검증) =====
1318
1975
  function rulesPath(root) { return path.join(root, '.harness/rules.md'); }
1319
1976
  function rulesArchivePath(root) { return path.join(root, '.harness/rules.archive.md'); }
@@ -1392,6 +2049,7 @@ function ruleAdd(root, description) {
1392
2049
  rules.push({ id, trigger, rule: description, added: today(), status: 'active', lastVerified: '-' });
1393
2050
  writeRules(root, rules);
1394
2051
  ok(`rule added: ${id} [${trigger}] ${description}`);
2052
+ _autoRoadmap(root, 'data-change');
1395
2053
  }
1396
2054
 
1397
2055
  function ruleList(root) {
@@ -1425,6 +2083,7 @@ function rulePause(root, id) {
1425
2083
  r.status = 'paused';
1426
2084
  writeRules(root, rules);
1427
2085
  ok(`rule paused: ${id}`);
2086
+ _autoRoadmap(root, 'data-change');
1428
2087
  }
1429
2088
 
1430
2089
  function ruleResume(root, id) {
@@ -1436,6 +2095,7 @@ function ruleResume(root, id) {
1436
2095
  r.status = 'active';
1437
2096
  writeRules(root, rules);
1438
2097
  ok(`rule resumed: ${id}`);
2098
+ _autoRoadmap(root, 'data-change');
1439
2099
  }
1440
2100
 
1441
2101
  function ruleStop(root) {
@@ -1974,7 +2634,10 @@ function uiConsistency(root) {
1974
2634
  ok(`등록된 디자인 토큰: ${Object.keys(tokens).length}개`);
1975
2635
  const findings = [];
1976
2636
  for (const f of walkCode(root)) {
1977
- if (rel(root, f).startsWith('.harness/')) continue;
2637
+ const r = rel(root, f);
2638
+ if (r.startsWith('.harness/')) continue;
2639
+ // 1.9.12: leerness가 자동 생성하는 roadmap.html은 ui consistency 검사 대상 아님
2640
+ if (r === 'roadmap.html' || /\/roadmap\.html$/.test(r)) continue;
1978
2641
  if (!/\.(css|scss|sass|less|html|jsx|tsx|vue|svelte|js|ts)$/i.test(f)) continue;
1979
2642
  let text; try { text = read(f); } catch { continue; }
1980
2643
  const hexes = [...text.matchAll(/#[0-9a-fA-F]{3,8}\b/g)];
@@ -2232,6 +2895,11 @@ function viewworkInstall(root) {
2232
2895
 
2233
2896
  function help() {
2234
2897
  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|fix-evidence|relink [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
2898
+ leerness retro [path] [--days 7] # 회고 (1.9.13) — 작업/스킬/결정/검증 시간 추세 + 권장
2899
+ leerness insights [path] # 누적 통계 (1.9.13) — 핵심 지표 + 안정성
2900
+ leerness brainstorm "<주제>" # 브레인스토밍 (1.9.13) — 누적 자원 회수 + 시작 컨텍스트
2901
+ leerness roadmap [path] [--out file.html] # 좌→우 수평 트리 + 상하 중앙정렬 + 화이트보드 (1.9.11)
2902
+ leerness roadmap auto on|off|status [--on-every-change] [--out file.html] # 자동 갱신 (1.9.12, install/session-close 기본 ON)
2235
2903
  leerness verify-code [path] [--build] # npm test/lint/typecheck 자동 실행 + evidence 자동 기록 (1.9.7)
2236
2904
  leerness lessons [--query <키>] [--limit N] # 과거 결정/실수 자동 회수 (1.9.7)
2237
2905
  leerness lazy detect [path] [--auto-track] # --auto-track으로 새 TODO를 progress에 자동 등록 (1.9.7)
@@ -2280,6 +2948,11 @@ async function main() {
2280
2948
  if (cmd === 'gate') return gate(args[1] || process.cwd());
2281
2949
  if (cmd === 'verify-code') return verifyCodeCmd(args[1] || process.cwd());
2282
2950
  if (cmd === 'lessons') return lessonsCmd(arg('--path', process.cwd()));
2951
+ if (cmd === 'retro') return retroCmd(args[1] || process.cwd());
2952
+ if (cmd === 'insights') return insightsCmd(args[1] || process.cwd());
2953
+ if (cmd === 'brainstorm') return brainstormCmd(arg('--path', process.cwd()), args.slice(1).filter(x => !x.startsWith('-')).join(' '));
2954
+ if (cmd === 'roadmap' && args[1] === 'auto') return roadmapAutoCmd(arg('--path', process.cwd()), args[2]);
2955
+ if (cmd === 'roadmap') return roadmapCmd(args[1] || process.cwd());
2283
2956
  if (cmd === 'rule' && args[1] === 'add') return ruleAdd(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
2284
2957
  if (cmd === 'rule' && args[1] === 'list') return ruleList(arg('--path', process.cwd()));
2285
2958
  if (cmd === 'rule' && args[1] === 'remove') return ruleRemove(arg('--path', process.cwd()), args[2]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.10",
3
+ "version": "1.9.13",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -6,13 +6,15 @@ const os = require('os');
6
6
  const path = require('path');
7
7
  const cp = require('child_process');
8
8
 
9
+ // 1.9.12: e2e 안정성을 위해 자식 프로세스의 npm 호출 차단 (hang 방지)
10
+ process.env.LEERNESS_OFFLINE = process.env.LEERNESS_OFFLINE || '1';
9
11
  const CLI = path.resolve(__dirname, '..', 'bin', 'harness.js');
10
12
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-e2e-'));
11
13
  let failed = 0; let total = 0;
12
14
 
13
15
  function run(label, args, opts = {}) {
14
16
  total++;
15
- const r = cp.spawnSync(process.execPath, [CLI, ...args], { cwd: opts.cwd || tmp, encoding: 'utf8' });
17
+ const r = cp.spawnSync(process.execPath, [CLI, ...args], { cwd: opts.cwd || tmp, encoding: 'utf8', timeout: 30000 });
16
18
  const ok = (r.status === 0) === !opts.expectFail;
17
19
  process.stdout.write(`${ok ? '✓' : '✗'} ${label} (exit=${r.status})\n`);
18
20
  if (!ok) { failed++; process.stdout.write(r.stdout || ''); process.stderr.write(r.stderr || ''); }
@@ -235,6 +237,187 @@ total++;
235
237
  if (!(strongOK && weakHint)) failed++;
236
238
  }
237
239
 
240
+ // 1.9.13: retro / insights / brainstorm
241
+ total++;
242
+ {
243
+ const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-retro-'));
244
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
245
+ cp.spawnSync(process.execPath, [CLI, 'plan', 'add', '캐시 helper', '--status', 'done', '--path', tmpR], { stdio: 'ignore', timeout: 10000 });
246
+ cp.spawnSync(process.execPath, [CLI, 'plan', 'add', '인증 helper', '--status', 'in-progress', '--path', tmpR], { stdio: 'ignore', timeout: 10000 });
247
+ fs.appendFileSync(path.join(tmpR, '.harness/decisions.md'), `\n### 2026-05-13 — 캐시 차등 TTL 결정\n- Reason: ...\n`);
248
+ fs.appendFileSync(path.join(tmpR, '.harness/review-evidence.md'), `\n## 2026-05-13 verify-code\nexit=0 (250ms)\nexit=0 (180ms)\nexit=0 (120ms)\nexit=0 (90ms)\n`);
249
+ const r = cp.spawnSync(process.execPath, [CLI, 'retro', tmpR], { encoding: 'utf8', timeout: 15000 });
250
+ const ok = r.status === 0 && /한 줄 요약/.test(r.stdout) && /작업 상태 분포/.test(r.stdout) && /다음 우선 작업/.test(r.stdout) && /검증 시간 추세/.test(r.stdout);
251
+ console.log(ok ? '✓ B(1.9.13) retro: 한 줄 요약 + 다음 우선 작업 + 검증 시간 추세' : '✗ retro 실패');
252
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 800)); }
253
+ }
254
+
255
+ total++;
256
+ {
257
+ const tmpI = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ins-'));
258
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpI, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
259
+ const r = cp.spawnSync(process.execPath, [CLI, 'insights', tmpI], { encoding: 'utf8', timeout: 15000 });
260
+ const ok = r.status === 0 && /핵심 지표/.test(r.stdout) && /누적 task/.test(r.stdout);
261
+ console.log(ok ? '✓ B(1.9.13) insights: 핵심 지표 출력' : '✗ insights 실패');
262
+ if (!ok) failed++;
263
+ }
264
+
265
+ total++;
266
+ {
267
+ const tmpB = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-brain-'));
268
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpB, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
269
+ fs.appendFileSync(path.join(tmpB, '.harness/decisions.md'), `\n### 2026-05-13 — 캐시 차등 TTL 결정\n- Reason: open-meteo 응답 최적화\n`);
270
+ cp.spawnSync(process.execPath, [CLI, 'plan', 'add', '캐시 helper 구현', '--path', tmpB], { stdio: 'ignore', timeout: 10000 });
271
+ const r = cp.spawnSync(process.execPath, [CLI, 'brainstorm', '캐시', '--path', tmpB], { encoding: 'utf8', timeout: 15000 });
272
+ const ok = r.status === 0 && /Brainstorm — "캐시"/.test(r.stdout) && /관련 결정/.test(r.stdout) && /시작 전 권장 액션/.test(r.stdout);
273
+ console.log(ok ? '✓ B(1.9.13) brainstorm: 주제 매칭 + 시작 컨텍스트' : `✗ brainstorm 실패`);
274
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 600)); }
275
+ }
276
+
277
+ total++;
278
+ {
279
+ // session close 한 줄 요약 자동 출력
280
+ const tmpS = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-summary-'));
281
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpS, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
282
+ const r = cp.spawnSync(process.execPath, [CLI, 'session', 'close', tmpS], { encoding: 'utf8', timeout: 15000 });
283
+ const ok = r.status === 0 && /진행 요약/.test(r.stdout) && /자동 깊은 회고/.test(r.stdout);
284
+ console.log(ok ? '✓ B(1.9.13) session close: 한 줄 요약 자동 출력' : `✗ session close 요약 실패`);
285
+ if (!ok) { failed++; console.log(r.stdout.slice(-600)); }
286
+ }
287
+
288
+ total++;
289
+ {
290
+ // 5세션 마일스톤 — 자동 깊은 회고
291
+ const tmpD = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-deep-'));
292
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpD, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
293
+ // 카운터를 4로 설정 → 다음 close가 5번째
294
+ fs.mkdirSync(path.join(tmpD, '.harness/cache'), { recursive: true });
295
+ fs.writeFileSync(path.join(tmpD, '.harness/cache/session-counter.json'), JSON.stringify({ count: 4 }));
296
+ const r = cp.spawnSync(process.execPath, [CLI, 'session', 'close', tmpD], { encoding: 'utf8', timeout: 15000 });
297
+ const ok = r.status === 0 && /5세션 마일스톤/.test(r.stdout) && /회고 \(retro\)/.test(r.stdout);
298
+ console.log(ok ? '✓ B(1.9.13) 5세션 마일스톤: 자동 깊은 회고' : '✗ 5세션 자동 회고 실패');
299
+ if (!ok) failed++;
300
+ }
301
+
302
+ // 1.9.12: auto roadmap — install 직후 자동 생성
303
+ total++;
304
+ {
305
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-auto1-'));
306
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
307
+ const ok = fs.existsSync(path.join(tmpA, 'roadmap.html'));
308
+ console.log(ok ? '✓ B(1.9.12) install 직후 roadmap.html 자동 생성' : '✗ install 후 roadmap 없음');
309
+ if (!ok) failed++;
310
+ }
311
+
312
+ // 1.9.12: session close 후 자동 갱신
313
+ total++;
314
+ {
315
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-auto2-'));
316
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
317
+ // 첫 mtime 캡처
318
+ const f = path.join(tmpA, 'roadmap.html');
319
+ const mt1 = fs.statSync(f).mtimeMs;
320
+ // 시간 차이 보장
321
+ const wait = Date.now() + 50; while (Date.now() < wait) {}
322
+ // 데이터 변경 + session close
323
+ cp.spawnSync(process.execPath, [CLI, 'task', 'add', '신규 작업', '--path', tmpA], { stdio: 'ignore' });
324
+ cp.spawnSync(process.execPath, [CLI, 'session', 'close', tmpA], { stdio: 'ignore' });
325
+ const mt2 = fs.statSync(f).mtimeMs;
326
+ const ok = mt2 > mt1;
327
+ console.log(ok ? '✓ B(1.9.12) session close 후 roadmap.html 자동 갱신 (mtime 증가)' : `✗ session close 후 갱신 안 됨 mt1=${mt1} mt2=${mt2}`);
328
+ if (!ok) failed++;
329
+ }
330
+
331
+ // 1.9.12: roadmap auto off → 더 이상 자동 갱신 안 함
332
+ total++;
333
+ {
334
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-auto3-'));
335
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
336
+ cp.spawnSync(process.execPath, [CLI, 'roadmap', 'auto', 'off', '--path', tmpA], { stdio: 'ignore' });
337
+ const f = path.join(tmpA, 'roadmap.html');
338
+ const mt1 = fs.statSync(f).mtimeMs;
339
+ const wait = Date.now() + 80; while (Date.now() < wait) {}
340
+ cp.spawnSync(process.execPath, [CLI, 'task', 'add', '비활성 후 추가', '--path', tmpA], { stdio: 'ignore' });
341
+ cp.spawnSync(process.execPath, [CLI, 'session', 'close', tmpA], { stdio: 'ignore' });
342
+ const mt2 = fs.statSync(f).mtimeMs;
343
+ const ok = mt2 === mt1;
344
+ console.log(ok ? '✓ B(1.9.12) auto off: roadmap.html 갱신 안 됨' : `✗ auto off 후에도 갱신됨`);
345
+ if (!ok) failed++;
346
+ }
347
+
348
+ // 1.9.12: --on-every-change 옵트인 시 task add만으로 갱신
349
+ total++;
350
+ {
351
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-auto4-'));
352
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
353
+ cp.spawnSync(process.execPath, [CLI, 'roadmap', 'auto', 'on', '--on-every-change', '--path', tmpA], { stdio: 'ignore' });
354
+ const f = path.join(tmpA, 'roadmap.html');
355
+ const mt1 = fs.statSync(f).mtimeMs;
356
+ const wait = Date.now() + 80; while (Date.now() < wait) {}
357
+ cp.spawnSync(process.execPath, [CLI, 'task', 'add', 'on-every-change 테스트', '--path', tmpA], { stdio: 'ignore' });
358
+ const mt2 = fs.statSync(f).mtimeMs;
359
+ const ok = mt2 > mt1;
360
+ console.log(ok ? '✓ B(1.9.12) --on-every-change: task add만으로 즉시 갱신' : `✗ on-every-change 미작동`);
361
+ if (!ok) failed++;
362
+ }
363
+
364
+ // 1.9.12: status 출력
365
+ total++;
366
+ {
367
+ const tmpA = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-auto5-'));
368
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpA, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
369
+ const r = cp.spawnSync(process.execPath, [CLI, 'roadmap', 'auto', 'status', '--path', tmpA], { encoding: 'utf8' });
370
+ const ok = /enabled: true/.test(r.stdout) && /session-close.*✓/.test(r.stdout);
371
+ console.log(ok ? '✓ B(1.9.12) roadmap auto status: 상태 출력' : '✗ status 출력 실패');
372
+ if (!ok) failed++;
373
+ }
374
+
375
+ // 1.9.11: roadmap 명령 통합 + 화이트보드/토큰/중앙정렬 회귀
376
+ total++;
377
+ {
378
+ const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rm-'));
379
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
380
+ const r = cp.spawnSync(process.execPath, [CLI, 'roadmap', tmpR], { encoding: 'utf8' });
381
+ const outFile = path.join(tmpR, 'roadmap.html');
382
+ const ok = r.status === 0 && fs.existsSync(outFile);
383
+ console.log(ok ? '✓ B(1.9.11) roadmap: 명령 + 파일 생성' : `✗ roadmap 실패\n${r.stdout}\n${r.stderr}`);
384
+ if (!ok) failed++;
385
+ }
386
+ total++;
387
+ {
388
+ const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rm2-'));
389
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
390
+ cp.spawnSync(process.execPath, [CLI, 'roadmap', tmpR], { stdio: 'ignore' });
391
+ const html = fs.readFileSync(path.join(tmpR, 'roadmap.html'), 'utf8');
392
+ const ok = /화이트보드/.test(html) && /id="roadmap-svg"/.test(html) && /viewBox="0 0/.test(html) && /window\.lrZoom/.test(html) && /window\.lrReset/.test(html);
393
+ console.log(ok ? '✓ B(1.9.11) roadmap: 화이트보드 (panning/zoom JS)' : '✗ 화이트보드 부재');
394
+ if (!ok) failed++;
395
+ }
396
+ total++;
397
+ {
398
+ // 사용자 design-system 토큰 주입
399
+ const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rm3-'));
400
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
401
+ let ds = fs.readFileSync(path.join(tmpR, '.harness/design-system.md'), 'utf8');
402
+ ds = ds.replace('| color.primary | (실제 값으로 업데이트) | |', '| color.primary | #ff5722 | |');
403
+ fs.writeFileSync(path.join(tmpR, '.harness/design-system.md'), ds);
404
+ cp.spawnSync(process.execPath, [CLI, 'roadmap', tmpR], { stdio: 'ignore' });
405
+ const html = fs.readFileSync(path.join(tmpR, 'roadmap.html'), 'utf8');
406
+ const ok = /--lr-primary: #ff5722/.test(html);
407
+ console.log(ok ? '✓ B(1.9.11) roadmap: design-system 토큰 자동 주입' : '✗ 토큰 주입 실패');
408
+ if (!ok) failed++;
409
+ }
410
+ total++;
411
+ {
412
+ // recommended에 project-roadmap-generator 자동 포함
413
+ const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rm4-'));
414
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
415
+ const skillsDir = path.join(tmpR, '.harness/skills/project-roadmap-generator');
416
+ const ok = fs.existsSync(skillsDir) && fs.existsSync(path.join(skillsDir, 'skill.json'));
417
+ console.log(ok ? '✓ B(1.9.11) recommended에 project-roadmap-generator 자동 설치' : '✗ 자동 설치 실패');
418
+ if (!ok) failed++;
419
+ }
420
+
238
421
  // 1.9.10 A: skillpack 동적 로드 (LEERNESS_SKILLPACK_PATH로 시뮬)
239
422
  total++;
240
423
  {