deel-local-cli 1.20.9 → 1.20.11

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.
@@ -278,9 +278,57 @@ function 바이너리인가(abs) {
278
278
  + ' 정말 이 자리를 바꿔야 한다면 사용자에게 직접 물어보고, 다른 이름으로 새로 만드세요.';
279
279
  }
280
280
 
281
- /** 이 파일이 무슨 인코딩인지. 아직 Read 로 안 읽은 파일을 이어 쓸 때 쓴다. */
281
+ /**
282
+ * 이 파일이 무슨 인코딩인지. 아직 Read 로 안 읽은 파일을 이어 쓸 때 쓴다.
283
+ *
284
+ * **앞머리만 본다.** 여태 readFileSync 로 통째로 읽었는데, 인코딩 판정은
285
+ * 바이트 무늬를 보는 일이라 앞부분이면 충분하다 — 바로 위 바이너리인가()
286
+ * 도 같은 잣대로 앞 8000바이트만 본다. 30MB 로그에 다섯 줄 붙이자고 30MB 를
287
+ * 다시 읽을 이유가 없다 (test/bigfile.test.js 의 「이어 붙이는 값」).
288
+ *
289
+ * 앞머리를 좀 넉넉히 잡는다. CP949 와 CP932 는 바이트 범위가 겹쳐서 짧게
290
+ * 볼수록 자주 뒤집힌다. 64KB 면 판정이 흔들리지 않으면서도 크기와 상관없다.
291
+ */
292
+ const 인코딩볼바이트 = 64 * 1024;
293
+
294
+ /*
295
+ * 자른 자리가 **글자 한가운데**면 안 된다.
296
+ *
297
+ * 64KB 에서 뚝 자르면 마지막 글자의 바이트 일부만 남는 일이 잦다. 한글은
298
+ * UTF-8 로 한 글자에 세 바이트라 자를 자리 셋 중 둘이 그렇다. 그 부스러기
299
+ * 하나 때문에 「UTF-8 규칙에 안 맞는다」 로 판정이 뒤집히고, 멀쩡한 UTF-8
300
+ * 한글 파일이 CP936 으로 읽힌다.
301
+ *
302
+ * 실제로 그렇게 됐다. **64KB 를 넘는 UTF-8 한글 파일에는 Append 가 통째로
303
+ * 거절됐다** — 「이 파일은 CP936 로 되어 있는데 그 인코딩에 없는 글자가
304
+ * 있습니다」. 파일은 UTF-8 이고 글자도 멀쩡한데.
305
+ *
306
+ * 그런데 그 자리를 지키라고 세워 둔 검사(bigfile.test.js 의 「이어 붙이는
307
+ * 값」)는 **초록이었다.** 걸린 시간만 쟀지 돌아온 결과를 안 봤기 때문이다.
308
+ * 거절은 빠르니 값도 좋아 보였다.
309
+ *
310
+ * 그래서 끝에서 최대 세 바이트를 물러나 글자 경계에 맞춘다. 파일이 앞머리보다
311
+ * 짧으면 자른 것이 아니므로 손대지 않는다 — 거기서 물러나면 진짜 마지막
312
+ * 글자를 깎는 것이 된다.
313
+ */
314
+ function 글자경계까지(buf, 잘렸나) {
315
+ if (!잘렸나) return buf;
316
+ for (let 뒤 = 1; 뒤 <= 3 && 뒤 <= buf.length; 뒤++) {
317
+ const b = buf[buf.length - 뒤];
318
+ if ((b & 0xc0) === 0x80) continue; // 이어지는 바이트 — 더 물러난다
319
+ if (b < 0x80) return buf; // 아스키로 끝났다 — 성한 자리다
320
+ const 글자길이 = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1;
321
+ return 글자길이 > 뒤 ? buf.subarray(0, buf.length - 뒤) : buf;
322
+ }
323
+ return buf;
324
+ }
325
+
282
326
  function 재는인코딩(abs) {
283
- try { return decodeBytes(readFileSync(abs)).encoding; } catch { return 'utf-8'; }
327
+ try {
328
+ const buf = 앞머리(abs, 인코딩볼바이트);
329
+ if (!buf) return 'utf-8';
330
+ return decodeBytes(글자경계까지(buf, buf.length >= 인코딩볼바이트)).encoding;
331
+ } catch { return 'utf-8'; }
284
332
  }
285
333
 
286
334
  /**
@@ -342,7 +390,10 @@ function 뜰만한낱말(cmd) {
342
390
  * 스크립트 안에서 지우는 것, 폴더 통째는 여기서 안 보인다. 그래서 결과에
343
391
  * '이건 되돌릴 수 있다' 는 말을 붙이지 않고, 뜬 개수만 사실대로 넘긴다.
344
392
  *
345
- * @returns {string[]} 파일들의 보인 이름
393
+ * @returns {{뜬것: string[], 못뜬것: string[], 상한걸림: boolean}}
394
+ * 뜬것 — 되돌릴 수 있다
395
+ * 못뜬것 — 손은 댔는데 되돌릴 수 없다 (바이너리 등)
396
+ * 상한걸림 — 대상이 너무 많아 뒤엣것은 보지도 못했다
346
397
  */
