leerness 1.36.22 → 1.36.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.24 — 2026-07-14 — requests 계열 positional 경로 인식 — 다른 프로젝트에 조용히 오기록되던 갭 (UR-0027 확장, 실피해 재현)
4
+
5
+ - **결함(실피해 재현)**: `requests` 계열만 positional 경로를 지원하지 않아 `requests add "text" C:/other/proj` 가 **경로 인자를 조용히 무시하고 cwd 프로젝트에 기록** 후 성공을 보고했다. `task`/`rule` 은 `_taskPositionalPath` 로 지원 → 표면 간 불일치가 실수를 유도(1.36.21 라운드에서 본인이 직접 당해 leerness-pkg 에 UR-0060 오기록·정리한 건).
6
+ - **수정(배선 1줄)**: rule 선례(`arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd()`)와 동일 배선. 인프라는 이미 전부 존재 — `_taskPositionalPath` 는 선행 구분자(path-like)만 인정해 ID(`UR-0001`)/텍스트/내부 슬래시 제목(`src/auth …`)을 경로로 오인하지 않고, `requests add` 는 `_parseAddTitle`(1.9.416)이 텍스트를 path-like 에서 끊어 텍스트/경로 분리도 기존과 동일. UR-0027 이 경고한 "존재 디렉토리 인식"(bare 이름) 은 도입하지 않음 — FP 없는 기존 계약 유지.
7
+ - **검증**: selftest 294/294(배선 소스가드 + 순수 행위 3종: 절대경로 인정/ID 미오인/내부슬래시 미오인). 실측 — 피해 케이스 재현: cwd=내 프로젝트에서 `requests add "…" <다른경로>` → **대상 프로젝트에 기록 + 내 프로젝트 바이트 불변**, list/drop positional 동작, `--path` 우선 유지, path-only 빈 텍스트 거부(exit 1) 무회귀. 게이트 e2e, 게시본 클린룸.
8
+
9
+ ## 1.36.23 — 2026-07-14 — memory search: 한국어↔영어 동의어(핸드오프 0→38) + BM25 랭킹("앞 N건"→"상위 N건") + JSON 정합
10
+
11
+ ui-ux-pro-max 의 BM25 검색엔진을 검토해 **정렬 레이어로만** 이식. 원안(BM25 를 리콜 엔진으로)은 부적합 — **토큰화 리콜은 언어별 형태론 가정을 끌어들여** 기존 히트를 잃는다. 실측: `memory search "핸드오프"` 는 substring 이라 "핸드오프**를** 자동화…"를 찾지만, 공백 토큰화 BM25 로 리콜을 대체하면 doc 토큰이 `['핸드오프를',…]` 이라 쿼리와 불일치해 **지금 찾히는 문서가 0건**이 된다(교착어 조사가 대표 사례이고, 굴절·합성어 언어에서도 같은 계열의 손실). 형태소 분석기는 의존성 0 위반이라 불가 → substring 은 **언어 중립적이고 상위집합 안전**하므로 리콜로 유지한다.
12
+
13
+ - **`lib/search-core.js` 신설** (순수함수 · 0-deps · 산술만): `tokenizeForRank`(한글은 조사 절단 근사로 prefix 변형 방출 — `핸드오프를` 이 `핸드오프` 쿼리에 스코어를 받음) · `expandQuery`(동의어 맵 주입 → 순수) · `scoreHits`(BM25 k1=1.5/b=0.75) · `suggestTerms`.
14
+ - ★ **설계 불변식**: BM25 는 정렬만, 리콜은 substring 유지 → **토크나이저가 틀려도 히트가 0이 되지 않는다**(정렬 순서만 나빠질 뿐). 안전판.
15
+ - **동의어(`MEMORY_SYNONYMS`, catalogs 단일출처)** — 같은 개념을 다른 언어로 물으면 결과가 갈리던 비대칭 해소: `핸드오프` **0 → 38** hits, `교훈` 3 → 18, `결정` 4 → 18, `작업` 11 → 27, `계획` 5 → 19. 메모리 내용은 언어가 섞이기 마련이라(코드·커밋은 영어, 결정·교훈은 모국어) 한쪽 표기만 아는 사용자/에이전트가 존재하는 기록을 못 찾았다. 양방향(어느 쪽으로 물어도 동일). `--no-synonyms` 로 종전 동작(실측 재현 확인).
16
+ - **BM25 랭킹** — 종전엔 `--limit`(기본 5)이 파일당 **"앞 5건"** 임의 절단이었다(최고 5건이 아니라). 이제 히트를 전부 모아 스코어 → 정렬 → limit ⇒ **"상위 N건"**. 파일도 최고점수 순. `--include-code`(21k줄 소스)에서 특히 자의적이던 절단 해소.
17
+ - **`--json` 정합** — 종전 `total: 16 / results.length: 15` 처럼 어긋나던 것을 `total`(전체 히트) + `shown` + `truncated` 로 자기정합화. 모든 result 에 `score` 추가(메모리/코드 경로 형태 통일 — 종전엔 코드 경로만 score 없이 섞였다).
18
+ - **0건 근접어 제안** — 실제 색인 어휘에서만 추출(지어내지 않음): `혹시 이건가요: …`.
19
+ - **검증**: selftest 293/293 — 판정단이 채택 조건으로 건 가드 3종을 그대로 박음: (a) **한국어 조사 회귀 금지**(`핸드오프` 쿼리가 `핸드오프를…` 에 반드시 스코어 + substring 안전판 확인), (b) 동의어 순증(원본 항상 포함·양방향·모르는 말은 원본 그대로), (c) BM25 단조성(tf↑→score↑, 희귀어 idf↑). **정밀도 재검**(리콜 확대가 잠복 FP 를 깨우는지): 6개 동의어쌍 전수 — **리콜 회귀 0 · 추가 히트 중 동의어 무관 FP 0**. 게이트 e2e, 게시본 클린룸.
20
+ - 이연: skill match(jaccard)의 search-core 통합은 scope 경계로 제외 — 후속 후보.
21
+
3
22
  ## 1.36.22 — 2026-07-14 — SessionStart 컨텍스트 주입(compaction 후 컨텍스트 유실 차단) + README 를 소개·사용법·효과로 재구성
