leerness 1.9.10 → 1.9.11

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,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.11 — 2026-05-12
4
+
5
+ **`leerness roadmap` 명령 통합 + `project-roadmap-generator` 스킬 기본 추천 + 화이트보드/토큰/상하 중앙정렬**.
6
+
7
+ ### Added — `leerness roadmap [path] [--out file.html]`
8
+
9
+ `project-roadmap-generator` 로직을 leerness 본 패키지에 통합. 외부 의존성 없이 즉시 사용 가능.
10
+
11
+ - 좌→우 수평 트리 (project → milestones → tasks → skills/rules)
12
+ - **상하 중앙정렬**: 각 column의 노드들이 캔버스 세로 중앙 기준으로 균등 분포
13
+ - **디자인 토큰 자동 주입**: `.harness/design-system.md`의 Tokens 표 + 프로젝트 `styles/tokens.css`의 CSS 변수를 HTML `:root`에 `--lr-*`로 주입 (h1·card·border·dot 색상이 사용자 토큰을 따름)
14
+ - **화이트보드**: 드래그 panning, 휠 zoom (마우스 포인터 중심), 더블클릭 reset, +/-/⟳ 컨트롤 버튼
15
+ - 7개 상태 (완료/진행/보류/검토/예정/미완료/오류) + 스킬/룰 색상
16
+ - Milestones, 예정 작업, 보유 스킬, 활성 룰, 최근 결정, 디자인 토큰 6개 섹션 통합
17
+
18
+ ### Changed — `recommended` 스킬에 자동 포함
19
+
20
+ `leerness init . --skills recommended` 호출 시 `project-roadmap-generator` 스킬이 기본으로 설치됩니다 (기존 4종 + 1). 별도 설치 불필요.
21
+
22
+ ```
23
+ recommended = ['office','commerce-api','ai-verified-skill-publisher','feature-implementation','project-roadmap-generator']
24
+ ```
25
+
26
+ ### Migration
27
+
28
+ 기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다. `leerness roadmap`이 바로 사용 가능합니다.
29
+
3
30
  ## 1.9.10 — 2026-05-12
4
31
 
5
32
  **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.11';
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 정의 후.
@@ -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
 
@@ -1314,6 +1317,294 @@ function gate(root) {
1314
1317
  else ok('all gates passed');
1315
1318
  }
1316
1319
 
