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 +38 -0
- package/README.md +5 -5
- package/bin/harness.js +132 -30
- package/package.json +1 -1
- package/scripts/e2e.js +58 -0
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
|
-
[](https://www.npmjs.com/package/leerness) [](https://www.npmjs.com/package/leerness) []() []() []() []() []() []()
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
6113
|
-
|
|
6114
|
-
|
|
6115
|
-
|
|
6116
|
-
|
|
6117
|
-
|
|
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
|
|
6179
|
-
|
|
6180
|
-
|
|
6181
|
-
|
|
6182
|
-
|
|
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
|
-
|
|
6305
|
-
|
|
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 =
|
|
18231
|
-
|
|
18232
|
-
|
|
18233
|
-
|
|
18234
|
-
|
|
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
|
-
|
|
18245
|
-
rec
|
|
18246
|
-
|
|
18247
|
-
|
|
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
|
|
18257
|
-
|
|
18258
|
-
|
|
18259
|
-
|
|
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
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);
|