deel-local-cli 2.1.0 → 2.1.2

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.
@@ -218,6 +218,9 @@ function 기본규칙(ctx) {
218
218
  */
219
219
  const 규칙최대 = 20000;
220
220
 
221
+ /** 메시지 하나를 센 값 — 메시지 객체가 열쇠다(breakdown 의 「한 번 센 메시지」). 갈아 끼우면 저절로 빠진다. */
222
+ const 메시지셈 = new WeakMap();
223
+
221
224
  export class Session {
222
225
  #승인 = 'auto';
223
226
 
@@ -1108,16 +1111,33 @@ export class Session {
1108
1111
  return this.보정;
1109
1112
  }
1110
1113
 
1114
+ /*
1115
+ * 글이 같으면 센 값을 쓴다 (2.1.2).
1116
+ *
1117
+ * 상태줄은 일하는 동안 90ms 마다 그리고 한 번 그릴 때 여기를 여러 번 부른다. 대화는 2.1.1 에서
1118
+ * 메시지마다 한 번만 세게 했는데, 시스템 글(규칙 파일·기억·스킬 목록까지 수만 자)은 그때마다
1119
+ * 새로 짓고 **글자 하나씩** 다시 셌다 — 그것이 한 번 그리는 값의 대부분이었다. 칸마다 지난 글과
1120
+ * 센 값을 들고 있다가 글이 같으면(같은 글인지 보는 것은 글자 훑기보다 훨씬 싸다) 그 값을 쓴다.
1121
+ */
1122
+ #셈기억 = new Map();
1123
+ #셈(칸, 글) {
1124
+ const 있던 = this.#셈기억.get(칸);
1125
+ if (있던 && 있던.글 === 글) return 있던.t;
1126
+ const t = estimateTokens(글);
1127
+ this.#셈기억.set(칸, { 글, t });
1128
+ return t;
1129
+ }
1130
+
1111
1131
  /** 보정을 안 먹인 날 추정. 배운다() 가 견주는 값이다. */
1112
1132
  #원추정() {
1113
- const rules = this.rules ? estimateTokens(this.rules.text) : 0;
1133
+ const rules = this.rules ? this.#셈('규칙', String(this.rules.text ?? '')) : 0;
1114
1134
  const listed = this.listedSkills();
1115
1135
  const skills = listed.length
1116
- ? estimateTokens(listed.map((s) => `${s.name}: ${String(s.description ?? '').slice(0, this.maxSkillDesc)}`).join('\n'))
1136
+ ? this.#셈('스킬', listed.map((s) => `${s.name}: ${String(s.description ?? '').slice(0, this.maxSkillDesc)}`).join('\n'))
1117
1137
  : 0;
1118
- const 배움 = this.배움요약 ? estimateTokens(this.배움요약) : 0;
1138
+ const 배움 = this.배움요약 ? this.#셈('배움', String(this.배움요약)) : 0;
1119
1139
  // 기억도 매 요청에 통째로 나간다 — 아래 시스템 칸에서 빼려면 여기서 먼저 잰다.
1120
- const 기억 = this.memory ? estimateTokens(this.memory) : 0;
1140
+ const 기억 = this.memory ? this.#셈('기억', String(this.memory)) : 0;
1121
1141
  const 기억줄 = this.memory ? this.memory.split('\n').filter((l) => l.startsWith('- ')).length : 0;
1122
1142
 
1123
1143
  /*
@@ -1136,7 +1156,7 @@ export class Session {
1136
1156
  * 따로 줄을 갖는 것들(규칙·기억·배움·스킬)을 빼면 남는 것이 시스템
1137
1157
  * 칸이다. 앞으로 무엇이 더 붙어도 저절로 세어진다.
1138
1158
  */
1139
- const sys = Math.max(0, estimateTokens(this.systemPrompt()) - (rules + 기억 + 배움 + skills));
1159
+ const sys = Math.max(0, this.#셈('시스템', this.systemPrompt()) - (rules + 기억 + 배움 + skills));
1140
1160
 
1141
1161
  let history = 0;
1142
1162
  let files = 0;
@@ -1154,6 +1174,18 @@ export class Session {
1154
1174
  */
1155
1175
  let 도구결과수 = 0;
1156
1176
  for (const m of this.messages) {
1177
+ /*
1178
+ * 한 번 센 메시지는 다시 안 센다 (2.1.1 검수).
1179
+ *
1180
+ * 상태줄은 일하는 동안 90ms 마다 그려지고 그때마다 여기를 부른다. 64만 토큰 창이면 초당
1181
+ * 스무 번 넘게 대화 전체를 글자 단위로 훑었다. 메시지는 제자리에서 안 고치고 **새 것으로
1182
+ * 갈아 끼우므로**(접기·요약·짝맞추기 다 그렇다) 메시지 자체를 열쇠로 잡아도 낡지 않는다.
1183
+ */
1184
+ const 센것 = m && typeof m === 'object' ? 메시지셈.get(m) : undefined;
1185
+ if (센것) {
1186
+ if (센것.결과) { files += 센것.t; 도구결과수 += 1; } else history += 센것.t;
1187
+ continue;
1188
+ }
1157
1189
  /*
1158
1190
  * 그림은 글자 수로 세지 않는다.
1159
1191
  *
@@ -1170,7 +1202,9 @@ export class Session {
1170
1202
  // 도구 결과는 규격마다 다른 자리에 온다. `role` 만 보면 Anthropic 에서는
1171
1203
  // 도구 결과가 통째로 '대화' 로 세어져서, /context 가 「도구 결과 0 토큰」
1172
1204
  // 이라고 적는다 — 무엇을 접어야 할지 보라고 만든 표가 거꾸로 가리킨다.
1173
- if (도구결과인가(m)) { files += t; 도구결과수 += 1; } else history += t;
1205
+ const 결과 = 도구결과인가(m);
1206
+ if (m && typeof m === 'object') 메시지셈.set(m, { t, 결과 });
1207
+ if (결과) { files += t; 도구결과수 += 1; } else history += t;
1174
1208
  }
1175
1209
 
1176
1210
  // 도구 정의도 매 요청에 실려 나간다. 세는 값이라기보다 '이미 나간 값' 이다.
@@ -36,29 +36,8 @@ export const AUTH_STYLES = [
36
36
  { id: 'none', label: '인증 없음', apply: () => {} },
37
37
  ];
38
38
 
39
- /**
40
- * 열어 둔 연결을 닫는다. 프로그램을 끝내기 직전에 부른다.
41
- *
42
- * fetch 는 연결을 재사용하려고 소켓을 살려 둔다(keep-alive). 그래서 할 일이
43
- * 끝나도 프로세스가 저절로 안 끝난다. 예전에는 그걸 process.exit() 으로
44
- * 잘라 냈는데, 윈도우에서 닫는 중인 핸들을 두고 끊으면 libuv 가 abort 한다.
45
- *
46
- * Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c
47
- *
48
- * 실제로 `deel scan` 이 결과를 다 찍고 나서 이렇게 죽었다. 화면에는 정상으로
49
- * 보이는데 종료코드는 3221226505(0xC0000409) 였다.
50
- *
51
- * 그래서 잘라 내는 대신 닫는다. 이 자리는 Node 내부 이름이라 없을 수도 있으므로,
52
- * 없으면 조용히 넘어간다 — 그때는 부르는 쪽의 시간제한이 받아 준다.
53
- * 프록시 길은 요청마다 소켓을 새로 열고 끝나면 닫으므로(Connection: close) 여기 안 온다.
54
- */
55
- export function closeConnections() {
56
- try {
57
- const d = globalThis[Symbol.for('undici.globalDispatcher.1')];
58
- if (d && typeof d.close === 'function') return d.close().catch(() => {});
59
- } catch { /* 없으면 그만 */ }
60
- return Promise.resolve();
61
- }
39
+ // 살려 둔 연결 닫기는 src/exit.js 에 있다 — 진입점이 이 파일 없이 끝맺게 (2.1.2).
40
+ export { closeConnections } from '../exit.js';
62
41
 
63
42
  export function headersFor(authStyle, key, extra = {}) {
64
43
  const h = { 'Content-Type': 'application/json', Accept: 'application/json', ...extra };
@@ -952,6 +952,21 @@ export function 이름풀기(전체) {
952
952
  return m ? { 서버: m[1], 도구: m[2] } : null;
953
953
  }
954
954
 
955
+ /**
956
+ * 서버가 이 도구를 **읽기만 한다** 고 적었나 (MCP `annotations.readOnlyHint`).
957
+ *
958
+ * confirm 이 MCP 도구를 안 물을지 정하는 데만 쓴다 (loop.js 의 관문). 적힌 말은 남의 서버가 한
959
+ * 말이라 좁게 믿는다 — 참(`true`)일 때만, 그리고 「지운다」(destructiveHint) 를 같이 적었으면
960
+ * 앞뒤가 안 맞으니 안 믿는다. strict 는 이 말을 아예 안 본다.
961
+ */
962
+ export function 읽기만하나(서버들, 전체) {
963
+ const 갈린것 = 이름풀기(전체);
964
+ if (!갈린것) return false;
965
+ const 서버 = (서버들 ?? []).find((s) => s?.이름 === 갈린것.서버);
966
+ const 적힌것 = (서버?.도구 ?? []).find((t) => t?.name === 갈린것.도구)?.annotations;
967
+ return 적힌것?.readOnlyHint === true && 적힌것?.destructiveHint !== true;
968
+ }
969
+
955
970
  /**
956
971
  * 설정에 적힌 서버를 전부 띄운다.
957
972
  *
package/src/completion.js CHANGED
@@ -91,7 +91,13 @@ export const 깃발들 = [
91
91
  { 이름: '--hard', 뜻: 'reset all 에서 되돌리기·감사기록까지', en: 'with reset all: undo snapshots and the audit log too', 값: false },
92
92
  { 이름: '--json', 뜻: '결과를 JSON 으로', en: 'output JSON', 값: false },
93
93
  { 이름: '--check', 뜻: 'run: 끝내기 전에 돌릴 검사 명령', en: 'run: check command to pass before finishing', 값: null },
94
+ { 이름: '--check-rounds', 뜻: 'run: 검사를 몇 번까지 돌리나 (1~10)', en: 'run: how many times the check runs (1-10)', 값: null },
95
+ { 이름: '--check-timeout', 뜻: 'run: 검사 한 번의 제한 시간(초)', en: 'run: time limit per check run (seconds)', 값: null },
94
96
  { 이름: '--repeat', 뜻: 'eval: 과제마다 몇 번 돌릴까', en: 'eval: runs per task', 값: null },
97
+ { 이름: '--init', 뜻: 'eval: 예제 과제를 만든다', en: 'eval: write the example tasks', 값: false },
98
+ { 이름: '--only', 뜻: 'eval: 고른 과제만 (쉼표) · sbom: 한 가지만', en: 'eval: only these tasks (comma); sbom: only one part', 값: null },
99
+ { 이름: '--keep', 뜻: 'eval: 작업 폴더를 남긴다', en: 'eval: keep the work folders', 값: false },
100
+ { 이름: '--timeout', 뜻: 'eval: 과제 하나의 제한 시간(초)', en: 'eval: time limit per task (seconds)', 값: null },
95
101
  { 이름: '--days', 뜻: 'stats 에서 볼 기간(일)', en: 'stats: how many days back', 값: null },
96
102
  { 이름: '--all', 뜻: 'stats 에서 기간을 안 자름', en: 'stats: no time window', 값: false },
97
103
  // 값이 파일이라 셸이 파일을 완성해 줘야 한다. 목록으로 못 준다 —
@@ -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/eval/run.js CHANGED
@@ -97,20 +97,48 @@ export function 과제읽기(폴더) {
97
97
  return 과제들;
98
98
  }
99
99
 
100
+ // 수 칸은 수나 수를 적은 글만 읽는다. Number(true) = 1 · Number(null) = 0 이라 참/거짓이 수로 통과했다 (2.1.2 2차 눈).
101
+ const 수로 = (v) => (typeof v === 'number' || (typeof v === 'string' && v.trim() !== '') ? Number(v) : NaN);
102
+
100
103
  /** task.json 이 쓸 수 있는 모양인가. 쓸 수 있으면 null, 아니면 까닭. */
101
104
  export function 설정탈(v) {
102
105
  if (!v || typeof v !== 'object' || Array.isArray(v)) return 'task.json 이 객체가 아닙니다';
103
106
  if (typeof v.prompt !== 'string' || !v.prompt.trim()) return 'prompt(시킬 말)가 없습니다';
104
107
  if (typeof v.check !== 'string' || !v.check.trim()) return 'check(정답 검사 명령)가 없습니다';
105
108
  for (const 칸 of ['timeout', 'checkTimeout']) {
106
- if (v[칸] !== undefined && !(Number.isFinite(Number(v[칸])) && Number(v[칸]) > 0)) return `${칸} 은 0 보다 큰 초여야 합니다`;
109
+ if (v[칸] !== undefined && !(Number.isFinite(수로(v[칸])) && 수로(v[칸]) > 0)) return `${칸} 은 0 보다 큰 초여야 합니다`;
107
110
  }
108
111
  for (const 칸 of ['doneCheck', 'work']) {
109
112
  if (v[칸] !== undefined && typeof v[칸] !== 'string') return `${칸} 은 글이어야 합니다`;
110
113
  }
114
+ // 과제마다 완료 검사를 몇 번·얼마나 돌리나 (2.1.2) — deel run 의 --check-rounds · --check-timeout 으로 넘긴다.
115
+ if (v.doneCheckRounds !== undefined && !(Number.isInteger(수로(v.doneCheckRounds)) && 수로(v.doneCheckRounds) >= 1)) {
116
+ return 'doneCheckRounds 는 1 이상의 정수여야 합니다';
117
+ }
118
+ if (v.doneCheckTimeout !== undefined && !(Number.isFinite(수로(v.doneCheckTimeout)) && 수로(v.doneCheckTimeout) > 0)) {
119
+ return 'doneCheckTimeout 은 0 보다 큰 초여야 합니다';
120
+ }
121
+ if ((v.doneCheckRounds !== undefined || v.doneCheckTimeout !== undefined) && !v.doneCheck) {
122
+ return 'doneCheckRounds · doneCheckTimeout 은 doneCheck(완료 검사 명령)와 같이 적어야 합니다';
123
+ }
111
124
  return null;
112
125
  }
113
126
 
127
+ /**
128
+ * 과제 하나에 띄울 `deel run` 의 인자. 시킬 말은 `--` 뒤 맨 끝이다 — 대시로 시작해도 깃발로 안 읽힌다.
129
+ */
130
+ export function 실행인자(설정, 작업, 깃발들 = []) {
131
+ return [
132
+ 진입점, 'run', '--json', '--yes', '--root', 작업,
133
+ ...(설정.work ? ['--work', 설정.work] : []),
134
+ ...(설정.doneCheck ? ['--check', 설정.doneCheck] : []),
135
+ ...(설정.doneCheck && 설정.doneCheckRounds !== undefined ? ['--check-rounds', String(설정.doneCheckRounds)] : []),
136
+ ...(설정.doneCheck && 설정.doneCheckTimeout !== undefined ? ['--check-timeout', String(설정.doneCheckTimeout)] : []),
137
+ ...깃발들,
138
+ '--', 설정.prompt,
139
+ ];
140
+ }
141
+
114
142
  /**
115
143
  * 폴더를 통째로 복사한다. 원본이 없으면 아무것도 안 한다(start/ 가 없는 과제는 빈 폴더에서 시작).
116
144
  *
@@ -189,15 +217,8 @@ export async function 한번돌리기(과제, { 판 = 1, 깃발들 = [], 남김
189
217
  const 작업 = mkdtempSync(join(tmpdir(), 'deel-eval-'));
190
218
  try {
191
219
  폴더복사(join(과제.자리, 'start'), 작업);
192
- const 제한초 = Number(설정.timeout ?? 시간초 ?? 기본시간초);
193
- const 인자 = [
194
- 진입점, 'run', '--json', '--yes', '--root', 작업,
195
- ...(설정.work ? ['--work', 설정.work] : []),
196
- ...(설정.doneCheck ? ['--check', 설정.doneCheck] : []),
197
- ...깃발들,
198
- // `--` 뒤는 낱말이다 — 시킬 말이 대시로 시작해도 깃발로 안 읽힌다.
199
- '--', 설정.prompt,
200
- ];
220
+ const 제한초 = 제한초고르기(설정, 시간초);
221
+ const 인자 = 실행인자(설정, 작업, 깃발들);
201
222
  const 돌림 = await 돌려보기(process.execPath, 인자, {
202
223
  timeout: 제한초 * 1000, maxBuffer: 64 * 1024 * 1024,
203
224
  덤: { cwd: 작업, env: { ...process.env, NO_COLOR: '1' } },
@@ -251,35 +272,105 @@ export function 과제별(결과들) {
251
272
  /**
252
273
  * 지난 결과와 견준다. 다 통과하던 과제가 하나라도 떨어졌으면 나빠짐, 못 하던 과제를 다 해내면 나아짐.
253
274
  * 되풀이 수가 다르면 비율로 본다.
275
+ *
276
+ * 지난번에 있던 과제가 이번에 없으면 **사라짐**, 처음 재는 과제는 **새것** 으로 적는다 (2.1.2). 여태
277
+ * 사라진 과제는 말없이 빠져, 떨어지던 과제 폴더를 지우고 돌려도 남은 과제끼리 「지난번과 같습니다」 가
278
+ * 나왔다. `일부만`(--only)이면 안 고른 과제가 다 사라짐이 되므로 사라짐은 안 센다.
254
279
  */
255
- export function 견주기(지난것, 지금것) {
280
+ export function 견주기(지난것, 지금것, { 일부만 = false } = {}) {
256
281
  const 비율 = (x) => (x.판 ? x.통과 / x.판 : 0);
257
282
  const 지난표 = new Map((지난것 ?? []).map((x) => [x.과제, x]));
258
283
  const 나빠짐 = [];
259
284
  const 나아짐 = [];
285
+ // 몇 과제를 견줬나 — 0 이면 「지난번과 같습니다」 라 말할 근거가 없다.
286
+ let 견준 = 0;
260
287
  for (const x of 지금것) {
261
288
  const 전 = 지난표.get(x.과제);
262
289
  if (!전) continue;
290
+ 견준 += 1;
263
291
  if (비율(x) < 비율(전)) 나빠짐.push(x.과제);
264
292
  else if (비율(x) > 비율(전)) 나아짐.push(x.과제);
265
293
  }
266
- return { 나빠짐, 나아짐 };
294
+ const 지금이름 = new Set(지금것.map((x) => x.과제));
295
+ const 사라짐 = 일부만 ? [] : [...지난표.keys()].filter((이름) => !지금이름.has(이름));
296
+ const 새것 = 지금것.filter((x) => !지난표.has(x.과제)).map((x) => x.과제);
297
+ return { 나빠짐, 나아짐, 견준, 사라짐, 새것 };
267
298
  }
268
299
 
269
- /** 제일 새 결과 파일. 깨졌거나 과제별이 없는 것(손으로 적은 `{}` 따위)은 건너뛴다 — 견줄 것이 없다. */
270
- export function 지난결과(폴더) {
300
+ /**
301
+ * 「지난번과 같습니다」 를 어디까지 말할 수 있나. 달라진 과제가 있거나 견준 것이 없으면 null.
302
+ * 견주지 않은 과제(사라짐 · 새것 · --only 로 안 고른 것)가 있으면 '견준만큼' — 처음 재는 과제가 떨어져도
303
+ * 견준 과제끼리 같으면 「같습니다」 가 나와 그 실패가 묻혔다 (2.1.2 2차 눈).
304
+ */
305
+ export function 같음범위(견줌, { 일부만 = false } = {}) {
306
+ if (!견줌?.견준 || 견줌.나빠짐.length || 견줌.나아짐.length) return null;
307
+ return 일부만 || 견줌.사라짐.length || 견줌.새것.length ? '견준만큼' : '전부';
308
+ }
309
+
310
+ /**
311
+ * 과제 하나의 제한 시간(초). task.json 에 적힌 것이 `--timeout` 을 이긴다 — pytest-timeout 의 표시나
312
+ * jest 의 과제별 시간과 같은 차례다. `--timeout` 은 제 시간을 안 적은 과제의 기본값이다.
313
+ */
314
+ export const 제한초고르기 = (설정, 시간초) => Number(설정?.timeout ?? 시간초 ?? 기본시간초);
315
+
316
+ /**
317
+ * 제일 새 결과 파일. 깨졌거나 과제별이 없는 것(손으로 적은 `{}` 따위)은 건너뛴다 — 견줄 것이 없다.
318
+ *
319
+ * `전체만` 이면 `--only` 로 일부만 돈 결과(`only` 칸이 있는 것)도 건너뛴다. 안 그러면 `--only a`
320
+ * 한 번 뒤의 전체 판이 과제 a 하나만 견주고, 나머지가 떨어져도 「지난번과 같습니다」 가 나온다
321
+ * (2.1.1 검수). 일부만 도는 판은 아무것과 견줘도 된다 — 견주기 가 지금 돈 과제만 본다.
322
+ */
323
+ export function 지난결과(폴더, { 전체만 = false, 이름들 = null } = {}) {
271
324
  const 자리 = join(폴더, 결과폴더);
272
- if (!existsSync(자리)) return null;
273
- const 파일들 = readdirSync(자리).filter((f) => /^\d{8}-\d{6}.*\.json$/.test(f)).sort();
325
+ let 파일들;
326
+ // 손으로 만든 `.results` **파일**이면 readdirSync 가 던진다 — 견줄 것이 없을 뿐이다.
327
+ try { 파일들 = readdirSync(자리).filter((f) => /^\d{8}-\d{6}.*\.json$/.test(f)).sort(); } catch { return null; }
274
328
  for (const f of 파일들.reverse()) {
275
329
  try {
276
330
  const 값 = JSON.parse(readFileSync(join(자리, f), 'utf8'));
277
- if (Array.isArray(값?.과제별)) return { 파일: f, 값 };
331
+ if (!Array.isArray(값?.과제별)) continue;
332
+ if (전체만 && 값.only) continue;
333
+ // 일부만 도는 판은 **지금 과제가 든** 결과까지 거슬러 찾는다 — `--only a` 뒤의 `--only b` 가 a 만 든 판과
334
+ // 견주면 겹치는 과제가 없는데도 「지난번과 같습니다」 가 나온다 (2.1.1 2차 눈).
335
+ if (이름들 && !값.과제별.some((x) => 이름들.includes(x?.과제))) continue;
336
+ return { 파일: f, 값 };
278
337
  } catch { /* 깨진 것은 건너뛴다 */ }
279
338
  }
280
339
  return null;
281
340
  }
282
341
 
342
+ /**
343
+ * `--only` 로 과제를 고른다. 쉼표로 여럿.
344
+ *
345
+ * 이름이 **딱 맞는** 것이 있으면 그것만 고른다 — 이름 일부로만 고르면 `task-1` 이 `task-10` ·
346
+ * `task-11` 까지 불러 엉뚱한 과제에 시간을 쓴다 (2.1.1 검수). 딱 맞는 것이 없으면 예전처럼
347
+ * 이름 일부로 고른다(`--only 03` · `--only a-`).
348
+ */
349
+ export function 과제고르기(과제들, only) {
350
+ const 고를것 = String(only ?? '').split(',').map((s) => s.trim()).filter(Boolean);
351
+ return 과제들.filter((x) => 고를것.some((k) => (과제들.some((y) => y.이름 === k) ? x.이름 === k : x.이름.includes(k))));
352
+ }
353
+
354
+ /**
355
+ * 과제 한 판을 돌리되 **터져도 그 과제만** 실패로 센다.
356
+ *
357
+ * 한번돌리기 는 임시 폴더를 치우는 finally 만 있고 catch 가 없다. 정답 복사가 권한·자리 부족으로
358
+ * 터지면 그 예외가 runEval 까지 올라가 남은 과제를 다 버리고 결과 파일도 안 남겼다 (2.1.1 검수).
359
+ * `돌리개` 는 검사가 터지는 판을 만들려고 갈아 끼우는 자리다.
360
+ */
361
+ export async function 판돌리기(과제, 옵션 = {}, { 돌리개 = 한번돌리기 } = {}) {
362
+ try {
363
+ return await 돌리개(과제, 옵션);
364
+ } catch (err) {
365
+ const 까닭 = String(err?.message ?? err);
366
+ return {
367
+ 과제: 과제.이름, 판: 옵션.판 ?? 1, 통과: false, 초: 0,
368
+ deel: { code: null, reason: 'error', steps: 0, tools: 0, usage: { in: 0, out: 0 }, model: null },
369
+ 검사: { code: null, 꼬리: 글(`평가 도중 터졌습니다: ${까닭}`, `The run itself failed: ${까닭}`) },
370
+ };
371
+ }
372
+ }
373
+
283
374
  /**
284
375
  * 결과 파일 이름. **UTC** 로 적고 `Z` 를 붙인다 — 지역 시각이면 시차가 다른 PC 의 결과가 한
285
376
  * 폴더에 섞일 때(저장소에 올려 같이 쓰는 과제 모음) 이름 순서가 시각 순서와 어긋나고,
@@ -330,22 +421,26 @@ export async function runEval({ 폴더 = null, init = false, repeat = 1, keep =
330
421
  if (!Number.isInteger(되풀이) || 되풀이 < 1 || 되풀이 > 최대되풀이) {
331
422
  return 못돌림(64, 글(`--repeat 는 1 ~ ${최대되풀이} 사이의 정수여야 합니다: ${repeat}`, `--repeat must be a whole number from 1 to ${최대되풀이}: ${repeat}`));
332
423
  }
424
+ // 0 · 글 · 음수가 그대로 넘어가면 setTimeout(0) · NaN 이 되어 과제마다 deel 을 곧바로 죽인다 — 전부 「시간 초과」 실패.
425
+ if (시간초 !== null && !(Number.isFinite(Number(시간초)) && Number(시간초) > 0)) {
426
+ return 못돌림(64, 글(`--timeout 은 0 보다 큰 초여야 합니다: ${시간초}`, `--timeout must be a number of seconds above 0: ${시간초}`));
427
+ }
333
428
  if (!existsSync(고른폴더)) {
334
429
  return 못돌림(1, 글(`과제 모음이 없습니다 — ${보일}`, `No task folder — ${보일}`),
335
430
  `${c.gray(글('예제부터 만들려면:', 'To start from examples:'))} ${c.cyan('deel eval --init')}`);
336
431
  }
337
432
 
338
433
  let 과제들 = 과제읽기(고른폴더);
339
- if (only) {
340
- const 고를것 = String(only).split(',').map((s) => s.trim()).filter(Boolean);
341
- 과제들 = 과제들.filter((x) => 고를것.some((k) => x.이름.includes(k)));
342
- }
434
+ if (only) 과제들 = 과제고르기(과제들, only);
343
435
  if (!과제들.length) {
344
436
  return 못돌림(1, 글(`돌릴 과제가 없습니다 — ${보일} 밑에 task.json 이 든 폴더가 없습니다`, `Nothing to run — no folder with a task.json under ${보일}`));
345
437
  }
346
438
 
347
439
  말하기('');
348
440
  말하기(` ${c.bold(글('골든셋', 'Golden set'))} ${c.gray(`— ${보일} · ${글(`과제 ${과제들.length}개 · 과제마다 ${되풀이}번`, `${과제들.length} tasks · ${되풀이} run(s) each`)}`)}`);
441
+ // --timeout 을 줬는데 제 시간을 적은 과제가 있으면 그 과제는 적힌 값을 쓴다고 미리 말한다 (제한초고르기).
442
+ const 제시간 = 시간초 !== null ? 과제들.filter((x) => x.설정?.timeout !== undefined).length : 0;
443
+ if (제시간) 말하기(` ${c.gray(글(`task.json 에 timeout 을 적은 과제 ${제시간}개는 --timeout 대신 그 값을 씁니다`, `${제시간} task(s) set their own timeout in task.json and use it instead of --timeout`))}`);
349
444
  말하기('');
350
445
 
351
446
  /*
@@ -373,7 +468,7 @@ export async function runEval({ 폴더 = null, init = false, repeat = 1, keep =
373
468
  if (판 === 1) 말하기(` ${c.yellow('⊘')} ${과제.이름} ${c.gray(과제.탈)}`);
374
469
  continue;
375
470
  }
376
- const r = await 한번돌리기(과제, { 판, 깃발들, 남김: keep, 시간초 });
471
+ const r = await 판돌리기(과제, { 판, 깃발들, 남김: keep, 시간초 });
377
472
  결과들.push(r);
378
473
  const 옆 = [`${r.초}${글('초', 's')}`];
379
474
  if (r.deel.tools) 옆.push(글(`도구 ${r.deel.tools}회`, `${r.deel.tools} tool calls`));
@@ -391,8 +486,8 @@ export async function runEval({ 폴더 = null, init = false, repeat = 1, keep =
391
486
  const 판수 = 결과들.length;
392
487
  const 통과수 = 결과들.filter((r) => r.통과).length;
393
488
  const 모델 = 결과들.find((r) => r.deel?.model)?.deel.model ?? null;
394
- const 지난 = 지난결과(고른폴더);
395
- const 견줌 = 견주기(지난?.값?.과제별, 묶음);
489
+ const 지난 = 지난결과(고른폴더, { 전체만: !only, 이름들: only ? 과제들.map((x) => x.이름) : null });
490
+ const 견줌 = 견주기(지난?.값?.과제별, 묶음, { 일부만: !!only });
396
491
  const 초 = Math.round((Date.now() - t0) / 1000);
397
492
  const 토큰 = 결과들.reduce((a, r) => ({ in: a.in + (r.deel?.usage?.in ?? 0), out: a.out + (r.deel?.usage?.out ?? 0) }), { in: 0, out: 0 });
398
493
 
@@ -401,6 +496,8 @@ export async function runEval({ 폴더 = null, init = false, repeat = 1, keep =
401
496
  model: 모델,
402
497
  folder: 고른폴더,
403
498
  repeat: 되풀이,
499
+ // 일부만 돈 판이라는 표시 — 다음 전체 판이 이것을 「지난번」 으로 안 친다 (지난결과 의 전체만).
500
+ ...(only ? { only: String(only) } : {}),
404
501
  passed: 통과수,
405
502
  runs: 판수,
406
503
  rate: 판수 ? Math.round((통과수 / 판수) * 1000) / 10 : 0,
@@ -411,11 +508,16 @@ export async function runEval({ 폴더 = null, init = false, repeat = 1, keep =
411
508
  ...(지난 ? { 견줌: { 지난파일: 지난.파일, ...견줌 } } : {}),
412
509
  };
413
510
  let 남긴곳 = null;
511
+ let 못남긴까닭 = null;
414
512
  try {
415
513
  mkdirSync(join(고른폴더, 결과폴더), { recursive: true });
416
514
  남긴곳 = join(고른폴더, 결과폴더, `${시각이름()}.json`);
417
515
  writeFileSync(남긴곳, JSON.stringify(기록, null, 2) + '\n');
418
- } catch { 남긴곳 = null; }
516
+ } catch (err) {
517
+ // 조용히 삼키면 사람은 다음 판에 견줄 것이 있는 줄 안다. 채점은 그대로 끝낸다.
518
+ 남긴곳 = null;
519
+ 못남긴까닭 = String(err?.code ?? err?.message ?? err);
520
+ }
419
521
 
420
522
  if (json) process.stdout.write(JSON.stringify(기록) + '\n');
421
523
  말하기('');
@@ -424,9 +526,23 @@ export async function runEval({ 폴더 = null, init = false, repeat = 1, keep =
424
526
  if (지난) {
425
527
  if (견줌.나빠짐.length) 말하기(` ${c.red('▼')} ${글('지난번보다 나빠짐:', 'Worse than last time:')} ${견줌.나빠짐.join(' · ')}`);
426
528
  if (견줌.나아짐.length) 말하기(` ${c.green('▲')} ${글('지난번보다 나아짐:', 'Better than last time:')} ${견줌.나아짐.join(' · ')}`);
427
- if (!견줌.나빠짐.length && !견줌.나아짐.length) 말하기(` ${c.gray(글(`지난번(${지난.파일})과 같습니다`, `Same as last time (${지난.파일})`))}`);
529
+ if (견줌.사라짐.length) 말하기(` ${c.yellow('⚠')} ${글('지난번에 있던 과제가 이번엔 없습니다:', 'Tasks from last time are missing now:')} ${견줌.사라짐.join(' · ')}`);
530
+ if (견줌.새것.length) 말하기(` ${c.gray(글(`처음 재는 과제: ${견줌.새것.join(' · ')}`, `Measured for the first time: ${견줌.새것.join(' · ')}`))}`);
531
+ // 견주지 않은 과제가 있으면 「같다」 는 견준 과제끼리의 말이다 — 그렇게 적는다.
532
+ const 같음 = 같음범위(견줌, { 일부만: !!only });
533
+ if (같음) {
534
+ 말하기(` ${c.gray(같음 === '견준만큼'
535
+ ? 글(`견준 과제 ${견줌.견준}개는 지난번(${지난.파일})과 같습니다`, `The ${견줌.견준} task(s) compared are the same as last time (${지난.파일})`)
536
+ : 글(`지난번(${지난.파일})과 같습니다`, `Same as last time (${지난.파일})`))}`);
537
+ }
428
538
  }
429
539
  if (남긴곳) 말하기(` ${c.gray(글(`결과: ${relative(뿌리, 남긴곳)}`, `Results: ${relative(뿌리, 남긴곳)}`))}`);
540
+ else if (못남긴까닭) {
541
+ const 알림 = 글(`결과 파일을 못 남겼습니다 (${못남긴까닭}) — 다음 판은 이번 것과 못 견줍니다`,
542
+ `Could not save the results file (${못남긴까닭}) — the next run cannot compare with this one`);
543
+ if (json) process.stderr.write(`${알림}\n`);
544
+ else 말하기(` ${c.yellow('⚠')} ${알림}`);
545
+ }
430
546
  말하기('');
431
547
  return 통과수 === 판수 ? 0 : 1;
432
548
  }
@@ -58,7 +58,7 @@ export const 예제과제 = {
58
58
  'src/admin.js': "import { getUser } from './user.js';\n\nexport function isAdmin(id) {\n return getUser(id).id === 0;\n}\n",
59
59
  },
60
60
  golden: {
61
- 'golden-check.mjs': "import assert from 'node:assert/strict';\nimport { readdirSync, readFileSync } from 'node:fs';\nimport { fetchUser } from './src/user.js';\nimport { profileName } from './src/profile.js';\nimport { isAdmin } from './src/admin.js';\n\nassert.deepEqual(fetchUser(3), { id: 3, name: 'user3' });\nassert.equal(profileName(3), 'user3');\nassert.equal(isAdmin(0), true);\nassert.equal(isAdmin(1), false);\nconst 남은 = readdirSync('src').filter((f) => readFileSync('src/' + f, 'utf8').includes('getUser'));\nassert.deepEqual(남은, [], '옛 이름이 남은 파일: ' + 남은.join(', '));\nconsole.log('ok');\n",
61
+ 'golden-check.mjs': "import assert from 'node:assert/strict';\nimport { readdirSync, readFileSync } from 'node:fs';\nimport { fetchUser } from './src/user.js';\nimport { profileName } from './src/profile.js';\nimport { isAdmin } from './src/admin.js';\n\nassert.deepEqual(fetchUser(3), { id: 3, name: 'user3' });\nassert.equal(profileName(3), 'user3');\nassert.equal(isAdmin(0), true);\nassert.equal(isAdmin(1), false);\nconst 파일들 = (d) => readdirSync(d, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? 파일들(d + '/' + e.name) : e.isFile() ? [d + '/' + e.name] : []));\nconst 남은 = 파일들('src').filter((f) => readFileSync(f, 'utf8').includes('getUser'));\nassert.deepEqual(남은, [], '옛 이름이 남은 파일: ' + 남은.join(', '));\nconsole.log('ok');\n",
62
62
  },
63
63
  },
64
64
  '04-빨간검사고치기': {
package/src/exit.js ADDED
@@ -0,0 +1,116 @@
1
+ // 종료코드와 시작도 못 한 실패 한 덩이 — `deel run` 과 진입점(bin/deel.js)이 같이 쓴다.
2
+ //
3
+ // oneshot.js 에서 떼어 냈다 (2.1.2). 진입점이 이 표 하나 때문에 oneshot.js 를 불러오면 대화 화면·도구·
4
+ // 게이트웨이까지 딸려 와서, `deel --version` 한 줄이 파일 164개를 읽었다. oneshot.js 는 그대로 내보낸다.
5
+
6
+ /**
7
+ * 종료코드.
8
+ *
9
+ * 스크립트는 화면 글이 아니라 이 숫자만 본다. 그래서 '끝났다' 와 '끝난 척했다'
10
+ * 를 반드시 갈라 놔야 한다 — 걸음 수 상한에 걸려 멈춘 것을 0 으로 돌려주면
11
+ * 야간 배치가 아무 일도 안 하고 초록불을 켠다. 그게 제일 나쁜 결말이다.
12
+ */
13
+ export const EXIT = {
14
+ done: 0, // 끝까지 답했다
15
+ error: 1, // 오류로 끝났다 (연결 없음 · 시킬 말 없음 · 모델 오류)
16
+ limit: 2, // 도구 호출 걸음 수 상한에 닿았다
17
+ stuck: 3, // 같은 자리에서 헛돌아 스스로 멈췄다
18
+ aborted: 4, // 도중에 끊겼다 (Ctrl+C)
19
+ /*
20
+ * 서버가 끝났다는 말을 한 번도 안 주고 답을 멈췄다.
21
+ *
22
+ * 여기에 자기 코드가 있어야 한다. 전에는 이걸 아예 안 보고 `done`(0) 으로
23
+ * 끝냈다. `deel -p` 는 잡·CI 에서 돌고 그 뒤에 스크립트가 붙는데, 반쪽짜리
24
+ * 답이 0 으로 넘어가면 그 스크립트는 온전한 답으로 알고 그대로 쓴다.
25
+ * 사람이 나중에 결과를 보고 왜 반쪽인지 되짚을 방법이 없다.
26
+ */
27
+ cutoff: 5,
28
+ /*
29
+ * 모델이 안 하겠다고 했다 (안전 판정).
30
+ *
31
+ * 이것도 자기 코드가 있어야 한다. 거절한 답은 **아무 일도 안 한 답**인데,
32
+ * 도구 호출이 없다는 것 말고는 짧은 답과 겉모습이 같다. 0 으로 끝내면
33
+ * 뒤에 붙은 스크립트는 일이 된 줄 알고 그 다음 단계로 넘어간다.
34
+ *
35
+ * 오류(1)와도 갈라 둔다. 고칠 자리가 다르기 때문이다 — 오류는 연결이나
36
+ * 열쇠를 보는 일이고, 거절은 **시킨 말을 바꾸는 일**이다.
37
+ */
38
+ refusal: 6,
39
+ /*
40
+ * 답이 --output-schema 에 안 맞았다.
41
+ *
42
+ * 여기에도 자기 코드가 있어야 한다. 「모양을 못 박아 달라」 고 한 사람에게
43
+ * 모양이 안 맞는 것을 0 으로 넘기면, 파이프 뒤 스크립트가 그걸 온전한
44
+ * 것으로 받아 쓴다 — 그게 이 기능이 없애려던 바로 그 상황이다.
45
+ *
46
+ * 오류(1)와도 가른다. 고칠 자리가 다르다 — 오류는 연결을 보는 일이고,
47
+ * 이건 스키마나 시킨 말을 바꾸는 일이다.
48
+ */
49
+ schema: 7,
50
+ /*
51
+ * 완료 검사가 통과하지 않은 채 끝났다 (agent/donecheck.js · 2.1.0).
52
+ *
53
+ * 모델은 「다 됐습니다」 라고 했는데 사람이 정해 둔 검사(`--check` · 설정의 `check`)가
54
+ * 정해 둔 판수를 다 돌고도 빨갛거나, 관문에 막혀 아예 못 돌았다. 0 으로 끝내면 야간
55
+ * 배치가 빨간 검사 위에 초록불을 켠다 — 이 기능이 없애려던 바로 그 결말이다.
56
+ *
57
+ * 오류(1)와 가른다. 고칠 자리가 다르다 — 연결이 아니라 코드나 검사 쪽이다.
58
+ */
59
+ check: 8,
60
+ /*
61
+ * 인자를 잘못 줬다 — 모르는 깃발 · 값 없는 깃발 · 모르는 모드 이름 · 못 읽는 --ctx.
62
+ *
63
+ * 여기에 **따로** 둔다. 처음에는 `--work` 오타를 2 로 끝냈는데, 2 는 이 표에서 이미
64
+ * 「걸음 수 상한」 이다. `deel run --jsn …` 을 CI 에 건 사람은 오타 하나로 「일이 커서
65
+ * 멈췄다」 를 받고 작업을 쪼개러 간다. 고칠 자리가 스크립트 한 줄인데.
66
+ *
67
+ * 64 는 sysexits.h 의 EX_USAGE 다. 1~8 과 안 겹치고, 셸·CI 도구들이 「부른 모양이
68
+ * 틀렸다」 로 이미 알아듣는 수라 새로 지어내지 않았다. 이 경우는 모델을 한 번도 안 부른다.
69
+ */
70
+ usage: 64,
71
+ };
72
+
73
+ /**
74
+ * 모델을 부르기 전에 선 실패 한 덩이 (사냥5 B5-10).
75
+ *
76
+ * 성공한 `--json` 에는 model 과 usage.prompt·cacheRead·cacheWrite·못잰것 이 있는데, 모델을
77
+ * 부르기 전에 선 실패(64 · 7 · 시킬 말 없음 · 없는 뿌리 · 연결 없음 · 대화 화면 전용)에는
78
+ * 그 칸들이 없었다. `jq .usage.prompt` 는 null 을, 모양을 못 박은 파서는 오류를 낸다 —
79
+ * 실패를 제일 잘 다뤄야 할 자리에서 파서가 먼저 죽는다. 칸은 늘 같게 두고 값만 비운다.
80
+ *
81
+ * bin/deel.js 의 인자탈·마지막 catch 도 이걸로 짓는다. 세 벌이면 언젠가 한 벌만 고친다.
82
+ */
83
+ export function 실패덩이({ reason, code, why = '', model = null }) {
84
+ return {
85
+ ok: false, reason, code, text: '',
86
+ tools: 0, steps: 0,
87
+ usage: { in: 0, out: 0, prompt: 0, cacheRead: 0, cacheWrite: 0, calls: 0, ms: 0, 못잰것: 0, retries: 0 },
88
+ model,
89
+ ms: 0,
90
+ ...(why ? { why } : {}),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * 열어 둔 연결을 닫는다. 프로그램을 끝내기 직전에 부른다.
96
+ *
97
+ * fetch 는 연결을 재사용하려고 소켓을 살려 둔다(keep-alive). 그래서 할 일이
98
+ * 끝나도 프로세스가 저절로 안 끝난다. 예전에는 그걸 process.exit() 으로
99
+ * 잘라 냈는데, 윈도우에서 닫는 중인 핸들을 두고 끊으면 libuv 가 abort 한다.
100
+ *
101
+ * Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c
102
+ *
103
+ * 실제로 `deel scan` 이 결과를 다 찍고 나서 이렇게 죽었다. 화면에는 정상으로
104
+ * 보이는데 종료코드는 3221226505(0xC0000409) 였다.
105
+ *
106
+ * 그래서 잘라 내는 대신 닫는다. 이 자리는 Node 내부 이름이라 없을 수도 있으므로,
107
+ * 없으면 조용히 넘어간다 — 그때는 부르는 쪽의 시간제한이 받아 준다.
108
+ * 프록시 길은 요청마다 소켓을 새로 열고 끝나면 닫으므로(Connection: close) 여기 안 온다.
109
+ */
110
+ export function closeConnections() {
111
+ try {
112
+ const d = globalThis[Symbol.for('undici.globalDispatcher.1')];
113
+ if (d && typeof d.close === 'function') return d.close().catch(() => {});
114
+ } catch { /* 없으면 그만 */ }
115
+ return Promise.resolve();
116
+ }
package/src/i18n/en.js CHANGED
@@ -319,11 +319,12 @@ export const en = {
319
319
  'approve.auto.line': 'edits without asking. /undo is the safety net',
320
320
  'approve.confirm.name': 'risky only',
321
321
  'approve.confirm.short': 'risky',
322
- 'approve.confirm.line': 'asks before commands it cannot take back. Files are edited without asking',
322
+ 'approve.confirm.line': 'asks before commands it cannot take back and MCP tools. Files are edited without asking',
323
323
  'approve.strict.name': 'ask always',
324
324
  'approve.strict.short': 'always',
325
325
  'approve.strict.line': 'asks before every file edit and every command',
326
326
  'approve.newFile': 'new file',
327
+ 'approve.noChange': 'no change — same content as now',
327
328
  'approve.locked': 'A managed policy keeps approval at {바닥} or stricter — {곳}',
328
329
  'approve.lockedUnknown': 'The managed policy\'s approval value "{값}" is unknown, so it counts as strict, the narrowest — {곳}',
329
330
  'approve.noAlways': 'A managed policy sets approval, so "don\'t ask again" is not offered',
@@ -518,12 +519,15 @@ export const en = {
518
519
  'ev.hookBlock': 'A hook blocked this',
519
520
  'ev.nudgeRead': 'It was about to finish having only read things — pushed back once',
520
521
  'check.start': 'Completion check ({판}/{최대}) — {명령}',
522
+ 'check.clamped': '{칸} {준값} is over the limit — using {쓴값}',
523
+ 'check.invalid': '{칸} {준값} is not a usable value — using the default {쓴값}',
521
524
  'check.pass': 'Completion check passed — {명령}',
522
525
  'check.fail': 'Completion check failed ({판}/{최대}) — {요약}',
523
526
  'check.retry': 'Sending the failure back to fix',
524
527
  'check.gaveUp': 'Still failing after {판} runs — ending as failed',
525
528
  'check.skipped': 'Could not run the completion check — {까닭}',
526
529
  'cli.checkFlag': 'run this check before finishing; failures go back to be fixed, and still failing ends with 8',
530
+ 'cli.checkRoundsFlag': 'for this run only: how many times the check runs (1-10; fixes = runs - 1) and the time limit per run (s)',
527
531
  'cli.evalTitle': 'Golden set',
528
532
  'cli.evalWhy': '— run a set of tasks on the real model and measure how many it gets done',
529
533
  'cli.evalInit': 'create five example tasks in golden/',