deel-local-cli 1.1.0 → 1.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,326 @@
1
+ /**
2
+ * 만든 웹을 그 자리에서 띄워 본다.
3
+ *
4
+ * ── 왜 만드나 ───────────────────────────────────────────────────────────
5
+ *
6
+ * 웹을 하나 만들어 놓고 눈으로 보려면 여태 이랬다 —
7
+ * · 파일을 두 번 눌러 연다 → file:// 라 fetch·모듈·WebGL 텍스처가 막힌다
8
+ * · GitHub Pages 에 올린다 → 한 글자 고칠 때마다 커밋·푸시·기다림
9
+ * · 아무 정적 서버나 깐다 → 미승인 SW 반입 금지에 걸린다
10
+ *
11
+ * Node 에 http 가 들어 있으므로 셋 다 필요 없다. 의존성은 그대로 0개다.
12
+ *
13
+ * ── 무엇을 조심했나 ─────────────────────────────────────────────────────
14
+ *
15
+ * 서버를 띄운다는 것은 **내 디스크를 남에게 열어 주는 일**이다. 그래서
16
+ *
17
+ * 1. 127.0.0.1 에만 묶는다. 0.0.0.0 은 아예 못 쓰게 해 뒀다 —
18
+ * 같은 사무실 망에서 아무나 내 소스를 읽게 된다.
19
+ * 2. 포트는 0(커널이 빈 것을 준다). 고정 포트는 남이 쓰던 것을 뺏는다.
20
+ * 3. 경로는 guard 의 scope 로만 푼다. `../`·심볼릭 링크·정션을 이미
21
+ * 거기서 막고 있으므로 여기서 또 짜지 않는다. 두 벌이 되면 한쪽만 고쳐진다.
22
+ * 4. 파일을 **주기만** 한다. PUT·POST·DELETE 는 받지 않는다.
23
+ */
24
+ import { createServer } from 'node:http';
25
+ import { createReadStream, statSync, existsSync, readdirSync, watch } from 'node:fs';
26
+ import { join, extname, relative, sep } from 'node:path';
27
+ import { spawn } from 'node:child_process';
28
+
29
+ // 확장자 → 형식. 없는 것은 그냥 내려받게 둔다.
30
+ // glb·gltf·wasm 을 넣어 뒀다 — 형식이 틀리면 Three.js 가 조용히 아무것도 안 그린다.
31
+ const 형식 = {
32
+ '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8',
33
+ '.css': 'text/css; charset=utf-8',
34
+ '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
35
+ '.json': 'application/json; charset=utf-8', '.map': 'application/json; charset=utf-8',
36
+ '.txt': 'text/plain; charset=utf-8', '.md': 'text/plain; charset=utf-8',
37
+ '.csv': 'text/csv; charset=utf-8',
38
+ '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon',
39
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
40
+ '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf',
41
+ '.mp4': 'video/mp4', '.webm': 'video/webm', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
42
+ '.wasm': 'application/wasm',
43
+ '.glb': 'model/gltf-binary', '.gltf': 'model/gltf+json',
44
+ '.pdf': 'application/pdf',
45
+ };
46
+
47
+ // 고친 것이 바로 보이게 하는 조각. HTML 에만 끼워 넣는다.
48
+ //
49
+ // 파일을 고치면 화면이 저절로 새로 뜬다. 이게 없으면 결국 브라우저를 손으로
50
+ // 새로 고치게 되고, 그러면 굳이 여기서 띄울 이유가 절반은 사라진다.
51
+ const 되살림 = `
52
+ <script>/* deel 미리보기 — 파일이 바뀌면 새로 뜬다 */
53
+ (function(){try{
54
+ var s=new EventSource('/__deel__/살아있나');
55
+ s.onmessage=function(e){ if(e.data==='다시')location.reload(); };
56
+ // 서버가 꺼지면 EventSource 가 계속 다시 붙으려 한다. 조용히 놔둔다 —
57
+ // deel 을 다시 띄우면 그대로 이어 붙어서 화면을 새로 고치지 않아도 된다.
58
+ }catch(err){}})();
59
+ </script>`;
60
+
61
+ const 안전한경로 = (u) => {
62
+ // %00 이나 널바이트가 섞이면 아래 fs 가 경로를 잘라 읽는다. 통째로 거절한다.
63
+ let p;
64
+ try { p = decodeURIComponent(String(u).split('?')[0].split('#')[0]); } catch { return null; }
65
+ if (p.includes('\0')) return null;
66
+ return p.replace(/^\/+/, '');
67
+ };
68
+
69
+ /**
70
+ * 폴더 하나를 띄운다.
71
+ *
72
+ * @param {object} o
73
+ * @param {string} o.뿌리 띄울 폴더 (이미 scope 로 푼 절대 경로여야 한다)
74
+ * @param {object} o.scope guard 의 makeScope 결과. 경로는 전부 이걸로 푼다.
75
+ * @param {boolean} [o.되살리기=true] 파일이 바뀌면 새로 뜨게 할지
76
+ * @returns {Promise<{url:string, port:number, 닫기:Function, 바뀐수:Function}>}
77
+ */
78
+ export function 띄우기({ 뿌리, scope, 되살리기 = true }) {
79
+ const 듣는이들 = new Set(); // 살아 있는 EventSource 응답들
80
+ let 바뀐수 = 0;
81
+ let 감시 = null;
82
+ let 늦추기 = null;
83
+
84
+ const 알리기 = () => {
85
+ 바뀐수 += 1;
86
+ for (const res of 듣는이들) {
87
+ try { res.write('data: 다시\n\n'); } catch { 듣는이들.delete(res); }
88
+ }
89
+ };
90
+
91
+ const srv = createServer((req, res) => {
92
+ // 주기만 한다. 받는 길은 아예 열지 않는다.
93
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
94
+ res.writeHead(405, { allow: 'GET, HEAD' });
95
+ return res.end('GET 만 받습니다');
96
+ }
97
+
98
+ const 길 = 안전한경로(req.url);
99
+ if (길 === null) { res.writeHead(400); return res.end('주소가 이상합니다'); }
100
+
101
+ // 되살림 통로. 열어 두고 파일이 바뀔 때마다 한 줄씩 흘린다.
102
+ if (길 === '__deel__/살아있나') {
103
+ res.writeHead(200, {
104
+ 'content-type': 'text/event-stream; charset=utf-8',
105
+ 'cache-control': 'no-cache',
106
+ connection: 'keep-alive',
107
+ });
108
+ res.write(': 붙었습니다\n\n');
109
+ 듣는이들.add(res);
110
+ req.on('close', () => 듣는이들.delete(res));
111
+ return undefined;
112
+ }
113
+
114
+ let abs;
115
+ try {
116
+ abs = scope.resolve(길 ? join(뿌리, 길) : 뿌리);
117
+ // scope 는 작업 범위까지만 본다. 띄운 폴더 밖도 막아야 한다 —
118
+ // 아니면 /../다른폴더/비밀.env 로 프로젝트 전체가 열린다.
119
+ const 안쪽 = relative(뿌리, abs);
120
+ if (안쪽.startsWith('..') || (안쪽 !== '' && 안쪽.split(sep)[0] === '..')) {
121
+ res.writeHead(403); return res.end('띄운 폴더 밖입니다');
122
+ }
123
+ } catch {
124
+ res.writeHead(403);
125
+ return res.end('작업 범위 밖입니다');
126
+ }
127
+
128
+ if (!existsSync(abs)) {
129
+ /*
130
+ * 주소로 화면을 나누는 앱(React Router·Vue Router 같은 것)을 위한 길.
131
+ *
132
+ * `/설정` 같은 주소는 디스크에 파일이 없다. 첫 화면에서 눌러 들어가면
133
+ * 되는데 **거기서 새로고침을 하면 404** 가 뜬다. 만든 사람은 "내 앱이
134
+ * 깨졌나" 싶지만 앱은 멀쩡하고 서버가 모르는 것뿐이다.
135
+ *
136
+ * 그래서 확장자가 없고 HTML 을 달라는 요청이면 첫 장을 준다.
137
+ * 확장자가 있는 것(app.js·a.css)에는 절대 안 한다 — 없는 스크립트에
138
+ * HTML 을 돌려주면 브라우저가 "Unexpected token '<'" 로 죽는데,
139
+ * 그게 진짜 원인(파일 이름 오타)을 완전히 가린다.
140
+ */
141
+ const 첫장 = join(뿌리, 'index.html');
142
+ const 확장자없나 = !extname(길);
143
+ const html달라나 = String(req.headers.accept ?? '').includes('text/html');
144
+ if (확장자없나 && html달라나 && existsSync(첫장)) return 파일주기(첫장, req, res, 되살리기);
145
+ res.writeHead(404);
146
+ return res.end('없는 파일입니다');
147
+ }
148
+
149
+ let st;
150
+ try { st = statSync(abs); } catch { res.writeHead(404); return res.end('못 읽습니다'); }
151
+
152
+ if (st.isDirectory()) {
153
+ /*
154
+ * 끝에 슬래시가 없으면 **반드시** 붙여서 다시 보낸다.
155
+ *
156
+ * 이걸 안 하면 안엣것이 통째로 깨진다. `/docs` 로 들어오면 index.html 은
157
+ * 나오는데, 그 안의 `./app.js` 를 브라우저가 `/app.js` 로 푼다 —
158
+ * 실제로는 `/docs/app.js` 인데 404 가 난다.
159
+ * 화면은 흰데 오류는 콘솔에만 있어서 "왜 안 되지" 로 한참 헤매게 된다.
160
+ */
161
+ if (길 !== '' && !req.url.split('?')[0].endsWith('/')) {
162
+ const [, 물음표] = req.url.split(/(\?.*)$/);
163
+ // 헤더는 ASCII 만 실린다. 한글 폴더 이름을 그대로 넣으면 ERR_INVALID_CHAR 로
164
+ // **서버가 죽는다.** 미리보기 하나 켰다가 deel 이 통째로 끝나는 셈이다.
165
+ // 길은 이미 디코드된 값이므로 다시 인코드해서 싣는다.
166
+ res.writeHead(301, { location: `${encodeURI(`/${길}/`)}${물음표 ?? ''}` });
167
+ return res.end();
168
+ }
169
+ const 첫장 = join(abs, 'index.html');
170
+ if (existsSync(첫장)) return 파일주기(첫장, req, res, 되살리기);
171
+ return 목록주기(abs, 뿌리, res);
172
+ }
173
+ return 파일주기(abs, req, res, 되살리기);
174
+ });
175
+
176
+ return new Promise((done, fail) => {
177
+ srv.on('error', fail);
178
+ // 127.0.0.1 만. 여기를 0.0.0.0 으로 바꾸면 같은 망의 아무나 내 소스를 읽는다.
179
+ srv.listen(0, '127.0.0.1', () => {
180
+ const port = srv.address().port;
181
+
182
+ if (되살리기) {
183
+ try {
184
+ 감시 = watch(뿌리, { recursive: true }, () => {
185
+ // 저장 한 번에 이벤트가 여러 개 온다. 몰아서 한 번만 알린다.
186
+ clearTimeout(늦추기);
187
+ 늦추기 = setTimeout(알리기, 120);
188
+ // 이 시계가 프로그램을 붙잡고 있으면 안 된다.
189
+ 늦추기.unref?.();
190
+ });
191
+ } catch {
192
+ // 리눅스 옛 커널 등에서 recursive 가 안 될 수 있다. 그때는 되살림만 없다.
193
+ 감시 = null;
194
+ }
195
+ 감시?.unref?.();
196
+ }
197
+
198
+ done({
199
+ url: `http://127.0.0.1:${port}/`,
200
+ port,
201
+ 되살아나나: !!감시,
202
+ 바뀐수: () => 바뀐수,
203
+ 듣는수: () => 듣는이들.size,
204
+ 닫기() {
205
+ clearTimeout(늦추기);
206
+ try { 감시?.close(); } catch { /* 이미 닫혔다 */ }
207
+ // 열어 둔 통로를 안 끊으면 서버가 안 닫힌다 — 프로그램이 안 끝난다.
208
+ for (const r of 듣는이들) { try { r.end(); } catch { /* 그만 */ } }
209
+ 듣는이들.clear();
210
+ return new Promise((r) => srv.close(r));
211
+ },
212
+ });
213
+ });
214
+ });
215
+ }
216
+
217
+ function 파일주기(abs, req, res, 되살리기) {
218
+ const ext = extname(abs).toLowerCase();
219
+ const mime = 형식[ext] ?? 'application/octet-stream';
220
+ let st;
221
+ try { st = statSync(abs); } catch { res.writeHead(404); return res.end(); }
222
+
223
+ // HTML 에는 되살림 조각을 끼워 넣는다. 길이가 달라지므로 흘려보내지 않고 통째로 읽는다.
224
+ if (되살리기 && (ext === '.html' || ext === '.htm')) {
225
+ let 글 = '';
226
+ const s = createReadStream(abs, 'utf8');
227
+ s.on('data', (d) => { 글 += d; });
228
+ s.on('error', () => { res.writeHead(500); res.end(); });
229
+ s.on('end', () => {
230
+ // </body> 앞에 넣는 게 정석이지만 없는 문서도 많다. 없으면 그냥 뒤에 붙인다.
231
+ const 몸 = 글.includes('</body>') ? 글.replace('</body>', `${되살림}\n</body>`) : 글 + 되살림;
232
+ const buf = Buffer.from(몸, 'utf8');
233
+ res.writeHead(200, { 'content-type': mime, 'content-length': buf.length, 'cache-control': 'no-store' });
234
+ res.end(req.method === 'HEAD' ? undefined : buf);
235
+ });
236
+ return undefined;
237
+ }
238
+
239
+ /*
240
+ * 동영상·소리는 브라우저가 조각으로 달라고 한다(Range).
241
+ * 이걸 안 받아 주면 <video> 가 아예 안 돈다 — 파일은 멀쩡한데 화면만 검다.
242
+ */
243
+ const range = req.headers.range;
244
+ const m = range && /^bytes=(\d*)-(\d*)$/.exec(String(range).trim());
245
+ if (m && st.size > 0) {
246
+ let 처음 = m[1] === '' ? null : Number(m[1]);
247
+ let 끝 = m[2] === '' ? null : Number(m[2]);
248
+ if (처음 === null && 끝 !== null) { 처음 = Math.max(0, st.size - 끝); 끝 = st.size - 1; }
249
+ else { 처음 = 처음 ?? 0; 끝 = 끝 === null ? st.size - 1 : Math.min(끝, st.size - 1); }
250
+ if (!Number.isFinite(처음) || !Number.isFinite(끝) || 처음 > 끝 || 처음 >= st.size) {
251
+ res.writeHead(416, { 'content-range': `bytes */${st.size}` });
252
+ return res.end();
253
+ }
254
+ res.writeHead(206, {
255
+ 'content-type': mime,
256
+ 'content-range': `bytes ${처음}-${끝}/${st.size}`,
257
+ 'accept-ranges': 'bytes',
258
+ 'content-length': 끝 - 처음 + 1,
259
+ });
260
+ if (req.method === 'HEAD') return res.end();
261
+ return createReadStream(abs, { start: 처음, end: 끝 }).pipe(res);
262
+ }
263
+
264
+ res.writeHead(200, {
265
+ 'content-type': mime,
266
+ 'content-length': st.size,
267
+ 'accept-ranges': 'bytes',
268
+ // 고친 것이 바로 보여야 한다. 미리보기에서 캐시는 도움이 안 된다.
269
+ 'cache-control': 'no-store',
270
+ });
271
+ if (req.method === 'HEAD') return res.end();
272
+ return createReadStream(abs).pipe(res);
273
+ }
274
+
275
+ // index.html 이 없는 폴더. 무엇이 있는지라도 보여 준다 — 흰 화면보다 낫다.
276
+ function 목록주기(abs, 뿌리, res) {
277
+ let 것들 = [];
278
+ try { 것들 = readdirSync(abs, { withFileTypes: true }); } catch { /* 못 읽으면 빈 채로 */ }
279
+ const 여기 = relative(뿌리, abs).split(sep).join('/');
280
+ const 줄 = (e) => {
281
+ const 이름 = e.name + (e.isDirectory() ? '/' : '');
282
+ return `<li><a href="${encodeURIComponent(e.name)}${e.isDirectory() ? '/' : ''}">${이름}</a></li>`;
283
+ };
284
+ const 몸 = `<!doctype html><meta charset="utf-8"><title>${여기 || '/'}</title>`
285
+ + '<style>body{font:14px/1.7 ui-monospace,monospace;max-width:44rem;margin:3rem auto;padding:0 1rem}'
286
+ + 'h1{font-size:1rem;color:#888;font-weight:400}a{color:#0a7}li{list-style:none}'
287
+ + 'p{color:#999}@media(prefers-color-scheme:dark){body{background:#111;color:#ddd}a{color:#4d9}}</style>'
288
+ + `<h1>/${여기}</h1>`
289
+ + (여기 ? '<li><a href="../">../</a></li>' : '')
290
+ + 것들.map(줄).join('')
291
+ + '<p>index.html 이 없어서 목록을 보여 줍니다.</p>';
292
+ const buf = Buffer.from(몸, 'utf8');
293
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': buf.length });
294
+ res.end(buf);
295
+ }
296
+
297
+ /**
298
+ * 기본 브라우저로 연다.
299
+ *
300
+ * 실패해도 조용히 넘어간다 — 주소는 이미 화면에 찍혀 있으므로 손으로 열면 된다.
301
+ * 여기서 오류를 띄우면 '띄우기는 됐는데 실패한 것처럼' 보인다.
302
+ */
303
+ export function 브라우저로(url) {
304
+ /*
305
+ * 열면 안 되는 자리가 있다.
306
+ *
307
+ * 검사가 돌 때마다 진짜 브라우저 창이 뜨면 사람 화면이 난장판이 된다.
308
+ * 파이프로 넘길 때(로그·캡처)도 열 이유가 없다 — 볼 사람이 없다.
309
+ * 상자 검사는 isTTY 를 거짓말하게 만들어 자식을 띄우므로, TTY 만 봐서는
310
+ * 못 막는다. 그래서 env 로도 막을 수 있게 뒀고 검사 돌리개가 그걸 켠다.
311
+ */
312
+ if (process.env.DEEL_NO_OPEN) return false;
313
+ if (!process.stdout.isTTY) return false;
314
+ try {
315
+ const [cmd, args] = process.platform === 'win32'
316
+ // start 는 cmd 안엣말이다. 첫 따옴표 한 쌍은 창 제목 자리라 비워 둬야 한다.
317
+ ? ['cmd', ['/d', '/s', '/c', 'start', '', url]]
318
+ : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
319
+ const kid = spawn(cmd, args, { stdio: 'ignore', detached: process.platform !== 'win32', windowsHide: true });
320
+ kid.on('error', () => { /* 없으면 그만 */ });
321
+ kid.unref();
322
+ return true;
323
+ } catch {
324
+ return false;
325
+ }
326
+ }
package/src/repl.js CHANGED
@@ -6,7 +6,7 @@ import { c, say as 바로쓰기, mark, clip } from './ui/ansi.js';
6
6
  import { headerLines } from './ui/status.js';
