leerness 1.36.103 → 1.36.104

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.103 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.104 하네스를 사용합니다. 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.103는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.104는 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.103는 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.103 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.104 릴리스 태그 이력** (수백 라운드) · _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.103: 2026-08-05
238
+ Last synced by Leerness v1.36.104: 2026-08-05
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.103';
37
+ const VERSION = '1.36.104';
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') 시 호스트 프로세스 오염.
@@ -260,7 +260,7 @@ const _VALUE_FLAGS = new Set(['--language','--skills','--path','--status','--pro
260
260
  for (const _bf of _BRIEF_FIELDS) { if (_bf && _bf.flag) _VALUE_FLAGS.add('--' + _bf.flag); }
261
261
  // 부울 플래그(값 없음) — 오타 경고의 대조군. 여기 있거나 _VALUE_FLAGS 에 있으면 '실재하는 플래그' 로 본다.
262
262
  // 판정에만 쓰이고 파싱 동작에는 관여하지 않으므로, 넓게 잡아도 안전하다(과다 포함 = 경고 억제).
263
- const _BOOL_FLAGS = new Set(['--ai','--all','--all-apps','--all-presets','--allow-empty','--allow-unrelated','--apply','--audit','--auto','--auto-apply-delivered','--auto-cleanup-branches','--auto-fix','--auto-fix-bom','--auto-fix-encoding','--auto-main-push','--auto-recover','--auto-track','--banner','--bench','--build','--bundle-only','--claims','--close','--compact','--detect','--dry-run','--dry-run-npm','--embedding','--enforce','--fail-on-candidates','--fail-on-violation','--force','--gh-pages','--gh-release','--git-push','--global','--guide','--help','--html','--include-code','--interactive','--json','--last','--lenient','--major','--minimal','--minor','--no-animate','--no-auto-roadmap','--no-auto-update','--no-banner','--no-brainstorm-hits','--no-cache','--no-context-inject','--no-crawl','--no-drift-check','--no-enforce','--no-env','--no-env-detect','--no-feature-impact','--no-headline','--no-init-check','--no-interactive-select','--no-lazy-warn','--no-lessons','--no-longterm-recall','--no-mem-delta','--no-multiagent-hint','--no-next-actions','--no-npm','--no-official-skills','--no-on-every-change','--no-pre-wake','--no-preserve-link','--no-readme-sync','--no-record','--no-repl','--no-review','--no-save','--no-security-check','--no-security-summary','--no-setup-agents','--no-skill-suggest','--no-stale-check','--no-suggest','--no-synonyms','--no-team-reminders','--no-wakeup-miss','--no-workflow-guide','--no-write','--npm-publish','--offline','--on-every-change','--pack','--parent-migrate','--publish-npm','--pulse','--quiet','--record','--refresh','--remove','--repair','--repl','--require-evidence','--run-tests','--skeleton','--strict','--strict-claims','--strict-elements','--strict-exit','--suggest','--version','--yes',
263
+ const _BOOL_FLAGS = new Set(['--ai','--all','--all-apps','--baseline','--all-presets','--allow-empty','--allow-unrelated','--apply','--audit','--auto','--auto-apply-delivered','--auto-cleanup-branches','--auto-fix','--auto-fix-bom','--auto-fix-encoding','--auto-main-push','--auto-recover','--auto-track','--banner','--bench','--build','--bundle-only','--claims','--close','--compact','--detect','--dry-run','--dry-run-npm','--embedding','--enforce','--fail-on-candidates','--fail-on-violation','--force','--gh-pages','--gh-release','--git-push','--global','--guide','--help','--html','--include-code','--interactive','--json','--last','--lenient','--major','--minimal','--minor','--no-animate','--no-auto-roadmap','--no-auto-update','--no-banner','--no-brainstorm-hits','--no-cache','--no-context-inject','--no-crawl','--no-drift-check','--no-enforce','--no-env','--no-env-detect','--no-feature-impact','--no-headline','--no-init-check','--no-interactive-select','--no-lazy-warn','--no-lessons','--no-longterm-recall','--no-mem-delta','--no-multiagent-hint','--no-next-actions','--no-npm','--no-official-skills','--no-on-every-change','--no-pre-wake','--no-preserve-link','--no-readme-sync','--no-record','--no-repl','--no-review','--no-save','--no-security-check','--no-security-summary','--no-setup-agents','--no-skill-suggest','--no-stale-check','--no-suggest','--no-synonyms','--no-team-reminders','--no-wakeup-miss','--no-workflow-guide','--no-write','--npm-publish','--offline','--on-every-change','--pack','--parent-migrate','--publish-npm','--pulse','--quiet','--record','--refresh','--remove','--repair','--repl','--require-evidence','--run-tests','--skeleton','--strict','--strict-claims','--strict-elements','--strict-exit','--suggest','--version','--yes',
264
264
  // 소스에 리터럴로만 존재해 소비 형태를 기계 추출하지 못한 것들 — 실재하는 플래그이므로 오타로 오인하면 안 된다
265
265
  '--commands','--decision','--errors','--expand-all','--fix','--list','--notes','--print','--raw','--select-all','--skip-git-repo-check','--tests','--verbose','--yolo',
266
266
  // lib/*.js 의 부울 소비자 — bin/ 만 훑었다면 `agents route --confirm` 같은 실재 문법에 오탐 경고를 냈을 것이다
@@ -6263,7 +6263,8 @@ function _selfTestCases() {
6263
6263
  } catch { uiOk = false; } finally { try { fs.rmSync(tmp3, { recursive: true, force: true }); } catch {} }
6264
6264
  return clar && shape && noDbUrl && reqOk && guards && uiOk && _p0013LibraryOk() && _p0088CurrentStatePreserved()
6265
6265
  && _p0101SurfaceOk() && _p0101SkillIdOk() && _p0101PathStrictOk() && _p0102HonestyOk()
6266
- && _p0103FlagsOk() && _p0103JsonOk() && _p0103RootOk();
6266
+ && _p0103FlagsOk() && _p0103JsonOk() && _p0103RootOk() && _p0104UsageOk()
6267
+ && _p0014SecretBaselineOk() && _p0104ReminderOk();
6267
6268
  } },
6268
6269
  { name: '시크릿 스캐너 F-06 (1.36.56, 외부감사): 무명 확장자 소형 텍스트 스캔(이진 NUL 제외) + 같은 토큰 중복 보고 dedupe — 행위검사', run: () => {
6269
6270
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sc56_'));
@@ -7891,6 +7892,149 @@ function _p0103RootOk() {
7891
7892
  return wroteTarget && bareIsCwd && pageBare && pageWithPath && libTarget && typoRejected && typoTech;
7892
7893
  } catch { return false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
7893
7894
  }
7895
+ // 1.36.104: handoff 가 사람이 쓴 .harness/agent-reminders.md 를 통째로 지웠다(exit 0 · 안내 없음 · 백업 없음).
7896
+ // 대조군이 필수다 — 자동 생성분까지 남기면 그 파일이 영구 잔존해 매 세션 거짓 경보가 된다.
7897
+ function _p0104ReminderOk() {
7898
+ const os2 = require('os');
7899
+ const cp2 = require('child_process');
7900
+ const arena = fs.mkdtempSync(path.join(os2.tmpdir(), 'leerness-p104r-'));
7901
+ try {
7902
+ const env2 = Object.assign({}, process.env, { LEERNESS_OFFLINE: '1', LEERNESS_NO_AUTO_ROADMAP: '1' });
7903
+ const mk = (body) => {
7904
+ const d = path.join(arena, 'p' + Math.abs(body.length));
7905
+ fs.mkdirSync(d, { recursive: true });
7906
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
7907
+ cp2.spawnSync(process.execPath, [__filename, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 180000, env: env2 });
7908
+ writeUtf8(path.join(d, '.harness', 'agent-reminders.md'), body);
7909
+ cp2.spawnSync(process.execPath, [__filename, 'handoff', '.'], { cwd: d, encoding: 'utf8', timeout: 300000, env: env2 });
7910
+ return path.join(d, '.harness', 'agent-reminders.md');
7911
+ };
7912
+ // ① 사람이 쓴 파일은 살아남고 내용도 그대로
7913
+ const human = mk('# 내 리마인더\nHUMAN-KEEPME 사용자가 직접 쓴 줄\n');
7914
+ const kept = exists(human) && read(human).includes('HUMAN-KEEPME');
7915
+ // ② 대조군 — 우리가 쓴 그대로(표식+본문 해시 일치)면 여전히 자동 청소된다
7916
+ const body = '# 🔔 자동 reminder\ndrift critical 감지\n다음 라운드 정리 필요\n';
7917
+ const auto = mk(_autoReminderText(body));
7918
+ const cleaned = !exists(auto);
7919
+ // ③ (codex 검수 HIGH#3) 문서에서 표식만 복붙한 사용자 파일 — 해시가 없으므로 보존
7920
+ const pasted = mk('<!-- leerness:managed:auto -->\nUSER-DO-NOT-DELETE: production migration note\n');
7921
+ const pastedKept = exists(pasted) && read(pasted).includes('USER-DO-NOT-DELETE');
7922
+ // ④ (codex 검수 HIGH#3) 자동 생성분에 사용자가 덧붙인 경우 — 해시가 어긋나므로 보존
7923
+ const appended = mk(_autoReminderText(body) + 'USER-APPENDED 메모\n');
7924
+ const appendKept = exists(appended) && read(appended).includes('USER-APPENDED');
7925
+ // ⑤ 표식 검사 판별 케이스 — 표식으로 시작하지 않지만 첫 줄에 우연히 `sha=<16hex>` 가 있고 그 값이
7926
+ // 본문 해시와 일치하는 사용자 파일. 해시 검사만으로는 지워지고, 표식 검사가 있어야 보존된다.
7927
+ const body5 = 'USER-CONTENT 내 메모\n';
7928
+ const trap = mk('<!-- note sha=' + _reminderBodyHash(body5) + ' -->\n' + body5);
7929
+ const trapKept = exists(trap) && read(trap).includes('USER-CONTENT');
7930
+ return kept && cleaned && pastedKept && appendKept && trapKept;
7931
+ } catch { return false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
7932
+ }
7933
+ // 1.36.104 (P-0014): 시크릿 인정 기제 — "조용해졌다" 와 "눈이 멀었다" 를 대조군으로 가른다.
7934
+ // 동기: 자기 저장소가 63건을 보고했고 전부 의도적 가짜였다 → 매 세션 같은 숫자가 떠서 진짜 유출이 섞여도 묻힌다.
7935
+ // 그래서 가드는 '조용해짐' 만 보지 않는다 — 신규 유출·값 변경·손상 베이스라인 세 방향을 함께 건다.
7936
+ function _p0014SecretBaselineOk() {
7937
+ const os2 = require('os');
7938
+ const cp2 = require('child_process');
7939
+ const d = fs.mkdtempSync(path.join(os2.tmpdir(), 'leerness-p014-'));
7940
+ try {
7941
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
7942
+ const env2 = Object.assign({}, process.env, { LEERNESS_OFFLINE: '1', LEERNESS_NO_AUTO_ROADMAP: '1' });
7943
+ const R = (a) => cp2.spawnSync(process.execPath, [__filename, ...a], { cwd: d, encoding: 'utf8', timeout: 180000, env: env2 });
7944
+ R(['init', d, '--yes']);
7945
+ const J = (a) => { try { return JSON.parse(((R(a).stdout) || '').trim()); } catch { return null; } };
7946
+ const FIX = path.join(d, 'fixtures.test.js'), OTH = path.join(d, 'other.test.js');
7947
+ fs.writeFileSync(FIX, 'const A="sk-proj-FakeFixtureKey1234567890abcdef";\n');
7948
+ fs.writeFileSync(OTH, 'const B="sk-proj-SecondFixtureKey098765432100";\n');
7949
+ const before = J(['scan', 'secrets', '--json']);
7950
+ if (!before || before.unacknowledgedCount !== 2 || before.ok !== false) return false;
7951
+ // 인정 기록 — 원문 시크릿은 절대 저장하지 않는다
7952
+ const rec = J(['scan', 'secrets', '--baseline', '--json']);
7953
+ const stored = read(path.join(d, '.harness', 'secret-baseline.json'));
7954
+ if (!rec || rec.recorded !== 2 || stored.includes('FakeFixtureKey')) return false;
7955
+ // 조용해지되 사라지지 않는다
7956
+ const after = J(['scan', 'secrets', '--json']);
7957
+ const quiet = !!after && after.unacknowledgedCount === 0 && after.ok === true && after.acknowledgedCount === 2 && after.count === 2;
7958
+ // health 도 같은 술어를 쓰는가 — 표면마다 따로 세면 헤드라인과 health 가 어긋난다(변이로 드러난 공백).
7959
+ // 반드시 '전부 인정된 시점' 에서 재야 한다 — 아래 손상-베이스라인 시험 뒤에 재면 항상 2가 나온다.
7960
+ let healthOk = false;
7961
+ try {
7962
+ const hj = JSON.parse(((R(['health', '--json']).stdout) || '').trim());
7963
+ healthOk = !!hj && !!hj.checks && !!hj.checks.security && hj.checks.security.committedSecrets === 0;
7964
+ } catch { healthOk = false; }
7965
+ // 대조군 ① 신규 유출은 여전히 잡는다
7966
+ fs.writeFileSync(path.join(d, 'leaked.js'), 'const K="sk-proj-BrandNewRealLeak9876543210zyxw";\n');
7967
+ const leak = J(['scan', 'secrets', '--json']);
7968
+ const catches = !!leak && leak.unacknowledgedCount === 1 && leak.ok === false;
7969
+ // 대조군 ② 인정된 자리의 값이 바뀌면 자동 재경고 · 안 바뀐 것은 유지 · 사라진 지문은 stale
7970
+ fs.unlinkSync(path.join(d, 'leaked.js'));
7971
+ fs.writeFileSync(FIX, 'const A="sk-proj-CHANGEDToRealKey1234567890ab";\n');
7972
+ const chg = J(['scan', 'secrets', '--json']);
7973
+ const rearms = !!chg && chg.unacknowledgedCount === 1 && chg.acknowledgedCount === 1
7974
+ && Array.isArray(chg.staleBaseline) && chg.staleBaseline.length === 1;
7975
+ // 대조군 ③ 손상된 베이스라인은 fail-safe (인정 0)
7976
+ writeUtf8(path.join(d, '.harness', 'secret-baseline.json'), '{ broken json');
7977
+ const broken = J(['scan', 'secrets', '--json']);
7978
+ const failSafe = !!broken && broken.acknowledgedCount === 0 && broken.unacknowledgedCount === 2;
7979
+ // 대조군 ④ (codex 검수 HIGH#1) 앞 32자가 같은 다른 키가 자동 인정되면 안 된다.
7980
+ // 표시용 snippet 은 32자로 잘리므로, 그걸 해싱하면 서로 다른 두 키가 같은 지문을 갖는다.
7981
+ const d2 = path.join(d, 'trunc'); fs.mkdirSync(d2, { recursive: true });
7982
+ fs.writeFileSync(path.join(d2, 'package.json'), '{"name":"t","version":"0.1.0"}');
7983
+ cp2.spawnSync(process.execPath, [__filename, 'init', d2, '--yes'], { cwd: d2, encoding: 'utf8', timeout: 180000, env: env2 });
7984
+ const R2 = (a) => cp2.spawnSync(process.execPath, [__filename, ...a], { cwd: d2, encoding: 'utf8', timeout: 180000, env: env2 });
7985
+ const J2 = (a) => { try { return JSON.parse(((R2(a).stdout) || '').trim()); } catch { return null; } };
7986
+ const K1 = 'sk-proj-a1B2c3D4e5F6g7H8i9J0kLmNQ2w3E4r5T6y7U8i9';
7987
+ const K2 = 'sk-proj-a1B2c3D4e5F6g7H8i9J0kLmNM9n8B7v6C5x4Z3q2'; // 앞 32자 동일
7988
+ if (K1.slice(0, 32) !== K2.slice(0, 32)) return false; // 픽스처가 조건을 만족하는지 먼저 단언
7989
+ fs.writeFileSync(path.join(d2, 'fixture.js'), 'const A="' + K1 + '";\n');
7990
+ R2(['scan', 'secrets', '--baseline']);
7991
+ fs.writeFileSync(path.join(d2, 'fixture.js'), 'const A="' + K1 + '";\nconst B="' + K2 + '";\n');
7992
+ const tr = J2(['scan', 'secrets', '--json']);
7993
+ const noCollision = !!tr && tr.count === 2 && tr.unacknowledgedCount === 1 && tr.ok === false;
7994
+ // 대조군 ⑤ (codex 검수 MEDIUM#4) 파일 경로 + --baseline 은 ENOTDIR 로 죽지 않고 구조화 거절
7995
+ const fileBase = J2(['scan', 'secrets', path.join(d2, 'fixture.js'), '--baseline', '--json']);
7996
+ const rejectsFile = !!fileBase && fileBase.code === 'baseline_needs_project';
7997
+ return quiet && healthOk && catches && rearms && failSafe && noCollision && rejectsFile;
7998
+ } catch { return false; } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
7999
+ }
8000
+ // 1.36.104: MCP 사용량 귀속 — 쓰는 쪽과 읽는 쪽이 같은 파일의 다른 키를 보고 있었다.
8001
+ // _bumpMcpUsage 는 stats.mcp.tools 에 기록하는데, usage 표시는 commands 가 비면 이른 return 으로
8002
+ // 그 아래 MCP 섹션(1.9.70)에 도달하지 못했다 → MCP 로만 일하는 에이전트의 프로젝트가 "사용 기록 없음".
8003
+ // 동시에 서버 프로세스 자신이 cwd 에 `mcp: N` 을 쌓아, 사용자가 쓴 적 없는 명령이 남의 통계에 떴다.
8004
+ function _p0104UsageOk() {
8005
+ const os2 = require('os');
8006
+ const cp2 = require('child_process');
8007
+ const arena = fs.mkdtempSync(path.join(os2.tmpdir(), 'leerness-p104-'));
8008
+ try {
8009
+ const cwdP = path.join(arena, 'CWDPROJ'), tgt = path.join(arena, 'TARGETPROJ');
8010
+ const env2 = Object.assign({}, process.env, { LEERNESS_OFFLINE: '1', LEERNESS_NO_AUTO_ROADMAP: '1' });
8011
+ for (const d of [cwdP, tgt]) {
8012
+ fs.mkdirSync(d, { recursive: true });
8013
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"x","version":"0.1.0"}');
8014
+ cp2.spawnSync(process.execPath, [__filename, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 180000, env: env2 });
8015
+ }
8016
+ const mcp = (name) => cp2.spawnSync(process.execPath, [__filename, 'mcp', 'serve'], {
8017
+ cwd: cwdP, encoding: 'utf8', timeout: 180000, env: env2, maxBuffer: 32 * 1024 * 1024,
8018
+ input: [
8019
+ { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'g', version: '1' } } },
8020
+ { jsonrpc: '2.0', method: 'notifications/initialized' },
8021
+ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name, arguments: { path: tgt } } },
8022
+ ].map(r => JSON.stringify(r)).join('\n') + '\n'
8023
+ });
8024
+ mcp('leerness_about'); mcp('leerness_pulse');
8025
+ const U = (d) => ((cp2.spawnSync(process.execPath, [__filename, 'usage', '--path', d], { cwd: cwdP, encoding: 'utf8', timeout: 120000, env: env2 }).stdout) || '');
8026
+ const uT = U(tgt), uC = U(cwdP);
8027
+ // ① 실제로 일한 프로젝트가 그 사실을 말한다
8028
+ const shows = /leerness_about/.test(uT) && /leerness_pulse/.test(uT) && !/\(사용 기록 없음\)/.test(uT);
8029
+ // ② 서버가 우연히 서 있던 프로젝트에 남의 활동이 기록되지 않는다
8030
+ const cwdClean = !/\|\s*mcp\s*\|/.test(uC);
8031
+ // ③ 대조군 — 평범한 CLI 경로는 그대로 명령 표에 남는다(표시 조건을 바꾸며 죽이지 않았는가)
8032
+ cp2.spawnSync(process.execPath, [__filename, 'audit', '--path', tgt], { cwd: cwdP, encoding: 'utf8', timeout: 180000, env: env2 });
8033
+ const uT2 = U(tgt);
8034
+ const cliStillCounted = /\|\s*audit\s*\|/.test(uT2) && /leerness_about/.test(uT2);
8035
+ return shows && cwdClean && cliStillCounted;
8036
+ } catch { return false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
8037
+ }
7894
8038
  // 1.36.103: --json 계약 회귀 가드 — "실패해도 stdout 은 항상 파싱 가능한 JSON 하나".
7895
8039
  // 실측 위반 5건이 서로 다른 이유로 났다: 경고가 오류보다 먼저 나와 stdout 오염(reuse-map/retro/insights),
7896
8040
  // 실패 분기에 JSON 갈래가 없음(release cleanup 비-git), 그리고 stdout 을 삼키는 구간에서 표식만 서서
@@ -12202,34 +12346,127 @@ function _collectSecretFindings(root) {
12202
12346
  const _valKey = `${_valOff}:${String(val || '').length}`;
12203
12347
  if (_seenAt.has(_valKey)) { if (re.lastIndex === m.index) re.lastIndex++; continue; }
12204
12348
  _seenAt.add(_valKey);
12205
- findings.push({ file: fileRel, line, name, snippet: m[0].slice(0, 32), gitignored });
12349
+ // 1.36.104 (codex 검수 HIGH#1): 지문은 **전체 매치값**으로 만든다. 표시용 snippet 32자로 잘리는데
12350
+ // 그걸 해싱하면 앞 32자가 같은 다른 키가 같은 지문이 돼 자동으로 인정된다(실측 재현: 인정 2 / 미인정 0).
12351
+ // fp 는 여기서 계산해 finding 에 실어 보낸다 — 전체 값을 finding 에 담으면 --json 이 시크릿을 더 노출한다.
12352
+ findings.push({ file: fileRel, line, name, snippet: m[0].slice(0, 32), gitignored, fp: _fpOf(fileRel, name, m[0]) });
12206
12353
  // 16th 버그헌트 F1: break 제거 — 같은 패턴이 한 파일에 여러 번(예: secret: + api_key: 둘 다 'Hardcoded password') 나오면 모두 보고(보안 FN 차단). zero-width 매치는 lastIndex 전진으로 무한루프 방지.
12207
12354
  if (re.lastIndex === m.index) re.lastIndex++;
12208
12355
  }
12209
12356
  }
12210
12357
  }
12211
12358
  // gitignored(.env 등 안전 보관)는 info, 커밋 대상 발견만 실패.
12212
- return { findings, committed: findings.filter(f => !f.gitignored), ignored: findings.filter(f => f.gitignored) };
12359
+ const committed = findings.filter(f => !f.gitignored);
12360
+ const ignored = findings.filter(f => f.gitignored);
12361
+ // 1.36.104 (P-0014): 알려진 가짜(테스트 픽스처·보안 문서)를 '인정' 으로 분류한다.
12362
+ // 실측 동기 — leerness 자기 저장소가 63건을 보고했고 **전부** 의도적 가짜였다
12363
+ // (scripts/e2e.js 47 · e2e-core.js 5 · pure-utils.js 4 · 헌트 보고서 7). 매 세션 헤드라인이 영구 오경보라
12364
+ // 진짜 유출이 섞여도 묻힌다. 숨기지 않고 **분류만** 한다 — 인정 항목도 항상 열람 가능하고,
12365
+ // 지문에 내용이 들어가므로 값이 바뀌면 자동으로 다시 미인정이 된다.
12366
+ const base = _loadSecretBaseline(root);
12367
+ const acknowledged = [], unacknowledged = [];
12368
+ for (const f of committed) (base.has(_secretFp(f)) ? acknowledged : unacknowledged).push(f);
12369
+ // 베이스라인 부패 방지 — 기록만 남고 대응 발견이 사라진 항목은 알린다(조용히 불어나지 않게).
12370
+ const live = new Set(committed.map(_secretFp));
12371
+ const stale = [...base].filter(fp => !live.has(fp));
12372
+ return { findings, committed, ignored, acknowledged, unacknowledged, staleBaseline: stale };
12373
+ }
12374
+ // 1.36.104 (P-0014): 지문은 파일+패턴명+스니펫. **줄 번호는 뺀다** — 서식 변경으로 재알림하면 아무도 안 쓴다.
12375
+ // 반대로 내용은 넣는다 — 같은 자리의 값이 진짜 키로 바뀌면 인정이 자동 해제돼야 한다.
12376
+ // 1.36.104: handoff 가 회복 판정 시 .harness/agent-reminders.md 를 **무조건** unlink 했다.
12377
+ // 그 파일은 drift critical 일 때 도구가 만드는 자동 산출물이지만, 본문 스스로 "사용자가 이 파일을 보고
12378
+ // 리마인더 전달 가능" 이라 안내한다 — 그리고 CLAUDE.md 는 보호 파일 삭제를 금지한다.
12379
+ // 실측: 사용자가 쓴 줄을 넣고 `leerness handoff .` 한 번 → 파일 통째 삭제, exit 0, 안내 없음, 백업 없음.
12380
+ // (자기 저장소에서 반복해서 사라졌는데 외부 검수 탓으로 오귀속하고 있었다. 명령 단독 실행으로 귀속하니 handoff 였다.)
12381
+ // 1.36.98 의 current-state.md 처리와 같은 규약을 쓴다 — **표식이 있는 자동 생성분만** 지운다.
12382
+ // codex 검수 HIGH#3: 표식 유무만 보면 (a) 사용자가 문서에서 표식을 복붙했거나 (b) 자동 파일에 메모를 덧붙인
12383
+ // 경우에 여전히 내용을 지운다. 그래서 표식 줄에 **본문 해시**를 함께 적고, 청소는 '우리가 쓴 그대로일 때' 만 한다.
12384
+ // 한 글자라도 사람이 고치면 해시가 달라져 보존된다 — 판정이 정확하고 추측이 없다.
12385
+ const _AUTO_REMINDER_MARK = '<!-- leerness:managed:auto';
12386
+ function _reminderBodyHash(body) {
12387
+ return require('crypto').createHash('sha256').update(String(body)).digest('hex').slice(0, 16);
12388
+ }
12389
+ function _autoReminderText(body) { return `${_AUTO_REMINDER_MARK} sha=${_reminderBodyHash(body)} -->\n${body}`; }
12390
+ function _cleanAutoReminder(root) {
12391
+ try {
12392
+ const p = path.join(root, '.harness', 'agent-reminders.md');
12393
+ if (!exists(p)) return;
12394
+ const txt = read(p);
12395
+ const nl = txt.indexOf('\n');
12396
+ if (nl < 0) return;
12397
+ const head = txt.slice(0, nl);
12398
+ if (!head.startsWith(_AUTO_REMINDER_MARK)) return; // 사람이 만든 파일 — 건드리지 않는다
12399
+ const m = /sha=([0-9a-f]{16})/.exec(head);
12400
+ if (!m) return; // 해시 없는 옛 표식 — 손댄 흔적을 알 수 없으니 보존
12401
+ if (_reminderBodyHash(txt.slice(nl + 1)) !== m[1]) return; // 사람이 고쳤다 — 보존
12402
+ fs.unlinkSync(p);
12403
+ } catch { /* 청소 실패는 handoff 실패가 아니다 */ }
12404
+ }
12405
+ const _FP_SEP = String.fromCharCode(31); // 출하 소스에 생 NUL 을 두지 않는다(1.36.81 메타가드) — 이스케이프도 두지 않고 코드로 만든다
12406
+ function _fpOf(file, name, fullValue) {
12407
+ return require('crypto').createHash('sha256')
12408
+ .update(String(file) + _FP_SEP + String(name) + _FP_SEP + String(fullValue)).digest('hex');
12409
+ }
12410
+ // 구버전 finding(fp 없음)은 잘린 snippet 으로 폴백하지 않는다 — 폴백하면 위 결함이 그대로 살아난다.
12411
+ function _secretFp(f) { return f && f.fp ? f.fp : null; }
12412
+ function _secretBaselinePath(root) { return path.join(absRoot(root), '.harness', 'secret-baseline.json'); }
12413
+ function _loadSecretBaseline(root) {
12414
+ try {
12415
+ const p = _secretBaselinePath(root);
12416
+ if (!exists(p)) return new Set();
12417
+ const j = JSON.parse(read(p));
12418
+ return new Set((Array.isArray(j.entries) ? j.entries : []).map(e => e && e.fp).filter(Boolean));
12419
+ } catch { return new Set(); } // 손상된 베이스라인은 '인정 없음' 으로 — 오경보는 나도 누락은 안 난다
12213
12420
  }
