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/agent/loop.js CHANGED
@@ -2,10 +2,10 @@
2
2
  // 화면에 그릴 것은 이벤트로 흘려보낸다 — 화면 코드와 섞지 않는다.
3
3
  import { chat, chatStream, assistantMessage, toolMessage, 말없이끝남, 흐름멎음, 보낸토큰, 부른것들, 도구결과인가 } from '../backend/adapter.js';
4
4
  import { 그림메시지 } from '../backend/vision.js';
5
- import { 어떻게할까 } from '../safety/policy.js';
5
+ import { 어떻게할까, 승인바닥 } from '../safety/policy.js';
6
6
  import { 자리돌리기, 막힘말 } from '../safety/hooks.js';
7
- import { toolSchemas, runTool, TOOLS, 파일현황 } from '../tools/index.js';
8
- import { isMutating } from '../safety/guard.js';
7
+ import { toolSchemas, runTool, TOOLS, 파일현황, 바뀔내용 } from '../tools/index.js';
8
+ import { isMutating, 셸이파일에쓰나 } from '../safety/guard.js';
9
9
  import { effortFor, tokensFor, fullCap, wasCut, shiftLevel, 자동강도, 천장고르기, 인사인가 as 인사말인가 } from './effort.js';
10
10
  import { 배울전선, 카드고치기, 카드저장꼴, 전선붙이기 } from '../backend/wire.js';
11
11
  import { 살린쓰기 } from './salvage.js';