7
7
  import { 화면고르기 } from './ui/screen.js';
8
8
  import { STAGES } from './agent/effort.js';
9
- import { handle, COMMANDS } from './commands.js';
9
+ import { handle, COMMANDS, 미리보기끄기 } from './commands.js';
10
10
  import { next as nextWork, get as getWork, canWrite } from './agent/modes.js';
11
11
  import { route } from './agent/route.js';
12
12
  import { run } from './agent/loop.js';
@@ -22,6 +22,7 @@ import { askHidden } from './ui/prompt.js';
22
22
  import { explain, shows as levelShows } from './ui/level.js';
23
23
  import { 고르기 as 승인고르기, 다음 as 승인다음 } from './ui/approve.js';
24
24
  import { 추천, 채울글 } from './ui/complete.js';
25
+ import { 접어쓰기 } from './ui/wrap.js';
25
26
  import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
26
27
  import { renderDiff, shortStat } from './ui/diff.js';
27
28
  import { expand as expandMentions } from './agent/mention.js';
@@ -625,21 +626,63 @@ export async function chatLoop(opts = {}) {
625
626
  prompt();
626
627
  });
627
628
 
629
+ /*
630
+ * 계획을 승인받은 뒤 **사람이 다시 치지 않아도** 이어서 할 말.
631
+ *
632
+ * 전에는 계획 모드가 모델에게 "승인을 받으면 /code 로 바꿔 실행한다" 고
633
+ * 시켜 놓고, 정작 승인받는 자리도 이어가는 길도 없었다. 사람이 /code 를
634
+ * 알아서 쳐야 이어졌다 — 모드 설명에는 '승인 뒤 실행' 이라고 적혀 있었는데.
635
+ * 코드가 안 하는 것을 화면이 약속하고 있었던 셈이다.
636
+ */
637
+ let 이어갈것 = null; // 이어서 보낼 말 (null 이면 사람 입력을 기다린다)
638
+ let 이어갈모드 = null; // 그때 쓸 작업 모드. 다시 고르지 않는다.
639
+
640
+ /*
641
+ * 계획을 한 눈에 보여 준다.
642
+ *
643
+ * 오른쪽 테두리를 안 그린다. 한글은 두 칸을 먹는데 이모지·기호는 아니어서
644
+ * 폭을 맞추려다 어긋나면 상자가 깨져 보인다 — 안 그리면 어긋날 것이 없다.
645
+ */
646
+ const 계획상자 = (할일) => {
647
+ const 폭 = Math.max(40, Math.min(88, (process.stdout.columns || 80) - 6));
648
+ say(` ${c.hcyan('┌')} ${c.bold('계획')}`);
649
+ if (!할일.length) {
650
+ // TodoWrite 를 안 쓴 계획도 있다. 그때 빈 상자를 그리면 계획이 없는 줄 안다.
651
+ say(` ${c.hcyan('│')} ${c.gray('단계가 따로 적히지 않았습니다 — 위에 적힌 계획을 봐 주세요.')}`);
652
+ } else {
653
+ 할일.forEach((t, i) => {
654
+ const 줄들 = 접어쓰기(String(t.text ?? ''), 폭 - 6);
655
+ 줄들.forEach((줄, j) => {
656
+ const 앞 = j === 0 ? c.gray(String(i + 1).padStart(2) + '.') : ' ';
657
+ say(` ${c.hcyan('│')} ${앞} ${c.white(줄)}`);
658
+ });
659
+ });
660
+ }
661
+ say(` ${c.hcyan('└')} ${c.gray(`${할일.length}단계 · ${session.root}`)}`);
662
+ };
663
+
628
664
  for (;;) {
629
- prompt();
630
- 입력기다림 = true;
631
- // 줄이 이미 쌓여 있으면 그건 **일하는 동안 미리 것**이다.
632
- // 그때는 대화에 안 찍었으니(찍으면 이미 보낸 것처럼 보인다) 지금 찍는다.
633
- const 예약이었나 = queue.length > 0;
634
- const line = await nextLine();
635
- 입력기다림 = false;
636
- if (line === null) break; // 입력이 끝났다 (파이프 종료 / Ctrl+D)
637
- interrupted = false;
638
- const text = line.trim();
639
- if (!text) continue;
640
- if (예약이었나 && 상자쓰나) {
641
- 화면.입력지움();
642
- say(` ${c.hcyan('❯')} ${c.white(text)} ${c.gray('(미리 )')}`);
665
+ let text;
666
+ if (이어갈것 !== null) {
667
+ // 승인받아 이어가는 자리. 사람이 것이 아니므로 찍지 않는다.
668
+ text = 이어갈것;
669
+ 이어갈것 = null;
670
+ } else {
671
+ prompt();
672
+ 입력기다림 = true;
673
+ // 줄이 이미 쌓여 있으면 그건 **일하는 동안 미리 쳐 둔 것**이다.
674
+ // 그때는 대화에 안 찍었으니(찍으면 이미 보낸 것처럼 보인다) 지금 찍는다.
675
+ const 예약이었나 = queue.length > 0;
676
+ const line = await nextLine();
677
+ 입력기다림 = false;
678
+ if (line === null) break; // 입력이 끝났다 (파이프 종료 / Ctrl+D)
679
+ interrupted = false;
680
+ text = line.trim();
681
+ if (!text) continue;
682
+ if (예약이었나 && 상자쓰나) {
683
+ 화면.입력지움();
684
+ say(` ${c.hcyan('❯')} ${c.white(text)} ${c.gray('(미리 쳐 둔 것)')}`);
685
+ }
643
686
  }
644
687
 
645
688
  const cmd = await handle(text, session, ctx);
@@ -672,16 +715,31 @@ export async function chatLoop(opts = {}) {
672
715
  // 기본 모드는 안 건드린다 — 다음 한마디는 다시 처음부터 고른다.
673
716
  // 사용자가 직접 고른 모드가 있으면 여기 안 들어온다. 사람이 고른 것을 뒤집지 않는다.
674
717
  session.routed = null;
675
- if (session.work === 'auto') {
718
+ // 계획을 내고 승인을 받아야 하는 턴인가. 턴이 끝난 뒤 승인 창을 띄운다.
719
+ let 계획승인받나 = false;
720
+ if (이어갈모드) {
721
+ // 방금 승인받은 계획을 그대로 잇는 자리. 여기서 다시 고르면 안 된다 —
722
+ // "위 계획대로 진행해라" 에는 '계획' 이 들어 있어서 또 계획 모드로 간다.
723
+ session.routed = 이어갈모드;
724
+ 이어갈모드 = null;
725
+ } else if (session.work === 'auto') {
676
726
  const 골라진 = route(toSend);
677
727
  if (골라진.mode) {
678
728
  session.routed = 골라진.mode;
729
+ 계획승인받나 = 골라진.겹침 === true;
679
730
  const w = getWork(골라진.mode);
680
731
  say('');
681
732
  say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')}`
682
733
  + ` ${c.gray('말 속에 ' + 골라진.why + ' 가 있어서')}`
683
734
  + (canWrite(골라진.mode) ? '' : ` ${c.green('· 파일은 안 바꿉니다')}`));
684
- say(` ${c.gray('다르면')} ${c.cyan('/code')} ${c.gray('처럼 직접 고르세요. 그때부터는 바뀝니다.')}`);
735
+ // 겹친 요청은 '파일을 안 바꾼다' 끝나면된다. 시킨 일의 절반만 한 것이다.
736
+ // 그래서 여기서 뒷 절반이 온다는 것을 미리 말해 준다.
737
+ if (계획승인받나) {
738
+ say(` ${c.gray('계획과 실행이 같이 있어')} ${c.white('계획부터')} ${c.gray('냅니다.')}`
739
+ + ` ${c.gray('보시고 승인하면')} ${c.white('그대로 이어서')} ${c.gray('합니다.')}`);
740
+ } else {
741
+ say(` ${c.gray('다르면')} ${c.cyan('/code')} ${c.gray('처럼 직접 고르세요. 그때부터는 안 바뀝니다.')}`);
742
+ }
685
743
  }
686
744
  }
687
745
 
@@ -696,6 +754,9 @@ export async function chatLoop(opts = {}) {
696
754
  saved = session.messages.length;
697
755
  };
698
756
  let tools = 0;
757
+ // 이 턴이 탈 없이 끝났나. 끊겼거나 터진 뒤에 승인 창을 띄우면 안 된다 —
758
+ // 계획이 반만 나온 것을 두고 "이대로 진행할까요?" 를 묻는 꼴이 된다.
759
+ let 턴탈났나 = false;
699
760
  let thinkChars = 0;
700
761
  let streamed = false;
701
762
  let thinkingShown = false;
@@ -970,6 +1031,7 @@ export async function chatLoop(opts = {}) {
970
1031
 
971
1032
  // 같은 자리를 계속 반복하고 있다. 두면 컨텍스트만 차고 아무것도 안 나온다.
972
1033
  case 'stuck':
1034
+ 턴탈났나 = true;
973
1035
  clearThinking();
974
1036
  if (streamed) { say(''); streamed = false; }
975
1037
  say('');
@@ -991,6 +1053,7 @@ export async function chatLoop(opts = {}) {
991
1053
  break;
992
1054
 
993
1055
  case 'aborted':
1056
+ 턴탈났나 = true;
994
1057
  clearThinking();
995
1058
  if (streamed) { say(''); streamed = false; }
996
1059
  say('');
@@ -1003,6 +1066,7 @@ export async function chatLoop(opts = {}) {
1003
1066
  break;
1004
1067
 
1005
1068
  case 'error':
1069
+ 턴탈났나 = true;
1006
1070
  clearThinking();
1007
1071
  say('');
1008
1072
  오류보이기(ev.text);
@@ -1016,6 +1080,7 @@ export async function chatLoop(opts = {}) {
1016
1080
  }
1017
1081
  }
1018
1082
  } catch (err) {
1083
+ 턴탈났나 = true;
1019
1084
  접기멈춤();
1020
1085
  clearThinking();
1021
1086
  say('');
@@ -1038,6 +1103,45 @@ export async function chatLoop(opts = {}) {
1038
1103
  if (dIn || dOut) bits.push(`↑${dIn.toLocaleString()} ↓${dOut.toLocaleString()}`);
1039
1104
  say('');
1040
1105
  say(` ${c.gray('─'.repeat(2))} ${c.gray(bits.join(c.gray(' · ')))}`);
1106
+
1107
+ /*
1108
+ * ── 계획 승인 ──────────────────────────────────────────────────────
1109
+ *
1110
+ * 겹친 요청("계획해주고 만들어줘")일 때만 온다. 그냥 "고쳐줘" 에는 안 뜬다 —
1111
+ * 이 프로젝트는 승인 게이트 대신 되돌리기로 가기로 했고, 그 결정을
1112
+ * 아무 때나 뜨는 창으로 갉아먹으면 안 된다.
1113
+ *
1114
+ * ⏎ 하나로 진행되는 것이 중요하다. 여기서 뭘 더 치게 하면 사람은
1115
+ * 애초에 계획 같은 걸 안 보려 든다.
1116
+ */
1117
+ if (계획승인받나 && !턴탈났나) {
1118
+ const 할일 = (ctx.todos ?? []).filter((t) => t.state !== 'done');
1119
+ say('');
1120
+ 계획상자(할일);
1121
+ say('');
1122
+ const 답 = String(await ask(
1123
+ `이대로 진행할까요? ${c.gray('⏎ 진행 · n 취소 · 그 밖엔 고칠 점')}`,
1124
+ { def: 'y' },
1125
+ )).trim();
1126
+ const 낮춘 = 답.toLowerCase();
1127
+
1128
+ if (['n', 'no', 'ㄴ', '취소', '아니', '아니요', '아니오'].includes(낮춘)) {
1129
+ say(` ${c.gray('그만뒀습니다. 계획은 위에 남아 있으니 이어서 말씀하셔도 됩니다.')}`);
1130
+ } else if (['y', 'yes', 'ㅇ', 'ㅇㅇ', 'ㄱ', 'ㄱㄱ', '네', '응', '진행', 'ok'].includes(낮춘)) {
1131
+ // 계획을 다시 적으라고 하면 두 번 적는다. 컨텍스트만 먹고 사람은 같은 글을 두 번 본다.
1132
+ 이어갈것 = '위 계획을 승인받았다. 계획을 다시 적지 말고 지금부터 그대로 실행해라.'
1133
+ + ' 적어 둔 단계를 하나씩 끝내고, 다 끝나면 무엇을 만들었는지만 짧게 알려라.';
1134
+ 이어갈모드 = 'code';
1135
+ say('');
1136
+ say(` ${c.hgreen('▶')} ${c.gray('계획대로 진행합니다.')}`);
1137
+ } else {
1138
+ // 'e' 를 따로 두지 않는다 — 고칠 점을 바로 치는 것이 한 걸음 짧다.
1139
+ 이어갈것 = `계획에서 이걸 고쳐서 **계획만** 다시 내라 (아직 실행하지 마라): ${답}`;
1140
+ 이어갈모드 = 'plan';
1141
+ say('');
1142
+ say(` ${c.hcyan('☰')} ${c.gray('그 점을 반영해 계획을 다시 냅니다.')}`);
1143
+ }
1144
+ }
1041
1145
  }
1042
1146
 
1043
1147
  rl.close();
@@ -1051,6 +1155,12 @@ export async function chatLoop(opts = {}) {
1051
1155
  const 껐다 = 일감모두끝내기();
1052
1156
  if (껐다) say(` ${mark.ok} ${c.gray(`뒤에서 돌던 명령 ${껐다}개를 같이 껐습니다.`)}`);
1053
1157
  }
1158
+ // 미리보기 서버도 같은 이유로 거둔다. 안 거두면 포트를 물고 있는 채로 남아,
1159
+ // 다음에 띄운 것과 두 개가 뜬다 — 어느 쪽을 보고 있는지 알 수 없게 된다.
1160
+ {
1161
+ const 껐다 = await 미리보기끄기();
1162
+ if (껐다) say(` ${mark.ok} ${c.gray(`미리보기를 껐습니다 (${껐다.서버.url}).`)}`);
1163
+ }
1054
1164
  // 끝맺음은 화면을 접기 **전에** 그린다. close() 가 상자를 걷어내므로,
1055
1165
  // 그 뒤에 찍으면 걷어낸 자리에 뜬금없이 한 줄이 남는다.
1056
1166
  say('');
@@ -0,0 +1,64 @@
1
+ ---
2
+ name: 검사-먼저
3
+ description: 고치기 전에 실패하는 검사부터 만들기. 결함·새 기능에 쓴다
4
+ ---
5
+
6
+ # 검사 먼저
7
+
8
+ ## 왜 있나
9
+
10
+ "고쳤습니다" 는 증명이 아니다. 무엇이 어떻게 틀렸는지를 **먼저 못 박아
11
+ 두지 않으면**, 고친 뒤에 통과하는 것이 원래부터 통과하던 것인지 알 수 없다.
12
+
13
+ 실제로 겪는 순서는 이렇다. 검사를 안 만들고 고친다 → 통과한다 → 사실은
14
+ 그 자리를 한 번도 안 밟는 검사였다 → 결함이 그대로 나간다.
15
+
16
+ ## 순서
17
+
18
+ ### 1. 빨강 — 실패하는 것을 먼저 만든다
19
+
20
+ - 지금 코드로 **반드시 실패해야 한다.** 통과하면 그 검사는 아무것도 안 잰다
21
+ - 실패하는 것을 눈으로 확인한다. 이 단계를 건너뛰면 아무 의미가 없다
22
+ - 이름은 무엇이 잘못됐는지 말해야 한다.
23
+ `'끝내기가 돌아왔으면 이미 죽어 있다'` 가 `'jobs test 3'` 보다 낫다
24
+
25
+ ### 2. 초록 — 통과할 만큼만 고친다
26
+
27
+ - 통과에 필요한 것만. 김에 다른 것까지 손보지 않는다
28
+ - 통과하면 멈춘다
29
+
30
+ ### 3. 다시 본다
31
+
32
+ - 전체 검사를 돌린다. 다른 것이 깨졌으면 아직 안 끝난 것이다
33
+ - 검사가 우연히 통과하는 것은 아닌지 본다 — **일부러 코드를 되돌려 보고
34
+ 다시 실패하는지** 확인하면 확실하다
35
+
36
+ ## 무엇을 재야 하나
37
+
38
+ **말이 아니라 결과물을 잰다.**
39
+
40
+ - ✗ 화면에 "만들었습니다" 가 떴다
41
+ - ✓ 파일이 실제로 생겼고 내용이 맞다
42
+
43
+ - ✗ 함수가 오류를 안 던졌다
44
+ - ✓ 프로세스가 정말 죽었다 (커 가던 표시 파일이 더 이상 안 자란다)
45
+
46
+ ### 안 되어야 하는 것도 같은 무게로 잰다
47
+
48
+ 되는 것만 재면 절반이다. 승인 창이 **떠야 할 때** 뜨는 것만 재고 **안 떠야
49
+ 할 때** 안 뜨는 것을 안 재면, 아무 때나 뜨는 것을 못 잡는다.
50
+
51
+ ## 검사가 못 미더울 때
52
+
53
+ - 검사가 바깥 도구(tar·unzip·python)에 기대면, 그게 없는 자리에서 조용히
54
+ 건너뛰어진다. **건너뛴 것을 반드시 화면에 남긴다**
55
+ - 서버를 띄우는 검사는 **포트 0**(커널이 빈 포트를 준다)으로 띄운다.
56
+ 고정 포트는 남의 것을 뺏는다
57
+ - 임시 폴더는 매번 새로 만들고 끝나면 지운다. 진짜 설정 폴더를 안 건드린다
58
+
59
+ ## 하지 말 것
60
+
61
+ - 고친 뒤에 검사 만들기 — 그 검사는 십중팔구 그냥 통과한다
62
+ - 실패를 안 보고 넘어가기
63
+ - 검사를 통과시키려고 검사를 고치기 (원인을 봐야 한다)
64
+ - 재기 어렵다고 안 재기 — 못 재면 **못 잰다고 적어 둔다**