leerness 1.36.17 → 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,14 @@
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
+
3
12
  ## 1.36.17 — 2026-07-14 — 렌즈-외 표면 버그헌트: 데이터손실 2 + gate FN + 아카이브 주입 + --json 에러경로 (codex, 재현확정)
4
13
 
5
14
  게시본 1.36.16 대상, database 렌즈를 제외한 CLI 표면을 codex로 광범위 적대 헌트 → 8건 중 6건 수정(고영향 재현확정), 2건 백로그(UR-0052).
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.17 하네스를 사용합니다. 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.17는 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.17는 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.17)** · 매 라운드 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.17: 2026-07-14
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.17';
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
  } },
@@ -4801,7 +4822,7 @@ function pulseCmd(root) {
4801
4822
  const decisionCount = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
4802
4823
  const rulesActive = readRules(root).filter(r => r.status === 'active').length;
4803
4824
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
4804
- const milestonesCnt = (planText.match(/^### M-\d{4}\./gm) || []).length;
4825
+ const milestonesCnt = (planText.match(/^### M-\d{4,}\./gm) || []).length;
4805
4826
  const lessonsCount = _loadLessons(root).length;
4806
4827
  data.memorySurface = _memorySurface({ tasks: tasksInProgress, decisions: decisionCount, rules: rulesActive, milestones: milestonesCnt, lessons: lessonsCount });
4807
4828
  } catch {}
@@ -7279,7 +7300,7 @@ async function nextActionCmd(root, sub, ...rest) {
7279
7300
  // leerness task add 호출
7280
7301
  const taskResult = cp.spawnSync(process.execPath, [__filename, 'task', 'add', taskTitle, '--path', root], { encoding: 'utf8', timeout: 8000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
7281
7302
  if (taskResult.status === 0) {
7282
- const m = (taskResult.stdout || '').match(/T-\d{4}/);
7303
+ const m = (taskResult.stdout || '').match(/T-\d{4,}/);
7283
7304
  log(` ✓ task 추가: ${m ? m[0] : '?'} — "${taskTitle}"`);
7284
7305
  if (action.command) log(` 💡 실행 명령: ${action.command}`);
7285
7306
  } else {
@@ -7338,9 +7359,9 @@ function _suggestNextActions(root, latestRow, keyword) {
7338
7359
  try {
7339
7360
  if (fuzzyRe && exists(planPath(root))) {
7340
7361
  const pt = read(planPath(root));
7341
- 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));
7342
7363
  if (milestones.length > 0) {
7343
- const m = milestones[0].match(/M-\d{4}/);
7364
+ const m = milestones[0].match(/M-\d{4,}/);
7344
7365
  if (m) actions.push({ icon: '🎯', title: `plan.md milestone ${m[0]} 검증 — "${keyword}" 관련`, command: `leerness plan list --filter "${keyword}"` });
7345
7366
  }
7346
7367
  }
@@ -7573,7 +7594,7 @@ function readProgressRows(root) {
7573
7594
  const text = exists(progressPath(root)) ? read(progressPath(root)) : '';
7574
7595
  const rows = [];
7575
7596
  for (const line of text.split('\n')) {
7576
- if (!/^\| (?:T|M|D)-\d{4} \|/.test(line)) continue;
7597
+ if (!/^\| (?:T|M|D)-\d{4,} \|/.test(line)) continue;
7577
7598
  // 1.9.399 (7번째 버그헌트 P1-A, UR-0104): 비이스케이프 파이프에서만 분리 + 셀 복원 — 사용자 텍스트의 '|'(이스케이프됨)이 컬럼을 깨지 않음.
7578
7599
  const cells = line.split(/(?<!\\)\|/).slice(1, -1).map(s => _cellUnescape(s).trim());
7579
7600
  if (cells.length < 6) continue;
@@ -7614,7 +7635,7 @@ function upsertProgress(root, row) {
7614
7635
  });
7615
7636
  }
7616
7637
 
7617
- 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 구조화
7618
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'); }
7619
7640
  // 1.9.119: plan list — plan.md 의 모든 milestone (M-XXXX) 조회 (CLI + --json + MCP)
7620
7641
  function planListCmd(root, opts = {}) {
@@ -7631,9 +7652,9 @@ function planListCmd(root, opts = {}) {
7631
7652
  const text = read(pp);
7632
7653
  const milestones = [];
7633
7654
  // ### M-XXXX. <title> 블록 추출
7634
- const blocks = text.split(/\n(?=### M-\d{4}\.)/);
7655
+ const blocks = text.split(/\n(?=### M-\d{4,}\.)/);
7635
7656
  for (const b of blocks) {
7636
- const headerMatch = b.match(/^### (M-\d{4})\.[ \t]*(.+?)$/m);
7657
+ const headerMatch = b.match(/^### (M-\d{4,})\.[ \t]*(.+?)$/m);
7637
7658
  if (!headerMatch) continue;
7638
7659
  const id = headerMatch[1];
7639
7660
  const title = headerMatch[2].trim();
@@ -7985,7 +8006,7 @@ function memoryStatusCmd(root, opts = {}) {
7985
8006
  const rulesPaused = rules.filter(r => r.status === 'paused').length;
7986
8007
  // Plan
7987
8008
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
7988
- const milestones = (planText.match(/^### M-\d{4}\./gm) || []).length;
8009
+ const milestones = (planText.match(/^### M-\d{4,}\./gm) || []).length;
7989
8010
  // plan milestones in-progress: progress-tracker에서 plan:M-XXXX evidence 있고 in-progress 인 row
7990
8011
  const planInProgress = rows.filter(r => /plan:M-/.test(r.evidence || '') && r.status === 'in-progress').length;
7991
8012
  // Lessons (UR-0058: canonical _loadLessons — JSON 단일 진실소스, MD projection fallback)
@@ -8394,10 +8415,10 @@ function _jaccard(a, b) {
8394
8415
  function taskRelink(root) {
8395
8416
  root = absRoot(root);
8396
8417
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
8397
- const milestones = [...planText.matchAll(/^### (M-\d{4})\.[ \t]*(.+?)$/gm)]
8418
+ const milestones = [...planText.matchAll(/^### (M-\d{4,})\.[ \t]*(.+?)$/gm)]
8398
8419
  .map(m => ({ id: m[1], text: m[2].trim() }));
8399
8420
  const rows = readProgressRows(root);
8400
- 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));
8401
8422
  const orphanM = milestones.filter(m => !linkedM.has(m.id));
8402
8423
  if (!orphanM.length) return ok('미연결 milestone 없음');
8403
8424
 
@@ -8445,7 +8466,7 @@ function taskFixEvidence(root) {
8445
8466
  !r.evidence ||
8446
8467
  /^\s*$/.test(r.evidence) ||
8447
8468
  /^(user-request|-)$/.test(r.evidence) ||
8448
- /^plan:M-\d{4}\s*$/.test(r.evidence)
8469
+ /^plan:M-\d{4,}\s*$/.test(r.evidence)
8449
8470
  )
8450
8471
  );
8451
8472
  if (!candidates.length) return ok('갱신 후보 없음 (모든 done row가 검증 키워드 보유)');
@@ -8457,7 +8478,7 @@ function taskFixEvidence(root) {
8457
8478
  for (const r of candidates) {
8458
8479
  let newEv = setAll;
8459
8480
  if (preserveLink) {
8460
- const m = (r.evidence || '').match(/plan:M-\d{4}/);
8481
+ const m = (r.evidence || '').match(/plan:M-\d{4,}/);
8461
8482
  if (m && !newEv.includes(m[0])) {
8462
8483
  newEv = `${setAll} (${m[0]})`;
8463
8484
  preserved++;
@@ -8773,7 +8794,7 @@ function lazyDetect(root, opts = {}) {
8773
8794
  for (const r of rows) {
8774
8795
  if (!/^(done|completed|verified)$/i.test(r.status || '')) continue;
8775
8796
  const _ev = (r.evidence || '').trim();
8776
- 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);
8777
8798
  if (_trivial) {
8778
8799
  issues++; _warn(`done row without verifiable evidence: ${r.id} (${r.request})`,
8779
8800
  { kind: 'evidence_missing', severity: 'warn', taskId: r.id, request: r.request });
@@ -8857,7 +8878,7 @@ function lazyDetect(root, opts = {}) {
8857
8878
  _withLock(progressPath(root), () => {
8858
8879
  const header = progressHeader(root);
8859
8880
  const rows2 = readProgressRows(root);
8860
- let maxT = 0; const idRe = /\bT-(\d{4})\b/g;
8881
+ let maxT = 0; const idRe = /\bT-(\d{4,})\b/g;
8861
8882
  const scanSrc = (exists(planPath(root)) ? read(planPath(root)) : '') + '\n' + rows2.map(r => r.id).join('\n');
8862
8883
  let mm; while ((mm = idRe.exec(scanSrc))) maxT = Math.max(maxT, Number(mm[1]));
8863
8884
  for (const t of newTodos) {
@@ -9030,7 +9051,7 @@ function handoff(root) {
9030
9051
  const rules = readRules(root);
9031
9052
  const rulesActive = rules.filter(r => r.status === 'active').length;
9032
9053
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
9033
- const milestones = (planText.match(/^### M-\d{4}\./gm) || []).length;
9054
+ const milestones = (planText.match(/^### M-\d{4,}\./gm) || []).length;
9034
9055
  const lessonsCount = _loadLessons(root).length;
9035
9056
  // 1.9.130: archive 카운트 통합
9036
9057
  const archiveCountsH = { decisions: 0, lessons: 0, plan: 0, total: 0 };
@@ -9221,6 +9242,32 @@ function handoff(root) {
9221
9242
  log(JSON.stringify(result, null, 2));
9222
9243
  return;
9223
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
+ }
9224
9271
  const sections = [];
9225
9272
  function block(label, p) {
9226
9273
  if (!exists(p)) return;
@@ -9318,7 +9365,7 @@ function handoff(root) {
9318
9365
  const decisions = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
9319
9366
  const rulesActive = readRules(root).filter(r => r.status === 'active').length;
9320
9367
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
9321
- const planMilestones = (planText.match(/^### M-\d{4}\./gm) || []).length;
9368
+ const planMilestones = (planText.match(/^### M-\d{4,}\./gm) || []).length;
9322
9369
  const lessons = _loadLessons(root).length;
9323
9370
  parts.push(`🧠 mem T${inProgressTasks}/D${decisions}/R${rulesActive}/P${planMilestones}/L${lessons}`);
9324
9371
  } catch {}
@@ -13783,9 +13830,9 @@ function _brainstormFor(root, topic) {
13783
13830
  const planFile_brainstorm = planPath(root);
13784
13831
  if (exists(planFile_brainstorm)) {
13785
13832
  const planText = read(planFile_brainstorm);
13786
- const milestoneBlocks = planText.split(/\n(?=### M-\d{4}\.)/);
13833
+ const milestoneBlocks = planText.split(/\n(?=### M-\d{4,}\.)/);
13787
13834
  for (const b of milestoneBlocks) {
13788
- const m = b.match(/^### (M-\d{4})\.[ \t]*(.+?)$/m);
13835
+ const m = b.match(/^### (M-\d{4,})\.[ \t]*(.+?)$/m);
13789
13836
  if (m && matches(b)) {
13790
13837
  const idx = planText.indexOf(b);
13791
13838
  const lineNo = idx >= 0 ? planText.slice(0, idx).split('\n').length : 0;
@@ -14078,9 +14125,9 @@ function brainstormCmd(root, topic) {
14078
14125
  const planFile_b2 = planPath(root);
14079
14126
  if (exists(planFile_b2)) {
14080
14127
  const planText = read(planFile_b2);
14081
- const milestoneBlocks = planText.split(/\n(?=### M-\d{4}\.)/);
14128
+ const milestoneBlocks = planText.split(/\n(?=### M-\d{4,}\.)/);
14082
14129
  for (const b of milestoneBlocks) {
14083
- const m = b.match(/^### (M-\d{4})\.[ \t]*(.+?)$/m);
14130
+ const m = b.match(/^### (M-\d{4,})\.[ \t]*(.+?)$/m);
14084
14131
  if (m && matches(b)) {
14085
14132
  const idx = planText.indexOf(b);
14086
14133
  const lineNo = idx >= 0 ? planText.slice(0, idx).split('\n').length : 0;
@@ -14192,7 +14239,7 @@ function _roadmapData(root) {
14192
14239
  const milestones = _roadmapParseMilestones(exists(planPath(root)) ? read(planPath(root)) : '');
14193
14240
  const tasks = readProgressRows(root).map(t => ({
14194
14241
  ...t,
14195
- 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])
14196
14243
  }));
14197
14244
  // skills
14198
14245
  const skills = [];
@@ -14208,7 +14255,7 @@ function _roadmapData(root) {
14208
14255
  const rulesT = exists(rulesPath(root)) ? read(rulesPath(root)) : '';
14209
14256
  const rules = [];
14210
14257
  for (const line of rulesT.split('\n')) {
14211
- if (!/^\| R-\d{4} \|/.test(line)) continue;
14258
+ if (!/^\| R-\d{4,} \|/.test(line)) continue;
14212
14259
  const cells = line.split('|').slice(1, -1).map(s => s.trim());
14213
14260
  if (cells.length < 6) continue;
14214
14261
  rules.push({ id: cells[0], trigger: cells[1], rule: cells[2], status: cells[4], lastVerified: cells[5] });
@@ -14502,7 +14549,7 @@ function readRules(root) {
14502
14549
  if (!exists(f)) return [];
14503
14550
  const rules = [];
14504
14551
  for (const line of read(f).split('\n')) {
14505
- if (!/^\| R-\d{4} \|/.test(line)) continue;
14552
+ if (!/^\| R-\d{4,} \|/.test(line)) continue;
14506
14553
  // 1.9.399 (7번째 버그헌트 P1-A, UR-0104): 비이스케이프 파이프 분리 + 복원 — rule 텍스트의 '|'가 컬럼 밀림/멱등성 무력화를 못 일으킴.
14507
14554
  const cells = line.split(/(?<!\\)\|/).slice(1, -1).map(s => _cellUnescape(s).trim());
14508
14555
  if (cells.length < 6) continue;
@@ -14521,7 +14568,7 @@ function nextRuleId(root) {
14521
14568
  const rules = readRules(root);
14522
14569
  let max = 0;
14523
14570
  for (const r of rules) {
14524
- const m = r.id.match(/^R-(\d{4})$/);
14571
+ const m = r.id.match(/^R-(\d{4,})$/);
14525
14572
  if (m) max = Math.max(max, Number(m[1]));
14526
14573
  }
14527
14574
  // 1.11.3 (14th 버그헌트 P2, UR-0180): 아카이브의 R-id 도 카운트 — 기존엔 활성 rules 만 스캔해 rule remove 후 같은 R-id 가 다른 룰에 재사용됐음(아카이브 ID 충돌).
@@ -20148,6 +20195,7 @@ function contractVerifyCmd(specPath, implPath) {
20148
20195
  // 대신 정적 소스 분석 — module.exports = { foo, bar } / exports.foo = ... / module.exports.foo = ... 패턴 grep.
20149
20196
  const implSrc = read(implFile);
20150
20197
  // 1.9.429 (UR-0129): 브레이스 균형 top-level 키(멀티라인 module.exports 안전) + ESM export 인식 — pure-utils 단일출처
20198
+ // 1.36.18 (UR-0052 P2-6 이연): 주석 마스킹은 7라운드 codex 재검 결과 파서 없이 안전 구현 불가로 판단해 미적용(하단 참조).
20151
20199
  const implExports = new Set(_parseImplExports(implSrc));
20152
20200
  // 검사: spec 강선언(function 시그니처 + markdown bullet) 함수 중 impl exports에 없는 것.
20153
20201
  // 1.9.385 (UR-0086): 기존 `specText.includes('function '+fn)` 가드는 bullet/backtick 추출명을 무력화하던 잠재 FN.
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.17",
3
+ "version": "1.36.18",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",