leerness 1.36.108 → 1.36.110

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.108 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.110 하네스를 사용합니다. 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.108는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.110는 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.108는 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.108 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.110 릴리스 태그 이력** (수백 라운드) · _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.108: 2026-08-09
238
+ Last synced by Leerness v1.36.110: 2026-08-10
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.108';
37
+ const VERSION = '1.36.110';
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') 시 호스트 프로세스 오염.
@@ -5178,10 +5178,17 @@ function _selfTestCases() {
5178
5178
  && E('module.exports = require("./x"); void 0;\n') === false;
5179
5179
  return base && bypass && real && noop && noopFP;
5180
5180
  } },
5181
- { name: '위장 스텁 차단 (1.18.2): stub 루프 _vcImplIsEmpty 사용 + 메시지 + FILE_EXTS java/php 정합 (소스 가드)', run: () => {
5182
- const src = read(__filename);
5183
- return src.includes('if (_vcImplIsEmpty(body)) stubFiles.push(c.file);') && src.includes('비주석 코드 0줄 또는 export 껍데기')
5184
- && /const FILE_EXTS = '[^']*\bjava\b[^']*\bphp\b[^']*'/.test(src);
5181
+ { name: '위장 스텁 차단 (1.18.2 · 1.36.109 행위전환): 빈껍데기 판정이 실제로 동작 + 스텁 루프가 판정을 쓴다', run: () => {
5182
+ // 1.36.109: 종전엔 스텁 루프의 호출 줄을 **정확 리터럴**로 붙잡았다. 이번 라운드에 그 줄을 리팩터하자
5183
+ // (마커 파일 판정을 끼워야 했다) 리터럴이 사라져 메타가드가 즉시 잡았다 자기참조 소스가드가 공허해지는 형태다.
5184
+ // 표현이 아니라 **동작**을 본다.
5185
+ // ⚠ 배선(루프가 이 판정을 실제로 호출하는가)은 **여기서 소스로 확인하지 않는다**: 그 정규식을 쓰자
5186
+ // 바로 위 설명 주석이 스스로를 만족시켜 변이가 생존했다(자체 변이 시험이 잡았다 — 같은 함정의 재생산).
5187
+ // 배선은 e2e 블록 M 이 행위로 지킨다(0바이트 파일 → stub-impl). 배선을 끊으면 그 블록이 실패한다.
5188
+ const shells = ['', ' \n\n ', '/' + '/ TODO\n' + '/' + '/ later\n', '"use strict"; module.exports = {};\n', '/* c */\nvoid 0;\n'];
5189
+ const reals = ['function f(){return 1;}\nmodule.exports={f};\n', 'def f():\n return 1\n', 'export const x = 1;\nexport function g(){ return x; }\n'];
5190
+ const judged = shells.every(s => _vcImplIsEmpty(s) === true) && reals.every(s => _vcImplIsEmpty(s) === false);
5191
+ return judged && read(__filename).includes('비주석 코드 0줄 또는 빈 export 껍데기');
5185
5192
  } },
5186
5193
  { name: '품질 렌즈 (1.18.3): 카탈로그 무결성 — 사용자 원문 질문 + affects 상호참조 유효 (행위)', run: () => {
5187
5194
  const keys = Object.keys(LENS_CATALOG);
@@ -6423,7 +6430,7 @@ function _selfTestCases() {
6423
6430
  return clar && shape && noDbUrl && reqOk && guards && uiOk && _p0013LibraryOk() && _p0088CurrentStatePreserved()
6424
6431
  && _p0101SurfaceOk() && _p0101SkillIdOk() && _p0101PathStrictOk() && _p0102HonestyOk()
6425
6432
  && _p0103FlagsOk() && _p0103JsonOk() && _p0103RootOk() && _p0104UsageOk()
6426
- && _p0014SecretBaselineOk() && _p0104ReminderOk() && _p0015ModeOk() && _p0097AsyncLockOk();
6433
+ && _p0014SecretBaselineOk() && _p0104ReminderOk() && _p0015ModeOk() && _p0097AsyncLockOk() && _p0109ClaimGateOk() && _p0110HonestyOk();
6427
6434
  } },
6428
6435
  { name: '시크릿 스캐너 F-06 (1.36.56, 외부감사): 무명 확장자 소형 텍스트 스캔(이진 NUL 제외) + 같은 토큰 중복 보고 dedupe — 행위검사', run: () => {
6429
6436
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sc56_'));
@@ -8101,6 +8108,82 @@ function _p0015ModeOk() {
8101
8108
  // 순수 호출로 계약을 고정한다: 프로미스가 끝나기 전에는 락 파일이 살아 있어야 하고, 끝나면 사라져야 한다.
8102
8109
  // selftest 는 `c.run() === true` 만 통과시키므로(프로미스는 자동 실패) **동기 thenable** 로 같은 분기를
8103
8110
  // 결정적으로 밟는다 — `typeof out.then === 'function'` 이라는 판별 조건이 동일하다.
8111
+ // 1.36.109 (완료게이트 사냥): 두 결함을 **순수 호출**로 고정한다. 둘 다 양방향으로 단언한다 —
8112
+ // 막아야 할 것을 막는지, 그리고 통과해야 할 것이 계속 통과하는지. false-BLOCK 은 false-PASS 보다 나쁘다.
8113
+ // 1.36.110 (T-0099): 미검증 자백/헤지 탐지 — **언어 대칭**과 **오탐 0** 을 순수 호출로 고정한다.
8114
+ // 기준선은 재현율 1/16(6%), 한국어 0/8 이었다. 같은 뜻의 주장이 표기 언어에 따라 갈리면 판정이 아니라 우연이다.
8115
+ // 재현율보다 FP 가 먼저다 — 정직한 주장이 막히면 사용자는 게이트를 끈다. 실제 done 이력 139건 발화 0 으로 확인했다.
8116
+ function _p0110HonestyOk() {
8117
+ try {
8118
+ const an = require('../lib/analyzers');
8119
+ const F = (t, done) => an._epistemicHonestyCheck(t, done ? { claimedDone: true } : undefined).findings;
8120
+ const high = (t, done) => F(t, done).filter(f => f.severity === 'high').length > 0;
8121
+ // 이 라운드가 추가한 것은 **권고**다 — 차단하지 않는다. 가드도 그 계약을 그대로 단언한다.
8122
+ const hit = (t, done) => F(t, done).some(f => f.dim === 'unverified-admission');
8123
+ // ① 언어 대칭 — 같은 뜻의 ko/en 쌍이 같은 판정을 받아야 한다(1차 강화에서 4쌍이 갈렸고,
8124
+ // 그중 하나는 한국어 음절 합성 때문이었다: `듯하다` 는 잡히는데 `듯합니다` 는 안 잡혔다).
8125
+ // 이 라운드가 **권고로** 잡는 것 — 직접적인 미검증 자백. 두 언어가 같은 판정을 받아야 한다.
8126
+ const pairs = [
8127
+ ['테스트는 아직 안 돌렸지만 완료했습니다', 'Done, but not tested yet.'],
8128
+ ['로직 반영했는데 엣지 케이스는 안 봤습니다.', 'Logic applied; edge cases unchecked.'],
8129
+ ];
8130
+ const symmetric = pairs.every(([ko, en]) => hit(ko, true) === hit(en, true) && hit(ko, true) === true);
8131
+ // ①-c **차단하지 않는다** — 이 신호로 done 을 막지 않기로 한 결정을 고정한다.
8132
+ // 차단으로 올리려면 산문 파싱이 아니라 도구 자신의 실행 증거를 근거로 삼아야 한다(별도 과제).
8133
+ const neverBlocks = pairs.every(([ko, en]) => high(ko, true) === false && high(en, true) === false);
8134
+ // 기존 차단 차원(추정 표현)은 **그대로**여야 한다. 한국어 음절 합성 회귀를 여기서 고정한다 —
8135
+ // `듯\s*(?:하|싶)` 는 "듯하다" 는 잡지만 "듯합니다"(하+ㅂ→합)는 못 잡아 ko/en 이 갈렸다.
8136
+ const highPairs = [['해결된 듯합니다. 다음에 확인하겠습니다', 'Looks resolved. I will confirm next time.']];
8137
+ const highSymmetric = highPairs.every(([ko, en]) => high(ko, true) === true && high(en, true) === true);
8138
+ // ①-b **의도적으로 잡지 않는 것** — 미래 범위 표현. "추후 확인 예정" 은 (a) 이 작업을 검증 안 했다 와
8139
+ // (b) 앞으로 더 볼 게 남았다 를 모두 뜻할 수 있어 정규식으로 가를 수 없다. 자체 FP 헌트에서 이 표현이
8140
+ // 문서 수정·설정 변경·의존성 bump·revert 를 막았다(전부 정직한 완료 주장). 애매하면 통과시킨다.
8141
+ const futureScopeAllowed = [
8142
+ 'docs/guide.md 갱신. 오탈자가 더 있을 수 있어 추후 확인 예정.',
8143
+ 'package.json 의존성 3개 bump. 호환성은 추후 확인.',
8144
+ 'Bumped deps in package.json. Compatibility will be checked later.',
8145
+ ].every(t => hit(t, true) === false);
8146
+ // ② 오탐 0 — 근거를 댄 정직한 주장은 어떤 표현을 써도 통과한다. `!hasSource` 가 방어선이다.
8147
+ const honest = [
8148
+ 'src/queue.ts 재작성. jest 88 passed, 0 failed.',
8149
+ 'Rewrote src/queue.ts. jest: 88 passed, 0 failed.',
8150
+ 'src/a.js 구현. 테스트 20/20 통과. 성능 개선 효과는 추후 측정 예정.', // 헤지 낱말 + 근거 → 통과해야 한다
8151
+ '오타 수정만 했습니다. 검증 필요 없는 주석 변경. src/c.js', // "필요 없는" 은 반대 의미
8152
+ 'Comment typo only in src/c.js — needs no verification.',
8153
+ // 러너 이름을 열거하지 않는다는 계약 — 실행했는데 막히면 명백한 오차단이다(자체 FP 헌트가 6건 잡았다).
8154
+ 'app/src/Main.kt 구현. ./gradlew test 통과. 엣지 케이스는 안 봤습니다.',
8155
+ 'Program.cs 구현. dotnet test 실행 완료. 통합 시나리오는 아직 확인 못 했습니다.',
8156
+ 'app/models/user.rb 구현. bundle exec rspec 실행. 엣지 케이스는 안 봤습니다.',
8157
+ 'src/x.ts 구현. ./scripts/verify.sh 실행해 통과. 회귀는 아직 안 봤습니다.',
8158
+ ];
8159
+ const noFalseBlock = honest.every(t => hit(t, true) === false);
8160
+ // ③ claimedDone 은 **호출부가 아는 사실**을 받는 옵션이다 — 기본값 동작은 바뀌지 않아야 한다
8161
+ // (독립 표면 `honesty-check --text` 가 갑자기 더 엄해지면 그건 이 라운드가 약속한 범위 밖이다).
8162
+ const optIn = hit('로직 반영했는데 엣지 케이스는 안 봤습니다.', true) === true
8163
+ && hit('엣지 케이스는 안 봤습니다.', false) === false; // 산문에 완료 낱말이 없으면 기본값에선 안 잡는다
8164
+ return symmetric && neverBlocks && highSymmetric && futureScopeAllowed && noFalseBlock && optIn;
8165
+ } catch { return false; }
8166
+ }
8167
+ function _p0109ClaimGateOk() {
8168
+ try {
8169
+ const an = require('../lib/analyzers');
8170
+ // ① 빈 파일은 가장 명백한 스텁이다. 종전엔 `!body` 때문에 "스텁 아님" 이었고, 공백 3자보다 0바이트가 더 쉽게 통과했다.
8171
+ const emptyIsStub = _vcImplIsEmpty('') === true && _vcImplIsEmpty(' \n\n ') === true;
8172
+ const realIsNot = _vcImplIsEmpty('function f(){return 1;}\nmodule.exports={f};\n') === false
8173
+ && _vcImplIsEmpty('def f():\n return 1\n') === false; // 대조군 — 실코드는 스텁이 아니다
8174
+ const badInput = _vcImplIsEmpty(null) === false && _vcImplIsEmpty(undefined) === false; // 문자열 아닌 입력은 판정하지 않는다
8175
+ // ② 밑줄로 시작하는 파일명이 **잘리지 않고 통째로** 추출돼야 한다. 잘리면 없는 파일을 찾아 정직한 주장을 거부한다.
8176
+ const grab = (s) => (String(s).match(an._EVIDENCE_FILE_RE()) || [])[0] || null;
8177
+ const underscore = grab('src/_helpers.js 구현 완료') === 'src/_helpers.js'
8178
+ && grab('pages/_app.tsx 수정') === 'pages/_app.tsx'
8179
+ && grab('pkg/__init__.py 추가') === 'pkg/__init__.py'
8180
+ && grab('_private/util.ts 정리') === '_private/util.ts';
8181
+ const plainStill = grab('src/normal.js 구현') === 'src/normal.js' // 대조군 — 기존 인식 유지
8182
+ && grab('ops/containers/Dockerfile 작성') === 'ops/containers/Dockerfile'
8183
+ && grab('.github/workflows/ci.yml 갱신') === '.github/workflows/ci.yml';
8184
+ return emptyIsStub && realIsNot && badInput && underscore && plainStill;
8185
+ } catch { return false; }
8186
+ }
8104
8187
  function _p0097AsyncLockOk() {
8105
8188
  const os2 = require('os');
8106
8189
  const arena = fs.mkdtempSync(path.join(os2.tmpdir(), 'leerness-p97lk-'));
@@ -15165,7 +15248,12 @@ const _VC_EMPTY_SHELL_RE = new RegExp(
15165
15248
  ].join('|') + ')(?:\\s+as\\s+[A-Za-z0-9_$.<>\\[\\] ]+?)?\\s*;?$' // 선택적 TS 캐스트(as any) + ;
15166
15249
  );
15167
15250
  function _vcImplIsEmpty(body) {
15168
- if (typeof body !== 'string' || !body) return false;
15251
+ // 1.36.109 (완료게이트 사냥): 문자열은 **가장 명백한 스텁**인데 `!body` 그걸 "스텁 아님" 으로 만들었다.
15252
+ // 공백 3자짜리 파일은 stub-impl 로 막히는데 0바이트는 통과했다 — 같은 것이 더 비어 있을수록 통과하는 역전이다.
15253
+ // (`touch src/x.js` + evidence "src/x.js 구현 완료" → verify-claim 통과, 실측.)
15254
+ // 읽기 실패는 호출부가 catch 로 걸러 여기 오지 않는다. 빈 본문은 곧 빈 구현이다.
15255
+ if (typeof body !== 'string') return false;
15256
+ if (body === '') return true;
15169
15257
  // 블록주석 제거 → 줄별 trim/주석줄 제거 → 인라인 // 주석 제거(따옴표 없는 줄만, 문자열 보호) → join.
15170
15258
  const codeLines = body.replace(/\/\*[\s\S]*?\*\//g, '').split('\n').map(l => {
15171
15259
  let t = l.trim();
@@ -15271,6 +15359,12 @@ function verifyClaimCmd(root, taskId, opts = {}) {
15271
15359
  for (const m of evidence.matchAll(FILE_RE)) {
15272
15360
  const prev = m.index > 0 ? evidence[m.index - 1] : '';
15273
15361
  if (prev === '\\' || prev === '/') continue; // suffix-of-longer-path(UNC \\srv\..., C:\..., URL) → skip. ':' 는 "파일:src/x.js" 정상 케이스라 스킵 안 함
15362
+ // 1.36.109 (codex 검수 P2, 재현됨): 구분자만 보던 가드에 두 구멍이 있었다 —
15363
+ // `https://…/docs#_anchor.js` 의 URL 조각과 `C:_anchor.js` 의 드라이브 상대 경로가 **루트 상대 파일 주장**이 됐다.
15364
+ // (밑줄 확장 전에도 같은 형태로 `anchor.js` 가 통과했다 — 내가 만든 클래스는 아니지만 여기서 닫는다.)
15365
+ // `:` 를 통째로 막으면 "파일:src/x.js" 같은 정상 표기가 죽으므로, **앞이 단일 영문자인 `:` 만** 드라이브로 본다.
15366
+ if (prev === '#') continue; // URL 조각
15367
+ if (prev === ':' && /^[A-Za-z]$/.test(evidence[m.index - 2] || '')) continue; // C:name (드라이브 상대)
15274
15368
  filePatterns.push(m[0].replace(/\\/g, '/'));
15275
15369
  }
15276
15370
  // 중복 제거 + "tests/test.js" 같은 결과를 유지 (이미 `..` 없으니 그대로)
@@ -15317,15 +15411,34 @@ function verifyClaimCmd(root, taskId, opts = {}) {
15317
15411
  // 1.17.3 (UR-0046 범용성 P1②): 빈껍데기(스텁) 구현 + 테스트-구현 연결 검사 — "주석뿐 구현 + assert(true) 테스트"가 verify-claim 을 exit 0 으로 통과하던 공격(5축 실증 Attack C) 차단.
15318
15412
  // ① 스텁: 주장된 코드 파일(테스트 제외)의 비주석 코드줄이 0 이면 확정 스텁 — done 게이팅 FAIL(확실 신호만, 과탐 0).
15319
15413
  // ② 연결: 주장에 구현+테스트가 모두 있는데 어떤 테스트도 구현 파일명(basename)을 참조하지 않으면 — 기본 advisory ⚠, --strict-claims 시 FAIL.
15320
- const _VC_CODE_EXT = /\.(js|mjs|cjs|jsx|ts|tsx|py|rb|go|rs|java|cs|php)$/i;
15414
+ // 1.36.109 (codex 검수 P2): `pyi` 추가 — 여기에 없으면 위의 마커 예외가 **도달 불가**다(광고만 하고 안 도는 코드).
15415
+ const _VC_CODE_EXT = /\.(js|mjs|cjs|jsx|ts|tsx|pyi|py|rb|go|rs|java|cs|php)$/i;
15321
15416
  const _VC_TEST_PAT = /(^|[\\/])(test_[^\\/]+\.[a-z]+|[^\\/]+[._-]test\.[a-z]+|[^\\/]+\.spec\.[a-z]+)$|(^|[\\/])tests?[\\/]/i;
15322
15417
  const stubFiles = [];
15418
+ // 1.36.109 (codex 검수 P1, 재현됨): 관례상 비어 있는 **마커 파일**은 스텁으로 몰면 안 되지만,
15419
+ // 그것만으로 "구현했다" 가 성립해서도 안 된다. 처음엔 무조건 예외로 뒀더니 빈 `pkg/__init__.py` **하나만**
15420
+ // 인용한 done 이 exit 0 을 받았다(검수가 재현) — 내가 막겠다고 고친 바로 그 형태를 예외가 되살린 것이다.
15421
+ // 그래서 예외는 "다른 실질 파일이 함께 인용됐을 때만" 적용한다. 판정은 루프 뒤로 미룬다.
15422
+ const markerFiles = [];
15423
+ const _isPkgMarker = (f) => /(^|[\\/])__init__\.pyi?$/.test(f);
15323
15424
  for (const c of fileChecks) {
15324
15425
  if (!c.exists || !_VC_CODE_EXT.test(c.file) || _VC_TEST_PAT.test(c.file)) continue;
15325
15426
  let body = ''; try { body = read(path.join(root, c.file)); } catch { continue; }
15326
- if (!body || body.length > 512 * 1024) continue;
15427
+ // 1.36.109: `!body` 스킵 조건에 두면 0바이트 파일이 스텁 검사를 통째로 건너뛴다(가드 두 겹이 서로를 가림 —
15428
+ // 여기와 `_vcImplIsEmpty` 양쪽에 같은 falsy 함정이 있었고 한 곳만 고치면 구멍이 남는다). 크기 상한만 남긴다.
15429
+ if (body.length > 512 * 1024) continue;
15327
15430
  // 1.18.2: 코드 0줄(기존) + 빈 export 껍데기(위장 스텁) 통합 판정.
15328
- if (_vcImplIsEmpty(body)) stubFiles.push(c.file);
15431
+ if (!_vcImplIsEmpty(body)) continue;
15432
+ if (_isPkgMarker(c.file)) { markerFiles.push(c.file); continue; } // 판정 보류 — 아래에서 결정
15433
+ stubFiles.push(c.file);
15434
+ }
15435
+ // 빈 마커를 봐 주는 조건: 같이 인용된 **비어 있지 않은** 코드 파일이 하나라도 있어야 한다.
15436
+ // (파이썬 패키지 추가는 보통 `__init__.py` + 실제 모듈이 함께 온다 — 그 정직한 형태만 통과시킨다.)
15437
+ if (markerFiles.length) {
15438
+ const substantive = fileChecks.some(c =>
15439
+ c.exists && _VC_CODE_EXT.test(c.file) && !_VC_TEST_PAT.test(c.file) && !_isPkgMarker(c.file)
15440
+ && !stubFiles.includes(c.file));
15441
+ if (!substantive) for (const f of markerFiles) stubFiles.push(f);
15329
15442
  }
15330
15443
  const _vcImpl = fileChecks.filter(c => c.exists && _VC_CODE_EXT.test(c.file) && !_VC_TEST_PAT.test(c.file)).map(c => c.file);
15331
15444
  const _vcTests = fileChecks.filter(c => c.exists && _VC_CODE_EXT.test(c.file) && _VC_TEST_PAT.test(c.file)).map(c => c.file);
@@ -15454,11 +15567,17 @@ function verifyClaimCmd(root, taskId, opts = {}) {
15454
15567
  // 판정 기준은 **실행 확증**이어야 한다 — 실제로 돌았고 실패하지 않았는가(allPassed = exit 0 이며 파싱됐다면 전부 통과).
15455
15568
  const _runProven = !!(runResult && !runResult.skipped && runResult.allPassed);
15456
15569
  const unverifiableClaim = claimsChecked && files.length === 0 && !_runProven;
15457
- let optimismSuspects = [], honestyFindings = [], strictOk = true;
15570
+ let optimismSuspects = [], honestyFindings = [], honestyAdvisory = [], strictOk = true;
15458
15571
  if (claimsChecked) {
15459
15572
  const codeText = _scanCodeForPatterns(root);
15460
15573
  optimismSuspects = _detectOptimism(evidence, codeText);
15461
- honestyFindings = _epistemicHonestyCheck(evidence).findings.filter(f => f.severity === 'high');
15574
+ // 1.36.110 (T-0099): 여기 도달했다는 것은 이미 완료 주장(done/--require-evidence/--strict-claims)이라는 뜻이다.
15575
+ // 그 사실을 넘겨 산문에서 완료 낱말을 다시 찾는 중복 요구를 없앤다(그것이 재현율 병목이었다).
15576
+ const _hAll = _epistemicHonestyCheck(evidence, { claimedDone: true }).findings;
15577
+ honestyFindings = _hAll.filter(f => f.severity === 'high');
15578
+ // 미검증 자백은 **권고**다 — 게이팅하지 않는다(왜 그렇게 정했는지는 lib/analyzers.js 주석 참조).
15579
+ // 다만 보이지 않으면 탐지한 의미가 없으므로 사람 출력과 --json 에 함께 싣는다.
15580
+ honestyAdvisory = _hAll.filter(f => f.severity !== 'high');
15462
15581
  strictOk = optimismSuspects.length === 0 && honestyFindings.length === 0;
15463
15582
  }
15464
15583
  const gitStrongMismatch = gitApplicable && claimedInGit.length === 0; // 변경 있는데 주장 파일이 git 변경에 전무
@@ -15515,7 +15634,8 @@ function verifyClaimCmd(root, taskId, opts = {}) {
15515
15634
  },
15516
15635
  stubFiles: stubFiles.slice(0, 10),
15517
15636
  evidence: { required: mustHaveEvidence, ...evq },
15518
- claims: !claimsChecked ? null : { ok: strictOk, optimism: optimismSuspects.map(s => ({ kind: s.kind, label: s.label })), honesty: honestyFindings.map(f => ({ dim: f.dim, label: f.label })) },
15637
+ // 1.36.110: `advisory` **게이팅하지 않는** 신호다(ok 영향 없음). 소비자가 경계를 오인하지 않도록 키를 분리한다.
15638
+ claims: !claimsChecked ? null : { ok: strictOk, optimism: optimismSuspects.map(s => ({ kind: s.kind, label: s.label })), honesty: honestyFindings.map(f => ({ dim: f.dim, label: f.label })), advisory: honestyAdvisory.map(f => ({ dim: f.dim, label: f.label, severity: f.severity })) },
15519
15639
  git: gitChanged === null ? { applicable: false, reason: 'not-a-git-repo' } : (!gitApplicable ? { applicable: false, reason: 'no-working-changes-or-no-claimed-files' } : { applicable: true, claimedInGit: claimedInGit.length, claimedNotInGit, strongMismatch: gitStrongMismatch, changedNotClaimed }),
15520
15640
  scopeCreep: !gitApplicable ? null : { count: changedNotClaimed.length, files: changedNotClaimed.slice(0, 10) } // 1.13.2 (Karpathy 외과적 변경): 요청/주장 밖 변경 파일(advisory)
15521
15641
  };
@@ -15614,6 +15734,11 @@ function verifyClaimCmd(root, taskId, opts = {}) {
15614
15734
  for (const s of optimismSuspects) log(t(` · [${s.kind}] ${s.label}: evidence에 주장 있는데 코드에 호출 흔적 없음`, ` · [${s.kind}] ${s.label}: claimed in evidence but no call trace in code`));
15615
15735
  for (const f of honestyFindings) log(` · [${t('정직성', 'honesty')}:${f.dim}] ${f.label}: ${f.detail}`);
15616
15736
  }
15737
+ // 1.36.110 (T-0099): 권고는 통과/실패와 **무관하게** 보여 준다 — 안 보이면 탐지한 의미가 없다.
15738
+ // 차단하지 않는 이유(오차단 위험·우회 가능성)는 lib/analyzers.js 에 적었다.
15739
+ for (const f of honestyAdvisory) {
15740
+ log(t(` - ⓘ 권고(차단 아님) [${f.dim}] ${f.label}: ${f.detail}`, ` - ⓘ advisory (non-blocking) [${f.dim}] ${f.label}: ${f.detail}`));
15741
+ }
15617
15742
  }
15618
15743
  // 1.9.302 (UR-0042) + 1.11.2 (UR-0175): git diff 시맨틱 교차검증 — 주장 파일이 실제 git 변경에 있는가. gitClaimOk/gitStrongMismatch 는 상단 공유(done 기본 게이팅, --lenient 완화).
15619
15744
  if (gitChanged === null) {
package/lib/analyzers.js CHANGED
@@ -40,16 +40,22 @@ function _shellGuardAnalyze(cmd, ctx) {
40
40
  // 실측으로 확인된 사각지대: 다단계 경로의 확장자 없는 파일(ops/containers/Dockerfile),
41
41
  // 선두 점 디렉토리(.github/workflows/ci.yml → github/… 로 잘림), 점파일(.gitignore),
42
42
  // 그리고 Dockerfile.dev 가 Dockerfile 로 잘려 "없는 파일" 이 되던 것.
43
- const _VC_FILE_EXTS = 'webmanifest|properties|tscn|tres|godot|json5|prisma|swift|java|jsx|tsx|yaml|html|scss|sass|less|gltf|conf|json|toml|lock|mdx|xml|css|svg|yml|cfg|ini|env|php|sql|mjs|cjs|vue|svelte|md|js|ts|gd|cs|py|rb|go|rs|kt|sh|cpp|c|h';
43
+ // 1.36.109 (codex 검수 P2): `pyi` 추가 — 타입 스텁(PEP 561)은 실제 산출물인데 추출조차 안 돼
44
+ // `pkg/__init__.pyi` 만 인용한 정직한 주장이 "파일 0건" 으로 거부됐다. 길이 내림차순 규칙상 `py` 앞에 둔다.
45
+ const _VC_FILE_EXTS = 'webmanifest|properties|tscn|tres|godot|json5|prisma|swift|java|jsx|tsx|yaml|html|scss|sass|less|gltf|conf|json|toml|lock|mdx|xml|css|svg|yml|cfg|ini|env|php|sql|mjs|cjs|vue|svelte|pyi|md|js|ts|gd|cs|py|rb|go|rs|kt|sh|cpp|c|h';
44
46
  // 확장자 없는 표준 파일명 — 임의의 대문자 단어가 아니라 정확한 철자만(대소문자 구분)으로 FP 억제.
45
47
  const _VC_BARE_FILES = 'Dockerfile|Containerfile|Makefile|Gemfile|Procfile|Rakefile|Jenkinsfile|Vagrantfile|Caddyfile|Brewfile|Justfile|CODEOWNERS|LICENCE|LICENSE|NOTICE';
46
48
  // 점파일 — 확장자 규칙으로는 잡히지 않지만 명백한 파일 참조.
47
49
  const _VC_DOTFILES = '\\.(?:gitignore|gitattributes|gitmodules|dockerignore|npmrc|npmignore|nvmrc|editorconfig|prettierrc|eslintrc|babelrc|browserslistrc)';
48
50
  // 경로 접두: 0개 이상 세그먼트(각 세그먼트는 선두 점 허용) — `ops/containers/`, `.github/workflows/` 모두 포함.
49
- const _VC_PREFIX = '(?:\\.?[A-Za-z0-9][A-Za-z0-9._-]*[\\/\\\\])*';
51
+ // 1.36.109 (완료게이트 사냥): 첫 글자에 `_` 가 없어서 `src/_helpers.js` 가 `helpers.js` 로 **잘려서** 추출됐다 —
52
+ // "파일 없음" 이 아니라 **없는 파일을 찾아** files-missing 으로 정직한 주장을 거부했다(실측).
53
+ // 흔한 이름들이 여기 걸린다: Next.js `pages/_app.tsx` · `_document.tsx` · 파이썬 `__init__.py` · `_private/…`.
54
+ // false-BLOCK 은 false-PASS 보다 나쁘다 — 사용자는 이유 없이 막히고 게이트를 끄게 된다.
55
+ const _VC_PREFIX = '(?:\\.?[A-Za-z0-9_][A-Za-z0-9._-]*[\\/\\\\])*';
50
56
  // basename: (a) 확장자 파일 (b) 표준 bare 파일명(뒤에 .확장자가 오면 (a)에 양보) (c) 점파일
51
57
  // bare 파일은 관례적 접미사를 허용한다(Dockerfile.dev / Makefile.local) — 없으면 그 주장이 "파일 0건"이 된다.
52
- const _VC_BASE = `(?:[A-Za-z0-9][\\w.-]*\\.(?:${_VC_FILE_EXTS})\\b|(?:${_VC_BARE_FILES})(?:\\.[A-Za-z0-9_-]+)?(?![\\w.-])|${_VC_DOTFILES}(?![\\w-]))`;
58
+ const _VC_BASE = `(?:[A-Za-z0-9_][\\w.-]*\\.(?:${_VC_FILE_EXTS})\\b|(?:${_VC_BARE_FILES})(?:\\.[A-Za-z0-9_-]+)?(?![\\w.-])|${_VC_DOTFILES}(?![\\w-]))`;
53
59
  function _EVIDENCE_FILE_RE(flags = '') { return new RegExp(`${_VC_PREFIX}${_VC_BASE}`, flags); }
54
60
 
55
61
  function _evidenceQuality(evidence) {
@@ -88,8 +94,15 @@ function _parseEvidenceStats(text) {
88
94
 
89
95
  // 1.9.305 (사용자 명시): AI 인식론적 정직성 점검 — 모르는 걸 아는 척 / 정보 미수집 / 미검증 섣부른 판단 휴리스틱 탐지.
90
96
  // 순수 함수(텍스트→findings). 휴리스틱 advisory — 단정/추정/외부참조 표현 vs 근거·수집 흔적 대조. opt-in 점검용.
91
- function _epistemicHonestyCheck(text) {
97
+ // 1.36.110 (T-0099): `opts.claimedDone` — 호출부가 **이미 완료 주장임을 아는** 경우.
98
+ // verify-claim 은 status 가 done 인 행에만 이 검사를 돌린다(claimsChecked). 그 문맥에서
99
+ // "산문에 완료 낱말이 있는가" 를 다시 요구하는 건 중복이고, 실제로 그것이 재현율의 병목이었다 —
100
+ // 홀드아웃에서 놓친 6건 중 5건이 결론 어휘 미등록("마쳤/잡은/shipped/applied/gone")이었다.
101
+ // 완료 동의어를 두 언어로 계속 채우는 건 끝이 없다(휴리스틱 토끼굴). 호출부가 아는 사실을 받는다.
102
+ // 기본값은 종전 그대로 — `honesty-check --text` 같은 독립 표면의 동작은 바뀌지 않는다.
103
+ function _epistemicHonestyCheck(text, opts) {
92
104
  const t = String(text || '');
105
+ const claimedDone = !!(opts && opts.claimedDone);
93
106
  const findings = [];
94
107
  // 공통: 근거/출처 흔적 (파일경로·URL·테스트결과·Exit·문서·api-skill·인용·조회 흔적)
95
108
  const hasSource = /(?:[\w./-]+\.(?:js|ts|tsx|jsx|py|go|rs|rb|md|json|ya?ml|toml|sql|sh)\b)|https?:\/\/|\bExit\s*[:=]|\d+\s*\/\s*\d+\s*(?:통과|passed)|\b(?:passed|passing)\b|근거[::]|출처[::]|api-skill|공식\s*문서|문서\s*(?:확인|참조|에\s*따르면)|읽었|조회(?:함|했|함\b)|확인(?:함|했|됨)|grep|로그[::]/i.test(t);
@@ -97,9 +110,67 @@ function _epistemicHonestyCheck(text) {
97
110
  const definitive = /(반드시|항상|언제나|무조건|확실(?:히|함|하게)|당연히|틀림없|100\s*%|always|never|guaranteed|definitely|obviously|certainly)/i.test(t);
98
111
  if (definitive && !hasSource) findings.push({ dim: 'pretend-knowledge', severity: 'high', label: '근거 없는 단정', detail: '단정적 표현이 있으나 근거/출처(파일·문서·테스트·로그)가 없음 — 모르는 정보를 아는 척할 위험.' });
99
112
  // 차원2: 미검증 섣부른 판단 — 추정 표현 + 완료/성공 결론인데 근거 없음
100
- const assumption = /(아마|추정|것\s*같|듯\s*(?:하|싶)|probably|likely|maybe|perhaps|i\s*(?:think|assume|guess|believe|suppose)|should\s*(?:work|be|pass|fix)|생각(?:됩니다|된다|함|돼)|일\s*것|예상(?:됩니다|된다|됨)|짐작)/i.test(t);
101
- const conclusion = /(완료|done|성공|통과|해결(?:됨|했|함|되었)|fixed|resolved|works?\b|작동(?:함|한다|됨)|구현(?:됨|했|완료))/i.test(t);
113
+ // 1.36.110 (T-0099): 이 판정은 **언어에 상관없이** 같아야 하는데 실측 재현율이 영어 2/4 · 한국어 0/8 이었다.
114
+ // 같은 뜻의 주장이 표기 언어에 따라 통과/차단이 갈리면 그건 판정이 아니라 우연이다
115
+ // (`should work, will test later` 는 잡고 `아마 동작할 겁니다. 나중에 테스트 예정.` 은 통과시켰다).
116
+ // 측정 결과 원인은 둘이었다:
117
+ // (a) 결론 어휘가 좁다 — `작동` 은 있는데 `동작` 이 없고, `수정했`·`반영`·`적용` 이 빠져 있었다.
118
+ // (b) **미검증 자백** 신호가 아예 없었다 — "테스트는 아직 안 돌렸지만 완료했습니다" 에는 추정 표현이
119
+ // 하나도 없다. 이건 헤지가 아니라 **자기모순**이라 헤지보다 강한 신호다.
120
+ // 강화 전에 FP 코퍼스를 먼저 고정했다(정직한 주장 무탐지 100%). `!hasSource` 가 그 방어선이고,
121
+ // 근거를 댄 주장은 어떤 표현을 쓰든 통과한다 — "20/20 통과 … 성능은 추후 측정 예정" 같은 정직한 문장이 그 예다.
122
+ // ⚠ 세 목록은 **언어 대칭**을 유지해야 한다. 한쪽에만 표현을 더하면 같은 뜻의 주장이 표기 언어에 따라
123
+ // 갈린다 — 실제로 1차 강화에서 4쌍이 갈렸고(ko HIT / en MISS 등) 대칭 검사가 그걸 잡았다.
124
+ // 새 표현을 넣을 때는 반대 언어의 짝을 같이 넣고, 코퍼스에 그 쌍을 추가한다.
125
+ // ⚠ 한국어 **음절 합성** 함정: `듯\s*(?:하|싶)` 는 "듯하다" 는 잡지만 "듯합니다" 는 못 잡는다 —
126
+ // 하 + ㅂ 이 한 음절 `합` 으로 합쳐지기 때문이다. 대칭 검사가 이 한 글자 차이로 ko/en 판정이 갈린 것을 잡았다.
127
+ // 한국어 표현을 넣을 때는 활용형(하/해/한/했/함/합)을 함께 적어야 한다.
128
+ const assumption = /(아마|추정|것\s*같|듯\s*(?:하|해|한|했|함|합|싶)|probably|likely|maybe|perhaps|i\s*(?:think|assume|guess|believe|suppose)|seems?\s*to|appears?\s*to|looks?\s*(?:like|good|fine|ok|resolved|fixed|done)|should\s*(?:work|be|pass|fix)|expect(?:ed|s)?\s*to|생각(?:됩니다|된다|함|돼)|일\s*것|예상(?:됩니다|된다|됨|돼)|짐작)/i.test(t);
129
+ // 미검증 자백 — "아직 안 했다 / 나중에 하겠다" 를 스스로 말하면서 완료를 주장하는 형태.
130
+ // `(?!\s*없)` 는 "검증 필요 없는 오타 수정" 같은 **반대 의미**를 배제한다(FP 코퍼스가 잡아냈다).
131
+ // ⚠ **미래 범위 표현은 넣지 않는다** — "추후 확인 예정"·"will check later" 는 두 가지를 뜻할 수 있다:
132
+ // (a) 이 작업을 검증하지 않았다 (b) 앞으로 더 볼 것이 남았다. 정규식으로 둘을 가를 수 없다.
133
+ // 자체 FP 헌트에서 그 표현들이 문서 수정·설정 변경·의존성 bump·revert 를 막았다(실측 4건) —
134
+ // 전부 정직한 완료 주장이다. 애매하면 통과시킨다: false-BLOCK 이 false-PASS 보다 나쁘다.
135
+ // 여기 남기는 것은 **이미 일어난 일에 대한 부정**뿐이다("아직 안 돌렸다"·"not tested yet"·"unchecked").
136
+ const unverified = /(아직\s*(?:안|못)\s*(?:돌|해|확인|테스트|검증)|테스트\s*(?:는\s*)?(?:아직|안\s*(?:했|돌))|확인(?:은|을)?\s*(?:못|안)\s*(?:해|했|함)|안\s*(?:봤|돌렸|해봤)|미확인|not\s*(?:yet\s*)?(?:tested|verified|checked)|have\s*not\s*(?:tested|verified|checked)|untested|unverified|unchecked|yet\s*to\s*(?:test|verify|check)|todo\s*:?\s*(?:verify|test))/i.test(t);
137
+ const conclusion = /(완료|done|성공|통과|해결(?:됨|된|했|함|되었)|fixed|resolv(?:e|es|ed)|works?\b|complete[ds]?\b|implement(?:ed|s|ation)?\b|작동(?:함|한다|됨|할)|동작(?:함|한다|됨|할)|구현(?:됨|했|완료)|수정(?:됨|했|완료)|반영(?:됨|했|완료)|적용(?:됨|했|완료)|처리(?:됨|했|완료)|고쳤|고쳐(?:짐|졌)|끝냈|마무리(?:함|했|됨))/i.test(t);
138
+ // 1.36.110 (행위 가드가 순수 측정의 오류를 잡았다): `hasSource` 는 "파일을 언급했는가" 다.
139
+ // 그런데 verify-claim 의 evidence 는 **거의 항상 파일을 인용한다**(안 하면 files-missing/unverifiable 로 이미 막힌다) —
140
+ // 그래서 이 판정이 정작 필요한 문맥에서 도달 불가였다(실제 done 이력 139건 발화 0 의 진짜 이유).
141
+ // "아직 테스트 안 했다" 를 반박하는 것은 파일 이름이 아니라 **실행 증거**다. done 문맥에서만 기준을 그것으로 좁힌다.
142
+ // 기본 표면(`honesty-check --text`)의 판정은 종전 그대로 — 범위를 넘어 조이지 않는다.
143
+ // ⚠ 러너 **이름을 열거하지 않는다**. 처음엔 npm/pytest/cargo/jest… 를 나열했는데, 자체 FP 헌트에서
144
+ // Gradle · .NET · RSpec · PHPUnit · Elixir · Swift · 커스텀 스크립트가 **실제로 테스트를 돌렸는데도** 막혔다.
145
+ // 생태계마다 러너를 채우는 건 끝이 없다(완료 동의어와 같은 토끼굴). 러너 이름이 아니라
146
+ // **실행/결과의 흔적**을 본다: 실행 마커(Exit/Command) · 결과 수치 · 테스트 어휘 + 실행/통과 낱말.
147
+ const execMarker = /\bExit\s*[:=]|\bCommand\s*[:=]|exit\s*code/i.test(t);
148
+ const testOutcome = /\d+\s*(?:\/\s*\d+\s*)?(?:통과|passed|passing|성공)|\ball\s+(?:tests?\s+)?(?:passed|green)\b|BUILD\s+SUCCESS/i.test(t);
149
+ // 테스트/검증 어휘가 **실행·통과 낱말과 가까이** 있으면 실행 흔적으로 본다(러너 무관).
150
+ // ⚠ **부정을 건너뛰면 안 된다**: 처음엔 단순히 "테스트 낱말 … 실행/통과" 로 잡았는데,
151
+ // 그러면 `테스트는 아직 안 돌렸지만` 이 "테스트를 돌렸다" 로 읽혀 이 라운드의 대표 케이스가 통과했다.
152
+ // 사이에 부정어(안/못/않/없/not/no)가 오면 실행 흔적으로 세지 않는다(tempered 매칭).
153
+ // 부정어는 **뒤에 공백이 오는 것만** 본다 — "안정성"·"못지않게" 같은 낱말을 부정으로 오인하면
154
+ // 실행 흔적을 못 세고, 그건 곧 정직한 주장의 오차단으로 이어진다.
155
+ const NEG = '(?:(?!안\\s|못\\s|않|없|\\bnot\\b|\\bno\\b)[^\\n])';
156
+ const testWords = 'test|spec|unit|e2e|integration|verify|테스트|스펙|검증|점검';
157
+ const ranWords = '실행|돌렸|돌림|통과|성공|passed|passing|ran\\b|run\\b|green|success';
158
+ const testRun = new RegExp(`(?:${testWords})${NEG}{0,24}(?:${ranWords})`, 'i').test(t)
159
+ || new RegExp(`(?:실행|ran|run)${NEG}{0,24}(?:${testWords})`, 'i').test(t);
160
+ const ranSomething = execMarker || testOutcome || testRun;
161
+ const refuted = claimedDone ? ranSomething : hasSource;
162
+ // 종전 차원(추정 표현 + 결론 + 근거 없음)은 **그대로** 유지한다 — 기존 사용자의 게이팅 동작을 바꾸지 않는다.
102
163
  if (assumption && conclusion && !hasSource) findings.push({ dim: 'premature-judgment', severity: 'high', label: '검증 없는 섣부른 판단', detail: '가정·추정 표현과 완료·성공 결론이 함께 있으나 검증 근거가 없음 — 검증 없이 섣부르게 판단할 위험.' });
164
+ // 1.36.110 (T-0099) — **권고(medium)로 낸다. 차단하지 않는다.** 왜 그렇게 정했는지 남긴다:
165
+ // 이 신호를 차단으로 쓰려고 두 라운드를 넓혔는데, 자체 FP 헌트가 6건(실제로 테스트를 돌린 Gradle·.NET·
166
+ // RSpec·PHPUnit·Elixir·Swift 등)을, 외부 검수가 3건을 더 찾았다. 근본 이유 셋:
167
+ // (a) 직접 자백 표현이 두 언어에서 유계가 아니다("아직 테스트를 수행하지 않았습니다" 같은 어순 변형).
168
+ // (b) "검증 안 함" · "부분만 검증" · "해당 없음(문서 수정)" 을 정규식으로 가를 수 없다.
169
+ // (c) evidence 에 `Exit: 0` 한 줄만 적어도 반박된다 — 차단은 쉽게 우회되고, 그만큼 일관되지 못하다.
170
+ // 가끔 정직한 작업을 막고 자주 놓치는 게이트는 **명확한 권고보다 나쁘다**. 차단으로 올리려면
171
+ // 산문 파싱이 아니라 도구 자신의 실행 증거(`--run-tests` 결과)를 근거로 삼아야 한다 — 그건 별도 과제다.
172
+ // `unverified` 는 호출부가 done 을 보증할 때만 본다(기본 표면 동작 불변 약속을 지키기 위해).
173
+ if (claimedDone && unverified && !refuted) findings.push({ dim: 'unverified-admission', severity: 'medium', label: '미검증 자백', detail: '완료로 표시했으나 evidence 가 스스로 "아직 확인하지 않았다"고 말하며 실행 증거(테스트 결과·Exit·실행 흔적)가 없음 — 차단하지는 않되 재확인 권장.' });
103
174
  // 차원3: 정보 미수집 — 외부 API/라이브러리/버전/스펙 언급인데 수집·근거 흔적 없음
104
175
  // \bAPI\b(?!\.[a-z]) 로 파일경로(api.js/api.ts) 오탐 제외. 강한 근거(hasSource)나 수집 흔적(gathered) 있으면 통과.
105
176
  const externalRef = /(\bAPI\b(?!\.[a-z])|\bSDK\b|라이브러리|\blibrary\b|\bpackage\b|엔드포인트|\bendpoint\b|버전\s*\d|v\d+\.\d+|\bspec\b|rate\s*limit|레이트\s*리밋|문서에\s*따르면)/i.test(t);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.108",
3
+ "version": "1.36.110",
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
@@ -10358,6 +10358,177 @@ total++;
10358
10358
  if (!ok) failed++;
10359
10359
  }
10360
10360
 
10361
+ // ── 1.36.109 블록 M: 완료 게이트 — "done 에는 증거가 필요하다" 는 제품의 이름값이다.
10362
+ // 적대 사냥에서 두 방향으로 뚫려 있었다(대조군 2/2 통과 상태에서 측정 — 전부 거부하는 고장난 게이트가 아니었다):
10363
+ // · false-PASS: `touch src/x.js` 후 "src/x.js 구현 완료" 로 done 이 통과했다.
10364
+ // 공백 3자짜리 파일은 stub-impl 로 막히는데 **0바이트는 통과** — 더 비어 있을수록 쉽게 통과하는 역전이었다.
10365
+ // 원인은 `!body` 를 스킵 조건에 둔 것이고, 같은 falsy 함정이 호출부와 `_vcImplIsEmpty` **두 곳**에 있어
10366
+ // 한 곳만 고치면 구멍이 남았다(가드가 서로를 가리는 형태).
10367
+ // · false-BLOCK: `src/_helpers.js` 를 인용하면 추출기가 `helpers.js` 로 **잘라서** 없는 파일을 찾았다.
10368
+ // Next.js `pages/_app.tsx`, 파이썬 `__init__.py` 처럼 흔한 이름이 전부 여기 걸렸다. 이유 없이 막히면
10369
+ // 사용자는 게이트를 끈다 — false-BLOCK 이 false-PASS 보다 나쁘다.
10370
+ // 두 방향을 한 블록에서 함께 단언한다. 한쪽만 보면 "전부 거부" 나 "전부 통과" 로 도망갈 수 있다.
10371
+ {
10372
+ total++;
10373
+ let ok = false; const dbg = {};
10374
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-claim109-'));
10375
+ try {
10376
+ const ENV = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb });
10377
+ let seq = 0;
10378
+ const mk = (setup) => {
10379
+ const d = path.join(sb, 'p' + (++seq));
10380
+ fs.mkdirSync(d, { recursive: true });
10381
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
10382
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
10383
+ setup(d); return d;
10384
+ };
10385
+ const R = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
10386
+ const verdict = (d, evidence) => {
10387
+ const id = (String(R(d, ['task', 'add', '주장']).stdout || '').match(/T-\d{4,}/) || [])[0];
10388
+ R(d, ['task', 'update', id, '--status', 'done', '--evidence', evidence]);
10389
+ const r = R(d, ['verify-claim', id, '--json']);
10390
+ let j = null; try { j = JSON.parse(String(r.stdout || '')); } catch {}
10391
+ return { ok: j ? j.ok : null, reasons: j ? (j.reasons || []) : [] };
10392
+ };
10393
+ const W = (d, rel, body) => { const p = path.join(d, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, body); };
10394
+
10395
+ // ① 0바이트 = 스텁 (그리고 공백만도 여전히 스텁 — 둘이 같은 판정이어야 한다)
10396
+ const dEmpty = mk(d => W(d, 'src/empty.js', ''));
10397
+ const vEmpty = verdict(dEmpty, 'src/empty.js 구현 완료');
10398
+ const dBlank = mk(d => W(d, 'src/blank.js', ' \n\n '));
10399
+ const vBlank = verdict(dBlank, 'src/blank.js 구현 완료');
10400
+ dbg.emptyBlocked = vEmpty.ok === false && vEmpty.reasons.includes('stub-impl');
10401
+ dbg.blankBlocked = vBlank.ok === false && vBlank.reasons.includes('stub-impl');
10402
+
10403
+ // ② 대조군 — 실제 구현은 계속 통과해야 한다(게이트가 전부 거부하면 이 블록은 무의미하다)
10404
+ const dReal = mk(d => W(d, 'src/real.js', 'function f(){return 1;}\nmodule.exports={f};\n'));
10405
+ dbg.realPasses = verdict(dReal, 'src/real.js 구현 완료').ok === true;
10406
+
10407
+ // ③ 밑줄 시작 파일명이 잘리지 않는다 — 실존하는데 files-missing 이 나면 정직한 주장의 오차단
10408
+ const dUs = mk(d => W(d, 'src/_helpers.js', 'function h(){return 1;}\nmodule.exports={h};\n'));
10409
+ const vUs = verdict(dUs, 'src/_helpers.js 구현 완료');
10410
+ dbg.underscoreOk = vUs.ok === true && !vUs.reasons.includes('files-missing');
10411
+ const dNext = mk(d => W(d, 'pages/_app.tsx', 'export default function App(){ return null; }\n'));
10412
+ const vNext = verdict(dNext, 'pages/_app.tsx 수정 완료');
10413
+ dbg.nextjsOk = vNext.ok === true && !vNext.reasons.includes('files-missing');
10414
+ // 디렉토리 이름이 `_` 로 시작하는 경우도 같은 결함이다 — 경로 접두와 basename 은 별개 문자 클래스라
10415
+ // 한쪽만 고치면 나머지가 남는다(변이 시험에서 basename 만 되돌렸을 때 이 케이스는 통과했다).
10416
+ const dPriv = mk(d => W(d, '_private/util.ts', 'export function u(){ return 1; }\n'));
10417
+ const vPriv = verdict(dPriv, '_private/util.ts 정리 완료');
10418
+ dbg.privDirOk = vPriv.ok === true && !vPriv.reasons.includes('files-missing');
10419
+
10420
+ // ④ 관례상 빈 파일(__init__.py)은 스텁으로 막지 않는다 — 단, **다른 실질 파일과 함께일 때만**.
10421
+ const dPy = mk(d => { W(d, 'pkg/__init__.py', ''); W(d, 'pkg/core.py', 'def f():\n return 1\n'); });
10422
+ const vPy = verdict(dPy, 'pkg/__init__.py, pkg/core.py 구현 완료');
10423
+ dbg.initPyExempt = vPy.ok === true;
10424
+ // ④-b codex 검수 P1(재현됨): 마커 **단독**은 구현 증거가 아니다. 처음 판에서는 무조건 예외라
10425
+ // 빈 `__init__.py` 하나만 인용한 done 이 통과했고, 이 블록은 ④가 core.py 를 함께 인용한 탓에 못 잡았다.
10426
+ // 가드가 놓친 이유가 "조건을 안 만들어서" 였다 — 조건을 만든다.
10427
+ const dPyOnly = mk(d => W(d, 'pkg/__init__.py', ''));
10428
+ const vPyOnly = verdict(dPyOnly, 'pkg/__init__.py 구현 완료');
10429
+ dbg.markerOnlyBlocked = vPyOnly.ok === false && vPyOnly.reasons.includes('stub-impl');
10430
+ // ④-c 타입 스텁(PEP 561)도 같은 규칙 — 인식되어야 예외가 도달한다(안 그러면 광고만 하는 코드).
10431
+ const dPyi = mk(d => { W(d, 'pkg/__init__.pyi', ''); W(d, 'pkg/core.pyi', 'def f() -> int: ...\n'); });
10432
+ const vPyi = verdict(dPyi, 'pkg/__init__.pyi, pkg/core.pyi 타입 스텁 추가');
10433
+ dbg.pyiRecognized = vPyi.ok === true;
10434
+ // ⑤-b URL 조각 / 드라이브 상대 경로는 루트 상대 파일 주장이 아니다 — 실존 파일이 있어도 세탁되면 안 된다.
10435
+ const dFrag = mk(d => W(d, '_anchor.js', 'function a(){return 1;}\nmodule.exports={a};\n'));
10436
+ const vFrag = verdict(dFrag, 'https://example.test/docs#_anchor.js 검토 완료');
10437
+ dbg.urlFragBlocked = vFrag.ok === false;
10438
+ const dDrive = mk(d => W(d, '_anchor.js', 'function a(){return 1;}\nmodule.exports={a};\n'));
10439
+ const vDrive = verdict(dDrive, 'C:_anchor.js 검토 완료');
10440
+ dbg.driveRelBlocked = vDrive.ok === false;
10441
+ // 대조군 — 정상 표기 `파일:src/x.js` 는 계속 인식돼야 한다(`:` 를 통째로 막지 않았는지)
10442
+ const dColon = mk(d => W(d, 'src/colon.js', 'function c(){return 1;}\nmodule.exports={c};\n'));
10443
+ const vColon = verdict(dColon, '파일:src/colon.js 구현 완료');
10444
+ dbg.colonFormOk = vColon.ok === true;
10445
+
10446
+ // ⑤ 없는 파일은 여전히 막힌다(추출기를 넓혔다고 존재 검사가 느슨해지지 않았는지)
10447
+ const dGhost = mk(() => {});
10448
+ const vGhost = verdict(dGhost, 'src/_ghost.js 구현 완료');
10449
+ dbg.ghostStillBlocked = vGhost.ok === false && vGhost.reasons.includes('files-missing');
10450
+
10451
+ ok = dbg.emptyBlocked && dbg.blankBlocked && dbg.realPasses && dbg.underscoreOk
10452
+ && dbg.nextjsOk && dbg.privDirOk && dbg.initPyExempt && dbg.ghostStillBlocked
10453
+ && dbg.markerOnlyBlocked && dbg.pyiRecognized && dbg.urlFragBlocked && dbg.driveRelBlocked && dbg.colonFormOk;
10454
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
10455
+ console.log(ok ? '✓ M(1.36.109) 완료 게이트: 0바이트=스텁(공백과 동일 판정) + 밑줄 파일명 무손실 추출 + __init__.py 예외 + 실구현/없는파일 대조군'
10456
+ : '✗ 1.36.109 완료 게이트 실패 ' + JSON.stringify(dbg));
10457
+ if (!ok) failed++;
10458
+ }
10459
+
10460
+ // ── 1.36.110 블록 N (T-0099): 미검증 자백이 done 을 통과시키던 것.
10461
+ // 기준선 실측: 재현율 1/16(6%) — `should work, will test later` 는 잡고 같은 뜻의 한국어는 0/8 이었다.
10462
+ // 같은 뜻의 주장이 표기 언어에 따라 통과/차단이 갈리면 그건 판정이 아니라 우연이다.
10463
+ // 구조적 병목은 "산문에 완료 낱말이 있는가" 를 다시 요구한 것이었다 — verify-claim 은 이미 done 행에만
10464
+ // 이 검사를 돌린다. 호출부가 아는 사실(claimedDone)을 넘겨 중복 요구를 없앴다.
10465
+ // FP 를 먼저 고정했다: 실제 done 이력 139건 발화 0 · 홀드아웃 정직 주장 8/8 무탐지.
10466
+ {
10467
+ total++;
10468
+ let ok = false; const dbg = {};
10469
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-honest110-'));
10470
+ try {
10471
+ const ENV = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb });
10472
+ let seq = 0;
10473
+ const mk = (setup) => {
10474
+ const d = path.join(sb, 'p' + (++seq));
10475
+ fs.mkdirSync(d, { recursive: true });
10476
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
10477
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
10478
+ if (setup) setup(d); return d;
10479
+ };
10480
+ const R = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
10481
+ const W = (d, rel, body) => { const p = path.join(d, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, body); };
10482
+ const verdict = (d, ev, extra = []) => {
10483
+ const id = (String(R(d, ['task', 'add', '주장']).stdout || '').match(/T-\d{4,}/) || [])[0];
10484
+ R(d, ['task', 'update', id, '--status', 'done', '--evidence', ev]);
10485
+ const r = R(d, ['verify-claim', id, '--json', ...extra]);
10486
+ let j = null; try { j = JSON.parse(String(r.stdout || '')); } catch {}
10487
+ const adv = (j && j.claims && Array.isArray(j.claims.advisory)) ? j.claims.advisory : [];
10488
+ return { ok: j ? j.ok : null, reasons: j ? (j.reasons || []) : [], advisory: adv.map(a => a.dim), out: String(r.stdout || '') };
10489
+ };
10490
+ const impl = d => W(d, 'src/impl.js', 'function f(){return 1;}\nmodule.exports={f};\n');
10491
+
10492
+ // ① 미검증 자백 → **권고로 보고하되 차단하지 않는다**. ko/en 이 같은 판정이어야 한다(언어 대칭).
10493
+ // 차단하지 않기로 한 이유: 직접 자백 표현이 두 언어에서 유계가 아니고, "검증 안 함"과 "부분만 검증"과
10494
+ // "해당 없음(문서 수정)"을 정규식으로 가를 수 없으며, evidence 에 `Exit: 0` 한 줄이면 반박된다.
10495
+ // 자체 FP 헌트 6건 + 외부 검수 3건이 전부 그 경계에서 나왔다. 차단으로 올리려면 산문 파싱이 아니라
10496
+ // 도구 자신의 실행 증거(`--run-tests`)를 근거로 삼아야 한다(별도 과제).
10497
+ const vKo = verdict(mk(impl), 'src/impl.js 반영했는데 엣지 케이스는 안 봤습니다.');
10498
+ const vEn = verdict(mk(impl), 'src/impl.js applied; edge cases unchecked.');
10499
+ dbg.koAdvised = vKo.advisory.includes('unverified-admission');
10500
+ dbg.enAdvised = vEn.advisory.includes('unverified-admission');
10501
+ dbg.symmetric = dbg.koAdvised === dbg.enAdvised;
10502
+ dbg.notBlocking = vKo.ok === true && vEn.ok === true; // 권고는 게이팅하지 않는다
10503
+ dbg.visibleToUser = /권고|advisory/i.test(vKo.out); // 안 보이면 탐지한 의미가 없다
10504
+
10505
+ // ② 대조군 — 근거를 댄 정직한 주장은 권고조차 붙지 않는다.
10506
+ const vHonest = verdict(mk(impl), 'src/impl.js 구현. 테스트 12/12 통과 (Exit: 0).');
10507
+ dbg.honestClean = vHonest.ok === true && vHonest.advisory.length === 0;
10508
+ const vHedgedButSourced = verdict(mk(impl), 'src/impl.js 구현. 20/20 passed. 성능 개선 효과는 추후 측정 예정.');
10509
+ dbg.sourcedHedgeClean = vHedgedButSourced.ok === true && vHedgedButSourced.advisory.length === 0;
10510
+ // "검증 필요 없는" 은 반대 의미 · 미래 범위 표현은 의도적으로 잡지 않는다
10511
+ const vNegated = verdict(mk(impl), 'src/impl.js 오타 수정만. 검증 필요 없는 주석 변경. 12/12 통과.');
10512
+ dbg.negationClean = vNegated.ok === true && vNegated.advisory.length === 0;
10513
+ const vFuture = verdict(mk(impl), 'src/impl.js 설정값만 변경. 호환성은 추후 확인.');
10514
+ dbg.futureScopeClean = vFuture.ok === true && vFuture.advisory.length === 0;
10515
+ // 러너 이름을 열거하지 않는다 — 실행했는데 권고가 붙으면 사용자에게 잘못된 신호다
10516
+ const vGradle = verdict(mk(impl), 'src/impl.js 구현. ./gradlew test 통과. 엣지 케이스는 안 봤습니다.');
10517
+ dbg.unlistedRunnerClean = vGradle.ok === true && vGradle.advisory.length === 0;
10518
+
10519
+ // ③ 기존 차단 동작은 그대로 — 이 라운드가 게이팅을 바꾸지 않았다는 것을 고정한다.
10520
+ const vStub = verdict(mk(d => W(d, 'src/empty.js', '')), 'src/empty.js 구현 완료');
10521
+ dbg.existingGateIntact = vStub.ok === false && vStub.reasons.includes('stub-impl');
10522
+
10523
+ ok = dbg.koAdvised && dbg.enAdvised && dbg.symmetric && dbg.notBlocking && dbg.visibleToUser
10524
+ && dbg.honestClean && dbg.sourcedHedgeClean && dbg.negationClean && dbg.futureScopeClean
10525
+ && dbg.unlistedRunnerClean && dbg.existingGateIntact;
10526
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
10527
+ console.log(ok ? '✓ N(1.36.110/T-0099) 미검증 자백을 **권고로** 보고(차단 아님) · ko/en 동일 · 사용자에게 노출 · 정직 주장·미래범위·목록밖 러너 무탐 · 기존 게이팅 불변'
10528
+ : '✗ 1.36.110 정직성 판정 실패 ' + JSON.stringify(dbg));
10529
+ if (!ok) failed++;
10530
+ }
10531
+
10361
10532
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
10362
10533
  if (failed > 0) process.exit(1);
10363
10534