leerness 1.36.16 → 1.36.18

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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.18 — 2026-07-14 — UR-0052: handoff --compact 단일경로 + ID 5자리+ 대응 (P2-6 주석 마스킹은 조사 후 이연)
4
+
5
+ 1.36.17에서 백로그로 이연했던 UR-0052 3건 중 2건(P3-8·P1-2)을 재현·수정. P2-6(주석 마스킹)은 codex 7라운드 적대 재검 끝에 **파서 없이 안전 구현 불가**로 판단해 이연(하단 상세). 각 수정은 파일 스크립트 프로브 + selftest 케이스로 실증(맹신 X).
6
+
7
+ - **P3-8 handoff `--compact` 단일 워크스페이스 무효**: `--compact`가 멀티(`_handoffWorkspace`)에만 있어 단일 `handoff . --compact`는 섹션 몇 개만 억제하고 본문 186줄 전량 출력 → 문서상 "~500자 요약"과 불일치. MCP handoff·REPL preview·agent-mode 등 내부 호출자 3곳이 단일 경로에 `--compact`를 써 preview 첫 3줄이 헤더/날짜/이름으로 무의미했음(재현: 186줄). → 단일 `handoff()`에 compact 단축 추가(subprocess 없이 파일 로더만으로 프로젝트/진척/메모리요약/다음액션/미답요청 3줄 압축, `_lineSafe`로 projectName 개행 주입 차단). 실측 186→3줄, `--json`은 여전히 full, preview 유의미화.
8
+ - **P1-2 ID 리더 5자리+ 대응**: T-/M-/D-/R-/F- ID를 `\d{4}`(정확히 4자리)로 읽어, 10,000번째 ID(T-10000)부터 앞 4자리("1000")만 잡아 truncation/충돌(예: T-10000 뒤 `task add`가 T-1001 재발급)(재현확정). → letter-prefix ID의 `\d{4}`를 `\d{4,}`로 확장(bin 28·pure-utils 2·lib 9 = `feature.js`/`audit.js`/`health.js`/`session-close.js` 포함 — codex가 이 lib 누락을 지적). 판별자 `(?!-)`로 날짜 연도(`\d{4}-`)는 exactly-4 보존(불변 assert). `\d{4,}`는 4자리 ID도 매치해 무회귀. 실측: T-10000 뒤 add→**T-10001**, `feature show F-10000` 수용, 밀스톤 M-10000 카운트, 날짜 정상.
9
+ - **P2-6 이연(조사 완료, 미채택)**: `contract verify`가 raw 소스를 훑어 주석처리된 `// module.exports = { fn }`을 실 구현으로 카운트하는 FN은 실재한다. 그러나 이를 고치려면 스캔 전 "주석만" 지워야 하고, JS에서 주석 경계를 정확히 찾으려면 정규식-리터럴/나눗셈/문자열/템플릿 보간(임의 코드)을 모두 렉싱해야 한다. 의존성 0(파서 금지) 제약 하에 손수 만든 마스커 3종(주석+문자열+정규식 마스킹 → 주석+문자열만 원문보존 → 줄머리 주석만)을 codex가 **7라운드 연속** 유효 JS에서 깨뜨렸다 — 특히 실코드를 지우는 **거짓 FAIL**(정당 계약을 CI에서 차단): `if(x) /[/*]/`의 `/*`를 유령 블록주석으로, `${/[}]/.test(x)?`\n/* ${f.field} */\n`:''}`의 정규식 `}`를 보간 종료로 오계수해 중첩 템플릿의 실 필드를 삭제 등. CI 게이트에서 거짓 FAIL(정당 계약 차단) > 거짓 PASS(주석 export 카운트)이므로, **니치한 FN을 위해 현실적 거짓 FAIL 위험을 들이는 것은 순손해**로 판단하고 미채택. 근본 해법은 실 JS 파서(제약 위반)라 이연. contract verify는 기존 raw-소스 동작 유지(무회귀).
10
+ - **검증**: selftest 288/288(P1-2 케이스에 lib 확장 가드 포함; P2-6 케이스는 revert로 제거), P3-8/P1-2 파일 스크립트 프로브 + CLI E2E 실증, 게이트 e2e 386/386, 게시본 클린룸 재실증.
11
+
12
+ ## 1.36.17 — 2026-07-14 — 렌즈-외 표면 버그헌트: 데이터손실 2 + gate FN + 아카이브 주입 + --json 에러경로 (codex, 재현확정)
13
+
14
+ 게시본 1.36.16 대상, database 렌즈를 제외한 CLI 표면을 codex로 광범위 적대 헌트 → 8건 중 6건 수정(고영향 재현확정), 2건 백로그(UR-0052).
15
+
16
+ - **P1 데이터손실 — `memory restore` (decisions/lessons)**: 복원이 MD 프로젝션에만 append 하고 canonical JSON을 안 고쳐, `list`엔 안 보이고 다음 add/save가 JSON→MD 재생성으로 복원분을 영구 삭제(재현확정). → 복원 후 MD를 canonical 파서로 재파싱해 `_saveDecisions`/`_saveLessons`로 JSON 재빌드.
17
+ - **P2 데이터손실 — `plan remove`**: `### ` 만 블록 경계로 써서 마지막 마일스톤 뒤 사용자 h1/h2 섹션(`## Risks` 등)이 함께 삭제(재현확정). → 모든 헤딩(`#{1,3} `)을 경계로 분할, `### M-` 블록만 삭제.
18
+ - **P2 gate FN — `verify-claim`**: FILE_RE/`_evidenceQuality`가 basename 첫 글자를 `[A-Za-z]`로만 잡아 `123.js` 같은 숫자시작 실파일 미추출 → 없는 파일 인용한 done 주장이 vacuous-pass(재현확정, 정상 done FP 회귀 없음 확인). → 첫 글자에 숫자 허용(prev-separator 가드 유지).
19
+ - **P2 아카이브 주입**: lesson/decision drop이 사용자 텍스트를 archive MD에 raw 삽입 → 개행으로 `## 제거` 델리미터 위조(가짜 archive 엔트리)(재현확정). → 아카이브 필드에 `_lineSafe`.
20
+ - **P2 --json 에러경로**: task update/drop·lesson/decision/plan/rule not-found 6경로가 `--json`에도 평문 출력 → JSON 소비자 파싱 실패. → `failJson(has('--json'), code, msg)`.
21
+ - **백로그(UR-0052)**: P1-2(10k+ ID 고정폭 `\d{4}` 리더 ~20곳, 날짜 혼동 위험으로 신중한 일괄 필요), P2-6(contract verify 주석 마스킹), P3-8(handoff `--compact` 단일 워크스페이스 분기).
22
+ - **검증**: selftest 287/287(신규 6수정 소스가드), 데이터손실 3건 재현→수정 후 재검, 게이트 e2e 386/386.
23
+
3
24
  ## 1.36.16 — 2026-07-13 — 게시본 1.36.15 클린룸 codex 9건 채택: database 렌즈 정직/정밀 + selftest·병합 하드닝
4
25
 
5
26
  게시본 1.36.13~1.36.15 누적 신규 표면(문항 8→12, cap 16)을 codex 로 적대 리뷰 → 9건(P2/P3) 전부 재현/채택. clean 3축(i18n 패리티·앵커 유일성·크래시 없음).
package/README.md CHANGED
@@ -125,7 +125,7 @@ MIT
125
125
  <!-- leerness:project-readme:start -->
126
126
  ## Leerness Project Harness
127
127
 
128
- 이 프로젝트는 Leerness v1.36.16 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
128
+ 이 프로젝트는 Leerness v1.36.18 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
129
129
 
130
130
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
131
131
 
@@ -179,7 +179,7 @@ leerness memory restore decision <date|title>
179
179
 
180
180
  ### MCP server (외부 AI 통합)
181
181
 
182
- Leerness v1.36.16는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
182
+ Leerness v1.36.18는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **86개 도구**를 노출:
183
183
 