4
23
 
5
24
  **① SessionStart 컨텍스트 주입** (obra/superpowers 구조 검토 → 배선). 실측 결함: compaction/resume 후 에이전트가 "세션 시작 handoff" 의례를 건너뛰어 컨텍스트를 잃는다. superpowers 는 hook matcher 를 `startup|clear|compact` 로 걸어 하네스가 컨텍스트를 강제 적재한다(에이전트 자발성에 안 기댐) — leerness 는 hook 플럼빙과 compact 신호를 **이미 둘 다 갖고 있었고 배선만 없었다**.
package/README.ko.md CHANGED
@@ -164,7 +164,7 @@ leerness mcp serve # JSON-RPC over stdio, 85 도구
164
164
  - **에이전트 중립** — Claude / Codex / Cursor / Aider / Goose 등 어디에나 적용.
165
165
  - **거짓 완료 방지** — 완료 주장에 evidence 를 요구하는 anti-lazy 흐름.
166
166
  - **통합 게이트** — 보안·인코딩·드리프트·검증을 `gate` 하나로.
167
- - **한국어 우선 + Windows/인코딩 1급 시민** — CP949/BOM 가드, 한국어 출력 기본(`--language en` 지원).
167
+ - **Windows/인코딩 1급 시민** — CP949/BOM 가드, 터미널 인코딩 자동 회복. 출력 언어는 기본 ko · `--language en` 으로 영어.
168
168
  - **자기기술(self-describing)** — `about`/`commands`/`capabilities`/`doctor` 로 도구가 스스로 설명.
169
169
 
170
170
  ---
package/README.md CHANGED
@@ -121,7 +121,7 @@ MIT
121
121
  <!-- leerness:project-readme:start -->
122
122
  ## Leerness Project Harness
123
123
 
124
- 이 프로젝트는 Leerness v1.36.22 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
124
+ 이 프로젝트는 Leerness v1.36.24 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
125
 
126
126
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
127
127
 
@@ -175,7 +175,7 @@ leerness memory restore decision <date|title>
175
175
 
176
176
  ### MCP server (외부 AI 통합)
177
177
 
178
- Leerness v1.36.22는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
178
+ Leerness v1.36.24는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
179
179
 
