leerness 1.36.84 → 1.36.86

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,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.86 — 2026-07-29 — brainstorm 두 경로 수집 단일화(중복 149줄 제거) · 총계 계약 정정 · 조용한 절단 제거
4
+
5
+ 1.36.85 가 다음 라운드 과제로 남긴 "사람용 vs `--json` 이 갈린다"를 근본 수정했다. 원인은 **세 겹**이었다: ① 사람용이 자체 수집 코드를 갖고 있어 `code` 를 아예 모으지 않았고 ② 렌더러에 code 섹션이 없었으며 ③ 총계를 각자 인라인 계산해 서로 다른 표면을 누락했다.
6
+
7
+ - **수집 단일화** — 사람용도 `_brainstormFor` 를 쓴다(중복 **149줄 제거**, 순 -87줄). 게시본 1.36.85 와 같은 픽스처로 출력을 줄 단위 비교해 **`--include-code` 없이는 byte-identical**, 있을 때만 의도한 code 섹션·총계 증가만 나타남을 확인했다(외부 검수도 동일 결론: 토큰화·2자 필터·preview 길이·코드펜스 제외·순서·cap·경로 표기 모두 무변경).
8
+ - **총계 계약 정정** — `_brainstormTotal` 하나를 두 경로가 공유한다. archive 를 포함하도록 고쳤고(이전엔 JSON 이 hit 을 보여주면서 `total=0` 인 자기모순이 있었다), 반대로 **`lessons` 는 제외**했다 — `evidence` 의 파생 뷰(같은 블록을 실패 키워드로 재투영)라 더하면 **같은 자원을 두 번 센다**(검수 지적, 실측: evidence 1/lessons 1 인데 total 2). `total` 은 **고유 자원 수**를 뜻한다.
9
+ - **workspace 경로에도 같은 결함이 남아 있었다** — 단일 프로젝트만 고치고 `--include` 경로를 놓쳤다. 집계에는 넣고 렌더는 빠뜨려 **"3건" 이라 적고 1건만 표시**했다(lessons·plan·skill이력·task-log·archive 5개 표면 누락, 실측). 전부 렌더하도록 고쳤다. — *증상만 고치고 완료로 판정하는 그 패턴을 이 라운드에서 내가 그대로 저질렀고, 외부 검수가 짚어서야 발견했다.*
10
+ - **조용한 절단 2건 제거** — `--include-code` 의 상한 20 이 **재귀 프레임만** 종료해 형제 디렉토리와 다음 스캔 대상은 계속 훑었다(실측: 30개 하위 디렉토리 → 30건 수집). 전역 상한으로 교체. 사람용은 전체 건수를 헤딩에 적고 5건만 보여주면서 안내가 없었다 → `(20 — 상위 5건 표시)` 로 명시.
11
+ - **가드의 보장 범위를 정확히 적는다**(검수가 반례로 좁혀 준 대로): 신설 가드는 KO 단일 프로젝트의 4개 표면 건수·헤더 합계·workspace 헤더 대비 실제 표시 항목 수를 단언한다. **표면별 렌더 내용의 완전 일치나 EN 로케일·표면당 상한 초과·항목 identity 비교는 보장하지 않는다.** "구조적으로 드리프트 불가능"은 **수집에만** 해당하며 렌더 드리프트는 별도다.
12
+ - **총계를 같은 함수로 비교하던 동어반복도 고쳤다** — 1차판 가드는 `j.total !== _brainstormTotal(h)` 로 검사해 그 함수의 code 항을 빼도 양변이 함께 변해 통과했다. 기대값을 **독립 계산**으로 바꿔 확인.
13
+ - 검증: selftest 340 · e2e 406/406 · 1.36.85 대비 사람용 출력 회귀 비교 · 신규 가드 5방향 변이(사람용 code 섹션 제거 · 제품 총계에서 code 제외 · 자체 수집 회귀 · workspace lessons/plan 렌더 제거).
14
+
15
+ ## 1.36.85 — 2026-07-29 — brainstorm 줄번호 오보 수정(10곳) · 실행되지 않던 명령 경로에 가드
16
+
17
+ 여러 라운드 연속 테스트 인프라를 손봤으므로 **표면을 제품 정확성으로 옮겨** 검색했다(신뢰도가 낮은 곳을 보는 편이 낫다).
18
+
19
+ - **`brainstorm` 검색 결과의 줄번호가 틀렸다.** 줄번호를 `text.indexOf(block)` 로 구하는데, **내용이 같은 블록이 둘 이상이면 항상 첫 번째 위치**를 돌려준다 — 실측으로 3행·10행에 있는 동일 lesson 블록이 둘 다 `line=3` 으로 보고됐다. 사용자가 그 줄로 가면 다른 항목이 있다. `_blocksWithOffset(text, sep)` 로 **순회하며 오프셋을 누적**하도록 10곳 전부 전환(evidence · skill history · lessons · plan milestones · archive × `_brainstormFor`/사람용 두 구현). 경계 9종(CRLF · 빈 블록 · 선두/말미 · 단일 조각 · 한글 · 구분자 없음 · 빈 문자열) 검증.
20
+ - **selftest 338/338 이 초록인데 `brainstorm` 이 통째로 죽어 있었다.** 위 수정 중 import 를 빠뜨려 `_blocksWithOffset is not defined` 로 명령 전체가 실패했는데도 스위트는 전부 통과했다 — **그 명령의 실행 경로가 어떤 케이스에도 없었다**. 신설 가드는 실제 명령을 spawn 해 exit 와 출력을 함께 보므로 (a) 줄번호 회귀 (b) import 누락 둘 다 잡는다(변이로 확인).
21
+ - **`brainstorm` 은 사람용과 `--json` 이 서로 다른 구현을 쓴다**(`--json` 은 `_brainstormFor`, 사람용은 자체 수집 코드). **텍스트 비교로는 "skills 매칭 입력이 다르다"고 보였지만 행위로 비교하니 같은 결과를 냈다** — 텍스트 diff 가 오판이었다. 다만 검수가 **세 입력에서 두 경로가 실제로 갈린다**는 반례를 냈다(`--include-code` · archive-only · workspace `--include`). 내 픽스처로는 재현되지 않았고 제대로 고치려면 사람용 수집을 `_brainstormFor` 로 단일화하는 큰 리팩터가 필요해 **이번 라운드에는 하지 않았다** — 다음 라운드 과제로 명시한다. 신설 가드에는 lessons/decisions 두 표면의 **건수와 줄번호 일치**만 넣었다(그 이상은 주장하지 않는다).
22
+ - **작업 도구가 또 한 번 틀렸다**: 일괄 치환 스크립트의 앵커가 너무 일반적이라(`for (const b of blocks) {` 가 7곳 매칭) 엉뚱한 루프까지 바꿀 뻔했다 — dry-run 의 **기대 개수 검증**이 잡았다. 각 `indexOf` 지점에서 위로 올라가 그 루프만 지목하는 방식으로 바꿔 10/10 정확 적용.
23
+
24
+ **검수 26회전 반영** (High 0 · Medium 5 중 4건):
25
+
26
+ - **내가 이 라운드에 만든 가드가 공허했다** — `_lineOfOffset` 을 `offset + 1` 로 바꿔도 통과했다("서로 다르고 양수"만 검사). 정확한 값(`[3,10]`/`[7,14]`)을 단언하도록 강화하고 3방향 변이로 확인.
27
+ - **같은 버그의 다른 철자를 놓쳤다** — decisions 경로는 `indexOf` 가 아니라 `decLines.findIndex(line === heading)` 라 내 스윕 패턴에 안 걸렸고, 결과는 동일한 오보(`[3,3]`)였다. 클래스는 표현이 아니라 **의미**로 훑어야 한다. 코드펜스를 **길이 보존 마스킹**으로 제외하는 `_decisionBlocksWithOffset` 을 추가해(펜스·Template 제외 동작은 기존과 동일함을 확인) 정확한 줄번호를 얻는다. 펜스 없는 픽스처로는 이 회귀를 못 잡아 픽스처에 펜스를 넣었다.
28
+ - **O(n²) 성능 회귀** — `_lineOfOffset` 이 블록마다 처음부터 재스캔했다(검수 실측: lesson 40k건 ≈ 62초). 순회 중 줄 수를 누적해 O(n) 으로(자체 실측 5000건 973ms → 446ms).
29
+ - **행위 검사의 비용을 명시한다**: 소스 문자열 검사를 행위 검사로 바꾸며 selftest 가 **약 4초 → 약 13초**가 됐고(CLI spawn 비용), 4초 시절 기준으로 잡힌 e2e 타임아웃(30s)이 전체 부하에서 터졌다 — **게이트가 잡았다**. 격리 재현 3/3 통과로 검사 자체는 정상임을 확인한 뒤, 비용 구조를 측정해(`init` 1330ms · 초기화 디렉토리 복사 78ms · CLI 기동 434ms) 언어별 **공유 픽스처 캐시**를 도입(14.1→13.0s)하고 타임아웃 근거를 주석에 남겼다. 행위 검사는 소스 검사보다 3배 비싸지만, 소스 검사는 리팩터 한 번에 조용히 공허해지고 행위 검사는 import 누락 같은 실제 파손까지 잡는다 — 값을 치를 만하다고 판단했다.
30
+
31
+ - 검증: selftest 339 · e2e 406/406 · 원 버그 재현→수정(3행/10행, 펜스 포함 시 7행/14행) · 신규 가드 3방향 변이(line 누적 훼손 · line 고정 · 마스킹 제거) · 오프셋 경계 8종 · 격리 재현 3회.
32
+
3
33
  ## 1.36.84 — 2026-07-29 — 탐지 대신 **동결** · 검수 25회전 커버리지 부족 5건 상환
