deel-local-cli 1.2.0 → 1.4.1

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.
Files changed (50) hide show
  1. package/README.en.md +301 -1537
  2. package/README.md +272 -1582
  3. package/bin/deel.js +73 -4
  4. package/package.json +8 -3
  5. package/src/acp/jsonrpc.js +230 -0
  6. package/src/acp/map.js +219 -0
  7. package/src/acp/serve.js +556 -0
  8. package/src/agent/card.js +129 -0
  9. package/src/agent/compact.js +10 -2
  10. package/src/agent/effort.js +5 -0
  11. package/src/agent/evidence.js +186 -0
  12. package/src/agent/grade.js +20 -0
  13. package/src/agent/loop.js +134 -13
  14. package/src/agent/models.js +169 -0
  15. package/src/agent/modes.js +47 -1
  16. package/src/agent/pins.js +140 -0
  17. package/src/agent/preset.js +113 -0
  18. package/src/agent/project.js +10 -4
  19. package/src/agent/session.js +233 -13
  20. package/src/agent/store.js +31 -0
  21. package/src/backend/adapter.js +14 -0
  22. package/src/commands.js +616 -52
  23. package/src/i18n/en.js +265 -0
  24. package/src/i18n/index.js +126 -0
  25. package/src/i18n/ko.js +252 -0
  26. package/src/lsp/client.js +459 -0
  27. package/src/lsp/diag.js +112 -0
  28. package/src/lsp/rpc.js +84 -0
  29. package/src/lsp/servers.js +218 -0
  30. package/src/oneshot.js +19 -0
  31. package/src/pack/sbom.js +218 -0
  32. package/src/pack/selfpack.js +19 -3
  33. package/src/preview/serve.js +19 -2
  34. package/src/repl.js +193 -8
  35. package/src/safety/secrets.js +205 -0
  36. package/src/safety/undo.js +10 -3
  37. package/src/tools/desc.en.js +221 -0
  38. package/src/tools/docs.js +252 -0
  39. package/src/tools/index.js +190 -6
  40. package/src/tools/lsp.js +327 -0
  41. package/src/tools/task.js +30 -2
  42. package/src/ui/ansi.js +45 -0
  43. package/src/ui/approve.js +25 -21
  44. package/src/ui/banner.js +245 -0
  45. package/src/ui/export.js +217 -0
  46. package/src/ui/inputbox.js +37 -7
  47. package/src/ui/intro.js +206 -0
  48. package/src/ui/level.js +11 -5
  49. package/src/ui/notify.js +101 -0
  50. package/src/ui/status.js +181 -35
@@ -84,6 +84,12 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN }
84
84
 
85
85
  const 접을것 = 자리.slice(0, Math.max(0, 자리.length - keep)).filter((x) => x.글.length >= min);
86
86
  let 아낀토큰 = 0;
87
+ // 무엇을 버렸는지 이름으로 남긴다.
88
+ //
89
+ // 조용히 버리면 사람이 모른다 — "아까 그 파일 내용 어디 갔지" 를 스스로 답할
90
+ // 길이 없다. 접힌 자리에 표시가 남긴 하지만 그건 모델이 보는 쪽이고,
91
+ // 사람 화면에는 여기서 돌려주는 목록으로 알려 준다.
92
+ const 접은것들 = [];
87
93
 
88
94
  for (const { i, 글 } of 접을것) {
89
95
  const m = ms[i];
@@ -96,10 +102,12 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN }
96
102
  content: `${접힘표} ${아는것.name}${곳 ? `(${곳})` : ''} — ${줄수}줄. `
97
103
  + '자리를 비우려고 내용을 접었습니다. 필요하면 다시 읽으세요.',
98
104
  };
99
- 아낀토큰 += 전 - estimateTokens(ms[i].content);
105
+ const 아낀것 = 전 - estimateTokens(ms[i].content);
106
+ 아낀토큰 += 아낀것;
107
+ 접은것들.push({ 도구: 아는것.name, 곳, 줄수, 토큰: Math.max(0, 아낀것) });
100
108
  }
101
109
 
102
- return { 접은것: 접을것.length, 아낀토큰: Math.max(0, 아낀토큰) };
110
+ return { 접은것: 접을것.length, 아낀토큰: Math.max(0, 아낀토큰), 접은것들 };
103
111
  }
104
112
 
