leerness 1.9.387 → 1.9.388

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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.388 — 2026-06-06 — UR-0025 큰 핸들러 모듈화: migrate audit/apply/plan → lib/migrate.js (DI)
4
+
5
+ **🧩 첫 실제 핸들러 모듈 추출 — migrate 서브시스템(audit/apply/plan)을 lib/migrate.js 로 분리(의존성 주입 ctx). lib/io.js 토대(1.9.382/383)의 첫 활용.**
6
+
7
+ ### 배경 (UR-0025 큰 핸들러 모듈화, 사용자 승인)
8
+ 1.9.382/383 에서 lib/io.js(출력+fs 프리미티브 14종)를 만든 목적은 "핸들러를 별도 lib 모듈로 분리할 토대". 이번에 그 토대로 **첫 실제 핸들러**(migrate UR-0075, ~100줄, 응집적·e2e 3건 보유)를 추출.
9
+
10
+ ### 구현
11
+ 1. **lib/migrate.js 신규**: `migrateAuditCmd` / `migrateApplyCmd` / `migratePlanCmd` 이전.
12
+ - I/O 프리미티브: `require('./io')`(absRoot/exists/read/log/ok/warn).
13
+ - harness 고유 의존은 **deps 객체로 주입(DI)**: VERSION · compareVer · REQUIRED_WORKSPACE_FILES · canonical 메모리 함수 8종(decisionsPath/…/_save*) · harnessPath(plan 의 임시폴더 init spawn 용).
14
+ 2. **harness thin wrapper**: `_migrateDeps()` 가 deps 1회 구성 → 3개 wrapper 가 위임. **호출부(dispatch)·동작·출력 무변경**.
15
+ 3. 새 패턴 확립: 향후 다른 핸들러도 이 DI 방식으로 lib 모듈화 가능(io import + deps 주입).
16
+
17
+ ### 검증 (회귀 0)
18
+ - **selftest 133→134 PASS** (lib/migrate 3 exports + harness 위임 와이어 + lib 본문 이동 확인 + behavioral audit JSON). 1.9.380 케이스는 REQUIRED_WORKSPACE_FILES 소비처가 harness+lib 로 분산됨에 맞춰 교차참조 카운트로 갱신.
19
+ - **E2E 332 유지 PASS** (기존 migrate B(1.9.356/357/358) audit/apply/plan CLI 회귀가 CLI→wrapper→lib 경로를 그대로 검증 — 신규 케이스 불필요). 락 flake 시 재실행.
20
+ - 실측: audit(정합 willChange:0 / canonical-pending 감지) · apply --yes(decisions.json 백필 복원) · plan(tempInstallOk:true) · 사람용 출력 보존.
21
+
3
22
  ## 1.9.387 — 2026-06-06 — --json 일관성 연장: incident/runs list 빈 케이스 구조화 (UR-0088)
4
23
 
5
24
  **🔌 `incident list` / `runs list` 의 빈 상태도 `--json` 시 `{total:0,items:[]}` 출력 — AI 에이전트가 빈 프로젝트에서 사람용 텍스트를 파싱하다 실패하던 일관성 결함 해소.**
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > **AI 코딩 에이전트의 거짓 완료·중복·망각·충돌을 막아주는 검수·기억·협업 CLI 하네스.**
4
4
  > **A CLI harness that stops AI coding agents from faking completion, duplicating work, forgetting context, and colliding.**
5
5
 
