leerness 1.36.100 → 1.36.102

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,66 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.102 — 2026-08-05 — 모르는 것을 안다고 말하지 않는다 · 준 경로에 적용한다
4
+
5
+ 1.36.101 의 명령 표면 감사에서 남겨 둔 P1 두 건을 닫는다. 둘 다 **재현부터 하고** 고쳤다.
6
+
7
+ ### `update --check` 가 자기 말을 뒤집었다
8
+
9
+ ```
10
+ npm leerness latest: (unavailable — 판정 보류, 다음 실행에서 재조회)
11
+ → up to date
12
+ ```
13
+
14
+ 바로 윗줄에서 *"모른다"* 고 해 놓고 아랫줄에서 *"최신"* 이라 단정했다. 조회 실패가 판정 분기까지 전달되지 않아, `latest = latest || VERSION` 이 자기 버전을 정답처럼 쓰고 있었다. 이제 결론이 셋으로 갈린다 — `migration_available` / `up_to_date` / **`unknown`**. 모르면 *"확인 불가 — 레지스트리를 조회하지 못했습니다"* 라고 말한다.
15
+
16
+ 같은 자리에서 **`--json` 이 사람용 텍스트를 내던 것**도 고쳤다(`# leerness update` · `Current:` · `Checking npm registry…` 가 그대로 섞여 JSON 파싱이 불가능했다). `--json` 은 `{verdict, registryReachable, latest, current, cli}` 만 낸다.
17
+
18
+ ### `plan init <경로>` 가 그 경로 대신 현재 디렉토리를 채웠다
19
+
20
+ 실측 재현: 대상 디렉토리는 1개 파일 그대로인데 **cwd 가 1개 → 72개**가 됐다. 홈에서 실행하면 홈이 워크스페이스가 된다. `plan` 그룹이 `--path` 만 읽고 positional 은 `list` 만 지원했는데, **파괴적인 `init` 이 그 갭에 있었다**. 이제 준 경로에 적용하고, 없는 경로는 만들지 않고 거부한다.
21
+
22
+ ### 가드
23
+
24
+ selftest 343 · **변이 6/6 사살**. 가드는 소스 grep 이 아니라 실제 실행으로 검사한다 — 조회 실패 시 결론 문구 · `--json` 파싱 가능 여부 · `plan init` 후 양쪽 디렉토리의 파일 수 · 없는 경로 거부까지 모두 명령을 돌려 확인한다(1.36.101 에서 소스 grep 가드가 세 번 무너진 뒤 세운 원칙).
25
+
26
+ ## 1.36.101 — 2026-08-05 — 명령 표면 전수 감사: 경로 탈출로 남의 파일이 지워지고 있었다
27
+
28
+ 사용자 요청: *"leerness가 적용된 프로젝트에서 leerness 명령어가 의도한대로 동작하는지 테스트 및 디버그도 함께 진행."*
29
+
30
+ 명령 **1,488회**를 신선한 임시 프로젝트에서 실행했다(각 명령의 정상 경로 · `--json` · 인자 없음 · 잘못된 하위명령 · 없는 경로 · `--language en`). 결함 **73건 + 완결성 비평 5건**이 살아남았다(6건은 재현 실패로 반박). 이 릴리스는 그중 **P1 3건**을 닫는다.
31
+
32
+ ### 🚨 skill id 경로 탈출 — 프로젝트 밖 파일이 삭제·덮어쓰기 됐다
33
+
34
+ ```
35
+ skill remove "../../../../../victim" → exit 0 · "✓ user skill removed"
36
+ → 프로젝트 밖 디렉토리를 재귀 삭제 (재현 확인)
37
+ skill learn "../.." → 프로젝트 README.md 4,243바이트 → 125바이트 (재현 확인)
38
+ ```
39
+
40
+ `id` 는 사용자 입력인데 검증 없이 `<root>/.harness/skills/<id>` 에 붙었다. 호출부마다 막으면 다음 진입점에서 또 새므로 **id 를 쓰는 모든 함수가 통과하는 단일 병목**에서 거부하고, 파괴적 경로(remove)에는 경로 재확인을 한 겹 더 뒀다. 평범한 id(`open-meteo` 등)는 그대로 동작한다.
41
+
42
+ ### 🚨 "없는 경로에 초록 보고" — 조사한 32개 중 16개
43
+
44
+ `pulse`·`round-history`·`milestones`·`session-resume` 은 **존재하지 않는 디렉토리**에 exit 0 과 함께 *"✓ 비정상 종료 신호 없음 · R0 · 정상 운영 중"* 을 찍었다. 오타 하나가 안심 보고가 된다. `handoff`·`health`·`status` 는 같은 입력을 이미 exit 1 로 거부하고 있었으니, 절반만 지켜지던 관례다 — 코드 주석에 *"UR-0136 에서 이미 스윕했다"* 고 적혀 있는데도 절반이 남아 있었다(같은 클래스의 3회차).
45
+
46
+ 경로 해석 방식이 명령마다 제각각(`--path` / positional / `_resolveRoot`)이라 개별 수정이 계속 빠졌다 → **디스패처 한 곳**에서 검사한다. 실측: 뚫림 **16 → 0**, 정상 경로 **26/26** 그대로 동작(대조군 — '전부 거부' 하는 구현으로도 뚫림 0 은 나온다).
47
+
48
+ ### 있는데 안 보이던 명령 · 거짓 안내
49
+
50
+ - 디스패처 126개 중 **46개가 `leerness commands` 에 없었다.** 그중 **`library` 는 `--help` 에도 없었다** — 1.36.98 에서 내가 디스패처에만 붙이고 두 표면에 등재하지 않았다. `gate`·`toggle` 도 같았다. 셋 다 등재하고, 핵심 명령의 등재를 selftest 가 실제 출력으로 확인한다.
51
+ - `readme`·`contract`·`self`·`reuse`·`consistency`·`mcp`·`hook`·`llm-bench`·`auto-update`·`ui` 를 하위명령 없이 부르면 **"알 수 없는 명령"** 이라고 답했다. `readme` 는 알 수 없는 명령이 아니라 `readme sync` 가 필요한 명령이다 — 사용자를 오타 찾기로 보내는 거짓 안내였다. 안내 메커니즘은 이미 있었고 **데이터가 4개뿐**이었다.
52
+ - 그 두 맵(ko/en)을 모듈 스코프로 올려 **키 집합이 같은지 데이터로 단언**한다. 실행으로만 검사하면 `--language en` 이 프로젝트 밖에서 전환되지 않아 en 맵이 무방비로 남는다(변이에서 en 항목을 지워도 통과했다 — 1.36.89·1.36.97 에 이은 3회차).
53
+
54
+ ### 가드
55
+
56
+ selftest 343 · 변이 6/8 사살(나머지 2건은 5가지 탈출 시도를 실행해 **등가임을 증명**) · 표면 변이 6/7 · 경로 클래스 정상경로 26/26.
57
+
58
+ 가드를 짜는 동안 **소스 grep 이 세 번 무너졌다**: (1) `const _GROUP_USAGE = {` 문자열이 다른 selftest 의 가드 안에도 있어 슬라이스가 엉뚱한 곳을 집었고 (2) 선언 이름을 바꾸자 옛 가드가 허공을 가리켜 메타가드가 잡았고 (3) 이중가드 확인용 문자열이 **가드 자신에** 있어 자기참조로 통과했다. 그래서 결정을 남긴다 — **표면 가드는 소스 grep 이 아니라 실제 출력·데이터로 검사한다.** 술어만 시험하면 배선이 빠진 것도 못 본다(`skillLearn` 에서 호출을 지워도 통과했다).
59
+
60
+ ### 남은 것 (T-0092~0096 으로 등록)
61
+
62
+ i18n 누수 13건(`--language en` 무시) · invalid-json 5건 · dead-flag 9건 · false-claim 17건(그중 P1: `update --check` 가 오프라인 조회 실패를 *"최신입니다"* 로 단정 · `plan init <경로>` 가 인자를 무시하고 cwd 에 71파일 워크스페이스 설치) · `glossary build` 2회 실행 시 크래시. 그리고 자체 테스트가 임시 디렉토리를 **79,591개** 누수하고 있다(T-0090).
63
+
3
64
  ## 1.36.100 — 2026-08-05 — 승인된 시안을 계약으로 고정하고, 구현이 지켰는지 기계로 대조한다 (P-0012 후반)
