leerness 1.36.170 → 1.36.172

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.
@@ -12,10 +12,11 @@
12
12
  //
13
13
  // 이 모듈은 순수 함수만 담는다(스폰·IO 없음) — 가드가 자기 소스를 읽지 않고 행위로 검증할 수 있게.
14
14
 
15
- const SESSIONS_KEEP = 20; // 임의의 수 — 아래 주석 참조
15
+ const SESSIONS_KEEP = 20; // 보존할 최근 closed tombstone 수 — 아래 주석 참조
16
16
  const SESSIONS_CLOSED_TTL_MS = 7 * 24 * 3600 * 1000; // 임의의 수 — 아래 주석 참조
17
- // ⚠ 두 값은 **임의**다. 측정으로 유도한 것은 "상한이 있어야 한다" 는 사실뿐이고, 그 근거는 실측이다 —
18
- // 이 저장소의 `.leerness/runs/` 는 프루닝이 없어 909개 파일까지 자랐다. 값 자체는 근거가 없으므로 그렇게 적는다.
17
+ // ⚠ 두 값은 **임의**다. 측정으로 유도한 것은 closed tombstone에 상한이 있어야 한다는 사실뿐이고,
18
+ // 그 근거는 실측이다 — `.leerness/runs/` 는 프루닝이 없어 909개 파일까지 자랐다. 열린 peer 기록은
19
+ // 충돌 회피의 유일한 흔적일 수 있어 이 상한으로 삭제하지 않는다. 값 자체는 근거가 없으므로 그렇게 적는다.
19
20
 
20
21
  const KEY_RE = /^[A-Za-z0-9_-]{8,64}$/;
21
22
  const FILE_RE = /^[A-Za-z0-9_-]{8,64}\.json$/;
@@ -25,11 +26,65 @@ const RESERVED_KEYS = new Set(['unaddressed']);
25
26
  // 자체 점검이 쓰는 격리 marker는 실제 세션 주소가 될 수 없어야 한다. 그렇지 않으면 enforce install이
26
27
  // 같은 주소의 실제 handoff를 저장/복원 사이에 되돌려 버릴 수 있다.
27
28
  const RESERVED_PREFIXES = ['leerness-internal-'];
29
+ // Windows의 process.env는 키 대소문자를 구분하지 않지만, `{ ...process.env }`로 복사한
30
+ // 평범한 객체는 구분한다. presence 판정 직전에 아래 키만 OS 의미에 맞춰 canonical casing으로
31
+ // 올린다. POSIX에서는 원래의 case-sensitive 계약을 유지하고, 순수 회귀 테스트는 옵션으로
32
+ // Windows 의미를 강제할 수 있다.
33
+ const PRESENCE_ENV_KEYS = [
34
+ 'LEERNESS_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID',
35
+ 'CLAUDE_CODE_CHILD_SESSION', 'LEERNESS_MCP_ADDRESS_EXPLICIT', 'LEERNESS_INTERNAL', 'LEERNESS_HOOK',
36
+ 'LEERNESS_NO_SESSION_PRESENCE', 'CI', 'GITHUB_ACTIONS', 'CLAUDECODE', 'CURSOR_AGENT',
37
+ 'CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_BY_PNPM', 'CODEX_MANAGED_BY_BUN',
38
+ 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE',
39
+ ];
40
+ function normalizePresenceEnv(env, options) {
41
+ const source = env || {};
42
+ const out = { ...source };
43
+ const caseInsensitive = options && Object.prototype.hasOwnProperty.call(options, 'caseInsensitive')
44
+ ? options.caseInsensitive === true
45
+ : process.platform === 'win32';
46
+ if (!caseInsensitive) return out;
47
+ const keys = Object.keys(source);
48
+ for (const canonical of PRESENCE_ENV_KEYS) {
49
+ const variants = keys.filter(key => key.toLowerCase() === canonical.toLowerCase());
50
+ if (!variants.length) continue;
51
+ const selected = Object.prototype.hasOwnProperty.call(source, canonical) ? canonical : variants[0];
52
+ for (const key of variants) delete out[key];
53
+ out[canonical] = source[selected];
54
+ }
55
+ return out;
56
+ }
28
57
  function isValidKey(s) {
29
58
  if (typeof s !== 'string' || !KEY_RE.test(s)) return false;
30
59
  const folded = s.toLowerCase();
31
60
  return !RESERVED_KEYS.has(folded) && !RESERVED_PREFIXES.some(prefix => folded.startsWith(prefix));
32
61
  }
