leerness 1.36.92 → 1.36.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.93 — 2026-07-30 — 검증기가 못 보던 완료 행 · MCP 에서 영영 완료 못 하던 사용자
4
+
5
+ 앞선 헌트에서 확인만 하고 미뤄둔 16건 중 **사용자가 갇히거나 검증 없이 완료되는** 두 건을 처리했다.
6
+
7
+ **`plan add "<작업>" --status verified` 한 줄이 검증기가 보지 못하는 완료 행을 만들었다.** `task add` 는 저장 시 `completed`/`verified` 를 `done` 으로 정규화한다(1.11.3 이 "집계마다 다르게 세는" 불일치를 없애려고 넣은 것). `plan add` 만 값을 검증만 하고 **원문 그대로** 저장했다. 결과는 같은 실행 안에서의 모순이었다 — `lazy detect` 는 그 행을 done 으로 세는데 `verify-claim --all` 과 `gate --claims` 는 `done` 필터에 걸러 **아예 보지 않았다**. `--force` 도 손편집도 필요 없이, 게이트를 통과했다는 신호가 검증되지 않은 주장을 덮었다.
8
+
9
+ - 같은 정규화를 태웠다. 실측: 정규화 안 된 행 0건 · `verify-claim --all` 대상 3 = done 행 수 3(이전 2 vs 3).
10
+ - **정규화는 plan.md 본문의 `Status:` 줄에도 적용된다** — 이건 부수효과가 아니라 함께 고쳐야 할 것이었다. 로드맵 상태 라벨 목록(`ROADMAP_STATUS_LABEL`)에 `verified` 키가 **없어서**, 종전에는 렌더러가 모르는 값이 plan.md 에 적히고 있었다. 덧붙여 상태 이름으로 "verified" 를 스스로 적는 것 자체가 이 제품이 막으려는 **자기선언 검증**이다 — 검증 여부는 검증기가 판정한다.
11
+ - 정규화 클래스를 전수 스윕했다: progress 행에 status 를 쓰는 경로 6곳(task add · plan add · task update · task sync ×2 · 초기 planned) 모두 정규화 또는 안전한 리터럴임을 실행으로 확인.
12
+
13
+ **MCP 표면에서는 정직하게 고친 사용자가 완료를 영영 기록할 수 없었다.** bugfix probe 예산은 600초인데(그래야 `npm test` 같은 현실적인 probe 를 등록할 수 있다) MCP 래퍼는 60초에 자식을 죽였다. 게다가 stdout 이 비어 응답이 **`(no output)`** 뿐 — 차단 사유도, 타임아웃이라는 사실도 전달되지 않아 진단 단서가 0 이었고, 재시도할 때마다 테스트가 60초 돌다 죽어 비용만 쌓였다. 하네스가 AI 에게 권하는 1차 표면이 MCP 라 더 나빴다.
14
+
15
+ - 기본 한도를 **660초**(probe 예산 위)로 올리고 `LEERNESS_MCP_TIMEOUT_MS`(5_000~3_600_000)로 조절 가능하게 했다.
16
+ - 타임아웃을 **사실대로** 보고한다 — 무엇이 중단됐는지 · probe 가 최대 600초 걸릴 수 있다는 사실 · 한도 조절 방법 · **동일한 CLI 명령**. MCP 클라이언트 자체의 타임아웃은 leerness 가 어쩌지 못하므로, 해결할 수 없는 부분을 해결한 척하지 않고 대안을 준다.
17
+ - 실측: 한도 5초 + `leerness_selftest`(~20초) → `(no output)` 대신 사유 전달 · 기본 한도에서는 정상 완료(20.8초).
18
+
19
+ **codex 33차 검수가 내 수정이 만든 새 결함을 잡았다(5건 전부 반영):**
20
+
21
+ - **한도를 전역으로 올린 것이 서버를 얼렸다** — `spawnSync` 는 단일 스레드 MCP 서버를 그 시간만큼 통째로 막는다. 무관한 도구가 멎으면 11분간 ping·취소 통지조차 못 읽고, 클라이언트가 자체 타임아웃으로 먼저 포기하면 사용자는 안내를 보지도 못한 채 서버만 9분 더 죽어 있다. → 긴 예산은 **probe 가 실제로 도는 호출에만**(`task update --status done|completed|verified` · `task sync`), 나머지는 60초. 함수 단위 6케이스로 확인.
22
+ - **타임아웃이 아닌 실패를 타임아웃으로 오인했다** — `status == null && !stdout` 은 EPERM·시그널 종료도 같은 모양이라, 권한 문제로 실패한 호출에 "한도를 늘리세요"라는 엉뚱한 처방을 냈다. **`ETIMEDOUT` 만** 타임아웃으로 보고, 나머지는 실제 오류 코드·시그널을 알리며, **중단 전 부분 출력을 버리지 않는다**.
23
+ - probe 설명을 **probe 가 돌 수 있는 호출에만** 붙인다(`leerness_selftest` 타임아웃에 bugfix 원인을 대지 않는다). 안내하는 CLI 명령은 공백 포함 인자를 인용한다.
24
+ - **내가 만든 시한폭탄 대체가 또 다른 시한폭탄이었다** — `patch − 3` 은 `1.37.0`·prerelease·patch<3 에서 0건을 돌려주고 `shown === totalVersions === 0` 이라 **공허하게 통과**한다. CHANGELOG 에 실재하는 버전을 뽑아 쓰고 `shown > 0` 을 함께 단언한다.
25
+ - 타임아웃 단언이 **소스 리터럴만** 봐서 `Math.min(...)` 같은 변조가 통과했다 → 메시지에 찍히는 초 값이 **실제 적용된 한도**와 일치하는지(5초 지정 → "5초")까지 확인하고, 배선(`timeout: budget` · `_needsProbeBudget`)도 함께 단언한다.
26
+
27
+ **시한폭탄 테스트도 하나 걷어냈다.** e2e 가 `whats-new --from 1.36.60` 이 "절단되지 않는다"고 단언했는데, 릴리스가 쌓여 그 창이 기본 상한 30 을 넘겼다(실측 31개 → truncated=true). 제품은 정상이고 **테스트가 스스로 만료**된 것이다. 기준점을 현재 버전에서 3단계 거슬러 자동 산출하게 바꿔, 릴리스가 아무리 쌓여도 창이 유지된다.
28
+
29
+ 게이트: exit 0 · E2E 413/413 · selftest 341/341 · ✗ 0건
30
+
3
31
  ## 1.36.92 — 2026-07-30 — 동의 없는 외부 CLI 실행 차단 · 과소평가 우회 3종 · 손상 토글 fail-closed
