deel-local-cli 1.5.4 → 1.5.6

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/src/repl.js CHANGED
@@ -6,7 +6,7 @@ import { c, say as 바로쓰기, mark, clip } from './ui/ansi.js';
6
6
  import { headerLines } from './ui/status.js';
7
7
  import { 종, 창제목, 제목되돌리기, 알릴까, 제목글 } from './ui/notify.js';
8
8
  import { 보이기 as 인트로, 기본곁말 } from './ui/intro.js';
9
- import { 언어잡기 } from './i18n/index.js';
9
+ import { 언어잡기, 말 as 옮긴말 } from './i18n/index.js';
10
10
  import { 화면고르기 } from './ui/screen.js';
11
11
  import { STAGES } from './agent/effort.js';
12
12
  import { handle, COMMANDS, 미리보기끄기, 적용하기 as 그림적용 } from './commands.js';
@@ -391,14 +391,33 @@ export async function chatLoop(opts = {}) {
391
391
  이어쓰기줄들 = null;
392
392
  화면.입력지움();
393
393
  if (l.trim() || 이었나) say(`${이었나 ? ' ' : ` ${c.hcyan('❯')} `}${c.white(l)}`);
394
- } else if (l.trim()) {
394
+ } else if (l.trim() || 이어쓰기줄들 !== null) {
395
395
  /*
396
396
  * 일하는 도중에 미리 쳐 둔 것.
397
397
  *
398
- * 여기서 대화에 `❯ …` 찍으면 **이미 보낸 것처럼** 보인다. 실제로는
399
- * 지금 일이 끝난 뒤에 나가므로, 그때 가서 찍는다(아래 for 문). 지금은
398
+ * ── 여기서도 백틱이 먹어야 한다 ────────────────────────────
399
+ *
400
+ * 예전에는 이 갈래에 이어쓰기가 아예 없었다. 그래서 **일하는 도중에**
401
+ * 여러 줄을 치면 백틱이 그냥 글자로 나갔다 — 첫 줄이 백틱을 단 채로
402
+ * 혼자 보내지고, 다음 줄은 따로 또 보내진다. 사람 눈에는 줄바꿈이
403
+ * 안 먹는 것으로 보인다. 기다릴 때는 되고 일할 때는 안 되니, 되는
404
+ * 자리를 찾기도 어렵다.
405
+ *
406
+ * 미리 치는 것은 일하는 중에 하는 짓이라 오히려 여기가 더 흔하다.
407
+ *
408
+ * 그리는 것은 다르게 한다. 줄쌓기() 는 대화에 줄을 찍는데, 일하는
409
+ * 도중에 찍으면 이미 보낸 것처럼 보인다. 여기서는 조용히 쌓고
400
410
  * 상자에 몇 건이 밀려 있는지만 세어 준다.
401
411
  */
412
+ if (이어쓰기표시(l)) {
413
+ (이어쓰기줄들 ??= []).push(l.slice(0, -1).replace(/\s+$/, ''));
414
+ 화면.대기갱신('', queue.length + 1);
415
+ return;
416
+ }
417
+ if (이어쓰기줄들 !== null) {
418
+ 보낼것 = [...이어쓰기줄들, l].join('\n');
419
+ 이어쓰기줄들 = null;
420
+ }
402
421
  화면.대기갱신('', queue.length + 1);
403
422
  }
404
423
  }
@@ -646,6 +665,127 @@ export async function chatLoop(opts = {}) {
646
665
  const pw = await askHidden(rl, label, nextLine);
647
666
  return pw === null || pw === '' ? null : pw;
648
667
  },
