leerness 1.9.168 → 1.9.170

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,121 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.170 — 2026-05-21 — 🎉 100 라운드 자율 마일스톤
4
+
5
+ **사용자 명시 요청 2종: REPL Tab cycle provider/model + 실시간 스트리밍.**
6
+
7
+ 자율 모드 100 라운드 도달 (1.9.71~1.9.170). 사용자 직접 요청 2종 통합:
8
+ 1. **Tab 키 cycle** — provider/model 빠른 전환
9
+ 2. **실시간 스트리밍** — 추론중/diff/thinking 과정 즉시 표시
10
+
11
+ ### 1. REPL Tab cycle (사용자 명시 — "탭 키 등으로 provider/모델 셀렉과 선택 간편하게")
12
+
13
+ ```
14
+ agent[claude/actor/▶]> [Tab] # → ollama로 cycle
15
+ agent[ollama/actor/▶]> [Tab] # → claude로 cycle
16
+ agent[claude/actor/▶]> [Shift+Tab] # → 현재 provider의 다음 model로
17
+ ⇄ model: claude-opus-4-7 — 최신 1M context (Anthropic Opus 4.7)
18
+ ```
19
+
20
+ **구현**:
21
+ - `readline.emitKeypressEvents(process.stdin, rl)` — Tab 키 감지 활성화
22
+ - `process.stdin.on('keypress', ...)` — Tab/Shift+Tab 가로채서 cycle 실행
23
+ - `getProviders()` — 빌트인 5종 + `.harness/providers.json` 사용자 정의 통합
24
+ - prompt 갱신: `agent[provider/role/▶]>` (스트리밍 ON 시 ▶ 표시)
25
+
26
+ ### 2. 실제 모델 catalog 확장 (사용자 명시 — "gpt-5.5, gpt-5.4, claude opus4.7 등 실제 모델")
27
+
28
+ | Provider | 모델 |
29
+ |---|---|
30
+ | **claude** | **claude-opus-4-7** (1M context), opus-4-5, sonnet-4-7, sonnet-4-5, haiku-4-5 |
31
+ | **codex** | **gpt-5.5** (최신 추론), gpt-5.4, gpt-5, gpt-5-codex, o4-mini |
32
+ | **gemini** | gemini-2.5-pro, gemini-2.5-flash, gemini-3.0-pro |
33
+ | ollama | llama3, qwen2.5-coder, gpt-oss, deepseek-coder-v2 |
34
+ | copilot | default |
35
+
36
+ ### 3. 실시간 스트리밍 (사용자 명시 — "추론중, diff, 생각하는 과정 실시간 표시")
37
+
38
+ ```
39
+ user> 이 함수 리팩토링 해줘
40
+ → claude CLI stream 호출 중... (Ctrl+C 로 중단)
41
+ ── claude stream ──
42
+ [claude-opus-4-7] 분석 중...
43
+ function refactored() {
44
+ // 실시간으로 한 글자씩 흘러나옴
45
+ ...
46
+ }
47
+ ── /stream (3421ms) ──
48
+ [assistant: claude/claude-opus-4-7, role=actor, 3421ms · 1248자]
49
+ ```
50
+
51
+ **구현**:
52
+ - `_cliChatStream()` — `cp.spawn(..., { stdio: ['ignore', 'pipe', 'pipe'] })`
53
+ - Claude: `--output-format=stream-json --verbose` 활용 → `content_block_delta` / `thinking_delta` 파싱
54
+ - 다른 provider (codex/gemini/copilot): stdout raw pipe
55
+ - env scrub + cwd jail 유지 (1.9.150 sandboxing 호환)
56
+ - observability: `_recordRun(kind: 'agent_repl_cli_stream')`
57
+ - `:stream on|off` 토글, default ON (env `LEERNESS_REPL_STREAM=0` 로 끄기)
58
+
59
+ ### 100 라운드 마일스톤
60
+ | 마일스톤 | 라운드 | 도구 |
61
+ |---|---|---|
62
+ | MCP 30 도구 | 1.9.110 | Memory CRUD 완성 |
63
+ | MCP 50 도구 | 1.9.159 | Provider Registry CRUD |
64
+ | 90 라운드 | 1.9.160 | provider sync |
65
+ | MCP 53 도구 | 1.9.168 | Bridge 3종 외부 노출 |
66
+ | **100 라운드** | **1.9.170** | **🎉 Tab cycle + 실시간 스트리밍** |
67
+
68
+ ### Verified
69
+ - e2e 217/217 baseline 유지 (1.9.169 hotfix 후)
70
+ - stress-v115: **23/23** (catalog 4 + Tab cycle 3 + 스트리밍 4 + :stream + REPL 통합 5 + 누적 회귀 7)
71
+ - VERSION = 1.9.170 / autonomous-rounds = **100** 🎉 / main 자동 push 31 라운드 연속
72
+
73
+ ---
74
+
75
+ ## 1.9.169 — 2026-05-20
76
+
77
+ **🔧 Hotfix — `_collectWorkspacePaths()` --include 명시 시 cwd 자동 추가 안 함.**
78
+
79
+ 자율 모드 99 라운드. 1.9.168 release 후 발견된 e2e flake (209/217) 의 영구 해결.
80
+
81
+ ### 문제 진단
82
+ - `cwd: os.tmpdir()` 명령 실행 시 leerness가 cwd의 `.harness` 자동 발견 → 카운트 +1
83
+ - `os.tmpdir()` 에 잔존 `.harness` 디렉토리 존재 시 `--include` 명시했어도 cwd 추가됨
84
+ - 결과: `brainstorm --include p1,p2` 호출 시 "3개 프로젝트" (cwd + p1 + p2) 잘못 카운트
85
+ - 1.9.168 회귀 아님 — 1.9.15 이래 누적된 잠재 버그 (환경 의존 flake)
86
+ - 24,877개 누적 leerness-* 임시 디렉토리 + 잔존 `Temp/.harness` 가 트리거
87
+
88
+ ### Fix — _collectWorkspacePaths() (harness.js, 1.9.15 도입 함수)
89
+ ```javascript
90
+ function _collectWorkspacePaths(rootBase) {
91
+ const set = new Set();
92
+ const include = arg('--include', null);
93
+ // 1.9.169 fix: --include 명시 시 cwd 자동 추가 스킵 (explicit-only)
94
+ if (!include) {
95
+ if (exists(path.join(rootBase, '.harness'))) set.add(rootBase);
96
+ }
97
+ // ... --all-apps 동작 유지
98
+ if (include) { /* explicit paths only */ }
99
+ return Array.from(set);
100
+ }
101
+ ```
102
+
103
+ **원칙**: `--include` 가 명시되면 사용자가 의도한 explicit 경로만 사용. `--all-apps` 단독 또는 인자 없는 경우 기존 동작 (cwd 자동 추가) 유지.
104
+
105
+ ### 영향 범위
106
+ - `brainstorm --include`, `insights --include`, `handoff --include`, `reuse-map --include`, `retro --include`
107
+ - 모든 `--include` / 다중 프로젝트 명령 정확한 카운트
108
+
109
+ ### Verified
110
+ - e2e **217/217 ✓** (1.9.168 시점 209/217 → 1.9.169 217/217 회복)
111
+ - stress-v114: 14/14 (hotfix 4 + e2e 회복 3 + 누적 회귀 7)
112
+ - 진단 검증: `os.tmpdir()/.harness` 시뮬 환경에서도 정확 카운트
113
+ - VERSION = 1.9.169 / autonomous-rounds = 99 / main 자동 push 30 라운드 연속
114
+
115
+ ### 다음 라운드 (1.9.170) — 🎉 100 라운드 마일스톤 도달
116
+
117
+ ---
118
+
3
119
  ## 1.9.168 — 2026-05-20
