leerness 1.36.21 → 1.36.23
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 +33 -0
- package/CONTRIBUTING.md +34 -0
- package/README.ko.md +4 -9
- package/README.md +12 -16
- package/bin/leerness.js +135 -18
- package/lib/catalogs.js +17 -1
- package/lib/search-core.js +85 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.23 — 2026-07-14 — memory search: 한국어↔영어 동의어(핸드오프 0→38) + BM25 랭킹("앞 N건"→"상위 N건") + JSON 정합
|
|
4
|
+
|
|
5
|
+
ui-ux-pro-max 의 BM25 검색엔진을 검토해 **정렬 레이어로만** 이식. 원안(BM25 를 리콜 엔진으로)은 부적합 — **토큰화 리콜은 언어별 형태론 가정을 끌어들여** 기존 히트를 잃는다. 실측: `memory search "핸드오프"` 는 substring 이라 "핸드오프**를** 자동화…"를 찾지만, 공백 토큰화 BM25 로 리콜을 대체하면 doc 토큰이 `['핸드오프를',…]` 이라 쿼리와 불일치해 **지금 찾히는 문서가 0건**이 된다(교착어 조사가 대표 사례이고, 굴절·합성어 언어에서도 같은 계열의 손실). 형태소 분석기는 의존성 0 위반이라 불가 → substring 은 **언어 중립적이고 상위집합 안전**하므로 리콜로 유지한다.
|
|
6
|
+
|
|
7
|
+
- **`lib/search-core.js` 신설** (순수함수 · 0-deps · 산술만): `tokenizeForRank`(한글은 조사 절단 근사로 prefix 변형 방출 — `핸드오프를` 이 `핸드오프` 쿼리에 스코어를 받음) · `expandQuery`(동의어 맵 주입 → 순수) · `scoreHits`(BM25 k1=1.5/b=0.75) · `suggestTerms`.
|
|
8
|
+
- ★ **설계 불변식**: BM25 는 정렬만, 리콜은 substring 유지 → **토크나이저가 틀려도 히트가 0이 되지 않는다**(정렬 순서만 나빠질 뿐). 안전판.
|
|
9
|
+
- **동의어(`MEMORY_SYNONYMS`, catalogs 단일출처)** — 같은 개념을 다른 언어로 물으면 결과가 갈리던 비대칭 해소: `핸드오프` **0 → 38** hits, `교훈` 3 → 18, `결정` 4 → 18, `작업` 11 → 27, `계획` 5 → 19. 메모리 내용은 언어가 섞이기 마련이라(코드·커밋은 영어, 결정·교훈은 모국어) 한쪽 표기만 아는 사용자/에이전트가 존재하는 기록을 못 찾았다. 양방향(어느 쪽으로 물어도 동일). `--no-synonyms` 로 종전 동작(실측 재현 확인).
|
|
10
|
+
- **BM25 랭킹** — 종전엔 `--limit`(기본 5)이 파일당 **"앞 5건"** 임의 절단이었다(최고 5건이 아니라). 이제 히트를 전부 모아 스코어 → 정렬 → limit ⇒ **"상위 N건"**. 파일도 최고점수 순. `--include-code`(21k줄 소스)에서 특히 자의적이던 절단 해소.
|
|
11
|
+
- **`--json` 정합** — 종전 `total: 16 / results.length: 15` 처럼 어긋나던 것을 `total`(전체 히트) + `shown` + `truncated` 로 자기정합화. 모든 result 에 `score` 추가(메모리/코드 경로 형태 통일 — 종전엔 코드 경로만 score 없이 섞였다).
|
|
12
|
+
- **0건 근접어 제안** — 실제 색인 어휘에서만 추출(지어내지 않음): `혹시 이건가요: …`.
|
|
13
|
+
- **검증**: selftest 293/293 — 판정단이 채택 조건으로 건 가드 3종을 그대로 박음: (a) **한국어 조사 회귀 금지**(`핸드오프` 쿼리가 `핸드오프를…` 에 반드시 스코어 + substring 안전판 확인), (b) 동의어 순증(원본 항상 포함·양방향·모르는 말은 원본 그대로), (c) BM25 단조성(tf↑→score↑, 희귀어 idf↑). **정밀도 재검**(리콜 확대가 잠복 FP 를 깨우는지): 6개 동의어쌍 전수 — **리콜 회귀 0 · 추가 히트 중 동의어 무관 FP 0**. 게이트 e2e, 게시본 클린룸.
|
|
14
|
+
- 이연: skill match(jaccard)의 search-core 통합은 scope 경계로 제외 — 후속 후보.
|
|
15
|
+
|
|
16
|
+
## 1.36.22 — 2026-07-14 — SessionStart 컨텍스트 주입(compaction 후 컨텍스트 유실 차단) + README 를 소개·사용법·효과로 재구성
|
|
17
|
+
|
|
18
|
+
**① SessionStart 컨텍스트 주입** (obra/superpowers 구조 검토 → 배선). 실측 결함: compaction/resume 후 에이전트가 "세션 시작 handoff" 의례를 건너뛰어 컨텍스트를 잃는다. superpowers 는 hook matcher 를 `startup|clear|compact` 로 걸어 하네스가 컨텍스트를 강제 적재한다(에이전트 자발성에 안 기댐) — leerness 는 hook 플럼빙과 compact 신호를 **이미 둘 다 갖고 있었고 배선만 없었다**.
|
|
19
|
+
|
|
20
|
+
- **`leerness hook session-start [path]` 신설** — hook 전용 표면. Claude Code 규약(`{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"…"}}`)으로 진행상황 2~3줄 주입.
|
|
21
|
+
- ★ **handoff 를 호출하지 않는다**: handoff 는 `last-handoff` stamp 를 찍어 detector 3종(핸드오프 넛지·abnormal shutdown·R-0001 interval)을 오염시킨다. 동일 파일 로더만 재사용 — **subprocess 0 · 쓰기 0 · last-handoff.json 미접촉**(실측 불변 확인).
|
|
22
|
+
- **어떤 예외에서도 stdout 무출력 + exit 0** — 세션 시작을 절대 깨지 않는다. 미초기화/없는 경로도 무음.
|
|
23
|
+
- **자기 라벨링**(1.9.272 투명성): "위는 요약 — 전체 컨텍스트는 `leerness handoff .` 실행" + 끄는 법 동봉.
|
|
24
|
+
- opt-out: `--no-context-inject` / `LEERNESS_NO_CONTEXT_INJECT=1`.
|
|
25
|
+
- **`handoff --no-record` / `LEERNESS_HOOK=1` 가드 신설** — 읽기전용/hook 호출이 stamp 를 찍어 detector 를 오염시키던 경로 차단(기본 동작 불변, 실측 무회귀).
|
|
26
|
+
- **hook 설치 배선** — `auto-update install` 이 2번째 SessionStart hook(`matcher: 'startup|clear|compact'`)을 멱등 push. 기존 설치도 자동 취득. 투명성 문구로 무엇을/언제/왜/끄는법 명시.
|
|
27
|
+
- 채택하지 않음: superpowers 의 SKILL.md 전문 주입(leerness 의 값어치는 정적 문서가 아니라 동적 상태) · bash 래퍼 hook(leerness 는 이미 PATH 실행파일).
|
|
28
|
+
|
|
29
|
+
**② README 재구성** — 소개 / 사용법 / 사용효과만 담도록 정리. 자기평가·성숙도 서술은 README 의 일이 아니다.
|
|
30
|
+
- 제거: "자체 수행 클린룸 평가로 검증됨" **주장과 그 한정어를 함께**(주장이 없으면 한정어도 불필요 — 방법론·결과·한계는 원래부터 `docs/clean-room-evaluations.md` 가 보유, Links 에서 링크) · "성숙도" 섹션(초기 단계/단독 유지보수/자율 AI 개발/외부 채택) · ko 의 문서 출처 메타 노트.
|
|
31
|
+
- 이전(삭제 아님): 기여자 테스트 티어(`test:fast`/`test:core`/`npm test`) → **신규 `CONTRIBUTING.md`**(불변식·판별케이스·릴리스 절차 포함).
|
|
32
|
+
- 재구성: "Guidance vs enforcement (be honest about this)" → **"Make it enforced, not optional"**(ci init + branch protection 사용법). gitleaks 병행은 사과가 아니라 사용 권장으로.
|
|
33
|
+
- 정직성 회귀 가드로 전수 재검 → ko 에 남아있던 **"지속적 외부 검증 — 다중 모델 클린룸 리뷰"** 적발·제거(한정어를 뺀 자리에 제3자 검증 함의가 남아있었음). 최종 확인: 감사/인증/보장 함의 문구 0.
|
|
34
|
+
- **검증**: selftest 292/292(신규 hook 소스가드 — 쓰기0 불변식·JSON 규약·matcher compact·opt-out·자기라벨링), hook 실측 5종(JSON 규약 / last-handoff 불변 / 미초기화 무음 exit0 / opt-out 2종 / `--no-record` + 기본 stamp 무회귀), 게이트 e2e, 게시본 클린룸.
|
|
35
|
+
|
|
3
36
|
## 1.36.21 — 2026-07-14 — 경로/EOL/형태 견고성 4종 — 없는 경로에 트리 생성·CRLF plan tasks 증발·오타 하위명령 전체설치·user-requests 원본 덮어쓰기 (22 에이전트 전수 sweep, 전부 재현확정)
|
|
4
37
|
|
|
5
38
|
codex 이연분(#8/#6) 재현에 그치지 않고 **동일 버그 클래스를 전수 조사**(CRLF 파서 / `--json` 실패경로 / 로더 스키마 / mutation-order 잔여) — 22 에이전트가 실행 기반으로 확정하고 후보마다 독립 재검증. 확정 P2 중 **현실성 있는 4건**만 채택(P3/contrived 는 기각·이연).
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Contributing to leerness
|
|
2
|
+
|
|
3
|
+
## Invariants (non-negotiable)
|
|
4
|
+
|
|
5
|
+
- **0 runtime dependencies** and **0 install scripts** (`preinstall` / `install` / `postinstall`). `leerness install-safety` asserts both; the release gate fails if either is violated.
|
|
6
|
+
- **UTF-8, no BOM** for every source file.
|
|
7
|
+
- Korean-first comments in `bin/` and `lib/` (the codebase's existing convention).
|
|
8
|
+
- Protected harness files are never deleted — merge, archive, or mark deprecated instead.
|
|
9
|
+
|
|
10
|
+
## Test tiers
|
|
11
|
+
|
|
12
|
+
Three tiers, fastest to slowest:
|
|
13
|
+
|
|
14
|
+
| Command | What it runs | Time | Use it for |
|
|
15
|
+
|---|---|---|---|
|
|
16
|
+
| `npm run test:fast` | `selftest` + smoke (commands run without crashing) | < 1 min | the dev loop |
|
|
17
|
+
| `npm run test:core` | `selftest` + a flagship behavioral suite — `verify-claim` / `gate` / `contract` / `scan` actually reject bad input and pass honest input | ~20 s | pre-commit, quick CI |
|
|
18
|
+
| `npm test` | `--version` + `selftest` + the entire e2e suite | **10+ minutes by design** | the release gate |
|
|
19
|
+
|
|
20
|
+
`npm test` is the gate that must pass before publishing. It is deliberately slow: the e2e suite drives the published CLI surface end-to-end rather than mocking it.
|
|
21
|
+
|
|
22
|
+
## Adding a check
|
|
23
|
+
|
|
24
|
+
Prefer **behavioral** tests over source-grep guards. A source-grep guard asserts that a string exists, not that the behavior holds — and it can match its own literal in the file it greps (use split literals like `'function ' + 'name'` to anchor past your own line if you must grep).
|
|
25
|
+
|
|
26
|
+
When you add a guard, construct the **discriminating case**: an input where the old code passes and only the new check catches it. If you cannot construct one, the check may not be measuring what you think.
|
|
27
|
+
|
|
28
|
+
## Release
|
|
29
|
+
|
|
30
|
+
`npm test` (gate) → bump `VERSION` in `bin/leerness.js` **and** `version` in `package.json` (selftest asserts they match) → CHANGELOG entry → commit → publish → push.
|
|
31
|
+
|
|
32
|
+
## How releases get tested
|
|
33
|
+
|
|
34
|
+
Methodology, results, and limitations of the clean-room evaluations: [docs/clean-room-evaluations.md](./docs/clean-room-evaluations.md).
|
package/README.ko.md
CHANGED
|
@@ -11,12 +11,10 @@
|
|
|
11
11
|
╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
> **어떤 언어, 어떤 AI 에이전트로 작업하든 — "증거 없이는 끝났다고 말할 수 없게" 만드는 AI 코딩 운영 레이어.** 코드를 대신 쓰는 도구가 아니라, AI 에이전트의 **기억·인수인계·검증·감사·보안 가드**를 프로젝트에 영속화하는 CLI + MCP 서버입니다.
|
|
14
|
+
> **어떤 언어, 어떤 AI 에이전트로 작업하든 — "증거 없이는 끝났다고 말할 수 없게" 만드는 AI 코딩 운영 레이어.** 코드를 대신 쓰는 도구가 아니라, AI 에이전트의 **기억·인수인계·검증·감사·보안 가드**를 프로젝트에 영속화하는 CLI + MCP 서버입니다.
|
|
15
15
|
|
|
16
16
|
[](https://www.npmjs.com/package/leerness) ·  · **런타임 의존성 0** · **install-script 0** · offline-first · Node ≥ 18 · MIT
|
|
17
17
|
|
|
18
|
-
> 이 문서는 **AI 다중 모델(Codex / Claude Sonnet / Claude Opus)이 README 없이 leerness 를 직접 설치·실행·분석한 클린룸 리뷰**(행위 기반 — 제3자 인간 객관 감사는 아님)를 바탕으로 재구성됐습니다. 매 릴리스마다 자동 갱신됩니다.
|
|
19
|
-
|
|
20
18
|
---
|
|
21
19
|
|
|
22
20
|
## leerness가 뭔가요?
|
|
@@ -45,11 +43,9 @@ leerness는 이 문제들을 해결하는 **외부 운영 substrate**입니다.
|
|
|
45
43
|
|
|
46
44
|
---
|
|
47
45
|
|
|
48
|
-
##
|
|
49
|
-
|
|
50
|
-
의존하기 전에 솔직히: leerness 는 **초기 단계이고 사실상 단독 유지보수**이며, 대부분 **자율 AI 라운드**로 개발됐습니다 — 따라서 자체 `selftest` + e2e 스위트가 1차 품질 신호이고, 외부 채택은 아직 미미합니다. 신뢰만으로 load-bearing 으로 만들지 마세요: **버전을 핀**하고, 차별화된 슬라이스 — `verify-claim` + CI `gate`(필수 체크) — 만 의존할 부분으로 삼으세요.
|
|
46
|
+
## 도입 부담이 낮은 설계
|
|
51
47
|
|
|
52
|
-
|
|
48
|
+
MIT, **런타임 의존성 0**, offline-first, 모든 상태가 *당신의* 저장소 안 평문 파일입니다. lock-in 이 거의 없어 — 값을 못 하면 도구만 빼도 `task`/`decision`/`lesson` 파일은 그대로 남습니다. CI 에서는 **버전을 핀**하세요 — 조용한 업그레이드로 게이트 판정이 바뀌지 않습니다. (시크릿은 gitleaks/trufflehog 같은 전용 스캐너와 병행하세요 — `scan secrets` 는 에이전트가 로컬에서 보는 것과 같은 신호를 주는 편의 레이어입니다.)
|
|
53
49
|
|
|
54
50
|
---
|
|
55
51
|
|
|
@@ -168,9 +164,8 @@ leerness mcp serve # JSON-RPC over stdio, 85 도구
|
|
|
168
164
|
- **에이전트 중립** — Claude / Codex / Cursor / Aider / Goose 등 어디에나 적용.
|
|
169
165
|
- **거짓 완료 방지** — 완료 주장에 evidence 를 요구하는 anti-lazy 흐름.
|
|
170
166
|
- **통합 게이트** — 보안·인코딩·드리프트·검증을 `gate` 하나로.
|
|
171
|
-
-
|
|
167
|
+
- **Windows/인코딩 1급 시민** — CP949/BOM 가드, 터미널 인코딩 자동 회복. 출력 언어는 기본 ko · `--language en` 으로 영어.
|
|
172
168
|
- **자기기술(self-describing)** — `about`/`commands`/`capabilities`/`doctor` 로 도구가 스스로 설명.
|
|
173
|
-
- **지속적 외부 검증** — 다중 모델 클린룸 리뷰로 회귀 0 을 유지하며 진화.
|
|
174
169
|
|
|
175
170
|
---
|
|
176
171
|
|
package/README.md
CHANGED
|
@@ -65,13 +65,11 @@ Built-in harnesses remember what the AI **said**. leerness verifies what the AI
|
|
|
65
65
|
| Secrets · encoding · drift guards | none | `scan secrets` · `encoding check` · `drift check --auto-fix` — CI-ready |
|
|
66
66
|
| Lock-in | one vendor | any agent, any language, 0 runtime dependencies |
|
|
67
67
|
|
|
68
|
-
This positioning is checked by **self-administered clean-room evaluations** — AI agents do a fresh `npm install` into temp dirs and drive it by behavior only, including adversarial attacks against the verifier itself (fake tests, comment-only stubs, inflated test counts — all rejected). To be clear: these are *AI* clean-room runs, **not third-party human audits or peer review** — they make the claim *checkable* rather than a marketing line. Methodology, results, and honest limitations: **[docs/clean-room-evaluations.md](./docs/clean-room-evaluations.md)**.
|
|
69
|
-
|
|
70
68
|
---
|
|
71
69
|
|
|
72
|
-
##
|
|
70
|
+
## Make it enforced, not optional
|
|
73
71
|
|
|
74
|
-
By default leerness is **cooperative**: your AI agent runs the commands because CLAUDE.md / AGENTS.md tell it to. A determined agent could skip them. To
|
|
72
|
+
By default leerness is **cooperative**: your AI agent runs the commands because CLAUDE.md / AGENTS.md tell it to. A determined agent could skip them. To turn the guideline into a guardrail:
|
|
75
73
|
|
|
76
74
|
```bash
|
|
77
75
|
leerness ci init # writes .github/workflows/leerness-gate.yml — runs `leerness gate` on every PR
|
|
@@ -81,23 +79,19 @@ The generated workflow is production-grade: it **pins the leerness version** (re
|
|
|
81
79
|
|
|
82
80
|
Then make that check **required** in GitHub branch protection. Now a PR that skips verification (or whose claims fail) **cannot merge** — the gate runs independently of the agent, returns a non-zero exit code, and blocks. That is the difference between a guideline and a guardrail. For exact per-claim enforcement, run `leerness gate --claims` — it adds a 6th check that runs `verify-claim` on **every** completed task and fails the gate if any "done" task's evidence doesn't match reality (the default 5-check gate already blocks false-done via heuristics; `--claims` makes it precise).
|
|
83
81
|
|
|
84
|
-
For secrets, pair the gate with a **dedicated scanner** in the same workflow
|
|
82
|
+
For secrets, pair the gate with a **dedicated scanner** in the same workflow. `scan secrets` gives your agent the same signal locally; a dedicated scanner is the hard-guarantee layer:
|
|
85
83
|
|
|
86
84
|
```yaml
|
|
87
85
|
# add to .github/workflows/leerness-gate.yml (or a separate job)
|
|
88
86
|
- uses: gitleaks/gitleaks-action@v2 # dedicated scanner — the hard-guarantee layer
|
|
89
|
-
- run: npx leerness@<pinned-version> scan secrets . --json #
|
|
87
|
+
- run: npx leerness@<pinned-version> scan secrets . --json # same check your agent runs locally
|
|
90
88
|
```
|
|
91
89
|
|
|
92
90
|
---
|
|
93
91
|
|
|
94
|
-
##
|
|
95
|
-
|
|
96
|
-
Be honest with yourself before you depend on this: leerness is **early and largely solo-maintained**, developed mostly through autonomous AI rounds — so its own `selftest` + e2e suites are the primary quality signal, and external adoption is still small. Don't make it load-bearing on faith: **pin a version**, and treat the differentiated slice — `verify-claim` + the CI `gate` as a required check — as the part worth relying on.
|
|
97
|
-
|
|
98
|
-
(Contributor note — three test tiers, fastest to slowest: `npm run test:fast` = selftest + smoke (commands run without crashing, <1 min, dev loop); `npm run test:core` = selftest + a flagship behavioral suite (verify-claim / gate / contract / scan actually reject bad input and pass honest input, ~20 s, pre-commit / quick CI); `npm test` = selftest + the entire e2e suite (**10+ minutes by design**, the release gate).)
|
|
92
|
+
## Low-risk by design
|
|
99
93
|
|
|
100
|
-
|
|
94
|
+
**MIT · 0 runtime dependencies · offline-first**, and all state is plain files in *your* repo. Lock-in is near zero — if it doesn't earn its place, remove the tool and your `task` / `decision` / `lesson` files stay exactly where they are. **Pin a version** in CI so the gate's verdict can't change from a silent upgrade.
|
|
101
95
|
|
|
102
96
|
---
|
|
103
97
|
|
|
@@ -117,6 +111,8 @@ Full command reference, workflows, and architecture: **[README.ko.md](./README.k
|
|
|
117
111
|
- npm: https://www.npmjs.com/package/leerness
|
|
118
112
|
- Site & release videos: https://leerness.pages.dev
|
|
119
113
|
- Changelog: [CHANGELOG.md](./CHANGELOG.md)
|
|
114
|
+
- How this gets tested — methodology, results, and limitations: [docs/clean-room-evaluations.md](./docs/clean-room-evaluations.md)
|
|
115
|
+
- Contributing & test tiers (`test:fast` / `test:core` / `npm test`): [CONTRIBUTING.md](./CONTRIBUTING.md)
|
|
120
116
|
|
|
121
117
|
## License
|
|
122
118
|
|
|
@@ -125,7 +121,7 @@ MIT
|
|
|
125
121
|
<!-- leerness:project-readme:start -->
|
|
126
122
|
## Leerness Project Harness
|
|
127
123
|
|
|
128
|
-
이 프로젝트는 Leerness v1.36.
|
|
124
|
+
이 프로젝트는 Leerness v1.36.23 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
|
|
129
125
|
|
|
130
126
|
### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
|
|
131
127
|
|
|
@@ -179,7 +175,7 @@ leerness memory restore decision <date|title>
|
|
|
179
175
|
|
|
180
176
|
### MCP server (외부 AI 통합)
|
|
181
177
|
|
|
182
|
-
Leerness v1.36.
|
|
178
|
+
Leerness v1.36.23는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
|
|
183
179
|
|
|
184
180
|
```jsonc
|
|
185
181
|
// 카테고리별
|
|
@@ -200,7 +196,7 @@ Leerness v1.36.21는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
|
|
|
200
196
|
`<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
|
|
201
197
|
1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) stress-v* 신규 작성 + 누적 회귀 → 4) e2e 219/219 → 5) npm pack + git tag + GitHub release → 6) main 자동 push (1.9.140+) → 7) session close → 8) 다음 라운드 예약.
|
|
202
198
|
|
|
203
|
-
현재 누적: **70 라운드 (1.9.40 → 1.36.
|
|
199
|
+
현재 누적: **70 라운드 (1.9.40 → 1.36.23)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
|
|
204
200
|
|
|
205
201
|
### 성능 가이드 (1.9.140 측정)
|
|
206
202
|
|
|
@@ -238,6 +234,6 @@ leerness release pack --close --auto-main-push
|
|
|
238
234
|
- `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
|
|
239
235
|
- `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
|
|
240
236
|
|
|
241
|
-
Last synced by Leerness v1.36.
|
|
237
|
+
Last synced by Leerness v1.36.23: 2026-07-15
|
|
242
238
|
<!-- leerness:project-readme:end -->
|
|
243
239
|
|
package/bin/leerness.js
CHANGED
|
@@ -30,10 +30,11 @@ const { _isSecretKey, _isPlaceholderSecret, _looksSecretLike, _mergeLines, _merg
|
|
|
30
30
|
// 1.9.304 (UR-0025): 순수 분석/검증 함수 모듈 분리.
|
|
31
31
|
const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInGit, _epistemicHonestyCheck } = require('../lib/analyzers');
|
|
32
32
|
// 1.9.295 (UR-0025 4단계): 정적 데이터 카탈로그 모듈 분리 (비파괴, require-based).
|
|
33
|
-
const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _TOOL_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, SKILL_CATALOG_PRESETS } = require('../lib/catalogs'); // 1.9.344/368/369 (UR-0025): catalog 분리 · 1.11.4 (UR-0007): _TOOL_CATALOG
|
|
33
|
+
const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _TOOL_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, MEMORY_SYNONYMS, SKILL_CATALOG_PRESETS } = require('../lib/catalogs'); // 1.9.344/368/369 (UR-0025): catalog 분리 · 1.11.4 (UR-0007): _TOOL_CATALOG
|
|
34
|
+
const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
|
|
34
35
|
const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
|
|
35
36
|
|
|
36
|
-
const VERSION = '1.36.
|
|
37
|
+
const VERSION = '1.36.23';
|
|
37
38
|
|
|
38
39
|
// 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
|
|
39
40
|
// 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
|
|
@@ -253,7 +254,7 @@ function detectLanguageValue(root, value = 'auto') {
|
|
|
253
254
|
// ③ en 폴백
|
|
254
255
|
return 'en';
|
|
255
256
|
}
|
|
256
|
-
// 1.20.2 (UR-0010 CLI 영어화 Phase 1): UI 출력 언어 해석 —
|
|
257
|
+
// 1.20.2 (UR-0010 CLI 영어화 Phase 1): UI 출력 언어 해석 — 기본 ko(기존 사용자 기본값), 영어는 --language en / LEERNESS_LANG=en.
|
|
257
258
|
// 우선순위: --language 플래그 > LEERNESS_LANG env > .harness/manifest.json 의 language(init 선택) > 'ko'.
|
|
258
259
|
// (system locale 은 의도적으로 미사용 — 영어 OS 한국 사용자 놀람 방지. 영어는 명시 opt-in.)
|
|
259
260
|
function _uiLang(root) {
|
|
@@ -2982,6 +2983,52 @@ function _selfTestCases() {
|
|
|
2982
2983
|
const encOk = s.includes("(result.applied || []).some(a => a.action === 'failed')) process.exitCode = 1"); // encoding: 실패 exit1
|
|
2983
2984
|
return reuseOk && releaseOk && encOk;
|
|
2984
2985
|
} },
|
|
2986
|
+
{ name: 'memory search 랭킹/동의어 (1.36.23): 한국어 조사 회귀 0 + 동의어 리콜 순증 + BM25 단조성 — 순수 행위검사', run: () => {
|
|
2987
|
+
const sc = require('../lib/search-core');
|
|
2988
|
+
const { MEMORY_SYNONYMS } = require('../lib/catalogs');
|
|
2989
|
+
if (typeof sc.scoreHits !== 'function' || typeof sc.expandQuery !== 'function') return false;
|
|
2990
|
+
// (a) ★ 한국어 조사 회귀 금지 — 이 라운드의 최대 리스크. 토큰화 리콜로 바꿨다면 여기서 죽는다.
|
|
2991
|
+
// '핸드오프' 쿼리가 '핸드오프를…' 문서에 반드시 스코어를 준다(랭킹 토크나이저의 prefix 변형).
|
|
2992
|
+
const ko = sc.scoreHits('핸드오프', [{ text: '핸드오프를 자동화하기로 결정했다' }]);
|
|
2993
|
+
const koOk = ko.length === 1 && ko[0].score > 0;
|
|
2994
|
+
// 리콜 자체는 caller 의 substring 이 담당 — 안전판 확인(토크나이저가 조사를 못 떼도 substring 은 찾는다)
|
|
2995
|
+
const substringStillFinds = new RegExp('핸드오프', 'i').test('핸드오프를 자동화하기로 결정했다');
|
|
2996
|
+
// (b) 동의어 확장은 "순증"만 — 원본이 항상 포함되고(리콜 감소 0), 양방향
|
|
2997
|
+
const ex1 = sc.expandQuery('핸드오프', MEMORY_SYNONYMS);
|
|
2998
|
+
const ex2 = sc.expandQuery('handoff', MEMORY_SYNONYMS);
|
|
2999
|
+
const superset = ex1[0] === '핸드오프' && ex1.includes('handoff') && ex2[0] === 'handoff' && ex2.includes('핸드오프');
|
|
3000
|
+
const unknownKeeps = JSON.stringify(sc.expandQuery('zzz없는말', MEMORY_SYNONYMS)) === JSON.stringify(['zzz없는말']); // 모르는 말은 원본 그대로
|
|
3001
|
+
// (c) BM25 단조성: tf↑ → score↑ / 희귀어(idf↑)가 흔한 어휘보다 높은 점수
|
|
3002
|
+
const tfLow = sc.scoreHits('alpha', [{ text: 'alpha beta gamma delta' }])[0].score;
|
|
3003
|
+
const tfHigh = sc.scoreHits('alpha', [{ text: 'alpha alpha alpha beta gamma delta' }])[0].score;
|
|
3004
|
+
const monotonic = tfHigh > tfLow;
|
|
3005
|
+
const corpus = [{ text: 'rare unique term' }, { text: 'common word here' }, { text: 'common word there' }, { text: 'common word everywhere' }];
|
|
3006
|
+
const rareScore = sc.scoreHits('rare', corpus)[0].score;
|
|
3007
|
+
const commonScore = sc.scoreHits('common', corpus)[1].score;
|
|
3008
|
+
const idfOk = rareScore > commonScore;
|
|
3009
|
+
// (d) 0건 제안은 실제 어휘에서만(지어내지 않음)
|
|
3010
|
+
const sug = sc.suggestTerms('핸드', new Set(['핸드오프', '무관어']), { limit: 3 });
|
|
3011
|
+
const sugOk = sug.includes('핸드오프') && !sug.includes('무관어');
|
|
3012
|
+
// 배선 가드: 리콜은 substring(OR), 랭킹은 BM25, limit 은 정렬 "후"
|
|
3013
|
+
const s = read(__filename);
|
|
3014
|
+
const wired = s.includes('const _isHit = line => res.some(r => r.test(line));') && s.includes('_scoreHits(query, raw.map') && s.includes('.slice(0, _limit)');
|
|
3015
|
+
return koOk && substringStillFinds && superset && unknownKeeps && monotonic && idfOk && sugOk && wired;
|
|
3016
|
+
} },
|
|
3017
|
+
{ name: 'SessionStart 컨텍스트 주입 (1.36.22, obra/superpowers 구조 검토): hook 전용 표면 + 쓰기0 가드 + matcher 에 compact 포함 + opt-out — 소스가드', run: () => {
|
|
3018
|
+
const s = read(__filename);
|
|
3019
|
+
const surface = typeof hookSessionStartCmd === 'function' && s.includes("cmd === 'hook' && args[1] === 'session-start'");
|
|
3020
|
+
// ★ 핵심 불변식: hook 은 handoff 를 부르지 않는다(부르면 last-handoff stamp 로 detector 3종 오염) + record 는 가드 뒤에만
|
|
3021
|
+
const zeroWrite = s.includes("if (!has('--no-record') && process.env.LEERNESS_HOOK !== '1') { try { _recordLastHandoff(root); } catch {} }");
|
|
3022
|
+
// split-literal: 앵커를 통짜 문자열로 두면 이 selftest 줄 자신이 먼저 매칭돼 43자짜리 자기 슬라이스를 잡는다(자기참조 트랩, UR-0009 계열).
|
|
3023
|
+
const hookBody = s.slice(s.indexOf('function ' + 'hookSessionStartCmd'), s.indexOf('function ' + 'handoffCmd'));
|
|
3024
|
+
const noHandoffCall = hookBody.length > 100 && !/handoffCmd\(|_recordLastHandoff\(|writeUtf8\(|mkdirp\(/.test(hookBody); // 쓰기/handoff 호출 0
|
|
3025
|
+
const jsonContract = hookBody.includes("hookEventName: 'SessionStart'") && hookBody.includes('additionalContext');
|
|
3026
|
+
const optOut = hookBody.includes("has('--no-context-inject')") && hookBody.includes("LEERNESS_NO_CONTEXT_INJECT");
|
|
3027
|
+
const selfLabel = hookBody.includes('위는 요약'); // 1.9.272 투명성: 요약임을 자기 라벨링
|
|
3028
|
+
const matcherCompact = s.includes("{ matcher: 'startup|clear|compact', command: 'leerness hook session-start' }"); // 실측 결함(compaction) 커버
|
|
3029
|
+
const idempotent = s.includes("some(h => h.command && h.command.includes('leerness hook session-start'))");
|
|
3030
|
+
return surface && zeroWrite && noHandoffCall && jsonContract && optOut && selfLabel && matcherCompact && idempotent;
|
|
3031
|
+
} },
|
|
2985
3032
|
{ name: '경로/EOL/형태 견고성 4종 (1.36.21, 전수 sweep P2): handoff·env detect 없는경로 가드 + CRLF plan tasks + migrate 오타설치 차단 + user-requests 미인식 쓰기차단 — 소스가드 + 순수 행위', run: () => {
|
|
2986
3033
|
const s = read(__filename);
|
|
2987
3034
|
// B: 없는 경로에 트리/.harness 를 만들고 빈 프로젝트로 보고하던 것 차단 (UR-0136 관례 정렬)
|
|
@@ -4177,7 +4224,7 @@ function _selfTestCases() {
|
|
|
4177
4224
|
{ name: 'CLI 영어화 Phase 1 (1.20.2, UR-0010): _uiLang 해석(flag>env>manifest>ko) + 첫화면 _t 적용 (행위+소스)', run: () => {
|
|
4178
4225
|
const save = process.argv; const saveEnv = process.env.LEERNESS_LANG;
|
|
4179
4226
|
try {
|
|
4180
|
-
// 기본 ko (
|
|
4227
|
+
// 기본 ko (기존 사용자 기본값 보존 — 영어는 --language en / LEERNESS_LANG=en)
|
|
4181
4228
|
process.argv = ['node', 'h', 'handoff']; delete process.env.LEERNESS_LANG;
|
|
4182
4229
|
if (_uiLang('/no/such/dir') !== 'ko') return false;
|
|
4183
4230
|
// --language en 플래그 → en
|
|
@@ -9026,17 +9073,32 @@ function memorySearch(root, query) {
|
|
|
9026
9073
|
if (!query) { failJson(jsonMode, 'query_required', 'query required (e.g., memory search "키워드")'); return; }
|
|
9027
9074
|
// 1.13.1 (15th 블라인드 리뷰 P1, Sonnet): lessons.md + rules.md 누락 수정 — memory search 가 5종 메모리 표면을 표방하나 lesson/rule 을 검색 못 해(lesson add/rule add 로 저장한 교훈·룰이 'no matches') 모순감지 핵심 용도가 훼손됐음.
|
|
9028
9075
|
const files = ['.harness/decisions.md','.harness/lessons.md','.harness/rules.md','.harness/task-log.md','.harness/session-handoff.md','.harness/progress-tracker.md','.harness/plan.md','.harness/review-evidence.md','.harness/architecture.md'];
|
|
9029
|
-
|
|
9076
|
+
// 1.36.23: (1) 동의어 확장으로 리콜 "순증" — 실측 `핸드오프` 0 hits / `handoff` 37 hits 비대칭 해소. 기존 regex 에 OR 로 더하므로
|
|
9077
|
+
// 현 동작의 상위집합(히트 감소 0). --no-synonyms 로 종전 동작. (2) 히트를 BM25 로 정렬한 뒤 --limit 적용 — 종전엔 파일당
|
|
9078
|
+
// "앞 N건" 임의 절단이었다(최고 N건이 아니라). ★ 리콜은 substring 유지: 한국어 조사 때문에 토큰화 리콜은 지금 찾히는 문서를
|
|
9079
|
+
// 0건으로 만든다(lib/search-core.js 주석 참조) — BM25 는 정렬 전용이라 토크나이저가 틀려도 히트가 사라지지 않는다.
|
|
9080
|
+
const useSyn = !has('--no-synonyms');
|
|
9081
|
+
const terms = useSyn ? _expandQuery(query, MEMORY_SYNONYMS) : [query];
|
|
9082
|
+
const res = terms.map(t => new RegExp(String(t).replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'), 'i'));
|
|
9083
|
+
const _isHit = line => res.some(r => r.test(line)); // 리콜 = substring OR(원본 + 동의어) — 한국어 조사 안전판
|
|
9084
|
+
const _limit = _parseLimit(arg('--limit','5'),5);
|
|
9030
9085
|
let total = 0;
|
|
9086
|
+
const _blocks = []; // 파일별 결과 — best score 순으로 출력(관련 높은 파일 먼저)
|
|
9031
9087
|
for (const f of files) {
|
|
9032
9088
|
const p = path.join(root, f); if (!exists(p)) continue;
|
|
9033
9089
|
const lines = read(p).split('\n');
|
|
9034
|
-
const
|
|
9035
|
-
if (
|
|
9036
|
-
|
|
9037
|
-
|
|
9038
|
-
|
|
9039
|
-
}
|
|
9090
|
+
const raw = lines.map((line, i) => ({ line, i })).filter(x => _isHit(x.line));
|
|
9091
|
+
if (!raw.length) continue;
|
|
9092
|
+
const scored = _scoreHits(query, raw.map(x => ({ line: x.i + 1, text: x.line.trim() })))
|
|
9093
|
+
.sort((a, b) => (b.score - a.score) || (a.line - b.line)); // 동점이면 원래 순서(결정적)
|
|
9094
|
+
const top = scored.slice(0, _limit);
|
|
9095
|
+
_blocks.push({ file: f, top, best: top.length ? top[0].score : 0 });
|
|
9096
|
+
total += raw.length;
|
|
9097
|
+
}
|
|
9098
|
+
_blocks.sort((a, b) => (b.best - a.best) || a.file.localeCompare(b.file));
|
|
9099
|
+
for (const blk of _blocks) {
|
|
9100
|
+
if (!jsonMode) log(`\n# ${blk.file}`);
|
|
9101
|
+
for (const h of blk.top) { if (!jsonMode) log(` L${h.line}: ${h.text}`); results.push({ file: blk.file, line: h.line, text: h.text, score: h.score }); }
|
|
9040
9102
|
}
|
|
9041
9103
|
// 1.9.25: --include-code 옵션 — 실제 소스 코드 본문도 검색 (src/tests/bin)
|
|
9042
9104
|
// 이전 모순 감지 0/5 → 5/5의 핵심 보완
|
|
@@ -9053,10 +9115,13 @@ function memorySearch(root, query) {
|
|
|
9053
9115
|
if (!/\.(js|ts|jsx|tsx|gd|cs|py|rb|go|rs|md|html|css|json)$/i.test(e.name)) continue;
|
|
9054
9116
|
let txt; try { txt = read(p); } catch { continue; }
|
|
9055
9117
|
const lines = txt.split('\n');
|
|
9056
|
-
|
|
9118
|
+
// 1.36.23: 메모리 파일 경로와 동일 처리로 통일 — 동의어 리콜 + BM25 정렬 후 limit(종전엔 단일 regex + 앞 N건 절단이라
|
|
9119
|
+
// JSON results 배열에 score 있는 항목/없는 항목이 섞였다). 21k줄 소스에서 "앞 5건"은 특히 자의적이었음.
|
|
9120
|
+
const hits = _scoreHits(query, lines.map((line, i) => ({ line: i + 1, text: line.trim().slice(0, 160), _raw: line }))
|
|
9121
|
+
.filter(x => _isHit(x._raw))).sort((a, b) => (b.score - a.score) || (a.line - b.line));
|
|
9057
9122
|
if (hits.length) {
|
|
9058
9123
|
if (!jsonMode) log(`\n# ${rel(root, p)}`);
|
|
9059
|
-
for (const h of hits.slice(0,
|
|
9124
|
+
for (const h of hits.slice(0, _limit)) { if (!jsonMode) log(` L${h.line}: ${h.text}`); results.push({ file: rel(root, p), line: h.line, text: h.text, score: h.score }); }
|
|
9060
9125
|
total += hits.length;
|
|
9061
9126
|
}
|
|
9062
9127
|
}
|
|
@@ -9064,9 +9129,22 @@ function memorySearch(root, query) {
|
|
|
9064
9129
|
walkCodeDir(dp);
|
|
9065
9130
|
}
|
|
9066
9131
|
}
|
|
9067
|
-
|
|
9068
|
-
|
|
9069
|
-
|
|
9132
|
+
// 1.36.23: 0건이면 실제 색인 어휘로 근접어 제안(지어내지 않음 — vocab 은 방금 읽은 메모리 파일에서 추출).
|
|
9133
|
+
let _suggest = [];
|
|
9134
|
+
if (total === 0) {
|
|
9135
|
+
try {
|
|
9136
|
+
const vocab = new Set();
|
|
9137
|
+
for (const f of files) {
|
|
9138
|
+
const p = path.join(root, f); if (!exists(p)) continue;
|
|
9139
|
+
for (const t of _tokenizeForRank(read(p))) if (t.length >= 2) vocab.add(t);
|
|
9140
|
+
}
|
|
9141
|
+
_suggest = _suggestTerms(query, vocab, { limit: 5 });
|
|
9142
|
+
} catch {}
|
|
9143
|
+
}
|
|
9144
|
+
// 1.36.23: total(전체 히트) 과 results.length(표시분) 불일치를 자기정합화 — 종전엔 total 16 / results 15 처럼 어긋났다.
|
|
9145
|
+
if (jsonMode) { log(JSON.stringify({ version: VERSION, query, terms: useSyn && terms.length > 1 ? terms : undefined, total, shown: results.length, truncated: total > results.length, suggestions: _suggest.length ? _suggest : undefined, includeCode: has('--include-code'), results }, null, 2)); return; }
|
|
9146
|
+
if (total === 0) { log('(no matches)'); if (_suggest.length) log(` 혹시 이건가요: ${_suggest.join(', ')}`); }
|
|
9147
|
+
else log(`\n${total} matches${total > results.length ? ` (상위 ${results.length}건 표시 — --limit 로 조절)` : ''}${has('--include-code') ? ' · 소스 코드 포함' : ''}${useSyn && terms.length > 1 ? ` · 동의어: ${terms.slice(1).join('/')}` : ''}`);
|
|
9070
9148
|
}
|
|
9071
9149
|
|
|
9072
9150
|
function handoff(root) {
|
|
@@ -9075,7 +9153,9 @@ function handoff(root) {
|
|
|
9075
9153
|
// detector 가 prior gap 을 정확히 측정 (overwrite 전 값 필요). 함수 최상단에 선언.
|
|
9076
9154
|
let _priorHandoffGap = { hasLast: false };
|
|
9077
9155
|
try { _priorHandoffGap = _getLastHandoffGap(root); } catch {}
|
|
9078
|
-
|
|
9156
|
+
// 1.36.22: 읽기전용/hook 호출이 last-handoff stamp 를 찍으면 detector 3종(핸드오프 넛지·abnormal shutdown·R-0001 interval)이
|
|
9157
|
+
// "방금 handoff 했다"고 오판해 오염된다. --no-record / LEERNESS_HOOK=1 로 stamp 생략(기본 동작 불변).
|
|
9158
|
+
if (!has('--no-record') && process.env.LEERNESS_HOOK !== '1') { try { _recordLastHandoff(root); } catch {} }
|
|
9079
9159
|
// 1.9.203: auto-resume-plan 자동 로드 (사용자 명시 — 자동 모드 알람 트리거)
|
|
9080
9160
|
let _autoResumePlan = null;
|
|
9081
9161
|
try { _autoResumePlan = _loadAutoResumePlan(root); } catch {}
|
|
@@ -10701,6 +10781,35 @@ function _handoffWorkspace(rootBase) {
|
|
|
10701
10781
|
log(` - 새 패턴 추가 시 \`leerness reuse-map --all-apps\`로 중복 감지${sinceCutoff ? '' : ' / `--since 24h`로 최근 변경 추적'}`);
|
|
10702
10782
|
}
|
|
10703
10783
|
|
|
10784
|
+
// 1.36.22 (obra/superpowers 구조 검토 → 배선): SessionStart 컨텍스트 주입 전용 표면.
|
|
10785
|
+
// 문제(실측): compaction/resume 후 에이전트가 "세션 시작 handoff" 의례를 건너뛰어 컨텍스트를 잃는다.
|
|
10786
|
+
// superpowers 는 hook matcher 를 startup|clear|compact 로 걸어 하네스가 컨텍스트를 강제 적재한다(에이전트 자발성에 안 기댐).
|
|
10787
|
+
// leerness 는 hook 플럼빙(autoUpdateInstall)과 compact 신호(handoff --compact)를 이미 둘 다 갖고 있었고 배선만 없었음 → 그 배선.
|
|
10788
|
+
// ★ 여기서 handoff 를 호출하지 않는다: handoff 는 last-handoff stamp 를 찍어 detector 3종을 오염시킴. 동일 파일 로더만 재사용
|
|
10789
|
+
// (subprocess 0 · 쓰기 0 · last-handoff.json 미접촉). 어떤 예외에서도 stdout 무출력 + exit 0 — 세션 시작을 절대 깨지 않는다.
|
|
10790
|
+
function hookSessionStartCmd(root) {
|
|
10791
|
+
if (has('--no-context-inject') || process.env.LEERNESS_NO_CONTEXT_INJECT === '1') return; // opt-out → 무출력 exit 0
|
|
10792
|
+
try {
|
|
10793
|
+
root = absRoot(root || process.cwd());
|
|
10794
|
+
if (!exists(root) || !exists(path.join(root, '.harness'))) return; // 미초기화/없는 경로 → 조용히 무출력(hook 은 어디서나 실행됨)
|
|
10795
|
+
const rows = readProgressRows(root);
|
|
10796
|
+
const byStatus = {};
|
|
10797
|
+
for (const r of rows) byStatus[r.status] = (byStatus[r.status] || 0) + 1;
|
|
10798
|
+
const done = byStatus['done'] || 0, wip = byStatus['in-progress'] || 0, blocked = byStatus['blocked'] || 0;
|
|
10799
|
+
const pct = rows.length ? Math.round(done / rows.length * 100) : 0;
|
|
10800
|
+
const openReq = (_loadUserRequests(root).requests || []).filter(r => r && (r.status === 'open' || r.status === 'in-progress')).length;
|
|
10801
|
+
const nx = rows.find(r => r.status === 'in-progress') || rows.find(r => r.status === 'planned') || null;
|
|
10802
|
+
const flags = [];
|
|
10803
|
+
if (openReq) flags.push(`📥 미답 요청 ${openReq}`);
|
|
10804
|
+
if (blocked) flags.push(`🚫 blocked ${blocked}`);
|
|
10805
|
+
const lines = [_lineSafe(`[leerness] ${detectProjectName(root)} · ${done}/${rows.length}(${pct}%) done · WIP ${wip}${flags.length ? ' · ' + flags.join(' · ') : ''}`)];
|
|
10806
|
+
if (nx) lines.push(_lineSafe(`다음: ${nx.id} [${nx.status}] ${nx.nextAction || nx.request || ''}`).slice(0, 200));
|
|
10807
|
+
// 자기 라벨링(1.9.272 투명성 불변식): 요약임을 밝히고 전체 컨텍스트 경로와 끄는 법을 함께 준다.
|
|
10808
|
+
lines.push('⚠ 위는 요약 — 전체 컨텍스트는 `leerness handoff .` 실행. (이 주입 끄기: LEERNESS_NO_CONTEXT_INJECT=1)');
|
|
10809
|
+
// Claude Code SessionStart hook 규약. JSON.stringify 가 이스케이프 담당(수동 백슬래시/따옴표 처리 불필요).
|
|
10810
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: lines.join('\n') } }) + '\n');
|
|
10811
|
+
} catch { /* 세션 시작을 절대 깨지 않는다 — 무출력 exit 0 */ }
|
|
10812
|
+
}
|
|
10704
10813
|
function handoffCmd(root) {
|
|
10705
10814
|
// 1.36.21 (전수 sweep P2/high): 존재하지 않는 경로에 디렉토리 트리 + .harness 를 "생성"하고 깨끗한 빈 프로젝트로 보고하던 것 차단.
|
|
10706
10815
|
// 오타 경로에 handoff 하면 쓰레기 트리가 생기고, --json 은 ok/error 없는 정상 JSON + exit 0 이라 기계 소비자가 오타를 감지 못 했음.
|
|
@@ -12910,7 +13019,7 @@ function _banner(opts = {}) {
|
|
|
12910
13019
|
cprint(' ' + C.green(padded) + C.dim('# ' + desc));
|
|
12911
13020
|
};
|
|
12912
13021
|
|
|
12913
|
-
// 1.20.2 (UR-0010 Phase 1): 첫 화면 배너 — UI 언어(
|
|
13022
|
+
// 1.20.2 (UR-0010 Phase 1): 첫 화면 배너 — UI 언어(기본 ko, 영어 opt-in) 적용.
|
|
12914
13023
|
const L = _uiLang(arg('--path', process.cwd()));
|
|
12915
13024
|
const t = (ko, en) => (L === 'en' ? en : ko);
|
|
12916
13025
|
cprint('');
|
|
@@ -16012,6 +16121,11 @@ function autoUpdateInstall(root) {
|
|
|
16012
16121
|
if (!settings.hooks.SessionStart.some(h => h.command && h.command.includes('leerness update'))) {
|
|
16013
16122
|
settings.hooks.SessionStart.push({ matcher: '*', command: 'leerness update --check --quiet' });
|
|
16014
16123
|
}
|
|
16124
|
+
// 1.36.22: 2번째 SessionStart hook — 컨텍스트 주입. matcher 에 compact 를 포함하는 게 핵심(실측 결함: compaction/resume 후
|
|
16125
|
+
// 에이전트가 세션시작 handoff 의례를 건너뜀). 기존 설치는 이 멱등 push 로 자동 취득(1.9.364 업그레이드 패턴 재사용).
|
|
16126
|
+
if (!settings.hooks.SessionStart.some(h => h.command && h.command.includes('leerness hook session-start'))) {
|
|
16127
|
+
settings.hooks.SessionStart.push({ matcher: 'startup|clear|compact', command: 'leerness hook session-start' });
|
|
16128
|
+
}
|
|
16015
16129
|
writeUtf8(settingsFile, JSON.stringify(settings, null, 2) + '\n');
|
|
16016
16130
|
writeUtf8(path.join(root, '.claude/commands/update.md'),
|
|
16017
16131
|
`# /update\n\nleerness 자동 업데이트 (감지 → 마이그레이션 → 검증).\n\n\`\`\`\n!leerness update --yes\n\`\`\`\n\n체크만:\n\n\`\`\`\n!leerness update --check\n\`\`\`\n`);
|
|
@@ -16022,6 +16136,8 @@ function autoUpdateInstall(root) {
|
|
|
16022
16136
|
// 1.9.272 (GPT-5.5 외부 리뷰 반영): hook 설치 투명성 — 무엇을/왜/어떻게 끄는지 명시.
|
|
16023
16137
|
// 1.9.364 (4번째 외부평가 9.3): 비침투 — 세션 시작 시 --quiet 로 up-to-date 면 아무 출력 없음, 업데이트 가능 시에만 1줄.
|
|
16024
16138
|
log(` ⓘ 이 hook 은 Claude Code 세션 시작 시 \`leerness update --check --quiet\` 를 1회 실행합니다 (최신이면 무음, 업데이트 가능 시에만 1줄 통지 · 자동 설치는 안 함).`);
|
|
16139
|
+
// 1.36.22: 2번째 hook 투명성 — 무엇을(요약 주입)/언제(startup·clear·compact)/왜(compaction 후 컨텍스트 유실)/어떻게 끄는지.
|
|
16140
|
+
log(` ⓘ 2번째 hook \`leerness hook session-start\` 는 세션 시작·clear·compaction 시 진행상황 요약 2~3줄을 컨텍스트에 주입합니다 (쓰기 0 · 미초기화 프로젝트면 무음 · 끄기: LEERNESS_NO_CONTEXT_INJECT=1).`);
|
|
16025
16141
|
log(` ⓘ 제거: ${rel(root, settingsFile)} 의 hooks.SessionStart 항목 삭제 · 설치 시 끄기: leerness init . --no-auto-update`);
|
|
16026
16142
|
}
|
|
16027
16143
|
|
|
@@ -21097,6 +21213,7 @@ async function main() {
|
|
|
21097
21213
|
if (cmd === 'encoding' && args[1] === 'check') return encodingCheck(arg('--path', args[2] || process.cwd()));
|
|
21098
21214
|
if (cmd === 'lazy' && args[1] === 'detect') return lazyDetect(_resolveRoot(args[2]), { json: has('--json') });
|
|
21099
21215
|
if (cmd === 'memory' && args[1] === 'search') return memorySearch(arg('--path', process.cwd()), args.slice(2).join(' '));
|
|
21216
|
+
if (cmd === 'hook' && args[1] === 'session-start') return hookSessionStartCmd(arg('--path', args[2] || process.cwd())); // 1.36.22: SessionStart 컨텍스트 주입(쓰기 0)
|
|
21100
21217
|
if (cmd === 'handoff') { const _hp = arg('--path', args[1] || process.cwd()); const _hr = handoffCmd(_hp); _maybeAutoGraph(_hp); return _hr; }
|
|
21101
21218
|
if (cmd === 'reuse-map') return reuseMapCmd(arg('--path', args[1] || process.cwd()));
|
|
21102
21219
|
if (cmd === 'verify-claim') { const _p = arg('--path', process.cwd()); if (args[1] === '--all' || has('--all')) return verifyClaimAllCmd(_p); return verifyClaimCmd(_p, args[1]); } // 1.33.2: --all → 모든 done 주장 일괄 검증
|
package/lib/catalogs.js
CHANGED
|
@@ -413,6 +413,22 @@ const MERGE_OVERWRITE_FILES = new Set([
|
|
|
413
413
|
const REQUIRED_WORKSPACE_FILES = ['.harness/plan.md', '.harness/progress-tracker.md', '.harness/guideline.md', '.harness/protected-files.md', '.harness/design-system.md', '.harness/anti-lazy-work-policy.md', '.harness/session-handoff.md', '.harness/current-state.md', 'AGENTS.md'];
|
|
414
414
|
|
|
415
415
|
// 1.9.381 (UR-0025): 키워드 추출 stopwords — handoff 자동회수 / lessons 키워드 추출 단일출처 (harness 2중 중복 제거).
|
|
416
|
+
// 1.36.23: memory search 동의어 — 같은 개념을 다른 언어로 물으면 결과가 갈리던 비대칭 해소. 실측: `handoff` 37 hits /
|
|
417
|
+
// `핸드오프` **0 hits**, `lesson` 17 / `교훈` 3. 메모리 내용은 언어가 섞이기 마련이라(코드·커밋은 영어, 결정·교훈은 모국어)
|
|
418
|
+
// 한쪽 표기만 아는 사용자/에이전트가 존재하는 기록을 못 찾았다. leerness 도메인어만 담는다(검색 대상이 .harness 메모리
|
|
419
|
+
// 표면이므로 일반 UI/CSS 어휘는 잡음). 양방향으로 적는다 — 어느 쪽으로 물어도 같은 결과.
|
|
420
|
+
const MEMORY_SYNONYMS = {
|
|
421
|
+
'회고': ['retro', 'retrospective'], retro: ['회고'], retrospective: ['회고'],
|
|
422
|
+
'교훈': ['lesson'], lesson: ['교훈'], lessons: ['교훈'],
|
|
423
|
+
'룰': ['rule'], '규칙': ['rule'], rule: ['룰', '규칙'], rules: ['룰', '규칙'],
|
|
424
|
+
'핸드오프': ['handoff'], '인수인계': ['handoff'], handoff: ['핸드오프', '인수인계'],
|
|
425
|
+
'계획': ['plan'], plan: ['계획'], '마일스톤': ['milestone'], milestone: ['마일스톤'],
|
|
426
|
+
'결정': ['decision'], decision: ['결정'], decisions: ['결정'],
|
|
427
|
+
'작업': ['task'], task: ['작업'], tasks: ['작업'],
|
|
428
|
+
'검증': ['verify', 'verification'], verify: ['검증'],
|
|
429
|
+
'보안': ['security'], security: ['보안'], '시크릿': ['secret'], secret: ['시크릿'],
|
|
430
|
+
'접근성': ['a11y', 'accessibility'], a11y: ['접근성', 'accessibility'], accessibility: ['접근성', 'a11y'],
|
|
431
|
+
};
|
|
416
432
|
const KEYWORD_STOPWORDS = new Set([
|
|
417
433
|
'이런', '저런', '하다', '하고', '있는', '하지', '에서',
|
|
418
434
|
'작업', '구현', '추가', '진행', '수정', '변경', '검토', '확인',
|
|
@@ -493,4 +509,4 @@ const _TOOL_CATALOG = {
|
|
|
493
509
|
}
|
|
494
510
|
};
|
|
495
511
|
|
|
496
|
-
module.exports = { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _TOOL_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, SKILL_CATALOG_PRESETS };
|
|
512
|
+
module.exports = { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _TOOL_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, MEMORY_SYNONYMS, SKILL_CATALOG_PRESETS };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// lib/search-core.js — memory search 랭킹 코어 (1.36.23). 순수함수 · 0-deps · 부작용 0.
|
|
2
|
+
// require.main 가드(1.9.255) 덕에 단위테스트 가능. 산술(Math.log)만 사용 — 외부 의존성 없음.
|
|
3
|
+
//
|
|
4
|
+
// ★ 설계 불변식 — 이 모듈은 "정렬"만 한다. "무엇이 히트냐"(리콜)는 caller 의 substring regex 가 담당.
|
|
5
|
+
// 이유: 토큰화 리콜은 **언어별 형태론 가정**을 끌어들인다. substring 은 언어 중립적이고 현 동작의 상위집합이라 안전하다.
|
|
6
|
+
// 실측 사례(교착어): `memory search "핸드오프"` 는 substring 이라 "핸드오프**를** 자동화…" 를 찾지만, 공백 토큰화 BM25 로
|
|
7
|
+
// 리콜을 대체하면 doc 토큰이 ['핸드오프를',…] 이라 쿼리와 불일치 → **지금 찾히는 문서가 0건**이 된다. 조사·굴절·합성어가
|
|
8
|
+
// 있는 어떤 언어에서도 같은 계열의 손실이 난다(독일어 합성어, 터키어 교착 등). 형태소 분석기는 의존성 0 원칙상 불가.
|
|
9
|
+
// 따라서 BM25 는 랭킹 레이어로만 두고 리콜은 substring 유지 → tokenizeForRank 가 틀려도 히트가 사라지지 않는 안전판
|
|
10
|
+
// (정렬 순서만 나빠질 뿐). 한글 prefix 변형은 그 근사치일 뿐 정확도 요구사항이 아니다.
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
// 랭킹 전용 토크나이저. 한글 토큰은 조사/어미 절단을 근사하려고 prefix 변형도 함께 방출한다
|
|
14
|
+
// ('핸드오프를' → 핸드오프를/핸드/핸드오/핸드오프) → 쿼리 '핸드오프' 가 스코어를 받는다.
|
|
15
|
+
// 상한(len<=16, prefix<=6개)으로 긴 문자열의 토큰 폭발을 막는다. 정렬 전용이라 근사로 충분.
|
|
16
|
+
function tokenizeForRank(s) {
|
|
17
|
+
const out = [];
|
|
18
|
+
const base = String(s || '').toLowerCase().split(/[^0-9a-z가-힣_]+/i).filter(Boolean);
|
|
19
|
+
for (const t of base) {
|
|
20
|
+
out.push(t);
|
|
21
|
+
if (t.length <= 16 && /^[가-힣]+$/.test(t) && t.length >= 3) {
|
|
22
|
+
const max = Math.min(t.length - 1, 2 + 6);
|
|
23
|
+
for (let k = 2; k <= max; k++) out.push(t.slice(0, k));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 동의어 확장 — 맵을 인자로 주입받아 순수성 유지(카탈로그 의존 X). 원본을 항상 첫 요소로 둔다.
|
|
30
|
+
function expandQuery(query, synonymMap) {
|
|
31
|
+
const q = String(query || '').trim();
|
|
32
|
+
if (!q) return [];
|
|
33
|
+
const key = q.toLowerCase();
|
|
34
|
+
const out = [q];
|
|
35
|
+
for (const s of (synonymMap && synonymMap[key]) || []) {
|
|
36
|
+
if (s && String(s).toLowerCase() !== key) out.push(String(s));
|
|
37
|
+
}
|
|
38
|
+
return [...new Set(out)];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// BM25 — 히트 집합 내 상대 랭킹용. docs = hits 자체(각 라인이 1문서). 희귀어를 포함한 히트가 위로 온다.
|
|
42
|
+
// k1=1.5 / b=0.75 (표준). 히트가 1건이면 idf 가 평탄해도 순서에 영향 없음.
|
|
43
|
+
function scoreHits(query, hits, opts = {}) {
|
|
44
|
+
const k1 = opts.k1 == null ? 1.5 : opts.k1;
|
|
45
|
+
const b = opts.b == null ? 0.75 : opts.b;
|
|
46
|
+
const qTokens = [...new Set(tokenizeForRank(query))];
|
|
47
|
+
const docs = hits.map(h => tokenizeForRank((h && h.text) || ''));
|
|
48
|
+
const N = docs.length || 1;
|
|
49
|
+
const avgdl = (docs.reduce((s, d) => s + d.length, 0) / N) || 1;
|
|
50
|
+
const df = Object.create(null);
|
|
51
|
+
for (const d of docs) for (const t of new Set(d)) df[t] = (df[t] || 0) + 1;
|
|
52
|
+
return hits.map((h, i) => {
|
|
53
|
+
const d = docs[i], dl = d.length || 1;
|
|
54
|
+
const tf = Object.create(null);
|
|
55
|
+
for (const t of d) tf[t] = (tf[t] || 0) + 1;
|
|
56
|
+
let score = 0;
|
|
57
|
+
for (const q of qTokens) {
|
|
58
|
+
const f = tf[q] || 0;
|
|
59
|
+
if (!f) continue;
|
|
60
|
+
const n = df[q] || 0;
|
|
61
|
+
const idf = Math.log(1 + (N - n + 0.5) / (n + 0.5));
|
|
62
|
+
score += idf * ((f * (k1 + 1)) / (f + k1 * (1 - b + b * (dl / avgdl))));
|
|
63
|
+
}
|
|
64
|
+
return Object.assign({}, h, { score: Math.round(score * 1000) / 1000 });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 0건일 때 근접어 제안 — 실제 색인 어휘(vocab)만 근거로. 지어내지 않는다.
|
|
69
|
+
function suggestTerms(query, vocab, opts = {}) {
|
|
70
|
+
const minPrefix = opts.minPrefix || 2;
|
|
71
|
+
const limit = opts.limit || 5;
|
|
72
|
+
const q = String(query || '').toLowerCase().trim();
|
|
73
|
+
if (q.length < minPrefix || !vocab) return [];
|
|
74
|
+
const pre = q.slice(0, Math.min(3, Math.max(minPrefix, q.length)));
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const v of vocab) {
|
|
77
|
+
const lv = String(v || '').toLowerCase();
|
|
78
|
+
if (!lv || lv === q) continue;
|
|
79
|
+
if (lv.startsWith(pre) || (lv.length >= minPrefix && q.startsWith(lv.slice(0, minPrefix)))) out.push(String(v));
|
|
80
|
+
if (out.length >= limit * 4) break;
|
|
81
|
+
}
|
|
82
|
+
return [...new Set(out)].slice(0, limit);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { tokenizeForRank, expandQuery, scoreHits, suggestTerms };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.36.23",
|
|
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",
|
|
7
7
|
"ai",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"docs",
|
|
44
44
|
"README.md",
|
|
45
45
|
"CHANGELOG.md",
|
|
46
|
+
"CONTRIBUTING.md",
|
|
46
47
|
"SECURITY.md",
|
|
47
48
|
"LICENSE"
|
|
48
49
|
],
|