105
113
  const 요약지시 = `지금까지의 대화를 다음 형식으로 요약하세요. 이어서 일할 사람이 이것만 보고도 계속할 수 있어야 합니다.
@@ -36,18 +36,23 @@ export const STAGES = {
36
36
  export const PROFILES = {
37
37
  even: {
38
38
  name: '균일',
39
+ // 화면 말이 영어일 때 쓰는 이름. modes.js 의 en 과 같은 방식이다 —
40
+ // i18n 표에 같은 말을 또 적어 두면 언젠가 둘이 갈라진다.
41
+ en: 'even',
39
42
  desc: '모든 단계 같은 강도 — 예측 가능한 대신 느립니다',
40
43
  shift: { plan: 0, work: 0, fix: 0 },
41
44
  share: { plan: 0.40, work: 0.40, fix: 0.40 },
42
45
  },
43
46
  save: {
44
47
  name: '절약',
48
+ en: 'save',
45
49
  desc: '첫 판단만 세게, 이어가기는 얕게 — 대개 이게 낫습니다',
46
50
  shift: { plan: 0, work: -1, fix: +1 },
47
51
  share: { plan: 0.40, work: 0.35, fix: 0.45 },
48
52
  },
49
53
  deep: {
50
54
  name: '깊게',
55
+ en: 'deep',
51
56
  desc: '전 단계 한 칸씩 위로 — 어려운 일에만',
52
57
  shift: { plan: +1, work: 0, fix: +1 },
53
58
  share: { plan: 0.50, work: 0.45, fix: 0.50 },
@@ -0,0 +1,186 @@
1
+ // 증거 — 「다 됐습니다」 대신 검토할 수 있는 것을 내놓는다.
2
+ //
3
+ // ── 왜 만드나 ───────────────────────────────────────────────────────────
4
+ //
5
+ // 2026년 조사로는 개발자의 **96%가 AI 가 쓴 코드를 온전히 믿지 않는데, 매번
6
+ // 확인하는 사람은 48%** 다. 38%는 "사람 코드보다 리뷰가 더 힘들다"고 답한다.
7
+ //
8
+ // 왜 더 힘든가. 사람이 낸 코드는 "왜 이렇게 했나" 를 물으면 답이 온다. 에이전트가
9
+ // 낸 코드는 **"끝났습니다" 한 줄**과 함께 온다. 그 한 줄은 검토할 수가 없다.
10
+ // 무엇을 근거로 끝났다고 하는지가 그 안에 없기 때문이다.
11
+ //
12
+ // 그래서 이 파일이 하는 일은 하나다 — **말을 증거로 바꾼다.**
13
+ //
14
+ // 무엇을 바꿨나 파일과 줄 수
15
+ // 무엇을 돌렸나 명령과 그 결과
16
+ // 무엇이 증명됐나 고친 **뒤에** 돌아서 **성공한** 것이 있는가
17
+ //
18
+ // ── 제일 중요한 칸 ──────────────────────────────────────────────────────
19
+ //
20
+ // 바꾼 것을 늘어놓는 일은 /diff 도 한다. 여기서만 하는 것은 **증명 안 된 것**을
21
+ // 증명 안 됐다고 말하는 것이다.
22
+ //
23
+ // 고쳐 놓고 아무것도 안 돌렸으면 아무것도 증명되지 않았다. 검사가 빨간데
24
+ // 돌리기는 했다고 초록으로 치면 그건 **빨간 검사를 증거로 내미는** 셈이다.
25
+ // 고치기 **전에** 돌린 검사는 고친 것을 증명하지 못한다 — "아까 돌렸으니 됐다" 가
26
+ // 제일 흔한 자기기만이다. 셋 다 여기서 걸러진다.
27
+ //
28
+ // ── 안 하는 것 ──────────────────────────────────────────────────────────
29
+ //
30
+ // 프롬프트에 안 싣는다. 이건 사람이 보는 것이지 모델에게 먹이는 것이 아니다.
31
+ // 실으면 컨텍스트만 먹고, 모델이 제가 만든 증거를 다시 읽는 이상한 고리가 생긴다.
32
+ import { writeFileSync, mkdirSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+
35
+ /** 이 명령들은 '확인했다' 로 친다. 돌렸다고 다 증거가 되는 것은 아니다. */
36
+ const 확인하는명령 = /(^|[\s|;&])(npm|pnpm|yarn|bun)\s+(test|run\s+(test|check|lint|build|verify|typecheck))|(^|[\s|;&])(pytest|jest|vitest|mocha|go\s+test|cargo\s+test|mvn\s+test|gradle\s+test|dotnet\s+test|tsc|eslint|ruff|mypy)\b/i;
37
+
38
+ /** 우리 쪽 확인 도구. Bash 를 안 거치고 확인하는 길이다. */
39
+ const 확인하는도구 = new Set(['Verify', 'Test']);
40
+
41
+ /**
42
+ * 이번 대화의 증거를 모은다.
43
+ *
44
+ * @param {object} session
45
+ * @param {object} o
46
+ * @param {object} o.audit safety/audit.js 의 Audit
47
+ * @param {number} o.최근 감사기록을 몇 줄까지 볼까
48
+ * @param {Date} o.바꾼때 파일을 언제 고쳤다고 볼까 (검사가 고정할 수 있게)
49
+ */
50
+ export function 증거모으기(session, { audit = null, 최근 = 400, 바꾼때 = null } = {}) {
51
+ /*
52
+ * 감사기록은 폴더에 하나뿐이고 계속 덧붙는다. 그대로 읽으면 **지난주에 돌린
53
+ * 검사**가 오늘 고친 파일의 증거로 붙는다 — 증거를 모으는 도구가 거짓 증거를
54
+ * 만드는 셈이다. 이번 세션 것만 본다.
55
+ */
56
+ const 이번세션 = audit?.session ?? null;
57
+ const 기록 = (() => {
58
+ try {
59
+ const 전부 = audit?.recent?.(최근) ?? [];
60
+ return 이번세션 ? 전부.filter((r) => r?.session === 이번세션) : 전부;
61
+ } catch { return []; }
62
+ })();
63
+
64
+ // 돌린 것 — Bash 와 확인 도구만. 파일을 읽은 것까지 증거라고 하면 목록이
65
+ // 길어지기만 하고, 읽은 것은 아무것도 증명하지 않는다.
66
+ const 돌린것 = [];
67
+ for (const r of 기록) {
68
+ if (r?.kind !== 'tool') continue;
69
+ const 도구 = String(r.tool ?? '');
70
+ if (도구 !== 'Bash' && !확인하는도구.has(도구)) continue;
71
+ 돌린것.push({
72
+ 도구,
73
+ 무엇: String(r.target ?? ''),
74
+ 됐나: r.ok !== false,
75
+ 남긴말: r.ok === false ? `실패 — ${String(r.note ?? '').slice(0, 120)}` : String(r.note ?? '').slice(0, 120),
76
+ 때: r.at ?? null,
77
+ 확인인가: 확인하는도구.has(도구) || 확인하는명령.test(String(r.target ?? '')),
78
+ });
79
+ }
80
+
81
+ const 확인시도 = 돌린것.filter((x) => x.확인인가);
82
+ const 언제고쳤나 = 바꾼때 ? new Date(바꾼때).getTime() : null;
83
+
84
+ const 바꾼것 = [];
85
+ const 증명안된것 = [];
86
+ for (const [파일, d] of session?.changes ?? new Map()) {
87
+ // 고친 **뒤에** 돌린 확인만 본다.
88
+ const 쓸것 = 확인시도.filter((x) => {
89
+ if (언제고쳤나 == null || !x.때) return true; // 시각을 모르면 순서를 안 따진다
90
+ return new Date(x.때).getTime() >= 언제고쳤나;
91
+ });
92
+ /*
93
+ * **마지막** 확인이 증거다. 성공한 것 중 마지막이 아니다.
94
+ *
95
+ * 빌드가 통과하고 그 뒤에 검사가 깨졌는데 앞의 초록을 들고 와서
96
+ * "확인됐습니다" 하면 그게 거짓 증거다. 사람이 보는 순서로 생각하면
97
+ * 답이 분명하다 — 마지막에 돌린 것이 빨간데 확인됐을 리가 없다.
98
+ */
99
+ const 마지막 = 쓸것.length ? 쓸것[쓸것.length - 1] : null;
100
+ const 증명 = 마지막?.됐나 ? 마지막.무엇 : null;
101
+ const 한줄 = { 파일, 더한줄: d.added ?? 0, 뺀줄: d.removed ?? 0, 고친횟수: d.times ?? 0, 증명 };
102
+ 바꾼것.push(한줄);
103
+ if (!증명) {
104
+ 증명안된것.push({
105
+ 파일,
106
+ 왜: 왜못믿나(돌린것, 마지막, 언제고쳤나),
107
+ });
108
+ }
109
+ }
110
+
111
+ return {
112
+ 바꾼것,
113
+ 돌린것,
114
+ 증명안된것,
115
+ // 화면이 한눈에 쓸 수 있게 미리 세어 둔다.
116
+ 셈: {
117
+ 파일: 바꾼것.length,
118
+ 더한줄: 바꾼것.reduce((a, x) => a + x.더한줄, 0),
119
+ 뺀줄: 바꾼것.reduce((a, x) => a + x.뺀줄, 0),
120
+ 돌린것: 돌린것.length,
121
+ 실패한것: 돌린것.filter((x) => !x.됐나).length,
122
+ 증명안됨: 증명안된것.length,
123
+ },
124
+ };
125
+ }
126
+
127
+ function 왜못믿나(돌린것, 마지막, 언제고쳤나) {
128
+ if (!돌린것.length) return '고친 뒤에 아무것도 안 돌렸습니다 — 무엇도 확인되지 않았습니다.';
129
+ const 확인시도 = 돌린것.filter((x) => x.확인인가);
130
+ if (!확인시도.length) return '명령은 돌렸지만 검사·빌드는 안 돌렸습니다.';
131
+ if (마지막 && !마지막.됐나) {
132
+ return `마지막으로 돌린 \`${마지막.무엇}\` 이 실패했습니다 — 그 앞이 통과했어도 지금 상태를 증명하지 못합니다.`;
133
+ }
134
+ if (!마지막 && 언제고쳤나 != null) return '검사가 이 파일을 고치기 전에 돌았습니다 — 그 뒤 바뀐 것은 확인되지 않았습니다.';
135
+ return '확인되지 않았습니다.';
136
+ }
137
+
138
+ /** 사람이 읽고 남길 수 있는 글. 마크다운. */
139
+ export function 증거글(증거, { 제목 = '작업 증거' } = {}) {
140
+ const e = 증거 ?? {};
141
+ const 줄 = [`# ${제목}`, ''];
142
+
143
+ 줄.push('## 바꾼 것', '');
144
+ if (!e.바꾼것?.length) 줄.push('바꾼 파일이 없습니다.', '');
145
+ else {
146
+ 줄.push('| 파일 | 더함 | 뺌 | 확인한 것 |', '|---|---|---|---|');
147
+ for (const x of e.바꾼것) {
148
+ 줄.push(`| ${x.파일} | +${x.더한줄} | -${x.뺀줄} | ${x.증명 ? `\`${x.증명}\`` : '**없음**'} |`);
149
+ }
150
+ 줄.push('');
151
+ }
152
+
153
+ 줄.push('## 돌린 것', '');
154
+ if (!e.돌린것?.length) 줄.push('돌린 명령이 없습니다.', '');
155
+ else {
156
+ for (const x of e.돌린것) {
157
+ 줄.push(`- ${x.됐나 ? '✓' : '✗'} \`${x.무엇}\`${x.남긴말 ? ` — ${x.남긴말}` : ''}`);
158
+ }
159
+ 줄.push('');
160
+ }
161
+
162
+ // 이 절이 이 글의 요점이다. 비어 있으면 비어 있다고 분명히 적는다.
163
+ 줄.push('## 증명 안 된 것', '');
164
+ if (!e.증명안된것?.length) 줄.push('없습니다 — 바꾼 것마다 뒤에 돌린 확인이 있습니다.', '');
165
+ else {
166
+ for (const x of e.증명안된것) 줄.push(`- **${x.파일}** — ${x.왜}`);
167
+ 줄.push('');
168
+ }
169
+
170
+ return 줄.join('\n');
171
+ }
172
+
173
+ /**
174
+ * 파일로 남긴다. 화면은 스크롤로 사라지고, 검토는 나중에 다른 사람이 한다.
175
+ *
176
+ * @returns {string|null} 적은 자리. 못 적었으면 null — 못 적어도 대화는 계속된다.
177
+ */
178
+ export function 증거적기(root, 증거, 이름 = '증거') {
179
+ try {
180
+ const dir = join(root, '.deel', '증거');
181
+ mkdirSync(dir, { recursive: true });
182
+ const 자리 = join(dir, `${String(이름).replace(/[^\w가-힣.-]/g, '_')}.md`);
183
+ writeFileSync(자리, 증거글(증거), 'utf8');
184
+ return 자리;
185
+ } catch { return null; }
186
+ }
@@ -1,3 +1,4 @@
1
+ import { 언어 } from '../i18n/index.js';
1
2
  /**
2
3
  * 모델 급 — 지금 붙어 있는 모델이 얼마나 하는가.
3
4
  *
@@ -194,7 +195,26 @@ export function 값(급) {
194
195
  export function 급말(급) {
195
196
  const v = 값(급);
196
197
  if (급 === '큼') return '';
198
+ /*
199
+ * 이 글도 모델이 읽는 것이라 화면 말을 따라간다.
200
+ *
201
+ * 안 따라가면 영어로 켠 사람의 작은 모델만 한국어 지시를 받는데, 하필
202
+ * 그 조합이 제일 흔들리기 쉽다 — 작은 모델은 못 읽는 말이 섞이면 그 줄을
203
+ * 통째로 흘린다. 지시가 있는 척하면서 실제로는 없는 상태가 된다.
204
+ */
205
+ const 영 = 언어() === 'en';
197
206
  const 줄 = [];
