leerness 1.36.148 → 1.36.161

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/scripts/e2e.js CHANGED
@@ -10,6 +10,14 @@ const cp = require('child_process');
10
10
  // 1.36.87: 일부 e2e 타임아웃을 상향했다 — 격리 실측(agents list 5.2s · init --language 8.5s) 대비
11
11
  // 여유가 3배 미만이라 전체 스위트 부하에서 간헐 초과했다. 검사 대상은 속도가 아니라 동작이다.
12
12
  process.env.LEERNESS_OFFLINE = process.env.LEERNESS_OFFLINE || '1';
13
+ // The historical end-to-end fixtures assert the Korean default surface. Keep auto
14
+ // detection deterministic on English CI hosts without LEERNESS_LANG, which would
15
+ // override per-project English manifests and contaminate in-process doctor checks.
16
+ // Language-specific cases below pass their own LANG/LC_ALL values explicitly.
17
+ process.env.LC_ALL = '';
18
+ process.env.LC_CTYPE = '';
19
+ process.env.LANG = '';
20
+ process.env.LANGUAGE = 'ko';
13
21
  // 1.9.284 (UR-0029): e2e 속도 — 기본 roadmap.html(70KB HTML) 자동 생성 OFF (roadmap 전용 테스트 블록만 일시 ON).
14
22
  // 대부분의 init/session 테스트는 roadmap 을 검증하지 않으므로 생성 비용 제거 → 5분 내 완료.
15
23
  process.env.LEERNESS_NO_AUTO_ROADMAP = '1';
@@ -1688,7 +1696,7 @@ total++;
1688
1696
  fs.writeFileSync(shPath, body, 'utf8');
1689
1697
  }
1690
1698
  // handoff 호출 → reminder 자동 생성
1691
- cp.spawnSync(process.execPath, [CLI, 'handoff', tmpC, '--compact'], { encoding: 'utf8', timeout: 10000 });
1699
+ cp.spawnSync(process.execPath, [CLI, 'handoff', tmpC, '--compact', '--writeback'], { encoding: 'utf8', timeout: 10000 });
1692
1700
  const remPath = path.join(tmpC, '.harness', 'agent-reminders.md');
1693
1701
  const ok = fs.existsSync(remPath) && /drift critical/.test(fs.readFileSync(remPath, 'utf8'));
1694
1702
  console.log(ok ? '✓ B(1.9.38) drift critical → agent-reminders.md 자동 생성' : `✗ reminder 파일 실패`);
@@ -2473,6 +2481,17 @@ total++;
2473
2481
  if (!(okPatch && okMinor && okMajor)) failed++;
2474
2482
  }
2475
2483
 
2484
+ // T-0122: --patch는 경고 없이 수용하고, leerness 패키지의 package/bin/README 버전 표면을 함께 갱신한다.
2485
+ total++;
2486
+ {
2487
+ const probe = cp.spawnSync(process.execPath, [path.join(__dirname, 'release-bump-probe.js')], {
2488
+ cwd: path.resolve(__dirname, '..'), encoding: 'utf8', timeout: 30000
2489
+ });
2490
+ const ok = probe.status === 0 && /release-bump probe passed/.test(probe.stdout || '') && !/unknown flag|알 수 없는 플래그/i.test(probe.stderr || '');
2491
+ console.log(ok ? '✓ B(T-0122) release bump: --patch + package/bin/README 동기화' : `✗ B(T-0122) release bump 동기화 실패 (exit=${probe.status})`);
2492
+ if (!ok) { failed++; process.stdout.write(probe.stdout || ''); process.stderr.write(probe.stderr || ''); }
2493
+ }
2494
+
2476
2495
  // 1.9.8: release note → CHANGELOG.md 자동 갱신
2477
2496
  total++;
2478
2497
  {
@@ -2953,7 +2972,10 @@ total++;
2953
2972
  cp.spawnSync(process.execPath, [CLI, 'init', aDir, '--minimal', '--no-env', '--yes'], { encoding: 'utf8', timeout: 30000 });
2954
2973
  // adapter cursor: .cursor + .mcp.json 생성, .claude 미생성(minimal이라 commands는 있으나 cursor adapter는 안 건드림)
2955
2974
  const rCur = cp.spawnSync(process.execPath, [CLI, 'adapter', 'cursor', aDir], { encoding: 'utf8', timeout: 15000 });
2956
- const cursorOk = rCur.status === 0 && fs.existsSync(path.join(aDir, '.cursor', 'rules', 'leerness.mdc'));
2975
+ const cursorOk = rCur.status === 0
2976
+ && fs.existsSync(path.join(aDir, '.cursor', 'rules', 'leerness.mdc'))
2977
+ && fs.existsSync(path.join(aDir, '.cursor', 'hooks.json'))
2978
+ && fs.existsSync(path.join(aDir, '.cursor', 'hooks', 'leerness-session.cjs'));
2957
2979
  let mcpOk = false;
2958
2980
  try { const m = JSON.parse(fs.readFileSync(path.join(aDir, '.mcp.json'), 'utf8')); mcpOk = m.mcpServers && m.mcpServers.leerness && m.mcpServers.leerness.args.join(' ') === '-y leerness mcp serve'; } catch {}
2959
2981
  // adapter list --json: 9종
@@ -2965,7 +2987,7 @@ total++;
2965
2987
  const rDry = cp.spawnSync(process.execPath, [CLI, 'adapter', 'claude', '--dry-run', '--path', dDir], { encoding: 'utf8', timeout: 15000 });
2966
2988
  const dryOk = /\[dry-run\]/.test(rDry.stdout) && fs.readdirSync(dDir).length === 0;
2967
2989
  const ok = cursorOk && mcpOk && listOk && dryOk;
2968
- console.log(ok ? '✓ B(1.9.280) adapter: cursor(.cursor+.mcp.json)/list 9종/dry-run(0파일)' : `✗ adapter 실패 (cursor=${cursorOk} mcp=${mcpOk} list=${listOk} dry=${dryOk})`);
2990
+ console.log(ok ? '✓ B(1.9.280) adapter: cursor(.cursor rules+MCP+session hook)/list 9종/dry-run(0파일)' : `✗ adapter 실패 (cursor=${cursorOk} mcp=${mcpOk} list=${listOk} dry=${dryOk})`);
2969
2991
  if (!ok) { failed++; console.log((rCur.stdout || '').slice(0, 200)); }
2970
2992
  }
2971
2993
 
@@ -6724,12 +6746,15 @@ total++;
6724
6746
  cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { encoding: 'utf8', timeout: 30000 });
6725
6747
  const ci = cp.spawnSync(process.execPath, [CLI, 'ci', 'init', d], { encoding: 'utf8', timeout: 15000 });
6726
6748
  const wf = fs.readFileSync(path.join(d, '.github', 'workflows', 'leerness-gate.yml'), 'utf8');
6727
- // 버전 핀(설치 버전 == package.json) · 미핀 latest 부재 · 최소권한 · concurrency 취소 · gate 호출
6749
+ // 버전 핀(설치 버전 == package.json) · 미핀 latest 부재 · 최소권한 · concurrency 취소 · Node 24 액션/런타임 · gate 호출
6728
6750
  const pinned = new RegExp('run: npx -y leerness@' + ver.replace(/\./g, '\\.') + ' gate \\.').test(wf);
6729
6751
  const noUnpinned = !/npx -y leerness gate \./.test(wf);
6730
6752
  const perms = /permissions:\n\s*contents: read/.test(wf);
6731
6753
  const conc = /concurrency:\n\s*group: leerness-gate-\$\{\{ github\.ref \}\}\n\s*cancel-in-progress: true/.test(wf);
6732
- const stillGate = /leerness-gate/.test(wf) && /pull_request:/.test(wf) && /actions\/checkout@v4/.test(wf);
6754
+ const stillGate = /leerness-gate/.test(wf) && /pull_request:/.test(wf)
6755
+ && /actions\/checkout@v7/.test(wf) && /actions\/setup-node@v7/.test(wf)
6756
+ && /node-version:\s*'24'/.test(wf) && /package-manager-cache:\s*false/.test(wf)
6757
+ && !/actions\/(?:checkout|setup-node)@v4/.test(wf);
6733
6758
  fs.rmSync(d, { recursive: true, force: true });
6734
6759
  ok = ci.status === 0 && pinned && noUnpinned && perms && conc && stillGate;
6735
6760
  } catch {}
@@ -7194,12 +7219,15 @@ total++;
7194
7219
  // ⑦ (1.28.2 Phase 10c) doctor: en 영어(한글 0) + ko 기본 한글 보존
7195
7220
  // 1.36.87: doctor 는 내부에서 selftest 를 돌린다 — 실측 19~24s 인데 제한이 20s 라 여유가 없었다(1.36.86 에서도 0.9s).
7196
7221
  // 제한을 올린 대신 종료코드도 함께 단언한다 — 타임아웃(status=null)이 출력 매칭만으로 통과하면 안 된다(codex 26차 #11).
