deel-local-cli 1.20.13 → 2.0.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.
Files changed (155) hide show
  1. package/README.ko.md +96 -78
  2. package/README.md +119 -81
  3. package/bin/deel.js +398 -32
  4. package/package.json +2 -1
  5. package/src/acp/jsonrpc.js +87 -7
  6. package/src/acp/map.js +23 -3
  7. package/src/acp/serve.js +333 -25
  8. package/src/agent/agents.js +110 -12
  9. package/src/agent/askcheck.js +50 -6
  10. package/src/agent/asks.js +59 -5
  11. package/src/agent/budget.js +9 -2
  12. package/src/agent/card.js +8 -2
  13. package/src/agent/commit.js +168 -16
  14. package/src/agent/compact.js +93 -21
  15. package/src/agent/{/354/213/240/353/242/260/353/217/204.js → confidence.js} +1 -1
  16. package/src/agent/effort.js +85 -15
  17. package/src/agent/evidence.js +63 -7
  18. package/src/agent/evolve.js +132 -24
  19. package/src/agent/grade.js +12 -3
  20. package/src/agent/loop.js +408 -81
  21. package/src/agent/memory.js +176 -29
  22. package/src/agent/mention.js +22 -5
  23. package/src/agent/models.js +31 -7
  24. package/src/agent/modes.js +25 -10
  25. package/src/agent/outschema.js +381 -56
  26. package/src/agent/pins.js +33 -2
  27. package/src/agent/project.js +73 -8
  28. package/src/agent/recall.js +40 -9
  29. package/src/agent/{/354/204/261/355/225/234/352/270/260/353/241/235.js → recordshape.js} +19 -8
  30. package/src/agent/review.js +143 -13
  31. package/src/agent/route.js +253 -26
  32. package/src/agent/salvage.js +2 -1
  33. package/src/agent/session.js +457 -31
  34. package/src/agent/store.js +232 -22
  35. package/src/agent/threads.js +52 -3
  36. package/src/backend/adapter.js +508 -85
  37. package/src/backend/azure.js +12 -1
  38. package/src/backend/cachemark.js +31 -5
  39. package/src/backend/clientcert.js +23 -1
  40. package/src/backend/ctxsize.js +56 -9
  41. package/src/backend/detect.js +252 -19
  42. package/src/backend/http.js +383 -42
  43. package/src/backend/learn.js +101 -11
  44. package/src/backend/mcp.js +333 -24
  45. package/src/backend/price.js +61 -13
  46. package/src/backend/probe.js +188 -36
  47. package/src/backend/proxy.js +108 -14
  48. package/src/backend/quota.js +226 -30
  49. package/src/backend/retry.js +46 -3
  50. package/src/backend/scan.js +47 -10
  51. package/src/backend/scanui.js +57 -7
  52. package/src/backend/toolfit.js +204 -22
  53. package/src/backend/vision.js +14 -1
  54. package/src/backend/wire.js +58 -10
  55. package/src/cmdnames.js +16 -0
  56. package/src/commands/common.js +70 -0
  57. package/src/commands/extend.js +300 -0
  58. package/src/commands/model.js +852 -0
  59. package/src/commands/view.js +328 -0
  60. package/src/commands/work.js +823 -0
  61. package/src/commands.js +193 -2082
  62. package/src/completion.js +45 -3
  63. package/src/config.js +452 -39
  64. package/src/configexplain.js +125 -14
  65. package/src/doctor.js +164 -26
  66. package/src/i18n/en.js +22 -0
  67. package/src/i18n/index.js +31 -1
  68. package/src/i18n/ja.js +25 -1
  69. package/src/i18n/ko.js +25 -2
  70. package/src/i18n/zh.js +25 -1
  71. package/src/lsp/client.js +111 -9
  72. package/src/lsp/servers.js +86 -22
  73. package/src/oneshot.js +325 -65
  74. package/src/pack/sbom.js +28 -5
  75. package/src/pack/selfpack.js +54 -5
  76. package/src/pack/sheet.en.js +24 -3
  77. package/src/pack/tar.js +45 -8
  78. package/src/pack/zip.js +103 -8
  79. package/src/plugins/manage.js +165 -14
  80. package/src/preview/serve.js +239 -23
  81. package/src/providers/bedrock.js +30 -5
  82. package/src/providers/gemini.js +8 -0
  83. package/src/providers/index.js +17 -1
  84. package/src/repl.js +254 -61
  85. package/src/report.js +7 -2
  86. package/src/reset.js +151 -25
  87. package/src/safety/audit.js +108 -8
  88. package/src/safety/authcmd.js +75 -5
  89. package/src/safety/guard.js +507 -15
  90. package/src/safety/hooks.js +172 -20
  91. package/src/safety/keystore.js +178 -18
  92. package/src/safety/network.js +209 -6
  93. package/src/safety/policy.js +377 -36
  94. package/src/safety/runmode.js +9 -2
  95. package/src/safety/secrets.js +243 -17
  96. package/src/safety/shellenv.js +64 -3
  97. package/src/safety/trust.js +298 -32
  98. package/src/safety/undo.js +409 -17
  99. package/src/setup.js +175 -17
  100. package/src/skills/discover.js +122 -19
  101. package/src/stats.js +57 -16
  102. package/src/tools/{/355/231/225/354/235/270/353/262/225.js → checkmethods.js} +26 -1
  103. package/src/tools/clipboard.js +53 -20
  104. package/src/tools/convert.js +118 -37
  105. package/src/tools/desc.en.js +48 -5
  106. package/src/tools/doc2md.js +11 -1
  107. package/src/tools/docs.js +94 -18
  108. package/src/tools/edit-match.js +212 -13
  109. package/src/tools/encoding.js +525 -28
  110. package/src/tools/excel-com.js +63 -15
  111. package/src/tools/excel.js +12 -7
  112. package/src/tools/fastgrep.js +444 -36
  113. package/src/tools/fig.js +21 -4
  114. package/src/tools/fsutil.js +235 -21
  115. package/src/tools/hwpxwrite.js +80 -19
  116. package/src/tools/ignore.js +55 -7
  117. package/src/tools/index.js +1029 -145
  118. package/src/tools/jobs.js +325 -50
  119. package/src/tools/kiwi.js +50 -5
  120. package/src/tools/lsp.js +164 -32
  121. package/src/tools/outline.js +52 -12
  122. package/src/tools/pdf.js +366 -56
  123. package/src/tools/shell.js +12 -4
  124. package/src/tools/spawn.js +203 -18
  125. package/src/tools/todo.js +73 -4
  126. package/src/tools/verify.js +312 -43
  127. package/src/tools/webfetch.js +184 -25
  128. package/src/tools/xlsx.js +232 -31
  129. package/src/ui/ansi.js +209 -15
  130. package/src/ui/banner.js +2 -1
  131. package/src/ui/complete.js +46 -4
  132. package/src/ui/diff.js +4 -2
  133. package/src/ui/export.js +91 -17
  134. package/src/ui/inputbox.js +58 -18
  135. package/src/ui/intro.js +9 -2
  136. package/src/ui/level.js +32 -7
  137. package/src/ui/md.js +101 -15
  138. package/src/ui/motion.js +5 -2
  139. package/src/ui/notify.js +7 -1
  140. package/src/ui/office.js +37 -5
  141. package/src/ui/pastechip.js +3 -2
  142. package/src/ui/pick.js +85 -12
  143. package/src/ui/prompt.js +176 -13
  144. package/src/ui/screen.js +2 -1
  145. package/src/ui/spinner.js +6 -1
  146. package/src/ui/status.js +80 -12
  147. package/src/ui/wrap.js +23 -37
  148. /package/src/agent/{/353/213/250/352/263/204.js" → phase.js} +0 -0
  149. /package/src/skills/builtin/{/352/271/212/354/235/264/354/236/210/352/262/214-/353/247/214/353/223/244/352/270/260/SKILL.md" → build-deep/SKILL.md} +0 -0
  150. /package/src/skills/builtin/{/354/260/250/352/267/274/354/260/250/352/267/274-/353/224/224/353/262/204/352/271/205/SKILL.md" → debug-step-by-step/SKILL.md} +0 -0
  151. /package/src/skills/builtin/{/353/201/235/352/271/214/354/247/200-/355/225/230/352/270/260/SKILL.md" → finish-all/SKILL.md} +0 -0
  152. /package/src/skills/builtin/{/354/212/244/354/212/244/353/241/234-/352/262/200/355/206/240/SKILL.md" → self-review/SKILL.md} +0 -0
  153. /package/src/skills/builtin/{/354/275/224/353/223/234-/354/244/204/354/235/264/352/270/260/SKILL.md" → simplify-code/SKILL.md} +0 -0
  154. /package/src/skills/builtin/{/354/260/224/353/237/254/353/263/264/352/270/260/SKILL.md" → spike/SKILL.md} +0 -0
  155. /package/src/skills/builtin/{/352/262/200/354/202/254-/353/250/274/354/240/200/SKILL.md" → test-first/SKILL.md} +0 -0
@@ -1,16 +1,17 @@
1
1
  // 도구. 이름과 인자를 Claude Code 와 같게 맞춘다 —
2
2
  // 그래야 그 관례로 쓰인 스킬·명령이 그대로 먹는다.
3
- import { writeFileSync, appendFileSync, readFileSync, existsSync, mkdirSync, statSync, renameSync, cpSync, rmSync,
3
+ import { writeFileSync, appendFileSync, readFileSync, existsSync, mkdirSync, statSync, lstatSync, renameSync, cpSync, rmSync,
4
4
  openSync, readSync, closeSync } from 'node:fs';
5
- import { dirname, extname, join, relative, sep } from 'node:path';
5
+ import { dirname, extname, isAbsolute, join, relative, sep } from 'node:path';
6
+ import { createHash } from 'node:crypto';
6
7
  import { 무리로돌리기 } from './spawn.js';
7
- import { globToRegex, walk, readText, readTextFull, 내부살림 } from './fsutil.js';
8
+ import { walk, readText, readTextFull, 내부살림, glob거르개 } from './fsutil.js';
8
9
  import { 건너뜀말 } from './ignore.js';
9
- import { encode, label as encLabel, decode as decodeBytes, consoleCodepage, looksBinary } from './encoding.js';
10
+ import { encode, label as encLabel, decode as decodeBytes, consoleCodepage, looksBinary, 바꾼데만쓰기 } from './encoding.js';
10
11
  import { checkCommand, checkPaths, isMutating, 셸이파일에쓰나 } from '../safety/guard.js';
11
- import { 띄우기, 나무끊기, 무리끊기, JOBS_TOOL } from './jobs.js';
12
+ import { 띄우기, 나무끊기, 무리끊기, 남은무리끊기, JOBS_TOOL } from './jobs.js';
12
13
  import { 셸명령 } from './shell.js';
13
- import { findMatch, applySpans, reindent, TIER_LABELS } from './edit-match.js';
14
+ import { findMatch, applySpans, reindent, TIER_LABELS, CRLF뿐인가, CRLF로, 꼴맞추기 } from './edit-match.js';
14
15
  import { loadSkill } from '../skills/discover.js';
15
16
  import { WEB_FETCH_TOOL } from './webfetch.js';
16
17
  import { TODO_TOOL } from './todo.js';
@@ -71,6 +72,12 @@ const MAX_OUT = 30000;
71
72
  // Grep 이 열어 볼 파일 크기 상한. 이보다 크면 글 파일이라도 안 본다 —
72
73
  // 한 파일에서 몇십 초를 쓰면 그동안 화면이 멈춘 것처럼 보인다.
73
74
  const GREP_MAX_FILE = 2 * 1024 * 1024;
75
+ /*
76
+ * 콕 집은 파일 하나는 더 넉넉히 본다 — 「이 로그에서 찾아」 는 폴더를 훑는 것과 달리 사람이
77
+ * 그 파일을 골랐다는 뜻이다. 그래도 끝은 있다. 이 길은 파일을 통째로 글로 읽어서, GB 로그를
78
+ * 콕 집으면 메모리를 다 썼다(rg 가 있어도 파일 하나면 이 길로 온다). (6회차 Gemini 도구6f2 F2-1)
79
+ */
80
+ const GREP_MAX_ONE = 64 * 1024 * 1024;
74
81
  // 정규식으로 찾을 것이 없는 파일들. 열어 봐야 시간만 든다.
75
82
  // 목록은 tools/fastgrep.js 에 한 벌만 둔다 — rg 도 같은 목록으로 걸러야
76
83
  // 엔진이 달라도 같은 파일을 본다.
@@ -267,6 +274,126 @@ function 파일크기(abs) {
267
274
  try { return statSync(abs).size; } catch { return -1; }
268
275
  }
269
276
 
277
+ /*
278
+ * ── 크기만으로는 「그 파일 그대로」 를 모른다 ─────────────────────────────
279
+ *
280
+ * Append 의 두 기억(인코딩 · 줄 수)이 「크기가 같으면 남이 안 건드렸다」 로
281
+ * 캐시를 믿었다. 그런데 편집기의 「다른 이름으로 저장 → 바꿔치기」 나
282
+ * `iconv … > tmp && mv tmp 원래` 는 **같은 크기**로 끝나는 일이 흔하다
283
+ * (CP949 두 바이트 글자가 다른 두 바이트 글자로 바뀌는 식). 그러면 UTF-8 로
284
+ * 바뀐 파일 꼬리에 CP949 바이트가 붙고, 결과에는 `· CP949` 가 사실처럼 떴다.
285
+ *
286
+ * 줄 수는 크기 · 고친 시각(나노초) · 번호(ino)를 같이 본다 — 통째로 다시 세는
287
+ * 값이 커서 그 이상은 못 본다. 파일 시스템의 시각 눈금 안(윈도우 약 16ms,
288
+ * FAT 2초)에 같은 번호로 같은 크기를 덮어쓰면 이 표로는 못 가른다. 그때 틀리는
289
+ * 것은 화면의 줄 수 하나다.
290
+ *
291
+ * 인코딩은 틀리면 파일이 깨지므로 그 틈도 안 둔다 — 아래 표본지문.
292
+ */
293
+ function 파일표(abs) {
294
+ try {
295
+ const s = statSync(abs, { bigint: true });
296
+ return `${s.size}:${s.mtimeNs}:${s.ino}`;
297
+ } catch { return null; }
298
+ }
299
+
300
+ /**
301
+ * 인코딩을 잴 때 보는 앞머리 바이트(재는인코딩)의 지문.
302
+ *
303
+ * 재는인코딩 은 이 바이트만 보고 답을 낸다. 그러니 지문이 같으면 다시 재도
304
+ * 같은 답이다 — 시각 눈금이나 번호에 기대지 않고 캐시를 믿을 수 있다.
305
+ * 64KB 를 읽는 값은 붙이기 한 번에 비해 작고, 비싼 것(후보마다 풀어 점수 매기기)은 건너뛴다.
306
+ */
307
+ function 표본지문(buf) {
308
+ return buf ? createHash('sha1').update(buf).digest('base64') : null;
309
+ }
310
+
311
+ /** 쓰기가 깨진 까닭을 사람 말로. 날 오류 코드만 주면 모델은 같은 쓰기를 되풀이한다. */
312
+ function 쓰기실패말(err) {
313
+ const code = err?.code;
314
+ if (code === 'EPERM' || code === 'EACCES') return `쓸 권한이 없습니다 — 읽기 전용 파일이거나 권한이 없는 자리입니다 (${code})`;
315
+ if (code === 'EBUSY') return `다른 프로그램이 이 파일을 잡고 있습니다 — 엑셀·한글 같은 데서 열려 있으면 닫고 다시 하세요 (${code})`;
316
+ if (code === 'ENOSPC') return `디스크 자리가 모자랍니다 (${code})`;
317
+ if (code === 'EROFS') return `읽기 전용 드라이브입니다 (${code})`;
318
+ return err?.message ?? String(err);
319
+ }
320
+
321
+ /*
322
+ * ── 떠 놓고 **쓰다 깨지면** 방금 뜬 기록을 거둔다 ────────────────────────
323
+ *
324
+ * 스냅샷은 정말 쓰기 직전에 뜬다(한파일쓰기 머리말). 그런데 그 쓰기 자체가
325
+ * 깨지는 판이 남아 있었다 — 읽기 전용 파일(EPERM·EACCES), 엑셀이 잡고 있는
326
+ * 파일(EBUSY). 두 가지가 같이 샜다:
327
+ *
328
+ * · 날 오류가 도구 밖으로 던져졌다 (`EPERM: operation not permitted, open …`)
329
+ * · 파일은 한 글자도 안 바뀌었는데 이력에 그 턴이 남았다 — /undo 한 번이
330
+ * 그 헛턴에 먹히고, 사람이 되돌리려던 앞 턴의 진짜 변경은 그대로 남는다.
331
+ *
332
+ * 거두는 것은 **이번에 새로 뜬 기록**이고 **파일이 정말 그대로일 때**뿐이다.
333
+ * 같은 턴에 앞서 성공한 고치기의 기록은 턴 처음 모습이라 남겨야 하고,
334
+ * 쓰다 반쯤 깨진 파일은 기록이 있어야 /undo 로 되돌린다. 그대로인지는 쓰는
335
+ * 쪽이 가장 잘 안다(읽은 바이트 · 붙이기 전 크기) — 그래서 함수로 받는다.
336
+ *
337
+ * @returns {null | {error:string}} null 이면 썼다.
338
+ */
339
+ function 떠놓고쓰기(ctx, abs, label, 쓰기, 그대로인가) {
340
+ const 이력 = ctx.history;
341
+ const 새로뜸 = typeof 이력?.떴나 === 'function' && !이력.떴나(abs);
342
+ 이력.snapshot(abs, label);
343
+ try {
344
+ 쓰기();
345
+ return null;
346
+ } catch (err) {
347
+ let 그대로 = false;
348
+ try { 그대로 = !!그대로인가(); } catch { 그대로 = false; }
349
+ const 거둠 = 새로뜸 && 그대로 && 이력.버리기?.(abs) === true;
350
+ return {
351
+ error: `못 썼습니다: ${ctx.scope.show(abs)} — ${쓰기실패말(err)}\n`
352
+ + (그대로
353
+ ? ` (파일은 한 글자도 안 바뀌었습니다${거둠 ? ' — 되돌리기 이력에도 안 남겼습니다' : ''})`
354
+ : ' (쓰다가 깨져 파일이 반쯤 바뀌었을 수 있습니다 — /undo 로 이 턴 전으로 되돌릴 수 있습니다)'),
355
+ };
356
+ }
357
+ }
358
+
359
+ /*
360
+ * ── 절대경로로 적은 glob 무늬 ───────────────────────────────────────────
361
+ *
362
+ * Glob·Grep 은 무늬를 **상대경로**(`src/a.js`)와 견준다. 그래서 모델이 Read 에서
363
+ * 본 그대로 `C:/work/proj/src/*.js` · `/home/me/proj/src/*.ts` 로 적으면 한
364
+ * 파일에도 안 맞아 「찾은 파일 없음」 이 떴다 — 파일이 있는데. 작업 폴더 밖을
365
+ * 적어도 똑같이 「없음」 이라, 모델은 그 폴더가 비었다고 믿었다.
366
+ *
367
+ * 글로브 글자가 없는 앞머리를 떼어 경로로 풀고(범위 검사가 여기서 된다), 작업
368
+ * 폴더 기준 무늬로 바꾼다. 작업 폴더 자신이면 앞에 `/` 를 붙여 뿌리에 묶는다
369
+ * (fsutil.js 의 glob거르개 · rg 모두 `/` 로 시작하는 무늬를 뿌리에 묶어 읽는다).
370
+ */
371
+ function 절대무늬풀기(무늬, 기준, ctx) {
372
+ const 글 = String(무늬).replace(/\\/g, '/');
373
+ const 빼기 = 글.startsWith('!');
374
+ const 몸 = 빼기 ? 글.slice(1) : 글;
375
+ if (!isAbsolute(몸)) return { 무늬: 글 };
376
+ const 조각 = 몸.split('/');
377
+ let n = 조각.findIndex((x) => /[*?[{]/.test(x));
378
+ if (n < 0) n = 조각.length - 1; // 글로브 글자가 없으면 마지막 조각이 파일 이름이다
379
+ const 앞머리 = 조각.slice(0, n).join('/') || '/';
380
+ const 나머지 = 조각.slice(n).join('/');
381
+ let 자리;
382
+ try { 자리 = ctx.scope.resolve(앞머리); } catch (err) {
383
+ return {
384
+ error: `${String(err?.message ?? err).split('\n')[0]}\n`
385
+ + ` 무늬 ${무늬} 는 작업 범위 밖을 가리킵니다 — 「없음」 이 아니라 안 찾아본 것입니다.`
386
+ + ' 작업 폴더 안의 경로로 적거나 src/**/*.js 처럼 상대 무늬로 주세요.',
387
+ };
388
+ }
389
+ const rel = relative(기준, 자리);
390
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
391
+ return { error: `무늬의 자리(${ctx.scope.show(자리)})가 찾는 폴더(${ctx.scope.show(기준)}) 밖입니다: ${무늬}\n path 를 빼거나 그 폴더 안의 무늬로 주세요.` };
392
+ }
393
+ const 앞 = ctx.scope.show(자리);
394
+ return { 무늬: `${빼기 ? '!' : ''}${앞 === '.' ? '' : 앞}/${나머지}` };
395
+ }
396
+
270
397
  function 바이너리인가(abs) {
271
398
  if (!existsSync(abs)) return null;
272
399
  const buf = 앞머리(abs, 냄새맡을바이트);
@@ -323,11 +450,29 @@ function 글자경계까지(buf, 잘렸나) {
323
450
  return buf;
324
451
  }
325
452
 
326
- function 재는인코딩(abs) {
453
+ /*
454
+ * ── 위 자는 **UTF-8 경계만** 안다 ─────────────────────────────────────────
455
+ *
456
+ * CP949·Shift_JIS·GBK·Big5 는 한 글자가 두 바이트고, 64KB 에서 자르면 앞
457
+ * 바이트 하나가 외톨이로 남는 일이 절반이다. 위 자는 그 바이트를 UTF-8 의
458
+ * 앞 바이트로 읽어 **한 바이트를 더 깎기도** 했다. 엄격하게 푸는 CP949
459
+ * 후보는 그 외톨이 하나에 통째로 떨어지고, 남는 것은 아무 바이트나 받는
460
+ * CP1252 였다:
461
+ *
462
+ * 64KB 넘는 사내 CP949 로그에 「추가 줄입니다」 Append
463
+ * → 「이 파일은 CP1252 로 되어 있는데, 그 인코딩에 없는 글자」 (거절)
464
+ * 같은 파일에 「Total · 100」 Append
465
+ * → 이어 붙임 · CP1252 — `·` 가 B7 한 바이트로 붙는다 (조용히 깨짐)
466
+ *
467
+ * 그래서 판정기에 **잘라 온 표본**이라고 알린다(encoding.js 의 guess). 끝의
468
+ * 반쪽 글자는 탈로 치지 않고, 한가운데의 없는 조합은 여전히 거른다.
469
+ */
470
+ function 재는인코딩(abs, 읽어둔것 = null) {
327
471
  try {
328
- const buf = 앞머리(abs, 인코딩볼바이트);
472
+ const buf = 읽어둔것 ?? 앞머리(abs, 인코딩볼바이트);
329
473
  if (!buf) return 'utf-8';
330
- return decodeBytes(글자경계까지(buf, buf.length >= 인코딩볼바이트)).encoding;
474
+ const 잘렸나 = buf.length >= 인코딩볼바이트;
475
+ return decodeBytes(글자경계까지(buf, 잘렸나), { 잘림: 잘렸나 }).encoding;
331
476
  } catch { return 'utf-8'; }
332
477
  }
333
478
 
@@ -380,6 +525,37 @@ function 뜰만한낱말(cmd) {
380
525
  return out;
381
526
  }
382
527
 
528
+ /*
529
+ * ── `cd 하위 && rm x` — cd 한 자리 기준으로도 푼다 (6회차 직접 사냥 M2 · Gemini 셸뜨기6m N2) ──────
530
+ *
531
+ * 낱말을 작업 폴더 기준으로만 풀어서, 모델이 아주 흔히 쓰는 `cd sub && rm x.txt` 에서 sub/x.txt 를 못 떴다.
532
+ * 뜬 것이 0이라 되돌린다는 말도 없었지만 /undo 뒤에도 안 돌아왔다. `cd sub && echo hi > new.txt` 로 만든 파일도
533
+ * 루트의 new.txt 로 떠져 sub/new.txt 는 /undo 뒤에 남았다.
534
+ *
535
+ * cd 뒤의 낱말은 **두 자리 다** 낸다 — 작업 폴더 기준과 cd 한 자리 기준. 어느 쪽이 맞는지는 명령줄만 보고는
536
+ * 모른다(`cd sub; cd ..` · 서브셸 괄호). 헛다리를 짚어도 없는 자리는 넘어가므로(위 머리말) 손해가 없다.
537
+ * cd 가 가리키는 것이 작업 폴더 안의 **있는 폴더**일 때만 기준을 옮긴다. cmd 의 `cd /d` 같은 스위치는 건너뛴다.
538
+ */
539
+ function 풀낱말들(cmd, ctx) {
540
+ const out = [];
541
+ let cd자리 = null;
542
+ let cd다음 = false;
543
+ for (const t of 뜰만한낱말(cmd)) {
544
+ if (cd다음) {
545
+ if (/^\/[a-z]$/i.test(t)) continue;
546
+ cd다음 = false;
547
+ const 갈곳 = isAbsolute(t) || !cd자리 ? t : join(cd자리, t);
548
+ let 폴더인가 = false;
549
+ try { const a = ctx.scope.resolve(갈곳); 폴더인가 = existsSync(a) && statSync(a).isDirectory(); } catch { /* 밖이면 기준을 안 옮긴다 */ }
550
+ if (폴더인가) { cd자리 = 갈곳; continue; }
551
+ }
552
+ if (t === 'cd' || t === 'pushd') { cd다음 = true; continue; }
553
+ out.push(t);
554
+ if (cd자리 && !isAbsolute(t)) out.push(join(cd자리, t));
555
+ }
556
+ return out;
557
+ }
558
+
383
559
  /**
384
560
  * 파일을 바꾸는 Bash 명령이면, 손대기 전 내용을 떠 둔다.
385
561
  *
@@ -414,19 +590,30 @@ function 바꾸기전스냅샷(cmd, ctx) {
414
590
  * (safety/guard.js).
415
591
  */
416
592
  const 셸쓰기 = 셸이파일에쓰나(cmd);
417
- if (!isMutating(cmd) && !셸쓰기) return { 뜬것: [], 못뜬것: [], 상한걸림: false };
593
+ if (!isMutating(cmd) && !셸쓰기) return { 뜬것: [], 못뜬것: [], 상한걸림: false, 나중볼것: [] };
418
594
  const 뜬것 = [];
419
595
  const 못뜬것 = [];
420
596
  let 넘쳤나 = false;
421
- for (const t of 뜰만한낱말(cmd)) {
422
- if (뜬것.length + 못뜬것.length >= 스냅샷상한) { 넘쳤나 = true; break; }
597
+ // 명령 **뒤에** 볼 자리 — 지금 없는 이름과, 폴더 안으로 옮기거나 복사할 때의 새 이름 (나중에생긴것적기).
598
+ const 나중볼것 = [];
599
+ const 폴더들 = [];
600
+ const 있던파일 = [];
601
+ // 쓰는 꼴이라 **지금 없는 이름**을 미리 떠 둔 자리 — 명령 뒤에 정말 생긴 것만 화면 목록에 올린다 (나중에생긴것적기).
602
+ const 없던채뜬것 = [];
603
+ const 센것 = () => 뜬것.length + 못뜬것.length + 없던채뜬것.length;
604
+ // cd 기준으로 한 번 더 풀면 같은 자리가 두 번 나올 수 있다 — 한 번만 뜨고 한 번만 말한다.
605
+ const 본자리 = new Set();
606
+ for (const t of 풀낱말들(cmd, ctx)) {
607
+ if (센것() >= 스냅샷상한) { 넘쳤나 = true; break; }
423
608
  let abs;
424
609
  // 범위 밖은 어차피 checkPaths 가 이미 막았다. 여기서 터지면 안 된다 —
425
610
  // 뜨는 데 실패했다고 명령 자체를 막으면 안 되는 명령까지 막힌다.
426
611
  try { abs = ctx.scope.resolve(t); } catch { continue; }
612
+ if (본자리.has(abs)) continue;
613
+ 본자리.add(abs);
427
614
  try {
428
615
  const 있나 = existsSync(abs);
429
- if (있나 && statSync(abs).isDirectory()) continue;
616
+ if (있나 && statSync(abs).isDirectory()) { 폴더들.push(abs); continue; }
430
617
  /*
431
618
  * 쓰는 꼴이면 **없는 파일도** 떠 둔다.
432
619
  *
@@ -436,8 +623,12 @@ function 바꾸기전스냅샷(cmd, ctx) {
436
623
  *
437
624
  * 옮기기·지우기(isMutating)는 반대다. 없는 파일을 떠 두면 `rm *.tmp`
438
625
  * 한 번에 이력이 쓰레기로 찬다. 그래서 이 갈래는 쓰는 꼴에만 연다.
626
+ *
627
+ * 다만 `cp a b` · `mv a b` 의 새 이름(b)은 명령이 **만든다.** 안 뜨면 /undo 뒤에 사본이
628
+ * 남고 mv 는 두 벌이 됐다(6회차). 미리 뜨지 않고 명령 뒤에 **정말 생겼을 때만** 없던
629
+ * 자리로 적는다 — 헛기록은 안 쌓이고 새 이름은 되돌아간다.
439
630
  */
440
- if (!있나 && !셸쓰기) continue;
631
+ if (!있나 && !셸쓰기) { 나중볼것.push(abs); continue; }
441
632
  /*
442
633
  * ── 뜬 것과 **못 뜬 것**을 갈라 담는다 ──────────────────────────
443
634
  *
@@ -452,7 +643,10 @@ function 바꾸기전스냅샷(cmd, ctx) {
452
643
  * **안전망이 있다고 말하는데 없는 것**이다.
453
644
  */
454
645
  const rec = ctx.history.snapshot(abs, 'Bash');
646
+ if (있나) 있던파일.push(abs);
455
647
  if (rec?.skipped) 못뜬것.push(`${ctx.scope.show(abs)} (${rec.skipped})`);
648
+ // 지금 없는 이름(쓰는 꼴)은 명령 뒤에 정말 생겼을 때만 목록에 올린다 — `echo` · `new` 같은 낱말까지 「떠 뒀습니다」 로 찍혔다 (6회차 직접 사냥 M1).
649
+ else if (!있나) 없던채뜬것.push(abs);
456
650
  else 뜬것.push(ctx.scope.show(abs));
457
651
  } catch { /* 못 뜨면 그냥 넘어간다. 명령은 돌아야 한다 */ }
458
652
  }
@@ -464,7 +658,60 @@ function 바꾸기전스냅샷(cmd, ctx) {
464
658
  * 마흔 개가 다 되돌아갈 줄 안다. Move 가 같은 자리에서 이미 말해 준다
465
659
  * (되돌리기반쪽) — 여기만 안 말하고 있었다.
466
660
  */
467
- return { 뜬것, 못뜬것, 상한걸림: 넘쳤나 };
661
+ // 폴더 안으로 옮기거나 복사하면(`mv a.txt sub`) 새 이름은 `sub/a.txt` 다.
662
+ for (const 폴더 of 폴더들) {
663
+ for (const 파일 of 있던파일) {
664
+ const 새자리 = join(폴더, relative(dirname(파일), 파일));
665
+ /*
666
+ * 폴더 안에 **같은 이름이 이미 있으면** 지금 뜬다 (6회차 Gemini 셸뜨기6m N1).
667
+ *
668
+ * 무조건 명령 뒤로 미뤘더니 `cp a.txt sub` 가 덮어쓴 sub/a.txt 를 명령 뒤에 「원래 없던 자리」 로 적어, /undo 가
669
+ * 옛 내용으로 되돌리기는커녕 **지웠다**(mv 도). 없을 때만 명령 뒤에 본다. 있는데 못 뜨면(상한 · 못 읽음)
670
+ * 명령 뒤로 미루지 않는다 — 있던 파일을 없던 자리로 적는 것이 이 자리에서 제일 나쁜 꼴이다.
671
+ */
672
+ if (본자리.has(새자리)) continue;
673
+ 본자리.add(새자리);
674
+ let 있음 = true;
675
+ try { 있음 = existsSync(새자리); } catch { /* 모르면 있다고 본다 */ }
676
+ if (!있음) { 나중볼것.push(새자리); continue; }
677
+ if (센것() >= 스냅샷상한) { 넘쳤나 = true; continue; }
678
+ try {
679
+ if (statSync(새자리).isDirectory()) continue;
680
+ const rec = ctx.history.snapshot(새자리, 'Bash');
681
+ if (rec?.skipped) 못뜬것.push(`${ctx.scope.show(새자리)} (${rec.skipped})`);
682
+ else 뜬것.push(ctx.scope.show(새자리));
683
+ } catch { /* 못 뜨면 넘어간다 — 명령은 돌아야 한다 */ }
684
+ }
685
+ }
686
+ /*
687
+ * 명령 **뒤에** 볼 자리도 상한에 걸리면 걸렸다고 말한다.
688
+ *
689
+ * 아래 slice 가 뒤엣것을 말없이 잘라 내고 있었다. 잘린 이름은 나중에생긴것적기()
690
+ * 가 아예 안 보므로, `mv a.txt n00.txt … n29.txt` 처럼 새 이름이 많으면 그중
691
+ * 몇 개는 정말 생겨도 되돌리기에 안 올라간다. 그래 놓고 상한걸림 은 false 였다 —
692
+ * 위 @returns 가 「대상이 너무 많아 뒤엣것은 보지도 못했다」 로 약속한 바로 그 값이
693
+ * 앞 갈래(뜬 개수)에서만 참이 됐다. 같은 성격의 누락이라 같이 말한다.
694
+ */
695
+ if (나중볼것.length > 스냅샷상한) 넘쳤나 = true;
696
+ return { 뜬것, 못뜬것, 상한걸림: 넘쳤나, 나중볼것: 나중볼것.slice(0, 스냅샷상한), 없던채뜬것 };
697
+ }
698
+
699
+ /**
700
+ * 명령이 끝난 뒤, 앞서 없던 이름 가운데 **새로 생긴 파일**을 되돌릴 거리로 적는다 (safety/undo.js 없던자리기록).
701
+ * 뜬것 목록에도 넣어 화면이 「되돌릴 수 있다」 를 사실대로 말하게 한다. 폴더는 안 적는다 — 위 머리말과 같다.
702
+ */
703
+ function 나중에생긴것적기(떠본것, ctx) {
704
+ for (const abs of 떠본것?.나중볼것 ?? []) {
705
+ try {
706
+ if (!existsSync(abs) || statSync(abs).isDirectory()) continue;
707
+ const rec = ctx.history.없던자리기록?.(abs, 'Bash');
708
+ if (rec) 떠본것.뜬것.push(ctx.scope.show(abs));
709
+ } catch { /* 못 적으면 넘어간다 — 명령은 이미 돌았다 */ }
710
+ }
711
+ // 쓰는 꼴이라 미리 떠 둔 없는 이름 — 명령 뒤에 정말 생겼으면 그때 목록에 올린다 (바꾸기전스냅샷 · 6회차 직접 사냥 M1).
712
+ for (const abs of 떠본것?.없던채뜬것 ?? []) {
713
+ try { if (existsSync(abs) && !statSync(abs).isDirectory()) 떠본것.뜬것.push(ctx.scope.show(abs)); } catch { /* 넘어간다 */ }
714
+ }
468
715
  }
469
716
 
470
717
  /** 지금 파일이 몇 줄인가. 붙인 뒤 '얼마나 찼는지' 를 사실로 말해 주려고 센다. */
@@ -562,9 +809,10 @@ async function 엑셀읽기(abs, args, ctx) {
562
809
  * 아무것도 못 읽는 것보다 낫다.
563
810
  */
564
811
  const 빌린것 = await 빌려읽기(abs, ctx, r.error);
565
- if (빌린것) return 빌린것;
566
- if (직접못읽나(abs)) return { error: 못바꿈말(ctx.scope.show(abs), extname(abs)), 끝났다: true };
567
- return { error: r.error };
812
+ if (빌린것?.content) return 빌린것;
813
+ const 덧 = 빌린덧말(빌린것);
814
+ if (직접못읽나(abs)) return { error: 못바꿈말(ctx.scope.show(abs), extname(abs)) + 덧, 끝났다: true };
815
+ return { error: r.error + 덧 };
568
816
  }
569
817
 
570
818
  const { text, 잘림 } = excelText(r.sheets);
@@ -599,7 +847,17 @@ async function 엑셀읽기(abs, args, ctx) {
599
847
  * 그 프로그램이다. 그런데 모델은 그걸 부를 수도 없었고(울타리에 막혔다),
600
848
  * 있는지 볼 수도 없었다.
601
849
  *
602
- * @returns {object|null} 읽어냈으면 도구 결과. 못 하면 null (부르는 쪽이 원래 오류를 낸다)
850
+ * ── 못 했으면 **왜** 못 했는지 같이 돌려준다 (막판 훑기) ─────────────────
851
+ *
852
+ * 여태 실패를 전부 `null` 로 뭉쳤다. 그러면 부르는 쪽은 제 원래 오류만 내고,
853
+ * `글로바꾸기` 가 애써 적어 둔 까닭 — 「변환기가 종료 77 로 끝났습니다
854
+ * (javaldx failed…)」, 「바꿔 놓을 자리를 못 만들었습니다: EACCES」 — 이
855
+ * 아무 데도 안 남았다. 화면에는 「deel 이 직접 못 읽습니다」 한 줄뿐이라,
856
+ * LibreOffice 가 깔려 있는데 자바가 없어서 진 것인지 파일이 진짜 깨진 것인지
857
+ * 가릴 길이 없다. 사람이 할 일이 완전히 다른 두 경우가 같은 말로 끝났다.
858
+ *
859
+ * @returns {{content:string,summary:string}|{왜:string}|null}
860
+ * 읽어냈으면 도구 결과. **불렀는데** 실패했으면 `{왜}`. 아예 안 불렀으면 null.
603
861
  */
604
862
  async function 빌려읽기(abs, ctx, 원래오류) {
605
863
  if (!바꿔볼까(abs)) return null;
@@ -614,7 +872,13 @@ async function 빌려읽기(abs, ctx, 원래오류) {
614
872
  // signal 을 같이 넘긴다. 멈추라고 하면 soffice 를 죽여야 한다 — 안 그러면
615
873
  // ESC 를 듣고도 남은 90초를 그대로 기다린다.
616
874
  const r = await 글로바꾸기(abs, root, { 찾은것: 있는것, signal: ctx.signal ?? null });
617
- if (!r.ok || !r.text.trim()) return null;
875
+ if (!r.ok || !r.text.trim()) {
876
+ // `없음:true` 는 「이 갈래를 받는 변환기가 없다」 는 뜻이라 못바꿈말 이 이미
877
+ // 제 말로 설명한다. 여기서 또 얹으면 같은 말이 두 번 나간다.
878
+ if (r.없음) return null;
879
+ const 왜 = r.ok ? '변환기가 빈 글을 돌려줬습니다' : String(r.왜 ?? '').trim();
880
+ return 왜 ? { 왜 } : null;
881
+ }
618
882
 
619
883
  const 줄수 = r.text.split('\n').length;
620
884
  return {
@@ -628,6 +892,16 @@ async function 빌려읽기(abs, ctx, 원래오류) {
628
892
  };
629
893
  }
630
894
 
895
+ /**
896
+ * 빌려읽기 가 돌려준 실패 까닭을 오류 뒤에 붙일 한 줄로.
897
+ *
898
+ * 붙이는 자리가 넷이라 여기 한 번만 적는다 — 따로 적으면 한쪽만 고쳐진다.
899
+ * 읽어냈거나 아예 안 불렀으면 빈 글자다(붙일 것이 없다).
900
+ */
901
+ function 빌린덧말(빌린것) {
902
+ return 빌린것?.왜 ? `\n(이 PC 의 변환기로도 해 봤지만 실패했습니다: ${빌린것.왜})` : '';
903
+ }
904
+
631
905
  /**
632
906
  * Figma 시안을 글로 읽어 돌려준다.
633
907
  *
@@ -638,15 +912,23 @@ function fig읽기(abs, ctx) {
638
912
  const r = readFig(abs);
639
913
  if (!r.ok) return { error: r.error, ...(r.끝났다 ? { 끝났다: true } : {}) };
640
914
  const { text, 잘림 } = docText(r.덩이들);
915
+ /*
916
+ * 안내를 **앞에** 붙인다 — 머리말이 적어 둔 그대로.
917
+ *
918
+ * 여태 글 뒤에 붙였다. 그러면 창이 좁을 때 clip 이 끝을 잘라 안내가 통째로
919
+ * 사라진다. 긴 시안 하나를 8k 창에서 열어 보니 남은 것은 짜임 글뿐이었다 —
920
+ * 「그림·색·글꼴은 안 나옵니다」 도, 「Edit/Write 로 못 고칩니다」 도 한 글자도
921
+ * 안 실렸다. 모델은 색까지 다 봤다고 여기고 답하고, 못 고치는 파일을 고치려 든다.
922
+ *
923
+ * 앞에 두면 잘리는 것은 짜임 글이고, 그건 요약이 「일부만」 으로 말해 준다.
924
+ * 잘라 낼 몫도 안내 길이를 뺀 나머지로 잡는다 — 안 그러면 안내를 붙인 만큼
925
+ * 실을만큼() 을 넘겨 버린다.
926
+ */
927
+ const 안내 = '(.fig 시안을 짜임과 글로 바꿔서 보여준 것입니다. 그림·색·글꼴은 안 나옵니다.\n'
928
+ + ' 이 파일은 Edit/Write 로 고칠 수 없습니다.)'
929
+ + ([...(r.말 ?? []), ...잘림].length ? `\n(${[...(r.말 ?? []), ...잘림].join(' · ')})` : '');
641
930
  return {
642
- content: clip(
643
- `${text}
644
-
645
- (.fig 시안을 짜임과 글로 바꿔서 보여준 것입니다. 그림·색·글꼴은 안 나옵니다.
646
- 이 파일은 Edit/Write 로 고칠 수 없습니다.)`
647
- + ([...(r.말 ?? []), ...잘림].length ? `\n(${[...(r.말 ?? []), ...잘림].join(' · ')})` : ''),
648
- 실을만큼(ctx),
649
- ),
931
+ content: `${안내}\n\n${clip(text, Math.max(200, 실을만큼(ctx) - 안내.length - 2))}`,
650
932
  summary: figSummary(r) + (잘림.length ? ' · 일부만' : ''),
651
933
  };
652
934
  }
@@ -656,10 +938,10 @@ async function 문서읽기(abs, ctx) {
656
938
  if (!r.ok) {
657
939
  // 우리가 못 읽는다고 끝이 아니다. 이 PC 에 변환기가 있으면 빌려 본다.
658
940
  const 빌린것 = await 빌려읽기(abs, ctx, r.error);
659
- if (빌린것) return 빌린것;
941
+ if (빌린것?.content) return 빌린것;
660
942
  // 끝났다 를 그대로 넘긴다. 여기서 떨구면 docs.js 가 「다시 열어도 같다」고
661
943
  // 판정해 놓은 것이 루프까지 못 가서, 되풀이 억제가 안 걸린다.
662
- return { error: r.error, ...(r.끝났다 ? { 끝났다: true } : {}) };
944
+ return { error: r.error + 빌린덧말(빌린것), ...(r.끝났다 ? { 끝났다: true } : {}) };
663
945
  }
664
946
  const { text, 잘림 } = docText(r.덩이들);
665
947
  return {
@@ -781,25 +1063,108 @@ async function 한개옮기기({ from, to, overwrite = false }, ctx) {
781
1063
  // 읽기만 막고 옮기기를 열어 두면 .deel/config.json 을 옮겨 연결을 끊을 수 있다.
782
1064
  for (const p of [앞, 뒤]) { const 왜 = 내부살림(p); if (왜) return { error: 왜 }; }
783
1065
 
1066
+ /*
1067
+ * ── 링크(정션·심볼릭 링크) **자체**는 옮기지 않는다 ─────────────────────
1068
+ *
1069
+ * 폴더 링크를 옮기면 아래 walk 가 링크 너머의 진짜 파일을 짝지어 뜬다.
1070
+ * 옮기는 것은 링크 하나인데, /undo 는 「새 자리의 x.txt 는 원래 없던 것」
1071
+ * 이라며 지운다 — 새 자리는 여전히 진짜 폴더를 가리키므로 **진짜 파일이
1072
+ * 지워진다.** 헌 자리에는 링크 대신 사본 폴더가 생긴다. 안전망이 파일을
1073
+ * 지우는 꼴이라, 되돌리기(safety/undo.js)가 링크를 모르는 한 여기서 막는다.
1074
+ */
1075
+ const 링크인가 = (p) => { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } };
1076
+ if (링크인가(앞) || 링크인가(뒤)) {
1077
+ const 어느것 = 링크인가(앞) ? from : to;
1078
+ return {
1079
+ error: `${어느것} 는 링크(정션·심볼릭 링크)라 옮기지 않습니다 — 되돌리기가 링크를 따라가 진짜 파일을 지울 수 있습니다.\n`
1080
+ + ' 링크가 가리키는 폴더 안의 파일을 옮기거나, 링크를 옮겨야 하면 사용자에게 직접 해 달라고 하세요.',
1081
+ };
1082
+ }
1083
+
784
1084
  const 폴더인가 = statSync(앞).isDirectory();
1085
+ /*
1086
+ * ── 대소문자만 다른 **같은 파일** (윈도우·맥) ──────────────────────────
1087
+ *
1088
+ * 이 파일 시스템들은 `a.txt` 와 `A.txt` 를 한 파일로 친다. 글자로만 견주면
1089
+ * 두 가지가 틀어졌다 —
1090
+ *
1091
+ * Move a.txt → A.txt 「이미 있습니다」 (자기 자신과 겹친다고 했다)
1092
+ * Move src → SRC/inner EINVAL 날 오류 (제 안인 것을 못 알아봤다)
1093
+ *
1094
+ * 글자가 대소문자만 다를 때 **디스크의 같은 물건인지**를 번호(dev·ino)로 본다.
1095
+ * 리눅스처럼 가리는 판에서는 서로 다른 파일이라 이 갈래에 안 들어온다.
1096
+ */
1097
+ const 같은물건 = (p, q) => {
1098
+ try {
1099
+ const a = statSync(p, { bigint: true });
1100
+ const b = statSync(q, { bigint: true });
1101
+ return a.ino !== 0n && a.ino === b.ino && a.dev === b.dev;
1102
+ } catch { return false; }
1103
+ };
1104
+ const 이름만바꿈 = 앞.toLowerCase() === 뒤.toLowerCase() && 같은물건(앞, 뒤);
785
1105
  /*
786
1106
  * 폴더를 **제 안으로** 옮기면 그 폴더가 통째로 사라진다 (`mv a a/b`).
787
1107
  * 셸에서도 잘 나는 사고라 여기서 막는다.
788
1108
  */
789
- if (폴더인가 && (뒤 + sep).startsWith(앞 + sep)) {
1109
+ const 제안인가 = (뒤 + sep).startsWith(앞 + sep)
1110
+ || ((뒤.toLowerCase() + sep).startsWith(앞.toLowerCase() + sep) && 같은물건(앞, 뒤.slice(0, 앞.length)));
1111
+ if (폴더인가 && 제안인가 && !이름만바꿈) {
790
1112
  return { error: `폴더를 제 안으로 옮길 수 없습니다: ${from} → ${to}` };
791
1113
  }
792
1114
 
793
1115
  /*
794
- * 닿을 자리에 이미 있으면 **기본값은 거절**이다.
1116
+ * ── 옮기다 깨질 것이 뻔한 것은 **뜨기 전에** 거절한다 ────────────────────
1117
+ *
1118
+ * 아래에서 스냅샷을 뜬 다음 renameSync·mkdirSync 가 깨지면, 파일은 한
1119
+ * 글자도 안 움직였는데 되돌리기 이력에 그 턴이 남는다. /undo 는 그 헛턴을
1120
+ * 되돌리고 「되돌린수=1」 을 찍고, 사람이 되돌리려던 **앞 턴**의 진짜
1121
+ * 변경은 그대로 남는다. 한파일쓰기 가 「스냅샷은 정말 쓰기 직전에」 로
1122
+ * 막아 둔 그 꼴이 옮기기에서만 살아 있었다.
1123
+ *
1124
+ * 폴더 → 이미 있는 폴더 (overwrite) 윈도우 EPERM · 리눅스 ENOTEMPTY
1125
+ * 파일 → 이미 있는 폴더 (overwrite) EISDIR (스냅샷이 폴더를 읽다 날 오류로 던졌다)
1126
+ * 폴더 → 이미 있는 파일 (overwrite) EPERM · ENOTDIR
1127
+ * 길 중간이 파일 (f.txt/b.txt) mkdir EEXIST · ENOTDIR (날 오류로 던졌다)
1128
+ *
1129
+ * 폴더를 폴더 위에 덮어쓰는 것은 되돌리기로도 못 지킨다 — 닿을 자리에 있던
1130
+ * 파일들은 뜨지도 않는다. 막는 편이 맞다.
1131
+ *
1132
+ * ── 이 관문이 **overwrite 안내보다 먼저**여야 하는 이유 ──────────────────
1133
+ *
1134
+ * 아래 「덮어쓰려면 overwrite: true 를 주세요」 가 먼저 있었다. 그래서 있는
1135
+ * 폴더 위로 옮기면 두 말이 서로 어긋났다 —
1136
+ *
1137
+ * Move(a, b) → 덮어쓰려면 overwrite: true 를 주세요
1138
+ * Move(a, b, overwrite:true) → b 는 이미 있는 폴더라 덮어쓸 수 없습니다
1139
+ *
1140
+ * 시킨 대로 했는데 거절이다. 모델은 그 한 걸음을 반드시 밟으므로 걸음이
1141
+ * 통째로 버려지고, 작은 모델은 같은 자리를 맴돈다. 「폴더는 못 덮어쓴다」
1142
+ * 가 규칙이면 **첫 안내**가 그 말을 해야 한다 — 그리고 되는 길(이름까지
1143
+ * 적어서 그 안으로 넣기)을 같이 준다.
1144
+ */
1145
+ if (existsSync(뒤) && !이름만바꿈) {
1146
+ if (statSync(뒤).isDirectory()) {
1147
+ return { error: `${to} 는 이미 있는 폴더라 덮어쓸 수 없습니다 — 그 안으로 옮기려면 to 에 ${to}/${from.split(/[\\/]/).filter(Boolean).pop()} 처럼 이름까지 적어 주세요.` };
1148
+ }
1149
+ if (폴더인가) {
1150
+ return { error: `${to} 는 이미 있는 파일이라 폴더로 덮어쓸 수 없습니다 — 다른 이름을 주세요.` };
1151
+ }
1152
+ }
1153
+ /*
1154
+ * 닿을 자리에 이미 **파일**이 있으면 기본값은 거절이다.
795
1155
  *
796
1156
  * 조용히 덮어쓰면 그 파일 내용이 그 자리에서 없어진다. 구조를 바꾸는 일은
797
1157
  * 파일을 스무 개씩 옮기는 일이라, 이름이 겹치는 것이 드물지 않다.
798
- * 겹쳤다고 알려 주면 모델이 이름을 고쳐서 다시 부른다.
1158
+ * 겹쳤다고 알려 주면 모델이 이름을 고쳐서 다시 부른다. 여기까지 왔으면
1159
+ * 파일 → 파일 뿐이라, overwrite: true 는 **정말 되는 길**이다.
799
1160
  */
800
- if (existsSync(뒤) && !overwrite) {
1161
+ if (existsSync(뒤) && !overwrite && !이름만바꿈) {
801
1162
  return { error: `${말('err.alreadyThere', { 경로: to })}\n ${말('err.overwriteHint')}` };
802
1163
  }
1164
+ try { mkdirSync(dirname(뒤), { recursive: true }); }
1165
+ catch (err) {
1166
+ return { error: `못 옮겼습니다: ${to} 로 가는 길에 폴더를 만들 수 없습니다 — 길 중간에 같은 이름의 파일이 있는지 보세요 (${err.code ?? err.message})` };
1167
+ }
803
1168
 
804
1169
  /*
805
1170
  * 되돌릴 수 있게 뜬다.
@@ -821,7 +1186,8 @@ async function 한개옮기기({ from, to, overwrite = false }, ctx) {
821
1186
  * 뜨는 것은 여기서 훑은 것뿐이라, 2만 개가 넘는 폴더를 옮기면 `/undo` 가
822
1187
  * 앞의 2만 개만 되돌린다. 그걸 말 안 하면 사람은 되돌렸다고 믿고 넘어간다.
823
1188
  */
824
- const 되돌리기반쪽 = !!훑은것?.잘림;
1189
+ // 대소문자만 바꾸는 것은 이름 하나로 되돌린다 — 훑은 수와 상관없다 (아래 이름바꿈기록).
1190
+ const 되돌리기반쪽 = !!훑은것?.잘림 && !이름만바꿈;
825
1191
  /*
826
1192
  * ── 살림 폴더는 **옮겨지는데 세지도 뜨지도 않는다** ────────────────
827
1193
  *
@@ -838,23 +1204,70 @@ async function 한개옮기기({ from, to, overwrite = false }, ctx) {
838
1204
  * 나쁘다. 대신 **말은 한다.** 바로 아래 되돌리기반쪽 이 같은 성격의
839
1205
  * 누락을 이미 말해 주고 있어서, 한쪽만 말하는 것이 그 자체로 어긋남이다.
840
1206
  */
841
- const 안뜬살림 = 폴더인가 ? (훑은것?.건너뛴살림 ?? []) : [];
842
- for (const [a, b] of 짝들) {
843
- ctx.history.snapshot(a, 'Move');
844
- ctx.history.snapshot(b, 'Move');
1207
+ const 안뜬살림 = 폴더인가 && !이름만바꿈 ? (훑은것?.건너뛴살림 ?? []) : [];
1208
+ let 이름못되돌림 = false;
1209
+ // 옮기기가 깨지면 **이번에 새로 뜬 기록만** 거둔다 (떠놓고쓰기 머리말).
1210
+ const 이번에뜬것 = [];
1211
+ let 이름기록 = null;
1212
+ if (!이름만바꿈) {
1213
+ for (const [a, b] of 짝들) {
1214
+ for (const p of [a, b]) {
1215
+ if (typeof ctx.history.떴나 === 'function' && !ctx.history.떴나(p)) 이번에뜬것.push(p);
1216
+ ctx.history.snapshot(p, 'Move');
1217
+ }
1218
+ }
1219
+ } else if (typeof ctx.history.이름바꿈기록 === 'function') {
1220
+ /*
1221
+ * ── 대소문자만 바꿀 때는 **이름 바꾸기 자체**를 적는다 ──────────────
1222
+ *
1223
+ * 예전에는 뜨는 순서로 이름을 되돌렸다 — [A.txt: 없던 파일] 을 먼저,
1224
+ * [a.txt: 내용] 을 뒤에 남겨 /undo 가 지우고 다시 만들게. 그러려고 파일을
1225
+ * 잠깐 비켜 두기까지 했다. 세 곳에서 샜다:
1226
+ *
1227
+ * · 이 턴에 이미 손댄 파일 — 앞 기록이 먼저라 순서를 못 바꿔 「이름은 못
1228
+ * 되돌린다」 고만 했다
1229
+ * · 폴더 — 안의 파일만 떠서 /undo 뒤에도 폴더 이름은 DIR 로 남았다
1230
+ * · 그림처럼 내용을 못 뜨는 파일 — 지우지도 다시 만들지도 못해 이름이 남았다
1231
+ *
1232
+ * 이름 바꾸기는 내용을 안 바꾼다. 되돌릴 것은 이름 하나라 그걸 그대로
1233
+ * 적는다. /undo 는 내용을 다 되돌린 **뒤에** 이름을 되돌린다
1234
+ * (safety/undo.js 의 이름바꿈기록). 같은 턴의 앞 기록과 순서가 안 얽힌다.
1235
+ */
1236
+ 이름기록 = ctx.history.이름바꿈기록(앞, 뒤, 'Move');
1237
+ } else {
1238
+ // 이름 바꾸기를 적을 줄 모르는 이력이다(검사의 대역 등). 내용은 안 바뀌니 잃을 것은 없다.
1239
+ 이름못되돌림 = true;
845
1240
  }
846
1241
 
847
- mkdirSync(dirname(뒤), { recursive: true });
848
1242
  let 원본남음 = null;
1243
+ /*
1244
+ * 옮기는 함수는 갈아 끼울 수 있게 둔다. rename 이 EBUSY 로 깨지는 판(다른
1245
+ * 프로그램이 잡고 있는 파일)은 검사에서 파일 시스템으로 못 만든다
1246
+ * (test/undo.test.js 의 쓰기-실패-턴).
1247
+ */
1248
+ const 이름바꾸기 = ctx.옮기기fs?.renameSync ?? renameSync;
849
1249
  try {
850
- renameSync(앞, 뒤);
1250
+ 이름바꾸기(앞, 뒤);
851
1251
  } catch (err) {
1252
+ if (err.code !== 'EXDEV') {
1253
+ /*
1254
+ * rename 은 되거나 안 되거나다 — 여기 오면 한 바이트도 안 움직였다.
1255
+ * 방금 남긴 기록을 두면 /undo 한 번이 이 헛턴에 먹히고 앞 턴의 진짜
1256
+ * 변경은 그대로 남는다(위 「뜨기 전에 거절한다」 머리말의 그 꼴). 거둔다.
1257
+ */
1258
+ let 거둠 = false;
1259
+ if (이름기록) 거둠 = ctx.history.버리기?.(뒤, 이름기록) === true;
1260
+ for (const p of 이번에뜬것) 거둠 = (ctx.history.버리기?.(p) === true) || 거둠;
1261
+ return {
1262
+ error: `못 옮겼습니다: ${from} → ${to} — ${쓰기실패말(err)}\n`
1263
+ + ` (아무것도 안 옮겨졌습니다${거둠 ? ' — 되돌리기 이력에도 안 남겼습니다' : ''})`,
1264
+ };
1265
+ }
852
1266
  /*
853
1267
  * 드라이브가 다르면 rename 이 안 된다 (윈도우 C: → D:, 리눅스 마운트 경계).
854
1268
  * 그때만 복사해서 옮긴다. 늘 복사하지 않는 것은 큰 폴더에서 값이 크고,
855
1269
  * 복사 도중에 끊기면 양쪽에 반씩 남기 때문이다.
856
1270
  */
857
- if (err.code !== 'EXDEV') return { error: `못 옮겼습니다: ${err.message}` };
858
1271
  const 벌어진일 = 복사해옮기기(앞, 뒤);
859
1272
  if (벌어진일.복사깨짐) {
860
1273
  // 닿은 자리를 여기서 지우지 않는다 — 이미 있던 폴더로 옮기는 중이었으면
@@ -877,6 +1290,9 @@ async function 한개옮기기({ from, to, overwrite = false }, ctx) {
877
1290
  : '')
878
1291
  + (원본남음
879
1292
  ? `\n(닿은 자리에는 다 옮겼는데 원본을 못 지웠습니다 — 지금 ${ctx.scope.show(앞)} 에도 그대로 있습니다: ${원본남음})`
1293
+ : '')
1294
+ + (이름못되돌림
1295
+ ? '\n(이 되돌리기 이력은 이름 바꾸기를 적을 줄 몰라 /undo 가 이름의 대소문자는 못 되돌립니다 — 내용은 바뀌지 않았습니다)'
880
1296
  : '');
881
1297
  return {
882
1298
  content: `옮김: ${ctx.scope.show(앞)} → ${ctx.scope.show(뒤)}${무엇 ? ` (${무엇})` : ''}${경고}`,
@@ -885,10 +1301,14 @@ async function 한개옮기기({ from, to, overwrite = false }, ctx) {
885
1301
  /*
886
1302
  * 폴더를 옮겼으면 **옮겨진 파일 하나하나**를 적어 준다.
887
1303
  *
888
- * 닿은 자리가 이미 있던 폴더면(`Move('새것', 'src')`) 그 폴더에는 남이
889
- * 고치던 파일도 산다. 「이 폴더가 바뀌었다」 로만 적어 두면 나중에
890
- * /commit 이 그 폴더를 통째로 담고, 남의 변경이 이 커밋에 실린다.
1304
+ * 바로 위 changed 는 **폴더** 자리 하나다. 그런데 이걸 받는 쪽은 전부
1305
+ * 파일을 받는 자리다 — loop.js 의 손댄파일(턴 끝에 디스크와 견준다),
1306
+ * repl.js 의 noteChange, 거기서 이어지는 /diff 와 /commit. 폴더 이름
1307
+ * 하나만 적어 두면 파일 몇백 개가 움직였다는 사실이 아무 데도 안 남는다.
891
1308
  * 무엇이 실제로 움직였는지는 지금 이 자리만 안다.
1309
+ *
1310
+ * (닿은 자리가 이미 있던 폴더인 경우는 여기 못 온다 — 위 「옮기다 깨질
1311
+ * 것이 뻔한 것은 뜨기 전에 거절한다」 가 그 앞에서 막는다.)
892
1312
  */
893
1313
  바뀐것들: 폴더인가 ? 짝들.map(([, b]) => b) : null,
894
1314
  };
@@ -963,9 +1383,11 @@ const isHwpxPath = (p) => /\.hwpx$/i.test(String(p ?? ''));
963
1383
  */
964
1384
  function hwpx새로만들기(args, ctx, abs) {
965
1385
  const 만듦 = hwpx만들기(args.content, { 제목: null });
966
- ctx.history.snapshot(abs, 'Write');
967
- mkdirSync(dirname(abs), { recursive: true });
968
- writeFileSync(abs, 만듦.buf);
1386
+ const 못씀 = 떠놓고쓰기(ctx, abs, 'Write', () => {
1387
+ mkdirSync(dirname(abs), { recursive: true });
1388
+ writeFileSync(abs, 만듦.buf);
1389
+ }, () => !existsSync(abs));
1390
+ if (못씀) return 못씀;
969
1391
  /*
970
1392
  * `seen` 에 안 올린다.
971
1393
  *
@@ -1092,7 +1514,6 @@ function 한파일쓰기(args, ctx) {
1092
1514
  if (existed) { try { 읽음 = readTextFull(abs); } catch { 읽음 = null; } }
1093
1515
  // 덮어쓰기 전 내용. 바뀐 자리를 보여주려면 지금 떠 놔야 한다.
1094
1516
  const 이전 = 읽음?.text ?? null;
1095
- mkdirSync(dirname(abs), { recursive: true });
1096
1517
 
1097
1518
  /*
1098
1519
  * 원래 있던 파일이면 그 파일이 **지금** 쓰는 인코딩으로 되돌려 쓴다.
@@ -1114,7 +1535,15 @@ function 한파일쓰기(args, ctx) {
1114
1535
  * 되돌려 쓰는 쪽이 읽는 쪽보다 위험한데 말은 더 단정했다.
1115
1536
  */
1116
1537
  const 짐작이다 = !!(읽음 && 읽음.sure === false);
1117
- const 만든것 = encode(args.content, 원래);
1538
+ // 안 바뀐 앞뒤는 읽은 바이트 그대로 둔다 (encoding.js 의 바꾼데만쓰기 머리말).
1539
+ const 만든것 = 읽음 ? 바꾼데만쓰기(읽음.buf, 읽음.text, args.content, 원래) : encode(args.content, 원래);
1540
+ // 인코더를 못 만들면 조용히 UTF-8 이 된다. `lost` 는 비어 있다 — 따로 본다.
1541
+ if (만든것.fellBack) {
1542
+ return {
1543
+ error: `이 파일은 ${encLabel(원래)} 로 되어 있는데, 이 Node 가 그 인코딩으로 되돌려 쓸 줄 모릅니다.\n`
1544
+ + ' 그대로 쓰면 파일 전체가 UTF-8 로 바뀝니다. 안 쓰고 멈췄습니다.',
1545
+ };
1546
+ }
1118
1547
  if (만든것.lost.length) {
1119
1548
  return {
1120
1549
  error: `이 파일은 ${encLabel(원래)} 로 되어 있는데, 그 인코딩에 없는 글자가 있습니다: `
@@ -1122,8 +1551,12 @@ function 한파일쓰기(args, ctx) {
1122
1551
  + ` 그대로 쓰면 그 글자들이 뭉개집니다. 해당 글자를 빼거나, 파일을 UTF-8 로 바꿔도 되는지 사용자에게 물어보세요.`,
1123
1552
  };
1124
1553
  }
1125
- ctx.history.snapshot(abs, 'Write');
1126
- writeFileSync(abs, 만든것.buf);
1554
+ // 폴더 만들기도 쓰기 안에 둔다 — 길 중간이 파일이라 깨지면 그것도 못 쓴 것이다 (떠놓고쓰기).
1555
+ const 못씀 = 떠놓고쓰기(ctx, abs, 'Write', () => {
1556
+ mkdirSync(dirname(abs), { recursive: true });
1557
+ writeFileSync(abs, 만든것.buf);
1558
+ }, () => (existed ? !!읽음 && readFileSync(abs).equals(읽음.buf) : !existsSync(abs)));
1559
+ if (못씀) return 못씀;
1127
1560
  ctx.seen.add(abs);
1128
1561
  const n = args.content.split('\n').length;
1129
1562
  const 표기 = 원래 !== 'utf-8' ? ` · ${encLabel(원래)}${짐작이다 ? ' (짐작)' : ''}` : '';
@@ -1149,7 +1582,10 @@ function 여러파일쓰기(목록, ctx) {
1149
1582
  const 결과 = [];
1150
1583
  for (const x of 목록) {
1151
1584
  if (typeof x.file_path !== 'string' || !x.file_path) {
1152
- 결과.push({ path: null, ok: false, error: 'file_path 가 없습니다' });
1585
+ // 보인이름 을 빼면 실패 줄이 `✗ undefined — file_path 가 없습니다` 로
1586
+ // 모델에게 나간다. 작은 모델은 그걸 파일 이름으로 읽고 `undefined` 라는
1587
+ // 이름으로 다시 보낸다. 여러군데고치기() 가 같은 자리에 쓰는 말을 그대로 쓴다.
1588
+ 결과.push({ path: null, 보인이름: '(경로 없음)', ok: false, error: 'file_path 가 없습니다' });
1153
1589
  continue;
1154
1590
  }
1155
1591
  let r;
@@ -1195,6 +1631,27 @@ function 여러파일쓰기(목록, ctx) {
1195
1631
  * loop.js 의 잘린 인자 살려쓰기, repl.js 의 바뀐 자리 그리기, 되돌리기 스냅샷.
1196
1632
  */
1197
1633
  function 한군데고치기(args, ctx) {
1634
+ /*
1635
+ * ── 글이 아닌 old_string·new_string 은 **받지 않는다** ──────────────────
1636
+ *
1637
+ * 여기서 인자 꼴을 안 봤다. 그래서 모델이 new_string 을 빠뜨리면(지우려는
1638
+ * 뜻일 때 흔하다) 아래 applySpans 가 `undefined` 를 글자로 이어 붙였다:
1639
+ *
1640
+ * Edit({old_string:'world'}) → 고침: a.txt (1군데)
1641
+ * 파일 → hello undefined
1642
+ *
1643
+ * null 이면 `null`, 숫자면 그 숫자가 들어갔다. old_string 이 숫자면
1644
+ * indexOf 가 글로 바꿔 찾아 엉뚱한 자리가 고쳐졌다(`v456v123`). 오류가
1645
+ * 없으니 모델은 「고침」 을 믿고 넘어가고, 사람은 커밋에서야 본다.
1646
+ * edits 배열도 이 함수를 지나므로 같이 막힌다.
1647
+ */
1648
+ if (typeof args.old_string !== 'string') {
1649
+ return { error: 'old_string 이 없거나 글이 아닙니다 — 파일에서 찾을 글을 그대로 주세요.' };
1650
+ }
1651
+ if (typeof args.new_string !== 'string') {
1652
+ return { error: 'new_string 이 없거나 글이 아닙니다 — 바꿀 내용을 글로 주세요.'
1653
+ + ' 그 자리를 지우려는 것이면 new_string 에 빈 글("")을 주세요.' };
1654
+ }
1198
1655
  const abs = ctx.scope.resolve(args.file_path);
1199
1656
  if (!existsSync(abs)) return { error: 말('err.noSuchFile', { 경로: args.file_path }) };
1200
1657
  const 못고치는이유 = 내부살림(abs);
@@ -1234,11 +1691,28 @@ function 한군데고치기(args, ctx) {
1234
1691
  return { error: `찾지 못했습니다.${hint}` };
1235
1692
  }
1236
1693
 
1237
- const next = applySpans(text, m.spans, (matched) =>
1238
- m.tier === 'exact' ? args.new_string : reindent(args.new_string, matched, args.old_string));
1694
+ // CRLF 만 쓰는 파일이면 넣는 글도 CRLF 로 (edit-match.js 의 CRLF뿐인가 머리말).
1695
+ const 줄끝맞춤 = CRLF뿐인가(text) ? CRLF로 : (s) => s;
1696
+ const next = applySpans(text, m.spans, (matched) => 줄끝맞춤(
1697
+ m.tier === 'exact' ? args.new_string
1698
+ // 정규화로 찾았으면 넣는 글도 파일의 꼴(NFC·NFD)로 (edit-match.js 의 꼴맞추기).
1699
+ : m.tier === 'nfc' ? 꼴맞추기(args.new_string, matched)
1700
+ : reindent(args.new_string, matched, args.old_string)));
1239
1701
 
1240
- // 읽은 그 인코딩으로 되돌려 쓴다.
1241
- const 만든것 = encode(next, 읽음.encoding);
1702
+ // 읽은 그 인코딩으로 되돌려 쓴다. 안 바뀐 앞뒤는 읽은 바이트 그대로 (encoding.js 의 바꾼데만쓰기).
1703
+ const 만든것 = 바꾼데만쓰기(읽음.buf, text, next, 읽음.encoding);
1704
+ /*
1705
+ * 인코더를 못 만들면 encode() 는 **조용히 UTF-8 바이트**를 돌려준다.
1706
+ * 그 값을 그냥 쓰면 한 줄 고쳤을 뿐인데 파일 전체가 UTF-8 로 바뀐다 —
1707
+ * 이 저장소가 encoding.js 맨 위에 「제일 위험한 자리」 라고 적어 둔 그것이다.
1708
+ * `lost` 는 비어 있어서 위의 검사로는 안 걸린다. 따로 본다.
1709
+ */
1710
+ if (만든것.fellBack) {
1711
+ return {
1712
+ error: `이 파일은 ${encLabel(읽음.encoding)} 로 되어 있는데, 이 Node 가 그 인코딩으로 되돌려 쓸 줄 모릅니다.\n`
1713
+ + ' 그대로 쓰면 파일 전체가 UTF-8 로 바뀝니다. 안 고치고 멈췄습니다.',
1714
+ };
1715
+ }
1242
1716
  if (만든것.lost.length) {
1243
1717
  return {
1244
1718
  error: `이 파일은 ${encLabel(읽음.encoding)} 로 되어 있는데, 그 인코딩에 없는 글자를 넣으려 합니다: `
@@ -1248,9 +1722,9 @@ function 한군데고치기(args, ctx) {
1248
1722
  }
1249
1723
  // 스냅샷은 **정말 쓰기 직전**에 뜬다 (한파일쓰기 의 같은 자리 머리말).
1250
1724
  // 위 인코딩 거절로 끝나는 길에서 뜨면, 아무것도 안 바꾼 턴이 이력에 남아
1251
- // /undo 한 번을 통째로 먹는다.
1252
- ctx.history.snapshot(abs, 'Edit');
1253
- writeFileSync(abs, 만든것.buf);
1725
+ // /undo 한 번을 통째로 먹는다. 쓰기 자체가 깨지는 것은 떠놓고쓰기 머리말.
1726
+ const 못씀 = 떠놓고쓰기(ctx, abs, 'Edit', () => writeFileSync(abs, 만든것.buf), () => readFileSync(abs).equals(읽음.buf));
1727
+ if (못씀) return 못씀;
1254
1728
 
1255
1729
  const n = m.spans.length;
1256
1730
  const how = m.tier === 'exact' ? '' : ` · ${TIER_LABELS[m.tier]}`;
@@ -1411,6 +1885,52 @@ function 그림보기(abs, ctx) {
1411
1885
  * rg 길도 못 막는다 — 없는 자리면 rg 가 죽고 빠르게찾기() 가 null 을
1412
1886
  * 돌려주며 그대로 이 아래 자바스크립트 길로 내려온다.
1413
1887
  */
1888
+ /*
1889
+ * ── 파일 하나를 자바스크립트로 찾는다 ────────────────────────────────────
1890
+ *
1891
+ * 예전 길(rg 없는 PC)과 「rg 가 글자로 못 푼 파일」 이 **같은 함수**를 쓴다.
1892
+ * 따로 적어 두면 두 자리의 규칙(바이너리 판정·인코딩 풀기·줄 가르기)이
1893
+ * 언젠가 갈리고, 그날 같은 파일이 엔진에 따라 다른 답을 낸다 — fastgrep.js
1894
+ * 머리말이 막으려는 바로 그 고장이다.
1895
+ *
1896
+ * @returns {boolean} 열어 읽었나. 바이너리이거나 못 연 파일이면 false.
1897
+ */
1898
+ function 한파일에서찾기(path, re, 맞음) {
1899
+ let text;
1900
+ try { text = readText(path); } catch { return false; }
1901
+ const ls = text.split('\n');
1902
+ /*
1903
+ * 줄 끝의 `\r` 은 줄 내용이 아니다.
1904
+ *
1905
+ * CRLF 파일을 `\n` 으로 가르면 줄마다 `\r` 이 남아 `end$` 가 한 줄도 안
1906
+ * 맞았다(`$` 앞에 `\r` 이 있다). rg 도 `--crlf` 없이는 똑같아서 두 길 다
1907
+ * 윈도우 파일에서 「일치 없음」 이었다. 이제 두 길 다 `\r\n` 을 줄 끝으로 본다.
1908
+ */
1909
+ for (let i = 0; i < ls.length; i++) {
1910
+ const 줄 = ls[i].endsWith('\r') ? ls[i].slice(0, -1) : ls[i];
1911
+ if (re.test(줄)) 맞음(i, 줄);
1912
+ }
1913
+ return true;
1914
+ }
1915
+
1916
+ /*
1917
+ * rg(Rust) 식 `\p{이름}` 을 자바스크립트가 받는 꼴로.
1918
+ *
1919
+ * Rust 는 `\p{Hangul}` · `\p{Greek}` 처럼 글자 체계 이름만 적어도 받는다.
1920
+ * 자바스크립트는 일반 갈래(`\p{L}`)만 이름으로 받고 글자 체계는
1921
+ * `\p{Script_Extensions=Hangul}` 로 적어야 한다. 이름 그대로 되는 것은 그대로
1922
+ * 두고, 안 되는 것만 글자 체계로 바꿔 본다. 그래도 안 되면 손대지 않는다.
1923
+ * 앞에 빗금이 짝수로 붙은(`\\p{…}` — 글자 그대로의 빗금) 것은 건드리지 않는다.
1924
+ */
1925
+ function 갈래이름풀기(무늬) {
1926
+ const 된다 = (x) => { try { new RegExp(x, 'u'); return true; } catch { return false; } };
1927
+ return String(무늬).replace(/(?<!\\)((?:\\\\)*)\\([pP])\{(\w+)\}/g, (온것, 빗금들, p, 이름) => {
1928
+ if (된다(`\\${p}{${이름}}`)) return 온것;
1929
+ const 체계 = `\\${p}{Script_Extensions=${이름}}`;
1930
+ return 된다(체계) ? `${빗금들}${체계}` : 온것;
1931
+ });
1932
+ }
1933
+
1414
1934
  function 찾을자리없나(root, 준것, ctx, { 폴더여야 = false } = {}) {
1415
1935
  const 보인이름 = 준것 ?? ctx.scope.show(root);
1416
1936
  if (!existsSync(root)) {
@@ -1431,12 +1951,38 @@ function 찾을자리없나(root, 준것, ctx, { 폴더여야 = false } = {}) {
1431
1951
  return null;
1432
1952
  }
1433
1953
 
1954
+ /*
1955
+ * ── Bash 제한 시간을 **쓸 수 있는 값**으로 맞춘다 (사냥5 M8) ─────────────────
1956
+ *
1957
+ * 받은 timeout 을 그대로 setTimeout 에 넣었다. 그래서 —
1958
+ *
1959
+ * 60 (초로 알고 보낸 값) 60ms 에 죽는다 — 셸이 뜨기도 전이다
1960
+ * 0 · -1 곧바로 죽는다
1961
+ * 1e10 32비트를 넘어 Node 가 1ms 로 바꾸고 TimeoutOverflowWarning
1962
+ * "abc" NaN 이라 1ms 로 바꾸고 TimeoutNaNWarning
1963
+ *
1964
+ * 설명서는 ms 라고 적어 두었으니 단위는 그대로 ms 로 읽는다. 다만 1초 밑은 올린다 —
1965
+ * 윈도우에서 셸 하나 뜨는 데 수백 ms 가 들어, 그보다 짧은 제한은 명령을 한 줄도 못 돌리고
1966
+ * 「시간 초과」 만 남긴다. 위로는 10분에서 멈춘다 — 그 너머는 턴이 통째로 서는 것이고
1967
+ * 끝나지 않는 것은 background 로 띄우라고 설명서가 이미 말한다. 못 읽는 값은 기본값이다.
1968
+ */
1969
+ export function bash제한시간(값) {
1970
+ const n = Number(값);
1971
+ if (값 == null || 값 === '' || !Number.isFinite(n) || n <= 0) return 120000;
1972
+ return Math.min(600000, Math.max(1000, Math.round(n)));
1973
+ }
1974
+
1434
1975
  export const TOOLS = {
1435
1976
  Read: {
1436
1977
  schema: {
1437
1978
  name: 'Read',
1438
1979
  description: '파일 하나를 읽는다. 줄 번호가 붙어 돌아온다. 고치기 전에는 반드시 먼저 읽어야 한다.'
1439
- + ' 엑셀 파일(.xlsx/.xlsm/.xls)도 그대로 읽을 수 있다 — 시트별 CSV 로 바꿔서 돌려준다.'
1980
+ // 스키마 문장이 곧 약속이다. 옛 .xls(OLE)는 우리가 직접 못 읽어서
1981
+ // 이 PC 에 엑셀이나 LibreOffice 가 있어야 빌려 읽는다(tools/convert.js).
1982
+ // 「그대로 읽을 수 있다」 에 같이 적어 두면, 없는 PC 에서는 모델이
1983
+ // 사용자에게 그 파일을 달라고 해 놓고 못 읽어 걸음 하나를 버린다.
1984
+ + ' 엑셀 파일(.xlsx/.xlsm)도 그대로 읽을 수 있다 — 시트별 CSV 로 바꿔서 돌려준다.'
1985
+ + ' 옛 형식(.xls)은 이 PC 에 엑셀이나 LibreOffice 가 있어야 읽힌다 — 없으면 못 읽는다고 말해 준다.'
1440
1986
  + ' 한글·워드·파워포인트 문서(.hwpx/.docx/.pptx)도 그대로 읽는다 — 글로 바꿔서 돌려준다.'
1441
1987
  + ' 사용자에게 다른 형식으로 내보내 달라고 할 필요가 없다. 다만 이런 파일들은 읽기만 되고 고칠 수는 없다.',
1442
1988
  parameters: {
@@ -1481,7 +2027,9 @@ export const TOOLS = {
1481
2027
  const 안내 = 옛hwp안내(ctx.scope.show(abs));
1482
2028
  // 괄호가 있어야 한다. await 를 빼면 `??` 가 **약속(Promise)** 을
1483
2029
  // 보고 "값이 있다" 고 판단해서, 안내문이 영영 안 나간다.
1484
- return (await 빌려읽기(abs, ctx, 안내)) ?? { error: 안내 };
2030
+ const 빌린것 = await 빌려읽기(abs, ctx, 안내);
2031
+ if (빌린것?.content) return 빌린것;
2032
+ return { error: 안내 + 빌린덧말(빌린것) };
1485
2033
  }
1486
2034
  } catch { /* 아래 일반 읽기가 제 오류를 낸다 */ }
1487
2035
  }
@@ -1500,8 +2048,41 @@ export const TOOLS = {
1500
2048
  if (직접못읽나(abs)) {
1501
2049
  const 보인이름 = ctx.scope.show(abs);
1502
2050
  const 빌린것 = await 빌려읽기(abs, ctx, `${보인이름} 을 deel 이 직접 못 읽습니다.`);
1503
- if (빌린것) return 빌린것;
1504
- return { error: 못바꿈말(보인이름, extname(abs)), 끝났다: true };
2051
+ if (빌린것?.content) return 빌린것;
2052
+ return { error: 못바꿈말(보인이름, extname(abs)) + 빌린덧말(빌린것), 끝났다: true };
2053
+ }
2054
+
2055
+ /*
2056
+ * ── offset·limit 은 **숫자로** 받는다. 못 받으면 그렇다고 말한다 ─────
2057
+ *
2058
+ * 여태 받은 값을 그대로 셈에 넣었다. 그래서 —
2059
+ *
2060
+ * offset:'abc' → NaN → 본문은 빈 글인데 요약은 「11줄」
2061
+ * offset:1.5 → 줄 번호가 `1.5 · 2.5 · 3.5`, 꼬리는 「2.5줄까지」
2062
+ * offset:99 → 빈 본문, 아무 말 없음
2063
+ *
2064
+ * 첫째와 셋째는 모델에게 「이 파일은 비었다」 로 읽힌다. 틀린 값을 조용히
2065
+ * 넘기느니 한 줄로 거절하는 편이 낫다 — 모델은 그 말을 보고 바로 고쳐 부른다.
2066
+ * 숫자로 적힌 글("3")은 여태처럼 받는다. 소수는 내려서 줄 번호로 쓴다.
2067
+ */
2068
+ const 줄번호로 = (값, 이름) => {
2069
+ if (값 == null || 값 === '') return { 값: null };
2070
+ const n = typeof 값 === 'number' || typeof 값 === 'string' ? Number(값) : NaN;
2071
+ if (!Number.isFinite(n)) {
2072
+ return { error: `${이름} 이 숫자가 아닙니다: ${JSON.stringify(값)} — ${이름 === 'offset' ? '1부터 세는 시작 줄 번호' : '읽을 줄 수'}를 숫자로 주세요.` };
2073
+ }
2074
+ return { 값: Math.floor(n) };
2075
+ };
2076
+ const 시작값 = 줄번호로(args.offset, 'offset');
2077
+ if (시작값.error) return { error: 시작값.error };
2078
+ const 몇줄값 = 줄번호로(args.limit, 'limit');
2079
+ if (몇줄값.error) return { error: 몇줄값.error };
2080
+ /*
2081
+ * limit 이 0 이하면 읽을 줄이 없다 — 그걸 그대로 셈에 넣어 「전체 3줄 중 -3줄까지」 라는
2082
+ * 있을 수 없는 꼬리를 지어냈다(사냥6 낮음). 모델은 파일이 비었거나 잘렸다고 읽는다.
2083
+ */
2084
+ if (몇줄값.값 != null && 몇줄값.값 < 1) {
2085
+ return { error: `limit 은 1 이상이어야 합니다 (받은 것: ${JSON.stringify(args.limit)}) — 읽을 줄 수를 주거나, 빼면 읽을 수 있는 만큼 읽습니다.` };
1505
2086
  }
1506
2087
 
1507
2088
  const 읽음 = readTextFull(abs);
@@ -1510,13 +2091,34 @@ export const TOOLS = {
1510
2091
  ctx.enc = ctx.enc ?? new Map();
1511
2092
  ctx.enc.set(abs, 읽음.encoding);
1512
2093
  const text = 읽음.text;
1513
- const lines = text.split('\n');
1514
- const start = Math.max(0, (args.offset ?? 1) - 1);
2094
+ /*
2095
+ * 0바이트 파일은 **0줄**이다 — 줄재기 머리말과 같은 규칙.
2096
+ *
2097
+ * `''.split('\n')` 은 빈 줄 하나짜리 배열이라, 빈 파일을 열면 요약에
2098
+ * 「1줄」 이 뜨고 본문에 있지도 않은 ` 1\t` 이 실렸다.
2099
+ */
2100
+ const lines = text === '' ? [] : text.split('\n');
2101
+ /*
2102
+ * 끝의 줄바꿈은 마지막 줄을 **닫는** 것이지 새 줄을 여는 것이 아니다.
2103
+ *
2104
+ * 위와 같은 까닭으로 `'L1\n…L10\n'.split('\n')` 은 끝에 빈 조각이 하나 더
2105
+ * 붙는다. 그래서 10줄 파일이 요약에 「11줄」 로 뜨고 본문 끝에 있지도 않은
2106
+ * ` 11\t` 이 실렸다. 모델은 그 빈 11번 줄 뒤에 붙이려 하고, Append 가
2107
+ * 세는 줄 수(줄재기 — 끝 줄바꿈을 닫는 것으로 센다)와도 하나씩 어긋났다.
2108
+ * 줄바꿈 하나뿐인 파일은 빈 줄 한 줄이다(`['', '']` → `['']`).
2109
+ */
2110
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
2111
+ const start = Math.max(0, (시작값.값 ?? 1) - 1);
1515
2112
  const 줄상한 = 읽을줄수(ctx.모델컨텍스트);
1516
- const count = Math.min(args.limit ?? 줄상한, 줄상한);
2113
+ const count = Math.min(몇줄값.값 ?? 줄상한, 줄상한);
1517
2114
  const slice = lines.slice(start, start + count);
1518
2115
  const 줄인것 = 붙박이그림줄이기(slice);
1519
- const body = 줄인것.줄들.map((l, i) => `${String(start + i + 1).padStart(6)}\t${l}`).join('\n');
2116
+ const body = 줄인것.줄들.map((l, i) => `${String(start + i + 1).padStart(6)}\t${l}`).join('\n')
2117
+ + (lines.length === 0 ? '(빈 파일입니다 — 0줄)' : '')
2118
+ + (lines.length > 0 && start >= lines.length
2119
+ ? `(파일 끝을 지났습니다 — 이 파일은 전체 ${lines.length}줄인데 ${start + 1}번째 줄부터 달라고 했습니다.`
2120
+ + ` offset 을 ${lines.length} 이하로 주세요.)`
2121
+ : '');
1520
2122
  const more = lines.length > start + count ? `\n… 전체 ${lines.length}줄 중 ${start + count}줄까지` : '';
1521
2123
  ctx.seen.add(abs);
1522
2124
  const 별난인코딩 = 읽음.encoding !== 'utf-8';
@@ -1552,7 +2154,11 @@ export const TOOLS = {
1552
2154
  summary: 이어(
1553
2155
  다못줌
1554
2156
  ? `${말('sum.linesOf', { 준: 준줄수, 전체: lines.length })} (${말('sum.partial')})`
1555
- : 세말('lines', lines.length),
2157
+ // 줄 수는 다 줬어도 **줄이 길어** clip 에 걸렸으면 사람에게도 일부만이라고 적는다.
2158
+ // 모델 글에는 「… (N자 잘림)」 이 있는데 화면에는 「10줄」 뿐이었다 (6회차 Gemini 도구6e2 E2-1).
2159
+ : 실린것.length < 통째로.length
2160
+ ? `${세말('lines', lines.length)} (${말('sum.partial')})`
2161
+ : 세말('lines', lines.length),
1556
2162
  줄인것.줄인바이트 ? 말('sum.imageDropped', { 크기: 몇KB(줄인것.줄인바이트) }) : '',
1557
2163
  /*
1558
2164
  * 짐작한 인코딩은 짐작이라고 적는다.
@@ -1633,6 +2239,7 @@ export const TOOLS = {
1633
2239
  description: '파일 끝에 이어 붙인다. 큰 파일은 이렇게 나눠서 만든다.'
1634
2240
  + ' 처음에는 Write 로 앞부분을 만들고, 그 뒤부터는 Append 를 여러 번 불러 끝까지 채운다.'
1635
2241
  + ' 한 번에 다 담으려다 잘리는 것보다 나눠서 확실히 남기는 편이 낫다.'
2242
+ + ' 이미 쓴 앞부분은 다시 보내지 마라 — 이어질 부분만 보낸다.'
1636
2243
  + ' Read 로 먼저 읽지 않아도 된다 — 끝에 붙이는 것뿐이라 읽을 이유가 없다.',
1637
2244
  parameters: {
1638
2245
  type: 'object',
@@ -1679,10 +2286,6 @@ export const TOOLS = {
1679
2286
  const 표막기 = 가린표되돌리나(ctx.scope.show(abs), args.content);
1680
2287
  if (표막기) return { error: 표막기 };
1681
2288
 
1682
- // Append 는 한 턴에 여러 번 불리는 것이 정상이다. 그래도 되돌리기 이력에
1683
- // 사본이 쌓이지 않는다 — History.snapshot 이 턴마다 한 번만 뜬다(undo.js).
1684
- ctx.history.snapshot(abs, 'Append');
1685
-
1686
2289
  // 원래 있던 파일이면 그 파일이 쓰던 인코딩 그대로 이어 붙인다.
1687
2290
  // 이어 붙이는 조각에는 앞머리 표식(BOM)이 들어가면 안 된다 — 파일 한가운데에
1688
2291
  // BOM 이 박히면 그 자리가 이상한 글자로 보인다. 그래서 표식 없는 이름으로 바꾼다.
@@ -1700,22 +2303,72 @@ export const TOOLS = {
1700
2303
  * 오류는 안 난다. 파일 뒤쪽만 깨지고, 결과에는 `이어 붙임 · CP949`
1701
2304
  * 가 사실처럼 뜬다.
1702
2305
  *
1703
- * 그렇다고 붙일 때마다 통째로 다시 읽지도 않는다. 파일 크기가
1704
- * 그대로면 남이 안 건드린 것이라 캐시를 그대로 쓴다 — 줄기억 이
1705
- * 같은 잣대를 이미 쓰고 있다.
2306
+ * 그렇다고 붙일 때마다 후보를 다 풀어 점수를 다시 매기지는 않는다.
2307
+ * 잴 때 보는 앞머리 바이트가 그대로면 다시 재도 같은 답이라 캐시를
2308
+ * 쓴다. 전에는 「크기가 그대로면」 이었는데, 같은 크기로 갈아엎은
2309
+ * 파일에서 낡은 인코딩을 댔다 (파일표 머리말).
1706
2310
  */
1707
2311
  const 잰것캐시 = ctx.enc재기 ?? (ctx.enc재기 = new Map());
1708
2312
  const 지금크기 = existed ? 파일크기(abs) : -1;
2313
+ const 앞표본 = existed ? 앞머리(abs, 인코딩볼바이트) : null;
2314
+ const 앞지문 = 표본지문(앞표본);
1709
2315
  const 든것 = 잰것캐시.get(abs);
1710
2316
  const 원래 = !existed ? 'utf-8'
1711
- : (든것 && 든것.바이트 === 지금크기) ? 든것.인코딩
1712
- : 재는인코딩(abs);
2317
+ : (든것 && 앞지문 !== null && 든것.지문 === 앞지문) ? 든것.인코딩
2318
+ : 재는인코딩(abs, 앞표본);
1713
2319
  // 여기서 넣어 두면 **다음 번에 반드시 빗나간다** — 붙이고 나면 크기가
1714
2320
  // 달라지기 때문이다. 넣는 자리는 붙인 **뒤**다 (아래 참고).
1715
2321
  // Read·Write 가 보는 표에도 맞춰 둔다. 안 맞추면 두 표가 서로 다른 답을 낸다.
1716
2322
  ctx.enc?.set?.(abs, 원래);
1717
- const 조각인코딩 = 원래 === 'utf-8-bom' ? 'utf-8' : 원래;
1718
- const 만든것 = encode(args.content, 조각인코딩);
2323
+ /*
2324
+ * ── 표식을 벗기는 자가 **UTF-8 만** 알고 있었다 ──────────────────
2325
+ *
2326
+ * 바로 위 머리말이 규칙을 적어 뒀다 — 「이어 붙이는 조각에는 앞머리
2327
+ * 표식(BOM)이 들어가면 안 된다」. 그런데 벗기는 줄은 `utf-8-bom` 한
2328
+ * 이름만 봤다. 그 사이에 UTF-16 도 표식 유무를 이름에 담게 됐고
2329
+ * (encoding.js 의 decode 머리말), `looksBinary` 도 UTF-16 을 글로
2330
+ * 치게 됐다. 그래서 이런 파일에 닿는다:
2331
+ *
2332
+ * 파워셸 `... > out.txt` · 메모장 '유니코드' 저장 · .reg 내보내기
2333
+ *
2334
+ * 전부 UTF-16LE(BOM) 이다. 거기에 한 줄 붙이면 `encode('utf-16le-bom')`
2335
+ * 이 조각 앞에도 `FF FE` 를 붙여, **파일 한가운데에** 표식이 박힌다.
2336
+ * 결과 줄에는 `이어 붙임 … · UTF-16LE(BOM)` 이 멀쩡히 뜬다. 편집기는
2337
+ * 대개 그대로 열리고, csv·.reg·로그를 먹는 쪽이 그 바이트에서 깨진다.
2338
+ *
2339
+ * 이름 끝의 `-bom` 을 벗긴다. 어느 인코딩이든 같은 규칙이다.
2340
+ */
2341
+ const 조각인코딩 = String(원래).endsWith('-bom') ? String(원래).slice(0, -4) : 원래;
2342
+ /*
2343
+ * ── 줄끝도 **파일의 줄끝**을 따른다 ────────────────────────────────
2344
+ *
2345
+ * 모델은 줄바꿈을 늘 `\n` 으로 적는다. Edit 는 「CRLF 만 쓰는 파일이면 넣는 글도
2346
+ * CRLF」 로 맞추는데(edit-match.js 의 CRLF뿐인가 머리말) 여기만 없어서, `.bat` 를
2347
+ * 나눠 쓰면 앞은 CRLF · 붙인 꼬리는 LF 인 파일이 됐다. 판단은 인코딩을 잴 때 이미
2348
+ * 읽어 둔 앞머리로 한다. 이미 섞였거나 빈 파일이면 어느 쪽인지 모르니 손대지 않는다.
2349
+ * (6회차 Gemini 도구6b B1)
2350
+ */
2351
+ const 앞글 = existed && 앞표본?.length ? decodeBytes(앞표본, { fallback: 원래, 잘림: true }).text : '';
2352
+ const 붙일글 = CRLF뿐인가(앞글) ? CRLF로(args.content) : args.content;
2353
+ const 만든것 = encode(붙일글, 조각인코딩);
2354
+ /*
2355
+ * ── Write·Edit 에는 있는 방벽이 여기만 없었다 ────────────────────
2356
+ *
2357
+ * `encode()` 는 되돌릴 인코더를 못 만들면 **UTF-8 바이트를 그대로**
2358
+ * 돌려주고 `lost` 는 빈 배열로 둔다(fellBack). 그래서 `lost.length`
2359
+ * 만 보는 이 자리는 절대 안 걸린다. 작은 ICU 로 빌드한 Node —
2360
+ * 오프라인 사내 설치에 흔하다 — 에서 CP949 파일에 한 줄 붙이면,
2361
+ * 앞부분은 CP949 고 꼬리 40줄만 UTF-8 인 파일이 된다.
2362
+ *
2363
+ * 화면에는 `이어 붙임: 사내로그.txt (+40줄, 지금 전체 812줄 · CP949)`
2364
+ * 가 뜬다. `· CP949` 는 방금 쓴 바이트에 대한 **거짓 주장**이다.
2365
+ */
2366
+ if (만든것.fellBack) {
2367
+ return {
2368
+ error: `이 파일은 ${encLabel(원래)} 로 되어 있는데, 이 Node 가 그 인코딩으로 되돌려 쓸 줄 모릅니다.\n`
2369
+ + ' 그대로 붙이면 붙인 자리부터 UTF-8 이 됩니다. 안 쓰고 멈췄습니다.',
2370
+ };
2371
+ }
1719
2372
  if (만든것.lost.length) {
1720
2373
  return {
1721
2374
  error: `이 파일은 ${encLabel(원래)} 로 되어 있는데, 그 인코딩에 없는 글자가 있습니다: `
@@ -1733,24 +2386,31 @@ export const TOOLS = {
1733
2386
  const 앞것 = !existed
1734
2387
  ? { 줄: 0, 끝줄바꿈: true }
1735
2388
  : (() => {
1736
- const 크기 = 파일크기(abs);
1737
2389
  const 든것 = 줄기억.get(abs);
1738
- if (든것 && 든것.바이트 === 크기) return 든것;
1739
- const 잰것 = 줄재기(abs, 원래);
1740
- return { 바이트: 크기, ...잰것 };
2390
+ if (든것 && 든것.표 === 파일표(abs)) return 든것;
2391
+ return 줄재기(abs, 원래);
1741
2392
  })();
1742
2393
 
1743
- mkdirSync(dirname(abs), { recursive: true });
1744
- if (existed) appendFileSync(abs, 만든것.buf);
1745
- else writeFileSync(abs, 만든것.buf);
1746
- // 붙인 **뒤** 크기로 적어 둔다. 다음 Append 는 이 크기와 맞으면 그대로
1747
- // 쓰고, 그 사이 남이 파일을 건드렸으면 크기가 어긋나 다시 잰다.
1748
- 잰것캐시.set(abs, { 바이트: 파일크기(abs), 인코딩: 원래 });
2394
+ // Append 는 한 턴에 여러 번 불리는 것이 정상이다. 그래도 되돌리기 이력에
2395
+ // 사본이 쌓이지 않는다 — History.snapshot 이 턴마다 한 번만 뜬다(undo.js).
2396
+ // 뜨는 자리는 인코딩 거절을 **다 지난 여기**다. 위에서 뜨고 거절하면
2397
+ // 아무것도 안 붙인 턴이 이력에 남아 /undo 한 번을 먹는다 (한파일쓰기 머리말).
2398
+ const 못씀 = 떠놓고쓰기(ctx, abs, 'Append', () => {
2399
+ mkdirSync(dirname(abs), { recursive: true });
2400
+ if (existed) appendFileSync(abs, 만든것.buf);
2401
+ else writeFileSync(abs, 만든것.buf);
2402
+ }, () => (existed ? 파일크기(abs) === 지금크기 : !existsSync(abs)));
2403
+ if (못씀) return 못씀;
2404
+ // 붙인 **뒤** 앞머리로 적어 둔다. 64KB 보다 작던 파일은 붙인 조각이 앞머리에 든다.
2405
+ 잰것캐시.set(abs, {
2406
+ 지문: 지금크기 >= 인코딩볼바이트 ? 앞지문 : 표본지문(앞머리(abs, 인코딩볼바이트)),
2407
+ 인코딩: 원래,
2408
+ });
1749
2409
  ctx.seen.add(abs);
1750
2410
 
1751
2411
  const 붙인줄 = args.content.split('\n').length - (args.content.endsWith('\n') ? 1 : 0);
1752
2412
  const 전체줄 = 앞것 ? 앞것.줄 + 붙인줄 - (앞것.끝줄바꿈 ? 0 : 1) : 줄수(abs, 원래);
1753
- 줄기억넣기(abs, { 바이트: 파일크기(abs), 줄: 전체줄, 끝줄바꿈: args.content.endsWith('\n') });
2413
+ 줄기억넣기(abs, { 표: 파일표(abs), 줄: 전체줄, 끝줄바꿈: args.content.endsWith('\n') });
1754
2414
  const 표기 = 원래 !== 'utf-8' ? ` · ${encLabel(원래)}` : '';
1755
2415
  return {
1756
2416
  content: `${existed ? '이어 붙임' : '새로 만듦'}: ${ctx.scope.show(abs)}`
@@ -1885,16 +2545,23 @@ export const TOOLS = {
1885
2545
  },
1886
2546
  async run(args, ctx) {
1887
2547
  const root = args.path ? ctx.scope.resolve(args.path) : ctx.scope.root;
2548
+ // 살림 자리는 목록도 안 낸다 — Read 가 막는 것을 이름 목록으로 흘리지 않게 (Grep 머리말 · 사냥6 F6-1).
2549
+ const 살림이유 = 내부살림(root);
2550
+ if (살림이유) return { error: 살림이유, 끝났다: true };
1888
2551
  const 없나 = 찾을자리없나(root, args.path, ctx, { 폴더여야: true });
1889
2552
  if (없나) return 없나;
1890
- const re = globToRegex(args.pattern);
2553
+ // 절대경로 무늬는 작업 폴더 기준으로 푼다 (절대무늬풀기 머리말).
2554
+ const 풀린 = 절대무늬풀기(args.pattern, root, ctx);
2555
+ if (풀린.error) return { error: 풀린.error };
2556
+ // 빗금 든 무늬는 찾는 폴더 기준으로도, 작업 폴더 기준으로도 본다 — Grep 의 glob 과 같은 자 (fsutil.js 의 glob거르개).
2557
+ const 맞나 = glob거르개(풀린.무늬, { 뿌리: ctx.scope.root, 자리: root });
1891
2558
  const 전부 = await walk(root, { signal: ctx.signal });
1892
2559
  // 훑다 말고 나왔으면 그렇다고 말한다. 조용히 적게 주면 「그런 파일이 없다」가 된다.
1893
2560
  if (전부.끊김) return { error: '중단했습니다. 폴더를 끝까지 안 훑었습니다.', 끝났다: true, 중단됨: true };
1894
2561
  // .gitignore 로 건너뛴 것은 수를 말한다 — 조용히 빼면 '그 파일이 없다' 로 읽힌다 (tools/ignore.js).
1895
2562
  const 건너뜀 = 건너뜀말(전부.건너뜀, 전부.잘림, 전부.상한);
1896
2563
  const 맞는것 = 전부
1897
- .filter((f) => re.test(f.rel) || re.test(f.rel.split('/').pop()))
2564
+ .filter((f) => 맞나(f.path))
1898
2565
  .sort((a, b) => b.mtime - a.mtime);
1899
2566
  const files = 맞는것.slice(0, 찾을개수(ctx.모델컨텍스트));
1900
2567
  // 훑기 상한에서 멈췄으면 '없다' 가 아니라 '본 데까지는 없다' 다.
@@ -1911,9 +2578,10 @@ export const TOOLS = {
1911
2578
  : '';
1912
2579
  return {
1913
2580
  content: files.map((f) => ctx.scope.show(f.path)).join('\n') + 잘림 + 건너뜀,
1914
- summary: 맞는것.length > files.length
2581
+ // 훑기 상한에 걸렸으면 찾은 것이 있어도 요약에 그렇다고 단다 — 글 끝의 건너뜀말만으로는 화면 한 줄이 다 본 것처럼 찍혔다 (6회차 도구6i I1).
2582
+ summary: (맞는것.length > files.length
1915
2583
  ? 말('sum.countOf', { n: files.length, 전체: 맞는것.length })
1916
- : 세말('count', files.length),
2584
+ : 세말('count', files.length)) + (전부.잘림 ? ` (${말('sum.notAllSeen')})` : ''),
1917
2585
  };
1918
2586
  },
1919
2587
  },
@@ -1940,12 +2608,57 @@ export const TOOLS = {
1940
2608
  // 큰 저장소에서 20초를 도는 동안에도 ESC 가 들려야 하기 때문이다.
1941
2609
  async run(args, ctx) {
1942
2610
  let re;
1943
- try { re = new RegExp(args.pattern, args['-i'] ? 'i' : ''); }
1944
- catch (err) { return { error: `정규식이 잘못됐습니다: ${err.message}` }; }
1945
-
2611
+ /*
2612
+ * ── `u` 를 먼저 켜 본다 ────────────────────────────────────────────
2613
+ *
2614
+ * `u` 없이 만든 정규식은 `\p{Hangul}` 을 「p{Hangul}」 이라는 글자로
2615
+ * 읽는다. rg 는 유니코드 갈래로 알아듣는다. 그래서 같은 무늬가 rg 가
2616
+ * 깔린 PC 에서는 한글 줄을 다 찾고, 없는 PC 에서는 한 줄도 못 찾았다 —
2617
+ * 오류도 안 나고 「일치 없음」 만 뜬다.
2618
+ *
2619
+ * 게다가 `u` 를 켜도 `\p{Hangul}` 은 **자바스크립트에서는 틀린 문법**이다.
2620
+ * 자바스크립트는 글자 체계를 `\p{Script_Extensions=Hangul}` 로만 받고, rg 는
2621
+ * 이름만 적어도 받는다. 그래서 `u` 로 안 만들어지면 그 이름을 한 번 풀어
2622
+ * 다시 만들어 본다(갈래이름풀기).
2623
+ *
2624
+ * 그렇다고 `u` 만 쓰면 `a\-b` 처럼 옛 문법으로 멀쩡하던 무늬가 거절된다.
2625
+ * 그것도 안 되면 여태처럼 `u` 없이 만든다. 셋 다 안 되면 그때 거절한다.
2626
+ */
2627
+ const 깃발 = args['-i'] ? 'i' : '';
2628
+ try { re = new RegExp(args.pattern, `${깃발}u`); }
2629
+ catch {
2630
+ try { re = new RegExp(갈래이름풀기(args.pattern), `${깃발}u`); }
2631
+ catch {
2632
+ try { re = new RegExp(args.pattern, 깃발); }
2633
+ catch (err) { return { error: `정규식이 잘못됐습니다: ${err.message}` }; }
2634
+ }
2635
+ }
1946
2636
  const root = args.path ? ctx.scope.resolve(args.path) : ctx.scope.root;
2637
+ /*
2638
+ * ── 살림 자리는 **찾지도 않는다** (사냥6 F6-1) ──────────────────────────
2639
+ *
2640
+ * Read 는 `.deel/config.json` 을 내부살림() 으로 막는데 여기에는 그 막이 없었다.
2641
+ * 그래서 `Grep {pattern:'apiKey', path:'.deel', output_mode:'content'}` 한 번이면 게이트웨이
2642
+ * 열쇠·MCP 토큰·남의 도구 기록이 **줄째로** 도구 결과에 실렸다 — rg 도 자바스크립트 길도.
2643
+ * 이름을 대고 들어오는 문(path)은 여기서 닫고, 그 밖으로 흘러 들어오는 파일은 아래에서
2644
+ * 한 파일씩 거른다(rg 결과) · walk 가 거른다(자바스크립트 길 · Glob · Outline).
2645
+ */
2646
+ const 살림이유 = 내부살림(root);
2647
+ if (살림이유) return { error: 살림이유, 끝났다: true };
1947
2648
  const 없나 = 찾을자리없나(root, args.path, ctx);
1948
2649
  if (없나) return 없나;
2650
+
2651
+ /*
2652
+ * glob 앞의 `./` 는 뗀다 — 두 엔진 다 `src/a.js` 꼴로 맞춰 보므로 붙어 있으면 한 파일도 안 맞는다.
2653
+ * 절대경로로 적었으면 작업 폴더 기준으로 푼다 (절대무늬풀기). 빗금 든 glob 을 어느 폴더
2654
+ * 기준으로 보나 · `!` 빼기는 fsutil.js 의 glob거르개 와 fastgrep.js 의 rg글로브들 머리말.
2655
+ */
2656
+ let glob = null;
2657
+ if (typeof args.glob === 'string' && args.glob) {
2658
+ const 풀린 = 절대무늬풀기(args.glob.replace(/^(!?)(?:\.\/)+/, '$1'), root, ctx);
2659
+ if (풀린.error) return { error: 풀린.error };
2660
+ glob = 풀린.무늬;
2661
+ }
1949
2662
  const isFile = statSync(root).isFile();
1950
2663
 
1951
2664
  const mode = args.output_mode ?? 'files_with_matches';
@@ -1966,7 +2679,9 @@ export const TOOLS = {
1966
2679
  const 빠른것 = isFile ? null : await 빠르게찾기({
1967
2680
  무늬: args.pattern,
1968
2681
  자리: root,
1969
- glob: args.glob ?? null,
2682
+ // rg 는 glob 을 제 작업 폴더 기준으로 맞춘다 — 그 폴더를 작업 폴더로 띄운다 (fastgrep.js).
2683
+ 뿌리: ctx.scope.root,
2684
+ glob,
1970
2685
  대소문자무시: !!args['-i'],
1971
2686
  무시파일: existsSync(무시파일) ? 무시파일 : null,
1972
2687
  최대: Math.max(limit * 4, 2000),
@@ -1976,6 +2691,8 @@ export const TOOLS = {
1976
2691
  if (빠른것) {
1977
2692
  const 파일별 = new Map();
1978
2693
  for (const x of 빠른것.줄들) {
2694
+ // 살림 파일이 섞여 오면 버린다 — rg 는 우리 막을 모른다 (위 「살림 자리는 찾지도 않는다」).
2695
+ if (내부살림(x.파일)) continue;
1979
2696
  // 우리 규칙(글 아닌 것·큰 파일)은 rg 쪽 옵션으로 이미 걸었다. 여기서는 세기만.
1980
2697
  const rel = ctx.scope.show(x.파일);
1981
2698
  파일별.set(rel, (파일별.get(rel) ?? 0) + 1);
@@ -1985,6 +2702,27 @@ export const TOOLS = {
1985
2702
  lines.push(`${rel}${num}: ${x.내용.trim().slice(0, 200)}`);
1986
2703
  }
1987
2704
  }
2705
+ /*
2706
+ * rg 가 글자로 못 푼 파일(CP949 · 표식 없는 UTF-16 · 8KB 뒤 NUL)은
2707
+ * 예전 길과 **같은 함수**로 다시 읽는다 (fastgrep.js 의 rg못푸는파일 머리말).
2708
+ * 그 파일에서 rg 가 낸 줄은 빠르게찾기 가 이미 버렸다.
2709
+ */
2710
+ let 직접본것 = 0;
2711
+ for (const 파일 of 빠른것.따로볼파일 ?? []) {
2712
+ if (ctx.signal?.aborted) break;
2713
+ if (내부살림(파일)) continue;
2714
+ const rel = ctx.scope.show(파일);
2715
+ let n = 0;
2716
+ const 열었나 = 한파일에서찾기(파일, re, (i, 줄글) => {
2717
+ n += 1; total += 1;
2718
+ if (mode === 'content' && lines.length < limit) {
2719
+ const num = args['-n'] === false ? '' : `:${i + 1}`;
2720
+ lines.push(`${rel}${num}: ${줄글.trim().slice(0, 200)}`);
2721
+ }
2722
+ });
2723
+ if (열었나) 직접본것 += 1;
2724
+ if (n) 파일별.set(rel, (파일별.get(rel) ?? 0) + n);
2725
+ }
1988
2726
  for (const [rel, n] of 파일별) hitFiles.push({ rel, n });
1989
2727
  /*
1990
2728
  * 꼬리에 무엇을 적나.
@@ -2012,6 +2750,11 @@ export const TOOLS = {
2012
2750
  파일잘림 ? `(맞은 파일 ${hitFiles.length}개 중 앞 ${limit}개만 적었습니다)` : '',
2013
2751
  줄잘림 ? `(맞은 줄 ${total}개 중 앞 ${lines.length}개만 적었습니다)` : '',
2014
2752
  엔진말(빠른것.엔진),
2753
+ 직접본것
2754
+ ? (빠른것.따로볼까닭 === '추림'
2755
+ ? `(git grep 으로 추린 파일 ${직접본것}개를 직접 열어 찾았습니다)`
2756
+ : `(rg 가 글자로 못 푸는 파일 ${직접본것}개는 직접 열어 읽었습니다)`)
2757
+ : '',
2015
2758
  '(.gitignore·.deelignore 는 지켰습니다. 건너뛴 수는 안 셌습니다)',
2016
2759
  ].filter(Boolean).join(' ');
2017
2760
  const 붙이기2 = (t) => (꼬리2 ? [t, '', 꼬리2].join('\n') : t);
@@ -2037,15 +2780,15 @@ export const TOOLS = {
2037
2780
  * 그러면 rg 를 빌려 쓰고도 제일 오래 걸리는 일을 그대로 한 셈이 된다.
2038
2781
  */
2039
2782
  let files = isFile
2040
- ? [{ path: root, rel: ctx.scope.show(root) }]
2783
+ ? [{ path: root, rel: ctx.scope.show(root), size: 파일크기(root), 콕집음: true }]
2041
2784
  : await walk(root, { signal: ctx.signal });
2042
2785
  if (files.끊김) return { error: '중단했습니다. 폴더를 끝까지 안 훑었습니다.', 끝났다: true, 중단됨: true };
2043
2786
  const 안본것 = isFile ? '' : 건너뜀말(files.건너뜀, files.잘림, files.상한).trim(); // .gitignore 로 건너뛴 수 — 꼬리에 적는다
2044
2787
  // 훑기 상한에 걸렸으면 "일치 없음" 이라고 잘라 말하면 안 된다. 안 본 것이다.
2045
2788
  const 다못봄 = !isFile && !!files.잘림;
2046
- if (args.glob) {
2047
- const g = globToRegex(args.glob);
2048
- files = files.filter((f) => g.test(f.rel) || g.test(f.rel.split('/').pop()));
2789
+ if (glob) {
2790
+ const 맞나 = glob거르개(glob, { 뿌리: ctx.scope.root, 자리: root });
2791
+ files = files.filter((f) => 맞나(f.path));
2049
2792
  }
2050
2793
 
2051
2794
  /*
@@ -2088,20 +2831,17 @@ export const TOOLS = {
2088
2831
  // 자리를 내준 사이에 눌렸을 수 있다. 내주고 나면 다시 본다.
2089
2832
  if (ctx.signal?.aborted) { 멈춤 = '중단'; break; }
2090
2833
  }
2091
- if ((f.size ?? 0) > GREP_MAX_FILE) { 건너뛴것++; continue; }
2834
+ if ((f.size ?? 0) > (f.콕집음 ? GREP_MAX_ONE : GREP_MAX_FILE)) { 건너뛴것++; continue; }
2092
2835
  if (안읽을확장자.test(f.rel)) { 건너뛴것++; continue; }
2093
- let text;
2094
- try { text = readText(f.path); } catch { 건너뛴것++; continue; }
2095
- const ls = text.split('\n');
2096
2836
  let n = 0;
2097
- for (let i = 0; i < ls.length; i++) {
2098
- if (!re.test(ls[i])) continue;
2837
+ const 열었나 = 한파일에서찾기(f.path, re, (i, 줄글) => {
2099
2838
  n++; total++;
2100
2839
  if (mode === 'content' && lines.length < limit) {
2101
2840
  const num = args['-n'] === false ? '' : `:${i + 1}`;
2102
- lines.push(`${ctx.scope.show(f.path)}${num}: ${ls[i].trim().slice(0, 200)}`);
2841
+ lines.push(`${ctx.scope.show(f.path)}${num}: ${줄글.trim().slice(0, 200)}`);
2103
2842
  }
2104
- }
2843
+ });
2844
+ if (!열었나) { 건너뛴것++; continue; }
2105
2845
  if (n) hitFiles.push({ rel: ctx.scope.show(f.path), n });
2106
2846
  if (mode !== 'content' && hitFiles.length >= limit) { 멈춤 = '상한'; break; }
2107
2847
  }
@@ -2151,7 +2891,9 @@ export const TOOLS = {
2151
2891
 
2152
2892
  const hit = list.find((s) => s.name === want)
2153
2893
  ?? list.find((s) => s.name.toLowerCase() === want.toLowerCase())
2154
- ?? list.find((s) => s.name.split(':').pop() === want);
2894
+ ?? list.find((s) => s.name.split(':').pop() === want)
2895
+ // 꼬리도 대소문자를 안 가린다 — 온이름만 안 가려서 `project:mySkill` 을 `myskill` 로 부르면 못 찾았다 (6회차 Gemini 솜씨6o P2).
2896
+ ?? list.find((s) => s.name.split(':').pop().toLowerCase() === want.toLowerCase());
2155
2897
  if (!hit) {
2156
2898
  const near = list.filter((s) => s.name.includes(want) || want.includes(s.name.split(':').pop()))
2157
2899
  .slice(0, 5).map((s) => s.name);
@@ -2226,7 +2968,12 @@ export const TOOLS = {
2226
2968
  설명: args.description ?? null,
2227
2969
  남길것: ctx.셸남길것 ?? [],
2228
2970
  });
2229
- if (r.error) return { error: r.error };
2971
+ /*
2972
+ * 실패로 돌아가도 **떠 둔 것은 싣는다** (아래 끊김·시간 초과 갈래와 같은 까닭).
2973
+ * `rm keep.txt && npm run dev` 가 곧장 죽으면 파일은 이미 없는데, 되돌릴것 을 빼면
2974
+ * 화면에도 모델에도 「떠 뒀다」 가 한 글자도 안 남았다. (6회차 Gemini 도구6c C2)
2975
+ */
2976
+ if (r.error) return { error: r.error, 되돌릴것: 뜬것, 못뜬것, 스냅샷상한걸림 };
2230
2977
  if (!r.떴나) {
2231
2978
  // 지켜보는 사이에 죽었다. 포트가 물려 있거나 명령이 틀린 경우다.
2232
2979
  // 이걸 '띄웠습니다' 로 넘기면 모델은 다음 단계로 가고, 사람은 안 뜬
@@ -2235,7 +2982,7 @@ export const TOOLS = {
2235
2982
  error: `띄우자마자 끝났습니다 (${r.시그널 ? `${r.시그널} 시그널` : `종료코드 ${r.종료코드}`}).`
2236
2983
  + ' 뒤에서 돌 명령이 아니거나, 뜨자마자 탈이 난 것입니다.'
2237
2984
  + (r.출력?.trim() ? `\n\n나온 말:\n${clip(r.출력, 4000)}` : ''),
2238
- failed: true,
2985
+ failed: true, 되돌릴것: 뜬것, 못뜬것, 스냅샷상한걸림,
2239
2986
  };
2240
2987
  }
2241
2988
  return {
@@ -2258,7 +3005,7 @@ export const TOOLS = {
2258
3005
  // (윈도우 cmd 의 따옴표 문제와 그 해법도 거기 적혀 있다.)
2259
3006
  const shell = 셸명령(cmd);
2260
3007
 
2261
- const 제한 = args.timeout ?? 120000;
3008
+ const 제한 = bash제한시간(args.timeout);
2262
3009
  // 무엇을 빼고 넘길지 여기서 한 번 정한다. 뺀 이름은 아래에서 명령이
2263
3010
  // 실패했을 때만 쓴다 — 잘 돌 때마다 적으면 매 부름에 군말이 붙는다.
2264
3011
  const 셸것 = 셸환경(process.env, { 남길것: ctx.셸남길것 ?? [] });
@@ -2316,9 +3063,24 @@ export const TOOLS = {
2316
3063
  detached: process.platform !== 'win32',
2317
3064
  windowsVerbatimArguments: shell.verbatim === true,
2318
3065
  encoding: 'buffer',
2319
- }, (err, stdoutBuf, stderrBuf) => {
3066
+ /*
3067
+ * 넘치면 **셸이 살아 있을 때 나무째** 끊는다 (사냥5 M3 · tools/spawn.js 넘치면 머리말).
3068
+ * 셸만 죽이면 쏟던 손자가 파이프를 물고 살아, 넘친 즉시가 아니라 시간 초과에 돌아왔다.
3069
+ * 파이프는 안 끊는다 — 손자가 죽으면 곧 close 가 와서 넘친 말과 함께 끝맺는다.
3070
+ */
3071
+ 넘치면: () => 죽이기({ 파이프끊기: false, 더줄까: 1500 }),
3072
+ /*
3073
+ * 셸이 끝났는데 파이프가 안 닫히면 1초만 더 기다린다 (사냥5 M2). `node x & echo done`
3074
+ * 처럼 셸이 뒤로 띄운 것이 물고 있는 판이다. 남은 것은 끊으러 가고(유닉스는 무리째),
3075
+ * 못 끊으면 못 껐다고 아래에서 적는다.
3076
+ */
3077
+ 뿌리끝나면기다림: 1000,
3078
+ 남은것끊기: (아이) => 남은무리끊기(아이),
3079
+ }, (err, stdoutBuf, stderrBuf, 덤 = {}) => {
2320
3080
  clearTimeout(뒷북);
2321
3081
  ctx.signal?.removeEventListener?.('abort', 끊기);
3082
+ // 명령 뒤에 새로 생긴 파일(cp·mv 의 새 이름)을 없던 자리로 적는다 — 끊겼든 넘쳤든 생긴 것은 생긴 것이다.
3083
+ 나중에생긴것적기(떠본것, ctx);
2322
3084
  const 콘솔 = consoleCodepage() === 65001 ? 'utf-8' : null;
2323
3085
  const 풀기 = (b) => {
2324
3086
  if (!b || !b.length) return '';
@@ -2343,7 +3105,14 @@ export const TOOLS = {
2343
3105
  되돌릴것: 뜬것, 못뜬것, 스냅샷상한걸림,
2344
3106
  });
2345
3107
  }
2346
- if (시간초과 || (err && err.killed)) {
3108
+ /*
3109
+ * 시간 초과는 **시간 초과일 때만** 적는다 (사냥5 M3).
3110
+ *
3111
+ * 여기는 `err.killed` 로 갈랐다. 그런데 출력이 넘쳐 끊은 것도, 시그널로 죽은 것도
3112
+ * killed 가 참이라 전부 「시간 초과로 중단됨」 이 됐다 — 모델은 timeout 을 늘려
3113
+ * 같은 명령을 다시 부른다. 넘침·시그널은 아래에서 제 말로 적는다.
3114
+ */
3115
+ if (시간초과 || err?.시한 === true) {
2347
3116
  return done({
2348
3117
  error: `시간 초과로 중단됨 (${제한}ms)`, content: clip(out, 실을만큼(ctx)),
2349
3118
  되돌릴것: 뜬것, 못뜬것, 스냅샷상한걸림,
@@ -2405,8 +3174,18 @@ export const TOOLS = {
2405
3174
  + `${셸것.뺀것.length > 8 ? ' …' : ''}.`
2406
3175
  + ' 이 명령에 필요하면 설정에 "셸환경": { "남길것": ["이름"] } 을 적으세요]'
2407
3176
  : '';
3177
+ /*
3178
+ * 셸은 끝났는데 셸이 뒤로 띄운 것이 파이프를 물고 있어 더 안 기다린 판 (사냥5 M2).
3179
+ * 사실대로 적는다 — 무엇이 남았는지, 껐는지, 다음엔 어떻게 띄워야 하는지.
3180
+ */
3181
+ const 남은말 = !덤.파이프남음 ? ''
3182
+ : `\n\n[셸은 끝났는데 셸이 뒤로 띄운 프로세스가 출력을 물고 있어 더 기다리지 않았습니다 — `
3183
+ + (덤.남은것끊음
3184
+ ? '그 프로세스 무리를 끝냈습니다.'
3185
+ : '윈도우에서는 셸이 먼저 끝나면 그 아래를 못 찾아 끄지 못했습니다(작업 관리자에서 찾아 끄세요).')
3186
+ + ' 끝나지 않는 것은 background: true 로 띄우고 Jobs 로 읽으세요]';
2408
3187
  done({
2409
- content: clip(out || '(출력 없음)', 실을만큼(ctx)) + 꼬리 + 뺀말,
3188
+ content: clip(out || '(출력 없음)', 실을만큼(ctx)) + 꼬리 + 남은말 + 뺀말,
2410
3189
  summary: 잘됨 ? 말('sum.ok')
2411
3190
  : 시그널 ? 말('sum.killedBy', { 시그널 })
2412
3191
  : 넘침 ? 말('sum.tooMuchOut')
@@ -2502,8 +3281,19 @@ export const TOOLS = {
2502
3281
  };
2503
3282
  const 끊기 = () => {
2504
3283
  끊겼나 = true;
2505
- // Ctrl+C 는 짧게 — 사람이 지금 돌려받으려고 누른 것이다.
2506
- 죽이기({ 더줄까: 1200 });
3284
+ /*
3285
+ * Ctrl+C 는 짧게 — 사람이 지금 돌려받으려고 누른 것이다.
3286
+ *
3287
+ * 다만 **파이프는 안 끊는다.** 여기가 기본값(파이프끊기: true)을 그대로
3288
+ * 쓰고 있었는데, 파이프를 끊으면 아직 안 읽힌 것이 통째로 사라진다.
3289
+ * 재 보니 몇 줄이 아니라 전부였다 — 200ms 동안 찍은 것이 한 글자도 안
3290
+ * 오고 content 가 빈 글이었다. 사람도 모델도 어디까지 됐는지 모른 채
3291
+ * 「사용자가 중단했습니다」 한 줄만 받는다.
3292
+ *
3293
+ * 바로 아래 시간 초과 갈래는 이미 파이프끊기: false 로 그 몇 줄을
3294
+ * 받는다. 아래 그물(400ms)이 이 자리를 오래 안 붙든다.
3295
+ */
3296
+ 죽이기({ 파이프끊기: false, 더줄까: 1200 });
2507
3297
  clearTimeout(뒷북);
2508
3298
  /*
2509
3299
  * ── 곧장 끝맺으면 **나온 말이 통째로 버려진다** ────────────────
@@ -2550,7 +3340,8 @@ export const TOOLS = {
2550
3340
  시간초과 = true;
2551
3341
  죽이기({ 파이프끊기: false, 더줄까: 4000 });
2552
3342
  const 그물 = setTimeout(() => {
2553
- done({ error: `시간 초과로 중단됨 (${제한}ms) — 자식 프로세스가 안 끝나 강제로 끝냈습니다` });
3343
+ // 여기서 끝맺어도 떠 둔 것은 싣는다 — 콜백의 시간 초과 갈래·중단 그물과 같다 (6회차 Gemini 도구6d D2).
3344
+ done({ error: `시간 초과로 중단됨 (${제한}ms) — 자식 프로세스가 안 끝나 강제로 끝냈습니다`, 되돌릴것: 뜬것, 못뜬것, 스냅샷상한걸림 });
2554
3345
  }, 1000);
2555
3346
  그물.unref?.();
2556
3347
  }, 제한);
@@ -2832,7 +3623,11 @@ export const TOOLS = {
2832
3623
  line: r.줄,
2833
3624
  // 화면에 무엇을 적었는지 보여 주려고 같이 넘긴다. 사람이 못 보면
2834
3625
  // 틀린 기억이 조용히 쌓인다 — 그게 제일 나쁘다.
2835
- content: r.줄,
3626
+ // 적었지만 안 실리면 모델에게도 그렇게 말한다 (6회차 Gemini 기억6z-b Z2′) — 안 그러면
3627
+ // 다음 요청부터 그 말이 지켜진다고 믿고 사람에게도 그렇게 말한다.
3628
+ content: r.안실림
3629
+ ? `${r.줄}\n(적었지만 다음 요청부터 안 실립니다 — 믿는 폴더가 아니고 이 PC 가 적지 않은 줄이 이 기억 파일에 섞여 있습니다. 사람에게 그대로 알리세요.)`
3630
+ : r.줄,
2836
3631
  };
2837
3632
  },
2838
3633
  },
@@ -2882,8 +3677,17 @@ export function 설명줄이기(schema, 한도) {
2882
3677
  const 자르기 = (글, 몫) => {
2883
3678
  const s = String(글 ?? '');
2884
3679
  if (s.length <= 몫) return s;
2885
- // 한국어 문장은 '다.' 로 끝난다. 영문 마침표도 같이 본다.
2886
- const 조각 = s.split(/(?<=다\.|[.!?])\s+/);
3680
+ /*
3681
+ * 문장 끝(`.` · `!` · `?`) **뒤의 공백**에서만 가른다.
3682
+ *
3683
+ * 여기가 `(?<=다\.|[.!?])` 였다. 앞 갈래는 한 번도 혼자 걸리지 않는다 —
3684
+ * `다.` 의 마침표를 뒤 갈래가 이미 먹기 때문이다. 도구 설명 전부와 인자
3685
+ * 설명 전부를 두 가르개로 갈라 견줘 보니 **다른 자리가 한 군데도 없었다.**
3686
+ * 마침표 없는 「…한다 …한다」 를 가르려던 뜻이었다면 그것도 안 됐다
3687
+ * (`다.` 는 마침표를 요구한다). 안 걸리는 갈래를 「한국어를 본다」 고
3688
+ * 적어 두면 다음 사람이 그 말을 믿고 한국어를 더 안 챙긴다.
3689
+ */
3690
+ const 조각 = s.split(/(?<=[.!?])\s+/);
2887
3691
  let 모은것 = 조각[0] ?? s;
2888
3692
  for (const 다음 of 조각.slice(1)) {
2889
3693
  if ((모은것 + ' ' + 다음).length > 몫) break;
@@ -2956,8 +3760,13 @@ export function 설명줄이기(schema, 한도) {
2956
3760
  */
2957
3761
  function 눈붙이기(fn, 이름, vision) {
2958
3762
  if (이름 !== 'Read' || !vision) return fn;
2959
- // 화면 말을 그대로 본다. 설명 글자를 보고 짐작하면 안 된다 — 줄이기가 문장
2960
- // 한복판을 자르므로 마지막 글자가 무엇일지 정해져 있지 않다.
3763
+ // 지시말() 을 그대로 본다 — 화면 말이 아니다. 이 덧말은 **모델이 읽는 글**에
3764
+ // 붙는 것이라, '한국어 화면 + 영어 지시' 를 고른 사람에게도 영어로 가야 한다.
3765
+ //
3766
+ // 설명 글자를 보고 짐작하면 안 된다. 줄이기는 늘 문장째로 끝나므로 마지막
3767
+ // 글자는 언제나 문장 끝이고, 그것만 봐서는 이 설명이 어느 말로 나갔는지
3768
+ // 가릴 수 없다 — 영어설명() 이 이미 통째로 갈아 끼웠을 수도 있다.
3769
+ //
2961
3770
  // 위 영어설명() 과 같은 갈림이어야 한다. 여기만 'en' 을 보면, 일본어로
2962
3771
  // 켠 사람은 영어 설명 뒤에 한국어 한 문장이 붙은 것을 받는다.
2963
3772
  const 덧말 = 지시말() !== 'ko'
@@ -2966,6 +3775,28 @@ function 눈붙이기(fn, 이름, vision) {
2966
3775
  return { ...fn, description: String(fn.description ?? '') + 덧말 };
2967
3776
  }
2968
3777
 
3778
+ /*
3779
+ * 배열 **속** 칸도 갈아 끼운다.
3780
+ *
3781
+ * 겉의 칸만 갈던 때, 영어로 켠 사람의 도구 정의에 `edits[].old_string: 바꿀 대상`
3782
+ * 같은 한국어가 아홉 군데 그대로 실렸다. 하필 이 도구들이 제일 세게 미는 길이
3783
+ * 배열 쪽이다 — 「여러 군데는 edits 로 한 번에」 「스무 개를 옮기려고 스무 번
3784
+ * 부르지 마라」. 제일 비싼 길의 설명서만 안 읽히고 있었던 셈이다.
3785
+ *
3786
+ * 겉의 말을 그대로 물려주지 않는다. 겉은 `(single file)` 처럼 **한 개짜리 길**을
3787
+ * 가리키는 말이라 배열 안에서는 틀린 말이 된다 — 그래서 속은 따로 적는다
3788
+ * (tools/desc.en.js 의 items). 속 표가 없는 도구는 여태처럼 군다.
3789
+ */
3790
+ function 속칸갈기(값, 속표) {
3791
+ if (!속표 || !값?.items?.properties) return 값;
3792
+ const 새칸 = {};
3793
+ for (const [칸, v] of Object.entries(값.items.properties)) {
3794
+ const 글 = 속표[칸];
3795
+ 새칸[칸] = 글 ? { ...v, description: 글 } : v;
3796
+ }
3797
+ return { ...값, items: { ...값.items, properties: 새칸 } };
3798
+ }
3799
+
2969
3800
  /*
2970
3801
  * 도구 설명을 영어 표로 갈아 끼운다.
2971
3802
  *
@@ -2987,7 +3818,7 @@ export function 영어설명(schema, 이름, 쓸말 = 지시말()) {
2987
3818
  const 새속성 = {};
2988
3819
  for (const [인자, 값] of Object.entries(p.properties ?? {})) {
2989
3820
  const 글 = 것.params?.[인자];
2990
- 새속성[인자] = 글 ? { ...값, description: 글 } : 값;
3821
+ 새속성[인자] = 속칸갈기(글 ? { ...값, description: 글 } : 값, 것.items?.[인자]);
2991
3822
  }
2992
3823
  return {
2993
3824
  ...schema,
@@ -3028,7 +3859,12 @@ export function toolSchemas(names = null, { hasSkills = false, web = true, work
3028
3859
  */
3029
3860
  const 한도 = 설명길이(ctx);
3030
3861
  /*
3031
- * 화면 말이 영어면 도구 설명도 영어로 갈아 끼운다 (tools/desc.en.js).
3862
+ * **지시말**이 영어면 도구 설명도 영어로 갈아 끼운다 (tools/desc.en.js).
3863
+ *
3864
+ * 화면 말(언어())이 아니다. 여기서 만드는 것은 모델에게 주는 목록이라
3865
+ * 지시말() 을 따른다 — `/tools` 로 사람이 보는 화면만 언어() 를 따로 본다
3866
+ * (영어설명 의 쓸말 인자). 한국어 화면 + 영어 지시를 고른 사람에게는
3867
+ * 화면은 한국어인데 이 목록은 영어로 나간다.
3032
3868
  *
3033
3869
  * 줄이기 **전에** 갈아 끼운다. 순서가 반대면 한글 설명을 한도에 맞춰 자른
3034
3870
  * 다음 영어로 통째로 바꾸는 셈이라, 자른 것이 아무 뜻이 없어지고 영어 글은
@@ -3136,6 +3972,29 @@ export async function 언어서버있나(뿌리) {
3136
3972
  }
3137
3973
 
3138
3974
  export async function runTool(name, args, ctx) {
3975
+ /*
3976
+ * ── 멈추라고 했으면 시작도 안 한다 ──────────────────────────────────
3977
+ *
3978
+ * 여럿을 함께 돌릴 때(loop.js 의 Promise.all) 앞엣것이 도는 사이 사람이
3979
+ * ESC 를 누르면, 뒤엣것들은 **아직 아무 일도 안 했는데** 그대로 돌았다.
3980
+ * 여기서 한 번 보면 그 자리가 막힌다.
3981
+ *
3982
+ * 중단은 **실패가 아니다.** 그래서 중단됨 을 따로 단다 — 이걸 실패로
3983
+ * 세면 되풀이 감지가 엉뚱하게 걸려서, 다음에 같은 도구를 부르는 것까지
3984
+ * "또 그러네" 로 막아 버린다. 사람이 멈춘 것은 도구 잘못이 아니다.
3985
+ *
3986
+ * ── 이 관문이 **제일 앞**에 있어야 하는 이유 ─────────────────────────
3987
+ *
3988
+ * 여태 바로 아래 MCP 갈래 **뒤에** 있었다. 그래서 `mcp__…` 이름은 이
3989
+ * 관문을 못 보고 곧장 서버로 갔다 — 사람이 ESC 를 누른 뒤에 **바깥
3990
+ * 프로세스로 요청이 나갔다.** 우리 도구가 멈추는 것보다 더 지켜야 할
3991
+ * 자리다. 남의 프로그램이 파일을 쓰거나 밖으로 글을 보내면 우리가
3992
+ * 되돌릴 길이 없다. 멈춤은 도구 갈래를 가리지 않는다.
3993
+ */
3994
+ if (ctx.signal?.aborted) {
3995
+ return { error: '중단했습니다. 실행하지 않았습니다.', 끝났다: true, 중단됨: true };
3996
+ }
3997
+
3139
3998
  // 밖에서 붙인 도구(MCP)는 이름 앞머리로 갈린다.
3140
3999
  //
3141
4000
  // 여기서 먼저 갈라야 하는 이유: MCP 서버는 우리 scope 를 안 지킨다.
@@ -3156,21 +4015,6 @@ export async function runTool(name, args, ctx) {
3156
4015
  const t = Object.hasOwn(TOOLS, name) ? TOOLS[name] : null;
3157
4016
  if (!t) return { error: `모르는 도구: ${name}` };
3158
4017
 
3159
- /*
3160
- * ── 멈추라고 했으면 시작도 안 한다 ──────────────────────────────────
3161
- *
3162
- * 여럿을 함께 돌릴 때(loop.js 의 Promise.all) 앞엣것이 도는 사이 사람이
3163
- * ESC 를 누르면, 뒤엣것들은 **아직 아무 일도 안 했는데** 그대로 돌았다.
3164
- * 여기서 한 번 보면 그 자리가 막힌다.
3165
- *
3166
- * 중단은 **실패가 아니다.** 그래서 중단됨 을 따로 단다 — 이걸 실패로
3167
- * 세면 되풀이 감지가 엉뚱하게 걸려서, 다음에 같은 도구를 부르는 것까지
3168
- * "또 그러네" 로 막아 버린다. 사람이 멈춘 것은 도구 잘못이 아니다.
3169
- */
3170
- if (ctx.signal?.aborted) {
3171
- return { error: '중단했습니다. 실행하지 않았습니다.', 끝났다: true, 중단됨: true };
3172
- }
3173
-
3174
4018
  try {
3175
4019
  const r = await t.run(args ?? {}, ctx);
3176
4020
  ctx.audit.tool(name, args, r);
@@ -3221,7 +4065,36 @@ export async function runTool(name, args, ctx) {
3221
4065
  * 이미 손댄 것은 손댄 대로 넘긴다 — 둘은 같이 참일 수 있다.
3222
4066
  */
3223
4067
  if (!바꿔놨나) {
3224
- return { error: '중단했습니다.', 끝났다: true, 중단됨: true };
4068
+ /*
4069
+ * ── 바꿔 놓은 것이 없어도 **여태 나온 말은 버리지 않는다** ────────
4070
+ *
4071
+ * 여기가 `{ error: '중단했습니다.' }` 한 줄을 **새로 지어서** 돌려주는
4072
+ * 자리다. 그러면서 도구가 실어 보낸 content 를 통째로 버리고 있었다.
4073
+ * 잰 것: 끊긴 Bash 를 `TOOLS.Bash.run` 으로 부르면 죽기 직전에 찍은
4074
+ * 몇백 자가 오는데, 같은 판을 `runTool` 로 부르면 `len=0` 이다.
4075
+ * 아래층(tools/spawn.js)이 close 를 못 받는 판에서도 그 글을 살려
4076
+ * 올려 보내게 해 놨는데, 그 애쓴 것이 이 줄에서 사라졌다.
4077
+ *
4078
+ * 그 몇 줄이 대개 제일 중요한 줄이다 — 어디까지 돌았는지, 무엇이
4079
+ * 뻗었는지가 거기 있다. 여덟 개를 서로 다른 까닭으로 다 실패한
4080
+ * Write·Edit 의 줄별 사유도 같은 자리에서 사라졌다(실을글 머리말).
4081
+ *
4082
+ * 싣는 것은 **도구가 스스로 탈을 달고 온 판**뿐이다. 탈이 있다는 것은
4083
+ * 「일하다 끊겼다」 는 뜻이라, 그 글은 무슨 일이 있었는지의 자국이다.
4084
+ * 탈 없이 멀쩡히 끝난 읽기는 여태처럼 버린다 — 바로 위 머리말이 적어
4085
+ * 둔 대로 버려도 잃을 것이 없고, 실어 보내면 모델이 「중단됐는데 답은
4086
+ * 다 받았다」 로 읽는다.
4087
+ *
4088
+ * 말은 바꿔 놓은 갈래(바로 아래)와 어긋나지 않는다. 거기도 오류와 글을
4089
+ * 같이 넘긴다 — 둘은 같이 참일 수 있다.
4090
+ */
4091
+ const 나온말 = (r.error && typeof r.content === 'string' && r.content.trim()) ? r.content : null;
4092
+ return {
4093
+ error: '중단했습니다.',
4094
+ ...(나온말 ? { content: 나온말 } : {}),
4095
+ 끝났다: true,
4096
+ 중단됨: true,
4097
+ };
3225
4098
  }
3226
4099
  return { ...r, 중단됨: true, 중단전에끝남: true };
3227
4100
  }
@@ -3270,7 +4143,18 @@ async function runMcpTool(name, args, ctx) {
3270
4143
  content: clip(글, 실을만큼(ctx)),
3271
4144
  };
3272
4145
  } catch (e) {
3273
- return { error: e.message };
4146
+ /*
4147
+ * `e.message` 만 쓰면 **실패가 성공으로 읽힌다.**
4148
+ *
4149
+ * 남의 프로세스에서 올라오는 것이라 Error 라는 보장이 없다 — JSON-RPC 로
4150
+ * 받은 값을 그대로 던지는 서버가 흔하고, 그러면 `{ error: undefined }` 가
4151
+ * 되어 부르는 쪽의 `if (result.error)` 가 거짓이 된다. 도구가 아무것도
4152
+ * 못 했는데 모델은 됐다고 여기고 다음 걸음으로 간다.
4153
+ *
4154
+ * 같은 파일의 형제 자리(runTool 의 catch)와 loop.js 는 이미
4155
+ * String(e?.message ?? e) 를 쓴다. 여기만 달랐다.
4156
+ */
4157
+ return { error: String(e?.message ?? e) };
3274
4158
  }
3275
4159
  })();
3276
4160
  ctx.audit.tool(name, args, r);