207
+ if (영) {
208
+ 줄.push('Sized to this model:');
209
+ 줄.push(`- Create at most ${v.한번에쓸파일} files in one Write call. More than that, split it across calls.`);
210
+ 줄.push(`- For a file over ${v.나눠쓰기줄} lines, Write the first part and Append the rest.`);
211
+ if (급 === '작음') {
212
+ 줄.push('- One thing per step. Do not call several tools at once.');
213
+ 줄.push('- Edit a file on the **very next step** after you Read it. Do not put anything in between.');
214
+ }
215
+ 줄.push('- Call Verify before you finish. Fix what comes back and call it again.');
216
+ return 줄.join('\n');
217
+ }
198
218
  줄.push('이 모델에 맞춘 것:');
199
219
  줄.push(`- 파일을 한 번에 만들 때는 ${v.한번에쓸파일}개까지만. 더 있으면 나눠서 여러 번 불러라.`);
200
220
  줄.push(`- ${v.나눠쓰기줄}줄이 넘는 파일은 Write 로 앞부분만 만들고 Append 로 이어 붙여라.`);
package/src/agent/loop.js CHANGED
@@ -10,7 +10,9 @@ import { compact, shouldCompact, shouldFold, foldToolResults } from './compact.j
10
10
  import { 걸음수, 하위걸음수, 요약길이 } from './budget.js';