7197
- // 1.36.127 (실측): 1.36.105 가 형제 블록의 doctor 제한을 120s→300s 로 올리며 "여유가 1.2배뿐이라
7198
- // 전체 부하에서 반복해 터진다" 고 적었는데, **이 클론은 120s 로 남아 있었다**(같은 수정의 누락분).
7199
- // 이번 게이트에서 실제로 여기서만 터졌고, 격리 실측은 `doctor --language en` **120s**(= 제한과 동일, 여유 1.0배).
7200
- // 측정 대상은 속도가 아니라 언어 렌더이므로 넉넉히 준다.
7201
- const docEnR = cp.spawnSync(process.execPath, [CLI, 'doctor', '--language', 'en'], { encoding: 'utf8', timeout: 300000, cwd: d });
7202
- const docKoR = cp.spawnSync(process.execPath, [CLI, 'doctor'], { encoding: 'utf8', timeout: 300000, cwd: d });
7222
+ // 1.36.161: 전체 E2E 108분 부하에서는 300s도 한 번 초과했다. 같은 픽스처 격리 실측은 en/ko 각각 139s,
7223
+ // 종료 0·언어 계약 통과였다. 측정 대상은 속도가 아니라 언어 렌더이므로 4배 이상 여유를 주고,
7224
+ // 다시 실패하면 status/error/시간/출력 꼬리를 아래 디버그에 남긴다.
7225
+ const docEnAt = Date.now();
7226
+ const docEnR = cp.spawnSync(process.execPath, [CLI, 'doctor', '--language', 'en'], { encoding: 'utf8', timeout: 600000, cwd: d });
7227
+ const docEnMs = Date.now() - docEnAt;
7228
+ const docKoAt = Date.now();
7229
+ const docKoR = cp.spawnSync(process.execPath, [CLI, 'doctor'], { encoding: 'utf8', timeout: 600000, cwd: d });
7230
+ const docKoMs = Date.now() - docKoAt;
7203
7231
  const docEn = out(docEnR), docKo = out(docKoR);
7204
7232
  const doctorOk = docEnR.status === 0 && docKoR.status === 0
7205
7233
  && /install\/environment diagnosis/.test(docEn) && !H.test(docEn) && /설치\/환경 진단/.test(docKo);
@@ -7224,11 +7252,11 @@ total++;
7224
7252
  cp.spawnSync(process.execPath, [CLI, 'init', de, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
7225
7253
  const snap = path.join(de, '.harness', 'environment.json');
7226
7254
  const forceEnvChange = () => { try { const s = JSON.parse(fs.readFileSync(snap, 'utf8')); if (s.node) s.node.version = 'v0.0.0-test'; fs.writeFileSync(snap, JSON.stringify(s, null, 2) + '\n'); } catch {} };
7227
- cp.spawnSync(process.execPath, [CLI, 'handoff', de], { encoding: 'utf8', timeout: 25000 }); // 첫 캡처(silent)
7255
+ cp.spawnSync(process.execPath, [CLI, 'handoff', de, '--writeback'], { encoding: 'utf8', timeout: 25000 }); // 첫 legacy projection 캡처(silent)
7228
7256
  forceEnvChange();
7229
- const edEn = out(cp.spawnSync(process.execPath, [CLI, 'handoff', de, '--language', 'en'], { encoding: 'utf8', timeout: 25000 }));
7257
+ const edEn = out(cp.spawnSync(process.execPath, [CLI, 'handoff', de, '--language', 'en', '--writeback'], { encoding: 'utf8', timeout: 25000 }));
7230
7258
  forceEnvChange(); // en 실행이 스냅샷 갱신 → ko 위해 재변경
7231
- const edKo = out(cp.spawnSync(process.execPath, [CLI, 'handoff', de], { encoding: 'utf8', timeout: 25000 }));
7259
+ const edKo = out(cp.spawnSync(process.execPath, [CLI, 'handoff', de, '--writeback'], { encoding: 'utf8', timeout: 25000 }));
7232
7260
  const edEnLines = edEn.split('\n').filter(l => /Runtime environment|env detect|change\(s\) detected/i.test(l));
7233
7261
  const edEnOk = /Runtime environment/.test(edEn) && edEnLines.length >= 1 && !edEnLines.some(l => H.test(l));
7234
7262
  const edKoOk = /실행 환경/.test(edKo);
@@ -7243,11 +7271,11 @@ total++;
7243
7271
  try { const s = JSON.parse(fs.readFileSync(ssnap, 'utf8')); if (s.node) s.node.version = 'v0.0.0-test'; fs.writeFileSync(ssnap, JSON.stringify(s, null, 2) + '\n'); } catch {}
7244
7272
  fs.writeFileSync(sfail, JSON.stringify({ failures: [{ cmd: 'ls && pwd', exitCode: 1, shell: 'powershell-5.1', issues: ['ps5-chain'] }] }, null, 2) + '\n');
7245
7273
  };
7246
- cp.spawnSync(process.execPath, [CLI, 'handoff', ds], { encoding: 'utf8', timeout: 25000 }); // 첫 캡처(silent)
7274
+ cp.spawnSync(process.execPath, [CLI, 'handoff', ds, '--writeback'], { encoding: 'utf8', timeout: 25000 }); // 첫 legacy projection 캡처(silent)
7247
7275
  seedSh();
7248
- const shEn = out(cp.spawnSync(process.execPath, [CLI, 'handoff', ds, '--language', 'en'], { encoding: 'utf8', timeout: 25000 }));
7276
+ const shEn = out(cp.spawnSync(process.execPath, [CLI, 'handoff', ds, '--language', 'en', '--writeback'], { encoding: 'utf8', timeout: 25000 }));
7249
7277
  seedSh(); // en 실행이 스냅샷 갱신 → ko 위해 재구성
7250
- const shKo = out(cp.spawnSync(process.execPath, [CLI, 'handoff', ds], { encoding: 'utf8', timeout: 25000 }));
7278
+ const shKo = out(cp.spawnSync(process.execPath, [CLI, 'handoff', ds, '--writeback'], { encoding: 'utf8', timeout: 25000 }));
7251
7279
  const shEnLines = shEn.split('\n').filter(l => /shell guard|shell failure|review past shell|shell-guard|check before running/i.test(l));
7252
7280
  const shEnOk = /Terminal shell guard/.test(shEn) && shEnLines.length >= 2 && !shEnLines.some(l => H.test(l));
7253
7281
  const shKoOk = /셸 가드/.test(shKo);
@@ -7278,7 +7306,10 @@ total++;
7278
7306
  ok = lensKoOk && lensEnOk && noLeak && stOk && healthOk && driftOk && doctorOk && hoEnOk && hoKoOk && edEnOk && edKoOk && shEnOk && shKoOk && agEnOk && agKoOk && f3En && f3Ko && f4;
7279
7307
  // 1.36.87: 단언 18개를 && 로 묶고 실패 시 어느 것인지 알려주지 않아, 진단하려면 22분짜리 스위트를 통째로
7280
7308
  // 다시 돌려야 했다. 다른 블록들과 같은 규율으로 실패 플래그를 노출한다.
7281
- if (!ok) console.log(' [i18n 디버그] ' + JSON.stringify({ lensKoOk, lensEnOk, noLeak, stOk, healthOk, driftOk, doctorOk, hoEnOk, hoKoOk, edEnOk, edKoOk, shEnOk, shKoOk, agEnOk, agKoOk, f3En, f3Ko, f4 }));
7309
+ if (!ok) console.log(' [i18n 디버그] ' + JSON.stringify({ lensKoOk, lensEnOk, noLeak, stOk, healthOk, driftOk, doctorOk,
7310
+ doctor: { en: { status: docEnR.status, error: docEnR.error && docEnR.error.code, ms: docEnMs, tail: docEn.trim().split(/\r?\n/).slice(-2) },
7311
+ ko: { status: docKoR.status, error: docKoR.error && docKoR.error.code, ms: docKoMs, tail: docKo.trim().split(/\r?\n/).slice(-2) } },
7312
+ hoEnOk, hoKoOk, edEnOk, edKoOk, shEnOk, shKoOk, agEnOk, agKoOk, f3En, f3Ko, f4 }));
7282
7313
  } catch (e) { console.log(' [i18n 디버그] 예외: ' + ((e && e.message) || e)); }
7283
7314
  console.log(ok ? '✓ B(1.25.1/1.25.2/1.27.2/1.28.2/1.29.1/1.29.2/1.29.3/1.29.4/1.30.5) i18n 행위: --language en 런타임 영어(lens/health/drift/doctor/handoff보안요약/env-detect/shell-guard/agent-slash/워크플로가이드/verify-claim) + ko 기본 보존 + --language positional 무누출 + status 에러 en/ko (UR-0010)' : '✗ i18n 행위 회귀 가드 실패');
7284
7315
  if (!ok) failed++;
@@ -9179,7 +9210,11 @@ total++;
9179
9210
  && (r.j.refusals || []).length === 1 && (r.j.refusals || [])[0].code === 'provider_not_authenticated';
9180
9211
  // 같은 실행파일을 가리키는 **정상 별칭** 전부에서 유지돼야 한다 — 문자열 완전일치로 비교하면
9181
9212
  // `codex.cmd`·대문자·절대경로 등록만으로 이번에 되살린 차단이 다시 사라진다(실측).
