leerness 1.9.301 → 1.9.303

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.303 — 2026-06-04 — UR-0043: 상태 파일 lost-update 락 (외부 AI 리뷰 — 멀티에이전트 안전 완성)
4
+
5
+ **🔐 원자적 쓰기(UR-0038)가 막지 못한 동시 read-modify-write lost-update를 advisory 락으로 차단. Codex/Opus가 지적한 "상태 쓰기에 락 0건" 해소 — 멀티에이전트 동시 쓰기 안전성 완성.**
6
+
7
+ ### 배경 (Codex / Opus A-3)
8
+ 원자적 쓰기는 부분쓰기 손상은 막으나, 두 에이전트가 동시에 `task add`(또는 `state record`)를 호출하면: A 읽기 → B 읽기 → A 쓰기 → B 쓰기(A 변경 누락) = **lost-update**. 특히 `nextId`(다음 ID 계산)가 쓰기와 분리돼 **동일 ID 충돌**(둘 다 같은 T-XXXX → 덮어쓰기)까지 발생. grep 결과 `flock`/`O_EXCL` 0건(Opus).
9
+
10
+ ### 구현 (UR-0043)
11
+ 1. **`_withLock(targetPath, fn)`** — `O_EXCL`(wx) 원자적 lock 파일로 상호배제. 점증 backoff 재시도, stale(crash) 30s 초과 시 탈취, 타임아웃 5s 시 락 없이 진행(원자쓰기로 손상은 이미 방지). **프로세스 내 재진입**(`_heldLocks`)으로 중첩 호출 데드락 방지.
12
+ 2. **`_updateRun(root, id, mutator)`** — run 레코드 RMW를 락으로 캡슐화.
13
+ 3. **적용** — `upsertProgress`(task/plan write), `taskAdd`/`planAdd`(nextId+upsert를 **하나의 락**으로 → ID 충돌 차단), `state record/verify/handoff`(_updateRun). `_sleepSyncMs`(Atomics.wait) 헬퍼.
14
+ 4. selftest 50→51 · e2e 247→248.
15
+
16
+ ### 검증
17
+ - **selftest 51/51 PASS** · **E2E 248/248 PASS** (회귀 0).
18
+ - **실측: 6개 `task add` 병렬 실행 → 6개 모두 보존 + ID 충돌 0 + 구분자 1줄**. (락 전: 3/6 보존, ID 충돌 발생 — 동일 ID 덮어쓰기 확인 후 수정.)
19
+
20
+ ### 🎉 외부 AI 리뷰 신뢰성/보안 핵심 권고 완수
21
+ - UR-0038 원자쓰기 · UR-0039 시크릿차단 · UR-0040 셸주입 · UR-0041 정책 메타데이터 · UR-0042 verify 시맨틱 · **UR-0043 lost-update 락** — 세 모델(Codex/Sonnet/Opus) 공통 high + 전략 항목 전부 코드화. 남은 UR-0044(handler 통합)는 low.
22
+
23
+ ## 1.9.302 — 2026-06-04 — UR-0042: verify-claim git diff 시맨틱 교차검증 (외부 AI 리뷰 R3, Opus G-1)
24
+
25
+ **🔍 Opus가 "가장 전략적 약점"으로 꼽은 거짓완료 검증의 실질화 — "파일 존재 + N passed" 문자열매칭에 git diff 교차검증 추가: 주장한 파일이 실제로 변경됐는가를 git working tree + 직전 커밋으로 대조.**
26
+
27
+ ### 배경 (Opus G-1)
28
+ verify-claim 의 차별점은 "거짓 완료 차단"인데, 기존 메커니즘은 evidence 텍스트에서 파일경로 추출 → `fs.existsSync` (존재만 확인) + "N passed" 정규식 파싱뿐. **변경 내용이 실제로 일어났는지는 검증 안 함** → "테스트 통과 = 구현 완료" 오인 가능. Opus: "파일이 존재하는가 + 테스트가 통과한다고 적혀있는가만 검증."
29
+
30
+ ### 구현 (UR-0042)
31
+ 1. **`_gitChangedFiles(root)`** — git working tree(staged/unstaged/untracked, `status --porcelain`) + 직전 커밋(`diff HEAD~1 HEAD`) 변경 파일 집합. git repo 아니면 null(검증 불가 → 페널티 없음).
32
+ 2. **`_claimFileInGit(claimed, gitSet)`** — 주장 파일이 git 변경에 있는지(상대경로 prefix 차이 허용).
33
+ 3. **verify-claim 종합에 git 교차검증** — 주장 N개 중 실제 변경 X개 표시. advisory 기본. `--strict-claims` 시 **강한 불일치**(working tree 변경 있는데 주장 파일이 git 변경에 전무)는 `overallFail` 기여(exit 1).
34
+ 4. 정직한 한계 고지 유지(시맨틱 정확성까지는 보장 X, 단 "주장↔실제 변경" 링크는 검증). selftest 49→50 · e2e 246→247.
35
+
36
+ ### 검증
37
+ - **selftest 50/50 PASS** · **E2E 247/247 PASS** (회귀 0).
38
+ - 실측(실 git repo): src/api.js 수정 후 "src/api.js 수정" 주장 → git 교차검증 **✓** · 미변경 old.js 주장 + `--strict-claims` → **⚠ 불일치 + exit 1**. git repo 아니면 skip(페널티 없음).
39
+ - false-positive 완화: working tree 변경 0(이미 커밋/미변경) 시 skip, 직전 커밋도 변경 집합에 포함.
40
+
3
41
  ## 1.9.301 — 2026-06-04 — UR-0041 (1단계): MCP 도구 requiredTier 메타데이터 + 정책 메타데이터 게이트 (R2)
