deel-local-cli 2.0.3 → 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.
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/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';
@@ -47,6 +47,7 @@ import { route } from './agent/route.js';
47
47
  import { get as getWork, 보일이름 } from './agent/modes.js';
48
48
  import { 모두끝내기 as 일감모두끝내기, 일감인자 } from './tools/jobs.js';
49
49
  import { 첫이름 } from './tools/label.js';
50
+ import { 검사설정 } from './agent/donecheck.js';
50
51
 
51
52
  /**
52
53
  * 종료코드.
@@ -92,6 +93,16 @@ export const EXIT = {
92
93
  * 이건 스키마나 시킨 말을 바꾸는 일이다.
93
94
  */
94
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,
95
106
  /*
96
107
  * 인자를 잘못 줬다 — 모르는 깃발 · 값 없는 깃발 · 모르는 모드 이름 · 못 읽는 --ctx.
97
108
  *
@@ -99,7 +110,7 @@ export const EXIT = {
99
110
  * 「걸음 수 상한」 이다. `deel run --jsn …` 을 CI 에 건 사람은 오타 하나로 「일이 커서
100
111
  * 멈췄다」 를 받고 작업을 쪼개러 간다. 고칠 자리가 스크립트 한 줄인데.
101
112
  *
102
- * 64 는 sysexits.h 의 EX_USAGE 다. 1~7 과 안 겹치고, 셸·CI 도구들이 「부른 모양이
113
+ * 64 는 sysexits.h 의 EX_USAGE 다. 1~8 과 안 겹치고, 셸·CI 도구들이 「부른 모양이
103
114
  * 틀렸다」 로 이미 알아듣는 수라 새로 지어내지 않았다. 이 경우는 모델을 한 번도 안 부른다.
104
115
  */
105
116
  usage: 64,
