leerness 1.36.121 → 1.36.123

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.121 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.123 하네스를 사용합니다. 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.121는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.123는 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.121는 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.121 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.123 릴리스 태그 이력** (수백 라운드) · _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.121: 2026-08-15
238
+ Last synced by Leerness v1.36.123: 2026-08-16
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.121';
37
+ const VERSION = '1.36.123';
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') 시 호스트 프로세스 오염.
@@ -1037,13 +1037,40 @@ function createBackup(root, reason, files, dry = false) {
1037
1037
  return { archiveDir: ar, candidates, pruned };
1038
1038
  }
1039
1039
 
1040
+ // 1.36.115: 구버전은 스킬을 `.claude/skills/leerness.md`(평면)로 깔았고 그 형식은 발견되지 않는다.
1041
+ // 새 위치로 옮겨 심었지만 **옛 파일은 지우지 않는다** — 사용자가 손댔을 수 있고, 이 저장소의 계약은 false-DROP 이 버그다.
1042
+ // 대신 사본 2벌이 생겼다는 사실과 어느 쪽이 실제로 읽히는지 알린다(안 그러면 옛 파일을 고치고 반영 안 돼 혼란).
1043
+ // 1.36.123 (자기 저장소 도그푸딩): 이 경고가 `init` 에만 있었다 — 도구 배선의 **문서화된 진입점**인 `adapter`
1044
+ // 로 들어온 사용자는 사본 2벌을 갖고도 아무 말을 못 들었다(우리 repo 에서 실제로 그랬다). 두 경로가 같이 쓴다.
1045
+ function _warnLegacySkillFile(root) {
1046
+ try {
1047
+ const f = path.join(root, '.claude', 'skills', 'leerness.md');
1048
+ if (!exists(f)) return false;
1049
+ warn(`구 스킬 파일이 남아 있습니다: .claude/skills/leerness.md`);
1050
+ log(` → 실제로 읽히는 것은 .claude/skills/leerness/SKILL.md 입니다(Claude Code 규약). 옛 파일은 무시되며, 필요 없으면 직접 지우세요.`);
1051
+ return true;
1052
+ } catch { return false; }
1053
+ }
1054
+
1040
1055
  // 1.9.1 P2 / 1.9.368 (UR-0025): MERGE_OVERWRITE_FILES → lib/catalogs, managedMerge 순수 코어 → lib/pure-utils. 얇은 래퍼.
