leerness 1.27.0 → 1.28.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,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.28.0 — 2026-06-15 — 🛡️ [안정화/Stable] 정직성 후속 + drift 영어화 안정 minor
4
+
5
+ **🛡️ 안정화(Stable) minor — 13번째 외부리뷰 정직성 수정 + drift 진단 영어화를 npm 공개.** 직전 minor(1.27.0) 이후 누적된 패치 2건(1.27.1 + 1.27.2)을 검증·통합해 배포. R-0011 정책의 19번째 stable minor. 한국어 우선 기본은 그대로.
6
+
7
+ ### 이번 minor 통합 (1.27.1~1.27.2)
8
+ - **🔎 정직성 후속 수정 (1.27.1)**: `audit <미초기화경로>` 가 "미초기화" 선언 후 없는 하네스에 design/reuse 체크를 보고하던 모순 출력 차단(요약/JSON 직행). `verify-claim --run-tests` 가 비-테스트 `--test-cmd`(exit 0, 미파싱)를 `✓ all passed` 로 거짓표기하던 것을 `✓ ran (exit 0) — test count unconfirmed` 로 정직 표기(판정/exit 불변 → FP=0).
9
+ - **🌐 drift check 출력 영어화 (1.27.2)**: `drift check` 기본 출력(경로/상태/신호 표/보안 신호/권장 조치)을 영어 opt-in. `--auto-fix` 진행 로그는 Phase 10b 백로그. 내부 호출(handoff/health)은 ko 기본이라 무영향.
10
+ - **한국어 우선 기본 보존**: 영어는 명시 opt-in. 한국어 출력/내부 JSON 은 그대로(e2e 무회귀).
11
+
12
+ ### 잔여 (백로그)
13
+ - drift `--auto-fix` 로그(Phase 10b) · capabilities/commands/doctor/install-safety/constraints 영어화 · init en seed 템플릿 i18n.
14
+
15
+ ### 검증 (회귀 0)
16
+ - **selftest 248/248** · **E2E 368/368** (정직성 후속 가드 + i18n 행위가드 lens/health/drift en/ko) · 게시본 클린룸 재실증.
17
+ - minor(1.28.0) — npm 배포(R-0011 stable) + annotated tag(Stable) + GitHub release(latest).
18
+
19
+ ## 1.27.2 — 2026-06-15 — CLI 영어화 Phase 10: drift check 출력 영어화 (UR-0010)
20
+
21
+ **🌐 drift 진단 출력을 영어로.** 고빈도 진단 `drift check` 의 **기본 출력**(경로/상태/신호 표/보안 신호/권장 조치)을 영어 opt-in 으로. `--auto-fix` 진행 로그(~25줄)는 정직하게 Phase 10b 로 분리(반쪽 주장 회피).
22
+
23
+ ### 변경 (UR-0010 Phase 10)
24
+ - **drift check 출력 영어화 (lib/drift.js, DI uiLang)**: 경로 없음 에러, 표 헤더(`신호/임계/가중치/발화`→`signal/threshold/weight/fired`), 신호 라벨 8종(`session close 누락`→`session close missing`, `current-state 갱신 없음`, `task update 없음`, `progress-tracker 비어있음`, `task-log 갱신 없음`, `보안 위험`→`security risk`, `Feature Graph 미정리`→`unlinked`, `task 0건 sub-app`), 보안 issue 2종, `권장 조치` 블록. `t(ko,en)`, ko 인자 verbatim.
25
+ - **내부 호출 무영향**: handoff/health 가 drift 를 내부 spawn(LEERNESS_INTERNAL, `--language` 없음)할 땐 ko 기본 라벨 — 한국어 출력/JSON 파싱 그대로(무회귀). 라벨은 `--json` 값도 언어 따름(en 시 영어).
26
+
27
+ ### 잔여 (UR-0010 Phase 10b+, 백로그)
28
+ - drift `--auto-fix` 진행 로그(~25줄) + capabilities/commands/doctor/install-safety/constraints + init en seed 템플릿 i18n.
29
+
30
+ ### 검증 (회귀 0)
31
+ - **selftest 247→248** (drift 영어/한국어 보존 + uiLang 주입 소스가드) · 행위(drift `--language en` 한글 0 / ko 보존 / 내부호출 ko 유지) · **E2E 368/368** (i18n 행위가드에 drift en/ko 추가).
32
+ - patch(1.27.2) — npm 미배포(R-0011, GitHub/CHANGELOG 누적).
33
+
34
+ ## 1.27.1 — 2026-06-15 — 13번째 외부리뷰 정직성 후속: audit 미초기화 모순출력 + verify-claim no-parse 표기
35
+
36
+ **🔎 13번째 외부리뷰의 정직성 잔여 P2/P3 2건 수정(맹신 X 양방향 재현).** 둘 다 leerness 핵심 정체성(정직한 보고)을 직접 건드리는 출력 문제.
37
+
38
+ ### 변경
39
+ - **audit 미초기화 경로 모순 출력 차단 (#2)**: `audit <미초기화경로>` 가 "미초기화" 를 선언한 직후 design/reuse 체크를 **없는 하네스에 대해** 보고하던 모순(예: "✓ no duplicate design guide candidates")을 차단 — 미초기화 감지 시 요약/JSON 으로 직행 후 종료. exit code(1)·`--json` 페이로드(not_initialized finding)는 종전과 동일, **정상 프로젝트 audit 은 무영향**(모든 체크 계속).
40
+ - **verify-claim --run-tests no-parse 정직 표기 (#3)**: 비-테스트 `--test-cmd`(예: `echo hi`)가 exit 0 이면서 테스트 비율을 못 파싱한 경우 `✓ all passed` 로 거짓표기하던 것을 `✓ ran (exit 0) — test count unconfirmed`(실행됨, 테스트 수 미확인)로 정직 표기. **메시지만 변경, 판정/exit 불변** → 출력 포맷이 다른 정상 테스트러너의 통과 주장을 거부하지 않음(FP=0). 진짜 N/N 테스트는 계속 `✓ all passed`, 실패(exit≠0)는 계속 `✗ FAIL`.
41
+
42
+ ### 검증 (회귀 0)
43
+ - **selftest 246→247** (소스가드; 기존 1.9.421 "audit body=lib" 가드와 자기참조 충돌을 코멘트 앵커로 회피 — [[lesson-selftest-self-reference-trap]] 적용) · 행위(맹신 X 양방향) · **E2E 367→368** (정직성 후속 회귀가드 1건).
44
+ - patch(1.27.1) — npm 미배포(R-0011, GitHub/CHANGELOG 누적). 잔여(init en seed=대형 템플릿 i18n, Phase 10 진단 영어화)는 백로그.
45
+
3
46
  ## 1.27.0 — 2026-06-15 — 🛡️ [안정화/Stable] 보안 수정 안정 minor (개인키 스캔 FN + placeholder FP)
4
47
 
5
48
  **🛡️ 안정화(Stable) minor — 13번째 외부리뷰에서 확인된 보안 수정을 조기 npm 공개.** 직전 minor(1.26.0) 이후 1.26.1 패치 1건이지만, **보안 FN/FP(거짓 "보안 OK" + CI 파손)는 패치 누적을 기다리기보다 조기 공개가 합리적**이라 단독 minor 로 게시. R-0011 정책의 18번째 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.27.0 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
107
+ 이 프로젝트는 Leerness v1.28.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.27.0는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **85개 도구**를 노출:
161
+ Leerness v1.28.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.27.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.27.0)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
182
+ 현재 누적: **70 라운드 (1.9.40 → 1.28.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.27.0: 2026-06-15
220
+ Last synced by Leerness v1.28.0: 2026-06-16
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.27.0';
35
+ const VERSION = '1.28.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') 시 호스트 프로세스 오염.
@@ -3820,6 +3820,22 @@ function _selfTestCases() {
3820
3820
  const retroGuard = bin.includes("failJson(has('--json'), 'invalid_arg'") && bin.includes('Math.min(days, 36500)');
3821
3821
  return keyFile && dbVg && phPlaceholder && retroGuard;
3822
3822
  } },
