deel-local-cli 1.7.0 → 1.10.0

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 (61) hide show
  1. package/README.ko.md +1166 -0
  2. package/README.md +1223 -1107
  3. package/bin/deel.js +78 -12
  4. package/package.json +4 -4
  5. package/src/acp/map.js +1 -1
  6. package/src/acp/serve.js +40 -3
  7. package/src/agent/compact.js +314 -296
  8. package/src/agent/effort.js +27 -7
  9. package/src/agent/evidence.js +2 -0
  10. package/src/agent/filemem.js +141 -0
  11. package/src/agent/grade.js +51 -1
  12. package/src/agent/loop.js +218 -40
  13. package/src/agent/session.js +136 -14
  14. package/src/agent/store.js +186 -9
  15. package/src/agent/threads.js +26 -1
  16. package/src/backend/adapter.js +272 -23
  17. package/src/backend/learn.js +24 -0
  18. package/src/backend/mcp.js +96 -5
  19. package/src/backend/probe.js +131 -61
  20. package/src/backend/quota.js +29 -5
  21. package/src/backend/retry.js +3 -0
  22. package/src/backend/scanui.js +1 -1
  23. package/src/backend/toolfit.js +325 -0
  24. package/src/commands.js +170 -61
  25. package/src/completion.js +20 -2
  26. package/src/config.js +47 -2
  27. package/src/i18n/en.js +210 -3
  28. package/src/i18n/index.js +40 -0
  29. package/src/i18n/ja.js +204 -3
  30. package/src/i18n/ko.js +270 -3
  31. package/src/i18n/zh.js +204 -3
  32. package/src/lsp/client.js +49 -5
  33. package/src/oneshot.js +92 -5
  34. package/src/pack/sbom.js +30 -4
  35. package/src/pack/selfpack.js +26 -10
  36. package/src/pack/sheet.en.js +288 -0
  37. package/src/pack/tar.js +65 -2
  38. package/src/plugins/manage.js +46 -9
  39. package/src/repl.js +344 -61
  40. package/src/reset.js +397 -0
  41. package/src/safety/audit.js +95 -8
  42. package/src/safety/authcmd.js +316 -0
  43. package/src/safety/guard.js +143 -0
  44. package/src/safety/keystore.js +114 -38
  45. package/src/safety/undo.js +25 -6
  46. package/src/tools/desc.en.js +19 -14
  47. package/src/tools/index.js +349 -75
  48. package/src/tools/jobs.js +175 -42
  49. package/src/tools/lsp.js +5 -4
  50. package/src/tools/outline.js +7 -3
  51. package/src/tools/task.js +10 -6
  52. package/src/tools/todo.js +9 -2
  53. package/src/tools/verify.js +42 -7
  54. package/src/tools/webfetch.js +94 -10
  55. package/src/ui/export.js +1 -1
  56. package/src/ui/md.js +201 -5
  57. package/src/ui/motion.js +0 -1
  58. package/src/ui/pastechip.js +50 -3
  59. package/src/ui/pick.js +115 -0
  60. package/src/ui/screen.js +23 -3
  61. package/README.en.md +0 -1117
package/src/repl.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // 대화 화면. 루프가 보내는 이벤트를 Claude Code 풍으로 그린다.
2
2
  import { createInterface, emitKeypressEvents } from 'node:readline';
3
- import { 규칙모으기 } from './safety/policy.js';
3
+ import { 규칙모으기, 정책읽기 } from './safety/policy.js';
4
+ import { 받기설정 } from './safety/authcmd.js';
4
5
  import { 주소가리기 } from './safety/secrets.js';
5
6
  import { homedir } from 'node:os';
6
7
  import { resolve, basename } from 'node:path';
@@ -8,7 +9,7 @@ import { c, say as 바로쓰기, mark, clip } from './ui/ansi.js';
8
9
  import { headerLines } from './ui/status.js';
9
10
  import { 종, 창제목, 제목되돌리기, 알릴까, 제목글 } from './ui/notify.js';
10
11
  import { 보이기 as 인트로, 기본곁말 } from './ui/intro.js';
11
- import { 언어잡기, 말 as 옮긴말 } from './i18n/index.js';
12
+ import { 언어잡기, 말 as 옮긴말, 세말 } from './i18n/index.js';
12
13
  import { 알림채움 } from './backend/retry.js';
13
14
  import { 화면고르기 } from './ui/screen.js';
14
15
  import { STAGES } from './agent/effort.js';
@@ -21,8 +22,8 @@ import { makeScope } from './safety/guard.js';
21
22
  import { 언어서버있나 } from './tools/index.js';
22
23
  import { 모두끄기 as 언어서버다끄기 } from './lsp/client.js';
23
24
  import { History } from './safety/undo.js';
24
- import { Audit } from './safety/audit.js';
25
- import { activeProfile, load, resolveKey, save as saveCfg, homeDir, 잠금소식 } from './config.js';
25
+ import { Audit, 열쇠묻기 } from './safety/audit.js';
26
+ import { activeProfile, load, resolveKey, save as saveCfg, homeDir, 잠금소식, 열쇠탈소식 } from './config.js';
26
27
  import { discover } from './skills/discover.js';
27
28
  import { allowEndpoint, setOffline, isOffline } from './safety/network.js';
28
29
  import { 지금모드, 바깥인가, 나갈수있나 } from './safety/runmode.js';
@@ -33,12 +34,13 @@ import { 못박기 } from './agent/pins.js';
33
34
  import { 카드 } from './agent/card.js';
34
35
  import { 배움 } from './agent/evolve.js';
35
36
  import { 마크다운 } from './ui/md.js';
36
- import { askHidden } from './ui/prompt.js';
37
+ import { askHidden, confirm } from './ui/prompt.js';
37
38
  import { explain, shows as levelShows } from './ui/level.js';
38
39
  import { 고르기 as 승인고르기, 다음 as 승인다음 } from './ui/approve.js';
39
40
  import { 추천, 채울글 } from './ui/complete.js';
40
41
  import { 접어쓰기 } from './ui/wrap.js';
41
- import { 접을까 as 붙임접을까, 표만들기 as 붙임표, 펼치기 as 붙임펼치기, 쓴번호들 as 붙임쓴번호들 } from './ui/pastechip.js';
42
+ import { 접을까 as 붙임접을까, 표만들기 as 붙임표, 펼치기 as 붙임펼치기, 쓴번호들 as 붙임쓴번호들, 남길글 } from './ui/pastechip.js';
43
+ import { 고른것풀기, 계획답풀기 } from './ui/pick.js';
42
44
  import { 이력지킴이 } from './ui/histline.js';
43
45
  import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
44
46
  import { renderDiff, shortStat } from './ui/diff.js';
