leerness 1.36.179 → 1.36.181

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.181 — 2026-08-30
4
+
5
+ - 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.
6
+
7
+ ## 1.36.180 — 2026-08-30
8
+
9
+ - 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.
10
+
3
11
  ## 1.36.179 — 2026-08-30
4
12
 
5
13
  - T-0091: parent-detect selftest를 OS 임시 디렉터리 상위의 외부 .leerness와 격리하고, 두 번째 temp 생성 실패 시 첫 fixture를 정리하도록 셋업 수명을 보강. 동일한 rule-add selftest 형제 경로와 결정론적 회귀 probe를 fast/core/full 게이트에 연결.
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.179 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
141
+ 이 프로젝트는 Leerness v1.36.181 하네스를 사용합니다. 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.179는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
195
+ Leerness v1.36.181는 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.179는 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.179 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
216
+ 현재 누적: **v1.9.x → 1.36.181 릴리스 태그 이력** (수백 라운드) · _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.179: 2026-08-30
254
+ Last synced by Leerness v1.36.181: 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.179';
52
+ const VERSION = '1.36.181';
53
53
 
54
54
  // MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
55
55
  // 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
@@ -11443,19 +11443,28 @@ function commandsCmd(root) {
11443
11443
  // 1.9.374 (UR-0074): release cadence — 릴리스 빈도 진단 + 권장 (외부리뷰 반복 지적 "릴리스 케이던스 과다" 가시화). git tag 재사용, 읽기 전용.
11444
11444
  function releaseCadenceCmd(root) {
11445
11445
  root = absRoot(root);
11446
+ const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
11446
11447
  const rh = _computeRoundHistory(root);
11447
11448
  if (rh.gitHistoryState !== 'available') _throwGitHistoryUnavailable(rh.gitHistoryError);
11448
11449
  const a = _cadenceAssessment(rh.avgRoundsPerDay, rh.roundCount, rh.daysActive);
11449
11450
  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
- log(`# leerness release cadence (1.9.374, UR-0074) — 릴리스 빈도 진단 (읽기 전용)`);
11451
- const observedRate = a.dataSufficient ? `${a.releasesPerDay}/day` : '관측 불가 (서로 다른 시각의 태그 2개 이상 필요)';
11452
- log(` 누적 릴리스: ${a.total} · 활동일: ${a.daysActive}일 · 빈도: ${observedRate}`);
11453
- log(` 수준: ${a.level}`);
11454
- log(` 권장: ${a.recommendation}`);
11455
- log(`\n 관련: leerness release channel (stable/next 정책) · leerness install-safety (공급망 신뢰)`);
11451
+ const recommendationEn = {
11452
+ 'insufficient-data': 'Defer the cadence assessment until at least two release tags have distinct timestamps.',
11453
+ '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.',
11454
+ high: 'Cadence is high: group related changes to reduce publish frequency and document runtime and verification evidence in each release note.',
11455
+ moderate: 'Cadence is moderate; consider batching into minor releases when stability is the priority.',
11456
+ healthy: 'Cadence is healthy.',
11457
+ }[a.level] || a.recommendation;
11458
+ log(t(`# leerness release cadence (1.9.374, UR-0074) — 릴리스 빈도 진단 (읽기 전용)`, `# leerness release cadence (1.9.374, UR-0074) — release-frequency diagnosis (read-only)`));
11459
+ const observedRate = a.dataSufficient ? `${a.releasesPerDay}/day` : t('관측 불가 (서로 다른 시각의 태그 2개 이상 필요)', 'unavailable (requires at least two tags with distinct timestamps)');
11460
+ log(t(` 누적 릴리스: ${a.total} · 활동일: ${a.daysActive}일 · 빈도: ${observedRate}`, ` total releases: ${a.total} · active days: ${a.daysActive} · frequency: ${observedRate}`));
11461
+ log(t(` 수준: ${a.level}`, ` level: ${a.level}`));
11462
+ log(t(` 권장: ${a.recommendation}`, ` recommendation: ${recommendationEn}`));
11463
+ 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
11464
  }
11457
11465
  function roundHistoryCmd(root) {
11458
11466
  root = absRoot(root);
11467
+ const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
11459
11468
  const isTty = process.stdout && process.stdout.isTTY;
11460
11469
  const cy = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
11461
11470
  const gr = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
@@ -11464,25 +11473,27 @@ function roundHistoryCmd(root) {
11464
11473
  const data = _computeRoundHistory(root);
11465
11474
  if (data.gitHistoryState !== 'available') _throwGitHistoryUnavailable(data.gitHistoryError);
11466
11475
  if (has('--json')) { log(JSON.stringify(data, null, 2)); return; }
11467
- log(cy(`# leerness round-history (1.9.226) — 자율 라운드 통계`));
11476
+ log(cy(t(`# leerness round-history (1.9.226) — 자율 라운드 통계`, `# leerness round-history (1.9.226) — autonomous-round statistics`)));
11468
11477
  log('');
11469
11478
  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))}`);
11479
+ if (data.harnessVersion) log(t(` 🧩 프로젝트 하네스: ${gr(data.harnessVersion)}`, ` 🧩 project harness: ${gr(data.harnessVersion)}`));
11480
+ else if (data.harnessVersionState === 'invalid' || data.harnessVersionState === 'unreadable') log(yl(t(` 🧩 프로젝트 하네스: 판독 불가 (${data.harnessVersionState})`, ` 🧩 project harness: unreadable (${data.harnessVersionState})`)));
11481
+ else if (data.harnessVersionState === 'missing') log(yl(t(` 🧩 프로젝트 하네스: 없음 (missing)`, ` 🧩 project harness: missing`)));
11482
+ if (data.latestTagVersion) log(t(` 🏷️ 저장소 최근 태그: v${data.latestTagVersion}`, ` 🏷️ latest repository tag: v${data.latestTagVersion}`));
11483
+ log(t(` 🔄 누적 라운드: ${gr(String(data.roundCount))}`, ` 🔄 total rounds: ${gr(String(data.roundCount))}`));
11475
11484
  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')}`);
11485
+ if (data.latestTagAt) log(t(` ⏰ 최근 tag: ${(data.latestTagAt || '').split('T')[0]}`, ` ⏰ latest tag: ${(data.latestTagAt || '').split('T')[0]}`));
11486
+ if (data.daysActive > 0) log(t(` 📊 활동: ${data.daysActive}일 / 평균 ${gr(data.avgRoundsPerDay + ' rounds/day')}`, ` 📊 activity: ${data.daysActive} days / average ${gr(data.avgRoundsPerDay + ' rounds/day')}`));
11478
11487
  log('');
11479
11488
  if (data.nextMilestone != null) {
11480
- log(yl(` 🎯 다음 마일스톤: R${data.nextMilestone} (${data.roundsToNextMilestone} 라운드 남음)`));
11489
+ log(yl(t(` 🎯 다음 마일스톤: R${data.nextMilestone} (${data.roundsToNextMilestone} 라운드 남음)`, ` 🎯 next milestone: R${data.nextMilestone} (${data.roundsToNextMilestone} rounds remaining)`)));
11481
11490
  } else {
11482
- log(data && (data.roundCount || data.totalRounds) ? gr(` 🎉 모든 마일스톤 달성 (500+)`) : dm(` (이 저장소 계보의 릴리스 태그 이력 없음)`)); // 1.36.38 (#4): 이력 0 인데 달성 축하 금지
11491
+ log(data && (data.roundCount || data.totalRounds)
11492
+ ? gr(t(` 🎉 모든 마일스톤 달성 (500+)`, ` 🎉 all milestones reached (500+)`))
11493
+ : dm(t(` (이 저장소 계보의 릴리스 태그 이력 없음)`, ` (no release-tag history for this repository lineage)`))); // 1.36.38 (#4): 이력 0 인데 달성 축하 금지
11483
11494
  }
11484
11495
  log('');
11485
- log(` 최근 10 tags:`);
11496
+ log(t(` 최근 10 tags:`, ` latest 10 tags:`));
11486
11497
  data.latestTags.forEach(t => log(` • v${t.version} (${t.date})`));
11487
11498
  }
11488
11499
 
@@ -13060,7 +13071,9 @@ function migrateWorkspaceDirCmd(root) {
13060
13071
  // 3. user-requests.json — 동일 텍스트 + open (1.9.207 자체 dedup 검증)
13061
13072
  // 4. active-wakeups.json — 동일 expectedFireAt 중복 (1.9.205 dedup 검증)
13062
13073
  // 5. next-action-queue.json — 동일 title 중복 (1.9.201 dedup 검증)
13063
- function _runIdempotencyAudit(root) {
13074
+ function _runIdempotencyAudit(root, lang = 'ko') {
13075
+ const t = (ko, en) => (lang === 'en' ? en : ko);
13076
+ const auditError = (area, error) => t(String(error && (error.message || error) || 'audit error'), `Could not audit ${area} safely (${String(error && error.code || 'read-error')})`);
13064
13077
  const audit = {
13065
13078
  auditedAt: new Date().toISOString(),
13066
13079
  auditVersion: VERSION,
@@ -13078,7 +13091,7 @@ function _runIdempotencyAudit(root) {
13078
13091
  audit.violations.push({
13079
13092
  kind: 'rule-duplicate',
13080
13093
  location: '.leerness/rules.md',
13081
- detail: `중복 룰: ${r.id} == ${seen.get(key)} (${r.trigger}: ${r.rule.slice(0, 60)})`,
13094
+ 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
13095
  severity: 'medium',
13083
13096
  fix: `leerness rule remove ${r.id}`
13084
13097
  });
@@ -13087,9 +13100,9 @@ function _runIdempotencyAudit(root) {
13087
13100
  }
13088
13101
  }
13089
13102
  if (audit.violations.filter(v => v.kind === 'rule-duplicate').length === 0) {
13090
- audit.verified.push({ kind: 'rules', detail: `${active.length} active rules, 중복 0건` });
13103
+ audit.verified.push({ kind: 'rules', detail: t(`${active.length} active rules, 중복 0건`, `${active.length} active rules, 0 duplicates`) });
13091
13104
  }
13092
- } catch (e) { audit.violations.push({ kind: 'audit-error', area: 'rules', detail: String(e.message || e), severity: 'low' }); }
13105
+ } catch (e) { audit.violations.push({ kind: 'audit-error', area: 'rules', detail: auditError('rules', e), severity: 'low' }); }
13093
13106
 
13094
13107
  // 2) progress-tracker.md 중복 request 검사
13095
13108
  // 포맷: | T-XXXX | status | request | evidence | nextAction | date |
@@ -13114,19 +13127,19 @@ function _runIdempotencyAudit(root) {
13114
13127
  audit.violations.push({
13115
13128
  kind: 'task-duplicate-request',
13116
13129
  location: '.leerness/progress-tracker.md',
13117
- detail: `중복 request: "${text.slice(0, 50)}…" (${prev.id} & ${id})`,
13130
+ detail: t(`중복 request: "${text.slice(0, 50)}…" (${prev.id} & ${id})`, `Duplicate request: "${text.slice(0, 50)}…" (${prev.id} & ${id})`),
13118
13131
  severity: 'medium',
13119
- fix: `중복 task 중 하나를 leerness task drop <id> 처리`
13132
+ fix: t(`중복 task 중 하나를 leerness task drop <id> 처리`, `drop one duplicate with leerness task drop <id>`)
13120
13133
  });
13121
13134
  } else {
13122
13135
  requests.set(text, { id, status, line: i + 1 });
13123
13136
  }
13124
13137
  }
13125
13138
  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건` });
13139
+ audit.verified.push({ kind: 'tasks', detail: t(`${requests.size} active tasks, request 중복 0건`, `${requests.size} active tasks, 0 duplicate requests`) });
13127
13140
  }
13128
13141
  }
13129
- } catch (e) { audit.violations.push({ kind: 'audit-error', area: 'tasks', detail: String(e.message || e), severity: 'low' }); }
13142
+ } catch (e) { audit.violations.push({ kind: 'audit-error', area: 'tasks', detail: auditError('tasks', e), severity: 'low' }); }
13130
13143
 
13131
13144
  // 3) user-requests.json 자체 dedup 검증 (1.9.207)
13132
13145
  try {
@@ -13139,7 +13152,7 @@ function _runIdempotencyAudit(root) {
13139
13152
  audit.violations.push({
13140
13153
  kind: 'user-request-duplicate',
13141
13154
  location: '.leerness/user-requests.json',
13142
- detail: `중복 open 요청: ${r.id} == ${seen.get(k)} ("${k.slice(0, 50)}…")`,
13155
+ 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
13156
  severity: 'low',
13144
13157
  fix: `leerness requests drop ${r.id}`
13145
13158
  });
@@ -13148,9 +13161,9 @@ function _runIdempotencyAudit(root) {
13148
13161
  }
13149
13162
  }
13150
13163
  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)` });
