leerness 1.36.94 → 1.36.95

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,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.95 — 2026-07-31 — AI 에게 매 세션 읽히던 파일의 절반이 틀린 과거 수치였다
4
+
5
+ 사용자 질문에서 시작했다 — "Opus 5 처럼 모델에 검증이 내장돼 있으면 하네스가 검증을 또 시키는 건 낭비 아닌가. 구동 모델을 인지해 맞춤으로 알려줄 수 있나?" 조사 결과 **모델 인지는 만들지 않기로 했고**(아래), 대신 그 조사가 훨씬 큰 것을 드러냈다.
6
+
7
+ **출하 중인 한글 `session-workflow.md` 템플릿 10,308자 중 5,216자(50.6%)가 낡은 릴리스 변경이력이었다.** 헤딩도 없이 Memory CRUD 섹션 아래 붙어 있었고, 내용은 MCP 도구 수를 18 → 42 로 세어 올라가는 로그였다 — **실제 도구 수는 89 다**. 이 파일은 AGENTS.md 의 필독 1번이고 CLAUDE.md 가 "⭐ 매 세션 첫 행동"으로 가리키는 파일이다. AI 가 매 세션 읽도록 설계된 문서의 절반이 틀린 과거 수치였다. 영문 템플릿은 **이미 깨끗했다**(`## Auto-recovery & security` 로 정리돼 있었다) — 한쪽 표면만 썩어 있었고 아무도 비교하지 않았다.
8
+
9
+ - 이력 43줄을 제거하고, 남긴 운영 사실 11줄에는 영문과 대응하는 `## 자동 회복 · 보안` 헤딩을 줬다. 실측: 한글 템플릿 **10,308자 → 5,107자**, 영문 5,075자 — 두 언어 크기가 맞았다.
10
+ - e2e 가 **도구 수 주장 자체가 0 인지**를 양쪽 언어에서 단언한다. 개수를 고쳐 적는 것은 다음 릴리스에 또 낡는다. (초안의 정규식 `\d+\s*도구\b` 는 **한글 뒤 `\b` 가 절대 매치되지 않아** 아무것도 세지 않는 공허 단언이었다 — 변이 테스트가 잡았고, 그 함정은 이미 내 메모리에 적혀 있었는데도 다시 밟았다.)
11
+
12
+ **기존 설치본의 이력까지 지우는 기능은 이 릴리스에서 뺐다.** 템플릿에서 줄을 지우면 머지(`_managedMerge`)가 그것을 "사용자 커스텀"으로 보고 `## Preserved previous content` 로 이월하므로, 원래는 기존 설치본의 이력도 함께 처리하려 했다. 세 가지 설계를 시도했고 전부 사용자 데이터를 지웠다:
13
+
14
+ 1. **휴리스틱**(버전 접두 + 릴리스노트 표지) — 마크다운을 쓰는 사용자 문장을 삭제했다(한국어 코퍼스 34/34, 다국어 5/5). 백틱 하나가 보존을 삭제로 뒤집었다. 내가 댄 근거 "실제 설치본 21개에서 오탐 0건"은 **그 21개가 전부 작성자 본인의 도그푸딩 프로젝트라 제3자 산문이 표본에 없어서** 무효였다.
15
+ 2. **기계 생성 정확 일치 목록** — 오탐 면적은 크게 줄었지만 `trim` 비교가 들여쓴 인용을 지웠고, 들여쓰기를 배제하자 이번엔 우리가 2칸 들여쓴 채 내보낸 줄을 못 지웠으며(설치본 21/21 · 42회), `raw === l` 비교는 **CRLF 파일에서 `\r` 때문에 121/121 을 전부 누출**시켰다(Windows 가 주 대상인데 기능 전체가 죽는다).
16
+ 3. **원문 그대로 비교** — 여기까지 와서도 `<details>` 가 파일 어디에든 있으면 무관한 줄의 리터럴 펜스가 변형됐고, 템플릿이 들여쓴 줄을 사용자가 안 들여쓰면 삭제됐다.
17
+
18
+ **근본 원인은 하나다: 문자열로는 "우리가 내보낸 줄"과 "사용자가 그 줄을 적은 것"을 구분할 수 없다 — 바이트가 같다.** 규칙을 정교하게 만들수록 한쪽을 고치면서 다른 쪽이 깨졌다. 진짜 해결은 생성 영역 마커(provenance)이고, 그건 `_managedMerge` 재설계라 별도 라운드로 다룬다.
19
+
20
+ **그리고 "삭제하지 않는다"를 실제로 참으로 만들기 위해 기존 필터 하나를 걷어냈다.** 범위를 좁힌 뒤에도 검수가 반증했다 — 템플릿에서 이력 43줄을 지우면 그 줄들이 **처음으로 머지 경로를 타게 되고**, 1.36.60 이 넣어 둔 자동생성 이력 필터(`- .*MCP **N 도구` 등)가 그때 발화한다. 실측: 설치본 21개의 이력 731줄 중 **346줄 삭제**, 그리고 사용자 문장 `- 사용자 메모: MCP **77 도구** 는 우리 내부 표현이며 삭제 금지` 도 함께 사라졌다. 원래 주석은 "사용자 산문이 이 패턴과 겹칠 확률은 무시 가능"이라고 적고 있었다. 그 필터를 제거했다 — 이력 줄은 삭제되지 않고 `Preserved` 로 이월된다.
21
+
22
+ - 실측(실제 설치본 21개, 진짜 `_managedMerge`): **유실 고유 줄 0**. 검수가 만든 사용자 문장 반례 5종 전부 보존. 이력 줄은 이월(삭제 아님). 멱등. **검수가 독립 재현으로 확인했다** — 21/21 유실 0, 같은 입력에 1.36.94 코어를 쓰면 정확히 346줄 유실.
23
+ - 대가는 **과보존**이다 — 기존 설치본의 `session-workflow.md` 는 줄지 않고, 이력이 `Preserved` 블록으로 자리를 옮긴다. 실측: 설치본 21개 합계 203,408 → 245,239자(**+20.6%**), 파일별 1.03~2.17배. 멱등이라 반복 성장은 없고 언어 전환(ko↔en)·다른 관리 파일에도 부작용이 없다. 이 파일의 계약(false-DROP 이 버그, false-PRESERVE 는 무해)에 맞는 방향이고, 실제 정리는 provenance 라운드에서 한다.
24
+ - 즉 이 릴리스의 효과는 **신규 `init` 은 즉시 깨끗해지고, 기존 설치본은 데이터를 잃지 않는다** 까지다.
25
+
26
+ **`handoff` 의 라벨과 동작이 어긋나 있었다.** `Decisions (last 40 lines)` 라고 써 놓고 파일 전체를 넣은 뒤 **앞** 4,000자를 잘랐다 — 단위(줄↔문자)도, 방향(마지막↔처음)도 반대였다. 라벨대로 뒤에서 N줄을 취한다. 이 한 줄짜리 수정이 **세 번의 회귀**를 거쳤고 전부 검수가 실행으로 잡았다:
27
+
28
+ - 1차: 섹션 전체를 `slice(-4000)` 했더니 `=== Decisions … ===` **헤더가 1순위로 잘려** 결정 내용이 앞 섹션 꼬리에 붙었다(실사용 50개 중 4개 발화). 상한은 **본문에만** 적용하고 헤더는 불가침으로 바꿨다.
29
+ - 2차: 줄 단위 절단을 매 반복 `join()` 으로 구현해 **O(n²)** 가 됐다(48,000줄에서 30초대, 입력 2배마다 4배). 누적 길이를 한 번에 계산하도록 고쳤다.
30
+ - 3차: 한 줄이 상한보다 길 때 문자 인덱스로 잘라 서로게이트 쌍이 쪼개졌고(평문 U+FFFD · JSON 단독 surrogate `0xDE00`), 빈 파일을 `1/1 줄` 이라 표시했고, 정확히 4,000자인 본문을 초과로 오판해 절반을 버렸다(마지막 줄에도 개행을 셌다).
31
+
32
+ - 잘릴 때도 라벨이 사실이어야 한다 — `25/121 줄 (상한으로 잘림)` 처럼 실제 표시 줄 수를 함께 적고 **줄 경계**에서 자른다.
33
+ - **`handoff --json` 은 같은 내용을 앞 8,000자로 잘랐다** — 같은 명령의 두 표면이 반대 방향이었고, 외부 AI/MCP 용이라고 문서화된 쪽이 가장 오래된 결정만 받았다(15MB `decisions.md` 에서 최신 미포함). 누적 로그(progress-tracker/decisions/task-log)는 뒤를 남기고, 그 외는 앞을 남긴다. `truncated`·`totalChars`·`kept` 를 함께 준다. 마커까지 포함해 8,000자를 넘지 않는다.
34
+ - 실측: 결정 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초). "입력과 무관하다"는 뜻이 아니다.
35
+
36
+ **모델 자동 인지는 만들지 않는다.** 실측 근거: 환경변수 전수(`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건). 문자수 감소만 실측으로 적는다.
37
+
38
+ **마지막으로 e2e 자신이 "삭제 안 됨"만 보고 "적용은 됐나"를 안 봤다.** 검수가 살려낸 변이 — 새 템플릿을 쓰지 않고 **이전 파일 뒤에 `Preserved` 헤더만 덧붙이는** 머지 — 가 무삭제 단언 3종을 전부 통과했다. 그러면 기존 설치본은 낡은 이력을 계속 **활성 본문**으로 읽는데 CI 는 초록이다. 부작용(삭제)은 봤지만 목적(교체)은 안 본 단언이었다. 관리 영역이 새 템플릿과 일치하고 옛 줄은 태그 뒤에만 있는지 단언한다. 그 단언조차 처음엔 정상 코드에서 실패했다 — 태그 앞에 붙는 구분선 `---` 을 반영하지 않았고, **제안받은 코드도 실행하기 전엔 맞는지 모른다**는 걸 30초 만에 확인했다.
39
+
40
+ **이 라운드에서 배운 것.** 외부 검수가 11회 차단했고 매번 실제 결함이었다. 마지막 다섯 번은 전부 **내 직전 수정이 만든 새 결함**이었고, 매번 내 자체 e2e 는 통과했다 — 내가 만든 픽스처는 내가 상상한 시나리오만 담기 때문이다. 검수에 **실제 설치본 21개를 함께 넘기자** "재현 못 함 → 미확인"이던 항목들이 "네 수치가 틀렸다"로 바뀌었다(설치본 잔존 0 → 실제 2/21, 코퍼스 121개 → 실제로는 빈 슬롯 포함 122). 그리고 마지막에는 **검수에게 출하 여부를 직접 물었다** — 내 "이번엔 다 잡았다"가 아홉 번 틀린 뒤였기 때문이고, 답은 "삭제 기능을 빼고 나머지만 내보내라"였다.
41
+
42
+ - 검증: e2e **415/415** · 회귀 가드 **변이 22종 22/22 사살**(옛 이력 필터 부활 · 이력 줄 삭제 · 새 템플릿 미적용 · 한쪽 언어만 정리 · 대문자 `**99 Tools**` · handoff 헤더 절단/O(n²) 복귀/서로게이트 분할/빈 파일 `1/1`/정확 상한 오판/Task Log 59 · `--json` 방향 뒤집기·`totalChars` 위조·마커 예산 이탈). 설치본 21개 실측 유실 0 은 **검수가 독립 재현**했고, CHANGELOG 의 "실측:" 문장은 전수 검증에서 정정 없이 통과했다.
43
+
3
44
  ## 1.36.94 — 2026-07-30 — 제품이 안내한 탈출구가 막다른 길이었다 · 재등록이 지운 인증 차단
4
45
 
5
46
  헌트에서 확인만 하고 미뤄둔 것 중 **사용자가 갇히는 것**과 **보호가 조용히 꺼지는 것** 두 건을 처리했다. 그리고 첫 수정이 틀려서 되돌린 과정을 함께 적는다 — 그게 이 릴리스에서 가장 중요한 부분이다.
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.95 하네스를 사용합니다. 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.95는 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.95 릴리스 태그 이력** (수백 라운드) · _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.95: 2026-07-31
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.95';
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)}`);
package/lib/pure-utils.js CHANGED
@@ -859,9 +859,15 @@ function _managedMerge(file, next, previous, archiveRel, overwriteSet, opts = {}
859
859
  if (!l || nextLines.has(l)) continue;
860
860
  const _ac = altCounts.get(l);
861
861
  if (_ac > 0) { altCounts.set(l, _ac - 1); continue; } // canonical 출현분만 소비 (복제 커스텀 생존)
862
- // 1.36.60 (검수 2차 #3): 구버전 workflow 자동생성 이력 로그(도구-카운트/마일스톤 불릿)는 커스텀이 아니라
863
- // 릴리스마다 낡는 생성물 — 좁은 시그니처만 필터 (사용자 산문이 이 패턴과 겹칠 확률은 무시 가능)
864
- if (String(file).includes('session-workflow') && (/^- .*MCP \*\*\d+\s*도구/.test(l) || /^- MCP \*\*\d+/.test(l) || /^- 🏆 마일스톤/.test(l) || /^- 🎉 \*\*\d+ 라운드/.test(l))) continue;
862
+ // 1.36.60 의 자동생성 이력 필터(`- .*MCP **N 도구` · `- MCP **N` · `- 🏆 마일스톤` · `- 🎉 **N 라운드`)는
863
+ // 1.36.95 에서 **삭제했다**. "사용자 산문이 이 패턴과 겹칠 확률은 무시 가능"이라고 적혀 있었지만
864
+ // 검수가 겹치는 실제 문장으로 삭제를 재현했다:
865
+ // `- 프로젝트 정책: MCP **3 도구는 별도 승인 후 사용한다` · `- 🏆 마일스톤은 고객 승인 뒤에만 확정한다`
866
+ // 이번 라운드가 템플릿에서 이력 43줄을 지우면서 그 줄들이 처음으로 이 경로를 타게 됐고,
867
+ // 실측에서 설치본 21개의 이력 731줄 중 **346줄이 삭제**됐으며 사용자 문장
868
+ // `- 사용자 메모: MCP **77 도구** 는 우리 내부 표현이며 삭제 금지` 도 함께 사라졌다.
869
+ // 이 릴리스는 "삭제하지 않는 변경만" 담기로 했으므로 필터를 걷어낸다 — 이력 줄은 Preserved 로 이월된다.
870
+ // 과보존은 이 파일의 계약상 무해하고(false-DROP 이 버그), 기존 설치본 정리는 provenance 라운드로 미룬다.
865
871
  // preserved 래퍼 자체의 보일러플레이트는 이월 대상 아님 (구 <details> 형식 + 신 평문 형식 + en 래퍼 모두)
866
872
  if (l === tag || l === '---' || l === '<details>' || l === '</details>') continue;
867
873
  if (/^```/.test(l) || /^<summary>/.test(l)) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.94",
3
+ "version": "1.36.95",
4
4
  "description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -8169,6 +8169,163 @@ total++;
8169
8169
  if (!ok) failed++;
8170
8170
  }
8171
8171
 
8172
+ // 1.36.95 (P-0009 A · 부채 상환 — 범위 축소): AI 가 매 세션 읽도록 지시받는 파일의 절반이 낡은 릴리스 이력이었다.
8173
+ // 한글 session-workflow 템플릿 10,308자 중 5,216자(50.6%)가 MCP 도구 수를 18→42 로 세는 로그였고
8174
+ // 실제 도구 수는 89 다. 영문 템플릿은 이미 깨끗했다(표면 불일치).
8175
+ // **기존 설치본의 이력까지 지우는 기능은 이 릴리스에서 뺐다.** 정확 일치 목록으로 시도했지만
8176
+ // 검수가 9회에 걸쳐 사용자 데이터 삭제/변형 반례를 계속 찾아냈다 — 문자열로는 "우리가 내보낸 줄"과
8177
+ // "사용자가 그 줄을 적은 것"을 구분할 수 없기 때문이다(바이트가 같다). 근본 해결은 생성 영역 마커
8178
+ // (provenance)이고 별도 라운드로 다룬다. 여기서는 **삭제하지 않는 변경만** 출하한다.
8179
+ total++;
8180
+ {
8181
+ let ok = false;
8182
+ const _d = [];
8183
+ let T1 = false, T2 = false, T3 = false;
8184
+ const dbg = {};
8185
+ try {
8186
+ const R = (a) => { const r = cp.spawnSync(process.execPath, [CLI, ...a], { encoding: 'utf8', timeout: 180000 }); return { s: r.status, o: ((r.stdout || '') + (r.stderr || '')).trim() }; };
8187
+ const mkTpl = (lang) => {
8188
+ const w = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-tpl95-')); _d.push(w);
8189
+ R(['init', w, '--yes', '--language', lang, '--no-stale-check']);
8190
+ return fs.readFileSync(path.join(w, '.harness', 'session-workflow.md'), 'utf8');
8191
+ };
8192
+ const ko = mkTpl('ko'), en = mkTpl('en');
8193
+
8194
+ // ── T1: 출하 템플릿에 낡은 릴리스 이력이 없다(양쪽 언어).
8195
+ // 개수를 고쳐 적는 것은 다음 릴리스에 또 낡는다 — **수치 주장 자체가 0** 인지 단언한다.
8196
+ // `\b` 를 한글 뒤에 붙이면 절대 매치되지 않으므로 쓰지 않는다(초안이 그래서 아무것도 세지 않았다).
8197
+ // 조사/단위(`23개 도구`)가 끼어도 세야 한다.
8198
+ // 영문은 `Tools` 처럼 대문자로도 쓴다 — 대소문자를 구분하면 `**99 Tools**` 변이가 그대로 통과한다(검수 실측).
8199
+ const staleCount = (s) => (s.match(/\d+\s*(?:개|여|여개)?\s*(?:도구|tools)/gi) || []).length;
8200
+ dbg.stale = staleCount(ko) + '/' + staleCount(en);
8201
+ dbg.size = ko.length + '/' + en.length;
8202
+ T1 = staleCount(ko) === 0 && staleCount(en) === 0
8203
+ && !/MCP server/.test(ko) && !/마일스톤/.test(ko)
8204
+ // 본문 계약은 그대로 — 지운 것은 이력뿐이다(대조: 6단계 구조와 운영 사실이 살아 있어야 한다)
8205
+ && /## Step 6/.test(ko) && /## 자동 회복/.test(ko) && /session close가 누락되면/.test(ko)
8206
+ && /## Step 6/.test(en) && /Auto-recovery/.test(en)
8207
+ // 두 언어의 크기가 비슷해야 한다 — 한쪽만 이력을 이고 있던 상태가 재발하면 여기서 걸린다
8208
+ && Math.abs(ko.length - en.length) < 1500;
8209
+
8210
+ // ── T3: 이 릴리스는 **삭제하지 않는다**. 템플릿에서 이력을 지우면 그 줄들이 처음으로 머지 경로를 타게 되고,
8211
+ // 1.36.60 이 넣어 둔 자동생성 이력 필터가 그때 발화해 **간접적으로** 삭제가 일어났다(검수 실측:
8212
+ // 설치본 21개의 이력 731줄 중 346줄 삭제 + 사용자 문장 유실). 그 필터를 걷어냈고, 여기서 못 박는다.
8213
+ const PU = require(path.join(path.dirname(CLI), '..', 'lib', 'pure-utils.js'));
8214
+ const MG = (prev) => PU._managedMerge('.harness/session-workflow.md', ko, prev, '.harness/archive', new Set(), { lang: 'ko' });
8215
+ // (a) 옛 필터의 시그니처와 겹치는 **사용자 문장** — 필터가 살아 있으면 삭제된다
8216
+ const userProse = [
8217
+ '- 사용자 메모: MCP **77 도구** 는 우리 내부 표현이며 삭제 금지',
8218
+ '- 프로젝트 정책: MCP **3 도구는 별도 승인 후 사용한다',
8219
+ '- 🏆 마일스톤은 고객 승인 뒤에만 확정한다',
8220
+ '- 🎉 **2 라운드 인터뷰 후 가격을 결정한다',
8221
+ '- MCP **7명의 담당자만 운영 배포를 승인한다',
8222
+ ];
8223
+ const mProse = MG(userProse.join('\n') + '\n');
8224
+ const proseOk = userProse.every(s => mProse.includes(s));
8225
+ // (b) 우리 이력 줄도 **삭제가 아니라 이월**이어야 한다(과보존이 이 파일의 계약이다)
8226
+ const hist = ['- 1.9.86+ MCP server **18 도구** (handoff/drift)', '- 1.9.100 🏆 마일스톤 — 30 라운드 자율 누적'];
8227
+ const mHist = MG(hist.join('\n') + '\n');
8228
+ const carryOk = hist.every(s => mHist.includes(s)) && /Preserved previous content/.test(mHist);
8229
+ // (c) 멱등
8230
+ const idemOk = MG(mHist) === MG(MG(mHist));
8231
+ // (d) **새 템플릿이 실제로 적용**돼야 한다 — 위 셋만 보면 "이전 파일을 그대로 두고 Preserved 헤더만
8232
+ // 덧붙이는" 머지도 통과한다(검수가 그 변이를 살렸다). 그러면 기존 설치본이 낡은 이력을 계속
8233
+ // **활성 본문**으로 읽는데 CI 는 초록이다. 관리 영역이 새 템플릿과 같은지, 옛 줄은 태그 뒤에 있는지 본다.
8234
+ const _tag = '<!-- leerness:migration-preserved -->';
8235
+ const _cut = mHist.indexOf(_tag);
8236
+ // 태그 앞에는 구분선(`---`)이 붙는다 — 그대로 비교하면 정상 코드에서도 실패한다(실제로 겪었다).
8237
+ const appliedOk = _cut >= 0
8238
+ && mHist.slice(0, _cut).replace(/\n*-{3,}\n*$/, '').trim() === ko.trim()
8239
+ && hist.every(s => mHist.slice(_cut).includes(s));
8240
+ dbg.nodel = `산문 ${proseOk} 이월 ${carryOk} 멱등 ${idemOk} 템플릿적용 ${appliedOk}`;
8241
+ T3 = proseOk && carryOk && idemOk && appliedOk;
8242
+
8243
+ // ── T2: handoff 의 `last N lines` 라벨이 동작과 일치한다.
8244
+ // 종전엔 파일 전체를 넣고 **앞** 4000자를 잘랐다(단위도 방향도 반대).
8245
+ // 수정 과정에서 세 번의 회귀가 있었고(헤더 잡아먹음 · O(n²) · 유니코드 파손) 전부 여기서 잡는다.
8246
+ const w5 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-hd95-')); _d.push(w5);
8247
+ R(['init', w5, '--yes', '--language', 'ko', '--no-stale-check']);
8248
+ const decPath = path.join(w5, '.harness', 'decisions.md');
8249
+ const mkDec = (n, pad) => '# Decisions\n' + Array.from({ length: n }, (_, i) => `- 결정 ${String(i + 1).padStart(3, '0')} ` + 'x'.repeat(pad)).join('\n') + '\n';
8250
+ const seg = (o) => o.slice(o.indexOf('=== Decisions'), o.indexOf('=== Task Log') > 0 ? o.indexOf('=== Task Log') : undefined);
8251
+ // (a) 마지막 40줄이 **끊김 없이** 나온다 — 양 끝만 보면 중간 유실을 놓친다.
8252
+ fs.writeFileSync(decPath, mkDec(120, 4));
8253
+ const sA = seg(R(['handoff', w5]).o);
8254
+ const idsA = (sA.match(/- 결정 (\d{3})/g) || []).map(x => Number(x.slice(-3)));
8255
+ const contiguous = idsA.length === 40 && idsA[0] === 81 && idsA[39] === 120 && idsA.every((v, i) => i === 0 || v === idsA[i - 1] + 1);
8256
+ // (b) 상한이 걸려도 **헤더는 잘리지 않고** 라벨의 표시 줄 수가 실제와 같아야 한다.
8257
+ // 표시 줄 수만 맞추면 "모든 줄 길이를 1로 계산" 같은 변이가 산다 — 렌더된 본문의 실제 문자 수를 잰다.
8258
+ fs.writeFileSync(decPath, mkDec(120, 150));
8259
+ const sB = seg(R(['handoff', w5]).o);
8260
+ const mB = sB.match(/(\d+)\/(\d+) 줄/);
8261
+ const cntB = (sB.match(/- 결정 \d{3}/g) || []).length;
8262
+ const bodyB = sB.slice(sB.indexOf('===\n') + 4).replace(/\n=== [\s\S]*$/, '').trim();
8263
+ const capOk = /=== Decisions \(last 40 lines\)/.test(sB) && !!mB && Number(mB[1]) === cntB && cntB > 0 && /결정 120/.test(sB)
8264
+ && bodyB.length <= 4000 && bodyB.length > 3000;
8265
+ // (c) 정확히 상한인 본문을 초과로 오판하면 안 된다 — 마지막 줄에도 개행을 세면 절반을 버린다.
8266
+ // 합이 **정확히 4,000자**여야 판별된다(3,999 면 버그 구현도 통과). 2000 + 구분자 1 + 1999.
8267
+ fs.writeFileSync(decPath, 'z'.repeat(2000) + '\n' + 'z'.repeat(1999));
8268
+ const exactSeg = seg(R(['handoff', w5]).o);
8269
+ const exactOk = /2\/2 줄/.test(exactSeg) && !/상한으로 잘림/.test(exactSeg);
8270
+ // (d) 한 줄이 상한을 넘으면 **코드포인트 경계**에서 잘라야 한다. 경계가 이모지 안쪽에 오도록 구성한다.
8271
+ fs.writeFileSync(decPath, '# D\n- ' + 'y'.repeat(300) + '😀' + 'x'.repeat(3999) + '\n');
8272
+ const oU = R(['handoff', w5]).o;
8273
+ const uniOk = !/�/.test(oU) && !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(oU);
8274
+ // (e) Task Log 도 같은 계약(Decisions 만 보면 tailLines 누락 변이가 산다). 총 줄 수는 헤더 포함 201.
8275
+ fs.writeFileSync(path.join(w5, '.harness', 'task-log.md'), '# Task Log\n' + Array.from({ length: 200 }, (_, i) => `- 작업 ${String(i + 1).padStart(3, '0')}`).join('\n') + '\n');
8276
+ const oT = R(['handoff', w5]).o;
8277
+ const sT = oT.slice(oT.indexOf('=== Task Log'));
8278
+ // **실제로 60줄인지** 세야 한다 — 라벨만 보면 tailLines 를 59 로 바꾼 변이가 통과한다(검수 실측).
8279
+ const taskIds = (sT.match(/- 작업 (\d{3})/g) || []).map(x => Number(x.slice(-3)));
8280
+ const taskOk = taskIds.length === 60 && taskIds[0] === 141 && taskIds[59] === 200
8281
+ && taskIds.every((v, i) => i === 0 || v === taskIds[i - 1] + 1) && /60\/201 줄/.test(sT);
8282
+ // (f) 빈 파일을 `1/1 줄` 이라고 말하면 안 된다(`''.split('\n')` 가 `['']` 이다)
8283
+ fs.writeFileSync(decPath, '');
8284
+ fs.writeFileSync(path.join(w5, '.harness', 'task-log.md'), '');
8285
+ const emptyOk = !/1\/1 줄/.test(R(['handoff', w5]).o);
8286
+ // (g) --json 은 누적 로그의 **뒤를** 남긴다(같은 명령이 표면마다 반대 방향이면 안 된다).
8287
+ // totalChars 는 원문 길이와 정확히 같아야 하고, content 는 마커 포함 8,000자를 넘지 않아야 한다.
8288
+ fs.writeFileSync(decPath, mkDec(600, 40));
8289
+ const decRaw = fs.readFileSync(decPath, 'utf8');
8290
+ let jd = null;
8291
+ try { const t = R(['handoff', w5, '--json']).o; jd = JSON.parse(t.slice(t.indexOf('{'))).files.decisions; } catch (e) { dbg.json = String(e && e.message).slice(0, 40); }
8292
+ const jsonOk = !!jd && /결정 600/.test(jd.content) && !/결정 001/.test(jd.content)
8293
+ && jd.truncated === true && jd.kept === 'tail' && jd.totalChars === decRaw.length && jd.content.length <= 8000;
8294
+ // (g2) --json 절단도 **코드포인트 경계**여야 한다. 예산은 마커를 포함하므로 8000 이 아니다 —
8295
+ // 상수를 박으면 경계가 이모지를 비껴가 변이가 산다(실제로 겪었다).
8296
+ // `head + 😀 + 'x'×(예산-1)` 이면 경계 = len-예산 = head.length+1 = 낮은 서로게이트로 자명하다.
8297
+ const _jbud = 8000 - '\n…(truncated)'.length;
8298
+ fs.writeFileSync(decPath, '# D\n' + 'y'.repeat(400) + '😀' + 'x'.repeat(_jbud - 1));
8299
+ let jd2 = null;
8300
+ try { const t = R(['handoff', w5, '--json']).o; jd2 = JSON.parse(t.slice(t.indexOf('{'))).files.decisions; } catch (e) { dbg.json2 = String(e && e.message).slice(0, 30); }
8301
+ const jsonUniOk = !!jd2 && !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(jd2.content) && !/�/.test(jd2.content);
8302
+ // (h) non-tail 파일(plan)은 **앞을** 남겨야 한다 — 방향을 뒤집어도 decisions 만 보면 통과한다.
8303
+ fs.writeFileSync(path.join(w5, '.harness', 'plan.md'), '# Plan\nFIRST_MARKER\n' + 'p'.repeat(9000) + '\nLAST_MARKER\n');
8304
+ let jp = null;
8305
+ try { const t = R(['handoff', w5, '--json']).o; jp = JSON.parse(t.slice(t.indexOf('{'))).files.plan; } catch (e) { dbg.plan = String(e && e.message).slice(0, 30); }
8306
+ // head 방향에도 **같은 계약**이 걸린다 — totalChars 정확성과 마커 예산을 tail 에서만 재면
8307
+ // non-tail 쪽 변이(totalChars 0 · 마커 예산 미적용으로 8,013자)가 그대로 통과한다(검수 실측).
8308
+ const planRaw = fs.readFileSync(path.join(w5, '.harness', 'plan.md'), 'utf8');
8309
+ const jsonHeadOk = !!jp && jp.kept === 'head' && /FIRST_MARKER/.test(jp.content) && !/LAST_MARKER/.test(jp.content)
8310
+ && jp.totalChars === planRaw.length && jp.content.length <= 8000 && jp.truncated === true;
8311
+ // (i) 본문 상한이 **선형**이어야 한다 — 줄마다 join() 하는 구현은 O(n²) 라 48,000줄에서 30초대였다.
8312
+ // 임계값 20초는 정상(수 초)과 회귀(30초대) 사이에 넉넉히 있어 느린 머신에서도 오탐하지 않는다.
8313
+ fs.writeFileSync(path.join(w5, '.harness', 'progress-tracker.md'),
8314
+ '# P\n' + Array.from({ length: 48000 }, (_, i) => `| T-${String(i).padStart(5, '0')} | done | ` + 'x'.repeat(80) + ' |').join('\n') + '\n');
8315
+ const _t0 = Date.now();
8316
+ R(['handoff', w5]);
8317
+ dbg.perf = Date.now() - _t0;
8318
+ const perfOk = dbg.perf < 20000;
8319
+ dbg.hd = `연속=${contiguous} 상한=${capOk}(${mB ? mB[0] : '-'} 본문 ${bodyB.length}자) 정확상한=${exactOk} 유니코드=${uniOk}/${jsonUniOk} 작업로그=${taskOk} 빈파일=${emptyOk} json=${jsonOk} head방향=${jsonHeadOk} 성능=${perfOk}(${dbg.perf}ms)`;
8320
+ T2 = contiguous && capOk && exactOk && uniOk && jsonUniOk && taskOk && emptyOk && jsonOk && jsonHeadOk && perfOk;
8321
+ ok = T1 && T2 && T3;
8322
+ if (!ok) console.log(` [P9A 디버그] 템플릿=${T1}(낡은수치 ${dbg.stale} 크기 ${dbg.size}) 무삭제=${T3}(${dbg.nodel}) handoff=${T2}(${dbg.hd})`);
8323
+ } catch (e) { console.log(' [P9A 디버그] 예외: ' + ((e && e.message) || e)); }
8324
+ finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
8325
+ console.log(ok ? '✓ B(1.36.95) 부채 상환: 템플릿 낡은 이력 0(ko/en · 수치 주장 자체가 0) · 머지 삭제 0(옛 이력 필터 제거 — 사용자 산문 보존 · 이력은 이월) · handoff last-N 라벨=동작(헤더 불가침 · 줄 경계 · 정확 상한 · 코드포인트 · Task Log · 빈 파일 · --json tail/head 방향 · totalChars · 선형 성능)' : '✗ 부채 상환(1.36.95) 실패');
8326
+ if (!ok) failed++;
8327
+ }
8328
+
8172
8329
  // 1.36.94 (헌트 이월 2건 + 자체 검수 반영): ① 형상 무효 스토어에서 **차단 메시지가 안내한 탈출구가
8173
8330
  // 같은 사유로 거부**돼, 남는 실행 가능한 선택지가 "보호를 끄기" 하나였다.
8174
8331
  // ② `provider add codex --bin codex` 라는 사실상 무변경 재등록이 빌트인 authCheck 를 지워 인증 축을
@@ -8791,3 +8948,4 @@ total++;
8791
8948
 
8792
8949
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
8793
8950
  if (failed > 0) process.exit(1);
8951
+