leerness 1.36.88 → 1.36.90

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,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.90 — 2026-07-29 — 하네스 대시보드 (P-0006, 읽기 전용) · 승인 시 정한 경계를 테스트로 못 박음
4
+
5
+ 사용자 요청은 더 컸다 — "`leerness.html` 을 없애고 `.harness` 안에 node/python 코드를 두고, 루트에 실행기를 만들어 서버로 게이트를 활성화하고 미리보기도 서버에서 본다." codex 와 함께 검토해 **대시보드만** 만들기로 정했고(사용자 승인), 넘지 않을 경계를 승인 시점에 preview 스토어에 기록했다.
6
+
7
+ - **`leerness dashboard [path] [--port N] [--timeout SEC] [--json]`** — localhost 읽기 전용 뷰: 기능 토글(기본 OFF 표시 포함) · 최근 task · 마일스톤 · 기능 미리보기 · 라우팅 기록 · bugfix probe. `--json` 은 **서버를 띄우지 않고** 같은 스냅샷을 낸다(CI·테스트가 서버 없이 같은 데이터를 쓴다).
8
+ - **서버 런타임을 새로 만들지 않았다** — 이미 출하된 `preview serve`(1.36.80)의 리스너를 공유한다(포트 충돌 시 순차 탐색 → OS 임의 포트).
9
+
10
+ **넘지 않기로 한 경계 — 그리고 그것을 테스트가 강제한다.** 경계가 문서에만 있으면 다음 라운드에 조용히 넘는다. e2e 가드가 다음을 단언하고, 각 경계를 침범하는 변이로 탐지력을 확인했다(**변이 15종 15/15 검출** — 하위폴더 실행코드·이름 다른 실행기·시크릿 마스킹 제거·술어 되돌림·형상 필터 제거 포함):
11
+
12
+ - **`.harness` 에 실행 코드를 두지 않는다** — npm 패키지가 코드를 소유한다. 복사본을 두면 패키지가 올라가도 저장소의 사본은 얼어붙어, 업그레이드 1회가 저장소 N곳의 코드 마이그레이션 N회가 된다(스키마 드리프트 · 보안 수정 미반영 · 사용자 저장소의 검토 부담 증가).
13
+ - **루트에 실행기를 기본 생성하지 않는다** — 의존성으로 설치된 도구가 사용자 저장소에 추적 파일을 심지 않는다. 실행기는 이미 있다(`leerness` CLI). Windows `.cmd`/PowerShell/POSIX 비호환, npm hoisting·workspaces·pnpm 해석 차이, CI 에서 서버가 잡을 매다는 문제도 함께 피한다.
14
+ - **게이트를 서버가 판정하지 않는다** — 게이트의 가치는 CI 에서 exit code 로 판정된다는 데 있다. 서버가 살아있는 것과 게이트가 통과한 것이 혼동되면 안 되고, 서버가 꺼져 있을 때 조용히 통과해서도 안 된다. 화면이 그렇게 말하고, 그 문구를 지우는 변이도 테스트가 잡는다.
15
+ - **정적 단일 파일 뷰는 존속한다** — `leerness graph --html`(leerness.html). 프로세스 없이 더블클릭, 오프라인, 단일 파일 공유, 프로세스 실행이 제한된 환경, 보는 동안 변하지 않는 스냅샷은 서버가 대체하지 못한다.
16
+
17
+ **보안** — 실제 HTTP 요청으로 확인했다: 127.0.0.1 바인딩 · GET/HEAD 외 405 · **URL→파일 매핑 자체가 없음**(경로 목록 고정이라 탐색 표면이 존재하지 않는다, `/../../Windows/win.ini` · `/.harness/environment.json` 모두 404) · Host 헤더 검사로 DNS rebinding 403(`127.0.0.1.evil.com` 포함) · 잘못된 URL 인코딩 400(서버 생존) · `nosniff`/`no-store`/`no-referrer` · 모든 삽입값 HTML 이스케이프(task/마일스톤/미리보기/라우팅/probe 표면에 XSS 페이로드를 심어 원시 `<img` 0건 확인) · 원문 파일 덤프 없음(표시할 필드만 골라 담으므로 `.env`/설정의 시크릿이 스냅샷에 섞이지 않음을 실측).
18
+
19
+ **출하 전 codex 검수(29차)가 6건(BLOCK)을 냈고 전부 재현해 고쳤다:**
20
+
21
+ - **내가 1.36.88 에서 고친 결함을 새 표면에서 그대로 재현했다** — 형제범위를 게이트는 `_siblingScopeOk` 로, 대시보드는 `!!siblingScope` 로 판정해 빈 목록(`{checked: []}`)을 한쪽은 "기록됨", 다른 쪽은 "미완"으로 봤다. 술어를 export 해 **공유**하도록 고쳤다(표면마다 다시 구현하면 같은 불일치가 되살아난다).
22
+ - **형상 이상 스토어가 서버를 죽였다** — `previews.json` 이 `[null]` 이면 `p.id` 에서 던져 프로세스가 종료됐고, `{}`(배열 아님)는 **경고 없이** "비어 있음"으로 보였다. 원소는 객체만 통과시키고, 배열이 아니면 그 사실을 화면에 남긴다.
23
+ - **probe 명령 안의 토큰이 그대로 노출됐다** — `API_TOKEN=sk-live-… npm test` 같은 값이 HTML 과 `/api/state` 에 실렸다. 표시용 자유 텍스트에 시크릿 마스킹(키 이름은 남기고 값만 `***`)을 적용하고, bugfix 목록에 상한(20건)을 걸었다.
24
+ - **경계 테스트가 경계를 지키지 못했다** — `.harness` **최상위만** 봐서 `.harness/dashboard/server.js` 가 통과했고, `leerness.*` 정확 일치만 봐서 `start-leerness.js` 가 통과했으며, `leerness.html` 은 init 이 이미 만든 파일을 확인해 `graph --html` 이 깨져도 통과했다. 재귀 탐색 · 이름 변형 포함 · **삭제 후 실제 재생성 + exit 0** 으로 좁히고, 그 구멍을 정확히 지나던 변이 2종을 추가해 잡히는 것을 확인했다.
25
+ - **Host 검사가 fail-open 이었다** — Host 헤더가 없으면 통과했고, `:` 로 잘라 `[::1]:5338` 이 `[` 가 되어 허용 목록에 있는데도 거부됐다. 부재는 거부하고 IPv6 대괄호를 먼저 처리한다.
26
+ - **포트·타임아웃 범위 미검증** — `--port 99999` 는 `listen()` 에서 RangeError 로 죽었고, `--timeout 2147484` 는 setTimeout 상한을 넘겨 **안내한 시간과 정반대로 즉시** 종료됐다. 둘 다 명확한 거부로 바꿨다.
27
+
28
+ **정직성 — "읽기 전용"을 과장하지 않았다.** 실측 중 `dashboard --json` 이 `.harness` 를 건드리는 것을 발견했는데, 원인은 대시보드가 아니라 **CLI 가 모든 명령에서 갱신하는 로컬 사용량 카운터**(`.harness/cache/usage-stats.json`)였다(`task list` 도 동일, `LEERNESS_INTERNAL=1` 이면 없음). 그래서 화면에 "완전한 무기록은 아니다"를 명시하고, 테스트는 **하네스 상태 파일이 바뀌지 않는다**는 실제로 참인 불변식을 단언한다(cache 제외). 네트워크 요청이 없다는 주장도 확인했다 — 업데이트 확인(`_warnIfStale`)은 install 경로에만 있고 dashboard 경로에는 없다.
29
+
30
+ 게이트: exit 0 · E2E 410/410 · selftest 341/341 · ✗ 0건
31
+
32
+ ## 1.36.89 — 2026-07-29 — 작업 난이도별 모델 라우팅 (P-0007, 옵트인 · 제안 후 확인 실행)
33
+
34
+ 사용자 요청: "codex/claude 등 CLI 설치·사용 가능 여부에 따라 작업 난이도별 AI 모델을 라우팅, 켜고 끌 수 있게." 함께 받은 제3자 제안(GPT-5.6=설계·판정 / Claude=지휘 / Codex-Spark=고속 작업자 고정 배치)은 codex 와 함께 검토해 **오래 가는 정책과 곧 낡을 정보를 갈라서** 전자만 반영했다.
35
+
36
+ - **`leerness agents route "<작업>"`** — 난이도(tiny/normal/high-risk)를 결정적 규칙으로 판정하고, 등급별 추상 역할 구성을 제안한다. 최상위 `route` 는 이미 컨텍스트 라우팅 명령이라(실행해보고 알았다) `agents recommend|dispatch` 와 같은 집에 두었다.
37
+ - **모델 이름은 한 줄도 하드코딩하지 않는다.** 정책은 추상 역할(`architect`/`commander`/`coder`/`reviewer`)로만 쓰고, 역할→provider+model 매핑은 기존 `leerness roles set` 사용자 설정이 소유한다. 벤더 모델명·성능 순위·컨텍스트 크기는 몇 달이면 낡아 제품 정책이 될 수 없다.
38
+ - **4중 fail-closed** — 토글 OFF · 역할 미설정 · 고위험인데 사람 승인(`--approved-by`) 없음 · 검수자와 구현자가 같은 provider. 마지막 항목은 같은 벤더가 구현과 검수를 겸하면 같은 맹점을 공유하므로 "검수됨"이라고 부를 수 없기 때문이다.
39
+ - **토글 `difficulty-routing` 기본 OFF.** 켜도 자동 실행은 없다 — `--dispatch` 를 **허용**할 뿐이고, 제안·열람은 토글과 무관하게 동작한다. 토글을 켜는 것이 무인 과금을 승인하는 뜻이 되면 안 된다.
40
+ - **감사 기록** `.harness/routing-log.json` (200건 cap, 원자 쓰기 + 락) — 입력·매칭된 신호·후보·선택·승인자·거부 사유·실행 여부. 거부된 시도도 남는다. 남기지 않으면 사후에 라우팅을 검증할 수 없다.
41
+
42
+ **정직성**: 판정하는 것은 **텍스트에 대한 규칙 매칭**뿐이고 난이도를 측정하지 않는다. 출력이 그대로 말한다 — "난이도는 텍스트 규칙 매칭 결과이며 측정값이 아닙니다" · "설치·활성 여부만 확인했습니다 — 인증·모델 사용권한·쿼터·과금 권한은 검증하지 않았습니다." `--version` 이 도는 것은 그중 무엇도 증명하지 않는다.
43
+
44
+ **규칙이 못 가르는 경우를 숨기지 않는다.** 검토에서 나온 함정 케이스 — "인증 문서 오타 수정"(고위험 키워드 + 초소형 키워드 동시) — 은 규칙만으로 가릴 수 없다. 보수적으로 high-risk 를 유지하되 `conflict: true` · `confidence: low` 를 노출하고 "사소한 작업이면 `--tier tiny` 로 낮추세요"를 안내한다. 근거가 없을 때도 tiny 로 내리지 않는다: 신호 0 + 짧은 설명은 `normal`(confidence low)이다 — **과소평가는 사고, 과대평가는 비용**이라 비용 쪽을 택했다.
45
+
46
+ 적대 검증 실측: 혼합 입력 5종("CSS 색상 조정하면서 결제 버튼 동작도 바꿈" 등) 전부 high-risk 로 상승 · 37만 자 입력 분류 1ms(ReDoS 없음) · 소스 주석의 "한글에는 `\b` 가 동작하지 않는다"는 주장도 실행으로 확인했다.
47
+
48
+ **출하 전 codex 검수(28차)가 BLOCK 2건 포함 10건을 냈고, 8건이 재현돼 전부 고쳤다** (1건은 반증 — `--approved-by --json` 은 실제로 `human_approval_required` 로 정확히 거부된다):
49
+
50
+ - **`dispatched: true` 라고 보고하면서 아무것도 실행하지 않았다.** 이 제품이 존재하는 이유가 "하지 않은 일을 했다고 말하지 않게 하는 것"인데 정확히 그 거짓말을 했다. 실제로 하는 일은 계획 확정이므로 `--confirm` / `confirmed` 로 이름을 바로잡고 `executed: false` 를 명시하며, "leerness 는 여기서 외부 CLI 를 실행하지 않습니다"를 사람용·JSON·`--log` 세 경로 모두에 넣었다.
51
+ - **영문 어간이 정규식을 통째로 빠져나갔다.** `\bauth\b` 는 `authentication` 에 매치되지 않아 **"fix typo in authentication middleware" 가 tiny 로 떨어졌다**(실측). `authorization`·`permissions`·`concurrency`·`idempotency` 도 같은 이유로 전부 누락. 어간 + `\w*` 로 바꾸고, `author`(글쓴이)·`tokenizer`(파서 용어)가 걸리지 않도록 오탐 대조군을 테스트에 함께 넣었다. **내 적대 테스트가 한글 입력만 써서 이 구멍을 통째로 놓쳤다.**
52
+ - **초소형 키워드 하나로 거대 작업이 tiny 가 됐다** — "Rewrite the entire compiler … and also fix one typo" 가 tiny 였다. tiny 는 "범위가 좁다"는 주장이므로 광범위 신호(entire/전체/갈아엎…)나 긴 설명이면 그 주장을 인정하지 않는다.
53
+ - **`--tier` 만 주면 빈 작업이 통과했다** — 빈 작업 검사가 declared 분기 뒤에 있었다.
54
+ - **빈 provider 가 "해석됨"이었다** — `resolved: !!r` 이라 공백 provider 로 확정이 통과했다.
55
+ - **손상된 감사 기록을 조용히 덮어썼다** — 감사 기록을 파괴하는 코드가 감사 기록 기능 안에 있었다. 손상 시 쓰지 않고 그 사실을 알린다(스토어 손상 = 덮어쓰기 거부, 기존 규율). 파싱 전에 파일 크기(4MB)도 본다.
56
+ - **하지 않은 확인을 했다고 말했다** — 고지 문구가 "설치·활성 여부만 확인했습니다"였는데 이 모듈은 설치 확인을 **한 번도 하지 않는다**. 실제로 하는 일(역할 매핑 읽기)만 말하도록 고치고, 고지가 없던 `--log` 경로에도 넣었다.
57
+
58
+ **신설 e2e 가드는 변이 14종으로 탐지력을 증명했다**(토글 무시 · 역할 미설정 무시 · 사람 승인 생략 · 독립성 검사 제거 · 충돌시 tiny 로 내림 · 근거부족을 tiny 로 · `--tier` 검증 제거 · 감사기록 미기록 · 고지를 옛 거짓 문구로 회귀 · 실행안함 고지 삭제 · 손상로그 덮어쓰기 복귀 · 빈 provider 를 resolved 로 · 빈 작업 검사 제거 · tiny 범위 가드 제거 → **14/14 검출**).
59
+
60
+ 세 번 고쳐서 14/14 가 됐고, 살아남은 변이의 원인이 매번 달랐다:
61
+ - `--tier` 검증을 없애도 exit 1 이 나왔다 — **검증이 아니라 크래시** 때문이었다(사유 `invalid_tier` 까지 단언 + 알 수 없는 등급은 크래시 대신 가장 엄격한 구성으로 fail-closed).
62
+ - probe 검사와 영수증 검사처럼 **두 거부 조건이 서로를 가렸다**(판별 케이스 추가).
63
+ - 마지막 하나는 코드가 아니라 **내 픽스처가 잘못된 스키마(`{roles:{…}}` 대신 최상위)로 저장돼 셋업이 조용히 실패한 것**이었다 — 빈 provider 를 검증한 게 아니라 "역할 없음"을 통과로 읽고 있었다. 셋업 성공 여부까지 함께 단언하도록 고쳤다.
64
+
65
+ 게이트: exit 0 · E2E 409/409 · selftest 341/341 · ✗ 0건
66
+
3
67
  ## 1.36.88 — 2026-07-29 — `selftest --json` 잡음 3종 제거 · "우연히 지켜지던" 단일 JSON 계약을 원인 단계에서 고정
