leerness 1.9.315 → 1.9.317

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,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.317 — 2026-06-04 — UR-0051: 텔레메트리 분리 (설치리뷰)
4
+
5
+ **📊 내부 auto-call 이 usage 통계를 오염시켜 거짓 skill 추천을 유발하던 결함 수정.**
6
+
7
+ ### 배경 (설치리뷰)
8
+ "텔레메트리 오염 — 내부 auto-call 이 사용자명령 집계 → 거짓 skill 추천 + task add ~550ms". leerness 가 자기 자신을 spawn 하는 내부 auto-call(`task add` → `review-request`, MCP `callLeerness`, session close/handoff/drift 자동 보조 호출 등 ~20곳)이 dispatch 의 usage 카운터에 **사용자 명령처럼 집계** → `skill suggest`(명령 빈도 기반)가 거짓 후보 추천.
9
+
10
+ ### 구현 (UR-0051)
11
+ 1. **`LEERNESS_INTERNAL=1` 마커**: 모든 내부 self-spawn(`cp.spawnSync(process.execPath, [__filename, …])`)의 env 에 마킹 (~20곳). 공통 env 프래그먼트 일괄 적용 + env 없던 3곳(task add/session close 자동) 보강.
12
+ 2. **usage 집계 가드**: dispatch 의 `_bumpUsage` 호출 조건에 `process.env.LEERNESS_INTERNAL !== '1'` 추가 — 내부 호출은 집계 제외.
13
+ 3. selftest 64→65 · e2e 261→262.
14
+
15
+ ### 검증
16
+ - **selftest 65/65 PASS** · **E2E 262/262 PASS** (회귀 0 — ~20곳 마킹에도 모든 auto-call 정상).
17
+ - 실측: `task add` 1회 → usage `{task:1}` (이전엔 `review-request` 도 집계) · 일반 drift 1→2 집계, **`LEERNESS_INTERNAL=1` drift → 미집계(2 유지)**.
18
+ - 범위: usage(skill 추천) 오염 제거. task add 지연(~550ms, review-request spawn 자체)은 별도 — 후속 캐싱/비동기화 검토 여지.
19
+
20
+ ## 1.9.316 — 2026-06-04 — 🐛 drift 'session close 누락' 영구 오발화 버그 (자가 발견)
21
+
22
+ **🔧 session close 직후에도 drift 가 `session close 누락`(13일)을 보고하던 버그 수정 — 4라운드 연속 관찰된 leerness 자체 정확성 결함.**
23
+
24
+ ### 근본 원인 (자가 발견)
25
+ - drift 의 `session close 누락` 신호는 `session-handoff.md` 의 `Last generated:` 타임스탬프로 나이를 계산.
26
+ - `sessionClose` 가 파일 재작성 시 프론트매터 보존 로직 `cur.indexOf('\n---\n', 4)` 가 **본문의 `---`(수평선/구분자)** 를 프론트매터 종료로 오인 → **구 블록 전체(구 `Last generated`)를 보존**하고 그 뒤에 새 블록을 append.
27
+ - 결과: `session-handoff.md` 에 `Last generated:` 가 **2개 누적**(구 + 신). drift 의 `.match()` 는 **첫(=구) 매치**를 읽어 → 매 session close 후에도 13일 stale → 영구 오발화.
28
+
29
+ ### 구현 (2중 수정)
30
+ 1. **근본 (write)**: 프론트매터는 파일이 **`^---` 로 시작할 때만** 추출 — 본문 `---` 오인 차단. 프론트매터 없으면 깨끗이 덮어써 단일 블록 유지(기존 손상 파일도 다음 close 에 self-heal).
31
+ 2. **방어 (read)**: drift 가 `matchAll` 로 **최신(마지막) `Last generated`** 사용 — 혹시 모를 중복에도 freshest 반영.
32
+ 3. selftest 63→64 · e2e 260→261.
33
+
34
+ ### 검증
35
+ - **selftest 64/64 PASS** · **E2E 261/261 PASS** (회귀 0).
36
+ - 실측: 손상 파일(구 timestamp + 본문 `---`) → session close → `Last generated` **1개**(신선), drift `session close 누락` **age 0.00d 클리어** · 프론트매터 파일 3회 연속 close → 누적 0(프론트매터 보존).
37
+
3
38
  ## 1.9.315 — 2026-06-04 — UR-0054(부분): doc/surface 정합 + doctor 명령 (설치리뷰)
4
39
 