4
65
 
5
66
  사용자가 고른 방식의 후반부다: *"승인 시 그대로 적용"* 을 **"시안을 계약으로 고정 + 구현 후 자동 대조"** 로 구현한다.
package/README.md CHANGED
@@ -122,7 +122,7 @@ MIT
122
122
  <!-- leerness:project-readme:start -->
123
123
  ## Leerness Project Harness
124
124
 
125
- 이 프로젝트는 Leerness v1.36.100 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.102 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
126
126
 
127
127
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
128
128
 
@@ -176,7 +176,7 @@ leerness memory restore decision <date|title>
176
176
 
177
177
  ### MCP server (외부 AI 통합)
178
178
 
179
- Leerness v1.36.100는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.102는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
180
180
 
181
181
  ```jsonc
182
182
  // 카테고리별
@@ -197,7 +197,7 @@ Leerness v1.36.100는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
197
197
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
198
198
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
199
199
 
200
- 현재 누적: **v1.9.x → 1.36.100 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.102 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
201
201
 
202
202
  ### 성능 가이드
203
203
 
@@ -235,5 +235,5 @@ leerness release pack --close --auto-main-push
235
235
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
236
236
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
237
237
 
238
- Last synced by Leerness v1.36.100: 2026-08-05
238
+ Last synced by Leerness v1.36.102: 2026-08-05
239
239
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
34
34
  const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
35
35
  const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
36
36
 
37
- const VERSION = '1.36.100';
37
+ const VERSION = '1.36.102';
38
38
 
39
39
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
40
40
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -1593,7 +1593,21 @@ function skillInfo(name, root) {
1593
1593
  if ((v.optimizations || []).length) { log('Optimizations:'); v.optimizations.forEach(o => log(`- ${o.at}: ${o.note || ''}`)); }
1594
1594
  }
1595
1595
 
1596
+ // 1.36.101 (명령 표면 감사 P1): skill id 가 검증되지 않아 경로를 탈출했다. 실측 재현 —
1597
+ // `skill remove "../../../../../victim"` → exit 0 · "✓ user skill removed" 와 함께 **프로젝트 밖 디렉토리를 재귀 삭제**.
1598
+ // `skill learn "../.."` → 프로젝트 README.md 를 4,243바이트 → 125바이트로 덮어씀.
1599
+ // id 는 사용자 입력이고 그대로 경로에 붙는다. 호출부마다 막으면 다음에 새 진입점에서 또 샌다 —
1600
+ // id 를 쓰는 모든 함수가 통과하는 단일 병목에서 거부한다.
1601
+ const _SKILL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
1602
+ function _badSkillId(id) {
1603
+ if (typeof id !== 'string' || !id) return 'id required';
1604
+ if (!_SKILL_ID_RE.test(id) || id.includes('..')) {
1605
+ return `잘못된 skill id: ${JSON.stringify(id)} — 영숫자/./_/- 만 허용하며 경로 구분자와 '..' 는 쓸 수 없습니다`;
1606
+ }
1607
+ return null;
1608
+ }
1596
1609
  function skillLearn(root, id) {
1610
+ { const e = _badSkillId(id); if (e) return fail(e); }
1597
1611
  if (!id) return fail('id required (e.g., skill learn open-meteo --command "..." --doc URL)');
1598
1612
  const docs = argAll('--doc');
1599
1613
  const command = arg('--command', null);
@@ -1617,6 +1631,7 @@ function skillLearn(root, id) {
1617
1631
  }
1618
1632
 
1619
1633
  function skillUse(root, id) {
1634
+ { const e = _badSkillId(id); if (e) return fail(e); }
1620
1635
  if (!id) return fail('id required');
1621
1636
  let data = loadUserSkill(root, id);
1622
1637
  if (!data) {
@@ -1633,6 +1648,7 @@ function skillUse(root, id) {
1633
1648
  }
1634
1649
 
1635
1650
  function skillOptimize(root, id) {
1651
+ { const e = _badSkillId(id); if (e) return fail(e); }
1636
1652
  if (!id) return fail('id required');
1637
1653
  let data = loadUserSkill(root, id);
1638
1654
  if (!data) {
@@ -1648,8 +1664,13 @@ function skillOptimize(root, id) {
1648
1664
  }
1649
1665
 
1650
1666
  function skillRemove(root, id) {
1667
+ { const e = _badSkillId(id); if (e) return fail(e); }
1651
1668
  if (!id) return fail('id required');
1652
1669
  const dir = path.join(userSkillsDir(root), id);
1670
+ // 이중 가드 — id 검증을 통과해도 삭제 대상이 skills 디렉토리 안인지 경로로 재확인한다(파괴적 작업이라 두 겹으로 막는다).
1671
+ const base = path.resolve(userSkillsDir(root));
1672
+ const target = path.resolve(dir);
1673
+ if (target === base || !target.startsWith(base + path.sep)) return fail(`삭제 대상이 skills 디렉토리를 벗어남: ${id}`);
1653
1674
  if (!exists(dir)) return fail(`skill folder not found: ${id}`);
1654
1675
  // 1.9.66: 캐시 invalidate
1655
1676
  try { _SKILLS_LIST_CACHE.delete(absRoot(root)); } catch {}
@@ -4829,8 +4850,10 @@ function _selfTestCases() {
4829
4850
  return c2 && c3 && c4;
4830
4851
  } },
4831
4852
  { name: '외부클린룸 UR-0042: bare 명령그룹(rule/skill/feature/memory) → 사용법 힌트(unknown command 아님) (1.16.2)', run: () => {
4832
- const src = read(__filename);
4833
- return src.includes('const _GROUP_USAGE = {') && src.includes("if (_GROUP_USAGE[cmd] && !args[1])") && src.includes("'subcommand_required'");
4853
+ // 1.36.101: 소스 grep 이던 것을 데이터/행위로 바꿨다 — 선언 이름이 바뀌자 이 가드가 허공을 가리켰고 메타가드가 잡았다.
4854
+ // 지켜야 할 성질은 ' 변수명이 존재한다' 아니라 '네 그룹이 사용법 안내를 받는다' 이다.
4855
+ return ['rule', 'skill', 'feature', 'memory'].every(c => typeof _GROUP_USAGE_KO[c] === 'string' && _GROUP_USAGE_KO[c].includes(c))
4856
+ && read(__filename).includes("'subcommand_required'");
4834
4857
  } },
4835
4858
  { name: '17th 버그헌트 P2: plan add 공백제목 trim(기본값) + milestone 파서 개행 미흡수 (1.17.1)', run: () => {
4836
4859
  const src = read(__filename);
@@ -6175,7 +6198,8 @@ function _selfTestCases() {
6175
6198
  uiOk = uiOk && tp.refreshTechProfile(nextApp).changed === true
6176
6199
  && (JSON.parse(read(path.join(nextApp, '.harness', 'tech-profile.json'))).current.ui || []).some(u => u.id === 'tailwind');
6177
6200
  } catch { uiOk = false; } finally { try { fs.rmSync(tmp3, { recursive: true, force: true }); } catch {} }
6178
- return clar && shape && noDbUrl && reqOk && guards && uiOk && _p0013LibraryOk() && _p0088CurrentStatePreserved();
6201
+ return clar && shape && noDbUrl && reqOk && guards && uiOk && _p0013LibraryOk() && _p0088CurrentStatePreserved()
6202
+ && _p0101SurfaceOk() && _p0101SkillIdOk() && _p0101PathStrictOk() && _p0102HonestyOk();
6179
6203
  } },
6180
6204
  { name: '시크릿 스캐너 F-06 (1.36.56, 외부감사): 무명 확장자 소형 텍스트 스캔(이진 NUL 제외) + 같은 토큰 중복 보고 dedupe — 행위검사', run: () => {
6181
6205
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sc56_'));
@@ -7645,6 +7669,191 @@ function _p0088CurrentStatePreserved() {
7645
7669
  const noEofWipe = !src.includes('cs.replace(/## Block' + 'ers');
7646
7670
  return preserved && idempotent && autoCount && noBlankRun && untouched && noEofWipe;
7647
7671
  }
7672
+ // 1.36.101 (명령 표면 감사): '있는데 안 보이는 명령' 과 '하위명령이 필요한데 알 수 없다고 답하는 명령' 을 기계가 막는다.
7673
+ // 실측 — 디스패처 126개 중 46개가 leerness commands 에 없었고, 그중 library(내가 1.36.98 에 넣은 것)는 --help 에도 없었다.
7674
+ // 그리고 readme/contract/self 같은 그룹 명령은 하위명령 없이 부르면 "알 수 없는 명령" 이라고 거짓으로 답했다.
7675
+ // 여기서 지키는 건 '전부 등재' 가 아니라 **핵심 사용자 명령이 최소 한 표면에는 보인다** 는 것이다 —
7676
+ // 내부 진입점(slash·usage·about 등)까지 강제하면 가드가 잡음이 되어 무시된다.
7677
+ const _SURFACE_MUST_LIST = ['library', 'lens', 'preview', 'task', 'plan', 'gate', 'audit', 'handoff', 'health', 'tech', 'toggle', 'verify-claim'];
7678
+ // 1.16.2 (외부클린룸 UR-0042): 유효 명령그룹을 하위명령 없이 부르면 'unknown command'(혼란) 대신 사용법 힌트.
7679
+ // 1.36.101: main() 안의 지역 변수였던 것을 모듈 스코프로 올렸다 — selftest 가 '두 맵의 키 집합이 같은가' 를
7680
+ // 데이터로 검사할 수 있어야 한다. 문자열 실행으로만 검사하면 --language en 이 프로젝트 밖에서 전환되지 않아
7681
+ // en 맵이 무방비로 남는다(변이에서 en 항목을 지워도 통과했다 — 1.36.89·1.36.97 에 이은 같은 사각지대의 3회차).
7682
+ // 그리고 이 맵이 4개뿐이라 readme/contract/self 같은 그룹은 "알 수 없는 명령" 이라는 거짓 안내를 냈다 —
7683
+ // readme 는 알 수 없는 명령이 아니라 `readme sync` 가 필요한 명령이다. 실행으로 전수 확인해 10개를 채웠다.
7684
+ const _GROUP_USAGE_KO = {
7685
+ rule: 'rule add "<텍스트>" --trigger <트리거> | rule list | rule pause/resume/remove <ID> | rule verify',
7686
+ skill: 'skill list | skill add <id> | skill use <id> | skill search "<키>" | skill match "<텍스트>"',
7687
+ feature: 'feature add "<이름>" | feature list | feature show <ID> | feature link <A> <B> | feature impact <ID>',
7688
+ memory: 'memory search "<키>" [--json] | memory status | memory archive | memory restore',
7689
+ readme: 'readme sync [path]',
7690
+ contract: 'contract verify <spec.md> <impl.js> [--json] [--allow-empty]',
7691
+ self: 'self check [path]',
7692
+ reuse: 'reuse autodetect [path] [--apply] [--json] | reuse find "<capability>"',
7693
+ consistency: 'consistency check [path] | consistency merge-design-guide [path]',
7694
+ mcp: 'mcp serve [--profile core|all]',
7695
+ hook: 'hook install [path] | hook status',
7696
+ 'llm-bench': 'llm-bench record --score N --model X [--label L] [--tokens T]',
7697
+ 'auto-update': 'auto-update install [path] | auto-update status',
7698
+ ui: 'ui check | ui open',
7699
+ };
7700
+ // 1.23.2 (UR-0010 Phase 7): 영어 자리표시자 병렬맵 — 키 집합은 ko 와 항상 같아야 한다(selftest 가 단언).
7701
+ const _GROUP_USAGE_EN = {
7702
+ rule: 'rule add "<text>" --trigger <trigger> | rule list | rule pause/resume/remove <ID> | rule verify',
7703
+ skill: 'skill list | skill add <id> | skill use <id> | skill search "<key>" | skill match "<text>"',
7704
+ feature: 'feature add "<name>" | feature list | feature show <ID> | feature link <A> <B> | feature impact <ID>',
7705
+ memory: 'memory search "<key>" [--json] | memory status | memory archive | memory restore',
7706
+ readme: 'readme sync [path]',
7707
+ contract: 'contract verify <spec.md> <impl.js> [--json] [--allow-empty]',
7708
+ self: 'self check [path]',
7709
+ reuse: 'reuse autodetect [path] [--apply] [--json] | reuse find "<capability>"',
7710
+ consistency: 'consistency check [path] | consistency merge-design-guide [path]',
7711
+ mcp: 'mcp serve [--profile core|all]',
7712
+ hook: 'hook install [path] | hook status',
7713
+ 'llm-bench': 'llm-bench record --score N --model X [--label L] [--tokens T]',
7714
+ 'auto-update': 'auto-update install [path] | auto-update status',
7715
+ ui: 'ui check | ui open',
7716
+ };
7717
+ const _SUBCOMMAND_GROUPS = Object.keys(_GROUP_USAGE_KO);
7718
+ function _p0101SurfaceOk() {
7719
+ // 소스 grep 으로 하면 안 된다 — `const _GROUP_USAGE = {` 라는 문자열이 '다른 selftest 의 소스 가드' 안에도 있어서
7720
+ // 슬라이스가 실제 선언 대신 그 리터럴을 집었다(자기참조 함정). 표면 검사는 실제 출력으로 한다.
7721
+ const cp2 = require('child_process');
7722
+ const run = (a) => { const r = cp2.spawnSync(process.execPath, [__filename, ...a], { encoding: 'utf8', timeout: 60000, env: Object.assign({}, process.env, { LEERNESS_OFFLINE: '1' }) }); return (r.stdout || '') + (r.stderr || ''); };
7723
+ const cmdsOut = run(['commands']);
7724
+ const helpOut = run(['--help']);
7725
+ if (!cmdsOut || !helpOut) return false; // 셋업 실패를 통과로 세지 않는다
7726
+ // ① 핵심 사용자 명령이 commands 목록에 실제로 보이는가(불릿 항목의 첫 토큰으로)
7727
+ const listed = new Set();
7728
+ for (const l of cmdsOut.split('\n')) { const m = /^\s*[•*\-•]\s+([a-z][\w-]*)\b/.exec(l); if (m) listed.add(m[1]); }
7729
+ const listedOk = listed.size > 50 && _SURFACE_MUST_LIST.every(c => listed.has(c)); // size 가드: 파싱 실패 시 공허 통과 방지
7730
+ // ② 하위명령 그룹을 인자 없이 부르면 '알 수 없는 명령' 이 아니라 사용법을 안내해야 한다(거짓 안내 금지)
7731
+ // 지키는 성질은 '특정 문구' 가 아니라 **사용자를 오타 찾기로 보내지 않는 것** 이다 —
7732
+ // rule 처럼 자기 핸들러가 이미 좋은 안내("알 수 없는 rule 하위명령 — leerness rule add|list|…")를 내는 경우도 통과해야 한다.
7733
+ // 문구를 고정하면 멀쩡한 안내를 결함으로 잡는다(실제로 그렇게 오탐했다).
7734
+ const groupOk = _SUBCOMMAND_GROUPS.every(c => {
7735
+ const o = run([c]);
7736
+ const generic = new RegExp('알 수 없는 명령: ' + c.replace('-', '\\-') + '\\b').test(o) || new RegExp('Unknown command: ' + c.replace('-', '\\-') + '\\b').test(o);
7737
+ const guides = /하위명령|subcommand/i.test(o);
7738
+ return guides && !generic;
7739
+ });
7740
+ // ③ ko/en 병렬맵의 키 집합이 같은가 — 실행으로는 en 경로가 프로젝트 밖에서 전환되지 않아 무방비였다. 데이터로 단언한다.
7741
+ const ko = Object.keys(_GROUP_USAGE_KO).sort().join(',');
7742
+ const en = Object.keys(_GROUP_USAGE_EN).sort().join(',');
7743
+ const parityOk = ko === en && Object.keys(_GROUP_USAGE_KO).length >= 14
7744
+ && Object.values(_GROUP_USAGE_EN).every(v => !/[가-힣]/.test(v)); // en 맵에 한글이 새면 안 된다
7745
+ return listedOk && groupOk && parityOk;
7746
+ }
7747
+ // 1.36.101 (명령 표면 감사 P1): skill id 경로 탈출 회귀 가드.
7748
+ // 실측 재현 — remove 는 프로젝트 밖 디렉토리를 재귀 삭제했고, learn 은 README(4,243B)를 125B 로 덮어썼다.
7749
+ // 판별식으로 검사한다: 탈출 id 는 전부 거부되고, 평범한 id 는 전부 통과해야 한다(한쪽만 재면 '전부 거부'도 통과한다).
7750
+ // 1.36.101 (명령 표면 감사): '없는 경로에 초록 보고' 클래스. 실측 — session-resume/pulse/round-history/milestones 는
7751
+ // 존재하지 않는 디렉토리에 exit 0 과 "✓ 비정상 종료 신호 없음 / R0 / 정상 운영 중" 을 냈다. 오타 하나가 안심 보고가 된다.
7752
+ // 같은 클래스를 UR-0136 에서 이미 한 번 스윕했는데(13981행 주석) 또 구멍이 남았다 —
7753
+ // 그래서 이번엔 목록을 테스트가 강제한다: 아래 명령들은 없는 경로에서 반드시 0이 아닌 exit 로 끝나야 한다.
7754
+ // (init/update 처럼 '없는 경로를 만드는' 명령은 당연히 제외한다.)
7755
+ // 목록은 두 부류다: (A) 이번에 새로 막은 것(디스패처 가드) · (B) 원래 자기 가드를 갖고 있던 것.
7756
+ // 둘 다 '없는 경로 → 0 아닌 exit' 라는 같은 계약을 지켜야 하므로 함께 못박는다.
7757
+ const _PATH_STRICT_CMDS = [
7758
+ // (A) 1.36.101 에서 새로 막은 것 — 전에는 없는 경로에 초록 보고를 냈다
7759
+ ['pulse'], ['round-history'], ['milestones'], ['session-resume'], ['tech'], ['library'],
7760
+ ['memory', 'status'], ['consistency', 'check'], ['env', 'check'], ['pre-wake-audit'], ['idempotency', 'audit'],
7761
+ ['retro'], ['insights'], ['reuse-map'], ['py-check'], ['api-skill', 'list'],
7762
+ // (B) 원래 지키고 있던 것 — 회귀 방지용(이쪽 메시지·로케일은 각 명령이 소유한다)
7763
+ ['handoff'], ['health'], ['status'], ['audit'], ['gate'], ['lazy', 'detect'], ['scan', 'secrets'], ['encoding', 'check'], ['drift', 'check'],
7764
+ ];
7765
+ function _p0101PathStrictOk() {
7766
+ const os2 = require('os');
7767
+ const bogus = path.join(os2.tmpdir(), 'leerness-no-such-dir-' + process.pid + '-zz');
7768
+ if (exists(bogus)) return false; // 셋업 전제가 깨지면 통과로 세지 않는다
7769
+ const cp2 = require('child_process');
7770
+ const bad = [];
7771
+ // 가드는 디스패처 한 곳이라 26개를 다 띄우는 건 중복이고 selftest 를 13초 늘렸다(32s → 45.5s).
7772
+ // 두 해석 형태(단일 토큰 / 쌍)와 '전에 실제로 뚫려 있던' 명령을 대표로 고른다 — 목록 자체는 정책 문서로 남긴다.
7773
+ const _sample = [['pulse'], ['session-resume'], ['tech'], ['library'], ['memory', 'status'], ['env', 'check'], ['retro']];
7774
+ for (const argv of _sample) {
7775
+ const r = cp2.spawnSync(process.execPath, [__filename, ...argv, bogus], { encoding: 'utf8', timeout: 60000, env: Object.assign({}, process.env, { LEERNESS_OFFLINE: '1' }) });
7776
+ if (r.status === 0) bad.push(argv.join(' '));
7777
+ }
7778
+ // 대조군: 같은 명령이 '정상 경로' 에서는 성공해야 한다(전부 실패시키는 구현으로도 위 단언은 통과한다)
7779
+ const okDir = fs.mkdtempSync(path.join(os2.tmpdir(), 'leerness-pathok-'));
7780
+ let controlOk = false;
7781
+ try {
7782
+ fs.writeFileSync(path.join(okDir, 'package.json'), '{"name":"p","version":"0.1.0"}');
7783
+ cp2.spawnSync(process.execPath, [__filename, 'init', okDir, '--yes'], { encoding: 'utf8', timeout: 180000, env: Object.assign({}, process.env, { LEERNESS_OFFLINE: '1' }) });
7784
+ const r = cp2.spawnSync(process.execPath, [__filename, 'health', okDir], { encoding: 'utf8', timeout: 60000, env: Object.assign({}, process.env, { LEERNESS_OFFLINE: '1' }) });
7785
+ controlOk = r.status === 0;
7786
+ } catch { controlOk = false; } finally { try { fs.rmSync(okDir, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
7787
+ // 표본이 조용히 줄어들면 가드가 약해진 걸 모른다 — 목록/표본 크기도 함께 못박는다.
7788
+ return bad.length === 0 && controlOk && _sample.length >= 7 && _PATH_STRICT_CMDS.length >= 20;
7789
+ }
7790
+ // 1.36.102 (명령 표면 감사 P1 2건): 회귀 가드.
7791
+ // ① update --check 가 레지스트리 조회에 실패하고도 "up to date" 라고 단정했다 —
7792
+ // 바로 윗줄에 "(unavailable — 판정 보류)" 를 찍어 놓고 아랫줄에서 결론을 뒤집는 자기모순이었다.
7793
+ // 그리고 --json 이 사람용 텍스트를 냈다(계약: 단일 유효 JSON).
7794
+ // ② plan init <경로> 가 positional 을 무시하고 cwd 에 워크스페이스 71개 파일을 깔았다(실측: cwd 1 → 72).
7795
+ function _p0102HonestyOk() {
7796
+ const os2 = require('os');
7797
+ const cp2 = require('child_process');
7798
+ const arena = fs.mkdtempSync(path.join(os2.tmpdir(), 'leerness-p102-'));
7799
+ try {
7800
+ const proj = path.join(arena, 'proj'); fs.mkdirSync(proj, { recursive: true });
7801
+ fs.writeFileSync(path.join(proj, 'package.json'), '{"name":"p","version":"0.1.0"}');
7802
+ const R = (a, cwd) => cp2.spawnSync(process.execPath, [__filename, ...a], { cwd: cwd || proj, encoding: 'utf8', timeout: 180000, env: Object.assign({}, process.env, { LEERNESS_OFFLINE: '1' }) });
7803
+ R(['init', proj, '--yes']);
7804
+ // ① 조회 실패 시 '최신' 이라 단정하지 않는다 + --json 은 순수 JSON
7805
+ const human = ((R(['update', proj, '--check']).stdout) || '');
7806
+ const noAssert = /확인 불가/.test(human) && !/→ up to date/.test(human);
7807
+ const raw = ((R(['update', proj, '--check', '--json']).stdout) || '').trim();
7808
+ let j = null; try { j = JSON.parse(raw); } catch { j = null; }
7809
+ const jsonOk = !!j && j.verdict === 'unknown' && j.registryReachable === false && j.latest === null;
7810
+ // ② plan init <경로> 는 그 경로에 적용하고 cwd 를 건드리지 않는다
7811
+ const cwdDir = path.join(arena, 'cwd'); const target = path.join(arena, 'target');
7812
+ fs.mkdirSync(cwdDir, { recursive: true }); fs.mkdirSync(target, { recursive: true });
7813
+ fs.writeFileSync(path.join(cwdDir, 'package.json'), '{"name":"c","version":"0.1.0"}');
7814
+ fs.writeFileSync(path.join(target, 'package.json'), '{"name":"t","version":"0.1.0"}');
7815
+ const cnt = (d) => { let n = 0; (function w(x) { for (const e of fs.readdirSync(x, { withFileTypes: true })) { const p = path.join(x, e.name); if (e.isDirectory()) w(p); else n++; } })(d); return n; };
7816
+ R(['plan', 'init', target], cwdDir);
7817
+ const planOk = cnt(cwdDir) === 1 && cnt(target) > 5;
7818
+ // 없는 경로를 주면 만들지 말고 거부한다
7819
+ const rejects = R(['plan', 'init', path.join(arena, 'NO-SUCH')], cwdDir).status !== 0 && !exists(path.join(arena, 'NO-SUCH'));
7820
+ return noAssert && jsonOk && planOk && rejects;
7821
+ } catch { return false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
7822
+ }
7823
+ function _p0101SkillIdOk() {
7824
+ const bad = ['../..', '../../../victim', '..\\..\\x', '/etc/passwd', 'C:\\Windows', 'a/b', 'a\\b', '.', '..', '.hidden', '', null, 'x'.repeat(65)];
7825
+ const good = ['open-meteo', 'stripe', 'my_skill', 'v1.2.3', 'a', 'A-Z_0.9'];
7826
+ const rejects = bad.every(v => !!_badSkillId(v));
7827
+ const accepts = good.every(v => _badSkillId(v) === null);
7828
+ // 술어만 시험하면 '배선' 이 빠진 걸 못 본다 — skillLearn 에서 호출을 지워도 통과했다(변이로 확인).
7829
+ // 그리고 소스 grep 으로 이중가드를 확인하려 했더니 그 문자열이 이 가드 자신에 있어 자기참조로 통과했다(3회차).
7830
+ // 그래서 실제로 명령을 돌려 '밖의 파일이 살아남는가' 를 본다.
7831
+ const os2 = require('os');
7832
+ const cp2 = require('child_process');
7833
+ const arena = fs.mkdtempSync(path.join(os2.tmpdir(), 'leerness-skid-'));
7834
+ let wiredOk = false;
7835
+ try {
7836
+ const victim = path.join(arena, 'victim');
7837
+ fs.mkdirSync(victim, { recursive: true });
7838
+ fs.writeFileSync(path.join(victim, 'KEEP.txt'), 'user data');
7839
+ const proj = path.join(arena, 'a', 'b', 'proj');
7840
+ fs.mkdirSync(proj, { recursive: true });
7841
+ fs.writeFileSync(path.join(proj, 'package.json'), '{"name":"p","version":"0.1.0"}');
7842
+ const R = (a) => cp2.spawnSync(process.execPath, [__filename, ...a], { cwd: proj, encoding: 'utf8', timeout: 180000, env: Object.assign({}, process.env, { LEERNESS_OFFLINE: '1' }) });
7843
+ R(['init', proj, '--yes']);
7844
+ const readmeBefore = exists(path.join(proj, 'README.md')) ? read(path.join(proj, 'README.md')).length : 0;
7845
+ const rmDepth = Array(5).fill('..').join('/') + '/victim';
7846
+ const r1 = R(['skill', 'remove', rmDepth]);
7847
+ const r2 = R(['skill', 'learn', '../..', '--capability', 'boom']);
7848
+ const readmeAfter = exists(path.join(proj, 'README.md')) ? read(path.join(proj, 'README.md')).length : 0;
7849
+ const okId = R(['skill', 'learn', 'open-meteo', '--capability', 'weather']); // 대조군: 평범한 id 는 여전히 동작
7850
+ wiredOk = r1.status !== 0 && r2.status !== 0
7851
+ && exists(path.join(victim, 'KEEP.txt')) // 밖의 파일이 살아 있어야 한다
7852
+ && readmeBefore > 0 && readmeAfter === readmeBefore // README 가 그대로여야 한다
7853
+ && okId.status === 0;
7854
+ } catch { wiredOk = false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
7855
+ return rejects && accepts && wiredOk;
7856
+ }
7648
7857
  function _lensDomainList() { return Object.keys(LENS_CATALOG).join('|'); }
7649
7858
  // 1.36.97: 완료 검증의 인라인 렌즈 줄은 '목차'다 — 본문이 아니다.
7650
7859
  // 심화 문항은 한 문단이라 통째로 찍으면 80칸에서 한 축이 3~4줄을 먹는다(검수 실측: advisory 블록이 8줄 → 15줄).
@@ -8006,6 +8215,8 @@ function commandsCmd(root) {
8006
8215
  { cmd: 'lazy detect [path] [--json]', desc: '게으른 작업 감지 (1.9.101)' },
8007
8216
  { cmd: 'verify-claim <T-ID|--all> [--run-tests] [--test-cmd "<명령>"] [--strict-claims] [--require-evidence]', desc: '주장 검증 (1.9.18~26) — --all: 모든 done 주장 일괄 검증(CI·스케일, 1.33.2) · --require-evidence: done 주장에 파일+테스트 근거 강제 (1.9.287) · --test-cmd: 비-JS 테스트 명령 (1.17.2)' },
8008
8217
  { cmd: `lens [${_lensDomainList()}] [--json]`, desc: '분야별 자기질문 품질 렌즈 + 분야간 인과관계 (1.18.3 · database/contract/recovery/observability 는 심화, axes 는 8축 경량 점검)' },
8218
+ // 1.36.101 (명령 표면 감사): 1.36.98 에서 디스패처에만 붙이고 commands/--help 에 등재하지 않아 '있는데 안 보이는' 명령이었다.
8219
+ { cmd: 'library [show|page] [path] [--json] [--ai]', desc: '재사용 인벤토리 — 컴포넌트·디자인 토큰을 저장소에서 추출. page: 오프라인 단일 HTML · --ai: 에이전트용 압축 JSON (1.36.98 P-0013)' },
8009
8220
  { cmd: 'optimism-check <T-ID>', desc: '낙관적 API 감지 (1.9.26)' },
8010
8221
  { cmd: 'requests audit|list|complete|drop|auto-complete', desc: '사용자 요청 추적 (1.9.207/223)' },
8011
8222
  { cmd: 'pre-wake-audit [path] [--last]', desc: 'sleep 전 점검 (1.9.209)' },
@@ -8071,6 +8282,9 @@ function commandsCmd(root) {
8071
8282
  { cmd: 'migrate --guide', desc: 'AI 에이전트용 크로스버전 마이그레이션 가이드 (1.9.355)' },
8072
8283
  { cmd: 'doctor [--json]', desc: '설치/환경 진단 (1.9.315)' },
8073
8284
  { cmd: 'selftest [--json]', desc: '코어 함수 무결성 자가 검증 (1.9.258)' },
8285
+ // 1.36.101 (명령 표면 감사): 둘 다 실행되고 문서에도 있는데 이 목록에만 없었다 — '있는데 안 보이는 명령'.
8286
+ { cmd: 'gate [path]', desc: 'CI 한 번 호출 게이트 — verify + audit + scan + encoding + lazy' },
8287
+ { cmd: 'toggle get|set|list <기능> [on|off]', desc: '기능 토글 (gate/lens/auto-graph/delegation-brief) — leerness.html ⚙ 탭과 연동 (1.36.30)' },
8074
8288
  { cmd: 'install-safety [--json]', desc: '설치 안전 프로필 — 0 deps/0 install-script (1.9.359)' },
8075
8289
  { cmd: 'env check|sync|detect [path]', desc: '셸/인코딩 환경 진단 (1.9.241)' },
8076
8290
  { cmd: 'shell-guard "<command>"', desc: '셸 호환성 정적 분석 (1.9.260)' },
@@ -19401,27 +19615,35 @@ async function updateCmd(root, opts = {}) {
19401
19615
  // 1.9.364 (4번째 외부평가 9.3 + Codex CV-2 후속): --quiet — SessionStart hook 비침투화.
19402
19616
  // up-to-date 시 무음, 업데이트 가능 시에만 1줄 통지 (매 세션 노이즈 제거). hook 명령이 이 모드를 사용.
19403
19617
  const quiet = !!opts.quiet || has('--quiet');
19618
+ // 1.36.102: --json 은 사람용 줄을 섞으면 안 된다(계약: 단일 유효 JSON). quiet 과 별개로 둔다 —
19619
+ // `const quiet = ...` 줄 자체는 다른 selftest 가 문자열로 붙잡고 있어 건드리지 않는다.
19620
+ const _humanQuiet = quiet || has('--json');
19404
19621
  const verF = path.join(root, '.harness/HARNESS_VERSION');
19405
19622
  const cur = exists(verF) ? parseHarnessVersion(read(verF)) : { plus: null, base: null, raw: '(not installed)' };
19406
- if (!quiet) log(`# leerness update`);
19407
- if (!quiet) log(`Current: ${cur.raw}`);
19623
+ if (!_humanQuiet) log(`# leerness update`);
19624
+ if (!_humanQuiet) log(`Current: ${cur.raw}`);
19408
19625
  const fromTar = arg('--from', null);
19409
19626
  const cacheHours = opts.checkOnly ? 24 : 0;
19410
19627
  let nextLeerness = VERSION;
19411
- if (fromTar) { if (!quiet) log(`Local tarball mode: ${fromTar}`); }
19628
+ // 1.36.102 (명령 표면 감사 P1): 레지스트리 조회에 실패했는데 결론은 "up to date" 였다 —
19629
+ // 바로 윗줄에서 "(unavailable — 판정 보류)" 라고 해 놓고 아랫줄에서 최신이라 단정하는 자기모순이다.
19630
+ // 모르는 것은 모른다고 말한다. 이 값이 판정 분기까지 흘러간다.
19631
+ let _registryUnknown = false;
19632
+ if (fromTar) { if (!_humanQuiet) log(`Local tarball mode: ${fromTar}`); }
19412
19633
  else {
19413
19634
  const cached = readUpdateCache(root);
19414
19635
  // 1.36.34 (codex 3차 #5): 조회 실패(null)를 24h 캐시로 굳히면 다음 실행이 "up to date" 로 오답 — null 은 캐시하지 않고 재시도.
19415
19636
  if (cacheFresh(cached, cacheHours) && cached.nextLeerness) {
19416
19637
  nextLeerness = cached.nextLeerness;
19417
- if (!quiet) log(`(cached ${Math.round((Date.now() - cached.at) / 60000)}m ago)`);
19418
- if (!quiet) log(`npm leerness latest: ${cached.nextLeerness}`);
19638
+ if (!_humanQuiet) log(`(cached ${Math.round((Date.now() - cached.at) / 60000)}m ago)`);
19639
+ if (!_humanQuiet) log(`npm leerness latest: ${cached.nextLeerness}`);
19419
19640
  } else {
19420
- if (!quiet) log('Checking npm registry…');
19641
+ if (!_humanQuiet) log('Checking npm registry…');
19421
19642
  const latest = await fetchNpmLatest('leerness');
19422
19643
  nextLeerness = latest || VERSION;
19423
19644
  if (latest) writeUpdateCache(root, { nextLeerness: latest, runningCli: VERSION });
19424
- if (!quiet) log(`npm leerness latest: ${latest || '(unavailable 판정 보류, 다음 실행에서 재조회)'}`);
19645
+ else _registryUnknown = true; // 1.36.102: 조회 실패를 판정까지 끌고 간다(아래 --check 결론에서 사용)
19646
+ if (!_humanQuiet) log(`npm leerness latest: ${latest || '(unavailable — 판정 보류, 다음 실행에서 재조회)'}`);
19425
19647
  }
19426
19648
  }
19427
19649
  // What is "current"? canonical=base; legacy plus also rolls into leerness 1.9.0+
@@ -19435,8 +19657,15 @@ async function updateCmd(root, opts = {}) {
19435
19657
  if (compareVer(nextLeerness, '1.9.0') >= 0) { needsMigrate = true; reason = 'consolidate legacy plus@ marker into canonical'; }
19436
19658
  }
19437
19659
  if (opts.checkOnly) {
19438
- if (quiet) { if (needsMigrate) log(`↑ leerness 업데이트 가능: ${reason} leerness update --yes`); return; } // up-to-date 시 무음
19660
+ // 1.36.102: --json 사람용 텍스트를 내던 것도 함께 고친다(--json 계약: 단일 유효 JSON).
19661
+ const _verdict = needsMigrate ? 'migration_available' : (_registryUnknown ? 'unknown' : 'up_to_date');
19662
+ if (has('--json')) {
19663
+ log(JSON.stringify({ ok: true, current: installed || null, cli: VERSION, latest: _registryUnknown ? null : nextLeerness, registryReachable: !_registryUnknown, verdict: _verdict, reason: needsMigrate ? reason : null }, null, 2));
19664
+ return;
19665
+ }
19666
+ if (quiet) { if (needsMigrate) log(`↑ leerness 업데이트 가능: ${reason} — leerness update --yes`); return; } // up-to-date/unknown 시 무음
19439
19667
  if (needsMigrate) log(`\n→ migration available: ${reason}`);
19668
+ else if (_registryUnknown) log('\n→ 확인 불가 — 레지스트리를 조회하지 못했습니다(최신 여부를 알 수 없음). 네트워크 복구 후 다시 실행하세요.');
19440
19669
  else log('\n→ up to date');
19441
19670
  return;
19442
19671
  }
@@ -24679,6 +24908,7 @@ VERIFICATION (evidence-gated "done")
24679
24908
  verify-code [path] [--build] [--bench] Run tests/lint/typecheck, record evidence
24680
24909
  gate [path] One-call CI gate: verify + audit + scan + encoding + lazy
24681
24910
  lens [${_lensDomainList()}] [--json] Quality self-question lenses
24911
+ library [show|page] [path] [--json] [--ai] Reusable inventory (components + design tokens) — page: offline HTML, --ai: compact JSON
24682
24912
 
24683
24913
  SECURITY & HYGIENE
24684
24914
  scan secrets [path] Committed-secret detection
@@ -24740,7 +24970,7 @@ function help() {
24740
24970
  // 1.23.1 (UR-0010 Phase 6): 영어 opt-in 시 큐레이트 영어판. 기본(ko) 은 아래 한국어 help 그대로.
24741
24971
  if (_uiLang(arg('--path', process.cwd())) === 'en') { _helpEn(); return; }
24742
24972
  log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path] [--all-apps] [--include p1,p2] [--since 24h|3d] [--compact] [--json] # 1.9.17-22 워크스페이스 (--compact: LLM 시스템 프롬프트용 1줄 요약)\n leerness orchestrate "<목표>" [--agents N] [--model qwen2.5:7b-instruct] [--retry-on-fail K] # 1.9.22 Ollama opt-in (LEERNESS_OLLAMA_BASE_URL 필요)\n leerness llm-bench record --score N --model X [--label L] [--tokens T] # 1.9.22 LLM 벤치 히스토리 누적\n leerness deps <capability> [--run-tests] [--json] # 1.9.24 depends-on 역방향 추적 + 자동 회귀 sweep\n leerness memory search "키" [--include-code] # 1.9.25 소스 코드 본문도 검색 (모순 감지 핵심)\n leerness brainstorm "주제" [--include-code] # 1.9.25 코드 본문 hits 포함\n leerness register-pending "<요청>" [--agent X] [--note Y] # 1.9.25 다중 세션 in-progress 즉시 등록\n leerness optimism-check <T-ID> [--json] # 1.9.26/27 낙관적 표시 감지 (1.9.27: 10 카테고리 + URL/메서드 매핑 + 신뢰도 점수)\n leerness persona list|show <id>|add <id> # 1.9.29 페르소나 카탈로그 (보안/성능/UX/testing/docs 5종 내장)\n leerness review <file> --persona <id1,id2,...> # 1.9.29 도메인 페르소나 리뷰 프롬프트 자동 생성\n leerness agents list|check|quota # 1.9.30/31 외부 AI CLI 가용성 + quota 추정 (claude/codex/agy/copilot)\n leerness agents dispatch "<task>" --to <id> # 1.9.30 활성 CLI 대상 실행 명령 생성 (실 호출 X, 사용자 실행)\n leerness agents multi "<task>" [--only c1,c2] [--write] [--execute] [--timeout 60] # 1.9.152/156 활성 N개 일괄 dispatch (--execute: 실 spawn + consensus)\n leerness provider list|add|remove [args] # 1.9.157 Provider Registry — 사용자 정의 CLI provider 동적 추가 (OpenRouter/Bedrock 흡수)\n leerness agents dispatch "<task>" --multi # 1.9.152 multi 모드 alias (또는 --to all)\n leerness setup-agents [path] [--yes|--no-setup-agents] # 1.9.32 sub-agent CLI 인터랙티브 설정 (.env + 미설치 자동 설치)\n leerness init [path] [--no-stale-check] # 1.9.33 npx 캐시 함정 — 옛 버전 자동 경고 (끄려면 --no-stale-check)\n leerness which [--json] # 1.9.164 진단: 현재 실행 경로/버전 + npm 캐시 + PATH 후보 (구버전 충돌 해결)\n leerness selftest [--json] # 1.9.258 코어 함수 무결성 자가 검증 (설치 손상/부분설치 감지, CI 친화 exit 1)\n leerness shell-guard "<command>" [--json] # 1.9.260 터미널 명령 셸 호환성 린터 (PowerShell 5.1 && 미지원 등 실행 전 감지, UR-0020)\n leerness shell-guard --record --cmd "..." --exit N # 1.9.260 실패한 터미널 명령 기록 → 다음 분석 시 회수\n leerness path-setup [--apply] [--json] # 1.9.254 leerness CLI PATH 자동 등록 (npm global bin 미등록 시)\n leerness web check|screenshot|extract <url> [--out file.png] [--selector "css"] # 1.9.165 playwright bridge (opt-in: npm i -g playwright + permissions.browser)\n leerness pc check|click|type|screenshot [--x N --y N] [--text "s"] [--out f.png] # 1.9.166 robotjs/nut-tree bridge (opt-in: npm i -g robotjs + permissions.mouse/keyboard, ⚠ full 모드 권장)\n leerness lsp check|symbols|references <file/name> [--in dir] [--json] # 1.9.167 LSP 어댑터 MVP (typescript opt-in + regex fallback, 코드 인텔리전스)\n leerness review-request "<request>" [--json] # 1.9.176 사용자 요청 사전 검토 (충돌/재사용/효율/권장 단계 — 사용자 명시)\n leerness contract verify <spec.md> <impl.js> [--json] # 1.9.35 명세 ↔ 구현 일치 검사 (함수/필드)\n leerness reuse autodetect [path] [--apply] [--json] # 1.9.35 src/*.js의 module.exports → reuse-map 후보 등록\n leerness audit [path] [--fix] # 1.9.35 --fix: session-handoff/current-state 자동 갱신\n leerness verify-claim <T-ID> ... [--strict-claims] # 1.9.26 verify-claim에 낙관적 표시 자동 검사 통합
24743
- leerness lens [${_lensDomainList()}] [--json] # 1.18.3/1.36.97 분야별 자기질문 품질 렌즈 (database·contract·recovery·observability 심화 · axes 8축 경량 — 완료 선언 전 자가 점검)\n leerness reuse-map [path] [--all-apps] [--include p1,p2] [--strict-elements] [--json] # 1.9.18 중복/잠재중복/depends-on\n leerness verify-claim <T-ID> [--path .] [--run-tests] [--json] # 1.9.18-20 evidence 자동 검증 (1.9.20: scenes/scripts 등 도메인 폴더 + jest/mocha 파싱)\n leerness verify-code [path] [--build] [--bench] # 1.9.20 --bench: scripts.bench 추가 실행 + evidence 누적\n leerness session close [path]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop|fix-evidence|relink [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy
24973
+ leerness lens [${_lensDomainList()}] [--json] # 1.18.3/1.36.97 분야별 자기질문 품질 렌즈 (database·contract·recovery·observability 심화 · axes 8축 경량 — 완료 선언 전 자가 점검)\n leerness library [show|page] [path] [--json] [--ai] # 1.36.98 재사용 인벤토리 — 컴포넌트·디자인 토큰 추출 (page: 오프라인 HTML · --ai: 에이전트용 압축)\n leerness reuse-map [path] [--all-apps] [--include p1,p2] [--strict-elements] [--json] # 1.9.18 중복/잠재중복/depends-on\n leerness verify-claim <T-ID> [--path .] [--run-tests] [--json] # 1.9.18-20 evidence 자동 검증 (1.9.20: scenes/scripts 등 도메인 폴더 + jest/mocha 파싱)\n leerness verify-code [path] [--build] [--bench] # 1.9.20 --bench: scripts.bench 추가 실행 + evidence 누적\n leerness session close [path]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop|fix-evidence|relink [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy
24744
24974
  leerness retro [path] [--days 7] [--all-apps] [--include p1,p2] [--json] # 회고 (1.9.13~1.9.16)
24745
24975
  leerness insights [path] [--all-apps] [--include p1,p2] [--json] # 누적 통계 (1.9.13~1.9.16)
24746
24976
  leerness brainstorm "<주제>" [--all-apps] [--include p1,p2] [--json] # 브레인스토밍 (1.9.13~1.9.16)
@@ -24773,6 +25003,34 @@ async function main() {
24773
25003
  return log(VERSION);
24774
25004
  }
24775
25005
  if (has('--help') || has('-h')) return help();
25006
+ // 1.36.101 (명령 표면 감사): '없는 경로에 초록 보고' 클래스를 디스패처 한 곳에서 막는다.
25007
+ // 실측 — 32개 조사 대상 중 16개가 존재하지 않는 디렉토리에 exit 0 과 정상 보고를 냈다
25008
+ // (pulse/round-history/milestones/session-resume 는 "✓ 비정상 종료 신호 없음 · R0 · 정상 운영 중" 까지 찍었다 — 오타가 안심 보고가 된다).
25009
+ // 같은 클래스를 UR-0136 에서 이미 한 번 스윕했는데 절반이 남아 있었다. 명령마다 고치면 다음 명령에서 또 빠지므로
25010
+ // 경로 해석 방식(--path / positional / _resolveRoot)이 제각각인 것을 여기서 한 번에 검사한다.
25011
+ // '없는 경로를 만드는' 명령(init/migrate/update 등)은 대상에서 제외한다 — 그쪽은 부재가 정상 입력이다.
25012
+ {
25013
+ // 대상은 '실제로 뚫려 있던 것' 만이다. 이미 자기 가드를 가진 명령(handoff/health/status/scan secrets/...)까지 넣었더니
25014
+ // 그쪽의 고유 메시지와 en 로케일을 가로채 기존 e2e 4건이 깨졌다 — 고쳐야 할 것만 고친다.
25015
+ // (scan secrets 처럼 '파일 경로' 가 정상 입력인 명령도 있어, 디렉토리 강제는 그 명령들엔 맞지 않는다.)
25016
+ const _strict = new Set(['pulse', 'round-history', 'milestones', 'session-resume',
25017
+ 'tech', 'library', 'retro', 'insights', 'reuse-map', 'py-check', 'pre-wake-audit']);
25018
+ const _strictPair = { memory: 'status', consistency: 'check', env: 'check', idempotency: 'audit', 'api-skill': 'list' };
25019
+ const _isStrict = _strict.has(cmd) || (_strictPair[cmd] && args[1] === _strictPair[cmd]);
25020
+ if (_isStrict) {
25021
+ // 사용자가 '경로를 명시했을 때만' 검사한다 — 미지정(cwd)은 항상 존재하므로 검사할 것이 없다.
25022
+ const _explicit = arg('--path', null) || [args[1], args[2]].find(a => a && !a.startsWith('-') && a !== _strictPair[cmd]);
25023
+ if (_explicit) {
25024
+ const _abs = absRoot(_explicit);
25025
+ if (!exists(_abs) || !fs.statSync(_abs).isDirectory()) {
25026
+ // 로케일을 붙인다 — en 프로젝트에서 이 가드만 한국어를 내면 그 자체가 i18n 누수다.
25027
+ const _en = _uiLang(_abs) === 'en' || _uiLang(process.cwd()) === 'en';
25028
+ failJson(has('--json'), 'path_not_found', _en ? `path not found or not a directory: ${_abs}` : `경로 없음 또는 디렉토리 아님: ${_abs}`);
25029
+ return;
25030
+ }
25031
+ }
25032
+ }
25033
+ }
24776
25034
  // 1.13.1 (15th 블라인드 리뷰 P1, codex gpt-5.5): 무명령 + 옵션만(예: leerness --json) 은 묵시적 init 쓰기를 하지 않음 — cwd 에 .harness 가 의도치 않게 생성되던 부작용 차단. 명시 명령 없이 옵션만이면 help (bare leerness 온보딩 init 은 유지).
24777
25035
  if (!args[0] && process.argv.slice(2).some(a => a.startsWith('-'))) { help(); return; }
24778
25036
  // 1.9.38 (B): 사용 통계 카운터 — usage stats 명령 자체와 비차단 경로는 제외
@@ -25245,7 +25503,19 @@ async function main() {
25245
25503
  if (cmd === 'plan') {
25246
25504
  const root = absRoot(arg('--path', process.cwd())); const sub = args[1] || 'show';
25247
25505
  if (sub==='show') return planShow(root);
25248
- if (sub==='init') return planInit(root);
25506
+ // 1.36.102 (명령 표면 감사 P1): `plan init <경로>` 가 positional 을 무시하고 **cwd 에** 워크스페이스를 깔았다.
25507
+ // 실측 재현: cwd 1개 파일 → 72개, 대상 디렉토리는 1개 그대로. 홈에서 실행하면 홈이 오염된다.
25508
+ // plan 그룹은 --path 만 읽고 positional 은 list 만 지원했다 — 파괴적인 init 이 그 갭에 있었다.
25509
+ // 무시하지 말고 명시적으로 처리한다: 준 경로에 적용하되, 존재하지 않으면 만들지 말고 거부한다.
25510
+ if (sub==='init') {
25511
+ const _pos = args[2] && !args[2].startsWith('-') ? args[2] : null;
25512
+ if (_pos) {
25513
+ const _abs = absRoot(_pos);
25514
+ if (!exists(_abs) || !fs.statSync(_abs).isDirectory()) { failJson(has('--json'), 'path_not_found', `경로 없음 또는 디렉토리 아님: ${_abs}`); return; }
25515
+ return planInit(_abs);
25516
+ }
25517
+ return planInit(root);
25518
+ }
25249
25519
  if (sub==='add') return planAdd(root, args.slice(2).join(' ').trim() || '새 계획'); // 17th 버그헌트 P2: 공백-only 제목이 || 기본값을 우회(truthy)해 plan.md 손상(파서가 다음 줄 'Status:' 흡수) → trim 후 판정
25250
25520
  if (sub==='drop') return planDrop(root, args.slice(2).join(' ') || '드랍 항목');
25251
25521
  if (sub==='remove') return planRemoveCmd(root, args[2]);
@@ -25347,20 +25617,9 @@ async function main() {
25347
25617
  // 1.9.306 (UR-0045): 명시적 help 요청은 exit 0, 그 외 미인식 명령은 안내 + exit 1 (실패를 성공으로 오판 방지).
25348
25618
  if (cmd === 'help' || cmd === 'commands' || cmd === '--help' || cmd === '-h') { help(); return; }
25349
25619
  // 1.16.2 (외부클린룸 UR-0042): 유효 명령그룹을 하위명령 없이 부르면 'unknown command'(혼란) 대신 사용법 힌트 — decision/lesson 과 일관.
25350
- const _GROUP_USAGE = {
25351
- rule: 'rule add "<텍스트>" --trigger <트리거> | rule list | rule pause/resume/remove <ID> | rule verify',
25352
- skill: 'skill list | skill add <id> | skill use <id> | skill search "<키>" | skill match "<텍스트>"',
25353
- feature: 'feature add "<이름>" | feature list | feature show <ID> | feature link <A> <B> | feature impact <ID>',
25354
- memory: 'memory search "<키>" [--json] | memory status | memory archive | memory restore',
25355
- };
25356
- // 1.23.2 (UR-0010 Phase 7): 영어 자리표시자 병렬맵 — ko 맵은 불변(e2e 안전), en 은 <text>/<trigger>/<key>/<name>.
25357
- const _GROUP_USAGE_EN = {
25358
- rule: 'rule add "<text>" --trigger <trigger> | rule list | rule pause/resume/remove <ID> | rule verify',
25359
- skill: 'skill list | skill add <id> | skill use <id> | skill search "<key>" | skill match "<text>"',
25360
- feature: 'feature add "<name>" | feature list | feature show <ID> | feature link <A> <B> | feature impact <ID>',
25361
- memory: 'memory search "<key>" [--json] | memory status | memory archive | memory restore',
25362
- };
25363
- if (_GROUP_USAGE[cmd] && !args[1]) { const _gu = _uiLang(arg('--path', process.cwd())) === 'en' ? `${cmd} subcommand required — usage: leerness ${_GROUP_USAGE_EN[cmd]}` : `${cmd} 하위명령 필요 — 사용법: leerness ${_GROUP_USAGE[cmd]}`; failJson(has('--json'), 'subcommand_required', _gu); return; }
25620
+ // 1.36.101: 맵을 모듈 스코프(_GROUP_USAGE_KO/_EN)로 올려 selftest 가 '키 집합이 같은가' 를 데이터로 검사한다.
25621
+ // 문자열 실행으로만 검사하면 --language en 프로젝트 밖에서 전환되지 않아 en 맵이 무방비로 남는다(변이로 확인).
25622
+ if (_GROUP_USAGE_KO[cmd] && !args[1]) { const _gu = _uiLang(arg('--path', process.cwd())) === 'en' ? `${cmd} subcommand required usage: leerness ${_GROUP_USAGE_EN[cmd]}` : `${cmd} 하위명령 필요 사용법: leerness ${_GROUP_USAGE_KO[cmd]}`; failJson(has('--json'), 'subcommand_required', _gu); return; }
25364
25623
  // 1.9.437 (11th 외부평가 Codex P2, UR-0138): --json 모드 unknown command 도 순수 JSON.
25365
25624
  failJson(has('--json'), 'unknown_command', `알 수 없는 명령: ${cmd} (leerness --help 로 전체 명령 확인)`);
25366
25625
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.100",
3
+ "version": "1.36.102",
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",
package/scripts/e2e.js CHANGED
@@ -67,9 +67,13 @@ run('memory search', ['memory', 'search', '마일스톤', '--path', tmp]);
67
67
  const offline = Object.assign({}, process.env, { LEERNESS_OFFLINE: '1' });
68
68
  total++;
69
69
  {
70
+ // 1.36.102: 이 단언이 결함을 고정하고 있었다 — 오프라인(레지스트리 조회 불가)에서 "up to date" 가 나오는 것을
71
+ // 정답으로 못박고 있었다. 모르는 것을 안다고 말하는 게 정답일 수 없다. 계약을 바꾼다:
72
+ // 조회에 실패하면 '확인 불가' 여야 하고, 마이그레이션이 필요하면 그건 조회와 무관하니 그대로 인정한다.
70
73
  const r = cp.spawnSync(process.execPath, [CLI, 'update', tmp, '--check'], { env: offline, encoding: 'utf8' });
71
- const ok = r.status === 0 && /up to date|migration available/.test(r.stdout || '');
72
- console.log(ok ? '✓ update --check (offline)' : `✗ update --check (offline) exit=${r.status}`);
74
+ const _o = r.stdout || '';
75
+ const ok = r.status === 0 && /확인 불가|migration available/.test(_o) && !/→ up to date/.test(_o);
76
+ console.log(ok ? '✓ update --check (offline): 조회 실패를 "최신" 으로 단정하지 않음 (1.36.102)' : `✗ update --check (offline) exit=${r.status}`);
73
77
  if (!ok) { failed++; console.log(r.stdout || ''); console.error(r.stderr || ''); }
74
78
  }
75
79