leerness 1.17.0 → 1.18.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,98 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.18.0 — 2026-06-10 — 🛡️ [안정화/Stable] 범용 검증 하네스 안정 minor
4
+
5
+ **🛡️ 안정화(Stable) minor — "범용 AI 코딩 하네스" 실증 격차 전부 해소.** 5개 독립 클린룸 실사용 평가(Python/Node/Rust 실개발·에이전트 교대·적대 공격)가 찾은 P1 2건 + P2 3건(1.17.2~1.17.6)을 검증·통합해 npm 공개. R-0011 정책의 9번째 minor. 새 포지셔닝: **"어떤 언어, 어떤 AI 에이전트로 작업하든 — 증거 없이는 끝났다고 말할 수 없게."**
6
+
7
+ ### 이번 minor 통합 (1.17.1~1.17.6)
8
+ - **🌐 테스트 명령 해석 체인 (P1)**: `verify-claim --run-tests` 가 npm test 하드코딩 → `--test-cmd` > 설정 `testCommand` > 실제 npm test(placeholder 제외) > skip. 파이썬 프로젝트 오판 해소 + pytest 출력 파싱.
9
+ - **🛡️ 빈껍데기·가짜 테스트 차단 (P1)**: 주석뿐 구현(비주석 코드 0줄) done 게이팅 FAIL + 테스트-구현 연결 검사(12개 언어, 파이썬 `#` 스텁 포함). `--json implementationSubstance/testImplLink`.
10
+ - **🧪 테스트 카운트 정직화 (P2)**: pytest·루트 `*.test.*` 관례 인식 + 측정불가="검증 미수행"(통과 위장 금지) + 한국어 "테스트 N개" 주장 추출(부풀린 주장 차단).
11
+ - **🔗 unknown flag 거부 (P2)**: `task update --next-action` 같은 미존재 옵션이 값을 조용히 버리던 것 → 거부 + did-you-mean(인수인계 유실 차단).
12
+ - **🔚 마감 정합 (P2)**: session close 가 done 낙관 의심·살아있는 시크릿을 재확인해 표면화 — 마감이 마지막 관문 역할.
13
+ - **plan add 공백제목 손상 수정** + milestone 파서 개행 미흡수(1.17.1).
14
+
15
+ ### 검증 (회귀 0)
16
+ - **selftest 222 PASS** · **E2E 365/365 PASS** · npm gate=minor_bump. 각 격차 행위 재현(공격 차단 + 정직 작업 과탐 0) 완료.
17
+
18
+ ### 안정화 표시 (R-0006)
19
+ CHANGELOG [안정화/Stable] · git tag (Stable) · GitHub release (Stable) · npm dist-tag `stable` 시도.
20
+
21
+ ## 1.17.6 — 2026-06-10 — 범용성⑤(P2 완결): session close 마감 정합 (UR-0049)
22
+
23
+ **🔚 마감이 "마지막 관문" 역할을 하도록.** 5축 실증에서 — verify-claim 이 거짓으로 판정했던 task 가 마감 보고서에 평범한 "done"으로, gate 가 시크릿으로 실패 중인데 "clean (sleep 안전)"으로 선언되던 정합 문제 해소. **이로써 5축 격차(P1 2건 + P2 3건) 전부 닫힘.**
24
+
25
+ ### 변경 (UR-0049)
26
+ - **done 낙관 재확인**: 마감 시 done task 들의 evidence 를 코드 흔적과 재대조(`_detectOptimism`) — verify-claim 을 건너뛴 거짓 주장(예: "DB 저장 구현" 인데 코드에 DB 호출 없음)을 `⚠ 낙관 의심 미해소` 로 표면화. `--json completionHonesty.optimismUnresolved`.
27
+ - **마감 보안 재확인**: 커밋 대상 시크릿이 살아있으면 `🚨 마감 보안: N건 미해소 — clean 아님` 표면화. `--json closeSecurity.committedSecrets`.
28
+ - advisory(차단 X) — 마감 흐름은 유지하되 거짓/위험이 조용히 통과하지 않음.
29
+
30
+ ### 검증 (회귀 0)
31
+ - **selftest 221→222** · **E2E 365/365** · 행위 3종: 거짓 DB 주장 done + 시크릿 → 두 경고 표면화 ✓ · --json 필드 ✓ · 진짜 구현+시크릿 제거 → 경고 0(과탐 0) ✓.
32
+ - patch(1.17.6) — npm 미배포(R-0011, GitHub). **다음: 1.18.0 [Stable] "범용 검증 하네스" minor(1.17.1~1.17.6 묶음 공개).**
33
+
34
+ ## 1.17.5 — 2026-06-10 — 범용성④: 모르는 옵션 조용히 무시 차단 (UR-0048)
35
+
36
+ **🔗 인수인계 유실의 최악 양식 차단.** 5축 실증에서 — `task update --next-action "..."` 처럼 존재하지 않는 옵션을 줘도 "✓ task updated"만 출력하고 값을 버려, **쓴 에이전트는 기록됐다고 믿고 다음 에이전트는 placeholder 를 받던** P2 해소.
37
+
38
+ ### 변경 (UR-0048)
39
+ - **`task update` unknown flag 거부**: 지원 외 `--플래그` 발견 시 exit 1 + **did-you-mean**(`--next-action` → "혹시 `--next`?") + 지원 플래그/사용법 안내. `--json` 은 `{code:'unknown_flag'}` 구조화.
40
+ - 전역 플래그(`--path/--json/--force/--lenient`)는 항상 허용 — 과탐 0. 재사용 가능한 `_rejectUnknownFlags` 헬퍼(다른 쓰기 명령 확대 여지).
41
+
42
+ ### 검증 (회귀 0)
43
+ - **selftest 220→221** · **E2E 365/365** · 행위 4종: `--next-action` 거부+제안 ✓ · `--json` 구조화 ✓ · 올바른 `--next` 정상 저장 ✓ · 전역 플래그 통과 ✓.
44
+ - patch(1.17.5) — npm 미배포(R-0011, GitHub). 잔여: UR-0049(마감 정합) → 완료 시 1.18.0 "범용 검증" minor.
45
+
46
+ ## 1.17.4 — 2026-06-10 — 범용성③: 테스트 카운트 "측정실패=통과" 역전 해소 (UR-0047)
47
+
48
+ **🧪 5개 클린룸 리포트가 공통 지적한 P2.** "실측: 테스트 파일 못 찾음"이라면서 바로 아래 "✓ pass (실측 ≥ 주장)"로 통과시키던 모순 — 부풀린 테스트 주장("cargo test 12개", "테스트 50개")이 검증을 헛통과하던 것 해소.
49
+
50
+ ### 변경 (UR-0047)
51
+ - **관례 확대**: 주장된 테스트 파일 우선 합산 + 글롭(pytest `test_*.py`·`*_test.py` / 루트·tests/ 의 `*.test.*`·`*.spec.*`) — 이전엔 `tests/test.js` 등 3개 하드코딩. 파이썬은 `def test_` 개수로 카운트.
52
+ - **측정불가 = 검증 미수행**: 테스트 파일을 못 찾으면 "⊘ 측정 불가 — 주장 N개 검증 미수행 (pass 아님)" 정직 표기 + verdict `testCountMatch: null`(게이팅 미기여 — false 만 FAIL).
53
+ - **🐛 주장 추출 보강(맹신 X 행위검증 중 발견)**: "테스트 50개"(한국어 자연어순)를 못 잡아 **부풀린 주장이 카운트 검증을 아예 안 탔음** → `테스트 N개` 패턴 추가.
54
+
55
+ ### 검증 (회귀 0)
56
+ - **selftest 219→220** · **E2E 365/365** · 행위 4종: pytest 실측(2개) ✓ · **부풀린 "테스트 50개"(실측 2) exit 1 차단**(이전 헛통과) · 측정불가 정직 표기 ✓ · 루트 `*.test.js` 인식(실측 3) ✓ · 정직 주장 exit 0(과탐 0).
57
+ - patch(1.17.4) — npm 미배포(R-0011, GitHub). 잔여: UR-0048(unknown flag)·UR-0049(마감 정합).
58
+
59
+ ## 1.17.3 — 2026-06-10 — 범용성②: 빈껍데기 구현·가짜 테스트 차단 (UR-0046)
60
+
61
+ **🛡️ 간판 기능 "거짓완료 차단"의 상한선 끌어올리기.** 5축 실증 분석의 Attack C — 주석뿐인 구현 파일 + 아무것도 검사하지 않는 `assert(true)` 테스트가 `verify-claim` 을 **exit 0 으로 완전 통과**하던 P1 — 을 차단.
62
+
63
+ ### 변경 (UR-0046)
64
+ - **구현 실체(스텁) 검사**: 주장된 구현 파일(테스트 제외, js/ts/py/go/rs 등 12종 확장자)의 **비주석 코드줄이 0** 이면 done 게이팅 FAIL — "주석/TODO 뿐인 빈껍데기"를 확실 신호만으로 차단(과탐 0).
65
+ - **테스트-구현 연결 검사**: 구현+테스트가 함께 주장됐는데 어떤 테스트도 구현 파일명을 참조하지 않으면 "빈 테스트 의심" advisory ⚠ (`--strict-claims` 시 FAIL).
66
+ - `--json` verdict 에 `implementationSubstance` / `testImplLink` + `stubFiles` 노출(머신 경로 동일 게이팅).
67
+
68
+ ### 검증 (회귀 0)
69
+ - **selftest 218→219** · **E2E 365/365** · Attack C 재현: 스텁+가짜테스트 **exit 0→1 차단** · 진짜 구현 exit 0(과탐 0) · **파이썬 스텁(#주석뿐)도 차단**(언어중립) · --json verdict 노출.
70
+ - patch(1.17.3) — npm 미배포(R-0011, GitHub). 잔여 격차: UR-0047(테스트카운트 역전)·0048(unknown flag)·0049(마감 정합).
71
+
72
+ ## 1.17.2 — 2026-06-10 — 범용성①: verify-claim 테스트 명령 해석 체인 (UR-0045)
73
+
74
+ **🌐 "범용 AI 코딩 하네스" 5축 실증 분석의 P1 1호 해소.** `verify-claim --run-tests` 가 `npm test` 하드코딩이라 — 테스트가 전부 통과한 파이썬 프로젝트(npm init 잔재 placeholder 존재)를 "주장 불일치 FAIL"로 오판하던 범용성 정면 반례를 수정.
75
+
76
+ ### 변경 (UR-0045)
77
+ - **테스트 명령 해석 체인**: `--test-cmd "<명령>"` > `.harness/leerness-config.json` 의 `"testCommand"` > package.json 의 **실제** test 스크립트(npm placeholder "no test specified" 는 테스트가 아니므로 제외) > **skip 표기**(불일치 판정 금지).
78
+ - **pytest 출력 파싱**: "N passed in …s" 형식 인식(파이썬 러너 pass/fail 비율 표시).
79
+ - 신규 value-flag `--test-cmd` 를 `nonFlagArgs` withValue 에 등록(1.14.2 교훈 적용 — 제목 흡수 차단).
80
+
81
+ ### 검증 (회귀 0)
82
+ - **selftest 217→218** · **E2E 365/365** · 행위 재현: ① placeholder-only 파이썬 프로젝트 `--run-tests` → skip + exit 0(오판 해소, 이전 exit 1) ② `--test-cmd "node mytest.js"` → 실행+파싱(2/2 passed)+주장 대조 ✓ ③ config testCommand 경유 동작 ④ 실제 npm test 스크립트는 기존대로 ⑤ 실패 명령은 정직하게 exit 1.
83
+ - patch(1.17.2) — npm 미배포(R-0011, GitHub). 다음: UR-0046(스텁/연결 검사 — 빈껍데기 구현 차단).
84
+
85
+ ## 1.17.1 — 2026-06-09 — 17번째 버그헌트: plan add 공백제목 데이터 손상 차단
86
+
87
+ **🔬 17번째 버그헌트(이번 세션 신규코드 회귀 + 광역 스윕).** 신규코드(gate --json stdout-swap·memory --json·_GROUP_USAGE·scan break·_cellSafe)는 **0 결함**(5000키 664ms 등 입증). plan add 입력검증 1건 발견·수정.
88
+
89
+ ### 수정 (재현 검증)
90
+ - **🟠 plan add 공백 제목 → plan.md 손상 (P2)**: `plan add " "`(공백만)이 `|| 기본값`을 우회(truthy)해 빈 제목으로 기록 → milestone 파서의 `\s*` 가 개행을 흡수해 **다음 줄 `Status: planned` 를 제목으로 오인**. 2중 수정: ① dispatch 에서 `.trim()` 후 판정(공백→기본값 '새 계획'), ② 파서 `\.\s*` → `\.[ \t]*`(개행 미흡수, 5곳 + pure-utils) — 손상 클래스 차단.
91
+
92
+ ### 검증 (회귀 0)
93
+ - **selftest 216→217** · **E2E 365/365** · `plan add " "` → '새 계획'(손상 없음)·정상 제목 보존 행위 재현.
94
+ - patch(1.17.1) — npm 미배포(R-0011, GitHub). (P3 plan add 빈-인자 기본값은 의도된 동작 — trim 으로 안전화.)
95
+
3
96
  ## 1.17.0 — 2026-06-09 — 🛡️ [안정화/Stable] 외부 클린룸 일관성 안정 minor
4
97
 
5
98
  **🛡️ 안정화(Stable) minor.** 외부 클린룸 리뷰(게시본 무README 신규사용자 관점)에서 도출한 --json·CLI 일관성 개선(1.16.1~1.16.2)을 검증·통합해 npm 공개. R-0011 정책의 8번째 minor. 영상은 HyperFrames "문제→해소" 디자인.
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝
10
10
  ```
11
11
 
12
- > **AI 코딩 에이전트를 위한 운영 레이어(operating layer).** 코드를 대신 쓰는 도구가 아니라, AI 에이전트의 **기억·인수인계·검증·감사·보안 가드**를 프로젝트에 영속화하는 CLI + MCP 서버입니다.
12
+ > **어떤 언어, 어떤 AI 에이전트로 작업하든 "증거 없이는 끝났다고 말할 수 없게" 만드는 AI 코딩 운영 레이어.** 코드를 대신 쓰는 도구가 아니라, AI 에이전트의 **기억·인수인계·검증·감사·보안 가드**를 프로젝트에 영속화하는 CLI + MCP 서버입니다. (이 포지셔닝은 5개 독립 클린룸 실사용 평가 — Python/Node/Rust 실개발·에이전트 교대·적대 공격 — 로 검증됐습니다.)
13
13
 
14
14
  [![npm](https://img.shields.io/npm/v/leerness)](https://www.npmjs.com/package/leerness) · ![MCP tools](https://img.shields.io/badge/MCP--tools-85-blue) · **런타임 의존성 0** · **install-script 0** · offline-first · Node ≥ 18 · MIT
15
15
 
@@ -186,7 +186,7 @@ MIT
186
186
  <!-- leerness:project-readme:start -->
187
187
  ## Leerness Project Harness
188
188
 
189
- 이 프로젝트는 Leerness v1.17.0 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
189
+ 이 프로젝트는 Leerness v1.18.0 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
190
190
 
191
191
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
192
192
 
@@ -240,7 +240,7 @@ leerness memory restore decision <date|title>
240
240
 
241
241
  ### MCP server (외부 AI 통합)
242
242
 
243
- Leerness v1.17.0는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **85개 도구**를 노출:
243
+ Leerness v1.18.0는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **85개 도구**를 노출:
244
244
 
245
245
  ```jsonc
246
246
  // 카테고리별
@@ -261,7 +261,7 @@ Leerness v1.17.0는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code
261
261
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
262
262
  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) 다음 라운드 예약.
