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.
@@ -0,0 +1,489 @@
1
+ // `deel eval` — 과제 모음(골든셋)을 실제 모델로 돌려 **얼마나 해내나**를 잰다 (2.1.0).
2
+ //
3
+ // ── 왜 만드나 ───────────────────────────────────────────────────────────
4
+ //
5
+ // 사내 검토에서 들은 말이다 — 「검증 조건에서 작업마다 골든셋이 필요하다. 지금처럼
6
+ // 정적 검사에 기대면 한계가 있다.」 맞는 말이었다. 검사 1만 4천여 개는 가짜 게이트웨이로
7
+ // 「도구가 약속대로 도나」 를 잰다. 「이 회사의 모델과 deel 이 실제 일을 몇 개 해내나」 는
8
+ // 한 번도 안 쟀다. 모델을 바꾸거나 deel 을 올릴 때 나아졌는지 나빠졌는지 말할 숫자가 없었다.
9
+ //
10
+ // ── 어떻게 재나 ─────────────────────────────────────────────────────────
11
+ //
12
+ // 과제 하나는 폴더 하나다.
13
+ //
14
+ // 과제이름/
15
+ // task.json { "prompt": 시킬 말, "check": 정답 검사 명령, … }
16
+ // start/ 시작 파일 — 임시 작업 폴더로 복사된다
17
+ // golden/ 정답 검사 파일 — deel 이 **다 끝낸 뒤에** 작업 폴더에 들어간다
18
+ //
19
+ // 1. 임시 폴더에 start/ 를 깐다
20
+ // 2. 그 폴더에서 `deel run --json --yes "<prompt>"` 를 돌린다 — 사람이 쓰는 그 길 그대로
21
+ // 3. golden/ 을 덮어 넣고 check 를 돌린다. 종료코드 0 이면 통과
22
+ //
23
+ // 정답은 **모델이 못 본다.** 일하는 동안 폴더에 없다가 끝난 뒤에 들어온다. 그래서 「검사를
24
+ // 통과하게 검사를 고치는」 길이 없다. 모델이 같은 이름의 파일을 만들어 둬도 덮인다.
25
+ //
26
+ // 결과는 `<과제 모음>/.results/<시각>.json` 에 남고, 지난 결과와 견줘 **나빠진 과제**를
27
+ // 짚는다. 판을 올리거나 모델을 바꾼 뒤 한 번 돌려 보는 것이 쓰임새다.
28
+ //
29
+ // ── 안 하는 것 ──────────────────────────────────────────────────────────
30
+ //
31
+ // 동시에 안 돌린다. 과제마다 모델을 오래 부르고 검사가 CPU 를 먹는다 — 사내 게이트웨이의
32
+ // 분당 한도와 이 PC 의 메모리를 같이 지킨다. 느려도 숫자가 맞는 쪽을 고른다.
33
+ //
34
+ // check 는 **사람이 적은 명령**이라 관문 없이 돈다(npm test 를 손으로 치는 것과 같다).
35
+ // 다만 그 명령이 모델이 쓴 코드를 돌리므로 열쇠처럼 생긴 환경변수는 빼고 넘긴다
36
+ // (safety/shellenv.js). 남이 준 과제 모음은 task.json 을 먼저 읽어 보고 돌리라.
37
+ import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, copyFileSync, mkdtempSync, rmSync } from 'node:fs';
38
+ import { join, dirname, resolve, relative } from 'node:path';
39
+ import { tmpdir } from 'node:os';
40
+ import { fileURLToPath } from 'node:url';
41
+ import { 돌려보기 } from '../tools/spawn.js';
42
+ import { 셸명령 } from '../tools/shell.js';
43
+ import { 셸환경 } from '../safety/shellenv.js';
44
+ import { 승인바닥 } from '../safety/policy.js';
45
+ import { c, say, mark } from '../ui/ansi.js';
46
+ import { 언어 } from '../i18n/index.js';
47
+ import { 예제과제 } from './starter.js';
48
+
49
+ const 진입점 = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'bin', 'deel.js');
50
+
51
+ /** 과제 하나를 deel 이 끝낼 때까지 기다리는 시간(초)과, 정답 검사 한 번의 시간(초). */
52
+ export const 기본시간초 = 600;
53
+ export const 검사시간초 = 120;
54
+ /** 한 과제를 몇 번까지 되풀이해 재나. 모델은 같은 말에도 다르게 답한다. */
55
+ export const 최대되풀이 = 20;
56
+ export const 결과폴더 = '.results';
57
+
58
+ const 글 = (ko, en) => (언어() === 'ko' ? ko : en);
59
+
60
+ /**
61
+ * 과제 모음 폴더를 읽는다. task.json 이 있는 하위 폴더 하나가 과제 하나다.
62
+ * 모양이 틀린 과제는 **빼지 않고** 탈을 달아 돌려준다 — 조용히 빼면 스무 개 중 열아홉만
63
+ * 돌고 「다 통과」 가 된다.
64
+ *
65
+ * @returns {Array<{이름:string, 자리:string, 설정:object|null, 탈:string|null}>}
66
+ */
67
+ export function 과제읽기(폴더) {
68
+ const 과제들 = [];
69
+ for (const 이름 of readdirSync(폴더).sort()) {
70
+ if (이름.startsWith('.')) continue;
71
+ const 자리 = join(폴더, 이름);
72
+ let 폴더인가 = false;
73
+ try { 폴더인가 = statSync(자리).isDirectory(); } catch { /* 사이에 지워졌다 */ }
74
+ if (!폴더인가) continue;
75
+ /*
76
+ * task.json 이 없어도 start/·golden/ 이 있으면 과제로 만든 폴더다 — 이름을 틀렸거나
77
+ * (`Task.json` · `task.jsn`) 빠뜨린 것이다. 이것도 탈로 센다. 둘 다 없는 폴더(과제들이
78
+ * 같이 쓰는 도우미 따위)만 과제가 아니다 (2차 눈 판정).
79
+ */
80
+ if (!existsSync(join(자리, 'task.json'))) {
81
+ if (existsSync(join(자리, 'start')) || existsSync(join(자리, 'golden'))) {
82
+ 과제들.push({ 이름, 자리, 설정: null, 탈: 'task.json 이 없습니다 — start/·golden/ 은 있는데 시킬 말과 정답 검사가 없습니다' });
83
+ }
84
+ continue;
85
+ }
86
+ let 설정 = null;
87
+ let 탈 = null;
88
+ try {
89
+ const v = JSON.parse(readFileSync(join(자리, 'task.json'), 'utf8').replace(/^\uFEFF/, ''));
90
+ 탈 = 설정탈(v);
91
+ if (!탈) 설정 = v;
92
+ } catch (e) {
93
+ 탈 = `task.json 을 못 읽었습니다 — ${String(e?.message ?? e).split('\n')[0]}`;
94
+ }
95
+ 과제들.push({ 이름, 자리, 설정, 탈 });
96
+ }
97
+ return 과제들;
98
+ }
99
+
100
+ /** task.json 이 쓸 수 있는 모양인가. 쓸 수 있으면 null, 아니면 까닭. */
101
+ export function 설정탈(v) {
102
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return 'task.json 이 객체가 아닙니다';
103
+ if (typeof v.prompt !== 'string' || !v.prompt.trim()) return 'prompt(시킬 말)가 없습니다';
104
+ if (typeof v.check !== 'string' || !v.check.trim()) return 'check(정답 검사 명령)가 없습니다';
105
+ for (const 칸 of ['timeout', 'checkTimeout']) {
106
+ if (v[칸] !== undefined && !(Number.isFinite(Number(v[칸])) && Number(v[칸]) > 0)) return `${칸} 은 0 보다 큰 초여야 합니다`;
107
+ }
108
+ for (const 칸 of ['doneCheck', 'work']) {
109
+ if (v[칸] !== undefined && typeof v[칸] !== 'string') return `${칸} 은 글이어야 합니다`;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * 폴더를 통째로 복사한다. 원본이 없으면 아무것도 안 한다(start/ 가 없는 과제는 빈 폴더에서 시작).
116
+ *
117
+ * `덮기` 는 정답을 넣을 때 쓴다. 모델이 같은 이름을 **다른 꼴**로 만들어 두었으면(정답은
118
+ * `sub/` 폴더인데 모델은 `sub` 파일) 그것을 걷어내고 정답을 넣는다. 안 걷으면 복사가 터져
119
+ * 평가 전체가 죽는다 — 과제 하나의 일로 스무 개가 다 멈춘다 (2차 눈 판정).
120
+ */
121
+ export function 폴더복사(원본, 대상, { 덮기 = false } = {}) {
122
+ if (!existsSync(원본)) return 0;
123
+ let n = 0;
124
+ for (const 이름 of readdirSync(원본)) {
125
+ const 가 = join(원본, 이름);
126
+ const 나 = join(대상, 이름);
127
+ const 폴더다 = statSync(가).isDirectory();
128
+ if (덮기) {
129
+ let 있는것 = null;
130
+ try { 있는것 = statSync(나); } catch { /* 없다 */ }
131
+ if (있는것 && 있는것.isDirectory() !== 폴더다) rmSync(나, { recursive: true, force: true, maxRetries: 3 });
132
+ }
133
+ if (폴더다) { mkdirSync(나, { recursive: true }); n += 폴더복사(가, 나, { 덮기 }); }
134
+ else { mkdirSync(dirname(나), { recursive: true }); copyFileSync(가, 나); n++; }
135
+ }
136
+ return n;
137
+ }
138
+
139
+ /**
140
+ * 예제 과제를 만든다. 이미 있는 과제 폴더는 **안 덮는다** — 사람이 고쳐 둔 과제를 지우면 안 된다.
141
+ * @returns {{만든:string[], 건너뜀:string[]}}
142
+ */
143
+ export function 예제만들기(폴더) {
144
+ const 만든 = [];
145
+ const 건너뜀 = [];
146
+ for (const [이름, 과제] of Object.entries(예제과제)) {
147
+ const 자리 = join(폴더, 이름);
148
+ if (existsSync(자리)) { 건너뜀.push(이름); continue; }
149
+ mkdirSync(자리, { recursive: true });
150
+ writeFileSync(join(자리, 'task.json'), JSON.stringify(과제.task, null, 2) + '\n');
151
+ for (const [갈래, 파일들] of [['start', 과제.start], ['golden', 과제.golden]]) {
152
+ for (const [길, 내용] of Object.entries(파일들 ?? {})) {
153
+ const 어디 = join(자리, 갈래, 길);
154
+ mkdirSync(dirname(어디), { recursive: true });
155
+ writeFileSync(어디, 내용);
156
+ }
157
+ }
158
+ 만든.push(이름);
159
+ }
160
+ return { 만든, 건너뜀 };
161
+ }
162
+
163
+ /** `deel run --json` 의 표준출력에서 마지막 JSON 한 덩이를 읽는다. 못 읽으면 null. */
164
+ export function 결과덩이(stdout) {
165
+ const 줄들 = String(stdout ?? '').trim().split('\n').reverse();
166
+ for (const 줄 of 줄들) {
167
+ const s = 줄.trim();
168
+ if (!s.startsWith('{')) continue;
169
+ try { return JSON.parse(s); } catch { /* 다음 줄 */ }
170
+ }
171
+ return null;
172
+ }
173
+
174
+ const 꼬리 = (s, n = 800) => {
175
+ const t = String(s ?? '').trim();
176
+ return t.length > n ? `…${t.slice(t.length - n)}` : t;
177
+ };
178
+
179
+ /**
180
+ * 과제 하나를 한 번 돌린다.
181
+ *
182
+ * 통과·실패는 **정답 검사 하나로만** 가른다. deel 이 걸음 상한이나 오류로 끝났어도 폴더가
183
+ * 맞게 고쳐져 있으면 통과다 — 재는 것은 「해냈나」 지 「깔끔하게 끝냈나」 가 아니다.
184
+ * deel 이 어떻게 끝났는지는 옆에 적는다.
185
+ */
186
+ export async function 한번돌리기(과제, { 판 = 1, 깃발들 = [], 남김 = false, 시간초 = null } = {}) {
187
+ const 설정 = 과제.설정;
188
+ const t0 = Date.now();
189
+ const 작업 = mkdtempSync(join(tmpdir(), 'deel-eval-'));
190
+ try {
191
+ 폴더복사(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
+ ];
201
+ const 돌림 = await 돌려보기(process.execPath, 인자, {
202
+ timeout: 제한초 * 1000, maxBuffer: 64 * 1024 * 1024,
203
+ 덤: { cwd: 작업, env: { ...process.env, NO_COLOR: '1' } },
204
+ });
205
+ const 덩이 = 결과덩이(돌림.stdout);
206
+ const deel = {
207
+ code: 돌림.status,
208
+ reason: 덩이?.reason ?? (돌림.error ? (/안 끝났습니다/.test(돌림.error.message) ? 'timeout' : 'spawn') : 'no-json'),
209
+ steps: 덩이?.steps ?? 0,
210
+ tools: 덩이?.tools ?? 0,
211
+ usage: { in: 덩이?.usage?.prompt ?? 덩이?.usage?.in ?? 0, out: 덩이?.usage?.out ?? 0 },
212
+ model: 덩이?.model ?? null,
213
+ ...(덩이?.check ? { check: 덩이.check } : {}),
214
+ ...(덩이 ? {} : { stderr: 꼬리(돌림.stderr) }),
215
+ };
216
+
217
+ // 정답을 넣는다 — 일이 다 끝난 **뒤에**. 모델이 같은 이름으로 만든 것은 꼴이 달라도 덮인다.
218
+ 폴더복사(join(과제.자리, 'golden'), 작업, { 덮기: true });
219
+ const 셸 = 셸명령(설정.check);
220
+ const 검사 = await 돌려보기(셸.file, 셸.args, {
221
+ timeout: Number(설정.checkTimeout ?? 검사시간초) * 1000,
222
+ 덤: { cwd: 작업, env: 셸환경(process.env).env, windowsVerbatimArguments: 셸.verbatim },
223
+ });
224
+ const 통과 = !검사.error && 검사.status === 0;
225
+ return {
226
+ 과제: 과제.이름, 판, 통과,
227
+ 초: Math.round((Date.now() - t0) / 100) / 10,
228
+ deel,
229
+ 검사: { code: 검사.status, ...(통과 ? {} : { 꼬리: 꼬리(`${검사.stdout ?? ''}\n${검사.stderr ?? ''}\n${검사.error?.message ?? ''}`) }) },
230
+ ...(남김 ? { 작업폴더: 작업 } : {}),
231
+ };
232
+ } finally {
233
+ if (!남김) {
234
+ try { rmSync(작업, { recursive: true, force: true, maxRetries: 3 }); } catch { /* 임시 폴더다 — 못 지워도 OS 가 거둔다 */ }
235
+ }
236
+ }
237
+ }
238
+
239
+ /** 과제별로 묶는다 — 몇 판 중 몇 판 통과했나. */
240
+ export function 과제별(결과들) {
241
+ const 표 = new Map();
242
+ for (const r of 결과들) {
243
+ const 것 = 표.get(r.과제) ?? { 과제: r.과제, 판: 0, 통과: 0 };
244
+ 것.판++;
245
+ if (r.통과) 것.통과++;
246
+ 표.set(r.과제, 것);
247
+ }
248
+ return [...표.values()];
249
+ }
250
+
251
+ /**
252
+ * 지난 결과와 견준다. 다 통과하던 과제가 하나라도 떨어졌으면 나빠짐, 못 하던 과제를 다 해내면 나아짐.
253
+ * 되풀이 수가 다르면 비율로 본다.
254
+ */
255
+ export function 견주기(지난것, 지금것) {
256
+ const 비율 = (x) => (x.판 ? x.통과 / x.판 : 0);
257
+ const 지난표 = new Map((지난것 ?? []).map((x) => [x.과제, x]));
258
+ const 나빠짐 = [];
259
+ const 나아짐 = [];
260
+ // 몇 과제를 견줬나 — 0 이면 「지난번과 같습니다」 라 말할 근거가 없다.
261
+ let 견준 = 0;
262
+ for (const x of 지금것) {
263
+ const 전 = 지난표.get(x.과제);
264
+ if (!전) continue;
265
+ 견준 += 1;
266
+ if (비율(x) < 비율(전)) 나빠짐.push(x.과제);
267
+ else if (비율(x) > 비율(전)) 나아짐.push(x.과제);
268
+ }
269
+ return { 나빠짐, 나아짐, 견준 };
270
+ }
271
+
272
+ /**
273
+ * 제일 새 결과 파일. 깨졌거나 과제별이 없는 것(손으로 적은 `{}` 따위)은 건너뛴다 — 견줄 것이 없다.
274
+ *
275
+ * `전체만` 이면 `--only` 로 일부만 돈 결과(`only` 칸이 있는 것)도 건너뛴다. 안 그러면 `--only a`
276
+ * 한 번 뒤의 전체 판이 과제 a 하나만 견주고, 나머지가 떨어져도 「지난번과 같습니다」 가 나온다
277
+ * (2.1.1 검수). 일부만 도는 판은 아무것과 견줘도 된다 — 견주기 가 지금 돈 과제만 본다.
278
+ */
279
+ export function 지난결과(폴더, { 전체만 = false, 이름들 = null } = {}) {
280
+ const 자리 = join(폴더, 결과폴더);
281
+ let 파일들;
282
+ // 손으로 만든 `.results` **파일**이면 readdirSync 가 던진다 — 견줄 것이 없을 뿐이다.
283
+ try { 파일들 = readdirSync(자리).filter((f) => /^\d{8}-\d{6}.*\.json$/.test(f)).sort(); } catch { return null; }
284
+ for (const f of 파일들.reverse()) {
285
+ try {
286
+ const 값 = JSON.parse(readFileSync(join(자리, f), 'utf8'));
287
+ if (!Array.isArray(값?.과제별)) continue;
288
+ if (전체만 && 값.only) continue;
289
+ // 일부만 도는 판은 **지금 과제가 든** 결과까지 거슬러 찾는다 — `--only a` 뒤의 `--only b` 가 a 만 든 판과
290
+ // 견주면 겹치는 과제가 없는데도 「지난번과 같습니다」 가 나온다 (2.1.1 2차 눈).
291
+ if (이름들 && !값.과제별.some((x) => 이름들.includes(x?.과제))) continue;
292
+ return { 파일: f, 값 };
293
+ } catch { /* 깨진 것은 건너뛴다 */ }
294
+ }
295
+ return null;
296
+ }
297
+
298
+ /**
299
+ * `--only` 로 과제를 고른다. 쉼표로 여럿.
300
+ *
301
+ * 이름이 **딱 맞는** 것이 있으면 그것만 고른다 — 이름 일부로만 고르면 `task-1` 이 `task-10` ·
302
+ * `task-11` 까지 불러 엉뚱한 과제에 시간을 쓴다 (2.1.1 검수). 딱 맞는 것이 없으면 예전처럼
303
+ * 이름 일부로 고른다(`--only 03` · `--only a-`).
304
+ */
305
+ export function 과제고르기(과제들, only) {
306
+ const 고를것 = String(only ?? '').split(',').map((s) => s.trim()).filter(Boolean);
307
+ return 과제들.filter((x) => 고를것.some((k) => (과제들.some((y) => y.이름 === k) ? x.이름 === k : x.이름.includes(k))));
308
+ }
309
+
310
+ /**
311
+ * 과제 한 판을 돌리되 **터져도 그 과제만** 실패로 센다.
312
+ *
313
+ * 한번돌리기 는 임시 폴더를 치우는 finally 만 있고 catch 가 없다. 정답 복사가 권한·자리 부족으로
314
+ * 터지면 그 예외가 runEval 까지 올라가 남은 과제를 다 버리고 결과 파일도 안 남겼다 (2.1.1 검수).
315
+ * `돌리개` 는 검사가 터지는 판을 만들려고 갈아 끼우는 자리다.
316
+ */
317
+ export async function 판돌리기(과제, 옵션 = {}, { 돌리개 = 한번돌리기 } = {}) {
318
+ try {
319
+ return await 돌리개(과제, 옵션);
320
+ } catch (err) {
321
+ const 까닭 = String(err?.message ?? err);
322
+ return {
323
+ 과제: 과제.이름, 판: 옵션.판 ?? 1, 통과: false, 초: 0,
324
+ deel: { code: null, reason: 'error', steps: 0, tools: 0, usage: { in: 0, out: 0 }, model: null },
325
+ 검사: { code: null, 꼬리: 글(`평가 도중 터졌습니다: ${까닭}`, `The run itself failed: ${까닭}`) },
326
+ };
327
+ }
328
+ }
329
+
330
+ /**
331
+ * 결과 파일 이름. **UTC** 로 적고 `Z` 를 붙인다 — 지역 시각이면 시차가 다른 PC 의 결과가 한
332
+ * 폴더에 섞일 때(저장소에 올려 같이 쓰는 과제 모음) 이름 순서가 시각 순서와 어긋나고,
333
+ * 「지난번」 을 엉뚱한 파일로 고른다 (2차 눈 판정).
334
+ */
335
+ export const 시각이름 = (d = new Date()) => {
336
+ const p = (n) => String(n).padStart(2, '0');
337
+ return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}-${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`;
338
+ };
339
+
340
+ /**
341
+ * `deel eval [폴더]` 의 몸통. 종료코드를 돌려준다 — 0 다 통과 · 1 하나라도 실패 · 64 부른 모양이 틀림.
342
+ */
343
+ export async function runEval({ 폴더 = null, init = false, repeat = 1, keep = false, json = false, only = null, 깃발들 = [], 시간초 = null } = {}) {
344
+ const 뿌리 = process.cwd();
345
+ const 고른폴더 = resolve(뿌리, 폴더 ?? (existsSync(join(뿌리, 'golden')) || init ? 'golden' : join('.deel', 'golden')));
346
+ const 보일 = relative(뿌리, 고른폴더) || '.';
347
+ const 말하기 = (s = '') => { if (!json) say(s); };
348
+ /*
349
+ * 부른 모양이 틀렸거나 돌릴 것이 없을 때. `--json` 이면 받는 쪽은 표준출력을 통째로
350
+ * JSON.parse 한다 — 색 입힌 글을 내면 그 자리에서 깨진다. 그래서 JSON 한 줄로 낸다.
351
+ */
352
+ const 못돌림 = (코드, 오류, 덧 = null) => {
353
+ if (json) process.stdout.write(JSON.stringify({ ok: false, code: 코드, error: 오류 }) + '\n');
354
+ else {
355
+ say(` ${c.red('✗')} ${오류}`);
356
+ if (덧) say(` ${덧}`);
357
+ }
358
+ return 코드;
359
+ };
360
+
361
+ if (init) {
362
+ const { 만든, 건너뜀 } = 예제만들기(고른폴더);
363
+ 말하기('');
364
+ 말하기(` ${mark.ok} ${글(`예제 과제 ${만든.length}개를 만들었습니다 — ${보일}`, `Created ${만든.length} example tasks — ${보일}`)}`);
365
+ for (const 이름 of 만든) 말하기(` ${c.gray(이름)}`);
366
+ if (건너뜀.length) 말하기(` ${c.gray(글(`이미 있어 안 덮은 것: ${건너뜀.join(' · ')}`, `Already there, left alone: ${건너뜀.join(' · ')}`))}`);
367
+ 말하기('');
368
+ 말하기(` ${c.gray(글('돌리기:', 'Run them:'))} ${c.cyan(`deel eval ${보일}`)}`);
369
+ 말하기(` ${c.gray(글('과제 하나 = task.json(시킬 말·정답 검사) + start/(시작 파일) + golden/(끝난 뒤 넣는 정답 검사)',
370
+ 'One task = task.json (prompt, check) + start/ (starting files) + golden/ (checks added after the run)'))}`);
371
+ 말하기('');
372
+ if (json) process.stdout.write(JSON.stringify({ ok: true, folder: 고른폴더, created: 만든, skipped: 건너뜀 }) + '\n');
373
+ return 0;
374
+ }
375
+
376
+ const 되풀이 = Number(repeat);
377
+ if (!Number.isInteger(되풀이) || 되풀이 < 1 || 되풀이 > 최대되풀이) {
378
+ return 못돌림(64, 글(`--repeat 는 1 ~ ${최대되풀이} 사이의 정수여야 합니다: ${repeat}`, `--repeat must be a whole number from 1 to ${최대되풀이}: ${repeat}`));
379
+ }
380
+ if (!existsSync(고른폴더)) {
381
+ return 못돌림(1, 글(`과제 모음이 없습니다 — ${보일}`, `No task folder — ${보일}`),
382
+ `${c.gray(글('예제부터 만들려면:', 'To start from examples:'))} ${c.cyan('deel eval --init')}`);
383
+ }
384
+
385
+ let 과제들 = 과제읽기(고른폴더);
386
+ if (only) 과제들 = 과제고르기(과제들, only);
387
+ if (!과제들.length) {
388
+ return 못돌림(1, 글(`돌릴 과제가 없습니다 — ${보일} 밑에 task.json 이 든 폴더가 없습니다`, `Nothing to run — no folder with a task.json under ${보일}`));
389
+ }
390
+
391
+ 말하기('');
392
+ 말하기(` ${c.bold(글('골든셋', 'Golden set'))} ${c.gray(`— ${보일} · ${글(`과제 ${과제들.length}개 · 과제마다 ${되풀이}번`, `${과제들.length} tasks · ${되풀이} run(s) each`)}`)}`);
393
+ 말하기('');
394
+
395
+ /*
396
+ * 관리 정책이 승인 바닥을 걸었으면 과제마다 띄우는 `deel run --yes` 의 --yes 가 안 먹는다(oneshot.js).
397
+ * 그러면 파일을 바꾸는 과제는 전부 거절되어 실패로 세어진다 — 모델 탓처럼 보이는 0% 가 나온다. 먼저 말한다.
398
+ */
399
+ const 바닥 = 승인바닥();
400
+ if (바닥.바닥 !== 'auto') {
401
+ 말하기(` ${c.yellow('⚠')} ${글(`관리 정책이 승인을 ${바닥.바닥} 로 걸어 두어 과제 안에서 파일을 못 바꿉니다 — 바꾸는 과제는 실패로 세어집니다 (${바닥.곳})`,
402
+ `A managed policy holds approval at ${바닥.바닥}, so tasks cannot change files — those will count as failed (${바닥.곳})`)}`);
403
+ 말하기('');
404
+ }
405
+ const t0 = Date.now();
406
+ const 결과들 = [];
407
+ for (const 과제 of 과제들) {
408
+ for (let 판 = 1; 판 <= 되풀이; 판++) {
409
+ const 이름표 = 되풀이 > 1 ? `${과제.이름} #${판}` : 과제.이름;
410
+ /*
411
+ * 모양이 틀린 과제도 **되풀이 수만큼** 실패를 센다. 한 판만 세면 `--repeat 5` 에서 분모가
412
+ * 넷 줄어 통과율이 부푼다 — 멀쩡한 과제 5/5 와 깨진 과제 하나가 50% 가 아니라 83% 가 된다
413
+ * (2차 눈 판정). 화면에는 한 번만 적는다.
414
+ */
415
+ if (과제.탈) {
416
+ 결과들.push({ 과제: 과제.이름, 판, 통과: false, 초: 0, 과제탈: 과제.탈 });
417
+ if (판 === 1) 말하기(` ${c.yellow('⊘')} ${과제.이름} ${c.gray(과제.탈)}`);
418
+ continue;
419
+ }
420
+ const r = await 판돌리기(과제, { 판, 깃발들, 남김: keep, 시간초 });
421
+ 결과들.push(r);
422
+ const 옆 = [`${r.초}${글('초', 's')}`];
423
+ if (r.deel.tools) 옆.push(글(`도구 ${r.deel.tools}회`, `${r.deel.tools} tool calls`));
424
+ if (r.deel.reason && r.deel.reason !== 'done') 옆.push(`deel: ${r.deel.reason}`);
425
+ 말하기(` ${r.통과 ? mark.ok : c.red('✗')} ${이름표} ${c.gray(옆.join(' · '))}`);
426
+ if (!r.통과) {
427
+ for (const 줄 of String(r.검사.꼬리 ?? '').split('\n').filter((l) => l.trim()).slice(-4)) 말하기(` ${c.gray(줄.slice(0, 160))}`);
428
+ if (r.deel.stderr) for (const 줄 of r.deel.stderr.split('\n').filter((l) => l.trim()).slice(-3)) 말하기(` ${c.gray(줄.slice(0, 160))}`);
429
+ }
430
+ if (r.작업폴더) 말하기(` ${c.gray(글(`작업 폴더를 남겼습니다: ${r.작업폴더}`, `Kept the work folder: ${r.작업폴더}`))}`);
431
+ }
432
+ }
433
+
434
+ const 묶음 = 과제별(결과들);
435
+ const 판수 = 결과들.length;
436
+ const 통과수 = 결과들.filter((r) => r.통과).length;
437
+ const 모델 = 결과들.find((r) => r.deel?.model)?.deel.model ?? null;
438
+ const 지난 = 지난결과(고른폴더, { 전체만: !only, 이름들: only ? 과제들.map((x) => x.이름) : null });
439
+ const 견줌 = 견주기(지난?.값?.과제별, 묶음);
440
+ const 초 = Math.round((Date.now() - t0) / 1000);
441
+ const 토큰 = 결과들.reduce((a, r) => ({ in: a.in + (r.deel?.usage?.in ?? 0), out: a.out + (r.deel?.usage?.out ?? 0) }), { in: 0, out: 0 });
442
+
443
+ const 기록 = {
444
+ when: new Date().toISOString(),
445
+ model: 모델,
446
+ folder: 고른폴더,
447
+ repeat: 되풀이,
448
+ // 일부만 돈 판이라는 표시 — 다음 전체 판이 이것을 「지난번」 으로 안 친다 (지난결과 의 전체만).
449
+ ...(only ? { only: String(only) } : {}),
450
+ passed: 통과수,
451
+ runs: 판수,
452
+ rate: 판수 ? Math.round((통과수 / 판수) * 1000) / 10 : 0,
453
+ seconds: 초,
454
+ tokens: 토큰,
455
+ 과제별: 묶음,
456
+ 결과: 결과들,
457
+ ...(지난 ? { 견줌: { 지난파일: 지난.파일, ...견줌 } } : {}),
458
+ };
459
+ let 남긴곳 = null;
460
+ let 못남긴까닭 = null;
461
+ try {
462
+ mkdirSync(join(고른폴더, 결과폴더), { recursive: true });
463
+ 남긴곳 = join(고른폴더, 결과폴더, `${시각이름()}.json`);
464
+ writeFileSync(남긴곳, JSON.stringify(기록, null, 2) + '\n');
465
+ } catch (err) {
466
+ // 조용히 삼키면 사람은 다음 판에 견줄 것이 있는 줄 안다. 채점은 그대로 끝낸다.
467
+ 남긴곳 = null;
468
+ 못남긴까닭 = String(err?.code ?? err?.message ?? err);
469
+ }
470
+
471
+ if (json) process.stdout.write(JSON.stringify(기록) + '\n');
472
+ 말하기('');
473
+ 말하기(` ${c.gray('──')} ${통과수 === 판수 ? c.green(글(`통과 ${통과수}/${판수}`, `passed ${통과수}/${판수}`)) : c.yellow(글(`통과 ${통과수}/${판수}`, `passed ${통과수}/${판수}`))}`
474
+ + ` ${c.gray(`(${기록.rate}%) · ${초}${글('초', 's')} · ↑${토큰.in.toLocaleString()} ↓${토큰.out.toLocaleString()}${모델 ? ` · ${모델}` : ''}`)}`);
475
+ if (지난) {
476
+ if (견줌.나빠짐.length) 말하기(` ${c.red('▼')} ${글('지난번보다 나빠짐:', 'Worse than last time:')} ${견줌.나빠짐.join(' · ')}`);
477
+ if (견줌.나아짐.length) 말하기(` ${c.green('▲')} ${글('지난번보다 나아짐:', 'Better than last time:')} ${견줌.나아짐.join(' · ')}`);
478
+ if (견줌.견준 && !견줌.나빠짐.length && !견줌.나아짐.length) 말하기(` ${c.gray(글(`지난번(${지난.파일})과 같습니다`, `Same as last time (${지난.파일})`))}`);
479
+ }
480
+ if (남긴곳) 말하기(` ${c.gray(글(`결과: ${relative(뿌리, 남긴곳)}`, `Results: ${relative(뿌리, 남긴곳)}`))}`);
481
+ else if (못남긴까닭) {
482
+ const 알림 = 글(`결과 파일을 못 남겼습니다 (${못남긴까닭}) — 다음 판은 이번 것과 못 견줍니다`,
483
+ `Could not save the results file (${못남긴까닭}) — the next run cannot compare with this one`);
484
+ if (json) process.stderr.write(`${알림}\n`);
485
+ else 말하기(` ${c.yellow('⚠')} ${알림}`);
486
+ }
487
+ 말하기('');
488
+ return 통과수 === 판수 ? 0 : 1;
489
+ }
@@ -0,0 +1,95 @@
1
+ // `deel eval --init` 이 만들어 주는 예제 과제 다섯 (src/eval/run.js).
2
+ //
3
+ // ── 왜 파일이 아니라 글로 들고 있나 ─────────────────────────────────────
4
+ //
5
+ // npm 으로 받는 사람에게는 저장소의 폴더가 안 온다(package.json 의 files). 과제를
6
+ // 폴더로 두면 `--init` 이 복사할 원본이 설치본에 없다. 그래서 여기 글로 든다.
7
+ //
8
+ // ── 무엇을 재나 ──────────────────────────────────────────────────────────
9
+ //
10
+ // 코딩 에이전트가 실제로 자주 받는 일 다섯 가지를 하나씩 담았다. 크기는 작게 —
11
+ // 한 과제가 사내 모델로 1~2분 안에 끝나야 판마다 돌려 볼 마음이 난다.
12
+ //
13
+ // 01 버그 고치기 원인을 찾아 한 줄 고치기
14
+ // 02 기능 더하기 적힌 규칙대로 함수 하나를 새로 쓰기
15
+ // 03 여러 파일 이름 바꾸기 부르는 곳까지 빠짐없이
16
+ // 04 빨간 검사 고치기 검사를 건드리지 않고 코드를 고치기 — 완료 검사 고리(doneCheck)를 탄다
17
+ // 05 CRLF 파일 고치기 윈도 줄끝을 지키며 한 줄만 바꾸기
18
+ //
19
+ // 정답 검사(golden/)는 **모델이 일을 끝낸 뒤에** 작업 폴더에 들어간다. 모델은 못 본다.
20
+ // 그래서 「검사를 통과하게 검사를 고치는」 길이 없다 — 이게 정적 검사와 다른 점이다.
21
+
22
+ export const 예제과제 = {
23
+ '01-버그고치기': {
24
+ task: {
25
+ prompt: 'src/sum.js 의 sum() 이 목록의 합을 틀리게 돌려줍니다. 원인을 찾아 고쳐 주세요.',
26
+ check: 'node golden-check.mjs',
27
+ },
28
+ start: {
29
+ 'package.json': '{ "type": "module" }\n',
30
+ 'src/sum.js': '// 목록의 합을 돌려준다.\nexport function sum(list) {\n let total = 0;\n for (let i = 0; i < list.length - 1; i++) total += list[i];\n return total;\n}\n',
31
+ },
32
+ golden: {
33
+ 'golden-check.mjs': "import assert from 'node:assert/strict';\nimport { sum } from './src/sum.js';\n\nassert.equal(sum([1, 2, 3]), 6);\nassert.equal(sum([]), 0);\nassert.equal(sum([5]), 5);\nassert.equal(sum([-1, 1, 10]), 10);\nconsole.log('ok');\n",
34
+ },
35
+ },
36
+ '02-기능더하기': {
37
+ task: {
38
+ prompt: 'src/text.js 에 slugify(text) 를 더해 주세요. 영문은 소문자로 바꾸고, 영문 글자와 숫자가 아닌 글자가 이어진 자리는 하이픈(-) 하나로 바꾸고, 앞뒤 하이픈은 떼어 냅니다. 예: "Hello, World!" → "hello-world", " A B " → "a-b". 이미 있는 capitalize 는 그대로 둡니다.',
39
+ check: 'node golden-check.mjs',
40
+ },
41
+ start: {
42
+ 'package.json': '{ "type": "module" }\n',
43
+ 'src/text.js': "// 글 다루는 작은 함수들.\nexport function capitalize(s) {\n return s.charAt(0).toUpperCase() + s.slice(1);\n}\n",
44
+ },
45
+ golden: {
46
+ 'golden-check.mjs': "import assert from 'node:assert/strict';\nimport { slugify, capitalize } from './src/text.js';\n\nassert.equal(slugify('Hello, World!'), 'hello-world');\nassert.equal(slugify(' A B '), 'a-b');\nassert.equal(slugify('Version 2.1.0'), 'version-2-1-0');\nassert.equal(slugify('---'), '');\nassert.equal(slugify('abc'), 'abc');\nassert.equal(capitalize('deel'), 'Deel');\nconsole.log('ok');\n",
47
+ },
48
+ },
49
+ '03-이름바꾸기': {
50
+ task: {
51
+ prompt: 'getUser 함수의 이름을 fetchUser 로 바꿔 주세요. 부르는 곳도 모두 바꾸고, 동작은 그대로 둡니다.',
52
+ check: 'node golden-check.mjs',
53
+ },
54
+ start: {
55
+ 'package.json': '{ "type": "module" }\n',
56
+ 'src/user.js': "export function getUser(id) {\n return { id, name: 'user' + id };\n}\n",
57
+ 'src/profile.js': "import { getUser } from './user.js';\n\nexport function profileName(id) {\n return getUser(id).name;\n}\n",
58
+ 'src/admin.js': "import { getUser } from './user.js';\n\nexport function isAdmin(id) {\n return getUser(id).id === 0;\n}\n",
59
+ },
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 파일들 = (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
+ },
63
+ },
64
+ '04-빨간검사고치기': {
65
+ task: {
66
+ prompt: 'npm test 가 실패합니다. 코드를 고쳐서 통과하게 해 주세요. 검사 파일(test.mjs)은 고치지 마세요.',
67
+ // 한 명령으로 둔다 — `&&` 는 PowerShell 5.1 을 셸로 고른 PC 에서 안 먹는다(tools/shell.js).
68
+ check: 'node golden-check.mjs',
69
+ // 모델이 끝내려 할 때 deel 이 이걸 돌린다 (agent/donecheck.js). 실패하면 이어서 고치게 한다.
70
+ doneCheck: 'npm test',
71
+ },
72
+ start: {
73
+ 'package.json': '{ "type": "module", "scripts": { "test": "node test.mjs" } }\n',
74
+ 'src/date.js': '// 날짜를 YYYY-MM-DD 로 적는다.\nexport function ymd(d) {\n return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;\n}\n',
75
+ 'test.mjs': "import assert from 'node:assert/strict';\nimport { ymd } from './src/date.js';\n\nassert.equal(ymd(new Date(2024, 0, 5)), '2024-01-05');\nconsole.log('ok');\n",
76
+ },
77
+ golden: {
78
+ // 모델이 test.mjs 를 고쳤어도 원래 것으로 되돌려 놓고 돌린다.
79
+ 'test.mjs': "import assert from 'node:assert/strict';\nimport { ymd } from './src/date.js';\n\nassert.equal(ymd(new Date(2024, 0, 5)), '2024-01-05');\nconsole.log('ok');\n",
80
+ 'golden-check.mjs': "import assert from 'node:assert/strict';\nimport { ymd } from './src/date.js';\n\nassert.equal(ymd(new Date(2024, 0, 5)), '2024-01-05');\nassert.equal(ymd(new Date(2024, 11, 31)), '2024-12-31');\nassert.equal(ymd(new Date(2025, 9, 10)), '2025-10-10');\nassert.equal(ymd(new Date(1999, 8, 9)), '1999-09-09');\nconsole.log('ok');\n",
81
+ },
82
+ },
83
+ '05-CRLF파일': {
84
+ task: {
85
+ prompt: 'config.ini 의 port 를 9090 으로 바꿔 주세요. 다른 줄은 건드리지 마세요.',
86
+ check: 'node golden-check.mjs',
87
+ },
88
+ start: {
89
+ 'config.ini': '[server]\r\nport=8080\r\nhost=localhost\r\n',
90
+ },
91
+ golden: {
92
+ 'golden-check.mjs': "import assert from 'node:assert/strict';\nimport { readFileSync } from 'node:fs';\n\nassert.equal(readFileSync('config.ini', 'utf8'), '[server]\\r\\nport=9090\\r\\nhost=localhost\\r\\n', '줄끝(CRLF)을 지키며 port 한 줄만 바뀌어야 합니다');\nconsole.log('ok');\n",
93
+ },
94
+ },
95
+ };
package/src/i18n/en.js CHANGED
@@ -323,6 +323,11 @@ export const en = {
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
+ 'approve.newFile': 'new file',
327
+ 'approve.noChange': 'no change — same content as now',
328
+ 'approve.locked': 'A managed policy keeps approval at {바닥} or stricter — {곳}',
329
+ 'approve.lockedUnknown': 'The managed policy\'s approval value "{값}" is unknown, so it counts as strict, the narrowest — {곳}',
330
+ 'approve.noAlways': 'A managed policy sets approval, so "don\'t ask again" is not offered',
326
331
  'work.title': 'Work modes',
327
332
  'work.canEdit': 'edits files',
328
333
  'work.readOnly': 'read-only',
@@ -388,6 +393,8 @@ export const en = {
388
393
  'plan.ask': 'Go ahead with this?',
389
394
  'plan.hint': '⏎ go · y with a note · 3 a note on that step · n cancel',
390
395
  'plan.cancelled': 'Stopped. The plan is still above — just say what you want next.',
396
+ 'plan.timedOut': 'No answer in {초}s — going ahead with the plan.',
397
+ 'plan.autoGo': 'Auto mode — going ahead with the plan without asking.',
391
398
  'plan.going': 'Going ahead with the plan.',
392
399
  'plan.goingWith': 'Going ahead, with your note — {말}',
393
400
  'plan.replan': 'Redoing the plan with that in mind.',
@@ -398,6 +405,9 @@ export const en = {
398
405
  'ask.pickPrompt': '1-{끝} (a note may follow), or type your own',
399
406
  'ask.freePrompt': 'Answer',
400
407
  'ask.stopped': 'Stopped — treating it as unanswered.',
408
+ 'ask.autoWait': 'If no answer comes in {초}s, it carries on by itself',
409
+ 'ask.timedOut': 'No answer in {초}s — carrying on by itself.',
410
+ 'ask.autoSkip': 'Auto mode, so it carries on without asking — {물음}',
401
411
 
402
412
  // ── When the server pushes back (backend/retry.js) ────────────────────
403
413
  'loop.backoff': 'The server pushed back ({무엇}) — calling again in {초}s ({n}/{max})',
@@ -508,6 +518,18 @@ export const en = {
508
518
  'ev.hookNote': 'hook: {말}',
509
519
  'ev.hookBlock': 'A hook blocked this',
510
520
  'ev.nudgeRead': 'It was about to finish having only read things — pushed back once',
521
+ 'check.start': 'Completion check ({판}/{최대}) — {명령}',
522
+ 'check.clamped': '{칸} {준값} is over the limit — using {쓴값}',
523
+ 'check.pass': 'Completion check passed — {명령}',
524
+ 'check.fail': 'Completion check failed ({판}/{최대}) — {요약}',
525
+ 'check.retry': 'Sending the failure back to fix',
526
+ 'check.gaveUp': 'Still failing after {판} runs — ending as failed',
527
+ 'check.skipped': 'Could not run the completion check — {까닭}',
528
+ 'cli.checkFlag': 'run this check before finishing; failures go back to be fixed, and still failing ends with 8',
529
+ 'cli.evalTitle': 'Golden set',
530
+ 'cli.evalWhy': '— run a set of tasks on the real model and measure how many it gets done',
531
+ 'cli.evalInit': 'create five example tasks in golden/',
532
+ 'cli.evalRun': 'run deel run on each task in a temp folder, then grade it with checks it never saw',
511
533
  'ev.learnedCtx': 'Setting the context limit to {한계} as the server reported, and calling again',
512
534
  'ev.learnedOut': 'Setting the answer-length limit to {한계} as the server reported, and calling again',
513
535
  'ev.reset': 'Out of room — cleared {버린수} earlier messages and carrying on, {할일} to-do items left',