leerness 1.36.148 → 1.36.160
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/CHANGELOG.md +51 -0
- package/README.ko.md +4 -1
- package/README.md +9 -5
- package/bin/leerness.js +1646 -258
- package/lib/catalogs.js +1 -1
- package/lib/cursor-session-hook.js +76 -0
- package/lib/diagnostics.js +28 -14
- package/lib/git.js +5 -5
- package/lib/io.js +6 -5
- package/lib/mcp-tools.js +1 -1
- package/lib/pure-utils.js +2 -1
- package/lib/session-presence.js +16 -3
- package/lib/team.js +16 -5
- package/package.json +6 -4
- package/scripts/e2e-command-surface.js +304 -0
- package/scripts/e2e-concurrency.js +141 -0
- package/scripts/e2e-core.js +24 -0
- package/scripts/e2e.js +338 -56
- package/scripts/handoff-readonly-probe.js +403 -0
- package/scripts/lock-probe.js +108 -5
- package/scripts/platform-smoke.js +185 -0
- package/scripts/release-bump-probe.js +137 -0
- package/scripts/smoke.js +5 -3
package/bin/leerness.js
CHANGED
|
@@ -33,8 +33,9 @@ const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInG
|
|
|
33
33
|
const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _TOOL_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, MEMORY_SYNONYMS, SKILL_CATALOG_PRESETS } = require('../lib/catalogs'); // 1.9.344/368/369 (UR-0025): catalog 분리 · 1.11.4 (UR-0007): _TOOL_CATALOG
|
|
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
|
+
const _cursorHook = require('../lib/cursor-session-hook');
|
|
36
37
|
|
|
37
|
-
const VERSION = '1.36.
|
|
38
|
+
const VERSION = '1.36.160';
|
|
38
39
|
|
|
39
40
|
// 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
|
|
40
41
|
// 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
|
|
@@ -166,14 +167,201 @@ function _sleepSyncMs(ms) {
|
|
|
166
167
|
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(1, ms | 0)); }
|
|
167
168
|
catch { const end = Date.now() + ms; while (Date.now() < end) {} }
|
|
168
169
|
}
|
|
169
|
-
// 1.9.303 (UR-0043, 외부리뷰 Codex/Opus): read-modify-write lost-update 방지
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
170
|
+
// 1.9.303 (UR-0043, 외부리뷰 Codex/Opus): read-modify-write lost-update 방지 락.
|
|
171
|
+
// mkdir 원자적 생성으로 상호배제. 다른 프로세스가 보유 중이면 짧게 대기 후 재시도한다.
|
|
172
|
+
// 1.36.160: PID/호스트/토큰 소유권을 기록하되 owner가 기록된 락은 자동 탈취하지 않고,
|
|
173
|
+
// 기본 쓰기는 timeout 시 fail-closed 한다. 자동 회수는 mkdir 뒤 owner 쓰기 전의 빈 디렉터리만
|
|
174
|
+
// 원자적 rmdir로 처리한다. fencing 없는 PID/mtime 판정은 clone/namespace에서 안전하지 않다.
|
|
175
|
+
// 손실을 허용한 비핵심 telemetry 만 failOpen 을 명시한다.
|
|
176
|
+
const _heldLocks = new Map(); // lockPath → owner token (프로세스 내 재진입 + 소유한 락만 해제)
|
|
173
177
|
// 1.36.132: 락 파일을 "지금은 못 만든다" 로 읽어야 하는 오류들. Windows 에서는 상대가 unlink 하는 순간에
|
|
174
178
|
// 열면 EPERM/EACCES 가, 파일 핸들이 아직 살아 있으면 EBUSY 가 난다 — 전부 **재시도 대상**이지 포기 사유가 아니다.
|
|
175
179
|
const _LOCK_RETRY_CODES = new Set(['EEXIST', 'EPERM', 'EACCES', 'EBUSY']);
|
|
176
180
|
const _lockFailOpen = []; // 보호 없이 진행한 기록(관측용) — 조용한 fail-open 을 막는다
|
|
181
|
+
// Metadata-free lock directories are possible if a process dies between mkdir and the
|
|
182
|
+
// metadata write. Owner records and legacy empty-file locks remain fail-closed because
|
|
183
|
+
// ownership is unknowable; this duration is used only by adversarial age fixtures.
|
|
184
|
+
const _LOCK_UNKNOWN_OWNER_STALE_MS = 5 * 60 * 1000;
|
|
185
|
+
const _LOCK_LEASE = 'worker-v1';
|
|
186
|
+
const _LOCK_OWNER_VERSION = 2;
|
|
187
|
+
const _LOCK_OWNER_FILE_RE = /^owner-([a-f0-9]{32})\.json$/;
|
|
188
|
+
// Date.now와 monotonic uptime의 차이는 이 Node 프로세스 인스턴스의 시작 시각이다.
|
|
189
|
+
// 진단용 메타데이터이며 stale 자동 회수의 근거로 쓰지 않는다.
|
|
190
|
+
const _PROCESS_STARTED_AT_MS = Math.round(Date.now() - process.uptime() * 1000);
|
|
191
|
+
let _lockHostIdentityCache;
|
|
192
|
+
let _lockPidScopeIdentityCache;
|
|
193
|
+
function _readLockHostSeed(file) {
|
|
194
|
+
try { const v = String(fs.readFileSync(file, 'utf8') || '').trim(); return /^[a-f0-9]{64}$/i.test(v) ? v.toLowerCase() : null; }
|
|
195
|
+
catch { return null; }
|
|
196
|
+
}
|
|
197
|
+
function _persistentLockHostSeed() {
|
|
198
|
+
// Windows에는 Node 표준 API로 MachineGuid를 읽는 경로가 없다. roaming이 아닌 LOCALAPPDATA에
|
|
199
|
+
// 사용자·머신별 랜덤 seed를 한 번만 만들고, 동시 최초 생성은 wx로 한 승자만 허용한다.
|
|
200
|
+
const base = process.platform === 'win32'
|
|
201
|
+
? String(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'))
|
|
202
|
+
: (process.platform === 'darwin'
|
|
203
|
+
? path.join(os.homedir(), 'Library', 'Application Support')
|
|
204
|
+
: String(process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state')));
|
|
205
|
+
if (!base || !path.isAbsolute(base)) return null;
|
|
206
|
+
const dir = path.join(base, 'leerness'); const fp = path.join(dir, 'lock-host-id');
|
|
207
|
+
let seed = _readLockHostSeed(fp); if (seed) return seed;
|
|
208
|
+
try {
|
|
209
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
210
|
+
const candidate = require('crypto').randomBytes(32).toString('hex');
|
|
211
|
+
fs.writeFileSync(fp, candidate + '\n', { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
212
|
+
return candidate;
|
|
213
|
+
} catch {}
|
|
214
|
+
// 다른 프로세스가 wx 경쟁에서 이겼지만 아직 쓰는 중일 수 있다. 짧게 재독하고, 끝내 확인되지
|
|
215
|
+
// 않으면 hostname으로 완화하지 않고 null(fail-closed)을 돌린다.
|
|
216
|
+
for (let i = 0; i < 4; i++) { _sleepSyncMs(10 + i * 10); seed = _readLockHostSeed(fp); if (seed) return seed; }
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
function _lockHostIdentity() {
|
|
220
|
+
if (_lockHostIdentityCache !== undefined) return _lockHostIdentityCache;
|
|
221
|
+
let raw = null;
|
|
222
|
+
try {
|
|
223
|
+
if (process.platform === 'linux') {
|
|
224
|
+
let machine = null; let boot = null;
|
|
225
|
+
for (const fp of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
|
226
|
+
try { const v = String(fs.readFileSync(fp, 'utf8') || '').trim(); if (/^[a-f0-9-]{16,}$/i.test(v)) { machine = v; break; } } catch {}
|
|
227
|
+
}
|
|
228
|
+
// 컨테이너/VM 복제에서 machine-id가 잘못 복제돼도 같은 공유 락의 로컬 머신으로 오인하지
|
|
229
|
+
// 않도록 boot_id를 반드시 함께 묶는다. 둘 중 하나라도 없으면 부분 식별자를 쓰지 않고
|
|
230
|
+
// 영속 랜덤 seed로 폴백한다. 재부팅 뒤 고아 락은 안전하게 fail-closed다.
|
|
231
|
+
try {
|
|
232
|
+
const v = String(fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8') || '').trim();
|
|
233
|
+
if (/^[a-f0-9-]{16,}$/i.test(v)) boot = v;
|
|
234
|
+
} catch {}
|
|
235
|
+
if (machine && boot) raw = `${machine}|boot:${boot}`;
|
|
236
|
+
}
|
|
237
|
+
if (!raw) raw = _persistentLockHostSeed();
|
|
238
|
+
} catch { raw = null; }
|
|
239
|
+
_lockHostIdentityCache = raw
|
|
240
|
+
? require('crypto').createHash('sha256').update(`leerness-lock-host-v1|${process.platform}|${raw}`).digest('hex').slice(0, 32)
|
|
241
|
+
: null;
|
|
242
|
+
return _lockHostIdentityCache;
|
|
243
|
+
}
|
|
244
|
+
function _lockPidScopeIdentity() {
|
|
245
|
+
if (_lockPidScopeIdentityCache !== undefined) return _lockPidScopeIdentityCache;
|
|
246
|
+
const hostId = _lockHostIdentity();
|
|
247
|
+
if (!hostId) { _lockPidScopeIdentityCache = null; return null; }
|
|
248
|
+
let scope = hostId;
|
|
249
|
+
if (process.platform === 'linux') {
|
|
250
|
+
// machine-id/boot_id가 같은 컨테이너도 PID namespace가 다르면 상대 PID를 볼 수 없다.
|
|
251
|
+
// readlink 값(pid:[inode])은 같은 커널의 namespace 객체를 가리키므로, 이 값이 없으면
|
|
252
|
+
// kill(pid, 0)의 ESRCH를 "죽음"으로 해석할 근거가 없다.
|
|
253
|
+
try {
|
|
254
|
+
const ns = String(fs.readlinkSync('/proc/self/ns/pid') || '').trim();
|
|
255
|
+
if (!/^pid:\[[0-9]+\]$/.test(ns)) { _lockPidScopeIdentityCache = null; return null; }
|
|
256
|
+
scope = `${hostId}|${ns}`;
|
|
257
|
+
} catch { _lockPidScopeIdentityCache = null; return null; }
|
|
258
|
+
}
|
|
259
|
+
_lockPidScopeIdentityCache = require('crypto').createHash('sha256')
|
|
260
|
+
.update(`leerness-lock-pid-scope-v1|${process.platform}|${scope}`).digest('hex').slice(0, 32);
|
|
261
|
+
return _lockPidScopeIdentityCache;
|
|
262
|
+
}
|
|
263
|
+
let _lockHeartbeatWorker = null;
|
|
264
|
+
let _lockHeartbeatUnavailable = false;
|
|
265
|
+
function _ensureLockHeartbeat() {
|
|
266
|
+
if (_lockHeartbeatWorker) return true;
|
|
267
|
+
if (_lockHeartbeatUnavailable) return false;
|
|
268
|
+
try {
|
|
269
|
+
const { Worker } = require('worker_threads');
|
|
270
|
+
const worker = new Worker(`
|
|
271
|
+
'use strict';
|
|
272
|
+
const fs = require('fs');
|
|
273
|
+
const { parentPort } = require('worker_threads');
|
|
274
|
+
const locks = new Map();
|
|
275
|
+
function touch(ownerPath, token) {
|
|
276
|
+
try {
|
|
277
|
+
const owner = JSON.parse(fs.readFileSync(ownerPath, 'utf8'));
|
|
278
|
+
if (!owner || owner.token !== token || owner.lease !== 'worker-v1') { locks.delete(ownerPath); return; }
|
|
279
|
+
const d = new Date(); fs.utimesSync(ownerPath, d, d);
|
|
280
|
+
} catch { locks.delete(ownerPath); }
|
|
281
|
+
}
|
|
282
|
+
parentPort.on('message', (m) => {
|
|
283
|
+
if (!m || typeof m.ownerPath !== 'string') return;
|
|
284
|
+
if (m.op === 'add' && typeof m.token === 'string') { locks.set(m.ownerPath, m.token); touch(m.ownerPath, m.token); }
|
|
285
|
+
else if (m.op === 'remove') locks.delete(m.ownerPath);
|
|
286
|
+
});
|
|
287
|
+
const timer = setInterval(() => { for (const [p, t] of locks) touch(p, t); }, 1000);
|
|
288
|
+
if (timer.unref) timer.unref();
|
|
289
|
+
`, { eval: true });
|
|
290
|
+
worker.on('error', () => { _lockHeartbeatWorker = null; _lockHeartbeatUnavailable = true; });
|
|
291
|
+
worker.on('exit', (code) => { _lockHeartbeatWorker = null; if (code !== 0) _lockHeartbeatUnavailable = true; });
|
|
292
|
+
worker.unref();
|
|
293
|
+
_lockHeartbeatWorker = worker;
|
|
294
|
+
return true;
|
|
295
|
+
} catch { _lockHeartbeatUnavailable = true; return false; }
|
|
296
|
+
}
|
|
297
|
+
function _lockHeartbeat(op, ownerPath, token) {
|
|
298
|
+
try { if (_lockHeartbeatWorker) _lockHeartbeatWorker.postMessage({ op, ownerPath, token }); } catch {}
|
|
299
|
+
}
|
|
300
|
+
function _newLockOwner(lease) {
|
|
301
|
+
const hostId = _lockHostIdentity();
|
|
302
|
+
const pidScopeId = _lockPidScopeIdentity();
|
|
303
|
+
if (!hostId || !pidScopeId) {
|
|
304
|
+
const err = new Error('lock host or PID-scope identity is unavailable');
|
|
305
|
+
err.code = 'E_LOCK_HOST_ID';
|
|
306
|
+
throw err;
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
v: _LOCK_OWNER_VERSION,
|
|
310
|
+
pid: process.pid,
|
|
311
|
+
host: String(os.hostname() || '').toLowerCase(),
|
|
312
|
+
hostId,
|
|
313
|
+
pidScopeId,
|
|
314
|
+
token: require('crypto').randomBytes(16).toString('hex'),
|
|
315
|
+
lease: lease ? _LOCK_LEASE : null,
|
|
316
|
+
processStartedAtMs: _PROCESS_STARTED_AT_MS,
|
|
317
|
+
createdAt: new Date().toISOString()
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function _lockOwnerPath(lockPath, token) {
|
|
321
|
+
return path.join(lockPath, `owner-${token}.json`);
|
|
322
|
+
}
|
|
323
|
+
function _readLockOwner(ownerPath) {
|
|
324
|
+
let raw = null;
|
|
325
|
+
try { raw = fs.readFileSync(ownerPath, 'utf8'); }
|
|
326
|
+
catch (e) { return { raw: null, owner: null, error: (e && e.code) || 'unknown' }; }
|
|
327
|
+
try {
|
|
328
|
+
const value = JSON.parse(raw);
|
|
329
|
+
if (!value || !Number.isSafeInteger(value.pid) || value.pid <= 0
|
|
330
|
+
|| typeof value.host !== 'string' || typeof value.token !== 'string' || value.token.length < 16) {
|
|
331
|
+
return { raw, owner: null, error: null };
|
|
332
|
+
}
|
|
333
|
+
return { raw, owner: value, error: null };
|
|
334
|
+
} catch { return { raw, owner: null, error: null }; }
|
|
335
|
+
}
|
|
336
|
+
function _sameLockDirectory(a, b) {
|
|
337
|
+
if (!a || !b || !a.isDirectory() || !b.isDirectory()) return false;
|
|
338
|
+
// ino/dev가 제공되는 플랫폼에서는 경로가 같은 새 디렉터리로 바뀐 ABA까지 구분한다.
|
|
339
|
+
// Windows처럼 둘 중 하나가 0이면 아래 단독-owner 검사가 이식 가능한 안전망이다.
|
|
340
|
+
return !a.ino || !b.ino || (a.ino === b.ino && a.dev === b.dev);
|
|
341
|
+
}
|
|
342
|
+
function _reclaimLockDirectory(lockPath, staleMs) {
|
|
343
|
+
let dirStat = null; let names = null;
|
|
344
|
+
try { dirStat = fs.statSync(lockPath); names = fs.readdirSync(lockPath); } catch { return false; }
|
|
345
|
+
if (!dirStat.isDirectory()) return false;
|
|
346
|
+
if (names.length === 0) {
|
|
347
|
+
// mkdir 뒤 owner 기록 전에 죽은 프로세스. 살아 있는 생성자가 뒤늦게 기록하면 rmdir 는 ENOTEMPTY로
|
|
348
|
+
// 실패하고, rmdir 가 먼저 이기면 그 생성자의 owner 쓰기가 실패해 임계구역에 들어오지 못한다.
|
|
349
|
+
if (!(Date.now() - dirStat.mtimeMs > staleMs)) return false;
|
|
350
|
+
try { fs.rmdirSync(lockPath); return true; } catch { return false; }
|
|
351
|
+
}
|
|
352
|
+
// owner가 하나라도 기록된 뒤에는 PID/host/mtime으로 자동 회수하지 않는다. VM 스냅샷·PID
|
|
353
|
+
// namespace·공유 볼륨에서는 어느 신호도 "원 소유자가 다시 실행되지 않는다"는 fencing 증명이
|
|
354
|
+
// 아니다. 잠금 소유자 엔트리는 수동 확인 후에만 복구한다(가용성보다 데이터 보존 우선).
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
function _reclaimStaleLock(lockPath, staleMs) {
|
|
358
|
+
let stat = null;
|
|
359
|
+
try { stat = fs.statSync(lockPath); } catch { return false; }
|
|
360
|
+
if (stat.isDirectory()) return _reclaimLockDirectory(lockPath, staleMs);
|
|
361
|
+
// v1 고정 파일도 살아 있는 구버전 프로세스가 재개할 수 있어 자동 삭제하지 않는다.
|
|
362
|
+
if (stat.isFile()) return false;
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
177
365
|
// 1.36.132 (검수 P1, 재현): 부모가 stderr 파이프를 먼저 닫으면 우리 경고 한 줄이 **비동기 EPIPE** 로 올라와
|
|
178
366
|
// 완전한 JSON 을 이미 출력한 성공 명령이 exit 1 이 됐다. try/catch 는 동기 예외만 잡으므로 소용이 없다.
|
|
179
367
|
// 경고를 못 전달하는 것은 감수한다 — 그 때문에 성공을 실패로 보고하는 것은 감수하지 않는다.
|
|
@@ -185,16 +373,47 @@ function _withLock(targetPath, fn, opts = {}) {
|
|
|
185
373
|
if (has('--dry-run')) return fn();
|
|
186
374
|
let bailCode = null, lastCode = null;
|
|
187
375
|
if (_heldLocks.has(lockPath)) return fn(); // 이미 이 프로세스가 보유 → 재진입(중첩 호출 안전)
|
|
188
|
-
|
|
189
|
-
|
|
376
|
+
// 1.36.160: Windows CI의 실제 24세션 경합은 직렬 구간이 10초를 넘어 2개 쓰기가 fail-open 했다.
|
|
377
|
+
// 토큰 owner와 기본 fail-closed 정책 아래에서 대기할 여유를 두어 느린 러너에서도 보호를 유지한다.
|
|
378
|
+
const maxWaitMs = opts.maxWaitMs == null ? 35000 : Math.max(0, Number(opts.maxWaitMs) || 0);
|
|
379
|
+
const staleMs = opts.staleMs == null ? 30000 : Math.max(0, Number(opts.staleMs) || 0);
|
|
190
380
|
// ⚠ 여기서 mkdirp 를 빼면 안 된다(1.36.108 에서 한 번 뺐다가 게이트가 5건을 잡았다).
|
|
191
381
|
// `state`·hunts 등 여러 호출부가 **이 mkdirp 가 만들어 주는 디렉토리에 기대어** 곧바로 파일을 쓴다 —
|
|
192
382
|
// 공유 헬퍼의 부작용을 없애면 그 의존이 한꺼번에 끊긴다. 미초기화 디렉토리 문제는 락을 다는 쪽에서 막는다.
|
|
193
383
|
try { mkdirpRaw(path.dirname(lockPath)); } catch {} // 1.36.144: 락은 dry-run 가드 예외(막으면 무보호 진행으로 떨어진다)
|
|
194
384
|
const start = Date.now();
|
|
195
|
-
let held = false;
|
|
385
|
+
let owner = null; let ownerPath = null; let held = false;
|
|
196
386
|
while (Date.now() - start <= maxWaitMs) {
|
|
197
|
-
try {
|
|
387
|
+
try {
|
|
388
|
+
let fd = null; let madeDir = false; let madeOwner = false; let createdDirStat = null;
|
|
389
|
+
try {
|
|
390
|
+
fs.mkdirSync(lockPath); madeDir = true; // 원자적 배타 디렉터리 생성
|
|
391
|
+
createdDirStat = fs.statSync(lockPath);
|
|
392
|
+
owner = _newLockOwner(_ensureLockHeartbeat()); // 획득 시도마다 새 토큰 — 경로 재사용 금지
|
|
393
|
+
ownerPath = _lockOwnerPath(lockPath, owner.token);
|
|
394
|
+
fd = fs.openSync(ownerPath, 'wx'); madeOwner = true;
|
|
395
|
+
fs.writeFileSync(fd, JSON.stringify(owner) + '\n', 'utf8');
|
|
396
|
+
fs.closeSync(fd); fd = null;
|
|
397
|
+
// 생성/쓰기 중 stale 빈 디렉터리 회수와 경합하면 현재 경로가 다른 디렉터리일 수 있다.
|
|
398
|
+
// 디렉터리 인스턴스(가능한 플랫폼)와 "내 owner 하나뿐"을 함께 확인한다. 단순 토큰 확인만
|
|
399
|
+
// 하면 교체 디렉터리에 두 owner가 생겨 둘 다 임계구역에 들어갈 수 있다.
|
|
400
|
+
const currentDirStat = fs.statSync(lockPath);
|
|
401
|
+
const names = fs.readdirSync(lockPath);
|
|
402
|
+
const confirmed = _readLockOwner(ownerPath);
|
|
403
|
+
if (!_sameLockDirectory(createdDirStat, currentDirStat)
|
|
404
|
+
|| names.length !== 1 || names[0] !== path.basename(ownerPath)
|
|
405
|
+
|| !confirmed || confirmed.error || !confirmed.owner || confirmed.owner.token !== owner.token) {
|
|
406
|
+
const verifyErr = new Error('lock ownership was not durable after creation');
|
|
407
|
+
verifyErr.code = 'E_LOCK_OWNERSHIP'; throw verifyErr;
|
|
408
|
+
}
|
|
409
|
+
} catch (e) {
|
|
410
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch {} }
|
|
411
|
+
if (madeOwner && ownerPath) { try { fs.unlinkSync(ownerPath); } catch {} }
|
|
412
|
+
if (madeDir) { try { fs.rmdirSync(lockPath); } catch {} }
|
|
413
|
+
throw e;
|
|
414
|
+
}
|
|
415
|
+
held = true; break;
|
|
416
|
+
}
|
|
198
417
|
catch (e) {
|
|
199
418
|
// 1.36.132 (P1, 실측): 종전엔 `EEXIST` 가 아니면 즉시 **락 없이 진행**했다. 그런데 Windows 에서
|
|
200
419
|
// 상대가 락을 지우는 그 순간에 열면 `EPERM` 이 난다 — 즉 **경합이 심할수록** 보호가 꺼졌다.
|
|
@@ -204,47 +423,182 @@ function _withLock(targetPath, fn, opts = {}) {
|
|
|
204
423
|
// 이 코드들은 전부 "지금은 못 잡는다" 이지 "잡을 수 없다" 가 아니다 → 기다린다.
|
|
205
424
|
lastCode = e.code || null; // 1.36.132 (검수 P2): 10초를 기다린 뒤 'timeout' 이라고만 하면 영구 권한 오류를 경합으로 오진한다
|
|
206
425
|
if (!_LOCK_RETRY_CODES.has(e.code)) { bailCode = e.code || 'unknown'; break; }
|
|
207
|
-
|
|
426
|
+
if (_reclaimStaleLock(lockPath, staleMs)) continue;
|
|
208
427
|
_sleepSyncMs(15 + Math.floor((Date.now() - start) / 50)); // 점증 backoff
|
|
209
428
|
}
|
|
210
429
|
}
|
|
211
|
-
if (held)
|
|
430
|
+
if (held) {
|
|
431
|
+
_heldLocks.set(lockPath, owner.token);
|
|
432
|
+
if (owner.lease === _LOCK_LEASE) _lockHeartbeat('add', ownerPath, owner.token);
|
|
433
|
+
}
|
|
212
434
|
else {
|
|
213
|
-
// 1.36.132: fail-open 은 **정책**이라 유지하되(명령이 멈추면 안 된다), 조용히 하지는 않는다.
|
|
214
|
-
// 종전엔 보호 없이 진행하면서 아무 말도 안 했다 — 유실이 나도 사용자에겐 성공으로만 보였다.
|
|
215
|
-
// stdout 은 `--json` 계약이 있으므로 건드리지 않는다. 경고는 stderr 로만 낸다.
|
|
216
435
|
const _why = bailCode || (lastCode && lastCode !== 'EEXIST' ? `${lastCode} 지속(${maxWaitMs}ms)` : 'timeout');
|
|
217
|
-
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
436
|
+
const waitedMs = Date.now() - start;
|
|
437
|
+
if (opts.failOpen === true) {
|
|
438
|
+
_lockFailOpen.push({ file: path.basename(targetPath), reason: _why, waitedMs });
|
|
439
|
+
if (process.env.LEERNESS_NO_LOCK_WARN !== '1') {
|
|
440
|
+
try {
|
|
441
|
+
process.stderr.write(`⚠ 락 획득 실패 — 비핵심 기록만 보호 없이 진행합니다: ${path.basename(targetPath)} (${_why}).\n`);
|
|
442
|
+
} catch { /* stderr 가 닫혀 있어도 진행은 막지 않는다 */ }
|
|
443
|
+
}
|
|
444
|
+
} else {
|
|
445
|
+
const err = new Error(`락 획득 실패 — 동시 쓰기로부터 데이터를 보호하기 위해 중단합니다: ${path.basename(targetPath)} (${_why})`);
|
|
446
|
+
err.code = bailCode === 'E_LOCK_HOST_ID' ? 'E_LOCK_HOST_ID' : 'E_LOCK_TIMEOUT';
|
|
447
|
+
err.lockPath = lockPath; err.waitedMs = waitedMs;
|
|
448
|
+
throw err;
|
|
222
449
|
}
|
|
223
450
|
}
|
|
224
451
|
// 1.36.108 (T-0097, 자체 적대 검사에서 발견): fn 이 **프로미스를 돌려주면** 동기 finally 가 그 즉시 락을 푼다 —
|
|
225
452
|
// 비동기 본체(네트워크 fetch 등)는 락 없이 도는데 호출부는 보호받는다고 믿는다. 가장 나쁜 조합이다
|
|
226
453
|
// (`provider sync` 를 감싸면서 실제로 이 형태를 만들었다). thenable 이면 **정착 후에** 푼다.
|
|
227
|
-
// 1.36.132 (검수 P1): 해제 실패를 조용히 삼키면 **고아 락**이
|
|
228
|
-
//
|
|
454
|
+
// 1.36.132 (검수 P1) + 1.36.160: 해제 실패를 조용히 삼키면 **고아 락**이 남는다. 기본 쓰기는
|
|
455
|
+
// 더 이상 자동 탈취하지 않으므로 잠깐 재시도하고, 그래도 안 되면 현재 성공 주장 자체를 거부한다.
|
|
456
|
+
let releaseSettled = false;
|
|
457
|
+
let releaseResult = null;
|
|
229
458
|
const release = () => {
|
|
230
|
-
|
|
459
|
+
// 성공과 실패를 모두 캐시한다. thenable 콜백에서 난 해제 오류가 바깥 `then.call` catch로
|
|
460
|
+
// 다시 올라와도 공개 lockPath나 tombstone을 두 번째로 검사하지 않는다.
|
|
461
|
+
if (releaseSettled) return releaseResult;
|
|
462
|
+
if (!held) return null;
|
|
463
|
+
_lockHeartbeat('remove', ownerPath, owner.token);
|
|
231
464
|
_heldLocks.delete(lockPath);
|
|
465
|
+
// owner를 먼저 지우고 같은 lockPath를 재시도하면, 그 사이 경쟁자가 만든 새 빈 디렉터리를
|
|
466
|
+
// 지우는 ABA가 생긴다. 소유한 디렉터리 전체를 토큰 고유 tombstone으로 먼저 원자 이동해
|
|
467
|
+
// 공개 lockPath와 정리 대상을 분리한다. 이후 경쟁자는 즉시 새 lockPath를 만들 수 있고,
|
|
468
|
+
// 우리는 그 경로를 다시 건드리지 않는다.
|
|
469
|
+
const releasePath = `${lockPath}.release-${owner.token}`;
|
|
470
|
+
const releaseOwnerPath = _lockOwnerPath(releasePath, owner.token);
|
|
471
|
+
let quarantined = false;
|
|
472
|
+
let ownerRemoved = false;
|
|
473
|
+
const releaseSuccess = () => {
|
|
474
|
+
releaseSettled = true; releaseResult = null; held = false; return null;
|
|
475
|
+
};
|
|
476
|
+
const releaseFailure = (reason, code) => {
|
|
477
|
+
_lockFailOpen.push({ file: path.basename(targetPath), reason, waitedMs: 0 });
|
|
478
|
+
const err = new Error(`락 소유권을 확인하지 못해 작업 결과를 성공으로 확정하지 않습니다: ${path.basename(lockPath)} (${reason})`);
|
|
479
|
+
err.code = code || 'E_LOCK_RELEASE'; err.lockPath = lockPath;
|
|
480
|
+
if (quarantined) err.releasePath = releasePath;
|
|
481
|
+
if (process.env.LEERNESS_NO_LOCK_WARN !== '1') {
|
|
482
|
+
try { process.stderr.write(`⚠ ${err.message}\n`); } catch {}
|
|
483
|
+
}
|
|
484
|
+
releaseSettled = true; releaseResult = err;
|
|
485
|
+
return releaseResult;
|
|
486
|
+
};
|
|
232
487
|
for (let i = 0; i < 6; i++) {
|
|
233
|
-
try {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
488
|
+
try {
|
|
489
|
+
if (!quarantined) {
|
|
490
|
+
const current = _readLockOwner(ownerPath);
|
|
491
|
+
if (!current || current.error) {
|
|
492
|
+
if (current && current.error === 'ENOENT') {
|
|
493
|
+
return releaseFailure('ownership-lost', 'E_LOCK_OWNERSHIP_LOST');
|
|
494
|
+
}
|
|
495
|
+
const readErr = new Error(`lock owner read failed: ${(current && current.error) || 'unknown'}`);
|
|
496
|
+
readErr.code = (current && current.error) || 'unknown';
|
|
497
|
+
throw readErr;
|
|
498
|
+
}
|
|
499
|
+
if (!current.owner || current.owner.token !== owner.token) {
|
|
500
|
+
return releaseFailure('ownership-lost', 'E_LOCK_OWNERSHIP_LOST');
|
|
501
|
+
}
|
|
502
|
+
fs.renameSync(lockPath, releasePath);
|
|
503
|
+
quarantined = true;
|
|
504
|
+
}
|
|
505
|
+
if (!ownerRemoved) {
|
|
506
|
+
// rename 뒤에도 같은 owner가 따라왔는지 확인한다. 외부 조작으로 경로가 교체됐다면
|
|
507
|
+
// unrelated tombstone을 지우지 않고 성공 주장도 거부한다.
|
|
508
|
+
const moved = _readLockOwner(releaseOwnerPath);
|
|
509
|
+
if (!moved || moved.error) {
|
|
510
|
+
if (moved && moved.error === 'ENOENT') {
|
|
511
|
+
return releaseFailure('ownership-lost-after-rename', 'E_LOCK_OWNERSHIP_LOST');
|
|
512
|
+
}
|
|
513
|
+
const readErr = new Error(`moved lock owner read failed: ${(moved && moved.error) || 'unknown'}`);
|
|
514
|
+
readErr.code = (moved && moved.error) || 'unknown';
|
|
515
|
+
throw readErr;
|
|
516
|
+
}
|
|
517
|
+
if (!moved.owner || moved.owner.token !== owner.token) {
|
|
518
|
+
return releaseFailure('ownership-lost-after-rename', 'E_LOCK_OWNERSHIP_LOST');
|
|
519
|
+
}
|
|
520
|
+
fs.unlinkSync(releaseOwnerPath); ownerRemoved = true;
|
|
521
|
+
}
|
|
522
|
+
fs.rmdirSync(releasePath); return releaseSuccess();
|
|
523
|
+
}
|
|
524
|
+
catch (e) {
|
|
525
|
+
if (e.code === 'ENOENT') {
|
|
526
|
+
return quarantined && ownerRemoved ? releaseSuccess() : releaseFailure('ownership-lost', 'E_LOCK_OWNERSHIP_LOST');
|
|
527
|
+
}
|
|
528
|
+
if (e.code === 'ENOTEMPTY' || e.code === 'EEXIST') {
|
|
529
|
+
if (!quarantined || !ownerRemoved) {
|
|
530
|
+
_sleepSyncMs(10 + i * 10);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
// Windows/Node 18에서는 마지막 owner를 unlink한 직후 **실제로 빈 디렉터리**에도
|
|
534
|
+
// rmdirSync가 순간적으로 ENOTEMPTY를 반환한다(로컬 1/3, CI 재현). 이를 곧바로
|
|
535
|
+
// 성공 처리하면 tombstone이 남는다. 빈 디렉터리/자기 deletion-pending owner만 재시도한다.
|
|
536
|
+
let remaining = null;
|
|
537
|
+
try { remaining = fs.readdirSync(releasePath); }
|
|
538
|
+
catch (readErr) {
|
|
539
|
+
if (readErr && readErr.code === 'ENOENT') return releaseSuccess();
|
|
540
|
+
_sleepSyncMs(10 + i * 10);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const ownName = path.basename(releaseOwnerPath);
|
|
544
|
+
if (remaining.some(name => name !== ownName)) {
|
|
545
|
+
return releaseFailure('release-tombstone-contaminated', 'E_LOCK_RELEASE');
|
|
546
|
+
}
|
|
547
|
+
_sleepSyncMs(10 + i * 10);
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
_sleepSyncMs(10 + i * 10);
|
|
551
|
+
}
|
|
239
552
|
}
|
|
553
|
+
return releaseFailure('release-failed', 'E_LOCK_RELEASE');
|
|
554
|
+
};
|
|
555
|
+
const releaseAndRethrow = e => {
|
|
556
|
+
const releaseErr = release();
|
|
557
|
+
if (releaseErr && e && typeof e === 'object') e.lockReleaseError = releaseErr.code;
|
|
558
|
+
throw e;
|
|
240
559
|
};
|
|
241
560
|
let out;
|
|
242
561
|
try { out = fn(); }
|
|
243
|
-
catch (e) {
|
|
244
|
-
|
|
245
|
-
|
|
562
|
+
catch (e) { return releaseAndRethrow(e); }
|
|
563
|
+
let then = null;
|
|
564
|
+
try { then = out && out.then; }
|
|
565
|
+
catch (e) { return releaseAndRethrow(e); }
|
|
566
|
+
if (typeof then === 'function') {
|
|
567
|
+
// Promise/A+의 first-settlement 규칙을 비표준 thenable에도 적용한다. 여러 번 resolve/reject
|
|
568
|
+
// 하거나 resolve 뒤 던져도 첫 결과만 락 수명과 반환/예외를 결정해야 한다.
|
|
569
|
+
let settlement = null;
|
|
570
|
+
const onFulfilled = v => {
|
|
571
|
+
if (settlement) return settlement.kind === 'fulfilled' ? settlement.value : undefined;
|
|
572
|
+
settlement = { kind: 'fulfilled', value: v, error: null };
|
|
573
|
+
const releaseErr = release();
|
|
574
|
+
if (releaseErr) {
|
|
575
|
+
settlement.kind = 'release-failed'; settlement.error = releaseErr; throw releaseErr;
|
|
576
|
+
}
|
|
577
|
+
return v;
|
|
578
|
+
};
|
|
579
|
+
const onRejected = e => {
|
|
580
|
+
if (settlement) return undefined;
|
|
581
|
+
settlement = { kind: 'rejected', value: undefined, error: e };
|
|
582
|
+
return releaseAndRethrow(e);
|
|
583
|
+
};
|
|
584
|
+
let thenResult;
|
|
585
|
+
try {
|
|
586
|
+
thenResult = then.call(out, onFulfilled, onRejected);
|
|
587
|
+
} catch (e) {
|
|
588
|
+
if (!settlement) {
|
|
589
|
+
// 콜백을 타이머에 예약한 뒤 동기 throw하는 비준수 thenable도 있다. throw를 첫
|
|
590
|
+
// settlement로 기록해야 늦은 콜백이 해제를 다시 호출하거나 결과를 뒤집지 않는다.
|
|
591
|
+
settlement = { kind: 'rejected', value: undefined, error: e };
|
|
592
|
+
return releaseAndRethrow(e);
|
|
593
|
+
}
|
|
594
|
+
if (settlement.kind === 'fulfilled') return settlement.value;
|
|
595
|
+
throw settlement.error;
|
|
596
|
+
}
|
|
597
|
+
if (settlement && settlement.kind !== 'fulfilled') throw settlement.error;
|
|
598
|
+
return thenResult;
|
|
246
599
|
}
|
|
247
|
-
release();
|
|
600
|
+
const releaseErr = release();
|
|
601
|
+
if (releaseErr) throw releaseErr;
|
|
248
602
|
return out;
|
|
249
603
|
}
|
|
250
604
|
// 1.9.327 (UR-0025): _getLocalTz / _formatLocal → lib/pure-utils.js 로 이동 (순수 TZ/날짜 포맷, require 사용).
|
|
@@ -317,7 +671,7 @@ const _VALUE_FLAGS = new Set(['--language','--skills','--path','--status','--pro
|
|
|
317
671
|
for (const _bf of _BRIEF_FIELDS) { if (_bf && _bf.flag) _VALUE_FLAGS.add('--' + _bf.flag); }
|
|
318
672
|
// 부울 플래그(값 없음) — 오타 경고의 대조군. 여기 있거나 _VALUE_FLAGS 에 있으면 '실재하는 플래그' 로 본다.
|
|
319
673
|
// 판정에만 쓰이고 파싱 동작에는 관여하지 않으므로, 넓게 잡아도 안전하다(과다 포함 = 경고 억제).
|
|
320
|
-
const _BOOL_FLAGS = new Set(['--ai','--all','--all-apps','--baseline','--all-presets','--allow-empty','--allow-unrelated','--apply','--audit','--auto','--auto-apply-delivered','--auto-cleanup-branches','--auto-fix','--auto-fix-bom','--auto-fix-encoding','--auto-main-push','--auto-recover','--auto-track','--banner','--bench','--build','--bundle-only','--claims','--close','--compact','--detect','--dry-run','--dry-run-npm','--embedding','--enforce','--fail-on-candidates','--fail-on-violation','--force','--gh-pages','--gh-release','--git-push','--global','--guide','--help','--html','--include-code','--interactive','--json','--last','--lenient','--major','--minimal','--minor','--no-animate','--no-auto-roadmap','--no-auto-update','--no-banner','--no-brainstorm-hits','--no-cache','--no-context-inject','--no-crawl','--no-drift-check','--no-enforce','--no-env','--no-env-detect','--no-feature-impact','--no-headline','--no-init-check','--no-interactive-select','--no-lazy-warn','--no-lessons','--no-longterm-recall','--no-mcp','--no-mem-delta','--no-multiagent-hint','--no-next-actions','--no-npm','--no-official-skills','--no-on-every-change','--no-pre-wake','--no-preserve-link','--no-readme-sync','--no-record','--no-repl','--no-review','--no-save','--no-security-check','--no-security-summary','--no-setup-agents','--no-skill-suggest','--no-stale-check','--no-suggest','--no-synonyms','--no-team-reminders','--no-wakeup-miss','--no-workflow-guide','--no-write','--npm-publish','--offline','--on-every-change','--pack','--parent-migrate','--publish-npm','--pulse','--quiet','--record','--refresh','--remove','--repair','--repl','--require-evidence','--run-tests','--skeleton','--skip-verify','--strict','--strict-claims','--strict-elements','--strict-exit','--suggest','--version','--yes',
|
|
674
|
+
const _BOOL_FLAGS = new Set(['--ai','--all','--all-apps','--baseline','--all-presets','--allow-empty','--allow-unrelated','--apply','--audit','--auto','--auto-apply-delivered','--auto-cleanup-branches','--auto-fix','--auto-fix-bom','--auto-fix-encoding','--auto-main-push','--auto-recover','--auto-track','--banner','--bench','--build','--bundle-only','--claims','--close','--compact','--detect','--dry-run','--dry-run-npm','--embedding','--enforce','--fail-on-candidates','--fail-on-violation','--force','--gh-pages','--gh-release','--git-push','--global','--guide','--help','--html','--include-code','--interactive','--json','--last','--lenient','--major','--minimal','--minor','--no-animate','--no-auto-roadmap','--no-auto-update','--no-banner','--no-brainstorm-hits','--no-cache','--no-context-inject','--no-crawl','--no-drift-check','--no-enforce','--no-env','--no-env-detect','--no-feature-impact','--no-headline','--no-init-check','--no-interactive-select','--no-lazy-warn','--no-lessons','--no-longterm-recall','--no-mcp','--no-mem-delta','--no-multiagent-hint','--no-next-actions','--no-npm','--no-official-skills','--no-on-every-change','--no-pre-wake','--no-preserve-link','--no-readme-sync','--no-record','--no-repl','--no-review','--no-save','--no-security-check','--no-security-summary','--no-setup-agents','--no-skill-suggest','--no-stale-check','--no-suggest','--no-synonyms','--no-team-reminders','--no-wakeup-miss','--no-workflow-guide','--no-write','--npm-publish','--offline','--on-every-change','--pack','--parent-migrate','--patch','--publish-npm','--pulse','--quiet','--record','--refresh','--remove','--repair','--repl','--require-evidence','--run-tests','--skeleton','--skip-verify','--strict','--strict-claims','--strict-elements','--strict-exit','--suggest','--version','--writeback','--yes',
|
|
321
675
|
// 소스에 리터럴로만 존재해 소비 형태를 기계 추출하지 못한 것들 — 실재하는 플래그이므로 오타로 오인하면 안 된다
|
|
322
676
|
'--commands','--decision','--errors','--expand-all','--fix','--list','--notes','--print','--raw','--select-all','--skip-git-repo-check','--tests','--verbose','--yolo',
|
|
323
677
|
// lib/*.js 의 부울 소비자 — bin/ 만 훑었다면 `agents route --confirm` 같은 실재 문법에 오탐 경고를 냈을 것이다
|
|
@@ -426,14 +780,14 @@ function ask(question) {
|
|
|
426
780
|
}
|
|
427
781
|
|
|
428
782
|
// 1.9.140: managedReadmeBlock 자동 풍부화 — 매 release pack 마다 명령/MCP/성능/가이드 갱신
|
|
429
|
-
function managedReadmeBlock(project, lang = 'ko') {
|
|
783
|
+
function managedReadmeBlock(project, lang = 'ko', version = VERSION) {
|
|
430
784
|
// 1.36.63 (F-05 5회차): en 프로젝트 README 관리 섹션 완전 영어 — 계약 동등(정체성/5계층/명령/CRUD/MCP/빠른시작/planning files)
|
|
431
785
|
if (lang === 'en') {
|
|
432
786
|
return [
|
|
433
787
|
README_START,
|
|
434
788
|
'## Leerness Project Harness',
|
|
435
789
|
'',
|
|
436
|
-
`This project uses the Leerness v${
|
|
790
|
+
`This project uses the Leerness v${version} harness. AI agents must load context with \`leerness handoff\` before working, and run \`leerness check\` / \`leerness audit\` / \`leerness session close\` afterwards.`,
|
|
437
791
|
'',
|
|
438
792
|
'### Identity — an operating layer for AI agents',
|
|
439
793
|
'',
|
|
@@ -489,7 +843,7 @@ function managedReadmeBlock(project, lang = 'ko') {
|
|
|
489
843
|
'',
|
|
490
844
|
'### MCP server (external AI integration)',
|
|
491
845
|
'',
|
|
492
|
-
`Leerness v${
|
|
846
|
+
`Leerness v${version} ships a stdio JSON-RPC MCP server — exposing **${_mcpToolCount()} tools** to Claude Code · Cursor · Codex CLI and others (\`leerness mcp serve\`; \`--profile core\` = 20 essentials).`,
|
|
493
847
|
'',
|
|
494
848
|
'### Quick start',
|
|
495
849
|
'',
|
|
@@ -508,7 +862,7 @@ function managedReadmeBlock(project, lang = 'ko') {
|
|
|
508
862
|
'- `.harness/session-handoff.md`: next-session handoff (auto-written)',
|
|
509
863
|
'- `.harness/lessons.md` / `decisions.md` / `rules.md`: permanent memory (5 surfaces)',
|
|
510
864
|
'',
|
|
511
|
-
`Last synced by Leerness v${
|
|
865
|
+
`Last synced by Leerness v${version}: ${today()}`,
|
|
512
866
|
README_END,
|
|
513
867
|
''
|
|
514
868
|
].join('\n');
|
|
@@ -517,7 +871,7 @@ function managedReadmeBlock(project, lang = 'ko') {
|
|
|
517
871
|
README_START,
|
|
518
872
|
'## Leerness Project Harness',
|
|
519
873
|
'',
|
|
520
|
-
`이 프로젝트는 Leerness v${
|
|
874
|
+
`이 프로젝트는 Leerness v${version} 하네스를 사용합니다. AI 에이전트는 작업 전 \`leerness handoff\`로 컨텍스트를 적재하고, 작업 후 \`leerness check\`/\`leerness audit\`/\`leerness session close\`를 수행해야 합니다.`,
|
|
521
875
|
'',
|
|
522
876
|
'### 정체성 — AI 에이전트 운영 레이어 (UR-0030)',
|
|
523
877
|
'',
|
|
@@ -571,7 +925,7 @@ function managedReadmeBlock(project, lang = 'ko') {
|
|
|
571
925
|
'',
|
|
572
926
|
'### MCP server (외부 AI 통합)',
|
|
573
927
|
'',
|
|
574
|
-
`Leerness v${
|
|
928
|
+
`Leerness v${version}는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **${_mcpToolCount()}개 도구**를 노출:`,
|
|
575
929
|
'',
|
|
576
930
|
'```jsonc',
|
|
577
931
|
'// 카테고리별',
|
|
@@ -593,7 +947,7 @@ function managedReadmeBlock(project, lang = 'ko') {
|
|
|
593
947
|
'`<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:',
|
|
594
948
|
'1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.',
|
|
595
949
|
'',
|
|
596
|
-
`현재 누적: **v1.9.x → ${
|
|
950
|
+
`현재 누적: **v1.9.x → ${version} 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.`,
|
|
597
951
|
'',
|
|
598
952
|
'### 성능 가이드',
|
|
599
953
|
'',
|
|
@@ -631,7 +985,7 @@ function managedReadmeBlock(project, lang = 'ko') {
|
|
|
631
985
|
'- `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)',
|
|
632
986
|
'- `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)',
|
|
633
987
|
'',
|
|
634
|
-
`Last synced by Leerness v${
|
|
988
|
+
`Last synced by Leerness v${version}: ${today()}`,
|
|
635
989
|
README_END,
|
|
636
990
|
''
|
|
637
991
|
].join('\n');
|
|
@@ -968,6 +1322,17 @@ leerness memory restore <surface> <target> # archive → active 복귀 (DELETE
|
|
|
968
1322
|
_files['.github/copilot-instructions.md'] = `${MARK}\n# Copilot Instructions\n\nUse AGENTS.md and .harness/ as project memory.\nDo not remove protected Leerness files.\nBefore completion, ensure plan.md, progress-tracker.md, current-state.md, session-handoff.md are updated.\n`;
|
|
969
1323
|
_files['.harness/session-workflow.md'] = fm('session-workflow', ['session start', 'new user request arrives', 'before distributing complex work'], ['workflow step changes'], `# Session Workflow — 6 steps of AI harness engineering\n\n> **At the start of every session the main agent reads this document first and follows the 6 steps as written.**\n> Same flow regardless of round length/complexity — that is what prevents drift.\n\n## Step 1. Analyze the request + check the environment\n\`\`\`bash\nleerness handoff . # load context + automatic drift warning\nleerness drift check . # signals + 4-level verdict\n\`\`\`\n- Decompose the request (5W1H). If ambiguous, ask clarifying questions (except autonomous mode).\n- On drift critical, run \`leerness session close .\` or \`drift check --auto-fix\` first.\n\n## Step 2. Plan\n- 3+ steps → use TodoWrite or \`leerness plan add\`.\n- New capability → search existing assets first via \`leerness reuse-map\` / \`reuse find <query>\`.\n- Multiple modules → define an integration spec up front (e.g. TICK_SPEC.md).\n\n## Step 3. Distribute — sub-agent mapping\n\`\`\`bash\nleerness agents list # ready CLIs\nleerness agents quota # limits\nleerness agents dispatch "<task>" --to <id>\n\`\`\`\n- Best sub-agent by task type: text/translation/analysis → claude · deep code reasoning → codex · direct file edits → agy --yolo · security review → \`leerness review --persona security\`.\n- **Conflict-prevention rules (mandatory)**: give each sub-agent *the exact files only it may modify*; require mtime-verification reports (concurrent writes risk last-writer-wins); verify against the spec afterwards with \`leerness contract verify\`.\n\n## Step 4. Sub-agent work + individual self-verification\n- Each sub-agent reports only after passing its own module tests.\n- Report format: line counts, tests N/N PASS, issues found, mtime verification.\n\n## Step 5. Integrated verification\n\`\`\`bash\nleerness contract verify SPEC.md src/<mod>.js\nleerness verify-claim T-XXX --run-tests --strict-claims\nleerness review <file> --persona security,performance,ux\n\`\`\`\n- The main agent writes and runs its own integration scenario (independent verification).\n- Cross-check that sub-agent verification and main verification *agree*.\n\n## Step 6. Close + handoff + next-round suggestion\n\`\`\`bash\nleerness session close . # --suggest on by default\nleerness session close . --no-suggest\n\n# or separately:\nleerness skill suggest .\nleerness drift check .\nleerness audit . --fix\n\`\`\`\n\n## 🧠 Memory CRUD Quick Reference\n\n| Surface | CREATE | READ | DELETE | RESTORE |\n|---|---|---|---|---|\n| **tasks** | task add | task list --json | task drop | task update |\n| **decisions** | decision add | decision list --json | decision drop | memory restore decisions |\n| **lessons** | lesson save | lesson list [--tag] | lesson drop | memory restore lessons |\n| **plan** | plan add | plan list --json | plan remove | memory restore plan |\n| **rules** | rule add | rule list --json | rule remove | (rule pause/resume) |\n\n\`\`\`bash\nleerness memory status [--json]\nleerness memory archive list [--surface s]\nleerness memory restore <surface> <target>\n\`\`\`\n\n**Recovering a wrongly saved item**:\n1. \`memory archive list\` — list restore candidates\n2. \`memory restore decisions "PostgreSQL"\` — archive → active\n3. handoff automatically notes archive activity within 24h every session\n\n## Auto-recovery & security\n- A skipped session close triggers drift critical at the next session start — recover with \`drift check --auto-fix\` (runs session close automatically on critical).\n- handoff auto-recalls past lessons (by current task keywords) and auto-suggests installed skills.\n- handoff prints a 1–2 line security summary (.env ↔ .env.example sync + .gitignore secret gaps); a missing .env in .gitignore is 🚨 CRITICAL — \`LEERNESS_AUTO_SECURITY_FIX=1\` runs \`audit --fix\` automatically.\n- \`leerness health\` gives a one-line combined check (drift + security + skills + usage + tasks).\n\n---\n\n## Quick checklist (before the session ends)\n- [ ] This round's tasks are registered in plan/progress-tracker (or task sync)\n- [ ] Every done item has evidence attached (verify-claim PASS)\n- [ ] contract verify PASS when sub-agents were used\n- [ ] drift score ≤ 30 — \`leerness drift check\`\n- [ ] session close was run\n- [ ] \`leerness health\` overall check\n- [ ] If .env is used: .gitignore secret patterns OK + .env.example synced\n- [ ] On security critical: \`LEERNESS_AUTO_SECURITY_FIX=1\` or \`audit --fix\`\n\n## Anti-patterns (drift signals)\n- ⚠ "work is done, just report and stop" → missed session close → drift critical next session\n- ⚠ "only TodoWrite updated, leerness unused" → \`task sync --from\` or \`task add\` is mandatory\n- ⚠ distributing to sub-agents without explicit file paths → concurrent-write conflicts\n- ⚠ self-reported "tests ran, PASS" only → verify-claim --run-tests not executed\n- ⚠ skipping contract verify → spec-mismatch bugs reach the user\n`, 'en');
|
|
970
1324
|
}
|
|
1325
|
+
// T-0136: handoff is a tracked-read boundary. The localized templates predate that contract,
|
|
1326
|
+
// so normalize the one stale sentence after language selection from a single place.
|
|
1327
|
+
_files['AGENTS.md'] = _files['AGENTS.md']
|
|
1328
|
+
.replace(
|
|
1329
|
+
'동적 상태(결정/교훈/계획/진행/검증/인수인계)는 leerness 가 **기본 워크스페이스 `.harness/`** 에 기록한다 (decisions.md / lessons.md / plan.md / progress-tracker.md / session-handoff.md). `leerness handoff` · `decision add` · `lesson save` 등이 여기에 쓴다.',
|
|
1330
|
+
'동적 상태(결정/교훈/계획/진행/검증/인수인계)는 leerness 가 **기본 워크스페이스 `.harness/`** 에 기록한다 (decisions.md / lessons.md / plan.md / progress-tracker.md / session-handoff.md). 기본 `leerness handoff`는 이를 읽고 ignored 세션 runtime record만 갱신하며, 추적 상태 갱신은 `decision add` · `lesson save` · `session close` 같은 명시 쓰기 명령 또는 `handoff --writeback`이 수행한다.'
|
|
1331
|
+
)
|
|
1332
|
+
.replace(
|
|
1333
|
+
'Dynamic state (decisions/lessons/plan/progress/verification/handoff) is recorded by leerness in the **default workspace `.harness/`** (decisions.md / lessons.md / plan.md / progress-tracker.md / session-handoff.md) via `leerness handoff`, `decision add`, `lesson save`, etc.',
|
|
1334
|
+
'Dynamic state (decisions/lessons/plan/progress/verification/handoff) is recorded by leerness in the **default workspace `.harness/`** (decisions.md / lessons.md / plan.md / progress-tracker.md / session-handoff.md). Default `leerness handoff` reads it and updates only an ignored per-session runtime record; tracked state changes come from explicit write commands such as `decision add`, `lesson save`, `session close`, or `handoff --writeback`.'
|
|
1335
|
+
);
|
|
971
1336
|
// 1.9.276: minimal 모드 — 코어가 요구하지 않는 파일 제외 (verify 필수 파일은 유지).
|
|
972
1337
|
if (opts.minimal) { for (const k of MINIMAL_SKIP_KEYS) delete _files[k]; }
|
|
973
1338
|
// 1.36.105 (P-0015): mode=minimal 은 **AI 가 매 세션 읽는 지침 자체**를 줄인다.
|
|
@@ -1197,12 +1562,12 @@ function writeMigrationReport(root, backup, actions, opts = {}) {
|
|
|
1197
1562
|
writeUtf8(p, `# Leerness Migration Report\n\nVersion: ${VERSION}\nDate: ${now()}\nBackup: ${rel(root, backup.archiveDir)}\n${opts.fromV ? `Previous: ${opts.fromV}\n` : ''}${aiReadBlock}\n## Policy\n\n- Existing harness, skill, and instruction files are backed up before migration.\n- Project memory files are preserved by default.\n- Managed instruction files are merged with previous content instead of being blindly overwritten.\n- .env.example/.gitignore are line-merged only.\n\n## Backed Up Candidates\n\n${backup.candidates.map(x => '- ' + x).join('\n')}\n\n## File Actions\n\n| File | Action |\n|---|---|\n${rows}\n`);
|
|
1198
1563
|
}
|
|
1199
1564
|
|
|
1200
|
-
function syncReadme(root) {
|
|
1565
|
+
function syncReadme(root, versionOverride = null) {
|
|
1201
1566
|
const p = path.join(root, 'README.md');
|
|
1202
1567
|
const existing = exists(p) ? read(p) : '';
|
|
1203
1568
|
// 1.9.40: 자체 README도 동기화 — version 배지, e2e 카운트, package.json#version 일관성
|
|
1204
1569
|
const _rmLang = (() => { try { return read(path.join(root, '.harness', 'LANGUAGE')).trim().toLowerCase() === 'en' ? 'en' : 'ko'; } catch { return 'ko'; } })(); // 1.36.63 (F-05 5회차)
|
|
1205
|
-
let updated = mergeReadmeSection(existing, managedReadmeBlock(detectProjectName(root), _rmLang));
|
|
1570
|
+
let updated = mergeReadmeSection(existing, managedReadmeBlock(detectProjectName(root), _rmLang, versionOverride || VERSION));
|
|
1206
1571
|
try {
|
|
1207
1572
|
// package.json#version 또는 .harness/HARNESS_VERSION을 참조하여 README 배지 자동 갱신
|
|
1208
1573
|
const pkgPath = path.join(root, 'package.json');
|
|
@@ -1243,6 +1608,55 @@ function syncReadme(root) {
|
|
|
1243
1608
|
ok('README.md Leerness section synced');
|
|
1244
1609
|
}
|
|
1245
1610
|
|
|
1611
|
+
// release bump은 일반 프로젝트의 package.json만 바꾸는 기존 계약을 유지한다.
|
|
1612
|
+
// 다만 leerness 자체 패키지를 대상으로 할 땐 package/bin/README가 같은 출하 버전을 말해야 한다.
|
|
1613
|
+
function _releasePathIdentity(p) {
|
|
1614
|
+
let resolved = path.resolve(p);
|
|
1615
|
+
try { resolved = fs.realpathSync.native ? fs.realpathSync.native(resolved) : fs.realpathSync(resolved); } catch {}
|
|
1616
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
1617
|
+
}
|
|
1618
|
+
function _prepareOwnReleaseVersionSurfaces(root, version) {
|
|
1619
|
+
// Windows의 드라이브 문자/경로 대소문자와 junction을 같은 패키지로 본다.
|
|
1620
|
+
if (_releasePathIdentity(root) !== _releasePathIdentity(path.resolve(__dirname, '..'))) return null;
|
|
1621
|
+
const source = read(__filename);
|
|
1622
|
+
const declarations = source.match(/^const VERSION = '[^']+';$/gm) || [];
|
|
1623
|
+
if (declarations.length !== 1) throw new Error('bin/leerness.js VERSION 선언을 단일하게 찾지 못해 동기화를 중단했습니다');
|
|
1624
|
+
const updated = source.replace(/^const VERSION = '[^']+';$/m, `const VERSION = '${version}';`);
|
|
1625
|
+
if (updated === source) throw new Error('bin/leerness.js VERSION이 갱신되지 않아 동기화를 중단했습니다');
|
|
1626
|
+
const readmePath = path.join(root, 'README.md');
|
|
1627
|
+
if (exists(readmePath)) fs.readFileSync(readmePath, 'utf8'); // 쓰기 전에 모든 자체 출하 표면의 읽기 가능성 확인
|
|
1628
|
+
return { sourcePath: __filename, source, updated, readmePath };
|
|
1629
|
+
}
|
|
1630
|
+
function syncOwnReleaseVersionSurfaces(root, version, prepared = null) {
|
|
1631
|
+
const plan = prepared || _prepareOwnReleaseVersionSurfaces(root, version);
|
|
1632
|
+
if (!plan) return false;
|
|
1633
|
+
writeUtf8(plan.sourcePath, plan.updated);
|
|
1634
|
+
syncReadme(root, version);
|
|
1635
|
+
return true;
|
|
1636
|
+
}
|
|
1637
|
+
function _releaseFileSnapshot(file) {
|
|
1638
|
+
return { file, existed: exists(file), text: exists(file) ? fs.readFileSync(file, 'utf8') : null };
|
|
1639
|
+
}
|
|
1640
|
+
function _restoreReleaseSnapshots(snapshots) {
|
|
1641
|
+
const errors = [];
|
|
1642
|
+
for (const snap of snapshots.slice().reverse()) {
|
|
1643
|
+
try {
|
|
1644
|
+
if (snap.existed) writeUtf8(snap.file, snap.text);
|
|
1645
|
+
else if (exists(snap.file)) fs.unlinkSync(snap.file);
|
|
1646
|
+
} catch (e) {
|
|
1647
|
+
errors.push(`${rel(path.dirname(snap.file), snap.file)}:${String((e && (e.code || e.message)) || e).slice(0, 50)}`);
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
return errors;
|
|
1651
|
+
}
|
|
1652
|
+
function _runReleaseTransaction(snapshots, action) {
|
|
1653
|
+
try {
|
|
1654
|
+
return { ok: true, value: action(), rollbackErrors: [] };
|
|
1655
|
+
} catch (error) {
|
|
1656
|
+
return { ok: false, error, rollbackErrors: _restoreReleaseSnapshots(snapshots) };
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1246
1660
|
// 1.9.369 (UR-0025): 순수 코어 _parseSkillsValue (lib/pure-utils) + skillCatalog 주입 래퍼.
|
|
1247
1661
|
function parseSkillsValue(v) { return _parseSkillsValue(v, skillCatalog); }
|
|
1248
1662
|
|
|
@@ -3518,7 +3932,7 @@ function _selftestFixture(lang) {
|
|
|
3518
3932
|
|
|
3519
3933
|
// 1.9.258: leerness selftest — 설치된 leerness 바이너리의 코어 순수 함수 자가 검증.
|
|
3520
3934
|
// 1.9.255~257 에서 export 한 보안/정확성/인코딩-핵심 함수를 실제 호출해 무결성 확인.
|
|
3521
|
-
// 사용자/CI 가 "내 leerness 가 정상인가?" 를
|
|
3935
|
+
// 사용자/CI 가 "내 leerness 가 정상인가?" 를 검증 (행위 검사가 포함돼 환경에 따라 수십 초 소요).
|
|
3522
3936
|
// --json: 기계 판독. 실패 시 exit 1 (CI 친화).
|
|
3523
3937
|
function _selfTestCases() {
|
|
3524
3938
|
// 각 케이스: { name, run: () => boolean } — 순수 함수만 (파일/네트워크 부작용 0)
|
|
@@ -3537,6 +3951,16 @@ function _selfTestCases() {
|
|
|
3537
3951
|
{ name: '_unixPathBlock: 멱등 마커 + export', run: () => { const b = _unixPathBlock('/x/bin'); return b.includes('managed') && /export PATH="\$PATH:\/x\/bin"/.test(b); } },
|
|
3538
3952
|
{ name: '_shellGuardAnalyze: PS5.1 && → ps5-chain error', run: () => { const r = _shellGuardAnalyze('a && b', { shell: 'powershell', psVersion: '5' }); return r.issues.some(i => i.rule === 'ps5-chain' && i.severity === 'error'); } },
|
|
3539
3953
|
{ name: '_shellGuardAnalyze: bash && → 문제 없음', run: () => _shellGuardAnalyze('a && b', { shell: 'bash' }).issues.length === 0 },
|
|
3954
|
+
{ name: 'provider 실행 경로: Windows 8.3 tilde 허용 + 셸 메타문자 거부 (1.36.160)', run: () => {
|
|
3955
|
+
const shortScript = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\auth.js';
|
|
3956
|
+
const shortBin = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\codex.cmd';
|
|
3957
|
+
const shortPolicy = process.platform === 'win32'
|
|
3958
|
+
? (_argsSafe([shortScript]) && _binSafe(shortBin))
|
|
3959
|
+
: (!_argsSafe([shortScript]) && !_binSafe(shortBin));
|
|
3960
|
+
return shortPolicy && !_argsSafe(['~/bin/provider']) && !_binSafe('~root/bin/provider')
|
|
3961
|
+
&& !_argsSafe(['safe.js', 'x&whoami']) && !_argsSafe(['safe.js', 'x|whoami'])
|
|
3962
|
+
&& !_binSafe('cmd /c whoami') && !_binSafe('codex&whoami');
|
|
3963
|
+
} },
|
|
3540
3964
|
{ name: 'AGENT_SLASH_COMMANDS: claude/codex/agy/grok/copilot 5종 + 명령 보유', run: () => { const ids = Object.keys(AGENT_SLASH_COMMANDS); return ['claude', 'codex', 'agy', 'grok', 'copilot'].every(id => ids.includes(id) && AGENT_SLASH_COMMANDS[id].commands.length > 0); } },
|
|
3541
3965
|
{ name: '_agentSlashHint: grok 슬래시 요약 + copilot 하위명령 라벨', run: () => { const g = _agentSlashHint('.', 'grok'); const c = _agentSlashHint('.', 'copilot'); return !!g && g.count > 0 && /Grok/.test(g.summary) && !!c && c.invoke === 'subcommand' && /하위명령/.test(c.summary); } },
|
|
3542
3966
|
{ name: '_parseSlashFromHelp: 슬래시 검출 + 플래그 제외 (1.9.267)', run: () => { const r = _parseSlashFromHelp(' /help show help\n /model switch model\n --version print version\n', 'slash'); return r.length === 2 && r[0].cmd === '/help' && /show help/.test(r[0].desc) && !r.some(c => /version/.test(c.cmd)); } },
|
|
@@ -3572,7 +3996,7 @@ function _selfTestCases() {
|
|
|
3572
3996
|
{ name: 'shell 주입 표면 제거: fetchNpmLatest execFile+pkg검증 + runCommandSafe argList 인용 (UR-0040 외부리뷰 1.9.300)', run: () => { const src = read(__filename); const npmFix = /'view', pkg, 'version'/.test(src) && !/cp\.exec\(.npm view \$\{pkg\}/.test(src) && /패키지명 charset/.test(src) && !/cp\.execFile\('npm', \[[^\]]*\], \{ timeout: 12000, shell:/.test(src); const argFix = /argList\.map\(_shellQuoteArg\)\.join/.test(src); return npmFix && argFix && typeof _shellQuoteArg === 'function'; } },
|
|
3573
3997
|
{ name: 'MCP requiredTier 메타데이터 + 정책 minTier 게이트 (UR-0041 외부리뷰 1.9.301)', run: () => { const T = require('../lib/mcp-tools'); const allValid = T.length >= 81 && T.every(t => PERMISSION_TIERS.includes(t.requiredTier)); const get = n => (T.find(t => t.name === n) || {}).requiredTier; const classOk = get('leerness_state_record') === 'safe-write' && get('leerness_provider_add') === 'safe-write' && get('leerness_web') === 'network' && get('leerness_handoff') === 'safe-write' && get('leerness_audit') === 'safe-write' && get('leerness_task_export') === 'project-write'; const src = read(__filename); const gateOk = /_tierRank\(minTier\) > _tierRank\(required\)/.test(src) && /_policyEnforce\(targetPath, cliArgs\.join\(' '\), _toolDef/.test(src); return allValid && classOk && gateOk; } },
|
|
3574
3998
|
{ name: 'verify-claim git diff 시맨틱 교차검증: _gitChangedFiles/_claimFileInGit + strict FAIL 통합 (UR-0042 외부리뷰 1.9.302)', run: () => { const fnOk = typeof _gitChangedFiles === 'function' && typeof _claimFileInGit === 'function'; const matchOk = _claimFileInGit('src/api.js', new Set(['src/api.js'])) === true && _claimFileInGit('./src/api.js', new Set(['src/api.js'])) === true && _claimFileInGit('other.js', new Set(['src/api.js'])) === false && _claimFileInGit('x', null) === null; const src = read(__filename); const wired = /git diff 교차검증/.test(src) && /\|\| !gitClaimOk/.test(src) && /_gitChangedFiles\(root\)/.test(src); return fnOk && matchOk && wired; } },
|
|
3575
|
-
{ name: '_withLock/_updateRun: lost-update 락(
|
|
3999
|
+
{ name: '_withLock/_updateRun: lost-update 락(원자적 디렉터리+재진입) + 적용 (UR-0043 외부리뷰 1.9.303)', run: () => { const src = read(__filename); const fnOk = typeof _withLock === 'function' && typeof _sleepSyncMs === 'function' && typeof _updateRun === 'function'; const reentrant = /if \(_heldLocks\.has\(lockPath\)\) return fn\(\)/.test(src); const excl = /fs\.mkdirSync\(lockPath\)/.test(src) && src.includes('owner-${token}.json'); const applied = /const id = _withLock\(progressPath\(root\)/.test(src) && /_updateRun\(root, curId/.test(src); return fnOk && reentrant && excl && applied; } },
|
|
3576
4000
|
{ name: 'lib/analyzers: 분석/검증 함수 4종 모듈 단일출처 분리 (UR-0025 1.9.304)', run: () => { const m = require('../lib/analyzers'); return m._evidenceQuality === _evidenceQuality && m._shellGuardAnalyze === _shellGuardAnalyze && m._parseEvidenceStats === _parseEvidenceStats && m._claimFileInGit === _claimFileInGit && !/function _evidenceQuality\(evidence\) \{/.test(read(__filename)) && !/function _shellGuardAnalyze\(cmd, ctx\) \{/.test(read(__filename)); } },
|
|
3577
4001
|
{ name: '17th헌트: _claimFileInGit bare-basename 충돌 차단 + scan .json5/.jsonc 포함 (1.35.5)', run: () => { const a = require('../lib/analyzers'); const collisionFixed = a._claimFileInGit('src/test.js', new Set(['test.js'])) !== true; const forwardOk = a._claimFileInGit('test.js', new Set(['src/test.js'])) === true; const exactOk = a._claimFileInGit('src/a.js', new Set(['src/a.js'])) === true; const nestedReverseOk = a._claimFileInGit('x/src/a.js', new Set(['src/a.js'])) === true; const src = read(__filename); const json5Ext = src.includes("'.js" + "on5'") && src.includes("'.js" + "onc'"); return collisionFixed && forwardOk && exactOk && nestedReverseOk && json5Ext; } },
|
|
3578
4002
|
{ name: '18th 위임실증: _harnessBrief 계약 브리프(handoff/task/verify-claim/session close, backtick·달러-free) + dispatch 접두 와이어 + bench stdin ignore (1.35.6)', run: () => {
|
|
@@ -3971,24 +4395,31 @@ function _selfTestCases() {
|
|
|
3971
4395
|
const s = read(__filename);
|
|
3972
4396
|
const wired = typeof enforceCmd === 'function' && s.includes("cmd === 'enforce'");
|
|
3973
4397
|
// 훅 자기완결(구버전 전역 CLI 무의존 — 실측 함정): leerness 호출 없이 find -mmin 으로 판정
|
|
3974
|
-
const selfContained = s.includes('find "$
|
|
4398
|
+
const selfContained = s.includes('find "$SESSION_FILE" -mmin -"$WIN_MIN"') && s.includes('FRESH_HANDOFF') && s.includes('LEERNESS_ENFORCE_BYPASS');
|
|
4399
|
+
const worktreeRooted = s.includes('LRN_TOP=$(git rev-parse --show-toplevel 2>/dev/null) || {')
|
|
4400
|
+
&& s.includes('LRN_TOP=$(CDPATH= cd "$LRN_TOP" 2>/dev/null && pwd -P) || {')
|
|
4401
|
+
&& s.includes('LRN_DIR="$LRN_TOP/$LRN_REL"')
|
|
4402
|
+
&& s.includes('fs.realpathSync.native || fs.realpathSync');
|
|
3975
4403
|
const chainOk = s.includes('pre-commit.pre-leerness');
|
|
3976
4404
|
const initAuto = s.includes("!has('--no-enforce') && process.env.LEERNESS_NO_ENFORCE !== '1'");
|
|
3977
4405
|
// 행위: 임시 git repo — install→(handoff 無)차단 메시지, mtime 신선→통과 (git 없으면 skip-통과)
|
|
3978
4406
|
let behavOk = true;
|
|
4407
|
+
let tmp = null;
|
|
3979
4408
|
try {
|
|
3980
|
-
|
|
4409
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_enf_'));
|
|
3981
4410
|
const g = _gitSpawn(['-C', tmp, 'init'], { encoding: 'utf8', timeout: 10000 });
|
|
3982
4411
|
if (g.status === 0) {
|
|
3983
4412
|
fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true });
|
|
3984
4413
|
const save = process.argv; const _w = process.stdout.write;
|
|
3985
4414
|
try { process.argv = ['node', 'h', 'enforce', 'install']; process.stdout.write = () => true; enforceCmd(tmp, 'install'); } finally { process.stdout.write = _w; process.argv = save; }
|
|
3986
4415
|
const hk = fs.readFileSync(path.join(tmp, '.git', 'hooks', 'pre-commit'), 'utf8');
|
|
3987
|
-
behavOk = hk.includes(_ENFORCE_MARK) && hk.includes('find "$
|
|
4416
|
+
behavOk = hk.includes(_ENFORCE_MARK) && hk.includes('find "$SESSION_FILE"') && hk.includes('FRESH_HANDOFF')
|
|
4417
|
+
&& hk.includes('git rev-parse --show-toplevel') && hk.includes('pwd -P')
|
|
4418
|
+
&& hk.includes('LRN_DIR="$LRN_TOP/$LRN_REL"');
|
|
3988
4419
|
}
|
|
3989
|
-
fs.rmSync(tmp, { recursive: true, force: true });
|
|
3990
4420
|
} catch { behavOk = false; }
|
|
3991
|
-
|
|
4421
|
+
finally { if (tmp) { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } }
|
|
4422
|
+
return wired && selfContained && worktreeRooted && chainOk && initAuto && behavOk;
|
|
3992
4423
|
} },
|
|
3993
4424
|
{ name: 'enforce 하드닝 (1.36.44, 적대적 자체헌트): .harness 無 체크아웃 FP 방지 + worktree 훅경로 + --no-verify 사후감지 audit + 한계 정직 (소스가드)', run: () => {
|
|
3994
4425
|
const s = read(__filename);
|
|
@@ -4124,16 +4555,18 @@ function _selfTestCases() {
|
|
|
4124
4555
|
const _selfE = _selfI < 0 ? -1 : s.indexOf(String.fromCharCode(10) + 'function ', _selfI + 10);
|
|
4125
4556
|
const _prod = (_selfI < 0 || _selfE < 0) ? '' : s.slice(0, _selfI) + s.slice(_selfE);
|
|
4126
4557
|
const _gateStart = _prod.indexOf("if (!has('--no-record') && process.env.LEERNESS_HOOK !== '1') {");
|
|
4127
|
-
const _gateStmt = _gateStart < 0 ? '' : _prod.slice(_gateStart, _prod.indexOf(
|
|
4558
|
+
const _gateStmt = _gateStart < 0 ? '' : _prod.slice(_gateStart, _prod.indexOf('// 1.9.203:', _gateStart));
|
|
4128
4559
|
const zeroWrite = _prod.length > 0 && _gateStart > 0
|
|
4129
|
-
&& _gateStmt.includes('_recordLastHandoff(root)')
|
|
4130
4560
|
&& _gateStmt.includes("_sessionPresenceRecord(root, 'open')")
|
|
4561
|
+
&& _gateStmt.includes('_recordHandoffFreshness(root)')
|
|
4562
|
+
&& /has\('--writeback'\)[\s\S]*?_recordLastHandoff\(root\)/.test(_gateStmt)
|
|
4131
4563
|
// ⚠ 정의 줄(`function _recordLastHandoff(root) {`)까지 세면 2가 된다 — **호출만** 센다.
|
|
4132
|
-
&& (_prod.match(/_recordLastHandoff\(root\)
|
|
4564
|
+
&& (_prod.match(/_recordLastHandoff\(root\)/g) || []).length === 2
|
|
4565
|
+
&& (_prod.match(/_recordHandoffFreshness\(root\)/g) || []).length === 2
|
|
4133
4566
|
&& (_prod.match(/_sessionPresenceRecord\(root, 'open'\)/g) || []).length === 1;
|
|
4134
4567
|
// split-literal: 앵커를 통짜 문자열로 두면 이 selftest 줄 자신이 먼저 매칭돼 43자짜리 자기 슬라이스를 잡는다(자기참조 트랩, UR-0009 계열).
|
|
4135
4568
|
const hookBody = s.slice(s.indexOf('function ' + 'hookSessionStartCmd'), s.indexOf('function ' + 'handoffCmd'));
|
|
4136
|
-
const noHandoffCall = hookBody.length > 100 && !/handoffCmd\(|_recordLastHandoff\(|writeUtf8\(|mkdirp\(/.test(hookBody); // 쓰기/handoff 호출 0
|
|
4569
|
+
const noHandoffCall = hookBody.length > 100 && !/handoffCmd\(|_recordLastHandoff\(|_recordHandoffFreshness\(|writeUtf8\(|mkdirp\(/.test(hookBody); // 쓰기/handoff 호출 0
|
|
4137
4570
|
const jsonContract = hookBody.includes("hookEventName: 'SessionStart'") && hookBody.includes('additionalContext');
|
|
4138
4571
|
const optOut = hookBody.includes("has('--no-context-inject')") && hookBody.includes("LEERNESS_NO_CONTEXT_INJECT");
|
|
4139
4572
|
const selfLabel = hookBody.includes('위는 요약'); // 1.9.272 투명성: 요약임을 자기 라벨링
|
|
@@ -4324,7 +4757,7 @@ function _selfTestCases() {
|
|
|
4324
4757
|
{ name: 'UR-0073 Phase B: _composeTeamPlan dry-run 실행 계획 (멤버별 dispatch, 실행 없음) 행위 (1.9.372)', run: () => { const m = require('../lib/pure-utils'); if (typeof _composeTeamPlan !== 'function' || m._composeTeamPlan !== _composeTeamPlan) return false; const team = { id: 'rev', name: 'R', purpose: 'PR 리뷰', personas: ['security', 'perf'], members: ['claude', 'codex'], schedule: 'manual' }; const p1 = _composeTeamPlan(team, '점검'); const ok1 = p1.steps.length === 2 && p1.task === '점검' && p1.steps[0].member === 'claude' && p1.steps[0].suggestedCommand.includes('agents dispatch') && p1.steps[0].suggestedCommand.includes('--to claude') && p1.steps[0].dispatchPrompt.includes('security'); const p2 = _composeTeamPlan(team, null); const ok2 = p2.task === 'PR 리뷰'; const p3 = _composeTeamPlan({ id: 'e', personas: [], members: [] }, 'x'); const ok3 = p3.steps.length === 0 && p3.memberCount === 0; return ok1 && ok2 && ok3; } },
|
|
4325
4758
|
{ name: 'UR-0073 Phase C: _teamHandoffReminders 스케줄 알림 (비-manual·active 만, 실행 트리거 아님) 행위 (1.9.373) + i18n en(1.31.3)', run: () => { const m = require('../lib/pure-utils'); if (typeof _teamHandoffReminders !== 'function' || m._teamHandoffReminders !== _teamHandoffReminders) return false; const r = _teamHandoffReminders([{ id: 'rev', schedule: 'every-session', status: 'active', members: ['a', 'b'] }, { id: 'man', schedule: 'manual', status: 'active' }, { id: 'paused', schedule: 'daily', status: 'paused' }]); const behaviorOk = r.length === 1 && r[0].includes('rev') && r[0].includes('every-session') && r[0].includes('team preview rev') && !r.join('|').includes('man') && !r.join('|').includes('paused'); const _H = /[가-힣]/; const en = _teamHandoffReminders([{ id: 'rev', schedule: 'every-session', status: 'active', members: ['a', 'b'], review: true }], 'en')[0] || ''; const ko = _teamHandoffReminders([{ id: 'rev', schedule: 'every-session', status: 'active', members: ['a', 'b'], review: true }])[0] || ''; const i18nOk = !_H.test(en) && /2 members/.test(en) && /review needed/.test(en) && /preview:/.test(en) && _H.test(ko) && /2명/.test(ko) && /검수필요/.test(ko); return behaviorOk && i18nOk; } },
|
|
4326
4759
|
{ name: 'UR-0074: _cadenceAssessment 릴리스 빈도 평가 (임계값) 행위 (1.9.374)', run: () => { const m = require('../lib/pure-utils'); if (typeof _cadenceAssessment !== 'function' || m._cadenceAssessment !== _cadenceAssessment || typeof releaseCadenceCmd !== 'function') return false; return _cadenceAssessment(7, 1, 1).level === 'very-high' && _cadenceAssessment(3, 1, 1).level === 'high' && _cadenceAssessment(1, 1, 1).level === 'moderate' && _cadenceAssessment(0.2, 1, 1).level === 'healthy' && _cadenceAssessment(7, 1, 1).recommendation.length > 0; } },
|
|
4327
|
-
{ name: 'UR-0084
|
|
4760
|
+
{ name: 'UR-0084 + 1.36.160: _withLock 35s 대기 + 토큰 소유권 + owner fail-closed 행위', run: () => _p0160LockOwnershipOk() },
|
|
4328
4761
|
{ name: 'UR-0073 Phase D: _teamDeployGate 이중 게이트 (dry-run 기본/env 게이트/실행) 행위 (1.9.376)', run: () => { const m = require('../lib/pure-utils'); if (typeof _teamDeployGate !== 'function' || m._teamDeployGate !== _teamDeployGate) return false; const team = { id: 'd', deployCommand: 'echo hi' }; const noCmd = _teamDeployGate({ id: 'x' }, { yes: true, envOn: true }).mode === 'no-command'; const dry = _teamDeployGate(team, { yes: false, envOn: true }).mode === 'dry-run'; const gated = _teamDeployGate(team, { yes: true, envOn: false }).mode === 'gated'; const exec = _teamDeployGate(team, { yes: true, envOn: true }).mode === 'execute'; return noCmd && dry && gated && exec; } },
|
|
4329
4762
|
{ name: 'UR-0025: _renderWorkspaceReferenceGuide 모듈 분리 + 빌더 동작 (1.9.377)', run: () => { const m = require('../lib/pure-utils'); if (typeof _renderWorkspaceReferenceGuide !== 'function' || m._renderWorkspaceReferenceGuide !== _renderWorkspaceReferenceGuide) return false; const g = _renderWorkspaceReferenceGuide('.leerness', '9.9.9', '2026-01-01T00:00:00.000Z'); const wrapperThin = read(__filename).includes('return _renderWorkspaceReferenceGuide(dirName, VERSION, new Date().toISOString())'); /* 1.36.126 (T-0107): 제목 리터럴('마이그레이션 안내')을 붙잡던 자리 — 그 절이 바로 **AI 를 죽은 사본으로 안내**하고 있었다. 제목이 아니라 계약을 건다: ① `.leerness` 상황을 다루는 절이 여전히 있고 ② 그 절이 "우선 사용/가야 함" 같은 **지시**를 하지 않는다(생성 결과 문자열에 대한 행위 단언 — 소스 자기참조가 아니다). */ const guideSafe = /\.leerness/.test(g) && !/우선 사용/.test(g) && !/로 가야 함/.test(g) && /읽지 마십시오/.test(g); return g.includes('.leerness/progress-tracker.md') && g.includes('9.9.9') && g.includes('자주 묻는 위치') && guideSafe && wrapperThin; } },
|
|
4330
4763
|
{ name: 'UR-0073: team MCP 도구 2종(read-only) 정의 + dispatch 와이어 (1.9.378)', run: () => { const tools = require('../lib/mcp-tools'); const src = read(__filename); const tl = tools.find(t => t.name === 'leerness_team_list'); const tp = tools.find(t => t.name === 'leerness_team_preview'); const defsOk = tl && tl.requiredTier === 'read-only' && tp && tp.requiredTier === 'read-only' && tp.inputSchema.required && tp.inputSchema.required.includes('id'); const wired = src.includes("case " + "'leerness_team_list':") && src.includes("case " + "'leerness_team_preview':") && /cliArgs = \['team', 'list'/.test(src) && /cliArgs = \['team', 'preview'/.test(src); return !!defsOk && wired; } },
|
|
@@ -5884,15 +6317,20 @@ function _selfTestCases() {
|
|
|
5884
6317
|
const hd = path.join(dir, '.harness'); fs.mkdirSync(hd);
|
|
5885
6318
|
const lhf = path.join(hd, 'last-handoff.json');
|
|
5886
6319
|
const iso = (msAgo) => new Date(Date.now() - msAgo).toISOString();
|
|
6320
|
+
const stampLegacy = (msAgo, history = [iso(msAgo)]) => {
|
|
6321
|
+
const at = new Date(Date.now() - msAgo);
|
|
6322
|
+
fs.writeFileSync(lhf, JSON.stringify({ last: at.toISOString(), history }));
|
|
6323
|
+
fs.utimesSync(lhf, at, at); // enforce hook과 같은 canonical clock
|
|
6324
|
+
};
|
|
5887
6325
|
try {
|
|
5888
6326
|
const noneOk = _handoffNudgeState(dir) === null; // 부재 → 넛지 없음(신규 프로젝트 FP 방지)
|
|
5889
|
-
|
|
6327
|
+
stampLegacy(3 * 3600 * 1000);
|
|
5890
6328
|
const st = _handoffNudgeState(dir);
|
|
5891
6329
|
const staleOk = !!st && st.gapMin >= 120; // 3h 오래됨 → 넛지
|
|
5892
|
-
|
|
6330
|
+
stampLegacy(5 * 60 * 1000);
|
|
5893
6331
|
const freshOk = _handoffNudgeState(dir) === null; // 5min 최근 → 없음
|
|
5894
6332
|
process.env.LEERNESS_NO_HANDOFF_NUDGE = '1';
|
|
5895
|
-
|
|
6333
|
+
stampLegacy(3 * 3600 * 1000, []);
|
|
5896
6334
|
const optOutOk = _handoffNudgeState(dir) === null; // opt-out 존중
|
|
5897
6335
|
delete process.env.LEERNESS_NO_HANDOFF_NUDGE;
|
|
5898
6336
|
return noneOk && staleOk && freshOk && optOutOk;
|
|
@@ -6557,10 +6995,24 @@ function _selfTestCases() {
|
|
|
6557
6995
|
uiOk = uiOk && tp.refreshTechProfile(nextApp).changed === true
|
|
6558
6996
|
&& (JSON.parse(read(path.join(nextApp, '.harness', 'tech-profile.json'))).current.ui || []).some(u => u.id === 'tailwind');
|
|
6559
6997
|
} catch { uiOk = false; } finally { try { fs.rmSync(tmp3, { recursive: true, force: true }); } catch {} }
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
|
|
6563
|
-
|
|
6998
|
+
// 이 회귀 묶음은 서로 무관한 23개 조건을 한 boolean 으로 접어 두어, CI flake가 나면
|
|
6999
|
+
// `err: null` 하나만 남고 어느 조건이 실패했는지 알 수 없었다. 기존과 같은 순서/첫 실패
|
|
7000
|
+
// short-circuit를 유지하되 이름을 붙여 진단 가능하게 한다.
|
|
7001
|
+
const checks = [
|
|
7002
|
+
['clarify-signals', () => clar], ['preview-shape', () => shape], ['database-url', () => noDbUrl],
|
|
7003
|
+
['requirements-prefix', () => reqOk], ['source-guards', () => guards], ['ui-profile', () => uiOk],
|
|
7004
|
+
['library', _p0013LibraryOk], ['current-state', _p0088CurrentStatePreserved],
|
|
7005
|
+
['command-surface', _p0101SurfaceOk], ['skill-id', _p0101SkillIdOk], ['path-strict', _p0101PathStrictOk],
|
|
7006
|
+
['honesty-102', _p0102HonestyOk], ['value-flags', _p0103FlagsOk], ['json-contract', _p0103JsonOk],
|
|
7007
|
+
['root-routing', _p0103RootOk], ['usage-attribution', _p0104UsageOk],
|
|
7008
|
+
['secret-baseline', _p0014SecretBaselineOk], ['reminder-preservation', _p0104ReminderOk],
|
|
7009
|
+
['mode', _p0015ModeOk], ['async-lock', _p0097AsyncLockOk], ['claim-gate', _p0109ClaimGateOk],
|
|
7010
|
+
['honesty-110', _p0110HonestyOk], ['carry', _p0112CarryOk],
|
|
7011
|
+
];
|
|
7012
|
+
for (const [name, check] of checks) {
|
|
7013
|
+
if (!check()) throw new Error(`failed check: ${name}`);
|
|
7014
|
+
}
|
|
7015
|
+
return true;
|
|
6564
7016
|
} },
|
|
6565
7017
|
{ name: '시크릿 스캐너 F-06 (1.36.56, 외부감사): 무명 확장자 소형 텍스트 스캔(이진 NUL 제외) + 같은 토큰 중복 보고 dedupe — 행위검사', run: () => {
|
|
6566
7018
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sc56_'));
|
|
@@ -6769,7 +7221,8 @@ function _selfTestCases() {
|
|
|
6769
7221
|
// ② 경로 탈옥 — 정상 5종은 통과, 탈출 6종은 전부 거부(한쪽만 재면 '전부 거부'가 통과한다)
|
|
6770
7222
|
const okKeys = ['f3d070f6-f51d-4d44-becd-0026daa0c538', 'a'.repeat(64), 'abc-123_x', '12345678', 'A'.repeat(8)];
|
|
6771
7223
|
if (!okKeys.every(k => SP.isValidKey(k))) return false;
|
|
6772
|
-
const badKeys = ['..', '../../x', 'a/b', 'a\\b', 'C:\\x', '', 'short', 'a'.repeat(65)
|
|
7224
|
+
const badKeys = ['..', '../../x', 'a/b', 'a\\b', 'C:\\x', '', 'short', 'a'.repeat(65),
|
|
7225
|
+
'unaddressed', 'UNADDRESSED', 'leerness-internal-probe-0001', 'LEERNESS-INTERNAL-PROBE-0001'];
|
|
6773
7226
|
if (badKeys.some(k => SP.isValidKey(k))) return false;
|
|
6774
7227
|
// ③ 최외곽 라벨 — CLAUDECODE 우선. **금지 신호로는 절대 판정하지 않는다**(실측: 이 저장소의 Claude 세션에
|
|
6775
7228
|
// CODEX_COMPANION_SESSION_ID 가 실재한다 — 그걸 근거로 삼으면 즉발 오라벨).
|
|
@@ -6785,10 +7238,15 @@ function _selfTestCases() {
|
|
|
6785
7238
|
if (_cur.agent !== 'unknown' || !_cur.evidence.includes('CURSOR_AGENT')) return false;
|
|
6786
7239
|
// ④ 억제 — 각 사유가 실제로 발화하고, 정상 조건에서는 null(대조군)
|
|
6787
7240
|
const base = { CLAUDE_CODE_SESSION_ID: 'abcdefgh' };
|
|
7241
|
+
const inheritedChild = { LEERNESS_SESSION_ID: 'addressed-child-01', CLAUDE_CODE_CHILD_SESSION: '1' };
|
|
6788
7242
|
if (SP.suppressionReason(base, true) !== null) return false;
|
|
6789
7243
|
if (SP.suppressionReason(Object.assign({}, base, { LEERNESS_INTERNAL: '1' }), true) !== 'internal') return false;
|
|
6790
7244
|
if (SP.suppressionReason(Object.assign({}, base, { LEERNESS_HOOK: '1' }), true) !== 'hook') return false;
|
|
6791
7245
|
if (SP.suppressionReason(Object.assign({}, base, { CLAUDE_CODE_CHILD_SESSION: '1' }), true) !== 'child-agent') return false;
|
|
7246
|
+
// 상태 귀속은 MCP가 호출별 명시 주소를 사용할 수 있지만, presence는 부모 주소를 상속한 child를
|
|
7247
|
+
// 독립 세션으로 오인하지 않는다.
|
|
7248
|
+
if (SP.deriveSessionKey(inheritedChild) !== 'addressed-child-01'
|
|
7249
|
+
|| SP.suppressionReason(inheritedChild, true) !== 'child-agent') return false;
|
|
6792
7250
|
if (SP.suppressionReason(Object.assign({}, base, { CI: '1' }), true) !== 'ci') return false;
|
|
6793
7251
|
// (검수 P2, 실측 재현) `CI="false"`/`"0"` 은 **억제가 아니다** — 명시적으로 아니라고 말한 환경이
|
|
6794
7252
|
// 조용히 등록되지 않던 결함. 참/거짓 양쪽을 다 건다(한쪽만 재면 '전부 억제'가 통과한다).
|
|
@@ -7200,6 +7658,22 @@ function _enforceCfg(root) {
|
|
|
7200
7658
|
try { if (exists(_enforceCfgPath(root))) return Object.assign(def, JSON.parse(read(_enforceCfgPath(root)))); } catch {}
|
|
7201
7659
|
return def;
|
|
7202
7660
|
}
|
|
7661
|
+
// Git 훅은 아직 만들어지지 않은 파일을 가리킬 수 있다. 그 파일에 full-path realpath 를 쓰면 ENOENT가 되어
|
|
7662
|
+
// 조상 junction/symlink를 놓친다. 가장 깊은 기존 조상만 실제 위치로 풀고, 없는 꼬리를 다시 붙여 비교 키를 만든다.
|
|
7663
|
+
// Windows는 파일 시스템이 대소문자를 구별하지 않으므로 같은 방식으로 접는다.
|
|
7664
|
+
function _gitPathIdentity(p0) {
|
|
7665
|
+
let head = path.resolve(p0);
|
|
7666
|
+
const tail = [];
|
|
7667
|
+
for (let i = 0; i < 64; i++) {
|
|
7668
|
+
try { head = fs.realpathSync.native ? fs.realpathSync.native(head) : fs.realpathSync(head); break; } catch {}
|
|
7669
|
+
const parent = path.dirname(head);
|
|
7670
|
+
if (!parent || parent === head) break;
|
|
7671
|
+
tail.unshift(path.basename(head));
|
|
7672
|
+
head = parent;
|
|
7673
|
+
}
|
|
7674
|
+
const resolved = path.resolve(head, ...tail);
|
|
7675
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
7676
|
+
}
|
|
7203
7677
|
function _gitHookPath(root) {
|
|
7204
7678
|
// 1.36.44 (하드닝): worktree 에선 .git 이 파일 — 훅은 공용 디렉토리에 산다. git 에게 물어 해석(양쪽 다 정답).
|
|
7205
7679
|
// 1.36.134 (검수 실측): `--git-common-dir` 는 **`core.hooksPath` 를 모른다** — 그 설정이 있는 저장소에서
|
|
@@ -7222,6 +7696,19 @@ function _gitHookPath(root) {
|
|
|
7222
7696
|
} catch {}
|
|
7223
7697
|
return path.join(root, '.git', 'hooks', 'pre-commit');
|
|
7224
7698
|
}
|
|
7699
|
+
function _gitShellCommand(root) {
|
|
7700
|
+
if (process.platform !== 'win32') return 'sh';
|
|
7701
|
+
try {
|
|
7702
|
+
const r = _gitSpawn(['-C', root, '--exec-path'], { encoding: 'utf8', timeout: 5000 });
|
|
7703
|
+
if (r.status === 0 && String(r.stdout || '').trim()) {
|
|
7704
|
+
const installRoot = path.resolve(String(r.stdout).trim(), '..', '..', '..');
|
|
7705
|
+
for (const candidate of [path.join(installRoot, 'bin', 'sh.exe'), path.join(installRoot, 'usr', 'bin', 'sh.exe')]) {
|
|
7706
|
+
if (exists(candidate)) return candidate;
|
|
7707
|
+
}
|
|
7708
|
+
}
|
|
7709
|
+
} catch {}
|
|
7710
|
+
return 'sh';
|
|
7711
|
+
}
|
|
7225
7712
|
function enforceCmd(root, sub) {
|
|
7226
7713
|
// 1.36.134 (실측): 이 함수의 사람 모드 출력이 스코프에 없는 흐림 헬퍼를 불러 **정상 저장소에서도 exit 1** 로 죽었다
|
|
7227
7714
|
// (`enforce status` → `✗ dm is not defined`). e2e 가 `--json` 만 재서 사람 경로의 크래시가 안 보였다.
|
|
@@ -7257,24 +7744,9 @@ function enforceCmd(root, sub) {
|
|
|
7257
7744
|
// · `.git` 안의 junction/symlink 가 바깥 공유 디렉토리를 가리켜도 "안" 이라 읽어 미탐(실측).
|
|
7258
7745
|
// · Windows 는 경로 대소문자를 안 가리는데 비교는 가려서, `.GIT/hooks` 가 "밖" 이 돼 오탐(실측).
|
|
7259
7746
|
// 실제 위치(realpath)로, 그리고 그 플랫폼이 쓰는 방식으로 비교한다.
|
|
7260
|
-
// ⚠ 없는 경로면 realpath 가 그대로 되돌려줘 **링크를 따라가지 못한다**(아직 없는 훅 디렉토리 등).
|
|
7261
|
-
// 존재하는 **가장 깊은 조상**까지 풀고 나머지를 다시 붙인다.
|
|
7262
|
-
const _real = (p0) => {
|
|
7263
|
-
let head = path.resolve(p0);
|
|
7264
|
-
const tail = [];
|
|
7265
|
-
for (let i = 0; i < 64; i++) {
|
|
7266
|
-
try { head = fs.realpathSync.native ? fs.realpathSync.native(head) : fs.realpathSync(head); break; } catch {}
|
|
7267
|
-
const parent = path.dirname(head);
|
|
7268
|
-
if (!parent || parent === head) break;
|
|
7269
|
-
tail.unshift(path.basename(head));
|
|
7270
|
-
head = parent;
|
|
7271
|
-
}
|
|
7272
|
-
const r = path.resolve(head, ...tail);
|
|
7273
|
-
return process.platform === 'win32' ? r.toLowerCase() : r;
|
|
7274
|
-
};
|
|
7275
7747
|
// 훅 파일 자체는 아직 없을 수 있으므로 **디렉토리**로 비교한다.
|
|
7276
|
-
const hookDir =
|
|
7277
|
-
const commonDir =
|
|
7748
|
+
const hookDir = _gitPathIdentity(path.dirname(hookP));
|
|
7749
|
+
const commonDir = _gitPathIdentity(abs);
|
|
7278
7750
|
return !(hookDir === commonDir || hookDir.startsWith(commonDir + path.sep));
|
|
7279
7751
|
} catch { return null; }
|
|
7280
7752
|
})();
|
|
@@ -7293,12 +7765,8 @@ function enforceCmd(root, sub) {
|
|
|
7293
7765
|
.map((l) => l.slice('worktree '.length).trim())
|
|
7294
7766
|
.filter(Boolean);
|
|
7295
7767
|
if (dirs.length <= 1) return dirs.length || null;
|
|
7296
|
-
|
|
7297
|
-
|
|
7298
|
-
try { r = fs.realpathSync.native ? fs.realpathSync.native(q) : fs.realpathSync(q); } catch {}
|
|
7299
|
-
r = path.resolve(r);
|
|
7300
|
-
return process.platform === 'win32' ? r.toLowerCase() : r;
|
|
7301
|
-
};
|
|
7768
|
+
// 아직 없는 pre-commit 파일도 상위 junction까지 같은 실제 위치로 풀어 비교한다.
|
|
7769
|
+
const norm = _gitPathIdentity;
|
|
7302
7770
|
const mine = norm(hookP);
|
|
7303
7771
|
let sharing = 0;
|
|
7304
7772
|
// ⚠ 1.36.144 (재검수 P1): "하나라도 모르면 null" 로 했더니 **사라진(prunable) worktree 하나**가
|
|
@@ -7406,7 +7874,8 @@ function enforceCmd(root, sub) {
|
|
|
7406
7874
|
// 종전엔 체인 백업을 이미 덮어쓴 뒤에 `unsafe_path` 로 반환해 **백업이 파괴된 채로 남았다**.
|
|
7407
7875
|
// 거절할 입력이면 아무것도 건드리기 전에 거절한다.
|
|
7408
7876
|
// 훅은 자기완결 sh — 설치된 leerness 버전/네트워크/PATH 에 무의존(구버전 전역 CLI 가 enforce 를 몰라 오차단하던 실측 함정 회피).
|
|
7409
|
-
// 핵심 체크 =
|
|
7877
|
+
// 핵심 체크 = **handoff 때만** 갱신되는 ignored freshness marker mtime 이 window 안인지
|
|
7878
|
+
// (주소가 없는 셸은 최신 marker fallback). presence record 는 session close 도 갱신하므로 근거로 쓰지 않는다.
|
|
7410
7879
|
// 1.36.134: git 이 훅을 부를 때의 작업 디렉토리 = toplevel. harness 위치를 그 기준 상대경로로 고정한다.
|
|
7411
7880
|
let topLevel = root;
|
|
7412
7881
|
{
|
|
@@ -7417,8 +7886,13 @@ function enforceCmd(root, sub) {
|
|
|
7417
7886
|
// `../…` 를 만들어 그것이 영구 훅에 박혔다(실측: `LRN_DIR='../__container/alias/.harness'`).
|
|
7418
7887
|
// 양쪽을 realpath 로 펴서 비교하고, 그래도 밖이면 상대경로로 표현하지 않고 **절대경로**를 쓴다.
|
|
7419
7888
|
let _hDir = path.join(root, '.harness'), _top = topLevel;
|
|
7420
|
-
|
|
7421
|
-
|
|
7889
|
+
// Node의 JS realpathSync는 Windows 8.3 별칭(`RUNNER~1`)을 그대로 둘 수 있지만 Git은
|
|
7890
|
+
// --show-toplevel에서 긴 경로를 돌려준다. 같은 디렉터리가 달라 보여 harnessRel이 `..`로
|
|
7891
|
+
// 시작하면 공유 worktree 훅에 main의 절대경로를 박는 fail-open이 된다. native realpath는
|
|
7892
|
+
// 두 입력을 같은 긴 경로로 정규화한다(Node 18+); 구형 런타임만 기존 구현으로 폴백한다.
|
|
7893
|
+
const _nativeRealpath = fs.realpathSync.native || fs.realpathSync;
|
|
7894
|
+
try { _hDir = _nativeRealpath(_hDir); } catch {}
|
|
7895
|
+
try { _top = _nativeRealpath(_top); } catch {}
|
|
7422
7896
|
let harnessRel = (path.relative(_top, _hDir) || '.harness').replace(/\\/g, '/');
|
|
7423
7897
|
if (harnessRel.startsWith('..')) harnessRel = _hDir.replace(/\\/g, '/');
|
|
7424
7898
|
// ⚠ 1.36.143 (재검수 P1): 이 거부가 **체인 백업 복사 뒤**에 있었다 — 거절하면서 백업을 파괴했다.
|
|
@@ -7441,6 +7915,21 @@ function enforceCmd(root, sub) {
|
|
|
7441
7915
|
// 둘 다 `unsafe_path` 로 막혔다. 한국어 환경에서 흔한 경로다. 거부가 아니라 **제대로 인용**하는 것이 답이다.
|
|
7442
7916
|
// POSIX sh 의 작은따옴표 안에서는 `'` 를 제외한 모든 문자가 리터럴이다 — `'` 만 표준 관용구로 탈출시킨다.
|
|
7443
7917
|
const shQuote = (v) => "'" + String(v).replace(/'/g, "'\''") + "'";
|
|
7918
|
+
// 공유 hooksPath의 같은 스크립트가 형제 worktree에서 실행될 때 설치한 main worktree의
|
|
7919
|
+
// cwd/상태를 빌려보면, main의 fresh handoff로 sibling 커밋이 조용히 통과한다. Git이 훅을
|
|
7920
|
+
// 어느 cwd에서 시작하든 현재 커밋 대상의 toplevel을 다시 물어 상대 harness 경로를 붙인다.
|
|
7921
|
+
// root 밖 harness는 기존처럼 설치 시점의 절대경로를 고정한다.
|
|
7922
|
+
const harnessSetup = path.isAbsolute(harnessRel) ? [
|
|
7923
|
+
`LRN_DIR=${shQuote(harnessRel)}`,
|
|
7924
|
+
] : [
|
|
7925
|
+
`LRN_REL=${shQuote(harnessRel)}`,
|
|
7926
|
+
'LRN_TOP=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "🔒 leerness 강제: 현재 git worktree 루트를 확인할 수 없어 커밋을 중단합니다" >&2; exit 1; }',
|
|
7927
|
+
// Git for Windows가 `C:/Users/RUNNER~1/...` 같은 DOS 별칭을 돌려줄 수 있다. Git의 sh에서
|
|
7928
|
+
// 이 문자열을 곧바로 `-d`에 넘기면 실제 디렉터리인데도 거짓이 되는 runner 조합이 있다.
|
|
7929
|
+
// subshell cd + pwd -P로 셸이 실제 접근한 물리 경로를 다시 받아 fail-open을 없앤다.
|
|
7930
|
+
'LRN_TOP=$(CDPATH= cd "$LRN_TOP" 2>/dev/null && pwd -P) || { echo "🔒 leerness 강제: 현재 git worktree 경로에 접근할 수 없어 커밋을 중단합니다" >&2; exit 1; }',
|
|
7931
|
+
'LRN_DIR="$LRN_TOP/$LRN_REL"',
|
|
7932
|
+
];
|
|
7444
7933
|
const hook = [
|
|
7445
7934
|
'#!/bin/sh',
|
|
7446
7935
|
_ENFORCE_MARK + ' — leerness 사용 강제 (제거: leerness enforce remove)',
|
|
@@ -7448,15 +7937,41 @@ function enforceCmd(root, sub) {
|
|
|
7448
7937
|
// 1.36.134 (검수 P1, 재현): 훅은 **git toplevel** 에서 돈다. `.harness` 가 하위 디렉토리에 있으면
|
|
7449
7938
|
// `-d ".harness"` 가 거짓이라 조용히 통과했다 — 설치는 `verified:'fired'` 인데 루트 커밋이 그냥 통과.
|
|
7450
7939
|
// 설치 시점에 **toplevel 기준 상대경로**를 훅에 박아 넣어, 커밋이 어디서 나든 같은 곳을 본다.
|
|
7451
|
-
|
|
7940
|
+
...harnessSetup,
|
|
7452
7941
|
'# 1.36.44: .harness 없는 체크아웃(워크트리/구브랜치)은 강제 대상 아님 — 오차단(FP) 방지',
|
|
7453
7942
|
'if [ ! -d "$LRN_DIR" ]; then',
|
|
7454
7943
|
' if [ -f "$(dirname "$0")/pre-commit.pre-leerness" ]; then exec sh "$(dirname "$0")/pre-commit.pre-leerness" "$@"; fi',
|
|
7455
7944
|
' exit 0',
|
|
7456
7945
|
'fi',
|
|
7457
|
-
`LH="$LRN_DIR/last-handoff.json"`,
|
|
7458
7946
|
`WIN_MIN=${windowHours * 60}`,
|
|
7459
|
-
|
|
7947
|
+
// 1.36.156 (외부 Codex P1): JS `deriveSessionKey` 와 같은 우선순위/검증을 쓴다.
|
|
7948
|
+
// "값이 있음"은 "유효한 주소"가 아니다. 잘못된 명시값은 유효한 fallback 을 가리면 안 되고,
|
|
7949
|
+
// 주소가 없을 때도 다른 세션 전체를 wildcard 로 훑지 않고 handoff 가 실제로 쓰는 unaddressed 슬롯만 본다.
|
|
7950
|
+
'normalize_session_key() {',
|
|
7951
|
+
' case "$1" in ""|*[!A-Za-z0-9_-]*) return 1 ;; esac',
|
|
7952
|
+
' [ "${#1}" -ge 8 ] && [ "${#1}" -le 64 ] || return 1',
|
|
7953
|
+
' NORMALIZED_KEY=$(printf "%s" "$1" | tr "[:upper:]" "[:lower:]")',
|
|
7954
|
+
' [ "$NORMALIZED_KEY" != "unaddressed" ] || return 1',
|
|
7955
|
+
' case "$NORMALIZED_KEY" in leerness-internal-*) return 1 ;; esac',
|
|
7956
|
+
' printf "%s" "$NORMALIZED_KEY"',
|
|
7957
|
+
'}',
|
|
7958
|
+
'SESSION_KEY=""',
|
|
7959
|
+
'SESSION_ADDRESSED=0',
|
|
7960
|
+
'if SESSION_KEY=$(normalize_session_key "$LEERNESS_SESSION_ID"); then',
|
|
7961
|
+
' SESSION_ADDRESSED=1',
|
|
7962
|
+
'elif [ "$CLAUDE_CODE_CHILD_SESSION" != "1" ]; then',
|
|
7963
|
+
' for SESSION_CANDIDATE in "$CLAUDE_CODE_HOST_SESSION_ID" "$CLAUDE_CODE_SESSION_ID" "$CODEX_THREAD_ID"; do',
|
|
7964
|
+
' if SESSION_KEY=$(normalize_session_key "$SESSION_CANDIDATE"); then SESSION_ADDRESSED=1; break; fi',
|
|
7965
|
+
' done',
|
|
7966
|
+
'fi',
|
|
7967
|
+
'if [ -z "$SESSION_KEY" ]; then SESSION_KEY=unaddressed; fi',
|
|
7968
|
+
'HANDOFF_DIR="$LRN_DIR/cache/handoffs"',
|
|
7969
|
+
'FRESH_HANDOFF=""',
|
|
7970
|
+
'SESSION_FILE="$HANDOFF_DIR/$SESSION_KEY.json"',
|
|
7971
|
+
'if [ -f "$SESSION_FILE" ]; then FRESH_HANDOFF=$(find "$SESSION_FILE" -mmin -"$WIN_MIN" -print -quit 2>/dev/null); fi',
|
|
7972
|
+
// legacy 전역 파일에는 세션 주소가 없다. 주소가 있는 세션의 증거로 쓰면 다른 세션 handoff를 빌려 통과한다.
|
|
7973
|
+
'if [ "$SESSION_ADDRESSED" = "0" ] && [ -z "$FRESH_HANDOFF" ] && [ -f "$LRN_DIR/last-handoff.json" ]; then FRESH_HANDOFF=$(find "$LRN_DIR/last-handoff.json" -mmin -"$WIN_MIN" -print -quit 2>/dev/null); fi',
|
|
7974
|
+
'if [ -z "$FRESH_HANDOFF" ]; then',
|
|
7460
7975
|
` echo "🔒 leerness 강제: 최근 ${windowHours}h 내 handoff 흔적 없음 — 커밋 전 실행: leerness handoff ." >&2`,
|
|
7461
7976
|
' echo " 긴급 우회(명시): LEERNESS_ENFORCE_BYPASS=1 git commit ..." >&2',
|
|
7462
7977
|
' exit 1',
|
|
@@ -7487,52 +8002,76 @@ function enforceCmd(root, sub) {
|
|
|
7487
8002
|
// handoff 흔적이 창 안이면 통과(0), 창 밖이면 차단(≠0) 이어야 한다. 둘이 같으면 그 훅은 죽은 파일이다.
|
|
7488
8003
|
// 검증 전용 사본: 체인 호출과 strict gate 줄만 걷어낸 같은 스크립트. 임시 디렉토리에 두고 끝나면 지운다.
|
|
7489
8004
|
// 출하되는 훅에는 검증 분기가 없으므로 사용자가 켤 수 있는 우회 스위치도 없다.
|
|
8005
|
+
// 실제 세션이 선택할 수 없는 예약 prefix + 호출별 난수 주소를 쓴다. 고정된 유효 주소는 동시 handoff와
|
|
8006
|
+
// 충돌할 수 있고, 설치 probe가 저장했던 과거 바이트로 복원하면서 방금 쓴 handoff를 지울 수 있다.
|
|
8007
|
+
const probeKey = `leerness-internal-probe-${require('crypto').randomBytes(12).toString('hex')}`;
|
|
7490
8008
|
const probeHookP = path.join(os.tmpdir(), `leerness-probe-${process.pid}-${Date.now()}.sh`);
|
|
7491
8009
|
try {
|
|
7492
|
-
|
|
8010
|
+
const probeHook = hook.split('\n')
|
|
8011
|
+
.filter(l => !/pre-commit\.pre-leerness|leerness gate \./.test(l))
|
|
8012
|
+
.join('\n')
|
|
8013
|
+
// 출하 훅에는 내부 우회 분기가 없다. 임시 사본만 예약 주소를 직접 가리킨다.
|
|
8014
|
+
.replace('if [ -z "$SESSION_KEY" ]; then SESSION_KEY=unaddressed; fi',
|
|
8015
|
+
`SESSION_KEY=${shQuote(probeKey)}\nSESSION_ADDRESSED=1`);
|
|
8016
|
+
writeUtf8(probeHookP, probeHook);
|
|
7493
8017
|
try { fs.chmodSync(probeHookP, 0o755); } catch {}
|
|
7494
8018
|
} catch { /* 못 만들면 아래 검증이 'skipped' 로 답한다 */ }
|
|
7495
8019
|
const _verifyHookFires = () => {
|
|
7496
|
-
|
|
7497
|
-
//
|
|
7498
|
-
//
|
|
7499
|
-
|
|
7500
|
-
|
|
7501
|
-
|
|
7502
|
-
if (exists(lhp)) { saved = read(lhp); const st = fs.statSync(lhp); savedTimes = [st.atime, st.mtime]; }
|
|
7503
|
-
} catch { return 'skipped'; }
|
|
8020
|
+
// 1.36.156 (외부 Codex P1): probe 가 검사하는 주소와 probe 가 찍는 marker 를 같게 한다.
|
|
8021
|
+
// legacy last-handoff.json 을 찍으면 설치 자체는 성공해도 실제 addressed 세션에는 marker 가 없어
|
|
8022
|
+
// 다음 커밋이 막힐 수 있었다. 합성 주소 전용 ignored marker 를 잠깐 만들고 원상복구한다.
|
|
8023
|
+
const lhp = _handoffFreshnessPath(root, probeKey);
|
|
8024
|
+
const handoffDir = _handoffFreshnessDir(root);
|
|
8025
|
+
const hadDir = exists(handoffDir);
|
|
7504
8026
|
const runHook = () => {
|
|
7505
8027
|
// PROBE=1 이면 훅이 자기 판정 직후 끝난다 — 사용자 기존 훅·strict gate 를 실행하지 않는다.
|
|
7506
8028
|
// ⚠ cwd 는 **git 이 훅을 부르는 곳**(toplevel)이어야 한다 — `root` 로 재면 하위 디렉토리 설치에서
|
|
7507
8029
|
// "발화한다" 고 잘못 답한다(실제로 그렇게 오답했고 루트 커밋이 통과했다).
|
|
7508
8030
|
// 체인·strict 를 뺀 **임시 사본**을 돌린다 — 사용자 기존 훅과 gate 는 실행되지 않는다(부작용 0 · 재귀 0).
|
|
7509
|
-
const r = cp.spawnSync(
|
|
7510
|
-
env: Object.assign({}, process.env, {
|
|
8031
|
+
const r = cp.spawnSync(_gitShellCommand(root), [probeHookP], { cwd: topLevel, encoding: 'utf8', timeout: 20000,
|
|
8032
|
+
env: Object.assign({}, process.env, {
|
|
8033
|
+
LEERNESS_ENFORCE_BYPASS: '', LEERNESS_SESSION_ID: '', CLAUDE_CODE_HOST_SESSION_ID: '',
|
|
8034
|
+
CLAUDE_CODE_SESSION_ID: '', CODEX_THREAD_ID: '', CLAUDE_CODE_CHILD_SESSION: '1'
|
|
8035
|
+
}) });
|
|
7511
8036
|
if (r.error) return null; // sh 가 없다 — 판정하지 않는다
|
|
7512
8037
|
return r.status;
|
|
7513
8038
|
};
|
|
8039
|
+
let result = 'skipped';
|
|
7514
8040
|
try {
|
|
7515
|
-
//
|
|
7516
|
-
|
|
7517
|
-
|
|
7518
|
-
|
|
7519
|
-
|
|
7520
|
-
|
|
7521
|
-
|
|
7522
|
-
|
|
7523
|
-
|
|
7524
|
-
|
|
7525
|
-
|
|
7526
|
-
|
|
7527
|
-
|
|
7528
|
-
|
|
8041
|
+
// 예약·난수 주소라 실제 handoff와 겹치지 않지만, 동시 설치까지 같은 marker lock 계약으로 보호한다.
|
|
8042
|
+
result = _withLock(lhp, () => {
|
|
8043
|
+
let saved = null, savedTimes = null;
|
|
8044
|
+
try {
|
|
8045
|
+
if (exists(lhp)) { saved = read(lhp); const st = fs.statSync(lhp); savedTimes = [st.atime, st.mtime]; }
|
|
8046
|
+
// ⚠ 훅은 JSON 내용이 아니라 **파일 mtime** 을 본다(`find -mmin`). 내용만 바꾸면 두 번 다 통과해
|
|
8047
|
+
// 정상 저장소까지 'not-fired' 로 오판한다(실제로 그렇게 만들었다가 과잉 차단을 냈다).
|
|
8048
|
+
const stamp = (ageH) => {
|
|
8049
|
+
const last = new Date(Date.now() - ageH * 3600000).toISOString();
|
|
8050
|
+
writeUtf8(lhp, JSON.stringify({ schemaVersion: 1, sessionKey: probeKey, last, history: [last] }, null, 2) + '\n');
|
|
8051
|
+
const t = new Date(Date.now() - ageH * 3600000);
|
|
8052
|
+
try { fs.utimesSync(lhp, t, t); } catch {}
|
|
8053
|
+
};
|
|
8054
|
+
stamp(0);
|
|
8055
|
+
const inWindow = runHook();
|
|
8056
|
+
stamp(windowHours + 24);
|
|
8057
|
+
const outWindow = runHook();
|
|
8058
|
+
if (inWindow === null || outWindow === null) return 'skipped';
|
|
8059
|
+
return (inWindow === 0 && outWindow !== 0) ? 'fired' : 'not-fired';
|
|
8060
|
+
} catch { return 'skipped'; }
|
|
8061
|
+
finally {
|
|
8062
|
+
try {
|
|
8063
|
+
if (saved === null) { if (exists(lhp)) fs.unlinkSync(lhp); }
|
|
8064
|
+
else { writeUtf8(lhp, saved); if (savedTimes) fs.utimesSync(lhp, savedTimes[0], savedTimes[1]); }
|
|
8065
|
+
} catch {}
|
|
8066
|
+
}
|
|
8067
|
+
});
|
|
8068
|
+
} catch { result = 'skipped'; }
|
|
7529
8069
|
finally {
|
|
7530
|
-
|
|
7531
|
-
|
|
7532
|
-
else { writeUtf8(lhp, saved); if (savedTimes) fs.utimesSync(lhp, savedTimes[0], savedTimes[1]); }
|
|
7533
|
-
} catch {}
|
|
8070
|
+
// _withLock가 lock 파일을 놓은 뒤에만 새 디렉토리를 정리할 수 있다.
|
|
8071
|
+
try { if (!hadDir && exists(handoffDir) && fs.readdirSync(handoffDir).length === 0) fs.rmdirSync(handoffDir); } catch {}
|
|
7534
8072
|
try { if (exists(probeHookP)) fs.unlinkSync(probeHookP); } catch {} // 임시 사본은 남기지 않는다
|
|
7535
8073
|
}
|
|
8074
|
+
return result;
|
|
7536
8075
|
};
|
|
7537
8076
|
const _fired = _verifyHookFires();
|
|
7538
8077
|
// 1.36.134 (검수 P1): `sh` 가 없어 **관측을 못 했는데** `ok:true` 로 답했다 — 이 라운드의 계약은
|
|
@@ -7540,7 +8079,7 @@ function enforceCmd(root, sub) {
|
|
|
7540
8079
|
// (opt-out: 관측 수단이 없는 환경에서 그래도 걸고 싶으면 `--skip-verify`.)
|
|
7541
8080
|
if (_fired === 'skipped' && !has('--skip-verify')) {
|
|
7542
8081
|
_rollbackHook();
|
|
7543
|
-
failJson(json, 'verify_unavailable', `훅 발화를 확인할 수 없습니다(
|
|
8082
|
+
failJson(json, 'verify_unavailable', `훅 발화를 확인할 수 없습니다(Git shell 사용 불가) — 관측하지 못한 강제를 성공이라 하지 않습니다. 그래도 설치하려면 --skip-verify` + _rbNote());
|
|
7544
8083
|
return;
|
|
7545
8084
|
}
|
|
7546
8085
|
if (_fired === 'not-fired') {
|
|
@@ -7589,9 +8128,10 @@ function enforceCmd(root, sub) {
|
|
|
7589
8128
|
}
|
|
7590
8129
|
}
|
|
7591
8130
|
const cfg = _enforceCfg(root);
|
|
7592
|
-
|
|
7593
|
-
|
|
7594
|
-
|
|
8131
|
+
// 1.36.155 (외부 Codex P1): hook 과 check 가 서로 다른 저장소를 읽으면 같은 handoff 를 두고
|
|
8132
|
+
// 하나는 통과, 하나는 차단한다. 둘 다 handoff-only marker → legacy 순서로 판정한다.
|
|
8133
|
+
const gap = _getLastHandoffGap(root);
|
|
8134
|
+
let lastAt = gap && gap.hasLast ? gap.lastAt : null;
|
|
7595
8135
|
// 1.36.49 (codex 6차 #2 High): 손상 타임스탬프 fail-closed — 종전엔 new Date('not-a-date') → NaN,
|
|
7596
8136
|
// NaN > windowHours 가 false 라서 손상 파일이 강제를 통과("handoff NaNh 전")했다.
|
|
7597
8137
|
const lastMs = lastAt ? new Date(lastAt).getTime() : NaN;
|
|
@@ -7618,7 +8158,7 @@ function enforceCmd(root, sub) {
|
|
|
7618
8158
|
if (!exists(path.join(root, '.harness'))) { failJson(json, 'harness_missing', `leerness 미설치: ${root}`); return; }
|
|
7619
8159
|
const cfg2 = _enforceCfg(root);
|
|
7620
8160
|
let handoffs = [];
|
|
7621
|
-
try { const j =
|
|
8161
|
+
try { const j = _getLastHandoffGap(root); handoffs = (j.history || []).map(t => new Date(t).getTime()).filter(Number.isFinite); } catch {}
|
|
7622
8162
|
const gl = _gitSpawn(['-C', root, 'log', '-n', '30', '--pretty=format:%H|%ct|%s'], { encoding: 'utf8', timeout: 10000 });
|
|
7623
8163
|
// 1.36.49 (codex 6차 #9): 커밋 0개(unborn HEAD)는 정상 저장소 — 실패가 아니라 "검사 대상 없음" 성공.
|
|
7624
8164
|
if (gl.status !== 0 && /does not have any commits|unknown revision|bad default revision|ambiguous argument/i.test(gl.stderr || '')) {
|
|
@@ -9048,28 +9588,536 @@ function _p0109ClaimGateOk() {
|
|
|
9048
9588
|
function _p0097AsyncLockOk() {
|
|
9049
9589
|
const os2 = require('os');
|
|
9050
9590
|
const arena = fs.mkdtempSync(path.join(os2.tmpdir(), 'leerness-p97lk-'));
|
|
9591
|
+
let phase = 'setup';
|
|
9051
9592
|
try {
|
|
9052
9593
|
const target = path.join(arena, 'store.json');
|
|
9053
9594
|
const lockFile = target + '.lock';
|
|
9595
|
+
const samePath = (a, b) => !!b && path.resolve(String(a)) === path.resolve(String(b));
|
|
9054
9596
|
fs.writeFileSync(target, '{}');
|
|
9055
9597
|
// ① 비동기 본체: 아직 정착하지 않은 동안 락이 살아 있어야 한다(조기 해제 = 보호 0).
|
|
9598
|
+
phase = 'thenable';
|
|
9056
9599
|
let heldWhilePending = null;
|
|
9057
9600
|
const thenable = { then(onOk) { heldWhilePending = fs.existsSync(lockFile); onOk('done'); return 'settled'; } };
|
|
9058
9601
|
const r1 = _withLock(target, () => thenable);
|
|
9059
9602
|
const releasedAfterSettle = !fs.existsSync(lockFile) && r1 === 'settled';
|
|
9603
|
+
if (heldWhilePending !== true || !releasedAfterSettle) throw new Error(`pending=${heldWhilePending}, released=${releasedAfterSettle}`);
|
|
9060
9604
|
// ② 대조군 — 동기 fn 은 종전대로 즉시 해제되고 값을 그대로 돌려준다(회귀로 락을 붙잡지 않았는지).
|
|
9605
|
+
phase = 'sync';
|
|
9061
9606
|
const r2 = _withLock(target, () => 42);
|
|
9062
9607
|
const syncReleased = !fs.existsSync(lockFile) && r2 === 42;
|
|
9608
|
+
if (!syncReleased) throw new Error(`released=${!fs.existsSync(lockFile)}, value=${r2}`);
|
|
9063
9609
|
// ③ 예외 경로도 해제되어야 한다 — 안 그러면 한 번 던진 뒤 그 스토어가 영구 잠긴다.
|
|
9610
|
+
phase = 'throw';
|
|
9064
9611
|
let threw = false;
|
|
9065
9612
|
try { _withLock(target, () => { throw new Error('x'); }); } catch { threw = true; }
|
|
9066
9613
|
const errReleased = threw && !fs.existsSync(lockFile);
|
|
9614
|
+
if (!errReleased) throw new Error(`threw=${threw}, released=${!fs.existsSync(lockFile)}`);
|
|
9067
9615
|
// ④ 거부된 thenable 도 해제 + 사유 전파.
|
|
9616
|
+
phase = 'reject';
|
|
9068
9617
|
let rejThrew = false;
|
|
9069
9618
|
const badThenable = { then(onOk, onErr) { onErr(new Error('boom')); return 'rejected'; } };
|
|
9070
9619
|
try { _withLock(target, () => badThenable); } catch { rejThrew = true; }
|
|
9071
9620
|
const rejReleased = rejThrew && !fs.existsSync(lockFile);
|
|
9072
|
-
|
|
9621
|
+
if (!rejReleased) throw new Error(`threw=${rejThrew}, released=${!fs.existsSync(lockFile)}`);
|
|
9622
|
+
|
|
9623
|
+
// ⑤ `then` getter/호출 자체가 동기 예외를 던져도, 정착 콜백에 도달하지 못했다는 이유로
|
|
9624
|
+
// 락을 남기면 안 된다. 정상 Promise 밖의 사용자 thenable도 같은 경계에서 정리한다.
|
|
9625
|
+
phase = 'throwing-thenable';
|
|
9626
|
+
let getterThrew = false, callThrew = false;
|
|
9627
|
+
const getterBoom = {};
|
|
9628
|
+
Object.defineProperty(getterBoom, 'then', { get() { throw new Error('then getter boom'); } });
|
|
9629
|
+
try { _withLock(target, () => getterBoom); } catch (e) { getterThrew = e && e.message === 'then getter boom'; }
|
|
9630
|
+
const getterReleased = !fs.existsSync(lockFile);
|
|
9631
|
+
try { _withLock(target, () => ({ then() { throw new Error('then call boom'); } })); }
|
|
9632
|
+
catch (e) { callThrew = e && e.message === 'then call boom'; }
|
|
9633
|
+
const callReleased = !fs.existsSync(lockFile);
|
|
9634
|
+
if (!getterThrew || !getterReleased || !callThrew || !callReleased) {
|
|
9635
|
+
throw new Error(`getter=${getterThrew}/${getterReleased}, call=${callThrew}/${callReleased}`);
|
|
9636
|
+
}
|
|
9637
|
+
|
|
9638
|
+
// ⑤-b 비준수 thenable의 중복 settlement도 첫 호출만 유효하다. 양방향 순서와 같은
|
|
9639
|
+
// callback 반복을 모두 강제해 두 번째 호출이 해제/결과를 덮지 못하게 한다.
|
|
9640
|
+
phase = 'thenable-first-settlement';
|
|
9641
|
+
const twiceResolved = _withLock(target, () => ({
|
|
9642
|
+
then(onOk) { const a = onOk(10); const b = onOk(20); return [a, b]; }
|
|
9643
|
+
}));
|
|
9644
|
+
let twiceRejectMessage = null;
|
|
9645
|
+
try {
|
|
9646
|
+
_withLock(target, () => ({ then(onOk, onErr) {
|
|
9647
|
+
try { onErr(new Error('reject-first')); } catch {}
|
|
9648
|
+
try { onErr(new Error('reject-second')); } catch {}
|
|
9649
|
+
return 'swallowed-rejects';
|
|
9650
|
+
} }));
|
|
9651
|
+
} catch (e) { twiceRejectMessage = e && e.message; }
|
|
9652
|
+
const resolveThenReject = _withLock(target, () => ({
|
|
9653
|
+
then(onOk, onErr) { onOk(30); onErr(new Error('late-reject')); return 'resolve-first'; }
|
|
9654
|
+
}));
|
|
9655
|
+
let rejectThenResolveMessage = null;
|
|
9656
|
+
try {
|
|
9657
|
+
_withLock(target, () => ({ then(onOk, onErr) {
|
|
9658
|
+
try { onErr(new Error('reject-wins')); } catch {}
|
|
9659
|
+
onOk(40); return 'late-resolve';
|
|
9660
|
+
} }));
|
|
9661
|
+
} catch (e) { rejectThenResolveMessage = e && e.message; }
|
|
9662
|
+
const firstSettlementOk = Array.isArray(twiceResolved) && twiceResolved[0] === 10 && twiceResolved[1] === 10
|
|
9663
|
+
&& twiceRejectMessage === 'reject-first' && resolveThenReject === 'resolve-first'
|
|
9664
|
+
&& rejectThenResolveMessage === 'reject-wins' && !fs.existsSync(lockFile);
|
|
9665
|
+
if (!firstSettlementOk) {
|
|
9666
|
+
throw new Error(`resolveTwice=${JSON.stringify(twiceResolved)}, rejectTwice=${twiceRejectMessage}, mixed=${resolveThenReject}/${rejectThenResolveMessage}`);
|
|
9667
|
+
}
|
|
9668
|
+
|
|
9669
|
+
// ⑤-c selftest 러너 자체는 동기이므로, 실제 pending native Promise는 격리 자식에서 await한다.
|
|
9670
|
+
// 소스를 메모리 모듈로 컴파일해 제품 export 표면을 넓히지 않고 _withLock 실구현을 그대로 잰다.
|
|
9671
|
+
phase = 'native-promise';
|
|
9672
|
+
const nativeProbeSource = `
|
|
9673
|
+
'use strict';
|
|
9674
|
+
const fs = require('fs'), path = require('path'), os = require('os'), Module = require('module');
|
|
9675
|
+
const filename = ${JSON.stringify(__filename)};
|
|
9676
|
+
const source = fs.readFileSync(filename, 'utf8').replace(/^#![^\\r\\n]*\\r?\\n/, '')
|
|
9677
|
+
+ '\\nmodule.exports.__nativeLockProbe = _withLock;\\n';
|
|
9678
|
+
const loaded = new Module(filename + '.native-promise-probe', module);
|
|
9679
|
+
loaded.filename = filename; loaded.paths = Module._nodeModulePaths(path.dirname(filename));
|
|
9680
|
+
loaded._compile(source, filename);
|
|
9681
|
+
const withLock = loaded.exports.__nativeLockProbe;
|
|
9682
|
+
(async () => {
|
|
9683
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-native-lock-'));
|
|
9684
|
+
const target = path.join(dir, 'store.json'), lock = target + '.lock';
|
|
9685
|
+
try {
|
|
9686
|
+
let fulfill;
|
|
9687
|
+
const pendingFulfill = new Promise(resolve => { fulfill = resolve; });
|
|
9688
|
+
const fulfilled = withLock(target, () => pendingFulfill);
|
|
9689
|
+
const fulfillHeld = fs.existsSync(lock);
|
|
9690
|
+
fulfill(41);
|
|
9691
|
+
const fulfillValue = await fulfilled;
|
|
9692
|
+
const fulfillReleased = !fs.existsSync(lock);
|
|
9693
|
+
|
|
9694
|
+
let reject;
|
|
9695
|
+
const pendingReject = new Promise((resolve, rejectFn) => { reject = rejectFn; });
|
|
9696
|
+
const rejected = withLock(target, () => pendingReject);
|
|
9697
|
+
const rejectHeld = fs.existsSync(lock);
|
|
9698
|
+
reject(new Error('native-reject'));
|
|
9699
|
+
let rejectMessage = null;
|
|
9700
|
+
try { await rejected; } catch (e) { rejectMessage = e && e.message; }
|
|
9701
|
+
const rejectReleased = !fs.existsSync(lock);
|
|
9702
|
+
|
|
9703
|
+
let lateThrowMessage = null, lateCallbackRan = false;
|
|
9704
|
+
let lateCallbackReturn = 'not-run', lateCallbackError = null;
|
|
9705
|
+
try {
|
|
9706
|
+
withLock(target, () => ({ then(onOk) {
|
|
9707
|
+
setTimeout(() => {
|
|
9708
|
+
lateCallbackRan = true;
|
|
9709
|
+
try { lateCallbackReturn = onOk(99); }
|
|
9710
|
+
catch (e) { lateCallbackError = e && e.message; }
|
|
9711
|
+
}, 5);
|
|
9712
|
+
throw new Error('sync-before-late-callback');
|
|
9713
|
+
} }));
|
|
9714
|
+
} catch (e) { lateThrowMessage = e && e.message; }
|
|
9715
|
+
const lateReleasedImmediately = !fs.existsSync(lock);
|
|
9716
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
9717
|
+
const lateStillReleased = !fs.existsSync(lock);
|
|
9718
|
+
const lateCallbackIgnored = lateCallbackRan && lateCallbackReturn === undefined && lateCallbackError === null;
|
|
9719
|
+
|
|
9720
|
+
const tombstones = fs.readdirSync(dir).filter(name => name.includes('.lock.release-'));
|
|
9721
|
+
process.stdout.write(JSON.stringify({
|
|
9722
|
+
ok: fulfillHeld && fulfillValue === 41 && fulfillReleased
|
|
9723
|
+
&& rejectHeld && rejectMessage === 'native-reject' && rejectReleased
|
|
9724
|
+
&& lateThrowMessage === 'sync-before-late-callback' && lateReleasedImmediately
|
|
9725
|
+
&& lateCallbackIgnored && lateStillReleased && tombstones.length === 0,
|
|
9726
|
+
fulfillHeld, fulfillValue, fulfillReleased, rejectHeld, rejectMessage, rejectReleased,
|
|
9727
|
+
lateThrowMessage, lateReleasedImmediately, lateCallbackRan, lateCallbackIgnored,
|
|
9728
|
+
lateCallbackError, lateStillReleased, tombstones
|
|
9729
|
+
}));
|
|
9730
|
+
} finally { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} }
|
|
9731
|
+
})().catch(e => { process.stdout.write(JSON.stringify({ ok: false, error: e && e.message })); process.exitCode = 1; });
|
|
9732
|
+
`;
|
|
9733
|
+
const nativeProbe = cp.spawnSync(process.execPath, ['-e', nativeProbeSource], {
|
|
9734
|
+
encoding: 'utf8', timeout: 30000, windowsHide: true,
|
|
9735
|
+
env: _probeEnv({ LEERNESS_NO_LOCK_WARN: '1' })
|
|
9736
|
+
});
|
|
9737
|
+
let nativeResult = null;
|
|
9738
|
+
try { nativeResult = JSON.parse(String(nativeProbe.stdout || '').trim()); } catch {}
|
|
9739
|
+
if (nativeProbe.status !== 0 || !nativeResult || nativeResult.ok !== true) {
|
|
9740
|
+
throw new Error(`status=${nativeProbe.status}, signal=${nativeProbe.signal || ''}, result=${JSON.stringify(nativeResult)}, stderr=${String(nativeProbe.stderr || '').slice(0, 200)}`);
|
|
9741
|
+
}
|
|
9742
|
+
|
|
9743
|
+
// ⑥ tombstone 이동 직후 owner 읽기가 Windows 공유 위반(EPERM)을 한 번 내도 소유권
|
|
9744
|
+
// 상실로 오분류하지 않고 같은 고유 경로에서 재시도한다.
|
|
9745
|
+
phase = 'moved-owner-read-retry';
|
|
9746
|
+
const originalReadFile = fs.readFileSync;
|
|
9747
|
+
let movedOwnerPath = null, movedReleasePath = null, movedReadRetried = 0, movedResult = null;
|
|
9748
|
+
try {
|
|
9749
|
+
fs.readFileSync = function (file, ...rest) {
|
|
9750
|
+
if (samePath(file, movedOwnerPath) && movedReadRetried === 0) {
|
|
9751
|
+
movedReadRetried++;
|
|
9752
|
+
const err = new Error('simulated transient moved-owner read'); err.code = 'EPERM'; throw err;
|
|
9753
|
+
}
|
|
9754
|
+
return originalReadFile.call(fs, file, ...rest);
|
|
9755
|
+
};
|
|
9756
|
+
movedResult = _withLock(target, () => {
|
|
9757
|
+
const ownerName = fs.readdirSync(lockFile)[0] || '';
|
|
9758
|
+
const token = ((_LOCK_OWNER_FILE_RE.exec(ownerName) || [])[1]) || null;
|
|
9759
|
+
movedReleasePath = token ? `${lockFile}.release-${token}` : null;
|
|
9760
|
+
movedOwnerPath = movedReleasePath ? path.join(movedReleasePath, ownerName) : null;
|
|
9761
|
+
return 6;
|
|
9762
|
+
});
|
|
9763
|
+
} finally { fs.readFileSync = originalReadFile; }
|
|
9764
|
+
const movedReadOk = movedResult === 6 && movedReadRetried === 1
|
|
9765
|
+
&& !fs.existsSync(lockFile) && !fs.existsSync(movedReleasePath || '');
|
|
9766
|
+
if (!movedReadOk) throw new Error(`value=${movedResult}, retried=${movedReadRetried}, released=${!fs.existsSync(lockFile)}`);
|
|
9767
|
+
|
|
9768
|
+
// ⑦ Windows/Node 18 결정적 회귀: tombstone 안의 owner unlink 직후 rmdir가 ENOTEMPTY를
|
|
9769
|
+
// 내고, readdir에는 deletion-pending인 **자기 owner 이름**이 한 번 더 보인다.
|
|
9770
|
+
// 실제 OS 타이밍에 기대지 않고 그 순서를 강제로 만든다.
|
|
9771
|
+
phase = 'windows-delete-pending';
|
|
9772
|
+
const originalRmdir = fs.rmdirSync, originalReaddir = fs.readdirSync;
|
|
9773
|
+
let pendingOwnerName = null, pendingReleasePath = null;
|
|
9774
|
+
let armed = false, fakePending = false, enotemptyInjected = 0;
|
|
9775
|
+
let winResult = null;
|
|
9776
|
+
try {
|
|
9777
|
+
fs.rmdirSync = function (p, ...rest) {
|
|
9778
|
+
if (armed && samePath(p, pendingReleasePath) && enotemptyInjected === 0) {
|
|
9779
|
+
enotemptyInjected++; fakePending = true;
|
|
9780
|
+
const err = new Error('simulated Windows deletion-pending directory'); err.code = 'ENOTEMPTY'; throw err;
|
|
9781
|
+
}
|
|
9782
|
+
return originalRmdir.call(fs, p, ...rest);
|
|
9783
|
+
};
|
|
9784
|
+
fs.readdirSync = function (p, ...rest) {
|
|
9785
|
+
if (fakePending && samePath(p, pendingReleasePath)) { fakePending = false; return [pendingOwnerName]; }
|
|
9786
|
+
return originalReaddir.call(fs, p, ...rest);
|
|
9787
|
+
};
|
|
9788
|
+
winResult = _withLock(target, () => {
|
|
9789
|
+
pendingOwnerName = originalReaddir.call(fs, lockFile)[0] || null;
|
|
9790
|
+
const token = ((_LOCK_OWNER_FILE_RE.exec(pendingOwnerName || '') || [])[1]) || null;
|
|
9791
|
+
pendingReleasePath = token ? `${lockFile}.release-${token}` : null;
|
|
9792
|
+
armed = true; return 7;
|
|
9793
|
+
});
|
|
9794
|
+
} finally { fs.rmdirSync = originalRmdir; fs.readdirSync = originalReaddir; }
|
|
9795
|
+
const winRetryOk = winResult === 7 && enotemptyInjected === 1
|
|
9796
|
+
&& !fs.existsSync(lockFile) && !fs.existsSync(pendingReleasePath || '');
|
|
9797
|
+
if (!winRetryOk) throw new Error(`value=${winResult}, injected=${enotemptyInjected}, released=${!fs.existsSync(lockFile)}`);
|
|
9798
|
+
|
|
9799
|
+
// ⑧ 외부 Codex P1 회귀: 정리 재시도 사이에 contender가 공개 lockPath를 새로 만들고
|
|
9800
|
+
// owner 쓰기 직전에 선점돼도, 해제자는 토큰 고유 tombstone만 지워야 한다.
|
|
9801
|
+
phase = 'release-aba';
|
|
9802
|
+
let abaReleasePath = null, abaInjected = 0, contenderDirMade = false, abaResult = null;
|
|
9803
|
+
try {
|
|
9804
|
+
fs.rmdirSync = function (p, ...rest) {
|
|
9805
|
+
if (samePath(p, abaReleasePath) && abaInjected === 0) {
|
|
9806
|
+
abaInjected++;
|
|
9807
|
+
fs.mkdirSync(lockFile); contenderDirMade = true;
|
|
9808
|
+
const err = new Error('simulated preempted contender during release retry'); err.code = 'ENOTEMPTY'; throw err;
|
|
9809
|
+
}
|
|
9810
|
+
return originalRmdir.call(fs, p, ...rest);
|
|
9811
|
+
};
|
|
9812
|
+
abaResult = _withLock(target, () => {
|
|
9813
|
+
const ownerName = originalReaddir.call(fs, lockFile)[0] || '';
|
|
9814
|
+
const token = ((_LOCK_OWNER_FILE_RE.exec(ownerName) || [])[1]) || null;
|
|
9815
|
+
abaReleasePath = token ? `${lockFile}.release-${token}` : null;
|
|
9816
|
+
return 8;
|
|
9817
|
+
});
|
|
9818
|
+
} finally { fs.rmdirSync = originalRmdir; }
|
|
9819
|
+
const contenderOwner = path.join(lockFile, 'owner-contender.json');
|
|
9820
|
+
if (contenderDirMade && fs.existsSync(lockFile)) fs.writeFileSync(contenderOwner, '{}');
|
|
9821
|
+
const abaOk = abaResult === 8 && abaInjected === 1 && fs.existsSync(contenderOwner)
|
|
9822
|
+
&& !fs.existsSync(abaReleasePath || '');
|
|
9823
|
+
try { fs.rmSync(lockFile, { recursive: true, force: true }); } catch {}
|
|
9824
|
+
if (!abaOk) throw new Error(`value=${abaResult}, injected=${abaInjected}, contenderPreserved=${fs.existsSync(contenderOwner)}`);
|
|
9825
|
+
|
|
9826
|
+
// ⑨ fulfillment 콜백에서 반복 ENOTEMPTY가 재시도 예산을 소진하면 바깥 then.call catch가
|
|
9827
|
+
// 해제를 두 번 실행하지 않고 최초 E_LOCK_RELEASE를 그대로 보존한다.
|
|
9828
|
+
phase = 'release-exhaustion';
|
|
9829
|
+
let exhaustedReleasePath = null, exhaustedAttempts = 0, exhaustedCode = null, exhaustedErrorPath = null;
|
|
9830
|
+
const savedNoLockWarn = process.env.LEERNESS_NO_LOCK_WARN;
|
|
9831
|
+
try {
|
|
9832
|
+
process.env.LEERNESS_NO_LOCK_WARN = '1';
|
|
9833
|
+
fs.rmdirSync = function (p, ...rest) {
|
|
9834
|
+
if (samePath(p, exhaustedReleasePath)) {
|
|
9835
|
+
exhaustedAttempts++;
|
|
9836
|
+
const err = new Error('simulated persistent Windows deletion-pending directory'); err.code = 'ENOTEMPTY'; throw err;
|
|
9837
|
+
}
|
|
9838
|
+
return originalRmdir.call(fs, p, ...rest);
|
|
9839
|
+
};
|
|
9840
|
+
try {
|
|
9841
|
+
_withLock(target, () => {
|
|
9842
|
+
const ownerName = originalReaddir.call(fs, lockFile)[0] || '';
|
|
9843
|
+
const token = ((_LOCK_OWNER_FILE_RE.exec(ownerName) || [])[1]) || null;
|
|
9844
|
+
exhaustedReleasePath = token ? `${lockFile}.release-${token}` : null;
|
|
9845
|
+
return { then(onOk) { return onOk(9); } };
|
|
9846
|
+
});
|
|
9847
|
+
} catch (e) { exhaustedCode = e && e.code; exhaustedErrorPath = e && e.releasePath; }
|
|
9848
|
+
} finally {
|
|
9849
|
+
fs.rmdirSync = originalRmdir;
|
|
9850
|
+
if (savedNoLockWarn === undefined) delete process.env.LEERNESS_NO_LOCK_WARN;
|
|
9851
|
+
else process.env.LEERNESS_NO_LOCK_WARN = savedNoLockWarn;
|
|
9852
|
+
}
|
|
9853
|
+
const exhaustionOk = exhaustedAttempts === 6 && exhaustedCode === 'E_LOCK_RELEASE'
|
|
9854
|
+
&& samePath(exhaustedErrorPath, exhaustedReleasePath)
|
|
9855
|
+
&& !fs.existsSync(lockFile) && fs.existsSync(exhaustedReleasePath || '');
|
|
9856
|
+
try { if (exhaustedReleasePath) fs.rmSync(exhaustedReleasePath, { recursive: true, force: true }); } catch {}
|
|
9857
|
+
if (!exhaustionOk) throw new Error(`attempts=${exhaustedAttempts}, code=${exhaustedCode}, publicReleased=${!fs.existsSync(lockFile)}`);
|
|
9858
|
+
return true;
|
|
9859
|
+
} catch (e) {
|
|
9860
|
+
const err = new Error(`async-lock ${phase}: ${(e && e.message) || String(e)}`);
|
|
9861
|
+
if (e && e.code) err.code = e.code;
|
|
9862
|
+
throw err;
|
|
9863
|
+
}
|
|
9864
|
+
finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch {} }
|
|
9865
|
+
}
|
|
9866
|
+
// 1.36.160 (외부 Codex P1): maxWait(35s)가 stale(30s)를 넘는 순간 살아 있는 장기 작업을
|
|
9867
|
+
// 탈취하던 구조와 고정 파일 unlink TOCTOU를 함께 막았다. 고유 owner가 있으면 자동 회수하지 않는다.
|
|
9868
|
+
function _p0160LockOwnershipOk() {
|
|
9869
|
+
const arena = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-p160lk-'));
|
|
9870
|
+
const target = path.join(arena, 'store.json');
|
|
9871
|
+
const lockDir = target + '.lock';
|
|
9872
|
+
const host = String(os.hostname() || '').toLowerCase();
|
|
9873
|
+
const hostId = _lockHostIdentity();
|
|
9874
|
+
const pidScopeId = _lockPidScopeIdentity();
|
|
9875
|
+
const ownerBody = (pid, token, ownerHost = host, lease = null,
|
|
9876
|
+
processStartedAtMs = pid === process.pid ? _PROCESS_STARTED_AT_MS : null,
|
|
9877
|
+
ownerHostId = ownerHost === host ? hostId : null,
|
|
9878
|
+
ownerPidScopeId = ownerHostId === hostId ? pidScopeId : null,
|
|
9879
|
+
ownerVersion = _LOCK_OWNER_VERSION) => JSON.stringify({
|
|
9880
|
+
v: ownerVersion, pid, host: ownerHost, hostId: ownerHostId, pidScopeId: ownerPidScopeId,
|
|
9881
|
+
token, lease, processStartedAtMs,
|
|
9882
|
+
createdAt: new Date(0).toISOString()
|
|
9883
|
+
}) + '\n';
|
|
9884
|
+
const agePath = (p, ms) => { const d = new Date(Date.now() - ms); fs.utimesSync(p, d, d); };
|
|
9885
|
+
const cleanLock = () => { try { fs.rmSync(lockDir, { recursive: true, force: true }); } catch {} };
|
|
9886
|
+
const seedOwner = (pid, token, ownerHost = host, lease = null, ageMs = 1000, body = null,
|
|
9887
|
+
processStartedAtMs = pid === process.pid ? _PROCESS_STARTED_AT_MS : null,
|
|
9888
|
+
ownerHostId = ownerHost === host ? hostId : null,
|
|
9889
|
+
ownerPidScopeId = ownerHostId === hostId ? pidScopeId : null,
|
|
9890
|
+
ownerVersion = _LOCK_OWNER_VERSION) => {
|
|
9891
|
+
cleanLock(); fs.mkdirSync(lockDir);
|
|
9892
|
+
const p = _lockOwnerPath(lockDir, token);
|
|
9893
|
+
fs.writeFileSync(p, body === null
|
|
9894
|
+
? ownerBody(pid, token, ownerHost, lease, processStartedAtMs, ownerHostId,
|
|
9895
|
+
ownerPidScopeId, ownerVersion) : body);
|
|
9896
|
+
agePath(p, ageMs); return p;
|
|
9897
|
+
};
|
|
9898
|
+
try {
|
|
9899
|
+
let reentrant = false; let metadataOk = false; let heartbeatOk = false;
|
|
9900
|
+
const normal = _withLock(target, () => {
|
|
9901
|
+
const names = fs.readdirSync(lockDir);
|
|
9902
|
+
const ownerPath = path.join(lockDir, names[0] || 'missing');
|
|
9903
|
+
const owner = JSON.parse(fs.readFileSync(ownerPath, 'utf8'));
|
|
9904
|
+
metadataOk = names.length === 1 && _LOCK_OWNER_FILE_RE.test(names[0])
|
|
9905
|
+
&& owner.v === _LOCK_OWNER_VERSION && owner.pid === process.pid
|
|
9906
|
+
&& owner.host === host && owner.lease === _LOCK_LEASE
|
|
9907
|
+
&& /^[a-f0-9]{32}$/.test(hostId || '') && owner.hostId === hostId
|
|
9908
|
+
&& /^[a-f0-9]{32}$/.test(pidScopeId || '') && owner.pidScopeId === pidScopeId
|
|
9909
|
+
&& owner.processStartedAtMs === _PROCESS_STARTED_AT_MS
|
|
9910
|
+
&& /^[a-f0-9]{32}$/.test(owner.token) && ownerPath === _lockOwnerPath(lockDir, owner.token);
|
|
9911
|
+
reentrant = _withLock(target, () => 42) === 42;
|
|
9912
|
+
const beforeBeat = fs.statSync(ownerPath).mtimeMs;
|
|
9913
|
+
_sleepSyncMs(1800); // 메인 스레드가 막혀도 진단용 heartbeat freshness는 관측 가능해야 한다.
|
|
9914
|
+
heartbeatOk = fs.statSync(ownerPath).mtimeMs > beforeBeat;
|
|
9915
|
+
return fs.existsSync(lockDir);
|
|
9916
|
+
});
|
|
9917
|
+
const normalOk = normal === true && metadataOk && reentrant && heartbeatOk && !fs.existsSync(lockDir);
|
|
9918
|
+
|
|
9919
|
+
// 로컬 hostId를 만들 수 없으면 owner를 쓰거나 임계구역에 들어가지 않는다.
|
|
9920
|
+
const identityTarget = path.join(arena, 'no-host-id.json');
|
|
9921
|
+
const savedHostIdentity = _lockHostIdentityCache;
|
|
9922
|
+
let noIdentityRan = false; let noIdentityCode = null;
|
|
9923
|
+
try {
|
|
9924
|
+
_lockHostIdentityCache = null;
|
|
9925
|
+
_withLock(identityTarget, () => { noIdentityRan = true; }, { maxWaitMs: 5, staleMs: 1 });
|
|
9926
|
+
} catch (e) { noIdentityCode = e && e.code; }
|
|
9927
|
+
finally { _lockHostIdentityCache = savedHostIdentity; }
|
|
9928
|
+
const noIdentityProtected = !noIdentityRan && noIdentityCode === 'E_LOCK_HOST_ID'
|
|
9929
|
+
&& !fs.existsSync(identityTarget + '.lock');
|
|
9930
|
+
|
|
9931
|
+
const retryTarget = path.join(arena, 'release-retry.json');
|
|
9932
|
+
const retryLock = retryTarget + '.lock';
|
|
9933
|
+
const originalRead = fs.readFileSync; let releaseReadRetried = false; let armReleaseRead = false;
|
|
9934
|
+
let retryOwnerPath = null; let retryResult = null;
|
|
9935
|
+
try {
|
|
9936
|
+
fs.readFileSync = function (file, ...rest) {
|
|
9937
|
+
if (armReleaseRead && !releaseReadRetried && retryOwnerPath
|
|
9938
|
+
&& path.resolve(String(file)) === path.resolve(retryOwnerPath)) {
|
|
9939
|
+
releaseReadRetried = true;
|
|
9940
|
+
const err = new Error('simulated transient Windows lock read'); err.code = 'EPERM'; throw err;
|
|
9941
|
+
}
|
|
9942
|
+
return originalRead.call(fs, file, ...rest);
|
|
9943
|
+
};
|
|
9944
|
+
retryResult = _withLock(retryTarget, () => {
|
|
9945
|
+
retryOwnerPath = path.join(retryLock, fs.readdirSync(retryLock)[0]); armReleaseRead = true; return 9;
|
|
9946
|
+
});
|
|
9947
|
+
} finally { fs.readFileSync = originalRead; }
|
|
9948
|
+
const releaseRetryOk = retryResult === 9 && releaseReadRetried && !fs.existsSync(retryLock);
|
|
9949
|
+
|
|
9950
|
+
const liveToken = 'a'.repeat(32);
|
|
9951
|
+
const livePath = seedOwner(process.pid, liveToken);
|
|
9952
|
+
let liveRan = false; let liveCode = null;
|
|
9953
|
+
try { _withLock(target, () => { liveRan = true; }, { maxWaitMs: 5, staleMs: 1 }); }
|
|
9954
|
+
catch (e) { liveCode = e && e.code; }
|
|
9955
|
+
const liveProtected = !liveRan && liveCode === 'E_LOCK_TIMEOUT'
|
|
9956
|
+
&& JSON.parse(fs.readFileSync(livePath, 'utf8')).token === liveToken;
|
|
9957
|
+
|
|
9958
|
+
// heartbeat가 5분 넘게 갱신되지 않아도 owner가 있으면 자동 탈취하지 않는다.
|
|
9959
|
+
const liveExpiredToken = '8'.repeat(32);
|
|
9960
|
+
const liveExpiredPath = seedOwner(process.pid, liveExpiredToken, host, _LOCK_LEASE,
|
|
9961
|
+
_LOCK_UNKNOWN_OWNER_STALE_MS + 1000);
|
|
9962
|
+
let liveExpiredRan = false; let liveExpiredCode = null;
|
|
9963
|
+
try { _withLock(target, () => { liveExpiredRan = true; }, { maxWaitMs: 5, staleMs: 1 }); }
|
|
9964
|
+
catch (e) { liveExpiredCode = e && e.code; }
|
|
9965
|
+
const liveExpiredLeaseProtected = !liveExpiredRan && liveExpiredCode === 'E_LOCK_TIMEOUT'
|
|
9966
|
+
&& JSON.parse(fs.readFileSync(liveExpiredPath, 'utf8')).token === liveExpiredToken;
|
|
9967
|
+
|
|
9968
|
+
// ESRCH나 PID 시작시각은 clone/namespace 밖 소유자가 다시 실행되지 않는다는 fencing 증명이
|
|
9969
|
+
// 아니다. 명백히 없는 PID여도 owner가 기록된 락은 자동 회수하지 않는다.
|
|
9970
|
+
const deadToken = 'b'.repeat(32);
|
|
9971
|
+
const deadPath = seedOwner(2147483647, deadToken);
|
|
9972
|
+
let deadRan = false; let deadCode = null;
|
|
9973
|
+
try { _withLock(target, () => { deadRan = true; }, { maxWaitMs: 5, staleMs: 1 }); }
|
|
9974
|
+
catch (e) { deadCode = e && e.code; }
|
|
9975
|
+
const deadOwnerProtected = !deadRan && deadCode === 'E_LOCK_TIMEOUT'
|
|
9976
|
+
&& fs.existsSync(deadPath) && JSON.parse(fs.readFileSync(deadPath, 'utf8')).token === deadToken;
|
|
9977
|
+
|
|
9978
|
+
const reusedPidToken = 'f'.repeat(32);
|
|
9979
|
+
seedOwner(process.pid, reusedPidToken, host, _LOCK_LEASE, _LOCK_UNKNOWN_OWNER_STALE_MS + 1000,
|
|
9980
|
+
null, _PROCESS_STARTED_AT_MS - 60000);
|
|
9981
|
+
let reusedPidRan = false; let reusedPidCode = null;
|
|
9982
|
+
try { _withLock(target, () => { reusedPidRan = true; }, { maxWaitMs: 5, staleMs: 1 }); }
|
|
9983
|
+
catch (e) { reusedPidCode = e && e.code; }
|
|
9984
|
+
const reusedPidProtected = !reusedPidRan && reusedPidCode === 'E_LOCK_TIMEOUT'
|
|
9985
|
+
&& fs.existsSync(_lockOwnerPath(lockDir, reusedPidToken));
|
|
9986
|
+
|
|
9987
|
+
const malformedToken = '9'.repeat(32);
|
|
9988
|
+
const malformedPath = seedOwner(process.pid, malformedToken, host, null, 1000, '');
|
|
9989
|
+
let malformedRan = false; let malformedCode = null;
|
|
9990
|
+
try { _withLock(target, () => { malformedRan = true; }, { maxWaitMs: 5, staleMs: 1 }); }
|
|
9991
|
+
catch (e) { malformedCode = e && e.code; }
|
|
9992
|
+
const malformedProtected = !malformedRan && malformedCode === 'E_LOCK_TIMEOUT' && fs.existsSync(malformedPath);
|
|
9993
|
+
agePath(malformedPath, _LOCK_UNKNOWN_OWNER_STALE_MS + 1000);
|
|
9994
|
+
let ancientMalformedRan = false; let ancientMalformedCode = null;
|
|
9995
|
+
try { _withLock(target, () => { ancientMalformedRan = true; }, { maxWaitMs: 5, staleMs: 1 }); }
|
|
9996
|
+
catch (e) { ancientMalformedCode = e && e.code; }
|
|
9997
|
+
const ancientMalformedProtected = !ancientMalformedRan && ancientMalformedCode === 'E_LOCK_TIMEOUT'
|
|
9998
|
+
&& fs.existsSync(malformedPath);
|
|
9999
|
+
|
|
10000
|
+
cleanLock(); fs.mkdirSync(lockDir); agePath(lockDir, 1000);
|
|
10001
|
+
const emptyRecovered = _withLock(target, () => 'empty-recovered', { maxWaitMs: 100, staleMs: 1 });
|
|
10002
|
+
const orphanDirectoryRecovered = emptyRecovered === 'empty-recovered' && !fs.existsSync(lockDir);
|
|
10003
|
+
|
|
10004
|
+
cleanLock(); fs.writeFileSync(lockDir, ''); agePath(lockDir, 1000);
|
|
10005
|
+
let legacyRan = false; let legacyCode = null;
|
|
10006
|
+
try { _withLock(target, () => { legacyRan = true; }, { maxWaitMs: 5, staleMs: 1 }); }
|
|
10007
|
+
catch (e) { legacyCode = e && e.code; }
|
|
10008
|
+
const legacyProtected = !legacyRan && legacyCode === 'E_LOCK_TIMEOUT' && fs.existsSync(lockDir);
|
|
10009
|
+
agePath(lockDir, _LOCK_UNKNOWN_OWNER_STALE_MS + 1000);
|
|
10010
|
+
let ancientLegacyRan = false; let ancientLegacyCode = null;
|
|
10011
|
+
try { _withLock(target, () => { ancientLegacyRan = true; }, { maxWaitMs: 5, staleMs: 1 }); }
|
|
10012
|
+
catch (e) { ancientLegacyCode = e && e.code; }
|
|
10013
|
+
const ancientLegacyProtected = !ancientLegacyRan && ancientLegacyCode === 'E_LOCK_TIMEOUT'
|
|
10014
|
+
&& fs.existsSync(lockDir);
|
|
10015
|
+
|
|
10016
|
+
const remoteNoLeaseToken = 'e'.repeat(32);
|
|
10017
|
+
const remoteNoLeasePath = seedOwner(12345, remoteNoLeaseToken, 'legacy-host.invalid', null,
|
|
10018
|
+
_LOCK_UNKNOWN_OWNER_STALE_MS + 1000);
|
|
10019
|
+
let remoteNoLeaseRan = false; let remoteNoLeaseCode = null;
|
|
10020
|
+
try { _withLock(target, () => { remoteNoLeaseRan = true; }, { maxWaitMs: 5, staleMs: 1 }); }
|
|
10021
|
+
catch (e) { remoteNoLeaseCode = e && e.code; }
|
|
10022
|
+
const remoteNoLeaseProtected = !remoteNoLeaseRan && remoteNoLeaseCode === 'E_LOCK_TIMEOUT'
|
|
10023
|
+
&& fs.existsSync(remoteNoLeasePath);
|
|
10024
|
+
|
|
10025
|
+
const remoteToken = 'd'.repeat(32);
|
|
10026
|
+
const remotePath = seedOwner(12345, remoteToken, 'retired-host.invalid', _LOCK_LEASE,
|
|
10027
|
+
_LOCK_UNKNOWN_OWNER_STALE_MS + 1000);
|
|
10028
|
+
let remoteRan = false; let remoteCode = null;
|
|
10029
|
+
try { _withLock(target, () => { remoteRan = true; }, { maxWaitMs: 5, staleMs: 1 }); }
|
|
10030
|
+
catch (e) { remoteCode = e && e.code; }
|
|
10031
|
+
const remoteOldProtected = !remoteRan && remoteCode === 'E_LOCK_TIMEOUT'
|
|
10032
|
+
&& JSON.parse(fs.readFileSync(remotePath, 'utf8')).token === remoteToken;
|
|
10033
|
+
|
|
10034
|
+
// 같은 hostname을 쓰는 다른 머신도 로컬 PID로 오인하지 않는다. 머신 ID가 다르면 remote와
|
|
10035
|
+
// 똑같이 fail-closed다(공유 볼륨/컨테이너 hostname 충돌 회귀).
|
|
10036
|
+
const collisionToken = '7'.repeat(32); const collisionHostId = '0'.repeat(32);
|
|
10037
|
+
const collisionPath = seedOwner(2147483647, collisionToken, host, _LOCK_LEASE,
|
|
10038
|
+
_LOCK_UNKNOWN_OWNER_STALE_MS + 1000, null, null, collisionHostId);
|
|
10039
|
+
let collisionRan = false; let collisionCode = null;
|
|
10040
|
+
try { _withLock(target, () => { collisionRan = true; }, { maxWaitMs: 5, staleMs: 1 }); }
|
|
10041
|
+
catch (e) { collisionCode = e && e.code; }
|
|
10042
|
+
const collidingHostnameProtected = !collisionRan && collisionCode === 'E_LOCK_TIMEOUT'
|
|
10043
|
+
&& fs.existsSync(collisionPath) && collisionHostId !== hostId;
|
|
10044
|
+
|
|
10045
|
+
// hostId가 같아도 PID namespace가 다르면 소유자를 회수하지 않는다.
|
|
10046
|
+
const scopeToken = '5'.repeat(32); const foreignPidScope = '4'.repeat(32);
|
|
10047
|
+
const scopePath = seedOwner(2147483647, scopeToken, host, _LOCK_LEASE,
|
|
10048
|
+
_LOCK_UNKNOWN_OWNER_STALE_MS + 1000, null, null, hostId, foreignPidScope);
|
|
10049
|
+
let scopeRan = false; let scopeCode = null;
|
|
10050
|
+
try { _withLock(target, () => { scopeRan = true; }, { maxWaitMs: 5, staleMs: 1 }); }
|
|
10051
|
+
catch (e) { scopeCode = e && e.code; }
|
|
10052
|
+
const foreignPidScopeProtected = !scopeRan && scopeCode === 'E_LOCK_TIMEOUT'
|
|
10053
|
+
&& fs.existsSync(scopePath) && foreignPidScope !== pidScopeId;
|
|
10054
|
+
cleanLock(); // 다음 해제-소유권 시나리오는 독립 픽스처에서 시작한다.
|
|
10055
|
+
|
|
10056
|
+
// mkdir 직후 정지한 생성자의 빈 디렉터리를 다른 회수자가 교체한 뒤, 원 생성자가 재개하는 ABA.
|
|
10057
|
+
// 교체 디렉터리에 자기 owner를 추가해도 "단독 owner"/디렉터리 인스턴스 검사가 진입을 막아야 한다.
|
|
10058
|
+
const creationRaceToken = '6'.repeat(32);
|
|
10059
|
+
const creationRacePath = _lockOwnerPath(lockDir, creationRaceToken);
|
|
10060
|
+
const originalOpen = fs.openSync; let creationReplaced = false;
|
|
10061
|
+
let creationRaceRan = false; let creationRaceCode = null;
|
|
10062
|
+
try {
|
|
10063
|
+
fs.openSync = function (file, flags, ...rest) {
|
|
10064
|
+
if (!creationReplaced && String(flags) === 'wx'
|
|
10065
|
+
&& path.resolve(path.dirname(String(file))) === path.resolve(lockDir)) {
|
|
10066
|
+
creationReplaced = true;
|
|
10067
|
+
fs.rmdirSync(lockDir); fs.mkdirSync(lockDir);
|
|
10068
|
+
fs.writeFileSync(creationRacePath, ownerBody(process.pid, creationRaceToken, host, _LOCK_LEASE));
|
|
10069
|
+
}
|
|
10070
|
+
return originalOpen.call(fs, file, flags, ...rest);
|
|
10071
|
+
};
|
|
10072
|
+
_withLock(target, () => { creationRaceRan = true; }, { maxWaitMs: 5, staleMs: 1 });
|
|
10073
|
+
} catch (e) { creationRaceCode = e && e.code; }
|
|
10074
|
+
finally { fs.openSync = originalOpen; }
|
|
10075
|
+
const creationReplacementProtected = creationReplaced && !creationRaceRan
|
|
10076
|
+
&& creationRaceCode === 'E_LOCK_TIMEOUT' && fs.existsSync(creationRacePath)
|
|
10077
|
+
&& JSON.parse(fs.readFileSync(creationRacePath, 'utf8')).token === creationRaceToken;
|
|
10078
|
+
cleanLock();
|
|
10079
|
+
|
|
10080
|
+
// 해제 중 owner 내용이 바뀌면 자신의 디렉터리라고 단정하지 않고 보존하며, 성공도 주장하지 않는다.
|
|
10081
|
+
const replacementToken = 'c'.repeat(32); let replacedPath = null;
|
|
10082
|
+
let replacementCode = null;
|
|
10083
|
+
const savedNoLockWarn = process.env.LEERNESS_NO_LOCK_WARN;
|
|
10084
|
+
try {
|
|
10085
|
+
process.env.LEERNESS_NO_LOCK_WARN = '1';
|
|
10086
|
+
_withLock(target, () => {
|
|
10087
|
+
replacedPath = path.join(lockDir, fs.readdirSync(lockDir)[0]);
|
|
10088
|
+
fs.writeFileSync(replacedPath, ownerBody(process.pid, replacementToken));
|
|
10089
|
+
return 7;
|
|
10090
|
+
});
|
|
10091
|
+
} catch (e) { replacementCode = e && e.code; }
|
|
10092
|
+
finally {
|
|
10093
|
+
if (savedNoLockWarn === undefined) delete process.env.LEERNESS_NO_LOCK_WARN;
|
|
10094
|
+
else process.env.LEERNESS_NO_LOCK_WARN = savedNoLockWarn;
|
|
10095
|
+
}
|
|
10096
|
+
const replacementKept = replacementCode === 'E_LOCK_OWNERSHIP_LOST' && fs.existsSync(replacedPath)
|
|
10097
|
+
&& JSON.parse(fs.readFileSync(replacedPath, 'utf8')).token === replacementToken;
|
|
10098
|
+
|
|
10099
|
+
// 오래된 owner도 자동 unlink하지 않는다는 직접 행위검사. PID/host/mtime 휴리스틱을 다시
|
|
10100
|
+
// 도입하면 clone/공유 저장소에서 이 검사가 즉시 실패한다.
|
|
10101
|
+
const oldToken = '1'.repeat(32);
|
|
10102
|
+
const oldPath = seedOwner(2147483647, oldToken);
|
|
10103
|
+
agePath(oldPath, _LOCK_UNKNOWN_OWNER_STALE_MS + 1000);
|
|
10104
|
+
const originalUnlink = fs.unlinkSync; let ownerUnlinkAttempted = false; let reclaimResult = false;
|
|
10105
|
+
try {
|
|
10106
|
+
fs.unlinkSync = function (file, ...rest) {
|
|
10107
|
+
if (path.resolve(String(file)) === path.resolve(oldPath)) ownerUnlinkAttempted = true;
|
|
10108
|
+
return originalUnlink.call(fs, file, ...rest);
|
|
10109
|
+
};
|
|
10110
|
+
reclaimResult = _reclaimStaleLock(lockDir, 1);
|
|
10111
|
+
} finally { fs.unlinkSync = originalUnlink; }
|
|
10112
|
+
const staleOwnerProtected = !reclaimResult && !ownerUnlinkAttempted && fs.existsSync(oldPath)
|
|
10113
|
+
&& JSON.parse(fs.readFileSync(oldPath, 'utf8')).token === oldToken;
|
|
10114
|
+
|
|
10115
|
+
return normalOk && noIdentityProtected && releaseRetryOk && liveProtected && liveExpiredLeaseProtected
|
|
10116
|
+
&& deadOwnerProtected && reusedPidProtected
|
|
10117
|
+
&& malformedProtected && ancientMalformedProtected && orphanDirectoryRecovered
|
|
10118
|
+
&& legacyProtected && ancientLegacyProtected && remoteNoLeaseProtected && remoteOldProtected
|
|
10119
|
+
&& collidingHostnameProtected && creationReplacementProtected
|
|
10120
|
+
&& foreignPidScopeProtected && replacementKept && staleOwnerProtected;
|
|
9073
10121
|
} catch { return false; }
|
|
9074
10122
|
finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch {} }
|
|
9075
10123
|
}
|
|
@@ -9102,7 +10150,7 @@ function _p0104ReminderOk() {
|
|
|
9102
10150
|
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
9103
10151
|
cp2.spawnSync(process.execPath, [__filename, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 180000, env: env2 });
|
|
9104
10152
|
writeUtf8(path.join(d, '.harness', 'agent-reminders.md'), body);
|
|
9105
|
-
cp2.spawnSync(process.execPath, [__filename, 'handoff', '.'], { cwd: d, encoding: 'utf8', timeout: 300000, env: env2 });
|
|
10153
|
+
cp2.spawnSync(process.execPath, [__filename, 'handoff', '.', '--writeback'], { cwd: d, encoding: 'utf8', timeout: 300000, env: env2 });
|
|
9106
10154
|
return path.join(d, '.harness', 'agent-reminders.md');
|
|
9107
10155
|
};
|
|
9108
10156
|
// ① 사람이 쓴 파일은 살아남고 내용도 그대로
|
|
@@ -9327,7 +10375,9 @@ function _p0103FlagsOk() {
|
|
|
9327
10375
|
const good = R(['pulse', '--json']);
|
|
9328
10376
|
const typo = R(['pulse', '--json', '--nosuchflagzz']);
|
|
9329
10377
|
const quietOnReal = !/알 수 없는 플래그|Unknown flag/.test(good.stderr || '');
|
|
9330
|
-
|
|
10378
|
+
// 프로젝트가 OS locale 에 따라 en 으로 초기화돼도 같은 계약을 검증한다.
|
|
10379
|
+
// 경고 정규식은 바로 위의 ko/en 공용 단일출처를 재사용해 CI locale 에 종속되지 않게 한다.
|
|
10380
|
+
const warnsOnTypo = W2.test(typo.stderr || '') && (typo.stderr || '').includes('--nosuchflagzz');
|
|
9331
10381
|
// 경고는 stderr 전용 — stdout(기계 소비자) 은 오염되지 않는다
|
|
9332
10382
|
const stdoutClean = !/알 수 없는 플래그|Unknown flag/.test(typo.stdout || '');
|
|
9333
10383
|
let jsonStillValid = false; try { JSON.parse((typo.stdout || '').trim()); jsonStillValid = true; } catch { jsonStillValid = false; }
|
|
@@ -9732,7 +10782,7 @@ function commandsCmd(root) {
|
|
|
9732
10782
|
const dm = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
|
|
9733
10783
|
const cats = {
|
|
9734
10784
|
status: [
|
|
9735
|
-
{ cmd: 'handoff [path] [--json] [--pulse] [--compact] [--quiet]', desc: '세션 시작 컨텍스트 +
|
|
10785
|
+
{ cmd: 'handoff [path] [--json] [--pulse] [--compact] [--quiet] [--writeback]', desc: '세션 시작 컨텍스트 + 세션별 runtime 기록 (--writeback: legacy projection 갱신)', descEn: 'session-start context + per-session runtime record (--writeback: refresh legacy projections)' },
|
|
9736
10786
|
{ cmd: 'health [path] [--json] [--strict]', desc: '종합 헬스 + drift/security/skill/memory/featureGraph/roundHistory/milestones', descEn: 'overall health + drift/security/skill/memory/featureGraph/roundHistory/milestones' },
|
|
9737
10787
|
{ cmd: 'pulse [path] [--json]', desc: '한 줄 종합 요약 (1.9.231)', descEn: 'one-line overall summary (1.9.231)' },
|
|
9738
10788
|
{ cmd: 'round-history [path] [--json]', desc: 'git tag 기반 자율 라운드 통계 (1.9.226)', descEn: 'autonomous round stats from git tags (1.9.226)' },
|
|
@@ -10134,22 +11184,14 @@ function _detectAbnormalShutdown(root) {
|
|
|
10134
11184
|
|
|
10135
11185
|
// 1) last-handoff gap > 60min (정상 25min interval 대비)
|
|
10136
11186
|
try {
|
|
10137
|
-
const
|
|
10138
|
-
if (
|
|
10139
|
-
|
|
10140
|
-
|
|
10141
|
-
|
|
10142
|
-
|
|
10143
|
-
|
|
10144
|
-
|
|
10145
|
-
result.signals.push({
|
|
10146
|
-
kind: 'last-handoff-stale',
|
|
10147
|
-
gapMin,
|
|
10148
|
-
detail: `last handoff ${gapMin}분 전 (정상 25min 간격 대비 ${Math.round(gapMin/25)}x 초과) — 절전/종료 의심`,
|
|
10149
|
-
severity: gapMin > 240 ? 'high' : 'medium'
|
|
10150
|
-
});
|
|
10151
|
-
}
|
|
10152
|
-
}
|
|
11187
|
+
const gap = _getLastHandoffGap(root);
|
|
11188
|
+
if (gap && gap.hasLast && gap.gapMin > 60) {
|
|
11189
|
+
result.signals.push({
|
|
11190
|
+
kind: 'last-handoff-stale',
|
|
11191
|
+
gapMin: gap.gapMin,
|
|
11192
|
+
detail: `last handoff ${gap.gapMin}분 전 (정상 25min 간격 대비 ${Math.round(gap.gapMin/25)}x 초과) — 절전/종료 의심`,
|
|
11193
|
+
severity: gap.gapMin > 240 ? 'high' : 'medium'
|
|
11194
|
+
});
|
|
10153
11195
|
}
|
|
10154
11196
|
} catch {}
|
|
10155
11197
|
|
|
@@ -12025,6 +13067,8 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
12025
13067
|
if (_sharedAgents && !(prev && prev.sharedKeyAgents === _sharedAgents) && process.env.LEERNESS_NO_LOCK_WARN !== '1') {
|
|
12026
13068
|
try { process.stderr.write(`⚠ 세션 주소 공유 감지: ${key} 를 서로 다른 에이전트(${_sharedAgents})가 쓰고 있습니다 — 두 세션이 한 기록으로 합쳐져 서로를 못 봅니다. 세션마다 다른 LEERNESS_SESSION_ID 를 쓰세요.\n`); } catch {}
|
|
12027
13069
|
}
|
|
13070
|
+
const _handoffHistory = Array.isArray(prev && prev.handoffHistory) ? prev.handoffHistory.slice(-10) : [];
|
|
13071
|
+
if (phase !== 'close') { _handoffHistory.push(nowIso); if (_handoffHistory.length > 10) _handoffHistory.shift(); }
|
|
12028
13072
|
const rec = {
|
|
12029
13073
|
schemaVersion: 1,
|
|
12030
13074
|
sessionKey: key,
|
|
@@ -12035,8 +13079,11 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
12035
13079
|
platform: process.platform,
|
|
12036
13080
|
leernessVersion: VERSION,
|
|
12037
13081
|
openedAt: (prev && prev.openedAt) || nowIso,
|
|
12038
|
-
|
|
13082
|
+
// 1.36.155 (외부 Codex P1): close-only 세션에 handoff 시각을 발명하지 않는다.
|
|
13083
|
+
// 종전 `|| nowIso` 는 session close 자체를 handoff 로 만들어 enforcement 를 우회했다.
|
|
13084
|
+
lastHandoffAt: phase === 'close' ? ((prev && prev.lastHandoffAt) || null) : nowIso,
|
|
12039
13085
|
handoffCount: (Number((prev && prev.handoffCount) || 0) || 0) + (phase === 'close' ? 0 : 1),
|
|
13086
|
+
handoffHistory: _handoffHistory,
|
|
12040
13087
|
closedAt: phase === 'close' ? nowIso : null,
|
|
12041
13088
|
// 1.36.132 (검수 실측): 같은 주소를 두 세션이 쓰면 레코드가 조용히 병합됐다(others=0·헤드라인 침묵·
|
|
12042
13089
|
// 라벨이 마지막 기록자로 덮임). 라벨이 **둘 다 unknown 이 아니면서** 바뀌었다면 그건 공유의 증거다.
|
|
@@ -12311,29 +13358,110 @@ function sessionsCmd(root) {
|
|
|
12311
13358
|
for (const b of blindSpots) log(dim(` · ${b}`));
|
|
12312
13359
|
}
|
|
12313
13360
|
function _lastHandoffPath(root) { return path.join(root, '.harness', 'last-handoff.json'); }
|
|
13361
|
+
const _HANDOFF_FRESHNESS_REL = ['.harness', 'cache', 'handoffs'];
|
|
13362
|
+
const HANDOFF_FRESHNESS_RE = /^[A-Za-z0-9_-]{8,64}\.json$/;
|
|
13363
|
+
function _handoffFreshnessDir(root) { return path.join(absRoot(root), ..._HANDOFF_FRESHNESS_REL); }
|
|
13364
|
+
function _handoffFreshnessKey() {
|
|
13365
|
+
try {
|
|
13366
|
+
const SP = require('../lib/session-presence');
|
|
13367
|
+
const key = SP.deriveSessionKey(process.env);
|
|
13368
|
+
return SP.isValidKey(key) ? key : 'unaddressed';
|
|
13369
|
+
} catch { return 'unaddressed'; }
|
|
13370
|
+
}
|
|
13371
|
+
function _handoffFreshnessPath(root, key) {
|
|
13372
|
+
return path.join(_handoffFreshnessDir(root), `${key || _handoffFreshnessKey()}.json`);
|
|
13373
|
+
}
|
|
13374
|
+
// 1.36.155 (T-0137 / 외부 Codex P1): enforcement freshness 는 presence record 와 분리한다.
|
|
13375
|
+
// presence 는 session close 에서도 갱신되므로 mtime 을 handoff 증거로 쓸 수 없다. 이 ignored marker 는
|
|
13376
|
+
// **실제 handoff 에서만** 쓰며, 주소 없는 Cursor/VS Code/일반 셸도 공용 unaddressed 슬롯에 흔적을 남긴다.
|
|
13377
|
+
function _recordHandoffFreshness(root) {
|
|
13378
|
+
try {
|
|
13379
|
+
root = absRoot(root);
|
|
13380
|
+
if (!exists(path.join(root, '.harness'))) return { written: false, reason: 'not-a-leerness-project' };
|
|
13381
|
+
const key = _handoffFreshnessKey();
|
|
13382
|
+
const dir = _handoffFreshnessDir(root);
|
|
13383
|
+
const fp = _handoffFreshnessPath(root, key);
|
|
13384
|
+
mkdirp(dir);
|
|
13385
|
+
if (path.dirname(path.resolve(fp)) !== path.resolve(dir)) return { written: false, reason: 'path-escape' };
|
|
13386
|
+
_withLock(fp, () => {
|
|
13387
|
+
let history = [];
|
|
13388
|
+
if (exists(fp)) {
|
|
13389
|
+
try { const j = JSON.parse(read(fp)); if (Array.isArray(j.history)) history = j.history; } catch {}
|
|
13390
|
+
}
|
|
13391
|
+
const nowIso = new Date().toISOString();
|
|
13392
|
+
history.push(nowIso);
|
|
13393
|
+
if (history.length > 10) history = history.slice(-10);
|
|
13394
|
+
writeUtf8(fp, JSON.stringify({ schemaVersion: 1, sessionKey: key === 'unaddressed' ? null : key, last: nowIso, history }, null, 2) + '\n');
|
|
13395
|
+
});
|
|
13396
|
+
return { written: true, reason: null, key };
|
|
13397
|
+
} catch (e) {
|
|
13398
|
+
return { written: false, reason: 'unavailable:' + String((e && (e.code || e.message)) || e).slice(0, 40) };
|
|
13399
|
+
}
|
|
13400
|
+
}
|
|
13401
|
+
function _latestHandoffFreshness(root) {
|
|
13402
|
+
try {
|
|
13403
|
+
const SP = require('../lib/session-presence');
|
|
13404
|
+
const dir = _handoffFreshnessDir(root);
|
|
13405
|
+
const selfKey = SP.deriveSessionKey(process.env);
|
|
13406
|
+
// 1.36.156 (외부 Codex P1): 주소가 있으면 그 주소만, 없으면 handoff 가 실제로 쓰는
|
|
13407
|
+
// unaddressed 슬롯만 본다. 다른 세션의 최신 marker 로 내 세션 freshness 를 대체하지 않는다.
|
|
13408
|
+
const names = [`${SP.isValidKey(selfKey) ? selfKey : 'unaddressed'}.json`];
|
|
13409
|
+
const found = [];
|
|
13410
|
+
for (const name of names) {
|
|
13411
|
+
if (!HANDOFF_FRESHNESS_RE.test(name)) continue;
|
|
13412
|
+
const fp = path.join(dir, name);
|
|
13413
|
+
if (path.dirname(path.resolve(fp)) !== path.resolve(dir) || !exists(fp)) continue;
|
|
13414
|
+
try {
|
|
13415
|
+
// Git hook 의 canonical 판정도 `find -mmin` 이다. 같은 marker 를 두 표면이 다르게
|
|
13416
|
+
// 해석하지 않도록 freshness 는 mtime 을 쓰고 JSON history 는 진단 정보로만 읽는다.
|
|
13417
|
+
// JSON이 손상돼도 hook은 mtime을 보므로 JS만 다르게 차단하지 않는다.
|
|
13418
|
+
const mtime = fs.statSync(fp).mtime;
|
|
13419
|
+
let history = [];
|
|
13420
|
+
try { const j = JSON.parse(read(fp)); if (Array.isArray(j.history)) history = j.history; } catch {}
|
|
13421
|
+
if (Number.isFinite(mtime.getTime())) found.push({ last: mtime.toISOString(), history, key: name.replace(/\.json$/, '') });
|
|
13422
|
+
} catch {}
|
|
13423
|
+
}
|
|
13424
|
+
return found.sort((a, b) => Date.parse(b.last) - Date.parse(a.last))[0] || null;
|
|
13425
|
+
} catch { return null; }
|
|
13426
|
+
}
|
|
12314
13427
|
function _recordLastHandoff(root) {
|
|
12315
13428
|
try {
|
|
12316
13429
|
mkdirp(path.join(root, '.harness'));
|
|
12317
13430
|
const fp = _lastHandoffPath(root);
|
|
12318
|
-
|
|
12319
|
-
|
|
12320
|
-
|
|
12321
|
-
|
|
12322
|
-
|
|
12323
|
-
|
|
12324
|
-
|
|
12325
|
-
|
|
12326
|
-
|
|
13431
|
+
_withLock(fp, () => {
|
|
13432
|
+
let history = [];
|
|
13433
|
+
if (exists(fp)) {
|
|
13434
|
+
try { const j = JSON.parse(read(fp)); if (Array.isArray(j.history)) history = j.history; } catch {}
|
|
13435
|
+
}
|
|
13436
|
+
const now = new Date().toISOString();
|
|
13437
|
+
history.push(now);
|
|
13438
|
+
if (history.length > 10) history = history.slice(-10);
|
|
13439
|
+
writeUtf8(fp, JSON.stringify({ last: now, history }, null, 2) + '\n');
|
|
13440
|
+
});
|
|
12327
13441
|
return true;
|
|
12328
13442
|
} catch { return false; }
|
|
12329
13443
|
}
|
|
12330
13444
|
function _getLastHandoffGap(root) {
|
|
12331
13445
|
try {
|
|
12332
|
-
const
|
|
12333
|
-
|
|
12334
|
-
const
|
|
12335
|
-
|
|
12336
|
-
|
|
13446
|
+
const SP = require('../lib/session-presence');
|
|
13447
|
+
const selfKey = SP.deriveSessionKey(process.env);
|
|
13448
|
+
const marker = _latestHandoffFreshness(root);
|
|
13449
|
+
let last = marker && marker.last;
|
|
13450
|
+
let history = marker && Array.isArray(marker.history) ? marker.history : [];
|
|
13451
|
+
// 주소가 있는 세션은 자신의 marker만 증거다. 전역 legacy 파일은 주소를 만들 수 없던
|
|
13452
|
+
// 구버전/일반 셸의 무주소 호환 경로에서만 허용한다.
|
|
13453
|
+
if (!last && !SP.isValidKey(selfKey)) {
|
|
13454
|
+
const fp = _lastHandoffPath(root);
|
|
13455
|
+
if (!exists(fp)) return { hasLast: false };
|
|
13456
|
+
const legacy = JSON.parse(read(fp));
|
|
13457
|
+
// legacy hook도 `find -mmin`으로 파일 mtime을 판정한다. JSON 내부 시각이 새롭고
|
|
13458
|
+
// 파일이 오래된 경우 JS만 통과하지 않도록 같은 canonical clock을 쓴다.
|
|
13459
|
+
const legacyMtime = fs.statSync(fp).mtime;
|
|
13460
|
+
last = Number.isFinite(legacyMtime.getTime()) ? legacyMtime.toISOString() : null;
|
|
13461
|
+
history = Array.isArray(legacy.history) ? legacy.history : [];
|
|
13462
|
+
}
|
|
13463
|
+
if (!last) return { hasLast: false };
|
|
13464
|
+
const lastMs = new Date(last).getTime();
|
|
12337
13465
|
if (!Number.isFinite(lastMs)) return { hasLast: false }; // 1.36.50 (NaN 날짜 클래스): 손상 타임스탬프 = 정보 부재로 취급
|
|
12338
13466
|
const gapMs = Date.now() - lastMs;
|
|
12339
13467
|
const gapMin = Math.floor(gapMs / 60000);
|
|
@@ -12341,9 +13469,37 @@ function _getLastHandoffGap(root) {
|
|
|
12341
13469
|
const expected = 25;
|
|
12342
13470
|
const isMiss = gapMin > 35;
|
|
12343
13471
|
const isLong = gapMin > 60;
|
|
12344
|
-
return { hasLast: true, lastAt:
|
|
13472
|
+
return { hasLast: true, lastAt: last, gapMin, expected, isMiss, isLong, history };
|
|
12345
13473
|
} catch { return { hasLast: false }; }
|
|
12346
13474
|
}
|
|
13475
|
+
function _handoffVersionSkew(root) {
|
|
13476
|
+
try {
|
|
13477
|
+
const versionFile = path.join(absRoot(root), '.harness', 'HARNESS_VERSION');
|
|
13478
|
+
if (!exists(versionFile)) return null;
|
|
13479
|
+
const parsed = parseHarnessVersion(read(versionFile));
|
|
13480
|
+
const harnessVersion = parsed.plus || parsed.base;
|
|
13481
|
+
if (!harnessVersion) return { detected: true, kind: 'invalid-harness-version', cliVersion: VERSION, harnessVersion: parsed.raw };
|
|
13482
|
+
const order = compareVer(VERSION, harnessVersion);
|
|
13483
|
+
if (order === 0) return null;
|
|
13484
|
+
return {
|
|
13485
|
+
detected: true,
|
|
13486
|
+
kind: order < 0 ? 'cli-older' : 'harness-older',
|
|
13487
|
+
cliVersion: VERSION,
|
|
13488
|
+
harnessVersion,
|
|
13489
|
+
action: order < 0 ? 'update the leerness CLI before write commands' : 'run leerness update --yes for this project'
|
|
13490
|
+
};
|
|
13491
|
+
} catch { return null; }
|
|
13492
|
+
}
|
|
13493
|
+
function _emitHandoffVersionSkew(skew) {
|
|
13494
|
+
if (!skew) return;
|
|
13495
|
+
if (skew.kind === 'cli-older') {
|
|
13496
|
+
warn(`버전 불일치: 실행 CLI v${skew.cliVersion} < 프로젝트 하네스 v${skew.harnessVersion} — 쓰기 명령 전 CLI를 업데이트하세요`);
|
|
13497
|
+
} else if (skew.kind === 'harness-older') {
|
|
13498
|
+
warn(`버전 불일치: 실행 CLI v${skew.cliVersion} > 프로젝트 하네스 v${skew.harnessVersion} — leerness update --yes 권장`);
|
|
13499
|
+
} else {
|
|
13500
|
+
warn(`프로젝트 HARNESS_VERSION 을 해석할 수 없습니다: ${skew.harnessVersion}`);
|
|
13501
|
+
}
|
|
13502
|
+
}
|
|
12347
13503
|
// 1.36.7 (메타-테스트 도그푸드): 세션에서 작업 중(task/decision add)인데 handoff 가 오래됨/미실행이면 넛지.
|
|
12348
13504
|
// 실측(트랜스크립트)에서 확인된 사각지대: compaction/resume 후 세션-시작 handoff 리추얼을 건너뜀 → 컨텍스트 미적재.
|
|
12349
13505
|
// 그 신호는 handoff 출력에만 떴는데 정작 handoff 를 안 돌리는 게 문제라, 에이전트가 실제로 실행하는 명령에 넛지를 노출.
|
|
@@ -12949,6 +14105,9 @@ function upsertProgress(root, row) {
|
|
|
12949
14105
|
}
|
|
12950
14106
|
|
|
12951
14107
|
function planShow(root) { const p = planPath(root); const has_ = exists(p); const content = has_ ? read(p) : ''; if (has('--json')) { const milestones = (content.match(/^### (M-\d{4,})\b.*$/gm) || []).map(l => l.replace(/^###\s*/, '').trim()); log(JSON.stringify({ exists: has_, milestones, raw: content }, null, 2)); return; } log(has_ ? content : 'plan.md not found'); } // 1.9.428 (UR-0128): plan show --json 구조화
|
|
14108
|
+
// plan.md와 progress-tracker.md를 함께 다루는 모든 RMW의 공통 키. plan restore도 이 키를 써야
|
|
14109
|
+
// plan add/drop/remove과 같은 스냅샷을 본다 (1.36.152/T-0115).
|
|
14110
|
+
function _withPlanTransaction(root, fn) { return _withLock(progressPath(root), fn); }
|
|
12952
14111
|
function planInit(root) { const goal = arg('--goal', ''); if (!exists(planPath(root))) return install(root); append(planPath(root), `\n## User Goal\n- ${goal || '사용자 목적을 작성하세요.'}\n`); ok('plan goal appended'); }
|
|
12953
14112
|
// 1.9.119: plan list — plan.md 의 모든 milestone (M-XXXX) 조회 (CLI + --json + MCP)
|
|
12954
14113
|
function planListCmd(root, opts = {}) {
|
|
@@ -13025,7 +14184,7 @@ function planAdd(root, text) {
|
|
|
13025
14184
|
// 1.14.2 (Karpathy 원칙4 "성공기준 정의", UR-0032): --done-when 으로 검증가능 완료조건을 milestone 에 기록. 미지정 시 (미정) — plan show/audit 가 환기.
|
|
13026
14185
|
const doneWhen = _lineSafe(arg('--done-when', '') || (_uiLang(root) === 'en' ? '(unset)' : '(미정)')); // 1.36.63 (검수 #2)
|
|
13027
14186
|
// 1.9.303 (UR-0043): M-id append + T-id upsert 를 하나의 락으로 — 동시 plan add ID 충돌 방지.
|
|
13028
|
-
const { id, tid } =
|
|
14187
|
+
const { id, tid } = _withPlanTransaction(root, () => {
|
|
13029
14188
|
const id = nextId(root, 'M');
|
|
13030
14189
|
// 1.36.113 (방치 표면 사냥): `text` 만 raw 였다 — 같은 문장의 doneWhen 은 1.36.63 검수가 _lineSafe 를 걸었고
|
|
13031
14190
|
// 바로 아래 planDrop 은 _cellSafe 를 쓰는데, 여기만 빠져 있었다(수정 클래스 스윕 누락의 전형).
|
|
@@ -13042,19 +14201,23 @@ function planAdd(root, text) {
|
|
|
13042
14201
|
_autoRoadmap(absRoot(root), 'data-change');
|
|
13043
14202
|
}
|
|
13044
14203
|
function planDrop(root, text) {
|
|
13045
|
-
const id = nextId(root, 'D');
|
|
13046
14204
|
const reason = arg('--reason', _uiLang(root) === 'en' ? 'dropped by user request' : '사용자 요청으로 제외'); // 1.36.63 (검수 #2)
|
|
13047
14205
|
// 1.10.4 (13th 버그헌트 P3, UR-0170): text/reason 의 파이프(|)·개행이 plan.md 마크다운 표 칼럼을 깨뜨림 → _cellSafe 셀 안전화(task/rule UR-0104 와 동일).
|
|
13048
14206
|
const safeText = _cellSafe(text); const safeReason = _cellSafe(reason);
|
|
13049
|
-
|
|
13050
|
-
|
|
13051
|
-
|
|
13052
|
-
|
|
13053
|
-
|
|
13054
|
-
|
|
13055
|
-
|
|
13056
|
-
|
|
13057
|
-
|
|
14207
|
+
let id;
|
|
14208
|
+
// 1.36.152 (T-0115): plan restore/add와 같은 plan.md를 RMW한다. D-ID 생성과 저장까지 한 키에서 처리한다.
|
|
14209
|
+
_withPlanTransaction(root, () => {
|
|
14210
|
+
id = nextId(root, 'D');
|
|
14211
|
+
const planFile = planPath(root); let p = exists(planFile) ? read(planFile) : '';
|
|
14212
|
+
const droppedHeader = '## Out of Scope / Dropped';
|
|
14213
|
+
if (p.includes(droppedHeader)) {
|
|
14214
|
+
p = p.replace(droppedHeader + '\n| ID | Item | Reason | Date |\n|---|---|---|---|\n',
|
|
14215
|
+
droppedHeader + '\n| ID | Item | Reason | Date |\n|---|---|---|---|\n' + `| ${id} | ${safeText} | ${safeReason} | ${today()} |\n`);
|
|
14216
|
+
writeUtf8(planFile, p);
|
|
14217
|
+
} else {
|
|
14218
|
+
append(planFile, `\n${droppedHeader}\n| ID | Item | Reason | Date |\n|---|---|---|---|\n| ${id} | ${safeText} | ${safeReason} | ${today()} |\n`);
|
|
14219
|
+
}
|
|
14220
|
+
});
|
|
13058
14221
|
// 1.9.449 (12th 외부평가 Sonnet P3, UR-0143): progress-tracker 에 dropped task(T-) 행 생성 제거 — plan drop 은 plan.md "Out of Scope / Dropped"(D-) 에만 기록.
|
|
13059
14222
|
// 기존엔 scope 드랍이 phantom T- task 로도 생겨 plan↔progress 역할 혼선 + task list 노이즈. 드랍 기록의 단일 출처 = plan.md.
|
|
13060
14223
|
ok(`plan dropped: ${id} → .harness/plan.md (Out of Scope / Dropped)`);
|
|
@@ -13083,39 +14246,47 @@ function planRemoveCmd(root, target) {
|
|
|
13083
14246
|
if (!target) return fail('plan remove <M-XXXX|title-substring> 필요 — 매칭되는 milestone 블록을 제거하고 .harness/plan.archive.md에 보존');
|
|
13084
14247
|
const pp = planPath(root);
|
|
13085
14248
|
if (!exists(pp)) return fail('plan.md 없음');
|
|
13086
|
-
const text = read(pp);
|
|
13087
|
-
// milestone 블록은 "### M-XXXX. 제목" 으로 시작; "## " (Out of Scope 등) 헤더 또는 EOF 이전까지
|
|
13088
|
-
// codex 버그헌트 P2: `### ` 만 경계로 쓰면 마지막 마일스톤 뒤의 사용자 h1/h2 섹션(예: `## Risks`)이
|
|
13089
|
-
// 그 마일스톤 블록에 흡수돼 함께 삭제된다. 모든 헤딩(#{1,3} )을 경계로 잘라 각 섹션을 독립 블록화.
|
|
13090
|
-
const blocks = text.split(/\n(?=#{1,3} )/);
|
|
13091
14249
|
let removed = 0;
|
|
13092
|
-
|
|
13093
|
-
|
|
13094
|
-
|
|
13095
|
-
|
|
13096
|
-
|
|
13097
|
-
|
|
13098
|
-
//
|
|
13099
|
-
|
|
13100
|
-
const
|
|
13101
|
-
|
|
13102
|
-
|
|
13103
|
-
|
|
13104
|
-
kept.push(b); continue;
|
|
13105
|
-
|
|
13106
|
-
|
|
13107
|
-
|
|
13108
|
-
|
|
13109
|
-
|
|
13110
|
-
const
|
|
13111
|
-
const
|
|
13112
|
-
|
|
13113
|
-
|
|
14250
|
+
let disappeared = false;
|
|
14251
|
+
// 1.36.152 (T-0115): plan restore/add와 archive를 공유한다. 락 안에서 다시 읽어야 새 milestone을 덮지 않는다.
|
|
14252
|
+
_withPlanTransaction(root, () => {
|
|
14253
|
+
if (!exists(pp)) { disappeared = true; return; }
|
|
14254
|
+
const text = read(pp);
|
|
14255
|
+
// milestone 블록은 "### M-XXXX. 제목" 으로 시작; "## " (Out of Scope 등) 헤더 또는 EOF 이전까지
|
|
14256
|
+
// codex 버그헌트 P2: `### ` 만 경계로 쓰면 마지막 마일스톤 뒤의 사용자 h1/h2 섹션(예: `## Risks`)이
|
|
14257
|
+
// 그 마일스톤 블록에 흡수돼 함께 삭제된다. 모든 헤딩(#{1,3} )을 경계로 잘라 각 섹션을 독립 블록화.
|
|
14258
|
+
const blocks = text.split(/\n(?=#{1,3} )/);
|
|
14259
|
+
removed = 0;
|
|
14260
|
+
const kept = [];
|
|
14261
|
+
for (const b of blocks) {
|
|
14262
|
+
if (!b.startsWith('### ')) { kept.push(b); continue; } // 마일스톤(### M-) 블록만 삭제 후보
|
|
14263
|
+
const headerMatch = b.match(/^### (.+)$/m);
|
|
14264
|
+
if (!headerMatch) { kept.push(b); continue; }
|
|
14265
|
+
const titleLine = headerMatch[1].trim();
|
|
14266
|
+
// M-XXXX. 제목 형태 파싱
|
|
14267
|
+
const mMatch = titleLine.match(/^(M-\d+)\.\s*(.+)$/);
|
|
14268
|
+
const mid = mMatch ? mMatch[1] : null;
|
|
14269
|
+
const title = mMatch ? mMatch[2].trim() : titleLine;
|
|
14270
|
+
// Template / 템플릿 블록 자동 보호
|
|
14271
|
+
if (/^Template(?:\s|\b|\()/i.test(titleLine) || /^템플릿/.test(titleLine)) {
|
|
14272
|
+
kept.push(b); continue;
|
|
14273
|
+
}
|
|
14274
|
+
const isIdTarget = mid === target;
|
|
14275
|
+
const isTitleTarget = title.includes(target);
|
|
14276
|
+
if (isIdTarget || isTitleTarget) {
|
|
14277
|
+
removed++;
|
|
14278
|
+
const archivePath = path.join(root, '.harness/plan.archive.md');
|
|
14279
|
+
const archiveHeader = exists(archivePath) ? '' : '# Plan archive\n\n';
|
|
14280
|
+
append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${target}")\n${b}\n`);
|
|
14281
|
+
continue;
|
|
14282
|
+
}
|
|
14283
|
+
kept.push(b);
|
|
13114
14284
|
}
|
|
13115
|
-
|
|
13116
|
-
|
|
14285
|
+
if (removed === 0) return;
|
|
14286
|
+
writeUtf8(pp, kept.join('\n'));
|
|
14287
|
+
});
|
|
14288
|
+
if (disappeared) return fail('plan.md 없음');
|
|
13117
14289
|
if (removed === 0) return failJson(has('--json'), 'milestone_not_found', `매칭 milestone 없음: "${target}"`); // codex P2: --json 에러 구조화
|
|
13118
|
-
writeUtf8(pp, kept.join('\n'));
|
|
13119
14290
|
ok(`milestone removed: ${removed}건 (보존: .harness/plan.archive.md)`);
|
|
13120
14291
|
_autoRoadmap(absRoot(root), 'data-change');
|
|
13121
14292
|
}
|
|
@@ -13504,6 +14675,15 @@ function memoryRestoreCmd(root, surface, target) {
|
|
|
13504
14675
|
if (!target) return fail('memory restore <surface> <target> — target 누락');
|
|
13505
14676
|
const hd = path.join(root, '.harness');
|
|
13506
14677
|
const archivePath = path.join(hd, `${surface}.archive.md`);
|
|
14678
|
+
const activePath = surface === 'decisions' ? decisionsPath(root)
|
|
14679
|
+
: surface === 'lessons' ? lessonsPath(root)
|
|
14680
|
+
: planPath(root);
|
|
14681
|
+
// 1.36.152 (T-0115): restore는 active canonical store와 archive를 함께 RMW한다.
|
|
14682
|
+
// decisions/lessons는 add/drop의 JSON 키, plan은 plan add/drop의 progress 키를 공유해야 한다.
|
|
14683
|
+
const memoryLockPath = surface === 'decisions' ? decisionsJsonPath(root)
|
|
14684
|
+
: surface === 'lessons' ? lessonsJsonPath(root)
|
|
14685
|
+
: progressPath(root);
|
|
14686
|
+
const restoredCount = _withLock(memoryLockPath, () => {
|
|
13507
14687
|
if (!exists(archivePath)) return fail(`${surface}.archive.md 없음 — 복원할 항목 없음`);
|
|
13508
14688
|
// 1.36.76 (9차 헌트 #3 P1) → (검수 #2): 잘린/깨진 UTF-8 아카이브가 손상 데이터를 active 로 유입시키던 것.
|
|
13509
14689
|
// 판정은 "디코딩 결과에 U+FFFD 포함"이 아니라 **원시 바이트의 UTF-8 유효성**(fatal decode) — 사용자가 정당하게
|
|
@@ -13536,10 +14716,6 @@ function memoryRestoreCmd(root, surface, target) {
|
|
|
13536
14716
|
}
|
|
13537
14717
|
}
|
|
13538
14718
|
if (restoredBlocks.length === 0) return fail(`매칭 archive entry 없음: surface=${surface}, target="${target}"`);
|
|
13539
|
-
// active 파일 경로
|
|
13540
|
-
const activePath = surface === 'decisions' ? decisionsPath(root)
|
|
13541
|
-
: surface === 'lessons' ? lessonsPath(root)
|
|
13542
|
-
: planPath(root);
|
|
13543
14719
|
// 1.36.74 (9차 헌트 #4 P1): 같은 항목이 이미 active 에 있으면 복원이 조용히 중복 레코드를 만들었다
|
|
13544
14720
|
// (drop → 같은 제목 재생성 → restore = 동일 제목 2건, 어느 쪽이 진짜인지 불명). 충돌은 기본 거부하고
|
|
13545
14721
|
// --force 로만 진행 — 복원은 "되살리기"지 "복제"가 아니다. archive 는 손대지 않으므로 재시도 가능.
|
|
@@ -13590,7 +14766,10 @@ function memoryRestoreCmd(root, surface, target) {
|
|
|
13590
14766
|
} else {
|
|
13591
14767
|
writeUtf8(archivePath, archiveHeader + kept.join('\n'));
|
|
13592
14768
|
}
|
|
13593
|
-
|
|
14769
|
+
return restoredBlocks.length;
|
|
14770
|
+
});
|
|
14771
|
+
if (!restoredCount) return;
|
|
14772
|
+
ok(`${surface} restored: ${restoredCount}건 (archive에서 active로 복귀)`);
|
|
13594
14773
|
_autoRoadmap(absRoot(root), 'data-change');
|
|
13595
14774
|
}
|
|
13596
14775
|
|
|
@@ -14375,9 +15554,14 @@ function lazyDetect(root, opts = {}) {
|
|
|
14375
15554
|
let todoCount = 0;
|
|
14376
15555
|
const newTodos = [];
|
|
14377
15556
|
const cliSelf = path.resolve(__filename);
|
|
15557
|
+
// This detector promises code TODOs. Reusing the broad text allow-list from
|
|
15558
|
+
// encoding/secret scans also included prose docs, so a README explaining the
|
|
15559
|
+
// word "TODO" became a blocking work item. Keep source/config files in scope,
|
|
15560
|
+
// but do not reinterpret ordinary Markdown/text prose as code annotations.
|
|
15561
|
+
const todoProseExt = new Set(['.md', '.txt']);
|
|
14378
15562
|
for (const file of walk(root)) {
|
|
14379
15563
|
const ext = path.extname(file).toLowerCase();
|
|
14380
|
-
if (!SCAN_TEXT_EXT.has(ext)) continue;
|
|
15564
|
+
if (!SCAN_TEXT_EXT.has(ext) || todoProseExt.has(ext)) continue;
|
|
14381
15565
|
if (file.includes('.harness')) continue;
|
|
14382
15566
|
if (path.resolve(file) === cliSelf) continue;
|
|
14383
15567
|
if (/[\\/]bin[\\/]harness\.js$/.test(file)) continue;
|
|
@@ -14591,13 +15775,18 @@ function memorySearch(root, query) {
|
|
|
14591
15775
|
|
|
14592
15776
|
function handoff(root) {
|
|
14593
15777
|
root = absRoot(root);
|
|
15778
|
+
const _versionSkew = _handoffVersionSkew(root);
|
|
14594
15779
|
// 1.9.199: handoff 진입 시 1) 이전 timestamp 보존 → 2) 현재 timestamp 기록
|
|
14595
15780
|
// detector 가 prior gap 을 정확히 측정 (overwrite 전 값 필요). 함수 최상단에 선언.
|
|
14596
15781
|
let _priorHandoffGap = { hasLast: false };
|
|
14597
15782
|
try { _priorHandoffGap = _getLastHandoffGap(root); } catch {}
|
|
14598
15783
|
// 1.36.22: 읽기전용/hook 호출이 last-handoff stamp 를 찍으면 detector 3종(핸드오프 넛지·abnormal shutdown·R-0001 interval)이
|
|
14599
15784
|
// "방금 handoff 했다"고 오판해 오염된다. --no-record / LEERNESS_HOOK=1 로 stamp 생략(기본 동작 불변).
|
|
14600
|
-
if (!has('--no-record') && process.env.LEERNESS_HOOK !== '1') {
|
|
15785
|
+
if (!has('--no-record') && process.env.LEERNESS_HOOK !== '1') {
|
|
15786
|
+
try { _recordHandoffFreshness(root); } catch {}
|
|
15787
|
+
try { _sessionPresenceRecord(root, 'open'); } catch {}
|
|
15788
|
+
if (has('--writeback')) { try { _recordLastHandoff(root); } catch {} }
|
|
15789
|
+
}
|
|
14601
15790
|
// 1.9.203: auto-resume-plan 자동 로드 (사용자 명시 — 자동 모드 알람 트리거)
|
|
14602
15791
|
let _autoResumePlan = null;
|
|
14603
15792
|
try { _autoResumePlan = _loadAutoResumePlan(root); } catch {}
|
|
@@ -14607,6 +15796,7 @@ function handoff(root) {
|
|
|
14607
15796
|
date: today(),
|
|
14608
15797
|
project: detectProjectName(root),
|
|
14609
15798
|
version: VERSION,
|
|
15799
|
+
versionSkew: _versionSkew,
|
|
14610
15800
|
files: {}
|
|
14611
15801
|
};
|
|
14612
15802
|
// 1.36.95: 같은 명령의 두 표면이 **반대 방향**으로 잘랐다 — 평문은 최근 N줄, JSON 은 앞 8000자.
|
|
@@ -14848,6 +16038,7 @@ function handoff(root) {
|
|
|
14848
16038
|
log(JSON.stringify(result, null, 2));
|
|
14849
16039
|
return;
|
|
14850
16040
|
}
|
|
16041
|
+
_emitHandoffVersionSkew(_versionSkew);
|
|
14851
16042
|
// 1.36.18 (UR-0052 P3-8): 단일 워크스페이스 --compact 단축. 종전엔 --compact 가 섹션 몇 개만 억제하고
|
|
14852
16043
|
// 본문(session-handoff/progress/decisions…) 186줄을 전량 출력 → 문서상 "~500자 1줄 요약"인 --compact 가
|
|
14853
16044
|
// 단일 경로에서 사실상 무효(멀티 워크스페이스 _handoffWorkspace 에만 compact 존재). MCP handoff·REPL preview·
|
|
@@ -15537,7 +16728,7 @@ function handoff(root) {
|
|
|
15537
16728
|
}
|
|
15538
16729
|
// 1.36.108 (T-0097): 날짜 스탬프도 read-modify-write 다 — 사용자가 쓴 본문 전체를 다시 쓴다.
|
|
15539
16730
|
// 락 밖이면 그 사이 session close 가 갱신한 Now/Next/Blockers 를 낡은 사본으로 되돌린다.
|
|
15540
|
-
if (exists(currentStatePath(root))) {
|
|
16731
|
+
if (has('--writeback') && process.env.LEERNESS_HOOK !== '1' && exists(currentStatePath(root))) {
|
|
15541
16732
|
_withLock(currentStatePath(root), () => {
|
|
15542
16733
|
const cs = read(currentStatePath(root)).replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
|
|
15543
16734
|
writeUtf8(currentStatePath(root), cs);
|
|
@@ -16091,9 +17282,9 @@ function handoff(root) {
|
|
|
16091
17282
|
const prev = _readEnvSnapshot(root);
|
|
16092
17283
|
const curr = _detectEnvironment(root);
|
|
16093
17284
|
const diff = _diffEnvSnapshots(prev, curr);
|
|
16094
|
-
//
|
|
17285
|
+
// 기본 handoff 는 관측만 한다. 추적 baseline 갱신은 명시적 --writeback 또는 `env detect` 의 책임이다.
|
|
16095
17286
|
const snapPath = _envSnapshotPath(root);
|
|
16096
|
-
if (!prev) {
|
|
17287
|
+
if (!prev && has('--writeback') && process.env.LEERNESS_HOOK !== '1') {
|
|
16097
17288
|
try { mkdirp(path.dirname(snapPath)); writeUtf8(snapPath, JSON.stringify(curr, null, 2) + '\n'); } catch {}
|
|
16098
17289
|
} else if (diff.changes.length || (diff.missing && diff.missing.length)) {
|
|
16099
17290
|
// 변동/누락 알림
|
|
@@ -16107,8 +17298,9 @@ function handoff(root) {
|
|
|
16107
17298
|
}
|
|
16108
17299
|
log(edDim(t(` → 상세: leerness env detect . --json`, ` → details: leerness env detect . --json`)));
|
|
16109
17300
|
log('');
|
|
16110
|
-
|
|
16111
|
-
|
|
17301
|
+
if (has('--writeback') && process.env.LEERNESS_HOOK !== '1') {
|
|
17302
|
+
try { writeUtf8(snapPath, JSON.stringify(curr, null, 2) + '\n'); } catch {}
|
|
17303
|
+
}
|
|
16112
17304
|
}
|
|
16113
17305
|
} catch {}
|
|
16114
17306
|
}
|
|
@@ -16486,17 +17678,17 @@ function handoffCmd(root) {
|
|
|
16486
17678
|
}
|
|
16487
17679
|
log('');
|
|
16488
17680
|
// 1.9.38 (A): critical 시 .harness/agent-reminders.md 자동 생성 — 다음 세션 시작 시 메인 에이전트가 읽도록.
|
|
16489
|
-
if (sevStale) {
|
|
17681
|
+
if (sevStale && has('--writeback') && process.env.LEERNESS_HOOK !== '1') {
|
|
16490
17682
|
try {
|
|
16491
17683
|
const remPath = path.join(absR0, '.harness', 'agent-reminders.md');
|
|
16492
17684
|
const body = `# 🔔 메인 에이전트용 자동 reminder\n\n_생성: ${new Date().toISOString()}_\n\n## drift critical 감지\n현재 워크스페이스의 메타파일이 매우 stale합니다. 이번 라운드 작업 끝에 반드시 다음 명령을 호출하세요:\n\n\`\`\`bash\nleerness session close .\n\`\`\`\n\n또는 상세 점검:\n\`\`\`bash\nleerness drift check .\n\`\`\`\n\nstale 신호:\n${shAge !== null ? `- session-handoff.md: ${shAge.toFixed(1)}일 stale\n` : ''}${ptAge !== null ? `- progress-tracker: ${ptAge.toFixed(1)}일 stale\n` : ''}\n\n_이 파일은 leerness 1.9.38+가 자동 갱신합니다. session close 후 자동 삭제.\n_사용자가 이 파일을 보고 메인 에이전트에 reminder 전달 가능._\n`;
|
|
16493
17685
|
writeUtf8(remPath, _autoReminderText(body)); // 1.36.104: 표식 줄에 본문 해시 — 사람이 고치면 청소 대상에서 빠진다
|
|
16494
17686
|
} catch {}
|
|
16495
|
-
} else {
|
|
17687
|
+
} else if (has('--writeback') && process.env.LEERNESS_HOOK !== '1') {
|
|
16496
17688
|
// attention 등급으로 회복했으면 reminder 파일 삭제 (자동 생성분에 한해)
|
|
16497
17689
|
_cleanAutoReminder(absR0);
|
|
16498
17690
|
}
|
|
16499
|
-
} else {
|
|
17691
|
+
} else if (has('--writeback') && process.env.LEERNESS_HOOK !== '1') {
|
|
16500
17692
|
// healthy → reminder 파일 자동 청소 (자동 생성분에 한해)
|
|
16501
17693
|
_cleanAutoReminder(absR0);
|
|
16502
17694
|
}
|
|
@@ -18697,8 +19889,17 @@ function _recommendAgent(task) {
|
|
|
18697
19889
|
// 손으로 고치거나 `provider sync` 로 받은 catalog 에 `versionArgs` 를 넣으면 `agents list` 한 번으로
|
|
18698
19890
|
// 임의 명령·셸 메타문자가 돌았다(실측: 마커 파일 생성 · 별도 프로세스 실행 · exit 0 · 경고 0).
|
|
18699
19891
|
// 같은 파일에서 실행되는 것은 **전부** 같은 술어를 통과해야 한다(키 하나만 막고 닫혔다고 말하지 않는다).
|
|
19892
|
+
function _shellAtomSafe(x) {
|
|
19893
|
+
if (typeof x !== 'string' || !x) return false;
|
|
19894
|
+
if (/^[A-Za-z0-9._:@/\\-]+$/.test(x)) return true;
|
|
19895
|
+
// Windows CI/기업 이미지의 유효한 8.3 경로는 `C:\\Users\\RUNNER~1\\...` 형태다.
|
|
19896
|
+
// POSIX의 `~`/`~user` 확장까지 허용하면 검증한 문자열과 shell:true가 실행하는 경로가 달라진다.
|
|
19897
|
+
// Windows에서만, `NAME~숫자`인 8.3 구성요소로 한정한다. 실제 셸 메타문자는 계속 거부한다.
|
|
19898
|
+
if (process.platform !== 'win32' || !/^[A-Za-z0-9._:@/\\~-]+$/.test(x)) return false;
|
|
19899
|
+
return x.split(/[\\/]/).every(part => !part.includes('~') || /^[A-Za-z0-9_]{1,6}~[0-9]+(?:\.[A-Za-z0-9_]{1,3})?$/.test(part));
|
|
19900
|
+
}
|
|
18700
19901
|
function _argsSafe(a) {
|
|
18701
|
-
return Array.isArray(a) && a.length > 0 && a.every(
|
|
19902
|
+
return Array.isArray(a) && a.length > 0 && a.every(_shellAtomSafe);
|
|
18702
19903
|
}
|
|
18703
19904
|
|
|
18704
19905
|
// bin 은 **실행파일 이름/경로**여야 한다 — 명령줄이 아니다. `shell:true` 로 넘기므로 여기에 공백이나
|
|
@@ -18707,7 +19908,7 @@ function _argsSafe(a) {
|
|
|
18707
19908
|
// 그 값은 읽기성 명령인 `agents list/check` 에서 실행된다. 공백을 막는 것은 회귀가 아니다 —
|
|
18708
19909
|
// `shell:true` 에서 공백 있는 bin 은 지금도 첫 토큰만 명령으로 해석돼 어차피 동작하지 않는다.
|
|
18709
19910
|
function _binSafe(b) {
|
|
18710
|
-
return typeof b === 'string' && b.length
|
|
19911
|
+
return typeof b === 'string' && b.length <= 260 && _shellAtomSafe(b);
|
|
18711
19912
|
}
|
|
18712
19913
|
|
|
18713
19914
|
// 같은 실행파일을 가리키는 **정상적인 별칭**까지 다른 것으로 보면, 이번에 되살린 인증 차단이
|
|
@@ -21087,11 +22288,39 @@ function releaseBump(root) {
|
|
|
21087
22288
|
if (kind === 'major') next = isPre && min === 0 && pat === 0 ? `${maj}.0.0` : `${maj + 1}.0.0`;
|
|
21088
22289
|
else if (kind === 'minor') next = isPre && pat === 0 ? `${maj}.${min}.0` : `${maj}.${min + 1}.0`;
|
|
21089
22290
|
else next = isPre ? `${maj}.${min}.${pat}` : `${maj}.${min}.${pat + 1}`;
|
|
21090
|
-
pkg.version = next;
|
|
21091
|
-
writeUtf8(pkgFile, JSON.stringify(pkg, null, 2) + '\n');
|
|
21092
22291
|
const hv = path.join(root, '.harness/HARNESS_VERSION');
|
|
21093
|
-
|
|
21094
|
-
|
|
22292
|
+
const updateHarnessVersion = exists(hv) && /^\d+\.\d+\.\d+/.test(read(hv).trim());
|
|
22293
|
+
// 1.36.156 (외부 Codex P2): package/HARNESS_VERSION 을 먼저 쓴 뒤 bin/README 검증이 실패하면
|
|
22294
|
+
// 출하 버전 표면이 반쯤 갱신됐다. 자체 패키지 표면을 모두 preflight 하고, 이후 쓰기는 snapshot
|
|
22295
|
+
// transaction 으로 묶어 어느 한 단계가 실패해도 명령 전 바이트로 되돌린다.
|
|
22296
|
+
let ownPlan = null;
|
|
22297
|
+
try { ownPlan = _prepareOwnReleaseVersionSurfaces(root, next); }
|
|
22298
|
+
catch (e) {
|
|
22299
|
+
failJson(has('--json'), 'release_preflight_failed', `버전 표면 사전검증 실패 — 변경 없음: ${String(e && e.message || e).slice(0, 160)}`);
|
|
22300
|
+
return;
|
|
22301
|
+
}
|
|
22302
|
+
const targetFiles = [pkgFile];
|
|
22303
|
+
if (updateHarnessVersion) targetFiles.push(hv);
|
|
22304
|
+
if (ownPlan) targetFiles.push(ownPlan.sourcePath, ownPlan.readmePath);
|
|
22305
|
+
let snapshots;
|
|
22306
|
+
try { snapshots = targetFiles.map(_releaseFileSnapshot); }
|
|
22307
|
+
catch (e) {
|
|
22308
|
+
failJson(has('--json'), 'release_preflight_failed', `버전 표면 snapshot 실패 — 변경 없음: ${String(e && e.message || e).slice(0, 160)}`);
|
|
22309
|
+
return;
|
|
22310
|
+
}
|
|
22311
|
+
const transaction = _runReleaseTransaction(snapshots, () => {
|
|
22312
|
+
pkg.version = next;
|
|
22313
|
+
writeUtf8(pkgFile, JSON.stringify(pkg, null, 2) + '\n');
|
|
22314
|
+
if (updateHarnessVersion) writeUtf8(hv, next + '\n');
|
|
22315
|
+
return syncOwnReleaseVersionSurfaces(root, next, ownPlan);
|
|
22316
|
+
});
|
|
22317
|
+
if (!transaction.ok) {
|
|
22318
|
+
const rollback = transaction.rollbackErrors.length ? `rollback 일부 실패(${transaction.rollbackErrors.join(', ')})` : '모든 표면 원상복구';
|
|
22319
|
+
failJson(has('--json'), 'release_bump_failed', `버전 갱신 실패 — ${rollback}: ${String(transaction.error && transaction.error.message || transaction.error).slice(0, 160)}`);
|
|
22320
|
+
return;
|
|
22321
|
+
}
|
|
22322
|
+
const syncedOwnSurfaces = transaction.value;
|
|
22323
|
+
ok(`version bumped: ${cur} → ${next} (${kind})${syncedOwnSurfaces ? ' · bin/README synced' : ''}`);
|
|
21095
22324
|
}
|
|
21096
22325
|
|
|
21097
22326
|
function releaseNote(root, text) {
|
|
@@ -22490,7 +23719,7 @@ function _bumpUsage(root, cmdName) {
|
|
|
22490
23719
|
// 카운터 하나 때문에 명령을 오래 붙잡을 수는 없으므로 **짧은 대기**만 준다 —
|
|
22491
23720
|
// 못 잡으면 `_withLock` 이 fail-open 으로 진행한다(드물게 한 건 놓치는 것이, 40%를 잃는 것보다 낫다).
|
|
22492
23721
|
try {
|
|
22493
|
-
return _withLock(_usageStatsPath(root), () => _bumpUsageLocked(root, cmdName), { maxWaitMs: 300 });
|
|
23722
|
+
return _withLock(_usageStatsPath(root), () => _bumpUsageLocked(root, cmdName), { maxWaitMs: 300, failOpen: true });
|
|
22494
23723
|
} catch { /* 카운터 실패가 명령을 실패시키지 않는다 */ }
|
|
22495
23724
|
}
|
|
22496
23725
|
function _bumpUsageLocked(root, cmdName) {
|
|
@@ -23793,6 +25022,9 @@ function envDiff(root) {
|
|
|
23793
25022
|
function envCheckCmd(root) {
|
|
23794
25023
|
const d = envDiff(root);
|
|
23795
25024
|
const isJson = has('--json');
|
|
25025
|
+
// JSON 출력도 사람용 출력과 같은 판정 계약을 가진다. 이 값을 early return 뒤에 두면
|
|
25026
|
+
// 누락 키를 정확히 보고하면서 exit 0을 내는 false-success가 된다(T-0132).
|
|
25027
|
+
if (d.inEnvOnly.length) process.exitCode = 1;
|
|
23796
25028
|
if (isJson) { log(JSON.stringify(d, null, 2)); return; }
|
|
23797
25029
|
log(`# leerness env check (1.9.71)`);
|
|
23798
25030
|
log(`.env 존재: ${exists(d.envPath)} · .env.example 존재: ${exists(d.examplePath)}`);
|
|
@@ -23814,8 +25046,6 @@ function envCheckCmd(root) {
|
|
|
23814
25046
|
log('');
|
|
23815
25047
|
log(`💡 자동 동기화: leerness env sync${d.inEnvOnly.length ? ' (.env.example에 누락 키 추가 — 값은 빈 문자열)' : ''}`);
|
|
23816
25048
|
}
|
|
23817
|
-
// 1.9.71: exit code = .env.example 누락 키 있으면 1 (보안 가시화)
|
|
23818
|
-
if (d.inEnvOnly.length) process.exitCode = 1;
|
|
23819
25049
|
}
|
|
23820
25050
|
function envSyncCmd(root) {
|
|
23821
25051
|
const d = envDiff(root);
|
|
@@ -24457,14 +25687,18 @@ const _ENV_SAFE_KEYS = new Set([
|
|
|
24457
25687
|
function _scrubEnv(extraEnv) {
|
|
24458
25688
|
const out = {};
|
|
24459
25689
|
for (const k of Object.keys(process.env || {})) {
|
|
24460
|
-
|
|
24461
|
-
|
|
25690
|
+
// Windows environment keys are case-insensitive, but enumeration commonly exposes
|
|
25691
|
+
// `Path`/`ComSpec` while this allow-list is canonical uppercase. Raw comparison
|
|
25692
|
+
// silently removed PATH from child processes and broke shell-backed verification.
|
|
25693
|
+
const safeKey = process.platform === 'win32' ? k.toUpperCase() : k;
|
|
25694
|
+
if (_ENV_SAFE_KEYS.has(safeKey) || safeKey.startsWith('LEERNESS_') || safeKey.startsWith('NPM_CONFIG_')) {
|
|
25695
|
+
out[safeKey] = process.env[k];
|
|
24462
25696
|
}
|
|
24463
25697
|
}
|
|
24464
25698
|
if (extraEnv && typeof extraEnv === 'object') {
|
|
24465
25699
|
for (const k of Object.keys(extraEnv)) {
|
|
24466
25700
|
// Allow caller overrides — explicit opt-in
|
|
24467
|
-
if (extraEnv[k] !== undefined) out[k] = String(extraEnv[k]);
|
|
25701
|
+
if (extraEnv[k] !== undefined) out[process.platform === 'win32' ? k.toUpperCase() : k] = String(extraEnv[k]);
|
|
24468
25702
|
}
|
|
24469
25703
|
}
|
|
24470
25704
|
return out;
|
|
@@ -24963,6 +26197,10 @@ function _leernessStateDir(root) { return path.join(absRoot(root), '.leerness');
|
|
|
24963
26197
|
// `../../evil` 같은 경로형이 소유 맵에 들어가 `state handoff` 가 `.leerness` **밖**에 썼다.
|
|
24964
26198
|
// run id 는 우리가 만드는 것이고 모양이 정해져 있다 — 그 모양만 받는다.
|
|
24965
26199
|
const _RUN_ID_RE = /^run-\d{1,12}$/;
|
|
26200
|
+
function _isRunId(id) { return typeof id === 'string' && _RUN_ID_RE.test(id); }
|
|
26201
|
+
function _runStoreError(message, file) {
|
|
26202
|
+
return Object.assign(new Error(message), { code: 'E_STORE_CORRUPT', file });
|
|
26203
|
+
}
|
|
24966
26204
|
function _loadLeernessState(root) {
|
|
24967
26205
|
const f = path.join(_leernessStateDir(root), 'state.json');
|
|
24968
26206
|
if (!exists(f)) return { schemaVersion: 1, project: detectProjectName(root), currentRunId: null, runCounter: 0, updatedAt: null };
|
|
@@ -24974,13 +26212,16 @@ function _loadLeernessState(root) {
|
|
|
24974
26212
|
// 단, runs 디렉토리가 비어 있으면(진짜 신규/빈 상태) 관대하게 기본값 — 없는 데이터를 지킬 필요는 없다.
|
|
24975
26213
|
const _shapeOk = _st && typeof _st === 'object' && !Array.isArray(_st)
|
|
24976
26214
|
&& Number.isInteger(_st.runCounter) && _st.runCounter >= 0
|
|
24977
|
-
|
|
26215
|
+
// 1.36.149 (T-0116 follow-up): 1.36.148 은 세션 소유 맵만 검증했다.
|
|
26216
|
+
// 그런데 legacy/current 전역 슬롯과 run 파일 내부의 run_id 도 같은 파일 경로 병목으로 흐른다.
|
|
26217
|
+
// 여기서 fail-closed 하지 않으면 `currentRunId=../../...` 가 handoff/record 의 쓰기 경로가 된다.
|
|
26218
|
+
&& (_st.currentRunId === null || _isRunId(_st.currentRunId))
|
|
24978
26219
|
// 1.36.144 (재검수 P2): `runsBySession` 이 형태 검증에서 빠져 배열·문자열·null 이 와도 통과했고,
|
|
24979
26220
|
// 그 경우 소유권이 통째로 '없음' 이 되어 **다른 세션의 run 을 인수**하는 fail-open 이 됐다.
|
|
24980
26221
|
// 모양이 아니면 손상으로 다룬다(없는 것은 정상 — 업그레이드 전 상태).
|
|
24981
26222
|
&& (_st.runsBySession === undefined
|
|
24982
26223
|
|| (_st.runsBySession && typeof _st.runsBySession === 'object' && !Array.isArray(_st.runsBySession)
|
|
24983
|
-
&& Object.values(_st.runsBySession).every(
|
|
26224
|
+
&& Object.values(_st.runsBySession).every(_isRunId)));
|
|
24984
26225
|
// 디스크의 최대 run 번호 — 카운터의 진실성 대조 기준
|
|
24985
26226
|
let _maxRun = 0;
|
|
24986
26227
|
try {
|
|
@@ -25005,9 +26246,34 @@ function _saveLeernessState(root, state) {
|
|
|
25005
26246
|
state.updatedAt = new Date().toISOString();
|
|
25006
26247
|
writeUtf8(path.join(dir, 'state.json'), JSON.stringify(state, null, 2) + '\n');
|
|
25007
26248
|
}
|
|
25008
|
-
|
|
25009
|
-
|
|
25010
|
-
|
|
26249
|
+
// 1.36.149 (T-0116 follow-up): state.json 만 검사하면 충분하지 않다. `currentRunId` 와
|
|
26250
|
+
// run record 의 `run_id` 모두 아래 파일 경로로 들어간다. 이 병목에서 다시 확인해
|
|
26251
|
+
// 미래 호출부도 .leerness/runs 밖에 읽거나 쓰지 못하게 한다.
|
|
26252
|
+
function _runFile(root, id) {
|
|
26253
|
+
if (!_isRunId(id)) {
|
|
26254
|
+
throw _runStoreError('.leerness run id 스키마 무효 — 읽기/쓰기 거부 (run-<숫자> 형식만 허용)', path.join(_leernessStateDir(root), 'runs'));
|
|
26255
|
+
}
|
|
26256
|
+
return path.join(_leernessStateDir(root), 'runs', `${id}.json`);
|
|
26257
|
+
}
|
|
26258
|
+
function _loadRun(root, id) {
|
|
26259
|
+
const f = _runFile(root, id);
|
|
26260
|
+
if (!exists(f)) return null;
|
|
26261
|
+
let rec;
|
|
26262
|
+
try { rec = JSON.parse(read(f)); }
|
|
26263
|
+
catch { throw _runStoreError(`.leerness run record 손상 — 읽기/쓰기 거부: ${f}`, f); }
|
|
26264
|
+
if (!rec || typeof rec !== 'object' || Array.isArray(rec) || !_isRunId(rec.run_id) || rec.run_id !== id) {
|
|
26265
|
+
throw _runStoreError(`.leerness run record 스키마 무효 — 읽기/쓰기 거부: ${f}`, f);
|
|
26266
|
+
}
|
|
26267
|
+
return rec;
|
|
26268
|
+
}
|
|
26269
|
+
function _saveRun(root, rec) {
|
|
26270
|
+
if (!rec || typeof rec !== 'object' || Array.isArray(rec)) {
|
|
26271
|
+
throw _runStoreError('.leerness run record 스키마 무효 — 쓰기 거부', path.join(_leernessStateDir(root), 'runs'));
|
|
26272
|
+
}
|
|
26273
|
+
const f = _runFile(root, rec.run_id);
|
|
26274
|
+
mkdirp(path.dirname(f));
|
|
26275
|
+
writeUtf8(f, JSON.stringify(rec, null, 2) + '\n');
|
|
26276
|
+
}
|
|
25011
26277
|
// 1.9.303 (UR-0043): run 레코드 read-modify-write 를 락으로 직렬화 — 동시 state record/verify/handoff lost-update 방지.
|
|
25012
26278
|
function _updateRun(root, id, mutator) {
|
|
25013
26279
|
return _withLock(_runFile(root, id), () => {
|
|
@@ -25482,6 +26748,62 @@ function _mergeMcpJson(root, rel) {
|
|
|
25482
26748
|
writeUtf8(f, JSON.stringify(obj, null, 2) + '\n');
|
|
25483
26749
|
return { file: _rel, action: already ? 'updated' : 'created' };
|
|
25484
26750
|
}
|
|
26751
|
+
|
|
26752
|
+
function _cursorHookPath(root, relativePath) {
|
|
26753
|
+
return path.join(absRoot(root), ...String(relativePath).split('/'));
|
|
26754
|
+
}
|
|
26755
|
+
function _readCursorHooksConfig(root) {
|
|
26756
|
+
const file = _cursorHookPath(root, _cursorHook.CURSOR_HOOK_CONFIG);
|
|
26757
|
+
if (!exists(file)) return {};
|
|
26758
|
+
let config;
|
|
26759
|
+
try { config = JSON.parse(read(file)); }
|
|
26760
|
+
catch { throw Object.assign(new Error(`Cursor hooks.json 이 손상돼 병합을 중단합니다 — 복구 후 재시도: ${file}`), { code: 'E_STORE_CORRUPT', file }); }
|
|
26761
|
+
try { return _cursorHook.validateCursorHooksConfig(config); }
|
|
26762
|
+
catch (error) { throw Object.assign(new Error(`${error.message} — 병합 중단: ${file}`), { code: 'E_STORE_CORRUPT', file }); }
|
|
26763
|
+
}
|
|
26764
|
+
function _assertCursorHookTargets(root) {
|
|
26765
|
+
_readCursorHooksConfig(root);
|
|
26766
|
+
const script = _cursorHookPath(root, _cursorHook.CURSOR_HOOK_SCRIPT);
|
|
26767
|
+
if (exists(script)) {
|
|
26768
|
+
const current = read(script);
|
|
26769
|
+
if (current !== _cursorHook.cursorSessionHookScript() && !_cursorHook.isManagedCursorHookScript(current)) {
|
|
26770
|
+
throw Object.assign(new Error(`Cursor hook script 경로에 사용자 파일이 있어 덮어쓰지 않습니다: ${script}`), { code: 'E_STORE_CONFLICT', file: script });
|
|
26771
|
+
}
|
|
26772
|
+
}
|
|
26773
|
+
}
|
|
26774
|
+
function _mergeCursorSessionHooks(root) {
|
|
26775
|
+
const scriptRel = _cursorHook.CURSOR_HOOK_SCRIPT;
|
|
26776
|
+
const scriptPath = _cursorHookPath(root, scriptRel);
|
|
26777
|
+
let scriptResult;
|
|
26778
|
+
_withLock(scriptPath, () => {
|
|
26779
|
+
const next = _cursorHook.cursorSessionHookScript();
|
|
26780
|
+
const had = exists(scriptPath);
|
|
26781
|
+
const current = had ? read(scriptPath) : '';
|
|
26782
|
+
if (had && current !== next && !_cursorHook.isManagedCursorHookScript(current)) {
|
|
26783
|
+
throw Object.assign(new Error(`Cursor hook script 경로에 사용자 파일이 있어 덮어쓰지 않습니다: ${scriptPath}`), { code: 'E_STORE_CONFLICT', file: scriptPath });
|
|
26784
|
+
}
|
|
26785
|
+
if (current === next) { scriptResult = { file: scriptRel, action: 'preserved' }; return; }
|
|
26786
|
+
mkdirp(path.dirname(scriptPath));
|
|
26787
|
+
writeUtf8(scriptPath, next);
|
|
26788
|
+
scriptResult = { file: scriptRel, action: had ? 'updated' : 'created' };
|
|
26789
|
+
});
|
|
26790
|
+
|
|
26791
|
+
const configRel = _cursorHook.CURSOR_HOOK_CONFIG;
|
|
26792
|
+
const configPath = _cursorHookPath(root, configRel);
|
|
26793
|
+
let configResult;
|
|
26794
|
+
_withLock(configPath, () => {
|
|
26795
|
+
const had = exists(configPath);
|
|
26796
|
+
const currentText = had ? read(configPath) : '';
|
|
26797
|
+
const current = _readCursorHooksConfig(root);
|
|
26798
|
+
const merged = _cursorHook.mergeCursorHooksConfig(current).config;
|
|
26799
|
+
const nextText = JSON.stringify(merged, null, 2) + '\n';
|
|
26800
|
+
if (currentText === nextText) { configResult = { file: configRel, action: 'preserved' }; return; }
|
|
26801
|
+
mkdirp(path.dirname(configPath));
|
|
26802
|
+
writeUtf8(configPath, nextText);
|
|
26803
|
+
configResult = { file: configRel, action: had ? 'updated' : 'created' };
|
|
26804
|
+
});
|
|
26805
|
+
return [configResult, scriptResult];
|
|
26806
|
+
}
|
|
25485
26807
|
// 1.11.4 (UR-0007): 의존성 용어집 — package.json/requirements deps → 큐레이션 카탈로그 plain-ko/en. 미카탈로그는 node_modules 로컬 description fallback, 미정의는 AI 보완 프롬프트. 무LLM·0deps.
|
|
25486
26808
|
function glossaryCmd(root, sub) {
|
|
25487
26809
|
root = absRoot(root || process.cwd());
|
|
@@ -25537,7 +26859,7 @@ function adapterCmd(root, tool, opts = {}) {
|
|
|
25537
26859
|
const json = has('--json');
|
|
25538
26860
|
const dry = has('--dry-run') || opts.dryRun;
|
|
25539
26861
|
if (!tool || tool === 'list') {
|
|
25540
|
-
if (json) { log(JSON.stringify({ adapters: Object.fromEntries(Object.entries(ADAPTERS).map(([k, v]) => [k, { label: v.label, files: v.keys, mcp: v.mcp }])) }, null, 2)); return; }
|
|
26862
|
+
if (json) { log(JSON.stringify({ adapters: Object.fromEntries(Object.entries(ADAPTERS).map(([k, v]) => [k, { label: v.label, files: [...v.keys, ...(v.hookFiles || [])], mcp: v.mcp }])) }, null, 2)); return; }
|
|
25541
26863
|
log(`# leerness adapter (1.9.280, UR-0033) — 도구별 선택 설치`);
|
|
25542
26864
|
log(` init 전체 대신 특정 도구의 지침/연결 파일만 생성합니다. (권장: leerness init . --minimal 후 adapter)`);
|
|
25543
26865
|
log('');
|
|
@@ -25545,7 +26867,7 @@ function adapterCmd(root, tool, opts = {}) {
|
|
|
25545
26867
|
// 1.36.128 (실측): `[+.mcp.json]` 은 **그 도구가 그 파일을 읽는다**는 주장이다. 실제로 두 종(codex·goose)은
|
|
25546
26868
|
// 읽지 않았다(codex: `mcp get` 이 not found · goose: 실효 설정에 미등장). 안 읽는 도구엔 파일 대신 **되는 명령**을 준다.
|
|
25547
26869
|
log(` ${id.padEnd(9)} ${a.label}${a.mcp ? ` [+${(a.mcpFiles || ['.mcp.json']).join(' +')}]` : ''}`);
|
|
25548
|
-
log(` ${a.keys.join(', ')}`);
|
|
26870
|
+
log(` ${[...a.keys, ...(a.hookFiles || [])].join(', ')}`);
|
|
25549
26871
|
if (!a.mcp && a.mcpSetup) log(` MCP: ${a.mcpWhy}
|
|
25550
26872
|
→ ${a.mcpSetup}`);
|
|
25551
26873
|
}
|
|
@@ -25617,6 +26939,7 @@ function adapterCmd(root, tool, opts = {}) {
|
|
|
25617
26939
|
const files = coreFiles(root, lang === 'en' ? 'en' : 'ko', [], { mode: _instMode || undefined });
|
|
25618
26940
|
const managedOverwrite = new Set(['AGENTS.md', 'CLAUDE.md', '.cursor/rules/leerness.mdc', '.github/copilot-instructions.md']);
|
|
25619
26941
|
const planned = a.keys.filter(k => files[k] != null);
|
|
26942
|
+
planned.push(...(a.hookFiles || []));
|
|
25620
26943
|
// 1.36.128 (검수 P1, 실측 재현): dry-run 계획이 `.mcp.json` 만 하드코딩해, cursor 는 실제 실행이
|
|
25621
26944
|
// **계획에 없던 `.cursor/mcp.json`(사용자 설정)** 까지 병합했다 — dry-run 이 보여주지 않은 파일을 건드리면
|
|
25622
26945
|
// 그건 조용한 변경이다. 카탈로그의 `mcpFiles` 를 계획 생성에 그대로 태운다(단일 출처).
|
|
@@ -25630,11 +26953,16 @@ function adapterCmd(root, tool, opts = {}) {
|
|
|
25630
26953
|
}
|
|
25631
26954
|
// 1.36.41 (codex 5차 #1b): mcp 병합이 실패할 파일이면 지침 파일도 쓰기 전에 중단 — 부분 적용 방지(선검증).
|
|
25632
26955
|
if (a.mcp) _assertStoreParsable(path.join(root, '.mcp.json'), '.mcp.json');
|
|
26956
|
+
if (tool === 'cursor') {
|
|
26957
|
+
_assertStoreParsable(path.join(root, '.cursor', 'mcp.json'), '.cursor/mcp.json');
|
|
26958
|
+
_assertCursorHookTargets(root);
|
|
26959
|
+
}
|
|
25633
26960
|
const results = [];
|
|
25634
26961
|
for (const k of a.keys) {
|
|
25635
26962
|
if (files[k] == null) continue;
|
|
25636
26963
|
results.push(writeIfSafe(root, k, files[k], { mergeManaged: managedOverwrite.has(k), lang: lang === 'en' ? 'en' : 'ko' })); // 1.36.60 (검수 2차 #2): 래퍼 언어 전달 (altTemplate 은 전환-감지 없는 이 경로에선 미적용 — High 회귀 방지)
|
|
25637
26964
|
}
|
|
26965
|
+
if (tool === 'cursor') results.push(..._mergeCursorSessionHooks(root));
|
|
25638
26966
|
// 1.36.119 (검수): 아래 안내 문구의 판정 대상을 **MCP 병합 결과로 한정**한다. 전에는 `results` 전체를 훑었고,
|
|
25639
26967
|
// 지금 맞는 이유는 `writeIfSafe` 가 'merged'/'preserved' 만 돌려주기 때문이다 — 그 이름이 바뀌면
|
|
25640
26968
|
// 지침 파일을 썼다는 이유로 "MCP 호출 가능" 이라고 말하게 된다(우연한 정합은 계약이 아니다).
|
|
@@ -25643,7 +26971,7 @@ function adapterCmd(root, tool, opts = {}) {
|
|
|
25643
26971
|
// 1.36.116 (검수 P1-D): Cursor 의 프로젝트별 MCP 설정은 `.cursor/mcp.json` 이다. 루트 `.mcp.json` 만 만들어 놓고
|
|
25644
26972
|
// "Cursor 도 인식한다" 고 말하던 것은 거짓 안내였다 — 이 어댑터의 목적 자체가 그 도구를 배선하는 것이다.
|
|
25645
26973
|
// 루트 파일도 그대로 둔다(다른 도구가 쓴다). 같은 병합기라 손상 파일은 여전히 보존/중단된다.
|
|
25646
|
-
if (tool === 'cursor') {
|
|
26974
|
+
if (tool === 'cursor') { const _m2 = _mergeMcpJson(root, '.cursor/mcp.json'); results.push(_m2); mcpResults.push(_m2); }
|
|
25647
26975
|
if (json) { log(JSON.stringify({ adapter: tool, files: results }, null, 2)); return; }
|
|
25648
26976
|
ok(`adapter ${tool} (${a.label}) 적용 — ${results.length}개 파일`);
|
|
25649
26977
|
for (const r of results) log(` ${r.action}: ${r.file}`);
|
|
@@ -25660,6 +26988,10 @@ function adapterCmd(root, tool, opts = {}) {
|
|
|
25660
26988
|
// 주장은 **파일 단위**로 한다 — 우리가 쓴 파일만 이름을 대고 말한다.
|
|
25661
26989
|
_wrote.forEach(r => log(` ℹ ${r.file} 에 MCP 등록 — 이 파일을 읽는 도구는 leerness MCP verb(state_show/start/record/verify/handoff)를 직접 호출 가능`));
|
|
25662
26990
|
_kept.forEach(r => log(` ℹ ${r.file} 의 기존 leerness 항목을 그대로 두었습니다 — 그 항목이 leerness MCP 를 띄우는지는 확인하지 않았습니다`));
|
|
26991
|
+
if (tool === 'cursor') {
|
|
26992
|
+
log(` ℹ Cursor IDE sessionStart hook 이 대화별 LEERNESS_SESSION_ID 를 자동 설정합니다`);
|
|
26993
|
+
log(` ℹ Cursor Cloud 는 sessionStart hook 을 실행하지 않으므로 그 환경에서는 LEERNESS_SESSION_ID 를 명시하세요`);
|
|
26994
|
+
}
|
|
25663
26995
|
// 1.36.128 (실측): 이 도구가 프로젝트 MCP 파일을 **안 읽는** 경우, 파일을 안 쓰는 것으로 끝내면
|
|
25664
26996
|
// 사용자는 "그럼 어떻게 등록하나" 를 모른 채 남는다. 못 하는 이유와 **되는 명령**을 함께 준다.
|
|
25665
26997
|
if (!a.mcp && a.mcpSetup) {
|
|
@@ -26725,6 +28057,18 @@ function _writeCredentials(root, data) {
|
|
|
26725
28057
|
}
|
|
26726
28058
|
} catch {}
|
|
26727
28059
|
}
|
|
28060
|
+
// 1.36.152 (T-0115): register/refresh 는 같은 JSON을 read→mutate→write 하는 짝이다.
|
|
28061
|
+
// 저장만 잠그면 읽은 사본이 이미 낡아 독립 서비스 등록도 사라진다. 읽기부터 저장까지 한 키로 묶는다.
|
|
28062
|
+
function _updateCredentials(root, mutate) {
|
|
28063
|
+
return _withLock(_credentialsPath(root), () => {
|
|
28064
|
+
const j = _readCredentials(root);
|
|
28065
|
+
j.services = j.services || {};
|
|
28066
|
+
const result = mutate(j);
|
|
28067
|
+
if (result === false) return null; // 변경할 항목이 없으면 mtime도 건드리지 않는다.
|
|
28068
|
+
_writeCredentials(root, j);
|
|
28069
|
+
return result;
|
|
28070
|
+
});
|
|
28071
|
+
}
|
|
26728
28072
|
function credsListCmd(root) {
|
|
26729
28073
|
root = absRoot(root || process.cwd());
|
|
26730
28074
|
const j = _readCredentials(root);
|
|
@@ -26752,16 +28096,17 @@ function credsRegisterCmd(root, service) {
|
|
|
26752
28096
|
const envVars = envVarArg.split(',').map(s => s.trim()).filter(Boolean);
|
|
26753
28097
|
const deployCmd = arg('--deploy', null);
|
|
26754
28098
|
const lifetime = parseInt(arg('--token-lifetime-hours', '0'), 10) || null;
|
|
26755
|
-
|
|
26756
|
-
|
|
26757
|
-
|
|
26758
|
-
|
|
26759
|
-
|
|
26760
|
-
|
|
26761
|
-
|
|
26762
|
-
|
|
26763
|
-
|
|
26764
|
-
|
|
28099
|
+
_updateCredentials(root, j => {
|
|
28100
|
+
const previous = j.services[service] || {};
|
|
28101
|
+
j.services[service] = {
|
|
28102
|
+
envVars,
|
|
28103
|
+
deployCommand: deployCmd || previous.deployCommand || null,
|
|
28104
|
+
tokenLifetimeHours: lifetime || previous.tokenLifetimeHours || null,
|
|
28105
|
+
lastRefreshed: previous.lastRefreshed || null,
|
|
28106
|
+
registeredAt: previous.registeredAt || new Date().toISOString()
|
|
28107
|
+
};
|
|
28108
|
+
return true;
|
|
28109
|
+
});
|
|
26765
28110
|
ok(`creds registered: ${service} · env=${envVars.join(',')}${deployCmd ? ` · deploy="${deployCmd}"` : ''}`);
|
|
26766
28111
|
// 환경변수 즉시 확인
|
|
26767
28112
|
const missing = envVars.filter(v => !process.env[v]);
|
|
@@ -26802,11 +28147,15 @@ function credsCheckCmd(root, service) {
|
|
|
26802
28147
|
function credsRefreshTimestampCmd(root, service) {
|
|
26803
28148
|
root = absRoot(root || process.cwd());
|
|
26804
28149
|
if (!service) return fail('service 이름 필요');
|
|
26805
|
-
|
|
26806
|
-
|
|
26807
|
-
|
|
26808
|
-
|
|
26809
|
-
|
|
28150
|
+
let refreshedAt = null;
|
|
28151
|
+
const updated = _updateCredentials(root, j => {
|
|
28152
|
+
if (!j.services[service]) return false;
|
|
28153
|
+
refreshedAt = new Date().toISOString();
|
|
28154
|
+
j.services[service].lastRefreshed = refreshedAt;
|
|
28155
|
+
return true;
|
|
28156
|
+
});
|
|
28157
|
+
if (!updated) { _assertStoreParsable(_credentialsPath(root), 'credentials'); return fail(`등록된 서비스 없음: ${service} — leerness creds register 먼저`); } // 1.36.114: 손상과 미등록을 가른다
|
|
28158
|
+
ok(`creds refreshed: ${service} · lastRefreshed=${refreshedAt}`);
|
|
26810
28159
|
}
|
|
26811
28160
|
|
|
26812
28161
|
// ---- (2) Incident Handler ----
|
|
@@ -27782,7 +29131,23 @@ function reviewRequestCmd(root, request) { return _reviewRequest.reviewRequestCm
|
|
|
27782
29131
|
// 1.9.392 (UR-0025 큰 핸들러 모듈화 4번째): doctor/which 진단 핸들러를 lib/diagnostics.js 로 분리.
|
|
27783
29132
|
// harness 는 deps(VERSION · _selfTestCases · _detectShellCtx · _mcpToolCount · has · harnessPath)를 구성해 위임(thin wrapper). 호출부/동작 무변경.
|
|
27784
29133
|
const _diag = require('../lib/diagnostics');
|
|
27785
|
-
function
|
|
29134
|
+
function _doctorSelftestReport() {
|
|
29135
|
+
const r = cp.spawnSync(process.execPath, [__filename, 'selftest', '--json'], {
|
|
29136
|
+
cwd: process.cwd(), encoding: 'utf8', timeout: 900000, windowsHide: true,
|
|
29137
|
+
maxBuffer: 4 * 1024 * 1024, shell: false
|
|
29138
|
+
});
|
|
29139
|
+
try {
|
|
29140
|
+
const j = JSON.parse(String(r.stdout || ''));
|
|
29141
|
+
if (!j || !Number.isSafeInteger(j.pass) || !Number.isSafeInteger(j.total) || !Array.isArray(j.results)) throw new Error('invalid report');
|
|
29142
|
+
const failed = j.results.filter(x => !x || x.ok !== true).map(x => String((x && x.name) || 'unnamed selftest'));
|
|
29143
|
+
return { pass: j.pass, total: j.total, ok: r.status === 0 && j.ok === true && failed.length === 0, failed };
|
|
29144
|
+
} catch {
|
|
29145
|
+
const reason = r.error ? String(r.error.code || r.error.message || 'spawn error')
|
|
29146
|
+
: (r.signal ? `signal ${r.signal}` : `exit ${r.status == null ? 'unknown' : r.status}`);
|
|
29147
|
+
return { pass: 0, total: 1, ok: false, failed: [`selftest subprocess ${reason}`] };
|
|
29148
|
+
}
|
|
29149
|
+
}
|
|
29150
|
+
function doctorCmd(opts = {}) { return _diag.doctorCmd(opts, { VERSION, uiLang: _uiLang(arg('--path', process.cwd())), _selfTestCases, _selfTestReport: _doctorSelftestReport, _controlSelftestEnv, _detectShellCtx, _mcpToolCount, has, harnessPath: __filename }); }
|
|
27786
29151
|
function whichCmd() { return _diag.whichCmd({ VERSION, uiLang: _uiLang(arg('--path', process.cwd())), has, harnessPath: __filename }); }
|
|
27787
29152
|
// 1.34.3 (T-0077): `graph --html` → lib/graph.js 온톨로지 HTML(leerness.html) 생성기 위임. 데이터는 in-process 로더 주입(자식 프로세스 셸링 X).
|
|
27788
29153
|
const _graph = require('../lib/graph');
|
|
@@ -27844,7 +29209,7 @@ SECURITY & HYGIENE
|
|
|
27844
29209
|
shell-guard "<command>" [--json] Shell-compatibility linter (PowerShell 5.1 etc.)
|
|
27845
29210
|
|
|
27846
29211
|
HANDOFF & SESSION
|
|
27847
|
-
handoff [path] [--compact] [--all-apps] [--json] Session
|
|
29212
|
+
handoff [path] [--compact] [--all-apps] [--json] [--writeback] Session context; tracked writeback is explicit
|
|
27848
29213
|
session close [path] Closing report + auto handoff
|
|
27849
29214
|
context [path] [--json] Agent onboarding context
|
|
27850
29215
|
retro | insights [path] [--json] [--limit N|--all] Retrospective / cumulative stats (JSON rows: newest 30 by default; rowsTotal = lifetime count, not the --days window)
|
|
@@ -28438,7 +29803,7 @@ async function main() {
|
|
|
28438
29803
|
// 같은 진입점의 나머지 쓰기(tech-profile 갱신 · 그래프 재생성)에도 적용한다. `context budget` 이 측정을 위해
|
|
28439
29804
|
// handoff 를 자식으로 부르는데, 그것만으로 last-handoff.json 과 tech-profile.json 이 바뀌었다(자체 적대 검사로 발견).
|
|
28440
29805
|
// 측정이 관측 대상을 바꾸면 안 된다.
|
|
28441
|
-
if (cmd === 'handoff') { const _hp = arg('--path', args[1] || process.cwd()); const _hr = handoffCmd(_hp); if (process.env.LEERNESS_HOOK !== '1') { try { _tech.refreshTechProfile(_hp); } catch {} _maybeAutoGraph(_hp); } return _hr; } //
|
|
29806
|
+
if (cmd === 'handoff') { const _hp = arg('--path', args[1] || process.cwd()); const _hr = handoffCmd(_hp); if (has('--writeback') && process.env.LEERNESS_HOOK !== '1') { try { _tech.refreshTechProfile(_hp); } catch {} _maybeAutoGraph(_hp); } return _hr; } // T-0136: 기본은 추적 파일 쓰기 0. 명시적 --writeback 만 기존 프로필/그래프 투영을 갱신한다.
|
|
28442
29807
|
if (cmd === 'reuse-map') return reuseMapCmd(arg('--path', args[1] || process.cwd()));
|
|
28443
29808
|
if (cmd === 'verify-claim') { const _p = arg('--path', process.cwd()); if (args[1] === '--all' || has('--all')) return verifyClaimAllCmd(_p); return verifyClaimCmd(_p, args[1]); } // 1.33.2: --all → 모든 done 주장 일괄 검증
|
|
28444
29809
|
if (cmd === 'orchestrate') return await orchestrateCmd(arg('--path', process.cwd()), args.slice(1).filter(x => !x.startsWith('-')));
|
|
@@ -28524,7 +29889,29 @@ async function main() {
|
|
|
28524
29889
|
if (cmd === 'creds' && args[1] === 'refresh') return _guardStore(has('--json'), () => credsRefreshTimestampCmd(arg('--path', process.cwd()), args[2]));
|
|
28525
29890
|
if (cmd === 'deploy' && args[1] === 'auto') return deployAutoCmd(arg('--path', process.cwd()), args[2]);
|
|
28526
29891
|
// 1.9.149: observability lite + runs list/show
|
|
28527
|
-
if (cmd === 'sessions')
|
|
29892
|
+
if (cmd === 'sessions') { // 1.36.129 (P-0016 P1) · T-0129: 미구현 verb 성공오판 차단
|
|
29893
|
+
const positional = args[1] && !args[1].startsWith('-') ? args[1] : null;
|
|
29894
|
+
if (positional) {
|
|
29895
|
+
const candidate = absRoot(positional);
|
|
29896
|
+
const isDirectory = exists(candidate) && fs.statSync(candidate).isDirectory();
|
|
29897
|
+
const looksPath = /^([A-Za-z]:[\\/]|\/|\.\.?[\\/])/.test(positional);
|
|
29898
|
+
if (!isDirectory && !looksPath) {
|
|
29899
|
+
const en = _uiLang(process.cwd()) === 'en';
|
|
29900
|
+
failJson(has('--json'), 'unknown_subcommand', en
|
|
29901
|
+
? `unknown sessions subcommand: ${positional} — this command only lists records; for a path use an explicit form like ./${positional}`
|
|
29902
|
+
: `알 수 없는 sessions 하위명령: ${positional} — 이 명령은 기록 조회만 지원합니다. 경로라면 ./${positional} 처럼 명시하세요`);
|
|
29903
|
+
return;
|
|
29904
|
+
}
|
|
29905
|
+
if (!isDirectory) {
|
|
29906
|
+
const en = _uiLang(process.cwd()) === 'en';
|
|
29907
|
+
failJson(has('--json'), 'path_not_found', en
|
|
29908
|
+
? `path not found or not a directory: ${candidate}`
|
|
29909
|
+
: `경로 없음 또는 디렉토리 아님: ${candidate}`);
|
|
29910
|
+
return;
|
|
29911
|
+
}
|
|
29912
|
+
}
|
|
29913
|
+
return sessionsCmd(absRoot(_resolveRoot(positional)));
|
|
29914
|
+
}
|
|
28528
29915
|
if (cmd === 'runs' && args[1] === 'list') return runsListCmd(absRoot(_resolveRoot(args[2]))); // 1.9.412 (UR-0100): positional path 지원
|
|
28529
29916
|
if (cmd === 'runs' && args[1] === 'show') return runsShowCmd(arg('--path', process.cwd()), args[2]);
|
|
28530
29917
|
// 1.9.85: leerness health — 종합 헬스 체크
|
|
@@ -29102,6 +30489,7 @@ module.exports = {
|
|
|
29102
30489
|
_isContractContentText, _isRecoveryContentText, _isObsContentText, // 1.36.97 (P-0011)
|
|
29103
30490
|
_anyLensContentInFiles, _withLensDomain, _lensDomainsWithContent, _axisHintForFiles, _lensDomainList,
|
|
29104
30491
|
_capLensDomains, _scanLensSignals,
|
|
29105
|
-
_handoffNudgeState, _getLastHandoffGap, _recordLastHandoff,
|
|
30492
|
+
_handoffNudgeState, _getLastHandoffGap, _recordLastHandoff, _recordHandoffFreshness,
|
|
30493
|
+
_releaseFileSnapshot, _runReleaseTransaction,
|
|
29106
30494
|
_detectOptimism, _scanCodeForPatterns
|
|
29107
30495
|
};
|