@@ -569,7 +580,16 @@ export async function runOnce(opts = {}) {
569
580
  // 승인은 기본이 거부다. 반대로 하면 안 된다 — 아무도 안 보는 자리에서
570
581
  // 되돌릴 수 없는 명령이 조용히 돌아가는 것이 이 프로그램이 제일 피하려는 일이다.
571
582
  // 정말 맡기고 싶은 사람은 --yes 로 그 뜻을 명시한다.
572
- 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
+ }
573
593
  /*
574
594
  * 사람이 적어 둔 훅을 읽는다 (safety/hooks.js).
575
595
  *
@@ -608,6 +628,8 @@ export async function runOnce(opts = {}) {
608
628
  // 고친 뒤 진단을 볼지 (lsp/diag.js). 아래 내놓기() 에서 반드시 거둔다 —
609
629
  // 안 거두면 배치가 끝나고도 언어 서버가 폴더를 물고 남는다.
610
630
  lsp: { 켬: true },
631
+ // 끝내려는 자리에서 돌릴 검사 (agent/donecheck.js). `--check` 가 설정의 check 를 이긴다.
632
+ 완료검사: 검사설정(cfg, opts.check),
611
633
  // 되물을 사람이 없으니 기본값을 그대로 돌려준다.
612
634
  ask: async (_label, o = {}) => o?.def ?? '',
613
635
  // 엑셀 암호를 여기서 기다리면 그대로 선다. 없다고 바로 답한다 —
@@ -731,6 +753,8 @@ export async function runOnce(opts = {}) {
731
753
  let steps = 0;
732
754
  let 이번단계글 = ''; // 지금 단계에서 모델이 흘린 글. 단계가 바뀌면 비운다
733
755
  let 답 = null;
756
+ // 완료 검사의 마지막 결과 (agent/donecheck.js). 안 돌았으면 null 이다.
757
+ let 검사결과 = null;
734
758
  // 서버가 끝났다는 말 없이 멈춘 적이 있나. 뒤에 오는 done 이 이걸 못 지운다.
735
759
  let 말없이끊겼나 = false;
736
760
  // 비우느라 시킨 말 뒤가 잘렸나 (사냥5 B5-01). 무엇으로 끝났든 이것이 까닭이 된다.
@@ -903,6 +927,20 @@ export async function runOnce(opts = {}) {
903
927
  곁(` ${c.yellow('✗')} ${c.gray(옮긴말('ev.hookBlock'))}`);
904
928
  break;
905
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
+
906
944
  case 'nudge':
907
945
  곁(` ${c.gray(`↺ ${ev.why === '요청누락'
908
946
  ? 옮긴말('ev.nudgeMissed', { n: ev.빠진?.length ?? 0 })
@@ -987,6 +1025,7 @@ export async function runOnce(opts = {}) {
987
1025
  case 'done':
988
1026
  reason = 'done';
989
1027
  답 = ev.text ?? 이번단계글;
1028
+ 검사결과 = ev.검사 ?? null;
990
1029
  break;
991
1030
  }
992
1031
  }
@@ -1119,6 +1158,19 @@ export async function runOnce(opts = {}) {
1119
1158
  }
1120
1159
  }
1121
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
+
1122
1174
  /*
1123
1175
  * SIGINT 손은 **되물음까지 끝난 뒤에** 뗀다 (사냥5 B5-02).
1124
1176
  *
@@ -1165,6 +1217,14 @@ export async function runOnce(opts = {}) {
1165
1217
  // 모양을 못 박았을 때만 실린다. --json 으로 받는 쪽은 text 를 다시 파싱할
1166
1218
  // 필요 없이 이 칸을 그대로 쓰면 된다.
1167
1219
  ...(스키마값 !== null ? { schema: 스키마값 } : {}),
1220
+ // 완료 검사를 돌렸을 때만 실린다. ok 가 null 이면 못 돌린 것이다(why 에 까닭).
1221
+ ...(검사결과 ? {
1222
+ check: {
1223
+ ok: 검사결과.ok, command: 검사결과.명령, rounds: 검사결과.판, max: 검사결과.최대,
1224
+ ...(검사결과.요약 ? { summary: 검사결과.요약 } : {}),
1225
+ ...(검사결과.까닭 ? { why: 검사결과.까닭 } : {}),
1226
+ },
1227
+ } : {}),
1168
1228
  ms: Date.now() - t0,
1169
1229
  ...(why ? { why } : {}),
1170
1230
  });
package/src/repl.js CHANGED
@@ -1,6 +1,8 @@
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 './agent/donecheck.js';
5
+ import { 자율대기 } from './ui/approve.js';
4
6
  import { 훅읽기 } from './safety/hooks.js';
5
7
  import { 에이전트읽기 } from './agent/agents.js';
6
8
  import { 받기설정 } from './safety/authcmd.js';
@@ -41,7 +43,7 @@ import { 배움 } from './agent/evolve.js';
41
43
  import { 마크다운 } from './ui/md.js';
42
44
  import { askHidden, 가림중 } from './ui/prompt.js';
43
45
  import { explain, shows as levelShows } from './ui/level.js';
44
- import { 고르기 as 승인고르기, 다음 as 승인다음 } from './ui/approve.js';
46
+ import { 고르기 as 승인고르기, 다음 as 승인다음, 미리보기줄들 } from './ui/approve.js';
45
47
  import { 추천, 채울글, 붙임탭완성기 } from './ui/complete.js';
46
48
  import { 접어쓰기 } from './ui/wrap.js';
47
49
  import { 접을까 as 붙임접을까, 표만들기 as 붙임표, 펼치기 as 붙임펼치기, 쓴번호들 as 붙임쓴번호들, 남길글, 안쪽최대 as 붙임안쪽최대 } from './ui/pastechip.js';
@@ -166,6 +168,8 @@ export const 이어쓰기표시 = (l) =>
166
168
 
167
169
  export async function chatLoop(opts = {}) {
168
170
  const cfg = load();
171
+ // 자율(auto) 모드에서 되묻기·계획 승인을 몇 초 기다리나 (ui/approve.js 의 자율대기). 설정의 askWait.
172
+ const 자율대기초 = 자율대기(cfg);
169
173
  // 모아 둔 소식을 비운다 (아래 「한 자리에서 비운다」 머리말).
170
174
  const 소식쓰기 = () => { for (const 줄 of 소식줄들(cfg)) 바로쓰기(줄); };
171
175
  const prof = activeProfile(cfg);
@@ -1042,11 +1046,14 @@ export async function chatLoop(opts = {}) {
1042
1046
  // Shift+Tab — 승인 방식 (자동 → 위험만 → 모두)
1043
1047
  if (key && key.name === 'tab' && key.shift) {
1044
1048
  const 앞 = 승인고르기(session.mode);
1045
- session.mode = 승인다음(session.mode);
1049
+ // 관리 정책의 바닥 위에서만 돈다 (ui/approve.js 의 다음).
1050
+ const 바닥 = 승인바닥();
1051
+ session.mode = 승인다음(session.mode, 바닥.바닥);
1046
1052
  const 뒤 = 승인고르기(session.mode);
1047
1053
  화면.입력지움();
1048
1054
  say(` ${뒤.색(뒤.글자)} ${c.bold(뒤.색(뒤.이름))} ${c.gray(뒤.한줄)}`);
1049
1055
  say(` ${c.gray(`${앞.이름} → ${뒤.이름} · Shift+Tab 으로 계속 바꿉니다`)}`);
1056
+ if (바닥.바닥 !== 'auto') say(` ${c.gray(옮긴말('approve.locked', { 바닥: 승인고르기(바닥.바닥).이름, 곳: 바닥.곳 ?? '' }))}`);
1050
1057
  prompt();
1051
1058
  return;
1052
1059
  }
@@ -1264,9 +1271,29 @@ export async function chatLoop(opts = {}) {
1264
1271
  * 있을 때만 「먼저 친 줄은 답이 아니다」 가 참이다.
1265
1272
  */
