leerness 1.36.120 → 1.36.122

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.120 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.122 하네스를 사용합니다. 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.120는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.122는 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.120는 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.120 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.122 릴리스 태그 이력** (수백 라운드) · _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.120: 2026-08-15
238
+ Last synced by Leerness v1.36.122: 2026-08-15
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.120';
37
+ const VERSION = '1.36.122';
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') 시 호스트 프로세스 오염.
@@ -1043,7 +1043,10 @@ function managedMerge(file, next, previous, archiveDir, mergeOpts = {}) {
1043
1043
  // 다른 디렉토리에서 실행하면 "전체 원본 백업: <경로>" 가 존재하지 않는 곳을 가리킨다(검수 재현).
1044
1044
  // 그 문장은 "삭제하지 않았다" 는 약속의 근거이므로 반드시 프로젝트 기준이어야 한다.
1045
1045
  const _base = mergeOpts && mergeOpts.root ? mergeOpts.root : process.cwd();
1046
- const archiveRel = archiveDir ? path.relative(_base, archiveDir).replace(/\\/g, '/') : '.harness/archive';
1046
+ // 1.36.122 (자기 저장소 도그푸딩): 백업을 만들지 않는 실행(`adapter` ) 여기서 일반 경로를 **채워** 넘겼고,
1047
+ // 그러면 이전 마이그레이션이 적어 둔 **구체 백업 경로가 덮여** 사라졌다(우리 repo 에서 실측).
1048
+ // 백업이 없으면 `null` 을 넘겨, 이전 문서의 포인터를 그대로 두게 한다(보존 블록의 정보 손실 방지).
1049
+ const archiveRel = archiveDir ? path.relative(_base, archiveDir).replace(/\\/g, '/') : null;
1047
1050
  return _managedMerge(file, next, previous, archiveRel, MERGE_OVERWRITE_FILES, mergeOpts); // 1.36.60: altTemplate/lang 전달
1048
1051
  }
1049
1052
 
