leerness 1.36.93 → 1.36.95
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 +69 -0
- package/README.md +4 -4
- package/bin/leerness.js +173 -76
- package/lib/agents.js +10 -1
- package/lib/bugfix.js +70 -8
- package/lib/pure-utils.js +9 -3
- package/package.json +1 -1
- package/scripts/e2e.js +416 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,74 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.95 — 2026-07-31 — AI 에게 매 세션 읽히던 파일의 절반이 틀린 과거 수치였다
|
|
4
|
+
|
|
5
|
+
사용자 질문에서 시작했다 — "Opus 5 처럼 모델에 검증이 내장돼 있으면 하네스가 검증을 또 시키는 건 낭비 아닌가. 구동 모델을 인지해 맞춤으로 알려줄 수 있나?" 조사 결과 **모델 인지는 만들지 않기로 했고**(아래), 대신 그 조사가 훨씬 큰 것을 드러냈다.
|
|
6
|
+
|
|
7
|
+
**출하 중인 한글 `session-workflow.md` 템플릿 10,308자 중 5,216자(50.6%)가 낡은 릴리스 변경이력이었다.** 헤딩도 없이 Memory CRUD 섹션 아래 붙어 있었고, 내용은 MCP 도구 수를 18 → 42 로 세어 올라가는 로그였다 — **실제 도구 수는 89 다**. 이 파일은 AGENTS.md 의 필독 1번이고 CLAUDE.md 가 "⭐ 매 세션 첫 행동"으로 가리키는 파일이다. AI 가 매 세션 읽도록 설계된 문서의 절반이 틀린 과거 수치였다. 영문 템플릿은 **이미 깨끗했다**(`## Auto-recovery & security` 로 정리돼 있었다) — 한쪽 표면만 썩어 있었고 아무도 비교하지 않았다.
|
|
8
|
+
|
|
9
|
+
- 이력 43줄을 제거하고, 남긴 운영 사실 11줄에는 영문과 대응하는 `## 자동 회복 · 보안` 헤딩을 줬다. 실측: 한글 템플릿 **10,308자 → 5,107자**, 영문 5,075자 — 두 언어 크기가 맞았다.
|
|
10
|
+
- e2e 가 **도구 수 주장 자체가 0 인지**를 양쪽 언어에서 단언한다. 개수를 고쳐 적는 것은 다음 릴리스에 또 낡는다. (초안의 정규식 `\d+\s*도구\b` 는 **한글 뒤 `\b` 가 절대 매치되지 않아** 아무것도 세지 않는 공허 단언이었다 — 변이 테스트가 잡았고, 그 함정은 이미 내 메모리에 적혀 있었는데도 다시 밟았다.)
|
|
11
|
+
|
|
12
|
+
**기존 설치본의 이력까지 지우는 기능은 이 릴리스에서 뺐다.** 템플릿에서 줄을 지우면 머지(`_managedMerge`)가 그것을 "사용자 커스텀"으로 보고 `## Preserved previous content` 로 이월하므로, 원래는 기존 설치본의 이력도 함께 처리하려 했다. 세 가지 설계를 시도했고 전부 사용자 데이터를 지웠다:
|
|
13
|
+
|
|
14
|
+
1. **휴리스틱**(버전 접두 + 릴리스노트 표지) — 마크다운을 쓰는 사용자 문장을 삭제했다(한국어 코퍼스 34/34, 다국어 5/5). 백틱 하나가 보존을 삭제로 뒤집었다. 내가 댄 근거 "실제 설치본 21개에서 오탐 0건"은 **그 21개가 전부 작성자 본인의 도그푸딩 프로젝트라 제3자 산문이 표본에 없어서** 무효였다.
|
|
15
|
+
2. **기계 생성 정확 일치 목록** — 오탐 면적은 크게 줄었지만 `trim` 비교가 들여쓴 인용을 지웠고, 들여쓰기를 배제하자 이번엔 우리가 2칸 들여쓴 채 내보낸 줄을 못 지웠으며(설치본 21/21 · 42회), `raw === l` 비교는 **CRLF 파일에서 `\r` 때문에 121/121 을 전부 누출**시켰다(Windows 가 주 대상인데 기능 전체가 죽는다).
|
|
16
|
+
3. **원문 그대로 비교** — 여기까지 와서도 `<details>` 가 파일 어디에든 있으면 무관한 줄의 리터럴 펜스가 변형됐고, 템플릿이 들여쓴 줄을 사용자가 안 들여쓰면 삭제됐다.
|
|
17
|
+
|
|
18
|
+
**근본 원인은 하나다: 문자열로는 "우리가 내보낸 줄"과 "사용자가 그 줄을 적은 것"을 구분할 수 없다 — 바이트가 같다.** 규칙을 정교하게 만들수록 한쪽을 고치면서 다른 쪽이 깨졌다. 진짜 해결은 생성 영역 마커(provenance)이고, 그건 `_managedMerge` 재설계라 별도 라운드로 다룬다.
|
|
19
|
+
|
|
20
|
+
**그리고 "삭제하지 않는다"를 실제로 참으로 만들기 위해 기존 필터 하나를 걷어냈다.** 범위를 좁힌 뒤에도 검수가 반증했다 — 템플릿에서 이력 43줄을 지우면 그 줄들이 **처음으로 머지 경로를 타게 되고**, 1.36.60 이 넣어 둔 자동생성 이력 필터(`- .*MCP **N 도구` 등)가 그때 발화한다. 실측: 설치본 21개의 이력 731줄 중 **346줄 삭제**, 그리고 사용자 문장 `- 사용자 메모: MCP **77 도구** 는 우리 내부 표현이며 삭제 금지` 도 함께 사라졌다. 원래 주석은 "사용자 산문이 이 패턴과 겹칠 확률은 무시 가능"이라고 적고 있었다. 그 필터를 제거했다 — 이력 줄은 삭제되지 않고 `Preserved` 로 이월된다.
|
|
21
|
+
|
|
22
|
+
- 실측(실제 설치본 21개, 진짜 `_managedMerge`): **유실 고유 줄 0**. 검수가 만든 사용자 문장 반례 5종 전부 보존. 이력 줄은 이월(삭제 아님). 멱등. **검수가 독립 재현으로 확인했다** — 21/21 유실 0, 같은 입력에 1.36.94 코어를 쓰면 정확히 346줄 유실.
|
|
23
|
+
- 대가는 **과보존**이다 — 기존 설치본의 `session-workflow.md` 는 줄지 않고, 이력이 `Preserved` 블록으로 자리를 옮긴다. 실측: 설치본 21개 합계 203,408 → 245,239자(**+20.6%**), 파일별 1.03~2.17배. 멱등이라 반복 성장은 없고 언어 전환(ko↔en)·다른 관리 파일에도 부작용이 없다. 이 파일의 계약(false-DROP 이 버그, false-PRESERVE 는 무해)에 맞는 방향이고, 실제 정리는 provenance 라운드에서 한다.
|
|
24
|
+
- 즉 이 릴리스의 효과는 **신규 `init` 은 즉시 깨끗해지고, 기존 설치본은 데이터를 잃지 않는다** 까지다.
|
|
25
|
+
|
|
26
|
+
**`handoff` 의 라벨과 동작이 어긋나 있었다.** `Decisions (last 40 lines)` 라고 써 놓고 파일 전체를 넣은 뒤 **앞** 4,000자를 잘랐다 — 단위(줄↔문자)도, 방향(마지막↔처음)도 반대였다. 라벨대로 뒤에서 N줄을 취한다. 이 한 줄짜리 수정이 **세 번의 회귀**를 거쳤고 전부 검수가 실행으로 잡았다:
|
|
27
|
+
|
|
28
|
+
- 1차: 섹션 전체를 `slice(-4000)` 했더니 `=== Decisions … ===` **헤더가 1순위로 잘려** 결정 내용이 앞 섹션 꼬리에 붙었다(실사용 50개 중 4개 발화). 상한은 **본문에만** 적용하고 헤더는 불가침으로 바꿨다.
|
|
29
|
+
- 2차: 줄 단위 절단을 매 반복 `join()` 으로 구현해 **O(n²)** 가 됐다(48,000줄에서 30초대, 입력 2배마다 4배). 누적 길이를 한 번에 계산하도록 고쳤다.
|
|
30
|
+
- 3차: 한 줄이 상한보다 길 때 문자 인덱스로 잘라 서로게이트 쌍이 쪼개졌고(평문 U+FFFD · JSON 단독 surrogate `0xDE00`), 빈 파일을 `1/1 줄` 이라 표시했고, 정확히 4,000자인 본문을 초과로 오판해 절반을 버렸다(마지막 줄에도 개행을 셌다).
|
|
31
|
+
|
|
32
|
+
- 잘릴 때도 라벨이 사실이어야 한다 — `25/121 줄 (상한으로 잘림)` 처럼 실제 표시 줄 수를 함께 적고 **줄 경계**에서 자른다.
|
|
33
|
+
- **`handoff --json` 은 같은 내용을 앞 8,000자로 잘랐다** — 같은 명령의 두 표면이 반대 방향이었고, 외부 AI/MCP 용이라고 문서화된 쪽이 가장 오래된 결정만 받았다(15MB `decisions.md` 에서 최신 미포함). 누적 로그(progress-tracker/decisions/task-log)는 뒤를 남기고, 그 외는 앞을 남긴다. `truncated`·`totalChars`·`kept` 를 함께 준다. 마커까지 포함해 8,000자를 넘지 않는다.
|
|
34
|
+
- 실측: 결정 120개 중 081~120 이 **끊김 없이 연속**, Task Log 200개 중 최신 60개(141~200)만, `--json` 에 최신 포함·최초 잘림. 성능은 **O(n²) 폭증이 사라지고 선형 범위**다 — 파일을 읽어 `split` 하므로 입력에 비례하는 비용은 남는다(검수 재측정: 12k/24k/48k/96k 에서 대략 0.4/0.4/0.4/0.7초). "입력과 무관하다"는 뜻이 아니다.
|
|
35
|
+
|
|
36
|
+
**모델 자동 인지는 만들지 않는다.** 실측 근거: 환경변수 전수(`CLAUDE*` 17개, 검수 환경에서는 15개 — 환경마다 다르다)에 모델 성분이 없고, `CLAUDE_CODE_SESSION_ID` 로 세션 transcript 파일은 특정되지만 그 안의 모델 필드는 비공개 내부 포맷이며, 서브에이전트마다 모델이 달라 턴 단위 정확도가 성립하지 않는다. MCP `clientInfo` 는 클라이언트만 준다. 모델→프로파일 매핑을 갖는 순간 `role-catalog.js` 의 `claude-opus-4-7 "최신"` 과 같은 운명이고, 1.36.89 의 "모델 이름은 한 줄도 하드코딩하지 않는다"와 충돌한다. **토큰 절감량도 주장하지 않는다** — leerness 에 토큰 계측 코드가 0줄이다(`input_tokens|totalTokens` 검색 결과 0건). 문자수 감소만 실측으로 적는다.
|
|
37
|
+
|
|
38
|
+
**마지막으로 e2e 자신이 "삭제 안 됨"만 보고 "적용은 됐나"를 안 봤다.** 검수가 살려낸 변이 — 새 템플릿을 쓰지 않고 **이전 파일 뒤에 `Preserved` 헤더만 덧붙이는** 머지 — 가 무삭제 단언 3종을 전부 통과했다. 그러면 기존 설치본은 낡은 이력을 계속 **활성 본문**으로 읽는데 CI 는 초록이다. 부작용(삭제)은 봤지만 목적(교체)은 안 본 단언이었다. 관리 영역이 새 템플릿과 일치하고 옛 줄은 태그 뒤에만 있는지 단언한다. 그 단언조차 처음엔 정상 코드에서 실패했다 — 태그 앞에 붙는 구분선 `---` 을 반영하지 않았고, **제안받은 코드도 실행하기 전엔 맞는지 모른다**는 걸 30초 만에 확인했다.
|
|
39
|
+
|
|
40
|
+
**이 라운드에서 배운 것.** 외부 검수가 11회 차단했고 매번 실제 결함이었다. 마지막 다섯 번은 전부 **내 직전 수정이 만든 새 결함**이었고, 매번 내 자체 e2e 는 통과했다 — 내가 만든 픽스처는 내가 상상한 시나리오만 담기 때문이다. 검수에 **실제 설치본 21개를 함께 넘기자** "재현 못 함 → 미확인"이던 항목들이 "네 수치가 틀렸다"로 바뀌었다(설치본 잔존 0 → 실제 2/21, 코퍼스 121개 → 실제로는 빈 슬롯 포함 122). 그리고 마지막에는 **검수에게 출하 여부를 직접 물었다** — 내 "이번엔 다 잡았다"가 아홉 번 틀린 뒤였기 때문이고, 답은 "삭제 기능을 빼고 나머지만 내보내라"였다.
|
|
41
|
+
|
|
42
|
+
- 검증: e2e **415/415** · 회귀 가드 **변이 22종 22/22 사살**(옛 이력 필터 부활 · 이력 줄 삭제 · 새 템플릿 미적용 · 한쪽 언어만 정리 · 대문자 `**99 Tools**` · handoff 헤더 절단/O(n²) 복귀/서로게이트 분할/빈 파일 `1/1`/정확 상한 오판/Task Log 59 · `--json` 방향 뒤집기·`totalChars` 위조·마커 예산 이탈). 설치본 21개 실측 유실 0 은 **검수가 독립 재현**했고, CHANGELOG 의 "실측:" 문장은 전수 검증에서 정정 없이 통과했다.
|
|
43
|
+
|
|
44
|
+
## 1.36.94 — 2026-07-30 — 제품이 안내한 탈출구가 막다른 길이었다 · 재등록이 지운 인증 차단
|
|
45
|
+
|
|
46
|
+
헌트에서 확인만 하고 미뤄둔 것 중 **사용자가 갇히는 것**과 **보호가 조용히 꺼지는 것** 두 건을 처리했다. 그리고 첫 수정이 틀려서 되돌린 과정을 함께 적는다 — 그게 이 릴리스에서 가장 중요한 부분이다.
|
|
47
|
+
|
|
48
|
+
**차단 메시지가 안내한 탈출구가 그 상태에서 반드시 실패했다.** `bugfix-receipts.json` 이 형상 무효가 되면(가장 현실적인 경로는 git 병합 충돌 — `.harness` 는 추적 대상이다) 완료 판정을 보류한다. 어느 task 에 probe 가 걸려 있었는지 알 수 없으니 보류 자체는 옳다. 문제는 그다음이었다: 화면은 `leerness bugfix drop <ID>` 를 탈출구로 제시했는데 **그 명령도 같은 사유로 거부**했다. 등록·해제·완료가 전부 잠긴 상태에서 남는 실행 가능한 선택지는 안내문의 나머지 절반, 즉 `toggle set bugfix-receipt off` — **보호를 통째로 끄는 것** 하나였다.
|
|
49
|
+
|
|
50
|
+
**첫 수정은 `bugfix drop --force` 였고, 출하 전에 걷어냈다.** 도구가 손상 파일을 `.corrupt-<시각>` 으로 옮겨 주는 편의 명령이었는데, 검수에서 그 **한 명령이 데이터 유실 경로 다섯 개를 들여온 것**이 실행으로 확인됐다. ① 락 밖에서 `load → rename` 을 해, 검사 직후 다른 프로세스가 정상적으로 쓴 스토어를 "손상 원본"이라며 치웠다 — 대조 실측에서 정상 `drop` 은 락을 1회 잡는데 `--force` 는 0회였고, 두 프로세스 자연 경합 12초에서 옮겨진 파일 2851건 중 **556건(19.5%)이 유효한 receipt** 였다. ② `_loadChecked` 가 파싱 오류와 읽기 실패(EACCES/EIO)를 같은 `invalid` 로 뭉개는데 `--force` 는 원인을 구분하지 않아, 권한만 막힌 **멀쩡한** 스토어를 치웠다(Windows ACL 로 읽기만 막고 실측). ③ 형상 무효 분기에서 `<ID>` 를 아예 쓰지 않아 `drop T-9999 --force` 같은 오타나 ID 생략도 전체를 날렸다(대조: 정상 스토어에서는 같은 명령줄이 `probe 없음` 으로 거부된다). ④ 그 뒤 게이트가 무장해제되는데 성공 메시지의 "필요하면 `bugfix start` 로 다시 등록하세요"는 **거짓**이다 — 등록은 probe 가 지금 실패할 것을 요구하므로 버그를 고친 뒤에는 재등록이 거부된다. ⑤ 안내가 `--path` 를 잃어, `--path B` 로 막힌 사용자가 문구를 그대로 복사하면 **cwd 인 프로젝트 A** 가 파괴됐다.
|
|
51
|
+
|
|
52
|
+
되돌린 근거는 실측이다 — **파일을 손으로 고치거나 옮기는 회복은 그 다섯 가지가 전부 없고, 되돌리면 게이트도 되살아난다.** 1.36.93 의 결함은 "탈출구가 없었던 것"이 아니라 **"탈출구를 말해주지 않은 것"** 이었다. 그래서 이번 릴리스는 파괴적 명령을 추가하지 않고 안내만 고친다.
|
|
53
|
+
|
|
54
|
+
- 형상 무효를 거부하는 **모든 발화점**(`bugfix start` · `receipt` · `drop` · `list` · 완료 판정)이 `_recoverHint(root)` 로 같은 문장을 말한다 — 스토어 절대경로와, 옮기면 게이트가 무영향이 되고 되돌리면 되살아난다는 사실까지.
|
|
55
|
+
- 회복 절차가 **평문 · `task update --json` · `task sync` · `task sync --json` · `bugfix list --json` 다섯 표면 전부**에 도달한다. 종전엔 `escapeHint` 가 `store_invalid` 에서 `null` 을 돌려주고 `task sync` 가 `e.lines` 를 버려서, "틀린 안내"가 **"무안내"로 회귀**했다(검수에서 실측으로 잡혔다). 이제 다섯 표면이 같은 문자열을 쓰고 e2e 가 그 동일성과 exit 코드를 함께 단언한다.
|
|
56
|
+
- 안내 문구가 `--path` 를 유지한다. `BugfixBlocked` 가 `root` 를 함께 싣고 `_pathArg(root)` 가 cwd 와 다를 때만 붙인다.
|
|
57
|
+
- 실측: 손상 → `done` 보류(exit 1) → `drop --force` 도 **거부**(파일 무변경 · `.corrupt-*` 미생성) → 파일을 옮기면 `done` 성공 → 되돌리면 다시 보류. cwd=A 에서 `--path B` 로 막으면 안내가 B 의 경로를 가리킨다.
|
|
58
|
+
|
|
59
|
+
**`leerness provider add codex --bin codex` 한 번이 고위험 인증 차단을 껐다.** 사용자가 보기엔 아무것도 바꾸지 않는 재등록인데(실제로 바뀌는 것은 `source: builtin→user` 와 `desc` 뿐이다), provider 정규화가 6개 필드만 복사하면서 빌트인 `authCheck` 를 **조용히 버렸다**. 그 순간부터 그 프로젝트에서 codex 의 인증 축은 `unknown` 이 되고, "인증되지 않은 provider 로 고위험 작업을 확정할 수 없다"는 거부가 발동하지 않는다. 게다가 `agents check` 는 그 상태를 "등록된 확인 명령 없음"이라고 **단언**했다 — 등록돼 있는데 override 가 떨어뜨린 것이므로 사실이 아니다.
|
|
60
|
+
|
|
61
|
+
- 실측(판별 케이스: 검수 독립성을 충족시켜 인증이 *유일한* 차단 사유가 되게 구성): 로그아웃 상태 codex 로 고위험 작업이 **exit 0 · `confirmed: true`** 로 확정됐다. 수정 후에는 `provider_not_authenticated` 로 거부(exit 1)한다.
|
|
62
|
+
- **같은 실행파일일 때만** 물려받는다. 다른 실행파일에 남의 하위명령을 쏘지 않기 위해서다. 처음엔 문자열 완전일치로 비교했는데, 그러면 `codex.cmd`·대문자 `CODEX`·절대경로 같은 **정상 등록만으로 이번에 되살린 차단이 다시 사라진다**(검수 실측: Windows 에서 `CODEX --version` 은 같은 실행파일을 부른다). 확장자·대소문자·디렉토리를 정규화한 basename 으로 비교하고, e2e 가 별칭 4종을 전부 돌린다. 물려받지 못한 경우 `authCheckDroppedReason` 으로 사유를 말하고, `agents check` 화면도 "확인 명령 없음"이 아니라 "override 된 bin 이 달라 물려받지 않았습니다"로 바뀐다(`agents route` 화면에는 아직 전달되지 않는다 — 다음 라운드).
|
|
63
|
+
- 되돌리는 방법은 있었다 — `leerness provider remove codex` 로 override 를 지우면 즉시 복구된다(실측). 진짜 문제는 영속성이 아니라 **무엇이 꺼졌는지 알려주는 신호가 없었다**는 것이다. (재등록이 바꾸는 것은 `source`·`desc` 외에 `installHint`·`installCmd` 도 있다 — "아무것도 바꾸지 않는다"는 사용자 체감이지 사실이 아니다.)
|
|
64
|
+
- **이 차단은 fail-closed 가 아니다.** `auth === 'no'` 일 때만 막고 타임아웃·런처 실패·문구 불일치는 `unknown` 으로 통과시킨다(멀쩡한 사용자를 부당하게 잠그지 않기 위한 의도). 빌트인 10종 중 확인 명령이 등록된 것은 codex 하나뿐이므로, 이번 수정이 되살린 것은 그 **유일한** 차단 경로다. `unknown` 도 막을지는 정책 변경이라 별도로 다룬다.
|
|
65
|
+
- **`providers.json` 은 실행 인자를 정하는 통로가 아니다** — 그런데 `authCheck` 한 키만 막고 통로를 닫았다고 적었던 것이 검수에서 반증됐다. 실제로는 두 곳이 더 열려 있었다. ① `versionArgs` 가 `shell:true` 로 무검증 실행됐다 — `agents list` 한 번으로 임의 명령과 셸 메타문자가 돌았다(마커 파일 생성 · exit 0 · 경고 0). ② **`bin` 자체가 명령 위치다** — `cmd /c … & rem` 같은 값이 그대로 실행됐다. 그리고 두 값 모두 `provider sync` 로 **원격 catalog 에서** 들어온다.
|
|
66
|
+
- 그래서 신뢰 경계를 **모양이 아니라 출처**로 잡았다. 한때 `versionArgs` 를 "플래그 형태만"으로 좁혔다가 되돌렸다 — `kubectl version --client` · `gh copilot --version` · `go version` 처럼 하위명령이 정상인 CLI 를 false-BLOCK 해서 **설치된 도구를 미설치로 보이게** 했기 때문이다(검수 실측). 최종 규율은 세 가지다: `bin` 은 실행파일 이름/경로여야 하고(공백·메타문자 불가, 아니면 아무것도 실행하지 않는다), 로컬 `versionArgs` 는 메타문자가 없어야 하며(두 번째 명령을 만들 수 없다), **원격 `provider sync` 는 `versionArgs` 를 아예 정하지 못한다**(`--version` 고정 · 명령줄 `bin` 은 등록 자체를 거부). 메타문자만 막는 것으로는 부족하다 — `npm exec --yes <패키지>` 처럼 메타문자 없이 임의 코드를 받아오는 조합이 있다. 무시한 값은 `agents list` **평문에도** `⚠` 로 고지한다(JSON 에만 넣으면 사람은 왜 미설치로 보이는지 모른다). 빌트인 10종 부작용 0 실측.
|
|
67
|
+
|
|
68
|
+
**이번 라운드에서 배운 것.** 세 가지가 한 릴리스 안에서 동시에 재발했고 셋 다 이미 이름을 붙여 둔 실수다. (1) 탈출구를 만들면서 그 탈출구를 **데이터 규율 밖에** 두었다 — 1.36.87 이 `_upsert/_remove` 를 락 안에 넣은 이유를 알면서도 새 `--force` 는 락도 재로드도 없이 `renameSync` 를 불렀다. (2) **한 키만 막고 목표를 달성했다고 선언**했다 — `authCheck` 는 막았지만 릴리스 노트는 "임의 명령 실행 통로"를 닫은 것처럼 적었고, 같은 파일의 `versionArgs` 는 열려 있었다. 보안 목표는 키 이름이 아니라 "이 파일에서 실행되는 모든 것"으로 정의했어야 했다. (3) **새 테스트가 새 결함과 같은 시야를 공유**했다 — 첫 변이 7/7 은 내가 고른 7종에 대한 참이었고, 적대적 변이 4종(셋업 실패 미단언 · 전체 출력 정규식 · 못 찾은 줄에 대한 부정식이 공허 참 · 갈래 하나만 시험)이 살아남았다. 그래서 이번 가드는 셋업 성공과 저장 결과를 단언하고, 사유를 **구조(`refusals[].code`)에서** 읽고, 라벨을 긍정으로 단언하고, 주입을 user·빌트인 두 갈래로 시험하고, 심을 판별형(모르는 하위명령엔 usage 오류)으로 바꿨다.
|
|
69
|
+
|
|
70
|
+
그렇게 고친 가드에 변이를 다시 걸었더니 **또 3종이 살아남았고, 원인이 셋 다 달랐다.** ① `store_invalid` 에서는 공통 푸터가 원래 `null` 이라, sync 가 그것을 버리는 변이가 **등가 변이**였다 — 테스트가 그 한 사유만 덮고 있었다. ② 회복 문장이 스토어 절대경로를 직접 담아서, `--path` 부착 로직을 통째로 무력화해도 통과했다 — `--path` 는 사실상 미검증이었다. ③ 셋 중 하나는 가드 약점이 아니라 **변이 자체의 결함**이었다(`sub === 'add'` 가 파일에 9곳이라 엉뚱한 명령을 건드렸다). ①②를 메우려고 "probe 가 아직 실패" 사유로 한 번 더 막고, 그 푸터가 대상 절대경로와 `--path` 를 달고 있는지 · 평문/`--json`/sync 세 표면이 **같은 문장**인지를 단언하는 케이스를 넣었다. 파일이 사라졌을 때 예외로 죽던 단언도 명시 단언으로 바꿨다 — 크래시는 검증한 척할 뿐 사유를 남기지 않는다. 최종 변이 **23종 중 22종 사살**(`--force` 부활 · 경로 제거 · sync/`--json`/`bugfix list --json` 회복 절차 폐기 · 문장 공유 깨기 · `--path` 무력화 · root→cwd 바꿔치기 · 상속 제거 · bin 무시 상속 · bin 별칭 정규화 제거 · 상속 args 오염 · 사유 라벨 제거 · 주입 신뢰 2갈래 · bin 형식 가드 제거 · 원격 sync 의 versionArgs/bin 수용 · 평문 고지 숨김 · 폴백이 남의 하위명령 발사 · 셋업 강제 실패 · 비-`store_invalid` 푸터 폐기). 남은 1종은 **등가 변이**다 — `_checkAgent` 안의 2차 `_argsSafe` 를 무력화해도 결과가 같은데, 호출부를 전수 확인하니 사용자 데이터는 `_allProviders`(단일 병목)를 통해서만 도달해 그 층이 설계상 도달 불가이기 때문이다. 방어를 한 겹 남겨 두되 **커버리지로는 세지 않는다**.
|
|
71
|
+
|
|
3
72
|
## 1.36.93 — 2026-07-30 — 검증기가 못 보던 완료 행 · MCP 에서 영영 완료 못 하던 사용자
|
|
4
73
|
|
|
5
74
|
앞선 헌트에서 확인만 하고 미뤄둔 16건 중 **사용자가 갇히거나 검증 없이 완료되는** 두 건을 처리했다.
|
package/README.md
CHANGED
|
@@ -122,7 +122,7 @@ MIT
|
|
|
122
122
|
<!-- leerness:project-readme:start -->
|
|
123
123
|
## Leerness Project Harness
|
|
124
124
|
|
|
125
|
-
이 프로젝트는 Leerness v1.36.
|
|
125
|
+
이 프로젝트는 Leerness v1.36.95 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
|
|
126
126
|
|
|
127
127
|
### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
|
|
128
128
|
|
|
@@ -176,7 +176,7 @@ leerness memory restore decision <date|title>
|
|
|
176
176
|
|
|
177
177
|
### MCP server (외부 AI 통합)
|
|
178
178
|
|
|
179
|
-
Leerness v1.36.
|
|
179
|
+
Leerness v1.36.95는 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.93는 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.
|
|
200
|
+
현재 누적: **v1.9.x → 1.36.95 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
|
|
201
201
|
|
|
202
202
|
### 성능 가이드
|
|
203
203
|
|
|
@@ -235,5 +235,5 @@ leerness release pack --close --auto-main-push
|
|
|
235
235
|
- `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
|
|
236
236
|
- `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
|
|
237
237
|
|
|
238
|
-
Last synced by Leerness v1.36.
|
|
238
|
+
Last synced by Leerness v1.36.95: 2026-07-31
|
|
239
239
|
<!-- leerness:project-readme:end -->
|
package/bin/leerness.js
CHANGED
|
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
|
|
|
34
34
|
const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
|
|
35
35
|
const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
|
|
36
36
|
|
|
37
|
-
const VERSION = '1.36.
|
|
37
|
+
const VERSION = '1.36.95';
|
|
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') 시 호스트 프로세스 오염.
|
|
@@ -693,6 +693,8 @@ leerness memory restore <surface> <target> # archive → active 복귀 (DELETE
|
|
|
693
693
|
3. handoff 가 매 세션 자동으로 24h 내 archive 활동 알림
|
|
694
694
|
|
|
695
695
|
|
|
696
|
+
## 자동 회복 · 보안
|
|
697
|
+
|
|
696
698
|
- session close가 누락되면 다음 세션 시작 시 drift critical 발생.
|
|
697
699
|
- 자동 회복 옵션: \`drift check --auto-fix\` (critical 시 session close 자동 실행).
|
|
698
700
|
- handoff가 매 세션 시작 시 **과거 lessons 자동 재상기** (현재 task 키워드 기준).
|
|
@@ -704,49 +706,6 @@ leerness memory restore <surface> <target> # archive → active 복귀 (DELETE
|
|
|
704
706
|
- handoff Date/Project 직후 통합 헤드라인 한 줄 (drift / 보안 / MCP / skill query / 설치 skill 수).
|
|
705
707
|
- \`leerness health\` 한 줄로 종합 점검 (drift + 보안 + skills + usage + tasks).
|
|
706
708
|
- \`leerness drift check --auto-fix\` 가 보안 신호 발견 시 \`audit --fix\` 자동 실행 → 재검사.
|
|
707
|
-
- MCP server **18 도구** (handoff/drift/audit/verify_claim/contract/agents/reuse/whats_new/usage_stats/session_close/skill_suggest/lessons/task_export/env_check/brainstorm/skill_match/skill_list/health).
|
|
708
|
-
- MCP server **21 도구** (skill_search/skill_info/benchmark 추가).
|
|
709
|
-
- \`leerness handoff --json\` (외부 AI/MCP 통합용 구조화 출력).
|
|
710
|
-
- \`leerness skill publish\` 보안 사전 점검 통합 (health 통과 후 publish).
|
|
711
|
-
- \`leerness handoff --quiet\` (자동화/CI 모드 — 자동 회수 라인 비활성).
|
|
712
|
-
- 🏆 마일스톤 — 30 라운드 자율 누적, stress-v45 30/30 PASS, e2e 219/219 PASS.
|
|
713
|
-
- \`leerness lazy detect --json\` + MCP **22 도구** (\`leerness_lazy_detect\` 추가 — 거짓 완료/empty handoff/no test run/TODO 미추적 신호 JSON).
|
|
714
|
-
- \`leerness audit --json\` 구조화 출력 (findings 11종 kind: design_dup/design_system_default/reuse_map_empty/milestone_unlinked/handoff_not_generated/current_state_stale/readme_version_mismatch/npm_cve/gitignore_missing_secrets/env_keys_missing/strict_promoted). MCP \`leerness_audit\`도 JSON 자동.
|
|
715
|
-
- \`leerness session close --json\` 마감 통계 JSON (taskCounts/rules/skillCandidates/drift/topCommands/mcpStats/workspacePeers). MCP \`leerness_session_close\`도 JSON 자동.
|
|
716
|
-
- MCP **23 도구** (\`leerness_retro\` 추가 — 4세션 누적 회고 JSON 외부 AI 노출).
|
|
717
|
-
- MCP **24 도구** (\`leerness_task_add\` 추가 — 외부 AI 가 task 즉시 등록, 양방향 제어 완성).
|
|
718
|
-
- MCP **25 도구** (\`leerness_task_update\` 추가 — task 상태/evidence/nextAction 갱신, read+add+update 3종 surface 완성).
|
|
719
|
-
- MCP **26 도구** (\`leerness_task_drop\` 추가 — task 폐기, **task CRUD 완성**: read/add/update/drop).
|
|
720
|
-
- \`leerness decision add\` CLI + MCP **27 도구** (\`leerness_decision_add\` — decisions.md 영구화 + handoff lessons 회수와 통합).
|
|
721
|
-
- \`leerness rule list --json\` + MCP **29 도구** (\`leerness_rule_add\` + \`leerness_rule_list\` — 자연어 영구 룰 R/W).
|
|
722
|
-
- MCP **30 도구 🎉 30 도구 마일스톤** (\`leerness_plan_add\` — plan.md milestone + progress-tracker 자동 동기화).
|
|
723
|
-
- MCP **31 도구** (\`leerness_lesson_save\` — lessons.md 직접 write, **Memory Write Surface 5종 완성**: tasks/decisions/rules/plan/lessons).
|
|
724
|
-
- handoff 통합 헤드라인에 **🧠 mem T/D/R/P/L 카운트** 추가 — 5종 메모리 영구화 상태 한눈에 확인.
|
|
725
|
-
- \`leerness memory status [--json]\` + MCP **32 도구** (\`leerness_memory_status\`) — 상세 상태 + 최근 항목 조회.
|
|
726
|
-
- \`leerness handoff --json\` 응답에 **\`memorySurface\` 필드 통합** — 단일 호출로 컨텍스트 + 5종 메모리 상태 동시 회수.
|
|
727
|
-
- \`leerness brainstorm\` 회수 범위에 **lessons.md + plan.md** milestone 추가 — Memory Surface 5종 완전 통합.
|
|
728
|
-
- \`leerness lesson list [--tag] [--json]\` + MCP **33 도구** (\`leerness_lesson_list\`) — lessons.md 전용 조회 + tag 필터.
|
|
729
|
-
- \`leerness decision list [--json]\` + MCP **34 도구** (\`leerness_decision_list\`) — decisions.md 전체 조회 (Decision/Reason/Alternatives/Impact 메타).
|
|
730
|
-
- \`leerness plan list [--json]\` + MCP **35 도구** (\`leerness_plan_list\`) — plan.md milestone 전체 (Status/Progress/Tasks). **Memory Surface READ 5종 완전 완성**.
|
|
731
|
-
- handoff 6번째 자동 회수 \`🆕 최근 24h 메모리 변동\` — 5종 surface 의 24h 내 추가 항목 자동 노출.
|
|
732
|
-
- \`session close --json\` 응답에도 \`memorySurface\` 필드 통합 — 마감 시 5종 메모리 상태 동시 회수.
|
|
733
|
-
- \`health --json\` 응답에도 \`memorySurface\` 필드 통합 — handoff/session close/memory status 모든 JSON 명령 일관성.
|
|
734
|
-
- \`leerness lesson drop <target>\` + MCP **36 도구** (\`leerness_lesson_drop\`) — 잘못 저장한 lesson 제거 (archive 자동 보존).
|
|
735
|
-
- \`leerness decision drop <target>\` + MCP **37 도구** (\`leerness_decision_drop\`) — 잘못 저장한 결정 제거 (archive 보존).
|
|
736
|
-
- \`leerness plan remove <M-XXXX|title>\` + MCP **38 도구** (\`leerness_plan_remove\`) — milestone 영구 제거 (archive 보존). **Memory Surface DELETE 5종 완전 완성** 🎉.
|
|
737
|
-
- \`leerness memory archive list [--surface decisions|lessons|plan] [--json]\` + MCP **39 도구** (\`leerness_memory_archive_list\`) — DELETE 5종 archive 통합 조회 (복원 후보 회수).
|
|
738
|
-
- \`leerness memory restore <surface> <target>\` + MCP **40 도구 🎉** (\`leerness_memory_restore\`) — archive → active 복귀 (DELETE→RESTORE cycle 완성). **MCP 40 도구 마일스톤**.
|
|
739
|
-
- handoff **7번째 자동 회수** — \`🗑 최근 24h archive\` (D/L/P 카운트 + 복원 후보 안내). DELETE 활동 자동 인지.
|
|
740
|
-
- 🎉 **60 라운드 자율 모드 마일스톤** — JSON 4종 (handoff/memory status/session close/health) \`memorySurface.archive\` 필드 통합. MCP 40 / handoff auto-recovery 7 / DELETE-RESTORE cycle 완성.
|
|
741
|
-
- \`brainstorm\` 회수 범위에 3 archive 파일 (decisions/lessons/plan archive) 통합 — 과거 제거된 ideas 가 새 brainstorm 시 다시 후보로 노출. \`hits.archive\` 필드 + 복원 안내 라인.
|
|
742
|
-
- \`session close\` 텍스트 모드에 archive 누적 라인 추가 — 마감 시점 DELETE 활동 가시화 (handoff 7번째 회수와 symmetric). archive 가시성 6 surface 완성.
|
|
743
|
-
- \`brainstorm\` 텍스트 모드 lessonsExplicit / planMilestones display 추가 — 데이터 수집은 됐지만 display 가 누락돼 있던 gap 보완 fix.
|
|
744
|
-
- \`leerness task list --json\` + MCP **41 도구** (\`leerness_task_list\`) — progress-tracker.md task JSON 조회 + \`--status\` 필터 (1.36.72 부터 기본 최신 100행, 전체는 \`--all\`). Task surface CRUD MCP 완전 완성 (add/list/update/drop).
|
|
745
|
-
- MCP **42 도구** (\`leerness_rule_remove\`) — rules.md 에서 특정 rule 제거 + archive 보존. **5 surface CRUD MCP 완전 완성** (task/decision/lesson/plan/rule 모두 add/list/delete MCP 노출).
|
|
746
|
-
- MCP \`leerness_drift_check\` JSON 응답 fix — \`--json\` 플래그 자동 추가하여 외부 AI가 구조화된 drift 신호 회수 (score, level, signals[], healthy).
|
|
747
|
-
- \`.harness/session-workflow.md\` 템플릿에 **🧠 Memory CRUD Quick Reference** 섹션 추가 — 5 surface × CRUD 매트릭스 + archive cycle 워크플로 가이드. 신규 \`init\` 워크스페이스 즉시 적용.
|
|
748
|
-
- \`leerness memory archive list --query <keyword>\` + MCP \`leerness_memory_archive_list\` query 인자 — archive 항목 키워드 case-insensitive 검색 (target/originalHeader 매칭).
|
|
749
|
-
- \`leerness lesson list --query\` + \`leerness decision list --query\` + MCP 동일 인자 — active Memory 항목 키워드 검색 (lesson: text/tag, decision: title/decision/reason/alternatives/impact).
|
|
750
709
|
|
|
751
710
|
---
|
|
752
711
|
|
|
@@ -10037,12 +9996,12 @@ function upsertProgress(root, row) {
|
|
|
10037
9996
|
code: 'toggles_unreadable',
|
|
10038
9997
|
message: `toggles.json 을 신뢰할 수 없어(${_why}) 완료 판정을 보류합니다 — 켜 두었던 게이트가 꺼진 채 통과하는 것을 막습니다: ${_tgl._togglesPath(root)}`,
|
|
10039
9998
|
lines: [`복구: 파일을 고치거나 삭제 후 leerness toggle set <id> on|off 로 다시 설정하세요.`],
|
|
10040
|
-
}, row.id);
|
|
9999
|
+
}, row.id, root);
|
|
10041
10000
|
}
|
|
10042
10001
|
if (_tchk.toggles['bugfix-receipt'] === true) {
|
|
10043
10002
|
const _bfm = require('../lib/bugfix');
|
|
10044
10003
|
const _r = _bfm.checkDoneTransition(root, row.id);
|
|
10045
|
-
if (_r.blocked) throw new _bfm.BugfixBlocked(_r, row.id);
|
|
10004
|
+
if (_r.blocked) throw new _bfm.BugfixBlocked(_r, row.id, root);
|
|
10046
10005
|
_lastBugfixLines = _r.lines || null;
|
|
10047
10006
|
}
|
|
10048
10007
|
}
|
|
@@ -10420,11 +10379,13 @@ function taskUpdate(root, id) {
|
|
|
10420
10379
|
_bfLines = _consumeBugfixLines();
|
|
10421
10380
|
} catch (e) {
|
|
10422
10381
|
if (!e || e.name !== 'BugfixBlocked') throw e;
|
|
10423
|
-
|
|
10382
|
+
// 1.36.94: 회복 절차를 **평문에만** 넣으면 --json 소비자(스크립트·MCP)는 사유만 받고 빠져나갈 길을 못 받는다.
|
|
10383
|
+
// 같은 문자열을 두 표면이 공유하게 한다(표면마다 따로 만들면 한쪽만 고치는 실패가 되풀이된다).
|
|
10384
|
+
const _rec = (e.lines || []).concat([require('../lib/bugfix').escapeHint(e.code, id, e.root)].filter(Boolean));
|
|
10385
|
+
if (has('--json')) { log(JSON.stringify({ ok: false, code: e.code, id, error: e.message, recovery: _rec }, null, 2)); }
|
|
10424
10386
|
else {
|
|
10425
10387
|
fail(`done 보류 — ${e.message}`);
|
|
10426
|
-
|
|
10427
|
-
log(` ⓘ 탈출구: leerness bugfix drop ${id} (probe 등록 취소) · leerness toggle set bugfix-receipt off (프로젝트 정책)`);
|
|
10388
|
+
_rec.forEach(l => log(' ' + l));
|
|
10428
10389
|
}
|
|
10429
10390
|
process.exitCode = 1;
|
|
10430
10391
|
return;
|
|
@@ -11587,18 +11548,34 @@ function handoff(root) {
|
|
|
11587
11548
|
version: VERSION,
|
|
11588
11549
|
files: {}
|
|
11589
11550
|
};
|
|
11590
|
-
|
|
11591
|
-
|
|
11592
|
-
|
|
11593
|
-
|
|
11594
|
-
|
|
11551
|
+
// 1.36.95: 같은 명령의 두 표면이 **반대 방향**으로 잘랐다 — 평문은 최근 N줄, JSON 은 앞 8000자.
|
|
11552
|
+
// 외부 AI/MCP 용이라고 문서화된 JSON 소비자가 가장 오래된 결정만 받았다(실측: 15MB decisions.md 에서
|
|
11553
|
+
// 최신 결정 미포함 · AEGIRINE task-log 529,747자 중 앞 1.5%). 누적 로그는 **뒤가 최신**이므로 뒤를 남긴다.
|
|
11554
|
+
function _addFile(key, p, tailFirst) {
|
|
11555
|
+
if (!exists(p)) return;
|
|
11556
|
+
const content = read(p);
|
|
11557
|
+
if (content.length <= 8000) { result.files[key] = { path: rel(root, p), content }; return; }
|
|
11558
|
+
// **코드포인트 경계**에서 자른다 — 문자 인덱스 slice 는 서로게이트 쌍을 쪼개 JSON 에 단독 surrogate 를
|
|
11559
|
+
// 남긴다(검수 실측: 0xDE00). 일부 소비자는 그걸 거부하거나 U+FFFD 로 바꾼다.
|
|
11560
|
+
const _safe = (s, n, fromEnd) => {
|
|
11561
|
+
const cps = Array.from(s); let len = 0, k = 0;
|
|
11562
|
+
if (fromEnd) { for (let i = cps.length - 1; i >= 0 && len + cps[i].length <= n; i--) { len += cps[i].length; k++; } return cps.slice(cps.length - k).join(''); }
|
|
11563
|
+
for (let i = 0; i < cps.length && len + cps[i].length <= n; i++) { len += cps[i].length; k++; }
|
|
11564
|
+
return cps.slice(0, k).join('');
|
|
11565
|
+
};
|
|
11566
|
+
// 마커를 상한 **바깥**에 붙이면 content 가 8,013자가 되어 8,000자를 예산으로 잡은 소비자가 넘긴다
|
|
11567
|
+
// (검수 실측). 마커까지 포함해 8,000자를 넘지 않게 한다.
|
|
11568
|
+
const MARK = '\n…(truncated)';
|
|
11569
|
+
const budget = 8000 - MARK.length;
|
|
11570
|
+
const cut = tailFirst ? MARK.trimStart() + '\n' + _safe(content, budget, true) : _safe(content, budget, false) + MARK;
|
|
11571
|
+
result.files[key] = { path: rel(root, p), content: cut, truncated: true, totalChars: content.length, kept: tailFirst ? 'tail' : 'head' };
|
|
11595
11572
|
}
|
|
11596
11573
|
_addFile('sessionHandoff', handoffPath(root));
|
|
11597
11574
|
_addFile('currentState', currentStatePath(root));
|
|
11598
11575
|
_addFile('plan', planPath(root));
|
|
11599
|
-
_addFile('progressTracker', progressPath(root));
|
|
11600
|
-
_addFile('decisions', decisionsPath(root));
|
|
11601
|
-
_addFile('taskLog', taskLogPath(root));
|
|
11576
|
+
_addFile('progressTracker', progressPath(root), true);
|
|
11577
|
+
_addFile('decisions', decisionsPath(root), true);
|
|
11578
|
+
_addFile('taskLog', taskLogPath(root), true);
|
|
11602
11579
|
// active rules
|
|
11603
11580
|
const activeRules = readRules(root).filter(r => r.status === 'active');
|
|
11604
11581
|
if (activeRules.length) result.activeRules = activeRules.map(r => ({ id: r.id, trigger: r.trigger, rule: r.rule }));
|
|
@@ -11831,17 +11808,54 @@ function handoff(root) {
|
|
|
11831
11808
|
return;
|
|
11832
11809
|
}
|
|
11833
11810
|
const sections = [];
|
|
11834
|
-
|
|
11811
|
+
// 1.36.95: 라벨과 동작이 어긋나 있었다 — `last 40 lines` 라고 써 놓고 파일 전체를 넣은 뒤
|
|
11812
|
+
// **앞** 4000자를 잘랐다(단위도 줄↔문자로, 방향도 마지막↔처음으로 반대). 결정/작업로그는
|
|
11813
|
+
// 최근 항목이 쓸모 있으므로 라벨대로 **뒤에서** N줄을 취하고, 안전 상한도 뒤를 남긴다.
|
|
11814
|
+
// **헤더는 절대 잘리지 않는다.** 1.36.95 초안은 섹션 전체를 `slice(-4000)` 해서 `=== Decisions … ===`
|
|
11815
|
+
// 헤더가 1순위로 잘려 나갔고(헤더가 문자열 맨 앞에 있다), 그 결과 결정 내용이 앞 섹션 꼬리에 붙어
|
|
11816
|
+
// AI 가 소속을 오독했다(실사용 50개 중 4개 발화). 상한은 **본문에만** 적용한다.
|
|
11817
|
+
// 그리고 잘릴 때도 라벨이 사실이어야 한다 — 줄 경계에서 자르고 실제 표시 줄 수를 함께 적는다
|
|
11818
|
+
// (종전엔 `last 40 lines` 라면서 25줄을 문장 중간에서 잘라 보여줬다).
|
|
11819
|
+
const BODY_CAP = 4000;
|
|
11820
|
+
function block(label, p, tailLines) {
|
|
11835
11821
|
if (!exists(p)) return;
|
|
11836
|
-
|
|
11822
|
+
const body = read(p).trim();
|
|
11823
|
+
// 빈 파일은 `''.split('\n') === ['']` 이라 종전엔 `1/1 줄` 로 표시됐다(라벨이 거짓).
|
|
11824
|
+
let ls = body ? body.split('\n') : [];
|
|
11825
|
+
const total = ls.length;
|
|
11826
|
+
if (tailLines && ls.length > tailLines) ls = ls.slice(-tailLines);
|
|
11827
|
+
let truncated = false;
|
|
11828
|
+
// 본문 상한은 **줄 단위**로 채운다. 매 반복마다 join() 하면 O(n²) 다 — 검수 실측으로 48,000줄에서
|
|
11829
|
+
// 30.7초가 걸렸다(입력 2배마다 4배). 누적 길이를 한 번에 계산해 자를 지점만 찾는다(선형).
|
|
11830
|
+
if (ls.length) {
|
|
11831
|
+
// 구분자(\n)는 **줄 사이에만** 있다 — 마지막 줄에도 +1 을 더하면 정확히 상한인 본문을 초과로 오판해
|
|
11832
|
+
// 절반을 버린다(검수 실측: 4,000자 2줄이 `1/2 줄 (상한으로 잘림)` 이 됐다).
|
|
11833
|
+
let acc = 0, keep = 0;
|
|
11834
|
+
const fits = (i) => acc + (keep ? 1 : 0) + ls[i].length <= BODY_CAP;
|
|
11835
|
+
if (tailLines) { for (let i = ls.length - 1; i >= 0; i--) { if (!fits(i) && keep > 0) break; acc += (keep ? 1 : 0) + ls[i].length; keep++; } if (keep < ls.length) { ls = ls.slice(-keep); truncated = true; } }
|
|
11836
|
+
else { for (let i = 0; i < ls.length; i++) { if (!fits(i) && keep > 0) break; acc += (keep ? 1 : 0) + ls[i].length; keep++; } if (keep < ls.length) { ls = ls.slice(0, keep); truncated = true; } }
|
|
11837
|
+
}
|
|
11838
|
+
// 한 줄이 상한보다 길면 그 줄만 자른다 — **코드포인트 경계**에서 자른다.
|
|
11839
|
+
// 문자 인덱스로 자르면 서로게이트 쌍이 쪼개져 U+FFFD 나 단독 surrogate 가 남는다(검수 실측).
|
|
11840
|
+
if (ls.length === 1 && ls[0].length > BODY_CAP) {
|
|
11841
|
+
const cp = Array.from(ls[0]);
|
|
11842
|
+
let n = 0, len = 0;
|
|
11843
|
+
if (tailLines) { for (let i = cp.length - 1; i >= 0 && len + cp[i].length <= BODY_CAP; i--) { len += cp[i].length; n++; } ls = [cp.slice(cp.length - n).join('')]; }
|
|
11844
|
+
else { for (let i = 0; i < cp.length && len + cp[i].length <= BODY_CAP; i++) { len += cp[i].length; n++; } ls = [cp.slice(0, n).join('')]; }
|
|
11845
|
+
truncated = true;
|
|
11846
|
+
}
|
|
11847
|
+
const note = (tailLines || truncated)
|
|
11848
|
+
? ` — ${ls.length}/${total} 줄${truncated ? ' (상한으로 잘림)' : ''}`
|
|
11849
|
+
: '';
|
|
11850
|
+
sections.push(`\n=== ${label}${note} (${rel(root,p)}) ===\n${ls.join('\n')}`);
|
|
11837
11851
|
}
|
|
11838
11852
|
block('Session Handoff', handoffPath(root));
|
|
11839
11853
|
block('Current State', currentStatePath(root));
|
|
11840
11854
|
block('Plan', planPath(root));
|
|
11841
11855
|
block('Progress Tracker', progressPath(root));
|
|
11842
|
-
block('Decisions (last 40 lines)', decisionsPath(root));
|
|
11843
|
-
block('Task Log (last 60 lines)', taskLogPath(root));
|
|
11844
|
-
const out = sections.
|
|
11856
|
+
block('Decisions (last 40 lines)', decisionsPath(root), 40);
|
|
11857
|
+
block('Task Log (last 60 lines)', taskLogPath(root), 60);
|
|
11858
|
+
const out = sections.join('\n');
|
|
11845
11859
|
log('# Session Start Context');
|
|
11846
11860
|
log(`Date: ${today()}`);
|
|
11847
11861
|
log(`Project: ${detectProjectName(root)}`);
|
|
@@ -14993,14 +15007,41 @@ function _allProviders(root) {
|
|
|
14993
15007
|
for (const u of userList) {
|
|
14994
15008
|
if (!u || !u.id) continue;
|
|
14995
15009
|
// 정상화 — 누락 필드는 빌트인에서 fallback
|
|
15010
|
+
const _bi = EXTERNAL_AGENTS.find(a => a.id === u.id);
|
|
15011
|
+
const _bin = u.bin || u.id;
|
|
15012
|
+
// 1.36.94 (헌트 #16/#21): 정규화가 6개 필드만 복사해 빌트인 `authCheck` 를 **조용히 버렸다** —
|
|
15013
|
+
// `provider add codex --bin codex`(사실상 무변경 등록) 한 번으로 인증 축이 영원히 unknown 이 되고,
|
|
15014
|
+
// 고위험 fail-closed(provider_not_authenticated)가 경고 없이 꺼졌다(실측: 로그아웃 codex 로 exit 0 확정).
|
|
15015
|
+
// 게다가 UI 는 "등록된 확인 명령 없음"이라고 **단언**했다 — 등록돼 있는데 override 가 떨어뜨린 것이라 거짓이다.
|
|
15016
|
+
// **같은 bin 일 때만** 빌트인 확인 명령을 물려받는다(다른 실행파일에 남의 하위명령을 쏘면 안 된다).
|
|
15017
|
+
// 사용자 JSON 의 authCheck 는 절대 신뢰하지 않는다(임의 명령 주입 경로) — 빌트인에서만 온다.
|
|
15018
|
+
// 같은 실행파일이면 물려받는다(별칭 정규화) — 다른 실행파일에 남의 하위명령을 쏘지는 않는다.
|
|
15019
|
+
const _inherited = (_bi && _bi.authCheck && _binKey(_bin) === _binKey(_bi.bin)) ? _bi.authCheck : undefined;
|
|
15020
|
+
// versionArgs 도 **같은 규율**로 다룬다. 종전엔 사용자 값을 그대로 실어 `shell:true` 로 돌렸는데,
|
|
15021
|
+
// 메타문자가 있으면 두 번째 명령이 됐다(실측: `agents list` 한 번으로 마커 파일 생성).
|
|
15022
|
+
// 한때 "플래그만 허용"으로 좁혔다가 되돌렸다 — `kubectl version --client` · `gh copilot --version` ·
|
|
15023
|
+
// `go version` 처럼 **하위명령이 정상인 CLI** 를 false-BLOCK 해서 설치된 도구를 미설치로 보이게 했다.
|
|
15024
|
+
// 두 번째 명령을 막는 실질 조건은 메타문자 부재이고, bin 은 위 _binSafe 로 따로 잠근다.
|
|
15025
|
+
const _uva = Array.isArray(u.versionArgs) ? u.versionArgs : null;
|
|
15026
|
+
const _uvaOk = !!_uva && _argsSafe(_uva);
|
|
15027
|
+
// 폴백은 **같은 실행파일일 때만** 빌트인 값을 쓴다 — 아니면 남의 하위명령(`gh copilot --version`)을
|
|
15028
|
+
// 엉뚱한 bin 에 쏜다(검수 실측: `kubectl copilot --version`).
|
|
15029
|
+
const _fallbackVa = (_bi && _binKey(_bin) === _binKey(_bi.bin) && _bi.versionArgs) || ['--version'];
|
|
15030
|
+
// bin 이 명령줄이면 실행 자체를 하지 않는다 — 이 provider 는 '미설치'로 보이고 사유를 말한다.
|
|
15031
|
+
const _binOk = _binSafe(_bin);
|
|
14996
15032
|
userOverrides.set(u.id, {
|
|
14997
15033
|
id: u.id,
|
|
14998
|
-
bin:
|
|
14999
|
-
|
|
15000
|
-
|
|
15001
|
-
|
|
15002
|
-
|
|
15003
|
-
|
|
15034
|
+
bin: _bin,
|
|
15035
|
+
...(_binOk ? {} : { binRejectedReason: `providers.json 의 bin 이 실행파일 이름/경로가 아니어서 실행하지 않았습니다(공백·셸 메타문자 불가): ${String(_bin).slice(0, 80)}` }),
|
|
15036
|
+
envFlag: u.envFlag || (_bi && _bi.envFlag) || `LEERNESS_ENABLE_${String(u.id).toUpperCase()}`,
|
|
15037
|
+
versionArgs: _uvaOk ? _uva : _fallbackVa,
|
|
15038
|
+
...((_uva && !_uvaOk) ? { versionArgsRejectedReason: `providers.json 의 versionArgs 에 셸 메타문자가 있어 실행하지 않았습니다: ${JSON.stringify(_uva).slice(0, 120)}` } : {}),
|
|
15039
|
+
desc: u.desc || (_bi && _bi.desc) || `(user) ${u.id}`,
|
|
15040
|
+
installHint: u.installHint || (_bi && _bi.installHint) || '',
|
|
15041
|
+
installCmd: u.installCmd || (_bi && _bi.installCmd) || '',
|
|
15042
|
+
...(_inherited ? { authCheck: _inherited } : {}),
|
|
15043
|
+
// 빌트인에 확인 명령이 있는데 bin 을 바꿔 물려받지 못한 경우 — UI 가 사유를 정확히 말할 수 있게 표시한다
|
|
15044
|
+
...((_bi && _bi.authCheck && !_inherited) ? { authCheckDroppedReason: `override 된 bin(${_bin})이 빌트인(${_bi.bin})과 달라 확인 명령을 물려받지 않았습니다` } : {}),
|
|
15004
15045
|
});
|
|
15005
15046
|
}
|
|
15006
15047
|
// 빌트인 먼저, user override 적용
|
|
@@ -15137,14 +15178,22 @@ function providerCmd(root, sub, ...args) {
|
|
|
15137
15178
|
let added = 0, updated = 0, skipped = 0;
|
|
15138
15179
|
for (const e of entries) {
|
|
15139
15180
|
if (!e.id || !e.bin || !validIdRegex.test(e.id)) { skipped++; continue; }
|
|
15181
|
+
// 1.36.94: **원격 catalog 는 실행 인자를 정하지 못한다.** 여기 저장된 값은 나중에 `agents list/check`
|
|
15182
|
+
// 같은 읽기성 명령에서 `shell:true` 로 실행된다 — 즉 원격 문자열이 곧 로컬 실행이다.
|
|
15183
|
+
// 메타문자만 막는 것으로는 부족하다: `npm exec --yes <패키지>` 처럼 **메타문자 없이도** 임의 코드를
|
|
15184
|
+
// 받아오는 조합이 존재한다. 그래서 versionArgs 는 원격에서 받지 않고 항상 `--version` 으로 고정한다
|
|
15185
|
+
// (문서화된 catalog 형식은 `id|bin|desc` 이므로 형식 손실도 없다).
|
|
15186
|
+
// bin 은 형식 검증을 통과한 것만 받는다 — 실행파일 이름/경로가 아니면 그 자체가 명령줄이 된다.
|
|
15187
|
+
if (!_binSafe(e.bin)) { skipped++; log(` ⚠ ${e.id} 무시 — bin 이 실행파일 이름/경로가 아닙니다: ${String(e.bin).slice(0, 60)}`); continue; }
|
|
15140
15188
|
const entry = {
|
|
15141
15189
|
id: e.id,
|
|
15142
15190
|
bin: e.bin,
|
|
15143
15191
|
envFlag: e.envFlag || `LEERNESS_ENABLE_${String(e.id).toUpperCase()}`,
|
|
15144
|
-
versionArgs:
|
|
15192
|
+
versionArgs: ['--version'],
|
|
15145
15193
|
desc: e.desc || `(synced) ${e.id}`,
|
|
15146
15194
|
installHint: e.installHint || ''
|
|
15147
15195
|
};
|
|
15196
|
+
if (e.versionArgs) log(` ⓘ ${e.id}: 원격 versionArgs 는 사용하지 않습니다(--version 고정) — 원격 값이 로컬 실행 인자가 되지 않게 합니다`);
|
|
15148
15197
|
if (existingIds.has(e.id)) {
|
|
15149
15198
|
const idx = userList.findIndex(u => u.id === e.id);
|
|
15150
15199
|
if (!dryRun) userList[idx] = entry;
|
|
@@ -15334,12 +15383,47 @@ function _recommendAgent(task) {
|
|
|
15334
15383
|
return { target: null, reason: '' };
|
|
15335
15384
|
}
|
|
15336
15385
|
|
|
15386
|
+
// args 는 shell:true 로 실행되므로(윈도우 .cmd 심 해석에 필요) **셸 메타문자가 없어야** 한다.
|
|
15387
|
+
// 있으면 인용이 깨져 엉뚱한 실행이 되고(실측: 따옴표 포함 args 가 파싱 실패로 exit 1), 주입 표면도 된다.
|
|
15388
|
+
// 1.36.94: 이 술어가 authCheck 안쪽에만 있어서 **바로 위의 versionArgs 는 무방비였다** — providers.json 을
|
|
15389
|
+
// 손으로 고치거나 `provider sync` 로 받은 catalog 에 `versionArgs` 를 넣으면 `agents list` 한 번으로
|
|
15390
|
+
// 임의 명령·셸 메타문자가 돌았다(실측: 마커 파일 생성 · 별도 프로세스 실행 · exit 0 · 경고 0).
|
|
15391
|
+
// 같은 파일에서 실행되는 것은 **전부** 같은 술어를 통과해야 한다(키 하나만 막고 닫혔다고 말하지 않는다).
|
|
15392
|
+
function _argsSafe(a) {
|
|
15393
|
+
return Array.isArray(a) && a.length > 0 && a.every(x => typeof x === 'string' && /^[A-Za-z0-9._:@/\\-]+$/.test(x));
|
|
15394
|
+
}
|
|
15395
|
+
|
|
15396
|
+
// bin 은 **실행파일 이름/경로**여야 한다 — 명령줄이 아니다. `shell:true` 로 넘기므로 여기에 공백이나
|
|
15397
|
+
// 메타문자가 있으면 그 자체가 두 번째 명령이 된다(`cmd /c whoami & rem`). versionArgs 만 막고 닫혔다고
|
|
15398
|
+
// 말했던 것이 검수에서 반증됐다: `provider sync` 는 원격 catalog 의 `bin` 을 그대로 저장하고,
|
|
15399
|
+
// 그 값은 읽기성 명령인 `agents list/check` 에서 실행된다. 공백을 막는 것은 회귀가 아니다 —
|
|
15400
|
+
// `shell:true` 에서 공백 있는 bin 은 지금도 첫 토큰만 명령으로 해석돼 어차피 동작하지 않는다.
|
|
15401
|
+
function _binSafe(b) {
|
|
15402
|
+
return typeof b === 'string' && b.length > 0 && b.length <= 260 && /^[A-Za-z0-9._:@/\\-]+$/.test(b);
|
|
15403
|
+
}
|
|
15404
|
+
|
|
15405
|
+
// 같은 실행파일을 가리키는 **정상적인 별칭**까지 다른 것으로 보면, 이번에 되살린 인증 차단이
|
|
15406
|
+
// `codex.cmd`·대문자·절대경로 등록만으로 다시 사라진다(검수 실측: Windows 에서 `CODEX --version` 동작).
|
|
15407
|
+
// 확장자·대소문자(win32)·디렉토리를 정규화한 **basename** 으로 비교한다.
|
|
15408
|
+
function _binKey(b) {
|
|
15409
|
+
let s = String(b || '').replace(/\\/g, '/');
|
|
15410
|
+
s = s.slice(s.lastIndexOf('/') + 1).replace(/\.(exe|cmd|bat|ps1|com)$/i, '');
|
|
15411
|
+
return process.platform === 'win32' ? s.toLowerCase() : s;
|
|
15412
|
+
}
|
|
15413
|
+
|
|
15337
15414
|
function _checkAgent(agent, opts = {}) {
|
|
15338
15415
|
const enabled = process.env[agent.envFlag] === '1';
|
|
15339
15416
|
// PATH 존재 확인 (which / where)
|
|
15340
15417
|
let installed = false, version = null, error = null;
|
|
15418
|
+
// 안전하지 않은 versionArgs 는 **실행하지 않고** 빌트인 기본값으로 되돌린다(거부보다 낫다 — 설치 확인 자체는 해야 한다).
|
|
15419
|
+
const _vaRaw = agent.versionArgs;
|
|
15420
|
+
const _vaSafe = _argsSafe(_vaRaw);
|
|
15421
|
+
const _versionArgs = _vaSafe ? _vaRaw : ['--version'];
|
|
15422
|
+
// bin 이 실행파일 이름/경로가 아니면 **아무것도 실행하지 않는다** — 여기가 shell:true 명령 위치다.
|
|
15423
|
+
const _binOk = _binSafe(agent.bin);
|
|
15341
15424
|
try {
|
|
15342
|
-
|
|
15425
|
+
if (!_binOk) throw new Error('bin 형식 거부');
|
|
15426
|
+
const r = cp.spawnSync(agent.bin, _versionArgs, { encoding: 'utf8', timeout: 5000, shell: true });
|
|
15343
15427
|
if (r.status === 0 || (r.stdout && r.stdout.trim())) {
|
|
15344
15428
|
installed = true;
|
|
15345
15429
|
version = (r.stdout || r.stderr || '').trim().split('\n')[0].slice(0, 80);
|
|
@@ -15353,9 +15437,6 @@ function _checkAgent(agent, opts = {}) {
|
|
|
15353
15437
|
// 아무것도 증명하지 않는다. 무료·비대화형·부작용 없음이 확인된 authCheck 를 가진 provider 만 실제로 판정하고,
|
|
15354
15438
|
// 나머지는 'unknown' 으로 **남긴다**(모르는 것을 안다고 말하지 않는다). opts.auth 일 때만 실행 — 지연 비용이 있다.
|
|
15355
15439
|
let auth = 'unknown', authEvidence = null, authSource = null;
|
|
15356
|
-
// args 는 shell:true 로 실행되므로(윈도우 .cmd 심 해석에 필요) **셸 메타문자가 없어야** 한다.
|
|
15357
|
-
// 있으면 인용이 깨져 엉뚱한 실행이 되고(실측: 따옴표 포함 args 가 파싱 실패로 exit 1), 주입 표면도 된다.
|
|
15358
|
-
const _argsSafe = (a) => Array.isArray(a) && a.length > 0 && a.every(x => typeof x === 'string' && /^[A-Za-z0-9._:@/\\-]+$/.test(x));
|
|
15359
15440
|
if (installed && opts.auth && agent.authCheck && _argsSafe(agent.authCheck.args)) {
|
|
15360
15441
|
authSource = `${agent.bin} ${agent.authCheck.args.join(' ')}`;
|
|
15361
15442
|
try {
|
|
@@ -15391,6 +15472,14 @@ function _checkAgent(agent, opts = {}) {
|
|
|
15391
15472
|
id: agent.id, bin: agent.bin, desc: agent.desc, envFlag: agent.envFlag,
|
|
15392
15473
|
enabled, installed, version, error,
|
|
15393
15474
|
auth, authEvidence, authSource,
|
|
15475
|
+
// 확인 명령이 없는 사유를 구분해 전달한다 — "원래 없음"과 "override 로 물려받지 못함"은 다른 말이다(헌트 #21).
|
|
15476
|
+
authCheckDroppedReason: agent.authCheckDroppedReason || null,
|
|
15477
|
+
// 조용히 기본값으로 바꾸지 않는다 — 사용자가 적어 둔 값이 무시됐다는 사실을 말해야 고칠 수 있다.
|
|
15478
|
+
// 판정은 _allProviders(단일 병목)에서 내리고, 여기 _argsSafe/_binSafe 는 그 경로를 타지 않는 호출에 대한 2차 방어다.
|
|
15479
|
+
versionArgsRejected: agent.versionArgsRejectedReason
|
|
15480
|
+
|| (_vaSafe ? null : `versionArgs 에 셸 메타문자가 있어 실행하지 않았습니다(--version 으로 대체): ${JSON.stringify(_vaRaw)}`),
|
|
15481
|
+
binRejected: agent.binRejectedReason
|
|
15482
|
+
|| (_binOk ? null : `bin 이 실행파일 이름/경로가 아니어서 실행하지 않았습니다: ${String(agent.bin).slice(0, 80)}`),
|
|
15394
15483
|
// 인증까지 확인된 경우에만 'ready' 라고 부르지 않는다 — 기존 status 의미(설치+활성)는 그대로 두고,
|
|
15395
15484
|
// 인증은 별도 축으로 노출한다. 두 축을 한 단어에 섞으면 무엇이 검증됐는지 알 수 없어진다.
|
|
15396
15485
|
status: enabled && installed ? 'ready' : !installed ? 'not-installed' : !enabled ? 'disabled' : 'unknown'
|
|
@@ -23284,7 +23373,13 @@ function taskSyncCmd(root) {
|
|
|
23284
23373
|
}
|
|
23285
23374
|
} catch (e) {
|
|
23286
23375
|
if (!e || e.name !== 'BugfixBlocked') throw e;
|
|
23287
|
-
|
|
23376
|
+
// 1.36.94: `e.lines`(회복 절차)를 버리면 안 된다 — 종전 sync 는 사유만 담고 빠져나갈 길을 통째로 잃어,
|
|
23377
|
+
// "틀린 안내"가 "무안내"로 바뀐 회귀였다. task update 와 **같은 문자열**을 갖도록 여기서 조립한다.
|
|
23378
|
+
const _bh = require('../lib/bugfix').escapeHint(e.code, e.taskId, e.root);
|
|
23379
|
+
blocked.push({
|
|
23380
|
+
id: e.taskId, content: String(t.content).slice(0, 60), code: e.code, message: e.message,
|
|
23381
|
+
recovery: (e.lines || []).concat([_bh].filter(Boolean)),
|
|
23382
|
+
});
|
|
23288
23383
|
}
|
|
23289
23384
|
}
|
|
23290
23385
|
if (blocked.length) {
|
|
@@ -23295,7 +23390,8 @@ function taskSyncCmd(root) {
|
|
|
23295
23390
|
}
|
|
23296
23391
|
fail(`${blocked.length}건이 bugfix 완료 게이트에 걸려 done 으로 반영되지 않았습니다`);
|
|
23297
23392
|
blocked.forEach(b => log(` - ${b.id} ${b.content} — ${b.message}`));
|
|
23298
|
-
|
|
23393
|
+
// 같은 회복 문장이 여러 건에서 반복되지 않게 중복만 접는다(사유별로 다르면 각각 나온다).
|
|
23394
|
+
blocked.flatMap(b => b.recovery).filter((h, i, a) => h && a.indexOf(h) === i).forEach(h => log(` ${h}`));
|
|
23299
23395
|
process.exitCode = 1;
|
|
23300
23396
|
}
|
|
23301
23397
|
log(`# leerness task sync (1.9.38)`);
|
|
@@ -24662,7 +24758,8 @@ if (require.main === module) {
|
|
|
24662
24758
|
// 스택 트레이스 대신 탈출구를 보여준다(막힌 사용자가 빠져나갈 방법을 알아야 한다).
|
|
24663
24759
|
if (err && err.name === 'BugfixBlocked') {
|
|
24664
24760
|
(err.lines || []).forEach(l => log(' ' + l));
|
|
24665
|
-
|
|
24761
|
+
const _h = require('../lib/bugfix').escapeHint(err.code, err.taskId, err.root);
|
|
24762
|
+
if (_h) log(' ' + _h);
|
|
24666
24763
|
}
|
|
24667
24764
|
process.exit(1);
|
|
24668
24765
|
});
|
package/lib/agents.js
CHANGED
|
@@ -41,6 +41,12 @@ function agentsCmd(root, sub, args = [], deps = {}) {
|
|
|
41
41
|
const statusEmoji = c.status === 'ready' ? '🟢 ready' : c.status === 'not-installed' ? '⚪ 미설치' : c.status === 'disabled' ? '🟡 비활성' : '❓';
|
|
42
42
|
log(`| ${c.id} | ${c.source} | ${envMark} ${c.envFlag} | ${instMark} | ${c.version || '-'} | ${statusEmoji} |`);
|
|
43
43
|
}
|
|
44
|
+
// 1.36.94: 무시한 설정을 **평문에서도** 말한다 — JSON 에만 넣으면 사람은 왜 미설치로 보이는지 모른다.
|
|
45
|
+
const _rejected = checks.filter(c => c.binRejected || c.versionArgsRejected);
|
|
46
|
+
if (_rejected.length) {
|
|
47
|
+
log('');
|
|
48
|
+
_rejected.forEach(c => log(`⚠ ${c.id}: ${c.binRejected || c.versionArgsRejected}`));
|
|
49
|
+
}
|
|
44
50
|
const ready = checks.filter(c => c.status === 'ready');
|
|
45
51
|
log('');
|
|
46
52
|
log(`## 활성 (${ready.length}/${checks.length}): ${ready.map(c => c.id).join(', ') || '(없음)'}`);
|
|
@@ -95,7 +101,10 @@ function agentsCmd(root, sub, args = [], deps = {}) {
|
|
|
95
101
|
checks.filter(c => c.installed).forEach(c => {
|
|
96
102
|
const mark = c.auth === 'ok' ? '🟢 확인됨' : c.auth === 'no' ? '🔴 미인증' : '⚪ 확인안됨';
|
|
97
103
|
// codex 30차 #7: "확인 명령 없음"은 과장이다 — 없다고 증명한 게 아니라 **등록된 것이 없을** 뿐이다.
|
|
98
|
-
|
|
104
|
+
const _why = c.authSource ? `(${c.authSource})`
|
|
105
|
+
: c.authCheckDroppedReason ? `(⚠ ${c.authCheckDroppedReason})`
|
|
106
|
+
: '(등록된 확인 명령 없음 — 무료·비대화형·부작용 없음이 확인된 것만 등록합니다)';
|
|
107
|
+
log(` ${mark} ${c.id.padEnd(10)} ${_why}${c.authEvidence ? ' — ' + c.authEvidence : ''}`);
|
|
99
108
|
});
|
|
100
109
|
log(` ⓘ ${authNote}`);
|
|
101
110
|
return;
|
package/lib/bugfix.js
CHANGED
|
@@ -161,7 +161,7 @@ function _start(root, id, deps) {
|
|
|
161
161
|
if (!expectBad.trim()) { failJson(json, 'missing_expect', '--expect-bad "<기대 실패 신호>" 필요 — 없으면 무관한 실패와 진짜 재현을 구분할 수 없습니다'); return; }
|
|
162
162
|
|
|
163
163
|
const chk = _loadChecked(root);
|
|
164
|
-
if (chk.invalid) { failJson(json, 'store_invalid', `bugfix-receipts.json 형상 무효 — 덮어쓰기
|
|
164
|
+
if (chk.invalid) { failJson(json, 'store_invalid', `bugfix-receipts.json 형상 무효 — 덮어쓰기 거부\n · ${_recoverHint(root)}`); return; }
|
|
165
165
|
|
|
166
166
|
const r = _run(repro, absRoot(root));
|
|
167
167
|
const lf = _launcherFailure(r);
|
|
@@ -213,7 +213,7 @@ function _receipt(root, id, deps) {
|
|
|
213
213
|
const { arg, has } = deps;
|
|
214
214
|
const json = !!(has && has('--json'));
|
|
215
215
|
const chk = _loadChecked(root);
|
|
216
|
-
if (chk.invalid) { failJson(json, 'store_invalid', `bugfix-receipts.json 형상 무효 — 덮어쓰기
|
|
216
|
+
if (chk.invalid) { failJson(json, 'store_invalid', `bugfix-receipts.json 형상 무효 — 덮어쓰기 거부\n · ${_recoverHint(root)}`); return; }
|
|
217
217
|
const e = chk.list.find(x => x.id === String(id));
|
|
218
218
|
if (!e) { failJson(json, 'probe_not_found', `bugfix probe 없음: ${id} — 먼저 leerness bugfix start ${id} --repro "..." --expect-bad "..."`); return; }
|
|
219
219
|
const rc = (arg ? arg('--root-cause', '') : '') || '';
|
|
@@ -234,12 +234,50 @@ function _receipt(root, id, deps) {
|
|
|
234
234
|
log(` ⓘ 근본원인·형제범위는 **선언**입니다 — leerness 는 내용의 진위를 판정하지 않습니다(검증되는 것은 probe 뿐).`);
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
// 대상 프로젝트가 cwd 가 아니면 안내 명령에 `--path` 를 붙인다. 1.36.94: 안내가 root 를 잃어서,
|
|
238
|
+
// `--path B` 로 막힌 사용자가 문구를 그대로 복사하면 **cwd 인 애먼 프로젝트 A** 가 변형됐다(실측).
|
|
239
|
+
function _pathArg(root) {
|
|
240
|
+
try {
|
|
241
|
+
const abs = absRoot(root);
|
|
242
|
+
if (!abs || abs === process.cwd()) return '';
|
|
243
|
+
return / /.test(abs) ? ` --path "${abs}"` : ` --path ${abs}`;
|
|
244
|
+
} catch { return ''; }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 형상 무효 스토어에서 **동작하는** 회복 절차. 1.36.94: start/receipt/drop/완료 판정이 모두 이 상태를 거부하는데
|
|
248
|
+
// 어느 메시지도 빠져나가는 방법을 말하지 않아, 사용자에게 남는 선택지가 보호 해제뿐이었다. 한 곳에서 만들어
|
|
249
|
+
// 모든 발화점이 같은 문장을 쓴다(발화점마다 따로 쓰면 다음에 또 한 곳만 고치고 나머지가 남는다).
|
|
250
|
+
// **도구가 대신 지워주지 않는다**: 손으로 고치거나 옮기는 회복은 되돌릴 수 있고 게이트도 살아 있다
|
|
251
|
+
// (실측: 파일을 되돌리면 done 이 다시 보류된다). 도구가 파일을 치우는 편의 명령을 한 번 만들어 봤지만,
|
|
252
|
+
// 락 밖 rename·읽기 실패를 손상으로 오인·ID 무시라는 데이터 유실 경로를 함께 들여와서 걷어냈다.
|
|
253
|
+
function _recoverHint(root) {
|
|
254
|
+
const f = _storePath(root);
|
|
255
|
+
return `회복: ${f} 를 고치거나(병합 충돌 마커 제거 등) 다른 이름으로 옮기세요 — 옮기면 등록된 probe 가 없는 상태가 되어 완료 게이트는 무영향입니다(파일을 되돌리면 게이트도 되살아납니다)`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 차단 시 붙는 공통 "탈출구" 안내. 1.36.94: 이 문구가 세 호출부에 복붙돼 있었고, 전부 `bugfix drop <ID>` 를
|
|
259
|
+
// 권했다 — 그런데 store_invalid 에서는 그 명령이 같은 사유로 거부되므로 **안내 자체가 막다른 길**이었고,
|
|
260
|
+
// 남는 실행 가능한 선택지가 "보호를 끄기" 하나였다. 사유별 판단을 여기 한 곳에 두고 호출부는 렌더만 한다
|
|
261
|
+
// (같은 술어를 표면마다 다시 구현하면 한 곳만 고치고 나머지가 되살아난다 — 1.36.90 에서 겪은 실패).
|
|
262
|
+
function escapeHint(code, id, root) {
|
|
263
|
+
// store_invalid 는 사유 자체가 회복 절차를 담고 있다(checkDoneTransition.lines) — 동작하지 않는 명령을
|
|
264
|
+
// 덧붙이면 사용자를 실패로 유도하고, 보호 해제를 유일한 출구처럼 보이게 만든다.
|
|
265
|
+
if (code === 'store_invalid') return null;
|
|
266
|
+
const p = _pathArg(root);
|
|
267
|
+
return `ⓘ 탈출구: leerness bugfix drop ${id || '<ID>'}${p} (probe 등록 취소) · leerness toggle set bugfix-receipt off${p} (프로젝트 정책)`;
|
|
268
|
+
}
|
|
269
|
+
|
|
237
270
|
// `--status done` 전이에서 호출. 토글 OFF 이거나 probe 미등록이면 **아무 영향 없음**(기존 동작 보존).
|
|
238
271
|
// 반환: { blocked, code, message, lines } — 호출부가 렌더/exit 를 결정한다.
|
|
239
272
|
function checkDoneTransition(root, id) {
|
|
240
273
|
const chk = _loadChecked(root);
|
|
241
274
|
if (chk.invalid) {
|
|
242
|
-
|
|
275
|
+
// 회복 경로를 **실제로 동작하는 것**으로 안내한다 — 종전엔 `bugfix drop` 을 권했는데 그 상태에서 반드시 실패했다.
|
|
276
|
+
return {
|
|
277
|
+
blocked: true, code: 'store_invalid',
|
|
278
|
+
message: `bugfix-receipts.json 형상 무효 — 완료 판정을 보류합니다(어느 task 에 probe 가 걸려 있었는지 알 수 없기 때문): ${_storePath(root)}`,
|
|
279
|
+
lines: [_recoverHint(root)],
|
|
280
|
+
};
|
|
243
281
|
}
|
|
244
282
|
const e = chk.list.find(x => x.id === String(id));
|
|
245
283
|
if (!e) return { blocked: false }; // bugfix 로 선언되지 않은 task 는 무영향
|
|
@@ -291,12 +329,15 @@ function checkDoneTransition(root, id) {
|
|
|
291
329
|
// 그래서 판정은 progress 행을 쓰는 **단일 병목**에서 하고, 막힐 때는 조용히 무시할 수 없도록 던진다.
|
|
292
330
|
// (반환값 무시로 인한 무언 통과가 이 클래스의 재발 양식이다.)
|
|
293
331
|
class BugfixBlocked extends Error {
|
|
294
|
-
|
|
332
|
+
// 1.36.94: root 를 함께 싣는다 — 안내 문구가 `--path` 를 잃으면 `--path B` 로 막힌 사용자가
|
|
333
|
+
// 그 문구를 그대로 복사해 **cwd 인 다른 프로젝트**를 건드린다(실측으로 확인된 파괴 경로).
|
|
334
|
+
constructor(res, id, root) {
|
|
295
335
|
super((res && res.message) || 'bugfix 완료 게이트에 의해 보류');
|
|
296
336
|
this.name = 'BugfixBlocked';
|
|
297
337
|
this.taskId = String(id);
|
|
298
338
|
this.code = (res && res.code) || 'blocked';
|
|
299
339
|
this.lines = (res && res.lines) || [];
|
|
340
|
+
this.root = root || null;
|
|
300
341
|
}
|
|
301
342
|
}
|
|
302
343
|
|
|
@@ -312,7 +353,20 @@ function bugfixCmd(root, sub, rest, deps = {}) {
|
|
|
312
353
|
// done 이 영구히 막히고(등록은 "지금 실패"를 요구하므로 재등록도 불가) 스토어도 무한히 자란다.
|
|
313
354
|
if (sub === 'drop') {
|
|
314
355
|
const chk = _loadChecked(root);
|
|
315
|
-
|
|
356
|
+
// 1.36.94 (헌트 #7/#10): 형상 무효 상태에서 **차단 메시지가 안내한 탈출구가 바로 그 사유로 거부**됐다 —
|
|
357
|
+
// 등록·해제·완료가 모두 잠기고, 남는 길은 보호를 통째로 끄는 것뿐이었다(제품이 사용자를 그쪽으로 몰았다).
|
|
358
|
+
// .harness 는 git 추적 대상이라 병합 충돌은 현실적인 손상 경로다.
|
|
359
|
+
// **도구가 파일을 치우는 --force 를 만들었다가 걷어냈다.** 검수에서 그 한 명령이 데이터 유실 경로 5개를
|
|
360
|
+
// 들여온 것이 실측으로 확인됐다: 락 밖 load→rename(동시 writer 가 쓴 정상 스토어를 "손상 원본"이라며 치움 —
|
|
361
|
+
// 자연 경합 실측 19.5%), 읽기 실패(EACCES/EIO)를 손상으로 오인해 유효 데이터 파괴, <ID> 를 무시하는 전역 삭제,
|
|
362
|
+
// 되돌릴 수 없는 게이트 무장해제, 안내가 --path 를 잃어 애먼 프로젝트 파괴.
|
|
363
|
+
// 손으로 고치거나 옮기는 회복은 이 다섯 가지가 전부 없고, 되돌리면 게이트도 되살아난다(실측).
|
|
364
|
+
// 그러니 도구가 할 일은 대신 지워 주는 게 아니라 **어디를 어떻게 고치면 되는지 말해 주는 것**이다.
|
|
365
|
+
if (chk.invalid) {
|
|
366
|
+
failJson(json, 'store_invalid', `bugfix-receipts.json 형상 무효 — 개별 삭제는 할 수 없습니다(어느 항목이 어느 task 것인지 읽을 수 없습니다)\n`
|
|
367
|
+
+ ` · ${_recoverHint(root)}`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
316
370
|
const left = _remove(root, id, deps);
|
|
317
371
|
if (left === null) { failJson(json, 'probe_not_found', `bugfix probe 없음: ${id}`); return; }
|
|
318
372
|
if (json) { log(JSON.stringify({ ok: true, id: String(id), remaining: left.length }, null, 2)); return; }
|
|
@@ -321,8 +375,16 @@ function bugfixCmd(root, sub, rest, deps = {}) {
|
|
|
321
375
|
}
|
|
322
376
|
if (sub === 'list' || !sub) {
|
|
323
377
|
const chk = _loadChecked(root);
|
|
324
|
-
|
|
325
|
-
if (
|
|
378
|
+
// 1.36.94: --json 도 회복 절차를 받는다 — 평문에만 넣으면 자동화 소비자는 사유만 받고 빠져나갈 길을 못 받는다.
|
|
379
|
+
if (json) {
|
|
380
|
+
log(JSON.stringify({
|
|
381
|
+
ok: !chk.invalid, invalid: chk.invalid,
|
|
382
|
+
...(chk.invalid ? { recovery: [_recoverHint(root)] } : {}),
|
|
383
|
+
receipts: chk.list.map(e => ({ id: e.id, repro: e.repro, baselineFailed: !!(e.baseline && e.baseline.failed), rootCause: e.rootCause, siblingScope: e.siblingScope })),
|
|
384
|
+
}, null, 2));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (chk.invalid) { warn(`bugfix-receipts.json 형상 무효: ${_storePath(root)}`); log(` · ${_recoverHint(root)}`); return; }
|
|
326
388
|
if (!chk.list.length) { log('등록된 bugfix probe 없음 — leerness bugfix start <T-ID> --repro "..." --expect-bad "..."'); return; }
|
|
327
389
|
// 1.36.88 (출하전 헌트 #12): 목록과 게이트가 **같은 술어**를 써야 한다 — 빈 checked 를 목록은 "기록됨",
|
|
328
390
|
// 게이트는 "영수증 미완"으로 판정해, 사용자가 목록을 보고 통과할 줄 알았다가 막혔다.
|
|
@@ -334,4 +396,4 @@ function bugfixCmd(root, sub, rest, deps = {}) {
|
|
|
334
396
|
|
|
335
397
|
// _siblingScopeOk 공개(1.36.90): 대시보드가 **같은 술어**를 쓴다 — 표면마다 다시 구현하면
|
|
336
398
|
// "목록은 기록됨, 게이트는 미완" 같은 불일치가 되살아난다(1.36.88 #12 에서 이미 한 번 겪었다).
|
|
337
|
-
module.exports = { bugfixCmd, checkDoneTransition, BugfixBlocked, _storePath, _loadChecked, _siblingScopeOk };
|
|
399
|
+
module.exports = { bugfixCmd, checkDoneTransition, BugfixBlocked, escapeHint, _storePath, _loadChecked, _siblingScopeOk };
|
package/lib/pure-utils.js
CHANGED
|
@@ -859,9 +859,15 @@ function _managedMerge(file, next, previous, archiveRel, overwriteSet, opts = {}
|
|
|
859
859
|
if (!l || nextLines.has(l)) continue;
|
|
860
860
|
const _ac = altCounts.get(l);
|
|
861
861
|
if (_ac > 0) { altCounts.set(l, _ac - 1); continue; } // canonical 출현분만 소비 (복제 커스텀 생존)
|
|
862
|
-
// 1.36.60 (
|
|
863
|
-
//
|
|
864
|
-
|
|
862
|
+
// 1.36.60 의 자동생성 이력 필터(`- .*MCP **N 도구` · `- MCP **N` · `- 🏆 마일스톤` · `- 🎉 **N 라운드`)는
|
|
863
|
+
// 1.36.95 에서 **삭제했다**. "사용자 산문이 이 패턴과 겹칠 확률은 무시 가능"이라고 적혀 있었지만
|
|
864
|
+
// 검수가 겹치는 실제 문장으로 삭제를 재현했다:
|
|
865
|
+
// `- 프로젝트 정책: MCP **3 도구는 별도 승인 후 사용한다` · `- 🏆 마일스톤은 고객 승인 뒤에만 확정한다`
|
|
866
|
+
// 이번 라운드가 템플릿에서 이력 43줄을 지우면서 그 줄들이 처음으로 이 경로를 타게 됐고,
|
|
867
|
+
// 실측에서 설치본 21개의 이력 731줄 중 **346줄이 삭제**됐으며 사용자 문장
|
|
868
|
+
// `- 사용자 메모: MCP **77 도구** 는 우리 내부 표현이며 삭제 금지` 도 함께 사라졌다.
|
|
869
|
+
// 이 릴리스는 "삭제하지 않는 변경만" 담기로 했으므로 필터를 걷어낸다 — 이력 줄은 Preserved 로 이월된다.
|
|
870
|
+
// 과보존은 이 파일의 계약상 무해하고(false-DROP 이 버그), 기존 설치본 정리는 provenance 라운드로 미룬다.
|
|
865
871
|
// preserved 래퍼 자체의 보일러플레이트는 이월 대상 아님 (구 <details> 형식 + 신 평문 형식 + en 래퍼 모두)
|
|
866
872
|
if (l === tag || l === '---' || l === '<details>' || l === '</details>') continue;
|
|
867
873
|
if (/^```/.test(l) || /^<summary>/.test(l)) continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.95",
|
|
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
|
@@ -8169,6 +8169,421 @@ total++;
|
|
|
8169
8169
|
if (!ok) failed++;
|
|
8170
8170
|
}
|
|
8171
8171
|
|
|
8172
|
+
// 1.36.95 (P-0009 A · 부채 상환 — 범위 축소): AI 가 매 세션 읽도록 지시받는 파일의 절반이 낡은 릴리스 이력이었다.
|
|
8173
|
+
// 한글 session-workflow 템플릿 10,308자 중 5,216자(50.6%)가 MCP 도구 수를 18→42 로 세는 로그였고
|
|
8174
|
+
// 실제 도구 수는 89 다. 영문 템플릿은 이미 깨끗했다(표면 불일치).
|
|
8175
|
+
// **기존 설치본의 이력까지 지우는 기능은 이 릴리스에서 뺐다.** 정확 일치 목록으로 시도했지만
|
|
8176
|
+
// 검수가 9회에 걸쳐 사용자 데이터 삭제/변형 반례를 계속 찾아냈다 — 문자열로는 "우리가 내보낸 줄"과
|
|
8177
|
+
// "사용자가 그 줄을 적은 것"을 구분할 수 없기 때문이다(바이트가 같다). 근본 해결은 생성 영역 마커
|
|
8178
|
+
// (provenance)이고 별도 라운드로 다룬다. 여기서는 **삭제하지 않는 변경만** 출하한다.
|
|
8179
|
+
total++;
|
|
8180
|
+
{
|
|
8181
|
+
let ok = false;
|
|
8182
|
+
const _d = [];
|
|
8183
|
+
let T1 = false, T2 = false, T3 = false;
|
|
8184
|
+
const dbg = {};
|
|
8185
|
+
try {
|
|
8186
|
+
const R = (a) => { const r = cp.spawnSync(process.execPath, [CLI, ...a], { encoding: 'utf8', timeout: 180000 }); return { s: r.status, o: ((r.stdout || '') + (r.stderr || '')).trim() }; };
|
|
8187
|
+
const mkTpl = (lang) => {
|
|
8188
|
+
const w = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-tpl95-')); _d.push(w);
|
|
8189
|
+
R(['init', w, '--yes', '--language', lang, '--no-stale-check']);
|
|
8190
|
+
return fs.readFileSync(path.join(w, '.harness', 'session-workflow.md'), 'utf8');
|
|
8191
|
+
};
|
|
8192
|
+
const ko = mkTpl('ko'), en = mkTpl('en');
|
|
8193
|
+
|
|
8194
|
+
// ── T1: 출하 템플릿에 낡은 릴리스 이력이 없다(양쪽 언어).
|
|
8195
|
+
// 개수를 고쳐 적는 것은 다음 릴리스에 또 낡는다 — **수치 주장 자체가 0** 인지 단언한다.
|
|
8196
|
+
// `\b` 를 한글 뒤에 붙이면 절대 매치되지 않으므로 쓰지 않는다(초안이 그래서 아무것도 세지 않았다).
|
|
8197
|
+
// 조사/단위(`23개 도구`)가 끼어도 세야 한다.
|
|
8198
|
+
// 영문은 `Tools` 처럼 대문자로도 쓴다 — 대소문자를 구분하면 `**99 Tools**` 변이가 그대로 통과한다(검수 실측).
|
|
8199
|
+
const staleCount = (s) => (s.match(/\d+\s*(?:개|여|여개)?\s*(?:도구|tools)/gi) || []).length;
|
|
8200
|
+
dbg.stale = staleCount(ko) + '/' + staleCount(en);
|
|
8201
|
+
dbg.size = ko.length + '/' + en.length;
|
|
8202
|
+
T1 = staleCount(ko) === 0 && staleCount(en) === 0
|
|
8203
|
+
&& !/MCP server/.test(ko) && !/마일스톤/.test(ko)
|
|
8204
|
+
// 본문 계약은 그대로 — 지운 것은 이력뿐이다(대조: 6단계 구조와 운영 사실이 살아 있어야 한다)
|
|
8205
|
+
&& /## Step 6/.test(ko) && /## 자동 회복/.test(ko) && /session close가 누락되면/.test(ko)
|
|
8206
|
+
&& /## Step 6/.test(en) && /Auto-recovery/.test(en)
|
|
8207
|
+
// 두 언어의 크기가 비슷해야 한다 — 한쪽만 이력을 이고 있던 상태가 재발하면 여기서 걸린다
|
|
8208
|
+
&& Math.abs(ko.length - en.length) < 1500;
|
|
8209
|
+
|
|
8210
|
+
// ── T3: 이 릴리스는 **삭제하지 않는다**. 템플릿에서 이력을 지우면 그 줄들이 처음으로 머지 경로를 타게 되고,
|
|
8211
|
+
// 1.36.60 이 넣어 둔 자동생성 이력 필터가 그때 발화해 **간접적으로** 삭제가 일어났다(검수 실측:
|
|
8212
|
+
// 설치본 21개의 이력 731줄 중 346줄 삭제 + 사용자 문장 유실). 그 필터를 걷어냈고, 여기서 못 박는다.
|
|
8213
|
+
const PU = require(path.join(path.dirname(CLI), '..', 'lib', 'pure-utils.js'));
|
|
8214
|
+
const MG = (prev) => PU._managedMerge('.harness/session-workflow.md', ko, prev, '.harness/archive', new Set(), { lang: 'ko' });
|
|
8215
|
+
// (a) 옛 필터의 시그니처와 겹치는 **사용자 문장** — 필터가 살아 있으면 삭제된다
|
|
8216
|
+
const userProse = [
|
|
8217
|
+
'- 사용자 메모: MCP **77 도구** 는 우리 내부 표현이며 삭제 금지',
|
|
8218
|
+
'- 프로젝트 정책: MCP **3 도구는 별도 승인 후 사용한다',
|
|
8219
|
+
'- 🏆 마일스톤은 고객 승인 뒤에만 확정한다',
|
|
8220
|
+
'- 🎉 **2 라운드 인터뷰 후 가격을 결정한다',
|
|
8221
|
+
'- MCP **7명의 담당자만 운영 배포를 승인한다',
|
|
8222
|
+
];
|
|
8223
|
+
const mProse = MG(userProse.join('\n') + '\n');
|
|
8224
|
+
const proseOk = userProse.every(s => mProse.includes(s));
|
|
8225
|
+
// (b) 우리 이력 줄도 **삭제가 아니라 이월**이어야 한다(과보존이 이 파일의 계약이다)
|
|
8226
|
+
const hist = ['- 1.9.86+ MCP server **18 도구** (handoff/drift)', '- 1.9.100 🏆 마일스톤 — 30 라운드 자율 누적'];
|
|
8227
|
+
const mHist = MG(hist.join('\n') + '\n');
|
|
8228
|
+
const carryOk = hist.every(s => mHist.includes(s)) && /Preserved previous content/.test(mHist);
|
|
8229
|
+
// (c) 멱등
|
|
8230
|
+
const idemOk = MG(mHist) === MG(MG(mHist));
|
|
8231
|
+
// (d) **새 템플릿이 실제로 적용**돼야 한다 — 위 셋만 보면 "이전 파일을 그대로 두고 Preserved 헤더만
|
|
8232
|
+
// 덧붙이는" 머지도 통과한다(검수가 그 변이를 살렸다). 그러면 기존 설치본이 낡은 이력을 계속
|
|
8233
|
+
// **활성 본문**으로 읽는데 CI 는 초록이다. 관리 영역이 새 템플릿과 같은지, 옛 줄은 태그 뒤에 있는지 본다.
|
|
8234
|
+
const _tag = '<!-- leerness:migration-preserved -->';
|
|
8235
|
+
const _cut = mHist.indexOf(_tag);
|
|
8236
|
+
// 태그 앞에는 구분선(`---`)이 붙는다 — 그대로 비교하면 정상 코드에서도 실패한다(실제로 겪었다).
|
|
8237
|
+
const appliedOk = _cut >= 0
|
|
8238
|
+
&& mHist.slice(0, _cut).replace(/\n*-{3,}\n*$/, '').trim() === ko.trim()
|
|
8239
|
+
&& hist.every(s => mHist.slice(_cut).includes(s));
|
|
8240
|
+
dbg.nodel = `산문 ${proseOk} 이월 ${carryOk} 멱등 ${idemOk} 템플릿적용 ${appliedOk}`;
|
|
8241
|
+
T3 = proseOk && carryOk && idemOk && appliedOk;
|
|
8242
|
+
|
|
8243
|
+
// ── T2: handoff 의 `last N lines` 라벨이 동작과 일치한다.
|
|
8244
|
+
// 종전엔 파일 전체를 넣고 **앞** 4000자를 잘랐다(단위도 방향도 반대).
|
|
8245
|
+
// 수정 과정에서 세 번의 회귀가 있었고(헤더 잡아먹음 · O(n²) · 유니코드 파손) 전부 여기서 잡는다.
|
|
8246
|
+
const w5 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-hd95-')); _d.push(w5);
|
|
8247
|
+
R(['init', w5, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
8248
|
+
const decPath = path.join(w5, '.harness', 'decisions.md');
|
|
8249
|
+
const mkDec = (n, pad) => '# Decisions\n' + Array.from({ length: n }, (_, i) => `- 결정 ${String(i + 1).padStart(3, '0')} ` + 'x'.repeat(pad)).join('\n') + '\n';
|
|
8250
|
+
const seg = (o) => o.slice(o.indexOf('=== Decisions'), o.indexOf('=== Task Log') > 0 ? o.indexOf('=== Task Log') : undefined);
|
|
8251
|
+
// (a) 마지막 40줄이 **끊김 없이** 나온다 — 양 끝만 보면 중간 유실을 놓친다.
|
|
8252
|
+
fs.writeFileSync(decPath, mkDec(120, 4));
|
|
8253
|
+
const sA = seg(R(['handoff', w5]).o);
|
|
8254
|
+
const idsA = (sA.match(/- 결정 (\d{3})/g) || []).map(x => Number(x.slice(-3)));
|
|
8255
|
+
const contiguous = idsA.length === 40 && idsA[0] === 81 && idsA[39] === 120 && idsA.every((v, i) => i === 0 || v === idsA[i - 1] + 1);
|
|
8256
|
+
// (b) 상한이 걸려도 **헤더는 잘리지 않고** 라벨의 표시 줄 수가 실제와 같아야 한다.
|
|
8257
|
+
// 표시 줄 수만 맞추면 "모든 줄 길이를 1로 계산" 같은 변이가 산다 — 렌더된 본문의 실제 문자 수를 잰다.
|
|
8258
|
+
fs.writeFileSync(decPath, mkDec(120, 150));
|
|
8259
|
+
const sB = seg(R(['handoff', w5]).o);
|
|
8260
|
+
const mB = sB.match(/(\d+)\/(\d+) 줄/);
|
|
8261
|
+
const cntB = (sB.match(/- 결정 \d{3}/g) || []).length;
|
|
8262
|
+
const bodyB = sB.slice(sB.indexOf('===\n') + 4).replace(/\n=== [\s\S]*$/, '').trim();
|
|
8263
|
+
const capOk = /=== Decisions \(last 40 lines\)/.test(sB) && !!mB && Number(mB[1]) === cntB && cntB > 0 && /결정 120/.test(sB)
|
|
8264
|
+
&& bodyB.length <= 4000 && bodyB.length > 3000;
|
|
8265
|
+
// (c) 정확히 상한인 본문을 초과로 오판하면 안 된다 — 마지막 줄에도 개행을 세면 절반을 버린다.
|
|
8266
|
+
// 합이 **정확히 4,000자**여야 판별된다(3,999 면 버그 구현도 통과). 2000 + 구분자 1 + 1999.
|
|
8267
|
+
fs.writeFileSync(decPath, 'z'.repeat(2000) + '\n' + 'z'.repeat(1999));
|
|
8268
|
+
const exactSeg = seg(R(['handoff', w5]).o);
|
|
8269
|
+
const exactOk = /2\/2 줄/.test(exactSeg) && !/상한으로 잘림/.test(exactSeg);
|
|
8270
|
+
// (d) 한 줄이 상한을 넘으면 **코드포인트 경계**에서 잘라야 한다. 경계가 이모지 안쪽에 오도록 구성한다.
|
|
8271
|
+
fs.writeFileSync(decPath, '# D\n- ' + 'y'.repeat(300) + '😀' + 'x'.repeat(3999) + '\n');
|
|
8272
|
+
const oU = R(['handoff', w5]).o;
|
|
8273
|
+
const uniOk = !/�/.test(oU) && !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(oU);
|
|
8274
|
+
// (e) Task Log 도 같은 계약(Decisions 만 보면 tailLines 누락 변이가 산다). 총 줄 수는 헤더 포함 201.
|
|
8275
|
+
fs.writeFileSync(path.join(w5, '.harness', 'task-log.md'), '# Task Log\n' + Array.from({ length: 200 }, (_, i) => `- 작업 ${String(i + 1).padStart(3, '0')}`).join('\n') + '\n');
|
|
8276
|
+
const oT = R(['handoff', w5]).o;
|
|
8277
|
+
const sT = oT.slice(oT.indexOf('=== Task Log'));
|
|
8278
|
+
// **실제로 60줄인지** 세야 한다 — 라벨만 보면 tailLines 를 59 로 바꾼 변이가 통과한다(검수 실측).
|
|
8279
|
+
const taskIds = (sT.match(/- 작업 (\d{3})/g) || []).map(x => Number(x.slice(-3)));
|
|
8280
|
+
const taskOk = taskIds.length === 60 && taskIds[0] === 141 && taskIds[59] === 200
|
|
8281
|
+
&& taskIds.every((v, i) => i === 0 || v === taskIds[i - 1] + 1) && /60\/201 줄/.test(sT);
|
|
8282
|
+
// (f) 빈 파일을 `1/1 줄` 이라고 말하면 안 된다(`''.split('\n')` 가 `['']` 이다)
|
|
8283
|
+
fs.writeFileSync(decPath, '');
|
|
8284
|
+
fs.writeFileSync(path.join(w5, '.harness', 'task-log.md'), '');
|
|
8285
|
+
const emptyOk = !/1\/1 줄/.test(R(['handoff', w5]).o);
|
|
8286
|
+
// (g) --json 은 누적 로그의 **뒤를** 남긴다(같은 명령이 표면마다 반대 방향이면 안 된다).
|
|
8287
|
+
// totalChars 는 원문 길이와 정확히 같아야 하고, content 는 마커 포함 8,000자를 넘지 않아야 한다.
|
|
8288
|
+
fs.writeFileSync(decPath, mkDec(600, 40));
|
|
8289
|
+
const decRaw = fs.readFileSync(decPath, 'utf8');
|
|
8290
|
+
let jd = null;
|
|
8291
|
+
try { const t = R(['handoff', w5, '--json']).o; jd = JSON.parse(t.slice(t.indexOf('{'))).files.decisions; } catch (e) { dbg.json = String(e && e.message).slice(0, 40); }
|
|
8292
|
+
const jsonOk = !!jd && /결정 600/.test(jd.content) && !/결정 001/.test(jd.content)
|
|
8293
|
+
&& jd.truncated === true && jd.kept === 'tail' && jd.totalChars === decRaw.length && jd.content.length <= 8000;
|
|
8294
|
+
// (g2) --json 절단도 **코드포인트 경계**여야 한다. 예산은 마커를 포함하므로 8000 이 아니다 —
|
|
8295
|
+
// 상수를 박으면 경계가 이모지를 비껴가 변이가 산다(실제로 겪었다).
|
|
8296
|
+
// `head + 😀 + 'x'×(예산-1)` 이면 경계 = len-예산 = head.length+1 = 낮은 서로게이트로 자명하다.
|
|
8297
|
+
const _jbud = 8000 - '\n…(truncated)'.length;
|
|
8298
|
+
fs.writeFileSync(decPath, '# D\n' + 'y'.repeat(400) + '😀' + 'x'.repeat(_jbud - 1));
|
|
8299
|
+
let jd2 = null;
|
|
8300
|
+
try { const t = R(['handoff', w5, '--json']).o; jd2 = JSON.parse(t.slice(t.indexOf('{'))).files.decisions; } catch (e) { dbg.json2 = String(e && e.message).slice(0, 30); }
|
|
8301
|
+
const jsonUniOk = !!jd2 && !/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(jd2.content) && !/�/.test(jd2.content);
|
|
8302
|
+
// (h) non-tail 파일(plan)은 **앞을** 남겨야 한다 — 방향을 뒤집어도 decisions 만 보면 통과한다.
|
|
8303
|
+
fs.writeFileSync(path.join(w5, '.harness', 'plan.md'), '# Plan\nFIRST_MARKER\n' + 'p'.repeat(9000) + '\nLAST_MARKER\n');
|
|
8304
|
+
let jp = null;
|
|
8305
|
+
try { const t = R(['handoff', w5, '--json']).o; jp = JSON.parse(t.slice(t.indexOf('{'))).files.plan; } catch (e) { dbg.plan = String(e && e.message).slice(0, 30); }
|
|
8306
|
+
// head 방향에도 **같은 계약**이 걸린다 — totalChars 정확성과 마커 예산을 tail 에서만 재면
|
|
8307
|
+
// non-tail 쪽 변이(totalChars 0 · 마커 예산 미적용으로 8,013자)가 그대로 통과한다(검수 실측).
|
|
8308
|
+
const planRaw = fs.readFileSync(path.join(w5, '.harness', 'plan.md'), 'utf8');
|
|
8309
|
+
const jsonHeadOk = !!jp && jp.kept === 'head' && /FIRST_MARKER/.test(jp.content) && !/LAST_MARKER/.test(jp.content)
|
|
8310
|
+
&& jp.totalChars === planRaw.length && jp.content.length <= 8000 && jp.truncated === true;
|
|
8311
|
+
// (i) 본문 상한이 **선형**이어야 한다 — 줄마다 join() 하는 구현은 O(n²) 라 48,000줄에서 30초대였다.
|
|
8312
|
+
// 임계값 20초는 정상(수 초)과 회귀(30초대) 사이에 넉넉히 있어 느린 머신에서도 오탐하지 않는다.
|
|
8313
|
+
fs.writeFileSync(path.join(w5, '.harness', 'progress-tracker.md'),
|
|
8314
|
+
'# P\n' + Array.from({ length: 48000 }, (_, i) => `| T-${String(i).padStart(5, '0')} | done | ` + 'x'.repeat(80) + ' |').join('\n') + '\n');
|
|
8315
|
+
const _t0 = Date.now();
|
|
8316
|
+
R(['handoff', w5]);
|
|
8317
|
+
dbg.perf = Date.now() - _t0;
|
|
8318
|
+
const perfOk = dbg.perf < 20000;
|
|
8319
|
+
dbg.hd = `연속=${contiguous} 상한=${capOk}(${mB ? mB[0] : '-'} 본문 ${bodyB.length}자) 정확상한=${exactOk} 유니코드=${uniOk}/${jsonUniOk} 작업로그=${taskOk} 빈파일=${emptyOk} json=${jsonOk} head방향=${jsonHeadOk} 성능=${perfOk}(${dbg.perf}ms)`;
|
|
8320
|
+
T2 = contiguous && capOk && exactOk && uniOk && jsonUniOk && taskOk && emptyOk && jsonOk && jsonHeadOk && perfOk;
|
|
8321
|
+
ok = T1 && T2 && T3;
|
|
8322
|
+
if (!ok) console.log(` [P9A 디버그] 템플릿=${T1}(낡은수치 ${dbg.stale} 크기 ${dbg.size}) 무삭제=${T3}(${dbg.nodel}) handoff=${T2}(${dbg.hd})`);
|
|
8323
|
+
} catch (e) { console.log(' [P9A 디버그] 예외: ' + ((e && e.message) || e)); }
|
|
8324
|
+
finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
|
|
8325
|
+
console.log(ok ? '✓ B(1.36.95) 부채 상환: 템플릿 낡은 이력 0(ko/en · 수치 주장 자체가 0) · 머지 삭제 0(옛 이력 필터 제거 — 사용자 산문 보존 · 이력은 이월) · handoff last-N 라벨=동작(헤더 불가침 · 줄 경계 · 정확 상한 · 코드포인트 · Task Log · 빈 파일 · --json tail/head 방향 · totalChars · 선형 성능)' : '✗ 부채 상환(1.36.95) 실패');
|
|
8326
|
+
if (!ok) failed++;
|
|
8327
|
+
}
|
|
8328
|
+
|
|
8329
|
+
// 1.36.94 (헌트 이월 2건 + 자체 검수 반영): ① 형상 무효 스토어에서 **차단 메시지가 안내한 탈출구가
|
|
8330
|
+
// 같은 사유로 거부**돼, 남는 실행 가능한 선택지가 "보호를 끄기" 하나였다.
|
|
8331
|
+
// ② `provider add codex --bin codex` 라는 사실상 무변경 재등록이 빌트인 authCheck 를 지워 인증 축을
|
|
8332
|
+
// unknown 으로 만들고, 고위험 확정의 유일한 인증 차단이 조용히 꺼졌다(실측: exit 0 · confirmed=true).
|
|
8333
|
+
// ①의 1차 수정으로 `drop --force`(손상 파일을 도구가 치움)를 넣었다가 **걷어냈다** — 검수에서 그 한 명령이
|
|
8334
|
+
// 데이터 유실 경로 5개를 들여온 것이 실행으로 확인됐다(락 밖 rename 으로 동시 writer 의 정상 스토어 파괴
|
|
8335
|
+
// 자연 경합 19.5% · 읽기 실패를 손상으로 오인 · <ID> 무시 전역 삭제 · 되돌릴 수 없는 게이트 무장해제 ·
|
|
8336
|
+
// 안내가 --path 를 잃어 애먼 프로젝트 파괴). 손으로 고치는 회복은 그 다섯이 전부 없고 되돌리면 게이트도
|
|
8337
|
+
// 살아난다 — 그래서 도구가 할 일은 대신 지워 주는 게 아니라 **어디를 어떻게 고치면 되는지 말하는 것**이다.
|
|
8338
|
+
total++;
|
|
8339
|
+
{
|
|
8340
|
+
let ok = false;
|
|
8341
|
+
const _d = [];
|
|
8342
|
+
let E1 = false, E2 = false, E3 = false, E4 = false, E5 = false;
|
|
8343
|
+
const dbg = {};
|
|
8344
|
+
try {
|
|
8345
|
+
const isWin = process.platform === 'win32';
|
|
8346
|
+
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-sh94-')); _d.push(shimDir);
|
|
8347
|
+
const pwned = path.join(shimDir, 'PWNED.txt');
|
|
8348
|
+
const metaFile = path.join(shimDir, 'META.txt');
|
|
8349
|
+
// 심은 **판별형**이어야 한다: 모르는 하위명령에는 로그아웃 문구가 아니라 usage 오류를 낸다.
|
|
8350
|
+
// 그러지 않으면 상속 args 를 엉뚱한 값으로 바꾸는 변이가 같은 출력을 내며 통과한다(검수 지적).
|
|
8351
|
+
const codexBody = isWin
|
|
8352
|
+
? '@echo off\r\nif "%1"=="--version" (echo codex-shim 1.0.0& exit /b 0)\r\nif "%1"=="login" (if "%2"=="status" (echo You are not logged in. Run codex login.& exit /b 1))\r\necho usage: codex [--version^|login status]\r\nexit /b 2\r\n'
|
|
8353
|
+
: '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "codex-shim 1.0.0"; exit 0; fi\nif [ "$1" = "login" ] && [ "$2" = "status" ]; then echo "You are not logged in. Run codex login."; exit 1; fi\necho "usage: codex [--version|login status]"\nexit 2\n';
|
|
8354
|
+
if (isWin) {
|
|
8355
|
+
fs.writeFileSync(path.join(shimDir, 'codex.cmd'), codexBody);
|
|
8356
|
+
fs.writeFileSync(path.join(shimDir, 'claude.cmd'), '@echo off\r\nif "%1"=="--version" (echo claude-shim 1.0.0& exit /b 0)\r\necho ok\r\nexit /b 0\r\n');
|
|
8357
|
+
fs.writeFileSync(path.join(shimDir, 'evil.cmd'), '@echo off\r\nif "%1"=="--version" (echo evil-shim 1.0.0& exit /b 0)\r\n>"' + pwned + '" echo ran\r\nexit /b 0\r\n');
|
|
8358
|
+
} else {
|
|
8359
|
+
const wsh = (n, body) => { const p = path.join(shimDir, n); fs.writeFileSync(p, body); try { fs.chmodSync(p, 0o755); } catch {} };
|
|
8360
|
+
wsh('codex', codexBody);
|
|
8361
|
+
wsh('claude', '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "claude-shim 1.0.0"; exit 0; fi\necho ok\nexit 0\n');
|
|
8362
|
+
wsh('evil', '#!/bin/sh\nif [ "$1" = "--version" ]; then echo "evil-shim 1.0.0"; exit 0; fi\necho ran > "' + pwned + '"\nexit 0\n');
|
|
8363
|
+
}
|
|
8364
|
+
const env = Object.assign({}, process.env, {
|
|
8365
|
+
PATH: shimDir + path.delimiter + process.env.PATH,
|
|
8366
|
+
LEERNESS_ENABLE_CODEX: '1', LEERNESS_ENABLE_CLAUDE: '1', LEERNESS_ENABLE_EVIL: '1',
|
|
8367
|
+
});
|
|
8368
|
+
const RS = (a, o) => { const r = cp.spawnSync(process.execPath, [CLI, ...a], Object.assign({ encoding: 'utf8', timeout: 180000, env }, o || {})); return { s: r.status, o: ((r.stdout || '') + (r.stderr || '')).trim() }; };
|
|
8369
|
+
const JS = (x) => { try { return JSON.parse(x.o.slice(x.o.indexOf('{'))); } catch { return null; } };
|
|
8370
|
+
const CORRUPT = '<<<<<<< HEAD\n[]\n=======\n[{"id":"T-0002"}]\n>>>>>>> branch\n';
|
|
8371
|
+
const hasRecovery = (s) => /bugfix-receipts\.json/.test(s) && /고치|옮기/.test(s);
|
|
8372
|
+
|
|
8373
|
+
// ── E1: 도구는 손상 스토어를 **건드리지 않는다**. --force 로도 파괴되지 않아야 한다.
|
|
8374
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-esc94-')); _d.push(d);
|
|
8375
|
+
RS(['init', d, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
8376
|
+
RS(['toggle', 'set', 'bugfix-receipt', 'on', '--path', d]);
|
|
8377
|
+
const idP = ((RS(['task', 'add', 'probe 있는 작업', '--path', d, '--json', '--no-review']).o.match(/"id"\s*:\s*"([^"]+)"/)) || [])[1];
|
|
8378
|
+
const idQ = ((RS(['task', 'add', 'probe 없는 작업', '--path', d, '--json', '--no-review']).o.match(/"id"\s*:\s*"([^"]+)"/)) || [])[1];
|
|
8379
|
+
fs.writeFileSync(path.join(d, 'app.js'), 'console.error("BUG_HERE"); process.exit(3);');
|
|
8380
|
+
const stReg = RS(['bugfix', 'start', idP, '--repro', 'node app.js', '--expect-bad', 'BUG_HERE', '--path', d, '--json']);
|
|
8381
|
+
const storeF = path.join(d, '.harness', 'bugfix-receipts.json');
|
|
8382
|
+
fs.writeFileSync(storeF, CORRUPT);
|
|
8383
|
+
const forced = RS(['bugfix', 'drop', idP, '--force', '--path', d]);
|
|
8384
|
+
// 파일이 사라지면 예외가 아니라 **단언 실패**로 떨어져야 한다 — 크래시는 "검증한 척"이라 사유를 못 남긴다.
|
|
8385
|
+
let survived = false;
|
|
8386
|
+
try { survived = fs.readFileSync(storeF, 'utf8') === CORRUPT; } catch { survived = false; }
|
|
8387
|
+
const noBackups = fs.readdirSync(path.join(d, '.harness')).filter(f => /corrupt-/.test(f)).length === 0;
|
|
8388
|
+
// 셋업이 조용히 실패하면 아래 단언이 공허해진다 — 등록 성공을 먼저 못 박는다.
|
|
8389
|
+
dbg.setup = stReg.s;
|
|
8390
|
+
E1 = stReg.s === 0 && forced.s === 1 && survived && noBackups
|
|
8391
|
+
&& !/--force/.test(forced.o) && hasRecovery(forced.o);
|
|
8392
|
+
|
|
8393
|
+
// ── E2: 회복 절차가 **모든 표면**에 같은 문장으로 도달한다(평문 · --json · sync).
|
|
8394
|
+
// 종전엔 sync/--json 에서 통째로 사라져 "틀린 안내"가 "무안내"로 회귀했다.
|
|
8395
|
+
const upd = RS(['task', 'update', idQ, '--status', 'done', '--path', d]);
|
|
8396
|
+
const updJraw = RS(['task', 'update', idQ, '--status', 'done', '--path', d, '--json']);
|
|
8397
|
+
const updJ = JS(updJraw);
|
|
8398
|
+
const listJ = JS(RS(['bugfix', 'list', '--path', d, '--json']));
|
|
8399
|
+
const todoF = path.join(d, 'todo.json');
|
|
8400
|
+
RS(['task', 'export', '--to', todoF, '--path', d]);
|
|
8401
|
+
try { const td = JSON.parse(fs.readFileSync(todoF, 'utf8')); td.forEach(t => { t.status = 'completed'; }); fs.writeFileSync(todoF, JSON.stringify(td)); } catch {}
|
|
8402
|
+
const sync = RS(['task', 'sync', '--from', todoF, '--path', d]);
|
|
8403
|
+
const syncJraw = RS(['task', 'sync', '--from', todoF, '--path', d, '--json']);
|
|
8404
|
+
const syncJ = JS(syncJraw);
|
|
8405
|
+
const recLine = ((updJ && updJ.recovery) || []).find(hasRecovery) || '';
|
|
8406
|
+
// 판별: 다섯 표면 전부 **존재**를 요구하고(부재만 보면 무안내가 통과한다), 같은 문자열인지까지 본다.
|
|
8407
|
+
// --json 경로는 exit 코드도 함께 본다 — 사유만 주고 exit 0 이면 자동화가 통과로 읽는다.
|
|
8408
|
+
E2 = upd.s === 1 && hasRecovery(upd.o) && upd.o.includes(recLine)
|
|
8409
|
+
&& !!recLine && updJraw.s === 1
|
|
8410
|
+
&& sync.s === 1 && sync.o.includes(recLine)
|
|
8411
|
+
&& !!syncJ && syncJraw.s === 1 && (syncJ.blocked || []).some(b => (b.recovery || []).includes(recLine))
|
|
8412
|
+
&& !!listJ && (listJ.recovery || []).includes(recLine)
|
|
8413
|
+
// 그리고 회복 후에는 실제로 통과해야 한다 — 안내가 사실인지 실행으로 확인한다.
|
|
8414
|
+
&& (fs.renameSync(storeF, storeF + '.bak'), RS(['task', 'update', idQ, '--status', 'done', '--path', d]).s === 0)
|
|
8415
|
+
// 되돌리면 게이트도 되살아난다 — 이게 파괴적 회복과 갈리는 지점이므로 **다시 막히는지** 실행으로 본다.
|
|
8416
|
+
&& (fs.renameSync(storeF + '.bak', storeF), RS(['task', 'update', idQ, '--status', 'done', '--path', d]).s === 1)
|
|
8417
|
+
&& /🟢 ON/.test(RS(['toggle', 'list', '--path', d]).o.split('\n').find(l => /bugfix-receipt/.test(l)) || '');
|
|
8418
|
+
|
|
8419
|
+
// ── E3: 안내가 **--path 를 잃지 않는다** — cwd 와 대상이 다르면 애먼 프로젝트를 파괴한다(실측된 경로).
|
|
8420
|
+
const dB = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-esc94b-')); _d.push(dB);
|
|
8421
|
+
RS(['init', dB, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
8422
|
+
RS(['toggle', 'set', 'bugfix-receipt', 'on', '--path', dB]);
|
|
8423
|
+
const idB = ((RS(['task', 'add', '대상 작업', '--path', dB, '--json', '--no-review']).o.match(/"id"\s*:\s*"([^"]+)"/)) || [])[1];
|
|
8424
|
+
fs.writeFileSync(path.join(dB, '.harness', 'bugfix-receipts.json'), CORRUPT);
|
|
8425
|
+
const cross = RS(['task', 'update', idB, '--status', 'done', '--path', dB], { cwd: d });
|
|
8426
|
+
// 판별: 회복 문장은 스토어 절대경로를 담으므로 그것만 보면 `--path` 부착이 무력화돼도 통과한다.
|
|
8427
|
+
// 그래서 **다른 사유**(probe 가 아직 실패 — 공통 푸터가 붙는 코드)로 한 번 더 막고, 그 푸터가
|
|
8428
|
+
// `--path <절대경로>` 를 달고 있는지, 그리고 그 문장이 sync/--json 에도 오는지 함께 본다.
|
|
8429
|
+
const dC2 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-esc94c-')); _d.push(dC2);
|
|
8430
|
+
RS(['init', dC2, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
8431
|
+
RS(['toggle', 'set', 'bugfix-receipt', 'on', '--path', dC2]);
|
|
8432
|
+
const idC = ((RS(['task', 'add', '아직 안 고친 작업', '--path', dC2, '--json', '--no-review']).o.match(/"id"\s*:\s*"([^"]+)"/)) || [])[1];
|
|
8433
|
+
fs.writeFileSync(path.join(dC2, 'app.js'), 'console.error("BUG_HERE"); process.exit(3);');
|
|
8434
|
+
const regC = RS(['bugfix', 'start', idC, '--repro', 'node app.js', '--expect-bad', 'BUG_HERE', '--path', dC2, '--json']);
|
|
8435
|
+
const stillPlain = RS(['task', 'update', idC, '--status', 'done', '--path', dC2], { cwd: d });
|
|
8436
|
+
const stillJson = JS(RS(['task', 'update', idC, '--status', 'done', '--path', dC2, '--json'], { cwd: d }));
|
|
8437
|
+
const todoC = path.join(dC2, 'todo.json');
|
|
8438
|
+
RS(['task', 'export', '--to', todoC, '--path', dC2]);
|
|
8439
|
+
try { const td = JSON.parse(fs.readFileSync(todoC, 'utf8')); td.forEach(t => { t.status = 'completed'; }); fs.writeFileSync(todoC, JSON.stringify(td)); } catch {}
|
|
8440
|
+
const stillSync = RS(['task', 'sync', '--from', todoC, '--path', dC2], { cwd: d });
|
|
8441
|
+
const footer = (((stillJson || {}).recovery) || []).find(l => /탈출구/.test(l)) || '';
|
|
8442
|
+
E3 = !!idB && cross.s === 1 && cross.o.includes(path.join(dB, '.harness')) && !cross.o.includes(path.join(d, '.harness'))
|
|
8443
|
+
&& regC.s === 0 && stillPlain.s === 1
|
|
8444
|
+
// 푸터가 존재하고 · 대상 절대경로를 달고 있고 · 세 표면이 같은 문장을 쓴다
|
|
8445
|
+
&& !!footer && footer.includes(dC2) && /--path/.test(footer)
|
|
8446
|
+
&& stillPlain.o.includes(footer) && stillSync.s === 1 && stillSync.o.includes(footer);
|
|
8447
|
+
|
|
8448
|
+
// ── E4: 같은 bin 재등록에도 인증 축과 고위험 차단이 살아 있다.
|
|
8449
|
+
// 판별 케이스 — reviewer 를 독립 provider 로 두어 **인증이 유일한 차단 사유**가 되게 한다.
|
|
8450
|
+
const mk = (over) => {
|
|
8451
|
+
const w = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ov94-')); _d.push(w);
|
|
8452
|
+
RS(['init', w, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
8453
|
+
['commander', 'coder', 'architect'].forEach(x => RS(['roles', 'set', x, '--provider', 'codex', '--path', w]));
|
|
8454
|
+
RS(['roles', 'set', 'reviewer', '--provider', 'claude', '--path', w]);
|
|
8455
|
+
RS(['toggle', 'set', 'difficulty-routing', 'on', '--path', w]);
|
|
8456
|
+
// 셋업 실패를 삼키면 A/B 대조가 같아져 변이가 살아남는다 — exit 와 **저장 결과**를 함께 단언한다.
|
|
8457
|
+
if (over) {
|
|
8458
|
+
const ar = RS(['provider', 'add', 'codex', '--bin', over, '--path', w]);
|
|
8459
|
+
let saved = false;
|
|
8460
|
+
try {
|
|
8461
|
+
const raw = JSON.parse(fs.readFileSync(path.join(w, '.harness', 'providers.json'), 'utf8'));
|
|
8462
|
+
saved = (Array.isArray(raw) ? raw : (raw.providers || [])).some(p => p.id === 'codex' && p.bin === over);
|
|
8463
|
+
} catch {}
|
|
8464
|
+
if (ar.s !== 0 || !saved) return null;
|
|
8465
|
+
}
|
|
8466
|
+
return w;
|
|
8467
|
+
};
|
|
8468
|
+
const axis = (w) => { const j = JS(RS(['agents', 'check', '--path', w, '--json'])) || {}; return ((j.agents || []).find(a => a.id === 'codex') || {}); };
|
|
8469
|
+
const route = (w) => { const r = RS(['agents', 'route', '인증 토큰 재발급 로직 변경', '--confirm', '--approved-by', '검증자', '--path', w, '--json']); return { s: r.s, j: JS(r) }; };
|
|
8470
|
+
// 판별: 사유 코드를 **구조에서** 읽고, 인증이 **유일한** 차단 사유인지까지 본다 —
|
|
8471
|
+
// 다른 blocker 가 끼면 인증이 꺼져도 exit 1 이라 회귀를 놓친다.
|
|
8472
|
+
const blockedByAuth = (r) => r.s === 1 && !!r.j && r.j.confirmed === false
|
|
8473
|
+
&& (r.j.refusals || []).length === 1 && (r.j.refusals || [])[0].code === 'provider_not_authenticated';
|
|
8474
|
+
// 같은 실행파일을 가리키는 **정상 별칭** 전부에서 유지돼야 한다 — 문자열 완전일치로 비교하면
|
|
8475
|
+
// `codex.cmd`·대문자·절대경로 등록만으로 이번에 되살린 차단이 다시 사라진다(실측).
|
|
8476
|
+
const aliases = [null, 'codex', 'CODEX', 'codex.cmd', path.join(shimDir, isWin ? 'codex.cmd' : 'codex')];
|
|
8477
|
+
let aliasOk = true, aliasBad = '';
|
|
8478
|
+
for (const al of aliases) {
|
|
8479
|
+
const w = mk(al);
|
|
8480
|
+
if (!w) { aliasOk = false; aliasBad = String(al) + ':setup'; break; }
|
|
8481
|
+
if (axis(w).auth !== 'no' || !blockedByAuth(route(w))) { aliasOk = false; aliasBad = String(al); break; }
|
|
8482
|
+
}
|
|
8483
|
+
dbg.alias = aliasBad;
|
|
8484
|
+
E4 = aliasOk;
|
|
8485
|
+
|
|
8486
|
+
// ── E5: providers.json 은 **실행 가능한 값을 주는 통로가 아니다**(authCheck · versionArgs 둘 다).
|
|
8487
|
+
const inject = (w, id, patch) => {
|
|
8488
|
+
const pf = path.join(w, '.harness', 'providers.json');
|
|
8489
|
+
const raw = JSON.parse(fs.readFileSync(pf, 'utf8'));
|
|
8490
|
+
(Array.isArray(raw) ? raw : (raw.providers || [])).forEach(p => { if (p.id === id) Object.assign(p, patch); });
|
|
8491
|
+
fs.writeFileSync(pf, JSON.stringify(raw, null, 2));
|
|
8492
|
+
};
|
|
8493
|
+
// (1) user-only provider · (2) **빌트인 override** 갈래 — 갈래 하나만 시험하면 나머지 변이가 살아남는다.
|
|
8494
|
+
const wD = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-inj94-')); _d.push(wD);
|
|
8495
|
+
RS(['init', wD, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
8496
|
+
RS(['provider', 'add', 'evil', '--bin', 'evil', '--path', wD]);
|
|
8497
|
+
inject(wD, 'evil', { authCheck: { args: ['pwn'], timeoutMs: 5000, noPattern: 'never', note: '주입' }, versionArgs: ['pwn'] });
|
|
8498
|
+
RS(['agents', 'list', '--path', wD]);
|
|
8499
|
+
const injJ = JS(RS(['agents', 'check', '--path', wD, '--json']));
|
|
8500
|
+
const evilA = (((injJ || {}).agents) || []).find(a => a.id === 'evil') || {};
|
|
8501
|
+
const wE = mk('codex');
|
|
8502
|
+
if (wE) inject(wE, 'codex', { authCheck: { args: ['pwn'], timeoutMs: 5000, noPattern: 'never' }, versionArgs: ['--version', '&', 'echo', 'META', '>', metaFile] });
|
|
8503
|
+
const bJ = wE ? JS(RS(['agents', 'check', '--path', wE, '--json'])) : null;
|
|
8504
|
+
const codexA = (((bJ || {}).agents) || []).find(a => a.id === 'codex') || {};
|
|
8505
|
+
// bin 이 달라 물려받지 못하면 **사유를 말한다** — 못 찾으면 '' 이라 부정식만으로는 공허하게 참이 된다.
|
|
8506
|
+
const wC = mk('node');
|
|
8507
|
+
const lineC = wC ? (RS(['agents', 'check', '--path', wC]).o.split('\n').find(l => /codex/.test(l) && /확인안됨|사용가능|사용불가/.test(l)) || '') : '';
|
|
8508
|
+
dbg.lineC = lineC.slice(0, 40);
|
|
8509
|
+
// **bin 자체가 shell:true 명령 위치다** — versionArgs 만 막으면 닫히지 않는다(원격 catalog 가 심는다).
|
|
8510
|
+
const wF = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-bin94-')); _d.push(wF);
|
|
8511
|
+
RS(['init', wF, '--yes', '--language', 'ko', '--no-stale-check']);
|
|
8512
|
+
RS(['provider', 'add', 'x1', '--bin', 'x1', '--path', wF]);
|
|
8513
|
+
inject(wF, 'x1', { bin: 'cmd /c echo INJECTED > "' + path.join(shimDir, 'BIN.txt') + '" & rem' });
|
|
8514
|
+
RS(['agents', 'list', '--path', wF]);
|
|
8515
|
+
const binJ = JS(RS(['agents', 'check', '--path', wF, '--json']));
|
|
8516
|
+
const x1A = (((binJ || {}).agents) || []).find(a => a.id === 'x1') || {};
|
|
8517
|
+
// 정상 하위명령 versionArgs 는 막지 않는다(false-BLOCK 금지 — 설치된 CLI 가 미설치로 보였다)
|
|
8518
|
+
RS(['provider', 'add', 'ktool', '--bin', 'codex', '--path', wF]);
|
|
8519
|
+
inject(wF, 'ktool', { versionArgs: ['version', '--client'] });
|
|
8520
|
+
const kJ = JS(RS(['agents', 'check', '--path', wF, '--json']));
|
|
8521
|
+
const ktA = (((kJ || {}).agents) || []).find(a => a.id === 'ktool') || {};
|
|
8522
|
+
// 거부 시 폴백이 **남의 하위명령을 다른 bin 에 쏘면 안 된다**(`kubectl copilot --version`).
|
|
8523
|
+
// 심이 받은 인자를 그대로 출력하므로 version 문자열로 관측한다 — 사유 필드만 보면 구분되지 않는다.
|
|
8524
|
+
const echoBin = isWin ? 'echoargs.cmd' : 'echoargs';
|
|
8525
|
+
if (isWin) fs.writeFileSync(path.join(shimDir, echoBin), '@echo off\r\necho ARGS %*\r\nexit /b 0\r\n');
|
|
8526
|
+
else { const p = path.join(shimDir, echoBin); fs.writeFileSync(p, '#!/bin/sh\necho "ARGS $@"\nexit 0\n'); try { fs.chmodSync(p, 0o755); } catch {} }
|
|
8527
|
+
RS(['provider', 'add', 'copilot', '--bin', 'echoargs', '--path', wF]);
|
|
8528
|
+
inject(wF, 'copilot', { versionArgs: ['--version', '&', 'x'] });
|
|
8529
|
+
const cpJ = JS(RS(['agents', 'check', '--path', wF, '--json']));
|
|
8530
|
+
const cpA = (((cpJ || {}).agents) || []).find(a => a.id === 'copilot') || {};
|
|
8531
|
+
dbg.fallback = String(cpA.version || '').slice(0, 40);
|
|
8532
|
+
const builtinNoise = (((kJ || {}).agents) || []).filter(a => ['codex', 'claude', 'copilot', 'ollama'].indexOf(a.id) >= 0 && (a.binRejected || a.versionArgsRejected));
|
|
8533
|
+
// **원격 catalog 는 실행 인자를 정하지 못한다** — 메타문자만 막으면 `npm exec --yes <패키지>` 처럼
|
|
8534
|
+
// 메타문자 없이 임의 코드를 받아오는 조합이 남는다. sync 결과에 원격 versionArgs 가 반영되면 안 된다.
|
|
8535
|
+
// 서버는 **별도 프로세스**여야 한다 — 이 스크립트는 spawnSync 로 자식을 기다리므로 같은 프로세스의
|
|
8536
|
+
// http 서버는 그 동안 요청을 처리할 수 없다(내 첫 프로브가 그래서 timeout 으로 오판했다).
|
|
8537
|
+
const catalog = path.join(shimDir, 'catalog.json');
|
|
8538
|
+
const srvJs = path.join(shimDir, 'srv.js');
|
|
8539
|
+
const portFile = path.join(shimDir, 'port.txt');
|
|
8540
|
+
fs.writeFileSync(srvJs, `const fs=require('fs'),http=require('http');
|
|
8541
|
+
const s=http.createServer((q,r)=>{r.writeHead(200,{'content-type':'application/json'});r.end(fs.readFileSync(process.argv[2]));});
|
|
8542
|
+
s.listen(0,'127.0.0.1',()=>fs.writeFileSync(process.argv[3],String(s.address().port)));`);
|
|
8543
|
+
const napMs = (ms) => { try { cp.spawnSync(process.execPath, ['-e', `setTimeout(function(){},${ms})`], { timeout: ms + 5000 }); } catch {} };
|
|
8544
|
+
const syncOnce = (entries) => {
|
|
8545
|
+
fs.writeFileSync(catalog, JSON.stringify(entries));
|
|
8546
|
+
try { fs.rmSync(portFile, { force: true }); } catch {}
|
|
8547
|
+
const child = cp.spawn(process.execPath, [srvJs, catalog, portFile], { stdio: 'ignore', detached: false });
|
|
8548
|
+
let port = null;
|
|
8549
|
+
for (let i = 0; i < 60 && !port; i++) { napMs(100); try { port = fs.readFileSync(portFile, 'utf8').trim() || null; } catch {} }
|
|
8550
|
+
// 이 스위트는 전역 LEERNESS_OFFLINE=1 로 외부 fetch 를 막는다(옳다). 여기서만 예외를 두되
|
|
8551
|
+
// 대상은 **루프백 서버**이므로 "테스트가 네트워크에 의존하지 않는다"는 보장은 그대로다.
|
|
8552
|
+
if (port) RS(['provider', 'sync', `http://127.0.0.1:${port}/c.json`, '--path', wF], { env: Object.assign({}, env, { LEERNESS_OFFLINE: '0' }) });
|
|
8553
|
+
try { child.kill(); } catch {}
|
|
8554
|
+
try { return JSON.parse(fs.readFileSync(path.join(wF, '.harness', 'providers.json'), 'utf8')); } catch { return null; }
|
|
8555
|
+
};
|
|
8556
|
+
const asList = (raw) => (Array.isArray(raw) ? raw : ((raw && raw.providers) || []));
|
|
8557
|
+
let syncedVa = null, syncSkippedBadBin = false;
|
|
8558
|
+
const r1 = syncOnce([{ id: 'remote1', bin: 'evil', versionArgs: ['pwn'], desc: 'r' }]);
|
|
8559
|
+
const got1 = asList(r1).find(p => p.id === 'remote1');
|
|
8560
|
+
syncedVa = got1 ? JSON.stringify(got1.versionArgs) : null;
|
|
8561
|
+
// 같은 경로로 명령줄 bin 이 들어오면 아예 등록되지 않아야 한다
|
|
8562
|
+
const r2 = syncOnce([{ id: 'remote2', bin: 'cmd /c echo X & rem', desc: 'r' }]);
|
|
8563
|
+
syncSkippedBadBin = !!r2 && !asList(r2).some(p => p.id === 'remote2');
|
|
8564
|
+
dbg.syncedVa = syncedVa;
|
|
8565
|
+
E5 = !fs.existsSync(metaFile) && !fs.existsSync(path.join(shimDir, 'BIN.txt'))
|
|
8566
|
+
&& evilA.auth === 'unknown'
|
|
8567
|
+
// 빌트인 override 갈래도 주입을 신뢰하지 않는다 — 상속된 authCheck 로 판정하고 auth 는 no 로 남는다
|
|
8568
|
+
&& !!wE && codexA.auth === 'no' && codexA.authSource === 'codex login status' && !!codexA.versionArgsRejected
|
|
8569
|
+
// 긍정 단언: 줄을 실제로 찾았고 그 줄이 사유를 담고 있다
|
|
8570
|
+
&& !!wC && /override/.test(lineC) && /node/.test(lineC) && !/등록된 확인 명령 없음/.test(lineC)
|
|
8571
|
+
// bin 거부는 사유를 말하고 **평문에도** 나온다(JSON 에만 넣으면 사람은 왜 미설치인지 모른다)
|
|
8572
|
+
&& !!x1A.binRejected && /⚠ x1:/.test(RS(['agents', 'list', '--path', wF]).o)
|
|
8573
|
+
// 정상 하위명령 versionArgs 는 막지 않는다(설치된 CLI 가 미설치로 보이던 false-BLOCK)
|
|
8574
|
+
&& !ktA.versionArgsRejected && builtinNoise.length === 0
|
|
8575
|
+
// 폴백은 빌트인 하위명령을 **다른 bin 에 쏘지 않는다** — 실제로 전달된 인자를 심 출력으로 확인한다
|
|
8576
|
+
&& /ARGS/.test(String(cpA.version || '')) && !/copilot/.test(String(cpA.version || ''))
|
|
8577
|
+
// 원격은 실행 인자를 정하지 못하고, 명령줄 bin 은 등록조차 되지 않는다
|
|
8578
|
+
&& syncedVa === '["--version"]' && syncSkippedBadBin;
|
|
8579
|
+
ok = E1 && E2 && E3 && E4 && E5;
|
|
8580
|
+
if (!ok) console.log(` [이월94 디버그] 무파괴=${E1}(setup ${dbg.setup}) 안내전달=${E2} path보존=${E3} 축유지=${E4}(별칭실패 "${dbg.alias}") 주입차단=${E5}(lineC "${dbg.lineC}" syncedVa=${dbg.syncedVa} 폴백="${dbg.fallback}")`);
|
|
8581
|
+
} catch (e) { console.log(' [이월94 디버그] 예외: ' + ((e && e.message) || e)); }
|
|
8582
|
+
finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
|
|
8583
|
+
console.log(ok ? '✓ B(1.36.94) 헌트 이월 2건: 손상 스토어를 도구가 건드리지 않음(--force 로도) · 회복 절차가 평문/--json/sync 에 같은 문장으로 도달하고 실제로 통과시킴 · 안내가 --path 유지 · 같은 bin 재등록에도 인증 차단 유지(사유 코드로 판별) · providers.json 의 authCheck/versionArgs 미실행(user·빌트인 두 갈래)' : '✗ 헌트 이월 2건(1.36.94) 실패');
|
|
8584
|
+
if (!ok) failed++;
|
|
8585
|
+
}
|
|
8586
|
+
|
|
8172
8587
|
// 1.36.76 (9차 헌트 이월 3건 종결): 손상 아카이브 복원 거부 · MCP read-only 무변형 · 긴 파생 id 안전화
|
|
8173
8588
|
total++;
|
|
8174
8589
|
{
|
|
@@ -8533,3 +8948,4 @@ total++;
|
|
|
8533
8948
|
|
|
8534
8949
|
console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
|
|
8535
8950
|
if (failed > 0) process.exit(1);
|
|
8951
|
+
|