leerness 1.36.181 → 1.36.183

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.183 — 2026-08-31
4
+
5
+ - T-0163: English skill list now emits zero Hangul across stored, environment, and explicit locale paths while preserving Korean bytes and locale-independent JSON values. Adversarial external-skill metadata and injective ID rendering are covered, and the 39-command leakage ratchet tightens exactly from 34 to 30 lines.
6
+
7
+ ## 1.36.182 — 2026-08-31
8
+
9
+ - T-0162: English-mode milestones now contains zero Hangul across stored, environment, and explicit locale paths while preserving Korean and locale-independent JSON contracts. The 39-command aggregate leakage ratchet tightens exactly from 38 to 34 lines. External Codex review findings are covered by UTC-midnight-safe ETA comparison and real CLI coverage of the 500+ terminal branch.
10
+
3
11
  ## 1.36.181 — 2026-08-30
4
12
 
5
13
  - T-0161: English-mode release cadence, idempotency audit, plan list, and round-history now contain zero Hangul across stored, environment, and explicit locale paths. A dedicated edge-state probe preserves Korean output, legacy plan sentinels, locale-independent auto-fix JSON/state writes, and the 39-command leakage ratchet tightens exactly from 58 to 38 lines so improvements cannot mask regressions elsewhere.
package/README.md CHANGED
@@ -138,7 +138,7 @@ MIT
138
138
  <!-- leerness:project-readme:start -->
139
139
  ## Leerness Project Harness
140
140
 
141
- 이 프로젝트는 Leerness v1.36.181 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
141
+ 이 프로젝트는 Leerness v1.36.183 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
142
142
 
143
143
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
144
144
 
@@ -192,7 +192,7 @@ leerness memory restore decision <date|title>
192
192
 
193
193
  ### MCP server (외부 AI 통합)
194
194
 
195
- Leerness v1.36.181는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
195
+ Leerness v1.36.183는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
196
196
 
197
197
  ```jsonc
198
198
  // 카테고리별
@@ -213,7 +213,7 @@ Leerness v1.36.181는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
213
213
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
214
214
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
215
215
 
216
- 현재 누적: **v1.9.x → 1.36.181 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
216
+ 현재 누적: **v1.9.x → 1.36.183 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
217
217
 
218
218
  ### 성능 가이드
219
219
 
@@ -251,5 +251,5 @@ leerness release pack --close --auto-main-push
251
251
  - `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
252
252
  - `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
253
253
 
254
- Last synced by Leerness v1.36.181: 2026-08-31
254
+ Last synced by Leerness v1.36.183: 2026-08-31
255
255
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -49,7 +49,7 @@ const {
49
49
  migrateLegacyWorkspace,
50
50
  } = require('../lib/workspace-dir');
51
51
 
52
- const VERSION = '1.36.181';
52
+ const VERSION = '1.36.183';
53
53
 
54
54
  // MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
55
55
  // 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
@@ -129,6 +129,16 @@ const README_END = '<!-- leerness:project-readme:end -->';
129
129
 
130
130
  // 1.9.10: leerness-skillpack 동적 로드 (선택). 없으면 BUILTIN 사용.
131
131
  function _tryLoadSkillpack() {
132
+ // 0) Explicit path overrides ambient installs. Besides matching user intent,
133
+ // this keeps a pinned pack deterministic on hosts that also have another
134
+ // copy in cwd or the global npm root.
135
+ if (process.env.LEERNESS_SKILLPACK_PATH) {
136
+ try {
137
+ const f = path.resolve(process.env.LEERNESS_SKILLPACK_PATH);
138
+ const target = f.endsWith('.json') ? f : path.join(f, 'catalog.json');
139
+ if (fs.existsSync(target)) return { src: 'env', data: JSON.parse(fs.readFileSync(target, 'utf8')) };
140
+ } catch {}
141
+ }
132
142
  // 1) 정상 require resolution
133
143
  try { return { src: 'require', data: require('leerness-skillpack/catalog.json') }; } catch {}
134
144
  // 2) cwd/node_modules
@@ -146,14 +156,6 @@ function _tryLoadSkillpack() {
146
156
  if (fs.existsSync(f)) return { src: 'global', data: JSON.parse(fs.readFileSync(f, 'utf8')) };
147
157
  }
148
158
  } catch {}
149
- // 4) 환경변수 명시 경로
150
- if (process.env.LEERNESS_SKILLPACK_PATH) {
151
- try {
152
- const f = path.resolve(process.env.LEERNESS_SKILLPACK_PATH);
153
- const target = f.endsWith('.json') ? f : path.join(f, 'catalog.json');
154
- if (fs.existsSync(target)) return { src: 'env', data: JSON.parse(fs.readFileSync(target, 'utf8')) };
155
- } catch {}
156
- }
157
159
  return null;
158
160
  }
159
161
 
@@ -166,12 +168,17 @@ function _loadSkillCatalog() {
166
168
  SKILLPACK_META = { name: sp.data.name, version: sp.data.version };
167
169
  const out = {};
168
170
  for (const s of sp.data.skills) {
171
+ if (!s || typeof s !== 'object' || typeof s.id !== 'string' || !s.id) continue;
169
172
  out[s.id] = {
170
173
  displayNameKo: s.displayNameKo,
174
+ displayNameEn: s.displayNameEn,
175
+ displayName: s.displayName,
176
+ name: s.name,
171
177
  version: s.version,
172
178
  lastUpdated: s.lastUpdated,
173
179
  verification: s.verification,
174
180
  capabilities: s.capabilities,
181
+ capabilitiesEn: s.capabilitiesEn,
175
182
  _source: 'skillpack'
176
183
  };
177
184
  }
@@ -2321,6 +2328,81 @@ function _buildAllSkills(root) {
2321
2328
  }
2322
2329
  return out;
2323
2330
  }
2331
+ // English human output never guesses a translation. Prefer explicit English
2332
+ // metadata, accept already-English legacy metadata, then fall back to the
2333
+ // stable skill ID / an empty capability cell. Canonical JSON remains untouched.
2334
+ const _SKILL_HANGUL = /\p{Script=Hangul}/u;
2335
+ const _SKILL_LETTER = /\p{Letter}/u;
2336
+ const _SKILL_LATIN = /\p{Script=Latin}/u;
2337
+ function _skillHumanCell(value) {
2338
+ let text = '';
2339
+ try {
2340
+ if (typeof value === 'string') text = value;
2341
+ else if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') text = String(value);
2342
+ else {
2343
+ const encoded = JSON.stringify(value);
2344
+ text = typeof encoded === 'string' ? encoded : '';
2345
+ }
2346
+ } catch {}
2347
+ return text
2348
+ .replace(/[\u0000-\u001F\u007F-\u009F]+/g, ' ')
2349
+ .replace(/\|/g, '&#124;')
2350
+ .replace(/\s+/g, ' ')
2351
+ .trim();
2352
+ }
2353
+ // Korean mode keeps the legacy byte representation for valid scalar metadata.
2354
+ // Only malformed values need a total, non-throwing fallback; presentation
2355
+ // normalization belongs exclusively to the English table.
2356
+ function _skillKoreanValue(value) {
2357
+ if (typeof value === 'string') return value;
2358
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value);
2359
+ try {
2360
+ const encoded = JSON.stringify(value);
2361
+ return typeof encoded === 'string' ? encoded : '';
2362
+ } catch { return ''; }
2363
+ }
2364
+ function _skillOwnValue(object, key, fallback) {
2365
+ if (object == null || !Object.prototype.hasOwnProperty.call(Object(object), key)) return fallback;
2366
+ const value = object[key];
2367
+ // `undefined` is not representable in a source JSON document and would make
2368
+ // JSON.stringify omit the public key. Preserve every representable falsy or
2369
+ // object value, while retaining the canonical fallback for absent fields.
2370
+ return value === undefined ? fallback : value;
2371
+ }
2372
+ function _isEnglishSkillText(value) {
2373
+ const text = typeof value === 'string' ? value.trim() : '';
2374
+ return !!text && !_SKILL_HANGUL.test(text)
2375
+ && Array.from(text).every(char => !_SKILL_LETTER.test(char) || _SKILL_LATIN.test(char));
2376
+ }
2377
+ function _englishSafeSkillId(id) {
2378
+ const projected = Array.from(String(id)).map(char => {
2379
+ const codePoint = char.codePointAt(0);
2380
+ if (char === '\\') return '\\\\';
2381
+ // Spaces must be escaped too: _skillHumanCell intentionally collapses and
2382
+ // trims free text, which would otherwise make `a b`/`a b` and ` `/`-`
2383
+ // collide. The projected alphabet contains no character that sanitizer
2384
+ // rewrites, while doubled input backslashes distinguish literal escapes.
2385
+ if (codePoint >= 0x21 && codePoint <= 0x7E && char !== '|') return char;
2386
+ return codePoint <= 0xFFFF
2387
+ ? `\\u${codePoint.toString(16).toUpperCase().padStart(4, '0')}`
2388
+ : `\\u{${codePoint.toString(16).toUpperCase()}}`;
2389
+ }).join('');
2390
+ return _skillHumanCell(projected) || '-';
2391
+ }
2392
+ function _englishSkillText(candidates, fallback) {
2393
+ for (const candidate of candidates) {
2394
+ if (_isEnglishSkillText(candidate)) return _skillHumanCell(candidate);
2395
+ }
2396
+ return _skillHumanCell(fallback);
2397
+ }
2398
+ function _englishSkillCapabilities(skill) {
2399
+ const preferred = Array.isArray(skill.capabilitiesEn) ? skill.capabilitiesEn : [];
2400
+ const legacy = Array.isArray(skill.capabilities) ? skill.capabilities : [];
2401
+ const clean = values => values.filter(_isEnglishSkillText).map(_skillHumanCell).filter(Boolean);
2402
+ const translated = clean(preferred);
2403
+ return translated.length ? translated : clean(legacy);
2404
+ }
2405
+
2324
2406
  // 1.9.66: skill 추가/제거 시 캐시 invalidate (외부 helper)
2325
2407
  function skillList(root) {
2326
2408
  const all = listAllSkills(root);
@@ -2328,25 +2410,58 @@ function skillList(root) {
2328
2410
  if (has('--json')) {
2329
2411
  const items = Object.entries(all).map(([id, v]) => ({
2330
2412
  id,
2331
- displayNameKo: v.displayNameKo || id,
2413
+ displayNameKo: _skillOwnValue(v, 'displayNameKo', id),
2332
2414
  source: v._source,
2333
- capabilities: v.capabilities || [],
2334
- usageCount: v.usage?.count || 0,
2335
- lastUsed: v.usage?.lastUsed || null,
2336
- lastUpdated: v.lastUpdated || null
2415
+ capabilities: _skillOwnValue(v, 'capabilities', []),
2416
+ usageCount: _skillOwnValue(v.usage, 'count', 0),
2417
+ lastUsed: _skillOwnValue(v.usage, 'lastUsed', null),
2418
+ lastUpdated: _skillOwnValue(v, 'lastUpdated', null)
2337
2419
  }));
2338
2420
  log(JSON.stringify({ skillpack: SKILLPACK_SOURCE, total: items.length, items }, null, 2));
2339
2421
  return;
2340
2422
  }
2341
- if (SKILLPACK_SOURCE !== 'builtin') log(`# skillpack 출처: ${SKILLPACK_SOURCE}${SKILLPACK_META ? ` (${SKILLPACK_META.name} v${SKILLPACK_META.version})` : ''}`);
2342
- else log('# skillpack 미설치 — builtin fallback 사용 (leerness 본 패키지 내장 카탈로그)');
2343
- log('| ID | 한글명 | 출처 | 능력(요약) | 사용횟수 | 최종 |');
2423
+ const english = _uiLang(root) === 'en';
2424
+ if (SKILLPACK_SOURCE !== 'builtin') {
2425
+ const koreanMeta = SKILLPACK_META
2426
+ ? ` (${_skillKoreanValue(SKILLPACK_META.name)} v${_skillKoreanValue(SKILLPACK_META.version)})`
2427
+ : '';
2428
+ const englishMetaName = SKILLPACK_META ? _englishSkillText([SKILLPACK_META.name], '') : '';
2429
+ const englishMetaVersion = SKILLPACK_META ? _englishSkillText([SKILLPACK_META.version], '') : '';
2430
+ const englishMeta = englishMetaName
2431
+ ? ` (${englishMetaName}${englishMetaVersion ? ` v${englishMetaVersion}` : ''})`
2432
+ : '';
2433
+ log(english ? `# skillpack source: ${SKILLPACK_SOURCE}${englishMeta}` : `# skillpack 출처: ${SKILLPACK_SOURCE}${koreanMeta}`);
2434
+ } else {
2435
+ log(english
2436
+ ? '# skillpack not installed — using builtin fallback (catalog bundled with leerness)'
2437
+ : '# skillpack 미설치 — builtin fallback 사용 (leerness 본 패키지 내장 카탈로그)');
2438
+ }
2439
+ log(english
2440
+ ? '| ID | Name | Source | Capabilities (summary) | Uses | Last |'
2441
+ : '| ID | 한글명 | 출처 | 능력(요약) | 사용횟수 | 최종 |');
2344
2442
  log('|---|---|---|---|---|---|');