180
180
  ```jsonc
181
181
  // 카테고리별
@@ -196,7 +196,7 @@ Leerness v1.36.22는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
196
196
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
197
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) 다음 라운드 예약.
198
198
 
199
- 현재 누적: **70 라운드 (1.9.40 → 1.36.22)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
199
+ 현재 누적: **70 라운드 (1.9.40 → 1.36.24)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
200
200
 
201
201
  ### 성능 가이드 (1.9.140 측정)
202
202
 
@@ -234,6 +234,6 @@ leerness release pack --close --auto-main-push
234
234
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
235
235
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
236
236
 
237
- Last synced by Leerness v1.36.22: 2026-07-15
237
+ Last synced by Leerness v1.36.24: 2026-07-15
238
238
  <!-- leerness:project-readme:end -->
239
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.22';
37
+ const VERSION = '1.36.24';
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 출력 언어 해석 — 한국어 우선 기본, 영어 opt-in.
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,47 @@ 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: 'requests positional 경로 (1.36.24, UR-0027 확장): requests 계열도 task/rule 과 동일하게 positional 경로 인식 — 다른 프로젝트 cwd 오기록 차단 — 소스가드+행위', run: () => {
2987
+ const s = read(__filename);
2988
+ const wired = s.includes("requestsCmd(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd()");
2989
+ // 행위(순수): path-like 만 경로 인정 — ID/텍스트/내부슬래시 무오인, 값-플래그 값 제외 (기존 계약 그대로)
2990
+ const p = require('../lib/pure-utils');
2991
+ const abs = p._taskPositionalPath(['requests', 'add', 'do the thing', 'C:\\other\\proj']) === 'C:\\other\\proj';
2992
+ const idNotPath = p._taskPositionalPath(['requests', 'drop', 'UR-0001']) === null;
2993
+ const innerSlashNotPath = p._taskPositionalPath(['requests', 'add', 'src/auth 모듈 정리']) === null;
2994
+ return wired && abs && idNotPath && innerSlashNotPath;
2995
+ } },
2996
+ { name: 'memory search 랭킹/동의어 (1.36.23): 한국어 조사 회귀 0 + 동의어 리콜 순증 + BM25 단조성 — 순수 행위검사', run: () => {
2997
+ const sc = require('../lib/search-core');
2998
+ const { MEMORY_SYNONYMS } = require('../lib/catalogs');
2999
+ if (typeof sc.scoreHits !== 'function' || typeof sc.expandQuery !== 'function') return false;
3000
+ // (a) ★ 한국어 조사 회귀 금지 — 이 라운드의 최대 리스크. 토큰화 리콜로 바꿨다면 여기서 죽는다.
3001
+ // '핸드오프' 쿼리가 '핸드오프를…' 문서에 반드시 스코어를 준다(랭킹 토크나이저의 prefix 변형).
3002
+ const ko = sc.scoreHits('핸드오프', [{ text: '핸드오프를 자동화하기로 결정했다' }]);
3003
+ const koOk = ko.length === 1 && ko[0].score > 0;
3004
+ // 리콜 자체는 caller 의 substring 이 담당 — 안전판 확인(토크나이저가 조사를 못 떼도 substring 은 찾는다)
3005
+ const substringStillFinds = new RegExp('핸드오프', 'i').test('핸드오프를 자동화하기로 결정했다');
3006
+ // (b) 동의어 확장은 "순증"만 — 원본이 항상 포함되고(리콜 감소 0), 양방향
3007
+ const ex1 = sc.expandQuery('핸드오프', MEMORY_SYNONYMS);
3008
+ const ex2 = sc.expandQuery('handoff', MEMORY_SYNONYMS);
3009
+ const superset = ex1[0] === '핸드오프' && ex1.includes('handoff') && ex2[0] === 'handoff' && ex2.includes('핸드오프');
3010
+ const unknownKeeps = JSON.stringify(sc.expandQuery('zzz없는말', MEMORY_SYNONYMS)) === JSON.stringify(['zzz없는말']); // 모르는 말은 원본 그대로
3011
+ // (c) BM25 단조성: tf↑ → score↑ / 희귀어(idf↑)가 흔한 어휘보다 높은 점수
3012
+ const tfLow = sc.scoreHits('alpha', [{ text: 'alpha beta gamma delta' }])[0].score;
3013
+ const tfHigh = sc.scoreHits('alpha', [{ text: 'alpha alpha alpha beta gamma delta' }])[0].score;
3014
+ const monotonic = tfHigh > tfLow;
3015
+ const corpus = [{ text: 'rare unique term' }, { text: 'common word here' }, { text: 'common word there' }, { text: 'common word everywhere' }];
3016
+ const rareScore = sc.scoreHits('rare', corpus)[0].score;
3017
+ const commonScore = sc.scoreHits('common', corpus)[1].score;
3018
+ const idfOk = rareScore > commonScore;
3019
+ // (d) 0건 제안은 실제 어휘에서만(지어내지 않음)
3020
+ const sug = sc.suggestTerms('핸드', new Set(['핸드오프', '무관어']), { limit: 3 });
3021
+ const sugOk = sug.includes('핸드오프') && !sug.includes('무관어');
3022
+ // 배선 가드: 리콜은 substring(OR), 랭킹은 BM25, limit 은 정렬 "후"
3023
+ const s = read(__filename);
3024
+ const wired = s.includes('const _isHit = line => res.some(r => r.test(line));') && s.includes('_scoreHits(query, raw.map') && s.includes('.slice(0, _limit)');
3025
+ return koOk && substringStillFinds && superset && unknownKeeps && monotonic && idfOk && sugOk && wired;
3026
+ } },
2985
3027
  { name: 'SessionStart 컨텍스트 주입 (1.36.22, obra/superpowers 구조 검토): hook 전용 표면 + 쓰기0 가드 + matcher 에 compact 포함 + opt-out — 소스가드', run: () => {
2986
3028
  const s = read(__filename);
2987
3029
  const surface = typeof hookSessionStartCmd === 'function' && s.includes("cmd === 'hook' && args[1] === 'session-start'");
@@ -4192,7 +4234,7 @@ function _selfTestCases() {
4192
4234
  { name: 'CLI 영어화 Phase 1 (1.20.2, UR-0010): _uiLang 해석(flag>env>manifest>ko) + 첫화면 _t 적용 (행위+소스)', run: () => {
4193
4235
  const save = process.argv; const saveEnv = process.env.LEERNESS_LANG;
4194
4236
  try {
4195
- // 기본 ko (한국어 우선 정체성 보존)
4237
+ // 기본 ko (기존 사용자 기본값 보존 — 영어는 --language en / LEERNESS_LANG=en)
4196
4238
  process.argv = ['node', 'h', 'handoff']; delete process.env.LEERNESS_LANG;
4197
4239
  if (_uiLang('/no/such/dir') !== 'ko') return false;
4198
4240
  // --language en 플래그 → en
@@ -9041,17 +9083,32 @@ function memorySearch(root, query) {
9041
9083
  if (!query) { failJson(jsonMode, 'query_required', 'query required (e.g., memory search "키워드")'); return; }
9042
9084
  // 1.13.1 (15th 블라인드 리뷰 P1, Sonnet): lessons.md + rules.md 누락 수정 — memory search 가 5종 메모리 표면을 표방하나 lesson/rule 을 검색 못 해(lesson add/rule add 로 저장한 교훈·룰이 'no matches') 모순감지 핵심 용도가 훼손됐음.
9043
9085
  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'];
9044
- const re = new RegExp(query.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'), 'i');
9086
+ // 1.36.23: (1) 동의어 확장으로 리콜 "순증" — 실측 `핸드오프` 0 hits / `handoff` 37 hits 비대칭 해소. 기존 regex 에 OR 로 더하므로
9087
+ // 현 동작의 상위집합(히트 감소 0). --no-synonyms 로 종전 동작. (2) 히트를 BM25 로 정렬한 뒤 --limit 적용 — 종전엔 파일당
9088
+ // "앞 N건" 임의 절단이었다(최고 N건이 아니라). ★ 리콜은 substring 유지: 한국어 조사 때문에 토큰화 리콜은 지금 찾히는 문서를
9089
+ // 0건으로 만든다(lib/search-core.js 주석 참조) — BM25 는 정렬 전용이라 토크나이저가 틀려도 히트가 사라지지 않는다.
9090
+ const useSyn = !has('--no-synonyms');
9091
+ const terms = useSyn ? _expandQuery(query, MEMORY_SYNONYMS) : [query];
9092
+ const res = terms.map(t => new RegExp(String(t).replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'), 'i'));
9093
+ const _isHit = line => res.some(r => r.test(line)); // 리콜 = substring OR(원본 + 동의어) — 한국어 조사 안전판
9094
+ const _limit = _parseLimit(arg('--limit','5'),5);
9045
9095
  let total = 0;
9096
+ const _blocks = []; // 파일별 결과 — best score 순으로 출력(관련 높은 파일 먼저)
9046
9097
  for (const f of files) {
9047
9098
  const p = path.join(root, f); if (!exists(p)) continue;
9048
9099
  const lines = read(p).split('\n');
9049
- const hits = lines.map((line, i) => ({ line, i })).filter(x => re.test(x.line));
9050
- if (hits.length) {
9051
- if (!jsonMode) log(`\n# ${f}`);
9052
- for (const h of hits.slice(0, _parseLimit(arg('--limit','5'),5))) { if (!jsonMode) log(` L${h.i+1}: ${h.line.trim()}`); results.push({ file: f, line: h.i + 1, text: h.line.trim() }); }
9053
- total += hits.length;
9054
- }
9100
+ const raw = lines.map((line, i) => ({ line, i })).filter(x => _isHit(x.line));
9101
+ if (!raw.length) continue;
9102
+ const scored = _scoreHits(query, raw.map(x => ({ line: x.i + 1, text: x.line.trim() })))
9103
+ .sort((a, b) => (b.score - a.score) || (a.line - b.line)); // 동점이면 원래 순서(결정적)
9104
+ const top = scored.slice(0, _limit);
9105
+ _blocks.push({ file: f, top, best: top.length ? top[0].score : 0 });
9106
+ total += raw.length;
9107
+ }
9108
+ _blocks.sort((a, b) => (b.best - a.best) || a.file.localeCompare(b.file));
9109
+ for (const blk of _blocks) {
9110
+ if (!jsonMode) log(`\n# ${blk.file}`);
9111
+ 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 }); }
9055
9112
  }