4
34
 
5
35
  1.36.82~83 에서 자기참조 공허 가드를 **탐지**하려 세 라운드 연속 탐지기를 고쳤고, 매번 외부 검수가 새 우회를 찾아냈다(직접 호출 · `fs.readFileSync` · `indexOf` · 정규식 `.test` · alias/재할당/구조분해 · 자기제외 표식 복사 · UI 문자열 · **읽는 파일의 무관한 위치에서 만족되는 리터럴**). 마지막 형태는 존재 검사로 **원리적으로 판별 불가**다.
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.84 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.86 하네스를 사용합니다. 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.84는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.86는 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.84는 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.84 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.86 릴리스 태그 이력** (수백 라운드) · _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.84: 2026-07-29
238
+ Last synced by Leerness v1.36.86: 2026-07-29
239
239
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -26,7 +26,7 @@ const { _isSecretKey, _isPlaceholderSecret, _looksSecretLike, _mergeLines, _merg
26
26
  _migrationGuideText, _parseContractSpec, _gitignoreMatch,
27
27
  _featureGraphTemplate, _parseFeatureGraph, _nextFeatureId, _featureBlock, _featureImpactBfs,
28
28
  _parseChangelogBetween, _cellSafe, _cellUnescape, _lineSafe, _parseLimit, _parseAddTitle, _parseImplExports, _taskPositionalPath, _completionClaimAllowed, _minorKey, _shouldPublishNpm,
29
- _matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, _briefUnfilled, _planGoalUnfilled, _draftAnchors, _replaceMdSection, _mdSectionBody } = require('../lib/pure-utils'); // 1.9.318~1.11.4 (UR-0025/.../0007 glossary): 순수 유틸 모듈 분리 · 1.36.36 anchors
29
+ _matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, _briefUnfilled, _planGoalUnfilled, _draftAnchors, _replaceMdSection, _mdSectionBody, _blocksWithOffset, _decisionBlocksWithOffset } = require('../lib/pure-utils'); // 1.9.318~1.11.4 (UR-0025/.../0007 glossary): 순수 유틸 모듈 분리 · 1.36.36 anchors
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).
@@ -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.84';
37
+ const VERSION = '1.36.86';
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') 시 호스트 프로세스 오염.
@@ -3193,6 +3193,28 @@ function pathSetupCmd(root, opts = {}) {
3193
3193
  }
3194
3194
  }
3195
3195
 
3196
+ // 1.36.85: selftest 행위 가드용 **공유 픽스처 캐시**.
3197
+ // 소스 문자열 검사를 행위 검사로 바꾸면서 케이스마다 `init` 을 spawn 했더니 selftest 가 4초대 → 14초가 됐고,
3198
+ // 30초 타임아웃을 쓰던 e2e 소비자가 전체 부하에서 실패했다(게이트가 잡음).
3199
+ // 실측 비용: init 1330ms · 초기화된 디렉토리 복사 78ms(17배 저렴) · CLI 1회 기동 434ms.
3200
+ // → 언어별로 **한 번만** init 하고 이후는 복사해서 쓴다. 각 호출자는 자기 사본을 받으므로 상호 오염이 없다.
3201
+ const _stFixtureCache = new Map();
3202
+ function _selftestFixture(lang) {
3203
+ const key = lang || 'ko';
3204
+ if (!_stFixtureCache.has(key)) {
3205
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_stfx_' + key + '_'));
3206
+ const args = [__filename, 'init', base, '--yes', '--no-env', '--no-stale-check'];
3207
+ if (key !== 'ko') args.push('--language', key);
3208
+ const r = cp.spawnSync(process.execPath, args, { encoding: 'utf8', timeout: 120000, maxBuffer: 16 * 1024 * 1024 });
3209
+ _stFixtureCache.set(key, r.status === 0 ? base : null);
3210
+ }
3211
+ const base = _stFixtureCache.get(key);
3212
+ if (!base) return null; // init 실패 시 호출자가 스스로 판단(조용한 통과 금지)
3213
+ const dst = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_stuse_'));
3214
+ fs.cpSync(base, dst, { recursive: true });
3215
+ return dst;
3216
+ }
3217
+
3196
3218
  // 1.9.258: leerness selftest — 설치된 leerness 바이너리의 코어 순수 함수 자가 검증.
3197
3219
  // 1.9.255~257 에서 export 한 보안/정확성/인코딩-핵심 함수를 실제 호출해 무결성 확인.
3198
3220
  // 사용자/CI 가 "내 leerness 가 정상인가?" 를 1초 내 검증 (npm 캐시 손상/부분 설치 감지).