3823
+ { name: '13번째 외부리뷰 정직성 후속 (1.27.1): audit 미초기화 early-return + verify-claim no-parse 표기 (소스 가드)', run: () => {
3824
+ const bin = read(__filename);
3825
+ const aud = read(path.join(path.dirname(__filename), '..', 'lib', 'audit.js'));
3826
+ // 주의: bin 에 'not_'+'initialized' 리터럴을 두면 1.9.421('body 가 lib 로 이동') 가드가 깨짐 → 코멘트 텍스트로 앵커.
3827
+ const auditReturn = aud.includes('// 1.27.1 (13번째 외부리뷰 #2)') && /외부리뷰 #2\)[\s\S]{0,1200}?process\.exitCode = 1;\s*\n\s*return;/.test(aud);
3828
+ const vcMsg = bin.includes("test count unconfirmed") && bin.includes('runResult.parsed ? ');
3829
+ return auditReturn && vcMsg;
3830
+ } },
3831
+ { name: 'CLI 영어화 Phase 10 (1.27.2, UR-0010): drift check 출력 영어/한국어 보존 + uiLang 주입 (소스 가드)', run: () => {
3832
+ const bin = read(__filename);
3833
+ const dr = read(path.join(path.dirname(__filename), '..', 'lib', 'drift.js'));
3834
+ const injected = bin.includes('uiLang: _uiLang(root), harnessPath: __filename, readProgressRows, planPath, handoffPath, currentStatePath, taskLogPath');
3835
+ const en = dr.includes('| signal | age | threshold | weight | fired |') && dr.includes('session close missing') && dr.includes('recommended actions') && dr.includes('security risk:');
3836
+ const koPreserved = dr.includes('| 신호 | age | 임계 | 가중치 | 발화 |') && dr.includes('session close 누락') && dr.includes('권장 조치'); // ko 인자 보존(e2e ko/내부호출)
3837
+ return injected && en && koPreserved;
3838
+ } },
3823
3839
  { name: 'VERSION 형식 (x.y.z)', run: () => /^\d+\.\d+\.\d+$/.test(VERSION) }
3824
3840
  ];
3825
3841
  }
