leerness 1.36.77 → 1.36.78

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.78 — 2026-07-28 — codex 10차 홀리스틱 헌트: 오케스트레이션/분석 명령군 7건 수정 (병렬 적대검증 8건 중 7 CONFIRMED·1 REFUTED)
4
+
5
+ 표면 회전으로 team/agents·constraints/intent·contract·milestones 를 헌트. 8건 보고를 8개 독립 에이전트로 병렬 재현(워크플로) → 7 CONFIRMED 수정, 1 REFUTED(pulse).
6
+
7
+ - **F1 team 스토어 형상 무효 → 데이터 손실**: 객체 루트(`{teams:[...]}`)/문자열 members 를 조용히 `[]`로 coerce → list 는 0건(무경고), 이어지는 add 가 원본을 통째로 덮었다. previews/state 규율 적용 — 로더가 invalid 반환, 변경 거부(원본 보존), 유효 항목 members/personas 배열 정규화(string→[] 크래시 제거).
8
+ - **F2 constraints 무효/중복 저장**: `--constraint` 가 `kind:detail` 형식 위반(콜론 없음/빈 kind/빈 detail)이면 조용히 버려지고 exit 0 + 빈 플랫폼을 쓰던 거짓 성공 → 저장 없이 거부. 동일 {kind,detail} 중복 누적 방지(멱등).
9
+ - **F3 손상 constraints 읽기가 기본값을 권위처럼 노출**: list/check 가 손상 store 를 조용히 defaults 로 폴백 → `corruptStore` 표기 + 경고(커스텀 제약 미반영 명시). add 는 이미 fail-closed.
10
+ - **F4 team --json 계약/exit 불일치**: add/remove/show/preview/deploy 의 --json 이 사람용 텍스트를 뱉고, deploy 는 사람모드 exit 1 vs --json exit 0 이던 것 → 전 서브명령 구조화 JSON + exit 일치.
11
+ - **F5 광고된 `agents recommend` 미구현**: review-request 가 안내하는 명령이 핸들러 부재로 usage(exit 1)만 뱉던 것 → 내부 `_recommendAgent` 를 정식 서브명령으로 노출(구조화 JSON).
12
+ - **F6 빈 contract 공허 통과**: 선언 0건 spec 이 항상 `ok:true`(CI 에서 빈/오탈자 spec 통과 위험) → 검증 불가로 거부, 의도적이면 `--allow-empty`.
13
+ - **F8 1태그 ETA 조작**: 태그 1개면 경과 구간이 없어 속도 관측 불가인데 `days=max(1,0)` 로 avg=1 을 지어내 구체 ETA 를 보였다 → 유효 태그 2+ & 양의 구간일 때만 속도/ETA(아니면 avg=0·eta=null). milestones + round-history 양쪽.
14
+ - **F7 REFUTED**: pulse 는 `.json` 만 손상시켜도 MD 프로젝션 폴백이 정확한 카운트를 복구 — 4파일 모두 지워야 0(진짜 빈 상태). 설계가 이미 옳아 무변경.
15
+ - **(R-0001 검수 20회전이 제 수정의 불완전성 3건 차단)**: ① team add 락 획득 후 재로드가 여전히 무효→[] coerce 라 대기 중 스토어가 무효로 바뀌면 덮어썼다 → 락내 재로드도 checked. ② deploy execute --json 이 gate JSON 후 사람 로그+inherited child stdout 을 뱉어 exact-once JSON 위반 → 자식 출력 캡처 후 단일 JSON. ③ `typeof []==='object'` 라 배열형 platforms(`{"platforms":[]}`)가 손상으로 안 잡혀 add 가 덮어썼다 → 배열 명시 배제 + add 도 checked 로더 fail-closed.
16
+ - 검증: 7건 재현→수정→재확인 실측(F8 은 실 git repo) + 검수 3건 재확인, selftest 334, e2e +1(15 단언).
17
+
3
18
  ## 1.36.77 — 2026-07-25 — UR-0061/0066 MCP 완결: leerness_clarify + leerness_preview (86→88 도구)
4
19
 
5
20
  clarify/preview 워크플로가 CLI 전용이라 MCP 전용 에이전트(Claude Desktop 등)는 질문·시안 승인 흐름을 쓸 수 없던 갭.
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.77 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
125
+ 이 프로젝트는 Leerness v1.36.78 하네스를 사용합니다. 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.77는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **88개 도구**를 노출:
179
+ Leerness v1.36.78는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **88개 도구**를 노출:
180
180
 
