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