leerness 1.36.165 → 1.36.166

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,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.166 — 2026-08-26
4
+
5
+ - T-0144: unknown/foreign/malformed CLI 플래그를 migration과 쓰기 전에 exit 1로 거부하고 commands 카탈로그의 verify-code·contract verify 누락을 보완했습니다. 명령별 strict route와 값 누락·부울 equals·language 선택값 회귀 probe를 npm test에 추가했습니다.
6
+
3
7
  ## 1.36.165 — 2026-08-26
4
8
 
5
9
  - verify-code가 fresh basic 권한에서도 Python/Go/Rust 러너를 제한적으로 실행하고, 혼합 Node/Python 테스트와 npm placeholder를 올바르게 판별하며, 실패·차단된 최신 증거가 lazy/gate를 거짓 통과하지 않도록 수정 (T-0143)
package/README.md CHANGED
@@ -128,7 +128,7 @@ MIT
128
128
  <!-- leerness:project-readme:start -->
129
129
  ## Leerness Project Harness
130
130
 
131
- 이 프로젝트는 Leerness v1.36.165 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
131
+ 이 프로젝트는 Leerness v1.36.166 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
132
132
 
133
133
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
134
134
 
@@ -182,7 +182,7 @@ leerness memory restore decision <date|title>
182
182
 
183
183
  ### MCP server (외부 AI 통합)
184
184
 
185
- Leerness v1.36.165는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
185
+ Leerness v1.36.166는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
186
186
 
187
187
  ```jsonc
188
188
  // 카테고리별
@@ -203,7 +203,7 @@ Leerness v1.36.165는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
203
203
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
204
204
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
205
205
 
206
- 현재 누적: **v1.9.x → 1.36.165 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
206
+ 현재 누적: **v1.9.x → 1.36.166 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
207
207
 
208
208
  ### 성능 가이드
209
209
 
@@ -241,5 +241,5 @@ leerness release pack --close --auto-main-push
241
241
  - `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
