leerness 1.36.144 → 1.36.146
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 +28 -8
- package/lib/git.js +32 -1
- package/lib/io.js +41 -3
- package/lib/mcp-tools.js +5 -5
- package/package.json +1 -1
- package/scripts/e2e.js +214 -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.146 하네스를 사용합니다. 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.146는 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.146 릴리스 태그 이력** (수백 라운드) · _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.146: 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.146';
|
|
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') 시 호스트 프로세스 오염.
|
|
@@ -23588,12 +23588,16 @@ function mcpServeCmd(root) {
|
|
|
23588
23588
|
// 예산 표(MCP_LONG_PATHS)와 _mcpTimeoutFor 는 모듈 스코프에 있다 — selftest 가 소스 grep 이 아니라
|
|
23589
23589
|
// **실제 호출**로 `budget > 내부한도` 를 단언할 수 있게 하기 위함(1.36.125).
|
|
23590
23590
|
const _shQuote = (s) => (/[\s"']/.test(String(s)) ? `"${String(s).replace(/"/g, '\\"')}"` : String(s));
|
|
23591
|
-
|
|
23591
|
+
// 1.36.145 (재검수 P1, 재현): T-0116 을 CLI 에서만 고쳤다 — MCP 도구에는 **세션 주소 입력이 없어**
|
|
23592
|
+
// 한 서버의 모든 호출이 같은(=무)주소로 접혀 전역 슬롯 오귀속이 그대로 남아 있었다.
|
|
23593
|
+
// 실측: A 의 증거가 B 의 run 에 붙고, 일하지 않은 B 가 `completion_claim_allowed: true` 를 받았다.
|
|
23594
|
+
// 자식 프로세스로 돌리므로 **env 로 주소를 넘길 수 있다** — 호출마다 다른 주소를 준다.
|
|
23595
|
+
function callLeerness(cliArgs, extraEnv) {
|
|
23592
23596
|
const budget = _mcpTimeoutFor(cliArgs);
|
|
23593
23597
|
const r = cp.spawnSync(process.execPath, [__filename, ...cliArgs], {
|
|
23594
23598
|
encoding: 'utf8',
|
|
23595
23599
|
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' }
|
|
23600
|
+
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
23601
|
});
|
|
23598
23602
|
const cmd = `leerness ${cliArgs.map(_shQuote).join(' ')}`;
|
|
23599
23603
|
const partial = ((r.stdout || '') + (r.stderr || '')).trim();
|
|
@@ -23672,6 +23676,13 @@ function mcpServeCmd(root) {
|
|
|
23672
23676
|
}
|
|
23673
23677
|
// 1.12.2 (14th 버그헌트, UR-0181): MCP 서버는 generic 설계 — cwd 에서 실행 후 각 호출의 path 로 대상 프로젝트 지정(의도된 기능, e2e 가 검증). 즉 path-타게팅 자체는 취약점 아님(신뢰된 로컬 MCP). policy 도 대상-프로젝트별로 path 에서 로드(올바름).
|
|
23674
23678
|
// 진짜 버그는 unknown tool 도 _bumpMcpUsage 가 임의 경로에 무조건 쓰던 것 → 사용 통계를 unknown-tool 검증 "후"로 이동. (프로젝트-scoped 가둠은 opt-in 플래그로 별도 검토 — UR backlog)
|
|
23679
|
+
// 1.36.146 (재검수 P1, 재현): 빈 `path` 가 falsy 라 **서버 root** 로 떨어져
|
|
23680
|
+
// CLI 의 `path_missing_value` 가드를 우회하고 엉뚜한 저장소에 썼다(실측: MCPSNEAK 기록됨).
|
|
23681
|
+
// 키를 준 이상 값은 문자열이어야 한다 — 아니면 그렇다고 말한다.
|
|
23682
|
+
if (args && Object.prototype.hasOwnProperty.call(args, 'path')
|
|
23683
|
+
&& (typeof args.path !== 'string' || !args.path.trim())) {
|
|
23684
|
+
return send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: 'path 에 값이 없습니다 — 아무것도 하지 않았습니다(서버 작업디렉토리로 대체하지 않음)' }], isError: true } });
|
|
23685
|
+
}
|
|
23675
23686
|
const targetPath = args.path || root;
|
|
23676
23687
|
let cliArgs;
|
|
23677
23688
|
try {
|
|
@@ -23689,7 +23700,9 @@ function mcpServeCmd(root) {
|
|
|
23689
23700
|
return send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `정책 차단(policy): ${pol.reason}` }], isError: true } });
|
|
23690
23701
|
}
|
|
23691
23702
|
} catch {}
|
|
23692
|
-
|
|
23703
|
+
// 세션 주소를 그대로 전달한다(문자열일 때만). 형식이 안 맞으면 CLI 가 무시하고 그 사실을 말한다.
|
|
23704
|
+
const _sk = (args && typeof args.sessionKey === 'string' && args.sessionKey.trim()) ? args.sessionKey.trim() : null;
|
|
23705
|
+
const r = callLeerness(cliArgs, _sk ? { LEERNESS_SESSION_ID: _sk } : null);
|
|
23693
23706
|
// 1.9.61: cursor 기반 페이지네이션 — 긴 출력은 cursor offset로 다음 청크
|
|
23694
23707
|
const fullText = r.stdout || r.stderr || '(no output)';
|
|
23695
23708
|
// 1.12.4 (15th 버그헌트 P2, UR-0016): _chunkSize 를 양의 정수로 클램프 — 음수/소수면 slice 빈 출력 + nextCursor 음수 → 무한 빈-루프(데이터 손실)였음.
|
|
@@ -25337,9 +25350,16 @@ function stateCmd(root, sub, ...args) {
|
|
|
25337
25350
|
const hdir = path.join(_leernessStateDir(root), 'handoff'); mkdirp(hdir);
|
|
25338
25351
|
// 1.9.443 (UR-0153): handoff evidence 에 completion_claim_allowed 포함 — 다음 에이전트/PR 이 증거 게이트를 직접 읽음.
|
|
25339
25352
|
const _ccaH = _completionClaimAllowed(rec);
|
|
25340
|
-
|
|
25341
|
-
|
|
25342
|
-
|
|
25353
|
+
const _payload = JSON.stringify({ ...rec, completion_claim_allowed: _ccaH }, null, 2) + '\n';
|
|
25354
|
+
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`;
|
|
25355
|
+
// 1.36.145 (재검수 P2): run 은 세션별인데 인수인계 산출물은 **전역 파일 하나**였다 —
|
|
25356
|
+
// 다른 세션이 뒤이어 마감하면 앞사람의 인수인계가 읽히기 전에 덮였다.
|
|
25357
|
+
// run 별 사본을 먼저 남긴다(잃을 것이 없다). `latest.*` 는 계약대로 "가장 최근" 을 유지하되
|
|
25358
|
+
// 누구의 것인지 알 수 있게 run_id 를 본문에 싣는다(이미 rec 에 있다).
|
|
25359
|
+
writeUtf8(path.join(hdir, `${rec.run_id}.json`), _payload);
|
|
25360
|
+
writeUtf8(path.join(hdir, `${rec.run_id}.md`), _md);
|
|
25361
|
+
writeUtf8(path.join(hdir, 'latest.json'), _payload);
|
|
25362
|
+
writeUtf8(path.join(hdir, 'latest.md'), _md);
|
|
25343
25363
|
_releaseRun(state, rec.run_id); // 내 소유만 놓는다(남의 run 은 건드리지 않는다)
|
|
25344
25364
|
_saveLeernessState(root, state);
|
|
25345
25365
|
if (json) { log(JSON.stringify({ handoff: rec.run_id, run: rec, completion_claim_allowed: _ccaH }, null, 2)); return; }
|
|
@@ -27882,7 +27902,7 @@ async function main() {
|
|
|
27882
27902
|
if (has('--help') || has('-h')) return help();
|
|
27883
27903
|
// 1.36.144 (T-0117, 전수 실측): `--dry-run` 을 받았으면 쓰기를 막는다 — 구현한 명령은 애초에 안 쓰므로
|
|
27884
27904
|
// 영향이 없고, 무시하던 명령은 첫 쓰기에서 멈춰 **아무것도 바꾸지 않은 채** 사유를 말한다.
|
|
27885
|
-
if (has('--dry-run')) setDryRunGuard(true);
|
|
27905
|
+
if (has('--dry-run')) { setDryRunGuard(true); try { require('../lib/git').setGitDryRun(true); } catch {} }
|
|
27886
27906
|
// 1.36.103: 실재하지 않는 플래그(오타)를 조용히 삼키지 않는다.
|
|
27887
27907
|
// 값-플래그 미등록 45종은 _VALUE_FLAGS 등록으로 닫았지만, 사용자 오타(--prio, --resaon)는 여전히
|
|
27888
27908
|
// 그 값이 본문에 붙는다(실측: `decision add "use redis" --resaon faster` → 저장된 제목 "use redis faster").
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.146",
|
|
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,220 @@ 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
|
+
|
|
14764
14978
|
console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
|
|
14765
14979
|
if (failed > 0) process.exit(1);
|
|
14766
14980
|
|