62
+ // `closedAt` 은 truthy 여부나 Date.parse()의 관대한 정규화가 아니라, 이 제품이 실제로 쓰는
63
+ // `Date#toISOString()` 형식과 달력 값이 정확히 일치해야 종료 증거다. 예를 들어 Date.parse는
64
+ // `0`과 `2024-02-30T00:00:00.000Z`도 시각으로 받아들이므로 그것만 믿으면 손상 레코드를 지운다.
65
+ // 손상/미래 스키마 값은 "열림"으로 단정하지도 않지만, 적어도 삭제 가능한 tombstone으로 취급하지 않는다.
66
+ const CANONICAL_ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
67
+ function confirmedCloseMs(record) {
68
+ const value = record && record.closedAt;
69
+ if (typeof value !== 'string' || !CANONICAL_ISO_UTC_RE.test(value)) return null;
70
+ const ms = Date.parse(value);
71
+ if (!Number.isFinite(ms)) return null;
72
+ try { return new Date(ms).toISOString() === value ? ms : null; } catch { return null; }
73
+ }
74
+ function hasConfirmedClose(record) { return confirmedCloseMs(record) !== null; }
75
+
76
+ // 구버전의 case-sensitive 저장소에서는 대소문자만 다른 주소가 서로 다른 열린 세션이었을 수 있다.
77
+ // 둘 이상이면 어느 하나를 canonical 주소로 합칠 근거가 없으므로 보존하고, 읽기/쓰기 표면에 충돌을 표시한다.
78
+ function caseVariantConflict(entries) {
79
+ const open = (entries || []).filter(e => e && e.record && !hasConfirmedClose(e.record));
80
+ if (open.length < 2) return null;
81
+ const variants = open.map(e => String(e.name || '')).filter(name => FILE_RE.test(name)).slice(0, 10);
82
+ return {
83
+ reason: 'ambiguous-open-case-variants',
84
+ count: open.length,
85
+ variants,
86
+ };
87
+ }
33
88
  // env 값의 참/거짓 — CI 계열은 `"false"`/`"0"` 을 **명시적으로** 넘기는 환경이 흔하다.
