leerness 1.36.73 → 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,19 @@
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
+
3
17
  ## 1.36.73 — 2026-07-25 — 8차 헌트 완전 종결: F12 skills-lock 정합 + F9 설계 결정
4
18
 
5
19
  8차 홀리스틱 헌트(16건)의 마지막 설계 판단 2건을 결론 — 이로써 16/16 전건 처리 완료.
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.73 하네스를 사용합니다. 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.73는 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.73는 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.73 릴리스 태그 이력** (수백 라운드) · _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.73: 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.73';
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') 시 호스트 프로세스 오염.
@@ -6094,7 +6094,9 @@ function _loadAPISkill(root, id) {
6094
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 크래시 방지.
6095
6095
  const content = read(fp).replace(/\r\n/g, '\n').replace(/\r/g, '\n');
6096
6096
  const fm = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
6097
- 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' };
6098
6100
  const meta = {};
6099
6101
  fm[1].split('\n').forEach(l => {
6100
6102
  const m = l.match(/^(\w+):\s*(.*)$/);
@@ -6140,23 +6142,28 @@ async function apiSkillCmd(root, sub) {
6140
6142
  const dir = _apiSkillsDir(root);
6141
6143
  if (sub === 'list') {
6142
6144
  if (!fs.existsSync(dir)) {
6143
- if (has('--json')) { log(JSON.stringify({ skills: [] })); return; }
6145
+ if (has('--json')) { log(JSON.stringify({ skills: [], corruptCount: 0 })); return; } // (검수 Low) 빈 저장소도 동일 필드 형태
6144
6146
  log(dm(`(아직 API skill 없음 — leerness api-skill add <url> 으로 생성)`));
6145
6147
  return;
6146
6148
  }
6147
6149
  const skills = _listAPISkills(root);
6150
+ const _corrupt = skills.filter(s => s.corrupt); // 1.36.74 (#6): 손상 레코드를 정상처럼 보이게 두지 않는다
6148
6151
  if (has('--json')) {
6149
- 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));
6150
6156
  return;
6151
6157
  }
6152
6158
  log(cy(`# leerness api-skill list (1.9.245, UR-0015)`));
6153
6159
  log('');
6154
6160
  if (skills.length === 0) { log(dm(`(없음)`)); return; }
6155
6161
  skills.forEach(s => {
6156
- log(` • ${gr(s.id)} — ${s.name}`);
6162
+ log(` • ${gr(s.id)} — ${s.name}${s.corrupt ? yl(' ⚠ 손상(frontmatter 파싱 불가)') : ''}`);
6157
6163
  if (s.urls && s.urls.length) log(dm(` ${s.urls[0]}${s.urls.length > 1 ? ` (+${s.urls.length - 1})` : ''}`));
6158
6164
  if (s.direction) log(dm(` 방향: ${s.direction.slice(0, 80)}`));
6159
6165
  });
6166
+ if (_corrupt.length) log(yl(` ⚠ 손상 ${_corrupt.length}건 — 해당 .md 의 frontmatter(--- 구분선)를 확인하세요`));
6160
6167
  return;
6161
6168
  }
6162
6169
  // 1.9.245: 위치 인자 robust 추출 — argv 에서 cmd/sub 뒤 첫 non-flag (flag value 도 skip)
@@ -6177,9 +6184,9 @@ async function apiSkillCmd(root, sub) {
6177
6184
  };
6178
6185
  if (sub === 'show') {
6179
6186
  const id = arg('--id') || _positionalAfterSub();
6180
- 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 에러 구조화
6181
6188
  const s = _loadAPISkill(root, id);
6182
- if (!s) { log(rd(`api-skill 없음: ${id}`)); process.exitCode = 1; return; }
6189
+ if (!s) { failJson(has('--json'), 'not_found', `api-skill 없음: ${id}`); return; }
6183
6190
  if (has('--json')) { log(JSON.stringify(s, null, 2)); return; }
6184
6191
  log(fs.readFileSync(path.join(dir, id + '.md'), 'utf8'));
6185
6192
  return;
@@ -6198,23 +6205,28 @@ async function apiSkillCmd(root, sub) {
6198
6205
  }
6199
6206
  if (sub === 'drop') {
6200
6207
  const id = arg('--id') || _positionalAfterSub();
6201
- 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; }
6202
6209
  const fp = path.join(dir, id + '.md');
6203
- 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 에러 구조화
6204
6211
  fs.unlinkSync(fp);
6212
+ if (has('--json')) { log(JSON.stringify({ ok: true, dropped: id }, null, 2)); return; }
6205
6213
  log(gr(`✓ 삭제: ${id}`));
6206
6214
  return;
6207
6215
  }
6208
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); };
6209
6221
  const url = arg('--url') || _positionalAfterSub();
6210
- 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; }
6211
6223
  const direction = arg('--direction', '');
6212
6224
  const name = arg('--name', '');
6213
6225
  const noCrawl = has('--no-crawl');