9182
- const aliases = [null, 'codex', 'CODEX', 'codex.cmd', path.join(shimDir, isWin ? 'codex.cmd' : 'codex')];
9213
+ // POSIX executable names are case-sensitive and do not resolve `.cmd`; those are aliases only on Windows.
9214
+ // Treating CODEX as codex on Linux would hide a genuinely different (or missing) executable.
9215
+ const aliases = [null, 'codex']
9216
+ .concat(isWin ? ['CODEX', 'codex.cmd'] : [])
9217
+ .concat([path.join(shimDir, isWin ? 'codex.cmd' : 'codex')]);
9183
9218
  let aliasOk = true, aliasBad = '';
9184
9219
  for (const al of aliases) {
9185
9220
  const w = mk(al);
@@ -10112,7 +10147,7 @@ total++;
10112
10147
  fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
10113
10148
  cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 120000 });
10114
10149
  fs.writeFileSync(path.join(d, '.harness', 'agent-reminders.md'), body);
10115
- cp.spawnSync(process.execPath, [CLI, 'handoff', '.'], { cwd: d, encoding: 'utf8', timeout: 180000 });
10150
+ cp.spawnSync(process.execPath, [CLI, 'handoff', '.', '--writeback'], { cwd: d, encoding: 'utf8', timeout: 180000 });
10116
10151
  return path.join(d, '.harness', 'agent-reminders.md');
10117
10152
  };
10118
10153
  const B = '# 🔔 자동 reminder\ndrift critical 감지\n';
@@ -10485,6 +10520,8 @@ total++;
10485
10520
  };
10486
10521
  const DERIVED = /^(cache\/|last-handoff\.json|environment\.json|tech-profile\.json|update-cache\.json|provider-probe-cache\.json|leerness\.html)/;
10487
10522
  // 스토어를 각각 건드리는 변경 명령들. seed 로 기존 항목을 만들어 **읽기가 실제로 일어나게** 한다.
10523
+ const SEED = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d],
10524
+ { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV0 });
10488
10525
  const CASES = [
10489
10526
  ['task add', ['task', 'add', '락프로브']],
10490
10527
  ['decision add', ['decision', 'add', '락프로브', '--why', 'x']],
@@ -10492,6 +10529,16 @@ total++;
10492
10529
  ['rule add', ['rule', 'add', '락프로브', '--trigger', 'every-session']],
10493
10530
  ['rule pause', ['rule', 'pause', 'R-0001'], ['rule', 'add', '씨앗룰', '--trigger', 'every-session']],
10494
10531
  ['plan add', ['plan', 'add', '락프로브']],
10532
+ // 1.36.152 (T-0115): 같은 RMW 클래스의 덜 쓰이는 진입점도 실행 계측에 넣는다.
10533
+ ['creds register', ['creds', 'register', '락프로브', '--env-var', 'LOCK_PROBE']],
10534
+ ['creds refresh', ['creds', 'refresh', '락프로브'], d => SEED(d, ['creds', 'register', '락프로브', '--env-var', 'LOCK_PROBE'])],
10535
+ // team ID는 ASCII slug만 허용한다. 표시 라벨과 달리 seed/삭제 대상은 실제 유효 ID를 쓴다.
10536
+ ['team remove', ['team', 'remove', 'lock-probe'], d => SEED(d, ['team', 'add', 'lock-probe', '--name', 'lock'])],
10537
+ ['memory restore decisions', ['memory', 'restore', 'decisions', '락프로브'], d => { SEED(d, ['decision', 'add', '락프로브', '--why', 'x']); SEED(d, ['decision', 'drop', '락프로브']); }],
10538
+ ['memory restore lessons', ['memory', 'restore', 'lessons', '락프로브'], d => { SEED(d, ['lesson', 'save', '락프로브']); SEED(d, ['lesson', 'drop', '락프로브']); }],
10539
+ ['memory restore plan', ['memory', 'restore', 'plan', '락프로브'], d => { SEED(d, ['plan', 'add', '락프로브']); SEED(d, ['plan', 'remove', '락프로브']); }],
10540
+ ['plan drop', ['plan', 'drop', '락프로브']],
10541
+ ['plan remove', ['plan', 'remove', '락프로브'], d => SEED(d, ['plan', 'add', '락프로브'])],
10495
10542
  ['preview add', ['preview', 'add', '락프로브', '--design', 'd', '--features', 'f']],
10496
10543
  ['requests add', ['requests', 'add', '락프로브']],
10497
10544
  ['next-action add', ['next-action', 'add', '락프로브']],
@@ -12378,9 +12425,9 @@ total++;
12378
12425
  { runners: selfNoBin.runners.filter(r => !/e2e\.js$/.test(r.file)) }));
12379
12426
  // ⚠ 비교 대상을 `orphanScripts` 하나로 두면 약한 불변식이다(검수 P3) — 판정 전체를 비교한다.
12380
12427
  // `orphanFiles` 를 통째로 빼면 자기참조 회귀를 못 잡는다(검수 P3) — **측정된 차이만 허용**한다.
12381
- // 허용치: `scripts/e2e-core.js`. 근거는 실측이다 — e2e.js 가 그 경로를 언급하고, 그 파일은
12382
- // 고아인 `test:core` 만 실행하므로 e2e.js 를 빼면 고아로 뜬다. 그 밖의 차이는 자기참조 회귀다.
12383
- const _FILE_ALLOW = new Set(['scripts/e2e-core.js']);
12428
+ // 허용치: e2e.js가 직접 실행하는 보조 테스트 세 개. e2e.js를 입력에서 빼면 이 파일들은
12429
+ // 고아처럼 보이지만, 실제 CI 진입점인 e2e.js에서 실행된다. 그 밖의 차이는 자기참조 회귀다.
12430
+ const _FILE_ALLOW = new Set(['scripts/e2e-core.js', 'scripts/e2e-concurrency.js', 'scripts/release-bump-probe.js']);
12384
12431
  const _shape = (x) => JSON.stringify({
12385
12432
  s: x.orphanScripts.slice().sort(),
12386
12433
  d: x.dynamicRunners.slice().sort(), p: x.enumPrefixes.slice().sort(),
@@ -12793,7 +12840,7 @@ total++;
12793
12840
  if (!planned.includes('.cursor/mcp.json')) bad.push(`②b계획에 .cursor/mcp.json 없음(${JSON.stringify(planned)})`);
12794
12841
  const rR = RA(dR, ['adapter', 'cursor', '--path', dR]);
12795
12842
  if (rR.status !== 0) bad.push(`②bcursor adapter exit=${rR.status}`);
12796
- for (const f of ['.cursor/mcp.json', '.mcp.json']) {
12843
+ for (const f of ['.cursor/mcp.json', '.mcp.json', '.cursor/hooks.json', '.cursor/hooks/leerness-session.cjs']) {
12797
12844
  if (fs.existsSync(path.join(dR, f)) && !planned.includes(f)) bad.push(`②b계획에 없던 파일을 생성: ${f}`);
12798
12845
  }
12799
12846
  dbg.cursor = { planned };
@@ -12832,6 +12879,7 @@ total++;
12832
12879
  for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDECODE',
12833
12880
  'CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_BY_PNPM', 'CODEX_MANAGED_BY_BUN', 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE',
12834
12881
  'CURSOR_AGENT', 'LEERNESS_NO_SESSION_PRESENCE', 'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'CI', 'GITHUB_ACTIONS',
12882
+ 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID',
12835
12883
  'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG', 'LEERNESS_MCP_PROFILE', 'LEERNESS_MCP_TIMEOUT_MS']) delete envS[k];
12836
12884
  // 캐너리 키는 **실행 시 생성**한다 — 소스에 적으면 이 파일이 러너로 읽히는 자기참조 함정(8회차)에 걸린다.
12837
12885
  const KEY = (tag) => `sess-${process.pid.toString(36)}-${tag}`;
@@ -12930,17 +12978,20 @@ total++;
12930
12978
  if (!(sj.total >= 1)) bad.push('⑥sessions 가 레코드를 못 봄');
12931
12979
  }
12932
12980
 
12933
- // ⑦ git 유출 없음 — 세션 파일은 무시되고, **다른 .harness 파일은 보인다**(git 이 실제로 관측 중임을 증명)
12981
+ // ⑦ git 유출 없음 — 세션 파일과 기본 handoff의 동적 projection은 보이지 않고,
12982
+ // **의도적으로 만든 다른 .harness 파일은 보인다**(git 이 실제로 관측 중임을 증명).
12934
12983
  const dG = mkS('git');
12935
12984
  const gi = cp.spawnSync('git', ['init', '-q'], { cwd: dG, encoding: 'utf8', timeout: 60000 });
12936
12985
  if (gi.status !== 0) bad.push('⑦git init 실패 — 이 검사는 스킵이 아니라 실패다(측정 붕괴를 통과로 세지 않는다)');