5
40
  **📋 stale 현재상태 표시 수정 + `leerness doctor` 설치 진단 명령 추가 (Codex#2·Sonnet#2·Opus#2 공통 지적).**
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > **AI 코딩 에이전트의 거짓 완료·중복·망각·충돌을 막아주는 검수·기억·협업 CLI 하네스.**
4
4
  > **A CLI harness that stops AI coding agents from faking completion, duplicating work, forgetting context, and colliding.**
5
5
 
6
- [![npm](https://img.shields.io/badge/npm-leerness-blue)](https://www.npmjs.com/package/leerness) [![version](https://img.shields.io/badge/version-1.9.315-green)]() [![tests](https://img.shields.io/badge/e2e-260%2F260-success)]() [![selftest](https://img.shields.io/badge/selftest-63-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-83-brightgreen)]() [![providers](https://img.shields.io/badge/AI_providers-10-brightgreen)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
6
+ [![npm](https://img.shields.io/badge/npm-leerness-blue)](https://www.npmjs.com/package/leerness) [![version](https://img.shields.io/badge/version-1.9.317-green)]() [![tests](https://img.shields.io/badge/e2e-262%2F262-success)]() [![selftest](https://img.shields.io/badge/selftest-65-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-83-brightgreen)]() [![providers](https://img.shields.io/badge/AI_providers-10-brightgreen)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
7
7
 
8
8
  ```
9
9
  ╔══════════════════════════════════════════════════════════════╗
@@ -471,7 +471,7 @@ MIT — © leerness contributors
471
471
  <!-- leerness:project-readme:start -->
472
472
  ## Leerness Project Harness
473
473
 
474
- 이 프로젝트는 Leerness v1.9.315 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
474
+ 이 프로젝트는 Leerness v1.9.317 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
475
475
 
476
476
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
477
477
 
@@ -525,7 +525,7 @@ leerness memory restore decision <date|title>
525
525
 
526
526
  ### MCP server (외부 AI 통합)
527
527
 
528
- Leerness v1.9.315는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **83개 도구**를 노출:
528
+ Leerness v1.9.317는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **83개 도구**를 노출:
529
529
 
530
530
  ```jsonc
531
531
  // 카테고리별
@@ -546,7 +546,7 @@ Leerness v1.9.315는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
546
546
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
547
547
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) stress-v* 신규 작성 + 누적 회귀 → 4) e2e 219/219 → 5) npm pack + git tag + GitHub release → 6) main 자동 push (1.9.140+) → 7) session close → 8) 다음 라운드 예약.
548
548
 
549
- 현재 누적: **70 라운드 (1.9.40 → 1.9.315)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
549
+ 현재 누적: **70 라운드 (1.9.40 → 1.9.317)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
550
550
 
551
551
  ### 성능 가이드 (1.9.140 측정)
552
552
 
@@ -584,6 +584,6 @@ leerness release pack --close --auto-main-push
584
584
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
585
585
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
586
586
 
587
- Last synced by Leerness v1.9.315: 2026-06-04
587
+ Last synced by Leerness v1.9.317: 2026-06-04
588
588
  <!-- leerness:project-readme:end -->
589
589
 
package/bin/harness.js CHANGED
@@ -14,7 +14,7 @@ const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInG
14
14
  // 1.9.295 (UR-0025 4단계): 정적 데이터 카탈로그 모듈 분리 (비파괴, require-based).
15
15
  const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST } = require('../lib/catalogs');
16
16
 
17
- const VERSION = '1.9.315';
17
+ const VERSION = '1.9.317';
18
18
 
