leerness 1.36.101 → 1.36.102

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,28 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.102 — 2026-08-05 — 모르는 것을 안다고 말하지 않는다 · 준 경로에 적용한다
4
+
5
+ 1.36.101 의 명령 표면 감사에서 남겨 둔 P1 두 건을 닫는다. 둘 다 **재현부터 하고** 고쳤다.
6
+
7
+ ### `update --check` 가 자기 말을 뒤집었다
8
+
9
+ ```
10
+ npm leerness latest: (unavailable — 판정 보류, 다음 실행에서 재조회)
11
+ → up to date
12
+ ```
13
+
14
+ 바로 윗줄에서 *"모른다"* 고 해 놓고 아랫줄에서 *"최신"* 이라 단정했다. 조회 실패가 판정 분기까지 전달되지 않아, `latest = latest || VERSION` 이 자기 버전을 정답처럼 쓰고 있었다. 이제 결론이 셋으로 갈린다 — `migration_available` / `up_to_date` / **`unknown`**. 모르면 *"확인 불가 — 레지스트리를 조회하지 못했습니다"* 라고 말한다.
15
+
16
+ 같은 자리에서 **`--json` 이 사람용 텍스트를 내던 것**도 고쳤다(`# leerness update` · `Current:` · `Checking npm registry…` 가 그대로 섞여 JSON 파싱이 불가능했다). `--json` 은 `{verdict, registryReachable, latest, current, cli}` 만 낸다.
17
+
18
+ ### `plan init <경로>` 가 그 경로 대신 현재 디렉토리를 채웠다
19
+
20
+ 실측 재현: 대상 디렉토리는 1개 파일 그대로인데 **cwd 가 1개 → 72개**가 됐다. 홈에서 실행하면 홈이 워크스페이스가 된다. `plan` 그룹이 `--path` 만 읽고 positional 은 `list` 만 지원했는데, **파괴적인 `init` 이 그 갭에 있었다**. 이제 준 경로에 적용하고, 없는 경로는 만들지 않고 거부한다.
21
+
22
+ ### 가드
23
+
24
+ selftest 343 · **변이 6/6 사살**. 가드는 소스 grep 이 아니라 실제 실행으로 검사한다 — 조회 실패 시 결론 문구 · `--json` 파싱 가능 여부 · `plan init` 후 양쪽 디렉토리의 파일 수 · 없는 경로 거부까지 모두 명령을 돌려 확인한다(1.36.101 에서 소스 grep 가드가 세 번 무너진 뒤 세운 원칙).
25
+
3
26
  ## 1.36.101 — 2026-08-05 — 명령 표면 전수 감사: 경로 탈출로 남의 파일이 지워지고 있었다
4
27
 
5
28
  사용자 요청: *"leerness가 적용된 프로젝트에서 leerness 명령어가 의도한대로 동작하는지 테스트 및 디버그도 함께 진행."*
package/README.md CHANGED
@@ -122,7 +122,7 @@ MIT
122
122
  <!-- leerness:project-readme:start -->
123
123
  ## Leerness Project Harness
124
124
 
125
- 이 프로젝트는 Leerness v1.36.101 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.102 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
126
126
 
127
127
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
128
128
 
@@ -176,7 +176,7 @@ leerness memory restore decision <date|title>
176
176
 
177
177
  ### MCP server (외부 AI 통합)
178
178
 
179
- Leerness v1.36.101는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
179
+ Leerness v1.36.102는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
180
180
 