1041
1056
  function managedMerge(file, next, previous, archiveDir, mergeOpts = {}) {
1042
1057
  // 1.36.105 (codex 검수 HIGH#4): 경로를 **cwd 기준**으로 만들어 프로젝트 파일 안에 박았다 —
1043
1058
  // 다른 디렉토리에서 실행하면 "전체 원본 백업: <경로>" 가 존재하지 않는 곳을 가리킨다(검수 재현).
1044
1059
  // 그 문장은 "삭제하지 않았다" 는 약속의 근거이므로 반드시 프로젝트 기준이어야 한다.
1045
1060
  const _base = mergeOpts && mergeOpts.root ? mergeOpts.root : process.cwd();
1046
- const archiveRel = archiveDir ? path.relative(_base, archiveDir).replace(/\\/g, '/') : '.harness/archive';
1061
+ // 1.36.122 (자기 저장소 도그푸딩): 백업을 만들지 않는 실행(`adapter` ) 여기서 일반 경로를 **채워** 넘겼고,
1062
+ // 그러면 이전 마이그레이션이 적어 둔 **구체 백업 경로가 덮여** 사라졌다(우리 repo 에서 실측).
1063
+ // 백업이 없으면 `null` 을 넘겨, 이전 문서의 포인터를 그대로 두게 한다(보존 블록의 정보 손실 방지).
1064
+ // ⚠ 1.36.123 (자체 헌트): 그 유지가 **없어진 아카이브를 영구 고착**시킬 수 있다 — 그 문장은
1065
+ // "전체 원본 백업: <경로>" 라는 **약속**이므로, 가리키는 곳이 사라졌으면 거짓 약속이 된다.
1066
+ // 여기(fs 접근 가능)에서 실재를 확인해, 없으면 일반 경로로 되돌린다. 순수 함수는 그대로 둔다.
1067
+ let archiveRel = archiveDir ? path.relative(_base, archiveDir).replace(/\\/g, '/') : null;
1068
+ if (!archiveRel && previous) {
1069
+ const _m = /(?:전체 원본 백업|Full original backup):\s*`([^`]+)`/.exec(String(previous));
1070
+ if (_m && _m[1] && _m[1] !== '.harness/archive') {
1071
+ archiveRel = exists(path.join(_base, _m[1])) ? _m[1] : '.harness/archive';
1072
+ }
1073
+ }
1047
1074
  return _managedMerge(file, next, previous, archiveRel, MERGE_OVERWRITE_FILES, mergeOpts); // 1.36.60: altTemplate/lang 전달
1048
1075
  }
1049
1076
 
@@ -1589,13 +1616,7 @@ async function install(root, opts = {}) {
1589
1616
  // 1.36.115: 구버전은 스킬을 `.claude/skills/leerness.md`(평면)로 깔았고 그 형식은 발견되지 않는다.
1590
1617
  // 새 위치로 옮겨 심었지만 **옛 파일은 지우지 않는다** — 사용자가 손댔을 수 있고, 이 저장소의 계약은 false-DROP 이 버그다.
1591
1618
  // 대신 사본 2벌이 생겼다는 사실과 어느 쪽이 실제로 읽히는지 알린다(안 그러면 옛 파일을 고치고 반영 안 돼 혼란).
1592
- try {
1593
- const _legacySkill = path.join(root, '.claude', 'skills', 'leerness.md');
1594
- if (exists(_legacySkill)) {
1595
- warn(`구 스킬 파일이 남아 있습니다: .claude/skills/leerness.md`);
1596
- log(` → 실제로 읽히는 것은 .claude/skills/leerness/SKILL.md 입니다(Claude Code 규약). 옛 파일은 무시되며, 필요 없으면 직접 지우세요.`);
1597
- }
1598
- } catch {}
1619
+ _warnLegacySkillFile(root);
1599
1620
  if (!has('--no-auto-update') && !opts.minimal) {
1600
1621
  try { autoUpdateInstall(root); } catch (e) { warn('auto-update hook install skipped: ' + (e && e.message)); }
1601
1622
  } else if (opts.minimal) {
@@ -23940,6 +23961,8 @@ function adapterCmd(root, tool, opts = {}) {
23940
23961
  // 주장은 **파일 단위**로 한다 — 우리가 쓴 파일만 이름을 대고 말한다.
23941
23962
  _wrote.forEach(r => log(` ℹ ${r.file} 에 MCP 등록 — 이 파일을 읽는 도구는 leerness MCP verb(state_show/start/record/verify/handoff)를 직접 호출 가능`));
23942
23963
  _kept.forEach(r => log(` ℹ ${r.file} 의 기존 leerness 항목을 그대로 두었습니다 — 그 항목이 leerness MCP 를 띄우는지는 확인하지 않았습니다`));
23964
+ // 스킬을 까는 어댑터면 구 평면 파일도 같이 알린다(1.36.123: `init` 에만 있어 이 경로는 조용했다)
23965
+ if (a.keys.some(k => String(k).includes('.claude/skills/'))) _warnLegacySkillFile(root);
23943
23966
  // 보존은 안전하지만 **막다른 길이면 안 된다** — 우리 것이 망가졌을 때 되돌릴 방법을 알려 준다(자체 헌트).
23944
23967
  if (_kept.length || _brk.length) log(` → 표준 설정으로 되돌리려면 그 파일의 "leerness" 항목을 지우고 이 명령을 다시 실행하세요`);
23945
23968
  return;
package/lib/audit.js CHANGED
@@ -95,7 +95,9 @@ function _collectGuardInputs(root) {
95
95
  // CMD 스텝의 `REM`·`::` 도 주석이다(검수 재현: `REM debug: node .\bin\phantom.js` 한 줄로 제품 진입점이 러너가 됐다).
96
96
  // ⚠ `--`·`;` 는 여기서 다루는 파일 종류(YAML·Makefile·셸·JS)에서 주석이 아니다 — 오히려 여러 줄 명령의
97
97
  // 연속 인자(`--file=scripts/x.js`)를 지워 파일 참조를 잃는다. 주석 표기는 실제로 쓰이는 것만 둔다.
98
- const _noComment = (s) => String(s).split('\n').filter(l => !/^\s*(#|\/\/|::|@?REM\s)/i.test(l)).join('\n');
98
+ // ⚠ 판정기(`_dropCommentLines`)와 **같은 표기**여야 한다 — `*`(블록 주석 이어짐) 한쪽에만 두면
99
+ // 두 단계의 입력이 갈린다(검수 P3: 정합성 주장이 사실이 아니었다).
100
+ const _noComment = (s) => String(s).split('\n').filter(l => !/^\s*(#|\/\/|\*|::|@?REM\s)/i.test(l)).join('\n');
99
101
  // 설명 필드(`- name: archive index.js`)는 실행 증거가 아니다 — bare 진입점 매처에도 같은 규칙을 건다.
100
102
  // 단 값이 **산문일 때만** 설명으로 본다(matrix 변수명이 `name` 일 수 있다 — 판정기와 같은 규칙).
101
103
  const _noDescriptive = (s) => String(s).split('\n').filter(l => {
package/lib/pure-utils.js CHANGED
@@ -993,7 +993,15 @@ function _managedMerge(file, next, previous, archiveRel, overwriteSet, opts = {}
993
993
  custom.push(raw.replace(/\\`\\`\\`/g, '```')); // 구 형식에서 이스케이프된 펜스 복원