@@ -14,18 +14,20 @@ import { compact, shouldCompact, shouldFold, foldToolResults, foldImages, 못박
14
14
  import { 걸음수, 하위걸음수, 요약길이 } from './budget.js';
15
15
  import { Session, estimateTokens } from './session.js';
16
16
  import { 최대깊이, 하위모드, 하위요약 } from '../tools/task.js';
17
+ import { 일감인자 } from '../tools/jobs.js';
17
18
  import { 찾기 as 에이전트찾기, 할일합치기, 도구줄이기 } from './agents.js';
18
19
  import { 프로필찾기, 쓸수있나, 연결만들기, 알릴말, 목록보기 } from './models.js';
19
20
  import { allowTemporarily, isOffline } from '../safety/network.js';
20
21
  import { 가리기, 훑기, 가렸다는말, 봤다는말, 가릴까 } from '../safety/secrets.js';
21
22
  import { 바깥인가 } from '../safety/runmode.js';
22
- import { get as workMode } from './modes.js';
23
+ import { get as workMode, 바꾸는도구 } from './modes.js';
23
24
  // 종합 모드에서 단계가 일을 따라간다 (agent/phase.js).
24
25
  import { 다음단계 } from './phase.js';
25
26
  import { 묻지말라했나, 손대라했나 } from './route.js';
26
27
  import { 지시말 } from '../i18n/index.js';
27
28
  import { 빠진것, 빠졌다는말 } from './asks.js';
28
29
  import { 환경속열쇠들 } from '../config.js';
30
+ import { 통과했나, 실패말 as 검사실패말, 출력꼬리 } from './donecheck.js';
29
31
 
30
32
  /*
31
33
  * 콜백으로만 소식을 주는 부름을, 제너레이터가 중간에 내보낼 수 있는 모양으로 바꾼다.
@@ -148,6 +150,13 @@ function thinkFor(conn, level) {
148
150
  */
149
151
  const 읽기전용 = new Set(['Read', 'Outline', 'Glob', 'Grep', 'Def', 'Refs', 'Recall', 'Skill', 'WebFetch']);
150
152
 
153
+ /*
154
+ * 스크립트 **파일**을 돌리는 꼴 — `python fix.py` · `node update.mjs` · `sh gen.sh`.
155
+ * 완료 검사가 「이번 턴에 무엇을 바꿨나」 를 셀 때만 쓴다. 스크립트 안에서 무엇을 쓰는지는
156
+ * 명령줄로 모르니 **바꿨을 수 있다**로 친다. 잘못 걸리면 검사가 한 번 더 돌 뿐이다.
157
+ */
158
+ const 스크립트돌림 = /\b(?:node|python[\d.]*|py|ruby|perl|php|deno|bun|bash|sh|pwsh|powershell)(?:\.exe)?\s+(?:-\S+\s+)*[^\s|;&]+\.(?:[cm]?js|ts|py|rb|pl|php|sh|ps1)\b/i;
159
+
151
160
  /*
152
161
  * 잘린 답을 「생각을 줄여」 다시 부를 때 쓰는 두 값.
153
162
  *
@@ -337,6 +346,100 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
337
346
  */
338
347
  const 손댄파일 = new Set();
339
348
  const 마무리 = () => [...손댄파일].map(파일현황);
349
+ /*
350
+ * 완료 검사 (agent/donecheck.js). 사람이 검사 명령을 정해 뒀으면 모델이 끝내려는 자리에서
351
+ * 그걸 돌린다. 하위 작업은 안 돈다 — 한 번 시킨 일에 검사가 하위 수만큼 돈다.
352
+ *
353
+ * 「고친 뒤에 다시 돌릴까」 는 바뀐 **횟수**로 가른다. 손댄파일 은 이름 모음이라 같은
354
+ * 파일을 다시 고치면 안 늘어난다 — 실패를 받고 같은 파일을 고친 판이 「안 바꿨다」 로
355
+ * 읽혀 검사가 다시 안 돈다.
356
+ */
357
+ const 완료검사 = 깊이 ? null : (ctx.완료검사 ?? null);
358
+ let 바뀜수 = 0;
359
+ let 검사때바뀜수 = 0;
360
+ let 검사판 = 0;
361
+ let 검사상태 = null;
362
+
363
+ /**
364
+ * 완료 검사를 한 번 돌린다. 모델이 부른 Bash 와 **같은 관문**을 지난다 — 적어 둔 규칙,
365
+ * 사람이 건 훅, 승인 모드, 그리고 Bash 도구 안의 위험 명령 막이·열쇠 뺀 환경.
366
+ * 설정에 적힌 명령이라고 건너뛰면 저장소 설정 한 줄이 strict 를 뚫는다.
367
+ *
368
+ * @returns {{ok: boolean|null, 명령, 판, 최대, 요약?, 까닭?, 출력?}} ok 가 null 이면 못 돌렸다
369
+ */
370
+ async function* 완료검사돌리기(판) {
371
+ const 명령 = 완료검사.명령;
372
+ const 최대 = 완료검사.판수;
373
+ const args = { command: 명령, description: '완료 검사', timeout: 완료검사.시간 };
374
+ // 상한에 걸려 줄인 설정은 첫 판에만 싣는다 — 판마다 같은 말을 되풀이하면 소음이다.
375
+ yield { type: 'check_start', 명령, 판, 최대, ...(판 === 1 && 완료검사.줄임?.length ? { 줄임: 완료검사.줄임 } : {}) };
376
+ const 못돌림 = (까닭) => ({ ok: null, 명령, 판, 최대, 까닭 });
377
+
378
+ let 결판 = null;
379
+ const 판정 = 어떻게할까(ctx.규칙들, 'Bash', args, { 뿌리: ctx.scope?.root ?? null });
380
+ if (판정.답 === 'deny') {
381
+ ctx.audit?.blocked?.('규칙으로 금지됨', `${판정.출처}: ${판정.규칙}`);
382
+ 결판 = 못돌림(`${판정.출처}에 적힌 규칙 ${판정.규칙} 으로 막혀 있습니다`);
383
+ }
384
+ if (!결판 && ctx.훅들?.length) {
385
+ const 훅 = await 자리돌리기(ctx.훅들, '도구전', {
386
+ 도구: 'Bash', 넣을것: { 인자: args }, signal, audit: ctx.audit,
387
+ });
388
+ if (훅.막힘) {
389
+ ctx.audit?.blocked?.('도구전 훅이 막음', `Bash · ${훅.막힘.훅.명령}`);
390
+ 결판 = 못돌림(막힘말(훅.막힘));
391
+ }
392
+ }
393
+ if (!결판) {
394
+ // 아래 도구 관문의 needsOk 와 같은 잣대다. 검사 명령은 대개 안 바꾸는 명령이라 confirm 은 안 묻는다.
395
+ const 정책이묻게함 = 승인바닥().바닥 !== 'auto';
396
+ const 물어야 = (판정.답 === 'allow' && !정책이묻게함) ? false
397
+ : session.mode === 'strict' ? true
398
+ : session.mode === 'confirm' ? isMutating(명령) : false;
399
+ if (물어야) {
400
+ const ok = ctx.confirm ? await ctx.confirm('Bash', args, { 미리보기: [] }) : false;
401
+ if (!ok) 결판 = 못돌림(ctx.confirm ? '사람이 거절했습니다' : '승인이 필요한데 물어볼 사람이 없습니다');
402
+ }
403
+ }
404
+ if (!결판) {
405
+ let result;
406
+ try { result = await runTool('Bash', args, ctx); }
407
+ catch (err) { result = { error: String(err?.message ?? err) }; }
408
+ const 아는열쇠들 = [conn.key, ...환경속열쇠들()].filter(Boolean);
409
+ if (result.중단됨) 결판 = 못돌림('중단했습니다');
410
+ /*
411
+ * Bash 도구 안의 위험 명령 막이·경로 막이에 걸렸다(`막힘 — …`). 이것도 **못 돌린** 것이다.
412
+ * 실패로 치면 모델에게 「검사를 통과시키라」 고 되미는데, 막힌 것은 모델이 고칠 수 있는 일이
413
+ * 아니다 — 판만 쓰고 같은 자리에서 또 막힌다 (2차 눈 판정).
414
+ */
415
+ else if (/^막힘 — /.test(String(result.error ?? ''))) 결판 = 못돌림(String(result.error));
416
+ else {
417
+ let 출력 = String(result.content ?? result.error ?? '');
418
+ // 모델에게 돌려줄 글이다 — 도구 결과와 같은 자로 가린다 (아래 도구 결과 싣는 자리).
419
+ if (가릴까('Bash', { 바깥: 바깥으로나감 })) {
420
+ const 가린 = 가리기(출력, { 열쇠들: 아는열쇠들 });
421
+ if (가린.가린것.length) 출력 = 가린.글 + 가렸다는말(가린.가린것);
422
+ }
423
+ 결판 = {
424
+ ok: 통과했나(result), 명령, 판, 최대, 출력,
425
+ 요약: String(result.summary ?? result.error ?? '').split('\n')[0],
426
+ };
427
+ }
428
+ // 도구전 훅을 지났으면 도구후 훅도 지난다 — 감사 훅이 앞만 보고 뒤를 못 보면 반쪽 기록이다.
429
+ if (ctx.훅들?.length && !result.중단됨) {
430
+ const 훅 = await 자리돌리기(ctx.훅들, '도구후', {
431
+ 도구: 'Bash', 넣을것: { 인자: args, 실패: 결판.ok !== true, 바뀐것: null }, signal, audit: ctx.audit,
432
+ });
433
+ if (훅.말들.length) {
434
+ const 가린 = 가리기(훅.말들.join('\n'), { 열쇠들: 아는열쇠들 });
435
+ for (const 말 of 가린.글.split('\n')) yield { type: 'hook_note', 자리: '도구후', 도구: 'Bash', 말 };
436
+ }
437
+ }
438
+ }
439
+ const { 출력: _뺌, ...보일것 } = 결판;
440
+ yield { type: 'check', ...보일것, 꼬리: 결판.출력 ? 출력꼬리(결판.출력, 1200) : '' };
441
+ return 결판;
442
+ }
340
443
 
341
444
  // 살려 쓴 파일이 지난번보다 실제로 커졌는지 보려고 크기를 기억해 둔다.
342
445
  const 살린크기 = new Map();
@@ -431,10 +534,22 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
431
534
  if (!부른것.length) return;
432
535
  // 결과가 어느 role 에 실리는지도 규격마다 다르다. `role === 'tool'` 로
433
536
  // 세면 Anthropic 꼴에서 언제나 0 이라, 지어낸 번호가 전부 겹친다.
434
- const 이미 = session.messages.filter(도구결과인가).length;
537
+ /*
538
+ * 지어낸 번호는 부름마다 새로 짓고, **이미 쓰인 번호는 건너뛴다.** 한 번만 세면 id 없는 부름 여럿이
539
+ * 같은 번호를 받고, 결과 수로만 세면 모델이 준 `call_1` 이나 접기 뒤 남은 옛 번호와 겹쳐 또 400 이다
540
+ * (2.1.1 검수 · 2차 눈). 빈 글 id 도 없는 것으로 친다.
541
+ */
542
+ const 쓴번호 = new Set(session.messages.flatMap((m) => 부른것들(m).map((t) => t.id)).filter(Boolean));
543
+ let 이미 = session.messages.filter(도구결과인가).length;
544
+ const 새번호 = () => {
545
+ let id;
546
+ do { id = `call_${++이미}`; } while (쓴번호.has(id));
547
+ 쓴번호.add(id);
548
+ return id;
549
+ };
435
550
  for (const t of 부른것) {
436
551
  session.push(toolMessage(conn.kind, {
437
- callId: t.id ?? `call_${이미 + 1}`,
552
+ callId: t.id || 새번호(),
438
553
  name: t.name ?? '?',
439
554
  content: 왜,
440
555
  }));
@@ -1536,6 +1651,30 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1536
1651
  yield { type: 'nudge', why: '요청누락', 빠진, text: msg.content };
1537
1652
  continue;
1538
1653
  }
1654
+ /*
1655
+ * ── 완료 검사 (agent/donecheck.js) ────────────────────────────────
1656
+ *
1657
+ * 모델은 「다 됐습니다」 라고 했다. 사람이 검사 명령을 정해 뒀고 이번 턴에 무엇을
1658
+ * 바꿨으면, 그 말을 믿기 전에 검사를 돌린다. 실패하면 출력을 돌려주고 이어서
1659
+ * 고치게 한다 — 정해 둔 판수까지. 다 쓰고도 실패면 **실패로** 끝낸다(done 의 검사).
1660
+ *
1661
+ * 지난 검사 뒤로 바뀐 것이 없으면 다시 안 돌린다. 같은 결과를 또 기다리게 할 뿐이다.
1662
+ * 못 돌린 것(규칙 금지·훅·승인 거절)은 되밀지 않는다 — 모델이 고칠 수 있는 일이 아니다.
1663
+ */
1664
+ if (완료검사 && 바뀜수 > 검사때바뀜수 && 검사판 < 완료검사.판수) {
1665
+ 검사판++;
1666
+ 검사때바뀜수 = 바뀜수;
1667
+ 검사상태 = yield* 완료검사돌리기(검사판);
1668
+ if (검사상태.ok === false && 검사판 < 완료검사.판수) {
1669
+ // 명령 줄도 모델에게 가는 글이다. 출력만 가리고 `--token=…` 을 날것으로 싣으면 반쪽이다.
1670
+ const 명령줄 = 가리기(검사상태.명령, { 열쇠들: [conn.key, ...환경속열쇠들()].filter(Boolean) }).글;
1671
+ session.push({
1672
+ role: 'user',
1673
+ content: 검사실패말({ 명령: 명령줄, 출력: 검사상태.출력, 판: 검사판, 최대: 완료검사.판수 }, { 영어: 지시말() === 'en' }),
1674
+ });
1675
+ continue;
1676
+ }
1677
+ }
1539
1678
  /*
1540
1679
  * ── 턴이 끝나는 자리 (safety/hooks.js) ────────────────────────────
1541
1680
  *
@@ -1560,7 +1699,9 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1560
1699
  }
1561
1700
  }
1562
1701
  // 밀고도 그대로면 조용히 넘어가지 않는다. 사람이 알아야 다음을 정한다.
1563
- yield { type: 'done', steps, text: msg.content, files: 마무리(), 빠진 };
1702
+ // 완료 검사를 돌렸으면 그 결과도 싣는다 — `deel run` 이 종료코드를, 화면이 끝맺음 줄을 여기서 정한다.
1703
+ const 검사 = 검사상태 && (({ 출력: _뺌, ...나머지 }) => 나머지)(검사상태);
1704
+ yield { type: 'done', steps, text: msg.content, files: 마무리(), 빠진, 검사 };
1564
1705
  return;
1565
1706
  }
1566
1707
 
@@ -1894,16 +2035,42 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1894
2035
  for (const 말 of 훅.말들) yield { type: 'hook_note', 자리: '도구전', 도구: call.name, 말 };
1895
2036
  }
1896
2037
 
1897
- // 모드에 따라 물어본다. 기본(auto)은 안 묻고 되돌리기로 대응한다.
1898
- const needsOk = 판정.답 === 'allow' ? false : session.mode === 'strict'
1899
- ? ['Write', 'Edit', 'Bash'].includes(call.name)
2038
+ /*
2039
+ * 모드에 따라 물어본다. 기본(auto)은 안 묻고 되돌리기로 대응한다.
2040
+ *
2041
+ * strict 는 **바꾸는 도구를 다** 묻는다. 여태 여기는 Write·Edit·Bash 셋만 적혀 있어서
2042
+ * Append·Move·Jobs 는 strict 에서도 안 묻고 돌았다 — modes.js 가 바꾸는 도구를 여섯으로
2043
+ * 적어 두고, 이 자리만 옛 목록에 남아 있었다. MCP 도구는 남의 프로그램이라 무엇을 바꾸는지
2044
+ * 우리가 모른다. 모를 때는 묻는다.
2045
+ *
2046
+ * 관리 정책이 승인 바닥을 걸었으면(policy.js 의 승인바닥) 사람이 설정에 적어 둔 「늘 허락」
2047
+ * 도 묻기를 못 건너뛴다. 정책은 설정을 이긴다 — 관리자가 「바꾸기 전에 늘 묻는다」 를
2048
+ * 걸었는데 `"allow": ["Edit"]` 한 줄로 풀리면 그 바닥은 장식이다.
2049
+ */
2050
+ const 정책이묻게함 = 승인바닥().바닥 !== 'auto';
2051
+ // Jobs 는 대개 뒤에서 도는 명령의 출력을 **읽는** 호출이다. 끝내기(stop)만 바꾸는 것으로
2052
+ // 친다 — 읽을 때마다 물으면 사람은 y 만 치다가 정작 물어야 할 것도 그렇게 넘긴다.
2053
+ // 끄는가는 **도구가 읽는 자**(일감인자)로 가른다. `stop` 칸만 보면 작은 모델이 보내는
2054
+ // `kill` · `끝내기` 가 안 묻고 서버를 끄고, `stop:"false"` 는 `!!` 로 참이 되어 헛물음이 난다.
2055
+ const 바꾸는것 = (call.name === 'Jobs' ? 일감인자(call.args).끝내기 === true : 바꾸는도구.includes(call.name))
2056
+ || call.name.startsWith('mcp__');
2057
+ const needsOk = (판정.답 === 'allow' && !정책이묻게함) ? false : session.mode === 'strict'
2058
+ ? 바꾸는것
1900
2059
  : session.mode === 'confirm'
1901
2060
  ? (call.name === 'Bash' && isMutating(call.args?.command))
1902
2061
  : false;
1903
- if (needsOk && ctx.confirm) {
1904
- const ok = await ctx.confirm(call.name, call.args);
2062
+ if (needsOk) {
2063
+ /*
2064
+ * 물어볼 사람이 없으면 **거절**이다. 여태 `needsOk && ctx.confirm` 이라, confirm 을 안 단
2065
+ * 부르는 쪽에서는 strict 가 물음 없이 그대로 실행했다 — 「묻는다」 가 「물을 곳이 있으면
2066
+ * 묻는다」 였다. 승인은 답한 사람이 있을 때만 난다 (repl.js 의 confirm 머리말과 같은 자세).
2067
+ */
2068
+ const 미리보기 = 바뀔내용(call.name, call.args, ctx);
2069
+ const ok = ctx.confirm ? await ctx.confirm(call.name, call.args, { 미리보기 }) : false;
1905
2070
  if (!ok) {
1906
- 거절(call, '사용자가 거부했습니다. 다른 방법을 찾거나 이유를 물어보세요.');
2071
+ 거절(call, ctx.confirm
2072
+ ? '사용자가 거부했습니다. 다른 방법을 찾거나 이유를 물어보세요.'
2073
+ : '승인이 필요한데 물어볼 사람이 없어 하지 않았습니다. 승인 없이 되는 방법을 고르세요.');
1907
2074
  // 거부를 못 알아듣고 같은 것을 계속 물으면 사람만 계속 n 을 치게 된다.
1908
2075
  if (막힘셈(call, '사용자 거부')) 멈출까 = '거부하셨는데도 같은 것을 계속 물어보고 있습니다';
1909
2076
  yield { type: 'tool', name: call.name, args: call.args, result: { error: '거부됨' }, showLabel: true };
@@ -2141,6 +2308,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
2141
2308
  * 여기서 또 합치면 같은 변경이 두 번 세어진다.
2142
2309
  */
2143
2310
  for (const f of 끝?.files ?? []) if (f?.path) 손댄파일.add(f.path);
2311
+ if (끝?.files?.length) 바뀜수++;
2144
2312
  // 하위가 쓴 토큰·시간도 이번 턴의 셈에 들어가야 한다. 안 그러면 /context 가 거짓말을 한다.
2145
2313
  session.usage.in += 자식.usage.in;
2146
2314
  session.usage.out += 자식.usage.out;
@@ -2282,6 +2450,17 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
2282
2450
  if (result.changed || result.여럿?.some((f) => f.ok) || result.바뀐것들?.length) {
2283
2451
  이번턴본것.바꿈 = true;
2284
2452
  }
2453
+ /*
2454
+ * 완료 검사가 「고친 뒤에 다시 돌릴까」 를 이걸로 가른다. 셸로 바꾼 것(sed -i · mv ·
2455
+ * `> 파일` · 스크립트 파일 돌리기)도 센다 — 실패를 받고 셸로만 고친 판에서 검사가 다시
2456
+ * 안 돌면 고쳐 놓고도 실패로 끝난다. **검사가 한 번 돈 뒤에는** 셸·MCP 호출을 무엇이든
2457
+ * 센다(2차 눈 판정: `make` · `npx codemod` 는 어느 목록에도 없다). 고치는 중이라
2458
+ * 헛짚어도 검사가 한 번 더 돌 뿐이고, 판수가 끝을 막는다.
2459
+ */
2460
+ const 셸명령 = call.name === 'Bash' ? String(call.args?.command ?? '') : null;
2461
+ if (!result.error && (result.changed || result.여럿?.some((f) => f.ok) || result.바뀐것들?.length
2462
+ || (셸명령 !== null && (isMutating(셸명령) || 셸이파일에쓰나(셸명령) || 스크립트돌림.test(셸명령)))
2463
+ || (검사판 > 0 && (셸명령 !== null || call.name.startsWith('mcp__'))))) 바뀜수++;
2285
2464
  if (call.name === 'TodoWrite' && !result.error) 이번턴본것.할일 = true;
2286
2465
  // 한 번에 여러 개를 쓴 경우. changed 하나만 보면 나머지가 조용히 빠져서,
2287
2466
  // 턴 끝에 "만들어졌다" 고 확인해 주는 파일이 넷 중 하나만 나온다.
@@ -36,6 +36,9 @@ const 계획 = ['TodoWrite', 'Remember'];
36
36
  // Move 는 구조를 바꾸는 유일한 길이다. 이게 없으면 "폴더를 역할대로 나눠 줘"
37
37
  // 에 남는 길이 Bash mv 뿐인데, 그건 매번 승인을 묻고 되돌리기에 안 잡힌다.
38
38
  const 쓰기 = ['Write', 'Append', 'Edit', 'Move', 'Bash', 'Jobs'];
39
+ // strict 승인이 묻는 도구도 이 목록이다 (agent/loop.js). 따로 적어 두면 여기 도구가
40
+ // 늘 때 관문 쪽 목록이 뒤에 남는다 — Append·Move·Jobs 가 실제로 그렇게 빠져 있었다.
41
+ export const 바꾸는도구 = Object.freeze([...쓰기]);
39
42
  // 만든 것을 확인하는 도구. 아무것도 안 바꾸지만 **쓰는 모드에만** 준다 —
40
43
  // 안 만든 모드에서 확인할 것이 없고, 도구 정의로 나가는 자리만 먹는다.
41
44
  const 확인 = ['Verify'];
@@ -1381,7 +1381,8 @@ export function route(text, { 승인받을수있나 = true } = {}) {
1381
1381
 
1382
1382
  // 1·2등이 붙으면 고르지 않는다. 반반인 것을 억지로 정하면 절반은 틀린다.
1383
1383
  const 둘째 = 순위[1]?.[1] ?? 0;
1384
- if (점수 - 둘째 < 2 && 둘째 >= (문턱[순위[1][0]] ?? 4)) {
1384
+ // 둘째가 없을 수도 있다 — 지금 표로는 늘 둘이 남지만, 모드 표가 바뀌면 여기서 사람 말 한 줄이 죽는다.
1385
+ if (순위[1] && 점수 - 둘째 < 2 && 둘째 >= (문턱[순위[1][0]] ?? 4)) {
1385
1386
  return {
1386
1387
  mode: null, score: 점수, why: `${으뜸} 과 ${순위[1][0]} 가 비슷함`,
1387
1388
  겹침: 겹.겹침, 점수들, 뺀것, 일부러: true,
@@ -7,6 +7,7 @@ import { 조각표 } from '../backend/cachemark.js';
7
7
  import { get as workMode, 말 as 모드말, DEFAULT as WORK_DEFAULT } from './modes.js';
8
8
  import { toolSchemas } from '../tools/index.js';
9
9
  import { isOffline } from '../safety/network.js';
10
+ import { 승인바닥, 더센승인 } from '../safety/policy.js';
10
11
  import { normalize as normLevel, DEFAULT as LEVEL_DEFAULT } from '../ui/level.js';
11
12
  import { 매김, 급말, 값 as 급값, 지켜본것 } from './grade.js';
12
13
  import { 지문 } from './project.js';
@@ -217,7 +218,24 @@ function 기본규칙(ctx) {
217
218
  */
218
219
  const 규칙최대 = 20000;
219
220
 
221
+ /** 메시지 하나를 센 값 — 메시지 객체가 열쇠다(breakdown 의 「한 번 센 메시지」). 갈아 끼우면 저절로 빠진다. */
222
+ const 메시지셈 = new WeakMap();
223
+
220
224
  export class Session {
225
+ #승인 = 'auto';
226
+
227
+ /*
228
+ * 승인 모드는 관리 정책의 바닥 아래로 못 내려간다 (safety/policy.js 의 승인바닥).
229
+ *
230
+ * 읽는 자리에서 덮는다. `/mode` · Shift+Tab · 편집기 · `deel run` · 하위 작업이 제각각
231
+ * session.mode 를 쓰는데, 자리마다 바닥을 걸면 하나를 빠뜨린 자리가 곧 구멍이다.
232
+ * 여기 한 곳에서 걸면 누가 무엇을 적든 관문(agent/loop.js)이 읽는 값은 바닥 위다.
233
+ */
234
+ get mode() { return 더센승인(this.#승인, 승인바닥().바닥); }
235
+ set mode(v) { this.#승인 = v; }
236
+ /** 사람이 고른 그대로 — 바닥이 덮었는지를 화면이 말할 때 쓴다. */
237
+ get 고른승인() { return this.#승인; }
238
+
221
239
  constructor(conn, { root, mode = 'auto', work = null, level = null, think = 'medium', effort = 'save', web = true, maxSteps = null } = {}) {
222
240
  this.conn = conn;
223
241
  this.root = root;
@@ -1139,6 +1157,18 @@ export class Session {
1139
1157
  */
1140
1158
  let 도구결과수 = 0;
1141
1159
  for (const m of this.messages) {
1160
+ /*
1161
+ * 한 번 센 메시지는 다시 안 센다 (2.1.1 검수).
1162
+ *
1163
+ * 상태줄은 일하는 동안 90ms 마다 그려지고 그때마다 여기를 부른다. 64만 토큰 창이면 초당
1164
+ * 스무 번 넘게 대화 전체를 글자 단위로 훑었다. 메시지는 제자리에서 안 고치고 **새 것으로
1165
+ * 갈아 끼우므로**(접기·요약·짝맞추기 다 그렇다) 메시지 자체를 열쇠로 잡아도 낡지 않는다.
1166
+ */
1167
+ const 센것 = m && typeof m === 'object' ? 메시지셈.get(m) : undefined;
1168
+ if (센것) {
1169
+ if (센것.결과) { files += 센것.t; 도구결과수 += 1; } else history += 센것.t;
1170
+ continue;
1171
+ }
1142
1172
  /*
1143
1173
  * 그림은 글자 수로 세지 않는다.
1144
1174
  *
@@ -1155,7 +1185,9 @@ export class Session {
1155
1185
  // 도구 결과는 규격마다 다른 자리에 온다. `role` 만 보면 Anthropic 에서는
1156
1186
  // 도구 결과가 통째로 '대화' 로 세어져서, /context 가 「도구 결과 0 토큰」
1157
1187
  // 이라고 적는다 — 무엇을 접어야 할지 보라고 만든 표가 거꾸로 가리킨다.
1158
- if (도구결과인가(m)) { files += t; 도구결과수 += 1; } else history += t;
1188
+ const 결과 = 도구결과인가(m);
1189
+ if (m && typeof m === 'object') 메시지셈.set(m, { t, 결과 });
1190
+ if (결과) { files += t; 도구결과수 += 1; } else history += t;
1159
1191
  }
1160
1192
 
1161
1193
  // 도구 정의도 매 요청에 실려 나간다. 세는 값이라기보다 '이미 나간 값' 이다.
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('');
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,12 @@ 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 },
95
+ { 이름: '--init', 뜻: 'eval: 예제 과제를 만든다', en: 'eval: write the example tasks', 값: false },
96
+ { 이름: '--only', 뜻: 'eval: 고른 과제만 (쉼표) · sbom: 한 가지만', en: 'eval: only these tasks (comma); sbom: only one part', 값: null },
97
+ { 이름: '--keep', 뜻: 'eval: 작업 폴더를 남긴다', en: 'eval: keep the work folders', 값: false },
98
+ { 이름: '--timeout', 뜻: 'eval: 과제 하나의 제한 시간(초)', en: 'eval: time limit per task (seconds)', 값: null },
92
99
  { 이름: '--days', 뜻: 'stats 에서 볼 기간(일)', en: 'stats: how many days back', 값: null },
93
100
  { 이름: '--all', 뜻: 'stats 에서 기간을 안 자름', en: 'stats: no time window', 값: false },
94
101
  // 값이 파일이라 셸이 파일을 완성해 줘야 한다. 목록으로 못 준다 —
@@ -25,6 +25,7 @@ import { homeDir, 집설정파일인가, 열쇠환경이름 } from './config.js'
25
25
  import { 믿나, 프로젝트거르기, BOM떼기, 연결칸 } from './safety/trust.js';
26
26
  import { 정책읽기, 정책자리 } from './safety/policy.js';
27
27
  import { 받기설정 } from './safety/authcmd.js';
28
+ import { 주소가리기 } from './safety/secrets.js';
28
29
 
29
30
  /*
30
31
  * 이 PC 프로필에 겹치는 저장소 프로필에서 연결 칸(baseUrl·kind·auth·제공자)을 걷는다.
@@ -78,7 +79,23 @@ const 환경변수 = {
78
79
  * 하는 것이 이런 자리에서 제일 흔한 사고다.
79
80
  */
80
81
  const 비밀표시 = '(적혀 있음 — 값은 안 보여 줍니다)';
81
- const 지운값 = (칸, v) => (v !== undefined && 가릴칸.has(칸) ? 비밀표시 : v);
82
+ /*
83
+ * **끝 칸 이름만** 보면 안 된다. `profiles.사내` 처럼 한 칸 위에서 멈추면 값이 객체이고,
84
+ * 그 안의 apiKey · 인증서 passphrase 가 통째로 그려졌다. 그래서 객체·배열은 안으로 파고들어
85
+ * 칸마다 같은 자로 본다.
86
+ *
87
+ * 주소 꼴의 글(`http://사람:암호@프록시:8080`)은 doctor 와 같은 자(주소가리기)로 가린다 —
88
+ * 호스트·포트는 남는다. 어디로 가는지는 사람이 봐야 한다.
89
+ */
90
+ const 주소꼴 = /^[a-z][a-z0-9+.-]*:\/\//i;
91
+ const 지운값 = (칸, v) => {
92
+ if (v === undefined) return v;
93
+ if (가릴칸.has(칸)) return 비밀표시;
94
+ if (typeof v === 'string') return 주소꼴.test(v.trim()) ? 주소가리기(v) : v;
95
+ if (Array.isArray(v)) return v.map((x) => 지운값('', x));
96
+ if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, 지운값(k, x)]));
97
+ return v;
98
+ };
82
99
 
83
100
  const 값보이기 = (칸, v) => {
84
101
  if (v === undefined) return null;
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(프로젝트설정) && !집설정파일인가(프로젝트설정)) {