leerness 1.36.64 → 1.36.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.66 — 2026-07-22 — codex 8차 홀리스틱 헌트: 기능 간 상호작용 11건 수정 (High 1·Med 8·Low 2)
4
+
5
+ 라운드별 diff 검수가 못 본 "기능 조합" 결함을 교차 헌트(16 발견) — High/데이터손실/거짓판정 우선 11건 수정, 정보노출성 2건(F14/F15) 정직 이연.
6
+
7
+ - **F13 (write-skip × 손상바이트)**: writeUtf8 동일-내용 비교를 디코딩 텍스트→**바이트 동일**로 — 손상 바이트(FF)/BOM 파일에 유효 UTF-8 정화 write 가 억제되던 것.
8
+ - **F5 High (auto-update install × managed)**: /update.md 를 raw writeUtf8 로 덮어써 커스텀 파괴 → writeIfSafe mergeManaged 경로로.
9
+ - **F6 (update --force 미전달)** → install 에 force 전달. **F4 (drift --json critical exit 0)** → exit 1 동치.
10
+ - **F2/F3 거짓판정 룰**: "version and changelog" 복합 룰이 version 절만 보고 pass(F2), "tests must **pass**" 가 실패 증거도 흔적만 있으면 pass(F3) → 절별 독립 평가 후 AND 집계 + 오늘자 증거 exit0 파싱.
11
+ - **F7/F8 (손상 manifest × 언어)**: manifest parse throw 가 유효 LANGUAGE 폴백까지 건너뛰어 en→ko 회귀 → 소스별 개별 try. integrity 도 detectLanguageValue 공용.
12
+ - **F10 (integrity repair × 커스텀)**: h1_lost/truncated 는 managed 병합 복구(커스텀 안전규칙 보존), 부재/판독불가만 raw. **F11**: en 프로젝트 anchors 초안 영어.
13
+ - 이연(정직): F14(session close 에 pending preview 노출)·F15(tech --json 후 graph 재생성) — 정보 노출성, 데이터 보존됨. F9(초고령 KO 템플릿 provenance)·F12(skills-lock 명명)은 설계 판단 필요.
14
+ - 검증: F13/F7/F11 행위 + F4/F5/F6/F8/F10 소스가드 selftest 334, 개별 실측(F2 version-only→pending 등), e2e.
15
+
16
+ ## 1.36.65 — 2026-07-22 — no-op 재설치 + 동일-내용 쓰기 스킵 (외부감사 F-08) — R-0001 검수 10회전
17
+
18
+ 무변경 재init 의 백업/아카이브/타임스탬프 증식(감사 지적) 해소 — 거짓 no-op 은 검수가 차단.
19
+
20
+ - **no-op 재설치**: coreFiles 계산 결과가 현재와 전부 동일하면(휘발 시각키·아카이브 경로 정규화 비교) 백업 생성·파일쓰기·마이그레이션 리포트 생략. force/dry/언어 전환/버전 차이/커스텀 변경은 정상 설치. `LEERNESS_DEBUG_NOOP=1` 진단.
21
+ - **(검수 HIGH) no-op ≠ 조기 종료**: 하위 조정기(.env/.gitignore/훅/스킬 payload/roadmap)는 항상 계속 — 조기 return 은 `--no-env→기본 재init` 등 6케이스에서 요청된 복구를 건너뛰었다(검수 실측). 무변경 churn 은 **writeUtf8 동일-내용 스킵**이 전역 해소.
22
+ - **(파생 선재버그) _mergeLines substring 흡수**: '.env' 가 '.env.local' 에 포함돼 gitignore 복구가 영영 안 되던 것 — 라인 단위 정확 매칭으로 수정.
23
+ - (검수 Low) 래퍼 정규화는 백틱 경로만(문구 변경은 실변경 감지). update.md 이중 출처 단일화(coreFiles↔훅 설치기).
24
+ - 실측: 기본/minimal/en/skills 재init no-op·archive/manifest 불변·.env/gitignore 복구 계속·커스텀 1회 유지·구버전/전환 정상 설치. e2e +1, selftest 333.
25
+
3
26
  ## 1.36.64 — 2026-07-22 — lint 게이트 (외부감사 F-10) — R-0001 검수 9회전
4
27
 