11
11
  import { Session } from './session.js';
12
12
  import { 최대깊이, 하위모드, 하위요약 } from '../tools/task.js';
13
- import { isOffline } from '../safety/network.js';
13
+ import { 프로필찾기, 쓸수있나, 연결만들기, 알릴말, 목록보기 } from './models.js';
14
+ import { allowTemporarily, isOffline } from '../safety/network.js';
15
+ import { 가리기, 훑기, 가렸다는말, 봤다는말, 가릴도구 } from '../safety/secrets.js';
14
16
  import { get as workMode } from './modes.js';
15
17
 
16
18
  /**
@@ -57,16 +59,20 @@ export function 묶기(calls) {
57
59
  }
58
60
 
59
61
  export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 } = {}) {
60
- session.push({ role: 'user', content: userText });
61
- ctx.audit.turn(깊이 ? `[하위작업 ${깊이}겹] ${userText}` : userText);
62
62
  /*
63
63
  * 되돌리기 턴은 **부모만** 연다.
64
64
  *
65
65
  * 하위 작업이 제 턴을 열면 `/undo` 한 번이 하위가 만든 것만 되돌리고
66
66
  * 부모가 만든 것은 남긴다 — 반쪽만 되돌아간 폴더가 된다. 사람 눈에는
67
67
  * 한 번 시킨 일이니 한 번에 되돌아가야 맞다.
68
+ *
69
+ * 턴을 **사람 말을 넣기 전에** 연다. 그래야 그 말 자체가 이 턴의 첫 말로
70
+ * 표시되고, 되감을 때 시킨 말까지 같이 걷힌다. 시킨 말만 남으면 모델은
71
+ * 되돌린 일을 또 하려 든다.
68
72
  */