994
994
  }
995
995
  if (!custom.length) return next;
996
- const ar = archiveRel || '.harness/archive';
996
+ // 1.36.122 (자기 저장소 도그푸딩 실측): 새 백업을 만들지 않는 실행(`adapter` 등)이 `archiveRel` 없이 들어오면
997
+ // 이 안내가 **구체 백업 경로를 일반 경로로 덮어썼다** — 보존이 목적인 블록에서의 정보 손실이다.
998
+ // (우리 repo 에서 `.harness/archive/leerness-1.36.100-2026-08-05T…` → `.harness/archive` 로 격하되는 걸 확인했다.)
999
+ // 이전 문서가 이미 구체 경로를 가리키면 그대로 둔다.
1000
+ let ar = archiveRel;
1001
+ if (!ar) {
1002
+ const _prevAr = /(?:전체 원본 백업|Full original backup):\s*`([^`]+)`/.exec(String(previous || ''));
1003
+ ar = (_prevAr && _prevAr[1]) || '.harness/archive';
1004
+ }
997
1005
  // 1.36.60 (검수): 보존 래퍼 문구도 프로젝트 언어를 따른다 — en 프로젝트에 한글 래퍼가 새 한글원을 만들던 것 해소
998
1006
  let note = opts.lang === 'en'
999
1007
  ? `> Custom user/project content carried over from the previous version — re-carried automatically on every migration. Full original backup: \`${ar}\``