9056
9113
  // 1.9.25: --include-code 옵션 — 실제 소스 코드 본문도 검색 (src/tests/bin)
9057
9114
  // 이전 모순 감지 0/5 → 5/5의 핵심 보완
@@ -9068,10 +9125,13 @@ function memorySearch(root, query) {
9068
9125
  if (!/\.(js|ts|jsx|tsx|gd|cs|py|rb|go|rs|md|html|css|json)$/i.test(e.name)) continue;
9069
9126
  let txt; try { txt = read(p); } catch { continue; }
9070
9127
  const lines = txt.split('\n');
9071
- const hits = lines.map((line, i) => ({ line, i })).filter(x => re.test(x.line));
9128
+ // 1.36.23: 메모리 파일 경로와 동일 처리로 통일 동의어 리콜 + BM25 정렬 후 limit(종전엔 단일 regex + 앞 N건 절단이라
9129
+ // JSON results 배열에 score 있는 항목/없는 항목이 섞였다). 21k줄 소스에서 "앞 5건"은 특히 자의적이었음.
9130
+ const hits = _scoreHits(query, lines.map((line, i) => ({ line: i + 1, text: line.trim().slice(0, 160), _raw: line }))
9131
+ .filter(x => _isHit(x._raw))).sort((a, b) => (b.score - a.score) || (a.line - b.line));
9072
9132
  if (hits.length) {
9073
9133
  if (!jsonMode) log(`\n# ${rel(root, p)}`);
9074
- for (const h of hits.slice(0, _parseLimit(arg('--limit','5'),5))) { if (!jsonMode) log(` L${h.i+1}: ${h.line.trim().slice(0, 160)}`); results.push({ file: rel(root, p), line: h.i + 1, text: h.line.trim().slice(0, 160) }); }
9134
+ 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 }); }
9075
9135
  total += hits.length;
