leerness 1.9.9 → 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,7 +1,70 @@
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
+
30
+ ## 1.9.10 — 2026-05-12
31
+
32
+ **leerness-skillpack 분리 + release publish 강화 (git remote 자동 감지 + GitHub Release + gh-pages 배포)**.
33
+
34
+ ### Changed — 스킬 카탈로그 동적 로드
35
+
36
+ - `leerness-skillpack`이 npm에 별도 패키지로 분리됨. leerness 본 패키지는 `_tryLoadSkillpack()`으로 다음 순서로 동적 로드:
37
+ 1. `require('leerness-skillpack/catalog.json')` 시도
38
+ 2. `<cwd>/node_modules/leerness-skillpack/catalog.json` 탐색
39
+ 3. `npm root -g`의 `leerness-skillpack/catalog.json` 탐색
40
+ 4. `LEERNESS_SKILLPACK_PATH` 환경변수 경로
41
+ 5. 모두 실패 시 leerness 본 패키지의 내장 fallback (1.9.x 호환 유지)
42
+ - `leerness init` 출력에 `Skill catalog source: skillpack v1.0.0 | builtin (fallback)` 안내.
43
+ - `leerness skill list` 헤더에 카탈로그 출처 + 출처 컬럼에 `skillpack` / `builtin` / `user` 표시.
44
+
45
+ ### Added — release publish 강화
46
+
47
+ - `detectGitRemote(root)`: 현재 디렉토리의 `git remote -v origin` 자동 감지 + GitHub owner/repo 추출.
48
+ - `leerness release publish` 신규 플래그:
49
+ - `--auto` — remote 있으면 자동 `git push` (편의)
50
+ - `--gh-release` — gh CLI로 GitHub Release 자동 생성 (`v<version>` 태그 + 자동 노트 + tarball 첨부)
51
+ - `--gh-pages` — `gh-pages` branch에 정적 파일 자동 배포 (orphan 또는 기존 branch). 기본 소스는 `roadmap.html`, `--gh-pages-src <file>` 또는 `--roadmap <file>`로 지정.
52
+ - `--pack` — npm pack만 명시적 실행
53
+ - `gh-pages` 배포는 임시 git worktree로 처리해 현재 작업 트리에 영향 없음. 배포 후 `https://<owner>.github.io/<repo>/` URL 안내.
54
+
55
+ ### Migration
56
+
57
+ 기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다. leerness-skillpack은 선택 설치:
58
+
59
+ ```bash
60
+ npm install leerness-skillpack # 본 카탈로그 사용
61
+ # 또는 그대로 두면 leerness 내장 fallback이 동작 (기존과 동일)
62
+ ```
63
+
3
64
  ## 1.9.9 — 2026-05-12
4
65
 
66
+ - 1.9.9 빌드 + GitHub 배포
67
+
5
68
  **1.9.8 시연 중 자체 도그푸드(dogfood)로 빌드된 패치 — 룰 시스템이 정확히 작동한 증거**.
6
69
 
7
70
  ### Fixed
package/bin/harness.js CHANGED
@@ -6,12 +6,64 @@ const path = require('path');
6
6
  const cp = require('child_process');
7
7
  const readline = require('readline');
8
8
 
9
- const VERSION = '1.9.9';
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 -->';
13
13
 
