leerness 1.29.0 → 1.31.0

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/bin/leerness.js CHANGED
@@ -32,7 +32,7 @@ const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInG
32
32
  // 1.9.295 (UR-0025 4단계): 정적 데이터 카탈로그 모듈 분리 (비파괴, require-based).
33
33
  const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _TOOL_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, SKILL_CATALOG_PRESETS } = require('../lib/catalogs'); // 1.9.344/368/369 (UR-0025): catalog 분리 · 1.11.4 (UR-0007): _TOOL_CATALOG
34
34
 
35
- const VERSION = '1.29.0';
35
+ const VERSION = '1.31.0';
36
36
 
37
37
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
38
38
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -2966,7 +2966,7 @@ function _selfTestCases() {
2966
2966
  { name: '6번째 외부평가/codex P1-A (UR-0098): install-safety 레시피 셸-무관 + hardeningNote (1.9.397)', run: () => { if (typeof installSafetyCmd !== 'function') return false; const save = process.argv; const _w = process.stdout.write; let out = ''; try { process.argv = ['node', 'h', 'install-safety', '--json']; process.stdout.write = s => { out += s; return true; }; installSafetyCmd({ json: true }); } catch {} finally { process.stdout.write = _w; process.argv = save; } let j; try { j = JSON.parse(out); } catch {} const noPosixPrefix = !!j && Array.isArray(j.safeInstall) && !j.safeInstall.some(x => /^npm_config_\w+=/.test(String(x).trim())); const crossShell = !!j && j.safeInstall.filter(x => String(x).includes('npx --yes')).length >= 2; const noteOk = !!j && typeof j.hardeningNote === 'string' && j.hardeningNote.includes('PowerShell'); return noPosixPrefix && crossShell && noteOk; } },
2967
2967
  { name: '6번째 외부평가/codex P1-C (UR-0099): --json 에러 경로 구조화 failJson + 와이어 (1.9.398)', run: () => { const io = require('../lib/io'); if (io.failJson !== failJson) return false; const _w = process.stdout.write; const saved = process.exitCode; let jOut = '', hOut = ''; let jExit = 0; try { process.stdout.write = s => { jOut += s; return true; }; process.exitCode = 0; failJson(true, 'tc', 'm'); jExit = process.exitCode; process.stdout.write = s => { hOut += s; return true; }; process.exitCode = 0; failJson(false, 'c', 'humanmsg'); } catch {} finally { process.stdout.write = _w; process.exitCode = saved; } let pj; try { pj = JSON.parse(jOut); } catch {} const jsonOk = !!pj && pj.ok === false && pj.code === 'tc' && pj.error === 'm' && jExit === 1; const humanOk = hOut.includes('✗') && hOut.includes('humanmsg') && !hOut.includes('{'); const src = read(__filename); const wired = src.includes("failJson(_j, 'missing_args'") && src.includes("failJson(_j, 'spec_not_found'"); return jsonOk && humanOk && wired; } },
2968
2968
  { name: '7번째 버그헌트 P1-A (UR-0104): 테이블셀 안전화 _cellSafe/_cellUnescape (파이프/개행 injection 차단) (1.9.399)', run: () => { const m = require('../lib/pure-utils'); if (m._cellSafe !== _cellSafe || m._cellUnescape !== _cellUnescape) return false; const safe = _cellSafe('fix | bug\nrow2'); const noRaw = !/(?<!\\)\|/.test(safe) && !/[\r\n]/.test(safe); const pipeRt = _cellUnescape(_cellSafe('a | b | c')) === 'a | b | c'; const nlGone = _cellSafe('a\nb') === 'a b'; const src = read(__filename); const wired = src.includes('_cellSafe(r.request)') && src.includes('_cellSafe(r.rule)'); return noRaw && pipeRt && nlGone && wired; } },
2969
- { name: '7번째 버그헌트 P1-B (UR-0105): verify-claim/optimism-check/honesty-check --json 에러 구조화 (1.9.400)', run: () => { const src = read(__filename); const vc = /function verifyClaimCmd[\s\S]{0,400}?failJson\(_j, 'not_found'/.test(src); const oc = /function optimismCheckCmd[\s\S]{0,400}?failJson\(_j, 'not_found'/.test(src); const hc = /function honestyCheckCmd[\s\S]{0,900}?failJson\(has\('--json'\), 'not_found'/.test(src); return vc && oc && hc; } },
2969
+ { name: '7번째 버그헌트 P1-B (UR-0105): verify-claim/optimism-check/honesty-check --json 에러 구조화 (1.9.400)', run: () => { const src = read(__filename); const vc = /function verifyClaimCmd[\s\S]{0,700}?failJson\(_j, 'not_found'/.test(src); const oc = /function optimismCheckCmd[\s\S]{0,700}?failJson\(_j, 'not_found'/.test(src); const hc = /function honestyCheckCmd[\s\S]{0,900}?failJson\(has\('--json'\), 'not_found'/.test(src); return vc && oc && hc; } }, // 1.30.5: {0,400}→{0,700} (F4 가 missing_args 라인을 en/ko 로 늘려 not_found 가 창 밖)
2970
2970
  { name: '7번째 버그헌트 P1-C (UR-0106): 시크릿 FN — gitignore 부정(!) + placeholder substring 정밀화 (1.9.401)', run: () => { const m = require('../lib/pure-utils'); const gm = m._gitignoreMatch; const negOk = gm('*.example\n!.env.example', '.env.example') === false && gm('*.log', 'a.log') === true && gm('a.log\n!a.log', 'a.log') === false && gm('.env', '.env') === true; const ph = m._isPlaceholderSecret; const phOk = ph('sk-EXAMPLEab12cd34ef56gh78ij90kl') === false && ph('sk-proj-realKEYexample9988776655') === false && ph('your-key-here') === true && ph('changeme') === true && ph('example') === true && ph('xxxxxxxxxxxxxxxxxxxxxxxxxxxx') === true; return negOk && phOk; } },
2971
2971
  { name: '7번째 버그헌트 P1-A 잔여 (UR-0108): decisions/lessons MD projection 개행 주입 차단 _lineSafe (1.9.402)', run: () => { const m = require('../lib/pure-utils'); if (m._lineSafe !== _lineSafe) return false; const lsOk = _lineSafe('a\nb\r\nc') === 'a b c'; const md = m._renderDecisionsMd([{ date: '2026-06-07', title: 'real\n### 2099-01-01 — FAKE\n- Decision: forged', decision: 'd', reason: 'r' }]); const re = m._decisionsFromMd(md); const noInject = re.length === 1 && !/^### 2099-01-01 — FAKE/m.test(md); const lmd = m._renderLessonsMd([{ date: '2026-06-07', text: 'l1\n### FAKE\n- Lesson: x', tag: 't' }]); const lre = m._parseLessonEntries(lmd); const lNoInject = lre.length === 1; return lsOk && noInject && lNoInject; } },
2972
2972
  { name: '7번째 버그헌트 P2 (UR-0107): api-skill show/drop 에러 exit code 1 (1.9.403)', run: () => { const src = read(__filename); const showId = src.includes("api-skill show <id>')); process.exitCode = 1"); const dropId = src.includes("api-skill drop <id>')); process.exitCode = 1"); const addUrl = src.includes("api-skill add <url> [--direction") && src.includes('process.exitCode = 1'); return showId && dropId && addUrl; } },
@@ -3114,7 +3114,7 @@ function _selfTestCases() {
3114
3114
  fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true });
3115
3115
  fs.writeFileSync(path.join(tmp, 'AGENTS.md'), '# x');
3116
3116
  process.stdout.write = s => { out += s; return true; };
3117
- m.audit(tmp, { json: true }, { VERSION, arg: (k, d) => d, has: f => f === '--json', planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills });
3117
+ m.audit(tmp, { json: true }, { VERSION, arg: (k, d) => d, has: f => f === '--json', planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills, _collectSecretFindings });
3118
3118
  } catch (e) { out = 'ERR:' + e.message; } finally { process.stdout.write = _w; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
3119
3119
  try { const j = JSON.parse(out); behavOk = typeof j.healthy === 'boolean' && Array.isArray(j.findings); } catch {}
3120
3120
  return expOk && delegated && movedToLib && behavOk;
@@ -3851,7 +3851,73 @@ function _selfTestCases() {
3851
3851
  const koPreserved = dg.includes('설치/환경 진단') && dg.includes('## npm 환경') && dg.includes('버전별 헤드라인'); // ko 인자 보존(내부호출/e2e)
3852
3852
  return injected && en && koPreserved;
3853
3853
  } },
3854
- { name: 'VERSION 형식 (x.y.z)', run: () => /^\d+\.\d+\.\d+$/.test(VERSION) }
3854
+ { name: 'Phase 10d (1.29.1): handoff 보안 요약 섹션 영어/한국어 보존 (소스 가드)', run: () => {
3855
+ const bin = read(__filename);
3856
+ // split-literals so this guard's own strings are not contiguous matches (self-reference trap)
3857
+ const en = bin.includes('Securit' + 'y summary —')
3858
+ && bin.includes('auto-recover' + 'ed (LEERNESS_AUTO_SECURITY_FIX')
3859
+ && bin.includes('auto-fix optio' + 'n: LEERNESS_AUTO_SECURITY_FIX');
3860
+ const koPreserved = bin.includes('보안 ' + '요약 (1.9.76)')
3861
+ && bin.includes('자동 실행 ' + '옵션: LEERNESS_AUTO_SECURITY_FIX');
3862
+ return en && koPreserved;
3863
+ } },
3864
+ { name: 'Phase 10e (1.29.2): handoff env-detect 블록 영어/한국어 보존 (소스 가드)', run: () => {
3865
+ const bin = read(__filename);
3866
+ // split-literals (self-reference trap 회피)
3867
+ const en = bin.includes('Runtime environ' + 'ment: ⚠')
3868
+ && bin.includes('change(s) detec' + 'ted')
3869
+ && bin.includes('→ detail' + 's: leerness env detect');
3870
+ const koPreserved = bin.includes('실행 환경 (1.9.145): ⚠ PATH 누' + '락')
3871
+ && bin.includes('→ 상' + '세: leerness env detect');
3872
+ return en && koPreserved;
3873
+ } },
3874
+ { name: 'Phase 10f (1.29.3): handoff shell-guard 블록 영어/한국어 보존 (소스 가드)', run: () => {
3875
+ const bin = read(__filename);
3876
+ // split-literals (self-reference trap 회피)
3877
+ const en = bin.includes('Terminal shell gua' + 'rd (UR-0020)')
3878
+ && bin.includes('review past shell fail' + 'ures')
3879
+ && bin.includes('check before running a comm' + 'and: leerness shell-guard');
3880
+ const koPreserved = bin.includes('터미널 셸 가' + '드 (1.9.263, UR-0020)')
3881
+ && bin.includes('과거 셸 실패 기록 재검' + '토 권장');
3882
+ return en && koPreserved;
3883
+ } },
3884
+ { name: 'Phase 10g (1.29.4): handoff CLI 에이전트 슬래시 블록 영어/한국어 보존 (소스 가드)', run: () => {
3885
+ const bin = read(__filename);
3886
+ // split-literals (self-reference trap 회피)
3887
+ const en = bin.includes('CLI agent slash comma' + 'nds (UR-0021)')
3888
+ && bin.includes("active agent(s) — use each one'" + 's slash commands')
3889
+ && bin.includes('full list / record / refr' + 'esh: leerness slash-commands');
3890
+ const koPreserved = bin.includes('CLI 에이전트 슬래시 명' + '령 (1.9.265~266, UR-0021)')
3891
+ && bin.includes('전체/기록/최신' + '화: leerness slash-commands');
3892
+ return en && koPreserved;
3893
+ } },
3894
+ { name: 'parent detect (1.30.2 #157): 상위 leerness 부모 탐지(행위) + 독립 null + assetCount', run: () => {
3895
+ const osx = require('os'); const fsx = require('fs');
3896
+ const base = fsx.mkdtempSync(path.join(osx.tmpdir(), 'leer-parent-st-'));
3897
+ const alone = fsx.mkdtempSync(path.join(osx.tmpdir(), 'leer-alone-st-'));
3898
+ try {
3899
+ fsx.mkdirSync(path.join(base, '.harness'), { recursive: true });
3900
+ fsx.writeFileSync(path.join(base, '.harness', 'design-system.md'), '# ds');
3901
+ const sub = path.join(base, 'sub'); fsx.mkdirSync(sub, { recursive: true });
3902
+ const found = _findParentWorkspace(sub);
3903
+ const standalone = _findParentWorkspace(alone);
3904
+ // 부모 탐지: 워크스페이스 .harness + assetCount≥1(design-system) · 독립: null · read-only(소스에 파일쓰기 없음 — adopt 미구현)
3905
+ const readOnly = /이 명령은 아무 파일도 쓰지 않는다/.test(read(__filename));
3906
+ return !!found && found.workspaceDir === '.harness' && found.assetCount >= 1 && standalone === null && readOnly;
3907
+ } finally { try { fsx.rmSync(base, { recursive: true, force: true }); } catch {}; try { fsx.rmSync(alone, { recursive: true, force: true }); } catch {} }
3908
+ } },
3909
+ { name: 'VERSION 형식 (x.y.z)', run: () => /^\d+\.\d+\.\d+$/.test(VERSION) },
3910
+ { name: 'VERSION ↔ package.json 일치 (1.30.2: 한쪽만 bump 하던 실수 2초 내 차단)', run: () => {
3911
+ // bin VERSION 과 package.json.version 이 어긋나면 배너/배포가 거짓 버전을 표기 → 즉시 fail.
3912
+ try { return require('../package.json').version === VERSION; } catch { return false; }
3913
+ } },
3914
+ { name: 'parent adopt (1.30.3 #158): 게이트형 adopt(dry-run 기본/--apply) + 비파괴 참조파일 (소스 가드)', run: () => {
3915
+ const bin = read(__filename);
3916
+ // split-literals (self-reference trap 회피)
3917
+ const hasAdopt = bin.includes("'ado" + "pt'") && bin.includes('parent ado' + 'pt --apply');
3918
+ const nonDestructive = bin.includes('inherited-from-pa' + 'rent.md') && bin.includes('PARENT_LI' + 'NK.json');
3919
+ return hasAdopt && nonDestructive;
3920
+ } }
3855
3921
  ];
3856
3922
  }
3857
3923
  function selfTestCmd(opts = {}) {
@@ -4631,6 +4697,7 @@ function commandsCmd(root) {
4631
4697
  { cmd: 'update [--check|--yes|--force]', desc: '자가 업데이트' },
4632
4698
  { cmd: 'wakeup-interval get|set|auto|history|record', desc: 'adaptive wakeup (1.9.210)' },
4633
4699
  { cmd: 'workspace-dir get|guide', desc: '워크스페이스 디렉토리 (1.9.211)' },
4700
+ { cmd: 'parent detect|adopt [--select <kinds>] [--apply]', desc: '상위 leerness 부모 탐지 + 자산 게이트형 adopt (1.30.2~3)' },
4634
4701
  { cmd: 'intent classify|expand|domains "<request>"', desc: '의도 파악 + scope (1.9.213)' },
4635
4702
  { cmd: 'constraints list|check|add', desc: '플랫폼/API 제약 (1.9.208)' },
4636
4703
  { cmd: 'provider list|add|remove|sync', desc: 'Provider Registry (1.9.157~160)' },
@@ -5162,6 +5229,36 @@ function _workspaceDirName(root) {
5162
5229
  function _workspaceDirAbs(root) {
5163
5230
  return path.join(root, _workspaceDirName(root));
5164
5231
  }
5232
+ // 1.30.2 (#157 사용자명시, 하위 프로젝트 방향 — 외부AI+Claude 교차검토 → 방향 C "탐지+게이트"):
5233
+ // 현재 root 의 '상위' 디렉토리 중 가장 가까운 leerness 부모(.harness/ 또는 .leerness/ 보유)를 탐지(read-only).
5234
+ // 부모 자산(design-system/reuse-map/tone) 적용은 자동이 아니라 사용자 결정 게이트 — 이 함수는 '탐지만' 한다(적용 X).
5235
+ // FP/안전: root 자신 제외(dirname 부터 시작) + 깊이 상한(monorepo/깊은 트리에서 무한 상승 방지) + 실제 워크스페이스 디렉토리만 매칭.
5236
+ function _findParentWorkspace(root, opts = {}) {
5237
+ try {
5238
+ root = absRoot(root);
5239
+ const maxDepth = opts.maxDepth || 8;
5240
+ let cur = path.dirname(root);
5241
+ let prev = null, depth = 0;
5242
+ while (cur && cur !== prev && depth < maxDepth) {
5243
+ const hasHarness = exists(path.join(cur, '.harness'));
5244
+ const hasLeerness = exists(path.join(cur, '.leerness'));
5245
+ if (hasHarness || hasLeerness) {
5246
+ const wd = (hasLeerness && exists(path.join(cur, '.leerness', 'MIGRATED_FROM_HARNESS'))) ? '.leerness' : (hasHarness ? '.harness' : '.leerness');
5247
+ const wsAbs = path.join(cur, wd);
5248
+ const assets = {
5249
+ designSystem: exists(path.join(wsAbs, 'design-system.md')),
5250
+ reuseMap: exists(path.join(wsAbs, 'reuse-map.md')),
5251
+ agents: exists(path.join(cur, 'AGENTS.md')),
5252
+ skills: exists(path.join(wsAbs, 'skills')),
5253
+ };
5254
+ const assetCount = Object.values(assets).filter(Boolean).length;
5255
+ return { parentRoot: cur, workspaceDir: wd, workspaceAbs: wsAbs, assets, assetCount, depth: depth + 1 };
5256
+ }
5257
+ prev = cur; cur = path.dirname(cur); depth++;
5258
+ }
5259
+ } catch {}
5260
+ return null;
5261
+ }
5165
5262
  // .harness → .leerness 마이그레이션 (copy + reference guide 생성)
5166
5263
  function _migrateWorkspaceDir(root, opts = {}) {
5167
5264
  const dryRun = opts.dryRun === true;
@@ -6001,6 +6098,103 @@ function workspaceDirCmd(root, sub) {
6001
6098
  process.exit(1);
6002
6099
  }
6003
6100
 
6101
+ // 1.30.2 (#157 사용자명시): leerness parent — 상위 leerness 부모 프로젝트 탐지(read-only).
6102
+ // 방향 C(교차검토): 부모 자산을 '재사용 후보'로 AI 에게 노출만 하고, 톤/스타일 등 실제 적용은 사용자 결정 게이트(후속 adopt 명령).
6103
+ // intent expand(1.9.213) 안전 모델과 동일 철학 — 탐지/노출 ≠ 적용. 이 명령은 아무 파일도 쓰지 않는다.
6104
+ function parentCmd(root, sub) {
6105
+ root = absRoot(root);
6106
+ const uiLang = _uiLang(root);
6107
+ const t = (ko, en) => (uiLang === 'en' ? en : ko);
6108
+ const isTty = process.stdout && process.stdout.isTTY;
6109
+ const cyan = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
6110
+ const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
6111
+ const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
6112
+ const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
6113
+ const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
6114
+ if (!sub || sub === 'detect') {
6115
+ const p = _findParentWorkspace(root);
6116
+ if (has('--json')) { log(JSON.stringify({ version: VERSION, root, parent: p, applied: false }, null, 2)); return; }
6117
+ log(cyan(`# leerness parent detect (1.30.2)`));
6118
+ if (!p) {
6119
+ log(dim(t(` 상위 leerness 부모 프로젝트 없음 — 독립 프로젝트입니다.`, ` no parent leerness project found — this is a standalone project.`)));
6120
+ return;
6121
+ }
6122
+ const mark = b => b ? '✓' : '✗';
6123
+ log(t(` 부모 프로젝트: ${grn(p.parentRoot)} (${p.workspaceDir}/, depth ${p.depth})`, ` parent project: ${grn(p.parentRoot)} (${p.workspaceDir}/, depth ${p.depth})`));
6124
+ log(t(` 재사용 가능 자산 ${p.assetCount}건: design-system=${mark(p.assets.designSystem)} reuse-map=${mark(p.assets.reuseMap)} AGENTS=${mark(p.assets.agents)} skills=${mark(p.assets.skills)}`,
6125
+ ` reusable assets ${p.assetCount}: design-system=${mark(p.assets.designSystem)} reuse-map=${mark(p.assets.reuseMap)} AGENTS=${mark(p.assets.agents)} skills=${mark(p.assets.skills)}`));
6126
+ log('');
6127
+ log(yel(t(` ⚠ 자동 적용하지 않음 — 부모 자산(톤/스타일/디자인) 재사용은 사용자 결정 게이트입니다.`, ` ⚠ not auto-applied — reusing parent assets (tone/style/design) is a user decision.`)));
6128
+ log(dim(t(` 참고: leerness reuse find "<capability>" --path ${p.parentRoot} · 부모 design-system: ${path.join(p.workspaceAbs, 'design-system.md')}`,
6129
+ ` ref: leerness reuse find "<capability>" --path ${p.parentRoot} · parent design-system: ${path.join(p.workspaceAbs, 'design-system.md')}`)));
6130
+ log(dim(t(` 재사용 적용(사용자 결정): leerness parent adopt --select design-system,reuse-map,conventions --apply`,
6131
+ ` adopt (your decision): leerness parent adopt --select design-system,reuse-map,conventions --apply`)));
6132
+ return;
6133
+ }
6134
+ // 1.30.3 (#158): leerness parent adopt — 부모 자산을 자식-로컬 참조로 기록(게이트형 적용).
6135
+ // 사용자 결정 게이트: dry-run 기본, --apply(사용자 명시) 시에만 기록. 자식 design-system.md 무변경(비파괴, additive).
6136
+ if (sub === 'adopt') {
6137
+ const p = _findParentWorkspace(root);
6138
+ const apply = has('--apply');
6139
+ const selRaw = arg('--select', 'all');
6140
+ const allKinds = ['design-system', 'reuse-map', 'conventions'];
6141
+ const kinds = (selRaw === 'all') ? allKinds : String(selRaw).split(',').map(s => s.trim()).filter(Boolean);
6142
+ const wsAbs = _workspaceDirAbs(root);
6143
+ const inheritedPath = path.join(wsAbs, 'inherited-from-parent.md');
6144
+ const linkPath = path.join(wsAbs, 'PARENT_LINK.json');
6145
+ const cand = [];
6146
+ if (p) {
6147
+ if (kinds.includes('design-system') && p.assets.designSystem) cand.push({ kind: 'design-system', src: path.join(p.workspaceAbs, 'design-system.md') });
6148
+ if (kinds.includes('reuse-map') && p.assets.reuseMap) cand.push({ kind: 'reuse-map', src: path.join(p.workspaceAbs, 'reuse-map.md') });
6149
+ if (kinds.includes('conventions') && p.assets.agents) cand.push({ kind: 'conventions', src: path.join(p.parentRoot, 'AGENTS.md') });
6150
+ }
6151
+ // 1.30.3: --json 은 단일 객체만 출력 — apply(부모 존재) 경로는 자체 JSON(applied:true)을 내므로 여기선 제외(이중 JSON 방지).
6152
+ if (has('--json') && (!p || !apply)) {
6153
+ log(JSON.stringify({ version: VERSION, root, parent: p ? p.parentRoot : null, selected: kinds, candidates: cand.map(c => c.kind), apply, applied: false, inheritedPath: null }, null, 2));
6154
+ }
6155
+ if (!p) {
6156
+ if (!has('--json')) { log(cyan(`# leerness parent adopt (1.30.3)`)); log(dim(t(` 상위 leerness 부모 프로젝트 없음 — adopt 대상 없음.`, ` no parent leerness project — nothing to adopt.`))); }
6157
+ return;
6158
+ }
6159
+ if (!apply) {
6160
+ if (!has('--json')) {
6161
+ log(cyan(`# leerness parent adopt (1.30.3) [DRY-RUN]`));
6162
+ log(t(` 부모: ${grn(p.parentRoot)} · 선택: ${kinds.join(', ')}`, ` parent: ${grn(p.parentRoot)} · select: ${kinds.join(', ')}`));
6163
+ if (!cand.length) log(dim(t(` 적용 후보 없음 (부모에 선택 자산 없음).`, ` no candidates (parent lacks the selected assets).`)));
6164
+ else cand.forEach(c => log(t(` • ${c.kind} ← ${c.src}`, ` • ${c.kind} ← ${c.src}`)));
6165
+ log('');
6166
+ log(yel(t(` ⚠ DRY-RUN — 실제 적용하려면 \`leerness parent adopt --apply\` (사용자 명시 결정).`, ` ⚠ DRY-RUN — to apply, run \`leerness parent adopt --apply\` (explicit user decision).`)));
6167
+ log(dim(t(` 적용해도 자식 design-system.md 는 변경하지 않고, .harness/inherited-from-parent.md 에 '참조'로만 기록(비파괴).`,
6168
+ ` even on apply, your design-system.md is NOT modified — parent assets are recorded as reference in .harness/inherited-from-parent.md.`)));
6169
+ }
6170
+ return;
6171
+ }
6172
+ // APPLY (사용자 명시): 자식-로컬 참조 파일 + 마커 기록 (비파괴 additive — 자식 원본 design-system.md/reuse-map.md 직접 변형 안 함)
6173
+ try {
6174
+ mkdirp(wsAbs);
6175
+ let body = `<!-- leerness:inherited-from-parent (1.30.3) — 사용자 명시 \`parent adopt --apply\`. 부모 자산을 '참조'로만 기록(자식 원본 무변경). -->\n`;
6176
+ body += `# ${t('부모 프로젝트 자산 (참조용)', 'Parent project assets (reference)')}\n\n`;
6177
+ body += `- ${t('부모', 'parent')}: ${p.parentRoot}\n- adopt: ${today()}\n- ${t('선택', 'select')}: ${kinds.join(', ')}\n\n`;
6178
+ for (const c of cand) { const content = exists(c.src) ? read(c.src) : ''; body += `## ${c.kind} (from ${c.src})\n\n${String(content).trim()}\n\n`; }
6179
+ writeUtf8(inheritedPath, body);
6180
+ writeUtf8(linkPath, JSON.stringify({ parentRoot: p.parentRoot, workspaceDir: p.workspaceDir, adoptedKinds: kinds, adoptedAt: today(), version: VERSION }, null, 2) + '\n');
6181
+ if (has('--json')) { log(JSON.stringify({ version: VERSION, root, parent: p.parentRoot, selected: kinds, candidates: cand.map(c => c.kind), apply: true, applied: true, inheritedPath }, null, 2)); }
6182
+ else {
6183
+ log(cyan(`# leerness parent adopt (1.30.3)`));
6184
+ log(grn(t(` ✓ adopt 완료 (${cand.length} 자산) — 자식 design-system.md 무변경(참조로만 기록).`, ` ✓ adopted (${cand.length} assets) — your design-system.md unchanged (recorded as reference only).`)));
6185
+ log(dim(` ${t('참조', 'reference')}: ${inheritedPath}`));
6186
+ log(dim(` ${t('마커', 'marker')}: ${linkPath}`));
6187
+ }
6188
+ } catch (e) {
6189
+ if (!has('--json')) log(red(t(` ✗ adopt 실패: ${e.message}`, ` ✗ adopt failed: ${e.message}`)));
6190
+ process.exitCode = 1;
6191
+ }
6192
+ return;
6193
+ }
6194
+ console.error(t(`Usage: leerness parent [detect|adopt] [--select <kinds>] [--apply] [--json]`, `Usage: leerness parent [detect|adopt] [--select <kinds>] [--apply] [--json]`));
6195
+ process.exit(1);
6196
+ }
6197
+
6004
6198
  // 1.9.211: leerness migrate-workspace-dir — .harness → .leerness 마이그레이션 (사용자 명시)
6005
6199
  function migrateWorkspaceDirCmd(root) {
6006
6200
  root = absRoot(root);
@@ -7515,12 +7709,19 @@ function lessonSave(root, text) {
7515
7709
  if (!text) return fail('lesson text required. 예: leerness lesson save "JWT는 refresh token도 짧게 (15분 권장)"');
7516
7710
  const tag = arg('--tag', '');
7517
7711
  // 1.9.406 (8번째 버그헌트, UR-0110): RMW 락 직렬화 — 동시 lesson save lost-update 방지(UR-0043 패턴).
7712
+ // 1.30.4 (14th리뷰 F5): task/rule add 와 일관된 dedup — 동일 text 존재 시 skip(--force 우회). 종전엔 무조건 append(중복 누적).
7713
+ let _skipped = false;
7518
7714
  _withLock(lessonsJsonPath(root), () => {
7519
7715
  const all = _loadLessons(root);
7716
+ if (!has('--force') && all.some(l => l && l.text === text)) { _skipped = true; return; }
7520
7717
  all.push({ date: today(), text, tag: tag || null });
7521
7718
  _saveLessons(root, all);
7522
7719
  });
7523
7720
  // 1.9.413 (6th외부평가 codex P2, UR-0101): --json 구조화 출력(데이터 이미 영속).
7721
+ if (_skipped) {
7722
+ if (has('--json')) { log(JSON.stringify({ ok: true, skipped: true, text, tag: tag || null })); return; }
7723
+ ok(`lesson exists (skip): ${text.slice(0, 40)} (--force 로 추가)`); return;
7724
+ }
7524
7725
  if (has('--json')) { log(JSON.stringify({ ok: true, text, tag: tag || null })); return; }
7525
7726
  ok(`lesson saved`);
7526
7727
  _autoRoadmap(absRoot(root), 'data-change');
@@ -7603,8 +7804,11 @@ function decisionAdd(root, title) {
7603
7804
  const impact = arg('--impact', '');
7604
7805
  // 1.9.339 (UR-0053): canonical JSON write path — 기존 항목(JSON 우선, 없으면 MD backfill) 로드 후 추가, JSON+MD projection 동시 저장.
7605
7806
  // 1.9.406 (8번째 버그헌트, UR-0110): RMW 락 직렬화 — 동시 decision add lost-update 방지(UR-0043 패턴).
7807
+ // 1.30.4 (14th리뷰 F5): task/rule add 와 일관된 dedup — 동일 title 존재 시 skip(--force 우회). 종전엔 무조건 append(중복 누적).
7808
+ let _skipped = false;
7606
7809
  _withLock(decisionsJsonPath(root), () => {
7607
7810
  const all = _loadDecisions(root);
7811
+ if (!has('--force') && all.some(d => d && (d.title === title || d.decision === title))) { _skipped = true; return; }
7608
7812
  all.push({
7609
7813
  date: today(), title,
7610
7814
  decision: title,
@@ -7615,6 +7819,10 @@ function decisionAdd(root, title) {
7615
7819
  _saveDecisions(root, all);
7616
7820
  });
7617
7821
  // 1.9.413 (6th외부평가 codex P2, UR-0101): --json 구조화 출력(데이터 이미 영속).
7822
+ if (_skipped) {
7823
+ if (has('--json')) { log(JSON.stringify({ ok: true, skipped: true, title })); return; }
7824
+ ok(`decision exists (skip): ${title} (--force 로 추가)`); return;
7825
+ }
7618
7826
  if (has('--json')) { log(JSON.stringify({ ok: true, title })); return; }
7619
7827
  ok(`decision added: ${title}`);
7620
7828
  // 1.9.43+ handoff lessons 회수 흐름과 자동 통합 (decisions.md fuzzy 매칭됨)
@@ -7832,7 +8040,7 @@ function debug(root) {
7832
8040
 
7833
8041
  const _audit = require('../lib/audit');
7834
8042
  // 1.9.421 (UR-0025/UR-0125 큰 핸들러 모듈화 6번째): audit → lib/audit.js (DI 위임, thin wrapper)
7835
- function audit(root, opts = {}) { return _audit.audit(root, opts, { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills }); }
8043
+ function audit(root, opts = {}) { return _audit.audit(root, opts, { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills, _collectSecretFindings }); }
7836
8044
 
7837
8045
  // 1.9.312 (UR-0050, 설치리뷰 3중수렴): secret 스캐너 현대 키 패턴 보강.
7838
8046
  // 배경: 기존 OpenAI 패턴 `sk-[A-Za-z0-9]{32,}` 은 하이픈에서 끊겨 sk-proj-/sk-svcacct- (modern 프로젝트/서비스 키)를 놓침.
@@ -8448,6 +8656,16 @@ function handoff(root) {
8448
8656
  const parts = [];
8449
8657
  // 1.20.3 (UR-0010 Phase 2): 헤드라인 항목 라벨 UI 언어 적용 (영어 opt-in, 한국어 기본). 블록 1회 해석.
8450
8658
  const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
8659
+ // 1.30.2 (#157): 상위 leerness 부모 프로젝트 탐지 → AI 가 세션 시작 즉시 인지(재사용 후보). read-only — 자동 적용 X(사용자 결정 게이트). 상세: leerness parent detect
8660
+ try {
8661
+ const _pw = _findParentWorkspace(root);
8662
+ if (_pw && _pw.assetCount > 0) {
8663
+ const _adopted = exists(path.join(_workspaceDirAbs(root), 'PARENT_LINK.json')); // 1.30.3: adopt 여부 반영
8664
+ parts.push(_adopted
8665
+ ? t(`🔗 부모 프로젝트 (${_pw.assetCount} 자산·adopted)`, `🔗 parent project (${_pw.assetCount} assets, adopted)`)
8666
+ : t(`🔗 부모 프로젝트 (${_pw.assetCount} 자산·미적용)`, `🔗 parent project (${_pw.assetCount} assets, not applied)`));
8667
+ }
8668
+ } catch {}
8451
8669
  // 1) drift level (가벼운 check)
8452
8670
  try {
8453
8671
  const r = cp.spawnSync(process.execPath, [__filename, 'drift', 'check', root, '--json'],
@@ -8806,23 +9024,26 @@ function handoff(root) {
8806
9024
  const hasFailures = sf.failures && sf.failures.length > 0;
8807
9025
  const hasDrift = drift && drift.changes && drift.changes.length > 0;
8808
9026
  if (hasFailures || hasDrift) {
9027
+ // 1.29.3: headline t() 스코프 밖 — 로컬 t() (1.29.1 교훈: 없으면 ReferenceError 가 try 에 삼켜져 블록 증발)
9028
+ const _Lsh = _uiLang(root);
9029
+ const t = (ko, en) => (_Lsh === 'en' ? en : ko);
8809
9030
  const isTty8 = process.stdout && process.stdout.isTTY;
8810
9031
  const yl8 = s => isTty8 ? `\x1b[33m${s}\x1b[0m` : s;
8811
9032
  const dm8 = s => isTty8 ? `\x1b[2m${s}\x1b[0m` : s;
8812
9033
  const cy8 = s => isTty8 ? `\x1b[36m${s}\x1b[0m` : s;
8813
9034
  log('');
8814
- log(cy8(`## 🐚 터미널 셸 가드 (1.9.263, UR-0020)`));
9035
+ log(cy8(t(`## 🐚 터미널 셸 가드 (1.9.263, UR-0020)`, `## 🐚 Terminal shell guard (UR-0020)`)));
8815
9036
  if (hasDrift) {
8816
- log(yl8(` ⚠ 환경 버전 변동 — 과거 셸 실패 기록 재검토 권장:`));
9037
+ log(yl8(t(` ⚠ 환경 버전 변동 — 과거 셸 실패 기록 재검토 권장:`, ` ⚠ Environment version changed — review past shell failures:`)));
8817
9038
  drift.changes.forEach(ch => log(dm8(` ${ch.what}: ${ch.from} → ${ch.to}`)));
8818
9039
  }
8819
9040
  if (hasFailures) {
8820
- log(dm8(` 최근 셸 실패 ${sf.failures.length}건 (최대 3 표시):`));
9041
+ log(dm8(t(` 최근 셸 실패 ${sf.failures.length}건 (최대 3 표시):`, ` ${sf.failures.length} recent shell failure(s) (showing up to 3):`)));
8821
9042
  sf.failures.slice(-3).reverse().forEach(f => {
8822
9043
  const rules = (f.issues && f.issues.length) ? ` [${f.issues.join(',')}]` : '';
8823
9044
  log(dm8(` • ${(f.cmd || '').slice(0, 50)} (exit=${f.exitCode}, ${f.shell})${rules}`));
8824
9045
  });
8825
- log(dm8(` → 명령 실행 전 점검: leerness shell-guard "<command>"`));
9046
+ log(dm8(t(` → 명령 실행 전 점검: leerness shell-guard "<command>"`, ` → check before running a command: leerness shell-guard "<command>"`)));
8826
9047
  }
8827
9048
  }
8828
9049
  } catch {}
@@ -8835,20 +9056,23 @@ function handoff(root) {
8835
9056
  return v && v !== '0' && String(v).toLowerCase() !== 'false';
8836
9057
  });
8837
9058
  if (enabledAgents.length > 0) {
9059
+ // 1.29.4: headline t() 스코프 밖 — 로컬 t() (1.29.1 교훈)
9060
+ const _Lag = _uiLang(root);
9061
+ const t = (ko, en) => (_Lag === 'en' ? en : ko);
8838
9062
  const isTtyS = process.stdout && process.stdout.isTTY;
8839
9063
  const cyS = s => isTtyS ? `\x1b[36m${s}\x1b[0m` : s;
8840
9064
  const dmS = s => isTtyS ? `\x1b[2m${s}\x1b[0m` : s;
8841
9065
  log('');
8842
- log(cyS(`## 🤖 CLI 에이전트 슬래시 명령 (1.9.265~266, UR-0021)`));
8843
- log(dmS(` 활성 에이전트 ${enabledAgents.length}개 — sub-agent 호출 시 각자 슬래시 명령 사용:`));
9066
+ log(cyS(t(`## 🤖 CLI 에이전트 슬래시 명령 (1.9.265~266, UR-0021)`, `## 🤖 CLI agent slash commands (UR-0021)`)));
9067
+ log(dmS(t(` 활성 에이전트 ${enabledAgents.length}개 — sub-agent 호출 시 각자 슬래시 명령 사용:`, ` ${enabledAgents.length} active agent(s) — use each one's slash commands when invoking a sub-agent:`)));
8844
9068
  for (const a of enabledAgents) {
8845
9069
  const hint = _agentSlashHint(root, a.id);
8846
9070
  if (hint && hint.commands.length) {
8847
9071
  const top = hint.commands.slice(0, 8).map(c => c.cmd).join(' ');
8848
- log(dmS(` ${a.id.padEnd(8)} ${top}${hint.invoke === 'subcommand' ? ' (하위명령)' : ''}`));
9072
+ log(dmS(` ${a.id.padEnd(8)} ${top}${hint.invoke === 'subcommand' ? t(' (하위명령)', ' (subcommand)') : ''}`));
8849
9073
  }
8850
9074
  }
8851
- log(dmS(` → 전체/기록/최신화: leerness slash-commands [agent] [--record]`));
9075
+ log(dmS(t(` → 전체/기록/최신화: leerness slash-commands [agent] [--record]`, ` → full list / record / refresh: leerness slash-commands [agent] [--record]`)));
8852
9076
  }
8853
9077
  } catch {}
8854
9078
 
@@ -9302,24 +9526,25 @@ function handoff(root) {
9302
9526
  const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
9303
9527
  const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
9304
9528
  const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
9529
+ const _t = (ko, en) => (_uiLang(root) === 'en' ? en : ko); // 1.30.5 (#156 F3): 로컬 i18n (이 블록 9536 에 t 화살표 파라미터가 있어 _t 로 명명)
9305
9530
  if (gapInfo.hasLast) {
9306
9531
  // 마지막 handoff 시점 알고 있음 — 정확한 측정 (1.9.199)
9307
9532
  if (gapInfo.isLong) {
9308
9533
  // 60분+ → 강한 알림
9309
- log(red(`## ⏰ ScheduleWakeup miss 강한 의심 (1.9.199) — 이전 handoff ${gapInfo.gapMin}분 전`));
9310
- log(dim(` R-0001 영구 룰 (25분) 대비 ${Math.floor(gapInfo.gapMin/25)}× 초과 — 시스템 sleep / wakeup 누락 확실`));
9311
- log(dim(` → 회복: 사용자가 "다음 라운드" 입력 또는 leerness rule list 로 룰 확인`));
9312
- log(dim(` → handoff 이력: ${(gapInfo.history || []).slice(-3).map(t => t.slice(11, 19)).join(' → ')}`));
9534
+ log(red(_t(`## ⏰ ScheduleWakeup miss 강한 의심 (1.9.199) — 이전 handoff ${gapInfo.gapMin}분 전`, `## ⏰ ScheduleWakeup miss strongly suspected — last handoff ${gapInfo.gapMin} min ago`)));
9535
+ log(dim(_t(` R-0001 영구 룰 (25분) 대비 ${Math.floor(gapInfo.gapMin/25)}× 초과 — 시스템 sleep / wakeup 누락 확실`, ` ${Math.floor(gapInfo.gapMin/25)}× over the R-0001 rule (25 min) — system sleep / missed wakeup`)));
9536
+ log(dim(_t(` → 회복: 사용자가 "다음 라운드" 입력 또는 leerness rule list 로 룰 확인`, ` → recover: user types "next round" or check rules via leerness rule list`)));
9537
+ log(dim(_t(` → handoff 이력: ${(gapInfo.history || []).slice(-3).map(t => t.slice(11, 19)).join(' → ')}`, ` → handoff history: ${(gapInfo.history || []).slice(-3).map(t => t.slice(11, 19)).join(' → ')}`)));
9313
9538
  log('');
9314
9539
  } else if (gapInfo.isMiss) {
9315
9540
  // 35~60분 → 의심
9316
- log(yel(`## ⏰ ScheduleWakeup 지연 (1.9.199) — 이전 handoff ${gapInfo.gapMin}분 전 (룰: 25분)`));
9317
- log(dim(` ±10분 buffer 초과 — wakeup 한 cycle 누락 가능성`));
9541
+ log(yel(_t(`## ⏰ ScheduleWakeup 지연 (1.9.199) — 이전 handoff ${gapInfo.gapMin}분 전 (룰: 25분)`, `## ⏰ ScheduleWakeup delayed — last handoff ${gapInfo.gapMin} min ago (rule: 25 min)`)));
9542
+ log(dim(_t(` ±10분 buffer 초과 — wakeup 한 cycle 누락 가능성`, ` beyond the ±10 min buffer — a wakeup cycle may have been missed`)));
9318
9543
  log('');
9319
9544
  } else if (gapInfo.gapMin >= 0 && gapInfo.gapMin <= 30) {
9320
9545
  // 정상 범위 (handoff_history.length >= 2 일 때만 의미 있음 — 첫 진입 제외)
9321
9546
  if ((gapInfo.history || []).length >= 2) {
9322
- log(dim(` ✓ ScheduleWakeup cycle 정상 (gap ${gapInfo.gapMin}분, 룰 25분 — 1.9.199)`));
9547
+ log(dim(_t(` ✓ ScheduleWakeup cycle 정상 (gap ${gapInfo.gapMin}분, 룰 25분 — 1.9.199)`, ` ✓ ScheduleWakeup cycle healthy (gap ${gapInfo.gapMin} min, rule 25 min)`)));
9323
9548
  }
9324
9549
  }
9325
9550
  } else {
@@ -9329,9 +9554,9 @@ function handoff(root) {
9329
9554
  const ageMs = Date.now() - fs.statSync(tlp).mtimeMs;
9330
9555
  const ageMin = Math.floor(ageMs / 60000);
9331
9556
  if (ageMin >= 60) {
9332
- const label = ageMin < 120 ? `${ageMin}분` : (ageMin < 1440 ? `${Math.floor(ageMin/60)}시간` : `${Math.floor(ageMin/1440)}일`);
9333
- log(yel(`## ⏰ ScheduleWakeup miss 의심 (1.9.196 fallback) — task-log 마지막 ${label} 전`));
9334
- log(dim(` 1.9.199 last-handoff.json 첫 기록 — 다음 handoff 부터 정확 측정`));
9557
+ const label = ageMin < 120 ? _t(`${ageMin}분`, `${ageMin}min`) : (ageMin < 1440 ? _t(`${Math.floor(ageMin/60)}시간`, `${Math.floor(ageMin/60)}h`) : _t(`${Math.floor(ageMin/1440)}일`, `${Math.floor(ageMin/1440)}d`));
9558
+ log(yel(_t(`## ⏰ ScheduleWakeup miss 의심 (1.9.196 fallback) — task-log 마지막 ${label} 전`, `## ⏰ ScheduleWakeup miss suspected (fallback) — task-log last modified ${label} ago`)));
9559
+ log(dim(_t(` 1.9.199 last-handoff.json 첫 기록 — 다음 handoff 부터 정확 측정`, ` first last-handoff.json record — precise measurement from the next handoff`)));
9335
9560
  log('');
9336
9561
  }
9337
9562
  }
@@ -9346,6 +9571,7 @@ function handoff(root) {
9346
9571
  const isTtyMd = process.stdout && process.stdout.isTTY;
9347
9572
  const mdCy = s => isTtyMd ? `\x1b[36m${s}\x1b[0m` : s;
9348
9573
  const mdDim = s => isTtyMd ? `\x1b[2m${s}\x1b[0m` : s;
9574
+ const t = (ko, en) => (_uiLang(root) === 'en' ? en : ko); // 1.30.5 (#156 F3): 로컬 t()
9349
9575
  const cutoff = Date.now() - 24 * 60 * 60 * 1000;
9350
9576
  const deltas = [];
9351
9577
  // tasks: progress-tracker.md row Updated 컬럼 기반 (간단 휴리스틱 — mtime이 24h내면 표시)
@@ -9386,7 +9612,7 @@ function handoff(root) {
9386
9612
  const pp = planPath(root);
9387
9613
  if (exists(pp) && fs.statSync(pp).mtimeMs > cutoff) {
9388
9614
  // M-XXXX 중 line이 24h내 추가됐는지 정확히는 어려움 — mtime 24h내면 "plan: 변경됨"으로 표시
9389
- deltas.push('plan: 변경됨');
9615
+ deltas.push(t('plan: 변경됨', 'plan: changed'));
9390
9616
  }
9391
9617
  } catch {}
9392
9618
  // rules: rule add 후 mtime 24h내
@@ -9398,8 +9624,8 @@ function handoff(root) {
9398
9624
  }
9399
9625
  } catch {}
9400
9626
  if (deltas.length) {
9401
- log(mdCy(`🆕 최근 24h 메모리 변동 (1.9.121): ${deltas.join(' · ')}`));
9402
- log(mdDim(` → 상세: leerness memory status --json`));
9627
+ log(mdCy(t(`🆕 최근 24h 메모리 변동 (1.9.121): ${deltas.join(' · ')}`, `🆕 memory changes in last 24h: ${deltas.join(' · ')}`)));
9628
+ log(mdDim(t(` → 상세: leerness memory status --json`, ` → details: leerness memory status --json`)));
9403
9629
  log('');
9404
9630
  }
9405
9631
  } catch {}
@@ -9489,6 +9715,9 @@ function handoff(root) {
9489
9715
  // 첫 실행에선 자동 캡처 (silent), 이후엔 변동/누락 시에만 노출.
9490
9716
  if (!has('--no-env-detect') && !has('--compact') && !has('--quiet') && process.env.LEERNESS_NO_ENV_DETECT !== '1') {
9491
9717
  try {
9718
+ // 1.29.2: headline t() 스코프 밖 — 로컬 t() (없으면 ReferenceError 가 try 에 삼켜져 env-detect 블록이 사라짐, 1.29.1 교훈)
9719
+ const _Lenv = _uiLang(root);
9720
+ const t = (ko, en) => (_Lenv === 'en' ? en : ko);
9492
9721
  const isTtyEd = process.stdout && process.stdout.isTTY;
9493
9722
  const edCy = s => isTtyEd ? `\x1b[35m${s}\x1b[0m` : s; // magenta
9494
9723
  const edDim = s => isTtyEd ? `\x1b[2m${s}\x1b[0m` : s;
@@ -9502,14 +9731,14 @@ function handoff(root) {
9502
9731
  } else if (diff.changes.length || (diff.missing && diff.missing.length)) {
9503
9732
  // 변동/누락 알림
9504
9733
  if (diff.missing && diff.missing.length) {
9505
- log(edCy(`🖥 실행 환경 (1.9.145): ⚠ PATH 누락 ${diff.missing.length}건 — npm run 시 실패 가능`));
9734
+ log(edCy(t(`🖥 실행 환경 (1.9.145): ⚠ PATH 누락 ${diff.missing.length}건 — npm run 시 실패 가능`, `🖥 Runtime environment: ⚠ ${diff.missing.length} PATH tool(s) missing — npm run may fail`)));
9506
9735
  for (const m of diff.missing.slice(0, 3)) log(edDim(` • ${m.command} (used by: npm run ${m.usedBy})`));
9507
9736
  }
9508
9737
  if (diff.changes.length) {
9509
- log(edCy(`🖥 실행 환경 (1.9.145): 변동 ${diff.changes.length}건 감지`));
9738
+ log(edCy(t(`🖥 실행 환경 (1.9.145): 변동 ${diff.changes.length}건 감지`, `🖥 Runtime environment: ${diff.changes.length} change(s) detected`)));
9510
9739
  for (const c of diff.changes.slice(0, 3)) log(edDim(` • ${c}`));
9511
9740
  }
9512
- log(edDim(` → 상세: leerness env detect . --json`));
9741
+ log(edDim(t(` → 상세: leerness env detect . --json`, ` → details: leerness env detect . --json`)));
9513
9742
  log('');
9514
9743
  // 갱신 (다음 비교 baseline)
9515
9744
  try { writeUtf8(snapPath, JSON.stringify(curr, null, 2) + '\n'); } catch {}
@@ -9520,13 +9749,21 @@ function handoff(root) {
9520
9749
  // 매 세션 시작 시 AI가 보안 위험을 즉시 인지. --no-security-summary 또는 --compact로 끄기
9521
9750
  if (!has('--no-security-summary') && !has('--compact') && !has('--quiet')) {
9522
9751
  try {
9752
+ // 1.29.1: 이 블록은 headline의 t() 스코프 밖 — 로컬 t() 정의 (없으면 ReferenceError가 try에 삼켜져 보안 요약 전체가 사라짐)
9753
+ const _Lsec = _uiLang(root);
9754
+ const t = (ko, en) => (_Lsec === 'en' ? en : ko);
9523
9755
  const envExists = exists(path.join(root, '.env'));
9756
+ const issues = [];
9757
+ // 0) 1.30.1 (14th 외부리뷰 F2): 커밋된 plaintext 시크릿을 보안 요약에 노출 — headline '🚨 시크릿 N건' 과 일관.
9758
+ // envExists 무관(소스에 커밋된 시크릿은 .env 없어도 위험). gitignored 는 _collectSecretFindings 가 committed 에서 제외.
9759
+ let committedSecrets = [];
9760
+ try { committedSecrets = _collectSecretFindings(root).committed || []; } catch {}
9761
+ if (committedSecrets.length) issues.push(t(`커밋된 시크릿 ${committedSecrets.length}건 (소스 노출)`, `${committedSecrets.length} committed secret(s) (exposed in source)`));
9524
9762
  if (envExists) {
9525
- const issues = [];
9526
9763
  // 1) env diff
9527
9764
  try {
9528
9765
  const d = envDiff(root);
9529
- if (d.inEnvOnly.length) issues.push(`.env→.env.example 누락 ${d.inEnvOnly.length}건`);
9766
+ if (d.inEnvOnly.length) issues.push(t(`.env→.env.example 누락 ${d.inEnvOnly.length}건`, `.env→.env.example missing ${d.inEnvOnly.length}`));
9530
9767
  } catch {}
9531
9768
  // 2) gitignore 시크릿 패턴
9532
9769
  try {
@@ -9535,43 +9772,45 @@ function handoff(root) {
9535
9772
  const giLines = giText.split('\n').map(l => l.trim());
9536
9773
  const SECRET_PATTERNS = ['.env', '.env.local', '.env.production', '.env.*.local', '*.pem', 'credentials.json'];
9537
9774
  const missing = SECRET_PATTERNS.filter(p => !giLines.some(l => l === p || l === '/' + p));
9538
- if (missing.length) issues.push(`.gitignore 시크릿 누락 ${missing.length}건`);
9775
+ if (missing.length) issues.push(t(`.gitignore 시크릿 누락 ${missing.length}건`, `.gitignore missing secret patterns ${missing.length}`));
9539
9776
  } catch {}
9540
- if (issues.length) {
9541
- const isTty = process.stdout && process.stdout.isTTY;
9542
- const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
9543
- const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
9544
- const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
9545
- log('');
9546
- log(red(`## 🔒 보안 요약 (1.9.76) — ${issues.length}건 주의`));
9547
- for (const i of issues) log(dim(` ⚠ ${i}`));
9548
- log(dim(` 자동 수정: leerness audit --fix · 상세: leerness env check / leerness audit`));
9549
- // 1.9.80: critical 수준 (.gitignore에 .env 자체 누락) 자동 회복 옵션
9550
- const giText = exists(path.join(root, '.gitignore')) ? read(path.join(root, '.gitignore')) : '';
9551
- const giLines = giText.split('\n').map(l => l.trim());
9552
- const envInGitignore = giLines.includes('.env') || giLines.includes('/.env');
9553
- if (!envInGitignore) {
9554
- // .env 자체 누락 최우선 위험
9555
- log(yel(` 🚨 CRITICAL (1.9.80): .env가 .gitignore에 없습니다! 시크릿 노출 위험 — 즉시 \`leerness audit --fix\` 권장.`));
9556
- // LEERNESS_AUTO_SECURITY_FIX=1 자동 실행 옵션
9557
- if (process.env.LEERNESS_AUTO_SECURITY_FIX === '1') {
9558
- try {
9559
- const r = cp.spawnSync(process.execPath, [__filename, 'audit', root, '--fix'],
9560
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
9561
- if (r.status === 0) {
9562
- log(dim(` ✓ 자동 회복 (LEERNESS_AUTO_SECURITY_FIX=1): audit --fix 완료`));
9563
- } else {
9564
- log(dim(` ⚠ 자동 회복 실패 (exit ${r.status}) 수동 \`leerness audit --fix\` 권장`));
9565
- }
9566
- } catch (e) {
9567
- log(dim(` ⚠ 자동 회복 예외: ${e.message}`));
9777
+ }
9778
+ if (issues.length) {
9779
+ const isTty = process.stdout && process.stdout.isTTY;
9780
+ const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
9781
+ const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
9782
+ const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
9783
+ log('');
9784
+ log(red(t(`## 🔒 보안 요약 (1.9.76) ${issues.length}건 주의`, `## 🔒 Security summary — ${issues.length} to review`)));
9785
+ for (const i of issues) log(dim(` ${i}`));
9786
+ // 1.30.1 (F2): 커밋된 시크릿 파일 위치 노출 ( snippet 미출력 handoff 로그로의 시크릿 유출 방지)
9787
+ for (const f of committedSecrets.slice(0, 4)) log(dim(` • ${f.file}:${f.line} (${f.name})`));
9788
+ log(dim(t(` → 자동 수정: leerness audit --fix · 상세: leerness scan secrets / leerness env check`, ` → auto-fix: leerness audit --fix · details: leerness scan secrets / leerness env check`)));
9789
+ // 1.9.80: critical 수준 (.gitignore에 .env 자체 누락) 자동 회복 옵션
9790
+ const giText2 = exists(path.join(root, '.gitignore')) ? read(path.join(root, '.gitignore')) : '';
9791
+ const giLines2 = giText2.split('\n').map(l => l.trim());
9792
+ const envInGitignore = giLines2.includes('.env') || giLines2.includes('/.env');
9793
+ if (envExists && !envInGitignore) {
9794
+ // .env 자체 누락 → 최우선 위험
9795
+ log(yel(t(` 🚨 CRITICAL (1.9.80): .env가 .gitignore에 없습니다! 시크릿 노출 위험 — 즉시 \`leerness audit --fix\` 권장.`, ` 🚨 CRITICAL: .env is not in .gitignore! secret-exposure risk — run \`leerness audit --fix\` now.`)));
9796
+ // LEERNESS_AUTO_SECURITY_FIX=1 자동 실행 옵션
9797
+ if (process.env.LEERNESS_AUTO_SECURITY_FIX === '1') {
9798
+ try {
9799
+ const r = cp.spawnSync(process.execPath, [__filename, 'audit', root, '--fix'],
9800
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
9801
+ if (r.status === 0) {
9802
+ log(dim(t(` ✓ 자동 회복 (LEERNESS_AUTO_SECURITY_FIX=1): audit --fix 완료`, ` ✓ auto-recovered (LEERNESS_AUTO_SECURITY_FIX=1): audit --fix done`)));
9803
+ } else {
9804
+ log(dim(t(` ⚠ 자동 회복 실패 (exit ${r.status}) — 수동 \`leerness audit --fix\` 권장`, ` ⚠ auto-recovery failed (exit ${r.status}) — run \`leerness audit --fix\` manually`)));
9568
9805
  }
9569
- } else {
9570
- log(dim(` 💡 자동 실행 옵션: LEERNESS_AUTO_SECURITY_FIX=1 leerness handoff .`));
9806
+ } catch (e) {
9807
+ log(dim(t(` 자동 회복 예외: ${e.message}`, ` ⚠ auto-recovery error: ${e.message}`)));
9571
9808
  }
9809
+ } else {
9810
+ log(dim(t(` 💡 자동 실행 옵션: LEERNESS_AUTO_SECURITY_FIX=1 leerness handoff .`, ` 💡 auto-fix option: LEERNESS_AUTO_SECURITY_FIX=1 leerness handoff .`)));
9572
9811
  }
9573
- log('');
9574
9812
  }
9813
+ log('');
9575
9814
  }
9576
9815
  } catch {}
9577
9816
  }
@@ -9604,16 +9843,17 @@ function handoff(root) {
9604
9843
  const cy = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
9605
9844
  const b = s => isTty ? `\x1b[1m${s}\x1b[0m` : s;
9606
9845
  const d = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
9846
+ const t = (ko, en) => (_uiLang(root) === 'en' ? en : ko); // 1.30.5 (#156 F3): headline t() 스코프 밖 — 로컬 t() (1.29.1 교훈)
9607
9847
  log('');
9608
- log(cy('## 🛠 세션 워크플로 6단계 (1.9.39+, AI 하네스 엔지니어링)'));
9609
- log(d(' 상세: ') + cy('.harness/session-workflow.md'));
9610
- log(` 1. ${b('요청 분석')} handoff(이미 완료) · drift check · 모호하면 명확화`);
9611
- log(` 2. ${b('계획 수립')} plan add / TodoWrite · reuse-map으로 기존 자원 우선`);
9612
- log(` 3. ${b('업무 분배')} agents list/recommend · 작업유형별 sub-agent 매핑`);
9613
- log(` 4. ${b('sub-agent 작업')} 파일 경로 격리 · mtime 검증 의무 · 자체 테스트`);
9614
- log(` 5. ${b('종합 검증')} contract verify · verify-claim --run-tests · review --persona`);
9615
- log(` 6. ${b('세션 마감')} session close · audit --fix · usage stats`);
9616
- log(d(' 끄려면: --no-workflow-guide 또는 LEERNESS_NO_WORKFLOW_GUIDE=1'));
9848
+ log(cy(t('## 🛠 세션 워크플로 6단계 (1.9.39+, AI 하네스 엔지니어링)', '## 🛠 Session workflow — 6 steps (AI harness engineering)')));
9849
+ log(d(t(' 상세: ', ' details: ')) + cy('.harness/session-workflow.md'));
9850
+ log(` 1. ${b(t('요청 분석', 'Analyze request'))} ${t('handoff(이미 완료) · drift check · 모호하면 명확화', 'handoff (done) · drift check · clarify if ambiguous')}`);
9851
+ log(` 2. ${b(t('계획 수립', 'Plan'))} ${t('plan add / TodoWrite · reuse-map으로 기존 자원 우선', 'plan add / TodoWrite · prefer existing via reuse-map')}`);
9852
+ log(` 3. ${b(t('업무 분배', 'Distribute'))} ${t('agents list/recommend · 작업유형별 sub-agent 매핑', 'agents list/recommend · map sub-agents by task type')}`);
9853
+ log(` 4. ${b(t('sub-agent 작업', 'sub-agent work'))} ${t('파일 경로 격리 · mtime 검증 의무 · 자체 테스트', 'isolate file paths · verify mtime · self-test')}`);
9854
+ log(` 5. ${b(t('종합 검증', 'Verify'))} ${t('contract verify · verify-claim --run-tests · review --persona', 'contract verify · verify-claim --run-tests · review --persona')}`);
9855
+ log(` 6. ${b(t('세션 마감', 'Close'))} ${t('session close · audit --fix · usage stats', 'session close · audit --fix · usage stats')}`);
9856
+ log(d(t(' 끄려면: --no-workflow-guide 또는 LEERNESS_NO_WORKFLOW_GUIDE=1', ' to disable: --no-workflow-guide or LEERNESS_NO_WORKFLOW_GUIDE=1')));
9617
9857
  log('');
9618
9858
  }
9619
9859
  // 1.9.373 (UR-0073 Phase C): 에이전트 팀 스케줄 알림 — 비-manual 팀이 정의돼 있으면 미리보기(dry-run) 안내. 실행 없음. opt-out.
@@ -9621,10 +9861,11 @@ function handoff(root) {
9621
9861
  try {
9622
9862
  const _teamRem = _teamHandoffReminders(_loadTeams(root));
9623
9863
  if (_teamRem.length) {
9864
+ const t = (ko, en) => (_uiLang(root) === 'en' ? en : ko); // 1.30.5 (#156 F3): 로컬 t()
9624
9865
  log('');
9625
- log('## 🤝 에이전트 팀 스케줄 (UR-0073 Phase C) — 정의 전용 · 자동 실행 X');
9866
+ log(t('## 🤝 에이전트 팀 스케줄 (UR-0073 Phase C) — 정의 전용 · 자동 실행 X', '## 🤝 Agent team schedule (UR-0073) — definitions only · no auto-run'));
9626
9867
  _teamRem.forEach(r => log(' ' + r));
9627
- log(' ⓘ 미리보기는 dry-run. 실행은 제안 명령 검토 후 직접 · 끄려면 --no-team-reminders 또는 LEERNESS_NO_TEAM_REMINDERS=1');
9868
+ log(t(' ⓘ 미리보기는 dry-run. 실행은 제안 명령 검토 후 직접 · 끄려면 --no-team-reminders 또는 LEERNESS_NO_TEAM_REMINDERS=1', ' ⓘ preview is dry-run. run it yourself after reviewing the suggested command · disable: --no-team-reminders or LEERNESS_NO_TEAM_REMINDERS=1'));
9628
9869
  }
9629
9870
  } catch {}
9630
9871
  }
@@ -10128,7 +10369,7 @@ function _vcImplIsEmpty(body) {
10128
10369
  function verifyClaimCmd(root, taskId) {
10129
10370
  root = absRoot(root);
10130
10371
  const _j = has('--json'); // 1.9.400 (7번째 버그헌트 P1-B, UR-0105): --json 에러도 구조화
10131
- if (!taskId) return failJson(_j, 'missing_args', 'verify-claim <T-ID> 필요. 예: leerness verify-claim T-0008');
10372
+ if (!taskId) return failJson(_j, 'missing_args', _uiLang(root) === 'en' ? 'verify-claim <T-ID> required. ex: leerness verify-claim T-0008' : 'verify-claim <T-ID> 필요. 예: leerness verify-claim T-0008'); // 1.30.5 (#156 F4)
10132
10373
  const rows = readProgressRows(root);
10133
10374
  const row = rows.find(r => r.id === taskId);
10134
10375
  if (!row) return failJson(_j, 'not_found', `progress-tracker.md에 ${taskId} 없음.`);
@@ -11012,7 +11253,7 @@ function honestyCheckCmd(root, arg1) {
11012
11253
  function optimismCheckCmd(root, taskId) {
11013
11254
  root = absRoot(root || process.cwd());
11014
11255
  const _j = has('--json'); // 1.9.400 (7번째 버그헌트 P1-B, UR-0105): --json 에러도 구조화
11015
- if (!taskId) return failJson(_j, 'missing_args', 'optimism-check <T-ID> 필요. 예: leerness optimism-check T-0001');
11256
+ if (!taskId) return failJson(_j, 'missing_args', _uiLang(root) === 'en' ? 'optimism-check <T-ID> required. ex: leerness optimism-check T-0001' : 'optimism-check <T-ID> 필요. 예: leerness optimism-check T-0001'); // 1.30.5 (#156 F4 twin)
11016
11257
  const rows = readProgressRows(root);
11017
11258
  const row = rows.find(r => r.id === taskId);
11018
11259
  if (!row) return failJson(_j, 'not_found', `progress-tracker.md에 ${taskId} 없음.`);
@@ -20152,13 +20393,19 @@ async function main() {
20152
20393
  if (cmd === 'wakeup-interval') return wakeupIntervalCmd(arg('--path', process.cwd()), args[1], args[2]);
20153
20394
  // 1.9.211: leerness workspace-dir <get|guide> — 현재 워크스페이스 디렉토리 / AI 참조 가이드 (사용자 명시)
20154
20395
  if (cmd === 'workspace-dir') return workspaceDirCmd(arg('--path', process.cwd()), args[1]);
20396
+ if (cmd === 'parent') return parentCmd(arg('--path', process.cwd()), args[1]);
20155
20397
  // 1.9.211: leerness migrate-workspace-dir — .harness → .leerness 마이그레이션 (사용자 명시)
20156
20398
  if (cmd === 'migrate-workspace-dir') return migrateWorkspaceDirCmd(arg('--path', process.cwd()));
20157
20399
  // 1.9.212: leerness idempotency audit — 멱등성 위반 탐지 (사용자 명시)
20158
20400
  if (cmd === 'idempotency') return idempotencyCmd(arg('--path', process.cwd()), args[1]);
20159
20401
  // 1.9.213: leerness intent <classify|expand|domains> — intent inference + scope expansion (사용자 명시)
20160
20402
  if (cmd === 'intent') return intentCmd(arg('--path', process.cwd()), args[1], ...args.slice(2));
20161
- if (cmd === 'rule' && args[1] === 'add') return ruleAdd(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd(), _parseAddTitle(args, 2)); // 1.9.426: flag/경로 break(_parseAddTitle) · 1.9.445 (UR-0151): positional path 지원(제목과 분리)
20403
+ if (cmd === 'rule' && args[1] === 'add') { // 1.9.426: flag/경로 break(_parseAddTitle) · 1.9.445 (UR-0151): positional path 지원(제목과 분리)
20404
+ const _desc = _parseAddTitle(args, 2);
20405
+ // 1.30.4 (14th리뷰 F6): 빈 입력 시 --json 에서도 구조화 JSON(task/decision add 와 일관). 종전엔 ruleAdd 내부 fail() 가 평문 출력.
20406
+ if (!_desc) { failJson(has('--json'), 'empty_title', 'rule add "<설명>" 필요 (빈/경로-only 거부)'); return process.exit(process.exitCode || 1); }
20407
+ return ruleAdd(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd(), _desc);
20408
+ }
20162
20409
  if (cmd === 'rule' && args[1] === 'list') return ruleList(arg('--path', process.cwd()));
20163
20410
  if (cmd === 'rule' && args[1] === 'remove') return ruleRemove(arg('--path', process.cwd()), args[2]);
20164
20411
  if (cmd === 'rule' && args[1] === 'pause') return rulePause(arg('--path', process.cwd()), args[2]);
@@ -20166,6 +20413,8 @@ async function main() {
20166
20413
  if (cmd === 'rule' && args[1] === 'stop') return ruleStop(arg('--path', process.cwd()));
20167
20414
  if (cmd === 'rule' && args[1] === 'resume-all') return ruleResumeAll(arg('--path', process.cwd()));
20168
20415
  if (cmd === 'rule' && args[1] === 'verify') return ruleVerifyCmd(arg('--path', process.cwd()));
20416
+ // 1.30.4 (14th리뷰 F7): rule 하위명령 미매칭 시 잘못된 토큰 명시 + usage(종전엔 top-level 'unknown_command: rule' 로 유효 부모명을 오인 표기).
20417
+ if (cmd === 'rule') { failJson(has('--json'), 'unknown_subcommand', `알 수 없는 rule 하위명령: ${args[1] || '(없음)'} — leerness rule add|list|remove|pause|resume|stop|resume-all|verify`); return process.exit(process.exitCode || 1); }
20169
20418
  if (cmd === 'release' && args[1] === 'bump') return releaseBump(args[2] || arg('--path', process.cwd()));
20170
20419
  if (cmd === 'release' && args[1] === 'note') return releaseNote(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
20171
20420
  if (cmd === 'release' && args[1] === 'publish') return releasePublish(args[2] || arg('--path', process.cwd()));
@@ -20220,6 +20469,8 @@ async function main() {
20220
20469
  if (sub==='relink') return taskRelink(root);
20221
20470
  if (sub==='sync') return taskSyncCmd(root);
20222
20471
  if (sub==='export') return taskExportCmd(root);
20472
+ // 1.30.4 (14th리뷰 F7): 미매칭 하위명령 시 잘못된 토큰을 명시 + usage(종전엔 top-level 'unknown_command: task' 로 유효 부모명을 오인 표기).
20473
+ failJson(has('--json'), 'unknown_subcommand', `알 수 없는 task 하위명령: ${sub} — leerness task list|add|update|drop|fix-evidence|relink|sync|export`); return process.exit(process.exitCode || 1);
20223
20474
  }
20224
20475
  // 1.9.114: memory status — Memory Write Surface 5종 통합 상태
20225
20476
  if (cmd === 'memory' && args[1] === 'status') {
@@ -20247,7 +20498,10 @@ async function main() {
20247
20498
  if (args[i].startsWith('--') || /^([A-Za-z]:[\\/]|\/|\.\.?[\\/])/.test(args[i])) break;
20248
20499
  textParts.push(args[i]);
20249
20500
  }
20250
- return lessonSave(root, textParts.join(' '));
20501
+ const _text = textParts.join(' ');
20502
+ // 1.30.4 (14th리뷰 F6): 빈 입력 시 --json 에서도 구조화 JSON(task/decision add 와 일관). 종전엔 lessonSave 내부 fail() 가 평문 출력.
20503
+ if (!_text) { failJson(has('--json'), 'empty_text', 'lesson save "<text>" 필요 (빈/경로-only 거부)'); return process.exit(process.exitCode || 1); }
20504
+ return lessonSave(root, _text);
20251
20505
  }
20252
20506
  if (sub === 'list') {
20253
20507
  return lessonListCmd(root, { json: has('--json') });