leerness 1.36.124 → 1.36.125
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 +4 -4
- package/bin/leerness.js +212 -46
- package/package.json +1 -1
- package/scripts/e2e.js +116 -8
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.
|
|
125
|
+
이 프로젝트는 Leerness v1.36.125 하네스를 사용합니다. 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.
|
|
179
|
+
Leerness v1.36.125는 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.124는 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.
|
|
200
|
+
현재 누적: **v1.9.x → 1.36.125 릴리스 태그 이력** (수백 라운드) · _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.
|
|
238
|
+
Last synced by Leerness v1.36.125: 2026-08-16
|
|
239
239
|
<!-- leerness:project-readme:end -->
|
package/bin/leerness.js
CHANGED
|
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
|
|
|
34
34
|
const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
|
|
35
35
|
const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
|
|
36
36
|
|
|
37
|
-
const VERSION = '1.36.
|
|
37
|
+
const VERSION = '1.36.125';
|
|
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') 시 호스트 프로세스 오염.
|
|
@@ -4920,7 +4920,12 @@ function _selfTestCases() {
|
|
|
4920
4920
|
const sigOpts = /function verifyClaimCmd\(root, taskId, opts = \{\}\)/.test(src);
|
|
4921
4921
|
const fn = /function verifyClaimAllCmd\(root\)/.test(src);
|
|
4922
4922
|
const doneFilter = src.includes("rows.filter(r => /done|완료|completed/i.test(String(r.status || '')))");
|
|
4923
|
-
|
|
4923
|
+
// 1.36.125: 여기에 `runMemo` 가 함께 넘어가는 것도 이제 **계약**이다 — 빠지면 같은 테스트 스위트를
|
|
4924
|
+
// done 행 수만큼 반복 실행한다(100주장 × 300초). 그래서 리터럴을 갱신만 하지 않고 메모 배선까지 건다.
|
|
4925
|
+
// ⚠ 실행 횟수 자체는 소스가 아니라 e2e U 블록이 **실제로 세어** 판정한다(소스가드는 배선 유무만).
|
|
4926
|
+
const reuse = src.includes("doneRows.map(r => verifyClaimCmd(root, r.id, { collect: true, runMemo }))")
|
|
4927
|
+
&& src.includes('const runMemo = {};')
|
|
4928
|
+
&& src.includes('if (_runMemo) _runMemo.result = runResult;');
|
|
4924
4929
|
// collect 게이팅 부울이 --json/human 경로와 동일 출처(분기 없음) — 정밀 검사를 일괄 모드에서도 그대로 재사용
|
|
4925
4930
|
const collectGate = src.includes('if (opts.collect) {') && src.includes("if (!filesAllExist) reasons.push('files-missing')") && src.includes("if (claimsChecked && !strictOk) reasons.push('optimism/honesty')") && src.includes("if (!gitClaimOk) reasons.push('git-mismatch')") && src.includes("if (claimsChecked && stubFiles.length > 0) reasons.push('stub-impl')");
|
|
4926
4931
|
const routed = src.includes("if (args[1] === '--all' || has('--all')) return verifyClaimAllCmd(_p)");
|
|
@@ -6633,10 +6638,116 @@ function _selfTestCases() {
|
|
|
6633
6638
|
&& s.includes('const lang = detectLanguageValue(root, ') && s.includes("managedMerge(key, tmpl, cur, null, { lang, root })") // 1.36.124: 대상 루트 전달이 계약이다(검수 P1);
|
|
6634
6639
|
return f13ok && skipOk && f7ok && f11ok && guards;
|
|
6635
6640
|
} finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
|
|
6636
|
-
} }
|
|
6641
|
+
} },
|
|
6642
|
+
// 1.36.125 (검수 P2 클래스 스윕): MCP 호출 예산은 **그 명령이 스스로 거는 내부 타임아웃보다 커야** 한다.
|
|
6643
|
+
// 1.36.124 는 `selftest` 이름 하나만 구제했고, 그 사이 verify-claim --run-tests(내부 300초)·
|
|
6644
|
+
// referee verify(600초)·api-skill add(최대 110초)는 60초에 잘려 **정상 동작을 거짓 실패로** 보고했다.
|
|
6645
|
+
// 이 가드는 소스 문자열이 아니라 **실제 `_mcpTimeoutFor()` 호출**로 판정한다(문자열 가드는 표가 바뀌면 조용히 죽는다).
|
|
6646
|
+
// 그리고 내부 한도는 손으로 적은 숫자가 아니라 **해당 파일에서 실제로 읽어** 대조한다 — 내부 한도를
|
|
6647
|
+
// 올리고 표를 안 고치면 여기서 깨진다(허용목록이 소비자보다 뒤처지는 것을 막는 유일한 기제).
|
|
6648
|
+
{ name: '1.36.125 (검수 P2): MCP 긴 예산 표 — 실제 예산 > 각 경로의 내부 타임아웃 + 대조군(무관 명령은 기본값)', run: () => {
|
|
6649
|
+
if (!Array.isArray(MCP_LONG_PATHS) || MCP_LONG_PATHS.length < 5) return false;
|
|
6650
|
+
const saved = process.env.LEERNESS_MCP_TIMEOUT_MS;
|
|
6651
|
+
delete process.env.LEERNESS_MCP_TIMEOUT_MS; // override 가 켜져 있으면 표를 재는 것이 아니다
|
|
6652
|
+
try {
|
|
6653
|
+
// ① 표의 각 경로가 실제로 그 예산을 받는가 + 예산이 내부 한도를 넘는가
|
|
6654
|
+
const probes = {
|
|
6655
|
+
'selftest': ['selftest', '--json'],
|
|
6656
|
+
'task-done-probe': ['task', 'update', 'T-1', '--status', 'done'],
|
|
6657
|
+
'verify-claim-run-tests': ['verify-claim', 'T-1', '--path', '.', '--run-tests'],
|
|
6658
|
+
'referee-verify': ['referee', 'verify', 'r1', '--path', '.', '--json'],
|
|
6659
|
+
'api-skill-add': ['api-skill', 'add', '--path', '.', '--url', 'https://example.com', '--json'],
|
|
6660
|
+
};
|
|
6661
|
+
for (const p of MCP_LONG_PATHS) {
|
|
6662
|
+
const args = probes[p.id];
|
|
6663
|
+
if (!args) return false; // 표에 새 항목이 생기면 프로브도 함께 적어야 한다
|
|
6664
|
+
const got = _mcpTimeoutFor(args);
|
|
6665
|
+
if (got !== p.budgetMs) return false; // 술어가 그 인자를 실제로 집어내는가
|
|
6666
|
+
if (!(got > p.mustExceedMs)) return false; // 예산이 내부 한도를 넘는가
|
|
6667
|
+
if (p.src) { // 내부 한도를 **소스에서 실제로 읽어** 대조
|
|
6668
|
+
const raw = read(path.join(__dirname, '..', p.src.file));
|
|
6669
|
+
const m = p.src.re.exec(raw);
|
|
6670
|
+
if (!m) return false; // 리터럴을 못 찾으면 공허통과가 아니라 실패
|
|
6671
|
+
const actual = Number(m[1]) * (p.src.scale || 1);
|
|
6672
|
+
if (!Number.isFinite(actual) || actual !== p.mustExceedMs) return false; // 소스가 바뀌면 표도 바뀌어야
|
|
6673
|
+
if (!(got > actual)) return false;
|
|
6674
|
+
}
|
|
6675
|
+
}
|
|
6676
|
+
// ② 대조군 — 무관한 명령은 기본값을 받는다(긴 예산이 옆으로 새면 서버가 11분씩 얼어붙는다)
|
|
6677
|
+
const ctrl = [['health', '--json'], ['task', 'list'], ['task', 'update', 'T-1', '--status', 'in_progress'],
|
|
6678
|
+
['verify-claim', 'T-1', '--path', '.'], ['referee', 'list', '--path', '.'], ['api-skill', 'list', '--path', '.']];
|
|
6679
|
+
if (!ctrl.every(a => _mcpTimeoutFor(a) === MCP_TIMEOUT_DEFAULT_MS)) return false;
|
|
6680
|
+
// ③ 타임아웃 사유 한 줄은 **그 호출에만** 붙고, 다른 경로의 사유가 새지 않는다
|
|
6681
|
+
const hintSelf = _mcpTimeoutHint(['selftest', '--json']);
|
|
6682
|
+
if (!/긴 예산\(300초\)/.test(hintSelf) || !hintSelf.includes('실측 116초')) return false;
|
|
6683
|
+
if (/실측 116초/.test(_mcpTimeoutHint(['referee', 'verify', 'r1']))) return false; // 옆 경로 사유가 새면 실패
|
|
6684
|
+
if (_mcpTimeoutHint(['health', '--json']) !== '') return false; // 무관한 도구엔 아무 사유도 없다
|
|
6685
|
+
// ④ 명시 override 는 여전히 최우선이고, 그때는 **표의 사유를 말하지 않는다**(5초에 잘린 사용자에게
|
|
6686
|
+
// "긴 예산 300초를 받습니다" 라고 하면 거짓말이다 — 1.36.125 에서 실제로 그렇게 쓸 뻔했다)
|
|
6687
|
+
process.env.LEERNESS_MCP_TIMEOUT_MS = '7000';
|
|
6688
|
+
const overridden = _mcpTimeoutFor(['selftest', '--json']) === 7000 && _mcpTimeoutFor(['health']) === 7000
|
|
6689
|
+
&& _mcpTimeoutHint(['selftest', '--json']) === '';
|
|
6690
|
+
delete process.env.LEERNESS_MCP_TIMEOUT_MS;
|
|
6691
|
+
return overridden;
|
|
6692
|
+
} finally { if (saved !== undefined) process.env.LEERNESS_MCP_TIMEOUT_MS = saved; else delete process.env.LEERNESS_MCP_TIMEOUT_MS; }
|
|
6693
|
+
} },
|
|
6694
|
+
// 1.36.125: 측정 통제 자체의 가드 — 목록이 비거나 배선이 끊기면 selftest 는 다시 사용자 설정에 휘둘린다.
|
|
6695
|
+
{ name: '1.36.125 (검수 P2): selftest 측정 통제 — 통제 목록 실재 + 실제 삭제 + 무시 사실 보고(조용한 무시 금지)', run: () => {
|
|
6696
|
+
const must = ['LEERNESS_MCP_PROFILE', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG', 'LEERNESS_MCP_TIMEOUT_MS'];
|
|
6697
|
+
if (!must.every(k => _SELFTEST_CONTROLLED_ENV.includes(k))) return false;
|
|
6698
|
+
// 하네스가 일부러 켜는 것은 통제 목록에 들어가면 안 된다(측정 계약을 스스로 지우는 셈)
|
|
6699
|
+
if (['LEERNESS_OFFLINE', 'LEERNESS_INTERNAL'].some(k => _SELFTEST_CONTROLLED_ENV.includes(k))) return false;
|
|
6700
|
+
const savedP = process.env.LEERNESS_MCP_PROFILE, savedL = process.env.LEERNESS_LANG;
|
|
6701
|
+
try {
|
|
6702
|
+
process.env.LEERNESS_MCP_PROFILE = 'core'; process.env.LEERNESS_LANG = 'en';
|
|
6703
|
+
const dropped = _controlSelftestEnv();
|
|
6704
|
+
const gone = process.env.LEERNESS_MCP_PROFILE === undefined && process.env.LEERNESS_LANG === undefined;
|
|
6705
|
+
const told = dropped.some(s => s.startsWith('LEERNESS_MCP_PROFILE=')) && dropped.some(s => s.startsWith('LEERNESS_LANG='));
|
|
6706
|
+
// 검수 P1: "검증 중에만" 이라고 말했으면 **끝나면 되돌려 놔야** 한다(export 된 함수라 호출자 설정이 영구히 사라졌다)
|
|
6707
|
+
dropped.restore();
|
|
6708
|
+
const restored = process.env.LEERNESS_MCP_PROFILE === 'core' && process.env.LEERNESS_LANG === 'en';
|
|
6709
|
+
// 대조군 — 설정이 없으면 아무것도 보고하지 않는다(빈 컬렉션 공허참으로 통과하지 않게 위에서 실제 값을 넣었다)
|
|
6710
|
+
delete process.env.LEERNESS_MCP_PROFILE; delete process.env.LEERNESS_LANG;
|
|
6711
|
+
const quiet = _controlSelftestEnv().length === 0;
|
|
6712
|
+
return gone && told && restored && quiet;
|
|
6713
|
+
} finally {
|
|
6714
|
+
if (savedP !== undefined) process.env.LEERNESS_MCP_PROFILE = savedP; else delete process.env.LEERNESS_MCP_PROFILE;
|
|
6715
|
+
if (savedL !== undefined) process.env.LEERNESS_LANG = savedL; else delete process.env.LEERNESS_LANG;
|
|
6716
|
+
}
|
|
6717
|
+
} },
|
|
6637
6718
|
];
|
|
6638
6719
|
}
|
|
6720
|
+
// 1.36.125 (검수 P2 + 클래스 스윕 실측): selftest 는 **자기가 재는 환경을 스스로 통제해야** 한다.
|
|
6721
|
+
// 실측(대조군 무설정 343/343 통과) — 공개 설정 하나를 켜 두기만 해도 설치 검증 도구가 거짓 실패를 냈다:
|
|
6722
|
+
// LEERNESS_MCP_PROFILE=core → 342/343 (프로브가 프로필을 상속해 없는 도구를 호출)
|
|
6723
|
+
// LEERNESS_WORKSPACE_DIR=.leerness → 339/343 + stderr 722B ("미초기화 디렉토리" 오판)
|
|
6724
|
+
// LEERNESS_LANG=en → 341/343 (픽스처가 정한 언어를 env 가 덮어써 ko 기대값이 어긋남)
|
|
6725
|
+
// 즉 "설치가 손상됐다" 는 보고가 사실은 **사용자 설정 때문**이었다 — 진단 도구가 낼 수 있는 최악의 거짓말이다.
|
|
6726
|
+
// 같은 방식으로 잰 AUTONOMOUS·ENFORCE_POLICY·HOOK·SKILL_AUTO_INSTALL·TZ 5종은 **343/343 무해**여서 넣지 않는다
|
|
6727
|
+
// (손 분류로 목록을 부풀리지 않는다 — 깨지는 것만 넣고, 안 깨지는 것은 안 깨진다고 적는다).
|
|
6728
|
+
// 1.36.124 는 이 클래스를 프로브 2개에만(_probeEnv) 적용했다. 프로브마다 지우면 새 프로브가 빠지므로
|
|
6729
|
+
// 병목에서 한 번 지운다 — 여기서 지우면 이 프로세스가 띄우는 **모든 자식**이 통제된 환경을 물려받는다.
|
|
6730
|
+
// ⚠ LEERNESS_OFFLINE / LEERNESS_INTERNAL 등 하네스가 **일부러 켜는** 것은 지우지 않는다(측정 계약의 일부).
|
|
6731
|
+
// ⚠ LANG 을 지워도 영어 커버리지는 죽지 않는다 — en 경로는 `_selftestFixture('en')`(init --language en)이
|
|
6732
|
+
// 따로 검증한다. 지우는 목적은 "언어의 출처를 픽스처 하나로 되돌리는" 것이지 en 을 안 보는 것이 아니다.
|
|
6733
|
+
const _SELFTEST_CONTROLLED_ENV = ['LEERNESS_MCP_PROFILE', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_MCP_TIMEOUT_MS', 'LEERNESS_LANG'];
|
|
6734
|
+
function _controlSelftestEnv() {
|
|
6735
|
+
const dropped = [], saved = {};
|
|
6736
|
+
for (const k of _SELFTEST_CONTROLLED_ENV) {
|
|
6737
|
+
if (process.env[k] !== undefined) { dropped.push(`${k}=${process.env[k]}`); saved[k] = process.env[k]; delete process.env[k]; }
|
|
6738
|
+
}
|
|
6739
|
+
// 1.36.125 (검수 P1): `selfTestCmd` 는 **export 된 라이브러리 함수**다. CLI/MCP 는 자식이 끝나며 격리되지만
|
|
6740
|
+
// 직접 호출하면 호출자 프로세스의 설정이 **영구히** 사라진다(실측: en/.leerness → undefined).
|
|
6741
|
+
// 게다가 우리 출력은 "검증 중에만 무시" 라고 말한다 — 복원하지 않으면 그 안내 자체가 거짓이 된다.
|
|
6742
|
+
const restore = () => { for (const k of Object.keys(saved)) process.env[k] = saved[k]; };
|
|
6743
|
+
// 조용히 지우지 않는다 — 무엇을 무시했는지 사람과 JSON 소비자 모두에게 말한다.
|
|
6744
|
+
return Object.assign(dropped, { restore });
|
|
6745
|
+
}
|
|
6639
6746
|
function selfTestCmd(opts = {}) {
|
|
6747
|
+
const droppedEnv = _controlSelftestEnv();
|
|
6748
|
+
try { return _selfTestCmdInner(opts, droppedEnv); } finally { droppedEnv.restore(); }
|
|
6749
|
+
}
|
|
6750
|
+
function _selfTestCmdInner(opts, droppedEnv) {
|
|
6640
6751
|
const isTty = process.stdout && process.stdout.isTTY;
|
|
6641
6752
|
const gr = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
|
|
6642
6753
|
const rd = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
|
|
@@ -6651,12 +6762,18 @@ function selfTestCmd(opts = {}) {
|
|
|
6651
6762
|
const pass = results.filter(r => r.ok).length;
|
|
6652
6763
|
const fail = results.length - pass;
|
|
6653
6764
|
if (opts.json) {
|
|
6654
|
-
log(JSON.stringify({ version: VERSION, total: results.length, pass, fail, ok: fail === 0, results }, null, 2));
|
|
6765
|
+
log(JSON.stringify({ version: VERSION, total: results.length, pass, fail, ok: fail === 0, controlledEnv: droppedEnv, results }, null, 2));
|
|
6655
6766
|
if (fail > 0) process.exitCode = 1;
|
|
6656
6767
|
return;
|
|
6657
6768
|
}
|
|
6658
6769
|
log(cy(`# leerness selftest (1.9.258) — 코어 함수 무결성 검증 (v${VERSION})`));
|
|
6659
6770
|
log('');
|
|
6771
|
+
// 무시한 설정은 반드시 보인다 — 사용자가 켜 둔 것이 안 먹었다는 사실을 숨기면 그 자체가 거짓 보고다(1.36.125).
|
|
6772
|
+
if (droppedEnv.length) {
|
|
6773
|
+
log(dm(` ⓘ 측정 통제: 검증 중에만 ${droppedEnv.length}개 환경설정을 무시합니다 — ${droppedEnv.join(', ')}`));
|
|
6774
|
+
log(dm(` (이 설정들이 켜져 있으면 selftest 가 "설치 손상" 을 거짓 보고했습니다 — 실제 제품 동작은 그대로입니다)`));
|
|
6775
|
+
log('');
|
|
6776
|
+
}
|
|
6660
6777
|
results.forEach(r => {
|
|
6661
6778
|
if (r.ok) log(` ${gr('✓')} ${r.name}`);
|
|
6662
6779
|
else log(` ${rd('✗')} ${r.name}${r.err ? dm(' — ' + r.err) : ''}`);
|
|
@@ -8339,9 +8456,14 @@ function _p0097AsyncLockOk() {
|
|
|
8339
8456
|
finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch {} }
|
|
8340
8457
|
}
|
|
8341
8458
|
// 1.36.124 (살아 있는 MCP 통신 실측): selftest 프로브가 `process.env` 를 그대로 상속했다 —
|
|
8342
|
-
// MCP 래퍼는
|
|
8343
|
-
// "
|
|
8459
|
+
// MCP 래퍼는 자식을 띄울 때 안내·점검 계열 변수를 **일부러 켜 두는데**, 그 환경에서 도는 프로브가
|
|
8460
|
+
// "그 동작이 살아 있다" 를 단언하다 MCP 경유에서만 실패했다. 그 결과 `leerness_selftest` 가
|
|
8344
8461
|
// AI 에게 "설치가 깨졌다" 고 **거짓 보고**했다(343 중 1 실패). 프로브는 억제 변수를 걷어낸 환경에서 돈다.
|
|
8462
|
+
// 1.36.125 (검수 P3, 주석 정정): 직접 원인은 workflow guide 의 stdout 이 아니다 — reminder 쪽은
|
|
8463
|
+
// `LEERNESS_NO_DRIFT_CHECK`, usage 쪽은 `LEERNESS_INTERNAL` 이었다. 원인을 잘못 적어 두면 다음 회귀
|
|
8464
|
+
// 분석이 엉뚱한 곳을 판다. 목록 전체를 걷어내는 처방 자체는 그대로 유효하다.
|
|
8465
|
+
// ⚠ 이 목록은 **프로브 단위** 처방이다. 프로필·워크스페이스·언어처럼 제품 의미를 바꾸는 설정은
|
|
8466
|
+
// 프로브마다 지우면 새 프로브가 빠지므로 selftest 진입점에서 통제한다(_SELFTEST_CONTROLLED_ENV).
|
|
8345
8467
|
const _PROBE_SUPPRESS_ENV = ['LEERNESS_INTERNAL', 'LEERNESS_NO_BANNER', 'LEERNESS_NO_PROMPT',
|
|
8346
8468
|
'LEERNESS_NO_WORKFLOW_GUIDE', 'LEERNESS_NO_STALE_CHECK', 'LEERNESS_NO_DRIFT_CHECK',
|
|
8347
8469
|
'LEERNESS_NO_HEADLINE', 'LEERNESS_NO_LAZY_WARN'];
|
|
@@ -15674,7 +15796,17 @@ function verifyClaimCmd(root, taskId, opts = {}) {
|
|
|
15674
15796
|
// 이전엔 npm test 하드코딩 → 비-JS(파이썬 등) 프로젝트에서 npm init 잔재 placeholder("no test specified"&&exit 1)가 실행돼
|
|
15675
15797
|
// 테스트 전부 통과한 작업을 "주장 불일치 FAIL"로 오판(5축 클린룸 실증 P1). placeholder 는 테스트가 아니므로 skip 처리.
|
|
15676
15798
|
let runResult = null;
|
|
15677
|
-
|
|
15799
|
+
// 1.36.125 (검수 P2): `verify-claim --all --run-tests` 는 done 행마다 verifyClaimCmd 를 부르고
|
|
15800
|
+
// `--run-tests` 는 전역 플래그다 → **같은 테스트 스위트를 task 수만큼 반복 실행**했다.
|
|
15801
|
+
// 한 번의 호출 안에서 같은 root·같은 명령의 결과는 정의상 동일하다(주장별로 달라질 수 없다).
|
|
15802
|
+
// 그래서 --all 스윕이 넘겨준 메모를 재사용한다. 효과는 두 가지 —
|
|
15803
|
+
// ① N×300초가 1×300초가 되어 MCP 예산(660초) 안에 들어온다(거짓 타임아웃 제거)
|
|
15804
|
+
// ② 우리 자신처럼 스위트가 79분인 프로젝트에서 N번 도는 낭비가 사라진다.
|
|
15805
|
+
// ⚠ 메모는 **호출자가 명시로 넘길 때만** 쓴다(모듈 전역 캐시로 두면 장수 프로세스에서 낡은 결과를 재사용한다).
|
|
15806
|
+
const _runMemo = opts && opts.runMemo && typeof opts.runMemo === 'object' ? opts.runMemo : null;
|
|
15807
|
+
if (has('--run-tests') && _runMemo && Object.prototype.hasOwnProperty.call(_runMemo, 'result')) {
|
|
15808
|
+
runResult = _runMemo.result;
|
|
15809
|
+
} else if (has('--run-tests')) {
|
|
15678
15810
|
let testCmd = arg('--test-cmd', null);
|
|
15679
15811
|
if (!testCmd) {
|
|
15680
15812
|
try { const cfg = JSON.parse(read(path.join(root, '.harness', 'leerness-config.json'))); if (cfg && typeof cfg.testCommand === 'string' && cfg.testCommand.trim()) testCmd = cfg.testCommand.trim(); } catch {}
|
|
@@ -15713,6 +15845,8 @@ function verifyClaimCmd(root, taskId, opts = {}) {
|
|
|
15713
15845
|
}
|
|
15714
15846
|
}
|
|
15715
15847
|
}
|
|
15848
|
+
// 이 호출에서 실제로 실행한 결과를 메모에 남긴다 — 다음 주장부터는 재실행하지 않는다(검수 P2).
|
|
15849
|
+
if (_runMemo) _runMemo.result = runResult;
|
|
15716
15850
|
}
|
|
15717
15851
|
|
|
15718
15852
|
// 1.9.309 (UR-0048, 설치리뷰 Opus critical): done 주장은 evidence(파일+테스트) 기본 강제 — 거짓완료 차단을 기본값·MCP·json 모두에서 작동.
|
|
@@ -16020,7 +16154,10 @@ function _verifyClaimsAll(root) {
|
|
|
16020
16154
|
root = absRoot(root);
|
|
16021
16155
|
const rows = readProgressRows(root);
|
|
16022
16156
|
const doneRows = rows.filter(r => /done|완료|completed/i.test(String(r.status || '')));
|
|
16023
|
-
|
|
16157
|
+
// 1.36.125 (검수 P2): `--run-tests` 는 전역 플래그라 종전엔 스위트를 done 행 수만큼 반복 실행했다
|
|
16158
|
+
// (100개 주장 × 300초 = 8시간 — MCP 예산으로는 어떤 값도 못 덮는다). 한 스윕 = 한 번 실행.
|
|
16159
|
+
const runMemo = {};
|
|
16160
|
+
const results = doneRows.map(r => verifyClaimCmd(root, r.id, { collect: true, runMemo }));
|
|
16024
16161
|
const failed = results.filter(x => x && !x.ok);
|
|
16025
16162
|
return { ok: failed.length === 0, total: doneRows.length, failed: failed.length, results };
|
|
16026
16163
|
}
|
|
@@ -22017,6 +22154,68 @@ function _mcpToCliArgs(name, args, targetPath) {
|
|
|
22017
22154
|
}
|
|
22018
22155
|
|
|
22019
22156
|
// 프로토콜: MCP 표준 (JSON-RPC 2.0). 메서드: initialize, tools/list, tools/call
|
|
22157
|
+
// 1.36.93 (헌트 #8): 래퍼 타임아웃(60s)이 bugfix probe 예산(600s)보다 짧아, 버그를 **진짜 고치고**
|
|
22158
|
+
// 영수증까지 채운 사용자가 MCP 표면에서는 done 을 영영 기록하지 못했다. 게다가 stdout 이 비어 있어
|
|
22159
|
+
// 응답이 `(no output)` 뿐 — 차단 사유도, 타임아웃이라는 사실도 전달되지 않았다(진단 단서 0).
|
|
22160
|
+
// ① 기본값을 probe 예산 위로 올리고 ② env 로 조절 가능하게 하며 ③ 타임아웃을 **사실대로** 보고한다.
|
|
22161
|
+
// (MCP 클라이언트 자체 타임아웃은 leerness 가 어쩌지 못한다 — 그래서 메시지에 CLI 대안을 함께 준다.)
|
|
22162
|
+
// codex 33차 #2: 한도를 **전역으로** 올리면 `spawnSync` 가 단일 스레드 서버를 그만큼 얼린다 —
|
|
22163
|
+
// 무관한 도구가 멎어도 11분간 ping·취소 통지조차 못 읽는다. 그래서 긴 예산은 그 예산이 필요한 호출에만 준다.
|
|
22164
|
+
const MCP_TIMEOUT_DEFAULT_MS = 60000;
|
|
22165
|
+
const MCP_TIMEOUT_PROBE_MS = 660000; // 제품 내부 최대 한도(600s) 위
|
|
22166
|
+
const MCP_TIMEOUT_LONG_MS = 300000;
|
|
22167
|
+
function _mcpTimeoutOverrideMs() {
|
|
22168
|
+
const v = parseInt(process.env.LEERNESS_MCP_TIMEOUT_MS || '', 10);
|
|
22169
|
+
return (Number.isFinite(v) && v >= 5000 && v <= 3600000) ? v : null;
|
|
22170
|
+
}
|
|
22171
|
+
// 1.36.125 (검수 P2 · 클래스 스윕): 1.36.124 는 `selftest` **한 이름만** 예산에서 구제했다 — 전형적인
|
|
22172
|
+
// "가드는 자기가 밟은 경로만 지킨다". 60초를 넘길 수 있는 경로는 그것만이 아니고, 판별 기준도 이름이 아니라
|
|
22173
|
+
// **그 명령이 스스로 거는 내부 타임아웃**이다. 예산이 내부 한도보다 짧으면 정상 동작을 잘라 놓고
|
|
22174
|
+
// AI 에게 거짓 실패를 보고한다(1.36.124 와 같은 클래스). 그래서 술어를 흩뿌리지 않고 한 표로 모으고,
|
|
22175
|
+
// 각 항목이 반드시 넘어야 할 내부 한도(mustExceedMs)를 함께 적는다 — selftest 가 그 숫자를 **소스에서
|
|
22176
|
+
// 실제로 읽어** `budget > 내부한도` 를 단언하므로, 내부 한도를 올리고 표를 안 고치면 게이트가 깨진다.
|
|
22177
|
+
// ⚠ 남는 한계(정직하게): referee verify 는 good/bad/check 세 번을 각각 내부 한도까지 쓸 수 있어
|
|
22178
|
+
// 최악(3×600초)은 어떤 예산으로도 못 덮는다 — 그 경우의 탈출구는 타임아웃 메시지가 안내하는
|
|
22179
|
+
// `LEERNESS_MCP_TIMEOUT_MS` 다. 예산을 3배로 키우면 이번엔 서버가 30분간 얼어붙어 더 나쁘다.
|
|
22180
|
+
const MCP_LONG_PATHS = [
|
|
22181
|
+
{ id: 'selftest', budgetMs: MCP_TIMEOUT_LONG_MS, mustExceedMs: 116000,
|
|
22182
|
+
why: '실측 116초(1.36.124) — 소스 리터럴이 아니라 측정값',
|
|
22183
|
+
match: (a) => a[0] === 'selftest' },
|
|
22184
|
+
// bugfix probe 가 도는 경로 — done 전이(task update --status done)와 TodoWrite 왕복(task sync).
|
|
22185
|
+
{ id: 'task-done-probe', budgetMs: MCP_TIMEOUT_PROBE_MS, mustExceedMs: 600000,
|
|
22186
|
+
why: 'lib/bugfix.js 의 probe 실행 한도', src: { file: 'lib/bugfix.js', re: /shell: true, encoding: 'utf8', timeout: (\d+)/ },
|
|
22187
|
+
match: (a) => a[0] === 'task' && (a[1] === 'sync'
|
|
22188
|
+
|| (a[1] === 'update' && a.includes('--status') && ['done', 'completed', 'verified'].includes(a[a.indexOf('--status') + 1]))) },
|
|
22189
|
+
{ id: 'verify-claim-run-tests', budgetMs: MCP_TIMEOUT_PROBE_MS, mustExceedMs: 300000,
|
|
22190
|
+
why: '프로젝트 테스트 실행 한도(runCommandSafe kind:verify_claim_test)',
|
|
22191
|
+
src: { file: 'bin/leerness.js', re: /timeout: (\d+) \* 60 \* 1000, kind: 'verify_claim_test'/, scale: 60000 },
|
|
22192
|
+
match: (a) => a[0] === 'verify-claim' && a.includes('--run-tests') },
|
|
22193
|
+
{ id: 'referee-verify', budgetMs: MCP_TIMEOUT_PROBE_MS, mustExceedMs: 600000,
|
|
22194
|
+
why: 'lib/referee.js calibrate 기본 한도(good/bad/check 각각)',
|
|
22195
|
+
src: { file: 'lib/referee.js', re: /Number\(opts\.timeoutMs\) \|\| (\d+)/ },
|
|
22196
|
+
match: (a) => a[0] === 'referee' && a[1] === 'verify' },
|
|
22197
|
+
{ id: 'api-skill-add', budgetMs: MCP_TIMEOUT_LONG_MS, mustExceedMs: 110000,
|
|
22198
|
+
why: '본문 1 + same-domain 링크 최대 10 요청 × 요청당 10초',
|
|
22199
|
+
match: (a) => a[0] === 'api-skill' && a[1] === 'add' },
|
|
22200
|
+
];
|
|
22201
|
+
function _mcpLongPathFor(cliArgs) {
|
|
22202
|
+
const a = (cliArgs || []).map(String);
|
|
22203
|
+
return MCP_LONG_PATHS.find(p => { try { return p.match(a) === true; } catch { return false; } }) || null;
|
|
22204
|
+
}
|
|
22205
|
+
function _mcpTimeoutFor(cliArgs) {
|
|
22206
|
+
const ov = _mcpTimeoutOverrideMs();
|
|
22207
|
+
if (ov != null) return ov;
|
|
22208
|
+
const p = _mcpLongPathFor(cliArgs);
|
|
22209
|
+
return p ? p.budgetMs : MCP_TIMEOUT_DEFAULT_MS;
|
|
22210
|
+
}
|
|
22211
|
+
// 타임아웃 사유 한 줄 — **실제로 적용된 예산**과 어긋나면 안 된다. 명시 override 가 걸린 호출에
|
|
22212
|
+
// "이 호출은 긴 예산(300초)을 받습니다" 라고 말하면 5초에 잘린 사용자에게 거짓말을 하는 것이다.
|
|
22213
|
+
// 순수 함수로 두는 이유: 진짜 타임아웃을 기다리지 않고도 두 분기를 실행으로 단언할 수 있게(1.36.125).
|
|
22214
|
+
function _mcpTimeoutHint(cliArgs) {
|
|
22215
|
+
if (_mcpTimeoutOverrideMs() != null) return ''; // 예산을 정한 것은 표가 아니라 사용자다
|
|
22216
|
+
const p = _mcpLongPathFor(cliArgs);
|
|
22217
|
+
return p ? ` · 이 호출은 긴 예산(${Math.round(p.budgetMs / 1000)}초)을 받습니다 — ${p.why}.\n` : '';
|
|
22218
|
+
}
|
|
22020
22219
|
function mcpServeCmd(root) {
|
|
22021
22220
|
root = absRoot(root || process.cwd());
|
|
22022
22221
|
// 노출할 leerness 도구 목록
|
|
@@ -22032,42 +22231,8 @@ function mcpServeCmd(root) {
|
|
|
22032
22231
|
function send(obj) {
|
|
22033
22232
|
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
22034
22233
|
}
|
|
22035
|
-
//
|
|
22036
|
-
//
|
|
22037
|
-
// 응답이 `(no output)` 뿐 — 차단 사유도, 타임아웃이라는 사실도 전달되지 않았다(진단 단서 0).
|
|
22038
|
-
// ① 기본값을 probe 예산 위로 올리고 ② env 로 조절 가능하게 하며 ③ 타임아웃을 **사실대로** 보고한다.
|
|
22039
|
-
// (MCP 클라이언트 자체 타임아웃은 leerness 가 어쩌지 못한다 — 그래서 메시지에 CLI 대안을 함께 준다.)
|
|
22040
|
-
// codex 33차 #2: 한도를 **전역으로** 올리면 `spawnSync` 가 단일 스레드 서버를 그만큼 얼린다 —
|
|
22041
|
-
// 무관한 도구가 멎어도 11분간 ping·취소 통지조차 못 읽는다. 그래서 긴 예산은 **probe 를 실행할 수 있는
|
|
22042
|
-
// 호출에만** 준다(done 전이 · task sync). 나머지는 종전대로 60초.
|
|
22043
|
-
const MCP_TIMEOUT_DEFAULT_MS = 60000;
|
|
22044
|
-
const MCP_TIMEOUT_PROBE_MS = 660000; // probe 예산(600s) 위 — 이 값이 적용되는 호출은 아래 _needsProbeBudget 뿐
|
|
22045
|
-
const MCP_TIMEOUT_OVERRIDE_MS = (() => {
|
|
22046
|
-
const v = parseInt(process.env.LEERNESS_MCP_TIMEOUT_MS || '', 10);
|
|
22047
|
-
return (Number.isFinite(v) && v >= 5000 && v <= 3600000) ? v : null;
|
|
22048
|
-
})();
|
|
22049
|
-
// 1.36.124 (살아 있는 MCP 통신 실측): `leerness_selftest` 는 **항상** 60초 한도를 넘겨 타임아웃만 돌려줬다.
|
|
22050
|
-
// 실측 116초(우리 repo·빈 임시 프로젝트 둘 다) — 즉 광고된 89개 도구 중 **설치 검증 도구가 MCP 로는 무용**이었다.
|
|
22051
|
-
// 전역 한도를 올리지 않는 이유는 위와 같다(무관한 도구가 서버를 그만큼 얼린다). 그래서 이 명령에만 준다.
|
|
22052
|
-
const MCP_TIMEOUT_LONG_MS = 300000; // 실측 116초 대비 2.5배 여유
|
|
22053
|
-
function _needsLongBudget(cliArgs) {
|
|
22054
|
-
const a = (cliArgs || []).map(String);
|
|
22055
|
-
return a[0] === 'selftest';
|
|
22056
|
-
}
|
|
22057
|
-
// bugfix probe 가 도는 경로만 긴 예산을 받는다 — done 전이(task update --status done)와 TodoWrite 왕복(task sync).
|
|
22058
|
-
function _needsProbeBudget(cliArgs) {
|
|
22059
|
-
const a = (cliArgs || []).map(String);
|
|
22060
|
-
if (a[0] !== 'task') return false;
|
|
22061
|
-
if (a[1] === 'sync') return true;
|
|
22062
|
-
if (a[1] !== 'update') return false;
|
|
22063
|
-
const i = a.indexOf('--status');
|
|
22064
|
-
return i >= 0 && ['done', 'completed', 'verified'].includes(a[i + 1]);
|
|
22065
|
-
}
|
|
22066
|
-
function _mcpTimeoutFor(cliArgs) {
|
|
22067
|
-
return MCP_TIMEOUT_OVERRIDE_MS != null ? MCP_TIMEOUT_OVERRIDE_MS
|
|
22068
|
-
: (_needsProbeBudget(cliArgs) ? MCP_TIMEOUT_PROBE_MS
|
|
22069
|
-
: (_needsLongBudget(cliArgs) ? MCP_TIMEOUT_LONG_MS : MCP_TIMEOUT_DEFAULT_MS));
|
|
22070
|
-
}
|
|
22234
|
+
// 예산 표(MCP_LONG_PATHS)와 _mcpTimeoutFor 는 모듈 스코프에 있다 — selftest 가 소스 grep 이 아니라
|
|
22235
|
+
// **실제 호출**로 `budget > 내부한도` 를 단언할 수 있게 하기 위함(1.36.125).
|
|
22071
22236
|
const _shQuote = (s) => (/[\s"']/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
|
|
22072
22237
|
function callLeerness(cliArgs) {
|
|
22073
22238
|
const budget = _mcpTimeoutFor(cliArgs);
|
|
@@ -22084,8 +22249,9 @@ function mcpServeCmd(root) {
|
|
|
22084
22249
|
return {
|
|
22085
22250
|
ok: false, exit: null, stdout: '',
|
|
22086
22251
|
stderr: `⏱ ${Math.round(budget / 1000)}초 안에 끝나지 않아 중단했습니다: ${cmd}\n`
|
|
22087
|
-
//
|
|
22088
|
-
|
|
22252
|
+
// 원인 설명은 그 호출에 실제로 해당하는 것만 — 무관한 도구에 엉뚱한 원인을 대지 않는다(#3).
|
|
22253
|
+
// 1.36.125: 표의 `why` 를 그대로 쓴다(예산을 준 근거와 사용자에게 대는 이유가 갈라지지 않게).
|
|
22254
|
+
+ _mcpTimeoutHint(cliArgs)
|
|
22089
22255
|
+ ` · 한도 조절: LEERNESS_MCP_TIMEOUT_MS=<밀리초> (5000~3600000)\n`
|
|
22090
22256
|
+ ` · 또는 터미널에서 직접: ${cmd}`
|
|
22091
22257
|
+ (partial ? `\n · 중단 전 출력:\n${partial.slice(0, 2000)}` : ''),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.125",
|
|
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
|
@@ -5147,34 +5147,83 @@ total++;
|
|
|
5147
5147
|
let ok = false; const bad = [];
|
|
5148
5148
|
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-mcp124-'));
|
|
5149
5149
|
const ENVm = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
|
|
5150
|
+
// 1.36.125 (검수 P3 + 실측): 이 블록은 **부모 환경을 상속하면 안 된다**. 사용자가 켜 둔 공개 설정 하나로
|
|
5151
|
+
// e2e 가 환경 의존적 거짓 실패를 낸다 — LEERNESS_MCP_TIMEOUT_MS 는 예산을, MCP_PROFILE 은 도구 수를,
|
|
5152
|
+
// WORKSPACE_DIR 은 상태 위치를, LANG 은 기대 문자열을 바꾼다(selftest 쪽 실측으로 확인된 같은 클래스).
|
|
5153
|
+
for (const k of ['LEERNESS_MCP_TIMEOUT_MS', 'LEERNESS_MCP_PROFILE', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG']) delete ENVm[k];
|
|
5150
5154
|
try {
|
|
5151
5155
|
const d = path.join(sb, 'p'); fs.mkdirSync(d, { recursive: true });
|
|
5152
5156
|
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
5153
|
-
|
|
5157
|
+
// 셋업 실패를 통과로 세지 않는다(검수 P1) — init 이 죽으면 이후 단언은 무의미하다.
|
|
5158
|
+
const rInit = cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENVm });
|
|
5159
|
+
if (rInit.status !== 0) bad.push(`셋업init실패(exit=${rInit.status})`);
|
|
5154
5160
|
const rpc = (id, method, params) => JSON.stringify({ jsonrpc: '2.0', id, method, ...(params ? { params } : {}) }) + '\n';
|
|
5161
|
+
// 1.36.125: 같은 세션에서 **오류 규격**도 함께 받는다(추가 spawn 없이).
|
|
5162
|
+
// 지난 라운드엔 도구 하나만 불러 봤다 — 한 곳에서 나온 결함(타임아웃·거짓 실패)이 클래스인지 보려면 폭이 필요하다.
|
|
5163
|
+
// 규격(2024-11-05 lifecycle)상 initialize 성공 뒤 클라이언트는 `notifications/initialized` 를 보낸다 —
|
|
5164
|
+
// 그걸 빼면 "실제 왕복" 이라면서 수명주기를 재현하지 않은 것이다(검수 P2). 알림은 id 가 없다.
|
|
5165
|
+
const notify = (method) => JSON.stringify({ jsonrpc: '2.0', method }) + '\n';
|
|
5155
5166
|
const input = rpc(1, 'initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'e2e', version: '0' } })
|
|
5156
|
-
+
|
|
5167
|
+
+ notify('notifications/initialized')
|
|
5168
|
+
+ rpc(2, 'tools/list') + rpc(3, 'tools/call', { name: 'leerness_selftest', arguments: { path: d } })
|
|
5169
|
+
+ rpc(4, 'tools/call', { name: 'leerness_brainstorm', arguments: { path: d } }) // 필수 인자 누락 → -32602
|
|
5170
|
+
+ rpc(5, 'tools/call', { name: 'leerness_does_not_exist', arguments: {} }) // 미지 도구 → -32601
|
|
5171
|
+
+ rpc(6, 'unknown/method', {}); // 미지 메서드 → -32601
|
|
5157
5172
|
const r = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve', '--path', d],
|
|
5158
5173
|
{ cwd: d, input, encoding: 'utf8', timeout: 900000, env: ENVm, maxBuffer: 64 * 1024 * 1024 });
|
|
5159
5174
|
const msgs = [];
|
|
5160
5175
|
for (const line of String(r.stdout || '').split('\n')) { const s = line.trim(); if (s) { try { msgs.push(JSON.parse(s)); } catch { bad.push('비JSON줄'); } } }
|
|
5161
5176
|
const by = (i) => msgs.find(m => m.id === i);
|
|
5177
|
+
// 서버 자체의 종료 상태도 본다(검수 P1) — 응답만 보면 **비정상 종료·타임아웃**을 성공으로 센다.
|
|
5178
|
+
if (r.error) bad.push(`서버오류(${r.error.code || r.error.message})`);
|
|
5179
|
+
if (r.signal) bad.push(`서버시그널(${r.signal})`);
|
|
5180
|
+
if (r.status !== 0) bad.push(`서버exit=${r.status}`);
|
|
5162
5181
|
if (String(r.stderr || '').length !== 0) bad.push(`stderr(${String(r.stderr).length}B)`);
|
|
5163
5182
|
const init = by(1); if (!init || init.error || !(init.result && init.result.serverInfo)) bad.push('initialize실패');
|
|
5164
5183
|
const tools = by(2) && by(2).result && by(2).result.tools;
|
|
5165
|
-
|
|
5184
|
+
// 1.36.125 (검수 P2): `>= 50` 은 도구가 39개 사라져도 통과한다. 등록부(lib/mcp-tools.js)와 **정확히** 대조한다 —
|
|
5185
|
+
// 서버가 걸러 내거나 프로필이 새면 그 자리에서 어긋난다. 그리고 실제로 부를 도구가 목록에 있는지도 본다
|
|
5186
|
+
// (dispatch 는 되는데 discoverability 에서 사라지는 경우가 있다).
|
|
5187
|
+
const REG = require(path.resolve(__dirname, '..', 'lib', 'mcp-tools.js'));
|
|
5188
|
+
if (!Array.isArray(tools) || tools.length !== REG.length) bad.push(`tools/list(${Array.isArray(tools) ? tools.length : 'none'}≠등록부 ${REG.length})`);
|
|
5166
5189
|
else {
|
|
5167
5190
|
if (tools.some(t => !t.description || !String(t.description).trim())) bad.push('도구설명없음');
|
|
5168
5191
|
if (tools.some(t => !t.inputSchema || typeof t.inputSchema !== 'object')) bad.push('도구스키마없음');
|
|
5192
|
+
if (!tools.some(t => t.name === 'leerness_selftest')) bad.push('호출할도구가목록에없음');
|
|
5193
|
+
// 1.36.125 (검수 P2): 길이 + "등록부 밖 없음" 만으로는 **중복이 누락을 상쇄**한다(A 를 두 번 내고 B 를 빼면 통과).
|
|
5194
|
+
// 집합 동등으로 본다 — 이름 유일 + 등록부 전체 포함(양방향).
|
|
5195
|
+
const regNames = new Set(REG.map(t => t.name));
|
|
5196
|
+
const gotNames = tools.map(t => t.name);
|
|
5197
|
+
const uniq = new Set(gotNames);
|
|
5198
|
+
if (uniq.size !== gotNames.length) bad.push(`도구이름중복(${gotNames.length - uniq.size}건)`);
|
|
5199
|
+
const extra = gotNames.filter(n => !regNames.has(n));
|
|
5200
|
+
if (extra.length) bad.push(`목록에등록부밖도구(${extra.slice(0, 3).join(',')})`);
|
|
5201
|
+
const missing = [...regNames].filter(n => !uniq.has(n));
|
|
5202
|
+
if (missing.length) bad.push(`등록부인데목록에없음(${missing.slice(0, 3).join(',')})`);
|
|
5169
5203
|
}
|
|
5170
5204
|
const call = by(3);
|
|
5171
5205
|
const txt = call && call.result && Array.isArray(call.result.content) ? String((call.result.content[0] || {}).text || '') : '';
|
|
5172
5206
|
// ⚠ content 가 비어 있지 않다는 것만 보면 **타임아웃 문구도 성공**으로 센다(내 첫 프로브가 그랬다).
|
|
5173
5207
|
if (!call || call.error) bad.push('tools/call오류');
|
|
5208
|
+
else if (call.result && call.result.isError) bad.push('tools/call_isError'); // 도구 실패를 성공으로 세던 자리(검수 P1)
|
|
5174
5209
|
else if (/안에 끝나지 않아 중단/.test(txt)) bad.push('tools/call타임아웃');
|
|
5175
5210
|
else if (!/"total"\s*:\s*\d+|전체 \d+건 통과/.test(txt)) bad.push('tools/call결과아님');
|
|
5176
5211
|
// MCP 경유 selftest 가 **실패를 보고하면** 그건 거짓 보고다(같은 프로젝트에서 CLI 는 통과한다)
|
|
5177
|
-
|
|
5212
|
+
// ⚠ `fail` 필드가 **없으면** 통과시키던 자리 — 계약상 반드시 있고 0 이어야 하며 pass===total 이어야 한다(검수 P1).
|
|
5213
|
+
else {
|
|
5214
|
+
const m = /"fail"\s*:\s*(\d+)/.exec(txt);
|
|
5215
|
+
if (!m) bad.push('selftest_fail필드없음');
|
|
5216
|
+
else if (Number(m[1]) > 0) bad.push(`MCP경유_selftest_거짓실패(fail=${m[1]})`);
|
|
5217
|
+
const p = /"pass"\s*:\s*(\d+)/.exec(txt), t = /"total"\s*:\s*(\d+)/.exec(txt);
|
|
5218
|
+
if (!p || !t || Number(p[1]) !== Number(t[1])) bad.push(`selftest_pass≠total(${p && p[1]}/${t && t[1]})`);
|
|
5219
|
+
}
|
|
5220
|
+
// 오류 규격 — 잘못된 호출을 **JSON-RPC 오류 코드**로 거절해야 한다(조용한 성공/크래시 금지).
|
|
5221
|
+
for (const [id, code, label] of [[4, -32602, '필수인자누락'], [5, -32601, '미지도구'], [6, -32601, '미지메서드']]) {
|
|
5222
|
+
const m = by(id);
|
|
5223
|
+
if (!m) bad.push(`${label}:무응답`);
|
|
5224
|
+
else if (!m.error) bad.push(`${label}:오류아님`);
|
|
5225
|
+
else if (m.error.code !== code) bad.push(`${label}:코드=${m.error.code}(기대 ${code})`);
|
|
5226
|
+
}
|
|
5178
5227
|
ok = bad.length === 0;
|
|
5179
5228
|
} catch (e) { bad.push('ERR:' + String(e && e.message).slice(0, 80)); }
|
|
5180
5229
|
finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
@@ -8310,16 +8359,21 @@ total++;
|
|
|
8310
8359
|
// codex 33차 #5: 메시지의 초 값이 **실제 적용된 한도**여야 한다 — 소스 리터럴만 보면
|
|
8311
8360
|
// `Math.min(MCP_TIMEOUT_MS, 60000)` 같은 변조가 그대로 통과한다. 5초 지정 → "5초" 로 찍혀야 한다.
|
|
8312
8361
|
&& /⏱ 5초 안에/.test(shortRes)
|
|
8313
|
-
// codex 33차 #3
|
|
8314
|
-
|
|
8315
|
-
|
|
8362
|
+
// codex 33차 #3 → 1.36.125 개정: 사유 줄은 그 호출에만 붙는다. 예전 단언은 사라진 문구를 부정해
|
|
8363
|
+
// **공허참**이 돼 있었다(문구를 지워도 초록). 이제 "명시 override 가 걸리면 표의 사유를 말하지 않는다"
|
|
8364
|
+
// 는 실제 계약을 건다 — 5초에 잘린 사용자에게 "긴 예산 300초" 라고 말하면 거짓말이기 때문.
|
|
8365
|
+
&& !/긴 예산\(/.test(shortRes);
|
|
8366
|
+
// codex 33차 #2: 긴 예산은 그 예산이 필요한 호출에만 — 전역으로 올리면 단일 스레드 서버가 그만큼 얼어붙는다.
|
|
8316
8367
|
const srcTxt = fs.readFileSync(CLI, 'utf8');
|
|
8317
8368
|
const probeBudget = (srcTxt.match(/MCP_TIMEOUT_PROBE_MS = (\d+)/) || [])[1];
|
|
8318
8369
|
const defBudget = (srcTxt.match(/MCP_TIMEOUT_DEFAULT_MS = (\d+)/) || [])[1];
|
|
8319
8370
|
T3 = !!probeBudget && Number(probeBudget) > 600000 // probe 예산 위
|
|
8320
8371
|
&& !!defBudget && Number(defBudget) <= 120000 // 기본은 짧게(서버 정지 시간 제한)
|
|
8321
8372
|
&& /timeout: budget,/.test(srcTxt) // 배선: 계산된 예산이 실제로 쓰인다
|
|
8322
|
-
|
|
8373
|
+
// 1.36.125: 이름 술어(_needsProbeBudget)는 표(MCP_LONG_PATHS)로 대체됐다 — 배선은 그 표를 쓰는지로 본다.
|
|
8374
|
+
// ⚠ 정확한 예산 계산 자체는 selftest 가 **실행으로** 단언한다(소스 문자열은 배선 유무만).
|
|
8375
|
+
&& /_mcpTimeoutHint\(cliArgs\)/.test(srcTxt)
|
|
8376
|
+
&& /const MCP_LONG_PATHS = \[/.test(srcTxt);
|
|
8323
8377
|
ok = T1 && T2 && T3;
|
|
8324
8378
|
if (!ok) console.log(` [이월2 디버그] plan정규화=${T1}(done=${doneCount} vc=${vcTotal}) 타임아웃사유=${T2} 기본한도=${T3}(${srcTimeout})`);
|
|
8325
8379
|
} catch (e) { console.log(' [이월2 디버그] 예외: ' + ((e && e.message) || e)); }
|
|
@@ -12303,6 +12357,60 @@ total++;
|
|
|
12303
12357
|
if (!ok) failed++;
|
|
12304
12358
|
}
|
|
12305
12359
|
|
|
12360
|
+
// 1.36.125 (검수 P2): `verify-claim --all --run-tests` 는 done 행마다 verifyClaimCmd 를 부르는데
|
|
12361
|
+
// `--run-tests` 는 전역 플래그다 → 같은 테스트 스위트를 **주장 수만큼** 반복 실행했다(100주장 × 300초 = 8시간).
|
|
12362
|
+
// 메모화로 1회로 줄였다. 그런데 "안 돌렸다" 와 "한 번 돌리고 결과를 나눠 줬다" 는 겉보기가 같다 —
|
|
12363
|
+
// 그래서 **실패하는 스위트**를 판별 케이스로 쓴다: 전파되면 N개 주장 **전부** 실패하고, 끊기면 첫 주장만 실패한다.
|
|
12364
|
+
// 대조군(통과 스위트)도 함께 둔다 — 메모가 없는 실패를 지어내지 않는지.
|
|
12365
|
+
total++;
|
|
12366
|
+
{
|
|
12367
|
+
let ok = false; const bad = []; const dbg = {};
|
|
12368
|
+
const _dirs = [];
|
|
12369
|
+
const N = 3;
|
|
12370
|
+
const scenario = (label, testBody, expectFail) => {
|
|
12371
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-memo125-')); _dirs.push(d);
|
|
12372
|
+
const env = Object.assign({}, process.env, { TMPDIR: d, TEMP: d, TMP: d, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
|
|
12373
|
+
const counter = path.join(d, 'runs.txt');
|
|
12374
|
+
fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'memo', version: '0.1.0', scripts: { test: 'node t.js' } }));
|
|
12375
|
+
fs.writeFileSync(path.join(d, 't.js'), `require('fs').appendFileSync(${JSON.stringify(counter)}, 'x\\n'); ${testBody}`);
|
|
12376
|
+
const ri = cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env });
|
|
12377
|
+
if (ri.status !== 0) { bad.push(`${label}:init실패(${ri.status})`); return; }
|
|
12378
|
+
for (let i = 0; i < N; i++) {
|
|
12379
|
+
const ra = cp.spawnSync(process.execPath, [CLI, 'task', 'add', `주장${i}`, '--path', d, '--json'], { cwd: d, encoding: 'utf8', timeout: 300000, env });
|
|
12380
|
+
let id = null; try { id = JSON.parse(ra.stdout || '{}').id; } catch {}
|
|
12381
|
+
if (!id) { bad.push(`${label}:task add 실패`); return; }
|
|
12382
|
+
const ru = cp.spawnSync(process.execPath, [CLI, 'task', 'update', id, '--status', 'done', '--evidence', 't.js 테스트 3건 통과', '--path', d],
|
|
12383
|
+
{ cwd: d, encoding: 'utf8', timeout: 600000, env });
|
|
12384
|
+
if (ru.status !== 0) { bad.push(`${label}:task update 실패(${ru.status})`); return; }
|
|
12385
|
+
}
|
|
12386
|
+
// 셋업이 조용히 실패하면 아래 판정이 공허해진다 — done 행이 실제로 N개인지 먼저 확인한다.
|
|
12387
|
+
const tracker = fs.readFileSync(path.join(d, '.harness', 'progress-tracker.md'), 'utf8');
|
|
12388
|
+
const doneRows = tracker.split('\n').filter(l => /^\|\s*T-\d+/.test(l) && /\|\s*done\s*\|/.test(l)).length;
|
|
12389
|
+
if (doneRows !== N) { bad.push(`${label}:셋업done행 ${doneRows}(기대 ${N})`); return; }
|
|
12390
|
+
try { fs.unlinkSync(counter); } catch {}
|
|
12391
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'verify-claim', '--all', '--path', d, '--json', '--run-tests'],
|
|
12392
|
+
{ cwd: d, encoding: 'utf8', timeout: 900000, env, maxBuffer: 32 * 1024 * 1024 });
|
|
12393
|
+
let runs = 0; try { runs = fs.readFileSync(counter, 'utf8').trim().split('\n').filter(Boolean).length; } catch {}
|
|
12394
|
+
let j = null; try { j = JSON.parse(r.stdout); } catch {}
|
|
12395
|
+
if (!j || !Array.isArray(j.results)) { bad.push(`${label}:JSON아님`); return; }
|
|
12396
|
+
if (j.total !== N) bad.push(`${label}:total=${j.total}(기대 ${N})`);
|
|
12397
|
+
if (runs !== 1) bad.push(`${label}:스위트실행 ${runs}회(기대 1)`);
|
|
12398
|
+
const failedClaims = j.results.filter(x => x && x.ok === false).length;
|
|
12399
|
+
if (expectFail && failedClaims !== N) bad.push(`${label}:실패전파 ${failedClaims}/${N}`);
|
|
12400
|
+
if (!expectFail && failedClaims !== 0) bad.push(`${label}:거짓실패 ${failedClaims}건`);
|
|
12401
|
+
dbg[label] = { runs, total: j.total, failedClaims };
|
|
12402
|
+
};
|
|
12403
|
+
try {
|
|
12404
|
+
scenario('통과', "console.log('3 passed, 0 failed'); process.exit(0);", false);
|
|
12405
|
+
scenario('실패', "console.log('1 passed, 2 failed'); process.exit(1);", true);
|
|
12406
|
+
ok = bad.length === 0;
|
|
12407
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
|
|
12408
|
+
finally { _dirs.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
|
|
12409
|
+
console.log(ok ? `✓ U(1.36.125) verify-claim --all --run-tests: 스위트 1회만 실행(주장 수만큼 반복 금지) · 실패 결과가 ${N}개 주장 **전부**에 전파 · 통과 시 거짓 실패 0 ${JSON.stringify(dbg)}`
|
|
12410
|
+
: '✗ 1.36.125 --all 테스트 메모화 실패 ' + JSON.stringify({ bad: bad.slice(0, 6), dbg }));
|
|
12411
|
+
if (!ok) failed++;
|
|
12412
|
+
}
|
|
12413
|
+
|
|
12306
12414
|
console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
|
|
12307
12415
|
if (failed > 0) process.exit(1);
|
|
12308
12416
|
|