leerness 1.25.0 → 1.27.0

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,82 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.27.0 — 2026-06-15 — 🛡️ [안정화/Stable] 보안 수정 안정 minor (개인키 스캔 FN + placeholder FP)
4
+
5
+ **🛡️ 안정화(Stable) minor — 13번째 외부리뷰에서 확인된 보안 수정을 조기 npm 공개.** 직전 minor(1.26.0) 이후 1.26.1 패치 1건이지만, **보안 FN/FP(거짓 "보안 OK" + CI 파손)는 패치 누적을 기다리기보다 조기 공개가 합리적**이라 단독 minor 로 게시. R-0011 정책의 18번째 stable minor.
6
+
7
+ ### 이번 minor 통합 (1.26.1)
8
+ - **🔒 개인키 파일 스캔 FN 차단**: `scan secrets` 가 `.pem`/`.key`/`.crt`/`.p8`/`.pfx` 등을 확장자 allow-list 누락으로 건너뛰어 **커밋된 개인키 미탐 + handoff 가 "보안 OK" 거짓보증**하던 문제 수정(basename 오버라이드). gitignore 된 키는 종전대로 info.
9
+ - **🔒 DB placeholder 오탐(FP) 차단**: `.env.example` 의 `user:password@`·`root:root` 등 교과서 placeholder 를 커밋 시크릿으로 오탐해 `gate`/CI 를 깨뜨리던 문제 수정(valueGroup + placeholder 마커). 진짜 고엔트로피 비밀번호는 계속 탐지(FN=0).
10
+ - **🔧 retro --json NaN 계약**: 비숫자 `--days` 가 plain text 를 `--json` 소비자에게 흘리던 문제 → 숫자 가드 + 클램프(failJson 구조화).
11
+
12
+ ### 잔여 (외부리뷰 백로그)
13
+ - init `--language en` seed 데이터 i18n(= 전체 `.harness/` 템플릿 i18n 필요, 대형) · verify-claim `--test-cmd` no-parse 하드닝(FP 회귀 위험으로 신중) · audit 미초기화 출력 정합(cosmetic) · 진단명령 영어화 Phase 10.
14
+
15
+ ### 검증 (회귀 0)
16
+ - **selftest 246/246** · **E2E 367/367** (개인키 스캔 FN차단 + placeholder FP차단/FN유지 + retro --json 행위 회귀가드 포함) · 게시본 클린룸 재실증.
17
+ - minor(1.27.0) — npm 배포(R-0011 stable) + annotated tag(Stable) + GitHub release(latest).
18
+
19
+ ## 1.26.1 — 2026-06-15 — 13번째 외부리뷰 P2 수정: 개인키파일 스캔 FN + DB placeholder FP + retro --json NaN
20
+
21
+ **🔎 13번째 외부 멀티모델 리뷰(1.26.0 게시본)에서 확인된 P2 3건 수정.** 3 에이전트 클린룸 리뷰 → 맹신 X 양방향 직접 재현으로 진짜만 채택 → 보안 2건 + --json 계약 1건 수정.
22
+
23
+ ### 변경 (확인된 P2 3건)
24
+ - **🔒 개인키 파일 스캔 FN 차단 (보안)**: `scan secrets` 가 `.pem`/`.key`/`.crt`/`.p8`/`.pfx` 등 개인키·인증서 확장자를 스캔 allow-list 누락으로 건너뛰어 **커밋된 개인키를 미탐 + handoff 가 "보안 OK" 거짓보증**하던 문제 → basename 오버라이드(env-family 패턴 미러)로 강제 스캔. (gitignore 된 키는 종전대로 info 강등.)
25
+ - **🔒 DB placeholder 오탐(FP) 차단 (보안/CI)**: `.env.example` 의 `postgres://user:password@`·`root:root`·`yourpassword` 같은 교과서 placeholder 가 커밋 시크릿으로 오탐돼 `gate`/CI 를 깨뜨리던 문제 → DB URI 정규식에 비밀번호 capture group(`valueGroup`) 추가 + placeholder 마커(`root`/`admin`/`user`/`yourpassword` 등 전체-값 정확 일치)로 차단. **진짜 고엔트로피 비밀번호는 계속 탐지(FN=0)**.
26
+ - **🔧 retro --json NaN 크래시 (계약)**: `retro --days <비숫자>` 가 `new Date(Invalid)` throw 로 `--json` 소비자에게 plain text(`✗ Invalid time value`)를 흘리던 문제 → 숫자 가드 + 음수/오버플로 클램프(`failJson` 구조화, insights/round-history 와 일관).
27
+
28
+ ### 검증 (회귀 0)
29
+ - **selftest 245→246** (소스가드) · 행위(맹신 X 양방향: 개인키 .key 탐지 + .crt 무오탐, placeholder 스킵 + 실비번 탐지, retro --json 구조화 + 정상동작) · **E2E 367/367** (신규 행위 회귀가드 1건).
30
+ - patch(1.26.1) — npm 미배포(R-0011, GitHub/CHANGELOG 누적). 잔여 리뷰 발견(audit 미초기화 출력 정합 P2, verify-claim --test-cmd no-parse 하드닝 P3, init en seed 데이터 i18n, 진단명령 영어화 Phase 10)은 백로그.
31
+
32
+ ## 1.26.0 — 2026-06-15 — 🛡️ [안정화/Stable] i18n 행위가드 + health 진단 영어화 안정 minor
33
+
34
+ **🛡️ 안정화(Stable) minor — i18n 레이어 견고성 검증·가드 + health 진단 영어화를 npm 공개.** 직전 minor(1.25.0) 이후 누적된 패치 2건(1.25.1 + 1.25.2)을 검증·통합해 배포. R-0011 정책의 17번째 stable minor. 한국어 우선 기본은 그대로.
35
+
36
+ ### 이번 minor 통합 (1.25.1~1.25.2)
37
+ - **🔬 22번째 버그헌트(i18n 레이어) + 행위 e2e 회귀가드 (1.25.1)**: 8 phase 영어화 레이어를 통째 적대 검증 → 런타임 버그 0(맹신 X 양방향). uiLang 크래시안전·`--language` positional 무누출·`--language=en` 문법·`--json` 유효·flag>manifest 우선순위 확인. 소스가드만 있던 공백(1.23.0 과장 통과 원인)을 **행위 e2e 가드**로 보강.
38
+ - **🌐 health 진단 완전 영어화 (1.25.2)**: 고빈도 진단 `health` 를 렌더 라벨 + 능력 매트릭스 evidence(16종) + issues + summary 까지 완전 영어화(반쪽 번역 회피). `--language en` opt-in.
39
+ - **한국어 우선 기본 보존**: 영어는 명시 opt-in. 한국어 출력/매트릭스는 한 글자도 안 바뀜(e2e 무회귀).
40
+
41
+ ### 잔여 (UR-0010 Phase 10+, 백로그)
42
+ - capabilities/commands/drift check/install-safety/constraints/doctor + 메모리 CRUD 빈상태 + handoff 본문 — en-leak 우선순위순.
43
+
44
+ ### 검증 (회귀 0)
45
+ - **selftest 245/245** · **E2E 366/366** (i18n 행위가드: lens/health en 한글 0 + ko 기본 보존 + positional 무누출 포함).
46
+ - minor(1.26.0) — npm 배포(R-0011 stable) + annotated tag(Stable) + GitHub release(latest) + 게시본 클린룸 재실증.
47
+
48
+ ## 1.25.2 — 2026-06-15 — CLI 영어화 Phase 9: health 진단 완전 영어화 (UR-0010)
49
+
50
+ **🌐 health 진단을 통째로 영어로.** en-leak 스캔 우선순위에서 고빈도 진단인 `health` 를 — 렌더 라벨만이 아니라 **능력 매트릭스 evidence·issues·요약까지 완전히** — 영어화. 반쪽 번역(1.23.0 과장)의 재발을 피하려 한 모듈(lib/health.js)의 모든 한국어를 한 번에 처리.
51
+
52
+ ### 변경 (UR-0010 Phase 9)
53
+ - **health 완전 영어화 (lib/health.js, DI uiLang 주입)**: 렌더 라벨(`## 보안`→`## Security`, skills/usage/tasks, `6능력 매트릭스`→`6-capability matrix`, `자동 회복`→`auto-recover`) + **능력 매트릭스 evidence 16종**(웹/PC/멀티/REPL/MCP/LSP 각 점수대) + summary + 보안 issues 4종 + path-not-found 에러. `t(ko,en)` 분기, ko 인자 verbatim.
54
+ - **bin DI**: `healthCmd` 호출에 `uiLang: _uiLang(root)` 주입(session-close/health 동일 패턴).
55
+ - **한국어 기본 유지**: 영어는 명시 opt-in. `health`/`health --json` 기본은 한국어 그대로(e2e 무회귀). `--json` 값도 언어 따름(en 시 영어 evidence).
56
+
57
+ ### 잔여 (UR-0010 Phase 10+, 백로그)
58
+ - capabilities(24)/commands(90)/drift check/install-safety/constraints/doctor + 메모리 CRUD 빈상태 메시지 + handoff 본문 — en-leak 우선순위순.
59
+
60
+ ### 검증 (회귀 0)
61
+ - **selftest 244→245** (health 영어/한국어 보존 + uiLang 주입 소스가드) · 행위(health `--language en` 한글 0 / ko 16줄 보존 / en --json 유효) · **E2E 366/366** (i18n 행위가드에 health en/ko 추가).
62
+ - patch(1.25.2) — npm 미배포(R-0011, GitHub/CHANGELOG 누적).
63
+
64
+ ## 1.25.1 — 2026-06-15 — 22번째 버그헌트(i18n 레이어 검증) + i18n 행위 e2e 회귀가드 (UR-0010)
65
+
66
+ **🔬 8 phase 영어화 레이어를 통째로 적대 검증.** uiLang/`_tx`/`_t` 머신을 cross-cutting 레이어로 한 번에 점검 — **런타임 버그 0** (맹신 X 양방향 확인). 다만 그동안 i18n 은 *소스가드(문자열 존재)* 만 있고 *행위 e2e* 가 없었는데, 이 공백이 1.23.0 "완전 영어" 과장(런타임 누출)을 통과시킨 근본 원인이었음 → 행위 회귀가드로 보강.
67
+
68
+ ### 검증 결과 (런타임 버그 없음, 레이어 견고 확인)
69
+ - `_uiLang` 크래시 안전(try/catch + String 가드, malformed manifest/빈 값 → ko 폴백).
70
+ - arg 상호작용: `--language en` 값이 positional 로 누출 안 됨(앞/뒤 위치 모두), `task add "텍스트" --language en` 보존.
71
+ - `--language=en` equals 문법 동작, `--json` 출력 en 에서도 유효 JSON, flag > env > manifest 우선순위(en 프로젝트에서 `--language ko` 가 ko 강제).
72
+
73
+ ### 변경 (defense-in-depth)
74
+ - **i18n 행위 e2e 회귀가드 1건 추가** (e2e 365→366): ① ko 프로젝트 기본 lens 한글 보존 ② `--language en` 런타임 영어 렌더 + 한글 0 ③ `--language` positional 무누출 ④ status path-not-found 에러 en/ko 분기. 소스가드가 못 잡는 *런타임 누출* 을 행위로 차단.
75
+
76
+ ### 검증 (회귀 0)
77
+ - **selftest 244/244** · **E2E 366/366** (신규 i18n 행위가드 포함, ko 기본 무회귀).
78
+ - patch(1.25.1) — npm 미배포(R-0011, GitHub/CHANGELOG 누적).
79
+
3
80
  ## 1.25.0 — 2026-06-15 — 🛡️ [안정화/Stable] 마감 본문 정직성 + lens 플래그십 영어화 안정 minor