package/lib/audit.js CHANGED
@@ -83,7 +83,9 @@ function _collectGuardInputs(root) {
83
83
  // 1.36.116 (검수 #5): 경로에 `scripts/` 가 있어야만 수집했다 — `tools/run-ci.mjs`·`ci/run.mjs`·`bin/run-checks.js`
84
84
  // 같은 흔한 배치를 놓치고, 그 안의 동적 열거자를 못 보면 다시 오탐이 폭발한다. 디렉터리 이름을 넓힌다.
85
85
  // 경로 구분자는 `/`·`\` 둘 다 받는다 — Windows CI 는 `.\bin\cli.js` 로 적는다(검수 P2 미탐).
86
- const _FILE_RE = /[\w./\\-]*(?:scripts|tools|ci|bin|tasks)[/\\][\w.-]+\.(?:m?js|cjs|ts|sh)/g;
86
+ // ⚠ **중간 디렉터리**를 허용해야 한다: `scripts/a/run.js` 처럼 한 단계 더 들어간 러너가 아예 수집되지 않아
87
+ // 그 러너가 배선한 것이 전부 거짓 고아가 됐다(실측: `scripts/run.js` 는 잡히고 `scripts/a/run.js` 는 안 잡힘).
88
+ const _FILE_RE = /[\w./\\-]*(?:scripts|tools|ci|bin|tasks)[/\\](?:[\w.-]+[/\\])*[\w.-]+\.(?:m?js|cjs|ts|sh)/g;
87
89
  const _norm = (x) => String(x).replace(/\\/g, '/').replace(/^\.\//, '');
88
90
  // CI 설정 파일이 부르는 것은 출처 null(무조건 유효한 러너), npm 스크립트가 부르는 것은 그 스크립트가 출처다.
89
91
  // ⚠ 자기 진입점 제외는 **CI 설정이 직접 부르는 경우까지** 막으면 안 된다(검수 P2) — 그 파일이 실제로
@@ -93,7 +95,9 @@ function _collectGuardInputs(root) {
93
95
  // CMD 스텝의 `REM`·`::` 도 주석이다(검수 재현: `REM debug: node .\bin\phantom.js` 한 줄로 제품 진입점이 러너가 됐다).
94
96
  // ⚠ `--`·`;` 는 여기서 다루는 파일 종류(YAML·Makefile·셸·JS)에서 주석이 아니다 — 오히려 여러 줄 명령의
95
97
  // 연속 인자(`--file=scripts/x.js`)를 지워 파일 참조를 잃는다. 주석 표기는 실제로 쓰이는 것만 둔다.
96
- 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');
97
101
  // 설명 필드(`- name: archive index.js`)는 실행 증거가 아니다 — bare 진입점 매처에도 같은 규칙을 건다.
98
102
  // 단 값이 **산문일 때만** 설명으로 본다(matrix 변수명이 `name` 일 수 있다 — 판정기와 같은 규칙).
99
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}\``
@@ -1035,6 +1043,15 @@ function _managedMerge(file, next, previous, archiveRel, overwriteSet, opts = {}
1035
1043
  // 1.36.116 (검수 #3): `guard:*` 가 후보 어휘에 없었다 — 이 기능이 잡으려는 것의 **이름 그 자체**인데
1036
1044
  // `guard:firestore-parity` 같은 스크립트는 후보가 0개라 구조적으로 못 봤다. `validate` 도 접미 위치에 없었다.
1037
1045
  const _GUARD_NAME_RE = /^(?:pre|post)?(?:test|check|verify|validate|guard|gate|lint|typecheck|audit|scan|e2e|smoke)\b|[:-](?:test|check|verify|validate|guard|gate|lint|audit|scan|e2e|smoke)\b/i;
1046
+ // 경로 표기 정규화 — 이 파일 안의 모든 경로 매칭이 같은 규칙을 쓴다(정규화가 갈리면 한쪽만 맞는다).
1047
+ // ① backslash → `/` (이스케이프된 `\\` 도 접힌다) ② 반복 `/./` 축약 ③ 중복 `//` 축약.
1048
+ function _normPath(s) {
1049
+ let t = String(s).replace(/\\+/g, '/');
1050
+ let prev;
1051
+ do { prev = t; t = t.split('/./').join('/'); } while (t !== prev);
1052
+ do { prev = t; t = t.split('//').join('/'); } while (t !== prev);
1053
+ return t;
1054
+ }
1038
1055
  // 동적 열거자 판정은 **두 신호를 독립적으로** 본다 — 한 파일이 package.json 을 읽고, scripts 를 열거한다.
1039
1056
  // ⚠ 근접 창(예: 400자)을 요구했다가 실측에서 깨졌다: 실제 열거자는 읽기(L42)와 열거(L98)가 56줄 떨어져 있었고,
1040
1057
  // 그 결과 가장 잘 만든 프로젝트에서 85건 오탐이 났다. 거리 가정을 쓰지 않는다.
@@ -1124,7 +1141,10 @@ function _detectOrphanGuards(input) {
1124
1141
  // **주석 줄의 언급은 실행이 아니다.** 이 저장소에서 실제로 겪었다 — 이 함정을 설명하려고 쓴 주석에
1125
1142
  // 스크립트 이름을 적었더니 그 이름이 '덮임' 으로 처리돼 진짜 고아가 사라졌다(자기참조 3회차).
1126
1143
  // 전면 문자열 마스킹은 앞서 정상 입력에서 깨졌으므로 쓰지 않는다 — 줄 시작이 주석인 줄만 뺀다(저위험).
1127
- const _dropCommentLines = (s) => String(s).split('\n').filter(l => !/^\s*(\/\/|#|\*|--)/.test(l)).join('\n');
1144
+ // ⚠ 수집기(lib/audit.js) **같은 주석 표기**를 써야 한다 — 1.36.120 에서 수집기에만 CMD 표기(`::`·`@REM`)
1145
+ // 넣고 판정기에 안 넣어서, CMD 주석 줄의 이름이 '호출됨' 으로 계상돼 진짜 고아가 사라졌다(검수 재현).
1146
+ // `--`·`;` 는 여기서도 주석이 아니다(여러 줄 명령의 연속 인자를 지운다).
1147
+ const _dropCommentLines = (s) => String(s).split('\n').filter(l => !/^\s*(\/\/|#|\*|::|@?REM\s)/i.test(l)).join('\n');
1128
1148
  const _has = (hay, name) => {
1129
1149
  const esc = String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1130
1150
  return new RegExp(`(^|[^\\w:.-])${esc}([^\\w:.-]|$)`, 'm').test(hay);
@@ -1255,8 +1275,11 @@ function _detectOrphanGuards(input) {
1255
1275
  // **실제로 실행되는 가드가 고아로 보고됐다**(자체 헌트 실측: POSIX 표기만 통과).
1256
1276
  // `scripts/./check.js` 같은 dot-segment 도 편다 — 전체 경로 비교가 정규화를 안 해서 실제 호출이
1257
1277
  // 고아로 보고됐다(검수 재현). basename 경계는 앞의 `/` 를 거부하므로 폴백으로도 안 잡혔다.
1258
- const allText = (runners.filter(_liveRunner).map(r => String(r.text || '')).join('\n') + '\n'
1259
- + liveScripts.map(([, b]) => String(b || '')).join('\n')).replace(/\\/g, '/').split('/./').join('/');
1278
+ // ⚠ 정규화는 **한 곳**에서 한다(`_normPath`): 실제 JS 원문에는 이스케이프된 `\\` 가 들어 있고
1279
+ // (`require(".\\check.js")` 파일 바이트는 backslash 2개), `a/././b` 처럼 dot-segment 가 반복될 수 있다.
1280
+ // 한 번만 치환하면 `.//check.js` 나 `a//b` 가 남아 실제 호출을 놓친다(검수 재현, 실제 파일 바이트로 확인).
1281
+ const allText = _normPath(runners.filter(_liveRunner).map(r => String(r.text || '')).join('\n') + '\n'
1282
+ + liveScripts.map(([, b]) => String(b || '')).join('\n'));
1260
1283
  // ⚠ basename 만으로 덮였다고 보면 **동명 파일이 서로를 살린다**(검수 P2): `a/check.js` 를 부르는 배선이
1261
1284
  // `b/check.js` 까지 덮어 버린다. basename 은 그 이름이 유일할 때만 근거로 쓴다.
1262
1285
  const _baseCount = new Map();
@@ -1272,12 +1295,16 @@ function _detectOrphanGuards(input) {
1272
1295
  // ⚠ 기준 디렉터리가 다르다: **CI 설정의 명령은 프로젝트 루트에서 실행**된다(`run: node ./scripts/x.js`).
1273
1296
  // 설정 파일 위치(`.github/workflows`) 기준으로 풀면 `.github/workflows/scripts/x.js` 가 돼 실제 호출을 놓친다(검수 재현).
1274
1297
  // 코드 파일 안의 `require('./x')` 는 그 파일 기준이 맞다.
1275
- const dir = _isConfigRunner(r.file) ? [] : String(r.file || '').replace(/\\/g, '/').split('/').slice(0, -1);
1276
- const body = _dropCommentLines(String(r.text || ''));
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);
1301
+ // 원문을 먼저 정규화한다 — 실제 JS 는 `.\\check.js`(backslash 2개)로 저장되고, 한 번만 치환하면
1302
+ // `.//check.js` 가 남아 아래 정규식에 안 걸린다(검수 재현: 실제로 도는 가드가 고아로 보고됐다).
1303
+ const body = _normPath(_dropCommentLines(String(r.text || '')));
1277
1304
  for (const m of body.matchAll(/(\.{1,2}(?:[/\\][\w.-]+)+\.(?:m?js|cjs|ts|sh))/g)) {
1278
1305
  const segs = dir.slice();
1279
1306
  let escaped = false;
1280
- for (const s of m[1].replace(/\\/g, '/').split('/')) {
1307
+ for (const s of _normPath(m[1]).split('/')) {
1281
1308
  if (s === '.' || s === '') continue;
1282
1309
  if (s === '..') { if (!segs.length) { escaped = true; break; } segs.pop(); } else segs.push(s);
1283
1310
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.120",
3
+ "version": "1.36.122",
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,13 +5091,50 @@ 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 잡음)');
5098
5099
  if (!ok) failed++;
5099
5100
  }
5100
5101
 
5102
+ // 1.36.121 — `--json` stderr 계약을 **selftest 하나가 아니라 명령 전반**에 건다.
5103
+ // 1.36.120 에서 audit 이 json 모드에 stdout 만 막고 stderr 를 열어 둔 것이 드러났고, 그 잡음의 내용이
5104
+ // 프로젝트 상태에 따라 달라져 같은 코드로 게이트가 통과/실패로 갈렸다. 한 곳에서 나온 결함은 대개 클래스다 —
5105
+ // 실측 스윕(조회성 79개)에서 정보성 누출은 audit 하나였고 나머지 4건은 **사용법 오류의 stderr 진단**(CLI 관례)이었다.
5106
+ // 그 측정을 래칫으로 고정한다: 성공 경로의 `--json` 은 stderr 가 비어야 한다.
5107
+ total++;
5108
+ {
5109
+ let ok = false; const bad = [];
5110
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-json121-'));
5111
+ const ENV = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
5112
+ try {
5113
+ const d = path.join(sb, 'p'); fs.mkdirSync(d, { recursive: true });
5114
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0","scripts":{"test":"node t.js"}}');
5115
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
5116
+ // 성공 경로만 — 인자 없이도 동작하는 조회성 명령 (사용법 오류 경로는 stderr 진단이 관례라 대상 아님)
5117
+ const CMDS = ['audit', 'health', 'doctor', 'drift', 'plan', 'task', 'pulse', 'tech', 'handoff',
5118
+ 'capabilities', 'commands', 'which', 'gate', 'dashboard', 'state'];
5119
+ for (const c of CMDS) {
5120
+ const r = cp.spawnSync(process.execPath, [CLI, c, '--path', d, '--json'], { cwd: d, encoding: 'utf8', timeout: 180000, env: ENV });
5121
+ const out = String(r.stdout || ''), err = String(r.stderr || '');
5122
+ if (!out.trim()) { bad.push(`${c}:출력없음`); continue; }
5123
+ let j = null; try { j = JSON.parse(out.trim()); } catch {}
5124
+ if (!j) { bad.push(`${c}:stdout이JSON아님`); continue; }
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
+ }
5128
+ // 판별력 — 이 검사가 실제로 무언가를 실행했는지(전부 건너뛰면 공허하게 통과한다)
5129
+ if (bad.length === 0 && CMDS.length < 10) bad.push('대상이_너무적음');
5130
+ ok = bad.length === 0;
5131
+ } catch (e) { bad.push('ERR:' + String(e && e.message).slice(0, 80)); }
5132
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
5133
+ console.log(ok ? '✓ B(1.36.121) --json stderr 계약 스윕: 조회성 15종 모두 stdout 단일 JSON + stderr 0'
5134
+ : '✗ --json stderr 계약 스윕 실패 ' + JSON.stringify(bad.slice(0, 6)));
5135
+ if (!ok) failed++;
5136
+ }
5137
+
5101
5138
  // 1.9.367 회귀 (UR-0025): _mergeEnvLines 모듈 분리 — migrate 가 사용자 .env 값을 key-aware 로 보존 (덮어쓰기 X)
5102
5139
  total++;
5103
5140
  {
@@ -5134,6 +5171,48 @@ total++;
5134
5171
  if (!ok) failed++;
5135
5172
  }
5136
5173
 
5174
+ // 1.36.122 (자기 저장소 도그푸딩): 보존 블록의 **백업 포인터**가 재실행에 덮여 사라졌다.
5175
+ // `adapter` 처럼 새 백업을 만들지 않는 실행이 일반 경로(`.harness/archive`)를 채워 넘겨서,
5176
+ // 이전 마이그레이션이 적어 둔 구체 경로(`.harness/archive/leerness-1.36.100-…`)를 덮었다.
5177
+ // 그 문장은 "삭제하지 않았다" 는 약속의 근거이므로 격하는 곧 정보 손실이다. 세 방향을 모두 잰다.
5178
+ total++;
5179
+ {
5180
+ let ok = false; const bad = [];
5181
+ try {
5182
+ const PUx = require(path.resolve(path.dirname(CLI), '..', 'lib', 'pure-utils'));
5183
+ const next = '# T\nmanaged\n';
5184
+ const prev = '# T\nmanaged\nCUSTOM-LINE\n';
5185
+ const ptr = (s) => { const m = /(?:전체 원본 백업|Full original backup):\s*`([^`]+)`/.exec(s); return m && m[1]; };
5186
+ // ① 이전 포인터가 없고 새 백업도 없으면 기본값
5187
+ if (ptr(PUx._managedMerge('CLAUDE.md', next, prev, null, null)) !== '.harness/archive') bad.push('기본값아님');
5188
+ // ② 이전 포인터가 **구체 경로**면 새 백업이 없을 때 그대로 둔다(격하 금지)
5189
+ const prev2 = prev + '\n> 전체 원본 백업: `.harness/archive/leerness-1.2.3-STAMP`\n';
5190
+ if (ptr(PUx._managedMerge('CLAUDE.md', next, prev2, null, null)) !== '.harness/archive/leerness-1.2.3-STAMP') bad.push('구체경로가_격하됨');
5191
+ // ③ 새 백업이 있으면 그쪽으로 갱신(보존이 과해 최신 백업을 못 가리키면 안 된다)
5192
+ if (ptr(PUx._managedMerge('CLAUDE.md', next, prev2, '.harness/archive/NEW', null)) !== '.harness/archive/NEW') bad.push('새백업으로_갱신안됨');
5193
+ // ④ 실제 CLI 경로 — 새 백업 없이 adapter 를 두 번 돌려도 포인터가 유지돼야 한다
5194
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ptr122-'));
5195
+ try {
5196
+ const d = path.join(sb, 'p'); fs.mkdirSync(d, { recursive: true });
5197
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
5198
+ const ENVp = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_NO_PROMPT: '1' });
5199
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENVp });
5200
+ const cf = path.join(d, 'CLAUDE.md');
5201
+ // 사용자 커스텀 + 구체 백업 포인터를 가진 보존 블록을 만든다
5202
+ fs.writeFileSync(cf, fs.readFileSync(cf, 'utf8')
5203
+ + '\n<!-- leerness:migration-preserved -->\n## Preserved previous content\n\n> 이전 버전에서 이어진 사용자/프로젝트 커스텀 내용 — 마이그레이션마다 자동 이월됩니다. 전체 원본 백업: `.harness/archive/leerness-9.9.9-STAMP`\n\nUSER-KEEP-LINE\n');
5204
+ cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--path', d], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENVp });
5205
+ const after2 = fs.readFileSync(cf, 'utf8');
5206
+ if (!/leerness-9\.9\.9-STAMP/.test(after2)) bad.push('CLI경로에서_포인터_격하');
5207
+ if (!after2.includes('USER-KEEP-LINE')) bad.push('CLI경로에서_사용자라인_유실');
5208
+ } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
5209
+ ok = bad.length === 0;
5210
+ } catch (e) { bad.push('ERR:' + String(e && e.message).slice(0, 80)); }
5211
+ console.log(ok ? '✓ B(1.36.122) 보존 블록 백업 포인터: 새 백업 없으면 기존 구체 경로 유지 · 있으면 갱신 · 없으면 기본값'
5212
+ : '✗ 보존 블록 백업 포인터 실패 ' + JSON.stringify(bad));
5213
+ if (!ok) failed++;
5214
+ }
5215
+
5137
5216
  // 1.9.369 회귀 (UR-0025): MINIMAL_SKIP_KEYS/_parseSkillsValue 분리 — init --minimal 비핵심 스킵+코어 유지, --skills recommended 설치
5138
5217
  total++;
5139
5218
  {
@@ -11908,6 +11987,60 @@ total++;
11908
11987
  scriptFiles: ['scripts/check.js'],
11909
11988
  });
11910
11989
  if (relDot.orphanFiles.length) bad.push(`dot세그먼트_경로_오탐(${relDot.orphanFiles.join(',')})`);
11990
+ // (검수) 합성 문자열이 아니라 **실제 파일 바이트**로 잰다: JS 원문의 `require(".\\check.js")` 는
11991
+ // backslash 가 두 개다. 한 번만 치환하면 `.//check.js` 가 남아 실제로 도는 가드가 고아로 보고됐다.
11992
+ // 같은 픽스처가 **중첩 러너**(`scripts/a/run.js`)도 함께 잰다 — 그건 아예 수집되지 않고 있었다.
11993
+ const dwin = path.join(sb4, 'winrel');
11994
+ fs.mkdirSync(path.join(dwin, 'scripts', 'a'), { recursive: true });
11995
+ fs.mkdirSync(path.join(dwin, 'scripts', 'b'), { recursive: true });
11996
+ fs.mkdirSync(path.join(dwin, '.github', 'workflows'), { recursive: true });
11997
+ fs.writeFileSync(path.join(dwin, 'package.json'), JSON.stringify({ name: 'w', version: '0.1.0', scripts: { test: 'node scripts/a/run.js' } }));
11998
+ fs.writeFileSync(path.join(dwin, '.github', 'workflows', 'ci.yml'), 'jobs:\n a:\n steps:\n - run: npm test\n');
11999
+ fs.writeFileSync(path.join(dwin, 'scripts', 'a', 'run.js'), 'require(".\\\\check.js");\n');
12000
+ fs.writeFileSync(path.join(dwin, 'scripts', 'a', 'check.js'), '// guard\n');
12001
+ fs.writeFileSync(path.join(dwin, 'scripts', 'b', 'check.js'), '// other\n');
12002
+ const winInp = AUD._collectGuardInputs(dwin);
12003
+ if (!winInp.runners.some(r => /scripts[\\/]a[\\/]run\.js$/.test(r.file))) bad.push('중첩러너_미수집');
12004
+ const winRes = PU._detectOrphanGuards(winInp);
12005
+ if (winRes.orphanFiles.includes('scripts/a/check.js')) bad.push('실제JS의_윈도우_상대경로_오탐');
12006
+ if (!winRes.orphanFiles.includes('scripts/b/check.js')) bad.push('판별력:무관파일까지_덮음');
12007
+ // 반복 dot-segment 정규화
12008
+ const relDot2 = PU._detectOrphanGuards({
12009
+ packageScripts: { test: 'node t.js' },
12010
+ runners: [{ file: '.github/workflows/ci.yml', viaScript: null, viaScripts: [], unconditional: true,
12011
+ text: 'jobs:\n a:\n steps:\n - run: node scripts/././check.js\n - run: npm test\n' }],
12012
+ scriptFiles: ['scripts/check.js'],
12013
+ });
12014
+ if (relDot2.orphanFiles.length) bad.push(`반복dot세그먼트_오탐(${relDot2.orphanFiles.join(',')})`);
12015
+ // 판정기의 주석 규칙이 수집기와 같아야 한다 — CMD 표기를 한쪽에만 넣으면 주석 속 이름이 호출로 계상된다
12016
+ const cmt = PU._detectOrphanGuards({
12017
+ packageScripts: { test: 'node t.js', 'check:leaf': 'node l.js' },
12018
+ runners: [{ file: '.github/workflows/ci.yml', viaScript: null, viaScripts: [], unconditional: true,
12019
+ text: 'jobs:\n a:\n steps:\n - shell: cmd\n run: |\n REM npm run check:leaf\n echo hi\n - run: npm test\n' }],
12020
+ scriptFiles: [],
12021
+ });
12022
+ if (!cmt.orphanScripts.includes('check:leaf')) bad.push('판정기가_CMD주석을_실행으로');
12023
+ // (검수) 러너 **자신의 경로**에 dot-segment 가 있으면 해석 결과가 후보와 어긋났다 — 실제로 도는 가드가 고아로.
12024
+ const ddot = path.join(sb4, 'dotnest');
12025
+ fs.mkdirSync(path.join(ddot, 'scripts', 'a'), { recursive: true });
12026
+ fs.mkdirSync(path.join(ddot, '.github', 'workflows'), { recursive: true });
12027
+ fs.writeFileSync(path.join(ddot, 'package.json'), JSON.stringify({ name: 'd', version: '0.1.0', scripts: { test: 'node scripts/./a/run.js' } }));
12028
+ fs.writeFileSync(path.join(ddot, '.github', 'workflows', 'ci.yml'), 'jobs:\n a:\n steps:\n - run: npm test\n');
12029
+ fs.writeFileSync(path.join(ddot, 'scripts', 'a', 'run.js'), 'require("./check.js");\n');
12030
+ fs.writeFileSync(path.join(ddot, 'scripts', 'a', 'check.js'), '// guard\n');
12031
+ const dotRes = PU._detectOrphanGuards(AUD._collectGuardInputs(ddot));
12032
+ if (dotRes.orphanFiles.includes('scripts/a/check.js')) bad.push('러너경로_dot세그먼트_오탐');
12033
+ // (검수 P3) 수집기와 판정기의 **주석 표기가 같아야** 한다 — `*`(블록 주석 이어짐)를 한쪽에만 두면 입력이 갈린다.
12034
+ const dstar = path.join(sb4, 'star');
12035
+ fs.mkdirSync(path.join(dstar, 'scripts'), { recursive: true });
12036
+ fs.mkdirSync(path.join(dstar, '.github', 'workflows'), { recursive: true });
12037
+ 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' } }));
12038
+ fs.writeFileSync(path.join(dstar, '.github', 'workflows', 'ci.yml'),
12039
+ 'jobs:\n a:\n steps:\n - run: |\n /*\n * node scripts/ghost.js\n */\n npm test\n');
12040
+ fs.writeFileSync(path.join(dstar, 'scripts', 'ghost.js'), 'sh("npm run check:leaf")\n');
12041
+ const starInp = AUD._collectGuardInputs(dstar);
12042
+ if (starInp.runners.some(r => /ghost\.js$/.test(r.file))) bad.push('블록주석에서_러너_수집');
12043
+ if (!PU._detectOrphanGuards(starInp).orphanScripts.includes('check:leaf')) bad.push('블록주석이_고아를_덮음');
11911
12044
  // (자체 헌트) Windows CI 는 `node scripts\x\check.js` 로 적는다. 후보 경로는 `/` 표기라
11912
12045
  // 전체 경로가 안 맞고 basename 경계 판정도 앞의 `\` 때문에 걸러져 **정상 배선이 고아로 보고**됐다.
11913
12046
  for (const [label, ref] of [['posix', 'scripts/x/check.js'], ['win', 'scripts\\x\\check.js'], ['win-dot', '.\\scripts\\x\\check.js']]) {