leerness 1.9.337 → 1.9.339
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 +34 -0
- package/README.md +5 -5
- package/bin/harness.js +80 -145
- package/lib/catalogs.js +27 -1
- package/lib/pure-utils.js +45 -1
- package/package.json +1 -1
- package/scripts/e2e.js +72 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.9.339 — 2026-06-05 — UR-0053: decisions canonical = JSON, MD = projection (아키텍처)
|
|
4
|
+
|
|
5
|
+
**🏛 상태 저장 단일 진실소스 — decisions 를 canonical JSON 으로 전환, decisions.md 는 projection(렌더 뷰). count drift 근본 해소.** (UR-0053 full, "UR-0053+UR-0025 둘 다 진행" 중 Round B)
|
|
6
|
+
|
|
7
|
+
### 배경
|
|
8
|
+
설치리뷰 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, 비파괴")
|
|
9
|
+
|
|
10
|
+
### 구현 (UR-0053 — 비파괴 아키텍처)
|
|
11
|
+
1. **canonical write path**: `decision add`/`drop` → `decisions.json`(canonical) + `decisions.md`(projection) 동시 기록. `_saveDecisions(root, arr)`.
|
|
12
|
+
2. **순수 파서/렌더** (lib/pure-utils): `_parseDecisionBlock`(블록→객체, 빈 값 null 정규화), `_decisionsFromMd`(MD→객체배열, template 제외), `_renderDecisionsMd`(객체배열→MD projection, template preamble 보존) — render↔parse round-trip 멱등.
|
|
13
|
+
3. **canonical 로더** `_loadDecisions(root)`: decisions.json 우선 → 없으면 decisions.md 파싱(읽기 무부작용). 손상 JSON 시 MD fallback.
|
|
14
|
+
4. **단일 진실소스 라우팅**: context/health/handoff/pulse/memory-status/session-close/round-history 의 decision count·list·recent 를 모두 `_loadDecisions` 로 전환. MCP `decision_list` 포함.
|
|
15
|
+
5. **비파괴/백필**: 기존 MD-only 프로젝트 → 첫 write 시 MD→JSON 백필(데이터 무손실). 읽기 명령은 부작용 0. retro 집계·fuzzy restore·today-heuristic 은 faithful projection MD 를 계속 읽어 일관.
|
|
16
|
+
|
|
17
|
+
### 검증
|
|
18
|
+
- **selftest 87/87 PASS** · **E2E 284/284 PASS** (회귀 0).
|
|
19
|
+
- 실측: 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 멱등.
|
|
20
|
+
|
|
21
|
+
## 1.9.338 — 2026-06-05 — UR-0025(심층): i18n STRINGS 서브시스템 분리
|
|
22
|
+
|
|
23
|
+
**🧩 i18n 문자열 서브시스템의 핵심(ko/en catalog + 순수 조회)을 모듈로 분리 — 동형 추출 6번째.** (UR-0025 심층, "UR-0053+UR-0025 둘 다 진행" 중 Round A)
|
|
24
|
+
|
|
25
|
+
### 배경
|
|
26
|
+
constraints·intent-도메인·LSP·anti-laziness·persona에 이은 동형 추출 6번째. 사용자가 "UR-0053 + UR-0025 심층 둘 다 진행"을 지시 → 원자성·회귀격리를 위해 2 독립 라운드로 분리(Round A=UR-0025 i18n[decisions 무관, 저위험] → Round B=UR-0053 decisions canonical JSON). 17줄 catalog 라 전사 오류 회피용 일회용 마이그레이션 스크립트로 결정적 이동 후 삭제.
|
|
27
|
+
|
|
28
|
+
### 구현 (UR-0025 심층)
|
|
29
|
+
1. **`STRINGS`**(i18n ko/en catalog, 17키: install/repl/common) → `lib/catalogs.js`.
|
|
30
|
+
2. **`_translate(strings, key, lang)`**(순수: catalog 주입, key→lang 값, fallback ko→key) → `lib/pure-utils.js`. harness `_t(key, lang)` = `_translate(STRINGS, key, lang)` 박막(호출처 0변경).
|
|
31
|
+
3. selftest 85→86 · e2e 282→283.
|
|
32
|
+
|
|
33
|
+
### 검증
|
|
34
|
+
- **selftest 86/86 PASS** · **E2E 283/283 PASS** (회귀 0).
|
|
35
|
+
- 실측: catalog 17키 · `_translate` ko/en/default·미존재키→키자체·null guard·en누락→ko fallback 정상. (`_t` 는 인터랙티브 install/REPL 전용 getter — 구조+순수동작 검증)
|
|
36
|
+
|
|
3
37
|
## 1.9.337 — 2026-06-05 — UR-0025(심층): review persona 서브시스템 분리
|
|
4
38
|
|
|
5
39
|
**🧩 코드리뷰 persona 서브시스템의 핵심(5종 페르소나 catalog + 순수 요약 변환)을 모듈로 분리 — 동형 추출 5번째.** (UR-0025 심층)
|
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
|
-
[](https://www.npmjs.com/package/leerness) [](https://www.npmjs.com/package/leerness) []() []() []() []() []() []()
|
|
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.
|
|
474
|
+
이 프로젝트는 Leerness v1.9.339 하네스를 사용합니다. 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.
|
|
528
|
+
Leerness v1.9.339는 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.337는 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.
|
|
549
|
+
현재 누적: **70 라운드 (1.9.40 → 1.9.339)** · 매 라운드 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.
|
|
587
|
+
Last synced by Leerness v1.9.339: 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
|
|
22
|
+
_personaSummaries, _translate,
|
|
23
|
+
_decisionsFromMd, _renderDecisionsMd } = 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
|
-
const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS } = require('../lib/catalogs'); // 1.9.
|
|
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.
|
|
29
|
+
const VERSION = '1.9.339';
|
|
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') 시 호스트 프로세스 오염.
|
|
@@ -1973,29 +1974,7 @@ async function skillAutoCacheCmd(root, sub) {
|
|
|
1973
1974
|
// 사용자 명시: "설치 가이드에서 언어 선택에 따라 설치 가이드 및 REPL agent 모드 등 설정된 언어로 표시"
|
|
1974
1975
|
// 설계: 핵심 string table (ko/en) + _t(key, lang) helper. 설치 시 선택 언어 → .harness/LANGUAGE 저장.
|
|
1975
1976
|
// lang 결정: explicit > .harness/LANGUAGE > LEERNESS_LANG env > 'ko' (default)
|
|
1976
|
-
|
|
1977
|
-
// 설치 가이드 prompt
|
|
1978
|
-
'install.lang.title': { ko: '설치 언어를 선택하세요', en: 'Select install language' },
|
|
1979
|
-
'install.lang.auto': { ko: '자동 감지', en: 'Auto detect' },
|
|
1980
|
-
'install.lang.auto.desc': { ko: '디렉토리/파일 + 시스템(OS) 언어 자동 판별 (한국어/영어)', en: 'Auto-detect from dir/files + system (OS) locale (KO/EN)' },
|
|
1981
|
-
'install.lang.sysNotice': { ko: '시스템 언어 감지', en: 'System language detected' },
|
|
1982
|
-
'install.lang.ko': { ko: '한국어', en: 'Korean' },
|
|
1983
|
-
'install.lang.ko.desc': { ko: '모든 인스트럭션을 한국어로 생성', en: 'All instructions in Korean' },
|
|
1984
|
-
'install.lang.en': { ko: 'English', en: 'English' },
|
|
1985
|
-
'install.lang.en.desc': { ko: '모든 인스트럭션을 영어로 생성', en: 'All instructions in English' },
|
|
1986
|
-
'install.agents.title': { ko: 'CLI 에이전트 활성화 (복수 선택, Space 토글) — sub-agent 위임용',
|
|
1987
|
-
en: 'Enable CLI agents (multi-select, Space toggle) — for sub-agent dispatch' },
|
|
1988
|
-
'install.agents.none': { ko: '선택 안함 (나중에 setup-agents)', en: 'None (later setup-agents)' },
|
|
1989
|
-
'install.complete': { ko: '✓ 설치 완료', en: '✓ Install complete' },
|
|
1990
|
-
// REPL agent 모드
|
|
1991
|
-
'repl.welcome.title': { ko: 'leerness REPL agent', en: 'leerness REPL agent' },
|
|
1992
|
-
'repl.welcome.subtitle': { ko: 'Tab provider · Shift+Tab model · :help · /slash', en: 'Tab provider · Shift+Tab model · :help · /slash' },
|
|
1993
|
-
'repl.welcome.start': { ko: '시작하려면 메시지를 입력하세요', en: 'Type a message to start' },
|
|
1994
|
-
// 공통
|
|
1995
|
-
'common.cancel': { ko: '취소됨', en: 'Cancelled' },
|
|
1996
|
-
'common.confirm': { ko: '확인', en: 'Confirm' },
|
|
1997
|
-
'common.ready': { ko: '준비 완료', en: 'Ready' }
|
|
1998
|
-
};
|
|
1977
|
+
// 1.9.338 (UR-0025 심층): STRINGS (i18n ko/en catalog) 는 lib/catalogs.js 로 이전 (import). _t 는 _translate(STRINGS,..) 박막.
|
|
1999
1978
|
// 현재 사용 언어 결정 (env > config > 'ko')
|
|
2000
1979
|
function _currentLang(root) {
|
|
2001
1980
|
if (process.env.LEERNESS_LANG) return process.env.LEERNESS_LANG === 'en' ? 'en' : 'ko';
|
|
@@ -2011,10 +1990,9 @@ function _currentLang(root) {
|
|
|
2011
1990
|
} catch {}
|
|
2012
1991
|
return 'ko'; // default
|
|
2013
1992
|
}
|
|
1993
|
+
// 1.9.338 (UR-0025 심층): 순수 _translate(STRINGS, key, lang) (lib/pure-utils) 박막 — STRINGS catalog 주입.
|
|
2014
1994
|
function _t(key, lang) {
|
|
2015
|
-
|
|
2016
|
-
if (!entry) return key; // fallback: 키 자체 반환
|
|
2017
|
-
return entry[lang || 'ko'] || entry.ko || key;
|
|
1995
|
+
return _translate(STRINGS, key, lang);
|
|
2018
1996
|
}
|
|
2019
1997
|
|
|
2020
1998
|
// 1.9.206: UI/UX 개선 — typewriter / fade-in 효과 (opt-in via LEERNESS_TYPEWRITER=1)
|
|
@@ -3067,7 +3045,7 @@ function _selfTestCases() {
|
|
|
3067
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; } },
|
|
3068
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 & 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; } },
|
|
3069
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; } },
|
|
3070
|
-
{ name: 'count drift 수정: _countDatedBlocks 코드펜스(템플릿) 제외
|
|
3048
|
+
{ name: 'count drift 수정: _countDatedBlocks 코드펜스(템플릿) 제외 (UR-0053 1.9.320; decisions 는 1.9.339 canonical 로 이관)', 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 lessonsWired = src.includes('_countDatedBlocks(read(lessonsPath(root)))'); return typeof f === 'function' && c1 && c0 && c2 && lessonsWired; } },
|
|
3071
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; } },
|
|
3072
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; } },
|
|
3073
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; } },
|
|
@@ -3085,6 +3063,8 @@ function _selfTestCases() {
|
|
|
3085
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; } },
|
|
3086
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; } },
|
|
3087
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; } },
|
|
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; } },
|
|
3088
3068
|
{ name: 'VERSION 형식 (x.y.z)', run: () => /^\d+\.\d+\.\d+$/.test(VERSION) }
|
|
3089
3069
|
];
|
|
3090
3070
|
}
|
|
@@ -3565,12 +3545,7 @@ function pulseCmd(root) {
|
|
|
3565
3545
|
try {
|
|
3566
3546
|
const rows = readProgressRows(root);
|
|
3567
3547
|
const tasksInProgress = rows.filter(r => r.status === 'in-progress').length;
|
|
3568
|
-
const
|
|
3569
|
-
const decisionBlocks = _extractDecisionBlocks(dm);
|
|
3570
|
-
const decisionCount = decisionBlocks.map(b => {
|
|
3571
|
-
const m = b.match(/^### (.+)$/m);
|
|
3572
|
-
return m ? '### ' + m[1].trim() : null;
|
|
3573
|
-
}).filter(Boolean).length;
|
|
3548
|
+
const decisionCount = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
|
|
3574
3549
|
const rulesActive = readRules(root).filter(r => r.status === 'active').length;
|
|
3575
3550
|
const planText = exists(planPath(root)) ? read(planPath(root)) : '';
|
|
3576
3551
|
const milestonesCnt = (planText.match(/^### M-\d{4}\./gm) || []).length;
|
|
@@ -4452,7 +4427,7 @@ function _buildAutoResumePlan(root, opts) {
|
|
|
4452
4427
|
// memory surface counts
|
|
4453
4428
|
const memorySurface = {
|
|
4454
4429
|
tasksInProgress: rows.filter(r => r.status === 'in-progress').length,
|
|
4455
|
-
decisions:
|
|
4430
|
+
decisions: _loadDecisions(root).length, // 1.9.339 (UR-0053): canonical 단일 진실소스
|
|
4456
4431
|
rulesActive: readRules(root).filter(r => r.status === 'active').length,
|
|
4457
4432
|
lessons: exists(lessonsPath(root)) ? _countDatedBlocks(read(lessonsPath(root))) : 0
|
|
4458
4433
|
};
|
|
@@ -5882,6 +5857,24 @@ const evidencePath = root => path.join(root, '.harness/review-evidence.md');
|
|
|
5882
5857
|
const handoffPath = root => path.join(root, '.harness/session-handoff.md');
|
|
5883
5858
|
const currentStatePath = root => path.join(root, '.harness/current-state.md');
|
|
5884
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
|
+
}
|
|
5885
5878
|
// 1.9.112: 전용 lessons.md (Memory Write Surface 5번째)
|
|
5886
5879
|
const lessonsPath = root => path.join(root, '.harness/lessons.md');
|
|
5887
5880
|
|
|
@@ -6234,13 +6227,9 @@ function memoryStatusCmd(root, opts = {}) {
|
|
|
6234
6227
|
for (const r of rows) tasksByStatus[r.status] = (tasksByStatus[r.status] || 0) + 1;
|
|
6235
6228
|
const tasksTotal = rows.length;
|
|
6236
6229
|
const tasksInProgress = tasksByStatus['in-progress'] || 0;
|
|
6237
|
-
// Decisions (1.9.
|
|
6238
|
-
const
|
|
6239
|
-
const
|
|
6240
|
-
const decisionHeaders = decisionBlocks.map(b => {
|
|
6241
|
-
const m = b.match(/^### (.+)$/m);
|
|
6242
|
-
return m ? '### ' + m[1].trim() : null;
|
|
6243
|
-
}).filter(Boolean);
|
|
6230
|
+
// Decisions (1.9.339 UR-0053: canonical _loadDecisions — JSON 단일 진실소스, MD projection fallback)
|
|
6231
|
+
const _decs = _loadDecisions(root);
|
|
6232
|
+
const decisionHeaders = _decs.map(d => '### ' + (d.date ? d.date + ' — ' + d.title : d.title));
|
|
6244
6233
|
const decisionLatest = decisionHeaders.length ? decisionHeaders[decisionHeaders.length - 1].replace(/^### /, '') : null;
|
|
6245
6234
|
// Rules
|
|
6246
6235
|
const rules = readRules(root);
|
|
@@ -6552,46 +6541,22 @@ function decisionListCmd(root, opts = {}) {
|
|
|
6552
6541
|
// 1.9.139: --query 필터 (title/decision/reason case-insensitive 매칭)
|
|
6553
6542
|
const queryFilter = arg('--query', null);
|
|
6554
6543
|
const queryRe = queryFilter ? new RegExp(queryFilter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') : null;
|
|
6555
|
-
|
|
6556
|
-
|
|
6544
|
+
// 1.9.339 (UR-0053): canonical 로더(_loadDecisions) — decisions.json 우선, MD fallback. 단일 진실소스.
|
|
6545
|
+
const all = _loadDecisions(root);
|
|
6546
|
+
if (!all.length) {
|
|
6557
6547
|
if (jsonMode) {
|
|
6558
6548
|
const empty = { version: VERSION, root, total: 0, decisions: [] };
|
|
6559
6549
|
if (queryFilter) empty.query = queryFilter;
|
|
6560
6550
|
process.stdout.write(JSON.stringify(empty, null, 2) + '\n');
|
|
6561
6551
|
return;
|
|
6562
6552
|
}
|
|
6563
|
-
return ok('decisions
|
|
6564
|
-
}
|
|
6565
|
-
const text = read(dp);
|
|
6566
|
-
const blocks = _extractDecisionBlocks(text);
|
|
6567
|
-
const decisions = [];
|
|
6568
|
-
for (const block of blocks) {
|
|
6569
|
-
const titleMatch = block.match(/^### (.+)$/m);
|
|
6570
|
-
if (!titleMatch) continue;
|
|
6571
|
-
const titleLine = titleMatch[1].trim();
|
|
6572
|
-
// 형식: "YYYY-MM-DD — <title>" 또는 "<title>" 단독
|
|
6573
|
-
const dateTitleMatch = titleLine.match(/^(\d{4}-\d{2}-\d{2})\s*—\s*(.+)$/);
|
|
6574
|
-
const date = dateTitleMatch ? dateTitleMatch[1] : null;
|
|
6575
|
-
const title = dateTitleMatch ? dateTitleMatch[2].trim() : titleLine;
|
|
6576
|
-
const decisionMatch = block.match(/- Decision:[ \t]*(.+)/);
|
|
6577
|
-
const reasonMatch = block.match(/- Reason:[ \t]*(.+)/);
|
|
6578
|
-
const alternativesMatch = block.match(/- Alternatives:[ \t]*(.+)/);
|
|
6579
|
-
const impactMatch = block.match(/- Impact:[ \t]*(.+)/);
|
|
6580
|
-
const entry = {
|
|
6581
|
-
date,
|
|
6582
|
-
title,
|
|
6583
|
-
decision: decisionMatch ? decisionMatch[1].trim() : null,
|
|
6584
|
-
reason: reasonMatch ? reasonMatch[1].trim() : null,
|
|
6585
|
-
alternatives: alternativesMatch ? alternativesMatch[1].trim() : null,
|
|
6586
|
-
impact: impactMatch ? impactMatch[1].trim() : null,
|
|
6587
|
-
};
|
|
6588
|
-
// 1.9.139: query 필터 — title/decision/reason 매칭
|
|
6589
|
-
if (queryRe) {
|
|
6590
|
-
const hay = [entry.title, entry.decision, entry.reason, entry.alternatives, entry.impact].filter(Boolean).join(' ');
|
|
6591
|
-
if (!queryRe.test(hay)) continue;
|
|
6592
|
-
}
|
|
6593
|
-
decisions.push(entry);
|
|
6553
|
+
return ok('decisions 비어있음 — leerness decision add "<title>" 로 첫 결정 영구화');
|
|
6594
6554
|
}
|
|
6555
|
+
// 1.9.139: query 필터 — title/decision/reason/alternatives/impact case-insensitive 매칭
|
|
6556
|
+
const decisions = queryRe ? all.filter(entry => {
|
|
6557
|
+
const hay = [entry.title, entry.decision, entry.reason, entry.alternatives, entry.impact].filter(Boolean).join(' ');
|
|
6558
|
+
return queryRe.test(hay);
|
|
6559
|
+
}) : all.slice();
|
|
6595
6560
|
if (jsonMode) {
|
|
6596
6561
|
const payload = { version: VERSION, root, total: decisions.length, decisions };
|
|
6597
6562
|
if (queryFilter) payload.query = queryFilter;
|
|
@@ -6613,44 +6578,26 @@ function decisionListCmd(root, opts = {}) {
|
|
|
6613
6578
|
function decisionDropCmd(root, target) {
|
|
6614
6579
|
root = absRoot(root);
|
|
6615
6580
|
if (!target) return fail('decision drop <date|title-substring> 필요. 예: leerness decision drop "2026-05-20" 또는 leerness decision drop "PostgreSQL"');
|
|
6616
|
-
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
// 코드블록 제외해서 안전하게 처리 (template 제외)
|
|
6620
|
-
// 단순 split: ### 으로 시작하는 블록만
|
|
6621
|
-
const blocks = text.split(/\n(?=### )/);
|
|
6622
|
-
let removed = 0;
|
|
6581
|
+
// 1.9.339 (UR-0053): canonical JSON 기준 drop (date 또는 title substring 매칭) — JSON+MD projection 동시 갱신.
|
|
6582
|
+
const all = _loadDecisions(root);
|
|
6583
|
+
if (!all.length) return fail('decisions 없음');
|
|
6623
6584
|
const kept = [];
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
6630
|
-
|
|
6631
|
-
|
|
6632
|
-
|
|
6633
|
-
|
|
6634
|
-
|
|
6635
|
-
|
|
6636
|
-
|
|
6637
|
-
|
|
6638
|
-
|
|
6639
|
-
|
|
6640
|
-
const isTitleTarget = title.includes(target);
|
|
6641
|
-
if (isDateTarget || isTitleTarget) {
|
|
6642
|
-
removed++;
|
|
6643
|
-
// archive 보존
|
|
6644
|
-
const archivePath = path.join(root, '.harness/decisions.archive.md');
|
|
6645
|
-
const archiveHeader = exists(archivePath) ? '' : '# Decisions archive\n\n';
|
|
6646
|
-
append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${target}")\n${b}\n`);
|
|
6647
|
-
continue;
|
|
6648
|
-
}
|
|
6649
|
-
kept.push(b);
|
|
6650
|
-
}
|
|
6651
|
-
if (removed === 0) return fail(`매칭 decision 없음: "${target}"`);
|
|
6652
|
-
writeUtf8(dp, kept.join('\n'));
|
|
6653
|
-
ok(`decision dropped: ${removed}건 (보존: .harness/decisions.archive.md)`);
|
|
6585
|
+
const removed = [];
|
|
6586
|
+
for (const d of all) {
|
|
6587
|
+
if (d.date === target || (d.title || '').includes(target)) removed.push(d);
|
|
6588
|
+
else kept.push(d);
|
|
6589
|
+
}
|
|
6590
|
+
if (!removed.length) return fail(`매칭 decision 없음: "${target}"`);
|
|
6591
|
+
// archive 보존 (projection MD 블록 형태)
|
|
6592
|
+
const archivePath = path.join(root, '.harness/decisions.archive.md');
|
|
6593
|
+
const archiveHeader = exists(archivePath) ? '' : '# Decisions archive\n\n';
|
|
6594
|
+
const archiveBlocks = removed.map(d => {
|
|
6595
|
+
const head = d.date ? `${d.date} — ${d.title}` : d.title;
|
|
6596
|
+
return `\n### ${head}\n- Decision: ${d.decision || ''}\n- Reason: ${d.reason || ''}\n- Alternatives: ${d.alternatives || ''}\n- Impact: ${d.impact || ''}\n`;
|
|
6597
|
+
}).join('');
|
|
6598
|
+
append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${target}")\n${archiveBlocks}\n`);
|
|
6599
|
+
_saveDecisions(root, kept);
|
|
6600
|
+
ok(`decision dropped: ${removed.length}건 (보존: .harness/decisions.archive.md)`);
|
|
6654
6601
|
_autoRoadmap(absRoot(root), 'data-change');
|
|
6655
6602
|
}
|
|
6656
6603
|
|
|
@@ -6659,16 +6606,19 @@ function decisionAdd(root, title) {
|
|
|
6659
6606
|
root = absRoot(root);
|
|
6660
6607
|
if (!_requireInit(root, 'decision add')) return; // 1.9.311 (UR-0047): init 가드
|
|
6661
6608
|
if (!title) return fail('decision title required. 예: leerness decision add "PostgreSQL 채택" --reason "..." ');
|
|
6662
|
-
const dp = decisionsPath(root);
|
|
6663
6609
|
const reason = arg('--reason', '');
|
|
6664
6610
|
const alternatives = arg('--alternatives', '');
|
|
6665
6611
|
const impact = arg('--impact', '');
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
6669
|
-
|
|
6670
|
-
|
|
6671
|
-
|
|
6612
|
+
// 1.9.339 (UR-0053): canonical JSON write path — 기존 항목(JSON 우선, 없으면 MD backfill) 로드 후 추가, JSON+MD projection 동시 저장.
|
|
6613
|
+
const all = _loadDecisions(root);
|
|
6614
|
+
all.push({
|
|
6615
|
+
date: today(), title,
|
|
6616
|
+
decision: title,
|
|
6617
|
+
reason: reason || null,
|
|
6618
|
+
alternatives: alternatives || null,
|
|
6619
|
+
impact: impact || null
|
|
6620
|
+
});
|
|
6621
|
+
_saveDecisions(root, all);
|
|
6672
6622
|
ok(`decision added: ${title}`);
|
|
6673
6623
|
// 1.9.43+ handoff lessons 회수 흐름과 자동 통합 (decisions.md fuzzy 매칭됨)
|
|
6674
6624
|
_autoRoadmap(absRoot(root), 'data-change');
|
|
@@ -7460,9 +7410,7 @@ function handoff(root) {
|
|
|
7460
7410
|
for (const s of STATUSES) tasksByStatus[s] = 0;
|
|
7461
7411
|
for (const r of rows) tasksByStatus[r.status] = (tasksByStatus[r.status] || 0) + 1;
|
|
7462
7412
|
const tasksInProgress = tasksByStatus['in-progress'] || 0;
|
|
7463
|
-
const
|
|
7464
|
-
const decisionBlocks = _extractDecisionBlocks(dm);
|
|
7465
|
-
const decisionsCount = decisionBlocks.length;
|
|
7413
|
+
const decisionsCount = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
|
|
7466
7414
|
const rules = readRules(root);
|
|
7467
7415
|
const rulesActive = rules.filter(r => r.status === 'active').length;
|
|
7468
7416
|
const planText = exists(planPath(root)) ? read(planPath(root)) : '';
|
|
@@ -7734,7 +7682,7 @@ function handoff(root) {
|
|
|
7734
7682
|
try {
|
|
7735
7683
|
const rows = readProgressRows(root);
|
|
7736
7684
|
const inProgressTasks = rows.filter(r => r.status === 'in-progress').length;
|
|
7737
|
-
const decisions =
|
|
7685
|
+
const decisions = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
|
|
7738
7686
|
const rulesActive = readRules(root).filter(r => r.status === 'active').length;
|
|
7739
7687
|
const planText = exists(planPath(root)) ? read(planPath(root)) : '';
|
|
7740
7688
|
const planMilestones = (planText.match(/^### M-\d{4}\./gm) || []).length;
|
|
@@ -12071,8 +12019,7 @@ function sessionClose(root, opts = {}) {
|
|
|
12071
12019
|
for (const s of STATUSES) tasksByStatus0[s] = 0;
|
|
12072
12020
|
for (const r of rows0) tasksByStatus0[r.status] = (tasksByStatus0[r.status] || 0) + 1;
|
|
12073
12021
|
const tasksInProgress0 = tasksByStatus0['in-progress'] || 0;
|
|
12074
|
-
const
|
|
12075
|
-
const decisionsCount0 = _extractDecisionBlocks(dm0).length;
|
|
12022
|
+
const decisionsCount0 = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
|
|
12076
12023
|
const rules0 = readRules(root);
|
|
12077
12024
|
const rulesActive0 = rules0.filter(r => r.status === 'active').length;
|
|
12078
12025
|
const planText0 = exists(planPath(root)) ? read(planPath(root)) : '';
|
|
@@ -12411,8 +12358,7 @@ function sessionClose(root, opts = {}) {
|
|
|
12411
12358
|
const ms = _computeMilestones(root);
|
|
12412
12359
|
const rows = readProgressRows(root);
|
|
12413
12360
|
const tIn = rows.filter(r => r.status === 'in-progress').length;
|
|
12414
|
-
const
|
|
12415
|
-
const dCnt = _extractDecisionBlocks(dm).length;
|
|
12361
|
+
const dCnt = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
|
|
12416
12362
|
const rActive = readRules(root).filter(r => r.status === 'active').length;
|
|
12417
12363
|
const planText = exists(planPath(root)) ? read(planPath(root)) : '';
|
|
12418
12364
|
const pCnt = (planText.match(/^### M-\d{4}\./gm) || []).length;
|
|
@@ -18045,20 +17991,10 @@ function contextCmd(root, opts = {}) {
|
|
|
18045
17991
|
const active = rows.find(r => r.status === 'in-progress' || r.status === '[진행]');
|
|
18046
17992
|
const activeRules = readRules(root).filter(r => r.status === 'active');
|
|
18047
17993
|
const openReqs = (_loadUserRequests(root).requests || []).filter(r => r.status === 'open' || r.status === 'in-progress');
|
|
18048
|
-
// 최근 결정 (last 3, 최신순) —
|
|
18049
|
-
const
|
|
18050
|
-
|
|
18051
|
-
|
|
18052
|
-
if (exists(decFile)) {
|
|
18053
|
-
const dtext = read(decFile);
|
|
18054
|
-
decisionCount = _countDatedBlocks(dtext);
|
|
18055
|
-
const blocks = _extractDecisionBlocks(dtext);
|
|
18056
|
-
recentDecisions = blocks.slice(-3).reverse().map(block => {
|
|
18057
|
-
const tm = (block.match(/^### (.+)$/m) || [])[1] || '';
|
|
18058
|
-
const dt = tm.match(/^(\d{4}-\d{2}-\d{2})\s*—\s*(.+)$/);
|
|
18059
|
-
return { date: dt ? dt[1] : null, title: (dt ? dt[2] : tm).trim().slice(0, 100) };
|
|
18060
|
-
});
|
|
18061
|
-
}
|
|
17994
|
+
// 최근 결정 (last 3, 최신순) — 1.9.339 (UR-0053): canonical _loadDecisions (JSON 단일 진실소스, MD projection fallback)
|
|
17995
|
+
const _decs = _loadDecisions(root);
|
|
17996
|
+
const decisionCount = _decs.length;
|
|
17997
|
+
const recentDecisions = _decs.slice(-3).reverse().map(d => ({ date: d.date || null, title: (d.title || '').slice(0, 100) }));
|
|
18062
17998
|
const queueState = _loadNextActionQueue(root);
|
|
18063
17999
|
const nextActions = (queueState.queue || []).slice(-3).reverse().map(a => ({ title: a.title, command: a.command || null }));
|
|
18064
18000
|
const memory = {
|
|
@@ -19665,8 +19601,7 @@ function healthCmd(root) {
|
|
|
19665
19601
|
for (const s of STATUSES) tasksByStatus[s] = 0;
|
|
19666
19602
|
for (const r of rows) tasksByStatus[r.status] = (tasksByStatus[r.status] || 0) + 1;
|
|
19667
19603
|
const tasksInProgress = tasksByStatus['in-progress'] || 0;
|
|
19668
|
-
const
|
|
19669
|
-
const decisionsCount = _extractDecisionBlocks(dm).length;
|
|
19604
|
+
const decisionsCount = _loadDecisions(root).length; // 1.9.339 (UR-0053): canonical 단일 진실소스
|
|
19670
19605
|
const rules = readRules(root);
|
|
19671
19606
|
const rulesActive = rules.filter(r => r.status === 'active').length;
|
|
19672
19607
|
const planText = exists(planPath(root)) ? read(planPath(root)) : '';
|
package/lib/catalogs.js
CHANGED
|
@@ -316,4 +316,30 @@ const BUILT_IN_PERSONAS = {
|
|
|
316
316
|
}
|
|
317
317
|
};
|
|
318
318
|
|
|
319
|
-
|
|
319
|
+
|
|
320
|
+
// 1.9.338 (UR-0025 심층): i18n 문자열 catalog (ko/en) — harness 에서 분리. 조회는 순수 _translate(STRINGS, key, lang).
|
|
321
|
+
const STRINGS = {
|
|
322
|
+
// 설치 가이드 prompt
|
|
323
|
+
'install.lang.title': { ko: '설치 언어를 선택하세요', en: 'Select install language' },
|
|
324
|
+
'install.lang.auto': { ko: '자동 감지', en: 'Auto detect' },
|
|
325
|
+
'install.lang.auto.desc': { ko: '디렉토리/파일 + 시스템(OS) 언어 자동 판별 (한국어/영어)', en: 'Auto-detect from dir/files + system (OS) locale (KO/EN)' },
|
|
326
|
+
'install.lang.sysNotice': { ko: '시스템 언어 감지', en: 'System language detected' },
|
|
327
|
+
'install.lang.ko': { ko: '한국어', en: 'Korean' },
|
|
328
|
+
'install.lang.ko.desc': { ko: '모든 인스트럭션을 한국어로 생성', en: 'All instructions in Korean' },
|
|
329
|
+
'install.lang.en': { ko: 'English', en: 'English' },
|
|
330
|
+
'install.lang.en.desc': { ko: '모든 인스트럭션을 영어로 생성', en: 'All instructions in English' },
|
|
331
|
+
'install.agents.title': { ko: 'CLI 에이전트 활성화 (복수 선택, Space 토글) — sub-agent 위임용',
|
|
332
|
+
en: 'Enable CLI agents (multi-select, Space toggle) — for sub-agent dispatch' },
|
|
333
|
+
'install.agents.none': { ko: '선택 안함 (나중에 setup-agents)', en: 'None (later setup-agents)' },
|
|
334
|
+
'install.complete': { ko: '✓ 설치 완료', en: '✓ Install complete' },
|
|
335
|
+
// REPL agent 모드
|
|
336
|
+
'repl.welcome.title': { ko: 'leerness REPL agent', en: 'leerness REPL agent' },
|
|
337
|
+
'repl.welcome.subtitle': { ko: 'Tab provider · Shift+Tab model · :help · /slash', en: 'Tab provider · Shift+Tab model · :help · /slash' },
|
|
338
|
+
'repl.welcome.start': { ko: '시작하려면 메시지를 입력하세요', en: 'Type a message to start' },
|
|
339
|
+
// 공통
|
|
340
|
+
'common.cancel': { ko: '취소됨', en: 'Cancelled' },
|
|
341
|
+
'common.confirm': { ko: '확인', en: 'Confirm' },
|
|
342
|
+
'common.ready': { ko: '준비 완료', en: 'Ready' }
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
module.exports = { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS };
|
package/lib/pure-utils.js
CHANGED
|
@@ -508,6 +508,46 @@ function _personaSummaries(catalog) {
|
|
|
508
508
|
return Object.values(catalog || {}).map(p => ({ id: p.id, name: p.name, description: p.description }));
|
|
509
509
|
}
|
|
510
510
|
|
|
511
|
+
// 1.9.338 (UR-0025 심층): i18n 순수 조회 — strings catalog 주입, key → lang 값 (fallback: ko → key 자체)
|
|
512
|
+
function _translate(strings, key, lang) {
|
|
513
|
+
const entry = strings && strings[key];
|
|
514
|
+
if (!entry) return key;
|
|
515
|
+
return entry[lang || 'ko'] || entry.ko || key;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// 1.9.339 (UR-0053): decision MD 블록(문자열) → 정규 객체 (canonical 스키마). list/load 단일 파서.
|
|
519
|
+
function _parseDecisionBlock(block) {
|
|
520
|
+
const titleMatch = String(block || '').match(/^### (.+)$/m);
|
|
521
|
+
const titleLine = titleMatch ? titleMatch[1].trim() : '';
|
|
522
|
+
const dateTitle = titleLine.match(/^(\d{4}-\d{2}-\d{2})\s*—\s*(.+)$/);
|
|
523
|
+
const g = re => { const m = String(block || '').match(re); const v = m ? m[1].trim() : null; return v || null; }; // 빈 값 → null 정규화 (render↔parse round-trip 멱등)
|
|
524
|
+
return {
|
|
525
|
+
date: dateTitle ? dateTitle[1] : null,
|
|
526
|
+
title: dateTitle ? dateTitle[2].trim() : titleLine,
|
|
527
|
+
decision: g(/- Decision:[ \t]*(.+)/),
|
|
528
|
+
reason: g(/- Reason:[ \t]*(.+)/),
|
|
529
|
+
alternatives: g(/- Alternatives:[ \t]*(.+)/),
|
|
530
|
+
impact: g(/- Impact:[ \t]*(.+)/)
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// 1.9.339 (UR-0053): decisions.md 본문 → canonical 객체 배열 (template/code 블록 제외, title 있는 것만).
|
|
535
|
+
function _decisionsFromMd(text) {
|
|
536
|
+
return _extractDecisionBlocks(text).map(_parseDecisionBlock).filter(d => d.title);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// 1.9.339 (UR-0053): canonical 객체 배열 → decisions.md projection (init template preamble 보존, round-trip 안전).
|
|
540
|
+
function _renderDecisionsMd(decisions) {
|
|
541
|
+
// preamble 의 코드펜스(```)는 single-quote 문자열로 안전 처리 (template literal 충돌 회피)
|
|
542
|
+
const preamble = '# Decisions\n\n## Template (예시 — 실제 결정은 아래 코드블록 밖에 추가)\n\n'
|
|
543
|
+
+ '```md\n### YYYY-MM-DD — Decision 제목\n- Decision:\n- Reason:\n- Alternatives:\n- Impact:\n```\n';
|
|
544
|
+
const body = (decisions || []).map(d => {
|
|
545
|
+
const head = d.date ? `${d.date} — ${d.title}` : d.title;
|
|
546
|
+
return `\n### ${head}\n- Decision: ${d.decision || ''}\n- Reason: ${d.reason || ''}\n- Alternatives: ${d.alternatives || ''}\n- Impact: ${d.impact || ''}\n`;
|
|
547
|
+
}).join('');
|
|
548
|
+
return preamble + body;
|
|
549
|
+
}
|
|
550
|
+
|
|
511
551
|
module.exports = {
|
|
512
552
|
_isSecretKey, compareVer, parseHarnessVersion,
|
|
513
553
|
_classifyCJK, _riskLabel, _detectSystemLang, _parseSlashFromHelp,
|
|
@@ -542,5 +582,9 @@ module.exports = {
|
|
|
542
582
|
// anti-laziness optimism-check 순수 로직
|
|
543
583
|
_extractUrlClaims, _verifyUrlClaim, _detectOptimism, _computeConfidence,
|
|
544
584
|
// 1.9.337 (UR-0025 심층): persona 요약 목록
|
|
545
|
-
_personaSummaries
|
|
585
|
+
_personaSummaries,
|
|
586
|
+
// 1.9.338 (UR-0025 심층): i18n 순수 조회
|
|
587
|
+
_translate,
|
|
588
|
+
// 1.9.339 (UR-0053): decisions canonical 파서/렌더 (JSON canonical, MD projection)
|
|
589
|
+
_parseDecisionBlock, _decisionsFromMd, _renderDecisionsMd
|
|
546
590
|
};
|
package/package.json
CHANGED
package/scripts/e2e.js
CHANGED
|
@@ -4251,5 +4251,77 @@ total++;
|
|
|
4251
4251
|
if (!ok) failed++;
|
|
4252
4252
|
}
|
|
4253
4253
|
|
|
4254
|
+
// 1.9.338 회귀 (UR-0025 심층): i18n STRINGS→lib/catalogs + _translate→pure-utils + _t 박막 (인터랙티브 전용 getter라 구조+순수동작 검증)
|
|
4255
|
+
total++;
|
|
4256
|
+
{
|
|
4257
|
+
let ok = false;
|
|
4258
|
+
try {
|
|
4259
|
+
const c = require(path.resolve(__dirname, '..', 'lib', 'catalogs.js'));
|
|
4260
|
+
const m = require(path.resolve(__dirname, '..', 'lib', 'pure-utils.js'));
|
|
4261
|
+
const catOk = c.STRINGS && typeof c.STRINGS['common.ready'] === 'object' && c.STRINGS['common.ready'].en === 'Ready' && c.STRINGS['common.ready'].ko === '준비 완료';
|
|
4262
|
+
const work = catOk && typeof m._translate === 'function'
|
|
4263
|
+
&& m._translate(c.STRINGS, 'common.ready', 'en') === 'Ready'
|
|
4264
|
+
&& m._translate(c.STRINGS, 'common.ready', 'ko') === '준비 완료'
|
|
4265
|
+
&& m._translate(c.STRINGS, 'no.such.key', 'en') === 'no.such.key'
|
|
4266
|
+
&& m._translate(null, 'x', 'ko') === 'x'
|
|
4267
|
+
&& m._translate({ k: { ko: '케이' } }, 'k', 'en') === '케이';
|
|
4268
|
+
const harnessSrc = fs.readFileSync(path.resolve(__dirname, '..', 'bin', 'harness.js'), 'utf8');
|
|
4269
|
+
const _catImp = (harnessSrc.match(/const \{[\s\S]*?\} = require\('\.\.\/lib\/catalogs'\)/) || [''])[0]; // import 순서/추가 비의존
|
|
4270
|
+
const _puImp = (harnessSrc.match(/const \{[\s\S]*?\} = require\('\.\.\/lib\/pure-utils'\)/) || [''])[0];
|
|
4271
|
+
// _t 박막: 인라인 STRINGS 정의 제거 + import + _translate(STRINGS,..) 호출
|
|
4272
|
+
const movedOut = !/const STRINGS = \{/.test(harnessSrc) && _catImp.includes('STRINGS') && _puImp.includes('_translate') && harnessSrc.includes('return _translate(STRINGS, key, lang)');
|
|
4273
|
+
ok = work && movedOut;
|
|
4274
|
+
} catch {}
|
|
4275
|
+
console.log(ok ? '✓ B(1.9.338) UR-0025 심층: i18n STRINGS/_translate 분리 + _t 박막 (UR-0025)' : '✗ i18n 서브시스템 분리 실패');
|
|
4276
|
+
if (!ok) failed++;
|
|
4277
|
+
}
|
|
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
|
+
|
|
4254
4326
|
console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
|
|
4255
4327
|
if (failed > 0) process.exit(1);
|