242
242
  - `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
243
243
 
244
- Last synced by Leerness v1.36.165: 2026-08-26
244
+ Last synced by Leerness v1.36.166: 2026-08-26
245
245
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -47,7 +47,7 @@ const {
47
47
  migrateLegacyWorkspace,
48
48
  } = require('../lib/workspace-dir');
49
49
 
50
- const VERSION = '1.36.165';
50
+ const VERSION = '1.36.166';
51
51
 
52
52
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
53
53
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -711,17 +711,57 @@ function _optSub(a) {
711
711
  if (/^([A-Za-z]:[\\/]|\/|\.\.?[\\/])/.test(v)) return undefined; // 선행 구분자 path-like → 하위명령 아님
712
712
  return v;
713
713
  }
714
- function nonFlagArgs() {
714
+ // 같은 이름이 route 에 따라 boolean/value 두 계약을 갖는 역사적 표면.
715
+ // update --check (boolean)
716
+ // referee add --check "command" (value)
717
+ // preview serve --keep (boolean)
718
+ // init/release cleanup --keep N (value)
719
+ // 전역 registry 하나로 arity 를 추측하면 정상 명령을 거부하거나 positional 값을 삼키므로,
720
+ // 명령/하위명령이 확인된 뒤 두 번째 pass 에서만 좁게 override 한다.
721
+ function _commandBooleanValueFlags(cmd, sub) {
722
+ const out = new Set();
723
+ if (cmd === 'update') out.add('--check');
724
+ if (cmd === 'preview' && sub === 'serve') out.add('--keep');
725
+ return out;
726
+ }
727
+ function _nonFlagArgsPass(booleanValueFlags = new Set()) {
715
728
  const out = [];
716
729
  const withValue = _VALUE_FLAGS;
717
730
  const a = process.argv.slice(2);
718
731
  for (let i = 0; i < a.length; i++) {
719
732
  const x = a[i];
720
- if (x.startsWith('-')) { if (withValue.has(x) && a[i+1] && !a[i+1].startsWith('-')) i++; continue; }
733
+ if (x.startsWith('-')) {
734
+ const name = x.split('=')[0];
735
+ if (withValue.has(name) && !booleanValueFlags.has(name) && !x.includes('=') && a[i+1] && !a[i+1].startsWith('-')) i++;
736
+ continue;
737
+ }
721
738
  out.push(x);
722
739
  }
723
740
  return out;
724
741
  }
742
+ function _commandSubHint(cmd) {
743
+ const raw = process.argv.slice(2);
744
+ const at = raw.indexOf(cmd);
745
+ if (at < 0) return undefined;
746
+ for (let i = at + 1; i < raw.length; i++) {
747
+ const token = raw[i];
748
+ if (token.startsWith('-')) {
749
+ const name = token.split('=')[0];
750
+ // preview 의 --keep 은 serve 를 알아야 boolean 임을 알 수 있고, serve 는 --keep 뒤에도 올 수 있다.
751
+ // 이 1개만 subcommand 탐색에서 provisional boolean 으로 보고 실제 arity 는 route 확정 후 검증한다.
752
+ const ambiguousBeforeSubcommand = cmd === 'preview' && name === '--keep';
753
+ if (!ambiguousBeforeSubcommand && _VALUE_FLAGS.has(name) && !token.includes('=') && raw[i + 1] && !raw[i + 1].startsWith('-')) i++;
754
+ continue;
755
+ }
756
+ return token;
757
+ }
758
+ return undefined;
759
+ }
760
+ function nonFlagArgs() {
761
+ const initial = _nonFlagArgsPass();
762
+ const overrides = _commandBooleanValueFlags(initial[0], _commandSubHint(initial[0]) || initial[1]);
763
+ return overrides.size ? _nonFlagArgsPass(overrides) : initial;
764
+ }
725
765
  function argAll(name) {
726
766
  const out = []; const a = process.argv;
727
767
  for (let i = 0; i < a.length; i++) if (a[i] === name && a[i+1] && !a[i+1].startsWith('-')) out.push(a[++i]);
@@ -4823,7 +4863,7 @@ function _selfTestCases() {
4823
4863
  { name: 'CV-2/UR-0077: fetchNpmLatest 신형 Node win EINVAL 회피 (npm JS 진입점 + try/catch)', run: () => { if (typeof fetchNpmLatest !== 'function') return false; const src = read(__filename); const i = src.indexOf('function fetchNpmLatest'); if (i < 0) return false; const body = src.slice(i, i + 1600); return body.includes("spawnNpmSync(['view', pkg, 'version']") && /try \{/.test(body) && !/execFile\(|cmd\.exe \/d/.test(body); } },
4824
4864
  { name: 'CV-1/UR-0076: arg() --path=값 파싱 + _resolveRoot(--path>positional>cwd) 행위', run: () => { if (typeof _resolveRoot !== 'function') return false; const save = process.argv; try { process.argv = ['node', 'h', 'context', '--path=/tmp/eqform']; const eq = arg('--path', null) === '/tmp/eqform'; process.argv = ['node', 'h', 'context', 'X', '--path', '/tmp/flag']; const flagWins = _resolveRoot('X') === '/tmp/flag'; process.argv = ['node', 'h', 'context', '/tmp/pos']; const posWins = _resolveRoot('/tmp/pos') === '/tmp/pos'; process.argv = ['node', 'h', 'context']; const cwdFb = _resolveRoot(undefined) === process.cwd(); return eq && flagWins && posWins && cwdFb; } finally { process.argv = save; } } },
4825
4865
  { name: 'CV-4/UR-0079: _pruneArchives archive retention (최신 keep 유지, 오래된 prune) 행위', run: () => { if (typeof _pruneArchives !== 'function') return false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_prune_')); try { const adir = path.join(tmp, '.leerness', 'archive'); fs.mkdirSync(adir, { recursive: true }); for (let i = 0; i < 5; i++) fs.mkdirSync(path.join(adir, 'leerness-1.9.' + i + '-stamp')); const pruned = _pruneArchives(tmp, 2); const left = fs.readdirSync(adir).filter(n => /^leerness-/.test(n)).length; return pruned === 3 && left === 2; } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } } },
4826
- { name: 'CV-7/UR-0082: commands 카탈로그 + help 에 누락 명령군 등재 (표면 drift 가드)', run: () => { const src = read(__filename); const ci = src.indexOf('function commandsCmd'); const hi = src.indexOf('function help('); if (ci < 0 || hi < 0) return false; const cbody = src.slice(ci, ci + 8000); const hbody = src.slice(hi, hi + 7000); const must = ['install-safety', 'feature add', 'creds list', 'incident list', 'webhook serve', 'deploy auto', 'runs list', 'permissions list', 'whats-new', 'migrate audit']; return must.every(c => cbody.includes(c)) && hbody.includes('install-safety') && hbody.includes('feature add'); } },
4866
+ { name: 'CV-7/UR-0082: commands 카탈로그 + help 에 누락 명령군 등재 (표면 drift 가드)', run: () => { const src = read(__filename); const ci = src.indexOf('function commandsCmd'); const hi = src.indexOf('function help('); if (ci < 0 || hi < 0) return false; const cbody = src.slice(ci, ci + 8000); const hbody = src.slice(hi, hi + 7000); const must = ['verify-code', 'contract verify', 'install-safety', 'feature add', 'creds list', 'incident list', 'webhook serve', 'deploy auto', 'runs list', 'permissions list', 'whats-new', 'migrate audit']; return must.every(c => cbody.includes(c)) && hbody.includes('install-safety') && hbody.includes('feature add'); } },
4827
4867
  { name: 'UR-0083(4th외부평가 9.3): auto-update hook 비침투 (update --quiet 모드 + hook --check --quiet + 업그레이드)', run: () => { const src = read(__filename); const quietMode = /const quiet = !!opts\.quiet \|\| has\('--quiet'\)/.test(src); const hookQuiet = src.includes("command: 'leerness update --check --quiet'"); const upgrade = /includes\('leerness update --check'\) && !h\.command\.includes\('--quiet'\)/.test(src); return quietMode && hookQuiet && upgrade; } },
4828
4868
  { name: 'CV-6/UR-0081: 시크릿 스캐너 FP/FN — _isPlaceholderSecret + _looksSecretLike 행위', run: () => { if (typeof _isPlaceholderSecret !== 'function' || typeof _looksSecretLike !== 'function') return false; const fp = _isPlaceholderSecret('change-me') && _isPlaceholderSecret('your-api-key-here') && _isPlaceholderSecret('<token>') && _isPlaceholderSecret('') && !_isPlaceholderSecret('hunter2realpass'); const fn = _looksSecretLike('secret123') && _looksSecretLike('a'.repeat(24)) && !_looksSecretLike('processEnv') && !_looksSecretLike('reqBodyPassword'); return fp && fn; } },
4829
4869
  { name: 'UR-0025: _mergeLines/_mergeEnvLines 순수 코어 모듈 분리 + 행위 (1.9.367)', run: () => { if (typeof _mergeLines !== 'function' || typeof _mergeEnvLines !== 'function') return false; const m = require('../lib/pure-utils'); const moved = m._mergeLines === _mergeLines && m._mergeEnvLines === _mergeEnvLines; const ml = _mergeLines('a\n', ['a', 'b']) === 'a\nb\n'; const meKeep = _mergeEnvLines('FOO=keep\n', ['FOO=new']) === 'FOO=keep\n'; const meAdd = _mergeEnvLines('FOO=keep\n', ['BAR=add']).includes('BAR=add'); return moved && ml && meKeep && meAdd; } },
@@ -5755,7 +5795,7 @@ function _selfTestCases() {
5755
5795
  } },
5756
5796
  { name: '범용성 P2 (UR-0048): task update unknown flag 거부 + did-you-mean(인수인계 유실 차단) (1.17.5)', run: () => {
5757
5797
  const src = read(__filename);
5758
- const wired = src.includes('function _rejectUnknownFlags(allowed, usageHint)') && src.includes("'unknown_flag'") && src.includes("_rejectUnknownFlags(['--status', '--evidence', '--next', '--note']");
5798
+ const wired = src.includes('function _rejectUnknownFlags(allowed, usageHint, options = {})') && src.includes("'unknown_flag'") && src.includes("_rejectUnknownFlags(['--status', '--evidence', '--next', '--note']");
5759
5799
  // did-you-mean prefix 로직: --next-action 은 --next 를 prefix 로 가짐
5760
5800
  const dymOk = '--next-action'.startsWith('--next');
5761
5801
  return wired && dymOk;
@@ -10481,21 +10521,24 @@ function _p0103FlagsOk() {
10481
10521
  const eqOk = pv2.includes('equals form title') && !pv2.includes('ZZEQ');
10482
10522
  const rtEq = R(['agents', 'route', '--tier=normal', 'fix the parser']);
10483
10523
  const eqRoute = !/비어 있|empty_task/.test((rtEq.stdout || '') + (rtEq.stderr || ''));
10484
- // ④ 동적 레지스트리 플래그(brief set 은 arg('--'+f.flag) 로 만든다)에 오탐 경고가 없어야 한다 — 판별 대조군 포함
10524
+ // ④ 동적 레지스트리 플래그(brief set 은 arg('--'+f.flag) 로 만든다)에 오탐 실패가 없어야 한다 — 판별 대조군 포함
10485
10525
  const W2 = /알 수 없는 플래그|Unknown flag/;
10486
- const dynOk = !W2.test((R(['brief', 'set', '--intro', 'hello']).stderr) || '')
10487
- && W2.test((R(['brief', 'set', '--introo', 'x']).stderr) || '');
10488
- // ⑤ 오타 가시성 — 판별 대조군: 실재 플래그는 조용하고, 없는 플래그만 경고한다.
10526
+ const dynGood = R(['brief', 'set', '--intro', 'hello']);
10527
+ const dynBad = R(['brief', 'set', '--introo', 'x']);
10528
+ const dynOk = !W2.test((dynGood.stdout || '') + (dynGood.stderr || ''))
10529
+ && dynBad.status !== 0 && W2.test((dynBad.stdout || '') + (dynBad.stderr || ''));
10530
+ // ⑤ 오타 fail-closed — 판별 대조군: 실재 플래그는 조용하고, 없는 플래그만 구조화 실패한다.
10489
10531
  const good = R(['pulse', '--json']);
10490
10532
  const typo = R(['pulse', '--json', '--nosuchflagzz']);
10491
10533
  const quietOnReal = !/알 수 없는 플래그|Unknown flag/.test(good.stderr || '');
10492
10534
  // 프로젝트가 OS locale 에 따라 en 으로 초기화돼도 같은 계약을 검증한다.
10493
10535
  // 경고 정규식은 바로 위의 ko/en 공용 단일출처를 재사용해 CI locale 에 종속되지 않게 한다.
10494
- const warnsOnTypo = W2.test(typo.stderr || '') && (typo.stderr || '').includes('--nosuchflagzz');
10495
- // 경고는 stderr 전용 — stdout(기계 소비자) 은 오염되지 않는다
10496
- const stdoutClean = !/알 수 없는 플래그|Unknown flag/.test(typo.stdout || '');
10497
- let jsonStillValid = false; try { JSON.parse((typo.stdout || '').trim()); jsonStillValid = true; } catch { jsonStillValid = false; }
10498
- return titleClean && pathClean && eqOk && eqRoute && dynOk && quietOnReal && warnsOnTypo && stdoutClean && jsonStillValid;
10536
+ let typoJson = null; try { typoJson = JSON.parse((typo.stdout || '').trim()); } catch { typoJson = null; }
10537
+ const rejectsTypo = typo.status !== 0 && typoJson?.code === 'unknown_flag'
10538
+ && String(typoJson?.error || '').includes('--nosuchflagzz');
10539
+ // JSON 오류는 stdout 단일 객체, stderr 무오염이다.
10540
+ const stderrClean = !(typo.stderr || '').trim();
10541
+ return titleClean && pathClean && eqOk && eqRoute && dynOk && quietOnReal && rejectsTypo && stderrClean;
10499
10542
  } catch { return false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
10500
10543
  }
10501
10544
  // 1.36.102 (명령 표면 감사 P1 2건): 회귀 가드.
@@ -10925,6 +10968,8 @@ function commandsCmd(root) {
10925
10968
  { cmd: 'encoding check [path]', desc: '인코딩 검증', descEn: 'encoding verification' },
10926
10969
  { cmd: 'lazy detect [path] [--json]', desc: '게으른 작업 감지 (1.9.101)', descEn: 'detect lazy/incomplete work (1.9.101)' },
10927
10970
  { cmd: 'verify-claim <T-ID|--all> [--run-tests] [--test-cmd "<명령>"] [--strict-claims] [--require-evidence]', cmdEn: 'verify-claim <T-ID|--all> [--run-tests] [--test-cmd "<command>"] [--strict-claims] [--require-evidence]', desc: '주장 검증 (1.9.18~26) — --all: 모든 done 주장 일괄 검증(CI·스케일, 1.33.2) · --require-evidence: done 주장에 파일+테스트 근거 강제 (1.9.287) · --test-cmd: 비-JS 테스트 명령 (1.17.2)', descEn: 'verify a claim (1.9.18~26) — --all: verify every done claim at once (CI/scale, 1.33.2) · --require-evidence: require file + test evidence on done claims (1.9.287) · --test-cmd: non-JS test command (1.17.2)' },
10971
+ { cmd: 'verify-code [path] [--build] [--bench] [--strict]', desc: '프로젝트 테스트·린트·타입검사 자동 감지 실행 + 검증 증거 기록', descEn: 'auto-detect and run project tests/lint/typecheck, then record verification evidence' },
10972
+ { cmd: 'contract verify <spec.md> <impl.js> [--allow-empty] [--json]', desc: '명세의 함수·필드 선언과 구현 export 정적 대조', descEn: 'statically compare functions and fields declared in a spec with implementation exports' },
10928
10973
  { cmd: `lens [${_lensDomainList()}] [--json]`, desc: '분야별 자기질문 품질 렌즈 + 분야간 인과관계 (1.18.3 · database/contract/recovery/observability 는 심화, axes 는 8축 경량 점검)', descEn: 'per-domain self-question quality lenses + cross-domain causality (1.18.3 · database/contract/recovery/observability are deep dives, axes is a light 8-axis check)' },
10929
10974
  // 1.36.101 (명령 표면 감사): 1.36.98 에서 디스패처에만 붙이고 commands/--help 에 등재하지 않아 '있는데 안 보이는' 명령이었다.
10930
10975
  { cmd: 'library [show|page] [path] [--json] [--ai]', desc: '재사용 인벤토리 — 컴포넌트·디자인 토큰을 저장소에서 추출. page: 오프라인 단일 HTML · --ai: 에이전트용 압축 JSON (1.36.98 P-0013)', descEn: 'reuse inventory — extracts components and design tokens from the repo. page: offline single HTML · --ai: compact JSON for agents (1.36.98 P-0013)' },
@@ -14473,16 +14518,126 @@ function taskAdd(root, text) {
14473
14518
  // 1.17.5 (UR-0048, 5축 실증 P2): 모르는 옵션 조용히 무시 차단 — `task update --next-action "x"` 처럼 오타/미존재 플래그가
14474
14519
  // "✓ task updated" 와 함께 값을 버려, 쓴 에이전트는 기록됐다고 믿고 다음 에이전트는 placeholder 를 받는(인수인계 유실) 최악의 실패 양식.
14475
14520
  // prefix 기반 did-you-mean(--next-action → --next) + exit 1. 전역 플래그(--path/--json/--force/--lenient)는 항상 허용.
14476
- function _rejectUnknownFlags(allowed, usageHint) {
14477
- const all = new Set([...allowed, '--path', '--json', '--force', '--lenient']);
14478
- const seen = process.argv.slice(2).filter(a => a.startsWith('--')).map(a => a.split('=')[0]);
14521
+ // T-0144: 명령별 검증은 options.globals 로 그 명령이 실제 지원하는 공통 플래그만 지정한다.
14522
+ // 기본값은 기존 task update 계약을 보존하고, exact 명령만 좁혀 과거의 묵시적 무시를 차단한다.
14523
+ function _rejectUnknownFlags(allowed, usageHint, options = {}) {
14524
+ const globals = Array.isArray(options.globals) ? options.globals : ['--path', '--json', '--force', '--lenient'];
14525
+ const all = new Set([...allowed, ...globals]);
14526
+ const seen = _argvLongFlagNames();
14479
14527
  const unknown = [...new Set(seen.filter(f => !all.has(f)))];
14480
14528
  if (!unknown.length) return true;
14481
14529
  const dym = (u) => { const c = [...all].find(k => u.startsWith(k) || k.startsWith(u)); return c ? ` — 혹시 ${c}?` : ''; };
14482
- failJson(has('--json'), 'unknown_flag', `알 수 없는 옵션: ${unknown.map(u => u + dym(u)).join(', ')} (지원: ${[...allowed].join(' ')}${usageHint ? ' · ' + usageHint : ''}) — 값이 조용히 버려지는 것을 방지하기 위해 거부`);
14530
+ const detail = unknown.map(u => u + dym(u)).join(', ');
14531
+ const supported = [...all].join(' ');
14532
+ const en = _uiLang(process.cwd()) === 'en';
14533
+ failJson(has('--json'), 'unknown_flag', en
14534
+ ? `Unsupported option for this command: ${detail} (supported: ${supported}${usageHint ? ' · ' + usageHint : ''}) — rejected instead of silently discarding its value`
14535
+ : `이 명령에서 지원하지 않는 옵션: ${detail} (지원: ${supported}${usageHint ? ' · ' + usageHint : ''}) — 값이 조용히 버려지는 것을 방지하기 위해 거부`);
14536
+ return false;
14537
+ }
14538
+
14539
+ function _argvLongFlagNames() {
14540
+ return [...new Set(process.argv.slice(2)
14541
+ .filter(a => typeof a === 'string' && a.startsWith('--') && a !== '--')
14542
+ .map(a => a.split('=')[0]))];
14543
+ }
14544
+
14545
+ // T-0144: 모든 명령에서 이름 자체가 없는 플래그는 migration/usage/write 이전에 fail-closed.
14546
+ function _rejectUnregisteredFlags() {
14547
+ const unknown = _argvLongFlagNames().filter(name => !_VALUE_FLAGS.has(name) && !_BOOL_FLAGS.has(name));
14548
+ if (!unknown.length) return true;
14549
+ const en = _uiLang(process.cwd()) === 'en';
14550
+ const human = en
14551
+ ? `Unknown flag: ${unknown.join(' ')} — no such flag in leerness. Its value could otherwise be absorbed into text or a path. Check: leerness --help`
14552
+ : `알 수 없는 플래그: ${unknown.join(' ')} — leerness 에 없는 이름입니다. 값이 본문이나 경로 인자에 섞이지 않도록 거부했습니다. 확인: leerness --help`;
14553
+ if (has('--json')) {
14554
+ // JSON 본문 자체는 stdout 의 단일 문서다. 예전 경고 문구를 그대로 넣으면 소비자가 이를
14555
+ // "JSON 앞에 섞인 warning" 으로 오인하므로 code를 단일출처로 두고 메시지는 구조화 용어를 쓴다.
14556
+ failJson(true, 'unknown_flag', en
14557
+ ? `Unregistered option: ${unknown.join(' ')} — rejected before its value could alter text or path arguments`
14558
+ : `등록되지 않은 옵션: ${unknown.join(' ')} — 값이 본문이나 경로 인자에 섞이기 전에 거부했습니다`);
14559
+ } else {
14560
+ // 사람용 계약은 기존 stderr 가시성을 보존하되, T-0144부터 exit 1로 fail-closed 한다.
14561
+ console.error('⚠ ' + human);
14562
+ process.exitCode = 1;
14563
+ }
14483
14564
  return false;
14484
14565
  }
14485
14566
 
14567
+ // 알려진 플래그도 형태가 틀리면 소비자가 기본값으로 조용히 폴백할 수 있다.
14568
+ // `--strict=true`는 has('--strict')가 false이고, `--language --json`은 language 값이 비어도 둘 다 성공했다.
14569
+ function _rejectMalformedFlagForms(cmd, args) {
14570
+ const raw = process.argv.slice(2);
14571
+ const errors = [];
14572
+ const valueChoices = { '--language': new Set(['auto', 'ko', 'en']) };
14573
+ const commandBooleans = _commandBooleanValueFlags(cmd, args && args[1]);
14574
+ for (let i = 0; i < raw.length; i++) {
14575
+ const token = raw[i];
14576
+ if (typeof token !== 'string' || !token.startsWith('--') || token === '--') continue;
14577
+ const eqAt = token.indexOf('=');
14578
+ const name = eqAt >= 0 ? token.slice(0, eqAt) : token;
14579
+ const booleanHere = _BOOL_FLAGS.has(name) || commandBooleans.has(name);
14580
+ if (booleanHere && eqAt >= 0) {
14581
+ errors.push({ kind: 'boolean_value', name, token });
14582
+ continue;
14583
+ }
14584
+ if (_VALUE_FLAGS.has(name) && !commandBooleans.has(name)) {
14585
+ const missingEqualsValue = eqAt >= 0 && token.slice(eqAt + 1) === '';
14586
+ const missingNextValue = eqAt < 0 && (raw[i + 1] == null || String(raw[i + 1]).startsWith('--'));
14587
+ if (missingEqualsValue || missingNextValue) {
14588
+ // gate 는 누락 referee 를 자체 check 배열(referee_missing_value)로 진단한다. 그 구조를
14589
+ // migration 전에 보존하는 early read-only 경로가 main 에 있으므로 여기서 평탄화하지 않는다.
14590
+ if (cmd === 'gate' && name === '--require-referee') continue;
14591
+ errors.push({ kind: 'missing_value', name, token });
14592
+ }
14593
+ else if (valueChoices[name]) {
14594
+ const value = eqAt >= 0 ? token.slice(eqAt + 1) : String(raw[i + 1]);
14595
+ if (!valueChoices[name].has(value.toLowerCase())) errors.push({ kind: 'invalid_value', name, token, value, choices: [...valueChoices[name]] });
14596
+ }
14597
+ }
14598
+ }
14599
+ if (!errors.length) return true;
14600
+ const onlyMissing = errors.every(e => e.kind === 'missing_value');
14601
+ const onlyInvalidValue = errors.every(e => e.kind === 'invalid_value');
14602
+ const en = _uiLang(process.cwd()) === 'en';
14603
+ const detail = errors.map(e => {
14604
+ if (e.kind === 'missing_value') {
14605
+ if (e.name === '--path') return en
14606
+ ? '--path was given without a value (see command usage)'
14607
+ : '--path 에 값이 없습니다 (명령 사용법 확인)';
14608
+ return `${e.name} (${en ? 'value required; see command usage' : '값 필요; 명령 사용법 확인'})`;
14609
+ }
14610
+ if (e.kind === 'invalid_value') return `${e.name}=${e.value} (${en ? 'choose' : '허용값'}: ${e.choices.join('|')})`;
14611
+ return `${e.token} (${en ? `boolean flag; use ${e.name}` : `부울 플래그 — ${e.name} 형태로 사용`})`;
14612
+ }).join(', ');
14613
+ const code = onlyMissing ? 'missing_flag_value' : (onlyInvalidValue ? 'invalid_flag_value' : 'invalid_flag_syntax');
14614
+ failJson(has('--json'), code, en
14615
+ ? `Invalid flag syntax: ${detail}`
14616
+ : `잘못된 플래그 형식: ${detail}`);
14617
+ return false;
14618
+ }
14619
+
14620
+ // 전역 registry는 "값을 갖는가"만 답한다. 아래 audit 완료 route는 현재 명령이 그 플래그를
14621
+ // 실제 소비하는지도 검사한다. 확인하지 않은 97개 표면을 추측 allowlist로 막지 않고 검증된 route부터 확장한다.
14622
+ // --path 는 기존 공용 호출 래퍼가 모든 명령에 붙이는 호환 플래그다. 명령이 별도 positional 파일을
14623
+ // 쓰더라도 이를 거부하면 정상 호출을 false-BLOCK 하므로 공통으로 보존한다.
14624
+ const _COMMON_COMMAND_FLAGS = ['--language', '--no-stale-check', '--path'];
14625
+ const _STRICT_COMMAND_FLAGS = {
14626
+ commands: { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'commands [--json] [--language en|ko] [--path <path>]' },
14627
+ lens: { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'lens [domain] [--json]' },
14628
+ about: { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'about [--json]' },
14629
+ identity: { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'identity [--json]' },
14630
+ gate: { allowed: ['--claims', '--require-referee'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'gate [path] [--claims] [--require-referee <id>] [--json]' },
14631
+ 'verify-code': { allowed: ['--build', '--bench', '--strict'], globals: [..._COMMON_COMMAND_FLAGS], usage: 'verify-code [path] [--build] [--bench] [--strict]' },
14632
+ 'contract verify': { allowed: ['--allow-empty'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'contract verify <spec.md> <impl.js> [--allow-empty] [--json]' },
14633
+ };
14634
+ function _validateCommandFlags(cmd, args) {
14635
+ const route = cmd === 'contract' && args[1] === 'verify' ? 'contract verify' : cmd;
14636
+ const cfg = _STRICT_COMMAND_FLAGS[route];
14637
+ if (!cfg) return true;
14638
+ return _rejectUnknownFlags(cfg.allowed, cfg.usage, { globals: cfg.globals });
14639
+ }
14640
+
14486
14641
  function taskUpdate(root, id) {
14487
14642
  if (!_requireInit(root, 'task update')) return; // 1.9.311 (UR-0047): init 가드
14488
14643
  if (!_rejectUnknownFlags(['--status', '--evidence', '--next', '--note'], 'task update T-0001 --status done --evidence "..." --next "..."')) { process.exitCode = 1; return; } // 1.17.5 (UR-0048)
@@ -29672,35 +29827,13 @@ async function main() {
29672
29827
  return log(VERSION);
29673
29828
  }
29674
29829
  if (has('--help') || has('-h')) return help();
29675
- if (cmd !== 'migrate-workspace-dir' && !_guardLinkedWorkspaceTargets(args)) return;
29676
- if (!_autoMigrateLegacyWorkspace(args, cmd)) return;
29677
- // 1.36.144 (T-0117, 전수 실측): `--dry-run` 을 받았으면 쓰기를 막는다 — 구현한 명령은 애초에 안 쓰므로
29678
- // 영향이 없고, 무시하던 명령은 첫 쓰기에서 멈춰 **아무것도 바꾸지 않은 채** 사유를 말한다.
29679
- if (has('--dry-run')) { setDryRunGuard(true); try { require('../lib/git').setGitDryRun(true); } catch {} }
29680
- // 1.36.103: 실재하지 않는 플래그(오타)를 조용히 삼키지 않는다.
29681
- // 값-플래그 미등록 45종은 _VALUE_FLAGS 등록으로 닫았지만, 사용자 오타(--prio, --resaon)는 여전히
29682
- // 그 값이 본문에 붙는다(실측: `decision add "use redis" --resaon faster` → 저장된 제목 "use redis faster").
29683
- // 파싱 기본값을 뒤집으면 미분류 부울 리터럴 쪽에서 positional 을 삼키는 새 회귀가 나므로, 동작 대신
29684
- // 가시성을 준다 — stderr 로만 알리고 stdout/--json/exit code 는 건드리지 않는다(기계 소비자 무영향).
29685
- {
29686
- const _unknownFlags = [];
29687
- for (const _tok of process.argv.slice(2)) {
29688
- if (typeof _tok !== 'string' || !_tok.startsWith('--') || _tok === '--') continue;
29689
- const _name = _tok.split('=')[0];
29690
- if (_VALUE_FLAGS.has(_name) || _BOOL_FLAGS.has(_name)) continue;
29691
- if (!_unknownFlags.includes(_name)) _unknownFlags.push(_name);
29692
- }
29693
- if (_unknownFlags.length) {
29694
- const _L = _uiLang(process.cwd());
29695
- console.error(_L === 'en'
29696
- ? `⚠ Unknown flag: ${_unknownFlags.join(' ')} — no such flag in leerness. Its value can be absorbed into the text or path argument. Check: leerness --help`
29697
- : `⚠ 알 수 없는 플래그: ${_unknownFlags.join(' ')} — leerness 에 없는 이름입니다. 그 값이 본문이나 경로 인자에 섞일 수 있습니다. 확인: leerness --help`);
29698
- }
29699
- }
29700
- // 1.36.103 (codex 검수 #9): --keep 은 소비처가 둘인데 한쪽(release cleanup)은 거부하고 다른 쪽(아카이브 retention)은
29701
- // 조용히 기본값 10 이 됐다 — `init . --keep abc --json` 이 exit 0 + {ok:true} 로 끝났다.
29702
- // "준 값을 조용히 바꾸지 않는다" 를 소비처마다 반복하지 않고 진입점 한 곳에서 강제한다.
29703
- {
29830
+ // 입력 오류는 자동 legacy migration·usage 기록보다 먼저 거부해야 실패 호출이 상태를 바꾸지 않는다.
29831
+ if (!_rejectUnregisteredFlags()) return;
29832
+ if (!_rejectMalformedFlagForms(cmd, args)) return;
29833
+ if (!_validateCommandFlags(cmd, args)) return;
29834
+ // 값이 존재하지만 의미가 잘못된 --keep 도 legacy migration/write 전에 거부한다.
29835
+ // preview serve 의 --keep 은 route별 boolean 계약이므로 숫자 검증 대상이 아니다.
29836
+ if (!_commandBooleanValueFlags(cmd, args[1]).has('--keep')) {
29704
29837
  const _k = arg('--keep', null);
29705
29838
  if (_k !== null) {
29706
29839
  const _kn = Number(_k);
@@ -29713,6 +29846,14 @@ async function main() {
29713
29846
  }
29714
29847
  }
29715
29848
  }
29849
+ if (cmd !== 'migrate-workspace-dir' && !_guardLinkedWorkspaceTargets(args)) return;
29850
+ // gate 의 누락 referee 는 기존의 진단 가능한 checks[] 계약을 유지하되, 잘못된 호출이
29851
+ // legacy workspace migration을 일으키기 전에 읽기전용으로 종료한다.
29852
+ if (cmd === 'gate' && _argAllStrict('--require-referee').missing) return gate(arg('--path', args[1] || process.cwd()));
29853
+ if (!_autoMigrateLegacyWorkspace(args, cmd)) return;
29854
+ // 1.36.144 (T-0117, 전수 실측): `--dry-run` 을 받았으면 쓰기를 막는다 — 구현한 명령은 애초에 안 쓰므로
29855
+ // 영향이 없고, 무시하던 명령은 첫 쓰기에서 멈춰 **아무것도 바꾸지 않은 채** 사유를 말한다.
29856
+ if (has('--dry-run')) { setDryRunGuard(true); try { require('../lib/git').setGitDryRun(true); } catch {} }
29716
29857
  // 1.36.101 (명령 표면 감사): '없는 경로에 초록 보고' 클래스를 디스패처 한 곳에서 막는다.
29717
29858
  // 실측 — 32개 조사 대상 중 16개가 존재하지 않는 디렉토리에 exit 0 과 정상 보고를 냈다
29718
29859
  // (pulse/round-history/milestones/session-resume 는 "✓ 비정상 종료 신호 없음 · R0 · 정상 운영 중" 까지 찍었다 — 오타가 안심 보고가 된다).
@@ -30448,12 +30589,9 @@ async function main() {
30448
30589
  // 1.36.51 (사용자 요청 UR-0061): 모호성 질문 + 미리보기 승인 워크플로
30449
30590
  if (cmd === 'clarify') return _clar.clarifyCmd(arg('--path', process.cwd()), args.slice(1).filter(x => !x.startsWith('-')).join(' '), { has });
30450
30591
  if (cmd === 'preview') {
30451
- // 미지 플래그(--design/--features/--note)의 값이 positional 로 새는 것 차단 — 원시 argv 에서 플래그+값 스킵 (1.36.49 release note 패턴)
30452
- const _pvRaw = process.argv.slice(2); const _pvI = _pvRaw.indexOf('preview'); const _pvToks = [];
30453
- for (let i = _pvI + 1; i < _pvRaw.length && _pvI >= 0; i++) {
30454
- 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 재현)
30455
- _pvToks.push(_pvRaw[i]);
30456
- }
30592
+ // 전역 2-pass 파서가 command별 arity까지 반영한 positional 단일출처다.
30593
+ // preview 전용 raw 재파싱은 --keep 을 다시 value로 오인해 뒤의 P-ID를 삼켰다(T-0144 교차검토).
30594
+ const _pvToks = args.slice(1);
30457
30595
  // 1.36.80 (UR-0067): 라이브 미리보기 — serve(로컬 서버) / mode(self|project) 는 별도 모듈로 위임
30458
30596
  const _pvRoot = arg('--path', process.cwd());
30459
30597
  if (_pvToks[0] === 'serve') return _pvsrv.previewServeCmd(_pvRoot, _pvToks[1], { has, arg, previews: _clar._loadPreviews(_pvRoot) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.165",
3
+ "version": "1.36.166",
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",
@@ -50,7 +50,7 @@
50
50
  "scripts": {
51
51
  "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
52
52
  "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js",
53
- "test:commands": "node ./scripts/e2e-command-surface.js",
53
+ "test:commands": "node ./scripts/command-flags-probe.js && node ./scripts/e2e-command-surface.js",
54
54
  "test:installed": "node ./scripts/installed-cleanroom-probe.js",
55
55
  "test:handoff": "node ./scripts/handoff-readonly-probe.js",
56
56
  "test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/smoke.js",
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const crypto = require('crypto');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const { spawnSync } = require('child_process');
9
+
10
+ const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
11
+ const env = {
12
+ ...process.env,
13
+ LEERNESS_OFFLINE: '1',
14
+ LEERNESS_NO_PROMPT: '1',
15
+ LEERNESS_NO_AUTOCHCP: '1',
16
+ LEERNESS_NO_AUTO_ROADMAP: '1',
17
+ LEERNESS_INTERNAL: '1',
18
+ LEERNESS_NO_STALE_CHECK: '1',
19
+ };
20
+
21
+ function run(args) {
22
+ return spawnSync(process.execPath, [CLI, ...args], {
23
+ cwd: path.resolve(__dirname, '..'),
24
+ encoding: 'utf8',
25
+ timeout: 30000,
26
+ env,
27
+ });
28
+ }
29
+
30
+ function json(result) {
31
+ try { return JSON.parse(result.stdout || ''); } catch { return null; }
32
+ }
33
+
34
+ function treeSnapshot(root) {
35
+ const rows = [];
36
+ function walk(dir, relative = '') {
37
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
38
+ const rel = path.join(relative, entry.name).replace(/\\/g, '/');
39
+ const absolute = path.join(dir, entry.name);
40
+ const stat = fs.lstatSync(absolute, { bigint: true });
41
+ const metadata = [Number(stat.mode), stat.size.toString(), stat.mtimeNs.toString()];
42
+ if (entry.isDirectory()) {
43
+ rows.push([rel, 'directory', ...metadata]);
44
+ walk(absolute, rel);
45
+ } else if (entry.isFile()) {
46
+ rows.push([rel, 'file', ...metadata, crypto.createHash('sha256').update(fs.readFileSync(absolute)).digest('hex')]);
47
+ } else if (entry.isSymbolicLink()) {
48
+ rows.push([rel, 'symlink', ...metadata, fs.readlinkSync(absolute)]);
49
+ } else {
50
+ rows.push([rel, 'other', ...metadata]);
51
+ }
52
+ }
53
+ }
54
+ walk(root);
55
+ return JSON.stringify(rows);
56
+ }
57
+
58
+ const failures = [];
59
+ function check(label, condition, result) {
60
+ if (condition) {
61
+ console.log(`✓ ${label}`);
62
+ return;
63
+ }
64
+ failures.push(label);
65
+ const detail = result
66
+ ? `exit=${result.status}\nstdout=${String(result.stdout || '').slice(0, 300)}\nstderr=${String(result.stderr || '').slice(0, 300)}`
67
+ : '';
68
+ console.log(`✗ ${label}${detail ? `\n${detail}` : ''}`);
69
+ }
70
+
71
+ const catalog = run(['commands', '--json']);
72
+ const catalogJson = json(catalog);
73
+ const catalogRows = catalogJson?.categories ? Object.values(catalogJson.categories).flat() : [];
74
+ check('commands --json returns the machine-readable catalog',
75
+ catalog.status === 0 && catalogJson?.totalCommands === 99 && catalogJson?.categories && !catalog.stderr,
76
+ catalog);
77
+ check('commands totalCommands equals the category sum',
78
+ catalogJson?.totalCommands === catalogRows.length,
79
+ catalog);
80
+ check('commands catalog includes verify-code and contract verify',
81
+ catalogRows.some(row => /^verify-code\b/.test(row.cmd))
82
+ && catalogRows.some(row => /^contract verify\b/.test(row.cmd)),
83
+ catalog);
84
+
85
+ const englishCatalog = run(['commands', '--language', 'en', '--json']);
86
+ check('commands accepts --language and returns an English catalog',
87
+ englishCatalog.status === 0 && json(englishCatalog)?.lang === 'en' && !englishCatalog.stderr,
88
+ englishCatalog);
89
+
90
+ const englishCatalogEquals = run(['commands', '--language=en', '--json']);
91
+ check('commands accepts the documented equals form for value flags',
92
+ englishCatalogEquals.status === 0 && json(englishCatalogEquals)?.lang === 'en' && !englishCatalogEquals.stderr,
93
+ englishCatalogEquals);
94
+
95
+ const missingLanguage = run(['commands', '--language', '--json']);
96
+ check('a value flag without a value fails instead of falling back',
97
+ missingLanguage.status === 1 && json(missingLanguage)?.code === 'missing_flag_value' && !missingLanguage.stderr,
98
+ missingLanguage);
99
+
100
+ const invalidLanguage = run(['commands', '--language', 'xx', '--json', '--no-stale-check']);
101
+ check('an invalid language value fails instead of silently falling back',
102
+ invalidLanguage.status === 1 && json(invalidLanguage)?.code === 'invalid_flag_value' && !invalidLanguage.stderr,
103
+ invalidLanguage);
104
+
105
+ const known = run(['commands', '--path', process.cwd(), '--json']);
106
+ check('commands accepts its documented global flags',
107
+ known.status === 0 && json(known)?.categories && !known.stderr,
108
+ known);
109
+
110
+ const nonexistent = run(['commands', '--definitely-unknown', '--json']);
111
+ const nonexistentJson = json(nonexistent);
112
+ check('a nonexistent flag fails closed with one JSON error',
113
+ nonexistent.status === 1 && nonexistentJson?.ok === false
114
+ && nonexistentJson?.code === 'unknown_flag'
115
+ && String(nonexistentJson?.error || '').includes('--definitely-unknown')
116
+ && !nonexistent.stderr,
117
+ nonexistent);
118
+
119
+ const nonexistentEquals = run(['commands', '--definitely-unknown=value', '--json']);
120
+ check('an equals-form nonexistent flag also fails closed',
121
+ nonexistentEquals.status === 1 && json(nonexistentEquals)?.code === 'unknown_flag' && !nonexistentEquals.stderr,
122
+ nonexistentEquals);
123
+
124
+ const nonexistentHuman = run(['commands', '--definitely-unknown']);
125
+ check('human-mode unknown flags stay visible on stderr while failing closed',
126
+ nonexistentHuman.status === 1 && /알 수 없는 플래그|Unknown flag/.test(nonexistentHuman.stderr || '')
127
+ && !(nonexistentHuman.stdout || '').trim(),
128
+ nonexistentHuman);
129
+
130
+ const booleanEquals = run(['commands', '--json=true']);
131
+ check('boolean flags reject equals-form values that their consumers would ignore',
132
+ booleanEquals.status === 1 && /--json=true/.test((booleanEquals.stdout || '') + (booleanEquals.stderr || '')),
133
+ booleanEquals);
134
+
135
+ const missingMeasure = run(['benchmark', '--measure']);
136
+ check('missing values retain a user-facing command-usage hint',
137
+ missingMeasure.status === 1 && /사용법|usage|task/i.test((missingMeasure.stdout || '') + (missingMeasure.stderr || '')),
138
+ missingMeasure);
139
+
140
+ const missingPath = run(['status', '--path']);
141
+ check('missing --path retains its specific no-value diagnostic',
142
+ missingPath.status === 1 && /--path 에 값이 없습니다|--path was given without a value/.test((missingPath.stdout || '') + (missingPath.stderr || '')),
143
+ missingPath);
144
+
145
+ // --cmd exists elsewhere in leerness, so the global registry alone cannot catch
146
+ // it. The commands command must reject a foreign, command-specific flag too.
147
+ const foreign = run(['commands', '--cmd', 'ignored-value', '--json']);
148
+ const foreignJson = json(foreign);
149
+ check('commands rejects a known-but-foreign command flag',
150
+ foreign.status === 1 && foreignJson?.ok === false
151
+ && foreignJson?.code === 'unknown_flag'
152
+ && String(foreignJson?.error || '').includes('--cmd')
153
+ && !foreign.stderr,
154
+ foreign);
155
+
156
+ const foreignTestCommand = run(['commands', '--test-cmd', 'ignored-command', '--json']);
157
+ check('commands rejects a value flag owned by another command',
158
+ foreignTestCommand.status === 1 && json(foreignTestCommand)?.code === 'unknown_flag' && !foreignTestCommand.stderr,
159
+ foreignTestCommand);
160
+
161
+ const lensUnknown = run(['lens', '--type', 'database', '--json']);
162
+ check('lens rejects an unknown value flag before its value becomes a domain',
163
+ lensUnknown.status === 1 && json(lensUnknown)?.code === 'unknown_flag' && !lensUnknown.stderr,
164
+ lensUnknown);
165
+
166
+ const lensForeign = run(['lens', '--test-cmd', 'database', '--json']);
167
+ check('lens rejects a known-but-foreign value flag',
168
+ lensForeign.status === 1 && json(lensForeign)?.code === 'unknown_flag' && !lensForeign.stderr,
169
+ lensForeign);
170
+
171
+ const contractUnknown = run(['contract', 'verify', 'missing-spec.md', '--type', 'missing-impl.md', '--json']);
172
+ check('contract verify rejects an unknown flag before positional error meaning changes',
173
+ contractUnknown.status === 1 && json(contractUnknown)?.code === 'unknown_flag'
174
+ && json(contractUnknown)?.code !== 'spec_not_found' && !contractUnknown.stderr,
175
+ contractUnknown);
176
+
177
+ const aboutForeign = run(['about', '--test-cmd', 'ignored-command', '--json']);
178
+ check('about rejects a known-but-foreign value flag',
179
+ aboutForeign.status === 1 && json(aboutForeign)?.code === 'unknown_flag' && !aboutForeign.stderr,
180
+ aboutForeign);
181
+
182
+ // The external evaluation's concrete case was verify-code --cmd/--test-cmd:
183
+ // both are valid names elsewhere, but this command does not consume them.
184
+ const emptyProject = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-command-flags-'));
185
+ try {
186
+ const initialized = run(['init', emptyProject, '--yes', '--json']);
187
+ check('route-arity fixture initializes successfully',
188
+ initialized.status === 0 && json(initialized)?.ok === true,
189
+ initialized);
190
+
191
+ for (const mutationFlag of ['--fix', '--baseline', '--auto-track']) {
192
+ const beforeInvalidGate = treeSnapshot(emptyProject);
193
+ const invalidGate = run(['gate', emptyProject, '--require-referee', mutationFlag, '--json']);
194
+ check(`gate rejects foreign ${mutationFlag} before any sub-check can write`,
195
+ invalidGate.status === 1 && json(invalidGate)?.code === 'unknown_flag'
196
+ && String(json(invalidGate)?.error || '').includes(mutationFlag)
197
+ && treeSnapshot(emptyProject) === beforeInvalidGate,
198
+ invalidGate);
199
+ }
200
+
201
+ const updateCheck = run(['update', emptyProject, '--check', '--json']);
202
+ check('update keeps --check as a boolean flag',
203
+ updateCheck.status === 0 && json(updateCheck)?.code !== 'missing_flag_value'
204
+ && typeof json(updateCheck)?.verdict === 'string',
205
+ updateCheck);
206
+
207
+ const updateCheckEquals = run(['update', emptyProject, '--check=true', '--json']);
208
+ check('update rejects a value attached to its boolean --check',
209
+ updateCheckEquals.status === 1 && json(updateCheckEquals)?.code === 'invalid_flag_syntax',
210
+ updateCheckEquals);
211
+
212
+ const previewKeep = run(['preview', 'serve', 'P-NOT-FOUND', '--keep', '--path', emptyProject, '--json']);
213
+ check('preview serve keeps --keep as a boolean flag',
214
+ previewKeep.status === 1 && json(previewKeep)?.code === 'not_found',
215
+ previewKeep);
216
+
217
+ const previewKeepBeforeId = run(['preview', 'serve', '--keep', 'P-NOT-FOUND', '--path', emptyProject, '--json']);
218
+ check('preview serve does not let --keep swallow a following preview id',
219
+ previewKeepBeforeId.status === 1 && json(previewKeepBeforeId)?.code === 'not_found'
220
+ && String(json(previewKeepBeforeId)?.error || '').includes('P-NOT-FOUND'),
221
+ previewKeepBeforeId);
222
+
223
+ const previewKeepBeforeSubcommand = run(['preview', '--keep', 'serve', 'P-NOT-FOUND', '--path', emptyProject, '--json']);
224
+ check('preview serve resolves its boolean --keep even before the subcommand',
225
+ previewKeepBeforeSubcommand.status === 1 && json(previewKeepBeforeSubcommand)?.code === 'not_found'
226
+ && String(json(previewKeepBeforeSubcommand)?.error || '').includes('P-NOT-FOUND'),
227
+ previewKeepBeforeSubcommand);
228
+
229
+ const refereeMissingCheck = run(['referee', 'add', 'route-arity-missing', '--check', '--good', 'node -e "process.exit(0)"', '--bad', 'node -e "process.exit(1)"', '--path', emptyProject, '--json']);
230
+ check('referee add still requires a value for --check',
231
+ refereeMissingCheck.status === 1 && json(refereeMissingCheck)?.code === 'missing_flag_value',
232
+ refereeMissingCheck);
233
+
234
+ const cleanupMissingKeep = run(['release', 'cleanup', '--keep', '--path', emptyProject, '--json']);
235
+ check('release cleanup still requires a numeric value for --keep',
236
+ cleanupMissingKeep.status === 1 && json(cleanupMissingKeep)?.code === 'missing_flag_value',
237
+ cleanupMissingKeep);
238
+
239
+ const gateMissingReferee = run(['gate', emptyProject, '--require-referee', '--json']);
240
+ check('gate retains its structured missing-referee check before dispatch',
241
+ gateMissingReferee.status === 1 && Array.isArray(json(gateMissingReferee)?.checks)
242
+ && json(gateMissingReferee).checks.some(row => row?.code === 'referee_missing_value'),
243
+ gateMissingReferee);
244
+
245
+ const spec = path.join(emptyProject, 'contract.md');
246
+ const impl = path.join(emptyProject, 'contract.js');
247
+ fs.writeFileSync(spec, '- charge()\n', 'utf8');
248
+ fs.writeFileSync(impl, 'function charge() {}\nmodule.exports = { charge };\n', 'utf8');
249
+ const contractWithGlobalPath = run(['contract', 'verify', spec, impl, '--path', emptyProject, '--json']);
250
+ check('contract verify retains the global --path compatibility flag',
251
+ contractWithGlobalPath.status === 0 && json(contractWithGlobalPath)?.ok === true && !contractWithGlobalPath.stderr,
252
+ contractWithGlobalPath);
253
+
254
+ const verifyForeign = run(['verify-code', emptyProject, '--cmd', 'ignored-command']);
255
+ check('verify-code rejects flags it does not implement',
256
+ verifyForeign.status === 1 && /--cmd/.test((verifyForeign.stdout || '') + (verifyForeign.stderr || '')),
257
+ verifyForeign);
258
+
259
+ const verifyTestCommand = run(['verify-code', emptyProject, '--test-cmd', 'ignored-command']);
260
+ check('verify-code rejects --test-cmd instead of recording unrelated auto-detected evidence',
261
+ verifyTestCommand.status === 1 && /--test-cmd/.test((verifyTestCommand.stdout || '') + (verifyTestCommand.stderr || '')),
262
+ verifyTestCommand);
263
+
264
+ const strictEquals = run(['verify-code', emptyProject, '--strict=true']);
265
+ check('verify-code rejects a boolean value form instead of losing strict mode',
266
+ strictEquals.status === 1 && /--strict=true/.test((strictEquals.stdout || '') + (strictEquals.stderr || '')),
267
+ strictEquals);
268
+
269
+ const claimOwnsTestCommand = run(['verify-claim', 'T-NOT-FOUND', '--run-tests', '--test-cmd', 'node -e "process.exit(0)"', '--path', emptyProject, '--json']);
270
+ check('verify-claim retains its supported --test-cmd contract',
271
+ claimOwnsTestCommand.status === 1 && json(claimOwnsTestCommand)?.code !== 'unknown_flag',
272
+ claimOwnsTestCommand);
273
+ } finally {
274
+ fs.rmSync(emptyProject, { recursive: true, force: true });
275
+ }
276
+
277
+ // Invalid input must not trigger the legacy workspace auto-migration that is
278
+ // normally allowed for a valid command.
279
+ const legacyProject = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-command-flags-legacy-'));
280
+ try {
281
+ const legacyWorkspaceName = ['.', 'harness'].join('');
282
+ const legacyDir = path.join(legacyProject, legacyWorkspaceName);
283
+ fs.mkdirSync(legacyDir);
284
+ fs.writeFileSync(path.join(legacyDir, 'HARNESS_VERSION'), '1.36.161\n', 'utf8');
285
+ const before = fs.readFileSync(path.join(legacyDir, 'HARNESS_VERSION'), 'utf8');
286
+ for (const mutationFlag of ['--fix', '--baseline', '--auto-track']) {
287
+ const beforeInvalidGate = treeSnapshot(legacyProject);
288
+ const invalidGate = run(['gate', '--require-referee', mutationFlag, '--path', legacyProject, '--json']);
289
+ check(`gate ${mutationFlag} rejection cannot migrate or mutate a legacy workspace`,
290
+ invalidGate.status === 1 && json(invalidGate)?.code === 'unknown_flag'
291
+ && fs.existsSync(legacyDir) && !fs.existsSync(path.join(legacyProject, '.leerness'))
292
+ && treeSnapshot(legacyProject) === beforeInvalidGate,
293
+ invalidGate);
294
+ }
295
+ const invalidLegacy = run(['commands', '--unknown-value', 'database', '--path', legacyProject, '--json']);
296
+ check('unknown flags fail before legacy workspace migration or writes',
297
+ invalidLegacy.status === 1 && json(invalidLegacy)?.code === 'unknown_flag'
298
+ && fs.existsSync(legacyDir) && !fs.existsSync(path.join(legacyProject, '.leerness'))
299
+ && fs.readFileSync(path.join(legacyDir, 'HARNESS_VERSION'), 'utf8') === before,
300
+ invalidLegacy);
301
+
302
+ const invalidKeepLegacy = run(['init', '--keep', 'abc', '--path', legacyProject, '--json']);
303
+ check('invalid value semantics fail before legacy workspace migration or writes',
304
+ invalidKeepLegacy.status === 1 && json(invalidKeepLegacy)?.code === 'invalid_keep'
305
+ && fs.existsSync(legacyDir) && !fs.existsSync(path.join(legacyProject, '.leerness'))
306
+ && fs.readFileSync(path.join(legacyDir, 'HARNESS_VERSION'), 'utf8') === before,
307
+ invalidKeepLegacy);
308
+
309
+ const missingRefereeLegacy = run(['gate', '--require-referee', '--path', legacyProject, '--json']);
310
+ check('command-specific malformed diagnostics also run before legacy migration',
311
+ missingRefereeLegacy.status === 1 && Array.isArray(json(missingRefereeLegacy)?.checks)
312
+ && json(missingRefereeLegacy).checks.some(row => row?.code === 'referee_missing_value')
313
+ && fs.existsSync(legacyDir) && !fs.existsSync(path.join(legacyProject, '.leerness')),
314
+ missingRefereeLegacy);
315
+ } finally {
316
+ fs.rmSync(legacyProject, { recursive: true, force: true });
317
+ }
318
+
319
+ if (failures.length) {
320
+ console.log(`UNKNOWN_FLAGS_ACCEPTED: ${failures.join('; ')}`);
321
+ process.exitCode = 1;
322
+ } else {
323
+ console.log('command flags probe passed');
324
+ }
@@ -104,9 +104,9 @@ async function race(name, config) {
104
104
  }));
105
105
  results.push(await race('memory-restore-decisions', {
106
106
  stallTarget: 'decisions.json',
107
- seed: d => { run(d, ['decision', 'add', 'archived', '--why', 'test']); run(d, ['decision', 'drop', 'archived']); },
107
+ seed: d => { run(d, ['decision', 'add', 'archived', '--reason', 'test']); run(d, ['decision', 'drop', 'archived']); },
108
108
  first: ['memory', 'restore', 'decisions', 'archived'],
109
- second: ['decision', 'add', 'survivor', '--why', 'test'],
109
+ second: ['decision', 'add', 'survivor', '--reason', 'test'],
110
110
  inspect: d => {
111
111
  const titles = JSON.parse(fs.readFileSync(path.join(d, '.leerness', 'decisions.json'), 'utf8')).map(item => item.title);
112
112
  return { titles, preserved: titles.some(title => title.includes('archived')) && titles.some(title => title.includes('survivor')) };
package/scripts/e2e.js CHANGED
@@ -10630,7 +10630,7 @@ total++;
10630
10630
  { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV0 });
10631
10631
  const CASES = [
10632
10632
  ['task add', ['task', 'add', '락프로브']],
10633
- ['decision add', ['decision', 'add', '락프로브', '--why', 'x']],
10633
+ ['decision add', ['decision', 'add', '락프로브', '--reason', 'x']],
10634
10634
  ['lesson save', ['lesson', 'save', '락프로브']],
10635
10635
  ['rule add', ['rule', 'add', '락프로브', '--trigger', 'every-session']],
10636
10636
  ['rule pause', ['rule', 'pause', 'R-0001'], ['rule', 'add', '씨앗룰', '--trigger', 'every-session']],
@@ -10640,7 +10640,7 @@ total++;
10640
10640
  ['creds refresh', ['creds', 'refresh', '락프로브'], d => SEED(d, ['creds', 'register', '락프로브', '--env-var', 'LOCK_PROBE'])],
10641
10641
  // team ID는 ASCII slug만 허용한다. 표시 라벨과 달리 seed/삭제 대상은 실제 유효 ID를 쓴다.
10642
10642
  ['team remove', ['team', 'remove', 'lock-probe'], d => SEED(d, ['team', 'add', 'lock-probe', '--name', 'lock'])],
10643
- ['memory restore decisions', ['memory', 'restore', 'decisions', '락프로브'], d => { SEED(d, ['decision', 'add', '락프로브', '--why', 'x']); SEED(d, ['decision', 'drop', '락프로브']); }],
10643
+ ['memory restore decisions', ['memory', 'restore', 'decisions', '락프로브'], d => { SEED(d, ['decision', 'add', '락프로브', '--reason', 'x']); SEED(d, ['decision', 'drop', '락프로브']); }],
10644
10644
  ['memory restore lessons', ['memory', 'restore', 'lessons', '락프로브'], d => { SEED(d, ['lesson', 'save', '락프로브']); SEED(d, ['lesson', 'drop', '락프로브']); }],
10645
10645
  ['memory restore plan', ['memory', 'restore', 'plan', '락프로브'], d => { SEED(d, ['plan', 'add', '락프로브']); SEED(d, ['plan', 'remove', '락프로브']); }],
10646
10646
  ['plan drop', ['plan', 'drop', '락프로브']],
@@ -10940,7 +10940,7 @@ total++;
10940
10940
  const R = (a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d], { cwd: d, encoding: 'utf8', timeout: 300000, env: ENV });
10941
10941
  // 상태를 조금 만든다 — 빈 프로젝트는 출력이 짧아 누출을 과소평가한다. 입력은 전부 영어.
10942
10942
  R(['task', 'add', 'Implement the parser']);
10943
- R(['decision', 'add', 'Use JSON storage', '--why', 'simplest']);
10943
+ R(['decision', 'add', 'Use JSON storage', '--reason', 'simplest']);
10944
10944
  R(['plan', 'add', 'Ship v1']);
10945
10945
  const HANGUL = /[가-힣ㄱ-ㆎ]/;
10946
10946
  // 측정 대상은 **도구가 말하는 표면**이다. 사용자 데이터(프로젝트명·과거 기록)는 한국어여도 정상이므로
@@ -11255,7 +11255,7 @@ total++;
11255
11255
  const SURFACES = [
11256
11256
  ['requests', (d, t) => R(d, ['requests', 'add', t]), (d) => [R(d, ['requests', 'list']), R(d, ['requests', 'audit'])]],
11257
11257
  ['task', (d, t) => R(d, ['task', 'add', t]), (d) => [R(d, ['task', 'list'])]],
11258
- ['decision', (d, t) => R(d, ['decision', 'add', t, '--why', 'r']), (d) => [R(d, ['decision', 'list'])]],
11258
+ ['decision', (d, t) => R(d, ['decision', 'add', t, '--reason', 'r']), (d) => [R(d, ['decision', 'list'])]],
11259
11259
  ['lesson', (d, t) => R(d, ['lesson', 'save', t]), (d) => [R(d, ['lesson', 'list'])]],
11260
11260
  ['rule', (d, t) => R(d, ['rule', 'add', t, '--trigger', 'every-round']), (d) => [R(d, ['rule', 'list'])]],
11261
11261
  ['plan', (d, t) => R(d, ['plan', 'add', t]), (d) => [R(d, ['plan', 'list'])]],
@@ -11464,11 +11464,11 @@ total++;
11464
11464
  const SEED = [['requests', 'add', 'seed'], ['preview', 'add', 'seed'], ['toggle', 'set', 'gate', 'off'],
11465
11465
  ['creds', 'register', 'seed', '--env-var', 'K'], ['permissions', 'set', 'extended'], ['wakeup-interval', 'set', '900'],
11466
11466
  ['next-action', 'add', 'seed'], ['constraints', 'add', 'seed', '--constraint', 'k:v'],
11467
- ['decision', 'add', 'seed', '--why', 'r'], ['lesson', 'save', 'seed']];
11467
+ ['decision', 'add', 'seed', '--reason', 'r'], ['lesson', 'save', 'seed']];
11468
11468
  const MUT = [['requests', 'add', 'x2'], ['preview', 'add', 'x2'], ['toggle', 'set', 'lens', 'off'],
11469
11469
  ['creds', 'register', 'x2', '--env-var', 'K2'], ['permissions', 'set', 'basic'], ['wakeup-interval', 'set', '1200'],
11470
11470
  ['next-action', 'add', 'x2'], ['constraints', 'add', 'x2', '--constraint', 'k:v'],
11471
- ['decision', 'add', 'x2', '--why', 'r'], ['lesson', 'save', 'x2'], ['task', 'add', 'x2'], ['plan', 'add', 'x2']];
11471
+ ['decision', 'add', 'x2', '--reason', 'r'], ['lesson', 'save', 'x2'], ['task', 'add', 'x2'], ['plan', 'add', 'x2']];
11472
11472
  const DERIVED = new Set(['manifest.json', 'skills-lock.json', 'leerness-config.json', 'environment.json']);
11473
11473
  const seeded = () => { const d = mk(); for (const a of SEED) R(d, a); return d; };
11474
11474
  const base = seeded();