deel-local-cli 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.ko.md +34 -7
  2. package/README.md +35 -7
  3. package/bin/deel.js +34 -1
  4. package/package.json +1 -1
  5. package/src/acp/serve.js +83 -17
  6. package/src/agent/commit.js +2 -2
  7. package/src/agent/compact.js +4 -4
  8. package/src/agent/donecheck.js +97 -0
  9. package/src/agent/grade.js +1 -1
  10. package/src/agent/loop.js +176 -13
  11. package/src/agent/memory.js +1 -1
  12. package/src/agent/models.js +1 -1
  13. package/src/agent/modes.js +3 -0
  14. package/src/agent/outschema.js +1 -1
  15. package/src/agent/pins.js +7 -2
  16. package/src/agent/project.js +1 -1
  17. package/src/agent/recall.js +1 -1
  18. package/src/agent/session.js +15 -0
  19. package/src/agent/store.js +13 -26
  20. package/src/backend/ctxsize.js +2 -2
  21. package/src/backend/http.js +1 -1
  22. package/src/backend/learn.js +27 -1
  23. package/src/backend/mcp.js +97 -16
  24. package/src/backend/proxy.js +1 -1
  25. package/src/backend/quota.js +2 -2
  26. package/src/commands.js +24 -2
  27. package/src/completion.js +16 -3
  28. package/src/config.js +33 -11
  29. package/src/configexplain.js +1 -1
  30. package/src/doctor.js +11 -1
  31. package/src/eval/run.js +432 -0
  32. package/src/eval/starter.js +95 -0
  33. package/src/i18n/en.js +20 -0
  34. package/src/i18n/ja.js +20 -0
  35. package/src/i18n/ko.js +20 -0
  36. package/src/i18n/zh.js +20 -0
  37. package/src/jsonl.js +42 -0
  38. package/src/lsp/client.js +6 -46
  39. package/src/oneshot.js +85 -3
  40. package/src/pack/selfpack.js +2 -2
  41. package/src/pack/zip.js +18 -1
  42. package/src/plugins/manage.js +81 -14
  43. package/src/reap.js +70 -0
  44. package/src/repl.js +85 -11
  45. package/src/report.js +1 -1
  46. package/src/safety/audit.js +4 -19
  47. package/src/safety/authcmd.js +1 -1
  48. package/src/safety/guard.js +56 -6
  49. package/src/safety/hooks.js +1 -1
  50. package/src/safety/keystore.js +18 -3
  51. package/src/safety/policy.js +38 -0
  52. package/src/safety/shellenv.js +1 -1
  53. package/src/safety/undo.js +44 -20
  54. package/src/skills/discover.js +1 -1
  55. package/src/tools/docs.js +3 -3
  56. package/src/tools/edit-match.js +1 -1
  57. package/src/tools/encoding.js +1 -1
  58. package/src/tools/excel-com.js +56 -30
  59. package/src/tools/fastgrep.js +1 -1
  60. package/src/tools/fsutil.js +8 -4
  61. package/src/tools/ignore.js +21 -1
  62. package/src/tools/index.js +122 -11
  63. package/src/tools/jobs.js +23 -4
  64. package/src/tools/shell.js +27 -0
  65. package/src/tools/todo.js +1 -1
  66. package/src/tools/webfetch.js +1 -1
  67. package/src/tools/xlsx.js +4 -4
  68. package/src/ui/approve.js +57 -3
  69. package/src/ui/banner.js +1 -1
  70. package/src/ui/inputbox.js +1 -1
  71. package/src/ui/md.js +1 -1
  72. package/src/ui/motion.js +1 -1
package/src/tools/docs.js CHANGED
@@ -84,7 +84,7 @@ const 서명들 = [
84
84
  ];
85
85
 
86
86
  /** 앞 몇 바이트로 정체를 짚는다. 모르면 null. */