4
81
 
5
82
  **🛡️ 안정화(Stable) minor — 자가 검증으로 잡은 정직성 수정 + 품질 렌즈 영어화를 npm 공개.** 직전 minor(1.24.0) 이후 누적된 패치 2건(1.24.1 + 1.24.2)을 검증·통합해 배포. R-0011 정책의 16번째 stable minor. 한국어 우선 기본은 그대로.
package/README.md CHANGED
@@ -104,7 +104,7 @@ MIT
104
104
  <!-- leerness:project-readme:start -->
105
105
  ## Leerness Project Harness
106
106
 
107
- 이 프로젝트는 Leerness v1.25.0 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
107
+ 이 프로젝트는 Leerness v1.27.0 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
108
108
 
109
109
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
110
110
 
@@ -158,7 +158,7 @@ leerness memory restore decision <date|title>
158
158
 
159
159
  ### MCP server (외부 AI 통합)
160
160
 
161
- Leerness v1.25.0는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **85개 도구**를 노출:
161
+ Leerness v1.27.0는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **85개 도구**를 노출:
162
162
 
163
163
  ```jsonc
164
164
  // 카테고리별
@@ -179,7 +179,7 @@ Leerness v1.25.0는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code
179
179
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
180
180
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) stress-v* 신규 작성 + 누적 회귀 → 4) e2e 219/219 → 5) npm pack + git tag + GitHub release → 6) main 자동 push (1.9.140+) → 7) session close → 8) 다음 라운드 예약.
181
181
 
182
- 현재 누적: **70 라운드 (1.9.40 → 1.25.0)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
182
+ 현재 누적: **70 라운드 (1.9.40 → 1.27.0)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
183
183
 
184
184
  ### 성능 가이드 (1.9.140 측정)
185
185
 
@@ -217,6 +217,6 @@ leerness release pack --close --auto-main-push
217
217
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
218
218
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
219
219
 
220
- Last synced by Leerness v1.25.0: 2026-06-15
220
+ Last synced by Leerness v1.27.0: 2026-06-15
221
221
  <!-- leerness:project-readme:end -->
222
222
 
package/bin/leerness.js CHANGED
@@ -32,7 +32,7 @@ const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInG
32
32
  // 1.9.295 (UR-0025 4단계): 정적 데이터 카탈로그 모듈 분리 (비파괴, require-based).
33
33
  const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _TOOL_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, SKILL_CATALOG_PRESETS } = require('../lib/catalogs'); // 1.9.344/368/369 (UR-0025): catalog 분리 · 1.11.4 (UR-0007): _TOOL_CATALOG
34
34
 
35
- const VERSION = '1.25.0';
35
+ const VERSION = '1.27.0';
36
36
 
37
37
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
38
38
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -3791,6 +3791,14 @@ function _selfTestCases() {
3791
3791
  const koPreserved = bin.includes('완료 ${done}/${total}') && bin.includes('roadmap.html 자동 갱신 (${trigger})') && sc.includes("t('- 없음', '- none')"); // ko 인자 보존
3792
3792
  return rowsEn && retroEn && roadmapEn && koPreserved;
3793
3793
  } },
3794
+ { name: 'CLI 영어화 Phase 9 (1.25.2, UR-0010): health 진단 영어/한국어 보존 + uiLang 주입 (소스 가드)', run: () => {
3795
+ const bin = read(__filename);
3796
+ const h = read(path.join(path.dirname(__filename), '..', 'lib', 'health.js'));
3797
+ const injected = bin.includes('uiLang: _uiLang(root), harnessPath: __filename, listAllSkills');
3798
+ const en = h.includes('## Security') && h.includes('command calls:') && h.includes('6-capability matrix') && h.includes('web bridge present, playwright not installed');
3799
+ const koPreserved = h.includes('## 보안') && h.includes('명령 호출:') && h.includes('6능력 매트릭스') && h.includes('playwright 미설치'); // ko 인자 보존(e2e ko)
3800
+ return injected && en && koPreserved;
3801
+ } },
3794
3802
  { name: 'CLI 영어화 Phase 8 (1.24.2, UR-0010): lens 영어 병렬필드 + 렌더 영어/한국어 보존 (소스 가드)', run: () => {
3795
3803
  // 카탈로그 영어 병렬필드 + ko verbatim 동시 보존 + 렌더 영어 분기
3796
3804
  const enFields = LENS_CATALOG.code.questionsEn && LENS_CATALOG.code.questionsEn.length === LENS_CATALOG.code.questions.length
@@ -3802,6 +3810,16 @@ function _selfTestCases() {
3802
3810
  const renderEn = bin.includes('quality self-question lenses (v${VERSION})') && bin.includes("t('페르소나', 'persona')");
3803
3811
  return enFields && koVerbatim && renderEn;
3804
3812
  } },
3813
+ { name: '13번째 외부리뷰 P2 수정 (1.26.1): 개인키파일 스캔 + DB placeholder valueGroup + retro NaN 가드 (소스 가드)', run: () => {
3814
+ const bin = read(__filename);
3815
+ const cat = read(path.join(path.dirname(__filename), '..', 'lib', 'catalogs.js'));
3816
+ const pu = read(path.join(path.dirname(__filename), '..', 'lib', 'pure-utils.js'));
3817
+ const keyFile = bin.includes('const isKeyFile =') && bin.includes('!isKeyFile) continue;');
3818
+ const dbVg = /DB connection string[^\n]*valueGroup: 1/.test(cat);
3819
+ const phPlaceholder = pu.includes('root|admin|user|username|yourpassword');
3820
+ const retroGuard = bin.includes("failJson(has('--json'), 'invalid_arg'") && bin.includes('Math.min(days, 36500)');
3821
+ return keyFile && dbVg && phPlaceholder && retroGuard;
3822
+ } },
3805
3823
  { name: 'VERSION 형식 (x.y.z)', run: () => /^\d+\.\d+\.\d+$/.test(VERSION) }
3806
3824
  ];
3807
3825
  }
@@ -7835,7 +7853,9 @@ function _collectSecretFindings(root) {
7835
7853
  const ext = path.extname(file).toLowerCase();
7836
7854
  // 1.9.386 (UR-0087): env-family(.env / .env.local / .env.production …) basename 강제 포함.
7837
7855
  const isEnvFamily = /^\.env(\.|$)/.test(path.basename(file));
7838
- if (!SCAN_TEXT_EXT.has(ext) && !isEnvFamily) continue;
7856
+ // 1.26.1 (13번째 외부리뷰 P2): 개인키/인증서 파일(.pem/.key/.crt/.p8 …) 확장자 allow-list 에 없어 스캔 누락 → 커밋된 개인키 미탐 + handoff 'OK' 거짓보증. basename 으로 강제 포함('Generic private key' 정규식이 실제로 돌도록).
7857
+ const isKeyFile = /\.(?:pem|key|crt|cer|der|p8|p12|pfx|pkcs8|ppk|asc|gpg|keystore|jks)$/i.test(path.basename(file));
7858
+ if (!SCAN_TEXT_EXT.has(ext) && !isEnvFamily && !isKeyFile) continue;
7839
7859
  let text;
7840
7860
  // 1.12.5 (15th 버그헌트 P2, UR-0019): stat-before-read — 1MB 초과 파일은 읽지 않고 건너뜀(이전엔 read 후 검사라 대형 파일 통째 로드).
7841
7861
  try { if (fs.statSync(file).size > 1024 * 1024) continue; } catch { continue; }
@@ -12501,7 +12521,10 @@ function retroCmd(root) {
12501
12521
  if (has('--all-apps') || arg('--include', null)) {
12502
12522
  return _retroWorkspace(root);
12503
12523
  }
12504
- const days = parseInt(arg('--days', '7'), 10);
12524
+ // 1.26.1 (13번째 외부리뷰 P2): 비숫자 --days → NaN → new Date(Invalid) throw 로 --json 소비자에 plain text 누출. 숫자 가드 + 음수/오버플로 클램프(insights/round-history 와 일관).
12525
+ let days = parseInt(arg('--days', '7'), 10);
12526
+ if (!Number.isFinite(days)) { failJson(has('--json'), 'invalid_arg', _uiLang(root) === 'en' ? '--days must be a number' : '--days 는 숫자여야 합니다'); return; }
12527
+ days = Math.max(0, Math.min(days, 36500));
12505
12528
  const cutoff = new Date(Date.now() - days * 86400 * 1000).toISOString().slice(0, 10);
12506
12529
  const agg = _retroAggregate(root);
12507
12530
  // 1.9.16: --json
@@ -18986,7 +19009,7 @@ async function deployAutoCmd(root, service) {
18986
19009
  // 1.9.85: leerness health — 종합 헬스 체크 (drift + 보안 + skill + MCP + 누적)
18987
19010
  const _health = require('../lib/health');
18988
19011
  // 1.9.423 (UR-0025/UR-0125 큰 핸들러 모듈화 8번째): healthCmd → lib/health.js (DI 위임, thin wrapper)
18989
- function healthCmd(root) { return _health.healthCmd(root, { VERSION, STATUSES, has, arg, harnessPath: __filename, listAllSkills, planPath, readProgressRows, readRules, envDiff, _collectSecretFindings, _readUsageStats, _loadDecisions, _loadLessons, _loadShellFailures, _readFeatureGraph, _scanShellScriptsEncoding, _shellEnvDrift, _computeMilestones, _computeRecentChanges, _computeRoundHistory, _collectPyFiles, _analyzePyFile, _collectRuntimeEnv, _listAPISkills, _matchAPISkills, _mcpToolCount }); }
19012
+ function healthCmd(root) { return _health.healthCmd(root, { VERSION, STATUSES, has, arg, uiLang: _uiLang(root), harnessPath: __filename, listAllSkills, planPath, readProgressRows, readRules, envDiff, _collectSecretFindings, _readUsageStats, _loadDecisions, _loadLessons, _loadShellFailures, _readFeatureGraph, _scanShellScriptsEncoding, _shellEnvDrift, _computeMilestones, _computeRecentChanges, _computeRoundHistory, _collectPyFiles, _analyzePyFile, _collectRuntimeEnv, _listAPISkills, _matchAPISkills, _mcpToolCount }); }
18990
19013
 
18991
19014
  function usageStatsCmd(root) {
18992
19015
  root = absRoot(root || process.cwd());
package/lib/catalogs.js CHANGED
@@ -383,7 +383,7 @@ const SECRET_PATTERNS = [
383
383
  // 1.9.350 (UR-0060 외부리뷰 3모델): 누락 패턴 보강
384
384
  { name: 'GitLab PAT', re: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
385
385
  { name: 'JWT', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
386
- { name: 'DB connection string (embedded password)', re: /\b(?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s/@]+:[^@\s/]+@/gi },
386
+ { name: 'DB connection string (embedded password)', re: /\b(?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s/@]+:([^@\s/]+)@/gi, valueGroup: 1 }, // 1.26.1 (13번째 외부리뷰 P2): valueGroup=비밀번호 → .env.example 의 placeholder(user:password 등) 오탐 차단
387
387
  { name: 'SendGrid API key', re: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g },
388
388
  { name: 'AWS Secret Access Key (context)', re: /\baws[^\n]{0,40}?(?:secret_access_key|secret_key|secret)[^\n]{0,12}?["']?[A-Za-z0-9/+]{40}["']?/gi },
389
389
  { name: 'Hardcoded Bearer token', re: /\bBearer\s+[A-Za-z0-9_\-.=]{20,}/g },
package/lib/health.js CHANGED
@@ -1,354 +1,355 @@
1
- // lib/health.js — health 종합 진단 핸들러 (UR-0025/UR-0125 큰 핸들러 모듈화 8번째, 1.9.423)
2
- // bin/leerness.js 에서 healthCmd(334줄) 분리. DI: harness 고유 의존 다수 주입.
3
- // io 프리미티브는 ./io, _parseArchiveBlocks 는 ./pure-utils, fs/cp/os/path 빌트인. 동작/출력 무변경.
4
- 'use strict';
5
- const cp = require('child_process');
6
- const os = require('os');
7
- const path = require('path');
8
- const fs = require('fs');
9
- const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
10
- const { _parseArchiveBlocks } = require('./pure-utils');
11
-
12
- function healthCmd(root, deps = {}) {
13
- const { VERSION, STATUSES, has, arg, harnessPath, listAllSkills, planPath, readProgressRows, readRules, envDiff, _collectSecretFindings, _readUsageStats, _loadDecisions, _loadLessons, _loadShellFailures, _readFeatureGraph, _scanShellScriptsEncoding, _shellEnvDrift, _computeMilestones, _computeRecentChanges, _computeRoundHistory, _collectPyFiles, _analyzePyFile, _collectRuntimeEnv, _listAPISkills, _matchAPISkills, _mcpToolCount } = deps;
14
- root = absRoot(root || process.cwd());
15
- // 1.9.434 (11th 외부평가 Opus P2, UR-0136): 미존재 경로는 healthy 위조 금지 failJson + exit 1(audit/verify 일치, CI 안전).
16
- if (!exists(root)) { failJson(has('--json'), 'path_not_found', `경로 없음: ${root}`); return; }
17
- const out = { root, generatedAt: new Date().toISOString(), checks: {} };
18
- // 1) drift level
19
- try {
20
- const r = cp.spawnSync(process.execPath, [harnessPath, 'drift', 'check', root, '--json'],
21
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '0' } });
22
- const j = JSON.parse(r.stdout.trim());
23
- out.checks.drift = { level: j.level, score: j.score, firedCount: (j.fired || []).length };
24
- } catch { out.checks.drift = { error: 'drift check 실패' }; }
25
- // 2) 보안 상태 (1.9.418, 9th 외부평가 Codex P2): .env/.gitignore + **실제 하드코딩 시크릿 스캔**.
26
- // 기존엔 .env .gitignore 있으면 critical:false 커밋된 하드코딩 시크릿이 있어도 health 가 healthy:true 였음(false-OK).
27
- // handoff/scan secrets 동일하게 _collectSecretFindings 커밋 대상 시크릿을 반영(정직성).
28
- try {
29
- const sec = _collectSecretFindings(root);
30
- const committedSecrets = sec.committed.length;
31
- const envPath = path.join(root, '.env');
32
- const hasDotEnv = exists(envPath);
33
- const s = { hasDotEnv, committedSecrets };
34
- if (hasDotEnv) {
35
- const d = envDiff(root);
36
- const giText = exists(path.join(root, '.gitignore')) ? read(path.join(root, '.gitignore')) : '';
37
- const giLines = giText.split('\n').map(l => l.trim());
38
- const envInGi = giLines.includes('.env') || giLines.includes('/.env');
39
- const SECRET_PATTERNS = ['.env', '.env.local', '.env.production', '.env.*.local', '*.pem', 'credentials.json'];
40
- s.envInGitignore = envInGi;
41
- s.envExampleMissing = d.inEnvOnly;
42
- s.gitignoreMissingSecrets = SECRET_PATTERNS.filter(p => !giLines.some(l => l === p || l === '/' + p));
43
- s.critical = !envInGi || committedSecrets > 0;
44
- } else {
45
- s.critical = committedSecrets > 0;
46
- }
47
- out.checks.security = s;
48
- } catch { out.checks.security = { error: '보안 점검 실패' }; }
49
- // 3) skill + skill query 누적
50
- try {
51
- const all = listAllSkills(root);
52
- const skillCount = Object.keys(all).length;
53
- let queryCount = 0;
54
- const histPath = path.join(root, '.harness', 'skill-suggestions.md');
55
- if (exists(histPath)) {
56
- queryCount = (read(histPath).match(/^## [\d-]+ [\d:]+ — query/gm) || []).length;
57
- }
58
- out.checks.skills = { installed: skillCount, queryHistoryCount: queryCount };
59
- } catch { out.checks.skills = { error: 'skill 점검 실패' }; }
60
- // 4) MCP + 명령 호출 누적
61
- try {
62
- const stats = _readUsageStats(root);
63
- const cmdTotal = Object.values(stats.commands || {}).reduce((s, n) => s + n, 0);
64
- const mcpTotal = stats.mcp?.tools ? Object.values(stats.mcp.tools).reduce((s, n) => s + n, 0) : 0;
65
- out.checks.usage = {
66
- commandTotal: cmdTotal,
67
- commandKinds: Object.keys(stats.commands || {}).length,
68
- mcpTotal,
69
- mcpToolKinds: stats.mcp?.tools ? Object.keys(stats.mcp.tools).length : 0,
70
- since: stats.since || null
71
- };
72
- } catch { out.checks.usage = { error: 'usage 점검 실패' }; }
73
- // 5) tasks (progress-tracker)
74
- try {
75
- const rows = readProgressRows(root);
76
- const byStatus = {};
77
- for (const r of rows) byStatus[r.status] = (byStatus[r.status] || 0) + 1;
78
- out.checks.tasks = { total: rows.length, byStatus };
79
- } catch { out.checks.tasks = { error: 'tasks 점검 실패' }; }
80
- // 1.9.123: memorySurface 통합 (handoff --json 1.9.115 / session close --json 1.9.122 동일 패턴)
81
- try {
82
- const rows = readProgressRows(root);
83
- const tasksByStatus = {};
84
- for (const s of STATUSES) tasksByStatus[s] = 0;
85
- for (const r of rows) tasksByStatus[r.status] = (tasksByStatus[r.status] || 0) + 1;
86
- const tasksInProgress = tasksByStatus['in-progress'] || 0;
87
- const decisionsCount = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
88
- const rules = readRules(root);
89
- const rulesActive = rules.filter(r => r.status === 'active').length;
90
- const planText = exists(planPath(root)) ? read(planPath(root)) : '';
91
- const milestones = (planText.match(/^### M-\d{4}\./gm) || []).length;
92
- const lessonsCount = _loadLessons(root).length;
93
- out.memorySurface = {
94
- tasks: { inProgress: tasksInProgress, total: rows.length, byStatus: tasksByStatus },
95
- decisions: { count: decisionsCount },
96
- rules: { active: rulesActive, total: rules.length },
97
- plan: { milestones },
98
- lessons: { count: lessonsCount },
99
- archive: (() => {
100
- // 1.9.130: archive 카운트 통합
101
- const a = { decisions: 0, lessons: 0, plan: 0, total: 0 };
102
- try {
103
- const hdHe = path.join(root, '.harness');
104
- for (const [key, file] of [['decisions', 'decisions.archive.md'], ['lessons', 'lessons.archive.md'], ['plan', 'plan.archive.md']]) {
105
- const fpHe = path.join(hdHe, file);
106
- if (exists(fpHe)) {
107
- const entries = _parseArchiveBlocks(read(fpHe));
108
- a[key] = entries.length;
109
- a.total += entries.length;
110
- }
111
- }
112
- } catch {}
113
- return a;
114
- })(),
115
- summary: `T${tasksInProgress}/D${decisionsCount}/R${rulesActive}/P${milestones}/L${lessonsCount}`,
116
- };
117
- } catch { out.memorySurface = { error: 'memorySurface 점검 실패' }; }
118
- // 1.9.143: health --json featureGraph 통합 (handoff/session close 동일 패턴 — JSON 4종 완성)
119
- try {
120
- const { nodes: fNodesHe } = _readFeatureGraph(root);
121
- const edgeCount = fNodesHe.reduce((s, n) => s + (n.dependsOn?.length || 0) + (n.affects?.length || 0) + (n.coChangesWith?.length || 0), 0);
122
- const linkedSet = new Set();
123
- for (const n of fNodesHe) {
124
- for (const x of [...(n.dependsOn||[]), ...(n.affects||[]), ...(n.coChangesWith||[])]) { linkedSet.add(n.id); linkedSet.add(x); }
125
- }
126
- const isolated = fNodesHe.length ? (fNodesHe.length - linkedSet.size) : 0;
127
- out.featureGraph = {
128
- total: fNodesHe.length,
129
- edges: edgeCount,
130
- isolated: Math.max(0, isolated),
131
- summary: `F${fNodesHe.length}/E${edgeCount}${isolated > 0 ? `/iso${isolated}` : ''}`
132
- };
133
- } catch { out.featureGraph = { error: 'featureGraph 점검 실패' }; }
134
- // 1.9.228: health --json roundHistory 통합 (handoff/session close 동일 JSON 3 명령 일관성 + 6 통합 필드 완성)
135
- try {
136
- const rh = _computeRoundHistory(root);
137
- out.roundHistory = {
138
- roundCount: rh.roundCount,
139
- baselineVersion: rh.baselineVersion,
140
- nextMilestone: rh.nextMilestone,
141
- roundsToNextMilestone: rh.roundsToNextMilestone,
142
- daysActive: rh.daysActive,
143
- avgRoundsPerDay: rh.avgRoundsPerDay
144
- };
145
- } catch { out.roundHistory = { error: 'roundHistory 점검 실패' }; }
146
- // 1.9.230: health --json milestones 통합 (handoff/session close/health 3 명령 일관성 유지)
147
- try {
148
- const ms = _computeMilestones(root);
149
- out.milestones = {
150
- reachedCount: ms.reached.length,
151
- reached: ms.reached.map(m => ({ milestone: m.milestone, version: m.version, reachedAt: m.reachedAt })),
152
- next: ms.next,
153
- avgRoundsPerDay: ms.avgRoundsPerDay
154
- };
155
- } catch { out.milestones = { error: 'milestones 점검 실패' }; }
156
- // 1.9.234: health --json recentChanges 통합 (3 명령 8 필드 일관성)
157
- try {
158
- out.recentChanges = _computeRecentChanges(root, 5);
159
- } catch { out.recentChanges = { error: 'recentChanges 점검 실패' }; }
160
- // 1.9.240: health --json pyFiles 통합 (3 명령 9 필드 UR-0013 2단계)
161
- try {
162
- const pyFiles = _collectPyFiles(root, 200);
163
- const analyses = pyFiles.slice(0, 200).map(f => _analyzePyFile(f)).filter(Boolean);
164
- out.pyFiles = {
165
- total: pyFiles.length,
166
- analyzed: analyses.length,
167
- totalLOC: analyses.reduce((s, a) => s + a.loc, 0),
168
- totalImports: analyses.reduce((s, a) => s + a.imports, 0),
169
- totalFuncs: analyses.reduce((s, a) => s + a.funcs, 0),
170
- totalClasses: analyses.reduce((s, a) => s + a.classes, 0)
171
- };
172
- } catch { out.pyFiles = { error: 'pyFiles 점검 실패' }; }
173
- // 1.9.242: health --json envInfo 통합 (3 명령 10 필드 UR-0014 2단계)
174
- try {
175
- const runtimeEnv = _collectRuntimeEnv();
176
- const encScan = _scanShellScriptsEncoding(root);
177
- out.envInfo = {
178
- os: runtimeEnv.os.platform,
179
- isKoreanWindows: runtimeEnv.locale.isKoreanWindows || false,
180
- codepage: runtimeEnv.locale.codepage || null,
181
- nodeVersion: runtimeEnv.node.version,
182
- shellScriptsScanned: encScan.scanned,
183
- encodingRiskCount: encScan.atRisk.length,
184
- encodingRiskFiles: encScan.atRisk.slice(0, 5).map(r => r.file),
185
- // 1.9.249 (UR-0018): 터미널 출력 인코딩 안전 여부 + 자동 회복 결과
186
- terminalEncodingOk: runtimeEnv.locale.codepage === 65001 || !runtimeEnv.locale.isKoreanWindows,
187
- autoChcpApplied: process.env._LEERNESS_AUTOCHCP_APPLIED || null,
188
- // 1.9.250 (UR-0018 2단계): POSIX (Linux/macOS/WSL) terminal encoding 점검
189
- posixEncodingOk: runtimeEnv.locale.posixEncodingOk,
190
- isWSL: runtimeEnv.locale.isWSL || false
191
- };
192
- } catch { out.envInfo = { error: 'envInfo 점검 실패' }; }
193
- // 1.9.245: health --json apiSkills 통합 (3 명령 11 필드 UR-0015)
194
- try {
195
- const allSkills = _listAPISkills(root);
196
- let currentTaskText = '';
197
- try {
198
- const rows = readProgressRows(root);
199
- const ip = rows.find(r => r.status === 'in-progress');
200
- if (ip) currentTaskText = (ip.title || '') + ' ' + (ip.notes || '');
201
- } catch {}
202
- const matched = currentTaskText ? _matchAPISkills(root, currentTaskText) : [];
203
- out.apiSkills = {
204
- total: allSkills.length,
205
- matched: matched.length,
206
- matchedIds: matched.slice(0, 5).map(s => s.id),
207
- ids: allSkills.slice(0, 10).map(s => s.id)
208
- };
209
- } catch { out.apiSkills = { error: 'apiSkills 점검 실패' }; }
210
- // 1.9.264: shellGuard 통합 (health JSON 12번째 통합 필드 handoff/session close 와 JSON 3 명령 일관성) — UR-0020
211
- try {
212
- const sf = _loadShellFailures(root);
213
- const drift = _shellEnvDrift(root);
214
- out.shellGuard = {
215
- failureCount: sf.failures.length,
216
- recent: sf.failures.slice(-3).map(f => ({ cmd: (f.cmd || '').slice(0, 50), exitCode: f.exitCode, shell: f.shell, rules: f.issues || [] })),
217
- envDriftChanges: drift && drift.changes ? drift.changes.length : 0,
218
- envDrift: drift ? drift.changes : null
219
- };
220
- } catch { out.shellGuard = { error: 'shellGuard 점검 실패' }; }
221
- // 1.9.163: 5능력 매트릭스 자동 평가 (1.9.155 sub-agent 점검 코드 기반 자동화)
222
- // 능력을 코드 grep 으로 검출 0~100 점수. 사용자가 health 호출 시 leerness 자기 평가 확인.
223
- try {
224
- const harnessSrc = read(harnessPath);
225
- const cap = {};
226
- // (1) 자동화 — 1.9.165 playwright bridge 통합 + 실제 playwright 설치 detect
227
- const hasWebBridge = /function webCmd\(root, sub/.test(harnessSrc);
228
- // 사용자가 playwright 설치했는지 실시간 detect (require try)
229
- let playwrightInstalled = false;
230
- try { require('playwright'); playwrightInstalled = true; }
231
- catch { try { require('playwright-core'); playwrightInstalled = true; } catch {} }
232
- if (hasWebBridge && playwrightInstalled) {
233
- cap.webAutomation = { score: 90, status: '✓', evidence: 'playwright 설치 + leerness web bridge (1.9.165)' };
234
- } else if (hasWebBridge) {
235
- cap.webAutomation = { score: 50, status: '⚠', evidence: 'leerness web bridge 있음, playwright 미설치 (npm i -g playwright)' };
236
- } else {
237
- cap.webAutomation = { score: 5, status: '❌', evidence: 'permissions.browser=toggle만 (실 코드 미구현)' };
238
- }
239
- // (2) PC 조작 — 1.9.166 robotjs/nut-tree bridge + 실제 설치 detect
240
- const hasPCBridge = /function pcCmd\(root, sub/.test(harnessSrc);
241
- let pcInstalled = false;
242
- try { require('robotjs'); pcInstalled = true; }
243
- catch { try { require('@nut-tree/nut-js'); pcInstalled = true; } catch {} }
244
- if (hasPCBridge && pcInstalled) {
245
- cap.pcAutomation = { score: 90, status: '✓', evidence: 'robotjs/nut-tree 설치 + leerness pc bridge (1.9.166)' };
246
- } else if (hasPCBridge) {
247
- cap.pcAutomation = { score: 50, status: '⚠', evidence: 'leerness pc bridge 있음, robotjs 미설치 (npm i -g robotjs)' };
248
- } else {
249
- cap.pcAutomation = { score: 5, status: '❌', evidence: 'permissions.mouse/keyboard=필드만 (실 사용처 0)' };
250
- }
251
- // (3) 멀티 에이전트 오케스트레이션 — agents multi --execute + consensus 로직?
252
- const hasExecute = /const execute = has\('--execute'\)/.test(harnessSrc);
253
- const hasConsensus = /multi-signal consensus/.test(harnessSrc);
254
- cap.multiAgentOrchestration = (hasExecute && hasConsensus)
255
- ? { score: 90, status: '✓', evidence: '실 spawn + multi-signal consensus (1.9.156+1.9.155)' }
256
- : { score: 50, status: '', evidence: '명령 출력만 (1.9.152 기본 모드)' };
257
- // (4) REPL multi-provider _agentRepl + _cliChat 5종?
258
- const hasRepl = /async function _agentRepl/.test(harnessSrc);
259
- const hasCliChat = /async function _cliChat/.test(harnessSrc);
260
- cap.replMultiProvider = (hasRepl && hasCliChat)
261
- ? { score: 90, status: '✓', evidence: 'ollama/claude/codex/agy/copilot 5종 (1.9.149+1.9.153)' }
262
- : { score: 30, status: '', evidence: 'REPL 미완성' };
263
- // (5) MCP 도구 tools array 카운트 (1.9.288: 정확한 도구 정의 패턴 — 자기-매칭 오탐 제거, Codex #5)
264
- const toolCount = _mcpToolCount();
265
- cap.mcpTools = toolCount >= 50
266
- ? { score: 100, status: '✓', evidence: `${toolCount}/50+ 도구 (1.9.159 CRUD 완성)` }
267
- : { score: Math.round((toolCount / 50) * 100), status: toolCount > 30 ? '✓' : '⚠', evidence: `${toolCount} 도구` };
268
- // (6) 코드 인텔리전스 1.9.167 LSP 어댑터 + typescript 설치 detect
269
- const hasLspBridge = /function lspCmd\(root, sub/.test(harnessSrc);
270
- let tsInstalled = false;
271
- try { require('typescript'); tsInstalled = true; } catch {}
272
- if (hasLspBridge && tsInstalled) {
273
- cap.codeIntel = { score: 90, status: '✓', evidence: 'typescript 설치 + leerness lsp bridge (1.9.167, Compiler API)' };
274
- } else if (hasLspBridge) {
275
- cap.codeIntel = { score: 50, status: '⚠', evidence: 'leerness lsp bridge 있음, typescript 미설치 (regex fallback 동작, npm i -g typescript)' };
276
- } else {
277
- cap.codeIntel = { score: 5, status: '❌', evidence: 'LSP 어댑터 미구현 (코드 인텔리전스 없음)' };
278
- }
279
- const avgScore = Math.round((cap.webAutomation.score + cap.pcAutomation.score + cap.multiAgentOrchestration.score + cap.replMultiProvider.score + cap.mcpTools.score + cap.codeIntel.score) / 6);
280
- out.capabilityMatrix = {
281
- capabilities: cap,
282
- overallScore: avgScore,
283
- summary: `웹${cap.webAutomation.score}/PC${cap.pcAutomation.score}/멀티${cap.multiAgentOrchestration.score}/REPL${cap.replMultiProvider.score}/MCP${cap.mcpTools.score}/LSP${cap.codeIntel.score} · 종합 ${avgScore}%`,
284
- assessment: avgScore >= 70 ? 'production-ready' : avgScore >= 50 ? 'beta-ready' : 'mvp'
285
- };
286
- } catch { out.capabilityMatrix = { error: '5능력 매트릭스 평가 실패' }; }
287
- // 6) issues 요약 (사용자 글로벌 가시화)
288
- const issues = [];
289
- if (out.checks.drift?.level && !/healthy/.test(out.checks.drift.level)) issues.push(`drift ${out.checks.drift.level}`);
290
- if (out.checks.security?.committedSecrets > 0) issues.push(`🚨 커밋 대상 하드코딩 시크릿 ${out.checks.security.committedSecrets}건 (보안 CRITICAL)`); // 1.9.418 (9th 외부평가 Codex P2)
291
- if (out.checks.security?.hasDotEnv && out.checks.security?.envInGitignore === false) issues.push('🚨 .env가 .gitignore에 누락 (보안 CRITICAL)');
292
- if (out.checks.security?.envExampleMissing?.length) issues.push(`.env→.env.example 누락 ${out.checks.security.envExampleMissing.length}건`);
293
- if (out.checks.security?.gitignoreMissingSecrets?.length) issues.push(`.gitignore 시크릿 누락 ${out.checks.security.gitignoreMissingSecrets.length}건`);
294
- out.issues = issues;
295
- out.healthy = issues.length === 0;
296
-
297
- // 1.9.430 (10th 외부평가 UR-0130): 보안 CRITICAL(커밋 시크릿 / .env 미보호)은 --strict 없이도 exit 1.
298
- // health CI 게이트로 써도 하드코딩 시크릿을 놓치지 않음(scan secrets exit code 일치). 비-CRITICAL issue 는 종전대로 exit 0(--strict 로 게이트).
299
- const criticalSecurity = (out.checks.security?.committedSecrets > 0) || !!(out.checks.security?.hasDotEnv && out.checks.security?.envInGitignore === false);
300
- out.criticalSecurity = criticalSecurity;
301
- // --strict: 모든 issue 시 exit 1. 외엔 보안 CRITICAL 만 exit 1.
302
- if ((has('--strict') && !out.healthy) || criticalSecurity) process.exitCode = 1;
303
-
304
- if (has('--json')) { log(JSON.stringify(out, null, 2)); return; }
305
- log(`# leerness health (1.9.85)`);
306
- log(`Date: ${out.generatedAt}`);
307
- log(`Status: ${out.healthy ? '✅ healthy' : `⚠ ${issues.length} issues`}`);
308
- log('');
309
- log(`## drift`);
310
- log(` level: ${out.checks.drift?.level || 'n/a'} (score ${out.checks.drift?.score || 0}, fired ${out.checks.drift?.firedCount || 0})`);
311
- log('');
312
- log(`## 보안`);
313
- if (out.checks.security?.hasDotEnv) {
314
- log(` .env 존재 · .gitignore에 .env 포함: ${out.checks.security.envInGitignore ? '✓' : '✗ CRITICAL'}`);
315
- log(` .env.example 누락 키: ${out.checks.security.envExampleMissing?.length || 0}건`);
316
- log(` .gitignore 시크릿 패턴 누락: ${out.checks.security.gitignoreMissingSecrets?.length || 0}건`);
317
- } else {
318
- log(` .env 없음 (검증 불필요)`);
319
- }
320
- log('');
321
- log(`## skills`);
322
- log(` 설치: ${out.checks.skills?.installed || 0}개 · skill query 누적: ${out.checks.skills?.queryHistoryCount || 0}회`);
323
- log('');
324
- log(`## usage`);
325
- log(` 명령 호출: ${out.checks.usage?.commandTotal || 0}회 / ${out.checks.usage?.commandKinds || 0}종`);
326
- log(` MCP 호출: ${out.checks.usage?.mcpTotal || 0}회 / ${out.checks.usage?.mcpToolKinds || 0} 도구`);
327
- log(` since: ${out.checks.usage?.since || 'unknown'}`);
328
- log('');
329
- log(`## tasks`);
330
- const tb = out.checks.tasks?.byStatus || {};
331
- log(` 총 ${out.checks.tasks?.total || 0}건: ${Object.entries(tb).map(([s, n]) => `${s}=${n}`).join(', ') || '없음'}`);
332
- // 1.9.163: 5능력 매트릭스 1.9.155 sub-agent 점검의 코드 기반 자동 평가
333
- if (out.capabilityMatrix && !out.capabilityMatrix.error) {
334
- log('');
335
- log(`## 🧪 6능력 매트릭스 (1.9.167 자동 평가)`);
336
- const cm = out.capabilityMatrix;
337
- log(` 종합: ${cm.overallScore}% (${cm.assessment})`);
338
- log(` (1) 자동화 ${cm.capabilities.webAutomation.status} ${cm.capabilities.webAutomation.score}% · ${cm.capabilities.webAutomation.evidence}`);
339
- log(` (2) PC 조작 ${cm.capabilities.pcAutomation.status} ${cm.capabilities.pcAutomation.score}% · ${cm.capabilities.pcAutomation.evidence}`);
340
- log(` (3) 멀티 오케스트레이션 ${cm.capabilities.multiAgentOrchestration.status} ${cm.capabilities.multiAgentOrchestration.score}% · ${cm.capabilities.multiAgentOrchestration.evidence}`);
341
- log(` (4) REPL multi-provider ${cm.capabilities.replMultiProvider.status} ${cm.capabilities.replMultiProvider.score}% · ${cm.capabilities.replMultiProvider.evidence}`);
342
- log(` (5) MCP 도구 ${cm.capabilities.mcpTools.status} ${cm.capabilities.mcpTools.score}% · ${cm.capabilities.mcpTools.evidence}`);
343
- log(` (6) 코드 인텔리전스 ${cm.capabilities.codeIntel.status} ${cm.capabilities.codeIntel.score}% · ${cm.capabilities.codeIntel.evidence}`);
344
- }
345
- if (issues.length) {
346
- log('');
347
- log(`## ⚠ Issues (${issues.length})`);
348
- for (const i of issues) log(` - ${i}`);
349
- log('');
350
- log(`💡 자동 회복: leerness drift check --auto-fix · leerness audit --fix`);
351
- }
352
- }
353
-
354
- module.exports = { healthCmd };
1
+ // lib/health.js — health 종합 진단 핸들러 (UR-0025/UR-0125 큰 핸들러 모듈화 8번째, 1.9.423)
2
+ // bin/leerness.js 에서 healthCmd(334줄) 분리. DI: harness 고유 의존 다수 주입.
3
+ // io 프리미티브는 ./io, _parseArchiveBlocks 는 ./pure-utils, fs/cp/os/path 빌트인. 동작/출력 무변경.
4
+ 'use strict';
5
+ const cp = require('child_process');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
10
+ const { _parseArchiveBlocks } = require('./pure-utils');
11
+
12
+ function healthCmd(root, deps = {}) {
13
+ const { VERSION, STATUSES, has, arg, uiLang, harnessPath, listAllSkills, planPath, readProgressRows, readRules, envDiff, _collectSecretFindings, _readUsageStats, _loadDecisions, _loadLessons, _loadShellFailures, _readFeatureGraph, _scanShellScriptsEncoding, _shellEnvDrift, _computeMilestones, _computeRecentChanges, _computeRoundHistory, _collectPyFiles, _analyzePyFile, _collectRuntimeEnv, _listAPISkills, _matchAPISkills, _mcpToolCount } = deps;
14
+ root = absRoot(root || process.cwd());
15
+ const t = (ko, en) => (uiLang === 'en' ? en : ko); // 1.25.2 (UR-0010 Phase 9): health 영어 opt-in
16
+ // 1.9.434 (11th 외부평가 Opus P2, UR-0136): 미존재 경로는 healthy 위조 금지 — failJson + exit 1(audit/verify 일치, CI 안전).
17
+ if (!exists(root)) { failJson(has('--json'), 'path_not_found', t(`경로 없음: ${root}`, `path not found: ${root}`)); return; }
18
+ const out = { root, generatedAt: new Date().toISOString(), checks: {} };
19
+ // 1) drift level
20
+ try {
21
+ const r = cp.spawnSync(process.execPath, [harnessPath, 'drift', 'check', root, '--json'],
22
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '0' } });
23
+ const j = JSON.parse(r.stdout.trim());
24
+ out.checks.drift = { level: j.level, score: j.score, firedCount: (j.fired || []).length };
25
+ } catch { out.checks.drift = { error: 'drift check 실패' }; }
26
+ // 2) 보안 상태 (1.9.418, 9th 외부평가 Codex P2): .env/.gitignore + **실제 하드코딩 시크릿 스캔**.
27
+ // 기존엔 .env .gitignore 있으면 critical:false 커밋된 하드코딩 시크릿이 있어도 health 가 healthy:true 였음(false-OK).
28
+ // handoff/scan secrets 와 동일하게 _collectSecretFindings 로 커밋 대상 시크릿을 반영(정직성).
29
+ try {
30
+ const sec = _collectSecretFindings(root);
31
+ const committedSecrets = sec.committed.length;
32
+ const envPath = path.join(root, '.env');
33
+ const hasDotEnv = exists(envPath);
34
+ const s = { hasDotEnv, committedSecrets };
35
+ if (hasDotEnv) {
36
+ const d = envDiff(root);
37
+ const giText = exists(path.join(root, '.gitignore')) ? read(path.join(root, '.gitignore')) : '';
38
+ const giLines = giText.split('\n').map(l => l.trim());
39
+ const envInGi = giLines.includes('.env') || giLines.includes('/.env');
40
+ const SECRET_PATTERNS = ['.env', '.env.local', '.env.production', '.env.*.local', '*.pem', 'credentials.json'];
41
+ s.envInGitignore = envInGi;
42
+ s.envExampleMissing = d.inEnvOnly;
43
+ s.gitignoreMissingSecrets = SECRET_PATTERNS.filter(p => !giLines.some(l => l === p || l === '/' + p));
44
+ s.critical = !envInGi || committedSecrets > 0;
45
+ } else {
46
+ s.critical = committedSecrets > 0;
47
+ }
48
+ out.checks.security = s;
49
+ } catch { out.checks.security = { error: '보안 점검 실패' }; }
50
+ // 3) skill 수 + skill query 누적
51
+ try {
52
+ const all = listAllSkills(root);
53
+ const skillCount = Object.keys(all).length;
54
+ let queryCount = 0;
55
+ const histPath = path.join(root, '.harness', 'skill-suggestions.md');
56
+ if (exists(histPath)) {
57
+ queryCount = (read(histPath).match(/^## [\d-]+ [\d:]+ — query/gm) || []).length;
58
+ }
59
+ out.checks.skills = { installed: skillCount, queryHistoryCount: queryCount };
60
+ } catch { out.checks.skills = { error: 'skill 점검 실패' }; }
61
+ // 4) MCP + 명령 호출 누적
62
+ try {
63
+ const stats = _readUsageStats(root);
64
+ const cmdTotal = Object.values(stats.commands || {}).reduce((s, n) => s + n, 0);
65
+ const mcpTotal = stats.mcp?.tools ? Object.values(stats.mcp.tools).reduce((s, n) => s + n, 0) : 0;
66
+ out.checks.usage = {
67
+ commandTotal: cmdTotal,
68
+ commandKinds: Object.keys(stats.commands || {}).length,
69
+ mcpTotal,
70
+ mcpToolKinds: stats.mcp?.tools ? Object.keys(stats.mcp.tools).length : 0,
71
+ since: stats.since || null
72
+ };
73
+ } catch { out.checks.usage = { error: 'usage 점검 실패' }; }
74
+ // 5) tasks (progress-tracker)
75
+ try {
76
+ const rows = readProgressRows(root);
77
+ const byStatus = {};
78
+ for (const r of rows) byStatus[r.status] = (byStatus[r.status] || 0) + 1;
79
+ out.checks.tasks = { total: rows.length, byStatus };
80
+ } catch { out.checks.tasks = { error: 'tasks 점검 실패' }; }
81
+ // 1.9.123: memorySurface 통합 (handoff --json 1.9.115 / session close --json 1.9.122 와 동일 패턴)
82
+ try {
83
+ const rows = readProgressRows(root);
84
+ const tasksByStatus = {};
85
+ for (const s of STATUSES) tasksByStatus[s] = 0;
86
+ for (const r of rows) tasksByStatus[r.status] = (tasksByStatus[r.status] || 0) + 1;
87
+ const tasksInProgress = tasksByStatus['in-progress'] || 0;
88
+ const decisionsCount = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
89
+ const rules = readRules(root);
90
+ const rulesActive = rules.filter(r => r.status === 'active').length;
91
+ const planText = exists(planPath(root)) ? read(planPath(root)) : '';
92
+ const milestones = (planText.match(/^### M-\d{4}\./gm) || []).length;
93
+ const lessonsCount = _loadLessons(root).length;
94
+ out.memorySurface = {
95
+ tasks: { inProgress: tasksInProgress, total: rows.length, byStatus: tasksByStatus },
96
+ decisions: { count: decisionsCount },
97
+ rules: { active: rulesActive, total: rules.length },
98
+ plan: { milestones },
99
+ lessons: { count: lessonsCount },
100
+ archive: (() => {
101
+ // 1.9.130: archive 카운트 통합
102
+ const a = { decisions: 0, lessons: 0, plan: 0, total: 0 };
103
+ try {
104
+ const hdHe = path.join(root, '.harness');
105
+ for (const [key, file] of [['decisions', 'decisions.archive.md'], ['lessons', 'lessons.archive.md'], ['plan', 'plan.archive.md']]) {
106
+ const fpHe = path.join(hdHe, file);
107
+ if (exists(fpHe)) {
108
+ const entries = _parseArchiveBlocks(read(fpHe));
109
+ a[key] = entries.length;
110
+ a.total += entries.length;
111
+ }
112
+ }
113
+ } catch {}
114
+ return a;
115
+ })(),
116
+ summary: `T${tasksInProgress}/D${decisionsCount}/R${rulesActive}/P${milestones}/L${lessonsCount}`,
117
+ };
118
+ } catch { out.memorySurface = { error: 'memorySurface 점검 실패' }; }
119
+ // 1.9.143: health --json featureGraph 통합 (handoff/session close 와 동일 패턴 — JSON 4종 완성)
120
+ try {
121
+ const { nodes: fNodesHe } = _readFeatureGraph(root);
122
+ const edgeCount = fNodesHe.reduce((s, n) => s + (n.dependsOn?.length || 0) + (n.affects?.length || 0) + (n.coChangesWith?.length || 0), 0);
123
+ const linkedSet = new Set();
124
+ for (const n of fNodesHe) {
125
+ for (const x of [...(n.dependsOn||[]), ...(n.affects||[]), ...(n.coChangesWith||[])]) { linkedSet.add(n.id); linkedSet.add(x); }
126
+ }
127
+ const isolated = fNodesHe.length ? (fNodesHe.length - linkedSet.size) : 0;
128
+ out.featureGraph = {
129
+ total: fNodesHe.length,
130
+ edges: edgeCount,
131
+ isolated: Math.max(0, isolated),
132
+ summary: `F${fNodesHe.length}/E${edgeCount}${isolated > 0 ? `/iso${isolated}` : ''}`
133
+ };
134
+ } catch { out.featureGraph = { error: 'featureGraph 점검 실패' }; }
135
+ // 1.9.228: health --json roundHistory 통합 (handoff/session close 와 동일 — JSON 3 명령 일관성 + 6 통합 필드 완성)
136
+ try {
137
+ const rh = _computeRoundHistory(root);
138
+ out.roundHistory = {
139
+ roundCount: rh.roundCount,
140
+ baselineVersion: rh.baselineVersion,
141
+ nextMilestone: rh.nextMilestone,
142
+ roundsToNextMilestone: rh.roundsToNextMilestone,
143
+ daysActive: rh.daysActive,
144
+ avgRoundsPerDay: rh.avgRoundsPerDay
145
+ };
146
+ } catch { out.roundHistory = { error: 'roundHistory 점검 실패' }; }
147
+ // 1.9.230: health --json milestones 통합 (handoff/session close/health 3 명령 일관성 유지)
148
+ try {
149
+ const ms = _computeMilestones(root);
150
+ out.milestones = {
151
+ reachedCount: ms.reached.length,
152
+ reached: ms.reached.map(m => ({ milestone: m.milestone, version: m.version, reachedAt: m.reachedAt })),
153
+ next: ms.next,
154
+ avgRoundsPerDay: ms.avgRoundsPerDay
155
+ };
156
+ } catch { out.milestones = { error: 'milestones 점검 실패' }; }
157
+ // 1.9.234: health --json recentChanges 통합 (3 명령 8 필드 일관성)
158
+ try {
159
+ out.recentChanges = _computeRecentChanges(root, 5);
160
+ } catch { out.recentChanges = { error: 'recentChanges 점검 실패' }; }
161
+ // 1.9.240: health --json pyFiles 통합 (3 명령 9 필드 — UR-0013 2단계)
162
+ try {
163
+ const pyFiles = _collectPyFiles(root, 200);
164
+ const analyses = pyFiles.slice(0, 200).map(f => _analyzePyFile(f)).filter(Boolean);
165
+ out.pyFiles = {
166
+ total: pyFiles.length,
167
+ analyzed: analyses.length,
168
+ totalLOC: analyses.reduce((s, a) => s + a.loc, 0),
169
+ totalImports: analyses.reduce((s, a) => s + a.imports, 0),
170
+ totalFuncs: analyses.reduce((s, a) => s + a.funcs, 0),
171
+ totalClasses: analyses.reduce((s, a) => s + a.classes, 0)
172
+ };
173
+ } catch { out.pyFiles = { error: 'pyFiles 점검 실패' }; }
174
+ // 1.9.242: health --json envInfo 통합 (3 명령 10 필드 — UR-0014 2단계)
175
+ try {
176
+ const runtimeEnv = _collectRuntimeEnv();
177
+ const encScan = _scanShellScriptsEncoding(root);
178
+ out.envInfo = {
179
+ os: runtimeEnv.os.platform,
180
+ isKoreanWindows: runtimeEnv.locale.isKoreanWindows || false,
181
+ codepage: runtimeEnv.locale.codepage || null,
182
+ nodeVersion: runtimeEnv.node.version,
183
+ shellScriptsScanned: encScan.scanned,
184
+ encodingRiskCount: encScan.atRisk.length,
185
+ encodingRiskFiles: encScan.atRisk.slice(0, 5).map(r => r.file),
186
+ // 1.9.249 (UR-0018): 터미널 출력 인코딩 안전 여부 + 자동 회복 결과
187
+ terminalEncodingOk: runtimeEnv.locale.codepage === 65001 || !runtimeEnv.locale.isKoreanWindows,
188
+ autoChcpApplied: process.env._LEERNESS_AUTOCHCP_APPLIED || null,
189
+ // 1.9.250 (UR-0018 2단계): POSIX (Linux/macOS/WSL) terminal encoding 점검
190
+ posixEncodingOk: runtimeEnv.locale.posixEncodingOk,
191
+ isWSL: runtimeEnv.locale.isWSL || false
192
+ };
193
+ } catch { out.envInfo = { error: 'envInfo 점검 실패' }; }
194
+ // 1.9.245: health --json apiSkills 통합 (3 명령 11 필드 — UR-0015)
195
+ try {
196
+ const allSkills = _listAPISkills(root);
197
+ let currentTaskText = '';
198
+ try {
199
+ const rows = readProgressRows(root);
200
+ const ip = rows.find(r => r.status === 'in-progress');
201
+ if (ip) currentTaskText = (ip.title || '') + ' ' + (ip.notes || '');
202
+ } catch {}
203
+ const matched = currentTaskText ? _matchAPISkills(root, currentTaskText) : [];
204
+ out.apiSkills = {
205
+ total: allSkills.length,
206
+ matched: matched.length,
207
+ matchedIds: matched.slice(0, 5).map(s => s.id),
208
+ ids: allSkills.slice(0, 10).map(s => s.id)
209
+ };
210
+ } catch { out.apiSkills = { error: 'apiSkills 점검 실패' }; }
211
+ // 1.9.264: shellGuard 통합 (health JSON 12번째 통합 필드 — handoff/session close 와 JSON 3 명령 일관성) — UR-0020
212
+ try {
213
+ const sf = _loadShellFailures(root);
214
+ const drift = _shellEnvDrift(root);
215
+ out.shellGuard = {
216
+ failureCount: sf.failures.length,
217
+ recent: sf.failures.slice(-3).map(f => ({ cmd: (f.cmd || '').slice(0, 50), exitCode: f.exitCode, shell: f.shell, rules: f.issues || [] })),
218
+ envDriftChanges: drift && drift.changes ? drift.changes.length : 0,
219
+ envDrift: drift ? drift.changes : null
220
+ };
221
+ } catch { out.shellGuard = { error: 'shellGuard 점검 실패' }; }
222
+ // 1.9.163: 5능력 매트릭스 자동 평가 (1.9.155 sub-agent 점검 코드 기반 자동화)
223
+ // 각 능력을 코드 grep 으로 검출 → 0~100 점수. 사용자가 매 health 호출 시 leerness 자기 평가 확인.
224
+ try {
225
+ const harnessSrc = read(harnessPath);
226
+ const cap = {};
227
+ // (1) 자동화 1.9.165 playwright bridge 통합 + 실제 playwright 설치 detect
228
+ const hasWebBridge = /function webCmd\(root, sub/.test(harnessSrc);
229
+ // 사용자가 playwright 설치했는지 실시간 detect (require try)
230
+ let playwrightInstalled = false;
231
+ try { require('playwright'); playwrightInstalled = true; }
232
+ catch { try { require('playwright-core'); playwrightInstalled = true; } catch {} }
233
+ if (hasWebBridge && playwrightInstalled) {
234
+ cap.webAutomation = { score: 90, status: '✓', evidence: t('playwright 설치 + leerness web bridge (1.9.165)', 'playwright installed + leerness web bridge') };
235
+ } else if (hasWebBridge) {
236
+ cap.webAutomation = { score: 50, status: '⚠', evidence: t('leerness web bridge 있음, playwright 미설치 (npm i -g playwright)', 'leerness web bridge present, playwright not installed (npm i -g playwright)') };
237
+ } else {
238
+ cap.webAutomation = { score: 5, status: '❌', evidence: t('permissions.browser=toggle만 (실 코드 미구현)', 'permissions.browser=toggle only (no real code)') };
239
+ }
240
+ // (2) PC 조작 — 1.9.166 robotjs/nut-tree bridge + 실제 설치 detect
241
+ const hasPCBridge = /function pcCmd\(root, sub/.test(harnessSrc);
242
+ let pcInstalled = false;
243
+ try { require('robotjs'); pcInstalled = true; }
244
+ catch { try { require('@nut-tree/nut-js'); pcInstalled = true; } catch {} }
245
+ if (hasPCBridge && pcInstalled) {
246
+ cap.pcAutomation = { score: 90, status: '✓', evidence: t('robotjs/nut-tree 설치 + leerness pc bridge (1.9.166)', 'robotjs/nut-tree installed + leerness pc bridge') };
247
+ } else if (hasPCBridge) {
248
+ cap.pcAutomation = { score: 50, status: '⚠', evidence: t('leerness pc bridge 있음, robotjs 미설치 (npm i -g robotjs)', 'leerness pc bridge present, robotjs not installed (npm i -g robotjs)') };
249
+ } else {
250
+ cap.pcAutomation = { score: 5, status: '❌', evidence: t('permissions.mouse/keyboard=필드만 (실 사용처 0)', 'permissions.mouse/keyboard=field only (no real usage)') };
251
+ }
252
+ // (3) 멀티 에이전트 오케스트레이션 agents multi --execute + consensus 로직?
253
+ const hasExecute = /const execute = has\('--execute'\)/.test(harnessSrc);
254
+ const hasConsensus = /multi-signal consensus/.test(harnessSrc);
255
+ cap.multiAgentOrchestration = (hasExecute && hasConsensus)
256
+ ? { score: 90, status: '', evidence: t(' spawn + multi-signal consensus (1.9.156+1.9.155)', 'real spawn + multi-signal consensus') }
257
+ : { score: 50, status: '⚠', evidence: t('명령 출력만 (1.9.152 기본 모드)', 'command output only (default mode)') };
258
+ // (4) REPL multi-provider _agentRepl + _cliChat 5종?
259
+ const hasRepl = /async function _agentRepl/.test(harnessSrc);
260
+ const hasCliChat = /async function _cliChat/.test(harnessSrc);
261
+ cap.replMultiProvider = (hasRepl && hasCliChat)
262
+ ? { score: 90, status: '', evidence: t('ollama/claude/codex/agy/copilot 5종 (1.9.149+1.9.153)', 'ollama/claude/codex/agy/copilot (5 providers)') }
263
+ : { score: 30, status: '⚠', evidence: t('REPL 미완성', 'REPL incomplete') };
264
+ // (5) MCP 도구 — tools array 카운트 (1.9.288: 정확한 도구 정의 패턴 — 자기-매칭 오탐 제거, Codex #5)
265
+ const toolCount = _mcpToolCount();
266
+ cap.mcpTools = toolCount >= 50
267
+ ? { score: 100, status: '✓', evidence: t(`${toolCount}/50+ 도구 (1.9.159 CRUD 완성)`, `${toolCount}/50+ tools (CRUD complete)`) }
268
+ : { score: Math.round((toolCount / 50) * 100), status: toolCount > 30 ? '✓' : '⚠', evidence: t(`${toolCount} 도구`, `${toolCount} tools`) };
269
+ // (6) 코드 인텔리전스 1.9.167 LSP 어댑터 + typescript 설치 detect
270
+ const hasLspBridge = /function lspCmd\(root, sub/.test(harnessSrc);
271
+ let tsInstalled = false;
272
+ try { require('typescript'); tsInstalled = true; } catch {}
273
+ if (hasLspBridge && tsInstalled) {
274
+ cap.codeIntel = { score: 90, status: '✓', evidence: t('typescript 설치 + leerness lsp bridge (1.9.167, Compiler API)', 'typescript installed + leerness lsp bridge (Compiler API)') };
275
+ } else if (hasLspBridge) {
276
+ cap.codeIntel = { score: 50, status: '⚠', evidence: t('leerness lsp bridge 있음, typescript 미설치 (regex fallback 동작, npm i -g typescript)', 'leerness lsp bridge present, typescript not installed (regex fallback active, npm i -g typescript)') };
277
+ } else {
278
+ cap.codeIntel = { score: 5, status: '❌', evidence: t('LSP 어댑터 미구현 (코드 인텔리전스 없음)', 'LSP adapter not implemented (no code intelligence)') };
279
+ }
280
+ const avgScore = Math.round((cap.webAutomation.score + cap.pcAutomation.score + cap.multiAgentOrchestration.score + cap.replMultiProvider.score + cap.mcpTools.score + cap.codeIntel.score) / 6);
281
+ out.capabilityMatrix = {
282
+ capabilities: cap,
283
+ overallScore: avgScore,
284
+ summary: t(`웹${cap.webAutomation.score}/PC${cap.pcAutomation.score}/멀티${cap.multiAgentOrchestration.score}/REPL${cap.replMultiProvider.score}/MCP${cap.mcpTools.score}/LSP${cap.codeIntel.score} · 종합 ${avgScore}%`, `web${cap.webAutomation.score}/PC${cap.pcAutomation.score}/multi${cap.multiAgentOrchestration.score}/REPL${cap.replMultiProvider.score}/MCP${cap.mcpTools.score}/LSP${cap.codeIntel.score} · overall ${avgScore}%`),
285
+ assessment: avgScore >= 70 ? 'production-ready' : avgScore >= 50 ? 'beta-ready' : 'mvp'
286
+ };
287
+ } catch { out.capabilityMatrix = { error: t('5능력 매트릭스 평가 실패', 'capability matrix evaluation failed') }; }
288
+ // 6) issues 요약 (사용자 글로벌 룰 가시화)
289
+ const issues = [];
290
+ if (out.checks.drift?.level && !/healthy/.test(out.checks.drift.level)) issues.push(`drift ${out.checks.drift.level}`);
291
+ if (out.checks.security?.committedSecrets > 0) issues.push(t(`🚨 커밋 대상 하드코딩 시크릿 ${out.checks.security.committedSecrets}건 (보안 CRITICAL)`, `🚨 ${out.checks.security.committedSecrets} hardcoded secret(s) staged for commit (security CRITICAL)`)); // 1.9.418 (9th 외부평가 Codex P2)
292
+ if (out.checks.security?.hasDotEnv && out.checks.security?.envInGitignore === false) issues.push(t('🚨 .env.gitignore에 누락 (보안 CRITICAL)', '🚨 .env missing from .gitignore (security CRITICAL)'));
293
+ if (out.checks.security?.envExampleMissing?.length) issues.push(t(`.env→.env.example 누락 ${out.checks.security.envExampleMissing.length}건`, `.env→.env.example missing ${out.checks.security.envExampleMissing.length}`));
294
+ if (out.checks.security?.gitignoreMissingSecrets?.length) issues.push(t(`.gitignore 시크릿 누락 ${out.checks.security.gitignoreMissingSecrets.length}건`, `.gitignore missing secret patterns ${out.checks.security.gitignoreMissingSecrets.length}`));
295
+ out.issues = issues;
296
+ out.healthy = issues.length === 0;
297
+
298
+ // 1.9.430 (10th 외부평가 UR-0130): 보안 CRITICAL(커밋 시크릿 / .env 미보호) --strict 없이도 exit 1.
299
+ // → health CI 게이트로 써도 하드코딩 시크릿을 놓치지 않음(scan secrets 와 exit code 일치). 비-CRITICAL issue 는 종전대로 exit 0(--strict 로 게이트).
300
+ const criticalSecurity = (out.checks.security?.committedSecrets > 0) || !!(out.checks.security?.hasDotEnv && out.checks.security?.envInGitignore === false);
301
+ out.criticalSecurity = criticalSecurity;
302
+ // --strict: 모든 issue exit 1. 외엔 보안 CRITICAL 만 exit 1.
303
+ if ((has('--strict') && !out.healthy) || criticalSecurity) process.exitCode = 1;
304
+
305
+ if (has('--json')) { log(JSON.stringify(out, null, 2)); return; }
306
+ log(`# leerness health (1.9.85)`);
307
+ log(`Date: ${out.generatedAt}`);
308
+ log(`Status: ${out.healthy ? '✅ healthy' : `⚠ ${issues.length} issues`}`);
309
+ log('');
310
+ log(`## drift`);
311
+ log(` level: ${out.checks.drift?.level || 'n/a'} (score ${out.checks.drift?.score || 0}, fired ${out.checks.drift?.firedCount || 0})`);
312
+ log('');
313
+ log(t(`## 보안`, `## Security`));
314
+ if (out.checks.security?.hasDotEnv) {
315
+ log(t(` .env 존재 · .gitignore에 .env 포함: ${out.checks.security.envInGitignore ? '✓' : '✗ CRITICAL'}`, ` .env present · .env in .gitignore: ${out.checks.security.envInGitignore ? '✓' : '✗ CRITICAL'}`));
316
+ log(t(` .env.example 누락 키: ${out.checks.security.envExampleMissing?.length || 0}건`, ` .env.example missing keys: ${out.checks.security.envExampleMissing?.length || 0}`));
317
+ log(t(` .gitignore 시크릿 패턴 누락: ${out.checks.security.gitignoreMissingSecrets?.length || 0}건`, ` .gitignore missing secret patterns: ${out.checks.security.gitignoreMissingSecrets?.length || 0}`));
318
+ } else {
319
+ log(t(` .env 없음 (검증 불필요)`, ` no .env (nothing to check)`));
320
+ }
321
+ log('');
322
+ log(`## skills`);
323
+ log(t(` 설치: ${out.checks.skills?.installed || 0}개 · skill query 누적: ${out.checks.skills?.queryHistoryCount || 0}회`, ` installed: ${out.checks.skills?.installed || 0} · skill queries: ${out.checks.skills?.queryHistoryCount || 0}`));
324
+ log('');
325
+ log(`## usage`);
326
+ log(t(` 명령 호출: ${out.checks.usage?.commandTotal || 0}회 / ${out.checks.usage?.commandKinds || 0}종`, ` command calls: ${out.checks.usage?.commandTotal || 0} / ${out.checks.usage?.commandKinds || 0} kinds`));
327
+ log(t(` MCP 호출: ${out.checks.usage?.mcpTotal || 0}회 / ${out.checks.usage?.mcpToolKinds || 0}종 도구`, ` MCP calls: ${out.checks.usage?.mcpTotal || 0} / ${out.checks.usage?.mcpToolKinds || 0} tools`));
328
+ log(` since: ${out.checks.usage?.since || 'unknown'}`);
329
+ log('');
330
+ log(`## tasks`);
331
+ const tb = out.checks.tasks?.byStatus || {};
332
+ log(t(` 총 ${out.checks.tasks?.total || 0}건: ${Object.entries(tb).map(([s, n]) => `${s}=${n}`).join(', ') || '없음'}`, ` total ${out.checks.tasks?.total || 0}: ${Object.entries(tb).map(([s, n]) => `${s}=${n}`).join(', ') || 'none'}`));
333
+ // 1.9.163: 5능력 매트릭스 — 1.9.155 sub-agent 점검의 코드 기반 자동 평가
334
+ if (out.capabilityMatrix && !out.capabilityMatrix.error) {
335
+ log('');
336
+ log(t(`## 🧪 6능력 매트릭스 (1.9.167 자동 평가)`, `## 🧪 6-capability matrix (auto-assessed)`));
337
+ const cm = out.capabilityMatrix;
338
+ log(t(` 종합: ${cm.overallScore}% (${cm.assessment})`, ` overall: ${cm.overallScore}% (${cm.assessment})`));
339
+ log(t(` (1) 자동화 ${cm.capabilities.webAutomation.status} ${cm.capabilities.webAutomation.score}% · ${cm.capabilities.webAutomation.evidence}`, ` (1) web automation ${cm.capabilities.webAutomation.status} ${cm.capabilities.webAutomation.score}% · ${cm.capabilities.webAutomation.evidence}`));
340
+ log(t(` (2) PC 조작 ${cm.capabilities.pcAutomation.status} ${cm.capabilities.pcAutomation.score}% · ${cm.capabilities.pcAutomation.evidence}`, ` (2) PC control ${cm.capabilities.pcAutomation.status} ${cm.capabilities.pcAutomation.score}% · ${cm.capabilities.pcAutomation.evidence}`));
341
+ log(t(` (3) 멀티 오케스트레이션 ${cm.capabilities.multiAgentOrchestration.status} ${cm.capabilities.multiAgentOrchestration.score}% · ${cm.capabilities.multiAgentOrchestration.evidence}`, ` (3) multi-agent orch. ${cm.capabilities.multiAgentOrchestration.status} ${cm.capabilities.multiAgentOrchestration.score}% · ${cm.capabilities.multiAgentOrchestration.evidence}`));
342
+ log(t(` (4) REPL multi-provider ${cm.capabilities.replMultiProvider.status} ${cm.capabilities.replMultiProvider.score}% · ${cm.capabilities.replMultiProvider.evidence}`, ` (4) REPL multi-provider ${cm.capabilities.replMultiProvider.status} ${cm.capabilities.replMultiProvider.score}% · ${cm.capabilities.replMultiProvider.evidence}`));
343
+ log(t(` (5) MCP 도구 ${cm.capabilities.mcpTools.status} ${cm.capabilities.mcpTools.score}% · ${cm.capabilities.mcpTools.evidence}`, ` (5) MCP tools ${cm.capabilities.mcpTools.status} ${cm.capabilities.mcpTools.score}% · ${cm.capabilities.mcpTools.evidence}`));
344
+ log(t(` (6) 코드 인텔리전스 ${cm.capabilities.codeIntel.status} ${cm.capabilities.codeIntel.score}% · ${cm.capabilities.codeIntel.evidence}`, ` (6) code intelligence ${cm.capabilities.codeIntel.status} ${cm.capabilities.codeIntel.score}% · ${cm.capabilities.codeIntel.evidence}`));
345
+ }
346
+ if (issues.length) {
347
+ log('');
348
+ log(`## Issues (${issues.length})`);
349
+ for (const i of issues) log(` - ${i}`);
350
+ log('');
351
+ log(t(`💡 자동 회복: leerness drift check --auto-fix · leerness audit --fix`, `💡 auto-recover: leerness drift check --auto-fix · leerness audit --fix`));
352
+ }
353
+ }
354
+
355
+ module.exports = { healthCmd };
package/lib/pure-utils.js CHANGED
@@ -642,7 +642,8 @@ function _isPlaceholderSecret(value) {
642
642
  let v = String(value).trim().replace(/^["']|["']$/g, '').trim().toLowerCase();
643
643
  if (!v) return true;
644
644
  // 전체가 placeholder 토큰
645
- if (/^(?:x{3,}|\*{3,}|\.{3,}|-+|0+|1234567890?|12345678|abc123|secret|password|passwd|changeme|change[-_]me|replace[-_]?me|placeholder|example|examples?|sample|dummy|test|testing|foo|bar|baz|tbd|todo|none|null|undefined|nil|empty|redacted|hidden|value|string|here)$/.test(v)) return true;
645
+ // 1.26.1 (13번째 외부리뷰 P2): DB URI 등 placeholder 자격증명(user:password / root:root / yourpassword …) 추가 — 전체-값 정확 일치만 매칭하므로 실키(길고 고엔트로피)에는 FN 영향 0.
646
+ if (/^(?:x{3,}|\*{3,}|\.{3,}|-+|0+|1234567890?|12345678|abc123|secret|password|passwd|pass|changeme|change[-_]me|changeit|replace[-_]?me|placeholder|example|examples?|sample|dummy|test|testing|foo|bar|baz|tbd|todo|none|null|undefined|nil|empty|redacted|hidden|value|string|here|root|admin|user|username|yourpassword|your[-_]?password|mypassword)$/.test(v)) return true;
646
647
  // 1.9.405 (8번째 버그헌트 회귀수정, UR-0109): placeholder 단어 신호를 entropy 가드보다 먼저 검사.
647
648
  // 1.9.401 회귀: 긴 서술형 placeholder('your-super-secret-api-key-example-value')가 고엔트로피(영숫자24+ & 고유12+)를 넘어 실키로 오탐(FP).
648
649
  // → placeholder 마커 단어가 있으면 entropy 가드 무시하고 placeholder 로 판정. 실키 prefix(sk-/AKIA 등)는 마커보다 우선(FN 방지).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.25.0",
3
+ "version": "1.27.0",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -6202,5 +6202,73 @@ total++;
6202
6202
  if (!ok) failed++;
6203
6203
  }
6204
6204
 
6205
+ // 1.25.1 (22nd 버그헌트 → i18n 행위 회귀 가드, UR-0010): --language en 런타임 렌더가 실제로 영어인지 + ko 기본 보존 + --language 값이 positional 로 누출 안 되는지 행위로 검증.
6206
+ // 소스가드(문자열 존재)만으로는 1.23.0 "session close 완전 영어" 과장(런타임 한글 누출)을 못 잡았던 공백을 e2e 로 보강(defense-in-depth).
6207
+ total++;
6208
+ {
6209
+ let ok = false;
6210
+ try {
6211
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-i18n-'));
6212
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6213
+ const H = /[가-힣]/;
6214
+ const out = (r) => (r.stdout || '') + (r.stderr || '');
6215
+ // ① 기본(ko 프로젝트, 플래그 없음): lens 한글 보존
6216
+ const lensKo = out(cp.spawnSync(process.execPath, [CLI, 'lens', '--path', d], { encoding: 'utf8', timeout: 15000 }));
6217
+ const lensKoOk = /분야별 자기질문 품질 렌즈/.test(lensKo);
6218
+ // ② 영어 opt-in(ko 프로젝트라도 flag 가 manifest 를 이김): lens 영어 렌더 + 한글 0
6219
+ const lensEn = out(cp.spawnSync(process.execPath, [CLI, 'lens', '--language', 'en', '--path', d], { encoding: 'utf8', timeout: 15000 }));
6220
+ const lensEnOk = /quality self-question lenses/.test(lensEn) && !H.test(lensEn);
6221
+ // ③ --language en 값이 positional 로 누출 안 됨: task add 텍스트 보존, request="en" 인 task 없음
6222
+ cp.spawnSync(process.execPath, [CLI, 'task', 'add', 'I18N_TASK_E2E', '--language', 'en', '--path', d], { encoding: 'utf8', timeout: 15000 });
6223
+ const tl = out(cp.spawnSync(process.execPath, [CLI, 'task', 'list', '--json', '--path', d], { encoding: 'utf8', timeout: 15000 }));
6224
+ const noLeak = tl.includes('I18N_TASK_E2E') && !/"request"\s*:\s*"en"/.test(tl);
6225
+ // ④ status path-not-found 에러: en 영어 / ko 한글 (failJson 분기)
6226
+ const stEn = out(cp.spawnSync(process.execPath, [CLI, 'status', path.join(d, 'nope'), '--language', 'en', '--json'], { encoding: 'utf8', timeout: 15000 }));
6227
+ const stKo = out(cp.spawnSync(process.execPath, [CLI, 'status', path.join(d, 'nope'), '--json'], { encoding: 'utf8', timeout: 15000 }));
6228
+ const stOk = /path not found/.test(stEn) && /경로 없음/.test(stKo);
6229
+ // ⑤ (1.25.2 Phase 9) health: en 렌더 영어(한글 0) + ko 기본 한글 보존
6230
+ const hEn = out(cp.spawnSync(process.execPath, [CLI, 'health', '--language', 'en', '--path', d], { encoding: 'utf8', timeout: 20000 }));
6231
+ const hKo = out(cp.spawnSync(process.execPath, [CLI, 'health', '--path', d], { encoding: 'utf8', timeout: 20000 }));
6232
+ const healthOk = /## Security/.test(hEn) && !H.test(hEn) && /## 보안/.test(hKo);
6233
+ fs.rmSync(d, { recursive: true, force: true });
6234
+ ok = lensKoOk && lensEnOk && noLeak && stOk && healthOk;
6235
+ } catch {}
6236
+ console.log(ok ? '✓ B(1.25.1/1.25.2) i18n 행위: --language en 런타임 영어(lens/health) + ko 기본 보존 + --language positional 무누출 + status 에러 en/ko (UR-0010)' : '✗ i18n 행위 회귀 가드 실패');
6237
+ if (!ok) failed++;
6238
+ }
6239
+
6240
+ // 1.26.1 (13번째 외부리뷰 P2 회귀가드): 개인키파일 스캔 FN + DB placeholder FP + retro --json NaN 행위 가드.
6241
+ total++;
6242
+ {
6243
+ let ok = false;
6244
+ try {
6245
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rev13-'));
6246
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { encoding: 'utf8', timeout: 30000 });
6247
+ const out = (r) => (r.stdout || '') + (r.stderr || '');
6248
+ // #4: 커밋된 개인키 파일(.key, gitignore 미포함)은 잡혀야 함(FN 차단)
6249
+ fs.writeFileSync(path.join(d, 'server.key'), '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA1234567890abcdefghij\n-----END RSA PRIVATE KEY-----\n');
6250
+ const keyScan = cp.spawnSync(process.execPath, [CLI, 'scan', 'secrets', d], { encoding: 'utf8', timeout: 15000 });
6251
+ const keyCaught = keyScan.status === 1 && /Generic private key/.test(out(keyScan));
6252
+ fs.unlinkSync(path.join(d, 'server.key'));
6253
+ // #5: .env.example 의 placeholder DB URI 는 오탐 X (FP 차단) / 진짜 비번은 잡힘(FN 유지)
6254
+ fs.writeFileSync(path.join(d, '.env.example'), 'A=postgres://user:password@h:5432/db\nB=mysql://root:root@h/db\n');
6255
+ const phScan = cp.spawnSync(process.execPath, [CLI, 'scan', 'secrets', d], { encoding: 'utf8', timeout: 15000 });
6256
+ const noFp = phScan.status === 0 && !/DB connection string/.test(out(phScan));
6257
+ fs.unlinkSync(path.join(d, '.env.example'));
6258
+ fs.writeFileSync(path.join(d, 'real.env'), 'D=postgres://admin:Xk9zQ2mP7rL4wT@prod.example.com:5432/main\n');
6259
+ const realScan = cp.spawnSync(process.execPath, [CLI, 'scan', 'secrets', d], { encoding: 'utf8', timeout: 15000 });
6260
+ const realCaught = realScan.status === 1 && /DB connection string/.test(out(realScan));
6261
+ fs.unlinkSync(path.join(d, 'real.env'));
6262
+ // #1: retro --days 비숫자 --json 은 구조화 JSON(plain text 누출 X)
6263
+ const rj = cp.spawnSync(process.execPath, [CLI, 'retro', d, '--days', 'xyz', '--json'], { encoding: 'utf8', timeout: 15000 });
6264
+ let retroJsonOk = false;
6265
+ try { const j = JSON.parse(rj.stdout); retroJsonOk = j && (j.error || j.code) && rj.status === 1; } catch {}
6266
+ fs.rmSync(d, { recursive: true, force: true });
6267
+ ok = keyCaught && noFp && realCaught && retroJsonOk;
6268
+ } catch {}
6269
+ console.log(ok ? '✓ B(1.26.1) 13th 외부리뷰: 개인키파일 스캔(FN차단) + DB placeholder(FP차단/FN유지) + retro --json NaN 구조화' : '✗ 13th 외부리뷰 P2 회귀가드 실패');
6270
+ if (!ok) failed++;
6271
+ }
6272
+
6205
6273
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
6206
6274
  if (failed > 0) process.exit(1);