12937
12986
  else {
12938
12987
  RS(dG, ['handoff', dG], { CLAUDE_CODE_SESSION_ID: KEY('g') }); cliCases++;
12988
+ fs.writeFileSync(path.join(dG, '.harness', 'git-visible-control.txt'), 'control\n');
12939
12989
  // ⚠ `--porcelain` 만 쓰면 untracked 디렉터리가 `?? .harness/` 한 줄로 **접혀서** 양쪽 판정이 모두
12940
12990
  // 공허해진다(내 대조군이 실제로 그걸 잡았다). `-uall` 로 파일 단위 열거를 강제한다.
12941
12991
  const st = String(cp.spawnSync('git', ['status', '--porcelain', '-uall'], { cwd: dG, encoding: 'utf8', timeout: 60000 }).stdout || '');
12942
12992
  if (/cache[\\/]sessions/.test(st)) bad.push('⑦세션 파일이 git 에 노출됨');
12943
- if (!/last-handoff\.json/.test(st)) bad.push('⑦대조군 실패 — git 이 .harness 를 아예 안 보고 있음(위 판정이 공허)');
12993
+ if (/environment\.json|last-handoff\.json|tech-profile\.json/.test(st)) bad.push('⑦기본 handoff 동적 projection이 git 에 노출됨');
12994
+ if (!/git-visible-control\.txt/.test(st)) bad.push('⑦대조군 실패 — git 이 .harness 를 아예 안 보고 있음(위 판정이 공허)');
12944
12995
  observedRecords += nRec(dG);
12945
12996
  }
12946
12997
 
@@ -13251,10 +13302,13 @@ total++;
13251
13302
  if (present !== N) bad.push(`①동시 쓰기 유실 ${N - present}행 (${present}/${N} 생존)`);
13252
13303
  if (crashed > 0) bad.push(`①자식 크래시 ${crashed}건 — rename/락 경합에서 죽었다: ${errs[0] || '?'}`);
13253
13304
  // 1.36.132 (검수 P1): 유실 0 만 보면 "보호 없이 진행했는데 운 좋게 안 겹친" 경우를 통과시킨다.
13254
- // fail-open 은 그 자체로 실패다 — 제품이 stderr 로 말하게 해 놨으니 그것을 단언한다.
13255
- const failOpen = errs.filter(e => /락 획득 실패/.test(e)).length;
13305
+ // 사용자 상태의 fail-open 은 그 자체로 실패다. 1.36.160부터 손실 허용을 명시한 usage telemetry만
13306
+ // 별도 문구+파일명으로 fail-open 할 수 있으므로 상태 보호 실패와 섞어 세지 않는다.
13307
+ const lockWarnings = errs.filter(e => /락 획득 실패/.test(e));
13308
+ const telemetryFailOpen = lockWarnings.filter(e => /비핵심 기록만 보호 없이 진행합니다: usage-stats\.json\b/.test(e)).length;
13309
+ const failOpen = lockWarnings.length - telemetryFailOpen;
13256
13310
  if (failOpen > 0) bad.push(`①락 fail-open ${failOpen}건 — 보호 없이 진행했다(유실이 없어도 실패다)`);
13257
- dbg.concurrent = { N, present, crashed, failOpen: errs.filter(e => /락 획득 실패/.test(e)).length, ms: Date.now() - t0 };
13311
+ dbg.concurrent = { N, present, crashed, failOpen, telemetryFailOpen, ms: Date.now() - t0 };
13258
13312
  }
13259
13313
 
13260
13314
  // ② 배달 — 세션 신호가 **4표면 전부**에 나온다. 종전엔 평문 헤드라인 1곳뿐이었고,
@@ -13468,7 +13522,7 @@ total++;
13468
13522
  ok = bad.length === 0;
13469
13523
  } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
13470
13524
  finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
13471
- console.log(ok ? `✓ AA(1.36.132/UR-0069) 다중세션 토대: 동시 24쓰기 무유실·무크래시(락 EPERM 무보호진행 + rename 동시독자 EPERM 둘 다 차단) · 세션 신호가 4표면 전부에 도달(평문·--compact·--json·훅, 훅은 쓰기 0 유지) · claude 아닌 에이전트도 주소를 갖고 서로를 봄 + 주소 없으면 그 사실을 말함 · 죽은 채널(agent-reminders) 노출 · 단일 흐름 오탐 0 ${JSON.stringify(dbg)}`
13525
+ console.log(ok ? `✓ AA(1.36.132/UR-0069) 다중세션 토대: 동시 24쓰기 무유실·무크래시(상태락 fail-open 차단 · 비핵심 telemetry 분리 · rename 동시독자 EPERM 차단) · 세션 신호가 4표면 전부에 도달(평문·--compact·--json·훅, 훅은 쓰기 0 유지) · claude 아닌 에이전트도 주소를 갖고 서로를 봄 + 주소 없으면 그 사실을 말함 · 죽은 채널(agent-reminders) 노출 · 단일 흐름 오탐 0 ${JSON.stringify(dbg)}`
13472
13526
  : '✗ 1.36.132 다중세션 토대 실패 ' + JSON.stringify({ bad: bad.slice(0, 10), dbg }));
13473
13527
  if (!ok) failed++;
13474
13528
  }
@@ -13698,6 +13752,11 @@ total++;
13698
13752
  for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
13699
13753
  'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
13700
13754
  'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG']) delete envP[k];
13755
+ // handoff freshness is session-scoped and handoff-only. Keep the fixture on one explicit address so both the
13756
+ // CLI and the git hook inspect the same ignored marker instead of the presence lifecycle/legacy projection.
13757
+ const ENFORCE_SESSION = 'e2e-enforce-runtime';
13758
+ envP.LEERNESS_SESSION_ID = ENFORCE_SESSION;
13759
+ const handoffRecord = (d) => path.join(d, '.harness', 'cache', 'handoffs', ENFORCE_SESSION + '.json');
13701
13760
  const RP = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a],
13702
13761
  { cwd: d, encoding: 'utf8', timeout: 300000, env: envP, maxBuffer: 32 * 1024 * 1024 });
13703
13762
  // ⚠ 1.36.140 (재검수 P1): 픽스처가 사용자의 **전역/시스템 git 설정을 상속**하면,
@@ -13821,22 +13880,34 @@ total++;
13821
13880
  // (제품이 쓰는 판별을 테스트가 직접 재현해 본다: handoff 흔적이 창 안이면 통과, 창 밖이면 차단.)
13822
13881
  {
13823
13882
  const d = cases.ok;
13883
+ RP(d, ['handoff', d]);
13824
13884
  const ri = J(RP(d, ['enforce', 'install', '--path', d, '--json']));
13825
13885
  if (!ri || ri.verified !== 'fired') bad.push(`⑩정상 저장소 설치의 verified=${ri && ri.verified} (기대 fired)`);
13826
13886
  const hookP = ri && ri.hook;
13827
13887
  if (hookP && fs.existsSync(hookP)) {
13828
- const lhp = path.join(d, '.harness', 'last-handoff.json');
13888
+ const lhp = handoffRecord(d);
13829
13889
  const stamp = (ageH) => {
13830
- fs.writeFileSync(lhp, JSON.stringify({ last: new Date(Date.now() - ageH * 3600000).toISOString() }, null, 2));
13831
13890
  const t = new Date(Date.now() - ageH * 3600000);
13832
13891
  try { fs.utimesSync(lhp, t, t); } catch {}
13833
13892
  };
13834
- const run = () => cp.spawnSync('sh', [hookP], { cwd: d, encoding: 'utf8', timeout: 20000, env: envP }).status;
13893
+ let sh = 'sh';
13894
+ if (process.platform === 'win32') {
13895
+ const ep = G(d, ['--exec-path']);
13896
+ const installRoot = ep.status === 0 ? path.resolve(String(ep.stdout || '').trim(), '..', '..', '..') : null;
13897
+ if (installRoot) {
13898
+ sh = [path.join(installRoot, 'bin', 'sh.exe'), path.join(installRoot, 'usr', 'bin', 'sh.exe')].find(p => fs.existsSync(p)) || sh;
13899
+ }
13900
+ }
13901
+ const run = () => cp.spawnSync(sh, [hookP], { cwd: d, encoding: 'utf8', timeout: 20000, env: envP }).status;
13835
13902
  stamp(0); const a = run();
13836
13903
  stamp(240); const b = run();
13837
- if (a === null || b === null) bad.push('⑩sh 로 훅을 돌리지 못함(계측 불가)');
13838
- else if (!(a === 0 && b !== 0)) bad.push(`⑩훅이 두 상태를 구분하지 못함(창안=${a} 창밖=${b}) — 발화 검증이 공허하다`);
13839
- dbg.discriminates = { inWindow: a, outWindow: b };
13904
+ // 외부 Codex P1: presence JSON의 mtime이 아니라 handoff-only marker를 봐야 한다.
13905
+ // stale marker 뒤 session close를 해도 gate가 열리면 close가 handoff로 둔갑한 것이다.
13906
+ RP(d, ['session', 'close', d, '--json']);
13907
+ const c = run();
13908
+ if (a === null || b === null || c === null) bad.push('⑩sh 로 훅을 돌리지 못함(계측 불가)');
13909
+ else if (!(a === 0 && b !== 0 && c !== 0)) bad.push(`⑩훅 freshness 불변식 실패(창안=${a} 창밖=${b} close후=${c}) — close가 handoff로 둔갑하거나 판별이 공허하다`);
13910
+ dbg.discriminates = { inWindow: a, outWindow: b, afterClose: c };
13840
13911
  }
13841
13912
  }
