deel-local-cli 2.0.1 → 2.1.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 (72) hide show
  1. package/README.ko.md +34 -7
  2. package/README.md +35 -7
  3. package/bin/deel.js +34 -1
  4. package/package.json +1 -1
  5. package/src/acp/serve.js +83 -17
  6. package/src/agent/commit.js +2 -2
  7. package/src/agent/compact.js +4 -4
  8. package/src/agent/donecheck.js +97 -0
  9. package/src/agent/grade.js +1 -1
  10. package/src/agent/loop.js +176 -13
  11. package/src/agent/memory.js +1 -1
  12. package/src/agent/models.js +1 -1
  13. package/src/agent/modes.js +3 -0
  14. package/src/agent/outschema.js +1 -1
  15. package/src/agent/pins.js +7 -2
  16. package/src/agent/project.js +1 -1
  17. package/src/agent/recall.js +1 -1
  18. package/src/agent/session.js +15 -0
  19. package/src/agent/store.js +13 -26
  20. package/src/backend/ctxsize.js +2 -2
  21. package/src/backend/http.js +1 -1
  22. package/src/backend/learn.js +27 -1
  23. package/src/backend/mcp.js +97 -16
  24. package/src/backend/proxy.js +1 -1
  25. package/src/backend/quota.js +2 -2
  26. package/src/commands.js +24 -2
  27. package/src/completion.js +16 -3
  28. package/src/config.js +33 -11
  29. package/src/configexplain.js +1 -1
  30. package/src/doctor.js +11 -1
  31. package/src/eval/run.js +432 -0
  32. package/src/eval/starter.js +95 -0
  33. package/src/i18n/en.js +20 -0
  34. package/src/i18n/ja.js +20 -0
  35. package/src/i18n/ko.js +20 -0
  36. package/src/i18n/zh.js +20 -0
  37. package/src/jsonl.js +42 -0
  38. package/src/lsp/client.js +6 -46
  39. package/src/oneshot.js +85 -3
  40. package/src/pack/selfpack.js +2 -2
  41. package/src/pack/zip.js +18 -1
  42. package/src/plugins/manage.js +81 -14
  43. package/src/reap.js +70 -0
  44. package/src/repl.js +85 -11
  45. package/src/report.js +1 -1
  46. package/src/safety/audit.js +4 -19
  47. package/src/safety/authcmd.js +1 -1
  48. package/src/safety/guard.js +56 -6
  49. package/src/safety/hooks.js +1 -1
  50. package/src/safety/keystore.js +18 -3
  51. package/src/safety/policy.js +38 -0
  52. package/src/safety/shellenv.js +1 -1
  53. package/src/safety/undo.js +44 -20
  54. package/src/skills/discover.js +1 -1
  55. package/src/tools/docs.js +3 -3
  56. package/src/tools/edit-match.js +1 -1
  57. package/src/tools/encoding.js +1 -1
  58. package/src/tools/excel-com.js +56 -30
  59. package/src/tools/fastgrep.js +1 -1
  60. package/src/tools/fsutil.js +8 -4
  61. package/src/tools/ignore.js +21 -1
  62. package/src/tools/index.js +122 -11
  63. package/src/tools/jobs.js +23 -4
  64. package/src/tools/shell.js +27 -0
  65. package/src/tools/todo.js +1 -1
  66. package/src/tools/webfetch.js +1 -1
  67. package/src/tools/xlsx.js +4 -4
  68. package/src/ui/approve.js +57 -3
  69. package/src/ui/banner.js +1 -1
  70. package/src/ui/inputbox.js +1 -1
  71. package/src/ui/md.js +1 -1
  72. package/src/ui/motion.js +1 -1
