leerness 1.36.141 → 1.36.143

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 CHANGED
@@ -122,7 +122,7 @@ MIT
122
122
  <!-- leerness:project-readme:start -->
123
123
  ## Leerness Project Harness
124
124
 
125
- 이 프로젝트는 Leerness v1.36.141 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.143 하네스를 사용합니다. 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.141는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.143는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
180
180
 
181
181
  ```jsonc
182
182
  // 카테고리별
@@ -197,7 +197,7 @@ Leerness v1.36.141는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
197
197
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
198
198
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
199
199
 
200
- 현재 누적: **v1.9.x → 1.36.141 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.143 릴리스 태그 이력** (수백 라운드) · _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.141: 2026-08-20
238
+ Last synced by Leerness v1.36.143: 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.141';
37
+ const VERSION = '1.36.143';
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') 시 호스트 프로세스 오염.
@@ -7275,16 +7275,33 @@ function enforceCmd(root, sub) {
7275
7275
  // 1.36.140 (재검수 P1, 재현): 연결된 worktree 들은 `core.hooksPath` 설정이 없어도 **같은 훅 파일**을 쓴다 —
7276
7276
  // 한 worktree 에 설치하면 형제 worktree 의 커밋까지 차단된다. `hooksPathShared` 는 설정에 관한 사실이므로
7277
7277
  // 그 뜻을 늘리지 않고, **별도 사실**로 센다(모르면 주장하지 않는다).
7278
+ // ⚠ 1.36.143 (재검수 P1): 첫 판은 "worktree 범위 설정이면 형제는 안 막힌다" 고 **추정**해 `null` 을 냈다.
7279
+ // 그런데 worktree 범위 설정이 그냥 공용 `.git/hooks` 를 가리키면 형제는 **실제로 막힌다** — 사실을 숨긴 것이다.
7280
+ // 추정하지 말고 **각 worktree 에게 자기 훅 경로를 직접 묻는다.** 우리가 쓰는 파일과 같은 곳을 보는 수를 센다.
7278
7281
  const _worktreeCount = (() => {
7279
7282
  try {
7280
- // ⚠ `core.hooksPath` 가 **worktree 범위**면 그 worktree 전용 훅이라 형제는 안 막힌다 —
7281
- // 그때 "형제도 차단된다" 고 말하면 거짓 경고다(재검수 P2). 모르면 주장하지 않는다.
7282
- const wsc = _gitSpawn(['-C', root, 'config', '--show-scope', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: 5000 });
7283
- if (wsc.status === 0 && String(wsc.stdout || '').trim().split(/\s+/)[0] === 'worktree') return null;
7284
7283
  const wl = _gitSpawn(['-C', root, 'worktree', 'list', '--porcelain'], { encoding: 'utf8', timeout: 5000 });
7285
7284
  if (wl.status !== 0) return null;
7286
- const n = String(wl.stdout || '').split('\n').filter((l) => l.startsWith('worktree ')).length;
7287
- return n > 0 ? n : null;
7285
+ const dirs = String(wl.stdout || '').split('\n')
7286
+ .filter((l) => l.startsWith('worktree '))
7287
+ .map((l) => l.slice('worktree '.length).trim())
7288
+ .filter(Boolean);
7289
+ if (dirs.length <= 1) return dirs.length || null;
7290
+ const norm = (q) => {
7291
+ let r = q;
7292
+ try { r = fs.realpathSync.native ? fs.realpathSync.native(q) : fs.realpathSync(q); } catch {}
7293
+ r = path.resolve(r);
7294
+ return process.platform === 'win32' ? r.toLowerCase() : r;
7295
+ };
7296
+ const mine = norm(hookP);
7297
+ let sharing = 0;
7298
+ for (const wd of dirs) {
7299
+ const hp = _gitSpawn(['-C', wd, 'rev-parse', '--git-path', 'hooks/pre-commit'], { encoding: 'utf8', timeout: 5000 });
7300
+ if (hp.status !== 0 || !String(hp.stdout || '').trim()) return null; // 하나라도 모르면 주장하지 않는다
7301
+ const raw = String(hp.stdout).trim();
7302
+ if (norm(path.isAbsolute(raw) ? raw : path.join(wd, raw)) === mine) sharing++;
7303
+ }
7304
+ return sharing > 0 ? sharing : null;
7288
7305
  } catch { return null; }
7289
7306
  })();
7290
7307
  const chainedP = hookP + '.pre-leerness';
@@ -7351,7 +7368,9 @@ function enforceCmd(root, sub) {
7351
7368
  } catch { /* 제자리 쓰기가 막히면 아래 지우고-쓰기로 넘어간다 */ }
7352
7369
  }
7353
7370
  }
7354
- if (_lexists(p)) { try { fs.unlinkSync(p); } catch {} }
7371
+ // ⚠ 1.36.143 (재검수 P1): 삭제 실패를 삼키면 **우리 차단 훅이 살아있는데** 되돌렸다고 말하게 된다.
7372
+ // 되돌리기 실패는 밖으로 던져 `_rbFail` 에 잡힌다.
7373
+ if (_lexists(p)) fs.unlinkSync(p);
7355
7374
  if (snap.kind === 'none') return;
7356
7375
  if (snap.kind === 'link') { fs.symlinkSync(snap.target, p); return; }
7357
7376
  fs.writeFileSync(p, snap.buf);
@@ -7368,12 +7387,9 @@ function enforceCmd(root, sub) {
7368
7387
  };
7369
7388
  const _rbNote = () => (_rbFail.length ? ` ⚠ 되돌리지 못한 것: ${_rbFail.join(' · ')}` : '');
7370
7389
  try {
7371
- mkdirp(path.dirname(hookP));
7372
- // 기존 훅 보존: 우리 것이 아니면 체인 파일로 이동 후 호출
7373
- if (exists(hookP) && !read(hookP).includes(_ENFORCE_MARK)) {
7374
- fs.copyFileSync(hookP, chainedP);
7375
- try { fs.chmodSync(chainedP, 0o755); } catch {}
7376
- }
7390
+ // 1.36.143 (재검수 P1): 경로 검사를 **어떤 변경보다도 앞**으로 옮겼다.
7391
+ // 종전엔 체인 백업을 이미 덮어쓴 뒤에 `unsafe_path` 로 반환해 **백업이 파괴된 채로 남았다**.
7392
+ // 거절할 입력이면 아무것도 건드리기 전에 거절한다.
7377
7393
  // 훅은 자기완결 sh — 설치된 leerness 버전/네트워크/PATH 에 무의존(구버전 전역 CLI 가 enforce 를 몰라 오차단하던 실측 함정 회피).
7378
7394
  // 핵심 체크 = last-handoff.json 의 mtime 이 window 안인지 (find -mmin, git bash 표준).
7379
7395
  // 1.36.134: git 이 훅을 부를 때의 작업 디렉토리 = toplevel. harness 위치를 그 기준 상대경로로 고정한다.
@@ -7390,6 +7406,18 @@ function enforceCmd(root, sub) {
7390
7406
  try { _top = fs.realpathSync(_top); } catch {}
7391
7407
  let harnessRel = (path.relative(_top, _hDir) || '.harness').replace(/\\/g, '/');
7392
7408
  if (harnessRel.startsWith('..')) harnessRel = _hDir.replace(/\\/g, '/');
7409
+ // ⚠ 1.36.143 (재검수 P1): 이 거부가 **체인 백업 복사 뒤**에 있었다 — 거절하면서 백업을 파괴했다.
7410
+ // 계산은 읽기뿐이므로 여기까지 올려도 안전하고, 거절은 **아무것도 쓰기 전에** 끝난다.
7411
+ if (/[\r\n]/.test(harnessRel)) {
7412
+ failJson(json, 'unsafe_path', `경로에 개행이 있어 훅 스크립트에 넣을 수 없습니다: ${JSON.stringify(harnessRel)}`);
7413
+ return; // ← 아직 아무것도 쓰지 않았다
7414
+ }
7415
+ mkdirp(path.dirname(hookP));
7416
+ // 기존 훅 보존: 우리 것이 아니면 체인 파일로 이동 후 호출
7417
+ if (exists(hookP) && !read(hookP).includes(_ENFORCE_MARK)) {
7418
+ fs.copyFileSync(hookP, chainedP);
7419
+ try { fs.chmodSync(chainedP, 0o755); } catch {}
7420
+ }
7393
7421
  // 1.36.134 (검수 P1, 재현): 이 값을 훅 셸 스크립트에 그대로 박았다 — 디렉토리 이름이 `pkg$(touch PWNED)` 면
7394
7422
  // **설치만으로 임의 코드가 실행**됐다(실측: PWNED 생성). 값을 신뢰하지 않는다.
7395
7423
  // ① 셸에서 특별한 뜻을 갖는 문자가 하나라도 있으면 설치를 거부한다(조용히 이스케이프하고 넘어가지 않는다 —
@@ -7398,10 +7426,6 @@ function enforceCmd(root, sub) {
7398
7426
  // 둘 다 `unsafe_path` 로 막혔다. 한국어 환경에서 흔한 경로다. 거부가 아니라 **제대로 인용**하는 것이 답이다.
7399
7427
  // POSIX sh 의 작은따옴표 안에서는 `'` 를 제외한 모든 문자가 리터럴이다 — `'` 만 표준 관용구로 탈출시킨다.
7400
7428
  const shQuote = (v) => "'" + String(v).replace(/'/g, "'\''") + "'";
7401
- if (/[\r\n]/.test(harnessRel)) {
7402
- failJson(json, 'unsafe_path', `경로에 개행이 있어 훅 스크립트에 넣을 수 없습니다: ${JSON.stringify(harnessRel)}`);
7403
- return;
7404
- }
7405
7429
  const hook = [
7406
7430
  '#!/bin/sh',
7407
7431
  _ENFORCE_MARK + ' — leerness 사용 강제 (제거: leerness enforce remove)',
@@ -25165,6 +25189,39 @@ function stateCmd(root, sub, ...args) {
25165
25189
  }
25166
25190
  const json = has('--json');
25167
25191
  sub = sub || 'show';
25192
+ // 1.36.142 (T-0116, 재현): `currentRunId` 가 **전역 슬롯 하나**라 두 세션이 같은 프로젝트에서
25193
+ // 일하면 뒤사람이 앞사람의 슬롯을 덮어쓴다. 실측: A 의 증거(files_changed · tests)가
25194
+ // **B 의 run 에 붙고**, 일하지 않은 B 가 `completion_claim_allowed: true` 를 받았으며,
25195
+ // A 는 `state show` 로 자기 run 을 볼 수조차 없었다. 증거 게이트가 그대로 뒤집힌다.
25196
+ // 락으로 막힐 문제가 아니다(직렬로 돌려도 재현된다) — **주소가 없는 것**이 문제다.
25197
+ const _SPst = require('../lib/session-presence');
25198
+ const _sessKey = _SPst.deriveSessionKey(process.env);
25199
+ // ⚠ 명시한 주소가 형식에 안 맞으면 조용히 전역 슬롯으로 떨어진다 — 그게 바로 이 버그다.
25200
+ // 사용자는 주소가 있다고 믿는데 없는 상태가 되므로, 무시했다면 반드시 말한다.
25201
+ const _badKey = _SPst.invalidExplicitKey(process.env);
25202
+ if (_badKey && !json) warn(`⚠ LEERNESS_SESSION_ID 가 형식에 맞지 않아 무시됩니다("${_badKey}") — 이 세션은 주소 없이 전역 슬롯을 씨니다`);
25203
+ const _ownedBy = (st) => (st && st.runsBySession && typeof st.runsBySession === 'object' && !Array.isArray(st.runsBySession)) ? st.runsBySession : {};
25204
+ // 이 세션의 run 을 고른다. 없으면 **다른 세션이 소유하지 않은** 전역 슬롯만 인수한다
25205
+ // (업그레이드 전에 시작한 run · 세션 키가 없는 단일 에이전트는 종전처럼 동작한다).
25206
+ const _resolveRunId = (st) => {
25207
+ const owned = _ownedBy(st);
25208
+ if (_sessKey && owned[_sessKey]) return owned[_sessKey];
25209
+ const others = new Set(Object.entries(owned).filter(([k]) => k !== _sessKey).map(([, v]) => v));
25210
+ if (st.currentRunId && !others.has(st.currentRunId)) return st.currentRunId;
25211
+ return null;
25212
+ };
25213
+ const _claimRun = (st, runId) => {
25214
+ if (_sessKey) { st.runsBySession = Object.assign({}, _ownedBy(st)); st.runsBySession[_sessKey] = runId; }
25215
+ st.currentRunId = runId; // 기존 소비자 계약 유지(마지막 시작된 run)
25216
+ };
25217
+ const _releaseRun = (st, runId) => {
25218
+ if (_sessKey && _ownedBy(st)[_sessKey] === runId) {
25219
+ st.runsBySession = Object.assign({}, _ownedBy(st));
25220
+ delete st.runsBySession[_sessKey];
25221
+ }
25222
+ // ⚠ 전역 슬롯은 **내 run 일 때만** 비운다 — 남의 run 을 끊지 않는다.
25223
+ if (st.currentRunId === runId) st.currentRunId = null;
25224
+ };
25168
25225
 
25169
25226
  if (sub === 'start') {
25170
25227
  // 1.9.311 (UR-0047): state 는 .leerness substrate(standalone) — .harness init 가드 미적용 (회귀 방지)
@@ -25179,9 +25236,9 @@ function stateCmd(root, sub, ...args) {
25179
25236
  model_name: arg('--model', null)
25180
25237
  });
25181
25238
  _saveRun(root, rec);
25182
- state.currentRunId = run_id;
25239
+ _claimRun(state, run_id);
25183
25240
  _saveLeernessState(root, state);
25184
- if (json) { log(JSON.stringify({ started: run_id, run: rec }, null, 2)); return; }
25241
+ if (json) { log(JSON.stringify({ started: run_id, run: rec, sessionKey: _sessKey || null, keySource: _SPst.keySource(process.env) || null }, null, 2)); return; }
25185
25242
  ok(`run 시작: ${run_id}${rec.agent_name ? ' · agent=' + rec.agent_name : ''}${rec.model_name ? ' · model=' + rec.model_name : ''}`);
25186
25243
  if (goal) log(` goal: ${goal}`);
25187
25244
  log(` → 기록: leerness state record --files-changed "a,b" --commands "npm test" --decision "..."`);
@@ -25189,8 +25246,14 @@ function stateCmd(root, sub, ...args) {
25189
25246
  }
25190
25247
 
25191
25248
  const state = _loadLeernessState(root);
25192
- const curId = state.currentRunId;
25249
+ const curId = _resolveRunId(state);
25193
25250
  if ((sub === 'record' || sub === 'verify' || sub === 'handoff') && !curId) {
25251
+ // 다른 세션이 쓰는 run 이 있으면 그 사실을 말한다 — 조용히 남의 run 에 쓰지 않는다.
25252
+ const _others = Object.entries(_ownedBy(state)).filter(([k]) => k !== _sessKey);
25253
+ if (_others.length) {
25254
+ return fail(`이 세션의 run 이 없습니다 — 다른 세션이 진행 중입니다(${_others.map(([k, v]) => `${String(k).slice(0, 8)}…:${v}`).join(', ')}). `
25255
+ + `남의 run 에 증거를 붙이지 않습니다 — 먼저 leerness state start "<goal>"`);
25256
+ }
25194
25257
  return fail('진행 중 run 없음 — 먼저 leerness state start "<goal>"');
25195
25258
  }
25196
25259
 
@@ -25236,7 +25299,7 @@ function stateCmd(root, sub, ...args) {
25236
25299
  writeUtf8(path.join(hdir, 'latest.json'), JSON.stringify({ ...rec, completion_claim_allowed: _ccaH }, null, 2) + '\n');
25237
25300
  writeUtf8(path.join(hdir, 'latest.md'),
25238
25301
  `# Handoff — ${rec.run_id}\n\n- task: ${rec.task_id || '-'}\n- agent: ${rec.agent_name || '-'} · model: ${rec.model_name || '-'}\n- goal: ${rec.goal || '-'}\n- files_changed: ${rec.files_changed.join(', ') || '-'}\n- tests_run: ${rec.tests_run.join(', ') || '-'}\n- verification: ${rec.verification_result || '-'}\n- completion_claim_allowed: ${_ccaH.allowed ? 'yes' : 'no (' + _ccaH.reasons.join(', ') + ')'}\n\n## Summary\n${rec.handoff_summary}\n`);
