leerness 1.36.106 → 1.36.107

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/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.106 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.107 하네스를 사용합니다. 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.106는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.107는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
180
180
 
181
181
  ```jsonc
182
182
  // 카테고리별
@@ -197,7 +197,7 @@ Leerness v1.36.106는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
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.106 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.107 릴리스 태그 이력** (수백 라운드) · _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.106: 2026-08-07
238
+ Last synced by Leerness v1.36.107: 2026-08-08
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.106';
37
+ const VERSION = '1.36.107';
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') 시 호스트 프로세스 오염.
@@ -620,20 +620,47 @@ function skillLock(skills, root) {
620
620
  // 두 등급이 정직하게 도는 편이 셋 중 하나가 거짓말하는 것보다 낫다. strict 는 실제 게이트를 갖출 때 다시 연다.
621
621
  const _MODES = ['minimal', 'standard'];
622
622
  function _normMode(v) { const s = String(v || '').toLowerCase(); return _MODES.includes(s) ? s : 'standard'; }
623
+ // 1.36.107: 재init 이 사용자의 선택을 되돌리지 않게 하는 두 술어. 우선순위는 **명시 플래그 > 기존 저장값 > 기본값**.
624
+ // opts.mode 는 호출부에서 정규화하지 않고 원본을 넘겨야 한다 — 미리 _normMode 를 걸면 '미지정' 과 '--mode standard'
625
+ // 가 구분되지 않아 명시적으로 standard 로 올리는 것과 그냥 재init 한 것이 같아진다.
626
+ // 손상 매니페스트에서는 prevManifest 가 비어 오므로 자연히 기본값으로 새로 만든다(init 은 복구 경로이기도 하다).
627
+ function _initMode(opts) {
628
+ const o = opts || {};
629
+ if (o.mode) return _normMode(o.mode);
630
+ const prev = (o.prevManifest && typeof o.prevManifest === 'object') ? o.prevManifest : {};
631
+ return _normMode(prev.mode);
632
+ }
633
+ function _initInstalledAt(opts) {
634
+ const prev = (opts && opts.prevManifest && typeof opts.prevManifest === 'object') ? opts.prevManifest : {};
635
+ return (typeof prev.installedAt === 'string' && prev.installedAt) ? prev.installedAt : now();
636
+ }
623
637
  // `--mode` 는 **등급을 정하는 명령에서만** 읽는다(init · mode).
624
638
  // codex 검수 HIGH#1: 전역으로 읽으면 `handoff . --mode minimal` 처럼 무관한 명령에 붙은 플래그가
625
639
  // 등급 미지정 프로젝트의 출력을 바꾼다 — 영속되지 않아도 놀람이고, "미지정=standard" 라는 승인 조건을 흐린다.
626
640
  function _modeFlagAllowed() {
627
641
  try { const c = (process.argv[2] || ''); return c === 'init' || c === 'mode'; } catch { return false; }
628
642
  }
643
+ // 1.36.107 (손상 스토어 사냥): manifest 는 **읽기 실패를 구분해서** 돌려준다.
644
+ // 종전엔 손상이든 미설정이든 똑같이 'standard' 로 떨어져, 사용자는 자기 등급이 날아간 걸 몰랐다.
645
+ // 같은 규율이 토글에는 1.36.49 에 섰는데(fail-closed) manifest 에는 없었고, 나는 거기에 **쓰기**를 추가했다.
646
+ function _readManifestChecked(root) {
647
+ const mf = path.join(absRoot(root || process.cwd()), '.harness', 'manifest.json');
648
+ if (!exists(mf)) return { path: mf, exists: false, corrupt: false, data: {} };
649
+ let raw = null;
650
+ try { raw = read(mf); } catch { return { path: mf, exists: true, corrupt: true, reason: '읽기 실패', data: {} }; }
651
+ let j = null;
652
+ try { j = JSON.parse(raw); } catch { return { path: mf, exists: true, corrupt: true, reason: 'JSON 파싱 실패', data: {} }; }
653
+ if (!j || typeof j !== 'object' || Array.isArray(j)) return { path: mf, exists: true, corrupt: true, reason: '최상위가 객체가 아님', data: {} };
654
+ return { path: mf, exists: true, corrupt: false, data: j };
655
+ }
629
656
  function _projectMode(root) {
630
657
  try {
631
658
  if (_modeFlagAllowed()) {
632
659
  const flag = String(arg('--mode', '') || '').toLowerCase();
633
660
  if (_MODES.includes(flag)) return flag;
634
661
  }
635
- const mf = path.join(absRoot(root || process.cwd()), '.harness', 'manifest.json');
636
- if (exists(mf)) return _normMode((JSON.parse(read(mf)) || {}).mode);
662
+ const m = _readManifestChecked(root);
663
+ if (!m.corrupt) return _normMode(m.data.mode);
637
664
  } catch {}
638
665
  return 'standard'; // 기존 프로젝트는 무변경 — 매니페스트에 mode 가 없으면 standard
639
666
  }
@@ -649,7 +676,12 @@ function coreFiles(root, lang = 'ko', selectedSkills = [], opts = {}) {
649
676
  '.harness/HARNESS_VERSION': VERSION + '\n',
650
677
  '.harness/LANGUAGE': lang + '\n',
651
678
  // 1.36.105 (P-0015): mode 를 매니페스트에 기록. 기본은 standard = 종전 동작과 바이트 동일.
652
- '.harness/manifest.json': JSON.stringify({ project, leernessVersion: VERSION, language: lang, mode: _normMode(opts.mode), installedAt: now(), minimal: !!opts.minimal }, null, 2) + '\n',
679
+ // 1.36.107 (같은 라운드 2번째 지점): 재init 사용자의 등급 선택을 조용히 되돌리던 차단.
680
+ // `mode set minimal` 로 등급을 낮춘 뒤 업그레이드 재init 하면 mode 가 standard 로 되돌아갔다(실측, exit 0 · 무경고).
681
+ // manifest 는 managedOverwrite 목록에 있어 매번 템플릿으로 재생성되는데, 템플릿은 사용자 선택을 모른다.
682
+ // 기존 값을 기본으로 삼고 **명시 플래그(--mode)만 이긴다**. installedAt 도 마찬가지 — 매번 갱신되면 이름이 거짓말이 된다.
683
+ // 이전 값은 호출부가 읽어 넘긴다(coreFiles 가 디스크를 몰래 읽으면 CWD 에 따라 산출물이 달라진다).
684
+ '.harness/manifest.json': JSON.stringify({ project, leernessVersion: VERSION, language: lang, mode: _initMode(opts), installedAt: _initInstalledAt(opts), minimal: !!opts.minimal }, null, 2) + '\n',
653
685
  '.harness/skills-lock.json': skillLock(selectedSkills, root),
654
686
  '.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`),