668
+ /*
669
+ * ── 모델이 사람에게 되묻는 자리 (Ask 도구) ──────────────────────────
670
+ *
671
+ * 계획 승인 상자와 같은 꼴로 그린다. 사람이 이미 아는 모양이라 새로 배울
672
+ * 것이 없고, 무엇보다 **숫자 하나로 끝난다.**
673
+ *
674
+ * 자유롭게 쳐도 그대로 간다. 고를 것이 늘 맞지는 않아서, 넷 다 아닐 때
675
+ * 다시 물어보게 하면 그게 제일 답답하다.
676
+ */
677
+ /*
678
+ * ── 이 터미널이 무슨 바이트를 보내는지 본다 (/keys) ──────────────────
679
+ *
680
+ * 맥에서 줄바꿈이 안 된다는 말을 몇 판째 듣고도 못 고쳤다. 고칠 데를
681
+ * 못 찾은 게 아니라 **무슨 일이 나는지를 몰랐다.**
682
+ *
683
+ * Alt+Enter 는 앞에 ESC 가 붙어 올 때만 잡을 수 있는데(위 keypress 참고),
684
+ * macOS 터미널은 기본값이 Option 을 그렇게 안 보낸다. Shift+Enter·
685
+ * Ctrl+Enter 는 대부분 터미널에서 평범한 Enter 와 **바이트가 똑같다.**
686
+ * 즉 여기서는 셋을 구분할 길이 아예 없다 — 안 오는 것을 잡을 수는 없다.
687
+ *
688
+ * 그래서 문서에 "터미널 설정을 이렇게 바꾸세요" 라고만 적어 뒀는데, 그건
689
+ * 고친 것이 아니다. 사람이 제 터미널에서 **눌러 보고 바로 알 수 있어야**
690
+ * 한다. 여기서 보여 주는 것은 짐작이 아니라 실제로 온 바이트다.
691
+ */
692
+ 키확인: async () => {
693
+ if (!process.stdin.isTTY) {
694
+ say('');
695
+ say(` ${c.gray('키 확인은 터미널에서만 됩니다 (지금은 파이프로 들어와 있습니다).')}`);
696
+ say('');
697
+ return;
698
+ }
699
+ say('');
700
+ say(` ${c.hcyan('┌')} ${c.bold('키 확인')}`);
701
+ say(` ${c.hcyan('│')} ${c.gray('아무 키나 눌러 보세요. 이 터미널이 실제로 보낸 바이트를 그대로 보여 줍니다.')}`);
702
+ say(` ${c.hcyan('│')} ${c.gray('줄바꿈에 쓰려던 것을 눌러 보세요 — Alt+Enter · Option+Enter · Shift+Enter.')}`);
703
+ say(` ${c.hcyan('└')} ${c.gray('끝내려면 Ctrl+C 또는 q')}`);
704
+ say('');
705
+
706
+ const 본것 = [];
707
+ await new Promise((끝) => {
708
+ const 듣기 = (ch, key) => {
709
+ const 이름 = key?.name ?? (ch ? JSON.stringify(ch) : '?');
710
+ if (이름 === 'q' || (key?.ctrl && key?.name === 'c')) {
711
+ process.stdin.off('keypress', 듣기);
712
+ return 끝();
713
+ }
714
+ // 실제로 온 바이트. 이것이 이 화면의 전부다.
715
+ const 날것 = JSON.stringify(String(key?.sequence ?? ch ?? ''))
716
+ .replace(/\\u001b/g, '\\e');
717
+ const 꾸밈 = [key?.ctrl && 'ctrl', key?.meta && 'meta', key?.shift && 'shift']
718
+ .filter(Boolean).join('+');
719
+ 본것.push({ 이름, 날것, 꾸밈 });
720
+ say(` ${c.hcyan('·')} ${c.white(이름.padEnd(10))} ${c.gray(날것.padEnd(14))}`
721
+ + `${꾸밈 ? c.hgreen(꾸밈) : c.gray('꾸밈 없음')}`);
722
+ };
723
+ process.stdin.on('keypress', 듣기);
724
+ });
725
+
726
+ /*
727
+ * 눌러 본 것을 놓고 **이 터미널에서 되는 길**을 알려 준다.
728
+ *
729
+ * Enter 를 여러 번 눌렀는데 meta 가 한 번도 안 붙었으면, 그 터미널은
730
+ * Option/Alt 를 ESC 로 안 보내는 것이다. 그 자리에서는 아무리 눌러도
731
+ * 안 되므로, 백틱 쪽을 알려 주는 것이 맞다.
732
+ */
733
+ const 엔터들 = 본것.filter((k) => k.이름 === 'return' || k.이름 === 'enter');
734
+ const 메타본적있나 = 엔터들.some((k) => /meta/.test(k.꾸밈));
735
+ say('');
736
+ if (!엔터들.length) {
737
+ say(` ${c.gray('Enter 를 안 눌러 봤습니다. 줄바꿈을 보려면 Alt/Option+Enter 를 눌러 보세요.')}`);
738
+ } else if (메타본적있나) {
739
+ say(` ${mark.ok} ${c.white('이 터미널은 Alt/Option+Enter 를 보냅니다')} ${c.gray('— 그대로 쓰시면 됩니다.')}`);
740
+ } else {
741
+ say(` ${mark.warn} ${c.white('이 터미널은 Alt/Option+Enter 를 안 보냅니다.')}`);
742
+ say(` ${c.gray(' 누른 Enter 가 평범한 Enter 와 바이트가 같아서, 프로그램 쪽에서 가려낼 길이 없습니다.')}`);
743
+ say('');
744
+ say(` ${c.white('줄바꿈은 이렇게 하세요')} ${c.gray('— 어느 터미널에서나 됩니다')}`);
745
+ say(` ${c.gray(' 줄 끝에')} ${c.cyan('빈칸 + `')} ${c.gray('를 붙이고 Enter. 마지막 줄은 그냥 Enter.')}`);
746
+ say('');
747
+ say(` ${c.gray(' Option 을 굳이 쓰시려면 터미널 설정을 바꿔야 합니다:')}`);
748
+ say(` ${c.gray(' Terminal.app 설정 → 프로파일 → 키보드 → ')}${c.cyan('Option을 Meta 키로 사용')}`);
749
+ say(` ${c.gray(' iTerm2 Settings → Profiles → Keys → Left Option → ')}${c.cyan('Esc+')}`);
750
+ say(` ${c.gray(' VS Code ')}${c.cyan('terminal.integrated.macOptionIsMeta')}${c.gray(' 를 true 로')}`);
751
+ }
752
+ say('');
753
+ },
754
+
755
+ ask물음: async (물음, 고를것 = []) => {
756
+ if (closed) return null;
757
+ say('');
758
+ say(` ${c.hcyan('┌')} ${c.bold(옮긴말('ask.title'))}`);
759
+ for (const 줄 of 접어쓰기(물음, Math.max(40, Math.min(88, (process.stdout.columns || 80) - 6)) - 4)) {
760
+ say(` ${c.hcyan('│')} ${c.white(줄)}`);
761
+ }
762
+ if (고를것.length) {
763
+ say(` ${c.hcyan('│')}`);
764
+ 고를것.forEach((것, i) => {
765
+ say(` ${c.hcyan('│')} ${c.hcyan(String(i + 1))} ${c.white(것)}`);
766
+ });
767
+ }
768
+ say(` ${c.hcyan('└')} ${c.gray(옮긴말(고를것.length ? 'ask.pickHint' : 'ask.freeHint'))}`);
769
+ say('');
770
+
771
+ // 막혀 있는 것은 기다린 만큼 그대로 손해다. confirm 과 같은 이유로 알린다.
772
+ if (알릴까({ 물어봄: true, 켬: 알림.켬 })) 종();
773
+ 창제목(제목글('물어봄', { 폴더: 알림.폴더 }));
774
+ const 답 = String(await ask(
775
+ 고를것.length ? 옮긴말('ask.pickPrompt', { 끝: 고를것.length }) : 옮긴말('ask.freePrompt'),
776
+ { def: 고를것.length ? '1' : '' },
777
+ )).trim();
778
+ 창제목(제목글('도는중', { 폴더: 알림.폴더 }));
779
+
780
+ if (!답) return null;
781
+ // 숫자로 골랐으면 그 줄의 글을 그대로 돌려준다 — 모델에게 "2" 만 가면
782
+ // 무엇을 고른 것인지 모른다.
783
+ const n = Number(답);
784
+ const 고른것 = Number.isInteger(n) && n >= 1 && n <= 고를것.length ? 고를것[n - 1] : 답;
785
+ say(` ${c.hgreen('▶')} ${c.gray(clip(고른것, 70))}`);
786
+ say('');
787
+ return 고른것;
788
+ },
649
789
  confirm: async (name, args) => {
650
790
  say('');
651
791
  say(` ${c.yellow('?')} ${toolLabel(name, args)}`);
@@ -1492,9 +1632,26 @@ export async function chatLoop(opts = {}) {
1492
1632
  if (['n', 'no', 'ㄴ', '취소', '아니', '아니요', '아니오'].includes(낮춘)) {
1493
1633
  say(` ${c.gray('그만뒀습니다. 계획은 위에 남아 있으니 이어서 말씀하셔도 됩니다.')}`);
1494
1634
  } else if (['y', 'yes', 'ㅇ', 'ㅇㅇ', 'ㄱ', 'ㄱㄱ', '네', '응', '진행', 'ok'].includes(낮춘)) {
1495
- // 계획을 다시 적으라고 하면 두 번 적는다. 컨텍스트만 먹고 사람은 같은 글을 두 번 본다.
1496
- 이어갈것 = '위 계획을 승인받았다. 계획을 다시 적지 말고 지금부터 그대로 실행해라.'
1497
- + ' 적어 둔 단계를 하나씩 끝내고, 다 끝나면 무엇을 만들었는지만 짧게 알려라.';
1635
+ /*
1636
+ * 승인한 계획을 **글자 그대로 실어 보낸다.**
1637
+ *
1638
+ * 예전에는 "위 계획을 승인받았다 · 적어 둔 단계를 하나씩 끝내라" 라고만
1639
+ * 보냈다. 사람 눈에는 계획이 바로 위에 있으니 말이 되는데, 모델에게
1640
+ * 이건 **가리키기만 하고 안 주는 말**이다. 그래서 작은 모델은 "적어 둔"
1641
+ * 것을 찾으러 Recall 로 지난 대화를 뒤지기 시작했다 — 세 번 뒤지고
1642
+ * 세 번 못 찾고, 정작 시킨 일은 시작도 안 했다.
1643
+ *
1644
+ * 계획은 이미 손에 있다(ctx.todos). 가리키지 말고 준다. 두 번 적는
1645
+ * 것이 아까워서 안 준 것이었는데, 아낀 토큰보다 헛도는 값이 훨씬 컸다.
1646
+ */
1647
+ const 단계글 = 할일.map((t, i) => `${i + 1}. ${String(t.text ?? '')}`).join('\n');
1648
+ 이어갈것 = (할일.length
1649
+ ? `방금 낸 계획을 사람이 승인했다. 계획은 아래 그대로다.\n\n${단계글}\n\n`
1650
+ + '지금부터 이 단계를 하나씩 실제로 해라.'
1651
+ : '바로 위에 네가 적은 계획을 사람이 승인했다.'
1652
+ + ' 그 계획을 다시 적지 말고 지금부터 그대로 해라.')
1653
+ + ' 계획을 다시 적지 마라. **지난 대화를 뒤지지 마라** — 필요한 것은'
1654
+ + ' 이 대화 안에 다 있다. 다 끝나면 무엇을 만들었는지만 짧게 알려라.';
1498
1655
  이어갈모드 = 'code';
1499
1656
  say('');
1500
1657
  say(` ${c.hgreen('▶')} ${c.gray('계획대로 진행합니다.')}`);
@@ -1,6 +1,6 @@
1
1
  // 되돌리기. 승인 프롬프트를 안 쓰는 대신 이게 안전망이다.
2
2
  // 파일을 고치기 전에 항상 이전 내용을 떠 놓고, /undo 로 턴 단위로 되돌린다.
3
- import { join } from 'node:path';
3
+ import { join, dirname } from 'node:path';
4
4
  import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, appendFileSync, statSync } from 'node:fs';
5
5
  import { looksBinary } from '../tools/encoding.js';
6
6
 
@@ -169,6 +169,13 @@ export class History {
169
169
  rmSync(path, { force: true });
170
170
  restored.push({ path, how: '삭제됨(원래 없던 파일)' });
171
171
  } else {
172
+ /*
173
+ * 담고 있던 폴더가 없어졌을 수 있다 — Move 로 폴더째 옮긴 경우다.
174
+ * 그러면 writeFileSync 가 ENOENT 로 죽고, 되돌리기는 "실패" 만 남긴 채
175
+ * 파일을 못 되살린다. 되돌릴 내용은 손에 있는데 담을 자리가 없어서
176
+ * 못 넣는 것이라, 자리를 다시 만들어 준다.
177
+ */
178
+ mkdirSync(dirname(path), { recursive: true });
172
179
  // enc 가 붙어 있으면 UTF-8 로 담을 수 없던 파일이다 — 바이트를 그대로 되돌린다.
173
180
  writeFileSync(path, rec.enc === 'b64' ? Buffer.from(rec.before, 'base64') : Buffer.from(rec.before, 'utf8'));
174
181
  restored.push({ path, how: '되돌림' });
@@ -122,6 +122,27 @@ export const 도구설명EN = {
122
122
  + ' material comes back cut (max 120000)',
123
123
  },
124
124
  },
125
+ Move: {
126
+ desc: 'Move or rename files and folders. This is how you restructure a tree.'
127
+ + ' **Several at once via the moves array** — do not call it twenty times to move twenty files.'
128
+ + ' Do not use Bash mv: it is not covered by undo and asks for approval every time.',
129
+ params: {
130
+ from: 'the file or folder to move (single form)',
131
+ to: 'where it goes. Missing folders are created (single form)',
132
+ moves: 'several at once. With this, from/to are unused',
133
+ overwrite: 'overwrite the destination if it already exists. Default false, so a clash is refused',
134
+ },
135
+ },
136
+ Ask: {
137
+ desc: 'Ask the person one question. At a fork, **do not pick for them** — use this.'
138
+ + ' If you do not know what to do, never write "let me know" and stop; call this instead —'
139
+ + ' asking in prose ends the turn and throws away everything you have looked at.'
140
+ + ' Give 2-4 options and they answer with a single number.',
141
+ params: {
142
+ question: 'one sentence. Make it clear what has to be decided',
143
+ options: '2-4 choices, one line each, so the difference is visible',
144
+ },
145
+ },
125
146
  Recall: {
126
147
  desc: 'Search past sessions in this folder. When the user points back ("last time"), use this'
127
148
  + ' instead of asking again. This does not search file contents — that is Grep.',
@@ -1,7 +1,7 @@
1
1
  // 도구. 이름과 인자를 Claude Code 와 같게 맞춘다 —
2
2
  // 그래야 그 관례로 쓰인 스킬·명령이 그대로 먹는다.
3
- import { writeFileSync, appendFileSync, readFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
4
- import { dirname } from 'node:path';
3
+ import { writeFileSync, appendFileSync, readFileSync, existsSync, mkdirSync, statSync, renameSync, cpSync, rmSync } from 'node:fs';
4
+ import { dirname, join, relative, sep } from 'node:path';
5
5
  import { execFile } from 'node:child_process';
6
6
  import { globToRegex, walk, readText, readTextFull, 내부살림 } from './fsutil.js';
7
7
  import { encode, label as encLabel, decode as decodeBytes, consoleCodepage, looksBinary } from './encoding.js';
@@ -24,7 +24,7 @@ import { isDocPath, readDoc, toText as docText, summarize as docSummary, looksOl
24
24
  import { diffLines } from '../ui/diff.js';
25
25
  import { 읽을줄수, 찾을개수, 찾을줄수, 설명길이 } from '../agent/budget.js';
26
26
  import { 도구설명EN } from './desc.en.js';
27
- import { 언어 } from '../i18n/index.js';
27
+ import { 지시말 } from '../i18n/index.js';
28
28
 
29
29
  /*
30
30
  * 한 번에 돌려줄 양은 **모델에 맞춰** 정한다 (agent/budget.js).
@@ -265,6 +265,121 @@ function 문서읽기(abs, ctx) {
265
265
  * 것이 중요하다. 한 덩이로 뜨면 `/undo` 가 전부-아니면-전무가 되어, 넷 중
266
266
  * 하나만 잘못 만들었을 때 나머지 셋까지 날려야 한다.
267
267
  */
268
+ /*
269
+ * ── 하나 옮기기 ─────────────────────────────────────────────────────────
270
+ *
271
+ * 되돌리기는 **내용을 떠 놓는** 방식이다(safety/undo.js). 옮기기는 그 틀에
272
+ * 이렇게 얹는다: 떠난 자리와 닿을 자리를 **둘 다** 뜬다.
273
+ *
274
+ * 떠난 자리 — 내용이 떠지니 되돌리면 그 자리에 다시 쓰인다
275
+ * 닿을 자리 — 없던 파일이라 null 로 떠지고, 되돌리면 지워진다
276
+ *
277
+ * 둘이 합쳐져야 '옮기기 전' 이 된다. 하나만 뜨면 되돌린 뒤 파일이 두 군데
278
+ * 있거나(닿을 자리를 안 뜬 경우) 한 군데도 없다(떠난 자리를 안 뜬 경우).
279
+ *
280
+ * 그림·zip 같은 바이너리는 내용을 못 뜬다(skipped). 그때 되돌리기는 그 파일을
281
+ * **손대지 않는다** — 옮겨진 자리에 그대로 남는다. 되돌아가지는 않지만
282
+ * 없어지지도 않는다. 안전망이 파일을 지우는 것보다 이쪽이 낫다.
283
+ */
284
+ function 한개옮기기({ from, to, overwrite = false }, ctx) {
285
+ if (typeof from !== 'string' || !from) return { error: 'from 이 없습니다' };
286
+ if (typeof to !== 'string' || !to) return { error: 'to 가 없습니다' };
287
+
288
+ const 앞 = ctx.scope.resolve(from);
289
+ const 뒤 = ctx.scope.resolve(to);
290
+ if (!existsSync(앞)) return { error: `없는 파일입니다: ${from}` };
291
+ if (앞 === 뒤) return { error: `옮길 자리가 지금 자리와 같습니다: ${from}` };
292
+
293
+ // 읽기만 막고 옮기기를 열어 두면 .deel/config.json 을 옮겨 연결을 끊을 수 있다.
294
+ for (const p of [앞, 뒤]) { const 왜 = 내부살림(p); if (왜) return { error: 왜 }; }
295
+
296
+ const 폴더인가 = statSync(앞).isDirectory();
297
+ /*
298
+ * 폴더를 **제 안으로** 옮기면 그 폴더가 통째로 사라진다 (`mv a a/b`).
299
+ * 셸에서도 잘 나는 사고라 여기서 막는다.
300
+ */
301
+ if (폴더인가 && (뒤 + sep).startsWith(앞 + sep)) {
302
+ return { error: `폴더를 제 안으로 옮길 수 없습니다: ${from} → ${to}` };
303
+ }
304
+
305
+ /*
306
+ * 닿을 자리에 이미 있으면 **기본값은 거절**이다.
307
+ *
308
+ * 조용히 덮어쓰면 그 파일 내용이 그 자리에서 없어진다. 구조를 바꾸는 일은
309
+ * 파일을 스무 개씩 옮기는 일이라, 이름이 겹치는 것이 드물지 않다.
310
+ * 겹쳤다고 알려 주면 모델이 이름을 고쳐서 다시 부른다.
311
+ */
312
+ if (existsSync(뒤) && !overwrite) {
313
+ return { error: `이미 있습니다: ${to}\n 덮어쓰려면 overwrite: true 를 주세요.` };
314
+ }
315
+
316
+ /*
317
+ * 되돌릴 수 있게 뜬다.
318
+ *
319
+ * 폴더면 안의 파일을 하나씩 짝지어 뜬다. walk 는 node_modules 같은 것을
320
+ * 건너뛰므로(fsutil.js 의 SKIP_DIRS) 그 안은 이력에 안 남는다 — 옮기기는
321
+ * 그대로 되지만 되돌리기에는 안 잡힌다. 그런 폴더를 되돌리자고 수만 개를
322
+ * 뜨는 쪽이 훨씬 나쁘다.
323
+ */
324
+ const 짝들 = 폴더인가
325
+ ? walk(앞).map((f) => [f.path, join(뒤, relative(앞, f.path))])
326
+ : [[앞, 뒤]];
327
+ for (const [a, b] of 짝들) {
328
+ ctx.history.snapshot(a, 'Move');
329
+ ctx.history.snapshot(b, 'Move');
330
+ }
331
+
332
+ mkdirSync(dirname(뒤), { recursive: true });
333
+ try {
334
+ renameSync(앞, 뒤);
335
+ } catch (err) {
336
+ /*
337
+ * 드라이브가 다르면 rename 이 안 된다 (윈도우 C: → D:, 리눅스 마운트 경계).
338
+ * 그때만 복사해서 옮긴다. 늘 복사하지 않는 것은 큰 폴더에서 값이 크고,
339
+ * 복사 도중에 끊기면 양쪽에 반씩 남기 때문이다.
340
+ */
341
+ if (err.code !== 'EXDEV') return { error: `못 옮겼습니다: ${err.message}` };
342
+ try {
343
+ cpSync(앞, 뒤, { recursive: true });
344
+ rmSync(앞, { recursive: true, force: true });
345
+ } catch (err2) {
346
+ return { error: `못 옮겼습니다: ${err2.message}` };
347
+ }
348
+ }
349
+
350
+ for (const [, b] of 짝들) ctx.seen.add(b);
351
+ const 무엇 = 폴더인가 ? `폴더 ${짝들.length}개 파일` : '';
352
+ return {
353
+ content: `옮김: ${ctx.scope.show(앞)} → ${ctx.scope.show(뒤)}${무엇 ? ` (${무엇})` : ''}`,
354
+ summary: `${ctx.scope.show(뒤)}`,
355
+ changed: 뒤,
356
+ };
357
+ }
358
+
359
+ /** 여러 개를 한 번에. 하나가 막혀도 나머지는 옮기고, 무엇이 막혔는지 다 알린다. */
360
+ function 여러개옮기기(목록, ctx) {
361
+ const 된것 = [];
362
+ const 안된것 = [];
363
+ for (const 하나 of 목록) {
364
+ const r = 한개옮기기(하나, ctx);
365
+ if (r.error) 안된것.push(`${하나.from ?? '?'} → ${하나.to ?? '?'}: ${r.error}`);
366
+ else 된것.push(r.content);
367
+ }
368
+ /*
369
+ * 하나도 못 옮겼으면 오류로 돌려준다. 그래야 모델이 '됐다' 고 넘어가지
370
+ * 않는다. 하나라도 됐으면 결과로 돌려주되 막힌 것을 그 안에 다 적는다 —
371
+ * 절반만 옮겨진 상태를 모르고 지나가는 것이 제일 나쁘다.
372
+ */
373
+ if (!된것.length) return { error: 안된것.join('\n') || '옮길 것이 없습니다' };
374
+ return {
375
+ content: `${된것.length}개 옮겼습니다${안된것.length ? `, ${안된것.length}개 실패` : ''}.\n`
376
+ + 된것.map((c) => ` ✓ ${c.replace(/^옮김: /, '')}`).join('\n')
377
+ + (안된것.length ? `\n${안된것.map((e) => ` ✗ ${e.split('\n')[0]}`).join('\n')}` : '')
378
+ + (안된것.length ? '\n\n실패한 것만 다시 보내세요. 된 것은 다시 안 보내도 됩니다.' : ''),
379
+ summary: `${된것.length}개 옮김${안된것.length ? ` · ${안된것.length}개 실패` : ''}`,
380
+ };
381
+ }
382
+
268
383
  function 한파일쓰기(args, ctx) {
269
384
  const abs = ctx.scope.resolve(args.file_path);
270
385
  if (typeof args.content !== 'string') return { error: 'content 가 문자열이 아닙니다' };
@@ -712,6 +827,62 @@ export const TOOLS = {
712
827
  },
713
828
  },
714
829
 
830
+ /*
831
+ * ── 파일을 옮긴다 ───────────────────────────────────────────────────────
832
+ *
833
+ * 이게 없어서 「폴더 구조를 역할에 맞게 바꿔 줘」 가 계속 헛돌았다.
834
+ *
835
+ * 도구 목록에 옮기는 것이 없으니 길은 `Bash mv` 하나뿐이었는데, mv 는 위험
836
+ * 명령으로 잡혀 있어서(safety/guard.js 의 MUTATING) 승인 방식에 따라
837
+ * **파일 하나 옮길 때마다** 물었다. 스무 개면 스무 번이다. 게다가 Bash 로
838
+ * 옮긴 것은 되돌리기에 안 잡혀서, /undo 를 눌러도 구조가 안 돌아왔다.
839
+ *
840
+ * 그래서 시키는 일에 맞는 도구가 없었던 것이지, 모델이 말을 안 들은 게
841
+ * 아니었다. 도구를 주면 한 번에 스무 개를 옮기고 되돌리기에도 잡힌다.
842
+ */
843
+ Move: {
844
+ schema: {
845
+ name: 'Move',
846
+ description: '파일·폴더를 옮기거나 이름을 바꾼다. 구조를 바꾸는 일은 이걸로 한다.'
847
+ + ' **여러 개는 moves 배열로 한 번에** — 스무 개를 옮기려고 스무 번 부르지 마라.'
848
+ + ' Bash 의 mv 를 쓰지 마라. 되돌리기에 안 잡히고 매번 승인을 묻는다.',
849
+ parameters: {
850
+ type: 'object',
851
+ properties: {
852
+ from: { type: 'string', description: '옮길 파일·폴더 (한 개일 때)' },
853
+ to: { type: 'string', description: '옮겨 갈 자리. 없는 폴더는 만든다 (한 개일 때)' },
854
+ moves: {
855
+ type: 'array',
856
+ description: '여러 개를 한 번에. 이걸 쓰면 from·to 는 안 쓴다.',
857
+ items: {
858
+ type: 'object',
859
+ properties: {
860
+ from: { type: 'string', description: '옮길 파일·폴더' },
861
+ to: { type: 'string', description: '옮겨 갈 자리' },
862
+ },
863
+ required: ['from', 'to'],
864
+ },
865
+ },
866
+ overwrite: {
867
+ type: 'boolean',
868
+ description: '옮겨 갈 자리에 이미 있어도 덮어쓴다. 기본은 false 라 겹치면 거절한다',
869
+ },
870
+ },
871
+ required: [],
872
+ },
873
+ },
874
+ run(args, ctx) {
875
+ const 목록 = Array.isArray(args.moves) ? args.moves.filter((x) => x && typeof x === 'object') : [];
876
+ if (목록.length) {
877
+ return 여러개옮기기(목록.map((m) => ({ ...m, overwrite: m.overwrite ?? args.overwrite })), ctx);
878
+ }
879
+ if (typeof args.from !== 'string' || !args.from) {
880
+ return { error: 'from 이 없습니다. 한 개면 from·to 를, 여러 개면 moves 배열을 주세요.' };
881
+ }
882
+ return 한개옮기기(args, ctx);
883
+ },
884
+ },
885
+
715
886
  Glob: {
716
887
  schema: {
717
888
  name: 'Glob',
@@ -1083,6 +1254,83 @@ export const TOOLS = {
1083
1254
  *
1084
1255
  * 이 폴더의 기록만 본다. 작업 범위 밖은 애초에 읽을 수 없다.
1085
1256
  */
1257
+ /*
1258
+ * ── 사람에게 되묻기 ─────────────────────────────────────────────────────
1259
+ *
1260
+ * 이게 없어서 실제로 이런 일이 났다. 큰 폴더를 "역할에 맞게 구조 바꿔 줘"
1261
+ * 라고 시켰더니, 모델이 파일을 스무 개쯤 읽고 나서 대화창에 이렇게 적고
1262
+ * 멈췄다:
1263
+ *
1264
+ * "수행할 구체적인 작업 요청이 없습니다. 원하는 변경 사항을 알려주세요."
1265
+ *
1266
+ * 요청은 있었다. 모델이 **어떻게 물어야 할지 몰랐던** 것이다. 물어볼 길이
1267
+ * 없으니 글로 적고 턴을 끝냈고, 사람은 아무것도 안 된 화면을 봤다.
1268
+ *
1269
+ * 되묻기가 도구여야 하는 이유:
1270
+ * · 글로 물으면 그 턴이 **끝난다.** 사람이 답해도 앞의 조사 결과는 이미
1271
+ * 끝난 턴에 있고, 모델은 처음부터 다시 읽는다.
1272
+ * · 도구로 물으면 답이 도구 결과로 돌아와 **하던 자리에서 이어진다.**
1273
+ *
1274
+ * 고를 것을 같이 주게 하는 것이 핵심이다. "어떻게 할까요?" 만 오면 사람은
1275
+ * 다시 생각해야 하지만, 세 갈래를 주면 숫자 하나로 끝난다.
1276
+ */
1277
+ Ask: {
1278
+ schema: {
1279
+ name: 'Ask',
1280
+ /*
1281
+ * 첫 문장에 제일 중요한 것을 둔다 — 좁은 창에서는 설명줄이기가
1282
+ * **뒤에서부터 문장째로** 잘라 낸다. 8k 에서 남는 것은 앞 90자다.
1283
+ */
1284
+ description: '갈림길에서 사람에게 하나 묻는다. 글로 "알려주세요" 하고 끝내지 마라 —'
1285
+ + ' 턴이 끝나서 여태 본 것이 버려진다. options 에 2~4개를 주면 숫자로 답한다.',
1286
+ parameters: {
1287
+ type: 'object',
1288
+ properties: {
1289
+ question: { type: 'string', description: '한 문장짜리 질문. 무엇을 정해야 하는지 분명하게' },
1290
+ options: {
1291
+ type: 'array',
1292
+ items: { type: 'string' },
1293
+ description: '고를 것 2~4개. 각각 한 줄로, 무엇이 달라지는지 알 수 있게',
1294
+ },
1295
+ },
1296
+ required: ['question'],
1297
+ },
1298
+ },
1299
+ async run(args, ctx) {
1300
+ const 물음 = String(args.question ?? '').trim();
1301
+ if (!물음) return { error: 'question 이 비었습니다' };
1302
+
1303
+ const 고를것 = (Array.isArray(args.options) ? args.options : [])
1304
+ .map((x) => String(x ?? '').trim()).filter(Boolean).slice(0, 4);
1305
+
1306
+ /*
1307
+ * 물어볼 자리가 없는 데서도 안 죽어야 한다 — `deel -p` 한 방 실행, 파이프,
1308
+ * 하위 작업. 거기서는 **막히지 말고** 스스로 판단하라고 돌려준다.
1309
+ * 답을 기다리며 서 있으면 그 실행은 영영 안 끝난다.
1310
+ */
1311
+ /*
1312
+ * 결과는 반드시 `content` 로 돌려준다.
1313
+ *
1314
+ * loop.js 가 대화에 싣는 것은 `result.content` 하나다(그 자리 하나로
1315
+ * 비밀 가리기·중복 셈이 다 걸린다). 처음에 `answer` 로만 돌려줬더니
1316
+ * 화면에는 답이 멀쩡히 뜨는데 **모델에게는 빈 글이 갔다** — 물어보고
1317
+ * 답을 받고도 못 들은 셈이라, 없느니만 못한 자리가 될 뻔했다.
1318
+ */
1319
+ if (typeof ctx.ask물음 !== 'function') {
1320
+ return {
1321
+ content: '지금은 사람에게 물을 수 없는 자리입니다(한 방 실행·하위 작업).'
1322
+ + ' 되묻지 말고 가장 그럴듯한 쪽으로 진행하고, 무엇을 가정했는지 끝에 적으세요.',
1323
+ };
1324
+ }
1325
+
1326
+ const 답 = await ctx.ask물음(물음, 고를것);
1327
+ if (답 === null || 답 === undefined || String(답).trim() === '') {
1328
+ return { content: '사람이 답하지 않았습니다. 되묻지 말고 스스로 판단해 이어가세요.' };
1329
+ }
1330
+ return { content: `사람의 답: ${String(답)}` };
1331
+ },
1332
+ },
1333
+
1086
1334
  Recall: {
1087
1335
  schema: {
1088
1336
  name: 'Recall',
@@ -1206,7 +1454,10 @@ export const TOOLS = {
1206
1454
  *
1207
1455
  * 인자 설명도 같이 줄인다. 괄호로 붙인 보충(`(한 개일 때)`)이 먼저 떨어진다.
1208
1456
  */
1209
- const 뻔한인자 = new Set(['file_path', 'content', 'pattern', 'path', 'command', 'text', 'name']);
1457
+ // from·to Move 쓴다. 옮기는 도구에서 "어디서 어디로" 는 이름만으로 안다.
1458
+ const 뻔한인자 = new Set([
1459
+ 'file_path', 'content', 'pattern', 'path', 'command', 'text', 'name', 'question', 'from', 'to',
1460
+ ]);
1210
1461
 
1211
1462
  export function 설명줄이기(schema, 한도) {
1212
1463
  if (!Number.isFinite(한도)) return schema;
@@ -1266,14 +1517,18 @@ export function 설명줄이기(schema, 한도) {
1266
1517
  }
1267
1518
 
1268
1519
  /**
1269
- * 도구 설명을 지금 화면 말에 맞춘다.
1520
+ * 도구 설명을 **모델에게 주는 말**에 맞춘다 (i18n 의 지시말).
1521
+ *
1522
+ * 화면 말이 아니라 지시말을 본다. 도구 설명은 사람이 아니라 모델이 읽는
1523
+ * 글이라, '한국어 화면 + 영어 지시' 를 고른 사람에게는 여기도 영어로
1524
+ * 가야 한다 — 그래야 아끼려던 토큰이 실제로 아껴진다.
1270
1525
  *
1271
- * 표에 없는 도구·인자는 한글 설명이 그대로 나간다 — 화면 말과 같은 규칙이다.
1526
+ * 표에 없는 도구·인자는 한글 설명이 그대로 나간다.
1272
1527
  * 빈 설명을 내보내지 않는다. 설명 없는 도구는 모델이 언제 쓰는지 모른 채로
1273
1528
  * 목록에만 서 있게 되는데, 그건 없는 것보다 나쁘다.
1274
1529
  */
1275
1530
  function 영어설명(schema, 이름) {
1276
- if (언어() !== 'en') return schema;
1531
+ if (지시말() !== 'en') return schema;
1277
1532
  const 것 = 도구설명EN[이름];
1278
1533
  if (!것) return schema;
1279
1534
 
@@ -522,21 +522,38 @@ export class InputBox {
522
522
  const 폭 = 칸수 ?? 80;
523
523
 
524
524
  /*
525
- * 지난번에 그린 크기와 다르면 들고 있던 것을 다 버린다.
525
+ * ── 크기가 바뀌었을 ──────────────────────────────────────────────
526
526
  *
527
- * 터미널이 이미 제 손으로 줄을 다시 접어 놨으므로, 옛 폭에서 센 값으로
528
- * 커서를 올리면 엉뚱한 줄에 덮어쓴다 — 대화가 지워지는 길이 그것이다.
527
+ * 폭이 바뀌면 터미널이 **이미 제 손으로 줄을 다시 접어 놨다.** 옛 폭에서
528
+ * 센 값으로 커서를 올리면 엉뚱한 줄에 덮어쓴다 — 대화가 지워지는 길이다.
529
529
  *
530
- * 그린줄을 0 으로 두면 위로 올라가고 지금 자리에서 새로 그린다. 창을
531
- * 줄인 직후 한 번은 낡은 상자가 위에 남을 있는데, 그건 스크롤로 지나가는
532
- * 자국일 뿐이다. **어디 있는지 모르는 줄을 짐작해서 덮어쓰는 것보다,
533
- * 안 건드리고 새로 그리는 편이 낫다.**
530
+ * 그래서 예전에는 크기가 조금이라도 다르면 그냥 버리고 지금 자리에서
531
+ * 새로 그렸다. "낡은 상자가 한 번 위에 남지만 스크롤 자국일 뿐" 이라는
532
+ * 셈이었는데, **한 번이 아니었다.**
533
+ *
534
+ * 창을 마우스로 끌면 터미널은 한 칸마다 크기를 새로 알려 준다. 끌면서
535
+ * 쉰 번이 오면 상자를 쉰 벌 찍고 하나도 안 지운다 — 사무실이 화면을
536
+ * 다다다 타고 내려가는 그 현상이 이것이다. 세로로 늘릴 때 특히 심하다.
537
+ * 28줄을 넘는 순간 사무실 열두 줄이 통째로 더 붙기 때문이다.
538
+ *
539
+ * 나눠서 다룬다. **접힘이 그대로면 들고 있던 수도 그대로 맞다.**
540
+ *
541
+ * 높이만 바뀌었다 — 줄 접힘은 폭이 정한다. 폭이 그대로면 접힘도 그대로라
542
+ * 그린줄·커서위 가 아직 맞다. 평소처럼 제대로 지운다.
543
+ * 폭이 바뀌었다 — 옛 수는 다 틀어졌다. 대신 커서 아래를 통째로 쓸어낸다.
544
+ * 상자는 늘 맨 아래에 있어서 그 아래에는 아무것도 없다.
545
+ * 위로는 안 올라가니 대화를 건드릴 일이 없다.
534
546
  */
535
547
  const 높이 = process.stdout.rows ?? null;
536
548
  if (this.그린폭 !== 폭 || this.그린높이 !== 높이) {
537
- this.앞줄들 = null;
538
- this.그린줄 = 0;
539
- this.커서위 = 0;
549
+ if (this.그린폭 === 폭) {
550
+ this.지우기();
551
+ } else {
552
+ if (this.그린줄) process.stdout.write('\r\x1b[J');
553
+ this.앞줄들 = null;
554
+ this.그린줄 = 0;
555
+ this.커서위 = 0;
556
+ }
540
557
  this.그린폭 = 폭;
541
558
  this.그린높이 = 높이;
542
559
  }