leerness 1.30.0 → 1.32.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.30.0';
35
+ const VERSION = '1.32.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') 시 호스트 프로세스 오염.
@@ -2863,7 +2863,7 @@ function _selfTestCases() {
2863
2863
  { name: 'get_project_context: MCP 시맨틱 verb 등록 + CLI context 디스패치 (UR-0031 1.9.292)', run: () => { const src = read(__filename); const mcpDef = require('../lib/mcp-tools').some(t => t.name === 'leerness_get_project_context'); const mcpCase = /case 'leerness_get_project_context':[\s\S]*?cliArgs = \['context'/.test(src); const cliDisp = /if \(cmd === 'context'\)\s+return contextCmd/.test(src); return typeof contextCmd === 'function' && mcpDef && mcpCase && cliDisp && _mcpToolCount() >= 80; } },
2864
2864
  { name: '_canonicalProgressHeader + idempotency auto-fix (근본 복제버그 fix 1.9.293)', run: () => { const h = _canonicalProgressHeader(); const headerOk = /leernessRole: progress-tracker/.test(h) && /\| ID \| Status \| Request \|/.test(h) && /\|---\|/.test(h); const src = read(__filename); const fnOk = typeof _autoFixIdempotency === 'function'; const noWholeTextFallback = /if \(idx < 0\) return _canonicalProgressHeader\(\);/.test(src); const driftWired = /_autoFixIdempotency\(root\)/.test(src) && /idempotency 중복/.test(src); return headerOk && fnOk && noWholeTextFallback && driftWired; } },
2865
2865
  { name: 'lib/role-catalog: ROLE/PROVIDER/ALIASES/PROMPTS 모듈 단일출처 분리 (UR-0025 1.9.294)', run: () => { const m = require('../lib/role-catalog'); return m.ROLE_CATALOG === ROLE_CATALOG && m._PROVIDER_MODEL_CATALOG === _PROVIDER_MODEL_CATALOG && m._ROLE_ALIASES === _ROLE_ALIASES && m._AGENT_ROLE_PROMPTS === _AGENT_ROLE_PROMPTS && Object.keys(m.ROLE_CATALOG).length === 7 && Object.keys(m._PROVIDER_MODEL_CATALOG).length === 10 && !/const ROLE_CATALOG = \{/.test(read(__filename)); } },
2866
- { name: 'lib/catalogs: CAPABILITY/ADAPTERS/REUSE 모듈 단일출처 분리 (UR-0025 1.9.295)', run: () => { const m = require('../lib/catalogs'); return m.CAPABILITY_SURFACE === CAPABILITY_SURFACE && m.ADAPTERS === ADAPTERS && m.REUSE_CATEGORIES === REUSE_CATEGORIES && m.REUSE_CHECKLIST === REUSE_CHECKLIST && m.POWERFUL_COMMANDS === POWERFUL_COMMANDS && Object.keys(m.CAPABILITY_SURFACE).length === 6 && !/const CAPABILITY_SURFACE = \{/.test(read(__filename)); } },
2866
+ { name: 'lib/catalogs: CAPABILITY/ADAPTERS/REUSE 모듈 단일출처 분리 (UR-0025 1.9.295) + i18n en(1.31.3)', run: () => { const m = require('../lib/catalogs'); const _H = /[가-힣]/; const cs = Object.values(m.CAPABILITY_SURFACE); const i18nOk = cs.length === 6 && cs.every(v => typeof v.descEn === 'string' && v.descEn.length > 0 && !_H.test(v.descEn) && typeof v.optOutEn === 'string' && !_H.test(v.optOutEn)) && m.POWERFUL_COMMANDS.every(c => typeof c.noteEn === 'string' && c.noteEn.length > 0 && !_H.test(c.noteEn)); return m.CAPABILITY_SURFACE === CAPABILITY_SURFACE && m.ADAPTERS === ADAPTERS && m.REUSE_CATEGORIES === REUSE_CATEGORIES && m.REUSE_CHECKLIST === REUSE_CHECKLIST && m.POWERFUL_COMMANDS === POWERFUL_COMMANDS && Object.keys(m.CAPABILITY_SURFACE).length === 6 && i18nOk && !/const CAPABILITY_SURFACE = \{/.test(read(__filename)); } },
2867
2867
  { name: 'about: 정체성 verb(AI 운영 레이어) + MCP leerness_about 등록 (UR-0030 1.9.296)', run: () => { const id = _leernessIdentity(); const src = read(__filename); return typeof aboutCmd === 'function' && /운영 레이어/.test(id.identity) && id.layers.length === 5 && id.surface.mcpTools >= 81 && require('../lib/mcp-tools').some(t => t.name === 'leerness_about') && /case 'leerness_about':/.test(src) && /cmd === 'about' \|\| cmd === 'identity'/.test(src); } },
2868
2868
  { name: 'lib/mcp-tools: MCP 도구 정의 모듈 단일출처 (_mcpToolCount=모듈 length, Codex #5 영구해소) (UR-0025 1.9.297)', run: () => { const T = require('../lib/mcp-tools'); return Array.isArray(T) && T.length >= 81 && T.every(t => t.name && t.description && t.inputSchema) && T[0].name === 'leerness_handoff' && _mcpToolCount() === T.length && !/const TOOLS = \[/.test(read(__filename)); } },
2869
2869
  { name: 'writeUtf8: 원자적 쓰기(temp→rename) 손상방지 행위 (UR-0038 외부리뷰 / CV-5 행위화 1.9.366)', run: () => { if (typeof writeUtf8 !== 'function') return false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_wu_')); try { const f = path.join(tmp, 'sub', 'a.txt'); writeUtf8(f, '한글 UTF-8 내용'); const okContent = read(f) === '한글 UTF-8 내용'; const noTmpLeft = fs.readdirSync(path.dirname(f)).every(n => !n.includes('.tmp-')); return okContent && noTmpLeft; } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } } },
@@ -2901,7 +2901,7 @@ function _selfTestCases() {
2901
2901
  { name: 'lib/pure-utils: project-brief config 분리(_BRIEF_FIELDS/_briefFilled) + 인라인 제거 (UR-0025 1.9.330)', run: () => { const m = require('../lib/pure-utils'); const cfgOk = Array.isArray(m._BRIEF_FIELDS) && m._BRIEF_FIELDS.length === 10 && m._BRIEF_FIELDS[0].key === 'intro'; const work = m._briefFilled({ intro: 'x', features: ['a'] }) === 2 && m._briefFilled({}) === 0; const src = read(__filename); const moved = m._briefFilled === _briefFilled && m._BRIEF_FIELDS === _BRIEF_FIELDS && !/^const _BRIEF_FIELDS = \[/m.test(src) && !/^function _briefFilled\(/m.test(src); return cfgOk && work && moved; } },
2902
2902
  { name: 'lib/pure-utils: brief 빌더 분리(_briefReadmeBlock/_briefBlueprint + BRIEF 마커, VERSION 주입) (UR-0025 1.9.331)', run: () => { const m = require('../lib/pure-utils'); const fnOk = typeof m._briefReadmeBlock === 'function' && typeof m._briefBlueprint === 'function' && m.BRIEF_START.includes('project-brief:start'); const b = { project: 'X', intro: 'i', features: ['f1'] }; const rb = m._briefReadmeBlock(b); const bp = m._briefBlueprint(b, '9.9.9'); const work = rb.includes(m.BRIEF_START) && rb.includes(m.BRIEF_END) && /f1/.test(rb) && /Blueprint/.test(bp) && /leerness v9\.9\.9/.test(bp); const src = read(__filename); const moved = m._briefBlueprint === _briefBlueprint && m.BRIEF_START === BRIEF_START && !/^function _briefReadmeBlock\(/m.test(src) && !/^function _briefBlueprint\(/m.test(src) && !/^const BRIEF_START =/m.test(src); return fnOk && work && moved; } },
2903
2903
  { name: 'lib/pure-utils: lessons.md 파서 분리(_parseLessonEntries) + 인라인 제거 (UR-0025 1.9.332)', run: () => { const m = require('../lib/pure-utils'); const r = m._parseLessonEntries('### 2026-06-05\n- Lesson: A\n- Tag: t\n\n### 2026-06-04\n- Lesson: B'); const work = r.length === 2 && r[0].text === 'A' && r[0].tag === 't' && r[1].tag === null && r[0].date === '2026-06-05'; const src = read(__filename); const moved = m._parseLessonEntries === _parseLessonEntries && !/^function _parseLessonEntries\(/m.test(src) && src.includes('_parseLessonEntries(read(mp))'); return work && moved; } },
2904
- { name: 'UR-0025 심층: constraints catalog→lib/catalogs + _matchConstraints→pure-utils 분리 (1.9.333)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = c._DEFAULT_PLATFORM_CONSTRAINTS && Object.keys(c._DEFAULT_PLATFORM_CONSTRAINTS.platforms).length === 6 && !!c._DEFAULT_PLATFORM_CONSTRAINTS.platforms.stripe; const r = m._matchConstraints(c._DEFAULT_PLATFORM_CONSTRAINTS, 'stripe 결제'); const work = r.matched.length === 1 && r.matched[0].platform === 'stripe' && r.totalPlatforms === 6 && m._matchConstraints(null, 'x').matched.length === 0; const src = read(__filename); const moved = _DEFAULT_PLATFORM_CONSTRAINTS === c._DEFAULT_PLATFORM_CONSTRAINTS && _matchConstraints === m._matchConstraints && !/const _DEFAULT_PLATFORM_CONSTRAINTS = \{/.test(src) && src.includes('_matchConstraints(_loadPlatformConstraints(root), text)'); return catOk && work && moved; } },
2904
+ { name: 'UR-0025 심층: constraints catalog→lib/catalogs + _matchConstraints→pure-utils 분리 (1.9.333) + i18n en(1.31.2)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = c._DEFAULT_PLATFORM_CONSTRAINTS && Object.keys(c._DEFAULT_PLATFORM_CONSTRAINTS.platforms).length === 6 && !!c._DEFAULT_PLATFORM_CONSTRAINTS.platforms.stripe; const r = m._matchConstraints(c._DEFAULT_PLATFORM_CONSTRAINTS, 'stripe 결제'); const work = r.matched.length === 1 && r.matched[0].platform === 'stripe' && r.totalPlatforms === 6 && m._matchConstraints(null, 'x').matched.length === 0; const _H = /[가-힣]/; const enSug = (m._matchConstraints(c._DEFAULT_PLATFORM_CONSTRAINTS, 'generic api integration widget', 'en').suggestions || [])[0] || ''; const koSug = (m._matchConstraints(c._DEFAULT_PLATFORM_CONSTRAINTS, 'generic api integration widget', 'ko').suggestions || [])[0] || ''; const i18nOk = c._DEFAULT_PLATFORM_CONSTRAINTS.platforms.stripe.constraints.some(x => x.detailEn && !_H.test(x.detailEn)) && enSug.length > 0 && !_H.test(enSug) && _H.test(koSug); const src = read(__filename); const moved = _DEFAULT_PLATFORM_CONSTRAINTS === c._DEFAULT_PLATFORM_CONSTRAINTS && _matchConstraints === m._matchConstraints && !/const _DEFAULT_PLATFORM_CONSTRAINTS = \{/.test(src) && src.includes('_matchConstraints(_loadPlatformConstraints(root), text)'); return catOk && work && i18nOk && moved; } },
2905
2905
  { name: 'UR-0025 심층(Codex 위임·검증): intent domain catalog→lib/catalogs + _matchDomain→pure-utils 분리 (1.9.334)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = c._DEFAULT_DOMAIN_CATALOG && Object.keys(c._DEFAULT_DOMAIN_CATALOG.domains).length === 5 && !!c._DEFAULT_DOMAIN_CATALOG.domains.game; const r = m._matchDomain(c._DEFAULT_DOMAIN_CATALOG, 'unity 게임'); const work = r.domain === 'game' && Array.isArray(r.components) && m._matchDomain(c._DEFAULT_DOMAIN_CATALOG, 'zzz없음').domain === null && m._matchDomain(null, 'x').domain === null; const src = read(__filename); const moved = _DEFAULT_DOMAIN_CATALOG === c._DEFAULT_DOMAIN_CATALOG && _matchDomain === m._matchDomain && !/const _DEFAULT_DOMAIN_CATALOG = \{/.test(src) && src.includes('_matchDomain(_loadDomainCatalog(root), text)'); return catOk && work && moved; } },
2906
2906
  { name: 'UR-0025 심층: LSP catalog→lib/catalogs(_LSP_LANG_PATTERNS) + _detectLspLang/_matchLspSymbols→pure-utils 분리 (1.9.335)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = c._LSP_LANG_PATTERNS && Object.keys(c._LSP_LANG_PATTERNS).length === 5 && Array.isArray(c._LSP_LANG_PATTERNS.javascript); const langOk = m._detectLspLang('a.py') === 'python' && m._detectLspLang('b.go') === 'go' && m._detectLspLang('c.md') === 'javascript'; const sy = m._matchLspSymbols(c._LSP_LANG_PATTERNS, 'function alpha(){}\nclass Beta{}', 'javascript'); const work = sy.length === 2 && sy[0].name === 'alpha' && sy[0].kind === 'function' && sy[1].kind === 'class' && m._matchLspSymbols(null, 'x', 'javascript').length === 0; const src = read(__filename); const moved = _LSP_LANG_PATTERNS === c._LSP_LANG_PATTERNS && _detectLspLang === m._detectLspLang && _matchLspSymbols === m._matchLspSymbols && !/const _LSP_LANG_PATTERNS = \{/.test(src) && !/function _detectLspLang\(/.test(src); return catOk && langOk && work && moved; } },
2907
2907
  { name: 'UR-0025 심층(Codex 위임·검증): anti-laziness catalog→lib/catalogs(OPTIMISM_PATTERNS) + optimism 순수로직→pure-utils 분리 (1.9.336)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = Array.isArray(c.OPTIMISM_PATTERNS) && c.OPTIMISM_PATTERNS.length === 10 && c.OPTIMISM_PATTERNS[0].kind === 'API'; const ev = 'API 호출 완료, POST /users'; const sus = m._detectOptimism(c.OPTIMISM_PATTERNS, ev, 'function x(){}'); const conf = m._computeConfidence(c.OPTIMISM_PATTERNS, ev, 'function x(){}'); const work = sus.some(s => s.kind === 'API' && s.severity === 'high') && conf < 0.5 && m._computeConfidence(c.OPTIMISM_PATTERNS, '정리함', 'x') === 1 && m._detectOptimism(null, ev, 'x').length === 0 && m._extractUrlClaims('POST /a').length === 1 && m._verifyUrlClaim({ path: '/a' }, 'has /a') === true; const src = read(__filename); const moved = OPTIMISM_PATTERNS === c.OPTIMISM_PATTERNS && _puDetectOptimism === m._detectOptimism && !/const OPTIMISM_PATTERNS = \[/.test(src) && !/function _extractUrlClaims\(/.test(src); return catOk && work && moved; } },
@@ -2939,7 +2939,7 @@ function _selfTestCases() {
2939
2939
  { name: 'UR-0025: _parseArchiveBlocks/_parseSkillCatalog 순수 파서 모듈 분리 + 행위 (1.9.370)', run: () => { const m = require('../lib/pure-utils'); if (typeof _parseArchiveBlocks !== 'function' || typeof _parseSkillCatalog !== 'function') return false; const moved = m._parseArchiveBlocks === _parseArchiveBlocks && m._parseSkillCatalog === _parseSkillCatalog; const ab = _parseArchiveBlocks('## 제거 2026-01-01 (target: ' + '"T-1")\n### 헤더\n'); const abOk = ab.length === 1 && ab[0].date === '2026-01-01' && ab[0].target === 'T-1' && ab[0].originalHeader === '헤더'; const md = _parseSkillCatalog('- [nm](https://x/SKILL.md) — d', ''); const mdOk = md.length === 1 && md[0].name === 'nm' && md[0].format === 'markdown'; const js = _parseSkillCatalog('{' + '"skills":[{"id":"a","url":"u"}]}', ''); const jsOk = js.length === 1 && js[0].name === 'a' && js[0].format === 'json'; return moved && abOk && mdOk && jsOk; } },
2940
2940
  { name: 'UR-0073 Phase A: team 정의 레지스트리 (_renderTeamsMd + canonical load/save) 행위 (1.9.371)', run: () => { const m = require('../lib/pure-utils'); if (typeof teamCmd !== 'function' || typeof _renderTeamsMd !== 'function' || m._renderTeamsMd !== _renderTeamsMd) return false; const md = _renderTeamsMd([{ id: 't1', name: 'N', personas: ['security'], members: ['claude'], schedule: 'daily', status: 'active' }]); const mdOk = md.includes('## t1') && md.includes('security') && md.includes('daily') && md.includes('정의 전용'); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_team_')); let rtOk = false; try { _saveTeams(tmp, [{ id: 'x', name: 'X', personas: [], members: [], schedule: 'manual', status: 'active' }]); const loaded = _loadTeams(tmp); rtOk = loaded.length === 1 && loaded[0].id === 'x' && fs.existsSync(path.join(tmp, '.harness', 'teams.json')) && fs.existsSync(path.join(tmp, '.harness', 'teams.md')); } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } return mdOk && rtOk; } },
2941
2941
  { name: 'UR-0073 Phase B: _composeTeamPlan dry-run 실행 계획 (멤버별 dispatch, 실행 없음) 행위 (1.9.372)', run: () => { const m = require('../lib/pure-utils'); if (typeof _composeTeamPlan !== 'function' || m._composeTeamPlan !== _composeTeamPlan) return false; const team = { id: 'rev', name: 'R', purpose: 'PR 리뷰', personas: ['security', 'perf'], members: ['claude', 'codex'], schedule: 'manual' }; const p1 = _composeTeamPlan(team, '점검'); const ok1 = p1.steps.length === 2 && p1.task === '점검' && p1.steps[0].member === 'claude' && p1.steps[0].suggestedCommand.includes('agents dispatch') && p1.steps[0].suggestedCommand.includes('--to claude') && p1.steps[0].dispatchPrompt.includes('security'); const p2 = _composeTeamPlan(team, null); const ok2 = p2.task === 'PR 리뷰'; const p3 = _composeTeamPlan({ id: 'e', personas: [], members: [] }, 'x'); const ok3 = p3.steps.length === 0 && p3.memberCount === 0; return ok1 && ok2 && ok3; } },
2942
- { name: 'UR-0073 Phase C: _teamHandoffReminders 스케줄 알림 (비-manual·active 만, 실행 트리거 아님) 행위 (1.9.373)', run: () => { const m = require('../lib/pure-utils'); if (typeof _teamHandoffReminders !== 'function' || m._teamHandoffReminders !== _teamHandoffReminders) return false; const r = _teamHandoffReminders([{ id: 'rev', schedule: 'every-session', status: 'active', members: ['a', 'b'] }, { id: 'man', schedule: 'manual', status: 'active' }, { id: 'paused', schedule: 'daily', status: 'paused' }]); return r.length === 1 && r[0].includes('rev') && r[0].includes('every-session') && r[0].includes('team preview rev') && !r.join('|').includes('man') && !r.join('|').includes('paused'); } },
2942
+ { name: 'UR-0073 Phase C: _teamHandoffReminders 스케줄 알림 (비-manual·active 만, 실행 트리거 아님) 행위 (1.9.373) + i18n en(1.31.3)', run: () => { const m = require('../lib/pure-utils'); if (typeof _teamHandoffReminders !== 'function' || m._teamHandoffReminders !== _teamHandoffReminders) return false; const r = _teamHandoffReminders([{ id: 'rev', schedule: 'every-session', status: 'active', members: ['a', 'b'] }, { id: 'man', schedule: 'manual', status: 'active' }, { id: 'paused', schedule: 'daily', status: 'paused' }]); const behaviorOk = r.length === 1 && r[0].includes('rev') && r[0].includes('every-session') && r[0].includes('team preview rev') && !r.join('|').includes('man') && !r.join('|').includes('paused'); const _H = /[가-힣]/; const en = _teamHandoffReminders([{ id: 'rev', schedule: 'every-session', status: 'active', members: ['a', 'b'], review: true }], 'en')[0] || ''; const ko = _teamHandoffReminders([{ id: 'rev', schedule: 'every-session', status: 'active', members: ['a', 'b'], review: true }])[0] || ''; const i18nOk = !_H.test(en) && /2 members/.test(en) && /review needed/.test(en) && /preview:/.test(en) && _H.test(ko) && /2명/.test(ko) && /검수필요/.test(ko); return behaviorOk && i18nOk; } },
2943
2943
  { name: 'UR-0074: _cadenceAssessment 릴리스 빈도 평가 (임계값) 행위 (1.9.374)', run: () => { const m = require('../lib/pure-utils'); if (typeof _cadenceAssessment !== 'function' || m._cadenceAssessment !== _cadenceAssessment || typeof releaseCadenceCmd !== 'function') return false; return _cadenceAssessment(7, 1, 1).level === 'very-high' && _cadenceAssessment(3, 1, 1).level === 'high' && _cadenceAssessment(1, 1, 1).level === 'moderate' && _cadenceAssessment(0.2, 1, 1).level === 'healthy' && _cadenceAssessment(7, 1, 1).recommendation.length > 0; } },
2944
2944
  { name: 'UR-0084: _withLock 획득/재진입/해제 + maxWaitMs 하드닝(10s) 행위 (1.9.375)', run: () => { if (typeof _withLock !== 'function') return false; const src = read(__filename); const hardened = /maxWaitMs = opts\.maxWaitMs \|\| 10000/.test(src); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_lock_')); try { const target = path.join(tmp, 'f.md'); let reentrant = false; const lockSeen = _withLock(target, () => { const exists = fs.existsSync(target + '.lock'); reentrant = (_withLock(target, () => 42) === 42); return exists; }); const cleaned = !fs.existsSync(target + '.lock'); return hardened && lockSeen === true && reentrant === true && cleaned; } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } } },
2945
2945
  { name: 'UR-0073 Phase D: _teamDeployGate 이중 게이트 (dry-run 기본/env 게이트/실행) 행위 (1.9.376)', run: () => { const m = require('../lib/pure-utils'); if (typeof _teamDeployGate !== 'function' || m._teamDeployGate !== _teamDeployGate) return false; const team = { id: 'd', deployCommand: 'echo hi' }; const noCmd = _teamDeployGate({ id: 'x' }, { yes: true, envOn: true }).mode === 'no-command'; const dry = _teamDeployGate(team, { yes: false, envOn: true }).mode === 'dry-run'; const gated = _teamDeployGate(team, { yes: true, envOn: false }).mode === 'gated'; const exec = _teamDeployGate(team, { yes: true, envOn: true }).mode === 'execute'; return noCmd && dry && gated && exec; } },
@@ -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;
@@ -3891,7 +3891,33 @@ function _selfTestCases() {
3891
3891
  && bin.includes('전체/기록/최신' + '화: leerness slash-commands');
3892
3892
  return en && koPreserved;
3893
3893
  } },
3894
- { name: 'VERSION 형식 (x.y.z)', run: () => /^\d+\.\d+\.\d+$/.test(VERSION) }
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
+ } }
3895
3921
  ];
3896
3922
  }
3897
3923
  function selfTestCmd(opts = {}) {
@@ -4671,6 +4697,7 @@ function commandsCmd(root) {
4671
4697
  { cmd: 'update [--check|--yes|--force]', desc: '자가 업데이트' },
4672
4698
  { cmd: 'wakeup-interval get|set|auto|history|record', desc: 'adaptive wakeup (1.9.210)' },
4673
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)' },
4674
4701
  { cmd: 'intent classify|expand|domains "<request>"', desc: '의도 파악 + scope (1.9.213)' },
4675
4702
  { cmd: 'constraints list|check|add', desc: '플랫폼/API 제약 (1.9.208)' },
4676
4703
  { cmd: 'provider list|add|remove|sync', desc: 'Provider Registry (1.9.157~160)' },
@@ -4786,8 +4813,8 @@ function _writePlatformConstraints(root, catalog) {
4786
4813
  }
4787
4814
  // 사용자 요청 텍스트에서 플랫폼 alias 매칭 → 적용 제약 목록 반환
4788
4815
  // 1.9.333 (UR-0025 심층): 매칭 로직은 순수 _matchConstraints(catalog, text) (lib/pure-utils) — fs(load)는 여기서 주입.
4789
- function _checkRequestConstraints(root, text) {
4790
- return _matchConstraints(_loadPlatformConstraints(root), text);
4816
+ function _checkRequestConstraints(root, text, lang) {
4817
+ return _matchConstraints(_loadPlatformConstraints(root), text, lang);
4791
4818
  }
4792
4819
 
4793
4820
  // 1.9.209: pre-wake sub-agent audit (사용자 명시)
@@ -5202,6 +5229,36 @@ function _workspaceDirName(root) {
5202
5229
  function _workspaceDirAbs(root) {
5203
5230
  return path.join(root, _workspaceDirName(root));
5204
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
+ }
5205
5262
  // .harness → .leerness 마이그레이션 (copy + reference guide 생성)
5206
5263
  function _migrateWorkspaceDir(root, opts = {}) {
5207
5264
  const dryRun = opts.dryRun === true;
@@ -5723,9 +5780,9 @@ function constraintsCmd(root, sub, ...rest) {
5723
5780
  const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
5724
5781
  const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
5725
5782
  const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
5783
+ const _L = _uiLang(root); const _t = (ko, en) => _L === 'en' ? en : ko; // 1.31.2 (UR-0010): function-level so list/check/add share it
5726
5784
 
5727
5785
  if (!sub || sub === 'help' || sub === '--help') {
5728
- const _L = _uiLang(root); const _t = (ko, en) => _L === 'en' ? en : ko; // 1.23.2 (UR-0010 Phase 7)
5729
5786
  log(_t(`# leerness constraints (1.9.208) — 플랫폼/API 제약 사전 체크`, `# leerness constraints — pre-check platform/API constraints`));
5730
5787
  log('');
5731
5788
  log(_t(` list → 등록된 모든 플랫폼 catalog 출력 (--json 가능)`, ` list → list all registered platform catalogs (--json)`));
@@ -5743,11 +5800,13 @@ function constraintsCmd(root, sub, ...rest) {
5743
5800
  log(` total platforms: ${Object.keys(catalog.platforms).length}`);
5744
5801
  log('');
5745
5802
  for (const [pid, plat] of Object.entries(catalog.platforms)) {
5746
- log(grn(` 📦 ${pid}`) + dim(` aliases: ${(plat.aliases || []).join(', ')}`));
5803
+ // 1.31.2: aliases are matchers (kept for matching); hide Hangul-only aliases from EN display
5804
+ const aliasList = _L === 'en' ? (plat.aliases || []).filter(a => !/[가-힣]/.test(a)) : (plat.aliases || []);
5805
+ log(grn(` 📦 ${pid}`) + dim(` aliases: ${aliasList.join(', ')}`));
5747
5806
  log(dim(` docs: ${plat.docs || '-'}`));
5748
5807
  for (const c of plat.constraints || []) {
5749
5808
  const icon = c.kind === 'rate-limit' ? '🚦' : (c.kind === 'cost' ? '💰' : (c.kind === 'auth' ? '🔐' : '📋'));
5750
- log(` ${icon} [${c.kind}] ${c.detail}`);
5809
+ log(` ${icon} [${c.kind}] ${_L === 'en' && c.detailEn ? c.detailEn : c.detail}`);
5751
5810
  }
5752
5811
  log('');
5753
5812
  }
@@ -5757,32 +5816,32 @@ function constraintsCmd(root, sub, ...rest) {
5757
5816
  if (sub === 'check') {
5758
5817
  const text = rest.filter(x => !x.startsWith('-')).join(' ');
5759
5818
  if (!text) { console.error('Usage: leerness constraints check "<request text>"'); process.exit(1); }
5760
- const result = _checkRequestConstraints(root, text);
5819
+ const result = _checkRequestConstraints(root, text, _L);
5761
5820
  if (has('--json')) { log(JSON.stringify({ query: text, ...result }, null, 2)); return; }
5762
5821
  log(cyan(`# leerness constraints check (1.9.208)`));
5763
5822
  log(` query: ${text.slice(0, 80)}${text.length > 80 ? '…' : ''}`);
5764
5823
  log('');
5765
5824
  if (result.matched.length === 0) {
5766
- log(grn(` ✓ 매칭된 플랫폼 없음 (catalog ${result.totalPlatforms}종 검토 완료)`));
5825
+ log(grn(_t(` ✓ 매칭된 플랫폼 없음 (catalog ${result.totalPlatforms}종 검토 완료)`, ` ✓ no platform matched (reviewed ${result.totalPlatforms} catalog entries)`)));
5767
5826
  if (result.suggestions.length) {
5768
5827
  log('');
5769
- log(yel(` 💡 제안:`));
5828
+ log(yel(_t(` 💡 제안:`, ` 💡 suggestions:`)));
5770
5829
  result.suggestions.forEach(s => log(` • ${s}`));
5771
5830
  }
5772
5831
  return;
5773
5832
  }
5774
- log(red(` ⚠ ${result.matched.length}개 플랫폼 매칭 — 제약 사전 확인 필요:`));
5833
+ log(red(_t(` ⚠ ${result.matched.length}개 플랫폼 매칭 — 제약 사전 확인 필요:`, ` ⚠ ${result.matched.length} platform(s) matched — review constraints before building:`)));
5775
5834
  log('');
5776
5835
  for (const m of result.matched) {
5777
5836
  log(grn(` 📦 ${m.platform}`) + dim(` (matched: "${m.matchedAlias}")`));
5778
5837
  log(dim(` docs: ${m.docs || '-'}`));
5779
5838
  for (const c of m.constraints || []) {
5780
5839
  const icon = c.kind === 'rate-limit' ? '🚦' : (c.kind === 'cost' ? '💰' : (c.kind === 'auth' ? '🔐' : '📋'));
5781
- log(` ${icon} [${c.kind}] ${c.detail}`);
5840
+ log(` ${icon} [${c.kind}] ${_L === 'en' && c.detailEn ? c.detailEn : c.detail}`);
5782
5841
  }
5783
5842
  log('');
5784
5843
  }
5785
- log(dim(` → 구현 전 위 제약을 반영한 설계 권장 (rate limiter / idempotency key / 비용 추정)`));
5844
+ log(dim(_t(` → 구현 전 위 제약을 반영한 설계 권장 (rate limiter / idempotency key / 비용 추정)`, ` → recommend designing with the above constraints before building (rate limiter / idempotency key / cost estimate)`)));
5786
5845
  return;
5787
5846
  }
5788
5847
 
@@ -5800,7 +5859,7 @@ function constraintsCmd(root, sub, ...rest) {
5800
5859
  if (kind && detail) catalog.platforms[id].constraints.push({ kind: kind.trim(), detail });
5801
5860
  _writePlatformConstraints(root, catalog);
5802
5861
  if (has('--json')) { log(JSON.stringify(catalog.platforms[id], null, 2)); return; }
5803
- log(grn(`✓ platform "${id}" 갱신 — constraints: ${catalog.platforms[id].constraints.length}`));
5862
+ log(grn(_t(`✓ platform "${id}" 갱신 — constraints: ${catalog.platforms[id].constraints.length}`, `✓ platform "${id}" updated — constraints: ${catalog.platforms[id].constraints.length}`)));
5804
5863
  return;
5805
5864
  }
5806
5865
 
@@ -6041,6 +6100,103 @@ function workspaceDirCmd(root, sub) {
6041
6100
  process.exit(1);
6042
6101
  }
6043
6102
 
6103
+ // 1.30.2 (#157 사용자명시): leerness parent — 상위 leerness 부모 프로젝트 탐지(read-only).
6104
+ // 방향 C(교차검토): 부모 자산을 '재사용 후보'로 AI 에게 노출만 하고, 톤/스타일 등 실제 적용은 사용자 결정 게이트(후속 adopt 명령).
6105
+ // intent expand(1.9.213) 안전 모델과 동일 철학 — 탐지/노출 ≠ 적용. 이 명령은 아무 파일도 쓰지 않는다.
6106
+ function parentCmd(root, sub) {
6107
+ root = absRoot(root);
6108
+ const uiLang = _uiLang(root);
6109
+ const t = (ko, en) => (uiLang === 'en' ? en : ko);
6110
+ const isTty = process.stdout && process.stdout.isTTY;
6111
+ const cyan = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
6112
+ const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
6113
+ const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
6114
+ const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
6115
+ const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
6116
+ if (!sub || sub === 'detect') {
6117
+ const p = _findParentWorkspace(root);
6118
+ if (has('--json')) { log(JSON.stringify({ version: VERSION, root, parent: p, applied: false }, null, 2)); return; }
6119
+ log(cyan(`# leerness parent detect (1.30.2)`));
6120
+ if (!p) {
6121
+ log(dim(t(` 상위 leerness 부모 프로젝트 없음 — 독립 프로젝트입니다.`, ` no parent leerness project found — this is a standalone project.`)));
6122
+ return;
6123
+ }
6124
+ const mark = b => b ? '✓' : '✗';
6125
+ log(t(` 부모 프로젝트: ${grn(p.parentRoot)} (${p.workspaceDir}/, depth ${p.depth})`, ` parent project: ${grn(p.parentRoot)} (${p.workspaceDir}/, depth ${p.depth})`));
6126
+ 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)}`,
6127
+ ` 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)}`));
6128
+ log('');
6129
+ log(yel(t(` ⚠ 자동 적용하지 않음 — 부모 자산(톤/스타일/디자인) 재사용은 사용자 결정 게이트입니다.`, ` ⚠ not auto-applied — reusing parent assets (tone/style/design) is a user decision.`)));
6130
+ log(dim(t(` 참고: leerness reuse find "<capability>" --path ${p.parentRoot} · 부모 design-system: ${path.join(p.workspaceAbs, 'design-system.md')}`,
6131
+ ` ref: leerness reuse find "<capability>" --path ${p.parentRoot} · parent design-system: ${path.join(p.workspaceAbs, 'design-system.md')}`)));
6132
+ log(dim(t(` 재사용 적용(사용자 결정): leerness parent adopt --select design-system,reuse-map,conventions --apply`,
6133
+ ` adopt (your decision): leerness parent adopt --select design-system,reuse-map,conventions --apply`)));
6134
+ return;
6135
+ }
6136
+ // 1.30.3 (#158): leerness parent adopt — 부모 자산을 자식-로컬 참조로 기록(게이트형 적용).
6137
+ // 사용자 결정 게이트: dry-run 기본, --apply(사용자 명시) 시에만 기록. 자식 design-system.md 무변경(비파괴, additive).
6138
+ if (sub === 'adopt') {
6139
+ const p = _findParentWorkspace(root);
6140
+ const apply = has('--apply');
6141
+ const selRaw = arg('--select', 'all');
6142
+ const allKinds = ['design-system', 'reuse-map', 'conventions'];
6143
+ const kinds = (selRaw === 'all') ? allKinds : String(selRaw).split(',').map(s => s.trim()).filter(Boolean);
6144
+ const wsAbs = _workspaceDirAbs(root);
6145
+ const inheritedPath = path.join(wsAbs, 'inherited-from-parent.md');
6146
+ const linkPath = path.join(wsAbs, 'PARENT_LINK.json');
6147
+ const cand = [];
6148
+ if (p) {
6149
+ if (kinds.includes('design-system') && p.assets.designSystem) cand.push({ kind: 'design-system', src: path.join(p.workspaceAbs, 'design-system.md') });
6150
+ if (kinds.includes('reuse-map') && p.assets.reuseMap) cand.push({ kind: 'reuse-map', src: path.join(p.workspaceAbs, 'reuse-map.md') });
6151
+ if (kinds.includes('conventions') && p.assets.agents) cand.push({ kind: 'conventions', src: path.join(p.parentRoot, 'AGENTS.md') });
6152
+ }
6153
+ // 1.30.3: --json 은 단일 객체만 출력 — apply(부모 존재) 경로는 자체 JSON(applied:true)을 내므로 여기선 제외(이중 JSON 방지).
6154
+ if (has('--json') && (!p || !apply)) {
6155
+ 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));
6156
+ }
6157
+ if (!p) {
6158
+ if (!has('--json')) { log(cyan(`# leerness parent adopt (1.30.3)`)); log(dim(t(` 상위 leerness 부모 프로젝트 없음 — adopt 대상 없음.`, ` no parent leerness project — nothing to adopt.`))); }
6159
+ return;
6160
+ }
6161
+ if (!apply) {
6162
+ if (!has('--json')) {
6163
+ log(cyan(`# leerness parent adopt (1.30.3) [DRY-RUN]`));
6164
+ log(t(` 부모: ${grn(p.parentRoot)} · 선택: ${kinds.join(', ')}`, ` parent: ${grn(p.parentRoot)} · select: ${kinds.join(', ')}`));
6165
+ if (!cand.length) log(dim(t(` 적용 후보 없음 (부모에 선택 자산 없음).`, ` no candidates (parent lacks the selected assets).`)));
6166
+ else cand.forEach(c => log(t(` • ${c.kind} ← ${c.src}`, ` • ${c.kind} ← ${c.src}`)));
6167
+ log('');
6168
+ log(yel(t(` ⚠ DRY-RUN — 실제 적용하려면 \`leerness parent adopt --apply\` (사용자 명시 결정).`, ` ⚠ DRY-RUN — to apply, run \`leerness parent adopt --apply\` (explicit user decision).`)));
6169
+ log(dim(t(` 적용해도 자식 design-system.md 는 변경하지 않고, .harness/inherited-from-parent.md 에 '참조'로만 기록(비파괴).`,
6170
+ ` even on apply, your design-system.md is NOT modified — parent assets are recorded as reference in .harness/inherited-from-parent.md.`)));
6171
+ }
6172
+ return;
6173
+ }
6174
+ // APPLY (사용자 명시): 자식-로컬 참조 파일 + 마커 기록 (비파괴 additive — 자식 원본 design-system.md/reuse-map.md 직접 변형 안 함)
6175
+ try {
6176
+ mkdirp(wsAbs);
6177
+ let body = `<!-- leerness:inherited-from-parent (1.30.3) — 사용자 명시 \`parent adopt --apply\`. 부모 자산을 '참조'로만 기록(자식 원본 무변경). -->\n`;
6178
+ body += `# ${t('부모 프로젝트 자산 (참조용)', 'Parent project assets (reference)')}\n\n`;
6179
+ body += `- ${t('부모', 'parent')}: ${p.parentRoot}\n- adopt: ${today()}\n- ${t('선택', 'select')}: ${kinds.join(', ')}\n\n`;
6180
+ 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`; }
6181
+ writeUtf8(inheritedPath, body);
6182
+ writeUtf8(linkPath, JSON.stringify({ parentRoot: p.parentRoot, workspaceDir: p.workspaceDir, adoptedKinds: kinds, adoptedAt: today(), version: VERSION }, null, 2) + '\n');
6183
+ 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)); }
6184
+ else {
6185
+ log(cyan(`# leerness parent adopt (1.30.3)`));
6186
+ log(grn(t(` ✓ adopt 완료 (${cand.length} 자산) — 자식 design-system.md 무변경(참조로만 기록).`, ` ✓ adopted (${cand.length} assets) — your design-system.md unchanged (recorded as reference only).`)));
6187
+ log(dim(` ${t('참조', 'reference')}: ${inheritedPath}`));
6188
+ log(dim(` ${t('마커', 'marker')}: ${linkPath}`));
6189
+ }
6190
+ } catch (e) {
6191
+ if (!has('--json')) log(red(t(` ✗ adopt 실패: ${e.message}`, ` ✗ adopt failed: ${e.message}`)));
6192
+ process.exitCode = 1;
6193
+ }
6194
+ return;
6195
+ }
6196
+ console.error(t(`Usage: leerness parent [detect|adopt] [--select <kinds>] [--apply] [--json]`, `Usage: leerness parent [detect|adopt] [--select <kinds>] [--apply] [--json]`));
6197
+ process.exit(1);
6198
+ }
6199
+
6044
6200
  // 1.9.211: leerness migrate-workspace-dir — .harness → .leerness 마이그레이션 (사용자 명시)
6045
6201
  function migrateWorkspaceDirCmd(root) {
6046
6202
  root = absRoot(root);
@@ -7555,12 +7711,19 @@ function lessonSave(root, text) {
7555
7711
  if (!text) return fail('lesson text required. 예: leerness lesson save "JWT는 refresh token도 짧게 (15분 권장)"');
7556
7712
  const tag = arg('--tag', '');
7557
7713
  // 1.9.406 (8번째 버그헌트, UR-0110): RMW 락 직렬화 — 동시 lesson save lost-update 방지(UR-0043 패턴).
7714
+ // 1.30.4 (14th리뷰 F5): task/rule add 와 일관된 dedup — 동일 text 존재 시 skip(--force 우회). 종전엔 무조건 append(중복 누적).
7715
+ let _skipped = false;
7558
7716
  _withLock(lessonsJsonPath(root), () => {
7559
7717
  const all = _loadLessons(root);
7718
+ if (!has('--force') && all.some(l => l && l.text === text)) { _skipped = true; return; }
7560
7719
  all.push({ date: today(), text, tag: tag || null });
7561
7720
  _saveLessons(root, all);
7562
7721
  });
7563
7722
  // 1.9.413 (6th외부평가 codex P2, UR-0101): --json 구조화 출력(데이터 이미 영속).
7723
+ if (_skipped) {
7724
+ if (has('--json')) { log(JSON.stringify({ ok: true, skipped: true, text, tag: tag || null })); return; }
7725
+ ok(`lesson exists (skip): ${text.slice(0, 40)} (--force 로 추가)`); return;
7726
+ }
7564
7727
  if (has('--json')) { log(JSON.stringify({ ok: true, text, tag: tag || null })); return; }
7565
7728
  ok(`lesson saved`);
7566
7729
  _autoRoadmap(absRoot(root), 'data-change');
@@ -7643,8 +7806,11 @@ function decisionAdd(root, title) {
7643
7806
  const impact = arg('--impact', '');
7644
7807
  // 1.9.339 (UR-0053): canonical JSON write path — 기존 항목(JSON 우선, 없으면 MD backfill) 로드 후 추가, JSON+MD projection 동시 저장.
7645
7808
  // 1.9.406 (8번째 버그헌트, UR-0110): RMW 락 직렬화 — 동시 decision add lost-update 방지(UR-0043 패턴).
7809
+ // 1.30.4 (14th리뷰 F5): task/rule add 와 일관된 dedup — 동일 title 존재 시 skip(--force 우회). 종전엔 무조건 append(중복 누적).
7810
+ let _skipped = false;
7646
7811
  _withLock(decisionsJsonPath(root), () => {
7647
7812
  const all = _loadDecisions(root);
7813
+ if (!has('--force') && all.some(d => d && (d.title === title || d.decision === title))) { _skipped = true; return; }
7648
7814
  all.push({
7649
7815
  date: today(), title,
7650
7816
  decision: title,
@@ -7655,6 +7821,10 @@ function decisionAdd(root, title) {
7655
7821
  _saveDecisions(root, all);
7656
7822
  });
7657
7823
  // 1.9.413 (6th외부평가 codex P2, UR-0101): --json 구조화 출력(데이터 이미 영속).
7824
+ if (_skipped) {
7825
+ if (has('--json')) { log(JSON.stringify({ ok: true, skipped: true, title })); return; }
7826
+ ok(`decision exists (skip): ${title} (--force 로 추가)`); return;
7827
+ }
7658
7828
  if (has('--json')) { log(JSON.stringify({ ok: true, title })); return; }
7659
7829
  ok(`decision added: ${title}`);
7660
7830
  // 1.9.43+ handoff lessons 회수 흐름과 자동 통합 (decisions.md fuzzy 매칭됨)
@@ -7830,6 +8000,9 @@ function installSafetyCmd(opts = {}) {
7830
8000
  const deps = Object.keys(pkg.dependencies || {});
7831
8001
  const scripts = pkg.scripts || {};
7832
8002
  const installHooks = ['preinstall', 'install', 'postinstall'].filter(h => scripts[h]);
8003
+ // 1.31.1 (UR-0010): install-safety 출력 영어 opt-in (한국어 기본). safeInstall 의 npx --yes 토큰 + hardeningNote 의 PowerShell 토큰은 양 언어 공통(셸-무관 가드 보존).
8004
+ const uiLang = _uiLang(arg('--path', process.cwd()));
8005
+ const t = (ko, en) => (uiLang === 'en' ? en : ko);
7833
8006
  const profile = {
7834
8007
  version: VERSION,
7835
8008
  runtimeDeps: deps.length,
@@ -7843,22 +8016,23 @@ function installSafetyCmd(opts = {}) {
7843
8016
  // 타 패키지 대비 일반 하드닝은 셸별로 hardeningNote 참조.
7844
8017
  safeInstall: [
7845
8018
  'git checkout -b chore/leerness-update',
7846
- 'npx --yes leerness@latest migrate plan --path . # 임시폴더 비교(읽기 전용, 셸 무관)',
8019
+ t('npx --yes leerness@latest migrate plan --path . # 임시폴더 비교(읽기 전용, 셸 무관)', 'npx --yes leerness@latest migrate plan --path . # read-only temp-folder compare (shell-agnostic)'),
7847
8020
  'npx --yes leerness@latest update --yes --path .',
7848
- 'git diff # 변경 검토 후 커밋 또는 롤백',
8021
+ t('git diff # 변경 검토 후 커밋 또는 롤백', 'git diff # review the diff, then commit or roll back'),
7849
8022
  ],
7850
- hardeningNote: 'leerness 자체는 0 deps / 0 install-script 라 설치/업데이트 시 임의코드 실행 없음. 일반 npx 하드닝(타 패키지 대비)은 셸별 — bash: npm_config_ignore_scripts=true npx ... · PowerShell: $env:npm_config_ignore_scripts=1; npx ... · cmd: set npm_config_ignore_scripts=1 && npx ...',
8023
+ hardeningNote: t('leerness 자체는 0 deps / 0 install-script 라 설치/업데이트 시 임의코드 실행 없음. 일반 npx 하드닝(타 패키지 대비)은 셸별 — bash: npm_config_ignore_scripts=true npx ... · PowerShell: $env:npm_config_ignore_scripts=1; npx ... · cmd: set npm_config_ignore_scripts=1 && npx ...',
8024
+ 'leerness itself has 0 deps / 0 install-script, so no arbitrary code runs on install/update. General npx hardening (for other packages) is shell-specific — bash: npm_config_ignore_scripts=true npx ... · PowerShell: $env:npm_config_ignore_scripts=1; npx ... · cmd: set npm_config_ignore_scripts=1 && npx ...'),
7851
8025
  };
7852
8026
  if (opts.json) { log(JSON.stringify(profile, null, 2)); return; }
7853
- log(`# leerness install-safety (1.9.359) — 설치 안전 프로필`);
7854
- log(` 버전: ${VERSION} · Node: ${profile.node || '(미지정)'}`);
7855
- log(` 런타임 의존성: ${deps.length === 0 ? '0건 (외부 패키지 없음 — 공급망 노출 0)' : deps.length + '건 — ' + deps.join(', ')}`);
7856
- log(` install-time 스크립트: ${installHooks.length === 0 ? '없음 (preinstall/install/postinstall 0 — 설치 시 임의코드 실행 없음)' : installHooks.join(', ')}`);
7857
- log(` 동작 방식: offline-first (설치 시 네트워크/빌드 불필요, 단일 bin + lib)`);
7858
- log(`\n 안전 설치 워크플로 (검토 후 적용):`);
8027
+ log(t(`# leerness install-safety (1.9.359) — 설치 안전 프로필`, `# leerness install-safety — install safety profile`));
8028
+ log(t(` 버전: ${VERSION} · Node: ${profile.node || '(미지정)'}`, ` version: ${VERSION} · Node: ${profile.node || '(unspecified)'}`));
8029
+ log(t(` 런타임 의존성: ${deps.length === 0 ? '0건 (외부 패키지 없음 — 공급망 노출 0)' : deps.length + '건 — ' + deps.join(', ')}`, ` runtime deps: ${deps.length === 0 ? '0 (no external packages — zero supply-chain exposure)' : deps.length + ' — ' + deps.join(', ')}`));
8030
+ log(t(` install-time 스크립트: ${installHooks.length === 0 ? '없음 (preinstall/install/postinstall 0 — 설치 시 임의코드 실행 없음)' : installHooks.join(', ')}`, ` install-time scripts: ${installHooks.length === 0 ? 'none (0 preinstall/install/postinstall — no arbitrary code on install)' : installHooks.join(', ')}`));
8031
+ log(t(` 동작 방식: offline-first (설치 시 네트워크/빌드 불필요, 단일 bin + lib)`, ` how it works: offline-first (no network/build on install, single bin + lib)`));
8032
+ log(t(`\n 안전 설치 워크플로 (검토 후 적용):`, `\n Safe install workflow (review then apply):`));
7859
8033
  profile.safeInstall.forEach((s, i) => log(` ${i + 1}. ${s}`));
7860
8034
  log(`\n ⓘ ${profile.hardeningNote}`); // 1.9.397 (UR-0098): 셸별 하드닝 노트
7861
- if (installHooks.length > 0) warn('install-time 스크립트 존재 — 위 ignore-scripts safe-install 권장');
8035
+ if (installHooks.length > 0) warn(t('install-time 스크립트 존재 — 위 ignore-scripts safe-install 권장', 'install-time scripts present — use the ignore-scripts safe-install above'));
7862
8036
  }
7863
8037
  function debug(root) {
7864
8038
  root = absRoot(root); let warnings = 0, failures = 0;
@@ -7872,7 +8046,7 @@ function debug(root) {
7872
8046
 
7873
8047
  const _audit = require('../lib/audit');
7874
8048
  // 1.9.421 (UR-0025/UR-0125 큰 핸들러 모듈화 6번째): audit → lib/audit.js (DI 위임, thin wrapper)
7875
- function audit(root, opts = {}) { return _audit.audit(root, opts, { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills }); }
8049
+ function audit(root, opts = {}) { return _audit.audit(root, opts, { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills, _collectSecretFindings }); }
7876
8050
 
7877
8051
  // 1.9.312 (UR-0050, 설치리뷰 3중수렴): secret 스캐너 현대 키 패턴 보강.
7878
8052
  // 배경: 기존 OpenAI 패턴 `sk-[A-Za-z0-9]{32,}` 은 하이픈에서 끊겨 sk-proj-/sk-svcacct- (modern 프로젝트/서비스 키)를 놓침.
@@ -8488,6 +8662,16 @@ function handoff(root) {
8488
8662
  const parts = [];
8489
8663
  // 1.20.3 (UR-0010 Phase 2): 헤드라인 항목 라벨 UI 언어 적용 (영어 opt-in, 한국어 기본). 블록 1회 해석.
8490
8664
  const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
8665
+ // 1.30.2 (#157): 상위 leerness 부모 프로젝트 탐지 → AI 가 세션 시작 즉시 인지(재사용 후보). read-only — 자동 적용 X(사용자 결정 게이트). 상세: leerness parent detect
8666
+ try {
8667
+ const _pw = _findParentWorkspace(root);
8668
+ if (_pw && _pw.assetCount > 0) {
8669
+ const _adopted = exists(path.join(_workspaceDirAbs(root), 'PARENT_LINK.json')); // 1.30.3: adopt 여부 반영
8670
+ parts.push(_adopted
8671
+ ? t(`🔗 부모 프로젝트 (${_pw.assetCount} 자산·adopted)`, `🔗 parent project (${_pw.assetCount} assets, adopted)`)
8672
+ : t(`🔗 부모 프로젝트 (${_pw.assetCount} 자산·미적용)`, `🔗 parent project (${_pw.assetCount} assets, not applied)`));
8673
+ }
8674
+ } catch {}
8491
8675
  // 1) drift level (가벼운 check)
8492
8676
  try {
8493
8677
  const r = cp.spawnSync(process.execPath, [__filename, 'drift', 'check', root, '--json'],
@@ -9348,24 +9532,25 @@ function handoff(root) {
9348
9532
  const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
9349
9533
  const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
9350
9534
  const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
9535
+ const _t = (ko, en) => (_uiLang(root) === 'en' ? en : ko); // 1.30.5 (#156 F3): 로컬 i18n (이 블록 9536 에 t 화살표 파라미터가 있어 _t 로 명명)
9351
9536
  if (gapInfo.hasLast) {
9352
9537
  // 마지막 handoff 시점 알고 있음 — 정확한 측정 (1.9.199)
9353
9538
  if (gapInfo.isLong) {
9354
9539
  // 60분+ → 강한 알림
9355
- log(red(`## ⏰ ScheduleWakeup miss 강한 의심 (1.9.199) — 이전 handoff ${gapInfo.gapMin}분 전`));
9356
- log(dim(` R-0001 영구 룰 (25분) 대비 ${Math.floor(gapInfo.gapMin/25)}× 초과 — 시스템 sleep / wakeup 누락 확실`));
9357
- log(dim(` → 회복: 사용자가 "다음 라운드" 입력 또는 leerness rule list 로 룰 확인`));
9358
- log(dim(` → handoff 이력: ${(gapInfo.history || []).slice(-3).map(t => t.slice(11, 19)).join(' → ')}`));
9540
+ log(red(_t(`## ⏰ ScheduleWakeup miss 강한 의심 (1.9.199) — 이전 handoff ${gapInfo.gapMin}분 전`, `## ⏰ ScheduleWakeup miss strongly suspected — last handoff ${gapInfo.gapMin} min ago`)));
9541
+ 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`)));
9542
+ log(dim(_t(` → 회복: 사용자가 "다음 라운드" 입력 또는 leerness rule list 로 룰 확인`, ` → recover: user types "next round" or check rules via leerness rule list`)));
9543
+ 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(' → ')}`)));
9359
9544
  log('');
9360
9545
  } else if (gapInfo.isMiss) {
9361
9546
  // 35~60분 → 의심
9362
- log(yel(`## ⏰ ScheduleWakeup 지연 (1.9.199) — 이전 handoff ${gapInfo.gapMin}분 전 (룰: 25분)`));
9363
- log(dim(` ±10분 buffer 초과 — wakeup 한 cycle 누락 가능성`));
9547
+ log(yel(_t(`## ⏰ ScheduleWakeup 지연 (1.9.199) — 이전 handoff ${gapInfo.gapMin}분 전 (룰: 25분)`, `## ⏰ ScheduleWakeup delayed — last handoff ${gapInfo.gapMin} min ago (rule: 25 min)`)));
9548
+ log(dim(_t(` ±10분 buffer 초과 — wakeup 한 cycle 누락 가능성`, ` beyond the ±10 min buffer — a wakeup cycle may have been missed`)));
9364
9549
  log('');
9365
9550
  } else if (gapInfo.gapMin >= 0 && gapInfo.gapMin <= 30) {
9366
9551
  // 정상 범위 (handoff_history.length >= 2 일 때만 의미 있음 — 첫 진입 제외)
9367
9552
  if ((gapInfo.history || []).length >= 2) {
9368
- log(dim(` ✓ ScheduleWakeup cycle 정상 (gap ${gapInfo.gapMin}분, 룰 25분 — 1.9.199)`));
9553
+ log(dim(_t(` ✓ ScheduleWakeup cycle 정상 (gap ${gapInfo.gapMin}분, 룰 25분 — 1.9.199)`, ` ✓ ScheduleWakeup cycle healthy (gap ${gapInfo.gapMin} min, rule 25 min)`)));
9369
9554
  }
9370
9555
  }
9371
9556
  } else {
@@ -9375,9 +9560,9 @@ function handoff(root) {
9375
9560
  const ageMs = Date.now() - fs.statSync(tlp).mtimeMs;
9376
9561
  const ageMin = Math.floor(ageMs / 60000);
9377
9562
  if (ageMin >= 60) {
9378
- const label = ageMin < 120 ? `${ageMin}분` : (ageMin < 1440 ? `${Math.floor(ageMin/60)}시간` : `${Math.floor(ageMin/1440)}일`);
9379
- log(yel(`## ⏰ ScheduleWakeup miss 의심 (1.9.196 fallback) — task-log 마지막 ${label} 전`));
9380
- log(dim(` 1.9.199 last-handoff.json 첫 기록 — 다음 handoff 부터 정확 측정`));
9563
+ 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`));
9564
+ log(yel(_t(`## ⏰ ScheduleWakeup miss 의심 (1.9.196 fallback) — task-log 마지막 ${label} 전`, `## ⏰ ScheduleWakeup miss suspected (fallback) — task-log last modified ${label} ago`)));
9565
+ log(dim(_t(` 1.9.199 last-handoff.json 첫 기록 — 다음 handoff 부터 정확 측정`, ` first last-handoff.json record — precise measurement from the next handoff`)));
9381
9566
  log('');
9382
9567
  }
9383
9568
  }
@@ -9392,6 +9577,7 @@ function handoff(root) {
9392
9577
  const isTtyMd = process.stdout && process.stdout.isTTY;
9393
9578
  const mdCy = s => isTtyMd ? `\x1b[36m${s}\x1b[0m` : s;
9394
9579
  const mdDim = s => isTtyMd ? `\x1b[2m${s}\x1b[0m` : s;
9580
+ const t = (ko, en) => (_uiLang(root) === 'en' ? en : ko); // 1.30.5 (#156 F3): 로컬 t()
9395
9581
  const cutoff = Date.now() - 24 * 60 * 60 * 1000;
9396
9582
  const deltas = [];
9397
9583
  // tasks: progress-tracker.md row Updated 컬럼 기반 (간단 휴리스틱 — mtime이 24h내면 표시)
@@ -9432,7 +9618,7 @@ function handoff(root) {
9432
9618
  const pp = planPath(root);
9433
9619
  if (exists(pp) && fs.statSync(pp).mtimeMs > cutoff) {
9434
9620
  // M-XXXX 중 line이 24h내 추가됐는지 정확히는 어려움 — mtime 24h내면 "plan: 변경됨"으로 표시
9435
- deltas.push('plan: 변경됨');
9621
+ deltas.push(t('plan: 변경됨', 'plan: changed'));
9436
9622
  }
9437
9623
  } catch {}
9438
9624
  // rules: rule add 후 mtime 24h내
@@ -9444,8 +9630,8 @@ function handoff(root) {
9444
9630
  }
9445
9631
  } catch {}
9446
9632
  if (deltas.length) {
9447
- log(mdCy(`🆕 최근 24h 메모리 변동 (1.9.121): ${deltas.join(' · ')}`));
9448
- log(mdDim(` → 상세: leerness memory status --json`));
9633
+ log(mdCy(t(`🆕 최근 24h 메모리 변동 (1.9.121): ${deltas.join(' · ')}`, `🆕 memory changes in last 24h: ${deltas.join(' · ')}`)));
9634
+ log(mdDim(t(` → 상세: leerness memory status --json`, ` → details: leerness memory status --json`)));
9449
9635
  log('');
9450
9636
  }
9451
9637
  } catch {}
@@ -9573,8 +9759,13 @@ function handoff(root) {
9573
9759
  const _Lsec = _uiLang(root);
9574
9760
  const t = (ko, en) => (_Lsec === 'en' ? en : ko);
9575
9761
  const envExists = exists(path.join(root, '.env'));
9762
+ const issues = [];
9763
+ // 0) 1.30.1 (14th 외부리뷰 F2): 커밋된 plaintext 시크릿을 보안 요약에 노출 — headline '🚨 시크릿 N건' 과 일관.
9764
+ // envExists 무관(소스에 커밋된 시크릿은 .env 없어도 위험). gitignored 는 _collectSecretFindings 가 committed 에서 제외.
9765
+ let committedSecrets = [];
9766
+ try { committedSecrets = _collectSecretFindings(root).committed || []; } catch {}
9767
+ if (committedSecrets.length) issues.push(t(`커밋된 시크릿 ${committedSecrets.length}건 (소스 노출)`, `${committedSecrets.length} committed secret(s) (exposed in source)`));
9576
9768
  if (envExists) {
9577
- const issues = [];
9578
9769
  // 1) env diff
9579
9770
  try {
9580
9771
  const d = envDiff(root);
@@ -9589,41 +9780,43 @@ function handoff(root) {
9589
9780
  const missing = SECRET_PATTERNS.filter(p => !giLines.some(l => l === p || l === '/' + p));
9590
9781
  if (missing.length) issues.push(t(`.gitignore 시크릿 누락 ${missing.length}건`, `.gitignore missing secret patterns ${missing.length}`));
9591
9782
  } catch {}
9592
- if (issues.length) {
9593
- const isTty = process.stdout && process.stdout.isTTY;
9594
- const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
9595
- const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
9596
- const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
9597
- log('');
9598
- log(red(t(`## 🔒 보안 요약 (1.9.76) — ${issues.length}건 주의`, `## 🔒 Security summary — ${issues.length} to review`)));
9599
- for (const i of issues) log(dim(` ⚠ ${i}`));
9600
- log(dim(t(` → 자동 수정: leerness audit --fix · 상세: leerness env check / leerness audit`, ` auto-fix: leerness audit --fix · details: leerness env check / leerness audit`)));
9601
- // 1.9.80: critical 수준 (.gitignore에 .env 자체 누락) 자동 회복 옵션
9602
- const giText = exists(path.join(root, '.gitignore')) ? read(path.join(root, '.gitignore')) : '';
9603
- const giLines = giText.split('\n').map(l => l.trim());
9604
- const envInGitignore = giLines.includes('.env') || giLines.includes('/.env');
9605
- if (!envInGitignore) {
9606
- // .env 자체 누락 최우선 위험
9607
- 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.`)));
9608
- // LEERNESS_AUTO_SECURITY_FIX=1 자동 실행 옵션
9609
- if (process.env.LEERNESS_AUTO_SECURITY_FIX === '1') {
9610
- try {
9611
- const r = cp.spawnSync(process.execPath, [__filename, 'audit', root, '--fix'],
9612
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
9613
- if (r.status === 0) {
9614
- log(dim(t(` ✓ 자동 회복 (LEERNESS_AUTO_SECURITY_FIX=1): audit --fix 완료`, ` ✓ auto-recovered (LEERNESS_AUTO_SECURITY_FIX=1): audit --fix done`)));
9615
- } else {
9616
- log(dim(t(` ⚠ 자동 회복 실패 (exit ${r.status}) 수동 \`leerness audit --fix\` 권장`, ` ⚠ auto-recovery failed (exit ${r.status}) — run \`leerness audit --fix\` manually`)));
9617
- }
9618
- } catch (e) {
9619
- log(dim(t(` ⚠ 자동 회복 예외: ${e.message}`, ` ⚠ auto-recovery error: ${e.message}`)));
9783
+ }
9784
+ if (issues.length) {
9785
+ const isTty = process.stdout && process.stdout.isTTY;
9786
+ const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
9787
+ const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
9788
+ const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
9789
+ log('');
9790
+ log(red(t(`## 🔒 보안 요약 (1.9.76) ${issues.length}건 주의`, `## 🔒 Security summary — ${issues.length} to review`)));
9791
+ for (const i of issues) log(dim(` ${i}`));
9792
+ // 1.30.1 (F2): 커밋된 시크릿 파일 위치 노출 ( snippet 미출력 handoff 로그로의 시크릿 유출 방지)
9793
+ for (const f of committedSecrets.slice(0, 4)) log(dim(` • ${f.file}:${f.line} (${f.name})`));
9794
+ log(dim(t(` → 자동 수정: leerness audit --fix · 상세: leerness scan secrets / leerness env check`, ` → auto-fix: leerness audit --fix · details: leerness scan secrets / leerness env check`)));
9795
+ // 1.9.80: critical 수준 (.gitignore에 .env 자체 누락) 자동 회복 옵션
9796
+ const giText2 = exists(path.join(root, '.gitignore')) ? read(path.join(root, '.gitignore')) : '';
9797
+ const giLines2 = giText2.split('\n').map(l => l.trim());
9798
+ const envInGitignore = giLines2.includes('.env') || giLines2.includes('/.env');
9799
+ if (envExists && !envInGitignore) {
9800
+ // .env 자체 누락 → 최우선 위험
9801
+ 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.`)));
9802
+ // LEERNESS_AUTO_SECURITY_FIX=1 자동 실행 옵션
9803
+ if (process.env.LEERNESS_AUTO_SECURITY_FIX === '1') {
9804
+ try {
9805
+ const r = cp.spawnSync(process.execPath, [__filename, 'audit', root, '--fix'],
9806
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
9807
+ if (r.status === 0) {
9808
+ log(dim(t(` ✓ 자동 회복 (LEERNESS_AUTO_SECURITY_FIX=1): audit --fix 완료`, ` ✓ auto-recovered (LEERNESS_AUTO_SECURITY_FIX=1): audit --fix done`)));
9809
+ } else {
9810
+ log(dim(t(` ⚠ 자동 회복 실패 (exit ${r.status}) — 수동 \`leerness audit --fix\` 권장`, ` ⚠ auto-recovery failed (exit ${r.status}) — run \`leerness audit --fix\` manually`)));
9620
9811
  }
9621
- } else {
9622
- log(dim(t(` 💡 자동 실행 옵션: LEERNESS_AUTO_SECURITY_FIX=1 leerness handoff .`, ` 💡 auto-fix option: LEERNESS_AUTO_SECURITY_FIX=1 leerness handoff .`)));
9812
+ } catch (e) {
9813
+ log(dim(t(` 자동 회복 예외: ${e.message}`, ` auto-recovery error: ${e.message}`)));
9623
9814
  }
9815
+ } else {
9816
+ log(dim(t(` 💡 자동 실행 옵션: LEERNESS_AUTO_SECURITY_FIX=1 leerness handoff .`, ` 💡 auto-fix option: LEERNESS_AUTO_SECURITY_FIX=1 leerness handoff .`)));
9624
9817
  }
9625
- log('');
9626
9818
  }
9819
+ log('');
9627
9820
  }
9628
9821
  } catch {}
9629
9822
  }
@@ -9656,27 +9849,29 @@ function handoff(root) {
9656
9849
  const cy = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
9657
9850
  const b = s => isTty ? `\x1b[1m${s}\x1b[0m` : s;
9658
9851
  const d = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
9852
+ const t = (ko, en) => (_uiLang(root) === 'en' ? en : ko); // 1.30.5 (#156 F3): headline t() 스코프 밖 — 로컬 t() (1.29.1 교훈)
9659
9853
  log('');
9660
- log(cy('## 🛠 세션 워크플로 6단계 (1.9.39+, AI 하네스 엔지니어링)'));
9661
- log(d(' 상세: ') + cy('.harness/session-workflow.md'));
9662
- log(` 1. ${b('요청 분석')} handoff(이미 완료) · drift check · 모호하면 명확화`);
9663
- log(` 2. ${b('계획 수립')} plan add / TodoWrite · reuse-map으로 기존 자원 우선`);
9664
- log(` 3. ${b('업무 분배')} agents list/recommend · 작업유형별 sub-agent 매핑`);
9665
- log(` 4. ${b('sub-agent 작업')} 파일 경로 격리 · mtime 검증 의무 · 자체 테스트`);
9666
- log(` 5. ${b('종합 검증')} contract verify · verify-claim --run-tests · review --persona`);
9667
- log(` 6. ${b('세션 마감')} session close · audit --fix · usage stats`);
9668
- log(d(' 끄려면: --no-workflow-guide 또는 LEERNESS_NO_WORKFLOW_GUIDE=1'));
9854
+ log(cy(t('## 🛠 세션 워크플로 6단계 (1.9.39+, AI 하네스 엔지니어링)', '## 🛠 Session workflow — 6 steps (AI harness engineering)')));
9855
+ log(d(t(' 상세: ', ' details: ')) + cy('.harness/session-workflow.md'));
9856
+ log(` 1. ${b(t('요청 분석', 'Analyze request'))} ${t('handoff(이미 완료) · drift check · 모호하면 명확화', 'handoff (done) · drift check · clarify if ambiguous')}`);
9857
+ log(` 2. ${b(t('계획 수립', 'Plan'))} ${t('plan add / TodoWrite · reuse-map으로 기존 자원 우선', 'plan add / TodoWrite · prefer existing via reuse-map')}`);
9858
+ log(` 3. ${b(t('업무 분배', 'Distribute'))} ${t('agents list/recommend · 작업유형별 sub-agent 매핑', 'agents list/recommend · map sub-agents by task type')}`);
9859
+ log(` 4. ${b(t('sub-agent 작업', 'sub-agent work'))} ${t('파일 경로 격리 · mtime 검증 의무 · 자체 테스트', 'isolate file paths · verify mtime · self-test')}`);
9860
+ log(` 5. ${b(t('종합 검증', 'Verify'))} ${t('contract verify · verify-claim --run-tests · review --persona', 'contract verify · verify-claim --run-tests · review --persona')}`);
9861
+ log(` 6. ${b(t('세션 마감', 'Close'))} ${t('session close · audit --fix · usage stats', 'session close · audit --fix · usage stats')}`);
9862
+ log(d(t(' 끄려면: --no-workflow-guide 또는 LEERNESS_NO_WORKFLOW_GUIDE=1', ' to disable: --no-workflow-guide or LEERNESS_NO_WORKFLOW_GUIDE=1')));
9669
9863
  log('');
9670
9864
  }
9671
9865
  // 1.9.373 (UR-0073 Phase C): 에이전트 팀 스케줄 알림 — 비-manual 팀이 정의돼 있으면 미리보기(dry-run) 안내. 실행 없음. opt-out.
9672
9866
  if (!has('--no-team-reminders') && !has('--compact') && !has('--quiet') && process.env.LEERNESS_NO_TEAM_REMINDERS !== '1') {
9673
9867
  try {
9674
- const _teamRem = _teamHandoffReminders(_loadTeams(root));
9868
+ const _teamRem = _teamHandoffReminders(_loadTeams(root), _uiLang(root));
9675
9869
  if (_teamRem.length) {
9870
+ const t = (ko, en) => (_uiLang(root) === 'en' ? en : ko); // 1.30.5 (#156 F3): 로컬 t()
9676
9871
  log('');
9677
- log('## 🤝 에이전트 팀 스케줄 (UR-0073 Phase C) — 정의 전용 · 자동 실행 X');
9872
+ log(t('## 🤝 에이전트 팀 스케줄 (UR-0073 Phase C) — 정의 전용 · 자동 실행 X', '## 🤝 Agent team schedule (UR-0073) — definitions only · no auto-run'));
9678
9873
  _teamRem.forEach(r => log(' ' + r));
9679
- log(' ⓘ 미리보기는 dry-run. 실행은 제안 명령 검토 후 직접 · 끄려면 --no-team-reminders 또는 LEERNESS_NO_TEAM_REMINDERS=1');
9874
+ 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'));
9680
9875
  }
9681
9876
  } catch {}
9682
9877
  }
@@ -10180,7 +10375,7 @@ function _vcImplIsEmpty(body) {
10180
10375
  function verifyClaimCmd(root, taskId) {
10181
10376
  root = absRoot(root);
10182
10377
  const _j = has('--json'); // 1.9.400 (7번째 버그헌트 P1-B, UR-0105): --json 에러도 구조화
10183
- if (!taskId) return failJson(_j, 'missing_args', 'verify-claim <T-ID> 필요. 예: leerness verify-claim T-0008');
10378
+ 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)
10184
10379
  const rows = readProgressRows(root);
10185
10380
  const row = rows.find(r => r.id === taskId);
10186
10381
  if (!row) return failJson(_j, 'not_found', `progress-tracker.md에 ${taskId} 없음.`);
@@ -11064,7 +11259,7 @@ function honestyCheckCmd(root, arg1) {
11064
11259
  function optimismCheckCmd(root, taskId) {
11065
11260
  root = absRoot(root || process.cwd());
11066
11261
  const _j = has('--json'); // 1.9.400 (7번째 버그헌트 P1-B, UR-0105): --json 에러도 구조화
11067
- if (!taskId) return failJson(_j, 'missing_args', 'optimism-check <T-ID> 필요. 예: leerness optimism-check T-0001');
11262
+ 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)
11068
11263
  const rows = readProgressRows(root);
11069
11264
  const row = rows.find(r => r.id === taskId);
11070
11265
  if (!row) return failJson(_j, 'not_found', `progress-tracker.md에 ${taskId} 없음.`);
@@ -17224,33 +17419,36 @@ function rolesCmd(root, sub, ...args) {
17224
17419
  // 무엇을 할 수 있고 어떻게 끄는지를 명시적으로 공개해 신뢰도를 높인다. SECURITY.md 와 동일 출처.
17225
17420
  // CAPABILITY_SURFACE / POWERFUL_COMMANDS → lib/catalogs.js (1.9.295 UR-0025 4단계)
17226
17421
  function capabilitiesCmd(root, opts = {}) {
17422
+ const _L = _uiLang(root); const _t = (ko, en) => _L === 'en' ? en : ko; // 1.31.3 (UR-0010)
17227
17423
  if (opts.json) {
17228
17424
  log(JSON.stringify({ version: VERSION, surface: CAPABILITY_SURFACE, powerfulCommands: POWERFUL_COMMANDS,
17229
- principles: ['0 런타임 의존성', 'postinstall 없음', '사용자 동의 없이 외부 LLM/API/CLI 자동 호출 안 함', '변경 전 자동 백업'] }, null, 2));
17425
+ principles: _L === 'en'
17426
+ ? ['0 runtime dependencies', 'no postinstall', 'no external LLM/API/CLI auto-call without user consent', 'auto-backup before changes']
17427
+ : ['0 런타임 의존성', 'postinstall 없음', '사용자 동의 없이 외부 LLM/API/CLI 자동 호출 안 함', '변경 전 자동 백업'] }, null, 2));
17230
17428
  return;
17231
17429
  }
17232
17430
  const isTty = process.stdout && process.stdout.isTTY;
17233
17431
  const cy = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
17234
17432
  const dm = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
17235
17433
  const riskMark = r => r === 'high' ? '🔴 high' : r === 'medium' ? '🟡 medium' : '🟢 low';
17236
- log(cy(`# leerness capabilities (1.9.272) — 권한·보안 표면 공개`));
17237
- log(dm(` leerness 는 권한이 큰 CLI 하네스입니다. 아래가 할 수 있는 전부이며, 각 항목의 opt-out 을 함께 표기합니다.`));
17434
+ log(cy(_t(`# leerness capabilities (1.9.272) — 권한·보안 표면 공개`, `# leerness capabilities — permission/security surface disclosure`)));
17435
+ log(dm(_t(` leerness 는 권한이 큰 CLI 하네스입니다. 아래가 할 수 있는 전부이며, 각 항목의 opt-out 을 함께 표기합니다.`, ` leerness is a high-privilege CLI harness. Below is everything it can do, with the opt-out for each item.`)));
17238
17436
  log('');
17239
- log(`## 원칙`);
17240
- log(` ✓ 런타임 의존성 0 · postinstall 없음 · 변경 전 자동 백업`);
17241
- log(` ✓ 사용자 동의 없이 외부 LLM/API/CLI 를 자동 호출하지 않음`);
17437
+ log(_t(`## 원칙`, `## Principles`));
17438
+ log(_t(` ✓ 런타임 의존성 0 · postinstall 없음 · 변경 전 자동 백업`, ` ✓ 0 runtime dependencies · no postinstall · auto-backup before changes`));
17439
+ log(_t(` ✓ 사용자 동의 없이 외부 LLM/API/CLI 를 자동 호출하지 않음`, ` ✓ never auto-calls an external LLM/API/CLI without user consent`));
17242
17440
  log('');
17243
- log(`## 권한 표면`);
17441
+ log(_t(`## 권한 표면`, `## Permission surface`));
17244
17442
  for (const [k, v] of Object.entries(CAPABILITY_SURFACE)) {
17245
17443
  log(` ${riskMark(v.risk)} ${cy(k)}`);
17246
- log(` ${v.desc}`);
17247
- log(dm(` opt-out: ${v.optOut}`));
17444
+ log(` ${_L === 'en' && v.descEn ? v.descEn : v.desc}`);
17445
+ log(dm(` opt-out: ${_L === 'en' && v.optOutEn ? v.optOutEn : v.optOut}`));
17248
17446
  }
17249
17447
  log('');
17250
- log(`## ⚠ 주의해서 쓸 명령 (회사/운영 코드)`);
17251
- for (const c of POWERFUL_COMMANDS) log(` • ${c.cmd.padEnd(22)} ${dm(c.note)}`);
17448
+ log(_t(`## ⚠ 주의해서 쓸 명령 (회사/운영 코드)`, `## ⚠ Commands to use with care (company/production code)`));
17449
+ for (const c of POWERFUL_COMMANDS) log(` • ${c.cmd.padEnd(22)} ${dm(_L === 'en' && c.noteEn ? c.noteEn : c.note)}`);
17252
17450
  log('');
17253
- log(dm(` 전체 정책: SECURITY.md · 기계 판독: leerness capabilities --json · 권한 등급: leerness policy`));
17451
+ log(dm(_t(` 전체 정책: SECURITY.md · 기계 판독: leerness capabilities --json · 권한 등급: leerness policy`, ` full policy: SECURITY.md · machine-readable: leerness capabilities --json · permission tiers: leerness policy`)));
17254
17452
  }
17255
17453
 
17256
17454
  // ===== 1.9.281 (UR-0034, GPT-5.5 범용 하네스): 권한 등급(permission tiers) =====
@@ -20204,13 +20402,19 @@ async function main() {
20204
20402
  if (cmd === 'wakeup-interval') return wakeupIntervalCmd(arg('--path', process.cwd()), args[1], args[2]);
20205
20403
  // 1.9.211: leerness workspace-dir <get|guide> — 현재 워크스페이스 디렉토리 / AI 참조 가이드 (사용자 명시)
20206
20404
  if (cmd === 'workspace-dir') return workspaceDirCmd(arg('--path', process.cwd()), args[1]);
20405
+ if (cmd === 'parent') return parentCmd(arg('--path', process.cwd()), args[1]);
20207
20406
  // 1.9.211: leerness migrate-workspace-dir — .harness → .leerness 마이그레이션 (사용자 명시)
20208
20407
  if (cmd === 'migrate-workspace-dir') return migrateWorkspaceDirCmd(arg('--path', process.cwd()));
20209
20408
  // 1.9.212: leerness idempotency audit — 멱등성 위반 탐지 (사용자 명시)
20210
20409
  if (cmd === 'idempotency') return idempotencyCmd(arg('--path', process.cwd()), args[1]);
20211
20410
  // 1.9.213: leerness intent <classify|expand|domains> — intent inference + scope expansion (사용자 명시)
20212
20411
  if (cmd === 'intent') return intentCmd(arg('--path', process.cwd()), args[1], ...args.slice(2));
20213
- 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 지원(제목과 분리)
20412
+ if (cmd === 'rule' && args[1] === 'add') { // 1.9.426: flag/경로 break(_parseAddTitle) · 1.9.445 (UR-0151): positional path 지원(제목과 분리)
20413
+ const _desc = _parseAddTitle(args, 2);
20414
+ // 1.30.4 (14th리뷰 F6): 빈 입력 시 --json 에서도 구조화 JSON(task/decision add 와 일관). 종전엔 ruleAdd 내부 fail() 가 평문 출력.
20415
+ if (!_desc) { failJson(has('--json'), 'empty_title', 'rule add "<설명>" 필요 (빈/경로-only 거부)'); return process.exit(process.exitCode || 1); }
20416
+ return ruleAdd(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd(), _desc);
20417
+ }
20214
20418
  if (cmd === 'rule' && args[1] === 'list') return ruleList(arg('--path', process.cwd()));
20215
20419
  if (cmd === 'rule' && args[1] === 'remove') return ruleRemove(arg('--path', process.cwd()), args[2]);
20216
20420
  if (cmd === 'rule' && args[1] === 'pause') return rulePause(arg('--path', process.cwd()), args[2]);
@@ -20218,6 +20422,8 @@ async function main() {
20218
20422
  if (cmd === 'rule' && args[1] === 'stop') return ruleStop(arg('--path', process.cwd()));
20219
20423
  if (cmd === 'rule' && args[1] === 'resume-all') return ruleResumeAll(arg('--path', process.cwd()));
20220
20424
  if (cmd === 'rule' && args[1] === 'verify') return ruleVerifyCmd(arg('--path', process.cwd()));
20425
+ // 1.30.4 (14th리뷰 F7): rule 하위명령 미매칭 시 잘못된 토큰 명시 + usage(종전엔 top-level 'unknown_command: rule' 로 유효 부모명을 오인 표기).
20426
+ 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); }
20221
20427
  if (cmd === 'release' && args[1] === 'bump') return releaseBump(args[2] || arg('--path', process.cwd()));
20222
20428
  if (cmd === 'release' && args[1] === 'note') return releaseNote(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
20223
20429
  if (cmd === 'release' && args[1] === 'publish') return releasePublish(args[2] || arg('--path', process.cwd()));
@@ -20272,6 +20478,8 @@ async function main() {
20272
20478
  if (sub==='relink') return taskRelink(root);
20273
20479
  if (sub==='sync') return taskSyncCmd(root);
20274
20480
  if (sub==='export') return taskExportCmd(root);
20481
+ // 1.30.4 (14th리뷰 F7): 미매칭 하위명령 시 잘못된 토큰을 명시 + usage(종전엔 top-level 'unknown_command: task' 로 유효 부모명을 오인 표기).
20482
+ failJson(has('--json'), 'unknown_subcommand', `알 수 없는 task 하위명령: ${sub} — leerness task list|add|update|drop|fix-evidence|relink|sync|export`); return process.exit(process.exitCode || 1);
20275
20483
  }
20276
20484
  // 1.9.114: memory status — Memory Write Surface 5종 통합 상태
20277
20485
  if (cmd === 'memory' && args[1] === 'status') {
@@ -20299,7 +20507,10 @@ async function main() {
20299
20507
  if (args[i].startsWith('--') || /^([A-Za-z]:[\\/]|\/|\.\.?[\\/])/.test(args[i])) break;
20300
20508
  textParts.push(args[i]);
20301
20509
  }
20302
- return lessonSave(root, textParts.join(' '));
20510
+ const _text = textParts.join(' ');
20511
+ // 1.30.4 (14th리뷰 F6): 빈 입력 시 --json 에서도 구조화 JSON(task/decision add 와 일관). 종전엔 lessonSave 내부 fail() 가 평문 출력.
20512
+ if (!_text) { failJson(has('--json'), 'empty_text', 'lesson save "<text>" 필요 (빈/경로-only 거부)'); return process.exit(process.exitCode || 1); }
20513
+ return lessonSave(root, _text);
20303
20514
  }
20304
20515
  if (sub === 'list') {
20305
20516
  return lessonListCmd(root, { json: has('--json') });