9076
9136
  }
9077
9137
  }
@@ -9079,9 +9139,22 @@ function memorySearch(root, query) {
9079
9139
  walkCodeDir(dp);
9080
9140
  }
9081
9141
  }
9082
- if (jsonMode) { log(JSON.stringify({ version: VERSION, query, total, includeCode: has('--include-code'), results }, null, 2)); return; }
9083
- if (total === 0) log('(no matches)');
9084
- else log(`\n${total} matches${has('--include-code') ? ' (소스 코드 포함)' : ''}`);
9142
+ // 1.36.23: 0건이면 실제 색인 어휘로 근접어 제안(지어내지 않음 vocab 방금 읽은 메모리 파일에서 추출).
9143
+ let _suggest = [];
9144
+ if (total === 0) {
9145
+ try {
9146
+ const vocab = new Set();
9147
+ for (const f of files) {
9148
+ const p = path.join(root, f); if (!exists(p)) continue;
9149
+ for (const t of _tokenizeForRank(read(p))) if (t.length >= 2) vocab.add(t);
9150
+ }
9151
+ _suggest = _suggestTerms(query, vocab, { limit: 5 });
9152
+ } catch {}
9153
+ }
9154
+ // 1.36.23: total(전체 히트) 과 results.length(표시분) 불일치를 자기정합화 — 종전엔 total 16 / results 15 처럼 어긋났다.
9155
+ 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; }
9156
+ if (total === 0) { log('(no matches)'); if (_suggest.length) log(` 혹시 이건가요: ${_suggest.join(', ')}`); }
9157
+ else log(`\n${total} matches${total > results.length ? ` (상위 ${results.length}건 표시 — --limit 로 조절)` : ''}${has('--include-code') ? ' · 소스 코드 포함' : ''}${useSyn && terms.length > 1 ? ` · 동의어: ${terms.slice(1).join('/')}` : ''}`);
9085
9158
  }