@@ -1287,7 +1295,9 @@ function _detectOrphanGuards(input) {
1287
1295
  // ⚠ 기준 디렉터리가 다르다: **CI 설정의 명령은 프로젝트 루트에서 실행**된다(`run: node ./scripts/x.js`).
1288
1296
  // 설정 파일 위치(`.github/workflows`) 기준으로 풀면 `.github/workflows/scripts/x.js` 가 돼 실제 호출을 놓친다(검수 재현).
1289
1297
  // 코드 파일 안의 `require('./x')` 는 그 파일 기준이 맞다.
1290
- const dir = _isConfigRunner(r.file) ? [] : String(r.file || '').replace(/\\/g, '/').split('/').slice(0, -1);
1298
+ // 러너 자신의 경로도 정규화한다 — `node scripts/./a/run.js` 수집된 러너는 `r.file` dot-segment
1299
+ // 남아 있어 해석 결과가 후보와 어긋났다(검수 재현: 실제로 도는 가드가 고아로 보고됨).
1300
+ const dir = _isConfigRunner(r.file) ? [] : _normPath(String(r.file || '')).split('/').slice(0, -1);
1291
1301
  // 원문을 먼저 정규화한다 — 실제 JS 는 `.\\check.js`(backslash 2개)로 저장되고, 한 번만 치환하면
1292
1302
  // `.//check.js` 가 남아 아래 정규식에 안 걸린다(검수 재현: 실제로 도는 가드가 고아로 보고됐다).
1293
1303
  const body = _normPath(_dropCommentLines(String(r.text || '')));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.121",
3
+ "version": "1.36.123",
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
@@ -5091,7 +5091,8 @@ total++;
5091
5091
  // 1.36.120: 잡음을 **열거**하는 대신 stderr 를 통째로 0 으로 고정한다. audit 이 `--json` 에서 stdout 만 막고
5092
5092
  // stderr 는 열어 둬서 사람용 경고가 샜고(309B), 그 문구가 **프로젝트 상태에 따라** 달라져
5093
5093
  // 같은 코드로 게이트가 통과/실패로 갈렸다(실측). 열거는 유계가 아니다 — 원인 단계에서 막았으니 여기선 0을 요구한다.
5094
- const noiseFree = se.trim() === '';
5094
+ // ⚠ `trim()===''` 은 개행만 있는 출력을 통과시킨다 — 문구가 "0 바이트" 면 검사도 0 바이트여야 한다(검수 P3).
5095
+ const noiseFree = se.length === 0;
5095
5096
  ok = pureStdout && payloadOk && noiseFree && r.status === 0;
5096
5097
  } catch {}
5097
5098
  console.log(ok ? '✓ B(1.36.88) selftest --json 계약: 리포 루트 cwd stdout 순수 JSON + stderr 잡음 0' : '✗ selftest --json 계약 실패 (stdout 오염 또는 stderr 잡음)');
@@ -5121,7 +5122,11 @@ total++;
5121
5122
  if (!out.trim()) { bad.push(`${c}:출력없음`); continue; }
5122
5123
  let j = null; try { j = JSON.parse(out.trim()); } catch {}
5123
5124
  if (!j) { bad.push(`${c}:stdout이JSON아님`); continue; }
5124
- if (err.trim() !== '') bad.push(`${c}:stderr잡음(${err.length}B:${err.trim().split('\n')[0].slice(0, 40)})`);
5125
+ // 문구가 "0 바이트" 면 검사도 0 바이트여야 한다 — `trim()===''` 은 개행만 있는 출력을 통과시킨다(검수 P3).
5126
+ if (err.length !== 0) bad.push(`${c}:stderr잡음(${err.length}B:${err.trim().split('\n')[0].slice(0, 40)})`);
5127
+ // "성공 경로 15종" 이라고 말하려면 **성공인지도** 봐야 한다 — 실패 JSON(`ok:false`, exit 1)도 세고 있었다(검수 P2).
5128
+ if (r.status !== 0) bad.push(`${c}:exit=${r.status}`);
5129
+ if (j.ok === false) bad.push(`${c}:ok=false`);
5125
5130
  }
