leerness 1.36.144 → 1.36.148
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 +76 -15
- package/lib/git.js +32 -1
- package/lib/io.js +41 -3
- package/lib/mcp-tools.js +5 -5
- package/lib/session-presence.js +7 -0
- package/lib/team.js +6 -0
- package/package.json +1 -1
- package/scripts/e2e.js +416 -0
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.148 하네스를 사용합니다. 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.148는 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.144는 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.148 릴리스 태그 이력** (수백 라운드) · _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.148: 2026-08-21
|
|
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.148';
|
|
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') 시 호스트 프로세스 오염.
|
|
@@ -7374,11 +7374,13 @@ function enforceCmd(root, sub) {
|
|
|
7374
7374
|
if (snap.kind === 'file') {
|
|
7375
7375
|
const cur = _lexists(p) ? (() => { try { return fs.lstatSync(p); } catch { return null; } })() : null;
|
|
7376
7376
|
if (cur && !cur.isSymbolicLink()) {
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
7380
|
-
|
|
7381
|
-
|
|
7377
|
+
// ⚠ 1.36.148 (재검수 P1): 제자리 쓰기가 실패하면 **지우고 다시 쓰기**로 넘어갔는데,
|
|
7378
|
+
// 그 재쓰기까지 실패하면 사용자 훅과 체인 백업을 **둘 다** 잃는다.
|
|
7379
|
+
// 같은 종류(파일→파일)면 지우지 않는다 — 실패는 위로 던져 `_rbFail` 에 잡히고,
|
|
7380
|
+
// 최소한 원본은 남는다("애매하면 남기는 쪽으로 실패한다").
|
|
7381
|
+
fs.writeFileSync(p, snap.buf);
|
|
7382
|
+
try { fs.chmodSync(p, snap.mode); } catch {} // 원래 권한으로 — 늘 0755 로 넓히지 않는다
|
|
7383
|
+
return;
|
|
7382
7384
|
}
|
|
7383
7385
|
}
|
|
7384
7386
|
// ⚠ 1.36.143 (재검수 P1): 삭제 실패를 삼키면 **우리 차단 훅이 살아있는데** 되돌렸다고 말하게 된다.
|
|
@@ -23588,12 +23590,16 @@ function mcpServeCmd(root) {
|
|
|
23588
23590
|
// 예산 표(MCP_LONG_PATHS)와 _mcpTimeoutFor 는 모듈 스코프에 있다 — selftest 가 소스 grep 이 아니라
|
|
23589
23591
|
// **실제 호출**로 `budget > 내부한도` 를 단언할 수 있게 하기 위함(1.36.125).
|
|
23590
23592
|
const _shQuote = (s) => (/[\s"']/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
|
|
23591
|
-
|
|
23593
|
+
// 1.36.145 (재검수 P1, 재현): T-0116 을 CLI 에서만 고쳤다 — MCP 도구에는 **세션 주소 입력이 없어**
|
|
23594
|
+
// 한 서버의 모든 호출이 같은(=무)주소로 접혀 전역 슬롯 오귀속이 그대로 남아 있었다.
|
|
23595
|
+
// 실측: A 의 증거가 B 의 run 에 붙고, 일하지 않은 B 가 `completion_claim_allowed: true` 를 받았다.
|
|
23596
|
+
// 자식 프로세스로 돌리므로 **env 로 주소를 넘길 수 있다** — 호출마다 다른 주소를 준다.
|
|
23597
|
+
function callLeerness(cliArgs, extraEnv) {
|
|
23592
23598
|
const budget = _mcpTimeoutFor(cliArgs);
|
|
23593
23599
|
const r = cp.spawnSync(process.execPath, [__filename, ...cliArgs], {
|
|
23594
23600
|
encoding: 'utf8',
|
|
23595
23601
|
timeout: budget,
|
|
23596
|
-
env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_STALE_CHECK: '1', LEERNESS_NO_DRIFT_CHECK: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_WORKFLOW_GUIDE: '1' }
|
|
23602
|
+
env: { ...process.env, LEERNESS_INTERNAL: '1', LEERNESS_NO_BANNER: '1', LEERNESS_NO_STALE_CHECK: '1', LEERNESS_NO_DRIFT_CHECK: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_WORKFLOW_GUIDE: '1', ...(extraEnv || {}) }
|
|
23597
23603
|
});
|
|
23598
23604
|
const cmd = `leerness ${cliArgs.map(_shQuote).join(' ')}`;
|
|
23599
23605
|
const partial = ((r.stdout || '') + (r.stderr || '')).trim();
|
|
@@ -23672,6 +23678,13 @@ function mcpServeCmd(root) {
|
|
|
23672
23678
|
}
|
|
23673
23679
|
// 1.12.2 (14th 버그헌트, UR-0181): MCP 서버는 generic 설계 — cwd 에서 실행 후 각 호출의 path 로 대상 프로젝트 지정(의도된 기능, e2e 가 검증). 즉 path-타게팅 자체는 취약점 아님(신뢰된 로컬 MCP). policy 도 대상-프로젝트별로 path 에서 로드(올바름).
|
|
23674
23680
|
// 진짜 버그는 unknown tool 도 _bumpMcpUsage 가 임의 경로에 무조건 쓰던 것 → 사용 통계를 unknown-tool 검증 "후"로 이동. (프로젝트-scoped 가둠은 opt-in 플래그로 별도 검토 — UR backlog)
|
|
23681
|
+
// 1.36.146 (재검수 P1, 재현): 빈 `path` 가 falsy 라 **서버 root** 로 떨어져
|
|
23682
|
+
// CLI 의 `path_missing_value` 가드를 우회하고 엉뚜한 저장소에 썼다(실측: MCPSNEAK 기록됨).
|
|
23683
|
+
// 키를 준 이상 값은 문자열이어야 한다 — 아니면 그렇다고 말한다.
|
|
23684
|
+
if (args && Object.prototype.hasOwnProperty.call(args, 'path')
|
|
23685
|
+
&& (typeof args.path !== 'string' || !args.path.trim())) {
|
|
23686
|
+
return send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: 'path 에 값이 없습니다 — 아무것도 하지 않았습니다(서버 작업디렉토리로 대체하지 않음)' }], isError: true } });
|
|
23687
|
+
}
|
|
23675
23688
|
const targetPath = args.path || root;
|
|
23676
23689
|
let cliArgs;
|
|
23677
23690
|
try {
|
|
@@ -23689,7 +23702,9 @@ function mcpServeCmd(root) {
|
|
|
23689
23702
|
return send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `정책 차단(policy): ${pol.reason}` }], isError: true } });
|
|
23690
23703
|
}
|
|
23691
23704
|
} catch {}
|
|
23692
|
-
|
|
23705
|
+
// 세션 주소를 그대로 전달한다(문자열일 때만). 형식이 안 맞으면 CLI 가 무시하고 그 사실을 말한다.
|
|
23706
|
+
const _sk = (args && typeof args.sessionKey === 'string' && args.sessionKey.trim()) ? args.sessionKey.trim() : null;
|
|
23707
|
+
const r = callLeerness(cliArgs, _sk ? { LEERNESS_SESSION_ID: _sk } : null);
|
|
23693
23708
|
// 1.9.61: cursor 기반 페이지네이션 — 긴 출력은 cursor offset로 다음 청크
|
|
23694
23709
|
const fullText = r.stdout || r.stderr || '(no output)';
|
|
23695
23710
|
// 1.12.4 (15th 버그헌트 P2, UR-0016): _chunkSize 를 양의 정수로 클램프 — 음수/소수면 slice 빈 출력 + nextCursor 음수 → 무한 빈-루프(데이터 손실)였음.
|
|
@@ -24944,6 +24959,10 @@ function policyCmd(root, sub, ...args) {
|
|
|
24944
24959
|
// 향후 UR-0031(MCP verb)/UR-0033(adapter) 가 이 스키마를 공통 호출 표면으로 사용.
|
|
24945
24960
|
function _leernessStateDir(root) { return path.join(absRoot(root), '.leerness'); }
|
|
24946
24961
|
// _newRunRecord → lib/pure-utils.js 로 이동 (1.9.283 UR-0025 2단계)
|
|
24962
|
+
// 1.36.148 (재검수 P1, 재현): run id 를 "빈 문자열만 아니면 통과" 로 본 탓에
|
|
24963
|
+
// `../../evil` 같은 경로형이 소유 맵에 들어가 `state handoff` 가 `.leerness` **밖**에 썼다.
|
|
24964
|
+
// run id 는 우리가 만드는 것이고 모양이 정해져 있다 — 그 모양만 받는다.
|
|
24965
|
+
const _RUN_ID_RE = /^run-\d{1,12}$/;
|
|
24947
24966
|
function _loadLeernessState(root) {
|
|
24948
24967
|
const f = path.join(_leernessStateDir(root), 'state.json');
|
|
24949
24968
|
if (!exists(f)) return { schemaVersion: 1, project: detectProjectName(root), currentRunId: null, runCounter: 0, updatedAt: null };
|
|
@@ -24961,7 +24980,7 @@ function _loadLeernessState(root) {
|
|
|
24961
24980
|
// 모양이 아니면 손상으로 다룬다(없는 것은 정상 — 업그레이드 전 상태).
|
|
24962
24981
|
&& (_st.runsBySession === undefined
|
|
24963
24982
|
|| (_st.runsBySession && typeof _st.runsBySession === 'object' && !Array.isArray(_st.runsBySession)
|
|
24964
|
-
&& Object.values(_st.runsBySession).every((v) =>
|
|
24983
|
+
&& Object.values(_st.runsBySession).every((v) => _RUN_ID_RE.test(String(v)))));
|
|
24965
24984
|
// 디스크의 최대 run 번호 — 카운터의 진실성 대조 기준
|
|
24966
24985
|
let _maxRun = 0;
|
|
24967
24986
|
try {
|
|
@@ -25268,6 +25287,18 @@ function stateCmd(root, sub, ...args) {
|
|
|
25268
25287
|
// 1.9.311 (UR-0047): state 는 .leerness substrate(standalone) — .harness init 가드 미적용 (회귀 방지)
|
|
25269
25288
|
const goal = (args.find(a => a && !a.startsWith('-')) || arg('--goal', '')).trim();
|
|
25270
25289
|
const state = _loadLeernessState(root);
|
|
25290
|
+
// 1.36.147 (재검수 P1, 재현): 같은 세션 키로 두 번 `start` 하면 둘 다 성공하고
|
|
25291
|
+
// 마지막만 소유해 **첫 run 이 증거 없이 고아**가 됐다(실측: run-0001 버려지고 run-0002 에 기록).
|
|
25292
|
+
// 한 세션에는 진행 중 run 이 하나다. 무엇이 열려 있는지 말하고, 넘어가려면 명시하게 한다.
|
|
25293
|
+
{
|
|
25294
|
+
const _open = _sessKey ? _ownedBy(state)[_sessKey] : null;
|
|
25295
|
+
if (_open && !has('--force')) {
|
|
25296
|
+
return failJson(json, 'run_already_open',
|
|
25297
|
+
`이 세션에 진행 중인 run 이 있습니다: ${_open} — 먼저 마감하세요(leerness state handoff "<요약>"). `
|
|
25298
|
+
+ `그래도 새 run 을 시작하려면 --force (${_open} 은 증거 없이 남습니다)`);
|
|
25299
|
+
}
|
|
25300
|
+
if (_open && has('--force')) warn(`⚠ 진행 중이던 ${_open} 을 놓고 새 run 을 시작합니다 — 그 run 은 증거 없이 남습니다`);
|
|
25301
|
+
}
|
|
25271
25302
|
state.runCounter = (state.runCounter || 0) + 1;
|
|
25272
25303
|
const run_id = `run-${String(state.runCounter).padStart(4, '0')}`;
|
|
25273
25304
|
const rec = _newRunRecord({
|
|
@@ -25288,6 +25319,14 @@ function stateCmd(root, sub, ...args) {
|
|
|
25288
25319
|
|
|
25289
25320
|
const state = _loadLeernessState(root);
|
|
25290
25321
|
const curId = _resolveRunId(state);
|
|
25322
|
+
// 1.36.147 (재검수 P1, 재현): 인수는 했는데 **소유를 저장하지 않았다** —
|
|
25323
|
+
// 그래서 두 세션이 같은 legacy run 을 각자 인수해 증거가 섮였다
|
|
25324
|
+
// (실측: 한 run 에 A.js 와 B.js 가 함께 들어갔다). 인수했으면 그 자리에서 이름을 달아 둔다.
|
|
25325
|
+
if (_sessKey && curId && _ownedBy(state)[_sessKey] !== curId
|
|
25326
|
+
&& ['record', 'verify', 'handoff'].includes(sub || '')) {
|
|
25327
|
+
_claimRun(state, curId);
|
|
25328
|
+
try { _saveLeernessState(root, state); } catch { /* 저장 실패는 아래 흐름이 드러낸다 */ }
|
|
25329
|
+
}
|
|
25291
25330
|
if ((sub === 'record' || sub === 'verify' || sub === 'handoff') && !curId) {
|
|
25292
25331
|
// 다른 세션이 쓰는 run 이 있으면 그 사실을 말한다 — 조용히 남의 run 에 쓰지 않는다.
|
|
25293
25332
|
const _others = Object.entries(_ownedBy(state)).filter(([k]) => k !== _sessKey);
|
|
@@ -25337,9 +25376,16 @@ function stateCmd(root, sub, ...args) {
|
|
|
25337
25376
|
const hdir = path.join(_leernessStateDir(root), 'handoff'); mkdirp(hdir);
|
|
25338
25377
|
// 1.9.443 (UR-0153): handoff evidence 에 completion_claim_allowed 포함 — 다음 에이전트/PR 이 증거 게이트를 직접 읽음.
|
|
25339
25378
|
const _ccaH = _completionClaimAllowed(rec);
|
|
25340
|
-
|
|
25341
|
-
|
|
25342
|
-
|
|
25379
|
+
const _payload = JSON.stringify({ ...rec, completion_claim_allowed: _ccaH }, null, 2) + '\n';
|
|
25380
|
+
const _md = `# 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`;
|
|
25381
|
+
// 1.36.145 (재검수 P2): run 은 세션별인데 인수인계 산출물은 **전역 파일 하나**였다 —
|
|
25382
|
+
// 다른 세션이 뒤이어 마감하면 앞사람의 인수인계가 읽히기 전에 덮였다.
|
|
25383
|
+
// run 별 사본을 먼저 남긴다(잃을 것이 없다). `latest.*` 는 계약대로 "가장 최근" 을 유지하되
|
|
25384
|
+
// 누구의 것인지 알 수 있게 run_id 를 본문에 싣는다(이미 rec 에 있다).
|
|
25385
|
+
writeUtf8(path.join(hdir, `${rec.run_id}.json`), _payload);
|
|
25386
|
+
writeUtf8(path.join(hdir, `${rec.run_id}.md`), _md);
|
|
25387
|
+
writeUtf8(path.join(hdir, 'latest.json'), _payload);
|
|
25388
|
+
writeUtf8(path.join(hdir, 'latest.md'), _md);
|
|
25343
25389
|
_releaseRun(state, rec.run_id); // 내 소유만 놓는다(남의 run 은 건드리지 않는다)
|
|
25344
25390
|
_saveLeernessState(root, state);
|
|
25345
25391
|
if (json) { log(JSON.stringify({ handoff: rec.run_id, run: rec, completion_claim_allowed: _ccaH }, null, 2)); return; }
|
|
@@ -25351,8 +25397,11 @@ function stateCmd(root, sub, ...args) {
|
|
|
25351
25397
|
const rec = curId ? _loadRun(root, curId) : null;
|
|
25352
25398
|
// 1.9.443 (UR-0153): evidence-first 완료 게이트 파생 노출.
|
|
25353
25399
|
if (json) {
|
|
25400
|
+
const _redactedOwners = Object.fromEntries(Object.keys(_ownedBy(state)).map((k) => [
|
|
25401
|
+
k === _sessKey ? k : (String(k).slice(0, 6) + '…'), _ownedBy(state)[k]]));
|
|
25402
|
+
const _stateOut = Object.assign({}, state, { runsBySession: _redactedOwners });
|
|
25354
25403
|
log(JSON.stringify({
|
|
25355
|
-
state, currentRun: rec, completion_claim_allowed: rec ? _completionClaimAllowed(rec) : null,
|
|
25404
|
+
state: _stateOut, currentRun: rec, completion_claim_allowed: rec ? _completionClaimAllowed(rec) : null,
|
|
25356
25405
|
sessionKey: _sessKey || null, keySource: _SPst.keySource(process.env) || null,
|
|
25357
25406
|
// 1.36.144 (재검수 P2): 남의 세션 키를 그대로 내보내면 **그 키를 흉내내 남의 run 을 고칠 수 있다**.
|
|
25358
25407
|
// 몇 개가 진행 중인지와 내 것이 무엇인지는 말하되, 남의 주소는 접두만 보인다.
|
|
@@ -27882,7 +27931,7 @@ async function main() {
|
|
|
27882
27931
|
if (has('--help') || has('-h')) return help();
|
|
27883
27932
|
// 1.36.144 (T-0117, 전수 실측): `--dry-run` 을 받았으면 쓰기를 막는다 — 구현한 명령은 애초에 안 쓰므로
|
|
27884
27933
|
// 영향이 없고, 무시하던 명령은 첫 쓰기에서 멈춰 **아무것도 바꾸지 않은 채** 사유를 말한다.
|
|
27885
|
-
if (has('--dry-run')) setDryRunGuard(true);
|
|
27934
|
+
if (has('--dry-run')) { setDryRunGuard(true); try { require('../lib/git').setGitDryRun(true); } catch {} }
|
|
27886
27935
|
// 1.36.103: 실재하지 않는 플래그(오타)를 조용히 삼키지 않는다.
|
|
27887
27936
|
// 값-플래그 미등록 45종은 _VALUE_FLAGS 등록으로 닫았지만, 사용자 오타(--prio, --resaon)는 여전히
|
|
27888
27937
|
// 그 값이 본문에 붙는다(실측: `decision add "use redis" --resaon faster` → 저장된 제목 "use redis faster").
|
|
@@ -28001,6 +28050,18 @@ async function main() {
|
|
|
28001
28050
|
: `경로 없음: ${_abs} — 디렉토리를 새로 만들지 않았습니다`);
|
|
28002
28051
|
return;
|
|
28003
28052
|
}
|
|
28053
|
+
// 1.36.148 (재검수 P2): 존재성만 봐서 **일반 파일**이 프로젝트 root 로 통과했고,
|
|
28054
|
+
// 그 아래에 `.harness` 가 있을 리 없으니 "빈 상태" 를 정상 결과처럼 보고했다.
|
|
28055
|
+
// 프로젝트 root 는 디렉토리다. 파일이면 그렇다고 말한다(스캔처럼 파일이 정상 입력인 경로는 위 _strict 가 따로 다룬다).
|
|
28056
|
+
try {
|
|
28057
|
+
if (!fs.statSync(_abs).isDirectory()) {
|
|
28058
|
+
const _en2 = _uiLang(process.cwd()) === 'en';
|
|
28059
|
+
failJson(has('--json'), 'path_not_a_directory', _en2
|
|
28060
|
+
? `--path is not a directory: ${_abs}`
|
|
28061
|
+
: `--path 가 디렉토리가 아닙니다: ${_abs}`);
|
|
28062
|
+
return;
|
|
28063
|
+
}
|
|
28064
|
+
} catch { /* stat 실패는 위 존재 검사에서 이미 걸러졌다 */ }
|
|
28004
28065
|
}
|
|
28005
28066
|
}
|
|
28006
28067
|
// 1.13.1 (15th 블라인드 리뷰 P1, codex gpt-5.5): 무명령 + 옵션만(예: leerness --json) 은 묵시적 init 쓰기를 하지 않음 — cwd 에 .harness 가 의도치 않게 생성되던 부작용 차단. 명시 명령 없이 옵션만이면 help (bare leerness 온보딩 init 은 유지).
|
package/lib/git.js
CHANGED
|
@@ -44,7 +44,38 @@ const _shouldDrop = (name) => { const u = String(name).toUpperCase(); return _DR
|
|
|
44
44
|
// 이 함수가 자기 자신을 초크포인트 전환 대상으로 잡아 무한 재귀가 된다
|
|
45
45
|
// (실제로 한 번 그렇게 만들어 스택 오버플로로 모든 git 조회가 'git-error' 가 됐다).
|
|
46
46
|
// 가드도 이 형태를 세므로 주석에도 적지 않는다(자기참조 트랩).
|
|
47
|
+
// 1.36.146 (재검수 P1, 재현): dry-run 가드는 파일만 봤고 **git 부작용**은 그대로였다 —
|
|
48
|
+
// `release cleanup --apply --dry-run` 이 브랜치 60 → 10 으로 실제 삭제했다.
|
|
49
|
+
// git 도 초크포인트가 하나니, 상태를 바꾸는 하위명령을 여기서 거절한다.
|
|
50
|
+
const _GIT_MUTATING = new Set(['add', 'rm', 'mv', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch',
|
|
51
|
+
'restore', 'branch', 'tag', 'push', 'fetch', 'pull', 'clone', 'init', 'worktree', 'stash', 'apply', 'cherry-pick',
|
|
52
|
+
'revert', 'clean', 'gc', 'prune', 'update-ref', 'symbolic-ref', 'notes', 'submodule', 'config', 'remote']);
|
|
53
|
+
// 조회성 하위명령은 이름이 같아도 플래그로 갈라진다 — 읽기만 하는 형태는 통과시킨다.
|
|
54
|
+
const _GIT_READ_ONLY_FORM = (sub, rest) => {
|
|
55
|
+
if (sub === 'branch') return !rest.some((x) => /^-(d|D|m|M|c|C)$|^--(delete|move|copy|set-upstream|unset-upstream|edit-description)/.test(x));
|
|
56
|
+
if (sub === 'config') return rest.includes('--get') || rest.includes('--get-all') || rest.includes('--list') || rest.includes('--show-scope') || rest.includes('-l');
|
|
57
|
+
if (sub === 'symbolic-ref') return rest.includes('-q') || rest.includes('--short') || rest.length <= 1;
|
|
58
|
+
if (sub === 'worktree') return rest[0] === 'list';
|
|
59
|
+
if (sub === 'stash') return rest[0] === 'list' || rest[0] === 'show';
|
|
60
|
+
if (sub === 'tag') return !rest.some((x) => /^-(d|a|s|f)$|^--(delete|annotate|sign|force)/.test(x));
|
|
61
|
+
if (sub === 'notes') return rest[0] === 'list' || rest[0] === 'show';
|
|
62
|
+
if (sub === 'submodule') return rest[0] === 'status' || rest[0] === 'summary';
|
|
63
|
+
if (sub === 'remote') return rest.length === 0 || rest[0] === '-v' || rest[0] === 'show' || rest[0] === 'get-url';
|
|
64
|
+
return false;
|
|
65
|
+
};
|
|
66
|
+
let _gitDry = false;
|
|
67
|
+
function setGitDryRun(on) { _gitDry = !!on; }
|
|
47
68
|
function gitSpawn(args, opts) {
|
|
69
|
+
if (_gitDry) {
|
|
70
|
+
// `-C <path>` 같은 선행 옵션을 건너뛰고 첫 하위명령을 찾는다.
|
|
71
|
+
const a = Array.isArray(args) ? args.map(String) : [];
|
|
72
|
+
let i = 0;
|
|
73
|
+
while (i < a.length && (a[i].startsWith('-') || (i > 0 && a[i - 1] === '-C'))) i++;
|
|
74
|
+
const sub = a[i] || '';
|
|
75
|
+
if (_GIT_MUTATING.has(sub) && !_GIT_READ_ONLY_FORM(sub, a.slice(i + 1))) {
|
|
76
|
+
throw Object.assign(new Error(`--dry-run 인데 git ${sub} 를 실행하려 했습니다`), { code: 'E_DRY_RUN_WRITE', file: `git ${sub}` });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
48
79
|
const _argv = ['--no-optional-locks', ...args]; // 사용자 저장소의 `.git/index` 재기록을 막는다(실측)
|
|
49
80
|
// ⚠ 키를 **접어서** 비교한다 — Windows 환경변수는 대소문자를 구분하지 않는데 JS 객체 키는 구분해서,
|
|
50
81
|
// 대문자만 지웠을 때 소문자 `git_dir` 로 그대로 우회됐다(실측: 남의 저장소 훅이 1 → 2).
|
|
@@ -53,4 +84,4 @@ function gitSpawn(args, opts) {
|
|
|
53
84
|
return cp.spawnSync('git', _argv, Object.assign({}, opts, { env: _env }));
|
|
54
85
|
}
|
|
55
86
|
|
|
56
|
-
module.exports = { gitSpawn, GIT_LOCATION_ENV, _shouldDrop };
|
|
87
|
+
module.exports = { gitSpawn, GIT_LOCATION_ENV, _shouldDrop, setGitDryRun };
|
package/lib/io.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
'use strict';
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
8
9
|
|
|
9
10
|
// 1.10.2 (UR-0146): quiet 모드 — 사람용 출력(log/ok/warn) 억제. init --json 등에서 큰 핸들러의 다수 log 를 비침투적으로 묵음 → 순수 JSON 1개만 출력.
|
|
10
11
|
// fail/failJson(오류)은 묵음 대상 아님(에러는 항상 노출). setQuiet 로 토글, 호출부 finally 에서 반드시 복구.
|
|
@@ -127,14 +128,51 @@ function readBuf(p) { return fs.readFileSync(p); }
|
|
|
127
128
|
// ⚠ 락/임시 파일과 사용량 카운터는 사용자 데이터가 아니므로 면제한다 — 안 그러면 정직하게 dry-run 을
|
|
128
129
|
// 구현한 명령이 락을 잡는 것만으로 걸린다(가드가 정상 동작을 막으면 그건 새 결함이다).
|
|
129
130
|
let _dryGuard = false;
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
// ⚠ 1.36.146 (재검수 P1, 재현): 첫 면제 규칙은 `[\\/]cache[\\/]` 였다 — 경로 **어디에든**
|
|
132
|
+
// `cache` 디렉토리가 있으면 면제돼, `C:/Users/me/cache/myproject/` 에 사는 프로젝트는
|
|
133
|
+
// dry-run 가드가 **통째로 꺼졌다**(실측: `decision add --dry-run` 이 그대로 기록됨).
|
|
134
|
+
// 면제는 우리가 아는 자리로 한정한다: 임시 디렉토리 · 락/임시 파일 · harness 캐시.
|
|
135
|
+
const _DRY_EXEMPT_TAIL = /(\.lock$|\.tmp-\d+-\d+$)/;
|
|
136
|
+
const _DRY_EXEMPT_DIR = /[\\/]\.(harness|leerness)[\\/]cache[\\/]/;
|
|
137
|
+
// ⚠ 한 번 `os.tmpdir()` 전체를 면제했다가 뺐다 — 그러면 **내 테스트가 전부 임시 디렉토리에 있어서**
|
|
138
|
+
// 실사용에서는 막히는데 계측만 죽는다(실측: 같은 프로브가 "가드 무력" 이라 보고). 가장 나쁜 조합이다.
|
|
139
|
+
// 면제는 leerness **자신이 만드는 임시 산출물**의 이름 규칙으로만 한정한다.
|
|
140
|
+
const _DRY_EXEMPT_OURS = /leerness-probe-\d+-\d+\.sh$/;
|
|
141
|
+
function _dryExempt(q) {
|
|
142
|
+
const s = String(q);
|
|
143
|
+
if (_DRY_EXEMPT_TAIL.test(s)) return true;
|
|
144
|
+
if (_DRY_EXEMPT_DIR.test(s)) return true;
|
|
145
|
+
if (_DRY_EXEMPT_OURS.test(s)) return true;
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
132
148
|
function _dryCheck(p) {
|
|
133
149
|
if (!_dryGuard) return;
|
|
134
150
|
const s = String(p);
|
|
135
|
-
if (
|
|
151
|
+
if (_dryExempt(s)) return;
|
|
136
152
|
throw Object.assign(new Error(`--dry-run 인데 쓰려고 했습니다: ${s}`), { code: 'E_DRY_RUN_WRITE', file: s });
|
|
137
153
|
}
|
|
154
|
+
// 1.36.146: 가드가 `writeUtf8`/`append`/`mkdirp` **세 함수만** 보고 있었다.
|
|
155
|
+
// 정적 열거: io 를 우회하는 `fs.*` 변이 호출이 **330건**(unlinkSync 15 · rmSync 85 · writeFileSync 125 …).
|
|
156
|
+
// 실측: `enforce remove --dry-run` 이 진짜 훅을 지우고, `release cleanup --apply --dry-run` 이 브랜치 50개를 지웠다.
|
|
157
|
+
// 330곳을 배선하면 330개의 새 실수 자리가 생긴다 — **프로세스 진입점에서 한 번** 막는다.
|
|
158
|
+
const _FS_MUTATORS = ['writeFileSync', 'appendFileSync', 'unlinkSync', 'rmSync', 'rmdirSync',
|
|
159
|
+
'renameSync', 'copyFileSync', 'mkdirSync', 'chmodSync', 'utimesSync', 'symlinkSync', 'truncateSync'];
|
|
160
|
+
let _fsPatched = false;
|
|
161
|
+
function _patchFsForDryRun() {
|
|
162
|
+
if (_fsPatched) return;
|
|
163
|
+
_fsPatched = true;
|
|
164
|
+
for (const name of _FS_MUTATORS) {
|
|
165
|
+
const orig = fs[name];
|
|
166
|
+
if (typeof orig !== 'function') continue;
|
|
167
|
+
fs[name] = function (...a) {
|
|
168
|
+
// 첫 인자가 대상 경로다(rename/copy 는 둘 다 본다).
|
|
169
|
+
_dryCheck(a[0]);
|
|
170
|
+
if ((name === 'renameSync' || name === 'copyFileSync' || name === 'symlinkSync') && a[1] !== undefined) _dryCheck(a[1]);
|
|
171
|
+
return orig.apply(fs, a);
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function setDryRunGuard(on) { _dryGuard = !!on; if (on) _patchFsForDryRun(); }
|
|
138
176
|
// ⚠ `mkdirp` 은 가드하지 **않는다**. 한 번 넣었다가 부작용을 재서 뺀다 —
|
|
139
177
|
// `_withLock` 이 락 디렉토리를 만드는 것까지 막혀 락 획득이 실패하고 **무보호 진행**으로 떨어졌다
|
|
140
178
|
// (실측: `state start --dry-run` → "락 획득 실패 — 보호 없이 진행합니다"). 빈 디렉토리는 사용자 데이터가
|
package/lib/mcp-tools.js
CHANGED
|
@@ -82,11 +82,11 @@ module.exports = [
|
|
|
82
82
|
{ name: 'leerness_slash_commands', requiredTier: 'safe-write', description: '1.9.265/266 (사용자 명시 UR-0021) — CLI AI 에이전트별 슬래시 명령어 레지스트리. 외부 AI(메인)가 sub-agent(codex/agy/claude/grok/copilot)를 호출할 때 각 에이전트에 알맞는 슬래시 명령을 참조. 빌트인 + 사용자 .harness/agent-slash-commands.json override 병합. 응답: { agents: { <id>: { label, asOf, invoke(slash|subcommand), note, source, count, commands[{cmd,desc}] } } }. 인자: { path?, agent? (생략 시 전체), refresh? (1.9.267 — 설치된 CLI --help probe 자동 갱신), dryRun? }. agent 지정 시 단일.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, agent: { type: 'string' }, refresh: { type: 'boolean' }, dryRun: { type: 'boolean' } } } },
|
|
83
83
|
{ name: 'leerness_roles', requiredTier: 'safe-write', description: '1.9.270 (사용자 명시) — 모델별 역할 부여. 여러 AI 에이전트 활성 시 역할(commander/reviewer/coder/architect/designer/debugger/dispatcher)을 provider+model 에 매핑하고, agents dispatch --role 로 라우팅. sub=suggest 면 활성 에이전트 기반 최적 배치 + 근거 반환(방향성 판단). sub=set 시 role/provider/model 필요. 응답: list/suggest/verify 별 JSON. 인자: { path?, sub(list|set|unset|catalog|suggest|verify), role?, provider?, model?, apply? }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, sub: { type: 'string' }, role: { type: 'string' }, provider: { type: 'string' }, model: { type: 'string' }, apply: { type: 'boolean' } } } },
|
|
84
84
|
// 1.9.279 (UR-0031, GPT-5.5 범용 하네스): 상태 substrate(.leerness/, 1.9.278)를 MCP 시맨틱 verb 로 노출 — 모든 에이전트 공통 호출 표면.
|
|
85
|
-
{ name: 'leerness_state_show', requiredTier: 'read-only', description: '1.9.279 (UR-0031) — get_project_context / get_current_task. .leerness/ 구조화 상태(현재 run + 누적)를 회수. 외부 에이전트가 작업 시작 전 "지금 무슨 작업이 진행 중인가 / 무엇을 읽고 무엇이 변경됐나"를 JSON 으로 파악. 응답: { state, currentRun }. 인자: { path? }.', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
|
|
86
|
-
{ name: 'leerness_state_start', requiredTier: 'safe-write', description: '1.9.279 (UR-0031) — start_task. 새 작업 run 시작 (.leerness/runs/run-NNNN.json 생성). 인자: { path?, goal (required), agent?, model?, task? }. 응답: { started, run }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, goal: { type: 'string' }, agent: { type: 'string' }, model: { type: 'string' }, task: { type: 'string' } }, required: ['goal'] } },
|
|
87
|
-
{ name: 'leerness_state_record', requiredTier: 'safe-write', description: '1.9.279 (UR-0031) — record_file_change / record_decision. 진행 중 run 에 변경 파일/명령/테스트/결정을 누적. 인자: { path?, filesChanged? (콤마), filesRead?, commands?, tests?, errors?, decision? }. 응답: { recorded, run }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, filesChanged: { type: 'string' }, filesRead: { type: 'string' }, commands: { type: 'string' }, tests: { type: 'string' }, errors: { type: 'string' }, decision: { type: 'string' } } } },
|
|
88
|
-
{ name: 'leerness_state_verify', requiredTier: 'safe-write', description: '1.9.279 (UR-0031) — request_verification / verify_done. 현재 run 의 검증 결과 기록. 인자: { path?, result (required: "pass"|"fail"), note? }. 응답: { verified, result, run }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, result: { type: 'string', enum: ['pass', 'fail'] }, note: { type: 'string' } }, required: ['result'] } },
|
|
89
|
-
{ name: 'leerness_state_handoff', requiredTier: 'safe-write', description: '1.9.279 (UR-0031) — create_handoff / make_handoff. 현재 run 을 마감하고 다음 에이전트용 .leerness/handoff/latest.{md,json} 작성 (Claude→Goose→Codex 인수인계 표준). 인자: { path?, summary (required) }. 응답: { handoff, run }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, summary: { type: 'string' } }, required: ['summary'] } },
|
|
85
|
+
{ name: 'leerness_state_show', requiredTier: 'read-only', description: '1.9.279 (UR-0031) — get_project_context / get_current_task. .leerness/ 구조화 상태(현재 run + 누적)를 회수. 외부 에이전트가 작업 시작 전 "지금 무슨 작업이 진행 중인가 / 무엇을 읽고 무엇이 변경됐나"를 JSON 으로 파악. 응답: { state, currentRun }. 인자: { path? }.', inputSchema: { type: 'object', properties: { sessionKey: { type: 'string', description: '1.36.145 — 이 호출을 하는 세션의 주소. 다른 에이전트와 **같은 프로젝트**에서 일할 때 증거가 섞이지 않게 한다. 주지 않으면 서버 전체가 한 주소를 공유해 섞일 수 있다(응답의 keySource 로 확인).' }, path: { type: 'string' } } } },
|
|
86
|
+
{ name: 'leerness_state_start', requiredTier: 'safe-write', description: '1.9.279 (UR-0031) — start_task. 새 작업 run 시작 (.leerness/runs/run-NNNN.json 생성). 인자: { path?, goal (required), agent?, model?, task? }. 응답: { started, run }.', inputSchema: { type: 'object', properties: { sessionKey: { type: 'string', description: '1.36.145 — 이 호출을 하는 세션의 주소. 다른 에이전트와 **같은 프로젝트**에서 일할 때 증거가 섞이지 않게 한다. 주지 않으면 서버 전체가 한 주소를 공유해 섞일 수 있다(응답의 keySource 로 확인).' }, path: { type: 'string' }, goal: { type: 'string' }, agent: { type: 'string' }, model: { type: 'string' }, task: { type: 'string' } }, required: ['goal'] } },
|
|
87
|
+
{ name: 'leerness_state_record', requiredTier: 'safe-write', description: '1.9.279 (UR-0031) — record_file_change / record_decision. 진행 중 run 에 변경 파일/명령/테스트/결정을 누적. 인자: { path?, filesChanged? (콤마), filesRead?, commands?, tests?, errors?, decision? }. 응답: { recorded, run }.', inputSchema: { type: 'object', properties: { sessionKey: { type: 'string', description: '1.36.145 — 이 호출을 하는 세션의 주소. 다른 에이전트와 **같은 프로젝트**에서 일할 때 증거가 섞이지 않게 한다. 주지 않으면 서버 전체가 한 주소를 공유해 섞일 수 있다(응답의 keySource 로 확인).' }, path: { type: 'string' }, filesChanged: { type: 'string' }, filesRead: { type: 'string' }, commands: { type: 'string' }, tests: { type: 'string' }, errors: { type: 'string' }, decision: { type: 'string' } } } },
|
|
88
|
+
{ name: 'leerness_state_verify', requiredTier: 'safe-write', description: '1.9.279 (UR-0031) — request_verification / verify_done. 현재 run 의 검증 결과 기록. 인자: { path?, result (required: "pass"|"fail"), note? }. 응답: { verified, result, run }.', inputSchema: { type: 'object', properties: { sessionKey: { type: 'string', description: '1.36.145 — 이 호출을 하는 세션의 주소. 다른 에이전트와 **같은 프로젝트**에서 일할 때 증거가 섞이지 않게 한다. 주지 않으면 서버 전체가 한 주소를 공유해 섞일 수 있다(응답의 keySource 로 확인).' }, path: { type: 'string' }, result: { type: 'string', enum: ['pass', 'fail'] }, note: { type: 'string' } }, required: ['result'] } },
|
|
89
|
+
{ name: 'leerness_state_handoff', requiredTier: 'safe-write', description: '1.9.279 (UR-0031) — create_handoff / make_handoff. 현재 run 을 마감하고 다음 에이전트용 .leerness/handoff/latest.{md,json} 작성 (Claude→Goose→Codex 인수인계 표준). 인자: { path?, summary (required) }. 응답: { handoff, run }.', inputSchema: { type: 'object', properties: { sessionKey: { type: 'string', description: '1.36.145 — 이 호출을 하는 세션의 주소. 다른 에이전트와 **같은 프로젝트**에서 일할 때 증거가 섞이지 않게 한다. 주지 않으면 서버 전체가 한 주소를 공유해 섞일 수 있다(응답의 keySource 로 확인).' }, path: { type: 'string' }, summary: { type: 'string' } }, required: ['summary'] } },
|
|
90
90
|
{ name: 'leerness_get_project_context', requiredTier: 'read-only', description: '1.9.292 (UR-0031) — get_project_context. 외부 에이전트가 작업 시작 전 단 1콜로 "지금 무엇을 알아야 하는가"를 파악하는 집약 컨텍스트. 여러 소스(현재 작업/미답 사용자 요청/최근 결정/활성 룰/next-actions/memory surface/프로젝트 의도)를 한 번에 구조화 회수. read-only. 인자: { path? }. 응답: { version, project, currentTask, openRequests, recentDecisions, activeRules, nextActions, memory }.', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
|
|
91
91
|
{ name: 'leerness_about', requiredTier: 'read-only', description: '1.9.296 (UR-0030) — leerness 정체성/포지셔닝 회수. leerness 가 무엇인가: "AI 에이전트 운영 레이어"(실행기 아님) — 기억·정책·인수인계·검증·감사 5계층. read-only. 어떤 에이전트든 "이 도구가 무엇이고 무엇을 보완하는가"를 1콜로 파악. 응답: { identity, isNot, tagline, layers[], complements, entryPoints, surface }.', inputSchema: { type: 'object', properties: {} } },
|
|
92
92
|
{ name: 'leerness_honesty_check', requiredTier: 'read-only', description: '1.9.305 (사용자 명시) — AI 인식론적 정직성 점검. 주장/evidence 텍스트가 (1) 모르는 걸 아는 척(근거 없는 단정), (2) 검증 없는 섣부른 판단(추정+완료 결론), (3) 외부 정보 미수집(API/버전/스펙 언급+수집흔적 없음) 중 무엇에 해당하는지 휴리스틱 탐지. read-only. 에이전트가 자기 주장을 단언 전 self-check. 인자: { path?, taskId?, text? }. 응답: { ok, findings[], dimensions[], highCount }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, taskId: { type: 'string' }, text: { type: 'string' } } } },
|
package/lib/session-presence.js
CHANGED
|
@@ -50,6 +50,13 @@ const FORBIDDEN_SIGNALS = ['CODEX_COMPANION_SESSION_ID', 'CODEX_COMPANION_TRANSC
|
|
|
50
50
|
const KEY_SOURCES = ['LEERNESS_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID'];
|
|
51
51
|
function keySource(env) {
|
|
52
52
|
const e = env || {};
|
|
53
|
+
// 1.36.148 (재검수 P1, 재현): child 억제가 **명시 주소보다 앞**에 있어,
|
|
54
|
+
// MCP 서버가 `CLAUDE_CODE_CHILD_SESSION=1` 을 상속하면 호출별 `sessionKey` 가 통째로 버려졌다
|
|
55
|
+
// — 1.36.145 에서 넣은 주소 전달이 조용히 무력화되고 전역 슬롯 오귀속이 돌아온다.
|
|
56
|
+
// 이 파일의 주석은 이미 "명시값을 앞에 둔다(사용자가 일부러 준 것이 추론보다 우선)" 라고
|
|
57
|
+
// 적어 두었는데 **구현이 그 원칙과 반대**였다. 명시 주소를 먼저 본다.
|
|
58
|
+
// (추론 경로에는 child 억제를 그대로 둔다 — sub-agent 가 별도 세션으로 등록되는 것은 실측으로 기각됐다.)
|
|
59
|
+
if (isValidKey(e.LEERNESS_SESSION_ID)) return 'LEERNESS_SESSION_ID';
|
|
53
60
|
if (e.CLAUDE_CODE_CHILD_SESSION === '1') return null;
|
|
54
61
|
for (const k of KEY_SOURCES) if (isValidKey(e[k])) return k;
|
|
55
62
|
return null;
|
package/lib/team.js
CHANGED
|
@@ -144,6 +144,12 @@ function teamCmd(root, sub, id, opts = {}, deps = {}) {
|
|
|
144
144
|
log(` 실행: LEERNESS_TEAM_DEPLOY=1 leerness team deploy ${t.id} --yes (셸 호환성 점검 후 실행)`);
|
|
145
145
|
return;
|
|
146
146
|
}
|
|
147
|
+
// 1.36.148 (재검수 P1): `--yes --dry-run` 이 이중 게이트를 통과하면 dry-run 을 무시하고
|
|
148
|
+
// 배포 프로세스를 **실제로 실행**했다. 외부 명령 실행은 파일도 git 도 아니라 가드 밖이다 — 여기서 막는다.
|
|
149
|
+
if (has('--dry-run')) {
|
|
150
|
+
log(`# team deploy ${t.id} — (dry-run) 실행하지 않음: ${t.deployCommand}`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
147
153
|
if (gate.mode === 'gated') { fail(`${gate.message} — dry-run 으로 먼저 검토: leerness team deploy ${t.id}`); return; }
|
|
148
154
|
// execute: shell-guard 정적 점검(advisory) 후 spawn
|
|
149
155
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.148",
|
|
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
|
@@ -14761,6 +14761,422 @@ total++;
|
|
|
14761
14761
|
if (!ok) failed++;
|
|
14762
14762
|
}
|
|
14763
14763
|
|
|
14764
|
+
total++;
|
|
14765
|
+
// AI(1.36.145) — **MCP 표면에도 세션 주소가 있어야 한다.**
|
|
14766
|
+
// T-0116 을 CLI 에서만 고쳤다. MCP `state` 도구에는 주소 입력이 없어 한 서버의 모든 호출이
|
|
14767
|
+
// 같은(=무)주소로 접혀 전역 슬롯 오귀속이 그대로 남아 있었다 — 실측: A 의 증거가 B 의 run 에 붙고
|
|
14768
|
+
// 일하지 않은 B 가 `completion_claim_allowed: true` 를 받았다. 가드는 자기가 밟는 경로만 지킨다.
|
|
14769
|
+
{
|
|
14770
|
+
let ok = false; const bad = []; const dbg = {};
|
|
14771
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-145-'));
|
|
14772
|
+
const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
|
|
14773
|
+
for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
|
|
14774
|
+
'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
|
|
14775
|
+
'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG']) delete envP[k];
|
|
14776
|
+
const RP = (d, a, env) => cp.spawnSync(process.execPath, [CLI, ...a],
|
|
14777
|
+
{ cwd: d, encoding: 'utf8', timeout: 120000, env: env || envP, maxBuffer: 32 * 1024 * 1024 });
|
|
14778
|
+
const mk = (name) => {
|
|
14779
|
+
const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
|
|
14780
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
14781
|
+
const ri = RP(d, ['init', d, '--yes', '--minimal']);
|
|
14782
|
+
if (ri.status !== 0) bad.push(`${name}:init exit=${ri.status}`);
|
|
14783
|
+
return d;
|
|
14784
|
+
};
|
|
14785
|
+
const KA = 'session-alpha01', KB = 'session-bravo01';
|
|
14786
|
+
try {
|
|
14787
|
+
// ① MCP 로 두 세션이 일해도 증거가 **일한 세션**에 붙는다.
|
|
14788
|
+
{
|
|
14789
|
+
const d = mk('mcp');
|
|
14790
|
+
const call = (name, a, id) => JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: a } });
|
|
14791
|
+
const req = [
|
|
14792
|
+
JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '1' } } }),
|
|
14793
|
+
call('leerness_state_start', { path: d, goal: 'A', sessionKey: KA }, 1),
|
|
14794
|
+
call('leerness_state_start', { path: d, goal: 'B', sessionKey: KB }, 2),
|
|
14795
|
+
call('leerness_state_record', { path: d, filesChanged: 'a.js', tests: 'npm test: 10/10', sessionKey: KA }, 3),
|
|
14796
|
+
call('leerness_state_verify', { path: d, result: 'pass', sessionKey: KB }, 4),
|
|
14797
|
+
].join('\n') + '\n';
|
|
14798
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], { cwd: d, input: req, encoding: 'utf8', env: envP, timeout: 300000 });
|
|
14799
|
+
const lines = String(r.stdout || '').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
14800
|
+
const jsonOf = (id) => { try { return JSON.parse(lines.find((x) => x.id === id).result.content[0].text); } catch { return null; } };
|
|
14801
|
+
const s1 = jsonOf(1), s2 = jsonOf(2), rec = jsonOf(3), ver = jsonOf(4);
|
|
14802
|
+
if (!s1 || !s2 || s1.started === s2.started) bad.push('①MCP 두 세션이 같은 run 을 받음 — 판별 불가');
|
|
14803
|
+
else {
|
|
14804
|
+
if (!rec || rec.recorded !== s1.started) bad.push(`①MCP: A 의 증거가 A 의 run 이 아닌 곳에 붙음(${rec && rec.recorded})`);
|
|
14805
|
+
const allowed = ver && ver.completion_claim_allowed && ver.completion_claim_allowed.allowed;
|
|
14806
|
+
if (allowed) bad.push('①MCP: 일하지 않은 세션이 완료 허용을 받음 — 증거 게이트가 뒤집힘');
|
|
14807
|
+
}
|
|
14808
|
+
// 도구 스키마가 실제로 주소를 받는다고 **광고**해야 한다(안 그러면 클라이언트가 줄 이유가 없다).
|
|
14809
|
+
const T = require(path.resolve(__dirname, '..', 'lib', 'mcp-tools'));
|
|
14810
|
+
const st = T.filter((t) => /^leerness_state_/.test(t.name));
|
|
14811
|
+
const withKey = st.filter((t) => t.inputSchema && t.inputSchema.properties && t.inputSchema.properties.sessionKey);
|
|
14812
|
+
if (st.length === 0 || withKey.length !== st.length) bad.push(`①state 도구 ${st.length}종 중 ${withKey.length}종만 sessionKey 를 광고`);
|
|
14813
|
+
dbg.mcp = { A: s1 && s1.started, B: s2 && s2.started, recordedTo: rec && rec.recorded,
|
|
14814
|
+
bAllowed: !!(ver && ver.completion_claim_allowed && ver.completion_claim_allowed.allowed),
|
|
14815
|
+
schemaTools: st.length, schemaWithKey: withKey.length };
|
|
14816
|
+
}
|
|
14817
|
+
// ② 인수인계 산출물이 **덮이지 않는다** — run 별 사본이 남고 `latest` 는 계약대로 최신.
|
|
14818
|
+
{
|
|
14819
|
+
const d = mk('handoff');
|
|
14820
|
+
const eA = Object.assign({}, envP, { LEERNESS_SESSION_ID: KA });
|
|
14821
|
+
const eB = Object.assign({}, envP, { LEERNESS_SESSION_ID: KB });
|
|
14822
|
+
RP(d, ['state', 'start', 'A', '--json'], eA);
|
|
14823
|
+
RP(d, ['state', 'start', 'B', '--json'], eB);
|
|
14824
|
+
RP(d, ['state', 'handoff', 'A 의 인수인계', '--json'], eA);
|
|
14825
|
+
RP(d, ['state', 'handoff', 'B 의 인수인계', '--json'], eB);
|
|
14826
|
+
const hd = path.join(d, '.leerness', 'handoff');
|
|
14827
|
+
const readJ = (f) => { try { return JSON.parse(fs.readFileSync(path.join(hd, f), 'utf8')); } catch { return null; } };
|
|
14828
|
+
const a = readJ('run-0001.json'), latest = readJ('latest.json');
|
|
14829
|
+
if (!a || a.handoff_summary !== 'A 의 인수인계') bad.push('②앞 세션의 인수인계가 덮여 사라짐');
|
|
14830
|
+
if (!latest || latest.run_id !== 'run-0002') bad.push(`②latest 가 최신이 아님(${latest && latest.run_id})`);
|
|
14831
|
+
dbg.handoff = { files: (() => { try { return fs.readdirSync(hd).length; } catch { return 0; } })(),
|
|
14832
|
+
aKept: !!(a && a.handoff_summary === 'A 의 인수인계'), latestRun: latest && latest.run_id };
|
|
14833
|
+
}
|
|
14834
|
+
// ③ 대조군 — 주소를 **안 주면** 종전처럼 동작한다(주소 없는 클라이언트를 막지 않는다).
|
|
14835
|
+
{
|
|
14836
|
+
const d = mk('nokey');
|
|
14837
|
+
const call = (name, a, id) => JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: a } });
|
|
14838
|
+
const req = [
|
|
14839
|
+
JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '1' } } }),
|
|
14840
|
+
call('leerness_state_start', { path: d, goal: '단독' }, 1),
|
|
14841
|
+
call('leerness_state_record', { path: d, filesChanged: 'x.js' }, 2),
|
|
14842
|
+
].join('\n') + '\n';
|
|
14843
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], { cwd: d, input: req, encoding: 'utf8', env: envP, timeout: 300000 });
|
|
14844
|
+
const lines = String(r.stdout || '').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
14845
|
+
const jsonOf = (id) => { try { return JSON.parse(lines.find((x) => x.id === id).result.content[0].text); } catch { return null; } };
|
|
14846
|
+
const s = jsonOf(1), rec = jsonOf(2);
|
|
14847
|
+
if (!s || !rec || rec.recorded !== s.started) bad.push(`③주소 없는 단독 흐름이 깨짐(${rec && rec.recorded} vs ${s && s.started})`);
|
|
14848
|
+
dbg.noKey = { started: s && s.started, recorded: rec && rec.recorded };
|
|
14849
|
+
}
|
|
14850
|
+
ok = bad.length === 0;
|
|
14851
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
|
|
14852
|
+
finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
14853
|
+
console.log(ok ? `✓ AI(1.36.145) MCP 표면 증거 귀속: 도구가 세션 주소를 받아 **일한 세션**에 증거가 붙고 놀던 세션은 완료 허용을 못 받음(스키마 광고 포함) · 인수인계가 덮이지 않음(run 별 사본 + latest 최신) · 주소 없는 단독 클라이언트 무회귀 ${JSON.stringify(dbg)}`
|
|
14854
|
+
: '✗ 1.36.145 MCP 귀속 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
|
|
14855
|
+
if (!ok) failed++;
|
|
14856
|
+
}
|
|
14857
|
+
|
|
14858
|
+
total++;
|
|
14859
|
+
// AJ(1.36.146) — dry-run 가드가 **쓰기만** 보고 있었다.
|
|
14860
|
+
// 1.36.144 의 가드는 `writeUtf8`/`append`/`mkdirp` 세 함수만 봤다. io 를 우회하는 `fs.*` 변경 호출은
|
|
14861
|
+
// 정적으로 **330건**(unlinkSync 15 · rmSync 85 · writeFileSync 125 …) 이었고, 실측으로:
|
|
14862
|
+
// · `enforce remove --dry-run` 이 **진짜 pre-commit 훅을 삭제**했다
|
|
14863
|
+
// · `release cleanup --apply --dry-run` 이 **브랜치 60 → 10** 으로 지웠다
|
|
14864
|
+
// · 면제 정규식이 `cache` 를 경로 어디서나 매칭해 `…/cache/proj/` 프로젝트는 **가드가 통째로 꺼졌다**
|
|
14865
|
+
// · MCP 의 빈 `path` 가 서버 root 로 대체돼 엉뚱한 저장소에 썼다
|
|
14866
|
+
// 330곳을 배선하면 330개의 새 실수 자리가 생긴다 — 프로세스 진입점에서 한 번 막고, git 은 초크포인트에서 막는다.
|
|
14867
|
+
{
|
|
14868
|
+
let ok = false; const bad = []; const dbg = {};
|
|
14869
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-146-'));
|
|
14870
|
+
const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
|
|
14871
|
+
for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
|
|
14872
|
+
'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
|
|
14873
|
+
'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG', 'GIT_DIR']) delete envP[k];
|
|
14874
|
+
envP.GIT_CONFIG_GLOBAL = path.join(sb, 'gcg'); envP.GIT_CONFIG_SYSTEM = path.join(sb, 'gcs');
|
|
14875
|
+
try { fs.writeFileSync(envP.GIT_CONFIG_GLOBAL, ''); fs.writeFileSync(envP.GIT_CONFIG_SYSTEM, ''); } catch {}
|
|
14876
|
+
const G = (d, a) => cp.spawnSync('git', a, { cwd: d, encoding: 'utf8', timeout: 60000, env: envP });
|
|
14877
|
+
const RP = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a],
|
|
14878
|
+
{ cwd: d, encoding: 'utf8', timeout: 120000, env: envP, maxBuffer: 32 * 1024 * 1024 });
|
|
14879
|
+
const mk = (dir, branches) => {
|
|
14880
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
14881
|
+
G(dir, ['init', '-q', '-b', 'main', '.']); G(dir, ['config', 'user.email', 't@t']); G(dir, ['config', 'user.name', 't']);
|
|
14882
|
+
fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
14883
|
+
fs.writeFileSync(path.join(dir, 'a.txt'), 'a'); G(dir, ['add', '-A']); G(dir, ['commit', '-qm', 'i']);
|
|
14884
|
+
for (let i = 0; i < (branches || 0); i++) G(dir, ['branch', 'release/1.0.' + i]);
|
|
14885
|
+
RP(dir, ['init', dir, '--yes', '--minimal']); RP(dir, ['handoff', dir]);
|
|
14886
|
+
return dir;
|
|
14887
|
+
};
|
|
14888
|
+
try {
|
|
14889
|
+
// ① **삭제**도 막는다 — `enforce remove --dry-run` 이 진짜 훅을 지우지 않는다.
|
|
14890
|
+
{
|
|
14891
|
+
const d = mk(path.join(sb, 'enf'));
|
|
14892
|
+
RP(d, ['enforce', 'install', '--path', d, '--json', '--skip-verify']);
|
|
14893
|
+
const hook = path.join(d, '.git', 'hooks', 'pre-commit');
|
|
14894
|
+
const had = fs.existsSync(hook);
|
|
14895
|
+
const r = RP(d, ['enforce', 'remove', '--path', d, '--dry-run']);
|
|
14896
|
+
const still = fs.existsSync(hook);
|
|
14897
|
+
if (!had) bad.push('①설치가 안 돼 판별 조건이 서지 않음(공허)');
|
|
14898
|
+
else if (!still) bad.push('①--dry-run 인데 실제 훅을 삭제');
|
|
14899
|
+
else if (r.status === 0) bad.push('①삭제를 막았는데 성공이라 보고');
|
|
14900
|
+
// 대조군 — 플래그 없이는 실제로 제거돼야 한다.
|
|
14901
|
+
const r2 = RP(d, ['enforce', 'remove', '--path', d]);
|
|
14902
|
+
if (fs.existsSync(hook)) bad.push(`②플래그 없는 remove 가 훅을 못 지움(exit=${r2.status}) — 가드가 기능을 죽였다`);
|
|
14903
|
+
dbg.enforceRemove = { had, afterDry: still, dryExit: r.status, afterReal: fs.existsSync(hook) };
|
|
14904
|
+
}
|
|
14905
|
+
// ② **git 부작용**도 막는다 — `release cleanup --apply --dry-run` 이 브랜치를 지우지 않는다.
|
|
14906
|
+
{
|
|
14907
|
+
const d = mk(path.join(sb, 'rel'), 60);
|
|
14908
|
+
const cnt = () => (G(d, ['branch', '--list', 'release/*']).stdout || '').split('\n').filter((l) => l.trim()).length;
|
|
14909
|
+
const before = cnt();
|
|
14910
|
+
const r = RP(d, ['release', 'cleanup', '--apply', '--keep', '10', '--dry-run', '--path', d]);
|
|
14911
|
+
const afterDry = cnt();
|
|
14912
|
+
if (before < 50) bad.push(`②픽스처 브랜치 ${before}개 — 정리 임계값에 못 미쳐 공허`);
|
|
14913
|
+
else if (afterDry !== before) bad.push(`②--dry-run 인데 브랜치 ${before - afterDry}개 삭제`);
|
|
14914
|
+
// 대조군 — 플래그 없이는 실제로 정리돼야 한다.
|
|
14915
|
+
RP(d, ['release', 'cleanup', '--apply', '--keep', '10', '--path', d]);
|
|
14916
|
+
const afterReal = cnt();
|
|
14917
|
+
if (afterReal >= before) bad.push(`②플래그 없는 정리가 동작하지 않음(${before}→${afterReal}) — 가드가 기능을 죽였다`);
|
|
14918
|
+
dbg.releaseCleanup = { before, afterDry, afterReal, dryExit: r.status };
|
|
14919
|
+
}
|
|
14920
|
+
// ③ 면제 경계 — `cache` 가 **경로 어디에나** 있다고 가드가 꺼지면 안 된다.
|
|
14921
|
+
{
|
|
14922
|
+
const holder = path.join(sb, 'cache'); fs.mkdirSync(holder, { recursive: true });
|
|
14923
|
+
const d = path.join(holder, 'proj'); fs.mkdirSync(d, { recursive: true });
|
|
14924
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
14925
|
+
RP(d, ['init', d, '--yes', '--minimal']);
|
|
14926
|
+
const dj = path.join(d, '.harness', 'decisions.json');
|
|
14927
|
+
const before = (() => { try { return fs.readFileSync(dj, 'utf8'); } catch { return ''; } })();
|
|
14928
|
+
const r = RP(d, ['decision', 'add', 'CACHEPATH', '--reason', 'r', '--dry-run', '--path', d]);
|
|
14929
|
+
const after = (() => { try { return fs.readFileSync(dj, 'utf8'); } catch { return ''; } })();
|
|
14930
|
+
if (before !== after) bad.push('③cache/ 하위 프로젝트에서 가드가 통째로 꺼짐');
|
|
14931
|
+
if (r.status === 0) bad.push('③cache/ 하위인데 성공이라 보고');
|
|
14932
|
+
// 대조군 — 그 프로젝트도 플래그 없이는 정상 기록돼야 한다.
|
|
14933
|
+
RP(d, ['decision', 'add', 'CACHEPATH', '--reason', 'r', '--path', d]);
|
|
14934
|
+
const real = (() => { try { return fs.readFileSync(dj, 'utf8'); } catch { return ''; } })();
|
|
14935
|
+
if (!/CACHEPATH/.test(real)) bad.push('③cache/ 하위에서 정상 기록이 막힘 — 과차단');
|
|
14936
|
+
dbg.cacheDir = { dryChanged: before !== after, dryExit: r.status, realWrote: /CACHEPATH/.test(real) };
|
|
14937
|
+
}
|
|
14938
|
+
// ④ MCP 의 빈 `path` 가 서버 root 로 대체되지 않는다.
|
|
14939
|
+
{
|
|
14940
|
+
const server = mk(path.join(sb, 'mcpserver'));
|
|
14941
|
+
const dj = path.join(server, '.harness', 'decisions.json');
|
|
14942
|
+
const before = (() => { try { return fs.readFileSync(dj, 'utf8'); } catch { return ''; } })();
|
|
14943
|
+
const req = [
|
|
14944
|
+
JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '1' } } }),
|
|
14945
|
+
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'leerness_decision_add', arguments: { path: '', title: 'MCPSNEAK', reason: 'r' } } }),
|
|
14946
|
+
].join('\n') + '\n';
|
|
14947
|
+
cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], { cwd: server, input: req, encoding: 'utf8', env: envP, timeout: 180000 });
|
|
14948
|
+
const after = (() => { try { return fs.readFileSync(dj, 'utf8'); } catch { return ''; } })();
|
|
14949
|
+
if (/MCPSNEAK/.test(after) && !/MCPSNEAK/.test(before)) bad.push('④MCP 빈 path 가 서버 root 저장소에 씀');
|
|
14950
|
+
dbg.mcpEmptyPath = { wrote: /MCPSNEAK/.test(after) && !/MCPSNEAK/.test(before) };
|
|
14951
|
+
}
|
|
14952
|
+
// ⑤ **과차단 대조군** — 플래그 없는 평범한 흐름이 전부 그대로 돈다(fs 전면 패치의 최대 위험).
|
|
14953
|
+
{
|
|
14954
|
+
const d = path.join(sb, 'plain'); fs.mkdirSync(d, { recursive: true });
|
|
14955
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
14956
|
+
const exits = [
|
|
14957
|
+
RP(d, ['init', d, '--yes', '--minimal']).status,
|
|
14958
|
+
RP(d, ['handoff', d]).status,
|
|
14959
|
+
RP(d, ['task', 'add', '작업', '--path', d]).status,
|
|
14960
|
+
RP(d, ['decision', 'add', '결정', '--reason', 'r', '--path', d]).status,
|
|
14961
|
+
RP(d, ['audit', '--path', d]).status,
|
|
14962
|
+
];
|
|
14963
|
+
if (exits.some((x) => x !== 0)) bad.push(`⑤플래그 없는 정상 흐름이 막힘(exits=${JSON.stringify(exits)})`);
|
|
14964
|
+
// dry-run 을 **구현한** 명령은 여전히 성공해야 한다.
|
|
14965
|
+
const impl = [RP(d, ['migrate', 'audit', '--path', d, '--dry-run']).status,
|
|
14966
|
+
RP(d, ['init', d, '--yes', '--minimal', '--dry-run']).status];
|
|
14967
|
+
if (impl.some((x) => x !== 0)) bad.push(`⑤dry-run 구현 명령이 막힘(exits=${JSON.stringify(impl)})`);
|
|
14968
|
+
dbg.noRegression = { plain: exits, dryImplemented: impl };
|
|
14969
|
+
}
|
|
14970
|
+
ok = bad.length === 0;
|
|
14971
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
|
|
14972
|
+
finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
14973
|
+
console.log(ok ? `✓ AJ(1.36.146) dry-run 이 **삭제·git 부작용**까지 막음: enforce remove 가 훅을 안 지움 · release cleanup 이 브랜치를 안 지움(둘 다 플래그 없으면 동작) · cache/ 하위에서도 가드 유효 · MCP 빈 path 차단 · 평범한 흐름 무회귀 ${JSON.stringify(dbg)}`
|
|
14974
|
+
: '✗ 1.36.146 dry-run 전면가드 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
|
|
14975
|
+
if (!ok) failed++;
|
|
14976
|
+
}
|
|
14977
|
+
|
|
14978
|
+
total++;
|
|
14979
|
+
// AK(1.36.147) — 소유권 코드가 만든 세 결함. 전부 **내가 T-0116 을 고치며 넣은 것**이다.
|
|
14980
|
+
// ① 인수는 했는데 **소유를 저장하지 않아** 두 세션이 같은 legacy run 을 각자 인수했다
|
|
14981
|
+
// (실측: 한 run 에 A.js 와 B.js 가 함께 들어갔다 — T-0116 이 잡으려던 바로 그 증거 혼합).
|
|
14982
|
+
// ② 같은 세션 키로 두 번 `start` 하면 둘 다 성공하고 마지막만 소유해 **첫 run 이 증거 없이 고아**가 됐다.
|
|
14983
|
+
// ③ 남의 세션 키 가림이 최상위 필드에만 걸려 **중첩 `state.runsBySession`** 으로 원본이 그대로 샜다.
|
|
14984
|
+
{
|
|
14985
|
+
let ok = false; const bad = []; const dbg = {};
|
|
14986
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-147-'));
|
|
14987
|
+
const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
|
|
14988
|
+
for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
|
|
14989
|
+
'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
|
|
14990
|
+
'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG']) delete envP[k];
|
|
14991
|
+
const RP = (d, a, env) => cp.spawnSync(process.execPath, [CLI, ...a],
|
|
14992
|
+
{ cwd: d, encoding: 'utf8', timeout: 120000, env: env || envP, maxBuffer: 32 * 1024 * 1024 });
|
|
14993
|
+
const J = (r) => { try { return JSON.parse(String(r.stdout || '')); } catch { return null; } };
|
|
14994
|
+
const mk = (name) => {
|
|
14995
|
+
const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
|
|
14996
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
14997
|
+
const ri = RP(d, ['init', d, '--yes', '--minimal']);
|
|
14998
|
+
if (ri.status !== 0) bad.push(`${name}:init exit=${ri.status}`);
|
|
14999
|
+
return d;
|
|
15000
|
+
};
|
|
15001
|
+
const KA = 'session-alpha01', KB = 'session-bravo01';
|
|
15002
|
+
const eA = Object.assign({}, envP, { LEERNESS_SESSION_ID: KA });
|
|
15003
|
+
const eB = Object.assign({}, envP, { LEERNESS_SESSION_ID: KB });
|
|
15004
|
+
try {
|
|
15005
|
+
// ① 인수는 **이름을 달아** 둔다 — 두 번째 세션은 같은 run 을 인수하지 못한다.
|
|
15006
|
+
{
|
|
15007
|
+
const d = mk('legacy');
|
|
15008
|
+
RP(d, ['state', 'start', '업그레이드 전', '--json']); // 주소 없이 시작
|
|
15009
|
+
const stf = path.join(d, '.leerness', 'state.json');
|
|
15010
|
+
const st = JSON.parse(fs.readFileSync(stf, 'utf8'));
|
|
15011
|
+
delete st.runsBySession; // 예전 스키마 재현
|
|
15012
|
+
fs.writeFileSync(stf, JSON.stringify(st, null, 2) + '\n');
|
|
15013
|
+
const rA = J(RP(d, ['state', 'record', '--files-changed', 'A.js', '--json'], eA));
|
|
15014
|
+
const rB = RP(d, ['state', 'record', '--files-changed', 'B.js'], eB);
|
|
15015
|
+
let run = null; try { run = JSON.parse(fs.readFileSync(path.join(d, '.leerness', 'runs', 'run-0001.json'), 'utf8')); } catch {}
|
|
15016
|
+
// 대조군 겸 무회귀 — **첫 세션의 인수 자체는 성공해야** 한다(업그레이드 전 사용자가 멈추면 안 된다).
|
|
15017
|
+
if (!rA || rA.recorded !== 'run-0001') bad.push(`①업그레이드 전 run 을 인수하지 못함(${rA && rA.recorded})`);
|
|
15018
|
+
if (rB.status === 0) bad.push('①두 번째 세션이 남의 run 을 인수해 성공이라 보고');
|
|
15019
|
+
if (!run || run.files_changed.includes('B.js')) bad.push(`①한 run 에 두 세션의 증거가 섞임(${run && JSON.stringify(run.files_changed)})`);
|
|
15020
|
+
dbg.legacy = { aRecorded: rA && rA.recorded, bExit: rB.status, files: run && run.files_changed };
|
|
15021
|
+
}
|
|
15022
|
+
// ② 한 세션에 진행 중 run 은 하나 — 두 번째 start 는 거절하되 `--force` 로 넘어갈 수 있다.
|
|
15023
|
+
{
|
|
15024
|
+
const d = mk('samekey');
|
|
15025
|
+
const s1 = J(RP(d, ['state', 'start', '첫째', '--json'], eA));
|
|
15026
|
+
const s2 = RP(d, ['state', 'start', '둘째', '--json'], eA);
|
|
15027
|
+
const rec = J(RP(d, ['state', 'record', '--files-changed', 'x.js', '--json'], eA));
|
|
15028
|
+
if (!s1) bad.push('②첫 start 실패 — 판별 불가');
|
|
15029
|
+
if (s2.status === 0) bad.push('②같은 세션에 run 이 열려 있는데 두 번째 start 가 성공');
|
|
15030
|
+
if (!/진행 중인 run/.test(String(s2.stdout || '') + String(s2.stderr || ''))) bad.push('②거절 사유에 열린 run 을 말하지 않음');
|
|
15031
|
+
if (!rec || rec.recorded !== (s1 && s1.started)) bad.push(`②거절 뒤 기록이 첫 run 으로 가지 않음(${rec && rec.recorded})`);
|
|
15032
|
+
// 막다른 길을 만들지 않는다 — 명시하면 넘어갈 수 있어야 한다(크래시 후 재시작).
|
|
15033
|
+
const s3 = J(RP(d, ['state', 'start', '셋째', '--json', '--force'], eA));
|
|
15034
|
+
if (!s3 || !s3.started || s3.started === (s1 && s1.started)) bad.push('②--force 로도 새 run 을 시작하지 못함 — 막다른 길');
|
|
15035
|
+
dbg.sameKey = { first: s1 && s1.started, secondExit: s2.status, recordedTo: rec && rec.recorded, forced: s3 && s3.started };
|
|
15036
|
+
}
|
|
15037
|
+
// ③ 가림은 **내보내는 모든 자리**에 걸린다(최상위 · 중첩).
|
|
15038
|
+
{
|
|
15039
|
+
const d = mk('leak');
|
|
15040
|
+
const eS = Object.assign({}, envP, { LEERNESS_SESSION_ID: 'session-secret01' });
|
|
15041
|
+
const eV = Object.assign({}, envP, { LEERNESS_SESSION_ID: 'session-viewer01' });
|
|
15042
|
+
RP(d, ['state', 'start', 'A', '--json'], eS);
|
|
15043
|
+
RP(d, ['state', 'start', 'B', '--json'], eV);
|
|
15044
|
+
const show = J(RP(d, ['state', 'show', '--json'], eV));
|
|
15045
|
+
const top = Object.keys((show && show.runsBySession) || {});
|
|
15046
|
+
const nested = Object.keys((show && show.state && show.state.runsBySession) || {});
|
|
15047
|
+
if (top.includes('session-secret01')) bad.push('③최상위에서 남의 키 노출');
|
|
15048
|
+
if (nested.includes('session-secret01')) bad.push('③중첩 state.runsBySession 에서 남의 키 노출');
|
|
15049
|
+
if (!top.includes('session-viewer01') || !nested.includes('session-viewer01')) bad.push('③내 키를 못 보여줌 — 내 것을 확인할 수 없다');
|
|
15050
|
+
dbg.redaction = { top, nested };
|
|
15051
|
+
}
|
|
15052
|
+
ok = bad.length === 0;
|
|
15053
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
|
|
15054
|
+
finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
15055
|
+
console.log(ok ? `✓ AK(1.36.147) 소유권: 인수가 **이름을 남겨** 두 세션이 같은 run 에 증거를 못 섞음(첫 인수는 그대로 성공) · 한 세션에 열린 run 은 하나(사유 명시 + --force 탈출구) · 남의 세션 키 가림이 최상위·중첩 모두 ${JSON.stringify(dbg)}`
|
|
15056
|
+
: '✗ 1.36.147 소유권 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
|
|
15057
|
+
if (!ok) failed++;
|
|
15058
|
+
}
|
|
15059
|
+
|
|
15060
|
+
total++;
|
|
15061
|
+
// AL(1.36.148) — 앞 라운드 수정들이 **다른 조건에서 무력화**되던 자리들.
|
|
15062
|
+
// ① `keySource` 가 child 억제를 **명시 주소보다 먼저** 검사해, MCP 서버가
|
|
15063
|
+
// `CLAUDE_CODE_CHILD_SESSION=1` 을 상속하면 1.36.145 의 주소 전달이 통째로 죽었다.
|
|
15064
|
+
// 같은 파일 주석은 "명시값을 앞에 둔다" 라고 적혀 있었다 — 구현이 자기 원칙과 반대였다.
|
|
15065
|
+
// ② run id 를 "빈 문자열만 아니면" 으로 봐서 `../../evil` 이 소유 맵에 들어가면 `.leerness` 밖에 썼다.
|
|
15066
|
+
// ③ 중앙 `--path` 가드가 존재성만 봐서 **일반 파일**이 프로젝트 root 로 통과했다.
|
|
15067
|
+
// ④ dry-run 가드는 파일·git 만 봤다 — `team deploy --yes --dry-run` 이 배포 subprocess 를 실제 실행했다.
|
|
15068
|
+
{
|
|
15069
|
+
let ok = false; const bad = []; const dbg = {};
|
|
15070
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-148-'));
|
|
15071
|
+
const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
|
|
15072
|
+
for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
|
|
15073
|
+
'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
|
|
15074
|
+
'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG']) delete envP[k];
|
|
15075
|
+
const RP = (d, a, env) => cp.spawnSync(process.execPath, [CLI, ...a],
|
|
15076
|
+
{ cwd: d, encoding: 'utf8', timeout: 120000, env: env || envP, maxBuffer: 32 * 1024 * 1024 });
|
|
15077
|
+
const J = (r) => { try { return JSON.parse(String(r.stdout || '')); } catch { return null; } };
|
|
15078
|
+
const mk = (name) => {
|
|
15079
|
+
const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
|
|
15080
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
15081
|
+
const ri = RP(d, ['init', d, '--yes', '--minimal']);
|
|
15082
|
+
if (ri.status !== 0) bad.push(`${name}:init exit=${ri.status}`);
|
|
15083
|
+
return d;
|
|
15084
|
+
};
|
|
15085
|
+
try {
|
|
15086
|
+
// ① 명시 주소가 child 억제를 이긴다 — 다만 **추론 경로의 억제는 그대로**여야 한다.
|
|
15087
|
+
{
|
|
15088
|
+
const SP = require(path.resolve(__dirname, '..', 'lib', 'session-presence'));
|
|
15089
|
+
const plain = SP.deriveSessionKey({ LEERNESS_SESSION_ID: 'session-alpha01' });
|
|
15090
|
+
const child = SP.deriveSessionKey({ LEERNESS_SESSION_ID: 'session-alpha01', CLAUDE_CODE_CHILD_SESSION: '1' });
|
|
15091
|
+
const inferred = SP.deriveSessionKey({ CLAUDE_CODE_SESSION_ID: 'claude-sess-0001', CLAUDE_CODE_CHILD_SESSION: '1' });
|
|
15092
|
+
if (plain !== 'session-alpha01') bad.push('①명시 주소를 못 읽음 — 판별 불가');
|
|
15093
|
+
if (child !== 'session-alpha01') bad.push('①child 억제가 명시 주소를 삼킴');
|
|
15094
|
+
if (inferred !== null) bad.push('①추론 주소에서 child 억제가 사라짐 — sub-agent 가 별도 세션이 된다(회귀)');
|
|
15095
|
+
dbg.keySource = { plain, child, inferredUnderChild: inferred };
|
|
15096
|
+
}
|
|
15097
|
+
// ② **행위** — 자식 세션 env 를 상속한 MCP 서버에서도 증거가 안 섞인다.
|
|
15098
|
+
{
|
|
15099
|
+
const d = mk('mcpchild');
|
|
15100
|
+
const env = Object.assign({}, envP, { CLAUDE_CODE_CHILD_SESSION: '1' });
|
|
15101
|
+
const call = (name, a, id) => JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/call', params: { name, arguments: a } });
|
|
15102
|
+
const req = [
|
|
15103
|
+
JSON.stringify({ jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '1' } } }),
|
|
15104
|
+
call('leerness_state_start', { path: d, goal: 'A', sessionKey: 'session-alpha01' }, 1),
|
|
15105
|
+
call('leerness_state_start', { path: d, goal: 'B', sessionKey: 'session-bravo01' }, 2),
|
|
15106
|
+
call('leerness_state_record', { path: d, filesChanged: 'a.js', sessionKey: 'session-alpha01' }, 3),
|
|
15107
|
+
call('leerness_state_verify', { path: d, result: 'pass', sessionKey: 'session-bravo01' }, 4),
|
|
15108
|
+
].join('\n') + '\n';
|
|
15109
|
+
const r = cp.spawnSync(process.execPath, [CLI, 'mcp', 'serve'], { cwd: d, input: req, encoding: 'utf8', env, timeout: 300000 });
|
|
15110
|
+
const lines = String(r.stdout || '').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
15111
|
+
const jsonOf = (id) => { try { return JSON.parse(lines.find((x) => x.id === id).result.content[0].text); } catch { return null; } };
|
|
15112
|
+
const s1 = jsonOf(1), s2 = jsonOf(2), rec = jsonOf(3), ver = jsonOf(4);
|
|
15113
|
+
if (!s1 || !s2 || s1.started === s2.started) bad.push('②자식 env 에서 두 세션이 같은 run — 판별 불가');
|
|
15114
|
+
else {
|
|
15115
|
+
if (!rec || rec.recorded !== s1.started) bad.push(`②자식 env 에서 증거가 엉뚱한 run 에 붙음(${rec && rec.recorded})`);
|
|
15116
|
+
if (ver && ver.completion_claim_allowed && ver.completion_claim_allowed.allowed) bad.push('②자식 env 에서 놀던 세션이 완료 허용을 받음');
|
|
15117
|
+
}
|
|
15118
|
+
dbg.mcpChild = { A: s1 && s1.started, recordedTo: rec && rec.recorded,
|
|
15119
|
+
bAllowed: !!(ver && ver.completion_claim_allowed && ver.completion_claim_allowed.allowed) };
|
|
15120
|
+
}
|
|
15121
|
+
// ③ 경로형 run id 가 `.leerness` 밖으로 못 나간다.
|
|
15122
|
+
{
|
|
15123
|
+
const d = mk('traversal');
|
|
15124
|
+
const eA = Object.assign({}, envP, { LEERNESS_SESSION_ID: 'session-alpha01' });
|
|
15125
|
+
RP(d, ['state', 'start', 'A', '--json'], eA);
|
|
15126
|
+
const stf = path.join(d, '.leerness', 'state.json');
|
|
15127
|
+
const st = JSON.parse(fs.readFileSync(stf, 'utf8'));
|
|
15128
|
+
st.runsBySession = { 'session-alpha01': '../../evil' };
|
|
15129
|
+
fs.writeFileSync(stf, JSON.stringify(st, null, 2) + '\n');
|
|
15130
|
+
const r = RP(d, ['state', 'handoff', '요약'], eA);
|
|
15131
|
+
const escaped = fs.existsSync(path.join(sb, 'evil.json')) || fs.existsSync(path.join(d, 'evil.json'));
|
|
15132
|
+
if (escaped) bad.push('③경로형 run id 로 .leerness 밖에 씀');
|
|
15133
|
+
if (r.status === 0) bad.push('③손상된 소유 맵인데 성공이라 보고');
|
|
15134
|
+
dbg.traversal = { exit: r.status, escaped };
|
|
15135
|
+
}
|
|
15136
|
+
// ④ `--path` 에 일반 파일은 거절 — **디렉토리는 그대로**(대조군).
|
|
15137
|
+
{
|
|
15138
|
+
const d = mk('filepath');
|
|
15139
|
+
const f = path.join(sb, 'notadir.txt'); fs.writeFileSync(f, 'x');
|
|
15140
|
+
const rf = RP(d, ['status', '--path', f]);
|
|
15141
|
+
const rd = RP(d, ['status', '--path', d]);
|
|
15142
|
+
if (rf.status === 0) bad.push('④일반 파일을 프로젝트 root 로 승인');
|
|
15143
|
+
if (rd.status !== 0) bad.push(`④디렉토리를 거절(과차단, exit=${rd.status})`);
|
|
15144
|
+
dbg.filePath = { fileExit: rf.status, dirExit: rd.status };
|
|
15145
|
+
}
|
|
15146
|
+
// ⑤ dry-run 이 **외부 명령 실행**도 막는다(파일도 git 도 아닌 세 번째 부작용).
|
|
15147
|
+
{
|
|
15148
|
+
const d = mk('team');
|
|
15149
|
+
const marker = path.join(sb, 'DEPLOYED.txt');
|
|
15150
|
+
RP(d, ['team', 'add', 'zz', '--path', d]);
|
|
15151
|
+
const tf = path.join(d, '.harness', 'teams.json');
|
|
15152
|
+
let prepared = false;
|
|
15153
|
+
try {
|
|
15154
|
+
const t = JSON.parse(fs.readFileSync(tf, 'utf8'));
|
|
15155
|
+
const arr = Array.isArray(t) ? t : (t.teams || []);
|
|
15156
|
+
// ⚠ 마커 **절대경로를 JS 문자열 안에 박으면** Windows 역슬래시가 이스케이프를 깨뜨렸다
|
|
15157
|
+
// (제가 한 번 그것으로 거짓 실패를 만들었다). 경로는 **환경변수**로 넘긴다.
|
|
15158
|
+
if (arr[0]) { arr[0].deployCommand = `node -e "require('fs').writeFileSync(process.env.LEERNESS_E2E_MARK,'1')"`; prepared = true; }
|
|
15159
|
+
fs.writeFileSync(tf, JSON.stringify(t, null, 2) + '\n');
|
|
15160
|
+
} catch {}
|
|
15161
|
+
if (!prepared) bad.push('⑤team 픽스처를 만들지 못함 — 판별 불가');
|
|
15162
|
+
else {
|
|
15163
|
+
const env = Object.assign({}, envP, { LEERNESS_TEAM_DEPLOY: '1', LEERNESS_E2E_MARK: marker });
|
|
15164
|
+
RP(d, ['team', 'deploy', 'zz', '--yes', '--dry-run', '--path', d], env);
|
|
15165
|
+
if (fs.existsSync(marker)) bad.push('⑤--dry-run 인데 배포 명령을 실제 실행');
|
|
15166
|
+
// 대조군 — 플래그 없이 게이트를 다 통과하면 **실제로 실행돼야** 한다(가드가 기능을 죽이면 안 된다).
|
|
15167
|
+
RP(d, ['team', 'deploy', 'zz', '--yes', '--path', d], env);
|
|
15168
|
+
if (!fs.existsSync(marker)) bad.push('⑤플래그 없는 배포가 실행되지 않음 — 가드가 기능을 죽였다');
|
|
15169
|
+
}
|
|
15170
|
+
dbg.teamDeploy = { prepared, ranUnderDryRun: false, ranForReal: fs.existsSync(marker) };
|
|
15171
|
+
}
|
|
15172
|
+
ok = bad.length === 0;
|
|
15173
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
|
|
15174
|
+
finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
15175
|
+
console.log(ok ? `✓ AL(1.36.148) 명시 세션 주소가 child 억제를 이김(추론 억제는 유지) · 자식 env MCP 에서도 귀속 정상 · 경로형 run id 가 .leerness 밖으로 못 나감 · --path 에 파일 거절(디렉토리는 통과) · dry-run 이 **외부 명령 실행**까지 막음(플래그 없으면 실행) ${JSON.stringify(dbg)}`
|
|
15176
|
+
: '✗ 1.36.148 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
|
|
15177
|
+
if (!ok) failed++;
|
|
15178
|
+
}
|
|
15179
|
+
|
|
14764
15180
|
console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
|
|
14765
15181
|
if (failed > 0) process.exit(1);
|
|
14766
15182
|
|