4
120
 
5
121
  **MCP bridge 3종 노출 (web/pc/lsp) — 50 → 53 도구 + 외부 AI 자동화 능력 직결.**
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.168-green)]() [![tests](https://img.shields.io/badge/e2e-217%2F217-success)]() [![stress](https://img.shields.io/badge/stress--v113-17%2F17-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-53-brightgreen)]() [![rounds](https://img.shields.io/badge/autonomous--rounds-98-blueviolet)]() [![main-push](https://img.shields.io/badge/release--main--push-29_rounds-success)]() [![mcp-bridge](https://img.shields.io/badge/MCP_bridge-web%2Fpc%2Flsp_노출-success)]() [![lsp-bridge](https://img.shields.io/badge/lsp_bridge-typescript_opt--in%2Bregex_fallback-success)]() [![pc-bridge](https://img.shields.io/badge/pc_bridge-robotjs%2Fnut--tree_opt--in-success)]() [![web-bridge](https://img.shields.io/badge/playwright_bridge-opt--in_MVP-success)]() [![capability](https://img.shields.io/badge/6_capability-72%25_production--ready-brightgreen)]() [![sandbox](https://img.shields.io/badge/runCommandSafe-cwd_jail%2Benv_scrub-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.170-green)]() [![tests](https://img.shields.io/badge/e2e-217%2F217-success)]() [![stress](https://img.shields.io/badge/stress--v115-23%2F23-success)]() [![mcp](https://img.shields.io/badge/MCP--tools-53-brightgreen)]() [![rounds](https://img.shields.io/badge/autonomous--rounds-100🎉-blueviolet)]() [![main-push](https://img.shields.io/badge/release--main--push-31_rounds-success)]() [![repl-tab](https://img.shields.io/badge/REPL-Tab_cycle%2B실시간_스트리밍-success)]() [![mcp-bridge](https://img.shields.io/badge/MCP_bridge-web%2Fpc%2Flsp_노출-success)]() [![lsp-bridge](https://img.shields.io/badge/lsp_bridge-typescript_opt--in%2Bregex_fallback-success)]() [![pc-bridge](https://img.shields.io/badge/pc_bridge-robotjs%2Fnut--tree_opt--in-success)]() [![web-bridge](https://img.shields.io/badge/playwright_bridge-opt--in_MVP-success)]() [![capability](https://img.shields.io/badge/6_capability-72%25_production--ready-brightgreen)]() [![sandbox](https://img.shields.io/badge/runCommandSafe-cwd_jail%2Benv_scrub-success)]() [![license](https://img.shields.io/badge/license-MIT-lightgrey)]()
6
6
 
7
7
  ```
8
8
  ╔══════════════════════════════════════════════════════════════╗
@@ -12,9 +12,9 @@
12
12
  ║ ██║ ██╔══╝ ██╔══╝ ██╔══██╗██║╚██╗██║██╔══╝ ╚════██║ ║
13
13
  ║ ███████╗███████╗███████╗██║ ██║██║ ╚████║███████╗███████║ ║
14
14
  ║ ╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝ ║
15
- ║ v1.9.168 AI Agent Reliability Harness + Sandbox ║
15
+ ║ v1.9.170 AI Agent Reliability Harness + Sandbox ║
16
16
  ║ verify · remember · orchestrate · audit · sandbox · drift ║
17
- 53 MCP tools · web/pc/lsp bridge 외부 AI 직접 호출 가능
17
+ 🎉 100 라운드 자율 마일스톤 · REPL Tab cycle + 실시간 스트림
18
18
  ╚══════════════════════════════════════════════════════════════╝
19
19
  ```
20
20
 
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.168';
9
+ const VERSION = '1.9.170';
10
10
  const MARK = '<!-- leerness:managed -->';
11
11
  const README_START = '<!-- leerness:project-readme:start -->';
12
12
  const README_END = '<!-- leerness:project-readme:end -->';
@@ -6343,9 +6343,16 @@ function _retroOneLine(agg) {
6343
6343
  }
6344
6344
 
6345
6345
  // 1.9.15: --all-apps / --include 경로 모음
6346
+ // 1.9.169 fix: --include 명시되면 cwd 자동 추가 안 함 (explicit-only).
6347
+ // 기존: cwd/.harness 자동 추가 → 잔존 .harness 시 의도치 않은 카운트 증가 (e2e flake 원인)
6348
+ // 변경: --include 시 사용자가 명시한 경로만 사용. --all-apps 단독은 기존 동작 유지.
6346
6349
  function _collectWorkspacePaths(rootBase) {
6347
6350
  const set = new Set();
6348
- if (exists(path.join(rootBase, '.harness'))) set.add(rootBase);
6351
+ const include = arg('--include', null);
6352
+ // --include 명시 시 cwd 자동 추가 스킵 (explicit-only 보장)
6353
+ if (!include) {
6354
+ if (exists(path.join(rootBase, '.harness'))) set.add(rootBase);
6355
+ }
6349
6356
  if (has('--all-apps')) {
6350
6357
  const baseCandidates = [path.resolve(rootBase, '_apps'), path.resolve(rootBase, '..', '_apps')];
6351
6358
  for (const base of baseCandidates) {
@@ -6360,7 +6367,6 @@ function _collectWorkspacePaths(rootBase) {
6360
6367
  }
6361
6368
  }
6362
6369
  }
6363
- const include = arg('--include', null);
6364
6370
  if (include) {
6365
6371
  for (const p of String(include).split(',')) {
6366
6372
  const abs = path.resolve(p.trim());
@@ -10545,6 +10551,108 @@ async function _cliChat(root, provider, prompt, opts) {
10545
10551
  };
10546
10552
  }
10547
10553
 
10554
+ // 1.9.170: REPL 실시간 스트리밍 — CLI stdout을 즉시 터미널에 표시 (사용자 명시 요청)
10555
+ // 추론중/diff/thinking 과정이 stdout 으로 흐르는 동안 실시간 렌더링.
10556
+ // runCommandSafe 의 배치 결과와 달리 cp.spawn 의 child.stdout 을 직접 pipe.
10557
+ // env scrub + cwd jail 은 동일하게 적용.
10558
+ async function _cliChatStream(root, provider, promptText, opts) {
10559
+ opts = opts || {};
10560
+ const agent = EXTERNAL_AGENTS.find(a => a.id === provider);
10561
+ if (!agent) return { ok: false, error: `unknown provider: ${provider}`, provider };
10562
+ const status = _checkAgent(agent);
10563
+ if (status.status !== 'ready') {
10564
+ return { ok: false, error: `${provider} 비활성 (${status.status}) — .env 에서 ${agent.envFlag}=1 필요`, provider };
10565
+ }
10566
+ let cmd, args;
10567
+ if (provider === 'claude') { cmd = 'claude'; args = ['--print', '--output-format=stream-json', '--verbose', promptText]; }
10568
+ else if (provider === 'codex') { cmd = 'codex'; args = ['exec', '--skip-git-repo-check', promptText]; }
10569
+ else if (provider === 'gemini') { cmd = 'gemini'; args = ['-p', promptText]; }
10570
+ else if (provider === 'copilot') { cmd = 'gh'; args = ['copilot', 'suggest', promptText]; }
10571
+ else return { ok: false, error: `provider ${provider} 미지원`, provider };
10572
+ const t0 = Date.now();
10573
+ return new Promise(resolve => {
10574
+ let out = '', err = '';
10575
+ let buf = ''; // claude stream-json 라인 버퍼
10576
+ let firstChunk = true;
10577
+ const isTty = process.stdout.isTTY;
10578
+ const dim = isTty ? (s) => `\x1b[2m${s}\x1b[0m` : s => s;
10579
+ const cy = isTty ? (s) => `\x1b[36m${s}\x1b[0m` : s => s;
10580
+ process.stdout.write(dim(`\n ── ${provider} stream ──\n`));
10581
+ let child;
10582
+ try {
10583
+ child = cp.spawn(cmd, args, {
10584
+ cwd: process.cwd(),
10585
+ env: _scrubEnv({}),
10586
+ shell: false,
10587
+ stdio: ['ignore', 'pipe', 'pipe']
10588
+ });
10589
+ } catch (e) {
10590
+ return resolve({ ok: false, error: 'spawn 실패: ' + e.message, provider });
10591
+ }
10592
+ const timer = setTimeout(() => {
10593
+ try { child.kill('SIGTERM'); } catch {}
10594
+ }, opts.timeout || 120000);
10595
+
10596
+ // Claude stream-json 모드: 각 라인이 JSON event. type=assistant_message_delta.text 만 출력
10597
+ function handleClaudeStream(chunk) {
10598
+ buf += chunk.toString();
10599
+ const lines = buf.split('\n');
10600
+ buf = lines.pop() || '';
10601
+ for (const ln of lines) {
10602
+ if (!ln.trim()) continue;
10603
+ try {
10604
+ const ev = JSON.parse(ln);
10605
+ // assistant 메시지 chunk만 표시 (thinking/system 은 dim)
10606
+ if (ev.type === 'content_block_delta' && ev.delta?.text) {
10607
+ process.stdout.write(ev.delta.text);
10608
+ out += ev.delta.text;
10609
+ } else if (ev.type === 'message_start' && firstChunk) {
10610
+ firstChunk = false;
10611
+ process.stdout.write(dim(` [${ev.message?.model || provider}] `));
10612
+ } else if (ev.type === 'thinking_delta' && ev.delta?.thinking) {
10613
+ process.stdout.write(dim(ev.delta.thinking));
10614
+ }
10615
+ } catch {
10616
+ // JSON 아니면 그대로 출력 (fallback)
10617
+ process.stdout.write(ln + '\n');
10618
+ out += ln + '\n';
10619
+ }
10620
+ }
10621
+ }
10622
+
10623
+ child.stdout.on('data', chunk => {
10624
+ if (provider === 'claude') {
10625
+ handleClaudeStream(chunk);
10626
+ } else {
10627
+ const s = chunk.toString();
10628
+ out += s;
10629
+ process.stdout.write(s);
10630
+ }
10631
+ });
10632
+ child.stderr.on('data', chunk => {
10633
+ const s = chunk.toString();
10634
+ err += s;
10635
+ // 추론중/진행 메시지는 dim 으로 (실제 에러는 출력 후에 종합)
10636
+ process.stdout.write(dim(s));
10637
+ });
10638
+ child.on('error', e => {
10639
+ clearTimeout(timer);
10640
+ resolve({ ok: false, error: 'child error: ' + e.message, provider });
10641
+ });
10642
+ child.on('close', code => {
10643
+ clearTimeout(timer);
10644
+ const dt = Date.now() - t0;
10645
+ process.stdout.write(dim(`\n ── /stream (${dt}ms) ──\n`));
10646
+ try { _recordRun(root, { kind: 'agent_repl_cli_stream', provider, durationMs: dt, ok: code === 0, exitCode: code, responseChars: out.length }); } catch {}
10647
+ if (code === 0) {
10648
+ resolve({ ok: true, response: out.trim(), provider, model: provider, durationMs: dt });
10649
+ } else {
10650
+ resolve({ ok: false, error: `exit=${code} ${(err || out).slice(0, 200)}`, provider, durationMs: dt });
10651
+ }
10652
+ });
10653
+ });
10654
+ }
10655
+
10548
10656
  // 1.9.149: observability lite — 모든 agent 호출의 traceId + duration + exit + failureCause 기록
10549
10657
  function _runsDir(root) { return path.join(absRoot(root), '.harness', 'runs'); }
10550
10658
  function _recordRun(root, entry) {
@@ -10695,20 +10803,27 @@ function runsShowCmd(root, id) {
10695
10803
  log(read(fp));
10696
10804
  }
10697
10805
  // 1.9.155: provider 별 추천 모델 카탈로그 — REPL :models 명령에서 노출 (실제 가용성은 사용자 CLI 가 결정)
10806
+ // 1.9.170: provider × 실제 모델 catalog 확장 (Tab cycle 지원 — 사용자 명시 요청)
10807
+ // 각 provider 의 최신 실제 모델 ID 를 반영. Tab/Shift+Tab 키로 cycle.
10698
10808
  const _PROVIDER_MODEL_CATALOG = {
10699
10809
  claude: [
10700
- { id: 'claude-opus-4-5', note: '최고 추론 (Anthropic)' },
10701
- { id: 'claude-sonnet-4-5', note: '균형형 (속도/품질)' },
10702
- { id: 'claude-haiku-4-5', note: '빠름' }
10810
+ { id: 'claude-opus-4-7', note: '최신 1M context (Anthropic Opus 4.7)' },
10811
+ { id: 'claude-opus-4-5', note: '안정 Opus 4.5' },
10812
+ { id: 'claude-sonnet-4-7', note: '균형형 — Sonnet 4.7 (속도/품질)' },
10813
+ { id: 'claude-sonnet-4-5', note: 'Sonnet 4.5 안정' },
10814
+ { id: 'claude-haiku-4-5', note: '빠름 — Haiku 4.5' }
10703
10815
  ],
10704
10816
  codex: [
10705
- { id: 'gpt-5', note: 'OpenAI 최신' },
10706
- { id: 'gpt-5-codex', note: '코드 특화' },
10817
+ { id: 'gpt-5.5', note: 'OpenAI 최신 추론 모델' },
10818
+ { id: 'gpt-5.4', note: 'OpenAI 안정 (이전 세대)' },
10819
+ { id: 'gpt-5', note: 'OpenAI gpt-5 (base)' },
10820
+ { id: 'gpt-5-codex', note: '코드 특화 (Codex)' },
10707
10821
  { id: 'o4-mini', note: '빠른 reasoning' }
10708
10822
  ],
10709
10823
  gemini: [
10710
- { id: 'gemini-2.5-pro', note: 'Google 최고급' },
10711
- { id: 'gemini-2.5-flash', note: '빠른 응답' }
10824
+ { id: 'gemini-2.5-pro', note: 'Google 최고급 (1M+ context)' },
10825
+ { id: 'gemini-2.5-flash', note: '빠른 응답' },
10826
+ { id: 'gemini-3.0-pro', note: '실험적 (사용 가능 시)' }
10712
10827
  ],
10713
10828
  copilot: [
10714
10829
  { id: 'default', note: 'gh copilot 기본 (모델 선택 불가)' }
@@ -10716,10 +10831,14 @@ const _PROVIDER_MODEL_CATALOG = {
10716
10831
  ollama: [
10717
10832
  { id: 'llama3', note: 'Meta — :models 로 실시간 조회 권장' },
10718
10833
  { id: 'qwen2.5-coder', note: 'Alibaba — 코드 특화' },
10719
- { id: 'gpt-oss', note: 'OpenAI 오픈소스' }
10834
+ { id: 'gpt-oss', note: 'OpenAI 오픈소스' },
10835
+ { id: 'deepseek-coder-v2', note: 'DeepSeek 코드 모델' }
10720
10836
  ]
10721
10837
  };
10722
10838
 
10839
+ // 1.9.170: provider cycle 순서 (Tab) — 빌트인 5종. user provider는 동적으로 뒤에 추가.
10840
+ const _PROVIDER_CYCLE_ORDER = ['ollama', 'claude', 'codex', 'gemini', 'copilot'];
10841
+
10723
10842
  // 1.9.148: planner/reviewer/actor 역할 시스템 프롬프트 (Gemini 권고 — 자기-승인 편향 방지)
10724
10843
  const _AGENT_ROLE_PROMPTS = {
10725
10844
  planner: '역할: planner. task를 step 3-6개로 분해, 각 step의 입출력/검증 방법 명시. 코드 작성 금지, 계획만.',
@@ -10764,8 +10883,11 @@ async function _agentRepl(root, opts) {
10764
10883
  role: opts.role || 'actor',
10765
10884
  history: [], // [{role: 'user'|'assistant', content: ''}]
10766
10885
  startedAt: new Date().toISOString(),
10767
- sessionId: 'sess-' + new Date().toISOString().replace(/[:.]/g, '-')
10886
+ sessionId: 'sess-' + new Date().toISOString().replace(/[:.]/g, '-'),
10887
+ // 1.9.170: 실시간 스트리밍 모드 (사용자 명시 — 추론중/diff/thinking 실시간 표시)
10888
+ streamMode: opts.stream !== false // default ON (env 로 끄려면 LEERNESS_REPL_STREAM=0)
10768
10889
  };
10890
+ if (process.env.LEERNESS_REPL_STREAM === '0') state.streamMode = false;
10769
10891
  const sessionPath = () => path.join(absRoot(root), '.harness', 'agent-sessions', `${state.sessionId}.jsonl`);
10770
10892
  const saveSession = () => {
10771
10893
  try {
@@ -10828,6 +10950,7 @@ async function _agentRepl(root, opts) {
10828
10950
  log(C.dim(' 메타 명령: :help | :model <m> | :role <r> | :provider <p> | :status | :clear | :save | :history | :quit'));
10829
10951
  log(C.dim(' Slash 명령 (1.9.150): :verify | :audit | :handoff | :health'));
10830
10952
  log(C.dim(' Memory Slash (1.9.161): :lessons | :brainstorm <topic> | :tasks | :plan'));
10953
+ log(C.dim(' 🆕 1.9.170 — Tab=provider cycle, Shift+Tab=model cycle, :stream on|off (실시간 출력)'));
10831
10954
  log(C.dim(` 현재 — provider=${state.provider} model=${state.model || '(기본)'} role=${state.role} permissions=${_readPermissions(root).mode}`));
10832
10955
  // 1.9.155: REPL 진입 시 handoff 컨텍스트 자동 노출 (UX 개선 — 사용자가 매번 :handoff 안 해도 컨텍스트 인지)
10833
10956
  try {
@@ -10844,8 +10967,73 @@ async function _agentRepl(root, opts) {
10844
10967
  }
10845
10968
  } catch {}
10846
10969
  log('');
10847
- const prompt = () => isTty ? C.cy(`agent[${state.role}]> `) : 'agent> ';
10970
+ const prompt = () => isTty ? C.cy(`agent[${state.provider}/${state.role}${state.streamMode ? '/▶' : ''}]> `) : 'agent> ';
10848
10971
  rl.setPrompt(prompt());
10972
+
10973
+ // 1.9.170: Tab cycle — provider (Tab) / model within provider (Shift+Tab)
10974
+ // 사용자 명시 요청: "탭 키 등으로 provider/모델 셀렉과 선택을 간편하게"
10975
+ // readline의 default tab=completion 동작을 keypress 리스너로 가로채서 cycle 수행.
10976
+ if (isTty) {
10977
+ try {
10978
+ const readlineLib = require('readline');
10979
+ readlineLib.emitKeypressEvents(process.stdin, rl);
10980
+ const getProviders = () => {
10981
+ // 빌트인 5종 + 사용자 정의 provider (.harness/providers.json)
10982
+ try {
10983
+ const userPath = path.join(absRoot(root), '.harness', 'providers.json');
10984
+ if (fs.existsSync(userPath)) {
10985
+ const j = JSON.parse(fs.readFileSync(userPath, 'utf8'));
10986
+ const userIds = (j.providers || []).map(p => p.id).filter(id => !_PROVIDER_CYCLE_ORDER.includes(id));
10987
+ return [..._PROVIDER_CYCLE_ORDER, ...userIds];
10988
+ }
10989
+ } catch {}
10990
+ return _PROVIDER_CYCLE_ORDER.slice();
10991
+ };
10992
+ const cycleProvider = (reverse) => {
10993
+ const list = getProviders();
10994
+ let idx = list.indexOf(state.provider);
10995
+ if (idx < 0) idx = 0;
10996
+ idx = reverse ? (idx - 1 + list.length) % list.length : (idx + 1) % list.length;
10997
+ state.provider = list[idx];
10998
+ state.model = null; // 새 provider 기본 모델
10999
+ const cat = _PROVIDER_MODEL_CATALOG[state.provider];
11000
+ const hint = cat?.length ? ` (${cat.length}개 모델 catalog — Shift+Tab으로 cycle)` : '';
11001
+ // 현재 입력 라인 보존: cursor를 라인 시작으로 이동 → 클리어 → status + prompt 재출력
11002
+ process.stdout.write('\r\x1b[K');
11003
+ process.stdout.write(C.green(` ⇄ provider: ${state.provider}${hint}\n`));
11004
+ rl.setPrompt(prompt());
11005
+ rl.prompt(true); // preserve cursor
11006
+ };
11007
+ const cycleModel = (reverse) => {
11008
+ const cat = _PROVIDER_MODEL_CATALOG[state.provider] || [];
11009
+ if (cat.length === 0) {
11010
+ process.stdout.write('\r\x1b[K');
11011
+ process.stdout.write(C.yel(` ⚠ ${state.provider} 추천 모델 catalog 없음 (:model <name> 으로 직접 지정)\n`));
11012
+ rl.prompt(true);
11013
+ return;
11014
+ }
11015
+ let idx = cat.findIndex(m => m.id === state.model);
11016
+ if (idx < 0) idx = -1;
11017
+ idx = reverse ? (idx - 1 + cat.length) % cat.length : (idx + 1) % cat.length;
11018
+ state.model = cat[idx].id;
11019
+ process.stdout.write('\r\x1b[K');
11020
+ process.stdout.write(C.green(` ⇄ model: ${state.model} ${C.dim('— ' + (cat[idx].note || ''))}\n`));
11021
+ rl.setPrompt(prompt());
11022
+ rl.prompt(true);
11023
+ };
11024
+ process.stdin.on('keypress', (str, key) => {
11025
+ if (!key) return;
11026
+ if (key.name === 'tab') {
11027
+ cycleProvider(key.shift === true);
11028
+ }
11029
+ // Shift+\ 또는 다른 모델 cycle alias — 일부 터미널에서 Shift+Tab 처리 어려움 대비
11030
+ // (Shift+Tab은 key.name='tab' + key.shift=true 로 위에서 처리됨)
11031
+ });
11032
+ } catch (e) {
11033
+ log(C.dim(` (Tab cycle 비활성: ${e.message})`));
11034
+ }
11035
+ }
11036
+
10849
11037
  rl.prompt();
10850
11038
  const handleMeta = async (cmd) => {
10851
11039
  const [op, ...rest] = cmd.slice(1).split(/\s+/);
@@ -10857,10 +11045,11 @@ async function _agentRepl(root, opts) {
10857
11045
  if (op === 'help' || op === '?') {
10858
11046
  log(C.bold('\n 메타 명령 (provider/모델/역할 전환):'));
10859
11047
  log(' :help / :? — 이 도움말');
10860
- log(' :model <name> — 모델 변경 (1.9.155 모든 provider 지원, 예: :model claude-opus-4-5)');
11048
+ log(' :model <name> — 모델 변경 (1.9.155 모든 provider 지원, 예: :model claude-opus-4-7)');
10861
11049
  log(' :models — provider 별 모델 목록 (ollama 실시간 / 그 외 추천 카탈로그)');
10862
11050
  log(' :role <r> — 역할 변경 (planner / reviewer / actor)');
10863
11051
  log(' :provider <p> — provider 변경 (ollama / claude / codex / gemini / copilot — ready 검증)');
11052
+ log(' :stream on|off — 🆕 1.9.170 실시간 스트리밍 토글 (추론중/diff/thinking 실시간 표시)');
10864
11053
  log(' :status — 현재 세션 상태 자세히 (1.9.155)');
10865
11054
  log(' :clear — 화면 클리어 + history 유지');
10866
11055
  log(' :reset — history 초기화');
@@ -10868,6 +11057,9 @@ async function _agentRepl(root, opts) {
10868
11057
  log(' :save — 세션 즉시 저장');
10869
11058
  log(' :permissions — 현재 권한 모드 표시');
10870
11059
  log(' :quit / :exit / :q — 종료 (자동 저장)');
11060
+ log(C.bold('\n 🆕 1.9.170 키보드 단축키:'));
11061
+ log(' Tab — 다음 provider 로 cycle (ollama → claude → codex → gemini → copilot)');
11062
+ log(' Shift+Tab — 현재 provider 의 다음 model 로 cycle (catalog 기준)');
10871
11063
  log(C.bold('\n Slash 명령 (1.9.150) — leerness 내부 명령 직접 호출:'));
10872
11064
  log(' :verify — leerness verify-code (테스트/타입/린트 자동 검수)');
10873
11065
  log(' :audit — leerness audit (보안 + drift + lazy)');
@@ -10948,6 +11140,21 @@ async function _agentRepl(root, opts) {
10948
11140
  log(C.green(` provider = ${state.provider}`));
10949
11141
  return false;
10950
11142
  }
11143
+ if (op === 'stream') {
11144
+ // 1.9.170: 실시간 스트리밍 모드 토글 (사용자 명시 — 추론중/diff/thinking 실시간 표시)
11145
+ const v = (rest[0] || '').toLowerCase();
11146
+ if (v === 'on' || v === '1' || v === 'true') {
11147
+ state.streamMode = true;
11148
+ log(C.green(' ▶ streaming mode: ON (CLI stdout 실시간 표시)'));
11149
+ } else if (v === 'off' || v === '0' || v === 'false') {
11150
+ state.streamMode = false;
11151
+ log(C.dim(' □ streaming mode: OFF (배치 응답)'));
11152
+ } else {
11153
+ log(C.dim(` 현재 streaming: ${state.streamMode ? 'ON ▶' : 'OFF □'} — 사용: :stream on|off`));
11154
+ }
11155
+ rl.setPrompt(prompt());
11156
+ return false;
11157
+ }
10951
11158
  if (op === 'clear') { process.stdout.write('\x1b[2J\x1b[H'); return false; }
10952
11159
  if (op === 'reset') { state.history = []; log(C.dim(' history 초기화됨')); return false; }
10953
11160
  if (op === 'history') {
@@ -11026,12 +11233,18 @@ async function _agentRepl(root, opts) {
11026
11233
  const t0 = Date.now();
11027
11234
  let result;
11028
11235
  // 1.9.153: multi-provider REPL — ollama 외 claude/codex/gemini/copilot 도 세션 관리 (사용자 명시)
11236
+ // 1.9.170: streamMode === true 이면 _cliChatStream 사용 (사용자 명시 — 추론중/diff 실시간 표시)
11029
11237
  if (state.provider === 'ollama') {
11030
11238
  log(C.dim(` → ollama${state.model ? ' (' + state.model + ')' : ''} 호출 중...`));
11031
11239
  result = await _ollamaChat(finalPrompt, state.model);
11032
11240
  } else if (['claude', 'codex', 'gemini', 'copilot'].includes(state.provider)) {
11033
- log(C.dim(` → ${state.provider} CLI 호출 중...`));
11034
- result = await _cliChat(root, state.provider, finalPrompt, { timeout: 90000 });
11241
+ if (state.streamMode) {
11242
+ log(C.dim(` → ${state.provider} CLI stream 호출 중... (Ctrl+C 로 중단)`));
11243
+ result = await _cliChatStream(root, state.provider, finalPrompt, { timeout: 120000 });
11244
+ } else {
11245
+ log(C.dim(` → ${state.provider} CLI 호출 중...`));
11246
+ result = await _cliChat(root, state.provider, finalPrompt, { timeout: 90000 });
11247
+ }
11035
11248
  } else {
11036
11249
  log(C.yel(` ⚠ ${state.provider} provider 미지원 — :provider ollama|claude|codex|gemini|copilot`));
11037
11250
  rl.prompt(); return;
@@ -11040,10 +11253,16 @@ async function _agentRepl(root, opts) {
11040
11253
  _recordRun(root, { kind: 'agent_repl_turn', provider: state.provider, model: state.model, role: state.role, durationMs: dt, ok: result.ok, error: result.error, promptChars: finalPrompt.length, responseChars: (result.response || '').length });
11041
11254
  if (result.ok) {
11042
11255
  state.history.push({ role: 'assistant', content: result.response });
11043
- log('');
11044
- log(C.bold(`assistant (${state.model}, role=${state.role}, ${dt}ms)`));
11045
- log(result.response);
11046
- log('');
11256
+ // 1.9.170: stream 모드에서는 이미 실시간으로 출력됐으므로 헤더만 표시 (응답 중복 방지)
11257
+ if (state.streamMode && ['claude', 'codex', 'gemini', 'copilot'].includes(state.provider)) {
11258
+ log(C.dim(` [assistant: ${state.provider}/${state.model || 'default'}, role=${state.role}, ${dt}ms · ${result.response.length}자]`));
11259
+ log('');
11260
+ } else {
11261
+ log('');
11262
+ log(C.bold(`assistant (${state.model || state.provider}, role=${state.role}, ${dt}ms)`));
11263
+ log(result.response);
11264
+ log('');
11265
+ }
11047
11266
  if (state.history.length % 6 === 0) saveSession(); // 6턴마다 자동 저장
11048
11267
  } else {
11049
11268
  log(C.yel(` ⚠ 실패: ${result.error || 'unknown'}`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.9.168",
3
+ "version": "1.9.170",
4
4
  "description": "Leerness: 비파괴 마이그레이션, 자동 버전 감지·업데이트, 계획/진행/핸드오프 자동화, 게으름·시크릿·인코딩 자동 가드, Claude Code 슬래시 통합을 갖춘 한국어 우선 AI 개발 하네스.",
5
5
  "keywords": [
6
6
  "leerness",