leerness 1.35.17 → 1.36.3

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,64 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.3 — 2026-07-08 — DB 안전성 렌즈 코어 승격 (race condition + ACID) — 사용자 명시 + codex 검수 채택
4
+
5
+ - 신규 내장 품질 렌즈 도메인 `database` (8 자기질문: lost update / check-then-act TOCTOU / atomicity / DB 제약 / isolation+retry / durability / distribution). `leerness lens database` 로 호출, verify-claim 이 DB 파일 변경 시 인라인 노출.
6
+ - `_lensDomainsForFiles`: DB 파일(`.sql`/`.prisma`/migrations/schema/models/repositories/dao/db·database 디렉토리/data-source·drizzle.config 등) → `database` 도메인 우선 매핑. 테스트 파일은 test 렌즈 유지(제외).
7
+ - `FILE_EXTS += sql|prisma` (verify-claim 파일 추출) + `_evidenceQuality += prisma` (파일 근거 인식) — `.sql`/`.prisma` 변경도 정상 검증.
8
+ - 저작: 6차원 워크플로 → 적대적 비평 → codex 독립 검수(SHIP-WITH-FIXES 8채택/1반박) → 게시본 재실증 → node:sqlite 데모 자가검증. selftest 276 + e2e-core 42 통과.
9
+
10
+ ## 1.36.2 — 2026-07-07 — 클린룸 후속: `feature add|show|link|impact` trailing positional path 존중 (조용한 cwd 오독 + stray `.harness` scaffold 차단)
11
+
12
+ **독립 클린룸 리뷰(1.35.17 게시본)가 재현한 P2 결함 소진.** 비-프로젝트 디렉토리에서 `leerness feature add "이름" <프로젝트경로>` 를 실행하면 성공 메시지는 프로젝트 경로를 되울리지만(→ 처리된 척), 실제 feature 는 **CWD** 에 떨어지고 그 자리에 **stray `.harness` 가 조용히 scaffold** 됐다. 즉 지정한 프로젝트가 아니라 아무 폴더나 오염시켰다. `--path <project>` 는 정상 동작했다.
13
+
14
+ ### 원인
15
+ - 디스패치(`bin/leerness.js`)가 `feature add` 를 `featureAddCmd(arg('--path', cwd), args.slice(2).filter(비-flag).join(' '))` 로 라우팅 — **모든 비-flag positional 이 NAME 으로 join** 되어 경로가 이름에 흡수되고 root 는 언제나 CWD. `feature list` 는 1.9.412(UR-0100)에서 `_resolveRoot(args[2])` 로 positional-path 를 이미 지원했지만 `add/show/link/impact` 는 갱신되지 않았다.
16
+ - `featureAddCmd` 의 `_ensureFeatureGraph(root)` 가 **무조건** `.harness/feature-graph.md` 를 생성 → 미초기화 폴더도 조용히 반쪽 상태로 오염.
17
+
18
+ ### 수정 — 두 겹 방어 (a) positional path 인식 (b) 미초기화 게이트
19
+ - **(a) trailing positional path 존중** — `add/show/link/impact` 모두 `--path > path-like positional(_taskPositionalPath) > cwd` 로 root 해석(기존 `task`/`rule add`/`feature list` 와 동일 규약). `add` 의 NAME 은 `_parseAddTitle(args, 2)` 로 첫 경로/플래그에서 절단해 경로 흡수를 차단(내부 슬래시 제목 `auth/login` 등은 선행 구분자만 경로로 보므로 보호).
20
+ - **(b) 미초기화 scaffold 게이트** — `featureAddCmd` 가 `_ensureFeatureGraph` 전에 `_requireInit(root, 'feature add')` 통과를 요구(=`task add` 와 동일). 워크스페이스 마커(HARNESS_VERSION/guideline.md/AGENTS.md) 없는 디렉토리는 **scaffold 대신 exit 1 + 안내**, `--force` 우회. `show/link/impact` 는 read-only(그래프 없으면 not-found/빈결과 — scaffold 안 함).
21
+ - **`_taskPositionalPath` 값-플래그 확장** — feature 값-플래그(`--files`/`--depends-on`/`--affects`/`--co-changes-with`)의 값이 path-like(`./src/x.js`)여도 root 로 오인하지 않도록 `_TASK_VALUE_FLAGS` 에 포함(예: `feature add "이름" --files ./src/a.js` 에서 `./src/a.js` 는 files 값이지 경로 아님).
22
+
23
+ ### 검증
24
+ - selftest **275**(+1: `_featRoot`/`_parseAddTitle` 디스패치 배선 + `_requireInit` 게이트 + `_taskPositionalPath` 값-플래그 skip; split-literal 앵커로 자기참조 false-pass 회피). e2e-core **+5**(positional 등록·이름 clean / stray scaffold 없음 / 미초기화 게이트 / show positional / `--path` 우선). full e2e **+1 회귀 블록**(add/show/link/impact positional + 미초기화 게이트 + `--files` path-like 값 오인 없음). 클린룸 재현(비-프로젝트 cwd)으로 fix 전/후 대조 확정 → 게시본 재실증.
25
+
26
+ ## 1.36.1 — 2026-07-07 — 클린룸 후속: 상태파일 JSON 무결성 체크 (health/check/audit false-negative 차단)
27
+
28
+ **1.36.0 클린룸 리뷰가 남긴 후속 백로그 소진** — 독립 리뷰어 A(P2, fails-safe)가 재현한 false-negative: `.harness/manifest.json` 을 깨진 JSON(`{ this is : not valid json ]]]`)으로 덮어써도 `health`/`doctor`/`check`/`audit` 가 전부 **"healthy" / exit 0** 을 반환. 원인은 모든 상태파일 로더가 `try{JSON.parse}catch{빈 상태}` 로 그레이스풀 폴백해(=크래시 없음, 좋은 성질) **손상을 조용히 빈 상태로 대체**하는데, 어떤 진단 명령도 상태 JSON 무결성을 별도로 검증하지 않던 갭.
29
+
30
+ ### 수정 — `.harness/*.json` JSON 무결성 검증 (신규 공유 헬퍼 `lib/state-integrity.js`)
31
+ - **단일출처 `findCorruptedStateJson(root)`** — `.harness` 바로 아래 `*.json`(manifest/decisions/lessons/skills-lock/active-wakeups/user-requests/…)을 열거해 `JSON.parse` 시도, 실패 파일 목록 반환. 비-재귀(archive/api-skills/cache 제외)·존재 파일만·빈/공백 파일 무오탐·BOM strip·**비-크래시**(파서 예외 흡수).
32
+ - **3 진단 표면에 배선** (심각도는 표면 성격에 맞춤):
33
+ - **`audit`** — `corrupted_state_json` finding(**warning**, `--strict` 시 failure 승격).
34
+ - **`health`** — `checks.stateIntegrity` + `issues` → `healthy:false`(**degraded 신호**, `--strict` 시 exit 1).
35
+ - **`check`** — pre-action 게이트이므로 **하드 실패(exit 1)**. 손상 상태를 통과시키지 않음.
36
+ - **`doctor` 는 무변경** — root 를 받지 않는 설치/환경 진단(selftest)이라 워크스페이스 상태 검증 범위 밖(설계상 분리).
37
+
38
+ ### 검증
39
+ - selftest **274**(+1: `findCorruptedStateJson` 탐지/무오탐/공유 배선 직접 검증). e2e-core **37**(+6: 클린 무오탐 + 손상 manifest → audit/health/check 3표면 표면화). full e2e **385**(+1 회귀 블록: 클린 무오탐 + 손상 표면화 + `audit --strict` 승격). 재현 확정 → 게시본 재실증.
40
+
41
+ ## 1.36.0 — 2026-07-07 — 🏁 안정 minor: gate 표면 전체 적대적 헌트 완주 (1.35.14~1.35.17 통합) + 독립 클린룸 검증
42
+
43
+ **codex 협업 7라운드로 gate 임베드 플래그십의 모든 체크 표면을 적대적 헌트 완주한 안정 릴리스**, 마지막으로 내 프레이밍 없는 **독립 블라인드 리뷰어 2명**으로 게시본(1.35.17)을 교차검증. 각 헌트 라운드 = 자체 프로브 + codex(gpt-5.5 xhigh) 독립 헌터 → 맹신 X 재현으로 확정만 수정 → FP-safe → 양-레벨 회귀가드(selftest+e2e-core) → 게시본 재실증.
44
+
45
+ ### 이 minor 에 묶인 gate 표면 sweep (전부 이미 patch 배포됨 — 안정 marker + 통합)
46
+ - **scan secrets (1.35.14):** 복합키(`DB_PASSWORD=`)/JSON 따옴표키(`"password":"x"`)/Django `SECRET_KEY`/개인키 변종 미탐 + 로컬 DB 기본자격·사전-단어 FP 억제.
47
+ - **encoding check (1.35.15):** 🔴 파괴적 `--apply` 가 CP949 파일에 BOM 만 붙여 손상시키던 것 차단(유효-UTF-8 만 BOM) + 순수-ASCII `.bat` FP + `.cmd` 미스캔 FN + BOM+깨진본문 은폐 FN.
48
+ - **lazy detect (1.35.16):** 안티-치트 우회 3종(status=completed evidence 회피, 아포스트로피/멀티라인 TODO 오판, 쓰레기 evidence) + 비-JS 테스트러너 FP.
49
+ - **audit (1.35.17):** 유저-프로젝트 FP 4종(`readme_synced` category error, `.env*` glob 무지, "rest" 오인, threshold-0 footgun).
50
+
51
+ ### 독립 클린룸 리뷰 반영 (블라인드 리뷰어 2명, 게시본 1.35.17 → 맹신 X 재현)
52
+ - **`--json` 성공 경로 무결성 (리뷰어 B, P2).** `task update`/`plan add`/`rule verify`/`reuse find` 가 `--json` 이어도 **성공 시 평문** 출력(에러 경로는 이미 구조화) → JSON 소비 스크립트가 성공 경로에서만 크래시. `task add`(1.9.413) 와 동일 emitter 로 4곳 보강. 재현 확정.
53
+ - **`migrate --force` 문구 정직화 (리뷰어 A, P2).** `--guide` 가 `--force` 를 "비파괴, 기존 내용 보존"이라 표기했으나 실제로는 관리 `.md` 를 템플릿으로 **교체**(기존 내용은 `.harness/archive` 백업, in-place 보존 아님) → 문구를 정직하게 수정(커스텀 보존은 `--force` 없이 migrate/`update --yes` 안내). 재현 확정.
54
+
55
+ ### 클린룸 발견 — 1.36.1 후속 백로그 (재현 확정했으나 behavior 변경이라 안정 릴리스에 미포함)
56
+ - **`feature add|show|link|impact` 가 positional `[path]` 무시 + CWD 에 stray `.harness` 스캐폴딩 (리뷰어 A, P2).** name 이 positional 이라 경로가 name 으로 흡수됨(`feature list` 는 1.9.412 로 이미 positional path 지원). arg-parsing 변경이라 focused 후속에서 처리.
57
+ - **`health`/`doctor`/`check` 가 손상된 `.harness/*.json` 에도 healthy 보고 (리뷰어 A, P2, fails-safe).** 상태파일 JSON 무결성 미검증(크래시는 없음 — graceful) → 진단 명령에 무결성 체크 추가는 신규 기능이라 후속.
58
+
59
+ ### 검증
60
+ - selftest 273. e2e-core **31**(플래그십 행위 + 클린룸 회귀). full e2e 384. 독립 리뷰어 2명이 확증: 에러경로 --json 무결성·exit code·injection·비파괴 마이그레이션(--force 제외)·team deploy 이중게이트 모두 **PASS**. 게시본 1.36.0 재실증. **R-0006 안정 marker.**
61
+
3
62
  ## 1.35.17 — 2026-07-07 — codex 협업 라운드 ⑦: audit 적대적 헌트 — 유저-프로젝트 FP 4종 (gate 표면 sweep 완결)
4
63
 
5
64
  **사용자 지시 "codex와 협업" 라운드 ⑦ — gate 임베드 마지막 미헌트 표면 `audit`.** audit 은 유저의 실제 프로젝트를 점검하므로 FP 가 `--strict` gate 를 거짓 실패시킴. 자체 프로브 + codex(gpt-5.5 xhigh) 교차. 재현으로 확정한 유저-프로젝트 FP 4종 수정. **이로써 gate 5체크(verify/audit/scan secrets/encoding/lazy detect) + gate 구성 + contract 전 표면 적대적 헌트 완주.**
package/README.md CHANGED
@@ -125,7 +125,7 @@ MIT
125
125
  <!-- leerness:project-readme:start -->
126
126
  ## Leerness Project Harness
127
127
 
128
- 이 프로젝트는 Leerness v1.35.17 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
128
+ 이 프로젝트는 Leerness v1.36.3 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
129
129
 
130
130
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
131
131
 
@@ -179,7 +179,7 @@ leerness memory restore decision <date|title>
179
179
 
180
180
  ### MCP server (외부 AI 통합)
181
181
 
182
- Leerness v1.35.17는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
182
+ Leerness v1.36.3는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
183
183
 
