deel-local-cli 1.5.7 → 1.6.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 (57) hide show
  1. package/README.en.md +24 -13
  2. package/README.md +23 -12
  3. package/bin/deel.js +10 -0
  4. package/package.json +2 -2
  5. package/src/acp/map.js +143 -0
  6. package/src/acp/serve.js +160 -13
  7. package/src/agent/commit.js +511 -0
  8. package/src/agent/compact.js +269 -226
  9. package/src/agent/loop.js +154 -12
  10. package/src/agent/mention.js +56 -10
  11. package/src/agent/review.js +192 -0
  12. package/src/agent/session.js +20 -2
  13. package/src/agent/threads.js +1 -1
  14. package/src/backend/adapter.js +342 -280
  15. package/src/backend/azure.js +151 -0
  16. package/src/backend/ctxsize.js +19 -0
  17. package/src/backend/detect.js +106 -0
  18. package/src/backend/http.js +350 -30
  19. package/src/backend/probe.js +48 -1
  20. package/src/backend/proxy.js +151 -0
  21. package/src/backend/quota.js +133 -0
  22. package/src/backend/retry.js +132 -0
  23. package/src/backend/vision.js +185 -0
  24. package/src/commands.js +341 -8
  25. package/src/completion.js +264 -0
  26. package/src/config.js +117 -1
  27. package/src/i18n/en.js +13 -1
  28. package/src/i18n/index.js +22 -3
  29. package/src/i18n/ja.js +266 -0
  30. package/src/i18n/ko.js +12 -0
  31. package/src/i18n/zh.js +266 -0
  32. package/src/oneshot.js +28 -3
  33. package/src/pack/sbom.js +25 -0
  34. package/src/pack/selfpack.js +9 -3
  35. package/src/plugins/manage.js +34 -7
  36. package/src/repl.js +113 -15
  37. package/src/report.js +26 -2
  38. package/src/safety/guard.js +82 -3
  39. package/src/safety/keystore.js +237 -0
  40. package/src/safety/network.js +76 -14
  41. package/src/safety/policy.js +209 -0
  42. package/src/safety/secrets.js +32 -0
  43. package/src/setup.js +40 -7
  44. package/src/tools/clipboard.js +178 -0
  45. package/src/tools/fastgrep.js +217 -0
  46. package/src/tools/fsutil.js +55 -12
  47. package/src/tools/ignore.js +192 -0
  48. package/src/tools/index.js +282 -39
  49. package/src/tools/jobs.js +4 -12
  50. package/src/tools/outline.js +3 -1
  51. package/src/tools/pdf.js +1326 -0
  52. package/src/tools/shell.js +123 -0
  53. package/src/tools/verify.js +20 -4
  54. package/src/tools/webfetch.js +18 -5
  55. package/src/ui/inputbox.js +2 -2
  56. package/src/ui/screen.js +4 -1
  57. package/src/ui/status.js +22 -0