4
32
 
5
33
  1.36.88~91 에서 새로 만든 표면(bugfix 게이트·라우팅·대시보드·인증 축)을 6렌즈 적대 헌트(41 에이전트)로 훑어 **21건 확인 / 14건 반증**. 이번 릴리스는 그중 상위 5건 + codex 32차 검수 8건을 반영했고, 나머지 16건은 다음 라운드로 기록해 두었다.
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.92 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.93 하네스를 사용합니다. 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.92는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.93는 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.92는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
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.92 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.93 릴리스 태그 이력** (수백 라운드) · _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.92: 2026-07-30
238
+ Last synced by Leerness v1.36.93: 2026-07-30
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.92';
37
+ const VERSION = '1.36.93';
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') 시 호스트 프로세스 오염.
@@ -10125,7 +10125,12 @@ function planListCmd(root, opts = {}) {
10125
10125
  function planAdd(root, text) {
10126
10126
  if (!_requireInit(root, 'plan add')) return; // 1.9.311 (UR-0047): init 가드
10127
10127
  if (!_validateChoice(arg('--status', null), TASK_STATUSES, 'plan status')) { process.exitCode = 1; return; } // 1.9.310 (UR-0046)
10128
- const status = arg('--status','planned'), progress = arg('--progress','0'), nextAction = arg('--next', _uiLang(root) === 'en' ? 'write the next action' : '다음 액션 작성'); // 1.36.63
10128
+ // 1.36.93 (헌트 #11): `--status` **정규화 없이** progress 행에 그대로 썼다 `plan add "x" --status verified`
10129
+ // `verified` 로 저장되면 lazy detect 는 done 으로 세는데 verify-claim --all / gate --claims 의 done 필터는
10130
+ // 그 행을 통째로 건너뛴다. `--force` 도 손편집도 없이 "검증기가 보지 않는 완료 행"이 만들어졌다.
10131
+ // 1.11.3 이 completed/verified 를 저장 시 done 으로 정규화한 이유가 바로 이 집계 불일치를 없애기 위함인데
10132
+ // plan add 만 그 경로를 타지 않았다. 같은 정규화를 태운다.
10133
+ const status = _normTaskStatus(arg('--status','planned')), progress = arg('--progress','0'), nextAction = arg('--next', _uiLang(root) === 'en' ? 'write the next action' : '다음 액션 작성'); // 1.36.63
10129
10134
  // 1.14.2 (Karpathy 원칙4 "성공기준 정의", UR-0032): --done-when 으로 검증가능 완료조건을 milestone 에 기록. 미지정 시 (미정) — plan show/audit 가 환기.
10130
10135
  const doneWhen = _lineSafe(arg('--done-when', '') || (_uiLang(root) === 'en' ? '(unset)' : '(미정)')); // 1.36.63 (검수 #2)
10131
10136
  // 1.9.303 (UR-0043): M-id append + T-id upsert 를 하나의 락으로 — 동시 plan add ID 충돌 방지.
@@ -19954,12 +19959,65 @@ function mcpServeCmd(root) {
19954
19959
  function send(obj) {
19955
19960
  process.stdout.write(JSON.stringify(obj) + '\n');
19956
19961
  }
19962
+ // 1.36.93 (헌트 #8): 래퍼 타임아웃(60s)이 bugfix probe 예산(600s)보다 짧아, 버그를 **진짜 고치고**
19963
+ // 영수증까지 채운 사용자가 MCP 표면에서는 done 을 영영 기록하지 못했다. 게다가 stdout 이 비어 있어
19964
+ // 응답이 `(no output)` 뿐 — 차단 사유도, 타임아웃이라는 사실도 전달되지 않았다(진단 단서 0).
19965
+ // ① 기본값을 probe 예산 위로 올리고 ② env 로 조절 가능하게 하며 ③ 타임아웃을 **사실대로** 보고한다.
19966
+ // (MCP 클라이언트 자체 타임아웃은 leerness 가 어쩌지 못한다 — 그래서 메시지에 CLI 대안을 함께 준다.)
19967
+ // codex 33차 #2: 한도를 **전역으로** 올리면 `spawnSync` 가 단일 스레드 서버를 그만큼 얼린다 —
19968
+ // 무관한 도구가 멎어도 11분간 ping·취소 통지조차 못 읽는다. 그래서 긴 예산은 **probe 를 실행할 수 있는
19969
+ // 호출에만** 준다(done 전이 · task sync). 나머지는 종전대로 60초.
19970
+ const MCP_TIMEOUT_DEFAULT_MS = 60000;
19971
+ const MCP_TIMEOUT_PROBE_MS = 660000; // probe 예산(600s) 위 — 이 값이 적용되는 호출은 아래 _needsProbeBudget 뿐
19972
+ const MCP_TIMEOUT_OVERRIDE_MS = (() => {
19973
+ const v = parseInt(process.env.LEERNESS_MCP_TIMEOUT_MS || '', 10);
19974
+ return (Number.isFinite(v) && v >= 5000 && v <= 3600000) ? v : null;
19975
+ })();
19976
+ // bugfix probe 가 도는 경로만 긴 예산을 받는다 — done 전이(task update --status done)와 TodoWrite 왕복(task sync).
19977
+ function _needsProbeBudget(cliArgs) {
19978
+ const a = (cliArgs || []).map(String);
19979
+ if (a[0] !== 'task') return false;
19980
+ if (a[1] === 'sync') return true;
19981
+ if (a[1] !== 'update') return false;
19982
+ const i = a.indexOf('--status');
19983
+ return i >= 0 && ['done', 'completed', 'verified'].includes(a[i + 1]);
19984
+ }
19985
+ function _mcpTimeoutFor(cliArgs) {
19986
+ return MCP_TIMEOUT_OVERRIDE_MS != null ? MCP_TIMEOUT_OVERRIDE_MS
19987
+ : (_needsProbeBudget(cliArgs) ? MCP_TIMEOUT_PROBE_MS : MCP_TIMEOUT_DEFAULT_MS);
19988
+ }
19989
+ const _shQuote = (s) => (/[\s"']/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
19957
19990
  function callLeerness(cliArgs) {
19991
+ const budget = _mcpTimeoutFor(cliArgs);
19958
19992
  const r = cp.spawnSync(process.execPath, [__filename, ...cliArgs], {
19959
19993
  encoding: 'utf8',
19960
- timeout: 60000,
19994
+ timeout: budget,
19961
19995
  env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_STALE_CHECK: '1', LEERNESS_NO_DRIFT_CHECK: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_WORKFLOW_GUIDE: '1' }
19962
19996
  });
19997
+ const cmd = `leerness ${cliArgs.map(_shQuote).join(' ')}`;
19998
+ const partial = ((r.stdout || '') + (r.stderr || '')).trim();
19999
+ // codex 33차 #1: `status == null && !stdout` 은 EPERM·시그널 종료까지 타임아웃으로 오인하고
20000
+ // 부분 출력을 버렸다. **ETIMEDOUT 만** 타임아웃으로 보고, 나머지 오류는 실제 코드로 알린다.
20001
+ if (r.error && String(r.error.code) === 'ETIMEDOUT') {
20002
+ return {
20003
+ ok: false, exit: null, stdout: '',
20004
+ stderr: `⏱ ${Math.round(budget / 1000)}초 안에 끝나지 않아 중단했습니다: ${cmd}\n`
20005
+ // probe 설명은 probe 가 실제로 돌 수 있는 호출에만 — 무관한 도구에 엉뚱한 원인을 대지 않는다(#3).
20006
+ + (_needsProbeBudget(cliArgs) ? ` · bugfix probe 가 등록된 task 를 done 으로 바꾸면 probe(최대 600초)가 실행됩니다 — 그만큼 시간이 필요합니다.\n` : '')
20007
+ + ` · 한도 조절: LEERNESS_MCP_TIMEOUT_MS=<밀리초> (5000~3600000)\n`
20008
+ + ` · 또는 터미널에서 직접: ${cmd}`
20009
+ + (partial ? `\n · 중단 전 출력:\n${partial.slice(0, 2000)}` : ''),
20010
+ };
20011
+ }
20012
+ if (r.error) {
20013
+ return {
20014
+ ok: false, exit: null, stdout: r.stdout || '',
20015
+ stderr: `✗ 실행 실패(${r.error.code || r.error.message}): ${cmd}` + (partial ? `\n${partial.slice(0, 2000)}` : ''),
20016
+ };
20017
+ }
20018
+ if (r.status == null && r.signal) {
20019
+ return { ok: false, exit: null, stdout: r.stdout || '', stderr: `✗ 시그널 ${r.signal} 로 종료됨: ${cmd}` + (partial ? `\n${partial.slice(0, 2000)}` : '') };
20020
+ }
19963
20021
  return { ok: r.status === 0, exit: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
19964
20022
  }
19965
20023
  function handleRequest(req) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.92",
3
+ "version": "1.36.93",
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
@@ -7351,8 +7351,17 @@ total++;
7351
7351
  // 무효 limit 은 기본값 폴백 (0/-5/abc)
7352
7352
  const badLim = ['0', '-5', 'abc'].every(v => { try { return JSON.parse(R(['--from', '1.9.33', '--json', '--limit', v]).stdout).shown === 30; } catch { return false; } });
7353
7353
  // 절단 없을 때 hint 부재
7354
- const small = JSON.parse(R(['--from', '1.36.60', '--json']).stdout);
7355
- const smallOk = small.truncated === false && small.hint === undefined && small.shown === small.totalVersions;
7354
+ // 1.36.93: `--from` 하드코딩(1.36.60)했더니 **릴리스가 쌓여 기본 상한 30 을 넘는 순간 만료**됐다
7355
+ // (실측: 1.36.60 이후 31개 truncated=true "절단 없음" 단언이 깨졌다). 제품 결함이 아니라
7356
+ // 시한폭탄 테스트다. 현재 버전에서 몇 단계만 거슬러 잡아 상한에 걸리지 않게 자동 산출한다.
7357
+ // codex 33차 #4: 버전 계산(patch-3)은 `1.37.0`·prerelease·patch<3 에서 **0건**을 돌려주고
7358
+ // `shown === totalVersions === 0` 이라 공허하게 통과한다. CHANGELOG 에 **실재하는** 최근 버전을 골라 쓰고,
7359
+ // 결과가 비어 있지 않은지(shown > 0)까지 단언한다.
7360
+ const _chg = fs.readFileSync(path.join(path.dirname(CLI), '..', 'CHANGELOG.md'), 'utf8');
7361
+ const _vers = (_chg.match(/^## (\d+\.\d+\.\d+)/gm) || []).map(s => s.replace('## ', ''));
7362
+ const _fromRecent = _vers[Math.min(3, _vers.length - 1)] || _vers[0];
7363
+ const small = JSON.parse(R(['--from', _fromRecent, '--json']).stdout);
7364
+ const smallOk = small.truncated === false && small.hint === undefined && small.shown === small.totalVersions && small.shown > 0;
7356
7365
  // MCP 인자 전달 (limit)
7357
7366
  const mcpReq = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'leerness_whats_new', arguments: { from: '1.9.33', limit: 2 } } });
7358
7367
  const mcpR = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], { encoding: 'utf8', timeout: 25000, input: mcpReq + '\n', maxBuffer: 32 * 1024 * 1024 });
@@ -8103,6 +8112,63 @@ total++;
8103
8112
  if (!ok) failed++;
8104
8113
  }
8105
8114
 
8115
+ // 1.36.93 (헌트 이월 2건): ① MCP 래퍼 타임아웃이 probe 예산보다 짧아 정직하게 고친 사용자가 done 을 못 하고,
8116
+ // 응답이 `(no output)` 이라 진단 단서가 0 이었다 ② `plan add --status verified` 가 정규화 없이 저장돼
8117
+ // lazy detect 는 done 으로 세는데 verify-claim/gate 는 그 행을 아예 보지 않았다(검증기가 못 보는 완료 행).
8118
+ total++;
8119
+ {
8120
+ let ok = false;
8121
+ const _d = [];
8122
+ const R = (args, env) => { const r = cp.spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', timeout: 300000, env: env || process.env }); return { s: r.status, o: ((r.stdout || '') + (r.stderr || '')).trim() }; };
8123
+ let T1 = false, T2 = false, T3 = false;
8124
+ try {
8125
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-carry-')); _d.push(d);
8126
+ R(['init', d, '--yes', '--language', 'ko', '--no-stale-check']);
8127
+ // T1: plan add 의 상태도 task add 와 **같은 정규화**를 탄다 — 저장 형태가 갈리면 집계마다 다르게 센다
8128
+ R(['task', 'add', 'A', '--path', d, '--status', 'completed', '--json', '--no-review']);
8129
+ R(['plan', 'add', 'B', '--path', d, '--status', 'completed', '--json']);
8130
+ R(['plan', 'add', 'C', '--path', d, '--status', 'verified', '--json']);
8131
+ const rows = fs.readFileSync(path.join(d, '.harness', 'progress-tracker.md'), 'utf8')
8132
+ .split('\n').filter(l => /^\|\s*T-\d/.test(l)).map(l => l.split('|').map(s => s.trim())[2]);
8133
+ const doneCount = rows.filter(s => s === 'done').length;
8134
+ let vcTotal = null; try { const o = R(['verify-claim', '--all', '--path', d, '--json']).o; vcTotal = JSON.parse(o.slice(o.indexOf('{'))).total; } catch {}
8135
+ // 판별: 정규화 안 된 행이 0 이고, **검증기가 보는 수 = done 행 수** 여야 한다(전엔 2 vs 3 으로 갈렸다)
8136
+ T1 = rows.filter(s => s === 'completed' || s === 'verified').length === 0 && doneCount === 3 && vcTotal === doneCount;
8137
+ // T2/T3: MCP 래퍼 타임아웃 — 사유를 사실대로 전달하고, 기본 한도는 probe 예산(600s) 위여야 한다
8138
+ const mcp = (ms) => {
8139
+ const msgs = [JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'initialize', params: {} }),
8140
+ JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'leerness_selftest', arguments: { path: d } } })].join('\n') + '\n';
8141
+ const r = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve', '--path', d], {
8142
+ input: msgs, encoding: 'utf8', timeout: 200000,
8143
+ env: Object.assign({}, process.env, ms ? { LEERNESS_MCP_TIMEOUT_MS: String(ms) } : {}),
8144
+ });
8145
+ const lines = ((r.stdout || '')).split('\n').filter(l => l.includes('"id":1'));
8146
+ return lines[lines.length - 1] || '';
8147
+ };
8148
+ const shortRes = mcp(5000); // selftest 는 ~20초 — 5초 한도면 반드시 걸린다
8149
+ T2 = !/no output/.test(shortRes) && /초 안에 끝나지 않아 중단/.test(shortRes)
8150
+ && /LEERNESS_MCP_TIMEOUT_MS/.test(shortRes) && /터미널에서 직접/.test(shortRes)
8151
+ // codex 33차 #5: 메시지의 초 값이 **실제 적용된 한도**여야 한다 — 소스 리터럴만 보면
8152
+ // `Math.min(MCP_TIMEOUT_MS, 60000)` 같은 변조가 그대로 통과한다. 5초 지정 → "5초" 로 찍혀야 한다.
8153
+ && /⏱ 5초 안에/.test(shortRes)
8154
+ // codex 33차 #3: probe 설명은 probe 가 돌 수 있는 호출에만 — selftest 에는 붙으면 안 된다
8155
+ && !/bugfix probe 가 등록된 task/.test(shortRes);
8156
+ // codex 33차 #2: 긴 예산은 **done 전이/sync 에만** 준다 — 전역으로 올리면 단일 스레드 서버가 그만큼 얼어붙는다.
8157
+ const srcTxt = fs.readFileSync(CLI, 'utf8');
8158
+ const probeBudget = (srcTxt.match(/MCP_TIMEOUT_PROBE_MS = (\d+)/) || [])[1];
8159
+ const defBudget = (srcTxt.match(/MCP_TIMEOUT_DEFAULT_MS = (\d+)/) || [])[1];
8160
+ T3 = !!probeBudget && Number(probeBudget) > 600000 // probe 예산 위
8161
+ && !!defBudget && Number(defBudget) <= 120000 // 기본은 짧게(서버 정지 시간 제한)
8162
+ && /timeout: budget,/.test(srcTxt) // 배선: 계산된 예산이 실제로 쓰인다
8163
+ && /_needsProbeBudget\(cliArgs\)/.test(srcTxt);
8164
+ ok = T1 && T2 && T3;
8165
+ if (!ok) console.log(` [이월2 디버그] plan정규화=${T1}(done=${doneCount} vc=${vcTotal}) 타임아웃사유=${T2} 기본한도=${T3}(${srcTimeout})`);
8166
+ } catch (e) { console.log(' [이월2 디버그] 예외: ' + ((e && e.message) || e)); }
8167
+ finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
8168
+ console.log(ok ? '✓ B(1.36.93) 헌트 이월 2건: plan add 상태 정규화(검증기가 보는 수 = done 행 수) · MCP 타임아웃 사유 전달(+CLI 대안·한도조절) · 기본 한도 > probe 예산' : '✗ 헌트 이월 2건 실패');
8169
+ if (!ok) failed++;
8170
+ }
8171
+
8106
8172
  // 1.36.76 (9차 헌트 이월 3건 종결): 손상 아카이브 복원 거부 · MCP read-only 무변형 · 긴 파생 id 안전화
8107
8173
  total++;
8108
8174
  {