19
19
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
20
20
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -2517,7 +2517,7 @@ function agentModeCmd(root, sub) {
2517
2517
  const yl = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
2518
2518
  const dm = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
2519
2519
  function spawnChild(args, opts) {
2520
- return cp.spawnSync(process.execPath, [__filename, ...args], Object.assign({ encoding: 'utf8', cwd: root, env: { ...process.env, LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1' } }, opts || {}));
2520
+ return cp.spawnSync(process.execPath, [__filename, ...args], Object.assign({ encoding: 'utf8', cwd: root, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1' } }, opts || {}));
2521
2521
  }
2522
2522
  if (!sub || sub === 'help') {
2523
2523
  log(cy(`# leerness agent-mode (1.9.239) — 자율 모드 전용 통합 명령`));
@@ -3114,6 +3114,8 @@ function _selfTestCases() {
3114
3114
  { name: 'MCP notification 준수: id없는 요청 무응답 가드 + ping {} (UR-0049 설치리뷰 1.9.313)', run: () => { const src = read(__filename); const guard = src.includes("const isNotification = !('id' in req)") && src.includes("req.method.startsWith('notifications/')") && src.includes('if (isNotification) return;'); const ping = src.includes("req.method === 'ping'") && /ping[\s\S]{0,140}result: \{\} \}/.test(src); return guard && ping; } },
3115
3115
  { name: 'PowerShell 감지: pwsh7(channel/Documents\\PowerShell/install) + ps5.1 영구경로 과경고 안함 (UR-0052 설치리뷰 1.9.314)', run: () => { const f = _detectPwshFromEnv; const pwsh7a = f({ POWERSHELL_DISTRIBUTION_CHANNEL: 'MSI:Windows 10' }).version === '7'; const pwsh7b = f({ PSModulePath: 'C:\\Users\\me\\Documents\\PowerShell\\Modules' }).version === '7'; const pwsh7c = f({ PSModulePath: 'C:\\Program Files\\PowerShell\\7\\Modules' }).version === '7'; const noFalsePs5 = f({ PSModulePath: 'C:\\Users\\me\\Documents\\WindowsPowerShell\\Modules' }).isPowerShell === false; const cmdSys = f({ PSModulePath: 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\Modules' }).isPowerShell === false; const empty = f({}).isPowerShell === false; const src = read(__filename); const wired = src.includes('const fromEnv = _detectPwshFromEnv()') && src.includes('const pwshEnv = _detectPwshFromEnv()'); return pwsh7a && pwsh7b && pwsh7c && noFalsePs5 && cmdSys && empty && wired; } },
3116
3116
  { name: 'doc/surface 정합: doctor 명령 + stale MCP 카운트 동적화(commands/banner) (UR-0054 설치리뷰 1.9.315)', run: () => { const src = read(__filename); const doctorOk = typeof doctorCmd === 'function' && /cmd === 'doctor'/.test(src) && /# leerness doctor/.test(src); const dynCount = /MCP 도구: \$\{_mcpToolCount\(\)\}/.test(src) && /외부 AI 통합 \(MCP \$\{_mcpToolCount\(\)\} 도구\)/.test(src); return doctorOk && dynCount; } },
3117
+ { name: 'drift 마커 버그: session-handoff 프론트매터는 ^--- 일 때만 + drift 최신 Last generated (1.9.316)', run: () => { const src = read(__filename); const writeFix = src.includes('if (/^---\\r?\\n/.test(cur))') && src.includes('writeUtf8(handoffPath(root), frontmatter + block)'); const readFix = src.includes('matchAll(/Last generated') && src.includes('allGen[allGen.length - 1]'); return writeFix && readFix; } },
3118
+ { name: '텔레메트리 분리: 내부 auto-call(LEERNESS_INTERNAL) usage 집계 제외 + 주요 spawn 마킹 (UR-0051 설치리뷰 1.9.317)', run: () => { const src = read(__filename); const guard = src.includes("process.env.LEERNESS_INTERNAL !== '1'"); const marked = (src.match(/LEERNESS_INTERNAL: '1'/g) || []).length >= 10; const reviewMarked = /'review-request'[\s\S]{0,200}LEERNESS_INTERNAL: '1'/.test(src); return guard && marked && reviewMarked; } },
3117
3119
  { name: 'VERSION 형식 (x.y.z)', run: () => /^\d+\.\d+\.\d+$/.test(VERSION) }
3118
3120
  ];
3119
3121
  }
@@ -5889,7 +5891,7 @@ async function nextActionCmd(root, sub, ...rest) {
5889
5891
  state.queue.splice(n, 1);
5890
5892
  _writeNextActionQueue(root, state.queue);
5891
5893
  // leerness task add 호출
5892
- const taskResult = cp.spawnSync(process.execPath, [__filename, 'task', 'add', taskTitle, '--path', root], { encoding: 'utf8', timeout: 8000 });
5894
+ const taskResult = cp.spawnSync(process.execPath, [__filename, 'task', 'add', taskTitle, '--path', root], { encoding: 'utf8', timeout: 8000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
5893
5895
  if (taskResult.status === 0) {
5894
5896
  const m = (taskResult.stdout || '').match(/T-\d{4}/);
5895
5897
  log(` ✓ task 추가: ${m ? m[0] : '?'} — "${taskTitle}"`);
@@ -6410,7 +6412,7 @@ function taskAdd(root, text) {
6410
6412
  const t0 = Date.now();
6411
6413
  const r = cp.spawnSync(process.execPath, [__filename, 'review-request', text, '--path', absRoot(root), '--json'], {
6412
6414
  encoding: 'utf8', timeout: 15000,
6413
- env: { ...process.env, LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' }
6415
+ env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' }
6414
6416
  });
6415
6417
  if (r.status === 0 && r.stdout) {
6416
6418
  const j = JSON.parse(r.stdout);
@@ -7917,7 +7919,7 @@ function handoff(root) {
7917
7919
  // 1) drift level (가벼운 check)
7918
7920
  try {
7919
7921
  const r = cp.spawnSync(process.execPath, [__filename, 'drift', 'check', root, '--json'],
7920
- { encoding: 'utf8', timeout: 8000, env: { ...process.env, LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '0' } });
7922
+ { encoding: 'utf8', timeout: 8000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '0' } });
7921
7923
  const j = JSON.parse(r.stdout.trim());
7922
7924
  if (j.level) parts.push(`drift ${j.level.replace(/^[^\w]+/, '')} (${j.score})`);
7923
7925
  } catch {}
@@ -8515,7 +8517,7 @@ function handoff(root) {
8515
8517
  if (!has('--no-brainstorm-hits') && !has('--quiet') && process.env.LEERNESS_NO_BRAINSTORM_HITS !== '1') {
8516
8518
  try {
8517
8519
  const r = cp.spawnSync(process.execPath, [__filename, 'brainstorm', keyword, '--path', root, '--json'],
8518
- { encoding: 'utf8', timeout: 8000, env: { ...process.env, LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
8520
+ { encoding: 'utf8', timeout: 8000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
8519
8521
  const bj = JSON.parse(r.stdout);
8520
8522
  const hits = bj.hits || {};
8521
8523
  const items = [];
@@ -9018,7 +9020,7 @@ function handoff(root) {
9018
9020
  if (process.env.LEERNESS_AUTO_SECURITY_FIX === '1') {
9019
9021
  try {
9020
9022
  const r = cp.spawnSync(process.execPath, [__filename, 'audit', root, '--fix'],
9021
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
9023
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
9022
9024
  if (r.status === 0) {
9023
9025
  log(dim(` ✓ 자동 회복 (LEERNESS_AUTO_SECURITY_FIX=1): audit --fix 완료`));
9024
9026
  } else {
@@ -9263,7 +9265,7 @@ function handoffCmd(root) {
9263
9265
  if (has('--auto-recover') && sevStale) {
9264
9266
  log(dim(` 🔧 --auto-recover 활성 — session close 자동 실행 중...`));
9265
9267
  try {
9266
- const r = cp.spawnSync(process.execPath, [__filename, 'session', 'close', absR0], { encoding: 'utf8', timeout: 60000 });
9268
+ const r = cp.spawnSync(process.execPath, [__filename, 'session', 'close', absR0], { encoding: 'utf8', timeout: 60000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
9267
9269
  if (r.status === 0) {
9268
9270
  log(dim(` ✓ session close 자동 완료 (다음 라운드부터 healthy)`));
9269
9271
  const s2 = _readUsageStats(absR0);
@@ -12246,9 +12248,15 @@ function sessionClose(root, opts = {}) {
12246
12248
  ``
12247
12249
  ].join('\n');
12248
12250
  const cur = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
12249
- const fmEnd = cur.indexOf('\n---\n', 4);
12250
- const frontmatter = fmEnd > 0 ? cur.slice(0, fmEnd + 5) + MARK + '\n' : '';
12251
- writeUtf8(handoffPath(root), (frontmatter || '') + block);
12251
+ // 1.9.316 (drift 마커 버그): 프론트매터는 파일이 '---' 로 시작할 때만 추출.
12252
+ // 이전: 본문의 '---'(수평선/구분자)을 프론트매터 종료로 오인 블록(구 'Last generated')을 보존
12253
+ // session-handoff.md 에 'Last generated' 중복 누적 → drift 가 첫(=구) 매치를 읽어 'session close 누락' 영구 오발화.
12254
+ let frontmatter = '';
12255
+ if (/^---\r?\n/.test(cur)) {
12256
+ const fmEnd = cur.indexOf('\n---\n', 4);
12257
+ if (fmEnd > 0) frontmatter = cur.slice(0, fmEnd + 5) + MARK + '\n';
12258
+ }
12259
+ writeUtf8(handoffPath(root), frontmatter + block);
12252
12260
 
12253
12261
  if (exists(currentStatePath(root))) {
12254
12262
  let cs = read(currentStatePath(root));
@@ -12295,7 +12303,7 @@ function sessionClose(root, opts = {}) {
12295
12303
  // 1) skill suggest
12296
12304
  try {
12297
12305
  const r = cp.spawnSync(process.execPath, [__filename, 'skill', 'suggest', '--path', root, '--min', '3', '--json'],
12298
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
12306
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
12299
12307
  const j = JSON.parse(r.stdout);
12300
12308
  if (j.candidates && j.candidates.length) {
12301
12309
  log(dim(' 📌 신규 skill 후보 (Hermes-style 자동 학습):'));
@@ -12306,7 +12314,7 @@ function sessionClose(root, opts = {}) {
12306
12314
  // 2) drift check
12307
12315
  try {
12308
12316
  const r = cp.spawnSync(process.execPath, [__filename, 'drift', 'check', root, '--json'],
12309
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '0' } });
12317
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '0' } });
12310
12318
  const j = JSON.parse(r.stdout.trim());
12311
12319
  if (j.level) {
12312
12320
  log(dim(` 🩺 drift 상태: ${j.level} ${j.score}/200`));
@@ -15588,7 +15596,9 @@ function driftCheckCmd(root, opts = {}) {
15588
15596
  const shPath = handoffPath(root);
15589
15597
  if (exists(shPath)) {
15590
15598
  const txt = read(shPath);
15591
- const m = txt.match(/Last generated:\s*([\d\-T:.Z]+)/);
15599
+ // 1.9.316 (drift 마커 버그): 최신(마지막) 'Last generated' 사용 — 구 블록 중복 시 첫() 매치를 읽던 오발화 방어.
15600
+ const allGen = [...txt.matchAll(/Last generated:\s*([\d\-T:.Z]+)/g)];
15601
+ const m = allGen.length ? allGen[allGen.length - 1] : null;
15592
15602
  let ageDays;
15593
15603
  if (m) {
15594
15604
  ageDays = (now - new Date(m[1]).getTime()) / 86400000;
@@ -15731,7 +15741,7 @@ function driftCheckCmd(root, opts = {}) {
15731
15741
  log(`🔒 --auto-fix 활성 (1.9.82) — 보안 신호 회복: audit --fix 자동 실행 중...`);
15732
15742
  try {
15733
15743
  const r = cp.spawnSync(process.execPath, [__filename, 'audit', root, '--fix'],
15734
- { encoding: 'utf8', timeout: 30000, env: { ...process.env, LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
15744
+ { encoding: 'utf8', timeout: 30000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
15735
15745
  if (r.status === 0) {
15736
15746
  log(`✓ audit --fix 완료 — .gitignore + .env.example 동기화`);
15737
15747
  // 재검사 (보안 신호 회복 확인)
@@ -15844,7 +15854,7 @@ function driftCheckCmd(root, opts = {}) {
15844
15854
  log('');
15845
15855
  log(`🔧 --auto-fix 활성 — session close 자동 실행 중...`);
15846
15856
  try {
15847
- const r = cp.spawnSync(process.execPath, [__filename, 'session', 'close', root], { encoding: 'utf8', timeout: 60000 });
15857
+ const r = cp.spawnSync(process.execPath, [__filename, 'session', 'close', root], { encoding: 'utf8', timeout: 60000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
15848
15858
  if (r.status === 0) {
15849
15859
  log(`✓ session close 자동 완료`);
15850
15860
  // autoResolved 카운트
@@ -16125,7 +16135,7 @@ function skillPublishCmd(root) {
16125
16135
  if (!has('--no-security-check') && !has('--force')) {
16126
16136
  try {
16127
16137
  const r = cp.spawnSync(process.execPath, [__filename, 'health', root, '--json'],
16128
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '0' } });
16138
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '0' } });
16129
16139
  const j = JSON.parse(r.stdout.trim());
16130
16140
  if (j.issues && j.issues.length > 0) {
16131
16141
  log(`🚨 보안 사전 점검 (1.9.98): ${j.issues.length}건 issue 발견`);
@@ -16208,11 +16218,11 @@ const BENCHMARK_SCENARIOS = {
16208
16218
  setup: (dir) => {
16209
16219
  // 빈 evidence로 done task 생성
16210
16220
  cp.spawnSync(process.execPath, [__filename, 'task', 'add', '거짓 완료된 작업', '--status', 'done', '--evidence', '', '--path', dir],
16211
- { encoding: 'utf8', timeout: 10000, env: { ...process.env, LEERNESS_NO_PROMPT: '1' } });
16221
+ { encoding: 'utf8', timeout: 10000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1' } });
16212
16222
  },
16213
16223
  measure: (dir) => {
16214
16224
  const r = cp.spawnSync(process.execPath, [__filename, 'lazy', 'detect', dir],
16215
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_NO_DRIFT_CHECK: '1' } });
16225
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
16216
16226
  const detected = /✗ |found.*issue|증거 없는|empty/.test(r.stdout);
16217
16227
  return { detected, exit: r.status, sample: r.stdout.slice(0, 200) };
16218
16228
  }
@@ -16227,7 +16237,7 @@ const BENCHMARK_SCENARIOS = {
16227
16237
  measure: (dir) => {
16228
16238
  const r = cp.spawnSync(process.execPath, [__filename, 'contract', 'verify',
16229
16239
  path.join(dir, 'mismatch-spec.md'), path.join(dir, 'mismatch-impl.js'), '--json'],
16230
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_NO_DRIFT_CHECK: '1' } });
16240
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_DRIFT_CHECK: '1' } });
16231
16241
  let j = null;
16232
16242
  try { j = JSON.parse(r.stdout); } catch {}
16233
16243
  const detected = j && j.missingFunctions && j.missingFunctions.includes('missingFn');
@@ -16247,7 +16257,7 @@ const BENCHMARK_SCENARIOS = {
16247
16257
  },
16248
16258
  measure: (dir) => {
16249
16259
  const r = cp.spawnSync(process.execPath, [__filename, 'drift', 'check', dir, '--json'],
16250
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_NO_DRIFT_CHECK: '0' } });
16260
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_DRIFT_CHECK: '0' } });
16251
16261
  let j = null;
16252
16262
  try { j = JSON.parse(r.stdout.trim()); } catch {}
16253
16263
  const detected = j && (j.level === '🔴 critical' || j.level === '🟠 attention');
@@ -16266,7 +16276,7 @@ const BENCHMARK_SCENARIOS = {
16266
16276
  measure: (dir) => {
16267
16277
  const r = cp.spawnSync(process.execPath, [__filename, 'skill', 'install',
16268
16278
  path.join(dir, 'bom-test.md'), '--path', dir],
16269
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_NO_PROMPT: '1' } });
16279
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1' } });
16270
16280
  const f = path.join(dir, '.harness', 'skills', 'bom-test', 'SKILL.md');
16271
16281
  return { detected: r.status === 0 && exists(f), sample: r.stdout.slice(0, 200) };
16272
16282
  }
@@ -16303,7 +16313,7 @@ async function _benchmarkMeasure(root, task) {
16303
16313
  const t1 = Date.now();
16304
16314
  cp.spawnSync(process.execPath, [__filename, 'audit', root, '--fix'], {
16305
16315
  encoding: 'utf8', timeout: 15000,
16306
- env: { ...process.env, LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' }
16316
+ env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' }
16307
16317
  });
16308
16318
  const verifyTime = Date.now() - t1;
16309
16319
  results.push({
@@ -16773,7 +16783,7 @@ function mcpServeCmd(root) {
16773
16783
  const r = cp.spawnSync(process.execPath, [__filename, ...cliArgs], {
16774
16784
  encoding: 'utf8',
16775
16785
  timeout: 60000,
16776
- env: { ...process.env, LEERNESS_NO_BANNER: '1', LEERNESS_NO_STALE_CHECK: '1', LEERNESS_NO_DRIFT_CHECK: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_WORKFLOW_GUIDE: '1' }
16786
+ env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_STALE_CHECK: '1', LEERNESS_NO_DRIFT_CHECK: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_WORKFLOW_GUIDE: '1' }
16777
16787
  });
16778
16788
  return { ok: r.status === 0, exit: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
16779
16789
  }
@@ -18908,7 +18918,7 @@ async function _agentRepl(root, opts) {
18908
18918
  try {
18909
18919
  const hf = cp.spawnSync(process.execPath, [__filename, 'handoff', root, '--compact', '--no-drift-check', '--no-headline'], {
18910
18920
  encoding: 'utf8', timeout: 8000,
18911
- env: { ...process.env, LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1', LEERNESS_NO_LESSONS: '1' }
18921
+ env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1', LEERNESS_NO_LESSONS: '1' }
18912
18922
  });
18913
18923
  if (hf.status === 0 && hf.stdout) {
18914
18924
  const preview = hf.stdout.split('\n').slice(0, 4).map(l => l.replace(/^\s+/, '')).filter(Boolean).slice(0, 3).join(' · ');
@@ -19650,7 +19660,7 @@ async function agentCmd(root, taskArg) {
19650
19660
  log(`permission mode: ${perms.mode || 'basic'}`);
19651
19661
  // handoff 자동 회수 (compact 모드)
19652
19662
  try {
19653
- const hf = cp.spawnSync(process.execPath, [__filename, 'handoff', root, '--compact', '--no-drift-check'], { encoding: 'utf8', timeout: 10000, env: { ...process.env, LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1' } });
19663
+ const hf = cp.spawnSync(process.execPath, [__filename, 'handoff', root, '--compact', '--no-drift-check'], { encoding: 'utf8', timeout: 10000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1' } });
19654
19664
  if (hf.status === 0 && hf.stdout) {
19655
19665
  const preview = hf.stdout.split('\n').slice(0, 6).join('\n');
19656
19666
  log('\n[handoff context (preview)]\n' + preview);
@@ -19877,7 +19887,7 @@ async function incidentHandleCmd(root, id) {
19877
19887
  const keywords = String(err).toLowerCase().match(/[\w가-힣]{4,}/g) || [];
19878
19888
  if (keywords.length) {
19879
19889
  const r = cp.spawnSync(process.execPath, [__filename, 'lessons', '--path', root, '--query', keywords[0], '--limit', '3'],
19880
- { encoding: 'utf8', timeout: 8000, env: { ...process.env, LEERNESS_NO_PROMPT: '1' } });
19890
+ { encoding: 'utf8', timeout: 8000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1' } });
19881
19891
  if (r.status === 0 && /총 \d+건 발견/.test(r.stdout)) {
19882
19892
  const block = r.stdout.split('\n').slice(0, 12).join('\n');
19883
19893
  log(`\n📚 관련 lessons:\n${block}`);
@@ -20018,7 +20028,7 @@ function healthCmd(root) {
20018
20028
  // 1) drift level
20019
20029
  try {
20020
20030
  const r = cp.spawnSync(process.execPath, [__filename, 'drift', 'check', root, '--json'],
20021
- { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '0' } });
20031
+ { encoding: 'utf8', timeout: 15000, env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '0' } });
20022
20032
  const j = JSON.parse(r.stdout.trim());
20023
20033
  out.checks.drift = { level: j.level, score: j.score, firedCount: (j.fired || []).length };
20024
20034
  } catch { out.checks.drift = { error: 'drift check 실패' }; }
@@ -21143,7 +21153,7 @@ function reviewRequestCmd(root, request) {
21143
21153
  try {
21144
21154
  const r = cp.spawnSync(process.execPath, [__filename, 'brainstorm', text, '--path', root, '--json'], {
21145
21155
  encoding: 'utf8', timeout: 12000,
21146
- env: { ...process.env, LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' }
21156
+ env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_DRIFT_CHECK: '1' }
21147
21157
  });
21148
21158
  if (r.stdout) {
21149
21159
  const j = JSON.parse(r.stdout);
@@ -21531,7 +21541,8 @@ async function main() {
21531
21541
  }
21532
21542
  if (has('--help') || has('-h')) return help();
21533
21543
  // 1.9.38 (B): 사용 통계 카운터 — usage stats 명령 자체와 비차단 경로는 제외
21534
- if (cmd !== 'usage' && cmd !== 'init' && cmd !== 'migrate' && cmd !== '--version' && cmd !== '--help') {
21544
+ // 1.9.317 (UR-0051, 설치리뷰): 내부 auto-call(LEERNESS_INTERNAL=1) usage 집계 제외 텔레메트리 오염(거짓 skill 추천) 방지.
21545
+ if (process.env.LEERNESS_INTERNAL !== '1' && cmd !== 'usage' && cmd !== 'init' && cmd !== 'migrate' && cmd !== '--version' && cmd !== '--help') {
21535
21546
  try {
21536
21547
  const root = absRoot(arg('--path', args[1] && !args[1].startsWith('-') ? args[1] : process.cwd()));
21537
21548
  if (exists(path.join(root, '.harness'))) _bumpUsage(root, cmd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.315",
3
+ "version": "1.9.317",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -3728,5 +3728,55 @@ total++;
3728
3728
  if (!ok) failed++;
3729
3729
  }
3730
3730
 
3731
+ // 1.9.316 회귀 (drift 마커 버그): session-handoff 'Last generated' 중복 누적 방지 + drift 'session close 누락' 클리어
3732
+ // 근본: sessionClose 프론트매터 추출이 본문 '---' 를 오인 → 구 블록 보존 → 첫(구) Last generated 를 drift 가 읽어 영구 오발화
3733
+ total++;
3734
+ {
3735
+ let ok = false;
3736
+ try {
3737
+ const dd = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-driftmark-'));
3738
+ cp.spawnSync(process.execPath, [CLI, 'init', dd, '--yes', '--language', 'ko', '--skills', 'recommended'], { encoding: 'utf8', timeout: 30000 });
3739
+ const shp = path.join(dd, '.harness', 'session-handoff.md');
3740
+ // 손상 시뮬: 구 timestamp + 본문 '---' (프론트매터 없는 파일)
3741
+ fs.writeFileSync(shp, '# Session Handoff\n\nLast generated: 2026-01-01T00:00:00.000Z\n\n## Completed\n- old\n\n---\n## x\n');
3742
+ cp.spawnSync(process.execPath, [CLI, 'session', 'close', dd], { encoding: 'utf8', timeout: 30000 });
3743
+ const after = fs.readFileSync(shp, 'utf8');
3744
+ const genCount = (after.match(/Last generated:/g) || []).length; // 단일화
3745
+ const noOld = !after.includes('2026-01-01'); // 구 timestamp 제거
3746
+ const dr = cp.spawnSync(process.execPath, [CLI, 'drift', 'check', dd, '--json'], { encoding: 'utf8', timeout: 20000 });
3747
+ let driftOk = false; try { const j = JSON.parse(dr.stdout); const sig = (j.signals || []).find(x => x.label && x.label.includes('session close')); driftOk = sig && sig.ageDays <= sig.threshold; } catch {}
3748
+ ok = genCount === 1 && noOld && driftOk;
3749
+ fs.rmSync(dd, { recursive: true, force: true });
3750
+ } catch {}
3751
+ console.log(ok ? '✓ B(1.9.316) drift 마커: session-handoff Last generated 단일화 + session close 누락 신호 클리어' : '✗ drift 마커 실패');
3752
+ if (!ok) failed++;
3753
+ }
3754
+
3755
+ // 1.9.317 회귀 (UR-0051 설치리뷰): 텔레메트리 분리 — 내부 auto-call(LEERNESS_INTERNAL)이 usage 집계 오염 안 함
3756
+ total++;
3757
+ {
3758
+ let ok = false;
3759
+ try {
3760
+ const td = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-telem-'));
3761
+ cp.spawnSync(process.execPath, [CLI, 'init', td, '--yes', '--language', 'ko', '--skills', 'recommended'], { encoding: 'utf8', timeout: 30000 });
3762
+ const usagePath = path.join(td, '.harness', 'cache', 'usage-stats.json');
3763
+ const cmds = () => { try { return JSON.parse(fs.readFileSync(usagePath, 'utf8')).commands || {}; } catch { return {}; } };
3764
+ // task add → 내부 review-request auto-call 이 usage 오염 안 함 (task 만 집계, review-request 없음)
3765
+ cp.spawnSync(process.execPath, [CLI, 'task', 'add', '텔레메트리 테스트', '--path', td], { encoding: 'utf8', timeout: 20000 });
3766
+ const afterTask = cmds();
3767
+ const noPollution = afterTask.task >= 1 && !afterTask['review-request'];
3768
+ // LEERNESS_INTERNAL=1 호출은 미집계 (일반 호출은 집계)
3769
+ cp.spawnSync(process.execPath, [CLI, 'drift', 'check', '--path', td], { encoding: 'utf8', timeout: 20000 });
3770
+ const d1 = cmds().drift || 0;
3771
+ cp.spawnSync(process.execPath, [CLI, 'drift', 'check', '--path', td], { encoding: 'utf8', timeout: 20000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
3772
+ const d2 = cmds().drift || 0;
3773
+ const internalSkip = d1 >= 1 && d2 === d1; // 일반=증가, INTERNAL=증가없음
3774
+ ok = noPollution && internalSkip;
3775
+ fs.rmSync(td, { recursive: true, force: true });
3776
+ } catch {}
3777
+ console.log(ok ? '✓ B(1.9.317) 텔레메트리 분리: task add review-request 오염 없음 + LEERNESS_INTERNAL 미집계 (UR-0051)' : '✗ 텔레메트리 분리 실패');
3778
+ if (!ok) failed++;
3779
+ }
3780
+
3731
3781
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
3732
3782
  if (failed > 0) process.exit(1);