leerness 1.36.182 → 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,9 @@
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
+
3
7
  ## 1.36.182 — 2026-08-31
4
8
 
5
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.
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.182 하네스를 사용합니다. 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.182는 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.182는 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.182 릴리스 태그 이력** (수백 라운드) · _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.182: 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.182';
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
 
@@ -31497,7 +31612,7 @@ async function main() {
31497
31612
  if (cmd === 'consistency' && args[1] === 'check') return consistencyCheck(arg('--path', args[2] || process.cwd()));
31498
31613
  if (cmd === 'consistency' && args[1] === 'merge-design-guide') return mergeDesign(arg('--path', args[2] || process.cwd()));
31499
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
31500
- 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]));
31501
31616
  if (cmd === 'skill' && args[1] === 'info') return skillInfo(args[2], absRoot(arg('--path', process.cwd())));
31502
31617
  if (cmd === 'skill' && args[1] === 'add') return addSkill(absRoot(arg('--path', process.cwd())), args[2]);
31503
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.182",
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
  // 좁은 목록의 기준선은 실제 부채보다 낙관적이었다. 줄인 만큼만 낮춘다(느슨하게 두면 그 안에서 조용히 썩는다).
@@ -11031,7 +11049,8 @@ total++;
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
11051
  // 1.36.182: milestones 빈 이력 표면 4줄을 영어화해 34로 조임. 도달/ETA 분기도 전용 probe가 고정한다.
11034
- const BASELINE = 34;
11052
+ // 1.36.183: skill list 기본 카탈로그 4줄을 영어화해 30으로 조임. stored/env/flag·ko·JSON은 전용 probe가 고정한다.
11053
+ const BASELINE = 30;
11035
11054
  dbg.baseline = BASELINE;
11036
11055
  // 합계가 우연히 낮아진 뒤 다른 명령의 회귀가 그 여유를 소비하지 못하게 exact 로 잠근다.
11037
11056
  // 번역이 더 진행되면 실측값과 BASELINE을 같은 변경에서 함께 낮춰야 한다.
@@ -11039,7 +11058,7 @@ total++;
11039
11058
  // 이번 라운드가 고친 표면은 **0 이어야** 한다 — 래칫과 별개로 회귀를 직접 막는다.
11040
11059
  const cmdOut = String(R(['commands']).stdout || '');
11041
11060
  dbg.commandsClean = cmdOut.split('\n').filter(l => HANGUL.test(l)).length === 0;
11042
- dbg.nextClusterClean = ['release cadence', 'idempotency audit', 'plan list', 'round-history', 'milestones']
11061
+ dbg.nextClusterClean = ['release cadence', 'idempotency audit', 'plan list', 'round-history', 'milestones', 'skill list']
11043
11062
  .every(cmd => measured.get(cmd) === 0);
11044
11063
  // 한국어 사용자가 보던 것은 **바뀌면 안 된다**. 영어화하면서 `개` 단위를 양쪽에서 없애는 회귀를 냈고
11045
11064
  // 직전 커밋과의 바이트 비교가 잡았다. 여기서는 한국어 출력이 여전히 한국어 관례를 지키는지 단언한다.
@@ -11053,7 +11072,8 @@ total++;
11053
11072
  && Object.values(cj.categories).every(list => list.every(e => typeof e.cmd === 'string' && typeof e.desc === 'string'
11054
11073
  && e.descEn === undefined && e.cmdEn === undefined)) // 내부 번역 키가 payload 로 새면 70% 부풀었다(검수 P1 실측)
11055
11074
  && cj.lang === 'en'; // 로케일 의존을 **명시**한다 — 암묵적으로 흔들리지 않는다
11056
- 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;
11057
11077
  } catch (e) { dbg.err = String(e && e.message).slice(0, 200); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
11058
11078
  console.log(ok ? `✓ O(1.36.111/T-0092) 영어 누출 ${dbg.leaky}줄 = exact 래칫 ${dbg.baseline} · commands/next-cluster 표면 0 · 한국어 출력 불변 · --json 계약 유지 · 계측 살아있음`
11059
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,10 +21,11 @@ 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,
@@ -105,6 +106,7 @@ const surfaces = [
105
106
  ['plan list', ['plan', 'list', '--path', project]],
106
107
  ['round-history', ['round-history', '--path', project]],
107
108
  ['milestones', ['milestones', '--path', project]],
109
+ ['skill list', ['skill', 'list', '--path', project]],
108
110
  ];
109
111
 
110
112
  try {
@@ -119,20 +121,237 @@ try {
119
121
  requireSuccess('decision add', run(['decision', 'add', 'Use JSON storage', '--reason', 'simplest', '--path', project]));
120
122
  requireSuccess('plan add', run(['plan', 'add', 'Ship the parser', '--path', project]));
121
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
+
122
183
  // Stored project language, environment override, and explicit flag must all
123
184
  // reach the same English renderer. Flip the stored language to Korean before
124
185
  // checking overrides so a disconnected env/flag path cannot pass by falling
125
186
  // back to the already-English manifest.
187
+ const storedEnglish = new Map();
126
188
  for (const [label, args] of surfaces) {
127
- requireNoHangul(`${label} (stored language)`, run(args));
189
+ storedEnglish.set(label, requireNoHangul(`${label} (stored language)`, run(args)));
128
190
  }
129
- const manifestFile = path.join(project, '.leerness', 'manifest.json');
130
- 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 });
131
347
  manifest.language = 'ko';
132
348
  fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n');
133
349
  for (const [label, args] of surfaces) {
134
- requireNoHangul(`${label} (environment language)`, run(args, { ...baseEnv, LEERNESS_LANG: 'en' }));
135
- 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
+ }
136
355
  }
137
356
  manifest.language = 'en';
138
357
  fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n');
@@ -243,6 +462,27 @@ try {
243
462
  throw new Error(`${label} JSON contract changed with UI language`);
244
463
  }
245
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
+ }
246
486
 
247
487
  const koEnv = { ...baseEnv, LEERNESS_LANG: 'ko' };
248
488
  const koreanAnchors = new Map([
@@ -251,6 +491,7 @@ try {
251
491
  ['plan list', ['완료기준(Done-When)', 'Tasks:', '완료)']],
252
492
  ['round-history', ['자율 라운드 통계', '누적 라운드', '최근 10 tags']],
253
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 | - |']],
254
495
  ]);
255
496
  for (const [label, args] of surfaces) {
256
497
  const output = requireSuccess(`${label} Korean control`, run(args, koEnv));
@@ -281,7 +522,7 @@ try {
281
522
  throw new Error('milestones terminal JSON did not preserve the 500-round contract');
282
523
  }
283
524
 
284
- console.log('✓ Five next-cluster English surfaces contain no Hangul across locale paths and edge states; 5/5 Korean controls and JSON contracts remain intact');
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');
285
526
  } catch (error) {
286
527
  console.error(`✗ ${error && error.message ? error.message : error}`);
287
528
  process.exitCode = 1;