12214
12421
 
12215
12422
  function scanSecrets(root, opts = {}) {
12216
12423
  root = absRoot(root);
12217
12424
  // 1.10.3 (13th 버그헌트 P2, UR-0174): 없는 경로에서 --json 비-JSON(ENOENT) 차단 → 구조화 에러. 단 파일 경로는 지원(UR-0072: _collectSecretFindings 가 파일 root 단일 스캔) → 존재성만 검사(디렉토리 강제 X).
12218
12425
  if (!exists(root)) { failJson(has('--json') || opts.json, 'path_not_found', `경로 없음: ${root}`); return; }
12219
- const { committed, ignored } = _collectSecretFindings(root);
12426
+ const { committed, ignored, acknowledged, unacknowledged, staleBaseline } = _collectSecretFindings(root);
12427
+ const jsonMode = has('--json') || opts.json;
12428
+ // 1.36.104 (P-0014): --baseline 은 현재 커밋 대상 발견을 '인정' 으로 기록한다.
12429
+ // 위험 수용 행위이므로 무엇을 인정했는지 파일·패턴별로 반드시 출력한다(조용히 잠재우지 않는다).
12430
+ if (has('--baseline')) {
12431
+ // codex 검수 MEDIUM#4: `scan secrets <파일> --baseline` 은 <파일>/.harness 를 만들려다 ENOTDIR 로 죽었다.
12432
+ // 베이스라인은 프로젝트 단위 결정이므로, 파일 스캔과의 조합은 스캔 전에 분명히 거절한다.
12433
+ let _isFile = false; try { _isFile = fs.statSync(root).isFile(); } catch {}
12434
+ if (_isFile) { failJson(jsonMode, 'baseline_needs_project', `--baseline 은 프로젝트 디렉토리에서만 씁니다 (받은 값: 파일 ${root}) — 상위 프로젝트 경로로 실행하세요`); return; }
12435
+ const entries = committed.map(f => ({ fp: _secretFp(f), file: f.file, name: f.name, line: f.line })).filter(e => e.fp);
12436
+ const p = _secretBaselinePath(root);
12437
+ mkdirp(path.dirname(p));
12438
+ writeUtf8(p, JSON.stringify({ version: VERSION, recordedAt: new Date().toISOString(), note: '알려진 가짜(테스트 픽스처/보안 문서) 인정 기록 — 원문 시크릿은 저장하지 않는다(파일·패턴명·해시만)', entries }, null, 2) + '\n');
12439
+ if (jsonMode) { log(JSON.stringify({ ok: true, baseline: rel(root, p), recorded: entries.length, byFile: entries.reduce((m, e) => (m[e.file] = (m[e.file] || 0) + 1, m), {}) }, null, 2)); return; }
12440
+ ok(`시크릿 베이스라인 기록: ${entries.length}건 → ${rel(root, p)}`);
12441
+ const byFile = entries.reduce((m, e) => (m[e.file] = (m[e.file] || 0) + 1, m), {});
12442
+ Object.entries(byFile).sort((a, b) => b[1] - a[1]).forEach(([f, n]) => log(` ${String(n).padStart(3)} ${f}`));
12443
+ log(' ⓘ 인정은 "가짜라고 사람이 판단했다" 는 뜻입니다 — 값이 바뀌면 자동으로 다시 경고합니다.');
12444
+ return;
12445
+ }
12220
12446
  // 1.9.415 (9th 외부평가 Opus/Codex): --json 일관성 — 기존엔 --json 무시하고 사람용 텍스트만 출력하던 FN.
12221
- if (has('--json') || opts.json) {
12222
- log(JSON.stringify({ version: VERSION, root, ok: committed.length === 0, count: committed.length, committed, ignored }, null, 2));
12223
- if (committed.length) process.exitCode = 1;
12447
+ if (jsonMode) {
12448
+ log(JSON.stringify({
12449
+ version: VERSION, root, ok: unacknowledged.length === 0,
12450
+ count: committed.length, unacknowledgedCount: unacknowledged.length, acknowledgedCount: acknowledged.length,
12451
+ committed, unacknowledged, acknowledged, ignored, staleBaseline
12452
+ }, null, 2));
12453
+ if (unacknowledged.length) process.exitCode = 1;
12224
12454
  return;
12225
12455
  }
12226
- if (committed.length) {
12227
- fail(`secret patterns found: ${committed.length}`);
12228
- committed.forEach(f => log(` ${f.file}:${f.line} ${f.name} ${f.snippet}…`));
12456
+ if (unacknowledged.length) {
12457
+ fail(`secret patterns found: ${unacknowledged.length}`);
12458
+ unacknowledged.forEach(f => log(` ${f.file}:${f.line} ${f.name} ${f.snippet}…`));
12229
12459
  process.exitCode = 1;
12230
12460
  } else {
12231
12461
  ok('no obvious secret patterns (커밋 대상)');
12232
12462
  }
12463
+ if (acknowledged.length) {
12464
+ log(` ⓘ 인정됨 ${acknowledged.length}건 (베이스라인 — 값이 바뀌면 자동 재경고): ${[...new Set(acknowledged.map(f => f.file))].slice(0, 6).join(', ')}`);
12465
+ if (has('--all')) acknowledged.forEach(f => log(` ${f.file}:${f.line} ${f.name}`));
12466
+ }
12467
+ if (staleBaseline.length) {
12468
+ log(` ⚠ 베이스라인 낡음 ${staleBaseline.length}건 — 대응 발견이 사라졌습니다. 정리: leerness scan secrets --baseline`);
12469
+ }
12233
12470
  if (ignored.length) {
12234
12471
  log(` ⓘ gitignored ${ignored.length}건 (커밋 제외 — 설계상 안전 보관, 비실패): ${[...new Set(ignored.map(f => f.file))].join(', ')}`);
12235
12472
  }
@@ -12913,8 +13150,10 @@ function handoff(root) {
12913
13150
  // 이제 커밋 대상 시크릿이 있으면 '🚨 시크릿 N건', 없을 때만 '보안 OK'(.env 미무시는 별도 경고).
12914
13151
  try {
12915
13152
  const sec = _collectSecretFindings(root);
12916
- if (sec.committed.length) {
12917
- parts.push(t(`🚨 시크릿 ${sec.committed.length}건`, `🚨 ${sec.committed.length} secret(s)`));
13153
+ // 1.36.104 (P-0014): 헤드라인은 **미인정**만 센다. 인정된 픽스처까지 세면 매 세션 같은 숫자가 떠서
13154
+ // 진짜 유출이 하나 섞여도 구분되지 않는다(실측: 자기 저장소 63건 전부 가짜였다).
13155
+ if (sec.unacknowledged.length) {
13156
+ parts.push(t(`🚨 시크릿 ${sec.unacknowledged.length}건`, `🚨 ${sec.unacknowledged.length} secret(s)`));
12918
13157
  } else {
12919
13158
  const envPath = path.join(root, '.env');
12920
13159
  if (exists(envPath)) {
@@ -14014,7 +14253,8 @@ function handoff(root) {
14014
14253
  // 0) 1.30.1 (14th 외부리뷰 F2): 커밋된 plaintext 시크릿을 보안 요약에 노출 — headline '🚨 시크릿 N건' 과 일관.
14015
14254
  // envExists 무관(소스에 커밋된 시크릿은 .env 없어도 위험). gitignored 는 _collectSecretFindings 가 committed 에서 제외.
14016
14255
  let committedSecrets = [];
14017
- try { committedSecrets = _collectSecretFindings(root).committed || []; } catch {}
14256
+ // 1.36.104 (P-0014): 표면(headline·health·audit) 같은 술어를 쓴다 미인정만 '문제' 로 센다.
14257
+ try { committedSecrets = _collectSecretFindings(root).unacknowledged || []; } catch {}
14018
14258
  if (committedSecrets.length) issues.push(t(`커밋된 시크릿 ${committedSecrets.length}건 (소스 노출)`, `${committedSecrets.length} committed secret(s) (exposed in source)`));
14019
14259
  if (envExists) {
14020
14260
  // 1) env diff
@@ -14369,22 +14609,16 @@ function handoffCmd(root) {
14369
14609
  if (sevStale) {
14370
14610
  try {
14371
14611
  const remPath = path.join(absR0, '.harness', 'agent-reminders.md');
14372
- const body = `<!-- leerness:managed:auto -->\n# 🔔 메인 에이전트용 자동 reminder\n\n_생성: ${new Date().toISOString()}_\n\n## drift critical 감지\n현재 워크스페이스의 메타파일이 매우 stale합니다. 이번 라운드 작업 끝에 반드시 다음 명령을 호출하세요:\n\n\`\`\`bash\nleerness session close .\n\`\`\`\n\n또는 상세 점검:\n\`\`\`bash\nleerness drift check .\n\`\`\`\n\nstale 신호:\n${shAge !== null ? `- session-handoff.md: ${shAge.toFixed(1)}일 stale\n` : ''}${ptAge !== null ? `- progress-tracker: ${ptAge.toFixed(1)}일 stale\n` : ''}\n\n_이 파일은 leerness 1.9.38+가 자동 갱신합니다. session close 후 자동 삭제.\n_사용자가 이 파일을 보고 메인 에이전트에 reminder 전달 가능._\n`;
14373
- writeUtf8(remPath, body);
14612
+ const body = `# 🔔 메인 에이전트용 자동 reminder\n\n_생성: ${new Date().toISOString()}_\n\n## drift critical 감지\n현재 워크스페이스의 메타파일이 매우 stale합니다. 이번 라운드 작업 끝에 반드시 다음 명령을 호출하세요:\n\n\`\`\`bash\nleerness session close .\n\`\`\`\n\n또는 상세 점검:\n\`\`\`bash\nleerness drift check .\n\`\`\`\n\nstale 신호:\n${shAge !== null ? `- session-handoff.md: ${shAge.toFixed(1)}일 stale\n` : ''}${ptAge !== null ? `- progress-tracker: ${ptAge.toFixed(1)}일 stale\n` : ''}\n\n_이 파일은 leerness 1.9.38+가 자동 갱신합니다. session close 후 자동 삭제.\n_사용자가 이 파일을 보고 메인 에이전트에 reminder 전달 가능._\n`;
14613
+ writeUtf8(remPath, _autoReminderText(body)); // 1.36.104: 표식 줄에 본문 해시 — 사람이 고치면 청소 대상에서 빠진다
14374
14614
  } catch {}
14375
14615
  } else {
14376
- // attention 등급으로 회복했으면 reminder 파일 삭제
14377
- try {
14378
- const remPath = path.join(absR0, '.harness', 'agent-reminders.md');
14379
- if (exists(remPath)) fs.unlinkSync(remPath);
14380
- } catch {}
14616
+ // attention 등급으로 회복했으면 reminder 파일 삭제 (자동 생성분에 한해)
14617
+ _cleanAutoReminder(absR0);
14381
14618
  }
14382
14619
  } else {
14383
- // healthy → reminder 파일 자동 청소
14384
- try {
14385
- const remPath = path.join(absR0, '.harness', 'agent-reminders.md');
14386
- if (exists(remPath)) fs.unlinkSync(remPath);
14387
- } catch {}
14620
+ // healthy → reminder 파일 자동 청소 (자동 생성분에 한해)
14621
+ _cleanAutoReminder(absR0);
14388
14622
  }
14389
14623
  } catch {}
14390
14624
  }
@@ -24350,16 +24584,26 @@ function usageStatsCmd(root) {
24350
24584
  log(`since: ${stats.since || '(unknown)'} · last: ${stats.lastAt || '(none)'}`);
24351
24585
  log('');
24352
24586
  const entries = Object.entries(stats.commands || {}).sort((a, b) => b[1] - a[1]);
24353
- if (!entries.length) {
24587
+ // 1.36.104: 이 자리의 이른 return 이 아래 MCP 섹션(1.9.70) **도달 불가**로 만들고 있었다.
24588
+ // MCP 로만 일하는 에이전트는 commands 가 늘 비어 있다(도구 호출은 stats.mcp.tools 에 기록된다) →
24589
+ // 실제로 89개 도구를 돌린 프로젝트가 "사용 기록 없음" 이라고 보고했다(실측: mcp.tools 3건 기록됨에도).
24590
+ // 쓰는 쪽과 읽는 쪽이 같은 파일의 다른 키를 보고 있었다 — 기록이 있으면 무엇이든 보여준다.
24591
+ const _mcpTools = (stats.mcp && stats.mcp.tools) || {};
24592
+ const _hasMcp = Object.keys(_mcpTools).length > 0;
24593
+ if (!entries.length && !_hasMcp) {
24354
24594
  log(' (사용 기록 없음)');
24355
24595
  return;
24356
24596
  }
24357
- log(`| 명령 | 호출 수 |`);
24358
- log(`|---|---:|`);
24359
- for (const [cmd, n] of entries.slice(0, 30)) log(`| ${cmd} | ${n} |`);
24360
- const total = entries.reduce((s, [, n]) => s + n, 0);
24361
- log('');
24362
- log(`총 ${total} 회 호출 · 종류 ${entries.length} 가지`);
24597
+ if (entries.length) {
24598
+ log(`| 명령 | 호출 수 |`);
24599
+ log(`|---|---:|`);
24600
+ for (const [cmd, n] of entries.slice(0, 30)) log(`| ${cmd} | ${n} |`);
24601
+ const total = entries.reduce((s, [, n]) => s + n, 0);
24602
+ log('');
24603
+ log(`총 ${total} 회 호출 · 종류 ${entries.length} 가지`);
24604
+ } else {
24605
+ log(' (CLI 명령 기록 없음 — 아래 MCP 호출 통계 참조)');
24606
+ }
24363
24607
  if (stats.drift) {
24364
24608
  log('');
24365
24609
  log(`drift 통계: critical 발견 ${stats.drift.criticalSeen || 0} · skip ${stats.drift.skipped || 0} · 자동 해소 ${stats.drift.autoResolved || 0}`);
@@ -25328,7 +25572,10 @@ async function main() {
25328
25572
  if (!args[0] && process.argv.slice(2).some(a => a.startsWith('-'))) { help(); return; }
25329
25573
  // 1.9.38 (B): 사용 통계 카운터 — usage stats 명령 자체와 비차단 경로는 제외
25330
25574
  // 1.9.317 (UR-0051, 설치리뷰): 내부 auto-call(LEERNESS_INTERNAL=1) 은 usage 집계 제외 — 텔레메트리 오염(거짓 skill 추천) 방지.
25331
- if (process.env.LEERNESS_INTERNAL !== '1' && cmd !== 'usage' && cmd !== 'init' && cmd !== 'migrate' && cmd !== '--version' && cmd !== '--help') {
25575
+ // 1.36.104: 'mcp' 제외한다. MCP 서버는 프로젝트 명령이 아니라 상주 프로세스라, 서버의 cwd 우연히
25576
+ // leerness 프로젝트면 거기에 `mcp: N` 이 쌓였다 — 사용자가 그 프로젝트에서 쓴 적 없는 '명령' 이 통계에 뜬다
25577
+ // (실측: 대상 프로젝트는 "사용 기록 없음", 무관한 cwd 는 "총 3회 호출"). 도구별 귀속은 _bumpMcpUsage 가 이미 한다.
25578
+ if (process.env.LEERNESS_INTERNAL !== '1' && cmd !== 'usage' && cmd !== 'init' && cmd !== 'migrate' && cmd !== 'mcp' && cmd !== '--version' && cmd !== '--help') {
25332
25579
  try {
25333
25580
  // 1.9.352 (UR-0069 외부리뷰): usage 루트 — --path 우선, 없으면 args[1] 이 .harness 보유 디렉토리일 때만 path(positional 보존), 아니면 cwd. (이전: args[1] 무조건 path 가정 → subcommand[decision add 등] root=cwd/add → .harness 못 찾아 미집계)
25334
25581
  const _pathArg = arg('--path', null);
package/lib/audit.js CHANGED
@@ -245,7 +245,9 @@ function audit(root, opts = {}, deps = {}) {
245
245
  // (audit 기반 CI 게이트가 노출 시크릿을 통과). gitignored 보관 시크릿은 _collectSecretFindings 가 committed 에서 제외(FP 0). 끄기: --no-secret-scan.
246
246
  if (!has('--no-secret-scan') && typeof _collectSecretFindings === 'function') {
247
247
  try {
248
- const { committed } = _collectSecretFindings(root);
248
+ // 1.36.104 (P-0014): 인정된 픽스처는 실패로 세지 않는다 — headline/health/session-close 와 같은 술어.
249
+ const _sec = _collectSecretFindings(root);
250
+ const committed = _sec.unacknowledged || _sec.committed;
249
251
  if (committed && committed.length) {
250
252
  failures++;
251
253
  fail(`커밋된 시크릿 ${committed.length}건 발견 (소스 노출) — leerness scan secrets 로 상세 확인`);
package/lib/health.js CHANGED
@@ -29,7 +29,8 @@ function healthCmd(root, deps = {}) {
29
29
  // handoff/scan secrets 와 동일하게 _collectSecretFindings 로 커밋 대상 시크릿을 반영(정직성).
30
30
  try {
31
31
  const sec = _collectSecretFindings(root);
32
- const committedSecrets = sec.committed.length;
32
+ // 1.36.104 (P-0014): 인정된 픽스처 제외 — 네 표면(headline/health/audit/session-close)이 같은 술어를 쓴다.
33
+ const committedSecrets = (sec.unacknowledged || sec.committed).length;
33
34
  const envPath = path.join(root, '.env');
34
35
  const hasDotEnv = exists(envPath);
35
36
  const s = { hasDotEnv, committedSecrets };
@@ -84,7 +84,8 @@ function sessionClose(root, opts = {}, deps = {}) {
84
84
  if (_doneOptimism.length) log(t(` ⚠ 완료 정직성: done ${_doneOptimism.length}건 낙관 의심 미해소 (${_doneOptimism.slice(0, 3).map(x => x.id).join(', ')}) — evidence 주장 vs 코드 흔적 불일치, 마감 전 verify-claim 재확인 권장 (advisory)`, ` ⚠ completion honesty: ${_doneOptimism.length} done with unresolved optimism (${_doneOptimism.slice(0, 3).map(x => x.id).join(', ')}) — evidence claim vs code trace mismatch, re-check with verify-claim before closing (advisory)`));
85
85
  // 1.17.6 (UR-0049): 마감 보안 재확인 — 커밋 대상 시크릿이 살아있으면 'clean' 으로 마감하지 않도록 표면화. advisory(차단 X).
86
86
  let _closeSecrets = 0;
87
- try { if (_collectSecretFindings) _closeSecrets = ((_collectSecretFindings(root) || {}).committed || []).length; } catch {}
87
+ // 1.36.104 (P-0014): 인정된 픽스처 제외 다른 표면과 같은 술어(미인정만 '미해소').
88
+ try { if (_collectSecretFindings) { const _s = _collectSecretFindings(root) || {}; _closeSecrets = (_s.unacknowledged || _s.committed || []).length; } } catch {}
88
89
  jsonResult.closeSecurity = { committedSecrets: _closeSecrets };
89
90
  if (_closeSecrets) log(t(` 🚨 마감 보안: 커밋 대상 시크릿 ${_closeSecrets}건 미해소 — clean 아님, leerness scan secrets 확인 후 마감 권장`, ` 🚨 close security: ${_closeSecrets} committed secret(s) unresolved — not clean, run leerness scan secrets before closing`));
90
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.103",
3
+ "version": "1.36.104",
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
@@ -9750,6 +9750,144 @@ total++;
9750
9750
  if (!ok) failed++;
9751
9751
  }
9752
9752
 
9753
+ // ── 1.36.104: MCP 사용량 귀속 — 실제로 일한 프로젝트가 그 사실을 말한다.
9754
+ // _bumpMcpUsage 는 stats.mcp.tools 에 기록하는데 usage 표시는 commands 가 비면 이른 return 이라
9755
+ // 그 아래 MCP 섹션에 도달하지 못했다 → MCP 로만 일하는 에이전트의 프로젝트가 "사용 기록 없음".
9756
+ // 동시에 서버 프로세스가 cwd 에 `mcp: N` 을 쌓아 남의 프로젝트 통계를 오염시켰다.
9757
+ {
9758
+ total++;
9759
+ let ok = false; const dbg = {};
9760
+ const arena = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-us104-'));
9761
+ try {
9762
+ const cwdP = path.join(arena, 'CWDPROJ'), tgt = path.join(arena, 'TARGETPROJ');
9763
+ for (const x of [cwdP, tgt]) {
9764
+ fs.mkdirSync(x, { recursive: true });
9765
+ fs.writeFileSync(path.join(x, 'package.json'), '{"name":"x","version":"0.1.0"}');
9766
+ cp.spawnSync(process.execPath, [CLI, 'init', x, '--yes'], { cwd: x, encoding: 'utf8', timeout: 120000 });
9767
+ }
9768
+ const mcp = (name) => cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], {
9769
+ cwd: cwdP, encoding: 'utf8', timeout: 120000, maxBuffer: 32 * 1024 * 1024,
9770
+ input: [
9771
+ { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'e', version: '1' } } },
9772
+ { jsonrpc: '2.0', method: 'notifications/initialized' },
9773
+ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name, arguments: { path: tgt } } },
9774
+ ].map(r => JSON.stringify(r)).join('\n') + '\n'
9775
+ });
9776
+ mcp('leerness_about'); mcp('leerness_pulse');
9777
+ const U = (x) => ((cp.spawnSync(process.execPath, [CLI, 'usage', '--path', x], { cwd: cwdP, encoding: 'utf8', timeout: 60000 }).stdout) || '');
9778
+ const uT = U(tgt), uC = U(cwdP);
9779
+ dbg.showsMcp = /leerness_about/.test(uT) && /leerness_pulse/.test(uT) && !/\(사용 기록 없음\)/.test(uT);
9780
+ dbg.cwdClean = !/\|\s*mcp\s*\|/.test(uC);
9781
+ // 대조군 — 평범한 CLI 경로는 여전히 명령 표에 남는다
9782
+ cp.spawnSync(process.execPath, [CLI, 'audit', '--path', tgt], { cwd: cwdP, encoding: 'utf8', timeout: 120000 });
9783
+ const uT2 = U(tgt);
9784
+ dbg.cliStill = /\|\s*audit\s*\|/.test(uT2) && /leerness_about/.test(uT2);
9785
+ ok = dbg.showsMcp && dbg.cwdClean && dbg.cliStill;
9786
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch {} }
9787
+ console.log(ok ? '✓ D(1.36.104) MCP 사용량이 실제 대상 프로젝트에 보고됨 + 서버 cwd 무오염 + CLI 집계 무회귀'
9788
+ : '✗ 1.36.104 usage 귀속 실패 ' + JSON.stringify(dbg));
9789
+ if (!ok) failed++;
9790
+ }
9791
+
9792
+ // ── 1.36.104 (P-0014): 시크릿 인정 기제 — 오경보를 없애되 눈이 멀지는 않는다.
9793
+ // 동기: leerness 자기 저장소가 63건을 보고했고 전부 의도적 가짜였다(e2e.js 47 · e2e-core.js 5 · pure-utils 4 · 보고서 7).
9794
+ // 매 세션 같은 숫자가 뜨면 진짜 유출이 하나 섞여도 구분되지 않는다. 그래서 대조군 3종을 함께 건다.
9795
+ {
9796
+ total++;
9797
+ let ok = false; const dbg = {};
9798
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-sb104-'));
9799
+ try {
9800
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
9801
+ const R = (a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: d, encoding: 'utf8', timeout: 120000 });
9802
+ R(['init', d, '--yes']);
9803
+ const J = (a) => { try { return JSON.parse(((R(a).stdout) || '').trim()); } catch { return null; } };
9804
+ const FIX = path.join(d, 'fixtures.test.js');
9805
+ fs.writeFileSync(FIX, 'const A="sk-proj-FakeFixtureKey1234567890abcdef";\n');
9806
+ fs.writeFileSync(path.join(d, 'other.test.js'), 'const B="sk-proj-SecondFixtureKey098765432100";\n');
9807
+ const b0 = J(['scan', 'secrets', '--json']);
9808
+ dbg.before = !!b0 && b0.unacknowledgedCount === 2 && b0.ok === false;
9809
+ const rec = J(['scan', 'secrets', '--baseline', '--json']);
9810
+ const stored = fs.readFileSync(path.join(d, '.harness', 'secret-baseline.json'), 'utf8');
9811
+ dbg.recorded = !!rec && rec.recorded === 2;
9812
+ dbg.noPlaintext = !stored.includes('FakeFixtureKey'); // 원문 시크릿을 베이스라인에 남기지 않는다
9813
+ const b1 = J(['scan', 'secrets', '--json']);
9814
+ dbg.quiet = !!b1 && b1.unacknowledgedCount === 0 && b1.ok === true && b1.acknowledgedCount === 2 && b1.count === 2;
9815
+ dbg.headlineQuiet = !/🚨 시크릿/.test((R(['handoff', '.']).stdout) || '');
9816
+ // 대조군 ① 신규 유출은 여전히 잡는다(조용해진 게 아니라 눈이 먼 것인지 판별)
9817
+ fs.writeFileSync(path.join(d, 'leaked.js'), 'const K="sk-proj-BrandNewRealLeak9876543210zyxw";\n');
9818
+ const b2 = J(['scan', 'secrets', '--json']);
9819
+ dbg.catchesNew = !!b2 && b2.unacknowledgedCount === 1 && b2.ok === false;
9820
+ dbg.headlineAlarms = /🚨 시크릿/.test((R(['handoff', '.']).stdout) || '');
9821
+ // 대조군 ② 인정된 자리의 값이 바뀌면 자동 재경고 · 안 바뀐 것은 유지 · 사라진 지문은 stale
9822
+ fs.unlinkSync(path.join(d, 'leaked.js'));
9823
+ fs.writeFileSync(FIX, 'const A="sk-proj-CHANGEDToRealKey1234567890ab";\n');
9824
+ const b3 = J(['scan', 'secrets', '--json']);
9825
+ dbg.rearms = !!b3 && b3.unacknowledgedCount === 1 && b3.acknowledgedCount === 1
9826
+ && Array.isArray(b3.staleBaseline) && b3.staleBaseline.length === 1;
9827
+ // 대조군 ③ 손상된 베이스라인은 fail-safe(인정 0) — 오경보는 나도 누락은 안 난다
9828
+ fs.writeFileSync(path.join(d, '.harness', 'secret-baseline.json'), '{ broken json');
9829
+ const b4 = J(['scan', 'secrets', '--json']);
9830
+ dbg.failSafe = !!b4 && b4.acknowledgedCount === 0 && b4.unacknowledgedCount === 2;
9831
+ // 대조군 ④ (codex 검수 HIGH#1) 표시용 snippet 은 32자로 잘린다 — 그걸 해싱하면 앞 32자가 같은
9832
+ // 서로 다른 키가 같은 지문을 갖는다(= 새 키가 자동 인정). 지문은 전체 매치값으로 만들어야 한다.
9833
+ const t2 = path.join(d, 'trunc'); fs.mkdirSync(t2, { recursive: true });
9834
+ fs.writeFileSync(path.join(t2, 'package.json'), '{"name":"t","version":"0.1.0"}');
9835
+ cp.spawnSync(process.execPath, [CLI, 'init', t2, '--yes'], { cwd: t2, encoding: 'utf8', timeout: 120000 });
9836
+ const R2 = (a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: t2, encoding: 'utf8', timeout: 120000 });
9837
+ const J2 = (a) => { try { return JSON.parse(((R2(a).stdout) || '').trim()); } catch { return null; } };
9838
+ const PRE = 'sk-proj-a1B2c3D4e5F6g7H8i9J0kLmN'; // 두 키가 공유하는 앞부분(32자 경계 안)
9839
+ const K1 = PRE + 'Q2w3E4r5T6y7U8i9', K2 = PRE + 'M9n8B7v6C5x4Z3q2';
9840
+ dbg.fixtureValid = K1.slice(0, 32) === K2.slice(0, 32) && K1 !== K2; // 픽스처가 조건을 만족하는지 먼저 단언
9841
+ fs.writeFileSync(path.join(t2, 'fixture.js'), 'const A="' + K1 + '";\n');
9842
+ R2(['scan', 'secrets', '--baseline']);
9843
+ fs.writeFileSync(path.join(t2, 'fixture.js'), 'const A="' + K1 + '";\nconst B="' + K2 + '";\n');
9844
+ const tr = J2(['scan', 'secrets', '--json']);
9845
+ dbg.noCollision = !!tr && tr.count === 2 && tr.unacknowledgedCount === 1 && tr.ok === false;
9846
+ // 대조군 ⑤ (codex 검수 MEDIUM#4) 파일 경로 + --baseline 은 ENOTDIR 로 죽지 않고 구조화 거절
9847
+ const fb = J2(['scan', 'secrets', path.join(t2, 'fixture.js'), '--baseline', '--json']);
9848
+ dbg.rejectsFile = !!fb && fb.code === 'baseline_needs_project';
9849
+ ok = dbg.before && dbg.recorded && dbg.noPlaintext && dbg.quiet && dbg.headlineQuiet
9850
+ && dbg.catchesNew && dbg.headlineAlarms && dbg.rearms && dbg.failSafe
9851
+ && dbg.fixtureValid && dbg.noCollision && dbg.rejectsFile;
9852
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
9853
+ console.log(ok ? '✓ E(1.36.104/P-0014) 시크릿 인정: 오경보 해소 + 신규 유출 포착 + 값변경 재경고 + 손상 fail-safe + 접두충돌 차단 + 원문 미저장'
9854
+ : '✗ P-0014 시크릿 인정 실패 ' + JSON.stringify(dbg));
9855
+ if (!ok) failed++;
9856
+ }
9857
+
9858
+ // ── 1.36.104: handoff 가 보호 파일을 지웠다 — 표식 줄의 본문 해시로 '우리가 쓴 그대로' 일 때만 청소한다.
9859
+ // 실측 귀속: 명령을 하나씩 단독 실행하니 handoff 였다(이전 라운드엔 외부 검수 탓으로 오귀속하고 있었다).
9860
+ {
9861
+ total++;
9862
+ let ok = false; const dbg = {};
9863
+ const arena = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rem104-'));
9864
+ try {
9865
+ const MARK = '<!-- leerness:managed:auto';
9866
+ const sha = (b) => require('crypto').createHash('sha256').update(b).digest('hex').slice(0, 16);
9867
+ const mk = (tag, body) => {
9868
+ const d = path.join(arena, tag); fs.mkdirSync(d, { recursive: true });
9869
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
9870
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 120000 });
9871
+ fs.writeFileSync(path.join(d, '.harness', 'agent-reminders.md'), body);
9872
+ cp.spawnSync(process.execPath, [CLI, 'handoff', '.'], { cwd: d, encoding: 'utf8', timeout: 180000 });
9873
+ return path.join(d, '.harness', 'agent-reminders.md');
9874
+ };
9875
+ const B = '# 🔔 자동 reminder\ndrift critical 감지\n';
9876
+ const human = mk('human', '# 내 리마인더\nHUMAN-KEEPME 사용자가 직접 쓴 줄\n');
9877
+ dbg.humanKept = fs.existsSync(human) && fs.readFileSync(human, 'utf8').includes('HUMAN-KEEPME');
9878
+ const pasted = mk('pasted', MARK + ' -->\nUSER-DO-NOT-DELETE: production note\n'); // 표식만 복붙(해시 없음)
9879
+ dbg.pastedKept = fs.existsSync(pasted) && fs.readFileSync(pasted, 'utf8').includes('USER-DO-NOT-DELETE');
9880
+ const appended = mk('appended', MARK + ' sha=' + sha(B) + ' -->\n' + B + 'USER-APPENDED 메모\n');
9881
+ dbg.appendKept = fs.existsSync(appended) && fs.readFileSync(appended, 'utf8').includes('USER-APPENDED');
9882
+ const auto = mk('auto', MARK + ' sha=' + sha(B) + ' -->\n' + B); // 손대지 않은 자동 생성분
9883
+ dbg.autoCleaned = !fs.existsSync(auto);
9884
+ ok = dbg.humanKept && dbg.pastedKept && dbg.appendKept && dbg.autoCleaned;
9885
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch {} }
9886
+ console.log(ok ? '✓ F(1.36.104) handoff 가 사람 손이 닿은 agent-reminders.md 를 지우지 않음(복붙 표식·덧붙임 포함) + 자동분은 여전히 청소'
9887
+ : '✗ 1.36.104 보호 파일 실패 ' + JSON.stringify(dbg));
9888
+ if (!ok) failed++;
9889
+ }
9890
+
9753
9891
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
9754
9892
  if (failed > 0) process.exit(1);
9755
9893