deel-local-cli 1.6.1 → 1.6.3

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.
@@ -134,12 +134,16 @@ export const 도구설명EN = {
134
134
  },
135
135
  },
136
136
  Ask: {
137
- desc: 'Ask the person one question at a genuine fork. Never write "let me know" and stop;'
138
- + ' asking in prose ends the turn and throws away everything you have looked at.'
139
- + ' Give 2-4 options and they answer with a single number.'
137
+ desc: 'Ask the person one question at a genuine fork. **First fill in `이해` with what you'
138
+ + ' understood this request to be**, and only ask if something is genuinely left to decide.'
139
+ + ' Never write "let me know" and stop; asking in prose ends the turn and throws away'
140
+ + ' everything you have looked at. Give 2-4 options and they answer with a single number.'
140
141
  + ' **Never ask back something the user already said.** "Tidy up the files" is the answer —'
141
142
  + ' go and do it. Deciding how is your job, not theirs.',
142
143
  params: {
144
+ 이해: 'one line: what you understood this request to be. The person reads this line to judge'
145
+ + ' whether you actually read them. "I understand your request" is not an understanding —'
146
+ + ' name the actual work',
143
147
  question: 'one sentence. Make it clear what has to be decided',
144
148
  options: '2-4 choices, one line each, so the difference is visible',
145
149
  },
@@ -24,6 +24,7 @@
24
24
  // 결과가 없는 것이 아니라 우리가 못 물어본 것이다. 그때 '일치 없음' 을
25
25
  // 돌려주면 사람은 없는 줄 안다. 그래서 실패는 실패로 두고 JS 로 다시 찾는다.
26
26
  import { spawnSync } from 'node:child_process';
27
+ import { 돌려보기 } from './spawn.js';
27
28
  import { join } from 'node:path';
28
29
 
29
30
  /*
@@ -99,7 +100,7 @@ export function 저장소인가(폴더) {
99
100
  *
100
101
  * @returns {{ok:true, 줄들:string[], 잘림:boolean} | {ok:false, 왜:string}}
101
102
  */
102
- export function rg로찾기({ 무늬, 자리, glob = null, 대소문자무시 = false, 무시파일 = null, 최대 = 5000, timeout = 20000 }) {
103
+ export async function rg로찾기({ 무늬, 자리, glob = null, 대소문자무시 = false, 무시파일 = null, 최대 = 5000, timeout = 20000, signal = null }) {
103
104
  const 인자 = [
104
105
  '--line-number',
105
106
  '--no-heading',
@@ -132,9 +133,7 @@ export function rg로찾기({ 무늬, 자리, glob = null, 대소문자무시 =
132
133
  // `--` 뒤로 넘겨서 무늬가 옵션으로 안 읽히게 한다. `-foo` 같은 무늬가 실제로 있다.
133
134
  인자.push('--regexp', 무늬, '--', 자리);
134
135
 
135
- const r = spawnSync('rg', 인자, {
136
- encoding: 'utf8', timeout, windowsHide: true, maxBuffer: 32 * 1024 * 1024,
137
- });
136
+ const r = await 돌려보기('rg', 인자, { timeout, signal });
138
137
  // rg 는 못 찾으면 1, 진짜 탈이 나면 2 를 준다. 1 은 성공(빈 결과)이다.
139
138
  if (r.error) return { ok: false, 왜: r.error.message };
140
139
  if (r.status === 2) return { ok: false, 왜: (r.stderr ?? '').split('\n')[0] || 'rg 가 무늬를 못 읽었습니다' };
@@ -143,14 +142,12 @@ export function rg로찾기({ 무늬, 자리, glob = null, 대소문자무시 =
143
142
  }
144
143
 
145
144
  /** `git grep` 으로 찾는다. rg 가 없고 여기가 저장소일 때. */
146
- export function git로찾기({ 무늬, 자리, 대소문자무시 = false, 최대 = 5000, timeout = 20000 }) {
145
+ export async function git로찾기({ 무늬, 자리, 대소문자무시 = false, 최대 = 5000, timeout = 20000, signal = null }) {
147
146
  const 인자 = ['-C', 자리, '--no-pager', 'grep', '--line-number', '--no-color', '-I', '-E'];
148
147
  if (대소문자무시) 인자.push('-i');
149
148
  // 여기도 같은 목록으로 뺀다. git 은 경로무늬 앞머리에 마법을 붙여 빼낸다.
150
149
  인자.push('-e', 무늬, '--', '.', ...안볼확장자.map((x) => `:(exclude,icase)*.${x}`));
151
- const r = spawnSync('git', 인자, {
152
- encoding: 'utf8', timeout, windowsHide: true, maxBuffer: 32 * 1024 * 1024,
153
- });
150
+ const r = await 돌려보기('git', 인자, { timeout, signal });
154
151
  if (r.error) return { ok: false, 왜: r.error.message };
155
152
  if (r.status !== 0 && r.status !== 1) {
156
153
  return { ok: false, 왜: (r.stderr ?? '').split('\n')[0] || 'git grep 이 무늬를 못 읽었습니다' };
@@ -191,10 +188,10 @@ export function 줄가르기(줄) {
191
188
  *
192
189
  * @returns {{엔진:string, 줄들:Array<{파일,줄,내용}>, 잘림:boolean} | null}
193
190
  */
194
- export function 빠르게찾기({ 무늬, 자리, glob = null, 대소문자무시 = false, 무시파일 = null, 최대 = 5000 }) {
191
+ export async function 빠르게찾기({ 무늬, 자리, glob = null, 대소문자무시 = false, 무시파일 = null, 최대 = 5000, signal = null }) {
195
192
  const 것 = 엔진찾기();
196
193
  if (것.rg) {
197
- const r = rg로찾기({ 무늬, 자리, glob, 대소문자무시, 무시파일, 최대 });
194
+ const r = await rg로찾기({ 무늬, 자리, glob, 대소문자무시, 무시파일, 최대, signal });
198
195
  if (r.ok) return { 엔진: 'rg', 줄들: r.줄들.map(줄가르기).filter(Boolean), 잘림: r.잘림 };
199
196
  // rg 가 무늬를 못 읽은 것일 수 있다(Rust regex 에는 되돌아보기가 없다).
200
197
  // 그건 '없다' 가 아니라 '못 물어봤다' 이므로 예전 길로 내려간다.
@@ -203,7 +200,7 @@ export function 빠르게찾기({ 무늬, 자리, glob = null, 대소문자무
203
200
  // git grep 은 .deelignore 를 시킬 방법이 없다. 그 파일이 있으면 예전 길로 간다 —
204
201
  // 사람이 "deel 은 여기 보지 마라" 고 적어 둔 것을 못 지키면 빠른 것이 뜻이 없다.
205
202
  if (것.gitgrep && !무시파일 && 저장소인가(자리)) {
206
- const r = git로찾기({ 무늬, 자리, 대소문자무시, 최대 });
203
+ const r = await git로찾기({ 무늬, 자리, 대소문자무시, 최대, signal });
207
204
  if (r.ok) return { 엔진: 'git grep', 줄들: r.줄들.map(줄가르기).filter(Boolean), 잘림: r.잘림 };
208
205
  return null;
209
206
  }
@@ -175,11 +175,22 @@ export function 훑기상한(env = process.env) {
175
175
  return Math.floor(n);
176
176
  }
177
177
 
178
- export function walk(root, { limit = 훑기상한(), skipDirs = SKIP_DIRS, ignore = true } = {}) {
178
+ /**
179
+ * 폴더를 훑는다.
180
+ *
181
+ * @param {AbortSignal|null} signal 멈추라면 훑다 말고 나온다.
182
+ * 사내망 드라이브에서는 파일 20,000개 훑기가 몇 초다. 그 사이에
183
+ * ESC 를 눌렀는데 끝까지 다 훑고 나서 멈추면, 사람 눈에는 멈추지
184
+ * 않는 것으로 보인다. 나온 것에 `끊김` 을 달아 부르는 쪽이 알게 한다 —
185
+ * 조용히 적게 돌려주면 「그런 파일이 없다」가 되어 버린다.
186
+ */
187
+ export function walk(root, { limit = 훑기상한(), skipDirs = SKIP_DIRS, ignore = true, signal = null } = {}) {
179
188
  const out = [];
180
189
  const 건너뜀 = { 폴더: 0, 파일: 0 };
190
+ let 끊김 = false;
181
191
  const stack = [{ dir: root, rel: '', 규칙: ignore ? 뿌리규칙읽기(root) : [] }];
182
192
  while (stack.length && out.length < limit) {
193
+ if (signal?.aborted) { 끊김 = true; break; }
183
194
  const { dir, rel, 규칙 } = stack.pop();
184
195
  let entries;
185
196
  try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
@@ -211,6 +222,7 @@ export function walk(root, { limit = 훑기상한(), skipDirs = SKIP_DIRS, ignor
211
222
  // 사람을 한 번 더 보게 만들 뿐이고, 다 봤다고 말하는 것은 못 보게 만든다.
212
223
  Object.defineProperty(out, '잘림', { value: out.length >= limit, enumerable: false });
213
224
  Object.defineProperty(out, '상한', { value: limit, enumerable: false });
225
+ Object.defineProperty(out, '끊김', { value: 끊김, enumerable: false });
214
226
  return out;
215
227
  }
216
228
 
@@ -24,6 +24,7 @@ import { 도구정의, 이름풀기 } from '../backend/mcp.js';
24
24
  import { isExcelPath, readExcel, toText as excelText, summarize as excelSummary } from './excel.js';
25
25
  import { isDocPath, readDoc, toText as docText, summarize as docSummary, looksOldHwp, 옛hwp안내, 문서는못고침 } from './docs.js';
26
26
  import { 바꿔볼까, 직접못읽나, 변환기찾기, 글로바꾸기, 못바꿈말 } from './convert.js';
27
+ import { 물음검사 } from '../agent/askcheck.js';
27
28
  import { isPdfPath, readPdf, toText as pdfText, summarize as pdfSummary, 못읽은말, pdf는못고침, 한쪽도못읽음말 } from './pdf.js';
28
29
  import { diffLines } from '../ui/diff.js';
29
30
  import { 읽을줄수, 찾을개수, 찾을줄수, 설명길이 } from '../agent/budget.js';
@@ -337,7 +338,7 @@ async function 엑셀읽기(abs, args, ctx) {
337
338
  * LibreOffice 가 있으면 그것이 곧 길이다. 표는 아니고 글이지만,
338
339
  * 아무것도 못 읽는 것보다 낫다.
339
340
  */
340
- const 빌린것 = 빌려읽기(abs, ctx, r.error);
341
+ const 빌린것 = await 빌려읽기(abs, ctx, r.error);
341
342
  if (빌린것) return 빌린것;
342
343
  if (직접못읽나(abs)) return { error: 못바꿈말(ctx.scope.show(abs), extname(abs)), 끝났다: true };
343
344
  return { error: r.error };
@@ -376,7 +377,7 @@ async function 엑셀읽기(abs, args, ctx) {
376
377
  *
377
378
  * @returns {object|null} 읽어냈으면 도구 결과. 못 하면 null (부르는 쪽이 원래 오류를 낸다)
378
379
  */
379
- function 빌려읽기(abs, ctx, 원래오류) {
380
+ async function 빌려읽기(abs, ctx, 원래오류) {
380
381
  if (!바꿔볼까(abs)) return null;
381
382
  const root = ctx.scope?.root;
382
383
  if (!root) return null;
@@ -386,7 +387,9 @@ function 빌려읽기(abs, ctx, 원래오류) {
386
387
  const 있는것 = ctx.변환기 ?? 변환기찾기();
387
388
  if (!있는것.soffice && !있는것.textutil) return null;
388
389
 
389
- const r = 글로바꾸기(abs, root, { 찾은것: 있는것 });
390
+ // signal 같이 넘긴다. 멈추라고 하면 soffice 를 죽여야 한다 — 안 그러면
391
+ // ESC 를 듣고도 남은 90초를 그대로 기다린다.
392
+ const r = await 글로바꾸기(abs, root, { 찾은것: 있는것, signal: ctx.signal ?? null });
390
393
  if (!r.ok || !r.text.trim()) return null;
391
394
 
392
395
  const 줄수 = r.text.split('\n').length;
@@ -400,11 +403,11 @@ function 빌려읽기(abs, ctx, 원래오류) {
400
403
  };
401
404
  }
402
405
 
403
- function 문서읽기(abs, ctx) {
406
+ async function 문서읽기(abs, ctx) {
404
407
  const r = readDoc(abs);
405
408
  if (!r.ok) {
406
409
  // 우리가 못 읽는다고 끝이 아니다. 이 PC 에 변환기가 있으면 빌려 본다.
407
- const 빌린것 = 빌려읽기(abs, ctx, r.error);
410
+ const 빌린것 = await 빌려읽기(abs, ctx, r.error);
408
411
  if (빌린것) return 빌린것;
409
412
  // 끝났다 를 그대로 넘긴다. 여기서 떨구면 docs.js 가 「다시 열어도 같다」고
410
413
  // 판정해 놓은 것이 루프까지 못 가서, 되풀이 억제가 안 걸린다.
@@ -585,10 +588,24 @@ function 한개옮기기({ from, to, overwrite = false }, ctx) {
585
588
  function 여러개옮기기(목록, ctx) {
586
589
  const 된것 = [];
587
590
  const 안된것 = [];
591
+ /*
592
+ * ── 무엇이 움직였는지 모아서 돌려준다 ──────────────────────────────
593
+ *
594
+ * 여기서 한 개짜리 결과의 `changed` 를 버리고 있었다. 그래서 스물두
595
+ * 개를 옮기면 화면에는 `22개 옮김` 이 뜨는데, **턴이 아는 「손댄
596
+ * 파일」 은 0개**였다. 그 값은 여러 군데가 본다 — 턴 끝의 만든 파일
597
+ * 목록, `/commit`, 그리고 헛도는지 재는 자리. 그 자리들이 전부
598
+ * "아무 일도 안 일어났다" 로 알고 있었다.
599
+ *
600
+ * 한 개씩 옮길 때는 멀쩡했다. 배열로 부를 때만 새던 자리다.
601
+ */
602
+ const 바뀐것들 = [];
588
603
  for (const 하나 of 목록) {
589
604
  const r = 한개옮기기(하나, ctx);
590
- if (r.error) 안된것.push(`${하나.from ?? '?'} → ${하나.to ?? '?'}: ${r.error}`);
591
- else 된것.push(r.content);
605
+ if (r.error) { 안된것.push(`${하나.from ?? '?'} → ${하나.to ?? '?'}: ${r.error}`); continue; }
606
+ 된것.push(r.content);
607
+ // 폴더를 옮겼으면 그 안의 파일 하나하나가, 아니면 옮겨 간 자리가 답이다.
608
+ for (const f of r.바뀐것들 ?? (r.changed ? [r.changed] : [])) 바뀐것들.push(f);
592
609
  }
593
610
  /*
594
611
  * 하나도 못 옮겼으면 오류로 돌려준다. 그래야 모델이 '됐다' 고 넘어가지
@@ -602,6 +619,7 @@ function 여러개옮기기(목록, ctx) {
602
619
  + (안된것.length ? `\n${안된것.map((e) => ` ✗ ${e.split('\n')[0]}`).join('\n')}` : '')
603
620
  + (안된것.length ? '\n\n실패한 것만 다시 보내세요. 된 것은 다시 안 보내도 됩니다.' : ''),
604
621
  summary: `${된것.length}개 옮김${안된것.length ? ` · ${안된것.length}개 실패` : ''}`,
622
+ 바뀐것들,
605
623
  };
606
624
  }
607
625
 
@@ -895,7 +913,7 @@ export const TOOLS = {
895
913
  required: ['file_path'],
896
914
  },
897
915
  },
898
- run(args, ctx) {
916
+ async run(args, ctx) {
899
917
  const abs = ctx.scope.resolve(args.file_path);
900
918
  if (!existsSync(abs)) return { error: `파일이 없습니다: ${args.file_path}` };
901
919
  if (statSync(abs).isDirectory()) return { error: `폴더입니다. Glob 을 쓰세요: ${args.file_path}` };
@@ -923,7 +941,9 @@ export const TOOLS = {
923
941
  if (looksOldHwp(abs, 머리)) {
924
942
  // 여기도 먼저 빌려 본다. 이 PC 에 변환기가 있으면 안내문보다 글이 낫다.
925
943
  const 안내 = 옛hwp안내(ctx.scope.show(abs));
926
- return 빌려읽기(abs, ctx, 안내) ?? { error: 안내 };
944
+ // 괄호가 있어야 한다. await 빼면 `??` 가 **약속(Promise)** 을
945
+ // 보고 "값이 있다" 고 판단해서, 안내문이 영영 안 나간다.
946
+ return (await 빌려읽기(abs, ctx, 안내)) ?? { error: 안내 };
927
947
  }
928
948
  } catch { /* 아래 일반 읽기가 제 오류를 낸다 */ }
929
949
  }
@@ -941,7 +961,7 @@ export const TOOLS = {
941
961
  */
942
962
  if (직접못읽나(abs)) {
943
963
  const 보인이름 = ctx.scope.show(abs);
944
- const 빌린것 = 빌려읽기(abs, ctx, `${보인이름} 을 deel 이 직접 못 읽습니다.`);
964
+ const 빌린것 = await 빌려읽기(abs, ctx, `${보인이름} 을 deel 이 직접 못 읽습니다.`);
945
965
  if (빌린것) return 빌린것;
946
966
  return { error: 못바꿈말(보인이름, extname(abs)), 끝났다: true };
947
967
  }
@@ -1215,7 +1235,9 @@ export const TOOLS = {
1215
1235
  run(args, ctx) {
1216
1236
  const root = args.path ? ctx.scope.resolve(args.path) : ctx.scope.root;
1217
1237
  const re = globToRegex(args.pattern);
1218
- const 전부 = walk(root);
1238
+ const 전부 = walk(root, { signal: ctx.signal });
1239
+ // 훑다 말고 나왔으면 그렇다고 말한다. 조용히 적게 주면 「그런 파일이 없다」가 된다.
1240
+ if (전부.끊김) return { error: '중단했습니다. 폴더를 끝까지 안 훑었습니다.', 끝났다: true, 중단됨: true };
1219
1241
  // .gitignore 로 건너뛴 것은 수를 말한다 — 조용히 빼면 '그 파일이 없다' 로 읽힌다 (tools/ignore.js).
1220
1242
  const 건너뜀 = 건너뜀말(전부.건너뜀, 전부.잘림, 전부.상한);
1221
1243
  const 맞는것 = 전부
@@ -1259,7 +1281,9 @@ export const TOOLS = {
1259
1281
  required: ['pattern'],
1260
1282
  },
1261
1283
  },
1262
- run(args, ctx) {
1284
+ // async 인 까닭: 아래 빠르게찾기() 가 rg·git grep 을 **비동기로** 부른다.
1285
+ // 큰 저장소에서 20초를 도는 동안에도 ESC 가 들려야 하기 때문이다.
1286
+ async run(args, ctx) {
1263
1287
  let re;
1264
1288
  try { re = new RegExp(args.pattern, args['-i'] ? 'i' : ''); }
1265
1289
  catch (err) { return { error: `정규식이 잘못됐습니다: ${err.message}` }; }
@@ -1282,13 +1306,15 @@ export const TOOLS = {
1282
1306
  * 다르게 나왔을 때 무엇으로 찾은 것인지 모르면 사람은 코드를 의심한다.
1283
1307
  */
1284
1308
  const 무시파일 = join(ctx.scope.root, '.deelignore');
1285
- const 빠른것 = isFile ? null : 빠르게찾기({
1309
+ const 빠른것 = isFile ? null : await 빠르게찾기({
1286
1310
  무늬: args.pattern,
1287
1311
  자리: root,
1288
1312
  glob: args.glob ?? null,
1289
1313
  대소문자무시: !!args['-i'],
1290
1314
  무시파일: existsSync(무시파일) ? 무시파일 : null,
1291
1315
  최대: Math.max(limit * 4, 2000),
1316
+ // 멈추라고 하면 rg 를 죽인다. 이게 없으면 ESC 를 듣고도 20초를 더 기다린다.
1317
+ signal: ctx.signal ?? null,
1292
1318
  });
1293
1319
  if (빠른것) {
1294
1320
  const 파일별 = new Map();
@@ -1339,7 +1365,8 @@ export const TOOLS = {
1339
1365
  */
1340
1366
  let files = isFile
1341
1367
  ? [{ path: root, rel: ctx.scope.show(root) }]
1342
- : walk(root);
1368
+ : walk(root, { signal: ctx.signal });
1369
+ if (files.끊김) return { error: '중단했습니다. 폴더를 끝까지 안 훑었습니다.', 끝났다: true, 중단됨: true };
1343
1370
  const 안본것 = isFile ? '' : 건너뜀말(files.건너뜀, files.잘림, files.상한).trim(); // .gitignore 로 건너뛴 수 — 꼬리에 적는다
1344
1371
  // 훑기 상한에 걸렸으면 "일치 없음" 이라고 잘라 말하면 안 된다. 안 본 것이다.
1345
1372
  const 다못봄 = !isFile && !!files.잘림;
@@ -1662,13 +1689,21 @@ export const TOOLS = {
1662
1689
  * 첫 문장에 제일 중요한 것을 둔다 — 좁은 창에서는 설명줄이기가
1663
1690
  * **뒤에서부터 문장째로** 잘라 낸다. 8k 에서 남는 것은 앞 90자다.
1664
1691
  */
1665
- description: '갈림길에서 사람에게 하나 묻는다. 글로 "알려주세요" 하고 끝내지 마라 '
1666
- + ' 턴이 끝나서 여태 것이 버려진다. options 2~4개를 주면 숫자로 답한다.'
1692
+ description: '갈림길에서 사람에게 하나 묻는다. **먼저 `이해` 이번 요청을 무엇으로'
1693
+ + ' 알아들었는지 줄로 적고**, 그러고도 정말 정할 것이 남았을 때만 묻는다.'
1694
+ + ' 글로 "알려주세요" 하고 끝내지 마라 — 턴이 끝나서 여태 본 것이 버려진다.'
1695
+ + ' options 에 2~4개를 주면 숫자로 답한다.'
1667
1696
  + ' **이미 사용자가 말한 것을 다시 묻지 마라.** "파일 정리해 줘" 라고 했으면'
1668
1697
  + ' 그게 답이다 — 그대로 하면 된다. 어떻게 할지 정하는 것은 네 일이다.',
1669
1698
  parameters: {
1670
1699
  type: 'object',
1671
1700
  properties: {
1701
+ 이해: {
1702
+ type: 'string',
1703
+ description: '이번 요청을 무엇으로 알아들었는지 한 줄. 사람은 이 줄을 보고 네가'
1704
+ + ' 제대로 읽었는지 판단한다. "요청을 이해했습니다" 같은 인사말은 안 된다 —'
1705
+ + ' 그 일의 내용이 들어가야 한다',
1706
+ },
1672
1707
  question: { type: 'string', description: '한 문장짜리 질문. 무엇을 정해야 하는지 분명하게' },
1673
1708
  options: {
1674
1709
  type: 'array',
@@ -1676,7 +1711,7 @@ export const TOOLS = {
1676
1711
  description: '고를 것 2~4개. 각각 한 줄로, 무엇이 달라지는지 알 수 있게',
1677
1712
  },
1678
1713
  },
1679
- required: ['question'],
1714
+ required: ['이해', 'question'],
1680
1715
  },
1681
1716
  },
1682
1717
  async run(args, ctx) {
@@ -1685,11 +1720,16 @@ export const TOOLS = {
1685
1720
 
1686
1721
  const 고를것 = (Array.isArray(args.options) ? args.options : [])
1687
1722
  .map((x) => String(x ?? '').trim()).filter(Boolean).slice(0, 4);
1723
+ const 이해 = String(args.이해 ?? '').trim();
1688
1724
 
1689
1725
  /*
1690
1726
  * 물어볼 자리가 없는 데서도 안 죽어야 한다 — `deel -p` 한 방 실행, 파이프,
1691
1727
  * 하위 작업. 거기서는 **막히지 말고** 스스로 판단하라고 돌려준다.
1692
1728
  * 답을 기다리며 서 있으면 그 실행은 영영 안 끝난다.
1729
+ *
1730
+ * 아래 관문보다 **먼저** 본다. 어차피 아무도 못 듣는 자리에서 "이해를
1731
+ * 채워서 다시 물어라" 라고 돌려주면, 고쳐서 다시 불러도 결과가 같다 —
1732
+ * 왕복만 한 번 늘고 그 실행은 그만큼 늦어진다.
1693
1733
  */
1694
1734
  /*
1695
1735
  * 결과는 반드시 `content` 로 돌려준다.
@@ -1706,7 +1746,28 @@ export const TOOLS = {
1706
1746
  };
1707
1747
  }
1708
1748
 
1709
- const 답 = await ctx.ask물음(물음, 고를것);
1749
+ /*
1750
+ * 사람에게 내보내기 전에 한 번 거른다 (agent/askcheck.js).
1751
+ *
1752
+ * 막힌 물음은 **오류로** 돌려준다. content 로 돌려주면 모델은 그것을
1753
+ * 사람의 답으로 읽고 그대로 이어 간다 — 묻지도 않은 답을 받은 셈이 된다.
1754
+ * 오류여야 되풀이 감지에도 걸리고, 같은 물음을 또 던지지 않는다.
1755
+ */
1756
+ const 관문 = 물음검사({
1757
+ 물음, 고를것, 이해, 요청: ctx.요청 ?? '', 이미물은것: ctx.물은것 ?? [],
1758
+ });
1759
+ if (!관문.ok) return { error: 관문.할말, 끝났다: true, 물음막힘: 관문.왜 };
1760
+
1761
+ const 답 = await ctx.ask물음(물음, 고를것, 이해);
1762
+
1763
+ /*
1764
+ * 물어본 것을 적어 둔다.
1765
+ *
1766
+ * 관문이 "이미 물었나" 를 보려면 기억할 자리가 있어야 한다. 답까지 같이
1767
+ * 적는 것은, 또 물으려 할 때 **앞의 답을 그대로 돌려주기** 위해서다 —
1768
+ * "이미 물었습니다" 만 말하면 모델은 그 답이 무엇이었는지 못 찾는다.
1769
+ */
1770
+ (ctx.물은것 ??= []).push({ 물음, 고를것, 답: 답 == null ? '' : String(답) });
1710
1771
  if (답 === null || 답 === undefined || String(답).trim() === '') {
1711
1772
  return { content: '사람이 답하지 않았습니다. 되묻지 말고 스스로 판단해 이어가세요.' };
1712
1773
  }
@@ -1745,7 +1806,24 @@ export const TOOLS = {
1745
1806
  const 왜 = r.예산초과
1746
1807
  ? `지난 대화 ${r.전체파일}개 중 ${r.본파일}개까지만 뒤졌습니다(양이 많아 멈춤). 못 찾았습니다`
1747
1808
  : `지난 대화 ${r.본파일}개를 다 뒤졌지만 없습니다`;
1748
- return { summary: `${왜}: ${q}`, hits: [], searched: r.본파일, total: r.전체파일, partial: r.예산초과 };
1809
+ /*
1810
+ * content 를 반드시 채운다.
1811
+ *
1812
+ * 대화에 실리는 것은 content 다. 여태 여기는 summary 만 돌려줬고,
1813
+ * 그래서 **모델에게는 빈 글이 갔다.** 사람 화면에는 「없습니다」가
1814
+ * 멀쩡히 찍히니 아무도 못 알아챘다. 모델은 못 찾은 줄도 모르고
1815
+ * 찾아본 줄도 몰라서, 그 자리에서 엉뚱한 선택지를 들이밀었다.
1816
+ *
1817
+ * 「없다」는 것도 알아낸 것이다. 알아낸 것은 반드시 전한다.
1818
+ */
1819
+ const 없다는말 = `${왜}: ${q}\n`
1820
+ + '이 대화 밖에는 단서가 없습니다. 없는 것을 지어내지 말고,'
1821
+ + ' 지금 대화에 있는 것으로 판단하거나 무엇이 없는지 밝히세요.';
1822
+ return {
1823
+ content: 없다는말,
1824
+ summary: `${왜}: ${q}`,
1825
+ hits: [], searched: r.본파일, total: r.전체파일, partial: r.예산초과,
1826
+ };
1749
1827
  }
1750
1828
 
1751
1829
  const 줄들 = r.맞은것.map((h) => {
@@ -2069,9 +2147,39 @@ export async function runTool(name, args, ctx) {
2069
2147
 
2070
2148
  const t = TOOLS[name];
2071
2149
  if (!t) return { error: `모르는 도구: ${name}` };
2150
+
2151
+ /*
2152
+ * ── 멈추라고 했으면 시작도 안 한다 ──────────────────────────────────
2153
+ *
2154
+ * 여럿을 함께 돌릴 때(loop.js 의 Promise.all) 앞엣것이 도는 사이 사람이
2155
+ * ESC 를 누르면, 뒤엣것들은 **아직 아무 일도 안 했는데** 그대로 돌았다.
2156
+ * 여기서 한 번 보면 그 자리가 막힌다.
2157
+ *
2158
+ * 중단은 **실패가 아니다.** 그래서 중단됨 을 따로 단다 — 이걸 실패로
2159
+ * 세면 되풀이 감지가 엉뚱하게 걸려서, 다음에 같은 도구를 부르는 것까지
2160
+ * "또 그러네" 로 막아 버린다. 사람이 멈춘 것은 도구 잘못이 아니다.
2161
+ */
2162
+ if (ctx.signal?.aborted) {
2163
+ return { error: '중단했습니다. 실행하지 않았습니다.', 끝났다: true, 중단됨: true };
2164
+ }
2165
+
2072
2166
  try {
2073
2167
  const r = await t.run(args ?? {}, ctx);
2074
2168
  ctx.audit.tool(name, args, r);
2169
+ /*
2170
+ * 도는 중에 멈췄다면 결과를 안 쓴다.
2171
+ *
2172
+ * 다만 **이미 바꿔 놓은 것은 사실대로 말한다.** 파일을 고친 뒤에
2173
+ * "중단했습니다" 만 돌려주면 모델은 안 고쳐진 줄 알고 또 고친다 —
2174
+ * 그러면 같은 편집이 두 번 들어가거나, 되돌리기 기록과 어긋난다.
2175
+ * 읽기만 한 것은 버려도 잃을 것이 없으니 중단으로 끝낸다.
2176
+ */
2177
+ if (ctx.signal?.aborted) {
2178
+ if (!isMutating(name) || r.error) {
2179
+ return { error: '중단했습니다.', 끝났다: true, 중단됨: true };
2180
+ }
2181
+ return { ...r, 중단됨: true, 중단전에끝남: true };
2182
+ }
2075
2183
  return await 고친뒤진단(name, r, ctx);
2076
2184
  } catch (err) {
2077
2185
  const r = { error: err.message };
@@ -232,7 +232,8 @@ export const OUTLINE_TOOL = {
232
232
  const 하나인가 = statSync(시작).isFile();
233
233
  let 파일들 = 하나인가
234
234
  ? [{ path: 시작, rel: ctx.scope.show(시작), mtime: statSync(시작).mtimeMs }]
235
- : walk(시작, { skipDirs: SKIP_DIRS });
235
+ : walk(시작, { skipDirs: SKIP_DIRS, signal: ctx.signal });
236
+ if (파일들.끊김) return { error: '중단했습니다. 폴더를 끝까지 안 훑었습니다.', 끝났다: true, 중단됨: true };
236
237
  const 건너뜀 = 하나인가 ? '' : 건너뜀말(파일들.건너뜀, 파일들.잘림, 파일들.상한); // .gitignore 로 건너뛴 수 (tools/ignore.js)
237
238
 
238
239
  // 좁히는 방식은 Glob 도구와 **같은 것**을 쓴다. 두 도구가 같은 패턴에
@@ -0,0 +1,99 @@
1
+ // 바깥 프로그램을 부른다 — **기다리는 동안 귀를 열어 둔 채로.**
2
+ //
3
+ // ── 왜 이 파일이 따로 있나 ─────────────────────────────────────────────
4
+ //
5
+ // 전에는 찾기(fastgrep.js)도 변환(convert.js)도 각자 spawnSync 를 썼다.
6
+ // 쓰기는 편하다. 그런데 그 호출이 도는 동안 Node 는 이벤트 루프를 통째로
7
+ // 멈춘다. 그 사이에는 키 입력이 배달되지 않는다 — 사람이 ESC 를 몇 번을
8
+ // 눌러도, 그 키는 버퍼에만 쌓여 있다가 **일이 다 끝난 뒤에야** 들어온다.
9
+ //
10
+ // rg 로 큰 저장소 찾기 최대 20초
11
+ // soffice 로 문서 바꾸기 최대 90초
12
+ //
13
+ // 그동안 프로그램은 귀머거리다. "ESC 를 눌러도 안 멈춘다" 는 제보의 뿌리가
14
+ // 여기였다. 화면에는 여전히 돌아가는 그림이 보이니 사람은 더 답답하다.
15
+ //
16
+ // 두 군데에 따로 적어 두면 언젠가 한쪽만 고쳐지고, 그때부터 "검색은 멈추는데
17
+ // 문서 변환은 안 멈추는" 프로그램이 된다. 그건 고장보다 설명하기 어렵다.
18
+ // 그래서 한 벌만 둔다.
19
+ //
20
+ // ── 돌려주는 모양 ──────────────────────────────────────────────────────
21
+ //
22
+ // 일부러 spawnSync 와 **똑같이** 맞췄다 — {error, status, stdout, stderr}.
23
+ // 부르는 쪽의 판단 코드(`status === 2 면 실패` 같은 것)를 한 줄도 안 건드리고
24
+ // 바꾸려는 것이다. 바꾼 자리가 적을수록 조용히 달라지는 것도 적다.
25
+ import { spawn } from 'node:child_process';
26
+
27
+ /**
28
+ * 자식 프로그램을 부르고, 끝날 때까지 기다린다 — 루프는 계속 돈다.
29
+ *
30
+ * @param {string} 이름 실행할 것
31
+ * @param {string[]} 인자 인자들. 셸을 안 거치므로 여기 든 따옴표·세미콜론은 글자다.
32
+ * @param {object} [옵션]
33
+ * @param {number} [옵션.timeout] 이 시간을 넘기면 죽이고 실패로 돌려준다
34
+ * @param {number} [옵션.maxBuffer] 나온 글이 이보다 커지면 죽인다
35
+ * @param {AbortSignal|null} [옵션.signal] 눌리면 자식을 죽인다
36
+ * @param {object} [옵션.덤] spawn 에 그대로 넘길 것 (windowsVerbatimArguments 같은)
37
+ * @returns {Promise<{error:Error|null, status:number|null, stdout:string, stderr:string}>}
38
+ */
39
+ export function 돌려보기(이름, 인자, { timeout = 20000, maxBuffer = 32 * 1024 * 1024, signal = null, 덤 = {} } = {}) {
40
+ return new Promise((resolve) => {
41
+ // 이미 눌렸으면 띄우지도 않는다. 여럿을 함께 돌릴 때 뒤엣것이 여기로 온다.
42
+ if (signal?.aborted) { resolve({ error: new Error('중단했습니다'), status: null, stdout: '', stderr: '' }); return; }
43
+
44
+ let 아이;
45
+ try {
46
+ 아이 = spawn(이름, 인자, { windowsHide: true, ...덤 });
47
+ } catch (탈난것) {
48
+ resolve({ error: 탈난것, status: null, stdout: '', stderr: '' });
49
+ return;
50
+ }
51
+
52
+ let 밖 = ''; let 탈 = ''; let 끝났나 = false;
53
+ const 끝내기 = (것) => {
54
+ if (끝났나) return;
55
+ 끝났나 = true;
56
+ clearTimeout(시계);
57
+ signal?.removeEventListener?.('abort', 중단하기);
58
+ resolve(것);
59
+ };
60
+
61
+ /*
62
+ * 멈추라고 하면 **자식을 죽인다.**
63
+ *
64
+ * 여기가 없으면 반쪽짜리다. 비동기로 바꾼 덕에 ESC 는 들리지만, 정작
65
+ * soffice 는 남은 시간을 마저 돈다 — 사람 눈에는 여전히 "눌렀는데 안
66
+ * 멈춘다". 듣는 것과 멈추는 것은 다른 일이라, 둘 다 해야 한 가지가 끝난다.
67
+ */
68
+ const 중단하기 = () => {
69
+ try { 아이.kill(); } catch { /* 이미 죽었다 */ }
70
+ 끝내기({ error: new Error('중단했습니다'), status: null, stdout: '', stderr: '' });
71
+ };
72
+ signal?.addEventListener?.('abort', 중단하기, { once: true });
73
+
74
+ const 시계 = setTimeout(() => {
75
+ try { 아이.kill(); } catch { /* 이미 죽었다 */ }
76
+ 끝내기({ error: new Error(`${이름} 가 ${timeout}ms 안에 안 끝났습니다`), status: null, stdout: 밖, stderr: 탈 });
77
+ }, timeout);
78
+ // 이 타이머가 프로그램을 붙들면 안 된다. 자식의 파이프가 이미 루프를 붙들고 있다.
79
+ 시계.unref?.();
80
+
81
+ 아이.stdout?.setEncoding('utf8');
82
+ 아이.stderr?.setEncoding('utf8');
83
+ 아이.stdout?.on('data', (조각) => {
84
+ 밖 += 조각;
85
+ /*
86
+ * spawnSync 는 이 자리에서 ENOBUFS 를 냈다. 조용히 잘라 버리면 사람
87
+ * 눈에는 '결과가 적다' 로만 보인다 — 오류도 안 나고 아무도 눈치 못 챈다.
88
+ * 같은 자리에서 같은 실패를 내야 부르는 쪽이 예전 길로 내려간다.
89
+ */
90
+ if (밖.length > maxBuffer) {
91
+ try { 아이.kill(); } catch { /* 이미 죽었다 */ }
92
+ 끝내기({ error: new Error('결과가 너무 많습니다'), status: null, stdout: '', stderr: 탈 });
93
+ }
94
+ });
95
+ 아이.stderr?.on('data', (조각) => { if (탈.length < 65536) 탈 += 조각; });
96
+ 아이.on('error', (탈난것) => 끝내기({ error: 탈난것, status: null, stdout: 밖, stderr: 탈 }));
97
+ 아이.on('close', (코드) => 끝내기({ error: null, status: 코드, stdout: 밖, stderr: 탈 }));
98
+ });
99
+ }
package/src/tools/todo.js CHANGED
@@ -95,6 +95,34 @@ export const TODO_TOOL = {
95
95
  const 이전 = ctx.todos ?? [];
96
96
  ctx.todos = items;
97
97
 
98
+ /*
99
+ * ── 똑같은 목록을 또 보냈으면 그렇다고 말한다 ────────────────────────
100
+ *
101
+ * 「폴더 정리 해줘」 에서 이 자리가 걸렸다. 모델이 파일을 옮긴 뒤
102
+ * 목록을 갱신하려 했는데, 끝난 줄을 done 으로 안 바꾸고 **글자 하나
103
+ * 안 틀린 같은 목록**을 다시 보냈다. 그러면 여기서는 성공으로 돌려주고
104
+ * 「3개 중 1개 완료」 라는 앞과 똑같은 글이 나간다. 모델 쪽에서 보면
105
+ * 갱신이 된 것이므로, 다음 걸음에 또 같은 것을 보낸다. 그렇게 세 번이면
106
+ * 헛돈다고 판정돼 턴이 죽었다.
107
+ *
108
+ * 오류로 만들지는 않는다. 목록은 실제로 저장됐고, 틀린 것을 한 것도
109
+ * 아니다. 다만 **아무 일도 안 일어났다는 사실**과 그럴 때 무엇을 해야
110
+ * 하는지를 그 자리에서 알려 준다.
111
+ */
112
+ const 같은목록 = 이전.length === items.length
113
+ && items.every((x, i) => 이전[i]?.text === x.text && 이전[i]?.state === x.state);
114
+ if (같은목록) {
115
+ return {
116
+ content: `${render(items)}\n\n`
117
+ + '이 목록은 앞에 보낸 것과 글자 하나까지 같습니다 — 바뀐 것이 없습니다.\n'
118
+ + '방금 끝낸 일이 있으면 그 줄을 done 으로 바꿔서 보내고,'
119
+ + ' 없으면 목록을 다시 보내지 말고 다음 일을 하세요.',
120
+ summary: '그대로 (바뀐 것 없음)',
121
+ todos: items,
122
+ 안바뀜: true,
123
+ };
124
+ }
125
+
98
126
  const 끝난것 = items.filter((x) => x.state === 'done').length;
99
127
  const 새로끝난 = items.filter((x) =>
100
128
  x.state === 'done' && !이전.some((y) => y.text === x.text && y.state === 'done'));
@@ -444,7 +444,15 @@ export class InputBox {
444
444
  // 답이 흘러나오는 동안 상자가 답 줄에 얹히는 것을 여기서 막는다.
445
445
  if (!this.일감 || this.줄중간) return;
446
446
  const 지난 = Date.now() - this.일감.시작;
447
- const 갈래 = 지난 >= 느긋해질때 ? '느긋' : this.일감.갈래;
447
+ /*
448
+ * '멈춤' 은 '느긋' 을 이긴다.
449
+ *
450
+ * 45초가 넘으면 갈래를 느긋으로 갈아 주는데, 사람이 ESC 를 누르는 때가
451
+ * 하필 딱 그때다 — 오래 걸려서 누르는 것이다. 순서를 안 정해 두면
452
+ * 「멈추는 중」이 화면에 영영 안 뜨고, 사람은 안 먹었다고 여겨 또 누른다.
453
+ */
454
+ const 멈추나 = this.일감.갈래 === '멈춤';
455
+ const 갈래 = (!멈추나 && 지난 >= 느긋해질때) ? '느긋' : this.일감.갈래;
448
456
  const 회차 = Math.floor((this.틱 * 돌림주기) / 문구주기);
449
457
  this.그리기(this.session, '', 0, {
450
458
  // 갈래에 맞는 작은 그림. 무엇을 하는 중인지 글자와 그림이 같이 말한다.
@@ -459,7 +467,8 @@ export class InputBox {
459
467
  this.일감.예약수 ? `${this.일감.예약수}건 예약됨` : '',
460
468
  // 도는 동안 보이는 자리라 여기서는 ESC 만 말한다. Ctrl+C 도 되지만
461
469
  // 두 번 누르면 끝나 버려서, 급히 멈추려는 사람에게 권할 것이 못 된다.
462
- 'ESC 중단',
470
+ // 이미 멈추는 중이면 안 권한다 — 또 누르라는 말로 읽힌다.
471
+ 멈추나 ? '' : 'ESC 중단',
463
472
  ].filter(Boolean).join(' · '),
464
473
  대기: this.일감.대기,
465
474
  });
package/src/ui/motion.js CHANGED
@@ -163,6 +163,20 @@ export const 그림들 = {
163
163
  ['......', '....##', '....##', '......'],
164
164
  ],
165
165
 
166
+ /*
167
+ * 멈춤 — ESC 를 눌렀을 때. **조여든다.**
168
+ *
169
+ * 지나가지도 왕복하지도 않는다. 둘 다 '아직 하는 중' 으로 읽히기 때문이다.
170
+ * 여기서 말해야 하는 것은 하나다 — 눌린 것이 닿았고, 지금 거두는 중이다.
171
+ * 그렇다고 멈춰 세우면 화면이 얼어붙은 것과 구별이 안 된다.
172
+ */
173
+ 멈춤: [
174
+ ['......', '######', '######', '......'],
175
+ ['......', '.####.', '.####.', '......'],
176
+ ['......', '..##..', '..##..', '......'],
177
+ ['......', '.####.', '.####.', '......'],
178
+ ],
179
+
166
180
  /*
167
181
  * 느긋 — 오래 걸릴 때. 왕복한다.
168
182
  *
@@ -307,6 +321,14 @@ const 기사그림들 = {
307
321
  ['...##.......', '..####......', '..####......', '..#..#......'],
308
322
  ],
309
323
 
324
+ // 멈춤 — 걸음을 멈추고 팔을 든다. "그만" 이라는 손짓이다.
325
+ 멈춤: [
326
+ ['.##.........', '####........', '####........', '#..#........'],
327
+ ['.##.#.......', '#####.......', '####........', '#..#........'],
328
+ ['.##.#.......', '#####.......', '####........', '#..#........'],
329
+ ['.##.........', '####........', '####........', '#..#........'],
330
+ ],
331
+
310
332
  /*
311
333
  * 느긋 — **넘어진다.** 45초가 넘어가면 이 그림으로 갈린다.
312
334
  *
@@ -417,6 +439,14 @@ const 동물그림들 = {
417
439
  ['..#..#......', '..####......', '.######.....', '..#..#......'],
418
440
  ],
419
441
 
442
+ // 멈춤 — 가던 걸음을 멈추고 다리를 모은다. 앉기 직전이다.
443
+ 멈춤: [
444
+ ['.#..#.......', '.####.......', '######......', '.#..#.......'],
445
+ ['.#..#.......', '.####.......', '######......', '..##........'],
446
+ ['.#..#.......', '.####.......', '######......', '..##........'],
447
+ ['.#..#.......', '.####.......', '######......', '.#..#.......'],
448
+ ],
449
+
420
450
  /*
421
451
  * 느긋 — 드러누워 잔다. 숨만 오르내린다.
422
452
  *