leerness 1.9.414 → 1.9.416

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,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.416 — 2026-06-07 — add류 CLI 인자 일관성: 경로 흡수 차단 + 빈 입력 거부 (9번째 외부평가, UR-0122)
4
+
5
+ **🧩 9번째 외부평가의 최강 UX 발견(3모델 공통: CLI 인자 규칙 불일치) 보강 — `task add`/`requests add`/`decision add` 의 제목 파싱을 단일 출처로 통일.**
6
+
7
+ ### 배경
8
+ Sonnet P1 + Codex: `task add "제목" /some/path` 가 경로를 제목에 흡수("제목 /some/path")하던 반면 `decision add` 는 1.9.351(UR-0064)에서 이미 경로형 positional 을 차단. 같은 add 패러다임에서 동작이 갈려 혼란.
9
+
10
+ ### 수정
11
+ - **`_parseAddTitle(args, startIdx)` 순수 헬퍼**(pure-utils): positional 을 join 하되 첫 `--flag` 또는 경로형 토큰(`/x`, `C:\x`, `./x`, `../x`)에서 멈춤 — **단일 출처**.
12
+ - `task add` / `requests add` / `decision add` 모두 이 헬퍼 사용 → 경로 흡수 일관 차단.
13
+ - **빈/경로-only 제목 거부**: `task add ""`, `task add /path` 등 → `failJson`(--json 시 `{ok:false,code:"empty_title"}`) + **exit 1**(기존엔 빈 task 생성). decision/requests add 동일.
14
+
15
+ ### 검증 (회귀 0)
16
+ - **selftest 161→162 PASS** (_parseAddTitle 4 단위 + task/requests 와이어).
17
+ - **E2E 415→416 PASS** (경로 흡수 차단 + 빈/경로-only exit 1 + --json + requests 경로 break).
18
+
19
+ ### 후속 백로그 (다음 라운드)
20
+ team add/show positional path 일관성(UR-0122 잔여) · status/health "healthy" 라벨(UR-0121 잔여) · contract field 범용화(UR-0123) · init 프로파일/명령 계층화(UR-0124).
21
+
22
+ ## 1.9.415 — 2026-06-07 — 정직성 수정: handoff 보안 헤드라인 false-OK + scan/encoding/contract --json (9번째 외부평가, UR-0121)
23
+
24
+ **🚨 9번째 외부 멀티모델 리뷰(Codex GPT-5.5 + Claude Sonnet/Opus 4.8, README 미참조 클린룸)에서 발견한 "의미적 false-OK"를 수정 — leerness 정체성(정직/anti-laziness)과 정면 충돌하던 결함.**
25
+
26
+ ### 배경
27
+ 3개 모델이 깨끗한 경로에 leerness@latest 설치(README 제거) 후 직접 사용으로 객관 평가. 모든 발견을 직접 재현·검증(맹신 X)해 확정분만 수정.
28
+
29
+ ### 수정 (확정 발견)
30
+ - **[Codex P1] handoff 보안 헤드라인 false-OK**: `handoff` 가 `.env` 가 `.gitignore` 에 있으면 무조건 "🔒 보안 OK" 출력 — **하드코딩된 시크릿이 있어도** 안전하다고 표시(scan secrets/gate 는 정확히 실패하는데 handoff 만 거짓 안심). 이제 실제 시크릿 스캔(`_collectSecretFindings`) 결과를 반영 → 커밋 대상 시크릿이 있으면 "🚨 시크릿 N건", 없을 때만 "🔒 보안 OK".
31
+ - **[Opus/Codex P2] `scan secrets` / `encoding check` --json 무시**: 두 명령이 `--json` 을 무시하고 텍스트만 출력(MCP/CI 파싱 불가) → 구조화 JSON + exit code 일관화.
32
+ - **[Opus P2] `contract verify --json` 불일치인데 exit 0**: `--json` 분기가 `process.exitCode=1` 보다 먼저 return → CI 가 계약 실패를 못 잡음. 불일치 시 exit 1.
33
+
34
+ ### 검증 (회귀 0)
35
+ - **selftest 160→161 PASS** · **E2E 414→415 PASS**.
36
+
37
+ ### 직접 재현으로 기각한 발견 (맹신 X)
38
+ - [Opus P1] contract verify 백틱 불릿 누락 → 이미 정상 작동(1.9.385 파서)으로 **기각**.
39
+ - [Sonnet] npm 패키지가 .harness 동봉 → 미동봉으로 **기각**.
40
+ - [Opus] route/gate/audit 동시성 손상 → Opus 자체 정정(파이프 아티팩트).
41
+
42
+ ### 후속 백로그 (UR-0122~0124)
43
+ team add positional path 일관성 · status/health "healthy" 라벨 명확화 · contract field 범용화 · init 침습성/명령 계층화/handoff 속도/--compact 압축.
44
+
3
45
  ## 1.9.414 — 2026-06-07 — 에이전트 팀에 "메인 검수" 단계 (서브에이전트 검수 통합, UR-0119/0120)