25239
- state.currentRunId = null; // 다음 에이전트가 새 run 시작
25302
+ _releaseRun(state, rec.run_id); // 내 소유만 놓는다(남의 run 은 건드리지 않는다)
25240
25303
  _saveLeernessState(root, state);
25241
25304
  if (json) { log(JSON.stringify({ handoff: rec.run_id, run: rec, completion_claim_allowed: _ccaH }, null, 2)); return; }
25242
25305
  ok(`handoff 작성: ${rec.run_id} → .leerness/handoff/latest.{md,json}`);
@@ -25246,7 +25309,14 @@ function stateCmd(root, sub, ...args) {
25246
25309
  // default: show
25247
25310
  const rec = curId ? _loadRun(root, curId) : null;
25248
25311
  // 1.9.443 (UR-0153): evidence-first 완료 게이트 파생 노출.
25249
- if (json) { log(JSON.stringify({ state, currentRun: rec, completion_claim_allowed: rec ? _completionClaimAllowed(rec) : null }, null, 2)); return; }
25312
+ if (json) {
25313
+ log(JSON.stringify({
25314
+ state, currentRun: rec, completion_claim_allowed: rec ? _completionClaimAllowed(rec) : null,
25315
+ sessionKey: _sessKey || null, keySource: _SPst.keySource(process.env) || null,
25316
+ runsBySession: _ownedBy(state), resolvedRunId: curId,
25317
+ }, null, 2));
25318
+ return;
25319
+ }
25250
25320
  log(`# leerness state (1.9.278, UR-0032) — .leerness/ 구조화 상태`);
25251
25321
  log(`project: ${state.project} · runs 누적: ${state.runCounter || 0} · 현재 run: ${curId || '(없음)'}`);
25252
25322
  if (rec) {
@@ -27854,6 +27924,26 @@ async function main() {
27854
27924
  }
27855
27925
  }
27856
27926
  }