87
- export function 속내용(buf) {
87
+ function 속내용(buf) {
88
88
  if (!Buffer.isBuffer(buf) || buf.length < 2) return null;
89
89
  for (const s of 서명들) {
90
90
  if (s.바이트.every((b, i) => buf[i] === b)) return s.이름;
@@ -103,7 +103,7 @@ export function 속내용(buf) {
103
103
  * @param {string} 갈래 겉으로 본 갈래 (pptx · docx · hwpx)
104
104
  * @param {string|null} 속 속내용() 이 짚은 것
105
105
  */
106
- export function 겉속다름말(갈래, 속) {
106
+ function 겉속다름말(갈래, 속) {
107
107
  const 머리 = `겉은 ${갈래} 인데 속이 다릅니다`;
108
108
  if (!속) {
109
109
  return `${머리} — 무엇인지 알아보지 못했습니다 (zip 꾸러미가 아닙니다).\n`
@@ -182,7 +182,7 @@ const 끝이름 = (name) => {
182
182
  * (tools/doc2md.js) — 마크다운 표는 칸이 몇 개인지 알아야 그릴 수 있고,
183
183
  * 이어 붙인 한 줄에서 그걸 되짚으면 글에 든 | 하나에 표가 어긋난다.
184
184
  */
185
- export function 문단뽑기(xml, 갈래, { 표를따로 = false } = {}) {
185
+ function 문단뽑기(xml, 갈래, { 표를따로 = false } = {}) {
186
186
  const 표기 = 이름표[갈래];
187
187
  const 문단들 = [];
188
188
 
@@ -224,7 +224,7 @@ export function findMatch(text, needle, { replaceAll = false } = {}) {
224
224
  }
225
225
 
226
226
  // 못 찾았을 때, 모델이 스스로 고칠 수 있게 "가장 비슷한 줄"을 알려준다.
227
- export function nearest(text, needle) {
227
+ function nearest(text, needle) {
228
228
  const key = String(needle).split(/\r?\n/).find((l) => l.trim()) ?? '';
229
229
  const probe = key.trim().replace(/\s+/g, ' ');
230
230
  if (probe.length < 4) return null;
@@ -581,7 +581,7 @@ export function detect(buf, { fallback = null, system = null, 잘림 = false } =
581
581
 
582
582
  // 이 컴퓨터가 쓰는 옛 인코딩. 콘솔 코드페이지를 물어봐서 정한다.
583
583
  let _sys = null;
584
- export function systemLegacy() {
584
+ function systemLegacy() {
585
585
  if (_sys) return _sys;
586
586
  const cp = consoleCodepage();
587
587
  _sys = LEGACY.find((x) => x.cp === cp)?.id ?? 'windows-1252';
@@ -53,6 +53,10 @@ $dst = $env:DEEL_XL_OUT
53
53
  # 그러면 맞는 암호를 넣은 사람이 「암호가 맞지 않습니다」 를 세 번 받고 끝난다. 나가는 쪽은
54
54
  # 이미 decode() 로 콘솔 인코딩을 풀고 있는데(아래 받은글), 들어오는 쪽만 빠져 있었다.
55
55
  try { [Console]::InputEncoding = New-Object System.Text.UTF8Encoding $false } catch { }
56
+ # 나가는 쪽도 UTF-8 로 (2.0.2 · XL3). 콘솔 코드페이지로 두면 그 판에 없는 글자(일본어 엑셀의 「パスワード」,
57
+ # 중국어 엑셀의 「密码」, 한글 판에 없는 시트 이름)가 「?」 가 되고, 받는 쪽 decode() 가 인코딩을 잘못 짚어
58
+ # 한 줄이 통째로 딴 글자가 됐다 — 그러면 암호를 틀렸다는 말도 못 알아본다.
59
+ try { [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false } catch { }
56
60
  $pw = [Console]::In.ReadLine()
57
61
  if ($null -eq $pw) { $pw = '' }
58
62
 
@@ -89,8 +93,8 @@ try {
89
93
  catch {
90
94
  $m = $_.Exception.Message
91
95
  # 작은따옴표는 PowerShell 에서 이스케이프를 안 푼다. 탭을 넣으려면 큰따옴표여야 한다.
92
- if ($m -match 'password|암호|protected') { Write-Output "ERR\`tPASSWORD" }
93
- elseif ($m -eq 'BUSY') { Write-Output "ERR\`tBUSY" }
96
+ # 「암호가 틀렸다」 는 말은 이 PC 언어로 온다 — 가르는 일은 JS 쪽 암호탓인가 가 한다 (2.0.2 · XL3).
97
+ if ($m -eq 'BUSY') { Write-Output "ERR\`tBUSY" }
94
98
  elseif ($m -eq 'OPENFAIL') { Write-Output "ERR\`tPASSWORD" }
95
99
  else { Write-Output ("ERR\`tOTHER\`t" + $m) }
96
100
  }
@@ -191,6 +195,53 @@ export async function excelToTables(경로, { password = '', timeout = 90000 } =
191
195
  }
192
196
  }
193
197
 
198
+ /**
199
+ * 엑셀이 「암호가 틀렸다」 고 한 말인가 (2.0.2 · XL3).
200
+ *
201
+ * 그 말은 **이 컴퓨터 언어로** 온다. 여기가 파워셸 안의 `'password|암호|protected'` 였는데,
202
+ * 일본어 엑셀은 「パスワード」, 중국어는 「密码」, 독일어는 「Kennwort」 라고 말한다 — 그러면 암호를
203
+ * 틀린 사람이 「암호가 맞지 않습니다」 대신 「엑셀이 열지 못했습니다: …」 를 받고, 모델은 암호를
204
+ * 다시 물을 줄 모른다. 판정은 이쪽에서 한 번만 한다 — 엑셀 없는 판에서도 잴 수 있게.
205
+ */
206
+ const 암호말 = /password|protected|암호|パスワード|密码|密碼|kennwort|mot de passe|contraseña|senha|palavra-passe|пароль|wachtwoord|hasło|parola|lösenord|adgangskode|passord|salasana|jelszó|heslo|mật khẩu/i;
207
+ export function 암호탓인가(말) {
208
+ return 암호말.test(String(말 ?? ''));
209
+ }
210
+
211
+ /**
212
+ * 파워셸이 뱉은 글(이미 푼 것)을 답으로 (2.0.2 · XL3).
213
+ *
214
+ * 돌리기 의 close 안에 있던 것을 떼어 냈다. 그 자리는 엑셀이 깔린 PC 에서만 닿아서,
215
+ * 여기 갈래가 틀려도 잴 길이 없었다.
216
+ */
217
+ export function 엑셀답읽기(out, err = '') {
218
+ const 줄들 = String(out ?? '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
219
+ const 오류 = 줄들.find((l) => l.startsWith('ERR\t'));
220
+ if (오류) {
221
+ const [, 갈래, 말] = 오류.split('\t');
222
+ if (갈래 === 'PASSWORD' || 암호탓인가(말)) return { ok: false, reason: 'password', message: '암호가 맞지 않거나, 이 파일에는 암호가 필요합니다' };
223
+ if (갈래 === 'BUSY') return { ok: false, reason: 'busy', message: '엑셀이 계속 바쁘다고 합니다 — 열려 있는 엑셀 창을 닫고 다시 해보세요' };
224
+ // COM 클래스를 못 찾는다 = 이 컴퓨터에 엑셀이 없다.
225
+ // 이걸 그대로 내보내면 사용자는 알아볼 수 없는 오류 코드만 받는다.
226
+ if (없는엑셀(말)) {
227
+ return { ok: false, reason: 'no-excel', message: '이 컴퓨터에 엑셀이 설치되어 있지 않습니다 — 암호가 걸린 파일과 옛 .xls 는 엑셀이 있어야 읽을 수 있습니다' };
228
+ }
229
+ return { ok: false, reason: 'other', message: `엑셀이 열지 못했습니다: ${말 ?? ''}`.trim() };
230
+ }
231
+ if (!줄들.includes('OK')) {
232
+ const 첫줄 = String(err ?? '').split('\n')[0] ?? '';
233
+ if (없는엑셀(err)) {
234
+ return { ok: false, reason: 'no-excel', message: '이 컴퓨터에 엑셀이 설치되어 있지 않습니다' };
235
+ }
236
+ return { ok: false, reason: 'other', message: `엑셀이 끝내지 못했습니다${첫줄 ? ` — ${첫줄}` : ''}` };
237
+ }
238
+ const 시트들 = 줄들.filter((l) => l.startsWith('SHEET\t')).map((l) => {
239
+ const [, i, ...name] = l.split('\t');
240
+ return { i: Number(i), name: name.join('\t') || `시트${i}` };
241
+ });
242
+ return { ok: true, 시트들 };
243
+ }
244
+
194
245
  function 돌리기(경로, 밖, password, timeout) {
195
246
  return new Promise((done) => {
196
247
  // 스크립트는 -EncodedCommand 로 넘긴다. 임시 .ps1 파일을 안 만들려는 것이다 —
@@ -202,7 +253,8 @@ function 돌리기(경로, 밖, password, timeout) {
202
253
  env: { ...process.env, DEEL_XL_IN: 경로, DEEL_XL_OUT: 밖 },
203
254
  });
204
255
 
205
- // PowerShell 은 UTF-8 이 아니라 이 컴퓨터 콘솔 인코딩으로 뱉는다.
256
+ // PowerShell 은 UTF-8 이 아니라 이 컴퓨터 콘솔 인코딩으로 뱉는다(스크립트가 UTF-8 로 돌려
257
+ // 놓지만, 그게 안 먹는 판도 있다).
206
258
  // utf8 이라고 하고 받으면 오류 메시지가 통째로 깨져서, 무엇이 잘못됐는지
207
259
  // 알아볼 수 없는 글자가 사용자에게 그대로 간다. 바이트로 모아 뒤에 푼다.
208
260
  const 밖조각 = [];
@@ -232,33 +284,7 @@ function 돌리기(경로, 밖, password, timeout) {
232
284
  if (끝남) return;
233
285
  끝남 = true;
234
286
  clearTimeout(시계);
235
- const out = 풀기(밖조각);
236
- const err = 풀기(오류조각);
237
- const 줄들 = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
238
- const 오류 = 줄들.find((l) => l.startsWith('ERR\t'));
239
- if (오류) {
240
- const [, 갈래, 말] = 오류.split('\t');
241
- if (갈래 === 'PASSWORD') return done({ ok: false, reason: 'password', message: '암호가 맞지 않거나, 이 파일에는 암호가 필요합니다' });
242
- if (갈래 === 'BUSY') return done({ ok: false, reason: 'busy', message: '엑셀이 계속 바쁘다고 합니다 — 열려 있는 엑셀 창을 닫고 다시 해보세요' });
243
- // COM 클래스를 못 찾는다 = 이 컴퓨터에 엑셀이 없다.
244
- // 이걸 그대로 내보내면 사용자는 알아볼 수 없는 오류 코드만 받는다.
245
- if (없는엑셀(말)) {
246
- return done({ ok: false, reason: 'no-excel', message: '이 컴퓨터에 엑셀이 설치되어 있지 않습니다 — 암호가 걸린 파일과 옛 .xls 는 엑셀이 있어야 읽을 수 있습니다' });
247
- }
248
- return done({ ok: false, reason: 'other', message: `엑셀이 열지 못했습니다: ${말 ?? ''}`.trim() });
249
- }
250
- if (!줄들.includes('OK')) {
251
- const 첫줄 = err.split('\n')[0] ?? '';
252
- if (없는엑셀(err)) {
253
- return done({ ok: false, reason: 'no-excel', message: '이 컴퓨터에 엑셀이 설치되어 있지 않습니다' });
254
- }
255
- return done({ ok: false, reason: 'other', message: `엑셀이 끝내지 못했습니다${첫줄 ? ` — ${첫줄}` : ''}` });
256
- }
257
- const 시트들 = 줄들.filter((l) => l.startsWith('SHEET\t')).map((l) => {
258
- const [, i, ...name] = l.split('\t');
259
- return { i: Number(i), name: name.join('\t') || `시트${i}` };
260
- });
261
- done({ ok: true, 시트들 });
287
+ done(엑셀답읽기(풀기(밖조각), 풀기(오류조각)));
262
288
  });
263
289
 
264
290
  // 암호는 여기로만 나간다. 줄바꿈까지 보내고 바로 닫는다.
@@ -235,7 +235,7 @@ export function rg가못준까닭(r) {
235
235
  */
236
236
  const 이음 = '[\\x80-\\xBF]';
237
237
  const 이음아님 = '(?:[^\\x80-\\xBF]|$)';
238
- export const 못푸는바이트무늬 = '(?-u:'
238
+ const 못푸는바이트무늬 = '(?-u:'
239
239
  + [
240
240
  '\\x00',
241
241
  '[\\xC0\\xC1\\xF5-\\xFF]',
@@ -109,10 +109,14 @@ export function 내부살림(abs) {
109
109
  * Write .deel/config.json::$DATA **설정 본체를 덮어썼다**
110
110
  * cat .deel/audit.jsonl::$DATA checkPaths 통과
111
111
  *
112
- * 드라이브 조각(`C:`)만 빼고 첫 `:` 뒤를 버린다. 유닉스에서 `:` 는 그냥 글자라 막는 쪽으로
113
- * 넓어질 뿐이다(`history:x` 라는 살림 이름을 따로 쓸 까닭이 없다).
112
+ * 드라이브 조각(`C:`)만 빼고 첫 `:` 뒤를 버린다.
113
+ *
114
+ * **윈도에서만** 그렇게 한다 (2.0.2 · 480). 유닉스·맥에서 `:` 는 그냥 글자이고 스트림이 없다 —
115
+ * `.deel/config.json:v2` 는 설정 파일이 아니라 딴 파일이다. 모든 판에서 벗겼더니 리눅스에서 그런
116
+ * 이름을 살림으로 잘못 알고 막았다(안전 쪽이지만 까닭 없는 막힘이다).
114
117
  */
115
- const 조각 = 편.split('/').map((x, 몇째) => (몇째 === 0 && /^[A-Za-z]:$/.test(x) ? x : x.replace(/:.*$/s, '')));
118
+ const 스트림꼴 = process.platform === 'win32';
119
+ const 조각 = 편.split('/').map((x, 몇째) => (!스트림꼴 || (몇째 === 0 && /^[A-Za-z]:$/.test(x)) ? x : x.replace(/:.*$/s, '')));
116
120
  const 이름 = (조각[조각.length - 1] ?? '').toLowerCase();
117
121
  /*
118
122
  * 살림 자리를 `.deel` 이라는 **글자**로 찾고 있었다.
@@ -405,7 +409,7 @@ export function 훑기상한(env = process.env) {
405
409
  * 자주 쉬면 훑기 자체가 느려지고, 드물게 쉬면 그만큼 귀를 닫는다. 200개면
406
410
  * 사내망 드라이브에서도 한 숨 사이가 10ms 언저리다.
407
411
  */
408
- export const 숨쉴간격 = 200;
412
+ const 숨쉴간격 = 200;
409
413
  const 한숨 = () => new Promise((풀기) => setImmediate(풀기));
410
414
 
411
415
  /**
@@ -30,11 +30,31 @@ export function 규칙읽기(text, 기준 = '') {
30
30
  return out;
31
31
  }
32
32
 
33
+ /*
34
+ * ── 끝 빈칸 떼기는 git 과 **똑같이** (2.0.2 · G4) ─────────────────────────────
35
+ *
36
+ * 「역슬래시 바로 뒤 빈칸은 살린다」 로 쟀더니 `foo\\ ` 가 틀렸다. 앞의 `\\` 는 역슬래시 **글자**
37
+ * 하나이고 빈칸은 안 살린 것이라 git 은 `foo\` 로 읽는데, 우리는 빈칸까지 남겨 `foo\ ` 를 찾았다.
38
+ * git(dir.c 의 trim_trailing_spaces)을 그대로 옮긴다 — 역슬래시는 다음 글자 하나를 먹고, 떼는 것은
39
+ * 끝에 이어진 **빈칸**(' ')뿐이다. 탭은 git 도 안 뗀다.
40
+ */
41
+ export function 끝빈칸떼기(줄) {
42
+ const p = String(줄 ?? '');
43
+ let 빈칸자리 = -1;
44
+ for (let i = 0; i < p.length; i++) {
45
+ const ch = p[i];
46
+ if (ch === ' ') { if (빈칸자리 < 0) 빈칸자리 = i; continue; }
47
+ if (ch === '\\') { i++; if (i >= p.length) return p; }
48
+ 빈칸자리 = -1;
49
+ }
50
+ return 빈칸자리 >= 0 ? p.slice(0, 빈칸자리) : p;
51
+ }
52
+
33
53
  /** 줄 하나 → 규칙. 빈 줄·주석이면 null. */
34
54
  export function 패턴규칙(줄, 기준 = '') {
35
55
  let p = String(줄 ?? '');
36
56
  // 끝의 빈칸은 뗀다 — 역슬래시로 살린 것만 남긴다.
37
- p = p.replace(/(?<!\\)\s+$/, '');
57
+ p = 끝빈칸떼기(p);
38
58
  if (!p || p.startsWith('#')) return null;
39
59
  let 부정 = false;
40
60
  if (p.startsWith('!')) { 부정 = true; p = p.slice(1); }
@@ -10,7 +10,7 @@ import { 건너뜀말 } from './ignore.js';
10
10
  import { encode, label as encLabel, decode as decodeBytes, consoleCodepage, looksBinary, 바꾼데만쓰기 } from './encoding.js';
11
11
  import { checkCommand, checkPaths, isMutating, 셸이파일에쓰나 } from '../safety/guard.js';
12
12
  import { 띄우기, 나무끊기, 무리끊기, 남은무리끊기, JOBS_TOOL } from './jobs.js';
13
- import { 셸명령 } from './shell.js';
13
+ import { 셸명령, 셸한계넘나 } from './shell.js';
14
14
  import { findMatch, applySpans, reindent, TIER_LABELS, CRLF뿐인가, CRLF로, 꼴맞추기 } from './edit-match.js';
15
15
  import { loadSkill } from '../skills/discover.js';
16
16
  import { WEB_FETCH_TOOL } from './webfetch.js';
@@ -187,7 +187,7 @@ export function 붙박이그림줄이기(줄들) {
187
187
  * 줄을 통째로 지워 버리는 경우가 더 흔하고, 그때는 표시도 같이 사라진다.
188
188
  * 그래서 **개수를 견준다** — 있던 것보다 줄었으면 잃은 것이다.
189
189
  */
190
- export function 박힌그림수(글) {
190
+ function 박힌그림수(글) {
191
191
  return (String(글 ?? '').match(그림자리) ?? []).length;
192
192
  }
193
193
 
@@ -274,6 +274,19 @@ function 파일크기(abs) {
274
274
  try { return statSync(abs).size; } catch { return -1; }
275
275
  }
276
276
 
277
+ /*
278
+ * 쓴 자리에 **파일이 생겼나** (2.0.2 · 379). 안 생겼으면 그 말을 돌려준다.
279
+ *
280
+ * 윈도 10 까지는 `NUL`·`CON`·`aux.txt` 같은 이름이 파일이 아니라 장치다 — 쓰기는 성공하고
281
+ * 아무것도 안 남는다. 그런데 화면은 `새로 만듦: NUL (1줄)` 이었다. 윈도 11 은 진짜 파일을
282
+ * 만들어서 판마다 다르다 — 이름을 외우지 않고 **쓴 결과**를 본다. Write·Append·한글 문서가
283
+ * 같이 쓴다.
284
+ */
285
+ export function 파일안생김(ctx, abs) {
286
+ try { if (statSync(abs).isFile()) return null; } catch { /* 없다 — 아래 말 */ }
287
+ return { error: `${ctx.scope.show(abs)} 에 썼는데 파일이 안 생겼습니다 — 윈도가 이 이름(NUL·CON·AUX·COM1 따위)을 장치로 읽습니다. 다른 이름을 쓰세요.` };
288
+ }
289
+
277
290
  /*
278
291
  * ── 크기만으로는 「그 파일 그대로」 를 모른다 ─────────────────────────────
279
292
  *
@@ -1439,6 +1452,7 @@ function hwpx새로만들기(args, ctx, abs) {
1439
1452
  writeFileSync(abs, 만듦.buf);
1440
1453
  }, () => !existsSync(abs));
1441
1454
  if (못씀) return 못씀;
1455
+ { const 탈 = 파일안생김(ctx, abs); if (탈) return 탈; }
1442
1456
  /*
1443
1457
  * `seen` 에 안 올린다.
1444
1458
  *
@@ -1608,6 +1622,7 @@ function 한파일쓰기(args, ctx) {
1608
1622
  writeFileSync(abs, 만든것.buf);
1609
1623
  }, () => (existed ? !!읽음 && readFileSync(abs).equals(읽음.buf) : !existsSync(abs)));
1610
1624
  if (못씀) return 못씀;
1625
+ { const 탈 = 파일안생김(ctx, abs); if (탈) return 탈; }
1611
1626
  ctx.seen.add(abs);
1612
1627
  const n = args.content.split('\n').length;
1613
1628
  const 표기 = 원래 !== 'utf-8' ? ` · ${encLabel(원래)}${짐작이다 ? ' (짐작)' : ''}` : '';
@@ -1681,6 +1696,106 @@ function 여러파일쓰기(목록, ctx) {
1681
1696
  * 결과 모양을 바꾸면 안 된다. changed·diff·tier 를 보고 있는 자리가 셋이다:
1682
1697
  * loop.js 의 잘린 인자 살려쓰기, repl.js 의 바뀐 자리 그리기, 되돌리기 스냅샷.
1683
1698
  */
1699
+ /*
1700
+ * old_string 을 찾아 new_string 으로 바꾼 **글**만 만든다. 파일은 안 건드린다.
1701
+ *
1702
+ * 고치기(한군데고치기)와 승인 미리보기(바뀔내용)가 이 셈 하나를 같이 쓴다. 둘이 따로
1703
+ * 셈하면 사람이 승인 창에서 본 것과 실제로 쓰인 것이 갈린다 — 들여쓰기를 맞춘 자리,
1704
+ * CRLF 로 바꾼 자리가 미리보기에만 없으면 「본 대로 허락했다」 가 거짓이 된다.
1705
+ */
1706
+ function 새글만들기(text, args) {
1707
+ const m = findMatch(text, args.old_string, { replaceAll: !!args.replace_all });
1708
+ if (!m.ok) return { m, next: null };
1709
+ // CRLF 만 쓰는 파일이면 넣는 글도 CRLF 로 (edit-match.js 의 CRLF뿐인가 머리말).
1710
+ const 줄끝맞춤 = CRLF뿐인가(text) ? CRLF로 : (s) => s;
1711
+ const next = applySpans(text, m.spans, (matched) => 줄끝맞춤(
1712
+ m.tier === 'exact' ? args.new_string
1713
+ // 정규화로 찾았으면 넣는 글도 파일의 꼴(NFC·NFD)로 (edit-match.js 의 꼴맞추기).
1714
+ : m.tier === 'nfc' ? 꼴맞추기(args.new_string, matched)
1715
+ : reindent(args.new_string, matched, args.old_string)));
1716
+ return { m, next };
1717
+ }
1718
+
1719
+ // 미리보기에 실을 만큼. 넘는 파일은 한 줄 물음으로 물러선다 — 승인 창에 몇 MB 를
1720
+ // 그리거나 편집기로 보내는 동안 사람은 멈춘 화면만 본다.
1721
+ const 미리보기상한 = 2_000_000;
1722
+
1723
+ /**
1724
+ * 이 도구 호출이 **실행되면** 무엇이 어떻게 바뀌는가. 승인을 묻기 전에 보여 줄 거리다.
1725
+ *
1726
+ * 사내 검토에서 나온 물음이 이것이었다 — 「고치기 전에 미리보기를 보여 주고 사람이
1727
+ * 확인하는가」. 여태 승인 창은 `Edit src/a.js` 한 줄만 보여 줬다. 무엇이 바뀌는지
1728
+ * 모르고 누르는 y 는 확인이 아니라 습관이다.
1729
+ *
1730
+ * 파일은 **읽기만** 한다. 못 만들면(경로 밖·없는 파일·못 찾는 old_string·문서 파일)
1731
+ * 빈 배열이다 — 그 경우 도구가 어차피 실패로 끝나거나, 물음은 예전처럼 한 줄로 뜬다.
1732
+ *
1733
+ * @returns {Array<{경로:string, 보일경로:string, 전:string|null, 후:string}>}
1734
+ */
1735
+ export function 바뀔내용(이름, 인자, ctx) {
1736
+ const a = 인자 ?? {};
1737
+ /*
1738
+ * 실제 도구가 거절하는 자리는 미리보기도 안 읽는다. deel 자신의 살림(기록·설정·열쇠)은 Write·Edit 이
1739
+ * 먼저 거절하는데(내부살림), 미리보기가 그걸 모르고 읽으면 승인 창에 「지금 내용」 으로 열쇠가 찍히고
1740
+ * 편집기로까지 간다 — 도구는 안 돌았는데 읽기는 일어난 셈이다. 문서 파일은 글로 못 보인다.
1741
+ */
1742
+ const 글파일인가 = (abs) => !(isExcelPath(abs) || isDocPath(abs) || isPdfPath(abs) || isFigPath(abs) || 내부살림(abs));
1743
+ const 읽기 = (abs) => (existsSync(abs) ? readTextFull(abs).text : null);
1744
+ const 담기 = (abs, 전, 후) => ({ 경로: abs, 보일경로: ctx.scope.show(abs), 전, 후 });
1745
+ try {
1746
+ if (이름 === 'Write' || 이름 === 'Append') {
1747
+ if (typeof a.file_path !== 'string' || typeof a.content !== 'string') return [];
1748
+ const abs = ctx.scope.resolve(a.file_path);
1749
+ // 실제 도구는 «가림:…» 표를 파일로 되돌리는 쓰기를 거절한다 — 안 일어날 변경은 안 그린다.
1750
+ if (!글파일인가(abs) || 표몇군데(a.content)) return [];
1751
+ const 전 = 읽기(abs);
1752
+ /*
1753
+ * Append 는 CRLF 만 쓰는 파일에 붙일 때 조각도 CRLF 로 바꿔 쓴다. 그 판정도 실제처럼
1754
+ * **앞머리**로 한다(아래 Append 의 앞글). LF 로 이어 붙여 그리면 본 것과 쓰인 것이 다르다.
1755
+ */
1756
+ const 붙일글 = 이름 === 'Append' && 전 !== null && CRLF뿐인가(전.slice(0, 인코딩볼바이트)) ? CRLF로(a.content) : a.content;
1757
+ const 후 = 이름 === 'Append' ? (전 ?? '') + 붙일글 : a.content;
1758
+ if ((전?.length ?? 0) + 후.length > 미리보기상한) return [];
1759
+ return [담기(abs, 전, 후)];
1760
+ }
1761
+ if (이름 === 'Edit') {
1762
+ // 여러 군데면 실제 고치기처럼 적은 순서대로 차례로 대 보고, 못 찾은 것은 건너뛴다
1763
+ // (여러군데고치기 가 그렇게 한다 — 된 것만 쓰고 안 된 것은 실패로 알린다).
1764
+ const 목록 = Array.isArray(a.edits) ? a.edits.filter((x) => x && typeof x === 'object') : [a];
1765
+ const 파일들 = new Map();
1766
+ for (const x of 목록) {
1767
+ if (typeof x.file_path !== 'string' || typeof x.old_string !== 'string' || typeof x.new_string !== 'string') continue;
1768
+ if (x.old_string === x.new_string) continue;
1769
+ let abs;
1770
+ try { abs = ctx.scope.resolve(x.file_path); } catch { continue; }
1771
+ // 아직 안 읽은 파일은 실제 Edit 이 거절한다(「먼저 Read 로」). 미리보기가 대신 읽어 보여 주면
1772
+ // 모델이 안 본 파일의 내용이 승인 창에 먼저 뜬다 — 그 호출은 어차피 실패로 끝난다.
1773
+ if (!글파일인가(abs) || !ctx.seen?.has(abs)) continue;
1774
+ // 가린 표를 되돌리는 고침은 실제 Edit 이 거절한다(한군데고치기) — 그 한 군데는 안 일어난다.
1775
+ if (표몇군데(x.new_string)) continue;
1776
+ if (!파일들.has(abs)) {
1777
+ const 글 = 읽기(abs);
1778
+ if (글 === null) continue;
1779
+ /*
1780
+ * 너무 커서 못 그리는 파일이 **하나라도** 있으면 미리보기를 통째로 안 낸다. 그 파일만 빼고
1781
+ * 그리면 사람은 작은 파일만 바뀌는 줄 알고 허락한다 — 반쪽 미리보기는 없는 것보다 나쁘다.
1782
+ * 물음은 예전처럼 한 줄로 뜬다 (2차 눈 판정).
1783
+ */
1784
+ if (글.length > 미리보기상한) return [];
1785
+ 파일들.set(abs, { 전: 글, 후: 글 });
1786
+ }
1787
+ const 판 = 파일들.get(abs);
1788
+ const { m, next } = 새글만들기(판.후, x);
1789
+ if (m.ok) 판.후 = next;
1790
+ // 고친 **뒤**도 잰다 — replace_all 한 번에 수십 MB 가 되면 동기 diff 가 승인 창을 붙잡는다.
1791
+ if (판.후.length > 미리보기상한) return [];
1792
+ }
1793
+ return [...파일들].filter(([, 판]) => 판.후 !== 판.전).map(([abs, 판]) => 담기(abs, 판.전, 판.후));
1794
+ }
1795
+ } catch { /* 미리보기는 거드는 것이다 — 못 만들면 물음은 한 줄로 뜬다 */ }
1796
+ return [];
1797
+ }
1798
+
1684
1799
  function 한군데고치기(args, ctx) {
1685
1800
  /*
1686
1801
  * ── 글이 아닌 old_string·new_string 은 **받지 않는다** ──────────────────
@@ -1722,7 +1837,7 @@ function 한군데고치기(args, ctx) {
1722
1837
 
1723
1838
  const 읽음 = readTextFull(abs);
1724
1839
  const text = 읽음.text;
1725
- const m = findMatch(text, args.old_string, { replaceAll: !!args.replace_all });
1840
+ const { m, next } = 새글만들기(text, args);
1726
1841
 
1727
1842
  if (!m.ok) {
1728
1843
  if (m.reason === 'ambiguous') {
@@ -1742,14 +1857,6 @@ function 한군데고치기(args, ctx) {
1742
1857
  return { error: `찾지 못했습니다.${hint}` };
1743
1858
  }
1744
1859
 
1745
- // CRLF 만 쓰는 파일이면 넣는 글도 CRLF 로 (edit-match.js 의 CRLF뿐인가 머리말).
1746
- const 줄끝맞춤 = CRLF뿐인가(text) ? CRLF로 : (s) => s;
1747
- const next = applySpans(text, m.spans, (matched) => 줄끝맞춤(
1748
- m.tier === 'exact' ? args.new_string
1749
- // 정규화로 찾았으면 넣는 글도 파일의 꼴(NFC·NFD)로 (edit-match.js 의 꼴맞추기).
1750
- : m.tier === 'nfc' ? 꼴맞추기(args.new_string, matched)
1751
- : reindent(args.new_string, matched, args.old_string)));
1752
-
1753
1860
  // 읽은 그 인코딩으로 되돌려 쓴다. 안 바뀐 앞뒤는 읽은 바이트 그대로 (encoding.js 의 바꾼데만쓰기).
1754
1861
  const 만든것 = 바꾼데만쓰기(읽음.buf, text, next, 읽음.encoding);
1755
1862
  /*
@@ -2452,6 +2559,7 @@ export const TOOLS = {
2452
2559
  else writeFileSync(abs, 만든것.buf);
2453
2560
  }, () => (existed ? 파일크기(abs) === 지금크기 : !existsSync(abs)));
2454
2561
  if (못씀) return 못씀;
2562
+ { const 탈 = 파일안생김(ctx, abs); if (탈) return 탈; }
2455
2563
  // 붙인 **뒤** 앞머리로 적어 둔다. 64KB 보다 작던 파일은 붙인 조각이 앞머리에 든다.
2456
2564
  잰것캐시.set(abs, {
2457
2565
  지문: 지금크기 >= 인코딩볼바이트 ? 앞지문 : 표본지문(앞머리(abs, 인코딩볼바이트)),
@@ -3055,6 +3163,9 @@ export const TOOLS = {
3055
3163
  // 어느 셸로 넘기나 — tools/shell.js 가 정한다. Jobs 와 같은 답이어야 하므로 한 군데다.
3056
3164
  // (윈도우 cmd 의 따옴표 문제와 그 해법도 거기 적혀 있다.)
3057
3165
  const shell = 셸명령(cmd);
3166
+ // cmd 의 한 줄 한계를 넘으면 돌리기 전에 말한다 (tools/shell.js 셸한계넘나 · 2.0.2 S6).
3167
+ const 한계 = 셸한계넘나(cmd, shell);
3168
+ if (한계) return { error: 한계 };
3058
3169
 
3059
3170
  const 제한 = bash제한시간(args.timeout);
3060
3171
  // 무엇을 빼고 넘길지 여기서 한 번 정한다. 뺀 이름은 아래에서 명령이
package/src/tools/jobs.js CHANGED
@@ -25,9 +25,10 @@
25
25
  */
26
26
  import { spawn } from 'node:child_process';
27
27
  import { decode as decodeBytes, consoleCodepage } from './encoding.js';
28
- import { 셸명령 } from './shell.js';
28
+ import { 셸명령, 셸한계넘나 } from './shell.js';
29
29
  import { 셸환경 } from '../safety/shellenv.js';
30
30
  import { 말, 세말 } from '../i18n/index.js';
31
+ import { 신호에거두기 } from '../reap.js';
31
32
 
32
33
  /* 결과 한 줄을 잇는다 — 빈 조각은 버린다(tools/index.js 의 이어 와 같은 것). */
33
34
  const 이어 = (...조각들) => 조각들.filter((x) => x != null && String(x) !== '').join(' · ');
@@ -319,7 +320,7 @@ export function 무리끊기(pid, { 곧장 = false, 늦게 = 800 } = {}) {
319
320
  export const 못재고기다릴최대 = 400;
320
321
 
321
322
  /** 무리끊기 의 **기다릴 수 있는** 판. 까닭은 바로 위 머리말에. */
322
- export async function 무리끊기기다려(pid, { 곧장 = false, 늦게 = 800 } = {}) {
323
+ async function 무리끊기기다려(pid, { 곧장 = false, 늦게 = 800 } = {}) {
323
324
  if (process.platform === 'win32' || !pid) return;
324
325
  if (곧장) { 신호주기(pid, 'SIGKILL'); return; }
325
326
  신호주기(pid, 'SIGTERM');
@@ -350,6 +351,19 @@ export async function 무리끊기기다려(pid, { 곧장 = false, 늦게 = 800
350
351
  export function 나무죽이기(kid, { 파이프끊기: 끊을까 = true, 곧장 = false, 상한 = 1500, 뿌리살리기 = true } = {}) {
351
352
  if (!kid) return true;
352
353
  let 나무끊었나 = true;
354
+ /*
355
+ * ── 끝난 것으로 본 아이에게는 taskkill 을 안 보낸다 (2.0.2 · 28회차 flake) ──────────
356
+ *
357
+ * Node 는 아이가 끝났다고 알리는 그 자리에서 프로세스 손잡이를 닫는다. 손잡이가 닫히면 윈도는
358
+ * 그 번호를 **곧바로 딴 프로세스에게 준다.** 그 뒤의 `taskkill /pid N /t` 는 우리 아이가 아니라
359
+ * 그 번호를 새로 받은 **남의 나무**를 끊는다 — 윈도 CI 에서 옆 대조군이 말없이 멈춘 것(28회차)이
360
+ * 이 모양이다. 상한에 잘려 다시 오는 길(뿌리살리기)에서 특히 그렇다: 첫 taskkill 이 뿌리를
361
+ * 죽이고 나서 두 번째가 같은 번호로 간다.
362
+ *
363
+ * 잃는 것은 없다. 뿌리가 끝난 뒤에는 /t 가 어차피 그 아래를 못 훑는다(남은무리끊기 머리말).
364
+ * 아직 안 끝난 아이는 손잡이가 열려 있어 번호가 안 바뀐다 — 그때만 보낸다.
365
+ */
366
+ const 끝난아이 = typeof kid.exitCode === 'number' || typeof kid.signalCode === 'string';
353
367
  if (process.platform === 'win32' && kid.pid) {
354
368
  /*
355
369
  * 상한은 부르는 쪽이 정한다. 여기는 끝날 때도 지나가는 자리라
@@ -357,7 +371,7 @@ export function 나무죽이기(kid, { 파이프끊기: 끊을까 = true, 곧장
357
371
  * 그래서 기본은 짧다. 사람이 「끝내」 라고 시킨 자리에서는 길게 준다 —
358
372
  * 거기서는 몇백 ms 늦는 것보다 안 죽는 것이 훨씬 나쁘다.
359
373
  */
360
- 나무끊었나 = 나무끊기(kid.pid, 상한);
374
+ if (!끝난아이) 나무끊었나 = 나무끊기(kid.pid, 상한);
361
375
  /*
362
376
  * 나무를 다 못 훑었으면 뿌리를 살려 둔다. 부르는 쪽이 다시 온다.
363
377
  *
@@ -675,8 +689,11 @@ export async function 띄우기(명령, { cwd, 설명 = null, 기다림 = 1500,
675
689
  return { error: `뒤에서 도는 명령이 이미 ${도는것.length}개입니다. Jobs 로 안 쓰는 것을 끝내고 다시 하세요.` };
676
690
  }
677
691
 
678
- const j = new 일감(다음번호++, 명령, 설명);
679
692
  const shell = 셸명령(명령);
693
+ // cmd 의 한 줄 한계를 넘으면 띄우기 전에 말한다 (tools/shell.js 셸한계넘나 · 2.0.2 S6). 번호를 받기 전이다.
694
+ const 한계 = 셸한계넘나(명령, shell);
695
+ if (한계) return { error: 한계 };
696
+ const j = new 일감(다음번호++, 명령, 설명);
680
697
  let kid;
681
698
  try {
682
699
  kid = spawn(shell.file, shell.args, {
@@ -918,6 +935,8 @@ export function 비우기() { 모두끝내기(); 다음번호 = 1; 치운것 = 0
918
935
 
919
936
  // 어떤 길로 끝나든 남기지 않는다. repl·oneshot 이 부르는 것과 겹쳐도 무해하다.
920
937
  process.once('exit', () => { try { 모두끝내기(); } catch { /* 끝나는 중이라 할 수 있는 게 없다 */ } });
938
+ // 신호(SIGINT·SIGTERM)로 끝나면 'exit' 이 안 돈다 — 그 길도 거둔다 (reap.js 머리말 · 2.0.2 M3).
939
+ 신호에거두기(모두끝내기);
921
940
 
922
941
  /*
923
942
  * ── 도구 ──────────────────────────────────────────────────────────────
@@ -100,6 +100,33 @@ export function 정한셸() {
100
100
  /** 검사용 — 다음 정한셸() 이 다시 고르게. */
101
101
  export function 셸지우기() { 지금 = null; }
102
102
 
103
+ /*
104
+ * ── cmd.exe 는 한 줄에 8,191자까지다 (2.0.2 · S6) ─────────────────────────────
105
+ *
106
+ * cmd 자체의 한계라 우리가 늘릴 수 없다. 넘기면 cmd 가 「The command line is too long.」 한 줄을
107
+ * 내고 **아무것도 안 돌린다** — 그런데 한국어 윈도에서는 그 줄이 코드페이지째 깨져 오고, 모델은
108
+ * 제 명령이 왜 안 돌았는지 모른 채 같은 긴 명령을 또 낸다. 넘길 것이면 넘기기 전에 사람 말로
109
+ * 멈추고, 어떻게 하면 되는지(파일로 써서 돌리기)를 같이 말한다. Git Bash·파워셸은 이 한계가 없다.
110
+ *
111
+ * 8,191자는 명령만이 아니라 **cmd 가 받은 한 줄 전체**다 — `"C:\WINDOWS\system32\cmd.exe" /d /s /c "…"`.
112
+ * 재 보니 이 PC 에서 명령에 쓸 수 있는 것은 8,152자였다(cmd 자리 27자 + 앞뒤 붙이는 것). 그래서 cmd 자리와
113
+ * 붙이는 글자만큼 빼고 잰다. 붙이는 것은 ` /d /s /c ` 10자 · 명령을 싸는 따옴표 2자에 2자를 더 둔다.
114
+ */
115
+ const cmd한줄최대 = 8191;
116
+
117
+ /** 이 cmd 로 명령에 쓸 수 있는 글자 수. */
118
+ export function cmd명령최대(file = 'cmd.exe') { return cmd한줄최대 - (String(file).length + 14); }
119
+
120
+ /** cmd 로 넘기면 한 줄 한계를 넘나. 넘으면 모델에게 줄 말, 아니면 null. */
121
+ export function 셸한계넘나(cmd, { id, file } = 정한셸()) {
122
+ if (id !== 'cmd') return null;
123
+ const n = String(cmd ?? '').length;
124
+ const 최대 = cmd명령최대(file);
125
+ if (n <= 최대) return null;
126
+ return `명령이 ${n.toLocaleString('en-US')}자입니다 — cmd.exe 는 한 줄에 ${cmd한줄최대.toLocaleString('en-US')}자까지만 받습니다(cmd 자리를 빼면 명령에는 ${최대.toLocaleString('en-US')}자 · cmd 자체 한계라 돌리지 않았습니다).`
127
+ + ' 긴 내용은 Write 로 파일(.bat · .ps1 · .js 등)에 쓰고 그 파일을 돌리세요. Git Bash 를 깔면 이 한계가 없습니다.';
128
+ }
129
+
103
130
  /** 명령 하나를 셸에 넘길 모양. Bash 도구와 Jobs 가 둘 다 이걸 쓴다. */
104
131
  export function 셸명령(cmd) {
105
132
  const s = 정한셸();
package/src/tools/todo.js CHANGED
@@ -13,7 +13,7 @@ import { 말 } from '../i18n/index.js';
13
13
 
14
14
  /* 결과 한 줄을 잇는다 — 빈 조각은 버린다(tools/index.js 의 이어 와 같은 것). */
15
15
  const 이어 = (...조각들) => 조각들.filter((x) => x != null && String(x) !== '').join(' · ');
16
- export const STATES = ['todo', 'doing', 'done'];
16
+ const STATES = ['todo', 'doing', 'done'];
17
17
 
18
18
  const 표시 = { todo: '☐', doing: '▶', done: '☑' };
19
19
 
@@ -303,7 +303,7 @@ export function 웹되돌림(다음, { allowPrivate = false } = {}) {
303
303
  * 첫 주소만 이름을 풀어 보면 소용이 없다. 공개 주소로 시작해 302 한 번으로
304
304
  * 사내로 들어가는 것이 가장 흔한 길이라, 홉마다 같은 것을 물어야 한다.
305
305
  */
306
- export async function 웹되돌림검사(다음, { allowPrivate = false } = {}) {
306
+ async function 웹되돌림검사(다음, { allowPrivate = false } = {}) {
307
307
  웹되돌림(다음, { allowPrivate });
308
308
  if (allowPrivate) return;
309
309
  const 푼것 = await 사설로풀리나(다음.hostname);
package/src/tools/xlsx.js CHANGED
@@ -232,12 +232,12 @@ function 날짜로(n) {
232
232
  * 이 밖을 가리키는 주소는 엑셀이 만든 파일에서 나올 수 없다. 나왔다면 누가
233
233
  * 손으로 적은 바이트다.
234
234
  */
235
- export const 엑셀최대행 = 1048576;
236
- export const 엑셀최대열 = 16384;
235
+ const 엑셀최대행 = 1048576;
236
+ const 엑셀최대열 = 16384;
237
237
  /** 빈 줄·빈 열이 이만큼 넘게 이어지면 자리를 안 지키고 건너뛰었다고 말한다. */
238
- export const 빈틈상한 = 1000;
238
+ const 빈틈상한 = 1000;
239
239
  /** 펼친 표(줄 × 폭)의 칸 수 상한. 넘는 아래쪽 줄은 빼고 뺐다고 말한다. */
240
- export const 칸수상한 = 1_000_000;
240
+ const 칸수상한 = 1_000_000;
241
241
 
242
242
  const 천단위 = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
243
243