4
46
 
5
47
  **🤝 team 에 `--review`(메인 에이전트 검수 요구) 추가 — "분배(sub-agent) → 메인 검수" 흐름을 팀 정의에 일급으로 통합.**
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > **AI 코딩 에이전트의 거짓 완료·중복·망각·충돌을 막아주는 검수·기억·협업 CLI 하네스.**
4
4
  > **A CLI harness that stops AI coding agents from faking completion, duplicating work, forgetting context, and colliding.**
5
5
 
6
- [![npm](https://img.shields.io/badge/npm-leerness-blue)](https://www.npmjs.com/package/leerness) [![version](https://img.shields.io/badge/version-1.9.414-green)]() [![tests](https://img.shields.io/badge/e2e-353%2F353-success)]() [![selftest](https://img.shields.io/badge/selftest-160-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-85-brightgreen)]() [![providers](https://img.shields.io/badge/AI_providers-10-brightgreen)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
6
+ [![npm](https://img.shields.io/badge/npm-leerness-blue)](https://www.npmjs.com/package/leerness) [![version](https://img.shields.io/badge/version-1.9.416-green)]() [![tests](https://img.shields.io/badge/e2e-355%2F355-success)]() [![selftest](https://img.shields.io/badge/selftest-162-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-85-brightgreen)]() [![providers](https://img.shields.io/badge/AI_providers-10-brightgreen)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
7
7
 
8
8
  ```
9
9
  ╔══════════════════════════════════════════════════════════════╗
@@ -471,7 +471,7 @@ MIT — © leerness contributors
471
471
  <!-- leerness:project-readme:start -->
472
472
  ## Leerness Project Harness
473
473
 
474
- 이 프로젝트는 Leerness v1.9.414 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
474
+ 이 프로젝트는 Leerness v1.9.416 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
475
475
 
476
476
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
477
477
 
@@ -525,7 +525,7 @@ leerness memory restore decision <date|title>
525
525
 
526
526
  ### MCP server (외부 AI 통합)
527
527
 
528
- Leerness v1.9.414는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **85개 도구**를 노출:
528
+ Leerness v1.9.416는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **85개 도구**를 노출:
529
529
 
530
530
  ```jsonc
531
531
  // 카테고리별
@@ -546,7 +546,7 @@ Leerness v1.9.414는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
546
546
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
547
547
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) stress-v* 신규 작성 + 누적 회귀 → 4) e2e 219/219 → 5) npm pack + git tag + GitHub release → 6) main 자동 push (1.9.140+) → 7) session close → 8) 다음 라운드 예약.
548
548
 
549
- 현재 누적: **70 라운드 (1.9.40 → 1.9.414)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
549
+ 현재 누적: **70 라운드 (1.9.40 → 1.9.416)** · 매 라운드 GitHub release/태그 생성 · _reports/는 비공개 보존.
550
550
 
551
551
  ### 성능 가이드 (1.9.140 측정)
552
552
 
@@ -584,6 +584,6 @@ leerness release pack --close --auto-main-push
584
584
  - `.harness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
585
585
  - `.harness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
586
586
 
587
- Last synced by Leerness v1.9.414: 2026-06-07
587
+ Last synced by Leerness v1.9.416: 2026-06-07
588
588
  <!-- leerness:project-readme:end -->
589
589
 
package/bin/harness.js CHANGED
@@ -25,13 +25,13 @@ const { _isSecretKey, _isPlaceholderSecret, _looksSecretLike, _mergeLines, _merg
25
25
  _withBuiltinSource, _esc, _roadmapTokenStyles, _parseSkillMd,
26
26
  _migrationGuideText, _parseContractSpec, _gitignoreMatch,
27
27
  _featureGraphTemplate, _parseFeatureGraph, _nextFeatureId, _featureBlock, _featureImpactBfs,
28
- _parseChangelogBetween, _cellSafe, _cellUnescape, _lineSafe, _parseLimit } = require('../lib/pure-utils'); // 1.9.318~399 (UR-0025/0053/0075/0086/0087/0104): 순수 유틸 모듈 분리
28
+ _parseChangelogBetween, _cellSafe, _cellUnescape, _lineSafe, _parseLimit, _parseAddTitle } = require('../lib/pure-utils'); // 1.9.318~416 (UR-0025/0053/0075/0086/0087/0104/0122): 순수 유틸 모듈 분리
29
29
  // 1.9.304 (UR-0025): 순수 분석/검증 함수 모듈 분리.
30
30
  const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInGit, _epistemicHonestyCheck } = require('../lib/analyzers');
31
31
  // 1.9.295 (UR-0025 4단계): 정적 데이터 카탈로그 모듈 분리 (비파괴, require-based).
