leerness 1.9.338 → 1.9.340

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,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.340 — 2026-06-05 — UR-0058: lessons canonical = JSON, MD = projection (Codex 위임·검증)
4
+
5
+ **🤖 UR-0053 decisions canonical 패턴을 lessons 로 확장 — Codex CLI(gpt-5.5) 위임 후 하네스 독립 검증. 상태 저장 단일 진실소스 일반화.** (직전 1.9.339 decisions 의 정밀 미러)
6
+
7
+ ### 배경 (위임 결정)
8
+ 1.9.339 에서 decisions canonical(JSON 진실소스 + MD projection)을 확립 → lessons 도 동일 MD-parse drift 부류이고 `_parseLessonEntries`(순수, `{date,text,tag}`)가 이미 존재 → 동형 작업을 Codex 에 위임("일부 Codex" 로테이션). 위임 ≠ 무검증: 1.9.339 구현을 참조 명세로 작성 → `codex exec` → 하네스가 독립 검증(범위·구문·round-trip·백필·count 사이트·save/list/drop end-to-end).
9
+
10
+ ### 구현 (Codex 수행)
11
+ 1. **canonical write path**: `lesson save`/`drop` → `lessons.json`(canonical) + `lessons.md`(projection) 동시 기록 (`_saveLessons`).
12
+ 2. **순수 렌더** `_renderLessonsMd(lessons)` → `lib/pure-utils.js` (tag 없으면 Tag 줄 생략 → `_parseLessonEntries` 와 round-trip 멱등). 기존 `_parseLessonEntries` 가 canonical 파서.
13
+ 3. **canonical 로더** `_loadLessons(root)`: lessons.json 우선 → 없으면 `_parseLessonEntries(lessons.md)` (읽기 무부작용). 손상 JSON 시 MD fallback.
14
+ 4. **단일 진실소스 라우팅**: lesson save/list(+tag/query)/drop + pulse/memorySurface/memory-status(lessonHeaders/latest)/handoff count 를 `_loadLessons` 로 전환.
15
+ 5. **비파괴/백필**: 기존 MD-only → 첫 write 시 MD→JSON 백필(무손실). 읽기 무부작용. fuzzy restore/retro/today-heuristic 은 projection MD 유지(일관).
16
+
17
+ ### 검증 (하네스 독립)
18
+ - **변경 범위**: bin/harness.js + lib/pure-utils.js 만 (VERSION/e2e/README/CHANGELOG/package/catalogs 미변경 — 명세 준수).
19
+ - **무결성**: node -c ×2 · selftest 88/88 PASS (Codex 가 self-reference-safe 케이스 추가) · **E2E 285/285 PASS** (회귀 0).
20
+ - **동작 실측**: save→lessons.json(tag=security·null)+lessons.md(projection, Tag 줄) · list 2·tag filter 1 · drop→1건+archive · memory-status L count=1 · **백필**: MD-only→list 2(읽기 무부작용)·첫 save 시 기존2+신규1=3 보존 · round-trip 멱등.
21
+
22
+ ## 1.9.339 — 2026-06-05 — UR-0053: decisions canonical = JSON, MD = projection (아키텍처)
23
+
24
+ **🏛 상태 저장 단일 진실소스 — decisions 를 canonical JSON 으로 전환, decisions.md 는 projection(렌더 뷰). count drift 근본 해소.** (UR-0053 full, "UR-0053+UR-0025 둘 다 진행" 중 Round B)
25
+
26
+ ### 배경
27
+ 설치리뷰 med 전략(Codex#6·Opus·Sonnet): MD 파싱이 count drift(`context.memory.decisions=2 실제1`)·field 오파싱(Alternatives→Impact). 1.9.320 부분수정(template 제외)에 이은 **full**: JSON 을 canonical 로, MD 를 projection 으로 분리해 audit/health/context/MCP 가 단일 진실소스를 보게. (사용자 범위 선택: "decisions만 canonical JSON, 비파괴")
28
+
29
+ ### 구현 (UR-0053 — 비파괴 아키텍처)
30
+ 1. **canonical write path**: `decision add`/`drop` → `decisions.json`(canonical) + `decisions.md`(projection) 동시 기록. `_saveDecisions(root, arr)`.
31
+ 2. **순수 파서/렌더** (lib/pure-utils): `_parseDecisionBlock`(블록→객체, 빈 값 null 정규화), `_decisionsFromMd`(MD→객체배열, template 제외), `_renderDecisionsMd`(객체배열→MD projection, template preamble 보존) — render↔parse round-trip 멱등.
32
+ 3. **canonical 로더** `_loadDecisions(root)`: decisions.json 우선 → 없으면 decisions.md 파싱(읽기 무부작용). 손상 JSON 시 MD fallback.
33
+ 4. **단일 진실소스 라우팅**: context/health/handoff/pulse/memory-status/session-close/round-history 의 decision count·list·recent 를 모두 `_loadDecisions` 로 전환. MCP `decision_list` 포함.
34
+ 5. **비파괴/백필**: 기존 MD-only 프로젝트 → 첫 write 시 MD→JSON 백필(데이터 무손실). 읽기 명령은 부작용 0. retro 집계·fuzzy restore·today-heuristic 은 faithful projection MD 를 계속 읽어 일관.
35
+
36
+ ### 검증
37
+ - **selftest 87/87 PASS** · **E2E 284/284 PASS** (회귀 0).
38
+ - 실측: add→decisions.json(canonical, alt=Mongo·빈값 null)+decisions.md(projection, template 보존) · list/context.memory.decisions=2 일치 · drop→1건+archive 보존 · **백필**: MD-only→list 2건(template 제외)·읽기 무부작용·첫 add 시 기존2+신규1=3 보존 · render↔parse round-trip 멱등.
39
+
3
40
  ## 1.9.338 — 2026-06-05 — UR-0025(심층): i18n STRINGS 서브시스템 분리
4
41
 
5
42
  **🧩 i18n 문자열 서브시스템의 핵심(ko/en catalog + 순수 조회)을 모듈로 분리 — 동형 추출 6번째.** (UR-0025 심층, "UR-0053+UR-0025 둘 다 진행" 중 Round A)
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.338-green)]() [![tests](https://img.shields.io/badge/e2e-283%2F283-success)]() [![selftest](https://img.shields.io/badge/selftest-86-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-83-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.340-green)]() [![tests](https://img.shields.io/badge/e2e-285%2F285-success)]() [![selftest](https://img.shields.io/badge/selftest-88-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-83-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.338 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
474
+ 이 프로젝트는 Leerness v1.9.340 하네스를 사용합니다. 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.338는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **83개 도구**를 노출:
528
+ Leerness v1.9.340는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **83개 도구**를 노출:
529
529
 
530
530
  ```jsonc
531
531
  // 카테고리별
@@ -546,7 +546,7 @@ Leerness v1.9.338는 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.338)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
549
+ 현재 누적: **70 라운드 (1.9.40 → 1.9.340)** · 매 라운드 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.338: 2026-06-05
587
+ Last synced by Leerness v1.9.340: 2026-06-05
588
588
  <!-- leerness:project-readme:end -->
589
589
 
package/bin/harness.js CHANGED
@@ -19,13 +19,14 @@ const { _isSecretKey, compareVer, parseHarnessVersion, _classifyCJK, _riskLabel,
19
19
  _parseLessonEntries, _matchConstraints, _matchDomain,
20
20
  _detectLspLang, _matchLspSymbols,
21
21
  _detectOptimism: _puDetectOptimism, _computeConfidence: _puComputeConfidence,
22
- _personaSummaries, _translate } = require('../lib/pure-utils'); // 1.9.318~338 (UR-0025): 순수 유틸 모듈 분리
22
+ _personaSummaries, _translate,
23
+ _decisionsFromMd, _renderDecisionsMd, _renderLessonsMd } = require('../lib/pure-utils'); // 1.9.318~339 (UR-0025/0053): 순수 유틸 모듈 분리
23
24
  // 1.9.304 (UR-0025): 순수 분석/검증 함수 모듈 분리.
24
25
  const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInGit, _epistemicHonestyCheck } = require('../lib/analyzers');
25
26
  // 1.9.295 (UR-0025 4단계): 정적 데이터 카탈로그 모듈 분리 (비파괴, require-based).
26
27
  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 } = require('../lib/catalogs'); // 1.9.338 (UR-0025): i18n STRINGS catalog 분리
27
28
 
28
- const VERSION = '1.9.338';
29
+ const VERSION = '1.9.340';
29
30
 
30
31
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
31
32
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -3044,7 +3045,7 @@ function _selfTestCases() {
3044
3045
  { name: '텔레메트리 분리: 내부 auto-call(LEERNESS_INTERNAL) usage 집계 제외 + 주요 spawn 마킹 (UR-0051 설치리뷰 1.9.317)', run: () => { const src = read(__filename); const guard = src.includes("process.env.LEERNESS_INTERNAL !== '1'"); const marked = (src.match(/LEERNESS_INTERNAL: '1'/g) || []).length >= 10; const reviewMarked = /'review-request'[\s\S]{0,200}LEERNESS_INTERNAL: '1'/.test(src); return guard && marked && reviewMarked; } },
3045
3046
  { name: 'lib/pure-utils: HTML 파싱 유틸 3종 모듈 분리 + 동작 + 인라인 제거 (UR-0025 1.9.318)', run: () => { const m = require('../lib/pure-utils'); const fnOk = typeof m._htmlToText === 'function' && typeof m._extractTitle === 'function' && typeof m._extractLinks === 'function'; const work = m._htmlToText('<p>Hello <b>World</b></p>') === 'Hello World' && m._extractTitle('<html><title>My &amp; Page</title></html>') === 'My & Page' && m._extractLinks('<a href="/a">A</a><a href="https://other.com/b">B</a>', 'https://x.com/').length === 1; const moved = m._htmlToText === _htmlToText && !/^function _htmlToText\(html\) \{/m.test(read(__filename)); return fnOk && work && moved; } },
3046
3047
  { name: 'MCP ToolRegistry 일치성: 모든 도구 def 가 dispatch case 보유 + 고아 case 0 + requiredTier 완비 (UR-0044 1.9.319)', run: () => { const tools = require('../lib/mcp-tools'); const src = read(__filename); const missing = tools.filter(t => !src.includes("case '" + t.name + "':")); const cases = [...src.matchAll(/case '(leerness_[a-z_]+)':/g)].map(m => m[1]); const defNames = new Set(tools.map(t => t.name)); const orphans = [...new Set(cases)].filter(c => !defNames.has(c)); const tierOk = tools.every(t => typeof t.requiredTier === 'string' && PERMISSION_TIERS.includes(t.requiredTier)); return tools.length >= 83 && missing.length === 0 && orphans.length === 0 && tierOk; } },
3047
- { name: 'count drift 수정: _countDatedBlocks 코드펜스(템플릿) 제외 + 카운트 사이트 단일화 (UR-0053 1.9.320)', run: () => { const f = _countDatedBlocks; const withTpl = '# D\n\n```md\n### 2026-01-01 — Decision 제목\n- Decision:\n```\n\n### 2026-06-04 — 실제\n- Decision: 실제\n'; const c1 = f(withTpl) === 1; const c0 = f('```md\n### 2026-01-01 — x\n```\n') === 0; const c2 = f('### 2026-01-01 — A\n### 2026-02-02 — B\n') === 2; const src = read(__filename); const wired = src.includes('_countDatedBlocks(read(decisionsPath(root)))') && src.includes('_countDatedBlocks(read(lessonsPath(root)))') && src.includes('_countDatedBlocks(dtext)') && !src.includes('read(decisionsPath(root)).' + 'match(/^### '); return typeof f === 'function' && c1 && c0 && c2 && wired; } },
3048
+ { name: 'count drift 수정: _countDatedBlocks 코드펜스(템플릿) 제외 (UR-0053 1.9.320; memory count canonical 전환 후 legacy parser 보존)', run: () => { const f = _countDatedBlocks; const withTpl = '# D\n\n```md\n### 2026-01-01 — Decision 제목\n- Decision:\n```\n\n### 2026-06-04 — 실제\n- Decision: 실제\n'; const c1 = f(withTpl) === 1; const c0 = f('```md\n### 2026-01-01 — x\n```\n') === 0; const c2 = f('### 2026-01-01 — A\n### 2026-02-02 — B\n') === 2; return typeof f === 'function' && c1 && c0 && c2; } },
3048
3049
  { name: 'decision/lesson 필드 파싱: 빈 필드가 다음 줄로 안 샘 ([ \\t]* 사용) (UR-0053 1.9.321)', run: () => { const block = '### 2026-06-05 — X\n- Decision: X\n- Reason: r\n- Alternatives: \n- Impact: 보안\n'; const alt = block.match(/- Alternatives:[ \t]*(.+)/); const imp = block.match(/- Impact:[ \t]*(.+)/); const altNoBleed = !alt || !/Impact/.test(alt[1]); const impOk = !!imp && imp[1].trim() === '보안'; const src = read(__filename); const fixedOk = src.includes('- Alternatives:[ \\t]*(.+)') && src.includes('- Lesson:[ \\t]*(.+)') && src.includes('- Impact:[ \\t]*(.+)'); return altNoBleed && impOk && fixedOk; } },
3049
3050
  { name: 'MCP handler 통합: _mcpToCliArgs 단일 함수 + mcpServeCmd 호출 + 인라인 switch 단일화 (UR-0044 1.9.322)', run: () => { const src = read(__filename); const fnDef = /function _mcpToCliArgs\(name, args, targetPath\) \{/.test(src); const called = src.includes('cliArgs = _mcpToCliArgs(name, args, targetPath)'); const switchCount = (src.match(/switch \(name\) \{/g) || []).length; const nullPath = src.includes('if (cliArgs === null) return send('); return fnDef && called && switchCount === 1 && nullPath; } },
3050
3051
  { name: 'fresh-init gate 통과: lazy detect 부재신호(handoff/test/progress) done-work 없으면 비차단 (UR-0054 ⑥ 1.9.323)', run: () => { const src = read(__filename); const doneWork = src.includes("const _hasDoneWork = rows.some(r => /^(done|completed|verified)$/i.test(r.status))"); const advisory = src.includes('_ADVISORY_KINDS') && src.includes("'handoff_never_generated'") && src.includes("'no_test_run'"); const blocking = src.includes('const blockingIssues = Math.max(0, issues - advisoryCount)') && src.includes('if (blockingIssues > 0) process.exitCode = 1'); return doneWork && advisory && blocking; } },
@@ -3056,13 +3057,15 @@ function _selfTestCases() {
3056
3057
  { name: 'lib/pure-utils: roadmap MD 파서 분리(_roadmapMapStatus/Milestones/Tokens) + 인라인 제거 (UR-0025 1.9.329)', run: () => { const m = require('../lib/pure-utils'); const work = m._roadmapMapStatus('REQUESTED') === 'planned' && m._roadmapMapStatus('done') === 'done' && m._roadmapParseMilestones('### M-0001. 로그인\nStatus: in-progress\nProgress: 40%')[0].progress === 40 && m._roadmapParseTokens('| color | #fff |').color === '#fff'; const src = read(__filename); const moved = m._roadmapMapStatus === _roadmapMapStatus && !/^function _roadmapMapStatus\(/m.test(src) && !/^function _roadmapParseMilestones\(/m.test(src) && !/^function _roadmapParseTokens\(/m.test(src); return work && moved; } },
3057
3058
  { name: 'lib/pure-utils: project-brief config 분리(_BRIEF_FIELDS/_briefFilled) + 인라인 제거 (UR-0025 1.9.330)', run: () => { const m = require('../lib/pure-utils'); const cfgOk = Array.isArray(m._BRIEF_FIELDS) && m._BRIEF_FIELDS.length === 10 && m._BRIEF_FIELDS[0].key === 'intro'; const work = m._briefFilled({ intro: 'x', features: ['a'] }) === 2 && m._briefFilled({}) === 0; const src = read(__filename); const moved = m._briefFilled === _briefFilled && m._BRIEF_FIELDS === _BRIEF_FIELDS && !/^const _BRIEF_FIELDS = \[/m.test(src) && !/^function _briefFilled\(/m.test(src); return cfgOk && work && moved; } },
3058
3059
  { name: 'lib/pure-utils: brief 빌더 분리(_briefReadmeBlock/_briefBlueprint + BRIEF 마커, VERSION 주입) (UR-0025 1.9.331)', run: () => { const m = require('../lib/pure-utils'); const fnOk = typeof m._briefReadmeBlock === 'function' && typeof m._briefBlueprint === 'function' && m.BRIEF_START.includes('project-brief:start'); const b = { project: 'X', intro: 'i', features: ['f1'] }; const rb = m._briefReadmeBlock(b); const bp = m._briefBlueprint(b, '9.9.9'); const work = rb.includes(m.BRIEF_START) && rb.includes(m.BRIEF_END) && /f1/.test(rb) && /Blueprint/.test(bp) && /leerness v9\.9\.9/.test(bp); const src = read(__filename); const moved = m._briefBlueprint === _briefBlueprint && m.BRIEF_START === BRIEF_START && !/^function _briefReadmeBlock\(/m.test(src) && !/^function _briefBlueprint\(/m.test(src) && !/^const BRIEF_START =/m.test(src); return fnOk && work && moved; } },
3059
- { name: 'lib/pure-utils: lessons.md 파서 분리(_parseLessonEntries) + 인라인 제거 (UR-0025 1.9.332)', run: () => { const m = require('../lib/pure-utils'); const r = m._parseLessonEntries('### 2026-06-05\n- Lesson: A\n- Tag: t\n\n### 2026-06-04\n- Lesson: B'); const work = r.length === 2 && r[0].text === 'A' && r[0].tag === 't' && r[1].tag === null && r[0].date === '2026-06-05'; const src = read(__filename); const moved = m._parseLessonEntries === _parseLessonEntries && !/^function _parseLessonEntries\(/m.test(src) && src.includes('for (const lesson of _parseLessonEntries(text))'); return work && moved; } },
3060
+ { name: 'lib/pure-utils: lessons.md 파서 분리(_parseLessonEntries) + 인라인 제거 (UR-0025 1.9.332)', run: () => { const m = require('../lib/pure-utils'); const r = m._parseLessonEntries('### 2026-06-05\n- Lesson: A\n- Tag: t\n\n### 2026-06-04\n- Lesson: B'); const work = r.length === 2 && r[0].text === 'A' && r[0].tag === 't' && r[1].tag === null && r[0].date === '2026-06-05'; const src = read(__filename); const moved = m._parseLessonEntries === _parseLessonEntries && !/^function _parseLessonEntries\(/m.test(src) && src.includes('_parseLessonEntries(read(mp))'); return work && moved; } },
3060
3061
  { name: 'UR-0025 심층: constraints catalog→lib/catalogs + _matchConstraints→pure-utils 분리 (1.9.333)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = c._DEFAULT_PLATFORM_CONSTRAINTS && Object.keys(c._DEFAULT_PLATFORM_CONSTRAINTS.platforms).length === 6 && !!c._DEFAULT_PLATFORM_CONSTRAINTS.platforms.stripe; const r = m._matchConstraints(c._DEFAULT_PLATFORM_CONSTRAINTS, 'stripe 결제'); const work = r.matched.length === 1 && r.matched[0].platform === 'stripe' && r.totalPlatforms === 6 && m._matchConstraints(null, 'x').matched.length === 0; const src = read(__filename); const moved = _DEFAULT_PLATFORM_CONSTRAINTS === c._DEFAULT_PLATFORM_CONSTRAINTS && _matchConstraints === m._matchConstraints && !/const _DEFAULT_PLATFORM_CONSTRAINTS = \{/.test(src) && src.includes('_matchConstraints(_loadPlatformConstraints(root), text)'); return catOk && work && moved; } },
3061
3062
  { name: 'UR-0025 심층(Codex 위임·검증): intent domain catalog→lib/catalogs + _matchDomain→pure-utils 분리 (1.9.334)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = c._DEFAULT_DOMAIN_CATALOG && Object.keys(c._DEFAULT_DOMAIN_CATALOG.domains).length === 5 && !!c._DEFAULT_DOMAIN_CATALOG.domains.game; const r = m._matchDomain(c._DEFAULT_DOMAIN_CATALOG, 'unity 게임'); const work = r.domain === 'game' && Array.isArray(r.components) && m._matchDomain(c._DEFAULT_DOMAIN_CATALOG, 'zzz없음').domain === null && m._matchDomain(null, 'x').domain === null; const src = read(__filename); const moved = _DEFAULT_DOMAIN_CATALOG === c._DEFAULT_DOMAIN_CATALOG && _matchDomain === m._matchDomain && !/const _DEFAULT_DOMAIN_CATALOG = \{/.test(src) && src.includes('_matchDomain(_loadDomainCatalog(root), text)'); return catOk && work && moved; } },
3062
3063
  { name: 'UR-0025 심층: LSP catalog→lib/catalogs(_LSP_LANG_PATTERNS) + _detectLspLang/_matchLspSymbols→pure-utils 분리 (1.9.335)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = c._LSP_LANG_PATTERNS && Object.keys(c._LSP_LANG_PATTERNS).length === 5 && Array.isArray(c._LSP_LANG_PATTERNS.javascript); const langOk = m._detectLspLang('a.py') === 'python' && m._detectLspLang('b.go') === 'go' && m._detectLspLang('c.md') === 'javascript'; const sy = m._matchLspSymbols(c._LSP_LANG_PATTERNS, 'function alpha(){}\nclass Beta{}', 'javascript'); const work = sy.length === 2 && sy[0].name === 'alpha' && sy[0].kind === 'function' && sy[1].kind === 'class' && m._matchLspSymbols(null, 'x', 'javascript').length === 0; const src = read(__filename); const moved = _LSP_LANG_PATTERNS === c._LSP_LANG_PATTERNS && _detectLspLang === m._detectLspLang && _matchLspSymbols === m._matchLspSymbols && !/const _LSP_LANG_PATTERNS = \{/.test(src) && !/function _detectLspLang\(/.test(src); return catOk && langOk && work && moved; } },
3063
3064
  { name: 'UR-0025 심층(Codex 위임·검증): anti-laziness catalog→lib/catalogs(OPTIMISM_PATTERNS) + optimism 순수로직→pure-utils 분리 (1.9.336)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = Array.isArray(c.OPTIMISM_PATTERNS) && c.OPTIMISM_PATTERNS.length === 10 && c.OPTIMISM_PATTERNS[0].kind === 'API'; const ev = 'API 호출 완료, POST /users'; const sus = m._detectOptimism(c.OPTIMISM_PATTERNS, ev, 'function x(){}'); const conf = m._computeConfidence(c.OPTIMISM_PATTERNS, ev, 'function x(){}'); const work = sus.some(s => s.kind === 'API' && s.severity === 'high') && conf < 0.5 && m._computeConfidence(c.OPTIMISM_PATTERNS, '정리함', 'x') === 1 && m._detectOptimism(null, ev, 'x').length === 0 && m._extractUrlClaims('POST /a').length === 1 && m._verifyUrlClaim({ path: '/a' }, 'has /a') === true; const src = read(__filename); const moved = OPTIMISM_PATTERNS === c.OPTIMISM_PATTERNS && _puDetectOptimism === m._detectOptimism && !/const OPTIMISM_PATTERNS = \[/.test(src) && !/function _extractUrlClaims\(/.test(src); return catOk && work && moved; } },
3064
3065
  { name: 'UR-0025 심층: persona catalog→lib/catalogs(BUILT_IN_PERSONAS) + _personaSummaries→pure-utils 분리 (1.9.337)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = c.BUILT_IN_PERSONAS && Object.keys(c.BUILT_IN_PERSONAS).length === 5 && c.BUILT_IN_PERSONAS.security && typeof c.BUILT_IN_PERSONAS.security.body === 'string'; const sm = m._personaSummaries(c.BUILT_IN_PERSONAS); const work = Array.isArray(sm) && sm.length === 5 && sm[0].id === 'security' && sm[0].body === undefined && typeof sm[0].name === 'string' && m._personaSummaries(null).length === 0; const src = read(__filename); const moved = BUILT_IN_PERSONAS === c.BUILT_IN_PERSONAS && _personaSummaries === m._personaSummaries && !/const BUILT_IN_PERSONAS = \{/.test(src); return catOk && work && moved; } },
3065
3066
  { name: 'UR-0025 심층: i18n STRINGS catalog→lib/catalogs + _translate→pure-utils 분리 (1.9.338)', run: () => { const c = require('../lib/catalogs'); const m = require('../lib/pure-utils'); const catOk = c.STRINGS && typeof c.STRINGS['common.ready'] === 'object' && c.STRINGS['common.ready'].en === 'Ready'; const work = m._translate(c.STRINGS, 'common.ready', 'en') === 'Ready' && m._translate(c.STRINGS, 'common.ready', 'ko') === '준비 완료' && m._translate(c.STRINGS, 'no.such.key', 'en') === 'no.such.key' && m._translate(null, 'x', 'ko') === 'x' && m._translate({ k: { ko: '케이' } }, 'k', 'en') === '케이'; const src = read(__filename); const moved = STRINGS === c.STRINGS && _translate === m._translate && !/const STRINGS = \{/.test(src); return catOk && work && moved; } },
3067
+ { name: 'UR-0053: decisions canonical JSON 레이어(_loadDecisions/_saveDecisions/decisionsJsonPath) + pure 파서/렌더 round-trip (1.9.339)', run: () => { const m = require('../lib/pure-utils'); const md = '# Decisions\n\n### 2026-06-05 — A\n- Decision: a\n- Reason: r\n- Alternatives: alt\n- Impact: imp\n\n### 2026-06-04 — B\n- Decision: b\n- Alternatives:\n'; const objs = m._decisionsFromMd(md); const parseOk = objs.length === 2 && objs[0].alternatives === 'alt' && objs[0].impact === 'imp' && objs[1].alternatives === null && objs[1].title === 'B'; const rt = m._decisionsFromMd(m._renderDecisionsMd(objs)); const rtOk = JSON.stringify(rt) === JSON.stringify(objs); const tplOk = m._decisionsFromMd(m._renderDecisionsMd([])).length === 0; const layerOk = typeof _loadDecisions === 'function' && typeof _saveDecisions === 'function' && typeof decisionsJsonPath === 'function' && _decisionsFromMd === m._decisionsFromMd; return parseOk && rtOk && tplOk && layerOk; } },
3068
+ { name: 'UR-0058: lessons canonical JSON 레이어(_loadLessons/_saveLessons/lessonsJsonPath) + pure 파서/렌더 round-trip', run: () => { const m = require('../lib/pure-utils'); const objs = [{ date: '2026-06-05', text: 'A', tag: 't' }, { date: '2026-06-04', text: 'B', tag: null }]; const rt = m._parseLessonEntries(m._renderLessonsMd(objs)); const rtOk = JSON.stringify(rt) === JSON.stringify(objs); const tplOk = m._parseLessonEntries(m._renderLessonsMd([])).length === 0; const layerOk = typeof _loadLessons === 'function' && typeof _saveLessons === 'function' && typeof lessonsJsonPath === 'function' && _renderLessonsMd === m._renderLessonsMd; return rtOk && tplOk && layerOk; } },
3066
3069
  { name: 'VERSION 형식 (x.y.z)', run: () => /^\d+\.\d+\.\d+$/.test(VERSION) }
3067
3070
  ];
3068
3071
  }
@@ -3543,17 +3546,11 @@ function pulseCmd(root) {
3543
3546
  try {
3544
3547
  const rows = readProgressRows(root);
3545
3548
  const tasksInProgress = rows.filter(r => r.status === 'in-progress').length;
3546
- const dm = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
3547
- const decisionBlocks = _extractDecisionBlocks(dm);
3548
- const decisionCount = decisionBlocks.map(b => {
3549
- const m = b.match(/^### (.+)$/m);
3550
- return m ? '### ' + m[1].trim() : null;
3551
- }).filter(Boolean).length;
3549
+ const decisionCount = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
3552
3550
  const rulesActive = readRules(root).filter(r => r.status === 'active').length;
3553
3551
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
3554
3552
  const milestonesCnt = (planText.match(/^### M-\d{4}\./gm) || []).length;
3555
- const lm = exists(lessonsPath(root)) ? read(lessonsPath(root)) : '';
3556
- const lessonsCount = (lm.match(/^### \d{4}-\d{2}-\d{2}[^\n]*/gm) || []).length;
3553
+ const lessonsCount = _loadLessons(root).length;
3557
3554
  data.memorySurface = `T${tasksInProgress}/D${decisionCount}/R${rulesActive}/P${milestonesCnt}/L${lessonsCount}`;
3558
3555
  } catch {}
3559
3556
  // 마일스톤 + ETA
@@ -4430,9 +4427,9 @@ function _buildAutoResumePlan(root, opts) {
4430
4427
  // memory surface counts
4431
4428
  const memorySurface = {
4432
4429
  tasksInProgress: rows.filter(r => r.status === 'in-progress').length,
4433
- decisions: exists(decisionsPath(root)) ? _countDatedBlocks(read(decisionsPath(root))) : 0,
4430
+ decisions: _loadDecisions(root).length, // 1.9.339 (UR-0053): canonical 단일 진실소스
4434
4431
  rulesActive: readRules(root).filter(r => r.status === 'active').length,
4435
- lessons: exists(lessonsPath(root)) ? _countDatedBlocks(read(lessonsPath(root))) : 0
4432
+ lessons: _loadLessons(root).length
4436
4433
  };
4437
4434
  return {
4438
4435
  nextRoundVersion: opts.nextRoundVersion || `next after ${currentVersion}`,
@@ -5860,8 +5857,41 @@ const evidencePath = root => path.join(root, '.harness/review-evidence.md');
5860
5857
  const handoffPath = root => path.join(root, '.harness/session-handoff.md');
5861
5858
  const currentStatePath = root => path.join(root, '.harness/current-state.md');
5862
5859
  const decisionsPath = root => path.join(root, '.harness/decisions.md');
5860
+ // 1.9.339 (UR-0053): decisions canonical = JSON, decisions.md 는 projection.
5861
+ const decisionsJsonPath = root => path.join(root, '.harness/decisions.json');
5862
+ // canonical 로더 — decisions.json(우선) → decisions.md 파싱 fallback. 읽기 안전(부작용 없음). audit/health/context/MCP 단일 진실소스.
5863
+ function _loadDecisions(root) {
5864
+ const jp = decisionsJsonPath(root);
5865
+ if (exists(jp)) {
5866
+ try { const arr = JSON.parse(read(jp)); if (Array.isArray(arr)) return arr; } catch {} // 손상 시 MD fallback
5867
+ }
5868
+ const mp = decisionsPath(root);
5869
+ return exists(mp) ? _decisionsFromMd(read(mp)) : [];
5870
+ }
5871
+ // canonical 저장 — decisions.json(canonical) + decisions.md(projection) 동시 기록 (단일 진실소스 write path).
5872
+ function _saveDecisions(root, decisions) {
5873
+ const arr = Array.isArray(decisions) ? decisions : [];
5874
+ mkdirp(path.dirname(decisionsJsonPath(root)));
5875
+ writeUtf8(decisionsJsonPath(root), JSON.stringify(arr, null, 2) + '\n');
5876
+ writeUtf8(decisionsPath(root), _renderDecisionsMd(arr));
5877
+ }
5863
5878
  // 1.9.112: 전용 lessons.md (Memory Write Surface 5번째)
5864
5879
  const lessonsPath = root => path.join(root, '.harness/lessons.md');
5880
+ const lessonsJsonPath = root => path.join(root, '.harness/lessons.json');
5881
+ function _loadLessons(root) {
5882
+ const jp = lessonsJsonPath(root);
5883
+ if (exists(jp)) {
5884
+ try { const arr = JSON.parse(read(jp)); if (Array.isArray(arr)) return arr; } catch {}
5885
+ }
5886
+ const mp = lessonsPath(root);
5887
+ return exists(mp) ? _parseLessonEntries(read(mp)) : [];
5888
+ }
5889
+ function _saveLessons(root, lessons) {
5890
+ const arr = Array.isArray(lessons) ? lessons : [];
5891
+ mkdirp(path.dirname(lessonsJsonPath(root)));
5892
+ writeUtf8(lessonsJsonPath(root), JSON.stringify(arr, null, 2) + '\n');
5893
+ writeUtf8(lessonsPath(root), _renderLessonsMd(arr));
5894
+ }
5865
5895
 
5866
5896
  function nextId(root, prefix) {
5867
5897
  const sources = [planPath(root), progressPath(root)].map(p => exists(p) ? read(p) : '').join('\n');
@@ -6212,13 +6242,9 @@ function memoryStatusCmd(root, opts = {}) {
6212
6242
  for (const r of rows) tasksByStatus[r.status] = (tasksByStatus[r.status] || 0) + 1;
6213
6243
  const tasksTotal = rows.length;
6214
6244
  const tasksInProgress = tasksByStatus['in-progress'] || 0;
6215
- // Decisions (1.9.114: _extractDecisionBlocks 사용template/code 블록 제외)
6216
- const dm = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
6217
- const decisionBlocks = _extractDecisionBlocks(dm);
6218
- const decisionHeaders = decisionBlocks.map(b => {
6219
- const m = b.match(/^### (.+)$/m);
6220
- return m ? '### ' + m[1].trim() : null;
6221
- }).filter(Boolean);
6245
+ // Decisions (1.9.339 UR-0053: canonical _loadDecisionsJSON 단일 진실소스, MD projection fallback)
6246
+ const _decs = _loadDecisions(root);
6247
+ const decisionHeaders = _decs.map(d => '### ' + (d.date ? d.date + ' — ' + d.title : d.title));
6222
6248
  const decisionLatest = decisionHeaders.length ? decisionHeaders[decisionHeaders.length - 1].replace(/^### /, '') : null;
6223
6249
  // Rules
6224
6250
  const rules = readRules(root);
@@ -6229,10 +6255,10 @@ function memoryStatusCmd(root, opts = {}) {
6229
6255
  const milestones = (planText.match(/^### M-\d{4}\./gm) || []).length;
6230
6256
  // plan milestones in-progress: progress-tracker에서 plan:M-XXXX evidence 있고 in-progress 인 row
6231
6257
  const planInProgress = rows.filter(r => /plan:M-/.test(r.evidence || '') && r.status === 'in-progress').length;
6232
- // Lessons (lessons.md)
6233
- const lm = exists(lessonsPath(root)) ? read(lessonsPath(root)) : '';
6234
- const lessonHeaders = lm.match(/^### \d{4}-\d{2}-\d{2}[^\n]*/gm) || [];
6235
- const lessonsLatest = lessonHeaders.length ? (lm.split('\n').filter(l => /- Lesson:/.test(l)).pop() || '').replace(/^- Lesson:\s*/, '').slice(0, 100) : null;
6258
+ // Lessons (UR-0058: canonical _loadLessons — JSON 단일 진실소스, MD projection fallback)
6259
+ const _les = _loadLessons(root);
6260
+ const lessonHeaders = _les.map(l => '### ' + l.date);
6261
+ const lessonsLatest = _les.length ? (_les[_les.length - 1].text || '').slice(0, 100) : null;
6236
6262
  // 1.9.130: DELETE 5종 archive entry counts
6237
6263
  const archiveCounts = { decisions: 0, lessons: 0, plan: 0, total: 0 };
6238
6264
  try {
@@ -6433,20 +6459,19 @@ function lessonListCmd(root, opts = {}) {
6433
6459
  // 1.9.139: --query 필터 (lesson text case-insensitive 매칭)
6434
6460
  const queryFilter = arg('--query', null);
6435
6461
  const queryRe = queryFilter ? new RegExp(queryFilter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') : null;
6436
- const lp = lessonsPath(root);
6437
- if (!exists(lp)) {
6462
+ const all = _loadLessons(root);
6463
+ if (!all.length) {
6438
6464
  if (jsonMode) {
6439
6465
  const empty = { version: VERSION, root, total: 0, lessons: [], tag: tagFilter };
6440
6466
  if (queryFilter) empty.query = queryFilter;
6441
6467
  process.stdout.write(JSON.stringify(empty, null, 2) + '\n');
6442
6468
  return;
6443
6469
  }
6444
- return ok('lessons.md 없음 — leerness lesson save "<text>" 로 첫 lesson 영구화');
6470
+ return ok('lessons 비어있음 — leerness lesson save "<text>" 로 첫 lesson 영구화');
6445
6471
  }
6446
- const text = read(lp);
6447
6472
  const lessons = [];
6448
- // 1.9.332 (UR-0025): 블록→엔트리 파싱은 순수 _parseLessonEntries (lib/pure-utils), 필터는 명령에 유지.
6449
- for (const lesson of _parseLessonEntries(text)) {
6473
+ // UR-0058: lessons.json canonical 로드(JSON 우선, MD fallback). tag/query 필터는 명령에 유지.
6474
+ for (const lesson of all) {
6450
6475
  if (tagFilter && lesson.tag !== tagFilter) continue;
6451
6476
  // 1.9.139: query 필터 — lesson text 또는 tag 매칭
6452
6477
  if (queryRe && !queryRe.test(lesson.text) && !queryRe.test(lesson.tag || '')) continue;
@@ -6476,32 +6501,24 @@ function lessonListCmd(root, opts = {}) {
6476
6501
  function lessonDropCmd(root, target) {
6477
6502
  root = absRoot(root);
6478
6503
  if (!target) return fail('lesson drop <date|text-substring> 필요. 예: leerness lesson drop "2026-05-20" 또는 leerness lesson drop "JWT"');
6479
- const lp = lessonsPath(root);
6480
- if (!exists(lp)) return fail('lessons.md 없음');
6481
- const text = read(lp);
6482
- const blocks = text.split(/\n(?=### )/);
6483
- let removed = 0;
6484
6504
  const kept = [];
6485
- for (const b of blocks) {
6486
- if (!b.startsWith('### ')) { kept.push(b); continue; }
6487
- // date 매칭 (정확) 또는 text substring (lesson content)
6488
- const dateMatch = b.match(/^### (\d{4}-\d{2}-\d{2})/);
6489
- const lessonMatch = b.match(/- Lesson:[ \t]*(.+)/);
6490
- const isDateTarget = dateMatch && dateMatch[1] === target;
6491
- const isTextTarget = lessonMatch && lessonMatch[1].includes(target);
6492
- if (isDateTarget || isTextTarget) {
6493
- removed++;
6494
- // archive 보존 lessons.archive.md 에 추가
6495
- const archivePath = path.join(root, '.harness/lessons.archive.md');
6496
- const archiveHeader = exists(archivePath) ? '' : '# Lessons archive\n\n';
6497
- append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${target}")\n${b}\n`);
6498
- continue;
6499
- }
6500
- kept.push(b);
6501
- }
6502
- if (removed === 0) return fail(`매칭 lesson 없음: "${target}"`);
6503
- writeUtf8(lp, kept.join('\n'));
6504
- ok(`lesson dropped: ${removed}건 (보존: .harness/lessons.archive.md)`);
6505
+ const removed = [];
6506
+ const all = _loadLessons(root);
6507
+ if (!all.length) return fail('lessons 없음');
6508
+ for (const l of all) {
6509
+ if (l.date === target || (l.text || '').includes(target)) removed.push(l);
6510
+ else kept.push(l);
6511
+ }
6512
+ if (!removed.length) return fail(`매칭 lesson 없음: "${target}"`);
6513
+ // archive 보존 — lessons.archive.md 에 projection MD 블록 형태로 추가
6514
+ const archivePath = path.join(root, '.harness/lessons.archive.md');
6515
+ const archiveHeader = exists(archivePath) ? '' : '# Lessons archive\n\n';
6516
+ const archiveBlocks = removed.map(l =>
6517
+ `\n### ${l.date}\n- Lesson: ${l.text}\n${l.tag ? `- Tag: ${l.tag}\n` : ''}`
6518
+ ).join('');
6519
+ append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${target}")\n${archiveBlocks}\n`);
6520
+ _saveLessons(root, kept);
6521
+ ok(`lesson dropped: ${removed.length}건 (보존: .harness/lessons.archive.md)`);
6505
6522
  _autoRoadmap(absRoot(root), 'data-change');
6506
6523
  }
6507
6524
 
@@ -6510,15 +6527,10 @@ function lessonSave(root, text) {
6510
6527
  root = absRoot(root);
6511
6528
  if (!_requireInit(root, 'lesson save')) return; // 1.9.311 (UR-0047): init 가드
6512
6529
  if (!text) return fail('lesson text required. 예: leerness lesson save "JWT는 refresh token도 짧게 (15분 권장)"');
6513
- const lp = lessonsPath(root);
6514
6530
  const tag = arg('--tag', '');
6515
- const tagBlock = tag ? `- Tag: ${tag}\n` : '';
6516
- const block = `\n### ${today()}\n- Lesson: ${text}\n${tagBlock}`;
6517
- if (!exists(lp)) {
6518
- writeUtf8(lp, `# Lessons (1.9.112)\n\n과거 실수/통찰/패턴 영구 기록 — handoff 자동 회수와 통합.\n${block}`);
6519
- } else {
6520
- append(lp, block);
6521
- }
6531
+ const all = _loadLessons(root);
6532
+ all.push({ date: today(), text, tag: tag || null });
6533
+ _saveLessons(root, all);
6522
6534
  ok(`lesson saved`);
6523
6535
  _autoRoadmap(absRoot(root), 'data-change');
6524
6536
  }
@@ -6530,46 +6542,22 @@ function decisionListCmd(root, opts = {}) {
6530
6542
  // 1.9.139: --query 필터 (title/decision/reason case-insensitive 매칭)
6531
6543
  const queryFilter = arg('--query', null);
6532
6544
  const queryRe = queryFilter ? new RegExp(queryFilter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') : null;
6533
- const dp = decisionsPath(root);
6534
- if (!exists(dp)) {
6545
+ // 1.9.339 (UR-0053): canonical 로더(_loadDecisions) — decisions.json 우선, MD fallback. 단일 진실소스.
6546
+ const all = _loadDecisions(root);
6547
+ if (!all.length) {
6535
6548
  if (jsonMode) {
6536
6549
  const empty = { version: VERSION, root, total: 0, decisions: [] };
6537
6550
  if (queryFilter) empty.query = queryFilter;
6538
6551
  process.stdout.write(JSON.stringify(empty, null, 2) + '\n');
6539
6552
  return;
6540
6553
  }
6541
- return ok('decisions.md 없음 — leerness decision add "<title>" 로 첫 결정 영구화');
6542
- }
6543
- const text = read(dp);
6544
- const blocks = _extractDecisionBlocks(text);
6545
- const decisions = [];
6546
- for (const block of blocks) {
6547
- const titleMatch = block.match(/^### (.+)$/m);
6548
- if (!titleMatch) continue;
6549
- const titleLine = titleMatch[1].trim();
6550
- // 형식: "YYYY-MM-DD — <title>" 또는 "<title>" 단독
6551
- const dateTitleMatch = titleLine.match(/^(\d{4}-\d{2}-\d{2})\s*—\s*(.+)$/);
6552
- const date = dateTitleMatch ? dateTitleMatch[1] : null;
6553
- const title = dateTitleMatch ? dateTitleMatch[2].trim() : titleLine;
6554
- const decisionMatch = block.match(/- Decision:[ \t]*(.+)/);
6555
- const reasonMatch = block.match(/- Reason:[ \t]*(.+)/);
6556
- const alternativesMatch = block.match(/- Alternatives:[ \t]*(.+)/);
6557
- const impactMatch = block.match(/- Impact:[ \t]*(.+)/);
6558
- const entry = {
6559
- date,
6560
- title,
6561
- decision: decisionMatch ? decisionMatch[1].trim() : null,
6562
- reason: reasonMatch ? reasonMatch[1].trim() : null,
6563
- alternatives: alternativesMatch ? alternativesMatch[1].trim() : null,
6564
- impact: impactMatch ? impactMatch[1].trim() : null,
6565
- };
6566
- // 1.9.139: query 필터 — title/decision/reason 매칭
6567
- if (queryRe) {
6568
- const hay = [entry.title, entry.decision, entry.reason, entry.alternatives, entry.impact].filter(Boolean).join(' ');
6569
- if (!queryRe.test(hay)) continue;
6570
- }
6571
- decisions.push(entry);
6554
+ return ok('decisions 비어있음 — leerness decision add "<title>" 로 첫 결정 영구화');
6572
6555
  }
6556
+ // 1.9.139: query 필터 — title/decision/reason/alternatives/impact case-insensitive 매칭
6557
+ const decisions = queryRe ? all.filter(entry => {
6558
+ const hay = [entry.title, entry.decision, entry.reason, entry.alternatives, entry.impact].filter(Boolean).join(' ');
6559
+ return queryRe.test(hay);
6560
+ }) : all.slice();
6573
6561
  if (jsonMode) {
6574
6562
  const payload = { version: VERSION, root, total: decisions.length, decisions };
6575
6563
  if (queryFilter) payload.query = queryFilter;
@@ -6591,44 +6579,26 @@ function decisionListCmd(root, opts = {}) {
6591
6579
  function decisionDropCmd(root, target) {
6592
6580
  root = absRoot(root);
6593
6581
  if (!target) return fail('decision drop <date|title-substring> 필요. 예: leerness decision drop "2026-05-20" 또는 leerness decision drop "PostgreSQL"');
6594
- const dp = decisionsPath(root);
6595
- if (!exists(dp)) return fail('decisions.md 없음');
6596
- const text = read(dp);
6597
- // 코드블록 제외해서 안전하게 처리 (template 제외)
6598
- // 단순 split: ### 으로 시작하는 블록만
6599
- const blocks = text.split(/\n(?=### )/);
6600
- let removed = 0;
6582
+ // 1.9.339 (UR-0053): canonical JSON 기준 drop (date 또는 title substring 매칭) — JSON+MD projection 동시 갱신.
6583
+ const all = _loadDecisions(root);
6584
+ if (!all.length) return fail('decisions 없음');
6601
6585
  const kept = [];
6602
- for (const b of blocks) {
6603
- if (!b.startsWith('### ')) { kept.push(b); continue; }
6604
- // 코드블록 내부는 건드리지 않음 단순화: 헤더 라인의 dateMatch / titleMatch
6605
- const headerMatch = b.match(/^### (.+)$/m);
6606
- if (!headerMatch) { kept.push(b); continue; }
6607
- const titleLine = headerMatch[1].trim();
6608
- // 형식: "YYYY-MM-DD <title>" 또는 "<title>" 단독
6609
- const dateTitleMatch = titleLine.match(/^(\d{4}-\d{2}-\d{2})\s*—\s*(.+)$/);
6610
- const date = dateTitleMatch ? dateTitleMatch[1] : null;
6611
- const title = dateTitleMatch ? dateTitleMatch[2].trim() : titleLine;
6612
- // template 블록 제외 (`### Template (예시 ...)` 같은 경우)
6613
- if (/^Template(?:\s|\b|\()/i.test(titleLine) || /^템플릿/.test(titleLine)) {
6614
- kept.push(b);
6615
- continue;
6616
- }
6617
- const isDateTarget = date === target;
6618
- const isTitleTarget = title.includes(target);
6619
- if (isDateTarget || isTitleTarget) {
6620
- removed++;
6621
- // archive 보존
6622
- const archivePath = path.join(root, '.harness/decisions.archive.md');
6623
- const archiveHeader = exists(archivePath) ? '' : '# Decisions archive\n\n';
6624
- append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${target}")\n${b}\n`);
6625
- continue;
6626
- }
6627
- kept.push(b);
6628
- }
6629
- if (removed === 0) return fail(`매칭 decision 없음: "${target}"`);
6630
- writeUtf8(dp, kept.join('\n'));
6631
- ok(`decision dropped: ${removed}건 (보존: .harness/decisions.archive.md)`);
6586
+ const removed = [];
6587
+ for (const d of all) {
6588
+ if (d.date === target || (d.title || '').includes(target)) removed.push(d);
6589
+ else kept.push(d);
6590
+ }
6591
+ if (!removed.length) return fail(`매칭 decision 없음: "${target}"`);
6592
+ // archive 보존 (projection MD 블록 형태)
6593
+ const archivePath = path.join(root, '.harness/decisions.archive.md');
6594
+ const archiveHeader = exists(archivePath) ? '' : '# Decisions archive\n\n';
6595
+ const archiveBlocks = removed.map(d => {
6596
+ const head = d.date ? `${d.date} ${d.title}` : d.title;
6597
+ return `\n### ${head}\n- Decision: ${d.decision || ''}\n- Reason: ${d.reason || ''}\n- Alternatives: ${d.alternatives || ''}\n- Impact: ${d.impact || ''}\n`;
6598
+ }).join('');
6599
+ append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${target}")\n${archiveBlocks}\n`);
6600
+ _saveDecisions(root, kept);
6601
+ ok(`decision dropped: ${removed.length}건 (보존: .harness/decisions.archive.md)`);
6632
6602
  _autoRoadmap(absRoot(root), 'data-change');
6633
6603
  }
6634
6604
 
@@ -6637,16 +6607,19 @@ function decisionAdd(root, title) {
6637
6607
  root = absRoot(root);
6638
6608
  if (!_requireInit(root, 'decision add')) return; // 1.9.311 (UR-0047): init 가드
6639
6609
  if (!title) return fail('decision title required. 예: leerness decision add "PostgreSQL 채택" --reason "..." ');
6640
- const dp = decisionsPath(root);
6641
6610
  const reason = arg('--reason', '');
6642
6611
  const alternatives = arg('--alternatives', '');
6643
6612
  const impact = arg('--impact', '');
6644
- const block = `\n### ${today()}${title}\n- Decision: ${title}\n- Reason: ${reason}\n- Alternatives: ${alternatives}\n- Impact: ${impact}\n`;
6645
- if (!exists(dp)) {
6646
- writeUtf8(dp, `# Decisions\n${block}`);
6647
- } else {
6648
- append(dp, block);
6649
- }
6613
+ // 1.9.339 (UR-0053): canonical JSON write path 기존 항목(JSON 우선, 없으면 MD backfill) 로드 추가, JSON+MD projection 동시 저장.
6614
+ const all = _loadDecisions(root);
6615
+ all.push({
6616
+ date: today(), title,
6617
+ decision: title,
6618
+ reason: reason || null,
6619
+ alternatives: alternatives || null,
6620
+ impact: impact || null
6621
+ });
6622
+ _saveDecisions(root, all);
6650
6623
  ok(`decision added: ${title}`);
6651
6624
  // 1.9.43+ handoff lessons 회수 흐름과 자동 통합 (decisions.md fuzzy 매칭됨)
6652
6625
  _autoRoadmap(absRoot(root), 'data-change');
@@ -7438,15 +7411,12 @@ function handoff(root) {
7438
7411
  for (const s of STATUSES) tasksByStatus[s] = 0;
7439
7412
  for (const r of rows) tasksByStatus[r.status] = (tasksByStatus[r.status] || 0) + 1;
7440
7413
  const tasksInProgress = tasksByStatus['in-progress'] || 0;
7441
- const dm = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
7442
- const decisionBlocks = _extractDecisionBlocks(dm);
7443
- const decisionsCount = decisionBlocks.length;
7414
+ const decisionsCount = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
7444
7415
  const rules = readRules(root);
7445
7416
  const rulesActive = rules.filter(r => r.status === 'active').length;
7446
7417
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
7447
7418
  const milestones = (planText.match(/^### M-\d{4}\./gm) || []).length;
7448
- const lm = exists(lessonsPath(root)) ? read(lessonsPath(root)) : '';
7449
- const lessonsCount = (lm.match(/^### \d{4}-\d{2}-\d{2}[^\n]*/gm) || []).length;
7419
+ const lessonsCount = _loadLessons(root).length;
7450
7420
  // 1.9.130: archive 카운트 통합
7451
7421
  const archiveCountsH = { decisions: 0, lessons: 0, plan: 0, total: 0 };
7452
7422
  try {
@@ -7712,11 +7682,11 @@ function handoff(root) {
7712
7682
  try {
7713
7683
  const rows = readProgressRows(root);
7714
7684
  const inProgressTasks = rows.filter(r => r.status === 'in-progress').length;
7715
- const decisions = exists(decisionsPath(root)) ? _countDatedBlocks(read(decisionsPath(root))) : 0;
7685
+ const decisions = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
7716
7686
  const rulesActive = readRules(root).filter(r => r.status === 'active').length;
7717
7687
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
7718
7688
  const planMilestones = (planText.match(/^### M-\d{4}\./gm) || []).length;
7719
- const lessons = exists(lessonsPath(root)) ? _countDatedBlocks(read(lessonsPath(root))) : 0;
7689
+ const lessons = _loadLessons(root).length;
7720
7690
  parts.push(`🧠 mem T${inProgressTasks}/D${decisions}/R${rulesActive}/P${planMilestones}/L${lessons}`);
7721
7691
  } catch {}
7722
7692
  // 8) 1.9.152: 활성 외부 AI CLI 카운트 (1.9.151 복수 선택 결과 반영) — 메인 에이전트가 sub-agent 분배 가능성 즉시 인지
@@ -12049,14 +12019,12 @@ function sessionClose(root, opts = {}) {
12049
12019
  for (const s of STATUSES) tasksByStatus0[s] = 0;
12050
12020
  for (const r of rows0) tasksByStatus0[r.status] = (tasksByStatus0[r.status] || 0) + 1;
12051
12021
  const tasksInProgress0 = tasksByStatus0['in-progress'] || 0;
12052
- const dm0 = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
12053
- const decisionsCount0 = _extractDecisionBlocks(dm0).length;
12022
+ const decisionsCount0 = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
12054
12023
  const rules0 = readRules(root);
12055
12024
  const rulesActive0 = rules0.filter(r => r.status === 'active').length;
12056
12025
  const planText0 = exists(planPath(root)) ? read(planPath(root)) : '';
12057
12026
  const milestones0 = (planText0.match(/^### M-\d{4}\./gm) || []).length;
12058
- const lm0 = exists(lessonsPath(root)) ? read(lessonsPath(root)) : '';
12059
- const lessonsCount0 = (lm0.match(/^### \d{4}-\d{2}-\d{2}[^\n]*/gm) || []).length;
12027
+ const lessonsCount0 = _loadLessons(root).length;
12060
12028
  // 1.9.130: archive 카운트 통합
12061
12029
  const archiveCountsS = { decisions: 0, lessons: 0, plan: 0, total: 0 };
12062
12030
  try {
@@ -12389,13 +12357,11 @@ function sessionClose(root, opts = {}) {
12389
12357
  const ms = _computeMilestones(root);
12390
12358
  const rows = readProgressRows(root);
12391
12359
  const tIn = rows.filter(r => r.status === 'in-progress').length;
12392
- const dm = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
12393
- const dCnt = _extractDecisionBlocks(dm).length;
12360
+ const dCnt = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
12394
12361
  const rActive = readRules(root).filter(r => r.status === 'active').length;
12395
12362
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
12396
12363
  const pCnt = (planText.match(/^### M-\d{4}\./gm) || []).length;
12397
- const lm = exists(lessonsPath(root)) ? read(lessonsPath(root)) : '';
12398
- const lCnt = (lm.match(/^### \d{4}-\d{2}-\d{2}[^\n]*/gm) || []).length;
12364
+ const lCnt = _loadLessons(root).length;
12399
12365
  const mem = `T${tIn}/D${dCnt}/R${rActive}/P${pCnt}/L${lCnt}`;
12400
12366
  let pulseLine = `📍 v${VERSION} · 🔄 R${rh.roundCount} · 🧠 ${mem}`;
12401
12367
  if (ms.next) {
@@ -18023,27 +17989,17 @@ function contextCmd(root, opts = {}) {
18023
17989
  const active = rows.find(r => r.status === 'in-progress' || r.status === '[진행]');
18024
17990
  const activeRules = readRules(root).filter(r => r.status === 'active');
18025
17991
  const openReqs = (_loadUserRequests(root).requests || []).filter(r => r.status === 'open' || r.status === 'in-progress');
18026
- // 최근 결정 (last 3, 최신순) — decisionListCmd 동일 파서 재사용
18027
- const decFile = decisionsPath(root);
18028
- let recentDecisions = [];
18029
- let decisionCount = 0;
18030
- if (exists(decFile)) {
18031
- const dtext = read(decFile);
18032
- decisionCount = _countDatedBlocks(dtext);
18033
- const blocks = _extractDecisionBlocks(dtext);
18034
- recentDecisions = blocks.slice(-3).reverse().map(block => {
18035
- const tm = (block.match(/^### (.+)$/m) || [])[1] || '';
18036
- const dt = tm.match(/^(\d{4}-\d{2}-\d{2})\s*—\s*(.+)$/);
18037
- return { date: dt ? dt[1] : null, title: (dt ? dt[2] : tm).trim().slice(0, 100) };
18038
- });
18039
- }
17992
+ // 최근 결정 (last 3, 최신순) — 1.9.339 (UR-0053): canonical _loadDecisions (JSON 단일 진실소스, MD projection fallback)
17993
+ const _decs = _loadDecisions(root);
17994
+ const decisionCount = _decs.length;
17995
+ const recentDecisions = _decs.slice(-3).reverse().map(d => ({ date: d.date || null, title: (d.title || '').slice(0, 100) }));
18040
17996
  const queueState = _loadNextActionQueue(root);
18041
17997
  const nextActions = (queueState.queue || []).slice(-3).reverse().map(a => ({ title: a.title, command: a.command || null }));
18042
17998
  const memory = {
18043
17999
  tasksInProgress: rows.filter(r => r.status === 'in-progress').length,
18044
18000
  decisions: decisionCount,
18045
18001
  rulesActive: activeRules.length,
18046
- lessons: exists(lessonsPath(root)) ? _countDatedBlocks(read(lessonsPath(root))) : 0
18002
+ lessons: _loadLessons(root).length
18047
18003
  };
18048
18004
  // 프로젝트 청사진 (1.9.308 UR-0055 2단계): brief 에서 의도/개요/방향이력 회수 (단일 출처)
18049
18005
  const brief = _loadBrief(root);
@@ -19643,14 +19599,12 @@ function healthCmd(root) {
19643
19599
  for (const s of STATUSES) tasksByStatus[s] = 0;
19644
19600
  for (const r of rows) tasksByStatus[r.status] = (tasksByStatus[r.status] || 0) + 1;
19645
19601
  const tasksInProgress = tasksByStatus['in-progress'] || 0;
19646
- const dm = exists(decisionsPath(root)) ? read(decisionsPath(root)) : '';
19647
- const decisionsCount = _extractDecisionBlocks(dm).length;
19602
+ const decisionsCount = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
19648
19603
  const rules = readRules(root);
19649
19604
  const rulesActive = rules.filter(r => r.status === 'active').length;
19650
19605
  const planText = exists(planPath(root)) ? read(planPath(root)) : '';
19651
19606
  const milestones = (planText.match(/^### M-\d{4}\./gm) || []).length;
19652
- const lm = exists(lessonsPath(root)) ? read(lessonsPath(root)) : '';
19653
- const lessonsCount = (lm.match(/^### \d{4}-\d{2}-\d{2}[^\n]*/gm) || []).length;
19607
+ const lessonsCount = _loadLessons(root).length;
19654
19608
  out.memorySurface = {
19655
19609
  tasks: { inProgress: tasksInProgress, total: rows.length, byStatus: tasksByStatus },
19656
19610
  decisions: { count: decisionsCount },
package/lib/pure-utils.js CHANGED
@@ -375,6 +375,15 @@ function _parseLessonEntries(text) {
375
375
  return out;
376
376
  }
377
377
 
378
+ // UR-0058: canonical lessons 객체 배열 → lessons.md projection. _parseLessonEntries 와 round-trip 안전.
379
+ function _renderLessonsMd(lessons) {
380
+ const preamble = '# Lessons (1.9.112)\n\n과거 실수/통찰/패턴 영구 기록 — handoff 자동 회수와 통합.\n';
381
+ const body = (lessons || []).map(l =>
382
+ `\n### ${l.date}\n- Lesson: ${l.text}\n${l.tag ? `- Tag: ${l.tag}\n` : ''}`
383
+ ).join('');
384
+ return preamble + body;
385
+ }
386
+
378
387
  // 1.9.333 (UR-0025 심층): 순수 플랫폼 제약 매칭 — catalog + 텍스트 → 매칭 플랫폼/제약/제안 (fs 의존 0, catalog 주입).
379
388
  function _matchConstraints(catalog, text) {
380
389
  if (!text || typeof text !== 'string' || !catalog || !catalog.platforms) return { matched: [], suggestions: [] };
@@ -515,6 +524,39 @@ function _translate(strings, key, lang) {
515
524
  return entry[lang || 'ko'] || entry.ko || key;
516
525
  }
517
526
 
527
+ // 1.9.339 (UR-0053): decision MD 블록(문자열) → 정규 객체 (canonical 스키마). list/load 단일 파서.
528
+ function _parseDecisionBlock(block) {
529
+ const titleMatch = String(block || '').match(/^### (.+)$/m);
530
+ const titleLine = titleMatch ? titleMatch[1].trim() : '';
531
+ const dateTitle = titleLine.match(/^(\d{4}-\d{2}-\d{2})\s*—\s*(.+)$/);
532
+ const g = re => { const m = String(block || '').match(re); const v = m ? m[1].trim() : null; return v || null; }; // 빈 값 → null 정규화 (render↔parse round-trip 멱등)
533
+ return {
534
+ date: dateTitle ? dateTitle[1] : null,
535
+ title: dateTitle ? dateTitle[2].trim() : titleLine,
536
+ decision: g(/- Decision:[ \t]*(.+)/),
537
+ reason: g(/- Reason:[ \t]*(.+)/),
538
+ alternatives: g(/- Alternatives:[ \t]*(.+)/),
539
+ impact: g(/- Impact:[ \t]*(.+)/)
540
+ };
541
+ }
542
+
543
+ // 1.9.339 (UR-0053): decisions.md 본문 → canonical 객체 배열 (template/code 블록 제외, title 있는 것만).
544
+ function _decisionsFromMd(text) {
545
+ return _extractDecisionBlocks(text).map(_parseDecisionBlock).filter(d => d.title);
546
+ }
547
+
548
+ // 1.9.339 (UR-0053): canonical 객체 배열 → decisions.md projection (init template preamble 보존, round-trip 안전).
549
+ function _renderDecisionsMd(decisions) {
550
+ // preamble 의 코드펜스(```)는 single-quote 문자열로 안전 처리 (template literal 충돌 회피)
551
+ const preamble = '# Decisions\n\n## Template (예시 — 실제 결정은 아래 코드블록 밖에 추가)\n\n'
552
+ + '```md\n### YYYY-MM-DD — Decision 제목\n- Decision:\n- Reason:\n- Alternatives:\n- Impact:\n```\n';
553
+ const body = (decisions || []).map(d => {
554
+ const head = d.date ? `${d.date} — ${d.title}` : d.title;
555
+ return `\n### ${head}\n- Decision: ${d.decision || ''}\n- Reason: ${d.reason || ''}\n- Alternatives: ${d.alternatives || ''}\n- Impact: ${d.impact || ''}\n`;
556
+ }).join('');
557
+ return preamble + body;
558
+ }
559
+
518
560
  module.exports = {
519
561
  _isSecretKey, compareVer, parseHarnessVersion,
520
562
  _classifyCJK, _riskLabel, _detectSystemLang, _parseSlashFromHelp,
@@ -538,8 +580,8 @@ module.exports = {
538
580
  _BRIEF_FIELDS, _briefFilled,
539
581
  // 1.9.331 (UR-0025): project-brief 텍스트 빌더 + 마커
540
582
  BRIEF_START, BRIEF_END, _briefReadmeBlock, _briefBlueprint,
541
- // 1.9.332 (UR-0025): 순수 lessons.md 파서
542
- _parseLessonEntries,
583
+ // 1.9.332/UR-0058: 순수 lessons.md 파서 + canonical projection renderer
584
+ _parseLessonEntries, _renderLessonsMd,
543
585
  // 1.9.333 (UR-0025 심층): 순수 플랫폼 제약 매칭
544
586
  _matchConstraints,
545
587
  // 1.9.333 패턴 적용: 순수 도메인 매칭
@@ -551,5 +593,7 @@ module.exports = {
551
593
  // 1.9.337 (UR-0025 심층): persona 요약 목록
552
594
  _personaSummaries,
553
595
  // 1.9.338 (UR-0025 심층): i18n 순수 조회
554
- _translate
596
+ _translate,
597
+ // 1.9.339 (UR-0053): decisions canonical 파서/렌더 (JSON canonical, MD projection)
598
+ _parseDecisionBlock, _decisionsFromMd, _renderDecisionsMd
555
599
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.338",
3
+ "version": "1.9.340",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -4276,5 +4276,98 @@ total++;
4276
4276
  if (!ok) failed++;
4277
4277
  }
4278
4278
 
4279
+ // 1.9.339 회귀 (UR-0053): decisions canonical = JSON, MD = projection. add→json+md / context count 단일소스 / drop+archive / MD-only 백필
4280
+ total++;
4281
+ {
4282
+ let ok = false;
4283
+ try {
4284
+ const m = require(path.resolve(__dirname, '..', 'lib', 'pure-utils.js'));
4285
+ // 순수 round-trip + field 정규화
4286
+ const md = '# Decisions\n\n### 2026-06-05 — A\n- Decision: a\n- Reason: r\n- Alternatives: alt\n- Impact: imp\n\n### 2026-06-04 — B\n- Decision: b\n- Alternatives:\n';
4287
+ const objs = m._decisionsFromMd(md);
4288
+ const pureOk = objs.length === 2 && objs[0].alternatives === 'alt' && objs[1].alternatives === null
4289
+ && JSON.stringify(m._decisionsFromMd(m._renderDecisionsMd(objs))) === JSON.stringify(objs);
4290
+ // end-to-end: add → decisions.json(canonical) + decisions.md(projection)
4291
+ const dd = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-dec-'));
4292
+ cp.spawnSync(process.execPath, [CLI, 'init', dd, '--yes', '--language', 'ko', '--skills', 'recommended'], { encoding: 'utf8', timeout: 30000 });
4293
+ cp.spawnSync(process.execPath, [CLI, 'decision', 'add', 'PG 채택', '--reason', '관계형', '--alternatives', 'Mongo', '--path', dd], { encoding: 'utf8', timeout: 20000 });
4294
+ cp.spawnSync(process.execPath, [CLI, 'decision', 'add', 'Redis', '--reason', '속도', '--path', dd], { encoding: 'utf8', timeout: 20000 });
4295
+ const jsonExists = fs.existsSync(path.join(dd, '.harness', 'decisions.json'));
4296
+ const canon = jsonExists ? JSON.parse(fs.readFileSync(path.join(dd, '.harness', 'decisions.json'), 'utf8')) : [];
4297
+ const mdProj = fs.existsSync(path.join(dd, '.harness', 'decisions.md')) ? fs.readFileSync(path.join(dd, '.harness', 'decisions.md'), 'utf8') : '';
4298
+ const writeOk = jsonExists && canon.length === 2 && canon[0].alternatives === 'Mongo' && canon[1].alternatives === null
4299
+ && mdProj.includes('PG 채택') && mdProj.includes('Redis');
4300
+ // list + context count = canonical 단일소스
4301
+ let listOk = false, ctxOk = false;
4302
+ const lr = cp.spawnSync(process.execPath, [CLI, 'decision', 'list', '--path', dd, '--json'], { encoding: 'utf8', timeout: 20000 });
4303
+ try { const j = JSON.parse(lr.stdout); listOk = j.total === 2; } catch {}
4304
+ const cr = cp.spawnSync(process.execPath, [CLI, 'context', '--path', dd, '--json'], { encoding: 'utf8', timeout: 20000 });
4305
+ try { const j = JSON.parse(cr.stdout); ctxOk = j.memory && j.memory.decisions === 2; } catch {}
4306
+ // drop + archive
4307
+ cp.spawnSync(process.execPath, [CLI, 'decision', 'drop', 'PG', '--path', dd], { encoding: 'utf8', timeout: 20000 });
4308
+ const afterDrop = JSON.parse(fs.readFileSync(path.join(dd, '.harness', 'decisions.json'), 'utf8'));
4309
+ const dropOk = afterDrop.length === 1 && afterDrop[0].title === 'Redis' && fs.existsSync(path.join(dd, '.harness', 'decisions.archive.md'));
4310
+ fs.rmSync(dd, { recursive: true, force: true });
4311
+ // 백필: MD-only(JSON 없음) → list 가 MD 파싱(template 제외), 읽기 무부작용
4312
+ const bd = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-bf-'));
4313
+ cp.spawnSync(process.execPath, [CLI, 'init', bd, '--yes', '--language', 'ko', '--skills', 'recommended'], { encoding: 'utf8', timeout: 30000 });
4314
+ fs.rmSync(path.join(bd, '.harness', 'decisions.json'), { force: true });
4315
+ fs.writeFileSync(path.join(bd, '.harness', 'decisions.md'), '# Decisions\n\n## Template\n\n' + '```md\n### YYYY-MM-DD — 제목\n- Decision:\n```\n\n### 2026-06-01 — 기존A\n- Decision: A\n\n### 2026-06-02 — 기존B\n- Decision: B\n', 'utf8');
4316
+ const br = cp.spawnSync(process.execPath, [CLI, 'decision', 'list', '--path', bd, '--json'], { encoding: 'utf8', timeout: 20000 });
4317
+ let backfillOk = false;
4318
+ try { const j = JSON.parse(br.stdout); backfillOk = j.total === 2 && !fs.existsSync(path.join(bd, '.harness', 'decisions.json')); } catch {}
4319
+ fs.rmSync(bd, { recursive: true, force: true });
4320
+ ok = pureOk && writeOk && listOk && ctxOk && dropOk && backfillOk;
4321
+ } catch {}
4322
+ console.log(ok ? '✓ B(1.9.339) UR-0053: decisions canonical JSON + MD projection + 백필 + context 단일소스 (UR-0053)' : '✗ decisions canonical JSON 실패');
4323
+ if (!ok) failed++;
4324
+ }
4325
+
4326
+ // 1.9.340 회귀 (UR-0058, Codex 위임·검증): lessons canonical = JSON, MD = projection. save→json+md / list+tag / drop+archive / MD-only 백필
4327
+ total++;
4328
+ {
4329
+ let ok = false;
4330
+ try {
4331
+ const m = require(path.resolve(__dirname, '..', 'lib', 'pure-utils.js'));
4332
+ // 순수 round-trip (tag null 포함)
4333
+ const objs = [{ date: '2026-06-05', text: 'A', tag: 't' }, { date: '2026-06-04', text: 'B', tag: null }];
4334
+ const pureOk = JSON.stringify(m._parseLessonEntries(m._renderLessonsMd(objs))) === JSON.stringify(objs)
4335
+ && m._parseLessonEntries(m._renderLessonsMd([])).length === 0;
4336
+ // end-to-end: save → lessons.json(canonical) + lessons.md(projection)
4337
+ const ld = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-les-'));
4338
+ cp.spawnSync(process.execPath, [CLI, 'init', ld, '--yes', '--language', 'ko', '--skills', 'recommended'], { encoding: 'utf8', timeout: 30000 });
4339
+ cp.spawnSync(process.execPath, [CLI, 'lesson', 'save', 'JWT 짧게', '--tag', 'security', '--path', ld], { encoding: 'utf8', timeout: 20000 });
4340
+ cp.spawnSync(process.execPath, [CLI, 'lesson', 'save', '캐시 TTL', '--path', ld], { encoding: 'utf8', timeout: 20000 });
4341
+ const jsonExists = fs.existsSync(path.join(ld, '.harness', 'lessons.json'));
4342
+ const canon = jsonExists ? JSON.parse(fs.readFileSync(path.join(ld, '.harness', 'lessons.json'), 'utf8')) : [];
4343
+ const mdProj = fs.existsSync(path.join(ld, '.harness', 'lessons.md')) ? fs.readFileSync(path.join(ld, '.harness', 'lessons.md'), 'utf8') : '';
4344
+ const writeOk = jsonExists && canon.length === 2 && canon[0].tag === 'security' && canon[1].tag === null
4345
+ && mdProj.includes('JWT 짧게') && mdProj.includes('캐시 TTL') && mdProj.includes('- Tag: security');
4346
+ // list + tag filter
4347
+ let listOk = false, tagOk = false;
4348
+ const lr = cp.spawnSync(process.execPath, [CLI, 'lesson', 'list', '--path', ld, '--json'], { encoding: 'utf8', timeout: 20000 });
4349
+ try { listOk = JSON.parse(lr.stdout).total === 2; } catch {}
4350
+ const tr = cp.spawnSync(process.execPath, [CLI, 'lesson', 'list', '--tag', 'security', '--path', ld, '--json'], { encoding: 'utf8', timeout: 20000 });
4351
+ try { tagOk = JSON.parse(tr.stdout).total === 1; } catch {}
4352
+ // drop + archive
4353
+ cp.spawnSync(process.execPath, [CLI, 'lesson', 'drop', 'JWT', '--path', ld], { encoding: 'utf8', timeout: 20000 });
4354
+ const afterDrop = JSON.parse(fs.readFileSync(path.join(ld, '.harness', 'lessons.json'), 'utf8'));
4355
+ const dropOk = afterDrop.length === 1 && afterDrop[0].text === '캐시 TTL' && fs.existsSync(path.join(ld, '.harness', 'lessons.archive.md'));
4356
+ fs.rmSync(ld, { recursive: true, force: true });
4357
+ // 백필: MD-only(JSON 없음) → list 가 MD 파싱, 읽기 무부작용
4358
+ const bd = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-lbf-'));
4359
+ cp.spawnSync(process.execPath, [CLI, 'init', bd, '--yes', '--language', 'ko', '--skills', 'recommended'], { encoding: 'utf8', timeout: 30000 });
4360
+ fs.rmSync(path.join(bd, '.harness', 'lessons.json'), { force: true });
4361
+ fs.writeFileSync(path.join(bd, '.harness', 'lessons.md'), '# Lessons\n\n### 2026-06-01\n- Lesson: 기존1\n- Tag: t1\n\n### 2026-06-02\n- Lesson: 기존2\n', 'utf8');
4362
+ const br = cp.spawnSync(process.execPath, [CLI, 'lesson', 'list', '--path', bd, '--json'], { encoding: 'utf8', timeout: 20000 });
4363
+ let backfillOk = false;
4364
+ try { backfillOk = JSON.parse(br.stdout).total === 2 && !fs.existsSync(path.join(bd, '.harness', 'lessons.json')); } catch {}
4365
+ fs.rmSync(bd, { recursive: true, force: true });
4366
+ ok = pureOk && writeOk && listOk && tagOk && dropOk && backfillOk;
4367
+ } catch {}
4368
+ console.log(ok ? '✓ B(1.9.340) UR-0058(Codex 위임·검증): lessons canonical JSON + MD projection + 백필 (UR-0058)' : '✗ lessons canonical JSON 실패');
4369
+ if (!ok) failed++;
4370
+ }
4371
+
4279
4372
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
4280
4373
  if (failed > 0) process.exit(1);