655
687
  '.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`),
@@ -881,7 +913,9 @@ leerness memory restore <surface> <target> # archive → active 복귀 (DELETE
881
913
  // 1.36.105 (P-0015): mode=minimal 은 **AI 가 매 세션 읽는 지침 자체**를 줄인다.
882
914
  // --minimal(파일 수)과 다른 축이다 — 그쪽은 세션 적재를 0.3% 밖에 못 줄였다(실측).
883
915
  // 남기는 것은 핵심 3종뿐: 모델 독립 상태 · 완료 증거 검증 · 인수인계. verify-claim/session close 는 여기서도 필수다.
884
- if (_normMode(opts.mode) === 'minimal') {
916
+ // 1.36.107: 등급 판정은 manifest 와 **같은 술어**를 써야 한다. 여기만 opts.mode 보면
917
+ // 재init 시 manifest 는 minimal 인데 지침 파일은 standard 로 재생성되는 불일치가 난다.
918
+ if (_initMode(opts) === 'minimal') {
885
919
  _files['AGENTS.md'] = `${MARK}\n` + (lang === 'en' ? _AGENTS_MINIMAL_EN : _AGENTS_MINIMAL_KO);
886
920
  _files['CLAUDE.md'] = `${MARK}\n` + (lang === 'en'
887
921
  ? `# Claude Code Instructions\n\nFollow AGENTS.md.\n`
@@ -1248,7 +1282,12 @@ async function install(root, opts = {}) {
1248
1282
  if (resolved.permissionMode) log(`Agent 권한 모드: ${resolved.permissionMode} (1.9.174 — REPL에서 \`:permissions extended|full\` 로 즉시 변경 가능)`);
1249
1283
  // 1.9.10: 스킬 카탈로그 출처 안내
1250
1284
  // 1.9.184 (사용자 명시): leerness-skillpack 미사용 정책 — 안내 메시지 제거. builtin catalog 만 사용.
1251
- const files = coreFiles(root, lang, skills, { minimal: !!opts.minimal, mode: opts.mode });
1285
+ // 1.36.107: 기존 매니페스트를 읽어 넘긴다 재init mode/installedAt 을 되돌리지 않도록.
1286
+ // 손상이면 data 가 비어 오고, init 은 새로 만든다(복구). 경고는 아래에서 한 번 낸다.
1287
+ const _prevMani = _readManifestChecked(root);
1288
+ const _coreOpts = { minimal: !!opts.minimal, mode: opts.mode, prevManifest: _prevMani.corrupt ? {} : _prevMani.data };
1289
+ if (_prevMani.corrupt) log(` ⚠ manifest.json 손상(${_prevMani.reason}) — 기존 등급/설치시각을 읽을 수 없어 기본값으로 재생성합니다: ${_prevMani.path}`);
1290
+ const files = coreFiles(root, lang, skills, _coreOpts);
1252
1291
  // 1.36.60 (F-05 2회차, 검수 #1 High): 반대-언어 canonical 템플릿 — 언어 전환 재init/마이그레이션 시
1253
1292
  // 구 언어 템플릿 라인을 "사용자 커스텀"으로 오인해 통째로 Preserved 에 이월하던 것을 차감 병합으로 해소.
1254
1293
  // (검수 2차 High): 차감은 "실제 언어 전환일 때만" — 동일-언어 재init 에 무조건 적용하면 반대-언어 템플릿과
@@ -1259,7 +1298,9 @@ async function install(root, opts = {}) {
1259
1298
  const _langSwitched = !!(_prevLang && (_prevLang === 'ko' || _prevLang === 'en') && _prevLang !== lang);
1260
1299
  const _altLang = lang === 'en' ? 'ko' : 'en';
1261
1300
  let _altFiles = {};
1262
- if (_langSwitched) { try { _altFiles = coreFiles(root, _altLang, skills, { minimal: !!opts.minimal }); } catch {} }
1301
+ // 1.36.107: 차감용 반대-언어 템플릿도 **같은 등급**으로 만들어야 한다. 등급이 다르면 차감이 어긋나
1302
+ // minimal 프로젝트가 언어를 바꿀 때 구 언어 템플릿이 "사용자 커스텀" 으로 오인돼 Preserved 로 이월된다.
1303
+ if (_langSwitched) { try { _altFiles = coreFiles(root, _altLang, skills, Object.assign({}, _coreOpts)); } catch {} }
1263
1304
  const managedOverwrite = new Set([
1264
1305
  'AGENTS.md','CLAUDE.md','.cursor/rules/leerness.mdc','.github/copilot-instructions.md',
1265
1306
  '.harness/session-workflow.md', // 1.36.60 (검수 #1): managed 편입 — 종전엔 재init 시 구 언어/구 버전 그대로 방치
@@ -25777,9 +25818,12 @@ async function main() {
25777
25818
  if (!exists(path.join(_mRoot, '.harness'))) { failJson(_mj, 'harness_missing', `leerness 미설치: ${_mRoot} — 먼저 leerness init`); return; }
25778
25819
  const sub = args[1] && !args[1].startsWith('-') && !_MODES.includes(args[1]) && !/^[.\/\\]|^[A-Za-z]:/.test(args[1]) ? args[1] : (_MODES.includes(args[1]) ? 'set' : 'get');
25779
25820
  const cur = _projectMode(_mRoot);
25821
+ // 1.36.107: 손상을 조용히 'standard' 로 보고하지 않는다 — 사용자는 설정이 날아간 걸 알아야 한다.
25822
+ const _mChk = _readManifestChecked(_mRoot);
25780
25823
  if (sub === 'get') {
25781
- if (_mj) { log(JSON.stringify({ ok: true, root: _mRoot, mode: cur, modes: _MODES }, null, 2)); return; }
25824
+ if (_mj) { log(JSON.stringify({ ok: !_mChk.corrupt, root: _mRoot, mode: cur, modes: _MODES, corrupt: !!_mChk.corrupt, corruptReason: _mChk.reason || null }, null, 2)); if (_mChk.corrupt) process.exitCode = 1; return; }
25782
25825
  log(_cy(`# leerness mode — ${cur}`));
25826
+ if (_mChk.corrupt) log(` ⚠ manifest.json 손상(${_mChk.reason}) — 위 값은 **저장값이 아니라 기본값**입니다: ${_mChk.path}`);
25783
25827
  log(` minimal 핵심 3종만 읽힌다 (handoff · verify-claim · session close)`);
25784
25828
  log(` standard 현재 기본 — 전체 지침/상태 문서`);
25785
25829
  // 목록은 _MODES 에서 유도한다 — 하드코딩하면 등급을 뺐을 때 안내만 남아 없는 등급을 계속 광고한다
@@ -25790,8 +25834,18 @@ async function main() {
25790
25834
  if (sub === 'set') {
25791
25835
  const want = _normMode(args.find(a => _MODES.includes(a)));
25792
25836
  if (!args.some(a => _MODES.includes(a))) { failJson(_mj, 'invalid_mode', `mode 는 ${_MODES.join('|')} 중 하나입니다`); return; }
25793
- const mf = path.join(_mRoot, '.harness', 'manifest.json');
25794
- let m = {}; try { m = JSON.parse(read(mf)) || {}; } catch { m = {}; }
25837
+ // 1.36.107 (손상 스토어 사냥): 종전엔 `catch { m = {} }` 후 덮어썼다 — 손상 매니페스트 위에 새로 쓰면서
25838
+ // project·language·installedAt **남아 있던 필드까지 통째로 날렸다**(실측 3/3 손상 유형에서 재현).
25839
+ // 1.36.49 가 토글에 세운 규율과 같다: 변경 진입점은 fail-closed. 읽지 못하면 쓰지 않는다.
25840
+ if (_mChk.corrupt) {
25841
+ failJson(_mj, 'manifest_corrupt', `manifest.json 손상(${_mChk.reason}) — 덮어쓰기 거부: ${_mChk.path}\n 복구 후 재시도하세요(지금 쓰면 project/language 등 남은 필드가 사라집니다)`);
25842
+ return;
25843
+ }
25844
+ const mf = _mChk.path;
25845
+ // 1.36.107 (codex 검수, 재현됨): Object.assign 은 [[Set]] 을 쓰므로 `__proto__` 키에서 setter 가 걸려
25846
+ // 그 필드를 **조용히 버린다**(실측: 주입 후 mode set → 파일에서 사라짐). 스프레드는 CreateDataProperty 라 보존한다.
25847
+ // 병적인 입력이긴 하나 이 수정이 내건 계약이 "남은 필드를 전부 보존" 이므로, 예외를 두면 계약이 거짓이 된다.
25848
+ const m = { ..._mChk.data };
25795
25849
  m.mode = want; writeUtf8(mf, JSON.stringify(m, null, 2) + '\n');
25796
25850
  // 지침 파일은 등급의 산출물이므로 즉시 재생성한다 — 안 그러면 mode 는 이름만 바뀌고 읽히는 양은 그대로다.
25797
25851
  let regenerated = 0;
@@ -25870,8 +25924,14 @@ async function main() {
25870
25924
  // 12,000 은 신규 설치에 약 40% 여유를 주면서 오래 자란 프로젝트는 확실히 잡는 선이다.
25871
25925
  const budget = { minimal: 1200, standard: 12000 }[mode] || 12000;
25872
25926
  const over = total > budget;
25873
- if (_bj) { log(JSON.stringify({ ok: !over, root: _bRoot, mode, budget, total, over, parts }, null, 2)); if (over) process.exitCode = 1; return; }
25927
+ // 1.36.107 (codex 검수 P3, 재현됨): `mode` 손상을 알리는데 여기서는 조용히 standard 떨어져
25928
+ // 같은 매니페스트를 두고 두 표면이 다른 말을 했다(mode → exit 1 + corrupt, context budget → exit 0 + ok).
25929
+ // 등급을 파생시키는 표면은 손상 상태를 함께 실어야 한다 — 안 그러면 "예산 이내" 라는 판정이
25930
+ // 실은 읽지 못한 등급의 기본값 위에서 나온 것이 된다. 술어를 공유하지 않으면 표면마다 진실이 갈린다.
25931
+ const _bChk = _readManifestChecked(_bRoot);
25932
+ if (_bj) { log(JSON.stringify({ ok: !over && !_bChk.corrupt, root: _bRoot, mode, budget, total, over, corrupt: !!_bChk.corrupt, corruptReason: _bChk.reason || null, parts }, null, 2)); if (over || _bChk.corrupt) process.exitCode = 1; return; }
25874
25933
  log(_cy(`# leerness context budget — mode ${mode}`));
25934
+ if (_bChk.corrupt) log(` ⚠ manifest.json 손상(${_bChk.reason}) — 위 등급은 **저장값이 아니라 기본값**입니다: ${_bChk.path}`);
25875
25935
  for (const p of parts) log(` ${String(p.tokens).padStart(6)} tok ${String(p.bytes).padStart(7)} B ${p.what}`);
25876
25936
  log(` ${String(total).padStart(6)} tok 합계 · 예산 ${budget} tok`);
25877
25937
  log('');
@@ -25888,7 +25948,13 @@ async function main() {
25888
25948
  if (cmd === 'init') {
25889
25949
  const _initRoot = absRoot(arg('--path', args[1] && !args[1].startsWith('-') ? args[1] : process.cwd()));
25890
25950
  // 1.36.105 (P-0015): --mode 는 '무엇을 읽히나' 를 정한다. --minimal(파일 수)과 직교하며 함께 쓸 수 있다.
25891
- const _initOpts = { force:has('--force'), dry:has('--dry-run'), migration:false, minimal:has('--minimal'), noEnv:has('--no-env'), mode:_normMode(arg('--mode', null)) };
25951
+ // 1.36.107: 여기서 _normMode 걸면 미지정이 'standard' 굳어 **기존 등급을 되돌린다**.
25952
+ // 원본을 넘기고 정규화는 _initMode 가 한다(명시 > 저장값 > 기본). 잘못된 값은 거기서 standard 로 떨어진다.
25953
+ const _initMode_flag = arg('--mode', null);
25954
+ if (_initMode_flag !== null && !_MODES.includes(String(_initMode_flag).toLowerCase())) {
25955
+ warn(`알 수 없는 등급 '${_initMode_flag}' — ${_MODES.join('|')} 중 하나여야 합니다. standard 로 진행합니다.`);
25956
+ }
25957
+ const _initOpts = { force:has('--force'), dry:has('--dry-run'), migration:false, minimal:has('--minimal'), noEnv:has('--no-env'), mode:_initMode_flag };
25892
25958
  if (has('--json')) {
25893
25959
  // 1.10.2 (UR-0146): init --json — 사람용 출력 묵음(setQuiet) + 순수 JSON 요약 1개. 기존엔 --json 을 silent ignore(배너만 출력).
25894
25960
  // 1.10.3 (UR-0173): json:true → 배너/진행바 억제(TTY 누출 차단), nonInteractive:true → 대화 메뉴 차단(머신 계약).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.106",
3
+ "version": "1.36.107",
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
@@ -10086,6 +10086,139 @@ total++;
10086
10086
  if (!ok) failed++;
10087
10087
  }
10088
10088
 
10089
+ // ── 1.36.107: 손상된 manifest.json 위에 덮어써서 남은 필드를 날렸다.
10090
+ // 손상 스토어 클래스는 이 저장소에서 3회 재발했고(토글 1.36.49 · preview 형상 1.36.28 · 토글 값 1.36.92),
10091
+ // 스토어 9종 × 손상 3유형 = 27조합을 실제로 돌려보니 **manifest 만** 3/3 실패였다 —
10092
+ // 다른 8종은 fail-closed 규율이 서 있는데 manifest 에는 없었고, 1.36.105 가 거기에 쓰기(mode set)를 추가했다.
10093
+ {
10094
+ total++;
10095
+ let ok = false; const dbg = {};
10096
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-mf107-'));
10097
+ try {
10098
+ const ENV = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb });
10099
+ const mk = (tag) => { const d = path.join(sb, tag); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}'); cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 180000, env: ENV }); return d; };
10100
+ const R = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: d, encoding: 'utf8', timeout: 120000, env: ENV });
10101
+ // 손상 3유형 — 파싱 실패 · 타입 뒤바뀜 · 배열
10102
+ const kinds = { broken: '{ this is not json', typeflip: '"a string"', arr: '[1,2,3]' };
10103
+ const res = [];
10104
+ for (const [tag, body] of Object.entries(kinds)) {
10105
+ const d = mk('c-' + tag);
10106
+ const mf = path.join(d, '.harness', 'manifest.json');
10107
+ const orig = fs.readFileSync(mf, 'utf8');
10108
+ fs.writeFileSync(mf, body);
10109
+ const rg = R(d, ['mode']);
10110
+ const warns = /손상/.test((rg.stdout || '') + (rg.stderr || '')); // 조용히 기본값으로 떨어지지 않는다
10111
+ const rs = R(d, ['mode', 'set', 'minimal']);
10112
+ const refused = rs.status !== 0 && fs.readFileSync(mf, 'utf8') === body; // 손상 위에 쓰지 않는다
10113
+ fs.writeFileSync(mf, orig);
10114
+ const rs2 = R(d, ['mode', 'set', 'minimal']);
10115
+ let after = null; try { after = JSON.parse(fs.readFileSync(mf, 'utf8')); } catch {}
10116
+ const recovers = rs2.status === 0 && !!after && after.mode === 'minimal' && !!after.project && !!after.language;
10117
+ res.push(warns && refused && recovers);
10118
+ }
10119
+ dbg.corruptHandled = res.length === 3 && res.every(Boolean);
10120
+ // 대조군 — 정상 매니페스트는 종전대로 동작하고 기존 필드가 전부 살아남는다
10121
+ const good = mk('good');
10122
+ const gmf = path.join(good, '.harness', 'manifest.json');
10123
+ const before = JSON.parse(fs.readFileSync(gmf, 'utf8'));
10124
+ const gr = R(good, ['mode']);
10125
+ dbg.noFalseAlarm = gr.status === 0 && !/손상/.test((gr.stdout || '') + (gr.stderr || ''));
10126
+ R(good, ['mode', 'set', 'minimal']);
10127
+ const afterG = JSON.parse(fs.readFileSync(gmf, 'utf8'));
10128
+ dbg.fieldsPreserved = afterG.mode === 'minimal' && afterG.project === before.project
10129
+ && afterG.language === before.language && afterG.installedAt === before.installedAt;
10130
+ // codex 검수 P3(재현됨): 등급을 **파생시키는 다른 표면**도 같은 말을 해야 한다.
10131
+ // 손상인데 `mode` 는 exit 1 + corrupt, `context budget` 은 exit 0 + ok 였다 — 같은 파일을 두고 두 표면이 갈렸다.
10132
+ // 술어를 공유하지 않으면 "예산 이내" 라는 판정이 읽지 못한 등급의 기본값 위에서 나온다.
10133
+ const dsurf = mk('surf');
10134
+ const smf = path.join(dsurf, '.harness', 'manifest.json');
10135
+ const sorig = fs.readFileSync(smf, 'utf8');
10136
+ fs.writeFileSync(smf, '{ not valid json');
10137
+ const rb = R(dsurf, ['context', 'budget', '--json']);
10138
+ let bj = null; try { bj = JSON.parse(rb.stdout); } catch {}
10139
+ const surfCorrupt = rb.status === 1 && !!bj && bj.corrupt === true && bj.ok === false;
10140
+ fs.writeFileSync(smf, sorig); // 대조군 — 정상이면 손상 표시가 없어야 한다
10141
+ const rb2 = R(dsurf, ['context', 'budget', '--json']);
10142
+ let bj2 = null; try { bj2 = JSON.parse(rb2.stdout); } catch {}
10143
+ dbg.surfaceAgrees = surfCorrupt && !!bj2 && bj2.corrupt === false;
10144
+ ok = dbg.corruptHandled && dbg.noFalseAlarm && dbg.fieldsPreserved && dbg.surfaceAgrees;
10145
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
10146
+ console.log(ok ? '✓ J(1.36.107) 손상 manifest: 조용한 기본값 금지 + 덮어쓰기 거부(fail-closed) + 복구 후 정상 + 대조군 무회귀'
10147
+ : '✗ 1.36.107 손상 manifest 처리 실패 ' + JSON.stringify(dbg));
10148
+ if (!ok) failed++;
10149
+ }
10150
+
10151
+ // ── 1.36.107 블록 K: 같은 라운드에서 **두 번째 쓰기 지점**이 나왔다.
10152
+ // 손상 스토어를 고치고 나서 "manifest 에 쓰는 곳이 또 있나" 를 훑었더니 재init 이 있었다:
10153
+ // `mode set minimal` 로 등급을 낮춘 프로젝트를 업그레이드하려 재init 하면 mode 가 standard 로 되돌아갔다
10154
+ // (실측 exit 0 · 무경고). manifest 는 managedOverwrite 목록에 있어 매번 템플릿으로 재생성되는데
10155
+ // 템플릿은 사용자 선택을 모른다. installedAt 도 매 재init 마다 갱신돼 이름이 거짓말을 하고 있었다.
10156
+ // 고치면서 반대 실패(등급을 바꿀 수 없게 되는 false-STICK)를 만들지 않았는지 함께 단언한다.
10157
+ {
10158
+ total++;
10159
+ let ok = false; const dbg = {};
10160
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-mk107-'));
10161
+ try {
10162
+ const ENV = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb });
10163
+ const R = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: d, encoding: 'utf8', timeout: 180000, env: ENV });
10164
+ const mk = (tag, extra) => { const d = path.join(sb, tag); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}'); R(d, ['init', d, '--yes'].concat(extra || [])); return d; };
10165
+ const M = (d) => { try { return JSON.parse(fs.readFileSync(path.join(d, '.harness', 'manifest.json'), 'utf8')); } catch { return {}; } };
10166
+
10167
+ // 1) 재init 이 등급/설치시각을 되돌리지 않는다 (원래 결함)
10168
+ const d1 = mk('keep', ['--mode', 'minimal']);
10169
+ const first = M(d1);
10170
+ dbg.setup = first.mode === 'minimal' && !!first.installedAt;
10171
+ R(d1, ['init', d1, '--yes']);
10172
+ const kept = M(d1);
10173
+ dbg.modeKept = kept.mode === 'minimal';
10174
+ // installedAt 은 **쓰기가 실제로 일어난 재init** 으로 봐야 한다. 플래그 없는 재init 은 no-op 감지에 걸려
10175
+ // 파일을 아예 안 쓰므로(그리고 no-op 비교는 installedAt 을 휘발 필드로 정규화한다) 값이 그대로다 —
10176
+ // 변이 시험이 이 단언을 공허하다고 잡아냈다(항상 now() 로 바꿔도 생존). --force 로 쓰기를 강제한다.
10177
+ R(d1, ['init', d1, '--yes', '--force']);
10178
+ const forced = M(d1);
10179
+ dbg.installedAtKept = forced.installedAt === first.installedAt && forced.mode === 'minimal';
10180
+ // 지침 파일이 manifest 와 같은 등급이어야 한다 — 여기만 opts.mode 를 보면 둘이 어긋난다
10181
+ const agentsBytes = fs.readFileSync(path.join(d1, 'AGENTS.md'), 'utf8').length;
10182
+
10183
+ // 2) 되돌리는 길은 살아 있어야 한다 (false-STICK 방지) — 명시 플래그가 저장값을 이긴다
10184
+ R(d1, ['init', d1, '--yes', '--mode', 'standard']);
10185
+ const up = M(d1);
10186
+ dbg.explicitWins = up.mode === 'standard';
10187
+ dbg.templateFollows = fs.readFileSync(path.join(d1, 'AGENTS.md'), 'utf8').length > agentsBytes * 1.5;
10188
+
10189
+ // 3) 손상 위 재init 은 복구 경로 — 크래시 없이 경고 + 유효 매니페스트
10190
+ const d2 = mk('corrupt', ['--mode', 'minimal']);
10191
+ fs.writeFileSync(path.join(d2, '.harness', 'manifest.json'), '{ broken ]]]');
10192
+ const rc = R(d2, ['init', d2, '--yes']);
10193
+ const rebuilt = M(d2);
10194
+ // '손상' 한 단어로 잡으면 안 된다 — init 이 부르는 state-integrity 점검도 같은 낱말을 쓴다.
10195
+ // 변이 시험이 이걸 잡아냈다(경고를 지워도 다른 줄에 걸려 통과). 이 경고 고유의 문구로 단언한다.
10196
+ dbg.corruptReinit = rc.status === 0 && /기존 등급\/설치시각을 읽을 수 없어/.test((rc.stdout || '') + (rc.stderr || ''))
10197
+ && rebuilt.mode === 'standard' && !!rebuilt.project;
10198
+
10199
+ // 4) 신규 설치는 여전히 현재 시각을 기록한다 (보존이 '항상 빈 값' 이 되지 않았는지)
10200
+ const d3 = mk('fresh');
10201
+ const ia = M(d3).installedAt;
10202
+ dbg.freshStamped = typeof ia === 'string' && !isNaN(Date.parse(ia)) && Math.abs(Date.now() - Date.parse(ia)) < 900000;
10203
+
10204
+ // 5) codex 검수(재현됨): Object.assign 은 __proto__ 키를 [[Set]] 으로 지나가 **조용히 버린다**.
10205
+ // "남은 필드를 전부 보존" 이 이 라운드의 계약이므로 예외를 두면 계약이 거짓이 된다.
10206
+ const d4 = mk('proto');
10207
+ const mf4 = path.join(d4, '.harness', 'manifest.json');
10208
+ const j4 = JSON.parse(fs.readFileSync(mf4, 'utf8'));
10209
+ fs.writeFileSync(mf4, JSON.stringify(Object.assign({ __PH__: 0 }, j4), null, 2).replace('"__PH__": 0', '"__proto__": {"a":1}') + '\n');
10210
+ const rp = R(d4, ['mode', 'set', 'minimal']);
10211
+ const rawAfter = fs.readFileSync(mf4, 'utf8');
10212
+ dbg.protoPreserved = rp.status === 0 && /"__proto__"/.test(rawAfter) && JSON.parse(rawAfter).mode === 'minimal';
10213
+
10214
+ ok = dbg.setup && dbg.modeKept && dbg.installedAtKept && dbg.explicitWins && dbg.templateFollows
10215
+ && dbg.corruptReinit && dbg.freshStamped && dbg.protoPreserved;
10216
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
10217
+ console.log(ok ? '✓ K(1.36.107) 재init: 등급/설치시각 보존 + 명시 플래그 우선 + 지침 동조 + 손상 복구 + __proto__ 보존'
10218
+ : '✗ 1.36.107 재init 매니페스트 보존 실패 ' + JSON.stringify(dbg));
10219
+ if (!ok) failed++;
10220
+ }
10221
+
10089
10222
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
10090
10223
  if (failed > 0) process.exit(1);
10091
10224
 
@@ -0,0 +1,92 @@
1
+ // 마이그레이션 사냥 v2 — 기록된 실패 모드를 정확히 겨냥한다.
2
+ // (a) **반복** 마이그레이션 (1.36.28 사고: "2번째부터 사라졌다")
3
+ // (b) 언어 전환 (1.36.60 사고: 구 언어 템플릿이 커스텀으로 오인돼 이월)
4
+ // (c) 연쇄 업그레이드 (구버전 → 중간 → 현재)
5
+ // (d) 사용자가 **템플릿과 같은 문장**을 의도적으로 쓴 경우(차감이 지우면 안 된다)
6
+ const fs = require('fs'), path = require('path'), os = require('os'), cp = require('child_process');
7
+ const CUR = require('path').join(__dirname, '..', 'bin', 'leerness.js'); // 저장소 어디에 있든 동작
8
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mh2-'));
9
+ const E = (sb) => Object.assign({}, process.env, { LEERNESS_OFFLINE: '1', LEERNESS_NO_AUTO_ROADMAP: '1', TMPDIR: sb, TEMP: sb, TMP: sb });
10
+ let bad = 0;
11
+ const t = (k, ok, n) => { if (!ok) bad++; console.log((ok ? ' ok ' : ' ✗ ') + k.padEnd(52) + (n || '')); };
12
+ const FILES = ['AGENTS.md', 'CLAUDE.md', '.harness/current-state.md', '.harness/plan.md', '.harness/decisions.md', '.harness/project-brief.md'];
13
+ const plant = (proj, tag) => { const done = []; for (const rel of FILES) { const p = path.join(proj, rel); if (!fs.existsSync(p)) continue; fs.appendFileSync(p, `\nUSERMARK-${tag}-${rel.replace(/[^\w]/g, '')}\n`); done.push(rel); } return done; };
14
+ const liveHas = (proj, tag, rel) => { const p = path.join(proj, rel); if (!fs.existsSync(p)) return false; return fs.readFileSync(p, 'utf8').includes(`USERMARK-${tag}-${rel.replace(/[^\w]/g, '')}`); };
15
+ const anywhere = (proj, needle) => { let f = false; (function w(y) { for (const e of fs.readdirSync(y, { withFileTypes: true })) { const q = path.join(y, e.name); if (e.isDirectory()) { if (!/node_modules|\.git$/.test(e.name)) w(q); } else if (!f) { try { if (fs.readFileSync(q, 'utf8').includes(needle)) f = true; } catch { } } } })(proj); return f; };
16
+ const R = (sb, cli, a, cwd) => cp.spawnSync(process.execPath, [cli, ...a], { cwd, encoding: 'utf8', timeout: 900000, env: E(sb) });
17
+
18
+ function setupOld(ver, tag) {
19
+ const sb = path.join(root, tag); fs.mkdirSync(sb, { recursive: true });
20
+ fs.writeFileSync(path.join(sb, 'package.json'), JSON.stringify({ name: 'h', version: '0.1.0', private: true }));
21
+ const inst = cp.spawnSync('npm', ['i', 'leerness@' + ver, '--no-audit', '--no-fund'], { cwd: sb, encoding: 'utf8', shell: true, timeout: 900000 });
22
+ if (inst.status !== 0) return null;
23
+ const OLD = path.join(sb, 'node_modules', 'leerness', 'bin', 'leerness.js');
24
+ const proj = path.join(sb, 'proj'); fs.mkdirSync(proj, { recursive: true });
25
+ fs.writeFileSync(path.join(proj, 'package.json'), '{"name":"p","version":"0.1.0"}');
26
+ if (R(sb, OLD, ['init', proj, '--yes'], proj).status !== 0) return null;
27
+ return { sb, OLD, proj };
28
+ }
29
+
30
+ console.log('■ (a) 반복 마이그레이션 — 기록된 사고는 "2번째부터"');
31
+ {
32
+ const s = setupOld('1.30.0', 'repeat');
33
+ if (!s) { console.log(' 설치 실패 — 건너뜀'); }
34
+ else {
35
+ const planted = plant(s.proj, 'REP');
36
+ for (let i = 1; i <= 4; i++) {
37
+ R(s.sb, CUR, ['init', s.proj, '--yes'], s.proj);
38
+ const live = planted.filter(rel => liveHas(s.proj, 'REP', rel));
39
+ const gone = planted.filter(rel => !anywhere(s.proj, `USERMARK-REP-${rel.replace(/[^\w]/g, '')}`));
40
+ console.log(` ${i}회차: 본문 생존 ${live.length}/${planted.length} · 완전손실 ${gone.length}` + (gone.length ? ' → ' + gone.join(', ') : ''));
41
+ if (i === 4) { t('반복 4회 후 완전손실 0', gone.length === 0); t('반복 4회 후 본문 생존 전부', live.length === planted.length, live.length + '/' + planted.length); }
42
+ }
43
+ // 파일 크기 폭주도 함께 본다 (이월이 무한히 자라면 그것도 결함)
44
+ const sz = fs.statSync(path.join(s.proj, 'CLAUDE.md')).size;
45
+ t('반복해도 CLAUDE.md 가 폭주하지 않음', sz < 30000, sz + ' B');
46
+ }
47
+ }
48
+
49
+ console.log('\n■ (b) 언어 전환 (ko → en) 후 사용자 커스텀');
50
+ {
51
+ const s = setupOld('1.33.0', 'lang');
52
+ if (!s) { console.log(' 설치 실패 — 건너뜀'); }
53
+ else {
54
+ const planted = plant(s.proj, 'LANG');
55
+ R(s.sb, CUR, ['init', s.proj, '--yes', '--language', 'en'], s.proj);
56
+ const gone = planted.filter(rel => !anywhere(s.proj, `USERMARK-LANG-${rel.replace(/[^\w]/g, '')}`));
57
+ const live = planted.filter(rel => liveHas(s.proj, 'LANG', rel));
58
+ t('언어 전환 후 완전손실 0', gone.length === 0, gone.join(', '));
59
+ t('언어 전환 후 본문 생존', live.length === planted.length, live.length + '/' + planted.length);
60
+ }
61
+ }
62
+
63
+ console.log('\n■ (c) 연쇄 업그레이드 1.30.0 → 1.33.0 → 현재');
64
+ {
65
+ const s = setupOld('1.30.0', 'chain');
66
+ if (!s) { console.log(' 설치 실패 — 건너뜀'); }
67
+ else {
68
+ const planted = plant(s.proj, 'CHAIN');
69
+ const mid = path.join(s.sb, 'mid'); fs.mkdirSync(mid, { recursive: true });
70
+ fs.writeFileSync(path.join(mid, 'package.json'), JSON.stringify({ name: 'm', version: '0.1.0', private: true }));
71
+ const i2 = cp.spawnSync('npm', ['i', 'leerness@1.33.0', '--no-audit', '--no-fund'], { cwd: mid, encoding: 'utf8', shell: true, timeout: 900000 });
72
+ if (i2.status === 0) R(s.sb, path.join(mid, 'node_modules', 'leerness', 'bin', 'leerness.js'), ['init', s.proj, '--yes'], s.proj);
73
+ R(s.sb, CUR, ['init', s.proj, '--yes'], s.proj);
74
+ const gone = planted.filter(rel => !anywhere(s.proj, `USERMARK-CHAIN-${rel.replace(/[^\w]/g, '')}`));
75
+ t('연쇄 업그레이드 후 완전손실 0', gone.length === 0, gone.join(', '));
76
+ }
77
+ }
78
+
79
+ console.log('\n■ (d) 사용자가 템플릿과 같은 문장을 쓴 경우 (차감이 지우면 안 된다)');
80
+ {
81
+ const s = setupOld('1.33.0', 'dup');
82
+ if (!s) { console.log(' 설치 실패 — 건너뜀'); }
83
+ else {
84
+ const ag = path.join(s.proj, 'AGENTS.md');
85
+ const first = fs.readFileSync(ag, 'utf8').split('\n').find(l => l.trim().length > 20 && !/^#/.test(l)) || '';
86
+ fs.appendFileSync(ag, '\n' + first + '\nUSERMARK-DUP-copy\n');
87
+ R(s.sb, CUR, ['init', s.proj, '--yes'], s.proj);
88
+ t('템플릿 복제본 옆 사용자 표식 생존', anywhere(s.proj, 'USERMARK-DUP-copy'), '앵커=' + JSON.stringify(first.slice(0, 40)));
89
+ }
90
+ }
91
+ console.log('\n실패 ' + bad + '건 · ' + root);
92
+ process.exit(bad ? 1 : 0);