27927
+ // 1.36.143 (전수 스윕): 위 가드는 **이름 목록**(_strict)이라 소비자를 못 따라갔다 — 검수는 3건을 지목했지만
27928
+ // 모든 명령에 없는 `--path` 를 주고 실측하니 **33건**이 그 경로를 만들었다(`.harness` 25 · `.leerness` 7 · 기타).
27929
+ // 그중 다수는 **성공이라 말하면서** 만든다(`provider add` · `team add` · `skill learn` · `glossary` · `wakeup-interval`).
27930
+ // `roles` · `decision drop` · `lesson drop` 을 개별 등록한 게 세 번째였다 — 네 번째를 만들지 않으려면 이름이 아니라
27931
+ // **성질**로 막아야 한다: 사용자가 `--path` 로 **명시한** 경로는 존재해야 한다. 부재가 정상 입력인 명령만 뺀다.
27932
+ // (위치인자 경로 해석은 명령마다 계약이 달라 건드리지 않는다 — 명시 플래그만 다룬다.)
27933
+ {
27934
+ const _PATH_MAY_BE_ABSENT = new Set(['init', 'migrate', 'migrate-workspace-dir', 'update', 'auto-update', 'adapter', 'ci']);
27935
+ const _p = arg('--path', null);
27936
+ if (_p && !_PATH_MAY_BE_ABSENT.has(cmd)) {
27937
+ const _abs = absRoot(_p);
27938
+ if (!exists(_abs)) {
27939
+ const _en = _uiLang(process.cwd()) === 'en';
27940
+ failJson(has('--json'), 'path_not_found', _en
27941
+ ? `path not found: ${_abs} — no directory was created`
27942
+ : `경로 없음: ${_abs} — 디렉토리를 새로 만들지 않았습니다`);
27943
+ return;
27944
+ }
27945
+ }
27946
+ }
27857
27947
  // 1.13.1 (15th 블라인드 리뷰 P1, codex gpt-5.5): 무명령 + 옵션만(예: leerness --json) 은 묵시적 init 쓰기를 하지 않음 — cwd 에 .harness 가 의도치 않게 생성되던 부작용 차단. 명시 명령 없이 옵션만이면 help (bare leerness 온보딩 init 은 유지).