69
- if (!깊이) ctx.history.nextTurn();
73
+ if (!깊이) session.턴시작(ctx.history.nextTurn());
74
+ session.push({ role: 'user', content: userText });
75
+ ctx.audit.turn(깊이 ? `[하위작업 ${깊이}겹] ${userText}` : userText);
70
76
 
71
77
  /*
72
78
  * 중단 신호를 도구도 볼 수 있게 여기 걸어 둔다.
@@ -95,7 +101,15 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
95
101
  * '헛도는 작업' 을 구분하지 못한다. 여기서는 같은 자리를 반복하는 것만 본다.
96
102
  */
97
103
  const 막힘 = new Map();
98
- const MAX_SAME = 3;
104
+ /*
105
+ * 몇 번까지 봐 줄까는 **이 모델을 겪어 본 만큼** 정한다 (agent/card.js).
106
+ *
107
+ * 되풀이가 버릇인 모델에서는 세 번까지 기다릴 이유가 없다. 되풀이한 만큼
108
+ * 컨텍스트가 차고 그만큼 요약이 빨리 온다 — 걸음만 태우는 것이 아니다.
109
+ * 겪은 것이 모자라면 여태 쓰던 3 그대로다.
110
+ */
111
+ const 이카드 = ctx?.카드 ?? null;
112
+ const MAX_SAME = 이카드?.조정?.같은것한계 ?? 3;
99
113
  let 멈출까 = null;
