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 CHANGED
@@ -122,7 +122,7 @@ MIT
122
122
  <!-- leerness:project-readme:start -->
123
123
  ## Leerness Project Harness
124
124
 
125
- 이 프로젝트는 Leerness v1.36.107 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
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.107는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
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.107 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
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.107: 2026-08-08
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.107';
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
- try { return fn(); }
191
- finally { if (held) { _heldLocks.delete(lockPath); try { fs.unlinkSync(lockPath); } catch {} } }
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
- const state = _loadPreWakeReport(root);
9067
- state.reports.push(audit);
9068
- // 최근 10개만 유지
9069
- if (state.reports.length > 10) state.reports = state.reports.slice(-10);
9070
- _writePreWakeReport(root, state.reports);
9071
- return audit;
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
- const state = _loadWakeupHistory(root);
9232
- state.fires.push({ at: new Date().toISOString(), kind: kind || 'auto' });
9233
- // 최근 50개만 유지
9234
- if (state.fires.length > 50) state.fires = state.fires.slice(-50);
9235
- _writeWakeupHistory(root, state);
9236
- return state;
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
- const state = _loadWakeupHistory(root);
10139
- state.override = n;
10140
- _writeWakeupHistory(root, state);
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
- const state = _loadWakeupHistory(root);
10149
- state.override = null;
10150
- _writeWakeupHistory(root, state);
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
- const state = _loadNextActionQueue(root);
10870
- const existingTitles = new Set(state.queue.map(a => a.title));
10871
- let added = 0;
10872
- for (const a of actions) {
10873
- if (existingTitles.has(a.title)) continue;
10874
- state.queue.push({ ...a, addedAt: new Date().toISOString() });
10875
- existingTitles.add(a.title);
10876
- added++;
10877
- }
10878
- // 최근 20개만 유지
10879
- if (state.queue.length > 20) state.queue = state.queue.slice(-20);
10880
- if (added > 0) _writeNextActionQueue(root, state.queue);
10881
- return added;
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
- state.queue.splice(n, 1);
10920
- _writeNextActionQueue(root, state.queue);
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
- const cs = read(currentStatePath(root)).replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
13873
- writeUtf8(currentStatePath(root), cs);
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
- writeUtf8(f, JSON.stringify({ schemaVersion: 1, recordedAt: new Date().toISOString(), agents }, null, 2) + '\n');
16302
- return { file: f, agentCount: Object.keys(agents).length };
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
- writeUtf8(file, JSON.stringify({ schemaVersion: 1, recordedAt: new Date().toISOString(), refreshedAt: new Date().toISOString(), agents }, null, 2) + '\n');
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
- if (!dryRun) _writeUserProviders(root, userList);
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
- const rules = readRules(root);
18998
- const i = rules.findIndex(r => r.id === id);
18999
- if (i < 0) return failJson(has('--json'), 'rule_not_found', `rule not found: ${id}`); // codex P2: --json 에러 구조화
19000
- const removed = rules.splice(i, 1)[0];
19001
- writeRules(root, rules);
19002
- const archive = exists(rulesArchivePath(root)) ? read(rulesArchivePath(root)) : '# Rules archive\n\n| ID | Trigger | Rule | Added | Status | Removed |\n|---|---|---|---|---|---|\n';
19003
- // 1.11.3 (14th 버그헌트 P2, UR-0179): 아카이브 셀도 _cellSafe 기존엔 removed.rule/trigger raw 기록해 파이프(|) 포함 룰이 아카이브 표를 깨뜨렸음(rules.md 는 이미 _cellSafe 적용).
19004
- writeUtf8(rulesArchivePath(root), archive + `| ${_cellSafe(removed.id)} | ${_cellSafe(removed.trigger)} | ${_cellSafe(removed.rule)} | ${_cellSafe(removed.added)} | removed | ${today()} |\n`);
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 rules = readRules(root);
19012
- const r = rules.find(x => x.id === id);
19013
- if (!r) return fail(`rule not found: ${id}`);
19014
- r.status = 'paused';
19015
- writeRules(root, rules);
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 rules = readRules(root);
19024
- const r = rules.find(x => x.id === id);
19025
- if (!r) return fail(`rule not found: ${id}`);
19026
- r.status = 'active';
19027
- writeRules(root, rules);
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 rules = readRules(root);
19035
- let n = 0;
19036
- for (const r of rules) if (r.status === 'active') { r.status = 'paused'; n++; }
19037
- writeRules(root, rules);
19038
- ok(`${n}개 룰 일시 정지 (rule resume <id> 또는 rule resume-all로 재개)`);
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 rules = readRules(root);
19044
- let n = 0;
19045
- for (const r of rules) if (r.status === 'paused') { r.status = 'active'; n++; }
19046
- writeRules(root, rules);
19047
- ok(`${n}개 룰 재개`);
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
- function featureAddCmd(root, title) { return _feature.featureAddCmd(root, title, _featureDeps()); }
19984
- function featureLinkCmd(root, fromId) { return _feature.featureLinkCmd(root, fromId, _featureDeps()); }
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.107 (codex 검수, 재현됨): Object.assign[[Set]] 쓰므로 `__proto__` 키에서 setter 걸려
25846
- // 필드를 **조용히 버린다**(실측: 주입 mode set 파일에서 사라짐). 스프레드는 CreateDataProperty 보존한다.
25847
- // 병적인 입력이긴 하나 수정이 내건 계약이 "남은 필드를 전부 보존" 이므로, 예외를 두면 계약이 거짓이 된다.
25848
- const m = { ..._mChk.data };
25849
- m.mode = want; writeUtf8(mf, JSON.stringify(m, null, 2) + '\n');
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
- if (cmd === 'toggle') return _tgl.toggleCmd(arg('--path', process.cwd()), args[1], args[2], args[3], { has, VERSION }); // 1.36.30: 기능 토글 (그래프 ⚙ 탭 연동)
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
- const stamped = handoff.replace(/Last generated: \((자동|auto)\)/, `Last generated: ${today()} (leerness audit --fix)`); // 1.36.61: 언어 --fix
115
- writeUtf8(handoffPath(root), stamped);
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
- const stamped = cur.replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
135
- writeUtf8(currentStatePath(root), stamped);
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
  }
@@ -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
- const cur = exists(handoffPath(root)) ? read(handoffPath(root)) : '';
129
- // 1.9.316 (drift 마커 버그): 프론트매터는 파일이 '---' 시작할 때만 추출.
130
- // 이전: 본문의 '---'(수평선/구분자)을 프론트매터 종료로 오인 구 블록(구 'Last generated') 보존
131
- // session-handoff.md 'Last generated' 중복 누적 drift 첫(=구) 매치를 읽어 'session close 누락' 영구 오발화.
132
- let frontmatter = '';
133
- if (/^---\r?\n/.test(cur)) {
134
- const fmEnd = cur.indexOf('\n---\n', 4);
135
- if (fmEnd > 0) frontmatter = cur.slice(0, fmEnd + 5) + MARK + '\n';
136
- }
137
- writeUtf8(handoffPath(root), frontmatter + block);
138
-
139
- if (exists(currentStatePath(root))) {
140
- let cs = read(currentStatePath(root));
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.107",
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
+ });