181
181
  ```jsonc
182
182
  // 카테고리별
@@ -197,7 +197,7 @@ Leerness v1.36.101는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
197
197
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
198
198
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
199
199
 
200
- 현재 누적: **v1.9.x → 1.36.101 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.102 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
201
201
 
202
202
  ### 성능 가이드
203
203
 
@@ -235,5 +235,5 @@ leerness release pack --close --auto-main-push
235
235
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
236
236
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
237
237
 
238
- Last synced by Leerness v1.36.101: 2026-08-05
238
+ Last synced by Leerness v1.36.102: 2026-08-05
239
239
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
34
34
  const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
35
35
  const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
36
36
 
37
- const VERSION = '1.36.101';
37
+ const VERSION = '1.36.102';
38
38
 
39
39
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
40
40
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -6199,7 +6199,7 @@ function _selfTestCases() {
6199
6199
  && (JSON.parse(read(path.join(nextApp, '.harness', 'tech-profile.json'))).current.ui || []).some(u => u.id === 'tailwind');
6200
6200
  } catch { uiOk = false; } finally { try { fs.rmSync(tmp3, { recursive: true, force: true }); } catch {} }
6201
6201
  return clar && shape && noDbUrl && reqOk && guards && uiOk && _p0013LibraryOk() && _p0088CurrentStatePreserved()
6202
- && _p0101SurfaceOk() && _p0101SkillIdOk() && _p0101PathStrictOk();
6202
+ && _p0101SurfaceOk() && _p0101SkillIdOk() && _p0101PathStrictOk() && _p0102HonestyOk();
6203
6203
  } },
6204
6204
  { name: '시크릿 스캐너 F-06 (1.36.56, 외부감사): 무명 확장자 소형 텍스트 스캔(이진 NUL 제외) + 같은 토큰 중복 보고 dedupe — 행위검사', run: () => {
6205
6205
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sc56_'));
@@ -7787,6 +7787,39 @@ function _p0101PathStrictOk() {
7787
7787
  // 표본이 조용히 줄어들면 가드가 약해진 걸 모른다 — 목록/표본 크기도 함께 못박는다.
7788
7788
  return bad.length === 0 && controlOk && _sample.length >= 7 && _PATH_STRICT_CMDS.length >= 20;
7789
7789
  }
7790
+ // 1.36.102 (명령 표면 감사 P1 2건): 회귀 가드.
7791
+ // ① update --check 가 레지스트리 조회에 실패하고도 "up to date" 라고 단정했다 —
7792
+ // 바로 윗줄에 "(unavailable — 판정 보류)" 를 찍어 놓고 아랫줄에서 결론을 뒤집는 자기모순이었다.
7793
+ // 그리고 --json 이 사람용 텍스트를 냈다(계약: 단일 유효 JSON).
7794
+ // ② plan init <경로> 가 positional 을 무시하고 cwd 에 워크스페이스 71개 파일을 깔았다(실측: cwd 1 → 72).
7795
+ function _p0102HonestyOk() {
7796
+ const os2 = require('os');
7797
+ const cp2 = require('child_process');
7798
+ const arena = fs.mkdtempSync(path.join(os2.tmpdir(), 'leerness-p102-'));
7799
+ try {
7800
+ const proj = path.join(arena, 'proj'); fs.mkdirSync(proj, { recursive: true });
7801
+ fs.writeFileSync(path.join(proj, 'package.json'), '{"name":"p","version":"0.1.0"}');
7802
+ const R = (a, cwd) => cp2.spawnSync(process.execPath, [__filename, ...a], { cwd: cwd || proj, encoding: 'utf8', timeout: 180000, env: Object.assign({}, process.env, { LEERNESS_OFFLINE: '1' }) });
7803
+ R(['init', proj, '--yes']);
7804
+ // ① 조회 실패 시 '최신' 이라 단정하지 않는다 + --json 은 순수 JSON
7805
+ const human = ((R(['update', proj, '--check']).stdout) || '');
7806
+ const noAssert = /확인 불가/.test(human) && !/→ up to date/.test(human);
7807
+ const raw = ((R(['update', proj, '--check', '--json']).stdout) || '').trim();
7808
+ let j = null; try { j = JSON.parse(raw); } catch { j = null; }
7809
+ const jsonOk = !!j && j.verdict === 'unknown' && j.registryReachable === false && j.latest === null;
7810
+ // ② plan init <경로> 는 그 경로에 적용하고 cwd 를 건드리지 않는다
7811
+ const cwdDir = path.join(arena, 'cwd'); const target = path.join(arena, 'target');
7812
+ fs.mkdirSync(cwdDir, { recursive: true }); fs.mkdirSync(target, { recursive: true });
7813
+ fs.writeFileSync(path.join(cwdDir, 'package.json'), '{"name":"c","version":"0.1.0"}');
7814
+ fs.writeFileSync(path.join(target, 'package.json'), '{"name":"t","version":"0.1.0"}');
7815
+ const cnt = (d) => { let n = 0; (function w(x) { for (const e of fs.readdirSync(x, { withFileTypes: true })) { const p = path.join(x, e.name); if (e.isDirectory()) w(p); else n++; } })(d); return n; };
7816
+ R(['plan', 'init', target], cwdDir);
7817
+ const planOk = cnt(cwdDir) === 1 && cnt(target) > 5;
7818
+ // 없는 경로를 주면 만들지 말고 거부한다
7819
+ const rejects = R(['plan', 'init', path.join(arena, 'NO-SUCH')], cwdDir).status !== 0 && !exists(path.join(arena, 'NO-SUCH'));
7820
+ return noAssert && jsonOk && planOk && rejects;
7821
+ } catch { return false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
7822
+ }
7790
7823
  function _p0101SkillIdOk() {
7791
7824
  const bad = ['../..', '../../../victim', '..\\..\\x', '/etc/passwd', 'C:\\Windows', 'a/b', 'a\\b', '.', '..', '.hidden', '', null, 'x'.repeat(65)];
7792
7825
  const good = ['open-meteo', 'stripe', 'my_skill', 'v1.2.3', 'a', 'A-Z_0.9'];
@@ -19582,27 +19615,35 @@ async function updateCmd(root, opts = {}) {
19582
19615
  // 1.9.364 (4번째 외부평가 9.3 + Codex CV-2 후속): --quiet — SessionStart hook 비침투화.
19583
19616
  // up-to-date 시 무음, 업데이트 가능 시에만 1줄 통지 (매 세션 노이즈 제거). hook 명령이 이 모드를 사용.
19584
19617
  const quiet = !!opts.quiet || has('--quiet');
19618
+ // 1.36.102: --json 은 사람용 줄을 섞으면 안 된다(계약: 단일 유효 JSON). quiet 과 별개로 둔다 —
19619
+ // `const quiet = ...` 줄 자체는 다른 selftest 가 문자열로 붙잡고 있어 건드리지 않는다.
19620
+ const _humanQuiet = quiet || has('--json');
19585
19621
  const verF = path.join(root, '.harness/HARNESS_VERSION');
19586
19622
  const cur = exists(verF) ? parseHarnessVersion(read(verF)) : { plus: null, base: null, raw: '(not installed)' };
19587
- if (!quiet) log(`# leerness update`);
19588
- if (!quiet) log(`Current: ${cur.raw}`);
19623
+ if (!_humanQuiet) log(`# leerness update`);
19624
+ if (!_humanQuiet) log(`Current: ${cur.raw}`);
19589
19625
  const fromTar = arg('--from', null);
