leerness 1.36.99 → 1.36.101

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,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.101 — 2026-08-05 — 명령 표면 전수 감사: 경로 탈출로 남의 파일이 지워지고 있었다
4
+
5
+ 사용자 요청: *"leerness가 적용된 프로젝트에서 leerness 명령어가 의도한대로 동작하는지 테스트 및 디버그도 함께 진행."*
6
+
7
+ 명령 **1,488회**를 신선한 임시 프로젝트에서 실행했다(각 명령의 정상 경로 · `--json` · 인자 없음 · 잘못된 하위명령 · 없는 경로 · `--language en`). 결함 **73건 + 완결성 비평 5건**이 살아남았다(6건은 재현 실패로 반박). 이 릴리스는 그중 **P1 3건**을 닫는다.
8
+
9
+ ### 🚨 skill id 경로 탈출 — 프로젝트 밖 파일이 삭제·덮어쓰기 됐다
10
+
11
+ ```
12
+ skill remove "../../../../../victim" → exit 0 · "✓ user skill removed"
13
+ → 프로젝트 밖 디렉토리를 재귀 삭제 (재현 확인)
14
+ skill learn "../.." → 프로젝트 README.md 4,243바이트 → 125바이트 (재현 확인)
15
+ ```
16
+
17
+ `id` 는 사용자 입력인데 검증 없이 `<root>/.harness/skills/<id>` 에 붙었다. 호출부마다 막으면 다음 진입점에서 또 새므로 **id 를 쓰는 모든 함수가 통과하는 단일 병목**에서 거부하고, 파괴적 경로(remove)에는 경로 재확인을 한 겹 더 뒀다. 평범한 id(`open-meteo` 등)는 그대로 동작한다.
18
+
19
+ ### 🚨 "없는 경로에 초록 보고" — 조사한 32개 중 16개
20
+
21
+ `pulse`·`round-history`·`milestones`·`session-resume` 은 **존재하지 않는 디렉토리**에 exit 0 과 함께 *"✓ 비정상 종료 신호 없음 · R0 · 정상 운영 중"* 을 찍었다. 오타 하나가 안심 보고가 된다. `handoff`·`health`·`status` 는 같은 입력을 이미 exit 1 로 거부하고 있었으니, 절반만 지켜지던 관례다 — 코드 주석에 *"UR-0136 에서 이미 스윕했다"* 고 적혀 있는데도 절반이 남아 있었다(같은 클래스의 3회차).
22
+
23
+ 경로 해석 방식이 명령마다 제각각(`--path` / positional / `_resolveRoot`)이라 개별 수정이 계속 빠졌다 → **디스패처 한 곳**에서 검사한다. 실측: 뚫림 **16 → 0**, 정상 경로 **26/26** 그대로 동작(대조군 — '전부 거부' 하는 구현으로도 뚫림 0 은 나온다).
24
+
25
+ ### 있는데 안 보이던 명령 · 거짓 안내
26
+
27
+ - 디스패처 126개 중 **46개가 `leerness commands` 에 없었다.** 그중 **`library` 는 `--help` 에도 없었다** — 1.36.98 에서 내가 디스패처에만 붙이고 두 표면에 등재하지 않았다. `gate`·`toggle` 도 같았다. 셋 다 등재하고, 핵심 명령의 등재를 selftest 가 실제 출력으로 확인한다.
28
+ - `readme`·`contract`·`self`·`reuse`·`consistency`·`mcp`·`hook`·`llm-bench`·`auto-update`·`ui` 를 하위명령 없이 부르면 **"알 수 없는 명령"** 이라고 답했다. `readme` 는 알 수 없는 명령이 아니라 `readme sync` 가 필요한 명령이다 — 사용자를 오타 찾기로 보내는 거짓 안내였다. 안내 메커니즘은 이미 있었고 **데이터가 4개뿐**이었다.
29
+ - 그 두 맵(ko/en)을 모듈 스코프로 올려 **키 집합이 같은지 데이터로 단언**한다. 실행으로만 검사하면 `--language en` 이 프로젝트 밖에서 전환되지 않아 en 맵이 무방비로 남는다(변이에서 en 항목을 지워도 통과했다 — 1.36.89·1.36.97 에 이은 3회차).
30
+
31
+ ### 가드
32
+
33
+ selftest 343 · 변이 6/8 사살(나머지 2건은 5가지 탈출 시도를 실행해 **등가임을 증명**) · 표면 변이 6/7 · 경로 클래스 정상경로 26/26.
34
+
35
+ 가드를 짜는 동안 **소스 grep 이 세 번 무너졌다**: (1) `const _GROUP_USAGE = {` 문자열이 다른 selftest 의 가드 안에도 있어 슬라이스가 엉뚱한 곳을 집었고 (2) 선언 이름을 바꾸자 옛 가드가 허공을 가리켜 메타가드가 잡았고 (3) 이중가드 확인용 문자열이 **가드 자신에** 있어 자기참조로 통과했다. 그래서 결정을 남긴다 — **표면 가드는 소스 grep 이 아니라 실제 출력·데이터로 검사한다.** 술어만 시험하면 배선이 빠진 것도 못 본다(`skillLearn` 에서 호출을 지워도 통과했다).
36
+
37
+ ### 남은 것 (T-0092~0096 으로 등록)
38
+
39
+ 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).
40
+
41
+ ## 1.36.100 — 2026-08-05 — 승인된 시안을 계약으로 고정하고, 구현이 지켰는지 기계로 대조한다 (P-0012 후반)
42
+
43
+ 사용자가 고른 방식의 후반부다: *"승인 시 그대로 적용"* 을 **"시안을 계약으로 고정 + 구현 후 자동 대조"** 로 구현한다.
44
+
45
+ 프레임워크 프로젝트에서 렌더된 마크업을 컴포넌트 소스로 되돌릴 수는 없다. 그래서 **"자동 적용" 이라고 부르지 않는다.** 대신 시안이 약속한 것을 저장해 두고, 구현이 끝난 뒤 그 약속이 지켜졌는지 센다.
46
+
47
+ - 시안에 `<!-- leerness:draw-start -->` / `draw-end` 마커를 넣었다. AI 는 그 **사이만** 그리고, 승인 시 그 구간의 클래스·`var(--토큰)`·요소가 계약으로 고정된다(스캐폴드 자신의 장식은 계약에서 제외).
48
+ - `leerness preview verify <P-ID> --files a,b` — 구현 파일에서 계약된 클래스·토큰이 나타나는지 대조하고 **누락 목록과 존재율**을 낸다. 실측: 계약을 무시한 구현 8% · 계약대로 구현 100%.
49
+ - 아직 안 그린 시안을 승인하면 *"계약이 비어 대조가 무의미합니다"* 라고 말한다 — 빈 계약을 조용히 만들지 않는다.
50
+
51
+ **한계를 매번 말한다.** 이건 **존재 검사**지 시각적 일치 검사가 아니다. 클래스가 있다고 같아 보이는 건 아니다 — 그건 사람이 시안과 화면을 나란히 봐야 한다. 이 구분을 흐리면 1.36.5 에서 고쳤던 과장(휴리스틱을 측정된 판정처럼 광고)을 그대로 되풀이하게 된다. 그래서 출력·JSON·도움말 모두에 `presence-only` 를 박았고, e2e 가 그 문구의 존재까지 단언한다.
52
+
53
+ 구현 중 판정 하나가 틀렸다. "아직 안 그렸다" 를 **글자 수**로 봤는데 도구가 넣어 둔 안내 문구만으로도 120자를 넘어 경고가 안 떴다 — 손대지 않았다는 확실한 신호는 길이가 아니라 **그 자리에 placeholder 가 그대로 있다는 것**이었다.
54
+
3
55
  ## 1.36.99 — 2026-08-05 — 시안을 빈 종이가 아니라 이 앱의 재료로 시작시킨다 (P-0012)
