leerness 1.9.145 → 1.9.146

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,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.146 — 2026-05-20
4
+
5
+ **사용자 명시 요청 5종 통합** — CLI 에이전트 모드 + 권한 시스템 + install 흐름 재구성.
6
+
7
+ ### 설계 결정 (사용자 질문에 대한 답)
8
+ - **agent 모드는 별도 명령** (`leerness agent`) — 기존 명령에 영향 없음
9
+ - **권한은 공유 시스템** (`.harness/agent-permissions.json`) — basic/extended/full 프리셋
10
+ - **IDE 통합은 자동** — IDE가 leerness CLI 호출 시 `agent-permissions.json` 자동 적용 (별도 모드 불필요)
11
+
12
+ ### ① 스킬 라이브러리 단순화 (사용자 요청)
13
+ - 인터랙티브 옵션 2개로 축소: **"표준 공식 5종 자동 설치"** / **"건너뛰기"**
14
+ - 이전: 빌트인 카탈로그 전체 + 추천 default + 직접 입력 등
15
+ - 비대화형 (--yes / --skills) 동작은 그대로
16
+
17
+ ### ② install 흐름 재구성 (사용자 요청)
18
+ - 모든 prompt (언어 / 스킬 / agent 활성화 / 권한 모드) 응답 완료 후 → 일괄 설치
19
+ - 응답 수집 단계와 파일 생성 단계 명확히 분리
20
+ - "📦 응답 수집 완료 — leerness 파일 설치 시작" 메시지
21
+
22
+ ### ③ Ollama CLI 에이전트 활성화 추가 (사용자 요청)
23
+ - `EXTERNAL_AGENTS` 에 ollama 추가 — `LEERNESS_ENABLE_OLLAMA=1`
24
+ - HTTP API (기본 `http://localhost:11434`), 모델 env: `LEERNESS_OLLAMA_MODEL`
25
+ - `agents list` 표에 표시
26
+ - install prompt: "Claude 단일 / Ollama 단일 / 전체 / 활성화 안함" 4지선다
27
+
28
+ ### ④ leerness agent — 오픈소스 CLI 에이전트 모드 (사용자 요청)
29
+ - `leerness agent "<task 설명>"` — OpenClaw/Hermes 스타일
30
+ - handoff context 자동 회수 (compact preview)
31
+ - Ollama HTTP API 직접 호출 (MVP) — `_ollamaChat(prompt, model)`
32
+ - 다른 provider 는 `leerness agents dispatch` 또는 외부 CLI 직접 호출 안내
33
+ - `--dry-run` / `--provider <name>` 지원
34
+ - task-log 자동 기록
35
+
36
+ ### ⑤ Agent 권한 시스템 (사용자 요청)
37
+ - `.harness/agent-permissions.json` — basic / extended / full 프리셋
38
+ - **basic**: `.harness/` 만 read/write, shell/network/mouse/keyboard/browser/admin 거부 (deny-by-default)
39
+ - **extended**: 프로젝트 폴더 + shell allowlist (npm/git/node/pnpm/yarn/pytest/jest/tsc), network localhost/github/npm 만
40
+ - **full**: 마우스/키보드/웹/관리자 전체 ⚠ IDE 통합 시에만 권장
41
+ - `leerness permissions list --json` 조회
42
+ - `leerness permissions set <mode>` 변경
43
+ - `permissionCheck(root, action, target)` 내부 헬퍼 — agent 작업 시 사전 검증
44
+
45
+ ### Validation
46
+ - stress-v91: PASS
47
+ - e2e: 219/219 PASS
48
+
3
49
  ## 1.9.145 — 2026-05-20
4
50
 
5
51
  **실행 환경 자동 감지 — 사용자 명시 요청.**
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **AI 코딩 에이전트의 거짓 완료·중복·망각·충돌을 막아주는 검수·기억·협업 CLI 하네스.**
4
4
 
