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 +14 -0
- package/README.md +4 -4
- package/bin/leerness.js +126 -35
- package/package.json +1 -1
- package/scripts/e2e.js +75 -0
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.
|
|
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.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.
|
|
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') 시 호스트 프로세스 오염.
|
|
@@ -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
|
-
|
|
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({
|
|
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) {
|
|
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) {
|
|
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) {
|
|
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)) {
|
|
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) {
|
|
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
|
-
|
|
6215
|
-
|
|
6216
|
-
if (direction)
|
|
6217
|
-
|
|
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
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
|
|
6238
|
-
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));
|
|
6239
6251
|
} catch (e) {
|
|
6240
|
-
|
|
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
|
-
|
|
6244
|
-
|
|
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
|
-
|
|
6253
|
-
|
|
6254
|
-
|
|
6255
|
-
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));
|
|
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
|
-
|
|
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', () =>
|
|
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
|
-
|
|
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.
|
|
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);
|