184
184
  ```jsonc
185
185
  // 카테고리별
@@ -200,7 +200,7 @@ Leerness v1.35.17는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
200
200
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
201
201
  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) 다음 라운드 예약.
202
202
 
203
- 현재 누적: **70 라운드 (1.9.40 → 1.35.17)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
203
+ 현재 누적: **70 라운드 (1.9.40 → 1.36.3)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
204
204
 
205
205
  ### 성능 가이드 (1.9.140 측정)
206
206
 
@@ -238,6 +238,6 @@ leerness release pack --close --auto-main-push
238
238
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
239
239
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
240
240
 
241
- Last synced by Leerness v1.35.17: 2026-07-07
241
+ Last synced by Leerness v1.36.3: 2026-07-08
242
242
  <!-- leerness:project-readme:end -->
243
243
 
package/bin/leerness.js CHANGED
@@ -31,8 +31,9 @@ const { _isSecretKey, _isPlaceholderSecret, _looksSecretLike, _mergeLines, _merg
31
31
  const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInGit, _epistemicHonestyCheck } = require('../lib/analyzers');
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
+ const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
34
35
 
35
- const VERSION = '1.35.17';
36
+ const VERSION = '1.36.3';
36
37
 
37
38
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
38
39
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -3193,6 +3194,24 @@ function _selfTestCases() {
3193
3194
  const u = m._parseAddTitle(['rule', 'add', '세션', '점검', '--trigger', 'every-session', '/p'], 2) === '세션 점검';
3194
3195
  return wired && u;
3195
3196
  } },
3197
+ { name: '클린룸 (UR-0184): feature add/show/link/impact positional-path 와이어 + 미초기화 게이트 + _taskPositionalPath 값-플래그 skip (1.36.2)', run: () => {
3198
+ const src = read(__filename);
3199
+ // split-literal 앵커 — selftest 자기참조 false-pass 회피(자기참조 트랩); 실제 디스패치/주입에만 매칭
3200
+ const featRootDef = 'const _feat' + 'Root = () => absRoot(';
3201
+ const addWire = 'featureAddCmd(_feat' + 'Root(), _parseAddTitle(args, 2))';
3202
+ const showWire = 'featureShowCmd(_feat' + 'Root(), args[2])';
3203
+ const depInject = 'arg, has, _require' + 'Init }'; // _featureDeps 에 _requireInit 주입
3204
+ const wired = src.includes(featRootDef) && src.includes(addWire) && src.includes(showWire) && src.includes(depInject);
3205
+ // lib/feature.js: featureAddCmd 가 미초기화 dir scaffold 대신 _requireInit 게이트
3206
+ const featSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'feature.js'));
3207
+ const gateWired = featSrc.includes('_require' + "Init(root, 'feature add')");
3208
+ // 순수 동작: _taskPositionalPath 가 feature 값-플래그(--files 등)의 path-like 값을 root 로 오인 안 함 + 실제 경로/드라이브만 추출(id 는 제외)
3209
+ const m = require('../lib/pure-utils');
3210
+ const p1 = m._taskPositionalPath(['feature', 'add', 'Name', '--files', './src/x.js'], 2) === null;
3211
+ const p2 = m._taskPositionalPath(['feature', 'add', 'Name', '/proj'], 2) === '/proj';
3212
+ const p3 = m._taskPositionalPath(['feature', 'show', 'F-0001', 'C:\\proj'], 2) === 'C:\\proj';
3213
+ return wired && gateWired && p1 && p2 && p3;
3214
+ } },
3196
3215
  { name: 'UR-0025 큰핸들러 모듈화 8번째: healthCmd → lib/health.js + DI 위임 + 동작 (1.9.423)', run: () => {
3197
3216
  const m = require('../lib/health');
3198
3217
  const expOk = typeof m.healthCmd === 'function';
@@ -3831,7 +3850,7 @@ function _selfTestCases() {
3831
3850
  } },
3832
3851
  { name: '품질 렌즈 (1.18.3): lens 명령 표면 등재 + REPL 설치문항 제거 (소스 가드)', run: () => {
3833
3852
  const src = read(__filename);
3834
- const surface = src.includes("if (cmd === 'lens')") && src.includes("cmd: 'lens [code|design|docs|test|security]") && src.includes('leerness lens [code|design|docs|test|security]');
3853
+ const surface = src.includes("if (cmd === 'lens')") && src.includes("cmd: 'lens [code|design|docs|test|security|database]") && src.includes('leerness lens [code|design|docs|test|security|database]');
3835
3854
  const replGone = !src.includes('설치 완료 후 REPL agent ' + '모드를 즉시 시작할까요') && src.includes('REPL agent 모드 진입 ' + '문항 제거');
3836
3855
  return surface && replGone;
3837
3856
  } },
@@ -3875,6 +3894,26 @@ function _selfTestCases() {
3875
3894
  && eq(F([]), [])
3876
3895
  && eq(F(['a.css', 'b.md', 'c.js', 'd.test.js']).length, 2); // 최대 2개 클러터 방지
3877
3896
  } },
3897
+ { name: 'DB 안전성 렌즈 (코어 승격, db-safety-lens 키트): database 도메인 8문항 + DB파일 매핑 인라인 + 비-DB 회귀0 (행위)', run: () => {
3898
+ const F = _lensDomainsForFiles;
3899
+ const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
3900
+ const d = LENS_CATALOG.database;
3901
+ const catOk = !!d && d.questions.length === 8 && d.questionsEn.length === 8
3902
+ && d.questions[0].includes('lost update') && d.questions.some(q => q.includes('TOCTOU'))
3903
+ && d.questions.some(q => q.includes('in-doubt')) && d.questions.some(q => q.includes('SKIP LOCKED'))
3904
+ && d.affects.every(a => LENS_CATALOG[a]);
3905
+ // DB 파일 → database 최우선(questions[0] 인라인 노출), DB+code → [database, code], 비-DB 코드 → 회귀 없음
3906
+ const mapOk = eq(F(['db/migrations/001_init.sql']), ['database'])
3907
+ && eq(F(['prisma/schema.prisma']), ['database'])
3908
+ && eq(F(['src/user.repository.ts']), ['database', 'code'])
3909
+ && eq(F(['src/models/user.js']), ['database', 'code'])
3910
+ && eq(F(['src/db/client.ts']), ['database', 'code']) // codex P2d: 흔한 DB 진입파일 recall
3911
+ && eq(F(['data-source.ts']), ['database', 'code']) // codex P2d: TypeORM data-source
3912
+ && eq(F(['tests/models/user.test.js']), ['code', 'test']) // codex P2b: models/ 안 테스트파일은 test 렌즈 유지
3913
+ && eq(F(['src/api.js']), ['code']) // 비-DB 코드 회귀 0
3914
+ && eq(F(['README.md']), ['docs']);
3915
+ return catOk && mapOk;
3916
+ } },
3878
3917
  { name: 'GPT-5.5 평가 #5 (1.19.1, UR-0009) + 정직성 calibration (1.35.12): 클린룸 문서 공개 + 한계 명시 + self-administered 라벨 (행위)', run: () => {
3879
3918
  const dp = path.join(path.dirname(__filename), '..', 'docs', 'clean-room-evaluations.md');
3880
3919
  if (!exists(dp)) return true; // 패키지에 없으면 스킵(설치본 안전)
@@ -4081,6 +4120,26 @@ function _selfTestCases() {
4081
4120
  const hasAdopt = bin.includes("'ado" + "pt'") && bin.includes('parent ado' + 'pt --apply');
4082
4121
  const nonDestructive = bin.includes('inherited-from-pa' + 'rent.md') && bin.includes('PARENT_LI' + 'NK.json');
4083
4122
  return hasAdopt && nonDestructive;
4123
+ } },
4124
+ { name: 'lib/state-integrity: findCorruptedStateJson 손상 JSON 탐지 + 유효/빈파일 무오탐 + audit/health 공유 (클린룸 리뷰 FN 1.36.1)', run: () => {
4125
+ const m = require('../lib/state-integrity');
4126
+ if (typeof m.findCorruptedStateJson !== 'function') return false;
4127
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_si_'));
4128
+ try {
4129
+ const hd = path.join(tmp, '.harness'); fs.mkdirSync(hd, { recursive: true });
4130
+ fs.writeFileSync(path.join(hd, 'valid.json'), '{"a":1}');
4131
+ fs.writeFileSync(path.join(hd, 'empty.json'), ''); // 빈 파일 = 그레이스풀 빈 상태(무오탐)
4132
+ fs.writeFileSync(path.join(hd, 'manifest.json'), '{ bad : json ]]]');
4133
+ const r = m.findCorruptedStateJson(tmp);
4134
+ const onlyBad = r.length === 1 && r[0].file === '.harness/manifest.json' && !!r[0].error;
4135
+ // .harness 부재 → 빈 배열(비-크래시)
4136
+ const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_si2_'));
4137
+ const noHarness = m.findCorruptedStateJson(emptyDir).length === 0;
4138
+ try { fs.rmSync(emptyDir, { recursive: true, force: true }); } catch {}
4139
+ // audit/health 가 헬퍼를 require 로 공유(DI 아님 — 래퍼 시그니처 무변경)
4140
+ const wired = read(path.join(__dirname, '..', 'lib', 'audit.js')).includes("require('./state-integrity')") && read(path.join(__dirname, '..', 'lib', 'health.js')).includes("require('./state-integrity')");
4141
+ return onlyBad && noHarness && wired;
4142
+ } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
4084
4143
  } }
4085
4144
  ];
4086
4145
  }
@@ -4695,6 +4754,34 @@ const LENS_CATALOG = {
4695
4754
  ],
4696
4755
  affects: ['code', 'test'], affectsNote: '보안 가드를 넣었다면 우회/오탐 테스트가 따라와야 함',
4697
4756
  affectsNoteEn: 'if you added a security guard, bypass/false-positive tests should follow'
4757
+ },
4758
+ // DB 안전성(동시성/트랜잭션) 렌즈 — race condition + ACID. .harness/db-safety-lens 키트에서 코어로 승격
4759
+ // (6차원 워크플로 저작 → 적대적 비평 → codex 독립 검수 8채택/1반박 → 게시본 실증 → node:sqlite 데모 자가검증).
4760
+ database: {
4761
+ title: 'DB 안전성', persona: '이중 결제·유실된 갱신·크래시 후 사라진 커밋으로 호출당해 본 DBA 겸 동시성·분산 리뷰어',
4762
+ titleEn: 'database safety', personaEn: 'a DBA and concurrency / distributed-systems reviewer',
4763
+ questions: [
4764
+ "값을 SELECT해서 앱 코드로 계산한 뒤 다시 UPDATE로 되쓰고 있지 않은가? — 두 요청이 같은 행을 동시에 읽으면 한쪽 갱신이 조용히 사라진다(lost update). 앱 계산을 없앤 원자적 조건 UPDATE(SET n = n + ?)나 버전 CAS(... WHERE version = ?, 영향 행이 0이면 성공이 아니라 충돌로 보고 재시도)로 바꿨는가?",
4765
+ "존재/유일성/한도/잔액 '검사'와 '쓰기'가 별개 문장으로 갈라져 있지 않은가? ('있으면 skip 없으면 INSERT', 'count 확인 후 처리', '잔액 확인 후 차감') — 두 문장 사이에 다른 요청이 끼어들면 중복 행·한도 초과·음수 잔액이 난다(check-then-act TOCTOU). 판정을 DB로 내렸는가? — (a) 존재/유일성은 UNIQUE 제약 + INSERT ... ON CONFLICT(UPSERT)로 강제한다. 없는 행에 SELECT ... FOR UPDATE를 걸어도 Postgres에선 0행이면 아무 락도 잡지 않아 두 요청이 함께 INSERT할 수 있으니 FOR UPDATE로 삽입을 막지 마라(MySQL InnoDB는 gap/next-key 락으로 부분적으로 막지만 엔진 의존이니 제약에 기대라); (b) 이미 있는 행의 잔액/한도는 가드를 UPDATE의 WHERE로 내리고(... WHERE bal >= ?) 영향 행 0을 실패로 처리하거나 그 행을 읽는 SELECT ... FOR UPDATE로 잠근다; (c) 여러 행에 걸친 합계 불변식(sum(할당) <= 예산, 겹침 예약 금지)은 단일 행 CAS로 못 막으니 SERIALIZABLE·EXCLUDE 제약(Postgres)·부모/요약 행 잠금이 필요하다.",
4766
+ "함께 성공하거나 함께 실패해야 할 여러 쓰기(여러 행·여러 테이블)를 하나의 명시적 BEGIN/COMMIT으로 묶었고, 모든 실패 경로(예외·조기 return·타임아웃)에서 ROLLBACK이 보장되는가? — 문장별 autocommit이면 중간 실패 시 절반만 커밋된다(partial commit). 트랜잭션 내내 같은 커넥션이 쓰이는지, 그리고 BEGIN~COMMIT 사이에 외부 HTTP/RPC·사용자 대기를 두지 않아 트랜잭션이 짧게 유지되는지(오래 열리거나 idle-in-transaction이면 커넥션 풀이 고갈되고 MVCC 팽창·VACUUM 차단이 생기니 idle_in_transaction_session_timeout 등으로 방어)도 확인했는가?",
4767
+ "롤백 불가능한 부수효과(이메일·큐 발행·외부 HTTP)와 파생 데이터(집계·카운터·캐시·검색 인덱스)가 원본 커밋과 어긋날 수 있지 않은가? — 트랜잭션 안에서 부수효과를 내면 롤백돼도 유령 이메일이 나가고, 커밋 뒤 fire-and-forget면 커밋과 발행 사이 크래시에 유실된다(멱등키는 중복만 막지 이 유실은 못 막는다). 그래서 반드시 도착해야 하는 효과는 트랜잭셔널 아웃박스(같은 트랜잭션에 의도를 저장하고 커밋 뒤 릴레이가 at-least-once 발송)/내구 job 행/CDC로 보장하고, 명시적으로 best-effort인 효과만 멱등키로 보호된 '커밋 후 발행'으로 빼고, 파생 데이터는 같은 트랜잭션(또는 트리거/아웃박스/CDC)으로 원본과 원자적으로 묶은 뒤 원본에서 재계산하는 정합성 경로를 뒀는가?",
4768
+ "이 불변식(잔액>=0, 유일성, NOT NULL, 참조무결성)이 DB 제약(CHECK/UNIQUE/FK/NOT NULL)으로 강제되는가, 아니면 앱 코드 검증에만 있는가? — 앱 검증은 동시 쓰기·다른 서비스·백필 스크립트·직접 SQL이 조용히 우회한다. 위반을 시도하는 테스트로 (a) 제약이 이 엔진/연결에서 실제로 켜져 있는지(예: SQLite는 연결마다 PRAGMA foreign_keys=ON; Postgres에서 NOT VALID로 추가한 FK는 신규 INSERT/UPDATE에는 그대로 적용되고 기존 행만 검증을 건너뛴다), (b) UNIQUE 컬럼의 NULL은 표준상 서로 구별돼 NULL 행이 무제한 허용된다는 함정을 확인했는가? — NULL이 유효하지 않으면 NOT NULL, NULL을 하나만 허용하려면 Postgres 15 NULLS NOT DISTINCT나 생성열/표현식 유니크로 풀어라(부분 인덱스 WHERE col IS NOT NULL은 NULL을 제약하는 게 아니라 오히려 무제한 허용한다 — non-null만 유니크할 때 쓰는 것).",
4769
+ "이 트랜잭션의 격리 수준을 의식적으로 골랐고, 데드락·직렬화 실패에 유한 재시도 루프(재시도 안에서 최신 상태 재조회, 백오프/지터, 재시도되는 트랜잭션엔 외부 부수효과가 없어 두 번 실행돼도 안전할 것)를 넣었는가? — 기본값에 그냥 맡기면 non-repeatable read·phantom·write skew가 샌다(기본값은 엔진마다 다름: Postgres는 보통 READ COMMITTED, MySQL InnoDB는 보통 REPEATABLE READ). 재시도할 에러코드도 엔진별로 다르니 확인했는가(Postgres 데드락 40P01·직렬화 40001; MySQL 데드락 40001/1213·락 대기 타임아웃 1205 — 1205는 기본적으로 문장만 롤백하니 트랜잭션 전체를 BEGIN부터 재시도하거나 innodb_rollback_on_timeout을 켤 것; SQLite는 SQLITE_BUSY/BUSY_SNAPSHOT에 busy_timeout·쓰기 트랜잭션엔 BEGIN IMMEDIATE·필요 시 WAL로 대응). 그리고 COMMIT 도중 타임아웃/연결끊김으로 결과가 불확실(in-doubt)한 트랜잭션은 무조건 롤백으로 단정하지 말고 멱등키/트랜잭션ID/원본 재조회로 실제 반영 여부를 재확인한 뒤 재시도하는가? 스냅샷 격리/Postgres REPEATABLE READ만으론 write skew가 안 막히니(SERIALIZABLE 또는 명시적 잠금 필요) 합계·겹침 불변식엔 그 방어를 뒀고, 여러 행/테이블을 잠그는 경로는 늘 같은 순서로 잠그는가?",
4770
+ "사용자에게 '성공'을 돌려준 이 쓰기가 실제로 확인된 COMMIT에 도달했고(커밋을 await하고 결과를 검사했는가), 그 ack가 메모리 버퍼가 아니라 안정 저장소(커밋 시 WAL/redo/journal이 flush됨)를 의미하는가? — autocommit을 끈 채 커밋을 빠뜨리면 커넥션 반납 시 열린 트랜잭션이 조용히 롤백된다. 내구성 설정은 엔진마다 다르니 데이터 등급에 맞게 확인했는가(예: Postgres synchronous_commit, MySQL innodb_flush_log_at_trx_commit=1 + sync_binlog=1, SQLite synchronous=FULL; Redis는 기본이 비내구적). 노드 손실까지 견뎌야 하는 데이터라면 쿼럼/동기 복제 ack(예: w:majority, 동기 스탠바이)로 한 노드가 죽어도 유실되지 않게 했고, failover RPO를 명시했는가?",
4771
+ "동시성 방어를 인프로세스 뮤텍스·싱글턴·인메모리 dedup에 기대거나, 여러 서비스에 걸친 작업을 하나의 DB 트랜잭션으로 감쌀 수 있다고 착각하거나, 방금 프라이머리에 쓴 값을 지연된 리드 레플리카에서 읽고 있지 않은가? — 인스턴스가 2개 이상이면 인프로세스 락은 무력하니 공유 지점으로 옮기고(DB UNIQUE 제약·행 잠금·advisory lock, 큐 소비는 FOR UPDATE SKIP LOCKED), 원자성은 프로세스/네트워크 경계를 못 넘으니 크로스서비스는 아웃박스+saga+보상 트랜잭션으로, at-least-once 전달은 멱등키(UNIQUE)로, 복제 지연이 깨는 read-your-writes는 프라이머리 라우팅/LSN 확인으로 옮겼는가? 분산 락을 쓴다면 리스 만료 후 늦게 깨어난 작업이 최신 작업을 덮어쓰지 못하게 단조 증가 펜싱 토큰(DB 시퀀스/행 버전)을 발급하고 하위 자원이 낮은 토큰을 거부하게 했는가?"
4772
+ ],
4773
+ questionsEn: [
4774
+ "Am I reading a value with SELECT, computing it in app code, then writing it back with UPDATE? Two requests reading the same row concurrently silently drop one write (lost update). Did I switch to an atomic conditional UPDATE (SET n = n + ?) or a version CAS (... WHERE version = ?, treating 0 rows affected as a conflict to retry, not as success)?",
4775
+ "Are my existence/uniqueness/limit/balance 'check' and 'write' split into two separate statements ('skip if exists else INSERT', 'check count then act', 'check balance then debit')? A concurrent request slipping between them yields a duplicate row, a breached limit, or a negative balance (check-then-act TOCTOU). Did I push the decision into the DB? — (a) for existence/uniqueness, enforce it with a UNIQUE constraint + INSERT ... ON CONFLICT (upsert): a SELECT ... FOR UPDATE that returns zero rows takes NO lock on PostgreSQL, so two requests both INSERT — don't use FOR UPDATE to guard an insert (MySQL InnoDB gap/next-key locks block this partially, but that's engine-dependent; rely on the constraint); (b) for a balance/limit on an EXISTING row, fold the guard into UPDATE ... WHERE bal >= ? and treat 0 rows affected as failure, or lock that row with SELECT ... FOR UPDATE; (c) a cross-row aggregate invariant (sum(allocations) <= budget, no overlapping bookings) can't be held by a single-row CAS — it needs SERIALIZABLE, a Postgres EXCLUDE constraint, or a lock on the parent/summary row.",
4776
+ "Are the writes that must succeed-or-fail together (multiple rows / tables) wrapped in ONE explicit BEGIN/COMMIT, with ROLLBACK guaranteed on every failure path (exception, early return, timeout)? Under per-statement autocommit a mid-way failure leaves a half-applied partial commit. Did I also confirm the same pinned connection is used throughout, and that the transaction stays short — no external HTTP/RPC or user-wait between BEGIN and COMMIT (a long-open or idle-in-transaction session exhausts the connection pool and, on MVCC engines, bloats the heap and blocks VACUUM; guard with idle_in_transaction_session_timeout)?",
4777
+ "Can a non-rollbackable side effect (email, queue publish, external HTTP) or derived data (aggregate, counter, cache, search index) diverge from the source commit? A side effect issued inside the tx can't be rolled back (ghost email); one fired-and-forgotten after commit is lost on a crash between commit and dispatch (an idempotency key prevents duplicates, not this loss). So for effects that MUST arrive, guarantee them with a transactional outbox (persist intent in the same tx; a relay dispatches at-least-once after commit) / durable job row / CDC, and only move an explicitly best-effort effect to publish-after-commit guarded by an idempotency key; and bind derived data to the source atomically (same tx / trigger / outbox / CDC) with a recompute-from-source reconciliation path?",
4778
+ "Is this invariant (balance>=0, uniqueness, NOT NULL, referential integrity) enforced by a DB constraint (CHECK/UNIQUE/FK/NOT NULL), or does it live only in app-code validation? App validation is silently bypassed by concurrent writers, other services, backfill scripts, and direct SQL. Did I confirm, with a test that attempts a violation and asserts rejection: (a) the constraint is actually live on this engine/connection (e.g. SQLite needs PRAGMA foreign_keys=ON per connection; a Postgres FK added NOT VALID IS enforced for all new INSERT/UPDATE and only skips validating pre-existing rows), and (b) I'm not tripped by UNIQUE's NULL semantics — standard UNIQUE treats NULLs as distinct, so a 'unique' nullable column silently allows unlimited NULL rows? If NULL is invalid, use NOT NULL; if only one NULL is allowed, use Postgres 15 NULLS NOT DISTINCT or a generated/expression unique (a partial index WHERE col IS NOT NULL does NOT constrain NULLs — it leaves them unlimited; that's the tool for unique-when-present).",
4779
+ "Did I consciously choose this transaction's isolation level and add a bounded retry loop for deadlock / serialization failure (re-read fresh state inside the retry, use backoff/jitter, and make sure the retried transaction is free of external side effects so re-running it is safe)? Inheriting an unexamined default can leak non-repeatable reads, phantoms, or write skew (defaults differ by engine — Postgres tends to READ COMMITTED, MySQL InnoDB tends to REPEATABLE READ). Did I use the RIGHT retry error codes (Postgres deadlock 40P01 / serialization 40001; MySQL deadlock 40001/1213 / lock-wait timeout 1205 — 1205 by default rolls back only the statement, so retry the WHOLE transaction from BEGIN or set innodb_rollback_on_timeout; SQLite: handle SQLITE_BUSY/BUSY_SNAPSHOT with busy_timeout, BEGIN IMMEDIATE for write txns, WAL where apt)? And if a COMMIT is left in-doubt by a timeout/dropped connection, do I NOT assume rollback but reconcile actual persistence via idempotency key / transaction id / source-of-truth read before retrying? Snapshot isolation / Postgres REPEATABLE READ does NOT stop write skew (needs SERIALIZABLE or explicit locking), so did I guard sum/overlap invariants that way — and do all paths that lock multiple rows/tables acquire them in the same order?",
4780
+ "Did the write I returned 'success' for actually reach a checked COMMIT (did I await the commit and inspect its result), and does that ack mean stable storage — WAL/redo/journal flushed on commit — not just a memory buffer? With autocommit off and a forgotten COMMIT, the open transaction is silently rolled back when the connection returns to the pool. Durability settings differ by engine — did I verify the one matching this data's class (e.g. Postgres synchronous_commit, MySQL innodb_flush_log_at_trx_commit=1 + sync_binlog=1, SQLite synchronous=FULL; Redis is not durable by default)? For must-survive-node-loss data, did I require a quorum / synchronous-replication ack (e.g. w:majority, synchronous standby) so one node dying can't lose it, and state the failover RPO?",
4781
+ "Am I relying on an in-process mutex/singleton/in-memory dedup, or assuming one DB transaction can wrap work spanning multiple services, or reading a just-written value from a lagging read replica? With >=2 instances an in-process lock is useless, so move mutual exclusion to a shared point (DB UNIQUE constraint, row lock, advisory lock; pull queue work with FOR UPDATE SKIP LOCKED); atomicity can't cross a process/network boundary, so route cross-service work through an outbox + saga + compensating actions and collapse at-least-once delivery with an idempotency key (UNIQUE); and replication lag breaks read-your-writes, so route read-after-write / consistency-critical reads to the primary or gate on replica LSN/lag. If I use a distributed lock, did I issue a monotonically increasing fencing token (DB sequence / row version) and have the guarded resource reject lower tokens, so a worker that resumes after its lease expired can't overwrite newer work?"
4782
+ ],
4783
+ affects: ['code', 'test'], affectsNote: '동시성 방어엔 인터리빙/부하/재시도/멀티인스턴스 실패를 재현하는 테스트가 따라와야 함(단일 인스턴스 초록불은 증거가 아님)',
4784
+ affectsNoteEn: 'a concurrency defense needs an interleaving/load/retry/multi-instance reproduction test'
4698
4785
  }
4699
4786
  };
4700
4787
  // 1.19.3 (UR-0003 렌즈 완전판 v3): 프로젝트별 커스텀 렌즈 — .harness/quality-lenses.json 읽기-병합(쓰기 명령 없음, AI/사용자가 편집).
@@ -4737,8 +4824,13 @@ function _lensDomainsForFiles(files) {
4737
4824
  if (has(/\.(md|mdx|rst|adoc|txt)$/i)) out.push('docs'); // 문서
4738
4825
  if (has(/(^|[\\/])(test_[^\\/]+\.[a-z]+|[^\\/]+[._-]test\.[a-z]+|[^\\/]+\.spec\.[a-z]+)$|(^|[\\/])tests?[\\/]/i)) out.push('test'); // 테스트
4739
4826
  if (has(/\.(js|mjs|cjs|ts|py|rb|go|rs|java|cs|php)$/i)) out.push('code'); // 실코드
4740
- // 순서: code 먼저(가장 일반), 그다음 design/docs/test. 중복 제거 + 최대 2개(클러터 방지).
4741
- const ordered = ['code', 'design', 'docs', 'test'].filter(d => out.includes(d));
4827
+ // DB 표면: 마이그레이션/스키마/ORM/DB 진입 파일 database 렌즈(동시성/트랜잭션). 결정적 경로/확장자 매칭.
4828
+ // 테스트 파일(예: models/ 안의 *.test.js)은 test 렌즈를 유지하도록 database 매핑에서 제외.
4829
+ const _dbRe = /\.sql$|\.prisma$|(^|[\\/])(migrations?|migrate|db|database)[\\/]|(^|[\\/])(models?|entities|repositor(?:y|ies)|daos?)[\\/]|[._-](repository|entity|model|dao|schema|migration)\.[a-z]+$|(^|[\\/])(schema|db|database|data-source|ormconfig|knexfile|drizzle\.config|typeorm\.config)\.[a-z]+$/i;
4830
+ const _testRe = /(^|[\\/])(test_[^\\/]+\.[a-z]+|[^\\/]+[._-]test\.[a-z]+|[^\\/]+\.spec\.[a-z]+)$|(^|[\\/])tests?[\\/]/i;
4831
+ if (arr.some(f => typeof f === 'string' && _dbRe.test(f) && !_testRe.test(f))) out.push('database');
4832
+ // 순서: database 먼저(DB 파일이면 동시성/트랜잭션 질문을 인라인 노출), 그다음 code/design/docs/test. 중복 제거 + 최대 2개(클러터 방지).
4833
+ const ordered = ['database', 'code', 'design', 'docs', 'test'].filter(d => out.includes(d));
4742
4834
  return ordered.slice(0, 2);
4743
4835
  }
4744
4836
  function lensCmd(domain, opts = {}) {
@@ -4812,7 +4904,7 @@ function commandsCmd(root) {
4812
4904
  { cmd: 'encoding check [path]', desc: '인코딩 검증' },
4813
4905
  { cmd: 'lazy detect [path] [--json]', desc: '게으른 작업 감지 (1.9.101)' },
4814
4906
  { cmd: 'verify-claim <T-ID|--all> [--run-tests] [--test-cmd "<명령>"] [--strict-claims] [--require-evidence]', desc: '주장 검증 (1.9.18~26) — --all: 모든 done 주장 일괄 검증(CI·스케일, 1.33.2) · --require-evidence: done 주장에 파일+테스트 근거 강제 (1.9.287) · --test-cmd: 비-JS 테스트 명령 (1.17.2)' },
4815
- { cmd: 'lens [code|design|docs|test|security] [--json]', desc: '분야별 자기질문 품질 렌즈 + 분야간 인과관계 (1.18.3)' },
4907
+ { cmd: 'lens [code|design|docs|test|security|database] [--json]', desc: '분야별 자기질문 품질 렌즈 + 분야간 인과관계 (1.18.3, database: 동시성/트랜잭션)' },
4816
4908
  { cmd: 'optimism-check <T-ID>', desc: '낙관적 API 감지 (1.9.26)' },
4817
4909
  { cmd: 'requests audit|list|complete|drop|auto-complete', desc: '사용자 요청 추적 (1.9.207/223)' },
4818
4910
  { cmd: 'pre-wake-audit [path] [--last]', desc: 'sleep 전 점검 (1.9.209)' },
@@ -7339,6 +7431,8 @@ function planAdd(root, text) {
7339
7431
  upsertProgress(root, { id: tid, status, request: text, evidence: `plan:${id}`, nextAction });
7340
7432
  return { id, tid };
7341
7433
  });
7434
+ // 1.36.0 (클린룸 리뷰 B, --json 무결성): plan add 성공 경로 --json 보강(에러 경로는 이미 구조화).
7435
+ if (has('--json')) { log(JSON.stringify({ ok: true, milestone: id, task: tid, text })); return; }
7342
7436
  ok(`plan added: ${id} → progress: ${tid}`);
7343
7437
  _autoRoadmap(absRoot(root), 'data-change');
7344
7438
  }
@@ -7593,6 +7687,8 @@ function taskUpdate(root, id) {
7593
7687
  if (arg('--next') !== null) patch.nextAction = arg('--next');
7594
7688
  if (arg('--note')) patch.request = arg('--note');
7595
7689
  upsertProgress(root, patch);
7690
+ // 1.36.0 (클린룸 리뷰 B, --json 무결성): task add(1.9.413) 는 --json 성공 출력이 있으나 task update 는 누락 — 성공 시 평문만 내보내 JSON 소비 스크립트가 성공 경로에서만 크래시(에러 경로는 이미 구조화). 동일 패턴으로 보강.
7691
+ if (has('--json')) { log(JSON.stringify({ ok: true, ...patch })); return; }
7596
7692
  ok(`task updated: ${id}`);
7597
7693
  _autoRoadmap(absRoot(root), 'data-change');
7598
7694
  }
@@ -8553,6 +8649,17 @@ function preCheck(root) {
8553
8649
  const pfOk = pf.includes('AGENTS.md');
8554
8650
  checks.push({ name: 'protected-files.md AGENTS.md', kind: 'integrity', ok: pfOk });
8555
8651
  if (!pfOk) { issues++; if (!json) fail('protected-files.md missing AGENTS.md'); }
8652
+ // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태파일 JSON 무결성 — 손상 시 하드 실패(exit 1). check 는 pre-action 게이트이므로 손상 상태를 통과시키지 않는다.
8653
+ // (audit=warning / health=degraded 와 달리 check 는 차단 신호. 비-크래시: 헬퍼가 파서 예외를 흡수.)
8654
+ try {
8655
+ const corrupted = _findCorruptedStateJson(root);
8656
+ if (corrupted.length) {
8657
+ for (const c of corrupted) { checks.push({ name: c.file, kind: 'json-integrity', ok: false, error: c.error }); issues++; if (!json) fail(`corrupted JSON: ${c.file} (${c.error})`); }
8658
+ } else {
8659
+ checks.push({ name: 'state JSON integrity', kind: 'json-integrity', ok: true });
8660
+ if (!json) ok('state JSON integrity OK (.harness/*.json)');
8661
+ }
8662
+ } catch {}
8556
8663
  if (json) { log(JSON.stringify({ root, healthy: issues === 0, issues, checks }, null, 2)); if (issues) process.exitCode = 1; return; }
8557
8664
  if (issues === 0) ok('pre-action check passed');
8558
8665
  else { process.exitCode = 1; }
@@ -10621,7 +10728,7 @@ function verifyClaimCmd(root, taskId, opts = {}) {
10621
10728
  // 확장자는 길이 내림차순(긴 것 먼저 매치) + \b 종결로 .ts vs .tscn 구분.
10622
10729
  // 1.9.21: 설정/메타 파일 확장자 추가 — Godot export_presets.cfg 등 false negative 보완
10623
10730
  // 1.18.2: java|php|mjs|cjs 추가 — _VC_CODE_EXT 와 정합(이전엔 .java/.php 임플 주장이 추출조차 안 돼 스텁/존재 검사를 무검사 통과).
10624
- const FILE_EXTS = 'webmanifest|dockerfile|properties|tscn|tres|godot|json5|java|jsx|tsx|yaml|html|scss|sass|less|gltf|conf|json|toml|lock|mdx|xml|css|svg|yml|cfg|ini|env|php|mjs|cjs|md|js|ts|gd|cs|py|rb|go|rs|kt|sh|h';
10731
+ const FILE_EXTS = 'webmanifest|dockerfile|properties|tscn|tres|godot|json5|prisma|java|jsx|tsx|yaml|html|scss|sass|less|gltf|conf|json|toml|lock|mdx|xml|css|svg|yml|cfg|ini|env|php|sql|mjs|cjs|md|js|ts|gd|cs|py|rb|go|rs|kt|sh|h';
10625
10732
  const FILE_RE = new RegExp(`(?:[A-Za-z][A-Za-z0-9_-]*\\/)?[A-Za-z][\\w./-]*\\.(?:${FILE_EXTS})\\b`, 'g');
10626
10733
  const filePatterns = evidence.match(FILE_RE) || [];
10627
10734
  // 중복 제거 + "tests/test.js" 같은 결과를 유지 (이미 `..` 없으니 그대로)
@@ -14302,6 +14409,8 @@ function verifyRules(root) {
14302
14409
  function ruleVerifyCmd(root) {
14303
14410
  root = absRoot(root);
14304
14411
  const results = verifyRules(root);
14412
+ // 1.36.0 (클린룸 리뷰 B, --json 무결성): rule verify 성공 경로 --json 보강 — 기존엔 --json 이어도 평문 표/텍스트만 출력.
14413
+ if (has('--json')) { log(JSON.stringify({ ok: true, count: results.length, results })); return; }
14305
14414
  if (!results.length) return ok('활성 룰 없음');
14306
14415
  log('# Rules verification');
14307
14416
  log('| ID | Trigger | Rule | Verified | Note |');
@@ -15116,7 +15225,7 @@ function _writeFeatureGraph(root, nodes) {
15116
15225
  // 1.9.391 (UR-0025 큰 핸들러 모듈화 3번째): feature add/link/impact/list/show 핸들러를 lib/feature.js 로 분리.
15117
15226
  // _featureImpactBfs 는 pure-utils 로 이동(handoff/audit 공유). feature-graph I/O 헬퍼(_readFeatureGraph 등)는 공유라 harness 유지+주입.
15118
15227
  const _feature = require('../lib/feature');
15119
- function _featureDeps() { return { _ensureFeatureGraph, _readFeatureGraph, _writeFeatureGraph, arg, has }; }
15228
+ function _featureDeps() { return { _ensureFeatureGraph, _readFeatureGraph, _writeFeatureGraph, arg, has, _requireInit }; } // 1.36.2: _requireInit 주입 — feature add 미초기화 dir scaffold 게이트
15120
15229
  function featureAddCmd(root, title) { return _feature.featureAddCmd(root, title, _featureDeps()); }
15121
15230
  function featureLinkCmd(root, fromId) { return _feature.featureLinkCmd(root, fromId, _featureDeps()); }
15122
15231
  function featureImpactCmd(root, fromId) { return _feature.featureImpactCmd(root, fromId, _featureDeps()); }
@@ -15226,6 +15335,8 @@ function reuseFind(root, query) {
15226
15335
  if (exportRe.test(lines[i])) matches.push({ source: rel(root, f), line: i + 1, text: lines[i].trim().slice(0, 120) });
15227
15336
  }
15228
15337
  }
15338
+ // 1.36.0 (클린룸 리뷰 B, --json 무결성): reuse find 성공 경로 --json 보강 — 기존엔 --json 이어도 사람용 텍스트만.
15339
+ if (has('--json')) { log(JSON.stringify({ ok: true, query, count: matches.length, matches: matches.slice(0, _parseLimit(arg('--limit', '20'), 20)) })); return; }
15229
15340
  log(`# reuse find: "${query}"`);