9086
9159
 
9087
9160
  function handoff(root) {
@@ -12956,7 +13029,7 @@ function _banner(opts = {}) {
12956
13029
  cprint(' ' + C.green(padded) + C.dim('# ' + desc));
12957
13030
  };
12958
13031
 
12959
- // 1.20.2 (UR-0010 Phase 1): 첫 화면 배너 — UI 언어(한국어 우선, 영어 opt-in) 적용.
13032
+ // 1.20.2 (UR-0010 Phase 1): 첫 화면 배너 — UI 언어(기본 ko, 영어 opt-in) 적용.
12960
13033
  const L = _uiLang(arg('--path', process.cwd()));
12961
13034
  const t = (ko, en) => (L === 'en' ? en : ko);
12962
13035
  cprint('');
@@ -21261,7 +21334,10 @@ async function main() {
21261
21334
  // 1.9.203: leerness resume — auto-resume-plan 읽고 다음 라운드 즉시 안내 (사용자 명시)
21262
21335
  if (cmd === 'resume') return resumeCmd(arg('--path', process.cwd()));
21263
21336
  // 1.9.207: leerness requests <audit|add|list|complete|drop> — 사용자 요청 누락 확인 절차 (사용자 명시)
21264
- if (cmd === 'requests') return requestsCmd(arg('--path', process.cwd()), args[1], ...args.slice(2));
21337
+ // 1.36.24 (UR-0027 확장, 실피해 재현): requests 계열만 positional 경로 미지원이라 `requests add "text" C:/other/proj` 가
21338
+ // 조용히 cwd 프로젝트에 기록하고 성공 보고했다(task/rule 은 _taskPositionalPath 지원 → 불일치가 실수를 유도, 본인 피해 확인).
21339
+ // rule 선례와 동일 배선 — path-like(선행 구분자) 만 인정하는 기존 계약이라 텍스트/ID 오인 FP 없음(_parseAddTitle 이 add 텍스트를 path-like 에서 끊는 것도 기존과 동일).
21340
+ if (cmd === 'requests') return requestsCmd(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd(), args[1], ...args.slice(2));
21265
21341
  // 1.9.220: leerness session-resume — 비정상 종료 감지 + 자율 재개 가이드 (사용자 명시)
21266
21342
  if (cmd === 'session-resume') return sessionResumeCmd(arg('--path', process.cwd()));
21267
21343
  // 1.9.226: leerness round-history — 자율 라운드 통계 + 다음 마일스톤
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.22",
4
- "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
3
+ "version": "1.36.24",
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",