4
42
 
5
43
  **🛡 외부 AI 리뷰 3종이 공통 지적한 "정책이 regex라 취약 + 도구 정의/dispatch/tier 3중 분산"의 핵심부 해소 — 81개 MCP 도구에 `requiredTier` 메타데이터를 부여하고, 정책 게이트가 regex와 메타데이터 중 더 엄격한 tier로 판정.**
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > **AI 코딩 에이전트의 거짓 완료·중복·망각·충돌을 막아주는 검수·기억·협업 CLI 하네스.**
4
4
  > **A CLI harness that stops AI coding agents from faking completion, duplicating work, forgetting context, and colliding.**
5
5
 
6
- [![npm](https://img.shields.io/badge/npm-leerness-blue)](https://www.npmjs.com/package/leerness) [![version](https://img.shields.io/badge/version-1.9.301-green)]() [![tests](https://img.shields.io/badge/e2e-246%2F246-success)]() [![selftest](https://img.shields.io/badge/selftest-49-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-81-brightgreen)]() [![providers](https://img.shields.io/badge/AI_providers-10-brightgreen)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
6
+ [![npm](https://img.shields.io/badge/npm-leerness-blue)](https://www.npmjs.com/package/leerness) [![version](https://img.shields.io/badge/version-1.9.303-green)]() [![tests](https://img.shields.io/badge/e2e-248%2F248-success)]() [![selftest](https://img.shields.io/badge/selftest-51-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-81-brightgreen)]() [![providers](https://img.shields.io/badge/AI_providers-10-brightgreen)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
7
7
 
8
8
  ```
9
9
  ╔══════════════════════════════════════════════════════════════╗
@@ -471,7 +471,7 @@ MIT — © leerness contributors
471
471
  <!-- leerness:project-readme:start -->
472
472
  ## Leerness Project Harness
473
473
 
474
- 이 프로젝트는 Leerness v1.9.301 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
474
+ 이 프로젝트는 Leerness v1.9.303 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
475
475
 
476
476
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
477
477
 
@@ -525,7 +525,7 @@ leerness memory restore decision <date|title>
525
525
 
526
526
  ### MCP server (외부 AI 통합)
527
527
 
528
- Leerness v1.9.301는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **81개 도구**를 노출:
528
+ Leerness v1.9.303는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **81개 도구**를 노출:
529
529
 
530
530
  ```jsonc
531
531
  // 카테고리별
@@ -546,7 +546,7 @@ Leerness v1.9.301는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
546
546
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
547
547
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) stress-v* 신규 작성 + 누적 회귀 → 4) e2e 219/219 → 5) npm pack + git tag + GitHub release → 6) main 자동 push (1.9.140+) → 7) session close → 8) 다음 라운드 예약.
548
548
 
549
- 현재 누적: **70 라운드 (1.9.40 → 1.9.301)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
549
+ 현재 누적: **70 라운드 (1.9.40 → 1.9.303)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
550
550
 
551
551
  ### 성능 가이드 (1.9.140 측정)
552
552
 
@@ -584,6 +584,6 @@ leerness release pack --close --auto-main-push
584
584
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
585
585
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
586
586
 
587
- Last synced by Leerness v1.9.301: 2026-06-04
587
+ Last synced by Leerness v1.9.303: 2026-06-04
588
588
  <!-- leerness:project-readme:end -->
589
589
 
package/bin/harness.js CHANGED
@@ -12,7 +12,7 @@ const { _isSecretKey, compareVer, parseHarnessVersion, _classifyCJK, _riskLabel,
12
12
  // 1.9.295 (UR-0025 4단계): 정적 데이터 카탈로그 모듈 분리 (비파괴, require-based).
13
13
  const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST } = require('../lib/catalogs');
14
14
 
15
- const VERSION = '1.9.301';
15
+ const VERSION = '1.9.303';
16
16
 
17
17
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
18
18
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -189,6 +189,35 @@ function writeUtf8(p, s) {
189
189
  }
190
190
  }
191
191
  function append(p, s) { mkdirp(path.dirname(p)); fs.appendFileSync(p, s, 'utf8'); }