19590
19626
  const cacheHours = opts.checkOnly ? 24 : 0;
19591
19627
  let nextLeerness = VERSION;
19592
- if (fromTar) { if (!quiet) log(`Local tarball mode: ${fromTar}`); }
19628
+ // 1.36.102 (명령 표면 감사 P1): 레지스트리 조회에 실패했는데 결론은 "up to date" 였다 —
19629
+ // 바로 윗줄에서 "(unavailable — 판정 보류)" 라고 해 놓고 아랫줄에서 최신이라 단정하는 자기모순이다.
19630
+ // 모르는 것은 모른다고 말한다. 이 값이 판정 분기까지 흘러간다.
19631
+ let _registryUnknown = false;
19632
+ if (fromTar) { if (!_humanQuiet) log(`Local tarball mode: ${fromTar}`); }
19593
19633
  else {
19594
19634
  const cached = readUpdateCache(root);
19595
19635
  // 1.36.34 (codex 3차 #5): 조회 실패(null)를 24h 캐시로 굳히면 다음 실행이 "up to date" 로 오답 — null 은 캐시하지 않고 재시도.
19596
19636
  if (cacheFresh(cached, cacheHours) && cached.nextLeerness) {
19597
19637
  nextLeerness = cached.nextLeerness;
19598
- if (!quiet) log(`(cached ${Math.round((Date.now() - cached.at) / 60000)}m ago)`);
19599
- if (!quiet) log(`npm leerness latest: ${cached.nextLeerness}`);
19638
+ if (!_humanQuiet) log(`(cached ${Math.round((Date.now() - cached.at) / 60000)}m ago)`);
19639
+ if (!_humanQuiet) log(`npm leerness latest: ${cached.nextLeerness}`);
19600
19640
  } else {
19601
- if (!quiet) log('Checking npm registry…');
19641
+ if (!_humanQuiet) log('Checking npm registry…');
19602
19642
  const latest = await fetchNpmLatest('leerness');
19603
19643
  nextLeerness = latest || VERSION;
19604
19644
  if (latest) writeUpdateCache(root, { nextLeerness: latest, runningCli: VERSION });
19605
- if (!quiet) log(`npm leerness latest: ${latest || '(unavailable 판정 보류, 다음 실행에서 재조회)'}`);
19645
+ else _registryUnknown = true; // 1.36.102: 조회 실패를 판정까지 끌고 간다(아래 --check 결론에서 사용)
19646
+ if (!_humanQuiet) log(`npm leerness latest: ${latest || '(unavailable — 판정 보류, 다음 실행에서 재조회)'}`);
19606
19647
  }
19607
19648
  }
19608
19649
  // What is "current"? canonical=base; legacy plus also rolls into leerness 1.9.0+
@@ -19616,8 +19657,15 @@ async function updateCmd(root, opts = {}) {
19616
19657
  if (compareVer(nextLeerness, '1.9.0') >= 0) { needsMigrate = true; reason = 'consolidate legacy plus@ marker into canonical'; }
19617
19658
  }