15230
15341
  if (!matches.length) return ok('기존 자원 없음 — 새로 만드는 것이 최선의 선택일 수 있음');
15231
15342
  log(`${matches.length}개 후보:`);
@@ -20375,7 +20486,7 @@ VERIFICATION (evidence-gated "done")
20375
20486
  contract verify <spec.md> <impl.js> [--json] Spec <-> implementation match
20376
20487
  verify-code [path] [--build] [--bench] Run tests/lint/typecheck, record evidence
20377
20488
  gate [path] One-call CI gate: verify + audit + scan + encoding + lazy
20378
- lens [code|design|docs|test|security] [--json] Quality self-question lenses
20489
+ lens [code|design|docs|test|security|database] [--json] Quality self-question lenses
20379
20490
 
20380
20491
  SECURITY & HYGIENE
20381
20492
  scan secrets [path] Committed-secret detection
@@ -20437,7 +20548,7 @@ function help() {
20437
20548
  // 1.23.1 (UR-0010 Phase 6): 영어 opt-in 시 큐레이트 영어판. 기본(ko) 은 아래 한국어 help 그대로.
20438
20549
  if (_uiLang(arg('--path', process.cwd())) === 'en') { _helpEn(); return; }
20439
20550
  log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path] [--all-apps] [--include p1,p2] [--since 24h|3d] [--compact] [--json] # 1.9.17-22 워크스페이스 (--compact: LLM 시스템 프롬프트용 1줄 요약)\n leerness orchestrate "<목표>" [--agents N] [--model qwen2.5:7b-instruct] [--retry-on-fail K] # 1.9.22 Ollama opt-in (LEERNESS_OLLAMA_BASE_URL 필요)\n leerness llm-bench record --score N --model X [--label L] [--tokens T] # 1.9.22 LLM 벤치 히스토리 누적\n leerness deps <capability> [--run-tests] [--json] # 1.9.24 depends-on 역방향 추적 + 자동 회귀 sweep\n leerness memory search "키" [--include-code] # 1.9.25 소스 코드 본문도 검색 (모순 감지 핵심)\n leerness brainstorm "주제" [--include-code] # 1.9.25 코드 본문 hits 포함\n leerness register-pending "<요청>" [--agent X] [--note Y] # 1.9.25 다중 세션 in-progress 즉시 등록\n leerness optimism-check <T-ID> [--json] # 1.9.26/27 낙관적 표시 감지 (1.9.27: 10 카테고리 + URL/메서드 매핑 + 신뢰도 점수)\n leerness persona list|show <id>|add <id> # 1.9.29 페르소나 카탈로그 (보안/성능/UX/testing/docs 5종 내장)\n leerness review <file> --persona <id1,id2,...> # 1.9.29 도메인 페르소나 리뷰 프롬프트 자동 생성\n leerness agents list|check|quota # 1.9.30/31 외부 AI CLI 가용성 + quota 추정 (claude/codex/agy/copilot)\n leerness agents dispatch "<task>" --to <id> # 1.9.30 활성 CLI 대상 실행 명령 생성 (실 호출 X, 사용자 실행)\n leerness agents multi "<task>" [--only c1,c2] [--write] [--execute] [--timeout 60] # 1.9.152/156 활성 N개 일괄 dispatch (--execute: 실 spawn + consensus)\n leerness provider list|add|remove [args] # 1.9.157 Provider Registry — 사용자 정의 CLI provider 동적 추가 (OpenRouter/Bedrock 흡수)\n leerness agents dispatch "<task>" --multi # 1.9.152 multi 모드 alias (또는 --to all)\n leerness setup-agents [path] [--yes|--no-setup-agents] # 1.9.32 sub-agent CLI 인터랙티브 설정 (.env + 미설치 자동 설치)\n leerness init [path] [--no-stale-check] # 1.9.33 npx 캐시 함정 — 옛 버전 자동 경고 (끄려면 --no-stale-check)\n leerness which [--json] # 1.9.164 진단: 현재 실행 경로/버전 + npm 캐시 + PATH 후보 (구버전 충돌 해결)\n leerness selftest [--json] # 1.9.258 코어 함수 무결성 자가 검증 (설치 손상/부분설치 감지, CI 친화 exit 1)\n leerness shell-guard "<command>" [--json] # 1.9.260 터미널 명령 셸 호환성 린터 (PowerShell 5.1 && 미지원 등 실행 전 감지, UR-0020)\n leerness shell-guard --record --cmd "..." --exit N # 1.9.260 실패한 터미널 명령 기록 → 다음 분석 시 회수\n leerness path-setup [--apply] [--json] # 1.9.254 leerness CLI PATH 자동 등록 (npm global bin 미등록 시)\n leerness web check|screenshot|extract <url> [--out file.png] [--selector "css"] # 1.9.165 playwright bridge (opt-in: npm i -g playwright + permissions.browser)\n leerness pc check|click|type|screenshot [--x N --y N] [--text "s"] [--out f.png] # 1.9.166 robotjs/nut-tree bridge (opt-in: npm i -g robotjs + permissions.mouse/keyboard, ⚠ full 모드 권장)\n leerness lsp check|symbols|references <file/name> [--in dir] [--json] # 1.9.167 LSP 어댑터 MVP (typescript opt-in + regex fallback, 코드 인텔리전스)\n leerness review-request "<request>" [--json] # 1.9.176 사용자 요청 사전 검토 (충돌/재사용/효율/권장 단계 — 사용자 명시)\n leerness contract verify <spec.md> <impl.js> [--json] # 1.9.35 명세 ↔ 구현 일치 검사 (함수/필드)\n leerness reuse autodetect [path] [--apply] [--json] # 1.9.35 src/*.js의 module.exports → reuse-map 후보 등록\n leerness audit [path] [--fix] # 1.9.35 --fix: session-handoff/current-state 자동 갱신\n leerness verify-claim <T-ID> ... [--strict-claims] # 1.9.26 verify-claim에 낙관적 표시 자동 검사 통합