192
+ // 1.9.303 (UR-0043, 외부리뷰): 동기 sleep (Atomics.wait, 실패 시 busy-wait 폴백).
193
+ function _sleepSyncMs(ms) {
194
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(1, ms | 0)); }
195
+ catch { const end = Date.now() + ms; while (Date.now() < end) {} }
196
+ }
197
+ // 1.9.303 (UR-0043, 외부리뷰 Codex/Opus): read-modify-write lost-update 방지 advisory 락.
198
+ // O_EXCL(wx) 원자적 생성으로 상호배제. 다른 프로세스가 보유 중이면 짧게 대기 후 재시도.
199
+ // stale(보유 프로세스 crash) 은 staleMs 초과 시 탈취. 타임아웃(maxWaitMs) 시 락 없이 진행(원자쓰기로 손상은 이미 방지, lost-update 만 rare 노출).
200
+ const _heldLocks = new Set(); // 프로세스 내 재진입 추적 (중첩 _withLock 데드락 방지)
201
+ function _withLock(targetPath, fn, opts = {}) {
202
+ const lockPath = targetPath + '.lock';
203
+ if (_heldLocks.has(lockPath)) return fn(); // 이미 이 프로세스가 보유 → 재진입(중첩 호출 안전)
204
+ const maxWaitMs = opts.maxWaitMs || 5000;
205
+ const staleMs = opts.staleMs || 30000;
206
+ try { mkdirp(path.dirname(lockPath)); } catch {}
207
+ const start = Date.now();
208
+ let held = false;
209
+ while (Date.now() - start <= maxWaitMs) {
210
+ try { const fd = fs.openSync(lockPath, 'wx'); fs.closeSync(fd); held = true; break; } // 원자적 배타 생성
211
+ catch (e) {
212
+ if (e.code !== 'EEXIST') break; // 예기치 못한 오류 → 락 없이 진행
213
+ try { const st = fs.statSync(lockPath); if (Date.now() - st.mtimeMs > staleMs) { fs.unlinkSync(lockPath); continue; } } catch {}
214
+ _sleepSyncMs(15 + Math.floor((Date.now() - start) / 50)); // 점증 backoff
215
+ }
216
+ }
217
+ if (held) _heldLocks.add(lockPath);
218
+ try { return fn(); }
219
+ finally { if (held) { _heldLocks.delete(lockPath); try { fs.unlinkSync(lockPath); } catch {} } }
220
+ }
192
221
  function rel(root, p) { return path.relative(root, p).replace(/\\/g, '/') || '.'; }
193
222
  function today() { return new Date().toISOString().slice(0, 10); }
194
223
  function now() { return new Date().toISOString(); }
