leerness 1.36.141 → 1.36.144
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 +185 -30
- package/lib/git.js +5 -1
- package/lib/io.js +29 -3
- package/package.json +1 -1
- package/scripts/e2e.js +429 -16
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.144 하네스를 사용합니다. 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.144는 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.141는 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.144 릴리스 태그 이력** (수백 라운드) · _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.144: 2026-08-21
|
|
239
239
|
<!-- leerness:project-readme:end -->
|
package/bin/leerness.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const cp = require('child_process');
|
|
7
|
-
const { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel } = require('../lib/io'); // 1.9.382/383 (UR-0025): 출력/시간/파일 프리미티브 공유 모듈 · 1.10.2 (UR-0146): setQuiet
|
|
7
|
+
const { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel, setDryRunGuard, mkdirpRaw } = require('../lib/io'); // 1.9.382/383 (UR-0025): 출력/시간/파일 프리미티브 공유 모듈 · 1.10.2 (UR-0146): setQuiet
|
|
8
8
|
const os = require('os'); // 1.9.178: _publishToNpm 에서 os.tmpdir() 사용 (전역 import)
|
|
9
9
|
const readline = require('readline');
|
|
10
10
|
// 1.9.274 (UR-0025 1단계): 순수 유틸 함수 모듈 분리 (require-based, 비파괴). selftest 7종이 동작 검증.
|
|
@@ -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.144';
|
|
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') 시 호스트 프로세스 오염.
|
|
@@ -180,6 +180,9 @@ const _lockFailOpen = []; // 보호 없이 진행한 기록(관측용) — 조
|
|
|
180
180
|
for (const _st of [process.stderr, process.stdout]) { try { _st.on('error', (e) => { if (!e || e.code !== 'EPIPE') return; }); } catch {} }
|
|
181
181
|
function _withLock(targetPath, fn, opts = {}) {
|
|
182
182
|
const lockPath = targetPath + '.lock';
|
|
183
|
+
// 1.36.144 (T-0117): dry-run 이면 **락을 잡지 않는다** — 쓰기가 어차피 가드에 막히므로 지킬 것이 없고,
|
|
184
|
+
// 락 디렉토리를 만드는 것만으로 "변경 없음" 이 거짓이 된다(실측: `state *` 8건이 빈 `.leerness/` 를 남겼다).
|
|
185
|
+
if (has('--dry-run')) return fn();
|
|
183
186
|
let bailCode = null, lastCode = null;
|
|
184
187
|
if (_heldLocks.has(lockPath)) return fn(); // 이미 이 프로세스가 보유 → 재진입(중첩 호출 안전)
|
|
185
188
|
const maxWaitMs = opts.maxWaitMs || 10000; // 1.9.375 (UR-0084): 5s→10s — 고부하 경합 시 fail-open(락 없이 진행) 전 인내 상향, lost-update 창 축소
|
|
@@ -187,7 +190,7 @@ function _withLock(targetPath, fn, opts = {}) {
|
|
|
187
190
|
// ⚠ 여기서 mkdirp 를 빼면 안 된다(1.36.108 에서 한 번 뺐다가 게이트가 5건을 잡았다).
|
|
188
191
|
// `state`·hunts 등 여러 호출부가 **이 mkdirp 가 만들어 주는 디렉토리에 기대어** 곧바로 파일을 쓴다 —
|
|
189
192
|
// 공유 헬퍼의 부작용을 없애면 그 의존이 한꺼번에 끊긴다. 미초기화 디렉토리 문제는 락을 다는 쪽에서 막는다.
|
|
190
|
-
try {
|
|
193
|
+
try { mkdirpRaw(path.dirname(lockPath)); } catch {} // 1.36.144: 락은 dry-run 가드 예외(막으면 무보호 진행으로 떨어진다)
|
|
191
194
|
const start = Date.now();
|
|
192
195
|
let held = false;
|
|
193
196
|
while (Date.now() - start <= maxWaitMs) {
|
|
@@ -1332,7 +1335,10 @@ async function resolveInstallOptions(root, opts = {}) {
|
|
|
1332
1335
|
}
|
|
1333
1336
|
|
|
1334
1337
|
async function install(root, opts = {}) {
|
|
1335
|
-
|
|
1338
|
+
// 1.36.144 (게이트가 잡음): `--dry-run` 인데 **여기서 프로젝트 디렉토리를 먼저 만들었다** —
|
|
1339
|
+
// enforce 에서 고쳤던 것과 같은 순서 결함이다(거절/미리보기는 어떤 변경보다 앞서야 한다).
|
|
1340
|
+
// dry 모드에서는 만들지 않는다. 아래 흐름은 존재하지 않는 root 에서도 요약까지 도달한다.
|
|
1341
|
+
root = absRoot(root); if (!opts.dry) mkdirp(root);
|
|
1336
1342
|
// 1.9.184+1.9.190: 설치 도중 Ctrl+C 시 즉시 종료 (사용자 명시 BUG fix — npx 설치 진행 차단).
|
|
1337
1343
|
// 1.9.184 의 2단계 confirm 은 readline raw mode 가 SIGINT 가로채서 호출 안 됨 → 1.9.190 즉시 종료로 단순화.
|
|
1338
1344
|
// _selectOne/_selectMany 는 stdin '\x03' 직접 처리, ask() 는 rl.on('SIGINT') 처리, 이 핸들러는 fallback.
|
|
@@ -7275,16 +7281,40 @@ function enforceCmd(root, sub) {
|
|
|
7275
7281
|
// 1.36.140 (재검수 P1, 재현): 연결된 worktree 들은 `core.hooksPath` 설정이 없어도 **같은 훅 파일**을 쓴다 —
|
|
7276
7282
|
// 한 worktree 에 설치하면 형제 worktree 의 커밋까지 차단된다. `hooksPathShared` 는 설정에 관한 사실이므로
|
|
7277
7283
|
// 그 뜻을 늘리지 않고, **별도 사실**로 센다(모르면 주장하지 않는다).
|
|
7284
|
+
// ⚠ 1.36.143 (재검수 P1): 첫 판은 "worktree 범위 설정이면 형제는 안 막힌다" 고 **추정**해 `null` 을 냈다.
|
|
7285
|
+
// 그런데 worktree 범위 설정이 그냥 공용 `.git/hooks` 를 가리키면 형제는 **실제로 막힌다** — 사실을 숨긴 것이다.
|
|
7286
|
+
// 추정하지 말고 **각 worktree 에게 자기 훅 경로를 직접 묻는다.** 우리가 쓰는 파일과 같은 곳을 보는 수를 센다.
|
|
7278
7287
|
const _worktreeCount = (() => {
|
|
7279
7288
|
try {
|
|
7280
|
-
// ⚠ `core.hooksPath` 가 **worktree 범위**면 그 worktree 전용 훅이라 형제는 안 막힌다 —
|
|
7281
|
-
// 그때 "형제도 차단된다" 고 말하면 거짓 경고다(재검수 P2). 모르면 주장하지 않는다.
|
|
7282
|
-
const wsc = _gitSpawn(['-C', root, 'config', '--show-scope', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: 5000 });
|
|
7283
|
-
if (wsc.status === 0 && String(wsc.stdout || '').trim().split(/\s+/)[0] === 'worktree') return null;
|
|
7284
7289
|
const wl = _gitSpawn(['-C', root, 'worktree', 'list', '--porcelain'], { encoding: 'utf8', timeout: 5000 });
|
|
7285
7290
|
if (wl.status !== 0) return null;
|
|
7286
|
-
const
|
|
7287
|
-
|
|
7291
|
+
const dirs = String(wl.stdout || '').split('\n')
|
|
7292
|
+
.filter((l) => l.startsWith('worktree '))
|
|
7293
|
+
.map((l) => l.slice('worktree '.length).trim())
|
|
7294
|
+
.filter(Boolean);
|
|
7295
|
+
if (dirs.length <= 1) return dirs.length || null;
|
|
7296
|
+
const norm = (q) => {
|
|
7297
|
+
let r = q;
|
|
7298
|
+
try { r = fs.realpathSync.native ? fs.realpathSync.native(q) : fs.realpathSync(q); } catch {}
|
|
7299
|
+
r = path.resolve(r);
|
|
7300
|
+
return process.platform === 'win32' ? r.toLowerCase() : r;
|
|
7301
|
+
};
|
|
7302
|
+
const mine = norm(hookP);
|
|
7303
|
+
let sharing = 0;
|
|
7304
|
+
// ⚠ 1.36.144 (재검수 P1): "하나라도 모르면 null" 로 했더니 **사라진(prunable) worktree 하나**가
|
|
7305
|
+
// 살아 있는 형제들의 실제 차단까지 통째로 숨겼다. 모르는 것은 세지 않되, **아는 것은 그대로 센다**.
|
|
7306
|
+
// 디렉토리가 없어진 worktree 는 애초에 커밋하지 않으므로 셈에서 빠지는 것이 옳다.
|
|
7307
|
+
let unknown = 0;
|
|
7308
|
+
for (const wd of dirs) {
|
|
7309
|
+
if (!exists(wd)) { unknown++; continue; } // prunable — 실재하지 않는다
|
|
7310
|
+
const hp = _gitSpawn(['-C', wd, 'rev-parse', '--git-path', 'hooks/pre-commit'], { encoding: 'utf8', timeout: 5000 });
|
|
7311
|
+
if (hp.status !== 0 || !String(hp.stdout || '').trim()) { unknown++; continue; }
|
|
7312
|
+
const raw = String(hp.stdout).trim();
|
|
7313
|
+
if (norm(path.isAbsolute(raw) ? raw : path.join(wd, raw)) === mine) sharing++;
|
|
7314
|
+
}
|
|
7315
|
+
// 아무것도 못 알아냈을 때만 "모른다" 고 한다.
|
|
7316
|
+
if (sharing === 0 && unknown > 0) return null;
|
|
7317
|
+
return sharing > 0 ? sharing : null;
|
|
7288
7318
|
} catch { return null; }
|
|
7289
7319
|
})();
|
|
7290
7320
|
const chainedP = hookP + '.pre-leerness';
|
|
@@ -7351,7 +7381,9 @@ function enforceCmd(root, sub) {
|
|
|
7351
7381
|
} catch { /* 제자리 쓰기가 막히면 아래 지우고-쓰기로 넘어간다 */ }
|
|
7352
7382
|
}
|
|
7353
7383
|
}
|
|
7354
|
-
|
|
7384
|
+
// ⚠ 1.36.143 (재검수 P1): 삭제 실패를 삼키면 **우리 차단 훅이 살아있는데** 되돌렸다고 말하게 된다.
|
|
7385
|
+
// 되돌리기 실패는 밖으로 던져 `_rbFail` 에 잡힌다.
|
|
7386
|
+
if (_lexists(p)) fs.unlinkSync(p);
|
|
7355
7387
|
if (snap.kind === 'none') return;
|
|
7356
7388
|
if (snap.kind === 'link') { fs.symlinkSync(snap.target, p); return; }
|
|
7357
7389
|
fs.writeFileSync(p, snap.buf);
|
|
@@ -7368,12 +7400,9 @@ function enforceCmd(root, sub) {
|
|
|
7368
7400
|
};
|
|
7369
7401
|
const _rbNote = () => (_rbFail.length ? ` ⚠ 되돌리지 못한 것: ${_rbFail.join(' · ')}` : '');
|
|
7370
7402
|
try {
|
|
7371
|
-
|
|
7372
|
-
//
|
|
7373
|
-
|
|
7374
|
-
fs.copyFileSync(hookP, chainedP);
|
|
7375
|
-
try { fs.chmodSync(chainedP, 0o755); } catch {}
|
|
7376
|
-
}
|
|
7403
|
+
// 1.36.143 (재검수 P1): 경로 검사를 **어떤 변경보다도 앞**으로 옮겼다.
|
|
7404
|
+
// 종전엔 체인 백업을 이미 덮어쓴 뒤에 `unsafe_path` 로 반환해 **백업이 파괴된 채로 남았다**.
|
|
7405
|
+
// 거절할 입력이면 아무것도 건드리기 전에 거절한다.
|
|
7377
7406
|
// 훅은 자기완결 sh — 설치된 leerness 버전/네트워크/PATH 에 무의존(구버전 전역 CLI 가 enforce 를 몰라 오차단하던 실측 함정 회피).
|
|
7378
7407
|
// 핵심 체크 = last-handoff.json 의 mtime 이 window 안인지 (find -mmin, git bash 표준).
|
|
7379
7408
|
// 1.36.134: git 이 훅을 부를 때의 작업 디렉토리 = toplevel. harness 위치를 그 기준 상대경로로 고정한다.
|
|
@@ -7390,6 +7419,18 @@ function enforceCmd(root, sub) {
|
|
|
7390
7419
|
try { _top = fs.realpathSync(_top); } catch {}
|
|
7391
7420
|
let harnessRel = (path.relative(_top, _hDir) || '.harness').replace(/\\/g, '/');
|
|
7392
7421
|
if (harnessRel.startsWith('..')) harnessRel = _hDir.replace(/\\/g, '/');
|
|
7422
|
+
// ⚠ 1.36.143 (재검수 P1): 이 거부가 **체인 백업 복사 뒤**에 있었다 — 거절하면서 백업을 파괴했다.
|
|
7423
|
+
// 계산은 읽기뿐이므로 여기까지 올려도 안전하고, 거절은 **아무것도 쓰기 전에** 끝난다.
|
|
7424
|
+
if (/[\r\n]/.test(harnessRel)) {
|
|
7425
|
+
failJson(json, 'unsafe_path', `경로에 개행이 있어 훅 스크립트에 넣을 수 없습니다: ${JSON.stringify(harnessRel)}`);
|
|
7426
|
+
return; // ← 아직 아무것도 쓰지 않았다
|
|
7427
|
+
}
|
|
7428
|
+
mkdirp(path.dirname(hookP));
|
|
7429
|
+
// 기존 훅 보존: 우리 것이 아니면 체인 파일로 이동 후 호출
|
|
7430
|
+
if (exists(hookP) && !read(hookP).includes(_ENFORCE_MARK)) {
|
|
7431
|
+
fs.copyFileSync(hookP, chainedP);
|
|
7432
|
+
try { fs.chmodSync(chainedP, 0o755); } catch {}
|
|
7433
|
+
}
|
|
7393
7434
|
// 1.36.134 (검수 P1, 재현): 이 값을 훅 셸 스크립트에 그대로 박았다 — 디렉토리 이름이 `pkg$(touch PWNED)` 면
|
|
7394
7435
|
// **설치만으로 임의 코드가 실행**됐다(실측: PWNED 생성). 값을 신뢰하지 않는다.
|
|
7395
7436
|
// ① 셸에서 특별한 뜻을 갖는 문자가 하나라도 있으면 설치를 거부한다(조용히 이스케이프하고 넘어가지 않는다 —
|
|
@@ -7398,10 +7439,6 @@ function enforceCmd(root, sub) {
|
|
|
7398
7439
|
// 둘 다 `unsafe_path` 로 막혔다. 한국어 환경에서 흔한 경로다. 거부가 아니라 **제대로 인용**하는 것이 답이다.
|
|
7399
7440
|
// POSIX sh 의 작은따옴표 안에서는 `'` 를 제외한 모든 문자가 리터럴이다 — `'` 만 표준 관용구로 탈출시킨다.
|
|
7400
7441
|
const shQuote = (v) => "'" + String(v).replace(/'/g, "'\''") + "'";
|
|
7401
|
-
if (/[\r\n]/.test(harnessRel)) {
|
|
7402
|
-
failJson(json, 'unsafe_path', `경로에 개행이 있어 훅 스크립트에 넣을 수 없습니다: ${JSON.stringify(harnessRel)}`);
|
|
7403
|
-
return;
|
|
7404
|
-
}
|
|
7405
7442
|
const hook = [
|
|
7406
7443
|
'#!/bin/sh',
|
|
7407
7444
|
_ENFORCE_MARK + ' — leerness 사용 강제 (제거: leerness enforce remove)',
|
|
@@ -21545,7 +21582,10 @@ async function releasePackCmd(root) {
|
|
|
21545
21582
|
}
|
|
21546
21583
|
|
|
21547
21584
|
// 4. 자동 task add — 매 release 라운드가 progress-tracker에 흔적 남도록
|
|
21548
|
-
|
|
21585
|
+
// ⚠ 1.36.144 (게이트가 잡음): 이 단계가 `dryRun` 을 안 봐서 **"mode: dry-run" 이라 찍고는
|
|
21586
|
+
// progress-tracker 에 실제로 썼다**. dry 면 무엇을 할지만 말한다.
|
|
21587
|
+
if (taskTitle && dryRun) log(` (dry-run) task add 스킵: ${taskTitle}`);
|
|
21588
|
+
if (taskTitle && !dryRun) {
|
|
21549
21589
|
const v = getCurrentVersion(root) || VERSION;
|
|
21550
21590
|
const id = nextId(root, 'T');
|
|
21551
21591
|
upsertProgress(root, {
|
|
@@ -21559,7 +21599,9 @@ async function releasePackCmd(root) {
|
|
|
21559
21599
|
}
|
|
21560
21600
|
|
|
21561
21601
|
// 5. session close
|
|
21562
|
-
|
|
21602
|
+
// ⚠ 같은 이유로 dry 면 건너뛴다 — 마감은 보존 파일을 새로 쓴다.
|
|
21603
|
+
if (close && dryRun) log('\n (dry-run) session close 스킵');
|
|
21604
|
+
if (close && !dryRun) {
|
|
21563
21605
|
log('\n[session close]');
|
|
21564
21606
|
try {
|
|
21565
21607
|
const r = sessionClose(root);
|
|
@@ -22437,6 +22479,9 @@ function _readUsageStats(root) {
|
|
|
22437
22479
|
} catch { return { commands: {}, drift: {}, since: today() }; }
|
|
22438
22480
|
}
|
|
22439
22481
|
function _bumpUsage(root, cmdName) {
|
|
22482
|
+
// 1.36.144 (T-0117): dry-run 은 아무것도 바꾸지 않는다 — 사용량 카운터도 예외가 아니다.
|
|
22483
|
+
// (전수 실측에서 378 호출 중 155건이 이 카운터 하나 때문에 "썼다" 로 잡혔다.)
|
|
22484
|
+
if (has('--dry-run')) return;
|
|
22440
22485
|
// 가벼운 카운터 — 명령 실행마다 호출 (sync write로 작은 파일)
|
|
22441
22486
|
// 1.36.137 (동시성 감사 실측): 이 함수는 **모든 명령이 공유하는 단 하나의 파일**을 락 없이 RMW 했다 —
|
|
22442
22487
|
// 2 프로세스부터 카운터가 38~46% 사라졌다(8프로세스×15회=120 실행 → 74 계수). exit 0 · 경고 0 · 손상 0.
|
|
@@ -23357,7 +23402,10 @@ function _mcpToCliArgs(name, args, targetPath) {
|
|
|
23357
23402
|
break;
|
|
23358
23403
|
case 'leerness_release_cleanup':
|
|
23359
23404
|
// 1.9.236 (1.9.235): local release/* branches 정리
|
|
23360
|
-
|
|
23405
|
+
// ⚠ 1.36.144 (재검수 P1, 재현): 여기서 `targetPath` 를 빠뜨려 요청한 저장소가 아니라
|
|
23406
|
+
// **MCP 서버의 cwd** 저장소 브랜치를 지웠다(실측: 요청 path=target 인데 victim 60 → 10, 50건 삭제 + 성공 보고).
|
|
23407
|
+
// `drift --auto-fix` 때와 같은 클래스가 MCP 표면에 남아 있었다 — 가드는 자기가 밟는 경로만 지킨다.
|
|
23408
|
+
cliArgs = ['release', 'cleanup', '--path', targetPath, '--json'];
|
|
23361
23409
|
if (args.apply === true) cliArgs.push('--apply');
|
|
23362
23410
|
if (typeof args.keep === 'number') cliArgs.push('--keep', String(args.keep));
|
|
23363
23411
|
break;
|
|
@@ -24907,7 +24955,13 @@ function _loadLeernessState(root) {
|
|
|
24907
24955
|
// 단, runs 디렉토리가 비어 있으면(진짜 신규/빈 상태) 관대하게 기본값 — 없는 데이터를 지킬 필요는 없다.
|
|
24908
24956
|
const _shapeOk = _st && typeof _st === 'object' && !Array.isArray(_st)
|
|
24909
24957
|
&& Number.isInteger(_st.runCounter) && _st.runCounter >= 0
|
|
24910
|
-
&& (_st.currentRunId === null || typeof _st.currentRunId === 'string')
|
|
24958
|
+
&& (_st.currentRunId === null || typeof _st.currentRunId === 'string')
|
|
24959
|
+
// 1.36.144 (재검수 P2): `runsBySession` 이 형태 검증에서 빠져 배열·문자열·null 이 와도 통과했고,
|
|
24960
|
+
// 그 경우 소유권이 통째로 '없음' 이 되어 **다른 세션의 run 을 인수**하는 fail-open 이 됐다.
|
|
24961
|
+
// 모양이 아니면 손상으로 다룬다(없는 것은 정상 — 업그레이드 전 상태).
|
|
24962
|
+
&& (_st.runsBySession === undefined
|
|
24963
|
+
|| (_st.runsBySession && typeof _st.runsBySession === 'object' && !Array.isArray(_st.runsBySession)
|
|
24964
|
+
&& Object.values(_st.runsBySession).every((v) => typeof v === 'string' && v)));
|
|
24911
24965
|
// 디스크의 최대 run 번호 — 카운터의 진실성 대조 기준
|
|
24912
24966
|
let _maxRun = 0;
|
|
24913
24967
|
try {
|
|
@@ -25165,6 +25219,50 @@ function stateCmd(root, sub, ...args) {
|
|
|
25165
25219
|
}
|
|
25166
25220
|
const json = has('--json');
|
|
25167
25221
|
sub = sub || 'show';
|
|
25222
|
+
// 1.36.142 (T-0116, 재현): `currentRunId` 가 **전역 슬롯 하나**라 두 세션이 같은 프로젝트에서
|
|
25223
|
+
// 일하면 뒤사람이 앞사람의 슬롯을 덮어쓴다. 실측: A 의 증거(files_changed · tests)가
|
|
25224
|
+
// **B 의 run 에 붙고**, 일하지 않은 B 가 `completion_claim_allowed: true` 를 받았으며,
|
|
25225
|
+
// A 는 `state show` 로 자기 run 을 볼 수조차 없었다. 증거 게이트가 그대로 뒤집힌다.
|
|
25226
|
+
// 락으로 막힐 문제가 아니다(직렬로 돌려도 재현된다) — **주소가 없는 것**이 문제다.
|
|
25227
|
+
const _SPst = require('../lib/session-presence');
|
|
25228
|
+
const _sessKey = _SPst.deriveSessionKey(process.env);
|
|
25229
|
+
// ⚠ 명시한 주소가 형식에 안 맞으면 조용히 전역 슬롯으로 떨어진다 — 그게 바로 이 버그다.
|
|
25230
|
+
// 사용자는 주소가 있다고 믿는데 없는 상태가 되므로, 무시했다면 반드시 말한다.
|
|
25231
|
+
const _badKey = _SPst.invalidExplicitKey(process.env);
|
|
25232
|
+
if (_badKey && !json) warn(`⚠ LEERNESS_SESSION_ID 가 형식에 맞지 않아 무시됩니다("${_badKey}") — 이 세션은 주소 없이 전역 슬롯을 씨니다`);
|
|
25233
|
+
// 1.36.144 (재검수 P2): `__proto__` 같은 키는 plain object 에 넣으면 프로토타입에 먹혀
|
|
25234
|
+
// `start` 는 성공하는데 소유가 사라지고 `record` 가 실패했다. 소유 맵은 **프로토타입 없는 객체**로 다룬다.
|
|
25235
|
+
const _ownedBy = (st) => {
|
|
25236
|
+
const raw = (st && st.runsBySession && typeof st.runsBySession === 'object' && !Array.isArray(st.runsBySession)) ? st.runsBySession : {};
|
|
25237
|
+
const o = Object.create(null);
|
|
25238
|
+
for (const k of Object.keys(raw)) if (typeof raw[k] === 'string' && raw[k]) o[k] = raw[k];
|
|
25239
|
+
return o;
|
|
25240
|
+
};
|
|
25241
|
+
// 이 세션의 run 을 고른다. 없으면 **다른 세션이 소유하지 않은** 전역 슬롯만 인수한다
|
|
25242
|
+
// (업그레이드 전에 시작한 run · 세션 키가 없는 단일 에이전트는 종전처럼 동작한다).
|
|
25243
|
+
const _resolveRunId = (st) => {
|
|
25244
|
+
const owned = _ownedBy(st);
|
|
25245
|
+
if (_sessKey && owned[_sessKey]) return owned[_sessKey];
|
|
25246
|
+
const others = new Set(Object.entries(owned).filter(([k]) => k !== _sessKey).map(([, v]) => v));
|
|
25247
|
+
if (st.currentRunId && !others.has(st.currentRunId)) return st.currentRunId;
|
|
25248
|
+
return null;
|
|
25249
|
+
};
|
|
25250
|
+
const _claimRun = (st, runId) => {
|
|
25251
|
+
if (_sessKey) {
|
|
25252
|
+
const o = _ownedBy(st); o[_sessKey] = runId;
|
|
25253
|
+
// 직렬화 전에 평범한 객체로 되돌린다(프로토타입 없는 객체도 JSON 은 그대로 쓴다 — 명시적으로 복사한다).
|
|
25254
|
+
st.runsBySession = Object.fromEntries(Object.keys(o).map((k) => [k, o[k]]));
|
|
25255
|
+
}
|
|
25256
|
+
st.currentRunId = runId; // 기존 소비자 계약 유지(마지막 시작된 run)
|
|
25257
|
+
};
|
|
25258
|
+
const _releaseRun = (st, runId) => {
|
|
25259
|
+
if (_sessKey && _ownedBy(st)[_sessKey] === runId) {
|
|
25260
|
+
const o = _ownedBy(st); delete o[_sessKey];
|
|
25261
|
+
st.runsBySession = Object.fromEntries(Object.keys(o).map((k) => [k, o[k]]));
|
|
25262
|
+
}
|
|
25263
|
+
// ⚠ 전역 슬롯은 **내 run 일 때만** 비운다 — 남의 run 을 끊지 않는다.
|
|
25264
|
+
if (st.currentRunId === runId) st.currentRunId = null;
|
|
25265
|
+
};
|
|
25168
25266
|
|
|
25169
25267
|
if (sub === 'start') {
|
|
25170
25268
|
// 1.9.311 (UR-0047): state 는 .leerness substrate(standalone) — .harness init 가드 미적용 (회귀 방지)
|
|
@@ -25179,9 +25277,9 @@ function stateCmd(root, sub, ...args) {
|
|
|
25179
25277
|
model_name: arg('--model', null)
|
|
25180
25278
|
});
|
|
25181
25279
|
_saveRun(root, rec);
|
|
25182
|
-
state
|
|
25280
|
+
_claimRun(state, run_id);
|
|
25183
25281
|
_saveLeernessState(root, state);
|
|
25184
|
-
if (json) { log(JSON.stringify({ started: run_id, run: rec }, null, 2)); return; }
|
|
25282
|
+
if (json) { log(JSON.stringify({ started: run_id, run: rec, sessionKey: _sessKey || null, keySource: _SPst.keySource(process.env) || null }, null, 2)); return; }
|
|
25185
25283
|
ok(`run 시작: ${run_id}${rec.agent_name ? ' · agent=' + rec.agent_name : ''}${rec.model_name ? ' · model=' + rec.model_name : ''}`);
|
|
25186
25284
|
if (goal) log(` goal: ${goal}`);
|
|
25187
25285
|
log(` → 기록: leerness state record --files-changed "a,b" --commands "npm test" --decision "..."`);
|
|
@@ -25189,8 +25287,14 @@ function stateCmd(root, sub, ...args) {
|
|
|
25189
25287
|
}
|
|
25190
25288
|
|
|
25191
25289
|
const state = _loadLeernessState(root);
|
|
25192
|
-
const curId = state
|
|
25290
|
+
const curId = _resolveRunId(state);
|
|
25193
25291
|
if ((sub === 'record' || sub === 'verify' || sub === 'handoff') && !curId) {
|
|
25292
|
+
// 다른 세션이 쓰는 run 이 있으면 그 사실을 말한다 — 조용히 남의 run 에 쓰지 않는다.
|
|
25293
|
+
const _others = Object.entries(_ownedBy(state)).filter(([k]) => k !== _sessKey);
|
|
25294
|
+
if (_others.length) {
|
|
25295
|
+
return fail(`이 세션의 run 이 없습니다 — 다른 세션이 진행 중입니다(${_others.map(([k, v]) => `${String(k).slice(0, 8)}…:${v}`).join(', ')}). `
|
|
25296
|
+
+ `남의 run 에 증거를 붙이지 않습니다 — 먼저 leerness state start "<goal>"`);
|
|
25297
|
+
}
|
|
25194
25298
|
return fail('진행 중 run 없음 — 먼저 leerness state start "<goal>"');
|
|
25195
25299
|
}
|
|
25196
25300
|
|
|
@@ -25236,7 +25340,7 @@ function stateCmd(root, sub, ...args) {
|
|
|
25236
25340
|
writeUtf8(path.join(hdir, 'latest.json'), JSON.stringify({ ...rec, completion_claim_allowed: _ccaH }, null, 2) + '\n');
|
|
25237
25341
|
writeUtf8(path.join(hdir, 'latest.md'),
|
|
25238
25342
|
`# Handoff — ${rec.run_id}\n\n- task: ${rec.task_id || '-'}\n- agent: ${rec.agent_name || '-'} · model: ${rec.model_name || '-'}\n- goal: ${rec.goal || '-'}\n- files_changed: ${rec.files_changed.join(', ') || '-'}\n- tests_run: ${rec.tests_run.join(', ') || '-'}\n- verification: ${rec.verification_result || '-'}\n- completion_claim_allowed: ${_ccaH.allowed ? 'yes' : 'no (' + _ccaH.reasons.join(', ') + ')'}\n\n## Summary\n${rec.handoff_summary}\n`);
|
|
25239
|
-
state.
|
|
25343
|
+
_releaseRun(state, rec.run_id); // 내 소유만 놓는다(남의 run 은 건드리지 않는다)
|
|
25240
25344
|
_saveLeernessState(root, state);
|
|
25241
25345
|
if (json) { log(JSON.stringify({ handoff: rec.run_id, run: rec, completion_claim_allowed: _ccaH }, null, 2)); return; }
|
|
25242
25346
|
ok(`handoff 작성: ${rec.run_id} → .leerness/handoff/latest.{md,json}`);
|
|
@@ -25246,7 +25350,18 @@ function stateCmd(root, sub, ...args) {
|
|
|
25246
25350
|
// default: show
|
|
25247
25351
|
const rec = curId ? _loadRun(root, curId) : null;
|
|
25248
25352
|
// 1.9.443 (UR-0153): evidence-first 완료 게이트 파생 노출.
|
|
25249
|
-
if (json) {
|
|
25353
|
+
if (json) {
|
|
25354
|
+
log(JSON.stringify({
|
|
25355
|
+
state, currentRun: rec, completion_claim_allowed: rec ? _completionClaimAllowed(rec) : null,
|
|
25356
|
+
sessionKey: _sessKey || null, keySource: _SPst.keySource(process.env) || null,
|
|
25357
|
+
// 1.36.144 (재검수 P2): 남의 세션 키를 그대로 내보내면 **그 키를 흉내내 남의 run 을 고칠 수 있다**.
|
|
25358
|
+
// 몇 개가 진행 중인지와 내 것이 무엇인지는 말하되, 남의 주소는 접두만 보인다.
|
|
25359
|
+
runsBySession: Object.fromEntries(Object.keys(_ownedBy(state)).map((k) => [
|
|
25360
|
+
k === _sessKey ? k : (String(k).slice(0, 6) + '…'), _ownedBy(state)[k]])),
|
|
25361
|
+
resolvedRunId: curId,
|
|
25362
|
+
}, null, 2));
|
|
25363
|
+
return;
|
|
25364
|
+
}
|
|
25250
25365
|
log(`# leerness state (1.9.278, UR-0032) — .leerness/ 구조화 상태`);
|
|
25251
25366
|
log(`project: ${state.project} · runs 누적: ${state.runCounter || 0} · 현재 run: ${curId || '(없음)'}`);
|
|
25252
25367
|
if (rec) {
|
|
@@ -27765,6 +27880,9 @@ async function main() {
|
|
|
27765
27880
|
return log(VERSION);
|
|
27766
27881
|
}
|
|
27767
27882
|
if (has('--help') || has('-h')) return help();
|
|
27883
|
+
// 1.36.144 (T-0117, 전수 실측): `--dry-run` 을 받았으면 쓰기를 막는다 — 구현한 명령은 애초에 안 쓰므로
|
|
27884
|
+
// 영향이 없고, 무시하던 명령은 첫 쓰기에서 멈춰 **아무것도 바꾸지 않은 채** 사유를 말한다.
|
|
27885
|
+
if (has('--dry-run')) setDryRunGuard(true);
|
|
27768
27886
|
// 1.36.103: 실재하지 않는 플래그(오타)를 조용히 삼키지 않는다.
|
|
27769
27887
|
// 값-플래그 미등록 45종은 _VALUE_FLAGS 등록으로 닫았지만, 사용자 오타(--prio, --resaon)는 여전히
|
|
27770
27888
|
// 그 값이 본문에 붙는다(실측: `decision add "use redis" --resaon faster` → 저장된 제목 "use redis faster").
|
|
@@ -27854,6 +27972,37 @@ async function main() {
|
|
|
27854
27972
|
}
|
|
27855
27973
|
}
|
|
27856
27974
|
}
|
|
27975
|
+
// 1.36.143 (전수 스윕): 위 가드는 **이름 목록**(_strict)이라 소비자를 못 따라갔다 — 검수는 3건을 지목했지만
|
|
27976
|
+
// 모든 명령에 없는 `--path` 를 주고 실측하니 **33건**이 그 경로를 만들었다(`.harness` 25 · `.leerness` 7 · 기타).
|
|
27977
|
+
// 그중 다수는 **성공이라 말하면서** 만든다(`provider add` · `team add` · `skill learn` · `glossary` · `wakeup-interval`).
|
|
27978
|
+
// `roles` · `decision drop` · `lesson drop` 을 개별 등록한 게 세 번째였다 — 네 번째를 만들지 않으려면 이름이 아니라
|
|
27979
|
+
// **성질**로 막아야 한다: 사용자가 `--path` 로 **명시한** 경로는 존재해야 한다. 부재가 정상 입력인 명령만 뺀다.
|
|
27980
|
+
// (위치인자 경로 해석은 명령마다 계약이 달라 건드리지 않는다 — 명시 플래그만 다룬다.)
|
|
27981
|
+
{
|
|
27982
|
+
const _PATH_MAY_BE_ABSENT = new Set(['init', 'migrate', 'migrate-workspace-dir', 'update', 'auto-update', 'adapter', 'ci']);
|
|
27983
|
+
const _p = arg('--path', null);
|
|
27984
|
+
// 1.36.144 (재검수 P1, 재현): `--path=` 는 빈 문자열(falsy), 값 없는 `--path` 는 boolean true 가 된다 —
|
|
27985
|
+
// 둘 다 이 가드를 그냥 지나쳐 **cwd 에 쓰고 성공을 보고**했다(실측: `decision add SNEAKY --path=` → ✓ 기록됨).
|
|
27986
|
+
// 플래그를 적었는데 값이 없으면 그건 오타다. 조용히 cwd 로 떨어뜨리지 않는다.
|
|
27987
|
+
const _pathTyped = process.argv.slice(2).some((t) => t === '--path' || (typeof t === 'string' && t.startsWith('--path=')));
|
|
27988
|
+
if (_pathTyped && (typeof _p !== 'string' || !_p.trim())) {
|
|
27989
|
+
const _en = _uiLang(process.cwd()) === 'en';
|
|
27990
|
+
failJson(has('--json'), 'path_missing_value', _en
|
|
27991
|
+
? '--path was given without a value — nothing was done'
|
|
27992
|
+
: '--path 에 값이 없습니다 — 아무것도 하지 않았습니다');
|
|
27993
|
+
return;
|
|
27994
|
+
}
|
|
27995
|
+
if (_p && !_PATH_MAY_BE_ABSENT.has(cmd)) {
|
|
27996
|
+
const _abs = absRoot(_p);
|
|
27997
|
+
if (!exists(_abs)) {
|
|
27998
|
+
const _en = _uiLang(process.cwd()) === 'en';
|
|
27999
|
+
failJson(has('--json'), 'path_not_found', _en
|
|
28000
|
+
? `path not found: ${_abs} — no directory was created`
|
|
28001
|
+
: `경로 없음: ${_abs} — 디렉토리를 새로 만들지 않았습니다`);
|
|
28002
|
+
return;
|
|
28003
|
+
}
|
|
28004
|
+
}
|
|
28005
|
+
}
|
|
27857
28006
|
// 1.13.1 (15th 블라인드 리뷰 P1, codex gpt-5.5): 무명령 + 옵션만(예: leerness --json) 은 묵시적 init 쓰기를 하지 않음 — cwd 에 .harness 가 의도치 않게 생성되던 부작용 차단. 명시 명령 없이 옵션만이면 help (bare leerness 온보딩 init 은 유지).
|
|
27858
28007
|
if (!args[0] && process.argv.slice(2).some(a => a.startsWith('-'))) { help(); return; }
|
|
27859
28008
|
// 1.9.38 (B): 사용 통계 카운터 — usage stats 명령 자체와 비차단 경로는 제외
|
|
@@ -28815,6 +28964,12 @@ if (require.main === module) {
|
|
|
28815
28964
|
main()
|
|
28816
28965
|
.then(() => { if (process.exitCode && process.exitCode !== 0) process.exit(process.exitCode); })
|
|
28817
28966
|
.catch(err => {
|
|
28967
|
+
// 1.36.144 (T-0117): dry-run 인데 쓰려다 멈춘 것은 크래시가 아니라 **계약 위반 보고**다.
|
|
28968
|
+
if (err && err.code === 'E_DRY_RUN_WRITE') {
|
|
28969
|
+
failJson(process.argv.includes('--json'), 'dry_run_unsupported',
|
|
28970
|
+
`이 명령은 --dry-run 을 구현하지 않았습니다 — ${err.file} 을(를) 쓰려다 멈췄습니다. 변경된 것은 없습니다(--dry-run 을 빼고 실행하세요).`);
|
|
28971
|
+
process.exit(1);
|
|
28972
|
+
}
|
|
28818
28973
|
fail(err && err.message ? err.message : String(err));
|
|
28819
28974
|
// 1.36.88: 완료 게이트는 upsertProgress 에서 던진다 — taskUpdate/taskSync 외의 경로로 올라와도
|
|
28820
28975
|
// 스택 트레이스 대신 탈출구를 보여준다(막힌 사용자가 빠져나갈 방법을 알아야 한다).
|
package/lib/git.js
CHANGED
|
@@ -30,7 +30,11 @@ const cp = require('child_process');
|
|
|
30
30
|
// `not-a-repo` 라고 정직하게 답한다.
|
|
31
31
|
const GIT_LOCATION_ENV = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR',
|
|
32
32
|
'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_NAMESPACE',
|
|
33
|
-
'GIT_CONFIG', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_PARAMETERS'
|
|
33
|
+
'GIT_CONFIG', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_PARAMETERS',
|
|
34
|
+
// 1.36.143 (재검수 P1): `GIT_CONFIG_NOSYSTEM=1` 은 값을 주지 않고 **시스템 설정을 감춘다** —
|
|
35
|
+
// 시스템 `core.hooksPath` 가 있는 환경에서 우리만 그것을 못 보고 `.git/hooks` 에 설치하면,
|
|
36
|
+
// 사용자의 실제 커밋은 시스템 훅을 써서 강제가 **그냥 우회**된다. 같은 클래스다.
|
|
37
|
+
'GIT_CONFIG_NOSYSTEM'];
|
|
34
38
|
const _DROP = new Set(GIT_LOCATION_ENV);
|
|
35
39
|
// `GIT_CONFIG_KEY_0`, `GIT_CONFIG_VALUE_0`, … 은 개수가 정해져 있지 않으므로 접두로 판별한다.
|
|
36
40
|
const _DROP_PREFIX = /^GIT_CONFIG_(KEY|VALUE)_\d+$/;
|
package/lib/io.js
CHANGED
|
@@ -118,7 +118,32 @@ function read(p) {
|
|
|
118
118
|
return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
|
|
119
119
|
}
|
|
120
120
|
function readBuf(p) { return fs.readFileSync(p); }
|
|
121
|
-
|
|
121
|
+
// 1.36.144 (T-0117, 전수 실측): `--dry-run` 은 **등록된 전역 플래그**인데 많은 명령이 그냥 무시하고 썼다.
|
|
122
|
+
// 378 호출을 전후 트리 비교로 재니 183건이 썼고, 그중 **28건이 사용자 데이터**를 바꿨다 —
|
|
123
|
+
// `session close --dry-run` 이 current-state·session-handoff·task-log 를 다시 쓰고,
|
|
124
|
+
// `task add` · `decision add` · `rule add` · `plan add` · `lesson save` · `state start` · `ci init` 은 **실제로 추가**했다.
|
|
125
|
+
// 명령마다 dry-run 분기를 심으면 16개의 새 실수 자리가 생긴다. 그래서 **쓰기 시점 하나**에서 막는다:
|
|
126
|
+
// dry-run 인데 쓰려 하면 그 자리에서 멈추고 무엇을 쓰려 했는지 말한다(조용히 쓰지도, 조용히 넘어가지도 않는다).
|
|
127
|
+
// ⚠ 락/임시 파일과 사용량 카운터는 사용자 데이터가 아니므로 면제한다 — 안 그러면 정직하게 dry-run 을
|
|
128
|
+
// 구현한 명령이 락을 잡는 것만으로 걸린다(가드가 정상 동작을 막으면 그건 새 결함이다).
|
|
129
|
+
let _dryGuard = false;
|
|
130
|
+
function setDryRunGuard(on) { _dryGuard = !!on; }
|
|
131
|
+
const _DRY_EXEMPT = /(\.lock$|\.tmp-\d+-\d+$|[\\/]cache[\\/])/;
|
|
132
|
+
function _dryCheck(p) {
|
|
133
|
+
if (!_dryGuard) return;
|
|
134
|
+
const s = String(p);
|
|
135
|
+
if (_DRY_EXEMPT.test(s)) return;
|
|
136
|
+
throw Object.assign(new Error(`--dry-run 인데 쓰려고 했습니다: ${s}`), { code: 'E_DRY_RUN_WRITE', file: s });
|
|
137
|
+
}
|
|
138
|
+
// ⚠ `mkdirp` 은 가드하지 **않는다**. 한 번 넣었다가 부작용을 재서 뺀다 —
|
|
139
|
+
// `_withLock` 이 락 디렉토리를 만드는 것까지 막혀 락 획득이 실패하고 **무보호 진행**으로 떨어졌다
|
|
140
|
+
// (실측: `state start --dry-run` → "락 획득 실패 — 보호 없이 진행합니다"). 빈 디렉토리는 사용자 데이터가
|
|
141
|
+
// 아니고, 의미 있는 쓰기는 전부 `writeUtf8`/`append` 를 지나므로 거기서 막는 것으로 충분하다.
|
|
142
|
+
// 디렉토리 생성도 막는다 — 빈 디렉토리라도 dry-run 이 남기면 "변경 없음" 이 거짓이 된다.
|
|
143
|
+
// ⚠ 단, **락 디렉토리는 예외**다. 한 번 전부 막았다가 `_withLock` 의 락 획득이 실패해
|
|
144
|
+
// **무보호 진행**으로 떨어졌다(실측). 락은 `mkdirpRaw` 로 우회한다 — 인프라지 사용자 데이터가 아니다.
|
|
145
|
+
function mkdirp(p) { _dryCheck(path.join(String(p), '_')); fs.mkdirSync(p, { recursive: true }); }
|
|
146
|
+
function mkdirpRaw(p) { fs.mkdirSync(p, { recursive: true }); }
|
|
122
147
|
// 1.9.298 (UR-0038, 외부리뷰 3중수렴): 원자적 쓰기 — temp 파일에 기록 후 rename(원자 교체)으로 부분쓰기 손상 방지.
|
|
123
148
|
// temp 이름은 .tmp-PID-SEQ 라 *.md glob 에 안 걸림. 실패 시 temp 정리.
|
|
124
149
|
let _writeSeq = 0;
|
|
@@ -138,6 +163,7 @@ function _rescueCorruptJson(p, s) {
|
|
|
138
163
|
try { fs.renameSync(p, bak); console.error(`⚠ 손상 JSON 감지 — 원본 대피 후 저장: ${path.basename(bak)} (수동 복구용)`); } catch {}
|
|
139
164
|
}
|
|
140
165
|
function writeUtf8(p, s) {
|
|
166
|
+
_dryCheck(p);
|
|
141
167
|
// 1.36.65 (외부감사 F-08): 동일-내용 재기록 스킵 — 무변경 재설치/조정기의 mtime·diff churn 을 전역 해소.
|
|
142
168
|
// 1.36.66 (8차 헌트 F13): 비교는 디코딩 텍스트가 아니라 "바이트 동일"로 — 손상 바이트(FF)/BOM 파일에
|
|
143
169
|
// 유효 UTF-8 을 쓸 때 read() 의 BOM-strip·치환문자가 텍스트를 같게 보이게 해 정화 write 가 억제됐다.
|
|
@@ -175,7 +201,7 @@ function _renameWithRetry(from, to) {
|
|
|
175
201
|
}
|
|
176
202
|
}
|
|
177
203
|
}
|
|
178
|
-
function append(p, s) { mkdirp(path.dirname(p)); fs.appendFileSync(p, s, 'utf8'); }
|
|
204
|
+
function append(p, s) { _dryCheck(p); mkdirp(path.dirname(p)); fs.appendFileSync(p, s, 'utf8'); }
|
|
179
205
|
function rel(root, p) { return path.relative(root, p).replace(/\\/g, '/') || '.'; }
|
|
180
206
|
|
|
181
|
-
module.exports = { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel };
|
|
207
|
+
module.exports = { log, ok, warn, fail, failJson, setQuiet, today, now, absRoot, exists, read, readBuf, mkdirp, writeUtf8, append, rel, setDryRunGuard, mkdirpRaw };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.144",
|
|
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
|
@@ -1514,13 +1514,26 @@ total++;
|
|
|
1514
1514
|
// init 후 가벼운 package.json 흉내 (release pack은 npm pack 시도하므로 dry-run으로 우회)
|
|
1515
1515
|
cp.spawnSync(process.execPath, [CLI, 'init', tmpC, '--yes', '--no-banner', '--no-stale-check', '--language', 'ko', '--skills', 'recommended'], { stdio: 'ignore', timeout: 30000 });
|
|
1516
1516
|
fs.writeFileSync(path.join(tmpC, 'package.json'), JSON.stringify({ name: 'rp-test', version: '0.0.1' }), 'utf8');
|
|
1517
|
+
// ⚠ 1.36.144: 이 축의 **판정을 바꿨다**. 종전엔 `--dry-run` 인데 `/task added/` 를, 즉
|
|
1518
|
+
// **dry-run 이 실제로 progress-tracker 에 쓰는 것**을 단언하고 있었다. 그것이 T-0117 의 그 결함이다
|
|
1519
|
+
// (같은 실행이 "mode: dry-run" 과 "(dry-run) npm pack 스킵" 을 찍으면서 task 는 진짜로 추가했다).
|
|
1520
|
+
// dry 는 "하겠다" 고만 말해야 한다. 그리고 **플래그 없는 실행은 여전히 등록해야** 하므로 대조군으로 같이 잰다
|
|
1521
|
+
// (한쪽만 재면 "아무것도 안 하는 release pack" 이 통과한다).
|
|
1522
|
+
const _tracker = path.join(tmpC, '.harness', 'progress-tracker.md');
|
|
1523
|
+
const _before = (() => { try { return fs.readFileSync(_tracker, 'utf8'); } catch { return ''; } })();
|
|
1517
1524
|
const r = cp.spawnSync(process.execPath, [CLI, 'release', 'pack', tmpC, '--dry-run', '--task-add', '1.9.40 e2e 검증', '--no-readme-sync'], { encoding: 'utf8', timeout: 30000 });
|
|
1525
|
+
const _afterDry = (() => { try { return fs.readFileSync(_tracker, 'utf8'); } catch { return ''; } })();
|
|
1526
|
+
const r2 = cp.spawnSync(process.execPath, [CLI, 'release', 'pack', tmpC, '--task-add', '1.9.40 e2e 검증', '--no-readme-sync'], { encoding: 'utf8', timeout: 60000 });
|
|
1527
|
+
const _afterReal = (() => { try { return fs.readFileSync(_tracker, 'utf8'); } catch { return ''; } })();
|
|
1528
|
+
const dryQuiet = _afterDry === _before && !/task added/.test(r.stdout) && /\(dry-run\) task add 스킵/.test(r.stdout);
|
|
1529
|
+
const realWrote = _afterReal !== _before && /task added/.test(r2.stdout);
|
|
1518
1530
|
const ok = r.status === 0
|
|
1519
1531
|
&& /release pack \(1\.9\.40\)/.test(r.stdout)
|
|
1520
|
-
&& /
|
|
1521
|
-
&&
|
|
1522
|
-
console.log(ok ? '✓ B(1.9.40) release pack: --dry-run
|
|
1523
|
-
|
|
1532
|
+
&& /dry-run/.test(r.stdout)
|
|
1533
|
+
&& dryQuiet && realWrote;
|
|
1534
|
+
console.log(ok ? '✓ B(1.9.40/1.36.144) release pack: --dry-run 은 task 를 **쓰지 않고 말만** 하고, 플래그 없는 실행은 그대로 등록(대조군)'
|
|
1535
|
+
: `✗ release pack 실패 ${JSON.stringify({ dryQuiet, realWrote, dryExit: r.status, realExit: r2.status })}`);
|
|
1536
|
+
if (!ok) { failed++; console.log(r.stdout.slice(0, 400)); console.log('--- real ---'); console.log(r2.stdout.slice(0, 400)); }
|
|
1524
1537
|
}
|
|
1525
1538
|
|
|
1526
1539
|
total++;
|
|
@@ -6103,18 +6116,26 @@ total++;
|
|
|
6103
6116
|
if (!ok) failed++;
|
|
6104
6117
|
}
|
|
6105
6118
|
|
|
6106
|
-
// 1.9.410 회귀 (8번째 버그헌트, UR-0114): 값 없는 --path (boolean true) 가 raw TypeError
|
|
6119
|
+
// 1.9.410 회귀 (8번째 버그헌트, UR-0114): 값 없는 --path (boolean true) 가 raw TypeError 로 크래시하지 않는다.
|
|
6120
|
+
// ⚠ 1.36.144 에서 이 축의 **판정을 바꿨다**. 종전엔 "cwd 로 폴백해 정상 실행" 을 단언했는데,
|
|
6121
|
+
// 그 폴백이 바로 이번에 고친 결함이다 — `decision add SNEAKY --path=` 가 cwd 에 쓰고 "✓ 기록됨" 을 냈다(실측).
|
|
6122
|
+
// 플래그를 적었는데 값이 없으면 그건 오타다. 원래 목적(**raw TypeError 를 흘리지 않는다**)은 그대로 두고,
|
|
6123
|
+
// "조용한 폴백" 대신 "사유를 말하는 거절" 을 단언한다. 그리고 값을 준 경우가 여전히 도는지 대조군으로 잰다.
|
|
6107
6124
|
total++;
|
|
6108
6125
|
{
|
|
6109
6126
|
let ok = false;
|
|
6110
6127
|
try {
|
|
6111
6128
|
const r = cp.spawnSync(process.execPath, [CLI, 'status', '--path'], { encoding: 'utf8', timeout: 15000 });
|
|
6112
6129
|
const out = (r.stdout || '') + (r.stderr || '');
|
|
6113
|
-
const noCrash = !/paths\[0\].*argument must be of type string|Received type boolean
|
|
6114
|
-
const
|
|
6115
|
-
|
|
6130
|
+
const noCrash = !/paths\[0\].*argument must be of type string|Received type boolean|at Object\.<anonymous>/.test(out);
|
|
6131
|
+
const saysWhy = /--path 에 값이 없습니다|--path was given without a value/.test(out);
|
|
6132
|
+
const refused = r.status !== 0;
|
|
6133
|
+
// 대조군 — 값을 준 `--path` 는 그대로 동작해야 한다(거절이 과차단으로 번지면 여기서 잡힌다).
|
|
6134
|
+
const r2 = cp.spawnSync(process.execPath, [CLI, 'status', '--path', tmp], { encoding: 'utf8', timeout: 15000 });
|
|
6135
|
+
const stillWorks = /Leerness:|Files:/.test((r2.stdout || '') + (r2.stderr || ''));
|
|
6136
|
+
ok = noCrash && saysWhy && refused && stillWorks;
|
|
6116
6137
|
} catch {}
|
|
6117
|
-
console.log(ok ? '✓ B(1.9.410)
|
|
6138
|
+
console.log(ok ? '✓ B(1.9.410/1.36.144) 값없는 --path: raw TypeError 없이 **사유를 말하는 거절**(조용한 cwd 폴백 제거) + 값 준 경우 무회귀 (UR-0114)' : '✗ --path 크래시 가드 실패');
|
|
6118
6139
|
if (!ok) failed++;
|
|
6119
6140
|
}
|
|
6120
6141
|
|
|
@@ -14288,21 +14309,30 @@ total++;
|
|
|
14288
14309
|
if (restored.length !== 1) bad.push(`③복원이 ${restored.length}건을 만듦(기대 1)`);
|
|
14289
14310
|
dbg.dupDrop = { exits: codes, archiveBlocks: blocks, restoredCount: restored.length };
|
|
14290
14311
|
}
|
|
14291
|
-
// ④ 없는
|
|
14292
|
-
//
|
|
14293
|
-
//
|
|
14294
|
-
//
|
|
14312
|
+
// ④ 없는 경로는 **아무것도 만들지 않고 거절**한다 — 그러나 **동작하던 사용법은 깨지 않는다**.
|
|
14313
|
+
// ⚠ 이 축은 한 번 뒤집혔다. 처음엔 "없는 경로에서도 `roles catalog`/`list` 는 exit 0 이어야 한다" 고 적었다 —
|
|
14314
|
+
// 1.36.139 가 그랬기 때문이다. 그런데 1.36.143 이 `--path` 규칙을 **모든 명령에 일관되게** 적용하자
|
|
14315
|
+
// 이 단언이 게이트를 깨뜨렸다. 다시 재보니 옛 동작이 지키던 것은 **오타를 조용히 성공시키는 것**이었다:
|
|
14316
|
+
// `--path` 없음 → 동작(경로 무관 사용) · `--path <실재>` → 동작 · `--path <오타>` → exit 0 (조용한 성공)
|
|
14317
|
+
// 마지막 칸만 거절로 바뀌었고 그게 맞다. 그래서 축을 "오타에도 성공" 이 아니라
|
|
14318
|
+
// **"실제로 쓰던 두 사용법이 살아 있다"** 로 다시 쓴다 — 과차단 보호는 그대로 유지된다.
|
|
14295
14319
|
{
|
|
14296
14320
|
const ghost = path.join(sb, 'ghost-parent', 'nope');
|
|
14297
14321
|
const w1 = RP(sb, ['roles', 'unset', 'coder', '--path', ghost]);
|
|
14298
14322
|
const w2 = RP(sb, ['roles', 'set', 'coder', '--provider', 'codex', '--force', '--path', ghost]);
|
|
14299
14323
|
const created = fs.existsSync(ghost);
|
|
14300
|
-
const
|
|
14324
|
+
const r1 = RP(sb, ['roles', 'catalog', '--path', ghost]);
|
|
14325
|
+
// 과차단 대조군 — 경로를 안 주는 사용과 실재 경로를 주는 사용은 **둘 다 살아 있어야 한다**.
|
|
14326
|
+
const real = mk('roles-real');
|
|
14327
|
+
const noPath = RP(real, ['roles', 'catalog']).status;
|
|
14328
|
+
const realPath = ['catalog', 'list'].map((s) => RP(real, ['roles', s, '--path', real]).status);
|
|
14301
14329
|
if (w1.status === 0) bad.push('④없는 경로인데 unset 이 성공이라 보고');
|
|
14302
14330
|
if (w2.status === 0) bad.push('④없는 경로인데 set 이 성공이라 보고');
|
|
14303
14331
|
if (created) bad.push('④실패하면서 없는 경로를 만들었다');
|
|
14304
|
-
if (
|
|
14305
|
-
|
|
14332
|
+
if (r1.status === 0) bad.push('④오타 난 --path 를 조용히 성공시킴');
|
|
14333
|
+
if (noPath !== 0) bad.push(`④--path 없는 사용을 막았다(exit=${noPath}) — 과차단 회귀`);
|
|
14334
|
+
if (realPath.some((s) => s !== 0)) bad.push(`④실재 경로 조회를 막았다(exit=${JSON.stringify(realPath)}) — 과차단 회귀`);
|
|
14335
|
+
dbg.ghostPath = { unset: w1.status, set: w2.status, created, ghostRead: r1.status, noPath, realPath };
|
|
14306
14336
|
}
|
|
14307
14337
|
// ⑤ 한 번짜리 설정 주입(`GIT_CONFIG_COUNT`)이 **남의 저장소**에 훅을 쓰지 못한다 + 지목한 저장소에는 쓴다.
|
|
14308
14338
|
{
|
|
@@ -14348,6 +14378,389 @@ total++;
|
|
|
14348
14378
|
if (!ok) failed++;
|
|
14349
14379
|
}
|
|
14350
14380
|
|
|
14381
|
+
total++;
|
|
14382
|
+
// AF(1.36.142/T-0116) — **증거는 일한 세션에 붙어야 한다.**
|
|
14383
|
+
// `.leerness/state.json` 의 `currentRunId` 는 전역 슬롯 하나였다. 두 세션이 같은 프로젝트에서 일하면
|
|
14384
|
+
// 뒤에 `state start` 한 쪽이 앞사람 슬롯을 덮어썼고, 실측으로:
|
|
14385
|
+
// · A 의 증거(files_changed · tests)가 **B 의 run 에 붙었고**
|
|
14386
|
+
// · 일하지 않은 B 가 `completion_claim_allowed: true` 를 받았고
|
|
14387
|
+
// · A 는 `state show` 로 **자기 run 을 볼 수조차 없었다**.
|
|
14388
|
+
// 락 문제가 아니다(직렬로 돌려도 재현된다) — 주소가 없는 것이 문제다.
|
|
14389
|
+
{
|
|
14390
|
+
let ok = false; const bad = []; const dbg = {};
|
|
14391
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-142-'));
|
|
14392
|
+
const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
|
|
14393
|
+
for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
|
|
14394
|
+
'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
|
|
14395
|
+
'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG']) delete envP[k];
|
|
14396
|
+
const R = (d, env, a) => cp.spawnSync(process.execPath, [CLI, ...a],
|
|
14397
|
+
{ cwd: d, encoding: 'utf8', timeout: 300000, env, maxBuffer: 32 * 1024 * 1024 });
|
|
14398
|
+
const J = (r) => { try { return JSON.parse(String(r.stdout || '')); } catch { return null; } };
|
|
14399
|
+
const mk = (name) => {
|
|
14400
|
+
const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
|
|
14401
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
14402
|
+
const ri = R(d, envP, ['init', d, '--yes', '--minimal']);
|
|
14403
|
+
if (ri.status !== 0) bad.push(`${name}:init exit=${ri.status}`);
|
|
14404
|
+
return d;
|
|
14405
|
+
};
|
|
14406
|
+
// ⚠ 세션 키는 제품의 유효성 규칙을 통과해야 한다 — 짧은 이름은 `deriveSessionKey` 가 null 을 준다.
|
|
14407
|
+
// (한 번 그렇게 재서 "제품이 아니라 픽스처" 를 측정했다. 여기서 먼저 확인하고 시작한다.)
|
|
14408
|
+
const SP = require(path.resolve(__dirname, '..', 'lib', 'session-presence'));
|
|
14409
|
+
const KA = 'session-alpha01', KB = 'session-bravo01';
|
|
14410
|
+
if (!SP.deriveSessionKey({ LEERNESS_SESSION_ID: KA }) || !SP.deriveSessionKey({ LEERNESS_SESSION_ID: KB })) {
|
|
14411
|
+
bad.push('픽스처 세션 키가 무효 — 이 블록은 아무것도 재지 못한다');
|
|
14412
|
+
}
|
|
14413
|
+
const eA = Object.assign({}, envP, { LEERNESS_SESSION_ID: KA });
|
|
14414
|
+
const eB = Object.assign({}, envP, { LEERNESS_SESSION_ID: KB });
|
|
14415
|
+
try {
|
|
14416
|
+
// ① 증거 귀속 + 완료 게이트 — A 가 일하고 B 는 놀았다.
|
|
14417
|
+
{
|
|
14418
|
+
const d = mk('attrib');
|
|
14419
|
+
const sA = J(R(d, eA, ['state', 'start', 'A 목표', '--agent', 'agentA', '--json']));
|
|
14420
|
+
const sB = J(R(d, eB, ['state', 'start', 'B 목표', '--agent', 'agentB', '--json']));
|
|
14421
|
+
const rA = J(R(d, eA, ['state', 'record', '--files-changed', 'a.js', '--tests', 'npm test: 10/10', '--json']));
|
|
14422
|
+
const vB = J(R(d, eB, ['state', 'verify', '--result', 'pass', '--json']));
|
|
14423
|
+
if (!sA || !sB || sA.started === sB.started) bad.push('①두 세션이 같은 run 을 받았다 — 판별 불가');
|
|
14424
|
+
if (!rA || rA.recorded !== (sA && sA.started)) bad.push(`①A 의 증거가 A 의 run 이 아닌 곳에 붙음(${rA && rA.recorded})`);
|
|
14425
|
+
const cca = vB && vB.completion_claim_allowed && vB.completion_claim_allowed.allowed;
|
|
14426
|
+
if (cca) bad.push('①일하지 않은 세션이 완료 허용을 받았다 — 증거 게이트가 뒤집힘');
|
|
14427
|
+
const showA = J(R(d, eA, ['state', 'show', '--json']));
|
|
14428
|
+
if (!showA || showA.resolvedRunId !== (sA && sA.started)) bad.push(`①A 가 자기 run 을 못 본다(${showA && showA.resolvedRunId})`);
|
|
14429
|
+
dbg.attrib = { A: sA && sA.started, B: sB && sB.started, recordedTo: rA && rA.recorded, bAllowed: !!cca, aSees: showA && showA.resolvedRunId };
|
|
14430
|
+
}
|
|
14431
|
+
// ② 남의 run 에 조용히 쓰지 않고 **사유를 말한다**(증거는 그대로 남는다).
|
|
14432
|
+
{
|
|
14433
|
+
const d = mk('refuse');
|
|
14434
|
+
R(d, eA, ['state', 'start', 'A 목표', '--json']);
|
|
14435
|
+
const rB = R(d, eB, ['state', 'record', '--files-changed', 'stolen.js']);
|
|
14436
|
+
const msg = String(rB.stdout || '') + String(rB.stderr || '');
|
|
14437
|
+
let runA = null; try { runA = JSON.parse(fs.readFileSync(path.join(d, '.leerness', 'runs', 'run-0001.json'), 'utf8')); } catch {}
|
|
14438
|
+
if (rB.status === 0) bad.push('②남의 run 인데 성공이라 보고');
|
|
14439
|
+
if (!/다른 세션이 진행 중/.test(msg)) bad.push('②거절 사유를 말하지 않음');
|
|
14440
|
+
if (!runA || runA.files_changed.length !== 0) bad.push('②거절했는데 남의 증거가 오염됨');
|
|
14441
|
+
dbg.refuse = { exit: rB.status, saysWhy: /다른 세션이 진행 중/.test(msg), aFiles: runA && runA.files_changed.length };
|
|
14442
|
+
}
|
|
14443
|
+
// ③ 남의 handoff 가 내 run 을 끊지 않는다.
|
|
14444
|
+
{
|
|
14445
|
+
const d = mk('release');
|
|
14446
|
+
R(d, eA, ['state', 'start', 'A', '--json']);
|
|
14447
|
+
R(d, eB, ['state', 'start', 'B', '--json']);
|
|
14448
|
+
R(d, eB, ['state', 'handoff', 'B 마감', '--json']);
|
|
14449
|
+
const aShow = J(R(d, eA, ['state', 'show', '--json']));
|
|
14450
|
+
const aRec = J(R(d, eA, ['state', 'record', '--files-changed', 'a2.js', '--json']));
|
|
14451
|
+
if (!aShow || aShow.resolvedRunId !== 'run-0001') bad.push(`③남의 handoff 뒤 내 run 이 사라짐(${aShow && aShow.resolvedRunId})`);
|
|
14452
|
+
if (!aRec || aRec.recorded !== 'run-0001') bad.push(`③남의 handoff 뒤 기록 불가(${aRec && aRec.recorded})`);
|
|
14453
|
+
dbg.release = { aResolved: aShow && aShow.resolvedRunId, aRecorded: aRec && aRec.recorded };
|
|
14454
|
+
}
|
|
14455
|
+
// ④ **대조군 — 기존 단일 사용자(세션 키 없음)는 종전 그대로.**
|
|
14456
|
+
// 한쪽만 재면 "주소 없는 사람을 막아 버리는" 회귀가 통과한다.
|
|
14457
|
+
{
|
|
14458
|
+
const d = mk('single');
|
|
14459
|
+
const st = J(R(d, envP, ['state', 'start', '목표', '--json']));
|
|
14460
|
+
const rc = J(R(d, envP, ['state', 'record', '--files-changed', 'x.js', '--tests', 'npm test: 3/3', '--json']));
|
|
14461
|
+
const vf = J(R(d, envP, ['state', 'verify', '--result', 'pass', '--json']));
|
|
14462
|
+
const ho = J(R(d, envP, ['state', 'handoff', '요약', '--json']));
|
|
14463
|
+
const after = J(R(d, envP, ['state', 'show', '--json']));
|
|
14464
|
+
const chain = [rc && rc.recorded, vf && vf.verified, ho && ho.handoff];
|
|
14465
|
+
if (!st || chain.some((x) => x !== st.started)) bad.push(`④세션키 없는 단일 흐름이 깨짐(${JSON.stringify(chain)})`);
|
|
14466
|
+
if (!after || after.resolvedRunId !== null) bad.push(`④handoff 뒤에도 run 이 남음(${after && after.resolvedRunId})`);
|
|
14467
|
+
if (!vf || !vf.completion_claim_allowed || vf.completion_claim_allowed.allowed !== true) {
|
|
14468
|
+
bad.push('④일한 단일 사용자가 완료 허용을 못 받음 — 게이트가 과차단');
|
|
14469
|
+
}
|
|
14470
|
+
dbg.single = { started: st && st.started, chain, after: after && after.resolvedRunId,
|
|
14471
|
+
allowed: vf && vf.completion_claim_allowed && vf.completion_claim_allowed.allowed };
|
|
14472
|
+
}
|
|
14473
|
+
// ⑤ 업그레이드 이전 상태(전역 슬롯만)를 **인수**한다 — 쓰던 사람이 멈추지 않는다.
|
|
14474
|
+
{
|
|
14475
|
+
const d = mk('migrate');
|
|
14476
|
+
R(d, envP, ['state', 'start', '업그레이드 전', '--json']);
|
|
14477
|
+
const stFile = path.join(d, '.leerness', 'state.json');
|
|
14478
|
+
const before = JSON.parse(fs.readFileSync(stFile, 'utf8'));
|
|
14479
|
+
delete before.runsBySession;
|
|
14480
|
+
fs.writeFileSync(stFile, JSON.stringify(before, null, 2) + '\n');
|
|
14481
|
+
const rc = J(R(d, eA, ['state', 'record', '--files-changed', 'y.js', '--json']));
|
|
14482
|
+
if (!rc || rc.recorded !== before.currentRunId) bad.push(`⑤예전 run 을 인수하지 못함(${rc && rc.recorded} vs ${before.currentRunId})`);
|
|
14483
|
+
dbg.migrate = { old: before.currentRunId, recorded: rc && rc.recorded };
|
|
14484
|
+
}
|
|
14485
|
+
// ⑥ 무효한 명시 주소를 **조용히 무시하지 않는다**(무시하면 전역 슬롯으로 떨어져 이 버그가 되살아난다).
|
|
14486
|
+
{
|
|
14487
|
+
const d = mk('badkey');
|
|
14488
|
+
const eBad = Object.assign({}, envP, { LEERNESS_SESSION_ID: 'x' });
|
|
14489
|
+
const r = R(d, eBad, ['state', 'start', '목표']);
|
|
14490
|
+
const msg = String(r.stdout || '') + String(r.stderr || '');
|
|
14491
|
+
if (SP.deriveSessionKey({ LEERNESS_SESSION_ID: 'x' })) bad.push('⑥픽스처 키가 유효해져 이 축이 공허해졌다');
|
|
14492
|
+
else if (!/형식에 맞지 않아 무시/.test(msg)) bad.push('⑥무효한 세션 주소를 조용히 무시');
|
|
14493
|
+
dbg.badKey = { warned: /형식에 맞지 않아 무시/.test(msg) };
|
|
14494
|
+
}
|
|
14495
|
+
ok = bad.length === 0;
|
|
14496
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
|
|
14497
|
+
finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
14498
|
+
console.log(ok ? `✓ AF(1.36.142/T-0116) 증거 귀속: 두 세션이 같은 프로젝트에서 일해도 증거가 **일한 세션**에 붙고 놀던 세션은 완료 허용을 못 받음 · 남의 run 거절+사유 · 남의 handoff 가 내 run 을 안 끊음 · 세션키 없는 기존 흐름 무회귀 · 예전 상태 인수 · 무효 주소 고지 ${JSON.stringify(dbg)}`
|
|
14499
|
+
: '✗ 1.36.142 증거 귀속 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
|
|
14500
|
+
if (!ok) failed++;
|
|
14501
|
+
}
|
|
14502
|
+
|
|
14503
|
+
total++;
|
|
14504
|
+
// AG(1.36.143) — **없는 `--path` 는 아무것도 만들지 않는다** + 공유 판정은 추정하지 않는다.
|
|
14505
|
+
// 검수는 `provider add` · `team add` · `creds register` 3건을 지목했다. 이름을 더 넣는 대신
|
|
14506
|
+
// 모든 명령에 없는 `--path` 를 주고 전수로 재니 **33건**이 그 경로를 만들었다 —
|
|
14507
|
+
// 다수는 **성공이라 말하면서**(`provider add` · `team add` · `skill learn` · `glossary` · `wakeup-interval`),
|
|
14508
|
+
// 그중엔 직전 라운드에 내가 넣은 `state start` 도 있었다. 개별 등록은 이미 세 번째였다.
|
|
14509
|
+
// 그래서 이름 목록이 아니라 **성질**로 막고, 여기서도 이름이 아니라 **열거**로 잰다.
|
|
14510
|
+
{
|
|
14511
|
+
let ok = false; const bad = []; const dbg = {};
|
|
14512
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-143-'));
|
|
14513
|
+
const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
|
|
14514
|
+
for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
|
|
14515
|
+
'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
|
|
14516
|
+
'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG',
|
|
14517
|
+
'GIT_DIR', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_NOSYSTEM']) delete envP[k];
|
|
14518
|
+
envP.GIT_CONFIG_GLOBAL = path.join(sb, 'gitconfig-global');
|
|
14519
|
+
envP.GIT_CONFIG_SYSTEM = path.join(sb, 'gitconfig-system');
|
|
14520
|
+
try { fs.writeFileSync(envP.GIT_CONFIG_GLOBAL, ''); fs.writeFileSync(envP.GIT_CONFIG_SYSTEM, ''); } catch {}
|
|
14521
|
+
const G = (d, a) => cp.spawnSync('git', a, { cwd: d, encoding: 'utf8', timeout: 60000, env: envP });
|
|
14522
|
+
const RP = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a],
|
|
14523
|
+
{ cwd: d, encoding: 'utf8', timeout: 60000, env: envP, maxBuffer: 32 * 1024 * 1024 });
|
|
14524
|
+
const J = (r) => { try { return JSON.parse(String(r.stdout || '')); } catch { return null; } };
|
|
14525
|
+
const cwd = path.join(sb, 'cwd'); fs.mkdirSync(cwd, { recursive: true });
|
|
14526
|
+
try {
|
|
14527
|
+
// ① 전수 — 제품 자신에게 명령 목록을 묻고, 하위명령까지 펴서 없는 `--path` 로 부른다.
|
|
14528
|
+
{
|
|
14529
|
+
let usages = [];
|
|
14530
|
+
const lr = RP(cwd, ['commands', '--json']);
|
|
14531
|
+
try {
|
|
14532
|
+
const collect = (v) => {
|
|
14533
|
+
if (Array.isArray(v)) for (const x of v) collect(x);
|
|
14534
|
+
else if (v && typeof v === 'object') { if (typeof v.cmd === 'string') usages.push(v.cmd); else if (typeof v.name === 'string') usages.push(v.name); else for (const k of Object.keys(v)) collect(v[k]); }
|
|
14535
|
+
else if (typeof v === 'string') usages.push(v);
|
|
14536
|
+
};
|
|
14537
|
+
collect(JSON.parse(lr.stdout));
|
|
14538
|
+
} catch (e) { bad.push('①명령 목록을 얻지 못함: ' + String(e.message).slice(0, 60)); }
|
|
14539
|
+
// 부재가 정상 입력인 명령(경로를 만드는 것이 그 일)은 대상이 아니다.
|
|
14540
|
+
const CREATORS = /^(init|migrate|migrate-workspace-dir|update|auto-update|adapter|ci)\b/;
|
|
14541
|
+
const invocations = []; const seen = new Set();
|
|
14542
|
+
for (const raw of Array.from(new Set(usages))) {
|
|
14543
|
+
const u = String(raw).replace(/^leerness\s+/, '').trim();
|
|
14544
|
+
if (!u || CREATORS.test(u)) continue;
|
|
14545
|
+
const toks = u.split(/\s+/); const head = toks[0];
|
|
14546
|
+
if (!/^[a-z][a-z0-9-]*$/.test(head)) continue;
|
|
14547
|
+
const second = toks[1] || '';
|
|
14548
|
+
let subs = [null];
|
|
14549
|
+
if (second && second.includes('|') && !/^[[<]/.test(second)) subs = second.split('|').map((x) => x.replace(/[[\]<>"]/g, '')).filter((x) => /^[a-z][a-z0-9-]*$/.test(x));
|
|
14550
|
+
else if (second && /^[a-z][a-z0-9-]*$/.test(second)) subs = [second];
|
|
14551
|
+
for (const sub of subs) {
|
|
14552
|
+
for (const extra of [[], ['zzprobe']]) {
|
|
14553
|
+
const argv = sub ? [head, sub, ...extra] : [head, ...extra];
|
|
14554
|
+
const label = argv.join(' ');
|
|
14555
|
+
if (!seen.has(label)) { seen.add(label); invocations.push({ label, argv }); }
|
|
14556
|
+
}
|
|
14557
|
+
}
|
|
14558
|
+
}
|
|
14559
|
+
// 열거가 붕괴하면 "0건" 은 아무 뜻이 없다 — 크기를 함께 단언한다.
|
|
14560
|
+
if (invocations.length < 100) bad.push(`①열거가 ${invocations.length}건 — 계측 붕괴(0건 통과가 무의미해진다)`);
|
|
14561
|
+
const leaks = [];
|
|
14562
|
+
for (let i = 0; i < invocations.length; i++) {
|
|
14563
|
+
const ghost = path.join(sb, 'g' + i, 'nope');
|
|
14564
|
+
const r = RP(cwd, [...invocations[i].argv, '--path', ghost]);
|
|
14565
|
+
if (fs.existsSync(ghost)) leaks.push(`${invocations[i].label}(exit=${r.status})`);
|
|
14566
|
+
}
|
|
14567
|
+
if (leaks.length) bad.push(`①없는 경로를 만든 호출 ${leaks.length}건: ${leaks.slice(0, 6).join(' · ')}`);
|
|
14568
|
+
dbg.ghostSweep = { invocations: invocations.length, leaks: leaks.length };
|
|
14569
|
+
}
|
|
14570
|
+
// ② 대조군 — 존재하는 프로젝트에서는 그대로 동작해야 한다(과차단으로 통과하면 안 된다).
|
|
14571
|
+
{
|
|
14572
|
+
const d = path.join(sb, 'real'); fs.mkdirSync(d, { recursive: true });
|
|
14573
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
14574
|
+
RP(d, ['init', d, '--yes', '--minimal']);
|
|
14575
|
+
const exits = ['handoff', 'status', 'audit'].map((c) => RP(d, [c, '--path', d]).status);
|
|
14576
|
+
if (exits.some((x) => x !== 0)) bad.push(`②정상 프로젝트에서 조회가 막힘(exits=${JSON.stringify(exits)})`);
|
|
14577
|
+
dbg.realProject = { exits };
|
|
14578
|
+
}
|
|
14579
|
+
// ③ worktree 형제 차단을 **추정하지 않고 각 worktree 에게 묻는다**.
|
|
14580
|
+
{
|
|
14581
|
+
const mkRepo = (name) => {
|
|
14582
|
+
const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
|
|
14583
|
+
G(d, ['init', '-q', '-b', 'main', '.']); G(d, ['config', 'user.email', 't@t']); G(d, ['config', 'user.name', 't']);
|
|
14584
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
14585
|
+
fs.writeFileSync(path.join(d, 'a.txt'), 'a'); G(d, ['add', '-A']); G(d, ['commit', '-qm', 'i']);
|
|
14586
|
+
RP(d, ['init', d, '--yes', '--minimal']); RP(d, ['handoff', d]);
|
|
14587
|
+
return d;
|
|
14588
|
+
};
|
|
14589
|
+
const m1 = mkRepo('wt-shared');
|
|
14590
|
+
const add1 = G(m1, ['worktree', 'add', '-qb', 'lk', path.join(sb, 'wt-shared-linked')]);
|
|
14591
|
+
const j1 = J(RP(m1, ['enforce', 'install', '--path', m1, '--json', '--skip-verify']));
|
|
14592
|
+
const m2 = mkRepo('wt-own');
|
|
14593
|
+
G(m2, ['config', 'extensions.worktreeConfig', 'true']);
|
|
14594
|
+
const add2 = G(m2, ['worktree', 'add', '-qb', 'lk2', path.join(sb, 'wt-own-linked')]);
|
|
14595
|
+
fs.mkdirSync(path.join(m2, '.git', 'wt-hooks'), { recursive: true });
|
|
14596
|
+
const cfg = G(m2, ['config', '--worktree', 'core.hooksPath', '.git/wt-hooks']);
|
|
14597
|
+
const j2 = J(RP(m2, ['enforce', 'status', '--path', m2, '--json']));
|
|
14598
|
+
if (add1.status !== 0 || add2.status !== 0 || cfg.status !== 0) bad.push('③worktree 픽스처를 만들지 못함 — 판별 불가');
|
|
14599
|
+
else {
|
|
14600
|
+
if (!j1 || j1.worktrees !== 2) bad.push(`③공용 훅인데 형제 차단을 말하지 않음(worktrees=${j1 && j1.worktrees})`);
|
|
14601
|
+
if (!j2 || j2.worktrees !== 1) bad.push(`③worktree 전용 훅인데 형제도 막힌다고 함(worktrees=${j2 && j2.worktrees})`);
|
|
14602
|
+
}
|
|
14603
|
+
dbg.worktree = { shared: j1 && j1.worktrees, perWorktree: j2 && j2.worktrees };
|
|
14604
|
+
}
|
|
14605
|
+
// ④ 세척 클래스의 **경계** — 지워야 할 것과 남겨야 할 것을 같이 잰다(한쪽만 재면 과세척이 통과한다).
|
|
14606
|
+
{
|
|
14607
|
+
const g = require(path.resolve(__dirname, '..', 'lib', 'git.js'));
|
|
14608
|
+
const mustDrop = ['GIT_DIR', 'git_dir', 'GIT_CONFIG_NOSYSTEM', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_7', 'GIT_CONFIG_VALUE_7'];
|
|
14609
|
+
const mustKeep = ['GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CEILING_DIRECTORIES', 'GIT_AUTHOR_NAME', 'GIT_SSH_COMMAND', 'GIT_EDITOR'];
|
|
14610
|
+
const droppedNot = mustDrop.filter((k) => !g._shouldDrop(k));
|
|
14611
|
+
const keptNot = mustKeep.filter((k) => g._shouldDrop(k));
|
|
14612
|
+
if (droppedNot.length) bad.push(`④세척해야 하는데 안 함: ${droppedNot.join(', ')}`);
|
|
14613
|
+
if (keptNot.length) bad.push(`④남겨야 하는데 세척함(과세척): ${keptNot.join(', ')}`);
|
|
14614
|
+
dbg.scrub = { droppedNot, keptNot, total: g.GIT_LOCATION_ENV.length };
|
|
14615
|
+
}
|
|
14616
|
+
ok = bad.length === 0;
|
|
14617
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
|
|
14618
|
+
finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
14619
|
+
console.log(ok ? `✓ AG(1.36.143) 없는 --path 는 아무것도 만들지 않음(전수 열거, 고치기 전 33건) · 정상 프로젝트 무회귀 · worktree 형제 차단을 추정 대신 **각 worktree 에게 물어** 판정 · 세척 클래스 경계(지울 것/남길 것 양방향) ${JSON.stringify(dbg)}`
|
|
14620
|
+
: '✗ 1.36.143 경로/공유 판정 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
|
|
14621
|
+
if (!ok) failed++;
|
|
14622
|
+
}
|
|
14623
|
+
|
|
14624
|
+
total++;
|
|
14625
|
+
// AH(1.36.144/T-0117) — **`--dry-run` 은 아무것도 쓰지 않는다** + 재검수 P1/P2.
|
|
14626
|
+
// `--dry-run` 은 등록된 전역 플래그인데 많은 명령이 그냥 무시하고 썼다. 378 호출을 전후 트리 비교로 재니
|
|
14627
|
+
// 183건이 썼고 그중 28건이 **사용자 데이터**를 바꿨다(`session close` 가 실제 마감을 수행, `task/decision/rule/plan add`
|
|
14628
|
+
// 가 실제로 추가). 명령마다 분기를 심으면 16개의 새 실수 자리가 생기므로 **쓰기 시점 하나**에서 막는다.
|
|
14629
|
+
{
|
|
14630
|
+
let ok = false; const bad = []; const dbg = {};
|
|
14631
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-144-'));
|
|
14632
|
+
const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
|
|
14633
|
+
for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
|
|
14634
|
+
'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
|
|
14635
|
+
'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG',
|
|
14636
|
+
'GIT_DIR', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_NOSYSTEM']) delete envP[k];
|
|
14637
|
+
envP.GIT_CONFIG_GLOBAL = path.join(sb, 'gcg'); envP.GIT_CONFIG_SYSTEM = path.join(sb, 'gcs');
|
|
14638
|
+
try { fs.writeFileSync(envP.GIT_CONFIG_GLOBAL, ''); fs.writeFileSync(envP.GIT_CONFIG_SYSTEM, ''); } catch {}
|
|
14639
|
+
const G = (d, a) => cp.spawnSync('git', a, { cwd: d, encoding: 'utf8', timeout: 60000, env: envP });
|
|
14640
|
+
const RP = (d, a, env) => cp.spawnSync(process.execPath, [CLI, ...a],
|
|
14641
|
+
{ cwd: d, encoding: 'utf8', timeout: 60000, env: env || envP, maxBuffer: 32 * 1024 * 1024 });
|
|
14642
|
+
const J = (r) => { try { return JSON.parse(String(r.stdout || '')); } catch { return null; } };
|
|
14643
|
+
const snap = (d) => {
|
|
14644
|
+
const out = {};
|
|
14645
|
+
const walk = (q) => {
|
|
14646
|
+
let ents = []; try { ents = fs.readdirSync(q, { withFileTypes: true }); } catch { return; }
|
|
14647
|
+
for (const e of ents) {
|
|
14648
|
+
const full = path.join(q, e.name);
|
|
14649
|
+
if (e.isDirectory()) { out[path.relative(d, full) + '/'] = 'DIR'; walk(full); }
|
|
14650
|
+
else { let h = 'ERR'; try { h = String(fs.readFileSync(full).length); } catch {} out[path.relative(d, full)] = h; }
|
|
14651
|
+
}
|
|
14652
|
+
};
|
|
14653
|
+
walk(d); return out;
|
|
14654
|
+
};
|
|
14655
|
+
const diffKeys = (a, b) => Array.from(new Set([...Object.keys(a), ...Object.keys(b)])).filter((k) => a[k] !== b[k]);
|
|
14656
|
+
const mkGit = (name) => {
|
|
14657
|
+
const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
|
|
14658
|
+
G(d, ['init', '-q', '-b', 'main', '.']); G(d, ['config', 'user.email', 't@t']); G(d, ['config', 'user.name', 't']);
|
|
14659
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
14660
|
+
fs.writeFileSync(path.join(d, 'a.txt'), 'a'); G(d, ['add', '-A']); G(d, ['commit', '-qm', 'i']);
|
|
14661
|
+
RP(d, ['init', d, '--yes', '--minimal']); RP(d, ['handoff', d]);
|
|
14662
|
+
return d;
|
|
14663
|
+
};
|
|
14664
|
+
try {
|
|
14665
|
+
// ① 실제로 썼던 명령들이 이제 **아무것도 바꾸지 않고** 거절한다 + 실행하면 동작한다(대조군).
|
|
14666
|
+
{
|
|
14667
|
+
const cases = [['task', 'add', '작업'], ['decision', 'add', '결정', '--reason', 'r'], ['rule', 'add', '룰'],
|
|
14668
|
+
['plan', 'add', '계획'], ['lesson', 'save', '교훈'], ['session', 'close'], ['state', 'start', '목표']];
|
|
14669
|
+
const wrote = [], notRefused = [];
|
|
14670
|
+
for (let i = 0; i < cases.length; i++) {
|
|
14671
|
+
const d = mkGit('dry' + i);
|
|
14672
|
+
const before = snap(d);
|
|
14673
|
+
const r = RP(d, [...cases[i], '--dry-run', '--path', d]);
|
|
14674
|
+
const changed = diffKeys(before, snap(d));
|
|
14675
|
+
if (changed.length) wrote.push(`${cases[i].join(' ')}→${changed.slice(0, 3).join(',')}`);
|
|
14676
|
+
if (r.status === 0) notRefused.push(cases[i].join(' '));
|
|
14677
|
+
}
|
|
14678
|
+
if (wrote.length) bad.push(`①--dry-run 인데 바뀐 것: ${wrote.join(' · ')}`);
|
|
14679
|
+
if (notRefused.length) bad.push(`①--dry-run 을 구현 안 했는데 성공이라 보고: ${notRefused.join(' · ')}`);
|
|
14680
|
+
// 대조군 — 같은 명령을 플래그 없이 돌리면 **실제로 동작해야** 한다(과차단으로 통과하면 안 된다).
|
|
14681
|
+
const d2 = mkGit('real');
|
|
14682
|
+
const okExits = [RP(d2, ['task', 'add', '작업', '--path', d2]).status,
|
|
14683
|
+
RP(d2, ['decision', 'add', '결정', '--reason', 'r', '--path', d2]).status];
|
|
14684
|
+
if (okExits.some((x) => x !== 0)) bad.push(`①플래그 없는 정상 실행이 막힘(exits=${JSON.stringify(okExits)})`);
|
|
14685
|
+
dbg.dryRun = { wrote: wrote.length, notRefused: notRefused.length, realExits: okExits };
|
|
14686
|
+
}
|
|
14687
|
+
// ② `--path=` / 값 없는 `--path` 가 가드를 우회해 cwd 에 쓰지 않는다.
|
|
14688
|
+
{
|
|
14689
|
+
const d = mkGit('emptypath');
|
|
14690
|
+
const before = snap(d);
|
|
14691
|
+
const r1 = RP(d, ['decision', 'add', 'SNEAKY1', '--reason', 'r', '--path=']);
|
|
14692
|
+
const r2 = RP(d, ['decision', 'add', 'SNEAKY2', '--reason', 'r', '--path']);
|
|
14693
|
+
const changed = diffKeys(before, snap(d));
|
|
14694
|
+
if (r1.status === 0 || r2.status === 0) bad.push(`②빈 --path 인데 성공이라 보고(${r1.status}/${r2.status})`);
|
|
14695
|
+
if (changed.length) bad.push(`②빈 --path 가 cwd 에 씀: ${changed.slice(0, 3).join(',')}`);
|
|
14696
|
+
dbg.emptyPath = { exits: [r1.status, r2.status], changed: changed.length };
|
|
14697
|
+
}
|
|
14698
|
+
// ③ MCP `release_cleanup` 이 **요청한 저장소**만 건드린다(서버 cwd 가 아니라).
|
|
14699
|
+
{
|
|
14700
|
+
const victim = mkGit('mcp-victim');
|
|
14701
|
+
for (let i = 0; i < 60; i++) G(victim, ['branch', 'release/1.0.' + i]);
|
|
14702
|
+
const target = mkGit('mcp-target');
|
|
14703
|
+
const cnt = () => (G(victim, ['branch', '--list', 'release/*']).stdout || '').split('\n').filter((l) => l.trim()).length;
|
|
14704
|
+
const before = cnt();
|
|
14705
|
+
const req = [
|
|
14706
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '1' } } }),
|
|
14707
|
+
JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'leerness_release_cleanup', arguments: { path: target, apply: true, keep: 10 } } }),
|
|
14708
|
+
].join('\n') + '\n';
|
|
14709
|
+
cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], { cwd: victim, input: req, encoding: 'utf8', env: envP, timeout: 180000 });
|
|
14710
|
+
const after = cnt();
|
|
14711
|
+
if (after !== before) bad.push(`③MCP 가 서버 cwd 저장소의 브랜치 ${before - after}개를 지움(${before}→${after})`);
|
|
14712
|
+
dbg.mcpCleanup = { before, after };
|
|
14713
|
+
}
|
|
14714
|
+
// ④ 세션 소유가 **손상값 · `__proto__` · 노출**에 견딘다.
|
|
14715
|
+
{
|
|
14716
|
+
const eA = Object.assign({}, envP, { LEERNESS_SESSION_ID: 'session-alpha01' });
|
|
14717
|
+
const eB = Object.assign({}, envP, { LEERNESS_SESSION_ID: 'session-bravo01' });
|
|
14718
|
+
const d = mkGit('own');
|
|
14719
|
+
RP(d, ['state', 'start', 'A', '--json'], eA);
|
|
14720
|
+
const stf = path.join(d, '.leerness', 'state.json');
|
|
14721
|
+
const st = JSON.parse(fs.readFileSync(stf, 'utf8'));
|
|
14722
|
+
st.runsBySession = ['corrupt'];
|
|
14723
|
+
fs.writeFileSync(stf, JSON.stringify(st, null, 2) + '\n');
|
|
14724
|
+
const rB = RP(d, ['state', 'record', '--files-changed', 'stolen.js'], eB);
|
|
14725
|
+
const runA = JSON.parse(fs.readFileSync(path.join(d, '.leerness', 'runs', 'run-0001.json'), 'utf8'));
|
|
14726
|
+
if (rB.status === 0) bad.push('④손상된 소유맵에서 남의 run 에 기록 성공(fail-open)');
|
|
14727
|
+
if (runA.files_changed.length) bad.push('④손상된 소유맵에서 남의 증거가 오염됨');
|
|
14728
|
+
// `__proto__` 키
|
|
14729
|
+
const d2 = mkGit('own2');
|
|
14730
|
+
const eP = Object.assign({}, envP, { LEERNESS_SESSION_ID: '__proto__aaaa' });
|
|
14731
|
+
const s1 = J(RP(d2, ['state', 'start', 'P', '--json'], eP));
|
|
14732
|
+
const r1 = J(RP(d2, ['state', 'record', '--files-changed', 'p.js', '--json'], eP));
|
|
14733
|
+
if (!s1 || !r1 || r1.recorded !== s1.started) bad.push(`④__proto__ 키에서 소유가 사라짐(${r1 && r1.recorded})`);
|
|
14734
|
+
// 남의 키 노출
|
|
14735
|
+
const d3 = mkGit('own3');
|
|
14736
|
+
RP(d3, ['state', 'start', 'A', '--json'], eA);
|
|
14737
|
+
RP(d3, ['state', 'start', 'B', '--json'], eB);
|
|
14738
|
+
const show = J(RP(d3, ['state', 'show', '--json'], eB));
|
|
14739
|
+
const keys = Object.keys((show && show.runsBySession) || {});
|
|
14740
|
+
if (keys.includes('session-alpha01')) bad.push('④남의 세션 키를 그대로 노출(그 키로 남의 run 을 고칠 수 있다)');
|
|
14741
|
+
if (!keys.includes('session-bravo01')) bad.push('④내 세션 키를 못 보여줌 — 내 것을 확인할 수 없다');
|
|
14742
|
+
dbg.ownership = { corruptExit: rB.status, protoRecorded: r1 && r1.recorded, keys };
|
|
14743
|
+
}
|
|
14744
|
+
// ⑤ 사라진(prunable) worktree 하나가 **살아 있는 형제의 차단을 숨기지 않는다**.
|
|
14745
|
+
{
|
|
14746
|
+
const m = mkGit('wt');
|
|
14747
|
+
const live = path.join(sb, 'wt-live'), gone = path.join(sb, 'wt-gone');
|
|
14748
|
+
const a1 = G(m, ['worktree', 'add', '-qb', 'live', live]);
|
|
14749
|
+
const a2 = G(m, ['worktree', 'add', '-qb', 'gone', gone]);
|
|
14750
|
+
fs.rmSync(gone, { recursive: true, force: true });
|
|
14751
|
+
const j = J(RP(m, ['enforce', 'install', '--path', m, '--json', '--skip-verify']));
|
|
14752
|
+
if (a1.status !== 0 || a2.status !== 0) bad.push('⑤worktree 픽스처 실패 — 판별 불가');
|
|
14753
|
+
else if (!j || j.worktrees !== 2) bad.push(`⑤prunable 하나가 전체 판정을 가림(worktrees=${j && j.worktrees}, 기대 2)`);
|
|
14754
|
+
dbg.prunable = { worktrees: j && j.worktrees };
|
|
14755
|
+
}
|
|
14756
|
+
ok = bad.length === 0;
|
|
14757
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
|
|
14758
|
+
finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
14759
|
+
console.log(ok ? `✓ AH(1.36.144/T-0117) --dry-run 이 아무것도 안 씀(고치기 전 378중 183) + 구현 안 한 명령은 거절 · 플래그 없는 실행은 그대로 · 빈 --path 가 cwd 에 못 씀 · MCP 가 서버 cwd 저장소를 안 지움 · 소유맵이 손상/__proto__/노출에 견딤 · prunable 이 형제 차단을 안 숨김 ${JSON.stringify(dbg)}`
|
|
14760
|
+
: '✗ 1.36.144 dry-run/소유 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
|
|
14761
|
+
if (!ok) failed++;
|
|
14762
|
+
}
|
|
14763
|
+
|
|
14351
14764
|
console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
|
|
14352
14765
|
if (failed > 0) process.exit(1);
|
|
14353
14766
|
|