leerness 1.36.86 → 1.36.88

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,63 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.88 — 2026-07-29 — `selftest --json` 잡음 3종 제거 · "우연히 지켜지던" 단일 JSON 계약을 원인 단계에서 고정
4
+
5
+ `selftest --json` 이 JSON 앞에 잡음 5줄(`✗ selftest probe` · `✗ probe` · graph 사람용 3줄)을 낸다는 제보로 시작했다. **먼저 계약이 실제로 깨졌는지 측정했고, 제보의 전제 두 가지는 사실이 아니었다.**
6
+
7
+ - **stdout 은 이미 순수 JSON 이었다** — `JSON.parse(stdout)` 성공 · `stdout.trimStart().startsWith('{')` 참 · exit 0. 잡음 5줄은 **전부 stderr** 였다(`2>&1` 로 합쳐 보면 오염처럼 보인다). 소비자 계약은 깨져 있지 않았다.
8
+ - **cwd 의존도 아니었다** — 리포 루트와 임시 cwd에서 잡음이 **동일하게** 나온다. e2e 가 통과한 이유는 "임시 cwd 라 잡음이 안 보여서"가 아니라 **stdout 이 실제로 순수해서**다.
9
+
10
+ 그럼에도 잡음 자체는 실재했고 원인 3개는 모두 진짜 결함이라 고쳤다:
11
+
12
+ - **프로브가 stderr 를 안 가로챘다** — `fail()` 은 `--json` 모드에서 `✗` 를 **stderr** 로 보내는데(io.js 설계), 두 케이스(`fail()` exitCode 행위 · `lib/io` 프리미티브)는 `process.stdout.write` 만 스왑했다. 양쪽 스트림을 스왑하도록 수정 — 이미 있던 패턴(4558행 케이스는 `_w`/`_e` 둘 다 스왑)을 따랐다.
13
+ - **프로브가 io 전역 래치를 오염시킨 채 빠져나갔다** — `fail()` 은 `_failLatched`/`_firstFail`/exit 훅을 세우는데 `finally` 가 `exitCode` 만 복구했다. 남은 300여 케이스 내내 래치가 살아 있어 사람용 `log()` 가 전부 stderr 로 강등됐다. `setQuiet(false)`(기존 "래치 초기화" 관용구, 5838행)로 복구.
14
+ - **graph 케이스에 `quiet: true` 누락** — `graph --html` 온톨로지 케이스 4개 중 **하나만** deps 에 `quiet` 를 안 넘겨 사람용 3줄이 샜다.
15
+
16
+ **핵심: stdout 순수성만 단언하는 가드였다면 공허했다.** 세 결함을 하나씩 되돌린 절제 실험에서 세 변종 모두 `pureStdout=true` · `payloadOk=true` · `exit=0` 으로 **통과**한다 — 잡히는 축은 오직 stderr 잡음뿐이었다. 위 두 번째 항목이 이유다: 래치가 사람용 출력을 stderr 로 강등해 주는 **우연** 덕분에 stdout 이 겨우 지켜지고 있었고, 래치 동작이 바뀌는 순간 그대로 stdout 오염이 된다. 그래서 신설 가드는 stdout 순수성 + 페이로드 + **stderr 잡음 0** 을 함께 단언한다.
17
+
18
+ > **가드가 덮는 범위 정정** (출하 전 적대 검수에서 실측): 신설 가드는 세 결함 중 **둘**(stderr 미스왑 · graph quiet 누락)을 잡고, **래치 오염은 잡지 못한다** — 래치만 되돌린 변종은 `stderr.len=0` 으로 가드를 그대로 통과한다. 래치 복구는 잡음을 만들지 않고 *없애는* 방향이라 "잡음 0" 축에 걸리지 않기 때문이다. 따라서 "원인 단계에서 막는다"는 세 결함 전부가 아니라 둘에 대해서만 성립한다. 또한 기존 5049 블록이 "e2e 기본 임시 cwd 로만 돌았다"는 서술도 사실이 아니었다 — 그 블록은 `cwd` 옵션이 없어 e2e 프로세스의 cwd(= `npm test` 실행 위치 = 리포 루트)를 그대로 상속한다(`process.chdir` 사용 0회, 실측 확인). 신설 가드의 실제 추가 가치는 cwd 차이가 아니라 **stderr 축을 단언한다는 점**이다.
19
+
20
+ - **신설 e2e 가드** — `selftest --json` 을 **리포 루트 cwd** 로 spawn 해 ① `stdout.trimStart().startsWith('{')` ② `JSON.parse(stdout)` 성공 + `ok/pass===total/fail===0` ③ stderr 에 `✗ ` · `leerness.html →` · `nodes · ` 부재를 단언. 기존 5049 블록은 e2e 기본 임시 cwd 로만 돌았다.
21
+ - **가드의 보장 범위**: `selftest --json` 한 명령의 stdout 단일 JSON 계약과 **알려진 유출원 3종**의 부재만 보장한다. 다른 `--json` 명령의 계약이나, 여기 열거되지 않은 새 유출 문자열은 보장하지 않는다.
22
+
23
+ ### 같은 릴리스에 함께 실린 것 — bugfix 완료 영수증 (P-0005, 옵트인)
24
+
25
+ 사용자 제기: "버그를 고쳐달라고 하면 증상만 고치고 고쳤다고 판정한다." 실사용 저장소를 **읽기 전용**으로 스캔해 근거를 먼저 세웠다 — 주문 상태 오판정이 플랫폼(SSG·11번가…)을 갈아가며 8회 반복, 한 파일이 22회 수정, 그 프로젝트 lessons 11건은 전부 개별 API 특이사항이고 **클래스 교훈은 0건**.
26
+
27
+ - **개입 지점은 `--status done` 전이 하나**. leerness 는 이미 debug 렌즈·review-request 로 올바른 절차를 안내하지만 그건 조언 텍스트일 뿐 **완료 시점에 소비되지 않는다**. 조언을 더 만드는 것이야말로 증상 처방이라 판단했다.
28
+ - **`leerness bugfix start <ID> --repro "<cmd>" --expect-bad "<신호>"`** — 등록은 probe 가 **지금, 2회 연속** 실패하고 기대 신호가 출력에 있을 때만 허용한다. 통과해버리는 probe 는 나중에 아무것도 증명하지 못한다.
29
+ - **`bugfix receipt`** 로 근본원인·형제범위를 남기고, `done` 시 probe 통과 + 영수증 존재를 요구한다. `bugfix drop` 으로 등록 취소.
30
+ - **기본 OFF(옵트인)** — `toggle set bugfix-receipt on` 으로만 켜진다. 이를 위해 토글 레지스트리에 `defaultOff` 를 신설했다. 기존 동작을 바꾸는 신규 토글이 기본 ON 이면 업그레이드만으로 모든 사용자의 `task update` 가 깨진다.
31
+ - **정직성 계약**: 기계적으로 검증되는 것은 **probe 뿐**이다. 근본원인·형제범위는 선언이며 출력이 그렇게 명시한다(`ⓘ 아래는 선언이며 내용의 진위는 검증하지 않았습니다`). 신뢰 경계도 모듈 헤더에 적었다 — 저장소 쓰기 권한자는 우회할 수 있으므로 이 게이트는 "성실한 작업자의 성급한 완료"를 막지, 적대적 우회를 막지 않는다.
32
+ - **기각한 것**: 재오픈 ID 경고(실측 정밀도 19% — 후속 커밋 37건 중 문서 14·확장 4·되돌림 1) · 파일 hotspot 경고(원래 자주 바뀌는 파일과 구분 불가) · 형제 분기 자동 제시(**구문 분기 ≠ 도메인 형제**, 기계가 발명할 수 없다).
33
+
34
+ **검수에서 잡힌 결함 8건은 전부 실행으로 재현한 뒤 고쳤다** (자체 헌트 3 + codex 5):
35
+
36
+ - **완료 렌더가 `task update` 를 죽였다** — baseline 없는 엔트리가 유효로 통과해 `e.baseline.exit` 에서 TypeError. 스토어 계약을 조여(`baseline.failed === true` 필수) 형상 무효로 보류하도록.
37
+ - **공허한 영수증** — `--siblings ","` 가 `checked: []` 를 만들며 "기록됨"으로 통과. 빈 목록은 기록이 아니다.
38
+ - **이 기능을 만들게 한 도메인에서의 오차단** — 출력에 `SyntaxError:` 가 있으면 런처 오류로 단정해, 11번가가 JSON 대신 HTML 을 주는 바로 그 케이스의 probe 가 **수정을 마치고 exit 0 이어도 완료가 막혔다**. 런처 판정은 하드 신호(spawn 실패·127·9009)만 보고, 출력 문자열은 등록 시점의 **비차단 경고**로 강등.
39
+ - **1회성 probe 가 게이트를 무력화** — `if(!exists(marker)){write(marker); exit 1}` 은 등록 때만 실패하고 이후 항상 통과해, 제품 코드를 한 줄도 안 고치고 done 을 통과시킨다. 등록 시 2회 연속 재현 요구로 차단(실측: 거부 · 결정적 probe 대조군은 정상 등록).
40
+ - **동시 등록이 서로를 지웠다** — 읽은 목록 전체를 덮어써서 4개 동시 등록 중 **2개가 사라졌고**, 사라진 task 는 "미등록"이 되어 게이트가 조용히 꺼졌다(fail-open). `_withLock` 주입 + 락 안 재로드 + 임시파일 rename → 실측 4/4 보존.
41
+ - **지키지 못할 약속 문구** — 게이트가 꺼진 프로젝트에서도 "done 시 probe 가 통과해야 합니다"라고 안내했다. 실제 토글 상태를 읽어 꺼져 있으면 그렇게 고지한다.
42
+ - **이미 done 인 task 재편집이 막혔다** — 조건이 `status === 'done'` 뿐이라 전이가 아닌 호출도 게이트를 탔고, probe 스크립트가 사라진 뒤엔 완료된 task 를 영영 손댈 수 없었다. 현재 상태와 비교해 **전이일 때만** 검사.
43
+ - **exit 0 인데 실패 신호 잔존** — 경고로 노출하되 **막지 않는다**. 여기서 잘못 막으면 이미 고쳐진 probe 는 재등록할 방법이 없어(등록이 실패를 요구) 사용자가 빠져나올 수 없다.
44
+
45
+ **출하 직전 적대 헌트(6렌즈·36에이전트)가 21건을 확인하고 9건을 반증했다.** 반증 9건은 설계상 명시된 한계(신뢰 경계·형상 무효 시 fail-closed 등)와 맞아 기각했다. 확인된 것 중 셋은 이 기능의 **뼈대**를 바꿨다:
46
+
47
+ - **게이트가 한 호출부에만 걸려 있었다** — `task sync --from`(TodoWrite 왕복, 하네스가 `session-workflow.md` 에서 직접 지시하는 정규 경로)이 경고 한 줄 없이 exit 0 으로 done 을 기록했다. 즉 게이트가 막으려는 주체가 **정상 명령 하나로** 무력화할 수 있었고, 사후에 알려주는 표면도 없었다. 판정을 `taskUpdate` 에서 progress 행을 쓰는 **단일 병목(`upsertProgress`)** 으로 옮기고, 막힐 때는 조용히 무시할 수 없도록 **던지게** 했다(반환값 무시가 이 클래스의 재발 양식이다). 이제 새 호출부가 생겨도 자동으로 게이트를 탄다.
48
+ - **"이미 done 이면 스킵"이 동기 시나리오를 정확히 무력화했다** — 1.36.87 에서 codex 지적(완료된 task 를 영영 못 고친다)을 받아 넣은 조건인데, **재오픈 버그**(1라운드 done → 회귀 → probe 등록 → 다시 done)가 그대로 통과했다. 이 기능이 존재하는 이유가 바로 그 시나리오다. 스킵을 없애고, 갇히는 문제는 `leerness bugfix drop <ID>` 라는 **명시적 탈출구**로 푼다.
49
+ - **가드가 배선을 한 줄도 실행하지 않았다** — selftest 는 `checkDoneTransition` 을 in-process 로만 불러서, 게이트를 통째로 죽여도 341/341 그린이었다. 실제 CLI 경로로 도는 e2e 가드를 신설하고 **변이 8종으로 탐지력을 증명**했다(게이트 제거·차단결과 무시·sync 예외 삼킴·재오픈 스킵 복귀·없는id 확인 제거·probe 검사 제거·영수증 검사 제거·토글 무시 → 8/8 검출). 처음 만든 가드는 6/8 이었는데, 살아남은 둘은 **서로를 가려주고 있었다**(probe 검사와 영수증 검사 중 하나를 죽여도 다른 하나가 막아 exit code 만 보는 단언에는 안 잡힘) — 사유(`code`)까지 단언하고 "수정했지만 영수증 없음" 판별 케이스를 넣어 해소했다.
50
+
51
+ 나머지 확인건도 함께 정리했다: 존재하지 않는 task id 로도 등록에 성공해 **오타 하나면 "보호받는 줄 아는" 무보호**가 되던 구멍(등록 시 tracker 확인) · `ENOBUFS`(출력 8MB 초과)를 "명령이 실행되지 못했습니다"로 오분류해 통과하는 probe 를 **사실과 다른 사유로** 차단하던 것(판정 불가는 판정 불가라고 말하고 별도 code 로 분리) · `bugfix list` 가 위치 경로를 무시하고 cwd 를 읽어 등록된 probe 를 "없음"으로 보고하던 것 · 목록과 게이트가 형제범위를 **서로 다른 술어**로 판정하던 불일치(단일 술어로 통합) · 토글 설명이 적용 범위(probe 를 등록한 task 에 한함)를 말하지 않던 것 · `leerness commands` 와 설치되는 `session-workflow.md` 에 이 기능이 아예 없어 사용자가 발견할 수 없던 것.
52
+
53
+ 곁가지로 토글 값 해석도 고쳤다 — 종전 `v !== false` 는 `null`/`0`/`"off"` 를 **ON 으로** 읽었다. 기본 ON 토글에서는 무해했지만 차단 게이트가 생긴 순간 "알 수 없는 값이 차단을 켜는" 동작이 된다. 인식 가능한 표현만 받고 나머지는 레지스트리 기본값으로 되돌린다(변이 테스트로 가드 유효성 확인: 종전 규칙 복귀 시 케이스 FAIL).
54
+
55
+ e2e 의 `doctor` 제한도 손봤다 — `doctor` 는 내부에서 selftest 를 돌려 실측 19~24s 인데 제한이 20s 였다(**게시본 1.36.86 기준 여유 0.9초**). 120s 로 올리면서 **종료코드 단언을 함께 넣었다**: 제한만 올리고 status 를 안 보면 타임아웃(status=null)이 출력 매칭만으로 통과한다.
56
+ - 래치 초기화가 새 stdout 오염을 만들지 않는지 확인했다(래치 해제 후 사람용 `log()` 은 stdout 으로 간다) — 수정 후 실측 stderr 길이 **0**, stdout 순수 JSON. 즉 잔여 유출원은 문자열 매칭이 아니라 **전량 부재**로 확인했다.
57
+ - **stderr 를 "빈 문자열" 로 단언하지 않은 이유**: Node 가 환경에 따라 stderr 로 `DeprecationWarning` 등을 내보낸다(이번 e2e 실행에서도 관측). 빈 문자열 단언은 런타임/노드 버전에 따라 깨지므로 **알려진 유출 문자열 3종의 부재**로 좁혔다.
58
+ - 검증: selftest **341/341**(`--json` · 사람용 양쪽) · e2e **407/407**(1796s) · 리포 루트/임시 cwd 양쪽 stdout 순수성 실측 · 신설 가드 **4방향 절제 실험**(fix 3종 개별 되돌림 + 전체 되돌림 → 모두 FAIL, 각 되돌림이 정확히 해당 잡음만 되살림). `graph --html` 실명령의 사람용 3줄 출력은 무변경 확인.
59
+ - 참고(이번 수정과 무관): 1차 e2e 실행에서 `doc/surface` 1건이 실패했다가 재실행에서 통과했다 — `doctor --json` 은 격리 실측 **21.1s** 인데 해당 블록 제한이 **30s**(여유 1.4배)라 전체 부하에서 간헐 초과한다. 1.36.87 이 같은 이유로 다른 타임아웃을 올렸던 것과 동일 패턴이며, 이 블록은 아직 안 올라가 있다.
60
+
3
61
  ## 1.36.86 — 2026-07-29 — brainstorm 두 경로 수집 단일화(중복 149줄 제거) · 총계 계약 정정 · 조용한 절단 제거
4
62
 
5
63
  1.36.85 가 다음 라운드 과제로 남긴 "사람용 vs `--json` 이 갈린다"를 근본 수정했다. 원인은 **세 겹**이었다: ① 사람용이 자체 수집 코드를 갖고 있어 `code` 를 아예 모으지 않았고 ② 렌더러에 code 섹션이 없었으며 ③ 총계를 각자 인라인 계산해 서로 다른 표면을 누락했다.
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.86 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.88 하네스를 사용합니다. 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.86는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.88는 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.86는 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.86 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.88 릴리스 태그 이력** (수백 라운드) · _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.86: 2026-07-29
238
+ Last synced by Leerness v1.36.88: 2026-07-29
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.86';
37
+ const VERSION = '1.36.88';
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') 시 호스트 프로세스 오염.
@@ -238,7 +238,7 @@ function _resolveRoot(positional) {
238
238
  }