2345
2443
  for (const [id, v] of Object.entries(all)) {
2346
- const cap = (v.capabilities || []).slice(0, 3).join(' / ') + ((v.capabilities || []).length > 3 ? ' …' : '');
2347
- const usage = v.usage?.count || 0;
2348
- const last = v.usage?.lastUsed?.slice(0, 10) || v.lastUpdated || '-';
2349
- log(`| ${id} | ${v.displayNameKo || id} | ${v._source} | ${cap} | ${usage} | ${last} |`);
2444
+ const displayId = english ? _englishSafeSkillId(id) : id;
2445
+ const capabilities = english
2446
+ ? _englishSkillCapabilities(v)
2447
+ : (Array.isArray(v.capabilities) ? v.capabilities.filter(value => typeof value === 'string') : []);
2448
+ const cap = capabilities.length
2449
+ ? capabilities.slice(0, 3).join(' / ') + (capabilities.length > 3 ? ' …' : '')
2450
+ : (english ? '-' : '');
2451
+ const name = english
2452
+ ? _englishSkillText([v.displayNameEn, v.displayName, v.name, v.displayNameKo], displayId)
2453
+ : (typeof v.displayNameKo === 'string' && v.displayNameKo.trim() ? v.displayNameKo : id);
2454
+ const canonicalUsage = v.usage?.count;
2455
+ const usage = typeof canonicalUsage === 'number' && Number.isFinite(canonicalUsage) && canonicalUsage >= 0
2456
+ ? canonicalUsage
2457
+ : 0;
2458
+ const rawLastUsed = typeof v.usage?.lastUsed === 'string' ? v.usage.lastUsed.slice(0, 10) : null;
2459
+ const rawLastUpdated = typeof v.lastUpdated === 'string' ? v.lastUpdated : null;
2460
+ const canonicalLast = rawLastUsed || rawLastUpdated || '-';
2461
+ const last = english ? _englishSkillText([canonicalLast], '-') : canonicalLast;
2462
+ log(english
2463
+ ? `| ${_skillHumanCell(displayId)} | ${_skillHumanCell(name)} | ${_skillHumanCell(v._source)} | ${_skillHumanCell(cap)} | ${_skillHumanCell(usage)} | ${_skillHumanCell(last)} |`
2464
+ : `| ${displayId} | ${name} | ${v._source} | ${cap} | ${usage} | ${last} |`);
2350
2465
  }
2351
2466
  }
2352
2467
 