347
398
  function 바꾸기전스냅샷(cmd, ctx) {
348
399
  /*
@@ -363,10 +414,12 @@ function 바꾸기전스냅샷(cmd, ctx) {
363
414
  * (safety/guard.js).
364
415
  */
365
416
  const 셸쓰기 = 셸이파일에쓰나(cmd);
366
- if (!isMutating(cmd) && !셸쓰기) return [];
417
+ if (!isMutating(cmd) && !셸쓰기) return { 뜬것: [], 못뜬것: [], 상한걸림: false };
367
418
  const 뜬것 = [];
419
+ const 못뜬것 = [];
420
+ let 넘쳤나 = false;
368
421
  for (const t of 뜰만한낱말(cmd)) {
369
- if (뜬것.length >= 스냅샷상한) break;
422
+ if (뜬것.length + 못뜬것.length >= 스냅샷상한) { 넘쳤나 = true; break; }
370
423
  let abs;
371
424
  // 범위 밖은 어차피 checkPaths 가 이미 막았다. 여기서 터지면 안 된다 —
372
425
  // 뜨는 데 실패했다고 명령 자체를 막으면 안 되는 명령까지 막힌다.
@@ -385,11 +438,33 @@ function 바꾸기전스냅샷(cmd, ctx) {
385
438
  * 한 번에 이력이 쓰레기로 찬다. 그래서 이 갈래는 쓰는 꼴에만 연다.
386
439
  */
387
440
  if (!있나 && !셸쓰기) continue;
388
- ctx.history.snapshot(abs, 'Bash');
389
- 뜬것.push(ctx.scope.show(abs));
441
+ /*
442
+ * ── 뜬 것과 **못 뜬 것**을 갈라 담는다 ──────────────────────────
443
+ *
444
+ * snapshot() 의 답을 버리고 무조건 「떴다」 목록에 넣고 있었다.
445
+ * 그런데 그림·xlsx·zip·hwp 는 safeRead 가 내용을 못 떠서
446
+ * `skipped` 로 돌아오고, /undo 는 그런 기록을 만나면 **그 파일을
447
+ * 아예 안 건드린다**(undo.js). 즉 되돌릴 수 없다.
448
+ *
449
+ * 그 사실을 안 갈라서, `rm 로고.png` 한 줄에 화면은
450
+ * 「↩ 로고.png 는 떠 뒀습니다 — /undo 로 되돌아갑니다」 를 찍었다.
451
+ * 파일은 이미 없고 영영 안 돌아온다. 이 프로그램에서 제일 나쁜 꼴 —
452
+ * **안전망이 있다고 말하는데 없는 것**이다.
453
+ */
454
+ const rec = ctx.history.snapshot(abs, 'Bash');
455
+ if (rec?.skipped) 못뜬것.push(`${ctx.scope.show(abs)} (${rec.skipped})`);
456
+ else 뜬것.push(ctx.scope.show(abs));
390
457
  } catch { /* 못 뜨면 그냥 넘어간다. 명령은 돌아야 한다 */ }
391
458
  }
392
- return 뜬것;
459
+ /*
460
+ * 상한에 걸려 **뒤엣것을 아예 안 봤으면** 그것도 못 뜬 것이다.
461
+ *
462
+ * `sed -i … f1.js … f40.js` 처럼 대상이 상한을 넘으면 뒤의 것은 이력에
463
+ * 안 남는다. 그런데 화면에는 앞의 24개만 「떠 뒀습니다」 로 뜨고, 사람은
464
+ * 마흔 개가 다 되돌아갈 줄 안다. Move 가 같은 자리에서 이미 말해 준다
465
+ * (되돌리기반쪽) — 여기만 안 말하고 있었다.
466
+ */
467
+ return { 뜬것, 못뜬것, 상한걸림: 넘쳤나 };
393
468
  }
394
469
 
395
470
  /** 지금 파일이 몇 줄인가. 붙인 뒤 '얼마나 찼는지' 를 사실로 말해 주려고 센다. */
@@ -407,6 +482,16 @@ function 줄재기(abs, 인코딩) {
407
482
  if (looksBinary(buf)) return { 줄: 0, 끝줄바꿈: true };
408
483
  const t = 인코딩 && 인코딩 !== 'utf-8' ? decodeBytes(buf).text : buf.toString('utf8');
409
484
  const 끝줄바꿈 = t.length === 0 || t.endsWith('\n');
485
+ /*
486
+ * 빈 파일은 **0줄**이다.
487
+ *
488
+ * 끝줄바꿈 쪽은 `t.length === 0` 을 이미 봐 두고, 줄 세는 쪽은
489
+ * `t.endsWith('\n')` 만 봤다. 그래서 0바이트 파일이 `''.split()` →
490
+ * 1줄이 됐다. Append 로 큰 파일을 만드는 것이 이 도구의 쓰임인데,
491
+ * 첫 줄부터 하나가 더 얹히고 그 값이 줄기억에 캐시로 남아 **끝까지
492
+ * 하나 더 많은 수**가 모델에게 간다.
493
+ */
494
+ if (t.length === 0) return { 줄: 0, 끝줄바꿈 };
410
495
  return { 줄: t.split('\n').length - (t.endsWith('\n') ? 1 : 0), 끝줄바꿈 };
411
496
  } catch { return { 줄: 0, 끝줄바꿈: true }; }
412
497
  }
