deel-local-cli 1.20.12 → 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 -77
  2. package/README.md +119 -80
  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 +62 -13
  7. package/src/acp/serve.js +338 -30
  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 +506 -32
  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 +40 -9
  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
@@ -23,8 +23,8 @@
23
23
  // 남이 미리 담아 둔 것(index)을 풀지도 않는다 — 남의 준비를 말없이 흩는
24
24
  // 것이 커밋을 하나 더 만드는 것보다 나쁘다. 대신 화면에 같이 적는다.
25
25
  import { spawnSync } from 'node:child_process';
26
- import { writeFileSync, unlinkSync, statSync } from 'node:fs';
27
- import { join, relative, isAbsolute } from 'node:path';
26
+ import { writeFileSync, unlinkSync, statSync, existsSync } from 'node:fs';
27
+ import { join, relative, isAbsolute, dirname } from 'node:path';
28
28
  import { 진짜자리 } from '../safety/guard.js';
29
29
  import { tmpdir } from 'node:os';
30
30
  import { randomBytes } from 'node:crypto';
@@ -133,6 +133,18 @@ export function 살림에닿나(abs) {
133
133
  /** 담을 때 git 에게도 빼 달라고 하는 자리. 어느 깊이든, 대소문자 상관없이. */
134
134
  const 살림빼기 = [':(exclude,icase,glob)**/.deel/**', ':(exclude,icase,glob)**/.deel'];
135
135
 
136
+ /*
137
+ * ── 파일 이름은 글자 그대로 넘긴다 (사냥5 H5-4) ─────────────────────────────
138
+ *
139
+ * `git add -- <경로>` 의 경로 자리는 **패스스펙**이다. git 은 거기 적힌 `[ab].txt` 를
140
+ * 그 이름의 파일로도, 「a.txt 나 b.txt」 라는 글롭으로도 푼다. 그래서 이번 대화가
141
+ * `[ab].txt` 하나만 만들었는데 옆 창에서 고치던 a.txt·b.txt 가 같이 담겨 커밋에
142
+ * 실렸다 — 이 파일이 없애려고 만든 `git add -A` 사고가 이름 한 글자로 되돌아온다.
143
+ * `:(literal)` 을 붙이면 그 한 자리만 글자 그대로 읽는다. 위 살림빼기는 일부러
144
+ * 글롭이라(어느 깊이든) 그대로 둔다 — 패스스펙마다 따로 읽히므로 섞어도 된다.
145
+ */
146
+ export const 글자그대로 = (경로) => `:(literal)${경로}`;
147
+
136
148
  /**
137
149
  * 링크를 다 푼 진짜 자리 — 자는 울타리와 **같은 것**을 쓴다.
138
150
  *
@@ -157,10 +169,73 @@ const 진짜 = 진짜자리;
157
169
  * 없애려던 `git add -A` 사고를 이름만 바꿔 다시 내는 셈이다. 안 담은 폴더는
158
170
  * 목록으로 돌려주고, 화면이 그렇게 말한다.
159
171
  */
172
+ /**
173
+ * 이 파일이 **딴 저장소** 안인가 — 서브모듈이나, 안쪽에 따로 `git init` 한 폴더.
174
+ *
175
+ * 그 안 경로를 `git add` 에 실으면 서브모듈은 `fatal: Pathspec … is in submodule`
176
+ * 로 죽고, 같이 실은 바깥 파일까지 못 담아 /commit 이 통째로 안 된다. 등록 안 된
177
+ * 안쪽 저장소는 거꾸로 add 가 0 으로 끝나고 아무것도 안 담는다 — 말없이 빠진다.
178
+ * 뿌리와 파일 사이 폴더에 `.git`(서브모듈은 파일, 안쪽 저장소는 폴더)이 있으면 딴 저장소다.
179
+ * (6회차 Gemini 커밋 C2)
180
+ *
181
+ * @returns {string|null} 그 저장소 폴더 — 저장소 뿌리 기준 상대경로(`/`)
182
+ */
183
+ function 딴저장소자리(abs, 기준) {
184
+ for (let d = dirname(abs); d.length > 기준.length && d !== dirname(d); d = dirname(d)) {
185
+ if (existsSync(join(d, '.git'))) return relative(기준, d).replace(/\\/g, '/');
186
+ }
187
+ return null;
188
+ }
189
+
190
+ /** git 의 index 가 이 이름으로 내놓는 것들. 못 물어봤으면 null — 「없다」 와 다르다. */
191
+ function 깃이아는것(기준, rel) {
192
+ const r = 깃(기준, ['ls-files', '-z', '--', 글자그대로(rel)]);
193
+ if (!r.ok) return null;
194
+ return r.out.split(String.fromCharCode(0)).filter(Boolean);
195
+ }
196
+
197
+ /**
198
+ * 지워진 뒤에는 stat 이 폴더였는지 안 알려 준다 — git 의 index 에 물어본다.
199
+ *
200
+ * 폴더는 담을 목록에서 뺀다(바로 아래). 그런데 **지워진** 폴더만 그 그물을
201
+ * 빠져나갔다. statSync 가 던지면 「지워진 것은 파일로 친다」 쪽으로 갔고, 그
202
+ * 이름이 그대로 `git add -A -- :(literal)<폴더>` 에 실렸다. git 은 폴더를 받으면
203
+ * 그 아래 **전부**를 담는다 — 옆 창에서 고치던 남의 파일까지. 이 파일이 없애려고
204
+ * 만들어진 바로 그 `git add -A` 사고가 이름만 바꿔 되돌아온 것이다. (8회차 커밋 1)
205
+ *
206
+ * index 가 그 이름 **아래**의 것들을 내놓으면 폴더였다. 그 이름 자체가 나오면
207
+ * 파일이고, 아무것도 안 나오면 git 이 모르던 것이라 여태대로 파일로 친다.
208
+ */
209
+ function 지워진폴더인가(기준, rel) {
210
+ const 든것 = 깃이아는것(기준, rel);
211
+ return !!든것 && 든것.length > 0 && !든것.includes(rel);
212
+ }
213
+
214
+ /**
215
+ * 지워졌는데 git 이 그 이름을 **한 번도 본 적이 없나** — 담을 수 없는 이름인가.
216
+ *
217
+ * 이번 대화가 만들었다 지운 임시 파일이 이렇다. 추적된 적이 없으니 index 에도
218
+ * 없고, 작업 폴더에도 이제 없다. 그 이름이 `git add -A -- :(literal)<이름>` 에
219
+ * 실리면 git 은 `fatal: pathspec … did not match any files` 로 죽는데, add 는
220
+ * 하나라도 못 맞추면 **아무것도 안 담고** 끝난다 — 같이 실은 진짜 변경까지
221
+ * 전부. 그래서 /commit 이 통째로 안 됐다. 담을 수 없는 이름 하나 때문에 담을
222
+ * 수 있는 것까지 못 담는 것이 제일 나쁘다. 이 이름만 빼고 나머지는 담는다.
223
+ * (8회차 커밋2)
224
+ *
225
+ * 못 물어봤으면(`null`) 여태대로 담아 본다 — 모르면서 빼면 사람이 담긴 줄 알고
226
+ * 넘어간다. 추적하던 파일의 삭제는 index 에 그 이름이 있으므로 여기 안 걸린다.
227
+ */
228
+ function 깃이모르나(기준, rel) {
229
+ const 든것 = 깃이아는것(기준, rel);
230
+ return !!든것 && 든것.length === 0;
231
+ }
232
+
160
233
  export function 이번에바꾼것(session, 뿌리) {
161
234
  const 기준 = 진짜(뿌리);
162
235
  const 것들 = [];
163
236
  const 폴더들 = [];
237
+ const 딴저장소 = [];
238
+ const 모르는이름 = [];
164
239
  for (const p of (session?.changes?.keys?.() ?? [])) {
165
240
  const abs = 진짜(isAbsolute(p) ? p : join(기준, p));
166
241
  const rel = relative(기준, abs).replace(/\\/g, '/');
@@ -176,13 +251,21 @@ export function 이번에바꾼것(session, 뿌리) {
176
251
  */
177
252
  if (!rel || rel.startsWith('../') || rel === '..' || isAbsolute(rel)) continue;
178
253
  if (살림경로인가(rel) || 살림에닿나(abs)) continue;
254
+ const 딴곳 = 딴저장소자리(abs, 기준);
255
+ if (딴곳 !== null) { if (!딴저장소.includes(딴곳)) 딴저장소.push(딴곳); continue; }
179
256
  let 폴더인가 = false;
180
- try { 폴더인가 = statSync(abs).isDirectory(); } catch { /* 지워진 것은 파일로 친다 */ }
257
+ // 지워졌으면 stat 이 폴더였는지 못 알려 준다 — git 에게 묻는다 (8회차 커밋 1).
258
+ try { 폴더인가 = statSync(abs).isDirectory(); } catch { 폴더인가 = 지워진폴더인가(기준, rel); }
181
259
  if (폴더인가) { if (!폴더들.includes(rel)) 폴더들.push(rel); continue; }
260
+ // 지금 없고 git 도 모르는 이름은 담을 수가 없다 — 그것만 빼고 나머지는 담는다 (8회차 커밋2).
261
+ if (!existsSync(abs) && 깃이모르나(기준, rel)) { if (!모르는이름.includes(rel)) 모르는이름.push(rel); continue; }
182
262
  if (!것들.includes(rel)) 것들.push(rel);
183
263
  }
184
264
  것들.sort();
185
265
  Object.defineProperty(것들, '폴더', { value: 폴더들.sort(), enumerable: false });
266
+ Object.defineProperty(것들, '딴저장소', { value: 딴저장소.sort(), enumerable: false });
267
+ // 만들었다 지워서 git 이 끝내 못 본 이름 — 안 담았다고 화면이 말한다 (8회차 커밋2).
268
+ Object.defineProperty(것들, '모르는이름', { value: 모르는이름.sort(), enumerable: false });
186
269
  return 것들;
187
270
  }
188
271
 
@@ -201,9 +284,10 @@ export function 담기(뿌리, 경로들, { 전부 = false, 안쪽 = '' } = {})
201
284
  * 담기고 — 그 내용이 커밋 메시지를 지으러 모델에게도 나간다. 사람은
202
285
  * "이 폴더에서 일하는 중" 이라고 알고 있었다.
203
286
  */
204
- if (전부) return 깃(뿌리, ['add', '-A', '--', 안쪽 || '.', ...살림빼기]);
287
+ // 작업 폴더 이름도 글자 그대로다 — `sub[1]` 폴더에서 켰는데 옆의 `sub1` 이 담기면 안 된다.
288
+ if (전부) return 깃(뿌리, ['add', '-A', '--', 안쪽 ? 글자그대로(안쪽) : '.', ...살림빼기]);
205
289
  if (!경로들.length) return { ok: true, code: 0, out: '', err: '' };
206
- return 깃(뿌리, ['add', '-A', '--', ...경로들, ...살림빼기]);
290
+ return 깃(뿌리, ['add', '-A', '--', ...경로들.map(글자그대로), ...살림빼기]);
207
291
  }
208
292
 
209
293
  /**
@@ -219,8 +303,9 @@ export function 살림도로빼기(뿌리, 파일들) {
219
303
  const 샌것 = 파일들.filter((f) => 살림에닿나(join(뿌리, f)));
220
304
  if (!샌것.length) return { 샌것, 못뺀것: [] };
221
305
  // 첫 커밋 전이면 HEAD 가 없어 restore 가 안 된다. 그때는 index 에서 지운다.
222
- const r = 깃(뿌리, ['restore', '--staged', '--', ...샌것]);
223
- if (!r.ok) 깃(뿌리, ['rm', '--cached', '-q', '-r', '--', ...샌것]);
306
+ // 도로 빼는 것도 글자 그대로 — 글롭으로 풀리면 남이 담아 둔 것까지 풀어 버린다 (사냥5 H5-4).
307
+ const r = 깃(뿌리, ['restore', '--staged', '--', ...샌것.map(글자그대로)]);
308
+ if (!r.ok) 깃(뿌리, ['rm', '--cached', '-q', '-r', '--', ...샌것.map(글자그대로)]);
224
309
  const 아직 = 담긴것(뿌리).파일들.filter((f) => 살림에닿나(join(뿌리, f)));
225
310
  return { 샌것, 못뺀것: 아직 };
226
311
  }
@@ -248,10 +333,17 @@ export function 살림도로빼기(뿌리, 파일들) {
248
333
  * @returns {{파일들:string[], 통계:string, diff:string, 못읽음:Array<{무엇:string,왜:string}>}}
249
334
  */
250
335
  export function 담긴것(뿌리) {
251
- const 이름 = 깃(뿌리, ['diff', '--cached', '--name-only']);
336
+ const 이름 = 깃(뿌리, ['diff', '--cached', '--name-only', '-z']);
252
337
  const 통계 = 깃(뿌리, ['diff', '--cached', '--stat']);
253
338
  const 몸통 = 깃(뿌리, ['diff', '--cached']);
254
- const 파일들 = 이름.out.split('\n').map((x) => x.trim()).filter(Boolean);
339
+ /*
340
+ * 이름은 NUL 로 끊어 받는다 (6회차 커밋 C5).
341
+ *
342
+ * 줄로 받아 줄마다 trim 하면 ` a.txt` 가 `a.txt` 로 적혀, 살림도로빼기 가 없는
343
+ * 이름으로 링크를 보고 화면·감사기록도 다른 이름을 적는다. 리눅스에서는 탭·따옴표·
344
+ * 줄바꿈이 든 이름이 quotepath 를 꺼도 C 따옴표로 싸여 온다. `-z` 면 손대지 않고 온다.
345
+ */
346
+ const 파일들 = 이름.out.split(String.fromCharCode(0)).filter(Boolean);
255
347
  const 못읽음 = [];
256
348
  const 적기 = (무엇, r) => {
257
349
  if (!r.ok) 못읽음.push({ 무엇, 왜: (r.err || r.out || `git ${무엇} 실패`).trim().split('\n')[0] });
@@ -293,9 +385,24 @@ const 답형식 = `아래 두 줄 형식으로만 답하라. 다른 말은 붙
293
385
 
294
386
  /** 코드울타리·따옴표를 벗긴다. 작은 모델이 자주 씌운다. */
295
387
  function 껍질벗기기(글) {
296
- let s = String(글 ?? '').trim();
388
+ // CRLF 로 답하는 게이트웨이·모델이 있다. 줄바꿈을 먼저 LF 로 맞춰야 울타리 무늬가 걸린다 —
389
+ // 안 걸리면 JSON 답이 줄글로 갈라져 커밋 제목이 ```json 이 된다 (6회차 커밋 C6).
390
+ let s = String(글 ?? '').replace(/\r\n?/g, '\n').trim();
297
391
  const 울타리 = s.match(/^```[a-z]*\n([\s\S]*?)\n?```$/i);
298
392
  if (울타리) s = 울타리[1].trim();
393
+ /*
394
+ * 따옴표도 벗긴다 — 머리말은 벗긴다고 적어 두고 안 벗기고 있었다 (8회차 커밋 5).
395
+ *
396
+ * 답을 통째로 따옴표로 싸서 주는 모델이 있다. 그러면 첫 줄이 `"제목: …` 이 되어
397
+ * `제목:` 무늬가 안 걸리고, 줄글로 읽혀 **라벨째** 커밋 제목이 된다.
398
+ * 안쪽에 같은 따옴표가 또 있으면 껍질이 아니라 인용문이다 — 그때는 그냥 둔다.
399
+ */
400
+ const 짝 = { '"': '"', "'": "'", '`': '`', '「': '」', '『': '』' };
401
+ const 닫는 = 짝[s[0]];
402
+ if (닫는 && s.length >= 2 && s.endsWith(닫는)) {
403
+ const 속 = s.slice(1, -1);
404
+ if (!속.includes(s[0]) && !속.includes(닫는)) s = 속.trim();
405
+ }
299
406
  return s;
300
407
  }
301
408
 
@@ -323,7 +430,9 @@ export function 답가르기(글) {
323
430
  if (표) {
324
431
  const 뒤 = s.slice(s.indexOf(표[0]) + 표[0].length);
325
432
  const 본문표 = 뒤.match(/^\s*본문\s*[::]\s*/m);
326
- const 본문 = 본문표 ? 뒤.slice(뒤.indexOf(본문표[0]) + 본문표[0].length) : 뒤;
433
+ // 자리는 무늬가 찾은 그 자리다. indexOf 로 다시 뒤지면 앞줄 사족에 낀 같은
434
+ // 글자가 먼저 걸려, 진짜 `본문:` 라벨이 본문에 그대로 남는다 (8회차 커밋 4).
435
+ const 본문 = 본문표 ? 뒤.slice(본문표.index + 본문표[0].length) : 뒤;
327
436
  return { 제목: 표[1].trim(), 본문: 본문.trim() };
328
437
  }
329
438
 
@@ -384,8 +493,12 @@ const 가짜꼬리표 = /^\s*(signed-off-by|co-authored-by|reviewed-by|acked-by|
384
493
  * 그렇다고 `Fixes` 로 시작하는 줄을 통째로 지우면 안 된다. 「Fixes the crash
385
494
  * when …」 은 본문에 있어야 할 진짜 문장이다. 그래서 **뒤에 `#숫자`나 주소만
386
495
  * 달랑 오는 꼴**일 때만 지운다.
496
+ *
497
+ * 끝에 온점·쉼표가 붙어도 깃허브는 그 이슈를 닫는다. 그런데 무늬가 `#123` 뒤에
498
+ * 곧바로 줄 끝을 요구해서, 문장처럼 쓴 `Fixes #123.` 은 안 걸리고 통과했다 —
499
+ * 걸러내려던 바로 그것이 온점 하나로 다시 통과한 셈이다. (8회차 커밋 3)
387
500
  */
388
- const 닫는말 = /^\s*(clos(e|es|ed)|fix(|es|ed)|resolv(e|es|ed))\s*:?\s*(#\d+|https?:\/\/\S+)\s*$/i;
501
+ const 닫는말 = /^\s*(clos(e|es|ed)|fix(|es|ed)|resolv(e|es|ed))\s*:?\s*(#\d+|https?:\/\/\S+)\s*[.,;!]*\s*$/i;
389
502
 
390
503
  export function 꼬리표걸러내기(본문) {
391
504
  return String(본문 ?? '')
@@ -405,7 +518,14 @@ export function 제목다듬기(글) {
405
518
  const 앞 = 글자.slice(0, 제목상한).join('');
406
519
  const 빈칸 = 앞.lastIndexOf(' ');
407
520
  const 자를자리 = 빈칸 > 제목상한 * 0.5 ? 빈칸 : 앞.length;
408
- return { 제목: 앞.slice(0, 자를자리).trim(), 남은것: t.slice(자를자리).trim() };
521
+ /*
522
+ * 자른 끝에도 마침표를 안 남긴다 (8회차 커밋 6).
523
+ *
524
+ * 위에서 뗀 것은 **자르기 전** 끝이었다. 문장 경계에서 잘리면 그 자리의
525
+ * 마침표가 그대로 남아, 지시문이 못 박은 「마침표 없이」 를 잘린 제목만 어겼다.
526
+ */
527
+ const 자른것 = 앞.slice(0, 자를자리).trim().replace(/[.。]+$/, '').trim();
528
+ return { 제목: 자른것, 남은것: t.slice(자를자리).trim() };
409
529
  }
410
530
 
411
531
  /** 모델이 못 만들었을 때 — 지어내는 대신 사실만 적는다. */
@@ -492,7 +612,8 @@ export async function 메시지짓기(session, { 뿌리, diff, 통계, 파일들
492
612
  // 본문만 달라고 했어도 작은 모델은 `제목:`/`본문:` 꼴을 그대로 흉내 낸다.
493
613
  // 그걸 그대로 본문에 넣으면 커밋 안에 '제목:' 이라는 줄이 남는다.
494
614
  const 갈린 = /^\s*제목\s*[::]/m.test(글) ? 답가르기(글) : null;
495
- return { 제목, 본문: 갈린?.본문 ?? 껍질벗기기(글) };
615
+ // `제목:` 없이 `본문:` 표식만 붙여 답하기도 한다 — 그 표식도 떼야 커밋 첫 줄에 안 남는다 (6회차 커밋 C9).
616
+ return { 제목, 본문: 갈린?.본문 ?? 껍질벗기기(글).replace(/^\s*본문\s*[::]\s*/, '') };
496
617
  }
497
618
  return 답가르기(글);
498
619
  } catch (err) {
@@ -516,13 +637,21 @@ export async function 커밋준비(session, ctx, { 전부 = false, 제목 = null
516
637
 
517
638
  const 미리담긴 = 담긴것(뿌리).파일들; // 남이 먼저 담아 둔 것. 풀지 않고 알리기만 한다.
518
639
  const 내것 = 이번에바꾼것(session, 뿌리);
640
+ // 딴 저장소 안만 바꿨으면 「바꾼 파일이 없다」 가 아니다 — 어디서 커밋해야 하는지 말한다 (6회차 커밋 C2).
641
+ if (!전부 && !내것.length && !미리담긴.length && 내것.딴저장소.length) {
642
+ return { ok: false, why: `딴 저장소(${내것.딴저장소.slice(0, 4).join(', ')}) 안에서만 바꿨습니다 — 서브모듈·안쪽 저장소는 그 폴더에서 따로 커밋하세요.` };
643
+ }
644
+ // 만들었다 지운 것뿐이면 「바꾼 파일이 없다」 가 아니다 — 무엇을 왜 못 담는지 말한다 (8회차 커밋2).
645
+ if (!전부 && !내것.length && !미리담긴.length && 내것.모르는이름.length) {
646
+ return { ok: false, why: `이번 대화가 만들었다 지운 것(${내것.모르는이름.slice(0, 4).join(', ')})뿐입니다 — git 이 한 번도 본 적 없는 이름이라 담을 것이 없습니다.` };
647
+ }
519
648
  if (!전부 && !내것.length && !미리담긴.length) {
520
649
  return { ok: false, why: '이번 대화에서 바꾼 파일이 없습니다 — 작업 폴더 전부를 담으려면 `/commit 전부`.' };
521
650
  }
522
651
 
523
652
  // `전부` 가 미칠 자리 = 작업 폴더. 저장소 뿌리가 아니다 (담기() 머리말).
524
653
  const 안쪽 = relative(뿌리, 여기).replace(/\\/g, '/');
525
- const 살림바뀜 = !!깃(뿌리, ['status', '--short', '--', 안쪽 ? `${안쪽}/${살림폴더}` : 살림폴더]).out.trim();
654
+ const 살림바뀜 = !!깃(뿌리, ['status', '--short', '--', 글자그대로(안쪽 ? `${안쪽}/${살림폴더}` : 살림폴더)]).out.trim();
526
655
  const 담은결과 = 담기(뿌리, 내것, { 전부, 안쪽 });
527
656
  if (!담은결과.ok) return { ok: false, why: `담지 못했습니다 — ${(담은결과.err || '').trim() || 'git add 실패'}` };
528
657
 
@@ -558,7 +687,20 @@ export async function 커밋준비(session, ctx, { 전부 = false, 제목 = null
558
687
 
559
688
  const 사실 = !지은것;
560
689
  const { 제목: 날제목, 본문: 날본문 } = 지은것 ?? 사실로만(파일들, 통계);
561
- const 다듬 = 제목다듬기(제목 ?? 날제목);
690
+ /*
691
+ * 모델이 지은 제목에도 꼬리표를 건다 (8회차 커밋 2).
692
+ *
693
+ * 본문은 메시지꾸리기() 가 거르는데 제목만 맨몸으로 나갔다. 모델이
694
+ * `Fixes #123` 한 줄을 제목으로 주면 그게 커밋 첫 줄이 되고, 그 커밋이 기본
695
+ * 가지에 실리는 순간 **깃허브가 그 이슈를 진짜로 닫는다** — 지어낸 번호면 남의
696
+ * 이슈가 닫히고, 커밋을 되돌려도 닫힌 이슈는 안 열린다.
697
+ * 걸러 내고 남는 것이 없으면 지어낸 제목 대신 사실만 적는다.
698
+ *
699
+ * 사람이 제 손으로 준 제목(`제목`)은 안 건드린다 — 그건 사람 뜻이고, 화면에
700
+ * 미리 보여 준 것과 찍히는 것이 달라지면 승인을 받은 뜻이 없어진다.
701
+ */
702
+ const 고른제목 = 제목 ?? (꼬리표걸러내기(날제목) || 사실로만(파일들, 통계).제목);
703
+ const 다듬 = 제목다듬기(고른제목);
562
704
  if (!다듬.제목) return { ok: false, why: '커밋 제목을 만들지 못했습니다.' };
563
705
  const 본문 = [다듬.남은것, 날본문].filter(Boolean).join('\n\n');
564
706
 
@@ -587,6 +729,16 @@ export async function 커밋준비(session, ctx, { 전부 = false, 제목 = null
587
729
  살림뺌: 살림바뀜 || 뺀것.샌것.length > 0,
588
730
  링크로샌것: 뺀것.샌것,
589
731
  폴더통째: 전부 ? [] : (내것.폴더 ?? []),
732
+ // 서브모듈·안쪽 저장소 안에서 바뀐 것 — 안 담았다고 화면이 말한다 (6회차 커밋 C2).
733
+ 딴저장소: 전부 ? [] : (내것.딴저장소 ?? []),
734
+ /*
735
+ * 만들었다 지워서 git 이 끝내 못 본 이름 — 조용히 빼면 사람은 담긴 줄 안다 (8회차 커밋2).
736
+ *
737
+ * 담긴 것에 이미 들어 있는 이름은 여기서 뺀다. 삭제가 **먼저 담겨 있던**
738
+ * 파일은 index 에서도 이름이 사라져 위 그물에 같이 걸리는데, 그건 이번
739
+ * 커밋에 제대로 실린다 — 실린 것을 「안 담았습니다」 라고 말하면 그것도 거짓이다.
740
+ */
741
+ 모르는이름: 전부 ? [] : (내것.모르는이름 ?? []).filter((f) => !파일들.includes(f)),
590
742
  // git 이 답을 못 한 것. 화면이 그대로 말한다 — 못 본 것을 안 본 척하면
591
743
  // 메시지가 무엇을 근거로 쓰였는지가 사라진다.
592
744
  diff못읽음,
@@ -257,9 +257,26 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN,
257
257
  * 캐시를 지키려고 만든 단계가 안 돈 것이다.
258
258
  */
259
259
  const 이름표 = new Map();
260
- for (const m of ms) {
261
- for (const c of 부른것들(m)) if (c.id) 이름표.set(c.id, { name: c.name ?? '도구', args: c.args ?? {} });
262
- }
260
+ /*
261
+ * id 가 없는 규격(Ollama)은 결과가 부름을 **차례로** 짝짓는다. id 로만 찾으면 그 규격에서는
262
+ * 인자가 늘 비어서 경로를 못 뽑았다 — 접힌 파일의 기억이 안 지워지고, 다시 읽으면
263
+ * 「앞에서 읽은 그대로입니다」 만 돌아갔다. 바로 앞 부름들을 차례대로 맞춘다.
264
+ */
265
+ const 차례짝 = new Map(); // `${메시지 자리}:${결과 자리}` → 부름
266
+ let 남은부름 = [];
267
+ ms.forEach((m, i) => {
268
+ const 부름 = 부른것들(m);
269
+ if (부름.length) {
270
+ for (const c of 부름) if (c.id) 이름표.set(c.id, { name: c.name ?? '도구', args: c.args ?? {} });
271
+ 남은부름 = [...부름];
272
+ return;
273
+ }
274
+ 결과들(m).forEach((x, k) => {
275
+ const j = x?.id ? 남은부름.findIndex((c) => c.id === x.id) : -1;
276
+ if (j >= 0) { 남은부름.splice(j, 1); return; }
277
+ if (!x?.id && 남은부름.length) 차례짝.set(`${i}:${k}`, 남은부름.shift());
278
+ });
279
+ });
263
280
 
264
281
  const 자리 = [];
265
282
  ms.forEach((m, i) => {
@@ -279,7 +296,7 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN,
279
296
  * 접힌 파일을 다시 읽을 때 「앞에서 읽은 그대로입니다」 만 돌아온다.
280
297
  * 같은 것을 두 번 뽑을 까닭이 없다 — 한 번 뽑아서 같이 들고 간다.
281
298
  */
282
- const 아는것 = 이름표.get(첫?.id) ?? null;
299
+ const 아는것 = 이름표.get(첫?.id) ?? 차례짝.get(`${i}:0`) ?? null;
283
300
  /*
284
301
  * 이름은 **한 메시지에 실린 것 전부**를 들고 간다.
285
302
  *
@@ -292,11 +309,13 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN,
292
309
  * 아래 머리말이 「접는 순간 남은 항목이 어디에도 없어진다」 고 적어 둔
293
310
  * 바로 그 일이, 도구를 둘 같이 부른 판에서만 조용히 일어났다 (35차 리뷰).
294
311
  */
295
- const 이름들 = 것들.map((x) => 이름표.get(x?.id)?.name ?? x?.name ?? '').filter(Boolean);
312
+ const 이름들 = 것들.map((x, k) => 이름표.get(x?.id)?.name ?? 차례짝.get(`${i}:${k}`)?.name ?? x?.name ?? '').filter(Boolean);
296
313
  자리.push({
297
314
  i, 글, 이름들,
298
315
  이름: 아는것?.name ?? 첫?.name ?? '',
299
316
  args: 아는것?.args ?? {},
317
+ // 한 메시지에 결과가 여럿이면 인자도 여럿이다 — 경로를 다 뽑아야 파일 기억을 다 지운다.
318
+ args들: 것들.map((x, k) => (이름표.get(x?.id) ?? 차례짝.get(`${i}:${k}`))?.args).filter(Boolean),
300
319
  });
301
320
  });
302
321
 
@@ -387,7 +406,7 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN,
387
406
  // 사람 화면에는 여기서 돌려주는 목록으로 알려 준다.
388
407
  const 접은것들 = [];
389
408
 
390
- for (const { i, 글, 이름, args } of 접을것) {
409
+ for (const { i, 글, 이름, args, args들 } of 접을것) {
391
410
  const m = ms[i];
392
411
  const 도구이름 = 이름 || '도구';
393
412
  const 곳 = 어디(args);
@@ -413,10 +432,16 @@ export function foldToolResults(session, { keep = KEEP_RECENT, min = FOLD_MIN,
413
432
  * 긴 경로가 안 맞아서 조용히 안 지워진다 — 그러면 접힌 파일을 다시
414
433
  * 읽을 때 「앞에서 읽은 그대로입니다」 만 돌아간다(agent/filemem.js).
415
434
  */
416
- const 경로 = typeof args?.file_path === 'string'
417
- ? args.file_path
418
- : (typeof args?.path === 'string' ? args.path : null);
419
- 접은것들.push({ 도구: 도구이름, 곳, 경로, 줄수, 토큰: Math.max(0, 아낀것) });
435
+ const 경로뽑기 = (a) => (typeof a?.file_path === 'string'
436
+ ? a.file_path
437
+ : (typeof a?.path === 'string' ? a.path : null));
438
+ const 경로 = 경로뽑기(args);
439
+ /*
440
+ * 한 메시지에 결과가 둘 실리면(Anthropic) 첫 결과의 경로만 들고 가서, 두 번째 파일은
441
+ * 내용이 접혔는데 기억이 남았다 — 다시 읽으면 「앞에서 읽은 그대로입니다」 (2.0.0 2차 리뷰).
442
+ */
443
+ const 경로들 = [...new Set([경로, ...(args들 ?? []).map(경로뽑기)].filter(Boolean))];
444
+ 접은것들.push({ 도구: 도구이름, 곳, 경로, 경로들, 줄수, 토큰: Math.max(0, 아낀것) });
420
445
  }
421
446
 
422
447
  /*
@@ -521,9 +546,19 @@ function transcript(msgs) {
521
546
  */
522
547
  const 결과인가 = 도구결과인가(m);
523
548
  const who = 결과인가 ? '도구결과' : (m.role === 'user' ? '사용자' : m.role === 'assistant' ? '나' : '도구결과');
549
+ /*
550
+ * 할 말이 없는 걸음은 **빈 말로 둔다.**
551
+ *
552
+ * 도구만 부른 걸음은 `content: null` 로 온다(규격 그대로). 그런데
553
+ * `JSON.stringify(m.content ?? '')` 는 그것을 빈 글이 아니라 따옴표 두
554
+ * 개짜리 **글자열**로 바꿔서, 아래 비었는지 보는 문을 그냥 지나갔다.
555
+ * 그래서 걸음마다 `나: ""` 가 한 줄씩 꼈다 — 자리를 먹는 것보다, 모델이
556
+ * 그것을 읽을 것이 있는 줄로 본다는 쪽이 나쁘다. 요약은 대화를 대신하게
557
+ * 될 글이라, 여기 낀 잡음이 그대로 대화가 된다.
558
+ */
524
559
  let body = 결과인가
525
560
  ? 결과들(m).map((x) => x.글).join('\n')
526
- : (typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? ''));
561
+ : (typeof m.content === 'string' ? m.content : (m.content == null ? '' : JSON.stringify(m.content)));
527
562
  if (결과인가) body = body.slice(0, 400);
528
563
  const calls = 부른것들(m).map((t) => `${t.name}(${JSON.stringify(t.args ?? {}).slice(0, 160)})`);
529
564
  if (calls.length) out.push(`${who}: [도구] ${calls.join(', ')}`);
@@ -600,6 +635,43 @@ export async function compact(session, { auto = false, signal = null, onBackoff
600
635
  summary = null;
601
636
  }
602
637
 
638
+ /*
639
+ * ── 접을 대화보다 큰 요약은 받지 않는다 (사냥5 L5-8) ─────────────────────
640
+ *
641
+ * 요약은 maxTokens 1200 으로 부르지만 그 값을 무시하는 서버가 있다. 위에서는 잘렸나·
642
+ * 거절인가만 봐서, 접을 대화보다 **큰** 요약도 ok:true 로 끼워 넣었다. 8k 창에서
643
+ * 10,762 → 19,935 토큰으로 부풀었고, shouldCompact 가 그대로 참이라 다음 걸음에서
644
+ * 또 요약을 부르고 또 부푼다. 접기는 자리를 내려고 하는 일이다 — 자리를 못 내면
645
+ * 요약이 아무리 멀쩡해도 실패다.
646
+ *
647
+ * 끼워 넣을 모양을 한 번 만들어 재 보고, 줄지 않으면 되돌리고 그냥 줄이기로 물러선다.
648
+ * 재는 동안만 바꿔 끼운다. breakdown() 은 부를 때마다 messages 에서 새로 세고,
649
+ * 쪽지 표시(박은쪽지표시)는 받기로 정한 뒤에만 한다.
650
+ */
651
+ let 쪽지 = '';
652
+ let 요약말 = null;
653
+ if (summary) {
654
+ 쪽지 = 못박을것(session);
655
+ 요약말 = {
656
+ role: 'user',
657
+ content: `[앞선 대화 ${parts.fold.length}개를 요약해 접었습니다. 아래가 그 요약입니다.]\n\n${summary}\n\n`
658
+ + 쪽지
659
+ + '[요약 끝. 이어서 진행하세요. 파일 내용이 필요하면 다시 읽으세요.]',
660
+ };
661
+ const 옛말들 = session.messages;
662
+ let 접은뒤 = before;
663
+ try {
664
+ session.messages = [...parts.head, 요약말, ...parts.tail];
665
+ 접은뒤 = session.breakdown().used;
666
+ } finally {
667
+ session.messages = 옛말들;
668
+ }
669
+ if (접은뒤 >= before) {
670
+ 못한까닭 = `요약이 접을 대화보다 커서(${before.toLocaleString('en-US')} → ${접은뒤.toLocaleString('en-US')}토큰) 버렸습니다`;
671
+ summary = null;
672
+ }
673
+ }
674
+
603
675
  // 요약을 못 받았으면 옛 방식으로 물러선다. 멈추지는 않는다.
604
676
  if (!summary) {
605
677
  const folded = session.trim();
@@ -628,16 +700,16 @@ export async function compact(session, { auto = false, signal = null, onBackoff
628
700
  };
629
701
  }
630
702
 
631
- session.messages = [
632
- ...parts.head,
633
- {
634
- role: 'user',
635
- content: `[앞선 대화 ${parts.fold.length}개를 요약해 접었습니다. 아래가 그 요약입니다.]\n\n${summary}\n\n`
636
- + 못박을것(session)
637
- + '[요약 끝. 이어서 진행하세요. 파일 내용이 필요하면 다시 읽으세요.]',
638
- },
639
- ...parts.tail,
640
- ];
703
+ // 요약말과 쪽지는 위 크기 재기에서 이미 만들었다.
704
+ /*
705
+ * 이 요약에 **어느 턴의** 시킨 말을 박았는지 적어 둔다 (session.js 의 박은쪽지표시).
706
+ *
707
+ * 요약은 그 턴의 자리표보다 앞에 놓인다. 그래서 그 턴을 /undo 로 되감으면
708
+ * 자리표부터 뒤만 걷히고, 요약 속 「이번에 시킨 말 — 빠짐없이 하세요」 는
709
+ * 남아서 다음 턴에 **되돌린 일**을 다시 시켰다. 되감기가 이 표를 보고 그 쪽지만 뺀다.
710
+ */
711
+ session.박은쪽지표시?.(요약말, 쪽지);
712
+ session.messages = [...parts.head, 요약말, ...parts.tail];
641
713
  session.filesRead.clear(); // 접힌 뒤에는 읽어 둔 파일도 기억에서 지운다
642
714
  /*
643
715
  * 들고 있던 파일 내용도 같이 버린다.
@@ -43,7 +43,7 @@
43
43
  * ── 여기 없는 것 ────────────────────────────────────────────────────────
44
44
  *
45
45
  * 파일에서 읽은 것이 **애초에 우리가 적은 모양이 맞나**는 여기서 안 본다
46
- * (agent/성한기록.js). 통계와 문지기는 다른 일이고, 섞어 두면 통계 문턱을
46
+ * (agent/recordshape.js). 통계와 문지기는 다른 일이고, 섞어 두면 통계 문턱을
47
47
  * 만질 때마다 문지기도 같이 흔들린다.
48
48
  *
49
49
  * ── 안 하는 것 ──────────────────────────────────────────────────────────