leerness 1.36.84 → 1.36.85

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.85 — 2026-07-29 — brainstorm 줄번호 오보 수정(10곳) · 실행되지 않던 명령 경로에 가드
4
+
5
+ 여러 라운드 연속 테스트 인프라를 손봤으므로 **표면을 제품 정확성으로 옮겨** 검색했다(신뢰도가 낮은 곳을 보는 편이 낫다).
6
+
7
+ - **`brainstorm` 검색 결과의 줄번호가 틀렸다.** 줄번호를 `text.indexOf(block)` 로 구하는데, **내용이 같은 블록이 둘 이상이면 항상 첫 번째 위치**를 돌려준다 — 실측으로 3행·10행에 있는 동일 lesson 블록이 둘 다 `line=3` 으로 보고됐다. 사용자가 그 줄로 가면 다른 항목이 있다. `_blocksWithOffset(text, sep)` 로 **순회하며 오프셋을 누적**하도록 10곳 전부 전환(evidence · skill history · lessons · plan milestones · archive × `_brainstormFor`/사람용 두 구현). 경계 9종(CRLF · 빈 블록 · 선두/말미 · 단일 조각 · 한글 · 구분자 없음 · 빈 문자열) 검증.
8
+ - **selftest 338/338 이 초록인데 `brainstorm` 이 통째로 죽어 있었다.** 위 수정 중 import 를 빠뜨려 `_blocksWithOffset is not defined` 로 명령 전체가 실패했는데도 스위트는 전부 통과했다 — **그 명령의 실행 경로가 어떤 케이스에도 없었다**. 신설 가드는 실제 명령을 spawn 해 exit 와 출력을 함께 보므로 (a) 줄번호 회귀 (b) import 누락 둘 다 잡는다(변이로 확인).
9
+ - **`brainstorm` 은 사람용과 `--json` 이 서로 다른 구현을 쓴다**(`--json` 은 `_brainstormFor`, 사람용은 자체 수집 코드). **텍스트 비교로는 "skills 매칭 입력이 다르다"고 보였지만 행위로 비교하니 같은 결과를 냈다** — 텍스트 diff 가 오판이었다. 다만 검수가 **세 입력에서 두 경로가 실제로 갈린다**는 반례를 냈다(`--include-code` · archive-only · workspace `--include`). 내 픽스처로는 재현되지 않았고 제대로 고치려면 사람용 수집을 `_brainstormFor` 로 단일화하는 큰 리팩터가 필요해 **이번 라운드에는 하지 않았다** — 다음 라운드 과제로 명시한다. 신설 가드에는 lessons/decisions 두 표면의 **건수와 줄번호 일치**만 넣었다(그 이상은 주장하지 않는다).
10
+ - **작업 도구가 또 한 번 틀렸다**: 일괄 치환 스크립트의 앵커가 너무 일반적이라(`for (const b of blocks) {` 가 7곳 매칭) 엉뚱한 루프까지 바꿀 뻔했다 — dry-run 의 **기대 개수 검증**이 잡았다. 각 `indexOf` 지점에서 위로 올라가 그 루프만 지목하는 방식으로 바꿔 10/10 정확 적용.
11
+
12
+ **검수 26회전 반영** (High 0 · Medium 5 중 4건):
13
+
14
+ - **내가 이 라운드에 만든 가드가 공허했다** — `_lineOfOffset` 을 `offset + 1` 로 바꿔도 통과했다("서로 다르고 양수"만 검사). 정확한 값(`[3,10]`/`[7,14]`)을 단언하도록 강화하고 3방향 변이로 확인.
15
+ - **같은 버그의 다른 철자를 놓쳤다** — decisions 경로는 `indexOf` 가 아니라 `decLines.findIndex(line === heading)` 라 내 스윕 패턴에 안 걸렸고, 결과는 동일한 오보(`[3,3]`)였다. 클래스는 표현이 아니라 **의미**로 훑어야 한다. 코드펜스를 **길이 보존 마스킹**으로 제외하는 `_decisionBlocksWithOffset` 을 추가해(펜스·Template 제외 동작은 기존과 동일함을 확인) 정확한 줄번호를 얻는다. 펜스 없는 픽스처로는 이 회귀를 못 잡아 픽스처에 펜스를 넣었다.
16
+ - **O(n²) 성능 회귀** — `_lineOfOffset` 이 블록마다 처음부터 재스캔했다(검수 실측: lesson 40k건 ≈ 62초). 순회 중 줄 수를 누적해 O(n) 으로(자체 실측 5000건 973ms → 446ms).
17
+ - **행위 검사의 비용을 명시한다**: 소스 문자열 검사를 행위 검사로 바꾸며 selftest 가 **약 4초 → 약 13초**가 됐고(CLI spawn 비용), 4초 시절 기준으로 잡힌 e2e 타임아웃(30s)이 전체 부하에서 터졌다 — **게이트가 잡았다**. 격리 재현 3/3 통과로 검사 자체는 정상임을 확인한 뒤, 비용 구조를 측정해(`init` 1330ms · 초기화 디렉토리 복사 78ms · CLI 기동 434ms) 언어별 **공유 픽스처 캐시**를 도입(14.1→13.0s)하고 타임아웃 근거를 주석에 남겼다. 행위 검사는 소스 검사보다 3배 비싸지만, 소스 검사는 리팩터 한 번에 조용히 공허해지고 행위 검사는 import 누락 같은 실제 파손까지 잡는다 — 값을 치를 만하다고 판단했다.
18
+
19
+ - 검증: selftest 339 · e2e 406/406 · 원 버그 재현→수정(3행/10행, 펜스 포함 시 7행/14행) · 신규 가드 3방향 변이(line 누적 훼손 · line 고정 · 마스킹 제거) · 오프셋 경계 8종 · 격리 재현 3회.
20
+
3
21
  ## 1.36.84 — 2026-07-29 — 탐지 대신 **동결** · 검수 25회전 커버리지 부족 5건 상환
