leerness 1.36.171 → 1.36.173

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.173 — 2026-08-27
4
+
5
+ - Memory DELETE 5종(task/decision/lesson/rule/plan)의 성공 --json 경로를 단일 JSON 문서·clean stderr 계약으로 통일했습니다. onEveryChange 자동 로드맵은 JSON 출력에 사람용 로그를 섞지 않으면서 다섯 명령 각각 갱신되며, 명령별 산출물 재생성과 외부 Codex P2 재현을 회귀로 고정했습니다.
6
+
7
+ ## 1.36.172 — 2026-08-27
8
+
9
+ - GitHub CI에서 재현된 동시 최초 .harness→.leerness 마이그레이션 TOCTOU를 수정했습니다. 라이브 마이그레이션이 두 번째 상태 검사와 충돌 스캔 전에 프로젝트 잠금을 획득하며, 실제 peer EEXIST와 잠금 해제 직전까지의 대기를 검증하는 결정론적 회귀를 fast/core/full 게이트에 연결했습니다.
10
+
3
11
  ## 1.36.171 — 2026-08-27
4
12
 
5
13
  - MCP handoff/session-close가 호출별 sessionKey로 presence를 등록·종료하고, Windows 환경변수 casing·일회성 marker·legacy case variant·손상 closedAt를 안전하게 처리하도록 개선했습니다. 전용 MCP presence 회귀 22/22와 전체 E2E 467/467를 추가·통과했습니다.
package/README.md CHANGED
@@ -138,7 +138,7 @@ MIT
138
138
  <!-- leerness:project-readme:start -->
139
139
  ## Leerness Project Harness
140
140
 
141
- 이 프로젝트는 Leerness v1.36.171 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
141
+ 이 프로젝트는 Leerness v1.36.173 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
142
142
 
143
143
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
144
144
 
@@ -192,7 +192,7 @@ leerness memory restore decision <date|title>
192
192
 
193
193
  ### MCP server (외부 AI 통합)
194
194
 
195
- Leerness v1.36.171는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
195
+ Leerness v1.36.173는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
196
196
 
197
197
  ```jsonc
198
198
  // 카테고리별
@@ -213,7 +213,7 @@ Leerness v1.36.171는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
213
213
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
214
214
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
215
215
 
216
- 현재 누적: **v1.9.x → 1.36.171 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
216
+ 현재 누적: **v1.9.x → 1.36.173 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
217
217
 
218
218
  ### 성능 가이드
219
219
 
@@ -251,5 +251,5 @@ leerness release pack --close --auto-main-push
251
251
  - `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