5
- [![npm](https://img.shields.io/badge/npm-leerness-blue)](https://www.npmjs.com/package/leerness) [![version](https://img.shields.io/badge/version-1.9.145-green)]() [![tests](https://img.shields.io/badge/e2e-219%2F219-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-47-blue)]() [![json](https://img.shields.io/badge/--json-20_commands-blueviolet)]() [![rounds](https://img.shields.io/badge/autonomous--rounds-75-blueviolet)]() [![main-push](https://img.shields.io/badge/release--main--push-auto-success)]() [![env-detect](https://img.shields.io/badge/env--detect-1.9.145_PATH_guard-success)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
5
+ [![npm](https://img.shields.io/badge/npm-leerness-blue)](https://www.npmjs.com/package/leerness) [![version](https://img.shields.io/badge/version-1.9.146-green)]() [![tests](https://img.shields.io/badge/e2e-219%2F219-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-47-blue)]() [![json](https://img.shields.io/badge/--json-20_commands-blueviolet)]() [![rounds](https://img.shields.io/badge/autonomous--rounds-76-blueviolet)]() [![main-push](https://img.shields.io/badge/release--main--push-auto-success)]() [![cli-agent](https://img.shields.io/badge/leerness--agent-1.9.146-success)]() [![permissions](https://img.shields.io/badge/agent--permissions-basic%2Fextended%2Ffull-orange)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
6
6
 
7
7
  ```
8
8
  ╔══════════════════════════════════════════════════════════════╗
@@ -12,7 +12,7 @@
12
12
  ║ ██║ ██╔══╝ ██╔══╝ ██╔══██╗██║╚██╗██║██╔══╝ ╚════██║ ║
13
13
  ║ ███████╗███████╗███████╗██║ ██║██║ ╚████║███████╗███████║ ║
14
14
  ║ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝ ║
15
- ║ v1.9.145 AI Agent Reliability Harness ║
15
+ ║ v1.9.146 AI Agent Reliability Harness ║
16
16
  ║ verify · remember · orchestrate · audit · prevent drift ║
17
17
  ╚══════════════════════════════════════════════════════════════╝
18
18
  ```
package/bin/harness.js CHANGED
@@ -6,7 +6,7 @@ const path = require('path');
6
6
  const cp = require('child_process');
7
7
  const readline = require('readline');
8
8
 
9
- const VERSION = '1.9.145';
9
+ const VERSION = '1.9.146';
10
10
  const MARK = '<!-- leerness:managed -->';
11
11
  const README_START = '<!-- leerness:project-readme:start -->';
12
12
  const README_END = '<!-- leerness:project-readme:end -->';
@@ -681,35 +681,61 @@ async function resolveInstallOptions(root, opts = {}) {
681
681
  lang = a === '2' ? 'ko' : a === '3' ? 'en' : detectLanguageValue(root, 'auto');
682
682
  }
683
683
  }
684
+ // 1.9.146: 스킬 라이브러리 — 표준 공식 추천 자동 설치 / 건너뛰기 2-option 단순화 (사용자 명시 요청 #1)
684
685
  if (shouldAsk && !explicitSkills) {
685
686
  if (useInteractive) {
686
- // 카탈로그에서 옵션 생성
687
- const cat = Object.entries(skillCatalog).map(([id, meta]) => ({
688
- id, label: id, description: (meta.displayNameKo || id).slice(0, 50)
689
- }));
690
- // 추천 4개의 인덱스 계산
691
- const recommended = ['office', 'commerce-api', 'ai-verified-skill-publisher', 'feature-implementation'];
692
- const defaults = recommended.map(id => cat.findIndex(c => c.id === id)).filter(i => i >= 0);
693
- const picked = await _selectMany(
694
- '설치할 스킬 라이브러리 (Space=토글, a=전체, n=해제, Enter=확정)',
695
- cat,
696
- { defaults }
697
- );
698
- skills = picked.map(p => p.id);
687
+ const opt = await _selectOne('스킬 라이브러리 설치 (표준 공식 5종)', [
688
+ { label: '표준 공식 5종 자동 설치 (추천)', description: 'office · commerce-api · ai-verified-skill-publisher · feature-implementation · project-roadmap-generator', id: 'recommended' },
689
+ { label: '건너뛰기 (필요할 때 leerness skill install 로 추가)', description: '하네스만 설치, 스킬은 없음', id: 'none' }
690
+ ], { defaultIndex: 0 });
691
+ skills = (opt && opt.id === 'recommended') ? parseSkillsValue('recommended') : [];
692
+ } else {
693
+ log('\n스킬 라이브러리 설치를 선택하세요.');
694
+ log('1) 표준 공식 5종 자동 설치 (추천)');
695
+ log('2) 건너뛰기 (leerness skill install 로 추가 가능)');
696
+ const a = await ask('선택 [1]: ');
697
+ skills = (a === '2') ? [] : parseSkillsValue('recommended');
698
+ }
699
+ }
700
+ // 1.9.146: CLI 에이전트 활성화 선택 (사용자 명시 요청 #3 — Ollama 추가)
701
+ // 설치 마지막에 .env.example 에 활성화 옵트인 키만 기록 (실제 토큰 입력은 사용자가 직접).
702
+ let agentsOptIn = null;
703
+ if (shouldAsk && !opts._skipAgentsPrompt) {
704
+ if (useInteractive) {
705
+ const aOpt = await _selectOne('CLI 에이전트 활성화 (sub-agent 위임용, opt-in)', [
706
+ { label: '활성화 안함 (나중에 .env에서 직접 설정)', description: '권장 — 토큰/모델은 사용자가 직접 관리', id: 'none' },
707
+ { label: 'Claude (LEERNESS_CLAUDE_ENABLED=1)', description: 'claude CLI 또는 ANTHROPIC_API_KEY', id: 'claude' },
708
+ { label: 'Ollama (LEERNESS_OLLAMA_ENABLED=1) — 로컬 LLM', description: 'http://localhost:11434 (모델: llama3/qwen 등)', id: 'ollama' },
709
+ { label: '여러 개 (claude+codex+gemini+copilot+ollama)', description: '전체 후보 활성화 (각각 별도 토큰 필요)', id: 'all' }
710
+ ], { defaultIndex: 0 });
711
+ agentsOptIn = aOpt ? aOpt.id : 'none';
699
712
  } else {
700
- log('\n설치할 스킬 라이브러리를 선택하세요.');
701
- log('0) 기본 하네스만 설치');
702
- log('1) 추천: office, commerce-api, ai-verified-skill-publisher, feature-implementation');
703
- log('2) 전체 스킬 설치'); log('3) 직접 입력');
704
- skillList();
713
+ log('\nCLI 에이전트 활성화 (opt-in, 나중에 .env에서 변경 가능):');
714
+ log('1) 활성화 안함 2) Claude 3) Ollama 4) 전체');
705
715
  const a = await ask('선택 [1]: ');
706
- if (!a || a === '1') skills = parseSkillsValue('recommended');
707
- else if (a === '2') skills = parseSkillsValue('all');
708
- else if (a === '3') skills = parseSkillsValue(await ask('스킬 ID를 쉼표로 입력: '));
709
- else if (a === '0') skills = [];
716
+ agentsOptIn = a === '2' ? 'claude' : a === '3' ? 'ollama' : a === '4' ? 'all' : 'none';
710
717
  }
711
718
  }
712
- return { lang, skills };
719
+ // 1.9.146: 권한 모드 (사용자 명시 요청 #5 — agent IDE 모드 사전 prompt)
720
+ let permissionMode = null;
721
+ if (shouldAsk && !opts._skipPermissionsPrompt) {
722
+ if (useInteractive) {
723
+ const pOpt = await _selectOne('agent 권한 모드 (leerness agent 사용 시 적용)', [
724
+ { label: 'basic (안전) — 읽기/쓰기 .harness/ 만', description: '권장 — 파일시스템/네트워크 거부, .harness 안만 쓰기', id: 'basic' },
725
+ { label: 'extended — 프로젝트 폴더 + shell allowlist', description: '프로젝트 폴더 read/write, 사전 정의된 명령만 exec', id: 'extended' },
726
+ { label: 'full — 전체 (마우스/키보드/웹/관리자) ⚠ 위험', description: '⚠ IDE 통합 시에만 권장 — 모든 PC 작업 가능', id: 'full' }
727
+ ], { defaultIndex: 0 });
728
+ permissionMode = pOpt ? pOpt.id : 'basic';
729
+ } else {
730
+ log('\nagent 권한 모드 (leerness agent 명령 사용 시):');
731
+ log('1) basic (안전) — .harness/ 만');
732
+ log('2) extended — 프로젝트 폴더 + shell allowlist');
733
+ log('3) full ⚠ — 마우스/키보드/웹/관리자 전체 (IDE 통합 시에만)');
734
+ const a = await ask('선택 [1]: ');
735
+ permissionMode = a === '2' ? 'extended' : a === '3' ? 'full' : 'basic';
736
+ }
737
+ }
738
+ return { lang, skills, agentsOptIn, permissionMode };
713
739
  }
714
740
 
715
741
  async function install(root, opts = {}) {
@@ -731,10 +757,13 @@ async function install(root, opts = {}) {
731
757
  const resolved = await resolveInstallOptions(root, opts);
732
758
  const lang = resolved.lang;
733
759
  const skills = resolved.skills;
734
- log(`Leerness v${VERSION}`);
760
+ // 1.9.146: 사용자 명시 요청 #2 — 모든 prompt 끝난 후 한꺼번에 설치 단계 진입 (응답 수집과 파일 쓰기 분리)
761
+ log(`\n📦 응답 수집 완료 — leerness 파일 설치 시작 (Leerness v${VERSION})`);
735
762
  log(`Target: ${root}`);
736
763
  log(`Language: ${lang}`);
737
- log(`Skills: ${skills.length ? skills.join(', ') : 'none'}`);
764
+ log(`Skills: ${skills.length ? skills.join(', ') : 'none (건너뜀)'}`);
765
+ if (resolved.agentsOptIn && resolved.agentsOptIn !== 'none') log(`Agents 활성화: ${resolved.agentsOptIn}`);
766
+ if (resolved.permissionMode) log(`Agent 권한 모드: ${resolved.permissionMode}`);
738
767
  // 1.9.10: 스킬 카탈로그 출처 안내
739
768
  if (SKILLPACK_SOURCE === 'builtin') log(`Skill catalog source: builtin (leerness-skillpack 미설치 — \`npm i leerness-skillpack\`로 확장 가능)`);
740
769
  else log(`Skill catalog source: ${SKILLPACK_SOURCE} (leerness-skillpack${SKILLPACK_META ? ` v${SKILLPACK_META.version}` : ''})`);
@@ -775,24 +804,32 @@ async function install(root, opts = {}) {
775
804
  '.harness/skill-publish.local.json','.harness/**/*.local.json','.env.local',
776
805
  '.harness/archive/','.harness/migration-report.md','.harness/cache/'
777
806
  ]);
807
+ // 1.9.146: agentsOptIn 선택에 따라 LEERNESS_ENABLE_* 플래그 자동 설정 (사용자 명시 요청 #3 — Ollama 추가)
808
+ const a = resolved.agentsOptIn || 'none';
809
+ const enable = (cli) => a === 'all' || a === cli;
778
810
  mergeLinesFile(path.join(root, '.env.example'), [
779
811
  '# Leerness uses environment variable names only. Do not store real secrets here.',
780
812
  'LEERNESS_NPM_TOKEN=','LEERNESS_GITHUB_TOKEN=',
781
813
  '# 1.9.22 — orchestrate opt-in. URL이 설정되면 leerness가 Ollama를 사용 가능. 미설정 시 LLM 호출 자동 시작 금지.',
782
- 'LEERNESS_OLLAMA_BASE_URL=',
783
- '# 선택. 기본 모델 (orchestrate --model 로 override 가능).',
814
+ `LEERNESS_OLLAMA_BASE_URL=${enable('ollama') ? 'http://localhost:11434' : ''}`,
815
+ '# 선택. 기본 모델 (orchestrate --model 로 override 가능). 예: llama3 / qwen2.5-coder / gpt-oss',
784
816
  'LEERNESS_OLLAMA_MODEL=',
785
- '# 1.9.30 — 외부 AI CLI 활성화 플래그. 1=활성, 0/미설정=비활성. 메인 에이전트가 sub-agent 분배 시 활성 CLI들에 작업 위임 가능.',
786
- 'LEERNESS_ENABLE_CLAUDE=1',
787
- 'LEERNESS_ENABLE_CODEX=0',
788
- 'LEERNESS_ENABLE_GEMINI=0',
789
- 'LEERNESS_ENABLE_COPILOT=0',
817
+ '# 1.9.30+1.9.146 — 외부 AI CLI 활성화 플래그. 1=활성, 0/미설정=비활성. 메인 에이전트가 sub-agent 분배 시 활성 CLI들에 작업 위임 가능.',
818
+ `LEERNESS_ENABLE_CLAUDE=${enable('claude') ? 1 : 0}`,
819
+ `LEERNESS_ENABLE_CODEX=${enable('all') ? 1 : 0}`,
820
+ `LEERNESS_ENABLE_GEMINI=${enable('all') ? 1 : 0}`,
821
+ `LEERNESS_ENABLE_COPILOT=${enable('all') ? 1 : 0}`,
822
+ `LEERNESS_ENABLE_OLLAMA=${enable('ollama') ? 1 : 0}`,
790
823
  '# 1.9.42 — agentskills.io 공개 표준 스킬 자동 탐색 (opt-in). URL 설정 시 `leerness skill discover` 사용 가능.',
791
824
  '# 예: LEERNESS_SKILL_DISCOVER_URL=https://agentskills.io/llms.txt',
792
825
  'LEERNESS_SKILL_DISCOVER_URL=',
793
826
  '# (선택) 사용자 요청 분석 시 자동 매칭 스킬 추천. 1=활성, 0/미설정=비활성.',
794
827
  'LEERNESS_SKILL_AUTO_DISCOVER=0'
795
828
  ]);
829
+ // 1.9.146: agent 권한 파일 자동 생성 (사용자 명시 요청 #5)
830
+ if (resolved.permissionMode) {
831
+ try { _writePermissionsPreset(root, resolved.permissionMode); } catch (e) { warn('permissions 생성 실패: ' + e.message); }
832
+ }
796
833
  mergeLinesFile(path.join(root, '.gitattributes'), [
797
834
  '* text=auto eol=lf','*.bat text eol=crlf','*.ps1 text eol=crlf'
798
835
  ]);
@@ -4430,7 +4467,10 @@ const EXTERNAL_AGENTS = [
4430
4467
  { id: 'gemini', bin: 'gemini', envFlag: 'LEERNESS_ENABLE_GEMINI', versionArgs: ['--version'], desc: 'Google Gemini CLI (--yolo 모드 워크스페이스 직접 수정 가능)',
4431
4468
  installCmd: 'npm i -g @google/gemini-cli', installHint: 'https://github.com/google-gemini/gemini-cli' },
4432
4469
  { id: 'copilot', bin: 'gh', envFlag: 'LEERNESS_ENABLE_COPILOT', versionArgs: ['copilot', '--version'], desc: 'GitHub Copilot CLI (gh copilot)',
4433
- installCmd: 'gh extension install github/gh-copilot', installHint: 'https://github.com/github/gh-copilot (gh CLI 선행 설치 필요)' }
4470
+ installCmd: 'gh extension install github/gh-copilot', installHint: 'https://github.com/github/gh-copilot (gh CLI 선행 설치 필요)' },
4471
+ // 1.9.146: Ollama 추가 (사용자 명시 요청 #3) — 로컬 LLM, HTTP API 11434
4472
+ { id: 'ollama', bin: 'ollama', envFlag: 'LEERNESS_ENABLE_OLLAMA', versionArgs: ['--version'], desc: 'Ollama 로컬 LLM (http://localhost:11434, llama3/qwen 등)',
4473
+ installCmd: 'curl -fsSL https://ollama.com/install.sh | sh (또는 https://ollama.com/download)', installHint: 'ollama serve 실행 + ollama pull <model>' }
4434
4474
  ];
4435
4475
 
4436
4476
  // 1.9.36: 작업 키워드 분석으로 최적 CLI 추천
@@ -9786,6 +9826,203 @@ function envDetectCmd(root, opts = {}) {
9786
9826
  if (diff.missing && diff.missing.length) process.exitCode = 1;
9787
9827
  }
9788
9828
 
9829
+ // ===== 1.9.146: Agent 권한 시스템 (사용자 명시 요청 #5) =====
9830
+ // .harness/agent-permissions.json — leerness agent 명령 실행 시 적용. 기본 deny-by-default.
9831
+ function _permissionsPath(root) { return path.join(absRoot(root), '.harness', 'agent-permissions.json'); }
9832
+ function _permissionsPreset(mode) {
9833
+ // basic: 안전 — .harness/ 안만 쓰기
9834
+ // extended: 프로젝트 폴더 + shell allowlist
9835
+ // full: 전체 (mouse/keyboard/web/admin) — IDE 통합 시
9836
+ const presets = {
9837
+ basic: {
9838
+ mode: 'basic',
9839
+ filesystem: { read: true, write: true, restrictTo: ['.harness/', 'progress-tracker.md', 'session-handoff.md'], delete: false },
9840
+ shell: { exec: false, allowList: [] },
9841
+ network: { fetch: false, outboundAllowList: [] },
9842
+ mouse: false, keyboard: false, browser: false, admin: false,
9843
+ requireConfirmation: ['shell.exec', 'filesystem.delete', 'network.fetch']
9844
+ },
9845
+ extended: {
9846
+ mode: 'extended',
9847
+ filesystem: { read: true, write: true, restrictTo: ['./'], delete: false },
9848
+ shell: { exec: true, allowList: ['npm', 'git', 'node', 'pnpm', 'yarn', 'pytest', 'jest', 'tsc'] },
9849
+ network: { fetch: true, outboundAllowList: ['localhost', 'github.com', 'api.github.com', 'npmjs.org'] },
9850
+ mouse: false, keyboard: false, browser: false, admin: false,
9851
+ requireConfirmation: ['filesystem.delete', 'shell.exec_outside_allowlist']
9852
+ },
9853
+ full: {
9854
+ mode: 'full',
9855
+ filesystem: { read: true, write: true, restrictTo: ['./'], delete: true },
9856
+ shell: { exec: true, allowList: ['*'] },
9857
+ network: { fetch: true, outboundAllowList: ['*'] },
9858
+ mouse: true, keyboard: true, browser: true, admin: true,
9859
+ requireConfirmation: ['filesystem.delete_outside_project', 'admin_action']
9860
+ }
9861
+ };
9862
+ return presets[mode] || presets.basic;
9863
+ }
9864
+ function _writePermissionsPreset(root, mode) {
9865
+ const preset = _permissionsPreset(mode);
9866
+ preset.generatedAt = new Date().toISOString();
9867
+ preset.leernessVersion = VERSION;
9868
+ mkdirp(path.dirname(_permissionsPath(root)));
9869
+ writeUtf8(_permissionsPath(root), JSON.stringify(preset, null, 2) + '\n');
9870
+ return preset;
9871
+ }
9872
+ function _readPermissions(root) {
9873
+ const p = _permissionsPath(root);
9874
+ if (!exists(p)) return _permissionsPreset('basic');
9875
+ try { return JSON.parse(read(p)); } catch { return _permissionsPreset('basic'); }
9876
+ }
9877
+ function permissionsListCmd(root) {
9878
+ root = absRoot(root || process.cwd());
9879
+ const p = _readPermissions(root);
9880
+ if (has('--json')) { log(JSON.stringify(p, null, 2)); return; }
9881
+ log(`# leerness permissions (1.9.146)`);
9882
+ log(`mode: ${p.mode || 'basic'} · generated: ${p.generatedAt || '(없음)'}`);
9883
+ log('');
9884
+ log(`📂 filesystem: read=${p.filesystem?.read} write=${p.filesystem?.write} delete=${p.filesystem?.delete}`);
9885
+ if (p.filesystem?.restrictTo?.length) log(` restrict to: ${p.filesystem.restrictTo.join(', ')}`);
9886
+ log(`💻 shell.exec: ${p.shell?.exec} · allowList: ${(p.shell?.allowList || []).join(', ') || '(없음)'}`);
9887
+ log(`🌐 network.fetch: ${p.network?.fetch} · outbound: ${(p.network?.outboundAllowList || []).join(', ') || '(없음)'}`);
9888
+ log(`🖱 mouse=${p.mouse} ⌨ keyboard=${p.keyboard} 🌐 browser=${p.browser} 👑 admin=${p.admin}`);
9889
+ if (p.requireConfirmation?.length) log(`\n⚠ 확인 필요: ${p.requireConfirmation.join(', ')}`);
9890
+ }
9891
+ function permissionsSetCmd(root, mode) {
9892
+ root = absRoot(root || process.cwd());
9893
+ if (!['basic', 'extended', 'full'].includes(mode)) {
9894
+ return fail(`mode 는 basic / extended / full — 받음: ${mode || '(없음)'}`);
9895
+ }
9896
+ const p = _writePermissionsPreset(root, mode);
9897
+ ok(`permissions mode set: ${p.mode}`);
9898
+ log(` → 수정: ${_permissionsPath(root).replace(root, '.').replace(/\\/g, '/')}`);
9899
+ if (mode === 'full') warn(`⚠ full 모드 — IDE 통합 외 환경에서는 위험. agent 작업 시작 전 leerness permissions list 로 재확인 권장.`);
9900
+ }
9901
+ function permissionCheck(root, action, target) {
9902
+ // leerness agent 호출 시 권한 검증 — true(허용) / false(거부) 반환
9903
+ const p = _readPermissions(root);
9904
+ try {
9905
+ if (action === 'filesystem.read') return !!p.filesystem?.read;
9906
+ if (action === 'filesystem.write') {
9907
+ if (!p.filesystem?.write) return false;
9908
+ const restrict = p.filesystem?.restrictTo || [];
9909
+ if (!restrict.length || restrict.includes('./') || restrict.includes('*')) return true;
9910
+ return restrict.some(prefix => (target || '').startsWith(prefix));
9911
+ }
9912
+ if (action === 'filesystem.delete') return !!p.filesystem?.delete;
9913
+ if (action === 'shell.exec') {
9914
+ if (!p.shell?.exec) return false;
9915
+ const allow = p.shell?.allowList || [];
9916
+ if (allow.includes('*')) return true;
9917
+ const first = String(target || '').split(/\s+/)[0];
9918
+ return allow.includes(first);
9919
+ }
9920
+ if (action === 'network.fetch') return !!p.network?.fetch;
9921
+ if (action === 'mouse') return !!p.mouse;
9922
+ if (action === 'keyboard') return !!p.keyboard;
9923
+ if (action === 'browser') return !!p.browser;
9924
+ if (action === 'admin') return !!p.admin;
9925
+ } catch {}
9926
+ return false;
9927
+ }
9928
+
9929
+ // ===== 1.9.146: leerness agent — OpenClaw/Hermes 스타일 오픈소스 CLI 에이전트 모드 (사용자 명시 요청 #4) =====
9930
+ // MVP: handoff 컨텍스트 자동 로드 → 활성 CLI (claude/codex/gemini/ollama) 1개에 작업 위임.
9931
+ // 권한은 .harness/agent-permissions.json 기준. 실제 LLM 호출은 외부 CLI 또는 Ollama HTTP API.
9932
+ function _activeCliAgents() {
9933
+ const out = [];
9934
+ if (process.env.LEERNESS_ENABLE_CLAUDE === '1') out.push('claude');
9935
+ if (process.env.LEERNESS_ENABLE_CODEX === '1') out.push('codex');
9936
+ if (process.env.LEERNESS_ENABLE_GEMINI === '1') out.push('gemini');
9937
+ if (process.env.LEERNESS_ENABLE_COPILOT === '1') out.push('copilot');
9938
+ if (process.env.LEERNESS_ENABLE_OLLAMA === '1') out.push('ollama');
9939
+ return out;
9940
+ }
9941
+ async function _ollamaChat(prompt, model) {
9942
+ // Ollama HTTP API — 기본 http://localhost:11434/api/generate
9943
+ const url = (process.env.LEERNESS_OLLAMA_BASE_URL || 'http://localhost:11434').replace(/\/+$/, '') + '/api/generate';
9944
+ const mdl = model || process.env.LEERNESS_OLLAMA_MODEL || 'llama3';
9945
+ return new Promise((resolve) => {
9946
+ try {
9947
+ const body = JSON.stringify({ model: mdl, prompt, stream: false });
9948
+ const u = new URL(url);
9949
+ const lib = u.protocol === 'https:' ? require('https') : require('http');
9950
+ const req = lib.request({
9951
+ hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80),
9952
+ path: u.pathname + (u.search || ''), method: 'POST',
9953
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
9954
+ timeout: 60000
9955
+ }, (res) => {
9956
+ let data = '';
9957
+ res.on('data', c => { data += c; });
9958
+ res.on('end', () => {
9959
+ try { const j = JSON.parse(data); resolve({ ok: res.statusCode === 200, response: j.response || '', model: mdl }); }
9960
+ catch { resolve({ ok: false, error: 'invalid JSON response', model: mdl }); }
9961
+ });
9962
+ });
9963
+ req.on('error', e => resolve({ ok: false, error: e.message, model: mdl }));
9964
+ req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'timeout', model: mdl }); });
9965
+ req.write(body); req.end();
9966
+ } catch (e) { resolve({ ok: false, error: e.message, model: mdl }); }
9967
+ });
9968
+ }
9969
+ async function agentCmd(root, taskArg) {
9970
+ root = absRoot(root || process.cwd());
9971
+ const task = (taskArg || arg('--task', '') || '').trim();
9972
+ if (!task) {
9973
+ log('# leerness agent (1.9.146) — 오픈소스 CLI 에이전트 모드');
9974
+ log('');
9975
+ log('사용법:');
9976
+ log(' leerness agent "<task 설명>" # 1회 위임');
9977
+ log(' leerness agent --provider ollama # 명시적 provider 선택');
9978
+ log(' leerness agent --dry-run # LLM 호출 없이 흐름만 확인');
9979
+ log('');
9980
+ log('현재 활성 provider: ' + (_activeCliAgents().join(', ') || '(없음) — .env에서 LEERNESS_ENABLE_* 활성화'));
9981
+ log('권한 모드: ' + (_readPermissions(root).mode || 'basic'));
9982
+ return;
9983
+ }
9984
+ const dryRun = has('--dry-run');
9985
+ const providerArg = arg('--provider', null);
9986
+ const active = _activeCliAgents();
9987
+ const provider = providerArg || active[0] || null;
9988
+ log(`# leerness agent (1.9.146)`);
9989
+ log(`task: ${task.slice(0, 120)}${task.length > 120 ? '…' : ''}`);
9990
+ log(`provider: ${provider || '(없음 — .env 에서 LEERNESS_ENABLE_* 활성화 필요)'}`);
9991
+ const perms = _readPermissions(root);
9992
+ log(`permission mode: ${perms.mode || 'basic'}`);
9993
+ // handoff 자동 회수 (compact 모드)
9994
+ try {
9995
+ const hf = cp.spawnSync(process.execPath, [__filename, 'handoff', root, '--compact', '--no-drift-check'], { encoding: 'utf8', timeout: 10000, env: { ...process.env, LEERNESS_NO_BANNER: '1', LEERNESS_NO_PROMPT: '1' } });
9996
+ if (hf.status === 0 && hf.stdout) {
9997
+ const preview = hf.stdout.split('\n').slice(0, 6).join('\n');
9998
+ log('\n[handoff context (preview)]\n' + preview);
9999
+ }
10000
+ } catch {}
10001
+ if (dryRun) { log('\n(dry-run) LLM 호출 스킵 — provider/권한/컨텍스트만 출력'); return; }
10002
+ if (!provider) { fail('활성 provider 없음 — .env 에서 LEERNESS_ENABLE_OLLAMA=1 또는 LEERNESS_ENABLE_CLAUDE=1 활성화'); process.exitCode = 1; return; }
10003
+ // MVP: Ollama 지원 (로컬). 다른 CLI 는 사용자가 직접 호출 (leerness agents dispatch 이미 존재).
10004
+ if (provider === 'ollama') {
10005
+ log('\n[ollama 호출 중...]');
10006
+ const r = await _ollamaChat(task);
10007
+ if (r.ok) {
10008
+ log('\n[response (model=' + r.model + ')]\n' + r.response);
10009
+ // task-log 자동 기록
10010
+ try {
10011
+ const tlp = taskLogPath(root);
10012
+ const block = `\n## ${today()} leerness agent (ollama:${r.model})\n- task: ${task.slice(0, 200)}\n- response (preview): ${r.response.slice(0, 240).replace(/\n+/g, ' ')}\n`;
10013
+ append(tlp, block);
10014
+ } catch {}
10015
+ } else {
10016
+ fail(`ollama 호출 실패: ${r.error || 'unknown'}`);
10017
+ log(` → ollama serve 실행 + LEERNESS_OLLAMA_BASE_URL 확인`);
10018
+ process.exitCode = 1;
10019
+ }
10020
+ return;
10021
+ }
10022
+ // 그 외 provider: 사용자에게 직접 dispatch 안내
10023
+ log(`\n💡 ${provider} provider 는 \`leerness agents dispatch "<task>" --to ${provider}\` 또는 외부 CLI 직접 호출 권장`);
10024
+ }
10025
+
9789
10026
  // 1.9.85: leerness health — 종합 헬스 체크 (drift + 보안 + skill + MCP + 누적)
