leerness 1.36.107 → 1.36.108
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/bin/leerness.js +256 -94
- package/lib/audit.js +15 -4
- package/lib/session-close.js +26 -14
- package/lib/toggles.js +6 -0
- package/package.json +1 -1
- package/scripts/e2e.js +139 -0
- package/scripts/lock-probe.js +53 -0
package/README.md
CHANGED
|
@@ -122,7 +122,7 @@ MIT
|
|
|
122
122
|
<!-- leerness:project-readme:start -->
|
|
123
123
|
## Leerness Project Harness
|
|
124
124
|
|
|
125
|
-
이 프로젝트는 Leerness v1.36.
|
|
125
|
+
이 프로젝트는 Leerness v1.36.108 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
|
|
126
126
|
|
|
127
127
|
### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
|
|
128
128
|
|
|
@@ -176,7 +176,7 @@ leerness memory restore decision <date|title>
|
|
|
176
176
|
|
|
177
177
|
### MCP server (외부 AI 통합)
|
|
178
178
|
|
|
179
|
-
Leerness v1.36.
|
|
179
|
+
Leerness v1.36.108는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
|
|
180
180
|
|
|
181
181
|
```jsonc
|
|
182
182
|
// 카테고리별
|
|
@@ -197,7 +197,7 @@ Leerness v1.36.107는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
|
|
|
197
197
|
`<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
|
|
198
198
|
1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
|
|
199
199
|
|
|
200
|
-
현재 누적: **v1.9.x → 1.36.
|
|
200
|
+
현재 누적: **v1.9.x → 1.36.108 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
|
|
201
201
|
|
|
202
202
|
### 성능 가이드
|
|
203
203
|
|
|
@@ -235,5 +235,5 @@ leerness release pack --close --auto-main-push
|
|
|
235
235
|
- `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
|
|
236
236
|
- `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
|
|
237
237
|
|
|
238
|
-
Last synced by Leerness v1.36.
|
|
238
|
+
Last synced by Leerness v1.36.108: 2026-08-09
|
|
239
239
|
<!-- leerness:project-readme:end -->
|
package/bin/leerness.js
CHANGED
|
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
|
|
|
34
34
|
const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
|
|
35
35
|
const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
|
|
36
36
|
|
|
37
|
-
const VERSION = '1.36.
|
|
37
|
+
const VERSION = '1.36.108';
|
|
38
38
|
|
|
39
39
|
// 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
|
|
40
40
|
// 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
|
|
@@ -175,6 +175,9 @@ function _withLock(targetPath, fn, opts = {}) {
|
|
|
175
175
|
if (_heldLocks.has(lockPath)) return fn(); // 이미 이 프로세스가 보유 → 재진입(중첩 호출 안전)
|
|
176
176
|
const maxWaitMs = opts.maxWaitMs || 10000; // 1.9.375 (UR-0084): 5s→10s — 고부하 경합 시 fail-open(락 없이 진행) 전 인내 상향, lost-update 창 축소
|
|
177
177
|
const staleMs = opts.staleMs || 30000;
|
|
178
|
+
// ⚠ 여기서 mkdirp 를 빼면 안 된다(1.36.108 에서 한 번 뺐다가 게이트가 5건을 잡았다).
|
|
179
|
+
// `state`·hunts 등 여러 호출부가 **이 mkdirp 가 만들어 주는 디렉토리에 기대어** 곧바로 파일을 쓴다 —
|
|
180
|
+
// 공유 헬퍼의 부작용을 없애면 그 의존이 한꺼번에 끊긴다. 미초기화 디렉토리 문제는 락을 다는 쪽에서 막는다.
|
|
178
181
|
try { mkdirp(path.dirname(lockPath)); } catch {}
|
|
179
182
|
const start = Date.now();
|
|
180
183
|
let held = false;
|
|
@@ -187,8 +190,18 @@ function _withLock(targetPath, fn, opts = {}) {
|
|
|
187
190
|
}
|
|
188
191
|
}
|
|
189
192
|
if (held) _heldLocks.add(lockPath);
|
|
190
|
-
|
|
191
|
-
|
|
193
|
+
// 1.36.108 (T-0097, 자체 적대 검사에서 발견): fn 이 **프로미스를 돌려주면** 동기 finally 가 그 즉시 락을 푼다 —
|
|
194
|
+
// 비동기 본체(네트워크 fetch 등)는 락 없이 도는데 호출부는 보호받는다고 믿는다. 가장 나쁜 조합이다
|
|
195
|
+
// (`provider sync` 를 감싸면서 실제로 이 형태를 만들었다). thenable 이면 **정착 후에** 푼다.
|
|
196
|
+
const release = () => { if (held) { _heldLocks.delete(lockPath); try { fs.unlinkSync(lockPath); } catch {} } };
|
|
197
|
+
let out;
|
|
198
|
+
try { out = fn(); }
|
|
199
|
+
catch (e) { release(); throw e; }
|
|
200
|
+
if (out && typeof out.then === 'function') {
|
|
201
|
+
return out.then(v => { release(); return v; }, e => { release(); throw e; });
|
|
202
|
+
}
|
|
203
|
+
release();
|
|
204
|
+
return out;
|
|
192
205
|
}
|
|
193
206
|
// 1.9.327 (UR-0025): _getLocalTz / _formatLocal → lib/pure-utils.js 로 이동 (순수 TZ/날짜 포맷, require 사용).
|
|
194
207
|
// 자동 모드 활성 여부 (R-XXXX every-round 룰 존재 시 true)
|
|
@@ -2417,8 +2430,16 @@ function _writeUserRequests(root, requests) {
|
|
|
2417
2430
|
return true;
|
|
2418
2431
|
} catch { return false; }
|
|
2419
2432
|
}
|
|
2433
|
+
// 1.36.108 (T-0097): read-modify-write 를 락으로 감싼다. **함수 안에서** 감싸는 것이 핵심이다 —
|
|
2434
|
+
// 호출부마다 거는 방식은 이 저장소에서 이미 실패했다: rules.md 는 add 만 보호되고 pause/resume/remove
|
|
2435
|
+
// 셋이 락 밖이었다(런타임 계측으로 실측). 진입점이 늘면 그만큼 구멍이 는다.
|
|
2436
|
+
// 유실은 가설이 아니라 재현이다 — 2프로세스 프로브에서 동시 `requests add` 한쪽이 통째로 사라졌다
|
|
2437
|
+
// (대조군 previews/decisions 는 B 가 2.4초 대기 후 둘 다 생존 → 락이 실제로 막는다는 것도 같이 확인).
|
|
2420
2438
|
function _recordUserRequest(root, text, opts = {}) {
|
|
2421
2439
|
if (!text || typeof text !== 'string') return null;
|
|
2440
|
+
return _withLock(_userRequestsPath(root), () => _recordUserRequestLocked(root, text, opts));
|
|
2441
|
+
}
|
|
2442
|
+
function _recordUserRequestLocked(root, text, opts) {
|
|
2422
2443
|
const state = _loadUserRequests(root);
|
|
2423
2444
|
if (state.unrecognized) { warn(`user-requests.json 형태 인식 불가 — 기록 건너뜀(원본 덮어쓰기 방지). 파일 확인: ${_userRequestsPath(root)}`); return null; } // 1.36.21: 읽지 못한 데이터 파괴 차단
|
|
2424
2445
|
// 중복 방지: 동일 텍스트 + open 상태 이미 존재 시 skip
|
|
@@ -2443,6 +2464,9 @@ function _recordUserRequest(root, text, opts = {}) {
|
|
|
2443
2464
|
return entry;
|
|
2444
2465
|
}
|
|
2445
2466
|
function _updateUserRequest(root, id, patch) {
|
|
2467
|
+
return _withLock(_userRequestsPath(root), () => _updateUserRequestLocked(root, id, patch));
|
|
2468
|
+
}
|
|
2469
|
+
function _updateUserRequestLocked(root, id, patch) {
|
|
2446
2470
|
const state = _loadUserRequests(root);
|
|
2447
2471
|
if (state.unrecognized) { warn(`user-requests.json 형태 인식 불가 — 업데이트 건너뜀(원본 덮어쓰기 방지). 파일 확인: ${_userRequestsPath(root)}`); return null; } // 1.36.21
|
|
2448
2472
|
const idx = state.requests.findIndex(r => r && r.id === id);
|
|
@@ -6399,7 +6423,7 @@ function _selfTestCases() {
|
|
|
6399
6423
|
return clar && shape && noDbUrl && reqOk && guards && uiOk && _p0013LibraryOk() && _p0088CurrentStatePreserved()
|
|
6400
6424
|
&& _p0101SurfaceOk() && _p0101SkillIdOk() && _p0101PathStrictOk() && _p0102HonestyOk()
|
|
6401
6425
|
&& _p0103FlagsOk() && _p0103JsonOk() && _p0103RootOk() && _p0104UsageOk()
|
|
6402
|
-
&& _p0014SecretBaselineOk() && _p0104ReminderOk() && _p0015ModeOk();
|
|
6426
|
+
&& _p0014SecretBaselineOk() && _p0104ReminderOk() && _p0015ModeOk() && _p0097AsyncLockOk();
|
|
6403
6427
|
} },
|
|
6404
6428
|
{ name: '시크릿 스캐너 F-06 (1.36.56, 외부감사): 무명 확장자 소형 텍스트 스캔(이진 NUL 제외) + 같은 토큰 중복 보고 dedupe — 행위검사', run: () => {
|
|
6405
6429
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sc56_'));
|
|
@@ -8071,6 +8095,40 @@ function _p0015ModeOk() {
|
|
|
8071
8095
|
}
|
|
8072
8096
|
// 1.36.104: handoff 가 사람이 쓴 .harness/agent-reminders.md 를 통째로 지웠다(exit 0 · 안내 없음 · 백업 없음).
|
|
8073
8097
|
// 대조군이 필수다 — 자동 생성분까지 남기면 그 파일이 영구 잔존해 매 세션 거짓 경보가 된다.
|
|
8098
|
+
// 1.36.108 (T-0097, 자체 적대 검사에서 발견): `_withLock` 에 **비동기 fn** 을 넘기면 동기 finally 가
|
|
8099
|
+
// 프로미스가 정착하기 전에 락을 푼다 — 본체는 락 없이 돌면서 호출부는 보호받는다고 믿는다.
|
|
8100
|
+
// `provider sync`(15초 네트워크 fetch)를 감싸며 실제로 이 형태를 만들었고, e2e 는 그 명령을 돌리지 않아 못 잡았다.
|
|
8101
|
+
// 순수 호출로 계약을 고정한다: 프로미스가 끝나기 전에는 락 파일이 살아 있어야 하고, 끝나면 사라져야 한다.
|
|
8102
|
+
// selftest 는 `c.run() === true` 만 통과시키므로(프로미스는 자동 실패) **동기 thenable** 로 같은 분기를
|
|
8103
|
+
// 결정적으로 밟는다 — `typeof out.then === 'function'` 이라는 판별 조건이 동일하다.
|
|
8104
|
+
function _p0097AsyncLockOk() {
|
|
8105
|
+
const os2 = require('os');
|
|
8106
|
+
const arena = fs.mkdtempSync(path.join(os2.tmpdir(), 'leerness-p97lk-'));
|
|
8107
|
+
try {
|
|
8108
|
+
const target = path.join(arena, 'store.json');
|
|
8109
|
+
const lockFile = target + '.lock';
|
|
8110
|
+
fs.writeFileSync(target, '{}');
|
|
8111
|
+
// ① 비동기 본체: 아직 정착하지 않은 동안 락이 살아 있어야 한다(조기 해제 = 보호 0).
|
|
8112
|
+
let heldWhilePending = null;
|
|
8113
|
+
const thenable = { then(onOk) { heldWhilePending = fs.existsSync(lockFile); onOk('done'); return 'settled'; } };
|
|
8114
|
+
const r1 = _withLock(target, () => thenable);
|
|
8115
|
+
const releasedAfterSettle = !fs.existsSync(lockFile) && r1 === 'settled';
|
|
8116
|
+
// ② 대조군 — 동기 fn 은 종전대로 즉시 해제되고 값을 그대로 돌려준다(회귀로 락을 붙잡지 않았는지).
|
|
8117
|
+
const r2 = _withLock(target, () => 42);
|
|
8118
|
+
const syncReleased = !fs.existsSync(lockFile) && r2 === 42;
|
|
8119
|
+
// ③ 예외 경로도 해제되어야 한다 — 안 그러면 한 번 던진 뒤 그 스토어가 영구 잠긴다.
|
|
8120
|
+
let threw = false;
|
|
8121
|
+
try { _withLock(target, () => { throw new Error('x'); }); } catch { threw = true; }
|
|
8122
|
+
const errReleased = threw && !fs.existsSync(lockFile);
|
|
8123
|
+
// ④ 거부된 thenable 도 해제 + 사유 전파.
|
|
8124
|
+
let rejThrew = false;
|
|
8125
|
+
const badThenable = { then(onOk, onErr) { onErr(new Error('boom')); return 'rejected'; } };
|
|
8126
|
+
try { _withLock(target, () => badThenable); } catch { rejThrew = true; }
|
|
8127
|
+
const rejReleased = rejThrew && !fs.existsSync(lockFile);
|
|
8128
|
+
return heldWhilePending === true && releasedAfterSettle && syncReleased && errReleased && rejReleased;
|
|
8129
|
+
} catch { return false; }
|
|
8130
|
+
finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch {} }
|
|
8131
|
+
}
|
|
8074
8132
|
function _p0104ReminderOk() {
|
|
8075
8133
|
const os2 = require('os');
|
|
8076
8134
|
const cp2 = require('child_process');
|
|
@@ -9062,13 +9120,16 @@ function _runPreWakeAudit(root) {
|
|
|
9062
9120
|
};
|
|
9063
9121
|
return audit;
|
|
9064
9122
|
}
|
|
9123
|
+
// 1.36.108 (T-0097): append 형 RMW — session close 와 pre-wake-audit 가 겹칠 수 있다.
|
|
9065
9124
|
function _saveAndAppendPreWakeReport(root, audit) {
|
|
9066
|
-
|
|
9067
|
-
|
|
9068
|
-
|
|
9069
|
-
|
|
9070
|
-
|
|
9071
|
-
|
|
9125
|
+
return _withLock(_preWakeReportPath(root), () => {
|
|
9126
|
+
const state = _loadPreWakeReport(root);
|
|
9127
|
+
state.reports.push(audit);
|
|
9128
|
+
// 최근 10개만 유지
|
|
9129
|
+
if (state.reports.length > 10) state.reports = state.reports.slice(-10);
|
|
9130
|
+
_writePreWakeReport(root, state.reports);
|
|
9131
|
+
return audit;
|
|
9132
|
+
});
|
|
9072
9133
|
}
|
|
9073
9134
|
|
|
9074
9135
|
// 1.9.220: 비정상 종료 감지 + 자율 재개 (사용자 명시)
|
|
@@ -9226,14 +9287,18 @@ function _writeWakeupHistory(root, state) {
|
|
|
9226
9287
|
return true;
|
|
9227
9288
|
} catch { return false; }
|
|
9228
9289
|
}
|
|
9290
|
+
// 1.36.108 (T-0097): append 형 RMW — 락 밖이면 동시 기록이 서로를 지운다.
|
|
9291
|
+
// 이 스토어는 자동(스케줄)과 사용자 트리거가 동시에 들어올 수 있어 겹칠 여지가 실제로 있다.
|
|
9229
9292
|
function _recordWakeupFire(root, kind) {
|
|
9230
9293
|
// kind: 'auto' (scheduled), 'user-trigger' (사용자 명시 다음라운드), 'wakeup-miss'
|
|
9231
|
-
|
|
9232
|
-
|
|
9233
|
-
|
|
9234
|
-
|
|
9235
|
-
|
|
9236
|
-
|
|
9294
|
+
return _withLock(_wakeupHistoryPath(root), () => {
|
|
9295
|
+
const state = _loadWakeupHistory(root);
|
|
9296
|
+
state.fires.push({ at: new Date().toISOString(), kind: kind || 'auto' });
|
|
9297
|
+
// 최근 50개만 유지
|
|
9298
|
+
if (state.fires.length > 50) state.fires = state.fires.slice(-50);
|
|
9299
|
+
_writeWakeupHistory(root, state);
|
|
9300
|
+
return state;
|
|
9301
|
+
});
|
|
9237
9302
|
}
|
|
9238
9303
|
// 권장 interval 계산 (sec)
|
|
9239
9304
|
// user-trigger 빈도 + pre-wake critical + 최근 평균 gap 기반
|
|
@@ -9891,6 +9956,13 @@ function sessionResumeCmd(root) {
|
|
|
9891
9956
|
// add <id> --aliases ... --constraint "kind:detail" → 사용자 정의 플랫폼 추가
|
|
9892
9957
|
function constraintsCmd(root, sub, ...rest) {
|
|
9893
9958
|
root = absRoot(root);
|
|
9959
|
+
// 1.36.108 (T-0097): 변경 하위명령은 읽기~쓰기 전체를 직렬화한다(clarify/referee 와 같은 재진입 패턴).
|
|
9960
|
+
// 손상 거부(1.36.78)는 손상만 막고 **동시 쓰기 유실**은 못 막는다 — 두 제약을 동시에 추가하면 하나가 조용히 사라진다.
|
|
9961
|
+
if (sub === 'add' && !_withLock.__inConstraints) {
|
|
9962
|
+
_withLock.__inConstraints = true;
|
|
9963
|
+
try { return _withLock(_platformConstraintsPath(root), () => constraintsCmd(root, sub, ...rest)); }
|
|
9964
|
+
finally { _withLock.__inConstraints = false; }
|
|
9965
|
+
}
|
|
9894
9966
|
const isTty = process.stdout && process.stdout.isTTY;
|
|
9895
9967
|
const cyan = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
|
|
9896
9968
|
const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
|
|
@@ -10135,9 +10207,12 @@ function wakeupIntervalCmd(root, sub, val) {
|
|
|
10135
10207
|
if (sub === 'set') {
|
|
10136
10208
|
const n = Number(val);
|
|
10137
10209
|
if (!val || isNaN(n) || n < 60) { fail('Usage: leerness wakeup-interval set <secs ≥ 60>'); process.exit(1); } // 1.36.50 (스윕 B)
|
|
10138
|
-
|
|
10139
|
-
|
|
10140
|
-
|
|
10210
|
+
// 1.36.108 (T-0097): override 만 바꾸는데도 파일 전체를 다시 쓴다 — 락 밖이면 그 사이 기록된 fires 가 사라진다.
|
|
10211
|
+
_withLock(_wakeupHistoryPath(root), () => {
|
|
10212
|
+
const state = _loadWakeupHistory(root);
|
|
10213
|
+
state.override = n;
|
|
10214
|
+
_writeWakeupHistory(root, state);
|
|
10215
|
+
});
|
|
10141
10216
|
if (has('--json')) { log(JSON.stringify({ override: n }, null, 2)); return; }
|
|
10142
10217
|
log(grn(`✓ wakeup-interval override 설정: ${n}s (${Math.round(n/60)}min)`));
|
|
10143
10218
|
log(dim(` 해제: leerness wakeup-interval auto`));
|
|
@@ -10145,9 +10220,11 @@ function wakeupIntervalCmd(root, sub, val) {
|
|
|
10145
10220
|
}
|
|
10146
10221
|
|
|
10147
10222
|
if (sub === 'auto') {
|
|
10148
|
-
|
|
10149
|
-
|
|
10150
|
-
|
|
10223
|
+
_withLock(_wakeupHistoryPath(root), () => {
|
|
10224
|
+
const state = _loadWakeupHistory(root);
|
|
10225
|
+
state.override = null;
|
|
10226
|
+
_writeWakeupHistory(root, state);
|
|
10227
|
+
});
|
|
10151
10228
|
const result = _computeAdaptiveInterval(root);
|
|
10152
10229
|
if (has('--json')) { log(JSON.stringify({ override: null, current: result }, null, 2)); return; }
|
|
10153
10230
|
log(grn(`✓ override 해제 — 자동 계산 모드`));
|
|
@@ -10864,21 +10941,25 @@ function _writeNextActionQueue(root, queue) {
|
|
|
10864
10941
|
} catch { return false; }
|
|
10865
10942
|
}
|
|
10866
10943
|
// handoff 에서 제안된 next-action 들을 큐에 자동 저장 (중복 방지: 이미 있는 title 은 skip)
|
|
10944
|
+
// 1.36.108 (T-0097): 읽기~쓰기를 락으로 직렬화. 이 큐는 handoff 가 자동으로도 채우기 때문에
|
|
10945
|
+
// 사용자가 `next-action add` 하는 순간 handoff 가 겹칠 수 있다 — 실사용에서 가장 겹치기 쉬운 조합이다.
|
|
10867
10946
|
function _enqueueNextActions(root, actions) {
|
|
10868
10947
|
if (!Array.isArray(actions) || actions.length === 0) return 0;
|
|
10869
|
-
|
|
10870
|
-
|
|
10871
|
-
|
|
10872
|
-
|
|
10873
|
-
|
|
10874
|
-
|
|
10875
|
-
|
|
10876
|
-
|
|
10877
|
-
|
|
10878
|
-
|
|
10879
|
-
|
|
10880
|
-
|
|
10881
|
-
|
|
10948
|
+
return _withLock(_nextActionQueuePath(root), () => {
|
|
10949
|
+
const state = _loadNextActionQueue(root);
|
|
10950
|
+
const existingTitles = new Set(state.queue.map(a => a.title));
|
|
10951
|
+
let added = 0;
|
|
10952
|
+
for (const a of actions) {
|
|
10953
|
+
if (existingTitles.has(a.title)) continue;
|
|
10954
|
+
state.queue.push({ ...a, addedAt: new Date().toISOString() });
|
|
10955
|
+
existingTitles.add(a.title);
|
|
10956
|
+
added++;
|
|
10957
|
+
}
|
|
10958
|
+
// 최근 20개만 유지
|
|
10959
|
+
if (state.queue.length > 20) state.queue = state.queue.slice(-20);
|
|
10960
|
+
if (added > 0) _writeNextActionQueue(root, state.queue);
|
|
10961
|
+
return added;
|
|
10962
|
+
});
|
|
10882
10963
|
}
|
|
10883
10964
|
|
|
10884
10965
|
// 1.9.201: next-action CLI 명령 — handoff 가 저장한 queue 에서 take/list/clear
|
|
@@ -10916,8 +10997,14 @@ async function nextActionCmd(root, sub, ...rest) {
|
|
|
10916
10997
|
try {
|
|
10917
10998
|
const taskTitle = action.title.replace(/^[^\w가-힣]+/, '').slice(0, 100);
|
|
10918
10999
|
// 큐에서 제거 → write
|
|
10919
|
-
|
|
10920
|
-
|
|
11000
|
+
// 1.36.108 (T-0097): 락 안에서 **다시 읽고** 제거한다. 함수 머리에서 읽은 state 로 지우면
|
|
11001
|
+
// 그 사이에 handoff 가 큐에 넣은 항목이 통째로 사라진다(handoff 는 자동으로 이 큐를 채운다).
|
|
11002
|
+
_withLock(_nextActionQueuePath(root), () => {
|
|
11003
|
+
const fresh = _loadNextActionQueue(root);
|
|
11004
|
+
const idx = fresh.queue.findIndex(a => a && a.title === action.title);
|
|
11005
|
+
if (idx >= 0) fresh.queue.splice(idx, 1);
|
|
11006
|
+
_writeNextActionQueue(root, fresh.queue);
|
|
11007
|
+
});
|
|
10921
11008
|
// leerness task add 호출
|
|
10922
11009
|
const taskResult = cp.spawnSync(process.execPath, [__filename, 'task', 'add', taskTitle, '--path', root], { encoding: 'utf8', timeout: 8000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
|
|
10923
11010
|
if (taskResult.status === 0) {
|
|
@@ -12423,7 +12510,7 @@ function debug(root) {
|
|
|
12423
12510
|
|
|
12424
12511
|
const _audit = require('../lib/audit');
|
|
12425
12512
|
// 1.9.421 (UR-0025/UR-0125 큰 핸들러 모듈화 6번째): audit → lib/audit.js (DI 위임, thin wrapper)
|
|
12426
|
-
function audit(root, opts = {}) { return _audit.audit(root, opts, { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills, _collectSecretFindings, _integrityFindings }); } // 1.36.79: integrity 신호 공유
|
|
12513
|
+
function audit(root, opts = {}) { return _audit.audit(root, opts, { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills, _collectSecretFindings, _integrityFindings, _withLock }); } // 1.36.79: integrity 신호 공유 · 1.36.108: 락 주입(T-0097)
|
|
12427
12514
|
|
|
12428
12515
|
// 1.9.312 (UR-0050, 설치리뷰 3중수렴): secret 스캐너 현대 키 패턴 보강.
|
|
12429
12516
|
// 배경: 기존 OpenAI 패턴 `sk-[A-Za-z0-9]{32,}` 은 하이픈에서 끊겨 sk-proj-/sk-svcacct- (modern 프로젝트/서비스 키)를 놓침.
|
|
@@ -13868,9 +13955,13 @@ function handoff(root) {
|
|
|
13868
13955
|
} else {
|
|
13869
13956
|
log(out);
|
|
13870
13957
|
}
|
|
13958
|
+
// 1.36.108 (T-0097): 날짜 스탬프도 read-modify-write 다 — 사용자가 쓴 본문 전체를 다시 쓴다.
|
|
13959
|
+
// 락 밖이면 그 사이 session close 가 갱신한 Now/Next/Blockers 를 낡은 사본으로 되돌린다.
|
|
13871
13960
|
if (exists(currentStatePath(root))) {
|
|
13872
|
-
|
|
13873
|
-
|
|
13961
|
+
_withLock(currentStatePath(root), () => {
|
|
13962
|
+
const cs = read(currentStatePath(root)).replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
|
|
13963
|
+
writeUtf8(currentStatePath(root), cs);
|
|
13964
|
+
});
|
|
13874
13965
|
}
|
|
13875
13966
|
// 1.9.56: handoff에 lessons --auto 자동 통합 — 현재 in-progress task와 관련된 과거 실수/결정 자동 재상기
|
|
13876
13967
|
// 매 세션 시작 시 AI가 과거에 같은 키워드로 실패한 사례를 잊지 않도록.
|
|
@@ -16284,22 +16375,26 @@ function _loadAgentSlashCommands(root) {
|
|
|
16284
16375
|
// 기존 사용자 override 가 있으면 보존(병합 결과 기록). force 시 빌트인으로 덮어쓰기.
|
|
16285
16376
|
function _recordAgentSlashCommands(root, opts = {}) {
|
|
16286
16377
|
const f = _agentSlashFile(root);
|
|
16287
|
-
let agents;
|
|
16288
|
-
if (opts.force || !exists(f)) {
|
|
16289
|
-
agents = {};
|
|
16290
|
-
for (const [id, def] of Object.entries(AGENT_SLASH_COMMANDS)) {
|
|
16291
|
-
agents[id] = { label: def.label, asOf: def.asOf, invoke: def.invoke || 'slash', note: def.note || undefined, commands: def.commands.slice() };
|
|
16292
|
-
}
|
|
16293
|
-
} else {
|
|
16294
|
-
const merged = _loadAgentSlashCommands(root);
|
|
16295
|
-
agents = {};
|
|
16296
|
-
for (const [id, def] of Object.entries(merged)) {
|
|
16297
|
-
agents[id] = { label: def.label, asOf: def.asOf, invoke: def.invoke, note: def.note || undefined, commands: def.commands };
|
|
16298
|
-
}
|
|
16299
|
-
}
|
|
16300
16378
|
mkdirp(path.dirname(f));
|
|
16301
|
-
|
|
16302
|
-
|
|
16379
|
+
// 1.36.108 (T-0097): 비-force 경로는 기존 파일을 읽어 병합한 결과를 쓴다 = read-modify-write.
|
|
16380
|
+
// 락 밖이면 그 사이 --refresh 가 probe 로 갱신한 agent 가 낡은 병합본에 덮인다(둘 다 이 파일을 쓴다).
|
|
16381
|
+
return _withLock(f, () => {
|
|
16382
|
+
let agents;
|
|
16383
|
+
if (opts.force || !exists(f)) {
|
|
16384
|
+
agents = {};
|
|
16385
|
+
for (const [id, def] of Object.entries(AGENT_SLASH_COMMANDS)) {
|
|
16386
|
+
agents[id] = { label: def.label, asOf: def.asOf, invoke: def.invoke || 'slash', note: def.note || undefined, commands: def.commands.slice() };
|
|
16387
|
+
}
|
|
16388
|
+
} else {
|
|
16389
|
+
const merged = _loadAgentSlashCommands(root);
|
|
16390
|
+
agents = {};
|
|
16391
|
+
for (const [id, def] of Object.entries(merged)) {
|
|
16392
|
+
agents[id] = { label: def.label, asOf: def.asOf, invoke: def.invoke, note: def.note || undefined, commands: def.commands };
|
|
16393
|
+
}
|
|
16394
|
+
}
|
|
16395
|
+
writeUtf8(f, JSON.stringify({ schemaVersion: 1, recordedAt: new Date().toISOString(), agents }, null, 2) + '\n');
|
|
16396
|
+
return { file: f, agentCount: Object.keys(agents).length };
|
|
16397
|
+
});
|
|
16303
16398
|
}
|
|
16304
16399
|
// dispatch 참조용 — 특정 agent 의 슬래시 명령어 힌트 (서브에이전트 프롬프트에 주입할 1줄 요약 + 배열).
|
|
16305
16400
|
// 2단계에서 agents multi/agent dispatch 가 이 헬퍼로 각 에이전트에 맞는 슬래시 명령을 안내.
|
|
@@ -16375,7 +16470,17 @@ function _refreshAgentSlashCommands(root, targets, opts = {}) {
|
|
|
16375
16470
|
if (anyOk && !opts.dryRun) {
|
|
16376
16471
|
file = _agentSlashFile(root);
|
|
16377
16472
|
mkdirp(path.dirname(file));
|
|
16378
|
-
|
|
16473
|
+
// 1.36.108 (T-0097): probe 는 외부 CLI 를 띄우느라 오래 걸린다 — **락 밖에서** 돌리고 저장만 락 안에서 한다
|
|
16474
|
+
// (lib/referee.js 가 캘리브레이션에 세운 규칙과 같다: 긴 작업을 락 안에 넣으면 stale 30s 에 락을 뺏겨
|
|
16475
|
+
// 오히려 남의 결과를 지운다). 락 안에서 파일을 **다시 읽어** 이번에 성공한 agent 만 덮어쓴다.
|
|
16476
|
+
const probed = results.filter(r => r.ok).map(r => r.id);
|
|
16477
|
+
_withLock(file, () => {
|
|
16478
|
+
const fresh = _loadAgentSlashCommands(root);
|
|
16479
|
+
const out = {};
|
|
16480
|
+
for (const [id, d] of Object.entries(fresh)) out[id] = { label: d.label, asOf: d.asOf, invoke: d.invoke, note: d.note || undefined, commands: d.commands };
|
|
16481
|
+
for (const id of probed) out[id] = agents[id];
|
|
16482
|
+
writeUtf8(file, JSON.stringify({ schemaVersion: 1, recordedAt: new Date().toISOString(), refreshedAt: new Date().toISOString(), agents: out }, null, 2) + '\n');
|
|
16483
|
+
});
|
|
16379
16484
|
}
|
|
16380
16485
|
return { results, file, anyOk };
|
|
16381
16486
|
}
|
|
@@ -16532,6 +16637,14 @@ function _allProviders(root) {
|
|
|
16532
16637
|
}
|
|
16533
16638
|
function providerCmd(root, sub, ...args) {
|
|
16534
16639
|
root = absRoot(root || process.cwd());
|
|
16640
|
+
// 1.36.108 (T-0097): 변경 하위명령은 읽기~쓰기를 직렬화. 목록 RMW 라 동시 등록 시 한쪽이 조용히 사라진다(실측).
|
|
16641
|
+
// `sync` 는 **여기서 감싸지 않는다** — 최대 15초 네트워크 fetch 를 락 안에 두면 stale(30s) 창에 다가가고,
|
|
16642
|
+
// 다른 프로세스가 락을 뺏어 둘 다 쓰게 된다(lib/referee.js 가 캘리브레이션에 세운 규칙). 저장만 아래에서 감싼다.
|
|
16643
|
+
if (['add', 'remove'].includes(sub) && !providerCmd.__locked) {
|
|
16644
|
+
providerCmd.__locked = true;
|
|
16645
|
+
try { return _withLock(_providersFile(root), () => providerCmd(root, sub, ...args)); }
|
|
16646
|
+
finally { providerCmd.__locked = false; }
|
|
16647
|
+
}
|
|
16535
16648
|
_loadEnvFile(root);
|
|
16536
16649
|
if (!sub || sub === 'list') {
|
|
16537
16650
|
const all = _allProviders(root);
|
|
@@ -16683,7 +16796,14 @@ function providerCmd(root, sub, ...args) {
|
|
|
16683
16796
|
log(` + ${e.id} (신규)`);
|
|
16684
16797
|
}
|
|
16685
16798
|
}
|
|
16686
|
-
|
|
16799
|
+
// 1.36.108 (T-0097): 네트워크는 락 밖에서 끝났다. 저장만 락 안에서 하되 **다시 읽어** 병합한다 —
|
|
16800
|
+
// 위의 userList 는 fetch 전 스냅샷이라, 그 사이 `provider add` 한 항목을 그대로 덮으면 지운다.
|
|
16801
|
+
if (!dryRun) _withLock(_providersFile(root), () => {
|
|
16802
|
+
const fresh = _readUserProviders(root);
|
|
16803
|
+
const byId = new Map(fresh.map(u => [u.id, u]));
|
|
16804
|
+
for (const u of userList) byId.set(u.id, u); // 이번 sync 결과가 이긴다(그게 sync 의 의미다)
|
|
16805
|
+
_writeUserProviders(root, [...byId.values()]);
|
|
16806
|
+
});
|
|
16687
16807
|
log('');
|
|
16688
16808
|
log(`✓ sync 완료: 신규 ${added} · 갱신 ${updated} · 무시 ${skipped}${dryRun ? ' (--dry-run)' : ''}`);
|
|
16689
16809
|
if (!dryRun) log(` .env 에 LEERNESS_ENABLE_<X>=1 설정 후 \`leerness agents list\` 로 확인`);
|
|
@@ -17659,7 +17779,7 @@ function llmBenchRecordCmd(root) {
|
|
|
17659
17779
|
|
|
17660
17780
|
const _sessionClose = require('../lib/session-close');
|
|
17661
17781
|
// 1.9.425 (UR-0025/UR-0125 큰 핸들러 모듈화 10번째): sessionClose → lib/session-close.js (DI 위임)
|
|
17662
|
-
function sessionClose(root, opts = {}) { return _sessionClose.sessionClose(root, opts, { VERSION, STATUSES, MARK, has, arg, uiLang: _uiLang(root), harnessPath: __filename, readProgressRows, evidencePath, handoffPath, currentStatePath, taskLogPath, verifyRules, _autoRoadmap, _readUsageStats, readSessionCounter, writeSessionCounter, _retroAggregate, _retroOneLine, retroCmd, _loadDecisions, readRules, planPath, _loadLessons, _readFeatureGraph, _auditUserRequests, _detectDeliveredRequests, _computeRoundHistory, _computeMilestones, _computeRecentChanges, _collectPyFiles, _analyzePyFile, _collectRuntimeEnv, _scanShellScriptsEncoding, _listAPISkills, _matchAPISkills, _loadShellFailures, _shellEnvDrift, _runPreWakeAudit, _saveAndAppendPreWakeReport, _runIdempotencyAudit, _detectAbnormalShutdown, _updateUserRequest, _detectOptimism, _scanCodeForPatterns, _collectSecretFindings }); } // 1.17.6 (UR-0049): 마감 정합 — done 낙관 재확인 + 시크릿 재확인
|
|
17782
|
+
function sessionClose(root, opts = {}) { return _sessionClose.sessionClose(root, opts, { VERSION, STATUSES, MARK, has, arg, uiLang: _uiLang(root), harnessPath: __filename, readProgressRows, evidencePath, handoffPath, currentStatePath, taskLogPath, verifyRules, _autoRoadmap, _readUsageStats, readSessionCounter, writeSessionCounter, _retroAggregate, _retroOneLine, retroCmd, _loadDecisions, readRules, planPath, _loadLessons, _readFeatureGraph, _auditUserRequests, _detectDeliveredRequests, _computeRoundHistory, _computeMilestones, _computeRecentChanges, _collectPyFiles, _analyzePyFile, _collectRuntimeEnv, _scanShellScriptsEncoding, _listAPISkills, _matchAPISkills, _loadShellFailures, _shellEnvDrift, _runPreWakeAudit, _saveAndAppendPreWakeReport, _runIdempotencyAudit, _detectAbnormalShutdown, _updateUserRequest, _detectOptimism, _scanCodeForPatterns, _collectSecretFindings, _withLock }); } // 1.17.6 (UR-0049): 마감 정합 — done 낙관 재확인 + 시크릿 재확인 · 1.36.108: 락 주입(T-0097)
|
|
17663
17783
|
|
|
17664
17784
|
function readmeCmd(root) { syncReadme(absRoot(root)); }
|
|
17665
17785
|
function consistencyCheck(root) {
|
|
@@ -18991,28 +19111,48 @@ function ruleList(root) {
|
|
|
18991
19111
|
for (const r of rules) log(`| ${r.id} | ${_cellSafe(r.trigger)} | ${_cellSafe(r.rule)} | ${r.status} | ${r.lastVerified} |`); // 16th 버그헌트 F2: 파이프 칼럼 깨짐 차단(표시 _cellSafe)
|
|
18992
19112
|
}
|
|
18993
19113
|
|
|
19114
|
+
// 1.36.108 (T-0097): rules.md 의 read-modify-write 병목. 종전엔 `rule add` **하나만** 락 안이었고
|
|
19115
|
+
// remove/pause/resume/stop/resume-all 다섯이 락 밖이었다(런타임 계측으로 실측 — 같은 스토어, 진입점 6개, 보호 1개).
|
|
19116
|
+
// 진입점마다 거는 방식은 이렇게 샌다. 읽기와 쓰기를 한 함수에 묶어 그 안에서 직렬화한다.
|
|
19117
|
+
// mutate 가 문자열을 돌려주면 그것을 실패 사유로 보고 쓰기를 건너뛴다(락 안에서 중단).
|
|
19118
|
+
function _updateRules(root, mutate) {
|
|
19119
|
+
return _withLock(rulesPath(root), () => {
|
|
19120
|
+
const rules = readRules(root);
|
|
19121
|
+
const r = mutate(rules);
|
|
19122
|
+
if (typeof r === 'string') return { error: r };
|
|
19123
|
+
writeRules(root, rules);
|
|
19124
|
+
return { ok: true, value: r };
|
|
19125
|
+
});
|
|
19126
|
+
}
|
|
19127
|
+
|
|
18994
19128
|
function ruleRemove(root, id) {
|
|
18995
19129
|
root = absRoot(root);
|
|
18996
19130
|
if (!id) return fail('id required');
|
|
18997
|
-
|
|
18998
|
-
const
|
|
18999
|
-
|
|
19000
|
-
|
|
19001
|
-
|
|
19002
|
-
|
|
19003
|
-
|
|
19004
|
-
|
|
19131
|
+
let removed = null;
|
|
19132
|
+
const res = _updateRules(root, (rules) => {
|
|
19133
|
+
const i = rules.findIndex(r => r.id === id);
|
|
19134
|
+
if (i < 0) return 'rule_not_found';
|
|
19135
|
+
removed = rules.splice(i, 1)[0];
|
|
19136
|
+
// 아카이브 추가도 같은 락 안에서 — 표 갱신과 아카이브가 갈라지면 룰이 양쪽 어디에도 없게 된다.
|
|
19137
|
+
const archive = exists(rulesArchivePath(root)) ? read(rulesArchivePath(root)) : '# Rules archive\n\n| ID | Trigger | Rule | Added | Status | Removed |\n|---|---|---|---|---|---|\n';
|
|
19138
|
+
// 1.11.3 (14th 버그헌트 P2, UR-0179): 아카이브 셀도 _cellSafe — 기존엔 removed.rule/trigger 를 raw 기록해 파이프(|) 포함 룰이 아카이브 표를 깨뜨렸음(rules.md 는 이미 _cellSafe 적용).
|
|
19139
|
+
writeUtf8(rulesArchivePath(root), archive + `| ${_cellSafe(removed.id)} | ${_cellSafe(removed.trigger)} | ${_cellSafe(removed.rule)} | ${_cellSafe(removed.added)} | removed | ${today()} |\n`);
|
|
19140
|
+
return removed;
|
|
19141
|
+
});
|
|
19142
|
+
if (res.error) return failJson(has('--json'), 'rule_not_found', `rule not found: ${id}`); // codex P2: --json 에러 구조화
|
|
19005
19143
|
ok(`rule removed: ${id} (보존: .harness/rules.archive.md)`);
|
|
19006
19144
|
}
|
|
19007
19145
|
|
|
19008
19146
|
function rulePause(root, id) {
|
|
19009
19147
|
root = absRoot(root);
|
|
19010
19148
|
if (!id) return fail('id required');
|
|
19011
|
-
const
|
|
19012
|
-
|
|
19013
|
-
|
|
19014
|
-
|
|
19015
|
-
|
|
19149
|
+
const res = _updateRules(root, (rules) => {
|
|
19150
|
+
const r = rules.find(x => x.id === id);
|
|
19151
|
+
if (!r) return 'rule_not_found';
|
|
19152
|
+
r.status = 'paused';
|
|
19153
|
+
return r;
|
|
19154
|
+
});
|
|
19155
|
+
if (res.error) return fail(`rule not found: ${id}`);
|
|
19016
19156
|
ok(`rule paused: ${id}`);
|
|
19017
19157
|
_autoRoadmap(root, 'data-change');
|
|
19018
19158
|
}
|
|
@@ -19020,31 +19160,35 @@ function rulePause(root, id) {
|
|
|
19020
19160
|
function ruleResume(root, id) {
|
|
19021
19161
|
root = absRoot(root);
|
|
19022
19162
|
if (!id) return fail('id required');
|
|
19023
|
-
const
|
|
19024
|
-
|
|
19025
|
-
|
|
19026
|
-
|
|
19027
|
-
|
|
19163
|
+
const res = _updateRules(root, (rules) => {
|
|
19164
|
+
const r = rules.find(x => x.id === id);
|
|
19165
|
+
if (!r) return 'rule_not_found';
|
|
19166
|
+
r.status = 'active';
|
|
19167
|
+
return r;
|
|
19168
|
+
});
|
|
19169
|
+
if (res.error) return fail(`rule not found: ${id}`);
|
|
19028
19170
|
ok(`rule resumed: ${id}`);
|
|
19029
19171
|
_autoRoadmap(root, 'data-change');
|
|
19030
19172
|
}
|
|
19031
19173
|
|
|
19032
19174
|
function ruleStop(root) {
|
|
19033
19175
|
root = absRoot(root);
|
|
19034
|
-
const
|
|
19035
|
-
|
|
19036
|
-
|
|
19037
|
-
|
|
19038
|
-
|
|
19176
|
+
const res = _updateRules(root, (rules) => {
|
|
19177
|
+
let n = 0;
|
|
19178
|
+
for (const r of rules) if (r.status === 'active') { r.status = 'paused'; n++; }
|
|
19179
|
+
return n;
|
|
19180
|
+
});
|
|
19181
|
+
ok(`${res.value}개 룰 일시 정지 (rule resume <id> 또는 rule resume-all로 재개)`);
|
|
19039
19182
|
}
|
|
19040
19183
|
|
|
19041
19184
|
function ruleResumeAll(root) {
|
|
19042
19185
|
root = absRoot(root);
|
|
19043
|
-
const
|
|
19044
|
-
|
|
19045
|
-
|
|
19046
|
-
|
|
19047
|
-
|
|
19186
|
+
const res = _updateRules(root, (rules) => {
|
|
19187
|
+
let n = 0;
|
|
19188
|
+
for (const r of rules) if (r.status === 'paused') { r.status = 'active'; n++; }
|
|
19189
|
+
return n;
|
|
19190
|
+
});
|
|
19191
|
+
ok(`${res.value}개 룰 재개`);
|
|
19048
19192
|
}
|
|
19049
19193
|
|
|
19050
19194
|
function captureProjectState(root) {
|
|
@@ -19980,8 +20124,17 @@ function _writeFeatureGraph(root, nodes) {
|
|
|
19980
20124
|
// _featureImpactBfs 는 pure-utils 로 이동(handoff/audit 공유). feature-graph I/O 헬퍼(_readFeatureGraph 등)는 공유라 harness 유지+주입.
|
|
19981
20125
|
const _feature = require('../lib/feature');
|
|
19982
20126
|
function _featureDeps() { return { _ensureFeatureGraph, _readFeatureGraph, _writeFeatureGraph, arg, has, _requireInit }; } // 1.36.2: _requireInit 주입 — feature add 미초기화 dir scaffold 게이트
|
|
19983
|
-
|
|
19984
|
-
|
|
20127
|
+
// 1.36.108 (T-0097): feature add/link 는 읽기~쓰기 RMW 다(그래프 전체를 다시 렌더링해 저장).
|
|
20128
|
+
// 락을 여기서 감싼다 — lib/feature.js 는 I/O 를 주입받으므로 호출 경계가 가장 좁은 병목이다.
|
|
20129
|
+
// 단 **`.harness` 가 이미 있을 때만** 감싼다. `_withLock` 은 락 파일을 만들려고 디렉토리를 만드는데,
|
|
20130
|
+
// 미초기화 디렉토리에서 이 명령은 scaffold 게이트로 막혀야 하고 잔여 파일을 남기지 않아야 한다(1.36.2 계약).
|
|
20131
|
+
// 게이트에 걸릴 경우엔 아무것도 쓰지 않으므로 락도 필요 없다.
|
|
20132
|
+
function _featureLocked(root, fn) {
|
|
20133
|
+
const r = absRoot(root);
|
|
20134
|
+
return exists(path.join(r, '.harness')) ? _withLock(featureGraphPath(r), fn) : fn();
|
|
20135
|
+
}
|
|
20136
|
+
function featureAddCmd(root, title) { return _featureLocked(root, () => _feature.featureAddCmd(root, title, _featureDeps())); }
|
|
20137
|
+
function featureLinkCmd(root, fromId) { return _featureLocked(root, () => _feature.featureLinkCmd(root, fromId, _featureDeps())); }
|
|
19985
20138
|
function featureImpactCmd(root, fromId) { return _feature.featureImpactCmd(root, fromId, _featureDeps()); }
|
|
19986
20139
|
function featureListCmd(root) { return _feature.featureListCmd(root, _featureDeps()); }
|
|
19987
20140
|
function featureShowCmd(root, fromId) { return _feature.featureShowCmd(root, fromId, _featureDeps()); }
|
|
@@ -25842,11 +25995,18 @@ async function main() {
|
|
|
25842
25995
|
return;
|
|
25843
25996
|
}
|
|
25844
25997
|
const mf = _mChk.path;
|
|
25845
|
-
// 1.36.
|
|
25846
|
-
//
|
|
25847
|
-
//
|
|
25848
|
-
|
|
25849
|
-
|
|
25998
|
+
// 1.36.108 (T-0097): 락 안에서 **다시 읽는다**. 1.36.107 은 손상 판정과 필드 보존을 넣었지만 그 사이 창은 남아 있었다 —
|
|
25999
|
+
// codex 검수가 rename 직전에 writer 를 주입해 그 writer 의 project/language/installedAt 이 전부 사라지는 것을 재현했다.
|
|
26000
|
+
// 락 밖에서 읽은 _mChk.data 로 쓰면 "보존" 하는 것이 **낡은 스냅샷**이라 옆 프로세스의 변경을 지운다.
|
|
26001
|
+
_withLock(mf, () => {
|
|
26002
|
+
const fresh = _readManifestChecked(_mRoot);
|
|
26003
|
+
if (fresh.corrupt) return; // 락 대기 중 손상됐다면 쓰지 않는다(fail-closed 유지)
|
|
26004
|
+
// 1.36.107 (codex 검수, 재현됨): Object.assign 은 [[Set]] 을 쓰므로 `__proto__` 키에서 setter 가 걸려
|
|
26005
|
+
// 그 필드를 **조용히 버린다**(실측: 주입 후 mode set → 파일에서 사라짐). 스프레드는 CreateDataProperty 라 보존한다.
|
|
26006
|
+
// 병적인 입력이긴 하나 이 수정이 내건 계약이 "남은 필드를 전부 보존" 이므로, 예외를 두면 계약이 거짓이 된다.
|
|
26007
|
+
const m = { ...fresh.data };
|
|
26008
|
+
m.mode = want; writeUtf8(mf, JSON.stringify(m, null, 2) + '\n');
|
|
26009
|
+
});
|
|
25850
26010
|
// 지침 파일은 등급의 산출물이므로 즉시 재생성한다 — 안 그러면 mode 는 이름만 바뀌고 읽히는 양은 그대로다.
|
|
25851
26011
|
let regenerated = 0;
|
|
25852
26012
|
try {
|
|
@@ -26172,7 +26332,9 @@ async function main() {
|
|
|
26172
26332
|
if (cmd === 'pulse') return pulseCmd(_resolveRoot(args[1]));
|
|
26173
26333
|
if (cmd === 'enforce') return enforceCmd(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd(), args[1]); // 1.36.43: 사용 강제 (git pre-commit)
|
|
26174
26334
|
if (cmd === 'anchors') return anchorsCmd(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd(), args[1] && !args[1].startsWith('-') ? args[1] : null); // 1.36.36: 정체성앵커 초안
|
|
26175
|
-
|
|
26335
|
+
// 1.36.108 (T-0097): _withLock 을 넘긴다 — lib 모듈이 락을 deps 로 받는 기존 관례(clarify·referee·routing·bugfix)에
|
|
26336
|
+
// toggles 만 빠져 있어 `toggle set` 이 락 밖 read-modify-write 였다(런타임 계측으로 실측).
|
|
26337
|
+
if (cmd === 'toggle') return _tgl.toggleCmd(arg('--path', process.cwd()), args[1], args[2], args[3], { has, VERSION, _withLock }); // 1.36.30: 기능 토글 (그래프 ⚙ 탭 연동)
|
|
26176
26338
|
// 1.36.53 (UR-0062): 기술 프로필 · 1.36.67 (F15): 변경 시 기존 leerness.html 동반 갱신(있을 때만)
|
|
26177
26339
|
if (cmd === 'tech') return _tech.techCmd(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd(), _optSub(args), { has, regenGraph: (r) => { if (exists(path.join(r, 'leerness.html'))) _graph.graphHtmlCmd(r, { _roadmapData, _loadDecisions, _loadLessons, _parseFeatureGraph, _loadToggles: _tgl.loadToggles, _toggleRegistry: _tgl.TOGGLE_REGISTRY, _loadTechProfile: _tech.loadTechProfile, quiet: true }); } });
|
|
26178
26340
|
// 1.36.98 (P-0013): 재사용 인벤토리 — 새 화면 조각을 만들기 전에 '이미 있는 것' 을 먼저 본다(reuse-map 의 UI 판).
|
package/lib/audit.js
CHANGED
|
@@ -8,6 +8,9 @@ const { log, ok, warn, fail, failJson, today, now, absRoot, exists, read, readBu
|
|
|
8
8
|
const { SECRET_PATTERNS } = require('./catalogs');
|
|
9
9
|
const { findCorruptedStateJson } = require('./state-integrity'); // 1.36.1 (클린룸 리뷰 FN): 상태 JSON 무결성
|
|
10
10
|
const { _briefUnfilled, _planGoalUnfilled } = require('./pure-utils'); // 1.36.19: 전략 앵커(project-brief/plan Goal) 미작성 감지
|
|
11
|
+
// 1.36.108 (T-0097): 락은 harness 가 주입한다(clarify/referee/routing 과 같은 관례) — lib 에 복제본을 두지 않는다.
|
|
12
|
+
// 주입이 없으면 그냥 실행한다(하위호환). 주입 누락은 e2e 락 가드가 실행으로 잡는다.
|
|
13
|
+
function _auditLock(deps, f, fn) { return (deps && typeof deps._withLock === 'function') ? deps._withLock(f, fn) : fn(); }
|
|
11
14
|
|
|
12
15
|
function audit(root, opts = {}, deps = {}) {
|
|
13
16
|
const { VERSION, arg, has, planPath, readProgressRows, currentStatePath, handoffPath, envDiff, _readFeatureGraph, _matchAPISkills, _listAPISkills, _collectSecretFindings } = deps;
|
|
@@ -111,8 +114,13 @@ function audit(root, opts = {}, deps = {}) {
|
|
|
111
114
|
_finding('handoff_not_generated', 'warn', 'session-handoff.md never auto-generated');
|
|
112
115
|
// 1.9.35 #5: --fix → session-handoff.md 자동 생성 마커 갱신
|
|
113
116
|
if (fix) {
|
|
114
|
-
|
|
115
|
-
|
|
117
|
+
// 1.36.108 (T-0097): 락 안에서 **다시 읽고** 스탬프한다. 위에서 읽은 handoff 로 쓰면
|
|
118
|
+
// 그 사이 session close 가 새로 생성한 내용을 낡은 스냅샷으로 덮는다(둘 다 이 파일을 쓴다).
|
|
119
|
+
_auditLock(deps, handoffPath(root), () => {
|
|
120
|
+
const fresh = exists(handoffPath(root)) ? read(handoffPath(root)) : handoff;
|
|
121
|
+
const stamped = fresh.replace(/Last generated: \((자동|auto)\)/, `Last generated: ${today()} (leerness audit --fix)`); // 1.36.61: 양 언어 --fix
|
|
122
|
+
writeUtf8(handoffPath(root), stamped);
|
|
123
|
+
});
|
|
116
124
|
ok(' ↳ fixed: session-handoff.md timestamp 갱신');
|
|
117
125
|
fixed++;
|
|
118
126
|
}
|
|
@@ -131,8 +139,11 @@ function audit(root, opts = {}, deps = {}) {
|
|
|
131
139
|
_finding('current_state_stale', 'warn', 'current-state.md stale', { days: Number.isFinite(dDays) ? Math.round(dDays) : -1 });
|
|
132
140
|
// 1.9.35 #5: --fix → current-state.md Updated 라인 갱신
|
|
133
141
|
if (fix) {
|
|
134
|
-
|
|
135
|
-
|
|
142
|
+
// 1.36.108 (T-0097): 락 안 재읽기 — 사용자 본문이 그 사이 바뀌었으면 낡은 사본으로 덮지 않는다.
|
|
143
|
+
_auditLock(deps, currentStatePath(root), () => {
|
|
144
|
+
const fresh = exists(currentStatePath(root)) ? read(currentStatePath(root)) : cur;
|
|
145
|
+
writeUtf8(currentStatePath(root), fresh.replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`));
|
|
146
|
+
});
|
|
136
147
|
ok(' ↳ fixed: current-state.md Updated 갱신');
|
|
137
148
|
fixed++;
|
|
138
149
|
}
|
package/lib/session-close.js
CHANGED
|
@@ -125,25 +125,37 @@ function sessionClose(root, opts = {}, deps = {}) {
|
|
|
125
125
|
`- ${(buckets['in-progress'][0]?.nextAction) || (buckets['planned'][0]?.nextAction) || (buckets['requested'][0]?.nextAction) || '없음'}`,
|
|
126
126
|
``
|
|
127
127
|
].join('\n');
|
|
128
|
-
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
128
|
+
// 1.36.108 (T-0097): 두 문서 모두 **읽어서 사용자 내용을 보존한 뒤 다시 쓰는** read-modify-write 다
|
|
129
|
+
// (handoff 는 프론트매터를, current-state 는 사용자가 쓴 본문을 지킨다). 락 밖이면 그 보존이
|
|
130
|
+
// 낡은 스냅샷 기준이라 옆 프로세스(audit --fix / handoff)의 갱신을 덮는다.
|
|
131
|
+
// **쓰기 구간만** 감싼다 — 명령 전체를 감싸면 session close 의 하위 감사가 길어져 stale(30s) 에 락을 뺏긴다
|
|
132
|
+
// (lib/referee.js 가 같은 이유로 verify 를 락 밖에 둔다).
|
|
133
|
+
// 1.36.108 (codex 검수 P2, 재현됨): 처음엔 두 파일을 **하나의 락(session-handoff.md.lock)** 으로 감쌌다.
|
|
134
|
+
// 그건 보호가 아니다 — `audit --fix` 는 current-state.md 의 락을 잡으므로 둘이 서로를 배제하지 못하고,
|
|
135
|
+
// 검수가 audit 의 갱신이 session close 의 낡은 스냅샷에 덮이는 것을 재현했다(양쪽 다 '락 보유' 로 보인 채로).
|
|
136
|
+
// 락은 **지키는 파일의 이름으로** 잡아야 한다. 파일마다 따로, 중첩 없이(중첩이 없으니 교착도 없다).
|
|
137
|
+
const _lk = (typeof deps._withLock === 'function') ? deps._withLock : ((f, fn) => fn());
|
|
138
|
+
_lk(handoffPath(root), () => {
|
|
139
|
+
const cur = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
|
|
140
|
+
// 1.9.316 (drift 마커 버그): 프론트매터는 파일이 '---' 로 시작할 때만 추출.
|
|
141
|
+
// 이전: 본문의 '---'(수평선/구분자)을 프론트매터 종료로 오인 → 구 블록(구 'Last generated')을 보존 →
|
|
142
|
+
// session-handoff.md 에 'Last generated' 중복 누적 → drift 가 첫(=구) 매치를 읽어 'session close 누락' 영구 오발화.
|
|
143
|
+
let frontmatter = '';
|
|
144
|
+
if (/^---\r?\n/.test(cur)) {
|
|
145
|
+
const fmEnd = cur.indexOf('\n---\n', 4);
|
|
146
|
+
if (fmEnd > 0) frontmatter = cur.slice(0, fmEnd + 5) + MARK + '\n';
|
|
147
|
+
}
|
|
148
|
+
writeUtf8(handoffPath(root), frontmatter + block);
|
|
149
|
+
});
|
|
150
|
+
_lk(currentStatePath(root), () => {
|
|
151
|
+
if (!exists(currentStatePath(root))) return;
|
|
152
|
+
let cs = read(currentStatePath(root)); // 락 안에서 읽는다 — 밖에서 읽은 사본으로 쓰면 그 사이 갱신을 지운다
|
|
141
153
|
cs = cs.replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
|
|
142
154
|
cs = _upsertAutoLines(cs, 'Now', [`- ${(buckets['in-progress'][0]?.request) || '대기 중'}`]);
|
|
143
155
|
cs = _upsertAutoLines(cs, 'Next', [`- ${(buckets['planned'][0]?.request) || (buckets['requested'][0]?.request) || '계획된 작업 없음'}`]);
|
|
144
156
|
cs = _upsertAutoLines(cs, 'Blockers', (buckets['blocked'] || []).length ? buckets['blocked'].map(b => `- ${b.id} ${b.request}`) : ['- (없음)']);
|
|
145
157
|
writeUtf8(currentStatePath(root), cs);
|
|
146
|
-
}
|
|
158
|
+
});
|
|
147
159
|
|
|
148
160
|
append(taskLogPath(root), `\n## ${today()} session-close\n- Generated session-handoff.md and refreshed current-state.md.\n`);
|
|
149
161
|
|
package/lib/toggles.js
CHANGED
|
@@ -95,6 +95,12 @@ function saveToggles(root, toggles) {
|
|
|
95
95
|
function toggleCmd(root, sub, id, val, deps = {}) {
|
|
96
96
|
const { has, VERSION } = deps;
|
|
97
97
|
root = absRoot(root);
|
|
98
|
+
// 1.36.108 (T-0097): 변경 하위명령은 읽기~쓰기 전체를 락으로 직렬화한다 — clarify/referee/routing 과 같은 관례.
|
|
99
|
+
// 종전엔 이 모듈만 관례에서 빠져 있어 동시 `toggle set` 이 서로의 값을 덮었다(런타임 계측으로 실측: 락 X).
|
|
100
|
+
// 재진입 플래그(_locked)로 자기 재호출을 한 번만 감싼다.
|
|
101
|
+
if (sub === 'set' && typeof deps._withLock === 'function' && !deps._locked) {
|
|
102
|
+
return deps._withLock(_togglesPath(root), () => toggleCmd(root, sub, id, val, Object.assign({}, deps, { _locked: true })));
|
|
103
|
+
}
|
|
98
104
|
const json = has && has('--json');
|
|
99
105
|
const _chk = loadTogglesChecked(root);
|
|
100
106
|
const cur = _chk.toggles;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.108",
|
|
4
4
|
"description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"leerness",
|
package/scripts/e2e.js
CHANGED
|
@@ -10219,6 +10219,145 @@ total++;
|
|
|
10219
10219
|
if (!ok) failed++;
|
|
10220
10220
|
}
|
|
10221
10221
|
|
|
10222
|
+
// ── 1.36.108 블록 L (T-0097): 사용자 상태 쓰기는 **락 안에서** 일어난다.
|
|
10223
|
+
// 이 저장소는 스토어별로 락을 하나씩 달아 왔고, 그래서 같은 스토어 안에서도 진입점마다 갈렸다 —
|
|
10224
|
+
// rules.md 는 `rule add` 만 보호되고 remove/pause/resume/stop/resume-all 다섯이 락 밖이었다(런타임 계측 실측).
|
|
10225
|
+
// 개별 수정은 다음 기능이 들어오면 또 샌다. 그래서 **불변식 자체를 실행으로 단언**한다.
|
|
10226
|
+
//
|
|
10227
|
+
// 왜 소스 검사가 아닌가: `_withLock(` 괄호 균형으로 판정하려 했더니 주석/정규식 리터럴에서 범위가 폭주해
|
|
10228
|
+
// selftest 픽스처 수천 줄이 '보호됨' 으로 나왔다. 락은 파일별이 아니라 **구간별**이기도 하다
|
|
10229
|
+
// (decisions.json 의 락을 쥔 채 decisions.md 를 쓴다). scripts/lock-probe.js 가 _withLock 을 미러링해 실행으로 관측한다.
|
|
10230
|
+
//
|
|
10231
|
+
// 유실은 가설이 아니라 재현이다: 읽기 직후를 4초 늘린 프로세스 A 와 그 창의 프로세스 B 를 붙이면
|
|
10232
|
+
// 락 있는 스토어는 B 가 2.4초 대기 후 둘 다 생존하고, 락 없던 user-requests.json 은 B 의 요청이 통째로 사라졌다.
|
|
10233
|
+
{
|
|
10234
|
+
total++;
|
|
10235
|
+
let ok = false; const dbg = {};
|
|
10236
|
+
const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-lock108-'));
|
|
10237
|
+
try {
|
|
10238
|
+
const ENV0 = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb });
|
|
10239
|
+
const PRELOAD = path.join(path.dirname(CLI), '..', 'scripts', 'lock-probe.js').replace(/\\/g, '/');
|
|
10240
|
+
// 병합 대상이 없어 lost-update 가 성립하지 않는 쓰기 — **사유가 있어야 목록에 남는다**.
|
|
10241
|
+
// 조용한 상한을 두지 않기 위해 여기 적힌 것 외에는 전부 실패로 본다.
|
|
10242
|
+
const EXEMPT = {
|
|
10243
|
+
'task-log.md': 'append 전용(appendFileSync = O_APPEND) — 동시 추가가 서로를 지우지 않는다',
|
|
10244
|
+
'agent-permissions.json': '상수 프리셋 전체 쓰기 — 읽기가 없어 병합할 이전 값이 없다',
|
|
10245
|
+
};
|
|
10246
|
+
const DERIVED = /^(cache\/|last-handoff\.json|environment\.json|tech-profile\.json|update-cache\.json|provider-probe-cache\.json|leerness\.html)/;
|
|
10247
|
+
// 스토어를 각각 건드리는 변경 명령들. seed 로 기존 항목을 만들어 **읽기가 실제로 일어나게** 한다.
|
|
10248
|
+
const CASES = [
|
|
10249
|
+
['task add', ['task', 'add', '락프로브']],
|
|
10250
|
+
['decision add', ['decision', 'add', '락프로브', '--why', 'x']],
|
|
10251
|
+
['lesson save', ['lesson', 'save', '락프로브']],
|
|
10252
|
+
['rule add', ['rule', 'add', '락프로브', '--trigger', 'every-session']],
|
|
10253
|
+
['rule pause', ['rule', 'pause', 'R-0001'], ['rule', 'add', '씨앗룰', '--trigger', 'every-session']],
|
|
10254
|
+
['plan add', ['plan', 'add', '락프로브']],
|
|
10255
|
+
['preview add', ['preview', 'add', '락프로브', '--design', 'd', '--features', 'f']],
|
|
10256
|
+
['requests add', ['requests', 'add', '락프로브']],
|
|
10257
|
+
['next-action add', ['next-action', 'add', '락프로브']],
|
|
10258
|
+
['toggle set', ['toggle', 'set', 'bugfix-receipt', 'off']],
|
|
10259
|
+
['mode set', ['mode', 'set', 'minimal']],
|
|
10260
|
+
['feature add', ['feature', 'add', '락프로브기능']],
|
|
10261
|
+
['constraints add', ['constraints', 'add', 'probeapi', '--alias', 'probe', '--constraint', 'auth:token']],
|
|
10262
|
+
['provider add', ['provider', 'add', 'probeprov']],
|
|
10263
|
+
['wakeup-interval set', ['wakeup-interval', 'set', '900']],
|
|
10264
|
+
['slash-commands --record', ['slash-commands', '--record']],
|
|
10265
|
+
['session close', ['session', 'close', '.']],
|
|
10266
|
+
// audit --fix 는 session close 와 **같은 두 문서**를 쓴다 — 둘을 함께 돌려야 락 키 불일치가 드러난다
|
|
10267
|
+
// (검수가 재현한 결함이 정확히 이 조합이었다: 한쪽은 handoff 락, 다른 쪽은 current-state 락).
|
|
10268
|
+
// ⚠ 신규 프로젝트에서는 current-state 가 신선해 audit --fix 가 그 파일을 **아예 쓰지 않는다** —
|
|
10269
|
+
// 조건을 만들지 않으면 이 케이스는 조용히 아무것도 관측하지 못한다(변이 시험이 그걸 잡아냈다).
|
|
10270
|
+
['audit --fix', ['audit', '.', '--fix'], (d) => {
|
|
10271
|
+
const cs = path.join(d, '.harness', 'current-state.md');
|
|
10272
|
+
if (fs.existsSync(cs)) fs.writeFileSync(cs, fs.readFileSync(cs, 'utf8').replace(/Updated: \d{4}-\d{2}-\d{2}/, 'Updated: 2000-01-01'));
|
|
10273
|
+
const sh = path.join(d, '.harness', 'session-handoff.md');
|
|
10274
|
+
if (fs.existsSync(sh) && !/Last generated:/.test(fs.readFileSync(sh, 'utf8'))) fs.appendFileSync(sh, '\nLast generated: (자동)\n');
|
|
10275
|
+
}],
|
|
10276
|
+
];
|
|
10277
|
+
// codex 검수가 지적한 커버리지 구멍: `provider sync` 는 네트워크가 필요해 목록에 없었고,
|
|
10278
|
+
// 바로 그 명령이 async 를 돌려줘 락이 조기 해제되고 있었다 — 가드가 통과하는 채로.
|
|
10279
|
+
// 외부망은 쓰지 않는다(게이트가 불안정해진다). 로컬 루프백 서버를 **별도 프로세스**로 띄운다:
|
|
10280
|
+
// 같은 프로세스에서 서빙하면 spawnSync 가 이벤트 루프를 막아 자식 요청이 영영 응답을 못 받는다.
|
|
10281
|
+
const srvJs = path.join(sb, 'srv.js');
|
|
10282
|
+
fs.writeFileSync(srvJs, [
|
|
10283
|
+
"const http=require('http'),fs=require('fs');",
|
|
10284
|
+
"const s=http.createServer((q,r)=>{r.writeHead(200,{'Content-Type':'text/plain'});r.end('probealpha|probealpha-cli|probe provider');});",
|
|
10285
|
+
"s.listen(0,'127.0.0.1',()=>fs.writeFileSync(process.argv[2], String(s.address().port)));"
|
|
10286
|
+
].join('\n'));
|
|
10287
|
+
const portFile = path.join(sb, 'port.txt');
|
|
10288
|
+
const srv = cp.spawn(process.execPath, [srvJs, portFile], { stdio: 'ignore' });
|
|
10289
|
+
let syncUrl = null;
|
|
10290
|
+
for (let i = 0; i < 100 && !syncUrl; i++) {
|
|
10291
|
+
try { const p = fs.readFileSync(portFile, 'utf8').trim(); if (p) syncUrl = `http://127.0.0.1:${p}/providers.txt`; } catch {}
|
|
10292
|
+
if (!syncUrl) { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50); } catch {} }
|
|
10293
|
+
}
|
|
10294
|
+
// 서버가 떴을 때만 추가한다 — 못 띄웠으면 '통과' 가 아니라 커버리지 부족으로 아래에서 실패시킨다.
|
|
10295
|
+
if (syncUrl) CASES.push(['provider sync', ['provider', 'sync', syncUrl]]);
|
|
10296
|
+
dbg.syncCovered = !!syncUrl;
|
|
10297
|
+
const offenders = [], failedCmds = [];
|
|
10298
|
+
const keysByFile = {}; // 파일 → 그 파일을 쓸 때 쥐고 있던 락 키 집합(명령을 가로질러 모은다)
|
|
10299
|
+
let lockedSeen = 0, rows = 0;
|
|
10300
|
+
for (const [label, argv, seed] of CASES) {
|
|
10301
|
+
const d = path.join(sb, label.replace(/[^a-z0-9]+/gi, '_'));
|
|
10302
|
+
fs.mkdirSync(d, { recursive: true });
|
|
10303
|
+
fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
|
|
10304
|
+
cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV0 });
|
|
10305
|
+
if (typeof seed === 'function') seed(d);
|
|
10306
|
+
else if (seed) cp.spawnSync(process.execPath, [CLI, ...seed, '--path', d], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV0 });
|
|
10307
|
+
const out = path.join(d, 'lock.jsonl');
|
|
10308
|
+
// e2e 는 맨 위에서 LEERNESS_OFFLINE=1 을 켠다(외부망 차단). provider sync 는 **루프백**이라
|
|
10309
|
+
// 그 정책의 대상이 아니다 — 이 케이스만 명시적으로 해제한다(다른 케이스는 그대로 차단 유지).
|
|
10310
|
+
const caseEnv = Object.assign({}, ENV0, { LOCKPROBE_OUT: out });
|
|
10311
|
+
if (label === 'provider sync') caseEnv.LEERNESS_OFFLINE = '0';
|
|
10312
|
+
const r = cp.spawnSync(process.execPath, ['--require', PRELOAD, CLI, ...argv, '--path', d],
|
|
10313
|
+
{ cwd: d, encoding: 'utf8', timeout: 600000, env: caseEnv });
|
|
10314
|
+
// 실패한 명령은 아무것도 안 써서 자동으로 '안전' 처럼 보인다 — 안전이 아니라 판정 불가다.
|
|
10315
|
+
if (r.status !== 0) { failedCmds.push(label + '(exit=' + r.status + ')'); continue; }
|
|
10316
|
+
let lines = [];
|
|
10317
|
+
try { lines = fs.readFileSync(out, 'utf8').split('\n').filter(Boolean).map(JSON.parse); } catch {}
|
|
10318
|
+
const byFile = {};
|
|
10319
|
+
for (const l of lines) {
|
|
10320
|
+
byFile[l.file] = (l.file in byFile ? byFile[l.file] : true) && l.locked;
|
|
10321
|
+
// 어느 락 키로 지켰는지 모은다 — 파일 자신의 락이 아니면 다른 명령과 서로 배제되지 않는다.
|
|
10322
|
+
if (l.locked && !DERIVED.test(l.file) && !EXEMPT[l.file]) {
|
|
10323
|
+
(keysByFile[l.file] = keysByFile[l.file] || new Set());
|
|
10324
|
+
for (const k of (l.heldOn || [])) keysByFile[l.file].add(k);
|
|
10325
|
+
}
|
|
10326
|
+
}
|
|
10327
|
+
for (const [f, lockedOk] of Object.entries(byFile)) {
|
|
10328
|
+
if (DERIVED.test(f)) continue;
|
|
10329
|
+
rows++;
|
|
10330
|
+
if (lockedOk) { lockedSeen++; continue; }
|
|
10331
|
+
if (EXEMPT[f]) continue;
|
|
10332
|
+
offenders.push(label + ' → ' + f);
|
|
10333
|
+
}
|
|
10334
|
+
}
|
|
10335
|
+
// codex 검수 P2(재현됨): "락을 하나라도 쥐었나" 는 너무 약하다. 상호배제는 **같은 키**를 쥘 때만 성립한다.
|
|
10336
|
+
// session close 는 session-handoff.md.lock 을 쥔 채 current-state.md 를 썼고 audit --fix 는
|
|
10337
|
+
// current-state.md.lock 을 쥐었다 — 둘 다 '락 보유' 인 채로 audit 의 갱신이 덮였다.
|
|
10338
|
+
// 올바른 불변식은 "자기 이름의 락" 이 아니라 **"그 파일의 모든 쓰기가 같은 키를 쓴다"** 이다.
|
|
10339
|
+
// (decisions.md 를 decisions.json.lock 아래에서 쓰는 것은 정상 — 그 파일을 쓰는 유일한 경로가 거기뿐이면.)
|
|
10340
|
+
const wrongKey = [];
|
|
10341
|
+
for (const [f, keys] of Object.entries(keysByFile)) {
|
|
10342
|
+
if (keys.size !== 1) wrongKey.push(f + ' ← 서로 다른 락 ' + [...keys].sort().join(' / '));
|
|
10343
|
+
}
|
|
10344
|
+
dbg.wrongKey = wrongKey;
|
|
10345
|
+
dbg.offenders = offenders;
|
|
10346
|
+
dbg.failedCmds = failedCmds;
|
|
10347
|
+
dbg.lockedSeen = lockedSeen;
|
|
10348
|
+
dbg.rows = rows;
|
|
10349
|
+
// 판별력: 락을 쥔 쓰기가 하나도 안 잡히면 계측이 죽은 것 — 그 상태의 "위반 0" 은 아무 의미가 없다.
|
|
10350
|
+
dbg.probeAlive = lockedSeen >= 10;
|
|
10351
|
+
// 명령이 대량으로 실패하면 커버리지가 무너진 것 — 그것도 통과로 보지 않는다.
|
|
10352
|
+
dbg.coverage = failedCmds.length === 0;
|
|
10353
|
+
ok = dbg.probeAlive && dbg.coverage && dbg.syncCovered && offenders.length === 0 && wrongKey.length === 0;
|
|
10354
|
+
try { srv.kill(); } catch {}
|
|
10355
|
+
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
10356
|
+
console.log(ok ? `✓ L(1.36.108/T-0097) 사용자 상태 쓰기 ${dbg.rows}건 전부 락 안 + 파일당 락 키 일관 (락 관측 ${dbg.lockedSeen}건 = 계측 살아있음 · 명령 실패 0)`
|
|
10357
|
+
: '✗ 1.36.108 락 불변식 위반 ' + JSON.stringify(dbg));
|
|
10358
|
+
if (!ok) failed++;
|
|
10359
|
+
}
|
|
10360
|
+
|
|
10222
10361
|
console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
|
|
10223
10362
|
if (failed > 0) process.exit(1);
|
|
10224
10363
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// scripts/lock-probe.js — 상태 스토어 쓰기가 **락 안에서** 일어나는지 실행으로 관측하는 계측 모듈.
|
|
2
|
+
//
|
|
3
|
+
// 왜 소스 검사가 아니라 계측인가: 정적으로 "이 writeUtf8 이 _withLock 안인가" 를 판정하려 했더니
|
|
4
|
+
// 괄호 균형 스캐너가 주석/정규식 리터럴에서 폭주해 selftest 픽스처 수천 줄을 "락 안" 으로 분류했다.
|
|
5
|
+
// 락은 **파일별이 아니라 구간별**이기도 하다 — leerness 는 decisions.json 의 락을 쥔 채 decisions.md 를 쓴다.
|
|
6
|
+
// 그래서 _withLock 이 하는 것과 똑같이(O_EXCL 로 <target>.lock 생성 → 끝나면 unlink) 보유 집합을 미러링하고,
|
|
7
|
+
// 쓰기 시점에 "이 프로세스가 락을 하나라도 쥐고 있었나" 를 기록한다.
|
|
8
|
+
//
|
|
9
|
+
// 사용: node --require scripts/lock-probe.js bin/leerness.js <명령> ... (LOCKPROBE_OUT 에 jsonl 기록)
|
|
10
|
+
'use strict';
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const OUT = process.env.LOCKPROBE_OUT;
|
|
15
|
+
const rec = [];
|
|
16
|
+
const held = new Set();
|
|
17
|
+
const norm = p => { try { return path.resolve(String(p)); } catch { return String(p); } };
|
|
18
|
+
// archive/ 는 백업 사본이라 경쟁 대상이 아니다. .lock 자체와 원자쓰기 임시파일도 제외(최종 경로는 rename 이 알려준다).
|
|
19
|
+
const isState = p => /[\\/]\.harness[\\/]/.test(p) && !/[\\/]archive[\\/]/.test(p) && !/\.lock$/.test(p) && !/\.tmp-\d+-\d+$/.test(p);
|
|
20
|
+
const relOf = p => (p.split(/[\\/]\.harness[\\/]/)[1] || p).replace(/\\/g, '/');
|
|
21
|
+
|
|
22
|
+
const _open = fs.openSync.bind(fs);
|
|
23
|
+
fs.openSync = function (p, flags, ...a) {
|
|
24
|
+
const r = _open(p, flags, ...a);
|
|
25
|
+
try { if (String(flags) === 'wx' && /\.lock$/.test(String(p))) held.add(norm(p)); } catch {}
|
|
26
|
+
return r;
|
|
27
|
+
};
|
|
28
|
+
const _unlink = fs.unlinkSync.bind(fs);
|
|
29
|
+
fs.unlinkSync = function (p, ...a) {
|
|
30
|
+
try { if (/\.lock$/.test(String(p))) held.delete(norm(p)); } catch {}
|
|
31
|
+
return _unlink(p, ...a);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// 1.36.108 (codex 검수 P2, 재현됨): "락을 하나라도 쥐었나" 는 **너무 약한 질문**이다.
|
|
35
|
+
// session close 는 session-handoff.md 의 락을 쥔 채 current-state.md 를 썼고, audit --fix 는
|
|
36
|
+
// current-state.md 의 락을 쥔다 — 둘 다 '락 보유' 인데 서로를 배제하지 못해 갱신이 덮였다.
|
|
37
|
+
// 그래서 **어느 락을 쥐었는지**(heldOn)를 함께 남긴다. 가드는 파일마다 락 키가 일관되는지까지 본다.
|
|
38
|
+
const note = (target, how) => {
|
|
39
|
+
const t = norm(target);
|
|
40
|
+
if (!isState(t)) return;
|
|
41
|
+
rec.push({ file: relOf(t), how, locked: held.size > 0, heldOn: [...held].map(relOf).sort() });
|
|
42
|
+
};
|
|
43
|
+
const _w = fs.writeFileSync.bind(fs);
|
|
44
|
+
fs.writeFileSync = function (p, ...a) { try { note(p, 'write'); } catch {} return _w(p, ...a); };
|
|
45
|
+
const _r = fs.renameSync.bind(fs);
|
|
46
|
+
fs.renameSync = function (from, to) { try { note(to, 'rename'); } catch {} return _r(from, to); };
|
|
47
|
+
const _a = fs.appendFileSync.bind(fs);
|
|
48
|
+
fs.appendFileSync = function (p, ...args) { try { note(p, 'append'); } catch {} return _a(p, ...args); };
|
|
49
|
+
|
|
50
|
+
process.on('exit', () => {
|
|
51
|
+
if (!OUT) return;
|
|
52
|
+
try { fs.appendFileSync(OUT, rec.map(r => JSON.stringify(r)).join('\n') + (rec.length ? '\n' : ''), 'utf8'); } catch {}
|
|
53
|
+
});
|