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 +18 -0
- package/README.md +4 -4
- package/bin/leerness.js +94 -47
- package/lib/pure-utils.js +39 -2
- package/package.json +1 -1
- package/scripts/e2e.js +6 -2
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
4817
|
+
const _te = _selftestFixture('en'); // 1.36.85: 공유 en 픽스처
|
|
4796
4818
|
try {
|
|
4797
|
-
|
|
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 =
|
|
5439
|
+
const _ed = _selftestFixture('en'); // 1.36.85: init spawn(1.3s) 대신 공유 픽스처 복사(78ms)
|
|
5381
5440
|
try {
|
|
5382
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 {}
|