@@ -3408,6 +3523,7 @@ function _computeMilestones(root) {
3408
3523
 
3409
3524
  function milestonesCmd(root) {
3410
3525
  root = absRoot(root);
3526
+ const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
3411
3527
  const isTty = process.stdout && process.stdout.isTTY;
3412
3528
  const cy = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
3413
3529
  const gr = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
@@ -3416,29 +3532,31 @@ function milestonesCmd(root) {
3416
3532
  const data = _computeMilestones(root);
3417
3533
  if (data.gitHistoryState !== 'available') _throwGitHistoryUnavailable(data.gitHistoryError);
3418
3534
  if (has('--json')) { log(JSON.stringify(data, null, 2)); return; }
3419
- log(cy(`# leerness milestones (1.9.229) — 도달 마일스톤 + 다음 ETA`));
3535
+ log(cy(t(`# leerness milestones (1.9.229) — 도달 마일스톤 + 다음 ETA`, `# leerness milestones (1.9.229) — reached milestones + next ETA`)));
3420
3536
  log('');
3421
- log(` 📦 총 라운드: ${gr(String(data.totalRounds))}`);
3537
+ log(t(` 📦 총 라운드: ${gr(String(data.totalRounds))}`, ` 📦 total rounds: ${gr(String(data.totalRounds))}`));
3422
3538
  if (data.baselineAt) log(` 📍 baseline: ${(data.baselineAt || '').split(' ')[0]}`);
3423
- if (data.avgRoundsPerDay) log(` 📊 평균: ${gr(data.avgRoundsPerDay + ' rounds/day')}`);
3539
+ if (data.avgRoundsPerDay) log(t(` 📊 평균: ${gr(data.avgRoundsPerDay + ' rounds/day')}`, ` 📊 average: ${gr(data.avgRoundsPerDay + ' rounds/day')}`));
3424
3540
  log('');
3425
3541
  if (data.reached.length === 0) {
3426
- log(dm(` (도달 마일스톤 없음 — 첫 R25 마일스톤 진행 중)`));
3542
+ log(dm(t(` (도달 마일스톤 없음 — 첫 R25 마일스톤 진행 중)`, ` (no milestones reached — progressing toward the first R25 milestone)`)));
3427
3543
  } else {
3428
- log(` 도달 마일스톤 ${data.reached.length}개:`);
3544
+ log(t(` 도달 마일스톤 ${data.reached.length}개:`, ` ${data.reached.length} milestone${data.reached.length === 1 ? '' : 's'} reached:`));
3429
3545
  for (const m of data.reached) {
3430
3546
  log(` ✓ R${m.milestone} ${gr(`(v${m.version}, ${m.reachedAt}, +${m.daysFromBaseline}d)`)}`);
3431
3547
  }
3432
3548
  }
3433
3549
  log('');
3434
3550
  if (data.next) {
3435
- log(yl(` 🎯 다음 마일스톤: R${data.next.milestone}`));
3436
- log(` • ${data.next.roundsRemaining} 라운드 남음`);
3551
+ log(yl(t(` 🎯 다음 마일스톤: R${data.next.milestone}`, ` 🎯 next milestone: R${data.next.milestone}`)));
3552
+ log(t(` • ${data.next.roundsRemaining} 라운드 남음`, ` • ${data.next.roundsRemaining} rounds remaining`));
3437
3553
  if (data.next.etaDays != null) {
3438
- log(` • ETA: ${data.next.etaDate} (~${data.next.etaDays}일 후, 현재 속도 기준)`);
3554
+ log(t(` • ETA: ${data.next.etaDate} (~${data.next.etaDays}일 후, 현재 속도 기준)`, ` • ETA: ${data.next.etaDate} (~${data.next.etaDays} days from now, based on current pace)`));
3439
3555
  }
3440
3556
  } else {
3441
- log(data && (data.roundCount || data.totalRounds) ? gr(` 🎉 모든 마일스톤 달성 (500+)`) : dm(` (이 저장소 계보의 릴리스 태그 이력 없음)`)); // 1.36.38 (#4): 이력 0 인데 달성 축하 금지
3557
+ log(data && (data.roundCount || data.totalRounds)
3558
+ ? gr(t(` 🎉 모든 마일스톤 달성 (500+)`, ` 🎉 all milestones reached (500+)`))
3559
+ : dm(t(` (이 저장소 계보의 릴리스 태그 이력 없음)`, ` (no release-tag history for this repository lineage)`))); // 1.36.38 (#4): 이력 0 인데 달성 축하 금지
3442
3560
  }
3443
3561
  }
3444
3562
 
@@ -31494,7 +31612,7 @@ async function main() {
31494
31612
  if (cmd === 'consistency' && args[1] === 'check') return consistencyCheck(arg('--path', args[2] || process.cwd()));
31495
31613
  if (cmd === 'consistency' && args[1] === 'merge-design-guide') return mergeDesign(arg('--path', args[2] || process.cwd()));
31496
31614
  if (cmd === 'skill' && args[1] === 'lint') return skillLintCmd(arg('--path', args[2] && !args[2].startsWith('-') && /^([A-Za-z]:[\\/]|\/|\.\.?[\\/])/.test(args[2]) ? args[2] : process.cwd()), args[2] && !args[2].startsWith('-') && !/^([A-Za-z]:[\\/]|\/|\.\.?[\\/])/.test(args[2]) ? args[2] : null); // 1.36.26: 메타 품질 lint
31497
- if (cmd === 'skill' && args[1] === 'list') return skillList(args[2] || arg('--path', process.cwd()));
31615
+ if (cmd === 'skill' && args[1] === 'list') return skillList(_resolveRoot(args[2]));
31498
31616
  if (cmd === 'skill' && args[1] === 'info') return skillInfo(args[2], absRoot(arg('--path', process.cwd())));
31499
31617
  if (cmd === 'skill' && args[1] === 'add') return addSkill(absRoot(arg('--path', process.cwd())), args[2]);
31500
31618
  if (cmd === 'skill' && args[1] === 'learn') return skillLearn(absRoot(arg('--path', process.cwd())), args[2]);
package/lib/catalogs.js CHANGED
@@ -362,9 +362,9 @@ const STRINGS = {
362
362
 
363
363
  // 1.9.341 (UR-0025 심층): 내장 스킬 catalog (9종) — harness 에서 분리. _loadSkillCatalog 의 builtin fallback.
364
364
  const BUILTIN_CATALOG = {
365
- 'feature-implementation': { displayNameKo: '기능 구현 표준 스킬', description: '새 기능 구현을 시작할 때 사용 — feature-contract 작성, 재사용 우선 검사, 테스트 증거 수집이 필요한 구현 작업일 때. Use when starting to implement a new feature.', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'unverified', capabilities: ['feature-contracts 작성', '재사용 우선 검사', '테스트 증거 수집', '핸드오프 트리거'] },
365
+ 'feature-implementation': { displayNameKo: '기능 구현 표준 스킬', displayNameEn: 'Feature implementation standard skill', description: '새 기능 구현을 시작할 때 사용 — feature-contract 작성, 재사용 우선 검사, 테스트 증거 수집이 필요한 구현 작업일 때. Use when starting to implement a new feature.', version: '1.0.0', lastUpdated: '2026-05-08', verification: 'unverified', capabilities: ['feature-contracts 작성', '재사용 우선 검사', '테스트 증거 수집', '핸드오프 트리거'], capabilitiesEn: ['feature contract authoring', 'reuse-first checks', 'test evidence collection', 'handoff triggers'] },
366
366
  // 1.9.11: 기본 내장 — 로드맵 자동 생성 스킬
367
- 'project-roadmap-generator': { displayNameKo: '프로젝트 로드맵 자동 생성 스킬', description: '프로젝트 진행 상황을 로드맵 한 장으로 시각화해 달라고 할 때 사용 — .leerness 상태를 SVG 트리 HTML 로 뽑을 때. Use when the user asks for a visual roadmap.', version: '0.2.0', lastUpdated: '2026-05-12', verification: 'unverified', capabilities: ['leerness .leerness/* 통합 파싱 (plan/progress/skills/rules/decisions/handoff/current-state)', '좌→우 수평 트리 + 상하 중앙정렬 SVG', '7개 상태 색상 (완료/진행/보류/검토/예정/미완료/오류)', 'design-system + CSS variables 자동 주입', '화이트보드 panning/zoom + 더블클릭 reset', '단일 HTML 출력 (외부 의존성 0)'] }
367
+ 'project-roadmap-generator': { displayNameKo: '프로젝트 로드맵 자동 생성 스킬', displayNameEn: 'Project roadmap generator skill', description: '프로젝트 진행 상황을 로드맵 한 장으로 시각화해 달라고 할 때 사용 — .leerness 상태를 SVG 트리 HTML 로 뽑을 때. Use when the user asks for a visual roadmap.', version: '0.2.0', lastUpdated: '2026-05-12', verification: 'unverified', capabilities: ['leerness .leerness/* 통합 파싱 (plan/progress/skills/rules/decisions/handoff/current-state)', '좌→우 수평 트리 + 상하 중앙정렬 SVG', '7개 상태 색상 (완료/진행/보류/검토/예정/미완료/오류)', 'design-system + CSS variables 자동 주입', '화이트보드 panning/zoom + 더블클릭 reset', '단일 HTML 출력 (외부 의존성 0)'], capabilitiesEn: ['Parse integrated .leerness/* state (plan/progress/skills/rules/decisions/handoff/current-state)', 'Left-to-right SVG tree with vertical centering', 'Seven status colors (done/in progress/on hold/review/planned/incomplete/error)', 'Automatic design-system and CSS variable injection', 'Whiteboard pan/zoom and double-click reset', 'Single HTML output (zero external dependencies)'] }
368
368
  };
369
369
 
370
370
  // 1.9.342 (UR-0025 심층): roadmap.html 상태 라벨/색상 맵 (status → ko 라벨 / hex 색상) — harness 에서 분리.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.181",
3
+ "version": "1.36.183",
4
4
  "description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -10993,7 +10993,7 @@ total++;
10993
10993
  R(['task', 'add', 'Implement the parser']);
10994
10994
  R(['decision', 'add', 'Use JSON storage', '--reason', 'simplest']);
10995
10995
  R(['plan', 'add', 'Ship v1']);
10996
- const HANGUL = /[가-힣ㄱ-ㆎ]/;
10996
+ const HANGUL = /\p{Script=Hangul}/u;
10997
10997
  // 측정 대상은 **도구가 말하는 표면**이다. 사용자 데이터(프로젝트명·과거 기록)는 한국어여도 정상이므로
10998
10998
  // 여기서는 영어 입력만 넣은 새 프로젝트를 쓴다 — 나오는 한글은 전부 도구의 것이다.
10999
10999
  // codex 검수 P2(재현됨): 목록이 좁으면 누출이 **목록 밖으로 옮겨가** 래칫을 빠져나간다.
@@ -11006,6 +11006,19 @@ total++;
11006
11006
  ['provider', 'list'], ['skill', 'list'], ['next-action', 'list'], ['session-resume'],
11007
11007
  ['idempotency', 'audit'], ['context', 'budget'], ['drift', 'check'], ['verify'],
11008
11008
  ['encoding', 'check'], ['scan', 'secrets'], ['which'], ['glossary'], ['release', 'cadence']];
11009
+ // Freeze the measured residual per command as well as the total. Without
11010
+ // this vector, a +1 regression can consume an unrelated -1 improvement and
11011
+ // still satisfy the aggregate 30-line ratchet.
11012
+ const EXPECTED_LEAKS = new Map([
11013
+ ['commands', 0], ['status', 0], ['health', 0], ['pulse', 2], ['check', 0], ['mode', 3],
11014
+ ['toggle list', 0], ['task list', 0], ['decision list', 1], ['plan list', 0], ['lesson list', 1],
11015
+ ['rule list', 1], ['lens', 0], ['route feature', 0], ['milestones', 0], ['round-history', 0],
11016
+ ['memory status', 2], ['reuse-map', 2], ['preview list', 2], ['feature list', 2], ['requests list', 1],
11017
+ ['roles list', 3], ['permissions list', 3], ['insights', 0], ['agents list', 0], ['constraints list', 0],
11018
+ ['provider list', 0], ['skill list', 0], ['next-action list', 2], ['session-resume', 3],
11019
+ ['idempotency audit', 0], ['context budget', 0], ['drift check', 0], ['verify', 0],
11020
+ ['encoding check', 0], ['scan secrets', 1], ['which', 0], ['glossary', 1], ['release cadence', 0],
11021
+ ]);
11009
11022
  let leaky = 0, produced = 0; const worst = [], silent = [], measured = new Map();
11010
11023
  for (const a of CMDS) {
11011
11024
  const r = R(a);
@@ -11021,7 +11034,12 @@ total++;
11021
11034
  dbg.leaky = leaky; dbg.produced = produced; dbg.worst = worst.slice(0, 8); dbg.silent = silent;
11022
11035
  // 계측 판별력 — **모든** 프로브가 성공하고 출력을 내야 한다. 한 건이라도 조용하면 판정 불가다.
11023
11036
  dbg.probeAlive = silent.length === 0 && produced === CMDS.length;
11024
- dbg.coverage = CMDS.length >= 39; // 케이스를 지워 통과시키는 우회 차단(줄이려면 근거를 적고 이 수도 함께 낮춰라)
11037
+ const commandNames = CMDS.map(a => a.join(' '));
11038
+ dbg.coverage = CMDS.length === 39 && new Set(commandNames).size === 39
11039
+ && EXPECTED_LEAKS.size === 39 && commandNames.every(name => EXPECTED_LEAKS.has(name));
11040
+ dbg.vectorDrift = [...measured].filter(([name, count]) => EXPECTED_LEAKS.get(name) !== count)
11041
+ .map(([name, count]) => `${name}:${EXPECTED_LEAKS.get(name)}→${count}`);
11042
+ dbg.perCommandRatchet = dbg.vectorDrift.length === 0;
11025
11043
  // 래칫: 지금 측정된 부채보다 **늘면 실패**. 줄어드는 것은 언제나 통과.
11026
11044
  // 1.36.111 실측. 처음엔 21개 명령만 재서 46 이었는데, 검수 지적으로 커버리지를 39개로 넓히자 **111** 이 드러났다 —
11027
11045
  // 좁은 목록의 기준선은 실제 부채보다 낙관적이었다. 줄인 만큼만 낮춘다(느슨하게 두면 그 안에서 조용히 썩는다).
@@ -11030,7 +11048,9 @@ total++;
11030
11048
  // 1.36.174: provider list 영어 모드의 요약/안내 두 줄을 영어화해 104로 조임(39개 명령 실측).
11031
11049
  // 1.36.180: agents list(18) + insights(17) + toggle list(11) 우선 표면을 영어화해 58로 조임.
11032
11050
  // 1.36.181: 공동 최상위 release cadence/idempotency audit/plan list/round-history(각 5)를 영어화해 38로 조임.
11033
- const BASELINE = 38;
11051
+ // 1.36.182: milestones 빈 이력 표면 4줄을 영어화해 34로 조임. 도달/ETA 분기도 전용 probe가 고정한다.
11052
+ // 1.36.183: skill list 기본 카탈로그 4줄을 영어화해 30으로 조임. stored/env/flag·ko·JSON은 전용 probe가 고정한다.
11053
+ const BASELINE = 30;
11034
11054
  dbg.baseline = BASELINE;
11035
11055
  // 합계가 우연히 낮아진 뒤 다른 명령의 회귀가 그 여유를 소비하지 못하게 exact 로 잠근다.
11036
11056
  // 번역이 더 진행되면 실측값과 BASELINE을 같은 변경에서 함께 낮춰야 한다.
@@ -11038,7 +11058,7 @@ total++;
11038
11058
  // 이번 라운드가 고친 표면은 **0 이어야** 한다 — 래칫과 별개로 회귀를 직접 막는다.
11039
11059
  const cmdOut = String(R(['commands']).stdout || '');
11040
11060
  dbg.commandsClean = cmdOut.split('\n').filter(l => HANGUL.test(l)).length === 0;
11041
- dbg.nextClusterClean = ['release cadence', 'idempotency audit', 'plan list', 'round-history']
11061
+ dbg.nextClusterClean = ['release cadence', 'idempotency audit', 'plan list', 'round-history', 'milestones', 'skill list']
11042
11062
  .every(cmd => measured.get(cmd) === 0);
11043
11063
  // 한국어 사용자가 보던 것은 **바뀌면 안 된다**. 영어화하면서 `개` 단위를 양쪽에서 없애는 회귀를 냈고
11044
11064
  // 직전 커밋과의 바이트 비교가 잡았다. 여기서는 한국어 출력이 여전히 한국어 관례를 지키는지 단언한다.
@@ -11052,7 +11072,8 @@ total++;
11052
11072
  && Object.values(cj.categories).every(list => list.every(e => typeof e.cmd === 'string' && typeof e.desc === 'string'
11053
11073
  && e.descEn === undefined && e.cmdEn === undefined)) // 내부 번역 키가 payload 로 새면 70% 부풀었다(검수 P1 실측)
11054
11074
  && cj.lang === 'en'; // 로케일 의존을 **명시**한다 — 암묵적으로 흔들리지 않는다
11055
- ok = dbg.gitInit && dbg.probeAlive && dbg.coverage && dbg.exactRatchet && dbg.commandsClean && dbg.nextClusterClean && dbg.koIntact && dbg.jsonContract;
11075
+ ok = dbg.gitInit && dbg.probeAlive && dbg.coverage && dbg.exactRatchet && dbg.perCommandRatchet
11076
+ && dbg.commandsClean && dbg.nextClusterClean && dbg.koIntact && dbg.jsonContract;
11056
11077
  } catch (e) { dbg.err = String(e && e.message).slice(0, 200); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
11057
11078
  console.log(ok ? `✓ O(1.36.111/T-0092) 영어 누출 ${dbg.leaky}줄 = exact 래칫 ${dbg.baseline} · commands/next-cluster 표면 0 · 한국어 출력 불변 · --json 계약 유지 · 계측 살아있음`
11058
11079
  : '✗ 1.36.111 i18n 래칫 위반 ' + JSON.stringify(dbg));
@@ -6,7 +6,7 @@ const path = require('path');
6
6
  const cp = require('child_process');
7
7
 
8
8
  const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
9
- const HANGUL = /[가-힣ㄱ-ㆎ]/;
9
+ const HANGUL = /\p{Script=Hangul}/u;
10
10
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-i18n-next-'));
11
11
  const project = path.join(tmp, 'project');
12
12
 
@@ -21,22 +21,24 @@ const baseEnv = {
21
21
  LEERNESS_OFFLINE: '1',
22
22
  };
23
23
  delete baseEnv.LEERNESS_LANG;
24
+ delete baseEnv.LEERNESS_SKILLPACK_PATH;
24
25
 
25
- function run(args, env = baseEnv) {
26
+ function run(args, env = baseEnv, cwd = project) {
26
27
  return cp.spawnSync(process.execPath, [CLI, ...args], {
27
- cwd: project,
28
+ cwd,
28
29
  env,
29
30
  encoding: 'utf8',
30
31
  timeout: 300000,
31
32
  });
32
33
  }
33
34
 
34
- function git(args, extraEnv = {}) {
35
+ function git(args, extraEnv = {}, input) {
35
36
  return cp.spawnSync('git', args, {
36
37
  cwd: project,
37
38
  env: { ...baseEnv, ...extraEnv },
38
39
  encoding: 'utf8',
39
40
  timeout: 30000,
41
+ input,
40
42
  });
41
43
  }
42
44
 
@@ -78,6 +80,17 @@ function normalizedJson(label, result) {
78
80
  throw new Error(`${label} did not return one JSON document: ${error.message}`);
79
81
  }
80
82
  if (parsed && typeof parsed === 'object') delete parsed.auditedAt;
83
+ // Milestone ETA is intentionally based on the wall clock. English and Korean
84
+ // probes run in separate processes, so they can legitimately straddle UTC
85
+ // midnight. Validate the public date shape, then remove only that volatility
86
+ // before comparing the locale-independent payloads.
87
+ if (parsed && parsed.next && Object.prototype.hasOwnProperty.call(parsed.next, 'etaDate')) {
88
+ const etaDate = parsed.next.etaDate;
89
+ if (etaDate !== null && !/^\d{4}-\d{2}-\d{2}$/.test(String(etaDate))) {
90
+ throw new Error(`${label} returned an invalid milestone etaDate: ${etaDate}`);
91
+ }
92
+ if (etaDate !== null) parsed.next.etaDate = '<clock-derived-date>';
93
+ }
81
94
  return parsed;
82
95
  }
83
96
 
@@ -92,6 +105,8 @@ const surfaces = [
92
105
  ['idempotency audit', ['idempotency', 'audit', '--path', project]],
93
106
  ['plan list', ['plan', 'list', '--path', project]],
94
107
  ['round-history', ['round-history', '--path', project]],
108
+ ['milestones', ['milestones', '--path', project]],
109
+ ['skill list', ['skill', 'list', '--path', project]],
95
110
  ];
96
111
 
97
112
  try {
@@ -106,20 +121,237 @@ try {
106
121
  requireSuccess('decision add', run(['decision', 'add', 'Use JSON storage', '--reason', 'simplest', '--path', project]));
107
122
  requireSuccess('plan add', run(['plan', 'add', 'Ship the parser', '--path', project]));
108
123
 
124
+ // A user skill may predate English catalog fields or contain malformed
125
+ // legacy usage metadata. English mode must not fall back to Korean-only
126
+ // presentation fields, leak a non-numeric count, or call string methods on
127
+ // a numeric timestamp. JSON keeps those canonical source values unchanged.
128
+ const koreanOnlySkillDir = path.join(project, '.leerness', 'skills', 'korean-only');
129
+ fs.mkdirSync(koreanOnlySkillDir, { recursive: true });
130
+ fs.writeFileSync(path.join(koreanOnlySkillDir, 'skill.json'), JSON.stringify({
131
+ name: 'korean-only',
132
+ displayNameKo: '한국어 전용 사용자 스킬',
133
+ displayNameEn: 'ㄴ invalid English label',
134
+ version: '1.0.0',
135
+ lastUpdated: '마지막',
136
+ verification: 'unverified',
137
+ capabilities: ['한국어 기능 설명'],
138
+ capabilitiesEn: ['ㄷ invalid English capability'],
139
+ usage: { count: '한국어 횟수', lastUsed: 1725062400000 },
140
+ }, null, 2) + '\n');
141
+ const unsafeCellsSkillDir = path.join(project, '.leerness', 'skills', 'unsafe-cells');
142
+ fs.mkdirSync(unsafeCellsSkillDir, { recursive: true });
143
+ fs.writeFileSync(path.join(unsafeCellsSkillDir, 'skill.json'), JSON.stringify({
144
+ name: 'unsafe-cells',
145
+ displayNameKo: '안전하지 않은 셀',
146
+ displayNameEn: 'Safe name\n| forged-name |',
147
+ lastUpdated: '2026-01-01\n| forged-last |',
148
+ capabilities: ['한국어 기능 설명'],
149
+ capabilitiesEn: ['safe | capability\ncontinued'],
150
+ usage: { count: '', lastUsed: 0 },
151
+ }, null, 2) + '\n');
152
+ const falsyJsonSkillDir = path.join(project, '.leerness', 'skills', 'falsy-json');
153
+ fs.mkdirSync(falsyJsonSkillDir, { recursive: true });
154
+ fs.writeFileSync(path.join(falsyJsonSkillDir, 'skill.json'), JSON.stringify({
155
+ name: 'falsy-json',
156
+ displayNameKo: '',
157
+ lastUpdated: false,
158
+ capabilities: false,
159
+ usage: { count: null, lastUsed: false },
160
+ }, null, 2) + '\n');
161
+ const hostileObjectSkillDir = path.join(project, '.leerness', 'skills', 'hostile-object');
162
+ fs.mkdirSync(hostileObjectSkillDir, { recursive: true });
163
+ fs.writeFileSync(path.join(hostileObjectSkillDir, 'skill.json'), JSON.stringify({
164
+ name: 'hostile-object',
165
+ displayNameKo: { toString: null },
166
+ lastUpdated: { toString: null },
167
+ capabilities: [{ toString: null }],
168
+ usage: { count: { toString: null }, lastUsed: { toString: null } },
169
+ }, null, 2) + '\n');
170
+ const hangulIdSkillDir = path.join(project, '.leerness', 'skills', '옛스킬');
171
+ fs.mkdirSync(hangulIdSkillDir, { recursive: true });
172
+ fs.writeFileSync(path.join(hangulIdSkillDir, 'skill.json'), JSON.stringify({
173
+ name: '옛스킬',
174
+ displayNameKo: '옛 사용자 스킬',
175
+ lastUpdated: '마지막',
176
+ capabilities: ['한국어 기능 설명'],
177
+ usage: { count: 0, lastUsed: null },
178
+ }, null, 2) + '\n');
179
+
180
+ const manifestFile = path.join(project, '.leerness', 'manifest.json');
181
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
182
+
109
183
  // Stored project language, environment override, and explicit flag must all
110
184
  // reach the same English renderer. Flip the stored language to Korean before
111
185
  // checking overrides so a disconnected env/flag path cannot pass by falling
112
186
  // back to the already-English manifest.
187
+ const storedEnglish = new Map();
113
188
  for (const [label, args] of surfaces) {
114
- requireNoHangul(`${label} (stored language)`, run(args));
189
+ storedEnglish.set(label, requireNoHangul(`${label} (stored language)`, run(args)));
115
190
  }
116
- const manifestFile = path.join(project, '.leerness', 'manifest.json');
117
- const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
191
+ const storedSkillList = storedEnglish.get('skill list');
192
+ const skillEnglishAnchors = [
193
+ '# skillpack not installed — using builtin fallback (catalog bundled with leerness)',
194
+ '| ID | Name | Source | Capabilities (summary) | Uses | Last |',
195
+ '| feature-implementation | Feature implementation standard skill | catalog+local | feature contract authoring / reuse-first checks / test evidence collection … |',
196
+ '| project-roadmap-generator | Project roadmap generator skill | catalog+local | Parse integrated .leerness/* state (plan/progress/skills/rules/decisions/handoff/current-state) / Left-to-right SVG tree with vertical centering / Seven status colors (done/in progress/on hold/review/planned/incomplete/error) … |',
197
+ '| korean-only | korean-only | user | - | 0 | - |',
198
+ '| unsafe-cells | Safe name &#124; forged-name &#124; | user | safe &#124; capability continued | 0 | 2026-01-01 &#124; forged-last &#124; |',
199
+ '| falsy-json | falsy-json | user | - | 0 | - |',
200
+ '| hostile-object | hostile-object | user | - | 0 | - |',
201
+ '| \\uC61B\\uC2A4\\uD0AC | \\uC61B\\uC2A4\\uD0AC | user | - | 0 | - |',
202
+ ];
203
+ for (const anchor of skillEnglishAnchors) {
204
+ if (!storedSkillList.includes(anchor)) throw new Error(`skill list English output lost anchor: ${anchor}`);
205
+ }
206
+ if (storedSkillList.split(/\r?\n/).some(line => /^\|\s*forged-/.test(line))) {
207
+ throw new Error('skill list English metadata forged a physical Markdown row');
208
+ }
209
+ // A real stored-Korean route prevents a default-English implementation from
210
+ // passing the stored-locale test accidentally. Run outside the target cwd so
211
+ // the locale must come from --path rather than cwd.
212
+ manifest.language = 'ko';
213
+ fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n');
214
+ const storedKoreanSkillList = requireSuccess('skill list stored Korean control', run(
215
+ ['skill', 'list', '--path', project], baseEnv, tmp
216
+ ));
217
+ if (!storedKoreanSkillList.includes('| ID | 한글명 | 출처 | 능력(요약) | 사용횟수 | 최종 |')) {
218
+ throw new Error('skill list ignored the stored Korean locale selected through --path');
219
+ }
220
+ manifest.language = 'en';
221
+ fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n');
222
+ // --path must outrank a conflicting positional root when resolving the
223
+ // stored locale. The pre-fix dispatcher passed args[2] straight through.
224
+ const koreanPositionalProject = path.join(tmp, 'korean-positional-project');
225
+ fs.mkdirSync(koreanPositionalProject, { recursive: true });
226
+ fs.writeFileSync(path.join(koreanPositionalProject, 'package.json'), '{"name":"korean-positional","version":"0.1.0"}\n');
227
+ requireSuccess('init Korean positional project', run(['init', koreanPositionalProject, '--yes', '--language', 'ko']));
228
+ requireNoHangul('skill list --path precedence', run(
229
+ ['skill', 'list', koreanPositionalProject, '--path', project], baseEnv, tmp
230
+ ));
231
+
232
+ // A separately loaded skillpack exercises the loader projection, including
233
+ // explicit English fields, legacy English fields, non-Latin text, complete
234
+ // Hangul-script coverage, and Markdown control-character sanitation.
235
+ const ambientSkillpackDir = path.join(project, 'node_modules', 'leerness-skillpack');
236
+ fs.mkdirSync(ambientSkillpackDir, { recursive: true });
237
+ fs.writeFileSync(path.join(ambientSkillpackDir, 'catalog.json'), JSON.stringify({
238
+ name: 'Ambient pack', version: '0.0.0', skills: [{ id: 'ambient-pack', displayNameEn: 'Ambient pack' }],
239
+ }, null, 2) + '\n');
240
+ const skillpackDir = path.join(tmp, 'skillpack');
241
+ fs.mkdirSync(skillpackDir, { recursive: true });
242
+ const nfdHangul = '한글'.normalize('NFD');
243
+ const literalEscapeId = String.raw`\uC61B`;
244
+ fs.writeFileSync(path.join(skillpackDir, 'catalog.json'), JSON.stringify({
245
+ name: 'External pack\n| forged-pack |',
246
+ version: { toString: null },
247
+ skills: [
248
+ { id: 'translated-pack', displayNameKo: '번역 스킬', displayNameEn: 'Translated pack skill', capabilities: ['한국어 기능'], capabilitiesEn: ['English capability'] },
249
+ { id: 'legacy-pack', displayNameKo: '레거시 스킬', displayName: 'Legacy pack name', capabilities: ['Legacy capability'] },
250
+ { id: 'legacy-name-pack', displayNameKo: '레거시 이름 스킬', name: 'Legacy name pack', capabilities: ['Legacy name capability'] },
251
+ { id: 'japanese-pack', displayNameKo: '日本語名', capabilities: ['日本語機能'] },
252
+ { id: 'jamo-pack', displayNameKo: '자모 스킬', displayNameEn: 'ㄴ invalid label', capabilities: ['한국어 기능'], capabilitiesEn: ['ㄷ invalid capability'] },
253
+ { id: nfdHangul, displayNameKo: '분해 자모 스킬', displayNameEn: nfdHangul, capabilities: ['한국어 기능'], capabilitiesEn: [nfdHangul] },
254
+ { id: 'a|b', displayNameEn: 'Pipe ID' },
255
+ { id: 'a&#124;b', displayNameEn: 'Literal entity ID' },
256
+ { id: 'a b', displayNameEn: 'Single-space ID' },
257
+ { id: 'a b', displayNameEn: 'Double-space ID' },
258
+ { id: ' ', displayNameEn: 'Space-only ID' },
259
+ { id: '-', displayNameEn: 'Dash ID' },
260
+ { id: literalEscapeId, displayNameEn: 'Literal escape ID' },
261
+ { id: '옛', displayNameEn: 'Hangul ID' },
262
+ null,
263
+ { id: { toString: null }, displayNameEn: 'Malformed ID' },
264
+ ],
265
+ }, null, 2) + '\n');
266
+ const skillpackOutput = requireNoHangul('skill list external skillpack', run(
267
+ ['skill', 'list', '--path', project],
268
+ { ...baseEnv, LEERNESS_LANG: 'en', LEERNESS_SKILLPACK_PATH: skillpackDir }
269
+ ));
270
+ for (const anchor of [
271
+ '# skillpack source: env (External pack &#124; forged-pack &#124;)',
272
+ '| translated-pack | Translated pack skill | skillpack | English capability |',
273
+ '| legacy-pack | Legacy pack name | skillpack | Legacy capability |',
274
+ '| legacy-name-pack | Legacy name pack | skillpack | Legacy name capability |',
275
+ '| japanese-pack | japanese-pack | skillpack | - |',
276
+ '| jamo-pack | jamo-pack | skillpack | - |',
277
+ '| \\u1112\\u1161\\u11AB\\u1100\\u1173\\u11AF | \\u1112\\u1161\\u11AB\\u1100\\u1173\\u11AF | skillpack | - |',
278
+ '| a\\u007Cb | Pipe ID | skillpack | - |',
279
+ '| a&#124;b | Literal entity ID | skillpack | - |',
280
+ '| a\\u0020b | Single-space ID | skillpack | - |',
281
+ '| a\\u0020\\u0020b | Double-space ID | skillpack | - |',
282
+ '| \\u0020 | Space-only ID | skillpack | - |',
283
+ '| - | Dash ID | skillpack | - |',
284
+ '| \\\\uC61B | Literal escape ID | skillpack | - |',
285
+ '| \\uC61B | Hangul ID | skillpack | - |',
286
+ ]) {
287
+ if (!skillpackOutput.includes(anchor)) throw new Error(`skill list external skillpack lost anchor: ${anchor}`);
288
+ }
289
+ if (skillpackOutput.includes('日本語') || skillpackOutput.split(/\r?\n/).some(line => /^\|\s*forged-/.test(line))) {
290
+ throw new Error('skill list external metadata bypassed English fallback or forged a Markdown row');
291
+ }
292
+ const skillpackJsonEnv = normalizedJson('skill list external skillpack environment JSON', run(
293
+ ['skill', 'list', '--path', project, '--json'],
294
+ { ...baseEnv, LEERNESS_LANG: 'en', LEERNESS_SKILLPACK_PATH: skillpackDir }, tmp
295
+ ));
296
+ const skillpackJsonExplicit = normalizedJson('skill list external skillpack explicit JSON', run(
297
+ ['skill', 'list', '--path', project, '--json', '--language', 'en'],
298
+ { ...baseEnv, LEERNESS_LANG: 'ko', LEERNESS_SKILLPACK_PATH: skillpackDir }, tmp
299
+ ));
300
+ const skillpackJsonKorean = normalizedJson('skill list external skillpack Korean JSON', run(
301
+ ['skill', 'list', '--path', project, '--json'],
302
+ { ...baseEnv, LEERNESS_LANG: 'ko', LEERNESS_SKILLPACK_PATH: skillpackDir }, tmp
303
+ ));
304
+ const canonicalSkillKeys = ['capabilities', 'displayNameKo', 'id', 'lastUpdated', 'lastUsed', 'source', 'usageCount'];
305
+ const externalTranslatedJson = skillpackJsonEnv.items && skillpackJsonEnv.items.find(item => item.id === 'translated-pack');
306
+ if (JSON.stringify(skillpackJsonEnv) !== JSON.stringify(skillpackJsonExplicit)
307
+ || JSON.stringify(skillpackJsonEnv) !== JSON.stringify(skillpackJsonKorean)
308
+ || skillpackJsonEnv.skillpack !== 'env'
309
+ || !skillpackJsonEnv.items.every(item => JSON.stringify(Object.keys(item).sort()) === JSON.stringify(canonicalSkillKeys))
310
+ || skillpackJsonEnv.items.some(item => item.id === 'ambient-pack' || typeof item.id !== 'string')
311
+ || !skillpackJsonEnv.items.some(item => item.id === 'a|b')
312
+ || !skillpackJsonEnv.items.some(item => item.id === 'a b')
313
+ || !skillpackJsonEnv.items.some(item => item.id === 'a b')
314
+ || !skillpackJsonEnv.items.some(item => item.id === ' ')
315
+ || !skillpackJsonEnv.items.some(item => item.id === '-')
316
+ || !skillpackJsonEnv.items.some(item => item.id === literalEscapeId)
317
+ || !externalTranslatedJson || externalTranslatedJson.displayNameKo !== '번역 스킬'
318
+ || externalTranslatedJson.capabilities[0] !== '한국어 기능') {
319
+ throw new Error('skill list external skillpack JSON changed across locale routes or exposed presentation-only fields');
320
+ }
321
+ // Valid Korean free text is a compatibility surface: unlike the English
322
+ // Markdown-safe projection it must retain repeated spaces byte-for-byte.
323
+ // Keep an ambient pack present so this also exercises explicit env priority.
324
+ const koreanBytePackDir = path.join(tmp, 'korean-byte-pack');
325
+ fs.mkdirSync(koreanBytePackDir, { recursive: true });
326
+ fs.writeFileSync(path.join(koreanBytePackDir, 'catalog.json'), JSON.stringify({
327
+ name: '외부 팩',
328
+ version: '1 2',
329
+ skills: [{
330
+ id: 'ko-space-pack',
331
+ displayNameKo: '가 나',
332
+ capabilities: ['첫 기능'],
333
+ lastUpdated: '2026 08',
334
+ }],
335
+ }, null, 2) + '\n');
336
+ const koreanByteOutput = requireSuccess('skill list external skillpack Korean byte preservation', run(
337
+ ['skill', 'list', '--path', project],
338
+ { ...baseEnv, LEERNESS_LANG: 'ko', LEERNESS_SKILLPACK_PATH: koreanBytePackDir }, tmp
339
+ ));
340
+ for (const anchor of [
341
+ '# skillpack 출처: env (외부 팩 v1 2)',
342
+ '| ko-space-pack | 가 나 | skillpack | 첫 기능 | 0 | 2026 08 |',
343
+ ]) {
344
+ if (!koreanByteOutput.includes(anchor)) throw new Error(`skill list Korean valid metadata changed bytes: ${anchor}`);
345
+ }
346
+ fs.rmSync(path.join(project, 'node_modules'), { recursive: true, force: true });
118
347
  manifest.language = 'ko';
119
348
  fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n');
120
349
  for (const [label, args] of surfaces) {
121
- requireNoHangul(`${label} (environment language)`, run(args, { ...baseEnv, LEERNESS_LANG: 'en' }));
122
- requireNoHangul(`${label} (explicit language)`, run([...args, '--language', 'en'], { ...baseEnv, LEERNESS_LANG: 'ko' }));
350
+ const envEnglish = requireNoHangul(`${label} (environment language)`, run(args, { ...baseEnv, LEERNESS_LANG: 'en' }));
351
+ const explicitEnglish = requireNoHangul(`${label} (explicit language)`, run([...args, '--language', 'en'], { ...baseEnv, LEERNESS_LANG: 'ko' }));
352
+ if (label === 'skill list' && (envEnglish !== storedSkillList || explicitEnglish !== storedSkillList)) {
353
+ throw new Error('skill list English output differs across stored, environment, or explicit locale routes');
354
+ }
123
355
  }
124
356
  manifest.language = 'en';
125
357
  fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n');
@@ -206,8 +438,19 @@ try {
206
438
  requireGitSuccess('git tag second snapshot', git(['tag', '-a', 'v1.0.1', '-m', 'v1.0.1'], {
207
439
  GIT_COMMITTER_DATE: secondDate,
208
440
  }));
441
+ // Reach R25 without manufacturing another 23 commits. Annotated tags have
442
+ // independent creation times, which is exactly the evidence milestones
443
+ // consumes, and exercise reached/next/ETA rendering in one small fixture.
444
+ for (let i = 2; i < 25; i++) {
445
+ const version = `v1.0.${i}`;
446
+ const tagDate = `2026-08-29T00:${String(i).padStart(2, '0')}:00+09:00`;
447
+ requireGitSuccess(`git tag ${version}`, git(['tag', '-a', version, '-m', version], {
448
+ GIT_COMMITTER_DATE: tagDate,
449
+ }));
450
+ }
209
451
  requireNoHangul('release cadence with measured history', run(['release', 'cadence', '--path', project]));
210
452
  requireNoHangul('round-history with tags', run(['round-history', '--path', project]));
453
+ requireNoHangul('milestones with reached history', run(['milestones', '--path', project]));
211
454
 
212
455
  // Machine payloads remain the canonical, locale-independent contract. The
213
456
  // idempotency timestamp is intentionally volatile and is normalized only
@@ -219,6 +462,27 @@ try {
219
462
  throw new Error(`${label} JSON contract changed with UI language`);
220
463
  }
221
464
  }
465
+ const skillJson = normalizedJson('skill list canonical JSON shape', run(['skill', 'list', '--path', project, '--json']));
466
+ const koreanOnlyJson = skillJson.items && skillJson.items.find(item => item.id === 'korean-only');
467
+ const unsafeCellsJson = skillJson.items && skillJson.items.find(item => item.id === 'unsafe-cells');
468
+ const falsyJson = skillJson.items && skillJson.items.find(item => item.id === 'falsy-json');
469
+ const hostileObjectJson = skillJson.items && skillJson.items.find(item => item.id === 'hostile-object');
470
+ const featureJson = skillJson.items && skillJson.items.find(item => item.id === 'feature-implementation');
471
+ const roadmapJson = skillJson.items && skillJson.items.find(item => item.id === 'project-roadmap-generator');
472
+ if (!skillJson || skillJson.skillpack !== 'builtin' || skillJson.total !== skillJson.items.length
473
+ || !koreanOnlyJson || koreanOnlyJson.displayNameKo !== '한국어 전용 사용자 스킬'
474
+ || koreanOnlyJson.capabilities[0] !== '한국어 기능 설명' || koreanOnlyJson.lastUpdated !== '마지막'
475
+ || koreanOnlyJson.usageCount !== '한국어 횟수' || koreanOnlyJson.lastUsed !== 1725062400000
476
+ || !unsafeCellsJson || unsafeCellsJson.usageCount !== '' || unsafeCellsJson.lastUsed !== 0
477
+ || !falsyJson || falsyJson.displayNameKo !== '' || falsyJson.capabilities !== false
478
+ || falsyJson.usageCount !== null || falsyJson.lastUsed !== false || falsyJson.lastUpdated !== false
479
+ || !hostileObjectJson || hostileObjectJson.usageCount.toString !== null
480
+ || hostileObjectJson.lastUsed.toString !== null || hostileObjectJson.lastUpdated.toString !== null
481
+ || !featureJson || featureJson.displayNameKo !== '기능 구현 표준 스킬' || featureJson.capabilities[0] !== 'feature-contracts 작성'
482
+ || !roadmapJson || roadmapJson.displayNameKo !== '프로젝트 로드맵 자동 생성 스킬' || !roadmapJson.capabilities[1].includes('좌→우 수평 트리')
483
+ || !skillJson.items.every(item => JSON.stringify(Object.keys(item).sort()) === JSON.stringify(canonicalSkillKeys))) {
484
+ throw new Error('skill list JSON no longer exposes the canonical locale-independent shape');
485
+ }
222
486
 
223
487
  const koEnv = { ...baseEnv, LEERNESS_LANG: 'ko' };
224
488
  const koreanAnchors = new Map([
@@ -226,6 +490,8 @@ try {
226
490
  ['idempotency audit', ['위반 발견', '중복 룰']],
227
491
  ['plan list', ['완료기준(Done-When)', 'Tasks:', '완료)']],
228
492
  ['round-history', ['자율 라운드 통계', '누적 라운드', '최근 10 tags']],
493
+ ['milestones', ['도달 마일스톤', '총 라운드', '다음 마일스톤', '라운드 남음']],
494
+ ['skill list', ['skillpack 미설치', '| ID | 한글명 | 출처 | 능력(요약) | 사용횟수 | 최종 |', '| feature-implementation | 기능 구현 표준 스킬 | catalog+local | feature-contracts 작성 / 재사용 우선 검사 / 테스트 증거 수집 … |', '| project-roadmap-generator | 프로젝트 로드맵 자동 생성 스킬 | catalog+local | leerness .leerness/* 통합 파싱 (plan/progress/skills/rules/decisions/handoff/current-state) / 좌→우 수평 트리 + 상하 중앙정렬 SVG / 7개 상태 색상 (완료/진행/보류/검토/예정/미완료/오류) … |', '한국어 전용 사용자 스킬', '옛 사용자 스킬', '| hostile-object | hostile-object | user | | 0 | - |']],
229
495
  ]);
230
496
  for (const [label, args] of surfaces) {
231
497
  const output = requireSuccess(`${label} Korean control`, run(args, koEnv));
@@ -233,7 +499,30 @@ try {
233
499
  if (missing.length) throw new Error(`${label} Korean control lost anchors: ${missing.join(', ')}`);
234
500
  }
235
501
 
236
- console.log('✓ Four next-cluster English surfaces contain no Hangul across locale paths and edge states; 4/4 Korean controls and JSON contracts remain intact');
502
+ // Exercise the terminal 500+ renderer without spawning another 475 `git tag`
503
+ // processes. Lightweight semver refs are valid release tags and one atomic
504
+ // update-ref process gives the real CLI a 500-tag history.
505
+ const headResult = git(['rev-parse', 'HEAD']);
506
+ requireGitSuccess('git resolve terminal milestone fixture', headResult);
507
+ const head = String(headResult.stdout || '').trim();
508
+ const refs = [];
509
+ for (let i = 25; i < 500; i++) refs.push(`create refs/tags/v1.0.${i} ${head}`);
510
+ requireGitSuccess('git create terminal milestone fixture', git(['update-ref', '--stdin'], {}, refs.join('\n') + '\n'));
511
+ const terminalEn = requireNoHangul('milestones all reached (English)', run(['milestones', '--path', project, '--language', 'en']));
512
+ if (!terminalEn.includes('all milestones reached (500+)')) {
513
+ throw new Error('milestones English terminal branch lost its 500+ completion marker');
514
+ }
515
+ const terminalKo = requireSuccess('milestones all reached (Korean)', run(['milestones', '--path', project], koEnv));
516
+ if (!terminalKo.includes('모든 마일스톤 달성 (500+)')) {
517
+ throw new Error('milestones Korean terminal branch lost its 500+ completion marker');
518
+ }
519
+ const terminalJson = normalizedJson('milestones terminal JSON', run(['milestones', '--path', project, '--json']));
520
+ if (terminalJson.totalRounds !== 500 || terminalJson.next !== null
521
+ || !Array.isArray(terminalJson.reached) || terminalJson.reached.at(-1)?.milestone !== 500) {
522
+ throw new Error('milestones terminal JSON did not preserve the 500-round contract');
523
+ }
524
+
525
+ console.log('✓ Six next-cluster English surfaces contain no Hangul across locale paths and edge states; 6/6 Korean controls and JSON contracts remain intact');
237
526
  } catch (error) {
238
527
  console.error(`✗ ${error && error.message ? error.message : error}`);
239
528
  process.exitCode = 1;