@@ -737,6 +822,23 @@ async function 한개옮기기({ from, to, overwrite = false }, ctx) {
737
822
  * 앞의 2만 개만 되돌린다. 그걸 말 안 하면 사람은 되돌렸다고 믿고 넘어간다.
738
823
  */
739
824
  const 되돌리기반쪽 = !!훑은것?.잘림;
825
+ /*
826
+ * ── 살림 폴더는 **옮겨지는데 세지도 뜨지도 않는다** ────────────────
827
+ *
828
+ * walk 는 node_modules·.git·dist 를 안 훑는다. 그런데 실제 이동은
829
+ * renameSync 라 **전부** 옮겨진다. 그래서 두 가지가 어긋났다 —
830
+ *
831
+ * · 결과문의 `폴더 12개 파일` 은 훑힌 것만 센 수다. 모델이 받는
832
+ * 숫자가 사실이 아니다.
833
+ * · /undo 를 누르면 훑힌 12개만 옛 자리로 돌아가고 `.git` 은 새
834
+ * 자리에 남는다. 소스와 .git 이 갈라진 저장소가 되고, 화면은
835
+ * 초록색 성공을 찍는다.
836
+ *
837
+ * 안 뜨는 것 자체는 그대로 둔다 — 되돌리자고 3만 개를 뜨는 쪽이 훨씬
838
+ * 나쁘다. 대신 **말은 한다.** 바로 아래 되돌리기반쪽 이 같은 성격의
839
+ * 누락을 이미 말해 주고 있어서, 한쪽만 말하는 것이 그 자체로 어긋남이다.
840
+ */
841
+ const 안뜬살림 = 폴더인가 ? (훑은것?.건너뛴살림 ?? []) : [];
740
842
  for (const [a, b] of 짝들) {
741
843
  ctx.history.snapshot(a, 'Move');
742
844
  ctx.history.snapshot(b, 'Move');
@@ -763,8 +865,14 @@ async function 한개옮기기({ from, to, overwrite = false }, ctx) {
763
865
  }
764
866
 
765
867
  for (const [, b] of 짝들) ctx.seen.add(b);
766
- const 무엇 = 폴더인가 ? `폴더 ${짝들.length}개 파일` : '';
767
- const 경고 = (되돌리기반쪽
868
+ const 무엇 = 폴더인가
869
+ ? `폴더 ${짝들.length}개 파일${안뜬살림.length ? ' + 살림 폴더' : ''}`
870
+ : '';
871
+ const 경고 = (안뜬살림.length
872
+ ? `\n(${안뜬살림.slice(0, 3).join(' · ')}${안뜬살림.length > 3 ? ` 외 ${안뜬살림.length - 3}개` : ''} 도 같이 옮겨졌지만 되돌리기에는 안 떴습니다`
873
+ + ' — /undo 를 하면 이것들만 새 자리에 남습니다)'
874
+ : '')
875
+ + (되돌리기반쪽
768
876
  ? `\n(파일이 ${훑은것.상한.toLocaleString('en-US')}개를 넘어 되돌리기에는 앞부분만 떴습니다 — 옮기기는 전부 됐지만 /undo 는 다 못 되돌립니다)`
769
877
  : '')
770
878
  + (원본남음
@@ -803,7 +911,22 @@ async function 여러개옮기기(목록, ctx) {
803
911
  */
804
912
  const 바뀐것들 = [];
805
913
  for (const 하나 of 목록) {
806
- const r = await 한개옮기기(하나, ctx);
914
+ /*
915
+ * ── 던지는 것도 **하나가 실패한 것**이다 ────────────────────────
916
+ *
917
+ * try 없이 부르고 있었다. 그런데 한개옮기기() 안의 scope.resolve 는
918
+ * 범위 밖이면 던지고, mkdirSync 는 길 중간이 파일이면 ENOTDIR 로
919
+ * 던진다. 그 예외가 여기를 뚫고 나가면 **앞서 실제로 옮겨진 파일이
920
+ * 결과에서 통째로 사라진다** — 디스크는 움직였는데 된것·바뀐것들 이
921
+ * 버려져서, 턴 끝 목록도 /commit 도 「아무 일도 없었다」 로 안다.
922
+ * 모델은 실패로 알고 같은 배열을 다시 보낸다.
923
+ *
924
+ * 형제 함수 둘(여러파일쓰기·여러군데고치기)은 이미 이 까닭으로
925
+ * 감싸고 있다. 여기만 빠져 있었다.
926
+ */
927
+ let r;
928
+ try { r = await 한개옮기기(하나, ctx); }
929
+ catch (err) { r = { error: String(err?.message ?? err) }; }
807
930
  if (r.error) { 안된것.push(`${하나.from ?? '?'} → ${하나.to ?? '?'}: ${r.error}`); continue; }
808
931
  된것.push(r.content);
809
932
  // 폴더를 옮겼으면 그 안의 파일 하나하나가, 아니면 옮겨 간 자리가 답이다.
@@ -865,6 +988,28 @@ function 한파일쓰기(args, ctx) {
865
988
  // 제 설정(.deel/config.json)을 덮어쓰면 연결이 통째로 날아간다.
866
989
  const 못쓰는이유 = 내부살림(abs);
867
990
  if (못쓰는이유) return { error: 못쓰는이유 };
991
+ /*
992
+ * 폴더 이름을 주면 **날 오류**가 그대로 나갔다.
993
+ *
994
+ * `EISDIR: illegal operation on a directory, read` — 되돌리기가
995
+ * 스냅샷을 뜨려고 폴더를 readFileSync 하다 터진 것이라, 모델은 제가
996
+ * 무엇을 잘못했는지 짚을 실마리를 못 얻는다. Read 도 Append 도 같은
997
+ * 자리에서 사람 말로 거절한다. Write 만 그 밖에 있었다.
998
+ */
999
+ if (existsSync(abs) && statSync(abs).isDirectory()) {
1000
+ return { error: `폴더입니다: ${args.file_path}. 파일 이름까지 적어 주세요.`, 끝났다: true };
1001
+ }
1002
+ /*
1003
+ * 그림 이름으로 **새 글 파일**을 만드는 것도 막는다.
1004
+ *
1005
+ * 바이너리 검사는 이미 있는 파일만 본다(바이너리인가 는 없는 파일에
1006
+ * null 을 준다). 그래서 `Write('로고.png', '…')` 는 검사를 통째로
1007
+ * 비껴가 확장자만 png 인 글 파일을 만들었고, 화면에는 `새로 만듦:
1008
+ * 로고.png (1줄)` 이 떴다. 그 뒤 Read 는 그림으로 열려다 실패한다.
1009
+ */
1010
+ if (그림인가(abs)) {
1011
+ return { error: `${ctx.scope.show(abs)} 는 그림 파일 이름입니다 — 글로는 만들 수 없습니다.`, 끝났다: true };
1012
+ }
868
1013
  // 엑셀 파일을 통째로 덮어쓰면 xlsx 가 아니라 그냥 글 파일이 된다.
869
1014
  // 열리지도 않는 파일이 되고, 원본은 이미 없다. 아예 막는다.
870
1015
  if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
@@ -911,7 +1056,19 @@ function 한파일쓰기(args, ctx) {
911
1056
  // 우리가 가린 비밀도 마찬가지다 (가린표되돌리나 머리말).
912
1057
  const 표막기 = 가린표되돌리나(ctx.scope.show(abs), args.content);
913
1058
  if (표막기) return { error: 표막기 };
914
- ctx.history.snapshot(abs, 'Write');
1059
+ /*
1060
+ * ── 스냅샷은 **정말 쓰기 직전**에 뜬다 ─────────────────────────
1061
+ *
1062
+ * 여기서 먼저 뜨고, 그 아래 인코딩 검사에서 거절당하는 길이 있었다.
1063
+ * 파일은 한 글자도 안 바뀌었는데 되돌리기 이력에는 그 턴이 남는다.
1064
+ * 그러면 turns() 가 그 턴을 세고, `/undo` 는 **같은 내용을 다시 써
1065
+ * 놓고** 「파일 1개를 되돌렸습니다」 를 찍는다. 정작 사람이 되돌리려던
1066
+ * 앞 턴의 진짜 변경은 그대로 남는다.
1067
+ *
1068
+ * 바꾸기전스냅샷 의 머리말이 「기록이 없으면 /undo 가 엉뚱한 턴을
1069
+ * 되돌린다」 를 적어 뒀다. 이건 그 거울상 — **기록만 있고 변경이 없는
1070
+ * 턴**이다. 아래로 내려서 거절 갈래를 다 지난 뒤에 뜬다.
1071
+ */
915
1072
  const existed = existsSync(abs);
916
1073
  // 덮어쓰기 전 내용. 바뀐 자리를 보여주려면 지금 떠 놔야 한다.
917
1074
  // 읽다 터지는 파일(바이너리 등)이면 그냥 없던 셈 친다 — 쓰는 것 자체는 막지 않는다.
@@ -947,6 +1104,16 @@ function 한파일쓰기(args, ctx) {
947
1104
  * 있는 자리에서는 잰 값이 이긴다. Edit 도 이미 그렇게 한다.
948
1105
  */
949
1106
  const 원래 = 읽음?.encoding ?? 'utf-8';
1107
+ /*
1108
+ * 잰 것이 **짐작인지**도 같이 든다.
1109
+ *
1110
+ * 인코딩은 바이트 무늬를 보고 점수를 매기는 것이라 CP949 와 CP932 는
1111
+ * 짧은 파일에서 자주 뒤집힌다. Read 는 그 확신도를 이미 「짐작」 으로
1112
+ * 적어 준다. 그런데 여기 Write 는 같은 값을 손에 쥐고도 안 봐서,
1113
+ * `· CP949` 를 사실처럼 찍고 파일 **전체**를 그 인코딩으로 다시 썼다.
1114
+ * 되돌려 쓰는 쪽이 읽는 쪽보다 위험한데 말은 더 단정했다.
1115
+ */
1116
+ const 짐작이다 = !!(읽음 && 읽음.sure === false);
950
1117
  const 만든것 = encode(args.content, 원래);
951
1118
  if (만든것.lost.length) {
952
1119
  return {
@@ -955,13 +1122,17 @@ function 한파일쓰기(args, ctx) {
955
1122
  + ` 그대로 쓰면 그 글자들이 뭉개집니다. 해당 글자를 빼거나, 파일을 UTF-8 로 바꿔도 되는지 사용자에게 물어보세요.`,
956
1123
  };
957
1124
  }
1125
+ ctx.history.snapshot(abs, 'Write');
958
1126
  writeFileSync(abs, 만든것.buf);
959
1127
  ctx.seen.add(abs);
960
1128
  const n = args.content.split('\n').length;
961
- const 표기 = 원래 !== 'utf-8' ? ` · ${encLabel(원래)}` : '';
1129
+ const 표기 = 원래 !== 'utf-8' ? ` · ${encLabel(원래)}${짐작이다 ? ' (짐작)' : ''}` : '';
962
1130
  return {
963
1131
  content: `${existed ? '덮어씀' : '새로 만듦'}: ${ctx.scope.show(abs)} (${n}줄${표기})`,
964
- summary: 이어(세말('lines', n), 원래 !== 'utf-8' ? encLabel(원래) : ''),
1132
+ summary: 이어(
1133
+ 세말('lines', n),
1134
+ 원래 === 'utf-8' ? '' : 짐작이다 ? 말('sum.encGuess', { 인코딩: encLabel(원래) }) : encLabel(원래),
1135
+ ),
965
1136
  changed: abs,
966
1137
  diff: 바뀐자리(이전, args.content),
967
1138
  };
@@ -1063,7 +1234,6 @@ function 한군데고치기(args, ctx) {
1063
1234
  return { error: `찾지 못했습니다.${hint}` };
1064
1235
  }
1065
1236
 
1066
- ctx.history.snapshot(abs, 'Edit');
1067
1237
  const next = applySpans(text, m.spans, (matched) =>
1068
1238
  m.tier === 'exact' ? args.new_string : reindent(args.new_string, matched, args.old_string));
1069
1239
 
@@ -1076,10 +1246,15 @@ function 한군데고치기(args, ctx) {
1076
1246
  + ` 그대로 쓰면 그 글자들이 뭉개집니다. 다른 표현을 쓰거나, 파일을 UTF-8 로 바꿔도 되는지 사용자에게 물어보세요.`,
1077
1247
  };
1078
1248
  }
1249
+ // 스냅샷은 **정말 쓰기 직전**에 뜬다 (한파일쓰기 의 같은 자리 머리말).
1250
+ // 위 인코딩 거절로 끝나는 길에서 뜨면, 아무것도 안 바꾼 턴이 이력에 남아
1251
+ // /undo 한 번을 통째로 먹는다.
1252
+ ctx.history.snapshot(abs, 'Edit');
1079
1253
  writeFileSync(abs, 만든것.buf);
1080
1254
 
1081
1255
  const n = m.spans.length;
1082
1256
  const how = m.tier === 'exact' ? '' : ` · ${TIER_LABELS[m.tier]}`;
1257
+ // 진짜 숫자를 그대로 내보낸다 — 아래 배열 갈래가 요약 글에서 되뽑지 않게.
1083
1258
  const 표기 = 읽음.encoding !== 'utf-8' ? ` · ${encLabel(읽음.encoding)}` : '';
1084
1259
  return {
1085
1260
  content: `고침: ${ctx.scope.show(abs)} (${n}군데${how}${표기})`,
@@ -1091,6 +1266,7 @@ function 한군데고치기(args, ctx) {
1091
1266
  ),
1092
1267
  changed: abs,
1093
1268
  tier: m.tier,
1269
+ 군데: n,
1094
1270
  diff: 바뀐자리(text, next),
1095
1271
  };
1096
1272
  }
@@ -1124,7 +1300,18 @@ function 여러군데고치기(목록, ctx) {
1124
1300
  path: r.changed,
1125
1301
  보인이름: ctx.scope.show(r.changed),
1126
1302
  ok: true,
1127
- 군데: Number(String(r.summary).match(/^(\d+)/)?.[1] ?? 1),
1303
+ /*
1304
+ * 사람이 읽을 요약 글에서 숫자를 **되뽑고** 있었다.
1305
+ *
1306
+ * 그 글은 세말() 이 만들고, 그 안에서 toLocaleString() 을 탄다.
1307
+ * 1,000군데를 넘으면 `1,234군데` 가 되고 /^(\d+)/ 는 `1` 만 집는다.
1308
+ * 디스크에서는 1,234군데가 바뀌었는데 화면과 모델은 `1군데` 를
1309
+ * 받았다. 파일 수 집계도 같이 틀어진다.
1310
+ *
1311
+ * tier·diff 는 객체로 잘 넘기면서 숫자만 글에서 뽑고 있었다.
1312
+ * 이제 한군데고치기() 가 m.spans.length 를 그대로 준다.
1313
+ */
1314
+ 군데: r.군데 ?? Number(String(r.summary).replace(/,/g, '').match(/^(\d+)/)?.[1] ?? 1),
1128
1315
  tier: r.tier,
1129
1316
  diff: r.diff,
1130
1317
  });
@@ -1203,6 +1390,47 @@ function 그림보기(abs, ctx) {
1203
1390
  };
1204
1391
  }
1205
1392
 
1393
+ /**
1394
+ * 찾기 시작할 자리가 **정말 있나.**
1395
+ *
1396
+ * ── 왜 필요한가 ────────────────────────────────────────────────────────
1397
+ *
1398
+ * Glob·Grep 은 `scope.resolve(path)` 만 하고 그 자리가 있는지를 안 봤다.
1399
+ * walk() 는 readdirSync 실패를 `catch { continue }` 로 삼키므로, 없는
1400
+ * 폴더·오타 난 경로·권한 없는 폴더가 전부 **빈 배열**이 됐다. 잘림도
1401
+ * 건너뜀도 0이라 꼬리말조차 안 붙는다.
1402
+ *
1403
+ * Glob 에 path:'srcs' 처럼 오타 난 폴더를 주면
1404
+ * → 찾은 파일 없음
1405
+ *
1406
+ * 한 군데도 안 찾아봤는데 「없음」 이다. 모델은 그걸 사실로 받아
1407
+ * 「이 프로젝트에는 X 가 없습니다」 로 답을 맺는다. 이 파일의 다른
1408
+ * 자리들은 「못 찾은 것과 안 본 것은 다르다」 를 세 번이나 적어 뒀는데,
1409
+ * 정작 **뿌리 경로가 없는 경우**만 아무도 안 봤다.
1410
+ *
1411
+ * rg 길도 못 막는다 — 없는 자리면 rg 가 죽고 빠르게찾기() 가 null 을
1412
+ * 돌려주며 그대로 이 아래 자바스크립트 길로 내려온다.
1413
+ */
1414
+ function 찾을자리없나(root, 준것, ctx, { 폴더여야 = false } = {}) {
1415
+ const 보인이름 = 준것 ?? ctx.scope.show(root);
1416
+ if (!existsSync(root)) {
1417
+ return {
1418
+ error: `그런 자리가 없습니다: ${보인이름}\n`
1419
+ + ' 한 글자도 안 찾아봤습니다 — 「그런 파일이 없다」 가 아닙니다.'
1420
+ + ' 경로를 다시 확인하거나, path 를 빼고 폴더 전체에서 찾으세요.',
1421
+ 끝났다: true,
1422
+ };
1423
+ }
1424
+ if (폴더여야) {
1425
+ try {
1426
+ if (!statSync(root).isDirectory()) {
1427
+ return { error: `폴더가 아니라 파일입니다: ${보인이름}. 안을 보려면 Read, 내용을 찾으려면 Grep 을 쓰세요.`, 끝났다: true };
1428
+ }
1429
+ } catch (err) { return { error: `${보인이름} 을 못 열었습니다 — ${err?.message ?? err}`, 끝났다: true }; }
1430
+ }
1431
+ return null;
1432
+ }
1433
+
1206
1434
  export const TOOLS = {
1207
1435
  Read: {
1208
1436
  schema: {
@@ -1422,6 +1650,25 @@ export const TOOLS = {
1422
1650
  const 못쓰는이유 = 내부살림(abs);
1423
1651
  if (못쓰는이유) return { error: 못쓰는이유 };
1424
1652
  if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
1653
+ /*
1654
+ * ── **없는 파일**은 바이너리 검사가 통째로 없다 ────────────────
1655
+ *
1656
+ * 바이너리인가() 는 existsSync 가 거짓이면 null 을 준다. 즉 아직
1657
+ * 없는 `.hwpx`·`.pdf`·`.png` 는 아무 검사도 안 거치고, 확장자만
1658
+ * 그것인 UTF-8 글 파일이 만들어진다. 화면에는 `새로 만듦:
1659
+ * 주간보고.hwpx (+40줄)` 이 뜨고, 한글에서는 안 열린다.
1660
+ *
1661
+ * Write 는 같은 자리에서 이 갈래를 다 막고, hwpx 만 제대로 만드는
1662
+ * 길(hwpx새로만들기)을 따로 낸다. Append 설명문은 「큰 파일은 이렇게
1663
+ * 나눠서 만든다」 고 시키고 있으니, 두 지시가 만나는 이 자리에
1664
+ * 검사가 없으면 안 된다.
1665
+ */
1666
+ if (isDocPath(abs)) return { error: 문서는못고침(args.file_path) };
1667
+ if (isPdfPath(abs)) return { error: pdf는못고침(args.file_path) };
1668
+ if (isFigPath(abs)) return { error: fig는못고침(args.file_path) };
1669
+ if (그림인가(abs)) {
1670
+ return { error: `${ctx.scope.show(abs)} 는 그림 파일 이름입니다 — 글을 이어 붙이면 열리지 않는 파일이 됩니다.`, 끝났다: true };
1671
+ }
1425
1672
  const 바이너리막기 = 바이너리인가(abs);
1426
1673
  if (바이너리막기) return { error: 바이너리막기 };
1427
1674
 
@@ -1439,9 +1686,33 @@ export const TOOLS = {
1439
1686
  // 원래 있던 파일이면 그 파일이 쓰던 인코딩 그대로 이어 붙인다.
1440
1687
  // 이어 붙이는 조각에는 앞머리 표식(BOM)이 들어가면 안 된다 — 파일 한가운데에
1441
1688
  // BOM 이 박히면 그 자리가 이상한 글자로 보인다. 그래서 표식 없는 이름으로 바꾼다.
1442
- const 원래 = existed ? (ctx.enc?.get(abs) ?? 재는인코딩(abs)) : 'utf-8';
1443
- // 것은 적어 둔다 Read 가 하는 것과 같은 자리다. 안 적어 두면 이 파일에
1444
- // 붙일 때마다 인코딩을 다시 재느라 파일을 통째로 또 읽는다.
1689
+ /*
1690
+ * ── 캐시를 **먼저** 보고 있었다 ────────────────────────────────
1691
+ *
1692
+ * Write 의 같은 자리 머리말이 이미 결론을 내려 뒀다 — 「캐시가 낡으면
1693
+ * 남이 이미 UTF-8 로 바꿔 둔 파일을 CP949 라고 우긴다. 재는 값이 있는
1694
+ * 자리에서는 잰 값이 이긴다」. Append 만 그 반대로 돌고 있었다.
1695
+ *
1696
+ * Read 로 CP949 로 읽는다 → 캐시에 CP949
1697
+ * Bash iconv 로 UTF-8 로 바꾼다 → 캐시는 그대로 CP949
1698
+ * Append 한 줄 → CP949 바이트를 UTF-8 파일 꼬리에
1699
+ *
1700
+ * 오류는 안 난다. 파일 뒤쪽만 깨지고, 결과에는 `이어 붙임 · CP949`
1701
+ * 가 사실처럼 뜬다.
1702
+ *
1703
+ * 그렇다고 붙일 때마다 통째로 다시 읽지도 않는다. 파일 크기가
1704
+ * 그대로면 남이 안 건드린 것이라 캐시를 그대로 쓴다 — 줄기억 이
1705
+ * 같은 잣대를 이미 쓰고 있다.
1706
+ */
1707
+ const 잰것캐시 = ctx.enc재기 ?? (ctx.enc재기 = new Map());
1708
+ const 지금크기 = existed ? 파일크기(abs) : -1;
1709
+ const 든것 = 잰것캐시.get(abs);
1710
+ const 원래 = !existed ? 'utf-8'
1711
+ : (든것 && 든것.바이트 === 지금크기) ? 든것.인코딩
1712
+ : 재는인코딩(abs);
1713
+ // 여기서 넣어 두면 **다음 번에 반드시 빗나간다** — 붙이고 나면 크기가
1714
+ // 달라지기 때문이다. 넣는 자리는 붙인 **뒤**다 (아래 참고).
1715
+ // Read·Write 가 보는 표에도 맞춰 둔다. 안 맞추면 두 표가 서로 다른 답을 낸다.
1445
1716
  ctx.enc?.set?.(abs, 원래);
1446
1717
  const 조각인코딩 = 원래 === 'utf-8-bom' ? 'utf-8' : 원래;
1447
1718
  const 만든것 = encode(args.content, 조각인코딩);
@@ -1472,6 +1743,9 @@ export const TOOLS = {
1472
1743
  mkdirSync(dirname(abs), { recursive: true });
1473
1744
  if (existed) appendFileSync(abs, 만든것.buf);
1474
1745
  else writeFileSync(abs, 만든것.buf);
1746
+ // 붙인 **뒤** 크기로 적어 둔다. 다음 Append 는 이 크기와 맞으면 그대로
1747
+ // 쓰고, 그 사이 남이 파일을 건드렸으면 크기가 어긋나 다시 잰다.
1748
+ 잰것캐시.set(abs, { 바이트: 파일크기(abs), 인코딩: 원래 });
1475
1749
  ctx.seen.add(abs);
1476
1750
 
1477
1751
  const 붙인줄 = args.content.split('\n').length - (args.content.endsWith('\n') ? 1 : 0);
@@ -1611,6 +1885,8 @@ export const TOOLS = {
1611
1885
  },
1612
1886
  async run(args, ctx) {
1613
1887
  const root = args.path ? ctx.scope.resolve(args.path) : ctx.scope.root;
1888
+ const 없나 = 찾을자리없나(root, args.path, ctx, { 폴더여야: true });
1889
+ if (없나) return 없나;
1614
1890
  const re = globToRegex(args.pattern);
1615
1891
  const 전부 = await walk(root, { signal: ctx.signal });
1616
1892
  // 훑다 말고 나왔으면 그렇다고 말한다. 조용히 적게 주면 「그런 파일이 없다」가 된다.
@@ -1668,7 +1944,9 @@ export const TOOLS = {
1668
1944
  catch (err) { return { error: `정규식이 잘못됐습니다: ${err.message}` }; }
1669
1945
 
1670
1946
  const root = args.path ? ctx.scope.resolve(args.path) : ctx.scope.root;
1671
- const isFile = existsSync(root) && statSync(root).isFile();
1947
+ const 없나 = 찾을자리없나(root, args.path, ctx);
1948
+ if (없나) return 없나;
1949
+ const isFile = statSync(root).isFile();
1672
1950
 
1673
1951
  const mode = args.output_mode ?? 'files_with_matches';
1674
1952
  const limit = args.head_limit ?? 찾을줄수(ctx.모델컨텍스트);
@@ -1715,8 +1993,24 @@ export const TOOLS = {
1715
1993
  * 숫자를 안 알려준다. 그러면 **모르는 것을 안 적는다** — 지어낸 숫자를
1716
1994
  * 적느니 안 셌다고 말하는 편이 낫다.
1717
1995
  */
1996
+ /*
1997
+ * ── 여기서도 **잘랐으면 잘랐다고 한다** ─────────────────────
1998
+ *
1999
+ * 위 `빠른것.잘림` 은 rg 자체의 상한에 걸렸을 때만 참이다. 그
2000
+ * 안쪽에서 우리가 limit 으로 다시 자르는 것은 아무 데도 안 적혔다.
2001
+ * 8k 모델이면 찾을줄수가 80이라, 300개 파일이 걸려도 모델은 80줄을
2002
+ * 받고 **그게 전부라고 믿는다.** 요약에만 `300개 파일` 이 뜬다.
2003
+ * 「전부 고쳤습니다」 가 여기서 나온다.
2004
+ *
2005
+ * 자바스크립트 길과 Glob 은 이 경우를 이미 말해 준다. rg 길만
2006
+ * 그 규칙 밖에 있었다.
2007
+ */
2008
+ const 파일잘림 = mode !== 'content' && hitFiles.length > limit;
2009
+ const 줄잘림 = mode === 'content' && total > lines.length;
1718
2010
  const 꼬리2 = [
1719
2011
  빠른것.잘림 ? '(결과가 많아 앞부분만 봤습니다 — 더 있을 수 있습니다)' : '',
2012
+ 파일잘림 ? `(맞은 파일 ${hitFiles.length}개 중 앞 ${limit}개만 적었습니다)` : '',
2013
+ 줄잘림 ? `(맞은 줄 ${total}개 중 앞 ${lines.length}개만 적었습니다)` : '',
1720
2014
  엔진말(빠른것.엔진),
1721
2015
  '(.gitignore·.deelignore 는 지켰습니다. 건너뛴 수는 안 셌습니다)',
1722
2016
  ].filter(Boolean).join(' ');
@@ -1816,6 +2110,11 @@ export const TOOLS = {
1816
2110
  const 꼬리 = [
1817
2111
  멈춤 === '중단' ? '(중단하셔서 여기까지만 찾았습니다)' : '',
1818
2112
  멈춤 === '상한' ? `(${limit}개에서 멈췄습니다 — 더 있을 수 있습니다)` : '',
2113
+ // content 갈래는 `lines.length < limit` 으로 조용히 담기를 멈춘다.
2114
+ // 그 자리도 잘린 것이다 — clip() 의 「N자 잘림」 은 글자 수 상한일 뿐,
2115
+ // 줄 수로 잘린 것은 못 알린다.
2116
+ mode === 'content' && total > lines.length
2117
+ ? `(맞은 줄 ${total}개 중 앞 ${lines.length}개만 적었습니다)` : '',
1819
2118
  건너뛴것 ? `(글이 아니거나 너무 큰 파일 ${건너뛴것}개는 건너뛰었습니다)` : '',
1820
2119
  안본것,
1821
2120
  ].filter(Boolean).join(' ');
@@ -1908,7 +2207,17 @@ export const TOOLS = {
1908
2207
  * 여기서 안 보인다. 그래서 '전부 되돌아간다' 고 말하지 않는다.
1909
2208
  * 그래도 손으로 옮기고 지우는 흔한 자리는 이걸로 덮인다.
1910
2209
  */
1911
- const 뜬것 = 바꾸기전스냅샷(cmd, ctx);
2210
+ const 떠본것 = 바꾸기전스냅샷(cmd, ctx);
2211
+ const 뜬것 = 떠본것.뜬것;
2212
+ /*
2213
+ * 못 뜬 것과 상한에 걸린 것은 **결과에 실어 보낸다.**
2214
+ *
2215
+ * 화면이 「떠 뒀습니다 — /undo 로 되돌아갑니다」 를 찍는 자리가
2216
+ * 하나뿐이라(repl.js), 여기서 안 갈라 주면 그 한 줄이 되돌릴 수
2217
+ * 없는 파일까지 되돌릴 수 있다고 말하게 된다.
2218
+ */
2219
+ const 못뜬것 = 떠본것.못뜬것;
2220
+ const 스냅샷상한걸림 = 떠본것.상한걸림;
1912
2221
 
1913
2222
  // 끝나지 않는 명령은 뒤에서 띄운다. 여기서 기다리면 그 턴이 통째로 멈춘다.
1914
2223
  if (args.background === true) {
@@ -1940,6 +2249,8 @@ export const TOOLS = {
1940
2249
  일감번호: r.번호,
1941
2250
  뒤에서: true,
1942
2251
  되돌릴것: 뜬것,
2252
+ 못뜬것,
2253
+ 스냅샷상한걸림,
1943
2254
  };
1944
2255
  }
1945
2256
 
@@ -2018,9 +2329,25 @@ export const TOOLS = {
2018
2329
  const stderr = 풀기(stderrBuf);
2019
2330
  const out = [stdout, stderr].filter(Boolean).join('\n').trim();
2020
2331
 
2021
- if (끊겼나) return done({ error: '사용자가 중단했습니다', content: clip(out, 실을만큼(ctx)) });
2332
+ /*
2333
+ * 끊겼든 시간이 넘었든 **떠 둔 것은 그대로 말해 준다.**
2334
+ *
2335
+ * `rm -r dist && npm run build` 를 돌리다 끊으면 dist 는 이미
2336
+ * 지워져 있다. 여기서 되돌릴것 을 빼면 화면에도 모델에도 그
2337
+ * 사실이 한 글자도 안 남는다 — 되돌릴 수 있는데 되돌릴 수
2338
+ * 있다는 것을 아무도 모르는 상태가 제일 나쁘다.
2339
+ */
2340
+ if (끊겼나) {
2341
+ return done({
2342
+ error: '사용자가 중단했습니다', content: clip(out, 실을만큼(ctx)),
2343
+ 되돌릴것: 뜬것, 못뜬것, 스냅샷상한걸림,
2344
+ });
2345
+ }
2022
2346
  if (시간초과 || (err && err.killed)) {
2023
- return done({ error: `시간 초과로 중단됨 (${제한}ms)`, content: clip(out, 실을만큼(ctx)) });
2347
+ return done({
2348
+ error: `시간 초과로 중단됨 (${제한}ms)`, content: clip(out, 실을만큼(ctx)),
2349
+ 되돌릴것: 뜬것, 못뜬것, 스냅샷상한걸림,
2350
+ });
2024
2351
  }
2025
2352
 
2026
2353
  /*
@@ -2090,6 +2417,10 @@ export const TOOLS = {
2090
2417
  signal: 시그널,
2091
2418
  // 무엇을 떠 뒀는지 화면이 알아야 '되돌릴 수 있다' 를 사실대로 적는다.
2092
2419
  되돌릴것: 뜬것,
2420
+ // 그리고 **못 뜬 것도** 알아야 한다. 이것만 빠지면 화면은
2421
+ // 되돌릴 수 없는 파일까지 되돌아간다고 말한다.
2422
+ 못뜬것,
2423
+ 스냅샷상한걸림,
2093
2424
  });
2094
2425
  });
2095
2426
 
@@ -2174,8 +2505,21 @@ export const TOOLS = {
2174
2505
  // Ctrl+C 는 짧게 — 사람이 지금 돌려받으려고 누른 것이다.
2175
2506
  죽이기({ 더줄까: 1200 });
2176
2507
  clearTimeout(뒷북);
2177
- // 콜백을 기다리지 않는다. 손자가 파이프를 물고 있으면 안 불릴 수도 있다.
2178
- done({ error: '사용자가 중단했습니다' });
2508
+ /*
2509
+ * ── 곧장 끝맺으면 **나온 말이 통째로 버려진다** ────────────────
2510
+ *
2511
+ * 여태 여기서 바로 done() 을 불렀다. done 은 한 번만 먹히므로,
2512
+ * 그 아래 콜백의 `if (끊겼나) … content: clip(out …)` 은 **닿을
2513
+ * 수 없는 줄**이었다. 끊긴 명령이 죽기 직전에 뱉은 몇 줄 —
2514
+ * 대개 그게 제일 중요한 줄이다 — 이 매번 사라졌다.
2515
+ * 바로 위 시간 초과 갈래는 이미 그 몇 줄을 받으려고 기다린다.
2516
+ *
2517
+ * 그렇다고 마냥 기다리지도 않는다. 손자가 파이프를 물고 있으면
2518
+ * 콜백이 영영 안 올 수 있어서, 짧은 그물을 하나 두고 그때까지만
2519
+ * 준다. 사람이 지금 돌려받으려고 누른 것이라 길게 잡지 않는다.
2520
+ */
2521
+ const 그물 = setTimeout(() => done({ error: '사용자가 중단했습니다', 되돌릴것: 뜬것, 못뜬것, 스냅샷상한걸림 }), 400);
2522
+ 그물.unref?.();
2179
2523
  };
2180
2524
  if (ctx.signal?.aborted) 끊기();
2181
2525
  else ctx.signal?.addEventListener?.('abort', 끊기, { once: true });
@@ -2412,9 +2756,24 @@ export const TOOLS = {
2412
2756
  const 날 = h.언제 instanceof Date ? h.언제.toISOString().slice(0, 16).replace('T', ' ') : '';
2413
2757
  return `[${h.세션} · ${날} · ${h.누구}] ${h.토막}`;
2414
2758
  });
2759
+ /*
2760
+ * ── **찾아 놓고 아무한테도 안 주고 있었다** ────────────────────
2761
+ *
2762
+ * 못 찾은 갈래는 바로 위에서 이미 고쳤다 — 「content 를 반드시
2763
+ * 채운다. 대화에 실리는 것은 content 다」. 그런데 **찾은** 갈래는
2764
+ * 그대로였다. 맞은 것을 `text` 에 담아 돌려주는데, loop.js 의
2765
+ * 실을글() 이 보는 것은 content → summary 차례이고 `text`·`hits`
2766
+ * 를 읽는 자리는 저장소 어디에도 없다.
2767
+ *
2768
+ * 그래서 모델이 받은 것은 요약 한 줄 — 「지난 대화 12건 중 8건」.
2769
+ * **여덟 건의 내용은 한 글자도 안 갔다.** 못 찾은 것보다 나쁘다.
2770
+ * 찾았다고 들었는데 내용이 없으면 지어내기 딱 좋은 자리가 된다.
2771
+ */
2772
+ const 찾은말 = 말('sum.recall', { 전체: r.전체맞음, 맞음: r.맞은것.length })
2773
+ + (r.예산초과 ? ` (${r.전체파일}개 중 ${r.본파일}개만 뒤짐)` : '');
2415
2774
  return {
2416
- summary: ('sum.recall', { 전체: r.전체맞음, 맞음: r.맞은것.length })
2417
- + (r.예산초과 ? ` (${r.전체파일}개 중 ${r.본파일}개만 뒤짐)` : ''),
2775
+ content: `${찾은말}\n${줄들.join('\n')}`,
2776
+ summary: 찾은말,
2418
2777
  hits: r.맞은것.map((h) => ({ session: h.세션, when: h.언제, who: h.누구, text: h.토막 })),
2419
2778
  text: 줄들.join('\n'),
2420
2779
  searched: r.본파일,
@@ -2451,7 +2810,22 @@ export const TOOLS = {
2451
2810
  async run(args, ctx) {
2452
2811
  const { 더하기 } = await import('../agent/memory.js');
2453
2812
  const r = 더하기(ctx.scope.root, args.text);
2454
- if (!r.ok) return { summary: r.why, remembered: false };
2813
+ /*
2814
+ * 안 적힌 것을 **안 적혔다고** 말한다.
2815
+ *
2816
+ * 여태 `{ summary, remembered:false }` 만 돌려줬다. error 도 content 도
2817
+ * 없으니 화면에는 여느 성공과 똑같이 `✓ Remember` 가 뜬다 — 한 글자도
2818
+ * 안 적혔는데 적힌 것처럼 보인다. 기억은 **다음 대화에서야** 없는 것을
2819
+ * 알게 되는 종류라, 이 자리에서 조용하면 아무도 못 알아챈다.
2820
+ *
2821
+ * 둘을 가른다. 빈 글은 부른 쪽이 잘못한 것이라 오류다. 이미 있는
2822
+ * 말은 잘못이 아니지만 **새로 적힌 것도 아니라서**, 적힌 것처럼
2823
+ * 보이지 않게 그 사실을 글로 돌려준다.
2824
+ */
2825
+ if (!r.ok) {
2826
+ if (!String(args.text ?? '').trim()) return { error: r.why, 끝났다: true, remembered: false };
2827
+ return { content: `안 적었습니다: ${r.why}`, summary: r.why, remembered: false };
2828
+ }
2455
2829
  return {
2456
2830
  summary: 이어(말('sum.remembered', { n: r.줄수 }), r.넘침 ? 말('sum.rememberFull') : ''),
2457
2831
  remembered: true,
@@ -2769,7 +3143,17 @@ export async function runTool(name, args, ctx) {
2769
3143
  // 섞이면 "이 폴더 밖은 못 건드린다" 는 말이 거짓이 된다.
2770
3144
  if (name.startsWith('mcp__')) return await runMcpTool(name, args, ctx);
2771
3145
 
2772
- const t = TOOLS[name];
3146
+ /*
3147
+ * 있는 도구인지는 hasOwn 으로만 묻는다.
3148
+ *
3149
+ * 이름은 **모델이 지어서 보낸 것**이다. `constructor` · `toString` 으로
3150
+ * 오면 `TOOLS[name]` 이 물려받은 함수라 참이 되어 이 관문을 통과하고,
3151
+ * 조금 아래 `t.run(...)` 에서 터진다. 화면에 남는 말은 「모르는 도구:
3152
+ * toString」 이 아니라 `t.run is not a function` 이다 — 사람도 모델도
3153
+ * 무엇이 잘못됐는지 알 수 없는 글이고, 모델은 고칠 실마리가 없으니
3154
+ * 같은 이름으로 또 부른다.
3155
+ */
3156
+ const t = Object.hasOwn(TOOLS, name) ? TOOLS[name] : null;
2773
3157
  if (!t) return { error: `모르는 도구: ${name}` };
2774
3158
 
2775
3159
  /*
@@ -2816,17 +3200,40 @@ export async function runTool(name, args, ctx) {
2816
3200
  * 바뀐것들·여럿 이 있으면 여러 개를 바꾼 것이다.
2817
3201
  */
2818
3202
  if (ctx.signal?.aborted) {
3203
+ /*
3204
+ * Bash 는 `changed` 를 **절대 안 낸다.** 파일을 바꾸는 도구 중
3205
+ * 유일하게 그렇다 — 무엇이 바뀌는지 명령줄만 보고는 모르기
3206
+ * 때문이다. 대신 떠 둔 목록(되돌릴것)을 낸다. 그것도 「이미
3207
+ * 손댔다」 는 증거다.
3208
+ *
3209
+ * 이 줄이 없을 때 `rm -r dist && npm run build` 를 돌리다 ESC 를
3210
+ * 누르면, dist 는 지워졌는데 화면도 모델도 「중단했습니다」 한 줄만
3211
+ * 받았다. 되돌릴 수 있다는 사실도 같이 사라졌다.
3212
+ */
2819
3213
  const 바꿔놨나 = !!(r.changed
2820
3214
  || r.바뀐것들?.length
3215
+ || r.되돌릴것?.length
2821
3216
  || (Array.isArray(r.여럿) && r.여럿.some((x) => x?.ok && x.path)));
2822
- if (!바꿔놨나 || r.error) {
3217
+ /*
3218
+ * `|| r.error` 로 같이 걷어내고 있었다. 그런데 끊긴 Bash 의 결과는
3219
+ * **언제나** error 를 달고 온다('사용자가 중단했습니다'). 그래서 위
3220
+ * 판정이 참이어도 이 줄에서 다시 버려졌다. 오류는 오류대로 남기고,
3221
+ * 이미 손댄 것은 손댄 대로 넘긴다 — 둘은 같이 참일 수 있다.
3222
+ */
3223
+ if (!바꿔놨나) {
2823
3224
  return { error: '중단했습니다.', 끝났다: true, 중단됨: true };
2824
3225
  }
2825
3226
  return { ...r, 중단됨: true, 중단전에끝남: true };
2826
3227
  }
2827
3228
  return await 고친뒤진단(name, r, ctx);
2828
3229
  } catch (err) {
2829
- const r = { error: err.message };
3230
+ /*
3231
+ * `err.message` 만 쓰면 Error 가 아닌 것이 올라올 때 undefined 가
3232
+ * 된다. 그러면 부르는 쪽의 `if (result.error)` 가 거짓이 되어
3233
+ * **실패가 성공으로 세어진다.** 같은 파일의 형제 자리들과 loop.js 는
3234
+ * 이미 String(err?.message ?? err) 를 쓴다. 여기만 달랐다.
3235
+ */
3236
+ const r = { error: String(err?.message ?? err) };
2830
3237
  ctx.audit.tool(name, args, r);
2831
3238
  return r;
2832
3239
  }