leerness 1.36.107 → 1.36.109

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.109 하네스를 사용합니다. 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.109는 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.109 릴리스 태그 이력** (수백 라운드) · _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.109: 2026-08-10
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.109';
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);
@@ -5154,10 +5178,17 @@ function _selfTestCases() {
5154
5178
  && E('module.exports = require("./x"); void 0;\n') === false;
5155
5179
  return base && bypass && real && noop && noopFP;
5156
5180
  } },
5157
- { name: '위장 스텁 차단 (1.18.2): stub 루프 _vcImplIsEmpty 사용 + 메시지 + FILE_EXTS java/php 정합 (소스 가드)', run: () => {
5158
- const src = read(__filename);
5159
- return src.includes('if (_vcImplIsEmpty(body)) stubFiles.push(c.file);') && src.includes('비주석 코드 0줄 또는 export 껍데기')
5160
- && /const FILE_EXTS = '[^']*\bjava\b[^']*\bphp\b[^']*'/.test(src);
5181
+ { name: '위장 스텁 차단 (1.18.2 · 1.36.109 행위전환): 빈껍데기 판정이 실제로 동작 + 스텁 루프가 판정을 쓴다', run: () => {
5182
+ // 1.36.109: 종전엔 스텁 루프의 호출 줄을 **정확 리터럴**로 붙잡았다. 이번 라운드에 그 줄을 리팩터하자
5183
+ // (마커 파일 판정을 끼워야 했다) 리터럴이 사라져 메타가드가 즉시 잡았다 자기참조 소스가드가 공허해지는 형태다.
5184
+ // 표현이 아니라 **동작**을 본다.
5185
+ // ⚠ 배선(루프가 이 판정을 실제로 호출하는가)은 **여기서 소스로 확인하지 않는다**: 그 정규식을 쓰자
5186
+ // 바로 위 설명 주석이 스스로를 만족시켜 변이가 생존했다(자체 변이 시험이 잡았다 — 같은 함정의 재생산).
5187
+ // 배선은 e2e 블록 M 이 행위로 지킨다(0바이트 파일 → stub-impl). 배선을 끊으면 그 블록이 실패한다.
5188
+ const shells = ['', ' \n\n ', '/' + '/ TODO\n' + '/' + '/ later\n', '"use strict"; module.exports = {};\n', '/* c */\nvoid 0;\n'];
5189
+ const reals = ['function f(){return 1;}\nmodule.exports={f};\n', 'def f():\n return 1\n', 'export const x = 1;\nexport function g(){ return x; }\n'];
5190
+ const judged = shells.every(s => _vcImplIsEmpty(s) === true) && reals.every(s => _vcImplIsEmpty(s) === false);
5191
+ return judged && read(__filename).includes('비주석 코드 0줄 또는 빈 export 껍데기');
5161
5192
  } },
5162
5193
  { name: '품질 렌즈 (1.18.3): 카탈로그 무결성 — 사용자 원문 질문 + affects 상호참조 유효 (행위)', run: () => {
5163
5194
  const keys = Object.keys(LENS_CATALOG);
@@ -6399,7 +6430,7 @@ function _selfTestCases() {
6399
6430
  return clar && shape && noDbUrl && reqOk && guards && uiOk && _p0013LibraryOk() && _p0088CurrentStatePreserved()
6400
6431
  && _p0101SurfaceOk() && _p0101SkillIdOk() && _p0101PathStrictOk() && _p0102HonestyOk()
6401
6432
  && _p0103FlagsOk() && _p0103JsonOk() && _p0103RootOk() && _p0104UsageOk()
6402
- && _p0014SecretBaselineOk() && _p0104ReminderOk() && _p0015ModeOk();
6433
+ && _p0014SecretBaselineOk() && _p0104ReminderOk() && _p0015ModeOk() && _p0097AsyncLockOk() && _p0109ClaimGateOk();
6403
6434
  } },
6404
6435
  { name: '시크릿 스캐너 F-06 (1.36.56, 외부감사): 무명 확장자 소형 텍스트 스캔(이진 NUL 제외) + 같은 토큰 중복 보고 dedupe — 행위검사', run: () => {
6405
6436
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sc56_'));
@@ -8071,6 +8102,62 @@ function _p0015ModeOk() {
8071
8102
  }
8072
8103
  // 1.36.104: handoff 가 사람이 쓴 .harness/agent-reminders.md 를 통째로 지웠다(exit 0 · 안내 없음 · 백업 없음).
8073
8104
  // 대조군이 필수다 — 자동 생성분까지 남기면 그 파일이 영구 잔존해 매 세션 거짓 경보가 된다.
8105
+ // 1.36.108 (T-0097, 자체 적대 검사에서 발견): `_withLock` 에 **비동기 fn** 을 넘기면 동기 finally 가
8106
+ // 프로미스가 정착하기 전에 락을 푼다 — 본체는 락 없이 돌면서 호출부는 보호받는다고 믿는다.
8107
+ // `provider sync`(15초 네트워크 fetch)를 감싸며 실제로 이 형태를 만들었고, e2e 는 그 명령을 돌리지 않아 못 잡았다.
8108
+ // 순수 호출로 계약을 고정한다: 프로미스가 끝나기 전에는 락 파일이 살아 있어야 하고, 끝나면 사라져야 한다.
8109
+ // selftest 는 `c.run() === true` 만 통과시키므로(프로미스는 자동 실패) **동기 thenable** 로 같은 분기를
8110
+ // 결정적으로 밟는다 — `typeof out.then === 'function'` 이라는 판별 조건이 동일하다.
8111
+ // 1.36.109 (완료게이트 사냥): 두 결함을 **순수 호출**로 고정한다. 둘 다 양방향으로 단언한다 —
8112
+ // 막아야 할 것을 막는지, 그리고 통과해야 할 것이 계속 통과하는지. false-BLOCK 은 false-PASS 보다 나쁘다.
8113
+ function _p0109ClaimGateOk() {
8114
+ try {
8115
+ const an = require('../lib/analyzers');
8116
+ // ① 빈 파일은 가장 명백한 스텁이다. 종전엔 `!body` 때문에 "스텁 아님" 이었고, 공백 3자보다 0바이트가 더 쉽게 통과했다.
8117
+ const emptyIsStub = _vcImplIsEmpty('') === true && _vcImplIsEmpty(' \n\n ') === true;
8118
+ const realIsNot = _vcImplIsEmpty('function f(){return 1;}\nmodule.exports={f};\n') === false
8119
+ && _vcImplIsEmpty('def f():\n return 1\n') === false; // 대조군 — 실코드는 스텁이 아니다
8120
+ const badInput = _vcImplIsEmpty(null) === false && _vcImplIsEmpty(undefined) === false; // 문자열 아닌 입력은 판정하지 않는다
8121
+ // ② 밑줄로 시작하는 파일명이 **잘리지 않고 통째로** 추출돼야 한다. 잘리면 없는 파일을 찾아 정직한 주장을 거부한다.
8122
+ const grab = (s) => (String(s).match(an._EVIDENCE_FILE_RE()) || [])[0] || null;
8123
+ const underscore = grab('src/_helpers.js 구현 완료') === 'src/_helpers.js'
8124
+ && grab('pages/_app.tsx 수정') === 'pages/_app.tsx'
8125
+ && grab('pkg/__init__.py 추가') === 'pkg/__init__.py'
8126
+ && grab('_private/util.ts 정리') === '_private/util.ts';
8127
+ const plainStill = grab('src/normal.js 구현') === 'src/normal.js' // 대조군 — 기존 인식 유지
8128
+ && grab('ops/containers/Dockerfile 작성') === 'ops/containers/Dockerfile'
8129
+ && grab('.github/workflows/ci.yml 갱신') === '.github/workflows/ci.yml';
8130
+ return emptyIsStub && realIsNot && badInput && underscore && plainStill;
8131
+ } catch { return false; }
8132
+ }
8133
+ function _p0097AsyncLockOk() {
8134
+ const os2 = require('os');
8135
+ const arena = fs.mkdtempSync(path.join(os2.tmpdir(), 'leerness-p97lk-'));
8136
+ try {
8137
+ const target = path.join(arena, 'store.json');
8138
+ const lockFile = target + '.lock';
8139
+ fs.writeFileSync(target, '{}');
8140
+ // ① 비동기 본체: 아직 정착하지 않은 동안 락이 살아 있어야 한다(조기 해제 = 보호 0).
8141
+ let heldWhilePending = null;
8142
+ const thenable = { then(onOk) { heldWhilePending = fs.existsSync(lockFile); onOk('done'); return 'settled'; } };
8143
+ const r1 = _withLock(target, () => thenable);
8144
+ const releasedAfterSettle = !fs.existsSync(lockFile) && r1 === 'settled';
8145
+ // ② 대조군 — 동기 fn 은 종전대로 즉시 해제되고 값을 그대로 돌려준다(회귀로 락을 붙잡지 않았는지).
8146
+ const r2 = _withLock(target, () => 42);
8147
+ const syncReleased = !fs.existsSync(lockFile) && r2 === 42;
8148
+ // ③ 예외 경로도 해제되어야 한다 — 안 그러면 한 번 던진 뒤 그 스토어가 영구 잠긴다.
8149
+ let threw = false;
8150
+ try { _withLock(target, () => { throw new Error('x'); }); } catch { threw = true; }
8151
+ const errReleased = threw && !fs.existsSync(lockFile);
8152
+ // ④ 거부된 thenable 도 해제 + 사유 전파.
8153
+ let rejThrew = false;
8154
+ const badThenable = { then(onOk, onErr) { onErr(new Error('boom')); return 'rejected'; } };
8155
+ try { _withLock(target, () => badThenable); } catch { rejThrew = true; }
8156
+ const rejReleased = rejThrew && !fs.existsSync(lockFile);
8157
+ return heldWhilePending === true && releasedAfterSettle && syncReleased && errReleased && rejReleased;
8158
+ } catch { return false; }
8159
+ finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch {} }
8160
+ }
8074
8161
  function _p0104ReminderOk() {
8075
8162
  const os2 = require('os');
8076
8163
  const cp2 = require('child_process');
@@ -9062,13 +9149,16 @@ function _runPreWakeAudit(root) {
9062
9149
  };
9063
9150
  return audit;
9064
9151
  }
9152
+ // 1.36.108 (T-0097): append 형 RMW — session close 와 pre-wake-audit 가 겹칠 수 있다.
9065
9153
  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;
9154
+ return _withLock(_preWakeReportPath(root), () => {
9155
+ const state = _loadPreWakeReport(root);
9156
+ state.reports.push(audit);
9157
+ // 최근 10개만 유지
9158
+ if (state.reports.length > 10) state.reports = state.reports.slice(-10);
9159
+ _writePreWakeReport(root, state.reports);
9160
+ return audit;
9161
+ });
9072
9162
  }
9073
9163
 
9074
9164
  // 1.9.220: 비정상 종료 감지 + 자율 재개 (사용자 명시)
@@ -9226,14 +9316,18 @@ function _writeWakeupHistory(root, state) {
9226
9316
  return true;
9227
9317
  } catch { return false; }
9228
9318
  }
9319
+ // 1.36.108 (T-0097): append 형 RMW — 락 밖이면 동시 기록이 서로를 지운다.
9320
+ // 이 스토어는 자동(스케줄)과 사용자 트리거가 동시에 들어올 수 있어 겹칠 여지가 실제로 있다.
9229
9321
  function _recordWakeupFire(root, kind) {
9230
9322
  // 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;
9323
+ return _withLock(_wakeupHistoryPath(root), () => {
9324
+ const state = _loadWakeupHistory(root);
9325
+ state.fires.push({ at: new Date().toISOString(), kind: kind || 'auto' });
9326
+ // 최근 50개만 유지
9327
+ if (state.fires.length > 50) state.fires = state.fires.slice(-50);
9328
+ _writeWakeupHistory(root, state);
9329
+ return state;
9330
+ });
9237
9331
  }
9238
9332
  // 권장 interval 계산 (sec)
9239
9333
  // user-trigger 빈도 + pre-wake critical + 최근 평균 gap 기반
@@ -9891,6 +9985,13 @@ function sessionResumeCmd(root) {
9891
9985
  // add <id> --aliases ... --constraint "kind:detail" → 사용자 정의 플랫폼 추가
9892
9986
  function constraintsCmd(root, sub, ...rest) {
9893
9987
  root = absRoot(root);
9988
+ // 1.36.108 (T-0097): 변경 하위명령은 읽기~쓰기 전체를 직렬화한다(clarify/referee 와 같은 재진입 패턴).
9989
+ // 손상 거부(1.36.78)는 손상만 막고 **동시 쓰기 유실**은 못 막는다 — 두 제약을 동시에 추가하면 하나가 조용히 사라진다.
9990
+ if (sub === 'add' && !_withLock.__inConstraints) {
9991
+ _withLock.__inConstraints = true;
9992
+ try { return _withLock(_platformConstraintsPath(root), () => constraintsCmd(root, sub, ...rest)); }
9993
+ finally { _withLock.__inConstraints = false; }
9994
+ }
9894
9995
  const isTty = process.stdout && process.stdout.isTTY;
9895
9996
  const cyan = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
9896
9997
  const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
@@ -10135,9 +10236,12 @@ function wakeupIntervalCmd(root, sub, val) {
10135
10236
  if (sub === 'set') {
10136
10237
  const n = Number(val);
10137
10238
  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);
10239
+ // 1.36.108 (T-0097): override 만 바꾸는데도 파일 전체를 다시 쓴다 — 락 밖이면 그 사이 기록된 fires 가 사라진다.
10240
+ _withLock(_wakeupHistoryPath(root), () => {
10241
+ const state = _loadWakeupHistory(root);
10242
+ state.override = n;
10243
+ _writeWakeupHistory(root, state);
10244
+ });
10141
10245
  if (has('--json')) { log(JSON.stringify({ override: n }, null, 2)); return; }
10142
10246
  log(grn(`✓ wakeup-interval override 설정: ${n}s (${Math.round(n/60)}min)`));
10143
10247
  log(dim(` 해제: leerness wakeup-interval auto`));
@@ -10145,9 +10249,11 @@ function wakeupIntervalCmd(root, sub, val) {
10145
10249
  }
10146
10250
 
10147
10251
  if (sub === 'auto') {
10148
- const state = _loadWakeupHistory(root);
10149
- state.override = null;
10150
- _writeWakeupHistory(root, state);
10252
+ _withLock(_wakeupHistoryPath(root), () => {
10253
+ const state = _loadWakeupHistory(root);
10254
+ state.override = null;
10255
+ _writeWakeupHistory(root, state);
10256
+ });
10151
10257
  const result = _computeAdaptiveInterval(root);
10152
10258
  if (has('--json')) { log(JSON.stringify({ override: null, current: result }, null, 2)); return; }
10153
10259
  log(grn(`✓ override 해제 — 자동 계산 모드`));
@@ -10864,21 +10970,25 @@ function _writeNextActionQueue(root, queue) {
10864
10970
  } catch { return false; }
10865
10971
  }
10866
10972
  // handoff 에서 제안된 next-action 들을 큐에 자동 저장 (중복 방지: 이미 있는 title 은 skip)
10973
+ // 1.36.108 (T-0097): 읽기~쓰기를 락으로 직렬화. 이 큐는 handoff 가 자동으로도 채우기 때문에
10974
+ // 사용자가 `next-action add` 하는 순간 handoff 가 겹칠 수 있다 — 실사용에서 가장 겹치기 쉬운 조합이다.
10867
10975
  function _enqueueNextActions(root, actions) {
10868
10976
  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;
10977
+ return _withLock(_nextActionQueuePath(root), () => {
10978
+ const state = _loadNextActionQueue(root);
10979
+ const existingTitles = new Set(state.queue.map(a => a.title));
10980
+ let added = 0;
10981
+ for (const a of actions) {
10982
+ if (existingTitles.has(a.title)) continue;
10983
+ state.queue.push({ ...a, addedAt: new Date().toISOString() });
10984
+ existingTitles.add(a.title);
10985
+ added++;
10986
+ }
10987
+ // 최근 20개만 유지
10988
+ if (state.queue.length > 20) state.queue = state.queue.slice(-20);
10989
+ if (added > 0) _writeNextActionQueue(root, state.queue);
10990
+ return added;
10991
+ });
10882
10992
  }
10883
10993
 
10884
10994
  // 1.9.201: next-action CLI 명령 — handoff 가 저장한 queue 에서 take/list/clear
@@ -10916,8 +11026,14 @@ async function nextActionCmd(root, sub, ...rest) {
10916
11026
  try {
10917
11027
  const taskTitle = action.title.replace(/^[^\w가-힣]+/, '').slice(0, 100);
10918
11028
  // 큐에서 제거 → write
10919
- state.queue.splice(n, 1);
10920
- _writeNextActionQueue(root, state.queue);
11029
+ // 1.36.108 (T-0097): 락 안에서 **다시 읽고** 제거한다. 함수 머리에서 읽은 state 로 지우면
11030
+ // 그 사이에 handoff 가 큐에 넣은 항목이 통째로 사라진다(handoff 는 자동으로 이 큐를 채운다).
11031
+ _withLock(_nextActionQueuePath(root), () => {
11032
+ const fresh = _loadNextActionQueue(root);
11033
+ const idx = fresh.queue.findIndex(a => a && a.title === action.title);
11034
+ if (idx >= 0) fresh.queue.splice(idx, 1);
11035
+ _writeNextActionQueue(root, fresh.queue);
11036
+ });
10921
11037
  // leerness task add 호출
10922
11038
  const taskResult = cp.spawnSync(process.execPath, [__filename, 'task', 'add', taskTitle, '--path', root], { encoding: 'utf8', timeout: 8000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
10923
11039
  if (taskResult.status === 0) {
@@ -12423,7 +12539,7 @@ function debug(root) {
12423
12539
 
12424
12540
  const _audit = require('../lib/audit');
12425
12541
  // 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 신호 공유
12542
+ 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
12543
 
12428
12544
  // 1.9.312 (UR-0050, 설치리뷰 3중수렴): secret 스캐너 현대 키 패턴 보강.
12429
12545
  // 배경: 기존 OpenAI 패턴 `sk-[A-Za-z0-9]{32,}` 은 하이픈에서 끊겨 sk-proj-/sk-svcacct- (modern 프로젝트/서비스 키)를 놓침.
@@ -13868,9 +13984,13 @@ function handoff(root) {
13868
13984
  } else {
13869
13985
  log(out);
13870
13986
  }
13987
+ // 1.36.108 (T-0097): 날짜 스탬프도 read-modify-write 다 — 사용자가 쓴 본문 전체를 다시 쓴다.
13988
+ // 락 밖이면 그 사이 session close 가 갱신한 Now/Next/Blockers 를 낡은 사본으로 되돌린다.
13871
13989
  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);
13990
+ _withLock(currentStatePath(root), () => {
13991
+ const cs = read(currentStatePath(root)).replace(/Updated: \d{4}-\d{2}-\d{2}/, `Updated: ${today()}`);
13992
+ writeUtf8(currentStatePath(root), cs);
13993
+ });
13874
13994
  }
13875
13995
  // 1.9.56: handoff에 lessons --auto 자동 통합 — 현재 in-progress task와 관련된 과거 실수/결정 자동 재상기
13876
13996
  // 매 세션 시작 시 AI가 과거에 같은 키워드로 실패한 사례를 잊지 않도록.
@@ -15074,7 +15194,12 @@ const _VC_EMPTY_SHELL_RE = new RegExp(
15074
15194
  ].join('|') + ')(?:\\s+as\\s+[A-Za-z0-9_$.<>\\[\\] ]+?)?\\s*;?$' // 선택적 TS 캐스트(as any) + ;
15075
15195
  );
15076
15196
  function _vcImplIsEmpty(body) {
15077
- if (typeof body !== 'string' || !body) return false;
15197
+ // 1.36.109 (완료게이트 사냥): 문자열은 **가장 명백한 스텁**인데 `!body` 그걸 "스텁 아님" 으로 만들었다.
15198
+ // 공백 3자짜리 파일은 stub-impl 로 막히는데 0바이트는 통과했다 — 같은 것이 더 비어 있을수록 통과하는 역전이다.
15199
+ // (`touch src/x.js` + evidence "src/x.js 구현 완료" → verify-claim 통과, 실측.)
15200
+ // 읽기 실패는 호출부가 catch 로 걸러 여기 오지 않는다. 빈 본문은 곧 빈 구현이다.
15201
+ if (typeof body !== 'string') return false;
15202
+ if (body === '') return true;
15078
15203
  // 블록주석 제거 → 줄별 trim/주석줄 제거 → 인라인 // 주석 제거(따옴표 없는 줄만, 문자열 보호) → join.
15079
15204
  const codeLines = body.replace(/\/\*[\s\S]*?\*\//g, '').split('\n').map(l => {
15080
15205
  let t = l.trim();
@@ -15180,6 +15305,12 @@ function verifyClaimCmd(root, taskId, opts = {}) {
15180
15305
  for (const m of evidence.matchAll(FILE_RE)) {
15181
15306
  const prev = m.index > 0 ? evidence[m.index - 1] : '';
15182
15307
  if (prev === '\\' || prev === '/') continue; // suffix-of-longer-path(UNC \\srv\..., C:\..., URL) → skip. ':' 는 "파일:src/x.js" 정상 케이스라 스킵 안 함
15308
+ // 1.36.109 (codex 검수 P2, 재현됨): 구분자만 보던 가드에 두 구멍이 있었다 —
15309
+ // `https://…/docs#_anchor.js` 의 URL 조각과 `C:_anchor.js` 의 드라이브 상대 경로가 **루트 상대 파일 주장**이 됐다.
15310
+ // (밑줄 확장 전에도 같은 형태로 `anchor.js` 가 통과했다 — 내가 만든 클래스는 아니지만 여기서 닫는다.)
15311
+ // `:` 를 통째로 막으면 "파일:src/x.js" 같은 정상 표기가 죽으므로, **앞이 단일 영문자인 `:` 만** 드라이브로 본다.
15312
+ if (prev === '#') continue; // URL 조각
15313
+ if (prev === ':' && /^[A-Za-z]$/.test(evidence[m.index - 2] || '')) continue; // C:name (드라이브 상대)
15183
15314
  filePatterns.push(m[0].replace(/\\/g, '/'));
15184
15315
  }
15185
15316
  // 중복 제거 + "tests/test.js" 같은 결과를 유지 (이미 `..` 없으니 그대로)
@@ -15226,15 +15357,34 @@ function verifyClaimCmd(root, taskId, opts = {}) {
15226
15357
  // 1.17.3 (UR-0046 범용성 P1②): 빈껍데기(스텁) 구현 + 테스트-구현 연결 검사 — "주석뿐 구현 + assert(true) 테스트"가 verify-claim 을 exit 0 으로 통과하던 공격(5축 실증 Attack C) 차단.
15227
15358
  // ① 스텁: 주장된 코드 파일(테스트 제외)의 비주석 코드줄이 0 이면 확정 스텁 — done 게이팅 FAIL(확실 신호만, 과탐 0).
15228
15359
  // ② 연결: 주장에 구현+테스트가 모두 있는데 어떤 테스트도 구현 파일명(basename)을 참조하지 않으면 — 기본 advisory ⚠, --strict-claims 시 FAIL.
15229
- const _VC_CODE_EXT = /\.(js|mjs|cjs|jsx|ts|tsx|py|rb|go|rs|java|cs|php)$/i;
15360
+ // 1.36.109 (codex 검수 P2): `pyi` 추가 — 여기에 없으면 위의 마커 예외가 **도달 불가**다(광고만 하고 안 도는 코드).
15361
+ const _VC_CODE_EXT = /\.(js|mjs|cjs|jsx|ts|tsx|pyi|py|rb|go|rs|java|cs|php)$/i;
15230
15362
  const _VC_TEST_PAT = /(^|[\\/])(test_[^\\/]+\.[a-z]+|[^\\/]+[._-]test\.[a-z]+|[^\\/]+\.spec\.[a-z]+)$|(^|[\\/])tests?[\\/]/i;
15231
15363
  const stubFiles = [];
15364
+ // 1.36.109 (codex 검수 P1, 재현됨): 관례상 비어 있는 **마커 파일**은 스텁으로 몰면 안 되지만,
15365
+ // 그것만으로 "구현했다" 가 성립해서도 안 된다. 처음엔 무조건 예외로 뒀더니 빈 `pkg/__init__.py` **하나만**
15366
+ // 인용한 done 이 exit 0 을 받았다(검수가 재현) — 내가 막겠다고 고친 바로 그 형태를 예외가 되살린 것이다.
15367
+ // 그래서 예외는 "다른 실질 파일이 함께 인용됐을 때만" 적용한다. 판정은 루프 뒤로 미룬다.
15368
+ const markerFiles = [];
15369
+ const _isPkgMarker = (f) => /(^|[\\/])__init__\.pyi?$/.test(f);
15232
15370
  for (const c of fileChecks) {
15233
15371
  if (!c.exists || !_VC_CODE_EXT.test(c.file) || _VC_TEST_PAT.test(c.file)) continue;
15234
15372
  let body = ''; try { body = read(path.join(root, c.file)); } catch { continue; }
15235
- if (!body || body.length > 512 * 1024) continue;
15373
+ // 1.36.109: `!body` 스킵 조건에 두면 0바이트 파일이 스텁 검사를 통째로 건너뛴다(가드 두 겹이 서로를 가림 —
15374
+ // 여기와 `_vcImplIsEmpty` 양쪽에 같은 falsy 함정이 있었고 한 곳만 고치면 구멍이 남는다). 크기 상한만 남긴다.
15375
+ if (body.length > 512 * 1024) continue;
15236
15376
  // 1.18.2: 코드 0줄(기존) + 빈 export 껍데기(위장 스텁) 통합 판정.
15237
- if (_vcImplIsEmpty(body)) stubFiles.push(c.file);
15377
+ if (!_vcImplIsEmpty(body)) continue;
15378
+ if (_isPkgMarker(c.file)) { markerFiles.push(c.file); continue; } // 판정 보류 — 아래에서 결정
15379
+ stubFiles.push(c.file);
15380
+ }
15381
+ // 빈 마커를 봐 주는 조건: 같이 인용된 **비어 있지 않은** 코드 파일이 하나라도 있어야 한다.
15382
+ // (파이썬 패키지 추가는 보통 `__init__.py` + 실제 모듈이 함께 온다 — 그 정직한 형태만 통과시킨다.)
15383
+ if (markerFiles.length) {
15384
+ const substantive = fileChecks.some(c =>
15385
+ c.exists && _VC_CODE_EXT.test(c.file) && !_VC_TEST_PAT.test(c.file) && !_isPkgMarker(c.file)
15386
+ && !stubFiles.includes(c.file));
15387
+ if (!substantive) for (const f of markerFiles) stubFiles.push(f);
15238
15388
  }
15239
15389
  const _vcImpl = fileChecks.filter(c => c.exists && _VC_CODE_EXT.test(c.file) && !_VC_TEST_PAT.test(c.file)).map(c => c.file);
15240
15390
  const _vcTests = fileChecks.filter(c => c.exists && _VC_CODE_EXT.test(c.file) && _VC_TEST_PAT.test(c.file)).map(c => c.file);
@@ -16284,22 +16434,26 @@ function _loadAgentSlashCommands(root) {
16284
16434
  // 기존 사용자 override 가 있으면 보존(병합 결과 기록). force 시 빌트인으로 덮어쓰기.
16285
16435
  function _recordAgentSlashCommands(root, opts = {}) {
16286
16436
  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
16437
  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 };
16438
+ // 1.36.108 (T-0097): 비-force 경로는 기존 파일을 읽어 병합한 결과를 쓴다 = read-modify-write.
16439
+ // 락 밖이면 사이 --refresh 가 probe 로 갱신한 agent 가 낡은 병합본에 덮인다(둘 다 이 파일을 쓴다).
16440
+ return _withLock(f, () => {
16441
+ let agents;
16442
+ if (opts.force || !exists(f)) {
16443
+ agents = {};
16444
+ for (const [id, def] of Object.entries(AGENT_SLASH_COMMANDS)) {
16445
+ agents[id] = { label: def.label, asOf: def.asOf, invoke: def.invoke || 'slash', note: def.note || undefined, commands: def.commands.slice() };
16446
+ }
16447
+ } else {
16448
+ const merged = _loadAgentSlashCommands(root);
16449
+ agents = {};
16450
+ for (const [id, def] of Object.entries(merged)) {
16451
+ agents[id] = { label: def.label, asOf: def.asOf, invoke: def.invoke, note: def.note || undefined, commands: def.commands };
16452
+ }
16453
+ }
16454
+ writeUtf8(f, JSON.stringify({ schemaVersion: 1, recordedAt: new Date().toISOString(), agents }, null, 2) + '\n');
16455
+ return { file: f, agentCount: Object.keys(agents).length };
16456
+ });
16303
16457
  }
16304
16458
  // dispatch 참조용 — 특정 agent 의 슬래시 명령어 힌트 (서브에이전트 프롬프트에 주입할 1줄 요약 + 배열).
16305
16459
  // 2단계에서 agents multi/agent dispatch 가 이 헬퍼로 각 에이전트에 맞는 슬래시 명령을 안내.
@@ -16375,7 +16529,17 @@ function _refreshAgentSlashCommands(root, targets, opts = {}) {
16375
16529
  if (anyOk && !opts.dryRun) {
16376
16530
  file = _agentSlashFile(root);
16377
16531
  mkdirp(path.dirname(file));
16378
- writeUtf8(file, JSON.stringify({ schemaVersion: 1, recordedAt: new Date().toISOString(), refreshedAt: new Date().toISOString(), agents }, null, 2) + '\n');
16532
+ // 1.36.108 (T-0097): probe 외부 CLI 띄우느라 오래 걸린다 **락 밖에서** 돌리고 저장만 락 안에서 한다
16533
+ // (lib/referee.js 가 캘리브레이션에 세운 규칙과 같다: 긴 작업을 락 안에 넣으면 stale 30s 에 락을 뺏겨
16534
+ // 오히려 남의 결과를 지운다). 락 안에서 파일을 **다시 읽어** 이번에 성공한 agent 만 덮어쓴다.
16535
+ const probed = results.filter(r => r.ok).map(r => r.id);
16536
+ _withLock(file, () => {
16537
+ const fresh = _loadAgentSlashCommands(root);
16538
+ const out = {};
16539
+ 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 };
16540
+ for (const id of probed) out[id] = agents[id];
16541
+ writeUtf8(file, JSON.stringify({ schemaVersion: 1, recordedAt: new Date().toISOString(), refreshedAt: new Date().toISOString(), agents: out }, null, 2) + '\n');
16542
+ });
16379
16543
  }
16380
16544
  return { results, file, anyOk };
16381
16545
  }
@@ -16532,6 +16696,14 @@ function _allProviders(root) {
16532
16696
  }
16533
16697
  function providerCmd(root, sub, ...args) {
16534
16698
  root = absRoot(root || process.cwd());
16699
+ // 1.36.108 (T-0097): 변경 하위명령은 읽기~쓰기를 직렬화. 목록 RMW 라 동시 등록 시 한쪽이 조용히 사라진다(실측).
16700
+ // `sync` 는 **여기서 감싸지 않는다** — 최대 15초 네트워크 fetch 를 락 안에 두면 stale(30s) 창에 다가가고,
16701
+ // 다른 프로세스가 락을 뺏어 둘 다 쓰게 된다(lib/referee.js 가 캘리브레이션에 세운 규칙). 저장만 아래에서 감싼다.
16702
+ if (['add', 'remove'].includes(sub) && !providerCmd.__locked) {
16703
+ providerCmd.__locked = true;
16704
+ try { return _withLock(_providersFile(root), () => providerCmd(root, sub, ...args)); }
16705
+ finally { providerCmd.__locked = false; }
16706
+ }
16535
16707
  _loadEnvFile(root);
16536
16708
  if (!sub || sub === 'list') {
16537
16709
  const all = _allProviders(root);
@@ -16683,7 +16855,14 @@ function providerCmd(root, sub, ...args) {
16683
16855
  log(` + ${e.id} (신규)`);
16684
16856
  }
16685
16857
  }
16686
- if (!dryRun) _writeUserProviders(root, userList);
16858
+ // 1.36.108 (T-0097): 네트워크는 락 밖에서 끝났다. 저장만 락 안에서 하되 **다시 읽어** 병합한다 —
16859
+ // 위의 userList 는 fetch 전 스냅샷이라, 그 사이 `provider add` 한 항목을 그대로 덮으면 지운다.
16860
+ if (!dryRun) _withLock(_providersFile(root), () => {
16861
+ const fresh = _readUserProviders(root);
16862
+ const byId = new Map(fresh.map(u => [u.id, u]));
16863
+ for (const u of userList) byId.set(u.id, u); // 이번 sync 결과가 이긴다(그게 sync 의 의미다)
16864
+ _writeUserProviders(root, [...byId.values()]);
16865
+ });
16687
16866
  log('');
16688
16867
  log(`✓ sync 완료: 신규 ${added} · 갱신 ${updated} · 무시 ${skipped}${dryRun ? ' (--dry-run)' : ''}`);
16689
16868
  if (!dryRun) log(` .env 에 LEERNESS_ENABLE_<X>=1 설정 후 \`leerness agents list\` 로 확인`);
@@ -17659,7 +17838,7 @@ function llmBenchRecordCmd(root) {
17659
17838
 
17660
17839
  const _sessionClose = require('../lib/session-close');
17661
17840
  // 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 낙관 재확인 + 시크릿 재확인
17841
+ 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
17842
 
17664
17843
  function readmeCmd(root) { syncReadme(absRoot(root)); }
17665
17844
  function consistencyCheck(root) {
@@ -18991,28 +19170,48 @@ function ruleList(root) {
18991
19170
  for (const r of rules) log(`| ${r.id} | ${_cellSafe(r.trigger)} | ${_cellSafe(r.rule)} | ${r.status} | ${r.lastVerified} |`); // 16th 버그헌트 F2: 파이프 칼럼 깨짐 차단(표시 _cellSafe)
18992
19171
  }
18993
19172
 
19173
+ // 1.36.108 (T-0097): rules.md 의 read-modify-write 병목. 종전엔 `rule add` **하나만** 락 안이었고
19174
+ // remove/pause/resume/stop/resume-all 다섯이 락 밖이었다(런타임 계측으로 실측 — 같은 스토어, 진입점 6개, 보호 1개).
19175
+ // 진입점마다 거는 방식은 이렇게 샌다. 읽기와 쓰기를 한 함수에 묶어 그 안에서 직렬화한다.
19176
+ // mutate 가 문자열을 돌려주면 그것을 실패 사유로 보고 쓰기를 건너뛴다(락 안에서 중단).
19177
+ function _updateRules(root, mutate) {
19178
+ return _withLock(rulesPath(root), () => {
19179
+ const rules = readRules(root);
19180
+ const r = mutate(rules);
19181
+ if (typeof r === 'string') return { error: r };
19182
+ writeRules(root, rules);
19183
+ return { ok: true, value: r };
19184
+ });
19185
+ }
19186
+
18994
19187
  function ruleRemove(root, id) {
18995
19188
  root = absRoot(root);
18996
19189
  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`);
19190
+ let removed = null;
19191
+ const res = _updateRules(root, (rules) => {
19192
+ const i = rules.findIndex(r => r.id === id);
19193
+ if (i < 0) return 'rule_not_found';
19194
+ removed = rules.splice(i, 1)[0];
19195
+ // 아카이브 추가도 같은 안에서 갱신과 아카이브가 갈라지면 룰이 양쪽 어디에도 없게 된다.
19196
+ const archive = exists(rulesArchivePath(root)) ? read(rulesArchivePath(root)) : '# Rules archive\n\n| ID | Trigger | Rule | Added | Status | Removed |\n|---|---|---|---|---|---|\n';
19197
+ // 1.11.3 (14th 버그헌트 P2, UR-0179): 아카이브 셀도 _cellSafe 기존엔 removed.rule/trigger raw 기록해 파이프(|) 포함 룰이 아카이브 표를 깨뜨렸음(rules.md 이미 _cellSafe 적용).
19198
+ writeUtf8(rulesArchivePath(root), archive + `| ${_cellSafe(removed.id)} | ${_cellSafe(removed.trigger)} | ${_cellSafe(removed.rule)} | ${_cellSafe(removed.added)} | removed | ${today()} |\n`);
19199
+ return removed;
19200
+ });
19201
+ if (res.error) return failJson(has('--json'), 'rule_not_found', `rule not found: ${id}`); // codex P2: --json 에러 구조화
19005
19202
  ok(`rule removed: ${id} (보존: .harness/rules.archive.md)`);
19006
19203
  }
19007
19204
 
19008
19205
  function rulePause(root, id) {
19009
19206
  root = absRoot(root);
19010
19207
  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);
19208
+ const res = _updateRules(root, (rules) => {
19209
+ const r = rules.find(x => x.id === id);
19210
+ if (!r) return 'rule_not_found';
19211
+ r.status = 'paused';
19212
+ return r;
19213
+ });
19214
+ if (res.error) return fail(`rule not found: ${id}`);
19016
19215
  ok(`rule paused: ${id}`);
19017
19216
  _autoRoadmap(root, 'data-change');
19018
19217
  }
@@ -19020,31 +19219,35 @@ function rulePause(root, id) {
19020
19219
  function ruleResume(root, id) {
19021
19220
  root = absRoot(root);
19022
19221
  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);
19222
+ const res = _updateRules(root, (rules) => {
19223
+ const r = rules.find(x => x.id === id);
19224
+ if (!r) return 'rule_not_found';
19225
+ r.status = 'active';
19226
+ return r;
19227
+ });
19228
+ if (res.error) return fail(`rule not found: ${id}`);
19028
19229
  ok(`rule resumed: ${id}`);
19029
19230
  _autoRoadmap(root, 'data-change');
19030
19231
  }
19031
19232
 
19032
19233
  function ruleStop(root) {
19033
19234
  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로 재개)`);
19235
+ const res = _updateRules(root, (rules) => {
19236
+ let n = 0;
19237
+ for (const r of rules) if (r.status === 'active') { r.status = 'paused'; n++; }
19238
+ return n;
19239
+ });
19240
+ ok(`${res.value}개 룰 일시 정지 (rule resume <id> 또는 rule resume-all로 재개)`);
19039
19241
  }
19040
19242
 
19041
19243
  function ruleResumeAll(root) {
19042
19244
  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}개 룰 재개`);
19245
+ const res = _updateRules(root, (rules) => {
19246
+ let n = 0;
19247
+ for (const r of rules) if (r.status === 'paused') { r.status = 'active'; n++; }
19248
+ return n;
19249
+ });
19250
+ ok(`${res.value}개 룰 재개`);
19048
19251
  }
19049
19252
 
19050
19253
  function captureProjectState(root) {
@@ -19980,8 +20183,17 @@ function _writeFeatureGraph(root, nodes) {
19980
20183
  // _featureImpactBfs 는 pure-utils 로 이동(handoff/audit 공유). feature-graph I/O 헬퍼(_readFeatureGraph 등)는 공유라 harness 유지+주입.
19981
20184
  const _feature = require('../lib/feature');
19982
20185
  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()); }
20186
+ // 1.36.108 (T-0097): feature add/link 는 읽기~쓰기 RMW 다(그래프 전체를 다시 렌더링해 저장).
20187
+ // 락을 여기서 감싼다 lib/feature.js I/O 를 주입받으므로 호출 경계가 가장 좁은 병목이다.
20188
+ // 단 **`.harness` 가 이미 있을 때만** 감싼다. `_withLock` 은 락 파일을 만들려고 디렉토리를 만드는데,
20189
+ // 미초기화 디렉토리에서 이 명령은 scaffold 게이트로 막혀야 하고 잔여 파일을 남기지 않아야 한다(1.36.2 계약).
20190
+ // 게이트에 걸릴 경우엔 아무것도 쓰지 않으므로 락도 필요 없다.
20191
+ function _featureLocked(root, fn) {
20192
+ const r = absRoot(root);
20193
+ return exists(path.join(r, '.harness')) ? _withLock(featureGraphPath(r), fn) : fn();
20194
+ }
20195
+ function featureAddCmd(root, title) { return _featureLocked(root, () => _feature.featureAddCmd(root, title, _featureDeps())); }
20196
+ function featureLinkCmd(root, fromId) { return _featureLocked(root, () => _feature.featureLinkCmd(root, fromId, _featureDeps())); }
19985
20197
  function featureImpactCmd(root, fromId) { return _feature.featureImpactCmd(root, fromId, _featureDeps()); }
19986
20198
  function featureListCmd(root) { return _feature.featureListCmd(root, _featureDeps()); }
19987
20199
  function featureShowCmd(root, fromId) { return _feature.featureShowCmd(root, fromId, _featureDeps()); }
@@ -25842,11 +26054,18 @@ async function main() {
25842
26054
  return;
25843
26055
  }
25844
26056
  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');
26057
+ // 1.36.108 (T-0097): 락 안에서 **다시 읽는다**. 1.36.107손상 판정과 필드 보존을 넣었지만 사이 창은 남아 있었다 —
26058
+ // codex 검수가 rename 직전에 writer 주입해 writer project/language/installedAt 전부 사라지는 것을 재현했다.
26059
+ // 밖에서 읽은 _mChk.data 쓰면 "보존" 하는 것이 **낡은 스냅샷**이라 프로세스의 변경을 지운다.
26060
+ _withLock(mf, () => {
26061
+ const fresh = _readManifestChecked(_mRoot);
26062
+ if (fresh.corrupt) return; // 락 대기 중 손상됐다면 쓰지 않는다(fail-closed 유지)
26063
+ // 1.36.107 (codex 검수, 재현됨): Object.assign 은 [[Set]] 을 쓰므로 `__proto__` 키에서 setter 가 걸려
26064
+ // 그 필드를 **조용히 버린다**(실측: 주입 후 mode set → 파일에서 사라짐). 스프레드는 CreateDataProperty 라 보존한다.
26065
+ // 병적인 입력이긴 하나 이 수정이 내건 계약이 "남은 필드를 전부 보존" 이므로, 예외를 두면 계약이 거짓이 된다.
26066
+ const m = { ...fresh.data };
26067
+ m.mode = want; writeUtf8(mf, JSON.stringify(m, null, 2) + '\n');
26068
+ });
25850
26069
  // 지침 파일은 등급의 산출물이므로 즉시 재생성한다 — 안 그러면 mode 는 이름만 바뀌고 읽히는 양은 그대로다.
25851
26070
  let regenerated = 0;
25852
26071
  try {
@@ -26172,7 +26391,9 @@ async function main() {
26172
26391
  if (cmd === 'pulse') return pulseCmd(_resolveRoot(args[1]));
26173
26392
  if (cmd === 'enforce') return enforceCmd(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd(), args[1]); // 1.36.43: 사용 강제 (git pre-commit)
26174
26393
  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: 기능 토글 (그래프 ⚙ 탭 연동)
26394
+ // 1.36.108 (T-0097): _withLock 넘긴다 lib 모듈이 락을 deps 받는 기존 관례(clarify·referee·routing·bugfix)
26395
+ // toggles 만 빠져 있어 `toggle set` 이 락 밖 read-modify-write 였다(런타임 계측으로 실측).
26396
+ if (cmd === 'toggle') return _tgl.toggleCmd(arg('--path', process.cwd()), args[1], args[2], args[3], { has, VERSION, _withLock }); // 1.36.30: 기능 토글 (그래프 ⚙ 탭 연동)
26176
26397
  // 1.36.53 (UR-0062): 기술 프로필 · 1.36.67 (F15): 변경 시 기존 leerness.html 동반 갱신(있을 때만)
26177
26398
  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
26399
  // 1.36.98 (P-0013): 재사용 인벤토리 — 새 화면 조각을 만들기 전에 '이미 있는 것' 을 먼저 본다(reuse-map 의 UI 판).
package/lib/analyzers.js CHANGED
@@ -40,16 +40,22 @@ function _shellGuardAnalyze(cmd, ctx) {
40
40
  // 실측으로 확인된 사각지대: 다단계 경로의 확장자 없는 파일(ops/containers/Dockerfile),
41
41
  // 선두 점 디렉토리(.github/workflows/ci.yml → github/… 로 잘림), 점파일(.gitignore),
42
42
  // 그리고 Dockerfile.dev 가 Dockerfile 로 잘려 "없는 파일" 이 되던 것.
43
- const _VC_FILE_EXTS = 'webmanifest|properties|tscn|tres|godot|json5|prisma|swift|java|jsx|tsx|yaml|html|scss|sass|less|gltf|conf|json|toml|lock|mdx|xml|css|svg|yml|cfg|ini|env|php|sql|mjs|cjs|vue|svelte|md|js|ts|gd|cs|py|rb|go|rs|kt|sh|cpp|c|h';
43
+ // 1.36.109 (codex 검수 P2): `pyi` 추가 — 타입 스텁(PEP 561)은 실제 산출물인데 추출조차 안 돼
44
+ // `pkg/__init__.pyi` 만 인용한 정직한 주장이 "파일 0건" 으로 거부됐다. 길이 내림차순 규칙상 `py` 앞에 둔다.
45
+ const _VC_FILE_EXTS = 'webmanifest|properties|tscn|tres|godot|json5|prisma|swift|java|jsx|tsx|yaml|html|scss|sass|less|gltf|conf|json|toml|lock|mdx|xml|css|svg|yml|cfg|ini|env|php|sql|mjs|cjs|vue|svelte|pyi|md|js|ts|gd|cs|py|rb|go|rs|kt|sh|cpp|c|h';
44
46
  // 확장자 없는 표준 파일명 — 임의의 대문자 단어가 아니라 정확한 철자만(대소문자 구분)으로 FP 억제.
45
47
  const _VC_BARE_FILES = 'Dockerfile|Containerfile|Makefile|Gemfile|Procfile|Rakefile|Jenkinsfile|Vagrantfile|Caddyfile|Brewfile|Justfile|CODEOWNERS|LICENCE|LICENSE|NOTICE';
46
48
  // 점파일 — 확장자 규칙으로는 잡히지 않지만 명백한 파일 참조.
47
49
  const _VC_DOTFILES = '\\.(?:gitignore|gitattributes|gitmodules|dockerignore|npmrc|npmignore|nvmrc|editorconfig|prettierrc|eslintrc|babelrc|browserslistrc)';
48
50
  // 경로 접두: 0개 이상 세그먼트(각 세그먼트는 선두 점 허용) — `ops/containers/`, `.github/workflows/` 모두 포함.
49
- const _VC_PREFIX = '(?:\\.?[A-Za-z0-9][A-Za-z0-9._-]*[\\/\\\\])*';
51
+ // 1.36.109 (완료게이트 사냥): 첫 글자에 `_` 가 없어서 `src/_helpers.js` 가 `helpers.js` 로 **잘려서** 추출됐다 —
52
+ // "파일 없음" 이 아니라 **없는 파일을 찾아** files-missing 으로 정직한 주장을 거부했다(실측).
53
+ // 흔한 이름들이 여기 걸린다: Next.js `pages/_app.tsx` · `_document.tsx` · 파이썬 `__init__.py` · `_private/…`.
54
+ // false-BLOCK 은 false-PASS 보다 나쁘다 — 사용자는 이유 없이 막히고 게이트를 끄게 된다.
55
+ const _VC_PREFIX = '(?:\\.?[A-Za-z0-9_][A-Za-z0-9._-]*[\\/\\\\])*';
50
56
  // basename: (a) 확장자 파일 (b) 표준 bare 파일명(뒤에 .확장자가 오면 (a)에 양보) (c) 점파일
51
57
  // bare 파일은 관례적 접미사를 허용한다(Dockerfile.dev / Makefile.local) — 없으면 그 주장이 "파일 0건"이 된다.
52
- const _VC_BASE = `(?:[A-Za-z0-9][\\w.-]*\\.(?:${_VC_FILE_EXTS})\\b|(?:${_VC_BARE_FILES})(?:\\.[A-Za-z0-9_-]+)?(?![\\w.-])|${_VC_DOTFILES}(?![\\w-]))`;
58
+ const _VC_BASE = `(?:[A-Za-z0-9_][\\w.-]*\\.(?:${_VC_FILE_EXTS})\\b|(?:${_VC_BARE_FILES})(?:\\.[A-Za-z0-9_-]+)?(?![\\w.-])|${_VC_DOTFILES}(?![\\w-]))`;
53
59
  function _EVIDENCE_FILE_RE(flags = '') { return new RegExp(`${_VC_PREFIX}${_VC_BASE}`, flags); }
54
60
 
55
61
  function _evidenceQuality(evidence) {
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.109",
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,244 @@ 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
+
10361
+ // ── 1.36.109 블록 M: 완료 게이트 — "done 에는 증거가 필요하다" 는 제품의 이름값이다.
10362
+ // 적대 사냥에서 두 방향으로 뚫려 있었다(대조군 2/2 통과 상태에서 측정 — 전부 거부하는 고장난 게이트가 아니었다):
10363
+ // · false-PASS: `touch src/x.js` 후 "src/x.js 구현 완료" 로 done 이 통과했다.
10364
+ // 공백 3자짜리 파일은 stub-impl 로 막히는데 **0바이트는 통과** — 더 비어 있을수록 쉽게 통과하는 역전이었다.
10365
+ // 원인은 `!body` 를 스킵 조건에 둔 것이고, 같은 falsy 함정이 호출부와 `_vcImplIsEmpty` **두 곳**에 있어
10366
+ // 한 곳만 고치면 구멍이 남았다(가드가 서로를 가리는 형태).
10367
+ // · false-BLOCK: `src/_helpers.js` 를 인용하면 추출기가 `helpers.js` 로 **잘라서** 없는 파일을 찾았다.
10368
+ // Next.js `pages/_app.tsx`, 파이썬 `__init__.py` 처럼 흔한 이름이 전부 여기 걸렸다. 이유 없이 막히면
10369
+ // 사용자는 게이트를 끈다 — false-BLOCK 이 false-PASS 보다 나쁘다.
10370
+ // 두 방향을 한 블록에서 함께 단언한다. 한쪽만 보면 "전부 거부" 나 "전부 통과" 로 도망갈 수 있다.
10371
+ {
10372
+ total++;
10373
+ let ok = false; const dbg = {};
10374
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-claim109-'));
10375
+ try {
10376
+ const ENV = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb });
10377
+ let seq = 0;
10378
+ const mk = (setup) => {
10379
+ const d = path.join(sb, 'p' + (++seq));
10380
+ fs.mkdirSync(d, { recursive: true });
10381
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
10382
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
10383
+ setup(d); return d;
10384
+ };
10385
+ const R = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
10386
+ const verdict = (d, evidence) => {
10387
+ const id = (String(R(d, ['task', 'add', '주장']).stdout || '').match(/T-\d{4,}/) || [])[0];
10388
+ R(d, ['task', 'update', id, '--status', 'done', '--evidence', evidence]);
10389
+ const r = R(d, ['verify-claim', id, '--json']);
10390
+ let j = null; try { j = JSON.parse(String(r.stdout || '')); } catch {}
10391
+ return { ok: j ? j.ok : null, reasons: j ? (j.reasons || []) : [] };
10392
+ };
10393
+ const W = (d, rel, body) => { const p = path.join(d, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, body); };
10394
+
10395
+ // ① 0바이트 = 스텁 (그리고 공백만도 여전히 스텁 — 둘이 같은 판정이어야 한다)
10396
+ const dEmpty = mk(d => W(d, 'src/empty.js', ''));
10397
+ const vEmpty = verdict(dEmpty, 'src/empty.js 구현 완료');
10398
+ const dBlank = mk(d => W(d, 'src/blank.js', ' \n\n '));
10399
+ const vBlank = verdict(dBlank, 'src/blank.js 구현 완료');
10400
+ dbg.emptyBlocked = vEmpty.ok === false && vEmpty.reasons.includes('stub-impl');
10401
+ dbg.blankBlocked = vBlank.ok === false && vBlank.reasons.includes('stub-impl');
10402
+
10403
+ // ② 대조군 — 실제 구현은 계속 통과해야 한다(게이트가 전부 거부하면 이 블록은 무의미하다)
10404
+ const dReal = mk(d => W(d, 'src/real.js', 'function f(){return 1;}\nmodule.exports={f};\n'));
10405
+ dbg.realPasses = verdict(dReal, 'src/real.js 구현 완료').ok === true;
10406
+
10407
+ // ③ 밑줄 시작 파일명이 잘리지 않는다 — 실존하는데 files-missing 이 나면 정직한 주장의 오차단
10408
+ const dUs = mk(d => W(d, 'src/_helpers.js', 'function h(){return 1;}\nmodule.exports={h};\n'));
10409
+ const vUs = verdict(dUs, 'src/_helpers.js 구현 완료');
10410
+ dbg.underscoreOk = vUs.ok === true && !vUs.reasons.includes('files-missing');
10411
+ const dNext = mk(d => W(d, 'pages/_app.tsx', 'export default function App(){ return null; }\n'));
10412
+ const vNext = verdict(dNext, 'pages/_app.tsx 수정 완료');
10413
+ dbg.nextjsOk = vNext.ok === true && !vNext.reasons.includes('files-missing');
10414
+ // 디렉토리 이름이 `_` 로 시작하는 경우도 같은 결함이다 — 경로 접두와 basename 은 별개 문자 클래스라
10415
+ // 한쪽만 고치면 나머지가 남는다(변이 시험에서 basename 만 되돌렸을 때 이 케이스는 통과했다).
10416
+ const dPriv = mk(d => W(d, '_private/util.ts', 'export function u(){ return 1; }\n'));
10417
+ const vPriv = verdict(dPriv, '_private/util.ts 정리 완료');
10418
+ dbg.privDirOk = vPriv.ok === true && !vPriv.reasons.includes('files-missing');
10419
+
10420
+ // ④ 관례상 빈 파일(__init__.py)은 스텁으로 막지 않는다 — 단, **다른 실질 파일과 함께일 때만**.
10421
+ const dPy = mk(d => { W(d, 'pkg/__init__.py', ''); W(d, 'pkg/core.py', 'def f():\n return 1\n'); });
10422
+ const vPy = verdict(dPy, 'pkg/__init__.py, pkg/core.py 구현 완료');
10423
+ dbg.initPyExempt = vPy.ok === true;
10424
+ // ④-b codex 검수 P1(재현됨): 마커 **단독**은 구현 증거가 아니다. 처음 판에서는 무조건 예외라
10425
+ // 빈 `__init__.py` 하나만 인용한 done 이 통과했고, 이 블록은 ④가 core.py 를 함께 인용한 탓에 못 잡았다.
10426
+ // 가드가 놓친 이유가 "조건을 안 만들어서" 였다 — 조건을 만든다.
10427
+ const dPyOnly = mk(d => W(d, 'pkg/__init__.py', ''));
10428
+ const vPyOnly = verdict(dPyOnly, 'pkg/__init__.py 구현 완료');
10429
+ dbg.markerOnlyBlocked = vPyOnly.ok === false && vPyOnly.reasons.includes('stub-impl');
10430
+ // ④-c 타입 스텁(PEP 561)도 같은 규칙 — 인식되어야 예외가 도달한다(안 그러면 광고만 하는 코드).
10431
+ const dPyi = mk(d => { W(d, 'pkg/__init__.pyi', ''); W(d, 'pkg/core.pyi', 'def f() -> int: ...\n'); });
10432
+ const vPyi = verdict(dPyi, 'pkg/__init__.pyi, pkg/core.pyi 타입 스텁 추가');
10433
+ dbg.pyiRecognized = vPyi.ok === true;
10434
+ // ⑤-b URL 조각 / 드라이브 상대 경로는 루트 상대 파일 주장이 아니다 — 실존 파일이 있어도 세탁되면 안 된다.
10435
+ const dFrag = mk(d => W(d, '_anchor.js', 'function a(){return 1;}\nmodule.exports={a};\n'));
10436
+ const vFrag = verdict(dFrag, 'https://example.test/docs#_anchor.js 검토 완료');
10437
+ dbg.urlFragBlocked = vFrag.ok === false;
10438
+ const dDrive = mk(d => W(d, '_anchor.js', 'function a(){return 1;}\nmodule.exports={a};\n'));
10439
+ const vDrive = verdict(dDrive, 'C:_anchor.js 검토 완료');
10440
+ dbg.driveRelBlocked = vDrive.ok === false;
10441
+ // 대조군 — 정상 표기 `파일:src/x.js` 는 계속 인식돼야 한다(`:` 를 통째로 막지 않았는지)
10442
+ const dColon = mk(d => W(d, 'src/colon.js', 'function c(){return 1;}\nmodule.exports={c};\n'));
10443
+ const vColon = verdict(dColon, '파일:src/colon.js 구현 완료');
10444
+ dbg.colonFormOk = vColon.ok === true;
10445
+
10446
+ // ⑤ 없는 파일은 여전히 막힌다(추출기를 넓혔다고 존재 검사가 느슨해지지 않았는지)
10447
+ const dGhost = mk(() => {});
10448
+ const vGhost = verdict(dGhost, 'src/_ghost.js 구현 완료');
10449
+ dbg.ghostStillBlocked = vGhost.ok === false && vGhost.reasons.includes('files-missing');
10450
+
10451
+ ok = dbg.emptyBlocked && dbg.blankBlocked && dbg.realPasses && dbg.underscoreOk
10452
+ && dbg.nextjsOk && dbg.privDirOk && dbg.initPyExempt && dbg.ghostStillBlocked
10453
+ && dbg.markerOnlyBlocked && dbg.pyiRecognized && dbg.urlFragBlocked && dbg.driveRelBlocked && dbg.colonFormOk;
10454
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
10455
+ console.log(ok ? '✓ M(1.36.109) 완료 게이트: 0바이트=스텁(공백과 동일 판정) + 밑줄 파일명 무손실 추출 + __init__.py 예외 + 실구현/없는파일 대조군'
10456
+ : '✗ 1.36.109 완료 게이트 실패 ' + JSON.stringify(dbg));
10457
+ if (!ok) failed++;
10458
+ }
10459
+
10222
10460
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
10223
10461
  if (failed > 0) process.exit(1);
10224
10462
 
@@ -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
+ });