9790
10027
  function healthCmd(root) {
9791
10028
  root = absRoot(root || process.cwd());
@@ -10287,6 +10524,10 @@ async function main() {
10287
10524
  if (cmd === 'env' && args[1] === 'sync') return envSyncCmd(args[2] || arg('--path', process.cwd()));
10288
10525
  // 1.9.145: 실행 환경 자동 감지 + 변동 추적 (사용자 명시)
10289
10526
  if (cmd === 'env' && args[1] === 'detect') return envDetectCmd(args[2] || arg('--path', process.cwd()));
10527
+ // 1.9.146: agent 권한 시스템 + CLI 에이전트 모드 (사용자 명시 요청 #4, #5)
10528
+ if (cmd === 'permissions' && args[1] === 'list') return permissionsListCmd(arg('--path', process.cwd()));
10529
+ if (cmd === 'permissions' && args[1] === 'set') return permissionsSetCmd(arg('--path', process.cwd()), args[2]);
10530
+ if (cmd === 'agent') return agentCmd(arg('--path', process.cwd()), args.slice(1).filter(x => !x.startsWith('--')).join(' '));
10290
10531
  // 1.9.85: leerness health — 종합 헬스 체크
10291
10532
  if (cmd === 'health') return healthCmd(args[1] || arg('--path', process.cwd()));
10292
10533
  if (cmd === 'whats-new') return whatsNewCmd(args[1] || arg('--path', process.cwd()));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.145",
3
+ "version": "1.9.146",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",
package/scripts/e2e.js CHANGED
@@ -807,13 +807,14 @@ total++;
807
807
  && /\| gemini \|/.test(r1.stdout)
808
808
  && /\| copilot \|/.test(r1.stdout);
809
809
  // env 모두 0 → 비활성
810
- const env2 = { ...process.env, LEERNESS_ENABLE_CLAUDE: '0', LEERNESS_ENABLE_CODEX: '0', LEERNESS_ENABLE_GEMINI: '0', LEERNESS_ENABLE_COPILOT: '0' };
810
+ // 1.9.146: Ollama 추가 5 CLI
811
+ const env2 = { ...process.env, LEERNESS_ENABLE_CLAUDE: '0', LEERNESS_ENABLE_CODEX: '0', LEERNESS_ENABLE_GEMINI: '0', LEERNESS_ENABLE_COPILOT: '0', LEERNESS_ENABLE_OLLAMA: '0' };
811
812
  const r2 = cp.spawnSync(process.execPath, [CLI, 'agents', 'list', '--json'], { encoding: 'utf8', timeout: 15000, env: env2 });
812
813
  let parsed = null;
813
814
  try { parsed = JSON.parse(r2.stdout); } catch {}
814
- const okJson = parsed && Array.isArray(parsed.agents) && parsed.agents.length === 4 && parsed.agents.every(a => a.status !== 'ready');
815
+ const okJson = parsed && Array.isArray(parsed.agents) && parsed.agents.length === 5 && parsed.agents.every(a => a.status !== 'ready');
815
816
  const ok = okList && okJson;
816
- console.log(ok ? '✓ B(1.9.30) agents list: 4 CLI 정의 + env 0 시 모두 비활성' : `✗ agents list 실패 (list=${okList} json=${okJson})`);
817
+ console.log(ok ? '✓ B(1.9.30+1.9.146) agents list: 5 CLI 정의 (claude/codex/gemini/copilot/ollama)' : `✗ agents list 실패 (list=${okList} json=${okJson})`);
817
818
  if (!ok) { failed++; console.log(r1.stdout.slice(0, 500)); }
818
819
  }
819
820
 
@@ -851,10 +852,11 @@ total++;
851
852
  const r2 = cp.spawnSync(process.execPath, [CLI, 'agents', 'quota', '--json'], { encoding: 'utf8', timeout: 15000, env });
852
853
  let parsed = null;
853
854
  try { parsed = JSON.parse(r2.stdout); } catch {}
854
- const okJson = parsed && Array.isArray(parsed.quota) && parsed.quota.length === 4
855
+ // 1.9.146: Ollama 추가 5 CLI
856
+ const okJson = parsed && Array.isArray(parsed.quota) && parsed.quota.length === 5
855
857
  && parsed.quota.every(q => typeof q.id === 'string' && typeof q.status === 'string' && (q.hint === null || typeof q.hint === 'string'));
856
858
  const ok = okText && okJson;
857
- console.log(ok ? '✓ B(1.9.31) agents quota: 4 CLI 사용량/안내 + JSON 출력' : `✗ quota 실패 (text=${okText} json=${okJson})`);
859
+ console.log(ok ? '✓ B(1.9.31+1.9.146) agents quota: 5 CLI 사용량/안내' : `✗ quota 실패 (text=${okText} json=${okJson})`);
858
860
  if (!ok) { failed++; console.log(r.stdout.slice(0, 500)); }
859
861
  }
860
862