leerness 1.36.125 → 1.36.126
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/README.md +4 -4
- package/bin/leerness.js +159 -26
- package/lib/audit.js +33 -0
- package/lib/pure-utils.js +10 -6
- package/package.json +1 -1
- package/scripts/e2e.js +168 -1
package/README.md
CHANGED
|
@@ -122,7 +122,7 @@ MIT
|
|
|
122
122
|
<!-- leerness:project-readme:start -->
|
|
123
123
|
## Leerness Project Harness
|
|
124
124
|
|
|
125
|
-
이 프로젝트는 Leerness v1.36.
|
|
125
|
+
이 프로젝트는 Leerness v1.36.126 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
|
|
126
126
|
|
|
127
127
|
### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
|
|
128
128
|
|
|
@@ -176,7 +176,7 @@ leerness memory restore decision <date|title>
|
|
|
176
176
|
|
|
177
177
|
### MCP server (외부 AI 통합)
|
|
178
178
|
|
|
179
|
-
Leerness v1.36.
|
|
179
|
+
Leerness v1.36.126는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
|
|
180
180
|
|
|
181
181
|
```jsonc
|
|
182
182
|
// 카테고리별
|
|
@@ -197,7 +197,7 @@ Leerness v1.36.125는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
|
|
|
197
197
|
`<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
|
|
198
198
|
1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
|
|
199
199
|
|
|
200
|
-
현재 누적: **v1.9.x → 1.36.
|
|
200
|
+
현재 누적: **v1.9.x → 1.36.126 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
|
|
201
201
|
|
|
202
202
|
### 성능 가이드
|
|
203
203
|
|
|
@@ -235,5 +235,5 @@ leerness release pack --close --auto-main-push
|
|
|
235
235
|
- `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
|
|
236
236
|
- `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
|
|
237
237
|
|
|
238
|
-
Last synced by Leerness v1.36.
|
|
238
|
+
Last synced by Leerness v1.36.126: 2026-08-16
|
|
239
239
|
<!-- leerness:project-readme:end -->
|
package/bin/leerness.js
CHANGED
|
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
|
|
|
34
34
|
const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
|
|
35
35
|
const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
|
|
36
36
|
|
|
37
|
-
const VERSION = '1.36.
|
|
37
|
+
const VERSION = '1.36.126';
|
|
38
38
|
|
|
39
39
|
// 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
|
|
40
40
|
// 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
|
|
@@ -3748,7 +3748,8 @@ function _selfTestCases() {
|
|
|
3748
3748
|
} catch {} // 심링크 생성 권한 없는 환경 → 소스 불변식만으로 판정(false-BLOCK 회피)
|
|
3749
3749
|
if (_linked) {
|
|
3750
3750
|
try {
|
|
3751
|
-
|
|
3751
|
+
// 1.36.126: CLI 는 fail-closed 지만 복사 구현의 안전 속성은 계속 시험한다(내부 통로).
|
|
3752
|
+
const _rep = _migrateWorkspaceDir(_st, { dryRun: true, allowUnsupported: true });
|
|
3752
3753
|
const _skipped = _rep.skippedFiles.some(f => /^loop\b/.test(f) && f.includes('symlink'));
|
|
3753
3754
|
const _notFollowed = !_rep.copiedFiles.some(f => /^loop[\\/]/.test(f));
|
|
3754
3755
|
symlinkGuard = symlinkGuard && _skipped && _notFollowed;
|
|
@@ -4261,7 +4262,7 @@ function _selfTestCases() {
|
|
|
4261
4262
|
{ name: 'UR-0074: _cadenceAssessment 릴리스 빈도 평가 (임계값) 행위 (1.9.374)', run: () => { const m = require('../lib/pure-utils'); if (typeof _cadenceAssessment !== 'function' || m._cadenceAssessment !== _cadenceAssessment || typeof releaseCadenceCmd !== 'function') return false; return _cadenceAssessment(7, 1, 1).level === 'very-high' && _cadenceAssessment(3, 1, 1).level === 'high' && _cadenceAssessment(1, 1, 1).level === 'moderate' && _cadenceAssessment(0.2, 1, 1).level === 'healthy' && _cadenceAssessment(7, 1, 1).recommendation.length > 0; } },
|
|
4262
4263
|
{ name: 'UR-0084: _withLock 획득/재진입/해제 + maxWaitMs 하드닝(10s) 행위 (1.9.375)', run: () => { if (typeof _withLock !== 'function') return false; const src = read(__filename); const hardened = /maxWaitMs = opts\.maxWaitMs \|\| 10000/.test(src); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_lock_')); try { const target = path.join(tmp, 'f.md'); let reentrant = false; const lockSeen = _withLock(target, () => { const exists = fs.existsSync(target + '.lock'); reentrant = (_withLock(target, () => 42) === 42); return exists; }); const cleaned = !fs.existsSync(target + '.lock'); return hardened && lockSeen === true && reentrant === true && cleaned; } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } } },
|
|
4263
4264
|
{ name: 'UR-0073 Phase D: _teamDeployGate 이중 게이트 (dry-run 기본/env 게이트/실행) 행위 (1.9.376)', run: () => { const m = require('../lib/pure-utils'); if (typeof _teamDeployGate !== 'function' || m._teamDeployGate !== _teamDeployGate) return false; const team = { id: 'd', deployCommand: 'echo hi' }; const noCmd = _teamDeployGate({ id: 'x' }, { yes: true, envOn: true }).mode === 'no-command'; const dry = _teamDeployGate(team, { yes: false, envOn: true }).mode === 'dry-run'; const gated = _teamDeployGate(team, { yes: true, envOn: false }).mode === 'gated'; const exec = _teamDeployGate(team, { yes: true, envOn: true }).mode === 'execute'; return noCmd && dry && gated && exec; } },
|
|
4264
|
-
{ name: 'UR-0025: _renderWorkspaceReferenceGuide 모듈 분리 + 빌더 동작 (1.9.377)', run: () => { const m = require('../lib/pure-utils'); if (typeof _renderWorkspaceReferenceGuide !== 'function' || m._renderWorkspaceReferenceGuide !== _renderWorkspaceReferenceGuide) return false; const g = _renderWorkspaceReferenceGuide('.leerness', '9.9.9', '2026-01-01T00:00:00.000Z'); const wrapperThin = read(__filename).includes('return _renderWorkspaceReferenceGuide(dirName, VERSION, new Date().toISOString())'); return g.includes('.leerness/progress-tracker.md') && g.includes('9.9.9') && g.includes('자주 묻는 위치') &&
|
|
4265
|
+
{ name: 'UR-0025: _renderWorkspaceReferenceGuide 모듈 분리 + 빌더 동작 (1.9.377)', run: () => { const m = require('../lib/pure-utils'); if (typeof _renderWorkspaceReferenceGuide !== 'function' || m._renderWorkspaceReferenceGuide !== _renderWorkspaceReferenceGuide) return false; const g = _renderWorkspaceReferenceGuide('.leerness', '9.9.9', '2026-01-01T00:00:00.000Z'); const wrapperThin = read(__filename).includes('return _renderWorkspaceReferenceGuide(dirName, VERSION, new Date().toISOString())'); /* 1.36.126 (T-0107): 제목 리터럴('마이그레이션 안내')을 붙잡던 자리 — 그 절이 바로 **AI 를 죽은 사본으로 안내**하고 있었다. 제목이 아니라 계약을 건다: ① `.leerness` 상황을 다루는 절이 여전히 있고 ② 그 절이 "우선 사용/가야 함" 같은 **지시**를 하지 않는다(생성 결과 문자열에 대한 행위 단언 — 소스 자기참조가 아니다). */ const guideSafe = /\.leerness/.test(g) && !/우선 사용/.test(g) && !/로 가야 함/.test(g) && /읽지 마십시오/.test(g); return g.includes('.leerness/progress-tracker.md') && g.includes('9.9.9') && g.includes('자주 묻는 위치') && guideSafe && wrapperThin; } },
|
|
4265
4266
|
{ name: 'UR-0073: team MCP 도구 2종(read-only) 정의 + dispatch 와이어 (1.9.378)', run: () => { const tools = require('../lib/mcp-tools'); const src = read(__filename); const tl = tools.find(t => t.name === 'leerness_team_list'); const tp = tools.find(t => t.name === 'leerness_team_preview'); const defsOk = tl && tl.requiredTier === 'read-only' && tp && tp.requiredTier === 'read-only' && tp.inputSchema.required && tp.inputSchema.required.includes('id'); const wired = src.includes("case " + "'leerness_team_list':") && src.includes("case " + "'leerness_team_preview':") && /cliArgs = \['team', 'list'/.test(src) && /cliArgs = \['team', 'preview'/.test(src); return !!defsOk && wired; } },
|
|
4266
4267
|
{ name: 'UR-0025 심화: pulse 렌더 코어 분리 — _memorySurface + _renderPulseLine 행위 (1.9.379)', run: () => { const m = require('../lib/pure-utils'); if (typeof _memorySurface !== 'function' || typeof _renderPulseLine !== 'function' || m._memorySurface !== _memorySurface || m._renderPulseLine !== _renderPulseLine) return false; const ms = _memorySurface({ tasks: 1, decisions: 2, rules: 3, milestones: 4, lessons: 5 }) === 'T1/D2/R3/P4/L5' && _memorySurface({}) === 'T0/D0/R0/P0/L0'; const base = _renderPulseLine({ version: '1.0.0', roundCount: 7, mcpTools: 85, memorySurface: 'T0/D1/R0/P2/L0' }); const ln = base.includes('v1.0.0') && base.includes('R7') && base.includes('MCP 85') && base.includes('T0/D1/R0/P2/L0') && !base.includes('🎯') && !base.includes('abnormal'); const full = _renderPulseLine({ version: '1.0.0', roundCount: 7, mcpTools: 85, memorySurface: 'x', nextMilestone: 400, etaDays: 6, abnormalShutdown: 'high' }); const ln2 = full.includes('🎯 R400 (6d)') && full.includes('abnormal:high'); const wired = read(__filename).includes('const line = _renderPulseLine(data)') && read(__filename).includes('data.memorySurface = _memorySurface('); return ms && ln && ln2 && wired; } },
|
|
4267
4268
|
{ name: 'UR-0025: REQUIRED_WORKSPACE_FILES 단일출처 — verify/migrate audit·apply 3중 중복 제거 (1.9.380)', run: () => { const c = require('../lib/catalogs'); if (REQUIRED_WORKSPACE_FILES !== c.REQUIRED_WORKSPACE_FILES) return false; const listOk = Array.isArray(c.REQUIRED_WORKSPACE_FILES) && c.REQUIRED_WORKSPACE_FILES.length === 9 && c.REQUIRED_WORKSPACE_FILES.includes('AGENTS.md') && c.REQUIRED_WORKSPACE_FILES.includes('.harness/plan.md'); const harnessUses = (read(__filename).match(/const required = REQUIRED_WORKSPACE_FILES;/g) || []).length >= 1; const migUses = (read(path.join(path.dirname(__filename), '..', 'lib', 'migrate.js')).match(/const required = REQUIRED_WORKSPACE_FILES;/g) || []).length >= 2; return listOk && harnessUses && migUses; } },
|
|
@@ -6692,6 +6693,45 @@ function _selfTestCases() {
|
|
|
6692
6693
|
} finally { if (saved !== undefined) process.env.LEERNESS_MCP_TIMEOUT_MS = saved; else delete process.env.LEERNESS_MCP_TIMEOUT_MS; }
|
|
6693
6694
|
} },
|
|
6694
6695
|
// 1.36.125: 측정 통제 자체의 가드 — 목록이 비거나 배선이 끊기면 selftest 는 다시 사용자 설정에 휘둘린다.
|
|
6696
|
+
// 1.36.126 (T-0107): `.leerness` 를 살아 있는 저장소로 **주장하지 않는다**.
|
|
6697
|
+
// ⚠ 이 케이스는 **행위검사만** 한다(자기 소스 읽기 금지 — 1.36.84 메타가드). 상수와 하드코딩 수의
|
|
6698
|
+
// 모순 금지 불변식은 e2e V 블록이 소스를 읽어 건다. 여기서는 실제 함수를 불러 결과를 본다.
|
|
6699
|
+
{ name: '1.36.126 (T-0107): 워크스페이스 대체 디렉터리 — 해석기가 살아 있는 저장소를 말함 + 무시 고지 + 대조군 침묵 (행위검사)', run: () => {
|
|
6700
|
+
// 행위 ① 해석기는 env·마커가 있어도 살아 있는 저장소를 말한다(예전엔 `.leerness` 를 말해 미초기화 오판을 냈다)
|
|
6701
|
+
const arena = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ws126-'));
|
|
6702
|
+
const savedEnv = process.env.LEERNESS_WORKSPACE_DIR;
|
|
6703
|
+
try {
|
|
6704
|
+
fs.mkdirSync(path.join(arena, '.leerness'), { recursive: true });
|
|
6705
|
+
fs.writeFileSync(path.join(arena, '.leerness', 'MIGRATED_FROM_HARNESS'), 'x');
|
|
6706
|
+
fs.mkdirSync(path.join(arena, '.harness'), { recursive: true });
|
|
6707
|
+
fs.writeFileSync(path.join(arena, '.harness', 'HARNESS_VERSION'), VERSION);
|
|
6708
|
+
process.env.LEERNESS_WORKSPACE_DIR = '.leerness';
|
|
6709
|
+
if (_workspaceDirName(arena) !== '.harness') return false;
|
|
6710
|
+
if (!_workspaceDirAbs(arena).endsWith('.harness')) return false;
|
|
6711
|
+
// 마커가 있어도 초기화된 프로젝트로 인식해야 한다(예전엔 여기서 "미초기화" 오판이 났다)
|
|
6712
|
+
if (_isInitialized(arena) !== true) return false;
|
|
6713
|
+
// 행위 ② 무시한 설정은 **말한다** — env·마커 각각, 그리고 아무것도 없으면 침묵(오탐 0)
|
|
6714
|
+
const both = _workspaceDirAltIgnored(arena);
|
|
6715
|
+
if (!both || both.env !== '.leerness' || both.marker !== true || both.effective !== '.harness') return false;
|
|
6716
|
+
delete process.env.LEERNESS_WORKSPACE_DIR;
|
|
6717
|
+
const markerOnly = _workspaceDirAltIgnored(arena);
|
|
6718
|
+
if (!markerOnly || markerOnly.env !== null || markerOnly.marker !== true) return false;
|
|
6719
|
+
const clean = path.join(arena, 'clean'); fs.mkdirSync(clean, { recursive: true });
|
|
6720
|
+
if (_workspaceDirAltIgnored(clean) !== null) return false; // 대조군 — 평범한 프로젝트는 조용하다
|
|
6721
|
+
// 행위 ③ 코어가 실제로 거부하는가 — CLI 인자 없이 함수를 직접 불러 판정(문자열이 아니라 결과로)
|
|
6722
|
+
const blocked = _migrateWorkspaceDir(arena, { dryRun: true });
|
|
6723
|
+
if (blocked.blocked !== true || blocked.blockedReason !== 'workspace-dir-alt-unsupported') return false;
|
|
6724
|
+
if ((blocked.copiedFiles || []).length !== 0) return false;
|
|
6725
|
+
// 그리고 내부 시험 통로는 여전히 복사 구현을 태운다(안전 속성이 도달 불가로 썩지 않게)
|
|
6726
|
+
const seam = _migrateWorkspaceDir(arena, { dryRun: true, allowUnsupported: true });
|
|
6727
|
+
if (seam.blocked === true) return false;
|
|
6728
|
+
return true;
|
|
6729
|
+
} catch { return false; }
|
|
6730
|
+
finally {
|
|
6731
|
+
if (savedEnv !== undefined) process.env.LEERNESS_WORKSPACE_DIR = savedEnv; else delete process.env.LEERNESS_WORKSPACE_DIR;
|
|
6732
|
+
try { fs.rmSync(arena, { recursive: true, force: true }); } catch {}
|
|
6733
|
+
}
|
|
6734
|
+
} },
|
|
6695
6735
|
{ name: '1.36.125 (검수 P2): selftest 측정 통제 — 통제 목록 실재 + 실제 삭제 + 무시 사실 보고(조용한 무시 금지)', run: () => {
|
|
6696
6736
|
const must = ['LEERNESS_MCP_PROFILE', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG', 'LEERNESS_MCP_TIMEOUT_MS'];
|
|
6697
6737
|
if (!must.every(k => _SELFTEST_CONTROLLED_ENV.includes(k))) return false;
|
|
@@ -9201,7 +9241,7 @@ function commandsCmd(root) {
|
|
|
9201
9241
|
{ cmd: 'migrate [path] [--dry-run]', desc: '업데이트 마이그레이션', descEn: 'update migration' },
|
|
9202
9242
|
{ cmd: 'update [--check|--yes|--force]', desc: '자가 업데이트', descEn: 'self-update' },
|
|
9203
9243
|
{ cmd: 'wakeup-interval get|set|auto|history|record', desc: 'adaptive wakeup (1.9.210)', descEn: 'adaptive wakeup interval (1.9.210)' },
|
|
9204
|
-
{ cmd: 'workspace-dir get|guide', desc: '워크스페이스 디렉토리 (1.
|
|
9244
|
+
{ cmd: 'workspace-dir get|guide', desc: '워크스페이스 디렉토리 확인 — 이 빌드는 .harness 만 사용 (migrate-workspace-dir 는 1.36.126 부터 거부, T-0107)', descEn: 'show the workspace directory — this build uses .harness only (migrate-workspace-dir refuses since 1.36.126, T-0107)' },
|
|
9205
9245
|
{ cmd: 'parent detect|adopt [--select <kinds>] [--apply]', desc: '상위 leerness 부모 탐지 + 자산 게이트형 adopt (1.30.2~3)', descEn: 'detect a parent leerness above + gated asset adoption (1.30.2~3)' },
|
|
9206
9246
|
{ cmd: 'intent classify|expand|domains "<request>"', desc: '의도 파악 + scope (1.9.213)', descEn: 'infer intent + scope (1.9.213)' },
|
|
9207
9247
|
{ cmd: 'constraints list|check|add', desc: '플랫폼/API 제약 (1.9.208)', descEn: 'platform/API constraints (1.9.208)' },
|
|
@@ -9756,20 +9796,49 @@ function _computeAdaptiveInterval(root) {
|
|
|
9756
9796
|
// - LEERNESS_WORKSPACE_DIR env 또는 .leerness/MARKER 존재 시 .leerness 사용
|
|
9757
9797
|
// - leerness migrate-workspace-dir CLI: .harness → .leerness copy + AI reference guide 생성
|
|
9758
9798
|
// - AI 참조 가이드: WHERE_TO_FIND.md (디렉토리 구조 + 파일별 역할 + 마이그레이션 history)
|
|
9759
|
-
|
|
9760
|
-
|
|
9761
|
-
|
|
9762
|
-
|
|
9763
|
-
|
|
9764
|
-
|
|
9765
|
-
|
|
9799
|
+
// 1.36.126 (T-0107 실측): 이 해석기는 **소수파**였다. 실측 —
|
|
9800
|
+
// 해석기 소비처 9곳 vs `.harness` 경로 구성 252곳(bin 215 · lib 37) + 안내/템플릿 문자열 634곳.
|
|
9801
|
+
// 그래서 env 나 마커로 `.leerness` 를 고르게 해도 **쓰기는 전부 `.harness` 로 갔다**(실측:
|
|
9802
|
+
// migrate 후 `task add` 가 `.leerness` 에 안 남음. env 를 켜도 동일). 결과는 두 가지 해악이었다:
|
|
9803
|
+
// ① `_isInitialized` 만 `.leerness` 를 봐서 정상 프로젝트를 "미초기화" 로 오판(실측 selftest 4건 실패)
|
|
9804
|
+
// ② `.leerness` 는 마이그레이션 시점에 얼어붙은 **죽은 사본**이 되고 WHERE_TO_FIND.md 가 AI 를 그리로 안내
|
|
9805
|
+
// 즉 소수파를 따르는 것은 "다르게 동작" 이 아니라 **틀리게 동작**이었다. 살아 있는 저장소를 그대로 말한다.
|
|
9806
|
+
// ⚠ 이 상수를 true 로 바꾸려면 하드코딩 252곳을 먼저 없애야 한다 — selftest 가 그 순서를 강제한다.
|
|
9807
|
+
const WORKSPACE_DIR_ALT_SUPPORTED = false;
|
|
9808
|
+
// 거부 사유로 대는 숫자는 **실행 시** 센다 — 손으로 적으면 다음 편집에서 곧바로 낡는다(검수 P2 실측: 252→255).
|
|
9809
|
+
// 거부 경로에서만 불리므로 비용은 문제되지 않는다. 세다가 실패하면 숫자를 지어내지 말고 null 로 둔다.
|
|
9810
|
+
function _measureWorkspaceHardcoding() {
|
|
9811
|
+
const out = { resolverConsumers: null, hardcodedPathSites: null, hardcodedTextMentions: null };
|
|
9766
9812
|
try {
|
|
9767
|
-
|
|
9768
|
-
|
|
9813
|
+
let src = read(__filename);
|
|
9814
|
+
const libDir = path.join(__dirname, '..', 'lib');
|
|
9815
|
+
for (const f of fs.readdirSync(libDir)) if (f.endsWith('.js')) src += '\n' + read(path.join(libDir, f));
|
|
9816
|
+
out.hardcodedPathSites = (src.match(/path\.join\([^)]*'\.harness'/g) || []).length;
|
|
9817
|
+
out.hardcodedTextMentions = (src.match(/\.harness\/[A-Za-z0-9_.-]+/g) || []).length;
|
|
9818
|
+
out.resolverConsumers = (src.match(/_workspaceDir(?:Name|Abs)\(/g) || []).length;
|
|
9819
|
+
} catch { /* 셀 수 없으면 null — 지어내지 않는다 */ }
|
|
9820
|
+
return out;
|
|
9821
|
+
}
|
|
9822
|
+
// 사용자가 켠 설정이 **안 먹는다는 사실**은 반드시 말해야 한다(조용한 무시 금지 — 1.36.125 와 같은 원칙).
|
|
9823
|
+
function _workspaceDirAltIgnored(root) {
|
|
9824
|
+
if (WORKSPACE_DIR_ALT_SUPPORTED) return null;
|
|
9825
|
+
const env = (process.env.LEERNESS_WORKSPACE_DIR || '').trim();
|
|
9826
|
+
let marker = false;
|
|
9827
|
+
try { marker = !!(root && exists(path.join(root, '.leerness', 'MIGRATED_FROM_HARNESS'))); } catch {}
|
|
9828
|
+
if (!env && !marker) return null;
|
|
9829
|
+
return { env: env || null, marker, effective: '.harness' };
|
|
9830
|
+
}
|
|
9831
|
+
function _workspaceDirName(root) {
|
|
9832
|
+
if (WORKSPACE_DIR_ALT_SUPPORTED) {
|
|
9833
|
+
if (process.env.LEERNESS_WORKSPACE_DIR) {
|
|
9834
|
+
const v = process.env.LEERNESS_WORKSPACE_DIR.trim();
|
|
9835
|
+
if (v) return v;
|
|
9769
9836
|
}
|
|
9770
|
-
|
|
9771
|
-
|
|
9772
|
-
|
|
9837
|
+
try {
|
|
9838
|
+
if (root && exists(path.join(root, '.leerness', 'MIGRATED_FROM_HARNESS'))) return '.leerness';
|
|
9839
|
+
} catch {}
|
|
9840
|
+
}
|
|
9841
|
+
return '.harness'; // 살아 있는 저장소 — 위 주석의 실측대로 이 빌드에서는 항상 여기다
|
|
9773
9842
|
}
|
|
9774
9843
|
function _workspaceDirAbs(root) {
|
|
9775
9844
|
return path.join(root, _workspaceDirName(root));
|
|
@@ -9788,7 +9857,12 @@ function _findParentWorkspace(root, opts = {}) {
|
|
|
9788
9857
|
const hasHarness = exists(path.join(cur, '.harness'));
|
|
9789
9858
|
const hasLeerness = exists(path.join(cur, '.leerness'));
|
|
9790
9859
|
if (hasHarness || hasLeerness) {
|
|
9791
|
-
|
|
9860
|
+
// 1.36.126 (T-0107): 여기는 해석기를 안 쓰고 **자체 로직으로 `.leerness` 를 우선**하고 있었다 —
|
|
9861
|
+
// 부모가 옛 migrate 를 돌렸다면 그 `.leerness` 는 죽은 사본이므로, 자식이 **낡은 자산**
|
|
9862
|
+
// (design-system·reuse-map·skills)을 물려받는다. 살아 있는 저장소가 있으면 그쪽이 먼저다.
|
|
9863
|
+
// `.leerness` 만 있는 경우(사용자가 믿고 `.harness` 를 지운 상태)는 부모 탐지 자체를 잃는 것보다
|
|
9864
|
+
// 읽어 주는 편이 낫다 — 읽기 전용 자산 탐지이고, 그 프로젝트는 audit 이 미초기화로 따로 알린다.
|
|
9865
|
+
const wd = hasHarness ? '.harness' : '.leerness';
|
|
9792
9866
|
const wsAbs = path.join(cur, wd);
|
|
9793
9867
|
const assets = {
|
|
9794
9868
|
designSystem: exists(path.join(wsAbs, 'design-system.md')),
|
|
@@ -9821,6 +9895,24 @@ function _migrateWorkspaceDir(root, opts = {}) {
|
|
|
9821
9895
|
errors: [],
|
|
9822
9896
|
dryRun
|
|
9823
9897
|
};
|
|
9898
|
+
// 1.36.126 (T-0107): 이 명령은 **배달할 수 없는 것을 배달했다고 말하고 있었다**. 실측 —
|
|
9899
|
+
// 복사(58파일)는 성공하고 "✓ 마이그레이션 완료 → 다음 handoff부터 .leerness 우선 사용" 이라고 찍는데,
|
|
9900
|
+
// 그 직후 `task add` 는 여전히 `.harness` 에 쓴다. 결과물은 마이그레이션이 아니라 **얼어붙은 사본**이고,
|
|
9901
|
+
// 같이 만들어지는 WHERE_TO_FIND.md 는 AI 를 그 낡은 상태로 안내한다 — 우리가 막으려는 바로 그 실패다.
|
|
9902
|
+
// 그래서 복사를 하지 않는다. 반쯤 된 상태를 만들 바에는 **아무것도 만들지 않는 편이 낫다**(fail-closed).
|
|
9903
|
+
// 되살리려면 하드코딩 252곳을 해석기로 바꾸는 것이 선행 조건이다(T-0107).
|
|
9904
|
+
// ⚠ `allowUnsupported` 는 **내부 시험 전용 통로**다. 복사 구현(심링크 미추종 등 1.36.33 안전 속성)을
|
|
9905
|
+
// 도달 불가로 만들면 그 보호가 조용히 썩는다 — T-0107 이 풀리면 다시 살아날 코드이므로 계속 시험한다.
|
|
9906
|
+
// CLI 경로는 이 옵션을 절대 넘기지 않는다(e2e 가 호출부를 열거해 단언한다).
|
|
9907
|
+
if (!WORKSPACE_DIR_ALT_SUPPORTED && opts.allowUnsupported !== true) {
|
|
9908
|
+
report.blocked = true;
|
|
9909
|
+
report.blockedReason = 'workspace-dir-alt-unsupported';
|
|
9910
|
+
// ⚠ 이 숫자를 손으로 적어 뒀더니 곧바로 낡았다(적을 땐 252, 이 라운드 편집 후 실제 255 — 검수 P2).
|
|
9911
|
+
// "실측" 이라 말하면서 낡은 수를 보여 주는 것은 이 라운드가 고치는 결함과 같은 클래스다. 실행 시 센다.
|
|
9912
|
+
report.measured = _measureWorkspaceHardcoding();
|
|
9913
|
+
report.errors.push('이 빌드는 .leerness 를 살아 있는 저장소로 쓰지 못합니다 — 복사하면 죽은 사본이 됩니다');
|
|
9914
|
+
return report;
|
|
9915
|
+
}
|
|
9824
9916
|
if (!report.srcExists) {
|
|
9825
9917
|
report.errors.push('source .harness not found');
|
|
9826
9918
|
return report;
|
|
@@ -10638,6 +10730,7 @@ function workspaceDirCmd(root, sub) {
|
|
|
10638
10730
|
const isTty = process.stdout && process.stdout.isTTY;
|
|
10639
10731
|
const cyan = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
|
|
10640
10732
|
const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
|
|
10733
|
+
const yel = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
|
|
10641
10734
|
const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
|
|
10642
10735
|
|
|
10643
10736
|
if (!sub || sub === 'get') {
|
|
@@ -10646,19 +10739,37 @@ function workspaceDirCmd(root, sub) {
|
|
|
10646
10739
|
const hasHarness = exists(path.join(root, '.harness'));
|
|
10647
10740
|
const hasLeerness = exists(path.join(root, '.leerness'));
|
|
10648
10741
|
const migrated = exists(path.join(root, '.leerness', 'MIGRATED_FROM_HARNESS'));
|
|
10649
|
-
|
|
10742
|
+
// 1.36.126 (T-0107): `migrated: true` 를 그대로 보여 주면 "옮겨졌다" 는 **거짓 인상**을 준다 —
|
|
10743
|
+
// 실제로는 그 뒤 모든 쓰기가 .harness 로 간다. 마커의 의미를 사실대로 옮겨 적는다.
|
|
10744
|
+
const ignored = _workspaceDirAltIgnored(root);
|
|
10745
|
+
// 1.36.126 (검수 P2): 마커가 지워진 사본도 잡되, 남의 `.leerness` 는 건드리지 않는다 —
|
|
10746
|
+
// 두 번째 신호는 **우리 생성물의 서명**(가이드 헤더). audit 과 같은 술어를 쓴다.
|
|
10747
|
+
let ourGuide = false;
|
|
10748
|
+
try { ourGuide = exists(path.join(root, '.leerness', 'WHERE_TO_FIND.md'))
|
|
10749
|
+
&& /by leerness \d+\.\d+\.\d+/.test(read(path.join(root, '.leerness', 'WHERE_TO_FIND.md'))); } catch {}
|
|
10750
|
+
const staleCopy = (migrated || ourGuide) && dirName === '.harness';
|
|
10751
|
+
const result = { current: dirName, abs: dirAbs, hasHarness, hasLeerness, migrated,
|
|
10752
|
+
altSupported: WORKSPACE_DIR_ALT_SUPPORTED, ignoredSetting: ignored, staleCopy };
|
|
10650
10753
|
if (has('--json')) { log(JSON.stringify(result, null, 2)); return; }
|
|
10651
|
-
log(cyan(`# leerness workspace-dir (1.
|
|
10652
|
-
log(` 현재 디렉토리: ${grn(dirName + '/')}`);
|
|
10754
|
+
log(cyan(`# leerness workspace-dir (1.36.126)`));
|
|
10755
|
+
log(` 현재 디렉토리: ${grn(dirName + '/')} ${dim('(읽기·쓰기가 실제로 일어나는 곳)')}`);
|
|
10653
10756
|
log(` abs: ${dirAbs}`);
|
|
10654
10757
|
log(` .harness 존재: ${hasHarness ? '✓' : '✗'}`);
|
|
10655
10758
|
log(` .leerness 존재: ${hasLeerness ? '✓' : '✗'}`);
|
|
10656
|
-
log(`
|
|
10759
|
+
log(` .leerness 마커: ${migrated ? '✓' : '✗'}`);
|
|
10657
10760
|
log('');
|
|
10658
|
-
if (
|
|
10659
|
-
log(
|
|
10660
|
-
|
|
10661
|
-
|
|
10761
|
+
if (ignored) {
|
|
10762
|
+
log(yel(` ⚠ ${ignored.env ? `LEERNESS_WORKSPACE_DIR=${ignored.env}` : '.leerness/MIGRATED_FROM_HARNESS 마커'} 는 이 빌드에서 적용되지 않습니다.`));
|
|
10763
|
+
log(` 실제 저장소는 ${grn(ignored.effective + '/')} 입니다 — 설정을 켜 두어도 동작은 바뀌지 않습니다(T-0107).`);
|
|
10764
|
+
}
|
|
10765
|
+
if (staleCopy) {
|
|
10766
|
+
log('');
|
|
10767
|
+
log(yel(` ⚠ .leerness/ 는 **죽은 사본**입니다 — 마이그레이션 시점에 멈춰 있고 이후 변경이 반영되지 않습니다.`));
|
|
10768
|
+
log(` .leerness/WHERE_TO_FIND.md 를 AI 에게 주지 마세요(낡은 상태로 안내합니다).`);
|
|
10769
|
+
log(dim(` 안전하게 지우려면: 내용을 .harness 와 비교해 필요한 것이 없는지 확인한 뒤 직접 삭제하세요.`));
|
|
10770
|
+
log(dim(` (leerness 는 사용자 데이터를 대신 지우지 않습니다)`));
|
|
10771
|
+
} else if (hasHarness && !hasLeerness) {
|
|
10772
|
+
log(dim(` → 이 빌드는 .harness 만 사용합니다(디렉터리 이름 변경은 T-0107 이후).`));
|
|
10662
10773
|
}
|
|
10663
10774
|
return;
|
|
10664
10775
|
}
|
|
@@ -10801,7 +10912,29 @@ function migrateWorkspaceDirCmd(root) {
|
|
|
10801
10912
|
const force = has('--force');
|
|
10802
10913
|
const report = _migrateWorkspaceDir(root, { dryRun, force });
|
|
10803
10914
|
|
|
10804
|
-
if (has('--json')) { log(JSON.stringify(report, null, 2)); return; }
|
|
10915
|
+
if (has('--json')) { log(JSON.stringify(report, null, 2)); if (report.blocked) process.exitCode = 1; return; }
|
|
10916
|
+
// 1.36.126: 못 하는 일을 했다고 말하지 않는다 — 사유와 **실측 근거**를 함께 준다.
|
|
10917
|
+
if (report.blocked) {
|
|
10918
|
+
const m = report.measured || {};
|
|
10919
|
+
log(cyan(`# leerness migrate-workspace-dir (1.36.126)`));
|
|
10920
|
+
log('');
|
|
10921
|
+
log(red(` ✗ 이 빌드는 .leerness 로 옮길 수 없습니다 — 복사만 하면 죽은 사본이 됩니다.`));
|
|
10922
|
+
log('');
|
|
10923
|
+
if (m.hardcodedPathSites != null) {
|
|
10924
|
+
log(` 왜: leerness 내부에서 워크스페이스 경로를 만드는 곳 ${m.hardcodedPathSites}곳이 '.harness' 를 그대로 쓰고,`);
|
|
10925
|
+
log(` 경로 해석기를 거치는 곳은 ${m.resolverConsumers}곳뿐입니다(안내 문구 ${m.hardcodedTextMentions}곳은 별도).`);
|
|
10926
|
+
} else {
|
|
10927
|
+
log(` 왜: leerness 내부 대부분이 '.harness' 경로를 그대로 쓰고 있어, 복사해도 그쪽으로는 아무것도 쓰이지 않습니다.`);
|
|
10928
|
+
log(` (이 실행에서는 소스를 세지 못해 정확한 수를 대지 못합니다 — 판정 자체는 바뀌지 않습니다.)`);
|
|
10929
|
+
}
|
|
10930
|
+
log(` 그래서 복사한 뒤에도 모든 쓰기는 .harness 로 갑니다 — .leerness 는 복사 시점에 멈춘 사본이 되고,`);
|
|
10931
|
+
log(` 함께 만들어지던 WHERE_TO_FIND.md 가 AI 를 그 낡은 상태로 안내합니다.`);
|
|
10932
|
+
log('');
|
|
10933
|
+
log(dim(` 지금 할 수 있는 것: 그대로 .harness 를 쓰시면 됩니다(기능 차이 없음 — 디렉터리 이름만 다릅니다).`));
|
|
10934
|
+
log(dim(` 진행 상황: 이 제약은 T-0107 로 등록돼 있습니다 — 해결되면 이 명령이 다시 동작합니다.`));
|
|
10935
|
+
process.exitCode = 1;
|
|
10936
|
+
return;
|
|
10937
|
+
}
|
|
10805
10938
|
log(cyan(`# leerness migrate-workspace-dir (1.9.211)${dryRun ? ' [DRY-RUN]' : ''}`));
|
|
10806
10939
|
log(` src: ${report.src} ${report.srcExists ? '✓' : red('✗ 없음')}`);
|
|
10807
10940
|
log(` dst: ${report.dst} ${report.dstExists ? yel('⚠ 이미 존재') : '(생성)'}`);
|
package/lib/audit.js
CHANGED
|
@@ -264,6 +264,39 @@ function audit(root, opts = {}, deps = {}) {
|
|
|
264
264
|
});
|
|
265
265
|
}
|
|
266
266
|
} catch {}
|
|
267
|
+
// 1.36.126 (T-0107): 예전 버전의 `migrate-workspace-dir` 는 `.harness` 를 `.leerness` 로 **복사만** 하고
|
|
268
|
+
// "마이그레이션 완료 → 다음 handoff부터 .leerness 우선 사용" 이라고 말했다. 실제로는 그 뒤 모든 쓰기가
|
|
269
|
+
// `.harness` 로 가므로 `.leerness` 는 복사 시점에 얼어붙는다. 문제는 디스크 낭비가 아니라
|
|
270
|
+
// 같이 만들어진 `WHERE_TO_FIND.md` 가 **AI 를 그 낡은 상태로 안내**한다는 것이다 — 우리가 막으려는 실패 그 자체.
|
|
271
|
+
// 1.36.126 부터 그 명령은 거부하지만, **이미 당한 프로젝트**는 스스로 알 수 없다. 여기서 알린다.
|
|
272
|
+
// ⚠ 지우지는 않는다 — 사용자 데이터를 대신 삭제하지 않는다는 원칙(편의 명령이 P1 5건을 냈던 전례).
|
|
273
|
+
try {
|
|
274
|
+
const staleMarker = path.join(root, '.leerness', 'MIGRATED_FROM_HARNESS');
|
|
275
|
+
const guide = path.join(root, '.leerness', 'WHERE_TO_FIND.md');
|
|
276
|
+
// 1.36.126 (검수 P2): 마커 하나만 믿으면 **마커가 지워진 사본**을 놓친다(수동 복구·복사 실패 등).
|
|
277
|
+
// 반대로 남의 도구가 만든 `.leerness` 를 "죽은 사본" 이라 부르면 오탐이 이 경고를 죽인다.
|
|
278
|
+
// 그래서 두 번째 신호는 **우리 생성물의 서명**으로 좁힌다 — 가이드 파일의 우리 헤더 문구.
|
|
279
|
+
// (파일명만 보면 남의 것과 구별할 수 없다. 내용 서명이 있어야 우리 것이라고 말할 수 있다.)
|
|
280
|
+
let ourGuide = false;
|
|
281
|
+
try { ourGuide = exists(guide) && /by leerness \d+\.\d+\.\d+/.test(read(guide)); } catch {}
|
|
282
|
+
if (exists(staleMarker) || ourGuide) {
|
|
283
|
+
let ageDays = null;
|
|
284
|
+
try {
|
|
285
|
+
const mt = exists(staleMarker) ? fs.statSync(staleMarker).mtimeMs : fs.statSync(guide).mtimeMs;
|
|
286
|
+
const live = fs.statSync(path.join(root, '.harness', 'progress-tracker.md')).mtimeMs;
|
|
287
|
+
ageDays = Math.max(0, Math.round((live - mt) / 86400000));
|
|
288
|
+
} catch {}
|
|
289
|
+
warnings++;
|
|
290
|
+
warn(`.leerness/ 는 죽은 사본입니다 — 실제 저장소는 .harness 이고, 이 사본은 복사 시점에 멈춰 있습니다`);
|
|
291
|
+
log(` 근거: ${exists(staleMarker) ? '.leerness/MIGRATED_FROM_HARNESS 마커' : '.leerness/WHERE_TO_FIND.md 가 leerness 생성물'} + 이 빌드는 .leerness 를 읽지 않음 (leerness workspace-dir)`);
|
|
292
|
+
if (ageDays != null) log(` 낡음 정도: 실제 저장소가 사본보다 약 ${ageDays}일 앞서 있습니다`);
|
|
293
|
+
if (exists(guide)) log(` ⚠ .leerness/WHERE_TO_FIND.md 를 AI 에게 주지 마세요 — 낡은 상태로 안내합니다`);
|
|
294
|
+
log(` → 내용을 .harness 와 비교해 필요한 것이 없는지 확인한 뒤 직접 삭제하세요 (leerness 는 대신 지우지 않습니다)`);
|
|
295
|
+
_finding('stale_workspace_copy', 'warn', '.leerness is a frozen copy; the live store is .harness', {
|
|
296
|
+
markerPresent: exists(staleMarker), guidePresent: exists(guide), guideIsOurs: ourGuide, liveAheadDays: ageDays,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
} catch {}
|
|
267
300
|
const milestoneIds = Array.from(planText.matchAll(/^### (M-\d{4,})\./gm)).map(m => m[1]);
|
|
268
301
|
const rows = readProgressRows(root);
|
|
269
302
|
// 1.9.6 수정: 한 row에 여러 plan:M-XXXX 링크가 있어도 모두 인식 (matchAll로 전부 추출)
|
package/lib/pure-utils.js
CHANGED
|
@@ -1579,14 +1579,18 @@ function _renderWorkspaceReferenceGuide(dirName, version, generatedAt) {
|
|
|
1579
1579
|
lines.push(`| API 제약 catalog | \`${dirName}/platform-constraints.json\` (1.9.208) |`);
|
|
1580
1580
|
lines.push(`| 자동 wakeup 권장 간격 | \`${dirName}/wakeup-history.json\` (1.9.210) |`);
|
|
1581
1581
|
lines.push('');
|
|
1582
|
-
|
|
1582
|
+
// 1.36.126 (T-0107, 검수 P1): 이 절은 AI 에게 **"마커가 있으면 `.leerness` 를 쓰라"** 고 지시하고 있었다.
|
|
1583
|
+
// 메커니즘(해석기)은 고쳐 놓고 지시문을 남기면, 그 지시문이 같은 버그를 계속 재생산한다.
|
|
1584
|
+
// 실측: `.leerness` 는 옛 migrate 가 만든 **얼어붙은 사본**이고 이후 모든 쓰기는 `.harness` 로 간다.
|
|
1585
|
+
lines.push(`## 🔄 \`.leerness\` 디렉터리를 보셨다면`);
|
|
1583
1586
|
lines.push('');
|
|
1584
|
-
lines.push(`이
|
|
1585
|
-
lines.push(`- \`.leerness
|
|
1586
|
-
lines.push(`- \`.
|
|
1587
|
-
lines.push(`-
|
|
1587
|
+
lines.push(`이 빌드가 읽고 쓰는 워크스페이스는 **\`${dirName}/\` 하나뿐**입니다.`);
|
|
1588
|
+
lines.push(`- \`.leerness/\` 가 있어도 **읽지 마십시오** — 옛 \`migrate-workspace-dir\` 가 만든 사본이며 그 시점에 멈춰 있습니다.`);
|
|
1589
|
+
lines.push(`- \`.leerness/MIGRATED_FROM_HARNESS\` 마커도 마찬가지입니다 — 이 빌드에서는 아무 효력이 없습니다.`);
|
|
1590
|
+
lines.push(`- \`.harness/MIGRATED_TO_LEERNESS.md\` 안내문 역시 낡았습니다.`);
|
|
1591
|
+
lines.push(`- \`migrate-workspace-dir\` 는 1.36.126 부터 사유를 설명하고 거부합니다 (T-0107).`);
|
|
1588
1592
|
lines.push('');
|
|
1589
|
-
lines.push(`AI 에이전트는 \`leerness handoff .\` 결과를 신뢰하십시오 —
|
|
1593
|
+
lines.push(`AI 에이전트는 \`leerness handoff .\` 결과를 신뢰하십시오 — 항상 살아 있는 워크스페이스를 씁니다.`);
|
|
1590
1594
|
lines.push('');
|
|
1591
1595
|
return lines.join('\n');
|
|
1592
1596
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.126",
|
|
4
4
|
"description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"leerness",
|
package/scripts/e2e.js
CHANGED
|
@@ -5482,7 +5482,11 @@ total++;
|
|
|
5482
5482
|
const pu = require(path.resolve(__dirname, '..', 'lib', 'pure-utils'));
|
|
5483
5483
|
const g = pu._renderWorkspaceReferenceGuide('.harness', '1.2.3', '2026-01-01T00:00:00.000Z');
|
|
5484
5484
|
ok = typeof g === 'string' && g.includes('.harness/progress-tracker.md') && g.includes('by leerness 1.2.3')
|
|
5485
|
-
&& g.includes('## 📁 디렉토리 구조 (핵심)') && g.includes('## 🧭 자주 묻는 위치') && g.includes('
|
|
5485
|
+
&& g.includes('## 📁 디렉토리 구조 (핵심)') && g.includes('## 🧭 자주 묻는 위치') && g.includes('plan.md')
|
|
5486
|
+
// 1.36.126 (T-0107): 여기도 제목 리터럴('## 🔄 마이그레이션 안내')을 붙잡고 있었다 — selftest 쪽만 고치고
|
|
5487
|
+
// 이 **복제본**을 놓쳐 게이트가 깨졌다(같은 단언이 두 표면에 있으면 둘 다 고쳐야 한다).
|
|
5488
|
+
// 제목이 아니라 계약을 건다: 가이드는 `.leerness` 를 다루되 그리로 **가라고 지시하지 않는다**.
|
|
5489
|
+
&& /\.leerness/.test(g) && !/우선 사용/.test(g) && !/로 가야 함/.test(g) && /읽지 마십시오/.test(g);
|
|
5486
5490
|
} catch {}
|
|
5487
5491
|
console.log(ok ? '✓ B(1.9.377) UR-0025: _renderWorkspaceReferenceGuide 모듈 분리 (빌더 동작 + 핵심 섹션)' : '✗ workspace guide 빌더 실패');
|
|
5488
5492
|
if (!ok) failed++;
|
|
@@ -12411,6 +12415,169 @@ total++;
|
|
|
12411
12415
|
if (!ok) failed++;
|
|
12412
12416
|
}
|
|
12413
12417
|
|
|
12418
|
+
// 1.36.126 (T-0107): `migrate-workspace-dir` 는 **배달할 수 없는 것을 배달했다고** 말하고 있었다.
|
|
12419
|
+
// 실측: 58파일을 복사하고 "✓ 마이그레이션 완료 → 다음 handoff부터 .leerness 우선 사용" 이라 찍은 뒤,
|
|
12420
|
+
// 바로 이어지는 `task add` 는 여전히 `.harness` 에 썼다(env 를 켜도 동일). 즉 `.leerness` 는 복사 시점에
|
|
12421
|
+
// 얼어붙은 사본이 되고, 같이 생성되는 WHERE_TO_FIND.md 가 **AI 를 그 낡은 상태로 안내**한다.
|
|
12422
|
+
// 배선 비용 실측: 경로 구성 252곳 + 안내 문자열 634곳 vs 해석기 소비처 9곳 → 이번 라운드에 배선 불가.
|
|
12423
|
+
// 그래서 ① 함정을 만들지 않고(fail-closed) ② 이미 당한 프로젝트를 알리고 ③ 평범한 프로젝트는 조용하다.
|
|
12424
|
+
total++;
|
|
12425
|
+
{
|
|
12426
|
+
let ok = false; const bad = []; const dbg = {};
|
|
12427
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-ws126-'));
|
|
12428
|
+
const envW = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
|
|
12429
|
+
for (const k of ['LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG', 'LEERNESS_MCP_PROFILE', 'LEERNESS_MCP_TIMEOUT_MS']) delete envW[k];
|
|
12430
|
+
const mkP = (name) => {
|
|
12431
|
+
const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
|
|
12432
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"w","version":"0.1.0"}');
|
|
12433
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 600000, env: envW });
|
|
12434
|
+
if (r.status !== 0) bad.push(`${name}:init실패(${r.status})`);
|
|
12435
|
+
return d;
|
|
12436
|
+
};
|
|
12437
|
+
const RW = (d, args, extra) => cp.spawnSync(process.execPath, [CLI, ...args],
|
|
12438
|
+
{ cwd: d, encoding: 'utf8', timeout: 600000, env: Object.assign({}, envW, extra || {}), maxBuffer: 32 * 1024 * 1024 });
|
|
12439
|
+
try {
|
|
12440
|
+
// ① 함정을 만들지 않는다 — 복사 0 · 명시적 실패 · 사유와 실측 근거를 함께 말한다
|
|
12441
|
+
const A = mkP('A');
|
|
12442
|
+
const m = RW(A, ['migrate-workspace-dir', '--apply', '--path', A]);
|
|
12443
|
+
if (fs.existsSync(path.join(A, '.leerness'))) bad.push('①.leerness가생성됨');
|
|
12444
|
+
if (m.status === 0) bad.push('①조용한성공(exit 0)');
|
|
12445
|
+
if (!/죽은 사본|옮길 수 없습니다/.test(m.stdout || '')) bad.push('①사유없음');
|
|
12446
|
+
// ⚠ 여기에 리터럴(252)을 박아 뒀더니 다음 편집에서 곧바로 틀렸다 — 사유의 숫자는 **실측과 같은지**로 본다.
|
|
12447
|
+
// 실제 대조는 아래 ⑥(제품 보고 == e2e 실측). 여기서는 "숫자를 아예 안 대는" 경우만 잡는다.
|
|
12448
|
+
if (!/\d{2,}곳이 '\.harness'/.test(m.stdout || '')) bad.push('①실측근거없음');
|
|
12449
|
+
const mj = RW(A, ['migrate-workspace-dir', '--apply', '--path', A, '--json']);
|
|
12450
|
+
let mjj = null; try { mjj = JSON.parse(mj.stdout); } catch { bad.push('①JSON아님'); }
|
|
12451
|
+
if (mjj && mjj.blocked !== true) bad.push('①json:blocked아님');
|
|
12452
|
+
if (mjj && mjj.blockedReason !== 'workspace-dir-alt-unsupported') bad.push(`①json:reason=${mjj && mjj.blockedReason}`);
|
|
12453
|
+
if (mj.status === 0) bad.push('①json:exit 0');
|
|
12454
|
+
if (mjj && Array.isArray(mjj.copiedFiles) && mjj.copiedFiles.length) bad.push('①json:복사보고');
|
|
12455
|
+
dbg.A = { exit: m.status, created: fs.existsSync(path.join(A, '.leerness')) };
|
|
12456
|
+
|
|
12457
|
+
// ② 이미 당한 프로젝트 — 옛 버전이 남긴 상태를 재현(옛 바이너리가 없으므로 손으로 만든다)
|
|
12458
|
+
const B = mkP('B');
|
|
12459
|
+
fs.mkdirSync(path.join(B, '.leerness'), { recursive: true });
|
|
12460
|
+
fs.copyFileSync(path.join(B, '.harness', 'progress-tracker.md'), path.join(B, '.leerness', 'progress-tracker.md'));
|
|
12461
|
+
fs.writeFileSync(path.join(B, '.leerness', 'MIGRATED_FROM_HARNESS'), 'migrated');
|
|
12462
|
+
fs.writeFileSync(path.join(B, '.leerness', 'WHERE_TO_FIND.md'), '# 어디서 찾나\n');
|
|
12463
|
+
RW(B, ['task', 'add', '사본 이후 작업', '--path', B, '--json']);
|
|
12464
|
+
const au = RW(B, ['audit', '--path', B, '--json']);
|
|
12465
|
+
let aj = null; try { aj = JSON.parse(au.stdout); } catch { bad.push('②auditJSON아님'); }
|
|
12466
|
+
const fnd = aj && Array.isArray(aj.findings) ? aj.findings.find(x => x.kind === 'stale_workspace_copy') : null;
|
|
12467
|
+
if (!fnd) bad.push('②죽은사본_미탐지');
|
|
12468
|
+
else if (fnd.guidePresent !== true) bad.push('②가이드파일_미보고');
|
|
12469
|
+
const wdj = RW(B, ['workspace-dir', 'get', '--path', B, '--json']);
|
|
12470
|
+
let wj = null; try { wj = JSON.parse(wdj.stdout); } catch { bad.push('②wdJSON아님'); }
|
|
12471
|
+
if (wj && wj.current !== '.harness') bad.push(`②현재=${wj.current}`);
|
|
12472
|
+
if (wj && wj.staleCopy !== true) bad.push('②staleCopy아님');
|
|
12473
|
+
if (wj && wj.altSupported !== false) bad.push('②altSupported참');
|
|
12474
|
+
if (!/죽은 사본/.test(RW(B, ['workspace-dir', 'get', '--path', B]).stdout || '')) bad.push('②사람용경고없음');
|
|
12475
|
+
// 마커·env 가 있어도 **쓰기는 정상**이어야 한다(예전엔 _isInitialized 가 .leerness 를 봐서 "미초기화" 거부했다)
|
|
12476
|
+
const w = RW(B, ['task', 'add', '마커 있어도 쓰기', '--path', B, '--json'], { LEERNESS_WORKSPACE_DIR: '.leerness' });
|
|
12477
|
+
if (w.status !== 0) bad.push(`②마커상태쓰기실패(${w.status})`);
|
|
12478
|
+
if (!fs.readFileSync(path.join(B, '.harness', 'progress-tracker.md'), 'utf8').includes('마커 있어도 쓰기')) bad.push('②쓰기가.harness에없음');
|
|
12479
|
+
dbg.B = { finding: !!fnd, staleCopy: wj && wj.staleCopy, writeExit: w.status };
|
|
12480
|
+
|
|
12481
|
+
// ③ 대조군 — 평범한 프로젝트는 조용하다(오탐이 이 경고를 죽인다)
|
|
12482
|
+
const C = mkP('C');
|
|
12483
|
+
const au2 = RW(C, ['audit', '--path', C, '--json']);
|
|
12484
|
+
let aj2 = null; try { aj2 = JSON.parse(au2.stdout); } catch { bad.push('③auditJSON아님'); }
|
|
12485
|
+
const n2 = aj2 && Array.isArray(aj2.findings) ? aj2.findings.filter(x => x.kind === 'stale_workspace_copy').length : -1;
|
|
12486
|
+
if (n2 !== 0) bad.push(`③대조군오탐 ${n2}건`);
|
|
12487
|
+
const wd2 = RW(C, ['workspace-dir', 'get', '--path', C]).stdout || '';
|
|
12488
|
+
if (/죽은 사본|적용되지 않습니다/.test(wd2)) bad.push('③대조군에경고문구');
|
|
12489
|
+
dbg.C = { falsePositives: n2 };
|
|
12490
|
+
|
|
12491
|
+
// ③-c 마커가 지워진 사본도 잡는다(검수 P2) + **남의 `.leerness` 는 건드리지 않는다**(오탐이 경고를 죽인다)
|
|
12492
|
+
{
|
|
12493
|
+
const D = mkP('D'); // 마커 없음 · 우리 가이드 파일만 남음
|
|
12494
|
+
fs.mkdirSync(path.join(D, '.leerness'), { recursive: true });
|
|
12495
|
+
fs.writeFileSync(path.join(D, '.leerness', 'WHERE_TO_FIND.md'), 'Generated: 2026-01-01T00:00:00.000Z by leerness 1.36.100\n');
|
|
12496
|
+
const ad = RW(D, ['audit', '--path', D, '--json']);
|
|
12497
|
+
let adj = null; try { adj = JSON.parse(ad.stdout); } catch { bad.push('③c auditJSON아님'); }
|
|
12498
|
+
const fd = adj && Array.isArray(adj.findings) ? adj.findings.find(x => x.kind === 'stale_workspace_copy') : null;
|
|
12499
|
+
if (!fd) bad.push('③c마커없는사본_미탐지');
|
|
12500
|
+
else if (fd.markerPresent !== false || fd.guideIsOurs !== true) bad.push(`③c근거오류(${fd.markerPresent}/${fd.guideIsOurs})`);
|
|
12501
|
+
|
|
12502
|
+
const E = mkP('E'); // 남의 도구가 만든 .leerness — 우리 서명 없음
|
|
12503
|
+
fs.mkdirSync(path.join(E, '.leerness'), { recursive: true });
|
|
12504
|
+
fs.writeFileSync(path.join(E, '.leerness', 'WHERE_TO_FIND.md'), '# someone else\n');
|
|
12505
|
+
fs.writeFileSync(path.join(E, '.leerness', 'config.yml'), 'a: 1\n');
|
|
12506
|
+
const ae = RW(E, ['audit', '--path', E, '--json']);
|
|
12507
|
+
let aej = null; try { aej = JSON.parse(ae.stdout); } catch { bad.push('③c(대조)auditJSON아님'); }
|
|
12508
|
+
const fe = aej && Array.isArray(aej.findings) ? aej.findings.filter(x => x.kind === 'stale_workspace_copy').length : -1;
|
|
12509
|
+
if (fe !== 0) bad.push(`③c남의.leerness오탐 ${fe}건`);
|
|
12510
|
+
dbg.D = { detected: !!fd, foreignFalsePositives: fe };
|
|
12511
|
+
}
|
|
12512
|
+
|
|
12513
|
+
// ③-b 부모 자산 — 부모에 죽은 사본이 있어도 **살아 있는 저장소**의 자산을 물려받아야 한다.
|
|
12514
|
+
// (`_findParentWorkspace` 는 해석기를 안 쓰고 자체 로직으로 `.leerness` 를 우선하고 있었다 —
|
|
12515
|
+
// 같은 클래스가 다른 자리에 남아 있던 경우. 자식이 낡은 design-system 을 물려받는다.)
|
|
12516
|
+
{
|
|
12517
|
+
const P = mkP('P'); // 부모
|
|
12518
|
+
fs.writeFileSync(path.join(P, '.harness', 'design-system.md'), '# LIVE\n');
|
|
12519
|
+
fs.mkdirSync(path.join(P, '.leerness'), { recursive: true });
|
|
12520
|
+
fs.writeFileSync(path.join(P, '.leerness', 'MIGRATED_FROM_HARNESS'), 'x');
|
|
12521
|
+
fs.writeFileSync(path.join(P, '.leerness', 'design-system.md'), '# STALE\n');
|
|
12522
|
+
const kid = path.join(P, 'kid'); fs.mkdirSync(kid, { recursive: true });
|
|
12523
|
+
fs.writeFileSync(path.join(kid, 'package.json'), '{"name":"kid","version":"0.1.0"}');
|
|
12524
|
+
const pw = RW(kid, ['parent', 'detect', '--path', kid, '--json']);
|
|
12525
|
+
let pj = null; try { pj = JSON.parse(pw.stdout); } catch { bad.push('③b parentJSON아님'); }
|
|
12526
|
+
const par = pj && pj.parent;
|
|
12527
|
+
if (!par) bad.push('③b부모미탐지(셋업실패면 아래 단언이 공허해진다)');
|
|
12528
|
+
else {
|
|
12529
|
+
if (par.workspaceDir !== '.harness') bad.push(`③b부모워크스페이스=${par.workspaceDir}`);
|
|
12530
|
+
if (!String(par.workspaceAbs || '').endsWith('.harness')) bad.push('③b부모abs가.harness아님');
|
|
12531
|
+
if (par.assets && par.assets.designSystem !== true) bad.push('③b살아있는자산미탐지(계측붕괴)');
|
|
12532
|
+
// 판별: 실제로 읽히는 파일이 LIVE 인가(경로만 맞고 내용이 STALE 이면 의미 없다)
|
|
12533
|
+
const body = fs.readFileSync(path.join(par.workspaceAbs, 'design-system.md'), 'utf8');
|
|
12534
|
+
if (!/LIVE/.test(body)) bad.push('③b읽히는자산이 STALE');
|
|
12535
|
+
}
|
|
12536
|
+
dbg.P = { workspaceDir: par && par.workspaceDir, depth: par && par.depth };
|
|
12537
|
+
}
|
|
12538
|
+
|
|
12539
|
+
// ④ 순서 강제 — 하드코딩이 남아 있는 한 "지원함" 이라 말할 수 없다.
|
|
12540
|
+
// (selftest 는 자기 소스를 못 읽는다 — 1.36.84 메타가드. 그래서 이 불변식은 여기서 건다.)
|
|
12541
|
+
const binSrc = fs.readFileSync(CLI, 'utf8');
|
|
12542
|
+
const libDir = path.resolve(path.dirname(CLI), '..', 'lib'); // CLI 에서 유도 — 실행 위치에 의존하지 않는다
|
|
12543
|
+
let libSrc = '';
|
|
12544
|
+
for (const f of fs.readdirSync(libDir)) if (f.endsWith('.js')) libSrc += '\n' + fs.readFileSync(path.join(libDir, f), 'utf8');
|
|
12545
|
+
const hard = ((binSrc + libSrc).match(/path\.join\([^)]*'\.harness'/g) || []).length;
|
|
12546
|
+
if (hard < 50) bad.push(`④하드코딩계측붕괴(${hard})`); // 0건이면 측정이 깨진 것 — 공허통과 금지
|
|
12547
|
+
const claims = /const WORKSPACE_DIR_ALT_SUPPORTED = (true|false);/.exec(binSrc);
|
|
12548
|
+
if (!claims) bad.push('④지원상수없음');
|
|
12549
|
+
else if (claims[1] === 'true' && hard > 0) bad.push(`④하드코딩 ${hard}곳인데 지원함이라 주장`);
|
|
12550
|
+
// ⑤ 내부 시험 통로가 **제품 경로**로 새지 않는다.
|
|
12551
|
+
// (검수 P3) 정확 카운트는 안전한 리팩터만으로 깨진다 — 개수가 아니라 **어디에 있는가**로 본다:
|
|
12552
|
+
// selftest 케이스 영역(_selfTestCases) 밖에 이 옵션을 넘기는 곳이 있으면 제품 경로 누출이다.
|
|
12553
|
+
const selfStart = binSrc.indexOf('function _selfTestCases(');
|
|
12554
|
+
const selfEnd = selfStart < 0 ? -1 : binSrc.indexOf('\nfunction ', selfStart + 10);
|
|
12555
|
+
if (selfStart < 0 || selfEnd < 0) bad.push('⑤selftest영역경계못찾음');
|
|
12556
|
+
else {
|
|
12557
|
+
const outside = binSrc.slice(0, selfStart) + binSrc.slice(selfEnd);
|
|
12558
|
+
const inside = binSrc.slice(selfStart, selfEnd);
|
|
12559
|
+
// "언급" 이 아니라 **넘기는 것**만 센다 — 주석·선언(`opts.allowUnsupported !== true`)은 호출이 아니다.
|
|
12560
|
+
const passes = (s) => (s.match(/allowUnsupported\s*:/g) || []).length;
|
|
12561
|
+
const leaked = passes(outside);
|
|
12562
|
+
if (leaked > 0) bad.push(`⑤시험통로가 제품 경로에 ${leaked}곳`);
|
|
12563
|
+
if (passes(inside) < 1) bad.push('⑤시험통로를 아무도 안 태움(보호가 썩는다)');
|
|
12564
|
+
dbg.seam = { leaked, insideUses: passes(inside) };
|
|
12565
|
+
}
|
|
12566
|
+
dbg.src = { hard, claims: claims && claims[1] };
|
|
12567
|
+
// ⑥ 거부 사유의 숫자는 **실행 시 측정**이어야 한다(검수 P2: 손으로 적은 252가 곧 255로 낡았다).
|
|
12568
|
+
// 제품이 보고한 수와 여기서 센 수가 같아야 한다 — 다르면 둘 중 하나가 거짓말이다.
|
|
12569
|
+
if (mjj && mjj.measured) {
|
|
12570
|
+
if (mjj.measured.hardcodedPathSites !== hard) bad.push(`⑥보고=${mjj.measured.hardcodedPathSites} vs 실측=${hard}`);
|
|
12571
|
+
if (!(mjj.measured.resolverConsumers > 0)) bad.push('⑥해석기 소비처 0(계측붕괴)');
|
|
12572
|
+
} else bad.push('⑥measured 없음');
|
|
12573
|
+
ok = bad.length === 0;
|
|
12574
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
|
|
12575
|
+
finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
12576
|
+
console.log(ok ? `✓ V(1.36.126/T-0107) 워크스페이스 대체 디렉터리: migrate 가 죽은 사본을 만들지 않고 명시적으로 실패(사유+실측) · 이미 당한 프로젝트를 audit/workspace-dir 가 알림 · 마커·env 가 있어도 쓰기 정상(.harness) · 대조군 오탐 0 ${JSON.stringify(dbg)}`
|
|
12577
|
+
: '✗ 1.36.126 워크스페이스 대체 디렉터리 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
|
|
12578
|
+
if (!ok) failed++;
|
|
12579
|
+
}
|
|
12580
|
+
|
|
12414
12581
|
console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
|
|
12415
12582
|
if (failed > 0) process.exit(1);
|
|
12416
12583
|
|