13164
+ 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
13165
  }
13153
- } catch (e) { audit.violations.push({ kind: 'audit-error', area: 'user-requests', detail: String(e.message || e), severity: 'low' }); }
13166
+ } catch (e) { audit.violations.push({ kind: 'audit-error', area: 'user-requests', detail: auditError('user-requests', e), severity: 'low' }); }
13154
13167
 
13155
13168
  // 4) active-wakeups.json 검증 (1.9.205)
13156
13169
  try {
@@ -13162,16 +13175,16 @@ function _runIdempotencyAudit(root) {
13162
13175
  audit.violations.push({
13163
13176
  kind: 'wakeup-duplicate',
13164
13177
  location: '.leerness/active-wakeups.json',
13165
- detail: `동일 expectedFireAt 중복: ${w.expectedFireAt}`,
13178
+ detail: t(`동일 expectedFireAt 중복: ${w.expectedFireAt}`, `Duplicate expectedFireAt: ${w.expectedFireAt}`),
13166
13179
  severity: 'high',
13167
- fix: `leerness 자동 _recordWakeup filter dedup 검토 필요`
13180
+ fix: t(`leerness 자동 _recordWakeup filter dedup 검토 필요`, `inspect the automatic leerness _recordWakeup dedup filter`)
13168
13181
  });
13169
13182
  } else {
13170
13183
  seenT.set(w.expectedFireAt, true);
13171
13184
  }
13172
13185
  }
13173
13186
  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)` });
13187
+ 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
13188
  }
13176
13189
  } catch {}
13177
13190
 
@@ -13252,6 +13265,7 @@ function _autoFixIdempotency(root) {
13252
13265
  }
13253
13266
  function idempotencyCmd(root, sub) {
13254
13267
  root = absRoot(root);
13268
+ const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
13255
13269
  const isTty = process.stdout && process.stdout.isTTY;
13256
13270
  const cyan = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
13257
13271
  const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
@@ -13260,23 +13274,22 @@ function idempotencyCmd(root, sub) {
13260
13274
  const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
13261
13275
 
13262
13276
  if (!sub || sub === 'help' || sub === '--help') {
13263
- const _L = _uiLang(root); const _t = (ko, en) => _L === 'en' ? en : ko; // 1.23.2 (UR-0010 Phase 7)
13264
- log(_t(`# leerness idempotency (1.9.212) — 멱등성 위반 탐지`, `# leerness idempotency — detect idempotency violations`));
13277
+ log(t(`# leerness idempotency (1.9.212) 멱등성 위반 탐지`, `# leerness idempotency detect idempotency violations`));
13265
13278
  log('');
13266
- log(_t(` audit → 워크스페이스 멱등성 점검 (rules / tasks / user-requests / wakeups) (--json 가능)`, ` audit → check workspace idempotency (rules / tasks / user-requests / wakeups) (--json)`));
13267
- 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`));
13279
+ log(t(` audit → 워크스페이스 멱등성 점검 (rules / tasks / user-requests / wakeups) (--json 가능)`, ` audit → check workspace idempotency (rules / tasks / user-requests / wakeups) (--json)`));
13280
+ 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
13281
  log('');
13269
- log(dim(_t(` dedup 적용 영역: ruleAdd / taskAdd (1.9.212) + _recordUserRequest (1.9.207) + _recordWakeup (1.9.205)`, ` dedup applies to: ruleAdd / taskAdd + _recordUserRequest + _recordWakeup`)));
13270
- 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`)));
13271
- log(dim(_t(` 자동화: drift check --auto-fix 가 idempotency 중복도 자동 정리 (1.9.293)`, ` automation: drift check --auto-fix also dedups idempotency violations`)));
13272
- log(dim(_t(` opt-out: --force 플래그로 dedup 우회 가능`, ` opt-out: pass --force to bypass dedup`)));
13282
+ log(dim(t(` dedup 적용 영역: ruleAdd / taskAdd (1.9.212) + _recordUserRequest (1.9.207) + _recordWakeup (1.9.205)`, ` dedup applies to: ruleAdd / taskAdd + _recordUserRequest + _recordWakeup`)));
13283
+ 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`)));
13284
+ log(dim(t(` 자동화: drift check --auto-fix 가 idempotency 중복도 자동 정리 (1.9.293)`, ` automation: drift check --auto-fix also dedups idempotency violations`)));
13285
+ log(dim(t(` opt-out: --force 플래그로 dedup 우회 가능`, ` opt-out: pass --force to bypass dedup`)));
13273
13286
  return;
13274
13287
  }
13275
13288
 
13276
13289
  if (sub === 'audit') {
13277
13290
  const autoFix = has('--auto-fix');
13278
13291
  const fixes = autoFix ? _autoFixIdempotency(root) : [];
13279
- const audit = _runIdempotencyAudit(root); // auto-fix 상태 반영
13292
+ const audit = _runIdempotencyAudit(root, has('--json') ? 'ko' : _L); // JSON 기존 canonical payload, 사람 출력만 locale 적용
13280
13293
  if (autoFix) audit.autoFixed = fixes;
13281
13294
  // 1.36.131 (검수 P1): --json 도 같은 계약 — auto-fix 가 실패했으면 성공 종료코드로 나가지 않는다.
13282
13295
  if (autoFix && fixes.some(f => f.action === 'error')) { audit.autoFixFailed = fixes.filter(f => f.action === 'error').length; process.exitCode = 1; }
@@ -13286,9 +13299,9 @@ function idempotencyCmd(root, sub) {
13286
13299
  log('');
13287
13300
  const s = audit.summary;
13288
13301
  if (s.overall === 'clean') {
13289
- log(grn(` ✓ 멱등성 위반 없음 — verified ${s.verifiedAreas} 영역`));
13302
+ log(grn(t(` ✓ 멱등성 위반 없음 — verified ${s.verifiedAreas} 영역`, ` ✓ no idempotency violations — verified ${s.verifiedAreas} areas`)));
13290
13303
  } else {
13291
- log(red(` ⚠ ${s.totalViolations} 위반 발견 (high: ${s.highSeverity}, medium: ${s.mediumSeverity}, low: ${s.lowSeverity})`));
13304
+ 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
13305
  }
13293
13306
  log('');
13294
13307
  if (audit.verified.length) {
@@ -13313,18 +13326,18 @@ function idempotencyCmd(root, sub) {
13313
13326
  const _applied = fixes.filter(f => f.action !== 'error');
13314
13327
  const _failedFix = fixes.filter(f => f.action === 'error');
13315
13328
  if (_applied.length) {
13316
- log(grn(`## 🔧 auto-fix 적용 (${_applied.length})`));
13329
+ log(grn(t(`## 🔧 auto-fix 적용 (${_applied.length})`, `## 🔧 auto-fix applied (${_applied.length})`)));
13317
13330
  _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 처리`);
13331
+ 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`));
13332
+ 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
13333
  else log(dim(` - [${f.kind}] ${f.action}${f.detail ? ' — ' + f.detail : ''}`));
13321
13334
  });
13322
13335
  } else if (!_failedFix.length) {
13323
- log(dim(' 🔧 auto-fix: 적용할 중복 없음 (이미 정합)'));
13336
+ log(dim(t(' 🔧 auto-fix: 적용할 중복 없음 (이미 정합)', ' 🔧 auto-fix: no duplicates to apply (already consistent)')));
13324
13337
  }
13325
13338
  if (_failedFix.length) {
13326
- log(red(`## ❌ auto-fix 실패 (${_failedFix.length}) — 고쳐지지 않았다. 수동 확인 필요`));
13327
- _failedFix.forEach(f => log(` - [${f.kind}] ${f.detail || '원인 미상'}`));
13339
+ log(red(t(`## ❌ auto-fix 실패 (${_failedFix.length}) — 고쳐지지 않았다. 수동 확인 필요`, `## ❌ auto-fix failed (${_failedFix.length}) — state was not repaired; inspect it manually`)));
13340
+ _failedFix.forEach(f => log(` - [${f.kind}] ${_L === 'en' ? 'could not repair safely' : (f.detail || '원인 미상')}`));
13328
13341
  process.exitCode = 1;
13329
13342
  }
13330
13343
  }