5126
5131
  // 판별력 — 이 검사가 실제로 무언가를 실행했는지(전부 건너뛰면 공허하게 통과한다)
5127
5132
  if (bad.length === 0 && CMDS.length < 10) bad.push('대상이_너무적음');
@@ -5169,6 +5174,67 @@ total++;
5169
5174
  if (!ok) failed++;
5170
5175
  }
5171
5176
 
5177
+ // 1.36.122 (자기 저장소 도그푸딩): 보존 블록의 **백업 포인터**가 재실행에 덮여 사라졌다.
5178
+ // `adapter` 처럼 새 백업을 만들지 않는 실행이 일반 경로(`.harness/archive`)를 채워 넘겨서,
5179
+ // 이전 마이그레이션이 적어 둔 구체 경로(`.harness/archive/leerness-1.36.100-…`)를 덮었다.
5180
+ // 그 문장은 "삭제하지 않았다" 는 약속의 근거이므로 격하는 곧 정보 손실이다. 세 방향을 모두 잰다.
5181
+ total++;
5182
+ {
5183
+ let ok = false; const bad = [];
5184
+ try {
5185
+ const PUx = require(path.resolve(path.dirname(CLI), '..', 'lib', 'pure-utils'));
5186
+ const next = '# T\nmanaged\n';
5187
+ const prev = '# T\nmanaged\nCUSTOM-LINE\n';
5188
+ const ptr = (s) => { const m = /(?:전체 원본 백업|Full original backup):\s*`([^`]+)`/.exec(s); return m && m[1]; };
5189
+ // ① 이전 포인터가 없고 새 백업도 없으면 기본값
5190
+ if (ptr(PUx._managedMerge('CLAUDE.md', next, prev, null, null)) !== '.harness/archive') bad.push('기본값아님');
5191
+ // ② 이전 포인터가 **구체 경로**면 새 백업이 없을 때 그대로 둔다(격하 금지)
5192
+ const prev2 = prev + '\n> 전체 원본 백업: `.harness/archive/leerness-1.2.3-STAMP`\n';
5193
+ if (ptr(PUx._managedMerge('CLAUDE.md', next, prev2, null, null)) !== '.harness/archive/leerness-1.2.3-STAMP') bad.push('구체경로가_격하됨');
5194
+ // ③ 새 백업이 있으면 그쪽으로 갱신(보존이 과해 최신 백업을 못 가리키면 안 된다)
5195
+ if (ptr(PUx._managedMerge('CLAUDE.md', next, prev2, '.harness/archive/NEW', null)) !== '.harness/archive/NEW') bad.push('새백업으로_갱신안됨');
5196
+ // ④ 실제 CLI 경로 — 새 백업 없이 adapter 를 두 번 돌려도 포인터가 유지돼야 한다
5197
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ptr122-'));
5198
+ try {
5199
+ const d = path.join(sb, 'p'); fs.mkdirSync(d, { recursive: true });
5200
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
5201
+ const ENVp = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_NO_PROMPT: '1' });
5202
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENVp });
5203
+ const cf = path.join(d, 'CLAUDE.md');
5204
+ // 사용자 커스텀 + 구체 백업 포인터를 가진 보존 블록을 만든다
5205
+ fs.writeFileSync(cf, fs.readFileSync(cf, 'utf8')
5206
+ + '\n<!-- leerness:migration-preserved -->\n## Preserved previous content\n\n> 이전 버전에서 이어진 사용자/프로젝트 커스텀 내용 — 마이그레이션마다 자동 이월됩니다. 전체 원본 백업: `.harness/archive/leerness-9.9.9-STAMP`\n\nUSER-KEEP-LINE\n');
5207
+ // ⚠ adapter 가 **실제로 무언가 했는지**부터 확인한다 — no-op/실패여도 미리 심은 포인터와 사용자 줄은
5208
+ // 그대로 남아 이 단언이 공허하게 통과한다(검수 P2). 아카이브도 실재해야 유지가 정당하다.
5209
+ fs.mkdirSync(path.join(d, '.harness', 'archive', 'leerness-9.9.9-STAMP'), { recursive: true });
5210
+ fs.rmSync(path.join(d, '.mcp.json'), { force: true });
5211
+ const rAd = cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', d], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENVp });
5212
+ if (rAd.status !== 0) bad.push(`adapter실패(exit=${rAd.status})`);
5213
+ if (!fs.existsSync(path.join(d, '.mcp.json'))) bad.push('adapter가_아무것도_안함(공허)');
5214
+ const after2 = fs.readFileSync(cf, 'utf8');
5215
+ if (!/leerness-9\.9\.9-STAMP/.test(after2)) bad.push('CLI경로에서_포인터_격하');
5216
+ if (!after2.includes('USER-KEEP-LINE')) bad.push('CLI경로에서_사용자라인_유실');
5217
+ // ⚠ 위 픽스처는 아카이브가 **실재**한다(위에서 만든다). 실재하지 않으면 그 문장은 거짓 약속이므로
5218
+ // 일반 경로로 되돌려야 한다 — 유지가 과하면 없어진 백업을 영구 고착시킨다(자체 헌트가 잡은 자리).
5219
+ const dgone = path.join(sb, 'gone'); fs.mkdirSync(dgone, { recursive: true });
5220
+ fs.writeFileSync(path.join(dgone, 'package.json'), '{"name":"g","version":"0.1.0"}');
5221
+ cp.spawnSync(process.execPath, [CLI, 'init', dgone, '--yes'], { cwd: dgone, encoding: 'utf8', timeout: 300000, env: ENVp });
5222
+ const gf = path.join(dgone, 'CLAUDE.md');
5223
+ fs.writeFileSync(gf, fs.readFileSync(gf, 'utf8')
5224
+ + '\n<!-- leerness:migration-preserved -->\n## Preserved previous content\n\n> 이전 버전에서 이어진 사용자/프로젝트 커스텀 내용 — 마이그레이션마다 자동 이월됩니다. 전체 원본 백업: `.harness/archive/GONE-9.9.9`\n\nUSER-KEEP-LINE\n');
5225
+ cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', dgone], { cwd: dgone, encoding: 'utf8', timeout: 300000, env: ENVp });
5226
+ const gAfter = fs.readFileSync(gf, 'utf8');
5227
+ if (/GONE-9\.9\.9/.test(gAfter)) bad.push('없어진_아카이브를_계속_가리킴');
5228
+ if (!/전체 원본 백업: `\.harness\/archive`/.test(gAfter)) bad.push('없어진_아카이브인데_일반경로로_안내림');
5229
+ if (!gAfter.includes('USER-KEEP-LINE')) bad.push('격하시_사용자라인_유실');
5230
+ } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
5231
+ ok = bad.length === 0;
5232
+ } catch (e) { bad.push('ERR:' + String(e && e.message).slice(0, 80)); }
5233
+ console.log(ok ? '✓ B(1.36.122) 보존 블록 백업 포인터: 새 백업 없으면 기존 구체 경로 유지 · 있으면 갱신 · 없으면 기본값'
5234
+ : '✗ 보존 블록 백업 포인터 실패 ' + JSON.stringify(bad));
5235
+ if (!ok) failed++;
5236
+ }
5237
+
5172
5238
  // 1.9.369 회귀 (UR-0025): MINIMAL_SKIP_KEYS/_parseSkillsValue 분리 — init --minimal 비핵심 스킵+코어 유지, --skills recommended 설치
5173
5239
  total++;
5174
5240
  {
@@ -11240,6 +11306,22 @@ total++;
11240
11306
  }
11241
11307
  // 평면 파일을 새로 만들지 않는다(구버전 잔재는 별개 — 지우지 않되 새로 만들지도 않는다)
11242
11308
  if (fs.existsSync(path.join(d, '.claude', 'skills', 'leerness.md'))) bad.push('skill:평면파일을새로만듦');
11309
+ // (자기 저장소 도그푸딩) 구 평면 잔재 경고가 `init` 에만 있었다 — 도구 배선의 문서화된 진입점인
11310
+ // `adapter` 로 들어온 사용자는 사본 2벌을 갖고도 아무 말을 못 들었다(우리 repo 가 그 상태였다).
11311
+ {
11312
+ const dleg = mk();
11313
+ fs.rmSync(path.join(dleg, '.claude', 'skills', 'leerness'), { recursive: true, force: true });
11314
+ fs.writeFileSync(path.join(dleg, '.claude', 'skills', 'leerness.md'), '---\nname: leerness\n---\nold\n');
11315
+ const rleg = cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', dleg], { cwd: dleg, encoding: 'utf8', timeout: 300000, env: ENV });
11316
+ const oleg = String(rleg.stdout || '') + String(rleg.stderr || '');
11317
+ if (!fs.existsSync(path.join(dleg, '.claude', 'skills', 'leerness', 'SKILL.md'))) bad.push('skill:adapter가_새경로_미생성');
11318
+ if (!/구 스킬 파일/.test(oleg)) bad.push('skill:adapter가_구파일_미경고');
11319
+ if (!fs.existsSync(path.join(dleg, '.claude', 'skills', 'leerness.md'))) bad.push('skill:구파일을_지움(false-DROP)');
11320
+ // 판별력 — 잔재가 없으면 그 경고가 뜨면 안 된다
11321
+ const dclean = mk();
11322
+ const rcl = cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', dclean], { cwd: dclean, encoding: 'utf8', timeout: 300000, env: ENV });
11323
+ if (/구 스킬 파일/.test(String(rcl.stdout || '') + String(rcl.stderr || ''))) bad.push('skill:잔재없는데_경고');
11324
+ }
11243
11325
  // ⚠ `init` 만 밟으면 안 된다. 실제로 `lib/catalogs.js` 의 어댑터 목록이 옛 평면 경로를 유지하고 있어
11244
11326
  // `adapter claude` 가 이 수정을 되돌리고 있었다(1.36.116 에서 발견) — 가드가 밟지 않은 경로였다.
11245
11327
  const da = mk(['--minimal']);
@@ -11976,6 +12058,47 @@ total++;
11976
12058
  scriptFiles: [],
11977
12059
  });
11978
12060
  if (!cmt.orphanScripts.includes('check:leaf')) bad.push('판정기가_CMD주석을_실행으로');
12061
+ // (검수) 러너 **자신의 경로**에 dot-segment 가 있으면 해석 결과가 후보와 어긋났다 — 실제로 도는 가드가 고아로.
12062
+ const ddot = path.join(sb4, 'dotnest');
12063
+ fs.mkdirSync(path.join(ddot, 'scripts', 'a'), { recursive: true });
12064
+ fs.mkdirSync(path.join(ddot, '.github', 'workflows'), { recursive: true });
12065
+ fs.writeFileSync(path.join(ddot, 'package.json'), JSON.stringify({ name: 'd', version: '0.1.0', scripts: { test: 'node scripts/./a/run.js' } }));
12066
+ fs.writeFileSync(path.join(ddot, '.github', 'workflows', 'ci.yml'), 'jobs:\n a:\n steps:\n - run: npm test\n');
12067
+ fs.writeFileSync(path.join(ddot, 'scripts', 'a', 'run.js'), 'require("./check.js");\n');
12068
+ fs.writeFileSync(path.join(ddot, 'scripts', 'a', 'check.js'), '// guard\n');
12069
+ const dotRes = PU._detectOrphanGuards(AUD._collectGuardInputs(ddot));
12070
+ if (dotRes.orphanFiles.includes('scripts/a/check.js')) bad.push('러너경로_dot세그먼트_오탐');
12071
+ // (검수 P3) 수집기와 판정기의 **주석 표기가 같아야** 한다 — `*`(블록 주석 이어짐)를 한쪽에만 두면 입력이 갈린다.
12072
+ const dstar = path.join(sb4, 'star');
12073
+ fs.mkdirSync(path.join(dstar, 'scripts'), { recursive: true });
12074
+ fs.mkdirSync(path.join(dstar, '.github', 'workflows'), { recursive: true });
12075
+ fs.writeFileSync(path.join(dstar, 'package.json'), JSON.stringify({ name: 's', version: '0.1.0', scripts: { test: 'node t.js', 'check:leaf': 'node l.js' } }));
12076
+ fs.writeFileSync(path.join(dstar, '.github', 'workflows', 'ci.yml'),
12077
+ 'jobs:\n a:\n steps:\n - run: |\n /*\n * node scripts/ghost.js\n */\n npm test\n');
12078
+ fs.writeFileSync(path.join(dstar, 'scripts', 'ghost.js'), 'sh("npm run check:leaf")\n');
12079
+ const starInp = AUD._collectGuardInputs(dstar);
12080
+ if (starInp.runners.some(r => /ghost\.js$/.test(r.file))) bad.push('블록주석에서_러너_수집');
12081
+ if (!PU._detectOrphanGuards(starInp).orphanScripts.includes('check:leaf')) bad.push('블록주석이_고아를_덮음');
12082
+ // ⚠ 위는 **수집기만** 잰다 — 판정기의 `*` 처리를 지워도 통과한다(검수 P3: 수집기가 이미 걸러 놨으니까).
12083
+ // 판정기 쪽은 러너를 **직접 주입해** 따로 잰다: 주석 줄에만 이름이 있으면 고아여야 한다.
12084
+ const starDet = PU._detectOrphanGuards({
12085
+ packageScripts: { test: 'node t.js', 'check:leaf': 'node l.js' },
12086
+ runners: [{ file: '.github/workflows/ci.yml', viaScript: null, viaScripts: [], unconditional: true,
12087
+ text: 'jobs:\n a:\n steps:\n - run: npm test\n' },
12088
+ { file: 'scripts/x.js', viaScript: 'test', viaScripts: ['test'],
12089
+ text: '/*\n * npm run check:leaf\n */\nconsole.log(1);\n' }],
12090
+ scriptFiles: [],
12091
+ });
12092
+ if (!starDet.orphanScripts.includes('check:leaf')) bad.push('판정기가_블록주석을_실행으로');
12093
+ // 판별력 — 주석이 아닌 실제 호출은 여전히 덮여야 한다
12094
+ const starLive = PU._detectOrphanGuards({
12095
+ packageScripts: { test: 'node t.js', 'check:leaf': 'node l.js' },
12096
+ runners: [{ file: '.github/workflows/ci.yml', viaScript: null, viaScripts: [], unconditional: true,
12097
+ text: 'jobs:\n a:\n steps:\n - run: npm test\n' },
12098
+ { file: 'scripts/x.js', viaScript: 'test', viaScripts: ['test'], text: 'sh("npm run check:leaf");\n' }],
12099
+ scriptFiles: [],
12100
+ });
12101
+ if (starLive.orphanScripts.length) bad.push('판정기_주석필터가_실제호출까지_지움');
11979
12102
  // (자체 헌트) Windows CI 는 `node scripts\x\check.js` 로 적는다. 후보 경로는 `/` 표기라
11980
12103
  // 전체 경로가 안 맞고 basename 경계 판정도 앞의 `\` 때문에 걸러져 **정상 배선이 고아로 보고**됐다.
11981
12104
  for (const [label, ref] of [['posix', 'scripts/x/check.js'], ['win', 'scripts\\x\\check.js'], ['win-dot', '.\\scripts\\x\\check.js']]) {