14
- const skillCatalog = {
14
+ // 1.9.10: leerness-skillpack 동적 로드 (선택). 없으면 BUILTIN 사용.
15
+ function _tryLoadSkillpack() {
16
+ // 1) 정상 require resolution
17
+ try { return { src: 'require', data: require('leerness-skillpack/catalog.json') }; } catch {}
18
+ // 2) cwd/node_modules
19
+ try {
20
+ const f = path.join(process.cwd(), 'node_modules/leerness-skillpack/catalog.json');
21
+ if (fs.existsSync(f)) return { src: 'cwd', data: JSON.parse(fs.readFileSync(f, 'utf8')) };
22
+ } catch {}
23
+ // 3) npm global root
24
+ try {
25
+ const root = cp.execSync('npm root -g', { encoding: 'utf8', timeout: 4000 }).trim();
26
+ const f = path.join(root, 'leerness-skillpack/catalog.json');
27
+ if (fs.existsSync(f)) return { src: 'global', data: JSON.parse(fs.readFileSync(f, 'utf8')) };
28
+ } catch {}
29
+ // 4) 환경변수 명시 경로
30
+ if (process.env.LEERNESS_SKILLPACK_PATH) {
31
+ try {
32
+ const f = path.resolve(process.env.LEERNESS_SKILLPACK_PATH);
33
+ const target = f.endsWith('.json') ? f : path.join(f, 'catalog.json');
34
+ if (fs.existsSync(target)) return { src: 'env', data: JSON.parse(fs.readFileSync(target, 'utf8')) };
35
+ } catch {}
36
+ }
37
+ return null;
38
+ }
39
+
40
+ let SKILLPACK_SOURCE = 'builtin';
41
+ let SKILLPACK_META = null;
42
+ function _loadSkillCatalog() {
43
+ const sp = _tryLoadSkillpack();
44
+ if (sp && sp.data && Array.isArray(sp.data.skills)) {
45
+ SKILLPACK_SOURCE = sp.src;
46
+ SKILLPACK_META = { name: sp.data.name, version: sp.data.version };
47
+ const out = {};
48
+ for (const s of sp.data.skills) {
49
+ out[s.id] = {
50
+ displayNameKo: s.displayNameKo,
51
+ version: s.version,
52
+ lastUpdated: s.lastUpdated,
53
+ verification: s.verification,
54
+ capabilities: s.capabilities,
55
+ _source: 'skillpack'
56
+ };
57
+ }
58
+ return out;
59
+ }
60
+ SKILLPACK_SOURCE = 'builtin';
61
+ const out = {};
62
+ for (const [k, v] of Object.entries(BUILTIN_CATALOG)) out[k] = { ...v, _source: 'builtin' };
63
+ return out;
64
+ }
65
+
66
+ const BUILTIN_CATALOG = {
15
67
  'office': { displayNameKo: '마이크로소프트 오피스 자동화 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['Word/Excel/PowerPoint 문서 자동화', '템플릿 기반 문서 생성', '표/차트/요약 문서화', '민감정보 제외 규칙 적용'] },
16
68
  'commerce-api': { displayNameKo: '커머스 API 연동 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['쿠팡·롯데온·스마트스토어 API 연동 설계', '주문/상품/매출 동기화', '환경변수 기반 인증 분리', '레이트리밋/재시도/오류 처리'] },
17
69
  'crawling': { displayNameKo: '크롤링·브라우저 자동화 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['Playwright 기반 자동화', '다운로드/로그인 세션 처리', '스크린샷 기반 실패 진단', '약관/권한/차단 위험 점검'] },
@@ -19,9 +71,14 @@ const skillCatalog = {
19
71
  'ads-analytics': { displayNameKo: '광고·GA4 분석 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['GA4 이벤트/전환 점검', '광고 데이터 수집 구조화', '소스/매체 분석', '리포트 자동화'] },
20
72
  'appstore-review': { displayNameKo: '앱스토어 심사 대응 스킬 라이브러리', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['심사 문구 분석', '개인정보 라벨 점검', '리젝 대응 초안', '웹뷰/앱 데이터 수집 구분'] },
21
73
  'ai-verified-skill-publisher': { displayNameKo: 'AI 검증 스킬 업로드·라이브러리화 스킬', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['검증된 스킬 정규화', '민감정보 스캔', 'AI 검증 메타데이터 작성', 'npm/git 업로드 dry-run 및 실행 게이트'] },
22
- '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)'] }
23
77
  };
24
78
 
79
+ // 1.9.10: skillCatalog는 skillpack 우선, fallback builtin. _loadSkillCatalog 호출은 BUILTIN_CATALOG 정의 후.
80
+ const skillCatalog = _loadSkillCatalog();
81
+
25
82
  const routes = {
26
83
  planning: { read: ['.harness/plan.md','.harness/progress-tracker.md','.harness/project-brief.md','.harness/current-state.md','.harness/guideline.md'], update: ['.harness/plan.md','.harness/progress-tracker.md','.harness/current-state.md','.harness/session-handoff.md'] },
27
84
  feature: { read: ['.harness/plan.md','.harness/current-state.md','.harness/architecture.md','.harness/context-map.md','.harness/feature-contracts.md','.harness/skills/feature-implementation/README.md','.harness/reuse-map.md'], update: ['.harness/progress-tracker.md','.harness/feature-contracts.md','.harness/current-state.md','.harness/task-log.md','.harness/session-handoff.md'] },
@@ -295,7 +352,8 @@ function syncReadme(root) {
295
352
  function parseSkillsValue(v) {
296
353
  if (!v || v === true) return [];
297
354
  if (v === 'all') return Object.keys(skillCatalog);
298
- 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'];
299
357
  return String(v).split(',').map(s => s.trim()).filter(Boolean).filter(s => skillCatalog[s]);
300
358
  }
301
359
 
@@ -335,6 +393,9 @@ async function install(root, opts = {}) {
335
393
  log(`Target: ${root}`);
336
394
  log(`Language: ${lang}`);
337
395
  log(`Skills: ${skills.length ? skills.join(', ') : 'none'}`);
396
+ // 1.9.10: 스킬 카탈로그 출처 안내
397
+ if (SKILLPACK_SOURCE === 'builtin') log(`Skill catalog source: builtin (leerness-skillpack 미설치 — \`npm i leerness-skillpack\`로 확장 가능)`);
398
+ else log(`Skill catalog source: ${SKILLPACK_SOURCE} (leerness-skillpack${SKILLPACK_META ? ` v${SKILLPACK_META.version}` : ''})`);
338
399
  const files = coreFiles(root, lang, skills);
339
400
  const backup = createBackup(root, opts.force ? 'force' : (opts.migration ? 'migration' : 'init'), files, opts.dry);
340
401
  if (opts.dry) {
@@ -430,7 +491,8 @@ function saveUserSkill(root, id, data) {
430
491
 
431
492
  function listAllSkills(root) {
432
493
  const out = {};
433
- for (const [k, v] of Object.entries(skillCatalog)) out[k] = { ...v, _source: 'catalog' };
494
+ // 1.9.10: skillCatalog의 _source('skillpack' 또는 'builtin')를 보존
495
+ for (const [k, v] of Object.entries(skillCatalog)) out[k] = { ...v, _source: v._source || 'builtin' };
434
496
  if (root) {
435
497
  const dir = userSkillsDir(root);
436
498
  if (exists(dir)) {
@@ -448,6 +510,8 @@ function listAllSkills(root) {
448
510
 
449
511
  function skillList(root) {
450
512
  const all = listAllSkills(root);
513
+ if (SKILLPACK_SOURCE !== 'builtin') log(`# skillpack 출처: ${SKILLPACK_SOURCE}${SKILLPACK_META ? ` (${SKILLPACK_META.name} v${SKILLPACK_META.version})` : ''}`);
514
+ else log('# skillpack 미설치 — builtin fallback 사용 (leerness 본 패키지 내장 카탈로그)');
451
515
  log('| ID | 한글명 | 출처 | 능력(요약) | 사용횟수 | 최종 |');
452
516
  log('|---|---|---|---|---|---|');
453
517
  for (const [id, v] of Object.entries(all)) {
@@ -1253,6 +1317,294 @@ function gate(root) {
1253
1317
  else ok('all gates passed');
1254
1318
  }
1255
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
+
1256
1608
  // ===== 1.9.8: User Rules (자연어 등록 + 매 세션 자동 노출/검증) =====
1257
1609
  function rulesPath(root) { return path.join(root, '.harness/rules.md'); }
1258
1610
  function rulesArchivePath(root) { return path.join(root, '.harness/rules.archive.md'); }
@@ -1528,21 +1880,121 @@ function releaseNote(root, text) {
1528
1880
  ok(`CHANGELOG.md 갱신: [${version}] ${text}`);
1529
1881
  }
1530
1882
 
1883
+ // 1.9.10: git remote 자동 감지 + gh-release + gh-pages 배포
1884
+ function detectGitRemote(root) {
1885
+ const r = cp.spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8', shell: true });
1886
+ if (r.status !== 0) return null;
1887
+ const url = (r.stdout || '').trim();
1888
+ if (!url) return null;
1889
+ // owner/repo 추출
1890
+ const m = url.match(/github\.com[:/]([^/]+)\/([^/.]+)(?:\.git)?/);
1891
+ return { url, host: m ? 'github' : 'unknown', owner: m ? m[1] : null, repo: m ? m[2] : null };
1892
+ }
1893
+
1894
+ function getCurrentVersion(root) {
1895
+ const pkgF = path.join(root, 'package.json');
1896
+ if (!exists(pkgF)) return null;
1897
+ try { return JSON.parse(read(pkgF)).version || null; } catch { return null; }
1898
+ }
1899
+
1900
+ function deployGhPages(root, sourceFile) {
1901
+ const remote = detectGitRemote(root);
1902
+ if (!remote || remote.host !== 'github') { fail('GitHub remote가 없습니다 — gh-pages 배포 불가'); process.exitCode = 1; return; }
1903
+ const src = path.resolve(root, sourceFile);
1904
+ if (!exists(src)) { fail(`소스 파일 없음: ${src}`); process.exitCode = 1; return; }
1905
+ log(`# gh-pages deploy`);
1906
+ log(`Source: ${rel(root, src)}`);
1907
+ log(`Target: gh-pages branch of ${remote.owner}/${remote.repo}`);
1908
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1909
+ const wt = path.join(root, '.harness/cache', `ghpages-${stamp}`);
1910
+ mkdirp(path.dirname(wt));
1911
+ // worktree (기존 gh-pages 있으면 fetch, 없으면 orphan)
1912
+ const fetchR = cp.spawnSync('git', ['fetch', 'origin', 'gh-pages'], { cwd: root, encoding: 'utf8', shell: true });
1913
+ const hasBranch = fetchR.status === 0;
1914
+ let wtArgs;
1915
+ if (hasBranch) wtArgs = ['worktree', 'add', wt, 'origin/gh-pages'];
1916
+ else wtArgs = ['worktree', 'add', '--orphan', '-b', 'gh-pages', wt];
1917
+ const wtR = cp.spawnSync('git', wtArgs, { cwd: root, encoding: 'utf8', shell: true });
1918
+ if (wtR.status !== 0) { fail('worktree 생성 실패: ' + (wtR.stderr || '').slice(0, 200)); process.exitCode = 1; return; }
1919
+ try {
1920
+ // orphan인 경우 초기화
1921
+ if (!hasBranch) {
1922
+ cp.spawnSync('git', ['rm', '-rf', '.'], { cwd: wt, encoding: 'utf8', shell: true });
1923
+ }
1924
+ // 소스 복사 (index.html로 이름 변경)
1925
+ const destName = path.basename(src) === 'index.html' ? 'index.html' : 'index.html';
1926
+ fs.copyFileSync(src, path.join(wt, destName));
1927
+ // 원본 파일명도 보존
1928
+ if (path.basename(src) !== 'index.html') fs.copyFileSync(src, path.join(wt, path.basename(src)));
1929
+ cp.spawnSync('git', ['add', '-A'], { cwd: wt, encoding: 'utf8' });
1930
+ const commit = cp.spawnSync('git', ['commit', '-m', `deploy: ${path.basename(src)} ${stamp}`], { cwd: wt, encoding: 'utf8' });
1931
+ if (commit.status !== 0 && !/nothing to commit/.test(commit.stdout || '')) {
1932
+ fail('commit 실패: ' + (commit.stdout || commit.stderr || '').slice(0, 200));
1933
+ process.exitCode = 1;
1934
+ } else {
1935
+ const pushR = cp.spawnSync('git', ['push', 'origin', 'gh-pages'], { cwd: wt, encoding: 'utf8' });
1936
+ if (pushR.status !== 0) { fail('push 실패: ' + (pushR.stderr || '').slice(0, 200)); process.exitCode = 1; }
1937
+ else ok(`gh-pages push 완료 → https://${remote.owner}.github.io/${remote.repo}/`);
1938
+ }
1939
+ } finally {
1940
+ cp.spawnSync('git', ['worktree', 'remove', '--force', wt], { cwd: root, encoding: 'utf8', shell: true });
1941
+ }
1942
+ }
1943
+
1531
1944
  function releasePublish(root) {
1532
1945
  root = absRoot(root);
1533
1946
  const dryRun = has('--dry-run');
1534
1947
  log('# release publish');
1535
1948
  log(`Mode: ${dryRun ? 'dry-run' : 'live'}`);
1536
- const packR = cp.spawnSync('npm', ['pack'], { cwd: root, encoding: 'utf8', shell: true });
1537
- if (packR.status !== 0) { fail('npm pack 실패'); log(packR.stderr); process.exitCode = 1; return; }
1538
- ok('npm pack 완료');
1539
- if (has('--git-push')) {
1949
+
1950
+ // 1. git remote 자동 감지 (1.9.10)
1951
+ const remote = detectGitRemote(root);
1952
+ if (remote) log(`Git remote (origin): ${remote.host === 'github' ? `${remote.owner}/${remote.repo}` : remote.url}`);
1953
+ else log('Git remote: 없음');
1954
+
1955
+ // 2. npm pack (필요한 경우 — pack-only도 의미 있음)
1956
+ if (has('--pack') || has('--npm-publish') || (!has('--git-push') && !has('--gh-release') && !has('--gh-pages'))) {
1957
+ const packR = cp.spawnSync('npm', ['pack'], { cwd: root, encoding: 'utf8', shell: true });
1958
+ if (packR.status !== 0) { fail('npm pack 실패'); log(packR.stderr); process.exitCode = 1; return; }
1959
+ ok('npm pack 완료');
1960
+ }
1961
+
1962
+ // 3. git push (--git-push 또는 --auto + remote 있을 때)
1963
+ if (has('--git-push') || (has('--auto') && remote)) {
1540
1964
  log('git push:');
1541
1965
  const r1 = cp.spawnSync('git', ['push'], { cwd: root, encoding: 'utf8', shell: true });
1542
- log(r1.stdout || r1.stderr || '(no output)');
1966
+ log((r1.stdout || r1.stderr || '').slice(-200) || '(no output)');
1543
1967
  const r2 = cp.spawnSync('git', ['push', '--tags'], { cwd: root, encoding: 'utf8', shell: true });
1544
- log(r2.stdout || r2.stderr || '(no output)');
1968
+ log((r2.stdout || r2.stderr || '').slice(-200) || '(no output)');
1969
+ }
1970
+
1971
+ // 4. GitHub Release (--gh-release, gh CLI 사용)
1972
+ if (has('--gh-release')) {
1973
+ if (!remote || remote.host !== 'github') { warn('--gh-release: GitHub remote 없음 — 스킵'); }
1974
+ else {
1975
+ const v = getCurrentVersion(root);
1976
+ if (!v) { warn('--gh-release: package.json#version 없음 — 스킵'); }
1977
+ else {
1978
+ const tag = `v${v}`;
1979
+ const ghArgs = ['release', 'create', tag, '--generate-notes', '--title', `${remote.repo} ${tag}`];
1980
+ const tarball = path.join(root, `${JSON.parse(read(path.join(root, 'package.json'))).name}-${v}.tgz`);
1981
+ if (exists(tarball)) ghArgs.push(tarball);
1982
+ log(`gh ${ghArgs.join(' ')}`);
1983
+ const ghR = cp.spawnSync('gh', ghArgs, { cwd: root, encoding: 'utf8', shell: true });
1984
+ log((ghR.stdout || ghR.stderr || '').slice(-300) || '(no output)');
1985
+ if (ghR.status !== 0) warn('gh release 생성 실패 (이미 존재할 수 있음)');
1986
+ else ok(`GitHub Release 생성: ${tag}`);
1987
+ }
1988
+ }
1989
+ }
1990
+
1991
+ // 5. gh-pages 배포 (--gh-pages)
1992
+ if (has('--gh-pages')) {
1993
+ const src = arg('--gh-pages-src', null) || arg('--roadmap', null) || 'roadmap.html';
1994
+ deployGhPages(root, src);
1545
1995
  }
1996
+
1997
+ // 6. npm publish (--npm-publish)
1546
1998
  if (has('--npm-publish')) {
1547
1999
  const args = dryRun ? ['publish', '--dry-run'] : ['publish', '--access', 'public'];
1548
2000
  log('npm ' + args.join(' '));
@@ -2071,6 +2523,7 @@ function viewworkInstall(root) {
2071
2523
 
2072
2524
  function help() {
2073
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)
2074
2527
  leerness verify-code [path] [--build] # npm test/lint/typecheck 자동 실행 + evidence 자동 기록 (1.9.7)
2075
2528
  leerness lessons [--query <키>] [--limit N] # 과거 결정/실수 자동 회수 (1.9.7)
2076
2529
  leerness lazy detect [path] [--auto-track] # --auto-track으로 새 TODO를 progress에 자동 등록 (1.9.7)
@@ -2078,7 +2531,7 @@ function help() {
2078
2531
  leerness rule list|verify|pause <id>|resume <id>|remove <id>|stop|resume-all
2079
2532
  leerness release bump [--patch|--minor|--major] # package.json 자동 bump (1.9.8)
2080
2533
  leerness release note "<내용>" # CHANGELOG.md 자동 추가 (1.9.8)
2081
- leerness release publish [--dry-run] [--git-push] [--npm-publish] # 통합 배포 (1.9.8)\n leerness impact <target> [--all] # 변경 전 영향 분석 (기본 strong, --all로 weak 포함)\n leerness reuse find <query> # 기존 자원 검색 (재귀 안내)\n leerness reuse register <name> --where <p> --kind component|hook|util|api [--note ...]\n leerness ui consistency [path] [--strict] [--fail-on-violation]\n leerness graph [path] [--out <file>] # mermaid 의존성 그래프\n leerness guide [target] # impact + reuse + ui consistency 통합 가이드\n`);
2534
+ leerness release publish [--dry-run] [--pack] [--git-push] [--gh-release] [--gh-pages] [--gh-pages-src file] [--npm-publish] [--auto] # 통합 배포 (1.9.8 + 1.9.10)\n leerness impact <target> [--all] # 변경 전 영향 분석 (기본 strong, --all로 weak 포함)\n leerness reuse find <query> # 기존 자원 검색 (재귀 안내)\n leerness reuse register <name> --where <p> --kind component|hook|util|api [--note ...]\n leerness ui consistency [path] [--strict] [--fail-on-violation]\n leerness graph [path] [--out <file>] # mermaid 의존성 그래프\n leerness guide [target] # impact + reuse + ui consistency 통합 가이드\n`);
2082
2535
  }
2083
2536
 
2084
2537
  async function main() {
@@ -2119,6 +2572,7 @@ async function main() {
2119
2572
  if (cmd === 'gate') return gate(args[1] || process.cwd());
2120
2573
  if (cmd === 'verify-code') return verifyCodeCmd(args[1] || process.cwd());
2121
2574
  if (cmd === 'lessons') return lessonsCmd(arg('--path', process.cwd()));
2575
+ if (cmd === 'roadmap') return roadmapCmd(args[1] || process.cwd());
2122
2576
  if (cmd === 'rule' && args[1] === 'add') return ruleAdd(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
2123
2577
  if (cmd === 'rule' && args[1] === 'list') return ruleList(arg('--path', process.cwd()));
2124
2578
  if (cmd === 'rule' && args[1] === 'remove') return ruleRemove(arg('--path', process.cwd()), args[2]);
@@ -2127,9 +2581,9 @@ async function main() {
2127
2581
  if (cmd === 'rule' && args[1] === 'stop') return ruleStop(arg('--path', process.cwd()));
2128
2582
  if (cmd === 'rule' && args[1] === 'resume-all') return ruleResumeAll(arg('--path', process.cwd()));
2129
2583
  if (cmd === 'rule' && args[1] === 'verify') return ruleVerifyCmd(arg('--path', process.cwd()));
2130
- if (cmd === 'release' && args[1] === 'bump') return releaseBump(arg('--path', process.cwd()));
2584
+ if (cmd === 'release' && args[1] === 'bump') return releaseBump(args[2] || arg('--path', process.cwd()));
2131
2585
  if (cmd === 'release' && args[1] === 'note') return releaseNote(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
2132
- if (cmd === 'release' && args[1] === 'publish') return releasePublish(arg('--path', process.cwd()));
2586
+ if (cmd === 'release' && args[1] === 'publish') return releasePublish(args[2] || arg('--path', process.cwd()));
2133
2587
  if (cmd === 'impact') return impactCmd(arg('--path', process.cwd()), args[1]);
2134
2588
  if (cmd === 'reuse' && args[1] === 'find') return reuseFind(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
2135
2589
  if (cmd === 'reuse' && args[1] === 'register') return reuseRegister(arg('--path', process.cwd()), args[2]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.9",
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,93 @@ 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
+
284
+ // 1.9.10 A: skillpack 동적 로드 (LEERNESS_SKILLPACK_PATH로 시뮬)
285
+ total++;
286
+ {
287
+ const skillpackDir = path.resolve(__dirname, '..', '..', 'leerness-skillpack');
288
+ const r = cp.spawnSync(process.execPath, [CLI, 'skill', 'list', '--path', tmp], {
289
+ encoding: 'utf8',
290
+ env: Object.assign({}, process.env, { LEERNESS_SKILLPACK_PATH: skillpackDir })
291
+ });
292
+ const ok = r.status === 0 && /skillpack 출처: env/.test(r.stdout) && /\| skillpack \|/.test(r.stdout);
293
+ console.log(ok ? '✓ B(1.9.10) skillpack 동적 로드 (env path)' : '✗ skillpack 로드 실패');
294
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 800)); }
295
+ }
296
+ // 1.9.10 A: skillpack 없을 때 builtin fallback
297
+ total++;
298
+ {
299
+ const r = cp.spawnSync(process.execPath, [CLI, 'skill', 'list', '--path', tmp], {
300
+ encoding: 'utf8',
301
+ env: Object.assign({}, process.env, { LEERNESS_SKILLPACK_PATH: '' })
302
+ });
303
+ const ok = r.status === 0 && /builtin fallback/.test(r.stdout) && /\| builtin \|/.test(r.stdout);
304
+ console.log(ok ? '✓ B(1.9.10) builtin fallback (skillpack 없을 때)' : '✗ builtin fallback 실패');
305
+ if (!ok) failed++;
306
+ }
307
+
308
+ // 1.9.10 B: detectGitRemote (가짜 git remote 시뮬은 어려움 — 실제 git 명령으로 확인)
309
+ total++;
310
+ {
311
+ // tmp는 git init이 없음 → detectGitRemote는 null → publish 호출 시 'Git remote: 없음' 출력
312
+ // 시뮬: tmp에 git init + remote add
313
+ cp.spawnSync('git', ['init'], { cwd: tmp, encoding: 'utf8', shell: true });
314
+ cp.spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/test/repo.git'], { cwd: tmp, encoding: 'utf8', shell: true });
315
+ // package.json도 필요
316
+ if (!fs.existsSync(path.join(tmp, 'package.json'))) {
317
+ fs.writeFileSync(path.join(tmp, 'package.json'), JSON.stringify({ name: 'e2e-test', version: '0.1.0' }));
318
+ }
319
+ const r = cp.spawnSync(process.execPath, [CLI, 'release', 'publish', tmp, '--dry-run'], { encoding: 'utf8' });
320
+ const ok = /Git remote \(origin\): test\/repo/.test(r.stdout);
321
+ console.log(ok ? '✓ B(1.9.10) detectGitRemote: github owner/repo 추출' : `✗ remote 감지 실패\n${r.stdout.slice(0, 500)}`);
322
+ if (!ok) failed++;
323
+ }
324
+
238
325
  // 1.9.8: rule add/list/pause/resume/remove
239
326
  total++;
240
327
  {