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
@@ -30,9 +30,13 @@ import { join, normalize, isAbsolute, resolve } from 'node:path';
30
30
  import { VERSION } from '../version.js';
31
31
  // 남의 저장소에 딸려 온 mcp.json 으로 남의 프로그램을 띄우지 않는다 (다붙이기 머리말).
32
32
  import { 믿나, BOM떼기 } from '../safety/trust.js';
33
+ // 신호로 끝날 때도 띄운 서버를 거둔다 — 손은 한 자리에만 단다 (reap.js 머리말).
34
+ import { 신호에거두기 } from '../reap.js';
33
35
 
34
36
  // 붙는 데 이만큼 넘게 걸리면 포기한다. 시작이 느려지면 안 쓰게 된다.
35
- export const 붙기제한 = 8000;
37
+ const 붙기제한 = 8000;
38
+ // 쓰던 서버가 저 혼자 죽으면 한 세션에 이만큼까지 다시 띄운다 (MCP서버.깨우기 머리말).
39
+ export const 되살리기최대 = 2;
36
40
  // 도구 하나 부르고 이만큼 기다린다.
37
41
  export const 부르기제한 = 60000;
38
42
  // 한 서버에서 받을 도구 수. 스키마가 통째로 매 요청에 실리므로 무한정 받으면
@@ -277,6 +281,22 @@ export function 모두닫기() {
277
281
  * 검사가 밖에서 확인할 길이 없다. 걷어내도 아무도 모르는 그물은 없는 것과 같다.
278
282
  */
279
283
  process.once('exit', 모두닫기);
284
+ 신호에거두기(모두닫기);
285
+
286
+ /**
287
+ * 약속을 기다리되 신호가 오면 **기다리기만** 그만둔다. 약속 자체는 안 끊는다 (부르기 · MB4).
288
+ */
289
+ function 끊기며기다리기(약속, signal) {
290
+ if (!signal) return 약속;
291
+ if (signal.aborted) return Promise.reject(new Error('중단했습니다'));
292
+ let 떼기 = () => {};
293
+ const 끊김 = new Promise((_, 실패) => {
294
+ const 손 = () => 실패(new Error('중단했습니다'));
295
+ signal.addEventListener?.('abort', 손, { once: true });
296
+ 떼기 = () => signal.removeEventListener?.('abort', 손);
297
+ });
298
+ return Promise.race([약속, 끊김]).finally(() => 떼기());
299
+ }
280
300
 
281
301
  /**
282
302
  * 서버 하나와의 연결.
@@ -316,11 +336,20 @@ export class MCP서버 {
316
336
  * 폴더 뿌리가 아니고, 그러면 남의 폴더에 메모를 적는다.
317
337
  */
318
338
  this.뿌리 = null;
339
+ /** 저 혼자 죽어 다시 띄운 수. 되살리기최대 를 넘으면 더 안 띄운다. */
340
+ this.되살린수 = 0;
341
+ /** 우리가 닫았나(닫기). 닫은 것은 되살리지 않는다. */
342
+ this.닫음 = false;
319
343
  }
320
344
 
321
345
  살아있나() { return !!this.kid && this.kid.exitCode === null && !this.죽음; }
322
- /** 지금 도구를 부를 수 있나. 대기 중이면 부르는 순간 뜬다. */
323
- 쓸수있나() { return this.살아있나() || (this.대기 && !this.죽음); }
346
+ /** 지금 도구를 부를 수 있나. 대기 중이면 부르는 순간 뜬다. 저 혼자 죽은 것도 되살린다. */
347
+ 쓸수있나() { return this.살아있나() || (this.대기 && !this.죽음) || this.되살릴수있나(); }
348
+ /**
349
+ * 죽었지만 다음 부름에 다시 띄울 것인가 (깨우기 머리말).
350
+ * 우리가 닫은 것(닫음)과 한 세션에 되살리기최대 번을 넘긴 것은 안 띄운다.
351
+ */
352
+ 되살릴수있나() { return !!this.죽음 && !this.닫음 && !this.대기 && this.되살린수 < 되살리기최대; }
324
353
 
325
354
  /**
326
355
  * 적어 둔 목록으로 세워 둔다. **안 띄운다.**
@@ -354,12 +383,32 @@ export class MCP서버 {
354
383
  */
355
384
  if (this.깨우는중) return this.깨우는중;
356
385
  if (this.살아있나()) return true;