27858
27948
  if (!args[0] && process.argv.slice(2).some(a => a.startsWith('-'))) { help(); return; }
27859
27949
  // 1.9.38 (B): 사용 통계 카운터 — usage stats 명령 자체와 비차단 경로는 제외
package/lib/git.js CHANGED
@@ -30,7 +30,11 @@ const cp = require('child_process');
30
30
  // `not-a-repo` 라고 정직하게 답한다.
31
31
  const GIT_LOCATION_ENV = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR',
32
32
  'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_NAMESPACE',
33
- 'GIT_CONFIG', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_PARAMETERS'];
33
+ 'GIT_CONFIG', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_PARAMETERS',
34
+ // 1.36.143 (재검수 P1): `GIT_CONFIG_NOSYSTEM=1` 은 값을 주지 않고 **시스템 설정을 감춘다** —
35
+ // 시스템 `core.hooksPath` 가 있는 환경에서 우리만 그것을 못 보고 `.git/hooks` 에 설치하면,
36
+ // 사용자의 실제 커밋은 시스템 훅을 써서 강제가 **그냥 우회**된다. 같은 클래스다.
37
+ 'GIT_CONFIG_NOSYSTEM'];
34
38
  const _DROP = new Set(GIT_LOCATION_ENV);
35
39
  // `GIT_CONFIG_KEY_0`, `GIT_CONFIG_VALUE_0`, … 은 개수가 정해져 있지 않으므로 접두로 판별한다.
36
40
  const _DROP_PREFIX = /^GIT_CONFIG_(KEY|VALUE)_\d+$/;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.141",
3
+ "version": "1.36.143",
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
@@ -14288,21 +14288,30 @@ total++;
14288
14288
  if (restored.length !== 1) bad.push(`③복원이 ${restored.length}건을 만듦(기대 1)`);
14289
14289
  dbg.dupDrop = { exits: codes, archiveBlocks: blocks, restoredCount: restored.length };
14290
14290
  }
