leerness 1.36.101 → 1.36.103

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.103 하네스를 사용합니다. 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.103는 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.103 릴리스 태그 이력** (수백 라운드) · _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.103: 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.103';
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') 시 호스트 프로세스 오염.
@@ -236,9 +236,49 @@ function _resolveRoot(positional) {
236
236
  if (positional && !String(positional).startsWith('-')) return positional;
237
237
  return process.cwd();
238
238
  }
239
+ // 1.36.103: 값-플래그 목록이 자기 소비자보다 45개 뒤처져 있었다.
240
+ // 미등록 값-플래그는 토큰만 건너뛰고 **값이 positional 로 떨어져** 사용자 데이터를 오염시킨다. 실측:
241
+ // leerness preview add "my page" --select ZZ → 저장된 제목 「my page ZZ」
242
+ // leerness review --persona security sample.js → "✗ 파일 없음: <cwd>\security" (값이 파일 경로로 소비)
243
+ // 같은 사고가 개별 등록으로 네 번 반복됐다 — --done-when(1.14.2) · --test-cmd(1.17.2) ·
244
+ // --require-referee(1.36.82, "툴이 안내하는 문법 그대로 쓰면 깨졌다") · 1.36.80/81 소급.
245
+ // 목록에 한 줄 더 넣는 대신 구조를 바꾼다: 목록을 모듈 스코프로 올려 selftest 가 소비자(arg('--x'))와
246
+ // 대조해 **완전성을 강제**하고(누락 시 게이트 실패), 어느 집합에도 없는 플래그는 오타로 보고 stderr 로 알린다.
247
+ // 동작은 바꾸지 않는다 — 미분류 부울 리터럴이 47개 남아 있어 '모르는 플래그의 값을 건너뛰기' 로 기본값을
248
+ // 뒤집으면 그쪽에서 positional 을 삼키는 새 회귀가 난다(가드는 false-BLOCK 이 아니라 false-PASS 쪽으로).
249
+ const _VALUE_FLAGS = new Set(['--language','--skills','--path','--status','--progress','--goal','--reason','--next','--target','--token-env','--package','--out','--from','--to','--repo','--id','--note','--evidence','--query','--limit','--action','--agent','--tool','--doc','--command','--capability','--before','--after','--display','--threshold','--trigger','--check','--set','--min-score','--include','--days','--gh-pages-src','--roadmap','--since','--agents','--model','--timeout','--retry-on-fail','--label','--score','--tokens','--alternatives','--impact','--tag','--surface','--depends-on','--affects','--co-changes-with','--files','--branch','--remote','--task-add','--next-action','--role','--provider','--env-var','--deploy','--token-lifetime-hours','--port','--secret','--keep','--shell','--ps-version','--done-when','--test-cmd','--require-referee','--referee','--expect-bad','--good','--bad','--static-dir','--dev-url','--repro','--root-cause','--siblings','--siblings-na','--previous-gap',
250
+ // 1.36.103 일괄 등록 — 소스의 arg()/argAll() 소비자에서 기계 추출한 미등록 45종(각각이 위 사고와 같은 오염 지점이었다)
251
+ '--alias','--bin','--cmd','--constraint','--desc','--design','--direction','--emit','--env-flag','--exit','--features','--file','--github','--in','--install-hint','--kind','--lang','--max','--measure','--min','--mockup','--name','--npm-otp','--npm-tag','--ollama-url','--persona','--preset','--presets','--profile','--project','--result','--scenario','--select','--selector','--source','--summary','--task','--text','--tier','--url','--version-args','--where','--window','--x','--y',
252
+ // bin/ 만 훑었을 때 놓친 lib/*.js 소비자 — 특히 --priority 는 _TASK_VALUE_FLAGS 에만 있고 여기 없어 값이 제목에 붙었다
253
+ '--files-changed','--files-read','--members','--only','--personas','--priority','--purpose','--schedule',
254
+ // --approved-by 는 `agents route` 가 값으로 쓴다(자체 인라인 목록에만 있었다) — 부울로 오분류하면 값이 토큰으로 샌다.
255
+ // --title 도 같다(_TASK_VALUE_FLAGS 소속). 둘 다 완전성 가드가 잡아냈다 — 손으로 분류한 것은 손으로 틀린다.
256
+ '--approved-by', '--title']);
257
+ // 1.36.103 (codex 검수 #10/#11): `brief set` 은 플래그를 정적 리터럴이 아니라 레지스트리에서 만든다
258
+ // (`arg('--' + f.flag)`). 정적 추출로는 보이지 않아 `brief set --intro hello` 가 "알 수 없는 플래그" 오탐을 냈다.
259
+ // 손으로 10개를 베껴 적으면 그 목록이 또 뒤처진다 — 레지스트리에서 **유도**한다(이 라운드의 요지 그대로).
260
+ for (const _bf of _BRIEF_FIELDS) { if (_bf && _bf.flag) _VALUE_FLAGS.add('--' + _bf.flag); }
261
+ // 부울 플래그(값 없음) — 오타 경고의 대조군. 여기 있거나 _VALUE_FLAGS 에 있으면 '실재하는 플래그' 로 본다.
262
+ // 판정에만 쓰이고 파싱 동작에는 관여하지 않으므로, 넓게 잡아도 안전하다(과다 포함 = 경고 억제).
263
+ const _BOOL_FLAGS = new Set(['--ai','--all','--all-apps','--all-presets','--allow-empty','--allow-unrelated','--apply','--audit','--auto','--auto-apply-delivered','--auto-cleanup-branches','--auto-fix','--auto-fix-bom','--auto-fix-encoding','--auto-main-push','--auto-recover','--auto-track','--banner','--bench','--build','--bundle-only','--claims','--close','--compact','--detect','--dry-run','--dry-run-npm','--embedding','--enforce','--fail-on-candidates','--fail-on-violation','--force','--gh-pages','--gh-release','--git-push','--global','--guide','--help','--html','--include-code','--interactive','--json','--last','--lenient','--major','--minimal','--minor','--no-animate','--no-auto-roadmap','--no-auto-update','--no-banner','--no-brainstorm-hits','--no-cache','--no-context-inject','--no-crawl','--no-drift-check','--no-enforce','--no-env','--no-env-detect','--no-feature-impact','--no-headline','--no-init-check','--no-interactive-select','--no-lazy-warn','--no-lessons','--no-longterm-recall','--no-mem-delta','--no-multiagent-hint','--no-next-actions','--no-npm','--no-official-skills','--no-on-every-change','--no-pre-wake','--no-preserve-link','--no-readme-sync','--no-record','--no-repl','--no-review','--no-save','--no-security-check','--no-security-summary','--no-setup-agents','--no-skill-suggest','--no-stale-check','--no-suggest','--no-synonyms','--no-team-reminders','--no-wakeup-miss','--no-workflow-guide','--no-write','--npm-publish','--offline','--on-every-change','--pack','--parent-migrate','--publish-npm','--pulse','--quiet','--record','--refresh','--remove','--repair','--repl','--require-evidence','--run-tests','--skeleton','--strict','--strict-claims','--strict-elements','--strict-exit','--suggest','--version','--yes',
264
+ // 소스에 리터럴로만 존재해 소비 형태를 기계 추출하지 못한 것들 — 실재하는 플래그이므로 오타로 오인하면 안 된다
265
+ '--commands','--decision','--errors','--expand-all','--fix','--list','--notes','--print','--raw','--select-all','--skip-git-repo-check','--tests','--verbose','--yolo',
266
+ // lib/*.js 의 부울 소비자 — bin/ 만 훑었다면 `agents route --confirm` 같은 실재 문법에 오탐 경고를 냈을 것이다
267
+ '--confirm','--dispatch','--execute','--ladder','--log','--multi','--no-env-check','--no-feature-check','--no-gitignore-check','--no-npm-audit','--no-secret-scan','--readonly','--write']);
268
+ // 1.36.103: 하위명령이 '선택' 인 명령에서 `<cmd> <경로>` 의 경로가 하위명령 자리로 읽히던 것 차단.
269
+ // tech/library 는 경로가 args[1] 인데 root 해석이 _taskPositionalPath(args, 2) 로 한 칸 늦게 시작해
270
+ // positional 을 통째로 건너뛰고 cwd 에 썼다(실측: `tech <TARGET>` 이 CWD 에 tech-profile.json 생성).
271
+ // 더 나쁜 건 1.36.101 path-strict 가드가 그 경로의 존재를 **검증해 준 뒤** 버렸다는 것이다 —
272
+ // 도구가 "그 경로 맞다" 고 확인하고 다른 곳에 쓴다. 경로형 토큰이면 하위명령이 아니라고 본다.
273
+ function _optSub(a) {
274
+ const v = a && a[1];
275
+ if (typeof v !== 'string' || !v) return undefined;
276
+ if (/^([A-Za-z]:[\\/]|\/|\.\.?[\\/])/.test(v)) return undefined; // 선행 구분자 path-like → 하위명령 아님
277
+ return v;
278
+ }
239
279
  function nonFlagArgs() {
240
280
  const out = [];
241
- const withValue = new Set(['--language','--skills','--path','--status','--progress','--goal','--reason','--next','--target','--token-env','--package','--out','--from','--to','--repo','--id','--note','--evidence','--query','--limit','--action','--agent','--tool','--doc','--command','--capability','--before','--after','--display','--threshold','--trigger','--check','--set','--min-score','--include','--days','--gh-pages-src','--roadmap','--since','--agents','--model','--timeout','--retry-on-fail','--label','--score','--tokens','--alternatives','--impact','--tag','--surface','--depends-on','--affects','--co-changes-with','--files','--branch','--remote','--task-add','--next-action','--role','--provider','--env-var','--deploy','--token-lifetime-hours','--port','--secret','--keep','--shell','--ps-version','--done-when','--test-cmd','--require-referee','--referee','--expect-bad','--good','--bad','--static-dir','--dev-url','--repro','--root-cause','--siblings','--siblings-na','--previous-gap']); // 1.14.2 (UR-0032): --done-when 값이 positional 로 누출돼 milestone 제목에 흡수되던 것 차단. 1.17.2 (UR-0045): --test-cmd 동일 원칙(신규 value-flag 는 반드시 여기 등록). 1.36.82 (검수 High#3): --require-referee 미등록으로 `gate --require-referee <id>` 의 값이 positional 경로로 흡수돼 root 가 <cwd>/<id> 가 됐다 — 툴이 안내하는 문법 그대로 쓰면 깨졌다. 1.36.80/81 신규 value-flag 도 소급 등록.
281
+ const withValue = _VALUE_FLAGS;
242
282
  const a = process.argv.slice(2);
243
283
  for (let i = 0; i < a.length; i++) {
244
284
  const x = a[i];
@@ -868,7 +908,15 @@ function createBackup(root, reason, files, dry = false) {
868
908
  candidates
869
909
  }, null, 2) + '\n');