1266
1273
  const 미리쳐둔 = process.stdin.isTTY ? queue.splice(0) : [];
1267
- const 멈춤 = o.signal ?? turn?.signal ?? null;
1274
+ /*
1275
+ * 시한 (자율 모드 · ui/approve.js 의 자율대기). 주면 그 초가 지나 손을 뗀다 — ESC 와 같은 길이되,
1276
+ * 무엇으로 답한 셈 칠지는 따로 받는다(`시간끝`). ESC 는 여전히 ESC 로 읽힌다.
1277
+ */
1278
+ const 턴멈춤 = o.signal ?? turn?.signal ?? null;
1279
+ let 시간지남 = false;
1280
+ let 시계 = null;
1281
+ let 멈춤 = 턴멈춤;
1282
+ const 이어멈춤 = () => 시계줄?.abort();
1283
+ const 시계줄 = o.기다림초 > 0 ? new AbortController() : null;
1284
+ if (시계줄) {
1285
+ 멈춤 = 시계줄.signal;
1286
+ if (턴멈춤?.aborted) 시계줄.abort();
1287
+ else 턴멈춤?.addEventListener('abort', 이어멈춤, { once: true });
1288
+ 시계 = setTimeout(() => { 시간지남 = true; 시계줄.abort(); }, o.기다림초 * 1000);
1289
+ }
1268
1290
  try {
1269
1291
  const a = await nextLine(멈춤);
1292
+ if (시간지남) {
1293
+ say('');
1294
+ say(` ${c.gray(o.시간끝말 ?? 옮긴말('ask.timedOut', { 초: o.기다림초 }))}`);
1295
+ return o.시간끝 ?? o.멈추면 ?? '';
1296
+ }
1270
1297
  if (멈춤?.aborted) {
1271
1298
  // 물음이 화면에 걸린 채로 남으면 안 된다. 줄을 끊고 멈췄다고 적는다.
1272
1299
  say('');
@@ -1284,6 +1311,8 @@ export async function chatLoop(opts = {}) {
1284
1311
  if (a === null) return o.끝나면 ?? o.def ?? '';
1285
1312
  return a.trim() || o.def || '';
1286
1313
  } finally {
1314
+ clearTimeout(시계);
1315
+ 턴멈춤?.removeEventListener?.('abort', 이어멈춤);
1287
1316
  묻는중 = null;
1288
1317
  // 빼 뒀던 것을 앞에 되돌린다. 차례가 바뀌면 안 된다.
1289
1318
  if (미리쳐둔.length) queue.unshift(...미리쳐둔);
@@ -1369,6 +1398,8 @@ export async function chatLoop(opts = {}) {
1369
1398
  get 눈있나() { return !!conn.vision; },
1370
1399
  // 적어 둔 허락·금지 규칙 (safety/policy.js). 승인 모드보다 먼저 본다.
1371
1400
  규칙들: 규칙모으기(cfg),
1401
+ // 모델이 끝내려는 자리에서 돌릴 검사 (agent/donecheck.js). 설정에 check 가 없으면 null — 안 돈다.
1402
+ 완료검사: 검사설정(cfg),
1372
1403
  // 사람이 적어 둔 훅 (safety/hooks.js). 프로젝트 파일은 믿는 폴더에서만 읽는다.
1373
1404
  훅들: 훅정보.훅들,
1374
1405
  // 이름 붙인 하위 작업. 목록은 Task 스키마에, 지침은 고른 뒤에만 실린다.
@@ -1506,6 +1537,16 @@ export async function chatLoop(opts = {}) {
1506
1537
  */
1507
1538
  ask물음: async (물음, 고를것 = [], 이해 = '') => {
1508
1539
  if (closed) return null;
1540
+ /*
1541
+ * 자율(auto) 모드는 맡긴 모드다 — 사람 답을 끝없이 기다리지 않는다 (ui/approve.js 의 자율대기).
1542
+ * 0 이면 묻지 않고 넘긴다. 도구는 null 을 받으면 「스스로 판단해 이어가라」 고 모델에게 말한다.
1543
+ */
1544
+ const 대기 = session.mode === 'auto' ? 자율대기초 : 0;
1545
+ if (session.mode === 'auto' && 대기 === 0) {
1546
+ say('');
1547
+ say(` ${c.gray(옮긴말('ask.autoSkip', { 물음: clip(String(물음 ?? ''), 80) }))}`);
1548
+ return null;
1549
+ }
1509
1550
  const 폭 = Math.max(40, Math.min(88, (process.stdout.columns || 80) - 6)) - 4;
1510
1551
  say('');
1511
1552
  say(` ${c.hcyan('┌')} ${c.bold(옮긴말('ask.title'))}`);
@@ -1543,8 +1584,10 @@ export async function chatLoop(opts = {}) {
1543
1584
  * 아무도 안 고른 판에서 1번을 「사람이 골랐다」 로 모델에게 실어 보낸다.
1544
1585
  */
1545
1586
  const 답 = String(await ask(
1546
- 고를것.length ? 옮긴말('ask.pickPrompt', { 끝: 고를것.length }) : 옮긴말('ask.freePrompt'),
1547
- { def: 고를것.length ? '1' : '', 끝나면: '' },
1587
+ (고를것.length ? 옮긴말('ask.pickPrompt', { 끝: 고를것.length }) : 옮긴말('ask.freePrompt'))
1588
+ + (대기 ? ` ${c.gray(`(${옮긴말('ask.autoWait', { 초: 대기 })})`)}` : ''),
1589
+ // 시한이 지나면 안 고른 것이다 — def 인 '1' 로 떨어지면 아무도 안 고른 1번이 사람의 답이 된다.
1590
+ { def: 고를것.length ? '1' : '', 끝나면: '', 기다림초: 대기, 시간끝: '' },
1548
1591
  )).trim();
1549
1592
  창제목(제목글('도는중', { 폴더: 알림.폴더 }));
1550
1593
 
@@ -1568,9 +1611,11 @@ export async function chatLoop(opts = {}) {
1568
1611
  say('');
1569
1612
  return 고른것;
1570
1613
  },
1571
- confirm: async (name, args) => {
1614
+ confirm: async (name, args, { 미리보기 } = {}) => {
1572
1615
  say('');
1573
1616
  say(` ${c.yellow('?')} ${toolLabel(name, args)}`);
1617
+ // 무엇이 바뀌는지 먼저 보여 주고 묻는다 (ui/approve.js 의 미리보기줄들).
1618
+ for (const l of 미리보기줄들(미리보기, { 줄수: DIFF_LINES[session.level] ?? 40 })) say(l);
1574
1619
  /*
1575
1620
  * 여기서 종을 울린다. 끝난 것은 늦게 알아도 되지만 **막혀 있는 것은**
1576
1621
  * 기다린 만큼 그대로 손해다 — 다른 창에 가 있는 사이 3분째 이 줄에서
@@ -3006,6 +3051,26 @@ export async function chatLoop(opts = {}) {
3006
3051
  : ` ${c.gray('↺ 읽기만 하고 끝내려고 해서 한 번 되밀었습니다')}`);
3007
3052
  break;
3008
3053
 
3054
+ // 완료 검사 (agent/donecheck.js) — 모델이 끝내려 하자 deel 이 정해 둔 검사를 돌린다.
3055
+ case 'check_start':
3056
+ clearThinking();
3057
+ if (streamed) { 답비우기(); say(''); streamed = false; }
3058
+ say('');
3059
+ say(` ${c.cyan('⧗')} ${c.gray(옮긴말('check.start', { 판: ev.판, 최대: ev.최대, 명령: ev.명령 }))}`);
3060
+ break;
3061
+
3062
+ case 'check':
3063
+ clearThinking();
3064
+ if (ev.ok === true) say(` ${mark.ok} ${c.gray(옮긴말('check.pass', { 명령: ev.명령 }))}`);
3065
+ else if (ev.ok === false) {
3066
+ say(` ${c.red('✗')} ${c.white(옮긴말('check.fail', { 판: ev.판, 최대: ev.최대, 요약: ev.요약 }))}`);
3067
+ for (const 한줄 of String(ev.꼬리 ?? '').trim().split('\n').filter((l) => l.trim()).slice(-6)) {
3068
+ say(` ${c.gray(clip(한줄, 100))}`);
3069
+ }
3070
+ say(` ${c.gray(ev.판 < ev.최대 ? 옮긴말('check.retry') : 옮긴말('check.gaveUp', { 판: ev.판 }))}`);
3071
+ } else say(` ${c.yellow('⊘')} ${c.gray(옮긴말('check.skipped', { 까닭: ev.까닭 }))}`);
3072
+ break;
3073
+
3009
3074
  /*
3010
3075
  * 모델이 안 하겠다고 했다 (agent/loop.js 의 거절).
3011
3076
  *
@@ -3231,10 +3296,19 @@ export async function chatLoop(opts = {}) {
3231
3296
  * 승인받는 자리는 막아 놓고 계획 **전체**를 승인하는 자리가 열려 있었다.
3232
3297
  * 재 보니 아무도 답하지 않은 판에서 계획이 승인되고 파일이 만들어졌다.
3233
3298
  */
3234
- const 답 = String(await ask(
3235
- `${옮긴말('plan.ask')} ${c.gray(옮긴말('plan.hint'))}`,
3236
- { def: 'y', 끝나면: 'n', 멈추면: 'n' },
3237
- )).trim();
3299
+ /*
3300
+ * 자율(auto) 모드면 시한을 둔다 (ui/approve.js 의 자율대기). 지나면 **그대로 진행**이다 —
3301
+ * 이 상자는 안전 관문이 아니라 「계획부터 보여 준다」 는 자리이고, auto 를 고른 사람은
3302
+ * 맡긴 사람이다. 입력이 끝난 것(파이프·Ctrl+D)은 여전히 승인이 아니다(`끝나면: 'n'`).
3303
+ */
3304
+ const 계획대기 = session.mode === 'auto' ? 자율대기초 : 0;
3305
+ const 답 = session.mode === 'auto' && 계획대기 === 0
3306
+ ? (say(` ${c.gray(옮긴말('plan.autoGo'))}`), 'y')
3307
+ : String(await ask(
3308
+ `${옮긴말('plan.ask')} ${c.gray(옮긴말('plan.hint'))}`
3309
+ + (계획대기 ? ` ${c.gray(`(${옮긴말('ask.autoWait', { 초: 계획대기 })})`)}` : ''),
3310
+ { def: 'y', 끝나면: 'n', 멈추면: 'n', 기다림초: 계획대기, 시간끝: 'y', 시간끝말: 옮긴말('plan.timedOut', { 초: 계획대기 }) },
3311
+ )).trim();
3238
3312
  /*
3239
3313
  * ── 골라진 것 + 하고 싶은 말 ────────────────────────────────────
3240
3314
  *
@@ -90,6 +90,44 @@ export function 정책읽기({ env = process.env, platform = process.platform,
90
90
  /** 검사가 원래대로 돌려놓을 때. */
91
91
  export function 정책잊기() { 읽은정책 = null; }
92
92
 
93
+ /*
94
+ * ── 승인 바닥 (2.1.0) ──────────────────────────────────────────────────
95
+ *
96
+ * 사내 검토에서 나온 물음: 「파일을 고치거나 지우기 전에 미리보기를 보여 주고
97
+ * 사람이 한 번 확인하는 절차가 **기본으로** 도는가.」
98
+ *
99
+ * 승인 모드는 쓰는 사람이 고른다(`/mode` · Shift+Tab). 기본은 auto 다 — 이 도구의
100
+ * 속도가 거기서 나온다. 그런데 관리자가 「이 PC 에서는 바꾸기 전에 늘 묻는다」 를
101
+ * 정할 자리가 없었다. 정책은 주소·봉인·금지만 받았다.
102
+ *
103
+ * { "approval": "strict" }
104
+ *
105
+ * 정책의 approval 은 **바닥**이다. 사람은 그보다 더 조일 수는 있어도 아래로는 못
106
+ * 내린다 — 정책이 넓히지 못하는 것과 같은 자세다. 모르는 값이면 제일 좁은
107
+ * strict 로 친다. 관리자가 오타를 냈다고 바닥이 통째로 빠지면, 관리자는 걸린 줄
108
+ * 알고 사람은 안 걸린 채로 쓴다.
109
+ */
110
+ export const 승인차례 = ['auto', 'confirm', 'strict'];
111
+
112
+ export function 승인바닥(읽은 = 정책읽기()) {
113
+ /*
114
+ * 파일이 있는데 **못 읽었으면**(쉼표 하나 틀린 JSON) 바닥도 strict 다. 위의 「모르는 값」 과
115
+ * 같은 까닭이다 — 관리자는 `"approval": "strict",` 를 적어 두었고 걸린 줄 안다. 못 읽었다고
116
+ * auto 로 풀면 오타 하나로 바닥이 통째로 빠진다 (2차 눈 판정). 파일이 없는 PC 는 그대로 auto.
117
+ */
118
+ if (읽은?.탈) return { 바닥: 'strict', 곳: 읽은.곳 ?? null, 모름: null, 탈: 읽은.탈 };
119
+ const v = 읽은?.값?.approval;
120
+ if (v === undefined || v === null || v === '') return { 바닥: 'auto', 곳: null, 모름: null };
121
+ const s = String(v).trim().toLowerCase();
122
+ if (승인차례.includes(s)) return { 바닥: s, 곳: 읽은.곳, 모름: null };
123
+ return { 바닥: 'strict', 곳: 읽은.곳, 모름: String(v) };
124
+ }
125
+
126
+ /** 둘 중 더 많이 묻는 쪽. 모르는 이름은 auto 로 친다 — 바닥 쪽이 따로 막는다. */
127
+ export function 더센승인(a, b) {
128
+ return 승인차례[Math.max(0, 승인차례.indexOf(a), 승인차례.indexOf(b))];
129
+ }
130
+
93
131
  /*
94
132
  * `Tool(무늬)` 를 읽는다.
95
133
  *
@@ -1696,6 +1696,106 @@ function 여러파일쓰기(목록, ctx) {
1696
1696
  * 결과 모양을 바꾸면 안 된다. changed·diff·tier 를 보고 있는 자리가 셋이다:
1697
1697
  * loop.js 의 잘린 인자 살려쓰기, repl.js 의 바뀐 자리 그리기, 되돌리기 스냅샷.
1698
1698
  */
1699
+ /*
1700
+ * old_string 을 찾아 new_string 으로 바꾼 **글**만 만든다. 파일은 안 건드린다.
1701
+ *
1702
+ * 고치기(한군데고치기)와 승인 미리보기(바뀔내용)가 이 셈 하나를 같이 쓴다. 둘이 따로
1703
+ * 셈하면 사람이 승인 창에서 본 것과 실제로 쓰인 것이 갈린다 — 들여쓰기를 맞춘 자리,
1704
+ * CRLF 로 바꾼 자리가 미리보기에만 없으면 「본 대로 허락했다」 가 거짓이 된다.
1705
+ */
1706
+ function 새글만들기(text, args) {
1707
+ const m = findMatch(text, args.old_string, { replaceAll: !!args.replace_all });
1708
+ if (!m.ok) return { m, next: null };
1709
+ // CRLF 만 쓰는 파일이면 넣는 글도 CRLF 로 (edit-match.js 의 CRLF뿐인가 머리말).
1710
+ const 줄끝맞춤 = CRLF뿐인가(text) ? CRLF로 : (s) => s;
1711
+ const next = applySpans(text, m.spans, (matched) => 줄끝맞춤(
1712
+ m.tier === 'exact' ? args.new_string
1713
+ // 정규화로 찾았으면 넣는 글도 파일의 꼴(NFC·NFD)로 (edit-match.js 의 꼴맞추기).
1714
+ : m.tier === 'nfc' ? 꼴맞추기(args.new_string, matched)
1715
+ : reindent(args.new_string, matched, args.old_string)));
1716
+ return { m, next };
1717
+ }
1718
+
1719
+ // 미리보기에 실을 만큼. 넘는 파일은 한 줄 물음으로 물러선다 — 승인 창에 몇 MB 를
1720
+ // 그리거나 편집기로 보내는 동안 사람은 멈춘 화면만 본다.
1721
+ const 미리보기상한 = 2_000_000;
1722
+
1723
+ /**
1724
+ * 이 도구 호출이 **실행되면** 무엇이 어떻게 바뀌는가. 승인을 묻기 전에 보여 줄 거리다.
1725
+ *
1726
+ * 사내 검토에서 나온 물음이 이것이었다 — 「고치기 전에 미리보기를 보여 주고 사람이
1727
+ * 확인하는가」. 여태 승인 창은 `Edit src/a.js` 한 줄만 보여 줬다. 무엇이 바뀌는지
1728
+ * 모르고 누르는 y 는 확인이 아니라 습관이다.
1729
+ *
1730
+ * 파일은 **읽기만** 한다. 못 만들면(경로 밖·없는 파일·못 찾는 old_string·문서 파일)
1731
+ * 빈 배열이다 — 그 경우 도구가 어차피 실패로 끝나거나, 물음은 예전처럼 한 줄로 뜬다.
1732
+ *
1733
+ * @returns {Array<{경로:string, 보일경로:string, 전:string|null, 후:string}>}
1734
+ */
1735
+ export function 바뀔내용(이름, 인자, ctx) {
1736
+ const a = 인자 ?? {};
1737
+ /*
1738
+ * 실제 도구가 거절하는 자리는 미리보기도 안 읽는다. deel 자신의 살림(기록·설정·열쇠)은 Write·Edit 이
1739
+ * 먼저 거절하는데(내부살림), 미리보기가 그걸 모르고 읽으면 승인 창에 「지금 내용」 으로 열쇠가 찍히고
1740
+ * 편집기로까지 간다 — 도구는 안 돌았는데 읽기는 일어난 셈이다. 문서 파일은 글로 못 보인다.
1741
+ */
1742
+ const 글파일인가 = (abs) => !(isExcelPath(abs) || isDocPath(abs) || isPdfPath(abs) || isFigPath(abs) || 내부살림(abs));
1743
+ const 읽기 = (abs) => (existsSync(abs) ? readTextFull(abs).text : null);
1744
+ const 담기 = (abs, 전, 후) => ({ 경로: abs, 보일경로: ctx.scope.show(abs), 전, 후 });
1745
+ try {
1746
+ if (이름 === 'Write' || 이름 === 'Append') {
1747
+ if (typeof a.file_path !== 'string' || typeof a.content !== 'string') return [];
1748
+ const abs = ctx.scope.resolve(a.file_path);
1749
+ // 실제 도구는 «가림:…» 표를 파일로 되돌리는 쓰기를 거절한다 — 안 일어날 변경은 안 그린다.
1750
+ if (!글파일인가(abs) || 표몇군데(a.content)) return [];
1751
+ const 전 = 읽기(abs);
1752
+ /*
1753
+ * Append 는 CRLF 만 쓰는 파일에 붙일 때 조각도 CRLF 로 바꿔 쓴다. 그 판정도 실제처럼
1754
+ * **앞머리**로 한다(아래 Append 의 앞글). LF 로 이어 붙여 그리면 본 것과 쓰인 것이 다르다.
1755
+ */
1756
+ const 붙일글 = 이름 === 'Append' && 전 !== null && CRLF뿐인가(전.slice(0, 인코딩볼바이트)) ? CRLF로(a.content) : a.content;
1757
+ const 후 = 이름 === 'Append' ? (전 ?? '') + 붙일글 : a.content;
1758
+ if ((전?.length ?? 0) + 후.length > 미리보기상한) return [];
1759
+ return [담기(abs, 전, 후)];
1760
+ }
1761
+ if (이름 === 'Edit') {
1762
+ // 여러 군데면 실제 고치기처럼 적은 순서대로 차례로 대 보고, 못 찾은 것은 건너뛴다
1763
+ // (여러군데고치기 가 그렇게 한다 — 된 것만 쓰고 안 된 것은 실패로 알린다).
1764
+ const 목록 = Array.isArray(a.edits) ? a.edits.filter((x) => x && typeof x === 'object') : [a];
1765
+ const 파일들 = new Map();
1766
+ for (const x of 목록) {
1767
+ if (typeof x.file_path !== 'string' || typeof x.old_string !== 'string' || typeof x.new_string !== 'string') continue;
1768
+ if (x.old_string === x.new_string) continue;
1769
+ let abs;
1770
+ try { abs = ctx.scope.resolve(x.file_path); } catch { continue; }
1771
+ // 아직 안 읽은 파일은 실제 Edit 이 거절한다(「먼저 Read 로」). 미리보기가 대신 읽어 보여 주면
1772
+ // 모델이 안 본 파일의 내용이 승인 창에 먼저 뜬다 — 그 호출은 어차피 실패로 끝난다.
1773
+ if (!글파일인가(abs) || !ctx.seen?.has(abs)) continue;
1774
+ // 가린 표를 되돌리는 고침은 실제 Edit 이 거절한다(한군데고치기) — 그 한 군데는 안 일어난다.
1775
+ if (표몇군데(x.new_string)) continue;
1776
+ if (!파일들.has(abs)) {
1777
+ const 글 = 읽기(abs);
1778
+ if (글 === null) continue;
1779
+ /*
1780
+ * 너무 커서 못 그리는 파일이 **하나라도** 있으면 미리보기를 통째로 안 낸다. 그 파일만 빼고
1781
+ * 그리면 사람은 작은 파일만 바뀌는 줄 알고 허락한다 — 반쪽 미리보기는 없는 것보다 나쁘다.
1782
+ * 물음은 예전처럼 한 줄로 뜬다 (2차 눈 판정).
1783
+ */
1784
+ if (글.length > 미리보기상한) return [];
1785
+ 파일들.set(abs, { 전: 글, 후: 글 });
1786
+ }
1787
+ const 판 = 파일들.get(abs);
1788
+ const { m, next } = 새글만들기(판.후, x);
1789
+ if (m.ok) 판.후 = next;
1790
+ // 고친 **뒤**도 잰다 — replace_all 한 번에 수십 MB 가 되면 동기 diff 가 승인 창을 붙잡는다.
1791
+ if (판.후.length > 미리보기상한) return [];
1792
+ }
1793
+ return [...파일들].filter(([, 판]) => 판.후 !== 판.전).map(([abs, 판]) => 담기(abs, 판.전, 판.후));
1794
+ }
1795
+ } catch { /* 미리보기는 거드는 것이다 — 못 만들면 물음은 한 줄로 뜬다 */ }
1796
+ return [];
1797
+ }
1798
+
1699
1799
  function 한군데고치기(args, ctx) {
1700
1800
  /*
1701
1801
  * ── 글이 아닌 old_string·new_string 은 **받지 않는다** ──────────────────
@@ -1737,7 +1837,7 @@ function 한군데고치기(args, ctx) {
1737
1837
 
1738
1838
  const 읽음 = readTextFull(abs);
1739
1839
  const text = 읽음.text;
1740
- const m = findMatch(text, args.old_string, { replaceAll: !!args.replace_all });
1840
+ const { m, next } = 새글만들기(text, args);
1741
1841
 
1742
1842
  if (!m.ok) {
1743
1843
  if (m.reason === 'ambiguous') {
@@ -1757,14 +1857,6 @@ function 한군데고치기(args, ctx) {
1757
1857
  return { error: `찾지 못했습니다.${hint}` };
1758
1858
  }
1759
1859
 
1760
- // CRLF 만 쓰는 파일이면 넣는 글도 CRLF 로 (edit-match.js 의 CRLF뿐인가 머리말).
1761
- const 줄끝맞춤 = CRLF뿐인가(text) ? CRLF로 : (s) => s;
1762
- const next = applySpans(text, m.spans, (matched) => 줄끝맞춤(
1763
- m.tier === 'exact' ? args.new_string
1764
- // 정규화로 찾았으면 넣는 글도 파일의 꼴(NFC·NFD)로 (edit-match.js 의 꼴맞추기).
1765
- : m.tier === 'nfc' ? 꼴맞추기(args.new_string, matched)
1766
- : reindent(args.new_string, matched, args.old_string)));
1767
-
1768
1860
  // 읽은 그 인코딩으로 되돌려 쓴다. 안 바뀐 앞뒤는 읽은 바이트 그대로 (encoding.js 의 바꾼데만쓰기).
1769
1861
  const 만든것 = 바꾼데만쓰기(읽음.buf, text, next, 읽음.encoding);
1770
1862
  /*