leerness 1.9.9 → 1.9.10

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,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.10 — 2026-05-12
4
+
5
+ **leerness-skillpack 분리 + release publish 강화 (git remote 자동 감지 + GitHub Release + gh-pages 배포)**.
6
+
7
+ ### Changed — 스킬 카탈로그 동적 로드
8
+
9
+ - `leerness-skillpack`이 npm에 별도 패키지로 분리됨. leerness 본 패키지는 `_tryLoadSkillpack()`으로 다음 순서로 동적 로드:
10
+ 1. `require('leerness-skillpack/catalog.json')` 시도
11
+ 2. `<cwd>/node_modules/leerness-skillpack/catalog.json` 탐색
12
+ 3. `npm root -g`의 `leerness-skillpack/catalog.json` 탐색
13
+ 4. `LEERNESS_SKILLPACK_PATH` 환경변수 경로
14
+ 5. 모두 실패 시 leerness 본 패키지의 내장 fallback (1.9.x 호환 유지)
15
+ - `leerness init` 출력에 `Skill catalog source: skillpack v1.0.0 | builtin (fallback)` 안내.
16
+ - `leerness skill list` 헤더에 카탈로그 출처 + 출처 컬럼에 `skillpack` / `builtin` / `user` 표시.
17
+
18
+ ### Added — release publish 강화
19
+
20
+ - `detectGitRemote(root)`: 현재 디렉토리의 `git remote -v origin` 자동 감지 + GitHub owner/repo 추출.
21
+ - `leerness release publish` 신규 플래그:
22
+ - `--auto` — remote 있으면 자동 `git push` (편의)
23
+ - `--gh-release` — gh CLI로 GitHub Release 자동 생성 (`v<version>` 태그 + 자동 노트 + tarball 첨부)
24
+ - `--gh-pages` — `gh-pages` branch에 정적 파일 자동 배포 (orphan 또는 기존 branch). 기본 소스는 `roadmap.html`, `--gh-pages-src <file>` 또는 `--roadmap <file>`로 지정.
25
+ - `--pack` — npm pack만 명시적 실행
26
+ - `gh-pages` 배포는 임시 git worktree로 처리해 현재 작업 트리에 영향 없음. 배포 후 `https://<owner>.github.io/<repo>/` URL 안내.
27
+
28
+ ### Migration
29
+
30
+ 기존 1.9.x 사용자는 `npx leerness@latest update . --yes`로 즉시 자동 마이그레이션됩니다. leerness-skillpack은 선택 설치:
31
+
32
+ ```bash
33
+ npm install leerness-skillpack # 본 카탈로그 사용
34
+ # 또는 그대로 두면 leerness 내장 fallback이 동작 (기존과 동일)
35
+ ```
36
+
3
37
  ## 1.9.9 — 2026-05-12
4
38
 
39
+ - 1.9.9 빌드 + GitHub 배포
40
+
5
41
  **1.9.8 시연 중 자체 도그푸드(dogfood)로 빌드된 패치 — 룰 시스템이 정확히 작동한 증거**.
6
42
 
7
43
  ### 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.10';
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 기반 자동화', '다운로드/로그인 세션 처리', '스크린샷 기반 실패 진단', '약관/권한/차단 위험 점검'] },
@@ -22,6 +74,9 @@ const skillCatalog = {
22
74
  'feature-implementation': { displayNameKo: '기능 구현 표준 스킬', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'passed', capabilities: ['feature-contracts 작성', '재사용 우선 검사', '테스트 증거 수집', '핸드오프 트리거'] }
23
75
  };
24
76
 