13842
13913
 
@@ -13857,7 +13928,7 @@ total++;
13857
13928
  const ins = J(RP(P, ['enforce', 'install', '--path', P, '--json']));
13858
13929
  if (!ins || ins.ok !== true) bad.push(`⑬하위 디렉토리 설치 실패 ok=${ins && ins.ok} code=${ins && ins.code}`);
13859
13930
  if (!ins || ins.verified !== 'fired') bad.push(`⑬하위 설치 verified=${ins && ins.verified}`);
13860
- const lhp = path.join(P, '.harness', 'last-handoff.json');
13931
+ const lhp = handoffRecord(P);
13861
13932
  const stamp = (ageH) => { const t = new Date(Date.now() - ageH * 3600000); try { fs.utimesSync(lhp, t, t); } catch {} };
13862
13933
  // 오래된 handoff → **루트에서** 커밋하면 차단돼야 한다(여기가 우회 지점이었다).
13863
13934
  stamp(200);
@@ -13920,7 +13991,7 @@ total++;
13920
13991
  if (/ENFORCE_PROBE|leerness-probe/.test(body)) bad.push('⑮출하되는 훅에 검증용 우회 스위치가 남아 있다');
13921
13992
  } else bad.push('⑮훅 파일을 찾지 못함(계측 붕괴)');
13922
13993
  // ② 행위로 확인 — 오래된 handoff 에서 어떤 스위치를 써도 커밋이 통과하면 안 된다.
13923
- const lhp = path.join(d, '.harness', 'last-handoff.json');
13994
+ const lhp = handoffRecord(d);
13924
13995
  const old = new Date(Date.now() - 200 * 3600000); try { fs.utimesSync(lhp, old, old); } catch {}
13925
13996
  fs.writeFileSync(path.join(d, 'p1.txt'), '1'); G(d, ['add', '-A']);
13926
13997
  const viaEnv = cp.spawnSync('git', ['--no-optional-locks', '-C', d, 'commit', '-m', 'env'],
@@ -14032,14 +14103,14 @@ total++;
14032
14103
  dbg.cfgFail = { code: r && r.code, hookLeft: left, recovered: r2 && r2.ok };
14033
14104
  }
14034
14105
 
14035
- // ⑫ **설치 행위가 자기가 설치하는 게이트를 약화시키지 않는다.** 발화 검증은 `.harness/last-handoff.json`(실데이터)을
14036
- // 임시로 덮는데, 훅이 신선도를 판정하는 근거가 그 파일의 **mtime** 이다 — 안 되돌리면 설치만으로
14106
+ // ⑫ **설치 행위가 자기가 설치하는 게이트를 약화시키지 않는다.** 발화 검증은 현재 세션의 runtime record를
14107
+ // 임시로 다루며, 훅이 신선도를 판정하는 근거가 그 파일의 **mtime** 이다 — 안 되돌리면 설치만으로
14037
14108
  // "방금 handoff 했다" 가 되어 다음 windowHours 동안 무임승차가 생긴다(실측으로 그렇게 만들었다가 고쳤다).
14038
14109
  {
14039
14110
  const d = mk('selfweaken', commit);
14040
14111
  RP(d, ['handoff', d]);
14041
- const lhp = path.join(d, '.harness', 'last-handoff.json');
14042
- if (!fs.existsSync(lhp)) bad.push('⑫셋업 실패: last-handoff.json 없음');
14112
+ const lhp = handoffRecord(d);
14113
+ if (!fs.existsSync(lhp)) bad.push('⑫셋업 실패: 세션 handoff record 없음');
14043
14114
  else {
14044
14115
  const oldT = new Date(Date.now() - 100 * 3600000);
14045
14116
  fs.utimesSync(lhp, oldT, oldT);
@@ -14048,7 +14119,7 @@ total++;
14048
14119
  const ins = J(RP(d, ['enforce', 'install', '--path', d, '--json']));
14049
14120
  const after = Math.round(fs.statSync(lhp).mtimeMs);
14050
14121
  if (!ins || ins.ok !== true) bad.push(`⑫설치 실패 ok=${ins && ins.ok}`);
14051
- if (fs.readFileSync(lhp, 'utf8') !== before_txt) bad.push('⑫설치가 사용자 실데이터(last-handoff.json) 내용을 바꿨다');
14122
+ if (fs.readFileSync(lhp, 'utf8') !== before_txt) bad.push('⑫설치가 세션 handoff record 내용을 바꿨다');
14052
14123
  if (Math.abs(after - before) > 1500) bad.push(`⑫설치가 handoff mtime 을 갱신했다(${before}→${after}) — 설치가 게이트를 약화시킨다`);
14053
14124
  // 행위로 확인 — 오래된 handoff 상태이므로 설치 직후 커밋은 **차단**돼야 한다.
14054
14125
  fs.writeFileSync(path.join(d, 'z.txt'), 'z'); G(d, ['add', '-A']);
@@ -14171,14 +14242,19 @@ total++;
14171
14242
  // ("실패했다" 로 재면 다른 이유로 실패해도 통과하는 공허한 단언이 된다.)
14172
14243
  const gm = require(path.resolve(__dirname, '..', 'lib', 'git.js'));
14173
14244
  const own = path.join(sb, 'own'), victim = path.join(sb, 'victim');
14245
+ const pathKey = (p) => {
14246
+ let q = path.resolve(String(p));
14247
+ try { q = fs.realpathSync.native ? fs.realpathSync.native(q) : fs.realpathSync(q); } catch {}
14248
+ return process.platform === 'win32' ? q.toLowerCase() : q;
14249
+ };
14174
14250
  const seenDir = (envx) => {
14175
14251
  const r = gm.gitSpawn(['rev-parse', '--absolute-git-dir'], { cwd: own, encoding: 'utf8', env: envx });
14176
14252
  return r.status === 0 ? path.resolve(String(r.stdout).trim()) : 'ERR:' + r.status;
14177
14253
  };
14178
14254
  const clean = seenDir(envP);
14179
14255
  const poisonedDir = seenDir(Object.assign({}, envP, { GIT_DIR: path.join(victim, '.git') }));
14180
- if (clean !== path.resolve(own, '.git')) bad.push(`④정상 환경에서 제 저장소를 못 찾음(${clean}) — 판별 불가`);
14181
- if (poisonedDir !== clean) bad.push(`④GIT_DIR 이 초크포인트를 통과해 대상이 바뀜(${poisonedDir})`);
14256
+ if (pathKey(clean) !== pathKey(path.resolve(own, '.git'))) bad.push(`④정상 환경에서 제 저장소를 못 찾음(${clean}) — 판별 불가`);
14257
+ if (pathKey(poisonedDir) !== pathKey(clean)) bad.push(`④GIT_DIR 이 초크포인트를 통과해 대상이 바뀜(${poisonedDir})`);
14182
14258
  dbg.chokeScrub = { clean, poisoned: poisonedDir };
14183
14259
  }
14184
14260
  ok = bad.length === 0;
@@ -14348,13 +14424,15 @@ total++;
14348
14424
  if (!targetHook) bad.push('⑤지목한 저장소에 훅이 없음 — 강제가 아예 걸리지 않았다');
14349
14425
  dbg.cfgInject = { foreignHook, targetHook };
14350
14426
  }
14351
- // ⑥ 공유 판정이 **모양이 아니라 실제 위치**로 — 대소문자만 다른 `.GIT/hooks` 는 공유가 아니다.
14427
+ // ⑥ 공유 판정이 **모양이 아니라 실제 위치**로 — Windows 에서는 `.GIT/hooks` 가 `.git/hooks` 와
14428
+ // 같은 위치라 비공유다. POSIX 에서는 서로 다른 외부 경로이므로 공유 위험으로 분류해야 한다.
14352
14429
  {
14353
14430
  const d = mk('casehooks');
14354
14431
  G(d, ['config', 'core.hooksPath', '.GIT/hooks']);
14355
14432
  const j = J(RP(d, ['enforce', 'status', '--path', d, '--json']));
14356
- if (!j || j.hooksPathShared !== false) bad.push(`⑥대소문자 차이를 공유로 오탐(hooksPathShared=${j && j.hooksPathShared})`);
14357
- dbg.caseHooks = { shared: j && j.hooksPathShared };
14433
+ const expected = process.platform === 'win32' ? false : true;
14434
+ if (!j || j.hooksPathShared !== expected) bad.push(`⑥플랫폼 경로 의미 오판(hooksPathShared=${j && j.hooksPathShared}, expected=${expected})`);
14435
+ dbg.caseHooks = { shared: j && j.hooksPathShared, expected };
14358
14436
  }
14359
14437
  // ⑦ 연결된 worktree 사실을 고지한다(설정이 없어도 훅은 공용이다).