252
252
  - `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
253
253
 
254
- Last synced by Leerness v1.36.171: 2026-08-27
254
+ Last synced by Leerness v1.36.173: 2026-08-27
255
255
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -48,7 +48,7 @@ const {
48
48
  migrateLegacyWorkspace,
49
49
  } = require('../lib/workspace-dir');
50
50
 
51
- const VERSION = '1.36.171';
51
+ const VERSION = '1.36.173';
52
52
 
53
53
  // MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
54
54
  // 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
@@ -10619,7 +10619,37 @@ function _p0103JsonOk() {
10619
10619
  // ④ --json 이 아예 구현되지 않아 사람용 텍스트를 내던 표면(게시본 3/30) — 계약 스윕
10620
10620
  const sweepBad = [['consistency', 'check', '--json'], ['agent-mode', 'tick', '--json'], ['graph', '--json']]
10621
10621
  .filter(a => !parse(R(a)));
10622
- return failuresOk && successOk && keepOk && sweepBad.length === 0;
10622
+ // ⑤ Memory DELETE 성공 경로도 같은 계약을 지킨다. 종전에는 mutation은 끝냈지만
10623
+ // stdout은 비고 사람용 ✓ 문구만 stderr에 남아 JSON 소비자가 성공할 때만 깨졌다(T-0093).
10624
+ const taskAdded = parse(R(['task', 'add', 'json-delete-task', '--json']));
10625
+ R(['decision', 'add', 'json-delete-decision']);
10626
+ R(['lesson', 'save', 'json-delete-lesson']);
10627
+ const ruleAdded = parse(R(['rule', 'add', 'json-delete-rule', '--trigger', 'every-session', '--json']));
10628
+ R(['plan', 'add', 'json-delete-plan']);
10629
+ if (!taskAdded?.id || !ruleAdded?.id) return false;
10630
+ fs.mkdirSync(path.join(proj, '.leerness', 'cache'), { recursive: true });
10631
+ fs.writeFileSync(path.join(proj, '.leerness', 'cache', 'auto-roadmap.json'), JSON.stringify({ enabled: true, onEveryChange: true }));
10632
+ const liveEnv = Object.assign({}, process.env, { LEERNESS_OFFLINE: '1' });
10633
+ delete liveEnv.LEERNESS_NO_AUTO_ROADMAP;
10634
+ const RLive = (a) => cp2.spawnSync(process.execPath, [__filename, ...a], { cwd: proj, encoding: 'utf8', timeout: 180000, env: liveEnv });
10635
+ const deleteArgs = [
10636
+ ['task', 'drop', taskAdded.id, '--json'],
10637
+ ['decision', 'drop', 'json-delete-decision', '--json'],
10638
+ ['lesson', 'drop', 'json-delete-lesson', '--json'],
10639
+ ['rule', 'remove', ruleAdded.id, '--json'],
10640
+ ['plan', 'remove', 'json-delete-plan', '--json'],
10641
+ ];
10642
+ const graphPath = path.join(proj, 'leerness.html');
10643
+ const deleteResults = [];
10644
+ let roadmapOk = true;
10645
+ for (const args of deleteArgs) {
10646
+ fs.rmSync(graphPath, { force: true });
10647
+ deleteResults.push(RLive(args));
10648
+ roadmapOk = roadmapOk && fs.existsSync(graphPath);
10649
+ }
10650
+ const deleteOk = deleteResults.every(r => r.status === 0 && !(r.stderr || '').trim() && parse(r)?.ok === true)
10651
+ && roadmapOk;
10652
+ return failuresOk && successOk && keepOk && sweepBad.length === 0 && deleteOk;
10623
10653
  } catch { return false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
10624
10654
  }
10625
10655
  // 1.36.103: 값-플래그 등록 완전성 + 오타 가시성 회귀 가드.
@@ -14688,9 +14718,9 @@ function planProgress(root, opts = {}) {
14688
14718
  // Memory Surface DELETE 5종 완전 완성 (task drop / decision drop / lesson drop / rule remove / plan remove)
14689
14719
  function planRemoveCmd(root, target) {
14690
14720
  root = absRoot(root);
14691
- if (!target) return fail('plan remove <M-XXXX|title-substring> 필요 — 매칭되는 milestone 블록을 제거하고 .leerness/plan.archive.md에 보존');
14721
+ if (!target) return failJson(has('--json'), 'missing_target', 'plan remove <M-XXXX|title-substring> 필요 — 매칭되는 milestone 블록을 제거하고 .leerness/plan.archive.md에 보존');
14692
14722
  const pp = planPath(root);
14693
- if (!exists(pp)) return fail('plan.md 없음');
14723
+ if (!exists(pp)) return failJson(has('--json'), 'plan_not_found', 'plan.md 없음');
14694
14724
  let removed = 0;
14695
14725
  let disappeared = false;
14696
14726
  // 1.36.152 (T-0115): plan restore/add와 archive를 공유한다. 락 안에서 다시 읽어야 새 milestone을 덮지 않는다.
@@ -14730,8 +14760,13 @@ function planRemoveCmd(root, target) {
14730
14760
  if (removed === 0) return;
14731
14761
  writeUtf8(pp, kept.join('\n'));
14732
14762
  });
14733
- if (disappeared) return fail('plan.md 없음');
14763
+ if (disappeared) return failJson(has('--json'), 'plan_not_found', 'plan.md 없음');
14734
14764
  if (removed === 0) return failJson(has('--json'), 'milestone_not_found', `매칭 milestone 없음: "${target}"`); // codex P2: --json 에러 구조화
14765
+ if (has('--json')) {
14766
+ _autoRoadmap(absRoot(root), 'data-change', { quiet: true });
14767
+ log(JSON.stringify({ ok: true, surface: 'plan', action: 'remove', target, removedCount: removed, archive: '.leerness/plan.archive.md' }, null, 2));
14768
+ return;
14769
+ }
14735
14770
  ok(`milestone removed: ${removed}건 (보존: .leerness/plan.archive.md)`);
14736
14771
  _autoRoadmap(absRoot(root), 'data-change');
14737
14772
  }
@@ -15065,12 +15100,19 @@ function taskUpdate(root, id) {
15065
15100
  }
15066
15101
  function taskDrop(root, id) {
15067
15102
  if (!_requireInit(root, 'task drop')) return; // 1.9.396 (6번째 외부평가/codex P1-B): init 가드
15068
- if (!id) return fail('id required');
15103
+ if (!id) return failJson(has('--json'), 'missing_id', 'id required');
15069
15104
  const rows = readProgressRows(root);
15070
15105
  // 1.9.396 (6번째 외부평가/codex P1-B): 없는 task drop 시 가짜 row(request undefined) 생성 = 데이터 손상 → task update 와 동일하게 존재 확인 후 fail(no-op).
15071
15106
  if (!rows.find(r => r.id === id)) { failJson(has('--json'), 'task_not_found', `task ${id} not found in progress-tracker.md`); return; } // codex P2: --json 에러 구조화
15072
15107
  const _tdEn = _uiLang(root) === 'en'; // 1.36.63 (검수 #2)
15073
- upsertProgress(root, { id, status: 'dropped', evidence: arg('--reason', _tdEn ? 'dropped by user request' : '사용자 요청으로 제외'), nextAction: _tdEn ? 'none' : '없음' });
15108
+ const evidence = arg('--reason', _tdEn ? 'dropped by user request' : '사용자 요청으로 제외');
15109
+ const nextAction = _tdEn ? 'none' : '없음';
15110
+ upsertProgress(root, { id, status: 'dropped', evidence, nextAction });
15111
+ if (has('--json')) {
15112
+ _autoRoadmap(absRoot(root), 'data-change', { quiet: true });
15113
+ log(JSON.stringify({ ok: true, surface: 'tasks', action: 'drop', id, status: 'dropped', evidence, nextAction }, null, 2));
15114
+ return;
15115
+ }
15074
15116
  ok(`task dropped: ${id}`);
15075
15117
  _autoRoadmap(absRoot(root), 'data-change');
15076
15118
  }
@@ -15385,7 +15427,7 @@ function lessonDropCmd(root, target) {
15385
15427
  // 1.36.140 (재검수 P3): 락/쓰기 헬퍼가 부모를 만들기 때문에, 오타 난 `--path` 로 실패하면서도
15386
15428
  // 그 경로와 `.leerness/` 를 만들었다(1.36.139 는 안 만들었다 — 대조군). 없으면 아무것도 만들지 않는다.
15387
15429
  if (!exists(root)) return failJson(has('--json'), 'path_not_found', `경로가 없습니다: ${root} — 디렉토리를 새로 만들지 않았습니다`);
15388
- if (!target) return fail('lesson drop <date|text-substring> 필요. 예: leerness lesson drop "2026-05-20" 또는 leerness lesson drop "JWT"');
15430
+ if (!target) return failJson(has('--json'), 'missing_target', 'lesson drop <date|text-substring> 필요. 예: leerness lesson drop "2026-05-20" 또는 leerness lesson drop "JWT"');
15389
15431
  // archive 보존 — lessons.archive.md 에 projection MD 블록 형태로 추가
15390
15432
  const archivePath = path.join(root, '.leerness/lessons.archive.md');
15391
15433
  // 1.36.140 (재검수 P1, 재현): 종전엔 락 **밖**에서 매칭한 뒤 락 안에서 `${date}|${text}` 복합 키로
@@ -15417,6 +15459,11 @@ function lessonDropCmd(root, target) {
15417
15459
  });
15418
15460
  if (loadErr === 'no_lessons') return failJson(has('--json'), 'no_lessons', 'lessons 없음'); // codex P2: --json 에러 구조화
15419
15461
  if (loadErr === 'lesson_not_found' || !removed.length) return failJson(has('--json'), 'lesson_not_found', `매칭 lesson 없음: "${target}"`);
15462
+ if (has('--json')) {
15463
+ _autoRoadmap(absRoot(root), 'data-change', { quiet: true });
15464
+ log(JSON.stringify({ ok: true, surface: 'lessons', action: 'drop', target, removedCount: removed.length, archive: '.leerness/lessons.archive.md' }, null, 2));
15465
+ return;
15466
+ }
15420
15467
  ok(`lesson dropped: ${removed.length}건 (보존: .leerness/lessons.archive.md)`);
15421
15468
  _autoRoadmap(absRoot(root), 'data-change');
15422
15469
  }
@@ -15494,7 +15541,7 @@ function decisionDropCmd(root, target) {
15494
15541
  // 1.36.140 (재검수 P3): 락/쓰기 헬퍼가 부모를 만들기 때문에, 오타 난 `--path` 로 실패하면서도
15495
15542
  // 그 경로와 `.leerness/` 를 만들었다(1.36.139 는 안 만들었다 — 대조군). 없으면 아무것도 만들지 않는다.
15496
15543
  if (!exists(root)) return failJson(has('--json'), 'path_not_found', `경로가 없습니다: ${root} — 디렉토리를 새로 만들지 않았습니다`);
15497
- if (!target) return fail('decision drop <date|title-substring> 필요. 예: leerness decision drop "2026-05-20" 또는 leerness decision drop "PostgreSQL"');
15544
+ if (!target) return failJson(has('--json'), 'missing_target', 'decision drop <date|title-substring> 필요. 예: leerness decision drop "2026-05-20" 또는 leerness decision drop "PostgreSQL"');
15498
15545
  // 1.9.339 (UR-0053): canonical JSON 기준 drop (date 또는 title substring 매칭) — JSON+MD projection 동시 갱신.
15499
15546
  const archivePath = path.join(root, '.leerness/decisions.archive.md');
15500
15547
  // 1.36.138 (동시성 감사 실측): `decision add` 는 락 안인데 **drop 은 락 밖**이었다 —
@@ -15527,6 +15574,11 @@ function decisionDropCmd(root, target) {
15527
15574
  });
15528
15575
  if (loadErr === 'no_decisions') return failJson(has('--json'), 'no_decisions', 'decisions 없음'); // codex P2: --json 에러 구조화
15529
15576
  if (loadErr === 'decision_not_found' || !removed.length) return failJson(has('--json'), 'decision_not_found', `매칭 decision 없음: "${target}"`);
15577
+ if (has('--json')) {
15578
+ _autoRoadmap(absRoot(root), 'data-change', { quiet: true });
15579
+ log(JSON.stringify({ ok: true, surface: 'decisions', action: 'drop', target, removedCount: removed.length, archive: '.leerness/decisions.archive.md' }, null, 2));
15580
+ return;
15581
+ }
15530
15582
  ok(`decision dropped: ${removed.length}건 (보존: .leerness/decisions.archive.md)`);
15531
15583
  _autoRoadmap(absRoot(root), 'data-change');
15532
15584
  }
@@ -22555,7 +22607,8 @@ function _autoRoadmapConfig(root) {
22555
22607
  function _saveAutoRoadmapConfig(root, cfg) {
22556
22608
  writeUtf8(_autoRoadmapConfigPath(root), JSON.stringify(cfg, null, 2) + '\n');
22557
22609
  }
22558
- function _autoRoadmap(root, trigger) {
22610
+ function _autoRoadmap(root, trigger, opts = {}) {
22611
+ const quiet = !!(opts && opts.quiet);
22559
22612
  try {
22560
22613
  if (process.env.LEERNESS_NO_AUTO_ROADMAP === '1') return false;
22561
22614
  if (!exists(path.join(root, '.leerness'))) return false;
@@ -22569,10 +22622,10 @@ function _autoRoadmap(root, trigger) {
22569
22622
  const outFile = path.resolve(cfg.outFile || path.join(root, 'leerness.html'));
22570
22623
  const s = _graph.graphHtmlCmd(root, { _roadmapData, _loadDecisions, _loadLessons, _parseFeatureGraph, _loadToggles: _tgl.loadToggles, _toggleRegistry: _tgl.TOGGLE_REGISTRY, _loadTechProfile: _tech.loadTechProfile, quiet: true }, outFile);
22571
22624
  const _en = _uiLang(root) === 'en';
22572
- log(_en ? `✓ ontology graph auto-updated (${trigger}) — ${rel(root, outFile)} (graph+roadmap+toggles)` : `✓ 온톨로지 그래프 자동 갱신 (${trigger}) — ${rel(root, outFile)} (그래프+로드맵+토글 탭)`);
22625
+ if (!quiet) log(_en ? `✓ ontology graph auto-updated (${trigger}) — ${rel(root, outFile)} (graph+roadmap+toggles)` : `✓ 온톨로지 그래프 자동 갱신 (${trigger}) — ${rel(root, outFile)} (그래프+로드맵+토글 탭)`);
22573
22626
  return true;
22574
22627
  } catch (e) {
22575
- warn((_uiLang(root) === 'en' ? 'roadmap auto-update failed: ' : 'roadmap 자동 갱신 실패: ') + (e && e.message ? e.message : e));
22628
+ if (!quiet) warn((_uiLang(root) === 'en' ? 'roadmap auto-update failed: ' : 'roadmap 자동 갱신 실패: ') + (e && e.message ? e.message : e));
22576
22629
  return false;
22577
22630
  }
22578
22631
  }
@@ -22850,7 +22903,7 @@ function _updateRules(root, mutate) {
22850
22903
 
22851
22904
  function ruleRemove(root, id) {
22852
22905
  root = absRoot(root);
22853
- if (!id) return fail('id required');
22906
+ if (!id) return failJson(has('--json'), 'missing_id', 'id required');
22854
22907
  let removed = null;
22855
22908
  const res = _updateRules(root, (rules) => {
22856
22909
  const i = rules.findIndex(r => r.id === id);
@@ -22863,7 +22916,13 @@ function ruleRemove(root, id) {
22863
22916
  return removed;
22864
22917
  });
22865
22918
  if (res.error) return failJson(has('--json'), 'rule_not_found', `rule not found: ${id}`); // codex P2: --json 에러 구조화
22919
+ if (has('--json')) {
22920
+ _autoRoadmap(root, 'data-change', { quiet: true });
22921
+ log(JSON.stringify({ ok: true, surface: 'rules', action: 'remove', id, removedCount: 1, archive: '.leerness/rules.archive.md' }, null, 2));
22922
+ return;
22923
+ }
22866
22924
  ok(`rule removed: ${id} (보존: .leerness/rules.archive.md)`);
22925
+ _autoRoadmap(root, 'data-change');
22867
22926
  }
22868
22927
 
22869
22928
  function rulePause(root, id) {
@@ -376,18 +376,18 @@ function _sameDestinationSnapshot(a, b) {
376
376
  return !!a && !!b && a.kind === b.kind && a.hash === b.hash;
377
377
  }
378
378
 
379
- function migrateLegacyWorkspace(root, opts = {}) {
380
- const state = inspectWorkspace(root);
381
- const dryRun = opts.dryRun === true;
382
- const force = opts.force === true;
383
- const version = String(opts.version || 'unknown');
379
+ const _MIGRATION_LOCK_OWNED = Symbol('leerness.workspace-migration-lock-owned');
380
+
381
+ function _migrationLockWaitMs(opts = {}) {
382
+ const parsed = Number(opts.lockWaitMs);
383
+ return Number.isFinite(parsed) ? Math.max(0, Math.min(60000, parsed)) : 10000;
384
+ }
385
+
386
+ function _newMigrationReport(state, opts = {}) {
384
387
  const at = opts.at || new Date().toISOString();
385
- const parsedLockWait = Number(opts.lockWaitMs);
386
- const lockWaitMs = Number.isFinite(parsedLockWait)
387
- ? Math.max(0, Math.min(60000, parsedLockWait))
388
- : 10000;
389
- const lockFile = path.join(state.root, '.leerness-workspace-migration.lock');
390
- const report = {
388
+ const version = String(opts.version || 'unknown');
389
+ const dryRun = opts.dryRun === true;
390
+ return {
391
391
  at,
392
392
  version,
393
393
  src: state.legacy.abs,
@@ -410,6 +410,74 @@ function migrateLegacyWorkspace(root, opts = {}) {
410
410
  waitedForLock: false,
411
411
  lockWaitedMs: 0,
412
412
  };
413
+ }
414
+
415
+ // Live first migrations serialize before the second workspace inspection or
416
+ // any destination conflict scan. Acquiring later leaves a TOCTOU window where
417
+ // a peer's in-progress canonical copy is mistaken for user-authored conflict.
418
+ // Dry-runs and already-canonical roots stay read-only and skip lock creation.
419
+ function migrateLegacyWorkspace(root, opts = {}) {
420
+ const initial = inspectWorkspace(root);
421
+ if (opts.dryRun === true || opts[_MIGRATION_LOCK_OWNED] === true || !initial.legacy.exists) {
422
+ return _migrateLegacyWorkspace(initial.root, opts);
423
+ }
424
+
425
+ const lockWaitMs = _migrationLockWaitMs(opts);
426
+ const lockFile = path.join(initial.root, '.leerness-workspace-migration.lock');
427
+ const startedAt = Date.now();
428
+ const deadline = startedAt + lockWaitMs;
429
+ let lockFd = null;
430
+ let waited = false;
431
+
432
+ while (lockFd === null) {
433
+ try { lockFd = fs.openSync(lockFile, 'wx'); }
434
+ catch (error) {
435
+ if (!error || (error.code !== 'EEXIST' && !_isTransientLockFsError(error))) {
436
+ const report = _newMigrationReport(initial, opts);
437
+ report.blocked = true;
438
+ report.blockedReason = 'workspace-dir-migration-failed';
439
+ report.errors.push(error && error.message ? error.message : String(error));
440
+ report.waitedForLock = waited;
441
+ report.lockWaitedMs = waited ? Math.max(0, Date.now() - startedAt) : 0;
442
+ return report;
443
+ }
444
+ waited = true;
445
+ if (Date.now() >= deadline) {
446
+ const report = _newMigrationReport(initial, opts);
447
+ report.blocked = true;
448
+ report.blockedReason = 'workspace-dir-migration-locked';
449
+ report.errors.push(`another workspace migration still owns: ${lockFile}`);
450
+ report.waitedForLock = true;
451
+ report.lockWaitedMs = Math.max(0, Date.now() - startedAt);
452
+ return report;
453
+ }
454
+ _sleepSync(Math.min(50, Math.max(1, deadline - Date.now())));
455
+ }
456
+ }
457
+
458
+ try {
459
+ const report = _migrateLegacyWorkspace(initial.root, { ...opts, [_MIGRATION_LOCK_OWNED]: true });
460
+ if (waited) {
461
+ report.waitedForLock = true;
462
+ report.lockWaitedMs = Math.max(report.lockWaitedMs || 0, Date.now() - startedAt);
463
+ }
464
+ return report;
465
+ } finally {
466
+ try { fs.closeSync(lockFd); } catch {}
467
+ _removeOwnedLockFile(lockFile);
468
+ }
469
+ }
470
+
471
+ function _migrateLegacyWorkspace(root, opts = {}) {
472
+ const state = inspectWorkspace(root);
473
+ const dryRun = opts.dryRun === true;
474
+ const migrationLockOwned = opts[_MIGRATION_LOCK_OWNED] === true;
475
+ const force = opts.force === true;
476
+ const version = String(opts.version || 'unknown');
477
+ const at = opts.at || new Date().toISOString();
478
+ const lockWaitMs = _migrationLockWaitMs(opts);
479
+ const lockFile = path.join(state.root, '.leerness-workspace-migration.lock');
480
+ const report = _newMigrationReport(state, opts);
413
481
 
414
482
  const block = (reason, message, extra = {}) => {
415
483
  report.blocked = true;
@@ -423,7 +491,7 @@ function migrateLegacyWorkspace(root, opts = {}) {
423
491
  // look foreign or dual-live while copying. Observe that transaction before
424
492
  // applying any conflict verdict; otherwise simultaneous first handoffs race
425
493
  // against an intermediate state and some fail before reaching the lock.
426
- if (!dryRun && fs.existsSync(lockFile)) {
494
+ if (!dryRun && !migrationLockOwned && fs.existsSync(lockFile)) {
427
495
  const peerStartedAt = Date.now();
428
496
  const peerDeadline = peerStartedAt + lockWaitMs;
429
497
  report.waitedForLock = true;
@@ -559,7 +627,7 @@ function migrateLegacyWorkspace(root, opts = {}) {
559
627
  const stamp = at.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 96) || String(Date.now());
560
628
  const migrationRoot = path.join(state.canonical.abs, 'archive', `workspace-dir-migration-${stamp}`);
561
629
  const canonicalBackup = path.join(migrationRoot, 'canonical-before-merge');
562
- let lockFd = null;
630
+ let lockFd = migrationLockOwned ? undefined : null;
563
631
  const created = [];
564
632
  const overwritten = [];
565
633
  const canonicalExisted = state.canonical.exists;
@@ -750,7 +818,7 @@ function migrateLegacyWorkspace(root, opts = {}) {
750
818
  }
751
819
  return report;
752
820
  } finally {
753
- if (lockFd !== null) {
821
+ if (!migrationLockOwned && lockFd !== null) {
754
822
  try { fs.closeSync(lockFd); } catch {}
755
823
  _removeOwnedLockFile(lockFile);
756
824
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.171",
3
+ "version": "1.36.173",
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",
@@ -48,13 +48,14 @@
48
48
  "LICENSE"
49
49
  ],
50
50
  "scripts": {
51
- "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
52
- "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js",
51
+ "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
52
+ "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js",
53
53
  "test:commands": "node ./scripts/command-flags-probe.js && node ./scripts/next-action-suggestion-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js",
54
54
  "test:next-actions": "node ./scripts/next-action-suggestion-probe.js",
55
55
  "test:installed": "node ./scripts/installed-cleanroom-probe.js",
56
56
  "test:handoff": "node ./scripts/handoff-readonly-probe.js",
57
- "test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/smoke.js",
57
+ "test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/smoke.js",
58
+ "test:workspace-lock-order": "node ./scripts/workspace-dir-lock-order-probe.js",
58
59
  "test:mcp-presence": "node ./scripts/mcp-presence-probe.js",
59
60
  "test:smoke": "node ./scripts/smoke.js",
60
61
  "lint": "node ./scripts/lint.js",
@@ -207,12 +207,40 @@ console.log('# leerness core (test:core) — flagship behavioral guarantees');
207
207
  // (10) --json 무결성: 변형/조회 명령이 성공 경로에서도 유효 JSON (1.36.0, 클린룸 리뷰 B)
208
208
  {
209
209
  const d = fresh();
210
- const okJson = (r) => { try { JSON.parse((r.stdout || '').trim()); return true; } catch { return false; } };
211
- run(d, ['task', 'add', 'sample']);
210
+ const jsonResult = (r) => { try { return JSON.parse((r.stdout || '').trim()); } catch { return null; } };
211
+ const okJson = (r) => { const payload = jsonResult(r); return r.status === 0 && !(r.stderr || '').trim() && payload !== null && payload.ok !== false; };
212
+ const addedTask = jsonResult(run(d, ['task', 'add', 'sample', '--json']));
212
213
  assert('json: task update --status done --json → valid JSON (was plain text)', okJson(run(d, ['task', 'update', 'T-0002', '--status', 'done', '--json'])));
213
214
  assert('json: plan add --json → valid JSON', okJson(run(d, ['plan', 'add', 'a milestone', '--json'])));
214
215
  assert('json: rule verify --json → valid JSON', okJson(run(d, ['rule', 'verify', '--json'])));
215
216
  assert('json: reuse find --json → valid JSON', okJson(run(d, ['reuse', 'find', 'button', '--json'])));
217
+
218
+ // T-0093: Memory DELETE 다섯 표면은 mutation은 성공하면서 JSON을 전혀 내지 않고
219
+ // 사람용 ✓ 문구만 stderr에 썼다. 성공 payload와 stderr-0을 함께 고정한다.
220
+ run(d, ['decision', 'add', 'json delete decision']);
221
+ run(d, ['lesson', 'save', 'json delete lesson']);
222
+ const addedRule = jsonResult(run(d, ['rule', 'add', 'json delete rule', '--trigger', 'every-session', '--json']));
223
+ run(d, ['plan', 'add', 'json delete milestone']);
224
+ run(d, ['roadmap', 'auto', 'on', '--on-every-change']);
225
+ const deleteCases = [
226
+ ['task drop', ['task', 'drop', addedTask.id, '--json'], 'tasks'],
227
+ ['decision drop', ['decision', 'drop', 'json delete decision', '--json'], 'decisions'],
228
+ ['lesson drop', ['lesson', 'drop', 'json delete lesson', '--json'], 'lessons'],
229
+ ['rule remove', ['rule', 'remove', addedRule.id, '--json'], 'rules'],
230
+ ['plan remove', ['plan', 'remove', 'json delete milestone', '--json'], 'plan'],
231
+ ];
232
+ const graphPath = path.join(d, 'leerness.html');
233
+ for (const [label, args, surface] of deleteCases) {
234
+ fs.rmSync(graphPath, { force: true });
235
+ const result = run(d, args);
236
+ const payload = jsonResult(result);
237
+ assert(`json: ${label} --json → one success document + clean stderr + silent roadmap refresh`,
238
+ result.status === 0 && !(result.stderr || '').trim() && payload?.ok === true
239
+ && payload.surface === surface
240
+ && (surface === 'tasks' ? payload.status === 'dropped' : payload.removedCount === 1)
241
+ && fs.existsSync(graphPath),
242
+ `status=${result.status} stdout=${result.stdout} stderr=${result.stderr}`);
243
+ }
216
244
  fs.rmSync(d, { recursive: true, force: true });
217
245
  }
218
246
 
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // A live legacy migration must own its project lock before the second workspace
5
+ // inspection. Otherwise a peer can populate `.leerness/` after validation and
6
+ // make the first caller misclassify the peer's intermediate copy as a user
7
+ // conflict. This probe forces that interleaving without relying on scheduler luck.
8
+
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+ const path = require('path');
12
+ const cp = require('child_process');
13
+ const workspace = require('../lib/workspace-dir');
14
+
15
+ const IS_PEER = process.argv[2] === '--peer';
16
+ const root = IS_PEER
17
+ ? path.resolve(process.argv[3] || '.')
18
+ : fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-lock-order-'));
19
+ const legacy = path.join(root, workspace.LEGACY_WORKSPACE_DIR);
20
+ const canonical = path.join(root, workspace.CANONICAL_WORKSPACE_DIR);
21
+ const lockFile = path.join(root, '.leerness-workspace-migration.lock');
22
+ const peerReadyFile = path.join(root, '.leerness-lock-order-peer-ready');
23
+ const peerContendedFile = path.join(root, '.leerness-lock-order-peer-contended');
24
+ const peerDoneFile = path.join(root, '.leerness-lock-order-peer-done');
25
+
26
+ function write(file, body) {
27
+ fs.mkdirSync(path.dirname(file), { recursive: true });
28
+ fs.writeFileSync(file, body, 'utf8');
29
+ }
30
+
31
+ function sleep(ms) {
32
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
33
+ }
34
+
35
+ function waitForPeerHandshake(timeoutMs) {
36
+ const deadline = Date.now() + timeoutMs;
37
+ while (Date.now() < deadline) {
38
+ const ready = fs.existsSync(peerReadyFile);
39
+ const contended = fs.existsSync(peerContendedFile);
40
+ const done = fs.existsSync(peerDoneFile);
41
+ if (ready && (contended || done)) return { ready, contended, done };
42
+ sleep(2);
43
+ }
44
+ return {
45
+ ready: fs.existsSync(peerReadyFile),
46
+ contended: fs.existsSync(peerContendedFile),
47
+ done: fs.existsSync(peerDoneFile),
48
+ };
49
+ }
50
+
51
+ function runPeer() {
52
+ const originalOpen = fs.openSync;
53
+ fs.openSync = function patchedOpen(target, flags, ...rest) {
54
+ try { return originalOpen.call(fs, target, flags, ...rest); }
55
+ catch (error) {
56
+ if (path.resolve(String(target)) === lockFile && flags === 'wx' && error && error.code === 'EEXIST') {
57
+ write(peerContendedFile, `${process.pid}\n`);
58
+ }
59
+ throw error;
60
+ }
61
+ };
62
+
63
+ write(peerReadyFile, `${process.pid}\n`);
64
+ let report;
65
+ try {
66
+ report = workspace.migrateLegacyWorkspace(root, {
67
+ version: 'lock-order-peer',
68
+ lockWaitMs: 20000,
69
+ at: '2026-08-27T00:00:01.000Z',
70
+ });
71
+ } finally {
72
+ fs.openSync = originalOpen;
73
+ }
74
+ write(peerDoneFile, `${JSON.stringify(report)}\n`);
75
+ if (!report || report.blocked || (!report.migrated && !report.alreadyCanonical)) process.exitCode = 1;
76
+ }
77
+
78
+ async function main() {
79
+ if (IS_PEER) {
80
+ runPeer();
81
+ return;
82
+ }
83
+
84
+ write(path.join(legacy, 'HARNESS_VERSION'), '1.36.161\n');
85
+ write(path.join(legacy, 'progress-tracker.md'), `# Read ${workspace.LEGACY_WORKSPACE_DIR}/progress-tracker.md\n`);
86
+ for (let i = 0; i < 1200; i++) {
87
+ write(path.join(legacy, 'bulk', `f-${String(i).padStart(4, '0')}.md`),
88
+ `Read ${workspace.LEGACY_WORKSPACE_DIR}/bulk/${i}\n${'x'.repeat(2048)}\n`);
89
+ }
90
+
91
+ const originalLstat = fs.lstatSync;
92
+ const originalUnlink = fs.unlinkSync;
93
+ let canonicalInspections = 0;
94
+ let peer = null;
95
+ let peerDone = null;
96
+ let earlyLockSeen = false;
97
+ let peerReadySeen = false;
98
+ let peerContentionSeen = false;
99
+ let peerCompletedBeforeRelease = false;
100
+ let lockReleaseObserved = false;
101
+ let peerBlockedUntilRelease = false;
102
+
103
+ fs.lstatSync = function patchedLstat(target, ...rest) {
104
+ const isCanonicalRoot = path.resolve(String(target)) === canonical;
105
+ let result;
106
+ let thrown;
107
+ try { result = originalLstat.call(fs, target, ...rest); }
108
+ catch (error) { thrown = error; }
109
+
110
+ if (isCanonicalRoot && ++canonicalInspections === 2) {
111
+ earlyLockSeen = fs.existsSync(lockFile);
112
+ peer = cp.spawn(process.execPath, [__filename, '--peer', root], {
113
+ cwd: root,
114
+ env: {
115
+ ...process.env,
116
+ LEERNESS_INTERNAL: '1',
117
+ LEERNESS_NO_BANNER: '1',
118
+ LEERNESS_NO_PROMPT: '1',
119
+ LEERNESS_NO_AUTOCHCP: '1',
120
+ },
121
+ stdio: ['ignore', 'ignore', 'ignore'],
122
+ });
123
+ peerDone = new Promise((resolve) => peer.on('close', (status) => resolve(status)));
124
+
125
+ // The child records an actual failed `open(..., "wx")`, not merely that
126
+ // it was spawned. This proves it reached lock acquisition while the
127
+ // victim is paused inside its second canonical workspace inspection.
128
+ const handshake = waitForPeerHandshake(20000);
129
+ peerReadySeen = handshake.ready;
130
+ peerContentionSeen = handshake.contended;
131
+ peerCompletedBeforeRelease = handshake.done;
132
+ }
133
+
134
+ if (thrown) throw thrown;
135
+ return result;
136
+ };
137
+ fs.unlinkSync = function patchedUnlink(target, ...rest) {
138
+ if (path.resolve(String(target)) === lockFile) {
139
+ lockReleaseObserved = true;
140
+ peerBlockedUntilRelease = peerReadySeen
141
+ && peerContentionSeen
142
+ && !fs.existsSync(peerDoneFile)
143
+ && peer !== null
144
+ && peer.exitCode === null;
145
+ }
146
+ return originalUnlink.call(fs, target, ...rest);
147
+ };
148
+
149
+ let victim;
150
+ try {
151
+ victim = workspace.migrateLegacyWorkspace(root, {
152
+ version: 'lock-order-probe',
153
+ lockWaitMs: 20000,
154
+ at: '2026-08-27T00:00:00.000Z',
155
+ });
156
+ } finally {
157
+ fs.lstatSync = originalLstat;
158
+ fs.unlinkSync = originalUnlink;
159
+ }
160
+
161
+ const peerStatus = peerDone ? await peerDone : null;
162
+ const ok = earlyLockSeen
163
+ && peerReadySeen
164
+ && peerContentionSeen
165
+ && !peerCompletedBeforeRelease
166
+ && lockReleaseObserved
167
+ && peerBlockedUntilRelease
168
+ && peerStatus === 0
169
+ && victim && victim.migrated === true && victim.blocked === false
170
+ && fs.existsSync(path.join(canonical, 'progress-tracker.md'))
171
+ && fs.existsSync(path.join(root, workspace.LEGACY_BACKUP_WORKSPACE_DIR, 'progress-tracker.md'))
172
+ && !fs.existsSync(legacy);
173
+
174
+ if (!ok) {
175
+ process.stderr.write(`WORKSPACE_LOCK_ORDER_RACE ${JSON.stringify({
176
+ earlyLockSeen,
177
+ peerReadySeen,
178
+ peerContentionSeen,
179
+ peerCompletedBeforeRelease,
180
+ lockReleaseObserved,
181
+ peerBlockedUntilRelease,
182
+ peerStatus,
183
+ victim: victim && {
184
+ migrated: victim.migrated,
185
+ alreadyCanonical: victim.alreadyCanonical,
186
+ blocked: victim.blocked,
187
+ blockedReason: victim.blockedReason,
188
+ errors: victim.errors,
189
+ },
190
+ })}\n`);
191
+ process.exitCode = 1;
192
+ } else {
193
+ process.stdout.write('WORKSPACE_LOCK_ORDER_OK\n');
194
+ }
195
+ }
196
+
197
+ main()
198
+ .catch((error) => {
199
+ process.stderr.write(`WORKSPACE_LOCK_ORDER_CRASH ${error && error.stack ? error.stack : error}\n`);
200
+ process.exitCode = 1;
201
+ })
202
+ .finally(() => {
203
+ if (!IS_PEER) {
204
+ try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
205
+ }
206
+ });