@@ -70,7 +72,7 @@ const TOOL_GLYPH = {
70
72
  function toolLabel(name, args) {
71
73
  const a = args ?? {};
72
74
  const first =
73
- a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ?? a.목적 ??
75
+ a.file_path ?? a.pattern ?? a.path ?? a.url ?? a.name ?? a.purpose ?? a.목적 ??
74
76
  (a.command ? String(a.command).replace(/\s+/g, ' ').slice(0, 52) : null) ??
75
77
  // 뒤에서 도는 명령. 번호가 곧 그 일감의 이름이다 — 빈 괄호를 띄우면
76
78
  // 어느 것을 보고 있는지가 화면에서 사라진다. 서너 개를 띄워 놓고 나면
@@ -79,27 +81,27 @@ function toolLabel(name, args) {
79
81
  //
80
82
  // 이름 고르기는 jobs.js 에 맡긴다. 모델이 `job` 으로 보낼 수도 있는데,
81
83
  // 여기서 `a.번호` 만 보면 도구는 제대로 도는데 화면만 빈 괄호가 된다.
82
- (() => { const g = 일감인자(a); return g.번호 != null ? `${g.번호}번${g.끝내기 ? ' · 끝내기' : ''}` : null; })() ??
84
+ (() => { const g = 일감인자(a); return g.번호 != null ? `${옮긴말('job.no', { n: g.번호 })}${g.끝내기 ? ` · ${옮긴말('job.stop')}` : ''}` : null; })() ??
83
85
  // 한 번에 여러 파일을 쓸 때. 빈 괄호를 띄우면 화면만 보고는 무엇을
84
86
  // 만들었는지 알 수 없다 — 첫 파일과 개수를 적는다.
85
87
  (Array.isArray(a.files) && a.files.length
86
- ? `${a.files[0]?.file_path ?? '?'}${a.files.length > 1 ? ` ${a.files.length - 1}개` : ''}`
88
+ ? `${a.files[0]?.file_path ?? '?'}${a.files.length > 1 ? ` ${옮긴말('more.files', { n: a.files.length - 1 })}` : ''}`
87
89
  : null) ??
88
90
  // 한 번에 여러 군데를 고칠 때. 파일 수와 군데 수는 다르다 — 한 파일을
89
91
  // 여섯 군데 고치는 것이 보통이라 '외 5개' 라고 적으면 거짓이 된다.
90
92
  (Array.isArray(a.edits) && a.edits.length
91
- ? `${a.edits[0]?.file_path ?? '?'}${a.edits.length > 1 ? ` ${a.edits.length - 1}군데` : ''}`
93
+ ? `${a.edits[0]?.file_path ?? '?'}${a.edits.length > 1 ? ` ${옮긴말('more.spots', { n: a.edits.length - 1 })}` : ''}`
92
94
  : null) ??
93
- (Array.isArray(a.paths) && a.paths.length ? `${a.paths.length}개` : null) ??
95
+ (Array.isArray(a.paths) && a.paths.length ? 세말('count', a.paths.length) : null) ??
94
96
  // 할 일 목록은 보여줄 경로가 없다. 빈 괄호를 띄우느니 개수를 적는다.
95
- (Array.isArray(a.todos) ? `${a.todos.length}건` : null) ?? '';
97
+ (Array.isArray(a.todos) ? 세말('hits', a.todos.length) : null) ?? '';
96
98
  const g = TOOL_GLYPH[name] ?? c.cyan('⏺');
97
99
  const 안 = clip(String(first ?? ''), 56);
98
100
  return `${g} ${c.bold(name)}${안 ? `${c.gray('(')}${c.gray(안)}${c.gray(')')}` : ''}`;
99
101
  }
100
102
 
101
103
  function toolResultLine(result, ms) {
102
- const t = ms > 700 ? c.gray(` ${(ms / 1000).toFixed(1)}초`) : '';
104
+ const t = ms > 700 ? c.gray(` ${옮긴말('unit.sec', { n: (ms / 1000).toFixed(1) })}`) : '';
103
105
  if (result?.error) return `${c.red('└')} ${c.red(clip(String(result.error).split('\n')[0], 80))}${t}`;
104
106
 
105
107
  /*
@@ -111,16 +113,16 @@ function toolResultLine(result, ms) {
111
113
  */
112
114
  if (typeof result?.탈 === 'number') {
113
115
  const 조각 = [];
114
- if (result.탈) 조각.push(c.red(`탈 ${result.탈}개`));
115
- if (result.확인됨) 조각.push(c.green(`확인 ${result.확인됨}개`));
116
- if (result.못확인) 조각.push(c.yellow(`못 확인 ${result.못확인}개`));
116
+ if (result.탈) 조각.push(c.red(옮긴말('sum.broken', { n: result.탈 })));
117
+ if (result.확인됨) 조각.push(c.green(옮긴말('sum.verified', { n: result.확인됨 })));
118
+ if (result.못확인) 조각.push(c.yellow(옮긴말('sum.unverified', { n: result.못확인 })));
117
119
  return `${result.탈 ? c.red('└') : c.gray('└')} `
118
- + `${조각.join(c.gray(' · ')) || c.gray('확인할 것이 없었습니다')}${t}`;
120
+ + `${조각.join(c.gray(' · ')) || c.gray(옮긴말('sum.nothingToCheck'))}${t}`;
119
121
  }
120
122
 
121
123
  // 고친 자리는 몇 줄이 늘고 줄었는지를 요약 옆에 붙인다.
122
124
  const 셈 = result?.diff ? ` ${shortStat(result.diff)}` : '';
123
- return `${c.gray('└')} ${c.gray(clip(result?.summary ?? '완료', 80))}${셈}${t}`;
125
+ return `${c.gray('└')} ${c.gray(clip(result?.summary ?? 옮긴말('sum.done'), 80))}${셈}${t}`;
124
126
  }
125
127
 
126
128
  // 수준별로 몇 줄까지 펼칠지. 초보에게 60줄을 쏟으면 아무것도 안 읽는다.
@@ -166,6 +168,22 @@ export async function chatLoop(opts = {}) {
166
168
  바로쓰기(` ${mark.ok} ${잠금}`);
167
169
  }
168
170
 
171
+ /*
172
+ * 잠근 열쇠를 못 풀었으면 **그 까닭을** 적는다.
173
+ *
174
+ * 이걸 안 하면 빈 열쇠가 그대로 나가서 Authorization 머리말이 아예 안 붙고,
175
+ * 사람은 401 만 보고 방화벽·주소·계정을 뒤진다. 진짜 까닭은 대개
176
+ * 「설정 파일을 다른 PC 로 옮겼다」 이고, 그건 우리만 알고 있다.
177
+ *
178
+ * 아래 conn 을 짓는 자리에서 resolveKey 가 불리므로, 읽는 것은 그 뒤여야 한다.
179
+ */
180
+ const 열쇠탈보이기 = () => {
181
+ const 탈 = 열쇠탈소식();
182
+ if (!탈) return;
183
+ 바로쓰기('');
184
+ 바로쓰기(` ${mark.warn} ${탈}`);
185
+ };
186
+
169
187
  /*
170
188
  * 여기서부터 화면에 나가는 것은 전부 `화면` 을 거친다.
171
189
  *
@@ -185,6 +203,14 @@ export async function chatLoop(opts = {}) {
185
203
  const conn = {
186
204
  kind: prof.kind, base: prof.baseUrl, auth: prof.auth,
187
205
  key: resolveKey(prof), model: prof.model,
206
+ /*
207
+ * 열쇠를 갖고 있는 대신 **받아 오는** 설정 (safety/authcmd.js).
208
+ *
209
+ * 여기서는 설정만 싣는다. 명령은 요청 직전에 부른다 — 켤 때 한 번
210
+ * 받아 두면 한 시간짜리 토큰이 세 시간짜리 대화 한복판에서 죽고,
211
+ * 그 401 은 화면에서 「열쇠가 틀렸다」 와 구별이 안 된다.
212
+ */
213
+ 열쇠받기: 받기설정(prof, { 정책값: 정책읽기().값 }),
188
214
  // 컨텍스트 길이. 순서가 곧 우선순위다 —
189
215
  // deel --ctx 655360 > 프로필에 저장된 값 > 기본값
190
216
  // 기본값으로 떨어졌다는 것은 '아직 못 쟀다' 는 뜻이다. 아래에서 그렇다고 말해 준다.
@@ -198,6 +224,9 @@ export async function chatLoop(opts = {}) {
198
224
  vision: prof.vision ?? false,
199
225
  };
200
226
 
227
+ // conn 을 짓느라 resolveKey 가 방금 불렸다. 못 푼 것이 있으면 여기서 말한다.
228
+ 열쇠탈보이기();
229
+
201
230
  /*
202
231
  * ── 주소와 허가를 뗀다 ───────────────────────────────────────────────
203
232
  *
@@ -267,9 +296,49 @@ export async function chatLoop(opts = {}) {
267
296
  * 여기서 한 번만 읽는다. 상태줄은 글자를 칠 때마다 다시 그려지는데,
268
297
  * 그때마다 설정 파일을 열면 디스크를 초당 수십 번 두드리게 된다.
269
298
  */
270
- session.요금표 = cfg?.요금 ?? null;
299
+ // `pricing` 으로 적어도 받는다 — 설정은 사람이 손으로 적는 것이라, 한글
300
+ // 자판이 없는 사람이 못 적는 이름 하나에 기능이 통째로 막히면 안 된다.
301
+ session.요금표 = cfg?.요금 ?? cfg?.pricing ?? null;
271
302
  session.제공자 = prof?.제공자 ?? null;
272
303
 
304
+ /*
305
+ * ── 열쇠를 받아 오는 연결 (safety/authcmd.js) ───────────────────────
306
+ *
307
+ * 두 자리를 여기서 잇는다.
308
+ *
309
+ * 묻기 — 우리가 남이 적어 준 명령을 띄우려는 참이다. 한 번은 물어야
310
+ * 한다. 다만 **한 판에 한 번만** 묻는다 (authcmd 가 기억한다) —
311
+ * 한 시간짜리 토큰으로 세 시간 일하면 세 번 받아 오는데, 세 번
312
+ * 다 물으면 사람은 손이 가는 대로 누른다. 관리 정책이 준
313
+ * 명령은 아예 안 묻는다.
314
+ * 표시 — 사내 로그인은 브라우저를 열고 사람을 기다린다. 그동안 화면이
315
+ * 조용하면 사람은 멈춘 줄 안다. 무엇을 기다리는지 적는다.
316
+ */
317
+ if (conn.열쇠받기) {
318
+ session.열쇠물어보기 = async (설정) => {
319
+ say('');
320
+ say(` ${mark.warn} ${c.gray('이 연결은 열쇠를 받아 옵니다. 아래 명령을 띄웁니다:')}`);
321
+ say(` ${c.white(clip(설정.명령, 88))}`);
322
+ say(` ${c.gray('받은 열쇠는 이 판이 도는 동안 메모리에만 있고, 파일로 안 적습니다.')}`);
323
+ const 예 = await confirm(' 띄울까요?', true);
324
+ say('');
325
+ return 예;
326
+ };
327
+ session.onAuth = (것) => {
328
+ if (것?.type === '시작') return 화면.돌리기(옮긴말('auth.waiting'));
329
+ if (것?.type === '끝') return 화면.돌림멈춤('');
330
+ 화면.돌림멈춤('');
331
+ if (것?.ok) {
332
+ const 초 = Math.max(0, Math.round((것.만료 - Date.now()) / 1000));
333
+ return say(` ${mark.ok} ${c.gray(옮긴말('auth.got', { 분: Math.round(초 / 60) }))}`);
334
+ }
335
+ // 못 받았어도 대화를 안 끊는다. 원래 열쇠로 가 보고, 그것도 안 되면
336
+ // 서버가 한 말이 화면에 나온다. 여기서 끊으면 무엇이 문제인지 못 본다.
337
+ say(` ${mark.warn} ${c.gray(옮긴말('auth.failed', { 왜: 것?.왜 ?? '?' }))}`);
338
+ if (것?.보인것) say(` ${c.gray(clip(String(것.보인것), 88))}`);
339
+ };
340
+ }
341
+
273
342
  // ── 대화 이어하기 ─────────────────────────────────────────────────────
274
343
  // 껐다 켜도 이어지도록, 메시지가 오갈 때마다 .deel/sessions/ 에 바로 적는다.
275
344
  let store = null;
@@ -320,7 +389,7 @@ export async function chatLoop(opts = {}) {
320
389
  */
321
390
  const mcp붙임 = await 다붙이기(root, {
322
391
  offline: isOffline(),
323
- audit: new Audit(root),
392
+ audit: new Audit(root, { 열쇠들: 열쇠묻기(conn) }),
324
393
  });
325
394
 
326
395
  // 이 PC 에 있는 스킬·명령·플러그인을 찾아 붙인다. 품고 다니지 않는다.
@@ -565,7 +634,7 @@ export async function chatLoop(opts = {}) {
565
634
  * 백틱으로 이어 쓴 경우에는 앞줄이 이미 찍혀 있으므로 마지막 줄만
566
635
  * 이어 찍는다 — 안 그러면 같은 줄이 두 번 보인다.
567
636
  */
568
- const 찍을것 = 이어쓴것 ? l : 보낼것;
637
+ const 찍을것 = 남길글(이어쓴것 ? l : 보낼것, 붙인것들);
569
638
  if (찍을것.trim() || 이었나) {
570
639
  for (const [i, 한줄] of String(찍을것).split('\n').entries()) {
571
640
  say(`${i === 0 && !이어쓴것 ? ` ${c.hcyan('❯')} ` : ' '}${c.white(한줄)}`);
@@ -712,7 +781,23 @@ export async function chatLoop(opts = {}) {
712
781
  const 붙인것 = 붙인줄들 ? 펴기(붙인줄들.join('\n')) : '';
713
782
  const 폭 = Math.max(20, (process.stdout.columns ?? 80) - 8);
714
783
 
715
- if (붙인줄들 && 입력기다림 && 묻는중 === null && 붙임접을까(붙인것, { 폭 })) {
784
+ /*
785
+ * ── 일하는 도중에 붙여도 접는다 ──────────────────────────────────
786
+ *
787
+ * 여태 이 갈래는 `입력기다림` 일 때만 돌았다. 그런데 붙여넣기는
788
+ * **일하는 도중이 더 흔하다** — 몇 분짜리 턴이 도는 동안 다음에
789
+ * 시킬 것을 미리 붙여 두는 것이 그것이다.
790
+ *
791
+ * 그때 무슨 일이 났나. 붙인 마흔 줄 중 마지막 한 줄만 rl.line 에
792
+ * 남고 나머지는 상자 밖 버퍼로 간다. 일하는 중 화면(대기갱신)은
793
+ * rl.line 만 그리므로, 사람 눈에는 **한 줄만 들어간 것**으로 보인다.
794
+ * 그래서 다시 붙인다. 이번엔 진짜로 두 번 들어간다 — 접기가 애초에
795
+ * 막으려던 바로 그 사고가, 기다릴 때는 안 나고 일할 때만 났다.
796
+ *
797
+ * 펴는 자리는 그대로다(아래 붙임펼치기). 어느 길로 보내든 원문이
798
+ * 간다 — 접는 자리만 넓혔지 되돌리는 길은 안 건드렸다.
799
+ */
800
+ if (붙인줄들 && 묻는중 === null && 붙임접을까(붙인것, { 폭 })) {
716
801
  const 번호 = ++붙임번호;
717
802
  붙인것들.set(번호, 붙인것);
718
803
  이어쓰기줄들 = 붙임앞줄수 ? (이어쓰기줄들 ?? []).slice(0, 붙임앞줄수) : null;
@@ -720,13 +805,22 @@ export async function chatLoop(opts = {}) {
720
805
  const 표 = 붙임표(번호, 붙인것);
721
806
  rl.line = 앞머리 + 표;
722
807
  rl.cursor = rl.line.length;
723
- 화면.입력지움();
724
- say(` ${c.gray(`${표} — 접어 뒀습니다. 보낼 때 그대로 펴집니다.`)}`);
808
+ if (입력기다림) {
809
+ 화면.입력지움();
810
+ say(` ${c.gray(옮긴말('paste.folded', { 표 }))}`);
811
+ } else {
812
+ /*
813
+ * 일하는 중에는 대화에 안 찍는다. 찍으면 이미 보낸 것처럼 보인다
814
+ * (아래 미리 쳐 둔 것 갈래와 같은 까닭이다). 일감 상자에 지금
815
+ * 들고 있는 것으로 그려 주면 그것으로 족하다.
816
+ */
817
+ 화면.대기갱신(펴기(rl.line), queue.length);
818
+ }
725
819
  } else if (이어쓰기줄들?.length && 입력기다림) {
726
820
  // 접지 않은 덩이는 몇 줄인지라도 알려 준다. 안 보이면 사라진 줄
727
821
  // 알고 다시 붙인다.
728
822
  화면.입력지움();
729
- say(` ${c.gray(`${이어쓰기줄들.length + 1}줄을 붙여넣었습니다 — Enter 로 한 번에 보냅니다`)}`);
823
+ say(` ${c.gray(옮긴말('paste.pasted', { n: 이어쓰기줄들.length + 1 }))}`);
730
824
  }
731
825
  화면.입력갱신?.(session, 펴기(rl.line), rl.cursor ?? 0);
732
826
  return;
@@ -741,8 +835,25 @@ export async function chatLoop(opts = {}) {
741
835
  * ESC 는 **멈추기만** 한다. 끝내지 않는다. 그래서 마음 놓고 누를 수 있다.
742
836
  * 도는 중이 아닐 때는 아무것도 안 한다 — 입력칸에서 ESC 를 눌렀다고
743
837
  * 치던 글이 날아가면 그게 더 놀랍다.
838
+ *
839
+ * ── meta 를 안 본다 ──────────────────────────────────────────────
840
+ *
841
+ * 여기 `!key.meta` 가 붙어 있었다. 그래서 이 갈래는 **한 번도 안
842
+ * 돌았다** — 사람이 ESC 를 눌러도 아무 일이 안 났다.
843
+ *
844
+ * 홑 ESC 는 그 자체가 escape 열쇠의 시작 바이트이기도 해서, Node 의
845
+ * readline 은 뒤에 뭐가 더 올지 500ms 를 기다렸다가 아무것도 안 오면
846
+ * `{ name:'escape', meta:true }` 로 배달한다. 즉 **ESC 는 언제나
847
+ * meta 가 참**이다. 직접 재어 봤다 —
848
+ *
849
+ * \x1b → {"name":"escape","meta":true}
850
+ * \x1b\x1b → {"name":"escape","meta":true}
851
+ *
852
+ * 그러니 meta 를 보고 거르면 ESC 를 통째로 거르는 셈이다. 이름이
853
+ * escape 인 것에 Ctrl·Shift 가 붙는 조합은 터미널이 아예 안 보내므로
854
+ * 나머지 둘만 본다.
744
855
  */
745
- if (key?.name === 'escape' && !key.ctrl && !key.meta && !key.shift) {
856
+ if (key?.name === 'escape' && !key.ctrl && !key.shift) {
746
857
  if (turn && !turn.signal.aborted) { turn.abort(); 멈추는중(); return; }
747
858
  return;
748
859
  }
@@ -880,12 +991,46 @@ export async function chatLoop(opts = {}) {
880
991
  });
881
992
  }
882
993
 
883
- const nextLine = () => {
994
+ /*
995
+ * 다음 줄을 기다린다. 멈춤신호를 주면 **기다리다가도 손을 뗀다.**
996
+ *
997
+ * 신호 없이 기다리는 자리(맨 위 입력칸)는 여태 그대로다 — 거기서 ESC 는
998
+ * 아무 일도 안 하는 것이 맞다. 신호를 주는 쪽은 turn 이 도는 동안 사람
999
+ * 답을 기다리는 자리뿐이다.
1000
+ */
1001
+ const nextLine = (멈춤 = null) => {
884
1002
  if (queue.length) return Promise.resolve(queue.shift());
885
1003
  if (closed) return Promise.resolve(null);
886
- return new Promise((res) => { waiter = res; });
1004
+ if (멈춤?.aborted) return Promise.resolve(null);
1005
+ return new Promise((res) => {
1006
+ waiter = res;
1007
+ if (!멈춤) return;
1008
+ /*
1009
+ * 기다리던 자리를 **반드시 비우고** 돌아간다.
1010
+ *
1011
+ * 안 비우면 다음에 사람이 친 줄이 이미 없어진 물음에게 배달되고,
1012
+ * 그 줄은 화면에서 그냥 사라진다 — 「쳤는데 안 먹었다」 가 된다.
1013
+ */
1014
+ 멈춤.addEventListener('abort', () => {
1015
+ if (waiter === res) waiter = null;
1016
+ res(null);
1017
+ }, { once: true });
1018
+ });
887
1019
  };
888
1020
 
1021
+ /*
1022
+ * ── 사람에게 되묻는 자리 ────────────────────────────────────────────────
1023
+ *
1024
+ * 여기도 ESC 로 손을 뗄 수 있어야 한다. 예전에는 이 자리가 **멈춤에서
1025
+ * 빠져 있었다** — 도구가 「실행할까요?」 를 띄운 채 답을 기다리면 ESC 를
1026
+ * 몇 번을 눌러도 아무 일도 안 났다. turn 은 이미 끊긴 상태인데 화면은
1027
+ * 물음을 그대로 들고 서 있으니, 사람은 Ctrl+C 로 손이 가고 두 번 누르면
1028
+ * 대화가 통째로 닫힌다. "ESC 를 눌러도 안 멈춘다" 는 제보의 한 갈래다.
1029
+ *
1030
+ * 멈췄을 때 무엇으로 답한 셈 칠지는 부르는 쪽이 정한다(`멈추면`). 기본값을
1031
+ * `def` 로 하면 안 된다 — confirm 의 def 는 'y' 라, 멈추라고 누른 것이
1032
+ * 승인으로 읽힌다.
1033
+ */
889
1034
  const ask = async (label, o = {}) => {
890
1035
  const 앞 = ` ${c.gray('›')} ${label} ${o.def ? c.gray(`[${o.def}] `) : ''}`;
891
1036
  화면.붙임(앞);
@@ -897,8 +1042,15 @@ export async function chatLoop(opts = {}) {
897
1042
  */
898
1043
  묻는중 = 상자쓰나 ? 앞 : null;
899
1044
  이어쓰기줄들 = null; 이어쓴것 = false; // 되묻는 사이에 걸쳐 있던 미완성 이어쓰기는 버린다
1045
+ const 멈춤 = o.signal ?? turn?.signal ?? null;
900
1046
  try {
901
- const a = await nextLine();
1047
+ const a = await nextLine(멈춤);
1048
+ if (멈춤?.aborted) {
1049
+ // 물음이 화면에 걸린 채로 남으면 안 된다. 줄을 끊고 멈췄다고 적는다.
1050
+ say('');
1051
+ say(` ${c.gray(옮긴말('ask.stopped'))}`);
1052
+ return o.멈추면 ?? '';
1053
+ }
902
1054
  if (a === null) return o.def ?? '';
903
1055
  return a.trim() || o.def || '';
904
1056
  } finally { 묻는중 = null; }
@@ -936,15 +1088,33 @@ export async function chatLoop(opts = {}) {
936
1088
  for (const f of files) {
937
1089
  const 이름 = ctx?.scope ? ctx.scope.show(f.path) : f.path;
938
1090
  if (f.missing) {
939
- say(` ${c.yellow('⚠')} ${c.white(이름)} ${c.gray('— 만들어지지 않았습니다')}`);
1091
+ say(` ${c.yellow('⚠')} ${c.white(이름)} ${c.gray(옮긴말('run.notMade'))}`);
940
1092
  continue;
941
1093
  }
942
1094
  if (f.dir) continue;
943
1095
  const kb = f.bytes >= 1024 ? `${(f.bytes / 1024).toFixed(1)}KB` : `${f.bytes}B`;
944
- say(` ${c.green('✓')} ${c.white(이름)} ${c.gray(`· ${f.lines.toLocaleString()} · ${kb}`)}`);
1096
+ say(` ${c.green('✓')} ${c.white(이름)} ${c.gray(`· ${세말('lines', f.lines)} · ${kb}`)}`);
945
1097
  }
946
1098
  };
947
1099
 
1100
+ /*
1101
+ * 시킨 것 중 자국이 하나도 안 남은 항목을 적는다.
1102
+ *
1103
+ * 조용히 넘어가면 사람은 다 된 줄 알고 그 위에 다음 것을 쌓는다. 나중에
1104
+ * 발견했을 때는 이미 늦다. 「몇 번 까먹거나 누락되거나」라는 제보가 정확히
1105
+ * 그 모양이었다 — 빠진 것 자체보다, 빠졌다는 말을 안 한 것이 문제다.
1106
+ *
1107
+ * 세 자리(done·limit·stuck)가 같은 글을 내야 한다. 한 군데만 고치면
1108
+ * 「끝났을 때는 알려 주는데 끊겼을 때는 안 알려 주는」 화면이 된다 —
1109
+ * 정작 빠질 확률은 끊겼을 때가 훨씬 높다.
1110
+ */
1111
+ const 빠진것보이기 = (빠진) => {
1112
+ if (!빠진?.length) return;
1113
+ say('');
1114
+ say(` ${c.yellow('!')} ${c.white(`시킨 것 중 ${빠진.length}가지가 안 다뤄졌습니다`)}`);
1115
+ for (const x of 빠진) say(` ${c.gray(`${x.번호}. ${clip(x.글, 68)}`)}`);
1116
+ };
1117
+
948
1118
  const ctx = {
949
1119
  scope: makeScope(root),
950
1120
  // 도구가 한 번에 돌려줄 양을 이 값에서 뽑는다 (agent/budget.js).
@@ -955,7 +1125,7 @@ export async function chatLoop(opts = {}) {
955
1125
  // 적어 둔 허락·금지 규칙 (safety/policy.js). 승인 모드보다 먼저 본다.
956
1126
  규칙들: 규칙모으기(cfg),
957
1127
  history: new History(root),
958
- audit: new Audit(root),
1128
+ audit: new Audit(root, { 열쇠들: 열쇠묻기(conn) }),
959
1129
  seen: new Set(),
960
1130
  // 붙은 MCP 서버. 도구를 부를 때 여기서 찾는다.
961
1131
  mcp: mcp붙임.서버들,
@@ -1099,10 +1269,21 @@ export async function chatLoop(opts = {}) {
1099
1269
  창제목(제목글('도는중', { 폴더: 알림.폴더 }));
1100
1270
 
1101
1271
  if (!답) return null;
1102
- // 숫자로 골랐으면 그 줄의 글을 그대로 돌려준다 — 모델에게 "2" 만 가면
1103
- // 무엇을 고른 것인지 모른다.
1104
- const n = Number(답);
1105
- const 고른것 = Number.isInteger(n) && n >= 1 && n <= 고를것.length ? 고를것[n - 1] : 답;
1272
+ /*
1273
+ * 숫자로 골랐으면 줄의 글을 그대로 돌려준다 — 모델에게 "2" 만 가면
1274
+ * 무엇을 고른 것인지 모른다.
1275
+ *
1276
+ * 그리고 **고르면서 한마디 덧붙일 수 있다.** 여태는 둘 중 하나였다 —
1277
+ * 숫자만 치면 고른 것이고, 그 밖의 글은 고른 것 없는 자유 답이었다.
1278
+ * 그래서 「2 파일은 나눠서」 라고 치면 2번을 골랐다는 사실이 사라졌다.
1279
+ * 가르는 규칙은 ui/pick.js 머리말에 적어 두었다.
1280
+ */
1281
+ const 푼것 = 고른것풀기(답, 고를것);
1282
+ const 고른것 = 푼것.글;
1283
+ /*
1284
+ * 되비추는 줄에도 **둘 다** 보인다. 고른 것만 보이면 덧붙인 말이
1285
+ * 갔는지 안 갔는지 알 길이 없어서, 사람은 다음 턴에 그 말을 또 친다.
1286
+ */
1106
1287
  say(` ${c.hgreen('▶')} ${c.gray(clip(고른것, 70))}`);
1107
1288
  say('');
1108
1289
  return 고른것;
@@ -1117,7 +1298,8 @@ export async function chatLoop(opts = {}) {
1117
1298
  */
1118
1299
  if (알릴까({ 물어봄: true, 켬: 알림.켬 })) 종();
1119
1300
  창제목(제목글('물어봄', { 폴더: 알림.폴더 }));
1120
- const a = (await ask('실행할까요? (y/n)', { def: 'y' })).toLowerCase();
1301
+ // 멈추라고 누른 것은 **거절**이다. def 'y' 로 물러나면 ESC 가 승인이 된다.
1302
+ const a = (await ask('실행할까요? (y/n)', { def: 'y', 멈추면: 'n' })).toLowerCase();
1121
1303
  창제목(제목글('도는중', { 폴더: 알림.폴더 }));
1122
1304
  return a === 'y' || a === 'yes' || a === 'ㅇ';
1123
1305
  },
@@ -1171,7 +1353,7 @@ export async function chatLoop(opts = {}) {
1171
1353
  if (opts.ctx == null) {
1172
1354
  // 이 컴퓨터 안의 서버면 눈 깜짝할 새다. 사내 게이트웨이는 몇 초 걸릴 수 있어
1173
1355
  // 무슨 일이 일어나는 중인지 알려 준다 — 멈춘 것처럼 보이면 안 된다.
1174
- 화면.돌리기('모델에 걸린 컨텍스트 길이를 확인하는 중…');
1356
+ 화면.돌리기(옮긴말('run.ctxProbe'));
1175
1357
  let r = null;
1176
1358
  try { r = await probeCtx(conn, { timeout: 6000 }); } catch { /* 못 물어보면 아래에서 처리 */ }
1177
1359
  화면.돌림멈춤('');
@@ -1257,15 +1439,15 @@ export async function chatLoop(opts = {}) {
1257
1439
  }
1258
1440
  // 안 뜬 것은 조용히 빠지면 안 된다. "왜 그 도구가 없지" 를 영영 알 수 없다.
1259
1441
  for (const m of mcp붙임.못한것) warn.push(`MCP ${c.white(m.이름)} 을 못 붙였습니다 — ${m.왜}`);
1260
- if (!conn.tools) warn.push('도구 호출이 확인되지 않았습니다 — deel diagnose 로 점검하세요');
1261
- if (!conn.streaming) warn.push('스트리밍이 없어 응답이 한 번에 나옵니다');
1442
+ if (!conn.tools) warn.push(옮긴말('run.noTools'));
1443
+ if (!conn.streaming) warn.push(옮긴말('run.noStream'));
1262
1444
  warn.push(...길이경고);
1263
1445
  // 잘 된 것은 경고 표시를 달지 않는다. ⚠ 가 붙으면 뭘 고쳐야 하나 싶어진다.
1264
1446
  for (const l of 길이알림) say(` ${mark.ok} ${c.gray(l)}`);
1265
1447
  for (const w of warn) say(` ${mark.warn} ${c.gray(w)}`);
1266
1448
  // ESC 를 앞에 둔다 — 멈추는 길로는 이것이 먼저다. Ctrl+C 는 두 번 누르면
1267
1449
  // 끝나 버려서, 급히 멈추려던 사람이 대화를 통째로 닫는 일이 실제로 있었다.
1268
- say(` ${c.gray('/help 명령 목록')} ${c.gray('ESC 중단')} ${c.gray('Ctrl+C 중단·끝내기')}`);
1450
+ say(` ${c.gray(옮긴말('run.hintHelp'))} ${c.gray(옮긴말('run.hintEsc'))} ${c.gray(옮긴말('run.hintQuit'))}`);
1269
1451
 
1270
1452
  /*
1271
1453
  * ── 바깥으로 나가도 되는지 한 번 묻는다 ─────────────────────────────
@@ -1606,7 +1788,7 @@ export async function chatLoop(opts = {}) {
1606
1788
  break;
1607
1789
 
1608
1790
  case 'waiting':
1609
- 화면.기다림(c.gray(꼬리표(stage) + '생각 중…'));
1791
+ 화면.기다림(c.gray(꼬리표(stage) + 옮긴말('run.thinking')));
1610
1792
  // 지워야 할 줄이 화면에 있다고 표시해 둔다.
1611
1793
  //
1612
1794
  // 전에는 이 표시를 안 세웠다. \r 로 커서만 앞으로 보내 놓고 지우지는
@@ -1617,7 +1799,7 @@ export async function chatLoop(opts = {}) {
1617
1799
 
1618
1800
  case 'thinking':
1619
1801
  thinkChars += ev.text.length;
1620
- 화면.생각(`${mark.think} ${c.gray(꼬리표(stage))}${c.gray(`생각 중… ${thinkChars.toLocaleString()}자`)}`);
1802
+ 화면.생각(`${mark.think} ${c.gray(꼬리표(stage))}${c.gray(옮긴말('run.thinkingChars', { n: thinkChars.toLocaleString() }))}`);
1621
1803
  if (process.stdout.isTTY) thinkingShown = true;
1622
1804
  break;
1623
1805
 
@@ -1898,6 +2080,30 @@ export async function chatLoop(opts = {}) {
1898
2080
  break;
1899
2081
  }
1900
2082
 
2083
+ /*
2084
+ * 자리가 다 차서 기억을 비웠다. **턴은 안 죽었다.**
2085
+ *
2086
+ * 여기 붉은 줄을 찍으면 사람은 하던 일이 엎어진 줄 알고 /clear 를
2087
+ * 치고 시킨 말을 다시 타이핑한다 — 정작 지금은 시킨 말과 남은 할 일을
2088
+ * 들고 그대로 이어 가는 중이다. 접기(compacted)와 같은 자리·같은
2089
+ * 빛깔로 한 줄만 적는다.
2090
+ */
2091
+ case 'reset': {
2092
+ 접기멈춤();
2093
+ clearThinking();
2094
+ const 할일수 = ev.kept?.할일 ?? 0;
2095
+ say(` ${c.cyan('◱')} ${c.gray(할일수
2096
+ ? 옮긴말('ev.reset', { 버린수: ev.dropped, 할일: 할일수 })
2097
+ : 옮긴말('ev.resetBare', { 버린수: ev.dropped }))}`);
2098
+ /*
2099
+ * 비우면 이력이 통째로 바뀐다. 덧붙이기로는 못 맞춘다.
2100
+ * 여기서 안 적으면, 비운 뒤에 죽었을 때 /resume 이 옛 대화를
2101
+ * 통째로 되살려 놓는다 — 자리가 없어서 비운 것이 도로 차 있다.
2102
+ */
2103
+ ctx.갈래.현재store().replace(session.messages, `자리부족 — 앞선 대화 ${ev.dropped}개를 비움`);
2104
+ saved = session.messages.length;
2105
+ break;
2106
+ }
1901
2107
  // 서버가 거절하면서 알려 준 한계를 받아 적었다. 실패로 보이면 안 된다 —
1902
2108
  // 사용자 눈에는 잠깐 멈췄다가 그냥 잘 되는 것으로 보여야 맞다.
1903
2109
  case 'learned':
@@ -1919,13 +2125,38 @@ export async function chatLoop(opts = {}) {
1919
2125
  * 대충 답한 줄 알고 같은 것을 다시 시키고, 같은 자리에서 또 잘린다.
1920
2126
  * 손댈 자리를 그 자리에서 알려 준다.
1921
2127
  */
2128
+ /*
2129
+ * ── 붙잡아 둔 반 줄을 먼저 비운다 ────────────────────────────
2130
+ *
2131
+ * 표를 그리려면 줄이 다 모여야 해서, 마크다운 그림은 마지막
2132
+ * 몇 줄을 손에 쥐고 있다. `done` 은 그걸 비우고 끝나는데
2133
+ * capped·cutoff·error 는 안 비우고 있었다.
2134
+ *
2135
+ * 그러면 두 가지가 난다. 붙잡힌 글이 **화면에서 그냥 사라지거나**
2136
+ * (오류로 끝나면 뒤에 done 이 없다), 무엇이 잘렸는지 보라고 띄운
2137
+ * 경고가 정작 그 답의 마지막 줄보다 **위에** 찍힌다.
2138
+ */
1922
2139
  case 'capped':
1923
2140
  clearThinking();
2141
+ if (streamed) { 답비우기(); say(''); streamed = false; }
1924
2142
  say(` ${mark.warn} ${c.gray(`답이 ${c.white(ev.cap.toLocaleString())} 토큰에서 잘렸습니다`)}`
1925
2143
  + `${ev.정한값 ? c.gray(' (직접 정하신 상한입니다)') : c.gray(' — 더 못 올리는 천장입니다')}`);
1926
2144
  say(` ${c.gray('한 번에 더 길게 받으려면')} ${c.cyan('/out 32k')}${c.gray('. 파일을 쓰는 중이었다면 Append 로 나눠 쓰게 하세요.')}`);
1927
2145
  break;
1928
2146
 
2147
+ /*
2148
+ * 상한에서 잘린 것이 아니라, 서버가 **끝난 까닭을 한 번도 안 주고**
2149
+ * 흘려보내기를 멈춘 것이다. 여태 이건 정상 종료와 구별이 안 돼서
2150
+ * 중간에서 끊긴 답이 온전한 답인 척 지나갔다.
2151
+ * /out 을 권하면 안 된다 — 상한 얘기가 아니라 연결 얘기다.
2152
+ */
2153
+ case 'cutoff':
2154
+ clearThinking();
2155
+ if (streamed) { 답비우기(); say(''); streamed = false; }
2156
+ say(` ${mark.warn} ${c.gray('서버가 끝났다는 말 없이 답을 멈췄습니다 — 중간에서 끊겼을 수 있습니다.')}`);
2157
+ say(` ${c.gray('중계 프록시·게이트웨이를 거치면 나는 일입니다. 같은 것을 다시 물어 보세요.')}`);
2158
+ break;
2159
+
1929
2160
  case 'compact_failed':
1930
2161
  접기멈춤();
1931
2162
  say(` ${c.gray(`(접지 못했습니다: ${ev.why})`)}`);
@@ -1946,6 +2177,9 @@ export async function chatLoop(opts = {}) {
1946
2177
  if (ev.남은할일.length > 8) say(` ${c.gray(`… 그 밖에 ${ev.남은할일.length - 8}개`)}`);
1947
2178
  }
1948
2179
  만든파일보이기(ev.files);
2180
+ // 할 일 목록에 못 들어간 채로 빠진 것도 같이 적는다 — 목록에
2181
+ // 없으면 「안 끝난 것」 에도 안 뜨므로 여기서 말 안 하면 아무 데도 없다.
2182
+ 빠진것보이기(ev.빠진);
1949
2183
  break;
1950
2184
 
1951
2185
  // 같은 자리를 계속 반복하고 있다. 두면 컨텍스트만 차고 아무것도 안 나온다.
@@ -1969,6 +2203,7 @@ export async function chatLoop(opts = {}) {
1969
2203
  // 멈췄어도 여기까지 만든 것은 있다. 그것부터 알려 준다 —
1970
2204
  // 없는 줄 알고 다시 시키면 앞서 만든 것을 덮어쓴다.
1971
2205
  만든파일보이기(ev.files);
2206
+ 빠진것보이기(ev.빠진);
1972
2207
  break;
1973
2208
 
1974
2209
  case 'aborted':
@@ -1987,8 +2222,17 @@ export async function chatLoop(opts = {}) {
1987
2222
  case 'error':
1988
2223
  턴탈났나 = true;
1989
2224
  clearThinking();
2225
+ // 붙잡아 둔 반 줄을 먼저 비운다 — 까닭은 위 capped 자리에 적어 두었다.
2226
+ if (streamed) { 답비우기(); say(''); streamed = false; }
1990
2227
  say('');
1991
2228
  오류보이기(ev.text);
2229
+ /*
2230
+ * 반쪽이라도 남겼으면 그렇다고 말해 준다.
2231
+ *
2232
+ * 안 말하면 사람은 화면에 보이는 200자가 대화에도 들어갔는지를
2233
+ * 모른다. 모르면 "이어서 해줘" 를 못 치고 처음부터 다시 친다.
2234
+ */
2235
+ if (ev.kept) say(` ${c.gray(옮긴말('run.errKept'))}`);
1992
2236
  break;
1993
2237
 
1994
2238
  /*
@@ -2018,11 +2262,7 @@ export async function chatLoop(opts = {}) {
2018
2262
  * 제보가 정확히 그 모양이었다 — 빠진 것 자체보다, 빠졌다는 말을
2019
2263
  * 안 한 것이 문제다.
2020
2264
  */
2021
- if (ev.빠진?.length) {
2022
- say('');
2023
- say(` ${c.yellow('!')} ${c.white(`시킨 것 중 ${ev.빠진.length}가지가 안 다뤄졌습니다`)}`);
2024
- for (const x of ev.빠진) say(` ${c.gray(`${x.번호}. ${clip(x.글, 68)}`)}`);
2025
- }
2265
+ 빠진것보이기(ev.빠진);
2026
2266
  break;
2027
2267
  }
2028
2268
  }
@@ -2060,11 +2300,15 @@ export async function chatLoop(opts = {}) {
2060
2300
  turn = null;
2061
2301
  interrupted = false; // 중단은 '끝내기' 의사가 아니다. 종료 카운트를 되돌린다.
2062
2302
  flush(); // 오류로 끝났어도 여기까지는 남긴다
2303
+ // 대화가 파일에 안 적히고 있으면 여기서 **한 번** 말한다 (agent/store.js).
2304
+ // 여태는 아무 말이 없어서, 사람이 알아차리는 자리가 다음 날 --resume 이었다.
2305
+ const 저장샘 = ctx.갈래?.현재store?.()?.처음못쓴것?.();
2306
+ if (저장샘) say(` ${mark.warn} ${c.yellow(옮긴말('store.notSaving', { n: 저장샘.수, 까닭: 저장샘.까닭 }))}`);
2063
2307
 
2064
2308
  // ── 꼬리말 — 이번 턴만의 숫자 ────────────────────────────────────────
2065
2309
  const secs = ((Date.now() - started) / 1000).toFixed(1);
2066
- const bits = [`${secs}초`];
2067
- if (tools) bits.push(`도구 ${tools}회`);
2310
+ const bits = [옮긴말('unit.sec', { n: secs })];
2311
+ if (tools) bits.push(`${옮긴말('scr.tools')} ${옮긴말('unit.calls', { n: tools })}`);
2068
2312
  const dIn = session.usage.in - before.in;
2069
2313
  const dOut = session.usage.out - before.out;
2070
2314
  if (dIn || dOut) bits.push(`↑${dIn.toLocaleString()} ↓${dOut.toLocaleString()}`);
@@ -2101,14 +2345,29 @@ export async function chatLoop(opts = {}) {
2101
2345
  if (알릴까({ 물어봄: true, 켬: 알림.켬 })) 종();
2102
2346
  창제목(제목글('물어봄', { 폴더: 알림.폴더 }));
2103
2347
  const 답 = String(await ask(
2104
- `이대로 진행할까요? ${c.gray('⏎ 진행 · n 취소 · 그 밖엔 고칠 점')}`,
2348
+ `${옮긴말('plan.ask')} ${c.gray(옮긴말('plan.hint'))}`,
2105
2349
  { def: 'y' },
2106
2350
  )).trim();
2107
- const 낮춘 = 답.toLowerCase();
2351
+ /*
2352
+ * ── 골라진 것 + 하고 싶은 말 ────────────────────────────────────
2353
+ *
2354
+ * 여태는 셋 중 하나였다. ⏎ 면 진행, n 이면 그만, 그 밖의 글은 통째로
2355
+ * 「이걸 고쳐서 다시 내라」. 그래서 「그대로 하되 검사도 넣어줘」 라고
2356
+ * 치면 **계획을 다시 짜러 갔다** — 사람은 진행하라고 한 것인데.
2357
+ * 반대로 「3단계는 빼자」 도 그냥 글 한 줄로 갔다. 어느 단계 얘긴지
2358
+ * 모델이 다시 세어 봐야 한다.
2359
+ *
2360
+ * 이제 넷이다. 가르는 규칙은 ui/pick.js 의 계획답풀기() 에 있다.
2361
+ * ⏎ · y → 그대로 진행
2362
+ * y 검사도 넣어줘 → 진행하되 그 말을 얹어서
2363
+ * 3 이건 빼자 → 3단계에 대고 하는 말로 붙여 다시 짜라
2364
+ * 그 밖의 글 → 여태 그대로 (다시 짜라)
2365
+ */
2366
+ const 계획답 = 계획답풀기(답, 할일.length);
2108
2367
 
2109
- if (['n', 'no', '', '취소', '아니', '아니요', '아니오'].includes(낮춘)) {
2110
- say(` ${c.gray('그만뒀습니다. 계획은 위에 남아 있으니 이어서 말씀하셔도 됩니다.')}`);
2111
- } else if (['y', 'yes', 'ㅇ', 'ㅇㅇ', 'ㄱ', 'ㄱㄱ', '네', '응', '진행', 'ok'].includes(낮춘)) {
2368
+ if (계획답.갈래 === '그만') {
2369
+ say(` ${c.gray(옮긴말('plan.cancelled'))}`);
2370
+ } else if (계획답.갈래 === '진행') {
2112
2371
  /*
2113
2372
  * 승인한 계획을 **글자 그대로 실어 보낸다.**
2114
2373
  *
@@ -2128,16 +2387,33 @@ export async function chatLoop(opts = {}) {
2128
2387
  : '바로 위에 네가 적은 계획을 사람이 승인했다.'
2129
2388
  + ' 그 계획을 다시 적지 말고 지금부터 그대로 해라.')
2130
2389
  + ' 계획을 다시 적지 마라. **지난 대화를 뒤지지 마라** — 필요한 것은'
2131
- + ' 이 대화 안에 다 있다. 다 끝나면 무엇을 만들었는지만 짧게 알려라.';
2390
+ + ' 이 대화 안에 다 있다. 다 끝나면 무엇을 만들었는지만 짧게 알려라.'
2391
+ /*
2392
+ * 승인하며 덧붙인 말은 **맨 뒤에** 붙인다.
2393
+ *
2394
+ * 계획 다음에 오는 말이라 「계획은 그대로, 다만 이것」 으로 읽힌다.
2395
+ * 앞에 붙이면 계획보다 먼저 읽혀서, 작은 모델은 그 한마디만
2396
+ * 하고 계획은 안 한다.
2397
+ */
2398
+ + (계획답.덧말 ? `\n\n사람이 승인하며 덧붙인 말: ${계획답.덧말}` : '');
2132
2399
  이어갈모드 = 'code';
2133
2400
  say('');
2134
- say(` ${c.hgreen('▶')} ${c.gray('계획대로 진행합니다.')}`);
2401
+ say(` ${c.hgreen('▶')} ${c.gray(계획답.덧말
2402
+ ? 옮긴말('plan.goingWith', { 말: clip(계획답.덧말, 50) })
2403
+ : 옮긴말('plan.going'))}`);
2135
2404
  } else {
2136
2405
  // 'e' 를 따로 두지 않는다 — 고칠 점을 바로 치는 것이 한 걸음 짧다.
2137
- 이어갈것 = `계획에서 이걸 고쳐서 **계획만** 다시 내라 (아직 실행하지 마라): ${답}`;
2406
+ // 단계 번호를 댔으면 단계에 대고 하는 말로 적어 준다. 모델이
2407
+ // 어느 단계 얘긴지 다시 세어 볼 까닭이 없다.
2408
+ const 고칠말 = 계획답.단계
2409
+ ? `${계획답.단계}단계에 대해: ${계획답.덧말}`
2410
+ : 계획답.덧말;
2411
+ 이어갈것 = `계획에서 이걸 고쳐서 **계획만** 다시 내라 (아직 실행하지 마라): ${고칠말}`;
2138
2412
  이어갈모드 = 'plan';
2139
2413
  say('');
2140
- say(` ${c.hcyan('☰')} ${c.gray('그 점을 반영해 계획을 다시 냅니다.')}`);
2414
+ say(` ${c.hcyan('☰')} ${c.gray(계획답.단계
2415
+ ? 옮긴말('plan.replanStep', { 단계: 계획답.단계 })
2416
+ : 옮긴말('plan.replan'))}`);
2141
2417
  }
2142
2418
  } else if (끊긴할일) {
2143
2419
  /*
@@ -2212,7 +2488,12 @@ export async function chatLoop(opts = {}) {
2212
2488
  // 끝맺음은 화면을 접기 **전에** 그린다. close() 가 상자를 걷어내므로,
2213
2489
  // 그 뒤에 찍으면 걷어낸 자리에 뜬금없이 한 줄이 남는다.
2214
2490
  say('');
2215
- say(` ${c.gray('끝냅니다.')} ${c.gray(`모델 호출 ${session.usage.calls}회 · 도구 시간 ${(session.usage.ms / 1000).toFixed(1)}초 · ↑${session.usage.in.toLocaleString()} ↓${session.usage.out.toLocaleString()}`)}`);
2491
+ say(` ${c.gray(옮긴말('run.bye'))} ${c.gray(옮긴말('run.byeStats', {
2492
+ n: session.usage.calls,
2493
+ 초: (session.usage.ms / 1000).toFixed(1),
2494
+ 입력: session.usage.in.toLocaleString(),
2495
+ 출력: session.usage.out.toLocaleString(),
2496
+ }))}`);
2216
2497
  say('');
2217
2498
  화면.close();
2218
2499
  return 0;
@@ -2227,6 +2508,8 @@ export async function chatLoop(opts = {}) {
2227
2508
  */
2228
2509
  function stageTag(ev, level) {
2229
2510
  if (!ev || level === '쉬움') return '';
2230
- const label = STAGES[ev.stage]?.label ?? ev.stage;
2511
+ // 아는 단계만 표를 거친다. 모르는 것에 말() 을 태우면 못 찾은 열쇠가
2512
+ // 그대로 화면에 찍혀 `stage.xyz 생각 중…` 이 된다.
2513
+ const label = STAGES[ev.stage] ? 옮긴말(`stage.${ev.stage}`) : ev.stage;
2231
2514
  return `${label}·${ev.level}`;
2232
2515
  }