leerness 1.36.185 → 1.36.187

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.ko.md +5 -0
  3. package/README.md +14 -4
  4. package/bin/leerness.js +291 -57
  5. package/docs/e2e-observation-contract.md +51 -0
  6. package/docs/runtime-compatibility-api.md +183 -0
  7. package/docs/state-paths-api.md +92 -0
  8. package/docs/state-scopes.md +266 -0
  9. package/docs/worktree-runtime-migration.md +278 -0
  10. package/lib/claims-baseline.js +1 -1
  11. package/lib/file-leases.js +2 -2
  12. package/lib/git.js +64 -15
  13. package/lib/io.js +9 -3
  14. package/lib/migrate.js +1 -1
  15. package/lib/preview-serve.js +5 -1
  16. package/lib/pure-utils.js +20 -0
  17. package/lib/role-fallback.js +3 -3
  18. package/lib/role-store.js +2 -2
  19. package/lib/runtime-layout.js +395 -0
  20. package/lib/runtime-writes.js +166 -0
  21. package/lib/session-close.js +1 -1
  22. package/lib/state-git.js +138 -0
  23. package/lib/state-inspect.js +39 -0
  24. package/lib/state-inventory.js +69 -0
  25. package/lib/state-paths.js +59 -0
  26. package/lib/workspace-dir.js +34 -10
  27. package/package.json +10 -6
  28. package/scripts/command-flags-probe.js +1 -1
  29. package/scripts/dead-flags-probe.js +1 -1
  30. package/scripts/e2e-child-diagnostics-probe.js +143 -0
  31. package/scripts/e2e-child-diagnostics.js +33 -0
  32. package/scripts/e2e-temp-scope-probe.js +127 -0
  33. package/scripts/e2e.js +108 -27
  34. package/scripts/encoding-selftest-probe.js +222 -0
  35. package/scripts/false-claim-probe.js +34 -3
  36. package/scripts/file-lease-probe.js +5 -2
  37. package/scripts/legacy-runtime-negative-probe.js +72 -0
  38. package/scripts/mutation-integrity-probe.js +3 -0
  39. package/scripts/role-fallback-probe.js +67 -6
  40. package/scripts/runtime-admission-probe.js +282 -0
  41. package/scripts/runtime-git-write-probe.js +116 -0
  42. package/scripts/runtime-layout-probe.js +1198 -0
  43. package/scripts/runtime-repl-probe.js +271 -0
  44. package/scripts/runtime-replacement-probe.js +190 -0
  45. package/scripts/runtime-write-probe.js +458 -0
  46. package/scripts/selftest-cleanup-probe.js +168 -0
  47. package/scripts/state-inspect-cli-probe.js +209 -0
  48. package/scripts/state-scopes-probe.js +590 -0
  49. package/scripts/workspace-dir-lock-order-probe.js +310 -35
  50. package/scripts/workspace-dir-migration-probe.js +17 -2
  51. package/scripts/workspace-selection-probe.js +230 -0
package/bin/leerness.js CHANGED
@@ -10,6 +10,7 @@ const { resolvePortableLaunch, spawnPortable, spawnPortableSync } = require('../
10
10
  const { scanShellScriptsEncoding, applyShellScriptUtf8Bom } = require('../lib/shell-encoding');
11
11
  const os = require('os'); // 1.9.178: _publishToNpm 에서 os.tmpdir() 사용 (전역 import)
12
12
  const readline = require('readline');
13
+ const { withRuntimeWrites, withRuntimePathWrite, assertCurrentRuntimeWrite, projectWriter } = require('../lib/runtime-writes');
13
14
  // 1.9.274 (UR-0025 1단계): 순수 유틸 함수 모듈 분리 (require-based, 비파괴). selftest 7종이 동작 검증.
14
15
  const { _isSecretKey, _isPlaceholderSecret, _looksSecretLike, redactSecrets, stripDefaultIgnorables, _mergeLines, _mergeEnvLines, _mergeReadmeSection, _managedMerge, _parseSkillsValue, _parseArchiveBlocks, _parseSkillCatalog, _renderTeamsMd, _composeTeamPlan, _teamHandoffReminders, _cadenceAssessment, _teamDeployGate, _renderWorkspaceReferenceGuide, _memorySurface, _renderPulseLine, compareVer, parseHarnessVersion, _classifyCJK, _riskLabel, _detectSystemLang, _parseSlashFromHelp,
15
16
  PERMISSION_TIERS, _tierRank, _requiredTier, _policyAllows, _resolveNpmTag, _mcpJsonContent, _newRunRecord,
@@ -29,7 +30,7 @@ const { _isSecretKey, _isPlaceholderSecret, _looksSecretLike, redactSecrets, str
29
30
  _migrationGuideText, _parseContractSpec, _gitignoreMatch,
30
31
  _featureGraphTemplate, _parseFeatureGraph, _nextFeatureId, _featureBlock, _featureImpactBfs,
31
32
  _parseChangelogBetween, _cellSafe, _cellUnescape, _lineSafe, _milestoneSurface, _recentChangesAggregate, _parseLimit, _parseAddTitle, _parseImplExports, _taskPositionalPath, _completionClaimAllowed, _minorKey, _shouldPublishNpm,
32
- _matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, _briefUnfilled, _planGoalUnfilled, _draftAnchors, _replaceMdSection, _mdSectionBody, _blocksWithOffset, _decisionBlocksWithOffset } = require('../lib/pure-utils'); // 1.9.318~1.11.4 (UR-0025/.../0007 glossary): 순수 유틸 모듈 분리 · 1.36.36 anchors
33
+ _matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, GLOSSARY_START, GLOSSARY_END, _briefUnfilled, _planGoalUnfilled, _draftAnchors, _replaceMdSection, _mdSectionBody, _blocksWithOffset, _decisionBlocksWithOffset } = require('../lib/pure-utils'); // 1.9.318~1.11.4 (UR-0025/.../0007 glossary): 순수 유틸 모듈 분리 · 1.36.36 anchors
33
34
  // 1.9.304 (UR-0025): 순수 분석/검증 함수 모듈 분리.
34
35
  const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInGit, _epistemicHonestyCheck } = require('../lib/analyzers');
35
36
  // 1.9.295 (UR-0025 4단계): 정적 데이터 카탈로그 모듈 분리 (비파괴, require-based).
@@ -49,7 +50,7 @@ const {
49
50
  migrateLegacyWorkspace,
50
51
  } = require('../lib/workspace-dir');
51
52
 
52
- const VERSION = '1.36.185';
53
+ const VERSION = '1.36.187';
53
54
 
54
55
  // MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
55
56
  // 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