@@ -0,0 +1,511 @@
1
+ // /commit — 이번 대화가 바꾼 것을 저장소에 남긴다.
2
+ //
3
+ // ── 왜 명령으로 두나 ────────────────────────────────────────────────────
4
+ //
5
+ // 일은 결국 git 을 거쳐야 남는다. 그런데 지금까지 그 마지막 한 걸음은
6
+ // 모델이 Bash 로 `git commit -m "…"` 를 치는 것이었고, 거기서 세 가지가 샜다.
7
+ //
8
+ // 1) 따옴표. 윈도우 cmd 에서 큰따옴표 안의 `"` · `%` · 줄바꿈은 사람이
9
+ // 기대한 대로 안 들어간다. 여러 줄 메시지는 사실상 못 쓴다 — 그래서
10
+ // 메시지가 한 줄로 쪼그라들고, 왜 고쳤는지가 사라진다.
11
+ // 2) 무엇을 담을지. `git add -A` 는 이번 대화와 상관없는 남의 변경까지
12
+ // 쓸어 담는다. 사람이 딴 창에서 고치던 파일이 남의 커밋에 실린다.
13
+ // 3) 증거. deel 은 무엇을 고쳤고 무엇으로 확인했는지를 이미 알고 있는데
14
+ // (evidence.js), 커밋 메시지에는 그게 한 글자도 안 들어갔다.
15
+ //
16
+ // 그래서 여기서 셋을 다 맡는다. 메시지는 파일로 넘기고(-F), 담는 것은 이번
17
+ // 대화가 건드린 파일뿐이고, 확인 안 된 것이 있으면 메시지에 그렇게 적는다.
18
+ //
19
+ // ── 안 하는 것 ──────────────────────────────────────────────────────────
20
+ //
21
+ // 절대로 push 하지 않는다. 되돌릴 수 있는 자리(로컬 커밋)와 못 되돌리는
22
+ // 자리(남이 보는 곳) 사이에 사람이 한 번은 있어야 한다.
23
+ // 남이 미리 담아 둔 것(index)을 풀지도 않는다 — 남의 준비를 말없이 흩는
24
+ // 것이 커밋을 하나 더 만드는 것보다 나쁘다. 대신 화면에 같이 적는다.
25
+ import { spawnSync } from 'node:child_process';
26
+ import { writeFileSync, unlinkSync, realpathSync, statSync } from 'node:fs';
27
+ import { join, relative, isAbsolute } from 'node:path';
28
+ import { tmpdir } from 'node:os';
29
+ import { randomBytes } from 'node:crypto';
30
+ import { chat } from '../backend/adapter.js';
31
+ import { 증거모으기 } from './evidence.js';
32
+ import { VERSION } from '../version.js';
33
+
34
+ /** 제목 길이 상한. git 관례(50~72)의 넉넉한 쪽. 한글은 글자 수로 센다. */
35
+ export const 제목상한 = 72;
36
+
37
+ /** 모델에게 보여 줄 diff 길이 상한(글자). 넘으면 자르고 잘랐다고 적는다. */
38
+ export const DIFF상한 = 12000;
39
+
40
+ /**
41
+ * git 을 부른다. 셸을 안 거친다 — 따옴표 사고가 나는 자리가 여기다.
42
+ *
43
+ * @returns {{ok:boolean, code:number, out:string, err:string, 없음?:boolean}}
44
+ */
45
+ export function 깃(root, 인자들, { 입력 = null } = {}) {
46
+ let r;
47
+ try {
48
+ /*
49
+ * quotepath 를 끈다.
50
+ *
51
+ * git 은 기본으로 한글 파일 이름을 `"\353\202\250…"` 같은 8진수 이스케이프로
52
+ * 내놓는다. 그 글자를 그대로 화면에 내면 사람은 제 파일을 못 알아보고,
53
+ * 그대로 다시 `git add` 에 넣으면 **없는 파일**을 담으라는 뜻이 된다.
54
+ * 한국어 저장소에서는 이게 예외가 아니라 보통이다.
55
+ */
56
+ r = spawnSync('git', ['-c', 'core.quotepath=false', ...인자들], {
57
+ cwd: root,
58
+ encoding: 'utf8',
59
+ input: 입력 ?? undefined,
60
+ windowsHide: true,
61
+ maxBuffer: 64 * 1024 * 1024,
62
+ });
63
+ } catch (err) {
64
+ return { ok: false, code: -1, out: '', err: err.message, 없음: err.code === 'ENOENT' };
65
+ }
66
+ if (r.error) return { ok: false, code: -1, out: '', err: r.error.message, 없음: r.error.code === 'ENOENT' };
67
+ return { ok: r.status === 0, code: r.status ?? -1, out: r.stdout ?? '', err: r.stderr ?? '' };
68
+ }
69
+
70
+ /** git 이 이 PC 에 있나. 없으면 명령 자체를 안 시작한다. */
71
+ export function 깃있나(root = process.cwd()) {
72
+ const r = 깃(root, ['--version']);
73
+ return r.ok && /git version/i.test(r.out);
74
+ }
75
+
76
+ /** 여기가 저장소인가. 맞으면 그 뿌리를 준다 (하위 폴더에서 불러도 된다). */
77
+ export function 저장소뿌리(root) {
78
+ const r = 깃(root, ['rev-parse', '--show-toplevel']);
79
+ if (!r.ok) return null;
80
+ const p = r.out.trim();
81
+ return p ? p.replace(/\//g, process.platform === 'win32' ? '\\' : '/') : null;
82
+ }
83
+
84
+ /*
85
+ * deel 자신의 살림은 절대 안 담는다.
86
+ *
87
+ * `.deel/config.json` 에 **게이트웨이 열쇠**가 들어 있다. `/commit 전부` 가
88
+ * `git add -A` 를 그대로 하면 그 열쇠가 커밋에 실리고, 한 번 실린 열쇠는
89
+ * 되돌린 뒤에도 이력에 남는다. 그다음이 push 면 끝이다.
90
+ * `.deel/audit.jsonl`(무엇을 언제 했는지)도 남의 저장소에 갈 것이 아니다.
91
+ */
92
+ export const 살림폴더 = '.deel';
93
+
94
+ /*
95
+ * 살림을 가리는 자를 세 겹으로 둔다. 한 겹은 반드시 뚫린다.
96
+ *
97
+ * 1) 경로 글자 — 어느 깊이에 있든, 대소문자가 어떻든 (`packages/x/.DEEL/…`)
98
+ * 2) git 패스스펙 — 담을 때 아예 빼 달라고 git 에게도 말한다
99
+ * 3) 진짜 자리 — 담긴 뒤 realpath 로 다시 본다. 이름이 다른 링크
100
+ * (`alias\ → .deel`)는 앞의 둘을 그냥 지나간다.
101
+ *
102
+ * 열쇠가 한 번 커밋에 실리면 되돌려도 이력에 남는다. 되돌릴 수 없는 것 앞에서는
103
+ * "설마" 를 쓰지 않는다.
104
+ */
105
+ const 살림꼴 = /(^|[\\/])\.deel([\\/]|$)/i;
106
+
107
+ /** 이 경로가 살림을 가리키나 — 글자로 본다. */
108
+ export function 살림경로인가(rel) { return 살림꼴.test(String(rel ?? '')); }
109
+
110
+ /** 이 경로가 **진짜로** 살림 안에 닿나 — 링크를 다 풀고 본다. */
111
+ export function 살림에닿나(abs) {
112
+ try { return 살림꼴.test(realpathSync(abs)); }
113
+ catch { return 살림경로인가(abs); } // 못 풀면(지워진 파일 등) 글자로만 본다
114
+ }
115
+
116
+ /** 담을 때 git 에게도 빼 달라고 하는 자리. 어느 깊이든, 대소문자 상관없이. */
117
+ const 살림빼기 = [':(exclude,icase,glob)**/.deel/**', ':(exclude,icase,glob)**/.deel'];
118
+
119
+ /**
120
+ * 링크를 다 푼 진짜 자리. 못 풀면(아직 없는 파일 등) 준 것을 그대로.
121
+ *
122
+ * 저장소 뿌리를 링크로 열어 둔 사람이 있다(윈도우 junction, 맥의 /tmp).
123
+ * git 은 링크를 풀어서 답하고 우리는 안 풀면, 같은 자리를 두 이름으로 부르게
124
+ * 되어 "이번에 바꾼 것이 없습니다" 가 된다 — 바꾼 것이 있는데도.
125
+ */
126
+ function 진짜(p) {
127
+ try { return realpathSync(p); } catch { return p; }
128
+ }
129
+
130
+ /**
131
+ * 이번 대화가 건드린 파일 — 저장소 안의 것만, 저장소 기준 상대경로로.
132
+ *
133
+ * 폴더는 안 담는다. `Move` 로 폴더를 옮기면 **닿은 폴더**가 바뀐 것으로 적히는데,
134
+ * 그 폴더 안에는 남이 고치던 파일도 같이 산다. 그걸 그대로 담으면 이 명령이
135
+ * 없애려던 `git add -A` 사고를 이름만 바꿔 다시 내는 셈이다. 안 담은 폴더는
136
+ * 목록으로 돌려주고, 화면이 그렇게 말한다.
137
+ */
138
+ export function 이번에바꾼것(session, 뿌리) {
139
+ const 기준 = 진짜(뿌리);
140
+ const 것들 = [];
141
+ const 폴더들 = [];
142
+ for (const p of (session?.changes?.keys?.() ?? [])) {
143
+ const abs = 진짜(isAbsolute(p) ? p : join(기준, p));
144
+ const rel = relative(기준, abs).replace(/\\/g, '/');
145
+ // 저장소 밖은 담지 않는다. `..` 로 시작하면 밖이다.
146
+ if (!rel || rel.startsWith('../') || rel === '..') continue;
147
+ if (살림경로인가(rel) || 살림에닿나(abs)) continue;
148
+ let 폴더인가 = false;
149
+ try { 폴더인가 = statSync(abs).isDirectory(); } catch { /* 지워진 것은 파일로 친다 */ }
150
+ if (폴더인가) { if (!폴더들.includes(rel)) 폴더들.push(rel); continue; }
151
+ if (!것들.includes(rel)) 것들.push(rel);
152
+ }
153
+ 것들.sort();
154
+ Object.defineProperty(것들, '폴더', { value: 폴더들.sort(), enumerable: false });
155
+ return 것들;
156
+ }
157
+
158
+ /**
159
+ * 담는다.
160
+ *
161
+ * `-A` 를 경로와 함께 쓴다 — 그래야 지운 파일도 '지웠다' 로 담긴다.
162
+ * 경로 없이 쓰면 남의 변경까지 쓸어 담는데, 그건 `전부` 를 시켰을 때만 한다.
163
+ */
164
+ export function 담기(뿌리, 경로들, { 전부 = false, 안쪽 = '' } = {}) {
165
+ /*
166
+ * `전부` 는 **작업 폴더 전부**지 저장소 전부가 아니다.
167
+ *
168
+ * 큰 저장소의 하위 폴더에서 deel 을 켜는 것은 흔한 일이다(모노레포). 그때
169
+ * 저장소 뿌리에 대고 `git add -A` 를 하면, 옆 팀 폴더와 그 안의 .env 까지
170
+ * 담기고 — 그 내용이 커밋 메시지를 지으러 모델에게도 나간다. 사람은
171
+ * "이 폴더에서 일하는 중" 이라고 알고 있었다.
172
+ */
173
+ if (전부) return 깃(뿌리, ['add', '-A', '--', 안쪽 || '.', ...살림빼기]);
174
+ if (!경로들.length) return { ok: true, code: 0, out: '', err: '' };
175
+ return 깃(뿌리, ['add', '-A', '--', ...경로들, ...살림빼기]);
176
+ }
177
+
178
+ /**
179
+ * 담긴 것 중 진짜로 살림에 닿는 것을 도로 뺀다.
180
+ *
181
+ * 이름이 다른 링크는 글자로도 패스스펙으로도 안 걸린다. 여기서 realpath 로
182
+ * 한 번 더 본다. 못 빼면 커밋을 아예 안 한다 — 열쇠가 실릴 바에는 안 되는
183
+ * 편이 낫다.
184
+ *
185
+ * @returns {{샌것: string[], 못뺀것: string[]}}
186
+ */
187
+ export function 살림도로빼기(뿌리, 파일들) {
188
+ const 샌것 = 파일들.filter((f) => 살림에닿나(join(뿌리, f)));
189
+ if (!샌것.length) return { 샌것, 못뺀것: [] };
190
+ // 첫 커밋 전이면 HEAD 가 없어 restore 가 안 된다. 그때는 index 에서 지운다.
191
+ const r = 깃(뿌리, ['restore', '--staged', '--', ...샌것]);
192
+ if (!r.ok) 깃(뿌리, ['rm', '--cached', '-q', '-r', '--', ...샌것]);
193
+ const 아직 = 담긴것(뿌리).파일들.filter((f) => 살림에닿나(join(뿌리, f)));
194
+ return { 샌것, 못뺀것: 아직 };
195
+ }
196
+
197
+ /** 지금 담겨 있는 것. */
198
+ export function 담긴것(뿌리) {
199
+ const 이름 = 깃(뿌리, ['diff', '--cached', '--name-only']);
200
+ const 통계 = 깃(뿌리, ['diff', '--cached', '--stat']);
201
+ const 몸통 = 깃(뿌리, ['diff', '--cached']);
202
+ const 파일들 = 이름.out.split('\n').map((x) => x.trim()).filter(Boolean);
203
+ return { 파일들, 통계: 통계.out.trimEnd(), diff: 몸통.out };
204
+ }
205
+
206
+ /** 저장소가 쓰던 말투를 흉내 내라고 최근 제목을 보여 준다. */
207
+ export function 최근제목들(뿌리, n = 10) {
208
+ const r = 깃(뿌리, ['log', `-${n}`, '--format=%s']);
209
+ if (!r.ok) return []; // 첫 커밋이면 로그가 없다 — 흉내낼 것이 없을 뿐이다
210
+ return r.out.split('\n').map((x) => x.trim()).filter(Boolean);
211
+ }
212
+
213
+ const 스키마 = {
214
+ type: 'object',
215
+ properties: { 제목: { type: 'string' }, 본문: { type: 'string' } },
216
+ required: ['제목', '본문'],
217
+ additionalProperties: false,
218
+ };
219
+
220
+ const 지시 = `너는 지금 준비된 변경(staged diff)을 보고 git 커밋 메시지를 쓴다.
221
+
222
+ 규칙:
223
+ - 제목 한 줄, 그다음 본문. 제목은 ${제목상한}자 이내, 마침표 없이.
224
+ - 저장소의 최근 제목들과 같은 말투를 쓴다. 그쪽이 conventional commits(feat:·fix:)면 따르고, 아니면 따르지 마라.
225
+ - 본문은 **무엇을 바꿨나**가 아니라 **왜 바꿨나**를 쓴다. 무엇을 바꿨는지는 diff 에 이미 있다.
226
+ - diff 에서 실제로 보이는 것만 써라. 안 돌린 검사를 돌렸다고 쓰지 마라.
227
+ - 없는 이슈 번호·이름을 지어내지 마라.
228
+ - 한국어로 써라(저장소의 최근 제목이 영어면 영어로).`;
229
+
230
+ const 답형식 = `아래 두 줄 형식으로만 답하라. 다른 말은 붙이지 마라.
231
+ 제목: <한 줄>
232
+ 본문:
233
+ <여러 줄>`;
234
+
235
+ /** 코드울타리·따옴표를 벗긴다. 작은 모델이 자주 씌운다. */
236
+ function 껍질벗기기(글) {
237
+ let s = String(글 ?? '').trim();
238
+ const 울타리 = s.match(/^```[a-z]*\n([\s\S]*?)\n?```$/i);
239
+ if (울타리) s = 울타리[1].trim();
240
+ return s;
241
+ }
242
+
243
+ /**
244
+ * 모델이 준 것을 제목과 본문으로 가른다.
245
+ *
246
+ * 세 가지 꼴을 다 받는다 — JSON, `제목:`/`본문:` 꼴, 그냥 줄글. 작은 모델은
247
+ * 시킨 형식을 자주 안 지키는데, 그때마다 실패로 치면 이 명령은 로컬에서
248
+ * 반도 안 된다. 못 알아볼 때만 실패로 친다.
249
+ */
250
+ export function 답가르기(글) {
251
+ const s = 껍질벗기기(글);
252
+ if (!s) return null;
253
+
254
+ if (s.startsWith('{')) {
255
+ try {
256
+ const j = JSON.parse(s);
257
+ const t = String(j.제목 ?? j.title ?? '').trim();
258
+ const b = String(j.본문 ?? j.body ?? '').trim();
259
+ if (t) return { 제목: t, 본문: b };
260
+ } catch { /* JSON 인 척한 것뿐이면 아래로 */ }
261
+ }
262
+
263
+ const 표 = s.match(/^\s*제목\s*[::]\s*(.+?)\s*$/m);
264
+ if (표) {
265
+ const 뒤 = s.slice(s.indexOf(표[0]) + 표[0].length);
266
+ const 본문표 = 뒤.match(/^\s*본문\s*[::]\s*/m);
267
+ const 본문 = 본문표 ? 뒤.slice(뒤.indexOf(본문표[0]) + 본문표[0].length) : 뒤;
268
+ return { 제목: 표[1].trim(), 본문: 본문.trim() };
269
+ }
270
+
271
+ const 줄들 = s.split('\n');
272
+ const 첫 = 줄들.findIndex((l) => l.trim());
273
+ if (첫 < 0) return null;
274
+ return { 제목: 줄들[첫].trim(), 본문: 줄들.slice(첫 + 1).join('\n').trim() };
275
+ }
276
+
277
+ /**
278
+ * 제목을 다듬는다. 넘치면 자르되 **버리지 않고** 본문 앞으로 넘긴다.
279
+ *
280
+ * 그냥 자르면 문장이 중간에서 끊긴 채로 영영 남는다. 커밋 제목은 나중에
281
+ * `git log --oneline` 에서 그 커밋을 찾는 유일한 단서라, 끊긴 자리에서
282
+ * 뜻이 뒤집히면(「…를 안 」) 아무도 못 찾는다.
283
+ */
284
+ /*
285
+ * 모델이 준 글에서 제어글자를 뺀다.
286
+ *
287
+ * 커밋 메시지는 **두 번 화면에 나간다** — 찍기 전 미리보기와, 나중에 누군가의
288
+ * `git log`. ESC 가 살아 있으면 그 두 자리에서 터미널이 그 글자를 명령으로
289
+ * 읽는다. 보이는 제목과 실제로 적히는 제목을 다르게 만들 수 있고(\x1b[8m 는
290
+ * 글자를 감춘다), 그렇게 적힌 것은 이력에 영영 남아 남의 터미널에서 다시 돈다.
291
+ * 줄바꿈과 탭만 남긴다.
292
+ */
293
+ export function 제어글자빼기(글) {
294
+ let 남길것 = '';
295
+ for (const 글자 of String(글 ?? '')) {
296
+ const 값 = 글자.codePointAt(0);
297
+ if (값 === 9 || 값 === 10) { 남길것 += 글자; continue; } // 탭·줄바꿈만 남긴다
298
+ if (값 < 32 || (값 >= 127 && 값 <= 159)) continue; // C0·C1 제어글자
299
+ 남길것 += 글자;
300
+ }
301
+ return 남길것;
302
+ }
303
+
304
+ /*
305
+ * 모델이 지어낸 꼬리표를 지운다.
306
+ *
307
+ * `Signed-off-by:` 는 사람이 "내가 이 코드에 책임진다" 고 적는 줄이다. 모델이
308
+ * 그 줄을 쓰면 없는 사람의 서명이 이력에 남고, 그걸 세는 도구들은 그것을
309
+ * 진짜로 읽는다. 우리 꼬리표(Generated-by)는 우리가 따로 붙이므로, 모델이
310
+ * 꼬리표를 쓸 이유가 아예 없다.
311
+ */
312
+ const 가짜꼬리표 = /^\s*(signed-off-by|co-authored-by|reviewed-by|acked-by|tested-by|generated-by|claude-session|closes|fixes)\s*:/i;
313
+
314
+ export function 꼬리표걸러내기(본문) {
315
+ return String(본문 ?? '')
316
+ .split('\n')
317
+ .filter((줄) => !가짜꼬리표.test(줄))
318
+ .join('\n')
319
+ .trim();
320
+ }
321
+
322
+ export function 제목다듬기(글) {
323
+ let t = 제어글자빼기(글).replace(/\s+/g, ' ').trim();
324
+ t = t.replace(/^["'`「『]+/, '').replace(/["'`」』]+$/, '').trim();
325
+ t = t.replace(/[.。]+$/, '').trim();
326
+ if (!t) return { 제목: '', 남은것: '' };
327
+ if ([...t].length <= 제목상한) return { 제목: t, 남은것: '' };
328
+ const 글자 = [...t];
329
+ const 앞 = 글자.slice(0, 제목상한).join('');
330
+ const 빈칸 = 앞.lastIndexOf(' ');
331
+ const 자를자리 = 빈칸 > 제목상한 * 0.5 ? 빈칸 : 앞.length;
332
+ return { 제목: 앞.slice(0, 자를자리).trim(), 남은것: t.slice(자를자리).trim() };
333
+ }
334
+
335
+ /** 모델이 못 만들었을 때 — 지어내는 대신 사실만 적는다. */
336
+ export function 사실로만(파일들, 통계) {
337
+ const 첫 = 파일들[0] ?? '변경';
338
+ const 제목 = 파일들.length > 1 ? `chore: ${첫} 외 ${파일들.length - 1}개 고침` : `chore: ${첫} 고침`;
339
+ const 본문 = [
340
+ '모델이 커밋 메시지를 만들지 못해, 바뀐 것만 그대로 적습니다.',
341
+ '',
342
+ 통계 || 파일들.map((f) => `- ${f}`).join('\n'),
343
+ ].join('\n');
344
+ return { 제목, 본문 };
345
+ }
346
+
347
+ /**
348
+ * 메시지 한 덩이로 꾸린다.
349
+ *
350
+ * 확인 안 된 것이 있으면 **본문에** 적는다. 커밋은 나중에 사람이 읽는
351
+ * 유일한 기록이고, "그때 검사를 돌렸던가" 는 그때 안 적으면 영영 모른다.
352
+ */
353
+ export function 메시지꾸리기({ 제목, 본문 = '', 확인 = 0, 미확인 = 0, 모델 = '', 버전 = VERSION }) {
354
+ const 몫 = [제어글자빼기(제목).trim()];
355
+ const b = 꼬리표걸러내기(제어글자빼기(본문));
356
+ if (b) 몫.push('', b);
357
+ if (미확인 > 0) 몫.push('', `검증: ${확인}건 확인 · ${미확인}건 미확인`);
358
+ 몫.push('', `Generated-by: deel ${버전}${모델 ? ` · ${모델}` : ''}`);
359
+ return `${몫.join('\n')}\n`;
360
+ }
361
+
362
+ /**
363
+ * 커밋 메시지를 짓는다. 지금 대화는 안 보낸다 — 담긴 diff 와 증거만 보낸다.
364
+ *
365
+ * 대화를 통째로 보내면 창을 두 번 먹고, 모델은 제가 한 말을 근거로 제
366
+ * 커밋 메시지를 쓰게 된다. 커밋에 실릴 것은 **코드가 말하는 것**이어야 한다.
367
+ */
368
+ export async function 메시지짓기(session, { 뿌리, diff, 통계, 파일들, 증거, 제목 = null, signal = null, onBackoff = null } = {}) {
369
+ const 자른diff = diff.length > DIFF상한
370
+ ? `${diff.slice(0, DIFF상한)}\n… (diff 가 길어 여기서 잘랐습니다 — 나머지는 통계로만 보세요)`
371
+ : diff;
372
+ const 최근 = 최근제목들(뿌리 ?? process.cwd());
373
+
374
+ const 몫 = [];
375
+ if (최근.length) 몫.push(`이 저장소의 최근 커밋 제목:\n${최근.map((x) => `- ${x}`).join('\n')}`);
376
+ if (증거) {
377
+ const 확인 = 증거.셈.파일 - 증거.셈.증명안됨;
378
+ 몫.push(`이번에 돌린 것: ${증거.셈.돌린것}개${증거.셈.실패한것 ? ` (실패 ${증거.셈.실패한것}개)` : ''}`
379
+ + `\n확인된 파일 ${확인}개 · 확인 안 된 파일 ${증거.셈.증명안됨}개`);
380
+ }
381
+ 몫.push(`바뀐 파일:\n${통계 || 파일들.map((f) => `- ${f}`).join('\n')}`);
382
+ 몫.push(`----- diff -----\n${자른diff}`);
383
+ if (제목) 몫.push(`제목은 이미 정해졌다: "${제목}"\n제목은 그대로 두고 본문만 써라.`);
384
+ 몫.push(제목 ? '본문만 답하라. 다른 말은 붙이지 마라.' : 답형식);
385
+
386
+ try {
387
+ const r = await chat(session.conn, {
388
+ messages: [
389
+ { role: 'system', content: 지시 },
390
+ { role: 'user', content: 몫.join('\n\n') },
391
+ ],
392
+ maxTokens: 700,
393
+ think: session.conn.kind === 'ollama' ? false : 'low',
394
+ json: 제목 ? null : (session.conn.json ? 스키마 : null),
395
+ signal,
396
+ onBackoff,
397
+ timeout: 60000,
398
+ });
399
+ const 글 = (r?.content ?? '').trim();
400
+ if (!글) return null;
401
+ if (제목) {
402
+ // 본문만 달라고 했어도 작은 모델은 `제목:`/`본문:` 꼴을 그대로 흉내 낸다.
403
+ // 그걸 그대로 본문에 넣으면 커밋 안에 '제목:' 이라는 줄이 남는다.
404
+ const 갈린 = /^\s*제목\s*[::]/m.test(글) ? 답가르기(글) : null;
405
+ return { 제목, 본문: 갈린?.본문 ?? 껍질벗기기(글) };
406
+ }
407
+ return 답가르기(글);
408
+ } catch (err) {
409
+ if (err?.name === 'Aborted' || signal?.aborted) return { 중단: true };
410
+ return null;
411
+ }
412
+ }
413
+
414
+ /**
415
+ * 커밋할 것을 준비한다. 화면은 안 그린다 — 부르는 쪽이 그린다.
416
+ *
417
+ * @returns {Promise<object>} ok:false 면 why 한 줄만 보고 끝내면 된다.
418
+ */
419
+ export async function 커밋준비(session, ctx, { 전부 = false, 제목 = null, signal = null, onBackoff = null } = {}) {
420
+ // 링크를 먼저 푼다. git 은 푼 자리로 답하므로, 우리도 같은 이름으로 말해야
421
+ // "바꾼 것이 없습니다" 라는 거짓말이 안 나온다.
422
+ const 여기 = 진짜(ctx?.scope?.root ?? session?.root ?? process.cwd());
423
+ if (!깃있나(여기)) return { ok: false, why: 'git 을 못 찾았습니다 — PATH 에 git 이 있어야 합니다.' };
424
+ const 뿌리 = 진짜(저장소뿌리(여기) ?? '');
425
+ if (!저장소뿌리(여기)) return { ok: false, why: '여기는 git 저장소가 아닙니다 — `git init` 부터 하세요.' };
426
+
427
+ const 미리담긴 = 담긴것(뿌리).파일들; // 남이 먼저 담아 둔 것. 풀지 않고 알리기만 한다.
428
+ const 내것 = 이번에바꾼것(session, 뿌리);
429
+ if (!전부 && !내것.length && !미리담긴.length) {
430
+ return { ok: false, why: '이번 대화에서 바꾼 파일이 없습니다 — 작업 폴더 전부를 담으려면 `/commit 전부`.' };
431
+ }
432
+
433
+ // `전부` 가 미칠 자리 = 작업 폴더. 저장소 뿌리가 아니다 (담기() 머리말).
434
+ const 안쪽 = relative(뿌리, 여기).replace(/\\/g, '/');
435
+ const 살림바뀜 = !!깃(뿌리, ['status', '--short', '--', 안쪽 ? `${안쪽}/${살림폴더}` : 살림폴더]).out.trim();
436
+ const 담은결과 = 담기(뿌리, 내것, { 전부, 안쪽 });
437
+ if (!담은결과.ok) return { ok: false, why: `담지 못했습니다 — ${(담은결과.err || '').trim() || 'git add 실패'}` };
438
+
439
+ // 이름이 다른 링크로 살림이 딸려 들어왔으면 여기서 도로 뺀다.
440
+ const 뺀것 = 살림도로빼기(뿌리, 담긴것(뿌리).파일들);
441
+ if (뺀것.못뺀것.length) {
442
+ return { ok: false, why: `열쇠가 든 자리(${뺀것.못뺀것.join(', ')})가 담긴 채로 안 빠집니다 — 커밋하지 않았습니다.` };
443
+ }
444
+
445
+ const { 파일들, 통계, diff } = 담긴것(뿌리);
446
+ if (!파일들.length) return { ok: false, why: '담을 것이 없습니다 — 바뀐 내용이 없습니다.' };
447
+
448
+ const 증거 = (() => { try { return 증거모으기(session, { audit: ctx?.audit }); } catch { return null; } })();
449
+ const 미확인 = 증거?.셈?.증명안됨 ?? 0;
450
+ const 확인 = 증거 ? 증거.셈.파일 - 미확인 : 0;
451
+
452
+ const 지은것 = await 메시지짓기(session, { 뿌리, diff, 통계, 파일들, 증거, 제목, signal, onBackoff });
453
+ if (지은것?.중단) return { ok: false, why: '중단했습니다 — 담긴 것은 그대로 둡니다.', aborted: true };
454
+
455
+ const 사실 = !지은것;
456
+ const { 제목: 날제목, 본문: 날본문 } = 지은것 ?? 사실로만(파일들, 통계);
457
+ const 다듬 = 제목다듬기(제목 ?? 날제목);
458
+ if (!다듬.제목) return { ok: false, why: '커밋 제목을 만들지 못했습니다.' };
459
+ const 본문 = [다듬.남은것, 날본문].filter(Boolean).join('\n\n');
460
+
461
+ const 메시지 = 메시지꾸리기({
462
+ 제목: 다듬.제목,
463
+ 본문,
464
+ 확인,
465
+ 미확인,
466
+ 모델: session?.conn?.model ?? '',
467
+ });
468
+
469
+ const 상태 = 깃(뿌리, ['status', '--short']).out.trimEnd();
470
+ return {
471
+ ok: true,
472
+ 뿌리,
473
+ 제목: 다듬.제목,
474
+ 본문,
475
+ 메시지,
476
+ 파일들,
477
+ 통계,
478
+ 상태,
479
+ 확인,
480
+ 미확인,
481
+ // 이 두 줄이 화면에서 사람이 놀랄 자리를 미리 말해 준다.
482
+ 남의것: 미리담긴.filter((f) => !내것.includes(f) && !전부),
483
+ 살림뺌: 살림바뀜 || 뺀것.샌것.length > 0,
484
+ 링크로샌것: 뺀것.샌것,
485
+ 폴더통째: 전부 ? [] : (내것.폴더 ?? []),
486
+ // 물어보는 사이에 담긴 것이 바뀌었는지 다시 보라고. 보여 준 것과 다른 것을
487
+ // 찍으면 승인을 받은 뜻이 없어진다.
488
+ 다시확인: () => 담긴것(뿌리).파일들,
489
+ 사실로만: 사실,
490
+ };
491
+ }
492
+
493
+ /** 진짜로 찍는다. 메시지는 파일로 넘긴다 — 따옴표 사고가 여기서 사라진다. */
494
+ export function 커밋실행(뿌리, 메시지, { audit = null, 파일들 = [], 제목 = '' } = {}) {
495
+ const 임시 = join(tmpdir(), `deel-commit-${randomBytes(6).toString('hex')}.txt`);
496
+ try {
497
+ // 잠깐 있다 지워지는 파일이지만 본인만 읽게 둔다 — 여러 사람이 쓰는
498
+ // 리눅스 서버에서 /tmp 는 남의 눈앞이다.
499
+ writeFileSync(임시, 메시지, { encoding: 'utf8', mode: 0o600 });
500
+ const r = 깃(뿌리, ['commit', '--file', 임시, '--cleanup=whitespace']);
501
+ if (!r.ok) return { ok: false, why: (r.err || r.out || 'git commit 실패').trim() };
502
+ const h = 깃(뿌리, ['rev-parse', '--short', 'HEAD']);
503
+ const hash = h.ok ? h.out.trim() : '';
504
+ audit?.write?.('commit', { hash, files: 파일들, title: 제목 });
505
+ return { ok: true, hash, out: r.out.trim() };
506
+ } catch (err) {
507
+ return { ok: false, why: err.message };
508
+ } finally {
509
+ try { unlinkSync(임시); } catch { /* 지워졌으면 그만 */ }
510
+ }
511
+ }