20440
- leerness lens [code|design|docs|test|security] [--json] # 1.18.3 분야별 자기질문 품질 렌즈 + 분야간 인과관계 (완료 선언 전 자가 점검)\n leerness reuse-map [path] [--all-apps] [--include p1,p2] [--strict-elements] [--json] # 1.9.18 중복/잠재중복/depends-on\n leerness verify-claim <T-ID> [--path .] [--run-tests] [--json] # 1.9.18-20 evidence 자동 검증 (1.9.20: scenes/scripts 등 도메인 폴더 + jest/mocha 파싱)\n leerness verify-code [path] [--build] [--bench] # 1.9.20 --bench: scripts.bench 추가 실행 + evidence 누적\n leerness session close [path]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop|fix-evidence|relink [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy
20551
+ leerness lens [code|design|docs|test|security|database] [--json] # 1.18.3 분야별 자기질문 품질 렌즈 (database: 동시성/트랜잭션 완료 선언 전 자가 점검)\n leerness reuse-map [path] [--all-apps] [--include p1,p2] [--strict-elements] [--json] # 1.9.18 중복/잠재중복/depends-on\n leerness verify-claim <T-ID> [--path .] [--run-tests] [--json] # 1.9.18-20 evidence 자동 검증 (1.9.20: scenes/scripts 등 도메인 폴더 + jest/mocha 파싱)\n leerness verify-code [path] [--build] [--bench] # 1.9.20 --bench: scripts.bench 추가 실행 + evidence 누적\n leerness session close [path]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop|fix-evidence|relink [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy
20441
20552
  leerness retro [path] [--days 7] [--all-apps] [--include p1,p2] [--json] # 회고 (1.9.13~1.9.16)
