leerness 1.36.94 → 1.36.96

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,142 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.96 — 2026-07-31 — 실행기를 만들지 않고 문서를 띄운다 (P-0010)
4
+
5
+ 사용자 요청: *"leerness.html 대신 exe 같은 실행기로 로드맵·그래프·스킬·기술·핸드오프·플랜 마크다운을 그대로 띄울 수 있나? 성능에 영향을 준다면 leerness.html 로 유지하고 거기에 반영."*
6
+
7
+ **실행기는 만들지 않았다.** exe/Electron 은 런타임 의존성 0 · install script 0 · 사용자 저장소에 실행기 파일 미생성이라는 경계를 깬다 — 그 경계는 1.36.90 에서 테스트로 못박아 뒀다. 사용자가 준 조건("성능에 영향을 준다면 leerness.html 에 반영")이 그대로 답이었다. 같은 목적을 **정적 단일 파일 안에서** 이룬다.
8
+
9
+ **📄 문서 탭** — `.harness` 핵심 문서 7종(플랜·스킬·기술 프로필·세션 핸드오프·현재 상태·진행 추적·결정 기록)을 `leerness.html` 안에서 그대로 읽는다. 좌측 목록 → 우측 본문, 브라우저만 있으면 된다.
10
+
11
+ - **렌더러는 최소 부분집합만 해석한다** — 제목(H1~H6) · **단층** 목록(중첩은 평탄화) · 순서 목록(**원문 번호 보존** — 시작값과 건너뛴 번호를 `ol start`·`li value` 로) · 코드펜스 · 표(**정렬 반영**) · 링크 · 강조. 나머지는 **이스케이프된 평문**이다(예외: 자리표시자와 충돌하는 U+0001·U+0002 는 표시 전에 제거한다). 이 파일은 사용자 문서를 그대로 싣기 때문에 파서를 넓힐수록 XSS 면이 넓어진다. 원시 HTML(`<script>`, `<img onerror>`)은 글자로 남고, 링크는 `http(s)`/`mailto`/앵커/상대경로만 통과한다(`javascript:`·`data:`·`vbscript:`·프로토콜 상대 `//` 거부).
12
+ - **자격증명 표지가 있는 문서는 본문을 아예 싣지 않는다.** 값 단위 마스킹을 다섯 번 고쳤고 매번 새 누출 경로가 나왔다(아래) — 그래서 **마스킹을 안전의 전제에서 뺐다**. 표지는 값이 아니라 **형식**으로만 정의해 줄 앞 접두어(`> `·`- `·들여쓰기)·대소문자·base64 조성·placeholder 모양에 의존하지 않는다. 마스킹(`redactSecrets`, 대시보드와 같은 술어)은 표지가 없는 문서에 대한 **두 번째 방어층**으로만 남는다.
13
+ - 비용을 먼저 쟀다 — 실제 프로젝트 문서 **156개 중 표지가 걸린 파일 1개(0.6%)**. 사실상 공짜라서 채택했다.
14
+ - 검증: 알려진 경로 **14종**(원본/인용문/목록/들여쓰기/CRLF/END없음 PEM 6 · `~`·`+`·`/` Bearer 3 · 소문자 `bearer` 1 · `AUTH=` 뒤 토큰 1 · placeholder 모양 1 · 반복 문자 1 · DB 접속문자열 1) 전부 본문 미포함. 이후 검수가 보인 **네 형태**(RFC6750 짧은 Bearer · 쿠키 · compact JWE · 강조로 감싼 스킴)도 같은 술어에서 검출된다. 표지 없는 문서는 그대로 실린다.
15
+ - **상한과 고지**: 문서당 64KB · 전체 512KB(**UTF-8 바이트**). 잘리면 `x/y자(뒷부분만)` 로 적는다. 전체 상한이 남은 경우 **잔여 예산만큼 담고**, 잔여가 의미 없는 크기(전체 상한의 1/8, 최대 2KB)면 조각 대신 "담지 않았다"고 고지한다. (기본 7종에서는 7×64KB=448KB 라 전체 상한이 걸리지 않는다 — 목록이 늘 때를 위한 backstop 이고, 상한을 주입해 분기를 실제로 발화시켜 시험한다.) 누적 로그(진행 추적·결정 기록)는 **뒤**를 남기고 그 외는 앞을 남긴다 — 1.36.95 handoff 에서 고친 그 방향 규칙을 그대로 따른다.
16
+ - 실측(이 저장소): 문서 탭이 `leerness.html` 에 더하는 양은 **+39.2KB**(120.8KB → 159.9KB). 자동 생성 기본값은 바꾸지 않았다.
17
+
18
+ **만들면서 세 번 틀렸고, 셋 다 다른 방식으로 드러났다.**
19
+
20
+ 1. **코드스팬 자리표시자가 본문의 모든 숫자를 삼켰다.** 자리표시자를 감싸는 문자를 빠뜨려 복원 정규식이 `/(\d+)/g` 가 됐고, `1.36.96` 같은 평범한 숫자가 코드로 바뀌었다. 게다가 그 문자를 **원시 제어문자(U+0001)로** 소스에 심어 놓아 눈에 보이지도 않았다 — 명시적 ``\u0001`` 이스케이프로 바꿨다.
21
+ 2. **전체 상한 512KB 가 구조적으로 도달 불가능했다.** 7종 × 64KB = 448KB 라 `skipped` 분기가 한 번도 실행되지 않았고, 그걸 검사하던 `every` 는 빈 컬렉션 위에서 공허하게 참이었다. 상한값을 주입 가능하게 해 분기를 실제로 발화시켰다(측정: skipped 3건). 그 과정에서 **159자짜리 쓸모없는 조각**이 "잘림"으로 표시되던 것도 발견해, 잔여 예산이 의미 없으면 조각 대신 "담지 않았다"로 바꿨다.
22
+ 3. **좁은 화면에서 레이아웃이 무너졌다** — 190px 고정 내비가 자리를 다 먹어 본문이 한 글자씩 세로로 흘렀다. 테스트로는 보이지 않는 종류라 **실제 브라우저로 열어 봐야** 잡혔다. 760px 이하에서 내비를 가로 칩으로 접는다(실측: 375px 에서 본문 폭 90 → 323px, 가로 스크롤 0).
23
+
24
+ **그리고 검수가 출하를 막았다 — 10건 중 7건이 재현됐고 전부 고쳤다.**
25
+
26
+ - **P1 시크릿 누출 — 마스킹이 스캐너보다 약한 목록을 쓰고 있었다.** DB 접속문자열 비밀번호 · GitLab PAT · npm 토큰 · Stripe live 키 · SendGrid · 개인키가 **그대로 화면에 실렸다**(실측 6종). 전부 `catalogs.js` 의 `SECRET_PATTERNS` 에 **이미 있던** 패턴이다 — 한쪽 표면만 강화된 상태로 방치돼 있었다. 같은 목록을 공유하게 하고(placeholder·엔트로피 가드도 스캐너와 동일), 개인키는 BEGIN 줄만이 아니라 **블록 전체**를 지운다(종전엔 본문 base64 가 남았다). `leerness.html` 은 공유·첨부되는 파일이라 결과가 더 나빴다. 이 수정은 대시보드에도 함께 적용된다.
27
+ - **P2 빈 경계 줄 하나가 문서 전체를 지웠다.** 선두 빈 줄 + 초장문 한 줄이면 `keep` 은 1 이 되는데 담긴 내용은 0자였다 — **70,000자 문서가 빈 화면**이 됐다(누적 로그는 끝 개행 하나로 같은 일이 났다). 줄 수가 아니라 담긴 **내용**으로 판정하고, 없으면 경계의 내용 있는 줄을 코드포인트로 자른다.
28
+ - **P2 "64KB/512KB" 가 사실이 아니었다.** UTF-16 코드 단위를 세면서 KB 라고 적어, 한글 문서에서 합계 **1,152KB**(HTML 1.21MB)까지 실렸다. 상한을 UTF-8 바이트로 바꿔 주장을 참으로 만들었다.
29
+ - **P2 링크 정규식이 O(n²) 였다.** 닫히지 않은 대괄호마다 남은 문자열 전체를 다시 훑어 64KB 입력에서 **1,825ms**(최악 2,730ms) 브라우저 메인 스레드가 멈췄다. 수량자를 유계로 바꿔 **41ms**(최악 23ms).
30
+ - **P2 역슬래시 프로토콜 상대 URL 이 통과했다.** 슬래시 2개만 막았는데 브라우저는 역슬래시를 슬래시처럼 읽는다 — `\\host\share` 가 링크가 됐다.
31
+ - **P2 e2e 가 계약을 깨는 변이 3종을 통과시켰다.** 허용 링크는 HTTPS 하나만, 제목은 H2 만 검사했고, `tech`·`progress` 두 문서는 아무것도 확인하지 않아 경로가 끊겨도 초록이었다.
32
+ - **P3 링크를 만든 뒤 강조 정규식이 href 까지 다시 해석했다** — `href="https://a/<b>b</b>/c"`. 완성된 앵커를 자리표시자로 빼 둔다. 문서에 실제로 U+0001 이 있으면 자리표시자와 충돌하던 것도 함께 막았다.
33
+ - **검수가 틀린 것도 있었다.** SendGrid "누출"은 세 번 연속 **내 픽스처**가 틀린 것이었고(뒤가 38자 → 같은 글자 반복 → 39자) 코드는 처음부터 정상이었다. 길이를 손으로 세지 말고 생성하도록 바꿨다. 마스킹 오탐 1건(`password = 사용자가…`)도 1.36.95 규칙만으로 재현해 **회귀가 아님**을 확인했다.
34
+
35
+ **그리고 재검수가 다시 막았다 — 6건 중 3건이 위 수정이 만든 새 결함이었다.**
36
+
37
+ - **P1 END 줄이 없는 개인키는 본문이 그대로 남았다.** "블록 전체를 지운다"는 규칙이 `-----END …-----` 를 요구했는데, 붙여넣다 잘린 키가 오히려 흔하다. BEGIN 뒤에 이어지는 base64/헤더 줄까지 함께 지운다.
38
+ - **P2 빈 경계 줄이 두 개면 같은 데이터 소실이 재발했다.** 내가 "담긴 내용으로 판정"한다며 쓴 `acc` 에는 **줄바꿈 바이트가 포함**돼 있었다 — 빈 줄 두 개면 `acc=1` 이 되어 판정을 통과하고 70,000자가 줄바꿈 한 글자로 줄었다. 내용 바이트를 따로 센다. 픽스처를 빈 줄 **한 개**로만 만든 탓에 못 봤다.
39
+ - **P2 스캐너 목록을 그대로 공유한 것이 정상 산문을 파괴했다.** 그 목록에는 `AWS secret …` · `Bearer …` 같은 **탐지 전용 문맥 휴리스틱**이 섞여 있다. 탐지에서 오탐은 경고 한 줄이지만, **가림에서 오탐은 사용자가 보는 문서를 조용히 훼손**한다(대시보드도 같은 함수를 쓴다). 실측: `결정: AWS secret commit <해시> was reverted` → `결정: *** was reverted`. 엔트로피 게이트로는 갈리지 않았다(진짜 AWS 키까지 placeholder 로 판정된다). 문맥 패턴은 가림에서 제외하고, Bearer 는 값에 숫자가 있을 때만 가린다. 남는 구멍(`Authorization:` 없이 적힌 영문자만의 Bearer 토큰)은 명시해 둔다.
40
+ - P3 링크 길이 상한이 **이스케이프 후** 길이에 걸려 원문 796자짜리 정상 쿼리 URL 이 거부됐다(`&`→`&amp;` 팽창). 실측 비교로 라벨 300 / URL 4,000 을 골랐다 — 최악 115ms(무계는 1,747ms).
41
+ - P3 잘림 고지의 "자" 가 UTF-16 코드 단위였다 — 이모지 문서에서 `32,767/60,001자` 로 표시되지만 실제는 `16,384/30,001자`. 코드포인트로 센다.
42
+ - **내 실측이 "신규 오탐 0" 이라고 말했는데도 오탐이 실재했다.** 실제 문서 189개에 그런 문장이 없었을 뿐이다 — **실제 데이터 측정은 구성된 반례를 대체하지 못한다.**
43
+
44
+ **세 번째 검수에서 7건이 더 나왔고, 같은 자리에서 세 번 틀린 것이 있었다.**
45
+
46
+ - **P1 암호화 개인키의 본문이 샜다.** 암호화 키는 `Proc-Type:`/`DEK-Info:` 헤더 뒤 **빈 줄**이 오고 본문이 시작하는데, 규칙이 빈 줄에서 멈췄다. 반대로 아무 `Name: value` 줄이나 먹으면 BEGIN 을 인용한 정책 문서의 산문을 삼킨다 — 헤더는 **PEM 표준 이름만** 허용하고 본문 base64 줄이 최소 하나 있을 때만 확장한다.
47
+ - **P1 무관한 산문을 통째로 삼키는 경로가 있었다(변이 실험 중 발견).** `[\s\S]*?` 로 BEGIN 과 END 를 잇는 규칙은 그 사이의 **아무 내용이나** 먹는다 — 종료되지 않은 키 하나가 문서 뒤쪽의 END 줄과 짝지어지면 사이의 모든 문단이 사라진다. 사이에는 PEM 헤더·base64·빈 줄만 허용하도록 좁혔다.
48
+ - **P2 영문자만으로 된 32자 Bearer 토큰과, 구분자 없는 AWS 실값이 샜다.** 앞 라운드에서 오탐을 막으려고 건 조건이 실값까지 놓쳤다. Bearer 는 "숫자가 있거나 32자 이상", AWS 문맥은 패턴을 되살리되 **꼬리 값만** 치환한다(문장 구조는 남는다 — 커밋 해시가 함께 가려지는 것은 감수한다. 누출보다 낫다).
49
+ - **P2 데이터 소실이 세 번째로 재발했다 — CRLF 의 `\r`, 공백, 탭이 "내용"으로 세어졌다.** `keep > 0` → `acc > 0` → `content > 0` 으로 세 번 고쳤고 매번 셈을 조금씩 틀렸다. **같은 자리에서 세 번 틀렸다면 픽스처가 아니라 접근이 틀린 것이다.** 셈을 없앴다 — 줄 단위로 담은 것과 경계 줄을 코드포인트로 자른 것을 **둘 다 만들어 긴 쪽**을 쓴다. 어떤 공백 조합에도 예산을 채우는 쪽이 이긴다.
50
+ - **P2 URL 스킴 판정이 C0 제어문자로 우회됐다.** 탭·개행만 지웠는데 `\x03`·`\x01`·`\x00`·`\x1F`·`\x7F` 를 앞이나 중간에 끼우면 통과했다. C0 전체와 DEL 을 제거한다.
51
+ **네 번째 검수에서 또 7건 — 그리고 "A를 고치면 B가 깨지는" 형태가 두 번 나왔다.**
52
+
53
+ - **P1 20~31자 영문 Bearer 토큰이 샜다.** 앞 라운드에서 오탐을 막으려 건 길이 문턱(32자)이 실제 토큰을 놓쳤다 — OAuth 토큰 길이는 보장되지 않는다. **길이가 아니라 모양**으로 가른다: 낱말을 이어 붙인 camelCase(각 조각 3자 이상)만 산문으로 보고 나머지는 가린다. `authenticationMiddleware` 는 조각이 14/10자라 산문, `AbCdEfGhIj…` 는 조각이 2자라 토큰이다.
54
+ - **P2 절단이 문서의 제목·푸터를 버렸다.** "줄 단위 결과와 코드포인트 절단 결과 중 긴 쪽" 이라는 앞 라운드의 수정이, 경계 줄이 길면 **앞부분을 통째로 버렸다** — `TITLE` + 초장문이면 `TITLE` 이 사라지고 화면 고지의 "앞부분만" 이 거짓이 된다. 온전한 줄로 채운 뒤 **남은 예산만큼 경계 줄을 이어 붙인다**. 그런데 그렇게만 하면 이번엔 평범한 문서까지 줄 중간에서 잘렸다 — **혼자서는 어떤 예산으로도 담길 수 없는 줄**일 때만 이어 붙이도록 좁혀 두 성질을 함께 만족시켰다.
55
+ - **P2 BEGIN 인용 뒤의 평범한 영문 한 줄을 개인키 본문으로 오인해 지웠다.** 본문 요건이 16자였다. 40자로 올렸더니 이번엔 **줄이 짧게 감긴 키의 본문이 샜다**(내 회귀 확인에서 22자·16자 본문이 남았다). **길이가 아니라 내용**으로 가른다 — 진짜 base64 는 숫자나 `+ / =` 를 포함하고, 영문 낱말을 이어 붙인 식별자는 포함하지 않는다.
56
+ - P3 PGP 블록은 END 앞 `=CRC` 줄을 허용하지 않아 CRC 와 END 가 남았다.
57
+ - **두 번 다 "더 센 조건" 이 아니라 판별 기준 자체를 바꾸는 것이 답이었다.** 길이 문턱을 올렸다 내렸다 하면 한쪽이 반드시 새거나 파괴된다.
58
+
59
+ **다섯 번째 검수가 그 "판별 기준"마저 양방향으로 깼다 — 그래서 원칙을 정했다.**
60
+
61
+ - **P1 실제 RSA 키의 영문자 전용 본문 줄에서 51줄이 그대로 노출됐다.** 검수가 진짜 키를 생성해 16자로 감싸니 영문자만인 줄이 나왔고, "진짜 base64 는 숫자나 `+ / =` 를 포함한다"는 내 판별이 무너졌다. 반대 방향도 함께 무너졌다 — `PolicyVersion2026Final` 은 숫자를 포함한 22자 영숫자라 본문으로 분류돼 문서가 훼손됐다.
62
+ - **P1 `Bearer abcdefghijklmnopqrst` · `Bearer CorrectHorseBatteryStaple` 같은 유효한 불투명 토큰이 산문으로 판정돼 노출됐다.** 낱말꼴 예외를 둔 순간 **그 예외가 곧 누출 경로**가 됐다.
63
+ - **다섯 번 시도해 매번 한쪽이 깨졌다면, 그건 판별의 문제가 아니라 원칙의 문제다.** 값의 조성으로 시크릿과 산문을 가르려는 시도에는 반드시 반례가 있다. 그래서 원칙을 정했다 — **표시용 마스킹은 안전한 쪽으로 실패한다.** 값이 아니라 **문맥**으로 판정한다: `BEGIN` 마커 뒤의 **줄 전체에 공백이 없는 줄**은 키 재료로 본다. 산문은 거의 항상 공백을 포함하므로 문단은 살아남고, 어떤 조성의 키 본문도 새지 않는다. Bearer 도 예외를 없앴다.
64
+ - **대가는 명시한다.** BEGIN 을 인용한 문서에서 **바로 뒤의 단일 토큰 한 줄**이 가려지고, `Use Bearer authenticationMiddleware for API requests.` 는 `Use *** for API requests.` 가 된다. 원본 파일은 그대로이고 이건 표시용 스냅샷이므로, 누출보다 이쪽이 낫다. (20자 미만은 카탈로그가 후보로 잡지 않으므로 `Bearer 인증` 같은 표현은 영향이 없다.)
65
+ - 검증: 진짜 RSA 키를 생성해 16자·64자 두 방식으로 감싸고 END 유무까지 조합해 **본문 0줄 누출**을 확인했다.
66
+
67
+ **여섯 번째 검수가 그 원칙마저 뚫었다 — 그래서 마스킹을 안전의 전제에서 뺐다.**
68
+
69
+ - **P1 Markdown 인용문(`> `)으로 감싼 실제 개인키가 전량 노출됐다.** 내 규칙은 줄 앞에 공백·탭만 허용했다. README·정책 문서에서 키를 blockquote 로 인용하는 것은 자연스러운 마크다운이다. 16자 래핑에서 51/51, 64자에서 13/13 줄이 그대로 실렸다.
70
+ - **P1 Bearer "예외 없음" 원칙에도 누출 경로가 넷 남아 있었다** — 카탈로그가 대문자 `Bearer` 만 받고 `~`·`+`·`/` 를 문자군에서 빠뜨렸으며, `AUTH=Bearer <토큰>` 은 앞부분만 가려 `AUTH=*** <실토큰>` 이라는 **거짓 안심**을 만들었고, placeholder 모양 토큰은 가드가 통과시켰다.
71
+ - **여섯 번을 고쳤는데도 새 경로가 나온다면, 고치는 방향이 틀린 것이다.** 값 단위 마스킹이 완벽해야 안전한 구조를 버렸다 — **자격증명 표지가 하나라도 있으면 그 문서의 본문을 싣지 않는다.** 이제 마스킹이 부분적으로 실패해도 누출로 이어지지 않는다.
72
+ - **변이가 5건 살아남아 픽스처의 결함도 드러났다** — 시크릿을 한 파일에 몰아넣으면 **표지끼리 서로를 가려 줘서**, 특정 표지를 무력화하는 변이가 그대로 통과했다. 표지 술어는 **하나씩 격리**해 단언한다(7/7 검출). 남은 생존 2건은 `_SECRET_BEARER` 가 먼저 가려 등가가 된 것이라 목록에서 뺐고, 마스킹 제거 변이는 "가드를 통과한 문서에도 두 번째 층이 살아 있는가" 단언을 넣어 잡았다.
73
+
74
+ **일곱 번째 검수가 가장 아픈 것을 짚었다 — 내가 표면 하나만 보고 안전을 선언했다.**
75
+
76
+ - **P1 문서 탭을 가드해도 같은 내용이 `nodes`·`roadmap` 으로 그대로 나갔다.** `decision add "Authorization: Bearer <토큰>"` 이 `DATA.nodes[].label` 과 `detail.decision` 에 원문으로 실렸다(실측). 그래프 쪽에는 **마스킹조차 없었다.** "문서 본문을 통째로 제외하므로 마스킹 실패가 누출로 이어지지 않는다"는 내 전제가 성립하지 않았다.
77
+ - 필드마다 손으로 감싸면 반드시 빠뜨린다(그래서 문서 탭만 덮였다). **임베드 직전 한 곳**에서 모든 문자열을 같은 술어로 통과시킨다 — 지금 있는 표면도, 나중에 추가될 표면도 함께 덮인다.
78
+ - 실측: decision·task·plan 세 경로 모두 생성 HTML 과 `DATA` 전 경로에서 0건. 정상 항목은 그래프에 그대로 남는다.
79
+ - **P1 표지를 빠져나가는 네 형태** — RFC6750 의 짧은(19자) Bearer, 세션/Set-Cookie, compact JWE(5분절이라 `eyJ…eyJ` 가 아니다), 마크다운 강조로 감싼 `` `Bearer` ``. 넓히기 전에 비용을 쟀다: 실제 문서 156개에서 **추가로 걸리는 파일 0개**라 셋 다 넓혔다.
80
+ **여덟 번째 검수에서 P1 이 처음으로 0 이 됐고, 남은 P2 는 내가 방금 넣은 코드가 원인이었다.**
81
+
82
+ - **깊이 처리를 세 번 바꿨고, 두 번째 진단이 틀렸다.** ① 상한 12 + **원본 통과** → 12단계 밖이 정규화를 우회(누출). ② "상한이 우회를 만든다"고 보고 **상한을 제거** → 약 24KB 짜리 유효 JSON 하나로 **생성 자체가 실패**(직렬화가 4천 단계에서 스택을 터뜨린다). ③ 상한 200 + **자리표시자 대체** → 8천 단계까지 생성 성공, 누출 0. **문제는 상한이 아니라 상한을 넘겼을 때 원본을 통과시킨 것이었다 — fail-open 이면 우회, fail-safe 면 보호다.**
83
+ - **순환을 막으려 쓴 방문 집합이 정상 데이터를 지웠다.** 전역 집합으로 두면 같은 객체를 두 필드가 공유할 때 두 번째가 순환이 아닌데도 `null` 이 된다(실측). 재귀 스택처럼 **들어갈 때 넣고 나올 때 빼야** 진짜 순환만 끊긴다.
84
+ - **CRLF 문서를 앞에서 자를 때 고립된 `\r` 이 남았다** — `\n` 으로만 나누므로 줄 끝의 `\r` 이 내용으로 세어지고, 재조립 때 개행이 붙지 않으면 제어문자가 표시 데이터에 남는다.
85
+ - **순서 목록 번호가 조용히 바뀌었다** — `2. two` 가 브라우저에서 `1. two` 로, `1·3` 이 `1·2` 로 보였다. 문서의 의미를 바꾸는 것이라 원문 번호를 보존한다. **표 정렬**도 문법으로는 받으면서 의미를 버리고 있었다 — 받은 이상 반영한다.
86
+ - **변이 진단에서 픽스처가 판별점을 비껴간 경우가 셋 더** — CRLF 는 경계 줄이 이어붙어 정상 CRLF 가 되고(상한 안에 들어가는 줄로 교체), 순환은 깊이 상한이 먼저 끊어 주며(출력 크기까지 단언), 깊이 상한은 "생성 성공"만 보면 낮춰도 통과한다(보통 깊이 데이터 보존까지 단언).
87
+
88
+ - **변이 진단에서 픽스처 함정이 둘 더 나왔다** — 누출 needle 을 `Authorization: Bearer …` 로 만들었더니 **마스킹만으로도 지워져** 표지 가드 제거 변이가 살아남았고(쿠키 형태로 교체), Bearer 문자군 판별 픽스처는 `/`·`~` 를 맨 앞에 둬서 **접두 허용이 흡수**해 버렸다(가운데 배치 + 양쪽 16자 미만으로 교체). 둘 다 가드가 약한 게 아니라 픽스처가 그 경로를 판별하지 못한 경우였다.
89
+
90
+ - **변이 생존 5건의 원인이 세 갈래로 갈렸다** — 등가 변이 1건(가림 제외 목록이 비어 분기가 무의미), **픽스처 간섭** 3건(한 문서에 PEM 을 몰아넣어 서로를 가려 줬다 → 마스킹 단언을 격리 입력으로 직접 검증), **픽스처가 분기에 도달하지 못한** 1건(산문 뒤 base64 가 없어 규칙이 발동조차 안 했다). 앵커가 유일하지 않아 엉뚱한 규칙을 고치던 것도 유일성 검사가 잡았다.
91
+
92
+ - **마스킹을 넓혔으니 실제 데이터로 오탐을 쟀다.** 로컬의 실제 leerness 프로젝트 문서 **189개 · 10,431줄**에 걸어 본 결과, 마스킹된 72줄은 **전부 1.36.95 규칙으로도 이미 마스킹되던 줄**이었다 — 이번 변경이 새로 건드린 줄은 **0건**, 반대로 커버리지가 후퇴한 줄도 **0건**. (그 72줄에는 `idempotency_key=run:phase:step` 같은 **기존 규칙의 오탐**이 섞여 있다. 이번 회귀가 아니므로 별도 항목으로 남겼다.)
93
+ - 검증: 게이트 exit 0 · e2e **416/416** · 신규 1블록 · 변이 **81종 81/81**(등가 2건 제외 · 앵커 문제 0) · 자체 헌트 24종 결함 0 · 출구 전수 13종 · 계약 25종(원시 HTML · 엔티티/제로폭 URL · 표 구분줄 오인 · 미종료 펜스 · 주석 · CDATA) · 성능 6종. 실제 브라우저(788px·375px)에서 탭 전환·7종 내비·본문 렌더 확인.
94
+ - **변이 1차에서 4건이 살아남았고 원인이 넷 다 달랐다** — 변이 하네스의 앵커가 유일하지 않아 엉뚱한 함수를 고친 것(하네스 버그), `mdInline` 도 내부에서 이스케이프하므로 XSS 단언만으론 못 잡는 것(단언이 목적이 아니라 부작용만 봄), 이모지만 반복한 픽스처가 서로게이트 쌍 경계에 정확히 맞아 쪼갤 자리가 없던 것, 줄 경계 단언이 tail 문서에만 걸려 non-tail 변이를 못 본 것. 앵커 유일성 검사를 러너에 넣고 나머지 셋은 픽스처를 고쳤다.
95
+ - **계약 검증 도구 자신이 오탐 5건을 냈다.** 출력 전체에서 `on...=` 를 찾아 `&lt;img src=x onerror=1&gt;` 처럼 **이미 이스케이프된 글자**를 속성으로 오인했다. 그대로 믿었으면 없는 버그를 고쳤을 것이다. 실제 태그 안만 보도록 고치고, **탐지기가 진짜 위반을 잡는지 대조군으로 먼저 증명**한 뒤 "위반 0"을 말한다.
96
+ - **게이트 실패 한 번은 내가 만들었다.** 게이트 실행 중에 소스를 고쳤고, 그 편집이 `String.raw` 템플릿 안 주석에 백틱을 넣어 템플릿을 끊었다 — 바로 그 위에 "여기선 백틱을 쓸 수 없다"고 적어 둔 자리였다. 처음엔 동시 실행 간섭이라고 생각했지만 격리 재현으로 5초 만에 진짜 원인이 나왔다.
97
+ - **검수가 살려낸 마지막 변이는 "데이터는 맞는데 화면이 틀리는" 경우였다.** 코드포인트 단언을 데이터에만 걸어 둬서, 화면 고지를 `String.length` 로 되돌리는 변이가 통과했다. 사용자가 보는 것은 화면이다 — 생성물에서 고지가 어느 값을 쓰는지까지 확인한다.
98
+
99
+ ## 1.36.95 — 2026-07-31 — AI 에게 매 세션 읽히던 파일의 절반이 틀린 과거 수치였다
100
+
101
+ 사용자 질문에서 시작했다 — "Opus 5 처럼 모델에 검증이 내장돼 있으면 하네스가 검증을 또 시키는 건 낭비 아닌가. 구동 모델을 인지해 맞춤으로 알려줄 수 있나?" 조사 결과 **모델 인지는 만들지 않기로 했고**(아래), 대신 그 조사가 훨씬 큰 것을 드러냈다.
102
+
103
+ **출하 중인 한글 `session-workflow.md` 템플릿 10,308자 중 5,216자(50.6%)가 낡은 릴리스 변경이력이었다.** 헤딩도 없이 Memory CRUD 섹션 아래 붙어 있었고, 내용은 MCP 도구 수를 18 → 42 로 세어 올라가는 로그였다 — **실제 도구 수는 89 다**. 이 파일은 AGENTS.md 의 필독 1번이고 CLAUDE.md 가 "⭐ 매 세션 첫 행동"으로 가리키는 파일이다. AI 가 매 세션 읽도록 설계된 문서의 절반이 틀린 과거 수치였다. 영문 템플릿은 **이미 깨끗했다**(`## Auto-recovery & security` 로 정리돼 있었다) — 한쪽 표면만 썩어 있었고 아무도 비교하지 않았다.
104
+
105
+ - 이력 43줄을 제거하고, 남긴 운영 사실 11줄에는 영문과 대응하는 `## 자동 회복 · 보안` 헤딩을 줬다. 실측: 한글 템플릿 **10,308자 → 5,107자**, 영문 5,075자 — 두 언어 크기가 맞았다.
106
+ - e2e 가 **도구 수 주장 자체가 0 인지**를 양쪽 언어에서 단언한다. 개수를 고쳐 적는 것은 다음 릴리스에 또 낡는다. (초안의 정규식 `\d+\s*도구\b` 는 **한글 뒤 `\b` 가 절대 매치되지 않아** 아무것도 세지 않는 공허 단언이었다 — 변이 테스트가 잡았고, 그 함정은 이미 내 메모리에 적혀 있었는데도 다시 밟았다.)
107
+
108
+ **기존 설치본의 이력까지 지우는 기능은 이 릴리스에서 뺐다.** 템플릿에서 줄을 지우면 머지(`_managedMerge`)가 그것을 "사용자 커스텀"으로 보고 `## Preserved previous content` 로 이월하므로, 원래는 기존 설치본의 이력도 함께 처리하려 했다. 세 가지 설계를 시도했고 전부 사용자 데이터를 지웠다:
109
+
110
+ 1. **휴리스틱**(버전 접두 + 릴리스노트 표지) — 마크다운을 쓰는 사용자 문장을 삭제했다(한국어 코퍼스 34/34, 다국어 5/5). 백틱 하나가 보존을 삭제로 뒤집었다. 내가 댄 근거 "실제 설치본 21개에서 오탐 0건"은 **그 21개가 전부 작성자 본인의 도그푸딩 프로젝트라 제3자 산문이 표본에 없어서** 무효였다.
111
+ 2. **기계 생성 정확 일치 목록** — 오탐 면적은 크게 줄었지만 `trim` 비교가 들여쓴 인용을 지웠고, 들여쓰기를 배제하자 이번엔 우리가 2칸 들여쓴 채 내보낸 줄을 못 지웠으며(설치본 21/21 · 42회), `raw === l` 비교는 **CRLF 파일에서 `\r` 때문에 121/121 을 전부 누출**시켰다(Windows 가 주 대상인데 기능 전체가 죽는다).
112
+ 3. **원문 그대로 비교** — 여기까지 와서도 `<details>` 가 파일 어디에든 있으면 무관한 줄의 리터럴 펜스가 변형됐고, 템플릿이 들여쓴 줄을 사용자가 안 들여쓰면 삭제됐다.
113
+
114
+ **근본 원인은 하나다: 문자열로는 "우리가 내보낸 줄"과 "사용자가 그 줄을 적은 것"을 구분할 수 없다 — 바이트가 같다.** 규칙을 정교하게 만들수록 한쪽을 고치면서 다른 쪽이 깨졌다. 진짜 해결은 생성 영역 마커(provenance)이고, 그건 `_managedMerge` 재설계라 별도 라운드로 다룬다.
115
+
116
+ **그리고 "삭제하지 않는다"를 실제로 참으로 만들기 위해 기존 필터 하나를 걷어냈다.** 범위를 좁힌 뒤에도 검수가 반증했다 — 템플릿에서 이력 43줄을 지우면 그 줄들이 **처음으로 머지 경로를 타게 되고**, 1.36.60 이 넣어 둔 자동생성 이력 필터(`- .*MCP **N 도구` 등)가 그때 발화한다. 실측: 설치본 21개의 이력 731줄 중 **346줄 삭제**, 그리고 사용자 문장 `- 사용자 메모: MCP **77 도구** 는 우리 내부 표현이며 삭제 금지` 도 함께 사라졌다. 원래 주석은 "사용자 산문이 이 패턴과 겹칠 확률은 무시 가능"이라고 적고 있었다. 그 필터를 제거했다 — 이력 줄은 삭제되지 않고 `Preserved` 로 이월된다.
117
+
118
+ - 실측(실제 설치본 21개, 진짜 `_managedMerge`): **유실 고유 줄 0**. 검수가 만든 사용자 문장 반례 5종 전부 보존. 이력 줄은 이월(삭제 아님). 멱등. **검수가 독립 재현으로 확인했다** — 21/21 유실 0, 같은 입력에 1.36.94 코어를 쓰면 정확히 346줄 유실.
119
+ - 대가는 **과보존**이다 — 기존 설치본의 `session-workflow.md` 는 줄지 않고, 이력이 `Preserved` 블록으로 자리를 옮긴다. 실측: 설치본 21개 합계 203,408 → 245,239자(**+20.6%**), 파일별 1.03~2.17배. 멱등이라 반복 성장은 없고 언어 전환(ko↔en)·다른 관리 파일에도 부작용이 없다. 이 파일의 계약(false-DROP 이 버그, false-PRESERVE 는 무해)에 맞는 방향이고, 실제 정리는 provenance 라운드에서 한다.
120
+ - 즉 이 릴리스의 효과는 **신규 `init` 은 즉시 깨끗해지고, 기존 설치본은 데이터를 잃지 않는다** 까지다.
121
+
122
+ **`handoff` 의 라벨과 동작이 어긋나 있었다.** `Decisions (last 40 lines)` 라고 써 놓고 파일 전체를 넣은 뒤 **앞** 4,000자를 잘랐다 — 단위(줄↔문자)도, 방향(마지막↔처음)도 반대였다. 라벨대로 뒤에서 N줄을 취한다. 이 한 줄짜리 수정이 **세 번의 회귀**를 거쳤고 전부 검수가 실행으로 잡았다:
123
+
124
+ - 1차: 섹션 전체를 `slice(-4000)` 했더니 `=== Decisions … ===` **헤더가 1순위로 잘려** 결정 내용이 앞 섹션 꼬리에 붙었다(실사용 50개 중 4개 발화). 상한은 **본문에만** 적용하고 헤더는 불가침으로 바꿨다.
125
+ - 2차: 줄 단위 절단을 매 반복 `join()` 으로 구현해 **O(n²)** 가 됐다(48,000줄에서 30초대, 입력 2배마다 4배). 누적 길이를 한 번에 계산하도록 고쳤다.
126
+ - 3차: 한 줄이 상한보다 길 때 문자 인덱스로 잘라 서로게이트 쌍이 쪼개졌고(평문 U+FFFD · JSON 단독 surrogate `0xDE00`), 빈 파일을 `1/1 줄` 이라 표시했고, 정확히 4,000자인 본문을 초과로 오판해 절반을 버렸다(마지막 줄에도 개행을 셌다).
127
+
128
+ - 잘릴 때도 라벨이 사실이어야 한다 — `25/121 줄 (상한으로 잘림)` 처럼 실제 표시 줄 수를 함께 적고 **줄 경계**에서 자른다.
129
+ - **`handoff --json` 은 같은 내용을 앞 8,000자로 잘랐다** — 같은 명령의 두 표면이 반대 방향이었고, 외부 AI/MCP 용이라고 문서화된 쪽이 가장 오래된 결정만 받았다(15MB `decisions.md` 에서 최신 미포함). 누적 로그(progress-tracker/decisions/task-log)는 뒤를 남기고, 그 외는 앞을 남긴다. `truncated`·`totalChars`·`kept` 를 함께 준다. 마커까지 포함해 8,000자를 넘지 않는다.
130
+ - 실측: 결정 120개 중 081~120 이 **끊김 없이 연속**, Task Log 200개 중 최신 60개(141~200)만, `--json` 에 최신 포함·최초 잘림. 성능은 **O(n²) 폭증이 사라지고 선형 범위**다 — 파일을 읽어 `split` 하므로 입력에 비례하는 비용은 남는다(검수 재측정: 12k/24k/48k/96k 에서 대략 0.4/0.4/0.4/0.7초). "입력과 무관하다"는 뜻이 아니다.
131
+
132
+ **모델 자동 인지는 만들지 않는다.** 실측 근거: 환경변수 전수(`CLAUDE*` 17개, 검수 환경에서는 15개 — 환경마다 다르다)에 모델 성분이 없고, `CLAUDE_CODE_SESSION_ID` 로 세션 transcript 파일은 특정되지만 그 안의 모델 필드는 비공개 내부 포맷이며, 서브에이전트마다 모델이 달라 턴 단위 정확도가 성립하지 않는다. MCP `clientInfo` 는 클라이언트만 준다. 모델→프로파일 매핑을 갖는 순간 `role-catalog.js` 의 `claude-opus-4-7 "최신"` 과 같은 운명이고, 1.36.89 의 "모델 이름은 한 줄도 하드코딩하지 않는다"와 충돌한다. **토큰 절감량도 주장하지 않는다** — leerness 에 토큰 계측 코드가 0줄이다(`input_tokens|totalTokens` 검색 결과 0건). 문자수 감소만 실측으로 적는다.
133
+
134
+ **마지막으로 e2e 자신이 "삭제 안 됨"만 보고 "적용은 됐나"를 안 봤다.** 검수가 살려낸 변이 — 새 템플릿을 쓰지 않고 **이전 파일 뒤에 `Preserved` 헤더만 덧붙이는** 머지 — 가 무삭제 단언 3종을 전부 통과했다. 그러면 기존 설치본은 낡은 이력을 계속 **활성 본문**으로 읽는데 CI 는 초록이다. 부작용(삭제)은 봤지만 목적(교체)은 안 본 단언이었다. 관리 영역이 새 템플릿과 일치하고 옛 줄은 태그 뒤에만 있는지 단언한다. 그 단언조차 처음엔 정상 코드에서 실패했다 — 태그 앞에 붙는 구분선 `---` 을 반영하지 않았고, **제안받은 코드도 실행하기 전엔 맞는지 모른다**는 걸 30초 만에 확인했다.
135
+
136
+ **이 라운드에서 배운 것.** 외부 검수가 11회 차단했고 매번 실제 결함이었다. 마지막 다섯 번은 전부 **내 직전 수정이 만든 새 결함**이었고, 매번 내 자체 e2e 는 통과했다 — 내가 만든 픽스처는 내가 상상한 시나리오만 담기 때문이다. 검수에 **실제 설치본 21개를 함께 넘기자** "재현 못 함 → 미확인"이던 항목들이 "네 수치가 틀렸다"로 바뀌었다(설치본 잔존 0 → 실제 2/21, 코퍼스 121개 → 실제로는 빈 슬롯 포함 122). 그리고 마지막에는 **검수에게 출하 여부를 직접 물었다** — 내 "이번엔 다 잡았다"가 아홉 번 틀린 뒤였기 때문이고, 답은 "삭제 기능을 빼고 나머지만 내보내라"였다.
137
+
138
+ - 검증: e2e **415/415** · 회귀 가드 **변이 22종 22/22 사살**(옛 이력 필터 부활 · 이력 줄 삭제 · 새 템플릿 미적용 · 한쪽 언어만 정리 · 대문자 `**99 Tools**` · handoff 헤더 절단/O(n²) 복귀/서로게이트 분할/빈 파일 `1/1`/정확 상한 오판/Task Log 59 · `--json` 방향 뒤집기·`totalChars` 위조·마커 예산 이탈). 설치본 21개 실측 유실 0 은 **검수가 독립 재현**했고, CHANGELOG 의 "실측:" 문장은 전수 검증에서 정정 없이 통과했다.
139
+
3
140
  ## 1.36.94 — 2026-07-30 — 제품이 안내한 탈출구가 막다른 길이었다 · 재등록이 지운 인증 차단