4
22
 
5
23
  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.85 하네스를 사용합니다. 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.85는 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.85 릴리스 태그 이력** (수백 라운드) · _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.85: 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.85';
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,44 @@ 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
+ } },
5057
5116
  { name: '자기소스 검사 동결 (1.36.84): selftest 가 자기 소스를 읽어 검증하는 케이스는 더 늘지 않는다 — 신규는 행위검사로 (메타가드)', run: () => {
5058
5117
  // 왜 "탐지"가 아니라 "동결"인가:
5059
5118
  // 1.36.82~83 에서 자기참조 공허 가드를 탐지하려 세 라운드 연속 탐지기를 고쳤는데,
@@ -5377,9 +5436,9 @@ function _selfTestCases() {
5377
5436
  // → 실제 CLI 를 en 으로 돌려 헤딩이 영어로 렌더되는지 **행위**로 확인하고, ko 보존은 소스로 확인한다.
5378
5437
  let en = false;
5379
5438
  {
5380
- const _ed = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_en3_'));
5439
+ const _ed = _selftestFixture('en'); // 1.36.85: init spawn(1.3s) 대신 공유 픽스처 복사(78ms)
5381
5440
  try {
5382
- cp.spawnSync(process.execPath, [__filename, 'init', _ed, '--yes', '--no-env', '--no-stale-check', '--language', 'en'], { encoding: 'utf8', timeout: 90000 });
5441
+ if (!_ed) return false; // 픽스처 준비 실패는 조용히 통과시키지 않는다
5383
5442
  cp.spawnSync(process.execPath, [__filename, 'task', 'add', 'probe', '--path', _ed], { encoding: 'utf8', timeout: 60000 });
5384
5443
  const _md = read(path.join(_ed, '.harness', 'progress-tracker.md'));
5385
5444
  const _tid = [...String(_md).matchAll(/\|\s*(T-\d{4})\s*\|/g)].map(m => m[1]).pop();
@@ -16270,11 +16329,10 @@ function _brainstormFor(root, topic) {
16270
16329
  const hits = { decisions: [], skills: [], tasks: [], rules: [], evidence: [], lessons: [], code: [], skillHistory: [], taskLogFails: [], lessonsExplicit: [], planMilestones: [] };
16271
16330
  const dec = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
16272
16331
  const decLines = dec.split('\n');
16273
- for (const b of _extractDecisionBlocks(dec)) {
16332
+ for (const { block: b, line: _dln } of _decisionBlocksWithOffset(dec)) {
16274
16333
  if (matches(b)) {
16275
16334
  const t = (b.match(/^### (.+)$/m) || [, ''])[1];
16276
- const lineIdx = decLines.findIndex(line => line === `### ${t}`);
16277
- const lineNo = lineIdx >= 0 ? lineIdx + 1 : 0;
16335
+ const lineNo = _dln;
16278
16336
  hits.decisions.push({ title: t, preview: b.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
16279
16337
  }
16280
16338
  }
@@ -16313,12 +16371,11 @@ function _brainstormFor(root, topic) {
16313
16371
  }
16314
16372
  }
16315
16373
  const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
16316
- for (const block of ev.split(/\n(?=## )/)) {
16374
+ for (const { block: block, line: _ln } of _blocksWithOffset(ev, /\n(?=## )/)) {
16317
16375
  if (!block.startsWith('## ')) continue;
16318
16376
  if (matches(block)) {
16319
16377
  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;
16378
+ const lineNo = _ln;
16322
16379
  hits.evidence.push({ title: t.trim(), preview: block.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
16323
16380
  if (/✗|fail|롤백|incomplete|버그/i.test(block)) hits.lessons.push({ title: t.trim(), line: lineNo });
16324
16381
  }
@@ -16327,12 +16384,11 @@ function _brainstormFor(root, topic) {
16327
16384
  const histPath = path.join(root, '.harness', 'skill-suggestions.md');
16328
16385
  if (exists(histPath)) {
16329
16386
  const histTxt = read(histPath);
16330
- for (const block of histTxt.split(/\n(?=## )/)) {
16387
+ for (const { block: block, line: _ln } of _blocksWithOffset(histTxt, /\n(?=## )/)) {
16331
16388
  if (!block.startsWith('## ')) continue;
16332
16389
  const h = block.match(/^## ([\d-]+ [\d:]+) — query "([^"]+)"/);
16333
16390
  if (h && matches(block)) {
16334
- const idx = histTxt.indexOf(block);
16335
- const lineNo = idx >= 0 ? histTxt.slice(0, idx).split('\n').length : 0;
16391
+ const lineNo = _ln;
16336
16392
  hits.skillHistory.push({ at: h[1], query: h[2], preview: block.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16337
16393
  }
16338
16394
  }
@@ -16341,12 +16397,11 @@ function _brainstormFor(root, topic) {
16341
16397
  const lp_brainstorm = lessonsPath(root);
16342
16398
  if (exists(lp_brainstorm)) {
16343
16399
  const lessonsText = read(lp_brainstorm);
16344
- for (const block of lessonsText.split(/\n(?=### )/)) {
16400
+ for (const { block: block, line: _ln } of _blocksWithOffset(lessonsText, /\n(?=### )/)) {
16345
16401
  if (!block.startsWith('### ')) continue;
16346
16402
  const lessonMatch = block.match(/- Lesson:[ \t]*(.+)/);
16347
16403
  if (lessonMatch && matches(block)) {
16348
- const idx = lessonsText.indexOf(block);
16349
- const lineNo = idx >= 0 ? lessonsText.slice(0, idx).split('\n').length : 0;
16404
+ const lineNo = _ln;
16350
16405
  hits.lessonsExplicit.push({ title: lessonMatch[1].trim().slice(0, 120), preview: block.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16351
16406
  }
16352
16407
  }
@@ -16355,12 +16410,11 @@ function _brainstormFor(root, topic) {
16355
16410
  const planFile_brainstorm = planPath(root);
16356
16411
  if (exists(planFile_brainstorm)) {
16357
16412
  const planText = read(planFile_brainstorm);
16358
- const milestoneBlocks = planText.split(/\n(?=### M-\d{4,}\.)/);
16359
- for (const b of milestoneBlocks) {
16413
+ const milestoneBlocks = _blocksWithOffset(planText, /\n(?=### M-\d{4,}\.)/);
16414
+ for (const { block: b, line: _ln } of milestoneBlocks) {
16360
16415
  const m = b.match(/^### (M-\d{4,})\.[ \t]*(.+?)$/m);
16361
16416
  if (m && matches(b)) {
16362
- const idx = planText.indexOf(b);
16363
- const lineNo = idx >= 0 ? planText.slice(0, idx).split('\n').length : 0;
16417
+ const lineNo = _ln;
16364
16418
  hits.planMilestones.push({ id: m[1], title: m[2].trim().slice(0, 120), preview: b.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16365
16419
  }
16366
16420
  }
@@ -16389,14 +16443,13 @@ function _brainstormFor(root, topic) {
16389
16443
  const fp = path.join(root, '.harness', src.file);
16390
16444
  if (!exists(fp)) continue;
16391
16445
  const txt = read(fp);
16392
- const blocks = txt.split(/\n(?=## 제거 )/);
16393
- for (const b of blocks) {
16446
+ const blocks = _blocksWithOffset(txt, /\n(?=## 제거 )/);
16447
+ for (const { block: b, line: _ln } of blocks) {
16394
16448
  const m = b.match(/^## 제거 (\d{4}-\d{2}-\d{2})\s*\(target:\s*"([^"]*)"\)/);
16395
16449
  if (!m) continue;
16396
16450
  if (matches(b)) {
16397
16451
  const headerMatch = b.match(/^### (.+)$/m);
16398
- const idx = txt.indexOf(b);
16399
- const lineNo = idx >= 0 ? txt.slice(0, idx).split('\n').length : 0;
16452
+ const lineNo = _ln;
16400
16453
  hits.archive[src.key].push({
16401
16454
  date: m[1],
16402
16455
  target: m[2],
@@ -16517,11 +16570,10 @@ function brainstormCmd(root, topic) {
16517
16570
  // decisions (1.9.14: 코드블록/Template 제외, 1.9.15: 라인 번호)
16518
16571
  const dec = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
16519
16572
  const decLines = dec.split('\n');
16520
- for (const b of _extractDecisionBlocks(dec)) {
16573
+ for (const { block: b, line: _dln } of _decisionBlocksWithOffset(dec)) {
16521
16574
  if (matches(b)) {
16522
16575
  const t = (b.match(/^### (.+)$/m) || [, ''])[1];
16523
- const lineIdx = decLines.findIndex(line => line === `### ${t}`);
16524
- const lineNo = lineIdx >= 0 ? lineIdx + 1 : 0;
16576
+ const lineNo = _dln;
16525
16577
  hits.decisions.push({ title: t, preview: b.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
16526
16578
  }
16527
16579
  }
@@ -16565,12 +16617,11 @@ function brainstormCmd(root, topic) {
16565
16617
  }
16566
16618
  // evidence — lessons 키워드 (fail/롤백/incomplete) 동반 (1.9.15: 라인 번호)
16567
16619
  const ev = exists(evidencePath(root)) ? read(evidencePath(root)) : '';
16568
- for (const block of ev.split(/\n(?=## )/)) {
16620
+ for (const { block: block, line: _ln } of _blocksWithOffset(ev, /\n(?=## )/)) {
16569
16621
  if (!block.startsWith('## ')) continue;
16570
16622
  if (matches(block)) {
16571
16623
  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;
16624
+ const lineNo = _ln;
16574
16625
  hits.evidence.push({ title: t.trim(), preview: block.slice(0, 200).replace(/\n+/g, ' '), line: lineNo });
16575
16626
  if (/✗|fail|롤백|incomplete|버그/i.test(block)) hits.lessons.push({ title: t.trim(), line: lineNo });
16576
16627
  }
@@ -16580,12 +16631,11 @@ function brainstormCmd(root, topic) {
16580
16631
  if (exists(histPath)) {
16581
16632
  const histTxt = read(histPath);
16582
16633
  let pos = 0;
16583
- for (const block of histTxt.split(/\n(?=## )/)) {
16634
+ for (const { block: block, line: _ln } of _blocksWithOffset(histTxt, /\n(?=## )/)) {
16584
16635
  if (!block.startsWith('## ')) { pos += block.length + 1; continue; }
16585
16636
  const h = block.match(/^## ([\d-]+ [\d:]+) — query "([^"]+)"/);
16586
16637
  if (h && matches(block)) {
16587
- const idx = histTxt.indexOf(block);
16588
- const lineNo = idx >= 0 ? histTxt.slice(0, idx).split('\n').length : 0;
16638
+ const lineNo = _ln;
16589
16639
  hits.skillHistory.push({ at: h[1], query: h[2], preview: block.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16590
16640
  }
16591
16641
  }
@@ -16613,14 +16663,13 @@ function brainstormCmd(root, topic) {
16613
16663
  const fp = path.join(root, '.harness', src.file);
16614
16664
  if (!exists(fp)) continue;
16615
16665
  const txt = read(fp);
16616
- const blocks = txt.split(/\n(?=## 제거 )/);
16617
- for (const b of blocks) {
16666
+ const blocks = _blocksWithOffset(txt, /\n(?=## 제거 )/);
16667
+ for (const { block: b, line: _ln } of blocks) {
16618
16668
  const m = b.match(/^## 제거 (\d{4}-\d{2}-\d{2})\s*\(target:\s*"([^"]*)"\)/);
16619
16669
  if (!m) continue;
16620
16670
  if (matches(b)) {
16621
16671
  const headerMatch = b.match(/^### (.+)$/m);
16622
- const idx = txt.indexOf(b);
16623
- const lineNo = idx >= 0 ? txt.slice(0, idx).split('\n').length : 0;
16672
+ const lineNo = _ln;
16624
16673
  hits.archive[src.key].push({
16625
16674
  date: m[1],
16626
16675
  target: m[2],
@@ -16637,12 +16686,11 @@ function brainstormCmd(root, topic) {
16637
16686
  const lp_b2 = lessonsPath(root);
16638
16687
  if (exists(lp_b2)) {
16639
16688
  const lessonsText = read(lp_b2);
16640
- for (const block of lessonsText.split(/\n(?=### )/)) {
16689
+ for (const { block: block, line: _ln } of _blocksWithOffset(lessonsText, /\n(?=### )/)) {
16641
16690
  if (!block.startsWith('### ')) continue;
16642
16691
  const lessonMatch = block.match(/- Lesson:[ \t]*(.+)/);
16643
16692
  if (lessonMatch && matches(block)) {
16644
- const idx = lessonsText.indexOf(block);
16645
- const lineNo = idx >= 0 ? lessonsText.slice(0, idx).split('\n').length : 0;
16693
+ const lineNo = _ln;
16646
16694
  hits.lessonsExplicit.push({ title: lessonMatch[1].trim().slice(0, 120), preview: block.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16647
16695
  }
16648
16696
  }
@@ -16650,12 +16698,11 @@ function brainstormCmd(root, topic) {
16650
16698
  const planFile_b2 = planPath(root);
16651
16699
  if (exists(planFile_b2)) {
16652
16700
  const planText = read(planFile_b2);
16653
- const milestoneBlocks = planText.split(/\n(?=### M-\d{4,}\.)/);
16654
- for (const b of milestoneBlocks) {
16701
+ const milestoneBlocks = _blocksWithOffset(planText, /\n(?=### M-\d{4,}\.)/);
16702
+ for (const { block: b, line: _ln } of milestoneBlocks) {
16655
16703
  const m = b.match(/^### (M-\d{4,})\.[ \t]*(.+?)$/m);
16656
16704
  if (m && matches(b)) {
16657
- const idx = planText.indexOf(b);
16658
- const lineNo = idx >= 0 ? planText.slice(0, idx).split('\n').length : 0;
16705
+ const lineNo = _ln;
16659
16706
  hits.planMilestones.push({ id: m[1], title: m[2].trim().slice(0, 120), preview: b.slice(0, 220).replace(/\n+/g, ' '), line: lineNo });
16660
16707
  }
16661
16708
  }
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.85",
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 {}