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 +22 -0
- package/README.md +4 -4
- package/bin/leerness.js +141 -38
- package/lib/pure-utils.js +11 -2
- package/package.json +1 -1
- package/scripts/e2e.js +114 -0
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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({
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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)) {
|
|
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) {
|
|
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
|
-
|
|
6203
|
-
|
|
6204
|
-
if (direction)
|
|
6205
|
-
|
|
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
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
6232
|
-
|
|
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
|
-
|
|
6241
|
-
|
|
6242
|
-
|
|
6243
|
-
if (
|
|
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
|
-
|
|
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', () =>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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);
|