4
141
 
5
142
  헌트에서 확인만 하고 미뤄둔 것 중 **사용자가 갇히는 것**과 **보호가 조용히 꺼지는 것** 두 건을 처리했다. 그리고 첫 수정이 틀려서 되돌린 과정을 함께 적는다 — 그게 이 릴리스에서 가장 중요한 부분이다.
package/README.md CHANGED
@@ -122,7 +122,7 @@ MIT
122
122
  <!-- leerness:project-readme:start -->
123
123
  ## Leerness Project Harness
124
124
 
125
- 이 프로젝트는 Leerness v1.36.94 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.96 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
126
126
 
127
127
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
128
128
 
@@ -176,7 +176,7 @@ leerness memory restore decision <date|title>
176
176
 
177
177
  ### MCP server (외부 AI 통합)
178
178
 
179
- Leerness v1.36.94는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.96는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
180
180
 
181
181
  ```jsonc
182
182
  // 카테고리별
@@ -197,7 +197,7 @@ Leerness v1.36.94는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
197
197
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
198
198
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
199
199
 
200
- 현재 누적: **v1.9.x → 1.36.94 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.96 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
201
201
 
202
202
  ### 성능 가이드
203
203
 
@@ -235,5 +235,5 @@ leerness release pack --close --auto-main-push
235
235
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
236
236
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
237
237
 
238
- Last synced by Leerness v1.36.94: 2026-07-30
238
+ Last synced by Leerness v1.36.96: 2026-08-04
239
239
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
34
34
  const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
35
35
  const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
36
36
 
37
- const VERSION = '1.36.94';
37
+ const VERSION = '1.36.96';
38
38
 
39
39
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
40
40
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -693,6 +693,8 @@ leerness memory restore <surface> <target> # archive → active 복귀 (DELETE
693
693
  3. handoff 가 매 세션 자동으로 24h 내 archive 활동 알림
694
694
 
695
695
 
696
+ ## 자동 회복 · 보안
697
+
696
698
  - session close가 누락되면 다음 세션 시작 시 drift critical 발생.
697
699
  - 자동 회복 옵션: \`drift check --auto-fix\` (critical 시 session close 자동 실행).
698
700
  - handoff가 매 세션 시작 시 **과거 lessons 자동 재상기** (현재 task 키워드 기준).
@@ -704,49 +706,6 @@ leerness memory restore <surface> <target> # archive → active 복귀 (DELETE
704
706
  - handoff Date/Project 직후 통합 헤드라인 한 줄 (drift / 보안 / MCP / skill query / 설치 skill 수).
705
707
  - \`leerness health\` 한 줄로 종합 점검 (drift + 보안 + skills + usage + tasks).
706
708
  - \`leerness drift check --auto-fix\` 가 보안 신호 발견 시 \`audit --fix\` 자동 실행 → 재검사.
707
- - MCP server **18 도구** (handoff/drift/audit/verify_claim/contract/agents/reuse/whats_new/usage_stats/session_close/skill_suggest/lessons/task_export/env_check/brainstorm/skill_match/skill_list/health).
708
- - MCP server **21 도구** (skill_search/skill_info/benchmark 추가).
709
- - \`leerness handoff --json\` (외부 AI/MCP 통합용 구조화 출력).
710
- - \`leerness skill publish\` 보안 사전 점검 통합 (health 통과 후 publish).
711
- - \`leerness handoff --quiet\` (자동화/CI 모드 — 자동 회수 라인 비활성).
712
- - 🏆 마일스톤 — 30 라운드 자율 누적, stress-v45 30/30 PASS, e2e 219/219 PASS.
713
- - \`leerness lazy detect --json\` + MCP **22 도구** (\`leerness_lazy_detect\` 추가 — 거짓 완료/empty handoff/no test run/TODO 미추적 신호 JSON).
714
- - \`leerness audit --json\` 구조화 출력 (findings 11종 kind: design_dup/design_system_default/reuse_map_empty/milestone_unlinked/handoff_not_generated/current_state_stale/readme_version_mismatch/npm_cve/gitignore_missing_secrets/env_keys_missing/strict_promoted). MCP \`leerness_audit\`도 JSON 자동.
715
- - \`leerness session close --json\` 마감 통계 JSON (taskCounts/rules/skillCandidates/drift/topCommands/mcpStats/workspacePeers). MCP \`leerness_session_close\`도 JSON 자동.
716
- - MCP **23 도구** (\`leerness_retro\` 추가 — 4세션 누적 회고 JSON 외부 AI 노출).
717
- - MCP **24 도구** (\`leerness_task_add\` 추가 — 외부 AI 가 task 즉시 등록, 양방향 제어 완성).
718
- - MCP **25 도구** (\`leerness_task_update\` 추가 — task 상태/evidence/nextAction 갱신, read+add+update 3종 surface 완성).
719
- - MCP **26 도구** (\`leerness_task_drop\` 추가 — task 폐기, **task CRUD 완성**: read/add/update/drop).
720
- - \`leerness decision add\` CLI + MCP **27 도구** (\`leerness_decision_add\` — decisions.md 영구화 + handoff lessons 회수와 통합).
721
- - \`leerness rule list --json\` + MCP **29 도구** (\`leerness_rule_add\` + \`leerness_rule_list\` — 자연어 영구 룰 R/W).
722
- - MCP **30 도구 🎉 30 도구 마일스톤** (\`leerness_plan_add\` — plan.md milestone + progress-tracker 자동 동기화).
723
- - MCP **31 도구** (\`leerness_lesson_save\` — lessons.md 직접 write, **Memory Write Surface 5종 완성**: tasks/decisions/rules/plan/lessons).
724
- - handoff 통합 헤드라인에 **🧠 mem T/D/R/P/L 카운트** 추가 — 5종 메모리 영구화 상태 한눈에 확인.
725
- - \`leerness memory status [--json]\` + MCP **32 도구** (\`leerness_memory_status\`) — 상세 상태 + 최근 항목 조회.
726
- - \`leerness handoff --json\` 응답에 **\`memorySurface\` 필드 통합** — 단일 호출로 컨텍스트 + 5종 메모리 상태 동시 회수.
727
- - \`leerness brainstorm\` 회수 범위에 **lessons.md + plan.md** milestone 추가 — Memory Surface 5종 완전 통합.
728
- - \`leerness lesson list [--tag] [--json]\` + MCP **33 도구** (\`leerness_lesson_list\`) — lessons.md 전용 조회 + tag 필터.
729
- - \`leerness decision list [--json]\` + MCP **34 도구** (\`leerness_decision_list\`) — decisions.md 전체 조회 (Decision/Reason/Alternatives/Impact 메타).
730
- - \`leerness plan list [--json]\` + MCP **35 도구** (\`leerness_plan_list\`) — plan.md milestone 전체 (Status/Progress/Tasks). **Memory Surface READ 5종 완전 완성**.
731
- - handoff 6번째 자동 회수 \`🆕 최근 24h 메모리 변동\` — 5종 surface 의 24h 내 추가 항목 자동 노출.
732
- - \`session close --json\` 응답에도 \`memorySurface\` 필드 통합 — 마감 시 5종 메모리 상태 동시 회수.
733
- - \`health --json\` 응답에도 \`memorySurface\` 필드 통합 — handoff/session close/memory status 모든 JSON 명령 일관성.
734
- - \`leerness lesson drop <target>\` + MCP **36 도구** (\`leerness_lesson_drop\`) — 잘못 저장한 lesson 제거 (archive 자동 보존).
735
- - \`leerness decision drop <target>\` + MCP **37 도구** (\`leerness_decision_drop\`) — 잘못 저장한 결정 제거 (archive 보존).
736
- - \`leerness plan remove <M-XXXX|title>\` + MCP **38 도구** (\`leerness_plan_remove\`) — milestone 영구 제거 (archive 보존). **Memory Surface DELETE 5종 완전 완성** 🎉.
737
- - \`leerness memory archive list [--surface decisions|lessons|plan] [--json]\` + MCP **39 도구** (\`leerness_memory_archive_list\`) — DELETE 5종 archive 통합 조회 (복원 후보 회수).
738
- - \`leerness memory restore <surface> <target>\` + MCP **40 도구 🎉** (\`leerness_memory_restore\`) — archive → active 복귀 (DELETE→RESTORE cycle 완성). **MCP 40 도구 마일스톤**.
739
- - handoff **7번째 자동 회수** — \`🗑 최근 24h archive\` (D/L/P 카운트 + 복원 후보 안내). DELETE 활동 자동 인지.
740
- - 🎉 **60 라운드 자율 모드 마일스톤** — JSON 4종 (handoff/memory status/session close/health) \`memorySurface.archive\` 필드 통합. MCP 40 / handoff auto-recovery 7 / DELETE-RESTORE cycle 완성.
741
- - \`brainstorm\` 회수 범위에 3 archive 파일 (decisions/lessons/plan archive) 통합 — 과거 제거된 ideas 가 새 brainstorm 시 다시 후보로 노출. \`hits.archive\` 필드 + 복원 안내 라인.
742
- - \`session close\` 텍스트 모드에 archive 누적 라인 추가 — 마감 시점 DELETE 활동 가시화 (handoff 7번째 회수와 symmetric). archive 가시성 6 surface 완성.
743
- - \`brainstorm\` 텍스트 모드 lessonsExplicit / planMilestones display 추가 — 데이터 수집은 됐지만 display 가 누락돼 있던 gap 보완 fix.
744
- - \`leerness task list --json\` + MCP **41 도구** (\`leerness_task_list\`) — progress-tracker.md task JSON 조회 + \`--status\` 필터 (1.36.72 부터 기본 최신 100행, 전체는 \`--all\`). Task surface CRUD MCP 완전 완성 (add/list/update/drop).
745
- - MCP **42 도구** (\`leerness_rule_remove\`) — rules.md 에서 특정 rule 제거 + archive 보존. **5 surface CRUD MCP 완전 완성** (task/decision/lesson/plan/rule 모두 add/list/delete MCP 노출).
746
- - MCP \`leerness_drift_check\` JSON 응답 fix — \`--json\` 플래그 자동 추가하여 외부 AI가 구조화된 drift 신호 회수 (score, level, signals[], healthy).
747
- - \`.harness/session-workflow.md\` 템플릿에 **🧠 Memory CRUD Quick Reference** 섹션 추가 — 5 surface × CRUD 매트릭스 + archive cycle 워크플로 가이드. 신규 \`init\` 워크스페이스 즉시 적용.
748
- - \`leerness memory archive list --query <keyword>\` + MCP \`leerness_memory_archive_list\` query 인자 — archive 항목 키워드 case-insensitive 검색 (target/originalHeader 매칭).
749
- - \`leerness lesson list --query\` + \`leerness decision list --query\` + MCP 동일 인자 — active Memory 항목 키워드 검색 (lesson: text/tag, decision: title/decision/reason/alternatives/impact).
750
709
 
751
710
  ---
752
711
 
@@ -11589,18 +11548,34 @@ function handoff(root) {
11589
11548
  version: VERSION,
11590
11549
  files: {}
11591
11550
  };
11592
- function _addFile(key, p) {
11593
- if (exists(p)) {
11594
- const content = read(p);
11595
- result.files[key] = { path: rel(root, p), content: content.length > 8000 ? content.slice(0, 8000) + '\n…(truncated)' : content };
11596
- }
11551
+ // 1.36.95: 같은 명령의 두 표면이 **반대 방향**으로 잘랐다 — 평문은 최근 N줄, JSON 은 앞 8000자.
11552
+ // 외부 AI/MCP 용이라고 문서화된 JSON 소비자가 가장 오래된 결정만 받았다(실측: 15MB decisions.md 에서
11553
+ // 최신 결정 미포함 · AEGIRINE task-log 529,747자 중 앞 1.5%). 누적 로그는 **뒤가 최신**이므로 뒤를 남긴다.
11554
+ function _addFile(key, p, tailFirst) {
11555
+ if (!exists(p)) return;
11556
+ const content = read(p);
11557
+ if (content.length <= 8000) { result.files[key] = { path: rel(root, p), content }; return; }
11558
+ // **코드포인트 경계**에서 자른다 — 문자 인덱스 slice 는 서로게이트 쌍을 쪼개 JSON 에 단독 surrogate 를
11559
+ // 남긴다(검수 실측: 0xDE00). 일부 소비자는 그걸 거부하거나 U+FFFD 로 바꾼다.
11560
+ const _safe = (s, n, fromEnd) => {
11561
+ const cps = Array.from(s); let len = 0, k = 0;
11562
+ if (fromEnd) { for (let i = cps.length - 1; i >= 0 && len + cps[i].length <= n; i--) { len += cps[i].length; k++; } return cps.slice(cps.length - k).join(''); }
11563
+ for (let i = 0; i < cps.length && len + cps[i].length <= n; i++) { len += cps[i].length; k++; }
11564
+ return cps.slice(0, k).join('');
11565
+ };
11566
+ // 마커를 상한 **바깥**에 붙이면 content 가 8,013자가 되어 8,000자를 예산으로 잡은 소비자가 넘긴다
11567
+ // (검수 실측). 마커까지 포함해 8,000자를 넘지 않게 한다.
11568
+ const MARK = '\n…(truncated)';
11569
+ const budget = 8000 - MARK.length;
11570
+ const cut = tailFirst ? MARK.trimStart() + '\n' + _safe(content, budget, true) : _safe(content, budget, false) + MARK;
11571
+ result.files[key] = { path: rel(root, p), content: cut, truncated: true, totalChars: content.length, kept: tailFirst ? 'tail' : 'head' };
11597
11572
  }
11598
11573
  _addFile('sessionHandoff', handoffPath(root));
11599
11574
  _addFile('currentState', currentStatePath(root));
11600
11575
  _addFile('plan', planPath(root));
11601
- _addFile('progressTracker', progressPath(root));
11602
- _addFile('decisions', decisionsPath(root));
11603
- _addFile('taskLog', taskLogPath(root));
11576
+ _addFile('progressTracker', progressPath(root), true);
11577
+ _addFile('decisions', decisionsPath(root), true);
11578
+ _addFile('taskLog', taskLogPath(root), true);
11604
11579
  // active rules
11605
11580
  const activeRules = readRules(root).filter(r => r.status === 'active');
11606
11581
  if (activeRules.length) result.activeRules = activeRules.map(r => ({ id: r.id, trigger: r.trigger, rule: r.rule }));
@@ -11833,17 +11808,54 @@ function handoff(root) {
11833
11808
  return;
11834
11809
  }
11835
11810
  const sections = [];
11836
- function block(label, p) {
11811
+ // 1.36.95: 라벨과 동작이 어긋나 있었다 — `last 40 lines` 라고 써 놓고 파일 전체를 넣은 뒤
11812
+ // **앞** 4000자를 잘랐다(단위도 줄↔문자로, 방향도 마지막↔처음으로 반대). 결정/작업로그는
11813
+ // 최근 항목이 쓸모 있으므로 라벨대로 **뒤에서** N줄을 취하고, 안전 상한도 뒤를 남긴다.
11814
+ // **헤더는 절대 잘리지 않는다.** 1.36.95 초안은 섹션 전체를 `slice(-4000)` 해서 `=== Decisions … ===`
11815
+ // 헤더가 1순위로 잘려 나갔고(헤더가 문자열 맨 앞에 있다), 그 결과 결정 내용이 앞 섹션 꼬리에 붙어
11816
+ // AI 가 소속을 오독했다(실사용 50개 중 4개 발화). 상한은 **본문에만** 적용한다.
11817
+ // 그리고 잘릴 때도 라벨이 사실이어야 한다 — 줄 경계에서 자르고 실제 표시 줄 수를 함께 적는다
11818
+ // (종전엔 `last 40 lines` 라면서 25줄을 문장 중간에서 잘라 보여줬다).
11819
+ const BODY_CAP = 4000;
11820
+ function block(label, p, tailLines) {
11837
11821
  if (!exists(p)) return;
11838
- sections.push(`\n=== ${label} (${rel(root,p)}) ===\n${read(p).trim()}`);
11822
+ const body = read(p).trim();
11823
+ // 빈 파일은 `''.split('\n') === ['']` 이라 종전엔 `1/1 줄` 로 표시됐다(라벨이 거짓).
11824
+ let ls = body ? body.split('\n') : [];
11825
+ const total = ls.length;
11826
+ if (tailLines && ls.length > tailLines) ls = ls.slice(-tailLines);
11827
+ let truncated = false;
11828
+ // 본문 상한은 **줄 단위**로 채운다. 매 반복마다 join() 하면 O(n²) 다 — 검수 실측으로 48,000줄에서
11829
+ // 30.7초가 걸렸다(입력 2배마다 4배). 누적 길이를 한 번에 계산해 자를 지점만 찾는다(선형).
11830
+ if (ls.length) {
11831
+ // 구분자(\n)는 **줄 사이에만** 있다 — 마지막 줄에도 +1 을 더하면 정확히 상한인 본문을 초과로 오판해
11832
+ // 절반을 버린다(검수 실측: 4,000자 2줄이 `1/2 줄 (상한으로 잘림)` 이 됐다).
11833
+ let acc = 0, keep = 0;
11834
+ const fits = (i) => acc + (keep ? 1 : 0) + ls[i].length <= BODY_CAP;
11835
+ if (tailLines) { for (let i = ls.length - 1; i >= 0; i--) { if (!fits(i) && keep > 0) break; acc += (keep ? 1 : 0) + ls[i].length; keep++; } if (keep < ls.length) { ls = ls.slice(-keep); truncated = true; } }
11836
+ else { for (let i = 0; i < ls.length; i++) { if (!fits(i) && keep > 0) break; acc += (keep ? 1 : 0) + ls[i].length; keep++; } if (keep < ls.length) { ls = ls.slice(0, keep); truncated = true; } }
11837
+ }
11838
+ // 한 줄이 상한보다 길면 그 줄만 자른다 — **코드포인트 경계**에서 자른다.
11839
+ // 문자 인덱스로 자르면 서로게이트 쌍이 쪼개져 U+FFFD 나 단독 surrogate 가 남는다(검수 실측).
11840
+ if (ls.length === 1 && ls[0].length > BODY_CAP) {
11841
+ const cp = Array.from(ls[0]);
11842
+ let n = 0, len = 0;
11843
+ if (tailLines) { for (let i = cp.length - 1; i >= 0 && len + cp[i].length <= BODY_CAP; i--) { len += cp[i].length; n++; } ls = [cp.slice(cp.length - n).join('')]; }
11844
+ else { for (let i = 0; i < cp.length && len + cp[i].length <= BODY_CAP; i++) { len += cp[i].length; n++; } ls = [cp.slice(0, n).join('')]; }
11845
+ truncated = true;
11846
+ }
11847
+ const note = (tailLines || truncated)
11848
+ ? ` — ${ls.length}/${total} 줄${truncated ? ' (상한으로 잘림)' : ''}`
11849
+ : '';
11850
+ sections.push(`\n=== ${label}${note} (${rel(root,p)}) ===\n${ls.join('\n')}`);
11839
11851
  }
11840
11852
  block('Session Handoff', handoffPath(root));
11841
11853
  block('Current State', currentStatePath(root));
11842
11854
  block('Plan', planPath(root));
11843
11855
  block('Progress Tracker', progressPath(root));
11844
- block('Decisions (last 40 lines)', decisionsPath(root));
11845
- block('Task Log (last 60 lines)', taskLogPath(root));
11846
- const out = sections.map(s => s.length <= 4000 ? s : s.slice(0, 4000) + '\n…(truncated)').join('\n');
11856
+ block('Decisions (last 40 lines)', decisionsPath(root), 40);
11857
+ block('Task Log (last 60 lines)', taskLogPath(root), 60);
11858
+ const out = sections.join('\n');
11847
11859
  log('# Session Start Context');
11848
11860
  log(`Date: ${today()}`);
11849
11861
  log(`Project: ${detectProjectName(root)}`);