leerness 1.36.173 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.174 — 2026-08-28
4
+
5
+ - T-0094 dead-flag 감사를 실행 회귀로 고정했습니다. intent expand의 가짜 승인 플래그는 fail-closed하고, auto-update status·toggle get·setup-agents opt-out을 실제 배선했으며 provider/api-skill 옵션 결과를 JSON과 사람 출력에서 관측 가능하게 했습니다. provider 영어 모드에서 내장 provider 설명·요약·안내의 한국어 누수도 0으로 고정했습니다. auto-update 상태는 정확한 matcher/실행 명령과 유효한 `/update` 본문만 설치로 인정해 `echo` 문자열·빈 파일 오탐을 차단하고, parse 가능한 비객체 settings와 scalar/mistyped SessionStart hook 항목도 손상으로 거부해 원본 바이트를 보존합니다. `init --no-setup-agents`는 기존 provider 활성화 값을 보존하며, toggle ID는 프로토타입 키를 유효한 ID로 오인하지 않습니다. archive/release/reuse/parent 4개 표면은 기존 정상 동작을 보수적으로 확인했습니다.
6
+
3
7
  ## 1.36.173 — 2026-08-27
4
8
 
5
9
  - Memory DELETE 5종(task/decision/lesson/rule/plan)의 성공 --json 경로를 단일 JSON 문서·clean stderr 계약으로 통일했습니다. onEveryChange 자동 로드맵은 JSON 출력에 사람용 로그를 섞지 않으면서 다섯 명령 각각 갱신되며, 명령별 산출물 재생성과 외부 Codex P2 재현을 회귀로 고정했습니다.
package/README.md CHANGED
@@ -138,7 +138,7 @@ MIT
138
138
  <!-- leerness:project-readme:start -->
139
139
  ## Leerness Project Harness
140
140
 
141
- 이 프로젝트는 Leerness v1.36.173 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
141
+ 이 프로젝트는 Leerness v1.36.174 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
142
142
 
143
143
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
144
144
 
@@ -192,7 +192,7 @@ leerness memory restore decision <date|title>
192
192
 
193
193
  ### MCP server (외부 AI 통합)
194
194
 
195
- Leerness v1.36.173는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
195
+ Leerness v1.36.174는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
196
196
 
197
197
  ```jsonc
198
198
  // 카테고리별
@@ -213,7 +213,7 @@ Leerness v1.36.173는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
213
213
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
214
214
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
215
215
 
216
- 현재 누적: **v1.9.x → 1.36.173 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
216
+ 현재 누적: **v1.9.x → 1.36.174 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
217
217
 
218
218
  ### 성능 가이드
219
219
 
@@ -251,5 +251,5 @@ leerness release pack --close --auto-main-push
251
251
  - `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
252
252
  - `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
253
253
 
254
- Last synced by Leerness v1.36.173: 2026-08-27
254
+ Last synced by Leerness v1.36.174: 2026-08-28
255
255
  <!-- leerness:project-readme:end -->
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.173';
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
  }
@@ -11963,7 +12010,7 @@ function _inferScopeExpansion(text, root) {
11963
12010
  result.expansionCandidates = [];
11964
12011
  result.note = `intent: precise — 명시 요청만 진행, AI 추론 확장 비활성 (사용자 의도 보호)`;
11965
12012
  } else if (classify.intent === 'broad') {
11966
- result.note = `intent: broad — ${result.expansionCandidates.length} 확장 후보 제시 (--expand-all 또는 --select N 으로 명시 승인 필요)`;
12013
+ result.note = `intent: broad — ${result.expansionCandidates.length} 확장 후보 제시(dry-run); 선택한 후보는 leerness task add 로 별도 등록`;
11967
12014
  } else {
11968
12015
  result.note = `intent: default — 명시 ${result.explicitMentions.length}건만 우선, 확장 후보 ${result.expansionCandidates.length}건 검토용`;
11969
12016
  }
@@ -15032,10 +15079,12 @@ const _STRICT_COMMAND_FLAGS = {
15032
15079
  'verify-claim baseline show': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'verify-claim baseline show [--json]' },
15033
15080
  'verify-code': { allowed: ['--build', '--bench', '--strict'], globals: [..._COMMON_COMMAND_FLAGS], usage: 'verify-code [path] [--build] [--bench] [--strict]' },
15034
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]' },
15035
15083
  };
15036
15084
  function _validateCommandFlags(cmd, args) {
15037
15085
  let route = cmd === 'contract' && args[1] === 'verify' ? 'contract verify' : cmd;
15038
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';
15039
15088
  const cfg = _STRICT_COMMAND_FLAGS[route];
15040
15089
  if (!cfg) return true;
15041
15090
  return _rejectUnknownFlags(cfg.allowed, cfg.usage, { globals: cfg.globals });
@@ -20293,6 +20342,7 @@ function _allProviders(root) {
20293
20342
  versionArgs: _uvaOk ? _uva : _fallbackVa,
20294
20343
  ...((_uva && !_uvaOk) ? { versionArgsRejectedReason: `providers.json 의 versionArgs 에 셸 메타문자가 있어 실행하지 않았습니다: ${JSON.stringify(_uva).slice(0, 120)}` } : {}),
20295
20344
  desc: u.desc || (_bi && _bi.desc) || `(user) ${u.id}`,
20345
+ descEn: u.desc || (_bi && _bi.descEn) || (_bi && _bi.desc) || `(user) ${u.id}`,
20296
20346
  installHint: u.installHint || (_bi && _bi.installHint) || '',
20297
20347
  installCmd: u.installCmd || (_bi && _bi.installCmd) || '',
20298
20348
  ...(_inherited ? { authCheck: _inherited } : {}),
@@ -20329,22 +20379,36 @@ function providerCmd(root, sub, ...args) {
20329
20379
  total: all.length,
20330
20380
  builtin: EXTERNAL_AGENTS.length,
20331
20381
  user: userList.length,
20332
- 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
+ }))
20333
20391
  }, null, 2));
20334
20392
  return;
20335
20393
  }
20394
+ const en = _uiLang(root) === 'en';
20395
+ const cell = v => String(v == null ? '' : v).replace(/\r?\n/g, ' ').replace(/\|/g, '\\|');
20336
20396
  log(`# leerness provider list (1.9.157)`);
20337
- 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})`);
20338
20400
  log('');
20339
- log(`| id | source | bin | envFlag |`);
20340
- log(`|---|---|---|---|`);
20401
+ log(`| id | source | bin | envFlag | versionArgs | description |`);
20402
+ log(`|---|---|---|---|---|---|`);
20341
20403
  for (const p of all) {
20342
20404
  const src = userIds.has(p.id) ? (EXTERNAL_AGENTS.some(b => b.id === p.id) ? 'user(override)' : 'user') : 'builtin';
20343
- 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)} |`);
20344
20406
  }