6
- [![npm](https://img.shields.io/badge/npm-leerness-blue)](https://www.npmjs.com/package/leerness) [![version](https://img.shields.io/badge/version-1.9.387-green)]() [![tests](https://img.shields.io/badge/e2e-332%2F332-success)]() [![selftest](https://img.shields.io/badge/selftest-133-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-85-brightgreen)]() [![providers](https://img.shields.io/badge/AI_providers-10-brightgreen)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
6
+ [![npm](https://img.shields.io/badge/npm-leerness-blue)](https://www.npmjs.com/package/leerness) [![version](https://img.shields.io/badge/version-1.9.388-green)]() [![tests](https://img.shields.io/badge/e2e-332%2F332-success)]() [![selftest](https://img.shields.io/badge/selftest-134-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-85-brightgreen)]() [![providers](https://img.shields.io/badge/AI_providers-10-brightgreen)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
7
7
 
8
8
  ```
9
9
  ╔══════════════════════════════════════════════════════════════╗
@@ -471,7 +471,7 @@ MIT — © leerness contributors
471
471
  <!-- leerness:project-readme:start -->
472
472
  ## Leerness Project Harness
473
473
 
474
- 이 프로젝트는 Leerness v1.9.387 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
474
+ 이 프로젝트는 Leerness v1.9.388 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
475
475
 
476
476
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
477
477
 
@@ -525,7 +525,7 @@ leerness memory restore decision <date|title>
525
525
 
526
526
  ### MCP server (외부 AI 통합)
527
527
 
528
- Leerness v1.9.387는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **85개 도구**를 노출:
528
+ Leerness v1.9.388는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **85개 도구**를 노출:
529
529
 
530
530
  ```jsonc
531
531
  // 카테고리별
@@ -546,7 +546,7 @@ Leerness v1.9.387는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
546
546
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
547
547
  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) 다음 라운드 예약.
548
548
 
549
- 현재 누적: **70 라운드 (1.9.40 → 1.9.387)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
549
+ 현재 누적: **70 라운드 (1.9.40 → 1.9.388)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
550
550
 
551
551
  ### 성능 가이드 (1.9.140 측정)
552
552
 
@@ -584,6 +584,6 @@ leerness release pack --close --auto-main-push
584
584
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
585
585
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
586
586
 
587
- Last synced by Leerness v1.9.387: 2026-06-06
587
+ Last synced by Leerness v1.9.388: 2026-06-06
588
588
  <!-- leerness:project-readme:end -->
589
589
 
package/bin/harness.js CHANGED
@@ -29,7 +29,7 @@ const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInG
29
29
  // 1.9.295 (UR-0025 4단계): 정적 데이터 카탈로그 모듈 분리 (비파괴, require-based).
30
30
  const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_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 분리 (MERGE_OVERWRITE_FILES/MINIMAL_SKIP_KEYS 포함)
31
31
 
32
- const VERSION = '1.9.387';
32
+ const VERSION = '1.9.388';
33
33
 
34
34
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
35
35
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -2982,7 +2982,7 @@ function _selfTestCases() {
2982
2982
  { name: 'UR-0025: _renderWorkspaceReferenceGuide 모듈 분리 + 빌더 동작 (1.9.377)', run: () => { const m = require('../lib/pure-utils'); if (typeof _renderWorkspaceReferenceGuide !== 'function' || m._renderWorkspaceReferenceGuide !== _renderWorkspaceReferenceGuide) return false; const g = _renderWorkspaceReferenceGuide('.leerness', '9.9.9', '2026-01-01T00:00:00.000Z'); const wrapperThin = read(__filename).includes('return _renderWorkspaceReferenceGuide(dirName, VERSION, new Date().toISOString())'); return g.includes('.leerness/progress-tracker.md') && g.includes('9.9.9') && g.includes('자주 묻는 위치') && g.includes('마이그레이션 안내') && wrapperThin; } },
2983
2983
  { name: 'UR-0073: team MCP 도구 2종(read-only) 정의 + dispatch 와이어 (1.9.378)', run: () => { const tools = require('../lib/mcp-tools'); const src = read(__filename); const tl = tools.find(t => t.name === 'leerness_team_list'); const tp = tools.find(t => t.name === 'leerness_team_preview'); const defsOk = tl && tl.requiredTier === 'read-only' && tp && tp.requiredTier === 'read-only' && tp.inputSchema.required && tp.inputSchema.required.includes('id'); const wired = src.includes("case " + "'leerness_team_list':") && src.includes("case " + "'leerness_team_preview':") && /cliArgs = \['team', 'list'/.test(src) && /cliArgs = \['team', 'preview'/.test(src); return !!defsOk && wired; } },
2984
2984
  { name: 'UR-0025 심화: pulse 렌더 코어 분리 — _memorySurface + _renderPulseLine 행위 (1.9.379)', run: () => { const m = require('../lib/pure-utils'); if (typeof _memorySurface !== 'function' || typeof _renderPulseLine !== 'function' || m._memorySurface !== _memorySurface || m._renderPulseLine !== _renderPulseLine) return false; const ms = _memorySurface({ tasks: 1, decisions: 2, rules: 3, milestones: 4, lessons: 5 }) === 'T1/D2/R3/P4/L5' && _memorySurface({}) === 'T0/D0/R0/P0/L0'; const base = _renderPulseLine({ version: '1.0.0', roundCount: 7, mcpTools: 85, memorySurface: 'T0/D1/R0/P2/L0' }); const ln = base.includes('v1.0.0') && base.includes('R7') && base.includes('MCP 85') && base.includes('T0/D1/R0/P2/L0') && !base.includes('🎯') && !base.includes('abnormal'); const full = _renderPulseLine({ version: '1.0.0', roundCount: 7, mcpTools: 85, memorySurface: 'x', nextMilestone: 400, etaDays: 6, abnormalShutdown: 'high' }); const ln2 = full.includes('🎯 R400 (6d)') && full.includes('abnormal:high'); const wired = read(__filename).includes('const line = _renderPulseLine(data)') && read(__filename).includes('data.memorySurface = _memorySurface('); return ms && ln && ln2 && wired; } },
2985
- { name: 'UR-0025: REQUIRED_WORKSPACE_FILES 단일출처 — verify/migrate audit·apply 3중 중복 제거 (1.9.380)', run: () => { const c = require('../lib/catalogs'); if (REQUIRED_WORKSPACE_FILES !== c.REQUIRED_WORKSPACE_FILES) return false; const listOk = Array.isArray(c.REQUIRED_WORKSPACE_FILES) && c.REQUIRED_WORKSPACE_FILES.length === 9 && c.REQUIRED_WORKSPACE_FILES.includes('AGENTS.md') && c.REQUIRED_WORKSPACE_FILES.includes('.harness/plan.md'); const usesConst = (read(__filename).match(/const required = REQUIRED_WORKSPACE_FILES;/g) || []).length >= 3; return listOk && usesConst; } },
2985
+ { name: 'UR-0025: REQUIRED_WORKSPACE_FILES 단일출처 — verify/migrate audit·apply 3중 중복 제거 (1.9.380)', run: () => { const c = require('../lib/catalogs'); if (REQUIRED_WORKSPACE_FILES !== c.REQUIRED_WORKSPACE_FILES) return false; const listOk = Array.isArray(c.REQUIRED_WORKSPACE_FILES) && c.REQUIRED_WORKSPACE_FILES.length === 9 && c.REQUIRED_WORKSPACE_FILES.includes('AGENTS.md') && c.REQUIRED_WORKSPACE_FILES.includes('.harness/plan.md'); const harnessUses = (read(__filename).match(/const required = REQUIRED_WORKSPACE_FILES;/g) || []).length >= 1; const migUses = (read(path.join(path.dirname(__filename), '..', 'lib', 'migrate.js')).match(/const required = REQUIRED_WORKSPACE_FILES;/g) || []).length >= 2; return listOk && harnessUses && migUses; } },
2986
2986
  { name: 'UR-0025: KEYWORD_STOPWORDS 단일출처 — handoff/lessons 키워드 stopwords 2중 중복 제거 (1.9.381)', run: () => { const c = require('../lib/catalogs'); if (KEYWORD_STOPWORDS !== c.KEYWORD_STOPWORDS) return false; const setOk = c.KEYWORD_STOPWORDS instanceof Set && c.KEYWORD_STOPWORDS.has('작업') && c.KEYWORD_STOPWORDS.has('task') && !c.KEYWORD_STOPWORDS.has('고유단어') && c.KEYWORD_STOPWORDS.size >= 25; const usesConst = (read(__filename).match(/const stopwords = KEYWORD_STOPWORDS;/g) || []).length >= 2 && !/const stopwords = new Set\(\[/.test(read(__filename)); return setOk && usesConst; } },
2987
2987
  { name: 'UR-0025 큰핸들러토대: lib/io.js 프리미티브(log/ok/warn/fail/today/now) 모듈 분리 + 동작 (1.9.382)', run: () => { const io = require('../lib/io'); const exportsOk = ['log', 'ok', 'warn', 'fail', 'today', 'now'].every(k => typeof io[k] === 'function') && io.log === log && io.fail === fail && io.now === now; const todayOk = /^\d{4}-\d{2}-\d{2}$/.test(io.today()) && /^\d{4}-\d{2}-\d{2}T/.test(io.now()); const src = read(__filename); const moved = src.includes("require('../lib/io')") && !/^function fail\(s\) \{ log/m.test(src) && !/^function now\(\) \{ return new Date/m.test(src); let exitOk = false; const saved = process.exitCode; const _w = process.stdout.write; try { process.stdout.write = () => true; process.exitCode = 0; io.fail('probe'); exitOk = process.exitCode === 1; } finally { process.stdout.write = _w; process.exitCode = saved; } return exportsOk && todayOk && moved && exitOk; } },
2988
2988
  { name: 'UR-0025 큰핸들러토대: lib/io.js fs 프리미티브(read/writeUtf8/exists/mkdirp/append/rel/absRoot) 분리 + round-trip (1.9.383)', run: () => { const io = require('../lib/io'); const exp = ['absRoot', 'exists', 'read', 'readBuf', 'mkdirp', 'writeUtf8', 'append', 'rel'].every(k => typeof io[k] === 'function') && io.read === read && io.writeUtf8 === writeUtf8 && io.exists === exists; const src = read(__filename); const moved = !/^function writeUtf8\(p, s\) \{/m.test(src) && !/^function read\(p\) \{/m.test(src) && !/^function exists\(p\) \{/m.test(src); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_io_')); let rt = false; try { const f = path.join(tmp, 'a', 'b.txt'); io.writeUtf8(f, '한글RT'); rt = io.exists(f) && io.read(f) === '한글RT' && io.rel(tmp, f) === 'a/b.txt'; } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } return exp && moved && rt; } },
@@ -2990,6 +2990,7 @@ function _selfTestCases() {
2990
2990
  { name: '5th외부평가/UR-0086: _parseContractSpec markdown bullet 함수 감지 + 순수 추출 (1.9.385)', run: () => { const m = require('../lib/pure-utils'); if (m._parseContractSpec !== _parseContractSpec) return false; const p = _parseContractSpec('# Spec\n- add(a,b)\n* subtract(a,b)\n1. multiply(a,b)\nfunction legacy(x)\n`mentioned(`\ntick.amount\n'); const declOk = ['add', 'subtract', 'multiply', 'legacy'].every(n => p.declared.includes(n)) && p.declared.length === 4; const menOk = p.mentioned.includes('mentioned') && !p.declared.includes('mentioned'); const fieldOk = p.fields.includes('amount'); const fpOk = _parseContractSpec('- 합계 (a+b)\n- result (total)\n- foo: bar(x)\n**bold**').declared.length === 0; const src = read(__filename); const moved = src.includes('_parseContractSpec(specText)') && !/specText\.matchAll\(\/function/.test(src); return declOk && menOk && fieldOk && fpOk && moved; } },
2991
2991
  { name: '5th외부평가/UR-0087: _gitignoreMatch git 일치(.env↛.env.bad) + env-family 스캔 (1.9.386)', run: () => { const m = require('../lib/pure-utils'); if (m._gitignoreMatch !== _gitignoreMatch) return false; const gm = _gitignoreMatch; const semOk = gm('.env', '.env') === true && gm('.env', '.env.bad') === false && gm('.env', '.env.local') === false && gm('.env.*', '.env.bad') === true && gm('.env*', '.env') === true && gm('*.pem', 'k.pem') === true && gm('src/', 'src/a.txt') === true; const src = read(__filename); const envFamilyScan = src.includes('const isEnvFamily =') && src.includes('!SCAN_TEXT_EXT.has(ext) && !isEnvFamily'); const delegated = src.includes('return _gitignoreMatch(gi, fileRel)'); return semOk && envFamilyScan && delegated; } },
2992
2992
  { name: 'UR-0088 5th외부평가 일관성: incident/runs list 빈 케이스 --json 구조화 (1.9.387)', run: () => { if (typeof incidentListCmd !== 'function' || typeof runsListCmd !== 'function') return false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_lj_')); const save = process.argv; const _w = process.stdout.write; let io = '', ro = ''; try { fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true }); process.argv = ['node', 'h', 'incident', 'list', '--json']; process.stdout.write = s => { io += s; return true; }; incidentListCmd(tmp); process.stdout.write = _w; process.argv = ['node', 'h', 'runs', 'list', '--json']; process.stdout.write = s => { ro += s; return true; }; runsListCmd(tmp); } catch {} finally { process.stdout.write = _w; process.argv = save; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } let ij, rj; try { ij = JSON.parse(io); rj = JSON.parse(ro); } catch {} return !!ij && ij.total === 0 && Array.isArray(ij.items) && !!rj && rj.total === 0 && Array.isArray(rj.items); } },
2993
+ { name: 'UR-0025 큰핸들러 모듈화: migrate audit/apply/plan → lib/migrate.js + DI 위임 + 동작 (1.9.388)', run: () => { const m = require('../lib/migrate'); const expOk = typeof m.migrateAuditCmd === 'function' && typeof m.migrateApplyCmd === 'function' && typeof m.migratePlanCmd === 'function'; const src = read(__filename); const delegated = src.includes("require('../lib/migrate')") && src.includes('_migrate.migrateAuditCmd(root, opts, _migrateDeps())') && src.includes('_migrate.migratePlanCmd(root, opts, _migrateDeps())'); const movedToLib = read(path.join(path.dirname(__filename), '..', 'lib', 'migrate.js')).includes('leerness-plan-'); let behavOk = false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_mig_')); const save = process.argv; const _w = process.stdout.write; let out = ''; try { fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true }); fs.writeFileSync(path.join(tmp, '.harness', 'HARNESS_VERSION'), VERSION); process.argv = ['node', 'h', 'migrate', 'audit', tmp, '--json']; process.stdout.write = s => { out += s; return true; }; migrateAuditCmd(tmp, { json: true }); } catch {} finally { process.stdout.write = _w; process.argv = save; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } try { const j = JSON.parse(out); behavOk = j.version === VERSION && typeof j.willChange === 'number' && Array.isArray(j.findings); } catch {} return expOk && delegated && movedToLib && behavOk; } },
2993
2994
  { name: 'VERSION 형식 (x.y.z)', run: () => /^\d+\.\d+\.\d+$/.test(VERSION) }
2994
2995
  ];
2995
2996
  }
@@ -6771,110 +6772,16 @@ function verify(root) {
6771
6772
  if (failures.length) { failures.forEach(f => fail(f)); process.exitCode = 1; } else ok('verify passed');
6772
6773
  }
6773
6774
  // 1.9.356 (UR-0075 Phase B): migrate audit — 비파괴 dry-run 스키마 drift 리포트(버전/ canonical JSON 백필/누락 파일). 실제 변경 X.
6774
- function migrateAuditCmd(root, opts = {}) {
6775
- root = absRoot(root);
6776
- const findings = [];
6777
- const hvPath = path.join(root, '.harness', 'HARNESS_VERSION');
6778
- const projVer = exists(hvPath) ? read(hvPath).trim() : null;
6779
- if (!projVer) findings.push({ kind: 'no-version', detail: 'HARNESS_VERSION 없음 (미초기화 또는 아주 구버전)', action: `leerness migrate --path ${root}` });
6780
- else if (compareVer(projVer, VERSION) < 0) findings.push({ kind: 'version-drift', detail: `${projVer} → ${VERSION}`, action: `leerness update --yes --path ${root}` });
6781
- // canonical JSON 백필 필요 (구 MD-only → decisions.json/lessons.json)
6782
- if (exists(decisionsPath(root)) && !exists(decisionsJsonPath(root)) && _loadDecisions(root).length > 0) findings.push({ kind: 'canonical-pending', detail: 'decisions.md → decisions.json 백필 예정', action: 'decision add/drop 또는 migrate 시 자동' });
6783
- if (exists(lessonsPath(root)) && !exists(lessonsJsonPath(root)) && _loadLessons(root).length > 0) findings.push({ kind: 'canonical-pending', detail: 'lessons.md → lessons.json 백필 예정', action: 'lesson save/drop 또는 migrate 시 자동' });
6784
- // 누락 예상 파일 (현재 버전 required set 기준)
6785
- const required = REQUIRED_WORKSPACE_FILES; // 1.9.380 (UR-0025): lib/catalogs 단일출처
6786
- for (const f of required) if (!exists(path.join(root, f))) findings.push({ kind: 'missing-file', detail: f, action: 'migrate 가 생성' });
6787
- if (opts.json) { log(JSON.stringify({ version: VERSION, root, projectVersion: projVer, willChange: findings.length, findings }, null, 2)); return; }
6788
- log(`# leerness migrate audit (1.9.356, UR-0075 dry-run) — 실제 변경 없음`);
6789
- log(` 대상: ${root}`);
6790
- log(` 프로젝트 버전: ${projVer || '(없음)'} · 도구 버전: ${VERSION}`);
6791
- if (!findings.length) { ok('마이그레이션 필요 없음 — 최신 스키마 정합'); return; }
6792
- log(` 예상 변경 ${findings.length}건:`);
6793
- for (const f of findings) log(` • [${f.kind}] ${f.detail}${f.action ? ` → ${f.action}` : ''}`);
6794
- log(`\n 적용: leerness update --yes --path ${root} · 안전 가이드: leerness migrate --guide`);
6795
- }
6796
- // UR-0075 Phase C (1.9.357): migrate apply — audit가 찾은 '안전 항목'(canonical 백필)만 비파괴 적용.
6797
- // 기본 dry-run(변경 없음) · --yes 로 실제 적용. version-drift/missing-file 은 apply 범위 외(수동 안내).
6798
- function migrateApplyCmd(root, opts = {}) {
6799
- root = absRoot(root);
6800
- const apply = !!opts.yes;
6801
- const applied = []; // 안전하게 적용 가능(또는 dry-run 예정): canonical 백필
6802
- const skipped = []; // apply 범위 외: 수동 조치 필요
6803
- // canonical-pending: MD 항목 존재 + JSON 부재 → load(MD 백필)→save(canonical JSON + MD 정규화). idempotent(UR-0053).
6804
- if (exists(decisionsPath(root)) && !exists(decisionsJsonPath(root)) && _loadDecisions(root).length > 0) {
6805
- if (apply) _saveDecisions(root, _loadDecisions(root));
6806
- applied.push({ kind: 'canonical-backfill', detail: 'decisions.md → decisions.json' });
6807
- }
6808
- if (exists(lessonsPath(root)) && !exists(lessonsJsonPath(root)) && _loadLessons(root).length > 0) {
6809
- if (apply) _saveLessons(root, _loadLessons(root));
6810
- applied.push({ kind: 'canonical-backfill', detail: 'lessons.md → lessons.json' });
6811
- }
6812
- // version-drift / 누락 파일 — apply 가 안전하게 in-place 처리 불가(npm 재설치/재초기화 필요) → 수동 안내.
6813
- const hvPath = path.join(root, '.harness', 'HARNESS_VERSION');
6814
- const projVer = exists(hvPath) ? read(hvPath).trim() : null;
6815
- if (!projVer) skipped.push({ kind: 'no-version', detail: 'HARNESS_VERSION 없음', reason: `leerness migrate --path ${root}` });
6816
- else if (compareVer(projVer, VERSION) < 0) skipped.push({ kind: 'version-drift', detail: `${projVer} → ${VERSION}`, reason: `leerness update --yes --path ${root}` });
6817
- const required = REQUIRED_WORKSPACE_FILES; // 1.9.380 (UR-0025): lib/catalogs 단일출처
6818
- for (const f of required) if (!exists(path.join(root, f))) skipped.push({ kind: 'missing-file', detail: f, reason: 'leerness migrate / init' });
6819
- if (opts.json) { log(JSON.stringify({ version: VERSION, root, dryRun: !apply, appliedCount: apply ? applied.length : 0, applied, skipped }, null, 2)); return; }
6820
- log(`# leerness migrate apply (1.9.357, UR-0075 Phase C)${apply ? '' : ' — dry-run (변경 없음 · --yes 로 적용)'}`);
6821
- log(` 대상: ${root}`);
6822
- if (!applied.length && !skipped.length) { ok('적용할 항목 없음 — 최신 스키마 정합'); return; }
6823
- if (applied.length) {
6824
- log(apply ? ` ✓ 적용 ${applied.length}건 (canonical 백필 · MD→JSON, MD 정규화):` : ` 적용 예정 ${applied.length}건 (canonical 백필 · MD→JSON, MD 정규화):`);
6825
- for (const a of applied) log(` • ${a.detail}`);
6826
- }
6827
- if (skipped.length) {
6828
- log(` ⚠ 수동 필요 ${skipped.length}건 (apply 범위 외 — 안전상 자동 변경 안 함):`);
6829
- for (const s of skipped) log(` • [${s.kind}] ${s.detail} → ${s.reason}`);
6830
- }
6831
- if (!apply && applied.length) log(`\n 적용: leerness migrate apply --path ${root} --yes`);
6832
- }
6833
- // UR-0075 Phase D (1.9.358): migrate plan — 임시폴더에 현재 버전을 설치(서브프로세스 격리)한 뒤
6834
- // 프로젝트의 .harness 코어 관리 파일과 비교해 정확한 마이그레이션 플랜을 산출. 읽기 전용(프로젝트 미변경).
6835
- // 사용자 비전("임시 폴더에 leerness 설치 후 기존 프로젝트 내용을 정확히 마이그레이션")의 진단 단계.
6836
- function migratePlanCmd(root, opts = {}) {
6837
- root = absRoot(root);
6838
- const plan = { version: VERSION, root, projectVersion: null, versionDrift: null, canonicalPending: [], missingFiles: [], tempInstallOk: false, willChange: 0 };
6839
- const hvPath = path.join(root, '.harness', 'HARNESS_VERSION');
6840
- plan.projectVersion = exists(hvPath) ? read(hvPath).trim() : null;
6841
- if (plan.projectVersion && compareVer(plan.projectVersion, VERSION) < 0) plan.versionDrift = `${plan.projectVersion} → ${VERSION}`;
6842
- if (exists(decisionsPath(root)) && !exists(decisionsJsonPath(root)) && _loadDecisions(root).length > 0) plan.canonicalPending.push('decisions.md → decisions.json');
6843
- if (exists(lessonsPath(root)) && !exists(lessonsJsonPath(root)) && _loadLessons(root).length > 0) plan.canonicalPending.push('lessons.md → lessons.json');
6844
- // 임시폴더에 현재 버전 init → 생성되는 .harness 코어 파일(depth-1) 비교 (서브프로세스 격리, stdout 미오염)
6845
- let tmp = null;
6846
- try {
6847
- tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-plan-'));
6848
- const langPath = path.join(root, '.harness', 'LANGUAGE');
6849
- const lang = exists(langPath) ? (read(langPath).trim() || 'ko') : 'ko';
6850
- const r = cp.spawnSync(process.execPath, [__filename, 'init', tmp, '--yes', '--language', lang, '--no-banner'], { encoding: 'utf8', timeout: 60000 });
6851
- plan.tempInstallOk = r.status === 0;
6852
- if (plan.tempInstallOk) {
6853
- const tmpHarness = path.join(tmp, '.harness');
6854
- let tmpFiles = [];
6855
- try { tmpFiles = fs.readdirSync(tmpHarness, { withFileTypes: true }).filter(e => e.isFile()).map(e => '.harness/' + e.name); } catch {}
6856
- for (const top of ['AGENTS.md', 'CLAUDE.md']) if (exists(path.join(tmp, top))) tmpFiles.push(top);
6857
- for (const f of tmpFiles) if (!exists(path.join(root, f))) plan.missingFiles.push(f);
6858
- }
6859
- } catch {}
6860
- finally { if (tmp) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } }
6861
- plan.willChange = plan.missingFiles.length + plan.canonicalPending.length + (plan.versionDrift ? 1 : 0);
6862
- if (opts.json) { log(JSON.stringify(plan, null, 2)); return; }
6863
- log(`# leerness migrate plan (1.9.358, UR-0075 Phase D) — 임시폴더 설치 후 비교 · 읽기 전용(프로젝트 미변경)`);
6864
- log(` 대상: ${root}`);
6865
- log(` 프로젝트 버전: ${plan.projectVersion || '(없음)'} · 도구 버전: ${VERSION}`);
6866
- if (!plan.tempInstallOk) warn('임시폴더 설치 실패 — 파일 비교 생략 (버전/canonical 만 보고)');
6867
- if (!plan.willChange) { ok('마이그레이션 필요 없음 — 최신 스키마 정합'); return; }
6868
- log(` 예상 변경 ${plan.willChange}건:`);
6869
- if (plan.versionDrift) log(` • [version-drift] ${plan.versionDrift} → leerness update --yes --path ${root}`);
6870
- for (const c of plan.canonicalPending) log(` • [canonical-pending] ${c} → leerness migrate apply --path ${root} --yes`);
6871
- if (plan.missingFiles.length) {
6872
- log(` • [missing-file] ${plan.missingFiles.length}건 (현재 버전이 생성하는 관리 파일 누락):`);
6873
- for (const f of plan.missingFiles.slice(0, 20)) log(` - ${f}`);
6874
- if (plan.missingFiles.length > 20) log(` … 외 ${plan.missingFiles.length - 20}건`);
6875
- }
6876
- log(`\n 전체 적용: leerness update --yes --path ${root} · canonical만: leerness migrate apply --path ${root} --yes`);
6775
+ // 1.9.388 (UR-0025 핸들러 모듈화): migrate audit/apply/plan 핸들러를 lib/migrate.js 로 분리.
6776
+ // harness deps(VERSION·canonical 메모리 함수·카탈로그·compareVer·harness 경로)를 1회 구성해 위임(thin wrapper). 호출부/동작 무변경.
6777
+ const _migrate = require('../lib/migrate');
6778
+ function _migrateDeps() {
6779
+ return { VERSION, compareVer, REQUIRED_WORKSPACE_FILES, decisionsPath, decisionsJsonPath, lessonsPath, lessonsJsonPath, _loadDecisions, _saveDecisions, _loadLessons, _saveLessons, harnessPath: __filename };
6877
6780
  }
6781
+ function migrateAuditCmd(root, opts = {}) { return _migrate.migrateAuditCmd(root, opts, _migrateDeps()); }
6782
+ function migrateApplyCmd(root, opts = {}) { return _migrate.migrateApplyCmd(root, opts, _migrateDeps()); }
6783
+ function migratePlanCmd(root, opts = {}) { return _migrate.migratePlanCmd(root, opts, _migrateDeps()); }
6784
+
6878
6785
  // UR-0074 (1.9.359): install-safety — 패키지 설치 안전 프로필 투명 공개 (외부리뷰 설치 신뢰성 우려 대응).
6879
6786
  // leerness 의 핵심 안전 속성(0 런타임 의존성 · 0 install-time 스크립트)을 사실 그대로 보고 + 안전 설치 워크플로 안내.
6880
6787
  // 회귀 가드 역할도 겸함: 누군가 런타임 deps/install hook 를 추가하면 selftest/e2e 가 실패시켜 의식적 결정을 강제.
package/lib/migrate.js ADDED
@@ -0,0 +1,123 @@
1
+ // lib/migrate.js — 크로스버전 마이그레이션 핸들러 (UR-0075: audit / apply / plan).
2
+ // 1.9.388 (UR-0025 큰 핸들러 모듈화): bin/harness.js 에서 분리한 첫 실제 핸들러 모듈.
3
+ // - I/O 프리미티브: ./io (absRoot/exists/read/log/ok/warn).
4
+ // - harness 고유 의존(VERSION · canonical 메모리 함수 · REQUIRED_WORKSPACE_FILES · compareVer · harness 경로)은
5
+ // deps 객체로 주입(DI). harness 는 thin wrapper 로 deps 를 1회 구성해 위임 → 호출부/동작 무변경.
6
+ 'use strict';
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const cp = require('child_process');
11
+ const { absRoot, exists, read, log, ok, warn } = require('./io');
12
+
13
+ // UR-0075 Phase B (1.9.356): migrate audit — 비파괴 dry-run 스키마 drift 리포트 (실제 변경 없음).
14
+ function migrateAuditCmd(root, opts = {}, deps = {}) {
15
+ const { VERSION, compareVer, REQUIRED_WORKSPACE_FILES, decisionsPath, decisionsJsonPath, lessonsPath, lessonsJsonPath, _loadDecisions, _loadLessons } = deps;
16
+ root = absRoot(root);
17
+ const findings = [];
18
+ const hvPath = path.join(root, '.harness', 'HARNESS_VERSION');
19
+ const projVer = exists(hvPath) ? read(hvPath).trim() : null;
20
+ if (!projVer) findings.push({ kind: 'no-version', detail: 'HARNESS_VERSION 없음 (미초기화 또는 아주 구버전)', action: `leerness migrate --path ${root}` });
21
+ else if (compareVer(projVer, VERSION) < 0) findings.push({ kind: 'version-drift', detail: `${projVer} → ${VERSION}`, action: `leerness update --yes --path ${root}` });
22
+ // canonical JSON 백필 필요 (구 MD-only → decisions.json/lessons.json)
23
+ if (exists(decisionsPath(root)) && !exists(decisionsJsonPath(root)) && _loadDecisions(root).length > 0) findings.push({ kind: 'canonical-pending', detail: 'decisions.md → decisions.json 백필 예정', action: 'decision add/drop 또는 migrate 시 자동' });
24
+ if (exists(lessonsPath(root)) && !exists(lessonsJsonPath(root)) && _loadLessons(root).length > 0) findings.push({ kind: 'canonical-pending', detail: 'lessons.md → lessons.json 백필 예정', action: 'lesson save/drop 또는 migrate 시 자동' });
25
+ // 누락 예상 파일 (현재 버전 required set 기준)
26
+ const required = REQUIRED_WORKSPACE_FILES; // 1.9.380 (UR-0025): lib/catalogs 단일출처
27
+ for (const f of required) if (!exists(path.join(root, f))) findings.push({ kind: 'missing-file', detail: f, action: 'migrate 가 생성' });
28
+ if (opts.json) { log(JSON.stringify({ version: VERSION, root, projectVersion: projVer, willChange: findings.length, findings }, null, 2)); return; }
29
+ log(`# leerness migrate audit (1.9.356, UR-0075 dry-run) — 실제 변경 없음`);
30
+ log(` 대상: ${root}`);
31
+ log(` 프로젝트 버전: ${projVer || '(없음)'} · 도구 버전: ${VERSION}`);
32
+ if (!findings.length) { ok('마이그레이션 필요 없음 — 최신 스키마 정합'); return; }
33
+ log(` 예상 변경 ${findings.length}건:`);
34
+ for (const f of findings) log(` • [${f.kind}] ${f.detail}${f.action ? ` → ${f.action}` : ''}`);
35
+ log(`\n 적용: leerness update --yes --path ${root} · 안전 가이드: leerness migrate --guide`);
36
+ }
37
+
38
+ // UR-0075 Phase C (1.9.357): migrate apply — audit가 찾은 '안전 항목'(canonical 백필)만 비파괴 적용.
39
+ // 기본 dry-run(변경 없음) · --yes 로 실제 적용. version-drift/missing-file 은 apply 범위 외(수동 안내).
40
+ function migrateApplyCmd(root, opts = {}, deps = {}) {
41
+ const { VERSION, compareVer, REQUIRED_WORKSPACE_FILES, decisionsPath, decisionsJsonPath, lessonsPath, lessonsJsonPath, _loadDecisions, _saveDecisions, _loadLessons, _saveLessons } = deps;
42
+ root = absRoot(root);
43
+ const apply = !!opts.yes;
44
+ const applied = []; // 안전하게 적용 가능(또는 dry-run 예정): canonical 백필
45
+ const skipped = []; // apply 범위 외: 수동 조치 필요
46
+ // canonical-pending: MD 항목 존재 + JSON 부재 → load(MD 백필)→save(canonical JSON + MD 정규화). idempotent(UR-0053).
47
+ if (exists(decisionsPath(root)) && !exists(decisionsJsonPath(root)) && _loadDecisions(root).length > 0) {
48
+ if (apply) _saveDecisions(root, _loadDecisions(root));
49
+ applied.push({ kind: 'canonical-backfill', detail: 'decisions.md → decisions.json' });
50
+ }
51
+ if (exists(lessonsPath(root)) && !exists(lessonsJsonPath(root)) && _loadLessons(root).length > 0) {
52
+ if (apply) _saveLessons(root, _loadLessons(root));
53
+ applied.push({ kind: 'canonical-backfill', detail: 'lessons.md → lessons.json' });
54
+ }
55
+ // version-drift / 누락 파일 — apply 가 안전하게 in-place 처리 불가(npm 재설치/재초기화 필요) → 수동 안내.
56
+ const hvPath = path.join(root, '.harness', 'HARNESS_VERSION');
57
+ const projVer = exists(hvPath) ? read(hvPath).trim() : null;
58
+ if (!projVer) skipped.push({ kind: 'no-version', detail: 'HARNESS_VERSION 없음', reason: `leerness migrate --path ${root}` });
59
+ else if (compareVer(projVer, VERSION) < 0) skipped.push({ kind: 'version-drift', detail: `${projVer} → ${VERSION}`, reason: `leerness update --yes --path ${root}` });
60
+ const required = REQUIRED_WORKSPACE_FILES; // 1.9.380 (UR-0025): lib/catalogs 단일출처
61
+ for (const f of required) if (!exists(path.join(root, f))) skipped.push({ kind: 'missing-file', detail: f, reason: 'leerness migrate / init' });
62
+ if (opts.json) { log(JSON.stringify({ version: VERSION, root, dryRun: !apply, appliedCount: apply ? applied.length : 0, applied, skipped }, null, 2)); return; }
63
+ log(`# leerness migrate apply (1.9.357, UR-0075 Phase C)${apply ? '' : ' — dry-run (변경 없음 · --yes 로 적용)'}`);
64
+ log(` 대상: ${root}`);
65
+ if (!applied.length && !skipped.length) { ok('적용할 항목 없음 — 최신 스키마 정합'); return; }
66
+ if (applied.length) {
67
+ log(apply ? ` ✓ 적용 ${applied.length}건 (canonical 백필 · MD→JSON, MD 정규화):` : ` 적용 예정 ${applied.length}건 (canonical 백필 · MD→JSON, MD 정규화):`);
68
+ for (const a of applied) log(` • ${a.detail}`);
69
+ }
70
+ if (skipped.length) {
71
+ log(` ⚠ 수동 필요 ${skipped.length}건 (apply 범위 외 — 안전상 자동 변경 안 함):`);
72
+ for (const s of skipped) log(` • [${s.kind}] ${s.detail} → ${s.reason}`);
73
+ }
74
+ if (!apply && applied.length) log(`\n 적용: leerness migrate apply --path ${root} --yes`);
75
+ }
76
+
77
+ // UR-0075 Phase D (1.9.358): migrate plan — 임시폴더에 현재 버전을 설치(서브프로세스 격리)한 뒤
78
+ // 프로젝트의 .harness 코어 관리 파일과 비교해 정확한 마이그레이션 플랜을 산출. 읽기 전용(프로젝트 미변경).
79
+ function migratePlanCmd(root, opts = {}, deps = {}) {
80
+ const { VERSION, compareVer, decisionsPath, decisionsJsonPath, lessonsPath, lessonsJsonPath, _loadDecisions, _loadLessons, harnessPath } = deps;
81
+ root = absRoot(root);
82
+ const plan = { version: VERSION, root, projectVersion: null, versionDrift: null, canonicalPending: [], missingFiles: [], tempInstallOk: false, willChange: 0 };
83
+ const hvPath = path.join(root, '.harness', 'HARNESS_VERSION');
84
+ plan.projectVersion = exists(hvPath) ? read(hvPath).trim() : null;
85
+ if (plan.projectVersion && compareVer(plan.projectVersion, VERSION) < 0) plan.versionDrift = `${plan.projectVersion} → ${VERSION}`;
86
+ if (exists(decisionsPath(root)) && !exists(decisionsJsonPath(root)) && _loadDecisions(root).length > 0) plan.canonicalPending.push('decisions.md → decisions.json');
87
+ if (exists(lessonsPath(root)) && !exists(lessonsJsonPath(root)) && _loadLessons(root).length > 0) plan.canonicalPending.push('lessons.md → lessons.json');
88
+ // 임시폴더에 현재 버전 init → 생성되는 .harness 코어 파일(depth-1) 비교 (서브프로세스 격리, stdout 미오염)
89
+ let tmp = null;
90
+ try {
91
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-plan-'));
92
+ const langPath = path.join(root, '.harness', 'LANGUAGE');
93
+ const lang = exists(langPath) ? (read(langPath).trim() || 'ko') : 'ko';
94
+ const r = cp.spawnSync(process.execPath, [harnessPath, 'init', tmp, '--yes', '--language', lang, '--no-banner'], { encoding: 'utf8', timeout: 60000 });
95
+ plan.tempInstallOk = r.status === 0;
96
+ if (plan.tempInstallOk) {
97
+ const tmpHarness = path.join(tmp, '.harness');
98
+ let tmpFiles = [];
99
+ try { tmpFiles = fs.readdirSync(tmpHarness, { withFileTypes: true }).filter(e => e.isFile()).map(e => '.harness/' + e.name); } catch {}
100
+ for (const top of ['AGENTS.md', 'CLAUDE.md']) if (exists(path.join(tmp, top))) tmpFiles.push(top);
101
+ for (const f of tmpFiles) if (!exists(path.join(root, f))) plan.missingFiles.push(f);
102
+ }
103
+ } catch {}
104
+ finally { if (tmp) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } }
105
+ plan.willChange = plan.missingFiles.length + plan.canonicalPending.length + (plan.versionDrift ? 1 : 0);
106
+ if (opts.json) { log(JSON.stringify(plan, null, 2)); return; }
107
+ log(`# leerness migrate plan (1.9.358, UR-0075 Phase D) — 임시폴더 설치 후 비교 · 읽기 전용(프로젝트 미변경)`);
108
+ log(` 대상: ${root}`);
109
+ log(` 프로젝트 버전: ${plan.projectVersion || '(없음)'} · 도구 버전: ${VERSION}`);
110
+ if (!plan.tempInstallOk) warn('임시폴더 설치 실패 — 파일 비교 생략 (버전/canonical 만 보고)');
111
+ if (!plan.willChange) { ok('마이그레이션 필요 없음 — 최신 스키마 정합'); return; }
112
+ log(` 예상 변경 ${plan.willChange}건:`);
113
+ if (plan.versionDrift) log(` • [version-drift] ${plan.versionDrift} → leerness update --yes --path ${root}`);
114
+ for (const c of plan.canonicalPending) log(` • [canonical-pending] ${c} → leerness migrate apply --path ${root} --yes`);
115
+ if (plan.missingFiles.length) {
116
+ log(` • [missing-file] ${plan.missingFiles.length}건 (현재 버전이 생성하는 관리 파일 누락):`);
117
+ for (const f of plan.missingFiles.slice(0, 20)) log(` - ${f}`);
118
+ if (plan.missingFiles.length > 20) log(` … 외 ${plan.missingFiles.length - 20}건`);
119
+ }
120
+ log(`\n 전체 적용: leerness update --yes --path ${root} · canonical만: leerness migrate apply --path ${root} --yes`);
121
+ }
122
+
123
+ module.exports = { migrateAuditCmd, migrateApplyCmd, migratePlanCmd };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.387",
3
+ "version": "1.9.388",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",