6214
- log(cy(`# leerness api-skill add (1.9.245, UR-0015)`));
6215
- log(` URL: ${url}`);
6216
- if (direction) log(` 방향: ${direction}`);
6217
- 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)...`));
6218
6230
  try {
6219
6231
  const doc = await _collectAPIDoc(url, { direction, noCrawl });
6220
6232
  const skillName = name || doc.title || new URL(url).hostname;
@@ -6231,17 +6243,27 @@ async function apiSkillCmd(root, sub) {
6231
6243
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
6232
6244
  const md = _serializeAPISkill(id, skillName, [url], direction, doc);
6233
6245
  fs.writeFileSync(path.join(dir, id + '.md'), md, 'utf8');
6234
- log(gr(`✓ 저장: .harness/api-skills/${id}.md`));
6235
- log(dm(` title: ${doc.title || '(none)'}`));
6236
- log(dm(` related links: ${(doc.related_links || []).length}`));
6237
- log(dm(` text size: ${doc.text.length} chars`));
6238
- 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));
6239
6251
  } catch (e) {
6240
- log(rd(`✗ Fetch 실패: ${e.message}`));
6252
+ _hlog(rd(`✗ Fetch 실패: ${e.message}`));
6241
6253
  // 1.9.245: --skeleton fallback — Cloudflare/WAF 차단된 URL 도 빈 .md 골격 생성 → 사용자가 수동 채움
6242
6254
  if (has('--skeleton') || /403|401|429/.test(e.message)) {
6243
- const skillName = name || new URL(url).hostname;
6244
- 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
+ }
6245
6267
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
6246
6268
  const stub = {
6247
6269
  url, title: skillName, text: `(fetch 실패 — 사용자가 직접 ${url} 에서 내용을 복사해 채워주세요)`,
@@ -6249,11 +6271,12 @@ async function apiSkillCmd(root, sub) {
6249
6271
  };
6250
6272
  const md = _serializeAPISkill(id, skillName, [url], direction, stub);
6251
6273
  fs.writeFileSync(path.join(dir, id + '.md'), md, 'utf8');
6252
- log(yl(`⚠ 빈 골격 생성 (--skeleton fallback): .harness/api-skills/${id}.md`));
6253
- log(dm(` → 직접 ${url} 내용을 복사해서 .md 파일에 채워넣어주세요`));
6254
- log(dm(` → 이후 leerness handoff 가 매칭 시 참조됩니다`));
6255
- 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));
6256
6278
  } else {
6279
+ if (_j) log(JSON.stringify({ ok: false, code: 'fetch_failed', error: e.message, url }, null, 2)); // 실패도 구조화(종전: --json 에 사람용 텍스트만)
6257
6280
  process.exitCode = 1;
6258
6281
  }
6259
6282
  }
@@ -9744,6 +9767,39 @@ function memoryRestoreCmd(root, surface, target) {
9744
9767
  const activePath = surface === 'decisions' ? decisionsPath(root)
9745
9768
  : surface === 'lessons' ? lessonsPath(root)
9746
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
+ }
9747
9803
  // active 파일에 복귀 (헤더 보존)
9748
9804
  for (const blk of restoredBlocks) {
9749
9805
  append(activePath, '\n' + blk + '\n');
@@ -19057,6 +19113,17 @@ function mcpServeCmd(root) {
19057
19113
 
19058
19114
  // stdin JSON-RPC 한 줄 단위
19059
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
+ };
19060
19127
  process.stdin.setEncoding('utf8');
19061
19128
  process.stdin.on('data', chunk => {
19062
19129
  buf += chunk;
@@ -19064,16 +19131,15 @@ function mcpServeCmd(root) {
19064
19131
  while ((nl = buf.indexOf('\n')) !== -1) {
19065
19132
  const line = buf.slice(0, nl).trim();
19066
19133
  buf = buf.slice(nl + 1);
19067
- if (!line) continue;
19068
- // 1.36.39 (#3): 파싱과 핸들링 분리 — 핸들러 내부 예외가 -32700 "Parse error"(id null)로 오보되지 않게.
19069
- let req = null;
19070
- try { req = JSON.parse(line); }
19071
- catch (e) { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error: ' + e.message } }); continue; }
19072
- try { handleRequest(req); }
19073
- 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);
19074
19135
  }
19075
19136
  });
19076
- 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
+ });
19077
19143
  // 인터럽트 처리
19078
19144
  process.on('SIGINT', () => process.exit(0));
19079
19145
  process.on('SIGTERM', () => process.exit(0));
@@ -20248,7 +20314,32 @@ function _loadLeernessState(root) {
20248
20314
  const f = path.join(_leernessStateDir(root), 'state.json');
20249
20315
  if (!exists(f)) return { schemaVersion: 1, project: detectProjectName(root), currentRunId: null, runCounter: 0, updatedAt: null };
20250
20316
  // 1.36.28 (#3): 손상 파일을 빈 상태로 오인하면 카운터가 0으로 리셋돼 run-0001 을 덮어쓴다(데이터 유실). 파싱 실패는 던진다.
20251
- 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;
20252
20343
  }
20253
20344
  function _saveLeernessState(root, state) {
20254
20345
  const dir = _leernessStateDir(root); mkdirp(dir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.73",
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
@@ -7337,5 +7337,80 @@ total++;
7337
7337
  if (!ok) failed++;
7338
7338
  }
7339
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
+
7340
7415
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
7341
7416
  if (failed > 0) process.exit(1);