@@ -190,8 +191,8 @@ function _loadSkillCatalog() {
190
191
 
191
192
  // 1.9.341 (UR-0025 심층): BUILTIN_CATALOG (9 내장 스킬 catalog) 는 lib/catalogs.js 로 이전 (import).
192
193
 
193
- // 1.9.10: skillCatalog는 skillpack 우선, fallback builtin. _loadSkillCatalog 호출은 BUILTIN_CATALOG 정의 후.
194
- const skillCatalog = _loadSkillCatalog();
194
+ // Catalog initialization is below the parser declarations so metadata-only
195
+ // inspection can skip irrelevant skillpack discovery (including npm root -g).
195
196
 
196
197
  const routes = {
197
198
  planning: { read: ['.leerness/plan.md','.leerness/progress-tracker.md','.leerness/project-brief.md','.leerness/current-state.md','.leerness/guideline.md'], update: ['.leerness/plan.md','.leerness/progress-tracker.md','.leerness/current-state.md','.leerness/session-handoff.md'] },
@@ -323,23 +324,30 @@ function _ensureLockHeartbeat() {
323
324
  const worker = new Worker(`
324
325
  'use strict';
325
326
  const fs = require('fs');
326
- const { parentPort } = require('worker_threads');
327
+ const { parentPort, workerData } = require('worker_threads');
328
+ const { withRuntimeWrites } = require(workerData.runtimeWrites);
327
329
  const locks = new Map();
328
- function touch(ownerPath, token) {
330
+ function touch(ownerPath, entry) {
329
331
  try {
330
332
  const owner = JSON.parse(fs.readFileSync(ownerPath, 'utf8'));
331
- if (!owner || owner.token !== token || owner.lease !== 'worker-v1') { locks.delete(ownerPath); return; }
332
- const d = new Date(); fs.utimesSync(ownerPath, d, d);
333
+ if (!owner || owner.token !== entry.token || owner.lease !== 'worker-v1') { locks.delete(ownerPath); return; }
334
+ const apply = index => index < entry.roots.length
335
+ ? withRuntimeWrites(entry.roots[index], () => apply(index + 1))
336
+ : fs.utimesSync(ownerPath, new Date(), new Date());
337
+ apply(0);
333
338
  } catch { locks.delete(ownerPath); }
334
339
  }
335
340
  parentPort.on('message', (m) => {
336
341
  if (!m || typeof m.ownerPath !== 'string') return;
337
- if (m.op === 'add' && typeof m.token === 'string') { locks.set(m.ownerPath, m.token); touch(m.ownerPath, m.token); }
342
+ if (m.op === 'add' && typeof m.token === 'string') {
343
+ const entry = { token: m.token, roots: Array.isArray(m.roots) ? m.roots : [] };
344
+ locks.set(m.ownerPath, entry); touch(m.ownerPath, entry);
345
+ }
338
346
  else if (m.op === 'remove') locks.delete(m.ownerPath);
339
347
  });
340
348
  const timer = setInterval(() => { for (const [p, t] of locks) touch(p, t); }, 1000);
341
349
  if (timer.unref) timer.unref();
342
- `, { eval: true });
350
+ `, { eval: true, workerData: { runtimeWrites: require.resolve('../lib/runtime-writes') } });
343
351
  worker.on('error', () => { _lockHeartbeatWorker = null; _lockHeartbeatUnavailable = true; });
344
352
  worker.on('exit', (code) => { _lockHeartbeatWorker = null; if (code !== 0) _lockHeartbeatUnavailable = true; });
345
353
  worker.unref();
@@ -348,7 +356,7 @@ function _ensureLockHeartbeat() {
348
356
  } catch { _lockHeartbeatUnavailable = true; return false; }
349
357
  }
350
358
  function _lockHeartbeat(op, ownerPath, token) {
351
- try { if (_lockHeartbeatWorker) _lockHeartbeatWorker.postMessage({ op, ownerPath, token }); } catch {}
359
+ try { if (_lockHeartbeatWorker) _lockHeartbeatWorker.postMessage({ op, ownerPath, token, roots: require('../lib/runtime-writes').runtimeWriteRoots() }); } catch {}
352
360
  }
353
361
  function _newLockOwner(lease) {
354
362
  const hostId = _lockHostIdentity();
@@ -420,6 +428,9 @@ function _reclaimStaleLock(lockPath, staleMs) {
420
428
  // 경고를 못 전달하는 것은 감수한다 — 그 때문에 성공을 실패로 보고하는 것은 감수하지 않는다.
421
429
  for (const _st of [process.stderr, process.stdout]) { try { _st.on('error', (e) => { if (!e || e.code !== 'EPIPE') return; }); } catch {} }
422
430
  function _withLock(targetPath, fn, opts = {}) {
431
+ return withRuntimePathWrite(targetPath, () => _withLockBody(targetPath, fn, opts));
432
+ }
433
+ function _withLockBody(targetPath, fn, opts = {}) {
423
434
  const lockPath = targetPath + '.lock';
424
435
  // 1.36.144 (T-0117): dry-run 이면 **락을 잡지 않는다** — 쓰기가 어차피 가드에 막히므로 지킬 것이 없고,
425
436
  // 락 디렉토리를 만드는 것만으로 "변경 없음" 이 거짓이 된다(실측: `state *` 8건이 빈 `.leerness/` 를 남겼다).
@@ -611,7 +622,7 @@ function _withLock(targetPath, fn, opts = {}) {
611
622
  throw e;
612
623
  };
613
624
  let out;
614
- try { out = fn(); }
625
+ try { assertCurrentRuntimeWrite(targetPath); out = fn(); }
615
626
  catch (e) { return releaseAndRethrow(e); }
616
627
  let then = null;
617
628
  try { then = out && out.then; }
@@ -882,8 +893,13 @@ function _errorUiLang(root, command) {
882
893
  const parsed = nonFlagArgs();
883
894
  const resolvedCommand = command || parsed[0];
884
895
  if (resolvedCommand === 'mode' && has('--json')) return 'ko';
896
+ if (resolvedCommand === 'state' && ['inspect', 'compatibility'].includes(parsed[1])) return _inspectionUiLang();
885
897
  return _uiLang(resolvedCommand === 'mode' ? _modeRoot(parsed, root) : root);
886
898
  }
899
+ function _inspectionUiLang() {
900
+ const language = String(arg('--language', process.env.LEERNESS_LANG || 'ko')).toLowerCase();
901
+ return language === 'en' ? 'en' : 'ko';
902
+ }
887
903
  // ko/en 쌍에서 해석된 UI 언어로 선택 (Phase 1: 첫 화면 한정 사용).
888
904
  function _tx(lang, ko, en) { return lang === 'en' ? en : ko; }
889
905
  function fm(role, readWhen, updateWhen, body, lang = 'ko') {
@@ -4252,6 +4268,33 @@ function _selftestFixture(lang) {
4252
4268
  return dst;
4253
4269
  }
4254
4270
 
4271
+ // Only these in-process diagnostics own this cleanup path. Windows can keep a
4272
+ // child-process cwd open briefly; native rmSync retry options do not reliably
4273
+ // wait for that case on supported Node versions. Never hide exhausted cleanup.
4274
+ function _stRemoveInProcessFixture(target) {
4275
+ const tempRoot = fs.realpathSync.native(os.tmpdir());
4276
+ const initial = fs.lstatSync(target, { bigint: true });
4277
+ const canonical = fs.realpathSync.native(target);
4278
+ const invalid = () => Object.assign(new Error('Selftest fixture cleanup identity changed'), { code: 'E_SELFTEST_FIXTURE_CHANGED' });
4279
+ if (!initial.isDirectory() || initial.isSymbolicLink() || path.dirname(canonical) !== tempRoot
4280
+ || !/^__leerness_(anchor_redraft|next_action|addjson)_[A-Za-z0-9]{6}$/.test(path.basename(canonical))) throw invalid();
4281
+ const deadline = process.hrtime.bigint() + 2500000000n;
4282
+ const waitCell = new Int32Array(new SharedArrayBuffer(4));
4283
+ for (;;) {
4284
+ let current;
4285
+ try { current = fs.lstatSync(canonical, { bigint: true }); }
4286
+ catch (error) { if (error.code === 'ENOENT') return; throw error; }
4287
+ if (!current.isDirectory() || current.isSymbolicLink() || current.dev !== initial.dev || current.ino !== initial.ino
4288
+ || fs.realpathSync.native(canonical) !== canonical || fs.realpathSync.native(os.tmpdir()) !== tempRoot) throw invalid();
4289
+ try { fs.rmSync(canonical, { recursive: true, force: true }); return; }
4290
+ catch (error) {
4291
+ const remaining = Number(deadline - process.hrtime.bigint()) / 1e6;
4292
+ if (!['EPERM', 'EBUSY', 'ENOTEMPTY'].includes(error.code) || remaining <= 0) throw error;
4293
+ Atomics.wait(waitCell, 0, 0, Math.min(100, remaining));
4294
+ }
4295
+ }
4296
+ }
4297
+
4255
4298
  // 1.9.258: leerness selftest — 설치된 leerness 바이너리의 코어 순수 함수 자가 검증.
4256
4299
  // 1.9.255~257 에서 export 한 보안/정확성/인코딩-핵심 함수를 실제 호출해 무결성 확인.
4257
4300
  // 사용자/CI 가 "내 leerness 가 정상인가?" 를 검증 (행위 검사가 포함돼 환경에 따라 수십 초 소요).
@@ -4643,7 +4686,7 @@ function _selfTestCases() {
4643
4686
  && rb.includes('새 정체성 신호') && rp.includes('새 정체성 신호');
4644
4687
  } catch {} finally {
4645
4688
  process.argv = savedArgv; process.stdout.write = savedWrite;
4646
- try { fs.rmSync(redraw, { recursive: true, force: true }); } catch {}
4689
+ _stRemoveInProcessFixture(redraw);
4647
4690
  }
4648
4691
  if (!(phraseOk && redraftOk)) return false;
4649
4692
  // 무신호 시 발명 금지
@@ -4711,7 +4754,7 @@ function _selfTestCases() {
4711
4754
  && migratedProgress.command === 'leerness task list --path .'
4712
4755
  && migrated.every(a => typeof a.actionKey === 'string' && a.actionKey.length > 0);
4713
4756
  } catch { return false; }
4714
- finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch {} }
4757
+ finally { _stRemoveInProcessFixture(arena); }
4715
4758
  } },
4716
4759
  { name: 'managed 파일 force 병합 (1.36.37, 1.9.441 드리프트 실측): writeIfSafe 가 --force 여도 managed 는 라인-diff 병합 — CLAUDE 커스텀 보존 (행위검사)', run: () => {
4717
4760
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_wif_'));
@@ -4767,7 +4810,12 @@ function _selfTestCases() {
4767
4810
  const s = read(__filename);
4768
4811
  const tagOk = s.includes("'--list', 'v*'") && !s.includes("'--list', 'v1.9" + ".*'");
4769
4812
  const celebOk = (s.match(/이 저장소 계보의 릴리스 태그 이력 없음/g) || []).length >= 2;
4770
- const usageOk = s.includes("else if (args[2] && !args[2].startsWith('-') && exists(path.join(absRoot(args[2]), '.leerness')))");
4813
+ const priorArgv = process.argv;
4814
+ let usageOk;
4815
+ try {
4816
+ process.argv = ['node', 'leerness', 'scan', 'secrets', tmp];
4817
+ usageOk = _runtimeProjectRoot(process.argv.slice(2), 'scan') === absRoot(tmp);
4818
+ } finally { process.argv = priorArgv; }
4771
4819
  const cutoffWired = s.includes('_retroAggregate(root, cutoff)');
4772
4820
  return filterOk && sigOk && tagOk && celebOk && usageOk && cutoffWired;
4773
4821
  } },
@@ -5284,6 +5332,7 @@ function _selfTestCases() {
5284
5332
  { name: '8번째 버그헌트 (UR-0112): _parseSkillMd CRLF/CR 줄바꿈 정규화 (Windows SKILL.md meta 소실 차단) (1.9.408)', run: () => { const m = require('../lib/pure-utils'); const lf = m._parseSkillMd('---\nname: s\ndescription: d\n---\nbody'); const crlf = m._parseSkillMd('---\r\nname: s\r\ndescription: d\r\n---\r\nbody'); const cr = m._parseSkillMd('---\rname: s\rdescription: d\r---\rbody'); const bom = m._parseSkillMd('---\r\nname: s\r\n---\r\nbody'); return lf.meta.name === 's' && crlf.meta.name === 's' && crlf.meta.description === 'd' && cr.meta.name === 's' && bom.meta.name === 's'; } },
5285
5333
  { name: '8번째 버그헌트 (UR-0113): env encoding-check --apply 셸/플랫폼/모호 인코딩 안전계약 (1.9.409/T-0022)', run: () => {
5286
5334
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_bom_'));
5335
+ const priorExit = process.exitCode;
5287
5336
  try {
5288
5337
  const sh = path.join(tmp, 's.sh');
5289
5338
  const ps = path.join(tmp, 's.ps1');
@@ -5291,12 +5340,12 @@ function _selfTestCases() {
5291
5340
  fs.writeFileSync(sh, '#!/bin/bash\n# 한글\necho hi\n');
5292
5341
  fs.writeFileSync(ps, 'Write-Host "😀"\n');
5293
5342
  fs.writeFileSync(ambiguous, Buffer.from([0x41, 0xC2, 0xA1, 0x42]));
5294
- const save = process.argv; let out = ''; const _w = process.stdout.write;
5343
+ const save = process.argv; let out = ''; let thrown = null; const _w = process.stdout.write;
5295
5344
  try {
5296
5345
  process.argv = ['node', 'h', 'env', 'encoding-check', '--path', tmp, '--apply', '--json'];
5297
5346
  process.stdout.write = s => { out += s; return true; };
5298
5347
  envCmd(tmp, 'encoding-check');
5299
- } catch {} finally { process.stdout.write = _w; process.argv = save; }
5348
+ } catch (error) { thrown = error; } finally { process.stdout.write = _w; process.argv = save; }
5300
5349
  const shBuf = fs.readFileSync(sh);
5301
5350
  const psBuf = fs.readFileSync(ps);
5302
5351
  const ambiguousBuf = fs.readFileSync(ambiguous);
@@ -5304,8 +5353,24 @@ function _selfTestCases() {
5304
5353
  const platformOk = process.platform === 'win32'
5305
5354
  ? psBuf.subarray(0, 3).equals(Buffer.from([0xEF, 0xBB, 0xBF]))
5306
5355
  : psBuf.equals(Buffer.from('Write-Host "😀"\n'));
5307
- return shNoBom && platformOk && ambiguousBuf.equals(Buffer.from([0x41, 0xC2, 0xA1, 0x42]));
5308
- } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
5356
+ const ambiguousOk = ambiguousBuf.equals(Buffer.from([0x41, 0xC2, 0xA1, 0x42]));
5357
+ if (thrown || !shNoBom || !platformOk || !ambiguousOk) {
5358
+ let report;
5359
+ try { report = JSON.parse(out); } catch { report = null; }
5360
+ // selftest는 실패 판정을 유지하고, 폐기하던 bounded 원인만 전달한다.
5361
+ const detail = {
5362
+ shNoBom, platformOk, ambiguousOk, exit: process.exitCode == null ? null : process.exitCode,
5363
+ thrown: thrown ? { code: String(thrown.code || 'error').slice(0, 80), message: String(thrown.message || '').slice(0, 400) } : null,
5364
+ scanErrors: report && Array.isArray(report.scanErrors) ? report.scanErrors.slice(0, 4).map(item => String(item.code || item.error || item).slice(0, 180)) : [],
5365
+ applied: report && Array.isArray(report.applied) ? report.applied.slice(0, 4).map(item => ({ file: path.basename(String(item.file || '')).slice(0, 80), action: String(item.action || '').slice(0, 80), code: String(item.code || '').slice(0, 80), error: String(item.error || '').slice(0, 300) })) : [],
5366
+ };
5367
+ throw new Error(`UR-0113 encoding contract failed: ${JSON.stringify(detail).slice(0, 3600)}`);
5368
+ }
5369
+ return true;
5370
+ } finally {
5371
+ process.exitCode = priorExit;
5372
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {}
5373
+ }
5309
5374
  } },
5310
5375
  { name: '8번째 버그헌트 (UR-0114): absRoot 비문자열(--path 값없음 boolean true) → cwd 폴백(raw TypeError 차단) (1.9.410)', run: () => { const io = require('../lib/io'); const cwd = process.cwd(); const tBool = io.absRoot(true) === cwd; const tEmpty = io.absRoot('') === cwd; const tUndef = io.absRoot(undefined) === cwd; const tSpace = io.absRoot(' ') === cwd; const tReal = io.absRoot(os.tmpdir()) === path.resolve(os.tmpdir()); return tBool && tEmpty && tUndef && tSpace && tReal; } },
5311
5376
  { name: '8번째 버그헌트 (UR-0115): lazy detect --auto-track 단일 RMW 배치(O(T×N)→O(N+T)) (1.9.411)', run: () => { const src = read(__filename); const batched = src.includes("8번째 버그헌트, UR-0115") && /has\('--auto-track'\)[\s\S]{0,500}?_withLock\(progressPath\(root\), \(\) => \{[\s\S]{0,1200}?writeProgressRows/.test(src); const noPerTodoUpsert = !/for \(const t of newTodos\) \{\s*const id = nextId\(root, 'T'\);/.test(src); return batched && noPerTodoUpsert; } },
@@ -5331,7 +5396,10 @@ function _selfTestCases() {
5331
5396
  l = cap(['node', 'h', 'lesson', 'save', 'JSON계약 L', '--json', '--tag', 'tg'], () => lessonSave(tmp, 'JSON계약 L'));
5332
5397
  r = cap(['node', 'h', 'rule', 'add', 'JSON계약 R', '--json', '--trigger', 'every-session'], () => ruleAdd(tmp, 'JSON계약 R'));
5333
5398
  rDup = cap(['node', 'h', 'rule', 'add', 'JSON계약 R', '--json', '--trigger', 'every-session'], () => ruleAdd(tmp, 'JSON계약 R'));
5334
- } catch (e) { return false; } finally { process.stdout.write = _w; process.argv = save; process.exitCode = savedExit; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
5399
+ } catch (e) { return false; } finally {
5400
+ process.stdout.write = _w; process.argv = save; process.exitCode = savedExit;
5401
+ _stRemoveInProcessFixture(tmp);
5402
+ }
5335
5403
  const taskJ = !!t && t.ok === true && /^T-\d{4}/.test(String(t.id)) && t.status === 'requested' && t.request === 'JSON계약 T';
5336
5404
  const decJ = !!d && d.ok === true && d.title === 'JSON계약 D';
5337
5405
  const lesJ = !!l && l.ok === true && l.text === 'JSON계약 L' && l.tag === 'tg';
@@ -5983,7 +6051,10 @@ function _selfTestCases() {
5983
6051
  { name: '14th 버그헌트 (UR-0181): MCP _bumpMcpUsage 를 unknown-tool 검증 후로 이동(unknown tool 임의경로 쓰기 차단) (1.12.2)', run: () => {
5984
6052
  const src = read(__filename);
5985
6053
  // _bumpMcpUsage 호출이 unknown-tool 가드(cliArgs === null return) "뒤"에 위치 — generic 서버 path-타게팅은 유지(취약점 아님), unknown tool 쓰기만 차단.
5986
- return /if \(cliArgs === null\) return send[\s\S]{0,400}?_bumpMcpUsage\(targetPath, name\)/.test(src);
6054
+ const dispatch = src.slice(src.indexOf('function ' + 'mcpServeCmd('));
6055
+ const unknownGuard = dispatch.indexOf('if (cliArgs === null) return send');
6056
+ const usageWrite = dispatch.indexOf('_bumpMcpUsage(targetPath, name)');
6057
+ return unknownGuard >= 0 && usageWrite > unknownGuard;
5987
6058
  } },
5988
6059
  { name: '14th 버그헌트 P2/P3 (UR-0182/0183): lazy TODO 파일별 추적 + session close 완료 정직성 advisory (1.12.3)', run: () => {
5989
6060
  const src = read(__filename);
@@ -10840,6 +10911,9 @@ function _p0014SecretBaselineOk() {
10840
10911
  const d3 = path.join(d, 'workspace-dir-migration');
10841
10912
  const canonicalDir = path.join(d3, '.leerness');
10842
10913
  fs.mkdirSync(canonicalDir, { recursive: true });
10914
+ // This is a migrated workspace, not an unowned directory containing an
10915
+ // arbitrary fixture file. Establish the same ownership marker as migration.
10916
+ fs.writeFileSync(path.join(canonicalDir, 'HARNESS_VERSION'), VERSION + '\n');
10843
10917
  const migratedRel = '.leerness/migrated-fixture.test.js';
10844
10918
  const migratedFile = path.join(d3, migratedRel);
10845
10919
  const oldKey = 'sk-proj-MigratedFixtureKey1234567890abcd';
@@ -11487,6 +11561,8 @@ function commandsCmd(root) {
11487
11561
  { cmd: 'roles list|set|unset|catalog|suggest|verify|validate', desc: '모델별 역할 부여 + fail-closed 저장소 검증 — 1.9.270/T-0171', descEn: 'assign roles and validate the role store fail-closed — 1.9.270/T-0171' },
11488
11562
  { cmd: 'capabilities [--json]', desc: '권한·보안 표면 공개 (무엇을 하는지 + opt-out + 주의 명령) — 1.9.272', descEn: 'disclose the permission/security surface (what it does + opt-outs + commands to watch) — 1.9.272' },
11489
11563
  { cmd: 'state show|start|record|verify|handoff', desc: '.leerness/ JSON 상태 substrate (에이전트 간 인수인계 표준) — 1.9.278', descEn: '.leerness/ JSON state substrate (cross-agent handoff standard) — 1.9.278' },
11564
+ { cmd: 'state inspect [path] [--json]', desc: '5-scope 현재/제안 경로와 기존 상태 목록 — 읽기 전용, 이동·활성화 없음', descEn: 'five-scope current/proposed paths and legacy inventory — read-only, no migration or activation' },
11565
+ { cmd: 'state compatibility [path] [--json]', desc: '관측한 runtime layout의 쓰기 호환성 — 읽기 전용, 이동·활성화 없음', descEn: 'observed runtime-layout write compatibility — read-only, no migration or activation' },
11490
11566
  { cmd: 'adapter <tool>|list [--dry-run]', desc: '도구별 지침/.mcp.json 선택 생성 (claude/cursor/codex/goose/...) — 1.9.280', descEn: 'generate per-tool instructions/.mcp.json selectively (claude/cursor/codex/goose/...) — 1.9.280' },
11491
11567
  { cmd: 'ci init [path] [--force]', desc: 'PR 마다 leerness gate 실행하는 GitHub Actions 워크플로 생성 (.github/workflows/leerness-gate.yml) — 1.9.444', descEn: 'create a GitHub Actions workflow that runs leerness gate on every PR (.github/workflows/leerness-gate.yml) — 1.9.444' },
11492
11568
  { cmd: 'export|prompt --target <agent>', desc: 'adapter 별칭 — 도구별 지침/계약 파일 생성 (claude/cursor/codex/agents-md/...) — 1.9.448', descEn: 'adapter alias — generate per-tool instruction/contract files (claude/cursor/codex/agents-md/...) — 1.9.448' },
@@ -12181,6 +12257,7 @@ const _OBSERVATION_ONLY_SUBCOMMANDS = new Map([
12181
12257
  ['lease', new Set(['list', 'check'])],
12182
12258
  ['roles', new Set(['validate'])],
12183
12259
  ['role', new Set(['validate'])],
12260
+ ['state', new Set(['inspect', 'compatibility'])],
12184
12261
  ]);
12185
12262
  function _cliMutationClass(args, cmd) {
12186
12263
  if (cmd === 'mcp') return 'observation-only'; // server startup must not mutate its incidental cwd
@@ -12195,6 +12272,88 @@ function _cliMutationClass(args, cmd) {
12195
12272
  }
12196
12273
  function _mustRemainReadOnly(args, cmd) { return _cliMutationClass(args, cmd) === 'observation-only'; }
12197
12274
 
12275
+ // 시작 bookkeeping도 실제 handler의 project target을 사용한다. 제목/출력 파일을
12276
+ // 디렉터리라는 이유만으로 project로 추측하거나, 명시 target 옆의 cwd를 쓰지 않는다.
12277
+ function _runtimeProjectRoot(args, cmd) {
12278
+ const sub = args[1];
12279
+ const cwd = process.cwd();
12280
+ const explicit = arg('--path', null);
12281
+ const at = index => typeof args[index] === 'string' && !args[index].startsWith('-') ? args[index] : null;
12282
+ const choose = (positional, positionalFirst = false) => absRoot(positionalFirst
12283
+ ? positional || explicit || cwd : explicit || positional || cwd);
12284
+ const global = new Set(['mcp', 'about', 'identity', 'help', 'which', 'doctor', 'selftest', 'self-test',
12285
+ 'install-safety', 'lens', 'route', 'path', 'path-setup']);
12286
+ if (global.has(cmd) || (cmd === 'adapter' && has('--global')) || !_isKnownTopLevelCommand(cmd)) return null;
12287
+ if (cmd === 'mode') {
12288
+ const root = _modeRoot(args);
12289
+ // Invalid mode targets already have a locale-independent path diagnostic.
12290
+ // They cannot own bookkeeping or a workspace, so leave validation to it.
12291
+ try { if (exists(root) && !fs.statSync(root).isDirectory()) return null; } catch {}
12292
+ return root;
12293
+ }
12294
+
12295
+ // These handlers own files rather than a project target.
12296
+ if (cmd === 'contract' && sub === 'verify') return null;
12297
+ if ((cmd === 'scan' && sub === 'secrets') || (cmd === 'encoding' && sub === 'check')) {
12298
+ const root = choose(at(2));
12299
+ try { if (exists(root) && !fs.statSync(root).isDirectory()) return null; } catch {}
12300
+ return root;
12301
+ }
12302
+ if (cmd === 'self' && sub === 'migrate') return null;
12303
+ if (cmd === 'migrate') {
12304
+ if (has('--guide') || sub === 'guide') return null;
12305
+ return choose(['audit', 'apply', 'plan'].includes(sub) || has('--audit') ? at(2) : at(1));
12306
+ }
12307
+ // install's first positional is a SKILL.md / URL / source directory.
12308
+ if (cmd === 'install') return choose();
12309
+ // plan's positional contracts are intentionally different by subcommand.
12310
+ if (cmd === 'plan') {
12311
+ if (sub === 'init') return choose(at(2), true);
12312
+ return choose(sub === 'list' ? at(2) : null);
12313
+ }
12314
+ if (cmd === 'decision' && sub === 'list') return choose(at(2));
12315
+ if (cmd === 'context' && sub === 'budget') return choose(_taskPositionalPath(args, 2));
12316
+ if (cmd === 'parent') return choose(args.slice(2).find(value => value && !value.startsWith('-')));
12317
+ if (cmd === 'memory' && sub === 'archive' && args[2] === 'list') return choose(at(3));
12318
+ if (cmd === 'skill') {
12319
+ if (sub === 'list') return choose(at(2));
12320
+ if (sub === 'impact') return choose(at(2), true);
12321
+ if (sub === 'lint') return choose(at(2) && /^([A-Za-z]:[\\/]|\/|\.\.?[\\/])/.test(at(2)) ? at(2) : null);
12322
+ return choose();
12323
+ }
12324
+ // Keep invalid operands for the handler to diagnose without bookkeeping.
12325
+ if (cmd === 'bugfix') {
12326
+ if (!sub || sub === 'list') return choose(_taskPositionalPath(args, sub ? 2 : 1));
12327
+ if (['start', 'receipt', 'drop'].includes(sub)) {
12328
+ const trailing = args.slice(3).filter(Boolean);
12329
+ if (!trailing.length) return choose();
12330
+ if (trailing.length !== 1 || explicit || !/^([A-Za-z]:[\\/]|\\\\|\/|\.\.?[\\/])/.test(trailing[0])) return null;
12331
+ return choose(trailing[0]);
12332
+ }
12333
+ return choose();
12334
+ }
12335
+
12336
+ if (['health', 'whats-new', 'benchmark'].includes(cmd)) return choose(at(1), true);
12337
+ if (['drift', 'usage', 'adapter', 'glossary', 'env'].includes(cmd)
12338
+ || (cmd === 'reuse' && sub === 'autodetect')
12339
+ || (cmd === 'release' && ['bump', 'publish', 'pack', 'sync-main', 'cleanup', 'channel'].includes(sub))) return choose(at(2), true);
12340
+
12341
+ const first = new Set(['init', 'update', 'status', 'verify', 'debug', 'audit', 'check',
12342
+ 'handoff', 'reuse-map', 'gate', 'verify-code', 'retro', 'insights', 'sessions', 'context',
12343
+ 'round-history', 'milestones', 'pulse', 'migrate-workspace-dir', 'graph', 'setup-agents']);
12344
+ if (first.has(cmd) || (cmd === 'roadmap' && sub !== 'auto')) return choose(at(1));
12345
+ const second = new Set(['lazy', 'hook', 'session', 'self', 'readme', 'consistency', 'auto-update']);
12346
+ const secondRoutes = new Set(['release cadence', 'team list', 'feature list', 'runs list',
12347
+ 'setup agents', 'ci init', 'ci ', 'memory status', 'ui consistency']);
12348
+ if (second.has(cmd) || secondRoutes.has(`${cmd} ${sub || ''}`)) return choose(at(2));
12349
+ const shapedFirst = new Set(['lessons', 'resume', 'session-resume', 'anchors', 'tech', 'library',
12350
+ 'dashboard', 'commands', 'py-check', 'capabilities', 'security-surface']);
12351
+ if (shapedFirst.has(cmd)) return choose(_taskPositionalPath(args, 1));
12352
+ const shapedSecond = new Set(['task', 'decision', 'lesson', 'requests', 'enforce', 'integrity', 'feature']);
12353
+ if (shapedSecond.has(cmd) || (cmd === 'rule' && sub === 'add')) return choose(_taskPositionalPath(args, 2));
12354
+ return choose();
12355
+ }
12356
+
12198
12357
  function _autoMigrateLegacyWorkspace(args, cmd) {
12199
12358
  if (process.env.LEERNESS_NO_AUTO_WORKSPACE_MIGRATION === '1') return true;
12200
12359
  if (has('--dry-run') || cmd === 'migrate-workspace-dir' || cmd === 'workspace-dir') return true;
@@ -12202,18 +12361,14 @@ function _autoMigrateLegacyWorkspace(args, cmd) {
12202
12361
  // Invalid commands must be observationally pure. In particular, a typo in
12203
12362
  // a legacy project may not rename project memory before reporting the typo.
12204
12363
  if (!_isKnownTopLevelCommand(cmd)) return true;
12205
- const candidates = [];
12206
- const explicit = arg('--path', null);
12207
- if (typeof explicit === 'string' && explicit.trim()) candidates.push(explicit);
12208
- else candidates.push(process.cwd());
12364
+ const targetRoot = _runtimeProjectRoot(args, cmd);
12365
+ const candidates = targetRoot ? [targetRoot] : [];
12209
12366
  // Never interpret arbitrary command content as a project to mutate. Topics,
12210
12367
  // titles and output paths may all be path-shaped (for example
12211
12368
  // `brainstorm ./legacy-design`); scanning every positional token used to
12212
12369
  // migrate those unrelated directories before command dispatch. Positional
12213
- // project targets can be migrated explicitly with `migrate-workspace-dir`,
12214
- // while normal automatic migration is limited to cwd and the unambiguous
12215
- // global `--path` option. When it is present, the unrelated process cwd is
12216
- // not another migration target.
12370
+ // project targets follow the same selector as command dispatch. The unrelated
12371
+ // process cwd is never added as another target when a project is specified.
12217
12372
  const seen = new Set();
12218
12373
  for (const candidate of candidates) {
12219
12374
  let root;
@@ -12238,19 +12393,8 @@ function _autoMigrateLegacyWorkspace(args, cmd) {
12238
12393
  }
12239
12394
 
12240
12395
  function _guardLinkedWorkspaceTargets(args) {
12241
- const candidates = [process.cwd()];
12242
- const explicit = arg('--path', null);
12243
- if (typeof explicit === 'string' && explicit.trim()) candidates.push(explicit);
12244
- // Positional project arguments vary by command. Inspecting an existing
12245
- // directory is read-only, so include every such token rather than risk a
12246
- // hardcoded writer following `<project>/.leerness` outside the project.
12247
- for (const token of (args || []).slice(1)) {
12248
- if (typeof token !== 'string' || token.startsWith('-')) continue;
12249
- try {
12250
- const candidate = absRoot(token);
12251
- if (exists(candidate) && fs.statSync(candidate).isDirectory()) candidates.push(candidate);
12252
- } catch {}
12253
- }
12396
+ const root = _runtimeProjectRoot(args, args[0] || 'init');
12397
+ const candidates = root ? [root] : [];
12254
12398
  const seen = new Set();
12255
12399
  for (const candidate of candidates) {
12256
12400
  let state;
@@ -15563,6 +15707,8 @@ const _STRICT_COMMAND_FLAGS = {
15563
15707
  'lease list': { allowed: ['--session', '--all'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'lease list [--session <key>] [--all] [--json]' },
15564
15708
  'lease check': { allowed: ['--session'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'lease check <file> [--session <key>] [--json]' },
15565
15709
  'roles validate': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'roles validate [--path .] [--json]' },
15710
+ 'state inspect': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'state inspect [path] [--path <path>] [--json]' },
15711
+ 'state compatibility': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'state compatibility [path] [--path <path>] [--json]' },
15566
15712
  'roles set': { allowed: ['--provider', '--to', '--model', '--model-family', '--policy', '--fallback-policy', '--candidate', '--candidate-family', '--remove-candidate', '--clear-candidates', '--persona', '--force'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'roles set <role> --provider <id> [--model <id>] [--policy strict|balanced|continuity]' },
15567
15713
  'roles unset': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'roles unset <role> [--path .] [--json]' },
15568
15714
  'roles suggest': { allowed: ['--apply'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'roles suggest [--apply] [--path .] [--json]' },
@@ -15644,6 +15790,13 @@ function _validateRolesCommandShape(cmd, args) {
15644
15790
  }
15645
15791
 
15646
15792
  function _validateCommandFlags(cmd, args) {
15793
+ if (cmd === 'state' && ['inspect', 'compatibility'].includes(args[1])) {
15794
+ if (!_rejectDuplicateValueFlags(['--path'])) return false;
15795
+ if (args.length > 3) {
15796
+ failJson(has('--json'), 'too_many_arguments', `Usage: state ${args[1]} [path] [--path <path>] [--json]`);
15797
+ return false;
15798
+ }
15799
+ }
15647
15800
  if (cmd === 'lease' && !_rejectDuplicateValueFlags(['--session', '--ttl', '--note', '--path'])) return false;
15648
15801
  if (cmd === 'agents' && args[1] === 'route'
15649
15802
  && !_rejectDuplicateValueFlags(['--tier', '--approved-by', '--reason', '--path'])) return false;
@@ -15656,6 +15809,7 @@ function _validateCommandFlags(cmd, args) {
15656
15809
  if (cmd === 'intent' && args[1] === 'expand') route = 'intent expand';
15657
15810
  if (cmd === 'lease') route = `lease ${args[1] == null ? 'list' : args[1]}`;
15658
15811
  if (roleSub) route = `roles ${roleSub === 'remove' || roleSub === 'rm' ? 'unset' : roleSub}`;
15812
+ if (cmd === 'state' && ['inspect', 'compatibility'].includes(args[1])) route = `state ${args[1]}`;
15659
15813
  const cfg = _STRICT_COMMAND_FLAGS[route];
15660
15814
  if (!cfg) return true;
15661
15815
  return _rejectUnknownFlags(cfg.allowed, cfg.usage, { globals: cfg.globals });
@@ -25790,6 +25944,9 @@ function _bumpUsageLocked(root, cmdName) {
25790
25944
 
25791
25945
  // 1.9.70: MCP tools/call 자동 사용 통계 — 도구별 호출 카운트
25792
25946
  function _bumpMcpUsage(root, toolName) {
25947
+ return withRuntimeWrites(root, () => _bumpMcpUsageBody(root, toolName));
25948
+ }
25949
+ function _bumpMcpUsageBody(root, toolName) {
25793
25950
  try {
25794
25951
  // 1.36.76 (9차 헌트 #2a): read-only 호출조차 텔레메트리가 대상 프로젝트에 디렉토리·파일을 만들었다
25795
25952
  // (존재하지 않는 대상까지 통째로 생성). 통계는 이미 초기화된 하네스에만 기록 — 읽기가 대상을 변형하지 않는다.
@@ -27157,7 +27314,11 @@ function mcpServeCmd(root) {
27157
27314
  const _mcpReadOnly = !!(_toolDef && _toolDef.requiredTier === 'read-only');
27158
27315
  // A read-only tool may not mutate the target merely to count itself.
27159
27316
  // Writable tools retain existing per-project MCP telemetry.
27160
- if (!_mcpReadOnly) { try { _bumpMcpUsage(targetPath, name); } catch {} }
27317
+ if (!_mcpReadOnly) {
27318
+ require('../lib/runtime-layout').assertRuntimeWriteAllowed(targetPath);
27319
+ try { _bumpMcpUsage(targetPath, name); }
27320
+ catch (error) { if (error.code === 'E_RUNTIME_LAYOUT_INCOMPATIBLE') throw error; }
27321
+ }
27161
27322
  // 1.9.288 (Codex gpt-5.5 리뷰 #1 수렴): MCP 도구도 policy enforce 적용 — read-only enforce 시 write 도구 차단.
27162
27323
  // 이전: _policyEnforce 는 agents multi --execute 한 곳뿐 → MCP state_start 등이 정책 우회하고 .leerness 기록.
27163
27324
  // cliArgs(실제 실행 명령) 로 required tier 판정 → enforce ON 이고 초과 시 JSON-RPC error 반환(실행 안 함).
@@ -28893,6 +29054,29 @@ function _updateRun(root, id, mutator) {
28893
29054
  }
28894
29055
  // 1.9.328 (UR-0025): _splitList → lib/pure-utils.js 로 이동 (순수 콤마 리스트 분할, require 사용).
28895
29056
 
29057
+ // Inspect is intentionally independent of all legacy state loaders and writers.
29058
+ function stateInspectCmd(root) {
29059
+ try {
29060
+ const { inspectState, formatStateInspection } = require('../lib/state-inspect');
29061
+ const report = inspectState(root);
29062
+ if (has('--json')) return log(JSON.stringify(report, null, 2));
29063
+ // Do not read a project manifest merely to render a metadata-only diagnostic.
29064
+ return log(formatStateInspection(report, _inspectionUiLang()));
29065
+ } catch (error) {
29066
+ const known = error.name === 'StatePathError' || error.name === 'WorkspaceDirectoryError';
29067
+ return failJson(has('--json'), known ? error.code : 'state_inspection_failed',
29068
+ known ? error.message : 'State inspection failed; no files were changed.');
29069
+ }
29070
+ }
29071
+
29072
+ function stateCompatibilityCmd(root) {
29073
+ const report = require('../lib/runtime-layout').inspectRuntimeCompatibility(root);
29074
+ if (has('--json')) log(JSON.stringify(report, null, 2));
29075
+ else log(`Runtime compatibility: ${report.writeDisposition} (${report.reasonCode})\nScope: ${report.scope}\nWriter protocol: ${report.supportedWriterProtocol}\nActivation: unsupported; legacy storage is unchanged.`);
29076
+ if (!report.compatible) process.exitCode = 1;
29077
+ return report;
29078
+ }
29079
+
28896
29080
  // leerness state <show|start|record|verify|handoff>
28897
29081
  // 1.9.292 (UR-0031): get_project_context — 외부 에이전트 온보딩용 단일 집약 컨텍스트.
28898
29082
  // 1콜로 현재 작업/미답 요청/최근 결정/활성 룰/next-actions/memory/프로젝트 의도를 구조화 회수.
@@ -30401,6 +30585,7 @@ async function _agentRepl(root, opts) {
30401
30585
  };
30402
30586
  return new Promise(resolve => {
30403
30587
  rl.on('line', async (line) => {
30588
+ try { await withRuntimeWrites(root, async () => {
30404
30589
  const input = line.trim();
30405
30590
  _lastCycleLines = 0; // 1.9.189: 사용자 입력 시 cycle overwrite 추적 reset
30406
30591
  if (!input) { promptWithStatus(); return; }
@@ -30529,8 +30714,14 @@ async function _agentRepl(root, opts) {
30529
30714
  } catch {}
30530
30715
  }
30531
30716
  promptWithStatus();
30717
+ }, { fresh: true }); }
30718
+ catch (error) { fail(error.message); rl.close(); resolve(); }
30719
+ });
30720
+ rl.on('close', () => {
30721
+ try { withRuntimeWrites(root, saveSession, { fresh: true }); }
30722
+ catch (error) { fail(error.message); }
30723
+ resolve();
30532
30724
  });
30533
- rl.on('close', () => { saveSession(); resolve(); });
30534
30725
  });
30535
30726
  }
30536
30727
 
@@ -31793,6 +31984,8 @@ SETUP & UPDATE
31793
31984
 
31794
31985
  STATUS & DIAGNOSTICS
31795
31986
  status [path] Install status (files present)
31987
+ state inspect [path] [--json] Read-only five-scope paths/inventory; no migration or runtime activation
31988
+ state compatibility [path] [--json] Read-only observed-layout write admission; no activation
31796
31989
  health [path] | doctor [--json] health: project state · doctor: CLI install/environment (no path arg)
31797
31990
  verify [path] Required-file verification
31798
31991
  which [--json] Resolve current binary/version (npm cache conflicts)
@@ -31874,6 +32067,8 @@ function help() {
31874
32067
  log(`Leerness v${VERSION}\n\nUsage:\n leerness init [path] [--language auto|ko|en] [--skills recommended|all|a,b]\n leerness migrate [path] [--dry-run] [--force]\n leerness update [path] [--check|--yes|--force|--from <tarball>]\n leerness auto-update install [path]\n leerness status [path]\n leerness verify [path]\n leerness debug [path]\n leerness audit [path]\n leerness check [path]\n leerness scan secrets [path]\n leerness encoding check [path]\n leerness lazy detect [path]\n leerness memory search "query" [--limit 5]\n leerness handoff [path] [--all-apps] [--include p1,p2] [--since 24h|3d] [--compact] [--json] # 1.9.17-22 워크스페이스 (--compact: LLM 시스템 프롬프트용 1줄 요약)\n leerness orchestrate "<목표>" [--agents N] [--model qwen2.5:7b-instruct] [--retry-on-fail K] # 1.9.22 Ollama opt-in (LEERNESS_OLLAMA_BASE_URL 필요)\n leerness llm-bench record --score N --model X [--label L] [--tokens T] # 1.9.22 LLM 벤치 히스토리 누적\n leerness deps <capability> [--run-tests] [--json] # 1.9.24 depends-on 역방향 추적 + 자동 회귀 sweep\n leerness memory search "키" [--include-code] # 1.9.25 소스 코드 본문도 검색 (모순 감지 핵심)\n leerness brainstorm "주제" [--include-code] # 1.9.25 코드 본문 hits 포함\n leerness register-pending "<요청>" [--agent X] [--note Y] # 1.9.25 다중 세션 in-progress 즉시 등록\n leerness optimism-check <T-ID> [--json] # 1.9.26/27 낙관적 표시 감지 (1.9.27: 10 카테고리 + URL/메서드 매핑 + 신뢰도 점수)\n leerness persona list|show <id>|add <id> # 1.9.29 페르소나 카탈로그 (보안/성능/UX/testing/docs 5종 내장)\n leerness review <file> --persona <id1,id2,...> # 1.9.29 도메인 페르소나 리뷰 프롬프트 자동 생성\n leerness agents list|check|quota # 외부 AI CLI 다축 가용성 확인\n leerness agents resolve \"<task>\" --role <role> [--preset strict|balanced|continuity] # 역할 유지 + 대체 선택지\n leerness agents fallback provider|session|direct|hold \"<task>\" --role <role> [--session-provider P --session-model M --session-model-family F] # 명시 선택·기록\n leerness agents record completed|failed|reviewed|validated \"<summary>\" [--task T-ID --role R --to P --model M --model-family F --agent P --agent-model M --agent-model-family F --evidence E]\n leerness agents history [--limit 20] # 실행자·검수자·결과 provenance\n leerness agents dispatch \"<task>\" --to <id> # 활성 CLI 대상 실행 명령 생성 (실 호출 X, 사용자 실행)\n leerness agents multi "<task>" [--only c1,c2] [--write] [--execute] [--timeout 60] # 1.9.152/156 활성 N개 일괄 dispatch (--execute: 실 spawn + consensus)\n leerness provider list|add|remove [args] # 1.9.157 Provider Registry — 사용자 정의 CLI provider 동적 추가 (OpenRouter/Bedrock 흡수)\n leerness agents dispatch "<task>" --multi # 1.9.152 multi 모드 alias (또는 --to all)\n leerness setup-agents [path] [--yes|--no-setup-agents] # 1.9.32 sub-agent CLI 인터랙티브 설정 (.env + 미설치 자동 설치)\n leerness init [path] [--no-stale-check] # 1.9.33 npx 캐시 함정 — 옛 버전 자동 경고 (끄려면 --no-stale-check)\n leerness which [--json] # 1.9.164 진단: 현재 실행 경로/버전 + npm 캐시 + PATH 후보 (구버전 충돌 해결)\n leerness selftest [--json] # 1.9.258 코어 함수 무결성 자가 검증 (설치 손상/부분설치 감지, CI 친화 exit 1)\n leerness shell-guard "<command>" [--json] # 1.9.260 터미널 명령 셸 호환성 린터 (PowerShell 5.1 && 미지원 등 실행 전 감지, UR-0020)\n leerness shell-guard --record --cmd "..." --exit N # 1.9.260 실패한 터미널 명령 기록 → 다음 분석 시 회수\n leerness path-setup [--apply] [--json] # 1.9.254 leerness CLI PATH 자동 등록 (npm global bin 미등록 시)\n leerness web check|screenshot|extract <url> [--out file.png] [--selector "css"] # 1.9.165 playwright bridge (opt-in: npm i -g playwright + permissions.browser)\n leerness pc check|click|type|screenshot [--x N --y N] [--text "s"] [--out f.png] # 1.9.166 robotjs/nut-tree bridge (opt-in: npm i -g robotjs + permissions.mouse/keyboard, ⚠ full 모드 권장)\n leerness lsp check|symbols|references <file/name> [--in dir] [--json] # 1.9.167 LSP 어댑터 MVP (typescript opt-in + regex fallback, 코드 인텔리전스)\n leerness review-request "<request>" [--json] # 1.9.176 사용자 요청 사전 검토 (충돌/재사용/효율/권장 단계 — 사용자 명시)\n leerness contract verify <spec.md> <impl.js> [--json] # 1.9.35 명세 ↔ 구현 일치 검사 (함수/필드)\n leerness reuse autodetect [path] [--apply] [--json] # 1.9.35 src/*.js의 module.exports → reuse-map 후보 등록\n leerness audit [path] [--fix] # 1.9.35 --fix: session-handoff/current-state 자동 갱신\n leerness verify-claim <T-ID> ... [--strict-claims] # 1.9.26 verify-claim에 낙관적 표시 자동 검사 통합
31875
32068
  leerness lens [${_lensDomainList()}] [--json] # 1.18.3/1.36.97 분야별 자기질문 품질 렌즈 (database·contract·recovery·observability 심화 · axes 8축 경량 — 완료 선언 전 자가 점검)\n leerness library [show|page] [path] [--json] [--ai] # 1.36.98 재사용 인벤토리 — 컴포넌트·디자인 토큰 추출 (page: 오프라인 HTML · --ai: 에이전트용 압축)\n leerness reuse-map [path] [--all-apps] [--include p1,p2] [--strict-elements] [--json] # 1.9.18 중복/잠재중복/depends-on\n leerness verify-claim <T-ID> [--path .] [--run-tests] [--json] # 1.9.18-20 evidence 자동 검증 (1.9.20: scenes/scripts 등 도메인 폴더 + jest/mocha 파싱)\n leerness verify-code [path] [--build] [--bench] # 1.9.20 --bench: scripts.bench 추가 실행 + evidence 누적\n leerness session close [path]\n leerness route <task-type>\n leerness self check [path]\n leerness readme sync [path]\n leerness consistency check [path]\n leerness consistency merge-design-guide [path]\n leerness plan show|init|add|drop|progress|sync [args]\n leerness task list|add|update|drop|fix-evidence|relink [args]\n leerness skill list|info <name>\n leerness skill learn <id> --doc <url> --command "..." --capability "..." [--note ...]\n leerness skill use <id> [--note ...]\n leerness skill optimize <id> --before "..." --after "..." [--note ...]\n leerness skill remove <id>\n leerness skill consolidate [--threshold 0.3]\n leerness gate [path] # verify+audit+scan+encoding+lazy
31876
32069
  leerness lease acquire|release|list|check [file|lease-id] [--session KEY] [--ttl SEC] [--json] # exact-file opt-in 협업 lease
32070
+ leerness state inspect [path] [--json] # 5-scope 경로/목록 읽기 전용 진단 — 이동·활성화 없음
32071
+ leerness state compatibility [path] [--json] # 관측 layout 쓰기 호환성 읽기 전용 진단 — 활성화 없음
31877
32072
  leerness retro [path] [--days 7] [--all-apps] [--include p1,p2] [--json] # 회고 (1.9.13~1.9.16)
31878
32073
  leerness insights [path] [--all-apps] [--include p1,p2] [--json] # 누적 통계 (1.9.13~1.9.16)
31879
32074
  leerness brainstorm "<주제>" [--all-apps] [--include p1,p2] [--json] # 브레인스토밍 (1.9.13~1.9.16)
@@ -31898,7 +32093,7 @@ function help() {
31898
32093
  leerness release publish [--dry-run] [--pack] [--git-push] [--gh-release] [--gh-pages] [--gh-pages-src file] [--npm-publish] [--auto] # 통합 배포 (1.9.8 + 1.9.10)\n leerness impact <target> [--all] # 변경 전 영향 분석 (기본 strong, --all로 weak 포함)\n leerness reuse find <query> # 기존 자원 검색 (재귀 안내)\n leerness reuse register <name> --where <p> --kind component|hook|util|api [--note ...]\n leerness ui consistency [path] [--strict] [--fail-on-violation]\n leerness graph [path] [--out <file>] # mermaid 의존성 그래프\n leerness graph [path] --html [--out <file>] # 온톨로지 그래프 HTML(leerness.html) 자동생성 — 노드 클릭으로 하네스 조회 (1.34.3)\n leerness guide [target] # impact + reuse + ui consistency 통합 가이드\n leerness migrate audit|apply|plan [path] [--json] [--yes] # 크로스버전 마이그레이션 진단/적용(canonical 백필)/플랜(임시폴더 비교) (UR-0075, 1.9.356~358)\n leerness migrate --guide # AI 에이전트용 크로스버전 마이그레이션 가이드 (1.9.355)\n leerness install-safety [--json] # 설치 안전 프로필 — 0 런타임 deps / 0 install-script (1.9.359)\n leerness capabilities [--json] # 권한·보안 표면 공개 (1.9.272)\n leerness feature add|link|impact|list|show # 기능 그래프(feature-graph) 추적\n leerness permissions list|set # agent 권한 모드 (1.9.174)\n leerness creds list|register|check|refresh # 크리덴셜 메타 추적 (값 미저장)\n leerness incident list|show|handle · webhook serve · deploy auto · runs list|show # 운영(ops)\n leerness whats-new [path] # 최근 버전 변경 요약\n leerness team list|add|show|remove|preview|deploy <id> [--personas a,b --members claude,codex --schedule every-session --task "..." --deploy "<배포명령>" --yes] # 에이전트 팀 정의/미리보기/배포 — UR-0073 A~D, opt-in · 배포는 2중게이트(--yes + LEERNESS_TEAM_DEPLOY=1)\n leerness release channel|cadence [path] [--json] # 릴리스 채널 정책 + 빈도 진단 (UR-0074 케이던스 가시화, 1.9.275/374)\n leerness commands [--json] # 전체 명령 전수 목록 (누락 없이 이 명령으로 확인)\n`);
31899
32094
  }
31900
32095
 
31901
- async function main() {
32096
+ async function main(runtimeEntered = false) {
31902
32097
  const args = nonFlagArgs(); const cmd = args[0] || 'init';
31903
32098
  if (has('--version') || has('-v')) {
31904
32099
  // 1.9.32: --version은 순수 버전만 (CI/script 친화). 배너는 --banner 시.
@@ -31910,6 +32105,20 @@ async function main() {
31910
32105
  if (!_rejectUnregisteredFlags(cmd)) return;
31911
32106
  if (!_rejectMalformedFlagForms(cmd, args)) return;
31912
32107
  if (!_validateCommandFlags(cmd, args)) return;
32108
+ // Exact early dispatch is both a no-write boundary and a latency boundary:
32109
+ // no workspace migration/guard rescans, usage locks, presence, or stale checks.
32110
+ if (cmd === 'state' && args[1] === 'inspect') {
32111
+ const explicit = arg('--path', null);
32112
+ return stateInspectCmd(explicit !== null ? explicit : args[2] === undefined ? process.cwd() : args[2]);
32113
+ }
32114
+ if (cmd === 'state' && args[1] === 'compatibility') {
32115
+ const explicit = arg('--path', null);
32116
+ return stateCompatibilityCmd(explicit !== null ? explicit : args[2] === undefined ? process.cwd() : args[2]);
32117
+ }
32118
+ const runtimeRoot = _runtimeProjectRoot(args, cmd);
32119
+ if (!runtimeEntered && runtimeRoot && !_mustRemainReadOnly(args, cmd)) {
32120
+ return withRuntimeWrites(runtimeRoot, () => main(true));
32121
+ }
31913
32122
  // 값이 존재하지만 의미가 잘못된 --keep 도 legacy migration/write 전에 거부한다.
31914
32123
  // preview serve 의 --keep 은 route별 boolean 계약이므로 숫자 검증 대상이 아니다.
31915
32124
  if (!_commandBooleanValueFlags(cmd, args[1]).has('--keep')) {
@@ -32038,16 +32247,10 @@ async function main() {
32038
32247
  // 1.36.104: 'mcp' 를 제외한다. MCP 서버는 프로젝트 명령이 아니라 상주 프로세스라, 서버의 cwd 가 우연히
32039
32248
  // leerness 프로젝트면 거기에 `mcp: N` 이 쌓였다 — 사용자가 그 프로젝트에서 쓴 적 없는 '명령' 이 통계에 뜬다
32040
32249
  // (실측: 대상 프로젝트는 "사용 기록 없음", 무관한 cwd 는 "총 3회 호출"). 도구별 귀속은 _bumpMcpUsage 가 이미 한다.
32041
- if (process.env.LEERNESS_INTERNAL !== '1' && !_mustRemainReadOnly(args, cmd) && cmd !== 'usage' && cmd !== 'init' && cmd !== 'migrate' && cmd !== 'mcp' && cmd !== '--version' && cmd !== '--help') {
32250
+ if (runtimeRoot && process.env.LEERNESS_INTERNAL !== '1' && !_mustRemainReadOnly(args, cmd) && cmd !== 'usage' && cmd !== 'init' && cmd !== 'migrate' && cmd !== 'mcp' && cmd !== '--version' && cmd !== '--help') {
32042
32251
  try {
32043
32252
  // 1.9.352 (UR-0069 외부리뷰): usage 루트 — --path 우선, 없으면 args[1] 이 .leerness 보유 디렉토리일 때만 path(positional 보존), 아니면 cwd. (이전: args[1] 무조건 path 가정 → subcommand[decision add 등] root=cwd/add → .leerness 못 찾아 미집계)
32044
- const _pathArg = arg('--path', null);
32045
- let root;
32046
- if (_pathArg) root = absRoot(_pathArg);
32047
- else if (args[1] && !args[1].startsWith('-') && exists(path.join(absRoot(args[1]), '.leerness'))) root = absRoot(args[1]);
32048
- // 1.36.38 (codex 4차 #9): subcommand 형(예: memory status <B>)은 대상 경로가 args[2] — 종전엔 cwd(A)에 오귀속됐다.
32049
- else if (args[2] && !args[2].startsWith('-') && exists(path.join(absRoot(args[2]), '.leerness'))) root = absRoot(args[2]);
32050
- else root = absRoot(process.cwd());
32253
+ const root = runtimeRoot;
32051
32254
  if (exists(path.join(root, '.leerness'))) _bumpUsage(root, cmd);
32052
32255
  } catch {}
32053
32256
  }
@@ -33091,6 +33294,14 @@ async function main() {
33091
33294
  return;
33092
33295
  }
33093
33296
 
33297
+ const skillCatalog = (() => {
33298
+ const args = nonFlagArgs();
33299
+ if (require.main === module && args[0] === 'state' && ['inspect', 'compatibility'].includes(args[1])) {
33300
+ return _withBuiltinSource(BUILTIN_CATALOG);
33301
+ }
33302
+ return _loadSkillCatalog();
33303
+ })();
33304
+
33094
33305
  // 1.9.4 B: main 종료 후 exitCode를 명시적으로 process.exit으로 강제 (셸/wrapper 차 무시).
33095
33306
  // 1.9.255 (UR-0019 2단계): require.main 가드 — `require('harness.js')` 시 main() 미실행 (init 부작용 차단 + 내부 함수 단위 테스트 가능).
33096
33307
  // CLI 직접 실행 (node harness.js / npx leerness) 시에만 main() 호출. 기존 동작 100% 보존.
@@ -33098,6 +33309,26 @@ if (require.main === module) {
33098
33309
  main()
33099
33310
  .then(() => { if (process.exitCode && process.exitCode !== 0) process.exit(process.exitCode); })
33100
33311
  .catch(err => {
33312
+ if (err && err.code === 'E_RUNTIME_LAYOUT_INCOMPATIBLE') {
33313
+ const blockedArgs = nonFlagArgs();
33314
+ const reason = String(err.reasonCode || '');
33315
+ // 기존 workspace 오류의 기계 소비자 계약은 유지한다. 새 layout 오류만
33316
+ // runtime_layout_incompatible로 확장하고, 어느 경우에도 writer를 재실행하지 않는다.
33317
+ if (blockedArgs[0] === 'migrate-workspace-dir' && /^workspace_dir_/.test(reason)) {
33318
+ const current = inspectWorkspace(_runtimeProjectRoot(blockedArgs, blockedArgs[0]));
33319
+ const blockedReason = reason === 'workspace_dir_symlink' && current.canonical.isSymbolicLink
33320
+ ? 'canonical-workspace-symlink' : reason.replace(/_/g, '-');
33321
+ const report = { ok: false, blocked: true, blockedReason, errors: [err.message] };
33322
+ if (process.argv.includes('--json')) log(JSON.stringify(report, null, 2)); else fail(err.message);
33323
+ process.exit(1);
33324
+ }
33325
+ if (/^workspace_dir_/.test(reason)) {
33326
+ failJson(process.argv.includes('--json'), reason, err.message);
33327
+ process.exit(1);
33328
+ }
33329
+ failJson(process.argv.includes('--json'), 'runtime_layout_incompatible', err.message);
33330
+ process.exit(1);
33331
+ }
33101
33332
  // 1.36.144 (T-0117): dry-run 인데 쓰려다 멈춘 것은 크래시가 아니라 **계약 위반 보고**다.
33102
33333
  if (err && err.code === 'E_DRY_RUN_WRITE') {
33103
33334
  failJson(process.argv.includes('--json'), 'dry_run_unsupported',
@@ -33135,7 +33366,7 @@ if (require.main === module) {
33135
33366
  // _isSecretKey (시크릿 차단, 보안), compareVer (버전 비교, stale-check 정확성), parseHarnessVersion (버전 파싱).
33136
33367
  module.exports = {
33137
33368
  VERSION,
33138
- _cliMutationClass, _mustRemainReadOnly,
33369
+ _cliMutationClass, _mustRemainReadOnly, _runtimeProjectRoot,
33139
33370
  _npmGlobalBin, _dirInPath, _leernessResolvable, _pathDiagnose, _registerPath,
33140
33371
  _winPathPsScript, _unixPathBlock, pathSetupCmd,
33141
33372
  _isSecretKey, compareVer, parseHarnessVersion,
@@ -33146,7 +33377,7 @@ module.exports = {
33146
33377
  // 1.9.260: shell-guard — 셸 호환성 린터 (UR-0020) 순수 분석 함수 — 단위 테스트
33147
33378
  _shellGuardAnalyze, _detectShellCtx, shellGuardCmd,
33148
33379
  // 1.9.263: shell 실패 메모리 + 환경 버전 변동 (UR-0020 3단계) — handoff 통합 단위 테스트
33149
- _shellFailuresPath, _loadShellFailures, _recordShellFailure, _shellEnvDrift,
33380
+ _shellFailuresPath, _loadShellFailures, _recordShellFailure: projectWriter(_recordShellFailure), _shellEnvDrift,
33150
33381
  // 1.9.265: CLI 에이전트 슬래시 명령어 레지스트리 (UR-0021 1단계) — 단위 테스트
33151
33382
  AGENT_SLASH_COMMANDS, _agentSlashFile, _loadAgentSlashCommands, _recordAgentSlashCommands, _agentSlashHint, slashCommandsCmd,
33152
33383
  _parseSlashFromHelp, _probeAgentSlash, _refreshAgentSlashCommands,
@@ -33164,9 +33395,12 @@ module.exports = {
33164
33395
  // 1.9.276: init --minimal 파일 필터 (GPT-5.5 2차 리뷰) — 단위 테스트
33165
33396
  MINIMAL_SKIP_KEYS, coreFiles,
33166
33397
  // 1.9.278: .leerness/ 상태 스키마 (UR-0032, GPT-5.5 범용 하네스) — 단위 테스트
33167
- _newRunRecord, _leernessStateDir, _loadLeernessState, _saveLeernessState, _loadRun, _saveRun, stateCmd,
33398
+ _newRunRecord, _leernessStateDir, _loadLeernessState, _saveLeernessState, _loadRun, _saveRun,
33399
+ stateCmd: projectWriter(stateCmd), stateCompatibilityCmd,
33400
+ _withLock, _recordRun: projectWriter(_recordRun),
33401
+ _bumpMcpUsage,
33168
33402
  // 1.9.280: adapter (UR-0033) — 단위 테스트
33169
- ADAPTERS, _mcpJsonContent, _mergeMcpJson, adapterCmd,
33403
+ ADAPTERS, _mcpJsonContent, _mergeMcpJson, adapterCmd: projectWriter(adapterCmd),
33170
33404
  // 1.9.281: 권한 등급 (UR-0034) — 단위 테스트
33171
33405
  PERMISSION_TIERS, _tierRank, _requiredTier, _policyAllows, _loadPolicy, _savePolicy, _policyEnforce, policyCmd,
33172
33406
  // 1.9.285: 외부 OSS 재사용 게이트 (UR-0023) — 단위 테스트