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 +4 -0
- package/README.md +4 -4
- package/bin/leerness.js +137 -22
- package/lib/catalogs.js +2 -2
- package/package.json +1 -1
- package/scripts/e2e.js +25 -5
- package/scripts/i18n-next-cluster-probe.js +250 -9
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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, '|')
|
|
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
|
|
2413
|
+
displayNameKo: _skillOwnValue(v, 'displayNameKo', id),
|
|
2332
2414
|
source: v._source,
|
|
2333
|
-
capabilities: v
|
|
2334
|
-
usageCount: v.usage
|
|
2335
|
-
lastUsed: v.usage
|
|
2336
|
-
lastUpdated: v
|
|
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
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
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
|
|
2347
|
-
const
|
|
2348
|
-
|
|
2349
|
-
|
|
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]
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
130
|
-
const
|
|
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 | forged-name | | user | safe | capability continued | 0 | 2026-01-01 | forged-last | |',
|
|
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|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 | forged-pack |)',
|
|
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|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('✓
|
|
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;
|