leerness 1.30.0 → 1.31.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,132 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.31.0 — 2026-06-16 — 🛡️ [안정화/Stable] 14th리뷰 7/7 + 하위 프로젝트(detect/adopt) 안정 minor
4
+
5
+ **🛡️ 안정화(Stable) minor — 14번째 외부 리뷰 7건 전부 수정 + 사용자 명시 하위 프로젝트 기능을 npm 공개.** 직전 minor(1.30.0) 이후 누적된 패치 5건(1.30.1~1.30.5)을 검증·통합해 배포. R-0011 정책의 22번째 stable minor. 한국어 우선 기본은 그대로.
6
+
7
+ ### 이번 minor 통합 (1.30.1~1.30.5)
8
+ - **🔒 보안 정직성 (1.30.1, 14th리뷰 F1+F2)**: `audit`/`check` 가 소스에 커밋된 시크릿을 `_collectSecretFindings` 콘텐츠 스캔으로 **failure 승격**(scan secrets 와 일관, gitignored 는 FP 0). handoff `🔒 보안 요약` 섹션이 `.env` 없어도 **커밋 시크릿 노출**(파일 위치만, 값 미출력).
9
+ - **🔗 하위 프로젝트 부모 자산: 탐지 (1.30.2, 사용자 명시 #157)**: `leerness parent detect [--json]` — 상위 leerness 부모 탐지(read-only). handoff 헤드라인 `🔗 부모 프로젝트 (N 자산·미적용)`. 외부AI(codex)+Claude(Plan) 교차검토로 **방향 C "탐지+게이트"** 결정(부모 자산 재활용은 사용자 결정 게이트).
10
+ - **🔗 하위 프로젝트 부모 자산: 적용 (1.30.3, #158)**: `leerness parent adopt [--select] [--apply]` — dry-run 기본, `--apply`(사용자 명시) 시에만 자식-로컬 `inherited-from-parent.md`+`PARENT_LINK.json` 기록. **자식 design-system.md 무변경**(비파괴). handoff 헤드라인 adopt 상태 반영.
11
+ - **🧹 cli-ux 일관성 (1.30.4, 14th리뷰 F5+F6+F7)**: decision/lesson **dedup**(task/rule 일관) · rule/lesson 빈입력 **--json 구조화**(exit1) · bogus subcommand **잘못된 토큰 명시**.
12
+ - **🌐 i18n (1.30.5, 14th리뷰 F3+F4)**: `--language en` handoff 본문 4블록(워크플로 가이드/메모리 변동/ScheduleWakeup/팀 스케줄) + verify-claim/optimism-check 에러 영어화.
13
+ - **🐛 인프라**: `VERSION === package.json` selftest 가드(한쪽만 bump 실수 2초 차단) · e2e flake 하드닝(dispatch/banner timeout 10s→30s) · 윈도형 소스가드 확대.
14
+
15
+ ### 검증 (회귀 0)
16
+ - **selftest 254→257** (parent detect/adopt + 버전정합 + 보안/cli-ux/i18n 가드).
17
+ - **E2E 368→372** — 신규 행위가드: B(1.30.1) 보안정직성 · B(1.30.2) parent detect · B(1.30.3) parent adopt 비파괴 · B(1.30.4) cli-ux · i18n ⑫(handoff 본문/verify-claim).
18
+ - minor(1.31.0) — npm 배포(R-0011 stable) + annotated tag(Stable) + GitHub release(latest) + 게시본 클린룸 재실증.
19
+
20
+ ### 🎉 14번째 외부 멀티모델 리뷰 7/7 완료
21
+ bounded 3-에이전트 리뷰 → 맹신 X 7/7 재현검증 → F1~F7 전부 수정·배포. 리뷰→검증→백로그→수정의 전체 사이클 완주.
22
+
23
+ ### 잔여 (UR-0010 백로그)
24
+ - 팀 reminder 본문 · capabilities/commands/constraints/install-safety · init en seed 템플릿.
25
+
26
+ ## 1.30.5 — 2026-06-16 — 🌐 handoff 본문 i18n + verify-claim 에러 영어화 (14th 외부리뷰 F3+F4) — 14th 리뷰 7/7 완료
27
+
28
+ **🌐 14번째 외부 리뷰 마지막 2건(F3 P2 + F4 P3) 수정 → 14th 리뷰 7건 전부 소진.** `--language en` 에서 handoff 본문이 한국어 라벨을 누출하던 것 + verify-claim 미입력 에러 한국어를 영어화.
29
+
30
+ ### 변경 (14th 외부리뷰 F3+F4)
31
+ - **🌐 F3 (P2) handoff 본문 en 누출 영어화**: 매 핸드오프에 붙던 한국어 런타임 블록 4종을 영어 opt-in. (1) **세션 워크플로 6단계** 가이드(헤더+6단계+끄기 안내), (2) **🆕 최근 24h 메모리 변동**(라벨+`plan: 변경됨`+상세), (3) **⏰ ScheduleWakeup** 감지(miss 강한의심/지연/cycle 정상/fallback 4 분기 + 단위 분·시간·일), (4) **🤝 에이전트 팀 스케줄** 헤더+안내. 각 블록은 headline `t()` 스코프 밖이라 블록 로컬 `t()`/`_t()` 정의(1.29.1 교훈). 한국어 verbatim 보존.
32
+ - **🌐 F4 (P3) verify-claim/optimism-check 미입력 에러 영어화**: `verify-claim <T-ID> 필요. 예: …`(+쌍둥이 `optimism-check`) 를 `_uiLang(root)` 분기로 영어(`<T-ID> required. ex: …`). plain + `--json` 모두.
33
+
34
+ ### 검증 (회귀 0)
35
+ - **selftest 257** — 단, F4 가 `missing_args` 라인을 en/ko 로 늘려 1.9.400 소스가드의 `{0,400}` 윈도가 `not_found` 에 못 미쳐 1건 실패 → **윈도 `{0,700}` 확대로 복구**(라인 길이 늘 때 윈도형 소스가드가 깨지는 패턴, 즉시 적발·수정).
36
+ - **행위(맹신 X)**: `handoff --language en` 워크플로 가이드(4줄 영어, 한글 0) + 메모리 변동 영어 · `handoff` ko 보존 · verify-claim/optimism-check en 영어(`required. ex:`) + ko 보존(`필요. 예:`). OVERALL PASS.
37
+ - **E2E 372/372**: 기존 i18n 행위 케이스에 가드 ⑫ 확장(워크플로 가이드 + verify-claim en/ko) — 케이스 추가가 아닌 어서션 확장이라 총수 유지.
38
+ - patch(1.30.5) — npm 미배포(R-0011). bin+package.json 동시 bump + 일치 가드 통과.
39
+
40
+ ### 잔여 (UR-0010 백로그)
41
+ - 팀 스케줄 reminder 본문(pure-utils `_teamHandoffReminders`, 팀 정의 시만 노출) · capabilities/commands/constraints/install-safety · init en seed 템플릿.
42
+
43
+ ### 🎉 14th 외부리뷰 7/7 완료
44
+ F1/F2 보안정직성(1.30.1) · F3/F4 i18n(1.30.5) · F5/F6/F7 cli-ux(1.30.4) — 14번째 외부 멀티모델 리뷰의 재현·확인된 7 발견 전부 수정 완료.
45
+
46
+ ## 1.30.4 — 2026-06-16 — cli-ux 일관성: add류 dedup + 빈입력 --json + bogus subcommand (14th 외부리뷰 F5+F6+F7)
47
+
48
+ **🧹 14번째 외부 리뷰가 재현·확인한 cli-ux 일관성 3종(P2/P2/P3) 수정.** add 계열 명령들이 서로 다르게 동작하던 비일관성을 정리.
49
+
50
+ ### 변경 (14th 외부리뷰 F5+F6+F7)
51
+ - **F5 (P2) decision/lesson dedup**: `decision add` / `lesson save` 가 동일 입력을 무조건 append 해 중복 누적되던 것을 `task add`/`rule add` 와 일관되게 **dedup**(동일 title/text 존재 시 `exists (skip)`, `--force` 우회). `--json` 시 `{ok:true,skipped:true}`.
52
+ - **F6 (P2) rule/lesson 빈입력 --json 구조화**: `rule add "" --json` / `lesson save "" --json` 가 평문 `✗` 를 출력하던 것을 `decision/task add` 와 일관되게 **구조화 JSON**(`{ok:false,code:empty_title|empty_text}` + exit 1). dispatch 레이어에 failJson 가드 추가. 성공 경로 JSON 무회귀.
53
+ - **F7 (P3) bogus subcommand 토큰 명시**: `task frobnicate` / `rule frobnicate` 가 top-level `unknown_command: task/rule`(유효 부모명 오인) 를 출력하던 것을, **잘못된 토큰을 명시 + usage**(`알 수 없는 task 하위명령: frobnicate — leerness task list|add|...`) + exit 1 로 수정. decision 의 usage 출력과 일관.
54
+
55
+ ### 검증 (회귀 0)
56
+ - **selftest 257** (변동 없음 — 행위 변경이라 e2e 로 검증).
57
+ - **행위(맹신 X)**: F5 decision/lesson 1 copy + --force→2 · F6 rule/lesson 빈입력 구조화 JSON+exit1 & 성공경로 JSON 유지 · F7 task/rule 잘못된 토큰 명시+exit1 & 유효 하위명령 무회귀. 3/3 PASS.
58
+ - **E2E 371→372**: 새 가드 B(1.30.4).
59
+ - patch(1.30.4) — npm 미배포(R-0011). bin+package.json 동시 bump(1.30.2 교훈) + 일치 가드 통과.
60
+
61
+ ### 14th 외부리뷰 잔여
62
+ - **#156** F3+F4 i18n(handoff 본문 en 누출 + verify-claim 에러) — 다음 라운드. → 14th 리뷰 7건 중 F1/F2(1.30.1)+F5/F6/F7(1.30.4) 완료, F3/F4 남음.
63
+
64
+ ## 1.30.3 — 2026-06-16 — 🔗 parent adopt: 부모 자산 게이트형 적용 (dry-run 기본·비파괴) (#158 사용자명시)
65
+
66
+ **🔗 1.30.2(탐지)의 후속 — 부모 자산을 자식 프로젝트에 '적용'하는 단계.** 사용자 결정 게이트: dry-run 기본, `--apply`(사용자 명시 결정) 시에만 기록하며, **자식 원본 design-system.md 를 변형하지 않고 별도 참조 파일에만 기록(비파괴)**.
67
+
68
+ ### 변경 (#158)
69
+ - **`leerness parent adopt [--select <kinds>] [--apply] [--json]`** (신규):
70
+ - **dry-run 기본**(--apply 없음): 적용 후보(design-system/reuse-map/conventions)를 보여주고 **아무 파일도 쓰지 않음**. "실제 적용하려면 --apply" 안내.
71
+ - **`--apply`(사용자 명시 결정)**: 부모 자산을 자식-로컬 `.harness/inherited-from-parent.md`(참조) + `.harness/PARENT_LINK.json`(마커)로 기록. **자식 design-system.md/reuse-map.md 직접 변형 안 함**(additive·비파괴·되돌리기 쉬움).
72
+ - `--select design-system,reuse-map,conventions`(기본 all) — 적용 자산 선택.
73
+ - intent expand(1.9.213) 안전 모델 동일: Always-Off Opt-In + Dry-run 기본 + 명시 결정.
74
+ - **handoff 헤드라인 adopt 상태 반영**: 자식에 `PARENT_LINK.json` 있으면 `🔗 부모 프로젝트 (N 자산·adopted)`, 없으면 `·미적용`(en: adopted / not applied). 게이트 상태 가시화.
75
+ - commands 카탈로그 `parent detect|adopt` 갱신.
76
+
77
+ ### 검증 (회귀 0)
78
+ - **selftest 256→257** (adopt 게이트/비파괴 소스가드).
79
+ - **행위(맹신 X)**: dry-run→쓰기 0 · `--apply`→inherited+marker 기록 · **자식 design-system.md 무변경 확인** · linkOk · handoff 헤드라인 adopted(ko/en) · `--json applied:true`. OVERALL PASS.
80
+ - **E2E 370→371**: 새 가드 B(1.30.3) — dry-run 쓰기0 + --apply 비파괴 + handoff adopted + --json.
81
+ - patch(1.30.3) — npm 미배포(R-0011). VERSION bump bin+package.json 동시(1.30.2 교훈) + 일치 가드 통과.
82
+
83
+ ### #157~158 정리
84
+ - 하위 프로젝트 부모 자산: **탐지(1.30.2, read-only) → adopt(1.30.3, 게이트형 비파괴)** 2 슬라이스 완료. 외부 codex 리뷰는 비동기(미반영) — 도착 시 후속 보강.
85
+
86
+ ## 1.30.2 — 2026-06-16 — 🔗 하위 프로젝트: 상위 leerness 부모 탐지(read-only) — 자산 재사용은 사용자 결정 게이트 (#157 사용자명시)
87
+
88
+ **🔗 leerness 프로젝트 하위에 신규 하위프로젝트로 이어 개발할 때, 부모의 자산(design-system/reuse-map/컨벤션)을 어떻게 다룰지** 에 대한 사용자 명시 요청(#157). **외부AI(codex)+Claude(Plan) 교차검토**로 방향을 정하고 첫 슬라이스 구현.
89
+
90
+ ### 방향 결정 (교차검토 + 사용자 제약)
91
+ - 사용자 하드 제약: **부모 자산 재활용은 자동이 아니라 사용자 결정 게이트**(묻거나 지시에 따라 톤/스타일 적용).
92
+ - 검토 결과 → **방향 C "탐지 + 게이트"**: (A 자동상속)은 사용자 제약·leerness intent-expand 안전모델 위배, (B 독립 nested)는 재사용 목표 폐기. **C = 부모를 탐지해 AI 에게 '재사용 후보'로 노출만 하고, 실제 적용은 사용자 결정 게이트**(intent expand 1.9.213 / migrate-workspace-dir 1.9.211 과 동일 철학: 탐지·노출 ≠ 적용).
93
+
94
+ ### 변경 (첫 슬라이스 — read-only 탐지)
95
+ - **`_findParentWorkspace(root)` 순수 헬퍼**: root 상위로 올라가며 가장 가까운 leerness 부모(`.harness`/`.leerness`)를 탐지. FP/안전 가드: root 자신 제외 + 깊이 상한(8) + 실제 워크스페이스 디렉토리만. 부모 자산(design-system/reuse-map/AGENTS/skills) 존재 여부 + assetCount 반환.
96
+ - **`leerness parent detect [--json]`** (신규, read-only): 부모 경로 + 재사용 가능 자산 + **"⚠ 자동 적용 안 함 — 사용자 결정 게이트"** 경고 + 부모 design-system/reuse-find 포인터. 아무 파일도 쓰지 않음(`applied:false`). ko/en.
97
+ - **handoff 헤드라인 토큰 `🔗 부모 프로젝트 (N 자산·미적용)`** (en: `🔗 parent project (N assets, not applied)`): AI 가 세션 시작 즉시 부모 컨텍스트를 인지(재발명 방지)하되 '미적용' 표기로 게이트 명확. read-only.
98
+ - commands 카탈로그 등재(표면 일관성).
99
+
100
+ ### 후속 (다음 슬라이스, 별도 라운드)
101
+ - **`leerness parent adopt --select tone|tokens|conventions --apply`** (dry-run 기본, 사용자 명시 confirm 시에만 적용): 부모 톤/스타일을 자식-로컬 `inherited-from-parent.md` + `PARENT_LINK` 마커로 기록(자식 design-system.md 직접 변형 X). intent expand 게이트 패턴 재사용.
102
+
103
+ ### 검증 (회귀 0)
104
+ - **selftest 254→256** (① parent detect 행위: 부모 탐지·assetCount·독립 null·read-only 소스마커 ② **VERSION ↔ package.json 일치 가드**).
105
+ - **행위**: subproject→부모탐지(4자산)·standalone→독립·`--json applied:false`·handoff 헤드라인 🔗(ko/en 미적용)·read-only(파일쓰기 0) 확인.
106
+ - **E2E 369→370**: 새 가드 B(1.30.2) — parent detect/--json/standalone-null/handoff-headline/read-only.
107
+ - **🐛 버전 불일치 자체수정 + 영구가드**: 이 라운드에서 `bin VERSION` 만 1.30.2 로 bump 하고 `package.json` 을 1.30.1 로 둔 실수 → 배너가 v1.30.2 인데 package.json 1.30.1 라 banner e2e 가 결정적 실패. 처음엔 flake 로 오인했으나 **격리 5회 재현(맹신 X)** 으로 결정적 실 버그임을 확인·수정. 재발 방지로 `VERSION === package.json.version` selftest 가드 추가(11분 e2e 대신 2초 차단).
108
+ - **e2e flake 하드닝(1.9.375 계열)**: agents dispatch(3 spawn)·--version --banner subprocess timeout 10s→30s — 전체 e2e 부하 하 간헐 빈-stdout 오판 방지(동작 무변경). dispatch 는 실제 flake, banner 는 위장한 실버그였음(위 항목).
109
+ - patch(1.30.2) — npm 미배포(R-0011, GitHub/CHANGELOG 누적).
110
+
111
+ > 교차검토 메모: Claude Plan 에이전트는 방향 C + 첫 슬라이스(탐지+헤드라인)를 권고. 외부 codex 에이전트 리뷰는 비동기 진행(완료 시 후속 adopt 슬라이스에 반영). 방향 C 는 사용자 제약·Plan·leerness 기존 안전모델이 모두 가리켜 overdetermined.
112
+
113
+ ## 1.30.1 — 2026-06-16 — 🔒 보안 정직성: audit/handoff 보안요약이 커밋된 시크릿 노출 (14th 외부리뷰 F1+F2)
114
+
115
+ **🔒 도구가 보안 상태를 정직하게 보고하도록 수정.** 14번째 외부 리뷰가 재현·확인한 정직성 갭 2건(P2): `audit`/handoff 보안요약이 소스에 커밋된 실 시크릿을 노출하지 못해, `scan secrets`(exit 1)와 모순되게 healthy 를 반환하던 문제. 정직성 테마(1.9.415/1.9.418/1.27.1 계열) 연장.
116
+
117
+ ### 변경 (14th 외부리뷰 F1+F2)
118
+ - **🔒 F1 — audit 커밋 시크릿 failure 승격**: `audit`/`check` 가 `_collectSecretFindings` 콘텐츠 스캔을 돌려 **committed 시크릿을 failure 로 승격**(healthy:false / exit 1) — `scan secrets` 와 일관. 기존엔 `.gitignore` 패턴/`.env` 동기화만 검사해, un-gitignored `.env` + 실 AWS/GitHub 키에 healthy:true/exit 0 을 반환(audit 기반 CI 게이트가 노출 시크릿을 통과)하던 갭. **gitignored 보관 시크릿은 committed 에서 제외 → FP 0**. 끄기: `--no-secret-scan`. (lib/audit.js, DI `_collectSecretFindings` 주입.)
119
+ - **🔒 F2 — handoff 보안요약이 committed 시크릿 노출**: `## 🔒 보안 요약` 섹션이 headline 의 `🚨 시크릿 N건` 과 일관되게 **커밋된 시크릿을 노출**. 기존엔 `.env`/`.gitignore` 만 검사 + `envExists` 단독 게이팅이라 `.env` 없으면 섹션 통째 생략(헤드라인은 시크릿 N건인데 상세 섹션 부재). 이제 committed 시크릿이 있으면 `.env` 없어도 섹션 표시 + 파일 위치(`file:line`) 노출(값 snippet 은 미출력 — handoff 로그로의 시크릿 유출 방지). ko/en 양쪽.
120
+
121
+ ### 검증 (회귀 0)
122
+ - **selftest 254/254** (audit 위임/FP-FN 가드 유지).
123
+ - **행위 검증**: F1 un-gitignored .env+실키 → audit exit 1/healthy:false(=scan 일관) · **FP 0**(gitignored .env+키 → healthy:true · 클린 → healthy:true) · F2 committed 시크릿(.env 없음) → 보안요약 섹션 노출(ko) + en 영어(섹션 한글 0, Node 탐지).
124
+ - **E2E i18n+보안가드 신규 B(1.30.1)**: F1 승격/FP0 + F2 ko/en 노출을 e2e 에 못박음. **368→369**.
125
+ - patch(1.30.1) — npm 미배포(R-0011, GitHub/CHANGELOG 누적).
126
+
127
+ ### 14th 외부리뷰 잔여 백로그 (다음 라운드)
128
+ - F5+F6+F7 cli-ux 일관성(decision/lesson dedup · rule/lesson 빈입력 --json 구조화 · bogus subcommand 토큰) · F3+F4 UR-0010 잔여 i18n(handoff 본문 en 누출 · verify-claim 에러).
129
+
3
130
  ## 1.30.0 — 2026-06-16 — 🛡️ [안정화/Stable] handoff 본문 i18n 4종 안정 minor
4
131
 
5
132
  **🛡️ 안정화(Stable) minor — handoff 본문 4블록 영어화 + i18n-coupling 감사 + 블록-스코프 t 회귀수정을 npm 공개.** 직전 minor(1.29.0) 이후 누적된 패치 4건(1.29.1~1.29.4)을 검증·통합해 배포. R-0011 정책의 21번째 stable minor. 한국어 우선 기본은 그대로.
package/README.md CHANGED
@@ -104,7 +104,7 @@ MIT
104
104
  <!-- leerness:project-readme:start -->
105
105
  ## Leerness Project Harness
106
106
 
107
- 이 프로젝트는 Leerness v1.30.0 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
107
+ 이 프로젝트는 Leerness v1.31.0 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
108
108
 
109
109
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
110
110
 
@@ -158,7 +158,7 @@ leerness memory restore decision <date|title>
158
158
 
159
159
  ### MCP server (외부 AI 통합)
160
160
 
161
- Leerness v1.30.0는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **85개 도구**를 노출:
161
+ Leerness v1.31.0는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **85개 도구**를 노출:
162
162
 
163
163
  ```jsonc
164
164
  // 카테고리별
@@ -179,7 +179,7 @@ Leerness v1.30.0는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code
179
179
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
180
180
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) stress-v* 신규 작성 + 누적 회귀 → 4) e2e 219/219 → 5) npm pack + git tag + GitHub release → 6) main 자동 push (1.9.140+) → 7) session close → 8) 다음 라운드 예약.
181
181
 
182
- 현재 누적: **70 라운드 (1.9.40 → 1.30.0)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
182
+ 현재 누적: **70 라운드 (1.9.40 → 1.31.0)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
183
183
 
184
184
  ### 성능 가이드 (1.9.140 측정)
185
185
 
@@ -217,6 +217,6 @@ leerness release pack --close --auto-main-push
217
217
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
218
218
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
219
219
 
220
- Last synced by Leerness v1.30.0: 2026-06-16
220
+ Last synced by Leerness v1.31.0: 2026-06-16
221
221
  <!-- leerness:project-readme:end -->
222
222
 
package/bin/leerness.js CHANGED
@@ -32,7 +32,7 @@ const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInG
32
32
  // 1.9.295 (UR-0025 4단계): 정적 데이터 카탈로그 모듈 분리 (비파괴, require-based).
33
33
  const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _TOOL_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, SKILL_CATALOG_PRESETS } = require('../lib/catalogs'); // 1.9.344/368/369 (UR-0025): catalog 분리 · 1.11.4 (UR-0007): _TOOL_CATALOG
34
34
 
35
- const VERSION = '1.30.0';
35
+ const VERSION = '1.31.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') 시 호스트 프로세스 오염.
@@ -2966,7 +2966,7 @@ function _selfTestCases() {
2966
2966
  { name: '6번째 외부평가/codex P1-A (UR-0098): install-safety 레시피 셸-무관 + hardeningNote (1.9.397)', run: () => { if (typeof installSafetyCmd !== 'function') return false; const save = process.argv; const _w = process.stdout.write; let out = ''; try { process.argv = ['node', 'h', 'install-safety', '--json']; process.stdout.write = s => { out += s; return true; }; installSafetyCmd({ json: true }); } catch {} finally { process.stdout.write = _w; process.argv = save; } let j; try { j = JSON.parse(out); } catch {} const noPosixPrefix = !!j && Array.isArray(j.safeInstall) && !j.safeInstall.some(x => /^npm_config_\w+=/.test(String(x).trim())); const crossShell = !!j && j.safeInstall.filter(x => String(x).includes('npx --yes')).length >= 2; const noteOk = !!j && typeof j.hardeningNote === 'string' && j.hardeningNote.includes('PowerShell'); return noPosixPrefix && crossShell && noteOk; } },
2967
2967
  { name: '6번째 외부평가/codex P1-C (UR-0099): --json 에러 경로 구조화 failJson + 와이어 (1.9.398)', run: () => { const io = require('../lib/io'); if (io.failJson !== failJson) return false; const _w = process.stdout.write; const saved = process.exitCode; let jOut = '', hOut = ''; let jExit = 0; try { process.stdout.write = s => { jOut += s; return true; }; process.exitCode = 0; failJson(true, 'tc', 'm'); jExit = process.exitCode; process.stdout.write = s => { hOut += s; return true; }; process.exitCode = 0; failJson(false, 'c', 'humanmsg'); } catch {} finally { process.stdout.write = _w; process.exitCode = saved; } let pj; try { pj = JSON.parse(jOut); } catch {} const jsonOk = !!pj && pj.ok === false && pj.code === 'tc' && pj.error === 'm' && jExit === 1; const humanOk = hOut.includes('✗') && hOut.includes('humanmsg') && !hOut.includes('{'); const src = read(__filename); const wired = src.includes("failJson(_j, 'missing_args'") && src.includes("failJson(_j, 'spec_not_found'"); return jsonOk && humanOk && wired; } },
2968
2968
  { name: '7번째 버그헌트 P1-A (UR-0104): 테이블셀 안전화 _cellSafe/_cellUnescape (파이프/개행 injection 차단) (1.9.399)', run: () => { const m = require('../lib/pure-utils'); if (m._cellSafe !== _cellSafe || m._cellUnescape !== _cellUnescape) return false; const safe = _cellSafe('fix | bug\nrow2'); const noRaw = !/(?<!\\)\|/.test(safe) && !/[\r\n]/.test(safe); const pipeRt = _cellUnescape(_cellSafe('a | b | c')) === 'a | b | c'; const nlGone = _cellSafe('a\nb') === 'a b'; const src = read(__filename); const wired = src.includes('_cellSafe(r.request)') && src.includes('_cellSafe(r.rule)'); return noRaw && pipeRt && nlGone && wired; } },
2969
- { name: '7번째 버그헌트 P1-B (UR-0105): verify-claim/optimism-check/honesty-check --json 에러 구조화 (1.9.400)', run: () => { const src = read(__filename); const vc = /function verifyClaimCmd[\s\S]{0,400}?failJson\(_j, 'not_found'/.test(src); const oc = /function optimismCheckCmd[\s\S]{0,400}?failJson\(_j, 'not_found'/.test(src); const hc = /function honestyCheckCmd[\s\S]{0,900}?failJson\(has\('--json'\), 'not_found'/.test(src); return vc && oc && hc; } },
2969
+ { name: '7번째 버그헌트 P1-B (UR-0105): verify-claim/optimism-check/honesty-check --json 에러 구조화 (1.9.400)', run: () => { const src = read(__filename); const vc = /function verifyClaimCmd[\s\S]{0,700}?failJson\(_j, 'not_found'/.test(src); const oc = /function optimismCheckCmd[\s\S]{0,700}?failJson\(_j, 'not_found'/.test(src); const hc = /function honestyCheckCmd[\s\S]{0,900}?failJson\(has\('--json'\), 'not_found'/.test(src); return vc && oc && hc; } }, // 1.30.5: {0,400}→{0,700} (F4 가 missing_args 라인을 en/ko 로 늘려 not_found 가 창 밖)
2970
2970
  { name: '7번째 버그헌트 P1-C (UR-0106): 시크릿 FN — gitignore 부정(!) + placeholder substring 정밀화 (1.9.401)', run: () => { const m = require('../lib/pure-utils'); const gm = m._gitignoreMatch; const negOk = gm('*.example\n!.env.example', '.env.example') === false && gm('*.log', 'a.log') === true && gm('a.log\n!a.log', 'a.log') === false && gm('.env', '.env') === true; const ph = m._isPlaceholderSecret; const phOk = ph('sk-EXAMPLEab12cd34ef56gh78ij90kl') === false && ph('sk-proj-realKEYexample9988776655') === false && ph('your-key-here') === true && ph('changeme') === true && ph('example') === true && ph('xxxxxxxxxxxxxxxxxxxxxxxxxxxx') === true; return negOk && phOk; } },
2971
2971
  { name: '7번째 버그헌트 P1-A 잔여 (UR-0108): decisions/lessons MD projection 개행 주입 차단 _lineSafe (1.9.402)', run: () => { const m = require('../lib/pure-utils'); if (m._lineSafe !== _lineSafe) return false; const lsOk = _lineSafe('a\nb\r\nc') === 'a b c'; const md = m._renderDecisionsMd([{ date: '2026-06-07', title: 'real\n### 2099-01-01 — FAKE\n- Decision: forged', decision: 'd', reason: 'r' }]); const re = m._decisionsFromMd(md); const noInject = re.length === 1 && !/^### 2099-01-01 — FAKE/m.test(md); const lmd = m._renderLessonsMd([{ date: '2026-06-07', text: 'l1\n### FAKE\n- Lesson: x', tag: 't' }]); const lre = m._parseLessonEntries(lmd); const lNoInject = lre.length === 1; return lsOk && noInject && lNoInject; } },
2972
2972
  { name: '7번째 버그헌트 P2 (UR-0107): api-skill show/drop 에러 exit code 1 (1.9.403)', run: () => { const src = read(__filename); const showId = src.includes("api-skill show <id>')); process.exitCode = 1"); const dropId = src.includes("api-skill drop <id>')); process.exitCode = 1"); const addUrl = src.includes("api-skill add <url> [--direction") && src.includes('process.exitCode = 1'); return showId && dropId && addUrl; } },
@@ -3114,7 +3114,7 @@ function _selfTestCases() {
3114
3114
  fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true });
3115
3115
  fs.writeFileSync(path.join(tmp, 'AGENTS.md'), '# x');
3116
3116
  process.stdout.write = s => { out += s; return true; };
3117
- m.audit(tmp, { json: true }, { VERSION, arg: (k, d) => d, has: f => f === '--json', planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills });
3117
+ m.audit(tmp, { json: true }, { VERSION, arg: (k, d) => d, has: f => f === '--json', planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills, _collectSecretFindings });
3118
3118
  } catch (e) { out = 'ERR:' + e.message; } finally { process.stdout.write = _w; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
3119
3119
  try { const j = JSON.parse(out); behavOk = typeof j.healthy === 'boolean' && Array.isArray(j.findings); } catch {}
3120
3120
  return expOk && delegated && movedToLib && behavOk;
@@ -3891,7 +3891,33 @@ function _selfTestCases() {
3891
3891
  && bin.includes('전체/기록/최신' + '화: leerness slash-commands');
3892
3892
  return en && koPreserved;
3893
3893
  } },
3894
- { name: 'VERSION 형식 (x.y.z)', run: () => /^\d+\.\d+\.\d+$/.test(VERSION) }
3894
+ { name: 'parent detect (1.30.2 #157): 상위 leerness 부모 탐지(행위) + 독립 null + assetCount', run: () => {
3895
+ const osx = require('os'); const fsx = require('fs');
3896
+ const base = fsx.mkdtempSync(path.join(osx.tmpdir(), 'leer-parent-st-'));
3897
+ const alone = fsx.mkdtempSync(path.join(osx.tmpdir(), 'leer-alone-st-'));
3898
+ try {
3899
+ fsx.mkdirSync(path.join(base, '.harness'), { recursive: true });
3900
+ fsx.writeFileSync(path.join(base, '.harness', 'design-system.md'), '# ds');
3901
+ const sub = path.join(base, 'sub'); fsx.mkdirSync(sub, { recursive: true });
3902
+ const found = _findParentWorkspace(sub);
3903
+ const standalone = _findParentWorkspace(alone);
3904
+ // 부모 탐지: 워크스페이스 .harness + assetCount≥1(design-system) · 독립: null · read-only(소스에 파일쓰기 없음 — adopt 미구현)
3905
+ const readOnly = /이 명령은 아무 파일도 쓰지 않는다/.test(read(__filename));
3906
+ return !!found && found.workspaceDir === '.harness' && found.assetCount >= 1 && standalone === null && readOnly;
3907
+ } finally { try { fsx.rmSync(base, { recursive: true, force: true }); } catch {}; try { fsx.rmSync(alone, { recursive: true, force: true }); } catch {} }
3908
+ } },
3909
+ { name: 'VERSION 형식 (x.y.z)', run: () => /^\d+\.\d+\.\d+$/.test(VERSION) },
3910
+ { name: 'VERSION ↔ package.json 일치 (1.30.2: 한쪽만 bump 하던 실수 2초 내 차단)', run: () => {
3911
+ // bin VERSION 과 package.json.version 이 어긋나면 배너/배포가 거짓 버전을 표기 → 즉시 fail.
3912
+ try { return require('../package.json').version === VERSION; } catch { return false; }
3913
+ } },
3914
+ { name: 'parent adopt (1.30.3 #158): 게이트형 adopt(dry-run 기본/--apply) + 비파괴 참조파일 (소스 가드)', run: () => {
3915
+ const bin = read(__filename);
3916
+ // split-literals (self-reference trap 회피)
3917
+ const hasAdopt = bin.includes("'ado" + "pt'") && bin.includes('parent ado' + 'pt --apply');
3918
+ const nonDestructive = bin.includes('inherited-from-pa' + 'rent.md') && bin.includes('PARENT_LI' + 'NK.json');
3919
+ return hasAdopt && nonDestructive;
3920
+ } }
3895
3921
  ];
3896
3922
  }
3897
3923
  function selfTestCmd(opts = {}) {
@@ -4671,6 +4697,7 @@ function commandsCmd(root) {
4671
4697
  { cmd: 'update [--check|--yes|--force]', desc: '자가 업데이트' },
4672
4698
  { cmd: 'wakeup-interval get|set|auto|history|record', desc: 'adaptive wakeup (1.9.210)' },
4673
4699
  { cmd: 'workspace-dir get|guide', desc: '워크스페이스 디렉토리 (1.9.211)' },
4700
+ { cmd: 'parent detect|adopt [--select <kinds>] [--apply]', desc: '상위 leerness 부모 탐지 + 자산 게이트형 adopt (1.30.2~3)' },
4674
4701
  { cmd: 'intent classify|expand|domains "<request>"', desc: '의도 파악 + scope (1.9.213)' },
4675
4702
  { cmd: 'constraints list|check|add', desc: '플랫폼/API 제약 (1.9.208)' },
4676
4703
  { cmd: 'provider list|add|remove|sync', desc: 'Provider Registry (1.9.157~160)' },
@@ -5202,6 +5229,36 @@ function _workspaceDirName(root) {
5202
5229
  function _workspaceDirAbs(root) {
5203
5230
  return path.join(root, _workspaceDirName(root));
5204
5231
  }
5232
+ // 1.30.2 (#157 사용자명시, 하위 프로젝트 방향 — 외부AI+Claude 교차검토 → 방향 C "탐지+게이트"):
5233
+ // 현재 root 의 '상위' 디렉토리 중 가장 가까운 leerness 부모(.harness/ 또는 .leerness/ 보유)를 탐지(read-only).
5234
+ // 부모 자산(design-system/reuse-map/tone) 적용은 자동이 아니라 사용자 결정 게이트 — 이 함수는 '탐지만' 한다(적용 X).
5235
+ // FP/안전: root 자신 제외(dirname 부터 시작) + 깊이 상한(monorepo/깊은 트리에서 무한 상승 방지) + 실제 워크스페이스 디렉토리만 매칭.
5236
+ function _findParentWorkspace(root, opts = {}) {
5237
+ try {
5238
+ root = absRoot(root);
5239
+ const maxDepth = opts.maxDepth || 8;
5240
+ let cur = path.dirname(root);
5241
+ let prev = null, depth = 0;
5242
+ while (cur && cur !== prev && depth < maxDepth) {
5243
+ const hasHarness = exists(path.join(cur, '.harness'));
5244
+ const hasLeerness = exists(path.join(cur, '.leerness'));
5245
+ if (hasHarness || hasLeerness) {
5246
+ const wd = (hasLeerness && exists(path.join(cur, '.leerness', 'MIGRATED_FROM_HARNESS'))) ? '.leerness' : (hasHarness ? '.harness' : '.leerness');
5247
+ const wsAbs = path.join(cur, wd);
5248
+ const assets = {
5249
+ designSystem: exists(path.join(wsAbs, 'design-system.md')),
5250
+ reuseMap: exists(path.join(wsAbs, 'reuse-map.md')),
5251
+ agents: exists(path.join(cur, 'AGENTS.md')),
5252
+ skills: exists(path.join(wsAbs, 'skills')),
5253
+ };
5254
+ const assetCount = Object.values(assets).filter(Boolean).length;
5255
+ return { parentRoot: cur, workspaceDir: wd, workspaceAbs: wsAbs, assets, assetCount, depth: depth + 1 };
5256
+ }
5257
+ prev = cur; cur = path.dirname(cur); depth++;
5258
+ }
5259
+ } catch {}
5260
+ return null;
5261
+ }
5205
5262
  // .harness → .leerness 마이그레이션 (copy + reference guide 생성)
5206
5263
  function _migrateWorkspaceDir(root, opts = {}) {
5207
5264
  const dryRun = opts.dryRun === true;
@@ -6041,6 +6098,103 @@ function workspaceDirCmd(root, sub) {
6041
6098
  process.exit(1);
6042
6099
  }
6043
6100
 
6101
+ // 1.30.2 (#157 사용자명시): leerness parent — 상위 leerness 부모 프로젝트 탐지(read-only).
6102
+ // 방향 C(교차검토): 부모 자산을 '재사용 후보'로 AI 에게 노출만 하고, 톤/스타일 등 실제 적용은 사용자 결정 게이트(후속 adopt 명령).
6103
+ // intent expand(1.9.213) 안전 모델과 동일 철학 — 탐지/노출 ≠ 적용. 이 명령은 아무 파일도 쓰지 않는다.
6104
+ function parentCmd(root, sub) {
6105
+ root = absRoot(root);
6106
+ const uiLang = _uiLang(root);
6107
+ const t = (ko, en) => (uiLang === 'en' ? en : ko);
6108
+ const isTty = process.stdout && process.stdout.isTTY;
6109
+ const cyan = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
6110
+ const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
6111
+ const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
6112
+ const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
6113
+ const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
6114
+ if (!sub || sub === 'detect') {
6115
+ const p = _findParentWorkspace(root);
6116
+ if (has('--json')) { log(JSON.stringify({ version: VERSION, root, parent: p, applied: false }, null, 2)); return; }
6117
+ log(cyan(`# leerness parent detect (1.30.2)`));
6118
+ if (!p) {
6119
+ log(dim(t(` 상위 leerness 부모 프로젝트 없음 — 독립 프로젝트입니다.`, ` no parent leerness project found — this is a standalone project.`)));
6120
+ return;
6121
+ }
6122
+ const mark = b => b ? '✓' : '✗';
6123
+ log(t(` 부모 프로젝트: ${grn(p.parentRoot)} (${p.workspaceDir}/, depth ${p.depth})`, ` parent project: ${grn(p.parentRoot)} (${p.workspaceDir}/, depth ${p.depth})`));
6124
+ log(t(` 재사용 가능 자산 ${p.assetCount}건: design-system=${mark(p.assets.designSystem)} reuse-map=${mark(p.assets.reuseMap)} AGENTS=${mark(p.assets.agents)} skills=${mark(p.assets.skills)}`,
6125
+ ` reusable assets ${p.assetCount}: design-system=${mark(p.assets.designSystem)} reuse-map=${mark(p.assets.reuseMap)} AGENTS=${mark(p.assets.agents)} skills=${mark(p.assets.skills)}`));
6126
+ log('');
6127
+ log(yel(t(` ⚠ 자동 적용하지 않음 — 부모 자산(톤/스타일/디자인) 재사용은 사용자 결정 게이트입니다.`, ` ⚠ not auto-applied — reusing parent assets (tone/style/design) is a user decision.`)));
6128
+ log(dim(t(` 참고: leerness reuse find "<capability>" --path ${p.parentRoot} · 부모 design-system: ${path.join(p.workspaceAbs, 'design-system.md')}`,
6129
+ ` ref: leerness reuse find "<capability>" --path ${p.parentRoot} · parent design-system: ${path.join(p.workspaceAbs, 'design-system.md')}`)));
6130
+ log(dim(t(` 재사용 적용(사용자 결정): leerness parent adopt --select design-system,reuse-map,conventions --apply`,
6131
+ ` adopt (your decision): leerness parent adopt --select design-system,reuse-map,conventions --apply`)));
6132
+ return;
6133
+ }
6134
+ // 1.30.3 (#158): leerness parent adopt — 부모 자산을 자식-로컬 참조로 기록(게이트형 적용).
6135
+ // 사용자 결정 게이트: dry-run 기본, --apply(사용자 명시) 시에만 기록. 자식 design-system.md 무변경(비파괴, additive).
6136
+ if (sub === 'adopt') {
6137
+ const p = _findParentWorkspace(root);
6138
+ const apply = has('--apply');
6139
+ const selRaw = arg('--select', 'all');
6140
+ const allKinds = ['design-system', 'reuse-map', 'conventions'];
6141
+ const kinds = (selRaw === 'all') ? allKinds : String(selRaw).split(',').map(s => s.trim()).filter(Boolean);
6142
+ const wsAbs = _workspaceDirAbs(root);
6143
+ const inheritedPath = path.join(wsAbs, 'inherited-from-parent.md');
6144
+ const linkPath = path.join(wsAbs, 'PARENT_LINK.json');
6145
+ const cand = [];
6146
+ if (p) {
6147
+ if (kinds.includes('design-system') && p.assets.designSystem) cand.push({ kind: 'design-system', src: path.join(p.workspaceAbs, 'design-system.md') });
6148
+ if (kinds.includes('reuse-map') && p.assets.reuseMap) cand.push({ kind: 'reuse-map', src: path.join(p.workspaceAbs, 'reuse-map.md') });
6149
+ if (kinds.includes('conventions') && p.assets.agents) cand.push({ kind: 'conventions', src: path.join(p.parentRoot, 'AGENTS.md') });
6150
+ }
6151
+ // 1.30.3: --json 은 단일 객체만 출력 — apply(부모 존재) 경로는 자체 JSON(applied:true)을 내므로 여기선 제외(이중 JSON 방지).
6152
+ if (has('--json') && (!p || !apply)) {
6153
+ log(JSON.stringify({ version: VERSION, root, parent: p ? p.parentRoot : null, selected: kinds, candidates: cand.map(c => c.kind), apply, applied: false, inheritedPath: null }, null, 2));
6154
+ }
6155
+ if (!p) {
6156
+ if (!has('--json')) { log(cyan(`# leerness parent adopt (1.30.3)`)); log(dim(t(` 상위 leerness 부모 프로젝트 없음 — adopt 대상 없음.`, ` no parent leerness project — nothing to adopt.`))); }
6157
+ return;
6158
+ }
6159
+ if (!apply) {
6160
+ if (!has('--json')) {
6161
+ log(cyan(`# leerness parent adopt (1.30.3) [DRY-RUN]`));
6162
+ log(t(` 부모: ${grn(p.parentRoot)} · 선택: ${kinds.join(', ')}`, ` parent: ${grn(p.parentRoot)} · select: ${kinds.join(', ')}`));
6163
+ if (!cand.length) log(dim(t(` 적용 후보 없음 (부모에 선택 자산 없음).`, ` no candidates (parent lacks the selected assets).`)));
6164
+ else cand.forEach(c => log(t(` • ${c.kind} ← ${c.src}`, ` • ${c.kind} ← ${c.src}`)));
6165
+ log('');
6166
+ log(yel(t(` ⚠ DRY-RUN — 실제 적용하려면 \`leerness parent adopt --apply\` (사용자 명시 결정).`, ` ⚠ DRY-RUN — to apply, run \`leerness parent adopt --apply\` (explicit user decision).`)));
6167
+ log(dim(t(` 적용해도 자식 design-system.md 는 변경하지 않고, .harness/inherited-from-parent.md 에 '참조'로만 기록(비파괴).`,
6168
+ ` even on apply, your design-system.md is NOT modified — parent assets are recorded as reference in .harness/inherited-from-parent.md.`)));
6169
+ }
6170
+ return;
6171
+ }
6172
+ // APPLY (사용자 명시): 자식-로컬 참조 파일 + 마커 기록 (비파괴 additive — 자식 원본 design-system.md/reuse-map.md 직접 변형 안 함)
6173
+ try {
6174
+ mkdirp(wsAbs);
6175
+ let body = `<!-- leerness:inherited-from-parent (1.30.3) — 사용자 명시 \`parent adopt --apply\`. 부모 자산을 '참조'로만 기록(자식 원본 무변경). -->\n`;
6176
+ body += `# ${t('부모 프로젝트 자산 (참조용)', 'Parent project assets (reference)')}\n\n`;
6177
+ body += `- ${t('부모', 'parent')}: ${p.parentRoot}\n- adopt: ${today()}\n- ${t('선택', 'select')}: ${kinds.join(', ')}\n\n`;
6178
+ for (const c of cand) { const content = exists(c.src) ? read(c.src) : ''; body += `## ${c.kind} (from ${c.src})\n\n${String(content).trim()}\n\n`; }
6179
+ writeUtf8(inheritedPath, body);
6180
+ writeUtf8(linkPath, JSON.stringify({ parentRoot: p.parentRoot, workspaceDir: p.workspaceDir, adoptedKinds: kinds, adoptedAt: today(), version: VERSION }, null, 2) + '\n');
6181
+ if (has('--json')) { log(JSON.stringify({ version: VERSION, root, parent: p.parentRoot, selected: kinds, candidates: cand.map(c => c.kind), apply: true, applied: true, inheritedPath }, null, 2)); }
6182
+ else {
6183
+ log(cyan(`# leerness parent adopt (1.30.3)`));
6184
+ log(grn(t(` ✓ adopt 완료 (${cand.length} 자산) — 자식 design-system.md 무변경(참조로만 기록).`, ` ✓ adopted (${cand.length} assets) — your design-system.md unchanged (recorded as reference only).`)));
6185
+ log(dim(` ${t('참조', 'reference')}: ${inheritedPath}`));
6186
+ log(dim(` ${t('마커', 'marker')}: ${linkPath}`));
6187
+ }
6188
+ } catch (e) {
6189
+ if (!has('--json')) log(red(t(` ✗ adopt 실패: ${e.message}`, ` ✗ adopt failed: ${e.message}`)));
6190
+ process.exitCode = 1;
6191
+ }
6192
+ return;
6193
+ }
6194
+ console.error(t(`Usage: leerness parent [detect|adopt] [--select <kinds>] [--apply] [--json]`, `Usage: leerness parent [detect|adopt] [--select <kinds>] [--apply] [--json]`));
6195
+ process.exit(1);
6196
+ }
6197
+
6044
6198
  // 1.9.211: leerness migrate-workspace-dir — .harness → .leerness 마이그레이션 (사용자 명시)
6045
6199
  function migrateWorkspaceDirCmd(root) {
6046
6200
  root = absRoot(root);
@@ -7555,12 +7709,19 @@ function lessonSave(root, text) {
7555
7709
  if (!text) return fail('lesson text required. 예: leerness lesson save "JWT는 refresh token도 짧게 (15분 권장)"');
7556
7710
  const tag = arg('--tag', '');
7557
7711
  // 1.9.406 (8번째 버그헌트, UR-0110): RMW 락 직렬화 — 동시 lesson save lost-update 방지(UR-0043 패턴).
7712
+ // 1.30.4 (14th리뷰 F5): task/rule add 와 일관된 dedup — 동일 text 존재 시 skip(--force 우회). 종전엔 무조건 append(중복 누적).
7713
+ let _skipped = false;
7558
7714
  _withLock(lessonsJsonPath(root), () => {
7559
7715
  const all = _loadLessons(root);
7716
+ if (!has('--force') && all.some(l => l && l.text === text)) { _skipped = true; return; }
7560
7717
  all.push({ date: today(), text, tag: tag || null });
7561
7718
  _saveLessons(root, all);
7562
7719
  });
7563
7720
  // 1.9.413 (6th외부평가 codex P2, UR-0101): --json 구조화 출력(데이터 이미 영속).
7721
+ if (_skipped) {
7722
+ if (has('--json')) { log(JSON.stringify({ ok: true, skipped: true, text, tag: tag || null })); return; }
7723
+ ok(`lesson exists (skip): ${text.slice(0, 40)} (--force 로 추가)`); return;
7724
+ }
7564
7725
  if (has('--json')) { log(JSON.stringify({ ok: true, text, tag: tag || null })); return; }
7565
7726
  ok(`lesson saved`);
7566
7727
  _autoRoadmap(absRoot(root), 'data-change');
@@ -7643,8 +7804,11 @@ function decisionAdd(root, title) {
7643
7804
  const impact = arg('--impact', '');
7644
7805
  // 1.9.339 (UR-0053): canonical JSON write path — 기존 항목(JSON 우선, 없으면 MD backfill) 로드 후 추가, JSON+MD projection 동시 저장.
7645
7806
  // 1.9.406 (8번째 버그헌트, UR-0110): RMW 락 직렬화 — 동시 decision add lost-update 방지(UR-0043 패턴).
7807
+ // 1.30.4 (14th리뷰 F5): task/rule add 와 일관된 dedup — 동일 title 존재 시 skip(--force 우회). 종전엔 무조건 append(중복 누적).
7808
+ let _skipped = false;
7646
7809
  _withLock(decisionsJsonPath(root), () => {
7647
7810
  const all = _loadDecisions(root);
7811
+ if (!has('--force') && all.some(d => d && (d.title === title || d.decision === title))) { _skipped = true; return; }
7648
7812
  all.push({
7649
7813
  date: today(), title,
7650
7814
  decision: title,
@@ -7655,6 +7819,10 @@ function decisionAdd(root, title) {
7655
7819
  _saveDecisions(root, all);
7656
7820
  });
7657
7821
  // 1.9.413 (6th외부평가 codex P2, UR-0101): --json 구조화 출력(데이터 이미 영속).
7822
+ if (_skipped) {
7823
+ if (has('--json')) { log(JSON.stringify({ ok: true, skipped: true, title })); return; }
7824
+ ok(`decision exists (skip): ${title} (--force 로 추가)`); return;
7825
+ }
7658
7826
  if (has('--json')) { log(JSON.stringify({ ok: true, title })); return; }
7659
7827
  ok(`decision added: ${title}`);
7660
7828
  // 1.9.43+ handoff lessons 회수 흐름과 자동 통합 (decisions.md fuzzy 매칭됨)
@@ -7872,7 +8040,7 @@ function debug(root) {
7872
8040
 
7873
8041
  const _audit = require('../lib/audit');
7874
8042
  // 1.9.421 (UR-0025/UR-0125 큰 핸들러 모듈화 6번째): audit → lib/audit.js (DI 위임, thin wrapper)
7875
- function audit(root, opts = {}) { return _audit.audit(root, opts, { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills }); }
8043
+ function audit(root, opts = {}) { return _audit.audit(root, opts, { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills, _collectSecretFindings }); }
7876
8044
 
7877
8045
  // 1.9.312 (UR-0050, 설치리뷰 3중수렴): secret 스캐너 현대 키 패턴 보강.
7878
8046
  // 배경: 기존 OpenAI 패턴 `sk-[A-Za-z0-9]{32,}` 은 하이픈에서 끊겨 sk-proj-/sk-svcacct- (modern 프로젝트/서비스 키)를 놓침.
@@ -8488,6 +8656,16 @@ function handoff(root) {
8488
8656
  const parts = [];
8489
8657
  // 1.20.3 (UR-0010 Phase 2): 헤드라인 항목 라벨 UI 언어 적용 (영어 opt-in, 한국어 기본). 블록 1회 해석.
8490
8658
  const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
8659
+ // 1.30.2 (#157): 상위 leerness 부모 프로젝트 탐지 → AI 가 세션 시작 즉시 인지(재사용 후보). read-only — 자동 적용 X(사용자 결정 게이트). 상세: leerness parent detect
8660
+ try {
8661
+ const _pw = _findParentWorkspace(root);
8662
+ if (_pw && _pw.assetCount > 0) {
8663
+ const _adopted = exists(path.join(_workspaceDirAbs(root), 'PARENT_LINK.json')); // 1.30.3: adopt 여부 반영
8664
+ parts.push(_adopted
8665
+ ? t(`🔗 부모 프로젝트 (${_pw.assetCount} 자산·adopted)`, `🔗 parent project (${_pw.assetCount} assets, adopted)`)
8666
+ : t(`🔗 부모 프로젝트 (${_pw.assetCount} 자산·미적용)`, `🔗 parent project (${_pw.assetCount} assets, not applied)`));
8667
+ }
8668
+ } catch {}
8491
8669
  // 1) drift level (가벼운 check)
8492
8670
  try {
8493
8671
  const r = cp.spawnSync(process.execPath, [__filename, 'drift', 'check', root, '--json'],
@@ -9348,24 +9526,25 @@ function handoff(root) {
9348
9526
  const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
9349
9527
  const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
9350
9528
  const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
9529
+ const _t = (ko, en) => (_uiLang(root) === 'en' ? en : ko); // 1.30.5 (#156 F3): 로컬 i18n (이 블록 9536 에 t 화살표 파라미터가 있어 _t 로 명명)
9351
9530
  if (gapInfo.hasLast) {
9352
9531
  // 마지막 handoff 시점 알고 있음 — 정확한 측정 (1.9.199)
9353
9532
  if (gapInfo.isLong) {
9354
9533
  // 60분+ → 강한 알림
9355
- log(red(`## ⏰ ScheduleWakeup miss 강한 의심 (1.9.199) — 이전 handoff ${gapInfo.gapMin}분 전`));
9356
- log(dim(` R-0001 영구 룰 (25분) 대비 ${Math.floor(gapInfo.gapMin/25)}× 초과 — 시스템 sleep / wakeup 누락 확실`));
9357
- log(dim(` → 회복: 사용자가 "다음 라운드" 입력 또는 leerness rule list 로 룰 확인`));
9358
- log(dim(` → handoff 이력: ${(gapInfo.history || []).slice(-3).map(t => t.slice(11, 19)).join(' → ')}`));
9534
+ log(red(_t(`## ⏰ ScheduleWakeup miss 강한 의심 (1.9.199) — 이전 handoff ${gapInfo.gapMin}분 전`, `## ⏰ ScheduleWakeup miss strongly suspected — last handoff ${gapInfo.gapMin} min ago`)));
9535
+ log(dim(_t(` R-0001 영구 룰 (25분) 대비 ${Math.floor(gapInfo.gapMin/25)}× 초과 — 시스템 sleep / wakeup 누락 확실`, ` ${Math.floor(gapInfo.gapMin/25)}× over the R-0001 rule (25 min) — system sleep / missed wakeup`)));
9536
+ log(dim(_t(` → 회복: 사용자가 "다음 라운드" 입력 또는 leerness rule list 로 룰 확인`, ` → recover: user types "next round" or check rules via leerness rule list`)));
9537
+ log(dim(_t(` → handoff 이력: ${(gapInfo.history || []).slice(-3).map(t => t.slice(11, 19)).join(' → ')}`, ` → handoff history: ${(gapInfo.history || []).slice(-3).map(t => t.slice(11, 19)).join(' → ')}`)));
9359
9538
  log('');
9360
9539
  } else if (gapInfo.isMiss) {
9361
9540
  // 35~60분 → 의심
9362
- log(yel(`## ⏰ ScheduleWakeup 지연 (1.9.199) — 이전 handoff ${gapInfo.gapMin}분 전 (룰: 25분)`));
9363
- log(dim(` ±10분 buffer 초과 — wakeup 한 cycle 누락 가능성`));
9541
+ log(yel(_t(`## ⏰ ScheduleWakeup 지연 (1.9.199) — 이전 handoff ${gapInfo.gapMin}분 전 (룰: 25분)`, `## ⏰ ScheduleWakeup delayed — last handoff ${gapInfo.gapMin} min ago (rule: 25 min)`)));
9542
+ log(dim(_t(` ±10분 buffer 초과 — wakeup 한 cycle 누락 가능성`, ` beyond the ±10 min buffer — a wakeup cycle may have been missed`)));
9364
9543
  log('');
9365
9544
  } else if (gapInfo.gapMin >= 0 && gapInfo.gapMin <= 30) {
9366
9545
  // 정상 범위 (handoff_history.length >= 2 일 때만 의미 있음 — 첫 진입 제외)
9367
9546
  if ((gapInfo.history || []).length >= 2) {
9368
- log(dim(` ✓ ScheduleWakeup cycle 정상 (gap ${gapInfo.gapMin}분, 룰 25분 — 1.9.199)`));
9547
+ log(dim(_t(` ✓ ScheduleWakeup cycle 정상 (gap ${gapInfo.gapMin}분, 룰 25분 — 1.9.199)`, ` ✓ ScheduleWakeup cycle healthy (gap ${gapInfo.gapMin} min, rule 25 min)`)));
9369
9548
  }
9370
9549
  }
9371
9550
  } else {
@@ -9375,9 +9554,9 @@ function handoff(root) {
9375
9554
  const ageMs = Date.now() - fs.statSync(tlp).mtimeMs;
9376
9555
  const ageMin = Math.floor(ageMs / 60000);
9377
9556
  if (ageMin >= 60) {
9378
- const label = ageMin < 120 ? `${ageMin}분` : (ageMin < 1440 ? `${Math.floor(ageMin/60)}시간` : `${Math.floor(ageMin/1440)}일`);
9379
- log(yel(`## ⏰ ScheduleWakeup miss 의심 (1.9.196 fallback) — task-log 마지막 ${label} 전`));
9380
- log(dim(` 1.9.199 last-handoff.json 첫 기록 — 다음 handoff 부터 정확 측정`));
9557
+ const label = ageMin < 120 ? _t(`${ageMin}분`, `${ageMin}min`) : (ageMin < 1440 ? _t(`${Math.floor(ageMin/60)}시간`, `${Math.floor(ageMin/60)}h`) : _t(`${Math.floor(ageMin/1440)}일`, `${Math.floor(ageMin/1440)}d`));
9558
+ log(yel(_t(`## ⏰ ScheduleWakeup miss 의심 (1.9.196 fallback) — task-log 마지막 ${label} 전`, `## ⏰ ScheduleWakeup miss suspected (fallback) — task-log last modified ${label} ago`)));
9559
+ log(dim(_t(` 1.9.199 last-handoff.json 첫 기록 — 다음 handoff 부터 정확 측정`, ` first last-handoff.json record — precise measurement from the next handoff`)));
9381
9560
  log('');
9382
9561
  }
9383
9562
  }
@@ -9392,6 +9571,7 @@ function handoff(root) {
9392
9571
  const isTtyMd = process.stdout && process.stdout.isTTY;
9393
9572
  const mdCy = s => isTtyMd ? `\x1b[36m${s}\x1b[0m` : s;
9394
9573
  const mdDim = s => isTtyMd ? `\x1b[2m${s}\x1b[0m` : s;
9574
+ const t = (ko, en) => (_uiLang(root) === 'en' ? en : ko); // 1.30.5 (#156 F3): 로컬 t()
9395
9575
  const cutoff = Date.now() - 24 * 60 * 60 * 1000;
9396
9576
  const deltas = [];
9397
9577
  // tasks: progress-tracker.md row Updated 컬럼 기반 (간단 휴리스틱 — mtime이 24h내면 표시)
@@ -9432,7 +9612,7 @@ function handoff(root) {
9432
9612
  const pp = planPath(root);
9433
9613
  if (exists(pp) && fs.statSync(pp).mtimeMs > cutoff) {
9434
9614
  // M-XXXX 중 line이 24h내 추가됐는지 정확히는 어려움 — mtime 24h내면 "plan: 변경됨"으로 표시
9435
- deltas.push('plan: 변경됨');
9615
+ deltas.push(t('plan: 변경됨', 'plan: changed'));
9436
9616
  }
9437
9617
  } catch {}
9438
9618
  // rules: rule add 후 mtime 24h내
@@ -9444,8 +9624,8 @@ function handoff(root) {
9444
9624
  }
9445
9625
  } catch {}
9446
9626
  if (deltas.length) {
9447
- log(mdCy(`🆕 최근 24h 메모리 변동 (1.9.121): ${deltas.join(' · ')}`));
9448
- log(mdDim(` → 상세: leerness memory status --json`));
9627
+ log(mdCy(t(`🆕 최근 24h 메모리 변동 (1.9.121): ${deltas.join(' · ')}`, `🆕 memory changes in last 24h: ${deltas.join(' · ')}`)));
9628
+ log(mdDim(t(` → 상세: leerness memory status --json`, ` → details: leerness memory status --json`)));
9449
9629
  log('');
9450
9630
  }
9451
9631
  } catch {}
@@ -9573,8 +9753,13 @@ function handoff(root) {
9573
9753
  const _Lsec = _uiLang(root);
9574
9754
  const t = (ko, en) => (_Lsec === 'en' ? en : ko);
9575
9755
  const envExists = exists(path.join(root, '.env'));
9756
+ const issues = [];
9757
+ // 0) 1.30.1 (14th 외부리뷰 F2): 커밋된 plaintext 시크릿을 보안 요약에 노출 — headline '🚨 시크릿 N건' 과 일관.
9758
+ // envExists 무관(소스에 커밋된 시크릿은 .env 없어도 위험). gitignored 는 _collectSecretFindings 가 committed 에서 제외.
9759
+ let committedSecrets = [];
9760
+ try { committedSecrets = _collectSecretFindings(root).committed || []; } catch {}
9761
+ if (committedSecrets.length) issues.push(t(`커밋된 시크릿 ${committedSecrets.length}건 (소스 노출)`, `${committedSecrets.length} committed secret(s) (exposed in source)`));
9576
9762
  if (envExists) {
9577
- const issues = [];
9578
9763
  // 1) env diff
9579
9764
  try {
9580
9765
  const d = envDiff(root);
@@ -9589,41 +9774,43 @@ function handoff(root) {
9589
9774
  const missing = SECRET_PATTERNS.filter(p => !giLines.some(l => l === p || l === '/' + p));
9590
9775
  if (missing.length) issues.push(t(`.gitignore 시크릿 누락 ${missing.length}건`, `.gitignore missing secret patterns ${missing.length}`));
9591
9776
  } catch {}
9592
- if (issues.length) {
9593
- const isTty = process.stdout && process.stdout.isTTY;
9594
- const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
9595
- const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
9596
- const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
9597
- log('');
9598
- log(red(t(`## 🔒 보안 요약 (1.9.76) — ${issues.length}건 주의`, `## 🔒 Security summary — ${issues.length} to review`)));
9599
- for (const i of issues) log(dim(` ⚠ ${i}`));
9600
- log(dim(t(` → 자동 수정: leerness audit --fix · 상세: leerness env check / leerness audit`, ` auto-fix: leerness audit --fix · details: leerness env check / leerness audit`)));
9601
- // 1.9.80: critical 수준 (.gitignore에 .env 자체 누락) 자동 회복 옵션
9602
- const giText = exists(path.join(root, '.gitignore')) ? read(path.join(root, '.gitignore')) : '';
9603
- const giLines = giText.split('\n').map(l => l.trim());
9604
- const envInGitignore = giLines.includes('.env') || giLines.includes('/.env');
9605
- if (!envInGitignore) {
9606
- // .env 자체 누락 최우선 위험
9607
- log(yel(t(` 🚨 CRITICAL (1.9.80): .env가 .gitignore에 없습니다! 시크릿 노출 위험 — 즉시 \`leerness audit --fix\` 권장.`, ` 🚨 CRITICAL: .env is not in .gitignore! secret-exposure risk — run \`leerness audit --fix\` now.`)));
9608
- // LEERNESS_AUTO_SECURITY_FIX=1 자동 실행 옵션
9609
- if (process.env.LEERNESS_AUTO_SECURITY_FIX === '1') {
9610
- try {
9611
- const r = cp.spawnSync(process.execPath, [__filename, 'audit', root, '--fix'],
9612
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
9613
- if (r.status === 0) {
9614
- log(dim(t(` ✓ 자동 회복 (LEERNESS_AUTO_SECURITY_FIX=1): audit --fix 완료`, ` ✓ auto-recovered (LEERNESS_AUTO_SECURITY_FIX=1): audit --fix done`)));
9615
- } else {
9616
- log(dim(t(` ⚠ 자동 회복 실패 (exit ${r.status}) 수동 \`leerness audit --fix\` 권장`, ` ⚠ auto-recovery failed (exit ${r.status}) — run \`leerness audit --fix\` manually`)));
9617
- }
9618
- } catch (e) {
9619
- log(dim(t(` ⚠ 자동 회복 예외: ${e.message}`, ` ⚠ auto-recovery error: ${e.message}`)));
9777
+ }
9778
+ if (issues.length) {
9779
+ const isTty = process.stdout && process.stdout.isTTY;
9780
+ const red = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
9781
+ const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
9782
+ const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
9783
+ log('');
9784
+ log(red(t(`## 🔒 보안 요약 (1.9.76) ${issues.length}건 주의`, `## 🔒 Security summary — ${issues.length} to review`)));
9785
+ for (const i of issues) log(dim(` ${i}`));
9786
+ // 1.30.1 (F2): 커밋된 시크릿 파일 위치 노출 ( snippet 미출력 handoff 로그로의 시크릿 유출 방지)
9787
+ for (const f of committedSecrets.slice(0, 4)) log(dim(` • ${f.file}:${f.line} (${f.name})`));
9788
+ log(dim(t(` → 자동 수정: leerness audit --fix · 상세: leerness scan secrets / leerness env check`, ` → auto-fix: leerness audit --fix · details: leerness scan secrets / leerness env check`)));
9789
+ // 1.9.80: critical 수준 (.gitignore에 .env 자체 누락) 자동 회복 옵션
9790
+ const giText2 = exists(path.join(root, '.gitignore')) ? read(path.join(root, '.gitignore')) : '';
9791
+ const giLines2 = giText2.split('\n').map(l => l.trim());
9792
+ const envInGitignore = giLines2.includes('.env') || giLines2.includes('/.env');
9793
+ if (envExists && !envInGitignore) {
9794
+ // .env 자체 누락 → 최우선 위험
9795
+ log(yel(t(` 🚨 CRITICAL (1.9.80): .env가 .gitignore에 없습니다! 시크릿 노출 위험 — 즉시 \`leerness audit --fix\` 권장.`, ` 🚨 CRITICAL: .env is not in .gitignore! secret-exposure risk — run \`leerness audit --fix\` now.`)));
9796
+ // LEERNESS_AUTO_SECURITY_FIX=1 자동 실행 옵션
9797
+ if (process.env.LEERNESS_AUTO_SECURITY_FIX === '1') {
9798
+ try {
9799
+ const r = cp.spawnSync(process.execPath, [__filename, 'audit', root, '--fix'],
9800
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
9801
+ if (r.status === 0) {
9802
+ log(dim(t(` ✓ 자동 회복 (LEERNESS_AUTO_SECURITY_FIX=1): audit --fix 완료`, ` ✓ auto-recovered (LEERNESS_AUTO_SECURITY_FIX=1): audit --fix done`)));
9803
+ } else {
9804
+ log(dim(t(` ⚠ 자동 회복 실패 (exit ${r.status}) — 수동 \`leerness audit --fix\` 권장`, ` ⚠ auto-recovery failed (exit ${r.status}) — run \`leerness audit --fix\` manually`)));
9620
9805
  }
9621
- } else {
9622
- log(dim(t(` 💡 자동 실행 옵션: LEERNESS_AUTO_SECURITY_FIX=1 leerness handoff .`, ` 💡 auto-fix option: LEERNESS_AUTO_SECURITY_FIX=1 leerness handoff .`)));
9806
+ } catch (e) {
9807
+ log(dim(t(` 자동 회복 예외: ${e.message}`, ` auto-recovery error: ${e.message}`)));
9623
9808
  }
9809
+ } else {
9810
+ log(dim(t(` 💡 자동 실행 옵션: LEERNESS_AUTO_SECURITY_FIX=1 leerness handoff .`, ` 💡 auto-fix option: LEERNESS_AUTO_SECURITY_FIX=1 leerness handoff .`)));
9624
9811
  }
9625
- log('');
9626
9812
  }
9813
+ log('');
9627
9814
  }
9628
9815
  } catch {}
9629
9816
  }
@@ -9656,16 +9843,17 @@ function handoff(root) {
9656
9843
  const cy = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
9657
9844
  const b = s => isTty ? `\x1b[1m${s}\x1b[0m` : s;
9658
9845
  const d = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
9846
+ const t = (ko, en) => (_uiLang(root) === 'en' ? en : ko); // 1.30.5 (#156 F3): headline t() 스코프 밖 — 로컬 t() (1.29.1 교훈)
9659
9847
  log('');
9660
- log(cy('## 🛠 세션 워크플로 6단계 (1.9.39+, AI 하네스 엔지니어링)'));
9661
- log(d(' 상세: ') + cy('.harness/session-workflow.md'));
9662
- log(` 1. ${b('요청 분석')} handoff(이미 완료) · drift check · 모호하면 명확화`);
9663
- log(` 2. ${b('계획 수립')} plan add / TodoWrite · reuse-map으로 기존 자원 우선`);
9664
- log(` 3. ${b('업무 분배')} agents list/recommend · 작업유형별 sub-agent 매핑`);
9665
- log(` 4. ${b('sub-agent 작업')} 파일 경로 격리 · mtime 검증 의무 · 자체 테스트`);
9666
- log(` 5. ${b('종합 검증')} contract verify · verify-claim --run-tests · review --persona`);
9667
- log(` 6. ${b('세션 마감')} session close · audit --fix · usage stats`);
9668
- log(d(' 끄려면: --no-workflow-guide 또는 LEERNESS_NO_WORKFLOW_GUIDE=1'));
9848
+ log(cy(t('## 🛠 세션 워크플로 6단계 (1.9.39+, AI 하네스 엔지니어링)', '## 🛠 Session workflow — 6 steps (AI harness engineering)')));
9849
+ log(d(t(' 상세: ', ' details: ')) + cy('.harness/session-workflow.md'));
9850
+ log(` 1. ${b(t('요청 분석', 'Analyze request'))} ${t('handoff(이미 완료) · drift check · 모호하면 명확화', 'handoff (done) · drift check · clarify if ambiguous')}`);
9851
+ log(` 2. ${b(t('계획 수립', 'Plan'))} ${t('plan add / TodoWrite · reuse-map으로 기존 자원 우선', 'plan add / TodoWrite · prefer existing via reuse-map')}`);
9852
+ log(` 3. ${b(t('업무 분배', 'Distribute'))} ${t('agents list/recommend · 작업유형별 sub-agent 매핑', 'agents list/recommend · map sub-agents by task type')}`);
9853
+ log(` 4. ${b(t('sub-agent 작업', 'sub-agent work'))} ${t('파일 경로 격리 · mtime 검증 의무 · 자체 테스트', 'isolate file paths · verify mtime · self-test')}`);
9854
+ log(` 5. ${b(t('종합 검증', 'Verify'))} ${t('contract verify · verify-claim --run-tests · review --persona', 'contract verify · verify-claim --run-tests · review --persona')}`);
9855
+ log(` 6. ${b(t('세션 마감', 'Close'))} ${t('session close · audit --fix · usage stats', 'session close · audit --fix · usage stats')}`);
9856
+ log(d(t(' 끄려면: --no-workflow-guide 또는 LEERNESS_NO_WORKFLOW_GUIDE=1', ' to disable: --no-workflow-guide or LEERNESS_NO_WORKFLOW_GUIDE=1')));
9669
9857
  log('');
9670
9858
  }
9671
9859
  // 1.9.373 (UR-0073 Phase C): 에이전트 팀 스케줄 알림 — 비-manual 팀이 정의돼 있으면 미리보기(dry-run) 안내. 실행 없음. opt-out.
@@ -9673,10 +9861,11 @@ function handoff(root) {
9673
9861
  try {
9674
9862
  const _teamRem = _teamHandoffReminders(_loadTeams(root));
9675
9863
  if (_teamRem.length) {
9864
+ const t = (ko, en) => (_uiLang(root) === 'en' ? en : ko); // 1.30.5 (#156 F3): 로컬 t()
9676
9865
  log('');
9677
- log('## 🤝 에이전트 팀 스케줄 (UR-0073 Phase C) — 정의 전용 · 자동 실행 X');
9866
+ log(t('## 🤝 에이전트 팀 스케줄 (UR-0073 Phase C) — 정의 전용 · 자동 실행 X', '## 🤝 Agent team schedule (UR-0073) — definitions only · no auto-run'));
9678
9867
  _teamRem.forEach(r => log(' ' + r));
9679
- log(' ⓘ 미리보기는 dry-run. 실행은 제안 명령 검토 후 직접 · 끄려면 --no-team-reminders 또는 LEERNESS_NO_TEAM_REMINDERS=1');
9868
+ log(t(' ⓘ 미리보기는 dry-run. 실행은 제안 명령 검토 후 직접 · 끄려면 --no-team-reminders 또는 LEERNESS_NO_TEAM_REMINDERS=1', ' ⓘ preview is dry-run. run it yourself after reviewing the suggested command · disable: --no-team-reminders or LEERNESS_NO_TEAM_REMINDERS=1'));
9680
9869
  }
9681
9870
  } catch {}
9682
9871
  }
@@ -10180,7 +10369,7 @@ function _vcImplIsEmpty(body) {
10180
10369
  function verifyClaimCmd(root, taskId) {
10181
10370
  root = absRoot(root);
10182
10371
  const _j = has('--json'); // 1.9.400 (7번째 버그헌트 P1-B, UR-0105): --json 에러도 구조화
10183
- if (!taskId) return failJson(_j, 'missing_args', 'verify-claim <T-ID> 필요. 예: leerness verify-claim T-0008');
10372
+ if (!taskId) return failJson(_j, 'missing_args', _uiLang(root) === 'en' ? 'verify-claim <T-ID> required. ex: leerness verify-claim T-0008' : 'verify-claim <T-ID> 필요. 예: leerness verify-claim T-0008'); // 1.30.5 (#156 F4)
10184
10373
  const rows = readProgressRows(root);
10185
10374
  const row = rows.find(r => r.id === taskId);
10186
10375
  if (!row) return failJson(_j, 'not_found', `progress-tracker.md에 ${taskId} 없음.`);
@@ -11064,7 +11253,7 @@ function honestyCheckCmd(root, arg1) {
11064
11253
  function optimismCheckCmd(root, taskId) {
11065
11254
  root = absRoot(root || process.cwd());
11066
11255
  const _j = has('--json'); // 1.9.400 (7번째 버그헌트 P1-B, UR-0105): --json 에러도 구조화
11067
- if (!taskId) return failJson(_j, 'missing_args', 'optimism-check <T-ID> 필요. 예: leerness optimism-check T-0001');
11256
+ if (!taskId) return failJson(_j, 'missing_args', _uiLang(root) === 'en' ? 'optimism-check <T-ID> required. ex: leerness optimism-check T-0001' : 'optimism-check <T-ID> 필요. 예: leerness optimism-check T-0001'); // 1.30.5 (#156 F4 twin)
11068
11257
  const rows = readProgressRows(root);
11069
11258
  const row = rows.find(r => r.id === taskId);
11070
11259
  if (!row) return failJson(_j, 'not_found', `progress-tracker.md에 ${taskId} 없음.`);
@@ -20204,13 +20393,19 @@ async function main() {
20204
20393
  if (cmd === 'wakeup-interval') return wakeupIntervalCmd(arg('--path', process.cwd()), args[1], args[2]);
20205
20394
  // 1.9.211: leerness workspace-dir <get|guide> — 현재 워크스페이스 디렉토리 / AI 참조 가이드 (사용자 명시)
20206
20395
  if (cmd === 'workspace-dir') return workspaceDirCmd(arg('--path', process.cwd()), args[1]);
20396
+ if (cmd === 'parent') return parentCmd(arg('--path', process.cwd()), args[1]);
20207
20397
  // 1.9.211: leerness migrate-workspace-dir — .harness → .leerness 마이그레이션 (사용자 명시)
20208
20398
  if (cmd === 'migrate-workspace-dir') return migrateWorkspaceDirCmd(arg('--path', process.cwd()));
20209
20399
  // 1.9.212: leerness idempotency audit — 멱등성 위반 탐지 (사용자 명시)
20210
20400
  if (cmd === 'idempotency') return idempotencyCmd(arg('--path', process.cwd()), args[1]);
20211
20401
  // 1.9.213: leerness intent <classify|expand|domains> — intent inference + scope expansion (사용자 명시)
20212
20402
  if (cmd === 'intent') return intentCmd(arg('--path', process.cwd()), args[1], ...args.slice(2));
20213
- if (cmd === 'rule' && args[1] === 'add') return ruleAdd(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd(), _parseAddTitle(args, 2)); // 1.9.426: flag/경로 break(_parseAddTitle) · 1.9.445 (UR-0151): positional path 지원(제목과 분리)
20403
+ if (cmd === 'rule' && args[1] === 'add') { // 1.9.426: flag/경로 break(_parseAddTitle) · 1.9.445 (UR-0151): positional path 지원(제목과 분리)
20404
+ const _desc = _parseAddTitle(args, 2);
20405
+ // 1.30.4 (14th리뷰 F6): 빈 입력 시 --json 에서도 구조화 JSON(task/decision add 와 일관). 종전엔 ruleAdd 내부 fail() 가 평문 출력.
20406
+ if (!_desc) { failJson(has('--json'), 'empty_title', 'rule add "<설명>" 필요 (빈/경로-only 거부)'); return process.exit(process.exitCode || 1); }
20407
+ return ruleAdd(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd(), _desc);
20408
+ }
20214
20409
  if (cmd === 'rule' && args[1] === 'list') return ruleList(arg('--path', process.cwd()));
20215
20410
  if (cmd === 'rule' && args[1] === 'remove') return ruleRemove(arg('--path', process.cwd()), args[2]);
20216
20411
  if (cmd === 'rule' && args[1] === 'pause') return rulePause(arg('--path', process.cwd()), args[2]);
@@ -20218,6 +20413,8 @@ async function main() {
20218
20413
  if (cmd === 'rule' && args[1] === 'stop') return ruleStop(arg('--path', process.cwd()));
20219
20414
  if (cmd === 'rule' && args[1] === 'resume-all') return ruleResumeAll(arg('--path', process.cwd()));
20220
20415
  if (cmd === 'rule' && args[1] === 'verify') return ruleVerifyCmd(arg('--path', process.cwd()));
20416
+ // 1.30.4 (14th리뷰 F7): rule 하위명령 미매칭 시 잘못된 토큰 명시 + usage(종전엔 top-level 'unknown_command: rule' 로 유효 부모명을 오인 표기).
20417
+ if (cmd === 'rule') { failJson(has('--json'), 'unknown_subcommand', `알 수 없는 rule 하위명령: ${args[1] || '(없음)'} — leerness rule add|list|remove|pause|resume|stop|resume-all|verify`); return process.exit(process.exitCode || 1); }
20221
20418
  if (cmd === 'release' && args[1] === 'bump') return releaseBump(args[2] || arg('--path', process.cwd()));
20222
20419
  if (cmd === 'release' && args[1] === 'note') return releaseNote(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
20223
20420
  if (cmd === 'release' && args[1] === 'publish') return releasePublish(args[2] || arg('--path', process.cwd()));
@@ -20272,6 +20469,8 @@ async function main() {
20272
20469
  if (sub==='relink') return taskRelink(root);
20273
20470
  if (sub==='sync') return taskSyncCmd(root);
20274
20471
  if (sub==='export') return taskExportCmd(root);
20472
+ // 1.30.4 (14th리뷰 F7): 미매칭 하위명령 시 잘못된 토큰을 명시 + usage(종전엔 top-level 'unknown_command: task' 로 유효 부모명을 오인 표기).
20473
+ failJson(has('--json'), 'unknown_subcommand', `알 수 없는 task 하위명령: ${sub} — leerness task list|add|update|drop|fix-evidence|relink|sync|export`); return process.exit(process.exitCode || 1);
20275
20474
  }
20276
20475
  // 1.9.114: memory status — Memory Write Surface 5종 통합 상태
20277
20476
  if (cmd === 'memory' && args[1] === 'status') {
@@ -20299,7 +20498,10 @@ async function main() {
20299
20498
  if (args[i].startsWith('--') || /^([A-Za-z]:[\\/]|\/|\.\.?[\\/])/.test(args[i])) break;
20300
20499
  textParts.push(args[i]);
20301
20500
  }
20302
- return lessonSave(root, textParts.join(' '));
20501
+ const _text = textParts.join(' ');
20502
+ // 1.30.4 (14th리뷰 F6): 빈 입력 시 --json 에서도 구조화 JSON(task/decision add 와 일관). 종전엔 lessonSave 내부 fail() 가 평문 출력.
20503
+ if (!_text) { failJson(has('--json'), 'empty_text', 'lesson save "<text>" 필요 (빈/경로-only 거부)'); return process.exit(process.exitCode || 1); }
20504
+ return lessonSave(root, _text);
20303
20505
  }
20304
20506
  if (sub === 'list') {
20305
20507
  return lessonListCmd(root, { json: has('--json') });
package/lib/audit.js CHANGED
@@ -8,7 +8,7 @@ const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBu
8
8
  const { SECRET_PATTERNS } = require('./catalogs');
9
9
 
10
10
  function audit(root, opts = {}, deps = {}) {
11
- const { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills } = deps;
11
+ const { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills, _collectSecretFindings } = deps;
12
12
  root = absRoot(root);
13
13
  let warnings = 0, failures = 0;
14
14
  // 1.9.35 개선 #5: --fix 옵션 — 자동 수정 가능한 항목 적용
@@ -192,6 +192,22 @@ function audit(root, opts = {}, deps = {}) {
192
192
  }
193
193
  } catch {}
194
194
  }
195
+ // 1.30.1 (14th 외부리뷰 F1): 커밋된 시크릿(_collectSecretFindings.committed)을 failure 로 승격 — scan secrets 와 일관.
196
+ // 기존엔 .gitignore 패턴/.env 동기화만 검사해 소스에 노출된 실 시크릿(AWS/GitHub 등)을 통과시키고 healthy:true 를 반환하던 정직성 갭
197
+ // (audit 기반 CI 게이트가 노출 시크릿을 통과). gitignored 보관 시크릿은 _collectSecretFindings 가 committed 에서 제외(FP 0). 끄기: --no-secret-scan.
198
+ if (!has('--no-secret-scan') && typeof _collectSecretFindings === 'function') {
199
+ try {
200
+ const { committed } = _collectSecretFindings(root);
201
+ if (committed && committed.length) {
202
+ failures++;
203
+ fail(`커밋된 시크릿 ${committed.length}건 발견 (소스 노출) — leerness scan secrets 로 상세 확인`);
204
+ committed.slice(0, 4).forEach(f => log(` ${f.file}:${f.line} ${f.name}`));
205
+ _finding('committed_secret', 'fail', '커밋된 시크릿 발견 (소스 노출)', { count: committed.length, sample: committed.slice(0, 10).map(f => ({ file: f.file, line: f.line, name: f.name })) });
206
+ } else {
207
+ ok('커밋된 시크릿 없음 (소스 스캔, 1.30.1)');
208
+ }
209
+ } catch {}
210
+ }
195
211
  // 1.9.71: .env / .env.example 동기화 감사 (--no-env-check로 끄기)
196
212
  if (!has('--no-env-check')) {
197
213
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.30.0",
3
+ "version": "1.31.0",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -830,13 +830,14 @@ total++;
830
830
  {
831
831
  // agents dispatch — 활성 미충족 시 거부
832
832
  const env = { ...process.env, LEERNESS_ENABLE_CODEX: '0' };
833
- const r = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', 'test task', '--to', 'codex'], { encoding: 'utf8', timeout: 10000, env });
833
+ // 1.30.2: timeout 10s→30s flake 하드닝(1.9.375 계열) 전체 e2e 부하(수백 spawn) 하에서 짧은 타임아웃이 간헐 빈-stdout→오판.
834
+ const r = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', 'test task', '--to', 'codex'], { encoding: 'utf8', timeout: 30000, env });
834
835
  const okBlocked = r.status !== 0 && /비활성|disabled|not-installed/i.test(r.stdout);
835
836
  // --to 누락 거부
836
- const r2 = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', 'test'], { encoding: 'utf8', timeout: 10000 });
837
+ const r2 = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', 'test'], { encoding: 'utf8', timeout: 30000 });
837
838
  const okNoTarget = r2.status !== 0 && /--to.*필요/.test(r2.stdout + r2.stderr);
838
839
  // 알 수 없는 agent 거부
839
- const r3 = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', 'test', '--to', 'jedi'], { encoding: 'utf8', timeout: 10000 });
840
+ const r3 = cp.spawnSync(process.execPath, [CLI, 'agents', 'dispatch', 'test', '--to', 'jedi'], { encoding: 'utf8', timeout: 30000 });
840
841
  const okBadAgent = r3.status !== 0 && /알 수 없는 agent/.test(r3.stdout + r3.stderr);
841
842
  const ok = okBlocked && okNoTarget && okBadAgent;
842
843
  console.log(ok ? '✓ B(1.9.30) agents dispatch: env=0/--to 누락/잘못된 agent 모두 거부' : `✗ dispatch 실패 (block=${okBlocked} noT=${okNoTarget} bad=${okBadAgent})`);
@@ -882,7 +883,7 @@ total++;
882
883
  total++;
883
884
  {
884
885
  // --version --banner: LEERNESS ASCII + 신규 슬로건 (1.9.144+ "AI 에이전트 검수·기억·드리프트 방지 하네스")
885
- const r = cp.spawnSync(process.execPath, [CLI, '--version', '--banner'], { encoding: 'utf8', timeout: 10000, env: { ...process.env, TERM: 'dumb' } });
886
+ const r = cp.spawnSync(process.execPath, [CLI, '--version', '--banner'], { encoding: 'utf8', timeout: 30000, env: { ...process.env, TERM: 'dumb' } });
886
887
  const ok = r.status === 0
887
888
  && /╔═+╗/.test(r.stdout)
888
889
  && /███████╗/.test(r.stdout)
@@ -6041,6 +6042,152 @@ total++;
6041
6042
  if (!ok) failed++;
6042
6043
  }
6043
6044
 
6045
+ // 1.30.1 회귀 (14th 외부리뷰 F1+F2): audit/handoff 보안요약이 커밋된 시크릿을 정직하게 노출.
6046
+ // F1: audit 가 _collectSecretFindings 콘텐츠 스캔을 돌려 committed 시크릿을 failure 로 승격(scan secrets 와 일관) — gitignored 는 무영향(FP 0).
6047
+ // F2: handoff 🔒 보안요약 섹션이 .env 없어도 committed 시크릿을 노출(envExists 단독 게이팅 제거).
6048
+ total++;
6049
+ {
6050
+ let ok = false;
6051
+ try {
6052
+ const H = /[가-힣]/;
6053
+ // (F1) un-gitignored .env + 실 시크릿 → audit healthy:false exit1
6054
+ const d1 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-f1bad-'));
6055
+ cp.spawnSync(process.execPath, [CLI, 'init', d1, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6056
+ fs.writeFileSync(path.join(d1, '.gitignore'), 'node_modules/\n');
6057
+ fs.writeFileSync(path.join(d1, '.env'), 'AWS=AKIAJQXMP7RZ2KL9WXYZ\nGH=ghp_aZ9bY8cX7dW6eV5fU4gT3hS2iR1jQ0kP9oN8\n');
6058
+ const a1 = cp.spawnSync(process.execPath, [CLI, 'audit', d1, '--json'], { encoding: 'utf8', timeout: 20000 });
6059
+ let f1bad = false; try { const j = JSON.parse(a1.stdout); f1bad = j.healthy === false && a1.status === 1 && j.findings.some(x => x.kind === 'committed_secret'); } catch {}
6060
+ // (F1-noFP) gitignored .env + 시크릿 → audit healthy:true (no false-positive)
6061
+ const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-f1ok-'));
6062
+ cp.spawnSync(process.execPath, [CLI, 'init', d2, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6063
+ fs.writeFileSync(path.join(d2, '.gitignore'), '.env\n.env.local\n.env.production\n.env.*.local\n*.pem\ncredentials.json\nnode_modules/\n');
6064
+ fs.writeFileSync(path.join(d2, '.env'), 'AWS=AKIAJQXMP7RZ2KL9WXYZ\n');
6065
+ const a2 = cp.spawnSync(process.execPath, [CLI, 'audit', d2, '--json'], { encoding: 'utf8', timeout: 20000 });
6066
+ let f1ok = false; try { const j = JSON.parse(a2.stdout); f1ok = j.healthy === true && a2.status === 0; } catch {}
6067
+ // (F2) committed secret in config.js, NO .env → handoff 보안요약 섹션이 노출(ko) + en 영어(섹션 한글 0)
6068
+ const d3 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-f2-'));
6069
+ cp.spawnSync(process.execPath, [CLI, 'init', d3, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6070
+ fs.writeFileSync(path.join(d3, '.gitignore'), 'node_modules/\n');
6071
+ fs.writeFileSync(path.join(d3, 'config.js'), 'const k="AKIAJQXMP7RZ2KL9WXYZ";\nconst g="ghp_aZ9bY8cX7dW6eV5fU4gT3hS2iR1jQ0kP9oN8";\n');
6072
+ const hoKo = (cp.spawnSync(process.execPath, [CLI, 'handoff', d3], { encoding: 'utf8', timeout: 25000 }).stdout) || '';
6073
+ const f2ko = /🔒\s*보안 요약/.test(hoKo) && /커밋된 시크릿/.test(hoKo) && /config\.js/.test(hoKo);
6074
+ const d4 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-f2en-'));
6075
+ cp.spawnSync(process.execPath, [CLI, 'init', d4, '--yes', '--language', 'en'], { encoding: 'utf8', timeout: 30000 });
6076
+ fs.writeFileSync(path.join(d4, '.gitignore'), 'node_modules/\n');
6077
+ fs.writeFileSync(path.join(d4, 'config.js'), 'const k="AKIAJQXMP7RZ2KL9WXYZ";\n');
6078
+ const hoEn = (cp.spawnSync(process.execPath, [CLI, 'handoff', d4], { encoding: 'utf8', timeout: 25000 }).stdout) || '';
6079
+ const enSecLines = hoEn.split('\n').filter(l => /Security summary|committed secret/i.test(l));
6080
+ const f2en = /Security summary/.test(hoEn) && /committed secret/i.test(hoEn) && enSecLines.length >= 1 && !enSecLines.some(l => H.test(l));
6081
+ [d1, d2, d3, d4].forEach(d => { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} });
6082
+ ok = f1bad && f1ok && f2ko && f2en;
6083
+ } catch {}
6084
+ console.log(ok ? '✓ B(1.30.1) 14th외부리뷰 F1+F2: audit committed-secret→failure(scan 일관, gitignored FP0) + handoff 보안요약이 committed 시크릿 노출(ko/en)' : '✗ 보안 정직성 F1+F2 가드 실패');
6085
+ if (!ok) failed++;
6086
+ }
6087
+
6088
+ // 1.30.2 회귀 (#157 사용자명시, 하위 프로젝트 방향 — 외부AI+Claude 교차검토 → 방향 C): parent detect 가 상위 leerness 부모를 탐지(read-only) + handoff 헤드라인 노출 + 자동 적용 안 함.
6089
+ total++;
6090
+ {
6091
+ let ok = false;
6092
+ try {
6093
+ const par = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-par-'));
6094
+ cp.spawnSync(process.execPath, [CLI, 'init', par, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6095
+ const sub = path.join(par, 'sub');
6096
+ cp.spawnSync(process.execPath, [CLI, 'init', sub, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6097
+ // (1) parent detect --json from sub → parent detected, applied:false, assetCount≥1
6098
+ const pj = cp.spawnSync(process.execPath, [CLI, 'parent', 'detect', '--path', sub, '--json'], { encoding: 'utf8', timeout: 15000 });
6099
+ let detectOk = false; try { const j = JSON.parse(pj.stdout); detectOk = j.applied === false && j.parent && j.parent.workspaceDir === '.harness' && j.parent.assetCount >= 1; } catch {}
6100
+ // (2) parent detect from standalone → null
6101
+ const alone = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-alone-'));
6102
+ cp.spawnSync(process.execPath, [CLI, 'init', alone, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6103
+ const aj = cp.spawnSync(process.execPath, [CLI, 'parent', 'detect', '--path', alone, '--json'], { encoding: 'utf8', timeout: 15000 });
6104
+ let aloneOk = false; try { const j = JSON.parse(aj.stdout); aloneOk = j.parent === null; } catch {}
6105
+ // (3) handoff headline from sub shows 🔗 부모 프로젝트 (미적용); en shows "not applied"
6106
+ const hoKo = (cp.spawnSync(process.execPath, [CLI, 'handoff', '--path', sub], { encoding: 'utf8', timeout: 25000 }).stdout) || '';
6107
+ const hoEn = (cp.spawnSync(process.execPath, [CLI, 'handoff', '--path', sub, '--language', 'en'], { encoding: 'utf8', timeout: 25000 }).stdout) || '';
6108
+ const headlineOk = /🔗 부모 프로젝트.*미적용/.test(hoKo) && /🔗 parent project.*not applied/.test(hoEn);
6109
+ // (4) read-only: parent detect 가 sub 에 아무 파일도 쓰지 않음(adopt 미구현)
6110
+ const before = fs.readdirSync(sub).sort().join(',');
6111
+ cp.spawnSync(process.execPath, [CLI, 'parent', 'detect', '--path', sub], { encoding: 'utf8', timeout: 15000 });
6112
+ const after = fs.readdirSync(sub).sort().join(',');
6113
+ const readOnlyOk = before === after;
6114
+ [par, alone].forEach(d => { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} });
6115
+ ok = detectOk && aloneOk && headlineOk && readOnlyOk;
6116
+ } catch {}
6117
+ console.log(ok ? '✓ B(1.30.2) #157 하위프로젝트: parent detect(상위 leerness 탐지·--json applied:false) + 독립 null + handoff 헤드라인 🔗(ko/en, 미적용) + read-only' : '✗ parent detect 가드 실패');
6118
+ if (!ok) failed++;
6119
+ }
6120
+
6121
+ // 1.30.3 회귀 (#158 사용자명시): parent adopt 게이트형 적용 — dry-run 기본(쓰기 0) + --apply(사용자 명시) 시에만 자식-로컬 참조 기록 + 자식 design-system.md 무변경(비파괴) + handoff 헤드라인 adopted 반영.
6122
+ total++;
6123
+ {
6124
+ let ok = false;
6125
+ try {
6126
+ const par = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-adopt-'));
6127
+ cp.spawnSync(process.execPath, [CLI, 'init', par, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6128
+ const sub = path.join(par, 'sub');
6129
+ cp.spawnSync(process.execPath, [CLI, 'init', sub, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6130
+ const childDs = path.join(sub, '.harness', 'design-system.md');
6131
+ const childDsBefore = fs.readFileSync(childDs, 'utf8');
6132
+ const inherited = path.join(sub, '.harness', 'inherited-from-parent.md');
6133
+ const link = path.join(sub, '.harness', 'PARENT_LINK.json');
6134
+ // (1) DRY-RUN: 쓰기 0
6135
+ cp.spawnSync(process.execPath, [CLI, 'parent', 'adopt', '--path', sub], { encoding: 'utf8', timeout: 15000 });
6136
+ const dryNoWrite = !fs.existsSync(inherited) && !fs.existsSync(link);
6137
+ // (2) --apply: 참조파일+마커 기록, 자식 design-system.md 무변경
6138
+ cp.spawnSync(process.execPath, [CLI, 'parent', 'adopt', '--apply', '--path', sub], { encoding: 'utf8', timeout: 15000 });
6139
+ const wrote = fs.existsSync(inherited) && fs.existsSync(link);
6140
+ const childUnchanged = fs.readFileSync(childDs, 'utf8') === childDsBefore;
6141
+ let linkOk = false; try { const j = JSON.parse(fs.readFileSync(link, 'utf8')); linkOk = !!j.parentRoot && Array.isArray(j.adoptedKinds) && j.adoptedKinds.length >= 1; } catch {}
6142
+ // (3) handoff 헤드라인 adopted 반영(ko/en)
6143
+ const hoKo = (cp.spawnSync(process.execPath, [CLI, 'handoff', '--path', sub], { encoding: 'utf8', timeout: 25000 }).stdout) || '';
6144
+ const hoEn = (cp.spawnSync(process.execPath, [CLI, 'handoff', '--path', sub, '--language', 'en'], { encoding: 'utf8', timeout: 25000 }).stdout) || '';
6145
+ const headlineOk = /🔗 부모 프로젝트.*adopted/.test(hoKo) && /🔗 parent project.*adopted/.test(hoEn);
6146
+ // (4) --json applied:true on apply
6147
+ const aj = cp.spawnSync(process.execPath, [CLI, 'parent', 'adopt', '--apply', '--json', '--path', sub], { encoding: 'utf8', timeout: 15000 });
6148
+ let jsonOk = false; try { const j = JSON.parse(aj.stdout); jsonOk = j.applied === true && typeof j.inheritedPath === 'string'; } catch {}
6149
+ fs.rmSync(par, { recursive: true, force: true });
6150
+ ok = dryNoWrite && wrote && childUnchanged && linkOk && headlineOk && jsonOk;
6151
+ } catch {}
6152
+ console.log(ok ? '✓ B(1.30.3) #158 parent adopt: dry-run 쓰기0 + --apply 참조파일/마커 + 자식 design-system 무변경(비파괴) + handoff adopted(ko/en) + --json applied:true' : '✗ parent adopt 가드 실패');
6153
+ if (!ok) failed++;
6154
+ }
6155
+
6156
+ // 1.30.4 회귀 (#155 / 14th리뷰 F5+F6+F7): add류 cli-ux 일관성 — decision/lesson dedup + rule/lesson 빈입력 --json 구조화 + task/rule bogus subcommand 토큰 명시.
6157
+ total++;
6158
+ {
6159
+ let ok = false;
6160
+ try {
6161
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-f567-'));
6162
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6163
+ const run = (args) => cp.spawnSync(process.execPath, [CLI, ...args, '--path', d], { encoding: 'utf8', timeout: 15000 });
6164
+ const isJson = (s) => { try { JSON.parse((s||'').trim()); return true; } catch { return false; } };
6165
+ // F5 dedup: decision/lesson 동일 입력 2회 → 1 copy, --force → 2
6166
+ run(['decision', 'add', 'dupdec']); run(['decision', 'add', 'dupdec']);
6167
+ const decCount = ((run(['decision', 'list']).stdout || '').match(/dupdec/g) || []).length;
6168
+ run(['lesson', 'save', 'duples']); run(['lesson', 'save', 'duples']);
6169
+ const lesCount = ((run(['lesson', 'list']).stdout || '').match(/duples/g) || []).length;
6170
+ run(['decision', 'add', 'dupdec', '--force']);
6171
+ const decForce = ((run(['decision', 'list']).stdout || '').match(/dupdec/g) || []).length;
6172
+ const f5 = decCount === 1 && lesCount === 1 && decForce === 2;
6173
+ // F6 빈입력 --json 구조화 + exit1 (성공경로도 JSON 유지)
6174
+ const ra = run(['rule', 'add', '', '--json']); const ls = run(['lesson', 'save', '', '--json']);
6175
+ const raOk = run(['rule', 'add', '룰F6', '--json']); const lsOk = run(['lesson', 'save', '레슨F6', '--json']);
6176
+ const f6 = isJson(ra.stdout) && /empty_title/.test(ra.stdout) && ra.status === 1
6177
+ && isJson(ls.stdout) && /empty_text/.test(ls.stdout) && ls.status === 1
6178
+ && isJson(raOk.stdout) && isJson(lsOk.stdout);
6179
+ // F7 bogus subcommand → 잘못된 토큰 명시 + exit1 (유효 하위명령 무회귀)
6180
+ const tf = run(['task', 'frobnicate']); const rf = run(['rule', 'frobnicate']);
6181
+ const f7 = /task 하위명령: frobnicate/.test(tf.stdout + tf.stderr) && tf.status === 1
6182
+ && /rule 하위명령: frobnicate/.test(rf.stdout + rf.stderr) && rf.status === 1
6183
+ && run(['task', 'list']).status === 0 && run(['rule', 'list']).status === 0;
6184
+ fs.rmSync(d, { recursive: true, force: true });
6185
+ ok = f5 && f6 && f7;
6186
+ } catch {}
6187
+ console.log(ok ? '✓ B(1.30.4) #155 cli-ux 일관성: decision/lesson dedup(--force 우회) + rule/lesson 빈입력 --json 구조화(exit1) + task/rule bogus subcommand 토큰 명시' : '✗ cli-ux 일관성 F5+F6+F7 가드 실패');
6188
+ if (!ok) failed++;
6189
+ }
6190
+
6044
6191
  // 1.9.430 (10th 외부평가 UR-0130): health 보안 CRITICAL(커밋 시크릿)은 --strict 없이도 exit 1(CI 게이트). 클린은 exit 0.
6045
6192
  total++;
6046
6193
  {
@@ -6297,9 +6444,22 @@ total++;
6297
6444
  const agEnOk = /CLI agent slash commands/.test(agEn) && agEnLines.length >= 2 && !agEnLines.some(l => H.test(l));
6298
6445
  const agKoOk = /에이전트 슬래시/.test(agKo);
6299
6446
  fs.rmSync(da, { recursive: true, force: true });
6300
- ok = lensKoOk && lensEnOk && noLeak && stOk && healthOk && driftOk && doctorOk && hoEnOk && hoKoOk && edEnOk && edKoOk && shEnOk && shKoOk && agEnOk && agKoOk;
6301
- } catch {}
6302
- console.log(ok ? ' B(1.25.1/1.25.2/1.27.2/1.28.2/1.29.1/1.29.2/1.29.3/1.29.4) i18n 행위: --language en 런타임 영어(lens/health/drift/doctor/handoff보안요약/env-detect/shell-guard/agent-slash) + ko 기본 보존 + --language positional 무누출 + status 에러 en/ko (UR-0010)' : ' i18n 행위 회귀 가드 실패');
6447
+ // (1.30.5 #156 F3+F4) handoff 본문 워크플로 가이드 + 메모리 변동 en 영어(섹션 한글 0) + ko 보존 · verify-claim/optimism-check 미입력 에러 en/ko.
6448
+ const df3 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-i18n-f3-'));
6449
+ cp.spawnSync(process.execPath, [CLI, 'init', df3, '--yes', '--language', 'en'], { encoding: 'utf8', timeout: 30000 });
6450
+ const hf3En = out(cp.spawnSync(process.execPath, [CLI, 'handoff', df3], { encoding: 'utf8', timeout: 25000 }));
6451
+ const wfLines = hf3En.split('\n').filter(l => /Session workflow|Analyze request|sub-agent work|to disable:/.test(l));
6452
+ const f3En = /Session workflow/.test(hf3En) && wfLines.length >= 3 && !wfLines.some(l => H.test(l));
6453
+ const df3ko = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-i18n-f3k-'));
6454
+ cp.spawnSync(process.execPath, [CLI, 'init', df3ko, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
6455
+ const f3Ko = /세션 워크플로 6단계/.test(out(cp.spawnSync(process.execPath, [CLI, 'handoff', df3ko], { encoding: 'utf8', timeout: 25000 })));
6456
+ const vcEn = out(cp.spawnSync(process.execPath, [CLI, 'verify-claim', '--path', df3, '--language', 'en'], { encoding: 'utf8', timeout: 15000 }));
6457
+ const vcKo = out(cp.spawnSync(process.execPath, [CLI, 'verify-claim', '--path', df3ko], { encoding: 'utf8', timeout: 15000 }));
6458
+ const f4 = /required\. ex:/.test(vcEn) && !H.test(vcEn) && /필요\. 예:/.test(vcKo);
6459
+ [df3, df3ko].forEach(d => { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} });
6460
+ ok = lensKoOk && lensEnOk && noLeak && stOk && healthOk && driftOk && doctorOk && hoEnOk && hoKoOk && edEnOk && edKoOk && shEnOk && shKoOk && agEnOk && agKoOk && f3En && f3Ko && f4;
6461
+ } catch {}
6462
+ console.log(ok ? '✓ B(1.25.1/1.25.2/1.27.2/1.28.2/1.29.1/1.29.2/1.29.3/1.29.4/1.30.5) i18n 행위: --language en 런타임 영어(lens/health/drift/doctor/handoff보안요약/env-detect/shell-guard/agent-slash/워크플로가이드/verify-claim) + ko 기본 보존 + --language positional 무누출 + status 에러 en/ko (UR-0010)' : '✗ i18n 행위 회귀 가드 실패');
6303
6463
  if (!ok) failed++;
6304
6464
  }
6305
6465