263
263
 
264
- 현재 누적: **70 라운드 (1.9.40 → 1.17.0)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
264
+ 현재 누적: **70 라운드 (1.9.40 → 1.18.0)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
265
265
 
266
266
  ### 성능 가이드 (1.9.140 측정)
267
267
 
@@ -299,6 +299,6 @@ leerness release pack --close --auto-main-push
299
299
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
300
300
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
301
301
 
302
- Last synced by Leerness v1.17.0: 2026-06-09
302
+ Last synced by Leerness v1.18.0: 2026-06-10
303
303
  <!-- leerness:project-readme:end -->
304
304
 
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.17.0';
35
+ const VERSION = '1.18.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') 시 호스트 프로세스 오염.
@@ -216,7 +216,7 @@ function _resolveRoot(positional) {
216
216
  }
217
217
  function nonFlagArgs() {
218
218
  const out = [];
219
- const withValue = new Set(['--language','--skills','--path','--status','--progress','--goal','--reason','--next','--target','--token-env','--package','--out','--from','--repo','--id','--note','--evidence','--query','--limit','--action','--agent','--tool','--doc','--command','--capability','--before','--after','--display','--threshold','--trigger','--check','--set','--min-score','--include','--days','--gh-pages-src','--roadmap','--since','--agents','--model','--timeout','--retry-on-fail','--label','--score','--tokens','--alternatives','--impact','--tag','--surface','--depends-on','--affects','--co-changes-with','--files','--branch','--remote','--task-add','--next-action','--role','--provider','--env-var','--deploy','--token-lifetime-hours','--port','--secret','--keep','--shell','--ps-version','--done-when']); // 1.14.2 (UR-0032): --done-when 값이 positional 로 누출돼 milestone 제목에 흡수되던 것 차단
219
+ const withValue = new Set(['--language','--skills','--path','--status','--progress','--goal','--reason','--next','--target','--token-env','--package','--out','--from','--repo','--id','--note','--evidence','--query','--limit','--action','--agent','--tool','--doc','--command','--capability','--before','--after','--display','--threshold','--trigger','--check','--set','--min-score','--include','--days','--gh-pages-src','--roadmap','--since','--agents','--model','--timeout','--retry-on-fail','--label','--score','--tokens','--alternatives','--impact','--tag','--surface','--depends-on','--affects','--co-changes-with','--files','--branch','--remote','--task-add','--next-action','--role','--provider','--env-var','--deploy','--token-lifetime-hours','--port','--secret','--keep','--shell','--ps-version','--done-when','--test-cmd']); // 1.14.2 (UR-0032): --done-when 값이 positional 로 누출돼 milestone 제목에 흡수되던 것 차단. 1.17.2 (UR-0045): --test-cmd 동일 원칙(신규 value-flag 는 반드시 여기 등록)
220
220
  const a = process.argv.slice(2);
221
221
  for (let i = 0; i < a.length; i++) {
222
222
  const x = a[i];
@@ -3557,6 +3557,55 @@ function _selfTestCases() {
3557
3557
  const src = read(__filename);
3558
3558
  return src.includes('const _GROUP_USAGE = {') && src.includes("if (_GROUP_USAGE[cmd] && !args[1])") && src.includes("'subcommand_required'");
3559
3559
  } },
3560
+ { name: '17th 버그헌트 P2: plan add 공백제목 trim(기본값) + milestone 파서 개행 미흡수 (1.17.1)', run: () => {
3561
+ const src = read(__filename);
3562
+ const wired = src.includes("args.slice(2).join(' ').trim() || '새 계획'") && src.includes('(M-\\d{4})\\.[ \\t]*(.+?)$');
3563
+ // 파서 동작: 공백제목 milestone 이 다음 줄 'Status:' 를 제목으로 먹지 않음
3564
+ const block = '### M-0006. \nStatus: planned\nProgress: 0%\n';
3565
+ const m = block.match(/^### (M-\d{4})\.[ \t]*(.+?)$/m);
3566
+ const safe = !m || (m[2] || '').indexOf('Status') === -1;
3567
+ return wired && safe;
3568
+ } },
3569
+ { name: '범용성 P1 (UR-0045): verify-claim --run-tests 테스트 명령 해석 체인(--test-cmd>config>실제 npm test>skip) + placeholder 미실행 (1.17.2)', run: () => {
3570
+ const src = read(__filename);
3571
+ const chain = src.includes("let testCmd = arg('--test-cmd', null)") && src.includes("typeof cfg.testCommand === 'string'") && src.includes("!/no test specified/i.test(ts)") && src.includes('테스트 명령 미지정');
3572
+ const wired = src.includes("'--done-when','--test-cmd'") && src.includes('runCommandSafe(testCmd, []') && src.includes('cmd: testCmd');
3573
+ // pytest 출력 파싱: "3 passed in 0.05s"
3574
+ const py = ('3 passed in 0.05s'.match(/(\d+)\s+passed\b/i) || [])[1] === '3';
3575
+ return chain && wired && py;
3576
+ } },
3577
+ { name: '범용성 P1② (UR-0046): verify-claim 스텁 구현 차단 + 테스트-구현 연결 검사 (1.17.3)', run: () => {
3578
+ const src = read(__filename);
3579
+ const wired = src.includes('const stubFiles = [];') && src.includes('implementationSubstance: stubFiles.length === 0') && src.includes('testImplLink: testLinkOk') && src.includes("(claimsChecked && stubFiles.length > 0) || (has('--strict-claims') && testLinkOk === false)");
3580
+ // 스텁 판정 로직 재현: 주석뿐 파일 → 코드줄 0
3581
+ const stub = '// TODO: call stripe\n// TODO: verify signature\n\n';
3582
+ const real = '// handler\nmodule.exports = function(){ return 1; };\n';
3583
+ const count = (s) => s.replace(/\/\*[\s\S]*?\*\//g, '').split('\n').map(l => l.trim()).filter(t => t && !t.startsWith('//') && !t.startsWith('#')).length;
3584
+ return wired && count(stub) === 0 && count(real) > 0;
3585
+ } },
3586
+ { name: '범용성 P2 (UR-0047): 테스트 카운트 관례 확대(pytest/루트 *.test.*) + 측정불가=검증미수행(역전 해소) (1.17.4)', run: () => {
3587
+ const src = read(__filename);
3588
+ const wired = src.includes('const _countTests = (fp)') && src.includes("def\\s+test_") && src.includes('측정 불가 — 주장') && src.includes('out.verdict.testCountMatch === false') && src.includes('const testMeasured = actualTestCount != null;');
3589
+ // 관례 글롭: pytest/루트 test 파일명 매칭
3590
+ const re = /^test_.+\.py$|_test\.py$|\.test\.[cm]?[jt]sx?$|\.spec\.[cm]?[jt]sx?$/i;
3591
+ const glob = re.test('test_calc.py') && re.test('calc_test.py') && re.test('rateLimiter.test.js') && re.test('app.spec.ts') && !re.test('calc.py') && !re.test('index.js');
3592
+ // 파이썬 카운트: def test_ 2개
3593
+ const py = ('def test_a():\n pass\ndef test_b():\n pass\n'.match(/^\s*def\s+test_/gm) || []).length === 2;
3594
+ return wired && glob && py;
3595
+ } },
3596
+ { name: '범용성 P2 (UR-0048): task update unknown flag 거부 + did-you-mean(인수인계 유실 차단) (1.17.5)', run: () => {
3597
+ const src = read(__filename);
3598
+ const wired = src.includes('function _rejectUnknownFlags(allowed, usageHint)') && src.includes("'unknown_flag'") && src.includes("_rejectUnknownFlags(['--status', '--evidence', '--next', '--note']");
3599
+ // did-you-mean prefix 로직: --next-action 은 --next 를 prefix 로 가짐
3600
+ const dymOk = '--next-action'.startsWith('--next');
3601
+ return wired && dymOk;
3602
+ } },
3603
+ { name: '범용성 P2 완결 (UR-0049): session close 마감 정합 — done 낙관 재확인 + 시크릿 재확인 (1.17.6)', run: () => {
3604
+ const sc = read(path.join(path.dirname(__filename), '..', 'lib', 'session-close.js'));
3605
+ const wired = sc.includes('_detectOptimism, _scanCodeForPatterns, _collectSecretFindings } = deps') && sc.includes('optimismUnresolved') && sc.includes('jsonResult.closeSecurity') && sc.includes('마감 보안: 커밋 대상 시크릿');
3606
+ const injected = read(__filename).includes('_updateUserRequest, _detectOptimism, _scanCodeForPatterns, _collectSecretFindings });');
3607
+ return wired && injected;
3608
+ } },
3560
3609
  { name: 'VERSION 형식 (x.y.z)', run: () => /^\d+\.\d+\.\d+$/.test(VERSION) }
3561
3610
  ];
3562
3611
  }
@@ -4121,7 +4170,7 @@ function commandsCmd(root) {
4121
4170
  { cmd: 'scan secrets [path]', desc: '시크릿 탐지' },
4122
4171
  { cmd: 'encoding check [path]', desc: '인코딩 검증' },
4123
4172
  { cmd: 'lazy detect [path] [--json]', desc: '게으른 작업 감지 (1.9.101)' },
4124
- { cmd: 'verify-claim <T-ID> [--run-tests] [--strict-claims] [--require-evidence]', desc: '주장 검증 (1.9.18~26) — --require-evidence: done 주장에 파일+테스트 근거 강제 (1.9.287)' },
4173
+ { cmd: 'verify-claim <T-ID> [--run-tests] [--test-cmd "<명령>"] [--strict-claims] [--require-evidence]', desc: '주장 검증 (1.9.18~26) — --require-evidence: done 주장에 파일+테스트 근거 강제 (1.9.287) · --test-cmd: 비-JS 테스트 명령 (1.17.2)' },
4125
4174
  { cmd: 'optimism-check <T-ID>', desc: '낙관적 API 감지 (1.9.26)' },
4126
4175
  { cmd: 'requests audit|list|complete|drop|auto-complete', desc: '사용자 요청 추적 (1.9.207/223)' },
4127
4176
  { cmd: 'pre-wake-audit [path] [--last]', desc: 'sleep 전 점검 (1.9.209)' },
@@ -6445,7 +6494,7 @@ function planListCmd(root, opts = {}) {
6445
6494
  // ### M-XXXX. <title> 블록 추출
6446
6495
  const blocks = text.split(/\n(?=### M-\d{4}\.)/);
6447
6496
  for (const b of blocks) {
6448
- const headerMatch = b.match(/^### (M-\d{4})\.\s*(.+?)$/m);
6497
+ const headerMatch = b.match(/^### (M-\d{4})\.[ \t]*(.+?)$/m);
6449
6498
  if (!headerMatch) continue;
6450
6499
  const id = headerMatch[1];
6451
6500
  const title = headerMatch[2].trim();
@@ -6715,8 +6764,22 @@ function taskAdd(root, text) {
6715
6764
  } catch {} // review 실패는 task add 자체에 영향 X
6716
6765
  }
6717
6766
  }
6767
+ // 1.17.5 (UR-0048, 5축 실증 P2): 모르는 옵션 조용히 무시 차단 — `task update --next-action "x"` 처럼 오타/미존재 플래그가
6768
+ // "✓ task updated" 와 함께 값을 버려, 쓴 에이전트는 기록됐다고 믿고 다음 에이전트는 placeholder 를 받는(인수인계 유실) 최악의 실패 양식.
6769
+ // prefix 기반 did-you-mean(--next-action → --next) + exit 1. 전역 플래그(--path/--json/--force/--lenient)는 항상 허용.
6770
+ function _rejectUnknownFlags(allowed, usageHint) {
6771
+ const all = new Set([...allowed, '--path', '--json', '--force', '--lenient']);
6772
+ const seen = process.argv.slice(2).filter(a => a.startsWith('--')).map(a => a.split('=')[0]);
6773
+ const unknown = [...new Set(seen.filter(f => !all.has(f)))];
6774
+ if (!unknown.length) return true;
6775
+ const dym = (u) => { const c = [...all].find(k => u.startsWith(k) || k.startsWith(u)); return c ? ` — 혹시 ${c}?` : ''; };
6776
+ failJson(has('--json'), 'unknown_flag', `알 수 없는 옵션: ${unknown.map(u => u + dym(u)).join(', ')} (지원: ${[...allowed].join(' ')}${usageHint ? ' · ' + usageHint : ''}) — 값이 조용히 버려지는 것을 방지하기 위해 거부`);
6777
+ return false;
6778
+ }
6779
+
6718
6780
  function taskUpdate(root, id) {
6719
6781
  if (!_requireInit(root, 'task update')) return; // 1.9.311 (UR-0047): init 가드
6782
+ if (!_rejectUnknownFlags(['--status', '--evidence', '--next', '--note'], 'task update T-0001 --status done --evidence "..." --next "..."')) { process.exitCode = 1; return; } // 1.17.5 (UR-0048)
6720
6783
  if (!id) return fail('id required (e.g., task update T-0001 --status in-progress)');
6721
6784
  if (!_validateChoice(arg('--status', null), TASK_STATUSES, 'task status')) { process.exitCode = 1; return; } // 1.9.310 (UR-0046)
6722
6785
  const rows = readProgressRows(root);
@@ -7147,7 +7210,7 @@ function _jaccard(a, b) {
7147
7210
  function taskRelink(root) {
7148
7211
  root = absRoot(root);
7149
7212
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
7150
- const milestones = [...planText.matchAll(/^### (M-\d{4})\.\s*(.+?)$/gm)]
7213
+ const milestones = [...planText.matchAll(/^### (M-\d{4})\.[ \t]*(.+?)$/gm)]
7151
7214
  .map(m => ({ id: m[1], text: m[2].trim() }));
7152
7215
  const rows = readProgressRows(root);
7153
7216
  const linkedM = new Set(rows.map(r => (r.evidence.match(/M-\d{4}/) || [])[0]).filter(Boolean));
@@ -9612,6 +9675,11 @@ function verifyClaimCmd(root, taskId) {
9612
9675
  // 4) N개 테스트 (단순 카운트)
9613
9676
  const m4 = evidence.match(/(\d+)\s*개\s*테스트/);
9614
9677
  if (m4) declaredTestCount = parseInt(m4[1], 10);
9678
+ // 4b) 테스트 N개 (1.17.4 UR-0047: 한국어 자연어순 '테스트 50개 통과' — 이전 미인식으로 부풀린 주장이 카운트 검증을 아예 안 탔음)
9679
+ if (!declaredTestCount) {
9680
+ const m4b = evidence.match(/테스트\s*(\d+)\s*개/);
9681
+ if (m4b) declaredTestCount = parseInt(m4b[1], 10);
9682
+ }
9615
9683
  // 5) N tests (영문 단순 카운트)
9616
9684
  if (!declaredTestCount) {
9617
9685
  const m5 = evidence.match(/(\d+)\s*tests?\b/i);
@@ -9620,6 +9688,27 @@ function verifyClaimCmd(root, taskId) {
9620
9688
 
9621
9689
  // 실제 파일 존재 검사
9622
9690
  const fileChecks = files.map(f => ({ file: f, exists: exists(path.join(root, f)) }));
9691
+
9692
+ // 1.17.3 (UR-0046 범용성 P1②): 빈껍데기(스텁) 구현 + 테스트-구현 연결 검사 — "주석뿐 구현 + assert(true) 테스트"가 verify-claim 을 exit 0 으로 통과하던 공격(5축 실증 Attack C) 차단.
9693
+ // ① 스텁: 주장된 코드 파일(테스트 제외)의 비주석 코드줄이 0 이면 확정 스텁 — done 게이팅 FAIL(확실 신호만, 과탐 0).
9694
+ // ② 연결: 주장에 구현+테스트가 모두 있는데 어떤 테스트도 구현 파일명(basename)을 참조하지 않으면 — 기본 advisory ⚠, --strict-claims 시 FAIL.
9695
+ const _VC_CODE_EXT = /\.(js|mjs|cjs|jsx|ts|tsx|py|rb|go|rs|java|cs|php)$/i;
9696
+ const _VC_TEST_PAT = /(^|[\\/])(test_[^\\/]+\.[a-z]+|[^\\/]+[._-]test\.[a-z]+|[^\\/]+\.spec\.[a-z]+)$|(^|[\\/])tests?[\\/]/i;
9697
+ const stubFiles = [];
9698
+ for (const c of fileChecks) {
9699
+ if (!c.exists || !_VC_CODE_EXT.test(c.file) || _VC_TEST_PAT.test(c.file)) continue;
9700
+ let body = ''; try { body = read(path.join(root, c.file)); } catch { continue; }
9701
+ if (!body || body.length > 512 * 1024) continue;
9702
+ const codeLines = body.replace(/\/\*[\s\S]*?\*\//g, '').split('\n').map(l => l.trim()).filter(t => t && !t.startsWith('//') && !t.startsWith('#'));
9703
+ if (codeLines.length === 0) stubFiles.push(c.file);
9704
+ }
9705
+ const _vcImpl = fileChecks.filter(c => c.exists && _VC_CODE_EXT.test(c.file) && !_VC_TEST_PAT.test(c.file)).map(c => c.file);
9706
+ const _vcTests = fileChecks.filter(c => c.exists && _VC_CODE_EXT.test(c.file) && _VC_TEST_PAT.test(c.file)).map(c => c.file);
9707
+ let testLinkOk = null; // null = 판단 불가(구현·테스트가 함께 주장되지 않음)
9708
+ if (_vcImpl.length && _vcTests.length) {
9709
+ const bases = _vcImpl.map(f => path.basename(f).replace(/\.[a-z]+$/i, ''));
9710
+ testLinkOk = _vcTests.some(tf => { let t = ''; try { t = read(path.join(root, tf)); } catch { return false; } return bases.some(b => b && t.includes(b)); });
9711
+ }
9623
9712
  // 1.9.302 (UR-0042, 외부리뷰 Opus G-1): git diff 시맨틱 교차검증 — 주장한 파일이 실제로 변경됐는가.
9624
9713
  // "파일 존재"만으로는 "테스트만 통과하면 done" 허위완료를 못 막음(Opus). git working tree+직전커밋 변경과 대조.
9625
9714
  const gitChanged = _gitChangedFiles(root); // Set | null(git repo 아님 → 검증 불가)
@@ -9629,33 +9718,55 @@ function verifyClaimCmd(root, taskId) {
9629
9718
  // 1.13.2 (Karpathy 가이드라인 3 "외과적 변경", UR-0030): 역방향 교차검증 — git 에 변경됐으나 evidence/주장에 없는 파일(scope-creep / 요청 범위 밖 변경 신호). 하네스 자체 기록(.harness 등)은 제외. advisory(오탐 방지 — 기본 FAIL 아님, 표면화만).
9630
9719
  const _SCOPE_SKIP = /^(\.harness[\\/]|\.git[\\/]|node_modules[\\/]|\.claude[\\/]|dist[\\/]|build[\\/])/;
9631
9720
  const changedNotClaimed = gitApplicable ? [...gitChanged].filter(g => !_SCOPE_SKIP.test(g) && !files.some(f => _claimFileInGit(f, new Set([g])))) : [];
9632
- // 테스트 카운트: tests/test.js의 check( 또는 it( 또는 test( 개수
9721
+ // 테스트 카운트 (1.17.4, UR-0047): 주장된 테스트 파일 우선 + 관례 글롭(pytest test_*.py·*_test.py / 루트·tests/ 의 *.test.*·*.spec.*) 인식.
9722
+ // 이전엔 tests/test.js 등 3개 하드코딩 — pytest/node:test 루트 관례가 안 보여 "파일 못 찾음"인데 ✓ pass 로 표기(측정실패=통과 역전, 5축 실증 P2 공통).
9723
+ const _countTests = (fp) => {
9724
+ let t = ''; try { t = read(fp); } catch { return 0; }
9725
+ if (/\.py$/i.test(fp)) { const d = (t.match(/^\s*def\s+test_/gm) || []).length; return d || (t.match(/^\s*assert\b/gm) || []).length; }
9726
+ return (t.match(/\bcheck\s*\(/g) || t.match(/\b(it|test)\s*\(/g) || []).length;
9727
+ };
9633
9728
  let actualTestCount = null;
9634
- const candidateTestFiles = ['tests/test.js', 'test/test.js', 'tests/index.js'];
9635
- for (const tf of candidateTestFiles) {
9636
- const tp = path.join(root, tf);
9637
- if (exists(tp)) {
9638
- const t = read(tp);
9639
- actualTestCount = (t.match(/\bcheck\s*\(/g) || t.match(/\b(it|test)\s*\(/g) || []).length;
9640
- break;
9729
+ if (_vcTests.length) {
9730
+ actualTestCount = _vcTests.reduce((a, f) => a + _countTests(path.join(root, f)), 0);
9731
+ } else {
9732
+ const found = new Set();
9733
+ for (const tf of ['tests/test.js', 'test/test.js', 'tests/index.js']) if (exists(path.join(root, tf))) found.add(tf);
9734
+ if (!found.size) {
9735
+ for (const dir of ['', 'tests', 'test']) {
9736
+ let ents = []; try { ents = fs.readdirSync(path.join(root, dir)); } catch { continue; }
9737
+ for (const e of ents) if (/^test_.+\.py$|_test\.py$|\.test\.[cm]?[jt]sx?$|\.spec\.[cm]?[jt]sx?$/i.test(e)) found.add(dir ? dir + '/' + e : e);
9738
+ if (found.size) break; // 루트 우선 (루트에 있으면 tests/ 중복 스캔 안 함)
9739
+ }
9641
9740
  }
9741
+ if (found.size) actualTestCount = [...found].reduce((a, f) => a + _countTests(path.join(root, f)), 0);
9642
9742
  }
9743
+ const testMeasured = actualTestCount != null;
9643
9744
 
9644
- // 1.9.19: --run-tests — npm test 자동 실행 + pass/fail 파싱
9745
+ // 1.9.19: --run-tests — 테스트 자동 실행 + pass/fail 파싱
9746
+ // 1.17.2 (UR-0045 범용성 P1): 테스트 명령 해석 체인 — --test-cmd > leerness-config.json testCommand > 실제 npm test 스크립트 > skip.
9747
+ // 이전엔 npm test 하드코딩 → 비-JS(파이썬 등) 프로젝트에서 npm init 잔재 placeholder("no test specified"&&exit 1)가 실행돼
9748
+ // 테스트 전부 통과한 작업을 "주장 불일치 FAIL"로 오판(5축 클린룸 실증 P1). placeholder 는 테스트가 아니므로 skip 처리.
9645
9749
  let runResult = null;
9646
9750
  if (has('--run-tests')) {
9647
- const pkgPath = path.join(root, 'package.json');
9648
- if (!exists(pkgPath)) {
9649
- runResult = { skipped: true, reason: 'package.json 없음' };
9751
+ let testCmd = arg('--test-cmd', null);
9752
+ if (!testCmd) {
9753
+ try { const cfg = JSON.parse(read(path.join(root, '.harness', 'leerness-config.json'))); if (cfg && typeof cfg.testCommand === 'string' && cfg.testCommand.trim()) testCmd = cfg.testCommand.trim(); } catch {}
9754
+ }
9755
+ if (!testCmd) {
9756
+ const pkgPath = path.join(root, 'package.json');
9757
+ if (exists(pkgPath)) {
9758
+ let pkg = null;
9759
+ try { pkg = JSON.parse(read(pkgPath)); } catch {}
9760
+ const ts = pkg && pkg.scripts && pkg.scripts.test;
9761
+ if (ts && !/no test specified/i.test(ts)) testCmd = 'npm test';
9762
+ }
9763
+ }
9764
+ if (!testCmd) {
9765
+ runResult = { skipped: true, reason: '테스트 명령 미지정 — 비-JS 프로젝트는 --test-cmd "<명령>" 또는 .harness/leerness-config.json 의 "testCommand" 로 지정 (불일치 판정 아님)' };
9650
9766
  } else {
9651
- let pkg = null;
9652
- try { pkg = JSON.parse(read(pkgPath)); } catch {}
9653
- const hasTestScript = pkg && pkg.scripts && pkg.scripts.test;
9654
- if (!hasTestScript) {
9655
- runResult = { skipped: true, reason: 'scripts.test 없음' };
9656
- } else {
9657
- // 1.9.299 (UR-0039): 신뢰 못 할 워크스페이스 npm test → runCommandSafe + scrubSecrets (시크릿 노출 차단 + cwd jail).
9658
- const r = runCommandSafe('npm test', [], { cwd: root, root, encoding: 'utf8', allowShell: true, scrubSecrets: true, timeout: 5 * 60 * 1000, kind: 'verify_claim_test' });
9767
+ {
9768
+ // 1.9.299 (UR-0039): 신뢰 못 할 워크스페이스 테스트 실행 → runCommandSafe + scrubSecrets (시크릿 노출 차단 + cwd jail).
9769
+ const r = runCommandSafe(testCmd, [], { cwd: root, root, encoding: 'utf8', allowShell: true, scrubSecrets: true, timeout: 5 * 60 * 1000, kind: 'verify_claim_test' });
9659
9770
  const out = (r.stdout || '') + (r.stderr || '');
9660
9771
  // 1.9.20: 파싱 패턴 확장 — 한국어 + jest/mocha/tap/vitest
9661
9772
  let parsed = null;
@@ -9677,8 +9788,14 @@ function verifyClaimCmd(root, taskId) {
9677
9788
  const m4 = out.match(/#\s*pass\s+(\d+)/i);
9678
9789
  if (m4) parsed = { num: parseInt(m4[1], 10), denom: parseInt(m4[1], 10) };
9679
9790
  }
9791
+ // 5) pytest: "N passed in 0.12s" (UR-0045 — 파이썬 러너 출력 인식)
9792
+ if (!parsed) {
9793
+ const m5 = out.match(/(\d+)\s+passed\b/i);
9794
+ if (m5) parsed = { num: parseInt(m5[1], 10), denom: parseInt(m5[1], 10) };
9795
+ }
9680
9796
  runResult = {
9681
9797
  skipped: false,
9798
+ cmd: testCmd,
9682
9799
  exitCode: r.status,
9683
9800
  parsed,
9684
9801
  allPassed: r.status === 0 && (!parsed || (parsed && parsed.num === parsed.denom))
@@ -9723,11 +9840,14 @@ function verifyClaimCmd(root, taskId) {
9723
9840
  actual: { fileChecks, testCount: actualTestCount },
9724
9841
  verdict: {
9725
9842
  filesAllExist,
9726
- testCountMatch: declaredTestCount == null || actualTestCount == null || actualTestCount >= declaredTestCount,
9843
+ testCountMatch: declaredTestCount == null ? null : (!testMeasured ? null : actualTestCount >= declaredTestCount), // 1.17.4 (UR-0047): null=측정불가(검증 미수행 — pass 아님), false 만 게이팅
9727
9844
  evidenceComplete: !mustHaveEvidence ? null : evq.ok,
9728
9845
  claimsConsistent: !claimsChecked ? null : strictOk, // 1.11.2 (UR-0175): optimism+정직성 (기본 게이팅)
9729
- gitCrossCheck: !gitApplicable ? null : !gitStrongMismatch // 1.11.2 (UR-0175): git 교차검증 (머신 경로 노출)
9846
+ gitCrossCheck: !gitApplicable ? null : !gitStrongMismatch, // 1.11.2 (UR-0175): git 교차검증 (머신 경로 노출)
9847
+ implementationSubstance: stubFiles.length === 0, // 1.17.3 (UR-0046): 주장된 구현이 주석/빈껍데기뿐이면 false
9848
+ testImplLink: testLinkOk // 1.17.3 (UR-0046): 테스트가 구현을 참조하는가 (null=판단불가)
9730
9849
  },
9850
+ stubFiles: stubFiles.slice(0, 10),
9731
9851
  evidence: { required: mustHaveEvidence, ...evq },
9732
9852
  claims: !claimsChecked ? null : { ok: strictOk, optimism: optimismSuspects.map(s => ({ kind: s.kind, label: s.label })), honesty: honestyFindings.map(f => ({ dim: f.dim, label: f.label })) },
9733
9853
  git: gitChanged === null ? { applicable: false, reason: 'not-a-git-repo' } : (!gitApplicable ? { applicable: false, reason: 'no-working-changes-or-no-claimed-files' } : { applicable: true, claimedInGit: claimedInGit.length, claimedNotInGit, strongMismatch: gitStrongMismatch, changedNotClaimed }),
@@ -9744,7 +9864,7 @@ function verifyClaimCmd(root, taskId) {
9744
9864
  log(JSON.stringify(out, null, 2));
9745
9865
  if (runResult && !runResult.skipped && !runResult.allPassed) return process.exit(1);
9746
9866
  // 1.11.2 (UR-0175): --json 도 optimism+git 게이팅 — 머신 경로가 허위완료를 통과시키지 않도록(human 경로와 동일).
9747
- if (!filesAllExist || !out.verdict.testCountMatch || !evidenceQualityOk || (claimsChecked && !strictOk) || !gitClaimOk) return process.exit(1);
9867
+ if (!filesAllExist || out.verdict.testCountMatch === false || !evidenceQualityOk || (claimsChecked && !strictOk) || !gitClaimOk || (claimsChecked && stubFiles.length > 0) || (has('--strict-claims') && testLinkOk === false)) return process.exit(1); // 1.17.3 (UR-0046): 스텁(done 기본) + 테스트 미연결(strict). 1.17.4 (UR-0047): testCountMatch null(측정불가)은 미기여
9748
9868
  return;
9749
9869
  }
9750
9870
 
@@ -9762,15 +9882,15 @@ function verifyClaimCmd(root, taskId) {
9762
9882
  log(`## 🧪 테스트 카운트`);
9763
9883
  if (declaredPass) log(` 주장 (pass): ${declaredPass.num}/${declaredPass.denom}`);
9764
9884
  if (declaredTestCount) log(` 주장 (개수): ${declaredTestCount}개`);
9765
- if (actualTestCount != null) log(` 실측: tests/test.js에 ${actualTestCount}개 check/test 호출`);
9766
- else log(` 실측: 테스트 파일 못 찾음 (tests/test.js )`);
9885
+ if (actualTestCount != null) log(` 실측: ${actualTestCount}개 테스트 호출 (${_vcTests.length ? '주장된 테스트 파일' : '관례 탐색: 루트/tests·test_*.py·*.test.*'})`);
9886
+ else log(` 실측: 테스트 파일 못 찾음 — 카운트 검증 미수행 (pass 아님)`);
9767
9887
 
9768
9888
  // 1.9.19: --run-tests 결과
9769
9889
  let runTestsOk = true;
9770
9890
  let declaredPassMatchesActual = true;
9771
9891
  if (runResult) {
9772
9892
  log('');
9773
- log(`## 🚦 npm test 실행 (--run-tests)`);
9893
+ log(`## 🚦 ${runResult.cmd || '테스트'} 실행 (--run-tests)`);
9774
9894
  if (runResult.skipped) {
9775
9895
  log(` ⚠ skipped: ${runResult.reason}`);
9776
9896
  } else {
@@ -9792,7 +9912,8 @@ function verifyClaimCmd(root, taskId) {
9792
9912
  const testOk = declaredTestCount == null || actualTestCount == null || actualTestCount >= declaredTestCount;
9793
9913
  log(`## 종합`);
9794
9914
  log(` - 파일 모두 존재: ${allFilesOk ? '✓ pass' : '✗ FAIL (일부 누락)'}`);
9795
- log(` - 테스트 카운트: ${testOk ? ' pass (실측 주장)' : '⚠ 주장보다 적음'}`);
9915
+ // 1.17.4 (UR-0047): 측정 불가는 '통과' 아니라 '검증 미수행' — 이전엔 실측 0 인데 pass(실측≥주장) 모순 표기.
9916
+ log(` - 테스트 카운트: ${declaredTestCount == null ? '⊘ (주장 없음)' : !testMeasured ? `⊘ 측정 불가 — 주장 ${declaredTestCount}개 검증 미수행 (pass 아님)` : testOk ? '✓ pass (실측 ≥ 주장)' : '⚠ 주장보다 적음'}`);
9796
9917
  if (runResult && !runResult.skipped) {
9797
9918
  log(` - npm test 실행: ${runTestsOk ? '✓ all passed' : '✗ FAIL'}`);
9798
9919
  if (declaredPass) log(` - 주장과 실행 결과 일치: ${declaredPassMatchesActual ? '✓ pass' : '⚠ 다름'}`);
@@ -9822,7 +9943,18 @@ function verifyClaimCmd(root, taskId) {
9822
9943
  log(` - evidence 완전성 (done 기본 강제): ${evidenceQualityOk ? '✓ pass (파일+테스트 근거 있음)' : `✗ FAIL (누락: ${evq.missing.join(', ')})`}`);
9823
9944
  if (!evidenceQualityOk) log(` · done 주장은 수정 파일 경로 + 테스트명/개수 가 evidence 에 있어야 함 (테스트 통과만으로는 불충분). 완화: --lenient`);
9824
9945
  }
9825
- const overallFail = !allFilesOk || !testOk || (runResult && !runResult.skipped && !runTestsOk) || (claimsChecked && !strictOk) || !evidenceQualityOk || !gitClaimOk;
9946
+ // 1.17.3 (UR-0046): 구현 실체(스텁) + 테스트-구현 연결 Attack C(주석뿐 구현+assert(true)) 차단.
9947
+ if (stubFiles.length) {
9948
+ log(` - 구현 실체 (done 기본): ✗ FAIL — 주장된 구현 파일이 주석/빈껍데기뿐: ${stubFiles.slice(0, 5).join(', ')} (비주석 코드 0줄)`);
9949
+ } else if (claimsChecked && _vcImpl.length) {
9950
+ log(` - 구현 실체 (done 기본): ✓ pass (주장 구현 파일에 실코드 존재)`);
9951
+ }
9952
+ if (testLinkOk === false) {
9953
+ log(` - 테스트-구현 연결: ⚠ 주장된 테스트(${_vcTests.slice(0, 3).join(', ')})가 구현 파일을 참조하지 않음 — 빈 테스트(assert(true)) 의심${has('--strict-claims') ? ' → FAIL' : ' (advisory — --strict-claims 시 FAIL)'}`);
9954
+ } else if (testLinkOk === true && claimsChecked) {
9955
+ log(` - 테스트-구현 연결: ✓ pass (테스트가 구현을 참조)`);
9956
+ }
9957
+ const overallFail = !allFilesOk || !testOk || (runResult && !runResult.skipped && !runTestsOk) || (claimsChecked && !strictOk) || !evidenceQualityOk || !gitClaimOk || (claimsChecked && stubFiles.length > 0) || (has('--strict-claims') && testLinkOk === false);
9826
9958
  // 1.9.287: 정직한 한계 고지 — 테스트 통과 ≠ 의미적 구현 정확성
9827
9959
  if (claimsChecked || mustHaveEvidence) {
9828
9960
  log('');
@@ -11694,7 +11826,7 @@ function llmBenchRecordCmd(root) {
11694
11826
 
11695
11827
  const _sessionClose = require('../lib/session-close');
11696
11828
  // 1.9.425 (UR-0025/UR-0125 큰 핸들러 모듈화 10번째): sessionClose → lib/session-close.js (DI 위임)
11697
- function sessionClose(root, opts = {}) { return _sessionClose.sessionClose(root, opts, { VERSION, STATUSES, MARK, has, arg, harnessPath: __filename, readProgressRows, evidencePath, handoffPath, currentStatePath, taskLogPath, verifyRules, _autoRoadmap, _readUsageStats, readSessionCounter, writeSessionCounter, _retroAggregate, _retroOneLine, retroCmd, _loadDecisions, readRules, planPath, _loadLessons, _readFeatureGraph, _auditUserRequests, _detectDeliveredRequests, _computeRoundHistory, _computeMilestones, _computeRecentChanges, _collectPyFiles, _analyzePyFile, _collectRuntimeEnv, _scanShellScriptsEncoding, _listAPISkills, _matchAPISkills, _loadShellFailures, _shellEnvDrift, _runPreWakeAudit, _saveAndAppendPreWakeReport, _runIdempotencyAudit, _detectAbnormalShutdown, _updateUserRequest }); }
11829
+ function sessionClose(root, opts = {}) { return _sessionClose.sessionClose(root, opts, { VERSION, STATUSES, MARK, has, arg, harnessPath: __filename, readProgressRows, evidencePath, handoffPath, currentStatePath, taskLogPath, verifyRules, _autoRoadmap, _readUsageStats, readSessionCounter, writeSessionCounter, _retroAggregate, _retroOneLine, retroCmd, _loadDecisions, readRules, planPath, _loadLessons, _readFeatureGraph, _auditUserRequests, _detectDeliveredRequests, _computeRoundHistory, _computeMilestones, _computeRecentChanges, _collectPyFiles, _analyzePyFile, _collectRuntimeEnv, _scanShellScriptsEncoding, _listAPISkills, _matchAPISkills, _loadShellFailures, _shellEnvDrift, _runPreWakeAudit, _saveAndAppendPreWakeReport, _runIdempotencyAudit, _detectAbnormalShutdown, _updateUserRequest, _detectOptimism, _scanCodeForPatterns, _collectSecretFindings }); } // 1.17.6 (UR-0049): 마감 정합 — done 낙관 재확인 + 시크릿 재확인
11698
11830
 
11699
11831
  function readmeCmd(root) { syncReadme(absRoot(root)); }
11700
11832
  function consistencyCheck(root) {
@@ -12180,7 +12312,7 @@ function _brainstormFor(root, topic) {
12180
12312
  const planText = read(planFile_brainstorm);
12181
12313
  const milestoneBlocks = planText.split(/\n(?=### M-\d{4}\.)/);
12182
12314
  for (const b of milestoneBlocks) {
12183
- const m = b.match(/^### (M-\d{4})\.\s*(.+?)$/m);
12315
+ const m = b.match(/^### (M-\d{4})\.[ \t]*(.+?)$/m);
12184
12316
  if (m && matches(b)) {
12185
12317
  const idx = planText.indexOf(b);
12186
12318
  const lineNo = idx >= 0 ? planText.slice(0, idx).split('\n').length : 0;
@@ -12475,7 +12607,7 @@ function brainstormCmd(root, topic) {
12475
12607
  const planText = read(planFile_b2);
12476
12608
  const milestoneBlocks = planText.split(/\n(?=### M-\d{4}\.)/);
12477
12609
  for (const b of milestoneBlocks) {
12478
- const m = b.match(/^### (M-\d{4})\.\s*(.+?)$/m);
12610
+ const m = b.match(/^### (M-\d{4})\.[ \t]*(.+?)$/m);
12479
12611
  if (m && matches(b)) {
12480
12612
  const idx = planText.indexOf(b);
12481
12613
  const lineNo = idx >= 0 ? planText.slice(0, idx).split('\n').length : 0;
@@ -19433,7 +19565,7 @@ async function main() {
19433
19565
  const root = absRoot(arg('--path', process.cwd())); const sub = args[1] || 'show';
19434
19566
  if (sub==='show') return planShow(root);
19435
19567
  if (sub==='init') return planInit(root);
19436
- if (sub==='add') return planAdd(root, args.slice(2).join(' ') || '새 계획');
19568
+ if (sub==='add') return planAdd(root, args.slice(2).join(' ').trim() || '새 계획'); // 17th 버그헌트 P2: 공백-only 제목이 || 기본값을 우회(truthy)해 plan.md 손상(파서가 다음 줄 'Status:' 흡수) → trim 후 판정
19437
19569
  if (sub==='drop') return planDrop(root, args.slice(2).join(' ') || '드랍 항목');
19438
19570
  if (sub==='remove') return planRemoveCmd(root, args[2]);
19439
19571
  if (sub==='progress') return planProgress(root, { json: has('--json'), updateIntent: args.slice(2).some(a => /^M-\d/i.test(a)) || has('--status') || arg('--progress', null) != null }); // 1.9.447 (UR-0145): --json + 변경의도 인자 경고
package/lib/pure-utils.js CHANGED
@@ -322,7 +322,7 @@ function _roadmapParseMilestones(text) {
322
322
  const s = String(text || '');
323
323
  const out = [];
324
324
  // 1.9.352 (UR-0068 외부리뷰): 다음 milestone 직전까지 block 한정 — 이전 구현은 slice(m.index) 로 다음 milestone 의 Status/Progress 를 누출했음
325
- const matches = [...s.matchAll(/^### (M-\d{4})\.\s*(.+?)$/gm)];
325
+ const matches = [...s.matchAll(/^### (M-\d{4})\.[ \t]*(.+?)$/gm)]; // 17th 버그헌트 P2: \s* 가 개행 흡수해 빈 제목 milestone 이 다음 줄(Status:)을 제목으로 먹던 것 차단
326
326
  for (let i = 0; i < matches.length; i++) {
327
327
  const m = matches[i];
328
328
  const end = i + 1 < matches.length ? matches[i + 1].index : s.length;
@@ -11,7 +11,7 @@ const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBu
11
11
  const { _sanitizeFences, _parseArchiveBlocks } = require('./pure-utils');
12
12
 
13
13
  function sessionClose(root, opts = {}, deps = {}) {
14
- const { VERSION, STATUSES, MARK, has, arg, harnessPath, readProgressRows, evidencePath, handoffPath, currentStatePath, taskLogPath, verifyRules, _autoRoadmap, _readUsageStats, readSessionCounter, writeSessionCounter, _retroAggregate, _retroOneLine, retroCmd, _loadDecisions, readRules, planPath, _loadLessons, _readFeatureGraph, _auditUserRequests, _detectDeliveredRequests, _computeRoundHistory, _computeMilestones, _computeRecentChanges, _collectPyFiles, _analyzePyFile, _collectRuntimeEnv, _scanShellScriptsEncoding, _listAPISkills, _matchAPISkills, _loadShellFailures, _shellEnvDrift, _runPreWakeAudit, _saveAndAppendPreWakeReport, _runIdempotencyAudit, _detectAbnormalShutdown, _updateUserRequest } = deps;
14
+ const { VERSION, STATUSES, MARK, has, arg, harnessPath, readProgressRows, evidencePath, handoffPath, currentStatePath, taskLogPath, verifyRules, _autoRoadmap, _readUsageStats, readSessionCounter, writeSessionCounter, _retroAggregate, _retroOneLine, retroCmd, _loadDecisions, readRules, planPath, _loadLessons, _readFeatureGraph, _auditUserRequests, _detectDeliveredRequests, _computeRoundHistory, _computeMilestones, _computeRecentChanges, _collectPyFiles, _analyzePyFile, _collectRuntimeEnv, _scanShellScriptsEncoding, _listAPISkills, _matchAPISkills, _loadShellFailures, _shellEnvDrift, _runPreWakeAudit, _saveAndAppendPreWakeReport, _runIdempotencyAudit, _detectAbnormalShutdown, _updateUserRequest, _detectOptimism, _scanCodeForPatterns, _collectSecretFindings } = deps;
15
15
  root = absRoot(root);
16
16
  // 1.10.4 (13th 버그헌트 P2, UR-0167): 경로 없음/디렉토리 아님 → 구조화 에러 + exit 1. mkdir <path>/.harness ENOTDIR 크래시 & 실패를 성공(exit 0)으로 오판하던 문제 차단.
17
17
  if (!exists(root) || !fs.statSync(root).isDirectory()) { failJson(!!opts.json || has('--json'), 'path_not_found', `경로 없음 또는 디렉토리 아님: ${root}`); return; }
@@ -34,6 +34,22 @@ function sessionClose(root, opts = {}, deps = {}) {
34
34
  const _doneNoEvidence = (buckets['done'] || []).filter(r => !r.evidence || /^(\s*|user-request|-)$/.test(r.evidence) || /^plan:M-\d{4}\s*$/.test(r.evidence));
35
35
  jsonResult.completionHonesty = { doneTotal: (buckets['done'] || []).length, doneWithoutEvidence: _doneNoEvidence.length, ids: _doneNoEvidence.slice(0, 5).map(r => r.id) };
36
36
  if (_doneNoEvidence.length) log(` ⚠ 완료 정직성: done ${_doneNoEvidence.length}건 evidence 없음/placeholder (${_doneNoEvidence.slice(0, 3).map(r => r.id).join(', ')}) — verify-claim 권장 (advisory)`);
37
+ // 1.17.6 (UR-0049 마감 정합): done 의 미해소 낙관 의심 재확인 — verify-claim 을 건너뛴 거짓 주장(evidence 에 API/DB 주장 있는데 코드 흔적 없음)이
38
+ // 평범한 'done' 으로 마감을 무사 통과하던 것(5축 실증 P2: 거짓 DB 주장이 done 으로 마감, gate 실패 중 'clean' 선언) — 마감이 마지막 관문 역할을 하도록 재확인. advisory.
39
+ let _doneOptimism = [];
40
+ try {
41
+ if (_detectOptimism && _scanCodeForPatterns && (buckets['done'] || []).length) {
42
+ const _codeText = _scanCodeForPatterns(root);
43
+ _doneOptimism = (buckets['done'] || []).map(r => ({ id: r.id, suspects: _detectOptimism(r.evidence || '', _codeText) || [] })).filter(x => x.suspects.length);
44
+ }
45
+ } catch {}
46
+ jsonResult.completionHonesty.optimismUnresolved = _doneOptimism.map(x => ({ id: x.id, kinds: x.suspects.map(s => s.kind) }));
47
+ if (_doneOptimism.length) log(` ⚠ 완료 정직성: done ${_doneOptimism.length}건 낙관 의심 미해소 (${_doneOptimism.slice(0, 3).map(x => x.id).join(', ')}) — evidence 주장 vs 코드 흔적 불일치, 마감 전 verify-claim 재확인 권장 (advisory)`);
48
+ // 1.17.6 (UR-0049): 마감 보안 재확인 — 커밋 대상 시크릿이 살아있으면 'clean' 으로 마감하지 않도록 표면화. advisory(차단 X).
49
+ let _closeSecrets = 0;
50
+ try { if (_collectSecretFindings) _closeSecrets = ((_collectSecretFindings(root) || {}).committed || []).length; } catch {}
51
+ jsonResult.closeSecurity = { committedSecrets: _closeSecrets };
52
+ if (_closeSecrets) log(` 🚨 마감 보안: 커밋 대상 시크릿 ${_closeSecrets}건 미해소 — clean 아님, leerness scan secrets 확인 후 마감 권장`);
37
53
 
38
54
  function rowsToList(arr) {
39
55
  if (!arr || !arr.length) return '- 없음';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -451,9 +451,11 @@ total++;
451
451
  fs.writeFileSync(path.join(tmpV, 'src/myMod.js'), 'module.exports = {};\n');
452
452
  fs.writeFileSync(path.join(tmpV, 'tests/test.js'), 'check(1); check(2); check(3); check(4); check(5);\n');
453
453
  // T-row를 evidence와 함께 추가
454
+ // 1.17.4 (UR-0047): evidence 에 명시적 개수 주장(테스트 5개) 포함 — 카운트 검증이 실제로 수행되는 경로를 테스트.
455
+ // 이전 evidence 는 "(5/5 통과)"(pass 비율)만 있어 개수 주장이 없었는데도 옛 코드가 "✓ pass (실측 ≥ 주장)" 으로 표기(측정실패=통과 모순의 일부) — 정직화로 "⊘ (주장 없음)" 이 되므로 의도(카운트 검증)에 맞게 주장을 명시.
454
456
  fs.appendFileSync(path.join(tmpV, '.harness/progress-tracker.md'),
455
- '| T-0099 | done | 신모듈 | src/myMod.js + tests/test.js (5/5 통과) | next | 2026-05-14 |\n');
456
- // 정상: 파일 존재 + 테스트 5개
457
+ '| T-0099 | done | 신모듈 | src/myMod.js + tests/test.js 테스트 5개 (5/5 통과) | next | 2026-05-14 |\n');
458
+ // 정상: 파일 존재 + 테스트 5개 (주장 5 = 실측 5)
457
459
  const r = cp.spawnSync(process.execPath, [CLI, 'verify-claim', 'T-0099', '--path', tmpV], { encoding: 'utf8', timeout: 15000 });
458
460
  const okPass = r.status === 0 && /✓ src\/myMod\.js/.test(r.stdout) && /✓ tests\/test\.js/.test(r.stdout) && /pass \(실측 ≥ 주장\)/.test(r.stdout);
459
461
  // 파일 없는 케이스 → exit ≠ 0