5
28
  과거 실사고(raw U+2028 정규식 리터럴 → 전 명령 SyntaxError 출하) 재발을 구조적으로 차단하는 0-deps lint.
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.64 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.66 하네스를 사용합니다. 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.64는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
179
+ Leerness v1.36.66는 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.64는 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.64 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.66 릴리스 태그 이력** (수백 라운드) · _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.64: 2026-07-22
238
+ Last synced by Leerness v1.36.66: 2026-07-22
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.64';
37
+ const VERSION = '1.36.66';
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') 시 호스트 프로세스 오염.
@@ -243,12 +243,16 @@ function detectLanguageValue(root, value = 'auto') {
243
243
  // 1.36.52 (외부 GPT 감사 F-01, 재현확정): 이미 초기화된 프로젝트는 저장된 명시 언어를 최우선 —
244
244
  // 1회차 init 이 생성한 한국어 관리 블록을 2회차 콘텐츠 감지가 오인해 en→ko 로 뒤집던 멱등성 버그.
245
245
  // 콘텐츠 자동 감지는 신규 프로젝트에만. (감사 첨부 패치 검증 후 채택)
246
+ // 1.36.66 (8차 헌트 F7): 소스별 개별 try — 손상 manifest 의 parse throw 가 유효한 LANGUAGE 폴백까지 건너뛰게 하던
247
+ // 것 차단(en 프로젝트가 manifest 손상 시 콘텐츠 휴리스틱으로 ko 회귀). manifest → LANGUAGE → 콘텐츠 순.
246
248
  try {
247
249
  const mf = path.join(root, '.harness', 'manifest.json');
248
250
  if (exists(mf)) {
249
251
  const saved = String((JSON.parse(read(mf)) || {}).language || '').toLowerCase();
250
252
  if (saved === 'ko' || saved === 'en') return saved;
251
253
  }
254
+ } catch {}
255
+ try {
252
256
  const lf = path.join(root, '.harness', 'LANGUAGE');
253
257
  if (exists(lf)) {
254
258
  const saved = String(read(lf) || '').trim().toLowerCase();
@@ -745,7 +749,8 @@ leerness memory restore <surface> <target> # archive → active 복귀 (DELETE
745
749
  '.claude/commands/session-close.md': `# /session-close\n\n세션 종료 보고를 자동 생성하고 session-handoff.md를 갱신합니다.\n\n\`\`\`\n!leerness session close .\n\`\`\`\n`,
746
750
  '.claude/commands/audit.md': `# /audit\n\n계획-진행 정렬, 디자인/재사용 일관성, 시크릿/인코딩을 일괄 점검합니다.\n\n\`\`\`\n!leerness audit .\n!leerness scan secrets .\n!leerness encoding check .\n\`\`\`\n`,
747
751
  '.claude/commands/lazy-detect.md': `# /lazy-detect\n\n게으름 방지 자동 평가를 실행합니다.\n\n\`\`\`\n!leerness lazy detect .\n\`\`\`\n`,
748
- '.claude/commands/update.md': `# /update\n\nleerness 자동 업데이트를 실행합니다 (감지 마이그레이션 검증).\n\n\`\`\`\n!leerness update --yes\n\`\`\`\n`,
752
+ // 1.36.65: auto-update 설치기와 동일 내용(단일화) 재init no-op 판정이 어긋나지 않게
753
+ '.claude/commands/update.md': `# /update\n\nleerness 자동 업데이트 (감지 → 마이그레이션 → 검증).\n\n\`\`\`\n!leerness update --yes\n\`\`\`\n\n체크만:\n\n\`\`\`\n!leerness update --check\n\`\`\`\n`,
749
754
  '.claude/skills/leerness.md': `---\nname: leerness\ndescription: Leerness harness commands - handoff, audit, scan secrets, encoding check, lazy detect, session close, update. Use when the user asks to load project context, verify work quality, scan secrets, check encoding, or end a session.\n---\n\n# leerness skill\n\n## When to use\n- 사용자가 프로젝트 컨텍스트를 로드해달라고 할 때\n- 완료 선언 전 자기 검증을 요청할 때\n- 세션을 종료하거나 인수인계를 요청할 때\n- 시크릿/한글 인코딩 점검을 요청할 때\n- 새 leerness 버전 적용을 요청할 때\n\n## Commands\n\n\`\`\`bash\nleerness handoff . # 컨텍스트 로드\nleerness check . # pre-action 체크\nleerness audit . # 일관성/계획 정렬 감사\nleerness scan secrets . # 시크릿 패턴 스캔\nleerness encoding check . # UTF-8/BOM/NUL\nleerness lazy detect . # 게으름 평가\nleerness memory search "key" # 결정/이력 검색\nleerness session close . # 종료 보고 + handoff 자동 생성\nleerness update --yes # 자동 업데이트\n\`\`\`\n`,
750
755
  };
751
756
  // 1.36.59 (외부감사 F-05 시리즈 1회차): en 프로젝트에서 에이전트가 "지시"로 읽는 최상위 5종은 완전 영어 —
@@ -767,7 +772,7 @@ leerness memory restore <surface> <target> # archive → active 복귀 (DELETE
767
772
  _files['.claude/commands/session-close.md'] = `# /session-close\n\nGenerate the end-of-session report and update session-handoff.md.\n\n\`\`\`\n!leerness session close .\n\`\`\`\n`;
768
773
  _files['.claude/commands/audit.md'] = `# /audit\n\nCheck plan-progress alignment, design/reuse consistency, secrets, and encoding in one pass.\n\n\`\`\`\n!leerness audit .\n!leerness scan secrets .\n!leerness encoding check .\n\`\`\`\n`;
769
774
  _files['.claude/commands/lazy-detect.md'] = `# /lazy-detect\n\nRun the lazy-work (false-done) automatic evaluation.\n\n\`\`\`\n!leerness lazy detect .\n\`\`\`\n`;
770
- _files['.claude/commands/update.md'] = `# /update\n\nRun the leerness auto-update (detect → migrate → verify).\n\n\`\`\`\n!leerness update --yes\n\`\`\`\n`;
775
+ _files['.claude/commands/update.md'] = `# /update\n\nRun the leerness auto-update (detect → migrate → verify).\n\n\`\`\`\n!leerness update --yes\n\`\`\`\n\nCheck only:\n\n\`\`\`\n!leerness update --check\n\`\`\`\n`;
771
776
  _files['.claude/skills/leerness.md'] = `---\nname: leerness\ndescription: Leerness harness commands - handoff, audit, scan secrets, encoding check, lazy detect, session close, update. Use when the user asks to load project context, verify work quality, scan secrets, check encoding, or end a session.\n---\n\n# leerness skill\n\n## When to use\n- When the user asks to load project context\n- When self-verification is requested before claiming completion\n- When ending a session or asked for a handoff\n- When secret/encoding checks are requested\n- When applying a new leerness version\n\n## Commands\n\n\`\`\`bash\nleerness handoff . # load context\nleerness check . # pre-action check\nleerness audit . # consistency/plan-alignment audit\nleerness scan secrets . # secret pattern scan\nleerness encoding check . # UTF-8/BOM/NUL\nleerness lazy detect . # lazy-work evaluation\nleerness memory search "key" # search decisions/history\nleerness session close . # close report + auto handoff\nleerness update --yes # auto-update\n\`\`\`\n`;
772
777
  // 1.36.61 (F-05 3회차): .harness 정책·시드 문서군 en 완역 — 지시 5종(1.36.59)에 이어 read-order 가 가리키는 참조층.
773
778
  _files['.harness/project-brief.md'] = fm('project-brief', ['confirming project purpose', 'judging new features', 'planning'], ['project purpose changes', 'users/scope change'], `# Project Brief\n\n## Project\n${project}\n\n## Purpose\n- Update this with the real purpose of the project.\n\n## Users\n-\n\n## Success Criteria\n-\n`, 'en');
@@ -1130,14 +1135,6 @@ async function install(root, opts = {}) {
1130
1135
  const _altLang = lang === 'en' ? 'ko' : 'en';
1131
1136
  let _altFiles = {};
1132
1137
  if (_langSwitched) { try { _altFiles = coreFiles(root, _altLang, skills, { minimal: !!opts.minimal }); } catch {} }
1133
- const backup = createBackup(root, opts.force ? 'force' : (opts.migration ? 'migration' : 'init'), files, opts.dry);
1134
- if (opts.dry) {
1135
- log(`Backup target: ${backup.archiveDir}`);
1136
- log('Files that would be backed up:');
1137
- backup.candidates.forEach(f => log('- ' + f));
1138
- } else {
1139
- ok(`backup created: ${rel(root, backup.archiveDir)}`);
1140
- }
1141
1138
  const managedOverwrite = new Set([
1142
1139
  'AGENTS.md','CLAUDE.md','.cursor/rules/leerness.mdc','.github/copilot-instructions.md',
1143
1140
  '.harness/session-workflow.md', // 1.36.60 (검수 #1): managed 편입 — 종전엔 재init 시 구 언어/구 버전 그대로 방치
@@ -1155,6 +1152,45 @@ async function install(root, opts = {}) {
1155
1152
  '.claude/commands/handoff.md','.claude/commands/session-close.md','.claude/commands/audit.md','.claude/commands/lazy-detect.md','.claude/commands/update.md',
1156
1153
  '.claude/skills/leerness.md'
1157
1154
  ]);
1155
+ // 1.36.65 (외부감사 F-08): no-op 재설치 감지 — 계산 결과가 현재와 전부 동일하면 백업/쓰기/타임스탬프를 만들지 않는다.
1156
+ // 비교는 휘발 필드 정규화 후: manifest/lock 의 시각류 키, preserved 래퍼의 아카이브 경로 라인.
1157
+ // force/dry/마이그레이션(버전 변화)·언어 전환은 no-op 판정 대상 아님.
1158
+ const _normVolatile = (f, s) => {
1159
+ let t = String(s);
1160
+ if (/\.json$/i.test(f)) t = t.replace(/"(installedAt|generatedAt|updatedAt|at)"\s*:\s*"[^"]*"/g, '"$1":"~"');
1161
+ t = t.replace(/(backup|백업): ?`[^`]*`/gi, '$1: `~`'); // (검수 2차 Low) 경로만 정규화 — 래퍼 문구 변경은 실변경으로 감지
1162
+ return t.replace(/\r\n/g, '\n').trim();
1163
+ };
1164
+ let _noop = !opts.force && !opts.dry && !_langSwitched && exists(path.join(root, '.harness'));
1165
+ if (_noop) {
1166
+ for (const [f, c] of Object.entries(files)) {
1167
+ const p = path.join(root, f);
1168
+ if (!exists(p)) { _noop = false; if (process.env.LEERNESS_DEBUG_NOOP) log(`[noop-debug] missing: ${f}`); break; }
1169
+ if (managedOverwrite.has(f)) {
1170
+ let cur = ''; try { cur = read(p); } catch { _noop = false; break; }
1171
+ const wouldBe = managedMerge(f, c, cur, null, { altTemplate: _altFiles[f], lang });
1172
+ if (_normVolatile(f, wouldBe) !== _normVolatile(f, cur)) { _noop = false; if (process.env.LEERNESS_DEBUG_NOOP) log(`[noop-debug] differs: ${f}`); break; }
1173
+ }
1174
+ // 비-managed 는 preserve(불변) 경로 — 존재하면 변경 없음
1175
+ }
1176
+ }
1177
+ // (검수 2차 HIGH): no-op 이어도 하위 조정기(.env/.gitignore/훅/스킬 payload/roadmap)는 계속 실행 —
1178
+ // 조기 return 은 "요청된 드리프트 복구"까지 건너뛰었다(--no-env→기본 재init 시 .env 미생성 등 6케이스 실측).
1179
+ // 생략 대상은 백업 생성 + coreFiles 쓰기 루프 + 마이그레이션 리포트뿐. 조정기의 무변경 churn 은
1180
+ // writeUtf8 동일-내용 스킵(1.36.65)이 전역 해소.
1181
+ let backup = { archiveDir: path.join(root, '.harness', 'archive'), candidates: [], skipped: true };
1182
+ if (_noop) {
1183
+ ok(`no-op: coreFiles 이미 v${VERSION} 최신 — 백업/파일쓰기 생략, 부속 구성(.env/훅/스킬)은 정합 확인 계속 (강제: --force)`);
1184
+ } else {
1185
+ backup = createBackup(root, opts.force ? 'force' : (opts.migration ? 'migration' : 'init'), files, opts.dry);
1186
+ if (opts.dry) {
1187
+ log(`Backup target: ${backup.archiveDir}`);
1188
+ log('Files that would be backed up:');
1189
+ backup.candidates.forEach(f => log('- ' + f));
1190
+ } else {
1191
+ ok(`backup created: ${rel(root, backup.archiveDir)}`);
1192
+ }
1193
+ }
1158
1194
  // 1.9.184 (사용자 명시): 파일 설치 — 생성 목록 나열 X, 로딩바 + 단일 완료 메시지.
1159
1195
  const actions = [];
1160
1196
  const totalFiles = Object.keys(files).length;
@@ -1173,6 +1209,7 @@ async function install(root, opts = {}) {
1173
1209
  // 덮어써 활성 기록이 archive 에만 남았다. force 는 가이드/템플릿 문서에만 적용 — 상태 파일은 force 여도 보존.
1174
1210
  const _USER_STATE = new Set(['.harness/progress-tracker.md', '.harness/plan.md', '.harness/task-log.md', '.harness/decisions.md', '.harness/current-state.md', '.harness/session-handoff.md', '.harness/rules.md', '.harness/feature-graph.md', '.harness/reuse-map.md', '.harness/feature-contracts.md', '.harness/project-brief.md', '.harness/architecture.md', '.harness/context-map.md', '.harness/design-system.md', '.harness/review-evidence.md']); // 1.36.62 (검수 #3): 검증 이력도 사용자 데이터
1175
1211
  for (const [f, c] of Object.entries(files)) {
1212
+ if (_noop) break; // 1.36.65: coreFiles 무변경 — 쓰기 루프 생략 (하위 조정기는 아래에서 계속)
1176
1213
  const existsNow = exists(path.join(root, f));
1177
1214
  const mergeManaged = managedOverwrite.has(f);
1178
1215
  const effForce = opts.force && !_USER_STATE.has(f);
@@ -1289,7 +1326,7 @@ async function install(root, opts = {}) {
1289
1326
  syncReadme(root);
1290
1327
  installSkills(root, skills);
1291
1328
  // 1.9.41: migrate 시 이전 버전을 미리 캡처해 차분 안내에 사용
1292
- writeMigrationReport(root, backup, actions, { fromV: opts._previousVersion || null });
1329
+ if (!_noop) writeMigrationReport(root, backup, actions, { fromV: opts._previousVersion || null }); // 1.36.65: no-op 시 리포트 무증식
1293
1330
  // 1.9.41: migrate 후 (= 점프인 경우) 차분 안내를 stdout에 즉시 출력 — AI 컨텍스트에 새 도구 주입
1294
1331
  if (opts.migration && opts._previousVersion && opts._previousVersion !== VERSION) {
1295
1332
  try {
@@ -5401,6 +5438,35 @@ function _selfTestCases() {
5401
5438
  const wired = pkg.scripts.lint === 'node ./scripts/lint.js' && ['test', 'test:fast', 'test:core'].every(k => pkg.scripts[k].startsWith('node ./scripts/lint.js &&')); // (검수 #1) test:core 포함
5402
5439
  return cleanOk && caught && synCaught && bomCaught && jsonCaught && wired;
5403
5440
  } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
5441
+ } },
5442
+ { name: 'codex 8차 홀리스틱 헌트 (1.36.66): writeUtf8 바이트비교(F13)·detectLang 소스별 try(F7)·en anchors(F11)·drift --json exit(F4)·integrity managed복구(F10) — 행위+소스가드', run: () => {
5443
+ const io = require('../lib/io'); const pu = require('../lib/pure-utils');
5444
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_h66_'));
5445
+ try {
5446
+ // F13: 손상 바이트(FF) 파일에 유효 UTF-8 쓰면 바이트 다르므로 정화 write 수행
5447
+ const f13 = path.join(tmp, 'c.txt');
5448
+ fs.writeFileSync(f13, Buffer.from([0x7b, 0xff, 0x7d]));
5449
+ io.writeUtf8(f13, '{"ok":true}');
5450
+ const f13ok = fs.readFileSync(f13, 'utf8') === '{"ok":true}' && !fs.readFileSync(f13).includes(0xff);
5451
+ // 동일 바이트는 스킵(멱등) — mtime 안정
5452
+ const before = fs.statSync(f13).mtimeMs; io.writeUtf8(f13, '{"ok":true}'); const skipOk = fs.statSync(f13).mtimeMs === before;
5453
+ // F7: 손상 manifest + 유효 LANGUAGE → en 유지 (detectLanguageValue)
5454
+ fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true });
5455
+ fs.writeFileSync(path.join(tmp, '.harness', 'manifest.json'), '{broken');
5456
+ fs.writeFileSync(path.join(tmp, '.harness', 'LANGUAGE'), 'en\n');
5457
+ const f7ok = detectLanguageValue(tmp, 'auto') === 'en';
5458
+ // F11: en anchors 초안 영어
5459
+ const d = pu._draftAnchors({ pkgDescription: 'payment service', lang: 'en' });
5460
+ const f11ok = !/[가-힣]/.test([...d.purpose, ...d.goal].join('\n')) && d.goal.some(l => l.includes('Keep the product'));
5461
+ // 소스가드: F4/F5/F6/F8/F10
5462
+ const dr = read(path.join(__dirname, '..', 'lib', 'drift.js'));
5463
+ const s = read(__filename);
5464
+ const guards = /if \(level === '🔴 critical'\) process\.exitCode = 1;\s*\n\s*log\(JSON\.stringify/.test(dr)
5465
+ && s.includes("writeIfSafe(root, '.claude/commands/update.md', _updContent")
5466
+ && s.includes('force: !!opts.force, dry: false, migration: true')
5467
+ && s.includes('const lang = detectLanguageValue(root, ') && s.includes("managedMerge(key, tmpl, cur, null, { lang })");
5468
+ return f13ok && skipOk && f7ok && f11ok && guards;
5469
+ } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
5404
5470
  } }
5405
5471
  ];
5406
5472
  }
@@ -5752,7 +5818,7 @@ function anchorsCmd(root, sub) {
5752
5818
  try { if (exists(path.join(root, 'README.md'))) readmeText = read(path.join(root, 'README.md')).slice(0, 4000); } catch {}
5753
5819
  let milestones = [], tasks = [];
5754
5820
  try { const rd = _roadmapData(root); milestones = rd.milestones || []; tasks = rd.tasks || []; } catch {}
5755
- const draft = _draftAnchors({ pkgDescription, readmeText, milestones, tasks });
5821
+ const draft = _draftAnchors({ pkgDescription, readmeText, milestones, tasks, lang: _uiLang(root) }); // 1.36.66 (F11): 초안 언어
5756
5822
  if (!draft.hasSignal) { failJson(json, 'no_signal', '초안을 만들 실신호가 없음 (package.json description / README 산문 / milestone / 활성 task 모두 부재) — 직접 작성 필요'); return; }
5757
5823
  const apply = has('--apply');
5758
5824
  const applied = [];
@@ -5789,8 +5855,11 @@ function integrityCmd(root) {
5789
5855
  const json = has('--json');
5790
5856
  const repair = has('--repair');
5791
5857
  if (!exists(path.join(root, '.harness'))) { failJson(json, 'harness_missing', `leerness 미설치: ${root} — 먼저 leerness init`); return; }
5792
- let lang = 'ko', minimal = false;
5793
- try { const mf = JSON.parse(read(path.join(root, '.harness', 'manifest.json'))); lang = mf.language || 'ko'; minimal = !!mf.minimal; } catch {}
5858
+ let minimal = false;
5859
+ try { minimal = !!JSON.parse(read(path.join(root, '.harness', 'manifest.json'))).minimal; } catch {}
5860
+ // 1.36.66 (8차 헌트 F8): 언어는 init/migrate 와 동일 해석기 사용 — 손상 manifest 여도 LANGUAGE 폴백으로 en 유지
5861
+ // (종전엔 manifest 만 봐서 손상 시 ko 로 복구 → en 프로젝트에 한국어 템플릿 재생성).
5862
+ const lang = detectLanguageValue(root, 'auto');
5794
5863
  // (검수 #1) minimal 설치 존중 — minimal 이 의도적으로 뺀 문서를 '부재 손상'으로 오판/재생성하지 않는다
5795
5864
  const templates = coreFiles(root, lang, [], { minimal });
5796
5865
  const docs = _INTEGRITY_DOCS.filter(k => typeof templates[k] === 'string');
@@ -5822,6 +5891,7 @@ function integrityCmd(root) {
5822
5891
  const f = { file: key, kind, advisory, templateBytes: Buffer.byteLength(tmpl, 'utf8') };
5823
5892
  if (repair && !advisory) {
5824
5893
  try {
5894
+ const _readable = kind === 'h1_lost' || kind === 'truncated';
5825
5895
  if (exists(fp)) {
5826
5896
  const bakDir = path.join(root, '.harness', 'archive');
5827
5897
  mkdirp(bakDir);
@@ -5829,7 +5899,14 @@ function integrityCmd(root) {
5829
5899
  fs.copyFileSync(fp, path.join(bakDir, `integrity-${path.basename(key)}-${Date.now()}-${process.pid}-${++_integSeq}.bak`));
5830
5900
  f.backedUp = true;
5831
5901
  }
5832
- writeUtf8(fp, tmpl);
5902
+ // 1.36.66 (8차 헌트 F10): 읽을 수 있는 손상(h1_lost/truncated)은 managed 병합으로 복구 —
5903
+ // raw 교체는 사용자가 추가한 커스텀 안전 규칙까지 지웠다. 부재/판독불가만 raw 재생성.
5904
+ if (_readable && exists(fp)) {
5905
+ let cur = ''; try { cur = read(fp); } catch {}
5906
+ writeUtf8(fp, managedMerge(key, tmpl, cur, null, { lang }));
5907
+ } else {
5908
+ writeUtf8(fp, tmpl);
5909
+ }
5833
5910
  f.repaired = true; repaired.push(key);
5834
5911
  } catch (e) { f.repaired = false; f.error = String(e.message || e).slice(0, 120); repairFailed = true; }
5835
5912
  }
@@ -16420,36 +16497,51 @@ function verifyRules(root) {
16420
16497
  const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
16421
16498
  const todayStr = today();
16422
16499
  const results = [];
16500
+ // 1.36.66 (8차 헌트 F2/F3): 절별 독립 평가 후 AND 집계 — 종전 if/else-if 는 "version and changelog" 규칙에서
16501
+ // version 절만 보고 pass 했고(F2), "tests must pass" 는 실패한 테스트 증거(exit≠0)도 흔적만 있으면 pass 했다(F3).
16502
+ const _todayEvidenceExit0 = () => {
16503
+ // 오늘자 최신 verify-code/test 증거 블록에서 실패 신호(exit=null/nonzero, FAIL)가 없는지
16504
+ const blocks = ev.split(/\n(?=## \d{4}-\d{2}-\d{2})/).filter(b => new RegExp(`^## ${todayStr}`).test(b.trim()) && /verify-code|test/i.test(b));
16505
+ if (!blocks.length) return null; // 흔적 없음
16506
+ const last = blocks[blocks.length - 1];
16507
+ if (/exit\s*=\s*(?!0\b)(\d+|null)/i.test(last) || /\bFAIL\b/.test(last)) return false;
16508
+ return true;
16509
+ };
16423
16510
  for (const r of active) {
16424
- let verified = 'manual';
16425
- let note = '';
16426
16511
  const rl = r.rule.toLowerCase();
16512
+ const clauses = []; // { kind, verified }
16427
16513
  if (/version|버전|bump|상승/i.test(rl)) {
16428
- if (prev.packageVersion && cur.packageVersion && prev.packageVersion !== cur.packageVersion) {
16429
- verified = 'pass'; note = `${prev.packageVersion} ${cur.packageVersion}`;
16430
- } else if (!prev.packageVersion) {
16431
- verified = 'baseline'; note = `초기 ${cur.packageVersion || '미확인'}`;
16432
- } else {
16433
- verified = 'pending'; note = '버전 변경 없음';
16434
- }
16435
- } else if (/changelog|패치노트|patch.*note|note.*추가|note.*add/i.test(rl)) {
16436
- if (prev.changelogMtime && cur.changelogMtime && cur.changelogMtime > prev.changelogMtime) {
16437
- verified = 'pass'; note = 'CHANGELOG.md 갱신 감지';
16438
- } else if (!prev.changelogMtime) {
16439
- verified = 'baseline'; note = '초기 측정';
16514
+ if (prev.packageVersion && cur.packageVersion && prev.packageVersion !== cur.packageVersion) clauses.push({ kind: 'version', verified: 'pass', note: `${prev.packageVersion} → ${cur.packageVersion}` });
16515
+ else if (!prev.packageVersion) clauses.push({ kind: 'version', verified: 'baseline', note: `초기 ${cur.packageVersion || '미확인'}` });
16516
+ else clauses.push({ kind: 'version', verified: 'pending', note: '버전 변경 없음' });
16517
+ }
16518
+ if (/changelog|패치노트|patch.*note|note.*추가|note.*add/i.test(rl)) {
16519
+ if (prev.changelogMtime && cur.changelogMtime && cur.changelogMtime > prev.changelogMtime) clauses.push({ kind: 'changelog', verified: 'pass', note: 'CHANGELOG.md 갱신 감지' });
16520
+ else if (!prev.changelogMtime) clauses.push({ kind: 'changelog', verified: 'baseline', note: '초기 측정' });
16521
+ else clauses.push({ kind: 'changelog', verified: 'pending', note: 'CHANGELOG.md 변경 없음' });
16522
+ }
16523
+ if (/test|테스트|verify/i.test(rl)) {
16524
+ // "must pass" 의미면 exit0 확인, 아니면 "흔적 존재"(must run)
16525
+ const mustPass = /pass|통과|성공|green/i.test(rl);
16526
+ if (mustPass) {
16527
+ const ok0 = _todayEvidenceExit0();
16528
+ clauses.push({ kind: 'test', verified: ok0 === true ? 'pass' : 'pending', note: ok0 === true ? '오늘 테스트 통과(exit0)' : ok0 === false ? '오늘 테스트 실패(exit≠0)' : '오늘 테스트 흔적 없음' });
16440
16529
  } else {
16441
- verified = 'pending'; note = 'CHANGELOG.md 변경 없음';
16530
+ const hasTest = new RegExp(`## ${todayStr}.*verify-code|## ${todayStr}.*test`, 'i').test(ev);
16531
+ clauses.push({ kind: 'test', verified: hasTest ? 'pass' : 'pending', note: hasTest ? '오늘 verify-code 흔적' : '오늘 verify-code 호출 없음' });
16442
16532
  }
16443
- } else if (/test|테스트|verify/i.test(rl)) {
16444
- const hasTest = new RegExp(`## ${todayStr}.*verify-code|## ${todayStr}.*test`, 'i').test(ev);
16445
- verified = hasTest ? 'pass' : 'pending';
16446
- note = hasTest ? '오늘 verify-code 흔적' : '오늘 verify-code 호출 없음';
16447
- } else if (/deploy|배포|publish|push|release/i.test(rl)) {
16448
- verified = 'manual'; note = '배포는 사용자 명시 호출 (leerness release publish)';
16449
- } else {
16450
- verified = 'manual'; note = '자동 검증 패턴 없음 — 수동 확인';
16451
16533
  }
16452
- results.push({ ...r, verified, note });
16534
+ if (/deploy|배포|publish|push|release/i.test(rl) && !clauses.length) clauses.push({ kind: 'deploy', verified: 'manual', note: '배포는 사용자 명시 호출 (leerness release publish)' });
16535
+ let verified, note;
16536
+ if (!clauses.length) { verified = 'manual'; note = '자동 검증 패턴 없음 — 수동 확인'; }
16537
+ else {
16538
+ // AND 집계: 하나라도 pending 이면 pending, 모두 pass/baseline 이면 (pass 있으면 pass, 아니면 baseline)
16539
+ if (clauses.some(c => c.verified === 'pending')) verified = 'pending';
16540
+ else if (clauses.some(c => c.verified === 'pass')) verified = 'pass';
16541
+ else verified = clauses.some(c => c.verified === 'manual') ? 'manual' : 'baseline';
16542
+ note = clauses.map(c => `${c.kind}:${c.note}`).join(' · ');
16543
+ }
16544
+ results.push({ ...r, verified, note, clauses: clauses.length > 1 ? clauses.map(c => ({ kind: c.kind, verified: c.verified })) : undefined });
16453
16545
  }
16454
16546
  // lastVerified 갱신 (pass인 경우만)
16455
16547
  for (const r of rules) {
@@ -17658,7 +17750,7 @@ async function updateCmd(root, opts = {}) {
17658
17750
  try { const _hv = read(path.join(root, '.harness', 'HARNESS_VERSION')).trim(); if (_hv) nextLeerness = _hv; } catch {}
17659
17751
  } else {
17660
17752
  log(`\nRunning in-process migrate (already on latest ${VERSION})…`);
17661
- await install(root, { force: false, dry: false, migration: true, nonInteractive: true });
17753
+ await install(root, { force: !!opts.force, dry: false, migration: true, nonInteractive: true }); // 1.36.66 (8차 헌트 F6): --force 전달 — no-op 우회해 손상 템플릿 재작성
17662
17754
  }
17663
17755
  log('\n# Post-migration checks');
17664
17756
  status(root);
@@ -17700,10 +17792,12 @@ function autoUpdateInstall(root) {
17700
17792
  }
17701
17793
  writeUtf8(settingsFile, JSON.stringify(settings, null, 2) + '\n');
17702
17794
  // 1.36.62 (검수 #1): en 프로젝트의 /update 를 한국어로 되덮던 것 — 언어 인지 렌더
17703
- const _updLang = (() => { try { return read(path.join(root, '.harness', 'LANGUAGE')).trim().toLowerCase(); } catch { return 'ko'; } })();
17704
- writeUtf8(path.join(root, '.claude/commands/update.md'), _updLang === 'en'
17795
+ // 1.36.66 (8차 헌트 F5): raw writeUtf8 managed 파일 커스텀을 파괴했다 coreFiles 동일 병합 경로(writeIfSafe mergeManaged)로.
17796
+ const _updLang = (() => { try { return read(path.join(root, '.harness', 'LANGUAGE')).trim().toLowerCase() === 'en' ? 'en' : 'ko'; } catch { return 'ko'; } })();
17797
+ const _updContent = _updLang === 'en'
17705
17798
  ? `# /update\n\nRun the leerness auto-update (detect → migrate → verify).\n\n\`\`\`\n!leerness update --yes\n\`\`\`\n\nCheck only:\n\n\`\`\`\n!leerness update --check\n\`\`\`\n`
17706
- : `# /update\n\nleerness 자동 업데이트 (감지 → 마이그레이션 → 검증).\n\n\`\`\`\n!leerness update --yes\n\`\`\`\n\n체크만:\n\n\`\`\`\n!leerness update --check\n\`\`\`\n`);
17799
+ : `# /update\n\nleerness 자동 업데이트 (감지 → 마이그레이션 → 검증).\n\n\`\`\`\n!leerness update --yes\n\`\`\`\n\n체크만:\n\n\`\`\`\n!leerness update --check\n\`\`\`\n`;
17800
+ writeIfSafe(root, '.claude/commands/update.md', _updContent, { mergeManaged: true, lang: _updLang });
17707
17801
  ok('auto-update SessionStart hook installed (.claude/settings.local.json)');
17708
17802
  if (removedLegacy) ok(`legacy hook 제거: ${removedLegacy}건 (leerness-plus → leerness 통합)`);
17709
17803
  if (upgradedQuiet) ok(`hook 업그레이드: ${upgradedQuiet}건 → --quiet (up-to-date 시 무음, 1.9.364)`);
package/lib/drift.js CHANGED
@@ -313,6 +313,8 @@ function driftCheckCmd(root, opts = {}, deps = {}) {
313
313
  }
314
314
  }
315
315
  if (has('--json')) {
316
+ // 1.36.66 (8차 헌트 F4): --json 도 사람용과 exit 동치 — critical 은 exit 1 (조기 return 전에 설정)
317
+ if (level === '🔴 critical') process.exitCode = 1;
316
318
  log(JSON.stringify({ root, score: totalScore, level, signals, fired, appsZeroTask }, null, 2));
317
319
  return;
318
320
  }
package/lib/io.js CHANGED
@@ -111,6 +111,10 @@ function _rescueCorruptJson(p, s) {
111
111
  try { fs.renameSync(p, bak); console.error(`⚠ 손상 JSON 감지 — 원본 대피 후 저장: ${path.basename(bak)} (수동 복구용)`); } catch {}
112
112
  }
113
113
  function writeUtf8(p, s) {
114
+ // 1.36.65 (외부감사 F-08): 동일-내용 재기록 스킵 — 무변경 재설치/조정기의 mtime·diff churn 을 전역 해소.
115
+ // 1.36.66 (8차 헌트 F13): 비교는 디코딩 텍스트가 아니라 "바이트 동일"로 — 손상 바이트(FF)/BOM 파일에
116
+ // 유효 UTF-8 을 쓸 때 read() 의 BOM-strip·치환문자가 텍스트를 같게 보이게 해 정화 write 가 억제됐다.
117
+ try { if (fs.existsSync(p) && fs.readFileSync(p).equals(Buffer.from(String(s), 'utf8'))) return; } catch {}
114
118
  mkdirp(path.dirname(p));
115
119
  _rescueCorruptJson(p, s);
116
120
  const tmp = `${p}.tmp-${process.pid}-${++_writeSeq}`;
package/lib/pure-utils.js CHANGED
@@ -738,8 +738,16 @@ function _looksSecretLike(value) {
738
738
 
739
739
  // 1.9.367 (UR-0025): 라인 머지 순수 코어 — 기존 텍스트에 없는 라인만 append (substring 중복 방지). mergeLinesFile 의 I/O 분리.
740
740
  function _mergeLines(currentText, lines) {
741
+ // 1.36.65 (검수 HIGH 파생, 선재 버그): substring 포함 검사는 '.env' 가 '.env.local' 에 흡수돼 영영 미복구 —
742
+ // 정확한 라인 단위 존재 검사로 교체.
741
743
  let next = currentText || '';
742
- for (const line of (lines || [])) if (!next.includes(line)) next += (next.endsWith('\n') || !next ? '' : '\n') + line + '\n';
744
+ const have = new Set(next.split(/\r?\n/).map(l => l.trim()).filter(Boolean));
745
+ for (const line of (lines || [])) {
746
+ const t = String(line).trim();
747
+ if (t && have.has(t)) continue;
748
+ next += (next.endsWith('\n') || !next ? '' : '\n') + line + '\n';
749
+ if (t) have.add(t);
750
+ }
743
751
  return next;
744
752
  }
745
753
  // 1.9.367 (UR-0025): .env key-aware 머지 순수 코어 — 기존 KEY 값 보존(덮어쓰기 X), 신규 KEY/주석만 append. mergeEnvFile 의 I/O 분리.
@@ -1193,10 +1201,14 @@ function _draftAnchors(sig = {}) {
1193
1201
  if (readmeLead && readmeLead !== desc) purpose.push(`- ${_cutAt(readmeLead, 200)}`);
1194
1202
  const ms = (sig.milestones || []).filter(m => m && m.title).slice(0, 3);
1195
1203
  const activeTasks = (sig.tasks || []).filter(t => t && /in-progress|planned|requested/.test(String(t.status || ''))).slice(0, 3);
1196
- if (desc || readmeLead) goal.push(`- 제품을 동작 상태로 유지·발전시킨다: ${_cutAt(desc || readmeLead, 140)}`);
1204
+ // 1.36.66 (8차 헌트 F11): en 프로젝트는 초안 마커·기본 goal 영어
1205
+ const _en = String(sig.lang || '').toLowerCase() === 'en';
1206
+ if (desc || readmeLead) goal.push(_en ? `- Keep the product working and evolving it: ${_cutAt(desc || readmeLead, 140)}` : `- 제품을 동작 상태로 유지·발전시킨다: ${_cutAt(desc || readmeLead, 140)}`);
1197
1207
  for (const m of ms) goal.push(`- [${m.id || 'M'}] ${_oneLine(m.title).slice(0, 120)}${m.status ? ` (${m.status})` : ''}`);
1198
1208
  if (!goal.length && activeTasks.length) for (const t of activeTasks) goal.push(`- ${t.id}: ${_oneLine(t.request).slice(0, 100)}`);
1199
- const marker = '<!-- draft: leerness anchors — 실신호 기반 초안. AI/사용자가 검토·수정 후 이 주석을 지우면 확정 -->';
1209
+ const marker = _en
1210
+ ? '<!-- draft: leerness anchors — draft from real signals. Remove this comment after AI/user review to confirm -->'
1211
+ : '<!-- draft: leerness anchors — 실신호 기반 초안. AI/사용자가 검토·수정 후 이 주석을 지우면 확정 -->';
1200
1212
  return {
1201
1213
  hasSignal: purpose.length > 0 || goal.length > 0,
1202
1214
  purpose: purpose.length ? [marker, ...purpose] : [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.64",
3
+ "version": "1.36.66",
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
@@ -4953,7 +4953,11 @@ total++;
4953
4953
  try {
4954
4954
  const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-arch-'));
4955
4955
  cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--language', 'ko', '--keep', '2'], { encoding: 'utf8', timeout: 30000 });
4956
- for (let i = 0; i < 3; i++) cp.spawnSync(process.execPath, [CLI, 'migrate', d, '--keep', '2'], { encoding: 'utf8', timeout: 30000 });
4956
+ // 1.36.65: 무변경 migrate no-op(백업 미생성) 리텐션 검증 의도 유지를 위해 매회 변경 주입으로 스냅샷 강제
4957
+ for (let i = 0; i < 3; i++) {
4958
+ fs.appendFileSync(path.join(d, 'AGENTS.md'), `\nRETENTION PROBE ${i}\n`);
4959
+ cp.spawnSync(process.execPath, [CLI, 'migrate', d, '--keep', '2'], { encoding: 'utf8', timeout: 30000 });
4960
+ }
4957
4961
  const adir = path.join(d, '.harness', 'archive');
4958
4962
  const cnt = fs.readdirSync(adir).filter(n => /^leerness-/.test(n)).length;
4959
4963
  fs.rmSync(d, { recursive: true, force: true });
@@ -7107,5 +7111,37 @@ total++;
7107
7111
  if (!ok) failed++;
7108
7112
  }
7109
7113
 
7114
+ // 1.36.65 (외부감사 F-08): no-op 재설치 — 변경 없으면 백업/아카이브/타임스탬프 무증식, 변경·구버전·force 는 정상 설치
7115
+ total++;
7116
+ {
7117
+ let ok = false;
7118
+ const _d = [];
7119
+ try {
7120
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-noop-')); _d.push(d);
7121
+ const R = () => cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--no-env'], { encoding: 'utf8', timeout: 40000 });
7122
+ R();
7123
+ const arch1 = fs.readdirSync(path.join(d, '.harness', 'archive')).length;
7124
+ const mf1 = fs.readFileSync(path.join(d, '.harness', 'manifest.json'), 'utf8');
7125
+ const r2 = R();
7126
+ const noopOk = r2.status === 0 && /no-op/.test(r2.stdout)
7127
+ && fs.readdirSync(path.join(d, '.harness', 'archive')).length === arch1
7128
+ && fs.readFileSync(path.join(d, '.harness', 'manifest.json'), 'utf8') === mf1;
7129
+ // 커스텀 추가 → 설치 수행(백업 생성) → 이후 다시 no-op + 커스텀 1회 유지
7130
+ fs.appendFileSync(path.join(d, 'AGENTS.md'), '\nNOOP CUSTOM LINE\n');
7131
+ const r3 = R();
7132
+ const changedOk = /backup created/.test(r3.stdout) && !/no-op/.test(r3.stdout);
7133
+ const r4 = R();
7134
+ const stableOk = /no-op/.test(r4.stdout) && (fs.readFileSync(path.join(d, 'AGENTS.md'), 'utf8').match(/NOOP CUSTOM LINE/g) || []).length === 1;
7135
+ // 구버전 마커 → 정상 설치
7136
+ fs.writeFileSync(path.join(d, '.harness', 'HARNESS_VERSION'), '1.36.60\n');
7137
+ const r5 = R();
7138
+ const verOk = /backup created/.test(r5.stdout);
7139
+ ok = noopOk && changedOk && stableOk && verOk;
7140
+ if (!ok) console.log(` [noop 디버그] noop=${noopOk} chg=${changedOk} stable=${stableOk} ver=${verOk}`);
7141
+ } catch (e) {} finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
7142
+ console.log(ok ? '✓ B(1.36.65) F-08 no-op 재설치: 무변경=백업/타임스탬프 생략 · 변경/구버전=정상 설치 · 커스텀 1회 유지' : '✗ no-op 재설치 실패');
7143
+ if (!ok) failed++;
7144
+ }
7145
+
7110
7146
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
7111
7147
  if (failed > 0) process.exit(1);