77
+ // 1.9.10: skillCatalog는 skillpack 우선, fallback builtin. _loadSkillCatalog 호출은 BUILTIN_CATALOG 정의 후.
78
+ const skillCatalog = _loadSkillCatalog();
79
+
25
80
  const routes = {
26
81
  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
82
  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'] },
@@ -335,6 +390,9 @@ async function install(root, opts = {}) {
335
390
  log(`Target: ${root}`);
336
391
  log(`Language: ${lang}`);
337
392
  log(`Skills: ${skills.length ? skills.join(', ') : 'none'}`);
393
+ // 1.9.10: 스킬 카탈로그 출처 안내
394
+ if (SKILLPACK_SOURCE === 'builtin') log(`Skill catalog source: builtin (leerness-skillpack 미설치 — \`npm i leerness-skillpack\`로 확장 가능)`);
395
+ else log(`Skill catalog source: ${SKILLPACK_SOURCE} (leerness-skillpack${SKILLPACK_META ? ` v${SKILLPACK_META.version}` : ''})`);
338
396
  const files = coreFiles(root, lang, skills);
339
397
  const backup = createBackup(root, opts.force ? 'force' : (opts.migration ? 'migration' : 'init'), files, opts.dry);
340
398
  if (opts.dry) {
@@ -430,7 +488,8 @@ function saveUserSkill(root, id, data) {
430
488
 
431
489
  function listAllSkills(root) {
432
490
  const out = {};
433
- for (const [k, v] of Object.entries(skillCatalog)) out[k] = { ...v, _source: 'catalog' };
491
+ // 1.9.10: skillCatalog의 _source('skillpack' 또는 'builtin')를 보존
492
+ for (const [k, v] of Object.entries(skillCatalog)) out[k] = { ...v, _source: v._source || 'builtin' };
434
493
  if (root) {
435
494
  const dir = userSkillsDir(root);
436
495
  if (exists(dir)) {
@@ -448,6 +507,8 @@ function listAllSkills(root) {
448
507
 
449
508
  function skillList(root) {
450
509
  const all = listAllSkills(root);
510
+ if (SKILLPACK_SOURCE !== 'builtin') log(`# skillpack 출처: ${SKILLPACK_SOURCE}${SKILLPACK_META ? ` (${SKILLPACK_META.name} v${SKILLPACK_META.version})` : ''}`);
511
+ else log('# skillpack 미설치 — builtin fallback 사용 (leerness 본 패키지 내장 카탈로그)');
451
512
  log('| ID | 한글명 | 출처 | 능력(요약) | 사용횟수 | 최종 |');
452
513
  log('|---|---|---|---|---|---|');
453
514
  for (const [id, v] of Object.entries(all)) {
@@ -1528,21 +1589,121 @@ function releaseNote(root, text) {
1528
1589
  ok(`CHANGELOG.md 갱신: [${version}] ${text}`);
1529
1590
  }
1530
1591
 
1592
+ // 1.9.10: git remote 자동 감지 + gh-release + gh-pages 배포
1593
+ function detectGitRemote(root) {
1594
+ const r = cp.spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8', shell: true });
1595
+ if (r.status !== 0) return null;
1596
+ const url = (r.stdout || '').trim();
1597
+ if (!url) return null;
1598
+ // owner/repo 추출
1599
+ const m = url.match(/github\.com[:/]([^/]+)\/([^/.]+)(?:\.git)?/);
1600
+ return { url, host: m ? 'github' : 'unknown', owner: m ? m[1] : null, repo: m ? m[2] : null };
1601
+ }
1602
+
1603
+ function getCurrentVersion(root) {
1604
+ const pkgF = path.join(root, 'package.json');
1605
+ if (!exists(pkgF)) return null;
1606
+ try { return JSON.parse(read(pkgF)).version || null; } catch { return null; }
1607
+ }
1608
+
1609
+ function deployGhPages(root, sourceFile) {
1610
+ const remote = detectGitRemote(root);
1611
+ if (!remote || remote.host !== 'github') { fail('GitHub remote가 없습니다 — gh-pages 배포 불가'); process.exitCode = 1; return; }
1612
+ const src = path.resolve(root, sourceFile);
1613
+ if (!exists(src)) { fail(`소스 파일 없음: ${src}`); process.exitCode = 1; return; }
1614
+ log(`# gh-pages deploy`);
1615
+ log(`Source: ${rel(root, src)}`);
1616
+ log(`Target: gh-pages branch of ${remote.owner}/${remote.repo}`);
1617
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1618
+ const wt = path.join(root, '.harness/cache', `ghpages-${stamp}`);
1619
+ mkdirp(path.dirname(wt));
1620
+ // worktree (기존 gh-pages 있으면 fetch, 없으면 orphan)
1621
+ const fetchR = cp.spawnSync('git', ['fetch', 'origin', 'gh-pages'], { cwd: root, encoding: 'utf8', shell: true });
1622
+ const hasBranch = fetchR.status === 0;
1623
+ let wtArgs;
1624
+ if (hasBranch) wtArgs = ['worktree', 'add', wt, 'origin/gh-pages'];
1625
+ else wtArgs = ['worktree', 'add', '--orphan', '-b', 'gh-pages', wt];
1626
+ const wtR = cp.spawnSync('git', wtArgs, { cwd: root, encoding: 'utf8', shell: true });
1627
+ if (wtR.status !== 0) { fail('worktree 생성 실패: ' + (wtR.stderr || '').slice(0, 200)); process.exitCode = 1; return; }
1628
+ try {
1629
+ // orphan인 경우 초기화
1630
+ if (!hasBranch) {
1631
+ cp.spawnSync('git', ['rm', '-rf', '.'], { cwd: wt, encoding: 'utf8', shell: true });
1632
+ }
1633
+ // 소스 복사 (index.html로 이름 변경)
1634
+ const destName = path.basename(src) === 'index.html' ? 'index.html' : 'index.html';
1635
+ fs.copyFileSync(src, path.join(wt, destName));
1636
+ // 원본 파일명도 보존
1637
+ if (path.basename(src) !== 'index.html') fs.copyFileSync(src, path.join(wt, path.basename(src)));
1638
+ cp.spawnSync('git', ['add', '-A'], { cwd: wt, encoding: 'utf8' });
1639
+ const commit = cp.spawnSync('git', ['commit', '-m', `deploy: ${path.basename(src)} ${stamp}`], { cwd: wt, encoding: 'utf8' });
1640
+ if (commit.status !== 0 && !/nothing to commit/.test(commit.stdout || '')) {
1641
+ fail('commit 실패: ' + (commit.stdout || commit.stderr || '').slice(0, 200));
1642
+ process.exitCode = 1;
1643
+ } else {
1644
+ const pushR = cp.spawnSync('git', ['push', 'origin', 'gh-pages'], { cwd: wt, encoding: 'utf8' });
1645
+ if (pushR.status !== 0) { fail('push 실패: ' + (pushR.stderr || '').slice(0, 200)); process.exitCode = 1; }
1646
+ else ok(`gh-pages push 완료 → https://${remote.owner}.github.io/${remote.repo}/`);
1647
+ }
1648
+ } finally {
1649
+ cp.spawnSync('git', ['worktree', 'remove', '--force', wt], { cwd: root, encoding: 'utf8', shell: true });
1650
+ }
1651
+ }
1652
+
1531
1653
  function releasePublish(root) {
1532
1654
  root = absRoot(root);
1533
1655
  const dryRun = has('--dry-run');
1534
1656
  log('# release publish');
1535
1657
  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')) {
1658
+
1659
+ // 1. git remote 자동 감지 (1.9.10)
1660
+ const remote = detectGitRemote(root);
1661
+ if (remote) log(`Git remote (origin): ${remote.host === 'github' ? `${remote.owner}/${remote.repo}` : remote.url}`);
1662
+ else log('Git remote: 없음');
1663
+
1664
+ // 2. npm pack (필요한 경우 — pack-only도 의미 있음)
1665
+ if (has('--pack') || has('--npm-publish') || (!has('--git-push') && !has('--gh-release') && !has('--gh-pages'))) {
1666
+ const packR = cp.spawnSync('npm', ['pack'], { cwd: root, encoding: 'utf8', shell: true });
1667
+ if (packR.status !== 0) { fail('npm pack 실패'); log(packR.stderr); process.exitCode = 1; return; }
1668
+ ok('npm pack 완료');
1669
+ }
1670
+
1671
+ // 3. git push (--git-push 또는 --auto + remote 있을 때)
1672
+ if (has('--git-push') || (has('--auto') && remote)) {
1540
1673
  log('git push:');
1541
1674
  const r1 = cp.spawnSync('git', ['push'], { cwd: root, encoding: 'utf8', shell: true });
1542
- log(r1.stdout || r1.stderr || '(no output)');
1675
+ log((r1.stdout || r1.stderr || '').slice(-200) || '(no output)');
1543
1676
  const r2 = cp.spawnSync('git', ['push', '--tags'], { cwd: root, encoding: 'utf8', shell: true });
1544
- log(r2.stdout || r2.stderr || '(no output)');
1677
+ log((r2.stdout || r2.stderr || '').slice(-200) || '(no output)');
1678
+ }
1679
+
1680
+ // 4. GitHub Release (--gh-release, gh CLI 사용)
1681
+ if (has('--gh-release')) {
1682
+ if (!remote || remote.host !== 'github') { warn('--gh-release: GitHub remote 없음 — 스킵'); }
1683
+ else {
1684
+ const v = getCurrentVersion(root);
1685
+ if (!v) { warn('--gh-release: package.json#version 없음 — 스킵'); }
1686
+ else {
1687
+ const tag = `v${v}`;
1688
+ const ghArgs = ['release', 'create', tag, '--generate-notes', '--title', `${remote.repo} ${tag}`];
1689
+ const tarball = path.join(root, `${JSON.parse(read(path.join(root, 'package.json'))).name}-${v}.tgz`);
1690
+ if (exists(tarball)) ghArgs.push(tarball);
1691
+ log(`gh ${ghArgs.join(' ')}`);
1692
+ const ghR = cp.spawnSync('gh', ghArgs, { cwd: root, encoding: 'utf8', shell: true });
1693
+ log((ghR.stdout || ghR.stderr || '').slice(-300) || '(no output)');
1694
+ if (ghR.status !== 0) warn('gh release 생성 실패 (이미 존재할 수 있음)');
1695
+ else ok(`GitHub Release 생성: ${tag}`);
1696
+ }
1697
+ }
1545
1698
  }
1699
+
1700
+ // 5. gh-pages 배포 (--gh-pages)
1701
+ if (has('--gh-pages')) {
1702
+ const src = arg('--gh-pages-src', null) || arg('--roadmap', null) || 'roadmap.html';
1703
+ deployGhPages(root, src);
1704
+ }
1705
+
1706
+ // 6. npm publish (--npm-publish)
1546
1707
  if (has('--npm-publish')) {
1547
1708
  const args = dryRun ? ['publish', '--dry-run'] : ['publish', '--access', 'public'];
1548
1709
  log('npm ' + args.join(' '));
@@ -2078,7 +2239,7 @@ function help() {
2078
2239
  leerness rule list|verify|pause <id>|resume <id>|remove <id>|stop|resume-all
2079
2240
  leerness release bump [--patch|--minor|--major] # package.json 자동 bump (1.9.8)
2080
2241
  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`);
2242
+ 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
2243
  }
2083
2244
 
2084
2245
  async function main() {
@@ -2127,9 +2288,9 @@ async function main() {
2127
2288
  if (cmd === 'rule' && args[1] === 'stop') return ruleStop(arg('--path', process.cwd()));
2128
2289
  if (cmd === 'rule' && args[1] === 'resume-all') return ruleResumeAll(arg('--path', process.cwd()));
2129
2290
  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()));
2291
+ if (cmd === 'release' && args[1] === 'bump') return releaseBump(args[2] || arg('--path', process.cwd()));
2131
2292
  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()));
2293
+ if (cmd === 'release' && args[1] === 'publish') return releasePublish(args[2] || arg('--path', process.cwd()));
2133
2294
  if (cmd === 'impact') return impactCmd(arg('--path', process.cwd()), args[1]);
2134
2295
  if (cmd === 'reuse' && args[1] === 'find') return reuseFind(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
2135
2296
  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.10",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -235,6 +235,47 @@ total++;
235
235
  if (!(strongOK && weakHint)) failed++;
236
236
  }
237
237
 
238
+ // 1.9.10 A: skillpack 동적 로드 (LEERNESS_SKILLPACK_PATH로 시뮬)
239
+ total++;
240
+ {
241
+ const skillpackDir = path.resolve(__dirname, '..', '..', 'leerness-skillpack');
242
+ const r = cp.spawnSync(process.execPath, [CLI, 'skill', 'list', '--path', tmp], {
243
+ encoding: 'utf8',
244
+ env: Object.assign({}, process.env, { LEERNESS_SKILLPACK_PATH: skillpackDir })
245
+ });
246
+ const ok = r.status === 0 && /skillpack 출처: env/.test(r.stdout) && /\| skillpack \|/.test(r.stdout);
247
+ console.log(ok ? '✓ B(1.9.10) skillpack 동적 로드 (env path)' : '✗ skillpack 로드 실패');
248
+ if (!ok) { failed++; console.log(r.stdout.slice(0, 800)); }
249
+ }
250
+ // 1.9.10 A: skillpack 없을 때 builtin fallback
251
+ total++;
252
+ {
253
+ const r = cp.spawnSync(process.execPath, [CLI, 'skill', 'list', '--path', tmp], {
254
+ encoding: 'utf8',
255
+ env: Object.assign({}, process.env, { LEERNESS_SKILLPACK_PATH: '' })
256
+ });
257
+ const ok = r.status === 0 && /builtin fallback/.test(r.stdout) && /\| builtin \|/.test(r.stdout);
258
+ console.log(ok ? '✓ B(1.9.10) builtin fallback (skillpack 없을 때)' : '✗ builtin fallback 실패');
259
+ if (!ok) failed++;
260
+ }
261
+
262
+ // 1.9.10 B: detectGitRemote (가짜 git remote 시뮬은 어려움 — 실제 git 명령으로 확인)
263
+ total++;
264
+ {
265
+ // tmp는 git init이 없음 → detectGitRemote는 null → publish 호출 시 'Git remote: 없음' 출력
266
+ // 시뮬: tmp에 git init + remote add
267
+ cp.spawnSync('git', ['init'], { cwd: tmp, encoding: 'utf8', shell: true });
268
+ cp.spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/test/repo.git'], { cwd: tmp, encoding: 'utf8', shell: true });
269
+ // package.json도 필요
270
+ if (!fs.existsSync(path.join(tmp, 'package.json'))) {
271
+ fs.writeFileSync(path.join(tmp, 'package.json'), JSON.stringify({ name: 'e2e-test', version: '0.1.0' }));
272
+ }
273
+ const r = cp.spawnSync(process.execPath, [CLI, 'release', 'publish', tmp, '--dry-run'], { encoding: 'utf8' });
274
+ const ok = /Git remote \(origin\): test\/repo/.test(r.stdout);
275
+ console.log(ok ? '✓ B(1.9.10) detectGitRemote: github owner/repo 추출' : `✗ remote 감지 실패\n${r.stdout.slice(0, 500)}`);
276
+ if (!ok) failed++;
277
+ }
278
+
238
279
  // 1.9.8: rule add/list/pause/resume/remove
239
280
  total++;
240
281
  {