181
181
  ```jsonc
182
182
  // 카테고리별
@@ -197,7 +197,7 @@ Leerness v1.36.77는 stdio JSON-RPC MCP server를 내장합니다 — Claude Cod
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.77 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
200
+ 현재 누적: **v1.9.x → 1.36.78 릴리스 태그 이력** (수백 라운드) · _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.77: 2026-07-26
238
+ Last synced by Leerness v1.36.78: 2026-07-28
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.77';
37
+ const VERSION = '1.36.78';
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') 시 호스트 프로세스 오염.
@@ -2400,12 +2400,17 @@ function _computeRoundHistory(root) {
2400
2400
  result.nextMilestone = next;
2401
2401
  result.roundsToNextMilestone = next - result.roundCount;
2402
2402
  }
2403
- // 일일 평균
2403
+ // 일일 평균 — 1.36.78 (F8): 태그 2+ & 양의 구간일 때만(1개면 속도 관측 불가 → 0 유지)
2404
2404
  const first = new Date(result.firstTagAt).getTime();
2405
2405
  const last = new Date(result.latestTagAt).getTime();
2406
- const days = Math.max(1, Math.floor((last - first) / 86400000));
2407
- result.daysActive = days;
2408
- result.avgRoundsPerDay = Math.round((result.roundCount / days) * 100) / 100;
2406
+ const spanDays = (last - first) / 86400000;
2407
+ if (tags.length >= 2 && spanDays > 0) {
2408
+ result.daysActive = Math.max(1, Math.floor(spanDays));
2409
+ result.avgRoundsPerDay = Math.round((result.roundCount / spanDays) * 100) / 100;
2410
+ } else {
2411
+ result.daysActive = 0;
2412
+ result.avgRoundsPerDay = 0;
2413
+ }
2409
2414
  } catch {}
2410
2415
  return result;
2411
2416
  }
@@ -2451,16 +2456,23 @@ function _computeMilestones(root) {
2451
2456
  }
2452
2457
  }
2453
2458
  // 다음 마일스톤 + 평균 속도로 ETA 계산
2459
+ // 1.36.78 (10차 헌트 F8): 태그 1개면 경과 구간이 없어 속도를 관측할 수 없다 — 종전엔 days=max(1,0)=1 로
2460
+ // avgPerDay=1 을 지어내 구체적 ETA 를 보였다. 유효 태그 2+ 이고 양의 구간일 때만 속도/ETA 산출(아니면 null).
2454
2461
  const lastTime = new Date(tags[tags.length - 1].date).getTime();
2455
- const days = Math.max(1, Math.floor((lastTime - baselineTime) / 86400000));
2456
- const avgPerDay = (tags.length / days);
2457
- result.avgRoundsPerDay = Math.round(avgPerDay * 100) / 100;
2462
+ const spanDays = (lastTime - baselineTime) / 86400000;
2458
2463
  const nextM = milestones.find(m => m > tags.length);
2459
- if (nextM != null) {
2460
- const roundsRemaining = nextM - tags.length;
2461
- const etaDays = avgPerDay > 0 ? Math.ceil(roundsRemaining / avgPerDay) : null;
2462
- const etaDate = etaDays != null ? new Date(Date.now() + etaDays * 86400000).toISOString().split('T')[0] : null;
2463
- result.next = { milestone: nextM, roundsRemaining, etaDays, etaDate };
2464
+ if (tags.length >= 2 && spanDays > 0) {
2465
+ const avgPerDay = tags.length / spanDays;
2466
+ result.avgRoundsPerDay = Math.round(avgPerDay * 100) / 100;
2467
+ if (nextM != null) {
2468
+ const roundsRemaining = nextM - tags.length;
2469
+ const etaDays = Math.ceil(roundsRemaining / avgPerDay);
2470
+ const etaDate = new Date(Date.now() + etaDays * 86400000).toISOString().split('T')[0];
2471
+ result.next = { milestone: nextM, roundsRemaining, etaDays, etaDate };
2472
+ }
2473
+ } else if (nextM != null) {
2474
+ // 속도 관측 불가 — 다음 마일스톤은 표시하되 ETA 는 null(추정 없음)
2475
+ result.next = { milestone: nextM, roundsRemaining: nextM - tags.length, etaDays: null, etaDate: null };
2464
2476
  }
2465
2477
  } catch {}
2466
2478
  return result;
@@ -3375,10 +3387,10 @@ function _selfTestCases() {
3375
3387
  const stateLock = s.includes("const _mut = ['start', 'record', 'verify', 'handoff'].includes(sub || '')")
3376
3388
  && s.includes("_withLock(path.join(_leernessStateDir(root), 'state.json'), () => stateCmd(root, sub, ...args))")
3377
3389
  && s.includes("!_heldLocks.has(path.join(_leernessStateDir(root), 'state.json') + '.lock')");
3378
- // #2: team add 가 락 안 재로드→중복검사→저장 + EPERM 재시도
3390
+ // #2: team add 가 락 안 재로드→중복검사→저장 + EPERM 재시도. 1.36.78: 락내 재로드가 checked 로더로(형상무효 레이스 거부).
3379
3391
  const teamSrc = read(path.join(path.dirname(__filename), '..', 'lib', 'team.js'));
3380
- const teamLock = teamSrc.includes('const cur = _loadTeams(root);') && teamSrc.includes("deps._withLock(require('path').join(root, '.harness', 'teams.json'), _doAdd)") && teamSrc.includes("e.code === 'EPERM'");
3381
- const teamWired = s.includes('_detectShellCtx, arg, has, _withLock }');
3392
+ const teamLock = teamSrc.includes('_loadTeamsChecked(root)') && teamSrc.includes("deps._withLock(require('path').join(root, '.harness', 'teams.json'), _doAdd)") && teamSrc.includes("e.code === 'EPERM'") && teamSrc.includes('_rechk.invalid');
3393
+ const teamWired = s.includes('_loadTeamsChecked, _saveTeams, _detectShellCtx, arg, has, _withLock, failJson }');
3382
3394
  // #7: pre-wake 매처가 테이블형+YAML형 모두 (행위)
3383
3395
  const both = ('| T-0001 | in-progress | x |\nstatus: in-progress\n'.match(/^\|\s*T-\d{4,}\s*\|\s*in-progress\s*\|/gm) || []).length === 1;
3384
3396
  const pwWired = /_runPreWakeAudit[\s\S]{0,2200}\^\\\|\\s\*T-\\d\{4,\}\\s\*\\\|\\s\*in-progress/.test(s);
@@ -6735,7 +6747,7 @@ function commandsCmd(root) {
6735
6747
  { cmd: 'session close [path] [--json] [--auto-apply-delivered]', desc: '세션 마감 + 9 카테고리 + 자동 통합' },
6736
6748
  { cmd: 'resume [path]', desc: 'auto-resume-plan 적용 (1.9.203)' },
6737
6749
  { cmd: 'route <task-type>', desc: '작업 유형 분류 (11종)' },
6738
- { cmd: 'agents list|check|quota|dispatch|multi', desc: '외부 AI CLI 오케스트레이션 (dispatch --role 모델 라우팅 1.9.270)' },
6750
+ { cmd: 'agents list|check|quota|dispatch|multi|recommend', desc: '외부 AI CLI 오케스트레이션 (dispatch --role 모델 라우팅 1.9.270)' },
6739
6751
  { cmd: 'roles list|set|unset|catalog|suggest|verify', desc: '모델별 역할 부여 (코딩/검수/지휘/디자인/디버그/설계/분배) — 1.9.270' },
6740
6752
  { cmd: 'capabilities [--json]', desc: '권한·보안 표면 공개 (무엇을 하는지 + opt-out + 주의 명령) — 1.9.272' },
6741
6753
  { cmd: 'state show|start|record|verify|handoff', desc: '.leerness/ JSON 상태 substrate (에이전트 간 인수인계 표준) — 1.9.278' },
@@ -6870,19 +6882,22 @@ function roundHistoryCmd(root) {
6870
6882
  // 기본 catalog 6종: Stripe / OpenAI / Anthropic / GitHub / Discord / Twitter
6871
6883
  function _platformConstraintsPath(root) { return path.join(root, '.harness', 'platform-constraints.json'); }
6872
6884
  // 1.9.333 (UR-0025 심층): _DEFAULT_PLATFORM_CONSTRAINTS → lib/catalogs.js 로 이동 (순수 데이터, require 사용).
6885
+ // 1.36.78 (10차 헌트 F3): 손상 store 를 조용히 defaults 로 폴백해 "권위 있는 정상 목록"처럼 보이던 것 —
6886
+ // 사용자 커스텀 제약이 사라졌는데도 알 수 없었다. 손상 여부를 함께 반환해 list/check 가 표면화(read 는 폴백 유지 + 경고).
6887
+ function _loadPlatformConstraintsChecked(root) {
6888
+ const fp = _platformConstraintsPath(root);
6889
+ if (!exists(fp)) return { catalog: _DEFAULT_PLATFORM_CONSTRAINTS, corrupt: false };
6890
+ let j;
6891
+ try { j = JSON.parse(read(fp)); } catch { return { catalog: _DEFAULT_PLATFORM_CONSTRAINTS, corrupt: true }; }
6892
+ // 1.36.78 (검수 #3): typeof []==='object' 라 배열 루트/배열 platforms 가 통과하던 것 — 명시 배제(비객체 취급).
6893
+ if (!j || typeof j !== 'object' || Array.isArray(j) || (j.platforms != null && (typeof j.platforms !== 'object' || Array.isArray(j.platforms)))) return { catalog: _DEFAULT_PLATFORM_CONSTRAINTS, corrupt: true };
6894
+ const merged = { ...JSON.parse(JSON.stringify(_DEFAULT_PLATFORM_CONSTRAINTS)) };
6895
+ if (j.platforms) for (const k of Object.keys(j.platforms)) merged.platforms[k] = j.platforms[k];
6896
+ return { catalog: merged, corrupt: false };
6897
+ }
6873
6898
  function _loadPlatformConstraints(root) {
6874
6899
  try {
6875
- const fp = _platformConstraintsPath(root);
6876
- if (!exists(fp)) return _DEFAULT_PLATFORM_CONSTRAINTS;
6877
- const j = JSON.parse(read(fp));
6878
- // user catalog 와 default merge — user override 우선
6879
- const merged = { ...JSON.parse(JSON.stringify(_DEFAULT_PLATFORM_CONSTRAINTS)) };
6880
- if (j.platforms) {
6881
- for (const k of Object.keys(j.platforms)) {
6882
- merged.platforms[k] = j.platforms[k];
6883
- }
6884
- }
6885
- return merged;
6900
+ return _loadPlatformConstraintsChecked(root).catalog;
6886
6901
  } catch { return _DEFAULT_PLATFORM_CONSTRAINTS; }
6887
6902
  }
6888
6903
  function _writePlatformConstraints(root, catalog) {
@@ -7882,8 +7897,10 @@ function constraintsCmd(root, sub, ...rest) {
7882
7897
  }
7883
7898
 
7884
7899
  if (sub === 'list') {
7885
- const catalog = _loadPlatformConstraints(root);
7886
- if (has('--json')) { log(JSON.stringify(catalog, null, 2)); return; }
7900
+ const _chk = _loadPlatformConstraintsChecked(root); // 1.36.78 (F3): 손상 표면화
7901
+ const catalog = _chk.catalog;
7902
+ if (has('--json')) { log(JSON.stringify({ ...catalog, ...(_chk.corrupt ? { corruptStore: true, note: 'platform-constraints.json 손상 — 기본값만 표시(커스텀 제약 미반영)' } : {}) }, null, 2)); return; }
7903
+ if (_chk.corrupt) log(yel(_t(' ⚠ platform-constraints.json 손상 — 기본값만 표시(커스텀 제약 미반영). 복구 후 재시도', ' ⚠ platform-constraints.json corrupt — showing defaults only (custom constraints omitted). Repair and retry')));
7887
7904
  log(cyan(`# leerness constraints list (1.9.208)`));