@@ -14782,6 +14795,7 @@ function planInit(root) {
14782
14795
  // 1.9.119: plan list — plan.md 의 모든 milestone (M-XXXX) 조회 (CLI + --json + MCP)
14783
14796
  function planListCmd(root, opts = {}) {
14784
14797
  root = absRoot(root);
14798
+ const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
14785
14799
  const jsonMode = !!opts.json || has('--json');
14786
14800
  const pp = planPath(root);
14787
14801
  if (!exists(pp)) {
@@ -14789,7 +14803,7 @@ function planListCmd(root, opts = {}) {
14789
14803
  process.stdout.write(JSON.stringify({ version: VERSION, root, total: 0, milestones: [] }, null, 2) + '\n');
14790
14804
  return;
14791
14805
  }
14792
- return ok('plan.md 없음 — leerness plan add "<text>" 로 첫 milestone 등록');
14806
+ return ok(t('plan.md 없음 — leerness plan add "<text>" 로 첫 milestone 등록', 'plan.md not found — add the first milestone with leerness plan add "<text>"'));
14793
14807
  }
14794
14808
  // 1.36.21 (codex #8a, 전수 sweep 3 에이전트 수렴): CRLF plan.md 에서 모든 마일스톤의 tasks 가 조용히 [] 로 증발하던 것 수정.
14795
14809
  // 체크박스 정규식(/^-\s*\[([\sx])\]\s*(.+)$/)이 /m 없이 후행 \r 앞에서 $ 를 못 잡아 실패 — id/title/status/progress 는 /m 이라 정상 파싱되어
@@ -14831,14 +14845,15 @@ function planListCmd(root, opts = {}) {
14831
14845
  return;
14832
14846
  }
14833
14847
  log(`# 🗺 Plan (1.9.119)\n`);
14834
- if (!milestones.length) return ok('plan milestones 비어있음');
14835
- log(`총 ${milestones.length}개 milestone:`);
14848
+ if (!milestones.length) return ok(t('plan milestones 비어있음', 'plan milestones are empty'));
14849
+ log(t(`총 ${milestones.length}개 milestone:`, `${milestones.length} milestones:`));
14836
14850
  for (const m of milestones) {
14837
14851
  log(`\n[${m.id}] ${m.title}`);
14838
14852
  if (m.status) log(` Status: ${m.status}`);
14839
14853
  if (m.progress) log(` Progress: ${m.progress}`);
14840
- log(` 완료기준(Done-When): ${m.doneWhen || '⚠ 미정 plan add ... --done-when "<검증가능 조건>" 권장 (Karpathy 원칙4)'}`);
14841
- if (m.tasks.length) log(` Tasks: ${m.tasks.length} (${m.tasks.filter(t => t.done).length} 완료)`);
14854
+ const doneWhen = _L === 'en' && m.doneWhen === '(미정)' ? '(unset)' : m.doneWhen;
14855
+ 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)'}`));
14856
+ 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
14857
  }
14843
14858
  }
14844
14859
 