1320
+ // ===== 1.9.11: Roadmap (project-roadmap-generator 통합) =====
1321
+ const ROADMAP_STATUS_LABEL = { done: '완료', 'in-progress': '진행', 'on-hold': '보류', waiting: '검토', incomplete: '미완료', planned: '예정', blocked: '오류', dropped: '취소', skill: '스킬', rule: '룰', meta: '프로젝트' };
1322
+ 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' };
1323
+ const ROADMAP_NODE_W = 220, ROADMAP_NODE_H = 72, ROADMAP_COL_GAP = 70, ROADMAP_ROW_GAP = 14;
1324
+
1325
+ function _esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
1326
+ function _truncate(s, n) { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
1327
+
1328
+ function _roadmapMapStatus(s) {
1329
+ s = String(s || '').toLowerCase();
1330
+ if (s === 'done' || s === 'in-progress' || s === 'on-hold' || s === 'waiting' || s === 'incomplete' || s === 'blocked' || s === 'dropped') return s;
1331
+ if (s === 'planned' || s === 'requested') return 'planned';
1332
+ return 'planned';
1333
+ }
1334
+
1335
+ function _roadmapParseMilestones(text) {
1336
+ const out = [];
1337
+ for (const m of String(text || '').matchAll(/^### (M-\d{4})\.\s*(.+?)$/gm)) {
1338
+ const after = text.slice(m.index);
1339
+ const sm = after.match(/^Status:\s*(\S+)/m);
1340
+ const pm = after.match(/^Progress:\s*(\d+)%/m);
1341
+ out.push({ id: m[1], title: m[2].trim(), status: sm ? sm[1] : 'planned', progress: pm ? parseInt(pm[1], 10) : 0 });
1342
+ }
1343
+ return out;
1344
+ }
1345
+
1346
+ function _roadmapParseTokens(text) {
1347
+ const tokens = {};
1348
+ for (const line of String(text || '').split('\n')) {
1349
+ const m = line.match(/^\|\s*([\w.\-]+)\s*\|\s*([^|]+?)\s*\|/);
1350
+ if (!m) continue;
1351
+ const key = m[1].trim(), val = m[2].trim();
1352
+ if (!key || !val || key === 'Token' || /^-+$/.test(key) || val === 'Value' || /\(실제 값으로 업데이트\)/.test(val)) continue;
1353
+ if (val.length > 80) continue;
1354
+ tokens[key] = val;
1355
+ }
1356
+ return tokens;
1357
+ }
1358
+
1359
+ function _roadmapParseCssVars(root) {
1360
+ const out = {};
1361
+ const cands = ['src/styles/tokens.css', 'styles/tokens.css', 'src/styles.css', 'styles.css', 'src/styles/main.css', 'public/styles.css'];
1362
+ for (const c of cands) {
1363
+ const f = path.join(root, c);
1364
+ if (!exists(f)) continue;
1365
+ const text = read(f);
1366
+ const m = text.match(/:root\s*\{([\s\S]*?)\}/);
1367
+ if (!m) continue;
1368
+ for (const line of m[1].split('\n')) {
1369
+ const v = line.match(/--([\w-]+)\s*:\s*([^;]+);/);
1370
+ if (v) out[v[1].trim()] = v[2].trim();
1371
+ }
1372
+ }
1373
+ return out;
1374
+ }
1375
+
1376
+ function _roadmapData(root) {
1377
+ root = absRoot(root);
1378
+ const milestones = _roadmapParseMilestones(exists(planPath(root)) ? read(planPath(root)) : '');
1379
+ const tasks = readProgressRows(root).map(t => ({
1380
+ ...t,
1381
+ milestones: Array.from(String(t.evidence || '').matchAll(/M-\d{4}/g)).map(m => m[0])
1382
+ }));
1383
+ // skills
1384
+ const skills = [];
1385
+ const skillsDir = path.join(root, '.harness/skills');
1386
+ if (exists(skillsDir)) {
1387
+ for (const id of fs.readdirSync(skillsDir)) {
1388
+ const f = path.join(skillsDir, id, 'skill.json');
1389
+ if (!exists(f)) continue;
1390
+ try { skills.push(JSON.parse(read(f))); } catch {}
1391
+ }
1392
+ }
1393
+ // rules
1394
+ const rulesT = exists(rulesPath(root)) ? read(rulesPath(root)) : '';
1395
+ const rules = [];
1396
+ for (const line of rulesT.split('\n')) {
1397
+ if (!/^\| R-\d{4} \|/.test(line)) continue;
1398
+ const cells = line.split('|').slice(1, -1).map(s => s.trim());
1399
+ if (cells.length < 6) continue;
1400
+ rules.push({ id: cells[0], trigger: cells[1], rule: cells[2], status: cells[4], lastVerified: cells[5] });
1401
+ }
1402
+ // currentState
1403
+ const csT = exists(currentStatePath(root)) ? read(currentStatePath(root)) : '';
1404
+ const now = (csT.match(/## Now\n([\s\S]*?)(?=\n## )/) || [, ''])[1].trim();
1405
+ const next = (csT.match(/## Next\n([\s\S]*?)(?=\n## )/) || [, ''])[1].trim();
1406
+ const blockers = (csT.match(/## Blockers\n([\s\S]*?)$/) || [, ''])[1].trim();
1407
+ // decisions (top 6)
1408
+ const decT = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
1409
+ const decisions = [];
1410
+ for (const block of decT.split(/\n(?=### )/)) {
1411
+ if (!block.startsWith('### ')) continue;
1412
+ const tm = block.match(/^### (.+)$/m);
1413
+ if (tm) decisions.push({ title: tm[1].trim() });
1414
+ }
1415
+ return {
1416
+ project: path.basename(root),
1417
+ version: exists(path.join(root, '.harness/HARNESS_VERSION')) ? read(path.join(root, '.harness/HARNESS_VERSION')).trim() : 'unknown',
1418
+ milestones, tasks, skills, rules,
1419
+ currentState: { now, next, blockers },
1420
+ decisions,
1421
+ designTokens: _roadmapParseTokens(exists(path.join(root, '.harness/design-system.md')) ? read(path.join(root, '.harness/design-system.md')) : ''),
1422
+ cssVariables: _roadmapParseCssVars(root)
1423
+ };
1424
+ }
1425
+
1426
+ function _roadmapLayout(data) {
1427
+ const nodes = []; const edges = [];
1428
+ 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 });
1429
+ for (const m of data.milestones) {
1430
+ nodes.push({ id: m.id, kind: 'milestone', title: m.id, subtitle: m.title, meta: `${m.progress}% · ${m.status}`, status: _roadmapMapStatus(m.status), col: 1 });
1431
+ edges.push({ from: 'project', to: m.id });
1432
+ }
1433
+ for (const t of data.tasks) {
1434
+ 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 });
1435
+ if (t.milestones.length) for (const mid of t.milestones) edges.push({ from: mid, to: t.id });
1436
+ else edges.push({ from: 'project', to: t.id });
1437
+ }
1438
+ for (const s of data.skills) {
1439
+ 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 });
1440
+ edges.push({ from: 'project', to: 'skill:' + s.name });
1441
+ }
1442
+ for (const r of data.rules.filter(r => r.status === 'active')) {
1443
+ nodes.push({ id: 'rule:' + r.id, kind: 'rule', title: r.id, subtitle: r.rule, meta: r.trigger, status: 'rule', col: 3 });
1444
+ edges.push({ from: 'project', to: 'rule:' + r.id });
1445
+ }
1446
+ // 상하 중앙정렬 (1.9.11 v0.2)
1447
+ const byCol = {};
1448
+ for (const n of nodes) (byCol[n.col] = byCol[n.col] || []).push(n);
1449
+ const colH = {}; let maxColH = 0; let maxCol = 0;
1450
+ for (const c of Object.keys(byCol)) {
1451
+ const r = byCol[c]; const h = r.length * ROADMAP_NODE_H + Math.max(0, r.length - 1) * ROADMAP_ROW_GAP;
1452
+ colH[c] = h; maxColH = Math.max(maxColH, h); maxCol = Math.max(maxCol, parseInt(c, 10));
1453
+ }
1454
+ const padding = 40; const minHeight = 360;
1455
+ const canvasHeight = Math.max(maxColH, minHeight) + padding * 2;
1456
+ for (const c of Object.keys(byCol)) {
1457
+ const r = byCol[c]; const h = colH[c]; const startY = (canvasHeight - h) / 2;
1458
+ r.forEach((n, i) => {
1459
+ n.x = parseInt(c, 10) * (ROADMAP_NODE_W + ROADMAP_COL_GAP) + padding;
1460
+ n.y = startY + i * (ROADMAP_NODE_H + ROADMAP_ROW_GAP);
1461
+ });
1462
+ }
1463
+ return { nodes, edges, width: (maxCol + 1) * (ROADMAP_NODE_W + ROADMAP_COL_GAP) + padding * 2, height: canvasHeight };
1464
+ }
1465
+
1466
+ function _roadmapTokenStyles(designTokens, cssVariables) {
1467
+ const vars = {};
1468
+ const map = [
1469
+ ['color.primary', 'color-primary', 'lr-primary'], ['color.surface', 'color-surface', 'lr-surface'],
1470
+ ['color.text', 'color-text', 'lr-text'], ['color.muted', 'color-muted', 'lr-muted'],
1471
+ ['space.1', 'space-1', 'lr-space-1'], ['space.2', 'space-2', 'lr-space-2'],
1472
+ ['space.3', 'space-3', 'lr-space-3'], ['space.4', 'space-4', 'lr-space-4'],
1473
+ ['radius', 'radius', 'lr-radius']
1474
+ ];
1475
+ for (const [ds, css, vn] of map) { const v = cssVariables[css] || designTokens[ds]; if (v) vars[vn] = v; }
1476
+ for (const [k, v] of Object.entries(cssVariables)) if (!vars[`lr-${k}`]) vars[`lr-${k}`] = v;
1477
+ if (!vars['lr-card-bg']) vars['lr-card-bg'] = vars['lr-surface'] || '#ffffff';
1478
+ if (!vars['lr-edge']) vars['lr-edge'] = vars['lr-muted'] || '#cbd5e1';
1479
+ if (!vars['lr-page-bg']) vars['lr-page-bg'] = '#f8fafc';
1480
+ return ':root {\n' + Object.entries(vars).map(([k, v]) => ` --${k}: ${v};`).join('\n') + '\n }';
1481
+ }
1482
+
1483
+ function _roadmapHTML(data) {
1484
+ const g = _roadmapLayout(data);
1485
+ const edges = g.edges.map(e => {
1486
+ const f = g.nodes.find(n => n.id === e.from), t = g.nodes.find(n => n.id === e.to);
1487
+ if (!f || !t) return '';
1488
+ 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;
1489
+ return `<path d="M ${x1},${y1} C ${mid},${y1} ${mid},${y2} ${x2},${y2}" stroke="var(--lr-edge, #cbd5e1)" stroke-width="1.5" fill="none"/>`;
1490
+ }).join('\n');
1491
+ const nodes = g.nodes.map(n => {
1492
+ const c = ROADMAP_STATUS_COLOR[n.status] || 'var(--lr-text, #0f172a)';
1493
+ const lbl = ROADMAP_STATUS_LABEL[n.status] || n.status;
1494
+ return `<g class="node node-${n.kind} status-${n.status}" data-id="${_esc(n.id)}" transform="translate(${n.x},${n.y})">
1495
+ <rect width="${ROADMAP_NODE_W}" height="${ROADMAP_NODE_H}" rx="8" ry="8" fill="var(--lr-card-bg, #ffffff)" stroke="${c}" stroke-width="2"/>
1496
+ <rect width="5" height="${ROADMAP_NODE_H}" fill="${c}"/>
1497
+ <text x="14" y="22" font-size="12" fill="${c}" font-weight="600">${_esc(n.title)} · ${_esc(lbl)}</text>
1498
+ <text x="14" y="42" font-size="11" fill="var(--lr-text, #1f2937)" font-weight="500">${_esc(_truncate(n.subtitle, 30))}</text>
1499
+ <text x="14" y="60" font-size="10" fill="var(--lr-muted, #64748b)">${_esc(_truncate(n.meta, 36))}</text>
1500
+ <title>${_esc(n.id)} — ${_esc(n.subtitle)}${n.meta ? '\n' + _esc(n.meta) : ''}</title>
1501
+ </g>`;
1502
+ }).join('\n');
1503
+ const counts = {};
1504
+ for (const t of data.tasks) counts[t.status] = (counts[t.status] || 0) + 1;
1505
+ const legend = ['done', 'in-progress', 'on-hold', 'waiting', 'incomplete', 'planned', 'blocked', 'skill', 'rule']
1506
+ .map(s => `<span class="badge" style="border-color:${ROADMAP_STATUS_COLOR[s]};color:${ROADMAP_STATUS_COLOR[s]}">${ROADMAP_STATUS_LABEL[s]}</span>`).join(' ');
1507
+ const chips = ['done', 'in-progress', 'on-hold', 'waiting', 'incomplete', 'planned', 'blocked']
1508
+ .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(' ');
1509
+ const upcoming = data.tasks.filter(t => ['planned', 'requested', 'in-progress'].includes(t.status)).slice(0, 10);
1510
+ 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>';
1511
+ 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>';
1512
+ 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>';
1513
+ const activeRules = data.rules.filter(r => r.status === 'active');
1514
+ 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>';
1515
+ 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>';
1516
+ const tokensSection = (Object.keys(data.designTokens).length || Object.keys(data.cssVariables).length)
1517
+ ? [...Object.entries(data.designTokens).slice(0, 8), ...Object.entries(data.cssVariables).slice(0, 8)]
1518
+ .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('')
1519
+ : '<div class="empty">디자인 토큰 없음</div>';
1520
+
1521
+ return `<!doctype html>
1522
+ <html lang="ko"><head><meta charset="utf-8"><title>${_esc(data.project)} — leerness 로드맵</title>
1523
+ <style>
1524
+ ${_roadmapTokenStyles(data.designTokens, data.cssVariables)}
1525
+ 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); }
1526
+ h1 { margin: 0 0 4px; font-size: 22px; color: var(--lr-primary, var(--lr-text, #0f172a)); }
1527
+ h2 { margin: 24px 0 8px; font-size: 16px; color: var(--lr-muted, #334155); }
1528
+ .meta { font-size: 11px; color: var(--lr-muted, #64748b); margin-left: 4px; }
1529
+ .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; }
1530
+ .legend { display: flex; gap: 6px; flex-wrap: wrap; margin: 12px 0; }
1531
+ .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); }
1532
+ .chip { padding: 3px 10px; }
1533
+ .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; }
1534
+ .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; }
1535
+ .row:last-child { border-bottom: none; }
1536
+ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
1537
+ .empty { font-size: 12px; color: var(--lr-muted, #94a3b8); font-style: italic; padding: 4px 0; }
1538
+ .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; }
1539
+ .roadmap-wrap.grabbing { cursor: grabbing; }
1540
+ .roadmap-wrap svg { display: block; width: 100%; height: 100%; }
1541
+ .node:hover rect:first-of-type { fill: var(--lr-page-bg, #f1f5f9); cursor: pointer; }
1542
+ .node text { user-select: none; pointer-events: none; }
1543
+ .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); }
1544
+ .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; }
1545
+ .controls button:hover { background: var(--lr-page-bg); }
1546
+ .footer { color: var(--lr-muted, #94a3b8); font-size: 11px; text-align: right; margin-top: 16px; }
1547
+ .columns { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
1548
+ @media (max-width: 900px) { .columns { grid-template-columns: 1fr; } }
1549
+ </style></head>
1550
+ <body>
1551
+ <h1>${_esc(data.project)} — leerness 로드맵</h1>
1552
+ <div class="meta">자동 생성 · ${new Date().toISOString().slice(0, 16).replace('T', ' ')} · leerness v${_esc(data.version)}</div>
1553
+ <div class="summary">
1554
+ <div><strong>milestones:</strong> ${data.milestones.length}</div>
1555
+ <div><strong>tasks:</strong> ${data.tasks.length}</div>
1556
+ <div><strong>skills:</strong> ${data.skills.length}</div>
1557
+ <div><strong>active rules:</strong> ${activeRules.length}</div>
1558
+ <div><strong>decisions:</strong> ${data.decisions.length}</div>
1559
+ <div><strong>design tokens:</strong> ${Object.keys(data.designTokens).length + Object.keys(data.cssVariables).length}</div>
1560
+ </div>
1561
+ <div class="legend">${legend}</div>
1562
+ <div class="legend">${chips}</div>
1563
+ <h2>📍 Current State</h2>
1564
+ <div class="block">
1565
+ <div class="row"><strong>Now:</strong> ${_esc(data.currentState.now || '-')}</div>
1566
+ <div class="row"><strong>Next:</strong> ${_esc(data.currentState.next || '-')}</div>
1567
+ <div class="row"><strong>Blockers:</strong> ${_esc(data.currentState.blockers || '-')}</div>
1568
+ </div>
1569
+ <h2>🗺️ Roadmap — 화이트보드 (드래그 panning · 휠 zoom · 더블클릭 reset)</h2>
1570
+ <div class="roadmap-wrap" id="roadmap-board">
1571
+ <svg id="roadmap-svg" viewBox="0 0 ${g.width} ${g.height}" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">
1572
+ <g class="viewport">
1573
+ <g class="edges">${edges}</g>
1574
+ <g class="nodes">${nodes}</g>
1575
+ </g>
1576
+ </svg>
1577
+ <div class="controls"><button onclick="lrZoom(0.9)">−</button><button onclick="lrZoom(1.1)">+</button><button onclick="lrReset()">⟳</button></div>
1578
+ </div>
1579
+ <div class="columns">
1580
+ <div>
1581
+ <h2>🎯 Milestones (${data.milestones.length})</h2><div class="block">${milestoneBlock}</div>
1582
+ <h2>📌 다음 예정 작업</h2><div class="block">${upcomingBlock}</div>
1583
+ <h2>📚 보유 스킬 (${data.skills.length})</h2><div class="block">${skillsBlock}</div>
1584
+ </div>
1585
+ <div>
1586
+ <h2>⚡ Active Rules (${activeRules.length})</h2><div class="block">${rulesBlock}</div>
1587
+ <h2>🧠 최근 결정</h2><div class="block">${decisionsBlock}</div>
1588
+ <h2>🎨 디자인 토큰</h2><div class="block">${tokensSection}</div>
1589
+ </div>
1590
+ </div>
1591
+ <div class="footer">leerness roadmap · v${_esc(data.version)} · 화이트보드 + 토큰 주입 + 상하 중앙정렬</div>
1592
+ <script>
1593
+ (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();};})();
1594
+ </script>
1595
+ </body></html>`;
1596
+ }
1597
+
1598
+ function roadmapCmd(root) {
1599
+ root = absRoot(root);
1600
+ if (!exists(path.join(root, '.harness'))) return fail(`leerness 미설치: ${root}/.harness 없음 — 먼저 \`leerness init .\``);
1601
+ const outFile = path.resolve(arg('--out', null) || path.join(root, 'roadmap.html'));
1602
+ const data = _roadmapData(root);
1603
+ writeUtf8(outFile, _roadmapHTML(data));
1604
+ ok(`로드맵 생성: ${rel(root, outFile)}`);
1605
+ 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}`);
1606
+ }
1607
+
1317
1608
  // ===== 1.9.8: User Rules (자연어 등록 + 매 세션 자동 노출/검증) =====
1318
1609
  function rulesPath(root) { return path.join(root, '.harness/rules.md'); }
1319
1610
  function rulesArchivePath(root) { return path.join(root, '.harness/rules.archive.md'); }
@@ -2232,6 +2523,7 @@ function viewworkInstall(root) {
2232
2523
 
2233
2524
  function help() {
2234
2525
  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
2526
+ leerness roadmap [path] [--out file.html] # 좌→우 수평 트리 + 상하 중앙정렬 + 화이트보드 (1.9.11)
2235
2527
  leerness verify-code [path] [--build] # npm test/lint/typecheck 자동 실행 + evidence 자동 기록 (1.9.7)
2236
2528
  leerness lessons [--query <키>] [--limit N] # 과거 결정/실수 자동 회수 (1.9.7)
2237
2529
  leerness lazy detect [path] [--auto-track] # --auto-track으로 새 TODO를 progress에 자동 등록 (1.9.7)
@@ -2280,6 +2572,7 @@ async function main() {
2280
2572
  if (cmd === 'gate') return gate(args[1] || process.cwd());
2281
2573
  if (cmd === 'verify-code') return verifyCodeCmd(args[1] || process.cwd());
2282
2574
  if (cmd === 'lessons') return lessonsCmd(arg('--path', process.cwd()));
2575
+ if (cmd === 'roadmap') return roadmapCmd(args[1] || process.cwd());
2283
2576
  if (cmd === 'rule' && args[1] === 'add') return ruleAdd(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
2284
2577
  if (cmd === 'rule' && args[1] === 'list') return ruleList(arg('--path', process.cwd()));
2285
2578
  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.11",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -235,6 +235,52 @@ total++;
235
235
  if (!(strongOK && weakHint)) failed++;
236
236
  }
237
237
 
238
+ // 1.9.11: roadmap 명령 통합 + 화이트보드/토큰/중앙정렬 회귀
239
+ total++;
240
+ {
241
+ const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rm-'));
242
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
243
+ const r = cp.spawnSync(process.execPath, [CLI, 'roadmap', tmpR], { encoding: 'utf8' });
244
+ const outFile = path.join(tmpR, 'roadmap.html');
245
+ const ok = r.status === 0 && fs.existsSync(outFile);
246
+ console.log(ok ? '✓ B(1.9.11) roadmap: 명령 + 파일 생성' : `✗ roadmap 실패\n${r.stdout}\n${r.stderr}`);
247
+ if (!ok) failed++;
248
+ }
249
+ total++;
250
+ {
251
+ const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rm2-'));
252
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
253
+ cp.spawnSync(process.execPath, [CLI, 'roadmap', tmpR], { stdio: 'ignore' });
254
+ const html = fs.readFileSync(path.join(tmpR, 'roadmap.html'), 'utf8');
255
+ const ok = /화이트보드/.test(html) && /id="roadmap-svg"/.test(html) && /viewBox="0 0/.test(html) && /window\.lrZoom/.test(html) && /window\.lrReset/.test(html);
256
+ console.log(ok ? '✓ B(1.9.11) roadmap: 화이트보드 (panning/zoom JS)' : '✗ 화이트보드 부재');
257
+ if (!ok) failed++;
258
+ }
259
+ total++;
260
+ {
261
+ // 사용자 design-system 토큰 주입
262
+ const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rm3-'));
263
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
264
+ let ds = fs.readFileSync(path.join(tmpR, '.harness/design-system.md'), 'utf8');
265
+ ds = ds.replace('| color.primary | (실제 값으로 업데이트) | |', '| color.primary | #ff5722 | |');
266
+ fs.writeFileSync(path.join(tmpR, '.harness/design-system.md'), ds);
267
+ cp.spawnSync(process.execPath, [CLI, 'roadmap', tmpR], { stdio: 'ignore' });
268
+ const html = fs.readFileSync(path.join(tmpR, 'roadmap.html'), 'utf8');
269
+ const ok = /--lr-primary: #ff5722/.test(html);
270
+ console.log(ok ? '✓ B(1.9.11) roadmap: design-system 토큰 자동 주입' : '✗ 토큰 주입 실패');
271
+ if (!ok) failed++;
272
+ }
273
+ total++;
274
+ {
275
+ // recommended에 project-roadmap-generator 자동 포함
276
+ const tmpR = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rm4-'));
277
+ cp.spawnSync(process.execPath, [CLI, 'init', tmpR, '--yes', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore' });
278
+ const skillsDir = path.join(tmpR, '.harness/skills/project-roadmap-generator');
279
+ const ok = fs.existsSync(skillsDir) && fs.existsSync(path.join(skillsDir, 'skill.json'));
280
+ console.log(ok ? '✓ B(1.9.11) recommended에 project-roadmap-generator 자동 설치' : '✗ 자동 설치 실패');
281
+ if (!ok) failed++;
282
+ }
283
+
238
284
  // 1.9.10 A: skillpack 동적 로드 (LEERNESS_SKILLPACK_PATH로 시뮬)
239
285
  total++;
240
286
  {