leerness 1.36.129 → 1.36.131

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.129 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.131 하네스를 사용합니다. 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.129는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.131는 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.129는 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.129 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.131 릴리스 태그 이력** (수백 라운드) · _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.129: 2026-08-18
238
+ Last synced by Leerness v1.36.131: 2026-08-18
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.129';
37
+ const VERSION = '1.36.131';
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') 시 호스트 프로세스 오염.
@@ -11304,6 +11304,13 @@ function _autoFixIdempotency(root) {
11304
11304
  try {
11305
11305
  const pt = path.join(root, '.harness', 'progress-tracker.md');
11306
11306
  if (exists(pt)) {
11307
+ // 1.36.130 (P2-a, 실측 3/3 유실): 이 구획은 **락 없이** 읽고 다시 썼다 — 그 사이에 착지한
11308
+ // 동시 `task add` 의 행이 통째로 사라졌고 양쪽 모두 exit 0 이었다(조용한 유실).
11309
+ // 진입점은 둘: `idempotency audit --auto-fix` 와 `drift check --auto-fix`
11310
+ // (후자는 이 프로젝트 CLAUDE.md 가 **직접 지시하는 경로**다).
11311
+ // 정상 경로(`upsertProgress`)가 이미 하는 형태를 그대로 따른다 — **락 안에서 읽고, 머지하고, 쓴다**.
11312
+ // ⚠ 이 구획은 순수 파일 작업뿐이라 락 보유 중 자식 프로세스를 띄우지 않는다.
11313
+ _withLock(progressPath(root), () => {
11307
11314
  const header = progressHeader(root);
11308
11315
  const rows = readProgressRows(root);
11309
11316
  const seenExact = new Set();
@@ -11330,6 +11337,7 @@ function _autoFixIdempotency(root) {
11330
11337
  writeProgressRows(root, header, kept, { expectedShrink: removedExact }); // 1.36.79 (검수 #1): 제거한 만큼만 축소 허용 — 손상 행은 여전히 차단
11331
11338
  fixed.push({ kind: 'task-duplicate-request', action: 'deduped', removedExact, droppedSameText });
11332
11339
  }
11340
+ });
11333
11341
  }
11334
11342
  } catch (e) { fixed.push({ kind: 'task-duplicate-request', action: 'error', detail: String(e.message || e) }); }
11335
11343
  // 2) user-requests.json open 중복 — 동일 텍스트 중 최초만 open 유지, 나머지 dropped(보존)
@@ -11379,6 +11387,8 @@ function idempotencyCmd(root, sub) {
11379
11387
  const fixes = autoFix ? _autoFixIdempotency(root) : [];
11380
11388
  const audit = _runIdempotencyAudit(root); // auto-fix 후 상태 반영
11381
11389
  if (autoFix) audit.autoFixed = fixes;
11390
+ // 1.36.131 (검수 P1): --json 도 같은 계약 — auto-fix 가 실패했으면 성공 종료코드로 나가지 않는다.
11391
+ if (autoFix && fixes.some(f => f.action === 'error')) { audit.autoFixFailed = fixes.filter(f => f.action === 'error').length; process.exitCode = 1; }
11382
11392
  if (has('--json')) { log(JSON.stringify(audit, null, 2)); return; }
11383
11393
  log(cyan(`# leerness idempotency audit (1.9.212)`));
11384
11394
  log(` audited at: ${audit.auditedAt}`);
@@ -11407,16 +11417,25 @@ function idempotencyCmd(root, sub) {
11407
11417
  }
11408
11418
  if (autoFix) {
11409
11419
  log('');
11410
- if (fixes.length) {
11411
- log(grn(`## 🔧 auto-fix 적용 (${fixes.length})`));
11412
- fixes.forEach(f => {
11420
+ // 1.36.131 (검수 P1): 종전엔 실패 항목(`action:'error'`)도 "auto-fix 적용 (N)" 에 섞여 세어졌고 exit 0 이었다 —
11421
+ // 중복이 그대로 남았는데 자동화는 고쳐졌다고 판단했다(거짓 성공). 적용과 실패를 갈라 세고, 실패는 exit 1 로 만든다.
11422
+ const _applied = fixes.filter(f => f.action !== 'error');
11423
+ const _failedFix = fixes.filter(f => f.action === 'error');
11424
+ if (_applied.length) {
11425
+ log(grn(`## 🔧 auto-fix 적용 (${_applied.length})`));
11426
+ _applied.forEach(f => {
11413
11427
  if (f.kind === 'task-duplicate-request') log(` - [tasks] 완전중복 ${f.removedExact || 0}행 제거 · 동일텍스트 ${f.droppedSameText || 0}건 dropped 처리`);
11414
11428
  else if (f.kind === 'user-request-duplicate') log(` - [user-requests] open 중복 ${f.count || 0}건 dropped 처리`);
11415
11429
  else log(dim(` - [${f.kind}] ${f.action}${f.detail ? ' — ' + f.detail : ''}`));
11416
11430
  });
11417
- } else {
11431
+ } else if (!_failedFix.length) {
11418
11432
  log(dim(' 🔧 auto-fix: 적용할 중복 없음 (이미 정합)'));
11419
11433
  }
11434
+ if (_failedFix.length) {
11435
+ log(red(`## ❌ auto-fix 실패 (${_failedFix.length}) — 고쳐지지 않았다. 수동 확인 필요`));
11436
+ _failedFix.forEach(f => log(` - [${f.kind}] ${f.detail || '원인 미상'}`));
11437
+ process.exitCode = 1;
11438
+ }
11420
11439
  }
11421
11440
  return;
11422
11441
  }
@@ -20061,7 +20080,78 @@ function writeRules(root, rules) {
20061
20080
  const tail = damaged.length ? '\n<!-- ⚠ 아래 행은 손상돼 파싱 불가 — 삭제하지 않고 보존함. 수동 복구 후 위 표로 병합하세요 -->\n' + damaged.join('\n') + '\n' : '';
20062
20081
  // 1.36.63: 저장 시 헤더 언어 = 프로젝트 언어 (en rules.md 가 rule add 로 ko 회귀하지 않게)
20063
20082
  let _rlLang = 'ko'; try { _rlLang = read(path.join(root, '.harness', 'LANGUAGE')).trim().toLowerCase() === 'en' ? 'en' : 'ko'; } catch {}
20064
- writeUtf8(rulesPath(root), _rulesHeader(_rlLang) + '\n' + body + (body ? '\n' : '') + tail);
20083
+ // 1.36.130 (P2-c, 실측 재현): 함수는 헤더+행+손상tail **전체를 재구성**해 왔다 표가 아닌
20084
+ // 사용자 텍스트(머리말·꼬리 절·메모)는 존재하지 않는 것과 같았고, `session close` 마다 조용히 사라졌다.
20085
+ // 실측: rules.md 에 머리말/꼬리말을 넣고 `session close` → 931B → 807B, 두 문구 모두 grep 0건.
20086
+ // 이 파일의 목적은 **사용자가 명시한 영구 룰**을 담는 것이다 — 그 파일에서 사용자 글을 지우는 것은
20087
+ // 기능이 아니라 데이터 유실이다. 우리가 소유한 구역(헤더 + 표 + 손상tail) 밖은 그대로 되돌려 놓는다.
20088
+ const _managed = _rulesHeader(_rlLang) + '\n' + body + (body ? '\n' : '') + tail;
20089
+ writeUtf8(rulesPath(root), _rulesPreserveOuter(root, _managed, rules));
20090
+ }
20091
+ // 1.36.131 (검수 P1 ×3 · P2 · P3): 첫 판은 **줄을 하나씩 분류**해 "우리 것 같으면 버린다" 였다.
20092
+ // 그 형태는 분류가 틀릴 때마다 사용자 데이터가 사라지고, 실제로 네 갈래로 틀렸다(전부 재현함):
20093
+ // · `/^\|/` 가 **사용자 마크다운 표**까지 지웠다(`| 김 | 백엔드 |` → 0행).
20094
+ // · 파일 어디에 있든 첫 `---` 를 frontmatter 시작으로 봐서 **그 뒤 전부**를 삼켰다(닫는 `---` 가 없으면 EOF 까지).
20095
+ // · 앞에 있던 사용자 글이 **표 뒤로 재배치**됐다(주석은 "앞은 앞에" 라고 적혀 있었다 — 안내와 구현 불일치).
20096
+ // · 언어를 바꾸면 이전 언어 헤더가 "사용자 글" 로 오인돼 남았다.
20097
+ // → 분류를 없앤다. 관리 구역을 **위치로** 잡고(마커 → 표 → 손상tail), 그 **바깥은 손대지 않고 그대로** 되돌린다.
20098
+ // 판정할 줄이 없으면 틀릴 줄도 없다.
20099
+ function _rulesManagedSpan(lines) {
20100
+ const mark = lines.findIndex(l => /^<!--\s*leerness:managed\s*-->/.test(l.trim()));
20101
+ if (mark < 0) return null;
20102
+ // 시작 — 마커 **바로 앞**에 붙어 있는 frontmatter 블록까지가 우리 구역이다.
20103
+ // (파일 어딘가의 `---` 를 찾지 않는다. 마커에 인접한 것만 본다.)
20104
+ let s = mark;
20105
+ {
20106
+ let i = mark - 1;
20107
+ while (i >= 0 && !lines[i].trim()) i--;
20108
+ if (i >= 0 && lines[i].replace(/^/, '').trim() === '---') {
20109
+ let j = i - 1;
20110
+ while (j >= 0 && lines[j].replace(/^/, '').trim() !== '---') j--;
20111
+ if (j >= 0) s = j;
20112
+ }
20113
+ }
20114
+ // 끝 — 마커 뒤 첫 표 구분선에서 시작하는 **연속된 표 행**, 그리고 붙어 있는 손상행 보존 블록까지.
20115
+ // 연속이 끊기는 순간 우리 구역도 끝난다 → 그 아래 사용자 표는 구역 밖이라 그대로 산다.
20116
+ const sep = lines.findIndex((l, i) => i > mark && /^\|\s*:?-{3,}/.test(l.trim()));
20117
+ if (sep < 0) return null;
20118
+ let e = sep;
20119
+ while (e + 1 < lines.length && /^\|/.test(lines[e + 1].trim())) e++;
20120
+ let k = e + 1;
20121
+ while (k < lines.length && !lines[k].trim()) k++;
20122
+ if (k < lines.length && /^<!--\s*⚠/.test(lines[k].trim())) {
20123
+ e = k;
20124
+ while (e + 1 < lines.length && /^\| R-\d/.test(lines[e + 1].trim())) e++;
20125
+ }
20126
+ return { s, e };
20127
+ }
20128
+ function _rulesPreserveOuter(root, managed, rules) {
20129
+ let prev = '';
20130
+ try { prev = exists(rulesPath(root)) ? read(rulesPath(root)) : ''; } catch { prev = ''; }
20131
+ if (!prev.trim()) return managed;
20132
+ const lines = prev.replace(/\r\n/g, '\n').split('\n');
20133
+ const span = _rulesManagedSpan(lines);
20134
+ let head, tail;
20135
+ if (span) {
20136
+ head = lines.slice(0, span.s).join('\n');
20137
+ tail = lines.slice(span.e + 1).join('\n');
20138
+ } else {
20139
+ // 마커/표를 못 찾았다 = 우리가 쓴 파일이 아니거나 사람이 구조를 바꿨다.
20140
+ // 이때 **지우는 쪽으로 실패하지 않는다** — 전체를 머리말로 살리고 관리 블록을 아래에 놓는다.
20141
+ // 다만 지금 다시 찍을 룰 행과 표 골격만 빼서 같은 룰이 두 번 파싱되는 것을 막는다.
20142
+ const ids = new Set((rules || []).map(r => String(r.id)));
20143
+ head = lines.filter(l => {
20144
+ const t = l.trim();
20145
+ const m = t.match(/^\|\s*(R-\d{4,})\s*\|/);
20146
+ if (m && ids.has(m[1])) return false;
20147
+ if (/^\|\s*:?-{3,}/.test(t)) return false;
20148
+ if (/^\|\s*(ID|아이디)\s*\|/.test(t)) return false;
20149
+ return true;
20150
+ }).join('\n');
20151
+ tail = '';
20152
+ }
20153
+ const H = head.replace(/\n+$/, ''), T = tail.replace(/^\n+/, '').replace(/\n+$/, '');
20154
+ return (H ? H + '\n\n' : '') + managed.replace(/\n*$/, '\n') + (T ? '\n' + T + '\n' : '');
20065
20155
  }
20066
20156
 
20067
20157
  function nextRuleId(root) {
@@ -20094,7 +20184,9 @@ function ruleAdd(root, description) {
20094
20184
  root = absRoot(root);
20095
20185
  if (!_requireInit(root, 'rule add')) return; // 1.9.311 (UR-0047): init 가드
20096
20186
  if (!description) return fail('rule description required (e.g., rule add "매 업데이트마다 버전 bump" --trigger every-update)');
20097
- if (!exists(rulesPath(root))) writeRules(root, []);
20187
+ // 1.36.131 (검수 P1 동류): 부트스트랩도 락 안에서 한다 — exists 확인과 쓰기 사이에 동시 `rule add` 가
20188
+ // 착지하면 빈 표로 덮어써 그 룰이 사라졌다(같은 락이라 아래 블록과 재진입 안전).
20189
+ _withLock(rulesPath(root), () => { if (!exists(rulesPath(root))) writeRules(root, []); });
20098
20190
  const trigger = arg('--trigger', 'every-session');
20099
20191
  // 1.9.310 (UR-0046): 무효 trigger 거부 (이전엔 warn 후 등록 → 오타 룰 등록됨). --force 우회.
20100
20192
  if (!_validateChoice(trigger, RULE_TRIGGERS, 'rule trigger')) { process.exitCode = 1; return; }
@@ -20281,11 +20373,11 @@ function verifyRules(root) {
20281
20373
  results.push({ ...r, verified, note, clauses: clauses.length > 1 ? clauses.map(c => ({ kind: c.kind, verified: c.verified })) : undefined });
20282
20374
  }
20283
20375
  // lastVerified 갱신 (pass인 경우만)
20284
- for (const r of rules) {
20285
- const m = results.find(x => x.id === r.id);
20286
- if (m && m.verified === 'pass') r.lastVerified = todayStr;
20287
- }
20288
- writeRules(root, rules);
20376
+ // 1.36.131 (검수 P1): 종전엔 위에서 읽은 배열을 그대로 `writeRules` 로 되썼다 — **락 밖 RMW**라
20377
+ // 그 사이 착지한 동시 `rule add` 의 룰이 통째로 사라지고 양쪽 다 성공했다(`session close` 경로를 탄다).
20378
+ // 1.36.108 만들어 초크포인트로 태운다: 락 안에서 **다시 읽고** id 로 lastVerified 병합한다.
20379
+ const _passIds = new Set(results.filter(x => x.verified === 'pass').map(x => x.id));
20380
+ _updateRules(root, (cur2) => { for (const r of cur2) if (_passIds.has(r.id)) r.lastVerified = todayStr; });
20289
20381
  writeUtf8(rulesCachePath(root), JSON.stringify(cur, null, 2));
20290
20382
  return results;
20291
20383
  }
package/lib/drift.js CHANGED
@@ -243,6 +243,14 @@ function driftCheckCmd(root, opts = {}, deps = {}) {
243
243
  try {
244
244
  const idemFixes = _autoFixIdempotency(root);
245
245
  const totalFixed = idemFixes.reduce((n, f) => n + (f.removedExact || 0) + (f.droppedSameText || 0) + (f.count || 0), 0);
246
+ // 1.36.131 (검수 P1): 실패 항목은 수치 합이 0 이라 종전엔 **조용히 지나갔다** — 중복이 남았는데 아무 말도 안 했다.
247
+ // 수치와 무관하게 실패는 말한다(이 명령은 회복 경로라 exit 를 바꾸지 않는다 — 대신 반드시 보인다).
248
+ const idemFailed = idemFixes.filter(f => f.action === 'error');
249
+ if (idemFailed.length) {
250
+ afLog('');
251
+ afLog(t(`❌ idempotency auto-fix 실패 ${idemFailed.length}건 — 중복이 남아 있다: ${idemFailed.map(f => f.detail || f.kind).join(' · ')}`,
252
+ `❌ idempotency auto-fix failed ${idemFailed.length} — duplicates remain: ${idemFailed.map(f => f.detail || f.kind).join(' · ')}`));
253
+ }
246
254
  if (totalFixed > 0) {
247
255
  afLog('');
248
256
  afLog(t(`🔁 --auto-fix 활성 (1.9.293) — idempotency 중복 ${totalFixed}건 자동 정리 (task/user-request dedup)`, `🔁 --auto-fix on — deduped ${totalFixed} idempotency duplicate(s) (task/user-request)`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.129",
3
+ "version": "1.36.131",
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
@@ -13004,6 +13004,168 @@ total++;
13004
13004
  if (!ok) failed++;
13005
13005
  }
13006
13006
 
13007
+ // 1.36.131 (UR-0068 / P-0016 P2, 검수 P1 ×4 반영): 다중 세션 쓰기 보호 — **측정된 유실**만 막는다.
13008
+ // ⚠ 원래 계획(해시 기준선 compare-and-set)은 정찰 실측으로 폐기했다: 이미 안전한 경로에서 100% 발화하고
13009
+ // 실제로 파괴하는 경로에서는 침묵했다. 신호가 위험과 무관하면 차단은 게이트 없느니만 못하다.
13010
+ // ⚠ 1.36.130 의 첫 판은 두 군데가 공허했다 — 둘 다 검수가 잡았고 여기서 고쳤다:
13011
+ // · 락 대기를 setInterval 로 관측했는데 메인스레드가 막혀 콜백이 아예 안 돌았다(관측값이 상수 false).
13012
+ // → 이제 **별도 프로세스**가 해제 시각을 파일로 남기고, 대상 파일 **mtime 과 비교**한다.
13013
+ // "잠깐 기다렸다 무락으로 쓰기" 도 mtime < 해제시각 이라 걸린다.
13014
+ // · 사용자 글 보존을 `includes` 로만 봐서 **표 뒤로 재배치**해도 통과했다 → 이제 **위치**를 단언한다.
13015
+ total++;
13016
+ {
13017
+ let ok = false; const bad = []; const dbg = {};
13018
+ const sb = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-p2-131-'));
13019
+ const envP = Object.assign({}, process.env, { TMPDIR: sb, TEMP: sb, TMP: sb, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' });
13020
+ for (const k of ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION',
13021
+ 'LEERNESS_INTERNAL', 'LEERNESS_HOOK', 'CI', 'GITHUB_ACTIONS', 'LEERNESS_WORKSPACE_DIR', 'LEERNESS_LANG']) delete envP[k];
13022
+ const RP = (d, a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: d, encoding: 'utf8', timeout: 600000, env: envP, maxBuffer: 32 * 1024 * 1024 });
13023
+ const mkP = (name, full) => {
13024
+ const d = path.join(sb, name); fs.mkdirSync(d, { recursive: true });
13025
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.1.0"}');
13026
+ const r = RP(d, ['init', d, '--yes'].concat(full ? [] : ['--minimal']));
13027
+ if (r.status !== 0) bad.push(`${name}:init실패(${r.status})`);
13028
+ return d;
13029
+ };
13030
+ // 락 준수 프로브 — 이벤트 루프에 의존하지 않는다. 외부에서 락을 잡고, **별도 프로세스**가 2.5초 뒤
13031
+ // 놓으면서 해제 시각을 남긴다. 제품은 spawnSync 로 돌린 뒤 대상 파일 mtime 과 해제 시각을 비교한다.
13032
+ const lockProbe = (tag, d, target, argv) => {
13033
+ const lock = target + '.lock', relFile = path.join(d, `released-${tag}.json`);
13034
+ fs.writeFileSync(lock, String(process.pid));
13035
+ const rs = path.join(sb, `rel-${tag}.js`);
13036
+ fs.writeFileSync(rs, 'const fs=require("fs");setTimeout(()=>{try{fs.unlinkSync(' + JSON.stringify(lock) + ')}catch{};fs.writeFileSync(' + JSON.stringify(relFile) + ',String(Date.now()))},2500);');
13037
+ const rel = cp.spawn(process.execPath, [rs], { detached: true, stdio: 'ignore' }); rel.unref();
13038
+ const t0 = Date.now();
13039
+ const r = RP(d, argv);
13040
+ const elapsed = Date.now() - t0;
13041
+ let tr = null; try { tr = Number(fs.readFileSync(relFile, 'utf8')); } catch {}
13042
+ let tw = null; try { tw = fs.statSync(target).mtimeMs; } catch {}
13043
+ try { fs.unlinkSync(lock); } catch {}
13044
+ // 해제 파일이 없다 = 우리가 놓기도 전에 제품이 끝났다 = 락을 무시했다(무락 절제가 정확히 여기서 걸린다).
13045
+ if (!tr) bad.push(`${tag}:해제 전에 끝남(${elapsed}ms) — 락 무시`);
13046
+ else if (!(tw >= tr - 150)) bad.push(`${tag}:락 보유 중에 씀(해제 대비 ${Math.round(tw - tr)}ms)`);
13047
+ else if (elapsed < 2000) bad.push(`${tag}:기다리지 않음(${elapsed}ms)`);
13048
+ return { exit: r.status, elapsed, lagMs: (tr && tw) ? Math.round(tw - tr) : null };
13049
+ };
13050
+ try {
13051
+ // ① rules.md — 사람이 쓴 텍스트가 **원래 자리 그대로** 살아남는다. 사용자 표도 산다. 표는 계속 파싱된다.
13052
+ {
13053
+ const d = mkP('rules', true);
13054
+ const ra = RP(d, ['rule', 'add', '매 세션 점검', '--trigger', 'every-session', '--path', d, '--json']);
13055
+ if (ra.status !== 0) bad.push(`①rule add exit=${ra.status}`);
13056
+ const rp = path.join(d, '.harness', 'rules.md');
13057
+ const HEAD = 'HEADNOTE' + process.pid.toString(36), TAIL = 'TAILNOTE' + process.pid.toString(36);
13058
+ fs.writeFileSync(rp, `# ${HEAD}\n\n` + fs.readFileSync(rp, 'utf8')
13059
+ + `\n\n## ${TAIL}\n자유 서술 문단.\n\n| 이름 | 담당 |\n|---|---|\n| 김 | 백엔드 |\n`);
13060
+ const rc = RP(d, ['session', 'close', d]);
13061
+ if (rc.status !== 0) bad.push(`①session close exit=${rc.status}`);
13062
+ const L = fs.readFileSync(rp, 'utf8').split('\n');
13063
+ const at = (s) => L.findIndex(l => l.includes(s));
13064
+ const iH = at(HEAD), iT = at(TAIL), iTable = L.findIndex(l => /^\| R-\d/.test(l.trim())), iUser = at('| 김 | 백엔드 |');
13065
+ if (iH < 0) bad.push('①머리말 유실');
13066
+ if (iT < 0) bad.push('①꼬리말 유실');
13067
+ // 위치 단언 — `includes` 만 보면 "표 뒤로 재배치" 를 통과시킨다(1.36.130 의 실제 결함).
13068
+ if (!(iH >= 0 && iTable >= 0 && iH < iTable)) bad.push(`①머리말이 앞자리를 잃음(head=${iH} table=${iTable})`);
13069
+ if (!(iT >= 0 && iTable >= 0 && iT > iTable)) bad.push(`①꼬리말이 뒷자리를 잃음(tail=${iT} table=${iTable})`);
13070
+ if (iUser < 0) bad.push('①사용자 마크다운 표가 지워짐');
13071
+ let rl = null; try { rl = JSON.parse(String(RP(d, ['rule', 'list', '--path', d, '--json']).stdout || '')); } catch { bad.push('①rule list JSON 아님'); }
13072
+ const nRules = rl ? (Array.isArray(rl) ? rl.length : (rl.rules || []).length) : -1;
13073
+ if (nRules !== 1) bad.push(`①룰 행 ${nRules}건(기대 1) — 보존이 표를 깼다`);
13074
+ for (let i = 0; i < 3; i++) RP(d, ['session', 'close', d]);
13075
+ const fin = fs.readFileSync(rp, 'utf8');
13076
+ const cH = (fin.match(new RegExp(HEAD, 'g')) || []).length, cT = (fin.match(new RegExp(TAIL, 'g')) || []).length;
13077
+ if (cH !== 1 || cT !== 1) bad.push(`①멱등 아님(머리 ${cH} 꼬리 ${cT})`);
13078
+ dbg.rules = { head: iH, table: iTable, tail: iT, userTable: iUser, rules: nRules, idem: cH === 1 && cT === 1 };
13079
+ }
13080
+
13081
+ // ①b 선두 frontmatter 가 없고 본문 중간에 `---` 가 있는 파일 — 첫 판은 그 뒤 전부를 삼켰다.
13082
+ {
13083
+ const d = mkP('rules-nofm', true);
13084
+ const rp = path.join(d, '.harness', 'rules.md');
13085
+ fs.writeFileSync(rp, '# 메모\n앞 문단 HEADSENT\n---\n뒤 문단 TAILSENT\n');
13086
+ const ra = RP(d, ['rule', 'add', '첫 룰', '--trigger', 'every-session', '--path', d, '--json']);
13087
+ if (ra.status !== 0) bad.push(`①b rule add exit=${ra.status}(셋업 실패 — 아래 단언이 공허해진다)`);
13088
+ const t = fs.readFileSync(rp, 'utf8');
13089
+ if (!t.includes('HEADSENT')) bad.push('①b 앞 문단 유실');
13090
+ if (!t.includes('TAILSENT')) bad.push('①b 중간 `---` 뒤 문단 유실(frontmatter 오인)');
13091
+ let rl = null; try { rl = JSON.parse(String(RP(d, ['rule', 'list', '--path', d, '--json']).stdout || '')); } catch {}
13092
+ const n2 = rl ? (Array.isArray(rl) ? rl.length : (rl.rules || []).length) : -1;
13093
+ if (n2 !== 1) bad.push(`①b 룰 ${n2}건(기대 1)`);
13094
+ dbg.noFm = { head: t.includes('HEADSENT'), tail: t.includes('TAILSENT'), rules: n2 };
13095
+ }
13096
+
13097
+ // ② `--auto-fix` 의 progress RMW 가 **락을 존중**한다 + 그러면서 dedup 기능은 살아 있다.
13098
+ // 두 축을 다 재야 한다: 락만 재면 "아무것도 안 하는 구현"이 통과하고, 기능만 재면 무락이 통과한다.
13099
+ {
13100
+ const d = mkP('autofix', false);
13101
+ const pt = path.join(d, '.harness', 'progress-tracker.md');
13102
+ const RID = 'T-7' + String(process.pid % 1000).padStart(3, '0');
13103
+ const row = `| ${RID} | requested | 중복행 | - | - | 2026-08-18 |`;
13104
+ fs.writeFileSync(pt, fs.readFileSync(pt, 'utf8').replace(/\n?$/, '\n') + row + '\n' + row + '\n');
13105
+ if ((fs.readFileSync(pt, 'utf8').match(new RegExp(RID, 'g')) || []).length !== 2) bad.push('②픽스처에 중복행이 없음(계측 붕괴)');
13106
+ const p = lockProbe('②autofix', d, pt, ['idempotency', 'audit', '--auto-fix', '--path', d]);
13107
+ const nAfter = (fs.readFileSync(pt, 'utf8').match(new RegExp(RID, 'g')) || []).length;
13108
+ if (nAfter !== 1) bad.push(`②dedup 안 됨(중복행 ${nAfter}, 기대 1) — 락이 기능을 죽였다`);
13109
+ dbg.autofix = Object.assign(p, { dupAfter: nAfter });
13110
+ }
13111
+
13112
+ // ③ `session close` 의 rules 쓰기(verifyRules)도 **락 안**이다. 종전엔 무락 RMW 라 동시 `rule add` 를 삼켰다.
13113
+ // 쓰기가 실제로 일어나는 상태를 만들어야 한다 — writeUtf8 은 동일 내용이면 쓰지 않는다(1.36.65).
13114
+ {
13115
+ const d = mkP('verifyrules', true);
13116
+ const rp = path.join(d, '.harness', 'rules.md');
13117
+ const ra = RP(d, ['rule', 'add', '매 커밋마다 버전 bump', '--trigger', 'every-commit', '--path', d, '--json']);
13118
+ if (ra.status !== 0) bad.push(`③rule add exit=${ra.status}`);
13119
+ const rv = RP(d, ['rule', 'verify', '--path', d]);
13120
+ if (rv.status !== 0) bad.push(`③rule verify exit=${rv.status}`);
13121
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"p","version":"0.2.0"}'); // → version 절 pass → lastVerified 갱신 → 실제 쓰기
13122
+ const p = lockProbe('③rules', d, rp, ['session', 'close', d]);
13123
+ if (!/\| R-\d/.test(fs.readFileSync(rp, 'utf8'))) bad.push('③룰 표가 사라짐');
13124
+ dbg.verifyRules = p;
13125
+ }
13126
+
13127
+ // ④ auto-fix 가 **실패했는데 성공으로 보고**하지 않는다(고칠 수 없는 손상행이 섞인 경우).
13128
+ {
13129
+ const d = mkP('falsesuccess', false);
13130
+ const pt = path.join(d, '.harness', 'progress-tracker.md');
13131
+ const RID = 'T-8' + String(process.pid % 1000).padStart(3, '0');
13132
+ const row = `| ${RID} | requested | 중복행 | - | - | 2026-08-18 |`;
13133
+ fs.writeFileSync(pt, fs.readFileSync(pt, 'utf8').replace(/\n?$/, '\n') + row + '\n' + row + '\n| T-9999 | requested | 셀부족 |\n');
13134
+ const r = RP(d, ['idempotency', 'audit', '--auto-fix', '--path', d]);
13135
+ const outTxt = String(r.stdout || '') + String(r.stderr || '');
13136
+ const stillDup = (fs.readFileSync(pt, 'utf8').match(new RegExp(RID, 'g')) || []).length;
13137
+ if (stillDup < 2) bad.push(`④전제 붕괴: 중복이 실제로 고쳐졌다(${stillDup}) — 이 케이스는 실패 경로가 아니다`);
13138
+ else {
13139
+ if (r.status === 0) bad.push('④고치지 못했는데 exit 0 (거짓 성공)');
13140
+ if (/auto-fix 적용/.test(outTxt)) bad.push('④고치지 못했는데 "auto-fix 적용" 이라고 보고');
13141
+ if (!/auto-fix 실패/.test(outTxt)) bad.push('④실패를 말하지 않음(조용한 무동작)');
13142
+ }
13143
+ const dr = RP(d, ['drift', 'check', '--auto-fix', '--path', d]);
13144
+ if (!/auto-fix 실패/.test(String(dr.stdout || '') + String(dr.stderr || ''))) bad.push('④drift --auto-fix 가 실패를 감춤');
13145
+ dbg.falseSuccess = { exit: r.status, stillDup, driftLoud: /auto-fix 실패/.test(String(dr.stdout || '')) };
13146
+ }
13147
+
13148
+ // ⑤ 대조군 — 평범한 단일 에이전트 흐름에서 아무것도 느려지거나 실패하지 않는다(오탐/회귀 0)
13149
+ {
13150
+ const d = mkP('plain', false);
13151
+ const t0 = Date.now();
13152
+ for (const a of [['task', 'add', '보통 작업', '--path', d, '--json'], ['handoff', d], ['session', 'close', d]]) {
13153
+ const r = RP(d, a);
13154
+ if (r.status !== 0) bad.push(`⑤평범한 흐름 실패: ${a[0]} exit=${r.status}`);
13155
+ }
13156
+ let tl = null; try { tl = JSON.parse(String(RP(d, ['task', 'list', '--path', d, '--json']).stdout || '')); } catch { bad.push('⑤task list JSON 아님'); }
13157
+ const n = tl ? (Array.isArray(tl) ? tl.length : (tl.tasks || tl.rows || []).length) : -1;
13158
+ if (!(n >= 1)) bad.push(`⑤작업이 사라짐(${n})`);
13159
+ dbg.plain = { ms: Date.now() - t0, tasks: n };
13160
+ }
13161
+ ok = bad.length === 0;
13162
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 200); }
13163
+ finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
13164
+ console.log(ok ? `✓ Z(1.36.131/P-0016 P2) 쓰기 유실 차단: rules.md 사람 글이 **자리까지** 보존(사용자 표·중간 --- 포함, 멱등) · progress/rules RMW 가 락 해제 뒤에만 씀(mtime 대조) · auto-fix 실패를 성공으로 보고하지 않음 · 평범한 단일 흐름 무회귀 ${JSON.stringify(dbg)}`
13165
+ : '✗ 1.36.131 쓰기 유실 차단 실패 ' + JSON.stringify({ bad: bad.slice(0, 10), dbg }));
13166
+ if (!ok) failed++;
13167
+ }
13168
+
13007
13169
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
13008
13170
  if (failed > 0) process.exit(1);
13009
13171