leerness 1.36.180 → 1.36.182
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 +8 -0
- package/README.md +4 -4
- package/bin/leerness.js +81 -63
- package/package.json +5 -5
- package/scripts/e2e.js +12 -5
- package/scripts/i18n-next-cluster-probe.js +290 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.182 — 2026-08-31
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
|
|
7
|
+
## 1.36.181 — 2026-08-30
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
|
|
3
11
|
## 1.36.180 — 2026-08-30
|
|
4
12
|
|
|
5
13
|
- English-mode agents list, insights, and toggle output now contain zero Hangul; a dedicated red/green probe and 39-command ratchet reduce measured leakage from 104 to 58 lines while preserving Korean output.
|
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.182 하네스를 사용합니다. 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.182는 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.180는 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.182 릴리스 태그 이력** (수백 라운드) · _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.182: 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.182';
|
|
53
53
|
|
|
54
54
|
// MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
|
|
55
55
|
// 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
|
|
@@ -3408,6 +3408,7 @@ function _computeMilestones(root) {
|
|
|
3408
3408
|
|
|
3409
3409
|
function milestonesCmd(root) {
|
|
3410
3410
|
root = absRoot(root);
|
|
3411
|
+
const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
|
|
3411
3412
|
const isTty = process.stdout && process.stdout.isTTY;
|
|
3412
3413
|
const cy = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
|
|
3413
3414
|
const gr = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
|
|
@@ -3416,29 +3417,31 @@ function milestonesCmd(root) {
|
|
|
3416
3417
|
const data = _computeMilestones(root);
|
|
3417
3418
|
if (data.gitHistoryState !== 'available') _throwGitHistoryUnavailable(data.gitHistoryError);
|
|
3418
3419
|
if (has('--json')) { log(JSON.stringify(data, null, 2)); return; }
|
|
3419
|
-
log(cy(`# leerness milestones (1.9.229) — 도달 마일스톤 + 다음 ETA`));
|
|
3420
|
+
log(cy(t(`# leerness milestones (1.9.229) — 도달 마일스톤 + 다음 ETA`, `# leerness milestones (1.9.229) — reached milestones + next ETA`)));
|
|
3420
3421
|
log('');
|
|
3421
|
-
log(` 📦 총 라운드: ${gr(String(data.totalRounds))}`);
|
|
3422
|
+
log(t(` 📦 총 라운드: ${gr(String(data.totalRounds))}`, ` 📦 total rounds: ${gr(String(data.totalRounds))}`));
|
|
3422
3423
|
if (data.baselineAt) log(` 📍 baseline: ${(data.baselineAt || '').split(' ')[0]}`);
|
|
3423
|
-
if (data.avgRoundsPerDay) log(` 📊 평균: ${gr(data.avgRoundsPerDay + ' rounds/day')}`);
|
|
3424
|
+
if (data.avgRoundsPerDay) log(t(` 📊 평균: ${gr(data.avgRoundsPerDay + ' rounds/day')}`, ` 📊 average: ${gr(data.avgRoundsPerDay + ' rounds/day')}`));
|
|
3424
3425
|
log('');
|
|
3425
3426
|
if (data.reached.length === 0) {
|
|
3426
|
-
log(dm(` (도달 마일스톤 없음 — 첫 R25 마일스톤 진행 중)`));
|
|
3427
|
+
log(dm(t(` (도달 마일스톤 없음 — 첫 R25 마일스톤 진행 중)`, ` (no milestones reached — progressing toward the first R25 milestone)`)));
|
|
3427
3428
|
} else {
|
|
3428
|
-
log(` 도달 마일스톤 ${data.reached.length}
|
|
3429
|
+
log(t(` 도달 마일스톤 ${data.reached.length}개:`, ` ${data.reached.length} milestone${data.reached.length === 1 ? '' : 's'} reached:`));
|
|
3429
3430
|
for (const m of data.reached) {
|
|
3430
3431
|
log(` ✓ R${m.milestone} ${gr(`(v${m.version}, ${m.reachedAt}, +${m.daysFromBaseline}d)`)}`);
|
|
3431
3432
|
}
|
|
3432
3433
|
}
|
|
3433
3434
|
log('');
|
|
3434
3435
|
if (data.next) {
|
|
3435
|
-
log(yl(` 🎯 다음 마일스톤: R${data.next.milestone}`));
|
|
3436
|
-
log(` • ${data.next.roundsRemaining} 라운드
|
|
3436
|
+
log(yl(t(` 🎯 다음 마일스톤: R${data.next.milestone}`, ` 🎯 next milestone: R${data.next.milestone}`)));
|
|
3437
|
+
log(t(` • ${data.next.roundsRemaining} 라운드 남음`, ` • ${data.next.roundsRemaining} rounds remaining`));
|
|
3437
3438
|
if (data.next.etaDays != null) {
|
|
3438
|
-
log(` • ETA: ${data.next.etaDate} (~${data.next.etaDays}일 후, 현재 속도 기준)`);
|
|
3439
|
+
log(t(` • ETA: ${data.next.etaDate} (~${data.next.etaDays}일 후, 현재 속도 기준)`, ` • ETA: ${data.next.etaDate} (~${data.next.etaDays} days from now, based on current pace)`));
|
|
3439
3440
|
}
|
|
3440
3441
|
} else {
|
|
3441
|
-
log(data && (data.roundCount || data.totalRounds)
|
|
3442
|
+
log(data && (data.roundCount || data.totalRounds)
|
|
3443
|
+
? gr(t(` 🎉 모든 마일스톤 달성 (500+)`, ` 🎉 all milestones reached (500+)`))
|
|
3444
|
+
: dm(t(` (이 저장소 계보의 릴리스 태그 이력 없음)`, ` (no release-tag history for this repository lineage)`))); // 1.36.38 (#4): 이력 0 인데 달성 축하 금지
|
|
3442
3445
|
}
|
|
3443
3446
|
}
|
|
3444
3447
|
|
|
@@ -11443,19 +11446,28 @@ function commandsCmd(root) {
|
|
|
11443
11446
|
// 1.9.374 (UR-0074): release cadence — 릴리스 빈도 진단 + 권장 (외부리뷰 반복 지적 "릴리스 케이던스 과다" 가시화). git tag 재사용, 읽기 전용.
|
|
11444
11447
|
function releaseCadenceCmd(root) {
|
|
11445
11448
|
root = absRoot(root);
|
|
11449
|
+
const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
|
|
11446
11450
|
const rh = _computeRoundHistory(root);
|
|
11447
11451
|
if (rh.gitHistoryState !== 'available') _throwGitHistoryUnavailable(rh.gitHistoryError);
|
|
11448
11452
|
const a = _cadenceAssessment(rh.avgRoundsPerDay, rh.roundCount, rh.daysActive);
|
|
11449
11453
|
if (has('--json')) { log(JSON.stringify({ version: VERSION, cliVersion: rh.cliVersion, harnessVersion: rh.harnessVersion, harnessVersionState: rh.harnessVersionState, currentVersion: rh.currentVersion, currentVersionScope: rh.currentVersionScope, baselineVersion: rh.baselineVersion, latestTagVersion: rh.latestTagVersion, gitHistoryState: rh.gitHistoryState, gitHistoryError: rh.gitHistoryError, ...a }, null, 2)); return; }
|
|
11450
|
-
|
|
11451
|
-
|
|
11452
|
-
|
|
11453
|
-
|
|
11454
|
-
|
|
11455
|
-
|
|
11454
|
+
const recommendationEn = {
|
|
11455
|
+
'insufficient-data': 'Defer the cadence assessment until at least two release tags have distinct timestamps.',
|
|
11456
|
+
'very-high': 'Strongly consider batched minor releases: group related patches into one or two minor releases per week, separate stable/next channels, and recommend stable to users.',
|
|
11457
|
+
high: 'Cadence is high: group related changes to reduce publish frequency and document runtime and verification evidence in each release note.',
|
|
11458
|
+
moderate: 'Cadence is moderate; consider batching into minor releases when stability is the priority.',
|
|
11459
|
+
healthy: 'Cadence is healthy.',
|
|
11460
|
+
}[a.level] || a.recommendation;
|
|
11461
|
+
log(t(`# leerness release cadence (1.9.374, UR-0074) — 릴리스 빈도 진단 (읽기 전용)`, `# leerness release cadence (1.9.374, UR-0074) — release-frequency diagnosis (read-only)`));
|
|
11462
|
+
const observedRate = a.dataSufficient ? `${a.releasesPerDay}/day` : t('관측 불가 (서로 다른 시각의 태그 2개 이상 필요)', 'unavailable (requires at least two tags with distinct timestamps)');
|
|
11463
|
+
log(t(` 누적 릴리스: ${a.total} · 활동일: ${a.daysActive}일 · 빈도: ${observedRate}`, ` total releases: ${a.total} · active days: ${a.daysActive} · frequency: ${observedRate}`));
|
|
11464
|
+
log(t(` 수준: ${a.level}`, ` level: ${a.level}`));
|
|
11465
|
+
log(t(` 권장: ${a.recommendation}`, ` recommendation: ${recommendationEn}`));
|
|
11466
|
+
log(t(`\n 관련: leerness release channel (stable/next 정책) · leerness install-safety (공급망 신뢰)`, `\n related: leerness release channel (stable/next policy) · leerness install-safety (supply-chain trust)`));
|
|
11456
11467
|
}
|
|
11457
11468
|
function roundHistoryCmd(root) {
|
|
11458
11469
|
root = absRoot(root);
|
|
11470
|
+
const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
|
|
11459
11471
|
const isTty = process.stdout && process.stdout.isTTY;
|
|
11460
11472
|
const cy = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
|
|
11461
11473
|
const gr = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
|
|
@@ -11464,25 +11476,27 @@ function roundHistoryCmd(root) {
|
|
|
11464
11476
|
const data = _computeRoundHistory(root);
|
|
11465
11477
|
if (data.gitHistoryState !== 'available') _throwGitHistoryUnavailable(data.gitHistoryError);
|
|
11466
11478
|
if (has('--json')) { log(JSON.stringify(data, null, 2)); return; }
|
|
11467
|
-
log(cy(`# leerness round-history (1.9.226) — 자율 라운드
|
|
11479
|
+
log(cy(t(`# leerness round-history (1.9.226) — 자율 라운드 통계`, `# leerness round-history (1.9.226) — autonomous-round statistics`)));
|
|
11468
11480
|
log('');
|
|
11469
11481
|
log(` 📦 leerness CLI: ${gr(data.cliVersion)}`);
|
|
11470
|
-
if (data.harnessVersion) log(` 🧩 프로젝트 하네스: ${gr(data.harnessVersion)}`);
|
|
11471
|
-
else if (data.harnessVersionState === 'invalid' || data.harnessVersionState === 'unreadable') log(yl(` 🧩 프로젝트 하네스: 판독 불가 (${data.harnessVersionState})`));
|
|
11472
|
-
else if (data.harnessVersionState === 'missing') log(yl(` 🧩 프로젝트 하네스: 없음 (missing)`));
|
|
11473
|
-
if (data.latestTagVersion) log(` 🏷️ 저장소 최근 태그: v${data.latestTagVersion}`);
|
|
11474
|
-
log(` 🔄 누적 라운드: ${gr(String(data.roundCount))}`);
|
|
11482
|
+
if (data.harnessVersion) log(t(` 🧩 프로젝트 하네스: ${gr(data.harnessVersion)}`, ` 🧩 project harness: ${gr(data.harnessVersion)}`));
|
|
11483
|
+
else if (data.harnessVersionState === 'invalid' || data.harnessVersionState === 'unreadable') log(yl(t(` 🧩 프로젝트 하네스: 판독 불가 (${data.harnessVersionState})`, ` 🧩 project harness: unreadable (${data.harnessVersionState})`)));
|
|
11484
|
+
else if (data.harnessVersionState === 'missing') log(yl(t(` 🧩 프로젝트 하네스: 없음 (missing)`, ` 🧩 project harness: missing`)));
|
|
11485
|
+
if (data.latestTagVersion) log(t(` 🏷️ 저장소 최근 태그: v${data.latestTagVersion}`, ` 🏷️ latest repository tag: v${data.latestTagVersion}`));
|
|
11486
|
+
log(t(` 🔄 누적 라운드: ${gr(String(data.roundCount))}`, ` 🔄 total rounds: ${gr(String(data.roundCount))}`));
|
|
11475
11487
|
if (data.baselineVersion) log(` 📍 baseline: v${data.baselineVersion} (${(data.firstTagAt || '').split('T')[0]})`);
|
|
11476
|
-
if (data.latestTagAt) log(` ⏰ 최근 tag: ${(data.latestTagAt || '').split('T')[0]}`);
|
|
11477
|
-
if (data.daysActive > 0) log(` 📊 활동: ${data.daysActive}일 / 평균 ${gr(data.avgRoundsPerDay + ' rounds/day')}`);
|
|
11488
|
+
if (data.latestTagAt) log(t(` ⏰ 최근 tag: ${(data.latestTagAt || '').split('T')[0]}`, ` ⏰ latest tag: ${(data.latestTagAt || '').split('T')[0]}`));
|
|
11489
|
+
if (data.daysActive > 0) log(t(` 📊 활동: ${data.daysActive}일 / 평균 ${gr(data.avgRoundsPerDay + ' rounds/day')}`, ` 📊 activity: ${data.daysActive} days / average ${gr(data.avgRoundsPerDay + ' rounds/day')}`));
|
|
11478
11490
|
log('');
|
|
11479
11491
|
if (data.nextMilestone != null) {
|
|
11480
|
-
log(yl(` 🎯 다음 마일스톤: R${data.nextMilestone} (${data.roundsToNextMilestone} 라운드 남음)`));
|
|
11492
|
+
log(yl(t(` 🎯 다음 마일스톤: R${data.nextMilestone} (${data.roundsToNextMilestone} 라운드 남음)`, ` 🎯 next milestone: R${data.nextMilestone} (${data.roundsToNextMilestone} rounds remaining)`)));
|
|
11481
11493
|
} else {
|
|
11482
|
-
log(data && (data.roundCount || data.totalRounds)
|
|
11494
|
+
log(data && (data.roundCount || data.totalRounds)
|
|
11495
|
+
? gr(t(` 🎉 모든 마일스톤 달성 (500+)`, ` 🎉 all milestones reached (500+)`))
|
|
11496
|
+
: dm(t(` (이 저장소 계보의 릴리스 태그 이력 없음)`, ` (no release-tag history for this repository lineage)`))); // 1.36.38 (#4): 이력 0 인데 달성 축하 금지
|
|
11483
11497
|
}
|
|
11484
11498
|
log('');
|
|
11485
|
-
log(` 최근 10 tags:`);
|
|
11499
|
+
log(t(` 최근 10 tags:`, ` latest 10 tags:`));
|
|
11486
11500
|
data.latestTags.forEach(t => log(` • v${t.version} (${t.date})`));
|
|
11487
11501
|
}
|
|
11488
11502
|
|
|
@@ -13060,7 +13074,9 @@ function migrateWorkspaceDirCmd(root) {
|
|
|
13060
13074
|
// 3. user-requests.json — 동일 텍스트 + open (1.9.207 자체 dedup 검증)
|
|
13061
13075
|
// 4. active-wakeups.json — 동일 expectedFireAt 중복 (1.9.205 dedup 검증)
|
|
13062
13076
|
// 5. next-action-queue.json — 동일 title 중복 (1.9.201 dedup 검증)
|
|
13063
|
-
function _runIdempotencyAudit(root) {
|
|
13077
|
+
function _runIdempotencyAudit(root, lang = 'ko') {
|
|
13078
|
+
const t = (ko, en) => (lang === 'en' ? en : ko);
|
|
13079
|
+
const auditError = (area, error) => t(String(error && (error.message || error) || 'audit error'), `Could not audit ${area} safely (${String(error && error.code || 'read-error')})`);
|
|
13064
13080
|
const audit = {
|
|
13065
13081
|
auditedAt: new Date().toISOString(),
|
|
13066
13082
|
auditVersion: VERSION,
|
|
@@ -13078,7 +13094,7 @@ function _runIdempotencyAudit(root) {
|
|
|
13078
13094
|
audit.violations.push({
|
|
13079
13095
|
kind: 'rule-duplicate',
|
|
13080
13096
|
location: '.leerness/rules.md',
|
|
13081
|
-
detail: `중복 룰: ${r.id} == ${seen.get(key)} (${r.trigger}: ${r.rule.slice(0, 60)})`,
|
|
13097
|
+
detail: t(`중복 룰: ${r.id} == ${seen.get(key)} (${r.trigger}: ${r.rule.slice(0, 60)})`, `Duplicate rule: ${r.id} == ${seen.get(key)} (${r.trigger}: ${r.rule.slice(0, 60)})`),
|
|
13082
13098
|
severity: 'medium',
|
|
13083
13099
|
fix: `leerness rule remove ${r.id}`
|
|
13084
13100
|
});
|
|
@@ -13087,9 +13103,9 @@ function _runIdempotencyAudit(root) {
|
|
|
13087
13103
|
}
|
|
13088
13104
|
}
|
|
13089
13105
|
if (audit.violations.filter(v => v.kind === 'rule-duplicate').length === 0) {
|
|
13090
|
-
audit.verified.push({ kind: 'rules', detail: `${active.length} active rules, 중복 0
|
|
13106
|
+
audit.verified.push({ kind: 'rules', detail: t(`${active.length} active rules, 중복 0건`, `${active.length} active rules, 0 duplicates`) });
|
|
13091
13107
|
}
|
|
13092
|
-
} catch (e) { audit.violations.push({ kind: 'audit-error', area: 'rules', detail:
|
|
13108
|
+
} catch (e) { audit.violations.push({ kind: 'audit-error', area: 'rules', detail: auditError('rules', e), severity: 'low' }); }
|
|
13093
13109
|
|
|
13094
13110
|
// 2) progress-tracker.md 중복 request 검사
|
|
13095
13111
|
// 포맷: | T-XXXX | status | request | evidence | nextAction | date |
|
|
@@ -13114,19 +13130,19 @@ function _runIdempotencyAudit(root) {
|
|
|
13114
13130
|
audit.violations.push({
|
|
13115
13131
|
kind: 'task-duplicate-request',
|
|
13116
13132
|
location: '.leerness/progress-tracker.md',
|
|
13117
|
-
detail: `중복 request: "${text.slice(0, 50)}…" (${prev.id} & ${id})`,
|
|
13133
|
+
detail: t(`중복 request: "${text.slice(0, 50)}…" (${prev.id} & ${id})`, `Duplicate request: "${text.slice(0, 50)}…" (${prev.id} & ${id})`),
|
|
13118
13134
|
severity: 'medium',
|
|
13119
|
-
fix: `중복 task 중 하나를 leerness task drop <id>
|
|
13135
|
+
fix: t(`중복 task 중 하나를 leerness task drop <id> 처리`, `drop one duplicate with leerness task drop <id>`)
|
|
13120
13136
|
});
|
|
13121
13137
|
} else {
|
|
13122
13138
|
requests.set(text, { id, status, line: i + 1 });
|
|
13123
13139
|
}
|
|
13124
13140
|
}
|
|
13125
13141
|
if (audit.violations.filter(v => v.kind === 'task-duplicate-request').length === 0) {
|
|
13126
|
-
audit.verified.push({ kind: 'tasks', detail: `${requests.size} active tasks, request 중복 0
|
|
13142
|
+
audit.verified.push({ kind: 'tasks', detail: t(`${requests.size} active tasks, request 중복 0건`, `${requests.size} active tasks, 0 duplicate requests`) });
|
|
13127
13143
|
}
|
|
13128
13144
|
}
|
|
13129
|
-
} catch (e) { audit.violations.push({ kind: 'audit-error', area: 'tasks', detail:
|
|
13145
|
+
} catch (e) { audit.violations.push({ kind: 'audit-error', area: 'tasks', detail: auditError('tasks', e), severity: 'low' }); }
|
|
13130
13146
|
|
|
13131
13147
|
// 3) user-requests.json 자체 dedup 검증 (1.9.207)
|
|
13132
13148
|
try {
|
|
@@ -13139,7 +13155,7 @@ function _runIdempotencyAudit(root) {
|
|
|
13139
13155
|
audit.violations.push({
|
|
13140
13156
|
kind: 'user-request-duplicate',
|
|
13141
13157
|
location: '.leerness/user-requests.json',
|
|
13142
|
-
detail: `중복 open 요청: ${r.id} == ${seen.get(k)} ("${k.slice(0, 50)}…")`,
|
|
13158
|
+
detail: t(`중복 open 요청: ${r.id} == ${seen.get(k)} ("${k.slice(0, 50)}…")`, `Duplicate open request: ${r.id} == ${seen.get(k)} ("${k.slice(0, 50)}…")`),
|
|
13143
13159
|
severity: 'low',
|
|
13144
13160
|
fix: `leerness requests drop ${r.id}`
|
|
13145
13161
|
});
|
|
@@ -13148,9 +13164,9 @@ function _runIdempotencyAudit(root) {
|
|
|
13148
13164
|
}
|
|
13149
13165
|
}
|
|
13150
13166
|
if (audit.violations.filter(v => v.kind === 'user-request-duplicate').length === 0) {
|
|
13151
|
-
audit.verified.push({ kind: 'user-requests', detail: `${seen.size} open requests, 중복 0건 (1.9.207 dedup OK)` });
|
|
13167
|
+
audit.verified.push({ kind: 'user-requests', detail: t(`${seen.size} open requests, 중복 0건 (1.9.207 dedup OK)`, `${seen.size} open requests, 0 duplicates (1.9.207 dedup OK)`) });
|
|
13152
13168
|
}
|
|
13153
|
-
} catch (e) { audit.violations.push({ kind: 'audit-error', area: 'user-requests', detail:
|
|
13169
|
+
} catch (e) { audit.violations.push({ kind: 'audit-error', area: 'user-requests', detail: auditError('user-requests', e), severity: 'low' }); }
|
|
13154
13170
|
|
|
13155
13171
|
// 4) active-wakeups.json 검증 (1.9.205)
|
|
13156
13172
|
try {
|
|
@@ -13162,16 +13178,16 @@ function _runIdempotencyAudit(root) {
|
|
|
13162
13178
|
audit.violations.push({
|
|
13163
13179
|
kind: 'wakeup-duplicate',
|
|
13164
13180
|
location: '.leerness/active-wakeups.json',
|
|
13165
|
-
detail: `동일 expectedFireAt 중복: ${w.expectedFireAt}`,
|
|
13181
|
+
detail: t(`동일 expectedFireAt 중복: ${w.expectedFireAt}`, `Duplicate expectedFireAt: ${w.expectedFireAt}`),
|
|
13166
13182
|
severity: 'high',
|
|
13167
|
-
fix: `leerness 자동 _recordWakeup filter dedup 검토
|
|
13183
|
+
fix: t(`leerness 자동 _recordWakeup filter dedup 검토 필요`, `inspect the automatic leerness _recordWakeup dedup filter`)
|
|
13168
13184
|
});
|
|
13169
13185
|
} else {
|
|
13170
13186
|
seenT.set(w.expectedFireAt, true);
|
|
13171
13187
|
}
|
|
13172
13188
|
}
|
|
13173
13189
|
if (audit.violations.filter(v => v.kind === 'wakeup-duplicate').length === 0) {
|
|
13174
|
-
audit.verified.push({ kind: 'wakeups', detail: `${pending.length} pending wakeups, 중복 0건 (1.9.205 dedup OK)` });
|
|
13190
|
+
audit.verified.push({ kind: 'wakeups', detail: t(`${pending.length} pending wakeups, 중복 0건 (1.9.205 dedup OK)`, `${pending.length} pending wakeups, 0 duplicates (1.9.205 dedup OK)`) });
|
|
13175
13191
|
}
|
|
13176
13192
|
} catch {}
|
|
13177
13193
|
|
|
@@ -13252,6 +13268,7 @@ function _autoFixIdempotency(root) {
|
|
|
13252
13268
|
}
|
|
13253
13269
|
function idempotencyCmd(root, sub) {
|
|
13254
13270
|
root = absRoot(root);
|
|
13271
|
+
const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
|
|
13255
13272
|
const isTty = process.stdout && process.stdout.isTTY;
|
|
13256
13273
|
const cyan = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
|
|
13257
13274
|
const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
|
|
@@ -13260,23 +13277,22 @@ function idempotencyCmd(root, sub) {
|
|
|
13260
13277
|
const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
|
|
13261
13278
|
|
|
13262
13279
|
if (!sub || sub === 'help' || sub === '--help') {
|
|
13263
|
-
|
|
13264
|
-
log(_t(`# leerness idempotency (1.9.212) — 멱등성 위반 탐지`, `# leerness idempotency — detect idempotency violations`));
|
|
13280
|
+
log(t(`# leerness idempotency (1.9.212) — 멱등성 위반 탐지`, `# leerness idempotency — detect idempotency violations`));
|
|
13265
13281
|
log('');
|
|
13266
|
-
log(
|
|
13267
|
-
log(
|
|
13282
|
+
log(t(` audit → 워크스페이스 멱등성 점검 (rules / tasks / user-requests / wakeups) (--json 가능)`, ` audit → check workspace idempotency (rules / tasks / user-requests / wakeups) (--json)`));
|
|
13283
|
+
log(t(` audit --auto-fix → task 완전중복 행 제거 + active 동일텍스트 dropped 처리 + user-request open 중복 정리 (1.9.293)`, ` audit --auto-fix → remove exact-duplicate tasks + mark same-text active as dropped + dedup open user-requests`));
|
|
13268
13284
|
log('');
|
|
13269
|
-
log(dim(
|
|
13270
|
-
log(dim(
|
|
13271
|
-
log(dim(
|
|
13272
|
-
log(dim(
|
|
13285
|
+
log(dim(t(` dedup 적용 영역: ruleAdd / taskAdd (1.9.212) + _recordUserRequest (1.9.207) + _recordWakeup (1.9.205)`, ` dedup applies to: ruleAdd / taskAdd + _recordUserRequest + _recordWakeup`)));
|
|
13286
|
+
log(dim(t(` --auto-fix 안전: 완전 동일 행만 제거 / 동일텍스트는 status=dropped 로 보존(id 유지) / git 회복 가능 / 멱등`, ` --auto-fix safety: removes only exact dupes / same-text kept as status=dropped (id preserved) / git-recoverable / idempotent`)));
|
|
13287
|
+
log(dim(t(` 자동화: drift check --auto-fix 가 idempotency 중복도 자동 정리 (1.9.293)`, ` automation: drift check --auto-fix also dedups idempotency violations`)));
|
|
13288
|
+
log(dim(t(` opt-out: --force 플래그로 dedup 우회 가능`, ` opt-out: pass --force to bypass dedup`)));
|
|
13273
13289
|
return;
|
|
13274
13290
|
}
|
|
13275
13291
|
|
|
13276
13292
|
if (sub === 'audit') {
|
|
13277
13293
|
const autoFix = has('--auto-fix');
|
|
13278
13294
|
const fixes = autoFix ? _autoFixIdempotency(root) : [];
|
|
13279
|
-
const audit = _runIdempotencyAudit(root); //
|
|
13295
|
+
const audit = _runIdempotencyAudit(root, has('--json') ? 'ko' : _L); // JSON 은 기존 canonical payload, 사람 출력만 locale 적용
|
|
13280
13296
|
if (autoFix) audit.autoFixed = fixes;
|
|
13281
13297
|
// 1.36.131 (검수 P1): --json 도 같은 계약 — auto-fix 가 실패했으면 성공 종료코드로 나가지 않는다.
|
|
13282
13298
|
if (autoFix && fixes.some(f => f.action === 'error')) { audit.autoFixFailed = fixes.filter(f => f.action === 'error').length; process.exitCode = 1; }
|
|
@@ -13286,9 +13302,9 @@ function idempotencyCmd(root, sub) {
|
|
|
13286
13302
|
log('');
|
|
13287
13303
|
const s = audit.summary;
|
|
13288
13304
|
if (s.overall === 'clean') {
|
|
13289
|
-
log(grn(` ✓ 멱등성 위반 없음 — verified ${s.verifiedAreas}
|
|
13305
|
+
log(grn(t(` ✓ 멱등성 위반 없음 — verified ${s.verifiedAreas} 영역`, ` ✓ no idempotency violations — verified ${s.verifiedAreas} areas`)));
|
|
13290
13306
|
} else {
|
|
13291
|
-
log(red(` ⚠ ${s.totalViolations} 위반 발견 (high: ${s.highSeverity}, medium: ${s.mediumSeverity}, low: ${s.lowSeverity})`));
|
|
13307
|
+
log(red(t(` ⚠ ${s.totalViolations} 위반 발견 (high: ${s.highSeverity}, medium: ${s.mediumSeverity}, low: ${s.lowSeverity})`, ` ⚠ ${s.totalViolations} violations found (high: ${s.highSeverity}, medium: ${s.mediumSeverity}, low: ${s.lowSeverity})`)));
|
|
13292
13308
|
}
|
|
13293
13309
|
log('');
|
|
13294
13310
|
if (audit.verified.length) {
|
|
@@ -13313,18 +13329,18 @@ function idempotencyCmd(root, sub) {
|
|
|
13313
13329
|
const _applied = fixes.filter(f => f.action !== 'error');
|
|
13314
13330
|
const _failedFix = fixes.filter(f => f.action === 'error');
|
|
13315
13331
|
if (_applied.length) {
|
|
13316
|
-
log(grn(`## 🔧 auto-fix 적용 (${_applied.length})`));
|
|
13332
|
+
log(grn(t(`## 🔧 auto-fix 적용 (${_applied.length})`, `## 🔧 auto-fix applied (${_applied.length})`)));
|
|
13317
13333
|
_applied.forEach(f => {
|
|
13318
|
-
if (f.kind === 'task-duplicate-request') log(` - [tasks] 완전중복 ${f.removedExact || 0}행 제거 · 동일텍스트 ${f.droppedSameText || 0}건 dropped
|
|
13319
|
-
else if (f.kind === 'user-request-duplicate') log(` - [user-requests] open 중복 ${f.count || 0}건 dropped
|
|
13334
|
+
if (f.kind === 'task-duplicate-request') log(t(` - [tasks] 완전중복 ${f.removedExact || 0}행 제거 · 동일텍스트 ${f.droppedSameText || 0}건 dropped 처리`, ` - [tasks] removed ${f.removedExact || 0} exact duplicate rows · marked ${f.droppedSameText || 0} same-text tasks as dropped`));
|
|
13335
|
+
else if (f.kind === 'user-request-duplicate') log(t(` - [user-requests] open 중복 ${f.count || 0}건 dropped 처리`, ` - [user-requests] marked ${f.count || 0} duplicate open requests as dropped`));
|
|
13320
13336
|
else log(dim(` - [${f.kind}] ${f.action}${f.detail ? ' — ' + f.detail : ''}`));
|
|
13321
13337
|
});
|
|
13322
13338
|
} else if (!_failedFix.length) {
|
|
13323
|
-
log(dim(' 🔧 auto-fix: 적용할 중복 없음 (이미 정합)'));
|
|
13339
|
+
log(dim(t(' 🔧 auto-fix: 적용할 중복 없음 (이미 정합)', ' 🔧 auto-fix: no duplicates to apply (already consistent)')));
|
|
13324
13340
|
}
|
|
13325
13341
|
if (_failedFix.length) {
|
|
13326
|
-
log(red(`## ❌ auto-fix 실패 (${_failedFix.length}) — 고쳐지지 않았다. 수동 확인
|
|
13327
|
-
_failedFix.forEach(f => log(` - [${f.kind}] ${f.detail || '원인 미상'}`));
|
|
13342
|
+
log(red(t(`## ❌ auto-fix 실패 (${_failedFix.length}) — 고쳐지지 않았다. 수동 확인 필요`, `## ❌ auto-fix failed (${_failedFix.length}) — state was not repaired; inspect it manually`)));
|
|
13343
|
+
_failedFix.forEach(f => log(` - [${f.kind}] ${_L === 'en' ? 'could not repair safely' : (f.detail || '원인 미상')}`));
|
|
13328
13344
|
process.exitCode = 1;
|
|
13329
13345
|
}
|
|
13330
13346
|
}
|
|
@@ -14782,6 +14798,7 @@ function planInit(root) {
|
|
|
14782
14798
|
// 1.9.119: plan list — plan.md 의 모든 milestone (M-XXXX) 조회 (CLI + --json + MCP)
|
|
14783
14799
|
function planListCmd(root, opts = {}) {
|
|
14784
14800
|
root = absRoot(root);
|
|
14801
|
+
const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
|
|
14785
14802
|
const jsonMode = !!opts.json || has('--json');
|
|
14786
14803
|
const pp = planPath(root);
|
|
14787
14804
|
if (!exists(pp)) {
|
|
@@ -14789,7 +14806,7 @@ function planListCmd(root, opts = {}) {
|
|
|
14789
14806
|
process.stdout.write(JSON.stringify({ version: VERSION, root, total: 0, milestones: [] }, null, 2) + '\n');
|
|
14790
14807
|
return;
|
|
14791
14808
|
}
|
|
14792
|
-
return ok('plan.md 없음 — leerness plan add "<text>" 로 첫 milestone 등록');
|
|
14809
|
+
return ok(t('plan.md 없음 — leerness plan add "<text>" 로 첫 milestone 등록', 'plan.md not found — add the first milestone with leerness plan add "<text>"'));
|
|
14793
14810
|
}
|
|
14794
14811
|
// 1.36.21 (codex #8a, 전수 sweep 3 에이전트 수렴): CRLF plan.md 에서 모든 마일스톤의 tasks 가 조용히 [] 로 증발하던 것 수정.
|
|
14795
14812
|
// 체크박스 정규식(/^-\s*\[([\sx])\]\s*(.+)$/)이 /m 없이 후행 \r 앞에서 $ 를 못 잡아 실패 — id/title/status/progress 는 /m 이라 정상 파싱되어
|
|
@@ -14831,14 +14848,15 @@ function planListCmd(root, opts = {}) {
|
|
|
14831
14848
|
return;
|
|
14832
14849
|
}
|
|
14833
14850
|
log(`# 🗺 Plan (1.9.119)\n`);
|
|
14834
|
-
if (!milestones.length) return ok('plan milestones 비어있음');
|
|
14835
|
-
log(`총 ${milestones.length}개 milestone:`);
|
|
14851
|
+
if (!milestones.length) return ok(t('plan milestones 비어있음', 'plan milestones are empty'));
|
|
14852
|
+
log(t(`총 ${milestones.length}개 milestone:`, `${milestones.length} milestones:`));
|
|
14836
14853
|
for (const m of milestones) {
|
|
14837
14854
|
log(`\n[${m.id}] ${m.title}`);
|
|
14838
14855
|
if (m.status) log(` Status: ${m.status}`);
|
|
14839
14856
|
if (m.progress) log(` Progress: ${m.progress}`);
|
|
14840
|
-
|
|
14841
|
-
|
|
14857
|
+
const doneWhen = _L === 'en' && m.doneWhen === '(미정)' ? '(unset)' : m.doneWhen;
|
|
14858
|
+
log(t(` 완료기준(Done-When): ${doneWhen || '⚠ 미정 — plan add ... --done-when "<검증가능 조건>" 권장 (Karpathy 원칙4)'}`, ` Done-When: ${doneWhen || '⚠ unset — consider plan add ... --done-when "<verifiable condition>" (Karpathy principle 4)'}`));
|
|
14859
|
+
if (m.tasks.length) log(t(` Tasks: ${m.tasks.length}개 (${m.tasks.filter(task => task.done).length} 완료)`, ` Tasks: ${m.tasks.length} (${m.tasks.filter(task => task.done).length} done)`));
|
|
14842
14860
|
}
|
|
14843
14861
|
}
|
|
14844
14862
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.182",
|
|
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",
|
|
@@ -48,19 +48,19 @@
|
|
|
48
48
|
"LICENSE"
|
|
49
49
|
],
|
|
50
50
|
"scripts": {
|
|
51
|
-
"test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
|
|
52
|
-
"test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js",
|
|
51
|
+
"test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && node ./scripts/i18n-next-cluster-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
|
|
52
|
+
"test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && node ./scripts/i18n-next-cluster-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js",
|
|
53
53
|
"test:commands": "node ./scripts/command-flags-probe.js && node ./scripts/dead-flags-probe.js && node ./scripts/false-claim-probe.js && node ./scripts/next-action-suggestion-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js",
|
|
54
54
|
"test:next-actions": "node ./scripts/next-action-suggestion-probe.js",
|
|
55
55
|
"test:installed": "node ./scripts/installed-cleanroom-probe.js",
|
|
56
56
|
"test:handoff": "node ./scripts/handoff-readonly-probe.js",
|
|
57
|
-
"test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/false-claim-probe.js && node ./scripts/smoke.js",
|
|
57
|
+
"test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/false-claim-probe.js && node ./scripts/smoke.js",
|
|
58
58
|
"test:false-claims": "node ./scripts/false-claim-probe.js",
|
|
59
59
|
"test:workspace-lock-order": "node ./scripts/workspace-dir-lock-order-probe.js",
|
|
60
60
|
"test:mcp-presence": "node ./scripts/mcp-presence-probe.js",
|
|
61
61
|
"test:smoke": "node ./scripts/smoke.js",
|
|
62
62
|
"test:parent-detect": "node ./scripts/parent-detect-selftest-probe.js",
|
|
63
|
-
"test:i18n": "node ./scripts/i18n-priority-surface-probe.js",
|
|
63
|
+
"test:i18n": "node ./scripts/i18n-priority-surface-probe.js && node ./scripts/i18n-next-cluster-probe.js",
|
|
64
64
|
"lint": "node ./scripts/lint.js",
|
|
65
65
|
"prepack": "node ./bin/leerness.js readme sync . && node ./bin/leerness.js --version"
|
|
66
66
|
},
|
package/scripts/e2e.js
CHANGED
|
@@ -11006,7 +11006,7 @@ 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
|
-
let leaky = 0, produced = 0; const worst = [], silent = [];
|
|
11009
|
+
let leaky = 0, produced = 0; const worst = [], silent = [], measured = new Map();
|
|
11010
11010
|
for (const a of CMDS) {
|
|
11011
11011
|
const r = R(a);
|
|
11012
11012
|
const out = String(r.stdout || '') + String(r.stderr || '');
|
|
@@ -11015,6 +11015,7 @@ total++;
|
|
|
11015
11015
|
if (r.status === 0 && out.trim()) produced++; else silent.push(a.join(' ') + '(exit=' + r.status + ')');
|
|
11016
11016
|
const n = out.split('\n').filter(l => HANGUL.test(l)).length;
|
|
11017
11017
|
leaky += n;
|
|
11018
|
+
measured.set(a.join(' '), n);
|
|
11018
11019
|
if (n) worst.push(a.join(' ') + ':' + n);
|
|
11019
11020
|
}
|
|
11020
11021
|
dbg.leaky = leaky; dbg.produced = produced; dbg.worst = worst.slice(0, 8); dbg.silent = silent;
|
|
@@ -11028,12 +11029,18 @@ total++;
|
|
|
11028
11029
|
// 갚은 만큼만 낮춘다 — 여유를 남기면 그 안에서 조용히 썩는다(1.36.82 의 자기참조 가드에서 겪은 형태).
|
|
11029
11030
|
// 1.36.174: provider list 영어 모드의 요약/안내 두 줄을 영어화해 104로 조임(39개 명령 실측).
|
|
11030
11031
|
// 1.36.180: agents list(18) + insights(17) + toggle list(11) 우선 표면을 영어화해 58로 조임.
|
|
11031
|
-
|
|
11032
|
+
// 1.36.181: 공동 최상위 release cadence/idempotency audit/plan list/round-history(각 5)를 영어화해 38로 조임.
|
|
11033
|
+
// 1.36.182: milestones 빈 이력 표면 4줄을 영어화해 34로 조임. 도달/ETA 분기도 전용 probe가 고정한다.
|
|
11034
|
+
const BASELINE = 34;
|
|
11032
11035
|
dbg.baseline = BASELINE;
|
|
11033
|
-
|
|
11036
|
+
// 합계가 우연히 낮아진 뒤 다른 명령의 회귀가 그 여유를 소비하지 못하게 exact 로 잠근다.
|
|
11037
|
+
// 번역이 더 진행되면 실측값과 BASELINE을 같은 변경에서 함께 낮춰야 한다.
|
|
11038
|
+
dbg.exactRatchet = leaky === BASELINE;
|
|
11034
11039
|
// 이번 라운드가 고친 표면은 **0 이어야** 한다 — 래칫과 별개로 회귀를 직접 막는다.
|
|
11035
11040
|
const cmdOut = String(R(['commands']).stdout || '');
|
|
11036
11041
|
dbg.commandsClean = cmdOut.split('\n').filter(l => HANGUL.test(l)).length === 0;
|
|
11042
|
+
dbg.nextClusterClean = ['release cadence', 'idempotency audit', 'plan list', 'round-history', 'milestones']
|
|
11043
|
+
.every(cmd => measured.get(cmd) === 0);
|
|
11037
11044
|
// 한국어 사용자가 보던 것은 **바뀌면 안 된다**. 영어화하면서 `개` 단위를 양쪽에서 없애는 회귀를 냈고
|
|
11038
11045
|
// 직전 커밋과의 바이트 비교가 잡았다. 여기서는 한국어 출력이 여전히 한국어 관례를 지키는지 단언한다.
|
|
11039
11046
|
const koEnv = Object.assign({}, ENV, { LEERNESS_LANG: 'ko' });
|
|
@@ -11046,9 +11053,9 @@ total++;
|
|
|
11046
11053
|
&& Object.values(cj.categories).every(list => list.every(e => typeof e.cmd === 'string' && typeof e.desc === 'string'
|
|
11047
11054
|
&& e.descEn === undefined && e.cmdEn === undefined)) // 내부 번역 키가 payload 로 새면 70% 부풀었다(검수 P1 실측)
|
|
11048
11055
|
&& cj.lang === 'en'; // 로케일 의존을 **명시**한다 — 암묵적으로 흔들리지 않는다
|
|
11049
|
-
ok = dbg.gitInit && dbg.probeAlive && dbg.coverage && dbg.
|
|
11056
|
+
ok = dbg.gitInit && dbg.probeAlive && dbg.coverage && dbg.exactRatchet && dbg.commandsClean && dbg.nextClusterClean && dbg.koIntact && dbg.jsonContract;
|
|
11050
11057
|
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
11051
|
-
console.log(ok ? `✓ O(1.36.111/T-0092) 영어 누출 ${dbg.leaky}줄
|
|
11058
|
+
console.log(ok ? `✓ O(1.36.111/T-0092) 영어 누출 ${dbg.leaky}줄 = exact 래칫 ${dbg.baseline} · commands/next-cluster 표면 0 · 한국어 출력 불변 · --json 계약 유지 · 계측 살아있음`
|
|
11052
11059
|
: '✗ 1.36.111 i18n 래칫 위반 ' + JSON.stringify(dbg));
|
|
11053
11060
|
if (!ok) failed++;
|
|
11054
11061
|
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const cp = require('child_process');
|
|
7
|
+
|
|
8
|
+
const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
|
|
9
|
+
const HANGUL = /[가-힣ㄱ-ㆎ]/;
|
|
10
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-i18n-next-'));
|
|
11
|
+
const project = path.join(tmp, 'project');
|
|
12
|
+
|
|
13
|
+
const baseEnv = {
|
|
14
|
+
...process.env,
|
|
15
|
+
TMPDIR: tmp,
|
|
16
|
+
TEMP: tmp,
|
|
17
|
+
TMP: tmp,
|
|
18
|
+
LEERNESS_INTERNAL: '1',
|
|
19
|
+
LEERNESS_NO_BANNER: '1',
|
|
20
|
+
LEERNESS_NO_STALE_CHECK: '1',
|
|
21
|
+
LEERNESS_OFFLINE: '1',
|
|
22
|
+
};
|
|
23
|
+
delete baseEnv.LEERNESS_LANG;
|
|
24
|
+
|
|
25
|
+
function run(args, env = baseEnv) {
|
|
26
|
+
return cp.spawnSync(process.execPath, [CLI, ...args], {
|
|
27
|
+
cwd: project,
|
|
28
|
+
env,
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
timeout: 300000,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function git(args, extraEnv = {}, input) {
|
|
35
|
+
return cp.spawnSync('git', args, {
|
|
36
|
+
cwd: project,
|
|
37
|
+
env: { ...baseEnv, ...extraEnv },
|
|
38
|
+
encoding: 'utf8',
|
|
39
|
+
timeout: 30000,
|
|
40
|
+
input,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function outputOf(result) {
|
|
45
|
+
return String(result.stdout || '') + String(result.stderr || '');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function requireSuccess(label, result) {
|
|
49
|
+
const output = outputOf(result);
|
|
50
|
+
if (result.status !== 0 || !output.trim()) {
|
|
51
|
+
throw new Error(`${label} probe command failed or was silent (exit ${result.status}): ${output.slice(0, 500)}`);
|
|
52
|
+
}
|
|
53
|
+
return output;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function requireGitSuccess(label, result) {
|
|
57
|
+
if (result.status !== 0) {
|
|
58
|
+
throw new Error(`${label} failed (exit ${result.status}): ${outputOf(result).slice(0, 500)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function hangulLines(output) {
|
|
63
|
+
return String(output).split(/\r?\n/).filter(line => HANGUL.test(line));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function requireNoHangul(label, result) {
|
|
67
|
+
const output = requireSuccess(label, result);
|
|
68
|
+
const lines = hangulLines(output);
|
|
69
|
+
if (lines.length) {
|
|
70
|
+
throw new Error(`${label} leaked ${lines.length} Hangul line(s): ${lines.slice(0, 4).join(' | ')}`);
|
|
71
|
+
}
|
|
72
|
+
return output;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizedJson(label, result) {
|
|
76
|
+
const output = requireSuccess(label, result);
|
|
77
|
+
let parsed;
|
|
78
|
+
try { parsed = JSON.parse(output); } catch (error) {
|
|
79
|
+
throw new Error(`${label} did not return one JSON document: ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
if (parsed && typeof parsed === 'object') delete parsed.auditedAt;
|
|
82
|
+
// Milestone ETA is intentionally based on the wall clock. English and Korean
|
|
83
|
+
// probes run in separate processes, so they can legitimately straddle UTC
|
|
84
|
+
// midnight. Validate the public date shape, then remove only that volatility
|
|
85
|
+
// before comparing the locale-independent payloads.
|
|
86
|
+
if (parsed && parsed.next && Object.prototype.hasOwnProperty.call(parsed.next, 'etaDate')) {
|
|
87
|
+
const etaDate = parsed.next.etaDate;
|
|
88
|
+
if (etaDate !== null && !/^\d{4}-\d{2}-\d{2}$/.test(String(etaDate))) {
|
|
89
|
+
throw new Error(`${label} returned an invalid milestone etaDate: ${etaDate}`);
|
|
90
|
+
}
|
|
91
|
+
if (etaDate !== null) parsed.next.etaDate = '<clock-derived-date>';
|
|
92
|
+
}
|
|
93
|
+
return parsed;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cloneProjectState(target) {
|
|
97
|
+
fs.mkdirSync(target, { recursive: true });
|
|
98
|
+
fs.copyFileSync(path.join(project, 'package.json'), path.join(target, 'package.json'));
|
|
99
|
+
fs.cpSync(path.join(project, '.leerness'), path.join(target, '.leerness'), { recursive: true });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const surfaces = [
|
|
103
|
+
['release cadence', ['release', 'cadence', '--path', project]],
|
|
104
|
+
['idempotency audit', ['idempotency', 'audit', '--path', project]],
|
|
105
|
+
['plan list', ['plan', 'list', '--path', project]],
|
|
106
|
+
['round-history', ['round-history', '--path', project]],
|
|
107
|
+
['milestones', ['milestones', '--path', project]],
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
fs.mkdirSync(project, { recursive: true });
|
|
112
|
+
fs.writeFileSync(path.join(project, 'package.json'), '{"name":"i18n-next-probe","version":"0.1.0"}\n');
|
|
113
|
+
requireSuccess('init', run(['init', project, '--yes', '--language', 'en']));
|
|
114
|
+
requireGitSuccess('git init', git(['init', '-q']));
|
|
115
|
+
requireGitSuccess('git user name', git(['config', 'user.name', 'Leerness Probe']));
|
|
116
|
+
requireGitSuccess('git user email', git(['config', 'user.email', 'probe@example.invalid']));
|
|
117
|
+
|
|
118
|
+
requireSuccess('task add', run(['task', 'add', 'Implement the parser', '--path', project]));
|
|
119
|
+
requireSuccess('decision add', run(['decision', 'add', 'Use JSON storage', '--reason', 'simplest', '--path', project]));
|
|
120
|
+
requireSuccess('plan add', run(['plan', 'add', 'Ship the parser', '--path', project]));
|
|
121
|
+
|
|
122
|
+
// Stored project language, environment override, and explicit flag must all
|
|
123
|
+
// reach the same English renderer. Flip the stored language to Korean before
|
|
124
|
+
// checking overrides so a disconnected env/flag path cannot pass by falling
|
|
125
|
+
// back to the already-English manifest.
|
|
126
|
+
for (const [label, args] of surfaces) {
|
|
127
|
+
requireNoHangul(`${label} (stored language)`, run(args));
|
|
128
|
+
}
|
|
129
|
+
const manifestFile = path.join(project, '.leerness', 'manifest.json');
|
|
130
|
+
const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
|
|
131
|
+
manifest.language = 'ko';
|
|
132
|
+
fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n');
|
|
133
|
+
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' }));
|
|
136
|
+
}
|
|
137
|
+
manifest.language = 'en';
|
|
138
|
+
fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n');
|
|
139
|
+
|
|
140
|
+
// Missing and milestone-free plans have separate human branches.
|
|
141
|
+
const planFile = path.join(project, '.leerness', 'plan.md');
|
|
142
|
+
const originalPlan = fs.readFileSync(planFile, 'utf8');
|
|
143
|
+
fs.rmSync(planFile);
|
|
144
|
+
requireNoHangul('plan list missing file', run(['plan', 'list', '--path', project]));
|
|
145
|
+
fs.writeFileSync(planFile, '# Plan\n\n## Milestones\n');
|
|
146
|
+
requireNoHangul('plan list without milestones', run(['plan', 'list', '--path', project]));
|
|
147
|
+
const legacyPlan = originalPlan.replace('Done-When: (unset)', 'Done-When: (미정)');
|
|
148
|
+
if (legacyPlan === originalPlan) throw new Error('plan fixture did not contain the English unset sentinel');
|
|
149
|
+
fs.writeFileSync(planFile, legacyPlan);
|
|
150
|
+
const legacyPlanOutput = requireNoHangul('plan list legacy Korean sentinel', run(['plan', 'list', '--path', project]));
|
|
151
|
+
if (!legacyPlanOutput.includes('Done-When: (unset)')) {
|
|
152
|
+
throw new Error('plan list did not translate the legacy (미정) sentinel to (unset)');
|
|
153
|
+
}
|
|
154
|
+
fs.writeFileSync(planFile, originalPlan);
|
|
155
|
+
|
|
156
|
+
// Public add commands normally deduplicate; --force deliberately creates
|
|
157
|
+
// realistic duplicate rows so the violation renderer and auto-fix summary
|
|
158
|
+
// cannot remain Korean while the empty-state probe passes.
|
|
159
|
+
requireSuccess('duplicate rule A', run(['rule', 'add', 'Run tests', '--trigger', 'every-session', '--force', '--path', project]));
|
|
160
|
+
requireSuccess('duplicate rule B', run(['rule', 'add', 'Run tests', '--trigger', 'every-session', '--force', '--path', project]));
|
|
161
|
+
requireSuccess('duplicate task A', run(['task', 'add', 'Resolve duplicate work', '--status', 'in-progress', '--force', '--path', project]));
|
|
162
|
+
requireSuccess('duplicate task B', run(['task', 'add', 'Resolve duplicate work', '--status', 'in-progress', '--force', '--path', project]));
|
|
163
|
+
requireSuccess('duplicate user request seed', run(['requests', 'add', 'Resolve duplicate request', '--path', project]));
|
|
164
|
+
const requestFile = path.join(project, '.leerness', 'user-requests.json');
|
|
165
|
+
const requestState = JSON.parse(fs.readFileSync(requestFile, 'utf8'));
|
|
166
|
+
const requestSeed = requestState.requests[requestState.requests.length - 1];
|
|
167
|
+
requestState.requests.push({ ...requestSeed, id: 'UR-9000', recordedAt: '2026-08-30T00:00:00.000Z' });
|
|
168
|
+
fs.writeFileSync(requestFile, JSON.stringify(requestState, null, 2) + '\n');
|
|
169
|
+
requireNoHangul('idempotency violation report', run(['idempotency', 'audit', '--path', project]));
|
|
170
|
+
|
|
171
|
+
// --auto-fix mutates its target, so compare two byte-identical fresh clones.
|
|
172
|
+
// This catches locale-dependent JSON fields as well as locale-dependent state
|
|
173
|
+
// writes that a post-fix clean audit would miss.
|
|
174
|
+
const autoFixEnProject = path.join(tmp, 'autofix-en');
|
|
175
|
+
const autoFixKoProject = path.join(tmp, 'autofix-ko');
|
|
176
|
+
cloneProjectState(autoFixEnProject);
|
|
177
|
+
cloneProjectState(autoFixKoProject);
|
|
178
|
+
const autoFixEn = normalizedJson('idempotency auto-fix English JSON', run(
|
|
179
|
+
['idempotency', 'audit', '--auto-fix', '--json', '--path', autoFixEnProject],
|
|
180
|
+
{ ...baseEnv, LEERNESS_LANG: 'en' }
|
|
181
|
+
));
|
|
182
|
+
const autoFixKo = normalizedJson('idempotency auto-fix Korean JSON', run(
|
|
183
|
+
['idempotency', 'audit', '--auto-fix', '--json', '--path', autoFixKoProject],
|
|
184
|
+
{ ...baseEnv, LEERNESS_LANG: 'ko' }
|
|
185
|
+
));
|
|
186
|
+
if (JSON.stringify(autoFixEn) !== JSON.stringify(autoFixKo)) {
|
|
187
|
+
throw new Error('idempotency auto-fix JSON contract changed with UI language');
|
|
188
|
+
}
|
|
189
|
+
const enProgress = fs.readFileSync(path.join(autoFixEnProject, '.leerness', 'progress-tracker.md'));
|
|
190
|
+
const koProgress = fs.readFileSync(path.join(autoFixKoProject, '.leerness', 'progress-tracker.md'));
|
|
191
|
+
if (!enProgress.equals(koProgress)) throw new Error('idempotency auto-fix wrote locale-dependent progress-tracker.md');
|
|
192
|
+
const enRequests = JSON.parse(fs.readFileSync(path.join(autoFixEnProject, '.leerness', 'user-requests.json'), 'utf8'));
|
|
193
|
+
const koRequests = JSON.parse(fs.readFileSync(path.join(autoFixKoProject, '.leerness', 'user-requests.json'), 'utf8'));
|
|
194
|
+
delete enRequests.updatedAt;
|
|
195
|
+
delete koRequests.updatedAt;
|
|
196
|
+
if (JSON.stringify(enRequests) !== JSON.stringify(koRequests)) {
|
|
197
|
+
throw new Error('idempotency auto-fix wrote locale-dependent user-requests.json');
|
|
198
|
+
}
|
|
199
|
+
requireNoHangul('idempotency auto-fix report', run(['idempotency', 'audit', '--auto-fix', '--path', project]));
|
|
200
|
+
|
|
201
|
+
// Two annotated tags with distinct creation times exercise measured cadence
|
|
202
|
+
// and non-empty round history rather than only the insufficient-data branch.
|
|
203
|
+
requireGitSuccess('git add first snapshot', git(['add', '-A']));
|
|
204
|
+
const firstDate = '2026-08-28T00:00:00+09:00';
|
|
205
|
+
requireGitSuccess('git commit first snapshot', git(['commit', '-qm', 'first'], {
|
|
206
|
+
GIT_AUTHOR_DATE: firstDate,
|
|
207
|
+
GIT_COMMITTER_DATE: firstDate,
|
|
208
|
+
}));
|
|
209
|
+
requireGitSuccess('git tag first snapshot', git(['tag', '-a', 'v1.0.0', '-m', 'v1.0.0'], {
|
|
210
|
+
GIT_COMMITTER_DATE: firstDate,
|
|
211
|
+
}));
|
|
212
|
+
fs.writeFileSync(path.join(project, 'history.txt'), 'second\n');
|
|
213
|
+
requireGitSuccess('git add second snapshot', git(['add', 'history.txt']));
|
|
214
|
+
const secondDate = '2026-08-29T00:00:00+09:00';
|
|
215
|
+
requireGitSuccess('git commit second snapshot', git(['commit', '-qm', 'second'], {
|
|
216
|
+
GIT_AUTHOR_DATE: secondDate,
|
|
217
|
+
GIT_COMMITTER_DATE: secondDate,
|
|
218
|
+
}));
|
|
219
|
+
requireGitSuccess('git tag second snapshot', git(['tag', '-a', 'v1.0.1', '-m', 'v1.0.1'], {
|
|
220
|
+
GIT_COMMITTER_DATE: secondDate,
|
|
221
|
+
}));
|
|
222
|
+
// Reach R25 without manufacturing another 23 commits. Annotated tags have
|
|
223
|
+
// independent creation times, which is exactly the evidence milestones
|
|
224
|
+
// consumes, and exercise reached/next/ETA rendering in one small fixture.
|
|
225
|
+
for (let i = 2; i < 25; i++) {
|
|
226
|
+
const version = `v1.0.${i}`;
|
|
227
|
+
const tagDate = `2026-08-29T00:${String(i).padStart(2, '0')}:00+09:00`;
|
|
228
|
+
requireGitSuccess(`git tag ${version}`, git(['tag', '-a', version, '-m', version], {
|
|
229
|
+
GIT_COMMITTER_DATE: tagDate,
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
requireNoHangul('release cadence with measured history', run(['release', 'cadence', '--path', project]));
|
|
233
|
+
requireNoHangul('round-history with tags', run(['round-history', '--path', project]));
|
|
234
|
+
requireNoHangul('milestones with reached history', run(['milestones', '--path', project]));
|
|
235
|
+
|
|
236
|
+
// Machine payloads remain the canonical, locale-independent contract. The
|
|
237
|
+
// idempotency timestamp is intentionally volatile and is normalized only
|
|
238
|
+
// for this equality assertion.
|
|
239
|
+
for (const [label, args] of surfaces) {
|
|
240
|
+
const en = normalizedJson(`${label} English JSON`, run([...args, '--json']));
|
|
241
|
+
const ko = normalizedJson(`${label} Korean JSON`, run([...args, '--json'], { ...baseEnv, LEERNESS_LANG: 'ko' }));
|
|
242
|
+
if (JSON.stringify(en) !== JSON.stringify(ko)) {
|
|
243
|
+
throw new Error(`${label} JSON contract changed with UI language`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const koEnv = { ...baseEnv, LEERNESS_LANG: 'ko' };
|
|
248
|
+
const koreanAnchors = new Map([
|
|
249
|
+
['release cadence', ['릴리스 빈도 진단', '누적 릴리스', '권장:']],
|
|
250
|
+
['idempotency audit', ['위반 발견', '중복 룰']],
|
|
251
|
+
['plan list', ['완료기준(Done-When)', 'Tasks:', '완료)']],
|
|
252
|
+
['round-history', ['자율 라운드 통계', '누적 라운드', '최근 10 tags']],
|
|
253
|
+
['milestones', ['도달 마일스톤', '총 라운드', '다음 마일스톤', '라운드 남음']],
|
|
254
|
+
]);
|
|
255
|
+
for (const [label, args] of surfaces) {
|
|
256
|
+
const output = requireSuccess(`${label} Korean control`, run(args, koEnv));
|
|
257
|
+
const missing = koreanAnchors.get(label).filter(anchor => !output.includes(anchor));
|
|
258
|
+
if (missing.length) throw new Error(`${label} Korean control lost anchors: ${missing.join(', ')}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Exercise the terminal 500+ renderer without spawning another 475 `git tag`
|
|
262
|
+
// processes. Lightweight semver refs are valid release tags and one atomic
|
|
263
|
+
// update-ref process gives the real CLI a 500-tag history.
|
|
264
|
+
const headResult = git(['rev-parse', 'HEAD']);
|
|
265
|
+
requireGitSuccess('git resolve terminal milestone fixture', headResult);
|
|
266
|
+
const head = String(headResult.stdout || '').trim();
|
|
267
|
+
const refs = [];
|
|
268
|
+
for (let i = 25; i < 500; i++) refs.push(`create refs/tags/v1.0.${i} ${head}`);
|
|
269
|
+
requireGitSuccess('git create terminal milestone fixture', git(['update-ref', '--stdin'], {}, refs.join('\n') + '\n'));
|
|
270
|
+
const terminalEn = requireNoHangul('milestones all reached (English)', run(['milestones', '--path', project, '--language', 'en']));
|
|
271
|
+
if (!terminalEn.includes('all milestones reached (500+)')) {
|
|
272
|
+
throw new Error('milestones English terminal branch lost its 500+ completion marker');
|
|
273
|
+
}
|
|
274
|
+
const terminalKo = requireSuccess('milestones all reached (Korean)', run(['milestones', '--path', project], koEnv));
|
|
275
|
+
if (!terminalKo.includes('모든 마일스톤 달성 (500+)')) {
|
|
276
|
+
throw new Error('milestones Korean terminal branch lost its 500+ completion marker');
|
|
277
|
+
}
|
|
278
|
+
const terminalJson = normalizedJson('milestones terminal JSON', run(['milestones', '--path', project, '--json']));
|
|
279
|
+
if (terminalJson.totalRounds !== 500 || terminalJson.next !== null
|
|
280
|
+
|| !Array.isArray(terminalJson.reached) || terminalJson.reached.at(-1)?.milestone !== 500) {
|
|
281
|
+
throw new Error('milestones terminal JSON did not preserve the 500-round contract');
|
|
282
|
+
}
|
|
283
|
+
|
|
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');
|
|
285
|
+
} catch (error) {
|
|
286
|
+
console.error(`✗ ${error && error.message ? error.message : error}`);
|
|
287
|
+
process.exitCode = 1;
|
|
288
|
+
} finally {
|
|
289
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
290
|
+
}
|