4
56
 
5
57
  사용자 지적: *"mockup.html 로 미리보기 시안의 정확도가 높지 않은데 — 개발하는 프로젝트 언어 등을 참고해서, 수정하려는 페이지나 요소를 복제해 높은 정확도의 시안을 보여줄 수 있나?"*
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.99 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.101 하네스를 사용합니다. 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.99는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.101는 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.99는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
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.99 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.101 릴리스 태그 이력** (수백 라운드) · _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.99: 2026-08-04
238
+ Last synced by Leerness v1.36.101: 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.99';
37
+ const VERSION = '1.36.101';
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();
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,158 @@ 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
+ function _p0101SkillIdOk() {
7791
+ const bad = ['../..', '../../../victim', '..\\..\\x', '/etc/passwd', 'C:\\Windows', 'a/b', 'a\\b', '.', '..', '.hidden', '', null, 'x'.repeat(65)];
7792
+ const good = ['open-meteo', 'stripe', 'my_skill', 'v1.2.3', 'a', 'A-Z_0.9'];
7793
+ const rejects = bad.every(v => !!_badSkillId(v));
7794
+ const accepts = good.every(v => _badSkillId(v) === null);
7795
+ // 술어만 시험하면 '배선' 이 빠진 걸 못 본다 — skillLearn 에서 호출을 지워도 통과했다(변이로 확인).
7796
+ // 그리고 소스 grep 으로 이중가드를 확인하려 했더니 그 문자열이 이 가드 자신에 있어 자기참조로 통과했다(3회차).
7797
+ // 그래서 실제로 명령을 돌려 '밖의 파일이 살아남는가' 를 본다.
7798
+ const os2 = require('os');
7799
+ const cp2 = require('child_process');
7800
+ const arena = fs.mkdtempSync(path.join(os2.tmpdir(), 'leerness-skid-'));
7801
+ let wiredOk = false;
7802
+ try {
7803
+ const victim = path.join(arena, 'victim');
7804
+ fs.mkdirSync(victim, { recursive: true });
7805
+ fs.writeFileSync(path.join(victim, 'KEEP.txt'), 'user data');
7806
+ const proj = path.join(arena, 'a', 'b', 'proj');
7807
+ fs.mkdirSync(proj, { recursive: true });
7808
+ fs.writeFileSync(path.join(proj, 'package.json'), '{"name":"p","version":"0.1.0"}');
7809
+ const R = (a) => cp2.spawnSync(process.execPath, [__filename, ...a], { cwd: proj, encoding: 'utf8', timeout: 180000, env: Object.assign({}, process.env, { LEERNESS_OFFLINE: '1' }) });
7810
+ R(['init', proj, '--yes']);
7811
+ const readmeBefore = exists(path.join(proj, 'README.md')) ? read(path.join(proj, 'README.md')).length : 0;
7812
+ const rmDepth = Array(5).fill('..').join('/') + '/victim';
7813
+ const r1 = R(['skill', 'remove', rmDepth]);
7814
+ const r2 = R(['skill', 'learn', '../..', '--capability', 'boom']);
7815
+ const readmeAfter = exists(path.join(proj, 'README.md')) ? read(path.join(proj, 'README.md')).length : 0;
7816
+ const okId = R(['skill', 'learn', 'open-meteo', '--capability', 'weather']); // 대조군: 평범한 id 는 여전히 동작
7817
+ wiredOk = r1.status !== 0 && r2.status !== 0
7818
+ && exists(path.join(victim, 'KEEP.txt')) // 밖의 파일이 살아 있어야 한다
7819
+ && readmeBefore > 0 && readmeAfter === readmeBefore // README 가 그대로여야 한다
7820
+ && okId.status === 0;
7821
+ } catch { wiredOk = false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
7822
+ return rejects && accepts && wiredOk;
7823
+ }
7648
7824
  function _lensDomainList() { return Object.keys(LENS_CATALOG).join('|'); }
7649
7825
  // 1.36.97: 완료 검증의 인라인 렌즈 줄은 '목차'다 — 본문이 아니다.
7650
7826
  // 심화 문항은 한 문단이라 통째로 찍으면 80칸에서 한 축이 3~4줄을 먹는다(검수 실측: advisory 블록이 8줄 → 15줄).
@@ -8006,6 +8182,8 @@ function commandsCmd(root) {
8006
8182
  { cmd: 'lazy detect [path] [--json]', desc: '게으른 작업 감지 (1.9.101)' },
8007
8183
  { 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
8184
  { cmd: `lens [${_lensDomainList()}] [--json]`, desc: '분야별 자기질문 품질 렌즈 + 분야간 인과관계 (1.18.3 · database/contract/recovery/observability 는 심화, axes 는 8축 경량 점검)' },
8185
+ // 1.36.101 (명령 표면 감사): 1.36.98 에서 디스패처에만 붙이고 commands/--help 에 등재하지 않아 '있는데 안 보이는' 명령이었다.
8186
+ { cmd: 'library [show|page] [path] [--json] [--ai]', desc: '재사용 인벤토리 — 컴포넌트·디자인 토큰을 저장소에서 추출. page: 오프라인 단일 HTML · --ai: 에이전트용 압축 JSON (1.36.98 P-0013)' },
8009
8187
  { cmd: 'optimism-check <T-ID>', desc: '낙관적 API 감지 (1.9.26)' },
8010
8188
  { cmd: 'requests audit|list|complete|drop|auto-complete', desc: '사용자 요청 추적 (1.9.207/223)' },
8011
8189
  { cmd: 'pre-wake-audit [path] [--last]', desc: 'sleep 전 점검 (1.9.209)' },
@@ -8033,7 +8211,7 @@ function commandsCmd(root) {
8033
8211
  { cmd: 'tech [--json]', desc: '기술 프로필 — 개발 언어·연결 서비스 자동 감지 + 마이그레이션/언어전환 이력, 그래프 🛠 탭 표시 — 1.36.53 UR-0062' },
8034
8212
  { cmd: 'integrity check [--repair] [--json]', desc: 'managed 정책-문서 12종 무결성(부재/H1 상실/절단) 점검 — --repair: archive 대피 후 템플릿 재생성 — 1.36.57 감사 F-04' },
8035
8213
  { cmd: 'referee add|verify|list|show|drop', desc: '검증기 캘리브레이션 — 신뢰 전에 탐지력 증명(known-good 통과 + 망가뜨린 known-bad 를 기대 사유로 거부). verify-claim --referee / gate 게이팅 — 1.36.80 P-0001' },
8036
- { cmd: 'preview add|list|show|approve|revise|mockup', desc: '신규 기능 미리보기 승인 워크플로 — approve 전 코드 작성 금지 계약. mockup <P-ID> [--force]: 자립형 HTML 디자인 시안 스캐폴드 · add --mockup <파일>: 기존 시안 첨부 — 1.36.51 UR-0061 · 1.36.75 UR-0066' },
8214
+ { cmd: 'preview add|list|show|approve|revise|mockup|verify', desc: '신규 기능 미리보기 승인 워크플로 — approve 전 코드 작성 금지 계약. mockup <P-ID> [--target <파일>] [--force]: 프로젝트의 실제 토큰·컴포넌트·대상 화면 클래스를 심은 시안 · approve 시 그 시안이 계약으로 고정됨 · verify <P-ID> --files a,b: 구현이 계약을 지켰는지 존재 대조(시각적 일치는 보증하지 않음) — 1.36.51 UR-0061 · 1.36.75 UR-0066 · 1.36.99/1.36.100 P-0012' },
8037
8215
  { cmd: 'dashboard [path] [--port N] [--timeout SEC] [--json]', desc: '.harness 읽기 전용 대시보드 — 토글·task·마일스톤·미리보기·라우팅기록·bugfix probe 를 localhost 에서 열람. 설정 변경·게이트 판정은 하지 않는다(CLI 가 판정). --json 은 서버 없이 같은 스냅샷 출력. 정적 단일파일 뷰(leerness.html)는 leerness graph --html 로 그대로 — 1.36.90 P-0006' },
8038
8216
  { cmd: 'agents route "<작업>" [--tier tiny|normal|high-risk] [--confirm --approved-by "<승인자>"] [--log]', desc: '작업 난이도 판정(결정적 규칙) → 추상 역할(architect/commander/coder/reviewer) 배치 제안. 모델명 하드코딩 없이 `roles set` 매핑을 쓰고, 실행은 명시 확인 필요. 토글 기본 OFF: leerness toggle set difficulty-routing on — 1.36.89 P-0007' },
8039
8217
  { cmd: 'bugfix start|receipt|drop|list', desc: 'bugfix 완료 영수증 — 수정 전 재현 probe 를 등록(2회 연속 실패해야 등록)하고, done 전이에서 통과 + 근본원인/형제범위 영수증을 요구. 토글 기본 OFF: leerness toggle set bugfix-receipt on — 1.36.87 P-0005' },
@@ -8071,6 +8249,9 @@ function commandsCmd(root) {
8071
8249
  { cmd: 'migrate --guide', desc: 'AI 에이전트용 크로스버전 마이그레이션 가이드 (1.9.355)' },
8072
8250
  { cmd: 'doctor [--json]', desc: '설치/환경 진단 (1.9.315)' },
8073
8251
  { cmd: 'selftest [--json]', desc: '코어 함수 무결성 자가 검증 (1.9.258)' },
8252
+ // 1.36.101 (명령 표면 감사): 둘 다 실행되고 문서에도 있는데 이 목록에만 없었다 — '있는데 안 보이는 명령'.
8253
+ { cmd: 'gate [path]', desc: 'CI 한 번 호출 게이트 — verify + audit + scan + encoding + lazy' },
8254
+ { cmd: 'toggle get|set|list <기능> [on|off]', desc: '기능 토글 (gate/lens/auto-graph/delegation-brief) — leerness.html ⚙ 탭과 연동 (1.36.30)' },
8074
8255
  { cmd: 'install-safety [--json]', desc: '설치 안전 프로필 — 0 deps/0 install-script (1.9.359)' },
8075
8256
  { cmd: 'env check|sync|detect [path]', desc: '셸/인코딩 환경 진단 (1.9.241)' },
8076
8257
  { cmd: 'shell-guard "<command>"', desc: '셸 호환성 정적 분석 (1.9.260)' },
@@ -24679,6 +24860,7 @@ VERIFICATION (evidence-gated "done")
24679
24860
  verify-code [path] [--build] [--bench] Run tests/lint/typecheck, record evidence
24680
24861
  gate [path] One-call CI gate: verify + audit + scan + encoding + lazy
24681
24862
  lens [${_lensDomainList()}] [--json] Quality self-question lenses
24863
+ library [show|page] [path] [--json] [--ai] Reusable inventory (components + design tokens) — page: offline HTML, --ai: compact JSON
24682
24864
 
24683
24865
  SECURITY & HYGIENE
24684
24866
  scan secrets [path] Committed-secret detection
@@ -24740,7 +24922,7 @@ function help() {
24740
24922
  // 1.23.1 (UR-0010 Phase 6): 영어 opt-in 시 큐레이트 영어판. 기본(ko) 은 아래 한국어 help 그대로.
24741
24923
  if (_uiLang(arg('--path', process.cwd())) === 'en') { _helpEn(); return; }
24742
24924
  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
24925
+ 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
24926
  leerness retro [path] [--days 7] [--all-apps] [--include p1,p2] [--json] # 회고 (1.9.13~1.9.16)
24745
24927
  leerness insights [path] [--all-apps] [--include p1,p2] [--json] # 누적 통계 (1.9.13~1.9.16)
24746
24928
  leerness brainstorm "<주제>" [--all-apps] [--include p1,p2] [--json] # 브레인스토밍 (1.9.13~1.9.16)
@@ -24773,6 +24955,34 @@ async function main() {
24773
24955
  return log(VERSION);
24774
24956
  }
24775
24957
  if (has('--help') || has('-h')) return help();
24958
+ // 1.36.101 (명령 표면 감사): '없는 경로에 초록 보고' 클래스를 디스패처 한 곳에서 막는다.
24959
+ // 실측 — 32개 조사 대상 중 16개가 존재하지 않는 디렉토리에 exit 0 과 정상 보고를 냈다
24960
+ // (pulse/round-history/milestones/session-resume 는 "✓ 비정상 종료 신호 없음 · R0 · 정상 운영 중" 까지 찍었다 — 오타가 안심 보고가 된다).
24961
+ // 같은 클래스를 UR-0136 에서 이미 한 번 스윕했는데 절반이 남아 있었다. 명령마다 고치면 다음 명령에서 또 빠지므로
24962
+ // 경로 해석 방식(--path / positional / _resolveRoot)이 제각각인 것을 여기서 한 번에 검사한다.
24963
+ // '없는 경로를 만드는' 명령(init/migrate/update 등)은 대상에서 제외한다 — 그쪽은 부재가 정상 입력이다.
24964
+ {
24965
+ // 대상은 '실제로 뚫려 있던 것' 만이다. 이미 자기 가드를 가진 명령(handoff/health/status/scan secrets/...)까지 넣었더니
24966
+ // 그쪽의 고유 메시지와 en 로케일을 가로채 기존 e2e 4건이 깨졌다 — 고쳐야 할 것만 고친다.
24967
+ // (scan secrets 처럼 '파일 경로' 가 정상 입력인 명령도 있어, 디렉토리 강제는 그 명령들엔 맞지 않는다.)
24968
+ const _strict = new Set(['pulse', 'round-history', 'milestones', 'session-resume',
24969
+ 'tech', 'library', 'retro', 'insights', 'reuse-map', 'py-check', 'pre-wake-audit']);
24970
+ const _strictPair = { memory: 'status', consistency: 'check', env: 'check', idempotency: 'audit', 'api-skill': 'list' };
24971
+ const _isStrict = _strict.has(cmd) || (_strictPair[cmd] && args[1] === _strictPair[cmd]);
24972
+ if (_isStrict) {
24973
+ // 사용자가 '경로를 명시했을 때만' 검사한다 — 미지정(cwd)은 항상 존재하므로 검사할 것이 없다.
24974
+ const _explicit = arg('--path', null) || [args[1], args[2]].find(a => a && !a.startsWith('-') && a !== _strictPair[cmd]);
24975
+ if (_explicit) {
24976
+ const _abs = absRoot(_explicit);
24977
+ if (!exists(_abs) || !fs.statSync(_abs).isDirectory()) {
24978
+ // 로케일을 붙인다 — en 프로젝트에서 이 가드만 한국어를 내면 그 자체가 i18n 누수다.
24979
+ const _en = _uiLang(_abs) === 'en' || _uiLang(process.cwd()) === 'en';
24980
+ failJson(has('--json'), 'path_not_found', _en ? `path not found or not a directory: ${_abs}` : `경로 없음 또는 디렉토리 아님: ${_abs}`);
24981
+ return;
24982
+ }
24983
+ }
24984
+ }
24985
+ }
24776
24986
  // 1.13.1 (15th 블라인드 리뷰 P1, codex gpt-5.5): 무명령 + 옵션만(예: leerness --json) 은 묵시적 init 쓰기를 하지 않음 — cwd 에 .harness 가 의도치 않게 생성되던 부작용 차단. 명시 명령 없이 옵션만이면 help (bare leerness 온보딩 init 은 유지).
24777
24987
  if (!args[0] && process.argv.slice(2).some(a => a.startsWith('-'))) { help(); return; }
24778
24988
  // 1.9.38 (B): 사용 통계 카운터 — usage stats 명령 자체와 비차단 경로는 제외
@@ -25347,20 +25557,9 @@ async function main() {
25347
25557
  // 1.9.306 (UR-0045): 명시적 help 요청은 exit 0, 그 외 미인식 명령은 안내 + exit 1 (실패를 성공으로 오판 방지).
25348
25558
  if (cmd === 'help' || cmd === 'commands' || cmd === '--help' || cmd === '-h') { help(); return; }
25349
25559
  // 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; }
25560
+ // 1.36.101: 맵을 모듈 스코프(_GROUP_USAGE_KO/_EN)로 올려 selftest 가 '키 집합이 같은가' 를 데이터로 검사한다.
25561
+ // 문자열 실행으로만 검사하면 --language en 프로젝트 밖에서 전환되지 않아 en 맵이 무방비로 남는다(변이로 확인).
25562
+ 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
25563
  // 1.9.437 (11th 외부평가 Codex P2, UR-0138): --json 모드 unknown command 도 순수 JSON.
25365
25564
  failJson(has('--json'), 'unknown_command', `알 수 없는 명령: ${cmd} (leerness --help 로 전체 명령 확인)`);
25366
25565
  return;
package/lib/clarify.js CHANGED
@@ -128,6 +128,30 @@ function _targetSkeleton(root, rel) {
128
128
  while ((m = TAG.exec(txt))) { const t = m[1]; if (!tseen.has(t)) { tseen.add(t); tags.push(t); } }
129
129
  return { file: rel, classes: classes.slice(0, 40), tags: tags.slice(0, 40), lines: txt.split('\n').length };
130
130
  }
131
+ // 1.36.100 (P-0012 후반): 승인된 시안을 '계약' 으로 고정한다.
132
+ // 프레임워크 프로젝트에서 렌더된 마크업을 컴포넌트 소스로 되돌릴 수는 없다 — 그래서 "자동 적용" 이라 부르지 않는다.
133
+ // 대신 시안이 약속한 **클래스와 토큰**을 저장해 두고, 구현이 끝난 뒤 그 약속이 지켜졌는지 기계로 대조한다.
134
+ // 한계는 분명히 적는다: 이건 '존재' 검사지 '시각적 일치' 검사가 아니다(있다고 해서 같아 보이는 건 아니다).
135
+ const DRAW_START = 'leerness:draw-start';
136
+ const DRAW_END = 'leerness:draw-end';
137
+ function _extractContract(html) {
138
+ const s = String(html || '');
139
+ const a = s.indexOf(DRAW_START), b = s.indexOf(DRAW_END);
140
+ if (a < 0 || b < 0 || b < a) return { ok: false, reason: 'draw 마커를 찾지 못함 — 시안에서 마커를 지웠거나 구버전 시안입니다' };
141
+ const area = s.slice(s.indexOf('-->', a) + 3, b);
142
+ const classes = new Set(); const tokens = new Set(); const tags = new Set();
143
+ let m;
144
+ const CLS = /class\s*=\s*["']([^"']{1,400})["']/g;
145
+ while ((m = CLS.exec(area))) for (const c of m[1].split(/\s+/)) if (c && !/^(?:placeholder|mock|notes|chips|sw|stack|meta)$/.test(c)) classes.add(c);
146
+ const TOK = /var\(\s*(--[\w-]+)\s*\)/g;
147
+ while ((m = TOK.exec(area))) tokens.add(m[1]);
148
+ const TAG = /<([a-zA-Z][\w-]*)/g;
149
+ while ((m = TAG.exec(area))) tags.add(m[1].toLowerCase());
150
+ // '아직 안 그렸다' 는 길이로 판정하면 안 된다 — 도구가 넣어 둔 안내 문구만으로도 120자를 넘는다(실측에서 이 판정이 틀렸다).
151
+ // 손대지 않았다는 확실한 신호는 그 자리에 도구의 placeholder 가 그대로 있다는 것이다.
152
+ const untouched = /class\s*=\s*["']placeholder["']/.test(area) || classes.size === 0;
153
+ return { ok: true, classes: [...classes], tokens: [...tokens], tags: [...tags], drawn: area.replace(/\s+/g, ' ').trim().length, untouched };
154
+ }
131
155
  function _mockupScaffold(p, ctx) {
132
156
  const feats = (p.features || []).map(f => ` <li>${_esc(f)}</li>`).join('\n');
133
157
  const c = ctx || { stack: [], tokens: { cssVars: [], utilities: [] }, components: [], target: null, notes: [] };
@@ -202,8 +226,10 @@ ${feats || ' <li>(없음 — --features "a,b" 로 기록)</li>'}
202
226
  ${notes ? `<p class="notes" style="margin-top:10px">인벤토리 한계</p><ul class="notes">${notes}</ul>` : ''}
203
227
  </section>
204
228
  <section class="mock">
205
- <h2>화면 시안 — ⬇ AI 영역을 실제 레이아웃 초안으로 교체하세요</h2>
229
+ <h2>화면 시안 — ⬇ 아래 마커 <b>사이</b>만 실제 레이아웃 초안으로 교체하세요</h2>
230
+ <!-- leerness:draw-start · 승인 시 이 구간의 클래스와 var(--토큰) 이 '계약' 으로 고정된다. 마커는 지우지 마세요. -->
206
231
  <div class="placeholder">위의 <b>실제 토큰·컴포넌트·대상 화면 클래스</b>를 재료로 삼아 그립니다 — 새 색/새 간격을 발명하지 마세요.<br>외부 리소스 없이(오프라인) 이 파일 하나로 열리게 유지하세요.</div>
232
+ <!-- leerness:draw-end -->
207
233
  </section>
208
234
  </main>
209
235
  <!-- leerness:mockup ${_esc(p.id)} — 이 파일은 시안입니다. 승인(leerness preview approve ${_esc(p.id)}) 전 실제 코드 작성 금지. -->
@@ -322,6 +348,44 @@ function previewCmd(root, sub, rest, deps = {}) {
322
348
  return;
323
349
  }
324
350
 
351
+ // 1.36.100 (P-0012 후반): `preview verify <P-ID> --files a,b` — 승인 시 고정한 계약과 구현을 대조.
352
+ // 무엇을 하는가: 시안이 약속한 클래스/토큰이 구현 파일에 '나타나는가' 를 센다.
353
+ // 무엇을 하지 않는가: 시각적 일치를 보증하지 않는다. 클래스가 있다고 같아 보이는 건 아니다 —
354
+ // 이 구분을 흐리면 1.36.5 에서 고쳤던 과장을 되풀이하게 된다. 그래서 출력에 한계를 매번 적는다.
355
+ if (sub === 'verify') {
356
+ if (!id) { failJson(json, 'id_required', `preview verify <P-ID> --files a,b 필요`); return; }
357
+ if (!p) { failJson(json, 'not_found', `preview 없음: ${id}`); return; }
358
+ if (!p.contract) { failJson(json, 'no_contract', `${id} 에 계약이 없습니다 — 시안을 만들고(preview mockup) 승인(preview approve)해야 고정됩니다`); return; }
359
+ const filesArg = (arg && arg('--files', '')) || '';
360
+ const files = filesArg.split(',').map(s => s.trim()).filter(Boolean);
361
+ if (!files.length) { failJson(json, 'files_required', `--files src/a.tsx,src/b.css 로 구현 파일을 지정하세요`); return; }
362
+ const base = absRoot(root);
363
+ let text = ''; const read_ = []; const missingFiles = [];
364
+ for (const f of files) {
365
+ const fp = path.resolve(base, f);
366
+ if (fp !== base && !fp.startsWith(base + path.sep)) { missingFiles.push(`${f} (루트 밖 — 읽지 않음)`); continue; }
367
+ if (!exists(fp)) { missingFiles.push(`${f} (없음)`); continue; }
368
+ try { const st = fs.statSync(fp); if (st.size > 1024 * 1024) { missingFiles.push(`${f} (1MB 초과 — 건너뜀)`); continue; } text += '\n' + read(fp); read_.push(f); } catch (e) { missingFiles.push(`${f} (${e.message})`); }
369
+ }
370
+ const cls = p.contract.classes || [], tok = p.contract.tokens || [];
371
+ const clsFound = cls.filter(c => text.includes(c));
372
+ const tokFound = tok.filter(t => text.includes(t));
373
+ const total = cls.length + tok.length;
374
+ const found = clsFound.length + tokFound.length;
375
+ const pct = total ? Math.round(found * 100 / total) : 0;
376
+ const missCls = cls.filter(c => !clsFound.includes(c)), missTok = tok.filter(t => !tokFound.includes(t));
377
+ if (json) { log(JSON.stringify({ ok: true, id, filesRead: read_, filesSkipped: missingFiles, classes: { total: cls.length, found: clsFound.length, missing: missCls }, tokens: { total: tok.length, found: tokFound.length, missing: missTok }, presencePct: pct, limit: 'presence-only — 시각적 일치를 보증하지 않음' }, null, 2)); return; }
378
+ log(`# preview verify ${id} — 시안 계약 ↔ 구현 대조`);
379
+ log(` 읽은 파일 ${read_.length}개${missingFiles.length ? ` · 건너뜀 ${missingFiles.length}개` : ''}`);
380
+ missingFiles.forEach(x => warn(` ${x}`));
381
+ log(` 클래스 ${clsFound.length}/${cls.length} · 토큰 ${tokFound.length}/${tok.length} → 존재율 ${pct}%`);
382
+ if (missCls.length) log(` 누락 클래스: ${missCls.slice(0, 20).join(' ')}${missCls.length > 20 ? ` …외 ${missCls.length - 20}` : ''}`);
383
+ if (missTok.length) log(` 누락 토큰: ${missTok.slice(0, 20).join(' ')}${missTok.length > 20 ? ` …외 ${missTok.length - 20}` : ''}`);
384
+ if (!read_.length) warn(' 읽은 구현 파일이 없어 존재율은 의미가 없습니다');
385
+ log(` ⓘ 한계: 이건 '존재' 검사입니다 — 클래스·토큰이 나타나는지만 봅니다. 같아 보이는지는 보지 않습니다(그건 사람이 시안과 화면을 나란히 봐야 합니다).`);
386
+ return;
387
+ }
388
+
325
389
  if (sub === 'show') {
326
390
  if (json) { log(JSON.stringify({ ok: true, ...p }, null, 2)); return; }
327
391
  log(`# ${p.id} 「${p.title}」 [${p.status}]`);
@@ -340,10 +404,26 @@ function previewCmd(root, sub, rest, deps = {}) {
340
404
  ok(`${p.id} 이미 승인됨 — 변경 없음`); return;
341
405
  }
342
406
  p.status = 'approved'; (p.history = p.history || []).push({ at: now(), event: 'approved' });
407
+ // 1.36.100 (P-0012 후반): 승인 순간의 시안을 계약으로 고정한다 — 나중에 구현이 그 약속을 지켰는지 대조하기 위해서다.
408
+ // 시안이 없거나 마커가 지워졌으면 계약을 만들지 않는다(없는 계약을 있는 척 하지 않는다).
409
+ let _contractNote = '';
410
+ if (p.mockupPath) {
411
+ const mp = path.resolve(root, p.mockupPath);
412
+ if (exists(mp)) {
413
+ const ex = _extractContract(read(mp));
414
+ if (ex.ok) {
415
+ p.contract = { at: now(), classes: ex.classes, tokens: ex.tokens, tags: ex.tags, drawnChars: ex.drawn, untouched: !!ex.untouched };
416
+ _contractNote = `계약 고정: 클래스 ${ex.classes.length} · 토큰 ${ex.tokens.length} · 요소 ${ex.tags.length}`;
417
+ if (ex.untouched) _contractNote += ' ⚠ 시안을 아직 안 그렸습니다(placeholder 그대로) — 계약이 비어 대조가 무의미합니다';
418
+ } else _contractNote = `계약 미고정: ${ex.reason}`;
419
+ } else _contractNote = '계약 미고정: 시안 파일 없음';
420
+ } else _contractNote = '계약 미고정: 시안 없음 (leerness preview mockup 으로 먼저 생성)';
343
421
  if (!_savePreviews(root, list, json)) return;
344
422
  try { if (typeof deps._onResolved === 'function') deps._onResolved(p.id); } catch {} // 1.36.80 (UR-0067): 확인 완료 → 임시 워크스페이스 정리
345
- if (json) { log(JSON.stringify({ ok: true, id: p.id, status: p.status }, null, 2)); return; }
423
+ if (json) { log(JSON.stringify({ ok: true, id: p.id, status: p.status, contract: p.contract || null, contractNote: _contractNote }, null, 2)); return; }
346
424
  ok(`${p.id} 승인 — 이제 구현을 시작해도 됩니다.`);
425
+ log(` ${_contractNote}`);
426
+ if (p.contract) log(` → 구현 후 대조: leerness preview verify ${p.id} --files src/a.tsx,src/b.css`);
347
427
  return;
348
428
  }
349
429
  if (sub === 'revise') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.99",
3
+ "version": "1.36.101",
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
@@ -3744,7 +3744,11 @@ total++;
3744
3744
  {
3745
3745
  let ok = false;
3746
3746
  try {
3747
- const r = cp.spawnSync(process.execPath, [CLI, 'doctor', '--json'], { encoding: 'utf8', timeout: 30000 });
3747
+ // 1.36.100: 호출만 30초로 남아 있었다 — doctor selftest 내장해 격리 실측 31s(게시된 1.36.98 도 동일)
3748
+ // 전체 스위트 부하에서 간헐 초과했고, 실제로 게이트 한 번을 실패시켰다(내 변경 때문이 아니라 잠복 결함).
3749
+ // 같은 명령의 다른 호출(4644·6955)은 1.36.87 에서 이미 120초로 올려 뒀다 — 여기만 빠져 있었다.
3750
+ // 검사 대상은 속도가 아니라 동작이다.
3751
+ const r = cp.spawnSync(process.execPath, [CLI, 'doctor', '--json'], { encoding: 'utf8', timeout: 120000 });
3748
3752
  let j = null; try { j = JSON.parse(r.stdout); } catch {}
3749
3753
  const doctorOk = j && j.version && typeof j.mcpTools === 'number' && j.mcpTools >= 80 && j.selftest && j.selftest.total > 0 && j.healthy === true && r.status === 0;
3750
3754
  // commands 요약 + banner 가 실제 MCP 수 노출 (하드코딩 65/46 아님)
@@ -9567,6 +9571,54 @@ total++;
9567
9571
  if (!ok) failed++;
9568
9572
  }
9569
9573
 
9574
+ // ── 1.36.100 (P-0012 후반): 승인 시 시안을 계약으로 고정 → 구현 후 존재 대조. 과장하지 않는 것까지 함께 건다.
9575
+ {
9576
+ total++;
9577
+ let ok = false; const dbg = {};
9578
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ct100-'));
9579
+ const R = (a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: d, encoding: 'utf8', timeout: 120000 });
9580
+ const out = (r) => (r.stdout || '') + (r.stderr || '');
9581
+ try {
9582
+ fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'shop', version: '0.1.0', dependencies: { react: '18', tailwindcss: '3' } }));
9583
+ R(['init', d, '--yes', '--language', 'ko', '--skills', 'recommended']);
9584
+ const W = (rel, t) => { const p = path.join(d, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, t); };
9585
+ W('src/theme.css', ':root { --brand-500: #2563eb; --surface-1: #0f1115; }\n');
9586
+ W('src/components/Button.tsx', 'export function Button(){ return <button className="px-3 py-1 rounded-lg">go</button> }\n');
9587
+ const id = (/(P-\d{4,})/.exec(out(R(['preview', 'add', '장바구니', '--design', 'x']))) || [])[1];
9588
+ R(['preview', 'mockup', id]);
9589
+ const mp = path.join(d, '.harness', 'previews', id + '-mockup.html');
9590
+ // 안 그린 시안을 승인하면 계약이 비었다고 말한다(조용히 빈 계약을 만들지 않는다)
9591
+ const ap0 = out(R(['preview', 'approve', id]));
9592
+ const warnsEmpty = /아직 안 그렸습니다/.test(ap0);
9593
+ // AI 가 마커 사이를 채운 뒤 재승인 → 계약 고정
9594
+ R(['preview', 'revise', id, '--note', '초안 반영']);
9595
+ let html = fs.readFileSync(mp, 'utf8');
9596
+ const drawn = '\n<header class="topbar p-4 rounded-lg" style="background:var(--surface-1)"><h1 class="text-xl">장바구니</h1></header>\n<ul class="cart-list gap-2"><li class="cart-item px-3 py-1" style="border-color:var(--brand-500)">항목</li></ul>\n';
9597
+ fs.writeFileSync(mp, html.replace(/(leerness:draw-start[^>]*-->)[\s\S]*?(<!-- leerness:draw-end)/, `$1${drawn}$2`));
9598
+ const ap = out(R(['preview', 'approve', id]));
9599
+ const fixed = /계약 고정: 클래스 [1-9]/.test(ap);
9600
+ const store = JSON.parse(fs.readFileSync(path.join(d, '.harness', 'previews.json'), 'utf8'));
9601
+ const rec = ((store.previews || store) || []).find(x => x.id === id);
9602
+ const stored = !!(rec && rec.contract && rec.contract.classes.includes('topbar') && rec.contract.tokens.includes('--brand-500'))
9603
+ && !rec.contract.classes.some(c => ['mock', 'notes', 'chips', 'placeholder'].includes(c)); // 스캐폴드 장식은 계약이 아니다
9604
+ // 미준수 구현 → 낮은 존재율 · 준수 구현 → 100%
9605
+ W('src/pages/Cart.tsx', 'export function Cart(){ return <main className="p-4">x</main> }\n');
9606
+ const low = Number((/존재율 (\d+)%/.exec(out(R(['preview', 'verify', id, '--files', 'src/pages/Cart.tsx']))) || [, '100'])[1]);
9607
+ W('src/pages/Cart.tsx', 'import "./cart.css";\nexport function Cart(){ return (<main><header className="topbar p-4 rounded-lg"><h1 className="text-xl">장바구니</h1></header><ul className="cart-list gap-2"><li className="cart-item px-3 py-1">항목</li></ul></main>) }\n');
9608
+ W('src/pages/cart.css', '.topbar{background:var(--surface-1)} .cart-item{border-color:var(--brand-500)}\n');
9609
+ const v2 = out(R(['preview', 'verify', id, '--files', 'src/pages/Cart.tsx,src/pages/cart.css']));
9610
+ const high = Number((/존재율 (\d+)%/.exec(v2) || [, '0'])[1]);
9611
+ // 과장 금지: '존재' 검사라는 한계를 매번 말한다(1.36.5 에서 고쳤던 과장의 재발 방지)
9612
+ const honest = /'존재' 검사입니다/.test(v2) && /같아 보이는지는 보지 않습니다/.test(v2);
9613
+ const guarded = /루트 밖/.test(out(R(['preview', 'verify', id, '--files', '../../etc/passwd'])));
9614
+ Object.assign(dbg, { warnsEmpty, fixed, stored, low, high, honest, guarded });
9615
+ ok = warnsEmpty && fixed && stored && low < 50 && high === 100 && honest && guarded;
9616
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
9617
+ console.log(ok ? '✓ B(1.36.100/P-0012) 승인 시 시안→계약 고정 + 구현 존재 대조(미준수 저율/준수 100%) + 한계 고지 + 루트밖 거부 + 빈 시안 경고'
9618
+ : '✗ 1.36.100 시안 계약 실패 ' + JSON.stringify(dbg));
9619
+ if (!ok) failed++;
9620
+ }
9621
+
9570
9622
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
9571
9623
  if (failed > 0) process.exit(1);
9572
9624