leerness 1.36.48 → 1.36.50

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,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.50 — 2026-07-21 — 결함 클래스 3종 전수 스윕: 발견이 아니라 클래스를 고친다 (시스템적 진입점 3곳)
4
+
5
+ 6차 헌트의 개별 결함 3건을 클래스로 확장해 코드베이스 전수 스윕(교훈: fix-class call-site sweep). 개별 지점 수선 대신 시스템적 진입점 수정으로 현재+미래 지점까지 커버.
6
+
7
+ - **클래스 1 — NaN 날짜 비교 fail-open** (enforce #2 의 클래스): `new Date(손상).getTime()` → NaN 비교가 조용히 통과하던 7개소. drift 4곳(무효 날짜 → mtime 폴백/제외 — advisory 는 false-BLOCK 회피 원칙), audit 1곳(무효 `Updated:` → stale 취급, --fix 가 유효 날짜로 자가 복구), handoff-gap(정보 부재 취급), session-resume(신호 산출 불가 스킵). 실측: `Updated: 2026-99-99` 종전 무언 통과 → 경고+자가복구.
8
+ - **클래스 A — 손상 JSON 스토어 클로버** (toggle #5 의 클래스, codex 실측 21개 스토어): 개별 가드 대신 단일 진입점 `writeUtf8` 에서 "손상 JSON 위에 유효 JSON 저장"(클로버 시그니처) 감지 시 원본을 `<file>.corrupt-<ts>` 로 대피 후 저장 — 바이트 유실 0 · 수동 흐름(handoff 등) 비차단 · 미래 스토어 자동 커버. 알림은 stderr(stdout JSON 계약 불오염). 명시 fail-closed 스토어(state/teams/toggles)는 상류 거부 유지.
9
+ - **클래스 B — --json 오류 계약** (review #7 의 클래스, codex 전수 매트릭스 40+ 실측): `fail()` 진입점 자체가 --json 시 첫 오류를 단일 `{ok:false,error,code:'error'}` 로 방출하고 이후 사람용 출력을 묵음(래치는 argv 정체성 스코프 — REPL/인프로세스에서 자동 해제). failJson 구체 code 경로는 그대로 우선. stderr-only 빈 stdout 6개소(requests complete/drop·wakeup-interval set/record·intent classify/expand)는 fail() 로 전환. enforce remove --json 성공 JSON 화.
10
+ - 이연(정직): --json 성공경로 사람용 텍스트 6개소(resume·pre-wake-audit --last·release cleanup·lessons --auto·bare-help 4종·legacy migrate preview)는 명령별 데이터 형상이 필요해 다음 라운드 — 오류경로와 달리 exit 0 이라 소비자 오판 위험이 낮다.
11
+ - 검증: 클로버 대피/fail 래치/NaN 3종 행위검사 포함 selftest 323(+3), 대표 오류경로 8종 + 기존 PASS 회귀 실측, e2e.
12
+
13
+ ## 1.36.49 — 2026-07-21 — codex 6차 헌트: 신작 표면(1.36.28~48) 10건 전수 수정 (High 3 · Medium 5 · Low 2)
14
+
15
+ 이번 창에서 신설된 기능들(enforce/toggles/anchors/release note/JSON 계약)을 외부 적대 검증(codex, 30만 토큰) — 10건 발견, 10건 전수 재현 확정 후 전부 수정 (반박 0, DEFER 0).
16
+
17
+ - **#1 High — rules 손상행 무언 삭제 재발 경로**: `nextRuleId` 가 파싱된 룰만 스캔해 손상행(셀 부족)의 R-id 가 새 룰에 재발급 → writeRules 보존 필터가 손상행을 기존 룰로 오인해 삭제(1.36.37 보존 로직이 지키려던 바로 그 행). 원시 파일의 모든 `| R-XXXX |` 를 카운트.
18
+ - **#2 High — enforce check 손상 타임스탬프 통과**: `new Date('not-a-date')` → NaN → `NaN > window` 가 false 라 손상 last-handoff.json 이 강제를 통과("handoff NaNh 전"). fail-closed(Infinity 취급) + `--json` 성공 시 구조화 출력(종전 빈 stdout).
19
+ - **#3 High — anchors 위험신호로 --apply 마다 파일 증식**: 개행/`##` 포함 package description 이 섹션 본문에 구조 마크다운으로 박혀 재적용 시 섹션 경계가 밀려 중복 증식. 신호 한 줄 정규화(`_oneLine`) + `_replaceMdSection` 본문 개행 평탄화 방어층.
20
+ - **#4 — `## Goal` 헤딩 부재를 "작성됨"으로 오보**: 섹션 자체가 없으면 gap 미탐지 + --apply 무동작. 부재=gap + `appendIfMissing` 으로 헤딩 신설.
21
+ - **#5 — toggle set 이 손상 toggles.json 을 기본값으로 리셋**: lens 하나 바꾸면 gate:false 가 무언 ON 복원. 변경 진입점 fail-closed(1.36.28 스토어 손상 클래스), 원본 보존.
22
+ - **#6 — release note 정상 입력 2종 실패**: 대시 시작 노트("- first")가 nonFlagArgs 전역 필터에 걸려 입력 불가(원시 argv 복원으로 수정) + SemVer 빌드메타(`1.2.3+build.1`)의 `+` 미이스케이프로 같은 버전 헤딩 중복 생성(전체 regex 이스케이프).
23
+ - **#7 — review --json 오류경로가 사람용 텍스트**: target/persona/파일 검증 실패 시 failJson 단일 JSON 계약 (target_required/persona_required/file_not_found/file_too_large/persona_not_found).
24
+ - **#8 — 무효 --window 무언 보정 설치**: 0/NaN→24 무언 보정, 400자리 정수→`WIN_MIN=Infinity` 훅(상시 차단). 1~8760 정수 외 invalid_window 거부.
25
+ - **#9 Low — 커밋 0개 저장소를 enforce audit 이 실패 처리**: unborn HEAD 를 git_log_failed 로 오분류 → `checked:0` 성공(no_commits).
26
+ - **#10 Low — whats-new 역순 범위 빈 성공**: from > to 를 invalid_range 명시 오류로.
27
+ - 검증: 재현→수정→재검 전 케이스 실측, selftest 316→320(행위 2 + 소스가드 2, 자기참조 트랩 회피형 앵커), e2e.
28
+
3
29
  ## 1.36.48 — 2026-07-16 — ACP 입장 명문화 (docs/interoperability.md, 사용자 질의)
4
30
 
5
31
  사용자 질의("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 기록.
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.48 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.50 하네스를 사용합니다. 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.48는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
179
+ Leerness v1.36.50는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
180
180
 
181
181
  ```jsonc
182
182
  // 카테고리별
@@ -197,7 +197,7 @@ Leerness v1.36.48는 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.48 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.50 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
201
201
 
202
202
  ### 성능 가이드
203
203
 
@@ -235,6 +235,6 @@ 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.48: 2026-07-21
238
+ Last synced by Leerness v1.36.50: 2026-07-21
239
239
  <!-- leerness:project-readme:end -->
240
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.48';
37
+ const VERSION = '1.36.50';
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') 시 호스트 프로세스 오염.
@@ -4113,10 +4113,12 @@ function _selfTestCases() {
4113
4113
  const io = require('../lib/io');
4114
4114
  if (typeof io.setQuiet !== 'function') return false;
4115
4115
  // quiet 면 log/ok/warn 묵음, fail/failJson 은 노출
4116
- let out = ''; const _w = process.stdout.write; process.stdout.write = s => { out += s; return true; };
4116
+ // 1.36.50: fail json-argv 모드에서 stderr 강등(계약 v2) "오류는 항상 노출" 의도는 스트림으로 검증
4117
+ let out = ''; const _w = process.stdout.write, _e = process.stderr.write;
4118
+ process.stdout.write = s => { out += s; return true; }; process.stderr.write = s => { out += s; return true; };
4117
4119
  let quietLogSuppressed, failShown;
4118
4120
  try { io.setQuiet(true); io.log('HUMAN'); io.ok('OK'); quietLogSuppressed = !/HUMAN|OK/.test(out); out = ''; io.fail('ERR'); failShown = /ERR/.test(out); }
4119
- finally { io.setQuiet(false); process.stdout.write = _w; process.exitCode = 0; }
4121
+ finally { io.setQuiet(false); process.stdout.write = _w; process.stderr.write = _e; process.exitCode = 0; }
4120
4122
  const src = read(__filename);
4121
4123
  const wired = src.includes("setQuiet(true);") && src.includes("action: 'init', version: VERSION, path: _initRoot, harnessFiles");
4122
4124
  return quietLogSuppressed && failShown && wired;
@@ -4894,6 +4896,94 @@ function _selfTestCases() {
4894
4896
  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
4897
  return onlyBad && noHarness && wired;
4896
4898
  } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
4899
+ } },
4900
+ { name: 'rules 손상행 ID 충돌 방지 (1.36.49, codex 6차 #1 High): nextRuleId 가 파싱불가 행의 R-id 도 카운트 — 행위검사', run: () => {
4901
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_rid_'));
4902
+ try {
4903
+ fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true });
4904
+ fs.writeFileSync(path.join(tmp, '.harness', 'rules.md'), '| R-0007 | every-session |\n');
4905
+ return nextRuleId(tmp) === 'R-0008'; // 종전: 손상행 무시 → R-0001 재발급 → writeRules 가 손상행을 무언 삭제
4906
+ } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
4907
+ } },
4908
+ { name: 'anchors 위험신호 정규화 + 헤딩 신설 (1.36.49, codex 6차 #3 High/#4): 개행·헤딩 포함 description 평탄화 + appendIfMissing — 순수 행위검사', run: () => {
4909
+ const p = require('../lib/pure-utils');
4910
+ const d = p._draftAnchors({ pkgDescription: 'Alpha | Beta\n## Injected Heading\nmore' });
4911
+ const flat = [...d.purpose, ...d.goal].every(l => !l.includes('\n') && !/^#/.test(l));
4912
+ // 섹션 부재 + appendIfMissing → 헤딩 신설; 미지정이면 원문 불변(기존 계약 유지)
4913
+ const base = '# T\n\n## Other\nx';
4914
+ const appended = p._replaceMdSection(base, 'Goal', ['- g'], { appendIfMissing: true });
4915
+ const addOk = appended.includes('## Goal') && appended.includes('- g') && p._replaceMdSection(base, 'Goal', ['- g']) === base;
4916
+ // 본문 줄 내 개행은 평탄화(구조 오염 방어층) + 같은 입력 재적용 멱등
4917
+ const rep1 = p._replaceMdSection('## Goal\nold\n## Next\ny', 'Goal', ['a\nb']);
4918
+ const idem = rep1.split('\n').includes('a') && rep1.split('\n').includes('b') && p._replaceMdSection(rep1, 'Goal', ['a\nb']) === rep1;
4919
+ return flat && addOk && idem;
4920
+ } },
4921
+ { name: 'enforce fail-closed 3종 (1.36.49, codex 6차 #2 High/#8/#9): 손상 타임스탬프·무효 window·unborn HEAD — 소스가드', run: () => {
4922
+ const s = read(__filename);
4923
+ const nanClosed = /const ageH = Number\.isFinite\(lastMs\)/.test(s) && /new Date\(lastAt\)\.getTime\(\) : NaN/.test(s);
4924
+ const winValid = /failJson\(json, 'invalid_window'/.test(s) && /Number\.isInteger\(windowHours\) \|\| windowHours < 1/.test(s);
4925
+ const unborn = /does not have any commits\|unknown revision/.test(s) && /note: 'no_commits'/.test(s);
4926
+ const jsonOk = /ok: true, lastHandoff: lastAt/.test(s); // --json 성공도 구조화 출력
4927
+ return nanClosed && winValid && unborn && jsonOk;
4928
+ } },
4929
+ { name: '스토어/계약 4종 (1.36.49, codex 6차 #5/#6/#7/#10): toggle set fail-closed·release note 대시/regex·review failJson·whats-new 역순 — 소스가드', run: () => {
4930
+ const tg = read(path.join(__dirname, '..', 'lib', 'toggles.js'));
4931
+ const tOk = /JSON\.parse\(read\(f\)\)/.test(tg) && tg.includes('덮어쓰기 거부');
4932
+ const s = read(__filename);
4933
+ const noteOk = /raw\.indexOf\('note'\)/.test(s) && /version\.replace\(\/\[\.\*\+\?\^/.test(s);
4934
+ const revOk = /failJson\(_rj, 'file_not_found'/.test(s) && /failJson\(_rj, 'persona_not_found'/.test(s);
4935
+ const dg = read(path.join(__dirname, '..', 'lib', 'diagnostics.js'));
4936
+ const wnOk = /compareVer\(fromV, toV\) > 0/.test(dg) && dg.includes("code: 'invalid_range'");
4937
+ return tOk && noteOk && revOk && wnOk;
4938
+ } },
4939
+ { name: '스토어 클로버 대피 (1.36.50, 클래스 스윕 A): 손상 JSON 위 유효 JSON 저장 시 원본 .corrupt-* 대피 — 행위검사', run: () => {
4940
+ const io = require('../lib/io');
4941
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_cw_'));
4942
+ const _ce = console.error; console.error = () => {}; // 대피 알림이 doctor(en) 등 상위 출력에 새지 않게 격리
4943
+ try {
4944
+ const f = path.join(tmp, 's.json');
4945
+ fs.writeFileSync(f, '{"keep":[1,'); // 손상
4946
+ io.writeUtf8(f, '{"fresh":true}');
4947
+ const baks = fs.readdirSync(tmp).filter(n => n.startsWith('s.json.corrupt-'));
4948
+ const rescued = baks.length === 1 && fs.readFileSync(path.join(tmp, baks[0]), 'utf8') === '{"keep":[1,' && read(f) === '{"fresh":true}';
4949
+ // 정상 파일 덮어쓰기·비-JSON 새 내용은 대피 없음(백업 증식 방지)
4950
+ io.writeUtf8(f, '{"fresh":2}');
4951
+ const f2 = path.join(tmp, 'n.json'); fs.writeFileSync(f2, 'not json'); io.writeUtf8(f2, 'still not json');
4952
+ const noExtra = fs.readdirSync(tmp).filter(n => n.includes('.corrupt-')).length === 1;
4953
+ return rescued && noExtra;
4954
+ } finally { console.error = _ce; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
4955
+ } },
4956
+ { name: 'NaN 날짜 fail-open 클래스 스윕 (1.36.50): drift mtime 폴백·무효날짜 제외 + audit stale 취급 + handoff-gap 정보부재 — 소스가드', run: () => {
4957
+ const dr = read(path.join(__dirname, '..', 'lib', 'drift.js'));
4958
+ const drOk = (dr.match(/Number\.isFinite/g) || []).length >= 4;
4959
+ const au = read(path.join(__dirname, '..', 'lib', 'audit.js'));
4960
+ const auOk = /if \(!Number\.isFinite\(dDays\)\) dDays = Infinity/.test(au);
4961
+ const s = read(__filename);
4962
+ const gapOk = /if \(!Number\.isFinite\(lastMs\)\) return \{ hasLast: false \}/.test(s);
4963
+ return drOk && auOk && gapOk;
4964
+ } },
4965
+ { name: '--json 오류 계약 진입점 v2 (1.36.50, 클래스 스윕 B): fail 래치 — 사람용 stderr 강등 + JSON 페이로드 통과 + 명시 failJson(false) human 보장 — 행위검사', run: () => {
4966
+ const io = require('../lib/io');
4967
+ const saveArgv = process.argv, saveExit = process.exitCode; const outs = [], errs = [];
4968
+ const _log = console.log, _err = console.error;
4969
+ try {
4970
+ process.argv = ['node', 'x', 'cmd', '--json'];
4971
+ console.log = (s) => outs.push(String(s)); console.error = (s) => errs.push(String(s));
4972
+ io.setQuiet(false); // 래치 초기화
4973
+ io.fail('첫 오류'); io.fail('둘째'); io.log('사람용 잔여줄'); io.log('{"agg":true}');
4974
+ // stdout: 집계 JSON 만 통과 · ✗/잔여줄은 stderr
4975
+ const passThru = outs.length === 1 && outs[0] === '{"agg":true}';
4976
+ const demoted = errs.some(e => e.includes('첫 오류')) && errs.some(e => e === '사람용 잔여줄');
4977
+ // 명시 failJson(false) → argv 에 --json 있어도 human ✗ (호출자 결정 우선, UR-0099 계약)
4978
+ io.setQuiet(false); outs.length = 0;
4979
+ io.failJson(false, 'c', '사람지정');
4980
+ const humanExplicit = outs.length === 1 && outs[0] === '✗ 사람지정';
4981
+ // 비-json argv 사람용 ✗ 유지
4982
+ io.setQuiet(false); process.argv = ['node', 'x', 'cmd']; outs.length = 0;
4983
+ io.fail('사람용');
4984
+ const human = outs.length === 1 && outs[0] === '✗ 사람용';
4985
+ return passThru && demoted && humanExplicit && human;
4986
+ } finally { console.log = _log; console.error = _err; process.argv = saveArgv; io.setQuiet(false); process.exitCode = saveExit; }
4897
4987
  } }
4898
4988
  ];
4899
4989
  }
@@ -5081,7 +5171,11 @@ function enforceCmd(root, sub) {
5081
5171
  if (sub === 'install') {
5082
5172
  if (!exists(path.join(root, '.git'))) { failJson(json, 'not_a_git_repo', `git 저장소가 아님: ${root} — pre-commit 강제는 git 저장소에서만 가능`); return; }
5083
5173
  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);
5174
+ // 1.36.49 (codex 6차 #8): 명시된 무효 window 24 무언 보정하지 않고 거부 — 종전엔 0/NaN→24,
5175
+ // 400자리 정수→Infinity 가 그대로 설치돼 훅에 WIN_MIN=Infinity(find -mmin 상시 실패=상시 차단)가 박혔다.
5176
+ const winRaw = arg('--window', '24');
5177
+ const windowHours = Number(winRaw);
5178
+ if (!Number.isInteger(windowHours) || windowHours < 1 || windowHours > 8760) { failJson(json, 'invalid_window', `--window 는 1~8760 정수(시간)만: 받음 "${winRaw}"`); return; }
5085
5179
  const strict = has('--strict');
5086
5180
  mkdirp(path.dirname(_enforceCfgPath(root)));
5087
5181
  writeUtf8(_enforceCfgPath(root), JSON.stringify({ windowHours, strict, installedAt: now() }, null, 2) + '\n');
@@ -5130,7 +5224,11 @@ function enforceCmd(root, sub) {
5130
5224
  const lhf = _lastHandoffPath(root);
5131
5225
  let lastAt = null;
5132
5226
  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;
5227
+ // 1.36.49 (codex 6차 #2 High): 손상 타임스탬프 fail-closed — 종전엔 new Date('not-a-date') NaN,
5228
+ // NaN > windowHours 가 false 라서 손상 파일이 강제를 통과("handoff NaNh 전")했다.
5229
+ const lastMs = lastAt ? new Date(lastAt).getTime() : NaN;
5230
+ const ageH = Number.isFinite(lastMs) ? (Date.now() - lastMs) / 3600000 : Infinity;
5231
+ if (!Number.isFinite(lastMs)) lastAt = null;
5134
5232
  if (ageH > cfg.windowHours) {
5135
5233
  fail(`🔒 leerness 강제: 최근 ${cfg.windowHours}h 내 handoff 흔적 없음${lastAt ? ` (마지막: ${lastAt})` : ''}`);
5136
5234
  log(` → 커밋 전 실행: leerness handoff . (컨텍스트 적재 + 사용 흔적)`);
@@ -5141,7 +5239,9 @@ function enforceCmd(root, sub) {
5141
5239
  const r = cp.spawnSync(process.execPath, [__filename, 'gate', root], { encoding: 'utf8', env: { ...process.env, LEERNESS_INTERNAL: '1' } });
5142
5240
  if (r.status !== 0) { fail(`🔒 leerness 강제(strict): gate 실패 — 커밋 차단`); process.stdout.write((r.stdout || '').slice(-1500)); process.exitCode = 1; return; }
5143
5241
  }
5144
- if (!json) ok(`enforce check 통과 (handoff ${lastAt ? Math.round(ageH * 10) / 10 + 'h 전' : '-'}${cfg.strict ? ' + gate' : ''})`);
5242
+ // 1.36.49 (codex 6차 #2 부속): --json 성공 stdout 아니라 구조화 객체를 내보낸다.
5243
+ 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; }
5244
+ ok(`enforce check 통과 (handoff ${lastAt ? Math.round(ageH * 10) / 10 + 'h 전' : '-'}${cfg.strict ? ' + gate' : ''})`);
5145
5245
  return;
5146
5246
  }
5147
5247
  if (sub === 'audit') {
@@ -5152,6 +5252,11 @@ function enforceCmd(root, sub) {
5152
5252
  let handoffs = [];
5153
5253
  try { const j = JSON.parse(read(_lastHandoffPath(root))); handoffs = (j.history || []).map(t => new Date(t).getTime()).filter(n => !Number.isNaN(n)); } catch {}
5154
5254
  const gl = cp.spawnSync('git', ['-C', root, 'log', '-n', '30', '--pretty=format:%H|%ct|%s'], { encoding: 'utf8', timeout: 10000 });
5255
+ // 1.36.49 (codex 6차 #9): 커밋 0개(unborn HEAD)는 정상 저장소 — 실패가 아니라 "검사 대상 없음" 성공.
5256
+ if (gl.status !== 0 && /does not have any commits|unknown revision|bad default revision|ambiguous argument/i.test(gl.stderr || '')) {
5257
+ if (json) { log(JSON.stringify({ checked: 0, windowHours: cfg2.windowHours, suspects: [], note: 'no_commits' }, null, 2)); return; }
5258
+ ok(' 커밋 0개 — 검사 대상 없음'); return;
5259
+ }
5155
5260
  if (gl.status !== 0) { failJson(json, 'git_log_failed', 'git log 실패 — git 저장소인지 확인'); return; }
5156
5261
  const winMs = cfg2.windowHours * 3600000;
5157
5262
  const suspects = [];
@@ -5173,11 +5278,16 @@ function enforceCmd(root, sub) {
5173
5278
  return;
5174
5279
  }
5175
5280
  if (sub === 'remove') {
5281
+ let removed = false, restored = false;
5176
5282
  if (exists(hookP) && read(hookP).includes(_ENFORCE_MARK)) {
5177
- if (exists(chainedP)) { fs.copyFileSync(chainedP, hookP); fs.unlinkSync(chainedP); try { fs.chmodSync(hookP, 0o755); } catch {} }
5283
+ if (exists(chainedP)) { fs.copyFileSync(chainedP, hookP); fs.unlinkSync(chainedP); try { fs.chmodSync(hookP, 0o755); } catch {} restored = true; }
5178
5284
  else fs.unlinkSync(hookP);
5179
- ok('enforce 제거 — pre-commit 훅 해제' + (exists(hookP) ? ' (기존 훅 복원)' : ''));
5180
- } else log('enforce 훅 없음 — 변경 없음');
5285
+ removed = true;
5286
+ }
5287
+ // 1.36.50 (스윕 B): --json 성공도 단일 JSON — 종전엔 사람용 텍스트/무출력이었다
5288
+ if (json) { log(JSON.stringify({ ok: true, removed, restoredChain: restored }, null, 2)); return; }
5289
+ if (removed) ok('enforce 제거 — pre-commit 훅 해제' + (restored ? ' (기존 훅 복원)' : ''));
5290
+ else log('enforce 훅 없음 — 변경 없음');
5181
5291
  return;
5182
5292
  }
5183
5293
  // status
@@ -5205,8 +5315,10 @@ function anchorsCmd(root, sub) {
5205
5315
  const naM = '<!-- leerness:na';
5206
5316
  // 1.36.47: draft 마커가 남아있는 섹션(미확정 초안)은 재초안 대상 — 초안 품질 개선을 기존 적용본에도 전파 가능.
5207
5317
  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));
5318
+ // 1.36.49 (codex 6차 #4): 헤딩 자체가 없는 파일도 gap 종전 _sectionUnfilled 는 "섹션 없음→미플래그"(감지용 보수 기본)
5319
+ // ## Goal 아예 없는 plan.md "작성됨"으로 보고되고 draft --apply 무동작이었다. apply 시 헤딩을 신설한다.
5320
+ const briefGap = !!briefTxt && !briefTxt.includes(naM) && (_briefUnfilled(briefTxt) || _mdSectionBody(briefTxt, 'Purpose') == null || (_mdSectionBody(briefTxt, 'Purpose') || '').includes(_draftM));
5321
+ const planGap = !!planTxt && !planTxt.includes(naM) && (_planGoalUnfilled(planTxt) || _mdSectionBody(planTxt, 'Goal') == null || (_mdSectionBody(planTxt, 'Goal') || '').includes(_draftM));
5210
5322
  if (!sub || sub === 'status') {
5211
5323
  if (json) { log(JSON.stringify({ briefUnfilled: briefGap, planGoalUnfilled: planGap }, null, 2)); return; }
5212
5324
  log(`# leerness anchors — 정체성앵커 상태`);
@@ -5228,10 +5340,10 @@ function anchorsCmd(root, sub) {
5228
5340
  const apply = has('--apply');
5229
5341
  const applied = [];
5230
5342
  if (briefGap && draft.purpose.length) {
5231
- if (apply) { writeUtf8(bf, _replaceMdSection(briefTxt, 'Purpose', draft.purpose)); applied.push('project-brief.md#Purpose'); }
5343
+ if (apply) { writeUtf8(bf, _replaceMdSection(briefTxt, 'Purpose', draft.purpose, { appendIfMissing: true })); applied.push('project-brief.md#Purpose'); }
5232
5344
  }
5233
5345
  if (planGap && draft.goal.length) {
5234
- if (apply) { writeUtf8(pf, _replaceMdSection(planTxt, 'Goal', draft.goal)); applied.push('plan.md#Goal'); }
5346
+ if (apply) { writeUtf8(pf, _replaceMdSection(planTxt, 'Goal', draft.goal, { appendIfMissing: true })); applied.push('plan.md#Goal'); }
5235
5347
  }
5236
5348
  if (json) { log(JSON.stringify({ briefUnfilled: briefGap, planGoalUnfilled: planGap, draft: { purpose: draft.purpose, goal: draft.goal }, applied, dryRun: !apply }, null, 2)); return; }
5237
5349
  log(`# leerness anchors draft ${apply ? '(적용)' : '(dry-run — 적용: --apply)'}`);
@@ -6324,7 +6436,7 @@ function _detectAbnormalShutdown(root) {
6324
6436
  if (exists(lhFp)) {
6325
6437
  const j = JSON.parse(read(lhFp));
6326
6438
  const last = j.lastAt || (j.history && j.history.length > 0 ? j.history[j.history.length - 1] : null);
6327
- if (last) {
6439
+ if (last && Number.isFinite(new Date(last).getTime())) { // 1.36.50 (NaN 날짜 클래스): 손상 타임스탬프는 신호 산출 불가 — 스킵
6328
6440
  const lastMs = new Date(last).getTime();
6329
6441
  const gapMin = Math.floor((now - lastMs) / 60000);
6330
6442
  if (gapMin > 60) {
@@ -6960,9 +7072,9 @@ function requestsCmd(root, sub, ...rest) {
6960
7072
 
6961
7073
  if (sub === 'complete') {
6962
7074
  const id = rest[0];
6963
- if (!id) { console.error('Usage: leerness requests complete <id>'); process.exit(1); }
7075
+ if (!id) { fail('Usage: leerness requests complete <id>'); process.exit(1); } // 1.36.50 (스윕 B): stderr-only → stdout 계약(fail 이 --json 인지)
6964
7076
  const updated = _updateUserRequest(root, id, { status: 'completed' });
6965
- if (!updated) { console.error(`request not found: ${id}`); process.exit(1); }
7077
+ if (!updated) { fail(`request not found: ${id}`); process.exit(1); }
6966
7078
  if (has('--json')) { log(JSON.stringify(updated, null, 2)); return; }
6967
7079
  log(grn(`✓ completed ${updated.id}`));
6968
7080
  return;
@@ -6970,9 +7082,9 @@ function requestsCmd(root, sub, ...rest) {
6970
7082
 
6971
7083
  if (sub === 'drop') {
6972
7084
  const id = rest[0];
6973
- if (!id) { console.error('Usage: leerness requests drop <id>'); process.exit(1); }
7085
+ if (!id) { fail('Usage: leerness requests drop <id>'); process.exit(1); } // 1.36.50 (스윕 B)
6974
7086
  const updated = _updateUserRequest(root, id, { status: 'dropped' });
6975
- if (!updated) { console.error(`request not found: ${id}`); process.exit(1); }
7087
+ if (!updated) { fail(`request not found: ${id}`); process.exit(1); }
6976
7088
  if (has('--json')) { log(JSON.stringify(updated, null, 2)); return; }
6977
7089
  log(yel(`✓ dropped ${updated.id}`)); // 1.9.354 (UR-0072 외부리뷰): 성공 동작에 실패 아이콘(✗) 대신 ✓
6978
7090
  return;
@@ -7349,7 +7461,7 @@ function wakeupIntervalCmd(root, sub, val) {
7349
7461
 
7350
7462
  if (sub === 'set') {
7351
7463
  const n = Number(val);
7352
- if (!val || isNaN(n) || n < 60) { console.error('Usage: leerness wakeup-interval set <secs ≥ 60>'); process.exit(1); }
7464
+ if (!val || isNaN(n) || n < 60) { fail('Usage: leerness wakeup-interval set <secs ≥ 60>'); process.exit(1); } // 1.36.50 (스윕 B)
7353
7465
  const state = _loadWakeupHistory(root);
7354
7466
  state.override = n;
7355
7467
  _writeWakeupHistory(root, state);
@@ -7388,7 +7500,7 @@ function wakeupIntervalCmd(root, sub, val) {
7388
7500
  if (sub === 'record') {
7389
7501
  const kind = val || 'auto';
7390
7502
  const validKinds = ['auto', 'user-trigger', 'wakeup-miss'];
7391
- if (!validKinds.includes(kind)) { console.error(`kind must be: ${validKinds.join('/')}`); process.exit(1); }
7503
+ if (!validKinds.includes(kind)) { fail(`kind must be: ${validKinds.join('/')}`); process.exit(1); } // 1.36.50 (스윕 B)
7392
7504
  _recordWakeupFire(root, kind);
7393
7505
  if (has('--json')) { log(JSON.stringify({ recorded: kind, at: new Date().toISOString() }, null, 2)); return; }
7394
7506
  log(grn(`✓ wakeup fire 기록: ${kind}`));
@@ -7894,7 +8006,7 @@ function intentCmd(root, sub, ...rest) {
7894
8006
 
7895
8007
  if (sub === 'classify') {
7896
8008
  const text = rest.filter(x => !x.startsWith('-')).join(' ');
7897
- if (!text) { console.error('Usage: leerness intent classify "<request text>"'); process.exit(1); }
8009
+ if (!text) { fail('Usage: leerness intent classify "<request text>"'); process.exit(1); } // 1.36.50 (스윕 B)
7898
8010
  const result = _classifyIntent(text);
7899
8011
  if (has('--json')) { log(JSON.stringify({ text, ...result }, null, 2)); return; }
7900
8012
  log(cyan(`# leerness intent classify (1.9.213)`));
@@ -7912,7 +8024,7 @@ function intentCmd(root, sub, ...rest) {
7912
8024
 
7913
8025
  if (sub === 'expand') {
7914
8026
  const text = rest.filter(x => !x.startsWith('-')).join(' ');
7915
- if (!text) { console.error('Usage: leerness intent expand "<request text>"'); process.exit(1); }
8027
+ if (!text) { fail('Usage: leerness intent expand "<request text>"'); process.exit(1); } // 1.36.50 (스윕 B)
7916
8028
  const result = _inferScopeExpansion(text, root);
7917
8029
  if (has('--json')) { log(JSON.stringify(result, null, 2)); return; }
7918
8030
  log(cyan(`# leerness intent expand (1.9.213) [DRY-RUN]`));
@@ -7991,6 +8103,7 @@ function _getLastHandoffGap(root) {
7991
8103
  const j = JSON.parse(read(fp));
7992
8104
  if (!j.last) return { hasLast: false };
7993
8105
  const lastMs = new Date(j.last).getTime();
8106
+ if (!Number.isFinite(lastMs)) return { hasLast: false }; // 1.36.50 (NaN 날짜 클래스): 손상 타임스탬프 = 정보 부재로 취급
7994
8107
  const gapMs = Date.now() - lastMs;
7995
8108
  const gapMin = Math.floor(gapMs / 60000);
7996
8109
  // R-0001 영구 룰: 25분 (1500s). gap > 35분 (140% buffer) → wakeup miss 의심
@@ -14168,22 +14281,24 @@ function personaCmd(root, sub, idOrName, ...rest) {
14168
14281
 
14169
14282
  function reviewCmd(root, target) {
14170
14283
  root = absRoot(root || process.cwd());
14171
- if (!target) { fail('review <file> 필요. 예: leerness review src/api.js --persona security'); return process.exit(1); }
14284
+ // 1.36.49 (codex 6차 #7): --json 요청 오류 경로도 단일 JSON 계약(failJson) 종전 fail() 은 사람용 텍스트를 냈다.
14285
+ const _rj = has('--json');
14286
+ if (!target) { failJson(_rj, 'target_required', 'review <file> 필요. 예: leerness review src/api.js --persona security'); return process.exit(1); }
14172
14287
  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); }
14288
+ if (!personaIds.length) { failJson(_rj, 'persona_required', '--persona <id> 필요. `leerness persona list`로 확인'); return process.exit(1); }
14174
14289
 
14175
14290
  // 파일 확인
14176
14291
  const filePath = path.isAbsolute(target) ? target : path.join(root, target);
14177
- if (!exists(filePath)) { fail(`파일 없음: ${filePath}`); return process.exit(1); }
14292
+ if (!exists(filePath)) { failJson(_rj, 'file_not_found', `파일 없음: ${filePath}`); return process.exit(1); }
14178
14293
  const fileContent = read(filePath);
14179
14294
  const fileSize = Buffer.byteLength(fileContent, 'utf8');
14180
- if (fileSize > 100 * 1024) { fail(`파일 너무 큼: ${fileSize} bytes. 100KB 미만 권장.`); return process.exit(1); }
14295
+ if (fileSize > 100 * 1024) { failJson(_rj, 'file_too_large', `파일 너무 큼: ${fileSize} bytes. 100KB 미만 권장.`); return process.exit(1); }
14181
14296
 
14182
14297
  // 페르소나 해석
14183
14298
  const personas = [];
14184
14299
  for (const id of personaIds) {
14185
14300
  const p = _resolvePersona(root, id);
14186
- if (!p) { fail(`페르소나 없음: ${id}. \`leerness persona list\` 확인`); return process.exit(1); }
14301
+ if (!p) { failJson(_rj, 'persona_not_found', `페르소나 없음: ${id}. \`leerness persona list\` 확인`); return process.exit(1); }
14187
14302
  personas.push(p);
14188
14303
  }
14189
14304
 
@@ -15592,6 +15707,15 @@ function nextRuleId(root) {
15592
15707
  const m = r.id.match(/^R-(\d{4,})$/);
15593
15708
  if (m) max = Math.max(max, Number(m[1]));
15594
15709
  }
15710
+ // 1.36.49 (codex 6차 #1 High): 파싱 불가(손상) 행의 R-id 도 카운트 — 종전엔 파싱된 룰만 스캔해
15711
+ // 손상 행과 같은 ID 가 새 룰에 재발급되고, writeRules 의 보존 필터(l.startsWith('| '+id))가
15712
+ // 그 손상 행을 "이미 표에 있는 룰"로 오인해 무언 삭제했다(보존 로직이 지키려던 바로 그 행).
15713
+ try {
15714
+ if (exists(rulesPath(root))) {
15715
+ const re = /(?:^|\n)\|\s*R-(\d+)\s*\|/g; const raw = read(rulesPath(root)); let m2;
15716
+ while ((m2 = re.exec(raw))) max = Math.max(max, Number(m2[1]));
15717
+ }
15718
+ } catch {}
15595
15719
  // 1.11.3 (14th 버그헌트 P2, UR-0180): 아카이브의 R-id 도 카운트 — 기존엔 활성 rules 만 스캔해 rule remove 후 같은 R-id 가 다른 룰에 재사용됐음(아카이브 ID 충돌).
15596
15720
  try {
15597
15721
  if (exists(rulesArchivePath(root))) {
@@ -15812,7 +15936,9 @@ function releaseNote(root, text) {
15812
15936
  if (exists(pkgFile)) { try { version = JSON.parse(read(pkgFile)).version || 'unknown'; } catch {} }
15813
15937
  const clFile = path.join(root, 'CHANGELOG.md');
15814
15938
  const date = today();
15815
- const headerRe = new RegExp(`^## ${version.replace(/\./g, '\\.')} `, 'm');
15939
+ // 1.36.49 (codex 6차 #6b): 점만 이스케이프하던 종전 regex 는 SemVer 빌드메타(1.2.3+build.1) `+` 수량자로
15940
+ // 해석해 기존 헤딩을 못 찾고 같은 버전 헤딩을 중복 생성했다 — 전체 regex 특수문자 이스케이프.
15941
+ const headerRe = new RegExp('^## ' + version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ' — ', 'm');
15816
15942
  if (exists(clFile)) {
15817
15943
  const cur = read(clFile);
15818
15944
  if (headerRe.test(cur)) {
@@ -22405,7 +22531,16 @@ async function main() {
22405
22531
  // 1.30.4 (14th리뷰 F7): rule 하위명령 미매칭 시 잘못된 토큰 명시 + usage(종전엔 top-level 'unknown_command: rule' 로 유효 부모명을 오인 표기).
22406
22532
  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
22533
  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(' '));
22534
+ // 1.36.49 (codex 6차 #6a): nonFlagArgs 대시 시작 토큰을 전역에서 걷어내 "- first" 같은 정상 노트가 입력 불가였다.
22535
+ // 원시 argv 에서 note 이후를 복원 — `--` 플래그(+값)만 스킵하고 단일 대시 텍스트("- …")는 노트로 보존.
22536
+ if (cmd === 'release' && args[1] === 'note') {
22537
+ const raw = process.argv.slice(2); const ni = raw.indexOf('note'); const toks = [];
22538
+ for (let i = ni + 1; i < raw.length && ni >= 0; i++) {
22539
+ if (raw[i].startsWith('--')) { if (raw[i] === '--path' && raw[i + 1]) i++; continue; }
22540
+ toks.push(raw[i]);
22541
+ }
22542
+ return releaseNote(arg('--path', process.cwd()), toks.join(' '));
22543
+ }
22409
22544
  if (cmd === 'release' && args[1] === 'publish') return releasePublish(args[2] || arg('--path', process.cwd()));
22410
22545
  if (cmd === 'release' && args[1] === 'pack') return await releasePackCmd(args[2] || arg('--path', process.cwd()));
22411
22546
  if (cmd === 'release' && args[1] === 'sync-main') return releaseSyncMainCmd(args[2] || arg('--path', process.cwd()));
package/lib/audit.js CHANGED
@@ -102,10 +102,14 @@ function audit(root, opts = {}, deps = {}) {
102
102
  const cur = exists(currentStatePath(root)) ? read(currentStatePath(root)) : '';
103
103
  const updMatch = cur.match(/Updated: (\d{4}-\d{2}-\d{2})/);
104
104
  if (updMatch) {
105
- const dDays = (Date.now() - new Date(updMatch[1]).getTime()) / 86400000;
105
+ // 1.36.50 (NaN 날짜 클래스 스윕): "Updated: 2026-99-99" 같은 무효 날짜는 NaN 비교로 무언 통과했다 —
106
+ // 신선함 증명 불가 = stale 취급(여긴 --fix 가 유효 날짜로 재스탬프하므로 자가 복구 경로).
107
+ let dDays = (Date.now() - new Date(updMatch[1]).getTime()) / 86400000;
108
+ if (!Number.isFinite(dDays)) dDays = Infinity;
106
109
  if (dDays > 7) {
107
- warnings++; warn(`current-state.md stale (${Math.round(dDays)} days)`);
108
- _finding('current_state_stale', 'warn', 'current-state.md stale', { days: Math.round(dDays) });
110
+ const dShow = Number.isFinite(dDays) ? Math.round(dDays) : '무효 날짜';
111
+ warnings++; warn(`current-state.md stale (${dShow} days)`);
112
+ _finding('current_state_stale', 'warn', 'current-state.md stale', { days: Number.isFinite(dDays) ? Math.round(dDays) : -1 });
109
113
  // 1.9.35 #5: --fix → current-state.md Updated 라인 갱신
110
114
  if (fix) {
111
115
  const stamped = cur.replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
@@ -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/drift.js CHANGED
@@ -29,7 +29,9 @@ function driftCheckCmd(root, opts = {}, deps = {}) {
29
29
  const m = allGen.length ? allGen[allGen.length - 1] : null;
30
30
  let ageDays;
31
31
  if (m) {
32
+ // 1.36.50 (NaN 날짜 클래스 스윕): 손상 타임스탬프(NaN)는 신선함을 증명 못 함 — mtime 폴백(무언 fresh 오판 방지)
32
33
  ageDays = (now - new Date(m[1]).getTime()) / 86400000;
34
+ if (!Number.isFinite(ageDays)) ageDays = _ageDays(shPath);
33
35
  } else {
34
36
  ageDays = _ageDays(shPath);
35
37
  }
@@ -39,13 +41,14 @@ function driftCheckCmd(root, opts = {}, deps = {}) {
39
41
  const csPath = currentStatePath(root);
40
42
  if (exists(csPath)) {
41
43
  const m = read(csPath).match(/Updated:\s*(\d{4}-\d{2}-\d{2})/);
42
- const ageDays = m ? (now - new Date(m[1]).getTime()) / 86400000 : _ageDays(csPath);
44
+ let ageDays = m ? (now - new Date(m[1]).getTime()) / 86400000 : _ageDays(csPath);
45
+ if (!Number.isFinite(ageDays)) ageDays = _ageDays(csPath); // 1.36.50: NaN 날짜 → mtime 폴백
43
46
  signals.push({ file: 'current-state.md', ageDays, threshold: 2, weight: 20, label: t('current-state 갱신 없음', 'current-state not updated') });
44
47
  }
45
48
  // 3. progress-tracker.md 마지막 row의 updated 컬럼
46
49
  const rows = readProgressRows(root);
47
50
  if (rows.length) {
48
- const dates = rows.map(r => (r.updated || '').match(/\d{4}-\d{2}-\d{2}/)).filter(Boolean).map(m => m[0]);
51
+ const dates = rows.map(r => (r.updated || '').match(/\d{4}-\d{2}-\d{2}/)).filter(Boolean).map(m => m[0]).filter(d => Number.isFinite(new Date(d).getTime())); // 1.36.50: 무효 날짜("2026-99-99") 제외
49
52
  if (dates.length) {
50
53
  dates.sort();
51
54
  const latest = dates[dates.length - 1];
@@ -58,7 +61,7 @@ function driftCheckCmd(root, opts = {}, deps = {}) {
58
61
  // 4. task-log.md 마지막 entry "## YYYY-MM-DD"
59
62
  const tlPath = taskLogPath(root);
60
63
  if (exists(tlPath)) {
61
- const dates = Array.from(read(tlPath).matchAll(/^## (\d{4}-\d{2}-\d{2})/gm)).map(m => m[1]);
64
+ const dates = Array.from(read(tlPath).matchAll(/^## (\d{4}-\d{2}-\d{2})/gm)).map(m => m[1]).filter(d => Number.isFinite(new Date(d).getTime())); // 1.36.50: 무효 날짜 제외
62
65
  if (dates.length) {
63
66
  dates.sort();
64
67
  const latest = dates[dates.length - 1];
package/lib/io.js CHANGED
@@ -9,17 +9,71 @@ const path = require('path');
9
9
  // 1.10.2 (UR-0146): quiet 모드 — 사람용 출력(log/ok/warn) 억제. init --json 등에서 큰 핸들러의 다수 log 를 비침투적으로 묵음 → 순수 JSON 1개만 출력.
10
10
  // fail/failJson(오류)은 묵음 대상 아님(에러는 항상 노출). setQuiet 로 토글, 호출부 finally 에서 반드시 복구.
11
11
  let _quiet = false;
12
- function setQuiet(v) { _quiet = !!v; }
13
- function log(s = '') { if (_quiet) return; console.log(s); }
12
+ function setQuiet(v) { _quiet = !!v; if (!v) { _jsonErrEmitted = false; _failLatched = false; _firstFail = null; } } // 1.36.50: 장수 프로세스(REPL)가 명령 경계에서 복구 시 JSON-오류 래치도 해제
13
+ function log(s = '') {
14
+ _unlatchIfNewCmd();
15
+ if (_quiet) return;
16
+ if (_failLatched || _jsonErrEmitted) {
17
+ // json-오류 래치 중: 명령 자신의 구조화 출력(JSON 페이로드)만 통과, 사람용 잔여 줄은 stderr 강등
18
+ const t = typeof s === 'string' ? s.trim() : '';
19
+ if (t && (t[0] === '{' || t[0] === '[')) { try { JSON.parse(t); console.log(s); _stdoutWrote = true; return; } catch {} }
20
+ console.error(s); return;
21
+ }
22
+ console.log(s); _stdoutWrote = true;
23
+ }
14
24
  function ok(s) { log('✓ ' + s); }
15
25
  function warn(s) { log('⚠ ' + s); }
16
26
  // fail() 은 오류 신호 → exit code 1 설정 (CI/MCP/에이전트가 실패를 성공으로 오판 방지, UR-0045).
17
- function fail(s) { console.log('✗ ' + s); process.exitCode = 1; } // quiet 무시(오류는 항상 노출)
27
+ // 1.36.50 (codex 스윕 B, 40+ 실측): --json 요청 fail() 자체가 단일 JSON 계약을 이행 —
28
+ // 첫 오류를 {ok:false,error,code:'error'} 로 stdout 에 내고 이후 사람용 출력(log/ok/warn·추가 fail)은
29
+ // 묵음/stderr 로 강등해 "✗ 텍스트·혼합·빈 stdout" 클래스를 진입점 한 곳에서 종결한다.
30
+ // 개별 명령이 failJson 으로 구체 code 를 주는 기존 경로는 그대로 우선.
31
+ // 설계 v2 (게이트 실측 후 재설계): 즉시 JSON 방출+전면 묵음은 "내부 fail 후 자기 집계 JSON 을 내는 명령"(gate 등)을 질식시켰다.
32
+ // → fail() 은 json 모드에서 stdout 에 아무것도 안 쓰고(✗ 는 stderr), 사람용 후속 log 만 억제하되
33
+ // JSON 페이로드 log 는 통과시킨다. 프로세스 exit 때 stdout 이 여전히 비어 있으면 첫 오류를 폴백 JSON 으로 방출 —
34
+ // 결과적으로 stdout 은 "명령 자신의 JSON" 또는 "폴백 오류 JSON" 중 정확히 하나.
35
+ let _jsonErrEmitted = false; // failJson 즉시 방출 후 후속 사람용 출력 억제 표식
36
+ let _failLatched = false; // fail() json 모드 발화 표식 (사람용 log 억제 + exit 폴백 대기)
37
+ let _firstFail = null;
38
+ let _stdoutWrote = false;
39
+ let _exitHooked = false;
40
+ let _latchArgv = null; // 래치는 argv 정체성 스코프 — 새 명령(REPL/인프로세스 테스트가 argv 교체) 시 자동 해제
41
+ // stdout 기록 추적은 스트림 레벨 — 일부 명령(audit 등)이 process.stdout.write 를 직접 캡처/사용해 log() 를 우회한다.
42
+ // 모듈 로드 시 1회 투명 래핑: 이후 어떤 경로(직접 write 포함)로든 stdout 에 쓰면 exit 폴백이 이중 JSON 을 내지 않는다.
43
+ {
44
+ const _rawWrite = process.stdout.write.bind(process.stdout);
45
+ process.stdout.write = function (...a) { _stdoutWrote = true; return _rawWrite(...a); };
46
+ }
47
+ function _argvJsonMode() { try { return process.argv.includes('--json'); } catch { return false; } }
48
+ function _unlatchIfNewCmd() {
49
+ if ((_jsonErrEmitted || _failLatched) && _latchArgv !== process.argv) { _jsonErrEmitted = false; _failLatched = false; _firstFail = null; _quiet = false; _latchArgv = null; }
50
+ }
51
+ function fail(s) {
52
+ _unlatchIfNewCmd();
53
+ if (_argvJsonMode()) {
54
+ if (_firstFail == null) _firstFail = s;
55
+ _failLatched = true; _latchArgv = process.argv;
56
+ console.error('✗ ' + s);
57
+ if (!_exitHooked) {
58
+ _exitHooked = true;
59
+ process.on('exit', () => { if (_failLatched && !_stdoutWrote && _firstFail != null) console.log(JSON.stringify({ ok: false, error: _firstFail, code: 'error' }, null, 2)); });
60
+ }
61
+ process.exitCode = 1; return;
62
+ }
63
+ console.log('✗ ' + s); process.exitCode = 1; // quiet 무시(오류는 항상 노출)
64
+ }
18
65
  // 1.9.398 (6번째 외부평가/codex P1-C, UR-0099): --json 모드 에러는 구조화 출력 — AI 에이전트가 에러 경로에서 JSON.parse 실패하지 않도록.
19
66
  // jsonMode 면 {ok:false,error,code} + exit1, 아니면 사람용 fail(). 양쪽 exit code 1 일관.
20
67
  function failJson(jsonMode, code, msg) {
21
- if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg, code }, null, 2)); process.exitCode = 1; } // quiet 무시(오류 JSON 항상 노출)
22
- else fail(msg);
68
+ _unlatchIfNewCmd();
69
+ if (jsonMode) {
70
+ if (!_jsonErrEmitted) { console.log(JSON.stringify({ ok: false, error: msg, code }, null, 2)); _jsonErrEmitted = true; _stdoutWrote = true; _failLatched = true; _latchArgv = process.argv; }
71
+ else console.error('✗ ' + msg);
72
+ process.exitCode = 1;
73
+ } else {
74
+ // 명시적 jsonMode=false 는 호출자의 결정 — argv 스니핑(fail)보다 우선해 human 출력 보장
75
+ console.log('✗ ' + msg); process.exitCode = 1;
76
+ }
23
77
  }
24
78
  function today() { return new Date().toISOString().slice(0, 10); }
25
79
  function now() { return new Date().toISOString(); }
@@ -38,8 +92,23 @@ function mkdirp(p) { fs.mkdirSync(p, { recursive: true }); }
38
92
  // 1.9.298 (UR-0038, 외부리뷰 3중수렴): 원자적 쓰기 — temp 파일에 기록 후 rename(원자 교체)으로 부분쓰기 손상 방지.
39
93
  // temp 이름은 .tmp-PID-SEQ 라 *.md glob 에 안 걸림. 실패 시 temp 정리.
40
94
  let _writeSeq = 0;
95
+ // 1.36.50 (스토어 손상 클래스 전수 스윕): "손상 JSON 위에 유효 JSON 저장" = 클로버 시그니처.
96
+ // 개별 스토어(21곳+)를 각자 가드하는 대신 단일 진입점에서: 기존 파일이 JSON 파싱 불가이고 새 내용은 유효 JSON 이면
97
+ // 원본을 <file>.corrupt-<ts> 로 대피(rename) 후 저장 — 유실 0 · 흐름(handoff 등 수동 기록) 비차단.
98
+ // 명시 fail-closed 스토어(state/teams/toggles 등)는 상류에서 이미 거부하므로 여기 도달하지 않는다.
99
+ function _rescueCorruptJson(p, s) {
100
+ if (!/\.json$/i.test(p) || !fs.existsSync(p)) return;
101
+ try { JSON.parse(s); } catch { return; } // 새 내용이 JSON 아니면 대상 아님
102
+ let old;
103
+ try { old = read(p); } catch { return; }
104
+ try { JSON.parse(old); return; } catch {} // 기존이 정상이면 통상 덮어쓰기
105
+ const bak = `${p}.corrupt-${Date.now()}`;
106
+ // 알림은 stderr — --json 명령의 stdout 단일-JSON 계약을 오염하지 않는다 (codex 스윕 B #1 실측)
107
+ try { fs.renameSync(p, bak); console.error(`⚠ 손상 JSON 감지 — 원본 대피 후 저장: ${path.basename(bak)} (수동 복구용)`); } catch {}
108
+ }
41
109
  function writeUtf8(p, s) {
42
110
  mkdirp(path.dirname(p));
111
+ _rescueCorruptJson(p, s);
43
112
  const tmp = `${p}.tmp-${process.pid}-${++_writeSeq}`;
44
113
  try {
45
114
  fs.writeFileSync(tmp, s, { encoding: 'utf8' });
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.48",
3
+ "version": "1.36.50",
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",