@@ -4792,10 +4814,9 @@ function _selfTestCases() {
4792
4814
  // 1.36.84 (검수 M4): **en 분기 기본값**도 따로 검사 — ko 만 보면 en 기본값을 ''로 없애도 통과했다(실측).
4793
4815
  let _enOk = false;
4794
4816
  // 언어는 수동 파일이 아니라 실제 init(--language en)으로만 확정된다(수동 .harness/LANGUAGE·manifest 로는 미적용 — 실측).
4795
- const _te = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_dwen_'));
4817
+ const _te = _selftestFixture('en'); // 1.36.85: 공유 en 픽스처
4796
4818
  try {
4797
- cp.spawnSync(process.execPath, [__filename, 'init', _te, '--yes', '--no-env', '--no-stale-check', '--language', 'en'],
4798
- { encoding: 'utf8', timeout: 90000, maxBuffer: 8 * 1024 * 1024 });
4819
+ if (!_te) { _enOk = false; throw new Error('fixture'); }
4799
4820
  const _re = cp.spawnSync(process.execPath, [__filename, 'plan', 'add', 'EN default guard', '--path', _te, '--json'],
4800
4821
  { cwd: _te, encoding: 'utf8', timeout: 60000, maxBuffer: 8 * 1024 * 1024 });
4801
4822
  _enOk = _re.status === 0 && /^Done-When: \(unset\)[ \t]*$/m.test(read(planPath(_te)).replace(/\r\n/g, '\n'));
@@ -5054,6 +5075,111 @@ function _selfTestCases() {
5054
5075
  && src.includes("failJson(has('--json'), 'rule_not_found'");
5055
5076
  return restoreOk && planOk && fileReOk && analyzersOk && archiveOk && jsonOk;
5056
5077
  } },
5078
+ { name: 'brainstorm 줄번호 정확성 + 두 경로 동등 (1.36.85): 동일 내용 블록이 각자 위치로 보고 · 사람용/--json 일치 (행위)', run: () => {
5079
+ // 사고: 줄번호를 `text.indexOf(block)` 로 구해, 내용이 같은 블록이 둘이면 **둘 다 첫 번째 줄번호**를 보고했다.
5080
+ // (실측: 3행·10행의 동일 lesson 블록이 둘 다 line=3). 오프셋 누적으로 교체하며 이 가드를 세운다.
5081
+ // 함께 잡는 것: 이 수정 중 `_blocksWithOffset` import 를 빠뜨려 brainstorm 이 통째로 죽었는데도
5082
+ // selftest 338/338 이 초록이었다 — 실행 경로가 어떤 케이스에도 없었다. 그래서 **실제 명령**을 돌린다.
5083
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_bsline_'));
5084
+ try {
5085
+ mkdirp(path.join(d, '.harness'));
5086
+ writeUtf8(path.join(d, '.harness', 'HARNESS_VERSION'), VERSION);
5087
+ // (검수 1.36.85 M3) 1차판은 "서로 다르고 양수" 만 봐서, `_lineOfOffset` 을 `offset + 1` 로 바꿔
5088
+ // 엉뚱한 값이 나와도 통과했다(실측). **정확한 줄번호**를 단언한다.
5089
+ const KW = 'zzq' + 'unique';
5090
+ // 3행과 10행에 동일 내용 블록(사이에 다른 블록) — 기대 line = [3, 10]
5091
+ writeUtf8(path.join(d, '.harness', 'lessons.md'),
5092
+ `# Lessons\n\n### 2026-01-01\n- Lesson: ${KW} 교훈\n- Tag: t\n\n### 2026-02-02\n- Lesson: 사이에 낀 다른 것\n\n### 2026-01-01\n- Lesson: ${KW} 교훈\n- Tag: t\n`);
5093
+ // 같은 제목의 decision 도 3행·10행 — findIndex 기반 오보(검수 M1) 회귀 차단.
5094
+ // **코드펜스 블록을 앞에 둔다**: decision 추출은 펜스를 길이 보존 마스킹으로 제외하는데,
5095
+ // 길이가 바뀌는 방식(삭제)으로 되돌리면 이후 모든 오프셋이 밀린다 — 펜스가 없는 픽스처로는 그 회귀를 못 잡는다.
5096
+ const _fence = String.fromCharCode(96).repeat(3);
5097
+ writeUtf8(path.join(d, '.harness', 'decisions.md'),
5098
+ `# Decisions\n\n${_fence}md\n### 2099-12-31 — 펜스 안 예시(집계 제외)\n${_fence}\n\n### 2026-01-01 — ${KW} 결정\n- Decision: ${KW} 채택\n- Reason: r\n\n### 2026-02-02 — 다른 결정\n- Decision: x\n\n### 2026-01-01 — ${KW} 결정\n- Decision: ${KW} 채택\n- Reason: r\n`);
5099
+ const _run = (a) => cp.spawnSync(process.execPath, [__filename, ...a, '--path', d],
5100
+ { encoding: 'utf8', timeout: 90000, maxBuffer: 16 * 1024 * 1024 });
5101
+ const rj = _run(['brainstorm', KW, '--json']);
5102
+ if (rj.status !== 0) return false; // 명령 자체가 죽으면 실패(위 사고 재발 차단)
5103
+ const j = JSON.parse(rj.stdout.slice(rj.stdout.indexOf('{')));
5104
+ const _lines = (arr) => (arr || []).map(x => x.line);
5105
+ const lessonsOk = JSON.stringify(_lines(j.hits && j.hits.lessonsExplicit)) === '[3,10]';
5106
+ const decisionsOk = JSON.stringify(_lines(j.hits && j.hits.decisions)) === '[7,14]';
5107
+ // 사람용 경로는 별도 구현이라 같은 입력에 같은 건수 + 같은 줄번호를 내야 한다(중복 구현 드리프트 가드)
5108
+ const rh = _run(['brainstorm', KW]);
5109
+ const ht = rh.stdout || '';
5110
+ const humanOk = rh.status === 0 && /관련 lessons \(2\)/.test(ht) && /관련 결정 \(2\)/.test(ht)
5111
+ && /lessons\.md:3\b/.test(ht) && /lessons\.md:10\b/.test(ht)
5112
+ && /decisions\.md:7\b/.test(ht) && /decisions\.md:14\b/.test(ht);
5113
+ return lessonsOk && decisionsOk && humanOk;
5114
+ } catch { return false; } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
5115
+ } },
5116
+ { name: 'brainstorm 두 경로 등가 (1.36.86): 사람용/--json 이 표면별로 같은 결과 — 수집 단일화 회귀 가드 (행위)', run: () => {
5117
+ // 사고: 사람용이 자체 수집 코드를 갖고 있어 `--include-code` 에서 --json 은 code 1건,
5118
+ // 사람용은 "관련 자원 없음" 을 냈다. 수집을 _brainstormFor 로 단일화(중복 149줄 제거)하고
5119
+ // 총계도 _brainstormTotal 하나로 통일했다(이전엔 lessons/archive/code 를 서로 다르게 누락).
5120
+ // 표면별로 "JSON 이 hit 을 보고하면 사람용도 반드시 보여준다"를 강제한다.
5121
+ const d = _selftestFixture('ko');
5122
+ if (!d) return false;
5123
+ try {
5124
+ const KW = 'eqv' + 'probe';
5125
+ const _run = (a) => cp.spawnSync(process.execPath, [__filename, 'brainstorm', KW, '--path', d, ...a],
5126
+ { encoding: 'utf8', timeout: 90000, maxBuffer: 16 * 1024 * 1024 });
5127
+ // 여러 표면에 동시에 심는다 — 한 표면만 보면 다른 표면의 불일치를 놓친다
5128
+ mkdirp(path.join(d, 'src'));
5129
+ writeUtf8(path.join(d, 'src', 'a.js'), `// ${KW} 구현\n`);
5130
+ writeUtf8(path.join(d, '.harness', 'lessons.md'), `# L\n\n### 2026-01-01\n- Lesson: ${KW} 교훈\n`);
5131
+ writeUtf8(path.join(d, '.harness', 'plan.md'), `# P\n\n### M-0001. ${KW} 마일스톤\nStatus: planned\nProgress: 0%\n`);
5132
+ writeUtf8(path.join(d, '.harness', 'decisions.md'), `# D\n\n### 2026-01-01 — ${KW} 결정\n- Decision: ${KW}\n`);
5133
+ const rj = _run(['--include-code', '--json']);
5134
+ if (rj.status !== 0) return false;
5135
+ const j = JSON.parse(rj.stdout.slice(rj.stdout.indexOf('{')));
5136
+ const h = j.hits || {};
5137
+ const n = (x) => (x && x.length) || 0;
5138
+ // JSON 이 실제로 여러 표면을 잡았는가(픽스처가 무력하면 이 가드도 공허해진다)
5139
+ if (!(n(h.code) && n(h.lessonsExplicit) && n(h.planMilestones) && n(h.decisions))) return false;
5140
+ // 총계는 **독립 계산**과 비교한다 — `_brainstormTotal(h)` 로 비교하면 그 함수를 망가뜨려도
5141
+ // 양변이 같이 변해 통과하는 동어반복이 된다(1차판이 실제로 그랬다: code 항을 빼도 초록).
5142
+ const _arc = h.archive ? Object.values(h.archive).reduce((s, v) => s + n(v), 0) : 0;
5143
+ const _expect = n(h.decisions) + n(h.skills) + n(h.tasks) + n(h.rules) + n(h.evidence) + n(h.lessons)
5144
+ + n(h.code) + n(h.skillHistory) + n(h.taskLogFails) + n(h.lessonsExplicit) + n(h.planMilestones) + _arc;
5145
+ if (j.total !== _expect) return false;
5146
+ const ht = (_run(['--include-code']).stdout) || '';
5147
+ if (/관련 자원 없음/.test(ht)) return false; // hit 이 있는데 "없음" 은 자기모순
5148
+ // 표면별 건수가 사람용 헤딩에도 그대로 나타나야
5149
+ const shown = [
5150
+ [n(h.code), /관련 소스 코드 \((\d+)\)/],
5151
+ [n(h.lessonsExplicit), /관련 lessons \((\d+)\)/],
5152
+ [n(h.planMilestones), /관련 plan milestones \((\d+)\)/],
5153
+ [n(h.decisions), /관련 결정 \((\d+)\)/],
5154
+ ];
5155
+ // 사람용 헤더의 **내역 합 = 총계** — 총계에만 반영되고 내역에서 빠진 표면이 있으면 사용자가 더했을 때 안 맞는다
5156
+ const _hdr = (ht.match(/총 (\d+)건 발견 \(([^)]*)\)/) || []);
5157
+ const _sum = (_hdr[2] || '').split('·').map(s => Number((s.trim().match(/(\d+)$/) || [])[1] || 0)).reduce((a, b) => a + b, 0);
5158
+ const headerOk = Number(_hdr[1]) === j.total && _sum === j.total;
5159
+ if (!(shown.every(([cnt, re]) => { const m = re.exec(ht); return !!m && Number(m[1]) === cnt; }) && headerOk)) return false;
5160
+ // 1.36.86 (검수 High#1): **workspace 경로**도 같은 계약을 지켜야 한다 —
5161
+ // 집계에는 넣고 렌더는 빠뜨려 "3건" 이라 적고 1건만 보여주던 상태였다(실측).
5162
+ // 프로젝트 헤더의 건수만큼 실제 항목 줄이 나와야 한다.
5163
+ const _wsBase = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_ws_'));
5164
+ try {
5165
+ const _app = path.join(_wsBase, 'app');
5166
+ mkdirp(path.join(_app, '.harness'));
5167
+ writeUtf8(path.join(_app, '.harness', 'HARNESS_VERSION'), VERSION);
5168
+ writeUtf8(path.join(_app, '.harness', 'lessons.md'), `# L\n\n### 2026-01-01\n- Lesson: ${KW} 교훈\n`);
5169
+ writeUtf8(path.join(_app, '.harness', 'plan.md'), `# P\n\n### M-0001. ${KW} 마일스톤\nStatus: planned\nProgress: 0%\n`);
5170
+ writeUtf8(path.join(_app, '.harness', 'decisions.md'), `# D\n\n### 2026-01-01 — ${KW} 결정\n- Decision: ${KW}\n`);
5171
+ const _opt = { cwd: _wsBase, encoding: 'utf8', timeout: 90000, maxBuffer: 16 * 1024 * 1024 };
5172
+ const _wj = cp.spawnSync(process.execPath, [__filename, 'brainstorm', KW, '--include', 'app', '--json'], _opt);
5173
+ const _wh = cp.spawnSync(process.execPath, [__filename, 'brainstorm', KW, '--include', 'app'], _opt);
5174
+ if (_wj.status !== 0 || _wh.status !== 0) return false;
5175
+ const _wjj = JSON.parse(_wj.stdout.slice(_wj.stdout.indexOf('{')));
5176
+ const _wt = (_wh.stdout || '');
5177
+ const _hdrN = Number((_wt.match(/^## app \((\d+)건\)/m) || [])[1] || -1);
5178
+ const _items = (_wt.match(/^ {4}- /gm) || []).length; // 실제 렌더된 항목 줄
5179
+ return _hdrN === _wjj.total && _items >= _wjj.total; // 헤더 건수 = 총계 = 실제 표시 항목
5180
+ } catch { return false; } finally { try { fs.rmSync(_wsBase, { recursive: true, force: true }); } catch {} }
5181
+ } catch { return false; } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
5182
+ } },
5057
5183
  { name: '자기소스 검사 동결 (1.36.84): selftest 가 자기 소스를 읽어 검증하는 케이스는 더 늘지 않는다 — 신규는 행위검사로 (메타가드)', run: () => {
5058
5184
  // 왜 "탐지"가 아니라 "동결"인가:
5059
5185
  // 1.36.82~83 에서 자기참조 공허 가드를 탐지하려 세 라운드 연속 탐지기를 고쳤는데,
@@ -5377,9 +5503,9 @@ function _selfTestCases() {
5377
5503
  // → 실제 CLI 를 en 으로 돌려 헤딩이 영어로 렌더되는지 **행위**로 확인하고, ko 보존은 소스로 확인한다.
5378
5504
  let en = false;
5379
5505
  {
5380
- const _ed = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_en3_'));
5506
+ const _ed = _selftestFixture('en'); // 1.36.85: init spawn(1.3s) 대신 공유 픽스처 복사(78ms)
5381
5507
  try {
5382
- cp.spawnSync(process.execPath, [__filename, 'init', _ed, '--yes', '--no-env', '--no-stale-check', '--language', 'en'], { encoding: 'utf8', timeout: 90000 });
5508
+ if (!_ed) return false; // 픽스처 준비 실패는 조용히 통과시키지 않는다
5383
5509
  cp.spawnSync(process.execPath, [__filename, 'task', 'add', 'probe', '--path', _ed], { encoding: 'utf8', timeout: 60000 });
5384
5510
  const _md = read(path.join(_ed, '.harness', 'progress-tracker.md'));
5385
5511
  const _tid = [...String(_md).matchAll(/\|\s*(T-\d{4})\s*\|/g)].map(m => m[1]).pop();
@@ -16270,11 +16396,10 @@ function _brainstormFor(root, topic) {
16270
16396
  const hits = { decisions: [], skills: [], tasks: [], rules: [], evidence: [], lessons: [], code: [], skillHistory: [], taskLogFails: [], lessonsExplicit: [], planMilestones: [] };
16271
16397
  const dec = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
16272
16398
  const decLines = dec.split('\n');
16273
- for (const b of _extractDecisionBlocks(dec)) {
16399
+ for (const { block: b, line: _dln } of _decisionBlocksWithOffset(dec)) {
16274
16400
  if (matches(b)) {
16275
16401
  const t = (b.match(/^### (.+)$/m) || [, ''])[1];
16276
- const lineIdx = decLines.findIndex(line => line === `### ${t}`);
16277
- const lineNo = lineIdx >= 0 ? lineIdx + 1 : 0;
16402
+ const lineNo = _dln;
16278
16403
  hits.decisions.push({ title: t, preview: b.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
16279
16404
  }
16280
16405
  }
@@ -16313,12 +16438,11 @@ function _brainstormFor(root, topic) {
16313
16438
  }
16314
16439
  }
16315
16440
  const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
16316
- for (const block of ev.split(/\n(?=## )/)) {
16441
+ for (const { block: block, line: _ln } of _blocksWithOffset(ev, /\n(?=## )/)) {
16317
16442
  if (!block.startsWith('## ')) continue;
16318
16443
  if (matches(block)) {
16319
16444
  const t = (block.match(/^## (.+)$/m) || [, ''])[1];
16320
- const idx = ev.indexOf(block);
16321
- const lineNo = idx >= 0 ? ev.slice(0, idx).split('\n').length : 0;
16445
+ const lineNo = _ln;
16322
16446
  hits.evidence.push({ title: t.trim(), preview: block.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
16323
16447
  if (/✗|fail|롤백|incomplete|버그/i.test(block)) hits.lessons.push({ title: t.trim(), line: lineNo });
16324
16448
  }
@@ -16327,12 +16451,11 @@ function _brainstormFor(root, topic) {
16327
16451
  const histPath = path.join(root, '.harness', 'skill-suggestions.md');
16328
16452
  if (exists(histPath)) {
16329
16453
  const histTxt = read(histPath);
16330
- for (const block of histTxt.split(/\n(?=## )/)) {
16454
+ for (const { block: block, line: _ln } of _blocksWithOffset(histTxt, /\n(?=## )/)) {
16331
16455
  if (!block.startsWith('## ')) continue;
16332
16456
  const h = block.match(/^## ([\d-]+ [\d:]+) — query "([^"]+)"/);
16333
16457
  if (h && matches(block)) {
16334
- const idx = histTxt.indexOf(block);
16335
- const lineNo = idx >= 0 ? histTxt.slice(0, idx).split('\n').length : 0;
16458
+ const lineNo = _ln;
16336
16459
  hits.skillHistory.push({ at: h[1], query: h[2], preview: block.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16337
16460
  }
16338
16461
  }
@@ -16341,12 +16464,11 @@ function _brainstormFor(root, topic) {
16341
16464
  const lp_brainstorm = lessonsPath(root);
16342
16465
  if (exists(lp_brainstorm)) {
16343
16466
  const lessonsText = read(lp_brainstorm);
16344
- for (const block of lessonsText.split(/\n(?=### )/)) {
16467
+ for (const { block: block, line: _ln } of _blocksWithOffset(lessonsText, /\n(?=### )/)) {
16345
16468
  if (!block.startsWith('### ')) continue;
16346
16469
  const lessonMatch = block.match(/- Lesson:[ \t]*(.+)/);
16347
16470
  if (lessonMatch && matches(block)) {
16348
- const idx = lessonsText.indexOf(block);
16349
- const lineNo = idx >= 0 ? lessonsText.slice(0, idx).split('\n').length : 0;
16471
+ const lineNo = _ln;
16350
16472
  hits.lessonsExplicit.push({ title: lessonMatch[1].trim().slice(0, 120), preview: block.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16351
16473
  }
16352
16474
  }
@@ -16355,12 +16477,11 @@ function _brainstormFor(root, topic) {
16355
16477
  const planFile_brainstorm = planPath(root);
16356
16478
  if (exists(planFile_brainstorm)) {
16357
16479
  const planText = read(planFile_brainstorm);
16358
- const milestoneBlocks = planText.split(/\n(?=### M-\d{4,}\.)/);
16359
- for (const b of milestoneBlocks) {
16480
+ const milestoneBlocks = _blocksWithOffset(planText, /\n(?=### M-\d{4,}\.)/);
16481
+ for (const { block: b, line: _ln } of milestoneBlocks) {
16360
16482
  const m = b.match(/^### (M-\d{4,})\.[ \t]*(.+?)$/m);
16361
16483
  if (m && matches(b)) {
16362
- const idx = planText.indexOf(b);
16363
- const lineNo = idx >= 0 ? planText.slice(0, idx).split('\n').length : 0;
16484
+ const lineNo = _ln;
16364
16485
  hits.planMilestones.push({ id: m[1], title: m[2].trim().slice(0, 120), preview: b.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16365
16486
  }
16366
16487
  }
@@ -16389,14 +16510,13 @@ function _brainstormFor(root, topic) {
16389
16510
  const fp = path.join(root, '.harness', src.file);
16390
16511
  if (!exists(fp)) continue;
16391
16512
  const txt = read(fp);
16392
- const blocks = txt.split(/\n(?=## 제거 )/);
16393
- for (const b of blocks) {
16513
+ const blocks = _blocksWithOffset(txt, /\n(?=## 제거 )/);
16514
+ for (const { block: b, line: _ln } of blocks) {
16394
16515
  const m = b.match(/^## 제거 (\d{4}-\d{2}-\d{2})\s*\(target:\s*"([^"]*)"\)/);
16395
16516
  if (!m) continue;
16396
16517
  if (matches(b)) {
16397
16518
  const headerMatch = b.match(/^### (.+)$/m);
16398
- const idx = txt.indexOf(b);
16399
- const lineNo = idx >= 0 ? txt.slice(0, idx).split('\n').length : 0;
16519
+ const lineNo = _ln;
16400
16520
  hits.archive[src.key].push({
16401
16521
  date: m[1],
16402
16522
  target: m[2],
@@ -16410,12 +16530,18 @@ function _brainstormFor(root, topic) {
16410
16530
  // 1.9.25: --include-code 옵션 — 소스 본문 검색 추가 (모순 감지 핵심)
16411
16531
  if (has('--include-code')) {
16412
16532
  const codeDirs = ['src', 'tests', 'bin', 'lib'];
16533
+ // 1.36.86 (검수 M3): 상한 20 이 **재귀 프레임만** 종료해 형제 디렉토리와 다음 codeDir 은 계속 훑었다
16534
+ // (실측: 30개 하위 디렉토리 → 30건 수집). 진입·복귀·바깥 루프 모두에서 전역 상한을 본다.
16535
+ const CODE_CAP = 20;
16413
16536
  for (const dir of codeDirs) {
16537
+ if (hits.code.length >= CODE_CAP) break;
16414
16538
  const dp = path.join(root, dir);
16415
16539
  if (!exists(dp)) continue;
16416
16540
  function walkCode(d) {
16541
+ if (hits.code.length >= CODE_CAP) return;
16417
16542
  let entries; try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
16418
16543
  for (const e of entries) {
16544
+ if (hits.code.length >= CODE_CAP) return;
16419
16545
  const p = path.join(d, e.name);
16420
16546
  if (e.isDirectory()) { walkCode(p); continue; }
16421
16547
  if (!/\.(js|ts|jsx|tsx|gd|cs|py|rb|go|rs)$/i.test(e.name)) continue;
@@ -16428,7 +16554,7 @@ function _brainstormFor(root, topic) {
16428
16554
  line: firstHit >= 0 ? firstHit + 1 : 0,
16429
16555
  preview: firstHit >= 0 ? lines[firstHit].trim().slice(0, 120) : ''
16430
16556
  });
16431
- if (hits.code.length >= 20) return; // 너무 많으면 stop
16557
+ if (hits.code.length >= CODE_CAP) return; // 전역 상한 — 위 진입/복귀 검사와 함께 동작
16432
16558
  }
16433
16559
  }
16434
16560
  }
@@ -16438,7 +16564,17 @@ function _brainstormFor(root, topic) {
16438
16564
  return hits;
16439
16565
  }
16440
16566
 
16441
- function _brainstormTotal(h) { return h.decisions.length + h.skills.length + h.tasks.length + h.rules.length + h.evidence.length + (h.code?.length || 0) + (h.skillHistory?.length || 0) + (h.taskLogFails?.length || 0) + (h.lessonsExplicit?.length || 0) + (h.planMilestones?.length || 0); }
16567
+ // 1.36.86: 모든 표면을 센다 이전엔 `lessons` `archive` 빠져 있어 **JSON hit 보여주면서 total=0**
16568
+ // 보고했다(자기모순). 사람용도 자체 인라인 합계를 쓰다 `code` 까지 빠뜨렸다 → 두 경로가 이 한 함수를 공유한다.
16569
+ function _brainstormTotal(h) {
16570
+ if (!h) return 0;
16571
+ const n = (x) => (x && x.length) || 0;
16572
+ const arc = h.archive ? Object.values(h.archive).reduce((s, v) => s + n(v), 0) : 0;
16573
+ // `lessons` 는 **evidence 의 파생 뷰**다 — 같은 블록을 실패 키워드로 한 번 더 투영한 것이라
16574
+ // 더하면 같은 자원을 두 번 센다(검수 확인: evidence=1/lessons=1 인데 total 이 2). 고유 자원 수를 뜻하도록 제외한다.
16575
+ return n(h.decisions) + n(h.skills) + n(h.tasks) + n(h.rules) + n(h.evidence)
16576
+ + n(h.code) + n(h.skillHistory) + n(h.taskLogFails) + n(h.lessonsExplicit) + n(h.planMilestones) + arc;
16577
+ }
16442
16578
 
16443
16579
  // 1.9.16: 워크스페이스 통합 brainstorm
16444
16580
  function _brainstormWorkspace(rootBase, topic) {
@@ -16482,8 +16618,35 @@ function _brainstormWorkspace(rootBase, topic) {
16482
16618
  }
16483
16619
  // 1.9.25: 소스 코드 본문 hits
16484
16620
  if (h.code && h.code.length) {
16485
- log(` 💻 코드 (${h.code.length})`);
16486
- h.code.slice(0, 5).forEach(c => log(` - ${c.file}:${c.line} — ${c.preview}`));
16621
+ const _s = Math.min(5, h.code.length);
16622
+ log(` 💻 코드 (${h.code.length}${h.code.length > _s ? ` 상위 ${_s}건` : ''})`);
16623
+ h.code.slice(0, _s).forEach(c => log(` - ${c.file}:${c.line} — ${c.preview}`));
16624
+ }
16625
+ // 1.36.86 (검수 High#1): 아래 5개 표면은 **집계에는 들어가는데 렌더되지 않아**
16626
+ // "3건" 이라 적고 1건만 보여주는 상태였다(실측). 단일 프로젝트 경로와 같은 표면을 모두 보여준다.
16627
+ if (h.skillHistory && h.skillHistory.length) {
16628
+ log(` 📒 skill match 이력 (${h.skillHistory.length})`);
16629
+ h.skillHistory.slice(0, 3).forEach(x => log(` - skill-suggestions.md:${x.line || '?'} — [${x.at}] "${x.query}"`));
16630
+ }
16631
+ if (h.taskLogFails && h.taskLogFails.length) {
16632
+ log(` 📜 task-log 실패 (${h.taskLogFails.length})`);
16633
+ h.taskLogFails.slice(0, 3).forEach(x => log(` - task-log.md:${x.line || '?'} — ${x.title}`));
16634
+ }
16635
+ if (h.lessonsExplicit && h.lessonsExplicit.length) {
16636
+ log(` 💡 lessons (${h.lessonsExplicit.length})`);
16637
+ h.lessonsExplicit.slice(0, 3).forEach(x => log(` - lessons.md:${x.line || '?'} — ${x.title}`));
16638
+ }
16639
+ if (h.planMilestones && h.planMilestones.length) {
16640
+ log(` 🗺 plan milestones (${h.planMilestones.length})`);
16641
+ h.planMilestones.slice(0, 3).forEach(x => log(` - plan.md:${x.line || '?'} — ${x.id} ${x.title}`));
16642
+ }
16643
+ if (h.archive) {
16644
+ const _arc = Object.entries(h.archive).filter(([, v]) => v && v.length);
16645
+ const _arcN = _arc.reduce((s, [, v]) => s + v.length, 0);
16646
+ if (_arcN) {
16647
+ log(` 🗑 archive 후보 (${_arcN})`);
16648
+ _arc.forEach(([k, v]) => v.slice(0, 2).forEach(x => log(` - ${k}-archive.md:${x.line || '?'} — ${x.originalHeader || x.target || ''}`)));
16649
+ }
16487
16650
  }
16488
16651
  }
16489
16652
  log(`\n## 📊 워크스페이스 총합: ${grandTotal}건 매치 (${paths.length} 프로젝트)${has('--include-code') ? ' (소스 코드 포함)' : ''}`);
@@ -16506,163 +16669,24 @@ function brainstormCmd(root, topic) {
16506
16669
  log(`# Brainstorm — "${topic}"`);
16507
16670
  log(`\n누적된 leerness 데이터에서 주제 관련 자원을 회수합니다.`);
16508
16671
 
16509
- // 1.9.14 B: 토큰 기반 매칭unicode word boundary. unicode 모드에서 하이픈은 escape 불필요.
16510
- function _escUnicode(s) { return String(s).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&'); }
16511
- const tokens = String(topic).split(/\s+/).filter(t => t.length >= 2);
16512
- const wordRes = tokens.map(t => new RegExp(`(?<![\\p{L}\\p{N}_])${_escUnicode(t)}(?![\\p{L}\\p{N}_])`, 'iu'));
16513
- function matches(text) { return wordRes.every(re => re.test(text)); }
16514
- // 1.9.72: skillHistory + taskLogFails 필드 추가 (brainstorm에 누적 컨텍스트 추가 회수)
16515
- const hits = { decisions: [], skills: [], tasks: [], rules: [], evidence: [], lessons: [], code: [], skillHistory: [], taskLogFails: [] };
16516
-
16517
- // decisions (1.9.14: 코드블록/Template 제외, 1.9.15: 라인 번호)
16518
- const dec = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
16519
- const decLines = dec.split('\n');
16520
- for (const b of _extractDecisionBlocks(dec)) {
16521
- if (matches(b)) {
16522
- const t = (b.match(/^### (.+)$/m) || [, ''])[1];
16523
- const lineIdx = decLines.findIndex(line => line === `### ${t}`);
16524
- const lineNo = lineIdx >= 0 ? lineIdx + 1 : 0;
16525
- hits.decisions.push({ title: t, preview: b.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
16526
- }
16527
- }
16528
- // skills
16529
- const skillsDir = path.join(root, '.harness/skills');
16530
- if (exists(skillsDir)) {
16531
- for (const id of fs.readdirSync(skillsDir)) {
16532
- const f = path.join(skillsDir, id, 'skill.json');
16533
- if (!exists(f)) continue;
16534
- try {
16535
- const s = JSON.parse(read(f));
16536
- const text = JSON.stringify(s);
16537
- if (matches(text)) hits.skills.push({ id, displayNameKo: s.displayNameKo, capabilities: s.capabilities, usage: s.usage });
16538
- } catch {}
16539
- }
16540
- }
16541
- // tasks (1.9.14: token 매칭, 1.9.15: 매치 필드 + 라인 번호)
16542
- const rows = readProgressRows(root);
16543
- const progressText = exists(progressPath(root)) ? read(progressPath(root)) : '';
16544
- for (const r of rows) {
16545
- const fields = [];
16546
- if (matches(r.request)) fields.push('request');
16547
- if (matches(r.evidence)) fields.push('evidence');
16548
- if (matches(r.nextAction)) fields.push('nextAction');
16549
- if (fields.length) {
16550
- const idx = progressText.indexOf(`| ${r.id} |`);
16551
- const lineNo = idx >= 0 ? progressText.slice(0, idx).split('\n').length : 0;
16552
- hits.tasks.push({ ...r, _fields: fields, line: lineNo });
16553
- }
16554
- }
16555
- // rules (1.9.15: 라인 번호)
16556
- if (exists(rulesPath(root))) {
16557
- const rulesText = read(rulesPath(root));
16558
- for (const r of readRules(root)) {
16559
- if (matches(r.rule)) {
16560
- const idx = rulesText.indexOf(`| ${r.id} |`);
16561
- const lineNo = idx >= 0 ? rulesText.slice(0, idx).split('\n').length : 0;
16562
- hits.rules.push({ ...r, line: lineNo });
16563
- }
16564
- }
16565
- }
16566
- // evidence — lessons 키워드 (fail/롤백/incomplete) 동반 (1.9.15: 라인 번호)
16567
- const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
16568
- for (const block of ev.split(/\n(?=## )/)) {
16569
- if (!block.startsWith('## ')) continue;
16570
- if (matches(block)) {
16571
- const t = (block.match(/^## (.+)$/m) || [, ''])[1];
16572
- const idx = ev.indexOf(block);
16573
- const lineNo = idx >= 0 ? ev.slice(0, idx).split('\n').length : 0;
16574
- hits.evidence.push({ title: t.trim(), preview: block.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
16575
- if (/✗|fail|롤백|incomplete|버그/i.test(block)) hits.lessons.push({ title: t.trim(), line: lineNo });
16576
- }
16577
- }
16578
- // 1.9.72: skill-suggestions.md rolling history hits (이전 매칭 결과 회수)
16579
- const histPath = path.join(root, '.harness', 'skill-suggestions.md');
16580
- if (exists(histPath)) {
16581
- const histTxt = read(histPath);
16582
- let pos = 0;
16583
- for (const block of histTxt.split(/\n(?=## )/)) {
16584
- if (!block.startsWith('## ')) { pos += block.length + 1; continue; }
16585
- const h = block.match(/^## ([\d-]+ [\d:]+) — query "([^"]+)"/);
16586
- if (h && matches(block)) {
16587
- const idx = histTxt.indexOf(block);
16588
- const lineNo = idx >= 0 ? histTxt.slice(0, idx).split('\n').length : 0;
16589
- hits.skillHistory.push({ at: h[1], query: h[2], preview: block.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16590
- }
16591
- }
16592
- }
16593
- // 1.9.72: task-log.md 실패 라인 hits
16594
- const tlogPath = path.join(root, '.harness', 'task-log.md');
16595
- if (exists(tlogPath)) {
16596
- const tlog = read(tlogPath);
16597
- const lines = tlog.split('\n');
16598
- for (let i = 0; i < lines.length; i++) {
16599
- const line = lines[i];
16600
- if (line.length > 4 && /✗|\bfail|롤백|재발|incomplete|버그/i.test(line) && matches(line)) {
16601
- hits.taskLogFails.push({ title: line.replace(/^[-*]\s*/, '').slice(0, 100), line: i + 1 });
16602
- }
16603
- }
16604
- }
16605
- // 1.9.131: 3 archive 파일 hits (brainstormCmd 변종) — DELETE 5종 archive 도 brainstorm 후보로
16606
- if (!hits.archive) hits.archive = { decisions: [], lessons: [], plan: [] };
16607
- const archiveSources_bsCmd = [
16608
- { key: 'decisions', file: 'decisions.archive.md' },
16609
- { key: 'lessons', file: 'lessons.archive.md' },
16610
- { key: 'plan', file: 'plan.archive.md' }
16611
- ];
16612
- for (const src of archiveSources_bsCmd) {
16613
- const fp = path.join(root, '.harness', src.file);
16614
- if (!exists(fp)) continue;
16615
- const txt = read(fp);
16616
- const blocks = txt.split(/\n(?=## 제거 )/);
16617
- for (const b of blocks) {
16618
- const m = b.match(/^## 제거 (\d{4}-\d{2}-\d{2})\s*\(target:\s*"([^"]*)"\)/);
16619
- if (!m) continue;
16620
- if (matches(b)) {
16621
- const headerMatch = b.match(/^### (.+)$/m);
16622
- const idx = txt.indexOf(b);
16623
- const lineNo = idx >= 0 ? txt.slice(0, idx).split('\n').length : 0;
16624
- hits.archive[src.key].push({
16625
- date: m[1],
16626
- target: m[2],
16627
- originalHeader: headerMatch ? headerMatch[1].trim() : null,
16628
- preview: b.slice(0, 220).replace(/\n+/g, ' '),
16629
- line: lineNo
16630
- });
16631
- }
16632
- }
16633
- }
16634
- // 1.9.116: lessons.md + plan.md milestone hits (Memory Surface 5종 완전 통합)
16635
- if (!hits.lessonsExplicit) hits.lessonsExplicit = [];
16636
- if (!hits.planMilestones) hits.planMilestones = [];
16637
- const lp_b2 = lessonsPath(root);
16638
- if (exists(lp_b2)) {
16639
- const lessonsText = read(lp_b2);
16640
- for (const block of lessonsText.split(/\n(?=### )/)) {
16641
- if (!block.startsWith('### ')) continue;
16642
- const lessonMatch = block.match(/- Lesson:[ \t]*(.+)/);
16643
- if (lessonMatch && matches(block)) {
16644
- const idx = lessonsText.indexOf(block);
16645
- const lineNo = idx >= 0 ? lessonsText.slice(0, idx).split('\n').length : 0;
16646
- hits.lessonsExplicit.push({ title: lessonMatch[1].trim().slice(0, 120), preview: block.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16647
- }
16648
- }
16649
- }
16650
- const planFile_b2 = planPath(root);
16651
- if (exists(planFile_b2)) {
16652
- const planText = read(planFile_b2);
16653
- const milestoneBlocks = planText.split(/\n(?=### M-\d{4,}\.)/);
16654
- for (const b of milestoneBlocks) {
16655
- const m = b.match(/^### (M-\d{4,})\.[ \t]*(.+?)$/m);
16656
- if (m && matches(b)) {
16657
- const idx = planText.indexOf(b);
16658
- const lineNo = idx >= 0 ? planText.slice(0, idx).split('\n').length : 0;
16659
- hits.planMilestones.push({ id: m[1], title: m[2].trim().slice(0, 120), preview: b.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16660
- }
16661
- }
16662
- }
16663
-
16664
- const total = hits.decisions.length + hits.skills.length + hits.tasks.length + hits.rules.length + hits.evidence.length + (hits.skillHistory ? hits.skillHistory.length : 0) + (hits.taskLogFails ? hits.taskLogFails.length : 0) + (hits.lessonsExplicit ? hits.lessonsExplicit.length : 0) + (hits.planMilestones ? hits.planMilestones.length : 0);
16665
- log(`\n📦 총 ${total}건 발견 (decisions ${hits.decisions.length} · skills ${hits.skills.length} · tasks ${hits.tasks.length} · rules ${hits.rules.length} · evidence ${hits.evidence.length}${hits.skillHistory && hits.skillHistory.length ? ` · skill-history ${hits.skillHistory.length}` : ''}${hits.taskLogFails && hits.taskLogFails.length ? ` · task-log-fails ${hits.taskLogFails.length}` : ''}${hits.lessonsExplicit && hits.lessonsExplicit.length ? ` · lessons ${hits.lessonsExplicit.length}` : ''}${hits.planMilestones && hits.planMilestones.length ? ` · plan ${hits.planMilestones.length}` : ''})`);
16672
+ // 1.36.86: 사람용도 **같은 수집기**(_brainstormFor)를 쓴다 이전엔 사람용이 자체 수집 코드를 갖고 있어
16673
+ // `--include-code` 에서 --json code 1건, 사람용은 "없음" 을 내는 등 두 경로가 실제로 갈렸다(실측).
16674
+ // 수집은 하나, 렌더만 구조적으로 드리프트가 불가능해진다(중복 ~145줄 제거).
16675
+ const hits = _brainstormFor(root, topic);
16676
+ // 1.36.86: 사람용 인라인 합계는 code(그리고 lessons/archive) 빠뜨려 --json 달랐다 공유 함수 사용.
16677
+ const total = _brainstormTotal(hits);
16678
+ // 1.36.86: 내역에 code/lessons(과거 실패)/archive 포함한다 총계에는 들어가는데 내역에 없어
16679
+ // 사용자가 내역을 더하면 총계와 맞지 않았다(총 7건인데 내역 합 6).
16680
+ const _arcN = hits.archive ? Object.values(hits.archive).reduce((s, v) => s + ((v && v.length) || 0), 0) : 0;
16681
+ const _part = (label, n) => (n ? ` · ${label} ${n}` : '');
16682
+ log(`\n📦 ${total}건 발견 (decisions ${hits.decisions.length} · skills ${hits.skills.length} · tasks ${hits.tasks.length} · rules ${hits.rules.length} · evidence ${hits.evidence.length}`
16683
+ + _part('code', hits.code && hits.code.length)
16684
+ + _part('past-fails', hits.lessons && hits.lessons.length)
16685
+ + _part('skill-history', hits.skillHistory && hits.skillHistory.length)
16686
+ + _part('task-log-fails', hits.taskLogFails && hits.taskLogFails.length)
16687
+ + _part('lessons', hits.lessonsExplicit && hits.lessonsExplicit.length)
16688
+ + _part('plan', hits.planMilestones && hits.planMilestones.length)
16689
+ + _part('archive', _arcN) + ')');
16666
16690
 
16667
16691
  // 1.9.15: 모든 출력에 출처 파일:라인 표시
16668
16692
  if (hits.decisions.length) {
@@ -16689,6 +16713,14 @@ function brainstormCmd(root, topic) {
16689
16713
  log(`\n## ⚠ 같은 주제 과거 실패/롤백 (${hits.lessons.length}) — 같은 실수 방지`);
16690
16714
  hits.lessons.slice(0, 5).forEach(l => log(` - .harness/review-evidence.md:${l.line || '?'} — ${l.title}`));
16691
16715
  }
16716
+ // 1.36.86: `--include-code` 결과를 사람용에도 렌더한다 — 이전엔 --json 만 code 를 보여주고
16717
+ // 사람용은 같은 입력에 "관련 자원 없음" 을 출력했다(실측 확인된 두 경로 불일치).
16718
+ if (hits.code && hits.code.length) {
16719
+ // 1.36.86 (검수): 헤딩은 전체 건수인데 5건만 보여주면서 안내가 없어 **조용한 절단**이었다 — 표시 수를 명시한다.
16720
+ const _shown = Math.min(5, hits.code.length);
16721
+ log(`\n## 💻 관련 소스 코드 (${hits.code.length}${hits.code.length > _shown ? ` — 상위 ${_shown}건 표시` : ''}) — --include-code 매칭`);
16722
+ hits.code.slice(0, _shown).forEach(c => log(` - ${c.file}${c.line ? ':' + c.line : ''}${c.preview ? ' — ' + String(c.preview).slice(0, 100) : ''}`));
16723
+ }
16692
16724
  // 1.9.72: skill-suggestions.md rolling history hits
16693
16725
  if (hits.skillHistory.length) {
16694
16726
  log(`\n## 📒 같은 주제 이전 skill match 이력 (${hits.skillHistory.length}) — 1.9.68 누적`);
package/lib/mcp-tools.js CHANGED
@@ -19,7 +19,7 @@ module.exports = [
19
19
  { name: 'leerness_lessons', requiredTier: 'read-only', description: '1.9.7/54 — 과거 결정·실수 자동 회수 (--auto: 현재 task 키워드 자동 추출)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, query: { type: 'string' }, auto: { type: 'boolean' }, limit: { type: 'number' } } } },
20
20
  { name: 'leerness_task_export', requiredTier: 'project-write', description: '1.9.60/66 — leerness task → Claude Code TodoWrite 호환 JSON (외부 AI 양방향 sync)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, to: { type: 'string' } } } },
21
21
  { name: 'leerness_env_check', requiredTier: 'read-only', description: '1.9.71/73 — .env vs .env.example 동기화 검사 (보안: 키만, 값 미노출). exit 1 if 누락 키 있음', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
22
- { name: 'leerness_brainstorm', requiredTier: 'read-only', description: '1.9.16/72/77 — 누적 컨텍스트(decisions+skills+tasks+rules+evidence+lessons+skillHistory+taskLogFails) 자원 회수. 외부 AI가 새 작업 시작 전 호출', inputSchema: { type: 'object', properties: { topic: { type: 'string' }, path: { type: 'string' }, allApps: { type: 'boolean' } }, required: ['topic'] } },
22
+ { name: 'leerness_brainstorm', requiredTier: 'read-only', description: '1.9.16/72/77 · 1.36.86 — 누적 컨텍스트 자원 회수(decisions·skills·tasks·rules·evidence·code(--include-code)·skillHistory·taskLogFails·lessonsExplicit·planMilestones·archive). `total` 은 **고유 자원 수**로, archive 를 포함하고 `lessons`(evidence 의 실패-키워드 파생 뷰)는 중복이라 제외한다. 외부 AI가 새 작업 시작 전 호출', inputSchema: { type: 'object', properties: { topic: { type: 'string' }, path: { type: 'string' }, allApps: { type: 'boolean' } }, required: ['topic'] } },
23
23
  { name: 'leerness_skill_match', requiredTier: 'safe-write', description: '1.9.45/50/83 — 사용자 task 키워드에 매칭되는 설치된 skill 추천 (jaccard 또는 embedding). 1.9.68 rolling history 자동 누적', inputSchema: { type: 'object', properties: { query: { type: 'string' }, path: { type: 'string' }, useEmbedding: { type: 'boolean' } }, required: ['query'] } },
24
24
  { name: 'leerness_skill_list', requiredTier: 'read-only', description: '1.9.84 — 워크스페이스에 설치된 skill 목록 + 사용 횟수 + 출처 (catalog/user). 외부 AI가 사용 가능한 skill 조회', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
25
25
  { name: 'leerness_health', requiredTier: 'read-only', description: '1.9.85/86 — 종합 헬스 체크 (drift + 보안 + skills + MCP + tasks + issues 배열). 외부 AI가 워크스페이스 상태 한 번에 확인', inputSchema: { type: 'object', properties: { path: { type: 'string' }, strict: { type: 'boolean' } } } },
@@ -92,9 +92,9 @@ module.exports = [
92
92
  { name: 'leerness_honesty_check', requiredTier: 'read-only', description: '1.9.305 (사용자 명시) — AI 인식론적 정직성 점검. 주장/evidence 텍스트가 (1) 모르는 걸 아는 척(근거 없는 단정), (2) 검증 없는 섣부른 판단(추정+완료 결론), (3) 외부 정보 미수집(API/버전/스펙 언급+수집흔적 없음) 중 무엇에 해당하는지 휴리스틱 탐지. read-only. 에이전트가 자기 주장을 단언 전 self-check. 인자: { path?, taskId?, text? }. 응답: { ok, findings[], dimensions[], highCount }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, taskId: { type: 'string' }, text: { type: 'string' } } } },
93
93
  { name: 'leerness_brief', requiredTier: 'read-only', description: '1.9.308 (UR-0055) — 프로젝트 청사진 회수. 프로젝트 개요/소개/목적/기능/스택/아키텍처/성공기준/방향이력을 구조화 반환(show) 또는 복사용 blueprint 텍스트(export=true). read-only. 외부 에이전트가 "이 프로젝트가 무엇이고 어디로 가는가"를 1콜로 파악하거나, 신규 프로젝트 계획으로 복사. 인자: { path?, export? }. 응답(show): { project, intro, purpose, features[], stack[], directionHistory[], ... }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, export: { type: 'boolean' } } } },
94
94
  { name: 'leerness_clarify', requiredTier: 'read-only', description: '1.36.77 (UR-0061) — 사용자 요청 텍스트의 판단-모호 신호 감지 → 사용자에게 그대로 물을 질문 목록 JSON ({ ambiguous, signals[], questions[] }). 추측 구현 방지 — 모호하면 질문 먼저.', inputSchema: { type: 'object', properties: { text: { type: 'string' }, path: { type: 'string' } }, required: ['text'] } },
95
- { name: 'leerness_preview', requiredTier: 'safe-write', description: '1.36.77 (UR-0061/0066) — 신규 기능/디자인 미리보기 승인 워크플로. action: add(title/design/features/mockup)·list·show·approve·revise(note 필수)·mockup(HTML 시안 스캐폴드 생성, force 로 재생성). 계약: approve 전 해당 기능 코드 작성 금지. list/show 는 읽기지만 단일 도구라 safe-write 로 보수 선언.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['add', 'list', 'show', 'approve', 'revise', 'mockup'] }, path: { type: 'string' }, title: { type: 'string' }, design: { type: 'string' }, features: { type: 'string' }, mockup: { type: 'string' }, id: { type: 'string' }, note: { type: 'string' }, force: { type: 'boolean' } }, required: ['action'] } },
96
- { name: 'leerness_referee', requiredTier: 'safe-write', description: '1.36.80 (P-0001) — 검증기 캘리브레이션: 검증기를 신뢰하기 전에 탐지력을 실행으로 증명(known-good 통과 + 일부러 망가뜨린 known-bad 를 기대 사유로 거부). action: add(id/check/good/bad/expectBad)·list·show·verify·drop. 명령/기대치가 바뀌면 캘리브레이션이 stale 이 되어 신뢰가 철회된다. verify-claim 의 refereeId 로 게이팅.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['add', 'list', 'show', 'verify', 'drop'] }, path: { type: 'string' }, id: { type: 'string' }, check: { type: 'string' }, good: { type: 'string' }, bad: { type: 'string' }, expectBad: { type: 'string' } }, required: ['action'] } },
97
- ];
95
+ { name: 'leerness_preview', requiredTier: 'safe-write', description: '1.36.77 (UR-0061/0066) — 신규 기능/디자인 미리보기 승인 워크플로. action: add(title/design/features/mockup)·list·show·approve·revise(note 필수)·mockup(HTML 시안 스캐폴드 생성, force 로 재생성). 계약: approve 전 해당 기능 코드 작성 금지. list/show 는 읽기지만 단일 도구라 safe-write 로 보수 선언.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['add', 'list', 'show', 'approve', 'revise', 'mockup'] }, path: { type: 'string' }, title: { type: 'string' }, design: { type: 'string' }, features: { type: 'string' }, mockup: { type: 'string' }, id: { type: 'string' }, note: { type: 'string' }, force: { type: 'boolean' } }, required: ['action'] } },
96
+ { name: 'leerness_referee', requiredTier: 'safe-write', description: '1.36.80 (P-0001) — 검증기 캘리브레이션: 검증기를 신뢰하기 전에 탐지력을 실행으로 증명(known-good 통과 + 일부러 망가뜨린 known-bad 를 기대 사유로 거부). action: add(id/check/good/bad/expectBad)·list·show·verify·drop. 명령/기대치가 바뀌면 캘리브레이션이 stale 이 되어 신뢰가 철회된다. verify-claim 의 refereeId 로 게이팅.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['add', 'list', 'show', 'verify', 'drop'] }, path: { type: 'string' }, id: { type: 'string' }, check: { type: 'string' }, good: { type: 'string' }, bad: { type: 'string' }, expectBad: { type: 'string' } }, required: ['action'] } },
97
+ ];
98
98
 
99
99
  // 1.36.58 (외부 GPT 감사 F-09): core 프로필 — 세션 시작 토큰/도구 선택 비용을 줄이는 핵심 20종.
100
100
  // `leerness mcp serve --profile core` (또는 LEERNESS_MCP_PROFILE=core) 에서 tools/list·tools/call 이 이 집합만 노출.
package/lib/pure-utils.js CHANGED
@@ -246,6 +246,19 @@ function _extractDecisionBlocks(text) {
246
246
  );
247
247
  }
248
248
 
249
+ // 1.36.85 (검수 M1): decision 블록 + **정확한 오프셋/줄번호**.
250
+ // 기존 소비부는 줄번호를 `decLines.findIndex(line => line === '### ' + title)` 로 구해,
251
+ // 같은 제목의 decision 이 둘이면 **둘 다 첫 번째 위치**를 보고했다(실측 [3,3], 기대 [3,10]).
252
+ // `_extractDecisionBlocks` 는 코드펜스를 **삭제**해 길이가 변하므로 오프셋을 쓸 수 없다 →
253
+ // 여기서는 같은 길이의 공백으로 **마스킹**해 원문과 1:1 대응을 유지한다(펜스 안의 `### ` 은 여전히 헤딩으로
254
+ // 인식되지 않고, 펜스 내용은 매칭에도 걸리지 않는다 — 기존 의도 보존).
255
+ function _decisionBlocksWithOffset(text) {
256
+ const raw = String(text || '');
257
+ const masked = raw.replace(/^```[^\n]*\n[\s\S]*?\n```[ \t]*$/gm, (m) => m.replace(/[^\n]/g, ' '));
258
+ return _blocksWithOffset(masked, /\n(?=### )/)
259
+ .filter(x => x.block.startsWith('### ') && !/^### (Template|템플릿)\b/.test(x.block.trim()));
260
+ }
261
+
249
262
  // 1.9.325 (UR-0025): 순수 intent 분류 — 사용자 텍스트의 precise/broad 신호로 의도 추정 (fs/상태 의존 0).
250
263
  function _classifyIntent(text) {
251
264
  if (!text || typeof text !== 'string') return { intent: 'default', signals: [] };
@@ -402,6 +415,30 @@ function _briefBlueprint(brief, version) {
402
415
  return L.join('\n');
403
416
  }
404
417
 
418
+ // 1.36.85: 블록 분할 + **정확한 오프셋**. brainstorm 계열이 줄번호를 `text.indexOf(block)` 으로 구했는데,
419
+ // 내용이 같은 블록이 둘 이상이면 항상 **첫 번째** 위치를 돌려줘 서로 다른 항목이 같은 줄번호로 보고됐다
420
+ // (실측: 3행·9행의 동일 lesson 블록이 둘 다 line=3). 순회하며 누적한 오프셋은 그 오류가 없다.
421
+ // sep 은 `/\n(?=…)/` 형태의 lookahead 분할을 전제한다 — 조각들은 '\n' 로 다시 이어붙으면 원문이 되므로
422
+ // 각 조각의 시작 = 앞 조각들의 길이 합 + (제거된 개행 수). 다른 형태의 sep 에는 쓰지 말 것.
423
+ // (검수 1.36.85 M4) `line` 도 **순회 중 누적**한다 — 블록마다 처음부터 slice+split 로 재스캔하면 O(n²) 라
424
+ // 대량 파일에서 사실상 멈춘다(실측: lesson 40k건 ≈ 62s). 누적은 O(n).
425
+ function _blocksWithOffset(text, sep) {
426
+ const out = [];
427
+ let pos = 0, line = 1;
428
+ for (const block of String(text || '').split(sep)) {
429
+ out.push({ block, offset: pos, line });
430
+ // 이 조각이 소비한 줄 수 + 분할에서 사라진 '\n' 1줄
431
+ line += (block.match(/\n/g) || []).length + 1;
432
+ pos += block.length + 1;
433
+ }
434
+ return out;
435
+ }
436
+ // 오프셋 → 1-기반 줄번호.
437
+ function _lineOfOffset(text, offset) {
438
+ if (!(offset >= 0)) return 0;
439
+ return String(text || '').slice(0, offset).split('\n').length;
440
+ }
441
+
405
442
  // 1.9.332 (UR-0025): 순수 lessons.md 파서 — 블록(### 날짜)→엔트리 {date, text, tag}. 필터는 호출측.
406
443
  function _parseLessonEntries(text) {
407
444
  const out = [];
@@ -1060,7 +1097,7 @@ function _renderPulseLine(data) {
1060
1097
  function _parseImplExports(src) {
1061
1098
  const out = new Set();
1062
1099
  const add = n => { if (n && /^[A-Za-z_$][\w$]*$/.test(n)) out.add(n); };
1063
- // 1) module.exports = { ... } — 브레이스 균형 + top-level 키
1100
+ // 1) module.exports = { _decisionBlocksWithOffset, _blocksWithOffset, _lineOfOffset, ... } — 브레이스 균형 + top-level 키
1064
1101
  const re = /module\.exports\s*=\s*\{/g; let mm;
1065
1102
  while ((mm = re.exec(src))) {
1066
1103
  const i = src.indexOf('{', mm.index); let depth = 0, end = -1;
@@ -1251,7 +1288,7 @@ function _replaceMdSection(content, heading, newBodyLines, opts = {}) {
1251
1288
  return [...lines.slice(0, start + 1), '', ...newBodyLines, '', ...lines.slice(end)].join('\n');
1252
1289
  }
1253
1290
 
1254
- module.exports = {
1291
+ module.exports = { _decisionBlocksWithOffset, _blocksWithOffset, _lineOfOffset,
1255
1292
  _parseImplExports, _briefUnfilled, _planGoalUnfilled, _mdSectionBody, _draftAnchors, _replaceMdSection,
1256
1293
  _matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, GLOSSARY_START, GLOSSARY_END,
1257
1294
  _isSecretKey, compareVer, parseHarnessVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.84",
3
+ "version": "1.36.86",
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
@@ -4633,7 +4633,10 @@ total++;
4633
4633
  let ok = false;
4634
4634
  try {
4635
4635
  const ni = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-noinit-')); // .harness 없는 비초기화 dir
4636
- const sr = cp.spawnSync(process.execPath, [CLI, 'selftest'], { cwd: ni, encoding: 'utf8', timeout: 30000 });
4636
+ // 1.36.85: selftest 소스문자열 검사 **행위 검사**로 바뀌며 4초대 → 약 13초가 됐다(CLI spawn 비용).
4637
+ // 30s 는 4초 시절 기준이라 전체 e2e 부하에서 타임아웃으로 터졌다(격리 재현 3/3 통과 = 검사 자체는 정상).
4638
+ // 여유를 넉넉히 준다 — 이 블록이 재는 것은 "비초기화 dir 에서도 통과하는가"이지 속도가 아니다.
4639
+ const sr = cp.spawnSync(process.execPath, [CLI, 'selftest'], { cwd: ni, encoding: 'utf8', timeout: 180000 });
4637
4640
  const sout = (sr.stdout || '') + (sr.stderr || '');
4638
4641
  const selftestOk = sr.status === 0 && /전체 \d+건 통과/.test(sout) && !/설치 손상/.test(sout);
4639
4642
  const dr = cp.spawnSync(process.execPath, [CLI, 'doctor'], { cwd: ni, encoding: 'utf8', timeout: 30000 });
@@ -5046,7 +5049,8 @@ total++;
5046
5049
  {
5047
5050
  let ok = false;
5048
5051
  try {
5049
- const r = cp.spawnSync(process.execPath, [CLI, 'selftest', '--json'], { encoding: 'utf8', timeout: 30000 });
5052
+ // 1.36.85: 행위검사 전환으로 selftest 13s 4초 시절 기준 30s 는 전체 부하에서 터진다.
5053
+ const r = cp.spawnSync(process.execPath, [CLI, 'selftest', '--json'], { encoding: 'utf8', timeout: 180000 });
5050
5054
  const j = JSON.parse(r.stdout);
5051
5055
  ok = j.ok === true && j.pass === j.total && j.fail === 0 && j.total >= 112 && r.status === 0;
5052
5056
  } catch {}