deel-local-cli 2.0.3 → 2.1.1

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/ja.js CHANGED
@@ -290,6 +290,11 @@ export const ja = {
290
290
  'approve.strict.name': '毎回訊く',
291
291
  'approve.strict.short': '毎回',
292
292
  'approve.strict.line': 'ファイルを直すたび、コマンドを打つたびに訊きます',
293
+ 'approve.newFile': '新しいファイル',
294
+ 'approve.noChange': '変更なし — 今と同じ内容',
295
+ 'approve.locked': '管理ポリシーが承認を {바닥} より下げられないようにしています — {곳}',
296
+ 'approve.lockedUnknown': '管理ポリシーの approval の値「{값}」が分からないため、いちばん狭い strict とみなします — {곳}',
297
+ 'approve.noAlways': '管理ポリシーが承認を固定しているため「今後は訊かない」はありません',
293
298
  'work.title': '作業モード',
294
299
  'work.canEdit': 'ファイルを変更',
295
300
  'work.readOnly': '読むだけ',
@@ -353,6 +358,8 @@ export const ja = {
353
358
  'plan.ask': 'この通り進めますか?',
354
359
  'plan.hint': '⏎ 進む · y 一言添えて進む · 3 その段階に一言 · n やめる',
355
360
  'plan.cancelled': 'やめました。計画は上に残っているので、続きから言ってください。',
361
+ 'plan.timedOut': '{초}秒答えがなかったので、計画どおり進めます。',
362
+ 'plan.autoGo': '自律モードなので、聞かずに計画どおり進めます。',
356
363
  'plan.going': '計画どおり進めます。',
357
364
  'plan.goingWith': '一言を添えて進めます — {말}',
358
365
  'plan.replan': 'その点を踏まえて計画を出し直します。',
@@ -363,6 +370,9 @@ export const ja = {
363
370
  'ask.pickPrompt': '1-{끝}(後ろに一言添えても可)、または自分で書く',
364
371
  'ask.freePrompt': '答え',
365
372
  'ask.stopped': '止めました — 答えなかったものとして進みます。',
373
+ 'ask.autoWait': '{초}秒以内に答えがなければ自分で進めます',
374
+ 'ask.timedOut': '{초}秒答えがなかったので、自分で進めます。',
375
+ 'ask.autoSkip': '自律モードなので聞かずに進めます — {물음}',
366
376
  'loop.backoff': 'サーバーに押し返されました ({무엇}) — {초} 秒後にもう一度 ({n}/{max})',
367
377
  'cost.retries': '再試行',
368
378
  // ── 数える言葉 ────────────────────────────────────────────────────────
@@ -466,6 +476,18 @@ export const ja = {
466
476
  'ev.hookNote': 'フック: {말}',
467
477
  'ev.hookBlock': 'フックが止めました',
468
478
  'ev.nudgeRead': '読むだけで終えようとしたので、一度押し戻しました',
479
+ 'check.start': '完了チェック ({판}/{최대}) — {명령}',
480
+ 'check.clamped': '{칸} {준값} は上限を超えるため {쓴값} にしました',
481
+ 'check.pass': '完了チェック合格 — {명령}',
482
+ 'check.fail': '完了チェック失敗 ({판}/{최대}) — {요약}',
483
+ 'check.retry': '失敗内容を返して直させます',
484
+ 'check.gaveUp': '{판}回回しても失敗しました — 失敗として終えます',
485
+ 'check.skipped': '完了チェックを回せませんでした — {까닭}',
486
+ 'cli.checkFlag': '終える前にこのチェックを回し、通れば終了(失敗なら直させ、最後まで失敗なら 8)',
487
+ 'cli.evalTitle': 'ゴールデンセット',
488
+ 'cli.evalWhy': '— 課題集を実際のモデルで回し、いくつこなせるかを測ります',
489
+ 'cli.evalInit': 'golden/ に例題を五つ作ります',
490
+ 'cli.evalRun': '課題ごとに一時フォルダで deel run を回し、終わった後に正解チェックで採点します',
469
491
  'ev.learnedCtx': 'サーバーが教えてくれたコンテキスト上限 {한계} に合わせて呼び直します',
470
492
  'ev.learnedOut': 'サーバーが教えてくれた答えの長さ上限 {한계} に合わせて呼び直します',
471
493
  'ev.reset': '場所が足りず、前の会話 {버린수} 件を消して続けます — 残りのやること {할일} 件',
package/src/i18n/ko.js CHANGED
@@ -312,6 +312,11 @@ export const ko = {
312
312
  'approve.strict.name': '모두 확인',
313
313
  'approve.strict.short': '모두',
314
314
  'approve.strict.line': '파일을 고치거나 명령을 돌리기 전에 매번 물어봅니다',
315
+ 'approve.newFile': '새 파일',
316
+ 'approve.noChange': '바뀌는 것 없음 — 지금과 같은 내용',
317
+ 'approve.locked': '관리 정책이 승인을 {바닥} 아래로 못 내리게 걸어 두었습니다 — {곳}',
318
+ 'approve.lockedUnknown': '관리 정책의 approval 값 "{값}" 을 몰라 제일 좁은 strict 로 칩니다 — {곳}',
319
+ 'approve.noAlways': '관리 정책이 승인을 걸어 두어 「앞으로 묻지 않기」 는 없습니다',
315
320
  'work.title': '작업 모드',
316
321
  'work.canEdit': '파일 바꿈',
317
322
  'work.readOnly': '읽기만',
@@ -388,6 +393,8 @@ export const ko = {
388
393
  'plan.ask': '이대로 진행할까요?',
389
394
  'plan.hint': '⏎ 진행 · y 한마디 붙여 진행 · 3 그 단계에 한마디 · n 취소',
390
395
  'plan.cancelled': '그만뒀습니다. 계획은 위에 남아 있으니 이어서 말씀하셔도 됩니다.',
396
+ 'plan.timedOut': '{초}초 동안 답이 없어 계획대로 진행합니다.',
397
+ 'plan.autoGo': '자율 모드라 묻지 않고 계획대로 진행합니다.',
391
398
  'plan.going': '계획대로 진행합니다.',
392
399
  'plan.goingWith': '한마디를 얹어 진행합니다 — {말}',
393
400
  'plan.replan': '그 점을 반영해 계획을 다시 냅니다.',
@@ -406,6 +413,9 @@ export const ko = {
406
413
  'ask.freePrompt': '답',
407
414
  // 물어보는 도중에 ESC 를 눌렀다. 물음이 화면에 걸린 채로 남으면 안 된다.
408
415
  'ask.stopped': '멈췄습니다 — 답하지 않은 것으로 넘어갑니다.',
416
+ 'ask.autoWait': '{초}초 안에 답이 없으면 알아서 진행합니다',
417
+ 'ask.timedOut': '{초}초 동안 답이 없어 알아서 진행합니다.',
418
+ 'ask.autoSkip': '자율 모드라 묻지 않고 진행합니다 — {물음}',
409
419
 
410
420
  // ── 잠깐 막혔을 때 (backend/retry.js) ─────────────────────────────────
411
421
  'loop.backoff': '서버가 잠시 막았습니다 ({무엇}) — {초}초 뒤 다시 부릅니다 ({n}/{max})',
@@ -540,6 +550,18 @@ export const ko = {
540
550
  'ev.hookNote': '훅: {말}',
541
551
  'ev.hookBlock': '훅이 막았습니다',
542
552
  'ev.nudgeRead': '읽기만 하고 끝내려고 해서 한 번 되밀었습니다',
553
+ 'check.start': '완료 검사 ({판}/{최대}) — {명령}',
554
+ 'check.clamped': '{칸} {준값} 은 상한을 넘어 {쓴값} 으로 줄였습니다',
555
+ 'check.pass': '완료 검사 통과 — {명령}',
556
+ 'check.fail': '완료 검사 실패 ({판}/{최대}) — {요약}',
557
+ 'check.retry': '실패 내용을 돌려주고 다시 고치게 합니다',
558
+ 'check.gaveUp': '{판}번 돌렸는데도 실패했습니다 — 실패로 끝냅니다',
559
+ 'check.skipped': '완료 검사를 못 돌렸습니다 — {까닭}',
560
+ 'cli.checkFlag': '끝내려 할 때 이 검사를 돌려 통과해야 끝남 (실패하면 고치게 하고, 끝내 실패면 8)',
561
+ 'cli.evalTitle': '골든셋',
562
+ 'cli.evalWhy': '— 과제 모음을 실제 모델로 돌려 몇 개를 해내나 잽니다',
563
+ 'cli.evalInit': '예제 과제 다섯을 golden/ 에 만듭니다',
564
+ 'cli.evalRun': '과제마다 임시 폴더에서 deel run 을 돌리고, 끝난 뒤 정답 검사로 채점합니다',
543
565
  'ev.learnedCtx': '서버가 알려 준 컨텍스트 한계 {한계} 로 맞추고 다시 부릅니다',
544
566
  'ev.learnedOut': '서버가 알려 준 답 길이 한계 {한계} 로 맞추고 다시 부릅니다',
545
567
  /*
package/src/i18n/zh.js CHANGED
@@ -290,6 +290,11 @@ export const zh = {
290
290
  'approve.strict.name': '每次都问',
291
291
  'approve.strict.short': '每次',
292
292
  'approve.strict.line': '每改一个文件、每跑一条命令都问',
293
+ 'approve.newFile': '新文件',
294
+ 'approve.noChange': '没有变化 — 与现在内容相同',
295
+ 'approve.locked': '管理策略把审批锁在 {바닥} 或更严 — {곳}',
296
+ 'approve.lockedUnknown': '不认识管理策略的 approval 值「{값}」,按最严的 strict 处理 — {곳}',
297
+ 'approve.noAlways': '管理策略锁定了审批,没有「以后不再问」',
293
298
  'work.title': '工作模式',
294
299
  'work.canEdit': '会改文件',
295
300
  'work.readOnly': '只读',
@@ -353,6 +358,8 @@ export const zh = {
353
358
  'plan.ask': '就按这个做吗?',
354
359
  'plan.hint': '⏎ 开始 · y 加一句再开始 · 3 对那一步说一句 · n 取消',
355
360
  'plan.cancelled': '停下了。计划还在上面,接着说就行。',
361
+ 'plan.timedOut': '{초}秒没有回答,按计划继续。',
362
+ 'plan.autoGo': '自动模式,不问直接按计划继续。',
356
363
  'plan.going': '按计划开始。',
357
364
  'plan.goingWith': '带上你这句话开始 — {말}',
358
365
  'plan.replan': '按这一点重新出计划。',
@@ -363,6 +370,9 @@ export const zh = {
363
370
  'ask.pickPrompt': '1-{끝}(后面可以加一句话),或者自己写',
364
371
  'ask.freePrompt': '回答',
365
372
  'ask.stopped': '已停止 — 按没有回答继续。',
373
+ 'ask.autoWait': '{초}秒内没有回答就自己继续',
374
+ 'ask.timedOut': '{초}秒没有回答,自己继续。',
375
+ 'ask.autoSkip': '自动模式,不问直接继续 — {물음}',
366
376
  'loop.backoff': '服务端把请求推回来了 ({무엇}) — {초} 秒后再试 ({n}/{max})',
367
377
  'cost.retries': '重试',
368
378
  // ── 计数词 ────────────────────────────────────────────────────────────
@@ -466,6 +476,18 @@ export const zh = {
466
476
  'ev.hookNote': '钩子:{말}',
467
477
  'ev.hookBlock': '钩子拦下了这一步',
468
478
  'ev.nudgeRead': '只看不动就要收尾,已经推回去一次',
479
+ 'check.start': '完成检查 ({판}/{최대}) — {명령}',
480
+ 'check.clamped': '{칸} {준값} 超过上限,改用 {쓴값}',
481
+ 'check.pass': '完成检查通过 — {명령}',
482
+ 'check.fail': '完成检查失败 ({판}/{최대}) — {요약}',
483
+ 'check.retry': '把失败内容交回去重新修',
484
+ 'check.gaveUp': '跑了 {판} 次仍然失败 — 按失败结束',
485
+ 'check.skipped': '没能跑完成检查 — {까닭}',
486
+ 'cli.checkFlag': '结束前跑这个检查,通过才结束(失败就让它修,最后仍失败则返回 8)',
487
+ 'cli.evalTitle': '黄金集',
488
+ 'cli.evalWhy': '— 用真实模型跑一组任务,测它能完成多少',
489
+ 'cli.evalInit': '在 golden/ 里建五个示例任务',
490
+ 'cli.evalRun': '每个任务在临时文件夹里跑 deel run,结束后用它没见过的检查评分',
469
491
  'ev.learnedCtx': '按服务端给出的上下文上限 {한계} 重新调用',
470
492
  'ev.learnedOut': '按服务端给出的回答长度上限 {한계} 重新调用',
471
493
  'ev.reset': '位置不够,已清掉前面 {버린수} 条对话继续做 — 还剩 {할일} 件待办',
package/src/lsp/client.js CHANGED
@@ -28,6 +28,7 @@
28
28
  import { spawn } from 'node:child_process';
29
29
  import { pathToFileURL, fileURLToPath } from 'node:url';
30
30
  import { readFileSync } from 'node:fs';
31
+ import { decode } from '../tools/encoding.js';
31
32
  import { 갈래, 고르기, 언어아이디 } from './servers.js';
32
33
  import { 틀, 받개 } from './rpc.js';
33
34
  import { 신호에거두기, 다시보낼까 } from '../reap.js';
@@ -419,7 +420,8 @@ export class 언어서버 {
419
420
  const uri = pathToFileURL(abs).href;
420
421
  let 내용 = 글;
421
422
  if (내용 == null) {
422
- try { 내용 = readFileSync(abs, 'utf8'); } catch { return null; }
423
+ // 도구(tools/lsp.js)와 같은 자로 읽는다 — EUC-KR 소스를 날 UTF-8 로 보여 주면 서버가 깨진 글을 색인한다.
424
+ try { 내용 = decode(readFileSync(abs)).text; } catch { return null; }
423
425
  }
424
426
  // 판 번호는 **경로**로 센다. 서버가 주소를 다르게 적어 돌려주므로
425
427
  // (pyright 의 `file:///c%3A/…`), 글자 그대로 열쇠를 삼으면 진단이 왔을 때
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,22 @@ 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
+ for (const 줄 of ev.줄임 ?? []) 곁(` ${c.yellow('⚠')} ${c.gray(옮긴말('check.clamped', 줄))}`);
933
+ break;
934
+
935
+ case 'check':
936
+ if (ev.ok === true) 곁(` ${mark.ok} ${c.gray(옮긴말('check.pass', { 명령: ev.명령 }))}`);
937
+ else if (ev.ok === false) {
938
+ 곁(` ${c.red('✗')} ${c.gray(옮긴말('check.fail', { 판: ev.판, 최대: ev.최대, 요약: ev.요약 }))}`);
939
+ // 실패 출력의 끝 몇 줄 — 배치 로그를 보는 사람이 무엇이 틀렸는지 여기서 안다.
940
+ // 빈 줄을 **먼저** 거르고 자른다 — 거꾸로 하면 원인 뒤 빈 줄 여럿이 8칸을 다 먹는다 (repl.js 와 같은 차례).
941
+ for (const 줄 of String(ev.꼬리 ?? '').trim().split('\n').filter((l) => l.trim()).slice(-8)) 곁(` ${c.gray(줄)}`);
942
+ 곁(` ${c.gray(ev.판 < ev.최대 ? 옮긴말('check.retry') : 옮긴말('check.gaveUp', { 판: ev.판 }))}`);
943
+ } else 곁(` ${c.yellow('⊘')} ${c.gray(옮긴말('check.skipped', { 까닭: ev.까닭 }))}`);
944
+ break;
945
+
906
946
  case 'nudge':
907
947
  곁(` ${c.gray(`↺ ${ev.why === '요청누락'
908
948
  ? 옮긴말('ev.nudgeMissed', { n: ev.빠진?.length ?? 0 })
@@ -987,6 +1027,7 @@ export async function runOnce(opts = {}) {
987
1027
  case 'done':
988
1028
  reason = 'done';
989
1029
  답 = ev.text ?? 이번단계글;
1030
+ 검사결과 = ev.검사 ?? null;
990
1031
  break;
991
1032
  }
992
1033
  }
@@ -1119,6 +1160,19 @@ export async function runOnce(opts = {}) {
1119
1160
  }
1120
1161
  }
1121
1162
 
1163
+ /*
1164
+ * 모델은 끝났다고 했는데 완료 검사가 통과하지 않았다 — 종료코드 8 (위 EXIT.check).
1165
+ *
1166
+ * 모양 판정(7)보다 **뒤에** 본다. 모양도 틀렸으면 그쪽을 먼저 고쳐야 답을 읽을 수 있다.
1167
+ * 아무것도 안 바꾼 턴은 검사가 안 돌아 검사결과가 null 이다 — 그건 실패가 아니다.
1168
+ */
1169
+ if (reason === 'done' && 검사결과 && 검사결과.ok !== true) {
1170
+ reason = 'check';
1171
+ why = 검사결과.ok === false
1172
+ ? 옮긴말('check.gaveUp', { 판: 검사결과.판 }) + ` — ${검사결과.명령}`
1173
+ : 옮긴말('check.skipped', { 까닭: 검사결과.까닭 });
1174
+ }
1175
+
1122
1176
  /*
1123
1177
  * SIGINT 손은 **되물음까지 끝난 뒤에** 뗀다 (사냥5 B5-02).
1124
1178
  *
@@ -1165,6 +1219,14 @@ export async function runOnce(opts = {}) {
1165
1219
  // 모양을 못 박았을 때만 실린다. --json 으로 받는 쪽은 text 를 다시 파싱할
1166
1220
  // 필요 없이 이 칸을 그대로 쓰면 된다.
1167
1221
  ...(스키마값 !== null ? { schema: 스키마값 } : {}),
1222
+ // 완료 검사를 돌렸을 때만 실린다. ok 가 null 이면 못 돌린 것이다(why 에 까닭).
1223
+ ...(검사결과 ? {
1224
+ check: {
1225
+ ok: 검사결과.ok, command: 검사결과.명령, rounds: 검사결과.판, max: 검사결과.최대,
1226
+ ...(검사결과.요약 ? { summary: 검사결과.요약 } : {}),
1227
+ ...(검사결과.까닭 ? { why: 검사결과.까닭 } : {}),
1228
+ },
1229
+ } : {}),
1168
1230
  ms: Date.now() - t0,
1169
1231
  ...(why ? { why } : {}),
1170
1232
  });
@@ -247,7 +247,7 @@ async function fetchInto(spec, dest, onStep) {
247
247
  * 이 함수가 없으면 tar 엔트리 이름을 아무리 잘 막아도 소용이 없다. 묶음이
248
248
  * **제 이름으로** 나가기 때문이다.
249
249
  */
250
- export function 이름한칸(이름) {
250
+ export function 이름한칸(이름, { 점허용 = false } = {}) {
251
251
  const 글 = String(이름 ?? '').replace(/\\/g, '/').trim();
252
252
  if (!글) return null;
253
253
  // 마지막 칸만 쓴다. `a/b/../c` 같은 것도 여기서 한 칸이 된다.
@@ -266,6 +266,12 @@ export function 이름한칸(이름) {
266
266
  */
267
267
  if (/[<>"|?*]/.test(한칸) || [...한칸].some((ch) => ch.charCodeAt(0) < 32 || ch.charCodeAt(0) === 127)) return null;
268
268
  if (/[. ]$/.test(한칸)) return null;
269
+ /*
270
+ * 점으로 **시작**하는 이름도 안 된다. 깔리기는 하는데 list()·이름으로찾기() 가 점 이름을 숨겨서
271
+ * (`.tmp-*`·`.old-*` 를 가리려고 둔 규칙) 영영 안 보이고, 스킬 찾기도 점 폴더를 안 내려간다 (2.1.1 검수).
272
+ * 지우는 자리(remove)만 `점허용` 으로 받는다 — 이 막이 전에 깔린 것을 폴더 이름으로 지울 길은 남긴다.
273
+ */
274
+ if (!점허용 && 한칸.startsWith('.')) return null;
269
275
  if (/^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i.test(한칸)) return null;
270
276
  if (한칸.length > 100) return null;
271
277
  return 한칸;
@@ -515,7 +521,7 @@ export function remove(name, { home = homeDir() } = {}) {
515
521
  * 설치 때 막아도 이미 깔려 있던 것에는 못 쓴 이름이 남아 있을 수 있다.
516
522
  */
517
523
  const base = pluginsDir(home);
518
- const 한칸 = 이름한칸(name);
524
+ const 한칸 = 이름한칸(name, { 점허용: true });
519
525
  const 찾는이름 = String(name ?? '').trim();
520
526
  /*
521
527
  * 폴더 이름으로 못 찾으면 **매니페스트 이름**으로 찾는다. 목록(list)은 매니페스트
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,27 @@ 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
+ for (const 줄 of ev.줄임 ?? []) say(` ${c.yellow('⚠')} ${c.gray(옮긴말('check.clamped', 줄))}`);
3061
+ break;
3062
+
3063
+ case 'check':
3064
+ clearThinking();
3065
+ if (ev.ok === true) say(` ${mark.ok} ${c.gray(옮긴말('check.pass', { 명령: ev.명령 }))}`);
3066
+ else if (ev.ok === false) {
3067
+ say(` ${c.red('✗')} ${c.white(옮긴말('check.fail', { 판: ev.판, 최대: ev.최대, 요약: ev.요약 }))}`);
3068
+ for (const 한줄 of String(ev.꼬리 ?? '').trim().split('\n').filter((l) => l.trim()).slice(-6)) {
3069
+ say(` ${c.gray(clip(한줄, 100))}`);
3070
+ }
3071
+ say(` ${c.gray(ev.판 < ev.최대 ? 옮긴말('check.retry') : 옮긴말('check.gaveUp', { 판: ev.판 }))}`);
3072
+ } else say(` ${c.yellow('⊘')} ${c.gray(옮긴말('check.skipped', { 까닭: ev.까닭 }))}`);
3073
+ break;
3074
+
3009
3075
  /*
3010
3076
  * 모델이 안 하겠다고 했다 (agent/loop.js 의 거절).
3011
3077
  *
@@ -3231,10 +3297,19 @@ export async function chatLoop(opts = {}) {
3231
3297
  * 승인받는 자리는 막아 놓고 계획 **전체**를 승인하는 자리가 열려 있었다.
3232
3298
  * 재 보니 아무도 답하지 않은 판에서 계획이 승인되고 파일이 만들어졌다.
3233
3299
  */
3234
- const 답 = String(await ask(
3235
- `${옮긴말('plan.ask')} ${c.gray(옮긴말('plan.hint'))}`,
3236
- { def: 'y', 끝나면: 'n', 멈추면: 'n' },
3237
- )).trim();
3300
+ /*
3301
+ * 자율(auto) 모드면 시한을 둔다 (ui/approve.js 의 자율대기). 지나면 **그대로 진행**이다 —
3302
+ * 이 상자는 안전 관문이 아니라 「계획부터 보여 준다」 는 자리이고, auto 를 고른 사람은
3303
+ * 맡긴 사람이다. 입력이 끝난 것(파이프·Ctrl+D)은 여전히 승인이 아니다(`끝나면: 'n'`).
3304
+ */
3305
+ const 계획대기 = session.mode === 'auto' ? 자율대기초 : 0;
3306
+ const 답 = session.mode === 'auto' && 계획대기 === 0
3307
+ ? (say(` ${c.gray(옮긴말('plan.autoGo'))}`), 'y')
3308
+ : String(await ask(
3309
+ `${옮긴말('plan.ask')} ${c.gray(옮긴말('plan.hint'))}`
3310
+ + (계획대기 ? ` ${c.gray(`(${옮긴말('ask.autoWait', { 초: 계획대기 })})`)}` : ''),
3311
+ { def: 'y', 끝나면: 'n', 멈추면: 'n', 기다림초: 계획대기, 시간끝: 'y', 시간끝말: 옮긴말('plan.timedOut', { 초: 계획대기 }) },
3312
+ )).trim();
3238
3313
  /*
3239
3314
  * ── 골라진 것 + 하고 싶은 말 ────────────────────────────────────
3240
3315
  *
@@ -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
  *
@@ -34,6 +34,26 @@
34
34
  /** 가려진 자리에 남는 표. 종류를 같이 적어 무엇이 가려졌는지 알 수 있게 한다. */
35
35
  const 표 = (종류) => `«가림:${종류}»`;
36
36
 
37
+ /*
38
+ * 웹훅 주소의 머리 — 여기까지는 누구 것이든 같고, 뒤가 열쇠다.
39
+ * 가리기(아래 갈래 의 웹훅)와 셸 환경 거르기(shellenv.js)가 이 한 벌을 같이 쓴다. 두 벌이면
40
+ * 한쪽에만 새 서비스를 더하고 다른 쪽은 새는 날이 반드시 온다.
41
+ */
42
+ const 웹훅머리 = [
43
+ String.raw`\bhttps:\/\/hooks\.slack\.com\/(?:services|workflows|triggers)\/`,
44
+ String.raw`\bhttps:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/`,
45
+ String.raw`\bhttps:\/\/[\w-]+\.webhook\.office\.com\/webhookb2\/`,
46
+ String.raw`\bhttps:\/\/outlook\.office(?:365)?\.com\/webhook\/`,
47
+ ].join('|');
48
+ const 웹훅주소무늬 = new RegExp(`^(?:${웹훅머리})[^\\s"'<>]{6,}`, 'i');
49
+ const 센트리무늬 = /^https?:\/\/[0-9a-f]{32}(?::[0-9a-f]{32})?@/i;
50
+
51
+ /** 값이 웹훅 주소나 Sentry DSN 인가 — 이름으로는 못 거르는 비밀. shellenv.js 가 쓴다. */
52
+ export function 웹훅주소인가(값) {
53
+ const s = typeof 값 === 'string' ? 값.trim() : '';
54
+ return 웹훅주소무늬.test(s) || 센트리무늬.test(s);
55
+ }
56
+
37
57
  /**
38
58
  * 찾는 것들.
39
59
  *
@@ -207,6 +227,23 @@ export const 갈래 = [
207
227
  { id: 'openai', re: /\bsk-[A-Za-z0-9_-]{20,}/g },
208
228
  { id: 'github', re: /\b(?:gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,})\b/g },
209
229
  { id: 'slack', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
230
+ {
231
+ /*
232
+ * 웹훅 주소 — 주소 **자체가** 열쇠다. 아는 사람은 누구나 그 채널에 글을 올린다.
233
+ * 이름(SLACK_WEBHOOK_URL)도 값의 꼴(사람:암호@)도 비밀이라고 말하지 않아서 `env` 한 줄에
234
+ * 그대로 실렸다 (2.1.1 검수). 호스트와 갈래(services/…)는 남긴다 — 어디로 가는 주소인지는
235
+ * 사람이 봐야 한다. 셸 환경(shellenv.js)도 같은 자(웹훅주소)로 값을 거른다.
236
+ */
237
+ id: '웹훅',
238
+ re: new RegExp(`(${웹훅머리})[^\\s"'<>]{6,}`, 'gi'),
239
+ 바꾸기: (m, 앞) => `${앞}${표('웹훅')}`,
240
+ },
241
+ {
242
+ // Sentry DSN — `https://<공개열쇠 32자>@o1.ingest.sentry.io/42`. 비밀번호 칸이 없어 주소속열쇠 에 안 걸린다.
243
+ id: 'sentry',
244
+ re: /(\bhttps?:\/\/)[0-9a-f]{32}(?::[0-9a-f]{32})?@/gi,
245
+ 바꾸기: (m, 앞) => `${앞}${표('sentry')}@`,
246
+ },
210
247
  { id: 'aws', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
211
248
  { id: 'google', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
212
249
  // `stripe.setKey("sk_live_…")` 처럼 이름이 비밀을 말하지 않는 자리는 값의 꼴로만 안다(2.0.0 3회차 사냥).