357
- if (this.죽음) return false;
386
+ /*
387
+ * ── 쓰던 서버가 죽으면 **다음 부름에 다시 띄운다** (2.0.2 · M1 · MB3) ─────────
388
+ *
389
+ * 여기서 `죽음` 이면 곧장 false 였다. 그래서 아래 「죽음 을 지우고 다시 붙는다」 는 한 번도
390
+ * 닿지 않는 줄이었고, 도중에 한 번 넘어진 서버(메모리 부족 · 제 오류로 끝남)는 세션 내내
391
+ * 「죽었습니다」 였다 — 도구가 통째로 사라진 채 대화를 이어 가야 했다.
392
+ *
393
+ * 떠서 쓰던 서버가 **저 혼자** 죽었으면 되살린다. 한 세션에 되살리기최대 번까지만 —
394
+ * 뜨자마자 죽는 서버를 부를 때마다 띄우면 부름마다 붙기제한만큼 멈춘다. 우리가 닫은 것
395
+ * (넘침 · 깨우다 실패 · 끝날 때)은 안 되살린다(되살릴수있나).
396
+ */
397
+ if (this.죽음 && !this.되살릴수있나()) return false;
398
+ const 되살림 = !!this.죽음;
358
399
  const 적어둔것 = this.도구.map((t) => t.name).join('\0');
359
400
  // 속까지 견주려면 정의를 그대로 들고 있어야 한다 (아래 바뀐것).
360
401
  const 적어둔도구 = this.도구;