14360
14438
  {
@@ -14519,9 +14597,18 @@ total++;
14519
14597
  envP.GIT_CONFIG_SYSTEM = path.join(sb, 'gitconfig-system');
14520
14598
  try { fs.writeFileSync(envP.GIT_CONFIG_GLOBAL, ''); fs.writeFileSync(envP.GIT_CONFIG_SYSTEM, ''); } catch {}
14521
14599
  const G = (d, a) => cp.spawnSync('git', a, { cwd: d, encoding: 'utf8', timeout: 60000, env: envP });
14522
- const RP = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a],
14523
- { cwd: d, encoding: 'utf8', timeout: 60000, env: envP, maxBuffer: 32 * 1024 * 1024 });
14600
+ const RP = (d, a, env) => cp.spawnSync(process.execPath, [CLI, ...a],
14601
+ { cwd: d, encoding: 'utf8', timeout: 60000, env: env || envP, maxBuffer: 32 * 1024 * 1024 });
14524
14602
  const J = (r) => { try { return JSON.parse(String(r.stdout || '')); } catch { return null; } };
14603
+ // ③과 ③a가 같은 초기 저장소 계약을 사용한다. ③a는 별도 블록이므로 이 범위에 둬야 한다.
14604
+ const mkRepo = (name) => {
14605
+ const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
14606
+ G(d, ['init', '-q', '-b', 'main', '.']); G(d, ['config', 'user.email', 't@t']); G(d, ['config', 'user.name', 't']);
14607
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
14608
+ fs.writeFileSync(path.join(d, 'a.txt'), 'a'); G(d, ['add', '-A']); G(d, ['commit', '-qm', 'i']);
14609
+ RP(d, ['init', d, '--yes', '--minimal']); RP(d, ['handoff', d]);
14610
+ return d;
14611
+ };
14525
14612
  const cwd = path.join(sb, 'cwd'); fs.mkdirSync(cwd, { recursive: true });
14526
14613
  try {
14527
14614
  // ① 전수 — 제품 자신에게 명령 목록을 묻고, 하위명령까지 펴서 없는 `--path` 로 부른다.
@@ -14578,14 +14665,6 @@ total++;
14578
14665
  }
14579
14666
  // ③ worktree 형제 차단을 **추정하지 않고 각 worktree 에게 묻는다**.
14580
14667
  {
14581
- const mkRepo = (name) => {
14582
- const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
14583
- G(d, ['init', '-q', '-b', 'main', '.']); G(d, ['config', 'user.email', 't@t']); G(d, ['config', 'user.name', 't']);
14584
- fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
14585
- fs.writeFileSync(path.join(d, 'a.txt'), 'a'); G(d, ['add', '-A']); G(d, ['commit', '-qm', 'i']);
14586
- RP(d, ['init', d, '--yes', '--minimal']); RP(d, ['handoff', d]);
14587
- return d;
14588
- };
14589
14668
  const m1 = mkRepo('wt-shared');
14590
14669
  const add1 = G(m1, ['worktree', 'add', '-qb', 'lk', path.join(sb, 'wt-shared-linked')]);
14591
14670
  const j1 = J(RP(m1, ['enforce', 'install', '--path', m1, '--json', '--skip-verify']));
@@ -14602,11 +14681,56 @@ total++;
14602
14681
  }
14603
14682
  dbg.worktree = { shared: j1 && j1.worktrees, perWorktree: j2 && j2.worktrees };
14604
14683
  }
14684
+ // ③a 아직 없는 pre-commit의 **조상** junction도 실제 위치로 풀어 형제 차단을 숨기지 않는다.
14685
+ // 첫 판은 훅 파일 자체만 realpath 했다. 설치 전에는 파일이 없어 두 worktree가 같은 훅을 보는데도 1개라고
14686
+ // 답했고, 설치 직후 형제 커밋은 실제로 차단됐다. 가장 깊은 기존 조상(link-hooks)을 풀어야 한다.
14687
+ {
14688
+ const main = mkRepo('wt-ancestor-junction');
14689
+ const sibling = path.join(sb, 'wt-ancestor-junction-linked');
14690
+ const add = G(main, ['worktree', 'add', '-qb', 'lj', sibling]);
14691
+ let linked = false;
14692
+ const actual = path.join(main, '.git', 'shared-hooks');
14693
+ const alias = path.join(main, '.git', 'link-hooks');
14694
+ let before = null, installed = null, siblingCommit = null;
14695
+ if (add.status !== 0) bad.push(`③a junction worktree 생성 실패(${add.status}) — 판별 불가`);
14696
+ else {
14697
+ try {
14698
+ fs.mkdirSync(actual, { recursive: true });
14699
+ fs.mkdirSync(path.join(sibling, '.harness'), { recursive: true });
14700
+ fs.symlinkSync(actual, alias, process.platform === 'win32' ? 'junction' : 'dir');
14701
+ linked = true;
14702
+ } catch (e) {
14703
+ dbg.ancestorJunction = { skipped: 'junction/symlink 생성 불가', error: String(e && e.code || e).slice(0, 80) };
14704
+ }
14705
+ if (linked) {
14706
+ const cfg = [
14707
+ G(main, ['config', 'extensions.worktreeConfig', 'true']),
14708
+ G(main, ['config', '--worktree', 'core.hooksPath', alias]),
14709
+ G(sibling, ['config', '--worktree', 'core.hooksPath', actual]),
14710
+ ];
14711
+ if (cfg.some((r) => r.status !== 0)) bad.push('③a worktree별 hooksPath 설정 실패 — 판별 불가');
14712
+ else {
14713
+ before = J(RP(main, ['enforce', 'status', '--path', main, '--json']));
14714
+ installed = J(RP(main, ['enforce', 'install', '--path', main, '--json', '--skip-verify']));
14715
+ // Some Git for Windows runners skip the pre-commit hook for an empty
14716
+ // commit. Stage a real change so this always measures the normal path.
14717
+ fs.writeFileSync(path.join(sibling, 'ancestor-junction-probe.txt'), 'probe\n');
14718
+ G(sibling, ['add', '-A']);
14719
+ siblingCommit = G(sibling, ['commit', '-m', 'ancestor-junction-probe']);
14720
+ if (!before || before.worktrees !== 2) bad.push(`③a 없는 훅 파일의 조상 junction을 못 풀어 형제 차단을 숨김(before=${before && before.worktrees})`);
14721
+ if (!installed || installed.worktrees !== 2) bad.push(`③a 설치 응답도 공유 worktree를 1개로 오보고(installed=${installed && installed.worktrees})`);
14722
+ if (!siblingCommit || siblingCommit.status === 0) bad.push('③a 픽스처가 실제 형제 커밋 차단을 증명하지 못함');
14723
+ }
14724
+ dbg.ancestorJunction = { before: before && before.worktrees, installed: installed && installed.worktrees,
14725
+ siblingCommitExit: siblingCommit && siblingCommit.status, sameTarget: fs.realpathSync(alias) === fs.realpathSync(actual) };
14726
+ }
14727
+ }
14728
+ }
14605
14729
  // ④ 세척 클래스의 **경계** — 지워야 할 것과 남겨야 할 것을 같이 잰다(한쪽만 재면 과세척이 통과한다).