14291
- // ④ 없는 경로의 **쓰기**는 거절하고 디렉토리를 만들지 않는다 —
14292
- // 그러나 **조회는 그대로 돌아야 한다**. 첫 판은 검사를 명령 진입부에 둬서
14293
- // 없는 경로에서도 잘 돌던 `roles catalog`(정적) · `roles list`(읽기) 를 exit 1 로 만들었다(회귀).
14294
- // 한쪽만 재면 과차단으로 되돌아가도 통과한다 — 양쪽을 같이 잰다.
14291
+ // ④ 없는 경로는 **아무것도 만들지 않고 거절**한다 — 그러나 **동작하던 사용법은 깨지 않는다**.
14292
+ // ⚠ 이 축은 한 번 뒤집혔다. 처음엔 "없는 경로에서도 `roles catalog`/`list` 는 exit 0 이어야 한다" 고 적었다 —
14293
+ // 1.36.139 가 그랬기 때문이다. 그런데 1.36.143 이 `--path` 규칙을 **모든 명령에 일관되게** 적용하자
14294
+ // 이 단언이 게이트를 깨뜨렸다. 다시 재보니 옛 동작이 지키던 것은 **오타를 조용히 성공시키는 것**이었다:
14295
+ // `--path` 없음 → 동작(경로 무관 사용) · `--path <실재>` → 동작 · `--path <오타>` → exit 0 (조용한 성공)
14296
+ // 마지막 칸만 거절로 바뀌었고 그게 맞다. 그래서 축을 "오타에도 성공" 이 아니라
14297
+ // **"실제로 쓰던 두 사용법이 살아 있다"** 로 다시 쓴다 — 과차단 보호는 그대로 유지된다.
14295
14298
  {
14296
14299
  const ghost = path.join(sb, 'ghost-parent', 'nope');
14297
14300
  const w1 = RP(sb, ['roles', 'unset', 'coder', '--path', ghost]);
14298
14301
  const w2 = RP(sb, ['roles', 'set', 'coder', '--provider', 'codex', '--force', '--path', ghost]);
14299
14302
  const created = fs.existsSync(ghost);
14300
- const rd = ['catalog', 'list'].map((s) => RP(sb, ['roles', s, '--path', ghost]).status);
14303
+ const r1 = RP(sb, ['roles', 'catalog', '--path', ghost]);
14304
+ // 과차단 대조군 — 경로를 안 주는 사용과 실재 경로를 주는 사용은 **둘 다 살아 있어야 한다**.
14305
+ const real = mk('roles-real');
14306
+ const noPath = RP(real, ['roles', 'catalog']).status;
14307
+ const realPath = ['catalog', 'list'].map((s) => RP(real, ['roles', s, '--path', real]).status);
14301
14308
  if (w1.status === 0) bad.push('④없는 경로인데 unset 이 성공이라 보고');
14302
14309
  if (w2.status === 0) bad.push('④없는 경로인데 set 이 성공이라 보고');
14303
14310
  if (created) bad.push('④실패하면서 없는 경로를 만들었다');
14304
- if (rd.some((s) => s !== 0)) bad.push(`④조회 하위명령까지 막았다(catalog/list exit=${JSON.stringify(rd)}) — 과차단 회귀`);
14305
- dbg.ghostPath = { unset: w1.status, set: w2.status, created, readExits: rd };
14311
+ if (r1.status === 0) bad.push('④오타 난 --path 를 조용히 성공시킴');
14312
+ if (noPath !== 0) bad.push(`④--path 없는 사용을 막았다(exit=${noPath}) — 과차단 회귀`);
14313
+ if (realPath.some((s) => s !== 0)) bad.push(`④실재 경로 조회를 막았다(exit=${JSON.stringify(realPath)}) — 과차단 회귀`);
14314
+ dbg.ghostPath = { unset: w1.status, set: w2.status, created, ghostRead: r1.status, noPath, realPath };
14306
14315
  }
14307
14316
  // ⑤ 한 번짜리 설정 주입(`GIT_CONFIG_COUNT`)이 **남의 저장소**에 훅을 쓰지 못한다 + 지목한 저장소에는 쓴다.
14308
14317
  {
@@ -14348,6 +14357,249 @@ total++;
14348
14357
  if (!ok) failed++;
14349
14358
  }
14350
14359
 