19618
19659
  if (opts.checkOnly) {
19619
- if (quiet) { if (needsMigrate) log(`↑ leerness 업데이트 가능: ${reason} leerness update --yes`); return; } // up-to-date 시 무음
19660
+ // 1.36.102: --json 사람용 텍스트를 내던 것도 함께 고친다(--json 계약: 단일 유효 JSON).
19661
+ const _verdict = needsMigrate ? 'migration_available' : (_registryUnknown ? 'unknown' : 'up_to_date');
19662
+ if (has('--json')) {
19663
+ log(JSON.stringify({ ok: true, current: installed || null, cli: VERSION, latest: _registryUnknown ? null : nextLeerness, registryReachable: !_registryUnknown, verdict: _verdict, reason: needsMigrate ? reason : null }, null, 2));
19664
+ return;
19665
+ }
19666
+ if (quiet) { if (needsMigrate) log(`↑ leerness 업데이트 가능: ${reason} — leerness update --yes`); return; } // up-to-date/unknown 시 무음
19620
19667
  if (needsMigrate) log(`\n→ migration available: ${reason}`);
19668
+ else if (_registryUnknown) log('\n→ 확인 불가 — 레지스트리를 조회하지 못했습니다(최신 여부를 알 수 없음). 네트워크 복구 후 다시 실행하세요.');
19621
19669
  else log('\n→ up to date');
19622
19670
  return;
19623
19671
  }
@@ -25455,7 +25503,19 @@ async function main() {
25455
25503
  if (cmd === 'plan') {
25456
25504
  const root = absRoot(arg('--path', process.cwd())); const sub = args[1] || 'show';
25457
25505
  if (sub==='show') return planShow(root);
25458
- if (sub==='init') return planInit(root);
25506
+ // 1.36.102 (명령 표면 감사 P1): `plan init <경로>` 가 positional 을 무시하고 **cwd 에** 워크스페이스를 깔았다.
25507
+ // 실측 재현: cwd 1개 파일 → 72개, 대상 디렉토리는 1개 그대로. 홈에서 실행하면 홈이 오염된다.
25508
+ // plan 그룹은 --path 만 읽고 positional 은 list 만 지원했다 — 파괴적인 init 이 그 갭에 있었다.
25509
+ // 무시하지 말고 명시적으로 처리한다: 준 경로에 적용하되, 존재하지 않으면 만들지 말고 거부한다.
25510
+ if (sub==='init') {
25511
+ const _pos = args[2] && !args[2].startsWith('-') ? args[2] : null;
25512
+ if (_pos) {
25513
+ const _abs = absRoot(_pos);
25514
+ if (!exists(_abs) || !fs.statSync(_abs).isDirectory()) { failJson(has('--json'), 'path_not_found', `경로 없음 또는 디렉토리 아님: ${_abs}`); return; }
25515
+ return planInit(_abs);
25516
+ }
25517
+ return planInit(root);
25518
+ }
25459
25519
  if (sub==='add') return planAdd(root, args.slice(2).join(' ').trim() || '새 계획'); // 17th 버그헌트 P2: 공백-only 제목이 || 기본값을 우회(truthy)해 plan.md 손상(파서가 다음 줄 'Status:' 흡수) → trim 후 판정
25460
25520
  if (sub==='drop') return planDrop(root, args.slice(2).join(' ') || '드랍 항목');
25461
25521
  if (sub==='remove') return planRemoveCmd(root, args[2]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.101",
3
+ "version": "1.36.102",
4
4
  "description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -67,9 +67,13 @@ run('memory search', ['memory', 'search', '마일스톤', '--path', tmp]);
67
67
  const offline = Object.assign({}, process.env, { LEERNESS_OFFLINE: '1' });
68
68
  total++;
69
69
  {
70
+ // 1.36.102: 이 단언이 결함을 고정하고 있었다 — 오프라인(레지스트리 조회 불가)에서 "up to date" 가 나오는 것을
71
+ // 정답으로 못박고 있었다. 모르는 것을 안다고 말하는 게 정답일 수 없다. 계약을 바꾼다:
72
+ // 조회에 실패하면 '확인 불가' 여야 하고, 마이그레이션이 필요하면 그건 조회와 무관하니 그대로 인정한다.
70
73
  const r = cp.spawnSync(process.execPath, [CLI, 'update', tmp, '--check'], { env: offline, encoding: 'utf8' });
71
- const ok = r.status === 0 && /up to date|migration available/.test(r.stdout || '');
72
- console.log(ok ? '✓ update --check (offline)' : `✗ update --check (offline) exit=${r.status}`);
74
+ const _o = r.stdout || '';
75
+ const ok = r.status === 0 && /확인 불가|migration available/.test(_o) && !/→ up to date/.test(_o);
76
+ console.log(ok ? '✓ update --check (offline): 조회 실패를 "최신" 으로 단정하지 않음 (1.36.102)' : `✗ update --check (offline) exit=${r.status}`);
73
77
  if (!ok) { failed++; console.log(r.stdout || ''); console.error(r.stderr || ''); }
74
78
  }
75
79