leerness 1.36.172 → 1.36.174

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
@@ -48,7 +48,7 @@ const {
48
48
  migrateLegacyWorkspace,
49
49
  } = require('../lib/workspace-dir');
50
50
 
51
- const VERSION = '1.36.172';
51
+ const VERSION = '1.36.174';
52
52
 
53
53
  // MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
54
54
  // 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
@@ -718,7 +718,7 @@ for (const _bf of _BRIEF_FIELDS) { if (_bf && _bf.flag) _VALUE_FLAGS.add('--' +
718
718
  // 판정에만 쓰이고 파싱 동작에는 관여하지 않으므로, 넓게 잡아도 안전하다(과다 포함 = 경고 억제).
719
719
  const _BOOL_FLAGS = new Set(['--ai','--all','--all-apps','--baseline','--all-presets','--allow-empty','--allow-unrelated','--apply','--audit','--auto','--auto-apply-delivered','--auto-cleanup-branches','--auto-fix','--auto-fix-bom','--auto-fix-encoding','--auto-main-push','--auto-recover','--auto-track','--banner','--bench','--build','--bundle-only','--claims','--close','--compact','--detect','--dry-run','--dry-run-npm','--embedding','--enforce','--fail-on-candidates','--fail-on-violation','--force','--gh-pages','--gh-release','--git-push','--global','--guide','--help','--html','--include-code','--interactive','--json','--last','--lenient','--major','--minimal','--minor','--no-animate','--no-auto-roadmap','--no-auto-update','--no-banner','--no-brainstorm-hits','--no-cache','--no-context-inject','--no-crawl','--no-drift-check','--no-enforce','--no-env','--no-env-detect','--no-feature-impact','--no-headline','--no-init-check','--no-interactive-select','--no-lazy-warn','--no-lessons','--no-longterm-recall','--no-mcp','--no-mem-delta','--no-multiagent-hint','--no-next-actions','--no-npm','--no-official-skills','--no-on-every-change','--no-pre-wake','--no-preserve-link','--no-readme-sync','--no-record','--no-repl','--no-review','--no-save','--no-security-check','--no-security-summary','--no-setup-agents','--no-skill-suggest','--no-stale-check','--no-suggest','--no-synonyms','--no-team-reminders','--no-wakeup-miss','--no-workflow-guide','--no-write','--npm-publish','--offline','--on-every-change','--pack','--parent-migrate','--patch','--publish-npm','--pulse','--quiet','--record','--refresh','--remove','--repair','--repl','--require-evidence','--run-tests','--skeleton','--skip-verify','--strict','--strict-claims','--strict-elements','--strict-exit','--suggest','--version','--writeback','--yes',
720
720
  // 소스에 리터럴로만 존재해 소비 형태를 기계 추출하지 못한 것들 — 실재하는 플래그이므로 오타로 오인하면 안 된다
721
- '--commands','--decision','--errors','--expand-all','--fix','--list','--notes','--print','--raw','--select-all','--skip-git-repo-check','--tests','--verbose','--yolo',
721
+ '--commands','--decision','--errors','--fix','--list','--notes','--print','--raw','--select-all','--skip-git-repo-check','--tests','--verbose','--yolo',
722
722
  // lib/*.js 의 부울 소비자 — bin/ 만 훑었다면 `agents route --confirm` 같은 실재 문법에 오탐 경고를 냈을 것이다
723
723
  '--confirm','--dispatch','--execute','--ladder','--log','--multi','--no-env-check','--no-feature-check','--no-gitignore-check','--no-npm-audit','--no-secret-scan','--readonly','--write']);
724
724
  // 1.36.103: 하위명령이 '선택' 인 명령에서 `<cmd> <경로>` 의 경로가 하위명령 자리로 읽히던 것 차단.
@@ -1791,7 +1791,8 @@ async function resolveInstallOptions(root, opts = {}) {
1791
1791
  // 1.9.151: CLI 에이전트 활성화 — 복수 선택 (사용자 명시 요청)
1792
1792
  // _selectMany 로 Space 토글, a 전체, n 해제, Enter 확정. 선택된 에이전트들만 .env.example에 LEERNESS_ENABLE_* 활성화.
1793
1793
  let agentsOptIn = null; // string[] (다중) 또는 'none' (선택 안함)
1794
- if (shouldAsk && !opts._skipAgentsPrompt) {
1794
+ const skipAgentSetup = opts.noSetupAgents === true || has('--no-setup-agents');
1795
+ if (shouldAsk && !opts._skipAgentsPrompt && !skipAgentSetup) {
1795
1796
  // 1.9.206: 선택된 언어로 prompt 표시
1796
1797
  const _agLang = lang === 'en' ? 'en' : 'ko';
1797
1798
  if (useInteractive) {
@@ -1830,7 +1831,7 @@ async function resolveInstallOptions(root, opts = {}) {
1830
1831
  // 1.18.3 (사용자 명시): 설치 직후 REPL agent 모드 진입 문항 제거 — REPL 은 완성도가 올라가면 그때 구현 예정.
1831
1832
  // 수동 진입(`leerness agent`)은 그대로 유지.
1832
1833
  const startRepl = false;
1833
- return { lang, skills, agentsOptIn, permissionMode, startRepl };
1834
+ return { lang, skills, agentsOptIn, skipAgentSetup, permissionMode, startRepl };
1834
1835
  }
1835
1836
 
1836
1837
  async function install(root, opts = {}) {
@@ -1891,6 +1892,7 @@ async function install(root, opts = {}) {
1891
1892
  const list = Array.isArray(resolved.agentsOptIn) ? resolved.agentsOptIn.join(', ') : String(resolved.agentsOptIn);
1892
1893
  log(`Agents 활성화: ${list}`);
1893
1894
  }
1895
+ if (resolved.skipAgentSetup) log(`Agents 설정 보존: --no-setup-agents (기존 활성화 값을 읽거나 덮어쓰지 않음)`);
1894
1896
  if (resolved.permissionMode) log(`Agent 권한 모드: ${resolved.permissionMode} (1.9.174 — REPL에서 \`:permissions extended|full\` 로 즉시 변경 가능)`);
1895
1897
  // 1.9.10: 스킬 카탈로그 출처 안내
1896
1898
  // 1.9.184 (사용자 명시): leerness-skillpack 미사용 정책 — 안내 메시지 제거. builtin catalog 만 사용.
@@ -2062,7 +2064,16 @@ async function install(root, opts = {}) {
2062
2064
  }
2063
2065
  // 1.9.187: 비시크릿 LEERNESS_* 설정 → .leerness/leerness-config.json (AI 가시성)
2064
2066
  try {
2065
- _writeLeernessConfig(root, {
2067
+ const leernessConfig = {
2068
+ LEERNESS_SKILL_DISCOVER_URL: '',
2069
+ LEERNESS_SKILL_AUTO_DISCOVER: '0',
2070
+ LEERNESS_SKILL_AUTO_INSTALL: '0',
2071
+ LEERNESS_SKILL_AUTO_PRESETS: 'vercel,anthropic'
2072
+ };
2073
+ // Explicit opt-out means preserve, not "select none". Omitting these keys
2074
+ // lets _writeLeernessConfig retain existing values and keeps a fresh init
2075
+ // free of an implicit ten-provider decision.
2076
+ if (!resolved.skipAgentSetup) Object.assign(leernessConfig, {
2066
2077
  LEERNESS_OLLAMA_BASE_URL: enable('ollama') ? 'http://localhost:11434' : '',
2067
2078
  LEERNESS_OLLAMA_MODEL: '',
2068
2079
  LEERNESS_ENABLE_CLAUDE: enable('claude') ? '1' : '0',
@@ -2075,11 +2086,8 @@ async function install(root, opts = {}) {
2075
2086
  LEERNESS_ENABLE_GOOSE: enable('goose') ? '1' : '0',
2076
2087
  LEERNESS_ENABLE_COPILOT: enable('copilot') ? '1' : '0',
2077
2088
  LEERNESS_ENABLE_OLLAMA: enable('ollama') ? '1' : '0',
2078
- LEERNESS_SKILL_DISCOVER_URL: '',
2079
- LEERNESS_SKILL_AUTO_DISCOVER: '0',
2080
- LEERNESS_SKILL_AUTO_INSTALL: '0',
2081
- LEERNESS_SKILL_AUTO_PRESETS: 'vercel,anthropic'
2082
2089
  });
2090
+ _writeLeernessConfig(root, leernessConfig);
2083
2091
  } catch (e) {
2084
2092
  warn(`.leerness/leerness-config.json 생성 실패 (계속 진행): ${e.message}`);
2085
2093
  }
@@ -4340,7 +4348,7 @@ function _selfTestCases() {
4340
4348
  const s = read(__filename);
4341
4349
  const forceGuard = s.includes("const effForce = opts.force && !_USER_STATE.has(f);") && s.includes("'.leerness/progress-tracker.md', '.leerness/plan.md', '.leerness/task-log.md'");
4342
4350
  const skillGuard = s.includes('유효하지 않은 skill name (path traversal/경로 문자 차단)') && s.includes('이미 설치된 skill: ${skillId} — 내용이 다릅니다');
4343
- const settingsGuard = s.includes('settings.local.json 이 손상돼(JSON 파싱 실패) hook 설치를 중단');
4351
+ const settingsGuard = s.includes('settings.local.json 이 손상돼(JSON 파싱/형상 실패) hook 설치를 중단');
4344
4352
  // Workspace migration fails closed on links/junctions; it never follows
4345
4353
  // them and never reports a partial migration as success.
4346
4354
  let symlinkGuard = false;
@@ -4779,8 +4787,10 @@ function _selfTestCases() {
4779
4787
  const jsonContract = hookBody.includes("hookEventName: 'SessionStart'") && hookBody.includes('additionalContext');
4780
4788
  const optOut = hookBody.includes("has('--no-context-inject')") && hookBody.includes("LEERNESS_NO_CONTEXT_INJECT");
4781
4789
  const selfLabel = hookBody.includes('위는 요약'); // 1.9.272 투명성: 요약임을 자기 라벨링
4782
- const matcherCompact = s.includes("{ matcher: 'startup|clear|compact', command: 'leerness hook session-start' }"); // 실측 결함(compaction) 커버
4783
- const idempotent = s.includes("some(h => h.command && h.command.includes('leerness hook session-start'))");
4790
+ const matcherCompact = _AUTO_UPDATE_CONTEXT_HOOK.matcher === 'startup|clear|compact'
4791
+ && _AUTO_UPDATE_CONTEXT_HOOK.command === 'leerness hook session-start'; // 실측 결함(compaction) 커버
4792
+ const idempotent = _exactHook({ matcher: 'startup|clear|compact', command: 'leerness hook session-start' }, _AUTO_UPDATE_CONTEXT_HOOK)
4793
+ && !_exactHook({ matcher: 'startup', command: 'echo leerness hook session-start' }, _AUTO_UPDATE_CONTEXT_HOOK);
4784
4794
  return surface && zeroWrite && noHandoffCall && jsonContract && optOut && selfLabel && matcherCompact && idempotent;
4785
4795
  } },
4786
4796
  { name: '경로/EOL/형태 견고성 4종 (1.36.21, 전수 sweep P2): handoff·env detect 없는경로 가드 + CRLF plan tasks + migrate 오타설치 차단 + user-requests 미인식 쓰기차단 — 소스가드 + 순수 행위', run: () => {
@@ -4956,7 +4966,35 @@ function _selfTestCases() {
4956
4966
  { name: 'CV-1/UR-0076: arg() --path=값 파싱 + _resolveRoot(--path>positional>cwd) 행위', run: () => { if (typeof _resolveRoot !== 'function') return false; const save = process.argv; try { process.argv = ['node', 'h', 'context', '--path=/tmp/eqform']; const eq = arg('--path', null) === '/tmp/eqform'; process.argv = ['node', 'h', 'context', 'X', '--path', '/tmp/flag']; const flagWins = _resolveRoot('X') === '/tmp/flag'; process.argv = ['node', 'h', 'context', '/tmp/pos']; const posWins = _resolveRoot('/tmp/pos') === '/tmp/pos'; process.argv = ['node', 'h', 'context']; const cwdFb = _resolveRoot(undefined) === process.cwd(); return eq && flagWins && posWins && cwdFb; } finally { process.argv = save; } } },
4957
4967
  { name: 'CV-4/UR-0079: _pruneArchives archive retention (최신 keep 유지, 오래된 prune) 행위', run: () => { if (typeof _pruneArchives !== 'function') return false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_prune_')); try { const adir = path.join(tmp, '.leerness', 'archive'); fs.mkdirSync(adir, { recursive: true }); for (let i = 0; i < 5; i++) fs.mkdirSync(path.join(adir, 'leerness-1.9.' + i + '-stamp')); const pruned = _pruneArchives(tmp, 2); const left = fs.readdirSync(adir).filter(n => /^leerness-/.test(n)).length; return pruned === 3 && left === 2; } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } } },
4958
4968
  { name: 'CV-7/UR-0082: commands 카탈로그 + help 에 누락 명령군 등재 (표면 drift 가드)', run: () => { const src = read(__filename); const ci = src.indexOf('function commandsCmd'); const hi = src.indexOf('function help('); if (ci < 0 || hi < 0) return false; const cbody = src.slice(ci, ci + 8000); const hbody = src.slice(hi, hi + 7000); const must = ['verify-code', 'contract verify', 'install-safety', 'feature add', 'creds list', 'incident list', 'webhook serve', 'deploy auto', 'runs list', 'permissions list', 'whats-new', 'migrate audit']; return must.every(c => cbody.includes(c)) && hbody.includes('install-safety') && hbody.includes('feature add'); } },
4959
- { name: 'UR-0083(4th외부평가 9.3): auto-update hook 비침투 (update --quiet 모드 + hook --check --quiet + 업그레이드)', run: () => { const src = read(__filename); const quietMode = /const quiet = !!opts\.quiet \|\| has\('--quiet'\)/.test(src); const hookQuiet = src.includes("command: 'leerness update --check --quiet'"); const upgrade = /includes\('leerness update --check'\) && !h\.command\.includes\('--quiet'\)/.test(src); return quietMode && hookQuiet && upgrade; } },
4969
+ { name: 'UR-0083(4th외부평가 9.3): auto-update hook 비침투 (update --quiet 모드 + hook --check --quiet + 업그레이드)', run: () => {
4970
+ const src = read(__filename);
4971
+ const quietMode = /const quiet = !!opts\.quiet \|\| has\('--quiet'\)/.test(src);
4972
+ const exactness = _exactHook({ matcher: '*', command: 'leerness update --check --quiet' }, _AUTO_UPDATE_CHECK_HOOK)
4973
+ && !_exactHook({ matcher: '*', command: 'echo leerness update --check --quiet' }, _AUTO_UPDATE_CHECK_HOOK);
4974
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_auto_update_self_'));
4975
+ try {
4976
+ mkdirp(path.join(tmp, '.claude'));
4977
+ writeUtf8(path.join(tmp, '.claude', 'settings.local.json'), JSON.stringify({ hooks: { SessionStart: [
4978
+ { matcher: '*', command: 'leerness update --check' },
4979
+ ] } }, null, 2) + '\n');
4980
+ const installed = cp.spawnSync(process.execPath, [__filename, 'auto-update', 'install', tmp], { encoding: 'utf8', timeout: 20000 });
4981
+ const settings = JSON.parse(read(path.join(tmp, '.claude', 'settings.local.json')));
4982
+ const hooks = settings.hooks.SessionStart || [];
4983
+ const upgraded = installed.status === 0
4984
+ && !hooks.some(h => h && h.command === 'leerness update --check')
4985
+ && hooks.some(h => _exactHook(h, _AUTO_UPDATE_CHECK_HOOK))
4986
+ && hooks.some(h => _exactHook(h, _AUTO_UPDATE_CONTEXT_HOOK))
4987
+ && _validUpdateSlashCommand(path.join(tmp, '.claude', 'commands', 'update.md'));
4988
+ const settingsFile = path.join(tmp, '.claude', 'settings.local.json');
4989
+ const malformedBytes = '{"hooks":{"SessionStart":[42]}}';
4990
+ writeUtf8(settingsFile, malformedBytes);
4991
+ const malformedStatus = cp.spawnSync(process.execPath, [__filename, 'auto-update', 'status', tmp, '--json'], { encoding: 'utf8', timeout: 20000 });
4992
+ const malformedInstall = cp.spawnSync(process.execPath, [__filename, 'auto-update', 'install', tmp], { encoding: 'utf8', timeout: 20000 });
4993
+ const malformedRejected = malformedStatus.status !== 0 && malformedInstall.status !== 0
4994
+ && read(settingsFile) === malformedBytes;
4995
+ return quietMode && exactness && upgraded && malformedRejected;
4996
+ } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
4997
+ } },
4960
4998
  { name: 'CV-6/UR-0081: 시크릿 스캐너 FP/FN — _isPlaceholderSecret + _looksSecretLike 행위', run: () => { if (typeof _isPlaceholderSecret !== 'function' || typeof _looksSecretLike !== 'function') return false; const fp = _isPlaceholderSecret('change-me') && _isPlaceholderSecret('your-api-key-here') && _isPlaceholderSecret('<token>') && _isPlaceholderSecret('') && !_isPlaceholderSecret('hunter2realpass'); const fn = _looksSecretLike('secret123') && _looksSecretLike('a'.repeat(24)) && !_looksSecretLike('processEnv') && !_looksSecretLike('reqBodyPassword'); return fp && fn; } },
4961
4999
  { name: 'UR-0025: _mergeLines/_mergeEnvLines 순수 코어 모듈 분리 + 행위 (1.9.367)', run: () => { if (typeof _mergeLines !== 'function' || typeof _mergeEnvLines !== 'function') return false; const m = require('../lib/pure-utils'); const moved = m._mergeLines === _mergeLines && m._mergeEnvLines === _mergeEnvLines; const ml = _mergeLines('a\n', ['a', 'b']) === 'a\nb\n'; const meKeep = _mergeEnvLines('FOO=keep\n', ['FOO=new']) === 'FOO=keep\n'; const meAdd = _mergeEnvLines('FOO=keep\n', ['BAR=add']).includes('BAR=add'); return moved && ml && meKeep && meAdd; } },
4962
5000
  { name: 'UR-0025: _mergeReadmeSection/_managedMerge + MERGE_OVERWRITE_FILES 모듈 분리 + 행위 (1.9.368)', run: () => { const m = require('../lib/pure-utils'); const c = require('../lib/catalogs'); if (typeof _mergeReadmeSection !== 'function' || typeof _managedMerge !== 'function') return false; const moved = m._mergeReadmeSection === _mergeReadmeSection && m._managedMerge === _managedMerge && MERGE_OVERWRITE_FILES === c.MERGE_OVERWRITE_FILES; const rd = _mergeReadmeSection('', 'BLOCK', '<s>', '<e>') === '# Project\n\nBLOCK\n'; const mm = _managedMerge('a.md', 'NEW', 'OLD', '.h', new Set()).includes('migration-preserved'); const ow = _managedMerge('.leerness/manifest.json', 'NEW', 'OLD', '.h', c.MERGE_OVERWRITE_FILES) === 'NEW'; const same = _managedMerge('a.md', 'X', 'X', '.h', new Set()) === 'X'; return moved && rd && mm && ow && same; } },
@@ -6696,7 +6734,7 @@ function _selfTestCases() {
6696
6734
  // 수신 변수 매칭에 단어 경계는 필수 — 없으면 `s.includes` 가 `tps.includes` 에 걸려 오탐이 난다(자체 실측).
6697
6735
  const litRe = /(\w+)\.includes\((['"`])((?:\\.|(?!\2)[\s\S])*?)\2\)/g;
6698
6736
  const codeish = (L) => /[(){}=;]|=>|\|\||&&/.test(L) && /[a-zA-Z_$]{3,}/.test(L);
6699
- let offenders = 0;
6737
+ const offenders = [];
6700
6738
  for (const body of chunks) {
6701
6739
  if (body.includes(MARK)) continue; // 이 메타가드 자신 (표식은 소스에 그대로 존재)
6702
6740
  if (!/read\(__filename\)/.test(body)) continue;
@@ -6711,13 +6749,18 @@ function _selfTestCases() {
6711
6749
  // (검수 High#2) 1차판이 따옴표까지 싸잡아 제외해 16개 가드가 무방비가 됐다 — 실측 우회 재현됨.
6712
6750
  if (lm[2] === '`' && L.includes('${')) continue;
6713
6751
  const haystack = binVars.has(lm[1]) ? binOutside : outside;
6714
- if (!haystack.includes(L)) { offenders++; break; }
6752
+ if (!haystack.includes(L)) {
6753
+ const caseName = (/\{ name: ['"]([^'"]+)/.exec(body) || [])[1] || 'unknown selftest';
6754
+ offenders.push(`${caseName}: ${L.slice(0, 120)}`);
6755
+ break;
6756
+ }
6715
6757
  }
6716
6758
  }
6717
6759
  // 1.36.83: 잔여 부채 **0** — 1.36.82 가 baseline 12 로 유예했던 것을 이 라운드에서 전부 수리했다
6718
6760
  // (각 가드는 지키던 동작을 실제로 깨뜨렸을 때 실패함을 변이로 증명). 이제 하나라도 생기면 즉시 실패한다.
6719
6761
  // 유예 수치를 남겨두면 그 안에서 조용히 썩으므로, 갚은 뒤에는 반드시 0 으로 조인다.
6720
- return offenders === 0;
6762
+ if (offenders.length) throw new Error(`stale source guard: ${offenders.join(' | ')}`);
6763
+ return true;
6721
6764
  } },
6722
6765
  { name: '동봉 문서의 낡을 수치 주장 금지 (1.36.82): README/docs 의 MCP 도구·selftest 케이스 수가 실측과 불일치 시 실패 (메타가드)', run: () => {
6723
6766
  // 사고: docs/interoperability.md 가 "86 도구"(3릴리스 낡음), README.ko.md 가 "selftest 210 케이스"(실제 335) 를
@@ -8937,8 +8980,10 @@ async function apiSkillCmd(root, sub) {
8937
8980
  while (i < argv.length) {
8938
8981
  const a = argv[i];
8939
8982
  if (a.startsWith('--')) {
8940
- // --key value 패턴이면 value 도 skip
8941
- if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) i += 2;
8983
+ // 값 플래그만 다음 token을 소비한다. 종전에는 `--no-crawl <url>`의
8984
+ // boolean flag가 URL까지 삼켜 위치에 따라 missing_args가 나왔다.
8985
+ const name = a.split('=')[0];
8986
+ if (_VALUE_FLAGS.has(name) && !a.includes('=') && i + 1 < argv.length && !argv[i + 1].startsWith('--')) i += 2;
8942
8987
  else i += 1;
8943
8988
  continue;
8944
8989
  }
@@ -8990,7 +9035,9 @@ async function apiSkillCmd(root, sub) {
8990
9035
  _hlog(cy(`# leerness api-skill add (1.9.245, UR-0015)`));
8991
9036
  _hlog(` URL: ${url}`);
8992
9037
  if (direction) _hlog(` 방향: ${direction}`);
8993
- _hlog(dm(` Fetch + crawl (depth=1, same-domain, max 10)...`));
9038
+ _hlog(dm(noCrawl
9039
+ ? ` Fetch only (--no-crawl: related links are not requested)...`
9040
+ : ` Fetch + crawl (depth=1, same-domain, max 10)...`));
8994
9041
  try {
8995
9042
  const doc = await _collectAPIDoc(url, { direction, noCrawl });
8996
9043
  const skillName = name || doc.title || new URL(url).hostname;
@@ -9011,7 +9058,7 @@ async function apiSkillCmd(root, sub) {
9011
9058
  _hlog(dm(` title: ${doc.title || '(none)'}`));
9012
9059
  _hlog(dm(` related links: ${(doc.related_links || []).length}`));
9013
9060
  _hlog(dm(` text size: ${doc.text.length} chars`));
9014
- if (_j) log(JSON.stringify({ ok: true, id, name: skillName, url, related_count: (doc.related_links || []).length }, null, 2));
9061
+ if (_j) log(JSON.stringify({ ok: true, id, name: skillName, url, crawl: !noCrawl, related_count: (doc.related_links || []).length }, null, 2));
9015
9062
  } catch (e) {
9016
9063
  _hlog(rd(`✗ Fetch 실패: ${e.message}`));
9017
9064
  // 1.9.245: --skeleton fallback — Cloudflare/WAF 차단된 URL 도 빈 .md 골격 생성 → 사용자가 수동 채움
@@ -9038,9 +9085,9 @@ async function apiSkillCmd(root, sub) {
9038
9085
  _hlog(yl(`⚠ 빈 골격 생성 (--skeleton fallback): .leerness/api-skills/${id}.md`));
9039
9086
  _hlog(dm(` → 직접 ${url} 내용을 복사해서 .md 파일에 채워넣어주세요`));
9040
9087
  _hlog(dm(` → 이후 leerness handoff 가 매칭 시 참조됩니다`));
9041
- if (_j) log(JSON.stringify({ ok: true, id, skeleton: true, url, name: skillName, fetchError: e.message }, null, 2));
9088
+ if (_j) log(JSON.stringify({ ok: true, id, skeleton: true, url, name: skillName, crawl: !noCrawl, related_count: 0, fetchError: e.message }, null, 2));
9042
9089
  } else {
9043
- if (_j) log(JSON.stringify({ ok: false, code: 'fetch_failed', error: e.message, url }, null, 2)); // 실패도 구조화(종전: --json 에 사람용 텍스트만)
9090
+ if (_j) log(JSON.stringify({ ok: false, code: 'fetch_failed', error: e.message, url, crawl: !noCrawl }, null, 2)); // 실패도 구조화(종전: --json 에 사람용 텍스트만)
9044
9091
  process.exitCode = 1;
9045
9092
  }
9046
9093
  }
@@ -10619,7 +10666,37 @@ function _p0103JsonOk() {
10619
10666
  // ④ --json 이 아예 구현되지 않아 사람용 텍스트를 내던 표면(게시본 3/30) — 계약 스윕
10620
10667
  const sweepBad = [['consistency', 'check', '--json'], ['agent-mode', 'tick', '--json'], ['graph', '--json']]
10621
10668
  .filter(a => !parse(R(a)));
10622
- return failuresOk && successOk && keepOk && sweepBad.length === 0;
10669
+ // ⑤ Memory DELETE 성공 경로도 같은 계약을 지킨다. 종전에는 mutation은 끝냈지만
10670
+ // stdout은 비고 사람용 ✓ 문구만 stderr에 남아 JSON 소비자가 성공할 때만 깨졌다(T-0093).
10671
+ const taskAdded = parse(R(['task', 'add', 'json-delete-task', '--json']));
10672
+ R(['decision', 'add', 'json-delete-decision']);
10673
+ R(['lesson', 'save', 'json-delete-lesson']);
10674
+ const ruleAdded = parse(R(['rule', 'add', 'json-delete-rule', '--trigger', 'every-session', '--json']));
10675
+ R(['plan', 'add', 'json-delete-plan']);
10676
+ if (!taskAdded?.id || !ruleAdded?.id) return false;
10677
+ fs.mkdirSync(path.join(proj, '.leerness', 'cache'), { recursive: true });
10678
+ fs.writeFileSync(path.join(proj, '.leerness', 'cache', 'auto-roadmap.json'), JSON.stringify({ enabled: true, onEveryChange: true }));
10679
+ const liveEnv = Object.assign({}, process.env, { LEERNESS_OFFLINE: '1' });
10680
+ delete liveEnv.LEERNESS_NO_AUTO_ROADMAP;
10681
+ const RLive = (a) => cp2.spawnSync(process.execPath, [__filename, ...a], { cwd: proj, encoding: 'utf8', timeout: 180000, env: liveEnv });
10682
+ const deleteArgs = [
10683
+ ['task', 'drop', taskAdded.id, '--json'],
10684
+ ['decision', 'drop', 'json-delete-decision', '--json'],
10685
+ ['lesson', 'drop', 'json-delete-lesson', '--json'],
10686
+ ['rule', 'remove', ruleAdded.id, '--json'],
10687
+ ['plan', 'remove', 'json-delete-plan', '--json'],
10688
+ ];
10689
+ const graphPath = path.join(proj, 'leerness.html');
10690
+ const deleteResults = [];
10691
+ let roadmapOk = true;
10692
+ for (const args of deleteArgs) {
10693
+ fs.rmSync(graphPath, { force: true });
10694
+ deleteResults.push(RLive(args));
10695
+ roadmapOk = roadmapOk && fs.existsSync(graphPath);
10696
+ }
10697
+ const deleteOk = deleteResults.every(r => r.status === 0 && !(r.stderr || '').trim() && parse(r)?.ok === true)
10698
+ && roadmapOk;
10699
+ return failuresOk && successOk && keepOk && sweepBad.length === 0 && deleteOk;
10623
10700
  } catch { return false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
10624
10701
  }
10625
10702
  // 1.36.103: 값-플래그 등록 완전성 + 오타 가시성 회귀 가드.
@@ -11933,7 +12010,7 @@ function _inferScopeExpansion(text, root) {
11933
12010
  result.expansionCandidates = [];
11934
12011
  result.note = `intent: precise — 명시 요청만 진행, AI 추론 확장 비활성 (사용자 의도 보호)`;
11935
12012
  } else if (classify.intent === 'broad') {
11936
- result.note = `intent: broad — ${result.expansionCandidates.length} 확장 후보 제시 (--expand-all 또는 --select N 으로 명시 승인 필요)`;
12013
+ result.note = `intent: broad — ${result.expansionCandidates.length} 확장 후보 제시(dry-run); 선택한 후보는 leerness task add 로 별도 등록`;
11937
12014
  } else {
11938
12015
  result.note = `intent: default — 명시 ${result.explicitMentions.length}건만 우선, 확장 후보 ${result.expansionCandidates.length}건 검토용`;
11939
12016
  }
@@ -14688,9 +14765,9 @@ function planProgress(root, opts = {}) {
14688
14765
  // Memory Surface DELETE 5종 완전 완성 (task drop / decision drop / lesson drop / rule remove / plan remove)
14689
14766
  function planRemoveCmd(root, target) {
14690
14767
  root = absRoot(root);
14691
- if (!target) return fail('plan remove <M-XXXX|title-substring> 필요 — 매칭되는 milestone 블록을 제거하고 .leerness/plan.archive.md에 보존');
14768
+ if (!target) return failJson(has('--json'), 'missing_target', 'plan remove <M-XXXX|title-substring> 필요 — 매칭되는 milestone 블록을 제거하고 .leerness/plan.archive.md에 보존');
14692
14769
  const pp = planPath(root);
14693
- if (!exists(pp)) return fail('plan.md 없음');
14770
+ if (!exists(pp)) return failJson(has('--json'), 'plan_not_found', 'plan.md 없음');
14694
14771
  let removed = 0;
14695
14772
  let disappeared = false;
14696
14773
  // 1.36.152 (T-0115): plan restore/add와 archive를 공유한다. 락 안에서 다시 읽어야 새 milestone을 덮지 않는다.
@@ -14730,8 +14807,13 @@ function planRemoveCmd(root, target) {
14730
14807
  if (removed === 0) return;
14731
14808
  writeUtf8(pp, kept.join('\n'));
14732
14809
  });
14733
- if (disappeared) return fail('plan.md 없음');
14810
+ if (disappeared) return failJson(has('--json'), 'plan_not_found', 'plan.md 없음');
14734
14811
  if (removed === 0) return failJson(has('--json'), 'milestone_not_found', `매칭 milestone 없음: "${target}"`); // codex P2: --json 에러 구조화
14812
+ if (has('--json')) {
14813
+ _autoRoadmap(absRoot(root), 'data-change', { quiet: true });
14814
+ log(JSON.stringify({ ok: true, surface: 'plan', action: 'remove', target, removedCount: removed, archive: '.leerness/plan.archive.md' }, null, 2));
14815
+ return;
14816
+ }
14735
14817
  ok(`milestone removed: ${removed}건 (보존: .leerness/plan.archive.md)`);
14736
14818
  _autoRoadmap(absRoot(root), 'data-change');
14737
14819
  }
@@ -14997,10 +15079,12 @@ const _STRICT_COMMAND_FLAGS = {
14997
15079
  'verify-claim baseline show': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'verify-claim baseline show [--json]' },
14998
15080
  'verify-code': { allowed: ['--build', '--bench', '--strict'], globals: [..._COMMON_COMMAND_FLAGS], usage: 'verify-code [path] [--build] [--bench] [--strict]' },
14999
15081
  'contract verify': { allowed: ['--allow-empty'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'contract verify <spec.md> <impl.js> [--allow-empty] [--json]' },
15082
+ 'intent expand': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'intent expand "<request>" [--json]' },
15000
15083
  };
15001
15084
  function _validateCommandFlags(cmd, args) {
15002
15085
  let route = cmd === 'contract' && args[1] === 'verify' ? 'contract verify' : cmd;
15003
15086
  if (cmd === 'verify-claim' && args[1] === 'baseline') route = `verify-claim baseline ${args[2] || ''}`.trim();
15087
+ if (cmd === 'intent' && args[1] === 'expand') route = 'intent expand';
15004
15088
  const cfg = _STRICT_COMMAND_FLAGS[route];
15005
15089
  if (!cfg) return true;
15006
15090
  return _rejectUnknownFlags(cfg.allowed, cfg.usage, { globals: cfg.globals });
@@ -15065,12 +15149,19 @@ function taskUpdate(root, id) {
15065
15149
  }
15066
15150
  function taskDrop(root, id) {
15067
15151
  if (!_requireInit(root, 'task drop')) return; // 1.9.396 (6번째 외부평가/codex P1-B): init 가드
15068
- if (!id) return fail('id required');
15152
+ if (!id) return failJson(has('--json'), 'missing_id', 'id required');
15069
15153
  const rows = readProgressRows(root);
15070
15154
  // 1.9.396 (6번째 외부평가/codex P1-B): 없는 task drop 시 가짜 row(request undefined) 생성 = 데이터 손상 → task update 와 동일하게 존재 확인 후 fail(no-op).
15071
15155
  if (!rows.find(r => r.id === id)) { failJson(has('--json'), 'task_not_found', `task ${id} not found in progress-tracker.md`); return; } // codex P2: --json 에러 구조화
15072
15156
  const _tdEn = _uiLang(root) === 'en'; // 1.36.63 (검수 #2)
15073
- upsertProgress(root, { id, status: 'dropped', evidence: arg('--reason', _tdEn ? 'dropped by user request' : '사용자 요청으로 제외'), nextAction: _tdEn ? 'none' : '없음' });
15157
+ const evidence = arg('--reason', _tdEn ? 'dropped by user request' : '사용자 요청으로 제외');
15158
+ const nextAction = _tdEn ? 'none' : '없음';
15159
+ upsertProgress(root, { id, status: 'dropped', evidence, nextAction });
15160
+ if (has('--json')) {
15161
+ _autoRoadmap(absRoot(root), 'data-change', { quiet: true });
15162
+ log(JSON.stringify({ ok: true, surface: 'tasks', action: 'drop', id, status: 'dropped', evidence, nextAction }, null, 2));
15163
+ return;
15164
+ }
15074
15165
  ok(`task dropped: ${id}`);
15075
15166
  _autoRoadmap(absRoot(root), 'data-change');
15076
15167
  }
@@ -15385,7 +15476,7 @@ function lessonDropCmd(root, target) {
15385
15476
  // 1.36.140 (재검수 P3): 락/쓰기 헬퍼가 부모를 만들기 때문에, 오타 난 `--path` 로 실패하면서도
15386
15477
  // 그 경로와 `.leerness/` 를 만들었다(1.36.139 는 안 만들었다 — 대조군). 없으면 아무것도 만들지 않는다.
15387
15478
  if (!exists(root)) return failJson(has('--json'), 'path_not_found', `경로가 없습니다: ${root} — 디렉토리를 새로 만들지 않았습니다`);
15388
- if (!target) return fail('lesson drop <date|text-substring> 필요. 예: leerness lesson drop "2026-05-20" 또는 leerness lesson drop "JWT"');
15479
+ if (!target) return failJson(has('--json'), 'missing_target', 'lesson drop <date|text-substring> 필요. 예: leerness lesson drop "2026-05-20" 또는 leerness lesson drop "JWT"');
15389
15480
  // archive 보존 — lessons.archive.md 에 projection MD 블록 형태로 추가
15390
15481
  const archivePath = path.join(root, '.leerness/lessons.archive.md');
15391
15482
  // 1.36.140 (재검수 P1, 재현): 종전엔 락 **밖**에서 매칭한 뒤 락 안에서 `${date}|${text}` 복합 키로
@@ -15417,6 +15508,11 @@ function lessonDropCmd(root, target) {
15417
15508
  });
15418
15509
  if (loadErr === 'no_lessons') return failJson(has('--json'), 'no_lessons', 'lessons 없음'); // codex P2: --json 에러 구조화
15419
15510
  if (loadErr === 'lesson_not_found' || !removed.length) return failJson(has('--json'), 'lesson_not_found', `매칭 lesson 없음: "${target}"`);
15511
+ if (has('--json')) {
15512
+ _autoRoadmap(absRoot(root), 'data-change', { quiet: true });
15513
+ log(JSON.stringify({ ok: true, surface: 'lessons', action: 'drop', target, removedCount: removed.length, archive: '.leerness/lessons.archive.md' }, null, 2));
15514
+ return;
15515
+ }
15420
15516
  ok(`lesson dropped: ${removed.length}건 (보존: .leerness/lessons.archive.md)`);
15421
15517
  _autoRoadmap(absRoot(root), 'data-change');
15422
15518
  }
@@ -15494,7 +15590,7 @@ function decisionDropCmd(root, target) {
15494
15590
  // 1.36.140 (재검수 P3): 락/쓰기 헬퍼가 부모를 만들기 때문에, 오타 난 `--path` 로 실패하면서도
15495
15591
  // 그 경로와 `.leerness/` 를 만들었다(1.36.139 는 안 만들었다 — 대조군). 없으면 아무것도 만들지 않는다.
15496
15592
  if (!exists(root)) return failJson(has('--json'), 'path_not_found', `경로가 없습니다: ${root} — 디렉토리를 새로 만들지 않았습니다`);
15497
- if (!target) return fail('decision drop <date|title-substring> 필요. 예: leerness decision drop "2026-05-20" 또는 leerness decision drop "PostgreSQL"');
15593
+ if (!target) return failJson(has('--json'), 'missing_target', 'decision drop <date|title-substring> 필요. 예: leerness decision drop "2026-05-20" 또는 leerness decision drop "PostgreSQL"');
15498
15594
  // 1.9.339 (UR-0053): canonical JSON 기준 drop (date 또는 title substring 매칭) — JSON+MD projection 동시 갱신.
15499
15595
  const archivePath = path.join(root, '.leerness/decisions.archive.md');
15500
15596
  // 1.36.138 (동시성 감사 실측): `decision add` 는 락 안인데 **drop 은 락 밖**이었다 —
@@ -15527,6 +15623,11 @@ function decisionDropCmd(root, target) {
15527
15623
  });
15528
15624
  if (loadErr === 'no_decisions') return failJson(has('--json'), 'no_decisions', 'decisions 없음'); // codex P2: --json 에러 구조화
15529
15625
  if (loadErr === 'decision_not_found' || !removed.length) return failJson(has('--json'), 'decision_not_found', `매칭 decision 없음: "${target}"`);
15626
+ if (has('--json')) {
15627
+ _autoRoadmap(absRoot(root), 'data-change', { quiet: true });
15628
+ log(JSON.stringify({ ok: true, surface: 'decisions', action: 'drop', target, removedCount: removed.length, archive: '.leerness/decisions.archive.md' }, null, 2));
15629
+ return;
15630
+ }
15530
15631
  ok(`decision dropped: ${removed.length}건 (보존: .leerness/decisions.archive.md)`);
15531
15632
  _autoRoadmap(absRoot(root), 'data-change');
15532
15633
  }
@@ -20241,6 +20342,7 @@ function _allProviders(root) {
20241
20342
  versionArgs: _uvaOk ? _uva : _fallbackVa,
20242
20343
  ...((_uva && !_uvaOk) ? { versionArgsRejectedReason: `providers.json 의 versionArgs 에 셸 메타문자가 있어 실행하지 않았습니다: ${JSON.stringify(_uva).slice(0, 120)}` } : {}),
20243
20344
  desc: u.desc || (_bi && _bi.desc) || `(user) ${u.id}`,
20345
+ descEn: u.desc || (_bi && _bi.descEn) || (_bi && _bi.desc) || `(user) ${u.id}`,
20244
20346
  installHint: u.installHint || (_bi && _bi.installHint) || '',
20245
20347
  installCmd: u.installCmd || (_bi && _bi.installCmd) || '',
20246
20348
  ...(_inherited ? { authCheck: _inherited } : {}),
@@ -20277,22 +20379,36 @@ function providerCmd(root, sub, ...args) {
20277
20379
  total: all.length,
20278
20380
  builtin: EXTERNAL_AGENTS.length,
20279
20381
  user: userList.length,
20280
- providers: all.map(p => ({ id: p.id, bin: p.bin, envFlag: p.envFlag, source: userIds.has(p.id) ? 'user' : 'builtin', desc: p.desc }))
20382
+ providers: all.map(p => ({
20383
+ id: p.id,
20384
+ bin: p.bin,
20385
+ envFlag: p.envFlag,
20386
+ versionArgs: Array.isArray(p.versionArgs) ? p.versionArgs.slice() : [],
20387
+ desc: p.desc || '',
20388
+ installHint: p.installHint || '',
20389
+ source: userIds.has(p.id) ? 'user' : 'builtin'
20390
+ }))
20281
20391
  }, null, 2));
20282
20392
  return;
20283
20393
  }
20394
+ const en = _uiLang(root) === 'en';
20395
+ const cell = v => String(v == null ? '' : v).replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
20284
20396
  log(`# leerness provider list (1.9.157)`);
20285
- log(`총 ${all.length}개 (빌트인 ${EXTERNAL_AGENTS.length} + 사용자 ${userList.length})`);
20397
+ log(en
20398
+ ? `Total ${all.length} (${EXTERNAL_AGENTS.length} built-in + ${userList.length} user-defined)`
20399
+ : `총 ${all.length}개 (빌트인 ${EXTERNAL_AGENTS.length} + 사용자 ${userList.length})`);
20286
20400
  log('');
20287
- log(`| id | source | bin | envFlag |`);
20288
- log(`|---|---|---|---|`);
20401
+ log(`| id | source | bin | envFlag | versionArgs | description |`);
20402
+ log(`|---|---|---|---|---|---|`);
20289
20403
  for (const p of all) {
20290
20404
  const src = userIds.has(p.id) ? (EXTERNAL_AGENTS.some(b => b.id === p.id) ? 'user(override)' : 'user') : 'builtin';
20291
- log(`| ${p.id} | ${src} | ${p.bin} | ${p.envFlag} |`);
20405
+ log(`| ${cell(p.id)} | ${src} | ${cell(p.bin)} | ${cell(p.envFlag)} | ${cell((p.versionArgs || []).join(' '))} | ${cell(en ? (p.descEn || p.desc) : p.desc)} |`);
20292
20406
  }
20293
20407
  if (!userList.length) {
20294
20408
  log('');
20295
- log(`💡 사용자 정의 provider 추가: leerness provider add <id> --bin <cmd> [--env-flag F] [--version-args ARGS] [--desc D]`);
20409
+ log(en
20410
+ ? `💡 Add a user-defined provider: leerness provider add <id> --bin <cmd> [--env-flag F] [--version-args ARGS] [--desc D]`
20411
+ : `💡 사용자 정의 provider 추가: leerness provider add <id> --bin <cmd> [--env-flag F] [--version-args ARGS] [--desc D]`);
20296
20412
  }
20297
20413
  return;
20298
20414
  }
@@ -21120,6 +21236,14 @@ function _tryInstallAgent(agent) {
21120
21236
  // 1.9.34: 방향키/스페이스 multi-select 도입 (LEERNESS_NO_INTERACTIVE=1 → 기존 yes/no 폴백)
21121
21237
  async function setupAgentsCmd(root, opts = {}) {
21122
21238
  root = absRoot(root || process.cwd());
21239
+ // T-0094: init 의 agent 선택을 건너뛰는 opt-out. standalone 에 붙어도
21240
+ // 플래그를 경로로 오독하거나 설정 화면을 띄우지 않는 명시적 no-op 으로 유지한다.
21241
+ if (has('--no-setup-agents')) {
21242
+ const payload = { ok: true, skipped: true, reason: 'no_setup_agents', root };
21243
+ if (has('--json')) log(JSON.stringify(payload, null, 2));
21244
+ else log(`⏭ 외부 AI CLI 설정 건너뜀 (--no-setup-agents): ${root}`);
21245
+ return payload;
21246
+ }
21123
21247
  _loadEnvFile(root);
21124
21248
  _loadEnvFile(path.join(root, '..'));
21125
21249
  const envPath = path.join(root, '.env');
@@ -22555,7 +22679,8 @@ function _autoRoadmapConfig(root) {
22555
22679
  function _saveAutoRoadmapConfig(root, cfg) {
22556
22680
  writeUtf8(_autoRoadmapConfigPath(root), JSON.stringify(cfg, null, 2) + '\n');
22557
22681
  }
22558
- function _autoRoadmap(root, trigger) {
22682
+ function _autoRoadmap(root, trigger, opts = {}) {
22683
+ const quiet = !!(opts && opts.quiet);
22559
22684
  try {
22560
22685
  if (process.env.LEERNESS_NO_AUTO_ROADMAP === '1') return false;
22561
22686
  if (!exists(path.join(root, '.leerness'))) return false;
@@ -22569,10 +22694,10 @@ function _autoRoadmap(root, trigger) {
22569
22694
  const outFile = path.resolve(cfg.outFile || path.join(root, 'leerness.html'));
22570
22695
  const s = _graph.graphHtmlCmd(root, { _roadmapData, _loadDecisions, _loadLessons, _parseFeatureGraph, _loadToggles: _tgl.loadToggles, _toggleRegistry: _tgl.TOGGLE_REGISTRY, _loadTechProfile: _tech.loadTechProfile, quiet: true }, outFile);
22571
22696
  const _en = _uiLang(root) === 'en';
22572
- log(_en ? `✓ ontology graph auto-updated (${trigger}) — ${rel(root, outFile)} (graph+roadmap+toggles)` : `✓ 온톨로지 그래프 자동 갱신 (${trigger}) — ${rel(root, outFile)} (그래프+로드맵+토글 탭)`);
22697
+ if (!quiet) log(_en ? `✓ ontology graph auto-updated (${trigger}) — ${rel(root, outFile)} (graph+roadmap+toggles)` : `✓ 온톨로지 그래프 자동 갱신 (${trigger}) — ${rel(root, outFile)} (그래프+로드맵+토글 탭)`);
22573
22698
  return true;
22574
22699
  } catch (e) {
22575
- warn((_uiLang(root) === 'en' ? 'roadmap auto-update failed: ' : 'roadmap 자동 갱신 실패: ') + (e && e.message ? e.message : e));
22700
+ if (!quiet) warn((_uiLang(root) === 'en' ? 'roadmap auto-update failed: ' : 'roadmap 자동 갱신 실패: ') + (e && e.message ? e.message : e));
22576
22701
  return false;
22577
22702
  }
22578
22703
  }
@@ -22850,7 +22975,7 @@ function _updateRules(root, mutate) {
22850
22975
 
22851
22976
  function ruleRemove(root, id) {
22852
22977
  root = absRoot(root);
22853
- if (!id) return fail('id required');
22978
+ if (!id) return failJson(has('--json'), 'missing_id', 'id required');
22854
22979
  let removed = null;
22855
22980
  const res = _updateRules(root, (rules) => {
22856
22981
  const i = rules.findIndex(r => r.id === id);
@@ -22863,7 +22988,13 @@ function ruleRemove(root, id) {
22863
22988
  return removed;
22864
22989
  });
22865
22990
  if (res.error) return failJson(has('--json'), 'rule_not_found', `rule not found: ${id}`); // codex P2: --json 에러 구조화
22991
+ if (has('--json')) {
22992
+ _autoRoadmap(root, 'data-change', { quiet: true });
22993
+ log(JSON.stringify({ ok: true, surface: 'rules', action: 'remove', id, removedCount: 1, archive: '.leerness/rules.archive.md' }, null, 2));
22994
+ return;
22995
+ }
22866
22996
  ok(`rule removed: ${id} (보존: .leerness/rules.archive.md)`);
22997
+ _autoRoadmap(root, 'data-change');
22867
22998
  }
22868
22999
 
22869
23000
  function rulePause(root, id) {
@@ -24522,6 +24653,47 @@ async function updateCmd(root, opts = {}) {
24522
24653
  ok('update complete');
24523
24654
  }
24524
24655
 
24656
+ const _AUTO_UPDATE_CHECK_HOOK = Object.freeze({ matcher: '*', command: 'leerness update --check --quiet' });
24657
+ const _AUTO_UPDATE_CONTEXT_HOOK = Object.freeze({ matcher: 'startup|clear|compact', command: 'leerness hook session-start' });
24658
+ function _exactHook(hook, expected) {
24659
+ return !!(hook && typeof hook.command === 'string' && typeof hook.matcher === 'string' &&
24660
+ hook.command.trim() === expected.command && hook.matcher.trim() === expected.matcher);
24661
+ }
24662
+ function _validUpdateSlashCommand(file) {
24663
+ if (!exists(file)) return false;
24664
+ try {
24665
+ const content = read(file);
24666
+ return /^\s*!leerness\s+update\s+--yes\s*$/m.test(content) &&
24667
+ /^\s*!leerness\s+update\s+--check\s*$/m.test(content);
24668
+ } catch { return false; }
24669
+ }
24670
+ function _validAutoUpdateSettingsShape(settings) {
24671
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return false;
24672
+ const own = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
24673
+ if (own(settings, 'hooks') && (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks))) return false;
24674
+ if (settings.hooks && own(settings.hooks, 'SessionStart')) {
24675
+ if (!Array.isArray(settings.hooks.SessionStart)) return false;
24676
+ // Preserve foreign/modern hook objects, but never treat scalar/array entries or
24677
+ // mistyped fields as a writable settings document. Otherwise install's filter
24678
+ // silently retains the bad entry and rewrites the user's file around it.
24679
+ const validHookEntry = hook => {
24680
+ if (!hook || typeof hook !== 'object' || Array.isArray(hook)) return false;
24681
+ if (own(hook, 'matcher') && typeof hook.matcher !== 'string') return false;
24682
+ if (own(hook, 'command') && typeof hook.command !== 'string') return false;
24683
+ if (own(hook, 'type') && typeof hook.type !== 'string') return false;
24684
+ if (own(hook, 'hooks')) {
24685
+ if (!Array.isArray(hook.hooks)) return false;
24686
+ if (!hook.hooks.every(nested => nested && typeof nested === 'object' && !Array.isArray(nested)
24687
+ && (!own(nested, 'command') || typeof nested.command === 'string')
24688
+ && (!own(nested, 'type') || typeof nested.type === 'string'))) return false;
24689
+ }
24690
+ return true;
24691
+ };
24692
+ if (!settings.hooks.SessionStart.every(validHookEntry)) return false;
24693
+ }
24694
+ return true;
24695
+ }
24696
+
24525
24697
  function autoUpdateInstall(root) {
24526
24698
  root = absRoot(root);
24527
24699
  const settingsDir = path.join(root, '.claude');
@@ -24531,25 +24703,28 @@ function autoUpdateInstall(root) {
24531
24703
  // 1.36.33 (codex 3차 #3, 1.36.28 손상-스토어 클래스): 손상 settings 를 빈 객체로 오인해 hooks-only 로 덮어쓰면
24532
24704
  // 사용자의 permissions 등 무관 설정이 유실된다. 파싱 실패 시 원본 보존 + 중단.
24533
24705
  if (exists(settingsFile)) {
24534
- try { settings = JSON.parse(read(settingsFile)); }
24535
- catch { fail(`.claude/settings.local.json 이 손상돼(JSON 파싱 실패) hook 설치를 중단합니다 — 파일을 복구한 뒤 재시도: ${settingsFile}`); process.exitCode = 1; return; }
24706
+ try {
24707
+ settings = JSON.parse(read(settingsFile));
24708
+ if (!_validAutoUpdateSettingsShape(settings)) throw new Error('settings schema invalid');
24709
+ } catch { fail(`.claude/settings.local.json 이 손상돼(JSON 파싱/형상 실패) hook 설치를 중단합니다 — 파일을 복구한 뒤 재시도: ${settingsFile}`); process.exitCode = 1; return; }
24536
24710
  }
24537
24711
  settings.hooks = settings.hooks || {};
24538
24712
  // 1.9.1 P1: legacy 'leerness-plus update' hook 자동 제거 (이전 fork 시절 잔재).
24539
24713
  let removedLegacy = 0, upgradedQuiet = 0;
24540
24714
  settings.hooks.SessionStart = (settings.hooks.SessionStart || []).filter(h => {
24541
- if (h && h.command && /\bleerness-plus update\b/.test(h.command)) { removedLegacy++; return false; }
24715
+ const command = h && typeof h.command === 'string' ? h.command.trim() : '';
24716
+ if (/^leerness-plus\s+update(?:\s|$)/.test(command)) { removedLegacy++; return false; }
24542
24717
  // 1.9.364 (4번째 외부평가 9.3): 비-quiet 'leerness update --check' 를 quiet 버전으로 업그레이드 (기존 설치 매세션 노이즈 제거)
24543
- if (h && h.command && h.command.includes('leerness update --check') && !h.command.includes('--quiet')) { upgradedQuiet++; return false; }
24718
+ if (command === 'leerness update --check') { upgradedQuiet++; return false; }
24544
24719
  return true;
24545
24720
  });
24546
- if (!settings.hooks.SessionStart.some(h => h.command && h.command.includes('leerness update'))) {
24547
- settings.hooks.SessionStart.push({ matcher: '*', command: 'leerness update --check --quiet' });
24721
+ if (!settings.hooks.SessionStart.some(h => _exactHook(h, _AUTO_UPDATE_CHECK_HOOK))) {
24722
+ settings.hooks.SessionStart.push({ ..._AUTO_UPDATE_CHECK_HOOK });
24548
24723
  }
24549
24724
  // 1.36.22: 2번째 SessionStart hook — 컨텍스트 주입. matcher 에 compact 를 포함하는 게 핵심(실측 결함: compaction/resume 후
24550
24725
  // 에이전트가 세션시작 handoff 의례를 건너뜀). 기존 설치는 이 멱등 push 로 자동 취득(1.9.364 업그레이드 패턴 재사용).
24551
- if (!settings.hooks.SessionStart.some(h => h.command && h.command.includes('leerness hook session-start'))) {
24552
- settings.hooks.SessionStart.push({ matcher: 'startup|clear|compact', command: 'leerness hook session-start' });
24726
+ if (!settings.hooks.SessionStart.some(h => _exactHook(h, _AUTO_UPDATE_CONTEXT_HOOK))) {
24727
+ settings.hooks.SessionStart.push({ ..._AUTO_UPDATE_CONTEXT_HOOK });
24553
24728
  }
24554
24729
  writeUtf8(settingsFile, JSON.stringify(settings, null, 2) + '\n');
24555
24730
  // 1.36.62 (검수 #1): en 프로젝트의 /update 를 한국어로 되덮던 것 — 언어 인지 렌더
@@ -24571,6 +24746,71 @@ function autoUpdateInstall(root) {
24571
24746
  log(` ⓘ 제거: ${rel(root, settingsFile)} 의 hooks.SessionStart 항목 삭제 · 설치 시 끄기: leerness init . --no-auto-update`);
24572
24747
  }
24573
24748
 
24749
+ // T-0094: help/command catalog 에 공개돼 있던 read-only 상태 표면. 설치 명령과 같은
24750
+ // 세 구성요소(update check hook, context hook, /update command)를 보고하며 파일은 만들거나 고치지 않는다.
24751
+ function autoUpdateStatus(root) {
24752
+ root = absRoot(root || process.cwd());
24753
+ if (exists(root) && !fs.statSync(root).isDirectory()) {
24754
+ const payload = { ok: false, code: 'path_not_directory', root, error: `프로젝트 경로가 디렉토리가 아닙니다: ${root}` };
24755
+ if (has('--json')) log(JSON.stringify(payload, null, 2));
24756
+ else fail(payload.error);
24757
+ process.exitCode = 1;
24758
+ return payload;
24759
+ }
24760
+ const settingsFile = path.join(root, '.claude', 'settings.local.json');
24761
+ const slashFile = path.join(root, '.claude', 'commands', 'update.md');
24762
+ const base = {
24763
+ ok: true,
24764
+ root,
24765
+ settingsFile,
24766
+ exists: exists(settingsFile),
24767
+ valid: true,
24768
+ installed: false,
24769
+ hooks: { updateCheck: false, updateQuiet: false, contextInjection: false },
24770
+ slashCommand: _validUpdateSlashCommand(slashFile),
24771
+ };
24772
+ if (!base.exists) {
24773
+ if (has('--json')) log(JSON.stringify(base, null, 2));
24774
+ else {
24775
+ log(`# leerness auto-update status`);
24776
+ log(` ⚪ 미설치 — ${settingsFile} 없음`);
24777
+ log(` 설치: leerness auto-update install ${root}`);
24778
+ }
24779
+ return base;
24780
+ }
24781
+ let settings;
24782
+ try {
24783
+ settings = JSON.parse(read(settingsFile));
24784
+ if (!_validAutoUpdateSettingsShape(settings)) throw new Error('settings schema invalid');
24785
+ }
24786
+ catch {
24787
+ const payload = { ...base, ok: false, valid: false, code: 'settings_corrupt', error: `.claude/settings.local.json JSON 파싱/형상 실패: ${settingsFile}` };
24788
+ if (has('--json')) log(JSON.stringify(payload, null, 2));
24789
+ else fail(`auto-update 상태 확인 실패 — settings JSON 손상(파일은 변경하지 않음): ${settingsFile}`);
24790
+ process.exitCode = 1;
24791
+ return payload;
24792
+ }
24793
+ const sessionStart = settings && settings.hooks && Array.isArray(settings.hooks.SessionStart)
24794
+ ? settings.hooks.SessionStart : [];
24795
+ base.hooks.updateQuiet = sessionStart.some(h => _exactHook(h, _AUTO_UPDATE_CHECK_HOOK));
24796
+ base.hooks.updateCheck = base.hooks.updateQuiet || sessionStart.some(h =>
24797
+ h && typeof h.command === 'string' && typeof h.matcher === 'string' &&
24798
+ h.command.trim() === 'leerness update --check' && h.matcher.trim() === _AUTO_UPDATE_CHECK_HOOK.matcher);
24799
+ base.hooks.contextInjection = sessionStart.some(h => _exactHook(h, _AUTO_UPDATE_CONTEXT_HOOK));
24800
+ // install이 생성하는 정확한 훅(`--quiet` 포함)이 아니면 일부 설치로 보고한다.
24801
+ base.installed = base.hooks.updateCheck && base.hooks.updateQuiet && base.hooks.contextInjection && base.slashCommand;
24802
+ if (has('--json')) log(JSON.stringify(base, null, 2));
24803
+ else {
24804
+ log(`# leerness auto-update status`);
24805
+ log(` ${base.installed ? '🟢 설치됨' : '🟡 일부 설치'} — ${settingsFile}`);
24806
+ log(` ${base.hooks.updateCheck ? '✓' : '✗'} update check hook${base.hooks.updateCheck && !base.hooks.updateQuiet ? ' (⚠ --quiet 없음)' : ''}`);
24807
+ log(` ${base.hooks.contextInjection ? '✓' : '✗'} context injection hook`);
24808
+ log(` ${base.slashCommand ? '✓' : '✗'} /update command (${slashFile})`);
24809
+ if (!base.installed) log(` 복구/설치: leerness auto-update install ${root}`);
24810
+ }
24811
+ return base;
24812
+ }
24813
+
24574
24814
  // 1.9.151: ViewWork hook 제거 (사용자 명시 — leerness와 무관한 외부 도구)
24575
24815
  // 이전 1.9.0~1.9.150 에서 .viewwork/ 디렉토리에 hook 으로 이벤트 기록했으나, ViewWork는 leerness 의존 산출물이
24576
24816
  // 아닌 별도 도구임. 사용자가 직접 ViewWork 를 사용하지 않으면 leerness 가 이를 강제할 이유가 없음.
@@ -25610,6 +25850,8 @@ function _mcpToCliArgs(name, args, targetPath) {
25610
25850
  cliArgs = ['api-skill', args.sub || 'list', '--path', targetPath, '--json'];
25611
25851
  if (args.sub === 'add' && args.url) cliArgs.push('--url', args.url);
25612
25852
  if (args.sub === 'add' && args.direction) cliArgs.push('--direction', args.direction);
25853
+ if (args.sub === 'add' && args.name) cliArgs.push('--name', args.name);
25854
+ if (args.sub === 'add' && args.noCrawl === true) cliArgs.push('--no-crawl');
25613
25855
  if ((args.sub === 'show' || args.sub === 'drop') && args.id) cliArgs.push('--id', args.id);
25614
25856
  if (args.sub === 'match' && args.query) cliArgs.push('--query', args.query);
25615
25857
  break;
@@ -30218,12 +30460,12 @@ Korean-first by default; this English help shows with --language en or LEERNESS_
30218
30460
  Usage: leerness <command> [path] [options]
30219
30461
 
30220
30462
  SETUP & UPDATE
30221
- init [path] [--language auto|ko|en] [--skills recommended|all|a,b] [--minimal] [--yes]
30463
+ init [path] [--language auto|ko|en] [--skills recommended|all|a,b] [--minimal] [--yes] [--no-setup-agents]
30222
30464
  Install the .leerness/ workspace into your project
30223
30465
  migrate [path] [--dry-run] [--force] Non-destructive cross-version migration
30224
30466
  migrate audit|apply|plan [path] [--json] Diagnose / backfill / compare migration
30225
30467
  update [path] [--check|--yes|--force] Auto-detect version + migrate
30226
- auto-update install [path] Install the auto-update hook
30468
+ auto-update install|status [path] Install or inspect the auto-update hook (status is read-only)
30227
30469
  path-setup [--apply] Register the leerness CLI on PATH
30228
30470
 
30229
30471
  STATUS & DIAGNOSTICS
@@ -30821,7 +31063,12 @@ async function main() {
30821
31063
  return await install(arg('--path', _msub || process.cwd()), { force:has('--force'), dry:has('--dry-run'), migration:true }); // 1.9.355 (UR-0075): --path 지원
30822
31064
  }
30823
31065
  if (cmd === 'update') return await updateCmd(arg('--path', args[1] || process.cwd()), { checkOnly: has('--check'), yes: has('--yes'), force: has('--force') }); // 1.9.355 (UR-0075): --path 지원
30824
- if (cmd === 'auto-update' && args[1] === 'install') return autoUpdateInstall(arg('--path', args[2] || process.cwd()));
31066
+ if (cmd === 'auto-update') {
31067
+ if (args[1] === 'install') return autoUpdateInstall(_resolveRoot(args[2]));
31068
+ if (args[1] === 'status') return autoUpdateStatus(_resolveRoot(args[2]));
31069
+ failJson(has('--json'), 'unknown_subcommand', `알 수 없는 auto-update 하위명령: ${args[1] || '(없음)'} — leerness auto-update install|status [path]`);
31070
+ return;
31071
+ }
30825
31072
  if (cmd === 'status') return status(arg('--path', args[1] || process.cwd()));
30826
31073
  if (cmd === 'verify') return verify(arg('--path', args[1] || process.cwd()));
30827
31074
  if (cmd === 'debug') return debug(arg('--path', args[1] || process.cwd()));
@@ -30957,7 +31204,8 @@ async function main() {
30957
31204
  if (cmd === 'health') return healthCmd(args[1] || arg('--path', process.cwd()));
30958
31205
  if (cmd === 'whats-new') return whatsNewCmd(args[1] || arg('--path', process.cwd()));
30959
31206
  if (cmd === 'reuse' && args[1] === 'autodetect') return reuseAutodetectCmd(args[2] || arg('--path', process.cwd()));
30960
- if (cmd === 'setup-agents' || cmd === 'setup' && args[1] === 'agents') return await setupAgentsCmd(args[1] && args[1] !== 'agents' ? args[1] : (arg('--path', args[2] || process.cwd())));
31207
+ if (cmd === 'setup-agents') return await setupAgentsCmd(_resolveRoot(args[1]));
31208
+ if (cmd === 'setup' && args[1] === 'agents') return await setupAgentsCmd(_resolveRoot(args[2]));
30961
31209
  if (cmd === 'session' && args[1] === 'close') return sessionClose(_resolveRoot(args[2]), { json: has('--json') });
30962
31210
  // 1.9.151: viewwork 명령 제거 (사용자 명시 — leerness 와 무관). session close 의 viewworkEmit 콜도 함께 제거.
30963
31211
  if (cmd === 'ci' && (args[1] === 'init' || args[1] == null)) return ciInitCmd(absRoot(_resolveRoot(args[2])), { force: has('--force'), json: has('--json') }); // 1.9.444 (UR-0152): CI gate 워크플로 생성