239
239
  function nonFlagArgs() {
240
240
  const out = [];
241
- const withValue = new Set(['--language','--skills','--path','--status','--progress','--goal','--reason','--next','--target','--token-env','--package','--out','--from','--to','--repo','--id','--note','--evidence','--query','--limit','--action','--agent','--tool','--doc','--command','--capability','--before','--after','--display','--threshold','--trigger','--check','--set','--min-score','--include','--days','--gh-pages-src','--roadmap','--since','--agents','--model','--timeout','--retry-on-fail','--label','--score','--tokens','--alternatives','--impact','--tag','--surface','--depends-on','--affects','--co-changes-with','--files','--branch','--remote','--task-add','--next-action','--role','--provider','--env-var','--deploy','--token-lifetime-hours','--port','--secret','--keep','--shell','--ps-version','--done-when','--test-cmd','--require-referee','--referee','--expect-bad','--good','--bad','--static-dir','--dev-url']); // 1.14.2 (UR-0032): --done-when 값이 positional 로 누출돼 milestone 제목에 흡수되던 것 차단. 1.17.2 (UR-0045): --test-cmd 동일 원칙(신규 value-flag 는 반드시 여기 등록). 1.36.82 (검수 High#3): --require-referee 미등록으로 `gate --require-referee <id>` 의 값이 positional 경로로 흡수돼 root 가 <cwd>/<id> 가 됐다 — 툴이 안내하는 문법 그대로 쓰면 깨졌다. 1.36.80/81 신규 value-flag 도 소급 등록.
241
+ const withValue = new Set(['--language','--skills','--path','--status','--progress','--goal','--reason','--next','--target','--token-env','--package','--out','--from','--to','--repo','--id','--note','--evidence','--query','--limit','--action','--agent','--tool','--doc','--command','--capability','--before','--after','--display','--threshold','--trigger','--check','--set','--min-score','--include','--days','--gh-pages-src','--roadmap','--since','--agents','--model','--timeout','--retry-on-fail','--label','--score','--tokens','--alternatives','--impact','--tag','--surface','--depends-on','--affects','--co-changes-with','--files','--branch','--remote','--task-add','--next-action','--role','--provider','--env-var','--deploy','--token-lifetime-hours','--port','--secret','--keep','--shell','--ps-version','--done-when','--test-cmd','--require-referee','--referee','--expect-bad','--good','--bad','--static-dir','--dev-url','--repro','--root-cause','--siblings','--siblings-na','--previous-gap']); // 1.14.2 (UR-0032): --done-when 값이 positional 로 누출돼 milestone 제목에 흡수되던 것 차단. 1.17.2 (UR-0045): --test-cmd 동일 원칙(신규 value-flag 는 반드시 여기 등록). 1.36.82 (검수 High#3): --require-referee 미등록으로 `gate --require-referee <id>` 의 값이 positional 경로로 흡수돼 root 가 <cwd>/<id> 가 됐다 — 툴이 안내하는 문법 그대로 쓰면 깨졌다. 1.36.80/81 신규 value-flag 도 소급 등록.
242
242
  const a = process.argv.slice(2);
243
243
  for (let i = 0; i < a.length; i++) {
244
244
  const x = a[i];
@@ -619,6 +619,10 @@ leerness drift check . # 4 신호 + 4단계 레벨
619
619
  \`\`\`
620
620
  - 사용자 요청을 5W1H로 분해. 모호하면 명확화 질문 (autonomous 모드 제외).
621
621
  - drift critical 시 \`leerness session close .\` 또는 \`drift check --auto-fix\` 우선 실행.
622
+ - **버그 수정 요청이면 코드를 고치기 전에 재현부터**: \`leerness bugfix start <T-ID> --repro "<재현 명령>" --expect-bad "<실패 신호>"\`
623
+ → 지금 실패하는 probe 를 먼저 등록해야, 나중에 "고쳤다"가 **증상만 덮은 것인지** 구분된다. 수정 후엔
624
+ \`leerness bugfix receipt <T-ID> --root-cause "..." --siblings "같은 원인이 걸린 다른 지점들"\` 로 근본원인과 형제 범위를 남긴다.
625
+ (완료 게이트는 옵트인: \`leerness toggle set bugfix-receipt on\`)
622
626
 
623
627
  ## Step 2. 계획 수립
624
628
  - 작업이 3 step 이상 → TodoWrite 또는 \`leerness plan add\` 사용.
@@ -631,10 +635,10 @@ leerness agents list # ready CLI 확인
631
635
  leerness agents quota # 한도 확인
632
636
  leerness agents dispatch "<task>" --to <id> # 작업 유형 추천 자동
633
637
  \`\`\`
634
- - 작업 유형별 최적 sub-agent:
635
- - 텍스트/번역/분석 → claude (1.7× 빠름)
636
- - 깊은 코드 추론 → codex (가장 상세)
637
- - 파일 직접 수정 → agy --yolo (정확, Antigravity CLI)
638
+ - 작업 유형별 기본 sub-agent 매핑 (leerness 의 **기본 관례**이며 측정된 성능·품질 순위가 아닙니다):
639
+ - 텍스트/번역/분석 → claude
640
+ - 깊은 코드 추론 → codex
641
+ - 파일 직접 수정 → agy --yolo (Antigravity CLI)
638
642
  - 보안 리뷰 → \`leerness review --persona security\`
639
643
  - **충돌 방지 규칙 (필수)**:
640
644
  - 각 sub-agent에 *자신만 수정할 파일 경로* 명시
@@ -3413,10 +3417,23 @@ function _selfTestCases() {
3413
3417
  fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true });
3414
3418
  // 토글 저장/로드 행위 + 기본 ON
3415
3419
  const d0 = tg.loadToggles(tmp);
3416
- const defOn = Object.values(d0).every(v => v === true);
3420
+ // 1.36.87: 원래 의도는 "**기존** 토글이 조용히 꺼지지 않는다" 이지 "모든 토글이 영원히 기본 ON" 이 아니다.
3421
+ // 기존 동작을 바꾸는 신규 토글은 반드시 defaultOff(옵트인)여야 업그레이드만으로 사용자 워크플로가 깨지지 않는다.
3422
+ // → 레지스트리 표시대로인지 양방향 단언(기본 ON 이어야 할 것 · 기본 OFF 여야 할 것 둘 다).
3423
+ const defOn = Object.entries(d0).every(([k, v]) => v === !(tg.TOGGLE_REGISTRY[k] || {}).defaultOff)
3424
+ && ['gate', 'lens', 'auto-graph', 'delegation-brief'].every(k => d0[k] === true)
3425
+ && Object.keys(tg.TOGGLE_REGISTRY).some(k => tg.TOGGLE_REGISTRY[k].defaultOff && d0[k] === false);
3417
3426
  tg.saveToggles(tmp, Object.assign({}, d0, { gate: false }));
3418
3427
  const offOk = tg.loadToggles(tmp).gate === false && tg.toggleOn(tmp, 'lens') === true;
3419
- behavOk = defOn && offOk;
3428
+ // 1.36.87: 해석 — 인식 불가 값이 **차단 게이트를 켜면 안 된다**(종전 `v !== false` 는 null/0/"off" 를 ON 으로 읽었다, 실측).
3429
+ // 반대로 손으로 쓴 "off" 문자열이 보호 게이트를 켠 채로 두던 것도 함께 고친다.
3430
+ const tf = path.join(tmp, '.harness', 'toggles.json');
3431
+ const withVal = (v) => { fs.writeFileSync(tf, JSON.stringify({ 'bugfix-receipt': v, gate: v })); return tg.loadToggles(tmp); };
3432
+ const coerceOk = [null, 0, 'off', 'OFF', 'false', [], {}].every(v => withVal(v)['bugfix-receipt'] === false)
3433
+ && withVal(true)['bugfix-receipt'] === true && withVal('on')['bugfix-receipt'] === true
3434
+ && withVal('off').gate === false && withVal(undefined).gate === true && withVal(undefined)['bugfix-receipt'] === false;
3435
+ fs.writeFileSync(tf, JSON.stringify(Object.assign({}, d0, { gate: false })));
3436
+ behavOk = defOn && offOk && coerceOk;
3420
3437
  // 그래프 HTML: 탭 3종 + roadmap/toggles 데이터 + 임베드 JS 문법
3421
3438
  const m = require('../lib/graph');
3422
3439
  const out = path.join(tmp, 'leerness.html');
@@ -3898,7 +3915,7 @@ function _selfTestCases() {
3898
3915
  return compoundEnv && jsonKey && camelKey && slackApp && dictFP && plainStart && djangoKey && pkVariants && dbDefaultFP;
3899
3916
  } },
3900
3917
  { name: 'honesty-check: AI 인식론적 정직성 3차원 + MCP/CLI/strict 통합 (사용자명시 1.9.305)', run: () => { const h = _epistemicHonestyCheck; const d1 = h('이 기능은 항상 정상 동작합니다').findings.some(f => f.dim === 'pretend-knowledge'); const d2 = h('아마 될 것 같습니다. 구현 완료했습니다').findings.some(f => f.dim === 'premature-judgment'); const d3 = h('이 API 의 rate limit 은 초당 5회입니다').findings.some(f => f.dim === 'no-info-gathering'); const clean = h('src/api.js 수정, 12/12 통과 (Exit: 0)').ok === true; const src = read(__filename); const wired = require('../lib/mcp-tools').some(t => t.name === 'leerness_honesty_check') && /if \(cmd === 'honesty-check'\)/.test(src) && /honestyFindings = _epistemicHonestyCheck/.test(src); return d1 && d2 && d3 && clean && wired; } },
3901
- { name: 'exit code 일관성: fail()→exitCode 1 행위 + unknown 명령 안내 (UR-0045 / CV-5 행위화 1.9.366)', run: () => { if (typeof fail !== 'function') return false; const saved = process.exitCode; const _w = process.stdout.write; let setOk = false; try { process.stdout.write = () => true; process.exitCode = 0; fail('selftest probe'); setOk = process.exitCode === 1; } finally { process.stdout.write = _w; process.exitCode = saved; } const src = read(__filename); const dispatchOk = /알 수 없는 명령: \$\{cmd\}/.test(src); return setOk && dispatchOk; } },
3918
+ { name: 'exit code 일관성: fail()→exitCode 1 행위 + unknown 명령 안내 (UR-0045 / CV-5 행위화 1.9.366)', run: () => { if (typeof fail !== 'function') return false; const saved = process.exitCode; const _w = process.stdout.write, _e = process.stderr.write; let setOk = false; try { process.stdout.write = () => true; process.stderr.write = () => true; process.exitCode = 0; fail('selftest probe'); setOk = process.exitCode === 1; } finally { process.stdout.write = _w; process.stderr.write = _e; process.exitCode = saved; setQuiet(false); } const src = read(__filename); const dispatchOk = /알 수 없는 명령: \$\{cmd\}/.test(src); return setOk && dispatchOk; } },
3902
3919
  { name: 'brief: 프로젝트 청사진 set/show/export + README 개요 섹션 (UR-0055 사용자명시 1.9.307)', run: () => { const src = read(__filename); const fnOk = typeof briefCmd === 'function' && typeof _loadBrief === 'function' && typeof _briefBlueprint === 'function' && _BRIEF_FIELDS.length === 10; const b = { project: 'X', intro: 'i', purpose: 'p', problem: '', features: ['f1', 'f2'], stack: ['s'], architecture: '', users: [], success: [], nonGoals: [], currentState: '' }; const bp = _briefBlueprint(b, VERSION); const bpOk = /Blueprint/.test(bp) && /소개 \(Intro\)/.test(bp) && /f1/.test(bp) && /신규 프로젝트 시작 가이드/.test(bp); const rb = _briefReadmeBlock(b); const rbOk = rb.includes(BRIEF_START) && rb.includes(BRIEF_END) && /프로젝트 개요/.test(rb) && /\*\*목적\*\*/.test(rb); return fnOk && bpOk && rbOk && /if \(cmd === 'brief'\)/.test(src); } },
3903
3920
  { name: 'brief 2단계: update --direction 이력 + MCP leerness_brief + context 통합 (UR-0055 1.9.308)', run: () => { const src = read(__filename); const b = { project: 'X', intro: '', purpose: '', problem: '', features: [], stack: [], architecture: '', users: [], success: [], nonGoals: [], currentState: '', directionHistory: ['2026-06-04: 확대'] }; const bpOk = /개발 방향 이력/.test(_briefBlueprint(b, VERSION)) && /최근 개발 방향 변경/.test(_briefReadmeBlock(b)); const histWired = /sub === 'update'/.test(src) && /brief\.directionHistory \|\| \[\]\), `\$\{today\(\)\}/.test(src); const mcpOk = require('../lib/mcp-tools').some(t => t.name === 'leerness_brief'); const ctxOk = /brief: \{ intro:/.test(src); return bpOk && histWired && mcpOk && ctxOk; } },
3904
3921
  { name: 'verify-claim: done 주장 evidence 기본강제 + --lenient + MCP/json 도달 (UR-0048 설치리뷰 critical 1.9.309)', run: () => { const src = read(__filename); const def = /const mustHaveEvidence = !has\('--lenient'\) && \(isDoneClaim \|\| has\('--require-evidence'\)\)/.test(src); const threshold = /has\('--require-evidence'\) \? evq\.ok : \(evq\.hasFile \|\| evq\.hasTest \|\| evq\.hasLog\)/.test(src); const jsonWired = /evidenceComplete:/.test(src) && /if \(!evidenceQualityOk\) out\.reasons\.push\('evidence-incomplete'\)/.test(src) && /if \(!out\.ok\) return process\.exit\(1\)/.test(src); /* 1.35.7: reasons 기반 단일 exit 재배선(의도 동일 — evidence 게이트가 json exit 도달) */ const mcpLenient = !!require('../lib/mcp-tools').find(t => t.name === 'leerness_verify_claim').inputSchema.properties.lenient; return def && threshold && jsonWired && mcpLenient; } },
@@ -3996,7 +4013,7 @@ function _selfTestCases() {
3996
4013
  { name: 'UR-0025 심화: pulse 렌더 코어 분리 — _memorySurface + _renderPulseLine 행위 (1.9.379)', run: () => { const m = require('../lib/pure-utils'); if (typeof _memorySurface !== 'function' || typeof _renderPulseLine !== 'function' || m._memorySurface !== _memorySurface || m._renderPulseLine !== _renderPulseLine) return false; const ms = _memorySurface({ tasks: 1, decisions: 2, rules: 3, milestones: 4, lessons: 5 }) === 'T1/D2/R3/P4/L5' && _memorySurface({}) === 'T0/D0/R0/P0/L0'; const base = _renderPulseLine({ version: '1.0.0', roundCount: 7, mcpTools: 85, memorySurface: 'T0/D1/R0/P2/L0' }); const ln = base.includes('v1.0.0') && base.includes('R7') && base.includes('MCP 85') && base.includes('T0/D1/R0/P2/L0') && !base.includes('🎯') && !base.includes('abnormal'); const full = _renderPulseLine({ version: '1.0.0', roundCount: 7, mcpTools: 85, memorySurface: 'x', nextMilestone: 400, etaDays: 6, abnormalShutdown: 'high' }); const ln2 = full.includes('🎯 R400 (6d)') && full.includes('abnormal:high'); const wired = read(__filename).includes('const line = _renderPulseLine(data)') && read(__filename).includes('data.memorySurface = _memorySurface('); return ms && ln && ln2 && wired; } },
3997
4014
  { name: 'UR-0025: REQUIRED_WORKSPACE_FILES 단일출처 — verify/migrate audit·apply 3중 중복 제거 (1.9.380)', run: () => { const c = require('../lib/catalogs'); if (REQUIRED_WORKSPACE_FILES !== c.REQUIRED_WORKSPACE_FILES) return false; const listOk = Array.isArray(c.REQUIRED_WORKSPACE_FILES) && c.REQUIRED_WORKSPACE_FILES.length === 9 && c.REQUIRED_WORKSPACE_FILES.includes('AGENTS.md') && c.REQUIRED_WORKSPACE_FILES.includes('.harness/plan.md'); const harnessUses = (read(__filename).match(/const required = REQUIRED_WORKSPACE_FILES;/g) || []).length >= 1; const migUses = (read(path.join(path.dirname(__filename), '..', 'lib', 'migrate.js')).match(/const required = REQUIRED_WORKSPACE_FILES;/g) || []).length >= 2; return listOk && harnessUses && migUses; } },
3998
4015
  { name: 'UR-0025: KEYWORD_STOPWORDS 단일출처 — handoff/lessons 키워드 stopwords 2중 중복 제거 (1.9.381)', run: () => { const c = require('../lib/catalogs'); if (KEYWORD_STOPWORDS !== c.KEYWORD_STOPWORDS) return false; const setOk = c.KEYWORD_STOPWORDS instanceof Set && c.KEYWORD_STOPWORDS.has('작업') && c.KEYWORD_STOPWORDS.has('task') && !c.KEYWORD_STOPWORDS.has('고유단어') && c.KEYWORD_STOPWORDS.size >= 25; const usesConst = (read(__filename).match(/const stopwords = KEYWORD_STOPWORDS;/g) || []).length >= 2 && !/const stopwords = new Set\(\[/.test(read(__filename)); return setOk && usesConst; } },
3999
- { name: 'UR-0025 큰핸들러토대: lib/io.js 프리미티브(log/ok/warn/fail/today/now) 모듈 분리 + 동작 (1.9.382)', run: () => { const io = require('../lib/io'); const exportsOk = ['log', 'ok', 'warn', 'fail', 'today', 'now'].every(k => typeof io[k] === 'function') && io.log === log && io.fail === fail && io.now === now; const todayOk = /^\d{4}-\d{2}-\d{2}$/.test(io.today()) && /^\d{4}-\d{2}-\d{2}T/.test(io.now()); const src = read(__filename); const moved = src.includes("require('../lib/io')") && !/^function fail\(s\) \{ log/m.test(src) && !/^function now\(\) \{ return new Date/m.test(src); let exitOk = false; const saved = process.exitCode; const _w = process.stdout.write; try { process.stdout.write = () => true; process.exitCode = 0; io.fail('probe'); exitOk = process.exitCode === 1; } finally { process.stdout.write = _w; process.exitCode = saved; } return exportsOk && todayOk && moved && exitOk; } },
4016
+ { name: 'UR-0025 큰핸들러토대: lib/io.js 프리미티브(log/ok/warn/fail/today/now) 모듈 분리 + 동작 (1.9.382)', run: () => { const io = require('../lib/io'); const exportsOk = ['log', 'ok', 'warn', 'fail', 'today', 'now'].every(k => typeof io[k] === 'function') && io.log === log && io.fail === fail && io.now === now; const todayOk = /^\d{4}-\d{2}-\d{2}$/.test(io.today()) && /^\d{4}-\d{2}-\d{2}T/.test(io.now()); const src = read(__filename); const moved = src.includes("require('../lib/io')") && !/^function fail\(s\) \{ log/m.test(src) && !/^function now\(\) \{ return new Date/m.test(src); let exitOk = false; const saved = process.exitCode; const _w = process.stdout.write, _e = process.stderr.write; try { process.stdout.write = () => true; process.stderr.write = () => true; process.exitCode = 0; io.fail('probe'); exitOk = process.exitCode === 1; } finally { process.stdout.write = _w; process.stderr.write = _e; process.exitCode = saved; io.setQuiet(false); } return exportsOk && todayOk && moved && exitOk; } },
4000
4017
  { name: 'UR-0025 큰핸들러토대: lib/io.js fs 프리미티브(read/writeUtf8/exists/mkdirp/append/rel/absRoot) 분리 + round-trip (1.9.383)', run: () => { const io = require('../lib/io'); const exp = ['absRoot', 'exists', 'read', 'readBuf', 'mkdirp', 'writeUtf8', 'append', 'rel'].every(k => typeof io[k] === 'function') && io.read === read && io.writeUtf8 === writeUtf8 && io.exists === exists; const src = read(__filename); const moved = !/^function writeUtf8\(p, s\) \{/m.test(src) && !/^function read\(p\) \{/m.test(src) && !/^function exists\(p\) \{/m.test(src); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_io_')); let rt = false; try { const f = path.join(tmp, 'a', 'b.txt'); io.writeUtf8(f, '한글RT'); rt = io.exists(f) && io.read(f) === '한글RT' && io.rel(tmp, f) === 'a/b.txt'; } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } return exp && moved && rt; } },
4001
4018
  { name: '5th외부평가/UR-0085: status --json 구조화 출력 + verify --json 와이어 (1.9.384)', run: () => { if (typeof status !== 'function' || typeof verify !== 'function') return false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sj_')); const save = process.argv; const _w = process.stdout.write; let so = ''; try { fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true }); process.argv = ['node', 'h', 'status', tmp, '--json']; process.stdout.write = s => { so += s; return true; }; status(tmp); } catch {} finally { process.stdout.write = _w; process.argv = save; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } let sj; try { sj = JSON.parse(so); } catch {} const statusOk = !!sj && typeof sj.total === 'number' && typeof sj.present === 'number' && 'healthy' in sj && Array.isArray(sj.missing); const src = read(__filename); const verifyWired = /function verify\(root\) \{[\s\S]*?has\('--json'\)[\s\S]*?JSON\.stringify\(\{ ok:/.test(src); return statusOk && verifyWired; } },
4002
4019
  { name: '5th외부평가/UR-0086: _parseContractSpec markdown bullet 함수 감지 + 순수 추출 (1.9.385)', run: () => { const m = require('../lib/pure-utils'); if (m._parseContractSpec !== _parseContractSpec) return false; const p = _parseContractSpec('# Spec\n- add(a,b)\n* subtract(a,b)\n1. multiply(a,b)\nfunction legacy(x)\n`mentioned(`\ntick.amount\n'); const declOk = ['add', 'subtract', 'multiply', 'legacy'].every(n => p.declared.includes(n)) && p.declared.length === 4; const menOk = p.mentioned.includes('mentioned') && !p.declared.includes('mentioned'); const fieldOk = p.fields.includes('amount'); const fpOk = _parseContractSpec('- 합계 (a+b)\n- result (total)\n- foo: bar(x)\n**bold**').declared.length === 0; const src = read(__filename); const moved = src.includes('_parseContractSpec(specText)') && !/specText\.matchAll\(\/function/.test(src); return declOk && menOk && fieldOk && fpOk && moved; } },
@@ -4022,7 +4039,7 @@ function _selfTestCases() {
4022
4039
  { name: '6번째 외부평가/codex P1-B: task drop 존재확인 가드 — 없는 ID 가짜 row 방지 (1.9.396)', run: () => { const src = read(__filename); const i = src.indexOf('function taskDrop(root, id)'); if (i < 0) return false; const body = src.slice(i, i + 700); return body.includes('not found in progress-tracker.md') && body.includes('rows.find(r => r.id === id)') && body.includes('_requireInit'); } },
4023
4040
  { name: '6번째 외부평가/codex P1-A (UR-0098): install-safety 레시피 셸-무관 + hardeningNote (1.9.397)', run: () => { if (typeof installSafetyCmd !== 'function') return false; const save = process.argv; const _w = process.stdout.write; let out = ''; try { process.argv = ['node', 'h', 'install-safety', '--json']; process.stdout.write = s => { out += s; return true; }; installSafetyCmd({ json: true }); } catch {} finally { process.stdout.write = _w; process.argv = save; } let j; try { j = JSON.parse(out); } catch {} const noPosixPrefix = !!j && Array.isArray(j.safeInstall) && !j.safeInstall.some(x => /^npm_config_\w+=/.test(String(x).trim())); const crossShell = !!j && j.safeInstall.filter(x => String(x).includes('npx --yes')).length >= 2; const noteOk = !!j && typeof j.hardeningNote === 'string' && j.hardeningNote.includes('PowerShell'); return noPosixPrefix && crossShell && noteOk; } },
4024
4041
  { name: '6번째 외부평가/codex P1-C (UR-0099): --json 에러 경로 구조화 failJson + 와이어 (1.9.398)', run: () => { const io = require('../lib/io'); if (io.failJson !== failJson) return false; const _w = process.stdout.write; const saved = process.exitCode; let jOut = '', hOut = ''; let jExit = 0; try { process.stdout.write = s => { jOut += s; return true; }; process.exitCode = 0; failJson(true, 'tc', 'm'); jExit = process.exitCode; process.stdout.write = s => { hOut += s; return true; }; process.exitCode = 0; failJson(false, 'c', 'humanmsg'); } catch {} finally { process.stdout.write = _w; process.exitCode = saved; } let pj; try { pj = JSON.parse(jOut); } catch {} const jsonOk = !!pj && pj.ok === false && pj.code === 'tc' && pj.error === 'm' && jExit === 1; const humanOk = hOut.includes('✗') && hOut.includes('humanmsg') && !hOut.includes('{'); const src = read(__filename); const wired = src.includes("failJson(_j, 'missing_args'") && src.includes("failJson(_j, 'spec_not_found'"); return jsonOk && humanOk && wired; } },
4025
- { name: 'T-0077 graph --html: leerness.html 온톨로지 생성 + 노드/엣지/XSS 무결성 (1.34.3)', run: () => { const m = require('../lib/graph'); const expOk = typeof m.graphHtmlCmd === 'function' && typeof m.buildGraphData === 'function'; const src = read(__filename); const delegated = src.includes("require('../lib/" + "graph')") && src.includes('function graphHtmlCmd(root) { return ' + '_graph.graphHtmlCmd('); const gSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'graph.js')); const movedToLib = gSrc.includes('buildGraphData') && gSrc.includes('String.raw') && gSrc.includes('/*__DATA__' + '*/null'); let behavOk = false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_graph_')); const _w = process.stdout.write; try { process.stdout.write = () => true; fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true }); fs.writeFileSync(path.join(tmp, '.harness', 'progress-tracker.md'), '| ID | Status | Request | Evidence | Next | Updated |\n|---|---|---|---|---|---|\n| T-0001 | done | first task | - | - | 2026-06-26 |\n| T-0002 | in-progress | follow-up to T-0001 </scr' + 'ipt> | - | - | 2026-06-26 |\n'); const deps = { _roadmapData, _loadDecisions, _loadLessons }; const data = m.buildGraphData(tmp, deps); const dataOk = data.nodes.some(n => n.id === 'T-0001') && data.nodes.some(n => n.id === 'T-0002') && data.counts.task >= 2; const edgeOk = data.edges.some(e => e.source === 'T-0002' && e.target === 'T-0001'); const out = path.join(tmp, 'leerness.html'); const r = m.graphHtmlCmd(tmp, Object.assign({ has: () => false }, deps), out); const html = fs.readFileSync(out, 'utf8'); const placeholderGone = !html.includes('/*__DATA__' + '*/null'); const hasNode = html.includes('T-0002'); const xssSafe = (html.match(/<\/script>/g) || []).length === 1; behavOk = dataOk && edgeOk && placeholderGone && hasNode && xssSafe && !!r && r.ok === true && fs.existsSync(out); } catch (e) { behavOk = false; } finally { process.stdout.write = _w; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } return expOk && delegated && movedToLib && behavOk; } },
4042
+ { name: 'T-0077 graph --html: leerness.html 온톨로지 생성 + 노드/엣지/XSS 무결성 (1.34.3)', run: () => { const m = require('../lib/graph'); const expOk = typeof m.graphHtmlCmd === 'function' && typeof m.buildGraphData === 'function'; const src = read(__filename); const delegated = src.includes("require('../lib/" + "graph')") && src.includes('function graphHtmlCmd(root) { return ' + '_graph.graphHtmlCmd('); const gSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'graph.js')); const movedToLib = gSrc.includes('buildGraphData') && gSrc.includes('String.raw') && gSrc.includes('/*__DATA__' + '*/null'); let behavOk = false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_graph_')); const _w = process.stdout.write; try { process.stdout.write = () => true; fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true }); fs.writeFileSync(path.join(tmp, '.harness', 'progress-tracker.md'), '| ID | Status | Request | Evidence | Next | Updated |\n|---|---|---|---|---|---|\n| T-0001 | done | first task | - | - | 2026-06-26 |\n| T-0002 | in-progress | follow-up to T-0001 </scr' + 'ipt> | - | - | 2026-06-26 |\n'); const deps = { _roadmapData, _loadDecisions, _loadLessons }; const data = m.buildGraphData(tmp, deps); const dataOk = data.nodes.some(n => n.id === 'T-0001') && data.nodes.some(n => n.id === 'T-0002') && data.counts.task >= 2; const edgeOk = data.edges.some(e => e.source === 'T-0002' && e.target === 'T-0001'); const out = path.join(tmp, 'leerness.html'); const r = m.graphHtmlCmd(tmp, Object.assign({ has: () => false }, deps, { quiet: true }), out); const html = fs.readFileSync(out, 'utf8'); const placeholderGone = !html.includes('/*__DATA__' + '*/null'); const hasNode = html.includes('T-0002'); const xssSafe = (html.match(/<\/script>/g) || []).length === 1; behavOk = dataOk && edgeOk && placeholderGone && hasNode && xssSafe && !!r && r.ok === true && fs.existsSync(out); } catch (e) { behavOk = false; } finally { process.stdout.write = _w; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } return expOk && delegated && movedToLib && behavOk; } },
4026
4043
  { name: 'T-0077 후속 graph auto-gen: handoff opt-in 배선 + quiet 무로그 (1.34.4)', run: () => { const m = require('../lib/graph'); const src = read(__filename); const wired = src.includes('_maybeAuto' + 'Graph(_hp)') && src.includes('LEERNESS_AUTO_' + 'GRAPH'); let quietOk = false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_autograph_')); const _w = process.stdout.write; let so = ''; try { process.stdout.write = s => { so += s; return true; }; fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true }); fs.writeFileSync(path.join(tmp, '.harness', 'progress-tracker.md'), '| ID | Status | Request | Evidence | Next | Updated |\n|---|---|---|---|---|---|\n| T-0001 | done | x | - | - | 2026-06-26 |\n'); const out = path.join(tmp, 'leerness.html'); const r = m.graphHtmlCmd(tmp, { _roadmapData, _loadDecisions, _loadLessons, quiet: true }, out); quietOk = !!r && r.ok === true && fs.existsSync(out) && so === ''; } catch (e) { quietOk = false; } finally { process.stdout.write = _w; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } return wired && quietOk; } },
4027
4044
  { name: 'graph --html: 빈/미초기화 하네스 무크래시 + 유효 빈 HTML (1.35.0 방어가드)', run: () => { const m = require('../lib/graph'); let ok = false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_emptyg_')); const _w = process.stdout.write; let so = ''; try { process.stdout.write = s => { so += s; return true; }; fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true }); const data = m.buildGraphData(tmp, {}); const out = path.join(tmp, 'leerness.html'); const r = m.graphHtmlCmd(tmp, { quiet: true }, out); const html = fs.readFileSync(out, 'utf8'); const closers = html.split('</' + 'script>').length - 1; const dm = html.match(/var DATA = (\{[\s\S]*?\});/); let parsed = null; try { parsed = JSON.parse(dm[1]); } catch (e) {} ok = !!data && Array.isArray(data.nodes) && data.nodes.length === 0 && !!r && r.ok === true && r.nodes === 0 && fs.existsSync(out) && closers === 1 && !!parsed && parsed.nodes.length === 0 && so === ''; } catch (e) { ok = false; } finally { process.stdout.write = _w; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } return ok; } },
4028
4045
  { name: 'graph --html: 임베드 script JS 신택스 유효성(U+2028/정규식 리터럴 회귀 영구 차단, 1.35.2)', run: () => { const m = require('../lib/graph'); let ok = false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_gjs_')); const _w = process.stdout.write; let so = ''; try { process.stdout.write = s => { so += s; return true; }; fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true }); fs.writeFileSync(path.join(tmp, '.harness', 'progress-tracker.md'), '| ID | Status | Request | Evidence | Next | Updated |\n|---|---|---|---|---|---|\n| T-0001 | done | x </scr' + 'ipt> & <b> ' + String.fromCharCode(0x24) + '{y} | M-0002 | - | 2026-06-26 |\n'); const out = path.join(tmp, 'leerness.html'); m.graphHtmlCmd(tmp, { _roadmapData, _loadDecisions, _loadLessons, quiet: true }, out); const html = fs.readFileSync(out, 'utf8'); const o = '<scr' + 'ipt>', c = '</scr' + 'ipt>'; const js = html.slice(html.indexOf(o) + o.length, html.lastIndexOf(c)); let synOk = false; try { new Function(js); synOk = true; } catch (e) { synOk = false; } ok = js.length > 200 && synOk; } catch (e) { ok = false; } finally { process.stdout.write = _w; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } return ok; } },
@@ -5113,6 +5130,83 @@ function _selfTestCases() {
5113
5130
  return lessonsOk && decisionsOk && humanOk;
5114
5131
  } catch { return false; } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
5115
5132
  } },
5133
+ { name: 'bugfix 완료 영수증 (1.36.87, P-0005): 기본 무영향 · 탐지력 없는 probe 거부 · 미수정 done 차단 · 통과 시 선언 명시 (행위)', run: () => {
5134
+ // 설계 계약(검토 통과분): 개입 지점은 `--status done` 하나. 무영향 조건 3중 —
5135
+ // 토글 OFF(기본) · done 전이 아님 · 해당 task 에 probe 미등록. 셋 중 하나면 기존 동작 그대로여야 한다.
5136
+ // 기계 검증은 probe(수정 전 실패 → 수정 후 통과)뿐이고 근본원인/형제범위는 선언이다 — 출력이 그렇게 말해야 한다.
5137
+ // 비용 주의: 1차판은 CLI 를 10회 spawn 해 selftest 에 **+6초**를 더했고, 이미 여유 0.9초였던
5138
+ // e2e 의 doctor(내부에서 selftest 실행, 제한 20s)를 확정적으로 넘겼다 — 게이트가 잡았다.
5139
+ // 상태기계 전이는 **in-process** 로 검사하고, 배선(디스패치·토글·출력문구)만 CLI 1회로 확인한다.
5140
+ const _bf = require('../lib/bugfix');
5141
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_bfx_'));
5142
+ try {
5143
+ mkdirp(path.join(d, '.harness'));
5144
+ writeUtf8(path.join(d, '.harness', 'HARNESS_VERSION'), VERSION);
5145
+ const ID = 'T-0009';
5146
+ const _flag = path.join(d, 'bf_fixed.txt');
5147
+ const _probe = `node -e "const f=require('fs');if(!f.existsSync('bf_fixed.txt')){console.error('BUG_HERE');process.exit(3)}"`;
5148
+ // in-process 호출은 stdout 에 쓴다 — `selftest --json` 의 출력을 오염시켜 소비자의 JSON.parse 가 깨진다
5149
+ // (실측: 첫 `{` 가 이 가드의 출력이 됐다). --json 순수성은 leerness 가 반복해 고쳐온 계약이므로 억제한다.
5150
+ const _quiet = (fn) => { const w = process.stdout.write; process.stdout.write = () => true; try { return fn(); } finally { process.stdout.write = w; } };
5151
+ // ① probe 미등록 → 무영향(선언하지 않은 task 는 막지 않는다)
5152
+ if (_bf.checkDoneTransition(d, ID).blocked !== false) return false;
5153
+ // ② 재현되는 probe 등록(수정 전 상태에서 실패해야 등록됨)
5154
+ _quiet(() => _bf.bugfixCmd(d, 'start', [ID], { has: (f) => f === '--json', arg: (f) => (f === '--repro' ? _probe : f === '--expect-bad' ? 'BUG_HERE' : '') }));
5155
+ const _st = _bf._loadChecked(d);
5156
+ if (_st.invalid || !_st.list.some(x => x.id === ID && x.baseline && x.baseline.failed)) return false;
5157
+ // ③ 수정 전 done 차단
5158
+ if (_bf.checkDoneTransition(d, ID).code !== 'probe_still_failing') return false;
5159
+ // ④ 수정 후에도 영수증 없으면 차단
5160
+ writeUtf8(_flag, 'ok');
5161
+ if (_bf.checkDoneTransition(d, ID).code !== 'receipt_incomplete') return false;
5162
+ // ⑤ 영수증 후 통과 + 출력이 "선언"임을 명시
5163
+ _quiet(() => _bf.bugfixCmd(d, 'receipt', [ID], { has: (f) => f === '--json', arg: (f) => (f === '--root-cause' ? '원인 한 문장' : f === '--siblings-na' ? '단일 경로' : '') }));
5164
+ const _pass = _bf.checkDoneTransition(d, ID);
5165
+ if (_pass.blocked !== false) return false;
5166
+ const _txt = (_pass.lines || []).join('\n');
5167
+ if (!(/재현 probe 통과/.test(_txt) && /선언/.test(_txt) && /진위는 검증하지 않았/.test(_txt))) return false;
5168
+ // ⑥ **배선**만 CLI 로 1회 — 모듈 로드/디스패치/토글 연결이 실제로 되는지(in-process 로는 안 드러남)
5169
+ const _cli = cp.spawnSync(process.execPath, [__filename, 'bugfix', 'list', '--path', d, '--json'],
5170
+ { encoding: 'utf8', timeout: 60000, maxBuffer: 8 * 1024 * 1024 });
5171
+ if (_cli.status !== 0) return false;
5172
+ const _j = JSON.parse(_cli.stdout.slice(_cli.stdout.indexOf('{')));
5173
+ if (!(_j.ok === true && (_j.receipts || []).some(r => r.id === ID && r.rootCause))) return false;
5174
+ // ⑦⑧⑨ 1.36.87 자체 헌트로 실측한 3결함 — 전부 손편집/구버전 스토어와 정상 앱 출력에서 나온다.
5175
+ const _put = (list) => writeUtf8(path.join(d, '.harness', 'bugfix-receipts.json'), JSON.stringify(list, null, 2));
5176
+ const _ok0 = `node -e "process.exit(0)"`;
5177
+ // ⑦ baseline 없는/재현된 적 없는 엔트리는 **형상 무효**로 보류한다 — 종전엔 유효로 통과해
5178
+ // (a) 완료 렌더에서 TypeError 로 `task update` 가 통째로 죽고 (b) 손으로 써넣은 probe 가 게이트를 통과했다.
5179
+ _put([{ id: 'T-0101', repro: _ok0, expectBad: 'X', rootCause: 'rc', siblingScope: { checked: ['a'] } }]);
5180
+ if (_bf.checkDoneTransition(d, 'T-0101').code !== 'store_invalid') return false; // 던지면 바깥 catch 로 떨어져 실패 처리됨
5181
+ _put([{ id: 'T-0101', repro: _ok0, expectBad: 'X', baseline: { failed: false, exit: 0 }, rootCause: 'rc', siblingScope: { checked: ['a'] } }]);
5182
+ if (_bf.checkDoneTransition(d, 'T-0101').code !== 'store_invalid') return false;
5183
+ // ⑧ 빈 형제 목록은 "기록됨"이 아니다 (`--siblings ","` 가 checked:[] 로 통과하던 공허 영수증)
5184
+ _put([{ id: 'T-0102', repro: _ok0, expectBad: 'X', baseline: { failed: true, exit: 3 }, rootCause: 'rc', siblingScope: { checked: [] } }]);
5185
+ if (_bf.checkDoneTransition(d, 'T-0102').code !== 'receipt_incomplete') return false;
5186
+ // ⑨ 앱이 정상적으로 뱉는 문자열(SyntaxError 등)을 런처 오류로 오분류해 **완료를 막던** false-BLOCK
5187
+ const _synProbe = `node -e "console.log('SyntaxError: Unexpected token < in JSON at position 0')"`;
5188
+ _put([{ id: 'T-0103', repro: _synProbe, expectBad: 'SyntaxError', baseline: { failed: true, exit: 1 }, rootCause: 'rc', siblingScope: { notApplicable: '단일' } }]);
5189
+ if (_bf.checkDoneTransition(d, 'T-0103').blocked !== false) return false;
5190
+ // ⑩ 1회성 probe 거부 — 상태를 남겨 등록 때만 실패하는 probe 는 제품 코드를 한 줄도 안 고치고 통과시킨다.
5191
+ // 등록은 **2회 연속** 재현을 요구해야 한다(codex 26차 #3).
5192
+ const _once = path.join(d, 'once.flag').replace(/\\/g, '/');
5193
+ const _oneShot = `node -e "const f=require('fs');if(!f.existsSync('${_once}')){f.writeFileSync('${_once}','1');console.error('BUG_HERE');process.exit(3)}"`;
5194
+ _put([]);
5195
+ // 등록 거부는 process.exitCode 를 1로 세운다 — in-process 로 부르면 selftest 자신의 종료코드가 오염된다. 복원.
5196
+ const _ec = process.exitCode;
5197
+ _quiet(() => { _bf.bugfixCmd(d, 'start', ['T-0105'], { has: (f) => f === '--json', arg: (f) => (f === '--repro' ? _oneShot : f === '--expect-bad' ? 'BUG_HERE' : '') }); });
5198
+ process.exitCode = _ec;
5199
+ if (_bf._loadChecked(d).list.some(x => x.id === 'T-0105')) return false;
5200
+ // ⑪ CLI 도 같은 규칙 — 빈 형제 목록은 거부(exit 1 + code) · 등록 취소 경로 존재
5201
+ _put([{ id: 'T-0104', repro: _ok0, expectBad: 'X', baseline: { failed: true, exit: 3 } }]);
5202
+ const _cli2 = cp.spawnSync(process.execPath, [__filename, 'bugfix', 'receipt', 'T-0104', '--siblings', ',', '--path', d, '--json'],
5203
+ { encoding: 'utf8', timeout: 60000, maxBuffer: 8 * 1024 * 1024 });
5204
+ const _j2 = JSON.parse(_cli2.stdout.slice(_cli2.stdout.indexOf('{')));
5205
+ if (!(_cli2.status === 1 && _j2.ok === false && _j2.code === 'empty_siblings')) return false;
5206
+ _quiet(() => _bf.bugfixCmd(d, 'drop', ['T-0104'], { has: (f) => f === '--json', arg: () => '' }));
5207
+ return !_bf._loadChecked(d).list.some(x => x.id === 'T-0104') && _bf.checkDoneTransition(d, 'T-0104').blocked === false;
5208
+ } catch { return false; } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
5209
+ } },
5116
5210
  { name: 'brainstorm 두 경로 등가 (1.36.86): 사람용/--json 이 표면별로 같은 결과 — 수집 단일화 회귀 가드 (행위)', run: () => {
5117
5211
  // 사고: 사람용이 자체 수집 코드를 갖고 있어 `--include-code` 에서 --json 은 code 1건,
5118
5212
  // 사람용은 "관련 자원 없음" 을 냈다. 수집을 _brainstormFor 로 단일화(중복 149줄 제거)하고
@@ -7377,6 +7471,7 @@ function commandsCmd(root) {
7377
7471
  { cmd: 'integrity check [--repair] [--json]', desc: 'managed 정책-문서 12종 무결성(부재/H1 상실/절단) 점검 — --repair: archive 대피 후 템플릿 재생성 — 1.36.57 감사 F-04' },
7378
7472
  { cmd: 'referee add|verify|list|show|drop', desc: '검증기 캘리브레이션 — 신뢰 전에 탐지력 증명(known-good 통과 + 망가뜨린 known-bad 를 기대 사유로 거부). verify-claim --referee / gate 게이팅 — 1.36.80 P-0001' },
7379
7473
  { cmd: 'preview add|list|show|approve|revise|mockup', desc: '신규 기능 미리보기 승인 워크플로 — approve 전 코드 작성 금지 계약. mockup <P-ID> [--force]: 자립형 HTML 디자인 시안 스캐폴드 · add --mockup <파일>: 기존 시안 첨부 — 1.36.51 UR-0061 · 1.36.75 UR-0066' },
7474
+ { cmd: 'bugfix start|receipt|drop|list', desc: 'bugfix 완료 영수증 — 수정 전 재현 probe 를 등록(2회 연속 실패해야 등록)하고, done 전이에서 통과 + 근본원인/형제범위 영수증을 요구. 토글 기본 OFF: leerness toggle set bugfix-receipt on — 1.36.87 P-0005' },
7380
7475
  { cmd: 'review <file> --persona <ids>', desc: '페르소나 리뷰 (1.9.29)' },
7381
7476
  { cmd: 'brainstorm "<topic>" [--include-code]', desc: '워크스페이스 회수 + 코드 grep' }
7382
7477
  ],
@@ -9918,7 +10013,21 @@ function writeProgressRows(root, header, rows, opts = {}) {
9918
10013
  (rows.length ? '\n' : '') + _suffix;
9919
10014
  writeUtf8(progressPath(root), composed);
9920
10015
  }
10016
+ // 1.36.88 (출하전 헌트 #1/#2/#17/#18): bugfix 완료 게이트를 **여기 한 곳**에서 건다.
10017
+ // 종전엔 taskUpdate 에만 배선돼 있었고, 실측 결과 `task sync --from`(하네스가 세션 워크플로에서
10018
+ // 직접 지시하는 TodoWrite 왕복 경로)이 경고 한 줄 없이 exit 0 으로 done 을 기록했다.
10019
+ // progress 행을 쓰는 경로는 전부 이 함수를 지나므로, 새 호출부가 생겨도 자동으로 게이트를 탄다.
10020
+ // 막힐 때 **던지는** 이유: 반환값을 무시하면 무언 통과가 되고, 그게 이 결함 클래스의 재발 양식이다.
10021
+ let _lastBugfixLines = null; // 통과 시 렌더용 — 직후 호출자가 _consumeBugfixLines() 로 가져간다
10022
+ function _consumeBugfixLines() { const l = _lastBugfixLines; _lastBugfixLines = null; return l; }
9921
10023
  function upsertProgress(root, row) {
10024
+ // 게이트는 **락 밖에서** — probe 는 최대 10분까지 도는 사용자 명령이라 progress 락을 물고 있으면 안 된다.
10025
+ if (row && row.status === 'done' && _tgl.toggleOn(root, 'bugfix-receipt')) {
10026
+ const _bfm = require('../lib/bugfix');
10027
+ const _r = _bfm.checkDoneTransition(root, row.id);
10028
+ if (_r.blocked) throw new _bfm.BugfixBlocked(_r, row.id);
10029
+ _lastBugfixLines = _r.lines || null;
10030
+ }
9922
10031
  // 1.9.303 (UR-0043): read-modify-write 전체를 락으로 직렬화 — 동시 task 쓰기 lost-update 방지.
9923
10032
  return _withLock(progressPath(root), () => {
9924
10033
  const header = progressHeader(root);
@@ -10274,10 +10383,33 @@ function taskUpdate(root, id) {
10274
10383
  if (arg('--evidence') !== null) patch.evidence = arg('--evidence');
10275
10384
  if (arg('--next') !== null) patch.nextAction = arg('--next');
10276
10385
  if (arg('--note')) patch.request = arg('--note');
10277
- upsertProgress(root, patch);
10386
+ // 1.36.87 (P-0005, codex+자체 이중검토 통과분): bugfix 완료 게이트 — **`done` 전이가 유일한 개입 지점**이다.
10387
+ // leerness 는 이미 debug 렌즈·review-request 로 올바른 절차를 안내하지만 그건 조언 텍스트일 뿐
10388
+ // 완료 시점에 소비되지 않는다(조언을 더 만드는 것이야말로 증상 처방).
10389
+ // 무영향 조건 3중: 토글 OFF(기본) · done 전이가 아님 · 해당 task 에 bugfix probe 미등록 → 기존 동작 그대로.
10390
+ // 1.36.88: 게이트 판정은 upsertProgress(단일 병목) 안에서 일어난다 — 여기서는 렌더만 한다.
10391
+ // 1.36.87 은 "이미 done 이면 스킵"을 넣었지만(codex 26차 #9), 그게 **재오픈 버그 시나리오를 정확히 무력화**했다:
10392
+ // 1라운드에서 done → 회귀 → probe 등록 → 다시 done 이 그대로 통과. 이 기능의 동기가 바로 그 시나리오다.
10393
+ // probe 가 낡아 갇히는 문제는 스킵이 아니라 `leerness bugfix drop <ID>` 로 푼다(문서화된 탈출구).
10394
+ let _bfLines = null;
10395
+ try {
10396
+ upsertProgress(root, patch);
10397
+ _bfLines = _consumeBugfixLines();
10398
+ } catch (e) {
10399
+ if (!e || e.name !== 'BugfixBlocked') throw e;
10400
+ if (has('--json')) { log(JSON.stringify({ ok: false, code: e.code, id, error: e.message }, null, 2)); }
10401
+ else {
10402
+ fail(`done 보류 — ${e.message}`);
10403
+ (e.lines || []).forEach(l => log(' ' + l));
10404
+ log(` ⓘ 탈출구: leerness bugfix drop ${id} (probe 등록 취소) · leerness toggle set bugfix-receipt off (프로젝트 정책)`);
10405
+ }
10406
+ process.exitCode = 1;
10407
+ return;
10408
+ }
10278
10409
  // 1.36.0 (클린룸 리뷰 B, --json 무결성): task add(1.9.413) 는 --json 성공 출력이 있으나 task update 는 누락 — 성공 시 평문만 내보내 JSON 소비 스크립트가 성공 경로에서만 크래시(에러 경로는 이미 구조화). 동일 패턴으로 보강.
10279
- if (has('--json')) { log(JSON.stringify({ ok: true, ...patch })); return; }
10410
+ if (has('--json')) { log(JSON.stringify({ ok: true, ...patch, ...(_bfLines ? { bugfixReceipt: _bfLines } : {}) })); return; }
10280
10411
  ok(`task updated: ${id}`);
10412
+ if (_bfLines) _bfLines.forEach(l => log(' ' + l));
10281
10413
  _autoRoadmap(absRoot(root), 'data-change');
10282
10414
  _emitHandoffNudge(root); // 1.36.8: 넛지 확장 — 작업 중 가장 자주 실행하는 명령(update)에도 노출
10283
10415
  }
@@ -15160,20 +15292,21 @@ function _recommendAgent(task) {
15160
15292
  if (!task || typeof task !== 'string') return { target: null, reason: '' };
15161
15293
  const t = task.toLowerCase();
15162
15294
  const hasAny = (keywords) => keywords.some(k => t.includes(k));
15163
- // 텍스트 분석/번역 claude (가장 빠름, 1.7×)
15295
+ // 1.36.88: 사유 문구에서 **검증되지 않은 성능·품질 주장**을 제거한다("1.7× 빠름" · "가장 상세" · "정확").
15296
+ // leerness 는 이 수치를 측정한 적이 없고, 벤더 모델은 몇 달마다 바뀌어 순위 주장은 곧 낡는다.
15297
+ // 이 매핑은 **기본 관례**이지 벤치마크 결과가 아니며, 사유 문구가 그렇게 말해야 한다.
15298
+ // (근거 표시는 추후 난이도 라우팅 P-0007 의 감사기록에서 확장된다.)
15164
15299
  if (hasAny(['translate', 'summary', 'explain', 'describe', 'analyze', 'review',
15165
15300
  '번역', '요약', '설명', '분석', '리뷰'])) {
15166
- return { target: 'claude', reason: '텍스트 분석·요약·번역은 claude 1.7× 빠름' };
15301
+ return { target: 'claude', reason: '키워드 매칭: 텍스트 분석·요약·번역 claude (기본 관례이며 측정된 성능 비교 아님)' };
15167
15302
  }
15168
- // 깊은 코드 추론
15169
15303
  if (hasAny(['architecture', 'design pattern', 'refactor', 'trace', 'complex', 'critical path',
15170
15304
  '아키텍처', '리팩터', '복잡'])) {
15171
- return { target: 'codex', reason: '깊은 코드 추론은 codex 가장 상세' };
15305
+ return { target: 'codex', reason: '키워드 매칭: 깊은 코드 추론 codex (기본 관례이며 측정된 품질 비교 아님)' };
15172
15306
  }
15173
- // 파일 작성·수정·생성
15174
15307
  if (hasAny(['create', 'write', 'generate', 'patch', 'fix', 'implement', 'edit',
15175
15308
  '구현', '생성', '작성', '수정', '추가'])) {
15176
- return { target: 'agy', reason: '워크스페이스 직접 수정은 agy --yolo (Antigravity) 정확' };
15309
+ return { target: 'agy', reason: '키워드 매칭: 워크스페이스 직접 수정 agy --yolo (기본 관례이며 측정된 정확도 비교 아님)' };
15177
15310
  }
15178
15311
  return { target: null, reason: '' };
15179
15312
  }
@@ -23012,21 +23145,41 @@ function taskSyncCmd(root) {
23012
23145
  catch (e) { fail(`JSON 파싱 실패: ${e.message}`); return process.exit(1); }
23013
23146
  if (!Array.isArray(todos)) { fail('JSON 최상위는 배열이어야 함'); return process.exit(1); }
23014
23147
  let imported = 0, updated = 0;
23148
+ // 1.36.88 (출하전 헌트 #1/#17): 이 경로가 bugfix 완료 게이트를 통째로 우회했다 — TodoWrite 를 completed 로
23149
+ // 바꿔 되돌려 넣는 것이 이 명령의 정규 사용법이라, 게이트가 막으려는 주체가 명령 하나로 무력화했다.
23150
+ // 게이트는 upsertProgress 안에서 던진다. 여기서는 **한 건이 막혀도 나머지는 처리**하고, 막힌 건을 보고한다.
23151
+ const blocked = [];
23015
23152
  for (const t of todos) {
23016
23153
  if (!t || !t.content) continue;
23017
23154
  const status = t.status === 'completed' ? 'done' : t.status === 'in_progress' ? 'in-progress' : 'planned';
23018
23155
  // 이미 같은 request 있는지
23019
23156
  const existing = readProgressRows(root).find(r => r.request === t.content);
23020
- if (existing) {
23021
- if (existing.status !== status) {
23022
- upsertProgress(root, { id: existing.id, status });
23023
- updated++;
23157
+ try {
23158
+ if (existing) {
23159
+ if (existing.status !== status) {
23160
+ upsertProgress(root, { id: existing.id, status });
23161
+ updated++;
23162
+ }
23163
+ } else {
23164
+ const id = nextId(root, 'T');
23165
+ upsertProgress(root, { id, status, request: t.content, evidence: 'todowrite-sync', nextAction: t.activeForm || '다음 액션' });
23166
+ imported++;
23024
23167
  }
23025
- } else {
23026
- const id = nextId(root, 'T');
23027
- upsertProgress(root, { id, status, request: t.content, evidence: 'todowrite-sync', nextAction: t.activeForm || '다음 액션' });
23028
- imported++;
23168
+ } catch (e) {
23169
+ if (!e || e.name !== 'BugfixBlocked') throw e;
23170
+ blocked.push({ id: e.taskId, content: String(t.content).slice(0, 60), code: e.code, message: e.message });
23171
+ }
23172
+ }
23173
+ if (blocked.length) {
23174
+ if (has('--json')) {
23175
+ log(JSON.stringify({ ok: false, code: 'bugfix_blocked', imported, updated, blocked }, null, 2));
23176
+ process.exitCode = 1;
23177
+ return;
23029
23178
  }
23179
+ fail(`${blocked.length}건이 bugfix 완료 게이트에 걸려 done 으로 반영되지 않았습니다`);
23180
+ blocked.forEach(b => log(` - ${b.id} ${b.content} — ${b.message}`));
23181
+ log(` ⓘ 탈출구: leerness bugfix drop <ID> · leerness toggle set bugfix-receipt off (프로젝트 정책)`);
23182
+ process.exitCode = 1;
23030
23183
  }
23031
23184
  log(`# leerness task sync (1.9.38)`);
23032
23185
  log(`from: ${full}`);
@@ -24038,6 +24191,25 @@ async function main() {
24038
24191
  }
24039
24192
  return refereeCmd(arg('--path', process.cwd()), _rvToks[0], _rvToks.slice(1));
24040
24193
  }
24194
+ // 1.36.87 (P-0005): bugfix 완료 영수증 — 값 플래그가 positional 로 새지 않게 원시 argv 파싱(referee/preview 와 동일 패턴)
24195
+ if (cmd === 'bugfix') {
24196
+ const _bfRaw = process.argv.slice(2); const _bfI = _bfRaw.indexOf('bugfix'); const _bfToks = [];
24197
+ for (let i = _bfI + 1; i < _bfRaw.length && _bfI >= 0; i++) {
24198
+ if (_bfRaw[i].startsWith('--')) { if (['--repro', '--expect-bad', '--root-cause', '--siblings', '--siblings-na', '--previous-gap', '--path'].includes(_bfRaw[i]) && _bfRaw[i + 1]) i++; continue; }
24199
+ _bfToks.push(_bfRaw[i]);
24200
+ }
24201
+ // 1.36.88 (출하전 헌트 #7): `bugfix list <path>` 가 위치 경로를 조용히 무시하고 cwd 를 읽어,
24202
+ // 등록된 probe 를 "없음"으로 보고했다 — 다른 명령들과 같은 규율로 위치 경로를 받는다.
24203
+ // (start/receipt/drop 의 첫 토큰은 task id 이므로, 경로로 오인하지 않도록 list/무인자에서만 적용)
24204
+ const _bfSub = _bfToks[0];
24205
+ const _bfRest = _bfToks.slice(1);
24206
+ let _bfRoot = arg('--path', null);
24207
+ if (!_bfRoot && (!_bfSub || _bfSub === 'list')) _bfRoot = _taskPositionalPath(['bugfix', ...(_bfSub ? [_bfSub] : []), ..._bfRest], _bfSub ? 2 : 1);
24208
+ // _withLock: 동시 등록이 서로를 지우면 게이트가 조용히 꺼진다(실측 4→2)
24209
+ // _taskExists: 존재하지 않는 id 로 등록되면 오타 하나로 "보호받는 줄 아는" 무보호 상태가 된다(출하전 헌트 #3)
24210
+ return require('../lib/bugfix').bugfixCmd(_bfRoot || process.cwd(), _bfSub, _bfRest,
24211
+ { has, arg, _withLock, _taskExists: (r, id) => readProgressRows(r).some(x => x.id === id) });
24212
+ }
24041
24213
  if (cmd === 'lens') return lensCmd(args[1]); // 1.18.3 (UR-0003): 분야별 자기질문 품질 렌즈
24042
24214
  // 1.9.233: leerness commands — 카테고리화된 전체 CLI 명령 목록
24043
24215
  if (cmd === 'commands') return commandsCmd(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd());
@@ -24312,7 +24484,16 @@ async function main() {
24312
24484
  if (require.main === module) {
24313
24485
  main()
24314
24486
  .then(() => { if (process.exitCode && process.exitCode !== 0) process.exit(process.exitCode); })
24315
- .catch(err => { fail(err && err.message ? err.message : String(err)); process.exit(1); });
24487
+ .catch(err => {
24488
+ fail(err && err.message ? err.message : String(err));
24489
+ // 1.36.88: 완료 게이트는 upsertProgress 에서 던진다 — taskUpdate/taskSync 외의 경로로 올라와도
24490
+ // 스택 트레이스 대신 탈출구를 보여준다(막힌 사용자가 빠져나갈 방법을 알아야 한다).
24491
+ if (err && err.name === 'BugfixBlocked') {
24492
+ (err.lines || []).forEach(l => log(' ' + l));
24493
+ log(` ⓘ 탈출구: leerness bugfix drop ${err.taskId} (probe 등록 취소) · leerness toggle set bugfix-receipt off (프로젝트 정책)`);
24494
+ }
24495
+ process.exit(1);
24496
+ });
24316
24497
  }
24317
24498
 
24318
24499
  // 1.9.255: 테스트/디버그용 내부 함수 export (require.main 가드 덕분에 require 시 CLI 미실행).
package/lib/bugfix.js ADDED
@@ -0,0 +1,335 @@
1
+ // lib/bugfix.js — bugfix 완료 영수증 (1.36.87, P-0005 · codex+자체 이중검토 통과분만 구현).
2
+ //
3
+ // 문제(사용자 제기 + 실사용 프로젝트 실측): 버그 수정을 요청하면 증상만 고치고 "고쳤다"고 판정한다.
4
+ // 읽기 전용으로 스캔한 실사용 저장소에서: 주문 상태 오판정이 플랫폼을 갈아가며 8회 반복,
5
+ // 한 파일이 22회 수정, 그 프로젝트 lessons 11건은 전부 개별 API 특이사항이고 **클래스 교훈은 0건**.
6
+ //
7
+ // 검토에서 **기각된 것**(만들지 않는다):
8
+ // · 재오픈 ID 경고 — 실측 정밀도 19%(후속 커밋 37건 중 문서 14·확장 4·되돌림 1). 80% 오탐이라 차단 불가.
9
+ // · 파일 hotspot 경고 — 원래 자주 바뀌는 파일(라우팅·registry·lock)과 구분 불가. dirty worktree 에서
10
+ // "이번 버그가 손댈 파일"을 추론하면 즉시 오염된다.
11
+ // · 형제 분기 자동 제시 — **구문 분기 ≠ 도메인 형제**. 같은 파일 안에서도 플랫폼마다 수집 단위·조기반환이
12
+ // 다르고, 공통 규칙은 분기 뒤에 다시 적용된다. 어떤 invariant 를 고치느냐에 따라 형제 범위가 달라지므로
13
+ // 기계가 발명할 수 없다. 0-deps 로 TS 의미구조를 얻으려면 자체 파서가 필요하고 정규식은 과다/과소 포함된다.
14
+ //
15
+ // 채택한 유일한 개입: **`--status done` 전이**. leerness 는 이미 debug 렌즈와 review-request 로 올바른 절차를
16
+ // 안내하지만 그건 조언 텍스트일 뿐 완료 시점에 소비되지 않는다. 조언을 더 만드는 것이야말로 증상 처방이다.
17
+ //
18
+ // 정직성 계약: 여기서 **기계적으로 검증되는 것은 probe 뿐**이다(수정 전 2회 연속 실패 → 수정 후 통과).
19
+ // 근본원인·형제범위는 **선언**이며 leerness 는 그 내용의 진위를 판정하지 않는다. 출력에 그대로 표시한다.
20
+ //
21
+ // 신뢰 경계(명시): 스토어(.harness/bugfix-receipts.json)는 **프로젝트가 신뢰하는 설정 파일**이고,
22
+ // 등록된 probe 명령은 done 전이에서 셸로 실행된다. 저장소에 쓰기 권한이 있는 주체는 이 게이트를
23
+ // 우회할 수 있다 — 이 기능은 "성실한 작업자의 성급한 완료"를 막지, 적대적 우회를 막지 않는다.
24
+ // 그래서 등록 시 실행 사실을 고지하고(⚠), 형상 무효 스토어는 완료를 보류한다.
25
+ 'use strict';
26
+ const cp = require('child_process');
27
+ const path = require('path');
28
+ const { absRoot, exists, read, writeUtf8, mkdirp, log, ok, warn, failJson, now } = require('./io');
29
+
30
+ function _storePath(root) { return path.join(absRoot(root), '.harness', 'bugfix-receipts.json'); }
31
+
32
+ const _ID_RE = /^[A-Z]+-\d{3,}$/;
33
+
34
+ // 1.36.87 (codex 26차 #5/#7): baseline 을 **필수**로 만든다. 종전엔 baseline 없는/실패하지 않은 엔트리가
35
+ // 유효로 통과해, (1) 완료 렌더에서 TypeError 로 `task update` 가 통째로 죽고(실측),
36
+ // (2) 손으로 써 넣은 "재현된 적 없는" probe 가 게이트를 통과했다.
37
+ // 등록 경로(_start)는 실패한 baseline 만 저장하므로, 그 형상을 스토어 계약으로 못 박는다.
38
+ function _entryValid(e) {
39
+ if (!e || typeof e !== 'object' || Array.isArray(e)) return false;
40
+ if (typeof e.id !== 'string' || !_ID_RE.test(e.id)) return false;
41
+ if (typeof e.repro !== 'string' || !e.repro.trim()) return false;
42
+ if (typeof e.expectBad !== 'string' || !e.expectBad.trim()) return false;
43
+ const b = e.baseline;
44
+ if (!b || typeof b !== 'object' || Array.isArray(b)) return false;
45
+ if (b.failed !== true) return false; // 재현되지 않은 probe 는 등록될 수 없다
46
+ if (typeof b.exit !== 'number') return false;
47
+ if (e.siblingScope != null && (typeof e.siblingScope !== 'object' || Array.isArray(e.siblingScope))) return false;
48
+ if (e.siblingScope && e.siblingScope.checked != null && !Array.isArray(e.siblingScope.checked)) return false;
49
+ if (e.rootCause != null && typeof e.rootCause !== 'string') return false;
50
+ return true;
51
+ }
52
+
53
+ // 형제범위가 "기록됨"인지의 **단일 술어** — 목록·게이트가 갈라지면 사용자가 통과할 줄 알았다가 막힌다.
54
+ // 빈 배열은 기록이 아니다(`--siblings ","` 가 checked:[] 를 만들던 공허 영수증).
55
+ function _siblingScopeOk(sc) {
56
+ if (!sc || typeof sc !== 'object') return false;
57
+ if (Array.isArray(sc.checked) && sc.checked.length > 0) return true;
58
+ return typeof sc.notApplicable === 'string' && !!sc.notApplicable.trim();
59
+ }
60
+
61
+ // 손상/형상무효 스토어는 빈 목록으로 오인하지 않는다(referee/previews 와 동일 규율).
62
+ function _loadChecked(root) {
63
+ const f = _storePath(root);
64
+ if (!exists(f)) return { list: [], invalid: false };
65
+ try {
66
+ const j = JSON.parse(read(f));
67
+ if (!Array.isArray(j) || j.some(e => !_entryValid(e))) return { list: [], invalid: true };
68
+ return { list: j, invalid: false };
69
+ } catch { return { list: [], invalid: true }; }
70
+ }
71
+ // 1.36.87 (codex 26차 #6): 종전엔 읽은 목록 전체를 그대로 덮어써서, 두 등록이 겹치면 나중 쓰기가
72
+ // 앞의 등록을 지웠다 — 지워진 task 는 "미등록"이 되어 게이트가 **조용히 꺼진다**(fail-open).
73
+ // 쓰기 직전 재로드 후 id 기준 병합하고, 임시파일 rename 으로 부분 기록도 막는다.
74
+ // 락은 호출부에서 주입한다(rules/decisions/teams/referee 와 동일 규율) — 재로드+병합만으로는 부족했다:
75
+ // 4개 동시 등록 실측에서 2건이 사라졌다.
76
+ function _atomicWrite(f, list) {
77
+ mkdirp(path.dirname(f));
78
+ const tmp = f + '.tmp' + process.pid;
79
+ writeUtf8(tmp, JSON.stringify(list, null, 2) + '\n');
80
+ require('fs').renameSync(tmp, f);
81
+ }
82
+ function _locked(deps, f, fn) { return (deps && typeof deps._withLock === 'function') ? deps._withLock(f, fn) : fn(); }
83
+ function _upsert(root, entry, deps) {
84
+ const f = _storePath(root);
85
+ return _locked(deps, f, () => {
86
+ const cur = _loadChecked(root); // 락 안에서 재로드 — 밖에서 읽은 목록은 이미 낡았을 수 있다
87
+ const list = (cur.invalid ? [] : cur.list).filter(x => x.id !== entry.id).concat([entry]);
88
+ _atomicWrite(f, list);
89
+ return list;
90
+ });
91
+ }
92
+ function _remove(root, id, deps) {
93
+ const f = _storePath(root);
94
+ return _locked(deps, f, () => {
95
+ const cur = _loadChecked(root);
96
+ if (cur.invalid) return null;
97
+ const list = cur.list.filter(x => x.id !== String(id));
98
+ if (list.length === cur.list.length) return null;
99
+ _atomicWrite(f, list);
100
+ return list;
101
+ });
102
+ }
103
+
104
+ function _run(cmd, cwd) {
105
+ const t0 = Date.now();
106
+ const r = cp.spawnSync(cmd, { cwd, shell: true, encoding: 'utf8', timeout: 600000, maxBuffer: 8 * 1024 * 1024 });
107
+ const out = ((r.stdout || '') + '\n' + (r.stderr || '')).trim();
108
+ const code = r.error ? String(r.error.code || r.error.message) : null;
109
+ return {
110
+ status: (r.status == null ? (r.error ? -1 : 1) : r.status),
111
+ // 1.36.88 (출하전 헌트 #4/#10): ENOBUFS 는 "명령이 실행되지 못한 것"이 아니라 **출력이 한도를 넘어
112
+ // 잘린 것**이다. 종전엔 launcher 실패로 분류해, 수정을 마쳐 통과하는 probe 를 "실행 실패"라는
113
+ // 사실과 다른 사유로 차단했다. 판정 불가는 판정 불가라고 말해야 한다.
114
+ overflow: code === 'ENOBUFS',
115
+ spawnError: (code && code !== 'ENOBUFS') ? code : null,
116
+ timedOut: !!(r.error && String(r.error.code) === 'ETIMEDOUT'),
117
+ output: out,
118
+ sample: out.slice(0, 4000),
119
+ ms: Date.now() - t0,
120
+ };
121
+ }
122
+
123
+ // referee 와 같은 규율: 명령 자체가 실행되지 못한 것은 "버그 재현"이 아니다.
124
+ // **하드 신호만** 본다(spawn 실패 · 127 · 9009). 출력 문자열은 보지 않는다 — 아래 _toolErrorHint 참조.
125
+ function _launcherFailure(r) {
126
+ if (!r) return null;
127
+ if (r.spawnError) return `실행 실패(${r.spawnError})`;
128
+ if (r.status === 127 || r.status === 9009) return `명령을 찾을 수 없음(exit ${r.status})`;
129
+ return null;
130
+ }
131
+
132
+ // 출력 문자열 기반 추정 — **등록 시점의 비차단 경고로만** 쓴다.
133
+ // 1.36.87 실측: 종전에는 출력에 `SyntaxError:` 가 있으면 런처 오류로 단정했다. 그런데 수정을 끝내
134
+ // exit 0 인 probe 라도 앱이 그 문자열을 정상 출력하면(예: 11번가가 JSON 대신 HTML 을 주는 케이스의
135
+ // 재현 로그) `probe_unrunnable` 로 **완료가 막혔다** — 이 기능을 만들게 한 바로 그 도메인에서의 오차단.
136
+ // 게이트 휴리스틱은 false-PASS 로 기울여야지 false-BLOCK 으로 기울면 안 된다.
137
+ function _toolErrorHint(r) {
138
+ if (!r || r.status === 0) return null;
139
+ if (/(?:command not found|not recognized as an internal or external command|cannot find module)/i.test(r.output || '')) {
140
+ return '출력이 도구/경로 오류처럼 보입니다 — 버그 재현이 아닐 수 있으니 probe 를 확인하세요';
141
+ }
142
+ return null;
143
+ }
144
+
145
+ // `leerness bugfix start <ID> --repro "<cmd>" --expect-bad "<신호>"`
146
+ // 수정 **전에** 실행한다. 지금 실패해야 하고, 기대 신호가 출력에 있어야 한다.
147
+ // 이 두 조건이 probe 의 탐지력이다 — 통과해버리는 probe 는 나중에 무엇도 증명하지 못한다.
148
+ function _start(root, id, deps) {
149
+ const { arg, has } = deps;
150
+ const json = !!(has && has('--json'));
151
+ const repro = (arg ? arg('--repro', '') : '') || '';
152
+ const expectBad = (arg ? arg('--expect-bad', '') : '') || '';
153
+ if (!_ID_RE.test(String(id || ''))) { failJson(json, 'invalid_id', `task id 형식이 아님: ${JSON.stringify(id)} (예: T-0007)`); return; }
154
+ // 1.36.88 (출하전 헌트 #3/#19): 존재하지 않는 id 로도 등록에 성공해, 오타 하나면 "보호받는다고 믿는데
155
+ // 실제로는 아무것도 막지 않는" 상태가 됐다. 등록 시점에 tracker 에 있는지 확인한다.
156
+ if (typeof deps._taskExists === 'function' && !deps._taskExists(root, String(id))) {
157
+ failJson(json, 'task_not_found', `progress-tracker 에 없는 task: ${id} — 먼저 leerness task add "<제목>" (오타면 leerness task list 로 확인)`);
158
+ return;
159
+ }
160
+ if (!repro.trim()) { failJson(json, 'missing_repro', '--repro "<버그를 재현하는 명령>" 필요 — 지금 실패해야 합니다'); return; }
161
+ if (!expectBad.trim()) { failJson(json, 'missing_expect', '--expect-bad "<기대 실패 신호>" 필요 — 없으면 무관한 실패와 진짜 재현을 구분할 수 없습니다'); return; }
162
+
163
+ const chk = _loadChecked(root);
164
+ if (chk.invalid) { failJson(json, 'store_invalid', `bugfix-receipts.json 형상 무효 — 덮어쓰기 거부: ${_storePath(root)}`); return; }
165
+
166
+ const r = _run(repro, absRoot(root));
167
+ const lf = _launcherFailure(r);
168
+ const matched = r.output.includes(expectBad);
169
+ const reasons = [];
170
+ if (r.overflow) reasons.push('probe 출력이 한도(8MB)를 넘어 재현 여부를 판정할 수 없습니다 — 출력을 줄이거나 파일로 보내세요');
171
+ if (lf) reasons.push(`${lf} — 심어둔 버그가 아니라 명령이 실행되지 못했습니다`);
172
+ else if (r.status === 0) reasons.push('probe 가 지금 **통과**합니다(exit 0) — 버그가 재현되지 않으면 수정 후 통과는 아무것도 증명하지 못합니다');
173
+ if (!lf && !matched) reasons.push(`출력에 기대 신호 "${expectBad}" 없음 — 무관한 실패일 수 있습니다`);
174
+ // 1.36.87 (codex 26차 #3): **두 번** 돌려 둘 다 실패해야 한다. 한 번만 보면 상태를 남기는 probe —
175
+ // 예: `if(!exists(marker)){write(marker); exit 1}` — 가 등록 때만 실패하고 이후 항상 통과해,
176
+ // 제품 코드를 한 줄도 고치지 않고 완료 게이트를 통과시킨다(1회성 probe = 탐지력 0).
177
+ let r2 = null;
178
+ if (!reasons.length) {
179
+ r2 = _run(repro, absRoot(root));
180
+ if (_launcherFailure(r2) || r2.status === 0 || !r2.output.includes(expectBad)) {
181
+ reasons.push(`두 번째 실행에서 재현되지 않았습니다(exit ${r2.status}) — 상태를 남기는 1회성 probe 는 수정 여부와 무관하게 통과하므로 등록하지 않습니다`);
182
+ }
183
+ }
184
+
185
+ const entry = {
186
+ id: String(id), repro, expectBad,
187
+ baseline: { at: now(), failed: !lf && r.status !== 0, exit: r.status, expectMatched: matched, reproduced: 2, ms: r.ms, sample: reasons.length ? r.sample.slice(0, 400) : undefined },
188
+ rootCause: null, siblingScope: null, previousGuardGap: null,
189
+ };
190
+ if (reasons.length) {
191
+ if (json) { log(JSON.stringify({ ok: false, code: 'probe_not_reproducing', id: entry.id, reasons }, null, 2)); }
192
+ else { warn(`probe 가 버그를 재현하지 못했습니다 — 등록하지 않습니다`); reasons.forEach(x => log(` - ${x}`)); }
193
+ if (typeof process !== 'undefined') process.exitCode = 1;
194
+ return;
195
+ }
196
+ const hint = _toolErrorHint(r); // 비차단 — 등록은 진행하되 사용자에게 알린다
197
+ _upsert(root, entry, deps);
198
+ // 1.36.87 (codex 26차 #1): 게이트는 기본 OFF 다. 토글 상태를 보지 않고 "done 때 통과해야 합니다"라고
199
+ // 쓰면, 실제로는 아무것도 강제되지 않는데 강제된다고 **약속하는 문구**가 된다.
200
+ const gateOn = require('./toggles').toggleOn(root, 'bugfix-receipt');
201
+ if (json) { log(JSON.stringify({ ok: true, id: entry.id, baseline: entry.baseline, gateEnabled: gateOn, hint: hint || undefined }, null, 2)); return; }
202
+ ok(`bugfix probe 등록: ${entry.id} — 지금 재현됨(2회 연속 exit ${r.status} · 신호 일치)`);
203
+ if (hint) warn(` ${hint}`);
204
+ log(` ⚠ 이 명령은 앞으로 \`--status done\` 시 자동 실행됩니다 — 저장 위치: ${_storePath(root)}`);
205
+ if (gateOn) log(` → 수정 후 \`leerness task update ${entry.id} --status done\` 시 같은 probe 가 통과해야 합니다.`);
206
+ else warn(` 이 프로젝트에서 완료 게이트는 **꺼져 있습니다** — 지금은 기록만 되고 done 은 막히지 않습니다.\n 켜기: leerness toggle set bugfix-receipt on`);
207
+ log(` → 근본원인/형제범위 기록: leerness bugfix receipt ${entry.id} --root-cause "..." --siblings "..."`);
208
+ }
209
+
210
+ // `leerness bugfix receipt <ID> --root-cause "..." [--siblings "..." | --siblings-na "사유"] [--previous-gap "..."]`
211
+ // **선언**이다. leerness 는 내용의 진위를 판정하지 않는다 — 존재와 문구만 기록한다.
212
+ function _receipt(root, id, deps) {
213
+ const { arg, has } = deps;
214
+ const json = !!(has && has('--json'));
215
+ const chk = _loadChecked(root);
216
+ if (chk.invalid) { failJson(json, 'store_invalid', `bugfix-receipts.json 형상 무효 — 덮어쓰기 거부: ${_storePath(root)}`); return; }
217
+ const e = chk.list.find(x => x.id === String(id));
218
+ if (!e) { failJson(json, 'probe_not_found', `bugfix probe 없음: ${id} — 먼저 leerness bugfix start ${id} --repro "..." --expect-bad "..."`); return; }
219
+ const rc = (arg ? arg('--root-cause', '') : '') || '';
220
+ const sib = (arg ? arg('--siblings', '') : '') || '';
221
+ const sibNa = (arg ? arg('--siblings-na', '') : '') || '';
222
+ const gap = (arg ? arg('--previous-gap', '') : '') || '';
223
+ if (rc.trim()) e.rootCause = rc.trim();
224
+ // 1.36.87 실측: `--siblings ","` 는 checked=[] 를 만들면서 "형제범위 기록됨"으로 통과했다(공허 영수증).
225
+ // 빈 목록은 기록이 아니다 — 거부한다.
226
+ const parsed = sib.trim() ? sib.split(',').map(s => s.trim()).filter(Boolean) : [];
227
+ if (sib.trim() && !parsed.length) { failJson(json, 'empty_siblings', `--siblings 값에 항목이 없습니다: ${JSON.stringify(sib)} — 쉼표로 구분된 형제 지점을 적거나, 없으면 --siblings-na "<사유>"`); return; }
228
+ if (parsed.length) e.siblingScope = { checked: parsed };
229
+ else if (sibNa.trim()) e.siblingScope = { notApplicable: sibNa.trim() };
230
+ if (gap.trim()) e.previousGuardGap = gap.trim();
231
+ _upsert(root, e, deps);
232
+ if (json) { log(JSON.stringify({ ok: true, id: e.id, rootCause: e.rootCause, siblingScope: e.siblingScope, previousGuardGap: e.previousGuardGap }, null, 2)); return; }
233
+ ok(`bugfix 영수증 갱신: ${e.id}`);
234
+ log(` ⓘ 근본원인·형제범위는 **선언**입니다 — leerness 는 내용의 진위를 판정하지 않습니다(검증되는 것은 probe 뿐).`);
235
+ }
236
+
237
+ // `--status done` 전이에서 호출. 토글 OFF 이거나 probe 미등록이면 **아무 영향 없음**(기존 동작 보존).
238
+ // 반환: { blocked, code, message, lines } — 호출부가 렌더/exit 를 결정한다.
239
+ function checkDoneTransition(root, id) {
240
+ const chk = _loadChecked(root);
241
+ if (chk.invalid) {
242
+ return { blocked: true, code: 'store_invalid', message: `bugfix-receipts.json 형상 무효 — 완료 판정을 보류합니다: ${_storePath(root)}` };
243
+ }
244
+ const e = chk.list.find(x => x.id === String(id));
245
+ if (!e) return { blocked: false }; // bugfix 로 선언되지 않은 task 는 무영향
246
+ const r = _run(e.repro, absRoot(root));
247
+ const lf = _launcherFailure(r);
248
+ const lines = [];
249
+ if (lf) return { blocked: true, code: 'probe_unrunnable', message: `재현 probe 를 실행할 수 없습니다 — ${lf}: ${e.repro}` };
250
+ // 출력 초과는 실행 실패가 아니다 — 통과/실패를 **알 수 없는** 상태이므로 그대로 말하고 보류한다(사실과 다른 사유 금지).
251
+ if (r.overflow) {
252
+ return {
253
+ blocked: true, code: 'probe_output_too_large',
254
+ message: `probe 출력이 한도(8MB)를 넘어 통과 여부를 판정할 수 없습니다 — 출력을 줄이거나 파일로 보내세요(예: > out.log): ${e.repro}`,
255
+ };
256
+ }
257
+ if (r.status !== 0) {
258
+ return {
259
+ blocked: true, code: 'probe_still_failing',
260
+ message: `재현 probe 가 아직 실패합니다(exit ${r.status}) — 버그가 남아 있거나 probe 자체가 깨졌습니다(아래 출력 확인): ${e.repro}`,
261
+ lines: [r.sample.split('\n').slice(0, 6).join('\n')],
262
+ };
263
+ }
264
+ const missing = [];
265
+ if (!e.rootCause) missing.push('--root-cause');
266
+ if (!_siblingScopeOk(e.siblingScope)) missing.push('--siblings 또는 --siblings-na');
267
+ if (missing.length) {
268
+ return {
269
+ blocked: true, code: 'receipt_incomplete',
270
+ message: `영수증 미완: ${missing.join(' · ')} — leerness bugfix receipt ${e.id} ${missing.map(m => m.split(' ')[0] + ' "..."').join(' ')}`,
271
+ };
272
+ }
273
+ // baseline 은 _entryValid 가 요구하지만, 렌더는 방어적으로 — 종전엔 여기서 TypeError 로 task update 가 통째로 죽었다(실측).
274
+ const wasExit = (e.baseline && e.baseline.exit != null) ? `수정 전 exit ${e.baseline.exit} → ` : '';
275
+ lines.push(`✓ 재현 probe 통과(${wasExit}지금 exit 0): ${e.repro}`);
276
+ // 1.36.87 (codex 26차 #2): exit 0 인데 등록 당시의 실패 신호가 출력에 그대로 있으면 "증상만 덮은" 신호다.
277
+ // 다만 **막지는 않는다** — 신호가 성공 메시지에 정상 등장할 수 있고, 여기서 잘못 막으면 이미 고쳐진
278
+ // probe 를 재등록할 방법이 없어(등록은 실패를 요구) 사용자가 빠져나올 수 없다. 경고로 노출한다.
279
+ if (e.expectBad && r.output.includes(e.expectBad)) {
280
+ lines.push(`⚠ exit 는 0 이지만 등록 당시 실패 신호 "${e.expectBad}" 가 출력에 아직 있습니다 — probe 가 정말 그 버그를 보고 있는지 확인하세요.`);
281
+ }
282
+ lines.push(`ⓘ 아래는 **선언**이며 내용의 진위는 검증하지 않았습니다:`);
283
+ lines.push(` 근본원인: ${e.rootCause}`);
284
+ lines.push(` 형제범위: ${e.siblingScope.notApplicable ? 'not-applicable — ' + e.siblingScope.notApplicable : (e.siblingScope.checked || []).join(', ')}`);
285
+ if (e.previousGuardGap) lines.push(` 이전 검증이 놓친 이유: ${e.previousGuardGap}`);
286
+ return { blocked: false, lines };
287
+ }
288
+
289
+ // 1.36.88 (출하전 헌트 #1/#17): 게이트를 호출부마다 배선하면 반드시 빠뜨린다 — 실측으로
290
+ // `task sync --from`(TodoWrite 왕복, 하네스가 직접 지시하는 정규 경로)이 게이트를 통째로 우회했다.
291
+ // 그래서 판정은 progress 행을 쓰는 **단일 병목**에서 하고, 막힐 때는 조용히 무시할 수 없도록 던진다.
292
+ // (반환값 무시로 인한 무언 통과가 이 클래스의 재발 양식이다.)
293
+ class BugfixBlocked extends Error {
294
+ constructor(res, id) {
295
+ super((res && res.message) || 'bugfix 완료 게이트에 의해 보류');
296
+ this.name = 'BugfixBlocked';
297
+ this.taskId = String(id);
298
+ this.code = (res && res.code) || 'blocked';
299
+ this.lines = (res && res.lines) || [];
300
+ }
301
+ }
302
+
303
+ function bugfixCmd(root, sub, rest, deps = {}) {
304
+ const { has } = deps;
305
+ root = absRoot(root);
306
+ const json = !!(has && has('--json'));
307
+ if (!exists(path.join(root, '.harness'))) { failJson(json, 'harness_missing', `leerness 미설치: ${root} — 먼저 leerness init`); return; }
308
+ const id = (rest || [])[0];
309
+ if (sub === 'start') return _start(root, id, deps);
310
+ if (sub === 'receipt') return _receipt(root, id, deps);
311
+ // 1.36.87 (codex 26차 #9): 등록을 되돌릴 길이 필요하다 — probe 스크립트가 사라졌거나 계약이 바뀌면
312
+ // done 이 영구히 막히고(등록은 "지금 실패"를 요구하므로 재등록도 불가) 스토어도 무한히 자란다.
313
+ if (sub === 'drop') {
314
+ const chk = _loadChecked(root);
315
+ if (chk.invalid) { failJson(json, 'store_invalid', `bugfix-receipts.json 형상 무효 — 수정 거부: ${_storePath(root)}`); return; }
316
+ const left = _remove(root, id, deps);
317
+ if (left === null) { failJson(json, 'probe_not_found', `bugfix probe 없음: ${id}`); return; }
318
+ if (json) { log(JSON.stringify({ ok: true, id: String(id), remaining: left.length }, null, 2)); return; }
319
+ ok(`bugfix probe 삭제: ${id} (남은 ${left.length}건) — 이 task 는 다시 완료 게이트 무영향이 됩니다`);
320
+ return;
321
+ }
322
+ if (sub === 'list' || !sub) {
323
+ const chk = _loadChecked(root);
324
+ if (json) { log(JSON.stringify({ ok: !chk.invalid, invalid: chk.invalid, receipts: chk.list.map(e => ({ id: e.id, repro: e.repro, baselineFailed: !!(e.baseline && e.baseline.failed), rootCause: e.rootCause, siblingScope: e.siblingScope })) }, null, 2)); return; }
325
+ if (chk.invalid) { warn(`bugfix-receipts.json 형상 무효: ${_storePath(root)}`); return; }
326
+ if (!chk.list.length) { log('등록된 bugfix probe 없음 — leerness bugfix start <T-ID> --repro "..." --expect-bad "..."'); return; }
327
+ // 1.36.88 (출하전 헌트 #12): 목록과 게이트가 **같은 술어**를 써야 한다 — 빈 checked 를 목록은 "기록됨",
328
+ // 게이트는 "영수증 미완"으로 판정해, 사용자가 목록을 보고 통과할 줄 알았다가 막혔다.
329
+ chk.list.forEach(e => log(` ${e.id} probe=${e.repro.slice(0, 50)} 근본원인=${e.rootCause ? '기록됨' : '미기록'} 형제범위=${_siblingScopeOk(e.siblingScope) ? '기록됨' : '미기록'}`));
330
+ return;
331
+ }
332
+ failJson(json, 'unknown_subcommand', `알 수 없는 bugfix 하위명령: ${sub} (가능: start, receipt, drop, list)`);
333
+ }
334
+
335
+ module.exports = { bugfixCmd, checkDoneTransition, BugfixBlocked, _storePath, _loadChecked };
package/lib/toggles.js CHANGED
@@ -11,25 +11,50 @@ const TOGGLE_REGISTRY = {
11
11
  'lens': { desc: '품질 렌즈 자기질문 (완료 선언 전 분야별 점검)', affects: 'leerness lens' },
12
12
  'auto-graph': { desc: '온톨로지 그래프(leerness.html) 자동 갱신 (install/session-close)', affects: 'install · session close' },
13
13
  'delegation-brief': { desc: '백그라운드 AI 위임 시 leerness 프로토콜 브리프 자동 접두', affects: 'agents dispatch · agents multi' },
14
+ // 1.36.87: **기본 OFF(옵트인)** — 켜면 bugfix 로 선언된 task 는 재현 probe 가 통과해야 done 이 된다.
15
+ // 기존 프로젝트의 `task update --status done` 을 일제히 깨뜨리지 않기 위해 반드시 opt-in 이어야 한다.
16
+ // 1.36.88 (출하전 헌트 #11): 켜기만 하면 뭔가 강제되는 것처럼 읽혔다 — 실제로는 `bugfix start` 로
17
+ // probe 를 등록한 task 에만 적용된다. 광고 문구가 적용 범위를 말해야 한다.
18
+ 'bugfix-receipt': { desc: 'bugfix 완료 게이트 — `leerness bugfix start` 로 probe 를 등록한 task 에 한해, done 시 probe 통과 + 근본원인/형제범위 영수증 요구 (기본 OFF)', affects: 'task update --status done · task sync --from', defaultOff: true },
14
19
  };
15
20
 
16
21
  function _togglesPath(root) { return path.join(absRoot(root), '.harness', 'toggles.json'); }
17
22
 
18
- // 전체 토글 상태 로드 — 파일 없으면 전부 기본 ON. 손상 파일은 읽기에선 기본값(비변경 경로 resilient).
23
+ // 전체 토글 상태 로드 — 파일 없으면 기본값(대부분 ON, `defaultOff` 표시된 것은 OFF).
24
+ // 1.36.87: 기존 동작을 바꾸는 신규 토글은 반드시 defaultOff 여야 한다 — 안 그러면 업그레이드만으로
25
+ // 모든 사용자의 워크플로가 깨진다(기본 ON 은 "새 검사가 조용히 강제됨"을 뜻하므로).
26
+ // 값 해석 — 인식 가능한 표현만 받고, 알 수 없는 값은 레지스트리 기본값으로 되돌린다.
27
+ // 1.36.87: 종전 규칙은 `v !== false` 였다. 기본 ON 토글에서는 무해했지만(어차피 ON), 기본 OFF 인
28
+ // 차단 게이트가 생긴 지금은 `null`/`0`/`"off"` 같은 값이 게이트를 **켜 버린다**(실측 확인).
29
+ // 원칙: 알 수 없는 값은 차단을 켜서도, 보호를 꺼서도 안 된다 → 기본값 유지.
30
+ function _coerceToggle(v, dflt) {
31
+ if (v === true || v === false) return v;
32
+ if (typeof v === 'string') {
33
+ const s = v.trim().toLowerCase();
34
+ if (s === 'on' || s === 'true' || s === '1') return true;
35
+ if (s === 'off' || s === 'false' || s === '0') return false;
36
+ }
37
+ return dflt;
38
+ }
39
+
19
40
  function loadToggles(root) {
20
41
  const out = {};
21
- for (const id of Object.keys(TOGGLE_REGISTRY)) out[id] = true;
42
+ for (const [id, meta] of Object.entries(TOGGLE_REGISTRY)) out[id] = !(meta && meta.defaultOff);
22
43
  const f = _togglesPath(root);
23
44
  if (!exists(f)) return out;
24
45
  try {
25
46
  const j = JSON.parse(read(f));
26
- for (const [k, v] of Object.entries(j || {})) if (k in out) out[k] = v !== false;
47
+ if (!j || typeof j !== 'object' || Array.isArray(j)) return out; // 배열/스칼라 저장본 기본값
48
+ for (const [k, v] of Object.entries(j)) if (k in out) out[k] = _coerceToggle(v, out[k]);
27
49
  } catch {}
28
50
  return out;
29
51
  }
30
52
 
31
- // 단일 토글 조회 헬퍼 — 호출부 한 줄용.
32
- function toggleOn(root, id) { return loadToggles(root)[id] !== false; }
53
+ // 단일 토글 조회 헬퍼 — 호출부 한 줄용. defaultOff 토글은 명시적으로 켜야 true.
54
+ function toggleOn(root, id) {
55
+ const st = loadToggles(root);
56
+ return id in st ? st[id] === true : true;
57
+ }
33
58
 
34
59
  function saveToggles(root, toggles) {
35
60
  const f = _togglesPath(root);
@@ -74,4 +99,4 @@ function toggleCmd(root, sub, id, val, deps = {}) {
74
99
  fail(`알 수 없는 하위명령: ${sub} (가능: list, set)`); process.exitCode = 1;
75
100
  }
76
101
 
77
- module.exports = { TOGGLE_REGISTRY, loadToggles, toggleOn, saveToggles, toggleCmd, _togglesPath };
102
+ module.exports = { TOGGLE_REGISTRY, loadToggles, toggleOn, saveToggles, toggleCmd, _togglesPath, _coerceToggle };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.86",
3
+ "version": "1.36.88",
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
@@ -7,6 +7,8 @@ const path = require('path');
7
7
  const cp = require('child_process');
8
8
 
9
9
  // 1.9.12: e2e 안정성을 위해 자식 프로세스의 npm 호출 차단 (hang 방지)
10
+ // 1.36.87: 일부 e2e 타임아웃을 상향했다 — 격리 실측(agents list 5.2s · init --language 8.5s) 대비
11
+ // 여유가 3배 미만이라 전체 스위트 부하에서 간헐 초과했다. 검사 대상은 속도가 아니라 동작이다.
10
12
  process.env.LEERNESS_OFFLINE = process.env.LEERNESS_OFFLINE || '1';
11
13
  // 1.9.284 (UR-0029): e2e 속도 — 기본 roadmap.html(70KB HTML) 자동 생성 OFF (roadmap 전용 테스트 블록만 일시 ON).
12
14
  // 대부분의 init/session 테스트는 roadmap 을 검증하지 않으므로 생성 비용 제거 → 5분 내 완료.
@@ -804,7 +806,7 @@ total++;
804
806
  {
805
807
  // agents list — claude가 환경변수 + PATH 둘 다 충족 시 ready
806
808
  const env1 = { ...process.env, LEERNESS_ENABLE_CLAUDE: '1', LEERNESS_ENABLE_CODEX: '0', LEERNESS_ENABLE_AGY: '0', LEERNESS_ENABLE_GROK: '0', LEERNESS_ENABLE_COPILOT: '0' };
807
- const r1 = cp.spawnSync(process.execPath, [CLI, 'agents', 'list'], { encoding: 'utf8', timeout: 15000, env: env1 });
809
+ const r1 = cp.spawnSync(process.execPath, [CLI, 'agents', 'list'], { encoding: 'utf8', timeout: 90000, env: env1 });
808
810
  const okList = r1.status === 0
809
811
  && /외부 AI CLI 오케스트레이션 \(1\.9\.30\)/.test(r1.stdout)
810
812
  && /\| claude \|/.test(r1.stdout)
@@ -816,7 +818,7 @@ total++;
816
818
  // 1.9.146: Ollama → 5 · 1.9.268: grok → 6 · 1.9.277: opencode/qwen/aider/goose → 10 CLI
817
819
  const offAll = { LEERNESS_ENABLE_CLAUDE: '0', LEERNESS_ENABLE_CODEX: '0', LEERNESS_ENABLE_AGY: '0', LEERNESS_ENABLE_GROK: '0', LEERNESS_ENABLE_OPENCODE: '0', LEERNESS_ENABLE_QWEN: '0', LEERNESS_ENABLE_AIDER: '0', LEERNESS_ENABLE_GOOSE: '0', LEERNESS_ENABLE_COPILOT: '0', LEERNESS_ENABLE_OLLAMA: '0' };
818
820
  const env2 = { ...process.env, ...offAll };
819
- const r2 = cp.spawnSync(process.execPath, [CLI, 'agents', 'list', '--json'], { encoding: 'utf8', timeout: 15000, env: env2 });
821
+ const r2 = cp.spawnSync(process.execPath, [CLI, 'agents', 'list', '--json'], { encoding: 'utf8', timeout: 90000, env: env2 });
820
822
  let parsed = null;
821
823
  try { parsed = JSON.parse(r2.stdout); } catch {}
822
824
  const okJson = parsed && Array.isArray(parsed.agents) && parsed.agents.length === 10 && parsed.agents.every(a => a.status !== 'ready');
@@ -849,7 +851,7 @@ total++;
849
851
  {
850
852
  // agents quota — env=0 시 모두 disabled/not-installed, 안내 메시지 포함
851
853
  const env = { ...process.env, LEERNESS_ENABLE_CLAUDE: '0', LEERNESS_ENABLE_CODEX: '0', LEERNESS_ENABLE_AGY: '0', LEERNESS_ENABLE_GROK: '0', LEERNESS_ENABLE_COPILOT: '0' };
852
- const r = cp.spawnSync(process.execPath, [CLI, 'agents', 'quota'], { encoding: 'utf8', timeout: 15000, env });
854
+ const r = cp.spawnSync(process.execPath, [CLI, 'agents', 'quota'], { encoding: 'utf8', timeout: 90000, env });
853
855
  const okText = r.status === 0
854
856
  && /외부 AI CLI quota 추정 \(1\.9\.31\)/.test(r.stdout)
855
857
  && /\| claude \|/.test(r.stdout)
@@ -859,7 +861,7 @@ total++;
859
861
  && /\| copilot \|/.test(r.stdout)
860
862
  && /provider 대시보드 참조/.test(r.stdout);
861
863
  // JSON 출력
862
- const r2 = cp.spawnSync(process.execPath, [CLI, 'agents', 'quota', '--json'], { encoding: 'utf8', timeout: 15000, env });
864
+ const r2 = cp.spawnSync(process.execPath, [CLI, 'agents', 'quota', '--json'], { encoding: 'utf8', timeout: 90000, env });
863
865
  let parsed = null;
864
866
  try { parsed = JSON.parse(r2.stdout); } catch {}
865
867
  // 1.9.277: opencode/qwen/aider/goose → 10 CLI
@@ -4639,9 +4641,11 @@ total++;
4639
4641
  const sr = cp.spawnSync(process.execPath, [CLI, 'selftest'], { cwd: ni, encoding: 'utf8', timeout: 180000 });
4640
4642
  const sout = (sr.stdout || '') + (sr.stderr || '');
4641
4643
  const selftestOk = sr.status === 0 && /전체 \d+건 통과/.test(sout) && !/설치 손상/.test(sout);
4642
- const dr = cp.spawnSync(process.execPath, [CLI, 'doctor'], { cwd: ni, encoding: 'utf8', timeout: 30000 });
4644
+ const dr = cp.spawnSync(process.execPath, [CLI, 'doctor'], { cwd: ni, encoding: 'utf8', timeout: 120000 }); // 1.36.87: doctor 는 selftest 를 내장한다(19~24s)
4643
4645
  const dout = (dr.stdout || '') + (dr.stderr || '');
4644
- const doctorOk = !/문제 감지|설치 손상|재설치/.test(dout) && /설치 정상|통과/.test(dout);
4646
+ // 1.36.87 (codex 26차 #11): 종료코드를 보면 **타임아웃/중단이 통과로 보인다**(spawnSync 타임아웃은 status=null,
4647
+ // 이미 찍힌 "통과" 문자열은 남는다). 제한을 120s 로 올린 만큼 status 단언이 반드시 함께 가야 한다.
4648
+ const doctorOk = dr.status === 0 && !/문제 감지|설치 손상|재설치/.test(dout) && /설치 정상|통과/.test(dout);
4645
4649
  ok = selftestOk && doctorOk;
4646
4650
  fs.rmSync(ni, { recursive: true, force: true });
4647
4651
  } catch {}
@@ -5058,6 +5062,29 @@ total++;
5058
5062
  if (!ok) failed++;
5059
5063
  }
5060
5064
 
5065
+ // 1.36.88 (--json 단일 JSON 계약 회귀): selftest --json 은 stdout 에 JSON 1개만 내야 한다 — **리포 루트 cwd** 에서도.
5066
+ // 사각지대: 위 5049 블록은 e2e 기본 tmp cwd 로만 돌아 "실제 하네스가 있는 cwd 에서만 보이는 오염"을 놓친다.
5067
+ // 또한 stderr 잡음 0 을 함께 단언한다 — 잡음의 실제 원인(프로브가 fail() 의 stderr 를 안 가로챔 · graph 케이스 quiet 누락)은
5068
+ // io 래치가 사람용 log 를 stderr 로 강등해 주는 **우연** 덕에 stdout 만 겨우 지켜졌다. 래치가 바뀌면 곧장 stdout 오염이 된다.
5069
+ // → stdout 순수성만 단언하면 "우연히 통과"하므로, 잡음 자체를 0 으로 고정해 원인 단계에서 막는다.
5070
+ total++;
5071
+ {
5072
+ let ok = false;
5073
+ try {
5074
+ const repoRoot = path.resolve(__dirname, '..');
5075
+ const r = cp.spawnSync(process.execPath, [CLI, 'selftest', '--json'], { cwd: repoRoot, encoding: 'utf8', timeout: 180000, maxBuffer: 64 * 1024 * 1024 });
5076
+ const pureStdout = typeof r.stdout === 'string' && r.stdout.trimStart().startsWith('{');
5077
+ const j = JSON.parse(r.stdout); // 계약: stdout 전체가 단일 JSON 문서
5078
+ const payloadOk = j.ok === true && j.pass === j.total && j.fail === 0 && j.total > 0;
5079
+ const se = r.stderr || '';
5080
+ // 알려진 유출원 3종: fail() 프로브의 '✗ ' · graph 사람용 3줄(파일경로/노드수/안내)
5081
+ const noiseFree = !se.includes('✗ ') && !se.includes('leerness.html →') && !/nodes · /.test(se);
5082
+ ok = pureStdout && payloadOk && noiseFree && r.status === 0;
5083
+ } catch {}
5084
+ console.log(ok ? '✓ B(1.36.88) selftest --json 계약: 리포 루트 cwd stdout 순수 JSON + stderr 잡음 0' : '✗ selftest --json 계약 실패 (stdout 오염 또는 stderr 잡음)');
5085
+ if (!ok) failed++;
5086
+ }
5087
+
5061
5088
  // 1.9.367 회귀 (UR-0025): _mergeEnvLines 모듈 분리 — migrate 가 사용자 .env 값을 key-aware 로 보존 (덮어쓰기 X)
5062
5089
  total++;
5063
5090
  {
@@ -6923,9 +6950,13 @@ total++;
6923
6950
  const drKo = out(cp.spawnSync(process.execPath, [CLI, 'drift', 'check', d], { encoding: 'utf8', timeout: 20000 }));
6924
6951
  const driftOk = /signal \| age \| threshold/.test(drEn) && !H.test(drEn) && /신호 \| age \| 임계/.test(drKo);
6925
6952
  // ⑦ (1.28.2 Phase 10c) doctor: en 영어(한글 0) + ko 기본 한글 보존
6926
- const docEn = out(cp.spawnSync(process.execPath, [CLI, 'doctor', '--language', 'en'], { encoding: 'utf8', timeout: 20000, cwd: d }));
6927
- const docKo = out(cp.spawnSync(process.execPath, [CLI, 'doctor'], { encoding: 'utf8', timeout: 20000, cwd: d }));
6928
- const doctorOk = /install\/environment diagnosis/.test(docEn) && !H.test(docEn) && /설치\/환경 진단/.test(docKo);
6953
+ // 1.36.87: doctor 내부에서 selftest 돌린다 실측 19~24s 인데 제한이 20s 라 여유가 없었다(1.36.86 에서도 0.9s).
6954
+ // 제한을 올린 대신 종료코드도 함께 단언한다 타임아웃(status=null)이 출력 매칭만으로 통과하면 된다(codex 26차 #11).
6955
+ const docEnR = cp.spawnSync(process.execPath, [CLI, 'doctor', '--language', 'en'], { encoding: 'utf8', timeout: 120000, cwd: d });
6956
+ const docKoR = cp.spawnSync(process.execPath, [CLI, 'doctor'], { encoding: 'utf8', timeout: 120000, cwd: d });
6957
+ const docEn = out(docEnR), docKo = out(docKoR);
6958
+ const doctorOk = docEnR.status === 0 && docKoR.status === 0
6959
+ && /install\/environment diagnosis/.test(docEn) && !H.test(docEn) && /설치\/환경 진단/.test(docKo);
6929
6960
  fs.rmSync(d, { recursive: true, force: true });
6930
6961
  // ⑧ (1.29.1) handoff 보안 요약 섹션: .env + 미흡한 .gitignore → en 영어(섹션 라인 한글 0) + ko 기본 한글.
6931
6962
  // 소스가드만으로는 못 잡는 회귀를 e2e 로 보강: 보안 요약 블록은 headline 의 t() 스코프 밖이라,
@@ -6987,19 +7018,22 @@ total++;
6987
7018
  fs.rmSync(da, { recursive: true, force: true });
6988
7019
  // ⑫ (1.30.5 #156 F3+F4) handoff 본문 워크플로 가이드 + 메모리 변동 en 영어(섹션 한글 0) + ko 보존 · verify-claim/optimism-check 미입력 에러 en/ko.
6989
7020
  const df3 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-i18n-f3-'));
6990
- cp.spawnSync(process.execPath, [CLI, 'init', df3, '--yes', '--language', 'en'], { encoding: 'utf8', timeout: 30000 });
7021
+ cp.spawnSync(process.execPath, [CLI, 'init', df3, '--yes', '--language', 'en'], { encoding: 'utf8', timeout: 120000 });
6991
7022
  const hf3En = out(cp.spawnSync(process.execPath, [CLI, 'handoff', df3], { encoding: 'utf8', timeout: 25000 }));
6992
7023
  const wfLines = hf3En.split('\n').filter(l => /Session workflow|Analyze request|sub-agent work|to disable:/.test(l));
6993
7024
  const f3En = /Session workflow/.test(hf3En) && wfLines.length >= 3 && !wfLines.some(l => H.test(l));
6994
7025
  const df3ko = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-i18n-f3k-'));
6995
- cp.spawnSync(process.execPath, [CLI, 'init', df3ko, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
7026
+ cp.spawnSync(process.execPath, [CLI, 'init', df3ko, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 120000 });
6996
7027
  const f3Ko = /세션 워크플로 6단계/.test(out(cp.spawnSync(process.execPath, [CLI, 'handoff', df3ko], { encoding: 'utf8', timeout: 25000 })));
6997
7028
  const vcEn = out(cp.spawnSync(process.execPath, [CLI, 'verify-claim', '--path', df3, '--language', 'en'], { encoding: 'utf8', timeout: 15000 }));
6998
7029
  const vcKo = out(cp.spawnSync(process.execPath, [CLI, 'verify-claim', '--path', df3ko], { encoding: 'utf8', timeout: 15000 }));
6999
7030
  const f4 = /required\. ex:/.test(vcEn) && !H.test(vcEn) && /필요\. 예:/.test(vcKo);
7000
7031
  [df3, df3ko].forEach(d => { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} });
7001
7032
  ok = lensKoOk && lensEnOk && noLeak && stOk && healthOk && driftOk && doctorOk && hoEnOk && hoKoOk && edEnOk && edKoOk && shEnOk && shKoOk && agEnOk && agKoOk && f3En && f3Ko && f4;
7002
- } catch {}
7033
+ // 1.36.87: 단언 18개를 && 로 묶고 실패 시 어느 것인지 알려주지 않아, 진단하려면 22분짜리 스위트를 통째로
7034
+ // 다시 돌려야 했다. 다른 블록들과 같은 규율으로 실패 플래그를 노출한다.
7035
+ if (!ok) console.log(' [i18n 디버그] ' + JSON.stringify({ lensKoOk, lensEnOk, noLeak, stOk, healthOk, driftOk, doctorOk, hoEnOk, hoKoOk, edEnOk, edKoOk, shEnOk, shKoOk, agEnOk, agKoOk, f3En, f3Ko, f4 }));
7036
+ } catch (e) { console.log(' [i18n 디버그] 예외: ' + ((e && e.message) || e)); }
7003
7037
  console.log(ok ? '✓ B(1.25.1/1.25.2/1.27.2/1.28.2/1.29.1/1.29.2/1.29.3/1.29.4/1.30.5) i18n 행위: --language en 런타임 영어(lens/health/drift/doctor/handoff보안요약/env-detect/shell-guard/agent-slash/워크플로가이드/verify-claim) + ko 기본 보존 + --language positional 무누출 + status 에러 en/ko (UR-0010)' : '✗ i18n 행위 회귀 가드 실패');
7004
7038
  if (!ok) failed++;
7005
7039
  }
@@ -7600,6 +7634,79 @@ total++;
7600
7634
  if (!ok) failed++;
7601
7635
  }
7602
7636
 
7637
+ // 1.36.88 (출하전 헌트 #1/#2/#3/#13/#17/#18): bugfix 완료 게이트 **배선** 회귀 가드.
7638
+ // selftest 는 checkDoneTransition 을 in-process 로만 불러, `task update --status done` 배선을 한 줄도
7639
+ // 실행하지 않았다 — 게이트를 통째로 죽여도 341/341 그린이었다(변이로 실측). 실제 CLI 경로로 못 박는다.
7640
+ // 특히 done 을 기록하는 경로가 taskUpdate 하나가 아니다: task sync --from(TodoWrite 왕복)이 우회했다.
7641
+ total++;
7642
+ {
7643
+ let ok = false;
7644
+ const _d = [];
7645
+ const R = (args, cwd) => { const r = cp.spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', timeout: 180000, cwd }); return { s: r.status, o: ((r.stdout || '') + (r.stderr || '')).trim() }; };
7646
+ let A = false, B = false, C = false, D = false, E = false, F = false, G = false;
7647
+ try {
7648
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-bfgate-')); _d.push(d);
7649
+ R(['init', d, '--yes', '--language', 'ko', '--no-stale-check']);
7650
+ fs.mkdirSync(path.join(d, 'src'), { recursive: true });
7651
+ const BUGGY = 'console.log("BUG_HERE"); process.exit(3);';
7652
+ fs.writeFileSync(path.join(d, 'src', 'app.js'), BUGGY);
7653
+ const ID = ((R(['task', 'add', 'fix order status', '--path', d, '--json', '--no-review']).o.match(/"id"\s*:\s*"([^"]+)"/)) || [])[1];
7654
+ // G: 토글 OFF(기본)에서는 probe 가 있어도 done 이 막히지 않는다 — 옵트인 계약
7655
+ R(['bugfix', 'start', ID, '--repro', 'node src/app.js', '--expect-bad', 'BUG_HERE', '--path', d, '--json']);
7656
+ G = R(['task', 'update', ID, '--status', 'in-progress', '--path', d]).s === 0
7657
+ && R(['task', 'update', ID, '--status', 'done', '--path', d]).s === 0;
7658
+ // 이후는 토글 ON 상태에서 (별 프로젝트 — G 가 done 을 기록했으므로)
7659
+ const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-bfgate2-')); _d.push(d2);
7660
+ R(['init', d2, '--yes', '--language', 'ko', '--no-stale-check']);
7661
+ R(['toggle', 'set', 'bugfix-receipt', 'on', '--path', d2]);
7662
+ fs.mkdirSync(path.join(d2, 'src'), { recursive: true });
7663
+ fs.writeFileSync(path.join(d2, 'src', 'app.js'), BUGGY);
7664
+ const ID2 = ((R(['task', 'add', 'fix order status', '--path', d2, '--json', '--no-review']).o.match(/"id"\s*:\s*"([^"]+)"/)) || [])[1];
7665
+ // F: progress-tracker 에 없는 id 는 등록 거부 — 오타 하나로 "보호받는 줄 아는" 무보호가 되던 구멍
7666
+ F = R(['bugfix', 'start', 'T-9999', '--repro', 'node src/app.js', '--expect-bad', 'BUG_HERE', '--path', d2, '--json']).s === 1;
7667
+ R(['bugfix', 'start', ID2, '--repro', 'node src/app.js', '--expect-bad', 'BUG_HERE', '--path', d2, '--json']);
7668
+ // A: task update 차단 (대조군). **사유까지** 단언한다 — exit 만 보면 probe 검사와 영수증 검사가
7669
+ // 서로를 가려, 둘 중 하나를 죽여도 다른 하나가 막아서 변이가 안 잡힌다(실측 M6/M7 생존).
7670
+ const ar = R(['task', 'update', ID2, '--status', 'done', '--path', d2, '--json']);
7671
+ A = ar.s === 1 && /"code":\s*"probe_still_failing"/.test(ar.o);
7672
+ // B: task sync --from 우회 차단 + tracker 에 done 이 **기록되지 않아야** (exit 만 보면 놓친다)
7673
+ R(['task', 'export', '--to', path.join(d2, 'todo.json'), '--path', d2]);
7674
+ const todo = JSON.parse(fs.readFileSync(path.join(d2, 'todo.json'), 'utf8'));
7675
+ todo.forEach(t => { if (t.content === 'fix order status') t.status = 'completed'; });
7676
+ fs.writeFileSync(path.join(d2, 'todo.json'), JSON.stringify(todo));
7677
+ const bs = R(['task', 'sync', '--from', path.join(d2, 'todo.json'), '--path', d2]);
7678
+ const tracker = fs.readFileSync(path.join(d2, '.harness', 'progress-tracker.md'), 'utf8');
7679
+ B = bs.s === 1 && !(new RegExp('\\|\\s*' + ID2 + '\\s*\\|\\s*done\\s*\\|').test(tracker));
7680
+ // C: 재오픈 시나리오 — 1라운드에서 이미 done 인 task 가 회귀했을 때도 막아야 한다(이 기능의 동기)
7681
+ const d3 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-bfgate3-')); _d.push(d3);
7682
+ R(['init', d3, '--yes', '--language', 'ko', '--no-stale-check']);
7683
+ R(['toggle', 'set', 'bugfix-receipt', 'on', '--path', d3]);
7684
+ fs.mkdirSync(path.join(d3, 'src'), { recursive: true });
7685
+ fs.writeFileSync(path.join(d3, 'src', 'app.js'), BUGGY);
7686
+ const ID3 = ((R(['task', 'add', 'fix order status', '--path', d3, '--json', '--no-review']).o.match(/"id"\s*:\s*"([^"]+)"/)) || [])[1];
7687
+ R(['task', 'update', ID3, '--status', 'done', '--evidence', 'r1', '--path', d3]); // 1라운드 완료(probe 없음)
7688
+ R(['bugfix', 'start', ID3, '--repro', 'node src/app.js', '--expect-bad', 'BUG_HERE', '--path', d3, '--json']);
7689
+ C = R(['task', 'update', ID3, '--status', 'done', '--evidence', 'r2', '--path', d3]).s === 1;
7690
+ // C2: **수정은 했지만 영수증이 없는** 상태 — probe 는 통과하므로 여기서 막는 것은 영수증 검사뿐이다.
7691
+ // 이 단계가 있어야 probe 검사와 영수증 검사가 서로를 가리지 않는다(판별 케이스).
7692
+ fs.writeFileSync(path.join(d3, 'src', 'app.js'), 'process.exit(0);');
7693
+ const c2 = R(['task', 'update', ID3, '--status', 'done', '--path', d3, '--json']);
7694
+ C = C && c2.s === 1 && /"code":\s*"receipt_incomplete"/.test(c2.o);
7695
+ // D: 수정 + 영수증 후에는 통과하고 "선언" 고지가 나온다 — 강화가 정상 흐름을 죽이면 안 된다
7696
+ R(['bugfix', 'receipt', ID3, '--root-cause', '상태 매핑 누락', '--siblings', 'ssg,11st', '--path', d3, '--json']);
7697
+ const dr = R(['task', 'update', ID3, '--status', 'done', '--evidence', 'r3', '--path', d3]);
7698
+ D = dr.s === 0 && /선언/.test(dr.o) && /진위는 검증하지 않았/.test(dr.o);
7699
+ // E: 탈출구 — drop 하면 다시 무영향
7700
+ E = R(['bugfix', 'drop', ID3, '--path', d3, '--json']).s === 0
7701
+ && R(['task', 'update', ID3, '--status', 'done', '--path', d3]).s === 0;
7702
+ ok = A && B && C && D && E && F && G;
7703
+ if (!ok) console.log(` [bugfix게이트 디버그] update차단(probe사유)=${A} sync차단=${B} 재오픈차단+영수증사유=${C} 정상통과=${D} drop탈출=${E} 없는id거부=${F} 토글OFF무영향=${G}`);
7704
+ } catch (e) { console.log(' [bugfix게이트 디버그] 예외: ' + ((e && e.message) || e)); }
7705
+ finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
7706
+ console.log(ok ? '✓ B(1.36.88) bugfix 완료 게이트 배선: task update·task sync 양 경로 차단 · 재오픈 차단 · 수정후 통과(선언 고지) · drop 탈출 · 없는id 거부 · 토글OFF 무영향' : '✗ bugfix 완료 게이트 배선 실패');
7707
+ if (!ok) failed++;
7708
+ }
7709
+
7603
7710
  // 1.36.76 (9차 헌트 이월 3건 종결): 손상 아카이브 복원 거부 · MCP read-only 무변형 · 긴 파생 id 안전화
7604
7711
  total++;
7605
7712
  {