leerness 1.36.72 → 1.36.74

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,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.74 — 2026-07-25 — codex 9차 홀리스틱 헌트: 미점검 표면 6건 수정 (P1 3·P2 3)
4
+
5
+ 표면 회전 원칙에 따라 최근 라운드가 비추지 않은 사용자 데이터 표면(MCP 프로토콜·memory archive/restore·.leerness state·api-skill)을 헌트. 8건 보고 중 자체 재현 확정 6건 수정.
6
+
7
+ - **P1 `.leerness/state.json` 스키마 무효 통과**: 파싱은 되지만 형태가 무효인 파일(`{}`)이 손상 가드를 통과해 runCounter 를 0 으로 되돌리고 **기존 run 을 덮어썼다**(실측 original-goal → replacement-goal). 형태 검증도 fail-closed — 단 runs/ 가 비어 있으면 관대(없는 데이터를 지킬 필요 없음).
8
+ - **P1 memory restore 가 활성 중복 생성**: drop → 같은 제목 재생성 → restore 시 동일 제목 2건이 조용히 생겨 어느 쪽이 진짜인지 알 수 없었다. 충돌 기본 거부(`restore_conflict`) + `--force` 우회 — 복원은 되살리기지 복제가 아니다. archive 는 그대로 두어 재시도 가능.
9
+ - **P1 api-skill skeleton 경로 id 충돌**: fetch 경로에만 있던 충돌 해소기가 skeleton 폴백엔 없어 URL 이 다른데 slug 가 같으면 앞 항목을 통째로 덮어썼다(alpha URL·방향 소실 실측). 동일 규칙 공유.
10
+ - **P2 MCP 마지막 요청 유실**: 개행 없이 끝난 마지막 JSON-RPC 요청이 EOF 에서 조용히 버려졌다(실측: 무응답 exit 0). EOF 시 잔여 버퍼도 동일 경로로 처리.
11
+ - **P2 손상 api-skill 이 정상처럼 노출**: frontmatter 깨진 파일이 "URL 0개 정상 스킬"로 강등돼 목록에 섞이던 것 — `corrupt`/`corruptCount` 표기 + 텍스트 경고(로드 자체는 유지, 비파괴).
12
+ - **P2 `api-skill add --json` 계약 위반**(자체 관측): 사람용 진행 로그·실패 메시지가 stdout 을 오염시켜 JSON 소비자가 파싱 불가 — 진행 로그 억제 + 실패 구조화(`{ok:false, code:'fetch_failed'}`), drop/show 에러도 failJson 통일.
13
+ - **(R-0001 검수 17회전이 제 수정의 불완전성 2건을 차단)**: ① state 형태 검증만으론 부족 — **형태는 유효하나 stale 한 runCounter**(0 < 실재 run-0001)가 여전히 기존 run 을 덮어썼다(실측). 디스크 최대 run 번호와 대조해 fail-closed. ② restore 충돌 판정이 "첫 줄 substring"이라 **날짜만 다른 논리적 중복은 통과**시키고 **같은 날 무관한 lesson·'Deploy' vs 'Deploy v2' 는 오탐 차단**했다(양쪽 실측) — 표면별 정체성(decision=title/lesson=text/plan=제목) 파싱 정확 일치로 교체. (검수 Low) 빈 api-skill 저장소도 `corruptCount` 동일 형태.
14
+ - 미재현 이월: 잘린 UTF-8 아카이브 복원(#3)은 제 절단 변형에선 U+FFFD 미유입 — 정확한 바이트 조작 재현 후 별도 판단. MCP 티어/텔레메트리(#2)·긴 파생 id(#8)는 다음 라운드.
15
+ - 정상 확인: MCP 잘못된 JSON(-32700)·미지 도구(-32601)·연속 요청 프레이밍·1MiB 라인, state 동시 8start/12record 무손실, 손상 state 파싱 실패 fail-closed.
16
+
17
+ ## 1.36.73 — 2026-07-25 — 8차 헌트 완전 종결: F12 skills-lock 정합 + F9 설계 결정
18
+
19
+ 8차 홀리스틱 헌트(16건)의 마지막 설계 판단 2건을 결론 — 이로써 16/16 전건 처리 완료.
20
+
21
+ - **F12**: skills-lock 은 "요청 집합"이 아니라 **디스크 실재**를 기록 — `--skills all` 후 `recommended` 재init 시 9개 디렉토리가 남는데 락은 5개만 주장하던 불일치를 합집합 재생성으로 해소(실측 dirs 9 = lock 9). 스킬 제거는 하지 않음(비파괴 원칙).
22
+ - **F9 (설계 결정)**: 초고령 KO canonical 이 en 프로젝트에 커스텀으로 오인 이월되는 것 — **자동 차감은 하지 않는다**(구판 템플릿 라인과 사용자 노트를 확신으로 구분 불가, false-DROP 이 버그·false-PRESERVE 는 무해). 대신 한글 비중 높은 이월(≥10줄·>50%) 시 Preserved 섹션에 **검토·정리 안내**를 명시해 조용한 동결을 없앤다. 안내는 멱등(재이월 안 됨), 소수 한글 커스텀·ko 프로젝트엔 무경고.
23
+ - 검증: F12 재현 시나리오 실측, F9 순수함수 5프로브(안내/멱등/무손실/소수 무경고/ko 무경고), selftest 334, e2e +1.
24
+
3
25
  ## 1.36.72 — 2026-07-24 — 성장 한계 클래스 4(task list) + F16 lint 거짓 PASS 차단 — R-0001 검수 15회전
4
26
 
5
27
  - **task list --json 상한**: 기본 최신 100행 + `shown/truncated` 정직 표기(`total` 은 필터 후 전량), `--all/--limit N` 옵트인. MCP `leerness_task_list` 계약 동반 갱신(limit/all 노출+전달). 텍스트 모드 무변경(codex: HEAD 와 바이트 동일 확인).
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.72 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.74 하네스를 사용합니다. 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.72는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
179
+ Leerness v1.36.74는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
180
180
 
181
181
  ```jsonc
182
182
  // 카테고리별
@@ -197,7 +197,7 @@ Leerness v1.36.72는 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.72 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.74 릴리스 태그 이력** (수백 라운드) · _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.72: 2026-07-25
238
+ Last synced by Leerness v1.36.74: 2026-07-25
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.72';
37
+ const VERSION = '1.36.74';
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') 시 호스트 프로세스 오염.
@@ -525,9 +525,21 @@ function mergeReadmeSection(existing, block) {
525
525
  return _mergeReadmeSection(existing, block, README_START, README_END);
526
526
  }
527
527
 
528
- function skillLock(skills) {
528
+ function skillLock(skills, root) {
529
+ // 1.36.73 (8차 헌트 F12): 락은 "요청 집합"이 아니라 디스크 실재를 기록 — `--skills all` 후 `recommended` 재init 시
530
+ // 9개 디렉토리가 남는데 락은 5개만 주장하던 불일치. 기존 설치 디렉토리(카탈로그 내장분)와 합집합.
531
+ // 스킬 제거는 하지 않음(비파괴 원칙 — 사용자가 쓰고 있을 수 있다).
532
+ const set = new Set(skills);
533
+ try {
534
+ if (root) {
535
+ const dir = path.join(root, '.harness', 'skills');
536
+ if (exists(dir)) for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
537
+ if (e.isDirectory() && skillCatalog[e.name]) set.add(e.name);
538
+ }
539
+ }
540
+ } catch {}
529
541
  const data = { leernessVersion: VERSION, updatedAt: now(), installedSkills: {} };
530
- for (const s of skills) data.installedSkills[s] = skillCatalog[s] || { version: 'unknown' };
542
+ for (const s of [...set].sort()) data.installedSkills[s] = skillCatalog[s] || { version: 'unknown' }; // (검수 #4) 정렬 직렬화 — 동일 멤버십이면 바이트 동일(no-op 보존)
531
543
  return JSON.stringify(data, null, 2) + '\n';
532
544
  }
533
545
 
@@ -543,7 +555,7 @@ function coreFiles(root, lang = 'ko', selectedSkills = [], opts = {}) {
543
555
  '.harness/HARNESS_VERSION': VERSION + '\n',
544
556
  '.harness/LANGUAGE': lang + '\n',
545
557
  '.harness/manifest.json': JSON.stringify({ project, leernessVersion: VERSION, language: lang, installedAt: now(), minimal: !!opts.minimal }, null, 2) + '\n',
546
- '.harness/skills-lock.json': skillLock(selectedSkills),
558
+ '.harness/skills-lock.json': skillLock(selectedSkills, root),
547
559
  '.harness/project-brief.md': fm('project-brief', ['프로젝트 목적 확인','신규 기능 판단','계획 수립'], ['프로젝트 목적 변경','사용자/범위 변경'], `# Project Brief\n\n## Project\n${project}\n\n## Purpose\n- 이 프로젝트의 목적을 실제 내용으로 업데이트하세요.\n\n## Users\n-\n\n## Success Criteria\n-\n`),
548
560
  '.harness/plan.md': fm('plan', ['작업 시작 전','새 요청 접수','범위 변경','신규 프로젝트 감지'], ['계획 추가/수정/드랍','milestone 변경','목표 변경'], `# Plan\n\n## Goal\n- 사용자 목적을 기준으로 전체 계획을 유지합니다.\n\n## Scope\n- 포함 범위를 기록합니다.\n\n## Out of Scope / Dropped\n| ID | Item | Reason | Date |\n|---|---|---|---|\n\n## Milestones\n\n### M-0001. 프로젝트 계획 정리\nStatus: planned\nProgress: 0%\n\nTasks:\n- [ ] project-brief.md를 실제 프로젝트 목적에 맞게 작성\n- [ ] context-map.md를 실제 파일 구조에 맞게 작성\n`),
549
561
  '.harness/progress-tracker.md': _canonicalProgressHeader(lang) + '\n', // 1.9.293: _canonicalProgressHeader 단일 출처 · 1.36.63: lang 전달
@@ -6082,7 +6094,9 @@ function _loadAPISkill(root, id) {
6082
6094
  // 1.12.5 (15th 버그헌트 P2, UR-0017): read()(BOM strip) + CRLF/CR 정규화 — 이전 raw readFileSync 는 CRLF 파일에서 '^---\n' 불일치로 frontmatter 전부 유실(1.9.408 SKILL.md 수정 누락분). 또 fallback 에 body 추가 → _matchAPISkills 의 s.body.slice 크래시 방지.
6083
6095
  const content = read(fp).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
6084
6096
  const fm = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
6085
- if (!fm) return { id, content, urls: [], name: id, body: content };
6097
+ // 1.36.74 (9차 헌트 #6 P2): frontmatter 깨진 파일(닫는 --- 누락 등)이 "URL 0개짜리 정상 스킬"로 조용히 강등돼
6098
+ // 목록에 섞였다 — 손상 사실을 표시해 사용자가 고칠 수 있게(로드 자체는 유지, 비파괴).
6099
+ if (!fm) return { id, content, urls: [], name: id, body: content, corrupt: true, corruptReason: 'frontmatter_unparsable' };
6086
6100
  const meta = {};
6087
6101
  fm[1].split('\n').forEach(l => {
6088
6102
  const m = l.match(/^(\w+):\s*(.*)$/);
@@ -6128,23 +6142,28 @@ async function apiSkillCmd(root, sub) {
6128
6142
  const dir = _apiSkillsDir(root);
6129
6143
  if (sub === 'list') {
6130
6144
  if (!fs.existsSync(dir)) {
6131
- if (has('--json')) { log(JSON.stringify({ skills: [] })); return; }
6145
+ if (has('--json')) { log(JSON.stringify({ skills: [], corruptCount: 0 })); return; } // (검수 Low) 빈 저장소도 동일 필드 형태
6132
6146
  log(dm(`(아직 API skill 없음 — leerness api-skill add <url> 으로 생성)`));
6133
6147
  return;
6134
6148
  }
6135
6149
  const skills = _listAPISkills(root);
6150
+ const _corrupt = skills.filter(s => s.corrupt); // 1.36.74 (#6): 손상 레코드를 정상처럼 보이게 두지 않는다
6136
6151
  if (has('--json')) {
6137
- log(JSON.stringify({ skills: skills.map(s => ({ id: s.id, name: s.name, urls: s.urls, captured_at: s.captured_at })) }, null, 2));
6152
+ log(JSON.stringify({
6153
+ skills: skills.map(s => ({ id: s.id, name: s.name, urls: s.urls, captured_at: s.captured_at, ...(s.corrupt ? { corrupt: true, corruptReason: s.corruptReason } : {}) })),
6154
+ corruptCount: _corrupt.length
6155
+ }, null, 2));
6138
6156
  return;
6139
6157
  }
6140
6158
  log(cy(`# leerness api-skill list (1.9.245, UR-0015)`));
6141
6159
  log('');
6142
6160
  if (skills.length === 0) { log(dm(`(없음)`)); return; }
6143
6161
  skills.forEach(s => {
6144
- log(` • ${gr(s.id)} — ${s.name}`);
6162
+ log(` • ${gr(s.id)} — ${s.name}${s.corrupt ? yl(' ⚠ 손상(frontmatter 파싱 불가)') : ''}`);
6145
6163
  if (s.urls && s.urls.length) log(dm(` ${s.urls[0]}${s.urls.length > 1 ? ` (+${s.urls.length - 1})` : ''}`));
6146
6164
  if (s.direction) log(dm(` 방향: ${s.direction.slice(0, 80)}`));
6147
6165
  });
6166
+ if (_corrupt.length) log(yl(` ⚠ 손상 ${_corrupt.length}건 — 해당 .md 의 frontmatter(--- 구분선)를 확인하세요`));
6148
6167
  return;
6149
6168
  }
6150
6169
  // 1.9.245: 위치 인자 robust 추출 — argv 에서 cmd/sub 뒤 첫 non-flag (flag value 도 skip)
@@ -6165,9 +6184,9 @@ async function apiSkillCmd(root, sub) {
6165
6184
  };
6166
6185
  if (sub === 'show') {
6167
6186
  const id = arg('--id') || _positionalAfterSub();
6168
- if (!id) { log(rd('id 필요: leerness api-skill show <id>')); process.exitCode = 1; return; }
6187
+ if (!id) { failJson(has('--json'), 'missing_args', 'id 필요: leerness api-skill show <id>'); return; } // 1.36.74: --json 에러 구조화
6169
6188
  const s = _loadAPISkill(root, id);
6170
- if (!s) { log(rd(`api-skill 없음: ${id}`)); process.exitCode = 1; return; }
6189
+ if (!s) { failJson(has('--json'), 'not_found', `api-skill 없음: ${id}`); return; }
6171
6190
  if (has('--json')) { log(JSON.stringify(s, null, 2)); return; }
6172
6191
  log(fs.readFileSync(path.join(dir, id + '.md'), 'utf8'));
6173
6192
  return;
@@ -6186,23 +6205,28 @@ async function apiSkillCmd(root, sub) {
6186
6205
  }
6187
6206
  if (sub === 'drop') {
6188
6207
  const id = arg('--id') || _positionalAfterSub();
6189
- if (!id) { log(rd('id 필요: leerness api-skill drop <id>')); process.exitCode = 1; return; }
6208
+ if (!id) { failJson(has('--json'), 'missing_args', 'id 필요: leerness api-skill drop <id>'); return; }
6190
6209
  const fp = path.join(dir, id + '.md');
6191
- if (!fs.existsSync(fp)) { log(rd(`없음: ${id}`)); process.exitCode = 1; return; }
6210
+ if (!fs.existsSync(fp)) { failJson(has('--json'), 'not_found', `없음: ${id}`); return; } // 1.36.74: --json 에러 구조화
6192
6211
  fs.unlinkSync(fp);
6212
+ if (has('--json')) { log(JSON.stringify({ ok: true, dropped: id }, null, 2)); return; }
6193
6213
  log(gr(`✓ 삭제: ${id}`));
6194
6214
  return;
6195
6215
  }
6196
6216
  if (sub === 'add') {
6217
+ // 1.36.74 (9차 헌트 자체관측): --json 인데 사람용 진행 로그/실패 메시지가 stdout 을 오염시켜 JSON 소비자(MCP/CI)가
6218
+ // 파싱 불가 — 다른 표면에서 이미 고친 --json 계약 위반의 잔존 지점. 진행 로그 억제 + 실패도 구조화.
6219
+ const _j = has('--json');
6220
+ const _hlog = (s) => { if (!_j) log(s); };
6197
6221
  const url = arg('--url') || _positionalAfterSub();
6198
- if (!url) { log(rd('URL 필요: leerness api-skill add <url> [--direction "..."]')); process.exitCode = 1; return; }
6222
+ if (!url) { failJson(_j, 'missing_args', 'URL 필요: leerness api-skill add <url> [--direction "..."]'); return; }
6199
6223
  const direction = arg('--direction', '');
6200
6224
  const name = arg('--name', '');
6201
6225
  const noCrawl = has('--no-crawl');
6202
- log(cy(`# leerness api-skill add (1.9.245, UR-0015)`));
6203
- log(` URL: ${url}`);
6204
- if (direction) log(` 방향: ${direction}`);
6205
- log(dm(` Fetch + crawl (depth=1, same-domain, max 10)...`));
6226
+ _hlog(cy(`# leerness api-skill add (1.9.245, UR-0015)`));
6227
+ _hlog(` URL: ${url}`);
6228
+ if (direction) _hlog(` 방향: ${direction}`);
6229
+ _hlog(dm(` Fetch + crawl (depth=1, same-domain, max 10)...`));
6206
6230
  try {
6207
6231
  const doc = await _collectAPIDoc(url, { direction, noCrawl });
6208
6232
  const skillName = name || doc.title || new URL(url).hostname;
@@ -6219,17 +6243,27 @@ async function apiSkillCmd(root, sub) {
6219
6243
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
6220
6244
  const md = _serializeAPISkill(id, skillName, [url], direction, doc);
6221
6245
  fs.writeFileSync(path.join(dir, id + '.md'), md, 'utf8');
6222
- log(gr(`✓ 저장: .harness/api-skills/${id}.md`));
6223
- log(dm(` title: ${doc.title || '(none)'}`));
6224
- log(dm(` related links: ${(doc.related_links || []).length}`));
6225
- log(dm(` text size: ${doc.text.length} chars`));
6226
- if (has('--json')) log(JSON.stringify({ id, name: skillName, url, related_count: (doc.related_links || []).length }, null, 2));
6246
+ _hlog(gr(`✓ 저장: .harness/api-skills/${id}.md`));
6247
+ _hlog(dm(` title: ${doc.title || '(none)'}`));
6248
+ _hlog(dm(` related links: ${(doc.related_links || []).length}`));
6249
+ _hlog(dm(` text size: ${doc.text.length} chars`));
6250
+ if (_j) log(JSON.stringify({ ok: true, id, name: skillName, url, related_count: (doc.related_links || []).length }, null, 2));
6227
6251
  } catch (e) {
6228
- log(rd(`✗ Fetch 실패: ${e.message}`));
6252
+ _hlog(rd(`✗ Fetch 실패: ${e.message}`));
6229
6253
  // 1.9.245: --skeleton fallback — Cloudflare/WAF 차단된 URL 도 빈 .md 골격 생성 → 사용자가 수동 채움
6230
6254
  if (has('--skeleton') || /403|401|429/.test(e.message)) {
6231
- const skillName = name || new URL(url).hostname;
6232
- const id = _apiSkillId(url, name);
6255
+ // 1.36.74 (9차 헌트 #5 P1): skeleton 폴백에 충돌 해소기가 없어 URL 이 다른데 slug 가 같으면 앞 항목을
6256
+ // 통째로 덮어썼다(실측: alpha URL·direction 소실). fetch 경로(1.36.28 #4)와 동일 규칙 공유.
6257
+ let skillName, id;
6258
+ try { skillName = name || new URL(url).hostname; } catch { skillName = name || String(url).slice(0, 60); }
6259
+ id = _apiSkillId(url, name);
6260
+ const _idFileS = (i) => path.join(dir, i + '.md');
6261
+ if (fs.existsSync(_idFileS(id))) {
6262
+ const existing = fs.readFileSync(_idFileS(id), 'utf8');
6263
+ const um = existing.match(/^urls:\s*\r?\n\s*-\s*(\S.*)$/m);
6264
+ const storedUrl = um ? um[1].trim() : '';
6265
+ if (storedUrl && storedUrl !== url) id = id + '-' + _shortHash(url);
6266
+ }
6233
6267
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
6234
6268
  const stub = {
6235
6269
  url, title: skillName, text: `(fetch 실패 — 사용자가 직접 ${url} 에서 내용을 복사해 채워주세요)`,
@@ -6237,11 +6271,12 @@ async function apiSkillCmd(root, sub) {
6237
6271
  };
6238
6272
  const md = _serializeAPISkill(id, skillName, [url], direction, stub);
6239
6273
  fs.writeFileSync(path.join(dir, id + '.md'), md, 'utf8');
6240
- log(yl(`⚠ 빈 골격 생성 (--skeleton fallback): .harness/api-skills/${id}.md`));
6241
- log(dm(` → 직접 ${url} 내용을 복사해서 .md 파일에 채워넣어주세요`));
6242
- log(dm(` → 이후 leerness handoff 가 매칭 시 참조됩니다`));
6243
- if (has('--json')) log(JSON.stringify({ id, skeleton: true, url, name: skillName }, null, 2));
6274
+ _hlog(yl(`⚠ 빈 골격 생성 (--skeleton fallback): .harness/api-skills/${id}.md`));
6275
+ _hlog(dm(` → 직접 ${url} 내용을 복사해서 .md 파일에 채워넣어주세요`));
6276
+ _hlog(dm(` → 이후 leerness handoff 가 매칭 시 참조됩니다`));
6277
+ if (_j) log(JSON.stringify({ ok: true, id, skeleton: true, url, name: skillName, fetchError: e.message }, null, 2));
6244
6278
  } else {
6279
+ if (_j) log(JSON.stringify({ ok: false, code: 'fetch_failed', error: e.message, url }, null, 2)); // 실패도 구조화(종전: --json 에 사람용 텍스트만)
6245
6280
  process.exitCode = 1;
6246
6281
  }
6247
6282
  }
@@ -9732,6 +9767,39 @@ function memoryRestoreCmd(root, surface, target) {
9732
9767
  const activePath = surface === 'decisions' ? decisionsPath(root)
9733
9768
  : surface === 'lessons' ? lessonsPath(root)
9734
9769
  : planPath(root);
9770
+ // 1.36.74 (9차 헌트 #4 P1): 같은 항목이 이미 active 에 있으면 복원이 조용히 중복 레코드를 만들었다
9771
+ // (drop → 같은 제목 재생성 → restore = 동일 제목 2건, 어느 쪽이 진짜인지 불명). 충돌은 기본 거부하고
9772
+ // --force 로만 진행 — 복원은 "되살리기"지 "복제"가 아니다. archive 는 손대지 않으므로 재시도 가능.
9773
+ if (!has('--force')) {
9774
+ // 1.36.74 (검수 High-2 + Medium): 첫 줄 substring 비교는 (a) 날짜만 다른 논리적 중복을 놓치고
9775
+ // (b) 같은 날 저장된 무관한 lesson·'Deploy' vs 'Deploy v2' 를 오탐 차단했다(양쪽 실측).
9776
+ // 표면별 "정체성"(decision=title, lesson=text, plan=제목)을 파싱해 정확 일치로만 판정.
9777
+ const _activeNow = exists(activePath) ? read(activePath) : '';
9778
+ const _identities = (mdText) => {
9779
+ try {
9780
+ if (surface === 'decisions') return new Set((_decisionsFromMd(mdText) || []).map(d => String(d.title || '').trim()).filter(Boolean));
9781
+ if (surface === 'lessons') return new Set((_parseLessonEntries(mdText) || []).map(l => String(l.text || '').trim()).filter(Boolean));
9782
+ } catch {}
9783
+ // plan: '## M-XXXX 제목' 헤딩의 제목부 (M-ID 는 재발급될 수 있어 제목으로 판정)
9784
+ const out = new Set();
9785
+ for (const l of String(mdText).split('\n')) {
9786
+ const m = l.match(/^##+\s*(?:M-\d+\s*)?(.+?)\s*$/);
9787
+ if (m && m[1] && !/^제거 /.test(m[1])) out.add(m[1].trim());
9788
+ }
9789
+ return out;
9790
+ };
9791
+ const _activeIds = _identities(_activeNow);
9792
+ const _dupes = [];
9793
+ for (const blk of restoredBlocks) {
9794
+ for (const id of _identities(blk)) if (_activeIds.has(id)) _dupes.push(id.slice(0, 70));
9795
+ }
9796
+ if (_dupes.length) {
9797
+ failJson(has('--json'), 'restore_conflict',
9798
+ `이미 active 에 동일 항목 존재 — 중복 생성 방지로 복원 거부 (${_dupes.length}건: ${_dupes.slice(0, 2).join(' · ')}). ` +
9799
+ `archive 는 그대로 두었으니 active 쪽을 정리 후 재시도하거나, 의도한 중복이면 --force`);
9800
+ return;
9801
+ }
9802
+ }
9735
9803
  // active 파일에 복귀 (헤더 보존)
9736
9804
  for (const blk of restoredBlocks) {
9737
9805
  append(activePath, '\n' + blk + '\n');
@@ -19045,6 +19113,17 @@ function mcpServeCmd(root) {
19045
19113
 
19046
19114
  // stdin JSON-RPC 한 줄 단위
19047
19115
  let buf = '';
19116
+ // 1.36.74 (9차 헌트 #7): 한 줄 처리 로직을 함수로 — EOF 시 개행 없이 남은 마지막 요청이 조용히 유실되던 것
19117
+ // (실측: 개행 없는 ping 은 무응답 exit 0, 개행 있는 동일 요청은 정상 응답). 클라이언트가 flush 를 개행 없이 끝내면 요청 손실.
19118
+ const _handleLine = (line) => {
19119
+ if (!line) return;
19120
+ // 1.36.39 (#3): 파싱과 핸들링 분리 — 핸들러 내부 예외가 -32700 "Parse error"(id null)로 오보되지 않게.
19121
+ let req = null;
19122
+ try { req = JSON.parse(line); }
19123
+ catch (e) { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error: ' + e.message } }); return; }
19124
+ try { handleRequest(req); }
19125
+ catch (e) { send({ jsonrpc: '2.0', id: (req && req.id != null) ? req.id : null, error: { code: -32603, message: 'Internal error: ' + e.message } }); }
19126
+ };
19048
19127
  process.stdin.setEncoding('utf8');
19049
19128
  process.stdin.on('data', chunk => {
19050
19129
  buf += chunk;
@@ -19052,16 +19131,15 @@ function mcpServeCmd(root) {
19052
19131
  while ((nl = buf.indexOf('\n')) !== -1) {
19053
19132
  const line = buf.slice(0, nl).trim();
19054
19133
  buf = buf.slice(nl + 1);
19055
- if (!line) continue;
19056
- // 1.36.39 (#3): 파싱과 핸들링 분리 — 핸들러 내부 예외가 -32700 "Parse error"(id null)로 오보되지 않게.
19057
- let req = null;
19058
- try { req = JSON.parse(line); }
19059
- catch (e) { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error: ' + e.message } }); continue; }
19060
- try { handleRequest(req); }
19061
- catch (e) { send({ jsonrpc: '2.0', id: (req && req.id != null) ? req.id : null, error: { code: -32603, message: 'Internal error: ' + e.message } }); }
19134
+ _handleLine(line);
19062
19135
  }
19063
19136
  });
19064
- process.stdin.on('end', () => process.exit(0));
19137
+ process.stdin.on('end', () => {
19138
+ const rest = buf.trim();
19139
+ buf = '';
19140
+ if (rest) _handleLine(rest); // 개행 없이 끝난 마지막 요청도 처리
19141
+ process.exit(0);
19142
+ });
19065
19143
  // 인터럽트 처리
19066
19144
  process.on('SIGINT', () => process.exit(0));
19067
19145
  process.on('SIGTERM', () => process.exit(0));
@@ -20236,7 +20314,32 @@ function _loadLeernessState(root) {
20236
20314
  const f = path.join(_leernessStateDir(root), 'state.json');
20237
20315
  if (!exists(f)) return { schemaVersion: 1, project: detectProjectName(root), currentRunId: null, runCounter: 0, updatedAt: null };
20238
20316
  // 1.36.28 (#3): 손상 파일을 빈 상태로 오인하면 카운터가 0으로 리셋돼 run-0001 을 덮어쓴다(데이터 유실). 파싱 실패는 던진다.
20239
- try { return JSON.parse(read(f)); } catch { throw Object.assign(new Error(`.leerness/state.json 손상 — 덮어쓰기 거부: ${f} (복구/삭제 후 재시도)`), { code: 'E_STORE_CORRUPT', file: f }); }
20317
+ let _st;
20318
+ try { _st = JSON.parse(read(f)); } catch { throw Object.assign(new Error(`.leerness/state.json 손상 — 덮어쓰기 거부: ${f} (복구/삭제 후 재시도)`), { code: 'E_STORE_CORRUPT', file: f }); }
20319
+ // 1.36.74 (9차 헌트 #1 P1): "파싱은 되지만 스키마 무효"(예: `{}`)가 위 가드를 통과해 runCounter 를 0 으로 되돌리고
20320
+ // 기존 run-0001.json 을 덮어쓰던 것(실측: original-goal → replacement-goal). 형태 검증도 fail-closed.
20321
+ // 단, runs 디렉토리가 비어 있으면(진짜 신규/빈 상태) 관대하게 기본값 — 없는 데이터를 지킬 필요는 없다.
20322
+ const _shapeOk = _st && typeof _st === 'object' && !Array.isArray(_st)
20323
+ && Number.isInteger(_st.runCounter) && _st.runCounter >= 0
20324
+ && (_st.currentRunId === null || typeof _st.currentRunId === 'string');
20325
+ // 디스크의 최대 run 번호 — 카운터의 진실성 대조 기준
20326
+ let _maxRun = 0;
20327
+ try {
20328
+ for (const x of fs.readdirSync(path.join(_leernessStateDir(root), 'runs'))) {
20329
+ const m = /^run-(\d+)\.json$/.exec(x);
20330
+ if (m) _maxRun = Math.max(_maxRun, parseInt(m[1], 10));
20331
+ }
20332
+ } catch {}
20333
+ if (!_shapeOk) {
20334
+ if (_maxRun > 0) throw Object.assign(new Error(`.leerness/state.json 스키마 무효 — 덮어쓰기 거부: ${f} (runs/ 에 기존 기록 있음 · 복구/삭제 후 재시도)`), { code: 'E_STORE_CORRUPT', file: f });
20335
+ return { schemaVersion: 1, project: detectProjectName(root), currentRunId: null, runCounter: 0, updatedAt: null };
20336
+ }
20337
+ // 1.36.74 (검수 High-1): 형태는 유효한데 값이 stale 한 카운터(runCounter < 실재 최대 run)도 다음 start 가
20338
+ // 기존 run 파일을 덮어쓴다(실측 ORIGINAL→REPLACEMENT). 형태 검증만으론 부족 — 디스크 실재와 대조해 fail-closed.
20339
+ if (_st.runCounter < _maxRun) {
20340
+ throw Object.assign(new Error(`.leerness/state.json 카운터 불일치 — 덮어쓰기 거부: runCounter=${_st.runCounter} < 실재 run-${String(_maxRun).padStart(4, '0')} (${f} 복구 또는 runCounter 를 ${_maxRun} 이상으로 수정 후 재시도)`), { code: 'E_STORE_CORRUPT', file: f });
20341
+ }
20342
+ return _st;
20240
20343
  }
20241
20344
  function _saveLeernessState(root, state) {
20242
20345
  const dir = _leernessStateDir(root); mkdirp(dir);
package/lib/pure-utils.js CHANGED
@@ -804,7 +804,7 @@ function _managedMerge(file, next, previous, archiveRel, overwriteSet, opts = {}
804
804
  // preserved 래퍼 자체의 보일러플레이트는 이월 대상 아님 (구 <details> 형식 + 신 평문 형식 + en 래퍼 모두)
805
805
  if (l === tag || l === '---' || l === '<details>' || l === '</details>') continue;
806
806
  if (/^```/.test(l) || /^<summary>/.test(l)) continue;
807
- if (/^## Preserved previous content|^Previous content was backed up|^`[^`]*archive[^`]*`$|^> 이전 버전에서 이어진|^> Custom user\/project content carried over/.test(l)) continue;
807
+ if (/^## Preserved previous content|^Previous content was backed up|^`[^`]*archive[^`]*`$|^> 이전 버전에서 이어진|^> Custom user\/project content carried over|^> ⚠ \d+ Korean lines were preserved below\. If this project never had Korean customizations.*with certainty\.$/.test(l)) continue; // (검수 #2) 생성 문장 전체 앵커 — 같은 접두의 사용자 커스텀 라인 오삭제 방지
808
808
  if (seen.has(l)) continue;
809
809
  seen.add(l);
810
810
  custom.push(raw.replace(/\\`\\`\\`/g, '```')); // 구 형식에서 이스케이프된 펜스 복원
@@ -812,9 +812,18 @@ function _managedMerge(file, next, previous, archiveRel, overwriteSet, opts = {}
812
812
  if (!custom.length) return next;
813
813
  const ar = archiveRel || '.harness/archive';
814
814
  // 1.36.60 (검수): 보존 래퍼 문구도 프로젝트 언어를 따른다 — en 프로젝트에 한글 래퍼가 새 한글원을 만들던 것 해소
815
- const note = opts.lang === 'en'
815
+ let note = opts.lang === 'en'
816
816
  ? `> Custom user/project content carried over from the previous version — re-carried automatically on every migration. Full original backup: \`${ar}\``
817
817
  : `> 이전 버전에서 이어진 사용자/프로젝트 커스텀 내용 — 마이그레이션마다 자동 이월됩니다. 전체 원본 백업: \`${ar}\``;
818
+ // 1.36.73 (8차 헌트 F9 설계 결정): 초고령 KO canonical(현행 altTemplate 에 없는 구판 템플릿)이 en 프로젝트에
819
+ // 커스텀으로 오인 이월될 수 있다. 자동 차감은 데이터 손실 위험(어느 라인이 템플릿인지 확신 불가 — false-DROP 이 버그)
820
+ // 이라 하지 않고, 한글 비중이 높으면 "검토·정리 안내"를 명시해 조용한 동결만 없앤다.
821
+ if (opts.lang === 'en') {
822
+ const hangulLines = custom.filter(l => /[가-힣]/.test(l)).length;
823
+ if (hangulLines >= 10 && hangulLines > custom.length * 0.5) {
824
+ note += `\n> ⚠ ${hangulLines} Korean lines were preserved below. If this project never had Korean customizations, most of these are likely template text from an old Korean install — review and prune manually (data is also in the backup above). leerness will not auto-delete them: it cannot distinguish old template lines from your own notes with certainty.`;
825
+ }
826
+ }
818
827
  return next.trimEnd() + `\n\n---\n${tag}\n## Preserved previous content\n\n${note}\n\n` + custom.join('\n') + '\n';
819
828
  }
820
829
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.72",
3
+ "version": "1.36.74",
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
@@ -7298,5 +7298,119 @@ total++;
7298
7298
  if (!ok) failed++;
7299
7299
  }
7300
7300
 
7301
+ // 1.36.73 (8차 헌트 F12/F9 종결): skills-lock = 디스크 실재 + KO-잔존 이월 안내
7302
+ total++;
7303
+ {
7304
+ let ok = false;
7305
+ const _d = [];
7306
+ try {
7307
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-f12-')); _d.push(d);
7308
+ // (검수 #3) exit 단언 + 정렬 집합 정확 비교 — 카운트만 비교하면 2차 init 실패를 1차 결과가 가렸다
7309
+ const i1 = cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--language', 'en', '--skills', 'all', '--no-stale-check', '--no-env'], { encoding: 'utf8', timeout: 40000 });
7310
+ const i2 = cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--language', 'en', '--skills', 'recommended', '--no-stale-check', '--no-env'], { encoding: 'utf8', timeout: 40000 });
7311
+ const dirSet = fs.readdirSync(path.join(d, '.harness', 'skills'), { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name).sort();
7312
+ const lockSet = Object.keys(JSON.parse(fs.readFileSync(path.join(d, '.harness', 'skills-lock.json'), 'utf8')).installedSkills).sort();
7313
+ const f12Ok = i1.status === 0 && i2.status === 0 && dirSet.length > 5 && JSON.stringify(dirSet) === JSON.stringify(lockSet);
7314
+ // F9: 실 CLI migrate 경유 — 구판 KO canonical 을 en 프로젝트 CLAUDE.md 에 심고 migrate
7315
+ const d9 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-f9-')); _d.push(d9);
7316
+ const i3 = cp.spawnSync(process.execPath, [CLI, 'init', d9, '--yes', '--language', 'en', '--no-stale-check', '--no-env'], { encoding: 'utf8', timeout: 40000 });
7317
+ const cl = path.join(d9, 'CLAUDE.md');
7318
+ const ko = Array.from({ length: 15 }, (_, i) => '한국어 구판 템플릿 라인 ' + i).join('\n');
7319
+ fs.appendFileSync(cl, '\n' + ko + '\nMY_CUSTOM_KEEP_42\n');
7320
+ fs.writeFileSync(path.join(d9, '.harness', 'HARNESS_VERSION'), '1.36.0');
7321
+ const mg = cp.spawnSync(process.execPath, [CLI, 'migrate', d9], { encoding: 'utf8', timeout: 60000, maxBuffer: 32 * 1024 * 1024 });
7322
+ const after = fs.readFileSync(cl, 'utf8');
7323
+ const mg2 = cp.spawnSync(process.execPath, [CLI, 'migrate', d9], { encoding: 'utf8', timeout: 60000, maxBuffer: 32 * 1024 * 1024 });
7324
+ const after2 = fs.readFileSync(cl, 'utf8');
7325
+ const f9Ok = i3.status === 0 && mg.status === 0 && mg2.status === 0
7326
+ && after.includes('Korean lines were preserved') // 실 파일에 안내
7327
+ && after.includes('MY_CUSTOM_KEEP_42') // 커스텀 무손실
7328
+ && (after2.match(/Korean lines were preserved/g) || []).length === 1; // 재migrate 멱등
7329
+ // (검수 #2 회귀) 같은 접두의 사용자 커스텀 라인은 생존해야
7330
+ const m = require(path.join(path.dirname(CLI), '..', 'lib', 'pure-utils.js'));
7331
+ const userLine = '> ⚠ 10 Korean lines were preserved for localization QA.';
7332
+ const surv = m._managedMerge('CLAUDE.md', '# N\n', '# O\n' + userLine, 'a', null, { lang: 'en' }).includes(userLine);
7333
+ ok = f12Ok && f9Ok && surv;
7334
+ if (!ok) console.log(` [f12/f9 디버그] f12=${f12Ok}(${dirSet.length}/${lockSet.length}) f9=${f9Ok}(mg=${mg.status}) surv=${surv}`);
7335
+ } catch (e) {} finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
7336
+ console.log(ok ? '✓ B(1.36.73) F12 skills-lock=디스크 실재 + F9 KO-잔존 이월 안내(멱등·무손실·소수 무경고)' : '✗ F12/F9 실패');
7337
+ if (!ok) failed++;
7338
+ }
7339
+
7340
+ // 1.36.74 (9차 헌트): state 스키마 fail-closed · restore 중복 거부 · api-skill skeleton 충돌 · MCP 마지막 줄 · 손상 표시 · --json 계약
7341
+ total++;
7342
+ {
7343
+ let ok = false;
7344
+ const _d = [];
7345
+ try {
7346
+ // #1 파싱되지만 스키마 무효인 state.json → 기존 run 보호
7347
+ const d1 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h9s-')); _d.push(d1);
7348
+ cp.spawnSync(process.execPath, [CLI, 'state', 'start', 'original-goal', '--path', d1, '--json'], { encoding: 'utf8', timeout: 30000 });
7349
+ const runFile = path.join(d1, '.leerness', 'runs', 'run-0001.json');
7350
+ const before = fs.readFileSync(runFile, 'utf8');
7351
+ fs.writeFileSync(path.join(d1, '.leerness', 'state.json'), '{}\n');
7352
+ const s2 = cp.spawnSync(process.execPath, [CLI, 'state', 'start', 'replacement-goal', '--path', d1, '--json'], { encoding: 'utf8', timeout: 30000 });
7353
+ const stateOk = s2.status === 1 && fs.readFileSync(runFile, 'utf8') === before;
7354
+ // 신규(빈 runs) 프로젝트에서는 관대 — 무효 state 가 있어도 정상 시작
7355
+ const d1b = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h9s2-')); _d.push(d1b);
7356
+ fs.mkdirSync(path.join(d1b, '.leerness'), { recursive: true });
7357
+ fs.writeFileSync(path.join(d1b, '.leerness', 'state.json'), '{}\n');
7358
+ const freshOk = cp.spawnSync(process.execPath, [CLI, 'state', 'start', 'g', '--path', d1b, '--json'], { encoding: 'utf8', timeout: 30000 }).status === 0;
7359
+ // #4 restore 충돌 거부 + 정상 복원 무회귀 + --force 우회
7360
+ const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h9r-')); _d.push(d2);
7361
+ cp.spawnSync(process.execPath, [CLI, 'init', d2, '--yes', '--minimal', '--no-env', '--no-stale-check'], { encoding: 'utf8', timeout: 40000 });
7362
+ const D = (a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d2], { encoding: 'utf8', timeout: 20000 });
7363
+ D(['decision', 'add', 'Collision Decision', '--reason', 'archived-original']);
7364
+ D(['decision', 'drop', 'Collision Decision']);
7365
+ D(['decision', 'add', 'Collision Decision', '--reason', 'active-replacement']);
7366
+ const conflict = D(['memory', 'restore', 'decisions', 'Collision Decision']);
7367
+ const cnt = () => { try { return (JSON.parse(D(['decision', 'list', '--json']).stdout).decisions || []).filter(x => x.title === 'Collision Decision').length; } catch { return -1; } };
7368
+ const restoreOk = conflict.status === 1 && cnt() === 1 && D(['memory', 'restore', 'decisions', 'Collision Decision', '--force']).status === 0 && cnt() === 2;
7369
+ // (검수 High-1) 형태는 유효하나 stale 한 runCounter 도 기존 run 을 덮어쓰면 안 된다
7370
+ const d1c = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h9st-')); _d.push(d1c);
7371
+ cp.spawnSync(process.execPath, [CLI, 'state', 'start', 'ORIGINAL', '--path', d1c, '--json'], { encoding: 'utf8', timeout: 30000 });
7372
+ const rf1 = path.join(d1c, '.leerness', 'runs', 'run-0001.json');
7373
+ const beforeStale = fs.readFileSync(rf1, 'utf8');
7374
+ const stp = path.join(d1c, '.leerness', 'state.json');
7375
+ const stj = JSON.parse(fs.readFileSync(stp, 'utf8')); stj.runCounter = 0; stj.currentRunId = null;
7376
+ fs.writeFileSync(stp, JSON.stringify(stj, null, 2));
7377
+ const staleR = cp.spawnSync(process.execPath, [CLI, 'state', 'start', 'REPLACEMENT', '--path', d1c, '--json'], { encoding: 'utf8', timeout: 30000 });
7378
+ const staleOk = staleR.status === 1 && fs.readFileSync(rf1, 'utf8') === beforeStale;
7379
+ // (검수 High-2) 날짜만 다른 논리적 중복도 차단 · (검수 Medium) 같은 날 무관한 항목은 오탐 없이 복원
7380
+ const d2b = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h9id-')); _d.push(d2b);
7381
+ cp.spawnSync(process.execPath, [CLI, 'init', d2b, '--yes', '--minimal', '--no-env', '--no-stale-check'], { encoding: 'utf8', timeout: 40000 });
7382
+ const D2 = (a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d2b], { encoding: 'utf8', timeout: 20000 });
7383
+ D2(['decision', 'add', 'Dup Title', '--reason', 'first']); D2(['decision', 'drop', 'Dup Title']);
7384
+ const ap2 = path.join(d2b, '.harness', 'decisions.archive.md');
7385
+ if (fs.existsSync(ap2)) fs.writeFileSync(ap2, fs.readFileSync(ap2, 'utf8').replace(/### \d{4}-\d{2}-\d{2}/g, '### 2020-01-01'));
7386
+ D2(['decision', 'add', 'Dup Title', '--reason', 'second']);
7387
+ const logicalOk = D2(['memory', 'restore', 'decisions', 'Dup Title']).status === 1;
7388
+ D2(['lesson', 'save', 'AAA 무관한 교훈']); D2(['lesson', 'save', 'BBB 다른 교훈']); D2(['lesson', 'drop', 'AAA 무관한 교훈']);
7389
+ const noFpOk = D2(['memory', 'restore', 'lessons', 'AAA']).status === 0; // 같은 날 저장이어도 정당한 복원은 통과
7390
+ // #5 skeleton 경로 id 충돌 → 양쪽 보존
7391
+ const d3 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h9a-')); _d.push(d3);
7392
+ const A = (u, dir) => cp.spawnSync(process.execPath, [CLI, 'api-skill', 'add', u, '--skeleton', '--no-crawl', '--direction', dir, '--path', d3, '--json'], { encoding: 'utf8', timeout: 25000 });
7393
+ A('ftp://example.test/docs?alpha', 'alpha-direction'); A('ftp://example.test/docs?beta', 'beta-direction');
7394
+ const askDir = path.join(d3, '.harness', 'api-skills');
7395
+ const all = fs.existsSync(askDir) ? fs.readdirSync(askDir).map(f => fs.readFileSync(path.join(askDir, f), 'utf8')).join('\n') : '';
7396
+ const apiOk = /alpha-direction/.test(all) && /beta-direction/.test(all);
7397
+ // #7 개행 없는 마지막 MCP 요청도 응답
7398
+ const noNl = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], { encoding: 'utf8', timeout: 25000, input: JSON.stringify({ jsonrpc: '2.0', id: 101, method: 'ping' }) });
7399
+ let mcpOk = false;
7400
+ try { mcpOk = JSON.parse((noNl.stdout || '').trim()).id === 101; } catch {}
7401
+ // #6 손상 frontmatter 표시 + api-skill --json 계약(사람용 텍스트 오염 0)
7402
+ const d4 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h9c-')); _d.push(d4);
7403
+ fs.mkdirSync(path.join(d4, '.harness', 'api-skills'), { recursive: true });
7404
+ fs.writeFileSync(path.join(d4, '.harness', 'api-skills', 'bad.md'), '---\nid: intended\nname: X\n# no closing');
7405
+ let corruptOk = false, jsonOk = false;
7406
+ try { const j = JSON.parse(cp.spawnSync(process.execPath, [CLI, 'api-skill', 'list', '--path', d4, '--json'], { encoding: 'utf8', timeout: 20000 }).stdout); corruptOk = j.corruptCount === 1 && j.skills[0].corrupt === true; } catch {}
7407
+ try { const j = JSON.parse(cp.spawnSync(process.execPath, [CLI, 'api-skill', 'add', 'not-a-url', '--path', d4, '--json'], { encoding: 'utf8', timeout: 25000 }).stdout); jsonOk = j.ok === false && j.code === 'fetch_failed'; } catch {}
7408
+ ok = stateOk && freshOk && restoreOk && staleOk && logicalOk && noFpOk && apiOk && mcpOk && corruptOk && jsonOk;
7409
+ if (!ok) console.log(` [h9 디버그] state=${stateOk} fresh=${freshOk} restore=${restoreOk} stale=${staleOk} logical=${logicalOk} noFp=${noFpOk} api=${apiOk} mcp=${mcpOk} corrupt=${corruptOk} json=${jsonOk}`);
7410
+ } catch (e) {} finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
7411
+ console.log(ok ? '✓ B(1.36.74) 9차헌트: state 스키마 fail-closed · restore 충돌거부(--force 우회) · skeleton 충돌보존 · MCP 마지막줄 · 손상표시 · --json 계약' : '✗ 9차 헌트 수정 실패');
7412
+ if (!ok) failed++;
7413
+ }
7414
+
7301
7415
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
7302
7416
  if (failed > 0) process.exit(1);