100
114
  const 막힘셈 = (call, 이유) => {
101
115
  // 파일 하나가 아니라 **그 파일** 을 센다. 이름만 세면 서로 다른 파일 세 개를
@@ -194,6 +208,9 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
194
208
  work: session.effectiveWork(), // 작업 모드가 쓰는 것만 (modes.js)
195
209
  // 밖에서 붙인 도구(MCP). 붙은 것이 없으면 아무것도 안 는다.
196
210
  mcp: ctx.mcp ?? null,
211
+ // 이 자리에 언어 서버가 있을 때만 Def·Refs 를 보여 준다 (tools/lsp.js).
212
+ // 없는 자리에서 목록에 세워 두면 모델이 부르고, 실패를 받고, 또 부른다.
213
+ lsp: session.lsp === true,
197
214
  // 창이 좁으면 도구 설명을 줄여 싣는다 (budget.js 의 설명길이).
198
215
  // 도구를 빼는 게 아니라 설명만 줄이므로 할 수 있는 일은 안 달라진다.
199
216
  ctx: conn.ctx ?? null,
@@ -248,7 +265,16 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
248
265
  // 출력 상한을 아는 값이 둘 있다 — 사용자가 정한 것(/out)과 서버에서 알아낸 것.
249
266
  // 사람이 정한 것이 먼저다. 둘 다 없으면 null 이고, 그때만 effort.js 의 MAX_CAP 에 선다.
250
267
  const room = { ctx: conn.ctx ?? 0, used: session.breakdown().used, max: conn.maxTokens ?? conn.maxOut ?? null };
251
- const cap = tokensFor(effort, stage, room);
268
+ /*
269
+ * 이 모델이 인자를 자주 잘라 먹는다면 처음부터 넉넉히 준다 (agent/card.js).
270
+ *
271
+ * 지금은 잘린 **뒤에야** 상한을 올려 다시 부른다. 한 번은 반드시 버리는 셈이다.
272
+ * 잘리는 것이 이 모델의 버릇으로 재어졌다면 그 한 번을 매번 버릴 이유가 없다.
273
+ * 상한은 최댓값이지 정해진 길이가 아니라서, 짧게 답하는 턴에는 값이 안 든다.
274
+ */
275
+ const cap = 이카드?.조정?.상한먼저올리기
276
+ ? Math.max(tokensFor(effort, stage, room), fullCap(room))
277
+ : tokensFor(effort, stage, room);
252
278
  yield { type: 'stage', stage, level, cap, step: steps };
253
279
 
254
280
  const ask = (maxTokens, think) => ({
@@ -592,7 +618,43 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
592
618
  }
593
619
 
594
620
  const 자식모드 = 하위모드(call.args?.모드 ?? call.args?.mode, 모드.id);
595
- const 자식 = new Session(conn, {
621
+
622
+ /*
623
+ * ── 다른 모델에게 떼어 주기 ─────────────────────────────────────
624
+ *
625
+ * 안 적으면 지금 쓰는 것 그대로다. 적었으면 **사람이 설정에 적어 둔
626
+ * 프로필**에서만 찾는다 — 모델이 주소를 지어내면 그 자리로 나갈 뻔하고,
627
+ * 그건 이 프로그램이 하는 약속을 그 자리에서 깨는 것이다.
628
+ *
629
+ * 못 찾으면 조용히 지금 모델로 돌지 않는다. 시킨 쪽은 작은 모델에게
630
+ * 넘긴 줄 알고 창을 아끼고 있는데 실제로는 큰 모델이 다 받는 셈이라,
631
+ * 원인을 못 찾는 쪽으로 어긋난다. 무엇이 있는지 적어 되돌려 준다.
632
+ */
633
+ let 자식conn = conn;
634
+ let 자리닫기 = null;
635
+ let 모델알림 = null;
636
+ const 부른모델 = String(call.args?.모델 ?? call.args?.model ?? '').trim();
637
+ if (부른모델) {
638
+ const 찾음 = 프로필찾기(부른모델);
639
+ if (!찾음.ok) {
640
+ const 있는것 = 목록보기().map((x) => x.id).join(' · ') || '(설정에 프로필이 없습니다)';
641
+ 거절(call, `${찾음.why}. 쓸 수 있는 이름: ${있는것}`);
642
+ yield { type: 'tool', name: 'Task', args: call.args, result: { error: 찾음.why }, showLabel: true };
643
+ continue;
644
+ }
645
+ const 되나 = 쓸수있나(찾음.prof);
646
+ if (!되나.ok) {
647
+ 거절(call, `${부른모델} 은 지금 못 씁니다 — ${되나.why}`);
648
+ yield { type: 'tool', name: 'Task', args: call.args, result: { error: 되나.why }, showLabel: true };
649
+ continue;
650
+ }
651
+ 자식conn = 연결만들기(찾음.prof);
652
+ 모델알림 = 알릴말(conn, 자식conn);
653
+ // 그 일이 도는 동안만 연다. 끝나면 반드시 닫는다 — 아래 finally.
654
+ if (모델알림.다른자리) 자리닫기 = allowTemporarily(자식conn.base);
655
+ }
656
+
657
+ const 자식 = new Session(자식conn, {
596
658
  root: session.root,
597
659
  // 승인 방식은 그대로 물려준다. 하위가 승인을 우회하면 strict 가 거짓말이 된다.
598
660
  mode: session.mode,
@@ -604,7 +666,9 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
604
666
  // 부모보다 적게 준다 (budget.js). 사람이 직접 정한 값이 있으면 그 절반.
605
667
  maxSteps: session.stepsSet
606
668
  ? Math.max(4, Math.floor(session.maxSteps / 2))
607
- : 하위걸음수(자식모드, conn.ctx),
669
+ // 걸음 수는 **하위가 쓸 창**으로 잰다. 부모 창으로 재면, 작은 모델에게
670
+ // 떼어 준 일이 제 창보다 큰 걸음 수를 받아 중간에 창이 찬 채로 돈다.
671
+ : 하위걸음수(자식모드, 자식conn.ctx),
608
672
  });
609
673
  // 스킬·명령·기억은 부모가 켤 때 한 번 찾아 든 것이다. 하위도 같은 것을 본다.
610
674
  자식.skills = session.skills;
@@ -622,8 +686,19 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
622
686
  * 미리 +1 을 해 두면 두 겹째에서 1이 두 번 붙어 3이 된다.
623
687
  * 경계선은 부모 자리에 그어야 어디부터가 떼어 낸 일인지 보인다.
624
688
  */
625
- yield { type: 'task_start', 목적, 모드: 자식모드, steps: 자식.maxSteps };
626
- ctx.audit.tool('Task', { 목적, 모드: 자식모드 }, { summary: `하위 작업 시작 (${깊이 + 1}겹)` });
689
+ /*
690
+ * 다른 모델을 쓰면 **어디로 나가는지까지** 적는다.
691
+ *
692
+ * 조용히 여는 것이 제일 나쁘다. 상태줄의 ⌂ 는 이 세션의 연결을 보고
693
+ * 있어서 하위가 딴 데로 나가는 것을 모른다. 그러니 이 줄과 감사기록이
694
+ * 그 사실을 남기는 유일한 자리다.
695
+ */
696
+ yield {
697
+ type: 'task_start', 목적, 모드: 자식모드, steps: 자식.maxSteps,
698
+ 모델: 모델알림?.말 ?? null, 밖으로: 모델알림?.밖으로 ?? false,
699
+ };
700
+ ctx.audit.tool('Task', { 목적, 모드: 자식모드, 모델: 모델알림?.말 ?? null },
701
+ { summary: `하위 작업 시작 (${깊이 + 1}겹)${모델알림 ? ` · ${모델알림.말}` : ''}` });
627
702
 
628
703
  let 끝 = null;
629
704
  try {
@@ -647,6 +722,10 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
647
722
  } catch (err) {
648
723
  // 하위가 터져도 부모 턴은 살린다. 무엇 때문에 터졌는지는 요약에 실린다.
649
724
  끝 = { type: 'stuck', why: String(err?.message ?? err), steps: 0, files: [] };
725
+ } finally {
726
+ // 잠깐 열어 둔 자리는 무슨 일이 있어도 닫는다. 안 닫으면 그 세션이
727
+ // 끝날 때까지 그 주소가 열린 채로 남는다 — '한 자리만 연다' 가 깨진다.
728
+ try { 자리닫기?.(); } catch { /* 닫다 터져도 이번 턴은 이어간다 */ }
650
729
  }
651
730
 
652
731
  /*
@@ -664,13 +743,13 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
664
743
  session.usage.ms += 자식.usage.ms;
665
744
 
666
745
  const 글 = 하위요약({
667
- 목적, 모드: 자식모드, 끝,
746
+ 목적, 모드: 자식모드, 끝, 모델: 모델알림?.말 ?? null,
668
747
  글자수: 요약길이(conn.ctx),
669
748
  보인이름: (경로) => ctx.scope?.show?.(경로) ?? 경로,
670
749
  });
671
750
  session.push(toolMessage(conn.kind, { callId: call.id, name: 'Task', content: 글 }));
672
751
  ctx.audit.tool('Task', { 목적 }, { summary: 글.slice(0, 300) });
673
- yield { type: 'task_done', 목적, 모드: 자식모드, };
752
+ yield { type: 'task_done', 목적, 모드: 자식모드, 끝, 모델: 모델알림?.말 ?? null };
674
753
 
675
754
  // 사용자가 중단했으면 부모도 여기서 멈춘다. 하위만 끊고 이어가면
676
755
  // 무엇이 중단된 것인지 알 수 없는 화면이 된다.
@@ -760,12 +839,54 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0 }
760
839
  }
761
840
  }
762
841
 
842
+ /*
843
+ * ── 비밀 가리기 ─────────────────────────────────────────────────
844
+ *
845
+ * 여기가 도구 결과가 대화로 들어가는 **유일한** 자리다. 여기서 막으면
846
+ * 모델에게도 안 가고 `.deel/sessions/*.jsonl` 에도 안 적힌다. 한 번
847
+ * 새면 두 벌이 되는 것을 한 곳에서 끊는다.
848
+ *
849
+ * 새는 자리는 거의 항상 명령 출력이다 — env · git remote -v · curl -v ·
850
+ * 검사 실패 로그. 그래서 그런 도구만 가린다.
851
+ *
852
+ * 파일에서 읽어 온 글은 **안 가린다.** 가리면 모델이 가려진 글을 보고
853
+ * 그대로 되돌려 써서, 진짜 열쇠가 있던 자리에 표가 적힌다 — 비밀을
854
+ * 지키려다 비밀을 지우는 셈이다. 대신 무엇이 들어왔는지 알린다.
855
+ */
856
+ const 아는열쇠들 = [conn.key, process.env.DEEL_API_KEY].filter(Boolean);
857
+ let 비밀 = [];
858
+ if (가릴도구.has(call.name)) {
859
+ const 가린 = 가리기(실을것, { 열쇠들: 아는열쇠들 });
860
+ if (가린.가린것.length) {
861
+ 비밀 = 가린.가린것;
862
+ 실을것 = 가린.글 + 가렸다는말(가린.가린것);
863
+ }
864
+ } else if (result.content) {
865
+ // 안 고치고 보기만 한다. 사람이 알아야 손을 쓸 수 있다.
866
+ 비밀 = 훑기(실을것, { 열쇠들: 아는열쇠들 });
867
+ }
868
+ if (비밀.length) {
869
+ ctx.audit?.write?.('secret', {
870
+ tool: call.name,
871
+ 가렸나: 가릴도구.has(call.name),
872
+ 무엇: 비밀.map((x) => `${x.종류}×${x.몇번}`).join(' '),
873
+ });
874
+ }
875
+
763
876
  session.push(toolMessage(conn.kind, {
764
877
  callId: call.id,
765
878
  name: call.name,
766
879
  content: 실을것,
767
880
  }));
768
- yield { type: 'tool', name: call.name, args: call.args, result, ms, parallel: 함께 };
881
+ yield {
882
+ type: 'tool',
883
+ name: call.name,
884
+ args: call.args,
885
+ result,
886
+ ms,
887
+ parallel: 함께,
888
+ ...(비밀.length ? { 비밀: { 가렸나: 가릴도구.has(call.name), 말: 봤다는말(비밀) } } : {}),
889
+ };
769
890
  }
770
891
  }
771
892