leerness 1.36.47 → 1.36.49

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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.49 — 2026-07-21 — codex 6차 헌트: 신작 표면(1.36.28~48) 10건 전수 수정 (High 3 · Medium 5 · Low 2)
4
+
5
+ 이번 창에서 신설된 기능들(enforce/toggles/anchors/release note/JSON 계약)을 외부 적대 검증(codex, 30만 토큰) — 10건 발견, 10건 전수 재현 확정 후 전부 수정 (반박 0, DEFER 0).
6
+
7
+ - **#1 High — rules 손상행 무언 삭제 재발 경로**: `nextRuleId` 가 파싱된 룰만 스캔해 손상행(셀 부족)의 R-id 가 새 룰에 재발급 → writeRules 보존 필터가 손상행을 기존 룰로 오인해 삭제(1.36.37 보존 로직이 지키려던 바로 그 행). 원시 파일의 모든 `| R-XXXX |` 를 카운트.
8
+ - **#2 High — enforce check 손상 타임스탬프 통과**: `new Date('not-a-date')` → NaN → `NaN > window` 가 false 라 손상 last-handoff.json 이 강제를 통과("handoff NaNh 전"). fail-closed(Infinity 취급) + `--json` 성공 시 구조화 출력(종전 빈 stdout).
9
+ - **#3 High — anchors 위험신호로 --apply 마다 파일 증식**: 개행/`##` 포함 package description 이 섹션 본문에 구조 마크다운으로 박혀 재적용 시 섹션 경계가 밀려 중복 증식. 신호 한 줄 정규화(`_oneLine`) + `_replaceMdSection` 본문 개행 평탄화 방어층.
10
+ - **#4 — `## Goal` 헤딩 부재를 "작성됨"으로 오보**: 섹션 자체가 없으면 gap 미탐지 + --apply 무동작. 부재=gap + `appendIfMissing` 으로 헤딩 신설.
11
+ - **#5 — toggle set 이 손상 toggles.json 을 기본값으로 리셋**: lens 하나 바꾸면 gate:false 가 무언 ON 복원. 변경 진입점 fail-closed(1.36.28 스토어 손상 클래스), 원본 보존.
12
+ - **#6 — release note 정상 입력 2종 실패**: 대시 시작 노트("- first")가 nonFlagArgs 전역 필터에 걸려 입력 불가(원시 argv 복원으로 수정) + SemVer 빌드메타(`1.2.3+build.1`)의 `+` 미이스케이프로 같은 버전 헤딩 중복 생성(전체 regex 이스케이프).
13
+ - **#7 — review --json 오류경로가 사람용 텍스트**: target/persona/파일 검증 실패 시 failJson 단일 JSON 계약 (target_required/persona_required/file_not_found/file_too_large/persona_not_found).
14
+ - **#8 — 무효 --window 무언 보정 설치**: 0/NaN→24 무언 보정, 400자리 정수→`WIN_MIN=Infinity` 훅(상시 차단). 1~8760 정수 외 invalid_window 거부.
15
+ - **#9 Low — 커밋 0개 저장소를 enforce audit 이 실패 처리**: unborn HEAD 를 git_log_failed 로 오분류 → `checked:0` 성공(no_commits).
16
+ - **#10 Low — whats-new 역순 범위 빈 성공**: from > to 를 invalid_range 명시 오류로.
17
+ - 검증: 재현→수정→재검 전 케이스 실측, selftest 316→320(행위 2 + 소스가드 2, 자기참조 트랩 회피형 앵커), e2e.
18
+
19
+ ## 1.36.48 — 2026-07-16 — ACP 입장 명문화 (docs/interoperability.md, 사용자 질의)
20
+
21
+ 사용자 질의("ACP 가 고려돼 있는가?")에 대한 검토 결과 언급 0 — "미고려" 를 "고려 후 결정" 으로 전환. **Agent Client Protocol**(Zed 계열): 에디터↔에이전트 토폴로지라 운영 레이어인 leerness 에 직접 구현 자리 없음 — ACP 에이전트는 MCP(.mcp.json) 경유로 이미 간접 호환, enforce 는 프로토콜 무관 커버. **Agent Communication Protocol**(BeeAI, REST): 오프라인-퍼스트/0-deps 원칙 충돌 — 동일 문제는 .leerness/ substrate 가 해결. 상호운용성 문서 신설(지원 표면 표 + 신규 프로토콜 평가 기준 3항 + 재검토 트리거), README Links 연결, decision 기록.
22
+
3
23
  ## 1.36.47 — 2026-07-16 — anchors 초안 품질 + 재초안 — 실프로젝트 6곳 실적용에서 발견·즉시 교정
4
24
 
5
25
  사용자 승인 하에 실프로젝트 6곳(hive-analytics/auto-macro/cafe-macro/blog-macro/view-work/AEGIRINE)을 1.36.46 으로 **무유실 마이그레이션**(5표면 카운트 전량 보존, auto-macro 는 1.9.441→46 32버전 점프에 CLAUDE 커스텀 포함 무손실 — 보존 3부작의 실전 입증)하고 anchors 초안을 일괄 적용한 결과, 실데이터에서 초안 품질 결함 2종 발견:
package/README.md CHANGED
@@ -112,6 +112,7 @@ Full command reference, workflows, and architecture: **[README.ko.md](./README.k
112
112
  - Site & release videos: https://leerness.pages.dev
113
113
  - Changelog: [CHANGELOG.md](./CHANGELOG.md)
114
114
  - How this gets tested — methodology, results, and limitations: [docs/clean-room-evaluations.md](./docs/clean-room-evaluations.md)
115
+ - Interoperability (MCP · ACP stance · state substrate): [docs/interoperability.md](./docs/interoperability.md)
115
116
  - Contributing & test tiers (`test:fast` / `test:core` / `npm test`): [CONTRIBUTING.md](./CONTRIBUTING.md)
116
117
 
117
118
  ## License
@@ -121,7 +122,7 @@ MIT
121
122
  <!-- leerness:project-readme:start -->
122
123
  ## Leerness Project Harness
123
124
 
124
- 이 프로젝트는 Leerness v1.36.47 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.49 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
126
 
126
127
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
127
128
 
@@ -175,7 +176,7 @@ leerness memory restore decision <date|title>
175
176
 
176
177
  ### MCP server (외부 AI 통합)
177
178
 
178
- Leerness v1.36.47는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
179
+ Leerness v1.36.49는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
179
180
 
180
181
  ```jsonc
181
182
  // 카테고리별
@@ -196,7 +197,7 @@ Leerness v1.36.47는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
196
197
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
197
198
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
198
199
 
199
- 현재 누적: **v1.9.x → 1.36.47 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.49 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
201
 
201
202
  ### 성능 가이드
202
203
 
@@ -234,6 +235,6 @@ leerness release pack --close --auto-main-push
234
235
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
235
236
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
236
237
 
237
- Last synced by Leerness v1.36.47: 2026-07-21
238
+ Last synced by Leerness v1.36.49: 2026-07-21
238
239
  <!-- leerness:project-readme:end -->
239
240
 
package/bin/leerness.js CHANGED
@@ -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.47';
37
+ const VERSION = '1.36.49';
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') 시 호스트 프로세스 오염.
@@ -4894,6 +4894,45 @@ function _selfTestCases() {
4894
4894
  const wired = read(path.join(__dirname, '..', 'lib', 'audit.js')).includes("require('./state-integrity')") && read(path.join(__dirname, '..', 'lib', 'health.js')).includes("require('./state-integrity')");
4895
4895
  return onlyBad && noHarness && wired;
4896
4896
  } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
4897
+ } },
4898
+ { name: 'rules 손상행 ID 충돌 방지 (1.36.49, codex 6차 #1 High): nextRuleId 가 파싱불가 행의 R-id 도 카운트 — 행위검사', run: () => {
4899
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_rid_'));
4900
+ try {
4901
+ fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true });
4902
+ fs.writeFileSync(path.join(tmp, '.harness', 'rules.md'), '| R-0007 | every-session |\n');
4903
+ return nextRuleId(tmp) === 'R-0008'; // 종전: 손상행 무시 → R-0001 재발급 → writeRules 가 손상행을 무언 삭제
4904
+ } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
4905
+ } },
4906
+ { name: 'anchors 위험신호 정규화 + 헤딩 신설 (1.36.49, codex 6차 #3 High/#4): 개행·헤딩 포함 description 평탄화 + appendIfMissing — 순수 행위검사', run: () => {
4907
+ const p = require('../lib/pure-utils');
4908
+ const d = p._draftAnchors({ pkgDescription: 'Alpha | Beta\n## Injected Heading\nmore' });
4909
+ const flat = [...d.purpose, ...d.goal].every(l => !l.includes('\n') && !/^#/.test(l));
4910
+ // 섹션 부재 + appendIfMissing → 헤딩 신설; 미지정이면 원문 불변(기존 계약 유지)
4911
+ const base = '# T\n\n## Other\nx';
4912
+ const appended = p._replaceMdSection(base, 'Goal', ['- g'], { appendIfMissing: true });
4913
+ const addOk = appended.includes('## Goal') && appended.includes('- g') && p._replaceMdSection(base, 'Goal', ['- g']) === base;
4914
+ // 본문 줄 내 개행은 평탄화(구조 오염 방어층) + 같은 입력 재적용 멱등
4915
+ const rep1 = p._replaceMdSection('## Goal\nold\n## Next\ny', 'Goal', ['a\nb']);
4916
+ const idem = rep1.split('\n').includes('a') && rep1.split('\n').includes('b') && p._replaceMdSection(rep1, 'Goal', ['a\nb']) === rep1;
4917
+ return flat && addOk && idem;
4918
+ } },
4919
+ { name: 'enforce fail-closed 3종 (1.36.49, codex 6차 #2 High/#8/#9): 손상 타임스탬프·무효 window·unborn HEAD — 소스가드', run: () => {
4920
+ const s = read(__filename);
4921
+ const nanClosed = /const ageH = Number\.isFinite\(lastMs\)/.test(s) && /new Date\(lastAt\)\.getTime\(\) : NaN/.test(s);
4922
+ const winValid = /failJson\(json, 'invalid_window'/.test(s) && /Number\.isInteger\(windowHours\) \|\| windowHours < 1/.test(s);
4923
+ const unborn = /does not have any commits\|unknown revision/.test(s) && /note: 'no_commits'/.test(s);
4924
+ const jsonOk = /ok: true, lastHandoff: lastAt/.test(s); // --json 성공도 구조화 출력
4925
+ return nanClosed && winValid && unborn && jsonOk;
4926
+ } },
4927
+ { name: '스토어/계약 4종 (1.36.49, codex 6차 #5/#6/#7/#10): toggle set fail-closed·release note 대시/regex·review failJson·whats-new 역순 — 소스가드', run: () => {
4928
+ const tg = read(path.join(__dirname, '..', 'lib', 'toggles.js'));
4929
+ const tOk = /JSON\.parse\(read\(f\)\)/.test(tg) && tg.includes('덮어쓰기 거부');
4930
+ const s = read(__filename);
4931
+ const noteOk = /raw\.indexOf\('note'\)/.test(s) && /version\.replace\(\/\[\.\*\+\?\^/.test(s);
4932
+ const revOk = /failJson\(_rj, 'file_not_found'/.test(s) && /failJson\(_rj, 'persona_not_found'/.test(s);
4933
+ const dg = read(path.join(__dirname, '..', 'lib', 'diagnostics.js'));
4934
+ const wnOk = /compareVer\(fromV, toV\) > 0/.test(dg) && dg.includes("code: 'invalid_range'");
4935
+ return tOk && noteOk && revOk && wnOk;
4897
4936
  } }
4898
4937
  ];
4899
4938
  }
@@ -5081,7 +5120,11 @@ function enforceCmd(root, sub) {
5081
5120
  if (sub === 'install') {
5082
5121
  if (!exists(path.join(root, '.git'))) { failJson(json, 'not_a_git_repo', `git 저장소가 아님: ${root} — pre-commit 강제는 git 저장소에서만 가능`); return; }
5083
5122
  if (!exists(path.join(root, '.harness'))) { failJson(json, 'harness_missing', `leerness 미설치: ${root} — 먼저 leerness init`); return; }
5084
- const windowHours = Math.max(1, parseInt(arg('--window', '24'), 10) || 24);
5123
+ // 1.36.49 (codex 6차 #8): 명시된 무효 window 24 무언 보정하지 않고 거부 — 종전엔 0/NaN→24,
5124
+ // 400자리 정수→Infinity 가 그대로 설치돼 훅에 WIN_MIN=Infinity(find -mmin 상시 실패=상시 차단)가 박혔다.
5125
+ const winRaw = arg('--window', '24');
5126
+ const windowHours = Number(winRaw);
5127
+ if (!Number.isInteger(windowHours) || windowHours < 1 || windowHours > 8760) { failJson(json, 'invalid_window', `--window 는 1~8760 정수(시간)만: 받음 "${winRaw}"`); return; }
5085
5128
  const strict = has('--strict');
5086
5129
  mkdirp(path.dirname(_enforceCfgPath(root)));
5087
5130
  writeUtf8(_enforceCfgPath(root), JSON.stringify({ windowHours, strict, installedAt: now() }, null, 2) + '\n');
@@ -5130,7 +5173,11 @@ function enforceCmd(root, sub) {
5130
5173
  const lhf = _lastHandoffPath(root);
5131
5174
  let lastAt = null;
5132
5175
  try { if (exists(lhf)) { const j = JSON.parse(read(lhf)); lastAt = j.last || (Array.isArray(j.history) && j.history.length ? j.history[j.history.length - 1] : null); } } catch {}
5133
- const ageH = lastAt ? (Date.now() - new Date(lastAt).getTime()) / 3600000 : Infinity;
5176
+ // 1.36.49 (codex 6차 #2 High): 손상 타임스탬프 fail-closed — 종전엔 new Date('not-a-date') NaN,
5177
+ // NaN > windowHours 가 false 라서 손상 파일이 강제를 통과("handoff NaNh 전")했다.
5178
+ const lastMs = lastAt ? new Date(lastAt).getTime() : NaN;
5179
+ const ageH = Number.isFinite(lastMs) ? (Date.now() - lastMs) / 3600000 : Infinity;
5180
+ if (!Number.isFinite(lastMs)) lastAt = null;
5134
5181
  if (ageH > cfg.windowHours) {
5135
5182
  fail(`🔒 leerness 강제: 최근 ${cfg.windowHours}h 내 handoff 흔적 없음${lastAt ? ` (마지막: ${lastAt})` : ''}`);
5136
5183
  log(` → 커밋 전 실행: leerness handoff . (컨텍스트 적재 + 사용 흔적)`);
@@ -5141,7 +5188,9 @@ function enforceCmd(root, sub) {
5141
5188
  const r = cp.spawnSync(process.execPath, [__filename, 'gate', root], { encoding: 'utf8', env: { ...process.env, LEERNESS_INTERNAL: '1' } });
5142
5189
  if (r.status !== 0) { fail(`🔒 leerness 강제(strict): gate 실패 — 커밋 차단`); process.stdout.write((r.stdout || '').slice(-1500)); process.exitCode = 1; return; }
5143
5190
  }
5144
- if (!json) ok(`enforce check 통과 (handoff ${lastAt ? Math.round(ageH * 10) / 10 + 'h 전' : '-'}${cfg.strict ? ' + gate' : ''})`);
5191
+ // 1.36.49 (codex 6차 #2 부속): --json 성공 stdout 아니라 구조화 객체를 내보낸다.
5192
+ if (json) { log(JSON.stringify({ ok: true, lastHandoff: lastAt, ageHours: Number.isFinite(ageH) ? Math.round(ageH * 10) / 10 : null, windowHours: cfg.windowHours, strict: !!cfg.strict }, null, 2)); return; }
5193
+ ok(`enforce check 통과 (handoff ${lastAt ? Math.round(ageH * 10) / 10 + 'h 전' : '-'}${cfg.strict ? ' + gate' : ''})`);
5145
5194
  return;
5146
5195
  }
5147
5196
  if (sub === 'audit') {
@@ -5152,6 +5201,11 @@ function enforceCmd(root, sub) {
5152
5201
  let handoffs = [];
5153
5202
  try { const j = JSON.parse(read(_lastHandoffPath(root))); handoffs = (j.history || []).map(t => new Date(t).getTime()).filter(n => !Number.isNaN(n)); } catch {}
5154
5203
  const gl = cp.spawnSync('git', ['-C', root, 'log', '-n', '30', '--pretty=format:%H|%ct|%s'], { encoding: 'utf8', timeout: 10000 });
5204
+ // 1.36.49 (codex 6차 #9): 커밋 0개(unborn HEAD)는 정상 저장소 — 실패가 아니라 "검사 대상 없음" 성공.
5205
+ if (gl.status !== 0 && /does not have any commits|unknown revision|bad default revision|ambiguous argument/i.test(gl.stderr || '')) {
5206
+ if (json) { log(JSON.stringify({ checked: 0, windowHours: cfg2.windowHours, suspects: [], note: 'no_commits' }, null, 2)); return; }
5207
+ ok(' 커밋 0개 — 검사 대상 없음'); return;
5208
+ }
5155
5209
  if (gl.status !== 0) { failJson(json, 'git_log_failed', 'git log 실패 — git 저장소인지 확인'); return; }
5156
5210
  const winMs = cfg2.windowHours * 3600000;
5157
5211
  const suspects = [];
@@ -5205,8 +5259,10 @@ function anchorsCmd(root, sub) {
5205
5259
  const naM = '<!-- leerness:na';
5206
5260
  // 1.36.47: draft 마커가 남아있는 섹션(미확정 초안)은 재초안 대상 — 초안 품질 개선을 기존 적용본에도 전파 가능.
5207
5261
  const _draftM = '<!-- draft: leerness anchors';
5208
- const briefGap = !!briefTxt && !briefTxt.includes(naM) && (_briefUnfilled(briefTxt) || (_mdSectionBody(briefTxt, 'Purpose') || '').includes(_draftM));
5209
- const planGap = !!planTxt && !planTxt.includes(naM) && (_planGoalUnfilled(planTxt) || (_mdSectionBody(planTxt, 'Goal') || '').includes(_draftM));
5262
+ // 1.36.49 (codex 6차 #4): 헤딩 자체가 없는 파일도 gap 종전 _sectionUnfilled 는 "섹션 없음→미플래그"(감지용 보수 기본)
5263
+ // ## Goal 아예 없는 plan.md "작성됨"으로 보고되고 draft --apply 무동작이었다. apply 시 헤딩을 신설한다.
5264
+ const briefGap = !!briefTxt && !briefTxt.includes(naM) && (_briefUnfilled(briefTxt) || _mdSectionBody(briefTxt, 'Purpose') == null || (_mdSectionBody(briefTxt, 'Purpose') || '').includes(_draftM));
5265
+ const planGap = !!planTxt && !planTxt.includes(naM) && (_planGoalUnfilled(planTxt) || _mdSectionBody(planTxt, 'Goal') == null || (_mdSectionBody(planTxt, 'Goal') || '').includes(_draftM));
5210
5266
  if (!sub || sub === 'status') {
5211
5267
  if (json) { log(JSON.stringify({ briefUnfilled: briefGap, planGoalUnfilled: planGap }, null, 2)); return; }
5212
5268
  log(`# leerness anchors — 정체성앵커 상태`);
@@ -5228,10 +5284,10 @@ function anchorsCmd(root, sub) {
5228
5284
  const apply = has('--apply');
5229
5285
  const applied = [];
5230
5286
  if (briefGap && draft.purpose.length) {
5231
- if (apply) { writeUtf8(bf, _replaceMdSection(briefTxt, 'Purpose', draft.purpose)); applied.push('project-brief.md#Purpose'); }
5287
+ if (apply) { writeUtf8(bf, _replaceMdSection(briefTxt, 'Purpose', draft.purpose, { appendIfMissing: true })); applied.push('project-brief.md#Purpose'); }
5232
5288
  }
5233
5289
  if (planGap && draft.goal.length) {
5234
- if (apply) { writeUtf8(pf, _replaceMdSection(planTxt, 'Goal', draft.goal)); applied.push('plan.md#Goal'); }
5290
+ if (apply) { writeUtf8(pf, _replaceMdSection(planTxt, 'Goal', draft.goal, { appendIfMissing: true })); applied.push('plan.md#Goal'); }
5235
5291
  }
5236
5292
  if (json) { log(JSON.stringify({ briefUnfilled: briefGap, planGoalUnfilled: planGap, draft: { purpose: draft.purpose, goal: draft.goal }, applied, dryRun: !apply }, null, 2)); return; }
5237
5293
  log(`# leerness anchors draft ${apply ? '(적용)' : '(dry-run — 적용: --apply)'}`);
@@ -14168,22 +14224,24 @@ function personaCmd(root, sub, idOrName, ...rest) {
14168
14224
 
14169
14225
  function reviewCmd(root, target) {
14170
14226
  root = absRoot(root || process.cwd());
14171
- if (!target) { fail('review <file> 필요. 예: leerness review src/api.js --persona security'); return process.exit(1); }
14227
+ // 1.36.49 (codex 6차 #7): --json 요청 오류 경로도 단일 JSON 계약(failJson) 종전 fail() 은 사람용 텍스트를 냈다.
14228
+ const _rj = has('--json');
14229
+ if (!target) { failJson(_rj, 'target_required', 'review <file> 필요. 예: leerness review src/api.js --persona security'); return process.exit(1); }
14172
14230
  const personaIds = (arg('--persona', null) || '').split(',').map(s => s.trim()).filter(Boolean);
14173
- if (!personaIds.length) { fail('--persona <id> 필요. \`leerness persona list\`로 확인'); return process.exit(1); }
14231
+ if (!personaIds.length) { failJson(_rj, 'persona_required', '--persona <id> 필요. `leerness persona list`로 확인'); return process.exit(1); }
14174
14232
 
14175
14233
  // 파일 확인
14176
14234
  const filePath = path.isAbsolute(target) ? target : path.join(root, target);
14177
- if (!exists(filePath)) { fail(`파일 없음: ${filePath}`); return process.exit(1); }
14235
+ if (!exists(filePath)) { failJson(_rj, 'file_not_found', `파일 없음: ${filePath}`); return process.exit(1); }
14178
14236
  const fileContent = read(filePath);
14179
14237
  const fileSize = Buffer.byteLength(fileContent, 'utf8');
14180
- if (fileSize > 100 * 1024) { fail(`파일 너무 큼: ${fileSize} bytes. 100KB 미만 권장.`); return process.exit(1); }
14238
+ if (fileSize > 100 * 1024) { failJson(_rj, 'file_too_large', `파일 너무 큼: ${fileSize} bytes. 100KB 미만 권장.`); return process.exit(1); }
14181
14239
 
14182
14240
  // 페르소나 해석
14183
14241
  const personas = [];
14184
14242
  for (const id of personaIds) {
14185
14243
  const p = _resolvePersona(root, id);
14186
- if (!p) { fail(`페르소나 없음: ${id}. \`leerness persona list\` 확인`); return process.exit(1); }
14244
+ if (!p) { failJson(_rj, 'persona_not_found', `페르소나 없음: ${id}. \`leerness persona list\` 확인`); return process.exit(1); }
14187
14245
  personas.push(p);
14188
14246
  }
14189
14247
 
@@ -15592,6 +15650,15 @@ function nextRuleId(root) {
15592
15650
  const m = r.id.match(/^R-(\d{4,})$/);
15593
15651
  if (m) max = Math.max(max, Number(m[1]));
15594
15652
  }
15653
+ // 1.36.49 (codex 6차 #1 High): 파싱 불가(손상) 행의 R-id 도 카운트 — 종전엔 파싱된 룰만 스캔해
15654
+ // 손상 행과 같은 ID 가 새 룰에 재발급되고, writeRules 의 보존 필터(l.startsWith('| '+id))가
15655
+ // 그 손상 행을 "이미 표에 있는 룰"로 오인해 무언 삭제했다(보존 로직이 지키려던 바로 그 행).
15656
+ try {
15657
+ if (exists(rulesPath(root))) {
15658
+ const re = /(?:^|\n)\|\s*R-(\d+)\s*\|/g; const raw = read(rulesPath(root)); let m2;
15659
+ while ((m2 = re.exec(raw))) max = Math.max(max, Number(m2[1]));
15660
+ }
15661
+ } catch {}
15595
15662
  // 1.11.3 (14th 버그헌트 P2, UR-0180): 아카이브의 R-id 도 카운트 — 기존엔 활성 rules 만 스캔해 rule remove 후 같은 R-id 가 다른 룰에 재사용됐음(아카이브 ID 충돌).
15596
15663
  try {
15597
15664
  if (exists(rulesArchivePath(root))) {
@@ -15812,7 +15879,9 @@ function releaseNote(root, text) {
15812
15879
  if (exists(pkgFile)) { try { version = JSON.parse(read(pkgFile)).version || 'unknown'; } catch {} }
15813
15880
  const clFile = path.join(root, 'CHANGELOG.md');
15814
15881
  const date = today();
15815
- const headerRe = new RegExp(`^## ${version.replace(/\./g, '\\.')} `, 'm');
15882
+ // 1.36.49 (codex 6차 #6b): 점만 이스케이프하던 종전 regex 는 SemVer 빌드메타(1.2.3+build.1) `+` 수량자로
15883
+ // 해석해 기존 헤딩을 못 찾고 같은 버전 헤딩을 중복 생성했다 — 전체 regex 특수문자 이스케이프.
15884
+ const headerRe = new RegExp('^## ' + version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ' — ', 'm');
15816
15885
  if (exists(clFile)) {
15817
15886
  const cur = read(clFile);
15818
15887
  if (headerRe.test(cur)) {
@@ -22405,7 +22474,16 @@ async function main() {
22405
22474
  // 1.30.4 (14th리뷰 F7): rule 하위명령 미매칭 시 잘못된 토큰 명시 + usage(종전엔 top-level 'unknown_command: rule' 로 유효 부모명을 오인 표기).
22406
22475
  if (cmd === 'rule') { failJson(has('--json'), 'unknown_subcommand', `알 수 없는 rule 하위명령: ${args[1] || '(없음)'} — leerness rule add|list|remove|pause|resume|stop|resume-all|verify`); return process.exit(process.exitCode || 1); }
22407
22476
  if (cmd === 'release' && args[1] === 'bump') return releaseBump(args[2] || arg('--path', process.cwd()));
22408
- if (cmd === 'release' && args[1] === 'note') return releaseNote(arg('--path', process.cwd()), args.slice(2).filter(x => !x.startsWith('-')).join(' '));
22477
+ // 1.36.49 (codex 6차 #6a): nonFlagArgs 대시 시작 토큰을 전역에서 걷어내 "- first" 같은 정상 노트가 입력 불가였다.
22478
+ // 원시 argv 에서 note 이후를 복원 — `--` 플래그(+값)만 스킵하고 단일 대시 텍스트("- …")는 노트로 보존.
22479
+ if (cmd === 'release' && args[1] === 'note') {
22480
+ const raw = process.argv.slice(2); const ni = raw.indexOf('note'); const toks = [];
22481
+ for (let i = ni + 1; i < raw.length && ni >= 0; i++) {
22482
+ if (raw[i].startsWith('--')) { if (raw[i] === '--path' && raw[i + 1]) i++; continue; }
22483
+ toks.push(raw[i]);
22484
+ }
22485
+ return releaseNote(arg('--path', process.cwd()), toks.join(' '));
22486
+ }
22409
22487
  if (cmd === 'release' && args[1] === 'publish') return releasePublish(args[2] || arg('--path', process.cwd()));
22410
22488
  if (cmd === 'release' && args[1] === 'pack') return await releasePackCmd(args[2] || arg('--path', process.cwd()));
22411
22489
  if (cmd === 'release' && args[1] === 'sync-main') return releaseSyncMainCmd(args[2] || arg('--path', process.cwd()));
@@ -0,0 +1,33 @@
1
+ # 상호운용성 (Interoperability) — leerness 의 프로토콜 입장
2
+
3
+ leerness 는 **에이전트도 에디터도 아닌, 에이전트 위의 운영 레이어**다. 이 위치가 각 프로토콜에 대한 입장을 결정한다.
4
+
5
+ ## 지원 표면 (구현됨)
6
+
7
+ | 표면 | 형태 | 용도 |
8
+ |---|---|---|
9
+ | **CLI** | 명령줄 | 모든 에이전트/사람의 기본 진입점 |
10
+ | **MCP** (`leerness mcp serve`) | stdio JSON-RPC, 86 도구 | 외부 AI 가 자연어→도구 호출로 직접 사용. `adapter <tool>` 이 `.mcp.json` 자동 병합 |
11
+ | **`.leerness/` state substrate** | 파일(JSON) | 에이전트 간 인수인계 표준 — Claude Code 가 한 작업을 Codex/Goose 가 이어받음. 오프라인·0-deps |
12
+ | **지침 계층** | AGENTS.md / CLAUDE.md / 전역 조건부(`adapter codex --global`) | 문서를 읽는 에이전트 대상 협조 계층 |
13
+ | **강제 계층** | `enforce`(pre-commit) + `ci init`(gate) | **프로토콜 무관** — 어떤 방식으로 붙은 에이전트든 커밋/PR 관문에서 검증 |
14
+
15
+ ## ACP — Agent Client Protocol (Zed 계열, 에디터↔에이전트)
16
+
17
+ **입장: 직접 구현하지 않는다 (고려 후 결정, 2026-07).**
18
+
19
+ - ACP 의 토폴로지는 에디터(클라이언트) ↔ 코딩 에이전트(서버)다. leerness 는 그 어느 쪽도 아니므로 ACP 서버/클라이언트를 구현할 구조적 자리가 없다.
20
+ - **간접 호환은 이미 성립**: ACP 로 에디터에 붙는 에이전트(Claude Code ACP 어댑터, Gemini CLI 등)는 도구 계층으로 MCP 를 사용한다 → 프로젝트의 `.mcp.json`(leerness adapter 가 병합)을 통해 leerness 86 도구에 그대로 접근한다.
21
+ - **강제는 이미 커버**: pre-commit enforce 는 접속 프로토콜과 무관하게 작동한다(커밋이 보편 관문).
22
+ - 재검토 트리거: ACP 가 에이전트-측 도구 계층으로 MCP 외 자체 도구 규격을 강제하게 되는 경우.
23
+
24
+ ## ACP — Agent Communication Protocol (BeeAI/Linux Foundation, 에이전트↔에이전트 REST)
25
+
26
+ **입장: 채택하지 않는다.**
27
+
28
+ - 서버 상주형 REST 는 leerness 의 오프라인-퍼스트·0-deps·plain-files 원칙과 충돌한다.
29
+ - 같은 문제(에이전트 간 인수인계)는 `.leerness/` substrate 가 파일 기반으로 해결한다 — 리포에 남고, 네트워크가 없어도 동작하며, 어떤 에이전트든 읽을 수 있다.
30
+
31
+ ## 요약
32
+
33
+ 새 프로토콜 평가 기준: **(1) leerness 의 역할(운영 레이어)에 자리가 있는가, (2) MCP/파일 substrate 로 이미 닿는가, (3) 오프라인·0-deps 원칙과 정합한가.** ACP 두 종 모두 (2)에서 이미 충족되거나 (3)에서 탈락한다 — 상태 변화 시 이 문서를 갱신한다.
@@ -7,7 +7,7 @@
7
7
  const cp = require('child_process');
8
8
  const path = require('path');
9
9
  const { log, fail, absRoot, exists, read } = require('./io');
10
- const { parseHarnessVersion, _parseChangelogBetween } = require('./pure-utils');
10
+ const { parseHarnessVersion, _parseChangelogBetween, compareVer } = require('./pure-utils');
11
11
 
12
12
  function doctorCmd(opts = {}, deps = {}) {
13
13
  const { VERSION, uiLang, _selfTestCases, _detectShellCtx, _mcpToolCount, has, harnessPath } = deps;
@@ -147,6 +147,12 @@ function whatsNewCmd(root, deps = {}) {
147
147
  fail(t('현재 버전을 파악할 수 없습니다. --from <version> 명시', 'cannot determine current version. specify --from <version>'));
148
148
  return process.exit(1);
149
149
  }
150
+ // 1.36.49 (codex 6차 #10): 역순 범위(from > to)는 빈 성공이 아니라 명시 오류 — 인자 순서 착오를 조용히 삼키지 않는다.
151
+ if (compareVer(fromV, toV) > 0) {
152
+ if (has('--json')) { log(JSON.stringify({ ok: false, code: 'invalid_range', error: `--from(${fromV}) 이 --to(${toV}) 보다 큼 — 인자 순서 확인` }, null, 2)); return process.exit(1); }
153
+ fail(t(`--from(${fromV}) 이 --to(${toV}) 보다 큼 — 인자 순서 확인`, `--from (${fromV}) is newer than --to (${toV}) — check argument order`));
154
+ return process.exit(1);
155
+ }
150
156
  // CHANGELOG.md — 우선 root, 없으면 leerness-pkg 자체 (lib/../CHANGELOG.md = pkg 루트)
151
157
  let changelogPath = path.join(root, 'CHANGELOG.md');
152
158
  if (!exists(changelogPath)) changelogPath = path.join(__dirname, '..', 'CHANGELOG.md');
package/lib/pure-utils.js CHANGED
@@ -1151,7 +1151,11 @@ function _planGoalUnfilled(content) { return _sectionUnfilled(content, 'Goal', _
1151
1151
  function _draftAnchors(sig = {}) {
1152
1152
  const purpose = [];
1153
1153
  const goal = [];
1154
- const desc = String(sig.pkgDescription || '').trim();
1154
+ // 1.36.49 (codex 6차 #3 High): 외부 신호 한 줄 정규화 — description 에 개행/헤딩(`##`)이 들어오면
1155
+ // 섹션 본문에 구조 마크다운이 박혀 다음 --apply 의 섹션 경계 인식이 밀리고 파일이 실행마다 자란다.
1156
+ // 개행→공백, 줄머리 헤딩 마커 제거(불릿 "- " 접두가 있으므로 줄 중간 # 은 무해).
1157
+ const _oneLine = (s) => String(s || '').replace(/\s*\r?\n\s*/g, ' ').replace(/^#+\s*/, '').trim();
1158
+ const desc = _oneLine(sig.pkgDescription);
1155
1159
  // README 첫 문단: 제목/배지/빈줄 건너뛰고 첫 산문 줄
1156
1160
  let readmeLead = '';
1157
1161
  for (const line of String(sig.readmeText || '').split(/\r?\n/)) {
@@ -1173,8 +1177,8 @@ function _draftAnchors(sig = {}) {
1173
1177
  const ms = (sig.milestones || []).filter(m => m && m.title).slice(0, 3);
1174
1178
  const activeTasks = (sig.tasks || []).filter(t => t && /in-progress|planned|requested/.test(String(t.status || ''))).slice(0, 3);
1175
1179
  if (desc || readmeLead) goal.push(`- 제품을 동작 상태로 유지·발전시킨다: ${_cutAt(desc || readmeLead, 140)}`);
1176
- for (const m of ms) goal.push(`- [${m.id || 'M'}] ${String(m.title).slice(0, 120)}${m.status ? ` (${m.status})` : ''}`);
1177
- if (!goal.length && activeTasks.length) for (const t of activeTasks) goal.push(`- ${t.id}: ${String(t.request || '').slice(0, 100)}`);
1180
+ for (const m of ms) goal.push(`- [${m.id || 'M'}] ${_oneLine(m.title).slice(0, 120)}${m.status ? ` (${m.status})` : ''}`);
1181
+ if (!goal.length && activeTasks.length) for (const t of activeTasks) goal.push(`- ${t.id}: ${_oneLine(t.request).slice(0, 100)}`);
1178
1182
  const marker = '<!-- draft: leerness anchors — 실신호 기반 초안. AI/사용자가 검토·수정 후 이 주석을 지우면 확정 -->';
1179
1183
  return {
1180
1184
  hasSignal: purpose.length > 0 || goal.length > 0,
@@ -1183,11 +1187,18 @@ function _draftAnchors(sig = {}) {
1183
1187
  };
1184
1188
  }
1185
1189
 
1186
- // 섹션 본문 교체(## <heading> 다음부터 다음 ## 전까지) — 파일 나머지는 불변. 대상 섹션 없으면 원문 그대로.
1187
- function _replaceMdSection(content, heading, newBodyLines) {
1190
+ // 섹션 본문 교체(## <heading> 다음부터 다음 ## 전까지) — 파일 나머지는 불변.
1191
+ // 대상 섹션 없으면 원문 그대로(appendIfMissing 지정 시 파일 끝에 `## <heading>` 신설 — 1.36.49 codex 6차 #4).
1192
+ function _replaceMdSection(content, heading, newBodyLines, opts = {}) {
1193
+ // 1.36.49 (codex 6차 #3 방어층): 본문 줄 안의 개행 평탄화 — 어떤 호출자가 개행 포함 줄을 넘겨도 구조 오염 방지.
1194
+ newBodyLines = (newBodyLines || []).flatMap(l => String(l).split('\n'));
1188
1195
  const lines = String(content || '').replace(/\r\n/g, '\n').split('\n');
1189
1196
  const start = lines.findIndex(l => l.trim().replace(/^#+\s*/, '').toLowerCase() === String(heading).toLowerCase() && /^#+\s/.test(l.trim()));
1190
- if (start < 0) return content;
1197
+ if (start < 0) {
1198
+ if (!opts.appendIfMissing) return content;
1199
+ const base = String(content || '').replace(/\r\n/g, '\n').replace(/\n*$/, '');
1200
+ return (base ? base + '\n\n' : '') + `## ${heading}\n\n` + newBodyLines.join('\n') + '\n';
1201
+ }
1191
1202
  let end = lines.length;
1192
1203
  for (let i = start + 1; i < lines.length; i++) if (/^##\s/.test(lines[i])) { end = i; break; }
1193
1204
  return [...lines.slice(0, start + 1), '', ...newBodyLines, '', ...lines.slice(end)].join('\n');
package/lib/toggles.js CHANGED
@@ -58,6 +58,13 @@ function toggleCmd(root, sub, id, val, deps = {}) {
58
58
  if (!TOGGLE_REGISTRY[id]) { fail(`알 수 없는 토글: ${id} (가능: ${Object.keys(TOGGLE_REGISTRY).join(', ')})`); process.exitCode = 1; return; }
59
59
  const on = String(val).toLowerCase();
60
60
  if (on !== 'on' && on !== 'off') { fail(`값은 on|off (받음: ${val})`); process.exitCode = 1; return; }
61
+ // 1.36.49 (codex 6차 #5): 손상 toggles.json 위에 set 하면 loadToggles 의 기본값 폴백이 저장돼
62
+ // 무관한 토글(예: gate:false)이 무언 ON 으로 리셋됐다 — 변경 진입점은 fail-closed (1.36.28 스토어 손상 클래스).
63
+ const f = _togglesPath(root);
64
+ if (exists(f)) {
65
+ try { JSON.parse(read(f)); }
66
+ catch { fail(`toggles.json 손상(JSON 파싱 실패) — 덮어쓰기 거부: ${f}\n 복구하거나 삭제(전 토글 기본 ON 복원) 후 재시도`); process.exitCode = 1; return; }
67
+ }
61
68
  cur[id] = on === 'on';
62
69
  saveToggles(root, cur);
63
70
  if (json) { log(JSON.stringify({ ok: true, id, value: cur[id], toggles: cur }, null, 2)); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.47",
3
+ "version": "1.36.49",
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",