4
68
 
5
69
  `selftest --json` 이 JSON 앞에 잡음 5줄(`✗ selftest probe` · `✗ probe` · graph 사람용 3줄)을 낸다는 제보로 시작했다. **먼저 계약이 실제로 깨졌는지 측정했고, 제보의 전제 두 가지는 사실이 아니었다.**
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.88 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.90 하네스를 사용합니다. 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.88는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.90는 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.88는 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.88 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.90 릴리스 태그 이력** (수백 라운드) · _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.88: 2026-07-29
238
+ Last synced by Leerness v1.36.90: 2026-07-29
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.88';
37
+ const VERSION = '1.36.90';
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') 시 호스트 프로세스 오염.
@@ -7471,6 +7471,8 @@ function commandsCmd(root) {
7471
7471
  { cmd: 'integrity check [--repair] [--json]', desc: 'managed 정책-문서 12종 무결성(부재/H1 상실/절단) 점검 — --repair: archive 대피 후 템플릿 재생성 — 1.36.57 감사 F-04' },
7472
7472
  { cmd: 'referee add|verify|list|show|drop', desc: '검증기 캘리브레이션 — 신뢰 전에 탐지력 증명(known-good 통과 + 망가뜨린 known-bad 를 기대 사유로 거부). verify-claim --referee / gate 게이팅 — 1.36.80 P-0001' },
7473
7473
  { 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' },
7474
+ { 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' },
7475
+ { 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' },
7474
7476
  { cmd: 'bugfix start|receipt|drop|list', desc: 'bugfix 완료 영수증 — 수정 전 재현 probe 를 등록(2회 연속 실패해야 등록)하고, done 전이에서 통과 + 근본원인/형제범위 영수증을 요구. 토글 기본 OFF: leerness toggle set bugfix-receipt on — 1.36.87 P-0005' },
7475
7477
  { cmd: 'review <file> --persona <ids>', desc: '페르소나 리뷰 (1.9.29)' },
7476
7478
  { cmd: 'brainstorm "<topic>" [--include-code]', desc: '워크스페이스 회수 + 코드 grep' }
@@ -24046,6 +24048,18 @@ async function main() {
24046
24048
  if (cmd === 'honesty-check') return honestyCheckCmd(arg('--path', process.cwd()), args[1]);
24047
24049
  if (cmd === 'persona') return personaCmd(arg('--path', process.cwd()), args[1], args[2]);
24048
24050
  if (cmd === 'review') return reviewCmd(arg('--path', process.cwd()), args[1]);
24051
+ // 1.36.89 (P-0007): agents route — 난이도 판정 + 추상 역할 배치 제안. `agents recommend|dispatch` 와 같은 집에 둔다.
24052
+ // (최상위 `route` 는 이미 컨텍스트 라우팅 명령이라 가리면 안 된다 — 실측으로 확인하고 이름을 옮겼다.)
24053
+ // 값 플래그가 positional 로 새지 않게 원시 argv 파싱(referee/preview/bugfix 와 동일 패턴).
24054
+ if (cmd === 'agents' && args[1] === 'route') {
24055
+ const _rtRaw = process.argv.slice(2); const _rtI = _rtRaw.indexOf('route'); const _rtToks = [];
24056
+ for (let i = _rtI + 1; i < _rtRaw.length && _rtI >= 0; i++) {
24057
+ if (_rtRaw[i].startsWith('--')) { if (['--tier', '--approved-by', '--path'].includes(_rtRaw[i]) && _rtRaw[i + 1] && !_rtRaw[i + 1].startsWith('--')) i++; continue; }
24058
+ _rtToks.push(_rtRaw[i]);
24059
+ }
24060
+ return require('../lib/routing').routeCmd(arg('--path', process.cwd()), _rtToks.join(' '),
24061
+ { has, arg, _withLock, _resolveRole, _toggleOn: (r, id) => _tgl.toggleOn(r, id) });
24062
+ }
24049
24063
  if (cmd === 'agents') return agentsCmd(arg('--path', process.cwd()), args[1], ...args.slice(2));
24050
24064
  // 1.9.157: Provider Registry — 사용자 정의 provider 동적 추가
24051
24065
  if (cmd === 'provider') return providerCmd(arg('--path', process.cwd()), args[1], ...args.slice(2));
@@ -24210,6 +24224,14 @@ async function main() {
24210
24224
  return require('../lib/bugfix').bugfixCmd(_bfRoot || process.cwd(), _bfSub, _bfRest,
24211
24225
  { has, arg, _withLock, _taskExists: (r, id) => readProgressRows(r).some(x => x.id === id) });
24212
24226
  }
24227
+ // 1.36.90 (P-0006, 사용자 승인 범위=대시보드만): .harness 읽기 전용 대시보드. 코드는 npm 패키지가 소유하고
24228
+ // `.harness` 는 데이터만 갖는다 — 실행 코드를 설치 프로젝트에 복사하지 않는다(업그레이드 드리프트 차단).
24229
+ if (cmd === 'dashboard') {
24230
+ return require('../lib/dashboard').dashboardCmd(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd(), {
24231
+ has, arg, VERSION, _roadmapData, _loadDecisions, _loadLessons,
24232
+ _loadToggles: _tgl.loadToggles, _toggleRegistry: _tgl.TOGGLE_REGISTRY,
24233
+ });
24234
+ }
24213
24235
  if (cmd === 'lens') return lensCmd(args[1]); // 1.18.3 (UR-0003): 분야별 자기질문 품질 렌즈
24214
24236
  // 1.9.233: leerness commands — 카테고리화된 전체 CLI 명령 목록
24215
24237
  if (cmd === 'commands') return commandsCmd(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd());
package/lib/bugfix.js CHANGED
@@ -332,4 +332,6 @@ function bugfixCmd(root, sub, rest, deps = {}) {
332
332
  failJson(json, 'unknown_subcommand', `알 수 없는 bugfix 하위명령: ${sub} (가능: start, receipt, drop, list)`);
333
333
  }
334
334
 
335
- module.exports = { bugfixCmd, checkDoneTransition, BugfixBlocked, _storePath, _loadChecked };
335
+ // _siblingScopeOk 공개(1.36.90): 대시보드가 **같은 술어**를 쓴다 표면마다 다시 구현하면
336
+ // "목록은 기록됨, 게이트는 미완" 같은 불일치가 되살아난다(1.36.88 #12 에서 이미 한 번 겪었다).
337
+ module.exports = { bugfixCmd, checkDoneTransition, BugfixBlocked, _storePath, _loadChecked, _siblingScopeOk };
@@ -0,0 +1,276 @@
1
+ // lib/dashboard.js — 하네스 대시보드 (1.36.90, P-0006 · 사용자 승인 범위 = **대시보드만**).
2
+ //
3
+ // 사용자 요청: "leerness.html 을 없애고 .harness 내용·설정을 코드로 두고, 루트 실행기로 서버를 띄워
4
+ // 게이트 활성화·디자인 미리보기·플랜/핸드오프/이력을 서버에서 보고 싶다."
5
+ //
6
+ // 검토(codex 27차 + 자체)에서 **넘지 않기로 한 경계** — 승인 시 preview 스토어에 기록했다:
7
+ // · `.harness` 에 실행 코드를 두지 않는다. npm 패키지가 코드를 소유하고 `.harness` 는 데이터만 갖는다.
8
+ // 복사본을 두면 패키지가 올라가도 각 저장소의 사본은 얼어붙어, 업그레이드 1회가 저장소 N곳의
9
+ // 코드 마이그레이션 N회가 된다(스키마 드리프트 · 보안 수정 미반영 · 사용자 저장소의 검토 부담).
10
+ // · 루트에 실행기 파일을 **기본 생성하지 않는다**. 의존성으로 설치된 도구가 사용자 저장소에 추적 파일을
11
+ // 심지 않는다. 실행기는 이미 있다 — `leerness` CLI 다.
12
+ // · **게이트를 서버가 판정하지 않는다.** 게이트의 가치는 CI 에서 exit code 로 판정된다는 데 있다.
13
+ // 서버가 살아있는 것과 게이트가 통과한 것이 혼동되면 안 되고, 서버가 꺼져 있을 때 조용히 통과해서도 안 된다.
14
+ // 여기서는 **읽기만** 한다.
15
+ // · `leerness.html`(정적 단일 파일)은 존속한다 — 프로세스 없이 더블클릭, 오프라인, 단일 파일 공유,
16
+ // 프로세스 실행이 제한된 환경, 보는 동안 변하지 않는 스냅샷은 서버가 대체하지 못한다.
17
+ //
18
+ // 보안: 127.0.0.1 에만 바인딩 · GET 외 거부 · URL→파일 매핑 없음(경로 탐색 표면 자체가 없다) ·
19
+ // Host 헤더 검사(DNS rebinding) · 모든 삽입값 HTML 이스케이프 · 쓰기 엔드포인트 없음.
20
+ 'use strict';
21
+ const http = require('http');
22
+ const path = require('path');
23
+ const fs = require('fs');
24
+ const { absRoot, exists, read, log, ok, warn, failJson, now } = require('./io');
25
+
26
+ const LEERNESS_DASHBOARD_PORT = 5338; // preview serve(5337) 바로 옆 — 동시 사용 가능
27
+
28
+ function _esc(s) {
29
+ return String(s == null ? '' : s)
30
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
31
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
32
+ }
33
+
34
+ function _readJson(root, name) {
35
+ const f = path.join(absRoot(root), '.harness', name);
36
+ if (!exists(f)) return { ok: true, missing: true, data: null };
37
+ try {
38
+ const st = fs.statSync(f);
39
+ if (st.size > 4 * 1024 * 1024) return { ok: false, reason: `파일이 큼(${Math.round(st.size / 1048576)}MB)`, data: null };
40
+ return { ok: true, data: JSON.parse(read(f)) };
41
+ } catch (e) { return { ok: false, reason: 'JSON 파싱 실패', data: null }; }
42
+ }
43
+
44
+ // 1.36.90 (codex 29차 #1): JSON 문법만 보고 **형상**을 안 봐서, `[null]` 이 `p.id` 에서 던져
45
+ // 서버 프로세스를 죽였고 `{}`(배열 아님)는 경고 없이 "비어 있음"으로 보였다.
46
+ // 배열이 아니면 그 사실을 notes 에 남기고, 원소는 객체만 통과시킨다.
47
+ function _asList(res, label, notes) {
48
+ if (!res.ok) { notes.push(`${label}: ${res.reason}`); return []; }
49
+ if (res.missing || res.data == null) return [];
50
+ if (!Array.isArray(res.data)) { notes.push(`${label}: 배열이 아닙니다 — 표시하지 않습니다`); return []; }
51
+ const items = res.data.filter(x => x && typeof x === 'object' && !Array.isArray(x));
52
+ if (items.length !== res.data.length) notes.push(`${label}: 형상이 다른 항목 ${res.data.length - items.length}건 제외`);
53
+ return items;
54
+ }
55
+
56
+ // 시크릿 마스킹 — probe 명령·작업 제목 같은 자유 텍스트에 토큰이 섞여 들어올 수 있다.
57
+ // 화면과 /api/state 는 사람이 보는 표면이므로, 값 자체를 내보내지 않는다(키 이름은 남겨 맥락 유지).
58
+ const _SECRET_ASSIGN = /\b([A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|PWD|CREDENTIAL|AUTH)[A-Za-z0-9_]*)\s*[=:]\s*("[^"]*"|'[^']*'|\S+)/gi;
59
+ const _SECRET_SHAPE = /\b(sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{16,}|gho_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{12,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,})/g;
60
+ function _redact(s, max) {
61
+ let out = String(s == null ? '' : s).replace(_SECRET_ASSIGN, (m, k) => `${k}=***`).replace(_SECRET_SHAPE, '***');
62
+ if (max && out.length > max) out = out.slice(0, max) + '…';
63
+ return out;
64
+ }
65
+
66
+ // 스냅샷 — **읽기 전용**. 원본 파일을 그대로 뿌리지 않고 표시할 필드만 골라 담는다
67
+ // (원문 덤프는 시크릿·개인정보가 섞여 나갈 수 있는 표면을 만든다).
68
+ function snapshot(root, deps = {}) {
69
+ root = absRoot(root);
70
+ const { VERSION, _roadmapData, _loadToggles, _toggleRegistry, _loadDecisions, _loadLessons } = deps;
71
+ const notes = [];
72
+
73
+ const rd = (typeof _roadmapData === 'function' ? _roadmapData(root) : {}) || {};
74
+ const tasks = Array.isArray(rd.tasks) ? rd.tasks : [];
75
+ const milestones = Array.isArray(rd.milestones) ? rd.milestones : [];
76
+
77
+ const toggleState = (typeof _loadToggles === 'function' ? _loadToggles(root) : {}) || {};
78
+ const registry = _toggleRegistry || {};
79
+ const toggles = Object.keys(registry).map(id => ({
80
+ id, on: toggleState[id] === true,
81
+ defaultOff: !!(registry[id] || {}).defaultOff,
82
+ desc: (registry[id] || {}).desc || '',
83
+ affects: (registry[id] || {}).affects || '',
84
+ }));
85
+
86
+ const previewList = _asList(_readJson(root, 'previews.json'), 'previews.json', notes);
87
+ const routingList = _asList(_readJson(root, 'routing-log.json'), 'routing-log.json', notes);
88
+ const bugfixList = _asList(_readJson(root, 'bugfix-receipts.json'), 'bugfix-receipts.json', notes);
89
+ // 형제범위 판정은 게이트와 **같은 술어**를 쓴다 — 표면마다 다시 구현하면 불일치가 되살아난다(codex 29차 #3).
90
+ let _sibOk;
91
+ try { _sibOk = require('./bugfix')._siblingScopeOk; } catch { _sibOk = null; }
92
+ if (typeof _sibOk !== 'function') _sibOk = (sc) => !!sc && ((Array.isArray(sc.checked) && sc.checked.length > 0) || (typeof sc.notApplicable === 'string' && !!sc.notApplicable.trim()));
93
+
94
+ const statusCount = {};
95
+ tasks.forEach(t => { const s = String(t.status || '?'); statusCount[s] = (statusCount[s] || 0) + 1; });
96
+
97
+ const handoffPath = path.join(root, '.harness', 'session-handoff.md');
98
+ const handoff = exists(handoffPath)
99
+ ? { exists: true, updated: (() => { try { return fs.statSync(handoffPath).mtime.toISOString(); } catch { return null; } })(),
100
+ ageDays: (() => { try { return Math.floor((Date.now() - fs.statSync(handoffPath).mtimeMs) / 86400000); } catch { return null; } })() }
101
+ : { exists: false, updated: null, ageDays: null };
102
+
103
+ const decisions = (typeof _loadDecisions === 'function' ? _loadDecisions(root) : []) || [];
104
+ const lessons = (typeof _loadLessons === 'function' ? _loadLessons(root) : []) || [];
105
+
106
+ return {
107
+ ok: true,
108
+ version: VERSION || null,
109
+ root,
110
+ generatedAt: now(),
111
+ readOnly: true,
112
+ gateNote: '게이트는 CLI 가 판정합니다 — 이 대시보드는 읽기만 하며 게이트를 실행하거나 통과시키지 않습니다(leerness gate).',
113
+ staticNote: '정적 단일 파일 뷰는 그대로 있습니다 — leerness graph --html (leerness.html, 프로세스 없이 열림).',
114
+ tasks: {
115
+ total: tasks.length, byStatus: statusCount,
116
+ recent: tasks.slice(-12).reverse().map(t => ({ id: _redact(t.id, 24), status: _redact(t.status, 24), request: _redact(t.request, 90), updated: t.updated || null })),
117
+ },
118
+ milestones: {
119
+ total: milestones.length,
120
+ list: milestones.slice(0, 12).map(m => ({ id: _redact(m.id, 24), title: _redact(m.title, 90), status: _redact(m.status, 24), progress: m.progress == null ? null : m.progress })),
121
+ },
122
+ toggles,
123
+ handoff,
124
+ memory: { decisions: decisions.length, lessons: lessons.length },
125
+ previews: previewList.slice(-12).reverse().map(p => ({ id: _redact(p.id, 24), title: _redact(p.title, 80), status: _redact(p.status, 24) })),
126
+ routing: routingList.slice(-8).reverse().map(r => ({
127
+ at: typeof r.at === 'string' ? r.at.slice(0, 32) : null,
128
+ tier: _redact(r.tier, 16), confidence: _redact(r.confidence, 16),
129
+ state: r.executed ? '실행' : r.confirmed ? '확정' : (r.requested === 'confirm' || r.requested === 'dispatch') ? '거부' : '제안',
130
+ task: _redact(r.task, 60),
131
+ })),
132
+ // 목록 상한을 건다 — 수천 건이 쌓인 스토어가 HTML 을 폭증시키지 않게(codex 29차 #2).
133
+ bugfix: bugfixList.slice(-20).map(b => ({
134
+ id: _redact(b.id, 24), repro: _redact(b.repro, 60),
135
+ rootCause: typeof b.rootCause === 'string' && !!b.rootCause.trim(),
136
+ siblingScope: _sibOk(b.siblingScope),
137
+ })),
138
+ bugfixTotal: bugfixList.length,
139
+ notes,
140
+ };
141
+ }
142
+
143
+ // Host 허용 판정 — 부재는 거부(fail-closed), IPv6 대괄호를 먼저 벗긴 뒤 포트를 자른다.
144
+ const _LOCAL_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]', '0:0:0:0:0:0:0:1']);
145
+ function _hostAllowed(raw) {
146
+ const h = String(raw == null ? '' : raw).trim().toLowerCase();
147
+ if (!h) return false; // Host 없는 요청은 받지 않는다
148
+ let name;
149
+ if (h.startsWith('[')) { const end = h.indexOf(']'); if (end < 0) return false; name = h.slice(0, end + 1); }
150
+ else name = h.split(':')[0];
151
+ return _LOCAL_HOSTS.has(name);
152
+ }
153
+
154
+ function _row(cells) { return `<tr>${cells.map(c => `<td>${c}</td>`).join('')}</tr>`; }
155
+
156
+ function renderHtml(s) {
157
+ const t = (x) => _esc(x);
158
+ const toggleRows = s.toggles.map(g => _row([
159
+ `<span class="${g.on ? 'on' : 'off'}">${g.on ? 'ON' : 'OFF'}</span>`,
160
+ `<code>${t(g.id)}</code>${g.defaultOff ? ' <span class="tag">기본 OFF</span>' : ''}`,
161
+ t(g.desc), `<code>${t(g.affects)}</code>`,
162
+ ])).join('');
163
+ const taskRows = s.tasks.recent.map(x => _row([`<code>${t(x.id)}</code>`, t(x.status), t(x.request), t(x.updated || '')])).join('');
164
+ const msRows = s.milestones.list.map(m => _row([`<code>${t(m.id)}</code>`, t(m.title), t(m.status || ''), m.progress == null ? '' : t(m.progress + '%')])).join('');
165
+ const pvRows = s.previews.map(p => _row([`<code>${t(p.id)}</code>`, t(p.title), t(p.status)])).join('');
166
+ const rtRows = s.routing.map(r => _row([t(String(r.at).slice(0, 16)), t(r.tier), t(r.confidence), t(r.state), t(r.task)])).join('');
167
+ const bfRows = s.bugfix.map(b => _row([`<code>${t(b.id)}</code>`, `<code>${t(b.repro)}</code>`, b.rootCause ? '기록됨' : '미기록', b.siblingScope ? '기록됨' : '미기록'])).join('');
168
+ const statusLine = Object.entries(s.tasks.byStatus).map(([k, v]) => `${t(k)} ${v}`).join(' · ');
169
+ const sec = (title, head, rows, empty) => rows
170
+ ? `<section><h2>${title}</h2><table><thead><tr>${head.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></section>`
171
+ : `<section><h2>${title}</h2><p class="empty">${empty}</p></section>`;
172
+
173
+ return `<!doctype html>
174
+ <html lang="ko"><head><meta charset="utf-8"><title>leerness 대시보드 — ${t(path.basename(s.root))}</title>
175
+ <meta name="viewport" content="width=device-width,initial-scale=1">
176
+ <style>
177
+ :root{color-scheme:light dark}
178
+ body{font:14px/1.6 -apple-system,"Segoe UI",system-ui,sans-serif;margin:0;padding:24px;max-width:1100px}
179
+ h1{font-size:20px;margin:0 0 4px} h2{font-size:15px;margin:28px 0 8px;border-bottom:1px solid #8884;padding-bottom:4px}
180
+ .meta{opacity:.7;font-size:12px} .note{background:#8881;border-left:3px solid #888;padding:8px 12px;margin:12px 0;font-size:13px}
181
+ table{border-collapse:collapse;width:100%;font-size:13px} th,td{text-align:left;padding:5px 8px;border-bottom:1px solid #8883;vertical-align:top}
182
+ th{font-weight:600;opacity:.75;font-size:12px} code{font-family:ui-monospace,Consolas,monospace;font-size:12px}
183
+ .on{color:#1a7f37;font-weight:700} .off{opacity:.55;font-weight:700} .tag{font-size:11px;opacity:.6;border:1px solid #8886;border-radius:3px;padding:0 4px}
184
+ .empty{opacity:.55} .cnt{display:inline-block;margin-right:16px}
185
+ </style></head><body>
186
+ <h1>leerness 대시보드</h1>
187
+ <div class="meta">v${t(s.version)} · ${t(s.root)} · ${t(s.generatedAt)} · <strong>읽기 전용</strong></div>
188
+ <div class="note">${t(s.gateNote)}</div>
189
+ <div class="note">${t(s.staticNote)}</div>
190
+ ${s.notes.length ? `<div class="note">⚠ ${s.notes.map(t).join(' · ')}</div>` : ''}
191
+ <section><h2>요약</h2>
192
+ <p><span class="cnt">task ${s.tasks.total} (${statusLine || '없음'})</span>
193
+ <span class="cnt">마일스톤 ${s.milestones.total}</span>
194
+ <span class="cnt">결정 ${s.memory.decisions}</span>
195
+ <span class="cnt">교훈 ${s.memory.lessons}</span>
196
+ <span class="cnt">핸드오프 ${s.handoff.exists ? t(s.handoff.ageDays) + '일 전' : '없음'}</span></p></section>
197
+ ${sec('기능 토글', ['상태', 'id', '설명', '영향'], toggleRows, '토글 없음')}
198
+ ${sec('최근 task', ['id', '상태', '내용', '갱신'], taskRows, 'task 없음')}
199
+ ${sec('마일스톤', ['id', '제목', '상태', '진행'], msRows, '마일스톤 없음')}
200
+ ${sec('기능 미리보기(승인 워크플로)', ['id', '제목', '상태'], pvRows, '미리보기 없음')}
201
+ ${sec('라우팅 기록', ['시각', '난이도', '신뢰도', '결과', '작업'], rtRows, '라우팅 기록 없음')}
202
+ ${sec('bugfix probe', ['task', 'probe', '근본원인', '형제범위'], bfRows, '등록된 probe 없음')}
203
+ <section><h2>이 화면이 하지 않는 일</h2><ul>
204
+ <li>설정을 바꾸지 않습니다 — 변경은 CLI(<code>leerness toggle set …</code>)로 합니다.</li>
205
+ <li>게이트를 실행하거나 통과시키지 않습니다 — <code>leerness gate</code> 의 exit code 가 판정입니다.</li>
206
+ <li>외부로 아무것도 보내지 않습니다 — 127.0.0.1 에만 바인딩되고 네트워크 요청을 하지 않습니다.</li>
207
+ <li>다만 <strong>완전한 무기록은 아닙니다</strong>: leerness CLI 는 모든 명령에서 로컬 사용량 카운터(<code>.harness/cache/usage-stats.json</code>)를 갱신합니다. 하네스 상태(토글·task·플랜·기록)는 이 화면이 건드리지 않습니다.</li>
208
+ </ul></section>
209
+ </body></html>`;
210
+ }
211
+
212
+ // `leerness dashboard [--port N] [--timeout SEC] [--json]`
213
+ function dashboardCmd(root, deps = {}) {
214
+ const { has, arg } = deps;
215
+ root = absRoot(root);
216
+ const json = !!(has && has('--json'));
217
+ if (!exists(path.join(root, '.harness'))) { failJson(json, 'harness_missing', `leerness 미설치: ${root} — 먼저 leerness init`); return; }
218
+
219
+ // --json 은 서버를 띄우지 않고 스냅샷만 낸다 — CI·스크립트·테스트가 서버 없이 같은 데이터를 쓴다.
220
+ if (json) { log(JSON.stringify(snapshot(root, deps), null, 2)); return; }
221
+
222
+ // 1.36.90 (codex 29차 #6): 범위를 안 보면 `--port 99999` 가 listen() 에서 RangeError 로 죽고,
223
+ // `--timeout 2147484` 는 setTimeout 상한(약 24.8일)을 넘겨 **즉시** 발화한다(안내한 시간과 정반대).
224
+ const _portArg = (arg ? arg('--port', '') : '');
225
+ let port = LEERNESS_DASHBOARD_PORT;
226
+ if (_portArg && _portArg !== true) {
227
+ const n = parseInt(_portArg, 10);
228
+ if (!Number.isFinite(n) || n < 0 || n > 65535) { failJson(false, 'invalid_port', `--port 는 0~65535 (받음: ${_portArg}) — 0 은 OS 가 빈 포트를 고릅니다`); return; }
229
+ port = n;
230
+ }
231
+ const _toArg = (arg ? arg('--timeout', '') : '');
232
+ const _TIMEOUT_MAX = 86400; // 하루 — setTimeout 상한(2^31-1 ms) 훨씬 아래로 잡아 오버플로 자체를 없앤다
233
+ let timeoutSec = 1800;
234
+ if (_toArg && _toArg !== true) {
235
+ const n = parseInt(_toArg, 10);
236
+ if (!Number.isFinite(n) || n < 5 || n > _TIMEOUT_MAX) { failJson(false, 'invalid_timeout', `--timeout 은 5~${_TIMEOUT_MAX}초 (받음: ${_toArg})`); return; }
237
+ timeoutSec = n;
238
+ }
239
+
240
+ const server = http.createServer((req, res) => {
241
+ const send = (code, type, body) => {
242
+ res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer' });
243
+ res.end(body);
244
+ };
245
+ // 쓰기 메서드는 아예 받지 않는다 — 이 서버에는 상태를 바꾸는 경로가 없다.
246
+ if (req.method !== 'GET' && req.method !== 'HEAD') { send(405, 'text/plain; charset=utf-8', 'method not allowed'); return; }
247
+ // DNS rebinding 방어: 로컬 바인딩이어도 외부 도메인이 127.0.0.1 을 가리키면 브라우저가 요청을 보낼 수 있다.
248
+ // 1.36.90 (codex 29차 #5): 종전엔 (a) Host 부재를 **통과**시켰고(fail-open) (b) `:` 로 잘라
249
+ // `[::1]:5338` 이 `[` 가 되어 허용 목록에 있는데도 거부됐다. 부재는 거부하고, IPv6 대괄호를 먼저 처리한다.
250
+ if (!_hostAllowed(req.headers.host)) { send(403, 'text/plain; charset=utf-8', 'forbidden host'); return; }
251
+ let p;
252
+ try { p = decodeURIComponent(String(req.url || '/').split('?')[0]); }
253
+ catch { send(400, 'text/plain; charset=utf-8', 'bad request'); return; }
254
+ // URL → 파일 매핑이 없다. 경로 목록이 고정이라 경로 탐색 표면 자체가 존재하지 않는다.
255
+ if (p === '/' || p === '/index.html') { send(200, 'text/html; charset=utf-8', renderHtml(snapshot(root, deps))); return; }
256
+ if (p === '/api/state') { send(200, 'application/json; charset=utf-8', JSON.stringify(snapshot(root, deps), null, 2)); return; }
257
+ send(404, 'text/plain; charset=utf-8', 'not found');
258
+ });
259
+
260
+ const _pv = require('./preview-serve');
261
+ _pv._listenWithFallback(server, port, '127.0.0.1', (actual, moved) => {
262
+ const url = `http://127.0.0.1:${actual}/`;
263
+ ok(`leerness 대시보드 실행 중 — ${url}`);
264
+ if (moved) warn(` ⓘ 기본 포트 ${port} 사용 중 → 비어 있는 ${actual} 로 자동 전환`);
265
+ log(` 읽기 전용입니다 — 설정 변경과 게이트 판정은 CLI 가 합니다.`);
266
+ log(` JSON: ${url}api/state · 서버 없이: leerness dashboard --json`);
267
+ log(` ⓘ ${timeoutSec}초 후 자동 종료(Ctrl+C 로 즉시 종료).`);
268
+ const stop = (why) => { try { server.close(); } catch {} log(` ⏹ 대시보드 종료(${why})`); process.exit(0); };
269
+ const t = setTimeout(() => stop('timeout'), timeoutSec * 1000);
270
+ if (t.unref) t.unref();
271
+ process.on('SIGINT', () => stop('SIGINT'));
272
+ process.on('SIGTERM', () => stop('SIGTERM'));
273
+ }, (e, tried) => { failJson(false, 'serve_failed', `대시보드 서버 시작 실패: ${e && e.message} (마지막 시도 포트 ${tried})`); });
274
+ }
275
+
276
+ module.exports = { dashboardCmd, snapshot, renderHtml, _esc, LEERNESS_DASHBOARD_PORT };
@@ -251,4 +251,5 @@ function previewModeCmd(root, val, deps = {}) {
251
251
  if (val === 'project') log(` static-dir: ${next.staticDir || 'public'} · dev-url: ${next.devUrl || 'http://localhost:3000'} (dev 서버는 사용자가 실행)`);
252
252
  }
253
253
 
254
- module.exports = { LEERNESS_PREVIEW_PORT, previewServeCmd, previewModeCmd, loadPreviewConfig, savePreviewConfig, cleanupServeDir, serveDir, modeQuestionLines, buildServeWorkspace };
254
+ // _listenWithFallback 공개(1.36.90): 대시보드가 **같은 런타임**을 쓴다 번째 서버 구현을 만들지 않는다.
255
+ module.exports = { LEERNESS_PREVIEW_PORT, previewServeCmd, previewModeCmd, loadPreviewConfig, savePreviewConfig, cleanupServeDir, serveDir, modeQuestionLines, buildServeWorkspace, _listenWithFallback };
package/lib/routing.js ADDED
@@ -0,0 +1,279 @@
1
+ // lib/routing.js — 작업 난이도별 모델 라우팅 (1.36.89, P-0007 · 사용자 승인 범위 = **제안 + 확인 후 실행**).
2
+ //
3
+ // 사용자 요청: "codex/claude 등의 CLI 설치·사용 가능 여부에 따라 작업 난이도별 AI 모델을 라우팅, 켜고 끌 수 있게".
4
+ //
5
+ // 검토(codex 27차 + 자체)에서 **기각/제한한 것**:
6
+ // · 자유 텍스트로부터의 **조용한 자동 라우팅** — 잘못된 등급이 조용히 선택되면 사용자는 알 수 없고,
7
+ // 비싼 등급이 선택되면 과금이 발생한다. 그래서 기본은 제안이고, 실행은 명시 확인(--dispatch)이 필요하다.
8
+ // · **모델 이름 하드코딩** — 벤더 모델은 몇 달마다 바뀐다. 정책은 추상 역할(architect/commander/coder/reviewer)로만
9
+ // 쓰고, 역할→provider+model 매핑은 사용자 설정(`leerness roles set`)에 맡긴다.
10
+ // · **"설치됨 = 사용 가능"** — `--version` 이 도는 것은 인증·모델 사용권한·쿼터·비대화형 동작·과금 권한을
11
+ // 아무것도 증명하지 않는다. 그래서 상태를 그대로 라벨링하고, 검증하지 않은 것은 검증하지 않았다고 적는다.
12
+ // · **변경 파일 수 기준 라우팅** — 일이 끝나야 알 수 있는 값이라 초기 라우팅 근거가 될 수 없다.
13
+ //
14
+ // 정직성 계약: 여기서 판정하는 것은 **텍스트에 대한 결정적 규칙 매칭**뿐이다. 난이도를 "측정"하지 않는다.
15
+ // 규칙이 겹치거나(오타 수정인데 인증 도메인) 신호가 약하면 그 사실을 그대로 노출하고 보수적으로 normal 이상을 준다.
16
+ 'use strict';
17
+ const path = require('path');
18
+ const { absRoot, exists, read, writeUtf8, mkdirp, log, ok, warn, failJson, now } = require('./io');
19
+
20
+ const TIERS = ['tiny', 'normal', 'high-risk'];
21
+
22
+ // 고위험 도메인 — 결정적 규칙. 여기 걸리면 tiny 로 내려가지 않는다(과대평가는 비용, 과소평가는 사고).
23
+ // 한글은 단어경계(\b)가 동작하지 않으므로 부분일치를 쓰고, 영문만 경계를 건다.
24
+ // 1.36.89 (codex 28차 #5): 종전엔 `\b(auth|permission|concurren|idempoten)\b` 처럼 **뒤쪽 \b** 를 붙여
25
+ // `authentication` · `authorization` · `permissions` · `concurrency` · `idempotency` 가 전부 매치되지 않았다.
26
+ // 그 결과 "fix typo in authentication middleware" 가 **tiny** 로 떨어졌다(실측). 어간 + `\w*` 로 바꾼다.
27
+ // 오탐 가드: `author`(글쓴이)는 매치되면 안 되므로 authentic/authoriz 어간을 따로 적고,
28
+ // `tokenizer`(파서 용어)를 피하려 token 뒤 `iz` 를 제외한다.
29
+ const HIGH_RISK = [
30
+ { id: 'auth', ko: '인증·인가', re: /\b(auth|authn|authz|authentic\w*|authoriz\w*|authoris\w*|login|log in|logout|sign in|session\w*|token(?!iz)\w*|jwt|oauth|sso|permission\w*|privilege\w*|rbac|acl)\b|인증|인가|로그인|세션|토큰|권한/i },
31
+ { id: 'payment', ko: '결제·정산', re: /\b(payment\w*|billing|checkout|invoice\w*|refund\w*|subscription\w*|stripe|paypal)\b|결제|정산|환불|청구|구독/i },
32
+ { id: 'data-migration', ko: '데이터 마이그레이션·스키마', re: /\b(migrat\w*|schema\w*|alter table|drop table|backfill\w*)\b|마이그레이션|스키마|백필/i },
33
+ { id: 'pii', ko: '개인정보', re: /\b(pii|gdpr|personal data|ssn|passport)\b|개인정보|주민등록|여권/i },
34
+ { id: 'secrets', ko: '시크릿·키', re: /\b(secret\w*|credential\w*|api key|private key|password\w*)\b|시크릿|비밀번호|자격증명|비밀키/i },
35
+ { id: 'public-api', ko: '공개 API 계약', re: /\b(public api|breaking change\w*|api contract|versioning)\b|공개 api|하위호환|호환성 깨/i },
36
+ { id: 'concurrency', ko: '동시성·트랜잭션', re: /\b(concurren\w*|transaction\w*|race condition\w*|deadlock\w*|idempoten\w*|mutex|semaphore)\b|동시성|트랜잭션|경쟁 상태|교착|멱등/i },
37
+ { id: 'infra', ko: '인프라·배포', re: /\b(deploy\w*|infra\w*|terraform|kubernetes|k8s|dockerfile|ci\/cd|pipeline\w*)\b|배포|인프라|파이프라인/i },
38
+ { id: 'destructive', ko: '파괴적 작업', re: /\b(delete all|truncate\w*|rm -rf|purge\w*|wipe\w*)\b|전체 삭제|일괄 삭제|초기화/i },
39
+ ];
40
+
41
+ // 초소형 — **범위가 명시적으로 좁을 때만**. 고위험 신호가 하나라도 있으면 적용하지 않는다.
42
+ const TINY = [
43
+ { id: 'typo', ko: '오타·문구', re: /\b(typo\w*|wording|copy fix)\b|오타|문구 수정|띄어쓰기/i },
44
+ { id: 'rename', ko: '이름 변경', re: /\b(rename|변수명|함수명)\b|이름 변경|네이밍/i },
45
+ { id: 'style', ko: '포맷·린트·CSS', re: /\b(lint\w*|format\w*|prettier|eslint|css|padding|margin|색상)\b|포맷|린트|여백|스타일/i },
46
+ { id: 'comment', ko: '주석·문서 문구', re: /\b(comment\w*|docstring\w*)\b|주석/i },
47
+ { id: 'single-test', ko: '테스트 1건', re: /\b(add a test|one test|테스트 한|테스트 1)\b/i },
48
+ ];
49
+
50
+ // 1.36.89 (codex 28차 #4): 초소형 키워드가 **하나라도** 있으면 tiny 로 떨어지던 것 —
51
+ // "Rewrite the entire compiler, replace the storage engine, and also fix one typo" 가 tiny 였다(실측).
52
+ // tiny 는 "범위가 좁다"는 주장이므로, 광범위 신호가 있거나 설명이 길면 그 주장을 인정하지 않는다.
53
+ const BROAD = /\b(entire|whole|all |everything|rewrite|overhaul|refactor all|across the|end.to.end)\b|전체|전면|모든|갈아엎|재작성|통째/i;
54
+ const TINY_MAX_LEN = 80;
55
+
56
+ function _norm(s) { return String(s == null ? '' : s).trim(); }
57
+
58
+ // 사용자에게 반복 노출되는 고지 — **이 모듈이 실제로 한 일만** 말한다.
59
+ // 1.36.89 (codex 28차 #7): 종전 문구는 "설치·활성 여부만 확인했습니다"였는데, 이 모듈은 역할 매핑만 읽고
60
+ // 설치 확인(`--version`)을 **한 번도 하지 않는다**. 하지 않은 확인을 했다고 말하는 것이 바로 이 제품이 잡는 결함이다.
61
+ const _AVAIL_NOTE = '이 명령은 역할 매핑(leerness roles)만 읽습니다 — 해당 CLI 의 설치·인증·모델 사용권한·쿼터·과금 권한은 확인하지 않았습니다(확인: leerness agents check).';
62
+ const _CLASS_NOTE = '난이도는 텍스트 규칙 매칭 결과이며 측정값이 아닙니다.';
63
+ const _EXEC_NOTE = 'leerness 는 여기서 외부 CLI 를 실행하지 않습니다 — 계획을 확정하고 기록할 뿐입니다.';
64
+
65
+ // 난이도 판정 — 순서가 곧 정책이다. (1) 사용자 명시 (2) 고위험 도메인 (3) 초소형 (4) 기본 normal
66
+ function classify(text, opts = {}) {
67
+ const t = _norm(text);
68
+ const declared = _norm(opts.tier).toLowerCase();
69
+ const high = HIGH_RISK.filter(r => r.re.test(t));
70
+ const tiny = TINY.filter(r => r.re.test(t));
71
+ const signals = [
72
+ ...high.map(r => ({ kind: 'high-risk', id: r.id, ko: r.ko })),
73
+ ...tiny.map(r => ({ kind: 'tiny', id: r.id, ko: r.ko })),
74
+ ];
75
+ const reasons = [];
76
+
77
+ // 1.36.89 (codex 28차 #3): 빈 작업 검사가 declared 분기 **뒤에** 있어, `--tier tiny` 만 주면
78
+ // 빈 작업이 통과했다(실측). 어떤 경로로도 빈 작업은 받지 않는다.
79
+ if (!t) return { ok: false, code: 'empty_task', error: '작업 설명이 비어 있습니다 — leerness agents route "<작업 설명>"' };
80
+
81
+ if (declared) {
82
+ if (!TIERS.includes(declared)) return { ok: false, code: 'invalid_tier', error: `--tier 는 ${TIERS.join('|')} (받음: ${declared})` };
83
+ reasons.push(`사용자가 --tier ${declared} 로 명시 — 규칙 판정보다 우선합니다`);
84
+ if (declared !== 'high-risk' && high.length) {
85
+ reasons.push(`⚠ 고위험 신호(${high.map(r => r.ko).join(', ')})가 있는데 ${declared} 로 지정됐습니다 — 의도한 것인지 확인하세요`);
86
+ }
87
+ return { ok: true, tier: declared, confidence: 'declared', signals, reasons, conflict: declared !== 'high-risk' && high.length > 0 };
88
+ }
89
+
90
+ if (high.length) {
91
+ // 고위험 + 초소형 신호 동시 매칭(예: "인증 문서 오타 수정")은 규칙만으로 못 가른다 — 사실을 노출하고 보수적으로 간다.
92
+ const conflict = tiny.length > 0;
93
+ reasons.push(`고위험 도메인 규칙 매칭: ${high.map(r => r.ko).join(', ')}`);
94
+ if (conflict) reasons.push(`동시에 초소형 신호(${tiny.map(r => r.ko).join(', ')})도 있습니다 — 규칙으로는 가릴 수 없어 보수적으로 high-risk 로 둡니다. 사소한 작업이면 --tier tiny 로 낮추세요`);
95
+ return { ok: true, tier: 'high-risk', confidence: conflict ? 'low' : 'high', signals, reasons, conflict };
96
+ }
97
+
98
+ // 신호가 없고 설명이 짧으면 판정 근거가 없는 것이다 — tiny 로 내리지 않고 normal 로 둔다(과소평가 금지).
99
+ if (!tiny.length) {
100
+ const weak = t.length < 12;
101
+ reasons.push(weak
102
+ ? '매칭된 규칙이 없고 설명이 짧습니다 — 판정 근거가 부족해 기본값 normal 로 둡니다'
103
+ : '매칭된 고위험/초소형 규칙 없음 — 기본값 normal');
104
+ return { ok: true, tier: 'normal', confidence: weak ? 'low' : 'medium', signals, reasons, conflict: false };
105
+ }
106
+
107
+ // tiny 는 "범위가 좁다"는 주장이다 — 광범위 신호가 있거나 설명이 길면 그 주장을 인정하지 않는다.
108
+ const broad = BROAD.test(t);
109
+ const longText = t.length > TINY_MAX_LEN;
110
+ if (broad || longText) {
111
+ reasons.push(`초소형 신호(${tiny.map(r => r.ko).join(', ')})가 있지만 ${broad ? '광범위 신호가 함께 있어' : `설명이 ${t.length}자로 길어(기준 ${TINY_MAX_LEN}자)`} 좁은 범위로 볼 수 없습니다 — normal 로 둡니다`);
112
+ return { ok: true, tier: 'normal', confidence: 'low', signals, reasons, conflict: true };
113
+ }
114
+ reasons.push(`초소형 신호 매칭: ${tiny.map(r => r.ko).join(', ')} · 고위험/광범위 신호 없음`);
115
+ return { ok: true, tier: 'tiny', confidence: 'medium', signals, reasons, conflict: false };
116
+ }
117
+
118
+ // 등급별 역할 구성 — **추상 역할 id 만** 쓴다(모델 이름 없음). 매핑은 `leerness roles set` 이 소유한다.
119
+ const TIER_ROLES = {
120
+ tiny: { roles: ['coder'], humanApproval: false, ko: '작업자 1명 + 기존 테스트' },
121
+ normal: { roles: ['commander', 'coder', 'reviewer'], humanApproval: false, ko: '지휘·구현·검수' },
122
+ 'high-risk': { roles: ['architect', 'commander', 'coder', 'reviewer'], humanApproval: true, ko: '설계 선행 · 독립 검수 · 사람 승인' },
123
+ };
124
+
125
+ // 라우팅 계획 — 역할을 사용자 설정으로 해석하고, 해석 못 한 역할과 독립성 결함을 그대로 보고한다.
126
+ function plan(root, cls, deps = {}) {
127
+ // 알 수 없는 등급은 여기서 크래시할 게 아니라 가장 보수적인 구성으로 떨어뜨린다 —
128
+ // 상류 검증이 사라져도 "조용히 느슨해지는" 대신 가장 엄격한 쪽으로 실패하게(fail-closed).
129
+ const spec = TIER_ROLES[cls.tier] || TIER_ROLES['high-risk'];
130
+ const resolve = typeof deps._resolveRole === 'function' ? deps._resolveRole : () => null;
131
+ // 1.36.89 (codex 28차 #6): 종전엔 `resolved: !!r` 라 provider 가 빈 문자열이어도 "해석됨"이었다 —
132
+ // 빈 provider 로 dispatch 가 통과하고 `coder:` 로 표시됐다(실측). 실제로 쓸 수 있는 값인지까지 본다.
133
+ const assignments = spec.roles.map(role => {
134
+ const r = resolve(root, role);
135
+ const provider = r && typeof r.provider === 'string' ? r.provider.trim() : '';
136
+ return { role, provider: provider || null, model: (r && r.model) || null, resolved: !!provider };
137
+ });
138
+ const unresolved = assignments.filter(a => !a.resolved).map(a => a.role);
139
+ const coder = assignments.find(a => a.role === 'coder');
140
+ const reviewer = assignments.find(a => a.role === 'reviewer');
141
+ // 독립 검수: 같은 provider 가 구현과 검수를 겸하면 "검수됨"이라고 말할 수 없다(같은 맹점을 공유한다).
142
+ const reviewerIndependent = !reviewer || !reviewer.resolved || !coder || !coder.resolved
143
+ ? null
144
+ : reviewer.provider !== coder.provider;
145
+ const blockers = [];
146
+ if (unresolved.length) blockers.push({ code: 'role_unresolved', message: `역할 미설정: ${unresolved.join(', ')} — leerness roles set <역할> --provider <id>` });
147
+ if (spec.humanApproval && reviewerIndependent === false) blockers.push({ code: 'reviewer_not_independent', message: `검수자와 구현자가 같은 provider(${coder.provider})입니다 — 고위험 작업에서는 독립 검수라고 볼 수 없습니다` });
148
+ return { tier: cls.tier, ko: spec.ko, humanApproval: spec.humanApproval, assignments, unresolved, reviewerIndependent, blockers };
149
+ }
150
+
151
+ function _logPath(root) { return path.join(absRoot(root), '.harness', 'routing-log.json'); }
152
+ const _LOG_CAP = 200;
153
+
154
+ const _LOG_MAX_BYTES = 4 * 1024 * 1024; // codex 28차 #9: 파싱 전에 크기를 본다 — 거대 파일을 동기로 읽어 CLI 가 멎으면 안 된다
155
+
156
+ function _loadLog(root) {
157
+ const f = _logPath(root);
158
+ if (!exists(f)) return { list: [], invalid: false };
159
+ try {
160
+ const sz = require('fs').statSync(f).size;
161
+ if (sz > _LOG_MAX_BYTES) return { list: [], invalid: true, reason: `파일이 너무 큽니다(${Math.round(sz / 1048576)}MB > 4MB)` };
162
+ const j = JSON.parse(read(f));
163
+ if (!Array.isArray(j)) return { list: [], invalid: true, reason: '최상위가 배열이 아닙니다' };
164
+ return { list: j, invalid: false };
165
+ } catch (e) { return { list: [], invalid: true, reason: 'JSON 파싱 실패' }; }
166
+ }
167
+
168
+ // 감사 기록 — 무엇을 보고 무엇을 골랐는지 남지 않으면 사후에 라우팅을 검증할 수 없다.
169
+ // 1.36.89 (codex 28차 #8): 손상 파일을 `[rec]` 으로 **조용히 덮어썼다**(실측) — 감사 기록을 파괴하는
170
+ // 동작이 감사 기록 기능 안에 있었다. 손상 시에는 쓰지 않고 그 사실을 반환한다(스토어 손상 = 덮어쓰기 거부, 기존 규율).
171
+ function _appendLog(root, rec, deps = {}) {
172
+ const f = _logPath(root);
173
+ const write = () => {
174
+ const cur = _loadLog(root);
175
+ if (cur.invalid) return { written: false, invalid: true, reason: cur.reason || '형상 무효' };
176
+ const list = cur.list.concat([rec]).slice(-_LOG_CAP);
177
+ mkdirp(path.dirname(f));
178
+ const tmp = f + '.tmp' + process.pid;
179
+ writeUtf8(tmp, JSON.stringify(list, null, 2) + '\n');
180
+ require('fs').renameSync(tmp, f);
181
+ return { written: true, invalid: false, total: list.length };
182
+ };
183
+ return (typeof deps._withLock === 'function') ? deps._withLock(f, write) : write();
184
+ }
185
+
186
+ // `leerness agents route "<작업>" [--tier x] [--dispatch] [--approved-by "이름"] [--json]`
187
+ function routeCmd(root, taskText, deps = {}) {
188
+ const { has, arg } = deps;
189
+ root = absRoot(root);
190
+ const json = !!(has && has('--json'));
191
+ if (!exists(path.join(root, '.harness'))) { failJson(json, 'harness_missing', `leerness 미설치: ${root} — 먼저 leerness init`); return; }
192
+
193
+ if (has && has('--log')) return _showLog(root, json);
194
+
195
+ const cls = classify(taskText, { tier: (arg ? arg('--tier', '') : '') || '' });
196
+ if (!cls.ok) { failJson(json, cls.code, cls.error); return; }
197
+ const p = plan(root, cls, deps);
198
+
199
+ const wantDispatch = !!(has && (has('--confirm') || has('--dispatch')));
200
+ const approvedBy = _norm(arg ? arg('--approved-by', '') : '');
201
+ const gateOn = typeof deps._toggleOn === 'function' ? deps._toggleOn(root, 'difficulty-routing') : false;
202
+
203
+ // 실행 거부 사유 — 하나라도 있으면 dispatch 하지 않는다(고위험은 fail-closed).
204
+ const refusals = [];
205
+ if (wantDispatch && !gateOn) refusals.push({ code: 'routing_disabled', message: '난이도 라우팅 토글이 꺼져 있습니다 — leerness toggle set difficulty-routing on' });
206
+ if (wantDispatch && p.blockers.length) p.blockers.forEach(b => refusals.push(b));
207
+ if (wantDispatch && p.humanApproval && !approvedBy) refusals.push({ code: 'human_approval_required', message: '고위험 작업은 사람 승인이 필요합니다 — --approved-by "<승인자>"' });
208
+
209
+ // 1.36.89 (codex 28차 #1): 종전 필드명은 `dispatched` 였는데 이 모듈은 **외부 CLI 를 실행하지 않는다** —
210
+ // 실행했다고 보고하면서 아무것도 실행하지 않는, 이 제품이 가장 경계하는 형태의 거짓말이었다(실측).
211
+ // 실제로 하는 일은 "계획을 확정하고 실행 명령을 내주는 것"이므로 그대로 `confirmed` 라고 부른다.
212
+ const confirmed = wantDispatch && !refusals.length;
213
+ const rec = {
214
+ at: now(),
215
+ task: _norm(taskText).slice(0, 300),
216
+ tier: cls.tier, confidence: cls.confidence, conflict: !!cls.conflict,
217
+ signals: cls.signals.map(s => `${s.kind}:${s.id}`),
218
+ assignments: p.assignments.map(a => ({ role: a.role, provider: a.provider, model: a.model })),
219
+ reviewerIndependent: p.reviewerIndependent,
220
+ requested: wantDispatch ? 'confirm' : 'suggest',
221
+ confirmed, executed: false, approvedBy: approvedBy || null,
222
+ refusals: refusals.map(r => r.code),
223
+ };
224
+ const logged = _appendLog(root, rec, deps) || {};
225
+
226
+ if (json) {
227
+ log(JSON.stringify({
228
+ ok: !wantDispatch || confirmed, tier: cls.tier, confidence: cls.confidence, conflict: !!cls.conflict,
229
+ signals: cls.signals, reasons: cls.reasons, plan: p, gateEnabled: gateOn,
230
+ confirmed, executed: false, refusals,
231
+ auditWritten: logged.written !== false, auditWarning: logged.invalid ? `감사 기록에 남기지 못했습니다(${logged.reason}) — ${_logPath(root)}` : undefined,
232
+ availabilityNote: _AVAIL_NOTE,
233
+ classificationNote: _CLASS_NOTE,
234
+ executionNote: _EXEC_NOTE,
235
+ }, null, 2));
236
+ if (wantDispatch && !confirmed) process.exitCode = 1;
237
+ return { cls, plan: p, confirmed };
238
+ }
239
+
240
+ log(`# leerness agents route — 난이도 ${cls.tier} (신뢰도 ${cls.confidence}${cls.conflict ? ' · 규칙 충돌' : ''})`);
241
+ cls.reasons.forEach(r => log(` · ${r}`));
242
+ log(`\n 구성(${p.ko}):`);
243
+ p.assignments.forEach(a => log(` ${a.role.padEnd(10)} ${a.resolved ? `${a.provider}${a.model ? ' / ' + a.model : ''}` : '⚠ 미설정 — leerness roles set ' + a.role + ' --provider <id>'}`));
244
+ if (p.reviewerIndependent === false) log(` ⚠ 검수자와 구현자가 같은 provider — 독립 검수가 아닙니다`);
245
+ log(`\n ⓘ ${_AVAIL_NOTE}`);
246
+ log(` ⓘ ${_CLASS_NOTE} 틀렸다면 --tier ${TIERS.join('|')} 로 지정하세요.`);
247
+ if (logged.invalid) warn(` 감사 기록에 남기지 못했습니다(${logged.reason}) — ${_logPath(root)} 를 고치거나 삭제하세요`);
248
+ if (!wantDispatch) {
249
+ log(`\n → 이 구성을 확정: leerness agents route "<작업>" --confirm${p.humanApproval ? ' --approved-by "<승인자>"' : ''}${gateOn ? '' : ' (먼저 leerness toggle set difficulty-routing on)'}`);
250
+ } else if (!confirmed) {
251
+ warn(`확정하지 않았습니다:`);
252
+ refusals.forEach(r => log(` - ${r.message}`));
253
+ process.exitCode = 1;
254
+ } else {
255
+ ok(`라우팅 확정 — ${p.assignments.filter(a => a.resolved).map(a => `${a.role}:${a.provider}`).join(' · ')}`);
256
+ log(` ⓘ ${_EXEC_NOTE}`);
257
+ log(` 아래 명령을 직접 실행하세요:`);
258
+ p.assignments.filter(a => a.resolved).forEach(a => log(` leerness agents dispatch "<${a.role} 작업 계약>" --role ${a.role}`));
259
+ log(` ⓘ 기록: ${_logPath(root)} (leerness agents route --log 로 열람)`);
260
+ }
261
+ return { cls, plan: p, confirmed };
262
+ }
263
+
264
+ function _showLog(root, json) {
265
+ const cur = _loadLog(root);
266
+ // codex 28차 #7: 고지가 --log 경로에는 없어서, 기록만 보는 사용자는 "provider/model 이 검증된 값"으로 읽었다.
267
+ if (json) { log(JSON.stringify({ ok: !cur.invalid, invalid: cur.invalid, reason: cur.reason, total: cur.list.length, entries: cur.list.slice(-20), availabilityNote: _AVAIL_NOTE, executionNote: _EXEC_NOTE }, null, 2)); if (cur.invalid) process.exitCode = 1; return; }
268
+ if (cur.invalid) { warn(`routing-log.json 형상 무효(${cur.reason}): ${_logPath(root)}`); process.exitCode = 1; return; }
269
+ if (!cur.list.length) { log('라우팅 기록 없음 — leerness agents route "<작업 설명>"'); return; }
270
+ log(`# leerness agents route --log (최근 ${Math.min(20, cur.list.length)} / 총 ${cur.list.length})`);
271
+ cur.list.slice(-20).forEach(e => {
272
+ const state = e.executed ? '실행' : e.confirmed ? '확정' : (e.requested === 'confirm' || e.requested === 'dispatch') ? '거부' : '제안';
273
+ log(` ${String(e.at).slice(0, 16)} ${String(e.tier).padEnd(9)} ${String(e.confidence).padEnd(8)} ${state} ${String(e.task).slice(0, 46)}`);
274
+ });
275
+ log(`\n ⓘ ${_AVAIL_NOTE}`);
276
+ log(` ⓘ ${_EXEC_NOTE}`);
277
+ }
278
+
279
+ module.exports = { routeCmd, classify, plan, TIERS, TIER_ROLES, HIGH_RISK, TINY, _logPath, _loadLog };
package/lib/toggles.js CHANGED
@@ -16,6 +16,8 @@ const TOGGLE_REGISTRY = {
16
16
  // 1.36.88 (출하전 헌트 #11): 켜기만 하면 뭔가 강제되는 것처럼 읽혔다 — 실제로는 `bugfix start` 로
17
17
  // probe 를 등록한 task 에만 적용된다. 광고 문구가 적용 범위를 말해야 한다.
18
18
  'bugfix-receipt': { desc: 'bugfix 완료 게이트 — `leerness bugfix start` 로 probe 를 등록한 task 에 한해, done 시 probe 통과 + 근본원인/형제범위 영수증 요구 (기본 OFF)', affects: 'task update --status done · task sync --from', defaultOff: true },
19
+ // 1.36.89 (P-0007): 켜도 **자동 실행은 없다** — `route --dispatch` 를 허용할 뿐이고, 고위험은 사람 승인이 별도로 필요하다.
20
+ 'difficulty-routing': { desc: '작업 난이도별 모델 라우팅 — `leerness agents route` 의 --confirm 허용(제안/열람은 토글과 무관). 고위험은 --approved-by 필수 (기본 OFF)', affects: 'leerness agents route --confirm', defaultOff: true },
19
21
  };
20
22
 
21
23
  function _togglesPath(root) { return path.join(absRoot(root), '.harness', 'toggles.json'); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.88",
3
+ "version": "1.36.90",
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
@@ -7707,6 +7707,195 @@ total++;
7707
7707
  if (!ok) failed++;
7708
7708
  }
7709
7709
 
7710
+ // 1.36.89 (P-0007, 사용자 승인 범위=제안+확인후실행): agents route 난이도 라우팅.
7711
+ // 판별 케이스를 함께 넣는다 — 거부 사유(code)까지 단언하지 않으면 여러 거부 조건이 서로를 가려
7712
+ // 하나를 죽여도 다른 하나가 막는다(1.36.88 에서 변이 6/8 로 실측한 함정).
7713
+ total++;
7714
+ {
7715
+ let ok = false;
7716
+ const _d = [];
7717
+ const R = (args, cwd) => { const r = cp.spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', timeout: 120000, cwd }); return { s: r.status, o: ((r.stdout || '') + (r.stderr || '')).trim() }; };
7718
+ const J = (x) => { try { return JSON.parse(x.o.slice(x.o.indexOf('{'))); } catch { return null; } };
7719
+ let C1 = false, C2 = false, C3 = false, C4 = false, D1 = false, D2 = false, D3 = false, D4 = false, D5 = false, H1 = false, H2 = false;
7720
+ try {
7721
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-route-')); _d.push(d);
7722
+ R(['init', d, '--yes', '--language', 'ko', '--no-stale-check']);
7723
+ const cls = (t, extra = []) => J(R(['agents', 'route', t, '--path', d, '--json', ...extra]));
7724
+ // 분류: 고위험 도메인 · 고위험+초소형 충돌은 보수적으로 high-risk · 초소형 · 근거부족은 normal(과소평가 금지)
7725
+ C1 = (cls('결제 환불 로직 수정') || {}).tier === 'high-risk'
7726
+ // codex 28차 #5: 영문 어간(authentication/authorization/permissions/concurrency/idempotency)이
7727
+ // 뒤쪽 \b 때문에 전부 빠져나가 **tiny 로 떨어졌다**(실측). 한글 케이스만 보면 놓친다.
7728
+ && ['fix typo in authentication middleware', 'fix typo in authorization permissions',
7729
+ 'fix typo in concurrency and idempotency control'].every(t => (cls(t) || {}).tier === 'high-risk')
7730
+ // 오탐 대조군: author 는 auth 가 아니고, tokenizer 는 token 이 아니다(과대escalation 방지)
7731
+ && (cls('fix typo in author bio css') || {}).tier === 'tiny';
7732
+ const conf = cls('인증 문서 오타 수정') || {};
7733
+ C2 = conf.tier === 'high-risk' && conf.conflict === true && conf.confidence === 'low';
7734
+ C3 = (cls('버튼 여백 4px 조정') || {}).tier === 'tiny'
7735
+ // codex 28차 #4: tiny 키워드 하나로 거대 작업이 tiny 가 됐다 — 광범위 신호/긴 설명은 tiny 가 아니다
7736
+ && (cls('Rewrite the entire compiler, replace the storage engine, and also fix one typo') || {}).tier === 'normal'
7737
+ && (cls('전체 아키텍처를 갈아엎고 저장 엔진을 교체하고 오타도 수정') || {}).tier === 'normal';
7738
+ const weak = cls('x') || {};
7739
+ C4 = weak.tier === 'normal' && weak.confidence === 'low' // tiny 로 내려가면 안 된다
7740
+ // codex 28차 #3: --tier 를 주면 빈 작업이 통과했다 — 어떤 경로로도 빈 작업은 받지 않는다
7741
+ && /"code":\s*"empty_task"/.test(R(['agents', 'route', '--tier', 'tiny', '--path', d, '--json']).o);
7742
+ // 사용자 명시 우선 + 잘못된 값 거부
7743
+ const dec = cls('결제 로직 오타', ['--tier', 'tiny']) || {};
7744
+ // 잘못된 --tier 는 **사유(code)까지** 단언한다 — exit 1 만 보면 검증을 없앴을 때 나는 크래시(TIER_ROLES 미정의)와
7745
+ // 구분되지 않아, 검증 제거 변이가 그대로 통과했다(실측 M7 생존).
7746
+ const badTier = R(['agents', 'route', 'x', '--tier', 'huge', '--path', d, '--json']);
7747
+ D1 = dec.tier === 'tiny' && dec.confidence === 'declared' && (dec.reasons || []).some(r => /의도한 것인지/.test(r))
7748
+ && badTier.s === 1 && /"code":\s*"invalid_tier"/.test(badTier.o);
7749
+ // 토글 OFF → dispatch 거부 (사유까지)
7750
+ const off = R(['agents', 'route', '주문 필터 추가', '--confirm', '--path', d, '--json']);
7751
+ D2 = off.s === 1 && /"routing_disabled"/.test(off.o);
7752
+ // 토글 ON + 역할 미설정 → 거부 (판별: 토글 거부와 다른 사유여야)
7753
+ R(['toggle', 'set', 'difficulty-routing', 'on', '--path', d]);
7754
+ const nr = R(['agents', 'route', '주문 필터 추가', '--confirm', '--path', d, '--json']);
7755
+ D3 = nr.s === 1 && /"role_unresolved"/.test(nr.o) && !/"routing_disabled"/.test(nr.o);
7756
+ // 역할 설정 후 normal 은 실행 확정
7757
+ R(['roles', 'set', 'commander', '--provider', 'claude', '--path', d]);
7758
+ R(['roles', 'set', 'coder', '--provider', 'codex', '--path', d]);
7759
+ R(['roles', 'set', 'reviewer', '--provider', 'claude', '--path', d]);
7760
+ const okd = J(R(['agents', 'route', '주문 필터 추가', '--confirm', '--path', d, '--json'])) || {};
7761
+ D4 = okd.confirmed === true && okd.plan && okd.plan.reviewerIndependent === true;
7762
+ // 고위험: 승인자 없으면 fail-closed (판별: 역할은 다 설정된 상태여야 role_unresolved 가 아님이 증명된다)
7763
+ R(['roles', 'set', 'architect', '--provider', 'codex', '--path', d]);
7764
+ const hr = R(['agents', 'route', '결제 환불 로직 수정', '--confirm', '--path', d, '--json']);
7765
+ H1 = hr.s === 1 && /"human_approval_required"/.test(hr.o) && !/"role_unresolved"/.test(hr.o);
7766
+ const hr2 = J(R(['agents', 'route', '결제 환불 로직 수정', '--confirm', '--approved-by', '승인자', '--path', d, '--json'])) || {};
7767
+ D5 = hr2.confirmed === true;
7768
+ // 독립성: 구현자와 검수자가 같은 provider 면 고위험은 거부 (승인자가 있는데도 막혀야 한다 = 판별)
7769
+ R(['roles', 'set', 'reviewer', '--provider', 'codex', '--path', d]);
7770
+ const dep = R(['agents', 'route', '결제 환불 로직 수정', '--confirm', '--approved-by', '승인자', '--path', d, '--json']);
7771
+ H2 = dep.s === 1 && /"reviewer_not_independent"/.test(dep.o) && !/"human_approval_required"/.test(dep.o);
7772
+ // codex 28차 #6: 손으로 편집한 역할 설정의 **빈 provider** 가 "해석됨"으로 통과했다 —
7773
+ // `roles set` 은 빈 값을 안 받으므로 이 상태는 파일 편집으로만 생긴다. 그래서 파일을 직접 만들어 검증한다.
7774
+ const rolesFile = path.join(d, '.harness', 'agent-roles.json');
7775
+ const savedRoles = fs.existsSync(rolesFile) ? fs.readFileSync(rolesFile, 'utf8') : null;
7776
+ // 스키마는 `{roles:{...}}` 다 — 최상위에 쓰면 _loadRoles 가 {} 를 돌려줘 "역할 없음"으로 통과하고,
7777
+ // 빈 provider 를 검증한 것이 아니라 **셋업 실패를 통과로 읽는다**(실측으로 잡았다).
7778
+ fs.writeFileSync(rolesFile, JSON.stringify({ schemaVersion: 1, roles: { coder: { provider: ' ' } } }));
7779
+ const emptyProv = R(['agents', 'route', '버튼 여백 4px 조정', '--confirm', '--path', d, '--json']);
7780
+ const emptyProvJ = J(emptyProv) || {};
7781
+ // 셋업이 유효했는지 함께 단언: coder 가 실제로 목록에 있어야 한다(없으면 다른 이유로 막힌 것)
7782
+ const emptyProvOk = emptyProv.s === 1 && /"role_unresolved"/.test(emptyProv.o)
7783
+ && ((emptyProvJ.plan || {}).assignments || []).some(a => a.role === 'coder' && a.resolved === false);
7784
+ if (savedRoles !== null) fs.writeFileSync(rolesFile, savedRoles); else fs.rmSync(rolesFile, { force: true });
7785
+ // 감사 기록 — codex 28차 #10: 종전엔 `total >= 8` 만 봐서 **전 항목이 {} 여도 통과**했다. 내용을 본다.
7786
+ const lg = J(R(['agents', 'route', '--log', '--path', d, '--json'])) || {};
7787
+ const ents = lg.entries || [];
7788
+ const auditOk = (lg.total || 0) >= 8
7789
+ && ents.some(e => e.tier === 'high-risk' && e.confirmed === true && e.approvedBy === '승인자'
7790
+ && Array.isArray(e.assignments) && e.assignments.some(a => a.role === 'coder' && a.provider)
7791
+ && Array.isArray(e.signals) && e.signals.some(s => /^high-risk:/.test(s)))
7792
+ && ents.some(e => e.confirmed === false && Array.isArray(e.refusals) && e.refusals.length > 0); // 거부된 시도도 남는다
7793
+ // 손상 감사로그를 조용히 덮어쓰지 않는다(codex 28차 #8)
7794
+ const lp = path.join(d, '.harness', 'routing-log.json');
7795
+ const savedLog = fs.readFileSync(lp, 'utf8');
7796
+ fs.writeFileSync(lp, '{"소중한":"기록"}');
7797
+ R(['agents', 'route', '무해한 조회', '--path', d]);
7798
+ const auditPreserved = fs.readFileSync(lp, 'utf8') === '{"소중한":"기록"}';
7799
+ fs.writeFileSync(lp, savedLog);
7800
+ // 정직성: 하지 않은 확인을 했다고 말하지 않고, 실행하지 않는다는 사실을 사람용·JSON·--log 모두에서 말한다
7801
+ const human = R(['agents', 'route', '주문 필터', '--path', d]).o;
7802
+ const logHuman = R(['agents', 'route', '--log', '--path', d]).o;
7803
+ const cj = J(R(['agents', 'route', '주문 필터 추가', '--confirm', '--path', d, '--json'])) || {};
7804
+ const honestOk = /확인하지 않았습니다/.test(human) && /측정값이 아닙니다/.test(human)
7805
+ && !/설치·활성 여부만 확인/.test(human) // 하지 않은 확인을 했다고 말하던 옛 문구
7806
+ && /확인하지 않았습니다/.test(logHuman) && /외부 CLI 를 실행하지 않습니다/.test(logHuman)
7807
+ && cj.executed === false && typeof cj.confirmed === 'boolean' && !('dispatched' in cj)
7808
+ && /외부 CLI 를 실행하지 않습니다/.test(JSON.stringify(cj));
7809
+ // 모델명 비하드코딩 — 정책 모듈에 벤더 모델 id 가 있으면 안 된다(몇 달이면 낡는다)
7810
+ // 경로는 CLI 기준으로 잡는다 — 검사 대상이 "지금 실행 중인 그 설치본"이어야 한다(__dirname 은 실행 위치에 따라 어긋난다).
7811
+ const srcOk = !/(claude-(opus|sonnet|haiku)|gpt-[45]|o[34]-mini|gemini-)/i.test(fs.readFileSync(path.join(path.dirname(CLI), '..', 'lib', 'routing.js'), 'utf8'));
7812
+ ok = C1 && C2 && C3 && C4 && D1 && D2 && D3 && D4 && D5 && H1 && H2 && auditOk && auditPreserved && honestOk && srcOk && emptyProvOk;
7813
+ if (!ok) console.log(` [route 디버그] C1=${C1} C2=${C2} C3=${C3} C4=${C4} D1=${D1} OFF거부=${D2} 역할미설정거부=${D3} normal실행=${D4} 고위험실행=${D5} 승인필요=${H1} 독립성=${H2} 감사=${auditOk} 손상보존=${auditPreserved} 정직=${honestOk} 소스=${srcOk} 빈provider=${emptyProvOk} log=${lg.total}`);
7814
+ } catch (e) { console.log(' [route 디버그] 예외: ' + ((e && e.message) || e)); }
7815
+ finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
7816
+ console.log(ok ? '✓ B(1.36.89) agents route 난이도 라우팅: 결정적 분류(충돌시 보수적·근거부족시 normal) · 토글OFF/역할미설정/사람승인/검수독립성 4중 fail-closed · 감사기록 · 모델명 비하드코딩 · 미검증 고지' : '✗ agents route 라우팅 실패');
7817
+ if (!ok) failed++;
7818
+ }
7819
+
7820
+ // 1.36.90 (P-0006, 사용자 승인 범위=대시보드만): 읽기 전용 대시보드 + **넘지 않기로 한 경계**를 가드로 못 박는다.
7821
+ // 경계가 문서에만 있으면 다음 라운드에 조용히 넘는다 — .harness 실행코드 0 · 루트 실행기 미생성 ·
7822
+ // 게이트 미판정 · 정적 leerness.html 존속을 테스트가 강제한다.
7823
+ total++;
7824
+ {
7825
+ let ok = false;
7826
+ const _d = [];
7827
+ const R = (args, cwd) => { const r = cp.spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', timeout: 120000, cwd }); return { s: r.status, o: ((r.stdout || '') + (r.stderr || '')).trim() }; };
7828
+ let S1 = false, S2 = false, S3 = false, X1 = false, B1 = false, B2 = false, B3 = false, B4 = false, R1 = false, R2 = false, R3 = false, P1 = false;
7829
+ try {
7830
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-dash-')); _d.push(d);
7831
+ R(['init', d, '--yes', '--language', 'ko', '--no-stale-check']);
7832
+ R(['task', 'add', '<script>alert(1)</script> XSS 픽스처', '--path', d, '--json', '--no-review']);
7833
+ // S1: --json 스냅샷이 서버 없이 같은 데이터를 낸다(테스트·CI 가 서버를 띄우지 않아도 되게)
7834
+ let snap = null; try { const o = R(['dashboard', '--path', d, '--json']).o; snap = JSON.parse(o.slice(o.indexOf('{'))); } catch {}
7835
+ S1 = !!snap && snap.ok === true && snap.readOnly === true && Array.isArray(snap.toggles) && snap.toggles.length > 0
7836
+ && snap.tasks && typeof snap.tasks.total === 'number' && snap.tasks.total > 0;
7837
+ // S2: 기본 OFF 토글이 OFF 로 보인다(대시보드가 상태를 왜곡하지 않는다)
7838
+ S2 = !!snap && snap.toggles.some(t => t.defaultOff === true && t.on === false);
7839
+ // S3: 게이트를 판정하지 않는다는 것을 화면이 말한다 + 정적 뷰가 대체되지 않았다고 말한다
7840
+ S3 = !!snap && /게이트를 실행하거나 통과시키지 않/.test(snap.gateNote || '') && /graph --html/.test(snap.staticNote || '');
7841
+ // X1: HTML 이스케이프 — task 제목이 그대로 실행되면 안 된다(사람이 여는 화면이다).
7842
+ // 판정은 **원시 태그 존재**로 한다 — `onerror=alert(1)` 문자열은 이스케이프된 텍스트에도 남아 지표가 못 된다(실측).
7843
+ const html = require(path.join(path.dirname(CLI), '..', 'lib', 'dashboard.js')).renderHtml(snap);
7844
+ X1 = !/<script>alert\(1\)<\/script>/.test(html) && !/<img\s/i.test(html) && /&lt;script&gt;/.test(html);
7845
+ // B1~B3: **승인 시 기록한 경계** — 넘으면 실패.
7846
+ // codex 29차 #4: 종전엔 `.harness` 최상위만 봐서 `.harness/dashboard/server.js` 가 통과했고,
7847
+ // `leerness.*` 정확 일치만 봐서 `start-leerness.js` 가 통과했으며, init 이 이미 만든 leerness.html 을
7848
+ // 확인해 `graph --html` 이 깨져도 통과했다. 셋 다 좁혔다.
7849
+ const EXEC_EXT = /\.(js|mjs|cjs|ts|py|sh|bash|ps1|bat|cmd|exe)$/i;
7850
+ const walk = (dir, acc = []) => {
7851
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
7852
+ const p = path.join(dir, e.name);
7853
+ if (e.isDirectory()) walk(p, acc); else if (e.isFile()) acc.push(p);
7854
+ }
7855
+ return acc;
7856
+ };
7857
+ B1 = walk(path.join(d, '.harness')).filter(f => EXEC_EXT.test(f)).length === 0; // .harness 실행코드 0 (재귀)
7858
+ B2 = !fs.readdirSync(d).some(f => /leerness/i.test(f) && EXEC_EXT.test(f)); // 루트 실행기 미생성 (이름 변형 포함)
7859
+ const gp = path.join(d, 'leerness.html');
7860
+ try { fs.rmSync(gp, { force: true }); } catch {}
7861
+ const gr = R(['graph', '--html', '--path', d]);
7862
+ B3 = gr.s === 0 && fs.existsSync(gp) && fs.statSync(gp).size > 1000; // 정적 단일파일 뷰 존속(실제 재생성)
7863
+ // B4: **읽기 전용의 정확한 범위** — 하네스 상태 파일은 대시보드 실행으로 바뀌지 않는다.
7864
+ // (CLI 는 모든 명령에서 .harness/cache/usage-stats.json 을 갱신한다 — 그건 제외하고 단언해야
7865
+ // "아무것도 안 쓴다"는 과장 대신 실제로 참인 불변식을 지킨다. 화면 문구도 그렇게 적었다.)
7866
+ const stateSig = () => fs.readdirSync(path.join(d, '.harness'), { withFileTypes: true })
7867
+ .filter(e => e.isFile()).map(e => { const p = path.join(d, '.harness', e.name); const st = fs.statSync(p); return `${e.name}:${st.size}:${Math.round(st.mtimeMs)}`; }).sort().join('|');
7868
+ const sigBefore = stateSig();
7869
+ R(['dashboard', '--path', d, '--json']);
7870
+ B4 = stateSig() === sigBefore;
7871
+ // R1~R3 (codex 29차 #1/#2/#3): 형상 이상 스토어로 죽지 않고 · 시크릿을 내보내지 않고 · 게이트와 같은 술어를 쓴다
7872
+ fs.writeFileSync(path.join(d, '.harness', 'previews.json'), '[null]');
7873
+ const crash = R(['dashboard', '--path', d, '--json']);
7874
+ fs.writeFileSync(path.join(d, '.harness', 'previews.json'), '{}');
7875
+ const nonArr = R(['dashboard', '--path', d, '--json']);
7876
+ let nj = null; try { nj = JSON.parse(nonArr.o.slice(nonArr.o.indexOf('{'))); } catch {}
7877
+ R1 = crash.s === 0 && !/TypeError|Cannot read/.test(crash.o)
7878
+ && nonArr.s === 0 && !!nj && (nj.notes || []).some(n => /previews\.json/.test(n)); // 조용히 "비어 있음"으로 보이면 안 된다
7879
+ fs.writeFileSync(path.join(d, '.harness', 'previews.json'), '[]');
7880
+ fs.writeFileSync(path.join(d, '.harness', 'bugfix-receipts.json'), JSON.stringify([
7881
+ { id: 'T-0777', repro: 'API_TOKEN=sk-live-LEAKME npm test', expectBad: 'x', baseline: { failed: true, exit: 1 }, rootCause: 'rc', siblingScope: { checked: [] } },
7882
+ ]));
7883
+ const leak = R(['dashboard', '--path', d, '--json']);
7884
+ let lj = null; try { lj = JSON.parse(leak.o.slice(leak.o.indexOf('{'))); } catch {}
7885
+ R2 = !!lj && !/sk-live-LEAKME/.test(leak.o) && /API_TOKEN=\*\*\*/.test(JSON.stringify(lj));
7886
+ // 빈 checked 는 게이트가 "미완"으로 보므로 대시보드도 미기록이어야 한다(표면마다 다른 판정 금지)
7887
+ R3 = !!lj && Array.isArray(lj.bugfix) && lj.bugfix.length === 1 && lj.bugfix[0].siblingScope === false;
7888
+ fs.rmSync(path.join(d, '.harness', 'bugfix-receipts.json'), { force: true });
7889
+ // 범위 밖 포트/타임아웃은 크래시가 아니라 명확한 거부(codex 29차 #6)
7890
+ P1 = R(['dashboard', '--path', d, '--port', '99999']).s === 1 && R(['dashboard', '--path', d, '--timeout', '2147484']).s === 1;
7891
+ ok = S1 && S2 && S3 && X1 && B1 && B2 && B3 && B4 && R1 && R2 && R3 && P1;
7892
+ if (!ok) console.log(` [dashboard 디버그] 스냅샷=${S1} 토글상태=${S2} 고지=${S3} XSS=${X1} harness실행코드0=${B1} 루트실행기없음=${B2} 정적뷰존속=${B3} 상태무변경=${B4} 형상내성=${R1} 시크릿마스킹=${R2} 술어일치=${R3} 범위검증=${P1}`);
7893
+ } catch (e) { console.log(' [dashboard 디버그] 예외: ' + ((e && e.message) || e)); }
7894
+ finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
7895
+ console.log(ok ? '✓ B(1.36.90) dashboard 읽기전용: 스냅샷/토글상태 · 게이트 미판정·정적뷰 존속 고지 · XSS 이스케이프 · 경계 3종(재귀 .harness 실행코드0 · 루트 실행기 미생성 · leerness.html 실제 재생성) · 상태 무변경 · 형상내성/시크릿마스킹/게이트와 동일 술어 · 포트·타임아웃 범위검증' : '✗ dashboard 실패');
7896
+ if (!ok) failed++;
7897
+ }
7898
+
7710
7899
  // 1.36.76 (9차 헌트 이월 3건 종결): 손상 아카이브 복원 거부 · MCP read-only 무변형 · 긴 파생 id 안전화
7711
7900
  total++;
7712
7901
  {