20442
20553
  leerness insights [path] [--all-apps] [--include p1,p2] [--json] # 누적 통계 (1.9.13~1.9.16)
20443
20554
  leerness brainstorm "<주제>" [--all-apps] [--include p1,p2] [--json] # 브레인스토밍 (1.9.13~1.9.16)
@@ -20773,11 +20884,16 @@ async function main() {
20773
20884
  if (cmd === 'release' && args[1] === 'channel') return releaseChannelCmd((args[2] && !args[2].startsWith('-')) ? args[2] : arg('--path', process.cwd()));
20774
20885
  if (cmd === 'release' && args[1] === 'cadence') return releaseCadenceCmd((args[2] && !args[2].startsWith('-')) ? args[2] : arg('--path', process.cwd())); // 1.9.374 (UR-0074): 릴리스 빈도 진단
20775
20886
  // 1.9.141: feature causality graph
20776
- if (cmd === 'feature' && args[1] === 'add') return featureAddCmd(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('--')).join(' '));
20777
- if (cmd === 'feature' && args[1] === 'link') return featureLinkCmd(arg('--path', process.cwd()), args[2]);
20778
- if (cmd === 'feature' && args[1] === 'impact') return featureImpactCmd(arg('--path', process.cwd()), args[2]);
20887
+ // 1.36.2 (clean-room, UR-0184): add/show/link/impact trailing positional path 인식 — list(1.9.412) 지원하던 것을 일관 확대.
20888
+ // 기존엔 add 모든 non-flag positional NAME 으로 join → 경로가 이름에 흡수 + cwd 에 stray .harness scaffold(조용한 오독).
20889
+ // 해석: --path > path-like positional(_taskPositionalPath) > cwd. add NAME _parseAddTitle 첫 경로/플래그에서 절단(경로 흡수 차단).
20890
+ // add 는 featureAddCmd 내부 _requireInit 게이트로 미초기화 dir scaffold 대신 에러(--force 우회). show/link/impact 는 read-only(scaffold 안 함).
20891
+ const _featRoot = () => absRoot(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd());
20892
+ if (cmd === 'feature' && args[1] === 'add') return featureAddCmd(_featRoot(), _parseAddTitle(args, 2));
20893
+ if (cmd === 'feature' && args[1] === 'link') return featureLinkCmd(_featRoot(), args[2]);
20894
+ if (cmd === 'feature' && args[1] === 'impact') return featureImpactCmd(_featRoot(), args[2]);
20779
20895
  if (cmd === 'feature' && args[1] === 'list') return featureListCmd(absRoot(_resolveRoot(args[2]))); // 1.9.412 (UR-0100): positional path 지원(조용한 cwd 오독 방지)
20780
- if (cmd === 'feature' && args[1] === 'show') return featureShowCmd(arg('--path', process.cwd()), args[2]);
20896
+ if (cmd === 'feature' && args[1] === 'show') return featureShowCmd(_featRoot(), args[2]);
20781
20897
  if (cmd === 'impact') return impactCmd(arg('--path', process.cwd()), args[1]);