7888
7905
  log(` total platforms: ${Object.keys(catalog.platforms).length}`);
7889
7906
  log('');
@@ -7904,8 +7921,10 @@ function constraintsCmd(root, sub, ...rest) {
7904
7921
  if (sub === 'check') {
7905
7922
  const text = rest.filter(x => !x.startsWith('-')).join(' ');
7906
7923
  if (!text) return failJson(has('--json'), 'missing_query', 'Usage: leerness constraints check "<request text>"'); // 1.32.1 (15th리뷰 C2): --json 구조화
7924
+ const _corrupt = _loadPlatformConstraintsChecked(root).corrupt; // 1.36.78 (F3): 손상 표면화
7907
7925
  const result = _checkRequestConstraints(root, text, _L);
7908
- if (has('--json')) { log(JSON.stringify({ query: text, ...result }, null, 2)); return; }
7926
+ if (has('--json')) { log(JSON.stringify({ query: text, ...result, ...(_corrupt ? { corruptStore: true } : {}) }, null, 2)); return; }
7927
+ if (_corrupt) log(yel(_t(' ⚠ platform-constraints.json 손상 — 커스텀 제약 미반영(기본값만 대조)', ' ⚠ platform-constraints.json corrupt — custom constraints omitted (defaults only)')));
7909
7928
  log(cyan(`# leerness constraints check (1.9.208)`));
7910
7929
  log(` query: ${text.slice(0, 80)}${text.length > 80 ? '…' : ''}`);
7911
7930
  log('');
@@ -7936,15 +7955,24 @@ function constraintsCmd(root, sub, ...rest) {
7936
7955
  if (sub === 'add') {
7937
7956
  const id = rest[0];
7938
7957
  if (!id) return failJson(has('--json'), 'missing_platform_id', 'Usage: leerness constraints add <platform-id> --constraint "kind:detail"'); // 1.32.1 (15th리뷰 C2)
7939
- const catalog = _loadPlatformConstraints(root);
7958
+ // 1.36.78 (검수 #3): add 도 checked 로더로 — 손상/배열형 store 위 변경 거부(기존 커스텀·metadata 보존).
7959
+ const _cchk = _loadPlatformConstraintsChecked(root);
7960
+ if (_cchk.corrupt) return failJson(has('--json'), 'store_corrupt', `platform-constraints.json 손상/형상 무효 — 덮어쓰기 거부: ${_platformConstraintsPath(root)} (복구 후 재시도)`);
7961
+ const catalog = _cchk.catalog;
7940
7962
  const aliases = (arg('--alias', '') || '').split(',').map(s => s.trim()).filter(Boolean);
7941
7963
  const detailRaw = arg('--constraint', '');
7942
- if (!detailRaw) return failJson(has('--json'), 'missing_constraint', '--constraint "kind:detail" required'); // 1.32.1 (15th리뷰 C2)
7943
- const [kind, ...rest2] = detailRaw.split(':');
7964
+ if (!detailRaw || detailRaw === true) return failJson(has('--json'), 'missing_constraint', '--constraint "kind:detail" required'); // 1.32.1 (15th리뷰 C2)
7965
+ const [kind, ...rest2] = String(detailRaw).split(':');
7966
+ const kindTrim = (kind || '').trim();
7944
7967
  const detail = rest2.join(':').trim();
7968
+ // 1.36.78 (10차 헌트 F2): 무효 kind:detail(콜론 없음/빈 kind/빈 detail)은 조용히 버려지고 exit 0 + 빈-constraints 플랫폼을
7969
+ // 쓰던 것(거짓 성공). 형식 위반은 저장 없이 거부. 플랫폼 생성도 검증 통과 후로 이동.
7970
+ if (!kindTrim || !detail) return failJson(has('--json'), 'invalid_constraint', '--constraint 는 "kind:detail" 형식(빈 kind/detail 불가) — 예: "auth:token required"');
7945
7971
  if (!catalog.platforms[id]) catalog.platforms[id] = { aliases: [], constraints: [] };
7946
7972
  if (aliases.length) catalog.platforms[id].aliases = [...new Set([...(catalog.platforms[id].aliases || []), ...aliases])];
7947
- if (kind && detail) catalog.platforms[id].constraints.push({ kind: kind.trim(), detail });
7973
+ // 1.36.78 (F2): 동일 {kind,detail} 중복 누적 방지(멱등)
7974
+ const _cons = catalog.platforms[id].constraints;
7975
+ if (!_cons.some(c => c.kind === kindTrim && c.detail === detail)) _cons.push({ kind: kindTrim, detail });
7948
7976
  _writePlatformConstraints(root, catalog);
7949
7977
  if (has('--json')) { log(JSON.stringify(catalog.platforms[id], null, 2)); return; }
7950
7978
  log(grn(_t(`✓ platform "${id}" 갱신 — constraints: ${catalog.platforms[id].constraints.length}`, `✓ platform "${id}" updated — constraints: ${catalog.platforms[id].constraints.length}`)));
@@ -9126,11 +9154,20 @@ function _saveDecisions(root, decisions) {
9126
9154
  // 1.9.371 (UR-0073 Phase A): agent team 정의 레지스트리 — canonical JSON(teams.json) 주 + teams.md projection. opt-in · 정의 전용(자동 실행 X).
9127
9155
  const teamsJsonPath = root => path.join(root, '.harness/teams.json');
9128
9156
  const teamsPath = root => path.join(root, '.harness/teams.md');
9129
- function _loadTeams(root) {
9157
+ // 1.36.78 (10차 헌트 F1): teams.json 이 "파싱은 되지만 형상 무효"(객체 루트 {teams:[...]}/항목 비객체/id 없음)일 때
9158
+ // 종전엔 조용히 []로 coerce → list 는 count:0(무경고), 이어지는 add 가 배열을 써서 원본을 통째로 덮었다(데이터 손실).
9159
+ // previews/state 와 동일 규율: 로더가 invalid 를 함께 반환하고 변경 진입점은 거부. 유효 항목은 members/personas 를 배열로 정규화(string→[]).
9160
+ function _loadTeamsChecked(root) {
9130
9161
  const jp = teamsJsonPath(root);
9131
- if (exists(jp)) { try { const arr = JSON.parse(read(jp)); if (Array.isArray(arr)) return arr; } catch {} }
9132
- return [];
9133
- }
9162
+ if (!exists(jp)) return { teams: [], invalid: false };
9163
+ let parsed;
9164
+ try { parsed = JSON.parse(read(jp)); } catch { return { teams: [], invalid: true }; }
9165
+ if (!Array.isArray(parsed) || parsed.some(e => !e || typeof e !== 'object' || typeof e.id !== 'string')) return { teams: [], invalid: true };
9166
+ const _arr = a => Array.isArray(a) ? a : (a == null ? [] : [String(a)]); // 무효 members(string) → 크래시 대신 배열 정규화
9167
+ const teams = parsed.map(t => Object.assign({}, t, { members: _arr(t.members), personas: _arr(t.personas) }));
9168
+ return { teams, invalid: false };
9169
+ }
9170
+ function _loadTeams(root) { return _loadTeamsChecked(root).teams; }
9134
9171
  function _saveTeams(root, teams) {
9135
9172
  const arr = Array.isArray(teams) ? teams : [];
9136
9173
  _assertStoreParsable(teamsJsonPath(root), 'team'); // 1.36.28 (#3): 손상 teams.json 을 덮어써 기존 팀 유실 방지
@@ -9144,7 +9181,7 @@ const _team = require('../lib/team');
9144
9181
  const _tgl = require('../lib/toggles'); // 1.36.30: 기능 토글 (그래프 ⚙ 탭 연동 — gate/lens/auto-graph/delegation-brief)
9145
9182
  const _clar = require('../lib/clarify'); // 1.36.51 (UR-0061): 모호성 질문 + 미리보기 승인 워크플로
9146
9183
  const _tech = require('../lib/tech-profile'); // 1.36.53 (UR-0062): 기술 프로필 (언어·서비스 감지 + 변경 이력)
9147
- function teamCmd(root, sub, id, opts = {}) { return _team.teamCmd(root, sub, id, opts, { VERSION, _loadTeams, _saveTeams, _detectShellCtx, arg, has, _withLock }); } // 1.36.31: add 경합 락
9184
+ function teamCmd(root, sub, id, opts = {}) { return _team.teamCmd(root, sub, id, opts, { VERSION, _loadTeams, _loadTeamsChecked, _saveTeams, _detectShellCtx, arg, has, _withLock, failJson }); } // 1.36.31: add 경합 락 · 1.36.78: 형상무효 가드 + failJson
9148
9185
 
9149
9186
  // 1.9.112: 전용 lessons.md (Memory Write Surface 5번째)
9150
9187
  const lessonsPath = root => path.join(root, '.harness/lessons.md');
@@ -22363,6 +22400,12 @@ function contractVerifyCmd(specPath, implPath) {
22363
22400
  const _fe = f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
22364
22401
  if (!new RegExp(`(?<![\\w$])${_fe}(?![\\w$])`).test(implSrc)) fieldMissing.push(f);
22365
22402
  }
22403
+ // 1.36.78 (10차 헌트 F6): 선언(강함수+필드)이 0건인 spec 은 검사할 게 없어 항상 ok:true 로 "공허 통과"했다
22404
+ // (CI 에서 빈/오탈자 spec 이 계약을 통과시키는 위험). 검증 불가로 취급 — 명시적 --allow-empty 로만 허용.
22405
+ if (declaredSpec.size === 0 && fieldSpec.size === 0 && !has('--allow-empty')) {
22406
+ failJson(_j, 'empty_contract', 'spec 에 검증할 함수/필드 선언이 없습니다 — 공허 통과 방지(의도된 빈 계약이면 --allow-empty). "- name(args)" 또는 "## Fields\\n- fieldName" 으로 선언하세요');
22407
+ return process.exit(process.exitCode || 1);
22408
+ }
22366
22409
  // 출력
22367
22410
  if (has('--json')) {
22368
22411
  const okJson = missing.length === 0 && fieldMissing.length === 0;
@@ -22973,7 +23016,7 @@ STATUS & DIAGNOSTICS
22973
23016
  VERIFICATION (evidence-gated "done")
22974
23017
  verify-claim <T-ID> [--run-tests] [--test-cmd "..."] [--json]
22975
23018
  Check evidence vs reality (stub / fake-test / inflated-count detection)
22976
- contract verify <spec.md> <impl.js> [--json] Spec <-> implementation match
23019
+ contract verify <spec.md> <impl.js> [--json] [--allow-empty] Spec <-> implementation match (empty spec rejected unless --allow-empty)
22977
23020
  verify-code [path] [--build] [--bench] Run tests/lint/typecheck, record evidence
22978
23021
  gate [path] One-call CI gate: verify + audit + scan + encoding + lazy
22979
23022
  lens [code|design|docs|test|security|database|debug] [--json] Quality self-question lenses
package/lib/agents.js CHANGED
@@ -59,6 +59,19 @@ function agentsCmd(root, sub, args = [], deps = {}) {
59
59
  return;
60
60
  }
61
61
 
62
+ // 1.36.78 (10차 헌트 F5): review-request 가 광고하는 `agents recommend <task>` 가 핸들러 부재로 usage(exit 1)만 뱉던 것.
63
+ // 내부 _recommendAgent 로직을 정식 서브명령으로 노출 — 작업 텍스트/유형 → 적합 CLI + 이유.
64
+ if (sub === 'recommend') {
65
+ const task = _taskArg();
66
+ if (!task) { failJson(has('--json'), 'missing_task', 'Usage: leerness agents recommend "<작업 설명 또는 유형>"'); return; }
67
+ const rec = _recommendAgent(task) || { target: null, reason: '' };
68
+ if (has('--json')) { log(JSON.stringify({ ok: true, task, recommended: rec.target, reason: rec.reason }, null, 2)); return; }
69
+ log(`# leerness agents recommend (1.36.78)`);
70
+ log(` 작업: ${task.slice(0, 80)}${task.length > 80 ? '…' : ''}`);
71
+ if (rec.target) { log(` 💡 추천: ${rec.target}${rec.reason ? ` — ${rec.reason}` : ''}`); }
72
+ else { log(` (특정 추천 없음 — 기본 provider 사용. leerness agents list 로 활성 확인)`); }
73
+ return;
74
+ }
62
75
  if (sub === 'check') {
63
76
  // list의 alias, 단 명시적 재확인 (JSON 출력 기본)
64
77
  // 1.9.157: Provider Registry 통합
@@ -467,7 +480,7 @@ function agentsCmd(root, sub, args = [], deps = {}) {
467
480
  return;
468
481
  }
469
482
 
470
- fail('사용법: leerness agents list|check|quota|dispatch|bench [--write] "<task>" [--to <id>]');
483
+ fail('사용법: leerness agents list|check|quota|dispatch|multi|bench|recommend [--write] "<task>" [--to <id>]');
471
484
  return process.exit(1);
472
485
  }
473
486
 
package/lib/team.js CHANGED
@@ -10,11 +10,16 @@ const { _composeTeamPlan, _teamDeployGate } = require('./pure-utils');
10
10
  const { _shellGuardAnalyze } = require('./analyzers');
11
11
 
12
12
  function teamCmd(root, sub, id, opts = {}, deps = {}) {
13
- const { VERSION, _loadTeams, _saveTeams, _detectShellCtx, arg, has } = deps;
13
+ const { VERSION, _loadTeams, _loadTeamsChecked, _saveTeams, _detectShellCtx, arg, has, failJson } = deps;
14
14
  root = absRoot(root);
15
15
  const json = opts.json || has('--json');
16
- const teams = _loadTeams(root);
17
16
  sub = sub || 'list';
17
+ // 1.36.78 (10차 헌트 F1): 형상 무효 스토어 위 변경 거부 — 읽기는 관용(빈 목록). failJson 이 없으면(구 DI) 기존 경로 유지.
18
+ const _chk = (typeof _loadTeamsChecked === 'function') ? _loadTeamsChecked(root) : { teams: _loadTeams(root), invalid: false };
19
+ const teams = _chk.teams;
20
+ const _fj = failJson || ((j, code, msg) => { if (j) log(JSON.stringify({ ok: false, code, error: msg }, null, 2)); else fail(msg); });
21
+ const _mutating = ['add', 'remove', 'deploy'].includes(sub);
22
+ if (_mutating && _chk.invalid) { _fj(json, 'store_invalid', `teams.json 형상 무효(비배열 루트 또는 null/ID 없는 항목) — 덮어쓰기 거부: ${require('path').join(root, '.harness', 'teams.json')} (수동 복구 후 재시도)`); return; }
18
23
  if (sub === 'list') {
19
24
  if (json) { log(JSON.stringify({ version: VERSION, root, count: teams.length, teams }, null, 2)); return; }
20
25
  log(`# leerness team (1.9.371, UR-0073 Phase A) — 에이전트 팀 정의 (opt-in · 정의 전용)`);
@@ -25,8 +30,8 @@ function teamCmd(root, sub, id, opts = {}, deps = {}) {
25
30
  }
26
31
  if (sub === 'add') {
27
32
  const teamId = String(id || '').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[.\-]+|[.\-]+$/g, '');
28
- if (!teamId || teamId.includes('..')) { fail(`무효한 team id: "${id}" (영숫자/._- 만)`); return; }
29
- if (teams.some(t => t.id === teamId)) { warn(`이미 존재: ${teamId} (제거 후 재정의: leerness team remove ${teamId})`); return; }
33
+ if (!teamId || teamId.includes('..')) { _fj(json, 'invalid_id', `무효한 team id: "${id}" (영숫자/._- 만)`); return; } // 1.36.78 (F4): --json 구조화
34
+ if (teams.some(t => t.id === teamId)) { _fj(json, 'already_exists', `이미 존재: ${teamId} (제거 후 재정의: leerness team remove ${teamId})`); return; }
30
35
  const splitCsv = v => (v && v !== true) ? String(v).split(',').map(s => s.trim()).filter(Boolean) : [];
31
36
  const sched = arg('--schedule', 'manual');
32
37
  const validSched = new Set(['manual', 'every-session', 'daily', 'weekly']);
@@ -44,8 +49,11 @@ function teamCmd(root, sub, id, opts = {}, deps = {}) {
44
49
  };
45
50
  // 1.36.31 (codex 미검토표면 #2): 병렬 team add 가 서로의 등록을 덮어쓰던 lost update — 락 안에서 재로드→중복검사→추가→저장.
46
51
  const _doAdd = () => {
47
- const cur = _loadTeams(root);
48
- if (cur.some(t => t.id === teamId)) { warn(`이미 존재: ${teamId} (제거 후 재정의: leerness team remove ${teamId})`); return false; }
52
+ // 1.36.78 (검수 #1): 락 획득 후 재로드도 checked 로 — 대기 중 스토어가 형상 무효로 바뀌면 덮어쓰기 거부(레이스 데이터 손실 차단).
53
+ const _rechk = (typeof _loadTeamsChecked === 'function') ? _loadTeamsChecked(root) : { teams: _loadTeams(root), invalid: false };
54
+ if (_rechk.invalid) { _fj(json, 'store_invalid', `teams.json 형상 무효 — 덮어쓰기 거부(락 재확인)`); return false; }
55
+ const cur = _rechk.teams;
56
+ if (cur.some(t => t.id === teamId)) { _fj(json, 'already_exists', `이미 존재: ${teamId} (제거 후 재정의: leerness team remove ${teamId})`); return false; }
49
57
  cur.push(team);
50
58
  // Windows: 락 밖의 병렬 _loadTeams 읽기가 파일을 잠깐 열어둔 사이 원자 rename 이 EPERM(일시적) — 짧은 재시도.
51
59
  let _lastErr = null;
@@ -60,13 +68,14 @@ function teamCmd(root, sub, id, opts = {}, deps = {}) {
60
68
  ? deps._withLock(require('path').join(root, '.harness', 'teams.json'), _doAdd)
61
69
  : _doAdd();
62
70
  if (!_added) return;
71
+ if (json) { log(JSON.stringify({ ok: true, id: teamId, personas: team.personas.length, members: team.members.length, schedule: team.schedule, review: team.review }, null, 2)); return; } // 1.36.78 (F4)
63
72
  ok(`team 정의: ${teamId} (personas:${team.personas.length} members:${team.members.length} schedule:${team.schedule} review:${team.review ? 'on' : 'off'})`);
64
73
  log(` ⓘ 정의 전용 — 자동 실행 없음. 목록: leerness team list`);
65
74
  return;
66
75
  }
67
76
  if (sub === 'show') {
68
77
  const t = teams.find(x => x.id === id);
69
- if (!t) { fail(`team 없음: ${id}`); return; }
78
+ if (!t) { _fj(json, 'not_found', `team 없음: ${id}`); return; } // 1.36.78 (F4): --json 구조화
70
79
  if (json) { log(JSON.stringify(t, null, 2)); return; }
71
80
  log(`# team ${t.id}`);
72
81
  log(` name: ${t.name || ''}`);
@@ -81,15 +90,16 @@ function teamCmd(root, sub, id, opts = {}, deps = {}) {
81
90
  if (sub === 'remove') {
82
91
  const before = teams.length;
83
92
  const next = teams.filter(x => x.id !== id);
84
- if (next.length === before) { warn(`team 없음: ${id}`); return; }
93
+ if (next.length === before) { _fj(json, 'not_found', `team 없음: ${id}`); return; } // 1.36.78 (F4): --json 구조화 + exit 1
85
94
  _saveTeams(root, next);
95
+ if (json) { log(JSON.stringify({ ok: true, removed: id, count: next.length }, null, 2)); return; }
86
96
  ok(`team 제거: ${id}`);
87
97
  return;
88
98
  }
89
99
  // 1.9.372 (UR-0073 Phase B): team preview — dry-run 실행 계획 미리보기 (실제 dispatch/spawn/배포 없음).
90
100
  if (sub === 'preview') {
91
101
  const t = teams.find(x => x.id === id);
92
- if (!t) { fail(`team 없음: ${id}`); return; }
102
+ if (!t) { _fj(json, 'not_found', `team 없음: ${id}`); return; } // 1.36.78 (F4): --json 구조화
93
103
  const plan = _composeTeamPlan(t, arg('--task', null));
94
104
  if (json) { log(JSON.stringify({ version: VERSION, dryRun: true, ...plan }, null, 2)); return; }
95
105
  log(`# team preview ${t.id} (1.9.372, UR-0073 Phase B) — dry-run (실제 실행 없음)`);
@@ -113,9 +123,19 @@ function teamCmd(root, sub, id, opts = {}, deps = {}) {
113
123
  // 1.9.376 (UR-0073 Phase D): team deploy — 사용자 설정 deployCommand 실행. 안전: dry-run 기본 + --yes + LEERNESS_TEAM_DEPLOY=1 이중 게이트 + shell-guard.
114
124
  if (sub === 'deploy') {
115
125
  const t = teams.find(x => x.id === id);
116
- if (!t) { fail(`team 없음: ${id}`); return; }
126
+ if (!t) { _fj(json, 'not_found', `team 없음: ${id}`); return; }
117
127
  const gate = _teamDeployGate(t, { yes: has('--yes'), envOn: process.env.LEERNESS_TEAM_DEPLOY === '1' });
118
- if (json) { log(JSON.stringify({ version: VERSION, teamId: t.id, ...gate }, null, 2)); if (gate.mode !== 'execute') return; }
128
+ // 1.36.78 (F4): --json 모드도 사람 모드와 exit 일치 no-command/gated 실패(exit 1), dry-run 은 정상(exit 0).
129
+ if (gate.mode === 'no-command' || gate.mode === 'gated') process.exitCode = 1;
130
+ // 1.36.78 (검수 #2): execute 는 exact-once JSON 위반(gate JSON 후 사람 로그 + inherited child stdout) → --json execute 전용 분기.
131
+ // 자식 출력을 캡처하고 실행 후 단일 JSON 하나만 방출한다.
132
+ if (json && gate.mode === 'execute') {
133
+ const r = cp.spawnSync(gate.command, { cwd: root, shell: true, encoding: 'utf8', timeout: 600000 });
134
+ if (r.status !== 0) process.exitCode = 1;
135
+ log(JSON.stringify({ version: VERSION, teamId: t.id, ...gate, executed: true, exitStatus: r.status, stdout: (r.stdout || '').slice(0, 4000), stderr: (r.stderr || '').slice(0, 4000) }, null, 2));
136
+ return;
137
+ }
138
+ if (json) { log(JSON.stringify({ version: VERSION, teamId: t.id, ...gate }, null, 2)); return; } // non-execute --json: 단일 JSON
119
139
  if (gate.mode === 'no-command') { fail(`team '${t.id}' deployCommand 미설정 — leerness team add ${t.id} --deploy "<배포 명령>"`); return; }
120
140
  if (gate.mode === 'dry-run') {
121
141
  log(`# team deploy ${t.id} (1.9.376, UR-0073 Phase D) — dry-run (실행 없음)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.77",
3
+ "version": "1.36.78",
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
@@ -6103,11 +6103,15 @@ total++;
6103
6103
  // (4) codex #9: 코드펜스 예제 함수는 계약 아님 → 통과(exit 0) + specFunctions 에 helper 없음
6104
6104
  fs.writeFileSync(path.join(d, 'fen.md'), '# S\n```js\nfunction helper(){}\n```\n');
6105
6105
  fs.writeFileSync(path.join(d, 'fen.js'), 'module.exports={};\n');
6106
- const cfen = cp.spawnSync(process.execPath, [CLI, 'contract', 'verify', path.join(d, 'fen.md'), path.join(d, 'fen.js'), '--json'], { encoding: 'utf8', timeout: 15000 });
6106
+ // 1.36.78 (10차 F6): 펜스-only spec 선언 0건이라 이제 empty_contract 거부됨 테스트 의도는 "helper 가 specFunctions 에 없음"(펜스 제외)이므로 --allow-empty 로 유지.
6107
+ const cfen = cp.spawnSync(process.execPath, [CLI, 'contract', 'verify', path.join(d, 'fen.md'), path.join(d, 'fen.js'), '--json', '--allow-empty'], { encoding: 'utf8', timeout: 15000 });
6107
6108
  let fenceOk = false; try { const j = JSON.parse(cfen.stdout); fenceOk = cfen.status === 0 && j.ok === true && !(j.specFunctions || []).includes('helper'); } catch {}
6109
+ // 1.36.78 (10차 F6): 펜스-only(--allow-empty 없음)는 empty_contract 거부(exit 1)
6110
+ const cfenReject = cp.spawnSync(process.execPath, [CLI, 'contract', 'verify', path.join(d, 'fen.md'), path.join(d, 'fen.js'), '--json'], { encoding: 'utf8', timeout: 15000 });
6111
+ let fenceRejectOk = false; try { fenceRejectOk = cfenReject.status === 1 && JSON.parse(cfenReject.stdout).code === 'empty_contract'; } catch {}
6108
6112
  fs.rmSync(d, { recursive: true, force: true });
6109
- ok = fpFixed && fnGuard && bracketOk && fenceOk;
6110
- if (!ok) console.log(` [cvdollar 디버그] fpFixed=${fpFixed} fnGuard=${fnGuard} bracket=${bracketOk} fence=${fenceOk}`);
6113
+ ok = fpFixed && fnGuard && bracketOk && fenceOk && fenceRejectOk;
6114
+ if (!ok) console.log(` [cvdollar 디버그] fpFixed=${fpFixed} fnGuard=${fnGuard} bracket=${bracketOk} fence=${fenceOk} fenceReject=${fenceRejectOk}`);
6111
6115
  } catch {}
6112
6116
  console.log(ok ? '✓ B(1.35.11) 자체 contract 헌트 + codex 교차: $필드 정규식 안전화 + bracket export(#8) + 코드펜스 예제 제외(#9) FP 3종 + no-FN' : '✗ contract 헌트 FP 3종 수정 실패');
6113
6117
  if (!ok) failed++;
@@ -7546,5 +7550,74 @@ total++;
7546
7550
  if (!ok) failed++;
7547
7551
  }
7548
7552
 
7553
+ // 1.36.78 (10차 헌트 확정 7건): team 형상/계약 · constraints 검증/손상 · agents recommend · 빈 contract
7554
+ total++;
7555
+ {
7556
+ let ok = false;
7557
+ const _d = [];
7558
+ try {
7559
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h10-')); _d.push(d);
7560
+ cp.spawnSync(process.execPath, [CLI, 'init', d, '--yes', '--no-env', '--no-stale-check'], { encoding: 'utf8', timeout: 40000 });
7561
+ const T = (a) => cp.spawnSync(process.execPath, [CLI, ...a, '--path', d], { encoding: 'utf8', timeout: 20000 });
7562
+ const J = (r) => { try { return JSON.parse(r.stdout); } catch { return null; } };
7563
+ // F1: 객체 루트 teams.json → 변경 거부 + 원본 보존
7564
+ const tj = path.join(d, '.harness', 'teams.json');
7565
+ fs.writeFileSync(tj, '{"teams":[{"id":"preserved","members":["codex"]}],"metadata":{"revision":7}}');
7566
+ const beforeT = fs.readFileSync(tj, 'utf8');
7567
+ const f1a = T(['team', 'add', 'newteam', '--members', 'claude', '--json']);
7568
+ const f1Ok = f1a.status === 1 && fs.readFileSync(tj, 'utf8') === beforeT;
7569
+ // F1 variant2: string members → 크래시 대신 정규화(list 정상)
7570
+ fs.writeFileSync(tj, '[{"id":"bad","members":"codex","personas":[],"schedule":"manual","status":"active"}]');
7571
+ let f1bOk = false; try { f1bOk = Array.isArray(J(T(['team', 'list', '--json'])).teams[0].members); } catch {}
7572
+ // F4: team --json 순수 + exit 일치
7573
+ fs.writeFileSync(tj, '[]');
7574
+ const addJson = T(['team', 'add', 'alpha', '--members', 'claude,codex', '--json']);
7575
+ const f4addOk = J(addJson) && J(addJson).ok === true;
7576
+ const dupJson = T(['team', 'add', 'alpha', '--members', 'x', '--json']);
7577
+ const f4dupOk = J(dupJson) && J(dupJson).ok === false && dupJson.status === 1;
7578
+ const rmMiss = T(['team', 'remove', 'nope', '--json']);
7579
+ const f4rmOk = J(rmMiss) && rmMiss.status === 1;
7580
+ const depHuman = T(['team', 'deploy', 'alpha']);
7581
+ const depJson = T(['team', 'deploy', 'alpha', '--json']);
7582
+ const f4depOk = depHuman.status === depJson.status && depHuman.status === 1 && J(depJson) != null;
7583
+ // F2: 무효 constraint 거부 + dedup
7584
+ const _pcf = path.join(d, '.harness', 'platform-constraints.json'); // 무효 add 는 파일 자체를 안 만들 수 있음(부재 = 미저장)
7585
+ const f2malOk = T(['constraints', 'add', 'x1', '--alias', 'x1', '--constraint', 'rate-limit', '--json']).status === 1
7586
+ && T(['constraints', 'add', 'x2', '--alias', 'x2', '--constraint', 'auth:', '--json']).status === 1
7587
+ && !(fs.existsSync(_pcf) && fs.readFileSync(_pcf, 'utf8').includes('"x1"'));
7588
+ T(['constraints', 'add', 'dp', '--alias', 'dp', '--constraint', 'auth:token required', '--json']);
7589
+ const dup2 = J(T(['constraints', 'add', 'dp', '--alias', 'dp', '--constraint', 'auth:token required', '--json']));
7590
+ const f2dupOk = dup2 && dup2.constraints.length === 1;
7591
+ // F3: 손상 constraints store 표면화
7592
+ fs.writeFileSync(path.join(d, '.harness', 'platform-constraints.json'), '{"platforms":');
7593
+ const f3Ok = J(T(['constraints', 'list', '--json'])).corruptStore === true
7594
+ && J(T(['constraints', 'check', 'stripe API', '--json'])).corruptStore === true;
7595
+ // F5: agents recommend 존재
7596
+ const rec = J(cp.spawnSync(process.execPath, [CLI, 'agents', 'recommend', 'refactor payment module', '--json'], { encoding: 'utf8', timeout: 20000 }));
7597
+ const f5Ok = rec && rec.ok === true && typeof rec.recommended === 'string';
7598
+ // F6: 빈 contract 거부 + --allow-empty
7599
+ fs.writeFileSync(path.join(d, 'SPEC.md'), '# Spec\nprose'); fs.writeFileSync(path.join(d, 'impl.js'), '// empty');
7600
+ const f6Ok = cp.spawnSync(process.execPath, [CLI, 'contract', 'verify', path.join(d, 'SPEC.md'), path.join(d, 'impl.js'), '--json'], { encoding: 'utf8', timeout: 20000 }).status === 1
7601
+ && cp.spawnSync(process.execPath, [CLI, 'contract', 'verify', path.join(d, 'SPEC.md'), path.join(d, 'impl.js'), '--json', '--allow-empty'], { encoding: 'utf8', timeout: 20000 }).status === 0;
7602
+ // (검수 #3) 배열형 platforms 도 손상 표면화 + add 거부(metadata 보존)
7603
+ const pc = path.join(d, '.harness', 'platform-constraints.json');
7604
+ fs.writeFileSync(pc, '{"platforms":[],"metadata":{"revision":7}}');
7605
+ const beforePc = fs.readFileSync(pc, 'utf8');
7606
+ const arrOk = J(T(['constraints', 'list', '--json'])).corruptStore === true
7607
+ && T(['constraints', 'add', 'p', '--alias', 'p', '--constraint', 'auth:x', '--json']).status === 1
7608
+ && fs.readFileSync(pc, 'utf8') === beforePc;
7609
+ // (검수 #2) deploy execute --json exact-once JSON
7610
+ const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-h10dep-')); _d.push(d2);
7611
+ cp.spawnSync(process.execPath, [CLI, 'init', d2, '--yes', '--no-env', '--no-stale-check'], { encoding: 'utf8', timeout: 40000 });
7612
+ cp.spawnSync(process.execPath, [CLI, 'team', 'add', 'dep', '--members', 'claude', '--deploy', 'node -e "process.exit(0)"', '--path', d2], { encoding: 'utf8', timeout: 20000 });
7613
+ const depExec = cp.spawnSync(process.execPath, [CLI, 'team', 'deploy', 'dep', '--yes', '--json', '--path', d2], { encoding: 'utf8', timeout: 30000, env: { ...process.env, LEERNESS_TEAM_DEPLOY: '1' } });
7614
+ let depExecOk = false; try { const j = JSON.parse(depExec.stdout); depExecOk = j.executed === true && j.exitStatus === 0 && depExec.status === 0; } catch {}
7615
+ ok = f1Ok && f1bOk && f4addOk && f4dupOk && f4rmOk && f4depOk && f2malOk && f2dupOk && f3Ok && f5Ok && f6Ok && arrOk && depExecOk;
7616
+ if (!ok) console.log(` [h10 디버그] f1=${f1Ok} f1b=${f1bOk} f4add=${f4addOk} f4dup=${f4dupOk} f4rm=${f4rmOk} f4dep=${f4depOk} f2mal=${f2malOk} f2dup=${f2dupOk} f3=${f3Ok} f5=${f5Ok} f6=${f6Ok} arr=${arrOk} depExec=${depExecOk}`);
7617
+ } catch (e) {} finally { _d.forEach(x => { try { fs.rmSync(x, { recursive: true, force: true }); } catch {} }); }
7618
+ console.log(ok ? '✓ B(1.36.78) 10차헌트: team 형상거부/정규화·JSON계약 · constraints 검증/dedup/손상표면 · agents recommend · 빈 contract 거부' : '✗ 10차 헌트 수정 실패');
7619
+ if (!ok) failed++;
7620
+ }
7621
+
7549
7622
  console.log(`\nE2E result: ${total - failed}/${total} passed · ${((Date.now() - _e2eStart) / 1000).toFixed(0)}s`);
7550
7623
  if (failed > 0) process.exit(1);