870
910
  // CV-4/UR-0079: 새 스냅샷 기록 후 retention 적용 (기본 10, --keep N 조정) — 무한 누적 차단.
871
- const keep = Math.max(1, parseInt(arg('--keep', '10'), 10) || 10);
911
+ // 1.36.103: 여기도 `|| 10` 이라 --keep 0 이 조용히 10 됐다. 유효하지 않은 값은 기본값을 쓰되 그렇다고 말하고,
912
+ // 0(=직전에 쓴 스냅샷만 남김)은 최소 1 로 고정한다 — 방금 만든 백업까지 지우는 것은 이 명령의 의도가 아니다.
913
+ const _keepRaw = arg('--keep', null);
914
+ let keep = 10;
915
+ if (_keepRaw !== null) {
916
+ const n = Number(_keepRaw);
917
+ if (!Number.isInteger(n) || n < 0) warn(`--keep 무시: 0 이상의 정수가 아닙니다 (받은 값: ${_keepRaw}) — 기본 10 적용`);
918
+ else keep = Math.max(1, n);
919
+ }
872
920
  const pruned = _pruneArchives(root, keep);
873
921
  return { archiveDir: ar, candidates, pruned };
874
922
  }
@@ -2674,6 +2722,17 @@ function agentModeCmd(root, sub) {
2674
2722
  }