14606
14730
  {
14607
14731
  const g = require(path.resolve(__dirname, '..', 'lib', 'git.js'));
14608
- const mustDrop = ['GIT_DIR', 'git_dir', 'GIT_CONFIG_NOSYSTEM', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_7', 'GIT_CONFIG_VALUE_7'];
14609
- const mustKeep = ['GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CEILING_DIRECTORIES', 'GIT_AUTHOR_NAME', 'GIT_SSH_COMMAND', 'GIT_EDITOR'];
14732
+ const mustDrop = ['GIT_DIR', 'git_dir', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_7', 'GIT_CONFIG_VALUE_7'];
14733
+ const mustKeep = ['GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_NOSYSTEM', 'GIT_CEILING_DIRECTORIES', 'GIT_AUTHOR_NAME', 'GIT_SSH_COMMAND', 'GIT_EDITOR'];
14610
14734
  const droppedNot = mustDrop.filter((k) => !g._shouldDrop(k));
14611
14735
  const keptNot = mustKeep.filter((k) => g._shouldDrop(k));
14612
14736
  if (droppedNot.length) bad.push(`④세척해야 하는데 안 함: ${droppedNot.join(', ')}`);
@@ -14616,7 +14740,7 @@ total++;
14616
14740
  ok = bad.length === 0;
14617
14741
  } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
14618
14742
  finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
14619
- console.log(ok ? `✓ AG(1.36.143) 없는 --path 는 아무것도 만들지 않음(전수 열거, 고치기 전 33건) · 정상 프로젝트 무회귀 · worktree 형제 차단을 추정 대신 **각 worktree 에게 물어** 판정 · 세척 클래스 경계(지울 것/남길 것 양방향) ${JSON.stringify(dbg)}`
14743
+ console.log(ok ? `✓ AG(1.36.143) 없는 --path 는 아무것도 만들지 않음(전수 열거, 고치기 전 33건) · 정상 프로젝트 무회귀 · worktree 형제 차단을 추정 대신 **각 worktree 에게 물어** 판정(없는 훅 파일의 조상 junction 포함) · 세척 클래스 경계(지울 것/남길 것 양방향) ${JSON.stringify(dbg)}`
14620
14744
  : '✗ 1.36.143 경로/공유 판정 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
14621
14745
  if (!ok) failed++;
14622
14746
  }
@@ -14684,6 +14808,22 @@ total++;
14684
14808
  if (okExits.some((x) => x !== 0)) bad.push(`①플래그 없는 정상 실행이 막힘(exits=${JSON.stringify(okExits)})`);
14685
14809
  dbg.dryRun = { wrote: wrote.length, notRefused: notRefused.length, realExits: okExits };
14686
14810
  }
14811
+ // ①a T-0117 재검수: 기존 픽스처는 세션 주소를 지워 프레즌스 기록 경로가 아예 실행되지 않았다.
14812
+ // 그러면 `session close --dry-run` 이 .harness/cache/sessions/.host-salt와 세션 JSON을
14813
+ // 남기는 실제 변경을 "트리 불변"으로 오판한다. 주소가 있는 실제 Codex/Claude 환경을 명시해
14814
+ // 캐시도 dry-run 계약(영속 파일 변경 0)의 대상임을 실행으로 고정한다.
14815
+ {
14816
+ const d = mkGit('dry-presence');
14817
+ const sessionEnv = Object.assign({}, envP, { LEERNESS_SESSION_ID: 'dryguard001' });
14818
+ const before = snap(d);
14819
+ const r = RP(d, ['session', 'close', '--dry-run', '--path', d], sessionEnv);
14820
+ const changed = diffKeys(before, snap(d));
14821
+ const sessionDir = path.join(d, '.harness', 'cache', 'sessions');
14822
+ if (r.status === 0) bad.push('①a session close --dry-run 이 쓰기 차단 뒤에도 성공이라 보고');
14823
+ if (changed.length) bad.push(`①a 세션 주소가 있을 때 --dry-run 이 바꾼 것: ${changed.slice(0, 4).join(',')}`);
14824
+ if (fs.existsSync(sessionDir)) bad.push('①a --dry-run 이 session-presence cache를 생성');
14825
+ dbg.dryPresence = { exit: r.status, changed: changed.length, sessionCacheCreated: fs.existsSync(sessionDir) };
14826
+ }
14687
14827
  // ② `--path=` / 값 없는 `--path` 가 가드를 우회해 cwd 에 쓰지 않는다.
14688
14828
  {
14689
14829
  const d = mkGit('emptypath');
@@ -15083,16 +15223,21 @@ total++;
15083
15223
  return d;
15084
15224
  };
15085
15225
  try {
15086
- // ① 명시 주소가 child 억제를 이긴다 — 다만 **추론 경로의 억제는 그대로**여야 한다.
15226
+ // ① 명시 주소는 상태 귀속에서 child 억제를 이긴다. presence는 부모의 env 주소까지 상속될 수
15227
+ // 있으므로 child를 계속 억제한다. 실제 MCP 호출별 귀속은 바로 아래 행위검사가 증명한다.
15087
15228
  {
15088
15229
  const SP = require(path.resolve(__dirname, '..', 'lib', 'session-presence'));
15089
15230
  const plain = SP.deriveSessionKey({ LEERNESS_SESSION_ID: 'session-alpha01' });
15090
15231
  const child = SP.deriveSessionKey({ LEERNESS_SESSION_ID: 'session-alpha01', CLAUDE_CODE_CHILD_SESSION: '1' });
15091
15232
  const inferred = SP.deriveSessionKey({ CLAUDE_CODE_SESSION_ID: 'claude-sess-0001', CLAUDE_CODE_CHILD_SESSION: '1' });
15233
+ const explicitSuppression = SP.suppressionReason({ LEERNESS_SESSION_ID: 'session-alpha01', CLAUDE_CODE_CHILD_SESSION: '1' }, true);
15234
+ const inferredSuppression = SP.suppressionReason({ CLAUDE_CODE_SESSION_ID: 'claude-sess-0001', CLAUDE_CODE_CHILD_SESSION: '1' }, true);
15092
15235
  if (plain !== 'session-alpha01') bad.push('①명시 주소를 못 읽음 — 판별 불가');
15093
15236
  if (child !== 'session-alpha01') bad.push('①child 억제가 명시 주소를 삼킴');
15094
15237
  if (inferred !== null) bad.push('①추론 주소에서 child 억제가 사라짐 — sub-agent 가 별도 세션이 된다(회귀)');
15095
- dbg.keySource = { plain, child, inferredUnderChild: inferred };
15238
+ if (explicitSuppression !== 'child-agent') bad.push(`①presence가 상속된 child 주소를 독립 세션으로 오인(${explicitSuppression})`);
15239
+ if (inferredSuppression !== 'child-agent') bad.push(`①주소 없는 child presence 억제가 사라짐(${inferredSuppression})`);
15240
+ dbg.keySource = { plain, child, inferredUnderChild: inferred, explicitSuppression, inferredSuppression };
15096
15241
  }
15097
15242
  // ② **행위** — 자식 세션 env 를 상속한 MCP 서버에서도 증거가 안 섞인다.
15098
15243
  {
@@ -15172,11 +15317,157 @@ total++;
15172
15317
  ok = bad.length === 0;
15173
15318
  } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
15174
15319
  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)}`
15320
+ console.log(ok ? `✓ AL(1.36.148) 상태용 명시 주소는 child에서도 유지·presence 상속 주소는 억제 · 자식 env MCP 에서도 귀속 정상 · 경로형 run id 가 .leerness 밖으로 못 나감 · --path 에 파일 거절(디렉토리는 통과) · dry-run 이 **외부 명령 실행**까지 막음(플래그 없으면 실행) ${JSON.stringify(dbg)}`
15176
15321
  : '✗ 1.36.148 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
15177
15322
  if (!ok) failed++;
15178
15323
  }
15179
15324
 
15325
+ total++;
15326
+ // AM(1.36.149) — 1.36.148 의 run-id 경로 차단은 소유 맵만 덮었다.
15327
+ // legacy `currentRunId` 와 디스크 run record 의 `run_id` 도 같은 파일 경로 병목을 탄다.
15328
+ // 둘 중 하나가 경로형이면 state record/handoff 가 프로젝트 밖에 쓰기 전에 손상으로 멈춰야 한다.
15329
+ {
15330
+ let ok = false; const bad = []; const dbg = {};
15331
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-149-'));
15332
+ const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
15333
+ for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
15334
+ 'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID', 'CI', 'GITHUB_ACTIONS',
15335
+ 'CLAUDECODE', 'CURSOR_AGENT', 'CODEX_MANAGED_BY_NPM', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG']) delete envP[k];
15336
+ const RP = (d, a, env) => cp.spawnSync(process.execPath, [CLI, ...a],
15337
+ { cwd: d, encoding: 'utf8', timeout: 120000, env: env || envP, maxBuffer: 32 * 1024 * 1024 });
15338
+ const mk = (name) => {
15339
+ const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
15340
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
15341
+ const ri = RP(d, ['init', d, '--yes', '--minimal']);
15342
+ if (ri.status !== 0) bad.push(`${name}:init exit=${ri.status}`);
15343
+ return d;
15344
+ };
15345
+ try {
15346
+ // ① global/legacy slot: 소유 맵이 없는 구버전 state 도 입력 경로가 될 수 있다.
15347
+ {
15348
+ const d = mk('current-id');
15349
+ RP(d, ['state', 'start', 'A', '--json']);
15350
+ const stf = path.join(d, '.leerness', 'state.json');
15351
+ const st = JSON.parse(fs.readFileSync(stf, 'utf8'));
15352
+ delete st.runsBySession;
15353
+ st.currentRunId = '../../escaped-current';
15354
+ fs.writeFileSync(stf, JSON.stringify(st, null, 2) + '\n');
15355
+ const r = RP(d, ['state', 'handoff', '요약']);
15356
+ const escaped = fs.existsSync(path.join(d, 'escaped-current.json')) || fs.existsSync(path.join(d, 'escaped-current.md'));
15357
+ if (r.status === 0) bad.push('①경로형 currentRunId 인데 성공이라 보고');
15358
+ if (escaped) bad.push('①경로형 currentRunId 로 .leerness 밖에 씀');
15359
+ dbg.currentRunId = { exit: r.status, escaped };
15360
+ }
15361
+ // ② run record: 파일명은 안전해도 내부 run_id 를 저장 대상으로 다시 쓰면 탈출한다.
15362
+ {
15363
+ const d = mk('record-id');
15364
+ RP(d, ['state', 'start', 'A', '--json']);
15365
+ const rf = path.join(d, '.leerness', 'runs', 'run-0001.json');
15366
+ const rec = JSON.parse(fs.readFileSync(rf, 'utf8'));
15367
+ rec.run_id = '../../escaped-record';
15368
+ fs.writeFileSync(rf, JSON.stringify(rec, null, 2) + '\n');
15369
+ const r = RP(d, ['state', 'handoff', '요약']);
15370
+ const escaped = fs.existsSync(path.join(d, 'escaped-record.json')) || fs.existsSync(path.join(d, 'escaped-record.md'));
15371
+ if (r.status === 0) bad.push('②경로형 record.run_id 인데 성공이라 보고');
15372
+ if (escaped) bad.push('②경로형 record.run_id 로 .leerness 밖에 씀');
15373
+ dbg.recordRunId = { exit: r.status, escaped };
15374
+ }
15375
+ ok = bad.length === 0;
15376
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
15377
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
15378
+ console.log(ok ? `✓ AM(1.36.149) legacy currentRunId·run record run_id 모두 경로형이면 .leerness 밖 쓰기 전에 손상으로 차단 ${JSON.stringify(dbg)}`
15379
+ : '✗ 1.36.149 run-id 경로 차단 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
15380
+ if (!ok) failed++;
15381
+ }
15382
+
15383
+ total++;
15384
+ // AN(1.36.150/T-0120) — `GIT_CONFIG_NOSYSTEM`은 저장소 위치 주입이 아니라 호출자가 시스템 설정을
15385
+ // 숨기겠다는 의미다. 설치기만 이를 지우면 system core.hooksPath에 훅을 쓰지만, 같은 셸의 실제 git commit은
15386
+ // 기본 .git/hooks를 보아 오래된 handoff도 통과한다. 설치 경로와 실제 커밋 경로가 같은 환경에서 일치해야 한다.
15387
+ {
15388
+ let ok = false; const bad = []; const dbg = {};
15389
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-150-'));
15390
+ const stripGitTargetEnv = (source) => {
15391
+ const out = Object.assign({}, source);
15392
+ const drop = new Set(['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT']);
15393
+ for (const k of Object.keys(out)) if (drop.has(k.toUpperCase())) delete out[k];
15394
+ return out;
15395
+ };
15396
+ const envP = stripGitTargetEnv(process.env);
15397
+ envP.TMPDIR = sb; envP.TEMP = sb; envP.TMP = sb;
15398
+ envP.LEERNESS_OFFLINE = '1'; envP.LEERNESS_NO_PROMPT = '1';
15399
+ envP.LEERNESS_SESSION_ID = 'e2e-enforce-nosystem';
15400
+ envP.GIT_CONFIG_GLOBAL = path.join(sb, 'global.gitconfig');
15401
+ envP.GIT_CONFIG_SYSTEM = path.join(sb, 'system.gitconfig');
15402
+ envP.GIT_CONFIG_NOSYSTEM = '1';
15403
+ const G = (d, a, env) => cp.spawnSync('git', a, { cwd: d, encoding: 'utf8', timeout: 60000, env: env || envP });
15404
+ const RP = (d, a, env) => cp.spawnSync(process.execPath, [CLI, ...a],
15405
+ { cwd: d, encoding: 'utf8', timeout: 120000, env: env || envP, maxBuffer: 32 * 1024 * 1024 });
15406
+ const J = (r) => { try { return JSON.parse(String(r.stdout || '')); } catch { return null; } };
15407
+ try {
15408
+ const d = path.join(sb, 'repo'), systemHooks = path.join(sb, 'system-hooks');
15409
+ fs.mkdirSync(d, { recursive: true }); fs.mkdirSync(systemHooks, { recursive: true });
15410
+ fs.writeFileSync(envP.GIT_CONFIG_GLOBAL, '');
15411
+ fs.writeFileSync(envP.GIT_CONFIG_SYSTEM, `[core]\n\thooksPath = ${systemHooks.replace(/\\/g, '/')}\n`);
15412
+ const setup = [G(d, ['init', '-q', '-b', 'main', '.']), G(d, ['config', 'user.email', 't@example.invalid']), G(d, ['config', 'user.name', 'T'])];
15413
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}\n');
15414
+ setup.push(G(d, ['add', '-A']), G(d, ['commit', '-qm', 'initial']));
15415
+ if (setup.some((r) => r.status !== 0)) bad.push('①git 픽스처를 만들지 못함 — 판별 불가');
15416
+ const withSystem = Object.assign({}, envP); delete withSystem.GIT_CONFIG_NOSYSTEM;
15417
+ const systemCfg = G(d, ['config', '--show-scope', '--get', 'core.hooksPath'], withSystem);
15418
+ const callerCfg = G(d, ['config', '--show-scope', '--get', 'core.hooksPath']);
15419
+ if (systemCfg.status !== 0 || callerCfg.status === 0) bad.push('①GIT_CONFIG_NOSYSTEM 픽스처가 system 설정을 갈라내지 못함 — 판별 불가');
15420
+ const init = RP(d, ['init', d, '--yes', '--minimal']);
15421
+ const handoff = RP(d, ['handoff', d]);
15422
+ const install = J(RP(d, ['enforce', 'install', '--path', d, '--json', '--skip-verify']));
15423
+ if (init.status !== 0 || handoff.status !== 0 || !install || !install.ok) bad.push(`①설치 흐름 실패(init=${init.status}, handoff=${handoff.status}, install=${install && install.ok})`);
15424
+ else {
15425
+ const raw = String(G(d, ['rev-parse', '--git-path', 'hooks/pre-commit']).stdout || '').trim();
15426
+ const callerHook = path.isAbsolute(raw) ? raw : path.join(d, raw);
15427
+ const old = new Date(Date.now() - 72 * 3600 * 1000);
15428
+ // 1.36.155부터 enforce freshness는 presence가 아니라 handoff 전용 marker를 본다.
15429
+ fs.utimesSync(path.join(d, '.harness', 'cache', 'handoffs', 'e2e-enforce-nosystem.json'), old, old);
15430
+ fs.writeFileSync(path.join(d, 'after.txt'), 'after\n'); G(d, ['add', '-A']);
15431
+ const commit = G(d, ['commit', '-qm', 'must-be-blocked']);
15432
+ const gitModule = require(path.resolve(__dirname, '..', 'lib', 'git.js'));
15433
+ if (gitModule._shouldDrop('GIT_CONFIG_NOSYSTEM')) bad.push('①GIT_CONFIG_NOSYSTEM을 내부 git 에서만 삭제');
15434
+ if (!gitModule._shouldDrop('GIT_CONFIG_PARAMETERS')) bad.push('①일회성 config 주입까지 보존 — 세척 경계 후퇴');
15435
+ if (!fs.existsSync(callerHook)) bad.push(`①설치 훅과 실제 git 훅 경로 불일치(install=${install.hook}, caller=${callerHook})`);
15436
+ if (commit.status === 0) bad.push('①오래된 handoff인데 실제 git commit이 통과 — 훅 우회');
15437
+ dbg.nosystem = { installHook: install.hook, callerHook, callerHasHook: fs.existsSync(callerHook), commitExit: commit.status,
15438
+ systemScope: systemCfg.status, callerScope: callerCfg.status };
15439
+ }
15440
+ ok = bad.length === 0;
15441
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
15442
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
15443
+ console.log(ok ? `✓ AN(1.36.150/T-0120) GIT_CONFIG_NOSYSTEM을 보존해 설치기와 같은 셸의 git이 같은 hooksPath를 보고, 오래된 handoff 커밋이 실제로 차단됨 ${JSON.stringify(dbg)}`
15444
+ : '✗ 1.36.150 GIT_CONFIG_NOSYSTEM 훅 경로 일치 실패 ' + JSON.stringify({ bad: bad.slice(0, 8), dbg }));
15445
+ if (!ok) failed++;
15446
+ }
15447
+
15448
+ total++;
15449
+ // AO(1.36.152/T-0115) — write 시점의 락 관측만으로는 "읽은 사본도 락 안인가"를 증명할 수 없다.
15450
+ // 첫 프로세스의 최종 rename을 강제로 멈추고 두 번째 쓰기를 붙여, 서로 다른 RMW 여섯 쌍이 모두 생존하는지 확인한다.
15451
+ {
15452
+ let ok = false; const dbg = {};
15453
+ try {
15454
+ const race = cp.spawnSync(process.execPath, [path.join(__dirname, 'e2e-concurrency.js'), CLI], {
15455
+ cwd: path.resolve(__dirname, '..'),
15456
+ encoding: 'utf8',
15457
+ timeout: 180000,
15458
+ env: Object.assign({}, process.env, { LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1', LEERNESS_NO_LOCK_WARN: '1' }),
15459
+ maxBuffer: 16 * 1024 * 1024
15460
+ });
15461
+ let report = null; try { report = JSON.parse(String(race.stdout || '').trim()); } catch {}
15462
+ dbg.exit = race.status; dbg.report = report;
15463
+ if (!report) dbg.stderr = String(race.stderr || '').slice(0, 500);
15464
+ ok = race.status === 0 && !!report && report.ok === true
15465
+ && Array.isArray(report.results) && report.results.length === 6 && report.results.every(r => r.ok === true);
15466
+ } catch (e) { dbg.err = String(e && e.message || e).slice(0, 200); }
15467
+ console.log(ok ? '✓ AO(1.36.152/T-0115) 지연 경쟁 6쌍 모두 exit 0 + 양쪽 기록 보존'
15468
+ : '✗ 1.36.152 동시 RMW 회귀 ' + JSON.stringify(dbg));
15469
+ if (!ok) failed++;
15470
+ }
15471
+
15180
15472
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
15181
15473
  if (failed > 0) process.exit(1);
15182
-