34
89
  function isTruthyEnv(v) {
35
90
  const t = String(v == null ? '' : v).trim().toLowerCase();
@@ -59,7 +114,7 @@ const FORBIDDEN_SIGNALS = ['CODEX_COMPANION_SESSION_ID', 'CODEX_COMPANION_TRANSC
59
114
  // 물려받으므로(실측) 부모 세션으로 접히는 지금 동작을 유지한다. 독립 실행 codex 만 자기 주소를 얻는다.
60
115
  const KEY_SOURCES = ['LEERNESS_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_SESSION_ID', 'CODEX_THREAD_ID'];
61
116
  function keySource(env) {
62
- const e = env || {};
117
+ const e = normalizePresenceEnv(env);
63
118
  // 1.36.148 (재검수 P1, 재현): child 억제가 **명시 주소보다 앞**에 있어,
64
119
  // MCP 서버가 `CLAUDE_CODE_CHILD_SESSION=1` 을 상속하면 호출별 `sessionKey` 가 통째로 버려졌다
65
120
  // — 1.36.145 에서 넣은 주소 전달이 조용히 무력화되고 전역 슬롯 오귀속이 돌아온다.
@@ -75,13 +130,14 @@ function keySource(env) {
75
130
  // `CaseKey01ABCD` 와 `casekey01abcd` 가 **다른 주소인 척하다가 같은 파일 하나로 조용히 병합**됐다(실측 2→1).
76
131
  // 주소를 소문자로 접어 **하나의 규칙**으로 만든다: 같은 글자면 같은 주소다(그리고 파일명과 항상 일치한다).
77
132
  function deriveSessionKey(env) {
78
- const src = keySource(env);
79
- return src ? String((env || {})[src]).toLowerCase() : null;
133
+ const e = normalizePresenceEnv(env);
134
+ const src = keySource(e);
135
+ return src ? String(e[src]).toLowerCase() : null;
80
136
  }
81
137
  // 1.36.132 (검수 P1): 사용자가 **명시한** 주소가 형식에 안 맞으면 조용히 다음 출처로 넘어갔다 —
82
138
  // 오타를 알리지 않고 설정을 뒤집는 것이다. 무엇이 왜 무시됐는지 말할 수 있게 사유를 돌려준다.
83
139
  function invalidExplicitKey(env) {
84
- const v = (env || {}).LEERNESS_SESSION_ID;
140
+ const v = normalizePresenceEnv(env).LEERNESS_SESSION_ID;
85
141
  if (v === undefined || v === null || v === '') return null;
86
142
  return isValidKey(v) ? null : String(v).slice(0, 40);
87
143
  }
@@ -90,7 +146,7 @@ function invalidExplicitKey(env) {
90
146
  // 보고했다 — codex 가 부모 env 를 통째로 물려주고, 그 codex 자신이 Claude 세션에서 시작됐기 때문이다.
91
147
  // env 가 가리키는 것은 **체인의 최외곽**이다. 이름을 그렇게 붙이면 그 라벨은 참이 된다.
92
148
  function detectOutermostAgent(env) {
93
- const e = env || {};
149
+ const e = normalizePresenceEnv(env);
94
150
  if (e.CLAUDECODE === '1') return { agent: 'claude-code', evidence: ['CLAUDECODE'] };
95
151
  if (!e.CLAUDECODE) {
96
152
  for (const k of ['CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_BY_PNPM', 'CODEX_MANAGED_BY_BUN']) {
@@ -109,13 +165,19 @@ function detectOutermostAgent(env) {
109
165
 
110
166
  // 억제 판정은 **한 곳**에서 한다. 호출부마다 조건을 복붙하면 새 호출부가 빠진다(1.36.108 이 고친 형태).
111
167
  function suppressionReason(env, hasHarness) {
112
- const e = env || {};
113
- if (e.LEERNESS_INTERNAL === '1') return 'internal'; // leerness 가 자기를 스폰하는 20여 지점
168
+ const e = normalizePresenceEnv(env);
169
+ // T-0110: MCP 서버도 자식 CLI를 `LEERNESS_INTERNAL=1` 로 실행한다. 그 값만 보면 호출자가
170
+ // 명시한 sessionKey까지 일반 내부 호출로 오인해 lifecycle handoff/close가 영원히 등록되지 않는다.
171
+ // 서버가 **호출별 유효 주소를 검증해 붙인 marker**만 예외로 삼는다. 단순히 부모 env에서 물려받은
172
+ // LEERNESS_SESSION_ID나 일반 내부 스폰은 이 marker가 없으므로 종전처럼 억제된다.
173
+ const addressedMcp = e.LEERNESS_MCP_ADDRESS_EXPLICIT === '1' && isValidKey(e.LEERNESS_SESSION_ID);
174
+ if (e.LEERNESS_INTERNAL === '1' && !addressedMcp) return 'internal';
114
175
  if (e.LEERNESS_HOOK === '1') return 'hook';
115
176
  // child는 부모의 LEERNESS_SESSION_ID도 그대로 상속할 수 있다. env 값만 보고 호출자가 새 주소를
116
177
  // 지정했다고 단정하면 자식 활동이 부모 presence에 합쳐진다. 상태 MCP의 호출별 sessionKey는
117
- // deriveSessionKey 경로에서 별도로 귀속되므로, presence 자체는 child를 계속 억제한다.
118
- if (e.CLAUDE_CODE_CHILD_SESSION === '1') return 'child-agent';
178
+ // deriveSessionKey 경로에서 별도로 귀속된다. presence 예외는 MCP 서버가 호출별 주소를 검증한
179
+ // lifecycle handoff/close에만 marker로 열고, 그 외 child는 계속 억제한다.
180
+ if (e.CLAUDE_CODE_CHILD_SESSION === '1' && !addressedMcp) return 'child-agent';
119
181
  // ⚠ (검수 P2, 실측 재현) `e.CI` 만 보면 `CI="false"`·`CI="0"` 도 억제했다 —
120
182
  // 명시적으로 '아니다' 라고 말한 정상 로컬 환경이 조용히 등록되지 않는다. **참인 값만** 본다.
121
183
  if (isTruthyEnv(e.CI) || isTruthyEnv(e.GITHUB_ACTIONS)) return 'ci';
@@ -137,34 +199,44 @@ function selectPrunable(records, opts) {
137
199
  const selfKey = o.selfKey || null;
138
200
  const list = (records || []).filter(e => e && typeof e.name === 'string' && FILE_RE.test(e.name));
139
201
  const keyOf = (e) => String(e.name).replace(/\.json$/, '');
140
- const trusted = list.filter(e => e.record && e.record.sessionKey === keyOf(e));
202
+ const foldedKeyOf = (e) => keyOf(e).toLowerCase();
203
+ const foldedSelf = typeof selfKey === 'string' ? selfKey.toLowerCase() : null;
204
+ // 1.36.171: 1.36.132 이전 POSIX 레코드는 파일명과 payload가 혼합 대소문자일 수 있다.
205
+ // 정규화 뒤 같은 주소라면 신뢰하되, 실제 삭제 대상은 언제나 원래 파일명으로 돌려준다.
206
+ const trusted = list.filter(e => e.record && isValidKey(e.record.sessionKey)
207
+ && String(e.record.sessionKey).toLowerCase() === foldedKeyOf(e));
141
208
  const doomed = new Set();
142
- // 이름과 키가 어긋난 것은 **그 파일만** 정리 후보로 본다(그 레코드의 말을 믿고 남을 지우지 않는다).
143
- for (const e of list) { if (!trusted.includes(e) && keyOf(e) !== selfKey) doomed.add(e.name); }
209
+ // 이름과 키가 어긋나도 열린 기록은 다른 에이전트의 마지막 흔적일 수 있다. close/tombstone이
210
+ // 확인된 파일만 정리한다. 저장소 상한 때문에 **열린 동료 기록을 삭제하지 않는다**.
211
+ for (const e of list) {
212
+ if (!trusted.includes(e) && foldedKeyOf(e) !== foldedSelf && hasConfirmedClose(e.record)) doomed.add(e.name);
213
+ }
144
214
  for (const e of trusted) {
145
- if (keyOf(e) === selfKey) continue;
146
- const c = Date.parse((e.record && e.record.closedAt) || '');
147
- if (Number.isFinite(c) && nowMs && (nowMs - c) > ttl) doomed.add(e.name);
215
+ if (foldedKeyOf(e) === foldedSelf) continue;
216
+ const c = confirmedCloseMs(e.record);
217
+ if (c !== null && nowMs && (nowMs - c) > ttl) doomed.add(e.name);
148
218
  }
149
- const alive = trusted.filter(e => !doomed.has(e.name));
150
- // ⚠ (검수 P2, 실측 재현) `openedAt` 만으로 줄 세우면 **오래 열려 있었지만 방금 handoff 한 세션**이
151
- // 상한 밖으로 밀려 삭제됐다. 지키려는 것은 '최근에 살아 있던 기록' 이므로 최근 활동을 함께 본다.
219
+ // 상한은 닫힌 tombstone에만 적용한다. 열린 기록은 라이브니스가 아니라도 충돌 회피를 위한 유일한
220
+ // 증거라서, 임의의 20개 제한으로 버리면 안 된다.
221
+ const closed = trusted.filter(e => !doomed.has(e.name) && hasConfirmedClose(e.record));
152
222
  const activity = (e) => Math.max(Date.parse((e.record && e.record.lastHandoffAt) || '') || 0,
223
+ Date.parse((e.record && e.record.closedAt) || '') || 0,
153
224
  Date.parse((e.record && e.record.openedAt) || '') || 0);
154
- const ranked = alive.slice().sort((a, b) => activity(b) - activity(a));
155
- for (const e of ranked.slice(keep)) { if (keyOf(e) !== selfKey) doomed.add(e.name); }
225
+ const ranked = closed.slice().sort((a, b) => activity(b) - activity(a));
226
+ for (const e of ranked.slice(keep)) { if (foldedKeyOf(e) !== foldedSelf) doomed.add(e.name); }
156
227
  return [...doomed];
157
228
  }
158
229
 
159
230
  // 헤드라인은 **0이면 침묵**한다. "다른 세션 0건" 은 '아무도 없다' 로 읽히는데, 이 레지스트리는
160
- // (a) handoff 를 안 부르는 에이전트 (b) 업그레이드 전에 시작된 세션 (c) 다른 머신 (d) MCP 전용 클라이언트를
231
+ // (a) handoff 를 안 부르는 에이전트 (b) 업그레이드 전에 시작된 세션 (c) 다른 머신 (d) sessionKey 없는 MCP 클라이언트를
161
232
  // **구조적으로 못 본다**. 못 본 것을 없다고 단언하지 않는다.
162
233
  // ⚠ hostId 를 모르면(둘 중 하나가 null) '같은 머신' 이라고도 '다른 머신' 이라고도 하지 않는다 — 세지 않는다.
163
234
  function countOtherSessions(records, selfKey, selfHostId) {
235
+ const foldedSelf = typeof selfKey === 'string' ? selfKey.toLowerCase() : null;
164
236
  return (records || []).filter(r => r && isValidKey(r.sessionKey)
165
- && r.sessionKey !== selfKey
237
+ && String(r.sessionKey).toLowerCase() !== foldedSelf
166
238
  && r.hostId && selfHostId && r.hostId === selfHostId
167
- && !r.closedAt).length;
239
+ && !hasConfirmedClose(r)).length;
168
240
  }
169
241
 
170
242
  // 같은 머신인지 **모를 수 있다** — 3상태로 답한다(검수 P2: null 을 '다른 머신' 으로 단정했다).
@@ -176,6 +248,7 @@ function hostRelation(recordHostId, selfHostId) {
176
248
  module.exports = {
177
249
  KEY_SOURCES, keySource, invalidExplicitKey,
178
250
  SESSIONS_KEEP, SESSIONS_CLOSED_TTL_MS, KEY_RE, FILE_RE, RESERVED_KEYS, RESERVED_PREFIXES, FORBIDDEN_SIGNALS,
179
- isValidKey, isTruthyEnv, deriveSessionKey, detectOutermostAgent, suppressionReason,
251
+ PRESENCE_ENV_KEYS, normalizePresenceEnv, isValidKey, confirmedCloseMs, hasConfirmedClose, caseVariantConflict,
252
+ isTruthyEnv, deriveSessionKey, detectOutermostAgent, suppressionReason,
180
253
  selectPrunable, countOtherSessions, hostRelation,
181
254
  };
@@ -376,18 +376,18 @@ function _sameDestinationSnapshot(a, b) {
376
376
  return !!a && !!b && a.kind === b.kind && a.hash === b.hash;
377
377
  }
378
378
 
379
- function migrateLegacyWorkspace(root, opts = {}) {
380
- const state = inspectWorkspace(root);
381
- const dryRun = opts.dryRun === true;
382
- const force = opts.force === true;
383
- const version = String(opts.version || 'unknown');
379
+ const _MIGRATION_LOCK_OWNED = Symbol('leerness.workspace-migration-lock-owned');
380
+
381
+ function _migrationLockWaitMs(opts = {}) {
382
+ const parsed = Number(opts.lockWaitMs);
383
+ return Number.isFinite(parsed) ? Math.max(0, Math.min(60000, parsed)) : 10000;
384
+ }
385
+
386
+ function _newMigrationReport(state, opts = {}) {
384
387
  const at = opts.at || new Date().toISOString();
385
- const parsedLockWait = Number(opts.lockWaitMs);
386
- const lockWaitMs = Number.isFinite(parsedLockWait)
387
- ? Math.max(0, Math.min(60000, parsedLockWait))
388
- : 10000;
389
- const lockFile = path.join(state.root, '.leerness-workspace-migration.lock');
390
- const report = {
388
+ const version = String(opts.version || 'unknown');
389
+ const dryRun = opts.dryRun === true;
390
+ return {
391
391
  at,
392
392
  version,
393
393
  src: state.legacy.abs,
@@ -410,6 +410,74 @@ function migrateLegacyWorkspace(root, opts = {}) {
410
410
  waitedForLock: false,
411
411
  lockWaitedMs: 0,
412
412
  };
413
+ }
414
+
415
+ // Live first migrations serialize before the second workspace inspection or
416
+ // any destination conflict scan. Acquiring later leaves a TOCTOU window where
417
+ // a peer's in-progress canonical copy is mistaken for user-authored conflict.
418
+ // Dry-runs and already-canonical roots stay read-only and skip lock creation.
419
+ function migrateLegacyWorkspace(root, opts = {}) {
420
+ const initial = inspectWorkspace(root);
421
+ if (opts.dryRun === true || opts[_MIGRATION_LOCK_OWNED] === true || !initial.legacy.exists) {
422
+ return _migrateLegacyWorkspace(initial.root, opts);
423
+ }
424
+
425
+ const lockWaitMs = _migrationLockWaitMs(opts);
426
+ const lockFile = path.join(initial.root, '.leerness-workspace-migration.lock');
427
+ const startedAt = Date.now();
428
+ const deadline = startedAt + lockWaitMs;
429
+ let lockFd = null;
430
+ let waited = false;
431
+
432
+ while (lockFd === null) {
433
+ try { lockFd = fs.openSync(lockFile, 'wx'); }
434
+ catch (error) {
435
+ if (!error || (error.code !== 'EEXIST' && !_isTransientLockFsError(error))) {
436
+ const report = _newMigrationReport(initial, opts);
437
+ report.blocked = true;
438
+ report.blockedReason = 'workspace-dir-migration-failed';
439
+ report.errors.push(error && error.message ? error.message : String(error));
440
+ report.waitedForLock = waited;
441
+ report.lockWaitedMs = waited ? Math.max(0, Date.now() - startedAt) : 0;
442
+ return report;
443
+ }
444
+ waited = true;
445
+ if (Date.now() >= deadline) {
446
+ const report = _newMigrationReport(initial, opts);
447
+ report.blocked = true;
448
+ report.blockedReason = 'workspace-dir-migration-locked';
449
+ report.errors.push(`another workspace migration still owns: ${lockFile}`);
450
+ report.waitedForLock = true;
451
+ report.lockWaitedMs = Math.max(0, Date.now() - startedAt);
452
+ return report;
453
+ }
454
+ _sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
455
+ }
456
+ }
457
+
458
+ try {
459
+ const report = _migrateLegacyWorkspace(initial.root, { ...opts, [_MIGRATION_LOCK_OWNED]: true });
460
+ if (waited) {
461
+ report.waitedForLock = true;
462
+ report.lockWaitedMs = Math.max(report.lockWaitedMs || 0, Date.now() - startedAt);
463
+ }
464
+ return report;
465
+ } finally {
466
+ try { fs.closeSync(lockFd); } catch {}
467
+ _removeOwnedLockFile(lockFile);
468
+ }
469
+ }
470
+
471
+ function _migrateLegacyWorkspace(root, opts = {}) {
472
+ const state = inspectWorkspace(root);
473
+ const dryRun = opts.dryRun === true;
474
+ const migrationLockOwned = opts[_MIGRATION_LOCK_OWNED] === true;
475
+ const force = opts.force === true;
476
+ const version = String(opts.version || 'unknown');
477
+ const at = opts.at || new Date().toISOString();
478
+ const lockWaitMs = _migrationLockWaitMs(opts);
479
+ const lockFile = path.join(state.root, '.leerness-workspace-migration.lock');
480
+ const report = _newMigrationReport(state, opts);
413
481
 
414
482
  const block = (reason, message, extra = {}) => {
415
483
  report.blocked = true;
@@ -423,7 +491,7 @@ function migrateLegacyWorkspace(root, opts = {}) {
423
491
  // look foreign or dual-live while copying. Observe that transaction before
424
492
  // applying any conflict verdict; otherwise simultaneous first handoffs race
425
493
  // against an intermediate state and some fail before reaching the lock.
426
- if (!dryRun && fs.existsSync(lockFile)) {
494
+ if (!dryRun && !migrationLockOwned && fs.existsSync(lockFile)) {
427
495
  const peerStartedAt = Date.now();
428
496
  const peerDeadline = peerStartedAt + lockWaitMs;
429
497
  report.waitedForLock = true;
@@ -559,7 +627,7 @@ function migrateLegacyWorkspace(root, opts = {}) {
559
627
  const stamp = at.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 96) || String(Date.now());
560
628
  const migrationRoot = path.join(state.canonical.abs, 'archive', `workspace-dir-migration-${stamp}`);
561
629
  const canonicalBackup = path.join(migrationRoot, 'canonical-before-merge');
562
- let lockFd = null;
630
+ let lockFd = migrationLockOwned ? undefined : null;
563
631
  const created = [];
564
632
  const overwritten = [];
565
633
  const canonicalExisted = state.canonical.exists;
@@ -750,7 +818,7 @@ function migrateLegacyWorkspace(root, opts = {}) {
750
818
  }
751
819
  return report;
752
820
  } finally {
753
- if (lockFd !== null) {
821
+ if (!migrationLockOwned && lockFd !== null) {
754
822
  try { fs.closeSync(lockFd); } catch {}
755
823
  _removeOwnedLockFile(lockFile);
756
824
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.170",
3
+ "version": "1.36.172",
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",
@@ -48,13 +48,15 @@
48
48
  "LICENSE"
49
49
  ],
50
50
  "scripts": {
51
- "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
52
- "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js",
51
+ "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
52
+ "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js",
53
53
  "test:commands": "node ./scripts/command-flags-probe.js && node ./scripts/next-action-suggestion-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js",
54
54
  "test:next-actions": "node ./scripts/next-action-suggestion-probe.js",
55
55
  "test:installed": "node ./scripts/installed-cleanroom-probe.js",
56
56
  "test:handoff": "node ./scripts/handoff-readonly-probe.js",
57
- "test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/smoke.js",
57
+ "test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/smoke.js",
58
+ "test:workspace-lock-order": "node ./scripts/workspace-dir-lock-order-probe.js",
59
+ "test:mcp-presence": "node ./scripts/mcp-presence-probe.js",
58
60
  "test:smoke": "node ./scripts/smoke.js",
59
61
  "lint": "node ./scripts/lint.js",
60
62
  "prepack": "node ./bin/leerness.js readme sync . && node ./bin/leerness.js --version"
package/scripts/e2e.js CHANGED
@@ -15029,6 +15029,7 @@ total++;
15029
15029
  if (!s1 || !s2 || s1.started === s2.started) bad.push('①MCP 두 세션이 같은 run 을 받음 — 판별 불가');
15030
15030
  else {
15031
15031
  if (!rec || rec.recorded !== s1.started) bad.push(`①MCP: A 의 증거가 A 의 run 이 아닌 곳에 붙음(${rec && rec.recorded})`);
15032
+ if (!ver || ver.result !== 'pass' || !ver.run || ver.run.verification_result !== 'pass') bad.push(`①MCP: B verify pass 응답이 없거나 실패(${JSON.stringify(ver)})`);
15032
15033
  const allowed = ver && ver.completion_claim_allowed && ver.completion_claim_allowed.allowed;
15033
15034
  if (allowed) bad.push('①MCP: 일하지 않은 세션이 완료 허용을 받음 — 증거 게이트가 뒤집힘');
15034
15035
  }
@@ -15345,6 +15346,7 @@ total++;
15345
15346
  if (!s1 || !s2 || s1.started === s2.started) bad.push('②자식 env 에서 두 세션이 같은 run — 판별 불가');
15346
15347
  else {
15347
15348
  if (!rec || rec.recorded !== s1.started) bad.push(`②자식 env 에서 증거가 엉뚱한 run 에 붙음(${rec && rec.recorded})`);
15349
+ if (!ver || ver.result !== 'pass' || !ver.run || ver.run.verification_result !== 'pass') bad.push(`②자식 env verify pass 응답이 없거나 실패(${JSON.stringify(ver)})`);
15348
15350
  if (ver && ver.completion_claim_allowed && ver.completion_claim_allowed.allowed) bad.push('②자식 env 에서 놀던 세션이 완료 허용을 받음');
15349
15351
  }
15350
15352
  dbg.mcpChild = { A: s1 && s1.started, recordedTo: rec && rec.recorded,