14360
+ total++;
14361
+ // AF(1.36.142/T-0116) — **증거는 일한 세션에 붙어야 한다.**
14362
+ // `.leerness/state.json` 의 `currentRunId` 는 전역 슬롯 하나였다. 두 세션이 같은 프로젝트에서 일하면
14363
+ // 뒤에 `state start` 한 쪽이 앞사람 슬롯을 덮어썼고, 실측으로:
14364
+ // · A 의 증거(files_changed · tests)가 **B 의 run 에 붙었고**
14365
+ // · 일하지 않은 B 가 `completion_claim_allowed: true` 를 받았고
14366
+ // · A 는 `state show` 로 **자기 run 을 볼 수조차 없었다**.
14367
+ // 락 문제가 아니다(직렬로 돌려도 재현된다) — 주소가 없는 것이 문제다.
14368
+ {
14369
+ let ok = false; const bad = []; const dbg = {};
14370
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-142-'));
14371
+ const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
14372
+ for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
14373
+ 'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
14374
+ 'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG']) delete envP[k];
14375
+ const R = (d, env, a) => cp.spawnSync(process.execPath, [CLI, ...a],
14376
+ { cwd: d, encoding: 'utf8', timeout: 300000, env, maxBuffer: 32 * 1024 * 1024 });
14377
+ const J = (r) => { try { return JSON.parse(String(r.stdout || '')); } catch { return null; } };
14378
+ const mk = (name) => {
14379
+ const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
14380
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
14381
+ const ri = R(d, envP, ['init', d, '--yes', '--minimal']);
14382
+ if (ri.status !== 0) bad.push(`${name}:init exit=${ri.status}`);
14383
+ return d;
14384
+ };
14385
+ // ⚠ 세션 키는 제품의 유효성 규칙을 통과해야 한다 — 짧은 이름은 `deriveSessionKey` 가 null 을 준다.
14386
+ // (한 번 그렇게 재서 "제품이 아니라 픽스처" 를 측정했다. 여기서 먼저 확인하고 시작한다.)
14387
+ const SP = require(path.resolve(__dirname, '..', 'lib', 'session-presence'));
14388
+ const KA = 'session-alpha01', KB = 'session-bravo01';
14389
+ if (!SP.deriveSessionKey({ LEERNESS_SESSION_ID: KA }) || !SP.deriveSessionKey({ LEERNESS_SESSION_ID: KB })) {
14390
+ bad.push('픽스처 세션 키가 무효 — 이 블록은 아무것도 재지 못한다');
14391
+ }
14392
+ const eA = Object.assign({}, envP, { LEERNESS_SESSION_ID: KA });
14393
+ const eB = Object.assign({}, envP, { LEERNESS_SESSION_ID: KB });
14394
+ try {
14395
+ // ① 증거 귀속 + 완료 게이트 — A 가 일하고 B 는 놀았다.
14396
+ {
14397
+ const d = mk('attrib');
14398
+ const sA = J(R(d, eA, ['state', 'start', 'A 목표', '--agent', 'agentA', '--json']));
14399
+ const sB = J(R(d, eB, ['state', 'start', 'B 목표', '--agent', 'agentB', '--json']));
14400
+ const rA = J(R(d, eA, ['state', 'record', '--files-changed', 'a.js', '--tests', 'npm test: 10/10', '--json']));
14401
+ const vB = J(R(d, eB, ['state', 'verify', '--result', 'pass', '--json']));
14402
+ if (!sA || !sB || sA.started === sB.started) bad.push('①두 세션이 같은 run 을 받았다 — 판별 불가');
14403
+ if (!rA || rA.recorded !== (sA && sA.started)) bad.push(`①A 의 증거가 A 의 run 이 아닌 곳에 붙음(${rA && rA.recorded})`);
14404
+ const cca = vB && vB.completion_claim_allowed && vB.completion_claim_allowed.allowed;
14405
+ if (cca) bad.push('①일하지 않은 세션이 완료 허용을 받았다 — 증거 게이트가 뒤집힘');
14406
+ const showA = J(R(d, eA, ['state', 'show', '--json']));
14407
+ if (!showA || showA.resolvedRunId !== (sA && sA.started)) bad.push(`①A 가 자기 run 을 못 본다(${showA && showA.resolvedRunId})`);
14408
+ dbg.attrib = { A: sA && sA.started, B: sB && sB.started, recordedTo: rA && rA.recorded, bAllowed: !!cca, aSees: showA && showA.resolvedRunId };
14409
+ }
14410
+ // ② 남의 run 에 조용히 쓰지 않고 **사유를 말한다**(증거는 그대로 남는다).
14411
+ {
14412
+ const d = mk('refuse');
14413
+ R(d, eA, ['state', 'start', 'A 목표', '--json']);
14414
+ const rB = R(d, eB, ['state', 'record', '--files-changed', 'stolen.js']);
14415
+ const msg = String(rB.stdout || '') + String(rB.stderr || '');
14416
+ let runA = null; try { runA = JSON.parse(fs.readFileSync(path.join(d, '.leerness', 'runs', 'run-0001.json'), 'utf8')); } catch {}
14417
+ if (rB.status === 0) bad.push('②남의 run 인데 성공이라 보고');
14418
+ if (!/다른 세션이 진행 중/.test(msg)) bad.push('②거절 사유를 말하지 않음');
14419
+ if (!runA || runA.files_changed.length !== 0) bad.push('②거절했는데 남의 증거가 오염됨');
14420
+ dbg.refuse = { exit: rB.status, saysWhy: /다른 세션이 진행 중/.test(msg), aFiles: runA && runA.files_changed.length };
14421
+ }
14422
+ // ③ 남의 handoff 가 내 run 을 끊지 않는다.
14423
+ {
14424
+ const d = mk('release');
14425
+ R(d, eA, ['state', 'start', 'A', '--json']);
14426
+ R(d, eB, ['state', 'start', 'B', '--json']);
14427
+ R(d, eB, ['state', 'handoff', 'B 마감', '--json']);
14428
+ const aShow = J(R(d, eA, ['state', 'show', '--json']));
14429
+ const aRec = J(R(d, eA, ['state', 'record', '--files-changed', 'a2.js', '--json']));
14430
+ if (!aShow || aShow.resolvedRunId !== 'run-0001') bad.push(`③남의 handoff 뒤 내 run 이 사라짐(${aShow && aShow.resolvedRunId})`);
14431
+ if (!aRec || aRec.recorded !== 'run-0001') bad.push(`③남의 handoff 뒤 기록 불가(${aRec && aRec.recorded})`);
14432
+ dbg.release = { aResolved: aShow && aShow.resolvedRunId, aRecorded: aRec && aRec.recorded };
14433
+ }
14434
+ // ④ **대조군 — 기존 단일 사용자(세션 키 없음)는 종전 그대로.**
14435
+ // 한쪽만 재면 "주소 없는 사람을 막아 버리는" 회귀가 통과한다.
14436
+ {
14437
+ const d = mk('single');
14438
+ const st = J(R(d, envP, ['state', 'start', '목표', '--json']));
14439
+ const rc = J(R(d, envP, ['state', 'record', '--files-changed', 'x.js', '--tests', 'npm test: 3/3', '--json']));
14440
+ const vf = J(R(d, envP, ['state', 'verify', '--result', 'pass', '--json']));
14441
+ const ho = J(R(d, envP, ['state', 'handoff', '요약', '--json']));
14442
+ const after = J(R(d, envP, ['state', 'show', '--json']));
14443
+ const chain = [rc && rc.recorded, vf && vf.verified, ho && ho.handoff];
14444
+ if (!st || chain.some((x) => x !== st.started)) bad.push(`④세션키 없는 단일 흐름이 깨짐(${JSON.stringify(chain)})`);
14445
+ if (!after || after.resolvedRunId !== null) bad.push(`④handoff 뒤에도 run 이 남음(${after && after.resolvedRunId})`);
14446
+ if (!vf || !vf.completion_claim_allowed || vf.completion_claim_allowed.allowed !== true) {
14447
+ bad.push('④일한 단일 사용자가 완료 허용을 못 받음 — 게이트가 과차단');
14448
+ }
14449
+ dbg.single = { started: st && st.started, chain, after: after && after.resolvedRunId,
14450
+ allowed: vf && vf.completion_claim_allowed && vf.completion_claim_allowed.allowed };
14451
+ }
14452
+ // ⑤ 업그레이드 이전 상태(전역 슬롯만)를 **인수**한다 — 쓰던 사람이 멈추지 않는다.
14453
+ {
14454
+ const d = mk('migrate');
14455
+ R(d, envP, ['state', 'start', '업그레이드 전', '--json']);
14456
+ const stFile = path.join(d, '.leerness', 'state.json');
14457
+ const before = JSON.parse(fs.readFileSync(stFile, 'utf8'));
14458
+ delete before.runsBySession;
14459
+ fs.writeFileSync(stFile, JSON.stringify(before, null, 2) + '\n');
14460
+ const rc = J(R(d, eA, ['state', 'record', '--files-changed', 'y.js', '--json']));
14461
+ if (!rc || rc.recorded !== before.currentRunId) bad.push(`⑤예전 run 을 인수하지 못함(${rc && rc.recorded} vs ${before.currentRunId})`);
14462
+ dbg.migrate = { old: before.currentRunId, recorded: rc && rc.recorded };
14463
+ }
14464
+ // ⑥ 무효한 명시 주소를 **조용히 무시하지 않는다**(무시하면 전역 슬롯으로 떨어져 이 버그가 되살아난다).
14465
+ {
14466
+ const d = mk('badkey');
14467
+ const eBad = Object.assign({}, envP, { LEERNESS_SESSION_ID: 'x' });
14468
+ const r = R(d, eBad, ['state', 'start', '목표']);
14469
+ const msg = String(r.stdout || '') + String(r.stderr || '');
14470
+ if (SP.deriveSessionKey({ LEERNESS_SESSION_ID: 'x' })) bad.push('⑥픽스처 키가 유효해져 이 축이 공허해졌다');
14471
+ else if (!/형식에 맞지 않아 무시/.test(msg)) bad.push('⑥무효한 세션 주소를 조용히 무시');
14472
+ dbg.badKey = { warned: /형식에 맞지 않아 무시/.test(msg) };
14473
+ }
14474
+ ok = bad.length === 0;
14475
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
14476
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
14477
+ console.log(ok ? `✓ AF(1.36.142/T-0116) 증거 귀속: 두 세션이 같은 프로젝트에서 일해도 증거가 **일한 세션**에 붙고 놀던 세션은 완료 허용을 못 받음 · 남의 run 거절+사유 · 남의 handoff 가 내 run 을 안 끊음 · 세션키 없는 기존 흐름 무회귀 · 예전 상태 인수 · 무효 주소 고지 ${JSON.stringify(dbg)}`
14478
+ : '✗ 1.36.142 증거 귀속 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
14479
+ if (!ok) failed++;
14480
+ }
14481
+
14482
+ total++;
14483
+ // AG(1.36.143) — **없는 `--path` 는 아무것도 만들지 않는다** + 공유 판정은 추정하지 않는다.
14484
+ // 검수는 `provider add` · `team add` · `creds register` 3건을 지목했다. 이름을 더 넣는 대신
14485
+ // 모든 명령에 없는 `--path` 를 주고 전수로 재니 **33건**이 그 경로를 만들었다 —
14486
+ // 다수는 **성공이라 말하면서**(`provider add` · `team add` · `skill learn` · `glossary` · `wakeup-interval`),
14487
+ // 그중엔 직전 라운드에 내가 넣은 `state start` 도 있었다. 개별 등록은 이미 세 번째였다.
14488
+ // 그래서 이름 목록이 아니라 **성질**로 막고, 여기서도 이름이 아니라 **열거**로 잰다.
14489
+ {
14490
+ let ok = false; const bad = []; const dbg = {};
14491
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-143-'));
14492
+ const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
14493
+ for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
14494
+ 'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
14495
+ 'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG',
14496
+ 'GIT_DIR', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_NOSYSTEM']) delete envP[k];
14497
+ envP.GIT_CONFIG_GLOBAL = path.join(sb, 'gitconfig-global');
14498
+ envP.GIT_CONFIG_SYSTEM = path.join(sb, 'gitconfig-system');
14499
+ try { fs.writeFileSync(envP.GIT_CONFIG_GLOBAL, ''); fs.writeFileSync(envP.GIT_CONFIG_SYSTEM, ''); } catch {}
14500
+ const G = (d, a) => cp.spawnSync('git', a, { cwd: d, encoding: 'utf8', timeout: 60000, env: envP });
14501
+ const RP = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a],
14502
+ { cwd: d, encoding: 'utf8', timeout: 60000, env: envP, maxBuffer: 32 * 1024 * 1024 });
14503
+ const J = (r) => { try { return JSON.parse(String(r.stdout || '')); } catch { return null; } };
14504
+ const cwd = path.join(sb, 'cwd'); fs.mkdirSync(cwd, { recursive: true });
14505
+ try {
14506
+ // ① 전수 — 제품 자신에게 명령 목록을 묻고, 하위명령까지 펴서 없는 `--path` 로 부른다.
14507
+ {
14508
+ let usages = [];
14509
+ const lr = RP(cwd, ['commands', '--json']);
14510
+ try {
14511
+ const collect = (v) => {
14512
+ if (Array.isArray(v)) for (const x of v) collect(x);
14513
+ else if (v && typeof v === 'object') { if (typeof v.cmd === 'string') usages.push(v.cmd); else if (typeof v.name === 'string') usages.push(v.name); else for (const k of Object.keys(v)) collect(v[k]); }
14514
+ else if (typeof v === 'string') usages.push(v);
14515
+ };
14516
+ collect(JSON.parse(lr.stdout));
14517
+ } catch (e) { bad.push('①명령 목록을 얻지 못함: ' + String(e.message).slice(0, 60)); }
14518
+ // 부재가 정상 입력인 명령(경로를 만드는 것이 그 일)은 대상이 아니다.
14519
+ const CREATORS = /^(init|migrate|migrate-workspace-dir|update|auto-update|adapter|ci)\b/;
14520
+ const invocations = []; const seen = new Set();
14521
+ for (const raw of Array.from(new Set(usages))) {
14522
+ const u = String(raw).replace(/^leerness\s+/, '').trim();
14523
+ if (!u || CREATORS.test(u)) continue;
14524
+ const toks = u.split(/\s+/); const head = toks[0];
14525
+ if (!/^[a-z][a-z0-9-]*$/.test(head)) continue;
14526
+ const second = toks[1] || '';
14527
+ let subs = [null];
14528
+ if (second && second.includes('|') && !/^[[<]/.test(second)) subs = second.split('|').map((x) => x.replace(/[[\]<>"]/g, '')).filter((x) => /^[a-z][a-z0-9-]*$/.test(x));
14529
+ else if (second && /^[a-z][a-z0-9-]*$/.test(second)) subs = [second];
14530
+ for (const sub of subs) {
14531
+ for (const extra of [[], ['zzprobe']]) {
14532
+ const argv = sub ? [head, sub, ...extra] : [head, ...extra];
14533
+ const label = argv.join(' ');
14534
+ if (!seen.has(label)) { seen.add(label); invocations.push({ label, argv }); }
14535
+ }
14536
+ }
14537
+ }
14538
+ // 열거가 붕괴하면 "0건" 은 아무 뜻이 없다 — 크기를 함께 단언한다.
14539
+ if (invocations.length < 100) bad.push(`①열거가 ${invocations.length}건 — 계측 붕괴(0건 통과가 무의미해진다)`);
14540
+ const leaks = [];
14541
+ for (let i = 0; i < invocations.length; i++) {
14542
+ const ghost = path.join(sb, 'g' + i, 'nope');
14543
+ const r = RP(cwd, [...invocations[i].argv, '--path', ghost]);
14544
+ if (fs.existsSync(ghost)) leaks.push(`${invocations[i].label}(exit=${r.status})`);
14545
+ }
14546
+ if (leaks.length) bad.push(`①없는 경로를 만든 호출 ${leaks.length}건: ${leaks.slice(0, 6).join(' · ')}`);
14547
+ dbg.ghostSweep = { invocations: invocations.length, leaks: leaks.length };
14548
+ }
14549
+ // ② 대조군 — 존재하는 프로젝트에서는 그대로 동작해야 한다(과차단으로 통과하면 안 된다).
14550
+ {
14551
+ const d = path.join(sb, 'real'); fs.mkdirSync(d, { recursive: true });
14552
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
14553
+ RP(d, ['init', d, '--yes', '--minimal']);
14554
+ const exits = ['handoff', 'status', 'audit'].map((c) => RP(d, [c, '--path', d]).status);
14555
+ if (exits.some((x) => x !== 0)) bad.push(`②정상 프로젝트에서 조회가 막힘(exits=${JSON.stringify(exits)})`);
14556
+ dbg.realProject = { exits };
14557
+ }
14558
+ // ③ worktree 형제 차단을 **추정하지 않고 각 worktree 에게 묻는다**.
14559
+ {
14560
+ const mkRepo = (name) => {
14561
+ const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
14562
+ G(d, ['init', '-q', '-b', 'main', '.']); G(d, ['config', 'user.email', 't@t']); G(d, ['config', 'user.name', 't']);
14563
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
14564
+ fs.writeFileSync(path.join(d, 'a.txt'), 'a'); G(d, ['add', '-A']); G(d, ['commit', '-qm', 'i']);
14565
+ RP(d, ['init', d, '--yes', '--minimal']); RP(d, ['handoff', d]);
14566
+ return d;
14567
+ };
14568
+ const m1 = mkRepo('wt-shared');
14569
+ const add1 = G(m1, ['worktree', 'add', '-qb', 'lk', path.join(sb, 'wt-shared-linked')]);
14570
+ const j1 = J(RP(m1, ['enforce', 'install', '--path', m1, '--json', '--skip-verify']));
14571
+ const m2 = mkRepo('wt-own');
14572
+ G(m2, ['config', 'extensions.worktreeConfig', 'true']);
14573
+ const add2 = G(m2, ['worktree', 'add', '-qb', 'lk2', path.join(sb, 'wt-own-linked')]);
14574
+ fs.mkdirSync(path.join(m2, '.git', 'wt-hooks'), { recursive: true });
14575
+ const cfg = G(m2, ['config', '--worktree', 'core.hooksPath', '.git/wt-hooks']);
14576
+ const j2 = J(RP(m2, ['enforce', 'status', '--path', m2, '--json']));
14577
+ if (add1.status !== 0 || add2.status !== 0 || cfg.status !== 0) bad.push('③worktree 픽스처를 만들지 못함 — 판별 불가');
14578
+ else {
14579
+ if (!j1 || j1.worktrees !== 2) bad.push(`③공용 훅인데 형제 차단을 말하지 않음(worktrees=${j1 && j1.worktrees})`);
14580
+ if (!j2 || j2.worktrees !== 1) bad.push(`③worktree 전용 훅인데 형제도 막힌다고 함(worktrees=${j2 && j2.worktrees})`);
14581
+ }
14582
+ dbg.worktree = { shared: j1 && j1.worktrees, perWorktree: j2 && j2.worktrees };
14583
+ }
14584
+ // ④ 세척 클래스의 **경계** — 지워야 할 것과 남겨야 할 것을 같이 잰다(한쪽만 재면 과세척이 통과한다).
14585
+ {
14586
+ const g = require(path.resolve(__dirname, '..', 'lib', 'git.js'));
14587
+ const mustDrop = ['GIT_DIR', 'git_dir', 'GIT_CONFIG_NOSYSTEM', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_7', 'GIT_CONFIG_VALUE_7'];
14588
+ const mustKeep = ['GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CEILING_DIRECTORIES', 'GIT_AUTHOR_NAME', 'GIT_SSH_COMMAND', 'GIT_EDITOR'];
14589
+ const droppedNot = mustDrop.filter((k) => !g._shouldDrop(k));
14590
+ const keptNot = mustKeep.filter((k) => g._shouldDrop(k));
14591
+ if (droppedNot.length) bad.push(`④세척해야 하는데 안 함: ${droppedNot.join(', ')}`);
14592
+ if (keptNot.length) bad.push(`④남겨야 하는데 세척함(과세척): ${keptNot.join(', ')}`);
14593
+ dbg.scrub = { droppedNot, keptNot, total: g.GIT_LOCATION_ENV.length };
14594
+ }
14595
+ ok = bad.length === 0;
14596
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
14597
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
14598
+ console.log(ok ? `✓ AG(1.36.143) 없는 --path 는 아무것도 만들지 않음(전수 열거, 고치기 전 33건) · 정상 프로젝트 무회귀 · worktree 형제 차단을 추정 대신 **각 worktree 에게 물어** 판정 · 세척 클래스 경계(지울 것/남길 것 양방향) ${JSON.stringify(dbg)}`
14599
+ : '✗ 1.36.143 경로/공유 판정 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
14600
+ if (!ok) failed++;
14601
+ }
14602
+
14351
14603
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
14352
14604
  if (failed > 0) process.exit(1);
14353
14605