184
184
  ```jsonc
185
185
  // 카테고리별
@@ -200,7 +200,7 @@ Leerness v1.36.16는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
200
200
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
201
201
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) stress-v* 신규 작성 + 누적 회귀 → 4) e2e 219/219 → 5) npm pack + git tag + GitHub release → 6) main 자동 push (1.9.140+) → 7) session close → 8) 다음 라운드 예약.
202
202
 
203
- 현재 누적: **70 라운드 (1.9.40 → 1.36.16)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
203
+ 현재 누적: **70 라운드 (1.9.40 → 1.36.18)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
204
204
 
205
205
  ### 성능 가이드 (1.9.140 측정)
206
206
 
@@ -238,6 +238,6 @@ leerness release pack --close --auto-main-push
238
238
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
239
239
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
240
240
 
241
- Last synced by Leerness v1.36.16: 2026-07-13
241
+ Last synced by Leerness v1.36.18: 2026-07-14
242
242
  <!-- leerness:project-readme:end -->
243
243
 
package/bin/leerness.js CHANGED
@@ -33,7 +33,7 @@ const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInG
33
33
  const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _TOOL_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, SKILL_CATALOG_PRESETS } = require('../lib/catalogs'); // 1.9.344/368/369 (UR-0025): catalog 분리 · 1.11.4 (UR-0007): _TOOL_CATALOG
34
34
  const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
35
35
 
36
- const VERSION = '1.36.16';
36
+ const VERSION = '1.36.18';
37
37
 
38
38
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
39
39
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -2963,6 +2963,27 @@ function _selfTestCases() {
2963
2963
  const realDecl = p._parseContractSpec('function realFn(){}\n').declared.includes('realFn'); // 회귀: 펜스 밖 선언 유지
2964
2964
  return bracket && dotStill && fenceExcluded && realDecl;
2965
2965
  } },
2966
+ { name: 'ID 리더 5자리+ 대응 (1.36.18, UR-0052 P1-2): \\d{4}→\\d{4,} — 10k+ ID(T-10000) truncation 방지, 날짜 연도는 exactly-4 보존 — 행위검사', run: () => {
2967
+ // 행위: 5자리 ID 전체 캡처(구 \d{4}는 앞 4자리만 → truncation/충돌), 4자리 ID 무회귀, 날짜 연도 exactly-4 유지.
2968
+ const taskRe = /\bT-(\d{4,})\b/;
2969
+ const idFive = (taskRe.exec('T-12345') || [])[1] === '12345'; // 5자리 전체
2970
+ const idFour = (taskRe.exec('T-0001') || [])[1] === '0001'; // 회귀: 4자리 유지
2971
+ const ruleFive = /^R-(\d{4,})$/.test('R-99999') && /^R-(\d{4,})$/.test('R-0001');
2972
+ const mileFive = /^### M-\d{4,}\./m.test('### M-10000. big') && /^### M-\d{4,}\./m.test('### M-0004. small');
2973
+ const dateExact4 = /^\d{4}-\d{2}-\d{2}$/.test('2026-07-14') && !/^\d{4}-\d{2}-\d{2}$/.test('20260-7-14'); // 연도는 정확히 4자리
2974
+ // 소스가드: 핵심 리더가 실제로 \d{4,} 로 넓혀졌고, 날짜 패턴은 \d{4}- 로 보존.
2975
+ const s = read(__filename);
2976
+ const readersWidened = s.includes('/\\bT-(\\d{4,})\\b/g') && s.includes('/^R-(\\d{4,})$/') && s.includes('### M-\\d{4,}\\.');
2977
+ const dateKept = s.includes('\\d{4}-\\d{2}-\\d{2}'); // 날짜 연도 미확장 확인
2978
+ // codex F2/F5 회귀가드: lib 리더도 함께 넓혀졌는지(feature/audit/health/session-close 누락 방지) — 없으면 5자리 ID 가 이 표면에서만 truncation.
2979
+ const dir = require('path').dirname(__dirname) + '/lib';
2980
+ const libWidened = read(dir + '/feature.js').includes('F-\\d{4,}$')
2981
+ && read(dir + '/audit.js').includes('M-\\d{4,}')
2982
+ && read(dir + '/health.js').includes('M-\\d{4,}')
2983
+ && read(dir + '/session-close.js').includes('M-\\d{4,}');
2984
+ const libNoStale = !read(dir + '/feature.js').includes('F-\\d{4}$') && !read(dir + '/audit.js').includes('(M-\\d{4})');
2985
+ return idFive && idFour && ruleFive && mileFive && dateExact4 && readersWidened && dateKept && libWidened && libNoStale;
2986
+ } },
2966
2987
  { name: '시크릿 스캐너 FN 헌트 (1.35.14): 하드코딩 자격증명 복합/JSON키 탐지 + Slack xapp + 사전단어 FP 억제 — 행위검사', run: () => {
2967
2988
  const pats = require('../lib/catalogs').SECRET_PATTERNS;
2968
2989
  const pu = require('../lib/pure-utils');
@@ -3732,10 +3753,10 @@ function _selfTestCases() {
3732
3753
  } },
3733
3754
  { name: '17th 버그헌트 P2: plan add 공백제목 trim(기본값) + milestone 파서 개행 미흡수 (1.17.1)', run: () => {
3734
3755
  const src = read(__filename);
3735
- const wired = src.includes("args.slice(2).join(' ').trim() || '새 계획'") && src.includes('(M-\\d{4})\\.[ \\t]*(.+?)$');
3756
+ const wired = src.includes("args.slice(2).join(' ').trim() || '새 계획'") && src.includes('(M-\\d{4,})\\.[ \\t]*(.+?)$');
3736
3757
  // 파서 동작: 공백제목 milestone 이 다음 줄 'Status:' 를 제목으로 먹지 않음
3737
3758
  const block = '### M-0006. \nStatus: planned\nProgress: 0%\n';
3738
- const m = block.match(/^### (M-\d{4})\.[ \t]*(.+?)$/m);
3759
+ const m = block.match(/^### (M-\d{4,})\.[ \t]*(.+?)$/m);
3739
3760
  const safe = !m || (m[2] || '').indexOf('Status') === -1;
3740
3761
  return wired && safe;
3741
3762
  } },
@@ -3928,6 +3949,26 @@ function _selfTestCases() {
3928
3949
  && eq(F(['README.md']), ['docs']);
3929
3950
  return catOk && mapOk;
3930
3951
  } },
3952
+ { name: '비-렌즈 버그헌트 수정 회귀 (게시본 1.36.16 codex): restore canonical 재빌드 + plan 헤딩경계 + FILE_RE 숫자시작 + archive/json 구조화 (소스가드)', run: () => {
3953
+ const src = read(__filename);
3954
+ // P1-1: memory restore 가 decisions/lessons canonical JSON 을 재빌드(MD-only append 아님)
3955
+ const restoreOk = src.includes("if (surface === 'decisions') _saveDecisions(root, _decisionsFromMd(read(activePath)))")
3956
+ && src.includes("else if (surface === 'lessons') _saveLessons(root, _parseLessonEntries(read(activePath)))");
3957
+ // P2-4: plan remove 가 모든 헤딩(#{1,3} )을 경계로 split (사용자 h2 섹션 흡수 방지)
3958
+ const planOk = src.includes('const blocks = text.split(/\\n(?=#{1,3} )/);');
3959
+ // P2-5: FILE_RE + _evidenceQuality basename 첫 글자에 숫자 허용 (저-백슬래시 판별 조각)
3960
+ const fileReOk = src.includes(')?[A-Za-z0-9][');
3961
+ const analyzersOk = read(path.join(__dirname, '..', 'lib', 'analyzers.js')).includes('[A-Za-z0-9][\\w./');
3962
+ // P2-3: archive 쓰기에 _lineSafe (개행 델리미터 위조 차단)
3963
+ const archiveOk = src.includes('`\\n## 제거 ${today()} (target: "${_lineSafe(target)}")\\n${archiveBlocks}\\n`');
3964
+ // P2-7: drop/remove not-found 가 failJson (--json 에러 구조화)
3965
+ const jsonOk = src.includes("failJson(has('--json'), 'lesson_not_found'")
3966
+ && src.includes("failJson(has('--json'), 'decision_not_found'")
3967
+ && src.includes("failJson(has('--json'), 'milestone_not_found'")
3968
+ && src.includes("failJson(has('--json'), 'task_not_found'")
3969
+ && src.includes("failJson(has('--json'), 'rule_not_found'");
3970
+ return restoreOk && planOk && fileReOk && analyzersOk && archiveOk && jsonOk;
3971
+ } },
3931
3972
  { name: 'DB 렌즈 recall (dogfood FN, 1.36.4): 내용기반 감지 — 평범한 이름의 DB 모듈 잡고 산문 FP 0 (행위)', run: () => {
3932
3973
  const T = _isDbContentText, W = _withDbDomain;
3933
3974
  const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
@@ -4781,7 +4822,7 @@ function pulseCmd(root) {
4781
4822
  const decisionCount = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
4782
4823
  const rulesActive = readRules(root).filter(r => r.status === 'active').length;
4783
4824
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
4784
- const milestonesCnt = (planText.match(/^### M-\d{4}\./gm) || []).length;
4825
+ const milestonesCnt = (planText.match(/^### M-\d{4,}\./gm) || []).length;
4785
4826
  const lessonsCount = _loadLessons(root).length;
4786
4827
  data.memorySurface = _memorySurface({ tasks: tasksInProgress, decisions: decisionCount, rules: rulesActive, milestones: milestonesCnt, lessons: lessonsCount });
4787
4828
  } catch {}
@@ -7259,7 +7300,7 @@ async function nextActionCmd(root, sub, ...rest) {
7259
7300
  // leerness task add 호출
7260
7301
  const taskResult = cp.spawnSync(process.execPath, [__filename, 'task', 'add', taskTitle, '--path', root], { encoding: 'utf8', timeout: 8000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
7261
7302
  if (taskResult.status === 0) {
7262
- const m = (taskResult.stdout || '').match(/T-\d{4}/);
7303
+ const m = (taskResult.stdout || '').match(/T-\d{4,}/);
7263
7304
  log(` ✓ task 추가: ${m ? m[0] : '?'} — "${taskTitle}"`);
7264
7305
  if (action.command) log(` 💡 실행 명령: ${action.command}`);
7265
7306
  } else {
@@ -7318,9 +7359,9 @@ function _suggestNextActions(root, latestRow, keyword) {
7318
7359
  try {
7319
7360
  if (fuzzyRe && exists(planPath(root))) {
7320
7361
  const pt = read(planPath(root));
7321
- const milestones = (pt.match(/^### (M-\d{4})\..*$/gm) || []).filter(l => fuzzyRe.test(l));
7362
+ const milestones = (pt.match(/^### (M-\d{4,})\..*$/gm) || []).filter(l => fuzzyRe.test(l));
7322
7363
  if (milestones.length > 0) {
7323
- const m = milestones[0].match(/M-\d{4}/);
7364
+ const m = milestones[0].match(/M-\d{4,}/);
7324
7365
  if (m) actions.push({ icon: '🎯', title: `plan.md milestone ${m[0]} 검증 — "${keyword}" 관련`, command: `leerness plan list --filter "${keyword}"` });
7325
7366
  }
7326
7367
  }
@@ -7553,7 +7594,7 @@ function readProgressRows(root) {
7553
7594
  const text = exists(progressPath(root)) ? read(progressPath(root)) : '';
7554
7595
  const rows = [];
7555
7596
  for (const line of text.split('\n')) {
7556
- if (!/^\| (?:T|M|D)-\d{4} \|/.test(line)) continue;
7597
+ if (!/^\| (?:T|M|D)-\d{4,} \|/.test(line)) continue;
7557
7598
  // 1.9.399 (7번째 버그헌트 P1-A, UR-0104): 비이스케이프 파이프에서만 분리 + 셀 복원 — 사용자 텍스트의 '|'(이스케이프됨)이 컬럼을 깨지 않음.
7558
7599
  const cells = line.split(/(?<!\\)\|/).slice(1, -1).map(s => _cellUnescape(s).trim());
7559
7600
  if (cells.length < 6) continue;
@@ -7594,7 +7635,7 @@ function upsertProgress(root, row) {
7594
7635
  });
7595
7636
  }
7596
7637
 
7597
- function planShow(root) { const p = planPath(root); const has_ = exists(p); const content = has_ ? read(p) : ''; if (has('--json')) { const milestones = (content.match(/^### (M-\d{4})\b.*$/gm) || []).map(l => l.replace(/^###\s*/, '').trim()); log(JSON.stringify({ exists: has_, milestones, raw: content }, null, 2)); return; } log(has_ ? content : 'plan.md not found'); } // 1.9.428 (UR-0128): plan show --json 구조화
7638
+ function planShow(root) { const p = planPath(root); const has_ = exists(p); const content = has_ ? read(p) : ''; if (has('--json')) { const milestones = (content.match(/^### (M-\d{4,})\b.*$/gm) || []).map(l => l.replace(/^###\s*/, '').trim()); log(JSON.stringify({ exists: has_, milestones, raw: content }, null, 2)); return; } log(has_ ? content : 'plan.md not found'); } // 1.9.428 (UR-0128): plan show --json 구조화
7598
7639
  function planInit(root) { const goal = arg('--goal', ''); if (!exists(planPath(root))) return install(root); append(planPath(root), `\n## User Goal\n- ${goal || '사용자 목적을 작성하세요.'}\n`); ok('plan goal appended'); }
7599
7640
  // 1.9.119: plan list — plan.md 의 모든 milestone (M-XXXX) 조회 (CLI + --json + MCP)
7600
7641
  function planListCmd(root, opts = {}) {
@@ -7611,9 +7652,9 @@ function planListCmd(root, opts = {}) {
7611
7652
  const text = read(pp);
7612
7653
  const milestones = [];
7613
7654
  // ### M-XXXX. <title> 블록 추출
7614
- const blocks = text.split(/\n(?=### M-\d{4}\.)/);
7655
+ const blocks = text.split(/\n(?=### M-\d{4,}\.)/);
7615
7656
  for (const b of blocks) {
7616
- const headerMatch = b.match(/^### (M-\d{4})\.[ \t]*(.+?)$/m);
7657
+ const headerMatch = b.match(/^### (M-\d{4,})\.[ \t]*(.+?)$/m);
7617
7658
  if (!headerMatch) continue;
7618
7659
  const id = headerMatch[1];
7619
7660
  const title = headerMatch[2].trim();
@@ -7717,11 +7758,13 @@ function planRemoveCmd(root, target) {
7717
7758
  if (!exists(pp)) return fail('plan.md 없음');
7718
7759
  const text = read(pp);
7719
7760
  // milestone 블록은 "### M-XXXX. 제목" 으로 시작; "## " (Out of Scope 등) 헤더 또는 EOF 이전까지
7720
- const blocks = text.split(/\n(?=### )/);
7761
+ // codex 버그헌트 P2: `### ` 만 경계로 쓰면 마지막 마일스톤 뒤의 사용자 h1/h2 섹션(예: `## Risks`)
7762
+ // 그 마일스톤 블록에 흡수돼 함께 삭제된다. 모든 헤딩(#{1,3} )을 경계로 잘라 각 섹션을 독립 블록화.
7763
+ const blocks = text.split(/\n(?=#{1,3} )/);
7721
7764
  let removed = 0;
7722
7765
  const kept = [];
7723
7766
  for (const b of blocks) {
7724
- if (!b.startsWith('### ')) { kept.push(b); continue; }
7767
+ if (!b.startsWith('### ')) { kept.push(b); continue; } // 마일스톤(### M-) 블록만 삭제 후보
7725
7768
  const headerMatch = b.match(/^### (.+)$/m);
7726
7769
  if (!headerMatch) { kept.push(b); continue; }
7727
7770
  const titleLine = headerMatch[1].trim();
@@ -7744,7 +7787,7 @@ function planRemoveCmd(root, target) {
7744
7787
  }
7745
7788
  kept.push(b);
7746
7789
  }
7747
- if (removed === 0) return fail(`매칭 milestone 없음: "${target}"`);
7790
+ if (removed === 0) return failJson(has('--json'), 'milestone_not_found', `매칭 milestone 없음: "${target}"`); // codex P2: --json 에러 구조화
7748
7791
  writeUtf8(pp, kept.join('\n'));
7749
7792
  ok(`milestone removed: ${removed}건 (보존: .harness/plan.archive.md)`);
7750
7793
  _autoRoadmap(absRoot(root), 'data-change');
@@ -7918,7 +7961,7 @@ function taskUpdate(root, id) {
7918
7961
  }
7919
7962
  if (!_validateChoice(arg('--status', null), TASK_STATUSES, 'task status')) { process.exitCode = 1; return; } // 1.9.310 (UR-0046)
7920
7963
  const rows = readProgressRows(root);
7921
- if (!rows.find(r => r.id === id)) { fail(`task ${id} not found in progress-tracker.md`); return; }
7964
+ if (!rows.find(r => r.id === id)) { failJson(has('--json'), 'task_not_found', `task ${id} not found in progress-tracker.md`); return; } // codex P2: --json 에러 구조화
7922
7965
  const patch = { id };
7923
7966
  if (arg('--status') !== null) patch.status = _normTaskStatus(arg('--status')); // 1.11.3 (UR-0178): completed/verified → done 정규화
7924
7967
  if (arg('--evidence') !== null) patch.evidence = arg('--evidence');
@@ -7936,7 +7979,7 @@ function taskDrop(root, id) {
7936
7979
  if (!id) return fail('id required');
7937
7980
  const rows = readProgressRows(root);
7938
7981
  // 1.9.396 (6번째 외부평가/codex P1-B): 없는 task drop 시 가짜 row(request undefined) 생성 = 데이터 손상 → task update 와 동일하게 존재 확인 후 fail(no-op).
7939
- if (!rows.find(r => r.id === id)) { fail(`task ${id} not found in progress-tracker.md`); return; }
7982
+ if (!rows.find(r => r.id === id)) { failJson(has('--json'), 'task_not_found', `task ${id} not found in progress-tracker.md`); return; } // codex P2: --json 에러 구조화
7940
7983
  upsertProgress(root, { id, status: 'dropped', evidence: arg('--reason','사용자 요청으로 제외'), nextAction: '없음' });
7941
7984
  ok(`task dropped: ${id}`);
7942
7985
  _autoRoadmap(absRoot(root), 'data-change');
@@ -7963,7 +8006,7 @@ function memoryStatusCmd(root, opts = {}) {
7963
8006
  const rulesPaused = rules.filter(r => r.status === 'paused').length;
7964
8007
  // Plan
7965
8008
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
7966
- const milestones = (planText.match(/^### M-\d{4}\./gm) || []).length;
8009
+ const milestones = (planText.match(/^### M-\d{4,}\./gm) || []).length;
7967
8010
  // plan milestones in-progress: progress-tracker에서 plan:M-XXXX evidence 있고 in-progress 인 row
7968
8011
  const planInProgress = rows.filter(r => /plan:M-/.test(r.evidence || '') && r.status === 'in-progress').length;
7969
8012
  // Lessons (UR-0058: canonical _loadLessons — JSON 단일 진실소스, MD projection fallback)
@@ -8134,6 +8177,12 @@ function memoryRestoreCmd(root, surface, target) {
8134
8177
  for (const blk of restoredBlocks) {
8135
8178
  append(activePath, '\n' + blk + '\n');
8136
8179
  }
8180
+ // codex 버그헌트 P1: decisions/lessons 는 canonical JSON 이 진실소스라(list/save 가 JSON 기준),
8181
+ // MD 프로젝션에만 append 하면 복원분이 list 에 안 보이고 다음 add/save 가 JSON→MD 재생성으로 영구 삭제.
8182
+ // 방금 복원된 MD 를 canonical 파서로 재파싱해 JSON 을 재빌드 → 복원분이 후속 write 에도 생존.
8183
+ if (surface === 'decisions') _saveDecisions(root, _decisionsFromMd(read(activePath)));
8184
+ else if (surface === 'lessons') _saveLessons(root, _parseLessonEntries(read(activePath)));
8185
+ // (plan 은 plan.md 가 진실소스라 append 로 충분)
8137
8186
  // archive 재작성 — 모두 제거되면 파일 비움 (헤더만 남김 또는 삭제)
8138
8187
  if (kept.length === 0) {
8139
8188
  // archive 헤더만 남겨도 의미 있음 — 향후 다시 사용 가능
@@ -8198,19 +8247,21 @@ function lessonDropCmd(root, target) {
8198
8247
  const kept = [];
8199
8248
  const removed = [];
8200
8249
  const all = _loadLessons(root);
8201
- if (!all.length) return fail('lessons 없음');
8250
+ if (!all.length) return failJson(has('--json'), 'no_lessons', 'lessons 없음'); // codex P2: --json 에러 구조화
8202
8251
  for (const l of all) {
8203
8252
  if (l.date === target || (l.text || '').includes(target)) removed.push(l);
8204
8253
  else kept.push(l);
8205
8254
  }
8206
- if (!removed.length) return fail(`매칭 lesson 없음: "${target}"`);
8255
+ if (!removed.length) return failJson(has('--json'), 'lesson_not_found', `매칭 lesson 없음: "${target}"`);
8207
8256
  // archive 보존 — lessons.archive.md 에 projection MD 블록 형태로 추가
8208
8257
  const archivePath = path.join(root, '.harness/lessons.archive.md');
8209
8258
  const archiveHeader = exists(archivePath) ? '' : '# Lessons archive\n\n';
8259
+ // codex 버그헌트 P2: 사용자 텍스트(l.text/l.tag/target)를 archive MD 에 raw 삽입하면 개행으로
8260
+ // `## 제거 …` 델리미터를 위조해 가짜 archive 엔트리를 만들 수 있다(archive list/restore 오염). _lineSafe 로 개행 무력화.
8210
8261
  const archiveBlocks = removed.map(l =>
8211
- `\n### ${l.date}\n- Lesson: ${l.text}\n${l.tag ? `- Tag: ${l.tag}\n` : ''}`
8262
+ `\n### ${_lineSafe(l.date)}\n- Lesson: ${_lineSafe(l.text)}\n${l.tag ? `- Tag: ${_lineSafe(l.tag)}\n` : ''}`
8212
8263
  ).join('');
8213
- append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${target}")\n${archiveBlocks}\n`);
8264
+ append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${_lineSafe(target)}")\n${archiveBlocks}\n`);
8214
8265
  _saveLessons(root, kept);
8215
8266
  ok(`lesson dropped: ${removed.length}건 (보존: .harness/lessons.archive.md)`);
8216
8267
  _autoRoadmap(absRoot(root), 'data-change');
@@ -8287,22 +8338,23 @@ function decisionDropCmd(root, target) {
8287
8338
  if (!target) return fail('decision drop <date|title-substring> 필요. 예: leerness decision drop "2026-05-20" 또는 leerness decision drop "PostgreSQL"');
8288
8339
  // 1.9.339 (UR-0053): canonical JSON 기준 drop (date 또는 title substring 매칭) — JSON+MD projection 동시 갱신.
8289
8340
  const all = _loadDecisions(root);
8290
- if (!all.length) return fail('decisions 없음');
8341
+ if (!all.length) return failJson(has('--json'), 'no_decisions', 'decisions 없음'); // codex P2: --json 에러 구조화
8291
8342
  const kept = [];
8292
8343
  const removed = [];
8293
8344
  for (const d of all) {
8294
8345
  if (d.date === target || (d.title || '').includes(target)) removed.push(d);
8295
8346
  else kept.push(d);
8296
8347
  }
8297
- if (!removed.length) return fail(`매칭 decision 없음: "${target}"`);
8348
+ if (!removed.length) return failJson(has('--json'), 'decision_not_found', `매칭 decision 없음: "${target}"`);
8298
8349
  // archive 보존 (projection MD 블록 형태)
8299
8350
  const archivePath = path.join(root, '.harness/decisions.archive.md');
8300
8351
  const archiveHeader = exists(archivePath) ? '' : '# Decisions archive\n\n';
8352
+ // codex 버그헌트 P2: 사용자 필드 raw 삽입 → 개행으로 `## 제거` 델리미터 위조 가능. _lineSafe 로 무력화.
8301
8353
  const archiveBlocks = removed.map(d => {
8302
- const head = d.date ? `${d.date} — ${d.title}` : d.title;
8303
- return `\n### ${head}\n- Decision: ${d.decision || ''}\n- Reason: ${d.reason || ''}\n- Alternatives: ${d.alternatives || ''}\n- Impact: ${d.impact || ''}\n`;
8354
+ const head = d.date ? `${_lineSafe(d.date)} — ${_lineSafe(d.title)}` : _lineSafe(d.title);
8355
+ return `\n### ${head}\n- Decision: ${_lineSafe(d.decision || '')}\n- Reason: ${_lineSafe(d.reason || '')}\n- Alternatives: ${_lineSafe(d.alternatives || '')}\n- Impact: ${_lineSafe(d.impact || '')}\n`;
8304
8356
  }).join('');
8305
- append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${target}")\n${archiveBlocks}\n`);
8357
+ append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${_lineSafe(target)}")\n${archiveBlocks}\n`);
8306
8358
  _saveDecisions(root, kept);
8307
8359
  ok(`decision dropped: ${removed.length}건 (보존: .harness/decisions.archive.md)`);
8308
8360
  _autoRoadmap(absRoot(root), 'data-change');
@@ -8363,10 +8415,10 @@ function _jaccard(a, b) {
8363
8415
  function taskRelink(root) {
8364
8416
  root = absRoot(root);
8365
8417
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
8366
- const milestones = [...planText.matchAll(/^### (M-\d{4})\.[ \t]*(.+?)$/gm)]
8418
+ const milestones = [...planText.matchAll(/^### (M-\d{4,})\.[ \t]*(.+?)$/gm)]
8367
8419
  .map(m => ({ id: m[1], text: m[2].trim() }));
8368
8420
  const rows = readProgressRows(root);
8369
- const linkedM = new Set(rows.map(r => (r.evidence.match(/M-\d{4}/) || [])[0]).filter(Boolean));
8421
+ const linkedM = new Set(rows.map(r => (r.evidence.match(/M-\d{4,}/) || [])[0]).filter(Boolean));
8370
8422
  const orphanM = milestones.filter(m => !linkedM.has(m.id));
8371
8423
  if (!orphanM.length) return ok('미연결 milestone 없음');
8372
8424
 
@@ -8414,7 +8466,7 @@ function taskFixEvidence(root) {
8414
8466
  !r.evidence ||
8415
8467
  /^\s*$/.test(r.evidence) ||
8416
8468
  /^(user-request|-)$/.test(r.evidence) ||
8417
- /^plan:M-\d{4}\s*$/.test(r.evidence)
8469
+ /^plan:M-\d{4,}\s*$/.test(r.evidence)
8418
8470
  )
8419
8471
  );
8420
8472
  if (!candidates.length) return ok('갱신 후보 없음 (모든 done row가 검증 키워드 보유)');
@@ -8426,7 +8478,7 @@ function taskFixEvidence(root) {
8426
8478
  for (const r of candidates) {
8427
8479
  let newEv = setAll;
8428
8480
  if (preserveLink) {
8429
- const m = (r.evidence || '').match(/plan:M-\d{4}/);
8481
+ const m = (r.evidence || '').match(/plan:M-\d{4,}/);
8430
8482
  if (m && !newEv.includes(m[0])) {
8431
8483
  newEv = `${setAll} (${m[0]})`;
8432
8484
  preserved++;
@@ -8742,7 +8794,7 @@ function lazyDetect(root, opts = {}) {
8742
8794
  for (const r of rows) {
8743
8795
  if (!/^(done|completed|verified)$/i.test(r.status || '')) continue;
8744
8796
  const _ev = (r.evidence || '').trim();
8745
- const _trivial = !_ev || /^(user-request|n\/?a|tbd|todo|wip|none|nil|x|-+|\.+|\?+)$/i.test(_ev) || /^plan:M-\d{4}$/i.test(_ev);
8797
+ const _trivial = !_ev || /^(user-request|n\/?a|tbd|todo|wip|none|nil|x|-+|\.+|\?+)$/i.test(_ev) || /^plan:M-\d{4,}$/i.test(_ev);
8746
8798
  if (_trivial) {
8747
8799
  issues++; _warn(`done row without verifiable evidence: ${r.id} (${r.request})`,
8748
8800
  { kind: 'evidence_missing', severity: 'warn', taskId: r.id, request: r.request });
@@ -8826,7 +8878,7 @@ function lazyDetect(root, opts = {}) {
8826
8878
  _withLock(progressPath(root), () => {
8827
8879
  const header = progressHeader(root);
8828
8880
  const rows2 = readProgressRows(root);
8829
- let maxT = 0; const idRe = /\bT-(\d{4})\b/g;
8881
+ let maxT = 0; const idRe = /\bT-(\d{4,})\b/g;
8830
8882
  const scanSrc = (exists(planPath(root)) ? read(planPath(root)) : '') + '\n' + rows2.map(r => r.id).join('\n');
8831
8883
  let mm; while ((mm = idRe.exec(scanSrc))) maxT = Math.max(maxT, Number(mm[1]));
8832
8884
  for (const t of newTodos) {
@@ -8999,7 +9051,7 @@ function handoff(root) {
8999
9051
  const rules = readRules(root);
9000
9052
  const rulesActive = rules.filter(r => r.status === 'active').length;
9001
9053
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
9002
- const milestones = (planText.match(/^### M-\d{4}\./gm) || []).length;
9054
+ const milestones = (planText.match(/^### M-\d{4,}\./gm) || []).length;
9003
9055
  const lessonsCount = _loadLessons(root).length;
9004
9056
  // 1.9.130: archive 카운트 통합
9005
9057
  const archiveCountsH = { decisions: 0, lessons: 0, plan: 0, total: 0 };
@@ -9190,6 +9242,32 @@ function handoff(root) {
9190
9242
  log(JSON.stringify(result, null, 2));
9191
9243
  return;
9192
9244
  }
9245
+ // 1.36.18 (UR-0052 P3-8): 단일 워크스페이스 --compact 단축. 종전엔 --compact 가 섹션 몇 개만 억제하고
9246
+ // 본문(session-handoff/progress/decisions…) 186줄을 전량 출력 → 문서상 "~500자 1줄 요약"인 --compact 가
9247
+ // 단일 경로에서 사실상 무효(멀티 워크스페이스 _handoffWorkspace 에만 compact 존재). MCP handoff·REPL preview·
9248
+ // agent-mode 등 내부 호출자 3곳이 단일 경로에 --compact 를 쓰며 186줄을 받아 preview 첫 3줄이 무의미(헤더/날짜/이름)했음.
9249
+ // subprocess 없이(REPL 8s timeout·MCP 친화) 파일 로더만으로 핵심 신호 압축 후 return — _handoffWorkspace compact 형식과 정합.
9250
+ if (has('--compact')) {
9251
+ const rows = readProgressRows(root);
9252
+ const byStatus = {};
9253
+ for (const r of rows) byStatus[r.status] = (byStatus[r.status] || 0) + 1;
9254
+ const done = byStatus['done'] || 0, wip = byStatus['in-progress'] || 0, blocked = byStatus['blocked'] || 0;
9255
+ const pct = rows.length ? Math.round(done / rows.length * 100) : 0;
9256
+ const decN = _loadDecisions(root).length;
9257
+ const rulesActive = readRules(root).filter(r => r.status === 'active').length;
9258
+ const lessN = _loadLessons(root).length;
9259
+ const planText = exists(planPath(root)) ? read(planPath(root)) : '';
9260
+ const mileN = (planText.match(/^### M-\d{4,}\./gm) || []).length;
9261
+ const openReq = (_loadUserRequests(root).requests || []).filter(r => r.status === 'open' || r.status === 'in-progress').length;
9262
+ const nx = rows.find(r => r.status === 'in-progress') || rows.find(r => r.status === 'planned') || null;
9263
+ const flags = [];
9264
+ if (openReq) flags.push(`📥 미답 ${openReq}`);
9265
+ if (blocked) flags.push(`🚫 blocked ${blocked}`);
9266
+ log(_lineSafe(`leerness compact: ${detectProjectName(root)} · ${done}/${rows.length}(${pct}%) done · WIP ${wip} · T${wip}/D${decN}/R${rulesActive}/P${mileN}/L${lessN}${flags.length ? ' · ' + flags.join(' · ') : ''}`)); // 1.36.18 (codex F4): projectName 개행 주입 차단
9267
+ if (nx) log(_lineSafe(`다음: ${nx.id} [${nx.status}] ${nx.nextAction || nx.request || ''}`).slice(0, 200));
9268
+ log('핵심 규칙: 의존성0 · 한국어주석 · UTF-8noBOM · reuse-map등록 · anti-lazy-work · verify-claim자동검수');
9269
+ return;
9270
+ }
9193
9271
  const sections = [];
9194
9272
  function block(label, p) {
9195
9273
  if (!exists(p)) return;
@@ -9287,7 +9365,7 @@ function handoff(root) {
9287
9365
  const decisions = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
9288
9366
  const rulesActive = readRules(root).filter(r => r.status === 'active').length;
9289
9367
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
9290
- const planMilestones = (planText.match(/^### M-\d{4}\./gm) || []).length;
9368
+ const planMilestones = (planText.match(/^### M-\d{4,}\./gm) || []).length;
9291
9369
  const lessons = _loadLessons(root).length;
9292
9370
  parts.push(`🧠 mem T${inProgressTasks}/D${decisions}/R${rulesActive}/P${planMilestones}/L${lessons}`);
9293
9371
  } catch {}
@@ -10971,7 +11049,10 @@ function verifyClaimCmd(root, taskId, opts = {}) {
10971
11049
  // 1.36.10 (선재 백로그): Windows 백슬래시 경로 지원 — 'src\\inventory.mjs' 가 'inventory.mjs' 로,
10972
11050
  // 'db\\migrations\\001_create.sql' 이 'create.sql' 로 잘려 추출돼 존재 검사가 false-fail 하던 결함.
10973
11051
  // 구분자에 \\ 허용 + 추출 후 / 로 정규화(하류의 git diff 비교·렌즈 매핑은 / 기준).
10974
- const FILE_RE = new RegExp(`(?:[A-Za-z][A-Za-z0-9_-]*[\\/\\\\])?[A-Za-z][\\w./\\\\-]*\\.(?:${FILE_EXTS})\\b`, 'g');
11052
+ // codex 버그헌트 P2: basename 첫 글자를 [A-Za-z] 로만 잡아 '123.js'·'2fa.ts' 같은 숫자시작 실파일이
11053
+ // 추출 안 돼 존재검사가 vacuous-pass → 없는 파일 인용한 done 주장이 통과(gate FN). 첫 글자에 숫자 허용.
11054
+ // (prev-separator 가드가 UNC/드라이브/URL 꼬리 오탐은 계속 차단하므로 FP 억제 유지)
11055
+ const FILE_RE = new RegExp(`(?:[A-Za-z][A-Za-z0-9_-]*[\\/\\\\])?[A-Za-z0-9][\\w./\\\\-]*\\.(?:${FILE_EXTS})\\b`, 'g');
10975
11056
  // 1.36.11 (클린룸 P1-B): 매치 직전 문자가 경로 구분자면 더 긴 경로의 "꼬리"만 잡은 것(UNC \\\\server\\..., 드라이브 X:\\...,
10976
11057
  // 숫자 시작 선두 세그먼트 등) — 잘린 꼬리를 루트-상대 경로로 검증하면 무관한 로컬 파일이 통과(우회성). 그런 매치는 버린다.
10977
11058
  const filePatterns = [];
@@ -13749,9 +13830,9 @@ function _brainstormFor(root, topic) {
13749
13830
  const planFile_brainstorm = planPath(root);
13750
13831
  if (exists(planFile_brainstorm)) {
13751
13832
  const planText = read(planFile_brainstorm);
13752
- const milestoneBlocks = planText.split(/\n(?=### M-\d{4}\.)/);
13833
+ const milestoneBlocks = planText.split(/\n(?=### M-\d{4,}\.)/);
13753
13834
  for (const b of milestoneBlocks) {
13754
- const m = b.match(/^### (M-\d{4})\.[ \t]*(.+?)$/m);
13835
+ const m = b.match(/^### (M-\d{4,})\.[ \t]*(.+?)$/m);
13755
13836
  if (m && matches(b)) {
13756
13837
  const idx = planText.indexOf(b);
13757
13838
  const lineNo = idx >= 0 ? planText.slice(0, idx).split('\n').length : 0;
@@ -14044,9 +14125,9 @@ function brainstormCmd(root, topic) {
14044
14125
  const planFile_b2 = planPath(root);
14045
14126
  if (exists(planFile_b2)) {
14046
14127
  const planText = read(planFile_b2);
14047
- const milestoneBlocks = planText.split(/\n(?=### M-\d{4}\.)/);
14128
+ const milestoneBlocks = planText.split(/\n(?=### M-\d{4,}\.)/);
14048
14129
  for (const b of milestoneBlocks) {
14049
- const m = b.match(/^### (M-\d{4})\.[ \t]*(.+?)$/m);
14130
+ const m = b.match(/^### (M-\d{4,})\.[ \t]*(.+?)$/m);
14050
14131
  if (m && matches(b)) {
14051
14132
  const idx = planText.indexOf(b);
14052
14133
  const lineNo = idx >= 0 ? planText.slice(0, idx).split('\n').length : 0;
@@ -14158,7 +14239,7 @@ function _roadmapData(root) {
14158
14239
  const milestones = _roadmapParseMilestones(exists(planPath(root)) ? read(planPath(root)) : '');
14159
14240
  const tasks = readProgressRows(root).map(t => ({
14160
14241
  ...t,
14161
- milestones: Array.from(String(t.evidence || '').matchAll(/M-\d{4}/g)).map(m => m[0])
14242
+ milestones: Array.from(String(t.evidence || '').matchAll(/M-\d{4,}/g)).map(m => m[0])
14162
14243
  }));
14163
14244
  // skills
14164
14245
  const skills = [];
@@ -14174,7 +14255,7 @@ function _roadmapData(root) {
14174
14255
  const rulesT = exists(rulesPath(root)) ? read(rulesPath(root)) : '';
14175
14256
  const rules = [];
14176
14257
  for (const line of rulesT.split('\n')) {
14177
- if (!/^\| R-\d{4} \|/.test(line)) continue;
14258
+ if (!/^\| R-\d{4,} \|/.test(line)) continue;
14178
14259
  const cells = line.split('|').slice(1, -1).map(s => s.trim());
14179
14260
  if (cells.length < 6) continue;
14180
14261
  rules.push({ id: cells[0], trigger: cells[1], rule: cells[2], status: cells[4], lastVerified: cells[5] });
@@ -14468,7 +14549,7 @@ function readRules(root) {
14468
14549
  if (!exists(f)) return [];
14469
14550
  const rules = [];
14470
14551
  for (const line of read(f).split('\n')) {
14471
- if (!/^\| R-\d{4} \|/.test(line)) continue;
14552
+ if (!/^\| R-\d{4,} \|/.test(line)) continue;
14472
14553
  // 1.9.399 (7번째 버그헌트 P1-A, UR-0104): 비이스케이프 파이프 분리 + 복원 — rule 텍스트의 '|'가 컬럼 밀림/멱등성 무력화를 못 일으킴.
14473
14554
  const cells = line.split(/(?<!\\)\|/).slice(1, -1).map(s => _cellUnescape(s).trim());
14474
14555
  if (cells.length < 6) continue;
@@ -14487,7 +14568,7 @@ function nextRuleId(root) {
14487
14568
  const rules = readRules(root);
14488
14569
  let max = 0;
14489
14570
  for (const r of rules) {
14490
- const m = r.id.match(/^R-(\d{4})$/);
14571
+ const m = r.id.match(/^R-(\d{4,})$/);
14491
14572
  if (m) max = Math.max(max, Number(m[1]));
14492
14573
  }
14493
14574
  // 1.11.3 (14th 버그헌트 P2, UR-0180): 아카이브의 R-id 도 카운트 — 기존엔 활성 rules 만 스캔해 rule remove 후 같은 R-id 가 다른 룰에 재사용됐음(아카이브 ID 충돌).
@@ -14547,7 +14628,7 @@ function ruleRemove(root, id) {
14547
14628
  if (!id) return fail('id required');
14548
14629
  const rules = readRules(root);
14549
14630
  const i = rules.findIndex(r => r.id === id);
14550
- if (i < 0) return fail(`rule not found: ${id}`);
14631
+ if (i < 0) return failJson(has('--json'), 'rule_not_found', `rule not found: ${id}`); // codex P2: --json 에러 구조화
14551
14632
  const removed = rules.splice(i, 1)[0];
14552
14633
  writeRules(root, rules);
14553
14634
  const archive = exists(rulesArchivePath(root)) ? read(rulesArchivePath(root)) : '# Rules archive\n\n| ID | Trigger | Rule | Added | Status | Removed |\n|---|---|---|---|---|---|\n';
@@ -20114,6 +20195,7 @@ function contractVerifyCmd(specPath, implPath) {
20114
20195
  // 대신 정적 소스 분석 — module.exports = { foo, bar } / exports.foo = ... / module.exports.foo = ... 패턴 grep.
20115
20196
  const implSrc = read(implFile);
20116
20197
  // 1.9.429 (UR-0129): 브레이스 균형 top-level 키(멀티라인 module.exports 안전) + ESM export 인식 — pure-utils 단일출처
20198
+ // 1.36.18 (UR-0052 P2-6 이연): 주석 마스킹은 7라운드 codex 재검 결과 파서 없이 안전 구현 불가로 판단해 미적용(하단 참조).
20117
20199
  const implExports = new Set(_parseImplExports(implSrc));
20118
20200
  // 검사: spec 강선언(function 시그니처 + markdown bullet) 함수 중 impl exports에 없는 것.
20119
20201
  // 1.9.385 (UR-0086): 기존 `specText.includes('function '+fn)` 가드는 bullet/backtick 추출명을 무력화하던 잠재 FN.
package/lib/analyzers.js CHANGED
@@ -36,7 +36,8 @@ function _shellGuardAnalyze(cmd, ctx) {
36
36
  }
37
37
  function _evidenceQuality(evidence) {
38
38
  const e = String(evidence || '');
39
- const hasFile = /(?:[A-Za-z][\w-]*[\/\\])?[A-Za-z][\w./\\-]*\.(?:js|ts|tsx|jsx|mjs|cjs|py|go|rs|rb|kt|cs|gd|java|php|swift|c|cpp|h|html|css|scss|vue|svelte|json|yaml|yml|toml|md|sql|prisma|sh)\b/i.test(e);
39
+ // codex 버그헌트 P2: basename 첫 글자에 숫자 허용 ('123.js' 등 숫자시작 실파일) — bin FILE_RE 와 정합.
40
+ const hasFile = /(?:[A-Za-z][\w-]*[\/\\])?[A-Za-z0-9][\w./\\-]*\.(?:js|ts|tsx|jsx|mjs|cjs|py|go|rs|rb|kt|cs|gd|java|php|swift|c|cpp|h|html|css|scss|vue|svelte|json|yaml|yml|toml|md|sql|prisma|sh)\b/i.test(e);
40
41
  const hasTest = /(\d+)\s*(?:\/\s*\d+\s*)?(?:통과|passed|passing|개\s*테스트)|\btests?\b\s*[:=]?\s*\d|Tests?:\s*\d|\b\d+\s*tests?\b/i.test(e);
41
42
  const hasLog = /Exit\s*[:=]|exit\s*code|Command\s*[:=]|npm\s+(?:test|run)|pytest|cargo\s+test|go\s+test/i.test(e);
42
43
  const missing = [];
package/lib/audit.js CHANGED
@@ -62,11 +62,11 @@ function audit(root, opts = {}, deps = {}) {
62
62
  else if (reuseLines === 0) { warnings++; warn('reuse-map.md is empty (consider populating known reusable elements)'); _finding('reuse_map_empty', 'warn', 'reuse-map.md is empty'); }
63
63
  else ok(`reuse-map.md has ${reuseLines} entries`);
64
64
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
65
- const milestoneIds = Array.from(planText.matchAll(/^### (M-\d{4})\./gm)).map(m => m[1]);
65
+ const milestoneIds = Array.from(planText.matchAll(/^### (M-\d{4,})\./gm)).map(m => m[1]);
66
66
  const rows = readProgressRows(root);
67
67
  // 1.9.6 수정: 한 row에 여러 plan:M-XXXX 링크가 있어도 모두 인식 (matchAll로 전부 추출)
68
68
  const linkedMs = new Set(
69
- rows.flatMap(r => Array.from(String(r.evidence || '').matchAll(/M-\d{4}/g), m => m[0]))
69
+ rows.flatMap(r => Array.from(String(r.evidence || '').matchAll(/M-\d{4,}/g), m => m[0]))
70
70
  );
71
71
  const missingFromProgress = milestoneIds.filter(m => !linkedMs.has(m));
72
72
  if (missingFromProgress.length) {
package/lib/feature.js CHANGED
@@ -41,7 +41,7 @@ function featureAddCmd(root, title, deps = {}) {
41
41
  function featureLinkCmd(root, fromId, deps = {}) {
42
42
  const { _readFeatureGraph, _writeFeatureGraph, arg } = deps;
43
43
  root = absRoot(root);
44
- if (!fromId || !/^F-\d{4}$/.test(fromId)) return fail('feature link: 첫 인자는 F-XXXX 형식 ID');
44
+ if (!fromId || !/^F-\d{4,}$/.test(fromId)) return fail('feature link: 첫 인자는 F-XXXX 형식 ID');
45
45
  const { nodes } = _readFeatureGraph(root);
46
46
  const node = nodes.find(n => n.id === fromId);
47
47
  if (!node) return fail(`feature ${fromId} 없음 — feature add 먼저`);
@@ -60,7 +60,7 @@ function featureImpactCmd(root, fromId, deps = {}) {
60
60
  const { _readFeatureGraph, has } = deps;
61
61
  root = absRoot(root);
62
62
  const _j = has('--json'); // 1.9.398 (UR-0099): --json 에러 구조화
63
- if (!fromId || !/^F-\d{4}$/.test(fromId)) return failJson(_j, 'bad_id', 'feature impact: F-XXXX ID 필요');
63
+ if (!fromId || !/^F-\d{4,}$/.test(fromId)) return failJson(_j, 'bad_id', 'feature impact: F-XXXX ID 필요');
64
64
  const { nodes } = _readFeatureGraph(root);
65
65
  const node = nodes.find(n => n.id === fromId);
66
66
  if (!node) return failJson(_j, 'not_found', `feature ${fromId} 없음`);
@@ -103,7 +103,7 @@ function featureShowCmd(root, fromId, deps = {}) {
103
103
  const { _readFeatureGraph, has } = deps;
104
104
  root = absRoot(root);
105
105
  const _j = has('--json'); // 1.9.398 (UR-0099): --json 에러 구조화
106
- if (!fromId || !/^F-\d{4}$/.test(fromId)) return failJson(_j, 'bad_id', 'feature show: F-XXXX ID 필요');
106
+ if (!fromId || !/^F-\d{4,}$/.test(fromId)) return failJson(_j, 'bad_id', 'feature show: F-XXXX ID 필요');
107
107
  const { nodes } = _readFeatureGraph(root);
108
108
  const node = nodes.find(n => n.id === fromId);
109
109
  if (!node) return failJson(_j, 'not_found', `feature ${fromId} 없음`);
package/lib/health.js CHANGED
@@ -95,7 +95,7 @@ function healthCmd(root, deps = {}) {
95
95
  const rules = readRules(root);
96
96
  const rulesActive = rules.filter(r => r.status === 'active').length;
97
97
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
98
- const milestones = (planText.match(/^### M-\d{4}\./gm) || []).length;
98
+ const milestones = (planText.match(/^### M-\d{4,}\./gm) || []).length;
99
99
  const lessonsCount = _loadLessons(root).length;
100
100
  out.memorySurface = {
101
101
  tasks: { inProgress: tasksInProgress, total: rows.length, byStatus: tasksByStatus },
package/lib/pure-utils.js CHANGED
@@ -322,7 +322,7 @@ function _roadmapParseMilestones(text) {
322
322
  const s = String(text || '');
323
323
  const out = [];
324
324
  // 1.9.352 (UR-0068 외부리뷰): 다음 milestone 직전까지 block 한정 — 이전 구현은 slice(m.index) 로 다음 milestone 의 Status/Progress 를 누출했음
325
- const matches = [...s.matchAll(/^### (M-\d{4})\.[ \t]*(.+?)$/gm)]; // 17th 버그헌트 P2: \s* 가 개행 흡수해 빈 제목 milestone 이 다음 줄(Status:)을 제목으로 먹던 것 차단
325
+ const matches = [...s.matchAll(/^### (M-\d{4,})\.[ \t]*(.+?)$/gm)]; // 17th 버그헌트 P2: \s* 가 개행 흡수해 빈 제목 milestone 이 다음 줄(Status:)을 제목으로 먹던 것 차단
326
326
  for (let i = 0; i < matches.length; i++) {
327
327
  const m = matches[i];
328
328
  const end = i + 1 < matches.length ? matches[i + 1].index : s.length;
@@ -933,6 +933,7 @@ function _renderPulseLine(data) {
933
933
  return line;
934
934
  }
935
935
 
936
+
936
937
  // 1.9.429 (10th 외부평가 UR-0129): impl 소스에서 export 식별자 추출.
937
938
  // 브레이스 균형으로 module.exports={...} 의 top-level 키만(함수 본문/중첩객체 안전 — 멀티라인 첫키만 버그 수정)
938
939
  // + exports.foo + ESM(export function/const/let/var/class, export {a, b as c}) 인식.
@@ -1251,7 +1252,7 @@ function _featureGraphTemplate() {
1251
1252
  function _parseFeatureGraph(text) {
1252
1253
  if (!text) return [];
1253
1254
  const nodes = [];
1254
- const re = /^## (F-\d{4})\s+(.+?)\s*$/gm;
1255
+ const re = /^## (F-\d{4,})\s+(.+?)\s*$/gm;
1255
1256
  const positions = [];
1256
1257
  let m;
1257
1258
  while ((m = re.exec(text)) !== null) positions.push({ id: m[1], title: m[2], start: m.index });
@@ -33,7 +33,7 @@ function sessionClose(root, opts = {}, deps = {}) {
33
33
  jsonResult.recommendedDirection = (buckets['in-progress'][0]?.request) || (buckets['planned'][0]?.request) || (buckets['requested'][0]?.request) || null;
34
34
  jsonResult.nextExactStep = (buckets['in-progress'][0]?.nextAction) || (buckets['planned'][0]?.nextAction) || (buckets['requested'][0]?.nextAction) || null;
35
35
  // 1.12.3 (14th 버그헌트 P3, UR-0183): 마감 시 완료 정직성 advisory — done 인데 evidence 가 비었거나 placeholder 인 task 노출(차단 X, 정직성 환기). lazy detect 의 done_no_evidence 휴리스틱과 동일.
36
- const _doneNoEvidence = (buckets['done'] || []).filter(r => !r.evidence || /^(\s*|user-request|-)$/.test(r.evidence) || /^plan:M-\d{4}\s*$/.test(r.evidence));
36
+ const _doneNoEvidence = (buckets['done'] || []).filter(r => !r.evidence || /^(\s*|user-request|-)$/.test(r.evidence) || /^plan:M-\d{4,}\s*$/.test(r.evidence));
37
37
  jsonResult.completionHonesty = { doneTotal: (buckets['done'] || []).length, doneWithoutEvidence: _doneNoEvidence.length, ids: _doneNoEvidence.slice(0, 5).map(r => r.id) };
38
38
  if (_doneNoEvidence.length) log(t(` ⚠ 완료 정직성: done ${_doneNoEvidence.length}건 evidence 없음/placeholder (${_doneNoEvidence.slice(0, 3).map(r => r.id).join(', ')}) — verify-claim 권장 (advisory)`, ` ⚠ completion honesty: ${_doneNoEvidence.length} done with no/placeholder evidence (${_doneNoEvidence.slice(0, 3).map(r => r.id).join(', ')}) — verify-claim recommended (advisory)`));
39
39
  // 1.17.6 (UR-0049 마감 정합): done 의 미해소 낙관 의심 재확인 — verify-claim 을 건너뛴 거짓 주장(evidence 에 API/DB 주장 있는데 코드 흔적 없음)이
@@ -283,7 +283,7 @@ function sessionClose(root, opts = {}, deps = {}) {
283
283
  const rules0 = readRules(root);
284
284
  const rulesActive0 = rules0.filter(r => r.status === 'active').length;
285
285
  const planText0 = exists(planPath(root)) ? read(planPath(root)) : '';
286
- const milestones0 = (planText0.match(/^### M-\d{4}\./gm) || []).length;
286
+ const milestones0 = (planText0.match(/^### M-\d{4,}\./gm) || []).length;
287
287
  const lessonsCount0 = _loadLessons(root).length;
288
288
  // 1.9.130: archive 카운트 통합
289
289
  const archiveCountsS = { decisions: 0, lessons: 0, plan: 0, total: 0 };
@@ -620,7 +620,7 @@ function sessionClose(root, opts = {}, deps = {}) {
620
620
  const dCnt = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
621
621
  const rActive = readRules(root).filter(r => r.status === 'active').length;
622
622
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
623
- const pCnt = (planText.match(/^### M-\d{4}\./gm) || []).length;
623
+ const pCnt = (planText.match(/^### M-\d{4,}\./gm) || []).length;
624
624
  const lCnt = _loadLessons(root).length;
625
625
  const mem = `T${tIn}/D${dCnt}/R${rActive}/P${pCnt}/L${lCnt}`;
626
626
  let pulseLine = `📍 v${VERSION} · 🔄 R${rh.roundCount} · 🧠 ${mem}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.16",
3
+ "version": "1.36.18",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",