2675
2723
  if (sub === 'tick') {
2676
2724
  // 가벼움 우선 — pulse 만 호출
2725
+ // 1.36.103: --json 일 때 헤더 문구 + 사람용 pulse 출력을 그대로 흘려 stdout 이 파싱 불가였다(게시본 동일).
2726
+ // tick 은 pulse 의 얇은 래퍼이므로 자식에게 --json 을 넘겨 그 페이로드를 그대로 통과시킨다.
2727
+ if (has('--json')) {
2728
+ const rj = spawnChild(['pulse', '--path', root, '--json']);
2729
+ const raw = (rj.stdout || '').trim();
2730
+ let parsed = null; try { parsed = JSON.parse(raw); } catch { parsed = null; }
2731
+ if (!parsed) { failJson(true, 'pulse_failed', `agent-mode tick: pulse 출력을 읽지 못했습니다 (exit ${rj.status})`); return; }
2732
+ log(JSON.stringify(Object.assign({ ok: true, mode: 'tick' }, parsed), null, 2));
2733
+ if (rj.status !== 0) process.exitCode = 1;
2734
+ return;
2735
+ }
2677
2736
  log(cy(`# leerness agent-mode tick (1.9.239) — 가벼운 상태 확인`));
2678
2737
  const r = spawnChild(['pulse', '--path', root]);
2679
2738
  process.stdout.write(r.stdout || '');
@@ -6174,7 +6233,11 @@ function _selfTestCases() {
6174
6233
  const tps = read(path.join(__dirname, '..', 'lib', 'tech-profile.js'));
6175
6234
  const guards = cl.includes('_withLock(_previewsPath(root)') && cl.includes("failJson(json, 'note_required'")
6176
6235
  && tps.includes('prev = null') && tps.includes('_mergeManifests(root, 2)')
6177
- && io2.includes('!_jsonErrEmitted && t && (t[0]') && /process\.on\('exit', \(code\) => \{ if \(code !== 0/.test(io2) && io2.includes('.corrupt-${Date.now()}-${process.pid}-')
6236
+ // 1.36.103: #7b(성공 exit 폴백 오류 JSON 금지) 소스 문자열이 아니라 행위로 단언한다.
6237
+ // 훅을 _installJsonExitFallback 으로 옮기자 예전 정규식이 형태만 보고 깨졌다 — 보호 대상은 그대로였는데.
6238
+ // 지금은 _p0103JsonOk() 가 양방향으로 검사한다: 실패 경로는 ok:false JSON 을 내고, 성공 경로(pulse --json,
6239
+ // exit 0)는 폴백이 끼어들지 않아 자기 페이로드가 그대로 나온다.
6240
+ && io2.includes('!_jsonErrEmitted && t && (t[0]') && io2.includes('_installJsonExitFallback') && io2.includes('.corrupt-${Date.now()}-${process.pid}-')
6178
6241
  && s.includes('declaredTestCount != null && runResult && !runResult.skipped && runResult.allPassed');
6179
6242
  // 1.36.98 (P-0013): UI 스택 감지 — 라이브러리 인벤토리/시안 생성기가 스택별 전략을 고르는 근거라 오판이 곧 잘못된 산출물이 된다.
6180
6243
  // 실측(실제 12개 프로젝트)에서 Tailwind 프로젝트는 CSS 변수가 0~5개뿐이고 유틸 어휘가 74~121종이라 토큰 출처가 정반대다.
@@ -6199,7 +6262,8 @@ function _selfTestCases() {
6199
6262
  && (JSON.parse(read(path.join(nextApp, '.harness', 'tech-profile.json'))).current.ui || []).some(u => u.id === 'tailwind');
6200
6263
  } catch { uiOk = false; } finally { try { fs.rmSync(tmp3, { recursive: true, force: true }); } catch {} }
6201
6264
  return clar && shape && noDbUrl && reqOk && guards && uiOk && _p0013LibraryOk() && _p0088CurrentStatePreserved()
6202
- && _p0101SurfaceOk() && _p0101SkillIdOk() && _p0101PathStrictOk();
6265
+ && _p0101SurfaceOk() && _p0101SkillIdOk() && _p0101PathStrictOk() && _p0102HonestyOk()
6266
+ && _p0103FlagsOk() && _p0103JsonOk() && _p0103RootOk();
6203
6267
  } },
6204
6268
  { name: '시크릿 스캐너 F-06 (1.36.56, 외부감사): 무명 확장자 소형 텍스트 스캔(이진 NUL 제외) + 같은 토큰 중복 보고 dedupe — 행위검사', run: () => {
6205
6269
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sc56_'));
@@ -7787,6 +7851,182 @@ function _p0101PathStrictOk() {
7787
7851
  // 표본이 조용히 줄어들면 가드가 약해진 걸 모른다 — 목록/표본 크기도 함께 못박는다.
7788
7852
  return bad.length === 0 && controlOk && _sample.length >= 7 && _PATH_STRICT_CMDS.length >= 20;
7789
7853
  }
7854
+ // 1.36.103: 검증한 경로를 실제로 쓴다 — `<cmd> <경로>` 의 positional 이 root 가 되는가.
7855
+ // tech/library 는 1.36.101 path-strict 가 args[1] 의 존재를 검증하는데 root 해석은 index 2 부터 시작해
7856
+ // 그 경로를 건너뛰고 cwd 에 썼다. 관측값은 출력 문구가 아니라 **파일시스템 부작용**이다(tech-profile.json 위치).
7857
+ // 대조군 두 개가 필수다: 인자 없으면 cwd 그대로 · 하위명령은 여전히 하위명령(경로로 오인 금지).
7858
+ function _p0103RootOk() {
7859
+ const os2 = require('os');
7860
+ const cp2 = require('child_process');
7861
+ const arena = fs.mkdtempSync(path.join(os2.tmpdir(), 'leerness-p103r-'));
7862
+ try {
7863
+ const cwdDir = path.join(arena, 'CWDPROJ'), target = path.join(arena, 'TARGETPROJ');
7864
+ for (const d of [cwdDir, target]) { fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, 'package.json'), '{"name":"x","version":"0.1.0"}'); }
7865
+ const env2 = Object.assign({}, process.env, { LEERNESS_OFFLINE: '1', LEERNESS_NO_AUTO_ROADMAP: '1' });
7866
+ const R = (a, cwd) => cp2.spawnSync(process.execPath, [__filename, ...a], { cwd: cwd || cwdDir, encoding: 'utf8', timeout: 180000, env: env2 });
7867
+ R(['init', cwdDir, '--yes'], cwdDir); R(['init', target, '--yes'], target);
7868
+ const tp = (d) => path.join(d, '.harness', 'tech-profile.json');
7869
+ const clear = () => { for (const d of [cwdDir, target]) { try { fs.unlinkSync(tp(d)); } catch { /* 없으면 그만 */ } } };
7870
+ // ① 준 경로에 쓴다
7871
+ clear(); R(['tech', target]);
7872
+ const wroteTarget = exists(tp(target)) && !exists(tp(cwdDir));
7873
+ // ② 대조군 A — 인자가 없으면 예전처럼 cwd
7874
+ clear(); R(['tech']);
7875
+ const bareIsCwd = exists(tp(cwdDir)) && !exists(tp(target));
7876
+ // ③ 하위명령은 경로로 오인되지 않는다. 1.36.101 의 path-strict 가 args[1] 을 무조건 경로로 봐서
7877
+ // `leerness library page` 가 "경로 없음: <cwd>\page" 로 거부됐다 — 게시된 1.36.102 에서 확인한 회귀다.
7878
+ const htmlT = path.join(target, 'leerness-library.html'), htmlC = path.join(cwdDir, 'leerness-library.html');
7879
+ for (const h of [htmlT, htmlC]) { try { fs.unlinkSync(h); } catch { /* 없으면 그만 */ } }
7880
+ R(['library', 'page']); // 경로 없이도 동작해야 한다(제품이 안내하는 형태)
7881
+ const pageBare = exists(htmlC);
7882
+ R(['library', 'page', target]);
7883
+ const pageWithPath = exists(htmlT);
7884
+ // ④ library 도 준 경로를 본다 — 헤더가 그 디렉토리 이름을 말한다
7885
+ const libOut = ((R(['library', target]).stdout) || '');
7886
+ const libTarget = libOut.includes('TARGETPROJ') && !libOut.includes('CWDPROJ');
7887
+ // ⑤ 좁히면서 false-PASS 를 만들지 않았는가 — 맨이름 오타는 여전히 거부한다(가능한 하위명령을 알려주면서)
7888
+ const typo = R(['library', 'NOPEZZ']);
7889
+ const typoRejected = typo.status !== 0 && /NOPEZZ/.test((typo.stdout || '') + (typo.stderr || ''));
7890
+ const typoTech = R(['tech', 'NOPEZZ']).status !== 0;
7891
+ return wroteTarget && bareIsCwd && pageBare && pageWithPath && libTarget && typoRejected && typoTech;
7892
+ } catch { return false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
7893
+ }
7894
+ // 1.36.103: --json 계약 회귀 가드 — "실패해도 stdout 은 항상 파싱 가능한 JSON 하나".
7895
+ // 실측 위반 5건이 서로 다른 이유로 났다: 경고가 오류보다 먼저 나와 stdout 오염(reuse-map/retro/insights),
7896
+ // 실패 분기에 JSON 갈래가 없음(release cleanup 비-git), 그리고 stdout 을 삼키는 구간에서 표식만 서서
7897
+ // 폴백이 막힘(gate). 그래서 한 자리가 아니라 계약 자체를 단언한다.
7898
+ function _p0103JsonOk() {
7899
+ const os2 = require('os');
7900
+ const cp2 = require('child_process');
7901
+ const arena = fs.mkdtempSync(path.join(os2.tmpdir(), 'leerness-p103j-'));
7902
+ try {
7903
+ const proj = path.join(arena, 'proj'); fs.mkdirSync(proj, { recursive: true });
7904
+ fs.writeFileSync(path.join(proj, 'package.json'), '{"name":"p","version":"0.1.0"}');
7905
+ 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', LEERNESS_NO_AUTO_ROADMAP: '1' }) });
7906
+ R(['init', proj, '--yes']);
7907
+ const parse = (r) => { const t = ((r.stdout) || '').trim(); if (!t) return null; try { return JSON.parse(t); } catch { return null; } };
7908
+ // ① 실패 경로 3종 — 각각 다른 원인이었다
7909
+ const gateJ = parse(R(['gate', 'no-such-gate', '--json'])); // stdout 을 삼키는 구간
7910
+ const relJ = parse(R(['release', 'cleanup', '--json'])); // 실패 분기에 JSON 없음 (비-git)
7911
+ const incJ = parse(R(['reuse-map', '--include', 'no-such-project-zz', '--json'])); // 경고가 stdout 을 선점
7912
+ const failuresOk = !!gateJ && gateJ.ok === false && !!relJ && relJ.ok === false && !!incJ;
7913
+ // ② 성공 경로는 폴백이 끼어들지 않는다 (대조군 — 없으면 "항상 ok:false" 로도 통과한다)
7914
+ const pulseR = R(['pulse', '--json']);
7915
+ const pulseJ = parse(pulseR);
7916
+ const successOk = pulseR.status === 0 && !!pulseJ && pulseJ.ok !== false;
7917
+ // ③ --keep 은 준 값을 조용히 바꾸지 않는다
7918
+ const keepBad = R(['release', 'cleanup', '--keep', 'abc', '--json']);
7919
+ const keepBadJ = parse(keepBad);
7920
+ // --keep 은 소비처가 둘이었고 서로 다르게 굴었다 — 진입점에서 통일했으므로 init 쪽도 함께 건다(codex 검수 #9).
7921
+ const keepInit = R(['init', path.join(arena, 'K'), '--yes', '--keep', 'abc', '--json']);
7922
+ let keepInitJ = null; try { keepInitJ = JSON.parse(((keepInit.stdout) || '').trim()); } catch { keepInitJ = null; }
7923
+ const keepOk = !!keepBadJ && keepBadJ.code === 'invalid_keep' && keepBad.status !== 0
7924
+ && !!keepInitJ && keepInitJ.code === 'invalid_keep' && keepInit.status !== 0;
7925
+ // ④ --json 이 아예 구현되지 않아 사람용 텍스트를 내던 표면(게시본 3/30) — 계약 스윕
7926
+ const sweepBad = [['consistency', 'check', '--json'], ['agent-mode', 'tick', '--json'], ['graph', '--json']]
7927
+ .filter(a => !parse(R(a)));
7928
+ return failuresOk && successOk && keepOk && sweepBad.length === 0;
7929
+ } catch { return false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
7930
+ }
7931
+ // 1.36.103: 값-플래그 등록 완전성 + 오타 가시성 회귀 가드.
7932
+ // 이 클래스는 개별 등록으로 네 번 재발했다(--done-when/--test-cmd/--require-referee/소급 2종).
7933
+ // 그래서 '고쳤다' 가 아니라 '뒤처질 수 없다' 를 단언한다 — 소스의 소비자(arg/argAll)를 열거해
7934
+ // _VALUE_FLAGS 를 **데이터로** 대조하므로, 새 값-플래그를 등록 없이 추가하면 여기서 게이트가 붉어진다.
7935
+ function _p0103FlagsOk() {
7936
+ const os2 = require('os');
7937
+ const cp2 = require('child_process');
7938
+ // ① 완전성 — 값으로 소비되는 플래그는 전부 등록돼 있어야 한다.
7939
+ // 범위는 bin + lib 전체다. bin/ 만 훑었을 때 --priority(_TASK_VALUE_FLAGS 에만 있던 것)와
7940
+ // lib 의 --purpose/--personas/--members/--only/--schedule/--files-read 를 통째로 놓쳤다.
7941
+ const libDir = path.join(path.dirname(__filename), '..', 'lib');
7942
+ let src = read(__filename);
7943
+ try { for (const f of fs.readdirSync(libDir)) if (f.endsWith('.js')) src += '\n' + read(path.join(libDir, f)); } catch { return false; }
7944
+ const IGNORE = new Set(['--definitely-not-real-xyz']); // selftest 전용 리터럴(실재 플래그 아님)
7945
+ const consumed = new Set();
7946
+ let m; const re = /\b_?arg(?:All(?:Strict)?)?\(\s*'(--[a-z0-9-]+)'/g;
7947
+ while ((m = re.exec(src))) if (!IGNORE.has(m[1])) consumed.add(m[1]);
7948
+ // 다른 기제로 값을 취하는 집합도 같은 계약을 진다. (_TASK_VALUE_FLAGS 는 pure-utils 내부 상수라
7949
+ // export 되지 않으므로, 이어붙인 소스에서 읽는다 — 임의 결합 대신 정의 위치를 그대로 참조한다.)
7950
+ const tvf = src.match(/const _TASK_VALUE_FLAGS = new Set\(\[([\s\S]*?)\]\)/);
7951
+ if (!tvf) return false;
7952
+ for (const q of (tvf[1].match(/'(--[a-z0-9-]+)'/g) || [])) consumed.add(q.slice(1, -1));
7953
+ const unregistered = [...consumed].filter(f => !_VALUE_FLAGS.has(f));
7954
+ // 열거 자체가 죽으면 공허참이 된다 — 표본 하한과 알려진 항목 존재를 함께 단언한다.
7955
+ const enumeratedOk = consumed.size >= 100 && consumed.has('--persona') && consumed.has('--select')
7956
+ && consumed.has('--require-referee') && consumed.has('--priority') && consumed.has('--purpose');
7957
+ if (unregistered.length || !enumeratedOk) return false;
7958
+
7959
+ // ② 행위 — 등록된 값-플래그의 값이 더 이상 본문/경로로 흡수되지 않는다.
7960
+ const arena = fs.mkdtempSync(path.join(os2.tmpdir(), 'leerness-p103-'));
7961
+ try {
7962
+ const proj = path.join(arena, 'proj'); fs.mkdirSync(proj, { recursive: true });
7963
+ fs.writeFileSync(path.join(proj, 'package.json'), '{"name":"p","version":"0.1.0"}');
7964
+ fs.writeFileSync(path.join(proj, 'sample.js'), 'function f(){ return 1; }\n');
7965
+ const R = (a) => cp2.spawnSync(process.execPath, [__filename, ...a], { cwd: proj, encoding: 'utf8', timeout: 180000, env: Object.assign({}, process.env, { LEERNESS_OFFLINE: '1', LEERNESS_NO_AUTO_ROADMAP: '1' }) });
7966
+ R(['init', proj, '--yes']);
7967
+ R(['preview', 'add', 'my page', '--select', 'ZZABSORB']);
7968
+ const pv = exists(path.join(proj, '.harness', 'previews.json')) ? read(path.join(proj, '.harness', 'previews.json')) : '';
7969
+ const titleClean = pv.includes('my page') && !pv.includes('ZZABSORB');
7970
+ // 제품이 스스로 안내하는 문법(persona list 의 "leerness review <file> --persona <id>") 이 순서와 무관하게 동작해야 한다
7971
+ const rev = R(['review', '--persona', 'security', 'sample.js']);
7972
+ const revOut = (rev.stdout || '') + (rev.stderr || '');
7973
+ const pathClean = !/security['"]?\s*$/m.test(revOut) && !revOut.includes(path.join(proj, 'security'));
7974
+
7975
+ // ③ 등호형(--flag=value)은 값을 자기가 들고 있다 — 다음 토큰까지 먹으면 위치인자가 사라진다.
7976
+ // 인라인 목록을 _VALUE_FLAGS 로 바꾸며 멤버십을 split('=')[0] 로 본 탓에 내가 만든 회귀다(codex 검수 #5~#8).
7977
+ R(['preview', 'add', '--select=ZZEQ', 'equals form title']);
7978
+ const pv2 = exists(path.join(proj, '.harness', 'previews.json')) ? read(path.join(proj, '.harness', 'previews.json')) : '';
7979
+ const eqOk = pv2.includes('equals form title') && !pv2.includes('ZZEQ');
7980
+ const rtEq = R(['agents', 'route', '--tier=normal', 'fix the parser']);
7981
+ const eqRoute = !/비어 있|empty_task/.test((rtEq.stdout || '') + (rtEq.stderr || ''));
7982
+ // ④ 동적 레지스트리 플래그(brief set 은 arg('--'+f.flag) 로 만든다)에 오탐 경고가 없어야 한다 — 판별 대조군 포함
7983
+ const W2 = /알 수 없는 플래그|Unknown flag/;
7984
+ const dynOk = !W2.test((R(['brief', 'set', '--intro', 'hello']).stderr) || '')
7985
+ && W2.test((R(['brief', 'set', '--introo', 'x']).stderr) || '');
7986
+ // ⑤ 오타 가시성 — 판별 대조군: 실재 플래그는 조용하고, 없는 플래그만 경고한다.
7987
+ const good = R(['pulse', '--json']);
7988
+ const typo = R(['pulse', '--json', '--nosuchflagzz']);
7989
+ const quietOnReal = !/알 수 없는 플래그|Unknown flag/.test(good.stderr || '');
7990
+ const warnsOnTypo = /알 수 없는 플래그/.test(typo.stderr || '') && (typo.stderr || '').includes('--nosuchflagzz');
7991
+ // 경고는 stderr 전용 — stdout(기계 소비자) 은 오염되지 않는다
7992
+ const stdoutClean = !/알 수 없는 플래그|Unknown flag/.test(typo.stdout || '');
7993
+ let jsonStillValid = false; try { JSON.parse((typo.stdout || '').trim()); jsonStillValid = true; } catch { jsonStillValid = false; }
7994
+ return titleClean && pathClean && eqOk && eqRoute && dynOk && quietOnReal && warnsOnTypo && stdoutClean && jsonStillValid;
7995
+ } catch { return false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
7996
+ }
7997
+ // 1.36.102 (명령 표면 감사 P1 2건): 회귀 가드.
7998
+ // ① update --check 가 레지스트리 조회에 실패하고도 "up to date" 라고 단정했다 —
7999
+ // 바로 윗줄에 "(unavailable — 판정 보류)" 를 찍어 놓고 아랫줄에서 결론을 뒤집는 자기모순이었다.
8000
+ // 그리고 --json 이 사람용 텍스트를 냈다(계약: 단일 유효 JSON).
8001
+ // ② plan init <경로> 가 positional 을 무시하고 cwd 에 워크스페이스 71개 파일을 깔았다(실측: cwd 1 → 72).
8002
+ function _p0102HonestyOk() {
8003
+ const os2 = require('os');
8004
+ const cp2 = require('child_process');
8005
+ const arena = fs.mkdtempSync(path.join(os2.tmpdir(), 'leerness-p102-'));
8006
+ try {
8007
+ const proj = path.join(arena, 'proj'); fs.mkdirSync(proj, { recursive: true });
8008
+ fs.writeFileSync(path.join(proj, 'package.json'), '{"name":"p","version":"0.1.0"}');
8009
+ 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' }) });
8010
+ R(['init', proj, '--yes']);
8011
+ // ① 조회 실패 시 '최신' 이라 단정하지 않는다 + --json 은 순수 JSON
8012
+ const human = ((R(['update', proj, '--check']).stdout) || '');
8013
+ const noAssert = /확인 불가/.test(human) && !/→ up to date/.test(human);
8014
+ const raw = ((R(['update', proj, '--check', '--json']).stdout) || '').trim();
8015
+ let j = null; try { j = JSON.parse(raw); } catch { j = null; }
8016
+ const jsonOk = !!j && j.verdict === 'unknown' && j.registryReachable === false && j.latest === null;
8017
+ // ② plan init <경로> 는 그 경로에 적용하고 cwd 를 건드리지 않는다
8018
+ const cwdDir = path.join(arena, 'cwd'); const target = path.join(arena, 'target');
8019
+ fs.mkdirSync(cwdDir, { recursive: true }); fs.mkdirSync(target, { recursive: true });
8020
+ fs.writeFileSync(path.join(cwdDir, 'package.json'), '{"name":"c","version":"0.1.0"}');
8021
+ fs.writeFileSync(path.join(target, 'package.json'), '{"name":"t","version":"0.1.0"}');
8022
+ 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; };
8023
+ R(['plan', 'init', target], cwdDir);
8024
+ const planOk = cnt(cwdDir) === 1 && cnt(target) > 5;
8025
+ // 없는 경로를 주면 만들지 말고 거부한다
8026
+ const rejects = R(['plan', 'init', path.join(arena, 'NO-SUCH')], cwdDir).status !== 0 && !exists(path.join(arena, 'NO-SUCH'));
8027
+ return noAssert && jsonOk && planOk && rejects;
8028
+ } catch { return false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
8029
+ }
7790
8030
  function _p0101SkillIdOk() {
7791
8031
  const bad = ['../..', '../../../victim', '..\\..\\x', '/etc/passwd', 'C:\\Windows', 'a/b', 'a\\b', '.', '..', '.hidden', '', null, 'x'.repeat(65)];
7792
8032
  const good = ['open-meteo', 'stripe', 'my_skill', 'v1.2.3', 'a', 'A-Z_0.9'];
@@ -16988,6 +17228,9 @@ function consistencyCheck(root) {
16988
17228
  root = absRoot(root);
16989
17229
  const cands = ['designguide.md','design-guide.md','.harness/designguide.md','docs/designguide.md','docs/design-guide.md'];
16990
17230
  const found = cands.filter(f => exists(path.join(root,f)));
17231
+ // 1.36.103: --json 이 전역 플래그로 광고되는데 여기선 구현이 없어 사람용 텍스트가 나갔다(게시본에서도 동일).
17232
+ // 기계 소비자에겐 파싱 불가 = 미구현과 같다 — 같은 데이터를 JSON 으로 낸다.
17233
+ if (has('--json')) { log(JSON.stringify({ ok: true, root, canonical: '.harness/design-system.md', mergeCandidates: found }, null, 2)); return; }
16991
17234
  log('Canonical design file: .harness/design-system.md');
16992
17235
  if (found.length) { warn('merge candidates found:'); found.forEach(x => log('- ' + x)); }
16993
17236
  else ok('no duplicate design guide candidates');
@@ -18668,10 +18911,25 @@ function releaseCleanupCmd(root) {
18668
18911
  const rd = s => isTty ? `\x1b[31m${s}\x1b[0m` : s;
18669
18912
  const dm = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
18670
18913
  const apply = has('--apply');
18671
- const keep = parseInt(arg('--keep', '5'), 10) || 5;
18914
+ const jsonMode = has('--json');
18915
+ // 1.36.103: `parseInt(...) || 5` 는 사용자가 준 0 을 조용히 5 로 바꿨다(실측: `--keep 0` → "merged 0 ≤ keep 5").
18916
+ // 0 은 "merged 전부 정리" 라는 명확한 의사표시다. 반대로 `--keep -3`/`--keep abc` 는 그대로 통과하거나
18917
+ // 조용히 기본값이 됐다 — 준 값을 무시하려면 최소한 그렇다고 말해야 한다.
18918
+ const _keepRaw = arg('--keep', null);
18919
+ let keep = 5;
18920
+ if (_keepRaw !== null) {
18921
+ const n = Number(_keepRaw);
18922
+ if (!Number.isInteger(n) || n < 0) {
18923
+ failJson(jsonMode, 'invalid_keep', `--keep 은 0 이상의 정수여야 합니다 (받은 값: ${_keepRaw})`);
18924
+ return;
18925
+ }
18926
+ keep = n;
18927
+ }
18672
18928
  // 1) git 저장소 확인
18673
18929
  const headR = cp.spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8' });
18674
18930
  if (headR.status !== 0) {
18931
+ // 1.36.103: --json 이 사람용 텍스트를 내던 자리 — 기계 소비자는 여기서 JSON.parse 에 실패했다.
18932
+ if (jsonMode) { log(JSON.stringify({ ok: false, error: 'git 저장소가 아닙니다', code: 'not_a_git_repo', root, deleted: [], candidates: [] }, null, 2)); process.exitCode = 1; return; }
18675
18933
  log(rd('# leerness release cleanup (1.9.235) — git 저장소 아님'));
18676
18934
  return;
18677
18935
  }
@@ -19497,6 +19755,13 @@ function graphCmd(root) {
19497
19755
  for (const e of edges) lines.push(` "${e.src}" --> "${e.dst}"`);
19498
19756
  lines.push('```');
19499
19757
  const md = `# Code dependency graph\n\n생성: ${now()}\n노드: ${nodeSet.size}, 엣지: ${edges.length}\n\n` + lines.join('\n') + '\n';
19758
+ // 1.36.103: --json 미구현이라 mermaid 마크다운이 stdout 으로 나갔다 — 그래프야말로 기계가 읽을 데이터다.
19759
+ if (has('--json')) {
19760
+ const payload = { ok: true, root, generatedAt: now(), nodes: [...nodeSet], edges, counts: { nodes: nodeSet.size, edges: edges.length } };
19761
+ if (out) { writeUtf8(path.resolve(root, out), md); payload.written = out; }
19762
+ log(JSON.stringify(payload, null, 2));
19763
+ return;
19764
+ }
19500
19765
  if (out) {
19501
19766
  writeUtf8(path.resolve(root, out), md);
19502
19767
  ok(`graph 저장: ${out}`);
@@ -19582,27 +19847,35 @@ async function updateCmd(root, opts = {}) {
19582
19847
  // 1.9.364 (4번째 외부평가 9.3 + Codex CV-2 후속): --quiet — SessionStart hook 비침투화.
19583
19848
  // up-to-date 시 무음, 업데이트 가능 시에만 1줄 통지 (매 세션 노이즈 제거). hook 명령이 이 모드를 사용.
19584
19849
  const quiet = !!opts.quiet || has('--quiet');
19850
+ // 1.36.102: --json 은 사람용 줄을 섞으면 안 된다(계약: 단일 유효 JSON). quiet 과 별개로 둔다 —
19851
+ // `const quiet = ...` 줄 자체는 다른 selftest 가 문자열로 붙잡고 있어 건드리지 않는다.
19852
+ const _humanQuiet = quiet || has('--json');
19585
19853
  const verF = path.join(root, '.harness/HARNESS_VERSION');
19586
19854
  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}`);
19855
+ if (!_humanQuiet) log(`# leerness update`);
19856
+ if (!_humanQuiet) log(`Current: ${cur.raw}`);
19589
19857
  const fromTar = arg('--from', null);
19590
19858
  const cacheHours = opts.checkOnly ? 24 : 0;
19591
19859
  let nextLeerness = VERSION;
19592
- if (fromTar) { if (!quiet) log(`Local tarball mode: ${fromTar}`); }
19860
+ // 1.36.102 (명령 표면 감사 P1): 레지스트리 조회에 실패했는데 결론은 "up to date" 였다 —
19861
+ // 바로 윗줄에서 "(unavailable — 판정 보류)" 라고 해 놓고 아랫줄에서 최신이라 단정하는 자기모순이다.
19862
+ // 모르는 것은 모른다고 말한다. 이 값이 판정 분기까지 흘러간다.
19863
+ let _registryUnknown = false;
19864
+ if (fromTar) { if (!_humanQuiet) log(`Local tarball mode: ${fromTar}`); }
19593
19865
  else {
19594
19866
  const cached = readUpdateCache(root);
19595
19867
  // 1.36.34 (codex 3차 #5): 조회 실패(null)를 24h 캐시로 굳히면 다음 실행이 "up to date" 로 오답 — null 은 캐시하지 않고 재시도.
19596
19868
  if (cacheFresh(cached, cacheHours) && cached.nextLeerness) {
19597
19869
  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}`);
19870
+ if (!_humanQuiet) log(`(cached ${Math.round((Date.now() - cached.at) / 60000)}m ago)`);
19871
+ if (!_humanQuiet) log(`npm leerness latest: ${cached.nextLeerness}`);
19600
19872
  } else {
19601
- if (!quiet) log('Checking npm registry…');
19873
+ if (!_humanQuiet) log('Checking npm registry…');
19602
19874
  const latest = await fetchNpmLatest('leerness');
19603
19875
  nextLeerness = latest || VERSION;
19604
19876
  if (latest) writeUpdateCache(root, { nextLeerness: latest, runningCli: VERSION });
19605
- if (!quiet) log(`npm leerness latest: ${latest || '(unavailable 판정 보류, 다음 실행에서 재조회)'}`);
19877
+ else _registryUnknown = true; // 1.36.102: 조회 실패를 판정까지 끌고 간다(아래 --check 결론에서 사용)
19878
+ if (!_humanQuiet) log(`npm leerness latest: ${latest || '(unavailable — 판정 보류, 다음 실행에서 재조회)'}`);
19606
19879
  }
19607
19880
  }
19608
19881
  // What is "current"? canonical=base; legacy plus also rolls into leerness 1.9.0+
@@ -19616,8 +19889,15 @@ async function updateCmd(root, opts = {}) {
19616
19889
  if (compareVer(nextLeerness, '1.9.0') >= 0) { needsMigrate = true; reason = 'consolidate legacy plus@ marker into canonical'; }
19617
19890
  }
19618
19891
  if (opts.checkOnly) {
19619
- if (quiet) { if (needsMigrate) log(`↑ leerness 업데이트 가능: ${reason} leerness update --yes`); return; } // up-to-date 시 무음
19892
+ // 1.36.102: --json 사람용 텍스트를 내던 것도 함께 고친다(--json 계약: 단일 유효 JSON).
19893
+ const _verdict = needsMigrate ? 'migration_available' : (_registryUnknown ? 'unknown' : 'up_to_date');
19894
+ if (has('--json')) {
19895
+ log(JSON.stringify({ ok: true, current: installed || null, cli: VERSION, latest: _registryUnknown ? null : nextLeerness, registryReachable: !_registryUnknown, verdict: _verdict, reason: needsMigrate ? reason : null }, null, 2));
19896
+ return;
19897
+ }
19898
+ if (quiet) { if (needsMigrate) log(`↑ leerness 업데이트 가능: ${reason} — leerness update --yes`); return; } // up-to-date/unknown 시 무음
19620
19899
  if (needsMigrate) log(`\n→ migration available: ${reason}`);
19900
+ else if (_registryUnknown) log('\n→ 확인 불가 — 레지스트리를 조회하지 못했습니다(최신 여부를 알 수 없음). 네트워크 복구 후 다시 실행하세요.');
19621
19901
  else log('\n→ up to date');
19622
19902
  return;
19623
19903
  }
@@ -24955,6 +25235,42 @@ async function main() {
24955
25235
  return log(VERSION);
24956
25236
  }
24957
25237
  if (has('--help') || has('-h')) return help();
25238
+ // 1.36.103: 실재하지 않는 플래그(오타)를 조용히 삼키지 않는다.
25239
+ // 값-플래그 미등록 45종은 _VALUE_FLAGS 등록으로 닫았지만, 사용자 오타(--prio, --resaon)는 여전히
25240
+ // 그 값이 본문에 붙는다(실측: `decision add "use redis" --resaon faster` → 저장된 제목 "use redis faster").
25241
+ // 파싱 기본값을 뒤집으면 미분류 부울 리터럴 쪽에서 positional 을 삼키는 새 회귀가 나므로, 동작 대신
25242
+ // 가시성을 준다 — stderr 로만 알리고 stdout/--json/exit code 는 건드리지 않는다(기계 소비자 무영향).
25243
+ {
25244
+ const _unknownFlags = [];
25245
+ for (const _tok of process.argv.slice(2)) {
25246
+ if (typeof _tok !== 'string' || !_tok.startsWith('--') || _tok === '--') continue;
25247
+ const _name = _tok.split('=')[0];
25248
+ if (_VALUE_FLAGS.has(_name) || _BOOL_FLAGS.has(_name)) continue;
25249
+ if (!_unknownFlags.includes(_name)) _unknownFlags.push(_name);
25250
+ }
25251
+ if (_unknownFlags.length) {
25252
+ const _L = _uiLang(process.cwd());
25253
+ console.error(_L === 'en'
25254
+ ? `⚠ Unknown flag: ${_unknownFlags.join(' ')} — no such flag in leerness. Its value can be absorbed into the text or path argument. Check: leerness --help`
25255
+ : `⚠ 알 수 없는 플래그: ${_unknownFlags.join(' ')} — leerness 에 없는 이름입니다. 그 값이 본문이나 경로 인자에 섞일 수 있습니다. 확인: leerness --help`);
25256
+ }
25257
+ }
25258
+ // 1.36.103 (codex 검수 #9): --keep 은 소비처가 둘인데 한쪽(release cleanup)은 거부하고 다른 쪽(아카이브 retention)은
25259
+ // 조용히 기본값 10 이 됐다 — `init . --keep abc --json` 이 exit 0 + {ok:true} 로 끝났다.
25260
+ // "준 값을 조용히 바꾸지 않는다" 를 소비처마다 반복하지 않고 진입점 한 곳에서 강제한다.
25261
+ {
25262
+ const _k = arg('--keep', null);
25263
+ if (_k !== null) {
25264
+ const _kn = Number(_k);
25265
+ if (!Number.isInteger(_kn) || _kn < 0) {
25266
+ const _enK = _uiLang(process.cwd()) === 'en';
25267
+ failJson(has('--json'), 'invalid_keep', _enK
25268
+ ? `--keep must be an integer >= 0 (got: ${_k})`
25269
+ : `--keep 은 0 이상의 정수여야 합니다 (받은 값: ${_k})`);
25270
+ return;
25271
+ }
25272
+ }
25273
+ }
24958
25274
  // 1.36.101 (명령 표면 감사): '없는 경로에 초록 보고' 클래스를 디스패처 한 곳에서 막는다.
24959
25275
  // 실측 — 32개 조사 대상 중 16개가 존재하지 않는 디렉토리에 exit 0 과 정상 보고를 냈다
24960
25276
  // (pulse/round-history/milestones/session-resume 는 "✓ 비정상 종료 신호 없음 · R0 · 정상 운영 중" 까지 찍었다 — 오타가 안심 보고가 된다).
@@ -24971,7 +25287,32 @@ async function main() {
24971
25287
  const _isStrict = _strict.has(cmd) || (_strictPair[cmd] && args[1] === _strictPair[cmd]);
24972
25288
  if (_isStrict) {
24973
25289
  // 사용자가 '경로를 명시했을 때만' 검사한다 — 미지정(cwd)은 항상 존재하므로 검사할 것이 없다.
24974
- const _explicit = arg('--path', null) || [args[1], args[2]].find(a => a && !a.startsWith('-') && a !== _strictPair[cmd]);
25290
+ // 1.36.103 (자기 회귀 수리): 가드는 args[1] 무조건 경로로 봤다. 하위명령을 가진 명령에서는 그게 틀린다 —
25291
+ // `leerness library page` 가 "경로 없음: <cwd>\page" 로 거부됐다(1.36.101/102 에 게시된 상태로 확인).
25292
+ // 그건 library 가 자기 출력에서 안내하는 명령이고, P-0013 사람용 페이지의 유일한 진입점이다.
25293
+ // 하위명령을 받는 명령에 한해 **선행 구분자 path-like 일 때만** 경로로 본다. 나머지(하위명령 없는 명령)는
25294
+ // 맨이름 오타도 경로로 잡아야 하므로 1.36.101 동작을 그대로 둔다.
25295
+ // tech 는 sub 를 받기만 하고 쓰지 않는다(하위명령 없음) → args[1] 은 언제나 경로다. library 만 'page' 를 갖는다.
25296
+ // 알려진 하위명령도 아니고 경로형도 아니면 그건 오타다 — 조용히 cwd 에서 초록 보고를 내는 대신 그렇다고 말한다
25297
+ // (좁히다가 false-BLOCK 을 false-PASS 로 바꾸면 1.36.101 이 잡던 바로 그 증상이 되돌아온다).
25298
+ const _SUBS = { library: new Set(['page']) };
25299
+ const _looksPath = (a) => /^([A-Za-z]:[\\/]|\/|\.\.?[\\/])/.test(a);
25300
+ let _cands = [args[1], args[2]];
25301
+ if (_SUBS[cmd] && args[1] && !args[1].startsWith('-')) {
25302
+ if (_SUBS[cmd].has(args[1])) _cands = [null, args[2]]; // 하위명령 — 경로 아님
25303
+ // codex 검수 #4 는 "존재하는 디렉토리면 맨이름도 경로로 받아 달라" 였다 — 받지 않는다.
25304
+ // root 해석(_taskPositionalPath)은 선행 구분자만 경로로 인정한다(제목 'src/auth' 를 경로로 오인하지 않기 위한 계약).
25305
+ // 여기서만 통과시키면 검증은 하고 root 는 cwd 로 가는, 이 라운드가 고치고 있는 바로 그 형태가 된다.
25306
+ // 그래서 거부하되 무엇을 치면 되는지(./name) 를 문장에 넣는다.
25307
+ else if (!_looksPath(args[1])) {
25308
+ const _en0 = _uiLang(process.cwd()) === 'en';
25309
+ failJson(has('--json'), 'unknown_subcommand', _en0
25310
+ ? `unknown ${cmd} subcommand: ${args[1]} (available: ${[..._SUBS[cmd]].join(', ')}) — for a path use an explicit form like ./${args[1]}`
25311
+ : `알 수 없는 ${cmd} 하위명령: ${args[1]} (가능: ${[..._SUBS[cmd]].join(', ')}) — 경로라면 ./${args[1]} 처럼 명시하세요`);
25312
+ return;
25313
+ }
25314
+ }
25315
+ const _explicit = arg('--path', null) || _cands.find(a => a && !a.startsWith('-') && a !== _strictPair[cmd]);
24975
25316
  if (_explicit) {
24976
25317
  const _abs = absRoot(_explicit);
24977
25318
  if (!exists(_abs) || !fs.statSync(_abs).isDirectory()) {
@@ -25093,7 +25434,10 @@ async function main() {
25093
25434
  if (cmd === 'agents' && args[1] === 'route') {
25094
25435
  const _rtRaw = process.argv.slice(2); const _rtI = _rtRaw.indexOf('route'); const _rtToks = [];
25095
25436
  for (let i = _rtI + 1; i < _rtRaw.length && _rtI >= 0; i++) {
25096
- if (_rtRaw[i].startsWith('--')) { if (['--tier', '--approved-by', '--path'].includes(_rtRaw[i]) && _rtRaw[i + 1] && !_rtRaw[i + 1].startsWith('--')) i++; continue; }
25437
+ // 1.36.103: 자체 인라인 목록(--tier/--approved-by/--path) 단일 출처 _VALUE_FLAGS 교체.
25438
+ // 같은 클래스의 허용목록이 다섯 벌 있었고 각각 따로 뒤처졌다 — 목록이 늘어나면 갭도 같이 늘어난다.
25439
+ // 등호형(--tier=normal)은 값을 자기가 들고 있다 — 다음 토큰까지 건너뛰면 위치인자를 먹는다(codex 검수 #6 재현).
25440
+ if (_rtRaw[i].startsWith('--')) { if (!_rtRaw[i].includes('=') && _VALUE_FLAGS.has(_rtRaw[i]) && _rtRaw[i + 1] && !_rtRaw[i + 1].startsWith('--')) i++; continue; }
25097
25441
  _rtToks.push(_rtRaw[i]);
25098
25442
  }
25099
25443
  // _providerAuth (1.36.91): 인증 축을 라우팅이 소비한다 — 확인 가능한 provider 만 ok/no, 나머지는 unknown.
@@ -25232,9 +25576,9 @@ async function main() {
25232
25576
  if (cmd === 'anchors') return anchorsCmd(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd(), args[1] && !args[1].startsWith('-') ? args[1] : null); // 1.36.36: 정체성앵커 초안
25233
25577
  if (cmd === 'toggle') return _tgl.toggleCmd(arg('--path', process.cwd()), args[1], args[2], args[3], { has, VERSION }); // 1.36.30: 기능 토글 (그래프 ⚙ 탭 연동)
25234
25578
  // 1.36.53 (UR-0062): 기술 프로필 · 1.36.67 (F15): 변경 시 기존 leerness.html 동반 갱신(있을 때만)
25235
- if (cmd === 'tech') return _tech.techCmd(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd(), args[1], { has, regenGraph: (r) => { if (exists(path.join(r, 'leerness.html'))) _graph.graphHtmlCmd(r, { _roadmapData, _loadDecisions, _loadLessons, _parseFeatureGraph, _loadToggles: _tgl.loadToggles, _toggleRegistry: _tgl.TOGGLE_REGISTRY, _loadTechProfile: _tech.loadTechProfile, quiet: true }); } });
25579
+ if (cmd === 'tech') return _tech.techCmd(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd(), _optSub(args), { has, regenGraph: (r) => { if (exists(path.join(r, 'leerness.html'))) _graph.graphHtmlCmd(r, { _roadmapData, _loadDecisions, _loadLessons, _parseFeatureGraph, _loadToggles: _tgl.loadToggles, _toggleRegistry: _tgl.TOGGLE_REGISTRY, _loadTechProfile: _tech.loadTechProfile, quiet: true }); } });
25236
25580
  // 1.36.98 (P-0013): 재사용 인벤토리 — 새 화면 조각을 만들기 전에 '이미 있는 것' 을 먼저 본다(reuse-map 의 UI 판).
25237
- if (cmd === 'library') return require('../lib/library').libraryCmd(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd(), args[1], { has, log, ok, warn, failJson });
25581
+ if (cmd === 'library') return require('../lib/library').libraryCmd(arg('--path', null) || _taskPositionalPath(args, 1) || process.cwd(), _optSub(args), { has, log, ok, warn, failJson });
25238
25582
  if (cmd === 'integrity') { // 1.36.57 (감사 F-04): managed 문서 무결성 점검/복구
25239
25583
  if (args[1] && args[1] !== 'check') { failJson(has('--json'), 'unknown_subcommand', `알 수 없는 integrity 하위명령: ${args[1]} (가능: check [--repair] [--force])`); return process.exit(process.exitCode || 1); }
25240
25584
  return integrityCmd(arg('--path', null) || _taskPositionalPath(args, 2) || process.cwd());
@@ -25245,7 +25589,7 @@ async function main() {
25245
25589
  // 미지 플래그(--design/--features/--note)의 값이 positional 로 새는 것 차단 — 원시 argv 에서 플래그+값 스킵 (1.36.49 release note 패턴)
25246
25590
  const _pvRaw = process.argv.slice(2); const _pvI = _pvRaw.indexOf('preview'); const _pvToks = [];
25247
25591
  for (let i = _pvI + 1; i < _pvRaw.length && _pvI >= 0; i++) {
25248
- if (_pvRaw[i].startsWith('--')) { if (['--design', '--features', '--note', '--path', '--mockup'].includes(_pvRaw[i]) && _pvRaw[i + 1]) i++; continue; } // 1.36.75 (검수 #2): --mockup 값이 제목에 흡수되던 것
25592
+ if (_pvRaw[i].startsWith('--')) { if (!_pvRaw[i].includes('=') && _VALUE_FLAGS.has(_pvRaw[i]) && _pvRaw[i + 1]) i++; continue; } // 1.36.75 (검수 #2): --mockup 값이 제목에 흡수되던 것. 1.36.103: 인라인 목록 → _VALUE_FLAGS (--select 등이 제목에 붙던 ) + 등호형은 다음 토큰을 먹지 않음(codex 검수 #5 재현)
25249
25593
  _pvToks.push(_pvRaw[i]);
25250
25594
  }
25251
25595
  // 1.36.80 (UR-0067): 라이브 미리보기 — serve(로컬 서버) / mode(self|project) 는 별도 모듈로 위임
@@ -25258,7 +25602,7 @@ async function main() {
25258
25602
  if (cmd === 'referee') {
25259
25603
  const _rvRaw = process.argv.slice(2); const _rvI = _rvRaw.indexOf('referee'); const _rvToks = [];
25260
25604
  for (let i = _rvI + 1; i < _rvRaw.length && _rvI >= 0; i++) {
25261
- if (_rvRaw[i].startsWith('--')) { if (['--check', '--good', '--bad', '--expect-bad', '--path', '--timeout'].includes(_rvRaw[i]) && _rvRaw[i + 1]) i++; continue; }
25605
+ if (_rvRaw[i].startsWith('--')) { if (!_rvRaw[i].includes('=') && _VALUE_FLAGS.has(_rvRaw[i]) && _rvRaw[i + 1]) i++; continue; } // 1.36.103: 인라인 목록 → _VALUE_FLAGS · 등호형 제외(codex 검수 #7)
25262
25606
  _rvToks.push(_rvRaw[i]);
25263
25607
  }
25264
25608
  return refereeCmd(arg('--path', process.cwd()), _rvToks[0], _rvToks.slice(1));
@@ -25267,7 +25611,7 @@ async function main() {
25267
25611
  if (cmd === 'bugfix') {
25268
25612
  const _bfRaw = process.argv.slice(2); const _bfI = _bfRaw.indexOf('bugfix'); const _bfToks = [];
25269
25613
  for (let i = _bfI + 1; i < _bfRaw.length && _bfI >= 0; i++) {
25270
- if (_bfRaw[i].startsWith('--')) { if (['--repro', '--expect-bad', '--root-cause', '--siblings', '--siblings-na', '--previous-gap', '--path'].includes(_bfRaw[i]) && _bfRaw[i + 1]) i++; continue; }
25614
+ if (_bfRaw[i].startsWith('--')) { if (!_bfRaw[i].includes('=') && _VALUE_FLAGS.has(_bfRaw[i]) && _bfRaw[i + 1]) i++; continue; } // 1.36.103: 인라인 목록 → _VALUE_FLAGS · 등호형 제외(codex 검수 #8)
25271
25615
  _bfToks.push(_bfRaw[i]);
25272
25616
  }
25273
25617
  // 1.36.88 (출하전 헌트 #7): `bugfix list <path>` 가 위치 경로를 조용히 무시하고 cwd 를 읽어,
@@ -25455,7 +25799,19 @@ async function main() {
25455
25799
  if (cmd === 'plan') {
25456
25800
  const root = absRoot(arg('--path', process.cwd())); const sub = args[1] || 'show';
25457
25801
  if (sub==='show') return planShow(root);
25458
- if (sub==='init') return planInit(root);
25802
+ // 1.36.102 (명령 표면 감사 P1): `plan init <경로>` 가 positional 을 무시하고 **cwd 에** 워크스페이스를 깔았다.
25803
+ // 실측 재현: cwd 1개 파일 → 72개, 대상 디렉토리는 1개 그대로. 홈에서 실행하면 홈이 오염된다.
25804
+ // plan 그룹은 --path 만 읽고 positional 은 list 만 지원했다 — 파괴적인 init 이 그 갭에 있었다.
25805
+ // 무시하지 말고 명시적으로 처리한다: 준 경로에 적용하되, 존재하지 않으면 만들지 말고 거부한다.
25806
+ if (sub==='init') {
25807
+ const _pos = args[2] && !args[2].startsWith('-') ? args[2] : null;
25808
+ if (_pos) {
25809
+ const _abs = absRoot(_pos);
25810
+ if (!exists(_abs) || !fs.statSync(_abs).isDirectory()) { failJson(has('--json'), 'path_not_found', `경로 없음 또는 디렉토리 아님: ${_abs}`); return; }
25811
+ return planInit(_abs);
25812
+ }
25813
+ return planInit(root);
25814
+ }
25459
25815
  if (sub==='add') return planAdd(root, args.slice(2).join(' ').trim() || '새 계획'); // 17th 버그헌트 P2: 공백-only 제목이 || 기본값을 우회(truthy)해 plan.md 손상(파서가 다음 줄 'Status:' 흡수) → trim 후 판정
25460
25816
  if (sub==='drop') return planDrop(root, args.slice(2).join(' ') || '드랍 항목');
25461
25817
  if (sub==='remove') return planRemoveCmd(root, args[2]);
package/lib/io.js CHANGED
@@ -18,13 +18,21 @@ function log(s = '') {
18
18
  // 1.36.54 (codex 7차 #7a): JSON 통과는 지연-fail(폴백 대기) 경로 전용 — failJson 이 이미 오류 JSON 을
19
19
  // 방출했다면(_jsonErrEmitted) 추가 JSON 은 이중 문서가 되므로 stderr 강등.
20
20
  const t = typeof s === 'string' ? s.trim() : '';
21
- if (!_jsonErrEmitted && t && (t[0] === '{' || t[0] === '[')) { try { JSON.parse(t); console.log(s); _stdoutWrote = true; return; } catch {} }
21
+ if (!_jsonErrEmitted && t && (t[0] === '{' || t[0] === '[')) { try { JSON.parse(t); console.log(s); return; } catch {} }
22
22
  console.error(s); return;
23
23
  }
24
- console.log(s); _stdoutWrote = true;
24
+ console.log(s);
25
25
  }
26
- function ok(s) { log(' ' + s); }
27
- function warn(s) { log('⚠ ' + s); }
26
+ // 1.36.103: _stdoutWrote 는 'log() 불렀다' 가 아니라 '실제로 stdout 썼다' 여야 한다.
27
+ // 여기서 직접 세우면, gate 처럼 하위 출력을 삼키려고 process.stdout.write 를 no-op 으로 바꾼 구간에서
28
+ // 바이트는 한 개도 안 나갔는데 표식만 서서 폴백 오류 JSON 이 막혔다(실측: gate <없는경로> --json → stdout 0바이트).
29
+ // 추적은 stdout 래퍼 한 곳에만 둔다 — 래퍼가 교체된 동안의 출력은 정의상 stdout 에 도달하지 않은 것이다.
30
+ // 1.36.103: --json 모드에서 ✓/⚠ 는 절대 JSON 페이로드의 일부가 될 수 없다 — stdout 이 아니라 stderr 로 낸다.
31
+ // 기존에는 fail() 이 래치된 뒤에만 강등돼, 경고가 오류보다 먼저 나오면 stdout 을 오염시켰다.
32
+ // 실측: `reuse-map|retro|insights --include <없는프로젝트> --json` → stdout 이 "⚠ --include 무시: …" 한 줄뿐이라
33
+ // JSON.parse 가 실패한다(그리고 _stdoutWrote 가 서 버려 폴백 오류 JSON 도 막힌다).
34
+ function ok(s) { if (_argvJsonMode() && !_quiet) { console.error('✓ ' + s); return; } log('✓ ' + s); }
35
+ function warn(s) { if (_argvJsonMode() && !_quiet) { console.error('⚠ ' + s); return; } log('⚠ ' + s); }
28
36
  // fail() 은 오류 신호 → exit code 1 설정 (CI/MCP/에이전트가 실패를 성공으로 오판 방지, UR-0045).
29
37
  // 1.36.50 (codex 스윕 B, 40+ 실측): --json 요청 시 fail() 자체가 단일 JSON 계약을 이행 —
30
38
  // 첫 오류를 {ok:false,error,code:'error'} 로 stdout 에 내고 이후 사람용 출력(log/ok/warn·추가 fail)은
@@ -39,14 +47,34 @@ let _failLatched = false; // fail() json 모드 발화 표식 (사람용 lo
39
47
  let _firstFail = null;
40
48
  let _stdoutWrote = false;
41
49
  let _exitHooked = false;
50
+ // 1.36.103: 폴백 JSON 은 '지금의' process.stdout.write 가 아니라 로드 시점의 원본 fd 로 쓴다.
51
+ // gate 는 JSON 모드에서 하위 단계 출력을 삼키려고 write 를 no-op 으로 갈아끼우는데(bin 17162),
52
+ // 그 구간에서 process.exit() 가 나면 finally 가 돌지 않아 복원되지 않는다 → console.log 가 통째로 삼켜져
53
+ // `--json` 인데 stdout 0바이트 + exit 1 이 됐다(실측). 원본을 잡아두면 그 상태에서도 계약을 지킬 수 있다.
54
+ let _rawStdoutWrite = null;
42
55
  let _latchArgv = null; // 래치는 argv 정체성 스코프 — 새 명령(REPL/인프로세스 테스트가 argv 교체) 시 자동 해제
43
56
  // stdout 기록 추적은 스트림 레벨 — 일부 명령(audit 등)이 process.stdout.write 를 직접 캡처/사용해 log() 를 우회한다.
44
57
  // 모듈 로드 시 1회 투명 래핑: 이후 어떤 경로(직접 write 포함)로든 stdout 에 쓰면 exit 폴백이 이중 JSON 을 내지 않는다.
45
58
  {
46
- const _rawWrite = process.stdout.write.bind(process.stdout);
47
- process.stdout.write = function (...a) { _stdoutWrote = true; return _rawWrite(...a); };
59
+ _rawStdoutWrite = process.stdout.write.bind(process.stdout);
60
+ process.stdout.write = function (...a) { _stdoutWrote = true; return _rawStdoutWrite(...a); };
48
61
  }
49
62
  function _argvJsonMode() { try { return process.argv.includes('--json'); } catch { return false; } }
63
+ // 1.36.103: 폴백 오류 JSON 을 fail() 안이 아니라 모듈 로드 시 설치한다.
64
+ // fail() 을 거치지 않고 실패하는 경로(하위 점검이 직접 console.error 후 exit)에서는 훅이 아예 걸리지 않아
65
+ // `--json` 인데 stdout 이 완전히 비고 exit 1 인 상태가 나왔다(실측: gate <없는경로> --json → stdout 0바이트).
66
+ // 기계 소비자에게 '빈 stdout' 은 파싱 불가와 같다 — --json 은 실패해도 반드시 JSON 하나를 남긴다.
67
+ function _installJsonExitFallback() {
68
+ if (_exitHooked) return;
69
+ _exitHooked = true;
70
+ process.on('exit', (code) => {
71
+ if (code === 0 || _stdoutWrote || !_argvJsonMode()) return;
72
+ const payload = JSON.stringify({ ok: false, error: _firstFail != null ? _firstFail : 'command failed', code: 'error' }, null, 2) + '\n';
73
+ if (_rawStdoutWrite) { try { _rawStdoutWrite(payload); return; } catch { /* 원본 write 실패 시 console 로 폴백 */ } }
74
+ console.log(payload.trimEnd());
75
+ });
76
+ }
77
+ _installJsonExitFallback();
50
78
  function _unlatchIfNewCmd() {
51
79
  if ((_jsonErrEmitted || _failLatched) && _latchArgv !== process.argv) { _jsonErrEmitted = false; _failLatched = false; _firstFail = null; _quiet = false; _latchArgv = null; }
52
80
  }
@@ -56,11 +84,9 @@ function fail(s) {
56
84
  if (_firstFail == null) _firstFail = s;
57
85
  _failLatched = true; _latchArgv = process.argv;
58
86
  console.error('✗ ' + s);
59
- if (!_exitHooked) {
60
- _exitHooked = true;
61
- // 1.36.54 (#7b): 최종 exit 이 0(명령이 회복)이면 폴백 오류 JSON 을 내지 않는다 — 성공 exit 에 ok:false 모순 방지
62
- process.on('exit', (code) => { if (code !== 0 && _failLatched && !_stdoutWrote && _firstFail != null) console.log(JSON.stringify({ ok: false, error: _firstFail, code: 'error' }, null, 2)); });
63
- }
87
+ // 1.36.54 (#7b): 최종 exit 이 0(명령이 회복)이면 폴백 오류 JSON 을 내지 않는다 — 성공 exit 에 ok:false 모순 방지.
88
+ // 1.36.103: 훅 설치는 모듈 로드 시점으로 옮겼다(_installJsonExitFallback) — fail() 을 안 거치는 실패 경로도 덮기 위해.
89
+ _installJsonExitFallback();
64
90
  process.exitCode = 1; return;
65
91
  }
66
92
  console.log('✗ ' + s); process.exitCode = 1; // quiet 무시(오류는 항상 노출)
@@ -70,7 +96,8 @@ function fail(s) {
70
96
  function failJson(jsonMode, code, msg) {
71
97
  _unlatchIfNewCmd();
72
98
  if (jsonMode) {
73
- if (!_jsonErrEmitted) { console.log(JSON.stringify({ ok: false, error: msg, code }, null, 2)); _jsonErrEmitted = true; _stdoutWrote = true; _failLatched = true; _latchArgv = process.argv; }
99
+ // 1.36.103: _stdoutWrote 래퍼가 세운다 여기서 세우면 삼켜진 출력을 '썼다' 오인한다.
100
+ if (!_jsonErrEmitted) { console.log(JSON.stringify({ ok: false, error: msg, code }, null, 2)); _jsonErrEmitted = true; _failLatched = true; _latchArgv = process.argv; }
74
101
  else console.error('✗ ' + msg);
75
102
  process.exitCode = 1;
76
103
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.101",
3
+ "version": "1.36.103",
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
 
@@ -9619,6 +9623,133 @@ total++;
9619
9623
  if (!ok) failed++;
9620
9624
  }
9621
9625
 
9626
+ // ── 1.36.103 A: 값-플래그의 값이 사용자 데이터/경로로 흡수되지 않는다.
9627
+ // 허용목록이 소비자보다 45개 뒤처져 있었고, 같은 클래스의 목록이 다섯 벌로 갈라져 각자 따로 뒤처졌다.
9628
+ // 실측 재현: `preview add "제목" --select X` → 저장 제목 「제목 X」 · `review --persona security f.js` → 파일이 "security".
9629
+ {
9630
+ total++;
9631
+ let ok = false; const dbg = {};
9632
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-fl103-'));
9633
+ const R = (a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: d, encoding: 'utf8', timeout: 120000 });
9634
+ const out = (r) => (r.stdout || '') + (r.stderr || '');
9635
+ try {
9636
+ fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'fl', version: '0.1.0' }));
9637
+ fs.writeFileSync(path.join(d, 'sample.js'), 'function f(){ return 1; }\n');
9638
+ R(['init', d, '--yes']);
9639
+ const allText = () => { let s = ''; (function w(y) { for (const e of fs.readdirSync(y, { withFileTypes: true })) { const p = path.join(y, e.name); if (e.isDirectory()) { if (!/node_modules|\.git$/.test(e.name)) w(p); } else { try { s += fs.readFileSync(p, 'utf8'); } catch {} } } })(d); return s; };
9640
+ R(['preview', 'add', 'cart page', '--select', 'ZZA1']);
9641
+ R(['task', 'add', 'do work', '--priority', 'ZZA2']);
9642
+ R(['requests', 'add', 'add mode', '--summary', 'ZZA3']);
9643
+ const txt = allText();
9644
+ dbg.absorbed = ['ZZA1', 'ZZA2', 'ZZA3'].filter(m => txt.includes(m));
9645
+ // 제품이 스스로 안내하는 문법(persona list 의 "leerness review <file> --persona <id>")은 순서와 무관해야 한다
9646
+ const rev = R(['review', '--persona', 'security', 'sample.js']);
9647
+ dbg.revOk = rev.status === 0 && /sample\.js/.test(rev.stdout || '');
9648
+ // 오타는 조용히 삼키지 않고 stderr 로 알린다 — 판별 대조군: 실재 플래그엔 침묵
9649
+ const W = /알 수 없는 플래그|Unknown flag/;
9650
+ dbg.typoWarns = W.test(R(['task', 'add', 'x', '--prio', 'high']).stderr || '');
9651
+ dbg.realQuiet = !W.test(R(['task', 'list', '--json']).stderr || '') && !W.test(R(['audit', '--no-secret-scan']).stderr || '');
9652
+ // 경고가 stdout/JSON 을 오염시키지 않는다
9653
+ const jr = R(['pulse', '--json', '--nosuchflagzz']);
9654
+ let jsonOk = false; try { JSON.parse((jr.stdout || '').trim()); jsonOk = true; } catch {}
9655
+ dbg.jsonOk = jsonOk && !W.test(jr.stdout || '');
9656
+ // 등호형(--flag=value)이 다음 토큰까지 먹지 않는다 — 인라인 목록을 공유 집합으로 바꾸며 내가 만든 회귀(codex 검수 #5~#8)
9657
+ R(['preview', 'add', '--select=ZZEQ', 'equals title']);
9658
+ const pvp = path.join(d, '.harness', 'previews.json');
9659
+ const pvt = fs.existsSync(pvp) ? fs.readFileSync(pvp, 'utf8') : '';
9660
+ dbg.equals = pvt.includes('equals title') && !pvt.includes('ZZEQ');
9661
+ const rEq = R(['agents', 'route', '--tier=normal', 'fix the parser']);
9662
+ dbg.equalsRoute = !/비어 있|empty_task/.test((rEq.stdout || '') + (rEq.stderr || ''));
9663
+ // 동적 레지스트리(brief set 은 arg('--'+f.flag))에 오탐 경고 없음 + 진짜 오타는 경고(판별 대조군)
9664
+ dbg.dynamic = !W.test((R(['brief', 'set', '--intro', 'hello']).stderr) || '') && W.test((R(['brief', 'set', '--introo', 'x']).stderr) || '');
9665
+ ok = dbg.absorbed.length === 0 && dbg.revOk && dbg.typoWarns && dbg.realQuiet && dbg.jsonOk
9666
+ && dbg.equals && dbg.equalsRoute && dbg.dynamic;
9667
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
9668
+ console.log(ok ? '✓ A(1.36.103) 값-플래그 흡수 차단(제목/경로) + 등호형 무흡수 + 동적 레지스트리 무오탐 + 오타만 stderr 경고 + JSON 무오염'
9669
+ : '✗ 1.36.103 플래그 계약 실패 ' + JSON.stringify(dbg));
9670
+ if (!ok) failed++;
9671
+ }
9672
+
9673
+ // ── 1.36.103 B: --json 은 실패해도 stdout 에 파싱 가능한 JSON 하나를 남긴다.
9674
+ // 위반 5건이 서로 다른 이유였다 — 경고가 stdout 선점 / 실패 분기에 JSON 없음 / 출력을 삼키는 구간에서 표식만 서서 폴백 차단.
9675
+ {
9676
+ total++;
9677
+ let ok = false; const dbg = {};
9678
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-js103-'));
9679
+ const R = (a) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: d, encoding: 'utf8', timeout: 180000 });
9680
+ try {
9681
+ fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'js', version: '0.1.0' }));
9682
+ R(['init', d, '--yes']);
9683
+ const J = (a) => { const r = R(a); const t = ((r.stdout) || '').trim(); let j = null; try { j = JSON.parse(t); } catch {} return { r, j, empty: !t }; };
9684
+ const g = J(['gate', 'no-such-gate', '--json']);
9685
+ const rc = J(['release', 'cleanup', '--json']);
9686
+ const inc = J(['reuse-map', '--include', 'no-such-project-zz', '--json']);
9687
+ dbg.gate = !!g.j && g.j.ok === false && !g.empty;
9688
+ dbg.release = !!rc.j && rc.j.code === 'not_a_git_repo';
9689
+ dbg.include = !!inc.j;
9690
+ // 대조군 — 성공 경로에 폴백이 끼어들면 "항상 ok:false" 로도 통과한다
9691
+ const p = J(['pulse', '--json']);
9692
+ dbg.success = p.r.status === 0 && !!p.j && p.j.ok !== false && p.j.version !== undefined;
9693
+ // --keep 은 준 값을 조용히 바꾸지 않는다 (0 은 유효, abc/-3 은 거부)
9694
+ const kb = J(['release', 'cleanup', '--keep', 'abc', '--json']);
9695
+ const kn = J(['release', 'cleanup', '--keep', '-3', '--json']);
9696
+ const ki = J(['init', path.join(d, 'KEEPTEST'), '--yes', '--keep', 'abc', '--json']); // 소비처 둘이 서로 다르게 굴던 것(codex 검수 #9)
9697
+ const k0 = J(['release', 'cleanup', '--keep', '0', '--json']); // 대조군: 0 은 유효
9698
+ dbg.keep = !!kb.j && kb.j.code === 'invalid_keep' && !!kn.j && kn.j.code === 'invalid_keep'
9699
+ && !!ki.j && ki.j.code === 'invalid_keep' && !!k0.j && k0.j.code !== 'invalid_keep';
9700
+ // 개별 케이스가 아니라 계약을 건다 — 표면을 훑어 "stdout 은 언제나 JSON 문서 하나" 를 단언한다.
9701
+ // 여기 셋(consistency check · agent-mode tick · graph)은 --json 구현 자체가 없어 사람용 텍스트를 냈다
9702
+ // (게시된 1.36.102 에서 3/30 위반으로 확인 → 구현 후 0/30).
9703
+ const SWEEP = [
9704
+ ['consistency', 'check', '--json'], ['agent-mode', 'tick', '--json'], ['graph', '--json'],
9705
+ ['anchors', '--json'], ['capabilities', '--json'], ['verify', '--json'], ['status', '--json'],
9706
+ ['enforce', 'status', '--json'], ['constraints', 'list', '--json'], ['next-action', 'list', '--json'],
9707
+ ];
9708
+ const swept = SWEEP.map(a => ({ a, r: J(a) })).filter(x => !x.r.j);
9709
+ dbg.sweepBad = swept.map(x => x.a.join(' '));
9710
+ ok = dbg.gate && dbg.release && dbg.include && dbg.success && dbg.keep && swept.length === 0;
9711
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
9712
+ console.log(ok ? '✓ B(1.36.103) --json 실패 경로 3종이 유효 JSON + 성공 경로 무간섭(대조군) + --keep 값 무시 금지 + 10표면 스윕'
9713
+ : '✗ 1.36.103 JSON 계약 실패 ' + JSON.stringify(dbg));
9714
+ if (!ok) failed++;
9715
+ }
9716
+
9717
+ // ── 1.36.103 C: 검증한 경로를 실제로 쓴다 + 하위명령을 경로로 오인하지 않는다.
9718
+ // 1.36.101 의 path-strict 가 args[1] 을 무조건 경로로 봐서 `leerness library page` 를 거부했고(게시된 1.36.102
9719
+ // 에서 확인), 같은 두 명령이 root 해석은 index 2 부터 시작해 검증된 경로를 건너뛰고 cwd 에 썼다.
9720
+ // 관측값은 파일시스템이다 — 문구가 아니라 '어디에 썼는지'.
9721
+ {
9722
+ total++;
9723
+ let ok = false; const dbg = {};
9724
+ const arena = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-rt103-'));
9725
+ try {
9726
+ const cwdDir = path.join(arena, 'CWDPROJ'), target = path.join(arena, 'TARGETPROJ');
9727
+ for (const x of [cwdDir, target]) { fs.mkdirSync(x, { recursive: true }); fs.writeFileSync(path.join(x, 'package.json'), '{"name":"x","version":"0.1.0"}'); }
9728
+ const R = (a, cwd) => cp.spawnSync(process.execPath, [CLI, ...a], { cwd: cwd || cwdDir, encoding: 'utf8', timeout: 120000 });
9729
+ R(['init', cwdDir, '--yes'], cwdDir); R(['init', target, '--yes'], target);
9730
+ const tp = (x) => path.join(x, '.harness', 'tech-profile.json');
9731
+ const clear = (...ps) => { for (const p of ps) { try { fs.unlinkSync(p); } catch {} } };
9732
+ clear(tp(cwdDir), tp(target)); R(['tech', target]);
9733
+ dbg.wroteTarget = fs.existsSync(tp(target)) && !fs.existsSync(tp(cwdDir));
9734
+ clear(tp(cwdDir), tp(target)); R(['tech']);
9735
+ dbg.bareIsCwd = fs.existsSync(tp(cwdDir)) && !fs.existsSync(tp(target));
9736
+ const htmlC = path.join(cwdDir, 'leerness-library.html'), htmlT = path.join(target, 'leerness-library.html');
9737
+ clear(htmlC, htmlT);
9738
+ R(['library', 'page']); dbg.pageBare = fs.existsSync(htmlC);
9739
+ R(['library', 'page', target]); dbg.pageWithPath = fs.existsSync(htmlT);
9740
+ const lo = (R(['library', target]).stdout) || '';
9741
+ dbg.libTarget = lo.includes('TARGETPROJ') && !lo.includes('CWDPROJ');
9742
+ // 좁히면서 false-PASS 를 만들지 않았는가 — 맨이름 오타는 여전히 거부
9743
+ dbg.typo = R(['library', 'NOPEZZ']).status !== 0 && R(['tech', 'NOPEZZ']).status !== 0;
9744
+ // 1.36.101 무회귀 — 경로형 오타는 그대로 거부
9745
+ dbg.strictKept = R(['pulse', path.join(arena, 'NOPE-zz')]).status !== 0 && R(['library', './NOPE-zz']).status !== 0;
9746
+ ok = dbg.wroteTarget && dbg.bareIsCwd && dbg.pageBare && dbg.pageWithPath && dbg.libTarget && dbg.typo && dbg.strictKept;
9747
+ } catch (e) { dbg.err = String(e && e.message).slice(0, 160); } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch {} }
9748
+ console.log(ok ? '✓ C(1.36.103) 준 경로에 적용(파일시스템 관측) + library page 회귀 수리 + 오타 거부 유지(1.36.101 무회귀)'
9749
+ : '✗ 1.36.103 root/하위명령 계약 실패 ' + JSON.stringify(dbg));
9750
+ if (!ok) failed++;
9751
+ }
9752
+
9622
9753
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
9623
9754
  if (failed > 0) process.exit(1);
9624
9755