deel-local-cli 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Outline — 프로젝트의 **뼈대만** 싸게 보여 준다.
3
+ *
4
+ * ── 왜 이게 필요한가 ───────────────────────────────────────────────────
5
+ *
6
+ * 지금 남의 코드를 이해하는 길은 두 가지뿐이다.
7
+ * Glob → 경로만 나온다. 안에 무엇이 있는지는 모른다.
8
+ * Read → 파일을 통째로 읽는다. 창이 순식간에 찬다.
9
+ *
10
+ * 가운데가 비어 있었다. budget.js 가 Read 줄 수를 모델에 맞춰 잘라 주긴 하지만,
11
+ * **자른 파일은 구조를 안 보여 준다** — 앞 200줄에 import 만 있는 파일이 흔하다.
12
+ * 그래서 모델은 "무엇이 어디 있는지" 를 모른 채로 고치기 시작하고, 엉뚱한 파일에
13
+ * 새 함수를 만들어 넣는다. 이미 있는 것을 못 봤기 때문이다.
14
+ *
15
+ * Outline 은 같은 폴더를 Read 의 몇십 분의 일로 보여 준다. 함수·클래스·export
16
+ * 이름과 줄 번호만 뽑기 때문이다. 이름만 봐도 "이건 여기 있겠구나" 가 서고,
17
+ * 그 다음에 그 파일 하나만 Read 하면 된다.
18
+ *
19
+ * ── 왜 정규식인가 ──────────────────────────────────────────────────────
20
+ *
21
+ * 제대로 하려면 언어마다 파서가 필요하다(tree-sitter). 그런데 이 프로그램은
22
+ * 의존성이 0개다 — 사내에 미승인 SW 를 못 들이기 때문이고, 그건 못 바꾼다.
23
+ *
24
+ * 정규식은 틀릴 수 있다. 문자열 안에 든 `function` 을 함수로 볼 수도 있다.
25
+ * 그래도 쓰는 이유는, 여기서 나온 것은 **다음에 무엇을 Read 할지 고르는 데**
26
+ * 쓰이지 그 자체로 판단 근거가 되지 않기 때문이다. 몇 개 더 나오거나 덜 나와도
27
+ * 다음 걸음이 조금 더 걸릴 뿐, 틀린 코드가 나가지는 않는다.
28
+ *
29
+ * 대신 **모르는 것은 모른다고 말한다.** 못 읽는 확장자를 조용히 빼면 모델은
30
+ * 그 파일이 없는 줄 안다. 그게 정규식의 부정확함보다 훨씬 나쁘다.
31
+ */
32
+ import { readFileSync, existsSync, statSync } from 'node:fs';
33
+ import { extname } from 'node:path';
34
+ import { walk, SKIP_DIRS, globToRegex, 내부살림 } from './fsutil.js';
35
+ import { decode, looksBinary } from './encoding.js';
36
+ import { 찾을개수, 뼈대줄수 } from '../agent/budget.js';
37
+
38
+ /**
39
+ * 언어별로 '뼈대' 를 이루는 것들.
40
+ *
41
+ * 각 항목은 [정규식, 갈래]. 정규식의 첫 번째 잡음이 이름이다.
42
+ * 갈래는 화면에 붙는 한 글자짜리 표시다 — 이름만 스무 줄 늘어놓으면
43
+ * 무엇이 함수고 무엇이 자료인지 안 보인다.
44
+ */
45
+ const 규칙 = {
46
+ js: [
47
+ [/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([\p{L}$_][\p{L}\p{N}$_]*)/u, 'fn'],
48
+ [/^\s*(?:export\s+)?(?:abstract\s+)?class\s+([\p{L}$_][\p{L}\p{N}$_]*)/u, 'class'],
49
+ [/^\s*(?:export\s+)?(?:const|let|var)\s+([\p{L}$_][\p{L}\p{N}$_]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[\p{L}$_][\p{L}\p{N}$_]*)\s*=>/u, 'fn'],
50
+ [/^\s*(?:export\s+)?(?:interface|type|enum)\s+([\p{L}$_][\p{L}\p{N}$_]*)/u, 'type'],
51
+ [/^\s*export\s+(?:const|let|var)\s+([\p{L}$_][\p{L}\p{N}$_]*)/u, 'const'],
52
+ // 클래스 안의 메서드. 들여쓰기가 있고 괄호로 이어지는 이름.
53
+ [/^\s{2,}(?:static\s+|async\s+|get\s+|set\s+|#)?([\p{L}$_][\p{L}\p{N}$_]*)\s*\([^)]*\)\s*\{/u, 'method'],
54
+ ],
55
+ py: [
56
+ [/^\s*class\s+([\p{L}_][\p{L}\p{N}_]*)/u, 'class'],
57
+ [/^\s*(?:async\s+)?def\s+([\p{L}_][\p{L}\p{N}_]*)/u, 'fn'],
58
+ [/^([A-Z_][A-Z0-9_]{2,})\s*[:=]/, 'const'],
59
+ ],
60
+ java: [
61
+ [/^\s*(?:public|private|protected)?\s*(?:static\s+)?(?:final\s+)?(?:abstract\s+)?(?:class|interface|enum|record)\s+([\p{L}_][\p{L}\p{N}_]*)/u, 'class'],
62
+ [/^\s*(?:public|private|protected)\s+(?:static\s+)?(?:final\s+)?[\p{L}\p{N}_<>[\],\s]+\s+([\p{L}_][\p{L}\p{N}_]*)\s*\(/u, 'method'],
63
+ ],
64
+ go: [
65
+ [/^func\s+(?:\([^)]*\)\s*)?([\p{L}_][\p{L}\p{N}_]*)/u, 'fn'],
66
+ [/^type\s+([\p{L}_][\p{L}\p{N}_]*)/u, 'type'],
67
+ [/^(?:const|var)\s+([\p{L}_][\p{L}\p{N}_]*)/u, 'const'],
68
+ ],
69
+ rs: [
70
+ [/^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([\p{L}_][\p{L}\p{N}_]*)/u, 'fn'],
71
+ [/^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|type)\s+([\p{L}_][\p{L}\p{N}_]*)/u, 'type'],
72
+ [/^\s*impl(?:<[^>]*>)?\s+([\p{L}_][\p{L}\p{N}_:]*)/u, 'impl'],
73
+ ],
74
+ cs: [
75
+ [/^\s*(?:public|private|protected|internal)?\s*(?:static\s+|sealed\s+|abstract\s+|partial\s+)*(?:class|interface|struct|enum|record)\s+([\p{L}_][\p{L}\p{N}_]*)/u, 'class'],
76
+ [/^\s*(?:public|private|protected|internal)\s+(?:static\s+|async\s+|virtual\s+|override\s+)*[\p{L}\p{N}_<>[\],?\s]+\s+([\p{L}_][\p{L}\p{N}_]*)\s*\(/u, 'method'],
77
+ ],
78
+ md: [
79
+ [/^(#{1,4})\s+(.+?)\s*$/, '#'],
80
+ ],
81
+ html: [
82
+ [/^\s*<(?:section|main|header|footer|nav|article|form|table|dialog)\b[^>]*\bid="([^"]+)"/i, 'tag'],
83
+ [/^\s*<(section|main|header|footer|nav|article|form|table|dialog)\b/i, 'tag'],
84
+ [/^\s*<h([1-3])\b[^>]*>(.*?)</i, '#'],
85
+ ],
86
+ css: [
87
+ [/^([.#][\p{L}_-][\p{L}\p{N}_-]*(?:\s*[,>+~]\s*[^{]+)?)\s*\{/u, 'rule'],
88
+ [/^(@[\p{L}-]+[^{]*)\{/u, 'at'],
89
+ ],
90
+ sh: [
91
+ [/^\s*(?:function\s+)?([\p{L}_][\p{L}\p{N}_]*)\s*\(\)\s*\{/u, 'fn'],
92
+ ],
93
+ json: [], // 자료다. 뼈대라고 할 것이 없다 — 아래에서 따로 다룬다.
94
+ };
95
+
96
+ /** 확장자 → 규칙 이름. 여기 없는 것은 '못 읽는다' 고 말한다. */
97
+ const 확장자 = {
98
+ '.js': 'js', '.mjs': 'js', '.cjs': 'js', '.jsx': 'js',
99
+ '.ts': 'js', '.tsx': 'js', '.mts': 'js', '.cts': 'js',
100
+ '.py': 'py', '.pyi': 'py',
101
+ '.java': 'java', '.kt': 'java', '.kts': 'java', '.groovy': 'java', '.scala': 'java',
102
+ '.go': 'go',
103
+ '.rs': 'rs',
104
+ '.cs': 'cs',
105
+ '.md': 'md', '.markdown': 'md',
106
+ '.html': 'html', '.htm': 'html', '.vue': 'html', '.svelte': 'html',
107
+ '.css': 'css', '.scss': 'css', '.less': 'css',
108
+ '.sh': 'sh', '.bash': 'sh', '.zsh': 'sh',
109
+ '.json': 'json',
110
+ };
111
+
112
+ /** 뼈대를 못 뽑는 확장자라도 '무엇인지' 는 말해 줄 수 있는 것들. */
113
+ const 설명만 = {
114
+ '.yml': 'YAML 설정', '.yaml': 'YAML 설정', '.toml': 'TOML 설정', '.ini': '설정',
115
+ '.env': '환경 변수', '.txt': '글', '.csv': '표', '.sql': 'SQL',
116
+ '.xml': 'XML', '.svg': '벡터 그림', '.lock': '잠금 파일',
117
+ };
118
+
119
+ /*
120
+ * 이름처럼 생겼지만 이름이 아닌 것들.
121
+ *
122
+ * 메서드 규칙은 `들여쓰기 + 이름(...) {` 을 잡는데, 그 모양은 `if (…) {` 와
123
+ * 똑같다. 걸러 내지 않으면 뼈대에 if·for·while 이 줄줄이 올라온다 —
124
+ * 실제로 screen.js 뼈대에 `method if`, inputbox.js 에 `method for` 가 떴다.
125
+ * 자리를 먹는 것도 문제지만, 그게 있으면 진짜 이름을 눈으로 못 고른다.
126
+ */
127
+ const 이름아님 = new Set([
128
+ 'if', 'for', 'while', 'switch', 'catch', 'do', 'else', 'try', 'finally',
129
+ 'return', 'function', 'class', 'new', 'delete', 'typeof', 'void', 'with',
130
+ 'constructor', // 진짜 이름이지만 클래스마다 하나씩 있어 목록만 채운다
131
+ ]);
132
+
133
+ /** 한 줄이 너무 길면 자른다 — 이름이 길어야 60자다. */
134
+ const 짧게 = (s, n = 72) => {
135
+ const t = String(s ?? '').replace(/\s+/g, ' ').trim();
136
+ return t.length > n ? `${t.slice(0, n - 1)}…` : t;
137
+ };
138
+
139
+ /**
140
+ * 파일 하나의 뼈대를 뽑는다.
141
+ *
142
+ * @returns {{항목:Array<{줄:number,갈래:string,이름:string}>, 줄수:number, 왜못읽나:string|null}}
143
+ */
144
+ export function 뼈대뽑기(글, 확장) {
145
+ const 규칙이름 = 확장자[String(확장).toLowerCase()];
146
+ if (!규칙이름) return { 항목: [], 줄수: 0, 왜못읽나: '아직 뼈대를 못 뽑는 종류' };
147
+
148
+ const 줄들 = String(글).split('\n');
149
+
150
+ // JSON 은 코드가 아니라 자료다. 맨 위 열쇠들이 곧 뼈대다.
151
+ if (규칙이름 === 'json') {
152
+ const 항목 = [];
153
+ for (const [i, 한줄] of 줄들.entries()) {
154
+ const m = /^\s{0,4}"([^"]+)"\s*:/.exec(한줄);
155
+ if (m) 항목.push({ 줄: i + 1, 갈래: 'key', 이름: m[1] });
156
+ if (항목.length >= 40) break;
157
+ }
158
+ return { 항목, 줄수: 줄들.length, 왜못읽나: null };
159
+ }
160
+
161
+ const 표 = 규칙[규칙이름] ?? [];
162
+ const 항목 = [];
163
+ const 본것 = new Set();
164
+
165
+ for (const [i, 한줄] of 줄들.entries()) {
166
+ // 주석 줄은 건너뛴다. 주석 안의 예제 코드가 뼈대로 올라오면 안 된다.
167
+ if (/^\s*(?:\/\/|\/\*|\*|#(?!\s*[#!])|--)/.test(한줄) && 규칙이름 !== 'md') continue;
168
+ for (const [re, 갈래] of 표) {
169
+ const m = re.exec(한줄);
170
+ if (!m) continue;
171
+ // md 헤딩과 html 헤딩은 잡음이 둘이다 — 깊이와 글.
172
+ let 이름;
173
+ let 실갈래 = 갈래;
174
+ if (갈래 === '#' && m[2] !== undefined) {
175
+ const 깊이 = 규칙이름 === 'md' ? m[1].length : Number(m[1]);
176
+ 이름 = `${' '.repeat(Math.max(0, 깊이 - 1))}${m[2]}`;
177
+ 실갈래 = `h${깊이}`;
178
+ } else {
179
+ 이름 = m[1];
180
+ }
181
+ 이름 = 짧게(이름);
182
+ if (!이름 || 이름아님.has(이름.trim())) continue;
183
+ const 열쇠 = `${실갈래}|${이름.trim()}`;
184
+ if (본것.has(열쇠)) continue; // 같은 이름이 여러 번 걸리는 규칙이 있다
185
+ 본것.add(열쇠);
186
+ 항목.push({ 줄: i + 1, 갈래: 실갈래, 이름 });
187
+ break; // 한 줄은 한 가지로만 센다
188
+ }
189
+ }
190
+ return { 항목, 줄수: 줄들.length, 왜못읽나: null };
191
+ }
192
+
193
+ /** 파일 하나를 안전하게 읽어 온다. 바이너리·내부살림은 안 읽는다. */
194
+ function 글읽기(abs) {
195
+ const 막을이유 = 내부살림(abs);
196
+ if (막을이유) return { 막힘: 막을이유 };
197
+ let buf;
198
+ try { buf = readFileSync(abs); } catch (e) { return { 막힘: e.message }; }
199
+ if (looksBinary(buf)) return { 막힘: '바이너리' };
200
+ try { return { 글: decode(buf).text }; } catch { return { 글: buf.toString('utf8') } ; }
201
+ }
202
+
203
+ /**
204
+ * Outline 도구.
205
+ *
206
+ * 폴더를 주면 그 안을, 파일을 주면 그 파일 하나를 본다.
207
+ */
208
+ export const OUTLINE_TOOL = {
209
+ schema: {
210
+ name: 'Outline',
211
+ description:
212
+ '폴더나 파일의 **뼈대만** 본다 — 파일별 함수·클래스·타입·헤딩 이름과 줄 번호.'
213
+ + ' 남의 코드를 고치기 전에 이걸 먼저 불러라. 파일을 통째로 Read 하는 것보다'
214
+ + ' 수십 분의 일만 쓰면서 "무엇이 어디 있는지" 를 알 수 있다.'
215
+ + ' 여기서 고칠 자리를 고른 다음, **그 파일만** Read 해라.'
216
+ + ' js/ts · py · java/kotlin · go · rust · c# · md · html · css · sh · json 을 읽는다.',
217
+ parameters: {
218
+ type: 'object',
219
+ properties: {
220
+ path: { type: 'string', description: '폴더 또는 파일 경로. 없으면 작업 폴더 전체' },
221
+ pattern: { type: 'string', description: '이름으로 좁히기 (예: **/*.js). 없으면 다 본다' },
222
+ },
223
+ required: [],
224
+ },
225
+ },
226
+
227
+ run(args, ctx) {
228
+ const 시작 = args.path ? ctx.scope.resolve(args.path) : ctx.scope.root;
229
+ if (!existsSync(시작)) return { error: `없는 경로입니다: ${args.path ?? '.'}` };
230
+
231
+ const 하나인가 = statSync(시작).isFile();
232
+ let 파일들 = 하나인가
233
+ ? [{ path: 시작, rel: ctx.scope.show(시작), mtime: statSync(시작).mtimeMs }]
234
+ : walk(시작, { skipDirs: SKIP_DIRS });
235
+
236
+ // 좁히는 방식은 Glob 도구와 **같은 것**을 쓴다. 두 도구가 같은 패턴에
237
+ // 다르게 답하면 모델이 둘 중 어느 것을 믿어야 할지 알 수 없다.
238
+ if (args.pattern && !하나인가) {
239
+ const re = globToRegex(args.pattern);
240
+ 파일들 = 파일들.filter((f) => re.test(f.rel) || re.test(f.rel.split('/').pop()));
241
+ }
242
+
243
+ if (!파일들.length) return { content: '볼 파일이 없습니다.', summary: '0개' };
244
+
245
+ // 최근에 손댄 것부터. 지금 하는 일과 가까울 가능성이 높다.
246
+ 파일들.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
247
+
248
+ const 파일상한 = 찾을개수(ctx.모델컨텍스트);
249
+ const 줄상한 = 뼈대줄수(ctx.모델컨텍스트);
250
+ const 볼것 = 파일들.slice(0, 파일상한);
251
+
252
+ const 줄들 = [];
253
+ let 쓴줄 = 0;
254
+ const 못읽은것 = new Map(); // 이유 → 그 이유로 못 읽은 파일들
255
+ let 뼈대있는파일 = 0;
256
+ let 항목수 = 0;
257
+ let 자리모자람 = false;
258
+
259
+ for (const f of 볼것) {
260
+ if (쓴줄 >= 줄상한) { 자리모자람 = true; break; }
261
+ const 보인이름 = ctx.scope.show(f.path);
262
+ const 확장 = extname(f.path).toLowerCase();
263
+
264
+ if (!확장자[확장]) {
265
+ const 뭔지 = 설명만[확장];
266
+ const 이유 = 뭔지 ? `${뭔지} — 뼈대 없음` : '아직 뼈대를 못 뽑는 종류';
267
+ 못읽은것.set(이유, [...(못읽은것.get(이유) ?? []), 보인이름]);
268
+ continue;
269
+ }
270
+
271
+ const 읽음 = 글읽기(f.path);
272
+ if (읽음.막힘) {
273
+ 못읽은것.set(읽음.막힘, [...(못읽은것.get(읽음.막힘) ?? []), 보인이름]);
274
+ continue;
275
+ }
276
+
277
+ const { 항목, 줄수 } = 뼈대뽑기(읽음.글, 확장);
278
+ if (!항목.length) {
279
+ 못읽은것.set('안에 이름 붙은 것이 없음', [...(못읽은것.get('안에 이름 붙은 것이 없음') ?? []), 보인이름]);
280
+ continue;
281
+ }
282
+
283
+ 뼈대있는파일++;
284
+ 줄들.push(`${보인이름} (${줄수}줄)`);
285
+ 쓴줄++;
286
+ // 파일 하나가 목록을 통째로 먹지 않게 한다. 500개짜리 파일 하나가
287
+ // 나머지 마흔 개를 밀어내면 '구조를 본다' 는 뜻이 없어진다.
288
+ const 파일당 = Math.max(6, Math.floor(줄상한 / Math.max(4, 볼것.length)));
289
+ const 보일항목 = 항목.slice(0, 파일당);
290
+ for (const it of 보일항목) {
291
+ 줄들.push(` ${String(it.줄).padStart(5)} ${it.갈래.padEnd(6)} ${it.이름}`);
292
+ 쓴줄++;
293
+ }
294
+ if (항목.length > 보일항목.length) {
295
+ 줄들.push(` … 그 밖에 ${항목.length - 보일항목.length}개 더`);
296
+ 쓴줄++;
297
+ }
298
+ 항목수 += 항목.length;
299
+ 줄들.push('');
300
+ 쓴줄++;
301
+ }
302
+
303
+ /*
304
+ * 못 읽은 것을 **반드시 말한다.**
305
+ *
306
+ * 조용히 빼면 모델은 그 파일이 없는 줄 안다. 그러면 이미 있는 설정을
307
+ * 다시 만들거나, .yml 안에 든 답을 못 찾고 헤맨다. 정규식이 몇 개 틀리는
308
+ * 것보다 "없는 줄 알았다" 가 훨씬 비싸다.
309
+ */
310
+ if (못읽은것.size) {
311
+ 줄들.push('여기 있지만 뼈대는 못 뽑은 것 (필요하면 Read 로 직접 읽어라):');
312
+ for (const [이유, 목록] of 못읽은것) {
313
+ const 앞 = 목록.slice(0, 8).join(' · ');
314
+ const 더 = 목록.length > 8 ? ` … 그 밖에 ${목록.length - 8}개` : '';
315
+ 줄들.push(` [${이유}] ${앞}${더}`);
316
+ }
317
+ }
318
+
319
+ if (파일들.length > 볼것.length || 자리모자람) {
320
+ 줄들.push('');
321
+ 줄들.push(`… 모두 ${파일들.length}개 중 ${뼈대있는파일}개의 뼈대만 실었습니다.`
322
+ + ' 좁혀서 다시 부르세요 (path 나 pattern 을 주면 됩니다).');
323
+ }
324
+
325
+ return {
326
+ content: 줄들.join('\n').trimEnd(),
327
+ summary: `${뼈대있는파일}개 파일 · ${항목수}곳`
328
+ + (못읽은것.size ? ` · 못 읽음 ${[...못읽은것.values()].reduce((a, x) => a + x.length, 0)}개` : ''),
329
+ };
330
+ },
331
+ };
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Task — 하위 작업. 큰 일의 한 덩이를 **따로 떨어진 창**에서 돌린다.
3
+ *
4
+ * ── 왜 필요한가 ────────────────────────────────────────────────────────
5
+ *
6
+ * 파일 여덟 개짜리 프로젝트를 한 창에서 만들면, 여덟 개 파일 내용이 전부 그
7
+ * 창에 쌓인다. 32k 모델이면 서너 개째에 창이 차고, 차면 앞엣말이 접혀 나간다.
8
+ * 그때부터 모델은 자기가 무엇을 만들던 중이었는지를 잊는다 — 화면에는 아무
9
+ * 오류도 안 뜨는데 결과만 이상해진다. "대시보드 만들어줘" 가 계획표 한 장으로
10
+ * 끝나던 일의 뿌리가 이것이다.
11
+ *
12
+ * 하위 작업은 그 덩이를 **새 대화**에서 돌리고, 부모에게는 요약 한 덩이만
13
+ * 돌려준다. 하위가 파일 열 개를 읽고 고쳐도 부모 창이 받는 것은 요약뿐이다.
14
+ * 그래서 8k 모델도 여러 파일짜리 일을 끝까지 끌고 갈 수 있다.
15
+ *
16
+ * ── 여기 없는 것 ───────────────────────────────────────────────────────
17
+ *
18
+ * 실행은 여기 없다. loop.js 가 가로채서 직접 돈다.
19
+ *
20
+ * 도구는 `{content, summary}` 만 돌려줄 뿐 이벤트를 못 흘린다. 하위 작업을
21
+ * 평범한 도구로 돌리면 그 몇 분 동안 화면이 완전히 죽는다 — 사람은 멈춘 건지
22
+ * 도는 건지 알 수 없다. 그래서 loop.js 가 하위 루프의 이벤트를 그대로 받아
23
+ * 한 단 들여쓴 채 위로 올려보낸다.
24
+ *
25
+ * 이 파일이 갖는 것은 스키마와, **경계를 지키는 판단들**이다.
26
+ */
27
+ import { get as workMode, canWrite, normalize as normWork } from '../agent/modes.js';
28
+
29
+ /**
30
+ * 하위 작업을 몇 겹까지 허용하나.
31
+ *
32
+ * 부모(0) → 하위(1) → 하위의 하위(2) 까지다. 그 아래로는 도구 목록에서
33
+ * Task 를 빼 버린다. 부탁으로 막지 않는다 — 모델은 부탁을 잊는다.
34
+ * 목록에 없으면 잊을 것이 없다 (modes.js 가 도구를 아예 빼는 것과 같은 방식).
35
+ */
36
+ export const 최대깊이 = 2;
37
+
38
+ export const TASK_TOOL = {
39
+ schema: {
40
+ name: 'Task',
41
+ description:
42
+ '큰 일의 한 덩이를 하위 작업으로 떼어 **따로 돌린다.** 하위 작업은 자기만의 대화에서'
43
+ + ' 처음부터 끝까지 일하고, 너에게는 결과 요약만 돌아온다 — 하위가 읽은 파일 내용은'
44
+ + ' 네 창에 안 쌓인다. 그래서 파일 여러 개를 만들거나 고치는 일은 이걸로 나눠야 끝까지 간다.'
45
+ + ' 덩이 하나는 혼자 끝낼 수 있는 크기여야 한다 (예: "index.html 과 style.css 만들기").'
46
+ + ' 하위는 네 대화를 못 본다 — 필요한 것은 할일 안에 다 적어 줘라.'
47
+ + ' 짧은 일 하나를 할 때는 쓰지 마라. 그냥 직접 하는 편이 빠르다.',
48
+ parameters: {
49
+ type: 'object',
50
+ properties: {
51
+ 목적: { type: 'string', description: '이 덩이를 한 줄로 (예: "대시보드 화면 뼈대 만들기")' },
52
+ 할일: {
53
+ type: 'string',
54
+ description:
55
+ '하위가 할 일을 빠짐없이. 하위는 지금 대화를 못 보니 필요한 배경·정한 것·파일 경로를'
56
+ + ' 여기 다 적어라. 무엇이 끝나면 다 된 것인지도 적어라.',
57
+ },
58
+ 모드: {
59
+ type: 'string',
60
+ description: '하위가 일할 방식: code(만들고 고침) · debug(원인 찾기) · ask(읽고 답만). 안 적으면 code.',
61
+ },
62
+ },
63
+ required: ['목적', '할일'],
64
+ },
65
+ },
66
+
67
+ /*
68
+ * 여기까지 오면 안 된다. loop.js 가 가로채기 때문이다.
69
+ *
70
+ * 그래도 두는 이유: 도구는 `run` 이 있다고 보고 부르는 자리가 여럿이다
71
+ * (runTool). 없으면 TypeError 가 나고, 그건 "왜 안 되는지" 를 아무에게도
72
+ * 안 알려 준다. 여기서 말로 알려 주면 적어도 무엇이 잘못됐는지는 남는다.
73
+ */
74
+ run() {
75
+ return { error: '하위 작업은 이 자리에서 돌릴 수 없습니다. (loop.js 가 처리해야 합니다)' };
76
+ },
77
+ };
78
+
79
+ /**
80
+ * 하위가 일할 모드를 정한다 — **부모보다 셀 수는 없다.**
81
+ *
82
+ * 이게 이 파일에서 제일 중요한 함수다.
83
+ *
84
+ * 설계·계획·묻기 모드는 "파일을 안 바꾼다" 는 약속이다. 사람은 그 약속을 믿고
85
+ * /architect 를 켠다. 그런데 하위 작업이 제 모드를 스스로 고를 수 있으면,
86
+ * 설계 모드에서 `Task({모드:'code'})` 한 번으로 그 약속이 깨진다. 화면에는
87
+ * 여전히 설계 모드라고 떠 있는 채로 파일이 바뀐다 — 제일 나쁜 종류의 구멍이다.
88
+ *
89
+ * 그래서 부모가 못 쓰는 것은 하위도 못 쓴다. 부모가 읽기 전용이면 하위가
90
+ * 무엇을 부탁하든 읽기 전용으로 내린다.
91
+ */
92
+ export function 하위모드(요청, 부모모드) {
93
+ const 고른것 = normWork(요청) ?? 'code';
94
+ if (canWrite(부모모드)) return 고른것;
95
+ // 부모가 못 바꾸면 하위도 못 바꾼다. 읽기만 하는 모드 중에서 고른다.
96
+ return canWrite(고른것) ? 'ask' : 고른것;
97
+ }
98
+
99
+ /**
100
+ * 하위가 끝난 뒤 부모 대화에 실을 글.
101
+ *
102
+ * 하위가 무엇을 했는지가 아니라 **부모가 다음 걸음을 정하는 데 필요한 것**만
103
+ * 담는다. 파일 목록은 디스크를 보고 적은 사실이고(loop.js 의 파일현황),
104
+ * 하위가 "만들었습니다" 라고 말한 것이 아니다.
105
+ *
106
+ * 못 끝낸 것을 빼면 안 된다. 부모는 그걸 보고 이어서 할지 정한다 —
107
+ * 다 됐다고 알면 안 끝난 일 위에 다음 일을 쌓는다.
108
+ */
109
+ export function 하위요약({ 목적, 모드, 끝, 글자수, 보인이름 = (p) => p }) {
110
+ const 줄 = [];
111
+ const w = workMode(모드);
112
+ const 왜끝났나 = {
113
+ done: '끝냄',
114
+ limit: '걸음 수를 다 써서 멈춤 — 다 못 했습니다',
115
+ stuck: '같은 자리를 되풀이해서 스스로 멈춤 — 다 못 했습니다',
116
+ aborted: '사용자가 중단함 — 다 못 했습니다',
117
+ }[끝?.type] ?? '끝난 이유를 알 수 없음';
118
+
119
+ 줄.push(`하위 작업 "${목적}" · ${w.name} 모드 · ${끝?.steps ?? 0}걸음 · ${왜끝났나}`);
120
+
121
+ const 파일들 = (끝?.files ?? []).filter((f) => !f.dir);
122
+ if (파일들.length) {
123
+ 줄.push('');
124
+ 줄.push('건드린 파일 (디스크에서 확인한 것):');
125
+ for (const f of 파일들) {
126
+ const 이름 = 보인이름(f.path);
127
+ if (f.missing) { 줄.push(` ✗ ${이름} — 만들어지지 않았습니다`); continue; }
128
+ const kb = f.bytes >= 1024 ? `${(f.bytes / 1024).toFixed(1)}KB` : `${f.bytes}B`;
129
+ 줄.push(` ✓ ${이름} · ${f.lines}줄 · ${kb}`);
130
+ }
131
+ } else {
132
+ 줄.push('건드린 파일: 없음');
133
+ }
134
+
135
+ const 남 = 끝?.남은할일 ?? [];
136
+ if (남.length) {
137
+ 줄.push('');
138
+ 줄.push('아직 안 끝난 것:');
139
+ for (const t of 남) 줄.push(` · ${t.text ?? t.title ?? String(t)}`);
140
+ }
141
+
142
+ if (끝?.why) { 줄.push(''); 줄.push(`막힌 데: ${끝.why}`); }
143
+
144
+ const 말 = String(끝?.text ?? '').trim();
145
+ if (말) {
146
+ 줄.push('');
147
+ 줄.push('하위가 한 말:');
148
+ // 자를 때는 자른 사실을 말한다. 조용히 자르면 부모는 그게 전부인 줄 안다.
149
+ 줄.push(말.length > 글자수 ? `${말.slice(0, 글자수)}\n …(길어서 여기까지만 옮겼습니다)` : 말);
150
+ }
151
+
152
+ return 줄.join('\n');
153
+ }