20345
20407
  if (!userList.length) {
20346
20408
  log('');
20347
- 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]`);
20348
20412
  }
20349
20413
  return;
20350
20414
  }
@@ -21172,6 +21236,14 @@ function _tryInstallAgent(agent) {
21172
21236
  // 1.9.34: 방향키/스페이스 multi-select 도입 (LEERNESS_NO_INTERACTIVE=1 → 기존 yes/no 폴백)
21173
21237
  async function setupAgentsCmd(root, opts = {}) {
21174
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
+ }
21175
21247
  _loadEnvFile(root);
21176
21248
  _loadEnvFile(path.join(root, '..'));
21177
21249
  const envPath = path.join(root, '.env');
@@ -24581,6 +24653,47 @@ async function updateCmd(root, opts = {}) {
24581
24653
  ok('update complete');
24582
24654
  }
24583
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
+
24584
24697
  function autoUpdateInstall(root) {
24585
24698
  root = absRoot(root);
24586
24699
  const settingsDir = path.join(root, '.claude');
@@ -24590,25 +24703,28 @@ function autoUpdateInstall(root) {
24590
24703
  // 1.36.33 (codex 3차 #3, 1.36.28 손상-스토어 클래스): 손상 settings 를 빈 객체로 오인해 hooks-only 로 덮어쓰면
24591
24704
  // 사용자의 permissions 등 무관 설정이 유실된다. 파싱 실패 시 원본 보존 + 중단.
24592
24705
  if (exists(settingsFile)) {
24593
- try { settings = JSON.parse(read(settingsFile)); }
24594
- 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; }
24595
24710
  }
24596
24711
  settings.hooks = settings.hooks || {};
24597
24712
  // 1.9.1 P1: legacy 'leerness-plus update' hook 자동 제거 (이전 fork 시절 잔재).
24598
24713
  let removedLegacy = 0, upgradedQuiet = 0;
24599
24714
  settings.hooks.SessionStart = (settings.hooks.SessionStart || []).filter(h => {
24600
- 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; }
24601
24717
  // 1.9.364 (4번째 외부평가 9.3): 비-quiet 'leerness update --check' 를 quiet 버전으로 업그레이드 (기존 설치 매세션 노이즈 제거)
24602
- 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; }
24603
24719
  return true;
24604
24720
  });
24605
- if (!settings.hooks.SessionStart.some(h => h.command && h.command.includes('leerness update'))) {
24606
- 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 });
24607
24723
  }
24608
24724
  // 1.36.22: 2번째 SessionStart hook — 컨텍스트 주입. matcher 에 compact 를 포함하는 게 핵심(실측 결함: compaction/resume 후
24609
24725
  // 에이전트가 세션시작 handoff 의례를 건너뜀). 기존 설치는 이 멱등 push 로 자동 취득(1.9.364 업그레이드 패턴 재사용).
24610
- if (!settings.hooks.SessionStart.some(h => h.command && h.command.includes('leerness hook session-start'))) {
24611
- 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 });
24612
24728
  }
24613
24729
  writeUtf8(settingsFile, JSON.stringify(settings, null, 2) + '\n');
24614
24730
  // 1.36.62 (검수 #1): en 프로젝트의 /update 를 한국어로 되덮던 것 — 언어 인지 렌더
@@ -24630,6 +24746,71 @@ function autoUpdateInstall(root) {
24630
24746
  log(` ⓘ 제거: ${rel(root, settingsFile)} 의 hooks.SessionStart 항목 삭제 · 설치 시 끄기: leerness init . --no-auto-update`);
24631
24747
  }
24632
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
+
24633
24814
  // 1.9.151: ViewWork hook 제거 (사용자 명시 — leerness와 무관한 외부 도구)
24634
24815
  // 이전 1.9.0~1.9.150 에서 .viewwork/ 디렉토리에 hook 으로 이벤트 기록했으나, ViewWork는 leerness 의존 산출물이
24635
24816
  // 아닌 별도 도구임. 사용자가 직접 ViewWork 를 사용하지 않으면 leerness 가 이를 강제할 이유가 없음.
@@ -25669,6 +25850,8 @@ function _mcpToCliArgs(name, args, targetPath) {
25669
25850
  cliArgs = ['api-skill', args.sub || 'list', '--path', targetPath, '--json'];
25670
25851
  if (args.sub === 'add' && args.url) cliArgs.push('--url', args.url);
25671
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');
25672
25855
  if ((args.sub === 'show' || args.sub === 'drop') && args.id) cliArgs.push('--id', args.id);
25673
25856
  if (args.sub === 'match' && args.query) cliArgs.push('--query', args.query);
25674
25857
  break;
@@ -30277,12 +30460,12 @@ Korean-first by default; this English help shows with --language en or LEERNESS_
30277
30460
  Usage: leerness <command> [path] [options]
30278
30461
 
30279
30462
  SETUP & UPDATE
30280
- 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]
30281
30464
  Install the .leerness/ workspace into your project
30282
30465
  migrate [path] [--dry-run] [--force] Non-destructive cross-version migration
30283
30466
  migrate audit|apply|plan [path] [--json] Diagnose / backfill / compare migration
30284
30467
  update [path] [--check|--yes|--force] Auto-detect version + migrate
30285
- 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)
30286
30469
  path-setup [--apply] Register the leerness CLI on PATH
30287
30470
 
30288
30471
  STATUS & DIAGNOSTICS
@@ -30880,7 +31063,12 @@ async function main() {
30880
31063
  return await install(arg('--path', _msub || process.cwd()), { force:has('--force'), dry:has('--dry-run'), migration:true }); // 1.9.355 (UR-0075): --path 지원
30881
31064
  }
30882
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 지원
30883
- 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
+ }
30884
31072
  if (cmd === 'status') return status(arg('--path', args[1] || process.cwd()));
30885
31073
  if (cmd === 'verify') return verify(arg('--path', args[1] || process.cwd()));
30886
31074
  if (cmd === 'debug') return debug(arg('--path', args[1] || process.cwd()));
@@ -31016,7 +31204,8 @@ async function main() {
31016
31204
  if (cmd === 'health') return healthCmd(args[1] || arg('--path', process.cwd()));
31017
31205
  if (cmd === 'whats-new') return whatsNewCmd(args[1] || arg('--path', process.cwd()));
31018
31206
  if (cmd === 'reuse' && args[1] === 'autodetect') return reuseAutodetectCmd(args[2] || arg('--path', process.cwd()));
31019
- 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]));
31020
31209
  if (cmd === 'session' && args[1] === 'close') return sessionClose(_resolveRoot(args[2]), { json: has('--json') });
31021
31210
  // 1.9.151: viewwork 명령 제거 (사용자 명시 — leerness 와 무관). session close 의 viewworkEmit 콜도 함께 제거.
31022
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 워크플로 생성
@@ -7,7 +7,7 @@
7
7
  // 사용자 정책: 환경변수로 활성화 명시 + 실제 PATH 존재 확인 + 메인이 sub-agent 분배 시 참조
8
8
  // 1.9.32: installCmd 추가 — setup-agents 시 자동 설치 시도 가능
9
9
  const EXTERNAL_AGENTS = [
10
- { id: 'claude', bin: 'claude', envFlag: 'LEERNESS_ENABLE_CLAUDE', versionArgs: ['--version'], desc: 'Anthropic Claude Code CLI',
10
+ { id: 'claude', bin: 'claude', envFlag: 'LEERNESS_ENABLE_CLAUDE', versionArgs: ['--version'], desc: 'Anthropic Claude Code CLI', descEn: 'Anthropic Claude Code CLI',
11
11
  installCmd: 'npm i -g @anthropic-ai/claude-code', installHint: 'https://docs.anthropic.com/en/docs/claude-code/setup' },
12
12
  // 1.36.91: authCheck — **무료·비대화형·부작용 없음**이 실측으로 확인된 명령만 등록한다.
13
13
  // `--version` 이 도는 것은 인증·모델 사용권한·쿼터·과금 권한을 아무것도 증명하지 않는다.
@@ -19,31 +19,31 @@ const EXTERNAL_AGENTS = [
19
19
  // 그래서 `noPattern` 에 **로그아웃 상태로 알아볼 수 있는 문구**가 함께 잡힐 때만 no 로 본다.
20
20
  // ⚠ 로그아웃 상태의 실제 출력은 이 머신에서 관측하지 못했다(로그인 상태였다) — 패턴이 빗나가면
21
21
  // no 대신 unknown 이 되고, unknown 은 차단하지 않으므로 안전한 방향으로 실패한다.
22
- { id: 'codex', bin: 'codex', envFlag: 'LEERNESS_ENABLE_CODEX', versionArgs: ['--version'], desc: 'OpenAI Codex CLI (격리 sandbox)',
22
+ { id: 'codex', bin: 'codex', envFlag: 'LEERNESS_ENABLE_CODEX', versionArgs: ['--version'], desc: 'OpenAI Codex CLI (격리 sandbox)', descEn: 'OpenAI Codex CLI (isolated sandbox)',
23
23
  authCheck: { args: ['login', 'status'], timeoutMs: 8000, noPattern: 'not logged in|logged out|no credentials|run .*login|sign in', note: 'codex login status (실측 158ms · 모델 호출 없음)' },
24
24
  installCmd: 'npm i -g @openai/codex', installHint: 'https://github.com/openai/codex' },
25
25
  // 1.9.248 (사용자 명시 UR-0017): Gemini CLI 제거 + Antigravity CLI (agy 명령어) 교체
26
26
  // Google Antigravity 는 IDE 기반 멀티 모달 에이전트 도구. agy CLI 는 워크스페이스 직접 수정 가능 모드 지원.
27
27
  // 기존 LEERNESS_ENABLE_AGY 환경변수는 LEERNESS_ENABLE_AGY 로 교체. provider id 도 'agy' 로 통일.
28
- { id: 'agy', bin: 'agy', envFlag: 'LEERNESS_ENABLE_AGY', versionArgs: ['--version'], desc: 'Google Antigravity CLI (멀티모달 에이전트, --yolo 워크스페이스 수정 가능)',
28
+ { id: 'agy', bin: 'agy', envFlag: 'LEERNESS_ENABLE_AGY', versionArgs: ['--version'], desc: 'Google Antigravity CLI (멀티모달 에이전트, --yolo 워크스페이스 수정 가능)', descEn: 'Google Antigravity CLI (multimodal agent; --yolo can modify the workspace)',
29
29
  installCmd: 'npm i -g @google/antigravity-cli', installHint: 'https://antigravity.google.com (Antigravity IDE/CLI)' },
30
30
  // 1.9.268: grok 정식 provider 승격 (1.9.266 후속 task) — 기존 슬래시 레지스트리(grok)만 보유하던 상태에서 EXTERNAL_AGENTS 로 편입.
31
31
  // → provider cycle / setup-agents / dispatch / slash-commands --refresh probe 가 grok 도 자동 처리.
32
- { id: 'grok', bin: 'grok', envFlag: 'LEERNESS_ENABLE_GROK', versionArgs: ['--version'], desc: 'xAI Grok CLI (커뮤니티 grok-cli, /model grok-beta 등)',
32
+ { id: 'grok', bin: 'grok', envFlag: 'LEERNESS_ENABLE_GROK', versionArgs: ['--version'], desc: 'xAI Grok CLI (커뮤니티 grok-cli, /model grok-beta 등)', descEn: 'xAI Grok CLI (community grok-cli; /model supports grok-beta)',
33
33
  installCmd: 'npm i -g @vibe-kit/grok-cli', installHint: 'https://github.com/superagent-ai/grok-cli (xAI API 키 필요 — /login 또는 GROK_API_KEY)' },
34
34
  // 1.9.277 (사용자 명시): 신규 CLI 에이전트 4종 — opencode/qwen/aider/goose. 활성 시 sub-agent dispatch/roles 라우팅 대상.
35
- { id: 'opencode', bin: 'opencode', envFlag: 'LEERNESS_ENABLE_OPENCODE', versionArgs: ['--version'], desc: 'opencode — 오픈소스 터미널 AI 코딩 에이전트 (provider-agnostic)',
35
+ { id: 'opencode', bin: 'opencode', envFlag: 'LEERNESS_ENABLE_OPENCODE', versionArgs: ['--version'], desc: 'opencode — 오픈소스 터미널 AI 코딩 에이전트 (provider-agnostic)', descEn: 'opencode — open-source terminal AI coding agent (provider-agnostic)',
36
36
  installCmd: 'npm i -g opencode-ai', installHint: 'https://opencode.ai (또는 curl -fsSL https://opencode.ai/install | bash)' },
37
- { id: 'qwen', bin: 'qwen', envFlag: 'LEERNESS_ENABLE_QWEN', versionArgs: ['--version'], desc: 'Qwen Code CLI (Alibaba qwen3-coder 등)',
37
+ { id: 'qwen', bin: 'qwen', envFlag: 'LEERNESS_ENABLE_QWEN', versionArgs: ['--version'], desc: 'Qwen Code CLI (Alibaba qwen3-coder 등)', descEn: 'Qwen Code CLI (Alibaba qwen3-coder and others)',
38
38
  installCmd: 'npm i -g @qwen-code/qwen-code', installHint: 'https://github.com/QwenLM/qwen-code (DASHSCOPE/OpenAI 호환 키)' },
39
- { id: 'aider', bin: 'aider', envFlag: 'LEERNESS_ENABLE_AIDER', versionArgs: ['--version'], desc: 'Aider — git-aware 페어 프로그래밍 CLI (--model 임의 지정)',
39
+ { id: 'aider', bin: 'aider', envFlag: 'LEERNESS_ENABLE_AIDER', versionArgs: ['--version'], desc: 'Aider — git-aware 페어 프로그래밍 CLI (--model 임의 지정)', descEn: 'Aider — git-aware pair-programming CLI (custom model supported)',
40
40
  installCmd: 'python -m pip install aider-install && aider-install', installHint: 'https://aider.chat (pip install aider-chat · API 키 env)' },
41
- { id: 'goose', bin: 'goose', envFlag: 'LEERNESS_ENABLE_GOOSE', versionArgs: ['--version'], desc: 'Goose — Block 오픈소스 범용 로컬 AI 에이전트 (MCP 확장)',
41
+ { id: 'goose', bin: 'goose', envFlag: 'LEERNESS_ENABLE_GOOSE', versionArgs: ['--version'], desc: 'Goose — Block 오픈소스 범용 로컬 AI 에이전트 (MCP 확장)', descEn: "Goose — Block's open-source general local AI agent (MCP extensible)",
42
42
  installCmd: 'curl -fsSL https://github.com/block/goose/releases/latest/download/download_cli.sh | bash', installHint: 'https://block.github.io/goose (goose configure 로 provider/model 설정)' },
43
- { id: 'copilot', bin: 'gh', envFlag: 'LEERNESS_ENABLE_COPILOT', versionArgs: ['copilot', '--version'], desc: 'GitHub Copilot CLI (gh copilot)',
43
+ { id: 'copilot', bin: 'gh', envFlag: 'LEERNESS_ENABLE_COPILOT', versionArgs: ['copilot', '--version'], desc: 'GitHub Copilot CLI (gh copilot)', descEn: 'GitHub Copilot CLI (gh copilot)',
44
44
  installCmd: 'gh extension install github/gh-copilot', installHint: 'https://github.com/github/gh-copilot (gh CLI 선행 설치 필요)' },
45
45
  // 1.9.146: Ollama 추가 (사용자 명시 요청 #3) — 로컬 LLM, HTTP API 11434
46
- { id: 'ollama', bin: 'ollama', envFlag: 'LEERNESS_ENABLE_OLLAMA', versionArgs: ['--version'], desc: 'Ollama 로컬 LLM (http://localhost:11434, llama3/qwen 등)',
46
+ { id: 'ollama', bin: 'ollama', envFlag: 'LEERNESS_ENABLE_OLLAMA', versionArgs: ['--version'], desc: 'Ollama 로컬 LLM (http://localhost:11434, llama3/qwen 등)', descEn: 'Ollama local LLM (http://localhost:11434; llama3/qwen and others)',
47
47
  installCmd: 'curl -fsSL https://ollama.com/install.sh | sh (또는 https://ollama.com/download)', installHint: 'ollama serve 실행 + ollama pull <model>' }
48
48
  ];
49
49
 
package/lib/mcp-tools.js CHANGED
@@ -52,7 +52,7 @@ module.exports = [
52
52
  { name: 'leerness_feature_add', requiredTier: 'safe-write', description: '1.9.142 — Feature Graph 에 새 노드 추가 (외부 AI가 코드 작성 중 직접 feature 등록). 인자: { title (required), dependsOn?, affects?, coChangesWith?, files?, path? }. 자동 F-XXXX ID 부여. CRUD 완성에 기여', inputSchema: { type: 'object', properties: { title: { type: 'string' }, dependsOn: { type: 'string' }, affects: { type: 'string' }, coChangesWith: { type: 'string' }, files: { type: 'string' }, path: { type: 'string' } }, required: ['title'] } },
53
53
  { name: 'leerness_feature_link', requiredTier: 'safe-write', description: '1.9.142 — 기존 feature 노드에 의존/영향/공변경 엣지 추가. 인자: { id (required, F-XXXX), dependsOn?, affects?, coChangesWith?, path? }. 외부 AI가 코드 변경 도중 발견한 인과관계를 즉시 그래프에 반영', inputSchema: { type: 'object', properties: { id: { type: 'string' }, dependsOn: { type: 'string' }, affects: { type: 'string' }, coChangesWith: { type: 'string' }, path: { type: 'string' } }, required: ['id'] } }, // 1.9.407 (8번째 버그헌트, UR-0111): 그래프 mutate 인데 read-only 오분류였음 → safe-write (feature_add 와 일관, 권한경계 회복)
54
54
  { name: 'leerness_env_detect', requiredTier: 'read-only', description: '1.9.145 — 실행 환경 자동 감지 + 변동 추적 JSON ({ snapshot: { os, hardware, locale, shell, node, tools, scriptDependencies }, diff: { firstCapture, changes, missing }, persisted }). "X은(는) 내부 또는 외부 명령... 아닙니다" 사전 방지: package.json scripts 의존 도구가 PATH에 있는지 검증 + 머신/Node/도구 변경 감지. 절대경로 마스킹 (보안). 인자: { path? }', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
55
- { name: 'leerness_provider_list', requiredTier: 'read-only', description: '1.9.157/158 — Provider Registry 조회 JSON ({ total, builtin, user, providers: [{ id, bin, envFlag, source, desc }] }). 빌트인 5종 (claude/codex/agy/copilot/ollama) + .leerness/providers.json 사용자 정의 통합. 외부 AI가 sub-agent 분배 가능한 provider 전체 회수 (OpenRouter/Bedrock 등 등록되어 있으면 같이 노출). 🎉 MCP 48 도구 마일스톤', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
55
+ { name: 'leerness_provider_list', requiredTier: 'read-only', description: '1.9.157/158 — Provider Registry 조회 JSON ({ total, builtin, user, providers: [{ id, bin, envFlag, versionArgs, source, desc, installHint }] }). 빌트인 10종 + .leerness/providers.json 사용자 정의 통합. 외부 AI가 sub-agent 분배 가능한 provider 전체 회수 (OpenRouter/Bedrock 등 등록되어 있으면 같이 노출). 🎉 MCP 48 도구 마일스톤', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
56
56
  { name: 'leerness_provider_add', requiredTier: 'safe-write', description: '1.9.159 — Provider Registry 에 새 provider 동적 추가. 인자: { id (required), bin?, envFlag?, versionArgs?, desc?, path? }. 외부 AI가 새 CLI 발견 시 자가 확장 (OpenRouter / Bedrock / Groq / Hugging Face 등 등록). 같은 id 두 번 호출 → 갱신. 빌트인 id 호출 → user override. id 는 영문자/숫자/_- 만 허용.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, bin: { type: 'string' }, envFlag: { type: 'string' }, versionArgs: { type: 'string' }, desc: { type: 'string' }, installHint: { type: 'string' }, path: { type: 'string' } }, required: ['id'] } },
57
57
  { name: 'leerness_provider_remove', requiredTier: 'safe-write', description: '1.9.159 — Provider Registry 에서 사용자 정의 provider 제거. 인자: { id (required), path? }. 빌트인 5종 id 는 제거 불가 (override 만 제거 가능). 🎉 MCP 50 도구 마일스톤 — Provider Registry CRUD MCP 완성 (list/add/remove)', inputSchema: { type: 'object', properties: { id: { type: 'string' }, path: { type: 'string' } }, required: ['id'] } },
58
58
  { name: 'leerness_web', requiredTier: 'network', description: '1.9.168 — Web Bridge (1.9.165 playwright opt-in). sub: check (설치 + permissions.browser 확인) | screenshot (URL → PNG) | extract (URL + CSS selector → DOM 텍스트). 외부 AI가 leerness 의 웹 자동화 능력을 직접 호출. playwright 미설치 시 친절 안내 (graceful). 인자: { sub (required), url?, out?, selector?, path? }', inputSchema: { type: 'object', properties: { sub: { type: 'string', enum: ['check', 'screenshot', 'extract'] }, url: { type: 'string' }, out: { type: 'string' }, selector: { type: 'string' }, path: { type: 'string' } }, required: ['sub'] } },
@@ -76,7 +76,7 @@ module.exports = [
76
76
  { name: 'leerness_py_check', requiredTier: 'read-only', description: '1.9.239 (사용자 명시 UR-0013) — Python 파일 분석. 의존성 0 regex fallback. .md 외 .py 도 leerness 인지. 응답: { totalFiles, totalLOC, totalImports, totalFuncs, totalClasses, totalTodos, biggest: [{ file, loc, funcs, classes }] }. 외부 AI가 "이 프로젝트 Python 표면이 얼마나 되나"를 회수. 인자: { path? }', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
77
77
  { name: 'leerness_agent_mode', requiredTier: 'safe-write', description: '1.9.239 (사용자 명시 UR-0013) — 자율 모드 전용 통합 명령. start: handoff + drift --auto-fix + session-resume --auto-fix (진입). tick: pulse 한 줄 (매 라운드). stop: session close --auto-apply-delivered --auto-cleanup-branches (마감). 외부 AI 가 자율 라운드 진입/매 라운드/마감을 단일 호출로 수행. 인자: { path?, sub (required: "start"|"tick"|"stop"|"help") }', inputSchema: { type: 'object', properties: { path: { type: 'string' }, sub: { type: 'string', enum: ['start', 'tick', 'stop', 'help'] } }, required: ['sub'] } },
78
78
  { name: 'leerness_env_info', requiredTier: 'read-only', description: '1.9.241 (사용자 명시 UR-0014) — 환경 종합 정보 회수. OS / 언어 (LANG, 코드페이지) / 한국어 Windows / 하드웨어 / 터미널 (TTY, PowerShell 버전) / 도구 버전 (git, npm, python). 외부 AI 가 환경 호환성을 미리 인지 → 인코딩 오류 예방. 응답: { os, node, locale, hardware, terminal, tools }. 인자: { path?, encodingCheck? }. encodingCheck: true 시 셸 스크립트 (.ps1/.bat/.cmd/.sh) BOM 없는 비-ASCII 위험 감지', inputSchema: { type: 'object', properties: { path: { type: 'string' }, encodingCheck: { type: 'boolean' } } } },
79
- { name: 'leerness_api_skill', requiredTier: 'safe-write', description: '1.9.245 (사용자 명시 UR-0015) — API 문서·관련링크 자동 캐시. 공식 API 문서 URL을 fetch 하고 1단계 same-domain 관련 링크까지 정리 → .leerness/api-skills/<id>.md 저장. 후속 같은 API 관련 작업 시 자동 참조. 응답: list (skills 배열) / show (전체 본문) / match (task 매칭 결과). 인자: { path?, sub ("list"|"show"|"match"|"add"|"drop"), url? (add), id? (show/drop), query? (match), direction? (add: 구현 방향 텍스트) }. 외부 AI가 "이 프로젝트 어떤 API 문서가 정리되어 있나?" / "내 작업과 매칭되는 API skill 있나?" 회수.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, sub: { type: 'string', enum: ['list', 'show', 'match', 'add', 'drop'] }, url: { type: 'string' }, id: { type: 'string' }, query: { type: 'string' }, direction: { type: 'string' } }, required: ['sub'] } },
79
+ { name: 'leerness_api_skill', requiredTier: 'safe-write', description: '1.9.245 (사용자 명시 UR-0015) — API 문서·관련링크 자동 캐시. 공식 API 문서 URL을 fetch 하고 기본은 1단계 same-domain 관련 링크까지 정리하며, noCrawl=true면 지정 URL만 fetch → .leerness/api-skills/<id>.md 저장. 후속 같은 API 관련 작업 시 자동 참조. 응답: list (skills 배열) / show (전체 본문) / match (task 매칭 결과). 인자: { path?, sub ("list"|"show"|"match"|"add"|"drop"), url? (add), id? (show/drop), query? (match), direction? (add), name? (add), noCrawl? (add) }. 외부 AI가 "이 프로젝트 어떤 API 문서가 정리되어 있나?" / "내 작업과 매칭되는 API skill 있나?" 회수.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, sub: { type: 'string', enum: ['list', 'show', 'match', 'add', 'drop'] }, url: { type: 'string' }, id: { type: 'string' }, query: { type: 'string' }, direction: { type: 'string' }, name: { type: 'string' }, noCrawl: { type: 'boolean' } }, required: ['sub'] } },
80
80
  { name: 'leerness_selftest', requiredTier: 'read-only', description: '1.9.258/259 — 설치된 leerness 바이너리의 코어 함수(보안 _isSecretKey / 버전 compareVer / 인코딩 _classifyCJK 등) 무결성 자가 검증. 응답: { version, total, pass, fail, ok, results[] }. 외부 AI/CI 가 "이 leerness 설치가 정상인가?(npx 캐시 손상·부분 설치 감지)" 를 확인하며, 행위 검사가 포함돼 환경에 따라 수십 초 걸릴 수 있음. 인자: 없음.', inputSchema: { type: 'object', properties: {} } },
81
81
  { name: 'leerness_shell_guard', requiredTier: 'read-only', description: '1.9.260/261 (사용자 명시 UR-0020) — 터미널 명령 셸 호환성 린터. 외부 AI 가 명령을 실행하기 전에 셸 호환성 문제를 사전 점검. 예: Windows PowerShell 5.1 은 && / || 미지원 → A; if ($?) { B } 제안. 6 규칙(ps5-chain/ps-devnull/ps-inline-env/ps-rm-rf/cmd-semicolon/ps-version-unknown) + 과거 실패 회수(.leerness/shell-failures.json). 응답: { shell, psVersion, issues[{rule,severity,detail,suggestion}], pastSame, pastSimilar, ok }. 인자: { command (required), path? }. 현재 셸/PS 버전 자동 감지.', inputSchema: { type: 'object', properties: { command: { type: 'string' }, path: { type: 'string' } }, required: ['command'] } },
82
82
  { name: 'leerness_slash_commands', requiredTier: 'safe-write', description: '1.9.265/266 (사용자 명시 UR-0021) — CLI AI 에이전트별 슬래시 명령어 레지스트리. 외부 AI(메인)가 sub-agent(codex/agy/claude/grok/copilot)를 호출할 때 각 에이전트에 알맞는 슬래시 명령을 참조. 빌트인 + 사용자 .leerness/agent-slash-commands.json override 병합. 응답: { agents: { <id>: { label, asOf, invoke(slash|subcommand), note, source, count, commands[{cmd,desc}] } } }. 인자: { path?, agent? (생략 시 전체), refresh? (1.9.267 — 설치된 CLI --help probe 자동 갱신), dryRun? }. agent 지정 시 단일.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, agent: { type: 'string' }, refresh: { type: 'boolean' }, dryRun: { type: 'boolean' } } } },
package/lib/toggles.js CHANGED
@@ -27,6 +27,7 @@ const TOGGLE_REGISTRY = {
27
27
  'double-verify': { desc: '완료 전 재확인 지시문(같은 것을 다시 확인하라는 안내) — 끄면 안내만 생략된다 (gate/verify-claim 실행은 그대로)', affects: 'handoff · lens 안내' },
28
28
  'full-reread': { desc: '매 세션 관련 문서 전체 재독 지시문 — 끄면 "필요할 때 읽어라" 로 바뀐다 (문서와 상태는 그대로 남는다)', affects: 'handoff 안내' },
29
29
  };
30
+ const _hasToggle = (id) => Object.prototype.hasOwnProperty.call(TOGGLE_REGISTRY, id);
30
31
  // P-0009 경계 계약: 토글로 끌 수 있는 것은 '지시문' 뿐이다. 아래 목록은 **어떤 토글 조합에서도** 살아 있어야 하며,
31
32
  // 변이 테스트가 이 계약을 고정한다(계약이 깨지면 토글을 출하하지 않는다는 것이 승인 조건이었다).
32
33
  const SCAFFOLD_TOGGLES = ['workflow-distribute', 'double-verify', 'full-reread'];
@@ -58,21 +59,25 @@ function loadTogglesChecked(root) {
58
59
  const out = {};
59
60
  for (const [id, meta] of Object.entries(TOGGLE_REGISTRY)) out[id] = !(meta && meta.defaultOff);
60
61
  const f = _togglesPath(root);
61
- if (!exists(f)) return { toggles: out, corrupt: false, unreadable: [] };
62
+ if (!exists(f)) return { toggles: out, corrupt: false, unreadable: [], stored: [] };
62
63
  try {
63
64
  const j = JSON.parse(read(f));
64
- if (!j || typeof j !== 'object' || Array.isArray(j)) return { toggles: out, corrupt: true, reason: '최상위가 객체가 아닙니다', unreadable: [] };
65
+ if (!j || typeof j !== 'object' || Array.isArray(j)) return { toggles: out, corrupt: true, reason: '최상위가 객체가 아닙니다', unreadable: [], stored: [] };
65
66
  // codex 32차 #1: JSON 은 멀쩡한데 **값을 못 읽는** 경우(`"banana"`)도 저장 의도가 유실된 것이다 —
66
67
  // 기본값으로 조용히 되돌리면 켜 뒀던 차단 게이트가 꺼진다. 어떤 키가 그랬는지 함께 돌려준다.
67
68
  const unreadable = [];
69
+ const stored = [];
68
70
  for (const [k, v] of Object.entries(j)) {
69
- if (!(k in out)) continue;
71
+ // Registry membership is own-key only. `in` admitted prototype names such
72
+ // as `toString`/`constructor`, so `toggle get toString` falsely succeeded.
73
+ if (!_hasToggle(k)) continue;
70
74
  const coerced = _coerceToggle(v, out[k]);
71
75
  if (_coerceToggle(v, true) !== _coerceToggle(v, false)) unreadable.push(k); // 기본값에 따라 답이 갈리면 = 못 읽은 값
76
+ else stored.push(k);
72
77
  out[k] = coerced;
73
78
  }
74
- return { toggles: out, corrupt: false, unreadable };
75
- } catch { return { toggles: out, corrupt: true, reason: 'JSON 파싱 실패', unreadable: [] }; }
79
+ return { toggles: out, corrupt: false, unreadable, stored };
80
+ } catch { return { toggles: out, corrupt: true, reason: 'JSON 파싱 실패', unreadable: [], stored: [] }; }
76
81
  }
77
82
 
78
83
  function loadToggles(root) { return loadTogglesChecked(root).toggles; }
@@ -80,7 +85,7 @@ function loadToggles(root) { return loadTogglesChecked(root).toggles; }
80
85
  // 단일 토글 조회 헬퍼 — 호출부 한 줄용. defaultOff 토글은 명시적으로 켜야 true.
81
86
  function toggleOn(root, id) {
82
87
  const st = loadToggles(root);
83
- return id in st ? st[id] === true : true;
88
+ return Object.prototype.hasOwnProperty.call(st, id) ? st[id] === true : true;
84
89
  }
85
90
 
86
91
  function saveToggles(root, toggles) {
@@ -91,7 +96,7 @@ function saveToggles(root, toggles) {
91
96
  writeUtf8(f, JSON.stringify(toggles, null, 2) + '\n');
92
97
  }
93
98
 
94
- // `leerness toggle [list|set <id> on|off] [--json]`
99
+ // `leerness toggle [list|get <id>|set <id> on|off] [--json]`
95
100
  function toggleCmd(root, sub, id, val, deps = {}) {
96
101
  const { has, VERSION } = deps;
97
102
  root = absRoot(root);
@@ -115,8 +120,28 @@ function toggleCmd(root, sub, id, val, deps = {}) {
115
120
  log(`\n 변경: leerness toggle set <id> on|off · 그래프 뷰: leerness graph --html → leerness.html 의 ⚙ 탭`);
116
121
  return;
117
122
  }
123
+ if (sub === 'get') {
124
+ if (!_hasToggle(id)) { fail(`알 수 없는 토글: ${id} (가능: ${Object.keys(TOGGLE_REGISTRY).join(', ')})`); process.exitCode = 1; return; }
125
+ const payload = {
126
+ ok: true,
127
+ id,
128
+ value: cur[id] === true,
129
+ // 파일이 존재해도 이 id가 없거나 값을 해석할 수 없으면 등록 기본값이다.
130
+ // 종전 파일 단위 판정은 읽지 않은 값도 `stored`라고 오보했다.
131
+ source: Array.isArray(_chk.stored) && _chk.stored.includes(id) ? 'stored' : 'default',
132
+ corrupt: !!_chk.corrupt,
133
+ corruptReason: _chk.reason || null,
134
+ unreadable: Array.isArray(_chk.unreadable) ? _chk.unreadable.includes(id) : false,
135
+ meta: TOGGLE_REGISTRY[id],
136
+ };
137
+ if (json) { log(JSON.stringify(payload, null, 2)); return payload; }
138
+ if (_chk.corrupt) log(` ⚠ toggles.json 손상(${_chk.reason}) — 아래 값은 저장값이 아니라 기본값입니다: ${_togglesPath(root)}`);
139
+ if (payload.unreadable) log(` ⚠ ${id} 저장값을 해석할 수 없어 기본값으로 표시합니다.`);
140
+ log(`${payload.value ? '🟢 ON' : '⚪ OFF'} ${id} — ${TOGGLE_REGISTRY[id].desc} [${payload.source}]`);
141
+ return payload;
142
+ }
118
143
  if (sub === 'set') {
119
- if (!TOGGLE_REGISTRY[id]) { fail(`알 수 없는 토글: ${id} (가능: ${Object.keys(TOGGLE_REGISTRY).join(', ')})`); process.exitCode = 1; return; }
144
+ if (!_hasToggle(id)) { fail(`알 수 없는 토글: ${id} (가능: ${Object.keys(TOGGLE_REGISTRY).join(', ')})`); process.exitCode = 1; return; }
120
145
  const on = String(val).toLowerCase();
121
146
  if (on !== 'on' && on !== 'off') { fail(`값은 on|off (받음: ${val})`); process.exitCode = 1; return; }
122
147
  // 1.36.49 (codex 6차 #5): 손상 toggles.json 위에 set 하면 loadToggles 의 기본값 폴백이 저장돼
@@ -132,7 +157,7 @@ function toggleCmd(root, sub, id, val, deps = {}) {
132
157
  ok(`toggle ${id} = ${on.toUpperCase()}${cur[id] ? '' : ' — 관련 명령이 스킵 동작으로 전환됩니다'}`);
133
158
  return;
134
159
  }
135
- fail(`알 수 없는 하위명령: ${sub} (가능: list, set)`); process.exitCode = 1;
160
+ fail(`알 수 없는 하위명령: ${sub} (가능: list, get, set)`); process.exitCode = 1;
136
161
  }
137
162
 
138
163
  module.exports = { TOGGLE_REGISTRY, SCAFFOLD_TOGGLES, EVIDENCE_COMMANDS, loadToggles, loadTogglesChecked, toggleOn, saveToggles, toggleCmd, _togglesPath, _coerceToggle };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.173",
3
+ "version": "1.36.174",
4
4
  "description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
5
5
  "keywords": [
6
6
  "leerness",
@@ -50,7 +50,7 @@
50
50
  "scripts": {
51
51
  "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
52
52
  "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js",
53
- "test:commands": "node ./scripts/command-flags-probe.js && node ./scripts/next-action-suggestion-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js",
53
+ "test:commands": "node ./scripts/command-flags-probe.js && node ./scripts/dead-flags-probe.js && node ./scripts/next-action-suggestion-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js",
54
54
  "test:next-actions": "node ./scripts/next-action-suggestion-probe.js",
55
55
  "test:installed": "node ./scripts/installed-cleanroom-probe.js",
56
56
  "test:handoff": "node ./scripts/handoff-readonly-probe.js",
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // T-0094: advertised options must change observable behavior (or fail closed).
5
+ // This probe intentionally uses isolated projects and real CLI processes so a
6
+ // flag that is merely present in source/help cannot pass.
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const cp = require('child_process');
12
+
13
+ const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
14
+ const roots = [];
15
+ const children = [];
16
+ const failures = [];
17
+
18
+ function temp(prefix) {
19
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
20
+ roots.push(root);
21
+ return root;
22
+ }
23
+
24
+ function mkdirp(p) { fs.mkdirSync(p, { recursive: true }); }
25
+ function write(p, text) { mkdirp(path.dirname(p)); fs.writeFileSync(p, text, 'utf8'); }
26
+
27
+ function run(args, opts = {}) {
28
+ return cp.spawnSync(process.execPath, [CLI, ...args], {
29
+ cwd: opts.cwd || os.tmpdir(),
30
+ encoding: 'utf8',
31
+ timeout: opts.timeout || 30000,
32
+ env: {
33
+ ...process.env,
34
+ FORCE_COLOR: '0',
35
+ NO_COLOR: '1',
36
+ LEERNESS_NO_PROMPT: '1',
37
+ LEERNESS_NO_STALE_CHECK: '1',
38
+ ...(opts.env || {}),
39
+ },
40
+ });
41
+ }
42
+
43
+ function git(root, args) {
44
+ const r = cp.spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 15000 });
45
+ if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${(r.stderr || r.stdout || '').trim()}`);
46
+ return (r.stdout || '').trim();
47
+ }
48
+
49
+ function json(result) {
50
+ try { return JSON.parse((result.stdout || '').trim()); }
51
+ catch { throw new Error(`invalid JSON (exit ${result.status}): ${(result.stdout || result.stderr || '').slice(0, 300)}`); }
52
+ }
53
+
54
+ function assert(condition, message) {
55
+ if (!condition) throw new Error(message);
56
+ }
57
+
58
+ async function check(name, fn) {
59
+ try {
60
+ await fn();
61
+ process.stdout.write(`ok - ${name}\n`);
62
+ } catch (error) {
63
+ failures.push(`${name}: ${error.message}`);
64
+ process.stdout.write(`not ok - ${name}: ${error.message}\n`);
65
+ }
66
+ }
67
+
68
+ function snapshotFiles(root, rels) {
69
+ return rels.map(rel => {
70
+ const fp = path.join(root, rel);
71
+ return fs.existsSync(fp) ? fs.readFileSync(fp).toString('base64') : null;
72
+ });
73
+ }
74
+
75
+ async function waitForFile(file, timeoutMs = 5000) {
76
+ const until = Date.now() + timeoutMs;
77
+ while (Date.now() < until) {
78
+ if (fs.existsSync(file) && fs.readFileSync(file, 'utf8').trim()) return;
79
+ await new Promise(resolve => setTimeout(resolve, 25));
80
+ }
81
+ throw new Error(`timed out waiting for ${file}`);
82
+ }
83
+
84
+ async function main() {
85
+ await check('memory archive positional path survives value flags', () => {
86
+ const root = temp('leerness-dead-memory-');
87
+ write(path.join(root, '.leerness', 'decisions.archive.md'), '# Decisions archive\n\n## 제거 2026-01-01 (target: "T-9")\n\n### Old decision\n- Decision: keep evidence\n');
88
+ write(path.join(root, '.leerness', 'lessons.archive.md'), '# Lessons archive\n\n## 제거 2026-01-02 (target: "other")\n\n### Other lesson\n');
89
+ const r = run(['memory', 'archive', 'list', '--surface', 'decisions', '--query', 'T-9', root, '--json']);
90
+ const j = json(r);
91
+ assert(r.status === 0 && j.root === path.resolve(root), `wrong root/status: ${j.root || r.status}`);
92
+ assert(j.totals.decisions === 1 && j.totals.lessons === 0 && j.totals.all === 1, `wrong totals: ${JSON.stringify(j.totals)}`);
93
+ });
94
+
95
+ await check('intent expand remains exact dry-run and rejects ghost approval flags', () => {
96
+ const root = temp('leerness-dead-intent-');
97
+ const progress = path.join(root, '.leerness', 'progress-tracker.md');
98
+ write(progress, '# Progress\n\n| ID | Request | Status | Evidence | Next action |\n|---|---|---|---|---|\n');
99
+ const before = snapshotFiles(root, ['.leerness/progress-tracker.md', '.leerness/plan.md']);
100
+ const request = '기본 게임 기능을 만들어줘';
101
+ const base = run(['intent', 'expand', request, '--path', root, '--json']);
102
+ const bj = json(base);
103
+ assert(base.status === 0 && bj.text === request && bj.mode === 'dry-run', `base contract drift: ${base.stdout.slice(0, 240)}`);
104
+ for (const extra of [['--expand-all'], ['--select', '1']]) {
105
+ const r = run(['intent', 'expand', request, ...extra, '--path', root, '--json']);
106
+ const j = json(r);
107
+ assert(r.status !== 0 && j.code === 'unknown_flag', `${extra[0]} did not fail closed: ${r.status}/${j.code}`);
108
+ }
109
+ assert(JSON.stringify(snapshotFiles(root, ['.leerness/progress-tracker.md', '.leerness/plan.md'])) === JSON.stringify(before), 'dry-run changed task state');
110
+ });
111
+
112
+ await check('auto-update status reports installed, missing, and corrupt state without writes', () => {
113
+ const root = temp('leerness-dead-autoupdate-');
114
+ const settings = path.join(root, '.claude', 'settings.local.json');
115
+ const slash = path.join(root, '.claude', 'commands', 'update.md');
116
+ write(settings, JSON.stringify({ permissions: { allow: ['Read'] }, hooks: { SessionStart: [
117
+ { matcher: '*', command: 'leerness update --check --quiet' },
118
+ { matcher: 'startup|clear|compact', command: 'leerness hook session-start' },
119
+ ] } }, null, 2) + '\n');
120
+ write(slash, '# /update\n\n```\n!leerness update --yes\n```\n\n```\n!leerness update --check\n```\n');
121
+ const before = snapshotFiles(root, ['.claude/settings.local.json', '.claude/commands/update.md']);
122
+ const r = run(['auto-update', 'status', root, '--json']);
123
+ const j = json(r);
124
+ assert(r.status === 0 && j.ok === true && j.installed === true, `installed state not reported: ${r.status}/${r.stdout.slice(0, 240)}`);
125
+ assert(j.hooks && j.hooks.updateCheck === true && j.hooks.contextInjection === true && j.slashCommand === true, 'component status missing');
126
+ assert(JSON.stringify(snapshotFiles(root, ['.claude/settings.local.json', '.claude/commands/update.md'])) === JSON.stringify(before), 'status mutated installed files');
127
+
128
+ write(settings, JSON.stringify({ hooks: { SessionStart: [
129
+ { matcher: '*', command: 'leerness update --check' },
130
+ { matcher: 'startup|clear|compact', command: 'leerness hook session-start' },
131
+ ] } }, null, 2) + '\n');
132
+ const degraded = json(run(['auto-update', 'status', root, '--json']));
133
+ assert(degraded.installed === false && degraded.hooks.updateCheck === true && degraded.hooks.updateQuiet === false,
134
+ 'non-quiet legacy hook was reported as fully installed');
135
+
136
+ write(settings, JSON.stringify({ hooks: { SessionStart: [
137
+ { matcher: '*', command: 'echo leerness update --check --quiet' },
138
+ { matcher: 'startup|clear|compact', command: 'echo leerness hook session-start' },
139
+ ] } }, null, 2) + '\n');
140
+ write(slash, '# /update\n');
141
+ const inert = json(run(['auto-update', 'status', root, '--json']));
142
+ assert(inert.installed === false && inert.hooks.updateCheck === false && inert.hooks.contextInjection === false && inert.slashCommand === false,
143
+ 'inert lookalikes were reported as executable installation');
144
+ const repaired = run(['auto-update', 'install', root]);
145
+ const repairedStatus = json(run(['auto-update', 'status', root, '--json']));
146
+ assert(repaired.status === 0 && repairedStatus.installed === true, 'install did not repair inert lookalikes');
147
+
148
+ const missing = temp('leerness-dead-autoupdate-missing-');
149
+ const mr = run(['auto-update', 'status', missing, '--json']);
150
+ const mj = json(mr);
151
+ assert(mr.status === 0 && mj.ok === true && mj.installed === false && mj.exists === false, 'missing state is not an honest non-error');
152
+ assert(fs.readdirSync(missing).length === 0, 'missing status created files');
153
+
154
+ const corrupt = temp('leerness-dead-autoupdate-corrupt-');
155
+ const corruptFile = path.join(corrupt, '.claude', 'settings.local.json');
156
+ write(corruptFile, '{broken');
157
+ const cr = run(['auto-update', 'status', corrupt, '--json']);
158
+ const cj = json(cr);
159
+ assert(cr.status !== 0 && cj.ok === false && cj.code === 'settings_corrupt' && cj.installed === false, 'corrupt state did not fail honestly');
160
+ assert(fs.readFileSync(corruptFile, 'utf8') === '{broken', 'corrupt status rewrote source');
161
+
162
+ for (const malformed of [
163
+ 'null',
164
+ '[]',
165
+ '{"hooks":[]}',
166
+ '{"hooks":{"SessionStart":{}}}',
167
+ '{"hooks":{"SessionStart":[42]}}',
168
+ '{"hooks":{"SessionStart":[{"matcher":42,"command":"leerness update --check --quiet"}]}}',
169
+ '{"hooks":{"SessionStart":[{"hooks":[null]}]}}',
170
+ ]) {
171
+ const malformedRoot = temp('leerness-dead-autoupdate-shape-');
172
+ const malformedFile = path.join(malformedRoot, '.claude', 'settings.local.json');
173
+ write(malformedFile, malformed);
174
+ const sr = run(['auto-update', 'status', malformedRoot, '--json']);
175
+ const sj = json(sr);
176
+ assert(sr.status !== 0 && sj.code === 'settings_corrupt' && sj.valid === false,
177
+ `malformed settings accepted by status: ${malformed}`);
178
+ const ir = run(['auto-update', 'install', malformedRoot]);
179
+ assert(ir.status !== 0 && fs.readFileSync(malformedFile, 'utf8') === malformed,
180
+ `malformed settings overwritten by install: ${malformed}`);
181
+ }
182
+
183
+ const notDir = path.join(temp('leerness-dead-autoupdate-file-'), 'package.json');
184
+ write(notDir, '{}\n');
185
+ const fr = run(['auto-update', 'status', notDir, '--json']);
186
+ const fj = json(fr);
187
+ assert(fr.status !== 0 && fj.code === 'path_not_directory', `file root accepted: ${fr.status}/${fr.stdout.slice(0, 200)}`);
188
+ });
189
+
190
+ await check('release cleanup honors keep=0 and rejects negative keep', () => {
191
+ const root = temp('leerness-dead-release-');
192
+ git(root, ['init', '-b', 'main']);
193
+ git(root, ['config', 'user.email', 'probe@example.invalid']);
194
+ git(root, ['config', 'user.name', 'Leerness Probe']);
195
+ write(path.join(root, 'seed.txt'), 'seed\n');
196
+ git(root, ['add', 'seed.txt']);
197
+ git(root, ['commit', '-m', 'seed']);
198
+ git(root, ['branch', 'release/1.0.0']);
199
+ git(root, ['branch', 'release/1.0.1']);
200
+ const r = run(['release', 'cleanup', root, '--keep', '0', '--json']);
201
+ const j = json(r);
202
+ assert(r.status === 0 && j.keep === 0 && j.deleteCount === 2 && j.apply === false, `keep=0 ignored: ${r.stdout.slice(0, 240)}`);
203
+ assert(git(root, ['branch', '--list', 'release/*']).split(/\r?\n/).filter(Boolean).length === 2, 'dry-run deleted branches');
204
+ const nr = run(['release', 'cleanup', root, '--keep', '-3', '--json']);
205
+ const nj = json(nr);
206
+ assert(nr.status !== 0 && nj.code === 'invalid_keep', `negative keep accepted: ${nr.status}/${nr.stdout.slice(0, 160)}`);
207
+ });
208
+
209
+ await check('setup-agents no-setup flag is an explicit read-only skip', () => {
210
+ const root = temp('leerness-dead-agents-');
211
+ write(path.join(root, 'keep.txt'), 'keep\n');
212
+ const before = snapshotFiles(root, ['keep.txt', '.env', '.leerness/providers.json']);
213
+ const r = run(['setup-agents', '--no-setup-agents', '--path', root, '--json']);
214
+ const j = json(r);
215
+ assert(r.status === 0 && j.ok === true && j.skipped === true && j.reason === 'no_setup_agents' && j.root === path.resolve(root), `skip not explicit: ${r.status}/${r.stdout.slice(0, 240)}`);
216
+ assert(JSON.stringify(snapshotFiles(root, ['keep.txt', '.env', '.leerness/providers.json'])) === JSON.stringify(before), 'skip mutated project');
217
+
218
+ const configured = temp('leerness-dead-agents-init-');
219
+ const configFile = path.join(configured, '.leerness', 'leerness-config.json');
220
+ write(configFile, JSON.stringify({ LEERNESS_ENABLE_CODEX: '1', CUSTOM_KEEP: 'yes' }, null, 2) + '\n');
221
+ const init = run(['init', configured, '--yes', '--no-setup-agents', '--minimal', '--no-env', '--no-auto-update', '--no-auto-roadmap', '--no-enforce', '--no-mcp', '--no-banner', '--language', 'en', '--json'], { timeout: 120000 });
222
+ assert(init.status === 0, `init opt-out failed: ${(init.stdout || init.stderr).slice(0, 240)}`);
223
+ const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
224
+ assert(config.LEERNESS_ENABLE_CODEX === '1' && config.CUSTOM_KEEP === 'yes', 'init opt-out overwrote existing agent configuration');
225
+ assert(!Object.keys(config).some(k => /^LEERNESS_ENABLE_/.test(k) && k !== 'LEERNESS_ENABLE_CODEX'), 'init opt-out introduced provider decisions');
226
+
227
+ const fresh = temp('leerness-dead-agents-fresh-');
228
+ const freshInit = run(['init', fresh, '--yes', '--no-setup-agents', '--minimal', '--no-env', '--no-auto-update', '--no-auto-roadmap', '--no-enforce', '--no-mcp', '--no-banner', '--language', 'en', '--json'], { timeout: 120000 });
229
+ assert(freshInit.status === 0, `fresh init opt-out failed: ${(freshInit.stdout || freshInit.stderr).slice(0, 240)}`);
230
+ const freshConfig = JSON.parse(fs.readFileSync(path.join(fresh, '.leerness', 'leerness-config.json'), 'utf8'));
231
+ assert(!Object.keys(freshConfig).some(k => /^LEERNESS_ENABLE_/.test(k)), 'fresh init opt-out wrote implicit provider flags');
232
+ });
233
+
234
+ await check('provider add persists and exposes env/version/description options', () => {
235
+ const root = temp('leerness-dead-provider-');
236
+ // A value that itself starts with "--" uses the CLI's documented
237
+ // --flag=value spelling so it cannot be mistaken for another option.
238
+ const add = run(['provider', 'add', 'probeai', '--bin', 'probe-ai', '--env-flag', 'PROBE_AI_ON', '--version-args=--version --verbose', '--desc', 'Probe provider', '--path', root]);
239
+ assert(add.status === 0, `provider add failed: ${(add.stdout || add.stderr).slice(0, 240)}`);
240
+ const stored = JSON.parse(fs.readFileSync(path.join(root, '.leerness', 'providers.json'), 'utf8'));
241
+ const entry = (Array.isArray(stored) ? stored : stored.providers).find(p => p.id === 'probeai');
242
+ assert(entry && entry.envFlag === 'PROBE_AI_ON' && entry.desc === 'Probe provider', 'stored fields missing');
243
+ assert(JSON.stringify(entry.versionArgs) === JSON.stringify(['--version', '--verbose']), `stored versionArgs wrong: ${JSON.stringify(entry && entry.versionArgs)}`);
244
+ const lr = run(['provider', 'list', '--path', root, '--json']);
245
+ const listed = json(lr).providers.find(p => p.id === 'probeai');
246
+ assert(listed && listed.envFlag === 'PROBE_AI_ON' && listed.desc === 'Probe provider', 'list omits env/desc');
247
+ assert(JSON.stringify(listed.versionArgs) === JSON.stringify(['--version', '--verbose']), 'list omits versionArgs');
248
+ const enList = run(['provider', 'list', '--path', root, '--language', 'en']);
249
+ assert(enList.status === 0 && !/[가-힣ㄱ-ㆎ]/.test(enList.stdout + enList.stderr), 'English provider list leaked Korean built-in descriptions');
250
+ });
251
+
252
+ await check('reuse-map strict-elements changes duplicate analysis', () => {
253
+ const a = temp('leerness-dead-reuse-a-');
254
+ const b = temp('leerness-dead-reuse-b-');
255
+ write(path.join(a, '.leerness', 'reuse-map.md'), '# Reuse Map\n\n| Capability | Element | Method | Notes |\n|---|---|---|---|\n| HtmlEscape | src/util.js (escapeHtml) | util | XSS |\n');
256
+ write(path.join(b, '.leerness', 'reuse-map.md'), '# Reuse Map\n\n| Capability | Element | Method | Notes |\n|---|---|---|---|\n| MarkupEscape | src/build.js (escapeHtml) | util | markup |\n');
257
+ const include = `${a},${b}`;
258
+ const base = json(run(['reuse-map', '--include', include, '--json']));
259
+ const strict = json(run(['reuse-map', '--include', include, '--strict-elements', '--json']));
260
+ assert(base.strictElements === false && base.fuzzyDuplicates.length === 0, 'default unexpectedly ran fuzzy analysis');
261
+ assert(strict.strictElements === true && strict.fuzzyDuplicates.length === 1 && strict.fuzzyDuplicates[0].functionName === 'escapehtml', 'strict analysis missing');
262
+ });
263
+
264
+ await check('api-skill no-crawl suppresses secondary HTTP requests', async () => {
265
+ const serverRoot = temp('leerness-dead-api-server-');
266
+ const serverFile = path.join(serverRoot, 'server.js');
267
+ const portFile = path.join(serverRoot, 'port.txt');
268
+ const countFile = path.join(serverRoot, 'counts.json');
269
+ write(serverFile, [
270
+ "'use strict';",
271
+ "const fs=require('fs'); const http=require('http');",
272
+ "const portFile=process.argv[2], countFile=process.argv[3]; const count={root:0,related:0};",
273
+ "const save=()=>fs.writeFileSync(countFile,JSON.stringify(count));",
274
+ "const server=http.createServer((req,res)=>{",
275
+ " if(req.url==='/related'){count.related++;save();res.end('<html><title>Related</title><body>secondary</body></html>');return;}",
276
+ " count.root++;save();res.end('<html><title>Root API</title><body>primary<a href=\"/related\">related</a></body></html>');",
277
+ "});",
278
+ "server.listen(0,'127.0.0.1',()=>fs.writeFileSync(portFile,String(server.address().port)));",
279
+ "process.on('SIGTERM',()=>server.close(()=>process.exit(0)));",
280
+ ].join('\n'));
281
+ const child = cp.spawn(process.execPath, [serverFile, portFile, countFile], { stdio: 'ignore' });
282
+ children.push(child);
283
+ await waitForFile(portFile);
284
+ const url = `http://127.0.0.1:${fs.readFileSync(portFile, 'utf8').trim()}/`;
285
+ const noRoot = temp('leerness-dead-api-no-');
286
+ const nr = run(['api-skill', 'add', url, '--no-crawl', '--path', noRoot, '--json']);
287
+ const nj = json(nr);
288
+ const afterNo = JSON.parse(fs.readFileSync(countFile, 'utf8'));
289
+ assert(nr.status === 0 && nj.ok === true && nj.crawl === false && nj.related_count === 0, `no-crawl result opaque/wrong: ${nr.stdout.slice(0, 240)}`);
290
+ assert(afterNo.root === 1 && afterNo.related === 0, `secondary fetch occurred: ${JSON.stringify(afterNo)}`);
291
+ const beforeRoot = afterNo.root;
292
+ const noRootFirst = temp('leerness-dead-api-no-first-');
293
+ const nfr = run(['api-skill', 'add', '--no-crawl', url, '--path', noRootFirst, '--json']);
294
+ const nfj = json(nfr);
295
+ const afterFirst = JSON.parse(fs.readFileSync(countFile, 'utf8'));
296
+ assert(nfr.status === 0 && nfj.crawl === false && afterFirst.root === beforeRoot + 1 && afterFirst.related === 0,
297
+ `boolean-before-positional order failed: ${nfr.status}/${nfr.stdout.slice(0, 200)}`);
298
+ const yesRoot = temp('leerness-dead-api-yes-');
299
+ const yr = run(['api-skill', 'add', url, '--path', yesRoot, '--json']);
300
+ const yj = json(yr);
301
+ const afterYes = JSON.parse(fs.readFileSync(countFile, 'utf8'));
302
+ assert(yr.status === 0 && yj.crawl === true && yj.related_count >= 1 && afterYes.related >= 1, `default crawl did not fetch related link: ${yr.stdout.slice(0, 240)}`);
303
+ child.kill();
304
+ });
305
+
306
+ await check('parent adopt select value is not mistaken for a project path', () => {
307
+ const parent = temp('leerness-dead-parent-');
308
+ const child = path.join(parent, 'child');
309
+ mkdirp(child);
310
+ write(path.join(parent, '.leerness', 'design-system.md'), '# Parent design\n');
311
+ const r = run(['parent', 'adopt', '--select', 'design-system', '--apply', '--json'], { cwd: child });
312
+ const j = json(r);
313
+ assert(r.status === 0 && j.applied === true && j.root === path.resolve(child), `cwd root lost: ${r.status}/${r.stdout.slice(0, 240)}`);
314
+ const link = JSON.parse(fs.readFileSync(path.join(child, '.leerness', 'PARENT_LINK.json'), 'utf8'));
315
+ assert(JSON.stringify(link.adoptedKinds) === JSON.stringify(['design-system']), `selection ignored: ${JSON.stringify(link.adoptedKinds)}`);
316
+ });
317
+
318
+ await check('toggle get returns one read-only toggle', () => {
319
+ const root = temp('leerness-dead-toggle-');
320
+ const r = run(['toggle', 'get', 'gate', '--path', root, '--json']);
321
+ const j = json(r);
322
+ assert(r.status === 0 && j.ok === true && j.id === 'gate' && j.value === true && j.corrupt === false && j.source === 'default', `toggle get failed: ${r.status}/${r.stdout.slice(0, 240)}`);
323
+ assert(fs.readdirSync(root).length === 0, 'toggle get created state');
324
+
325
+ const toggles = path.join(root, '.leerness', 'toggles.json');
326
+ write(toggles, JSON.stringify({ lens: false }) + '\n');
327
+ const absent = json(run(['toggle', 'get', 'gate', '--path', root, '--json']));
328
+ assert(absent.source === 'default' && absent.value === true, 'unpersisted id was mislabeled stored');
329
+ write(toggles, JSON.stringify({ gate: 'banana' }) + '\n');
330
+ const unreadable = json(run(['toggle', 'get', 'gate', '--path', root, '--json']));
331
+ assert(unreadable.source === 'default' && unreadable.unreadable === true && unreadable.value === true,
332
+ 'unreadable id was mislabeled stored');
333
+ for (const inherited of ['toString', 'constructor', '__proto__']) {
334
+ const badGet = run(['toggle', 'get', inherited, '--path', root, '--json']);
335
+ assert(badGet.status !== 0 && json(badGet).ok === false, `prototype toggle id accepted by get: ${inherited}`);
336
+ const badSet = run(['toggle', 'set', inherited, 'on', '--path', root, '--json']);
337
+ assert(badSet.status !== 0 && json(badSet).ok === false, `prototype toggle id accepted by set: ${inherited}`);
338
+ }
339
+ });
340
+
341
+ await check('MCP mirrors provider metadata and api no-crawl options', () => {
342
+ const tools = require('../lib/mcp-tools');
343
+ const provider = tools.find(tool => tool.name === 'leerness_provider_list');
344
+ const api = tools.find(tool => tool.name === 'leerness_api_skill');
345
+ const src = fs.readFileSync(CLI, 'utf8');
346
+ assert(provider && /versionArgs/.test(provider.description) && /installHint/.test(provider.description), 'provider MCP contract is stale');
347
+ assert(api && api.inputSchema.properties.noCrawl && api.inputSchema.properties.name, 'api MCP schema omits noCrawl/name');
348
+ assert(/args\.noCrawl === true\) cliArgs\.push\('--no-crawl'\)/.test(src), 'api MCP dispatcher drops noCrawl');
349
+ });
350
+
351
+ if (failures.length) {
352
+ process.stderr.write(`DEAD_FLAGS_PROBE_FAILED: ${failures.length}\n${failures.map(x => `- ${x}`).join('\n')}\n`);
353
+ process.exitCode = 1;
354
+ } else {
355
+ process.stdout.write('dead flags probe passed\n');
356
+ }
357
+ }
358
+
359
+ main().catch(error => {
360
+ process.stderr.write(`DEAD_FLAGS_PROBE_FAILED: fatal: ${error.stack || error.message}\n`);
361
+ process.exitCode = 1;
362
+ }).finally(() => {
363
+ for (const child of children) { try { child.kill(); } catch {} }
364
+ for (const root of roots) { try { fs.rmSync(root, { recursive: true, force: true }); } catch {} }
365
+ });
package/scripts/e2e.js CHANGED
@@ -10981,7 +10981,8 @@ total++;
10981
10981
  // 좁은 목록의 기준선은 실제 부채보다 낙관적이었다. 줄인 만큼만 낮춘다(느슨하게 두면 그 안에서 조용히 썩는다).
10982
10982
  // 1.36.112: 106 으로 조인다. `context budget` 을 손대는 김에 영어화해 5줄을 갚았다(111 → 106, 실측).
10983
10983
  // 갚은 만큼만 낮춘다 — 여유를 남기면 그 안에서 조용히 썩는다(1.36.82 의 자기참조 가드에서 겪은 형태).
10984
- const BASELINE = 106;
10984
+ // 1.36.174: provider list 영어 모드의 요약/안내 두 줄을 영어화해 104로 조임(39개 명령 실측).
10985
+ const BASELINE = 104;
10985
10986
  dbg.baseline = BASELINE;
10986
10987
  dbg.withinRatchet = leaky <= BASELINE;
10987
10988
  // 이번 라운드가 고친 표면은 **0 이어야** 한다 — 래칫과 별개로 회귀를 직접 막는다.