@@ -10383,7 +10399,8 @@ function verifyClaimCmd(root, taskId) {
10383
10399
  // 1.17.4 (UR-0047): 측정 불가는 '통과' 가 아니라 '검증 미수행' — 이전엔 실측 0 인데 ✓ pass(실측≥주장) 모순 표기.
10384
10400
  log(` - ${t('테스트 카운트', 'test count')}: ${declaredTestCount == null ? t('⊘ (주장 없음)', '⊘ (none claimed)') : !testMeasured ? t(`⊘ 측정 불가 — 주장 ${declaredTestCount}개 검증 미수행 (pass 아님)`, `⊘ not measurable — claimed ${declaredTestCount} not verified (not a pass)`) : testOk ? t('✓ pass (실측 ≥ 주장)', '✓ pass (measured ≥ claimed)') : t('⚠ 주장보다 적음', '⚠ fewer than claimed')}`);
10385
10401
  if (runResult && !runResult.skipped) {
10386
- log(` - ${runResult.cmd || 'npm test'} ${t('실행', 'run')}: ${runTestsOk ? '✓ all passed' : ' FAIL'}`);
10402
+ // 1.27.1 (13번째 외부리뷰 #3): exit 0 인데 테스트 비율을 못 파싱한 경우(예: 비-테스트 --test-cmd) '✓ all passed' 거짓표기하지 않음 — '실행됨, 테스트 수 미확인' 으로 정직 표기(판정/exit 불변 → 이색 테스트러너 FP 없음).
10403
+ log(` - ${runResult.cmd || 'npm test'} ${t('실행', 'run')}: ${runTestsOk ? (runResult.parsed ? '✓ all passed' : t('✓ 실행됨 (exit 0) — 테스트 수 미확인', '✓ ran (exit 0) — test count unconfirmed')) : '✗ FAIL'}`);
10387
10404
  if (declaredPass) log(` - ${t('주장과 실행 결과 일치', 'claimed matches run')}: ${declaredPassMatchesActual ? '✓ pass' : t('⚠ 다름', '⚠ differs')}`);
10388
10405
  }
10389
10406
  // 1.11.2 (UR-0175): optimism+정직성 — done 주장은 기본 게이팅(claimsChecked). 완화: --lenient.
@@ -14913,7 +14930,7 @@ function autoUpdateInstall(root) {
14913
14930
  // 1.9.37: drift detection — 메타파일 staleness 측정으로 "leerness 점점 안 쓰는" 현상 감지
14914
14931
  const _drift = require('../lib/drift');
14915
14932
  // 1.9.422 (UR-0025/UR-0125 큰 핸들러 모듈화 7번째): driftCheckCmd → lib/drift.js (DI 위임, thin wrapper)
14916
- function driftCheckCmd(root, opts = {}) { return _drift.driftCheckCmd(root, opts, { VERSION, has, arg, harnessPath: __filename, readProgressRows, planPath, handoffPath, currentStatePath, taskLogPath, envDiff, _usageStatsPath, _readUsageStats, _updateUserRequest, _scanShellScriptsEncoding, _readFeatureGraph, _detectDeliveredRequests, _autoFixIdempotency }); }
14933
+ function driftCheckCmd(root, opts = {}) { return _drift.driftCheckCmd(root, opts, { VERSION, has, arg, uiLang: _uiLang(root), harnessPath: __filename, readProgressRows, planPath, handoffPath, currentStatePath, taskLogPath, envDiff, _usageStatsPath, _readUsageStats, _updateUserRequest, _scanShellScriptsEncoding, _readFeatureGraph, _detectDeliveredRequests, _autoFixIdempotency }); }
14917
14934
 
14918
14935
  // 1.9.69: skill-suggestions.md rolling history 인덱스 — mtime 기반 캐시
14919
14936
  // handoff에서 같은 키워드 과거 추천 결과를 즉시 노출 (재매칭 불필요)
package/lib/audit.js CHANGED
@@ -26,6 +26,11 @@ function audit(root, opts = {}, deps = {}) {
26
26
  failures++;
27
27
  fail(`미초기화 또는 존재하지 않는 경로: ${root} (.harness/AGENTS.md 없음 — leerness init 필요)`);
28
28
  _finding('not_initialized', 'fail', 'uninitialized or missing path (.harness or AGENTS.md absent)', { root });
29
+ // 1.27.1 (13번째 외부리뷰 #2): 미초기화 시 후속 체크(design/reuse 등)를 없는 하네스에 대해 보고하던 모순 출력 차단 — 요약/JSON 으로 직행 후 종료(exit code/JSON 페이로드는 종전과 동일).
30
+ log(`Audit summary: warnings=${warnings} failures=${failures}`);
31
+ if (jsonMode) { process.stdout.write = _origWrite; process.stdout.write(JSON.stringify({ version: VERSION, root, warnings, failures, fixed, healthy: false, fixApplied: fix, strict: has('--strict'), strictThreshold: has('--strict') ? parseInt(arg('--threshold', '1'), 10) : null, summary: `warnings=${warnings} failures=${failures}`, findings }, null, 2) + '\n'); }
32
+ process.exitCode = 1;
33
+ return;
29
34
  }
30
35
  const designCands = ['designguide.md','design-guide.md','docs/designguide.md','docs/design-guide.md','.harness/designguide.md'];
31
36
  const dups = designCands.filter(f => exists(path.join(root,f)));
package/lib/drift.js CHANGED
@@ -1,341 +1,342 @@
1
- // lib/drift.js — drift check 핸들러 (UR-0025/UR-0125 큰 핸들러 모듈화 7번째, 1.9.422)
2
- // bin/leerness.js 에서 driftCheckCmd(322줄) 분리. DI: harness 고유 의존(VERSION, has, arg, harnessPath, readProgressRows, planPath, handoffPath, currentStatePath, _usageStatsPath, _readUsageStats, _updateUserRequest, _scanShellScriptsEncoding, _readFeatureGraph, _detectDeliveredRequests, _autoFixIdempotency) 주입.
3
- // io 프리미티브는 ./io, cp/path 빌트인. 내부 재귀(auto-fix 후 재검사)는 deps 전달. 동작/출력 무변경.
4
- 'use strict';
5
- const cp = require('child_process');
6
- const path = require('path');
7
- const fs = require('fs');
8
- const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
9
-
10
- function driftCheckCmd(root, opts = {}, deps = {}) {
11
- const { VERSION, has, arg, harnessPath, readProgressRows, planPath, handoffPath, currentStatePath, taskLogPath, envDiff, _usageStatsPath, _readUsageStats, _updateUserRequest, _scanShellScriptsEncoding, _readFeatureGraph, _detectDeliveredRequests, _autoFixIdempotency } = deps;
12
- root = absRoot(root || process.cwd());
13
- // 1.9.434 (11th 외부평가 Opus P2, UR-0136): 미존재 경로는 healthy 위조 금지 failJson + exit 1.
14
- if (!exists(root)) { failJson(has('--json'), 'path_not_found', `경로 없음: ${root}`); return; }
15
- const now = Date.now();
16
- const _ageDays = (p) => {
17
- if (!exists(p)) return null;
18
- return (now - fs.statSync(p).mtimeMs) / 86400000;
19
- };
20
- // 각 메타파일의 마지막 갱신
21
- const signals = [];
22
- // 1. session-handoff.md - "Last generated" 라인 우선, 없으면 mtime
23
- const shPath = handoffPath(root);
24
- if (exists(shPath)) {
25
- const txt = read(shPath);
26
- // 1.9.316 (drift 마커 버그): 최신(마지막) 'Last generated' 사용 — 구 블록 중복 시 첫(구) 매치를 읽던 오발화 방어.
27
- const allGen = [...txt.matchAll(/Last generated:\s*([\d\-T:.Z]+)/g)];
28
- const m = allGen.length ? allGen[allGen.length - 1] : null;
29
- let ageDays;
30
- if (m) {
31
- ageDays = (now - new Date(m[1]).getTime()) / 86400000;
32
- } else {
33
- ageDays = _ageDays(shPath);
34
- }
35
- signals.push({ file: 'session-handoff.md', ageDays, threshold: 1, weight: 30, label: 'session close 누락' });
36
- }
37
- // 2. current-state.md - "Updated: YYYY-MM-DD" 라인
38
- const csPath = currentStatePath(root);
39
- if (exists(csPath)) {
40
- const m = read(csPath).match(/Updated:\s*(\d{4}-\d{2}-\d{2})/);
41
- const ageDays = m ? (now - new Date(m[1]).getTime()) / 86400000 : _ageDays(csPath);
42
- signals.push({ file: 'current-state.md', ageDays, threshold: 2, weight: 20, label: 'current-state 갱신 없음' });
43
- }
44
- // 3. progress-tracker.md 마지막 row의 updated 컬럼
45
- const rows = readProgressRows(root);
46
- if (rows.length) {
47
- const dates = rows.map(r => (r.updated || '').match(/\d{4}-\d{2}-\d{2}/)).filter(Boolean).map(m => m[0]);
48
- if (dates.length) {
49
- dates.sort();
50
- const latest = dates[dates.length - 1];
51
- const ageDays = (now - new Date(latest).getTime()) / 86400000;
52
- signals.push({ file: 'progress-tracker.md', ageDays, threshold: 1, weight: 30, label: 'task update 없음' });
53
- }
54
- } else {
55
- signals.push({ file: 'progress-tracker.md', ageDays: 999, threshold: 1, weight: 25, label: 'progress-tracker 비어있음' });
56
- }
57
- // 4. task-log.md 마지막 entry "## YYYY-MM-DD"
58
- const tlPath = taskLogPath(root);
59
- if (exists(tlPath)) {
60
- const dates = Array.from(read(tlPath).matchAll(/^## (\d{4}-\d{2}-\d{2})/gm)).map(m => m[1]);
61
- if (dates.length) {
62
- dates.sort();
63
- const latest = dates[dates.length - 1];
64
- const ageDays = (now - new Date(latest).getTime()) / 86400000;
65
- signals.push({ file: 'task-log.md', ageDays, threshold: 2, weight: 20, label: 'task-log 갱신 없음' });
66
- }
67
- }
68
- // 점수 계산
69
- let totalScore = 0;
70
- const fired = [];
71
- for (const s of signals) {
72
- if (s.ageDays > s.threshold) {
73
- totalScore += s.weight;
74
- fired.push(s);
75
- }
76
- }
77
- // 1.9.78: 보안 신호 (env / .gitignore 누락) — 5번째 신호
78
- try {
79
- const envPath = path.join(root, '.env');
80
- if (exists(envPath)) {
81
- let secScore = 0;
82
- const secIssues = [];
83
- // (a) .env vs .env.example 동기화
84
- try {
85
- const d = envDiff(root);
86
- if (d.inEnvOnly.length) {
87
- secIssues.push(`.env→.env.example 누락 ${d.inEnvOnly.length}건`);
88
- secScore += 15;
89
- }
90
- } catch {}
91
- // (b) .gitignore 시크릿 패턴
92
- try {
93
- const giText = exists(path.join(root, '.gitignore')) ? read(path.join(root, '.gitignore')) : '';
94
- const giLines = giText.split('\n').map(l => l.trim());
95
- const SECRET_PATTERNS = ['.env', '.env.local', '.env.production', '.env.*.local', '*.pem', 'credentials.json'];
96
- const missing = SECRET_PATTERNS.filter(p => !giLines.some(l => l === p || l === '/' + p));
97
- if (missing.length) {
98
- secIssues.push(`.gitignore 시크릿 누락 ${missing.length}건`);
99
- // 누락이 .env 자체면 최우선 위험 15점 가중
100
- if (missing.includes('.env')) secScore += 30;
101
- else secScore += Math.min(20, missing.length * 5);
102
- }
103
- } catch {}
104
- if (secScore > 0) {
105
- totalScore += secScore;
106
- fired.push({ file: '.env / .gitignore', ageDays: null, threshold: 0, weight: secScore, label: `보안 위험 (1.9.78): ${secIssues.join(' · ')}` });
107
- }
108
- }
109
- } catch {}
110
- // 1.9.143: Feature Graph 미사용 신호 — 노드는 있는데 edges 비율 낮으면 인과관계 정리 미진
111
- try {
112
- const { nodes: fGraphNodes } = _readFeatureGraph(root);
113
- if (fGraphNodes.length >= 3) {
114
- const edgeCount = fGraphNodes.reduce((s, n) => s + (n.dependsOn?.length || 0) + (n.affects?.length || 0) + (n.coChangesWith?.length || 0), 0);
115
- const linkedSet = new Set();
116
- for (const n of fGraphNodes) {
117
- for (const x of [...(n.dependsOn||[]), ...(n.affects||[]), ...(n.coChangesWith||[])]) { linkedSet.add(n.id); linkedSet.add(x); }
118
- }
119
- const isolatedCount = Math.max(0, fGraphNodes.length - linkedSet.size);
120
- const isolatedRatio = isolatedCount / fGraphNodes.length;
121
- if (edgeCount === 0 || isolatedRatio >= 0.5) {
122
- const fgScore = edgeCount === 0 ? 25 : 15;
123
- totalScore += fgScore;
124
- fired.push({ file: '.harness/feature-graph.md', ageDays: null, threshold: 0, weight: fgScore, label: `Feature Graph 미정리 (1.9.143): ${fGraphNodes.length} 노드, edges=${edgeCount}, isolated=${isolatedCount}` });
125
- }
126
- }
127
- } catch {}
128
- // 신규 _apps/* 에서 task 0건도 신호로
129
- const appsDir = path.join(root, '_apps');
130
- let appsZeroTask = [];
131
- if (exists(appsDir)) {
132
- for (const d of fs.readdirSync(appsDir)) {
133
- const sub = path.join(appsDir, d);
134
- if (!exists(path.join(sub, '.harness'))) continue;
135
- const subRows = readProgressRows(sub);
136
- if (!subRows.length) appsZeroTask.push(d);
137
- }
138
- if (appsZeroTask.length) {
139
- const w = Math.min(50, appsZeroTask.length * 10);
140
- totalScore += w;
141
- fired.push({ file: `_apps/* (${appsZeroTask.length}개)`, ageDays: null, threshold: 0, weight: w, label: `task 0건 sub-app: ${appsZeroTask.slice(0, 3).join(', ')}${appsZeroTask.length > 3 ? '...' : ''}` });
142
- }
143
- }
144
- // 레벨 판정
145
- let level = '🟢 healthy';
146
- if (totalScore >= 100) level = '🔴 critical';
147
- else if (totalScore >= 50) level = '🟡 warning';
148
- else if (totalScore >= 20) level = '🟠 attention';
149
-
150
- // 1.9.38 (D): drift critical 등급은 누적 카운트 (학습 신호)
151
- try {
152
- if (level === '🔴 critical') {
153
- const stats = _readUsageStats(root);
154
- stats.drift = stats.drift || {};
155
- stats.drift.criticalSeen = (stats.drift.criticalSeen || 0) + 1;
156
- const p = _usageStatsPath(root);
157
- mkdirp(path.dirname(p));
158
- writeUtf8(p, JSON.stringify(stats, null, 2) + '\n');
159
- }
160
- } catch {}
161
- // 1.9.39: --auto-fix — critical 시 session close 자동 실행
162
- // 1.9.82: --auto-fix 보안 신호도 자동 회복 (audit --fix 호출)
163
- // 1.9.432 (10th 외부평가 Opus latent, UR-0131 잔여): depth 가드 재귀 호출(_noAutoFix)은 auto-fix 재진입 금지.
164
- // 기존엔 autoFix=has('--auto-fix')가 전역 argv 재독→재귀도 auto-fix 분기 재진입, 종료는 'audit이 보안신호를 지운다'는 취약 불변식에 의존(미래 신호 타입이 비가역이면 무한재귀). 명시 1회 보장.
165
- const autoFix = has('--auto-fix') && !opts._noAutoFix;
166
- // 1.9.439 (10th 외부평가 Codex P1, UR-0135): --json 모드면 auto-fix 진행로그 억제(stdout 순수 JSON 보장).
167
- // 재귀(_noAutoFix)는 auto-fix 블록을 건너뛰고 마지막 JSON(아래 has('--json') 블록)만 출력 afLog 패스 진행로그만 무음화.
168
- const afLog = has('--json') ? () => {} : log;
169
- // 1.9.82: 보안 신호가 fired에 있으면 우선 audit --fix 호출
170
- const hasSecurityFired = fired.some(f => /보안 위험 \(1\.9\.78\)/.test(f.label));
171
- if (autoFix && hasSecurityFired) {
172
- afLog('');
173
- afLog(`🔒 --auto-fix 활성 (1.9.82) — 보안 신호 회복: audit --fix 자동 실행 중...`);
174
- try {
175
- const r = cp.spawnSync(process.execPath, [harnessPath, 'audit', root, '--fix'],
176
- { encoding: 'utf8', timeout: 30000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
177
- if (r.status === 0) {
178
- afLog(`✓ audit --fix 완료 — .gitignore + .env.example 동기화`);
179
- // 재검사 (보안 신호 회복 확인)
180
- afLog('');
181
- afLog(`재검사 중...`);
182
- return driftCheckCmd(root, { ...opts, _noAutoFix: true }, deps); // 재귀 1회 (auto-fix 없이, 1.9.432 depth 가드)
183
- } else {
184
- afLog(`⚠ audit --fix 실패 (exit ${r.status}) — 수동 \`leerness audit --fix\` 권장`);
185
- }
186
- } catch (e) {
187
- afLog(`⚠ auto-fix 보안 회복 오류: ${e.message}`);
188
- }
189
- }
190
- // 1.9.242: drift check --auto-fix 에 env encoding BOM 자동 추가 통합 (사용자 명시 UR-0014 2단계)
191
- // 1.9.82 패턴 확장 drift 회복 스크립트 인코딩 위험도 자동 해결
192
- if (autoFix) {
193
- try {
194
- const encScan = _scanShellScriptsEncoding(root);
195
- if (encScan.atRisk && encScan.atRisk.length > 0) {
196
- afLog('');
197
- afLog(`🌐 --auto-fix 활성 (1.9.242) — 셸 스크립트 인코딩 위험 ${encScan.atRisk.length}건 BOM 자동 추가 중...`);
198
- let ok = 0;
199
- for (const r of encScan.atRisk) {
200
- try {
201
- const fullPath = path.join(root, r.file);
202
- const orig = fs.readFileSync(fullPath);
203
- const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
204
- const fixed = Buffer.concat([bom, orig]);
205
- fs.writeFileSync(fullPath, fixed);
206
- ok++;
207
- } catch {}
208
- }
209
- afLog(`✓ UTF-8 BOM 추가 ${ok}/${encScan.atRisk.length}건 (1.9.242 UR-0014)`);
210
- }
211
- } catch (e) {
212
- afLog(`⚠ env encoding auto-fix 오류 (1.9.242): ${e.message}`);
213
- }
214
- }
215
- // 1.9.225: drift check --auto-fix 에 delivered 패턴 자동 적용 통합 (1.9.223/224 시스템 회수)
216
- // 사용자 요청에 "구현 완료" 패턴이 누적되면 가짜 미답 신호가 drift score 가중시킬 수 있음 → 자동 정리.
217
- // 1.9.82 audit --fix 패턴과 동일: --auto-fix 즉시 적용, 적용 재검사.
218
- if (autoFix) {
219
- try {
220
- const delivered = _detectDeliveredRequests(root);
221
- if (delivered.candidates && delivered.candidates.length > 0) {
222
- afLog('');
223
- afLog(`📥 --auto-fix 활성 (1.9.225) — delivered 패턴 ${delivered.candidates.length}건 자동 완료 중...`);
224
- let ok = 0;
225
- for (const c of delivered.candidates) {
226
- const u = _updateUserRequest(root, c.id, { status: 'completed', autoCompletedAt: new Date().toISOString(), autoCompleteReason: 'drift-auto-fix-1.9.225' });
227
- if (u) ok++;
228
- }
229
- afLog(`✓ delivered 자동 완료 ${ok}/${delivered.candidates.length}건`);
230
- }
231
- } catch (e) {
232
- afLog(`⚠ delivered auto-apply 오류 (1.9.225): ${e.message}`);
233
- }
234
- }
235
- // 1.9.293: drift check --auto-fix 에 idempotency task/user-request 중복 자동 정리 통합
236
- // 누적 중복 task/요청이 idempotency 위반(medium)을 가중 drift/handoff 노이즈. 안전: 완전중복 행 제거 + 동일텍스트 dropped 보존(id 유지).
237
- if (autoFix) {
238
- try {
239
- const idemFixes = _autoFixIdempotency(root);
240
- const totalFixed = idemFixes.reduce((n, f) => n + (f.removedExact || 0) + (f.droppedSameText || 0) + (f.count || 0), 0);
241
- if (totalFixed > 0) {
242
- afLog('');
243
- afLog(`🔁 --auto-fix 활성 (1.9.293) — idempotency 중복 ${totalFixed}건 자동 정리 (task/user-request dedup)`);
244
- }
245
- } catch (e) {
246
- afLog(`⚠ idempotency auto-fix 오류 (1.9.293): ${e.message}`);
247
- }
248
- }
249
- // 1.9.236: drift check --auto-fix 에 release cleanup 통합 (1.9.235 회수)
250
- // 누적된 50개+ release/* branches → abnormal-shutdown release-branch-pending 신호 가중
251
- // 안전: keep 10 (최근 10개 유지), merged 삭제 (1.9.235 안전 가드)
252
- // 임계: 50초과 시만 자동 정리 (소량 누적은 정상 운영)
253
- if (autoFix) {
254
- try {
255
- const branchR = cp.spawnSync('git', ['branch', '--merged', 'main', '--list', 'release/*'], { cwd: root, encoding: 'utf8' });
256
- if (branchR.status === 0) {
257
- const merged = (branchR.stdout || '').split('\n')
258
- .map(l => l.replace(/^\*?\s+/, '').trim())
259
- .filter(l => l && /^release\/\d+\.\d+\.\d+$/.test(l));
260
- if (merged.length > 50) {
261
- afLog('');
262
- afLog(`🗑 --auto-fix 활성 (1.9.236) — release/* merged ${merged.length}개 (50+) 자동 정리 (keep 10)...`);
263
- // 정렬 (semver desc)
264
- merged.sort((a, b) => {
265
- const va = a.replace('release/', '').split('.').map(n => parseInt(n, 10) || 0);
266
- const vb = b.replace('release/', '').split('.').map(n => parseInt(n, 10) || 0);
267
- for (let i = 0; i < 3; i++) if (va[i] !== vb[i]) return vb[i] - va[i];
268
- return 0;
269
- });
270
- const currentBranchR = cp.spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8' });
271
- const currentBranch = (currentBranchR.stdout || '').trim();
272
- const toDelete = merged.slice(10).filter(b => b !== currentBranch);
273
- let ok = 0;
274
- for (const b of toDelete) {
275
- const r = cp.spawnSync('git', ['branch', '-d', b], { cwd: root, encoding: 'utf8' });
276
- if (r.status === 0) ok++;
277
- }
278
- afLog(`✓ release cleanup 자동 완료 ${ok}/${toDelete.length}건 (keep 10)`);
279
- }
280
- }
281
- } catch (e) {
282
- afLog(`⚠ release cleanup auto-fix 오류 (1.9.236): ${e.message}`);
283
- }
284
- }
285
- if (autoFix && level === '🔴 critical' && !hasSecurityFired) {
286
- afLog('');
287
- afLog(`🔧 --auto-fix 활성 — session close 자동 실행 중...`);
288
- try {
289
- const r = cp.spawnSync(process.execPath, [harnessPath, 'session', 'close', root], { encoding: 'utf8', timeout: 60000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
290
- if (r.status === 0) {
291
- afLog(`✓ session close 자동 완료`);
292
- // autoResolved 카운트
293
- const stats = _readUsageStats(root);
294
- stats.drift = stats.drift || {};
295
- stats.drift.autoResolved = (stats.drift.autoResolved || 0) + 1;
296
- const p = _usageStatsPath(root);
297
- mkdirp(path.dirname(p));
298
- writeUtf8(p, JSON.stringify(stats, null, 2) + '\n');
299
- // 재검사
300
- afLog('');
301
- afLog(`재검사 중...`);
302
- return driftCheckCmd(root, { ...opts, _noAutoFix: true }, deps); // 재귀 1회 (auto-fix 없이, 1.9.432 depth 가드)
303
- } else {
304
- afLog(`⚠ session close 실패 (exit ${r.status}) — 수동 실행 필요`);
305
- }
306
- } catch (e) {
307
- afLog(`⚠ auto-fix 오류: ${e.message}`);
308
- }
309
- }
310
- if (has('--json')) {
311
- log(JSON.stringify({ root, score: totalScore, level, signals, fired, appsZeroTask }, null, 2));
312
- return;
313
- }
314
- log(`# leerness drift check (1.9.37)`);
315
- log(`경로: ${root}`);
316
- log('');
317
- log(`상태: ${level} · 점수 ${totalScore}/200`);
318
- log('');
319
- log(`| 신호 | age | 임계 | 가중치 | 발화 |`);
320
- log(`|---|---:|---:|---:|---|`);
321
- for (const s of signals) {
322
- const fire = s.ageDays > s.threshold ? '🔥' : '✓';
323
- const age = s.ageDays === null ? '-' : `${s.ageDays.toFixed(1)}d`;
324
- log(`| ${s.label} | ${age} | ${s.threshold}d | ${s.weight} | ${fire} |`);
325
- }
326
- if (appsZeroTask.length) {
327
- log('');
328
- log(`task 0건 sub-app (${appsZeroTask.length}개): ${appsZeroTask.join(', ')}`);
329
- }
330
- if (totalScore >= 50) {
331
- log('');
332
- log(`💡 권장 조치:`);
333
- log(` - 즉시: leerness session close . (handoff/current-state 갱신)`);
334
- log(` - 또는: leerness audit . --fix (자동 갱신 가능 항목 적용)`);
335
- log(` - sub-app에 task 등록: cd _apps/X && leerness task add "..."`);
336
- log(` - 검사 끄기: --no-drift-check 또는 LEERNESS_NO_DRIFT_CHECK=1`);
337
- }
338
- if (level === '🔴 critical') process.exitCode = 1;
339
- }
340
-
341
- module.exports = { driftCheckCmd };
1
+ // lib/drift.js — drift check 핸들러 (UR-0025/UR-0125 큰 핸들러 모듈화 7번째, 1.9.422)
2
+ // bin/leerness.js 에서 driftCheckCmd(322줄) 분리. DI: harness 고유 의존(VERSION, has, arg, harnessPath, readProgressRows, planPath, handoffPath, currentStatePath, _usageStatsPath, _readUsageStats, _updateUserRequest, _scanShellScriptsEncoding, _readFeatureGraph, _detectDeliveredRequests, _autoFixIdempotency) 주입.
3
+ // io 프리미티브는 ./io, cp/path 빌트인. 내부 재귀(auto-fix 후 재검사)는 deps 전달. 동작/출력 무변경.
4
+ 'use strict';
5
+ const cp = require('child_process');
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+ const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
9
+
10
+ function driftCheckCmd(root, opts = {}, deps = {}) {
11
+ const { VERSION, has, arg, uiLang, harnessPath, readProgressRows, planPath, handoffPath, currentStatePath, taskLogPath, envDiff, _usageStatsPath, _readUsageStats, _updateUserRequest, _scanShellScriptsEncoding, _readFeatureGraph, _detectDeliveredRequests, _autoFixIdempotency } = deps;
12
+ root = absRoot(root || process.cwd());
13
+ const t = (ko, en) => (uiLang === 'en' ? en : ko); // 1.27.2 (UR-0010 Phase 10): drift 출력 영어 opt-in (--auto-fix 진행로그는 Phase 10b)
14
+ // 1.9.434 (11th 외부평가 Opus P2, UR-0136): 미존재 경로는 healthy 위조 금지 — failJson + exit 1.
15
+ if (!exists(root)) { failJson(has('--json'), 'path_not_found', t(`경로 없음: ${root}`, `path not found: ${root}`)); return; }
16
+ const now = Date.now();
17
+ const _ageDays = (p) => {
18
+ if (!exists(p)) return null;
19
+ return (now - fs.statSync(p).mtimeMs) / 86400000;
20
+ };
21
+ // 메타파일의 마지막 갱신
22
+ const signals = [];
23
+ // 1. session-handoff.md - "Last generated" 라인 우선, 없으면 mtime
24
+ const shPath = handoffPath(root);
25
+ if (exists(shPath)) {
26
+ const txt = read(shPath);
27
+ // 1.9.316 (drift 마커 버그): 최신(마지막) 'Last generated' 사용 — 구 블록 중복 시 첫() 매치를 읽던 오발화 방어.
28
+ const allGen = [...txt.matchAll(/Last generated:\s*([\d\-T:.Z]+)/g)];
29
+ const m = allGen.length ? allGen[allGen.length - 1] : null;
30
+ let ageDays;
31
+ if (m) {
32
+ ageDays = (now - new Date(m[1]).getTime()) / 86400000;
33
+ } else {
34
+ ageDays = _ageDays(shPath);
35
+ }
36
+ signals.push({ file: 'session-handoff.md', ageDays, threshold: 1, weight: 30, label: t('session close 누락', 'session close missing') });
37
+ }
38
+ // 2. current-state.md - "Updated: YYYY-MM-DD" 라인
39
+ const csPath = currentStatePath(root);
40
+ if (exists(csPath)) {
41
+ const m = read(csPath).match(/Updated:\s*(\d{4}-\d{2}-\d{2})/);
42
+ const ageDays = m ? (now - new Date(m[1]).getTime()) / 86400000 : _ageDays(csPath);
43
+ signals.push({ file: 'current-state.md', ageDays, threshold: 2, weight: 20, label: t('current-state 갱신 없음', 'current-state not updated') });
44
+ }
45
+ // 3. progress-tracker.md 마지막 row의 updated 컬럼
46
+ const rows = readProgressRows(root);
47
+ if (rows.length) {
48
+ const dates = rows.map(r => (r.updated || '').match(/\d{4}-\d{2}-\d{2}/)).filter(Boolean).map(m => m[0]);
49
+ if (dates.length) {
50
+ dates.sort();
51
+ const latest = dates[dates.length - 1];
52
+ const ageDays = (now - new Date(latest).getTime()) / 86400000;
53
+ signals.push({ file: 'progress-tracker.md', ageDays, threshold: 1, weight: 30, label: t('task update 없음', 'no task update') });
54
+ }
55
+ } else {
56
+ signals.push({ file: 'progress-tracker.md', ageDays: 999, threshold: 1, weight: 25, label: t('progress-tracker 비어있음', 'progress-tracker empty') });
57
+ }
58
+ // 4. task-log.md 마지막 entry "## YYYY-MM-DD"
59
+ const tlPath = taskLogPath(root);
60
+ if (exists(tlPath)) {
61
+ const dates = Array.from(read(tlPath).matchAll(/^## (\d{4}-\d{2}-\d{2})/gm)).map(m => m[1]);
62
+ if (dates.length) {
63
+ dates.sort();
64
+ const latest = dates[dates.length - 1];
65
+ const ageDays = (now - new Date(latest).getTime()) / 86400000;
66
+ signals.push({ file: 'task-log.md', ageDays, threshold: 2, weight: 20, label: t('task-log 갱신 없음', 'task-log not updated') });
67
+ }
68
+ }
69
+ // 점수 계산
70
+ let totalScore = 0;
71
+ const fired = [];
72
+ for (const s of signals) {
73
+ if (s.ageDays > s.threshold) {
74
+ totalScore += s.weight;
75
+ fired.push(s);
76
+ }
77
+ }
78
+ // 1.9.78: 보안 신호 (env / .gitignore 누락) — 5번째 신호
79
+ try {
80
+ const envPath = path.join(root, '.env');
81
+ if (exists(envPath)) {
82
+ let secScore = 0;
83
+ const secIssues = [];
84
+ // (a) .env vs .env.example 동기화
85
+ try {
86
+ const d = envDiff(root);
87
+ if (d.inEnvOnly.length) {
88
+ secIssues.push(t(`.env→.env.example 누락 ${d.inEnvOnly.length}건`, `.env→.env.example missing ${d.inEnvOnly.length}`));
89
+ secScore += 15;
90
+ }
91
+ } catch {}
92
+ // (b) .gitignore 시크릿 패턴
93
+ try {
94
+ const giText = exists(path.join(root, '.gitignore')) ? read(path.join(root, '.gitignore')) : '';
95
+ const giLines = giText.split('\n').map(l => l.trim());
96
+ const SECRET_PATTERNS = ['.env', '.env.local', '.env.production', '.env.*.local', '*.pem', 'credentials.json'];
97
+ const missing = SECRET_PATTERNS.filter(p => !giLines.some(l => l === p || l === '/' + p));
98
+ if (missing.length) {
99
+ secIssues.push(t(`.gitignore 시크릿 누락 ${missing.length}건`, `.gitignore missing secret patterns ${missing.length}`));
100
+ // 누락이 .env 자체면 최우선 위험 — 15점 가중
101
+ if (missing.includes('.env')) secScore += 30;
102
+ else secScore += Math.min(20, missing.length * 5);
103
+ }
104
+ } catch {}
105
+ if (secScore > 0) {
106
+ totalScore += secScore;
107
+ fired.push({ file: '.env / .gitignore', ageDays: null, threshold: 0, weight: secScore, label: t(`보안 위험 (1.9.78): ${secIssues.join(' · ')}`, `security risk: ${secIssues.join(' · ')}`) });
108
+ }
109
+ }
110
+ } catch {}
111
+ // 1.9.143: Feature Graph 미사용 신호 — 노드는 있는데 edges 비율 낮으면 인과관계 정리 미진
112
+ try {
113
+ const { nodes: fGraphNodes } = _readFeatureGraph(root);
114
+ if (fGraphNodes.length >= 3) {
115
+ const edgeCount = fGraphNodes.reduce((s, n) => s + (n.dependsOn?.length || 0) + (n.affects?.length || 0) + (n.coChangesWith?.length || 0), 0);
116
+ const linkedSet = new Set();
117
+ for (const n of fGraphNodes) {
118
+ for (const x of [...(n.dependsOn||[]), ...(n.affects||[]), ...(n.coChangesWith||[])]) { linkedSet.add(n.id); linkedSet.add(x); }
119
+ }
120
+ const isolatedCount = Math.max(0, fGraphNodes.length - linkedSet.size);
121
+ const isolatedRatio = isolatedCount / fGraphNodes.length;
122
+ if (edgeCount === 0 || isolatedRatio >= 0.5) {
123
+ const fgScore = edgeCount === 0 ? 25 : 15;
124
+ totalScore += fgScore;
125
+ fired.push({ file: '.harness/feature-graph.md', ageDays: null, threshold: 0, weight: fgScore, label: t(`Feature Graph 미정리 (1.9.143): ${fGraphNodes.length} 노드, edges=${edgeCount}, isolated=${isolatedCount}`, `Feature Graph unlinked: ${fGraphNodes.length} nodes, edges=${edgeCount}, isolated=${isolatedCount}`) });
126
+ }
127
+ }
128
+ } catch {}
129
+ // 신규 _apps/* 에서 task 0건도 신호로
130
+ const appsDir = path.join(root, '_apps');
131
+ let appsZeroTask = [];
132
+ if (exists(appsDir)) {
133
+ for (const d of fs.readdirSync(appsDir)) {
134
+ const sub = path.join(appsDir, d);
135
+ if (!exists(path.join(sub, '.harness'))) continue;
136
+ const subRows = readProgressRows(sub);
137
+ if (!subRows.length) appsZeroTask.push(d);
138
+ }
139
+ if (appsZeroTask.length) {
140
+ const w = Math.min(50, appsZeroTask.length * 10);
141
+ totalScore += w;
142
+ fired.push({ file: `_apps/* (${appsZeroTask.length}개)`, ageDays: null, threshold: 0, weight: w, label: t(`task 0건 sub-app: ${appsZeroTask.slice(0, 3).join(', ')}${appsZeroTask.length > 3 ? '...' : ''}`, `sub-app with 0 tasks: ${appsZeroTask.slice(0, 3).join(', ')}${appsZeroTask.length > 3 ? '...' : ''}`) });
143
+ }
144
+ }
145
+ // 레벨 판정
146
+ let level = '🟢 healthy';
147
+ if (totalScore >= 100) level = '🔴 critical';
148
+ else if (totalScore >= 50) level = '🟡 warning';
149
+ else if (totalScore >= 20) level = '🟠 attention';
150
+
151
+ // 1.9.38 (D): drift critical 등급은 누적 카운트 (학습 신호)
152
+ try {
153
+ if (level === '🔴 critical') {
154
+ const stats = _readUsageStats(root);
155
+ stats.drift = stats.drift || {};
156
+ stats.drift.criticalSeen = (stats.drift.criticalSeen || 0) + 1;
157
+ const p = _usageStatsPath(root);
158
+ mkdirp(path.dirname(p));
159
+ writeUtf8(p, JSON.stringify(stats, null, 2) + '\n');
160
+ }
161
+ } catch {}
162
+ // 1.9.39: --auto-fix critical session close 자동 실행
163
+ // 1.9.82: --auto-fix가 보안 신호도 자동 회복 (audit --fix 호출)
164
+ // 1.9.432 (10th 외부평가 Opus latent, UR-0131 잔여): depth 가드 재귀 호출(_noAutoFix) auto-fix 재진입 금지.
165
+ // 기존엔 autoFix=has('--auto-fix') 전역 argv 재독→재귀도 auto-fix 분기 재진입, 종료는 'audit이 보안신호를 지운다'는 취약 불변식에 의존(미래 신호 타입이 비가역이면 무한재귀). 명시 1회 보장.
166
+ const autoFix = has('--auto-fix') && !opts._noAutoFix;
167
+ // 1.9.439 (10th 외부평가 Codex P1, UR-0135): --json 모드면 auto-fix 진행로그 억제(stdout 순수 JSON 보장).
168
+ // 재귀(_noAutoFix)는 auto-fix 블록을 건너뛰고 마지막 JSON(아래 has('--json') 블록) 출력 afLog 로 첫 패스 진행로그만 무음화.
169
+ const afLog = has('--json') ? () => {} : log;
170
+ // 1.9.82: 보안 신호가 fired 있으면 우선 audit --fix 호출
171
+ const hasSecurityFired = fired.some(f => /보안 위험 \(1\.9\.78\)/.test(f.label));
172
+ if (autoFix && hasSecurityFired) {
173
+ afLog('');
174
+ afLog(`🔒 --auto-fix 활성 (1.9.82) — 보안 신호 회복: audit --fix 자동 실행 중...`);
175
+ try {
176
+ const r = cp.spawnSync(process.execPath, [harnessPath, 'audit', root, '--fix'],
177
+ { encoding: 'utf8', timeout: 30000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
178
+ if (r.status === 0) {
179
+ afLog(`✓ audit --fix 완료 .gitignore + .env.example 동기화`);
180
+ // 재검사 (보안 신호 회복 확인)
181
+ afLog('');
182
+ afLog(`재검사 중...`);
183
+ return driftCheckCmd(root, { ...opts, _noAutoFix: true }, deps); // 재귀 1회 (auto-fix 없이, 1.9.432 depth 가드)
184
+ } else {
185
+ afLog(`⚠ audit --fix 실패 (exit ${r.status}) — 수동 \`leerness audit --fix\` 권장`);
186
+ }
187
+ } catch (e) {
188
+ afLog(`⚠ auto-fix 보안 회복 오류: ${e.message}`);
189
+ }
190
+ }
191
+ // 1.9.242: drift check --auto-fix env encoding BOM 자동 추가 통합 (사용자 명시 UR-0014 2단계)
192
+ // 1.9.82 패턴 확장 — drift 회복 시 셸 스크립트 인코딩 위험도 자동 해결
193
+ if (autoFix) {
194
+ try {
195
+ const encScan = _scanShellScriptsEncoding(root);
196
+ if (encScan.atRisk && encScan.atRisk.length > 0) {
197
+ afLog('');
198
+ afLog(`🌐 --auto-fix 활성 (1.9.242) — 셸 스크립트 인코딩 위험 ${encScan.atRisk.length}건 BOM 자동 추가 중...`);
199
+ let ok = 0;
200
+ for (const r of encScan.atRisk) {
201
+ try {
202
+ const fullPath = path.join(root, r.file);
203
+ const orig = fs.readFileSync(fullPath);
204
+ const bom = Buffer.from([0xEF, 0xBB, 0xBF]);
205
+ const fixed = Buffer.concat([bom, orig]);
206
+ fs.writeFileSync(fullPath, fixed);
207
+ ok++;
208
+ } catch {}
209
+ }
210
+ afLog(`✓ UTF-8 BOM 추가 ${ok}/${encScan.atRisk.length}건 (1.9.242 UR-0014)`);
211
+ }
212
+ } catch (e) {
213
+ afLog(`⚠ env encoding auto-fix 오류 (1.9.242): ${e.message}`);
214
+ }
215
+ }
216
+ // 1.9.225: drift check --auto-fix delivered 패턴 자동 적용 통합 (1.9.223/224 시스템 회수)
217
+ // 사용자 요청에 "구현 완료" 패턴이 누적되면 가짜 미답 신호가 drift score 를 가중시킬 수 있음 → 자동 정리.
218
+ // 1.9.82 audit --fix 패턴과 동일: --auto-fix 시 즉시 적용, 적용 후 재검사.
219
+ if (autoFix) {
220
+ try {
221
+ const delivered = _detectDeliveredRequests(root);
222
+ if (delivered.candidates && delivered.candidates.length > 0) {
223
+ afLog('');
224
+ afLog(`📥 --auto-fix 활성 (1.9.225) — delivered 패턴 ${delivered.candidates.length}건 자동 완료 중...`);
225
+ let ok = 0;
226
+ for (const c of delivered.candidates) {
227
+ const u = _updateUserRequest(root, c.id, { status: 'completed', autoCompletedAt: new Date().toISOString(), autoCompleteReason: 'drift-auto-fix-1.9.225' });
228
+ if (u) ok++;
229
+ }
230
+ afLog(`✓ delivered 자동 완료 ${ok}/${delivered.candidates.length}건`);
231
+ }
232
+ } catch (e) {
233
+ afLog(`⚠ delivered auto-apply 오류 (1.9.225): ${e.message}`);
234
+ }
235
+ }
236
+ // 1.9.293: drift check --auto-fix idempotency task/user-request 중복 자동 정리 통합
237
+ // 누적 중복 task/요청이 idempotency 위반(medium) 가중 → drift/handoff 노이즈. 안전: 완전중복 행 제거 + 동일텍스트 dropped 보존(id 유지).
238
+ if (autoFix) {
239
+ try {
240
+ const idemFixes = _autoFixIdempotency(root);
241
+ const totalFixed = idemFixes.reduce((n, f) => n + (f.removedExact || 0) + (f.droppedSameText || 0) + (f.count || 0), 0);
242
+ if (totalFixed > 0) {
243
+ afLog('');
244
+ afLog(`🔁 --auto-fix 활성 (1.9.293) — idempotency 중복 ${totalFixed}건 자동 정리 (task/user-request dedup)`);
245
+ }
246
+ } catch (e) {
247
+ afLog(`⚠ idempotency auto-fix 오류 (1.9.293): ${e.message}`);
248
+ }
249
+ }
250
+ // 1.9.236: drift check --auto-fix release cleanup 통합 (1.9.235 회수)
251
+ // 누적된 50개+ release/* branches abnormal-shutdown release-branch-pending 신호 가중
252
+ // 안전: keep 10 (최근 10유지), merged 삭제 (1.9.235 안전 가드)
253
+ // 임계: 50개 초과 시만 자동 정리 (소량 누적은 정상 운영)
254
+ if (autoFix) {
255
+ try {
256
+ const branchR = cp.spawnSync('git', ['branch', '--merged', 'main', '--list', 'release/*'], { cwd: root, encoding: 'utf8' });
257
+ if (branchR.status === 0) {
258
+ const merged = (branchR.stdout || '').split('\n')
259
+ .map(l => l.replace(/^\*?\s+/, '').trim())
260
+ .filter(l => l && /^release\/\d+\.\d+\.\d+$/.test(l));
261
+ if (merged.length > 50) {
262
+ afLog('');
263
+ afLog(`🗑 --auto-fix 활성 (1.9.236) — release/* merged ${merged.length}개 (50+) 자동 정리 (keep 10)...`);
264
+ // 정렬 (semver desc)
265
+ merged.sort((a, b) => {
266
+ const va = a.replace('release/', '').split('.').map(n => parseInt(n, 10) || 0);
267
+ const vb = b.replace('release/', '').split('.').map(n => parseInt(n, 10) || 0);
268
+ for (let i = 0; i < 3; i++) if (va[i] !== vb[i]) return vb[i] - va[i];
269
+ return 0;
270
+ });
271
+ const currentBranchR = cp.spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8' });
272
+ const currentBranch = (currentBranchR.stdout || '').trim();
273
+ const toDelete = merged.slice(10).filter(b => b !== currentBranch);
274
+ let ok = 0;
275
+ for (const b of toDelete) {
276
+ const r = cp.spawnSync('git', ['branch', '-d', b], { cwd: root, encoding: 'utf8' });
277
+ if (r.status === 0) ok++;
278
+ }
279
+ afLog(`✓ release cleanup 자동 완료 ${ok}/${toDelete.length}건 (keep 10)`);
280
+ }
281
+ }
282
+ } catch (e) {
283
+ afLog(`⚠ release cleanup auto-fix 오류 (1.9.236): ${e.message}`);
284
+ }
285
+ }
286
+ if (autoFix && level === '🔴 critical' && !hasSecurityFired) {
287
+ afLog('');
288
+ afLog(`🔧 --auto-fix 활성 — session close 자동 실행 중...`);
289
+ try {
290
+ const r = cp.spawnSync(process.execPath, [harnessPath, 'session', 'close', root], { encoding: 'utf8', timeout: 60000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
291
+ if (r.status === 0) {
292
+ afLog(`✓ session close 자동 완료`);
293
+ // autoResolved 카운트
294
+ const stats = _readUsageStats(root);
295
+ stats.drift = stats.drift || {};
296
+ stats.drift.autoResolved = (stats.drift.autoResolved || 0) + 1;
297
+ const p = _usageStatsPath(root);
298
+ mkdirp(path.dirname(p));
299
+ writeUtf8(p, JSON.stringify(stats, null, 2) + '\n');
300
+ // 재검사
301
+ afLog('');
302
+ afLog(`재검사 중...`);
303
+ return driftCheckCmd(root, { ...opts, _noAutoFix: true }, deps); // 재귀 1회 (auto-fix 없이, 1.9.432 depth 가드)
304
+ } else {
305
+ afLog(`⚠ session close 실패 (exit ${r.status}) — 수동 실행 필요`);
306
+ }
307
+ } catch (e) {
308
+ afLog(`⚠ auto-fix 오류: ${e.message}`);
309
+ }
310
+ }
311
+ if (has('--json')) {
312
+ log(JSON.stringify({ root, score: totalScore, level, signals, fired, appsZeroTask }, null, 2));
313
+ return;
314
+ }
315
+ log(`# leerness drift check (1.9.37)`);
316
+ log(t(`경로: ${root}`, `path: ${root}`));
317
+ log('');
318
+ log(t(`상태: ${level} · 점수 ${totalScore}/200`, `status: ${level} · score ${totalScore}/200`));
319
+ log('');
320
+ log(t(`| 신호 | age | 임계 | 가중치 | 발화 |`, `| signal | age | threshold | weight | fired |`));
321
+ log(`|---|---:|---:|---:|---|`);
322
+ for (const s of signals) {
323
+ const fire = s.ageDays > s.threshold ? '🔥' : '✓';
324
+ const age = s.ageDays === null ? '-' : `${s.ageDays.toFixed(1)}d`;
325
+ log(`| ${s.label} | ${age} | ${s.threshold}d | ${s.weight} | ${fire} |`);
326
+ }
327
+ if (appsZeroTask.length) {
328
+ log('');
329
+ log(t(`task 0건 sub-app (${appsZeroTask.length}개): ${appsZeroTask.join(', ')}`, `sub-apps with 0 tasks (${appsZeroTask.length}): ${appsZeroTask.join(', ')}`));
330
+ }
331
+ if (totalScore >= 50) {
332
+ log('');
333
+ log(t(`💡 권장 조치:`, `💡 recommended actions:`));
334
+ log(t(` - 즉시: leerness session close . (handoff/current-state 갱신)`, ` - now: leerness session close . (refresh handoff/current-state)`));
335
+ log(t(` - 또는: leerness audit . --fix (자동 갱신 가능 항목 적용)`, ` - or: leerness audit . --fix (apply auto-fixable items)`));
336
+ log(t(` - sub-app에 task 등록: cd _apps/X && leerness task add "..."`, ` - add tasks to a sub-app: cd _apps/X && leerness task add "..."`));
337
+ log(t(` - 이 검사 끄기: --no-drift-check 또는 LEERNESS_NO_DRIFT_CHECK=1`, ` - disable this check: --no-drift-check or LEERNESS_NO_DRIFT_CHECK=1`));
338
+ }
339
+ if (level === '🔴 critical') process.exitCode = 1;
340
+ }
341
+
342
+ module.exports = { driftCheckCmd };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.27.0",
3
+ "version": "1.28.0",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -6230,10 +6230,14 @@ total++;
6230
6230
  const hEn = out(cp.spawnSync(process.execPath, [CLI, 'health', '--language', 'en', '--path', d], { encoding: 'utf8', timeout: 20000 }));
6231
6231
  const hKo = out(cp.spawnSync(process.execPath, [CLI, 'health', '--path', d], { encoding: 'utf8', timeout: 20000 }));
6232
6232
  const healthOk = /## Security/.test(hEn) && !H.test(hEn) && /## 보안/.test(hKo);
6233
+ // ⑥ (1.27.2 Phase 10) drift check 출력: en 영어(한글 0, --auto-fix 제외) + ko 기본 한글 보존
6234
+ const drEn = out(cp.spawnSync(process.execPath, [CLI, 'drift', 'check', d, '--language', 'en'], { encoding: 'utf8', timeout: 20000 }));
6235
+ const drKo = out(cp.spawnSync(process.execPath, [CLI, 'drift', 'check', d], { encoding: 'utf8', timeout: 20000 }));
6236
+ const driftOk = /signal \| age \| threshold/.test(drEn) && !H.test(drEn) && /신호 \| age \| 임계/.test(drKo);
6233
6237
  fs.rmSync(d, { recursive: true, force: true });
6234
- ok = lensKoOk && lensEnOk && noLeak && stOk && healthOk;
6238
+ ok = lensKoOk && lensEnOk && noLeak && stOk && healthOk && driftOk;
6235
6239
  } 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 행위 회귀 가드 실패');
6240
+ console.log(ok ? '✓ B(1.25.1/1.25.2/1.27.2) i18n 행위: --language en 런타임 영어(lens/health/drift) + ko 기본 보존 + --language positional 무누출 + status 에러 en/ko (UR-0010)' : '✗ i18n 행위 회귀 가드 실패');
6237
6241
  if (!ok) failed++;
6238
6242
  }
6239
6243
 
@@ -6270,5 +6274,40 @@ total++;
6270
6274
  if (!ok) failed++;
6271
6275
  }
6272
6276
 
6277
+ // 1.27.1 (13번째 외부리뷰 정직성 후속 회귀가드): audit 미초기화 모순출력 차단 + verify-claim no-parse 정직표기 (양방향 무회귀).
6278
+ total++;
6279
+ {
6280
+ let ok = false;
6281
+ try {
6282
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rev13b-'));
6283
+ const out = (r) => (r.stdout || '') + (r.stderr || '');
6284
+ // #2 audit 미초기화: design/reuse 모순 출력 없이 요약 직행 + exit 1 + --json not_initialized
6285
+ fs.mkdirSync(path.join(d, 'uninit'));
6286
+ const au = cp.spawnSync(process.execPath, [CLI, 'audit', path.join(d, 'uninit')], { encoding: 'utf8', timeout: 15000 });
6287
+ const auClean = au.status === 1 && !/design guide|reuse-map/.test(out(au)) && /Audit summary/.test(out(au));
6288
+ const auj = cp.spawnSync(process.execPath, [CLI, 'audit', path.join(d, 'uninit'), '--json'], { encoding: 'utf8', timeout: 15000 });
6289
+ let aujOk = false; try { const j = JSON.parse(auj.stdout); aujOk = j.healthy === false && (j.findings || []).some(f => f.kind === ('not_' + 'initialized')); } catch {}
6290
+ // #2 회귀: 정상 프로젝트 audit 는 체크 계속 수행
6291
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { encoding: 'utf8', timeout: 30000 });
6292
+ const auReal = out(cp.spawnSync(process.execPath, [CLI, 'audit', d], { encoding: 'utf8', timeout: 15000 }));
6293
+ const auRealOk = /Audit summary/.test(auReal) && /gitignore|design|reuse/.test(auReal);
6294
+ // #3 verify-claim 비-테스트 --test-cmd → 거짓 'all passed' 아님(정직 표기)
6295
+ fs.mkdirSync(path.join(d, 'src'), { recursive: true });
6296
+ fs.writeFileSync(path.join(d, 'src', 'x.js'), 'module.exports={};\n');
6297
+ fs.writeFileSync(path.join(d, 'x.test.js'), 'test();\n');
6298
+ cp.spawnSync(process.execPath, [CLI, 'task', 'add', 'x', '--path', d], { encoding: 'utf8', timeout: 15000 });
6299
+ cp.spawnSync(process.execPath, [CLI, 'task', 'update', 'T-0002', '--status', 'done', '--evidence', 'src/x.js implemented, x.test.js added', '--path', d], { encoding: 'utf8', timeout: 15000 });
6300
+ const vcNon = out(cp.spawnSync(process.execPath, [CLI, 'verify-claim', 'T-0002', '--run-tests', '--test-cmd', 'echo hi', '--path', d], { encoding: 'utf8', timeout: 20000 }));
6301
+ const vcNonOk = /미확인|unconfirmed/.test(vcNon) && !/echo hi.*all passed/.test(vcNon);
6302
+ // #3 회귀: 진짜 N/N 테스트 → all passed 유지
6303
+ const vcReal = out(cp.spawnSync(process.execPath, [CLI, 'verify-claim', 'T-0002', '--run-tests', '--test-cmd', 'echo Tests: 2 passed, 2 total', '--path', d], { encoding: 'utf8', timeout: 20000 }));
6304
+ const vcRealOk = /all passed/.test(vcReal);
6305
+ fs.rmSync(d, { recursive: true, force: true });
6306
+ ok = auClean && aujOk && auRealOk && vcNonOk && vcRealOk;
6307
+ } catch {}
6308
+ console.log(ok ? '✓ B(1.27.1) 13th 리뷰 정직성: audit 미초기화 모순출력 차단(+정상 무회귀) + verify-claim no-parse 정직표기(+진짜테스트 무회귀)' : '✗ 13th 리뷰 정직성 후속 회귀가드 실패');
6309
+ if (!ok) failed++;
6310
+ }
6311
+
6273
6312
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
6274
6313
  if (failed > 0) process.exit(1);