@@ -21608,7 +21623,7 @@ function _dispatchCommand(agentId, task, writeMode, model) {
21608
21623
 
21609
21624
  const _agents = require('../lib/agents');
21610
21625
  // 1.9.424 (UR-0025/UR-0125 큰 핸들러 모듈화 9번째): agentsCmd → lib/agents.js (DI 위임, rest→array)
21611
- function agentsCmd(root, sub, ...args) { return _agents.agentsCmd(root, sub, args, { VERSION, has, arg, _agentSlashHint, _allProviders, _checkAgent, _cliChat, _dispatchCommand, _harnessBrief: _tgl.toggleOn(root, 'delegation-brief') ? _harnessBrief : undefined, _loadEnvFile, _normalizeRole, _policyEnforce, _readUserProviders, _recommendAgent, _recordRun, _resolveRole, lessonsPath, taskLogPath }); } // 1.36.30: delegation-brief 토글 OFF 면 브리프 미접두(=--raw 경로)
21626
+ function agentsCmd(root, sub, ...args) { return _agents.agentsCmd(root, sub, args, { VERSION, has, arg, uiLang: _uiLang(root), _agentSlashHint, _allProviders, _checkAgent, _cliChat, _dispatchCommand, _harnessBrief: _tgl.toggleOn(root, 'delegation-brief') ? _harnessBrief : undefined, _loadEnvFile, _normalizeRole, _policyEnforce, _readUserProviders, _recommendAgent, _recordRun, _resolveRole, lessonsPath, taskLogPath }); } // 1.36.30: delegation-brief 토글 OFF 면 브리프 미접두(=--raw 경로)
21612
21627
 
21613
21628
  function personaCmd(root, sub, idOrName, ...rest) {
21614
21629
  root = absRoot(root || process.cwd());
@@ -22069,7 +22084,8 @@ function _retroOneLine(agg, lang) {
22069
22084
  // 1.9.169 fix: --include 명시되면 cwd 자동 추가 안 함 (explicit-only).
22070
22085
  // 기존: cwd/.leerness 자동 추가 → 잔존 .leerness 시 의도치 않은 카운트 증가 (e2e flake 원인)
22071
22086
  // 변경: --include 시 사용자가 명시한 경로만 사용. --all-apps 단독은 기존 동작 유지.
22072
- function _collectWorkspacePaths(rootBase) {
22087
+ function _collectWorkspacePaths(rootBase, uiLang = 'ko') {
22088
+ const t = (ko, en) => (uiLang === 'en' ? en : ko);
22073
22089
  const set = new Set();
22074
22090
  const include = arg('--include', null);
22075
22091
  // --include 명시 시 cwd 자동 추가 스킵 (explicit-only 보장)
@@ -22094,7 +22110,7 @@ function _collectWorkspacePaths(rootBase) {
22094
22110
  for (const p of String(include).split(',')) {
22095
22111
  const abs = path.resolve(p.trim());
22096
22112
  if (exists(path.join(abs, '.leerness'))) set.add(abs);
22097
- else warn(`--include 무시: ${abs} (.leerness 없음)`);
22113
+ else warn(t(`--include 무시: ${abs} (.leerness 없음)`, `Ignoring --include: ${abs} (no .leerness directory)`));
22098
22114
  }
22099
22115
  }
22100
22116
  return Array.from(set);
@@ -22221,9 +22237,10 @@ function _retroWorkspace(rootBase, cutoff) {
22221
22237
 
22222
22238
  function insightsCmd(root) {
22223
22239
  root = absRoot(root);
22240
+ const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
22224
22241
  // 1.9.15: --all-apps / --include 통합 모드
22225
22242
  if (has('--all-apps') || arg('--include', null)) {
22226
- return _insightsWorkspace(root);
22243
+ return _insightsWorkspace(root, _L);
22227
22244
  }
22228
22245
  const agg = _retroAggregate(root); // insights 는 누적 지표 명령 — 기간 필터 없음(retro 만 --days). 1.36.38 광역치환 오적용 교정.
22229
22246
  // 1.9.16: --json
@@ -22233,49 +22250,50 @@ function insightsCmd(root) {
22233
22250
  return;
22234
22251
  }
22235
22252
  const sc = readSessionCounter(root);
22236
- log(`# Insights — 누적 통계`);
22237
- log(`\n## 📊 핵심 지표`);
22238
- log(` - 누적 task: ${agg.totalTasks} (done ${agg.doneCount}, in-progress ${agg.statusCounts['in-progress']}, planned ${agg.statusCounts.planned})`);
22239
- log(` - 누적 결정 (decisions.md): ${agg.decisionBlocks}건`);
22240
- log(` - 누적 스킬: ${agg.skillUsage.length}종`);
22241
- log(` - 총 스킬 사용: ${agg.totalSkillUsage}회`);
22242
- log(` - 총 최적화 누적: ${agg.totalOptimizations}건`);
22243
- log(` - 활성 룰: ${agg.activeRules}건 (검증 ${agg.verifiedRules}건)`);
22244
- log(` - session close 횟수: ${sc.count}회${sc.lastCloseAt ? ' (마지막: ' + sc.lastCloseAt.slice(0, 16) + ')' : ''}`);
22253
+ log(t(`# Insights — 누적 통계`, `# Insights — cumulative statistics`));
22254
+ log(t(`\n## 📊 핵심 지표`, `\n## 📊 Key metrics`));
22255
+ log(t(` - 누적 task: ${agg.totalTasks} (done ${agg.doneCount}, in-progress ${agg.statusCounts['in-progress']}, planned ${agg.statusCounts.planned})`, ` - Tasks: ${agg.totalTasks} (done ${agg.doneCount}, in-progress ${agg.statusCounts['in-progress']}, planned ${agg.statusCounts.planned})`));
22256
+ log(t(` - 누적 결정 (decisions.md): ${agg.decisionBlocks}건`, ` - Decisions (decisions.md): ${agg.decisionBlocks}`));
22257
+ log(t(` - 누적 스킬: ${agg.skillUsage.length}종`, ` - Skills: ${agg.skillUsage.length}`));
22258
+ log(t(` - 총 스킬 사용: ${agg.totalSkillUsage}회`, ` - Total skill uses: ${agg.totalSkillUsage}`));
22259
+ log(t(` - 총 최적화 누적: ${agg.totalOptimizations}건`, ` - Total optimizations: ${agg.totalOptimizations}`));
22260
+ log(t(` - 활성 룰: ${agg.activeRules}건 (검증 ${agg.verifiedRules}건)`, ` - Active rules: ${agg.activeRules} (${agg.verifiedRules} verified)`));
22261
+ log(t(` - session close 횟수: ${sc.count}회${sc.lastCloseAt ? ' (마지막: ' + sc.lastCloseAt.slice(0, 16) + ')' : ''}`, ` - Session closes: ${sc.count}${sc.lastCloseAt ? ' (last: ' + sc.lastCloseAt.slice(0, 16) + ')' : ''}`));
22245
22262
 
22246
22263
  if (agg.skillUsage.length) {
22247
- log(`\n## 🏆 가장 활용도 높은 스킬 (top 5)`);
22248
- agg.skillUsage.slice(0, 5).forEach((s, i) => log(` ${i + 1}. ${s.id} (${s.displayNameKo}) — 사용 ${s.count}회, 최적화 ${s.optimizations}건`));
22264
+ log(t(`\n## 🏆 가장 활용도 높은 스킬 (top 5)`, `\n## 🏆 Most-used skills (top 5)`));
22265
+ agg.skillUsage.slice(0, 5).forEach((s, i) => log(t(` ${i + 1}. ${s.id} (${s.displayNameKo}) — 사용 ${s.count}회, 최적화 ${s.optimizations}건`, ` ${i + 1}. ${s.id} — ${s.count} uses, ${s.optimizations} optimizations`)));
22249
22266
  }
22250
22267
 
22251
22268
  if (agg.durations.length) {
22252
22269
  const total = agg.durations.reduce((a, b) => a + b, 0);
22253
- log(`\n## ⏱ 검증 시간 (verify-code)`);
22254
- log(` - 실행: ${agg.durations.length}회 / 총 ${total}ms / 평균 ${Math.round(total / agg.durations.length)}ms`);
22255
- log(` - 최소 ${Math.min(...agg.durations)}ms / 최대 ${Math.max(...agg.durations)}ms`);
22270
+ log(t(`\n## ⏱ 검증 시간 (verify-code)`, `\n## ⏱ Verification time (verify-code)`));
22271
+ log(t(` - 실행: ${agg.durations.length}회 / 총 ${total}ms / 평균 ${Math.round(total / agg.durations.length)}ms`, ` - Runs: ${agg.durations.length} / total ${total}ms / average ${Math.round(total / agg.durations.length)}ms`));
22272
+ log(t(` - 최소 ${Math.min(...agg.durations)}ms / 최대 ${Math.max(...agg.durations)}ms`, ` - Minimum ${Math.min(...agg.durations)}ms / maximum ${Math.max(...agg.durations)}ms`));
22256
22273
  }
22257
22274
 
22258
- log(`\n## 🔁 안정성 지표`);
22259
- log(` - pass 시그널: ${agg.passSignals} · fix 시그널: ${agg.fixSignals}`);
22275
+ log(t(`\n## 🔁 안정성 지표`, `\n## 🔁 Stability signals`));
22276
+ log(t(` - pass 시그널: ${agg.passSignals} · fix 시그널: ${agg.fixSignals}`, ` - Pass signals: ${agg.passSignals} · fix signals: ${agg.fixSignals}`));
22260
22277
  const ratio = agg.fixSignals > 0 ? (agg.passSignals / agg.fixSignals).toFixed(2) : '∞';
22261
- log(` - pass/fix 비율: ${ratio}${ratio === '∞' || parseFloat(ratio) > 3 ? ' (안정)' : parseFloat(ratio) < 1 ? ' (디버그 위주)' : ' (보통)'}`);
22278
+ log(t(` - pass/fix 비율: ${ratio}${ratio === '∞' || parseFloat(ratio) > 3 ? ' (안정)' : parseFloat(ratio) < 1 ? ' (디버그 위주)' : ' (보통)'}`, ` - Pass/fix ratio: ${ratio}${ratio === '∞' || parseFloat(ratio) > 3 ? ' (stable)' : parseFloat(ratio) < 1 ? ' (debug-heavy)' : ' (normal)'}`));
22262
22279
 
22263
- log(`\n## 📈 권장`);
22264
- if (agg.totalOptimizations === 0) log(` - 스킬에 최적화 누적 없음 — \`leerness skill optimize <id> --before --after\`로 더 나은 방법 기록`);
22265
- if (sc.count >= 5 && sc.count % 5 === 0) log(` - 5세션마다 자동 깊은 회고가 예정되어 있습니다 — session close가 자동 호출`);
22266
- if (agg.statusCounts.blocked > 0) log(` - blocked 작업 ${agg.statusCounts.blocked}건 — \`leerness lessons --query "blocked"\`로 과거 패턴 회수`);
22280
+ log(t(`\n## 📈 권장`, `\n## 📈 Recommendations`));
22281
+ if (agg.totalOptimizations === 0) log(t(` - 스킬에 최적화 누적 없음 — \`leerness skill optimize <id> --before --after\`로 더 나은 방법 기록`, ` - No skill optimizations recorded — use \`leerness skill optimize <id> --before --after\` to record a better method`));
22282
+ if (sc.count >= 5 && sc.count % 5 === 0) log(t(` - 5세션마다 자동 깊은 회고가 예정되어 있습니다 — session close가 자동 호출`, ` - An automatic deep retrospective is scheduled every five sessions and runs during session close`));
22283
+ if (agg.statusCounts.blocked > 0) log(t(` - blocked 작업 ${agg.statusCounts.blocked}건 — \`leerness lessons --query "blocked"\`로 과거 패턴 회수`, ` - ${agg.statusCounts.blocked} blocked task(s) — use \`leerness lessons --query "blocked"\` to retrieve past patterns`));
22267
22284
  }
22268
22285
 
22269
- function _insightsWorkspace(rootBase) {
22270
- const paths = _collectWorkspacePaths(rootBase);
22271
- if (!paths.length) return fail('대상 프로젝트 없음. --include 또는 --all-apps 사용.');
22286
+ function _insightsWorkspace(rootBase, uiLang = _uiLang(rootBase)) {
22287
+ const t = (ko, en) => (uiLang === 'en' ? en : ko);
22288
+ const paths = _collectWorkspacePaths(rootBase, uiLang);
22289
+ if (!paths.length) return fail(t('대상 프로젝트 없음. --include 또는 --all-apps 사용.', 'No target projects. Use --include or --all-apps.'));
22272
22290
  // 1.9.16: --json
22273
22291
  if (has('--json')) {
22274
22292
  const projects = paths.map(p => ({ project: path.basename(p), path: p, data: _retroJsonData(_retroAggregate(p)) }));
22275
22293
  log(JSON.stringify({ projects, projectCount: paths.length }, null, 2));
22276
22294
  return;
22277
22295
  }
22278
- log(`# Workspace Insights — ${paths.length}개 프로젝트`);
22296
+ log(t(`# Workspace Insights — ${paths.length}개 프로젝트`, `# Workspace Insights — ${paths.length} project(s)`));
22279
22297
  log(`\n| Project | Task | Done % | Decisions | Skills | Usage | Opts | Pass/Fix |`);
22280
22298
  log(`|---|---|---|---|---|---|---|---|`);
22281
22299
  const totals = { tasks: 0, done: 0, decisions: 0, skills: 0, usage: 0, opts: 0, pass: 0, fix: 0 };
@@ -22291,11 +22309,11 @@ function _insightsWorkspace(rootBase) {
22291
22309
  const tpf = totals.fix ? (totals.pass / totals.fix).toFixed(1) : '∞';
22292
22310
  const tDonePct = totals.tasks ? Math.round(totals.done / totals.tasks * 100) : 0;
22293
22311
  log(`| **TOTAL** | **${totals.tasks}** | **${tDonePct}%** | **${totals.decisions}** | **${totals.skills}** | **${totals.usage}** | **${totals.opts}** | **${totals.pass}/${totals.fix} (${tpf})** |`);
22294
- log(`\n## 📈 평가`);
22295
- if (totals.pass > totals.fix * 3) log(` - 안정성: 우수 (pass÷fix = ${tpf})`);
22296
- else if (totals.pass > totals.fix) log(` - 안정성: 보통 (pass÷fix = ${tpf})`);
22297
- else if (totals.fix > 0) log(` - 안정성: 주의 (fix가 pass보다 많음) — verify-code 자동화 검토`);
22298
- if (totals.opts === 0) log(` - 최적화 누적 없음 — \`leerness skill optimize\` 활용 권장`);
22312
+ log(t(`\n## 📈 평가`, `\n## 📈 Assessment`));
22313
+ if (totals.pass > totals.fix * 3) log(t(` - 안정성: 우수 (pass÷fix = ${tpf})`, ` - Stability: strong (pass÷fix = ${tpf})`));
22314
+ else if (totals.pass > totals.fix) log(t(` - 안정성: 보통 (pass÷fix = ${tpf})`, ` - Stability: moderate (pass÷fix = ${tpf})`));
22315
+ else if (totals.fix > 0) log(t(` - 안정성: 주의 (fix가 pass보다 많음) — verify-code 자동화 검토`, ` - Stability: caution (more fix than pass signals) — consider automating verify-code`));
22316
+ if (totals.opts === 0) log(t(` - 최적화 누적 없음 — \`leerness skill optimize\` 활용 권장`, ` - No optimizations recorded — consider using \`leerness skill optimize\``));
22299
22317
  }
22300
22318
 
22301
22319
  // 1.9.16: brainstorm 핵심 로직 분리 — 단일 프로젝트 결과 반환
@@ -31603,7 +31621,7 @@ async function main() {
31603
31621
  if (cmd === 'anchors') return anchorsCmd(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd(), args[1] && !args[1].startsWith('-') ? args[1] : null); // 1.36.36: 정체성앵커 초안
31604
31622
  // 1.36.108 (T-0097): _withLock 을 넘긴다 — lib 모듈이 락을 deps 로 받는 기존 관례(clarify·referee·routing·bugfix)에
31605
31623
  // toggles 만 빠져 있어 `toggle set` 이 락 밖 read-modify-write 였다(런타임 계측으로 실측).
31606
- if (cmd === 'toggle') return _tgl.toggleCmd(arg('--path', process.cwd()), args[1], args[2], args[3], { has, VERSION, _withLock }); // 1.36.30: 기능 토글 (그래프 ⚙ 탭 연동)
31624
+ if (cmd === 'toggle') return _tgl.toggleCmd(arg('--path', process.cwd()), args[1], args[2], args[3], { has, VERSION, uiLang: _uiLang(arg('--path', process.cwd())), _withLock }); // 1.36.30: 기능 토글 (그래프 ⚙ 탭 연동)
31607
31625
  // 1.36.53 (UR-0062): 기술 프로필 · 1.36.67 (F15): 변경 시 기존 leerness.html 동반 갱신(있을 때만)
31608
31626
  if (cmd === 'tech') return _tech.techCmd(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd(), _optSub(args), { has, regenGraph: (r) => { if (exists(path.join(r, 'leerness.html'))) _graph.graphHtmlCmd(r, { _roadmapData, _loadDecisions, _loadLessons, _parseFeatureGraph, _loadToggles: _tgl.loadToggles, _toggleRegistry: _tgl.TOGGLE_REGISTRY, _loadTechProfile: _tech.loadTechProfile, quiet: true }); } });
31609
31627
  // 1.36.98 (P-0013): 재사용 인벤토리 — 새 화면 조각을 만들기 전에 '이미 있는 것' 을 먼저 본다(reuse-map 의 UI 판).
package/lib/agents.js CHANGED
@@ -44,6 +44,8 @@ function agentsCmd(root, sub, args = [], deps = {}) {
44
44
  const { VERSION, has, arg, _agentSlashHint, _allProviders, _checkAgent, _cliChat, _dispatchCommand, _harnessBrief, _loadEnvFile, _normalizeRole, _policyEnforce, _readUserProviders, _recommendAgent, _recordRun, _resolveRole, lessonsPath, taskLogPath } = deps;
45
45
  const _spawnPortable = deps._spawnPortable || spawnPortable;
46
46
  root = absRoot(root || process.cwd());
47
+ const en = deps.uiLang === 'en';
48
+ const t = (ko, enText) => (en ? enText : ko);
47
49
  // 1.9.435 (11th 외부평가 Codex P2, UR-0137): dispatch/multi task 파싱 — flag 값이 task 본문에 흡수되던 버그 수정.
48
50
  // 상위(bin)에서 args 는 '--to' flag 만 제거되고 값(codex)은 positional 로 남아 기존 filter 가 task 에 흡수시켰음.
49
51
  // → flag 에서 break + lib 가 소비하는 값-flag(--to/--model/--role/--only) 값을 제외. 명시 task 는 --task 폴백.
@@ -63,36 +65,66 @@ function agentsCmd(root, sub, args = [], deps = {}) {
63
65
  const userIds = new Set(_readUserProviders(root).map(u => u.id));
64
66
  const checks = providers.map(a => ({ ...(_checkAgent(a)), source: userIds.has(a.id) ? 'user' : 'builtin' }));
65
67
  if (has('--json')) { log(JSON.stringify({ agents: checks }, null, 2)); return; }
66
- log(`# 외부 AI CLI 오케스트레이션 (1.9.30)`);
68
+ log(t(`# 외부 AI CLI 오케스트레이션 (1.9.30)`, `# External AI CLI orchestration (1.9.30)`));
67
69
  log('');
68
- log(`| Agent | source | env (${'env=1 활성'}) | 설치 | 버전 | 상태 |`);
70
+ log(t(`| Agent | source | env (${'env=1 활성'}) | 설치 | 버전 | 상태 |`, `| Agent | source | env (env=1 enabled) | installed | version | status |`));
69
71
  log(`|---|---|---|---|---|---|`);
70
72
  for (const c of checks) {
71
73
  const envMark = c.enabled ? '✓' : '✗';
72
74
  const instMark = c.installed ? '✓' : '✗';
73
- const statusEmoji = c.status === 'ready' ? '🟢 ready' : c.status === 'not-installed' ? '⚪ 미설치' : c.status === 'disabled' ? '🟡 비활성' : '❓';
75
+ const statusEmoji = c.status === 'ready' ? '🟢 ready'
76
+ : c.status === 'not-installed' ? t('⚪ 미설치', '⚪ not installed')
77
+ : c.status === 'disabled' ? t('🟡 비활성', '🟡 disabled') : '❓';
74
78
  log(`| ${c.id} | ${c.source} | ${envMark} ${c.envFlag} | ${instMark} | ${c.version || '-'} | ${statusEmoji} |`);
75
79
  }
76
80
  // 1.36.94: 무시한 설정을 **평문에서도** 말한다 — JSON 에만 넣으면 사람은 왜 미설치로 보이는지 모른다.
77
81
  const _rejected = checks.filter(c => c.binRejected || c.versionArgsRejected);
78
82
  if (_rejected.length) {
79
83
  log('');
80
- _rejected.forEach(c => log(`⚠ ${c.id}: ${c.binRejected || c.versionArgsRejected}`));
84
+ _rejected.forEach(c => {
85
+ // Rejection reasons are canonical diagnostic fields and remain unchanged
86
+ // in --json. Human English output uses a semantic rendering so Korean
87
+ // sanitizer text (including provider-specific reasons) cannot leak.
88
+ const reason = en
89
+ ? (c.binRejected
90
+ ? 'providers.json bin is not a safe executable name/path, so it was not run'
91
+ : 'providers.json versionArgs contain unsafe shell syntax, so they were ignored and --version was used')
92
+ : (c.binRejected || c.versionArgsRejected);
93
+ log(`⚠ ${c.id}: ${reason}`);
94
+ });
81
95
  }
82
96
  const ready = checks.filter(c => c.status === 'ready');
83
97
  log('');
84
- log(`## 활성 (${ready.length}/${checks.length}): ${ready.map(c => c.id).join(', ') || '(없음)'}`);
98
+ log(t(
99
+ `## 활성 (${ready.length}/${checks.length}): ${ready.map(c => c.id).join(', ') || '(없음)'}`,
100
+ `## Active (${ready.length}/${checks.length}): ${ready.map(c => c.id).join(', ') || '(none)'}`,
101
+ ));
85
102
  if (!ready.length) {
86
103
  log('');
87
- log(`💡 활성화 방법:`);
88
- log(` 1) CLI 설치 (예: \`npm i -g @openai/codex-cli\`, \`npm i -g @google/antigravity-cli\`)`);
89
- log(` 2) .env 또는 환경변수: LEERNESS_ENABLE_CODEX=1, LEERNESS_ENABLE_AGY=1`);
90
- log(` 3) \`leerness agents check\`로 재확인`);
91
- log(` 💡 1.9.157: 빌트인 외 CLI 추가: \`leerness provider add <id> --bin <cmd>\``);
104
+ log(t(`💡 활성화 방법:`, `💡 How to enable:`));
105
+ log(t(
106
+ ` 1) CLI 설치 (예: \`npm i -g @openai/codex-cli\`, \`npm i -g @google/antigravity-cli\`)`,
107
+ ` 1) Install a CLI (for example, \`npm i -g @openai/codex-cli\` or \`npm i -g @google/antigravity-cli\`)`,
108
+ ));
109
+ log(t(
110
+ ` 2) .env 또는 환경변수: LEERNESS_ENABLE_CODEX=1, LEERNESS_ENABLE_AGY=1`,
111
+ ` 2) Set .env or environment variables: LEERNESS_ENABLE_CODEX=1, LEERNESS_ENABLE_AGY=1`,
112
+ ));
113
+ log(t(` 3) \`leerness agents check\`로 재확인`, ` 3) Run \`leerness agents check\` to check again`));
114
+ log(t(
115
+ ` 💡 1.9.157: 빌트인 외 CLI 추가: \`leerness provider add <id> --bin <cmd>\``,
116
+ ` 💡 1.9.157: Add a CLI beyond the built-ins: \`leerness provider add <id> --bin <cmd>\``,
117
+ ));
92
118
  } else {
93
119
  log('');
94
- log(`💡 메인 에이전트가 sub-agent 분배 시 위 ${ready.length}개 CLI 활용 가능:`);
95
- log(` \`leerness agents dispatch "<task>" --to <id>\` 프롬프트 전달`);
120
+ log(t(
121
+ `💡 메인 에이전트가 sub-agent 분배 ${ready.length}개 CLI 활용 가능:`,
122
+ `💡 The primary agent can use the ${ready.length} enabled CLI(s) for sub-agent delegation:`,
123
+ ));
124
+ log(t(
125
+ ` \`leerness agents dispatch "<task>" --to <id>\` 로 프롬프트 전달`,
126
+ ` Pass a prompt with \`leerness agents dispatch "<task>" --to <id>\``,
127
+ ));
96
128
  }
97
129
  return;
98
130
  }
package/lib/toggles.js CHANGED
@@ -27,6 +27,17 @@ const TOGGLE_REGISTRY = {
27
27
  'double-verify': { desc: '완료 전 재확인 지시문(같은 것을 다시 확인하라는 안내) — 끄면 안내만 생략된다 (gate/verify-claim 실행은 그대로)', affects: 'handoff · lens 안내' },
28
28
  'full-reread': { desc: '매 세션 관련 문서 전체 재독 지시문 — 끄면 "필요할 때 읽어라" 로 바뀐다 (문서와 상태는 그대로 남는다)', affects: 'handoff 안내' },
29
29
  };
30
+ const TOGGLE_REGISTRY_EN = {
31
+ 'gate': { desc: 'completion gate (integrated verify+audit+scan+encoding+lazy checks)', affects: 'leerness gate' },
32
+ 'lens': { desc: 'quality-lens self-questions (domain checks before claiming completion)', affects: 'leerness lens' },
33
+ 'auto-graph': { desc: 'automatically refresh the ontology graph (leerness.html) on install/session-close', affects: 'install · session close' },
34
+ 'delegation-brief': { desc: 'prepend the Leerness protocol brief when delegating to a background AI', affects: 'agents dispatch · agents multi' },
35
+ 'bugfix-receipt': { desc: 'bugfix completion gate — tasks with a registered probe must pass it and record root cause/sibling scope before done (OFF by default)', affects: 'task update --status done · task sync --from' },
36
+ 'difficulty-routing': { desc: 'route work by difficulty — allows --confirm for leerness agents route; high-risk work still requires --approved-by (OFF by default)', affects: 'leerness agents route --confirm' },
37
+ 'workflow-distribute': { desc: 'the distribute (sub-agent) instruction in the session workflow; disabling it removes only the instruction, not evidence checks', affects: 'handoff · session-workflow guidance' },
38
+ 'double-verify': { desc: 'the instruction to recheck work before completion; disabling it omits only the guidance, not gate/verify-claim execution', affects: 'handoff · lens guidance' },
39
+ 'full-reread': { desc: 'the instruction to reread every related document each session; disabling it changes the wording to read when needed', affects: 'handoff guidance' },
40
+ };
30
41
  const _hasToggle = (id) => Object.prototype.hasOwnProperty.call(TOGGLE_REGISTRY, id);
31
42
  // P-0009 경계 계약: 토글로 끌 수 있는 것은 '지시문' 뿐이다. 아래 목록은 **어떤 토글 조합에서도** 살아 있어야 하며,
32
43
  // 변이 테스트가 이 계약을 고정한다(계약이 깨지면 토글을 출하하지 않는다는 것이 승인 조건이었다).
@@ -100,6 +111,11 @@ function saveToggles(root, toggles) {
100
111
  function toggleCmd(root, sub, id, val, deps = {}) {
101
112
  const { has, VERSION } = deps;
102
113
  root = absRoot(root);
114
+ const en = deps.uiLang === 'en';
115
+ const t = (ko, enText) => (en ? enText : ko);
116
+ const reasonText = reason => (en
117
+ ? (reason === '최상위가 객체가 아닙니다' ? 'top level is not an object' : reason === 'JSON 파싱 실패' ? 'JSON parse failed' : reason)
118
+ : reason);
103
119
  // 1.36.108 (T-0097): 변경 하위명령은 읽기~쓰기 전체를 락으로 직렬화한다 — clarify/referee/routing 과 같은 관례.
104
120
  // 종전엔 이 모듈만 관례에서 빠져 있어 동시 `toggle set` 이 서로의 값을 덮었다(런타임 계측으로 실측: 락 X).
105
121
  // 재진입 플래그(_locked)로 자기 재호출을 한 번만 감싼다.
@@ -111,17 +127,24 @@ function toggleCmd(root, sub, id, val, deps = {}) {
111
127
  const cur = _chk.toggles;
112
128
  if (!sub || sub === 'list') {
113
129
  if (json) { log(JSON.stringify({ version: VERSION, toggles: cur, corrupt: !!_chk.corrupt, corruptReason: _chk.reason, registry: TOGGLE_REGISTRY }, null, 2)); return; }
114
- log(`# leerness toggle — 기능 토글 (온톨로지 그래프 뷰 ⚙ 탭과 연동)`);
130
+ log(t(`# leerness toggle — 기능 토글 (온톨로지 그래프 뷰 ⚙ 탭과 연동)`, `# leerness toggle — feature toggles (synced with the ontology graph ⚙ tab)`));
115
131
  // 손상 시 "그냥 OFF" 로 보이면 켜 뒀던 게이트가 꺼진 줄 모른다 — 사실을 먼저 알린다(헌트 #5).
116
- if (_chk.corrupt) log(` ⚠ toggles.json 손상(${_chk.reason}) — 아래는 **저장값이 아니라 기본값**입니다. 켜 두었던 토글이 꺼져 보일 수 있습니다: ${_togglesPath(root)}`);
132
+ if (_chk.corrupt) log(t(
133
+ ` ⚠ toggles.json 손상(${_chk.reason}) — 아래는 **저장값이 아니라 기본값**입니다. 켜 두었던 토글이 꺼져 보일 수 있습니다: ${_togglesPath(root)}`,
134
+ ` ⚠ toggles.json is corrupt (${reasonText(_chk.reason)}) — values below are **defaults, not stored values**. A previously enabled toggle may appear disabled: ${_togglesPath(root)}`,
135
+ ));
117
136
  for (const [k, meta] of Object.entries(TOGGLE_REGISTRY)) {
118
- log(` ${cur[k] ? '🟢 ON ' : '⚪ OFF'} ${k.padEnd(17)} ${meta.desc} [${meta.affects}]`);
137
+ const shown = en ? TOGGLE_REGISTRY_EN[k] : meta;
138
+ log(` ${cur[k] ? '🟢 ON ' : '⚪ OFF'} ${k.padEnd(17)} ${shown.desc} [${shown.affects}]`);
119
139
  }
120
- log(`\n 변경: leerness toggle set <id> on|off · 그래프 뷰: leerness graph --html → leerness.html 의 ⚙ 탭`);
140
+ log(t(
141
+ `\n 변경: leerness toggle set <id> on|off · 그래프 뷰: leerness graph --html → leerness.html 의 ⚙ 탭`,
142
+ `\n Change: leerness toggle set <id> on|off · Graph view: leerness graph --html → the ⚙ tab in leerness.html`,
143
+ ));
121
144
  return;
122
145
  }
123
146
  if (sub === 'get') {
124
- if (!_hasToggle(id)) { fail(`알 수 없는 토글: ${id} (가능: ${Object.keys(TOGGLE_REGISTRY).join(', ')})`); process.exitCode = 1; return; }
147
+ if (!_hasToggle(id)) { fail(t(`알 수 없는 토글: ${id} (가능: ${Object.keys(TOGGLE_REGISTRY).join(', ')})`, `Unknown toggle: ${id} (valid: ${Object.keys(TOGGLE_REGISTRY).join(', ')})`)); process.exitCode = 1; return; }
125
148
  const payload = {
126
149
  ok: true,
127
150
  id,
@@ -135,29 +158,29 @@ function toggleCmd(root, sub, id, val, deps = {}) {
135
158
  meta: TOGGLE_REGISTRY[id],
136
159
  };
137
160
  if (json) { log(JSON.stringify(payload, null, 2)); return payload; }
138
- if (_chk.corrupt) log(` ⚠ toggles.json 손상(${_chk.reason}) — 아래 값은 저장값이 아니라 기본값입니다: ${_togglesPath(root)}`);
139
- if (payload.unreadable) log(` ⚠ ${id} 저장값을 해석할 수 없어 기본값으로 표시합니다.`);
140
- log(`${payload.value ? '🟢 ON' : '⚪ OFF'} ${id} — ${TOGGLE_REGISTRY[id].desc} [${payload.source}]`);
161
+ if (_chk.corrupt) log(t(` ⚠ toggles.json 손상(${_chk.reason}) — 아래 값은 저장값이 아니라 기본값입니다: ${_togglesPath(root)}`, ` ⚠ toggles.json is corrupt (${reasonText(_chk.reason)}) — the value below is a default, not a stored value: ${_togglesPath(root)}`));
162
+ if (payload.unreadable) log(t(` ⚠ ${id} 저장값을 해석할 수 없어 기본값으로 표시합니다.`, ` ⚠ The stored value for ${id} is unreadable, so the default is shown.`));
163
+ log(`${payload.value ? '🟢 ON' : '⚪ OFF'} ${id} — ${(en ? TOGGLE_REGISTRY_EN[id] : TOGGLE_REGISTRY[id]).desc} [${payload.source}]`);
141
164
  return payload;
142
165
  }
143
166
  if (sub === 'set') {
144
- if (!_hasToggle(id)) { fail(`알 수 없는 토글: ${id} (가능: ${Object.keys(TOGGLE_REGISTRY).join(', ')})`); process.exitCode = 1; return; }
167
+ if (!_hasToggle(id)) { fail(t(`알 수 없는 토글: ${id} (가능: ${Object.keys(TOGGLE_REGISTRY).join(', ')})`, `Unknown toggle: ${id} (valid: ${Object.keys(TOGGLE_REGISTRY).join(', ')})`)); process.exitCode = 1; return; }
145
168
  const on = String(val).toLowerCase();
146
- if (on !== 'on' && on !== 'off') { fail(`값은 on|off (받음: ${val})`); process.exitCode = 1; return; }
169
+ if (on !== 'on' && on !== 'off') { fail(t(`값은 on|off (받음: ${val})`, `Value must be on|off (received: ${val})`)); process.exitCode = 1; return; }
147
170
  // 1.36.49 (codex 6차 #5): 손상 toggles.json 위에 set 하면 loadToggles 의 기본값 폴백이 저장돼
148
171
  // 무관한 토글(예: gate:false)이 무언 ON 으로 리셋됐다 — 변경 진입점은 fail-closed (1.36.28 스토어 손상 클래스).
149
172
  const f = _togglesPath(root);
150
173
  if (exists(f)) {
151
174
  try { JSON.parse(read(f)); }
152
- catch { fail(`toggles.json 손상(JSON 파싱 실패) — 덮어쓰기 거부: ${f}\n 복구하거나 삭제(전 토글 기본 ON 복원) 후 재시도`); process.exitCode = 1; return; }
175
+ catch { fail(t(`toggles.json 손상(JSON 파싱 실패) — 덮어쓰기 거부: ${f}\n 복구하거나 삭제(전 토글 기본 ON 복원) 후 재시도`, `toggles.json is corrupt (JSON parse failed) — refusing to overwrite: ${f}\n Repair it or delete it to restore defaults, then retry`)); process.exitCode = 1; return; }
153
176
  }
154
177
  cur[id] = on === 'on';
155
178
  saveToggles(root, cur);
156
179
  if (json) { log(JSON.stringify({ ok: true, id, value: cur[id], toggles: cur }, null, 2)); return; }
157
- ok(`toggle ${id} = ${on.toUpperCase()}${cur[id] ? '' : ' — 관련 명령이 스킵 동작으로 전환됩니다'}`);
180
+ ok(`toggle ${id} = ${on.toUpperCase()}${cur[id] ? '' : t(' — 관련 명령이 스킵 동작으로 전환됩니다', ' — related commands will switch to skip behavior')}`);
158
181
  return;
159
182
  }
160
- fail(`알 수 없는 하위명령: ${sub} (가능: list, get, set)`); process.exitCode = 1;
183
+ fail(t(`알 수 없는 하위명령: ${sub} (가능: list, get, set)`, `Unknown subcommand: ${sub} (valid: list, get, set)`)); process.exitCode = 1;
161
184
  }
162
185
 
163
186
  module.exports = { TOGGLE_REGISTRY, SCAFFOLD_TOGGLES, EVIDENCE_COMMANDS, loadToggles, loadTogglesChecked, toggleOn, saveToggles, toggleCmd, _togglesPath, _coerceToggle };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.179",
3
+ "version": "1.36.181",
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,18 +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 ./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 ./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/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 && node ./scripts/i18n-next-cluster-probe.js",
63
64
  "lint": "node ./scripts/lint.js",
64
65
  "prepack": "node ./bin/leerness.js readme sync . && node ./bin/leerness.js --version"
65
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;
@@ -11027,12 +11028,18 @@ total++;
11027
11028
  // 1.36.112: 106 으로 조인다. `context budget` 을 손대는 김에 영어화해 5줄을 갚았다(111 → 106, 실측).
11028
11029
  // 갚은 만큼만 낮춘다 — 여유를 남기면 그 안에서 조용히 썩는다(1.36.82 의 자기참조 가드에서 겪은 형태).
11029
11030
  // 1.36.174: provider list 영어 모드의 요약/안내 두 줄을 영어화해 104로 조임(39개 명령 실측).
11030
- const BASELINE = 104;
11031
+ // 1.36.180: agents list(18) + insights(17) + toggle list(11) 우선 표면을 영어화해 58로 조임.
11032
+ // 1.36.181: 공동 최상위 release cadence/idempotency audit/plan list/round-history(각 5)를 영어화해 38로 조임.
11033
+ const BASELINE = 38;
11031
11034
  dbg.baseline = BASELINE;
11032
- dbg.withinRatchet = leaky <= BASELINE;
11035
+ // 합계가 우연히 낮아진 뒤 다른 명령의 회귀가 그 여유를 소비하지 못하게 exact 로 잠근다.
11036
+ // 번역이 더 진행되면 실측값과 BASELINE을 같은 변경에서 함께 낮춰야 한다.
11037
+ dbg.exactRatchet = leaky === BASELINE;
11033
11038
  // 이번 라운드가 고친 표면은 **0 이어야** 한다 — 래칫과 별개로 회귀를 직접 막는다.
11034
11039
  const cmdOut = String(R(['commands']).stdout || '');
11035
11040
  dbg.commandsClean = cmdOut.split('\n').filter(l => HANGUL.test(l)).length === 0;
11041
+ dbg.nextClusterClean = ['release cadence', 'idempotency audit', 'plan list', 'round-history']
11042
+ .every(cmd => measured.get(cmd) === 0);
11036
11043
  // 한국어 사용자가 보던 것은 **바뀌면 안 된다**. 영어화하면서 `개` 단위를 양쪽에서 없애는 회귀를 냈고
11037
11044
  // 직전 커밋과의 바이트 비교가 잡았다. 여기서는 한국어 출력이 여전히 한국어 관례를 지키는지 단언한다.
11038
11045
  const koEnv = Object.assign({}, ENV, { LEERNESS_LANG: 'ko' });
@@ -11045,9 +11052,9 @@ total++;
11045
11052
  && Object.values(cj.categories).every(list => list.every(e => typeof e.cmd === 'string' && typeof e.desc === 'string'
11046
11053
  && e.descEn === undefined && e.cmdEn === undefined)) // 내부 번역 키가 payload 로 새면 70% 부풀었다(검수 P1 실측)
11047
11054
  && cj.lang === 'en'; // 로케일 의존을 **명시**한다 — 암묵적으로 흔들리지 않는다
11048
- ok = dbg.gitInit && dbg.probeAlive && dbg.coverage && dbg.withinRatchet && dbg.commandsClean && dbg.koIntact && dbg.jsonContract;
11055
+ ok = dbg.gitInit && dbg.probeAlive && dbg.coverage && dbg.exactRatchet && dbg.commandsClean && dbg.nextClusterClean && dbg.koIntact && dbg.jsonContract;
11049
11056
  } catch (e) { dbg.err = String(e && e.message).slice(0, 200); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
11050
- console.log(ok ? `✓ O(1.36.111/T-0092) 영어 누출 ${dbg.leaky}줄 래칫 ${dbg.baseline} · commands 표면 0 · 한국어 출력 불변 · --json 계약 유지 · 계측 살아있음`
11057
+ console.log(ok ? `✓ O(1.36.111/T-0092) 영어 누출 ${dbg.leaky}줄 = exact 래칫 ${dbg.baseline} · commands/next-cluster 표면 0 · 한국어 출력 불변 · --json 계약 유지 · 계측 살아있음`
11051
11058
  : '✗ 1.36.111 i18n 래칫 위반 ' + JSON.stringify(dbg));
11052
11059
  if (!ok) failed++;
11053
11060
  }
@@ -0,0 +1,242 @@
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 = {}) {
35
+ return cp.spawnSync('git', args, {
36
+ cwd: project,
37
+ env: { ...baseEnv, ...extraEnv },
38
+ encoding: 'utf8',
39
+ timeout: 30000,
40
+ });
41
+ }
42
+
43
+ function outputOf(result) {
44
+ return String(result.stdout || '') + String(result.stderr || '');
45
+ }
46
+
47
+ function requireSuccess(label, result) {
48
+ const output = outputOf(result);
49
+ if (result.status !== 0 || !output.trim()) {
50
+ throw new Error(`${label} probe command failed or was silent (exit ${result.status}): ${output.slice(0, 500)}`);
51
+ }
52
+ return output;
53
+ }
54
+
55
+ function requireGitSuccess(label, result) {
56
+ if (result.status !== 0) {
57
+ throw new Error(`${label} failed (exit ${result.status}): ${outputOf(result).slice(0, 500)}`);
58
+ }
59
+ }
60
+
61
+ function hangulLines(output) {
62
+ return String(output).split(/\r?\n/).filter(line => HANGUL.test(line));
63
+ }
64
+
65
+ function requireNoHangul(label, result) {
66
+ const output = requireSuccess(label, result);
67
+ const lines = hangulLines(output);
68
+ if (lines.length) {
69
+ throw new Error(`${label} leaked ${lines.length} Hangul line(s): ${lines.slice(0, 4).join(' | ')}`);
70
+ }
71
+ return output;
72
+ }
73
+
74
+ function normalizedJson(label, result) {
75
+ const output = requireSuccess(label, result);
76
+ let parsed;
77
+ try { parsed = JSON.parse(output); } catch (error) {
78
+ throw new Error(`${label} did not return one JSON document: ${error.message}`);
79
+ }
80
+ if (parsed && typeof parsed === 'object') delete parsed.auditedAt;
81
+ return parsed;
82
+ }
83
+
84
+ function cloneProjectState(target) {
85
+ fs.mkdirSync(target, { recursive: true });
86
+ fs.copyFileSync(path.join(project, 'package.json'), path.join(target, 'package.json'));
87
+ fs.cpSync(path.join(project, '.leerness'), path.join(target, '.leerness'), { recursive: true });
88
+ }
89
+
90
+ const surfaces = [
91
+ ['release cadence', ['release', 'cadence', '--path', project]],
92
+ ['idempotency audit', ['idempotency', 'audit', '--path', project]],
93
+ ['plan list', ['plan', 'list', '--path', project]],
94
+ ['round-history', ['round-history', '--path', project]],
95
+ ];
96
+
97
+ try {
98
+ fs.mkdirSync(project, { recursive: true });
99
+ fs.writeFileSync(path.join(project, 'package.json'), '{"name":"i18n-next-probe","version":"0.1.0"}\n');
100
+ requireSuccess('init', run(['init', project, '--yes', '--language', 'en']));
101
+ requireGitSuccess('git init', git(['init', '-q']));
102
+ requireGitSuccess('git user name', git(['config', 'user.name', 'Leerness Probe']));
103
+ requireGitSuccess('git user email', git(['config', 'user.email', 'probe@example.invalid']));
104
+
105
+ requireSuccess('task add', run(['task', 'add', 'Implement the parser', '--path', project]));
106
+ requireSuccess('decision add', run(['decision', 'add', 'Use JSON storage', '--reason', 'simplest', '--path', project]));
107
+ requireSuccess('plan add', run(['plan', 'add', 'Ship the parser', '--path', project]));
108
+
109
+ // Stored project language, environment override, and explicit flag must all
110
+ // reach the same English renderer. Flip the stored language to Korean before
111
+ // checking overrides so a disconnected env/flag path cannot pass by falling
112
+ // back to the already-English manifest.
113
+ for (const [label, args] of surfaces) {
114
+ requireNoHangul(`${label} (stored language)`, run(args));
115
+ }
116
+ const manifestFile = path.join(project, '.leerness', 'manifest.json');
117
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
118
+ manifest.language = 'ko';
119
+ fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n');
120
+ for (const [label, args] of surfaces) {
121
+ requireNoHangul(`${label} (environment language)`, run(args, { ...baseEnv, LEERNESS_LANG: 'en' }));
122
+ requireNoHangul(`${label} (explicit language)`, run([...args, '--language', 'en'], { ...baseEnv, LEERNESS_LANG: 'ko' }));
123
+ }
124
+ manifest.language = 'en';
125
+ fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n');
126
+
127
+ // Missing and milestone-free plans have separate human branches.
128
+ const planFile = path.join(project, '.leerness', 'plan.md');
129
+ const originalPlan = fs.readFileSync(planFile, 'utf8');
130
+ fs.rmSync(planFile);
131
+ requireNoHangul('plan list missing file', run(['plan', 'list', '--path', project]));
132
+ fs.writeFileSync(planFile, '# Plan\n\n## Milestones\n');
133
+ requireNoHangul('plan list without milestones', run(['plan', 'list', '--path', project]));
134
+ const legacyPlan = originalPlan.replace('Done-When: (unset)', 'Done-When: (미정)');
135
+ if (legacyPlan === originalPlan) throw new Error('plan fixture did not contain the English unset sentinel');
136
+ fs.writeFileSync(planFile, legacyPlan);
137
+ const legacyPlanOutput = requireNoHangul('plan list legacy Korean sentinel', run(['plan', 'list', '--path', project]));
138
+ if (!legacyPlanOutput.includes('Done-When: (unset)')) {
139
+ throw new Error('plan list did not translate the legacy (미정) sentinel to (unset)');
140
+ }
141
+ fs.writeFileSync(planFile, originalPlan);
142
+
143
+ // Public add commands normally deduplicate; --force deliberately creates
144
+ // realistic duplicate rows so the violation renderer and auto-fix summary
145
+ // cannot remain Korean while the empty-state probe passes.
146
+ requireSuccess('duplicate rule A', run(['rule', 'add', 'Run tests', '--trigger', 'every-session', '--force', '--path', project]));
147
+ requireSuccess('duplicate rule B', run(['rule', 'add', 'Run tests', '--trigger', 'every-session', '--force', '--path', project]));
148
+ requireSuccess('duplicate task A', run(['task', 'add', 'Resolve duplicate work', '--status', 'in-progress', '--force', '--path', project]));
149
+ requireSuccess('duplicate task B', run(['task', 'add', 'Resolve duplicate work', '--status', 'in-progress', '--force', '--path', project]));
150
+ requireSuccess('duplicate user request seed', run(['requests', 'add', 'Resolve duplicate request', '--path', project]));
151
+ const requestFile = path.join(project, '.leerness', 'user-requests.json');
152
+ const requestState = JSON.parse(fs.readFileSync(requestFile, 'utf8'));
153
+ const requestSeed = requestState.requests[requestState.requests.length - 1];
154
+ requestState.requests.push({ ...requestSeed, id: 'UR-9000', recordedAt: '2026-08-30T00:00:00.000Z' });
155
+ fs.writeFileSync(requestFile, JSON.stringify(requestState, null, 2) + '\n');
156
+ requireNoHangul('idempotency violation report', run(['idempotency', 'audit', '--path', project]));
157
+
158
+ // --auto-fix mutates its target, so compare two byte-identical fresh clones.
159
+ // This catches locale-dependent JSON fields as well as locale-dependent state
160
+ // writes that a post-fix clean audit would miss.
161
+ const autoFixEnProject = path.join(tmp, 'autofix-en');
162
+ const autoFixKoProject = path.join(tmp, 'autofix-ko');
163
+ cloneProjectState(autoFixEnProject);
164
+ cloneProjectState(autoFixKoProject);
165
+ const autoFixEn = normalizedJson('idempotency auto-fix English JSON', run(
166
+ ['idempotency', 'audit', '--auto-fix', '--json', '--path', autoFixEnProject],
167
+ { ...baseEnv, LEERNESS_LANG: 'en' }
168
+ ));
169
+ const autoFixKo = normalizedJson('idempotency auto-fix Korean JSON', run(
170
+ ['idempotency', 'audit', '--auto-fix', '--json', '--path', autoFixKoProject],
171
+ { ...baseEnv, LEERNESS_LANG: 'ko' }
172
+ ));
173
+ if (JSON.stringify(autoFixEn) !== JSON.stringify(autoFixKo)) {
174
+ throw new Error('idempotency auto-fix JSON contract changed with UI language');
175
+ }
176
+ const enProgress = fs.readFileSync(path.join(autoFixEnProject, '.leerness', 'progress-tracker.md'));
177
+ const koProgress = fs.readFileSync(path.join(autoFixKoProject, '.leerness', 'progress-tracker.md'));
178
+ if (!enProgress.equals(koProgress)) throw new Error('idempotency auto-fix wrote locale-dependent progress-tracker.md');
179
+ const enRequests = JSON.parse(fs.readFileSync(path.join(autoFixEnProject, '.leerness', 'user-requests.json'), 'utf8'));
180
+ const koRequests = JSON.parse(fs.readFileSync(path.join(autoFixKoProject, '.leerness', 'user-requests.json'), 'utf8'));
181
+ delete enRequests.updatedAt;
182
+ delete koRequests.updatedAt;
183
+ if (JSON.stringify(enRequests) !== JSON.stringify(koRequests)) {
184
+ throw new Error('idempotency auto-fix wrote locale-dependent user-requests.json');
185
+ }
186
+ requireNoHangul('idempotency auto-fix report', run(['idempotency', 'audit', '--auto-fix', '--path', project]));
187
+
188
+ // Two annotated tags with distinct creation times exercise measured cadence
189
+ // and non-empty round history rather than only the insufficient-data branch.
190
+ requireGitSuccess('git add first snapshot', git(['add', '-A']));
191
+ const firstDate = '2026-08-28T00:00:00+09:00';
192
+ requireGitSuccess('git commit first snapshot', git(['commit', '-qm', 'first'], {
193
+ GIT_AUTHOR_DATE: firstDate,
194
+ GIT_COMMITTER_DATE: firstDate,
195
+ }));
196
+ requireGitSuccess('git tag first snapshot', git(['tag', '-a', 'v1.0.0', '-m', 'v1.0.0'], {
197
+ GIT_COMMITTER_DATE: firstDate,
198
+ }));
199
+ fs.writeFileSync(path.join(project, 'history.txt'), 'second\n');
200
+ requireGitSuccess('git add second snapshot', git(['add', 'history.txt']));
201
+ const secondDate = '2026-08-29T00:00:00+09:00';
202
+ requireGitSuccess('git commit second snapshot', git(['commit', '-qm', 'second'], {
203
+ GIT_AUTHOR_DATE: secondDate,
204
+ GIT_COMMITTER_DATE: secondDate,
205
+ }));
206
+ requireGitSuccess('git tag second snapshot', git(['tag', '-a', 'v1.0.1', '-m', 'v1.0.1'], {
207
+ GIT_COMMITTER_DATE: secondDate,
208
+ }));
209
+ requireNoHangul('release cadence with measured history', run(['release', 'cadence', '--path', project]));
210
+ requireNoHangul('round-history with tags', run(['round-history', '--path', project]));
211
+
212
+ // Machine payloads remain the canonical, locale-independent contract. The
213
+ // idempotency timestamp is intentionally volatile and is normalized only
214
+ // for this equality assertion.
215
+ for (const [label, args] of surfaces) {
216
+ const en = normalizedJson(`${label} English JSON`, run([...args, '--json']));
217
+ const ko = normalizedJson(`${label} Korean JSON`, run([...args, '--json'], { ...baseEnv, LEERNESS_LANG: 'ko' }));
218
+ if (JSON.stringify(en) !== JSON.stringify(ko)) {
219
+ throw new Error(`${label} JSON contract changed with UI language`);
220
+ }
221
+ }
222
+
223
+ const koEnv = { ...baseEnv, LEERNESS_LANG: 'ko' };
224
+ const koreanAnchors = new Map([
225
+ ['release cadence', ['릴리스 빈도 진단', '누적 릴리스', '권장:']],
226
+ ['idempotency audit', ['위반 발견', '중복 룰']],
227
+ ['plan list', ['완료기준(Done-When)', 'Tasks:', '완료)']],
228
+ ['round-history', ['자율 라운드 통계', '누적 라운드', '최근 10 tags']],
229
+ ]);
230
+ for (const [label, args] of surfaces) {
231
+ const output = requireSuccess(`${label} Korean control`, run(args, koEnv));
232
+ const missing = koreanAnchors.get(label).filter(anchor => !output.includes(anchor));
233
+ if (missing.length) throw new Error(`${label} Korean control lost anchors: ${missing.join(', ')}`);
234
+ }
235
+
236
+ console.log('✓ Four next-cluster English surfaces contain no Hangul across locale paths and edge states; 4/4 Korean controls and JSON contracts remain intact');
237
+ } catch (error) {
238
+ console.error(`✗ ${error && error.message ? error.message : error}`);
239
+ process.exitCode = 1;
240
+ } finally {
241
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
242
+ }
@@ -0,0 +1,145 @@
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-priority-'));
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
+ // The English assertions must be driven by the project manifest written by
24
+ // `init --language en`; an inherited env override would make that path untested.
25
+ delete baseEnv.LEERNESS_LANG;
26
+ for (const key of [
27
+ 'LEERNESS_ENABLE_CLAUDE', 'LEERNESS_ENABLE_CODEX', 'LEERNESS_ENABLE_AGY',
28
+ 'LEERNESS_ENABLE_GROK', 'LEERNESS_ENABLE_OPENCODE', 'LEERNESS_ENABLE_QWEN',
29
+ 'LEERNESS_ENABLE_AIDER', 'LEERNESS_ENABLE_GOOSE', 'LEERNESS_ENABLE_COPILOT',
30
+ 'LEERNESS_ENABLE_OLLAMA',
31
+ ]) baseEnv[key] = '0';
32
+
33
+ function run(args, env = baseEnv) {
34
+ return cp.spawnSync(process.execPath, [CLI, ...args], {
35
+ cwd: project,
36
+ env,
37
+ encoding: 'utf8',
38
+ timeout: 300000,
39
+ });
40
+ }
41
+
42
+ function outputOf(result) {
43
+ return String(result.stdout || '') + String(result.stderr || '');
44
+ }
45
+
46
+ function requireSuccess(label, result) {
47
+ const output = outputOf(result);
48
+ if (result.status !== 0 || !output.trim()) {
49
+ throw new Error(`${label} probe command failed or was silent (exit ${result.status}): ${output.slice(0, 500)}`);
50
+ }
51
+ return output;
52
+ }
53
+
54
+ function requireFailure(label, result) {
55
+ const output = outputOf(result);
56
+ if (result.status === 0 || !output.trim()) {
57
+ throw new Error(`${label} probe command unexpectedly succeeded or was silent (exit ${result.status}): ${output.slice(0, 500)}`);
58
+ }
59
+ return output;
60
+ }
61
+
62
+ function hangulLines(output) {
63
+ return String(output).split(/\r?\n/).filter(line => HANGUL.test(line));
64
+ }
65
+
66
+ try {
67
+ fs.mkdirSync(project, { recursive: true });
68
+ fs.writeFileSync(path.join(project, 'package.json'), '{"name":"i18n-probe","version":"0.1.0"}\n');
69
+ requireSuccess('init', run(['init', project, '--yes', '--language', 'en']));
70
+
71
+ requireSuccess('task add', run(['task', 'add', 'Implement the parser', '--path', project]));
72
+ requireSuccess('decision add', run(['decision', 'add', 'Use JSON storage', '--reason', 'simplest', '--path', project]));
73
+ requireSuccess('lesson save', run(['lesson', 'save', 'Keep outputs deterministic', '--tag', 'test', '--path', project]));
74
+ requireSuccess('plan add', run(['plan', 'add', 'Ship the parser', '--path', project]));
75
+
76
+ // Exercise the rejected-provider branch without executing an unsafe command.
77
+ // `provider add` creates the canonical store; the fixture then simulates a
78
+ // hand-edited/remote catalog that the production sanitizer must reject.
79
+ requireSuccess('provider add unsafe-bin', run(['provider', 'add', 'unsafe-bin', '--bin', 'missing-i18n-bin', '--path', project]));
80
+ requireSuccess('provider add unsafe-args', run(['provider', 'add', 'unsafe-args', '--bin', 'missing-i18n-args', '--path', project]));
81
+ const providersFile = path.join(project, '.leerness', 'providers.json');
82
+ const providersRaw = JSON.parse(fs.readFileSync(providersFile, 'utf8'));
83
+ const providers = Array.isArray(providersRaw) ? providersRaw : providersRaw.providers;
84
+ const unsafeBin = providers.find(p => p.id === 'unsafe-bin');
85
+ const unsafeArgs = providers.find(p => p.id === 'unsafe-args');
86
+ if (!unsafeBin || !unsafeArgs) throw new Error('provider rejection fixtures were not persisted');
87
+ unsafeBin.bin = 'unsafe provider bin';
88
+ unsafeArgs.versionArgs = ['--version', '&', 'never-run'];
89
+ fs.writeFileSync(providersFile, JSON.stringify(providersRaw, null, 2) + '\n');
90
+
91
+ const surfaces = [
92
+ ['agents list', ['agents', 'list', '--path', project]],
93
+ ['insights', ['insights', '--path', project]],
94
+ ['insights workspace', ['insights', '--include', project, '--path', project]],
95
+ ['toggle list', ['toggle', 'list', '--path', project]],
96
+ ['toggle get', ['toggle', 'get', 'gate', '--path', project]],
97
+ ['toggle set', ['toggle', 'set', 'gate', 'off', '--path', project]],
98
+ ];
99
+ const leaks = [];
100
+ for (const [label, args] of surfaces) {
101
+ const output = requireSuccess(label, run(args));
102
+ const lines = hangulLines(output);
103
+ if (lines.length) leaks.push(`${label}=${lines.length} (${lines.slice(0, 2).join(' | ')})`);
104
+ }
105
+
106
+ const koEnv = { ...baseEnv, LEERNESS_LANG: 'ko' };
107
+ for (const [label, args] of [
108
+ ['agents list Korean control', ['agents', 'list', '--path', project]],
109
+ ['insights Korean control', ['insights', '--path', project]],
110
+ ['toggle list Korean control', ['toggle', 'list', '--path', project]],
111
+ ]) {
112
+ const output = requireSuccess(label, run(args, koEnv));
113
+ if (!HANGUL.test(output)) throw new Error(`${label} lost Hangul; the probe cannot distinguish locales`);
114
+ }
115
+
116
+ const unknownOutput = requireFailure('toggle unknown English error', run(['toggle', 'get', 'missing-toggle', '--path', project]));
117
+ const unknownLines = hangulLines(unknownOutput);
118
+ if (unknownLines.length) leaks.push(`toggle unknown error=${unknownLines.length} (${unknownLines.slice(0, 2).join(' | ')})`);
119
+
120
+ const toggleFile = path.join(project, '.leerness', 'toggles.json');
121
+ fs.writeFileSync(toggleFile, '{broken json\n');
122
+ const corruptList = requireSuccess('toggle corrupt list', run(['toggle', 'list', '--path', project]));
123
+ const corruptSet = requireFailure('toggle corrupt set', run(['toggle', 'set', 'gate', 'on', '--path', project]));
124
+ for (const [label, output] of [['toggle corrupt list', corruptList], ['toggle corrupt set', corruptSet]]) {
125
+ const lines = hangulLines(output);
126
+ if (lines.length) leaks.push(`${label}=${lines.length} (${lines.slice(0, 2).join(' | ')})`);
127
+ }
128
+
129
+ // Machine output remains locale-neutral in shape: do not expose the private
130
+ // English projection or replace the canonical registry payload.
131
+ fs.rmSync(toggleFile, { force: true });
132
+ const toggleJson = JSON.parse(requireSuccess('toggle JSON contract', run(['toggle', 'list', '--path', project, '--json'])));
133
+ if (!toggleJson.registry || !toggleJson.registry.gate || toggleJson.registry.gate.descEn !== undefined
134
+ || !HANGUL.test(toggleJson.registry.gate.desc || '')) {
135
+ throw new Error('toggle JSON registry no longer exposes the canonical shape');
136
+ }
137
+
138
+ if (leaks.length) throw new Error(`English UI leaked Hangul: ${leaks.join('; ')}`);
139
+ console.log('✓ English priority surfaces and edge paths contain no Hangul; 3/3 Korean controls and JSON shape remain intact');
140
+ } catch (error) {
141
+ console.error(`✗ ${error && error.message ? error.message : error}`);
142
+ process.exitCode = 1;
143
+ } finally {
144
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
145
+ }