32
32
  const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE_CHECKLIST, _DEFAULT_PLATFORM_CONSTRAINTS, _DEFAULT_DOMAIN_CATALOG, _LSP_LANG_PATTERNS, OPTIMISM_PATTERNS, BUILT_IN_PERSONAS, STRINGS, BUILTIN_CATALOG, ROADMAP_STATUS_LABEL, ROADMAP_STATUS_COLOR, SECRET_PATTERNS, MERGE_OVERWRITE_FILES, MINIMAL_SKIP_KEYS, REQUIRED_WORKSPACE_FILES, KEYWORD_STOPWORDS, SKILL_CATALOG_PRESETS } = require('../lib/catalogs'); // 1.9.344/368/369 (UR-0025): catalog 분리 (MERGE_OVERWRITE_FILES/MINIMAL_SKIP_KEYS 포함)
33
33
 
34
- const VERSION = '1.9.414';
34
+ const VERSION = '1.9.416';
35
35
 
36
36
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
37
37
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -3025,6 +3025,34 @@ function _selfTestCases() {
3025
3025
  { name: '8번째 버그헌트 (UR-0115): lazy detect --auto-track 단일 RMW 배치(O(T×N)→O(N+T)) (1.9.411)', run: () => { const src = read(__filename); const batched = src.includes("8번째 버그헌트, UR-0115") && /has\('--auto-track'\)[\s\S]{0,500}?_withLock\(progressPath\(root\), \(\) => \{[\s\S]{0,1200}?writeProgressRows/.test(src); const noPerTodoUpsert = !/for \(const t of newTodos\) \{\s*const id = nextId\(root, 'T'\);/.test(src); return batched && noPerTodoUpsert; } },
3026
3026
  { name: '6번째 외부평가 Opus P1 (UR-0100): list-family(decision/feature/plan/runs/team list) positional path 지원 (조용한 cwd 오독 차단) (1.9.412)', run: () => { const src = read(__filename); const L = '_resolveRoot('; const decOk = src.includes("decisionListCmd(absRoot(" + L + "args[2]))"); const planOk = src.includes("planListCmd(absRoot(" + L + "args[2]))"); const featOk = src.includes("featureListCmd(absRoot(" + L + "args[2]))"); const runsOk = src.includes("runsListCmd(absRoot(" + L + "args[2]))"); const teamOk = src.includes(L + "args[1] === 'list' ? args[2] : null)"); return decOk && planOk && featOk && runsOk && teamOk; } },
3027
3027
  { name: '6번째 외부평가 codex P2 (UR-0101): action 명령(task/decision/rule/lesson add) --json 구조화 출력 (1.9.413)', run: () => { const src = read(__filename); const taskJ = src.includes("log(JSON.stringify({ ok: true, id, status: arg('--status', 'requested'), request: text }))"); const decJ = src.includes("log(JSON.stringify({ ok: true, title }))"); const lesJ = src.includes("log(JSON.stringify({ ok: true, text, tag: tag || null }))"); const ruleJ = src.includes("skipped: !!result.skip"); return taskJ && decJ && lesJ && ruleJ; } },
3028
+ { name: '9th 외부평가 Sonnet/Codex (UR-0122): add류 _parseAddTitle(flag/경로 break) + 빈 입력 거부 와이어 (1.9.416)', run: () => {
3029
+ const m = require('../lib/pure-utils');
3030
+ if (typeof m._parseAddTitle !== 'function' || m._parseAddTitle !== _parseAddTitle) return false;
3031
+ const u1 = m._parseAddTitle(['add', '제목', '/x/y'], 1) === '제목';
3032
+ const u2 = m._parseAddTitle(['add', 'my', 'title', '--note', 'v'], 1) === 'my title';
3033
+ const u3 = m._parseAddTitle(['add', '/only'], 1) === '';
3034
+ const u4 = m._parseAddTitle(['add', './rel'], 1) === '';
3035
+ const src = read(__filename);
3036
+ const taskWired = src.includes('_parseAddTitle(args, 2)') && src.includes("'empty_title'");
3037
+ const reqWired = src.includes('_parseAddTitle(rest, 0)');
3038
+ return u1 && u2 && u3 && u4 && taskWired && reqWired;
3039
+ } },
3040
+ { name: '9th 외부평가 Codex P1 (UR-0121): handoff 보안 헤드라인 실제 스캔 기반 + scan/encoding --json + contract --json exit (1.9.415)', run: () => {
3041
+ const src = read(__filename);
3042
+ const handoffWired = src.includes('_collectSecretFindings(root)') && src.includes('🚨 ' + '시크릿 ');
3043
+ const scanJson = src.includes('function scanSecrets(root, opts = {})') && src.includes("has('--json') || opts.json");
3044
+ const encJson = src.includes('function encodingCheck(root, opts = {})');
3045
+ const contractExit = src.includes('if (!okJson) process.exitCode = 1;');
3046
+ let behav = false;
3047
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_sec_'));
3048
+ try {
3049
+ fs.writeFileSync(path.join(tmp, 'c.js'), 'module.exports={apiKey:"sk-test-1234567890abcdefghijklmnopqrstuvwxyz"};');
3050
+ const r = _collectSecretFindings(tmp);
3051
+ const clean = _collectSecretFindings(fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_clean_')));
3052
+ behav = r.committed.length >= 1 && clean.committed.length === 0;
3053
+ } catch {} finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
3054
+ return handoffWired && scanJson && encJson && contractExit && behav;
3055
+ } },
3028
3056
  { name: '9라운드 (UR-0119/0120): team review(메인 검수) — _composeTeamPlan reviewStep + handoff 검수필요 + team add 와이어 (1.9.414)', run: () => { const m = require('../lib/pure-utils'); const on = m._composeTeamPlan({ id: 't', members: ['a', 'b'], personas: ['security'] }, '점검'); const off = m._composeTeamPlan({ id: 't', members: ['a'], review: false }, '점검'); const planOk = on.review === true && !!on.reviewStep && on.reviewStep.suggestedCommand.includes('verify-claim') && off.review === false && !off.reviewStep; const rem = m._teamHandoffReminders([{ id: 'r', schedule: 'every-session', status: 'active', members: ['a'], review: true }]); const remOk = rem.length === 1 && rem[0].includes('검수필요'); const teamSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'team.js')); const wired = teamSrc.includes("review: !has('--no-review')") && teamSrc.includes('메인 검수 (필수)'); return planOk && remOk && wired; } },
3029
3057
  { name: 'VERSION 형식 (x.y.z)', run: () => /^\d+\.\d+\.\d+$/.test(VERSION) }
3030
3058
  ];
@@ -4497,8 +4525,9 @@ function requestsCmd(root, sub, ...rest) {
4497
4525
  }
4498
4526
 
4499
4527
  if (sub === 'add') {
4500
- const text = rest.filter(x => !x.startsWith('-')).join(' ');
4501
- if (!text) { console.error('Usage: leerness requests add "<request text>"'); process.exit(1); }
4528
+ // 1.9.416 (UR-0122): flag/경로 break(기존 filter 는 경로형 positional 을 text 에 흡수) + 빈 입력 거부
4529
+ const text = _parseAddTitle(rest, 0);
4530
+ if (!text) { failJson(has('--json'), 'empty_request', 'leerness requests add "<요청>" 필요 (빈/경로-only 거부)'); return process.exit(process.exitCode || 1); }
4502
4531
  const entry = _recordUserRequest(root, text);
4503
4532
  if (!entry) { console.error('failed to record'); process.exit(1); }
4504
4533
  if (has('--json')) { log(JSON.stringify(entry, null, 2)); return; }
@@ -7128,30 +7157,31 @@ function _isLikelyGitignored(root, fileRel) {
7128
7157
  try { gi = read(path.join(root, '.gitignore')); } catch { return false; }
7129
7158
  return _gitignoreMatch(gi, fileRel);
7130
7159
  }
7131
- function scanSecrets(root) {
7160
+ // 1.9.415 (9th 외부평가 Codex P1): 시크릿 findings 수집 순수부 — scanSecrets 출력/exit 와 handoff 보안 헤드라인이 공유(단일 출처).
7161
+ // 기존엔 handoff 가 '.env 가 .gitignore 에 있으면 보안 OK' 로만 판단(하드코딩 시크릿 무시 → false-OK). 이제 둘 다 실제 스캔 결과를 사용.
7162
+ function _collectSecretFindings(root) {
7132
7163
  root = absRoot(root);
7133
7164
  const findings = [];
7134
- // 1.9.354 (UR-0072 외부리뷰): root 가 파일이면 그 파일만 스캔 (이전: walk 의 readdirSync → ENOTDIR). 디렉토리면 기존 walk.
7165
+ // 1.9.354 (UR-0072 외부리뷰): root 가 파일이면 그 파일만 스캔. 디렉토리면 walk.
7135
7166
  let _iter;
7136
7167
  try { _iter = fs.statSync(root).isFile() ? [root] : walk(root); } catch { _iter = walk(root); }
7137
7168
  for (const file of _iter) {
7138
7169
  const ext = path.extname(file).toLowerCase();
7139
- // 1.9.386 (UR-0087, 5번째 외부평가): env-family(.env / .env.local / .env.bad / .env.production …)
7140
- // extname 이 .bad/.local/.production 라 SCAN_TEXT_EXT 에 없어 통째로 스킵되던 FN → basename 으로 강제 포함.
7170
+ // 1.9.386 (UR-0087): env-family(.env / .env.local / .env.production …) basename 강제 포함.
7141
7171
  const isEnvFamily = /^\.env(\.|$)/.test(path.basename(file));
7142
7172
  if (!SCAN_TEXT_EXT.has(ext) && !isEnvFamily) continue;
7143
7173
  let text;
7144
7174
  try { text = read(file); } catch { continue; }
7145
7175
  if (text.length > 1024 * 1024) continue;
7146
- const fileRel = (file === root) ? path.basename(file) : rel(root, file); // 1.9.354 (UR-0072): 단일 파일 스캔 시 basename 표시('.' 방지)
7147
- // 1.9.350 (UR-0060 외부리뷰): leerness 자기 harness.js(regex 소스) + 생성 secret-policy 템플릿만 제외 — 정확 경로(사용자 파일명 substring false-negative 제거)
7176
+ const fileRel = (file === root) ? path.basename(file) : rel(root, file);
7177
+ // 1.9.350 (UR-0060): leerness 자기 harness.js + secret-policy 템플릿만 제외.
7148
7178
  if (path.resolve(file) === path.resolve(__filename) || /(^|[\\/])\.(?:harness|leerness)[\\/]secret-policy\.md$/.test(fileRel)) continue;
7149
- const gitignored = _isLikelyGitignored(root, fileRel); // 1.9.365 CV-6: gitignored 면 발견을 info 강등
7179
+ const gitignored = _isLikelyGitignored(root, fileRel); // 1.9.365 CV-6: gitignored 면 info 강등
7150
7180
  for (const { name, re, valueGroup, requireSecretLike } of SECRET_PATTERNS) {
7151
7181
  re.lastIndex = 0;
7152
7182
  let m;
7153
7183
  while ((m = re.exec(text))) {
7154
- // 1.9.365 (CV-6/UR-0081): assignment 패턴 값이 placeholder/예시면 오탐 → 스킵(같은 패턴 계속 탐색).
7184
+ // 1.9.365 (CV-6/UR-0081): placeholder/예시 스킵.
7155
7185
  if (valueGroup != null) {
7156
7186
  const val = m[valueGroup];
7157
7187
  if (_isPlaceholderSecret(val)) { if (re.lastIndex === m.index) re.lastIndex++; continue; }
@@ -7163,9 +7193,19 @@ function scanSecrets(root) {
7163
7193
  }
7164
7194
  }
7165
7195
  }
7166
- // 1.9.365 (CV-6/UR-0081): gitignored(.env 등 설계상 안전 보관) 발견은 info, 커밋되는 파일 발견만 실패.
7167
- const committed = findings.filter(f => !f.gitignored);
7168
- const ignored = findings.filter(f => f.gitignored);
7196
+ // gitignored(.env 등 안전 보관) info, 커밋 대상 발견만 실패.
7197
+ return { findings, committed: findings.filter(f => !f.gitignored), ignored: findings.filter(f => f.gitignored) };
7198
+ }
7199
+
7200
+ function scanSecrets(root, opts = {}) {
7201
+ root = absRoot(root);
7202
+ const { committed, ignored } = _collectSecretFindings(root);
7203
+ // 1.9.415 (9th 외부평가 Opus/Codex): --json 일관성 — 기존엔 --json 무시하고 사람용 텍스트만 출력하던 FN.
7204
+ if (has('--json') || opts.json) {
7205
+ log(JSON.stringify({ version: VERSION, root, ok: committed.length === 0, count: committed.length, committed, ignored }, null, 2));
7206
+ if (committed.length) process.exitCode = 1;
7207
+ return;
7208
+ }
7169
7209
  if (committed.length) {
7170
7210
  fail(`secret patterns found: ${committed.length}`);
7171
7211
  committed.forEach(f => log(` ${f.file}:${f.line} ${f.name} ${f.snippet}…`));
@@ -7178,7 +7218,7 @@ function scanSecrets(root) {
7178
7218
  }
7179
7219
  }
7180
7220
 
7181
- function encodingCheck(root) {
7221
+ function encodingCheck(root, opts = {}) {
7182
7222
  root = absRoot(root);
7183
7223
  let warnings = 0; const findings = [];
7184
7224
  for (const file of walk(root)) {
@@ -7207,6 +7247,12 @@ function encodingCheck(root) {
7207
7247
  }
7208
7248
  } catch {}
7209
7249
  }
7250
+ // 1.9.415 (9th 외부평가 Opus/Codex): --json 일관성 — 기존엔 --json 무시하고 텍스트만 출력하던 FN.
7251
+ if (has('--json') || opts.json) {
7252
+ log(JSON.stringify({ version: VERSION, root, ok: findings.length === 0, count: findings.length, findings }, null, 2));
7253
+ if (warnings > 0) process.exitCode = 1;
7254
+ return;
7255
+ }
7210
7256
  if (findings.length) {
7211
7257
  warn(`encoding issues: ${findings.length}`);
7212
7258
  findings.forEach(f => log(` ${f.file} ${f.issue}`));
@@ -7670,14 +7716,22 @@ function handoff(root) {
7670
7716
  const j = JSON.parse(r.stdout.trim());
7671
7717
  if (j.level) parts.push(`drift ${j.level.replace(/^[^\w]+/, '')} (${j.score})`);
7672
7718
  } catch {}
7673
- // 2) 보안 상태
7719
+ // 2) 보안 상태 (1.9.415, 9th 외부평가 Codex P1): 실제 시크릿 스캔 기반.
7720
+ // 기존엔 '.env 가 .gitignore 에 있으면 보안 OK' 로만 판단해 하드코딩 시크릿이 있어도 '🔒 보안 OK' 출력하던 false-OK.
7721
+ // 이제 커밋 대상 시크릿이 있으면 '🚨 시크릿 N건', 없을 때만 '보안 OK'(.env 미무시는 별도 경고).
7674
7722
  try {
7675
- const envPath = path.join(root, '.env');
7676
- if (exists(envPath)) {
7677
- const giText = exists(path.join(root, '.gitignore')) ? read(path.join(root, '.gitignore')) : '';
7678
- const giLines = giText.split('\n').map(l => l.trim());
7679
- if (giLines.includes('.env') || giLines.includes('/.env')) parts.push('🔒 보안 OK');
7680
- else parts.push('🚨 보안 위험');
7723
+ const sec = _collectSecretFindings(root);
7724
+ if (sec.committed.length) {
7725
+ parts.push(`🚨 시크릿 ${sec.committed.length}건`);
7726
+ } else {
7727
+ const envPath = path.join(root, '.env');
7728
+ if (exists(envPath)) {
7729
+ const giText = exists(path.join(root, '.gitignore')) ? read(path.join(root, '.gitignore')) : '';
7730
+ const giLines = giText.split('\n').map(l => l.trim());
7731
+ parts.push((giLines.includes('.env') || giLines.includes('/.env')) ? '🔒 보안 OK' : '🚨 .env 미무시');
7732
+ } else {
7733
+ parts.push('🔒 보안 OK');
7734
+ }
7681
7735
  }
7682
7736
  } catch {}
7683
7737
  // 3) MCP 활동 누적
@@ -19793,13 +19847,15 @@ function contractVerifyCmd(specPath, implPath) {
19793
19847
  }
19794
19848
  // 출력
19795
19849
  if (has('--json')) {
19850
+ const okJson = missing.length === 0 && fieldMissing.length === 0;
19796
19851
  log(JSON.stringify({
19797
19852
  spec: specFile, impl: implFile,
19798
19853
  specFunctions: [...fnSpec], specFields: [...fieldSpec],
19799
19854
  implExports: [...implExports],
19800
19855
  missingFunctions: missing, missingFields: fieldMissing,
19801
- ok: missing.length === 0 && fieldMissing.length === 0
19856
+ ok: okJson
19802
19857
  }, null, 2));
19858
+ if (!okJson) process.exitCode = 1; // 1.9.415 (9th 외부평가 Opus P2): --json 불일치도 exit 1 (기존엔 exit 0 → CI 가 계약실패 못 잡음)
19803
19859
  return;
19804
19860
  }
19805
19861
  log(`# leerness contract verify (1.9.35)`);
@@ -20953,7 +21009,12 @@ async function main() {
20953
21009
  if (cmd === 'task') {
20954
21010
  const root = absRoot(arg('--path', process.cwd())); const sub = args[1] || 'list';
20955
21011
  if (sub==='list') return taskList(root);
20956
- if (sub==='add') return taskAdd(root, args.slice(2).join(' ') || '새 작업');
21012
+ if (sub==='add') {
21013
+ // 1.9.416 (UR-0122): flag/경로 break + 빈 입력 거부 (기존: args.slice(2).join(' ') 가 경로 흡수 + 빈 task 생성)
21014
+ const title = _parseAddTitle(args, 2);
21015
+ if (!title) { failJson(has('--json'), 'empty_title', 'task add "<제목>" 필요 (빈/경로-only 제목 거부)'); return process.exit(process.exitCode || 1); }
21016
+ return taskAdd(root, title);
21017
+ }
20957
21018
  if (sub==='update') return taskUpdate(root, args[2]);
20958
21019
  if (sub==='drop') return taskDrop(root, args[2]);
20959
21020
  if (sub==='fix-evidence') return taskFixEvidence(root);
@@ -21009,13 +21070,10 @@ async function main() {
21009
21070
  const root = absRoot(arg('--path', process.cwd())); const sub = args[1] || '';
21010
21071
  if (sub === 'add') {
21011
21072
  // args[2..] 가 title (단, --flag 또는 경로형 positional 이 시작되기 전까지)
21012
- const titleParts = [];
21013
- for (let i = 2; i < args.length; i++) {
21014
- // 1.9.351 (UR-0064 외부리뷰): --flag value 제거 남는 경로형 positional title 흡수되는 것 차단
21015
- if (args[i].startsWith('--') || /^([A-Za-z]:[\\/]|\/|\.\.?[\\/])/.test(args[i])) break;
21016
- titleParts.push(args[i]);
21017
- }
21018
- return decisionAdd(root, titleParts.join(' '));
21073
+ // 1.9.351 (UR-0064) → 1.9.416 (UR-0122): 공유 헬퍼 _parseAddTitle 로 단일화(flag/경로 break) + 빈 입력 거부
21074
+ const title = _parseAddTitle(args, 2);
21075
+ if (!title) { failJson(has('--json'), 'empty_title', 'decision add "<제목>" 필요'); return process.exit(process.exitCode || 1); }
21076
+ return decisionAdd(root, title);
21019
21077
  }
21020
21078
  if (sub === 'list') {
21021
21079
  return decisionListCmd(absRoot(_resolveRoot(args[2])), { json: has('--json') }); // 1.9.412 (UR-0100): positional path 지원(add 의 args[2]=title 와 분리)
package/lib/pure-utils.js CHANGED
@@ -962,7 +962,9 @@ module.exports = {
962
962
  // 1.9.402 (7번째 버그헌트 P1-A 잔여, UR-0108): MD projection 라인 안전화(개행→공백)
963
963
  _lineSafe,
964
964
  // 1.9.407 (8번째 버그헌트, UR-0111): --limit 안전 파싱(NaN/음수/0 → 기본값)
965
- _parseLimit
965
+ _parseLimit,
966
+ // 1.9.416 (9th 외부평가, UR-0122): add 류 제목 파싱(flag/경로 break) 단일 출처
967
+ _parseAddTitle
966
968
  };
967
969
 
968
970
  // 1.9.355 (UR-0075 Phase A): AI 에이전트용 크로스버전 마이그레이션 안전 워크플로 가이드 (순수 텍스트). 임시설치 + --path + 백업 + diff 검증.
@@ -1208,3 +1210,17 @@ function _cellUnescape(s) { return String(s == null ? '' : s).replace(/\\\|/g, '
1208
1210
  function _lineSafe(s) { return String(s == null ? '' : s).replace(/\r\n|\r|\n/g, ' '); }
1209
1211
  // 1.9.407 (8번째 버그헌트, UR-0111): --limit 안전 파싱 — NaN(예: '--limit abc')/음수/0 은 slice(0,NaN)=[] 로 모든 결과를 조용히 숨김 → 기본값으로 폴백.
1210
1212
  function _parseLimit(raw, def) { const n = parseInt(raw, 10); return (Number.isFinite(n) && n > 0) ? n : def; }
1213
+
1214
+ // 1.9.416 (9th 외부평가 Sonnet/Codex, UR-0122): add 류(task/requests/decision) 제목 파싱 단일 출처.
1215
+ // positional 을 join 하되 첫 --flag 또는 경로형 토큰(/x, C:\x, ./x, ../x)에서 멈춤 →
1216
+ // `task add "제목" /some/path` 가 경로를 제목에 흡수하던 오염(decision add 는 이미 차단)을 일관 적용.
1217
+ function _parseAddTitle(args, startIdx = 0) {
1218
+ const parts = [];
1219
+ for (let i = startIdx; i < (args || []).length; i++) {
1220
+ const a = args[i];
1221
+ if (typeof a !== 'string') break;
1222
+ if (a.startsWith('--') || /^([A-Za-z]:[\\/]|\/|\.\.?[\\/])/.test(a)) break;
1223
+ parts.push(a);
1224
+ }
1225
+ return parts.join(' ').trim();
1226
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.414",
3
+ "version": "1.9.416",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -5922,5 +5922,69 @@ total++;
5922
5922
  if (!ok) failed++;
5923
5923
  }
5924
5924
 
5925
+ // 1.9.415 회귀 (9th 외부평가 Codex P1/Opus P2, UR-0121): handoff 보안 헤드라인 정직화 + scan/encoding --json + contract --json exit
5926
+ total++;
5927
+ {
5928
+ let ok = false;
5929
+ try {
5930
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-honesty-'));
5931
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
5932
+ fs.writeFileSync(path.join(d, 'config.js'), 'module.exports={apiKey:"sk-test-1234567890abcdefghijklmnopqrstuvwxyz",githubToken:"ghp_1234567890abcdefghijklmnopqrstuvwxyzABCD"};');
5933
+ fs.writeFileSync(path.join(d, 'bad.txt'), Buffer.from([0xff, 0xfe, 0x20, 0x80]));
5934
+ // (1) handoff 가 시크릿을 헤드라인에 정직 반영(보안 OK 아님)
5935
+ const ho = cp.spawnSync(process.execPath, [CLI, 'handoff', d], { encoding: 'utf8', timeout: 20000 }).stdout || '';
5936
+ const honestSecret = /시크릿\s*\d+건/.test(ho) && !/보안 OK/.test(ho);
5937
+ // (2) scan secrets --json 구조화 + exit 1
5938
+ const sj = cp.spawnSync(process.execPath, [CLI, 'scan', 'secrets', d, '--json'], { encoding: 'utf8', timeout: 15000 });
5939
+ let scanOk = false; try { const j = JSON.parse(sj.stdout); scanOk = j.ok === false && j.count >= 1 && sj.status === 1; } catch {}
5940
+ // (3) encoding check --json 구조화
5941
+ const ej = cp.spawnSync(process.execPath, [CLI, 'encoding', 'check', d, '--json'], { encoding: 'utf8', timeout: 15000 });
5942
+ let encOk = false; try { const j = JSON.parse(ej.stdout); encOk = j.ok === false && j.count >= 1; } catch {}
5943
+ // (4) contract verify --json 불일치 exit 1
5944
+ fs.writeFileSync(path.join(d, 's.md'), '# S\n- loginUser(id)\n- logoutUser(id)\n');
5945
+ fs.writeFileSync(path.join(d, 'i.js'), 'function loginUser(i){return i}\nmodule.exports={loginUser};\n');
5946
+ const cj = cp.spawnSync(process.execPath, [CLI, 'contract', 'verify', path.join(d, 's.md'), path.join(d, 'i.js'), '--json'], { encoding: 'utf8', timeout: 15000 });
5947
+ let contractOk = false; try { const j = JSON.parse(cj.stdout); contractOk = j.ok === false && cj.status === 1; } catch {}
5948
+ // (5) 정직성 회귀: 클린 워크스페이스는 시크릿 경고 없음
5949
+ const dc = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-clean-'));
5950
+ cp.spawnSync(process.execPath, [CLI, 'init', dc, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
5951
+ const hc = cp.spawnSync(process.execPath, [CLI, 'handoff', dc], { encoding: 'utf8', timeout: 20000 }).stdout || '';
5952
+ const cleanOk = !/시크릿\s*\d+건/.test(hc);
5953
+ fs.rmSync(d, { recursive: true, force: true }); fs.rmSync(dc, { recursive: true, force: true });
5954
+ ok = honestSecret && scanOk && encOk && contractOk && cleanOk;
5955
+ } catch {}
5956
+ console.log(ok ? '✓ B(1.9.415) 9th외부평가: handoff 보안 헤드라인 정직화 + scan/encoding --json + contract --json exit (UR-0121)' : '✗ honesty/--json 실패');
5957
+ if (!ok) failed++;
5958
+ }
5959
+
5960
+ // 1.9.416 회귀 (9th 외부평가 Sonnet/Codex, UR-0122): add류 제목 경로흡수 차단 + 빈 입력 거부
5961
+ total++;
5962
+ {
5963
+ let ok = false;
5964
+ try {
5965
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-addtitle-'));
5966
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--language', 'ko'], { encoding: 'utf8', timeout: 30000 });
5967
+ // (1) task add "제목" <경로> → 경로가 title 에 흡수되지 않음
5968
+ cp.spawnSync(process.execPath, [CLI, 'task', 'add', '인증 구현', d, '--path', d], { encoding: 'utf8', timeout: 15000 });
5969
+ const tl = cp.spawnSync(process.execPath, [CLI, 'task', 'list', d, '--path', d], { encoding: 'utf8', timeout: 15000 }).stdout || '';
5970
+ const noPathPollution = /인증 구현/.test(tl) && !new RegExp(d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).test(tl.split('인증 구현')[1] || '');
5971
+ // (2) task add 빈/경로-only 거부 exit 1
5972
+ const empty = cp.spawnSync(process.execPath, [CLI, 'task', 'add', '', '--path', d], { encoding: 'utf8', timeout: 15000 });
5973
+ const pathOnly = cp.spawnSync(process.execPath, [CLI, 'task', 'add', d, '--path', d], { encoding: 'utf8', timeout: 15000 });
5974
+ const rejectOk = empty.status === 1 && pathOnly.status === 1;
5975
+ // (3) --json 빈 거부 구조화
5976
+ const ej = cp.spawnSync(process.execPath, [CLI, 'task', 'add', '', '--path', d, '--json'], { encoding: 'utf8', timeout: 15000 });
5977
+ let jsonOk = false; try { const j = JSON.parse(ej.stdout); jsonOk = j.ok === false && j.code === 'empty_title'; } catch {}
5978
+ // (4) requests add 경로 break
5979
+ cp.spawnSync(process.execPath, [CLI, 'requests', 'add', '다크모드 지원', d, '--path', d], { encoding: 'utf8', timeout: 15000 });
5980
+ const rl = cp.spawnSync(process.execPath, [CLI, 'requests', 'list', '--path', d], { encoding: 'utf8', timeout: 15000 }).stdout || '';
5981
+ const reqClean = /다크모드 지원/.test(rl) && !new RegExp(d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).test(rl.split('다크모드 지원')[1] || '');
5982
+ fs.rmSync(d, { recursive: true, force: true });
5983
+ ok = noPathPollution && rejectOk && jsonOk && reqClean;
5984
+ } catch {}
5985
+ console.log(ok ? '✓ B(1.9.416) 9th외부평가: add류 제목 경로흡수 차단 + 빈 입력 거부 exit1 + --json (UR-0122)' : '✗ add류 제목 일관성 실패');
5986
+ if (!ok) failed++;
5987
+ }
5988
+
5925
5989
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
5926
5990
  if (failed > 0) process.exit(1);