361
402
  this.깨우는중 = (async () => {
362
- // 죽음 은 붙기() 가 실패하며 남긴다. 다시 붙으려면 지워 두고 시작한다.
403
+ if (되살림) {
404
+ this.되살린수++;
405
+ // 옛 아이를 명부에서 빼고, 받다 만 조각은 버린다 — 새 아이의 첫 줄에 붙으면 안 된다.
406
+ 띄운것들.delete(this);
407
+ try { this.kid?.kill(); } catch { /* 이미 죽었다 */ }
408
+ this.찌꺼기 = '';
409
+ this.마지막말 = undefined;
410
+ }
411
+ // 죽음 은 끝냄() 이 남긴다. 다시 붙으려면 지워 두고 시작한다.
363
412
  this.죽음 = null;
364
413
  const ok = await this.붙기({ timeout });
365
414
  this.대기 = false;
@@ -438,14 +487,21 @@ export class MCP서버 {
438
487
  // 떠 있으므로, 여기서 안 적으면 그 아이는 아무도 안 거두는 아이가 된다.
439
488
  띄운것들.add(this);
440
489
 
441
- this.kid.on('error', (e) => this.끝냄(`오류: ${e.message}`));
442
- this.kid.on('exit', (code, sig) => this.끝냄(`끝났습니다 (코드 ${code ?? sig})`));
443
- this.kid.stdout.setEncoding('utf8');
444
- this.kid.stdout.on('data', (d) => this.받음(d));
490
+ /*
491
+ * **이 아이의** 소리만 듣는다 (2.0.2 · M1). 되살리면 한 서버에 아이가 둘 거쳐 간다 —
492
+ * 옛 아이의 늦은 'exit' 이 새 아이를 죽음 으로 적거나, 옛 아이의 마지막 조각이 새 아이의
493
+ * 첫 줄에 붙으면 안 된다.
494
+ */
495
+ const 아이 = this.kid;
496
+ const 지금아이 = () => this.kid === 아이;
497
+ 아이.on('error', (e) => { if (지금아이()) this.끝냄(`오류: ${e.message}`); });
498
+ 아이.on('exit', (code, sig) => { if (지금아이()) this.끝냄(`끝났습니다 (코드 ${code ?? sig})`); });
499
+ 아이.stdout.setEncoding('utf8');
500
+ 아이.stdout.on('data', (d) => { if (지금아이()) this.받음(d); });
445
501
  // 서버가 stderr 에 로그를 쏟는 일이 흔하다. 화면에 흘리면 대화가 뒤덮인다.
446
502
  // 마지막 것만 들고 있다가 죽었을 때 원인으로 보여 준다.
447
- this.kid.stderr.setEncoding('utf8');
448
- this.kid.stderr.on('data', (d) => { this.마지막말 = String(d).trim().slice(-400); });
503
+ 아이.stderr.setEncoding('utf8');
504
+ 아이.stderr.on('data', (d) => { if (지금아이()) this.마지막말 = String(d).trim().slice(-400); });
449
505
 
450
506
  try {
451
507
  const r = await this.보내고기다리기('initialize', {
@@ -643,14 +699,27 @@ export class MCP서버 {
643
699
  }
644
700
 
645
701
  async 부르기(도구이름, args, { timeout = 부르기제한, signal = null } = {}) {
702
+ // 이미 멈췄으면 깨우지도 않는다 — 안 쓸 서버를 띄워 두는 것이다.
703
+ if (signal?.aborted) throw new Error('중단했습니다');
646
704
  /*
647
705
  * 대기 중이면 **여기서** 띄운다. 이게 지연 로딩의 전부다.
648
706
  *
649
707
  * 첫 부름 하나만 붙는 시간을 치르고, 그 뒤로는 여느 때와 똑같다. 켤 때
650
708
  * 다 띄우던 값을 「그 도구를 실제로 쓰는 사람」 에게만 물리는 셈이다.
651
709
  */
652
- if (this.대기) {
653
- const ok = await this.깨우기();
710
+ if (this.대기 || this.되살릴수있나()) {
711
+ /*
712
+ * ── 깨우는 동안에도 ESC 가 먹는다 (2.0.2 · MB4) ──────────────────────────
713
+ *
714
+ * 깨우기에는 신호를 안 넘겼다. 그래서 붙는 동안(붙기제한 8초까지) ESC 가 아무것도 안
715
+ * 했다. 신호를 깨우기에 넘기면 안 된다 — 깨우는 약속은 **같이 부른 도구들이 나눠
716
+ * 기다린다**(MB1). 한 사람의 ESC 가 그 약속을 끊으면 같이 기다리던 부름까지 끊기고,
717
+ * 반쯤 붙은 서버가 죽음 으로 남아 세션 내내 못 쓴다.
718
+ *
719
+ * 그래서 **이 부름만** 기다리기를 그만둔다. 깨우기는 뒤에서 제 길을 가고, 다 뜨면
720
+ * 다음 부름이 곧장 쓴다.
721
+ */
722
+ const ok = await 끊기며기다리기(this.깨우기(), signal);
654
723
  if (!ok) throw new Error(this.죽음 ?? '띄우지 못했습니다');
655
724
  /*
656
725
  * 띄우고 보니 그 도구가 없어졌으면 **그렇다고 말한다.**
@@ -703,6 +772,7 @@ export class MCP서버 {
703
772
  }
704
773
 
705
774
  닫기() {
775
+ this.닫음 = true;
706
776
  this.끝냄('닫았습니다');
707
777
  띄운것들.delete(this);
708
778
  try {
@@ -894,7 +964,15 @@ export async function 다붙이기(root, { offline = false, timeout = 붙기제
894
964
  if (설정.오류) return { 서버들: [], 못한것: [{ 이름: '(설정)', 왜: 설정.오류 }], 설정 };
895
965
  // 규격 때문에 안 받은 것은 어느 길로 끝나든 같이 내놓는다 (설정읽기 머리말).
896
966
  const 안받은것 = 설정.못받은것 ?? [];
897
- if (!설정.서버들.length) return { 서버들: [], 못한것: [...안받은것], 설정 };
967
+ /*
968
+ * 서버를 다 빼면 적어 둔 목록도 걷는다 (2.0.2 · M4). 여기서 곧장 돌아서서, 다 뺀 판에는
969
+ * 메모쓰기 의 걷기(남길이름)가 한 번도 안 돌았다 — 지운 서버들의 도구 목록이 파일에 그대로
970
+ * 남는다. 설정에 없는 이름은 안 읽으니 해는 없지만, 남은 파일은 「아직 쓰는 서버」 로 읽힌다.
971
+ */
972
+ if (!설정.서버들.length) {
973
+ 메모쓰기(root, [], { 남길이름: [] });
974
+ return { 서버들: [], 못한것: [...안받은것], 설정 };
975
+ }
898
976
 
899
977
  /*
900
978
  * ── 믿는 폴더에서만 띄운다 ──────────────────────────────────────────
@@ -984,10 +1062,13 @@ export async function 다붙이기(root, { offline = false, timeout = 붙기제
984
1062
  * 매번 새로 찍혀서 「일주일이면 다시 확인한다」 가 영영 안 온다. 그건 세 겹
985
1063
  * 그물 중 하나를 우리 손으로 걷는 것이다.
986
1064
  */
987
- if (게으르게 && 새로띄운게있나) {
1065
+ // 다 대기로 섰어도 설정에서 빠진 서버의 메모는 걷는다 (2.0.2 · M4 — 안 걷으면 새로 띄울 때까지 남는다).
1066
+ const 이름들 = 설정.서버들.map((s) => s.이름);
1067
+ const 걷을것 = Object.keys(메모들).some((n) => !이름들.includes(n));
1068
+ if (게으르게 && (새로띄운게있나 || 걷을것)) {
988
1069
  // 대기 중인 것은 안 넘긴다(적은때를 새로 찍으면 안 된다). 그래도 그 메모는
989
1070
  // 안 지워진다 — 메모쓰기 가 겹쳐 쓴다(그 머리말).
990
- 메모쓰기(root, 붙은것.filter((s) => !s.대기), { 남길이름: 설정.서버들.map((s) => s.이름) });
1071
+ 메모쓰기(root, 붙은것.filter((s) => !s.대기), { 남길이름: 이름들 });
991
1072
  }
992
1073
  return { 서버들: 붙은것, 못한것, 설정, 게으르게 };
993
1074
  }
@@ -80,7 +80,7 @@ export function 프록시읽기(값) {
80
80
  }
81
81
 
82
82
  /** NO_PROXY 한 줄을 항목 배열로. */
83
- export function 우회목록(값) {
83
+ function 우회목록(값) {
84
84
  return String(값 ?? '').split(',').map((x) => x.trim().toLowerCase()).filter(Boolean);
85
85
  }
86
86
 
@@ -271,8 +271,8 @@ export function 할당량읽기(머리) {
271
271
  * 그때는 남은 수 자체로 본다 — 요청 20회 아래, 토큰 20,000 아래.
272
272
  * 넉넉할 때 자꾸 띄우면 사람이 그 줄을 안 읽게 된다.
273
273
  */
274
- export const 요청바닥 = 20;
275
- export const 토큰바닥 = 20000;
274
+ const 요청바닥 = 20;
275
+ const 토큰바닥 = 20000;
276
276
 
277
277
  export function 아슬아슬한가(것) {
278
278
  if (!것?.있나) return false;
package/src/commands.js CHANGED
@@ -885,6 +885,12 @@ export async function handle(line, session, ctx) {
885
885
  say('');
886
886
  say(` ${c.gray('지금은')} ${승인표시(session.mode)} ${c.gray('입니다. 상태줄 오른쪽에도 늘 떠 있습니다.')}`);
887
887
  say(` ${c.gray('치지 않고 바꾸려면')} ${c.cyan('Shift+Tab')} ${c.gray('— 누를 때마다 차례로 돕니다.')}`);
888
+ {
889
+ const { 승인바닥 } = await import('./safety/policy.js');
890
+ const 바닥 = 승인바닥();
891
+ if (바닥.모름) say(` ${mark.warn} ${c.gray(말('approve.lockedUnknown', { 값: 바닥.모름, 곳: 바닥.곳 ?? '' }))}`);
892
+ else if (바닥.바닥 !== 'auto') say(` ${mark.warn} ${c.gray(말('approve.locked', { 바닥: 승인표시(바닥.바닥), 곳: 바닥.곳 ?? '' }))}`);
893
+ }
888
894
  /*
889
895
  * 적어 둔 규칙은 모드보다 세다. 그러니 모드만 보여 주고 규칙을 안 보여
890
896
  * 주면 화면이 거짓말을 하는 셈이다 — "안 묻습니다" 라고 적힌 모드에서
@@ -898,6 +904,20 @@ export async function handle(line, session, ctx) {
898
904
  return { handled: true };
899
905
  }
900
906
  const 앞 = session.mode;
907
+ /*
908
+ * 관리 정책의 바닥 아래로는 못 내린다 (safety/policy.js 의 승인바닥). Session 이 읽는
909
+ * 자리에서 어차피 덮지만, 여기서 말없이 받으면 화면에 「→ auto」 가 찍히고 실제로는
910
+ * strict 로 돈다 — 사람은 제 손으로 바꾼 줄 안다. 막고, 누가 막았는지 적는다.
911
+ */
912
+ const { 승인바닥, 더센승인 } = await import('./safety/policy.js');
913
+ const 바닥 = 승인바닥();
914
+ if (더센승인(승인이름, 바닥.바닥) !== 승인이름) {
915
+ say('');
916
+ say(` ${mark.warn} ${c.gray(말('approve.locked', { 바닥: 승인표시(바닥.바닥), 곳: 바닥.곳 ?? '' }))}`);
917
+ say(` ${c.gray('지금은')} ${승인표시(앞)} ${c.gray('입니다.')}`);
918
+ say('');
919
+ return { handled: true };
920
+ }
901
921
  session.mode = 승인이름;
902
922
  const m = 승인[승인이름];
903
923
  say('');
@@ -1572,7 +1592,7 @@ export async function handle(line, session, ctx) {
1572
1592
  }
1573
1593
 
1574
1594
  case 'mcp': {
1575
- const { 설정읽기, 설정자리, 도구최대 } = await import('./backend/mcp.js');
1595
+ const { 설정읽기, 설정자리, 도구최대, 되살리기최대 } = await import('./backend/mcp.js');
1576
1596
  const 붙은것 = session.mcp ?? [];
1577
1597
  rule('밖에서 붙인 도구 (MCP)', 70);
1578
1598
 
@@ -1602,7 +1622,7 @@ export async function handle(line, session, ctx) {
1602
1622
  *
1603
1623
  * ● 떠 있다 지금 프로세스가 돌고 있다
1604
1624
  * ◐ 대기 적어 둔 목록으로 서 있다 — 그 도구를 부르면 뜬다
1605
- * ○ 죽었다 띄웠는데 안 됐거나 도중에 죽었다
1625
+ * ○ 죽었다 띄웠는데 안 됐거나 도중에 죽었다 (도중에 저 혼자 죽은 것은 다음 부름에 되살린다)
1606
1626
  *
1607
1627
  * 대기를 죽음으로 뭉개면 사람은 없는 탈을 고치러 간다. 반대로 대기를
1608
1628
  * 초록으로 적으면 「띄워 봤다」 는 말이 거짓이 된다.
@@ -1621,6 +1641,8 @@ export async function handle(line, session, ctx) {
1621
1641
  say(` ${mark.warn} ${c.gray(`띄워 보니 목록이 달랐습니다 — ${것}`)}`);
1622
1642
  }
1623
1643
  if (!s.살아있나() && !s.대기) say(` ${c.red(s.죽음 ?? '죽었습니다')}`);
1644
+ // 저 혼자 죽은 것은 다음 부름에 되살린다(backend/mcp.js 깨우기 · 2.0.2 M1). 안 말하면 사람은 세션을 다시 켠다.
1645
+ if (s.되살릴수있나?.()) say(` ${c.gray(`다음에 이 서버 도구를 부르면 다시 띄웁니다 — 이번 세션에 ${되살리기최대 - s.되살린수}번 남았습니다.`)}`);
1624
1646
  }
1625
1647
  say('');
1626
1648
  say(` ${c.gray('모델에게는')} ${c.white('mcp__<서버>__<도구>')} ${c.gray('라는 이름으로 보입니다.')}`);
package/src/completion.js CHANGED
@@ -39,6 +39,7 @@ export const 명령들 = [
39
39
  { 이름: 'doctor', 뜻: 'diagnose 와 같다', en: 'same as diagnose' },
40
40
  { 이름: 'pack', 뜻: '오프라인 반입용으로 묶는다', en: 'bundle for offline transfer' },
41
41
  { 이름: 'audit', 뜻: '무엇을 했는지 기록을 본다', en: 'show the record of what it did' },
42
+ { 이름: 'eval', 뜻: '과제 모음(골든셋)을 실제 모델로 돌려 성공률을 잰다', en: 'run a task set (golden set) on the real model and measure the pass rate' },
42
43
  { 이름: 'doc2md', 뜻: '문서·시안을 마크다운으로 (hwpx·docx·pptx·xlsx·pdf·fig)', en: 'document or design to Markdown (hwpx/docx/pptx/xlsx/pdf/fig)' },
43
44
  { 이름: 'sbom', 뜻: '무엇이 들었는지 목록을 낸다', en: 'list what is inside (SBOM)' },
44
45
  { 이름: 'trust', 뜻: '이 폴더의 프로젝트 설정을 읽게 한다', en: "read this folder's project config" },
@@ -89,6 +90,8 @@ export const 깃발들 = [
89
90
  { 이름: '--yes', 뜻: '물어보지 않고 진행', en: 'do not ask, just proceed', 값: false },
90
91
  { 이름: '--hard', 뜻: 'reset all 에서 되돌리기·감사기록까지', en: 'with reset all: undo snapshots and the audit log too', 값: false },
91
92
  { 이름: '--json', 뜻: '결과를 JSON 으로', en: 'output JSON', 값: false },
93
+ { 이름: '--check', 뜻: 'run: 끝내기 전에 돌릴 검사 명령', en: 'run: check command to pass before finishing', 값: null },
94
+ { 이름: '--repeat', 뜻: 'eval: 과제마다 몇 번 돌릴까', en: 'eval: runs per task', 값: null },
92
95
  { 이름: '--days', 뜻: 'stats 에서 볼 기간(일)', en: 'stats: how many days back', 값: null },
93
96
  { 이름: '--all', 뜻: 'stats 에서 기간을 안 자름', en: 'stats: no time window', 값: false },
94
97
  // 값이 파일이라 셸이 파일을 완성해 줘야 한다. 목록으로 못 준다 —
@@ -156,11 +159,21 @@ function bash판({ zsh = false } = {}) {
156
159
  # echo 'source ~/.deel-completion.${zsh ? 'zsh' : 'bash'}' >> ~/.${zsh ? 'zshrc' : 'bashrc'}
157
160
  ${zsh ? '\nautoload -U +X bashcompinit && bashcompinit\n' : ''}
158
161
  # 파일(-f)·폴더(-d) 후보를 한 줄에 하나씩 담는다. 빈칸 든 이름이 조각나지 않게.
162
+ # 여는 따옴표를 친 채 누르면 cur 에 따옴표가 붙어 온다 — 떼고 찾는다.
163
+ # bash 3.2(맥 기본)에는 compopt 가 없다 — 그때는 빈칸 든 이름을 우리가 막아 넣는다.
159
164
  _deel_paths() {
160
165
  COMPREPLY=()
161
- local _deel_l
162
- while IFS= read -r _deel_l; do COMPREPLY+=("$_deel_l"); done < <(compgen "$1" -- "$cur")
163
- compopt -o filenames 2>/dev/null
166
+ local _deel_l _deel_c="$cur" _deel_q=""
167
+ case "$_deel_c" in
168
+ [\\"\\']*) _deel_q="\${_deel_c:0:1}"; _deel_c="\${_deel_c:1}" ;;
169
+ esac
170
+ while IFS= read -r _deel_l; do COMPREPLY+=("$_deel_l"); done < <(compgen "$1" -- "$_deel_c")
171
+ if type compopt >/dev/null 2>&1; then
172
+ compopt -o filenames 2>/dev/null
173
+ elif [[ -z "$_deel_q" ]]; then
174
+ local _deel_i
175
+ for _deel_i in "\${!COMPREPLY[@]}"; do COMPREPLY[$_deel_i]=$(printf '%q' "\${COMPREPLY[$_deel_i]}"); done
176
+ fi
164
177
  return 0
165
178
  }
166
179
 
package/src/config.js CHANGED
@@ -148,9 +148,11 @@ function 잠금옮기기(cfg, root = process.cwd()) {
148
148
  if (!쓸수있나().되나) return;
149
149
 
150
150
  let 옮김 = 0;
151
+ // 까닭은 **진 순간에** 받아 둔다 — 뒤의 열쇠가 잠기면 keystore 가 옛 까닭을 내려놓는다 (2.0.2 · 530).
152
+ let 진까닭 = null;
151
153
  for (const p of 평문) {
152
154
  const 잠근것 = 잠그기(p.apiKey, { 프로필id: p.id });
153
- if (잠근것) { p.apiKey = 잠근것; 옮김 += 1; }
155
+ if (잠근것) { p.apiKey = 잠근것; 옮김 += 1; } else 진까닭 ??= 잠그다실패한까닭();
154
156
  }
155
157
  if (!옮김) return;
156
158
  /*
@@ -164,7 +166,7 @@ function 잠금옮기기(cfg, root = process.cwd()) {
164
166
  if (옮김 < 평문.length) {
165
167
  const 못한것 = 평문.length - 옮김;
166
168
  소식 = `게이트웨이 열쇠 ${평문.length}개 가운데 ${못한것}개를 못 잠갔습니다`
167
- + `${잠그다실패한까닭() ? ` (${잠그다실패한까닭()})` : ''}`
169
+ + `${진까닭 ? ` (${진까닭})` : ''}`
168
170
  + ' — 반쯤 잠근 파일을 안 남기려고 설정 파일은 그대로 두었습니다 (파일에는 평문 그대로입니다)';
169
171
  return;
170
172
  }
@@ -225,24 +227,33 @@ export function load({ root = process.cwd() } = {}) {
225
227
  */
226
228
  const 정책흔적 = Symbol('정책이덮은것');
227
229
 
230
+ // 프로필 짝을 무엇으로 찾나. id 가 있으면 id — upsert 가 프로필을 **새 객체로 갈아 끼운다**
231
+ // (`cfg.profiles[i] = { ...옛것, ...새것 }`). 객체로만 찾으면 갈아 끼운 프로필을 못 알아보고
232
+ // 정책 주소가 그대로 사람 파일에 적혔다 (2.0.2 · 524).
233
+ const 프로필열쇠 = (p) => (p && p.id != null ? `id:${p.id}` : p);
234
+
228
235
  function 정책덮기(cfg) {
229
236
  const 정책 = 정책읽기().값;
230
237
  if (!정책 || typeof 정책 !== 'object') return cfg;
231
238
 
232
- const 덮기전 = { 주소: new Map(), offline: cfg.offline, deny: cfg.permissions?.deny };
239
+ const 덮기전 = { 주소: new Map(), 켠offline: false, offline: cfg.offline, 더한deny: [], deny: cfg.permissions?.deny };
233
240
 
234
241
  if (typeof 정책.baseUrl === 'string' && 정책.baseUrl.trim()) {
235
242
  for (const 프로필 of cfg.profiles ?? []) {
236
- 덮기전.주소.set(프로필, 프로필.baseUrl);
243
+ 덮기전.주소.set(프로필열쇠(프로필), 프로필.baseUrl);
237
244
  프로필.baseUrl = 정책.baseUrl.trim();
238
245
  }
239
246
  cfg.정책주소 = 정책.baseUrl.trim();
240
247
  }
241
248
  // 끄지는 못한다 — 켜기만. 글자 'true' 도 켠 것이다 (safety/runmode.js 의 봉인됐나).
242
- if (정책.offline === true || 정책.offline === 'true') cfg.offline = true;
249
+ if (정책.offline === true || 정책.offline === 'true') {
250
+ 덮기전.켠offline = cfg.offline !== true;
251
+ cfg.offline = true;
252
+ }
243
253
  if (Array.isArray(정책.permissions?.deny)) {
244
254
  cfg.permissions = cfg.permissions ?? {};
245
255
  const 있던것 = Array.isArray(cfg.permissions.deny) ? cfg.permissions.deny : [];
256
+ 덮기전.더한deny = 정책.permissions.deny.filter((x) => !있던것.includes(x));
246
257
  cfg.permissions.deny = [...new Set([...있던것, ...정책.permissions.deny])];
247
258
  }
248
259
  cfg[정책흔적] = 덮기전;
@@ -257,6 +268,13 @@ function 정책덮기(cfg) {
257
268
  * 그러면 사람이 적어 둔 주소는 사라지고 정책 주소가 제 파일에 박힌다.
258
269
  * 관리자가 나중에 정책을 걷어도 그 값은 남고, 그때 `deel config explain` 은
259
270
  * 「이 PC 설정 = …」 이라며 **사람 본인을 범인으로 가리킨다.**
271
+ *
272
+ * ── 벗기는 것은 **정책이 얹은 그 값**뿐이다 (2.0.2 · 524) ──────────────────
273
+ *
274
+ * 여기가 load 때 떠 둔 값으로 칸을 통째로 되돌렸다. 그러면 load 뒤에 바뀐 것까지
275
+ * 같이 사라진다 — 그 사이 더한 금지 규칙, 새로 적은 주소. 그래서 칸마다 「정책이
276
+ * 얹은 것이 **아직 그대로** 있나」 를 보고 그것만 뗀다: 주소는 정책 주소 그대로일
277
+ * 때만, 금지는 정책이 더한 줄만, offline 은 정책이 켠 판에만.
260
278
  */
261
279
  function 정책벗기기(cfg) {
262
280
  const 덮기전 = cfg?.[정책흔적];
@@ -264,17 +282,21 @@ function 정책벗기기(cfg) {
264
282
  const 사본 = { ...cfg };
265
283
  delete 사본.정책주소;
266
284
  사본.profiles = (cfg.profiles ?? []).map((p) => {
267
- if (!덮기전.주소.has(p)) return p;
285
+ const 열쇠 = 프로필열쇠(p);
286
+ if (!덮기전.주소.has(열쇠) || p.baseUrl !== cfg.정책주소) return p;
268
287
  const q = { ...p };
269
- const 원래 = 덮기전.주소.get(p);
288
+ const 원래 = 덮기전.주소.get(열쇠);
270
289
  if (원래 === undefined) delete q.baseUrl; else q.baseUrl = 원래;
271
290
  return q;
272
291
  });
273
- if (덮기전.offline === undefined) delete 사본.offline; else 사본.offline = 덮기전.offline;
274
- if (cfg.permissions) {
292
+ if (덮기전.켠offline && cfg.offline === true) {
293
+ if (덮기전.offline === undefined) delete 사본.offline; else 사본.offline = 덮기전.offline;
294
+ }
295
+ if (cfg.permissions && 덮기전.더한deny.length && Array.isArray(cfg.permissions.deny)) {
275
296
  사본.permissions = { ...cfg.permissions };
276
- if (덮기전.deny === undefined) delete 사본.permissions.deny;
277
- else 사본.permissions.deny = 덮기전.deny;
297
+ const 남길것 = cfg.permissions.deny.filter((x) => !덮기전.더한deny.includes(x));
298
+ if (!남길것.length && 덮기전.deny === undefined) delete 사본.permissions.deny;
299
+ else 사본.permissions.deny = 남길것;
278
300
  if (!Object.keys(사본.permissions).length) delete 사본.permissions;
279
301
  }
280
302
  return 사본;
@@ -77,7 +77,7 @@ const 환경변수 = {
77
77
  * 파이프를 타고 로그로, 사내 티켓으로 간다 — 화면만 가려 놓고 안전하다고
78
78
  * 하는 것이 이런 자리에서 제일 흔한 사고다.
79
79
  */
80
- export const 비밀표시 = '(적혀 있음 — 값은 안 보여 줍니다)';
80
+ const 비밀표시 = '(적혀 있음 — 값은 안 보여 줍니다)';
81
81
  const 지운값 = (칸, v) => (v !== undefined && 가릴칸.has(칸) ? 비밀표시 : v);
82
82
 
83
83
  const 값보이기 = (칸, v) => {
package/src/doctor.js CHANGED
@@ -32,7 +32,7 @@ import { 에이전트읽기, 에이전트줄들 } from './agent/agents.js';
32
32
  import { headersFor } from './backend/http.js';
33
33
  import { 열쇠출처, 집설정파일인가 } from './config.js';
34
34
  import { 받기설정, 쓸수있나 as 받기쓸수있나 } from './safety/authcmd.js';
35
- import { 정책읽기 } from './safety/policy.js';
35
+ import { 정책읽기, 승인바닥 } from './safety/policy.js';
36
36
  import { 설정읽기 as MCP설정읽기 } from './backend/mcp.js';
37
37
 
38
38
  /** 한 줄. `상태` 는 ok · warn · no · unknown 넷뿐이다. */
@@ -100,6 +100,16 @@ export async function 진찰(o = {}) {
100
100
  줄들.push(줄('no', '설정 파일', 설정탈.자리 ?? 설정자리 ?? '(어느 파일인지 모름)',
101
101
  `못 읽었습니다 — ${String(설정탈.까닭 ?? '').replace(/\s+/g, ' ').trim()}`));
102
102
  } else if (설정자리) 줄들.push(줄(existsSync(설정자리) ? 'ok' : 'warn', '설정 파일', 설정자리));
103
+ /*
104
+ * 관리 정책이 승인 바닥을 걸었나 (safety/policy.js 의 승인바닥). 걸렸으면 적는다 — 사람은
105
+ * `/mode auto` 가 왜 안 먹는지를 여기서 찾는다. 모르는 값이면 strict 로 친다는 것도 적는다.
106
+ */
107
+ {
108
+ const 바닥 = 승인바닥();
109
+ if (바닥.탈) 줄들.push(줄('warn', '승인 바닥', 'strict', `정책 파일을 못 읽어 제일 좁게 칩니다 — ${바닥.곳} · ${바닥.탈}`));
110
+ else if (바닥.모름) 줄들.push(줄('warn', '승인 바닥', 'strict', `정책의 approval "${바닥.모름}" 을 몰라 제일 좁게 칩니다 — ${바닥.곳}`));
111
+ else if (바닥.바닥 !== 'auto') 줄들.push(줄('ok', '승인 바닥', 바닥.바닥, `관리 정책이 걸었습니다 — ${바닥.곳}`));
112
+ }
103
113
  const 프로젝트설정 = join(root, '.deel', 'config.json');
104
114
  // 집 폴더에서 켜면 이 파일이 곧 이 PC 설정이다. 두 번 적으면 제 설정에 「deel trust」 를 권한다.
105
115
  if (existsSync(프로젝트설정) && !집설정파일인가(프로젝트설정)) {