20782
20898
  if (cmd === 'reuse' && args[1] === 'find') return reuseFind(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
20783
20899
  if (cmd === 'reuse' && args[1] === 'register') return reuseRegister(arg('--path', process.cwd()), args[2]);
package/lib/analyzers.js CHANGED
@@ -1,90 +1,90 @@
1
- // lib/analyzers.js — 순수 분석/검증 함수 (부작용 0, 입력→출력).
2
- // 1.9.304 (UR-0025): bin/harness.js 에서 비파괴 분리. selftest(evidenceQuality/parseEvidenceStats/shellGuardAnalyze/claimFileInGit)가 동작 검증.
3
- 'use strict';
4
-
5
- function _shellGuardAnalyze(cmd, ctx) {
6
- const c = String(cmd || '');
7
- const shell = (ctx && ctx.shell) || 'unknown';
8
- const psVer = ctx && ctx.psVersion != null ? parseInt(ctx.psVersion, 10) : null;
9
- const issues = [];
10
- const isWinPowerShell = shell === 'powershell' && psVer != null && psVer < 6; // 5.1 = Windows PowerShell
11
- // 규칙 1: PowerShell 5.1 에서 && / || 체이닝 미지원 (pwsh 7+ 부터 지원)
12
- if (isWinPowerShell && /&&|\|\|/.test(c)) { // 1.12.5 (15th 버그헌트 P2, UR-0018): 공백 무관 — PS5.1 은 a&&b 도 거부(이전 /\s&&\s/ 는 양쪽 공백 요구해 npm 체인 a&&b 미탐).
13
- issues.push({ rule: 'ps5-chain', severity: 'error', detail: 'Windows PowerShell 5.1 은 && / || 연산자를 지원하지 않습니다 (PowerShell 7+ 부터 지원).', suggestion: 'A; if ($?) { B } (조건부) 또는 A; B (무조건) 로 분리. 또는 pwsh 7 설치.' });
14
- }
15
- // 규칙 2: PowerShell 에서 2>/dev/null → 2>$null
16
- if (shell === 'powershell' && /2>\s*\/dev\/null/.test(c)) {
17
- issues.push({ rule: 'ps-devnull', severity: 'error', detail: 'PowerShell 은 /dev/null 경로가 없습니다.', suggestion: '2>$null 사용 (PowerShell 리다이렉트).' });
18
- }
19
- // 규칙 3: PowerShell 에서 inline env (VAR=val cmd) 미지원
20
- if (shell === 'powershell' && /^[A-Z_][A-Z0-9_]*=[^\s]+\s+\S/.test(c.trim())) {
21
- issues.push({ rule: 'ps-inline-env', severity: 'error', detail: 'PowerShell 은 VAR=val cmd 형식의 inline 환경변수를 지원하지 않습니다.', suggestion: "$env:VAR='val'; cmd 로 분리." });
22
- }
23
- // 규칙 4: PowerShell 에서 Unix 전용 명령 (rm -rf / ls -la 등) — 별칭은 되나 플래그 오류 가능
24
- if (shell === 'powershell' && /\brm\s+-rf\b/.test(c)) {
25
- issues.push({ rule: 'ps-rm-rf', severity: 'warn', detail: 'PowerShell 에서 rm -rf 는 -rf 플래그 파싱 오류 가능 (rm 은 Remove-Item 별칭).', suggestion: 'Remove-Item -Recurse -Force <path> 사용.' });
26
- }
27
- // 규칙 5: CMD 에서 ; 는 명령 구분자가 아님 (한 줄로 실행됨)
28
- if (shell === 'cmd' && /;/.test(c) && !/&&|\|\|/.test(c)) {
29
- issues.push({ rule: 'cmd-semicolon', severity: 'warn', detail: 'CMD 는 ; 를 명령 구분자로 처리하지 않습니다 (인자로 전달됨).', suggestion: 'A && B (조건부) 또는 A & B (무조건) 사용.' });
30
- }
31
- // 규칙 6: PowerShell 에서 && 가 있으나 버전 미상 — 정보성
32
- if (shell === 'powershell' && psVer == null && /&&|\|\|/.test(c)) { // 1.12.5 (UR-0018): 공백 무관 매칭
33
- issues.push({ rule: 'ps-version-unknown', severity: 'info', detail: 'PowerShell 버전 미상 — 5.1 이면 && 미지원, 7+ 이면 지원.', suggestion: '$PSVersionTable.PSVersion 확인. 안전하게 A; if ($?) { B } 권장.' });
34
- }
35
- return { shell, psVersion: psVer, issues };
36
- }
37
- function _evidenceQuality(evidence) {
38
- const e = String(evidence || '');
39
- const hasFile = /(?:[A-Za-z][\w-]*[\/\\])?[A-Za-z][\w./\\-]*\.(?:js|ts|tsx|jsx|mjs|cjs|py|go|rs|rb|kt|cs|gd|java|php|swift|c|cpp|h|html|css|scss|vue|svelte|json|yaml|yml|toml|md|sql|sh)\b/i.test(e);
40
- const hasTest = /(\d+)\s*(?:\/\s*\d+\s*)?(?:통과|passed|passing|개\s*테스트)|\btests?\b\s*[:=]?\s*\d|Tests?:\s*\d|\b\d+\s*tests?\b/i.test(e);
41
- const hasLog = /Exit\s*[:=]|exit\s*code|Command\s*[:=]|npm\s+(?:test|run)|pytest|cargo\s+test|go\s+test/i.test(e);
42
- const missing = [];
43
- if (!hasFile) missing.push('수정 파일 경로');
44
- if (!hasTest) missing.push('테스트명/개수');
45
- if (!hasLog) missing.push('실행 로그(Command/Exit)');
46
- return { hasFile, hasTest, hasLog, ok: hasFile && hasTest, missing };
47
- }
48
- function _claimFileInGit(claimed, gitSet) {
49
- if (!gitSet) return null;
50
- const c = String(claimed).replace(/\\/g, '/').replace(/^\.\//, '');
51
- for (const g of gitSet) { if (g === c || g.endsWith('/' + c) || (g.indexOf('/') >= 0 && c.endsWith('/' + g))) return true; } // 1.35.5: reverse match 는 git 경로가 다중세그먼트일 때만 — bare basename 충돌(src/test.js ↔ test.js) 차단
52
- return false;
53
- }
54
- function _parseEvidenceStats(text) {
55
- const t = String(text || '');
56
- const blocks = t.split(/\n(?=## )/).filter(b => /Command:|Exit:|verify|test/i.test(b));
57
- let pass = 0, fail = 0;
58
- for (const b of blocks) {
59
- const exitM = b.match(/Exit:\s*(-?\d+)/i);
60
- if (exitM) { (parseInt(exitM[1], 10) === 0 ? pass++ : fail++); continue; }
61
- if (/\bPASS\b|통과|성공|✓/i.test(b)) pass++;
62
- else if (/\bFAIL\b|실패|오류|error|✗/i.test(b)) fail++;
63
- }
64
- const entries = blocks.length;
65
- return { entries, pass, fail, rate: (pass + fail) ? Math.round(pass / (pass + fail) * 100) : null };
66
- }
67
-
68
- // 1.9.305 (사용자 명시): AI 인식론적 정직성 점검 — 모르는 걸 아는 척 / 정보 미수집 / 미검증 섣부른 판단 휴리스틱 탐지.
69
- // 순수 함수(텍스트→findings). 휴리스틱 advisory — 단정/추정/외부참조 표현 vs 근거·수집 흔적 대조. opt-in 점검용.
70
- function _epistemicHonestyCheck(text) {
71
- const t = String(text || '');
72
- const findings = [];
73
- // 공통: 근거/출처 흔적 (파일경로·URL·테스트결과·Exit·문서·api-skill·인용·조회 흔적)
74
- const hasSource = /(?:[\w./-]+\.(?:js|ts|tsx|jsx|py|go|rs|rb|md|json|ya?ml|toml|sql|sh)\b)|https?:\/\/|\bExit\s*[:=]|\d+\s*\/\s*\d+\s*(?:통과|passed)|\b(?:passed|passing)\b|근거[::]|출처[::]|api-skill|공식\s*문서|문서\s*(?:확인|참조|에\s*따르면)|읽었|조회(?:함|했|함\b)|확인(?:함|했|됨)|grep|로그[::]/i.test(t);
75
- // 차원1: 모르는 걸 아는 척 — 단정 표현인데 근거 없음
76
- const definitive = /(반드시|항상|언제나|무조건|확실(?:히|함|하게)|당연히|틀림없|100\s*%|always|never|guaranteed|definitely|obviously|certainly)/i.test(t);
77
- if (definitive && !hasSource) findings.push({ dim: 'pretend-knowledge', severity: 'high', label: '근거 없는 단정', detail: '단정적 표현이 있으나 근거/출처(파일·문서·테스트·로그)가 없음 — 모르는 정보를 아는 척할 위험.' });
78
- // 차원2: 미검증 섣부른 판단 — 추정 표현 + 완료/성공 결론인데 근거 없음
79
- const assumption = /(아마|추정|것\s*같|듯\s*(?:하|싶)|probably|likely|maybe|perhaps|i\s*(?:think|assume|guess|believe|suppose)|should\s*(?:work|be|pass|fix)|생각(?:됩니다|된다|함|돼)|일\s*것|예상(?:됩니다|된다|됨)|짐작)/i.test(t);
80
- const conclusion = /(완료|done|성공|통과|해결(?:됨|했|함|되었)|fixed|resolved|works?\b|작동(?:함|한다|됨)|구현(?:됨|했|완료))/i.test(t);
81
- if (assumption && conclusion && !hasSource) findings.push({ dim: 'premature-judgment', severity: 'high', label: '검증 없는 섣부른 판단', detail: '가정·추정 표현과 완료·성공 결론이 함께 있으나 검증 근거가 없음 — 검증 없이 섣부르게 판단할 위험.' });
82
- // 차원3: 정보 미수집 — 외부 API/라이브러리/버전/스펙 언급인데 수집·근거 흔적 없음
83
- // \bAPI\b(?!\.[a-z]) 로 파일경로(api.js/api.ts) 오탐 제외. 강한 근거(hasSource)나 수집 흔적(gathered) 있으면 통과.
84
- const externalRef = /(\bAPI\b(?!\.[a-z])|\bSDK\b|라이브러리|\blibrary\b|\bpackage\b|엔드포인트|\bendpoint\b|버전\s*\d|v\d+\.\d+|\bspec\b|rate\s*limit|레이트\s*리밋|문서에\s*따르면)/i.test(t);
85
- const gathered = /(https?:\/\/|api-skill|공식\s*문서|\bdocs?\b|문서\s*(?:확인|참조|읽)|읽었|조회(?:함|했)|확인(?:함|했|됨)|fetch|검색(?:함|했)|레퍼런스|reference)/i.test(t);
86
- if (externalRef && !gathered && !hasSource) findings.push({ dim: 'no-info-gathering', severity: 'medium', label: '외부 정보 미수집', detail: '외부 API/라이브러리/버전/스펙 언급이 있으나 정보 수집(공식문서·api-skill·조회) 흔적이 없음 — 정확한 정보를 먼저 수집 권장.' });
87
- return { ok: findings.length === 0, findings, dimensions: ['pretend-knowledge', 'premature-judgment', 'no-info-gathering'] };
88
- }
89
-
90
- module.exports = { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInGit, _epistemicHonestyCheck };
1
+ // lib/analyzers.js — 순수 분석/검증 함수 (부작용 0, 입력→출력).
2
+ // 1.9.304 (UR-0025): bin/harness.js 에서 비파괴 분리. selftest(evidenceQuality/parseEvidenceStats/shellGuardAnalyze/claimFileInGit)가 동작 검증.
3
+ 'use strict';
4
+
5
+ function _shellGuardAnalyze(cmd, ctx) {
6
+ const c = String(cmd || '');
7
+ const shell = (ctx && ctx.shell) || 'unknown';
8
+ const psVer = ctx && ctx.psVersion != null ? parseInt(ctx.psVersion, 10) : null;
9
+ const issues = [];
10
+ const isWinPowerShell = shell === 'powershell' && psVer != null && psVer < 6; // 5.1 = Windows PowerShell
11
+ // 규칙 1: PowerShell 5.1 에서 && / || 체이닝 미지원 (pwsh 7+ 부터 지원)
12
+ if (isWinPowerShell && /&&|\|\|/.test(c)) { // 1.12.5 (15th 버그헌트 P2, UR-0018): 공백 무관 — PS5.1 은 a&&b 도 거부(이전 /\s&&\s/ 는 양쪽 공백 요구해 npm 체인 a&&b 미탐).
13
+ issues.push({ rule: 'ps5-chain', severity: 'error', detail: 'Windows PowerShell 5.1 은 && / || 연산자를 지원하지 않습니다 (PowerShell 7+ 부터 지원).', suggestion: 'A; if ($?) { B } (조건부) 또는 A; B (무조건) 로 분리. 또는 pwsh 7 설치.' });
14
+ }
15
+ // 규칙 2: PowerShell 에서 2>/dev/null → 2>$null
16
+ if (shell === 'powershell' && /2>\s*\/dev\/null/.test(c)) {
17
+ issues.push({ rule: 'ps-devnull', severity: 'error', detail: 'PowerShell 은 /dev/null 경로가 없습니다.', suggestion: '2>$null 사용 (PowerShell 리다이렉트).' });
18
+ }
19
+ // 규칙 3: PowerShell 에서 inline env (VAR=val cmd) 미지원
20
+ if (shell === 'powershell' && /^[A-Z_][A-Z0-9_]*=[^\s]+\s+\S/.test(c.trim())) {
21
+ issues.push({ rule: 'ps-inline-env', severity: 'error', detail: 'PowerShell 은 VAR=val cmd 형식의 inline 환경변수를 지원하지 않습니다.', suggestion: "$env:VAR='val'; cmd 로 분리." });
22
+ }
23
+ // 규칙 4: PowerShell 에서 Unix 전용 명령 (rm -rf / ls -la 등) — 별칭은 되나 플래그 오류 가능
24
+ if (shell === 'powershell' && /\brm\s+-rf\b/.test(c)) {
25
+ issues.push({ rule: 'ps-rm-rf', severity: 'warn', detail: 'PowerShell 에서 rm -rf 는 -rf 플래그 파싱 오류 가능 (rm 은 Remove-Item 별칭).', suggestion: 'Remove-Item -Recurse -Force <path> 사용.' });
26
+ }
27
+ // 규칙 5: CMD 에서 ; 는 명령 구분자가 아님 (한 줄로 실행됨)
28
+ if (shell === 'cmd' && /;/.test(c) && !/&&|\|\|/.test(c)) {
29
+ issues.push({ rule: 'cmd-semicolon', severity: 'warn', detail: 'CMD 는 ; 를 명령 구분자로 처리하지 않습니다 (인자로 전달됨).', suggestion: 'A && B (조건부) 또는 A & B (무조건) 사용.' });
30
+ }
31
+ // 규칙 6: PowerShell 에서 && 가 있으나 버전 미상 — 정보성
32
+ if (shell === 'powershell' && psVer == null && /&&|\|\|/.test(c)) { // 1.12.5 (UR-0018): 공백 무관 매칭
33
+ issues.push({ rule: 'ps-version-unknown', severity: 'info', detail: 'PowerShell 버전 미상 — 5.1 이면 && 미지원, 7+ 이면 지원.', suggestion: '$PSVersionTable.PSVersion 확인. 안전하게 A; if ($?) { B } 권장.' });
34
+ }
35
+ return { shell, psVersion: psVer, issues };
36
+ }
37
+ function _evidenceQuality(evidence) {
38
+ const e = String(evidence || '');
39
+ const hasFile = /(?:[A-Za-z][\w-]*[\/\\])?[A-Za-z][\w./\\-]*\.(?:js|ts|tsx|jsx|mjs|cjs|py|go|rs|rb|kt|cs|gd|java|php|swift|c|cpp|h|html|css|scss|vue|svelte|json|yaml|yml|toml|md|sql|prisma|sh)\b/i.test(e);
40
+ const hasTest = /(\d+)\s*(?:\/\s*\d+\s*)?(?:통과|passed|passing|개\s*테스트)|\btests?\b\s*[:=]?\s*\d|Tests?:\s*\d|\b\d+\s*tests?\b/i.test(e);
41
+ const hasLog = /Exit\s*[:=]|exit\s*code|Command\s*[:=]|npm\s+(?:test|run)|pytest|cargo\s+test|go\s+test/i.test(e);
42
+ const missing = [];
43
+ if (!hasFile) missing.push('수정 파일 경로');
44
+ if (!hasTest) missing.push('테스트명/개수');
45
+ if (!hasLog) missing.push('실행 로그(Command/Exit)');
46
+ return { hasFile, hasTest, hasLog, ok: hasFile && hasTest, missing };
47
+ }
48
+ function _claimFileInGit(claimed, gitSet) {
49
+ if (!gitSet) return null;
50
+ const c = String(claimed).replace(/\\/g, '/').replace(/^\.\//, '');
51
+ for (const g of gitSet) { if (g === c || g.endsWith('/' + c) || (g.indexOf('/') >= 0 && c.endsWith('/' + g))) return true; } // 1.35.5: reverse match 는 git 경로가 다중세그먼트일 때만 — bare basename 충돌(src/test.js ↔ test.js) 차단
52
+ return false;
53
+ }
54
+ function _parseEvidenceStats(text) {
55
+ const t = String(text || '');
56
+ const blocks = t.split(/\n(?=## )/).filter(b => /Command:|Exit:|verify|test/i.test(b));
57
+ let pass = 0, fail = 0;
58
+ for (const b of blocks) {
59
+ const exitM = b.match(/Exit:\s*(-?\d+)/i);
60
+ if (exitM) { (parseInt(exitM[1], 10) === 0 ? pass++ : fail++); continue; }
61
+ if (/\bPASS\b|통과|성공|✓/i.test(b)) pass++;
62
+ else if (/\bFAIL\b|실패|오류|error|✗/i.test(b)) fail++;
63
+ }
64
+ const entries = blocks.length;
65
+ return { entries, pass, fail, rate: (pass + fail) ? Math.round(pass / (pass + fail) * 100) : null };
66
+ }
67
+
68
+ // 1.9.305 (사용자 명시): AI 인식론적 정직성 점검 — 모르는 걸 아는 척 / 정보 미수집 / 미검증 섣부른 판단 휴리스틱 탐지.
69
+ // 순수 함수(텍스트→findings). 휴리스틱 advisory — 단정/추정/외부참조 표현 vs 근거·수집 흔적 대조. opt-in 점검용.
70
+ function _epistemicHonestyCheck(text) {
71
+ const t = String(text || '');
72
+ const findings = [];
73
+ // 공통: 근거/출처 흔적 (파일경로·URL·테스트결과·Exit·문서·api-skill·인용·조회 흔적)
74
+ const hasSource = /(?:[\w./-]+\.(?:js|ts|tsx|jsx|py|go|rs|rb|md|json|ya?ml|toml|sql|sh)\b)|https?:\/\/|\bExit\s*[:=]|\d+\s*\/\s*\d+\s*(?:통과|passed)|\b(?:passed|passing)\b|근거[::]|출처[::]|api-skill|공식\s*문서|문서\s*(?:확인|참조|에\s*따르면)|읽었|조회(?:함|했|함\b)|확인(?:함|했|됨)|grep|로그[::]/i.test(t);
75
+ // 차원1: 모르는 걸 아는 척 — 단정 표현인데 근거 없음
76
+ const definitive = /(반드시|항상|언제나|무조건|확실(?:히|함|하게)|당연히|틀림없|100\s*%|always|never|guaranteed|definitely|obviously|certainly)/i.test(t);
77
+ if (definitive && !hasSource) findings.push({ dim: 'pretend-knowledge', severity: 'high', label: '근거 없는 단정', detail: '단정적 표현이 있으나 근거/출처(파일·문서·테스트·로그)가 없음 — 모르는 정보를 아는 척할 위험.' });
78
+ // 차원2: 미검증 섣부른 판단 — 추정 표현 + 완료/성공 결론인데 근거 없음
79
+ const assumption = /(아마|추정|것\s*같|듯\s*(?:하|싶)|probably|likely|maybe|perhaps|i\s*(?:think|assume|guess|believe|suppose)|should\s*(?:work|be|pass|fix)|생각(?:됩니다|된다|함|돼)|일\s*것|예상(?:됩니다|된다|됨)|짐작)/i.test(t);
80
+ const conclusion = /(완료|done|성공|통과|해결(?:됨|했|함|되었)|fixed|resolved|works?\b|작동(?:함|한다|됨)|구현(?:됨|했|완료))/i.test(t);
81
+ if (assumption && conclusion && !hasSource) findings.push({ dim: 'premature-judgment', severity: 'high', label: '검증 없는 섣부른 판단', detail: '가정·추정 표현과 완료·성공 결론이 함께 있으나 검증 근거가 없음 — 검증 없이 섣부르게 판단할 위험.' });
82
+ // 차원3: 정보 미수집 — 외부 API/라이브러리/버전/스펙 언급인데 수집·근거 흔적 없음
83
+ // \bAPI\b(?!\.[a-z]) 로 파일경로(api.js/api.ts) 오탐 제외. 강한 근거(hasSource)나 수집 흔적(gathered) 있으면 통과.
84
+ const externalRef = /(\bAPI\b(?!\.[a-z])|\bSDK\b|라이브러리|\blibrary\b|\bpackage\b|엔드포인트|\bendpoint\b|버전\s*\d|v\d+\.\d+|\bspec\b|rate\s*limit|레이트\s*리밋|문서에\s*따르면)/i.test(t);
85
+ const gathered = /(https?:\/\/|api-skill|공식\s*문서|\bdocs?\b|문서\s*(?:확인|참조|읽)|읽었|조회(?:함|했)|확인(?:함|했|됨)|fetch|검색(?:함|했)|레퍼런스|reference)/i.test(t);
86
+ if (externalRef && !gathered && !hasSource) findings.push({ dim: 'no-info-gathering', severity: 'medium', label: '외부 정보 미수집', detail: '외부 API/라이브러리/버전/스펙 언급이 있으나 정보 수집(공식문서·api-skill·조회) 흔적이 없음 — 정확한 정보를 먼저 수집 권장.' });
87
+ return { ok: findings.length === 0, findings, dimensions: ['pretend-knowledge', 'premature-judgment', 'no-info-gathering'] };
88
+ }
89
+
90
+ module.exports = { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInGit, _epistemicHonestyCheck };
package/lib/audit.js CHANGED
@@ -6,6 +6,7 @@ const cp = require('child_process');
6
6
  const path = require('path');
7
7
  const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
8
8
  const { SECRET_PATTERNS } = require('./catalogs');
9
+ const { findCorruptedStateJson } = require('./state-integrity'); // 1.36.1 (클린룸 리뷰 FN): 상태 JSON 무결성
9
10
 
10
11
  function audit(root, opts = {}, deps = {}) {
11
12
  const { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills, _collectSecretFindings } = deps;
@@ -32,6 +33,19 @@ function audit(root, opts = {}, deps = {}) {
32
33
  process.exitCode = 1;
33
34
  return;
34
35
  }
36
+ // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태파일 JSON 무결성 — 깨진 JSON 을 그레이스풀 폴백(빈 상태)이 "healthy" 로 감추던 false-negative 차단.
37
+ // 손상 파일을 warning + corrupted_state_json finding 으로 표면화(--strict 시 failure 로 승격). 비-크래시(헬퍼가 파서 예외를 흡수).
38
+ try {
39
+ const corrupted = findCorruptedStateJson(root);
40
+ if (corrupted.length) {
41
+ warnings++;
42
+ warn(`상태파일 JSON 손상 ${corrupted.length}건: ${corrupted.map(c => c.file).join(', ')} (수동 복구 또는 leerness init 필요)`);
43
+ corrupted.slice(0, 6).forEach(c => log(` ${c.file}: ${c.error}`));
44
+ _finding('corrupted_state_json', 'warn', '.harness 상태파일 JSON 파싱 실패 (손상)', { count: corrupted.length, files: corrupted.map(c => c.file), sample: corrupted.slice(0, 10) });
45
+ } else {
46
+ ok('상태파일 JSON 무결성 OK (.harness/*.json)');
47
+ }
48
+ } catch {}
35
49
  const designCands = ['designguide.md','design-guide.md','docs/designguide.md','docs/design-guide.md','.harness/designguide.md'];
36
50
  const dups = designCands.filter(f => exists(path.join(root,f)));
37
51
  if (dups.length) { warnings++; warn(`design guide duplicates outside canonical: ${dups.join(', ')} (run: leerness consistency merge-design-guide)`); _finding('design_dup', 'warn', 'design guide duplicates outside canonical', { duplicates: dups }); }
package/lib/feature.js CHANGED
@@ -8,9 +8,12 @@ const { absRoot, log, ok, warn, fail, failJson } = require('./io');
8
8
  const { _nextFeatureId, _featureImpactBfs } = require('./pure-utils');
9
9
 
10
10
  function featureAddCmd(root, title, deps = {}) {
11
- const { _ensureFeatureGraph, _readFeatureGraph, _writeFeatureGraph, arg } = deps;
11
+ const { _ensureFeatureGraph, _readFeatureGraph, _writeFeatureGraph, arg, _requireInit } = deps;
12
12
  root = absRoot(root);
13
13
  if (!title) return fail('feature add: title 필요 — leerness feature add "<title>"');
14
+ // 1.36.2 (clean-room, UR-0184): 미초기화 dir 에 stray .harness scaffold 대신 에러 — task add 와 동일 게이트(_requireInit, --force 우회).
15
+ // 기존엔 _ensureFeatureGraph 가 무조건 .harness/feature-graph.md 생성 → 비-프로젝트 폴더 조용히 오염. (_requireInit 미주입 시 fail-open: 구버전/직접호출 호환)
16
+ if (typeof _requireInit === 'function' && !_requireInit(root, 'feature add')) return;
14
17
  _ensureFeatureGraph(root);
15
18
  const { nodes } = _readFeatureGraph(root);
16
19
  if (nodes.some(n => n.title.toLowerCase() === title.toLowerCase())) {
package/lib/health.js CHANGED
@@ -8,6 +8,7 @@ const path = require('path');
8
8
  const fs = require('fs');
9
9
  const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('./io');
10
10
  const { _parseArchiveBlocks } = require('./pure-utils');
11
+ const { findCorruptedStateJson } = require('./state-integrity'); // 1.36.1 (클린룸 리뷰 FN): 상태 JSON 무결성
11
12
 
12
13
  function healthCmd(root, deps = {}) {
13
14
  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;
@@ -78,6 +79,11 @@ function healthCmd(root, deps = {}) {
78
79
  for (const r of rows) byStatus[r.status] = (byStatus[r.status] || 0) + 1;
79
80
  out.checks.tasks = { total: rows.length, byStatus };
80
81
  } catch { out.checks.tasks = { error: 'tasks 점검 실패' }; }
82
+ // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태파일 JSON 무결성 — 손상 JSON 을 그레이스풀 폴백이 healthy 로 위조하던 false-negative 를 degraded 신호로 표면화.
83
+ try {
84
+ const corrupted = findCorruptedStateJson(root);
85
+ out.checks.stateIntegrity = { ok: corrupted.length === 0, corruptedCount: corrupted.length, corrupted: corrupted.map(c => ({ file: c.file, error: c.error })) };
86
+ } catch { out.checks.stateIntegrity = { error: 'stateIntegrity 점검 실패' }; }
81
87
  // 1.9.123: memorySurface 통합 (handoff --json 1.9.115 / session close --json 1.9.122 와 동일 패턴)
82
88
  try {
83
89
  const rows = readProgressRows(root);
@@ -292,6 +298,7 @@ function healthCmd(root, deps = {}) {
292
298
  if (out.checks.security?.hasDotEnv && out.checks.security?.envInGitignore === false) issues.push(t('🚨 .env가 .gitignore에 누락 (보안 CRITICAL)', '🚨 .env missing from .gitignore (security CRITICAL)'));
293
299
  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
300
  if (out.checks.security?.gitignoreMissingSecrets?.length) issues.push(t(`.gitignore 시크릿 누락 ${out.checks.security.gitignoreMissingSecrets.length}건`, `.gitignore missing secret patterns ${out.checks.security.gitignoreMissingSecrets.length}`));
301
+ if (out.checks.stateIntegrity?.corruptedCount > 0) issues.push(t(`🗄 상태파일 JSON 손상 ${out.checks.stateIntegrity.corruptedCount}건 (${out.checks.stateIntegrity.corrupted.map(c => c.file).join(', ')}) — 수동 복구/leerness init 필요`, `🗄 ${out.checks.stateIntegrity.corruptedCount} corrupted state JSON file(s) (${out.checks.stateIntegrity.corrupted.map(c => c.file).join(', ')}) — repair or leerness init`)); // 1.36.1 (클린룸 리뷰 FN)
295
302
  out.issues = issues;
296
303
  out.healthy = issues.length === 0;
297
304
 
@@ -330,6 +337,13 @@ function healthCmd(root, deps = {}) {
330
337
  log(`## tasks`);
331
338
  const tb = out.checks.tasks?.byStatus || {};
332
339
  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'}`));
340
+ // 1.36.1 (클린룸 리뷰 FN): 상태파일 JSON 무결성 표면화
341
+ log('');
342
+ log(t(`## 상태파일 무결성`, `## state integrity`));
343
+ const _si = out.checks.stateIntegrity || {};
344
+ if (_si.error) log(` n/a (${_si.error})`);
345
+ else if (_si.corruptedCount > 0) log(t(` ✗ JSON 손상 ${_si.corruptedCount}건: ${_si.corrupted.map(c => c.file).join(', ')}`, ` ✗ ${_si.corruptedCount} corrupted: ${_si.corrupted.map(c => c.file).join(', ')}`));
346
+ else log(t(` ✓ .harness/*.json 파싱 정상`, ` ✓ .harness/*.json parse OK`));
333
347
  // 1.9.163: 5능력 매트릭스 — 1.9.155 sub-agent 점검의 코드 기반 자동 평가
334
348
  if (out.capabilityMatrix && !out.capabilityMatrix.error) {
335
349
  log('');
package/lib/pure-utils.js CHANGED
@@ -1142,7 +1142,7 @@ function _migrationGuideText(version) {
1142
1142
  '',
1143
1143
  '## 3. 마이그레이션 적용 (임시설치 = npx 캐시, 격리)',
1144
1144
  ' npx leerness@latest update --yes --path <project> # 자동 마이그레이션 (.harness/archive 백업 + 신 스키마 반영)',
1145
- ' # 또는: npx leerness@latest migrate <project> --force # 강제 재스캐폴딩(비파괴, 기존 내용 보존)',
1145
+ ' # 또는: npx leerness@latest migrate <project> --force # 강제 재스캐폴딩 — 관리 .md 를 템플릿으로 교체(기존 내용은 .harness/archive 로 백업, in-place 보존 아님). 커스텀 편집 보존은 --force 없이 migrate / update --yes 사용',
1146
1146
  '',
1147
1147
  '## 4. 검증 (필수)',
1148
1148
  ' git -C <project> diff # 생성/수정 파일 전수 확인 (예상치 못한 변경 점검)',
@@ -1418,7 +1418,7 @@ function _parseAddTitle(args, startIdx = 0) {
1418
1418
  // 1.9.442 (12th 외부평가 Sonnet UR-0141): task 계열 positional path 안전 추출.
1419
1419
  // _parseAddTitle 과 동일한 path-like 판정(선행 구분자 / ./ ../ C:\)으로 제목/ID/맨이름은 경로로 오인 안 함(src/auth 같은 내부 슬래시 제목 보호).
1420
1420
  // 값-취하는 플래그(--evidence /abs/log 등)의 값은 root 후보에서 제외(직전 토큰이 값-플래그면 skip) → 오탐 차단. 첫 path-like positional 만 반환, 없으면 null.
1421
- const _TASK_VALUE_FLAGS = new Set(['--status', '--evidence', '--priority', '--note', '--reason', '--title', '--desc', '--summary', '--id', '--limit', '--from', '--to', '--trigger', '--tag']); // 1.9.445 (UR-0151): rule/lesson add 값-플래그(--trigger/--tag) 포함
1421
+ const _TASK_VALUE_FLAGS = new Set(['--status', '--evidence', '--priority', '--note', '--reason', '--title', '--desc', '--summary', '--id', '--limit', '--from', '--to', '--trigger', '--tag', '--files', '--depends-on', '--affects', '--co-changes-with']); // 1.9.445 (UR-0151): rule/lesson add 값-플래그(--trigger/--tag) 포함. 1.36.2 (UR-0184): feature add 값-플래그(--files 등) 값이 path-like 여도 root 로 오인 안 하게 포함
1422
1422
  function _taskPositionalPath(args, startIdx = 2) {
1423
1423
  const a = args || [];
1424
1424
  for (let i = startIdx; i < a.length; i++) {
@@ -0,0 +1,38 @@
1
+ // lib/state-integrity.js — .harness/*.json 상태파일 JSON 무결성 검사 (단일출처, 1.36.1).
2
+ // 배경(클린룸 리뷰 FN): health/doctor/check/audit 는 상태 JSON 을 try{JSON.parse}catch{빈 상태} 로 그레이스풀
3
+ // 폴백하느라, .harness/manifest.json 등이 깨진 JSON 이어도 전부 "healthy"/exit 0 을 반환하던 false-negative 가 있었다.
4
+ // → 어떤 명령도 워크스페이스 상태파일의 JSON 무결성을 검증하지 않던 갭을 이 헬퍼로 메운다(audit/health/check 공유).
5
+ 'use strict';
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ // .harness 바로 아래의 *.json 상태파일을 열거해 JSON.parse 를 시도, 파싱 실패 파일 목록을 반환.
10
+ // 반환: [{ file: '.harness/<name>.json', error: '<message>' }, …] (파일명 오름차순 — 결정적/테스트 안정).
11
+ // 설계:
12
+ // - 존재하는 파일만 검사(부재는 무결성 문제 아님 — 그레이스풀 빈 상태로 취급).
13
+ // - 비-재귀(archive/api-skills/cache 하위는 코어 상태파일 아님 → 오탐 방지) · 파일만(디렉토리 skip).
14
+ // - 빈/공백-only 파일은 손상 아님(그레이스풀 빈 상태) — 오탐 차단.
15
+ // - BOM strip 후 파싱(io.read 와 동일 — Windows PowerShell Out-File 등의 BOM 대응).
16
+ // - 비-크래시: .harness 부재/디렉토리 읽기 오류 → 빈 배열, 개별 파일 읽기 오류 → skip.
17
+ function findCorruptedStateJson(root) {
18
+ const dir = path.join(root, '.harness');
19
+ let entries;
20
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
21
+ catch { return []; } // .harness 없으면 검사 대상 0 (미초기화는 별도 체크가 담당)
22
+ const corrupted = [];
23
+ for (const e of entries) {
24
+ if (!e.isFile() || !e.name.endsWith('.json')) continue;
25
+ const p = path.join(dir, e.name);
26
+ let text;
27
+ try { text = fs.readFileSync(p, 'utf8'); }
28
+ catch { continue; } // 읽기 불가(권한 등)는 JSON 무결성과 별개 문제 — skip
29
+ if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1); // BOM strip
30
+ if (text.trim() === '') continue; // 빈 파일 = 그레이스풀 빈 상태(손상 아님)
31
+ try { JSON.parse(text); }
32
+ catch (err) { corrupted.push({ file: '.harness/' + e.name, error: String((err && err.message) || err).slice(0, 200) }); }
33
+ }
34
+ corrupted.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
35
+ return corrupted;
36
+ }
37
+
38
+ module.exports = { findCorruptedStateJson };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.35.17",
3
+ "version": "1.36.3",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
@@ -196,6 +196,70 @@ console.log('# leerness core (test:core) — flagship behavioral guarantees');
196
196
  }
197
197
  }
198
198
 
199
+ // (10) --json 무결성: 변형/조회 명령이 성공 경로에서도 유효 JSON (1.36.0, 클린룸 리뷰 B)
200
+ {
201
+ const d = fresh();
202
+ const okJson = (r) => { try { JSON.parse((r.stdout || '').trim()); return true; } catch { return false; } };
203
+ run(d, ['task', 'add', 'sample']);
204
+ assert('json: task update --status done --json → valid JSON (was plain text)', okJson(run(d, ['task', 'update', 'T-0002', '--status', 'done', '--json'])));
205
+ assert('json: plan add --json → valid JSON', okJson(run(d, ['plan', 'add', 'a milestone', '--json'])));
206
+ assert('json: rule verify --json → valid JSON', okJson(run(d, ['rule', 'verify', '--json'])));
207
+ assert('json: reuse find --json → valid JSON', okJson(run(d, ['reuse', 'find', 'button', '--json'])));
208
+ fs.rmSync(d, { recursive: true, force: true });
209
+ }
210
+
211
+ // (11) 상태파일 JSON 무결성: 손상 .harness/*.json 을 audit/health/check 가 표면화 (클린룸 리뷰 FN, 1.36.1)
212
+ // 회귀 배경: 그레이스풀 폴백(try{JSON.parse}catch{빈상태})이 깨진 상태 JSON 을 "healthy"/exit 0 으로 감추던 false-negative.
213
+ {
214
+ const d = fresh();
215
+ const auditJson = (dir) => { const r = cp.spawnSync(process.execPath, [CLI, 'audit', dir, '--json'], { encoding: 'utf8', timeout: 40000, env: { ...process.env, LEERNESS_OFFLINE: '1' } }); try { return JSON.parse(r.stdout); } catch { return null; } };
216
+ const healthJson = (dir) => { const r = cp.spawnSync(process.execPath, [CLI, 'health', dir, '--json'], { encoding: 'utf8', timeout: 40000 }); try { return JSON.parse(r.stdout); } catch { return null; } };
217
+ const hasK = (j, k) => !!(j && (j.findings || []).some(f => f.kind === k));
218
+ // FP 가드: 클린(유효 JSON) 프로젝트 → 손상 finding 없음 + check exit 0 + health.stateIntegrity.ok
219
+ assert('state-integrity: clean project → no corrupted_state_json (FP guard)', !hasK(auditJson(d), 'corrupted_state_json'));
220
+ assert('state-integrity: clean project → check exit 0', run(d, ['check']).status === 0);
221
+ {
222
+ const hc = healthJson(d);
223
+ assert('state-integrity: clean project → health.stateIntegrity.ok', !!(hc && hc.checks && hc.checks.stateIntegrity && hc.checks.stateIntegrity.ok === true));
224
+ }
225
+ // 손상 주입: manifest.json 을 깨진 JSON 으로 덮어씀 (리뷰 재현과 동일)
226
+ fs.writeFileSync(path.join(d, '.harness', 'manifest.json'), '{ this is : not valid json ]]]');
227
+ assert('state-integrity: corrupted manifest.json → audit corrupted_state_json finding', hasK(auditJson(d), 'corrupted_state_json'));
228
+ assert('state-integrity: corrupted manifest.json → check exit 1 (hard gate)', run(d, ['check']).status === 1);
229
+ {
230
+ const hc = healthJson(d);
231
+ const flagged = !!(hc && hc.healthy === false && hc.checks && hc.checks.stateIntegrity && hc.checks.stateIntegrity.corruptedCount === 1 && (hc.checks.stateIntegrity.corrupted || []).some(c => c.file === '.harness/manifest.json'));
232
+ assert('state-integrity: corrupted manifest.json → health degraded (healthy=false + stateIntegrity)', flagged);
233
+ }
234
+ // 비-크래시 확인: 손상 상태에서도 audit/health/check 는 예외 없이 종료(위 spawn 이 이미 parse 됨 → 크래시 아님)
235
+ fs.rmSync(d, { recursive: true, force: true });
236
+ }
237
+
238
+ // (12) feature add/show 가 trailing positional path 를 존중 — cwd 조용한 오독 + stray .harness scaffold 차단 (클린룸 리뷰 UR-0184, 1.36.2)
239
+ // 회귀 배경: add 가 모든 non-flag positional 을 NAME 으로 join → 경로가 이름에 흡수 + 비-프로젝트 cwd 에 stray .harness scaffold(조용한 오염).
240
+ {
241
+ const target = fresh(); // 초기화된 타깃 프로젝트
242
+ const outsider = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-nonproj-')); // 비-프로젝트 cwd (init 안 함)
243
+ const at = (dir, args) => cp.spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', timeout: 40000, cwd: dir });
244
+ const graphOf = (dir) => { try { return fs.readFileSync(path.join(dir, '.harness', 'feature-graph.md'), 'utf8'); } catch { return ''; } };
245
+ // (a) 비-프로젝트 cwd 에서 `feature add "이름" <타깃경로>` → 타깃에 등록 + 이름에 경로 흡수 없음(node heading title clean)
246
+ at(outsider, ['feature', 'add', 'PosPathFeat', target]);
247
+ assert('feature add: positional path → 타깃 등록 + 이름 clean(경로 흡수 없음)', /^## F-\d{4} PosPathFeat\s*$/m.test(graphOf(target)));
248
+ // (b) 비-프로젝트 cwd 에 stray .harness scaffold 안 함 (조용한 오염 차단)
249
+ assert('feature add: positional path → 비-프로젝트 cwd 에 .harness scaffold 안 함', !fs.existsSync(path.join(outsider, '.harness')));
250
+ // (c) 경로 미지정 + 비-프로젝트 dir → init 게이트로 exit 1 + scaffold 없음 (option b)
251
+ const orphan = at(outsider, ['feature', 'add', 'OrphanFeat']);
252
+ assert('feature add: 미초기화 dir(경로 미지정) → init 게이트 exit 1 + scaffold 없음', orphan.status === 1 && !fs.existsSync(path.join(outsider, '.harness')));
253
+ // (d) show 도 positional path 존중 — 타깃 노드 조회(cwd 아님)
254
+ const showP = at(outsider, ['feature', 'show', 'F-0001', target]);
255
+ assert('feature show: positional path → 타깃 노드 조회(cwd 아님)', showP.status === 0 && /PosPathFeat/.test(showP.stdout || ''));
256
+ // (e) --path 는 여전히 우선(회귀 없음)
257
+ at(outsider, ['feature', 'add', 'ViaFlag', '--path', target]);
258
+ assert('feature add: --path 우선 보존(회귀 없음)', /^## F-\d{4} ViaFlag\s*$/m.test(graphOf(target)) && !fs.existsSync(path.join(outsider, '.harness')));
259
+ fs.rmSync(target, { recursive: true, force: true });
260
+ fs.rmSync(outsider, { recursive: true, force: true });
261
+ }
262
+
199
263
  const dur = ((Date.now() - t0) / 1000).toFixed(1);
200
264
  console.log(`\nCore result: ${total - failed}/${total} passed · ${dur}s`);
201
265
  if (failed > 0) process.exit(1);
package/scripts/e2e.js CHANGED
@@ -5881,6 +5881,45 @@ total++;
5881
5881
  if (!ok) failed++;
5882
5882
  }
5883
5883
 
5884
+ // 1.36.2 회귀 (클린룸 리뷰, UR-0184): feature add/show/link/impact 도 trailing positional path 인식 —
5885
+ // 기존엔 add 가 모든 non-flag positional 을 NAME 으로 join → 경로가 이름에 흡수 + 비-프로젝트 cwd 에 stray .harness scaffold(조용한 오독).
5886
+ // fix: --path > path-like positional > cwd; add 의 NAME 은 _parseAddTitle 로 절단; 미초기화 dir 은 _requireInit 게이트(scaffold 대신 에러).
5887
+ total++;
5888
+ {
5889
+ let ok = false;
5890
+ try {
5891
+ const target = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-featpos-t-'));
5892
+ const outsider = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-featpos-x-')); // 비-프로젝트 cwd
5893
+ cp.spawnSync(process.execPath, [CLI, 'init', target, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
5894
+ const at = (dir, a) => cp.spawnSync(process.execPath, [CLI, ...a], { encoding: 'utf8', timeout: 20000, cwd: dir });
5895
+ const graphOf = (dir) => { try { return fs.readFileSync(path.join(dir, '.harness', 'feature-graph.md'), 'utf8'); } catch { return ''; } };
5896
+ // (a) 비-프로젝트 cwd 에서 positional path → 타깃 등록 + 이름 clean(경로 흡수 없음)
5897
+ at(outsider, ['feature', 'add', 'PosFeat', target]);
5898
+ const addedClean = /^## F-\d{4} PosFeat\s*$/m.test(graphOf(target));
5899
+ // (b) 비-프로젝트 cwd 에 stray .harness scaffold 안 함
5900
+ const noStray = !fs.existsSync(path.join(outsider, '.harness'));
5901
+ // (c) 경로 미지정 + 미초기화 dir → init 게이트 exit 1 + scaffold 없음
5902
+ const orphan = at(outsider, ['feature', 'add', 'Orphan']);
5903
+ const gated = orphan.status === 1 && !fs.existsSync(path.join(outsider, '.harness'));
5904
+ // (d) show/link/impact 도 positional path 존중 (cwd 아님)
5905
+ at(outsider, ['feature', 'add', 'PosFeat2', target]);
5906
+ const showP = at(outsider, ['feature', 'show', 'F-0001', target]);
5907
+ const showOk = showP.status === 0 && /PosFeat/.test(showP.stdout || '');
5908
+ at(outsider, ['feature', 'link', 'F-0001', '--affects', 'F-0002', target]);
5909
+ const impactP = at(outsider, ['feature', 'impact', 'F-0001', target]);
5910
+ const impactOk = impactP.status === 0 && /F-0002/.test(impactP.stdout || '');
5911
+ // (e) --path 우선 보존(회귀 없음) + --files 의 path-like 값이 root 로 오인 안 됨
5912
+ at(outsider, ['feature', 'add', 'ViaFlag', '--files', './src/x.js', '--path', target]);
5913
+ const flagOk = /^## F-\d{4} ViaFlag\s*$/m.test(graphOf(target)) && !fs.existsSync(path.join(outsider, '.harness'));
5914
+ fs.rmSync(target, { recursive: true, force: true });
5915
+ fs.rmSync(outsider, { recursive: true, force: true });
5916
+ ok = addedClean && noStray && gated && showOk && impactOk && flagOk;
5917
+ if (!ok) console.log(` [featpos 디버그] clean=${addedClean} noStray=${noStray} gated=${gated} show=${showOk} impact=${impactOk} flag=${flagOk}`);
5918
+ } catch {}
5919
+ console.log(ok ? '✓ B(1.36.2) 클린룸: feature add/show/link/impact positional path 인식 + 미초기화 scaffold 게이트 (UR-0184)' : '✗ feature positional path 실패');
5920
+ if (!ok) failed++;
5921
+ }
5922
+
5884
5923
  // 1.9.413 회귀 (6th외부평가 codex P2, UR-0101): action 명령 --json 구조화 출력 + 데이터 영속 + 사람용 보존
5885
5924
  total++;
5886
5925
  {
@@ -6919,5 +6958,39 @@ total++;
6919
6958
  if (!ok) failed++;
6920
6959
  }
6921
6960
 
6961
+ // 1.36.1 회귀 (클린룸 리뷰 FN): 상태파일 JSON 무결성 — 손상 .harness/*.json 을 audit(warning)/health(degraded)/check(exit 1) 가 표면화.
6962
+ // 배경: 그레이스풀 폴백이 깨진 상태 JSON 을 "healthy"/exit 0 으로 감추던 false-negative(health/doctor/check 전부). 클린(유효 JSON)엔 무오탐.
6963
+ total++;
6964
+ {
6965
+ let ok = false;
6966
+ try {
6967
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-si-'));
6968
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6969
+ const auditJson = () => { const r = cp.spawnSync(process.execPath, [CLI, 'audit', d, '--json'], { encoding: 'utf8', timeout: 40000, env: { ...process.env, LEERNESS_OFFLINE: '1' } }); try { return JSON.parse(r.stdout); } catch { return null; } };
6970
+ const healthJson = () => { const r = cp.spawnSync(process.execPath, [CLI, 'health', d, '--json'], { encoding: 'utf8', timeout: 40000 }); try { return JSON.parse(r.stdout); } catch { return null; } };
6971
+ const checkExit = () => cp.spawnSync(process.execPath, [CLI, 'check', d], { encoding: 'utf8', timeout: 40000 }).status;
6972
+ const hasK = (j, k) => !!(j && (j.findings || []).some(f => f.kind === k));
6973
+ // 클린: 무오탐 + check exit 0 + health.stateIntegrity.ok
6974
+ const cleanAudit = !hasK(auditJson(), 'corrupted_state_json');
6975
+ const cleanCheck = checkExit() === 0;
6976
+ const hc0 = healthJson();
6977
+ const cleanHealth = !!(hc0 && hc0.checks && hc0.checks.stateIntegrity && hc0.checks.stateIntegrity.ok === true);
6978
+ // 손상 주입(리뷰 재현): manifest.json → 깨진 JSON
6979
+ fs.writeFileSync(path.join(d, '.harness', 'manifest.json'), '{ this is : not valid json ]]]');
6980
+ const badAudit = hasK(auditJson(), 'corrupted_state_json');
6981
+ const badCheck = checkExit() === 1;
6982
+ const hc1 = healthJson();
6983
+ const badHealth = !!(hc1 && hc1.healthy === false && hc1.checks.stateIntegrity && hc1.checks.stateIntegrity.corruptedCount === 1 && (hc1.checks.stateIntegrity.corrupted || []).some(c => c.file === '.harness/manifest.json'));
6984
+ // audit --strict: warning 승격 → exit 1 (게이트 친화)
6985
+ const strictExit = cp.spawnSync(process.execPath, [CLI, 'audit', d, '--strict', '--no-npm-audit'], { encoding: 'utf8', timeout: 40000, env: { ...process.env, LEERNESS_OFFLINE: '1' } }).status;
6986
+ const strictBlocks = strictExit === 1;
6987
+ fs.rmSync(d, { recursive: true, force: true });
6988
+ ok = cleanAudit && cleanCheck && cleanHealth && badAudit && badCheck && badHealth && strictBlocks;
6989
+ if (!ok) console.log(` [si 디버그] cleanA=${cleanAudit} cleanC=${cleanCheck} cleanH=${cleanHealth} badA=${badAudit} badC=${badCheck} badH=${badHealth} strict=${strictBlocks}`);
6990
+ } catch (e) {}
6991
+ console.log(ok ? '✓ B(1.36.1) 클린룸 FN: 상태 JSON 손상 표면화(audit finding/health degraded/check exit 1 + --strict 승격) + 클린 무오탐' : '✗ 상태 JSON 무결성 회귀가드 실패');
6992
+ if (!ok) failed++;
6993
+ }
6994
+
6922
6995
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
6923
6996
  if (failed > 0) process.exit(1);