@@ -3037,6 +3066,8 @@ function _selfTestCases() {
3037
3066
  { name: '_scrubTestEnv: npm test 시크릿 차단(_scrubEnv는 release 토큰 유지) (UR-0039 외부리뷰 1.9.299)', run: () => { const o = { N: process.env.NPM_TOKEN, L: process.env.LEERNESS_NPM_TOKEN }; process.env.NPM_TOKEN = 'sec1'; process.env.LEERNESS_NPM_TOKEN = 'sec2'; const base = _scrubEnv(); const test = _scrubTestEnv(); const r = base.NPM_TOKEN === 'sec1' && base.LEERNESS_NPM_TOKEN === 'sec2' && !test.NPM_TOKEN && !test.LEERNESS_NPM_TOKEN && !!test.PATH; if (o.N === undefined) delete process.env.NPM_TOKEN; else process.env.NPM_TOKEN = o.N; if (o.L === undefined) delete process.env.LEERNESS_NPM_TOKEN; else process.env.LEERNESS_NPM_TOKEN = o.L; return r; } },
3038
3067
  { name: 'shell 주입 표면 제거: fetchNpmLatest execFile+pkg검증 + runCommandSafe argList 인용 (UR-0040 외부리뷰 1.9.300)', run: () => { const src = read(__filename); const npmFix = /cp\.execFile\('npm', \['view', pkg, 'version'\]/.test(src) && !/cp\.exec\(.npm view \$\{pkg\}/.test(src) && /패키지명 charset/.test(src); const argFix = /argList\.map\(_shellQuoteArg\)\.join/.test(src); return npmFix && argFix && typeof _shellQuoteArg === 'function'; } },
3039
3068
  { name: 'MCP requiredTier 메타데이터 + 정책 minTier 게이트 (UR-0041 외부리뷰 1.9.301)', run: () => { const T = require('../lib/mcp-tools'); const allValid = T.length >= 81 && T.every(t => PERMISSION_TIERS.includes(t.requiredTier)); const get = n => (T.find(t => t.name === n) || {}).requiredTier; const classOk = get('leerness_state_record') === 'safe-write' && get('leerness_provider_add') === 'safe-write' && get('leerness_web') === 'network' && get('leerness_handoff') === 'read-only' && get('leerness_audit') === 'read-only'; const src = read(__filename); const gateOk = /_tierRank\(minTier\) > _tierRank\(required\)/.test(src) && /_policyEnforce\(targetPath, cliArgs\.join\(' '\), _toolDef/.test(src); return allValid && classOk && gateOk; } },
3069
+ { name: 'verify-claim git diff 시맨틱 교차검증: _gitChangedFiles/_claimFileInGit + strict FAIL 통합 (UR-0042 외부리뷰 1.9.302)', run: () => { const fnOk = typeof _gitChangedFiles === 'function' && typeof _claimFileInGit === 'function'; const matchOk = _claimFileInGit('src/api.js', new Set(['src/api.js'])) === true && _claimFileInGit('./src/api.js', new Set(['src/api.js'])) === true && _claimFileInGit('other.js', new Set(['src/api.js'])) === false && _claimFileInGit('x', null) === null; const src = read(__filename); const wired = /git diff 교차검증/.test(src) && /\|\| !gitClaimOk/.test(src) && /_gitChangedFiles\(root\)/.test(src); return fnOk && matchOk && wired; } },
3070
+ { name: '_withLock/_updateRun: lost-update 락(O_EXCL+재진입) + 적용 (UR-0043 외부리뷰 1.9.303)', run: () => { const src = read(__filename); const fnOk = typeof _withLock === 'function' && typeof _sleepSyncMs === 'function' && typeof _updateRun === 'function'; const reentrant = /if \(_heldLocks\.has\(lockPath\)\) return fn\(\)/.test(src); const excl = /fs\.openSync\(lockPath, 'wx'\)/.test(src); const applied = /const id = _withLock\(progressPath\(root\)/.test(src) && /_updateRun\(root, curId/.test(src); return fnOk && reentrant && excl && applied; } },
3040
3071
  { name: 'VERSION 형식 (x.y.z)', run: () => /^\d+\.\d+\.\d+$/.test(VERSION) }
3041
3072
  ];
3042
3073
  }
@@ -6109,12 +6140,15 @@ function writeProgressRows(root, header, rows) {
6109
6140
  writeUtf8(progressPath(root), composed);
6110
6141
  }
6111
6142
  function upsertProgress(root, row) {
6112
- const header = progressHeader(root);
6113
- const rows = readProgressRows(root);
6114
- const i = rows.findIndex(r => r.id === row.id);
6115
- if (i >= 0) rows[i] = { ...rows[i], ...row, updated: today() };
6116
- else rows.push({ ...row, updated: today() });
6117
- writeProgressRows(root, header, rows);
6143
+ // 1.9.303 (UR-0043): read-modify-write 전체를 락으로 직렬화 — 동시 task 쓰기 lost-update 방지.
6144
+ return _withLock(progressPath(root), () => {
6145
+ const header = progressHeader(root);
6146
+ const rows = readProgressRows(root);
6147
+ const i = rows.findIndex(r => r.id === row.id);
6148
+ if (i >= 0) rows[i] = { ...rows[i], ...row, updated: today() };
6149
+ else rows.push({ ...row, updated: today() });
6150
+ writeProgressRows(root, header, rows);
6151
+ });
6118
6152
  }
6119
6153
 
6120
6154
  function planShow(root) { const p = planPath(root); log(exists(p) ? read(p) : 'plan.md not found'); }
@@ -6175,11 +6209,15 @@ function planListCmd(root, opts = {}) {
6175
6209
  }
6176
6210
 
6177
6211
  function planAdd(root, text) {
6178
- const id = nextId(root, 'M');
6179
- const status = arg('--status','planned'), progress = arg('--progress','0');
6180
- append(planPath(root), `\n### ${id}. ${text}\nStatus: ${status}\nProgress: ${progress}%\n\nTasks:\n- [ ] ${text}\n`);
6181
- const tid = nextId(root, 'T');
6182
- upsertProgress(root, { id: tid, status, request: text, evidence: `plan:${id}`, nextAction: arg('--next', '다음 액션 작성') });
6212
+ const status = arg('--status','planned'), progress = arg('--progress','0'), nextAction = arg('--next', '다음 액션 작성');
6213
+ // 1.9.303 (UR-0043): M-id append + T-id upsert 를 하나의 락으로 — 동시 plan add ID 충돌 방지.
6214
+ const { id, tid } = _withLock(progressPath(root), () => {
6215
+ const id = nextId(root, 'M');
6216
+ append(planPath(root), `\n### ${id}. ${text}\nStatus: ${status}\nProgress: ${progress}%\n\nTasks:\n- [ ] ${text}\n`);
6217
+ const tid = nextId(root, 'T');
6218
+ upsertProgress(root, { id: tid, status, request: text, evidence: `plan:${id}`, nextAction });
6219
+ return { id, tid };
6220
+ });
6183
6221
  ok(`plan added: ${id} → progress: ${tid}`);
6184
6222
  _autoRoadmap(absRoot(root), 'data-change');
6185
6223
  }
@@ -6301,8 +6339,12 @@ function taskAdd(root, text) {
6301
6339
  }
6302
6340
  } catch {}
6303
6341
  }
6304
- const id = nextId(root, 'T');
6305
- upsertProgress(root, { id, status: arg('--status','requested'), request: text, evidence: arg('--evidence','user-request'), nextAction: arg('--next','다음 액션 작성') });
6342
+ // 1.9.303 (UR-0043): ID 할당 + write 를 하나의 락으로 — 동시 task add 의 ID 충돌(lost-update) 방지.
6343
+ const id = _withLock(progressPath(root), () => {
6344
+ const newId = nextId(root, 'T');
6345
+ upsertProgress(root, { id: newId, status: arg('--status','requested'), request: text, evidence: arg('--evidence','user-request'), nextAction: arg('--next','다음 액션 작성') });
6346
+ return newId;
6347
+ });
6306
6348
  ok(`task added: ${id}`);
6307
6349
  _autoRoadmap(absRoot(root), 'data-change');
6308
6350
  // 1.9.177: task add 자동 review-request trigger (사용자 명시 1.9.176 자동화).
@@ -9404,6 +9446,33 @@ function _evidenceQuality(evidence) {
9404
9446
  if (!hasLog) missing.push('실행 로그(Command/Exit)');
9405
9447
  return { hasFile, hasTest, hasLog, ok: hasFile && hasTest, missing };
9406
9448
  }
9449
+ // 1.9.302 (UR-0042, 외부리뷰 Opus G-1): git 변경 파일 집합 — verify-claim 시맨틱 교차검증용.
9450
+ // working tree(staged/unstaged/untracked) + 직전 커밋(HEAD~1..HEAD) 변경을 합쳐, "주장한 파일이 실제로 변경됐는가" 판정.
9451
+ // git repo 아니거나 git 미설치면 null(검증 불가 → 페널티 X). 경로는 root-relative forward-slash.
9452
+ function _gitChangedFiles(root) {
9453
+ try {
9454
+ const st = cp.spawnSync('git', ['-C', root, 'status', '--porcelain', '--untracked-files=all'], { encoding: 'utf8', timeout: 10000 });
9455
+ if (st.status !== 0) return null; // git repo 아님 / git 없음
9456
+ const out = new Set();
9457
+ for (let line of (st.stdout || '').split('\n')) {
9458
+ if (line.length < 4) continue;
9459
+ let p = line.slice(3).trim(); // 'XY ' 상태 프리픽스 제거
9460
+ if (p.includes(' -> ')) p = p.split(' -> ').pop(); // rename
9461
+ p = p.replace(/^"|"$/g, ''); // quoted path
9462
+ if (p) out.add(p.replace(/\\/g, '/'));
9463
+ }
9464
+ const df = cp.spawnSync('git', ['-C', root, 'diff', '--name-only', 'HEAD~1', 'HEAD'], { encoding: 'utf8', timeout: 10000 });
9465
+ if (df.status === 0) for (let line of (df.stdout || '').split('\n')) { line = line.trim(); if (line) out.add(line.replace(/\\/g, '/')); }
9466
+ return out;
9467
+ } catch { return null; }
9468
+ }
9469
+ // 주장 파일이 git 변경 집합에 있는지(상대경로 prefix 차이 허용).
9470
+ function _claimFileInGit(claimed, gitSet) {
9471
+ if (!gitSet) return null;
9472
+ const c = String(claimed).replace(/\\/g, '/').replace(/^\.\//, '');
9473
+ for (const g of gitSet) { if (g === c || g.endsWith('/' + c) || c.endsWith('/' + g)) return true; }
9474
+ return false;
9475
+ }
9407
9476
  function verifyClaimCmd(root, taskId) {
9408
9477
  root = absRoot(root);
9409
9478
  if (!taskId) return fail('verify-claim <T-ID> 필요. 예: leerness verify-claim T-0008');
@@ -9450,6 +9519,12 @@ function verifyClaimCmd(root, taskId) {
9450
9519
 
9451
9520
  // 실제 파일 존재 검사
9452
9521
  const fileChecks = files.map(f => ({ file: f, exists: exists(path.join(root, f)) }));
9522
+ // 1.9.302 (UR-0042, 외부리뷰 Opus G-1): git diff 시맨틱 교차검증 — 주장한 파일이 실제로 변경됐는가.
9523
+ // "파일 존재"만으로는 "테스트만 통과하면 done" 허위완료를 못 막음(Opus). git working tree+직전커밋 변경과 대조.
9524
+ const gitChanged = _gitChangedFiles(root); // Set | null(git repo 아님 → 검증 불가)
9525
+ const gitApplicable = !!gitChanged && gitChanged.size > 0 && files.length > 0;
9526
+ const claimedInGit = gitApplicable ? files.filter(f => _claimFileInGit(f, gitChanged)) : [];
9527
+ const claimedNotInGit = gitApplicable ? files.filter(f => !_claimFileInGit(f, gitChanged)) : [];
9453
9528
  // 테스트 카운트: tests/test.js의 check( 또는 it( 또는 test( 개수
9454
9529
  let actualTestCount = null;
9455
9530
  const candidateTestFiles = ['tests/test.js', 'test/test.js', 'tests/index.js'];
@@ -9596,6 +9671,19 @@ function verifyClaimCmd(root, taskId) {
9596
9671
  for (const s of optimismSuspects) log(` · [${s.kind}] ${s.label}: evidence에 주장 있는데 코드에 호출 흔적 없음`);
9597
9672
  }
9598
9673
  }
9674
+ // 1.9.302 (UR-0042): git diff 시맨틱 교차검증 — 주장한 파일이 실제 git 변경(working tree+직전커밋)에 있는가.
9675
+ // advisory 기본 표시. --strict-claims 시 강한 불일치(변경 있는데 주장 파일이 하나도 git 변경에 없음)는 FAIL 기여.
9676
+ let gitClaimOk = true;
9677
+ if (gitChanged === null) {
9678
+ log(` - git diff 교차검증: ⊘ skip (git repo 아님 — 검증 불가)`);
9679
+ } else if (!gitApplicable) {
9680
+ log(` - git diff 교차검증: ⊘ skip (working tree 변경 0 또는 주장 파일 0 — 이미 커밋됐거나 해당 없음)`);
9681
+ } else {
9682
+ const strongMismatch = claimedInGit.length === 0; // 변경 있는데 주장 파일이 git 변경에 전무
9683
+ log(` - git diff 교차검증: ${strongMismatch ? '⚠ 불일치' : '✓'} 주장 ${files.length}개 중 실제 변경 ${claimedInGit.length}개${claimedNotInGit.length ? ` · git 변경에 없음: ${claimedNotInGit.slice(0, 5).join(', ')}` : ''}`);
9684
+ if (strongMismatch) log(` · 주장한 파일이 working tree/직전커밋 변경에 전무 — 변경이 더 오래전 커밋이거나, 실제로 변경 안 됐을 수 있음(허위완료 의심)`);
9685
+ if (has('--strict-claims') && strongMismatch) gitClaimOk = false; // strict 시 강한 불일치는 FAIL
9686
+ }
9599
9687
  // 1.9.287 (Codex 리뷰 수렴): --require-evidence — done 주장의 evidence 완전성(파일+테스트) 강제.
9600
9688
  // "테스트 통과만으로 done" 차단 — Codex 가 발견한 허위 완료 통과 갭 보강.
9601
9689
  const reqEvidence = has('--require-evidence');
@@ -9607,7 +9695,7 @@ function verifyClaimCmd(root, taskId) {
9607
9695
  log(` - evidence 완전성 (--require-evidence): ${evidenceQualityOk ? '✓ pass (파일+테스트 근거 있음)' : `✗ FAIL (누락: ${evq.missing.join(', ')})`}`);
9608
9696
  if (!evidenceQualityOk) log(` · done 주장은 수정 파일 경로 + 테스트명/개수 가 evidence 에 있어야 함 (테스트 통과만으로는 불충분)`);
9609
9697
  }
9610
- const overallFail = !allFilesOk || !testOk || (runResult && !runResult.skipped && !runTestsOk) || (has('--strict-claims') && !strictOk) || !evidenceQualityOk;
9698
+ const overallFail = !allFilesOk || !testOk || (runResult && !runResult.skipped && !runTestsOk) || (has('--strict-claims') && !strictOk) || !evidenceQualityOk || !gitClaimOk;
9611
9699
  // 1.9.287: 정직한 한계 고지 — 테스트 통과 ≠ 의미적 구현 정확성
9612
9700
  if (has('--strict-claims') || reqEvidence) {
9613
9701
  log('');
@@ -18070,6 +18158,16 @@ function _saveLeernessState(root, state) {
18070
18158
  function _runFile(root, id) { return path.join(_leernessStateDir(root), 'runs', `${id}.json`); }
18071
18159
  function _loadRun(root, id) { const f = _runFile(root, id); if (!exists(f)) return null; try { return JSON.parse(read(f)); } catch { return null; } }
18072
18160
  function _saveRun(root, rec) { const f = _runFile(root, rec.run_id); mkdirp(path.dirname(f)); writeUtf8(f, JSON.stringify(rec, null, 2) + '\n'); }
18161
+ // 1.9.303 (UR-0043): run 레코드 read-modify-write 를 락으로 직렬화 — 동시 state record/verify/handoff lost-update 방지.
18162
+ function _updateRun(root, id, mutator) {
18163
+ return _withLock(_runFile(root, id), () => {
18164
+ const rec = _loadRun(root, id);
18165
+ if (!rec) return null;
18166
+ mutator(rec);
18167
+ _saveRun(root, rec);
18168
+ return rec;
18169
+ });
18170
+ }
18073
18171
  function _splitList(v) { return String(v || '').split(',').map(s => s.trim()).filter(Boolean); }
18074
18172
 
18075
18173
  // leerness state <show|start|record|verify|handoff>
@@ -18227,36 +18325,40 @@ function stateCmd(root, sub, ...args) {
18227
18325
  }
18228
18326
 
18229
18327
  if (sub === 'record') {
18230
- const rec = _loadRun(root, curId); if (!rec) return fail(`run 없음: ${curId}`);
18231
- for (const [flag, key] of [['--files-read', 'files_read'], ['--files-changed', 'files_changed'], ['--commands', 'commands_run'], ['--tests', 'tests_run'], ['--errors', 'errors'], ['--decision', 'decisions']]) {
18232
- const v = arg(flag, null); if (v) rec[key] = Array.from(new Set([...(rec[key] || []), ..._splitList(v)]));
18233
- }
18234
- _saveRun(root, rec);
18328
+ const rec = _updateRun(root, curId, (rec) => { // 1.9.303 (UR-0043): 직렬화 RMW
18329
+ for (const [flag, key] of [['--files-read', 'files_read'], ['--files-changed', 'files_changed'], ['--commands', 'commands_run'], ['--tests', 'tests_run'], ['--errors', 'errors'], ['--decision', 'decisions']]) {
18330
+ const v = arg(flag, null); if (v) rec[key] = Array.from(new Set([...(rec[key] || []), ..._splitList(v)]));
18331
+ }
18332
+ });
18333
+ if (!rec) return fail(`run 없음: ${curId}`);
18235
18334
  if (json) { log(JSON.stringify({ recorded: curId, run: rec }, null, 2)); return; }
18236
18335
  ok(`기록: ${curId} (files_changed ${rec.files_changed.length} · commands ${rec.commands_run.length} · tests ${rec.tests_run.length} · decisions ${rec.decisions.length})`);
18237
18336
  return;
18238
18337
  }
18239
18338
 
18240
18339
  if (sub === 'verify') {
18241
- const rec = _loadRun(root, curId); if (!rec) return fail(`run 없음: ${curId}`);
18242
18340
  const result = (arg('--result', '') || (args.find(a => a && !a.startsWith('-')) || '')).toLowerCase();
18243
18341
  if (result !== 'pass' && result !== 'fail') return fail('--result pass|fail 필요');
18244
- rec.verification_result = result;
18245
- rec.status = result === 'pass' ? 'verified' : 'in-progress';
18246
- const note = arg('--note', null); if (note) rec.decisions = [...(rec.decisions || []), `verify(${result}): ${note}`];
18247
- _saveRun(root, rec);
18342
+ const note = arg('--note', null);
18343
+ const rec = _updateRun(root, curId, (rec) => { // 1.9.303 (UR-0043): 락 직렬화 RMW
18344
+ rec.verification_result = result;
18345
+ rec.status = result === 'pass' ? 'verified' : 'in-progress';
18346
+ if (note) rec.decisions = [...(rec.decisions || []), `verify(${result}): ${note}`];
18347
+ });
18348
+ if (!rec) return fail(`run 없음: ${curId}`);
18248
18349
  if (json) { log(JSON.stringify({ verified: curId, result, run: rec }, null, 2)); return; }
18249
18350
  (result === 'pass' ? ok : warn)(`검증 결과: ${curId} → ${result}`);
18250
18351
  return;
18251
18352
  }
18252
18353
 
18253
18354
  if (sub === 'handoff') {
18254
- const rec = _loadRun(root, curId); if (!rec) return fail(`run 없음: ${curId}`);
18255
18355
  const summary = (args.find(a => a && !a.startsWith('-')) || arg('--summary', '')).trim();
18256
- rec.handoff_summary = summary || rec.handoff_summary || '(요약 없음)';
18257
- rec.ended_at = new Date().toISOString();
18258
- rec.status = 'handed-off';
18259
- _saveRun(root, rec);
18356
+ const rec = _updateRun(root, curId, (rec) => { // 1.9.303 (UR-0043): 락 직렬화 RMW
18357
+ rec.handoff_summary = summary || rec.handoff_summary || '(요약 없음)';
18358
+ rec.ended_at = new Date().toISOString();
18359
+ rec.status = 'handed-off';
18360
+ });
18361
+ if (!rec) return fail(`run 없음: ${curId}`);
18260
18362
  // 다음 에이전트가 읽을 latest handoff (json + md)
18261
18363
  const hdir = path.join(_leernessStateDir(root), 'handoff'); mkdirp(hdir);
18262
18364
  writeUtf8(path.join(hdir, 'latest.json'), JSON.stringify(rec, null, 2) + '\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.301",
3
+ "version": "1.9.303",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -3374,5 +3374,63 @@ total++;
3374
3374
  if (!ok) failed++;
3375
3375
  }
3376
3376
 
3377
+ // 1.9.302 회귀 (UR-0042 외부리뷰): verify-claim git diff 시맨틱 교차검증 (변경 파일 주장 ✓ / 미변경 주장 ⚠ + strict FAIL)
3378
+ total++;
3379
+ {
3380
+ let ok = false;
3381
+ try {
3382
+ const vDir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-vc-'));
3383
+ const git = (...a) => cp.spawnSync('git', ['-C', vDir, ...a], { encoding: 'utf8', timeout: 15000 });
3384
+ const gi = git('init');
3385
+ if (gi.status !== 0) throw new Error('git 없음'); // git 미설치 환경 → skip(아래 catch 로 ok=false 방지 위해 통과 처리)
3386
+ git('config', 'user.email', 't@t'); git('config', 'user.name', 't');
3387
+ cp.spawnSync(process.execPath, [CLI, 'init', vDir, '--yes', '--language', 'ko', '--skills', 'recommended'], { encoding: 'utf8', timeout: 30000 });
3388
+ fs.mkdirSync(path.join(vDir, 'src'), { recursive: true });
3389
+ fs.writeFileSync(path.join(vDir, 'src', 'api.js'), 'v1'); fs.writeFileSync(path.join(vDir, 'old.js'), 'old');
3390
+ git('add', '-A'); git('commit', '-m', 'init');
3391
+ fs.writeFileSync(path.join(vDir, 'src', 'api.js'), 'v2 changed'); // working tree 변경
3392
+ cp.spawnSync(process.execPath, [CLI, 'task', 'add', '작업1', '--path', vDir, '--status', 'done', '--evidence', 'src/api.js 수정'], { encoding: 'utf8', timeout: 15000 });
3393
+ cp.spawnSync(process.execPath, [CLI, 'task', 'add', '작업2', '--path', vDir, '--status', 'done', '--evidence', 'old.js 수정'], { encoding: 'utf8', timeout: 15000 });
3394
+ const r1 = cp.spawnSync(process.execPath, [CLI, 'verify-claim', 'T-0002', '--path', vDir], { encoding: 'utf8', timeout: 20000 });
3395
+ const r2 = cp.spawnSync(process.execPath, [CLI, 'verify-claim', 'T-0003', '--path', vDir, '--strict-claims'], { encoding: 'utf8', timeout: 20000 });
3396
+ const changedClaimOk = /git diff 교차검증: ✓/.test(r1.stdout || ''); // 변경 파일 주장 → 매칭
3397
+ const mismatchDetected = /git diff 교차검증: ⚠ 불일치/.test(r2.stdout || '') && r2.status === 1; // 미변경 주장 + strict → FAIL
3398
+ ok = changedClaimOk && mismatchDetected;
3399
+ fs.rmSync(vDir, { recursive: true, force: true });
3400
+ } catch (e) { if (/git 없음/.test(e.message)) { ok = true; console.log(' (git 미설치 — git 교차검증 e2e skip)'); } }
3401
+ console.log(ok ? '✓ B(1.9.302) verify-claim git diff 교차검증: 변경파일 주장 ✓ / 미변경 주장 ⚠ strict FAIL (UR-0042)' : '✗ verify-claim git 교차검증 실패');
3402
+ if (!ok) failed++;
3403
+ }
3404
+
3405
+ // 1.9.303 회귀 (UR-0043 외부리뷰): 동시 task add lost-update 락 — 6개 병렬 추가 시 모두 보존 + ID 충돌 0
3406
+ total++;
3407
+ {
3408
+ let ok = false;
3409
+ try {
3410
+ const lDir = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-lock-'));
3411
+ cp.spawnSync(process.execPath, [CLI, 'init', lDir, '--yes', '--language', 'ko', '--skills', 'recommended'], { encoding: 'utf8', timeout: 30000 });
3412
+ const N = 6;
3413
+ const procs = [];
3414
+ for (let i = 0; i < N; i++) procs.push(cp.spawn(process.execPath, [CLI, 'task', 'add', 'LOCKTEST-' + i, '--path', lDir], { stdio: 'ignore' }));
3415
+ const ptPath = path.join(lDir, '.harness', 'progress-tracker.md');
3416
+ // 자식들이 OS 프로세스로 독립 진행 → 부모는 파일을 sync 폴링(원자쓰기라 부분읽기 없음)
3417
+ const start = Date.now(); let found = 0;
3418
+ while (Date.now() - start < 25000) {
3419
+ try { const pt = fs.readFileSync(ptPath, 'utf8'); found = Array.from({ length: N }, (_, i) => i).filter(i => pt.includes('LOCKTEST-' + i)).length; if (found === N) break; } catch {}
3420
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
3421
+ }
3422
+ try { procs.forEach(p => { try { p.kill(); } catch {} }); } catch {}
3423
+ const pt = fs.readFileSync(ptPath, 'utf8');
3424
+ const allFound = Array.from({ length: N }, (_, i) => i).every(i => pt.includes('LOCKTEST-' + i));
3425
+ const ids = (pt.match(/^\| (T-\d{4}) \|/gm) || []).map(s => s.match(/T-\d{4}/)[0]);
3426
+ const noDupId = ids.length === new Set(ids).size; // ID 충돌 0
3427
+ const oneSep = pt.split('\n').filter(l => /^\|---\|/.test(l)).length === 1;
3428
+ ok = allFound && noDupId && oneSep;
3429
+ fs.rmSync(lDir, { recursive: true, force: true });
3430
+ } catch {}
3431
+ console.log(ok ? '✓ B(1.9.303) 동시 task add lost-update 락: 6 병렬 모두 보존 + ID 충돌 0 (UR-0043)' : '✗ lost-update 락 실패');
3432
+ if (!ok) failed++;
3433
+ }
3434
+
3377
3435
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
3378
3436
  if (failed > 0) process.exit(1);