package/src/i18n/ko.js CHANGED
@@ -312,6 +312,10 @@ export const ko = {
312
312
  'approve.strict.name': '모두 확인',
313
313
  'approve.strict.short': '모두',
314
314
  'approve.strict.line': '파일을 고치거나 명령을 돌리기 전에 매번 물어봅니다',
315
+ 'approve.newFile': '새 파일',
316
+ 'approve.locked': '관리 정책이 승인을 {바닥} 아래로 못 내리게 걸어 두었습니다 — {곳}',
317
+ 'approve.lockedUnknown': '관리 정책의 approval 값 "{값}" 을 몰라 제일 좁은 strict 로 칩니다 — {곳}',
318
+ 'approve.noAlways': '관리 정책이 승인을 걸어 두어 「앞으로 묻지 않기」 는 없습니다',
315
319
  'work.title': '작업 모드',
316
320
  'work.canEdit': '파일 바꿈',
317
321
  'work.readOnly': '읽기만',
@@ -388,6 +392,8 @@ export const ko = {
388
392
  'plan.ask': '이대로 진행할까요?',
389
393
  'plan.hint': '⏎ 진행 · y 한마디 붙여 진행 · 3 그 단계에 한마디 · n 취소',
390
394
  'plan.cancelled': '그만뒀습니다. 계획은 위에 남아 있으니 이어서 말씀하셔도 됩니다.',
395
+ 'plan.timedOut': '{초}초 동안 답이 없어 계획대로 진행합니다.',
396
+ 'plan.autoGo': '자율 모드라 묻지 않고 계획대로 진행합니다.',
391
397
  'plan.going': '계획대로 진행합니다.',
392
398
  'plan.goingWith': '한마디를 얹어 진행합니다 — {말}',
393
399
  'plan.replan': '그 점을 반영해 계획을 다시 냅니다.',
@@ -406,6 +412,9 @@ export const ko = {
406
412
  'ask.freePrompt': '답',
407
413
  // 물어보는 도중에 ESC 를 눌렀다. 물음이 화면에 걸린 채로 남으면 안 된다.
408
414
  'ask.stopped': '멈췄습니다 — 답하지 않은 것으로 넘어갑니다.',
415
+ 'ask.autoWait': '{초}초 안에 답이 없으면 알아서 진행합니다',
416
+ 'ask.timedOut': '{초}초 동안 답이 없어 알아서 진행합니다.',
417
+ 'ask.autoSkip': '자율 모드라 묻지 않고 진행합니다 — {물음}',
409
418
 
410
419
  // ── 잠깐 막혔을 때 (backend/retry.js) ─────────────────────────────────
411
420
  'loop.backoff': '서버가 잠시 막았습니다 ({무엇}) — {초}초 뒤 다시 부릅니다 ({n}/{max})',
@@ -540,6 +549,17 @@ export const ko = {
540
549
  'ev.hookNote': '훅: {말}',
541
550
  'ev.hookBlock': '훅이 막았습니다',
542
551
  'ev.nudgeRead': '읽기만 하고 끝내려고 해서 한 번 되밀었습니다',
552
+ 'check.start': '완료 검사 ({판}/{최대}) — {명령}',
553
+ 'check.pass': '완료 검사 통과 — {명령}',
554
+ 'check.fail': '완료 검사 실패 ({판}/{최대}) — {요약}',
555
+ 'check.retry': '실패 내용을 돌려주고 다시 고치게 합니다',
556
+ 'check.gaveUp': '{판}번 돌렸는데도 실패했습니다 — 실패로 끝냅니다',
557
+ 'check.skipped': '완료 검사를 못 돌렸습니다 — {까닭}',
558
+ 'cli.checkFlag': '끝내려 할 때 이 검사를 돌려 통과해야 끝남 (실패하면 고치게 하고, 끝내 실패면 8)',
559
+ 'cli.evalTitle': '골든셋',
560
+ 'cli.evalWhy': '— 과제 모음을 실제 모델로 돌려 몇 개를 해내나 잽니다',
561
+ 'cli.evalInit': '예제 과제 다섯을 golden/ 에 만듭니다',
562
+ 'cli.evalRun': '과제마다 임시 폴더에서 deel run 을 돌리고, 끝난 뒤 정답 검사로 채점합니다',
543
563
  'ev.learnedCtx': '서버가 알려 준 컨텍스트 한계 {한계} 로 맞추고 다시 부릅니다',
544
564
  'ev.learnedOut': '서버가 알려 준 답 길이 한계 {한계} 로 맞추고 다시 부릅니다',
545
565
  /*
package/src/i18n/zh.js CHANGED
@@ -290,6 +290,10 @@ export const zh = {
290
290
  'approve.strict.name': '每次都问',
291
291
  'approve.strict.short': '每次',
292
292
  'approve.strict.line': '每改一个文件、每跑一条命令都问',
293
+ 'approve.newFile': '新文件',
294
+ 'approve.locked': '管理策略把审批锁在 {바닥} 或更严 — {곳}',
295
+ 'approve.lockedUnknown': '不认识管理策略的 approval 值「{값}」,按最严的 strict 处理 — {곳}',
296
+ 'approve.noAlways': '管理策略锁定了审批,没有「以后不再问」',
293
297
  'work.title': '工作模式',
294
298
  'work.canEdit': '会改文件',
295
299
  'work.readOnly': '只读',
@@ -353,6 +357,8 @@ export const zh = {
353
357
  'plan.ask': '就按这个做吗?',
354
358
  'plan.hint': '⏎ 开始 · y 加一句再开始 · 3 对那一步说一句 · n 取消',
355
359
  'plan.cancelled': '停下了。计划还在上面,接着说就行。',
360
+ 'plan.timedOut': '{초}秒没有回答,按计划继续。',
361
+ 'plan.autoGo': '自动模式,不问直接按计划继续。',
356
362
  'plan.going': '按计划开始。',
357
363
  'plan.goingWith': '带上你这句话开始 — {말}',
358
364
  'plan.replan': '按这一点重新出计划。',
@@ -363,6 +369,9 @@ export const zh = {
363
369
  'ask.pickPrompt': '1-{끝}(后面可以加一句话),或者自己写',
364
370
  'ask.freePrompt': '回答',
365
371
  'ask.stopped': '已停止 — 按没有回答继续。',
372
+ 'ask.autoWait': '{초}秒内没有回答就自己继续',
373
+ 'ask.timedOut': '{초}秒没有回答,自己继续。',
374
+ 'ask.autoSkip': '自动模式,不问直接继续 — {물음}',
366
375
  'loop.backoff': '服务端把请求推回来了 ({무엇}) — {초} 秒后再试 ({n}/{max})',
367
376
  'cost.retries': '重试',
368
377
  // ── 计数词 ────────────────────────────────────────────────────────────
@@ -466,6 +475,17 @@ export const zh = {
466
475
  'ev.hookNote': '钩子:{말}',
467
476
  'ev.hookBlock': '钩子拦下了这一步',
468
477
  'ev.nudgeRead': '只看不动就要收尾,已经推回去一次',
478
+ 'check.start': '完成检查 ({판}/{최대}) — {명령}',
479
+ 'check.pass': '完成检查通过 — {명령}',
480
+ 'check.fail': '完成检查失败 ({판}/{최대}) — {요약}',
481
+ 'check.retry': '把失败内容交回去重新修',
482
+ 'check.gaveUp': '跑了 {판} 次仍然失败 — 按失败结束',
483
+ 'check.skipped': '没能跑完成检查 — {까닭}',
484
+ 'cli.checkFlag': '结束前跑这个检查,通过才结束(失败就让它修,最后仍失败则返回 8)',
485
+ 'cli.evalTitle': '黄金集',
486
+ 'cli.evalWhy': '— 用真实模型跑一组任务,测它能完成多少',
487
+ 'cli.evalInit': '在 golden/ 里建五个示例任务',
488
+ 'cli.evalRun': '每个任务在临时文件夹里跑 deel run,结束后用它没见过的检查评分',
469
489
  'ev.learnedCtx': '按服务端给出的上下文上限 {한계} 重新调用',
470
490
  'ev.learnedOut': '按服务端给出的回答长度上限 {한계} 重新调用',
471
491
  'ev.reset': '位置不够,已清掉前面 {버린수} 条对话继续做 — 还剩 {할일} 件待办',
package/src/jsonl.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 한 줄씩 덧붙여 가는 기록 파일(대화 · 감사기록 · 되돌리기 이력)이 같이 쓰는 두 손질.
3
+ *
4
+ * agent/store.js · safety/audit.js · safety/undo.js 에 바이트까지 같은 사본이 세 벌 있었다
5
+ * (2.0.0 7회차에 적어 둠). 한 벌을 고치고 두 벌을 잊는 날이 오므로 여기 한 벌만 둔다 (2.0.2).
6
+ *
7
+ * node:fs 말고는 아무것도 안 들여온다 — 되돌리기는 설정이 깨져도 돌아야 한다(undo.js 머리말).
8
+ * 그래서 `진짜자리()` 쌍둥이(guard · undo)는 여기로 안 모은다.
9
+ */
10
+ import { statSync, openSync, readSync, closeSync, chmodSync } from 'node:fs';
11
+
12
+ /**
13
+ * 파일이 개행으로 끝나나. 없거나 비었거나 못 읽으면 참 — 붙일 반쪽이 없다.
14
+ *
15
+ * 앞 판이 한 줄을 반만 적고 죽으면, 이어 적는 첫 줄이 그 반쪽 **뒤에 그대로 붙어** 한 줄이
16
+ * 되고, 그 줄은 JSON 이 아니라 다음에 읽을 때 통째로 건너뛴다. 부르는 쪽은 파일마다 처음
17
+ * 적을 때 한 번 이것을 보고, 아니면 개행부터 붙인다.
18
+ */
19
+ export function 줄로끝나나(file) {
20
+ let fd = null;
21
+ try {
22
+ const 크기 = statSync(file).size;
23
+ if (!크기) return true;
24
+ fd = openSync(file, 'r');
25
+ const 한바이트 = Buffer.alloc(1);
26
+ readSync(fd, 한바이트, 0, 1, 크기 - 1);
27
+ return 한바이트[0] === 0x0a;
28
+ } catch { return true; } // 없거나 못 읽으면 붙일 반쪽도 없다
29
+ finally { if (fd != null) { try { closeSync(fd); } catch { /* 닫다 터져도 적기는 한다 */ } } }
30
+ }
31
+
32
+ /**
33
+ * 본인만 읽게 잠근다. 건 결과를 돌려준다 — `{모드}` 면 걸었고, `{못함}` 이면 못 걸었다.
34
+ *
35
+ * 윈도우에서 chmod 는 **아무 일도 안 하고 성공한다.** 그 성공을 그대로 적으면 「잠근 척」
36
+ * 이 된다 — 같은 PC 를 여럿이 쓰는 사람이 화면만 보고 안심한다. 안 걸었으면 안 걸었다고 적는다.
37
+ */
38
+ export function 본인만잠그기(file) {
39
+ if (process.platform === 'win32') return { 못함: 'windows' };
40
+ try { chmodSync(file, 0o600); return { 모드: 0o600 }; }
41
+ catch (err) { return { 못함: err?.code ?? String(err) }; }
42
+ }
package/src/lsp/client.js CHANGED
@@ -30,6 +30,7 @@ import { pathToFileURL, fileURLToPath } from 'node:url';
30
30
  import { readFileSync } from 'node:fs';
31
31
  import { 갈래, 고르기, 언어아이디 } from './servers.js';
32
32
  import { 틀, 받개 } from './rpc.js';
33
+ import { 신호에거두기, 다시보낼까 } from '../reap.js';
33
34
 
34
35
  // 켜는 데 이만큼 넘게 걸리면 포기한다. rust-analyzer 처럼 무거운 것도
35
36
  // **악수(initialize)** 자체는 빠르다 — 오래 걸리는 것은 그 뒤의 색인이다.
@@ -606,53 +607,12 @@ export const 아이들데려가기 = () => {
606
607
  process.on('exit', 아이들데려가기);
607
608
 
608
609
  /*
609
- * ── 치우고 나면 **원래대로 죽어야 한다** ────────────────────────────────
610
- *
611
- * 여기는 세 신호에 같은 손을 달아 두고 치우기만 했다. 그런데 Node 는
612
- * SIGINT·SIGTERM 에 손이 하나라도 달려 있으면 **기본 동작(끝내기)을 안 한다.**
613
- * 그래서 이 파일이 한 번이라도 불려 들어온 프로그램은 —
614
- *
615
- * kill <pid> → 언어 서버는 죽고, deel 은 그대로 산다
616
- *
617
- * 유닉스에서 SIGTERM 을 **삼키는** 프로그램이 되어 있었다. 잡 관리자·컨테이너
618
- * 종료·systemd 가 전부 이 신호로 정리하는데, 그것들이 하나같이 시간을 다
619
- * 기다린 뒤 SIGKILL 로 때려잡게 된다. 그러면 정작 아이 정리는 못 하고 죽는다.
620
- *
621
- * 그래서 치운 다음 우리 손을 떼고 같은 신호를 자신에게 다시 보낸다. 그러면
622
- * 기본 동작이 돌아 128+n 로 끝난다 — 셸이 기대하는 그 값이다.
623
- *
624
- * 다만 **남이 그 신호를 맡고 있으면 안 건드린다.** 한 방 실행은 SIGINT 로
625
- * 턴만 끊고 살아 있어야 하고(oneshot.js), 대화 화면의 Ctrl+C 는 readline 이
626
- * 따로 받는다. 우리 것 말고 다른 손이 달려 있으면 그쪽 뜻이 먼저다.
627
- */
628
- /**
629
- * 치운 다음 그 신호로 진짜 죽어야 하나.
630
- *
631
- * 갈림을 함수로 빼 둔 까닭: 윈도우에서는 이 길을 통째로 밟아 볼 수가 없다
632
- * (SIGTERM 을 보내면 손이 돌기도 전에 Node 가 죽인다). 그런데 갈림 자체가
633
- * 틀리면 유닉스에서 한 방 실행의 Ctrl+C 가 턴만 끊는 대신 프로그램을 죽인다 —
634
- * 어느 판에서든 재어 볼 수 있어야 하는 자리다.
635
- *
636
- * @param 셈 이 신호에 달린 손의 수. 우리 것 하나만이면 우리가 마지막이다.
610
+ * 신호(SIGINT·SIGTERM)로 끝날 때도 거둔다. 손은 reap.js 에 하나만 단다 — 여기 따로 달면
611
+ * MCP·일감이 거둬지지 않고, 둘이 따로 달면 서로 「남이 맡았다」 고 보고 deel 이 SIGTERM 을
612
+ * 삼킨다(그 머리말). 다시보낼까 는 검사가 여기서 부르던 그대로 내보낸다.
637
613
  */
638
- export function 다시보낼까(신호, 셈 = process.listenerCount(신호)) {
639
- return Number(셈) <= 1;
640
- }
641
-
642
- for (const 신호 of ['SIGINT', 'SIGTERM']) {
643
- const 손 = () => {
644
- /*
645
- * 갈림을 **거두기보다 먼저** 본다. 거두고 나서 갈랐었다 — 그래서 대화 화면의 Ctrl+C 가
646
- * 턴만 끊고 세션은 사는데 언어 서버는 전부 죽고 풀도 비었다(2.0.0 6회차 사냥). 남이
647
- * 맡은 신호로 결국 끝나면 'exit' 그물(아이들데려가기)이 그때 거둔다.
648
- */
649
- if (!다시보낼까(신호)) return; // 남이 맡고 있다 — 그쪽 뜻이 먼저다
650
- 아이들데려가기();
651
- process.removeListener(신호, 손);
652
- try { process.kill(process.pid, 신호); } catch { process.exit(신호 === 'SIGINT' ? 130 : 143); }
653
- };
654
- process.on(신호, 손);
655
- }
614
+ 신호에거두기(아이들데려가기);
615
+ export { 다시보낼까 };
656
616
 
657
617
  /**
658
618
  * 켠 지 얼마 안 됐나.
package/src/oneshot.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // 여기서 루프를 다시 짜면 두 벌이 되고, 언젠가 한쪽만 고쳐진다.
13
13
  import { statSync } from 'node:fs';
14
14
  import { c, mark, clip, 화면글거르기 } from './ui/ansi.js';
15
- import { 규칙모으기, 정책읽기 } from './safety/policy.js';
15
+ import { 규칙모으기, 정책읽기, 승인바닥 } from './safety/policy.js';
16
16
  import { 훅읽기 } from './safety/hooks.js';
17
17
  import { 에이전트읽기 } from './agent/agents.js';
18
18
  import { 스키마읽기, 맞나, 답에서JSON뽑기, 시킬말 as 스키마시킬말 } from './agent/outschema.js';
@@ -33,6 +33,7 @@ import { 알림채움, 알림말 } from './backend/retry.js';
33
33
  import { 전선붙이기, 세션이름짓기 } from './backend/wire.js';
34
34
  import { newId } from './agent/store.js';
35
35
  import { discover, loadCommand } from './skills/discover.js';
36
+ import { 다붙이기 } from './backend/mcp.js';
36
37
  import { 명령들 } from './cmdnames.js';
37
38
  // 슬래시 명령을 찾는 규칙은 대화 화면과 **같은 것 하나**를 쓴다 (commands.js 의 슬래시명령찾기 머리말).
38
39
  import { 슬래시명령찾기, 비슷한슬래시명령 } from './commands.js';
@@ -46,6 +47,7 @@ import { route } from './agent/route.js';
46
47
  import { get as getWork, 보일이름 } from './agent/modes.js';
47
48
  import { 모두끝내기 as 일감모두끝내기, 일감인자 } from './tools/jobs.js';
48
49
  import { 첫이름 } from './tools/label.js';
50
+ import { 검사설정 } from './agent/donecheck.js';
49
51
 
50
52
  /**
51
53
  * 종료코드.
@@ -91,6 +93,16 @@ export const EXIT = {
91
93
  * 이건 스키마나 시킨 말을 바꾸는 일이다.
92
94
  */
93
95
  schema: 7,
96
+ /*
97
+ * 완료 검사가 통과하지 않은 채 끝났다 (agent/donecheck.js · 2.1.0).
98
+ *
99
+ * 모델은 「다 됐습니다」 라고 했는데 사람이 정해 둔 검사(`--check` · 설정의 `check`)가
100
+ * 정해 둔 판수를 다 돌고도 빨갛거나, 관문에 막혀 아예 못 돌았다. 0 으로 끝내면 야간
101
+ * 배치가 빨간 검사 위에 초록불을 켠다 — 이 기능이 없애려던 바로 그 결말이다.
102
+ *
103
+ * 오류(1)와 가른다. 고칠 자리가 다르다 — 연결이 아니라 코드나 검사 쪽이다.
104
+ */
105
+ check: 8,
94
106
  /*
95
107
  * 인자를 잘못 줬다 — 모르는 깃발 · 값 없는 깃발 · 모르는 모드 이름 · 못 읽는 --ctx.
96
108
  *
@@ -98,7 +110,7 @@ export const EXIT = {
98
110
  * 「걸음 수 상한」 이다. `deel run --jsn …` 을 CI 에 건 사람은 오타 하나로 「일이 커서
99
111
  * 멈췄다」 를 받고 작업을 쪼개러 간다. 고칠 자리가 스크립트 한 줄인데.
100
112
  *
101
- * 64 는 sysexits.h 의 EX_USAGE 다. 1~7 과 안 겹치고, 셸·CI 도구들이 「부른 모양이
113
+ * 64 는 sysexits.h 의 EX_USAGE 다. 1~8 과 안 겹치고, 셸·CI 도구들이 「부른 모양이
102
114
  * 틀렸다」 로 이미 알아듣는 수라 새로 지어내지 않았다. 이 경우는 모델을 한 번도 안 부른다.
103
115
  */
104
116
  usage: 64,
@@ -193,6 +205,8 @@ export async function runOnce(opts = {}) {
193
205
  let 출력스키마 = null;
194
206
  // 실패덩이에 실을 모델 이름 (사냥5 B5-10). 프로필을 찾기 전에 선 실패는 null 이다.
195
207
  let 알려진모델 = null;
208
+ // 붙인 MCP 서버. 아래 내놓기 가 닫는다 — 그래서 여기서 선언한다 (2.0.2 · 문 맞춤).
209
+ let mcp서버들 = [];
196
210
 
197
211
  const 내놓기 = (r) => {
198
212
  /*
@@ -208,6 +222,8 @@ export async function runOnce(opts = {}) {
208
222
  // 이건 아무 말 없이 한다 — 사용자가 띄우라고 한 적이 없는 것이라,
209
223
  // 껐다는 말부터 하면 "그건 또 뭐냐" 가 된다.
210
224
  언어서버다끄기().catch(() => {});
225
+ // 붙인 MCP 서버도 거둔다. 안 닫으면 잡이 끝난 뒤에도 남의 프로그램이 폴더를 물고 남는다.
226
+ for (const 서버 of mcp서버들) { try { 서버.닫기(); } catch { /* 닫다 터져도 끝맺음은 간다 */ } }
211
227
  /*
212
228
  * 문서를 글로 바꾸며 떨군 임시 파일도 거둔다 (tools/convert.js 의 임시치우기).
213
229
  *
@@ -461,6 +477,21 @@ export async function runOnce(opts = {}) {
461
477
 
462
478
  const found = discover(root);
463
479
  session.skills = found.skills;
480
+ /*
481
+ * 밖에서 붙인 도구(MCP) — 대화 화면·에디터와 **같은 규칙으로** 붙인다 (2.0.2 · 문 맞춤).
482
+ *
483
+ * 한 번 실행만 이 자리가 없었다. `.deel/mcp.json` 에 적어 둔 도구가 대화 화면에서는 되고
484
+ * `deel run` 에서는 **말없이 없었다** — 모델은 없는 도구 대신 셸로 돌아가고, 왜 없는지는
485
+ * 어디에도 안 남았다. 기본은 꺼져 있고(사람이 직접 적어야 뜬다) 봉인이면 안 띄우는 것도 같다.
486
+ * 못 붙은 것은 표준오류에 적는다 — 표준출력은 답 자리다.
487
+ */
488
+ const mcp붙임 = await 다붙이기(root, {
489
+ offline: 봉인됐나({ 깃발: opts.offline, prof, cfg }),
490
+ audit: new Audit(root, { 열쇠들: 열쇠묻기(conn) }),
491
+ }).catch((err) => ({ 서버들: [], 못한것: [{ 이름: '(전부)', 왜: String(err?.message ?? err) }] }));
492
+ mcp서버들 = mcp붙임.서버들;
493
+ session.mcp = mcp붙임.서버들;
494
+ for (const m of mcp붙임.못한것 ?? []) 곁(` ${mark.warn} ${c.yellow(`MCP ${m.이름} 을 못 붙였습니다 — ${m.왜}`)}`);
464
495
  session.commands = found.commands;
465
496
  session.plugins = found.plugins;
466
497
 
@@ -549,7 +580,16 @@ export async function runOnce(opts = {}) {
549
580
  // 승인은 기본이 거부다. 반대로 하면 안 된다 — 아무도 안 보는 자리에서
550
581
  // 되돌릴 수 없는 명령이 조용히 돌아가는 것이 이 프로그램이 제일 피하려는 일이다.
551
582
  // 정말 맡기고 싶은 사람은 --yes 로 그 뜻을 명시한다.
552
- const 자동승인 = opts.yes === true;
583
+ /*
584
+ * 관리 정책이 승인 바닥을 걸었으면 --yes 는 안 먹는다 (safety/policy.js 의 승인바닥).
585
+ * 바닥은 「바꾸기 전에 사람이 본다」 는 약속인데, --yes 는 바로 그 사람을 빼는 깃발이다.
586
+ * 둘이 같이 오면 정책이 이긴다 — 조용히 무시하지 않고 왜 안 먹는지 적는다.
587
+ */
588
+ const 바닥 = 승인바닥();
589
+ const 자동승인 = opts.yes === true && 바닥.바닥 === 'auto';
590
+ if (opts.yes === true && !자동승인) {
591
+ 곁(` ${c.yellow('⊘')} ${c.gray(`--yes 를 안 씁니다 — ${옮긴말('approve.locked', { 바닥: 바닥.바닥, 곳: 바닥.곳 ?? '' })}`)}`);
592
+ }
553
593
  /*
554
594
  * 사람이 적어 둔 훅을 읽는다 (safety/hooks.js).
555
595
  *
@@ -581,11 +621,15 @@ export async function runOnce(opts = {}) {
581
621
  history: new History(root),
582
622
  audit: new Audit(root, { 열쇠들: 열쇠묻기(conn) }),
583
623
  seen: new Set(),
624
+ // 붙은 MCP 서버. 도구를 부를 때 여기서 찾는다.
625
+ mcp: mcp붙임.서버들,
584
626
  skills: found.skills,
585
627
  loadedSkills: new Set(),
586
628
  // 고친 뒤 진단을 볼지 (lsp/diag.js). 아래 내놓기() 에서 반드시 거둔다 —
587
629
  // 안 거두면 배치가 끝나고도 언어 서버가 폴더를 물고 남는다.
588
630
  lsp: { 켬: true },
631
+ // 끝내려는 자리에서 돌릴 검사 (agent/donecheck.js). `--check` 가 설정의 check 를 이긴다.
632
+ 완료검사: 검사설정(cfg, opts.check),
589
633
  // 되물을 사람이 없으니 기본값을 그대로 돌려준다.
590
634
  ask: async (_label, o = {}) => o?.def ?? '',
591
635
  // 엑셀 암호를 여기서 기다리면 그대로 선다. 없다고 바로 답한다 —
@@ -709,6 +753,8 @@ export async function runOnce(opts = {}) {
709
753
  let steps = 0;
710
754
  let 이번단계글 = ''; // 지금 단계에서 모델이 흘린 글. 단계가 바뀌면 비운다
711
755
  let 답 = null;
756
+ // 완료 검사의 마지막 결과 (agent/donecheck.js). 안 돌았으면 null 이다.
757
+ let 검사결과 = null;
712
758
  // 서버가 끝났다는 말 없이 멈춘 적이 있나. 뒤에 오는 done 이 이걸 못 지운다.
713
759
  let 말없이끊겼나 = false;
714
760
  // 비우느라 시킨 말 뒤가 잘렸나 (사냥5 B5-01). 무엇으로 끝났든 이것이 까닭이 된다.
@@ -881,6 +927,20 @@ export async function runOnce(opts = {}) {
881
927
  곁(` ${c.yellow('✗')} ${c.gray(옮긴말('ev.hookBlock'))}`);
882
928
  break;
883
929
 
930
+ case 'check_start':
931
+ 곁(` ${c.cyan('⧗')} ${c.gray(옮긴말('check.start', { 판: ev.판, 최대: ev.최대, 명령: ev.명령 }))}`);
932
+ break;
933
+
934
+ case 'check':
935
+ if (ev.ok === true) 곁(` ${mark.ok} ${c.gray(옮긴말('check.pass', { 명령: ev.명령 }))}`);
936
+ else if (ev.ok === false) {
937
+ 곁(` ${c.red('✗')} ${c.gray(옮긴말('check.fail', { 판: ev.판, 최대: ev.최대, 요약: ev.요약 }))}`);
938
+ // 실패 출력의 끝 몇 줄 — 배치 로그를 보는 사람이 무엇이 틀렸는지 여기서 안다.
939
+ for (const 줄 of String(ev.꼬리 ?? '').trim().split('\n').slice(-8)) if (줄.trim()) 곁(` ${c.gray(줄)}`);
940
+ 곁(` ${c.gray(ev.판 < ev.최대 ? 옮긴말('check.retry') : 옮긴말('check.gaveUp', { 판: ev.판 }))}`);
941
+ } else 곁(` ${c.yellow('⊘')} ${c.gray(옮긴말('check.skipped', { 까닭: ev.까닭 }))}`);
942
+ break;
943
+
884
944
  case 'nudge':
885
945
  곁(` ${c.gray(`↺ ${ev.why === '요청누락'
886
946
  ? 옮긴말('ev.nudgeMissed', { n: ev.빠진?.length ?? 0 })
@@ -965,6 +1025,7 @@ export async function runOnce(opts = {}) {
965
1025
  case 'done':
966
1026
  reason = 'done';
967
1027
  답 = ev.text ?? 이번단계글;
1028
+ 검사결과 = ev.검사 ?? null;
968
1029
  break;
969
1030
  }
970
1031
  }
@@ -1097,6 +1158,19 @@ export async function runOnce(opts = {}) {
1097
1158
  }
1098
1159
  }
1099
1160
 
1161
+ /*
1162
+ * 모델은 끝났다고 했는데 완료 검사가 통과하지 않았다 — 종료코드 8 (위 EXIT.check).
1163
+ *
1164
+ * 모양 판정(7)보다 **뒤에** 본다. 모양도 틀렸으면 그쪽을 먼저 고쳐야 답을 읽을 수 있다.
1165
+ * 아무것도 안 바꾼 턴은 검사가 안 돌아 검사결과가 null 이다 — 그건 실패가 아니다.
1166
+ */
1167
+ if (reason === 'done' && 검사결과 && 검사결과.ok !== true) {
1168
+ reason = 'check';
1169
+ why = 검사결과.ok === false
1170
+ ? 옮긴말('check.gaveUp', { 판: 검사결과.판 }) + ` — ${검사결과.명령}`
1171
+ : 옮긴말('check.skipped', { 까닭: 검사결과.까닭 });
1172
+ }
1173
+
1100
1174
  /*
1101
1175
  * SIGINT 손은 **되물음까지 끝난 뒤에** 뗀다 (사냥5 B5-02).
1102
1176
  *
@@ -1143,6 +1217,14 @@ export async function runOnce(opts = {}) {
1143
1217
  // 모양을 못 박았을 때만 실린다. --json 으로 받는 쪽은 text 를 다시 파싱할
1144
1218
  // 필요 없이 이 칸을 그대로 쓰면 된다.
1145
1219
  ...(스키마값 !== null ? { schema: 스키마값 } : {}),
1220
+ // 완료 검사를 돌렸을 때만 실린다. ok 가 null 이면 못 돌린 것이다(why 에 까닭).
1221
+ ...(검사결과 ? {
1222
+ check: {
1223
+ ok: 검사결과.ok, command: 검사결과.명령, rounds: 검사결과.판, max: 검사결과.최대,
1224
+ ...(검사결과.요약 ? { summary: 검사결과.요약 } : {}),
1225
+ ...(검사결과.까닭 ? { why: 검사결과.까닭 } : {}),
1226
+ },
1227
+ } : {}),
1146
1228
  ms: Date.now() - t0,
1147
1229
  ...(why ? { why } : {}),
1148
1230
  });
@@ -34,7 +34,7 @@ function walk(dir, base, out) {
34
34
  return out;
35
35
  }
36
36
 
37
- export function shippedFiles(root = repoRoot()) {
37
+ function shippedFiles(root = repoRoot()) {
38
38
  const out = [];
39
39
  for (const d of SHIP) if (existsSync(join(root, d))) walk(join(root, d), root, out);
40
40
  for (const f of SHIP_FILES) if (existsSync(join(root, f))) out.push(f);
@@ -59,7 +59,7 @@ const PROBES = [
59
59
  note: '없어야 정상입니다' },
60
60
  ];
61
61
 
62
- export function scanCalls(root = repoRoot(), files = shippedFiles(root)) {
62
+ function scanCalls(root = repoRoot(), files = shippedFiles(root)) {
63
63
  const found = Object.fromEntries(PROBES.map((p) => [p.id, []]));
64
64
  for (const f of files) {
65
65
  if (!f.endsWith('.js')) continue;
package/src/pack/zip.js CHANGED
@@ -56,13 +56,26 @@ const UTF8_NAMES = 0x0800; // 플래그 11번 비트 — 이름이 UTF-8 이
56
56
  */
57
57
  export const 풀기총상한 = 256 * 1024 * 1024;
58
58
 
59
+ /*
60
+ * ── zip64 없이 담을 수 있는 끝 (2.0.2 · Z5) ───────────────────────────────────
61
+ *
62
+ * 이 zip 은 zip64 를 안 쓴다. 항목 수·이름 길이는 16비트, 크기·자리는 32비트 칸이다.
63
+ * 넘으면 `writeUInt16LE` 가 날것 RangeError(「must be >= 0 and <= 65535」)를 던졌다 —
64
+ * 그것도 파일 수만 개를 **다 눌러 담은 뒤** 맨 끝 표식을 쓰다가. 무엇이 넘었는지 사람
65
+ * 말로, 넘는 순간에 멈춘다. 검사에서 끝을 좁혀 잴 수 있게 한도는 넘겨받는다.
66
+ */
67
+ export const zip한도 = { 개수: 0xffff, 이름: 0xffff, 크기: 0xffffffff };
68
+
59
69
  /**
60
70
  * @param {Array<{name:string, data:Buffer, mtime?:Date, mode?:number}>} entries
61
71
  * name 은 zip 안에서의 경로. 구분자는 항상 '/'.
62
72
  * mode 는 유닉스 권한(예: 0o755). 실행 파일에 필요하다.
63
73
  * @returns {Buffer}
64
74
  */
65
- export function makeZip(entries) {
75
+ export function makeZip(entries, 한도 = zip한도) {
76
+ if (entries.length > 한도.개수) {
77
+ throw new Error(`zip 에 담을 파일이 ${entries.length.toLocaleString('en-US')}개입니다 — 한 묶음에 ${한도.개수.toLocaleString('en-US')}개까지 담습니다 (zip64 는 만들지 않습니다). 나눠서 묶으세요.`);
78
+ }
66
79
  const locals = [];
67
80
  const central = [];
68
81
  let offset = 0;
@@ -77,6 +90,10 @@ export function makeZip(entries) {
77
90
  const useDeflate = packed.length < raw.length;
78
91
  const body = useDeflate ? packed : raw;
79
92
  const method = useDeflate ? 8 : 0;
93
+ if (nameBuf.length > 한도.이름) throw new Error(`zip 에 담을 이름이 너무 깁니다 (${nameBuf.length}바이트) — ${한도.이름}바이트까지: ${e.name.slice(0, 80)}…`);
94
+ if (raw.length > 한도.크기 || offset + 30 + nameBuf.length + body.length > 한도.크기) {
95
+ throw new Error(`zip 이 zip64 없이 담는 크기(${한도.크기.toLocaleString('en-US')}바이트 · 약 4GB)를 넘습니다 — 나눠서 묶으세요 (넘은 자리: ${e.name})`);
96
+ }
80
97
 
81
98
  const when = 담을시각(e.mtime ?? new Date());
82
99
  const time = dosTime(when);
@@ -6,7 +6,7 @@ import { execFile } from 'node:child_process';
6
6
  import { homeDir } from '../config.js';
7
7
  import { join, dirname, basename, resolve, relative, sep } from 'node:path';
8
8
  import {
9
- existsSync, mkdirSync, writeFileSync, readFileSync, rmSync,
9
+ existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, renameSync,
10
10
  readdirSync, statSync,
11
11
  } from 'node:fs';
12
12
  import { untargz, stripTop, 밖을가리키는것 } from '../pack/tar.js';
@@ -107,13 +107,26 @@ export function 묶음풀기(dest, files) {
107
107
  * 고 하고 하나만 남겼다. 쓰기 전에 전부 재 보고 사람 말로 거절한다.
108
108
  */
109
109
  const 뿌리 = resolve(dest);
110
- const 자리들 = new Map(); // 소문자 상대 자리 → 적힌 이름
110
+ /*
111
+ * ── 쓸 이름도 **잰 이름과 같게** (2.0.2 · P3 · P1) ──────────────────────────
112
+ *
113
+ * 아래 재기는 역슬래시를 빗금으로 보고 쟀는데, 쓸 때는 적힌 이름 그대로 `join` 했다.
114
+ * 윈도에서는 둘이 같지만 리눅스·맥에서는 역슬래시가 **이름 글자**라, 윈도에서 만든
115
+ * 묶음의 `sub\file.txt` 가 `sub` 폴더 안이 아니라 뿌리에 한 파일로 풀렸다 — 스킬이
116
+ * 제자리를 못 찾는다. 이름을 한 번 빗금으로 편 것을 재기와 쓰기가 같이 쓴다.
117
+ *
118
+ * 같은 자리 견주기에는 NFC 도 맞춘다. 맥은 `한글`(NFC)과 `한글`(NFD)을 한 파일로
119
+ * 보므로, 두 꼴이 같이 든 묶음은 맥에서 하나가 말없이 사라진다. 대소문자만 다른
120
+ * 이름과 같은 대접으로 묶음째 거절한다.
121
+ */
122
+ const 편이름 = (f) => String(f.name ?? '').replace(/\\/g, '/');
123
+ const 자리들 = new Map(); // 소문자·NFC 상대 자리 → 적힌 이름
111
124
  for (const f of files) {
112
- const 상대 = relative(뿌리, resolve(뿌리, String(f.name ?? '').replace(/\\/g, '/'))).replace(/\\/g, '/');
125
+ const 상대 = relative(뿌리, resolve(뿌리, 편이름(f))).replace(/\\/g, '/');
113
126
  if (!상대) return { error: `묶음 안에 풀 폴더 그 자체를 가리키는 파일이 있습니다 — 풀지 않았습니다: ${f.name}` };
114
- const 열쇠 = 상대.toLowerCase();
127
+ const 열쇠 = 상대.normalize('NFC').toLowerCase();
115
128
  if (자리들.has(열쇠)) {
116
- return { error: `묶음 안에 같은 자리를 가리키는 이름이 둘 있습니다(대소문자만 다를 수 있음) — 풀지 않았습니다: ${자리들.get(열쇠)} · ${f.name}` };
129
+ return { error: `묶음 안에 같은 자리를 가리키는 이름이 둘 있습니다(대소문자나 한글 자모 꼴만 다를 수 있음) — 풀지 않았습니다: ${자리들.get(열쇠)} · ${f.name}` };
117
130
  }
118
131
  자리들.set(열쇠, f.name);
119
132
  }
@@ -126,13 +139,32 @@ export function 묶음풀기(dest, files) {
126
139
  }
127
140
  rmSync(dest, { recursive: true, force: true });
128
141
  for (const f of files) {
129
- const p = join(dest, f.name);
142
+ const p = join(dest, 편이름(f));
130
143
  mkdirSync(dirname(p), { recursive: true });
131
144
  writeFileSync(p, f.data);
132
145
  }
133
146
  return { 푼것: files.length };
134
147
  }
135
148
 
149
+ /*
150
+ * ── git 없이 받을 주소들 (2.0.2 · P5) ─────────────────────────────────────────
151
+ *
152
+ * `#v1.0.0` 처럼 **태그**를 적어도 `refs/heads/` 만 물어서, git 이 없는 PC 에서는 늘
153
+ * 「받지 못했습니다 — 가지 이름을 확인하세요」 였다. git 이 있으면 `clone --branch` 가
154
+ * 태그도 받으니, 같은 명령이 git 유무에 따라 되고 안 됐다. 가지를 먼저 묻고 없으면
155
+ * 태그를 묻는다(같은 이름이면 git 도 가지를 먼저 고른다).
156
+ */
157
+ export function 받을주소들(spec) {
158
+ const 뿌리 = `https://codeload.github.com/${spec.owner}/${spec.repo}/tar.gz`;
159
+ if (spec.ref) {
160
+ return [
161
+ { branch: spec.ref, url: `${뿌리}/refs/heads/${spec.ref}` },
162
+ { branch: spec.ref, url: `${뿌리}/refs/tags/${spec.ref}` },
163
+ ];
164
+ }
165
+ return ['main', 'master'].map((b) => ({ branch: b, url: `${뿌리}/refs/heads/${b}` }));
166
+ }
167
+
136
168
  // 받는 방법 두 가지. git 이 있으면 clone, 없으면 tarball 을 내려받아 푼다.
137
169
  async function fetchInto(spec, dest, onStep) {
138
170
  if (await has('git')) {
@@ -150,8 +182,7 @@ async function fetchInto(spec, dest, onStep) {
150
182
 
151
183
  onStep?.('tarball 내려받기');
152
184
  if (isOffline()) return { error: '오프라인 모드입니다 — 받아 올 수 없습니다. 풀어 놓은 폴더 경로를 주세요.' };
153
- for (const branch of spec.ref ? [spec.ref] : ['main', 'master']) {
154
- const url = `https://codeload.github.com/${spec.owner}/${spec.repo}/tar.gz/refs/heads/${branch}`;
185
+ for (const { branch, url } of 받을주소들(spec)) {
155
186
  // 사용자가 이 명령을 친 동안만 github 를 연다. 끝나면 바로 닫는다.
156
187
  const close = allowTemporarily(url);
157
188
  let gz;
@@ -183,7 +214,7 @@ async function fetchInto(spec, dest, onStep) {
183
214
  if (푼것.error) return { error: 푼것.error };
184
215
  return { how: 'tarball', branch };
185
216
  }
186
- return { error: '받지 못했습니다 — 저장소 주소나 가지 이름을 확인하세요' };
217
+ return { error: '받지 못했습니다 — 저장소 주소나 가지·태그 이름을 확인하세요' };
187
218
  }
188
219
 
189
220
  /**
@@ -246,7 +277,7 @@ export function 이름한칸(이름) {
246
277
  * 자르는 자가 언젠가 틀릴 수 있다. 그때 마지막으로 서는 그물이다. 지우고
247
278
  * 쓰는 자리라 두 겹으로 막는다.
248
279
  */
249
- export function 플러그인자리인가(base, dest) {
280
+ function 플러그인자리인가(base, dest) {
250
281
  const 뿌리 = resolve(base);
251
282
  const 갈곳 = resolve(dest);
252
283
  return 갈곳 !== 뿌리 && 갈곳.startsWith(뿌리 + sep);
@@ -312,7 +343,7 @@ export async function install(spec, 옵션 = {}) {
312
343
  }
313
344
  }
314
345
 
315
- async function 설치하기(spec, { home = homeDir(), onStep } = {}, 치울것 = []) {
346
+ async function 설치하기(spec, { home = homeDir(), onStep, 복사 = copyDir } = {}, 치울것 = []) {
316
347
  const base = pluginsDir(home);
317
348
 
318
349
  // 이미 풀어 놓은 폴더를 그대로 넣는 길. 오프라인 기기에서 이쪽을 쓴다.
@@ -383,9 +414,43 @@ async function 설치하기(spec, { home = homeDir(), onStep } = {}, 치울것 =
383
414
  return { error: `스킬도 명령도 없습니다. 플러그인이 맞는지 확인하세요 (${isLocal ? asPath : parsed.owner + '/' + parsed.repo})` };
384
415
  }
385
416
 
386
- rmSync(dest, { recursive: true, force: true });
417
+ /*
418
+ * ── 옛것을 **먼저 지우지 않는다** (2.0.2 · P7) ──────────────────────────────
419
+ *
420
+ * 다시 설치(업데이트)는 옛 폴더를 지우고 새것을 베꼈다. 베끼다 넘어지면(디스크가
421
+ * 꽉 참 · 잠긴 파일 · 긴 경로) **둘 다 잃었다** — 옛것은 지웠고 새것은 반쪽이다.
422
+ * 업데이트 한 번에 쓰던 플러그인이 사라지는 것이다.
423
+ *
424
+ * 옛것을 점 이름(`.old-…` — list() 가 안 보는 이름)으로 비켜 두고 베낀다. 다 베끼면
425
+ * 비켜 둔 것을 버리고, 넘어지면 반쪽을 치우고 비켜 둔 것을 제자리로 되돌린다.
426
+ * 비키기(rename)부터 안 되면(윈도에서 그 폴더의 파일을 연 프로그램이 있으면 흔하다)
427
+ * 아무것도 안 건드리고 그렇다고 말한다.
428
+ */
387
429
  mkdirSync(dirname(dest), { recursive: true });
388
- copyDir(tmp, dest, { skipped: 건너뛴것 });
430
+ const 옛것있나 = existsSync(dest);
431
+ const 비켜둘곳 = join(base, `.old-${name}-${process.pid}-${Date.now()}`);
432
+ if (옛것있나) {
433
+ try { renameSync(dest, 비켜둘곳); }
434
+ catch (err) {
435
+ rmSync(tmp, { recursive: true, force: true });
436
+ return { error: `깔려 있던 ${name} 을 비키지 못해 다시 설치하지 않았습니다 — 쓰던 것은 그대로입니다 (${err?.code ?? err?.message ?? err}). 그 폴더의 파일을 연 프로그램을 닫고 다시 하세요.` };
437
+ }
438
+ }
439
+ try {
440
+ 복사(tmp, dest, { skipped: 건너뛴것 });
441
+ } catch (err) {
442
+ try { rmSync(dest, { recursive: true, force: true }); } catch { /* 반쪽을 못 치우면 아래 되돌리기가 넘어진다 — 그 말도 한다 */ }
443
+ let 되돌림 = '쓰던 것은 그대로입니다';
444
+ if (옛것있나) {
445
+ try { renameSync(비켜둘곳, dest); }
446
+ catch { 되돌림 = `쓰던 것은 ${비켜둘곳} 에 남아 있습니다 — 폴더 이름을 ${name} 으로 되돌리면 됩니다`; }
447
+ } else {
448
+ 되돌림 = '반쪽은 치웠습니다';
449
+ }
450
+ rmSync(tmp, { recursive: true, force: true });
451
+ return { error: `${name} 을 베끼지 못해 설치하지 않았습니다 — ${되돌림} (${err?.code ?? err?.message ?? err})` };
452
+ }
453
+ if (옛것있나) { try { rmSync(비켜둘곳, { recursive: true, force: true }); } catch { /* 못 버려도 list() 는 점 이름을 안 본다 */ } }
389
454
  rmSync(tmp, { recursive: true, force: true });
390
455
  // 개수는 **깔린 자리**에서 다시 센다. 복사하다 빠진 것이 여기서 빠진다.
391
456
  const counts = countIn(dest);
@@ -569,7 +634,9 @@ export function pack(outFile, { home = homeDir(), only = null } = {}) {
569
634
  ].join('\n');
570
635
  entries.unshift({ name: '사용안내.txt', data: Buffer.from(manifest, 'utf8') });
571
636
 
572
- const zip = makeZip(entries);
637
+ // 한도(파일 65,535개 · 4GB)를 넘으면 makeZip 이 사람 말로 던진다 — 명령째 죽지 않게 받아 올린다 (2.0.2 · Z5).
638
+ let zip;
639
+ try { zip = makeZip(entries); } catch (err) { return { error: String(err?.message ?? err) }; }
573
640
  mkdirSync(dirname(outFile), { recursive: true });
574
641
  writeFileSync(outFile, zip);
575
642
  return { out: outFile, plugins: included, files: entries.length, skipped, bytes: zip.length, manifest };