deel-local-cli 1.20.11 → 1.20.13

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.
package/README.ko.md CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  [![Node.js CI](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/test.yml?branch=main&logo=github&logoColor=white&label=Node.js%20CI)](https://github.com/jysvai/deel-local-cli/actions/workflows/test.yml)
22
22
  [![CodeQL](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/codeql.yml?branch=main&logo=github&logoColor=white&label=CodeQL)](https://github.com/jysvai/deel-local-cli/actions/workflows/codeql.yml)
23
- [![tests](https://img.shields.io/badge/tests-9%2C967%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/ko/develop.md)
23
+ [![tests](https://img.shields.io/badge/tests-10%2C215%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/ko/develop.md)
24
24
 
25
25
  [![dependencies](https://img.shields.io/badge/dependencies-0-1a7f37)](https://www.npmjs.com/package/deel-local-cli?activeTab=dependencies)
26
26
  [![ESM](https://img.shields.io/badge/ESM-Node%2020%2B-5FA04E?logo=javascript&logoColor=white)](package.json)
@@ -301,7 +301,7 @@ deel --offline
301
301
  무엇이 어디로 갈 수 있는지는 켤 때 화면 맨 위에 늘 적혀 있습니다.
302
302
 
303
303
  ```
304
- deel 1.20.11 ⌂ 이 안
304
+ deel 1.20.13 ⌂ 이 안
305
305
  보냄 이 컴퓨터 안 127.0.0.1:11434 ← 여기 말고는 어디로도 안 갑니다
306
306
  ```
307
307
 
@@ -1289,6 +1289,8 @@ zip 은 진짜 `unzip` 으로, tar 는 진짜 `tar` 가 만든 것을 읽혀 교
1289
1289
 
1290
1290
  | 판 | 무엇이 바뀌었나 |
1291
1291
  |---|---|
1292
+ | [1.20.13](docs/ko/releases/1.20.md#12013) | 띄어쓰기 한 칸이 시킨 말을 통째로 숨겼습니다 |
1293
+ | [1.20.12](docs/ko/releases/1.20.md#12012) | 켠다고 적은 자물쇠가 아무것도 안 잠그고 있었습니다 |
1292
1294
  | [1.20.11](docs/ko/releases/1.20.md#12011) | 안전망이 있다고 말하는데 없었습니다 |
1293
1295
  | [1.20.10](docs/ko/releases/1.20.md#12010) | 답하지 않았는데 답한 것이 되어 있었습니다 |
1294
1296
  | [1.20.9](docs/ko/releases/1.20.md#1209) | 안 보이는 것이 아니라 다른 것이 보이고 있었습니다 |
package/README.md CHANGED
@@ -20,7 +20,7 @@ Vendor APIs connect too — **only when you say so**
20
20
 
21
21
  [![Node.js CI](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/test.yml?branch=main&logo=github&logoColor=white&label=Node.js%20CI)](https://github.com/jysvai/deel-local-cli/actions/workflows/test.yml)
22
22
  [![CodeQL](https://img.shields.io/github/actions/workflow/status/jysvai/deel-local-cli/codeql.yml?branch=main&logo=github&logoColor=white&label=CodeQL)](https://github.com/jysvai/deel-local-cli/actions/workflows/codeql.yml)
23
- [![tests](https://img.shields.io/badge/tests-9%2C967%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/en/develop.md)
23
+ [![tests](https://img.shields.io/badge/tests-10%2C215%20passing-1a7f37?logo=checkmarx&logoColor=white)](docs/en/develop.md)
24
24
 
25
25
  [![dependencies](https://img.shields.io/badge/dependencies-0-1a7f37)](https://www.npmjs.com/package/deel-local-cli?activeTab=dependencies)
26
26
  [![ESM](https://img.shields.io/badge/ESM-Node%2020%2B-5FA04E?logo=javascript&logoColor=white)](package.json)
@@ -374,7 +374,7 @@ deel --offline
374
374
  The destination is printed at the top of every session:
375
375
 
376
376
  ```
377
- deel 1.20.11 ⌂ inside
377
+ deel 1.20.13 ⌂ inside
378
378
  Sends to this machine 127.0.0.1:11434 ← nowhere else
379
379
  ```
380
380
 
@@ -1350,6 +1350,8 @@ so one run tells you everything.
1350
1350
 
1351
1351
  | Version | What changed |
1352
1352
  |---|---|
1353
+ | [1.20.13](docs/en/releases/1.20.md#12013) | One space hid the whole instruction |
1354
+ | [1.20.12](docs/en/releases/1.20.md#12012) | The lock we said was on was not attached to anything |
1353
1355
  | [1.20.11](docs/en/releases/1.20.md#12011) | The safety net said it was there when it wasn't |
1354
1356
  | [1.20.10](docs/en/releases/1.20.md#12010) | Nobody answered, yet an answer had been given |
1355
1357
  | [1.20.9](docs/en/releases/1.20.md#1209) | Nothing was missing from the screen — something else was on it |
package/bin/deel.js CHANGED
@@ -146,7 +146,7 @@ function runConfig(args, flags) {
146
146
  async function runDoctor(flags) {
147
147
  const { load: 읽기, activeProfile: 고른것, resolveKey: 열쇠풀기, configPath: 설정경로 } =
148
148
  await import('../src/config.js');
149
- const { 지금모드, 바깥인가, 나갈수있나 } = await import('../src/safety/runmode.js');
149
+ const { 지금모드, 바깥인가, 나갈수있나, 봉인됐나 } = await import('../src/safety/runmode.js');
150
150
  const { allowEndpoint } = await import('../src/safety/network.js');
151
151
 
152
152
  banner();
@@ -160,7 +160,7 @@ async function runDoctor(flags) {
160
160
  * 진단한다고 자물쇠를 넘어가면 안 된다 — 여기서 한 번 나가 버리면 「나가는
161
161
  * 주소는 사람이 정한 하나뿐」 이 진단 명령 하나로 깨진다.
162
162
  */
163
- const 모드 = 지금모드({ online: flags.online === true, offline: !!(flags.offline ?? prof?.offline ?? cfg?.offline) });
163
+ const 모드 = 지금모드({ online: flags.online === true, offline: 봉인됐나({ 깃발: flags.offline, prof, cfg }) });
164
164
  const 나감 = prof ? 나갈수있나(모드, { 바깥: 바깥인가(prof.baseUrl), 허가: prof.online === true }) : { 물어볼까: false };
165
165
  const 두드려도되나 = !!prof && !나감.물어볼까 && !모드.허가무시;
166
166
  if (두드려도되나) allowEndpoint(prof.baseUrl);
@@ -490,7 +490,17 @@ function runSbom(flags) {
490
490
  // 실제로 이랬다 — deel run --json "검사 돌려줘" 를 쳤더니 --json 이 뒤의 말을
491
491
  // 통째로 삼켰다. 시킬 말이 사라졌으니 "무엇을 시킬지 적어 주세요" 가 떴는데,
492
492
  // 화면만 보면 왜 그런지 알 길이 없다. 깃발을 앞에 두는 것은 아주 흔한 습관이다.
493
- const BOOL = new Set(['help', 'version', 'offline', 'online', 'continue', 'json', 'quiet', 'yes', 'no-tui', 'tui', 'all', 'no-hooks']);
493
+ const BOOL = new Set([
494
+ 'help', 'version', 'offline', 'online', 'continue', 'json', 'quiet', 'yes',
495
+ 'no-tui', 'tui', 'all', 'no-hooks',
496
+ /*
497
+ * `hard` 가 여기 빠져 있었다. 그래서 `deel reset --hard all --yes` 는
498
+ * `--hard` 가 뒤의 `all` 을 제 값으로 삼켰다 — 지울 갈래가 사라지니 화면은
499
+ * 「무엇을 지울지 같이 주세요」 를 찍고 **종료코드 0** 으로 끝났다.
500
+ * 스크립트는 전체 초기화가 된 줄 알고 다음 줄로 넘어간다.
501
+ */
502
+ 'hard',
503
+ ]);
494
504
 
495
505
  function parse(argv) {
496
506
  const flags = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deel-local-cli",
3
- "version": "1.20.11",
3
+ "version": "1.20.13",
4
4
  "description": "A coding-agent CLI that runs on local models and private gateways. Zero dependencies, Node 20+.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/acp/map.js CHANGED
@@ -25,6 +25,8 @@
25
25
  //
26
26
  // 읽기만 하는 순수 함수를 들여오는 것은 순수함을 안 깬다. 규격을 읽는 규칙은
27
27
  // backend/adapter.js 한 곳에만 있어야 한다 (집안 규칙: 자는 한 벌).
28
+ import { isAbsolute, resolve } from 'node:path';
29
+
28
30
  import { 부른것들, 결과들, 본문글 } from '../backend/adapter.js';
29
31
  import { 첫이름 } from '../tools/label.js';
30
32
 
@@ -40,9 +42,12 @@ const 갈래표 = {
40
42
  Write: 'edit',
41
43
  Append: 'edit',
42
44
  Edit: 'edit',
45
+ Move: 'move',
43
46
  Glob: 'search',
44
47
  Grep: 'search',
45
48
  Recall: 'search',
49
+ Def: 'search',
50
+ Refs: 'search',
46
51
  Bash: 'execute',
47
52
  Jobs: 'execute',
48
53
  Verify: 'execute',
@@ -50,6 +55,7 @@ const 갈래표 = {
50
55
  Task: 'think',
51
56
  TodoWrite: 'think',
52
57
  Skill: 'think',
58
+ Ask: 'other',
53
59
  Remember: 'other',
54
60
  };
55
61
 
@@ -77,8 +83,15 @@ export function 도구이름표(이름, 인자) {
77
83
  : null)
78
84
  ?? (Array.isArray(a.paths) && a.paths.length ? `${a.paths.length}개` : null)
79
85
  ?? (Array.isArray(a.todos) ? `${a.todos.length}건` : null);
80
- const 안 = 첫 == null ? '' : 자르기(String(첫), 80);
81
- return 안 ? `${이름}(${안})` : String(이름 ?? '도구');
86
+ /*
87
+ * 이름표는 에디터의 **한 줄** 머리다. 본문 줄이는 자르기() 를 쓰면 그 끝에
88
+ * `\n… (N자 줄임)` 이 붙어서 머리가 두 줄이 되고, 한 줄을 미리 정해 둔
89
+ * 자리라 그 줄이 깨진다. 긴 명령 하나면 바로 그렇게 된다.
90
+ */
91
+ const 안 = 첫 == null ? '' : 한줄로(String(첫), 80);
92
+ // 이름이 없으면 「도구」 다. 앞칸만 채우면 화면에 `undefined(a.js)` 가 뜬다.
93
+ const 이름글 = String(이름 ?? '').trim() || '도구';
94
+ return 안 ? `${이름글}(${안})` : 이름글;
82
95
  }
83
96
 
84
97
  /**
@@ -88,11 +101,21 @@ export function 도구이름표(이름, 인자) {
88
101
  * 있는데, 에디터는 절대 경로라야 연다. 인자만 보고 넘기면 눌러도 안 열리는
89
102
  * 링크가 되고, 그건 없느니만 못하다.
90
103
  */
91
- export function 도구자리(이름, 인자, 결과) {
104
+ export function 도구자리(이름, 인자, 결과, { 뿌리 = null } = {}) {
92
105
  const 모은것 = [];
93
106
  const 넣기 = (p) => {
94
107
  const s = typeof p === 'string' ? p.trim() : '';
95
- if (s && !모은것.includes(s)) 모은것.push(s);
108
+ if (!s) return;
109
+ /*
110
+ * 상대 경로는 **여기서** 절대 경로로 편다.
111
+ *
112
+ * 에디터는 절대 경로라야 연다. 상대 경로를 그대로 주면 눌러도 아무 일이
113
+ * 안 일어나는데, 화면에는 멀쩡한 링크로 보인다 — 없느니만 못하다.
114
+ * 도는 중(tool_call)에는 결과가 아직 없어서 인자에 적힌 상대 경로뿐이라,
115
+ * 이 자리를 안 펴면 도구가 도는 내내 링크가 다 죽어 있다.
116
+ */
117
+ const 편것 = 뿌리 && !isAbsolute(s) ? resolve(뿌리, s) : s;
118
+ if (!모은것.includes(편것)) 모은것.push(편것);
96
119
  };
97
120
 
98
121
  넣기(결과?.changed);
@@ -141,7 +164,7 @@ export function 도구내용(결과, 최대 = 2000) {
141
164
  * @param {string} 아이디 이 세션 안에서 유일한 번호
142
165
  * @param {object} ev loop.js 가 흘린 `tool` 이벤트
143
166
  */
144
- export function 도구끝남(아이디, ev) {
167
+ export function 도구끝남(아이디, ev, { 뿌리 = null } = {}) {
145
168
  return {
146
169
  sessionUpdate: 'tool_call_update',
147
170
  toolCallId: 아이디,
@@ -149,12 +172,12 @@ export function 도구끝남(아이디, ev) {
149
172
  kind: 도구갈래(ev?.name),
150
173
  status: 도구탈났나(ev?.result) ? 'failed' : 'completed',
151
174
  content: 도구내용(ev?.result),
152
- locations: 도구자리(ev?.name, ev?.args, ev?.result),
175
+ locations: 도구자리(ev?.name, ev?.args, ev?.result, { 뿌리 }),
153
176
  };
154
177
  }
155
178
 
156
179
  /** 이제 막 시작한 도구 호출. */
157
- export function 도구시작(아이디, 이름, 인자) {
180
+ export function 도구시작(아이디, 이름, 인자, { 뿌리 = null } = {}) {
158
181
  return {
159
182
  sessionUpdate: 'tool_call',
160
183
  toolCallId: 아이디,
@@ -162,7 +185,7 @@ export function 도구시작(아이디, 이름, 인자) {
162
185
  kind: 도구갈래(이름),
163
186
  status: 'in_progress',
164
187
  content: [],
165
- locations: 도구자리(이름, 인자, null),
188
+ locations: 도구자리(이름, 인자, null, { 뿌리 }),
166
189
  };
167
190
  }
168
191
 
@@ -227,7 +250,7 @@ function 인자풀기(x) {
227
250
  * @param {object[]} messages store.js 가 읽어 온 대화
228
251
  * @returns {object[]} session/update 의 update 자리에 그대로 넣을 것들
229
252
  */
230
- export function 되살린것(messages, { 최대내용 = 2000 } = {}) {
253
+ export function 되살린것(messages, { 최대내용 = 2000, 뿌리 = null } = {}) {
231
254
  const 줄 = Array.isArray(messages) ? messages : [];
232
255
 
233
256
  /*
@@ -315,7 +338,7 @@ export function 되살린것(messages, { 최대내용 = 2000 } = {}) {
315
338
  content: 없음
316
339
  ? [{ type: 'content', content: { type: 'text', text: '결과가 안 남았습니다 — 이 도구가 도는 중에 끊겼습니다.' } }]
317
340
  : [{ type: 'content', content: { type: 'text', text: 자르기(String(답), 최대내용) } }],
318
- locations: 도구자리(이름, 인자, null),
341
+ locations: 도구자리(이름, 인자, null, { 뿌리 }),
319
342
  });
320
343
  }
321
344
  }
@@ -365,3 +388,9 @@ function 자르기(s, n) {
365
388
  const t = String(s ?? '');
366
389
  return t.length > n ? `${t.slice(0, n)}\n… (${t.length - n}자 줄임)` : t;
367
390
  }
391
+
392
+ /** 한 줄짜리 자리(이름표)에 쓴다. 줄바꿈을 빈칸으로 눕히고 말줄임만 붙인다. */
393
+ function 한줄로(s, n) {
394
+ const t = String(s ?? '').replace(/\s+/g, ' ').trim();
395
+ return t.length > n ? `${t.slice(0, n)}…` : t;
396
+ }
package/src/acp/serve.js CHANGED
@@ -47,7 +47,7 @@ import { 알림채움, 알림말 } from '../backend/retry.js';
47
47
  import { 전선붙이기, 세션이름짓기 } from '../backend/wire.js';
48
48
  import { discover } from '../skills/discover.js';
49
49
  import { allowEndpoint, setOffline } from '../safety/network.js';
50
- import { 지금모드, 바깥인가, 나갈수있나 } from '../safety/runmode.js';
50
+ import { 지금모드, 바깥인가, 나갈수있나, 봉인됐나 } from '../safety/runmode.js';
51
51
  import { 주소가리기 } from '../safety/secrets.js';
52
52
  import { probeCtx, 기본값 as CTX_DEFAULT } from '../backend/ctxsize.js';
53
53
  import { 잠잠기본, 무소식기본 } from '../backend/http.js';
@@ -247,7 +247,8 @@ export async function acp(opts = {}) {
247
247
  const 실행모드 = 지금모드({
248
248
  online: opts.online === true,
249
249
  // 관리 정책이 offline 을 못박아 뒀으면 옵션과 상관없이 켠다 (safety/policy.js).
250
- offline: !!(opts.offline ?? prof.offline ?? cfg?.offline),
250
+ // 셋 중 하나라도 켜져 있으면 켜진 것이다 (safety/runmode.js 의 봉인됐나).
251
+ offline: 봉인됐나({ 깃발: opts.offline, prof, cfg }),
251
252
  });
252
253
  const 나감 = 나갈수있나(실행모드, { 바깥: 바깥연결, 허가: prof.online === true });
253
254
  if (나감.물어볼까) {
@@ -314,7 +315,8 @@ export async function acp(opts = {}) {
314
315
  const 캐시열쇠 = resolve(root);
315
316
  if (!mcp캐시.has(캐시열쇠)) {
316
317
  mcp캐시.set(캐시열쇠, 다붙이기(root, {
317
- offline: !!(opts.offline ?? prof.offline),
318
+ // 봉인은 MCP 를 붙이는 자리에도 그대로 걸린다 (safety/runmode.js 의 봉인됐나).
319
+ offline: 봉인됐나({ 깃발: opts.offline, prof, cfg }),
318
320
  audit: null,
319
321
  }).then((붙임) => {
320
322
  /*
@@ -443,7 +445,7 @@ export async function acp(opts = {}) {
443
445
  title: 도구이름표(이름, 인자),
444
446
  kind: 도구갈래(이름),
445
447
  status: 'pending',
446
- locations: 도구자리(이름, 인자, null),
448
+ locations: 도구자리(이름, 인자, null, { 뿌리: 방.root }),
447
449
  rawInput: 인자 ?? {},
448
450
  },
449
451
  options: [
@@ -641,17 +643,17 @@ export async function acp(opts = {}) {
641
643
  break;
642
644
 
643
645
  case 'tool_start':
644
- 보내기(도구시작(도구맡기기(방, ev.name), ev.name, ev.args));
646
+ 보내기(도구시작(도구맡기기(방, ev.name), ev.name, ev.args, { 뿌리: 방.root }));
645
647
  break;
646
648
 
647
649
  case 'tools_start':
648
650
  for (const 이름 of ev.names ?? []) {
649
- 보내기(도구시작(도구맡기기(방, 이름), 이름, null));
651
+ 보내기(도구시작(도구맡기기(방, 이름), 이름, null, { 뿌리: 방.root }));
650
652
  }
651
653
  break;
652
654
 
653
655
  case 'tool':
654
- 보내기(도구끝남(도구찾기(방, ev.name), ev));
656
+ 보내기(도구끝남(도구찾기(방, ev.name), ev, { 뿌리: 방.root }));
655
657
  적기();
656
658
  break;
657
659
 
@@ -880,7 +882,7 @@ export async function acp(opts = {}) {
880
882
  } catch { /* 못 읽으면 기억하고 있는 것으로 그린다 */ }
881
883
 
882
884
  let 흘린것 = 0;
883
- for (const update of 되살린것(그릴것)) {
885
+ for (const update of 되살린것(그릴것, { 뿌리: 방.root })) {
884
886
  관.알림('session/update', { sessionId: 방.id, update });
885
887
  흘린것++;
886
888
  }
@@ -164,9 +164,17 @@ export function 이번에바꾼것(session, 뿌리) {
164
164
  for (const p of (session?.changes?.keys?.() ?? [])) {
165
165
  const abs = 진짜(isAbsolute(p) ? p : join(기준, p));
166
166
  const rel = relative(기준, abs).replace(/\\/g, '/');
167
- // 저장소 밖은 담지 않는다. `..` 로 시작하면 밖이다.
168
- // (지워진 파일도 여기서 안 새어 나간다 — 진짜() 가 있는 데까지 풀어 준다.)
169
- if (!rel || rel.startsWith('../') || rel === '..') continue;
167
+ /*
168
+ * 저장소 밖은 담지 않는다. `..` 로 시작하면 밖이다.
169
+ * (지워진 파일도 여기서 안 새어 나간다 — 진짜() 가 있는 데까지 풀어 준다.)
170
+ *
171
+ * 절대경로도 밖이다. 윈도우에서 **드라이브가 다르면** relative() 가
172
+ * `..` 를 못 만들고 상대경로 대신 `D:\남의폴더\a.txt` 를 그대로 돌려준다.
173
+ * 그러면 위 두 검사를 그냥 지나가서, 저장소 밖 경로가 `git add` 에 실린다 —
174
+ * git 이 `fatal: … is outside repository` 로 죽고 /commit 이 통째로 안 된다.
175
+ * 담지 말아야 할 것 하나 때문에 담아야 할 것도 다 못 담는 꼴이다.
176
+ */
177
+ if (!rel || rel.startsWith('../') || rel === '..' || isAbsolute(rel)) continue;
170
178
  if (살림경로인가(rel) || 살림에닿나(abs)) continue;
171
179
  let 폴더인가 = false;
172
180
  try { 폴더인가 = statSync(abs).isDirectory(); } catch { /* 지워진 것은 파일로 친다 */ }
@@ -217,13 +225,41 @@ export function 살림도로빼기(뿌리, 파일들) {
217
225
  return { 샌것, 못뺀것: 아직 };
218
226
  }
219
227
 
220
- /** 지금 담겨 있는 것. */
228
+ /**
229
+ * 지금 담겨 있는 것.
230
+ *
231
+ * ── git 이 **답을 못 한 것**과 **답이 없는 것**은 다르다 ──────────────────
232
+ *
233
+ * 세 번 부르면서 ok 를 한 번도 안 봤다. 그래서 실패가 전부 「빈 값」 으로
234
+ * 내려가고, 부르는 쪽은 그것을 「바뀐 것이 없다」 로 읽었다. 실제로 겪는 꼴이
235
+ * 둘 있다.
236
+ *
237
+ * · 담긴 diff 가 64MB 를 넘으면 몸통만 ENOBUFS 로 죽는다(spawnSync 의
238
+ * maxBuffer). 파일 목록은 멀쩡히 오므로 커밋은 그대로 이어지고,
239
+ * **빈 diff 를 받은 모델**이 「무엇을 바꿨는지는 diff 에 이미 있다」 는
240
+ * 지시 아래 메시지를 짓는다 — 볼 것이 없으니 지어낸다. 큰 데이터 덤프나
241
+ * 묶은 번들은 이 도구가 실제로 만드는 것들이다.
242
+ * · `.git/index` 가 깨지면 이름 목록부터 죽는다. 그러면 화면에 「담을 것이
243
+ * 없습니다 — 바뀐 내용이 없습니다」 가 뜬다. 사람은 제 일이 안 담긴 줄
244
+ * 알고 다시 담으러 가는데, 실제로 필요한 것은 저장소 손보기다.
245
+ *
246
+ * 그래서 못 읽은 것을 못 읽었다고 같이 돌려준다.
247
+ *
248
+ * @returns {{파일들:string[], 통계:string, diff:string, 못읽음:Array<{무엇:string,왜:string}>}}
249
+ */
221
250
  export function 담긴것(뿌리) {
222
251
  const 이름 = 깃(뿌리, ['diff', '--cached', '--name-only']);
223
252
  const 통계 = 깃(뿌리, ['diff', '--cached', '--stat']);
224
253
  const 몸통 = 깃(뿌리, ['diff', '--cached']);
225
254
  const 파일들 = 이름.out.split('\n').map((x) => x.trim()).filter(Boolean);
226
- return { 파일들, 통계: 통계.out.trimEnd(), diff: 몸통.out };
255
+ const 못읽음 = [];
256
+ const 적기 = (무엇, r) => {
257
+ if (!r.ok) 못읽음.push({ 무엇, 왜: (r.err || r.out || `git ${무엇} 실패`).trim().split('\n')[0] });
258
+ };
259
+ 적기('목록', 이름);
260
+ 적기('통계', 통계);
261
+ 적기('diff', 몸통);
262
+ return { 파일들, 통계: 통계.out.trimEnd(), diff: 몸통.out, 못읽음 };
227
263
  }
228
264
 
229
265
  /** 저장소가 쓰던 말투를 흉내 내라고 최근 제목을 보여 준다. */
@@ -283,10 +319,10 @@ export function 답가르기(글) {
283
319
  } catch { /* JSON 인 척한 것뿐이면 아래로 */ }
284
320
  }
285
321
 
286
- const 표 = s.match(/^\s*제목\s*[::]\s*(.+?)\s*$/m);
322
+ const 표 = s.match(/^\s*제목\s*[::]\s*(.+?)\s*$/m);
287
323
  if (표) {
288
324
  const 뒤 = s.slice(s.indexOf(표[0]) + 표[0].length);
289
- const 본문표 = 뒤.match(/^\s*본문\s*[::]\s*/m);
325
+ const 본문표 = 뒤.match(/^\s*본문\s*[::]\s*/m);
290
326
  const 본문 = 본문표 ? 뒤.slice(뒤.indexOf(본문표[0]) + 본문표[0].length) : 뒤;
291
327
  return { 제목: 표[1].trim(), 본문: 본문.trim() };
292
328
  }
@@ -332,12 +368,29 @@ export function 제어글자빼기(글) {
332
368
  * 진짜로 읽는다. 우리 꼬리표(Generated-by)는 우리가 따로 붙이므로, 모델이
333
369
  * 꼬리표를 쓸 이유가 아예 없다.
334
370
  */
335
- const 가짜꼬리표 = /^\s*(signed-off-by|co-authored-by|reviewed-by|acked-by|tested-by|generated-by|claude-session|closes|fixes)\s*:/i;
371
+ const 가짜꼬리표 = /^\s*(signed-off-by|co-authored-by|reviewed-by|acked-by|tested-by|generated-by|claude-session)\s*:/i;
372
+
373
+ /*
374
+ * 이슈를 **닫는** 낱말은 콜론이 없다.
375
+ *
376
+ * 위 목록에 closes·fixes 를 같이 넣어 두고 있었는데, 끝에 `\s*:` 가 붙어 있어
377
+ * 정작 깃허브가 알아보는 꼴(`Closes #123`)은 한 번도 안 걸렸다. 걸러내려던
378
+ * 바로 그것만 통과한 셈이다.
379
+ *
380
+ * 이게 왜 중요하냐면, 이 줄이 기본 가지에 실리는 순간 **깃허브가 그 이슈를
381
+ * 진짜로 닫는다.** 모델이 지어낸 번호면 남의 이슈가 닫힌다 — 커밋을 되돌려도
382
+ * 닫힌 이슈는 안 열린다.
383
+ *
384
+ * 그렇다고 `Fixes` 로 시작하는 줄을 통째로 지우면 안 된다. 「Fixes the crash
385
+ * when …」 은 본문에 있어야 할 진짜 문장이다. 그래서 **뒤에 `#숫자`나 주소만
386
+ * 달랑 오는 꼴**일 때만 지운다.
387
+ */
388
+ const 닫는말 = /^\s*(clos(e|es|ed)|fix(|es|ed)|resolv(e|es|ed))\s*:?\s*(#\d+|https?:\/\/\S+)\s*$/i;
336
389
 
337
390
  export function 꼬리표걸러내기(본문) {
338
391
  return String(본문 ?? '')
339
392
  .split('\n')
340
- .filter((줄) => !가짜꼬리표.test(줄))
393
+ .filter((줄) => !가짜꼬리표.test(줄) && !닫는말.test(줄))
341
394
  .join('\n')
342
395
  .trim();
343
396
  }
@@ -388,18 +441,32 @@ export function 메시지꾸리기({ 제목, 본문 = '', 확인 = 0, 미확인
388
441
  * 대화를 통째로 보내면 창을 두 번 먹고, 모델은 제가 한 말을 근거로 제
389
442
  * 커밋 메시지를 쓰게 된다. 커밋에 실릴 것은 **코드가 말하는 것**이어야 한다.
390
443
  */
391
- export async function 메시지짓기(session, { 뿌리, diff, 통계, 파일들, 증거, 제목 = null, signal = null, onBackoff = null } = {}) {
392
- const 자른diff = diff.length > DIFF상한
393
- ? `${diff.slice(0, DIFF상한)}\n… (diff 가 길어 여기서 잘랐습니다 — 나머지는 통계로만 보세요)`
394
- : diff;
444
+ export async function 메시지짓기(session, { 뿌리, diff, 통계, 파일들, 증거, 제목 = null, signal = null, onBackoff = null, diff못읽음 = null } = {}) {
445
+ /*
446
+ * diff 를 **못 읽은 것**을 「없는 것」 으로 주면 모델은 지어낸다.
447
+ *
448
+ * 지시문이 「무엇을 바꿨는지는 diff 에 이미 있다」 라고 못을 박아 두었다.
449
+ * 그 상태로 빈 diff 를 주면 모델은 통계 줄만 보고 그럴듯한 이야기를 만든다.
450
+ * 못 봤으면 못 봤다고 말해야 「diff 에서 실제로 보이는 것만 써라」 가 지켜진다.
451
+ */
452
+ const 자른diff = diff못읽음
453
+ ? `(diff 를 못 읽었습니다: ${diff못읽음})\n`
454
+ + '내용을 한 줄도 못 봤습니다. 아래 통계에 적힌 파일 이름과 줄 수만 보고 쓰고,\n'
455
+ + '내용에 대해서는 아무것도 단정하지 마라.'
456
+ : diff.length > DIFF상한
457
+ ? `${diff.slice(0, DIFF상한)}\n… (diff 가 길어 여기서 잘랐습니다 — 나머지는 통계로만 보세요)`
458
+ : diff;
395
459
  const 최근 = 최근제목들(뿌리 ?? process.cwd());
396
460
 
397
461
  const 몫 = [];
398
462
  if (최근.length) 몫.push(`이 저장소의 최근 커밋 제목:\n${최근.map((x) => `- ${x}`).join('\n')}`);
399
- if (증거) {
400
- const 확인 = 증거.셈.파일 - 증거.셈.증명안됨;
401
- 몫.push(`이번에 돌린 것: ${증거.셈.돌린것}개${증거.셈.실패한것 ? ` (실패 ${증거.셈.실패한것}개)` : ''}`
402
- + `\n확인된 파일 ${확인}개 · 확인 안 된 파일 ${증거.셈.증명안됨}개`);
463
+ // 증거는 있는데 셈 칸이 없을 수 있다. 바로 아래 커밋준비() 는 `증거?.셈?.` 으로
464
+ // 읽으면서 여기만 맨몸이었다 — 한쪽이 조심하고 한쪽이 안 하면 언젠가 터진다.
465
+ if (증거?.셈) {
466
+ const 셈 = 증거.셈;
467
+ const 확인 = (셈.파일 ?? 0) - (셈.증명안됨 ?? 0);
468
+ 몫.push(`이번에 돌린 것: ${셈.돌린것 ?? 0}개${셈.실패한것 ? ` (실패 ${셈.실패한것}개)` : ''}`
469
+ + `\n확인된 파일 ${확인}개 · 확인 안 된 파일 ${셈.증명안됨 ?? 0}개`);
403
470
  }
404
471
  몫.push(`바뀐 파일:\n${통계 || 파일들.map((f) => `- ${f}`).join('\n')}`);
405
472
  몫.push(`----- diff -----\n${자른diff}`);
@@ -424,7 +491,7 @@ export async function 메시지짓기(session, { 뿌리, diff, 통계, 파일들
424
491
  if (제목) {
425
492
  // 본문만 달라고 했어도 작은 모델은 `제목:`/`본문:` 꼴을 그대로 흉내 낸다.
426
493
  // 그걸 그대로 본문에 넣으면 커밋 안에 '제목:' 이라는 줄이 남는다.
427
- const 갈린 = /^\s*제목\s*[::]/m.test(글) ? 답가르기(글) : null;
494
+ const 갈린 = /^\s*제목\s*[::]/m.test(글) ? 답가르기(글) : null;
428
495
  return { 제목, 본문: 갈린?.본문 ?? 껍질벗기기(글) };
429
496
  }
430
497
  return 답가르기(글);
@@ -465,14 +532,28 @@ export async function 커밋준비(session, ctx, { 전부 = false, 제목 = null
465
532
  return { ok: false, why: `열쇠가 든 자리(${뺀것.못뺀것.join(', ')})가 담긴 채로 안 빠집니다 — 커밋하지 않았습니다.` };
466
533
  }
467
534
 
468
- const { 파일들, 통계, diff } = 담긴것(뿌리);
535
+ const { 파일들, 통계, diff, 못읽음 } = 담긴것(뿌리);
536
+ /*
537
+ * **목록**을 못 읽었으면 「없다」 가 아니다.
538
+ *
539
+ * 깨진 index·잠긴 저장소에서 이 자리는 여태 「바뀐 내용이 없습니다」 를
540
+ * 냈다. 사람은 제 일이 안 담긴 줄 알고 다시 담으러 가는데, 실제로 할 일은
541
+ * 저장소를 손보는 것이다. 까닭을 그대로 옮긴다.
542
+ */
543
+ const 목록못읽음 = 못읽음.find((x) => x.무엇 === '목록');
544
+ if (목록못읽음) {
545
+ return { ok: false, why: `git 이 담긴 것을 못 읽었습니다 — ${목록못읽음.왜}` };
546
+ }
469
547
  if (!파일들.length) return { ok: false, why: '담을 것이 없습니다 — 바뀐 내용이 없습니다.' };
548
+ // 몸통(diff)만 죽는 판이 따로 있다 — 64MB 를 넘으면 그렇다. 파일 목록은
549
+ // 멀쩡하므로 커밋 자체는 이어 가되, **모델에게도 화면에도** 못 봤다고 말한다.
550
+ const diff못읽음 = 못읽음.find((x) => x.무엇 === 'diff')?.왜 ?? null;
470
551
 
471
552
  const 증거 = (() => { try { return 증거모으기(session, { audit: ctx?.audit }); } catch { return null; } })();
472
553
  const 미확인 = 증거?.셈?.증명안됨 ?? 0;
473
- const 확인 = 증거 ? 증거.셈.파일 - 미확인 : 0;
554
+ const 확인 = 증거?.셈 ? (증거.셈.파일 ?? 0) - 미확인 : 0;
474
555
 
475
- const 지은것 = await 메시지짓기(session, { 뿌리, diff, 통계, 파일들, 증거, 제목, signal, onBackoff });
556
+ const 지은것 = await 메시지짓기(session, { 뿌리, diff, 통계, 파일들, 증거, 제목, signal, onBackoff, diff못읽음 });
476
557
  if (지은것?.중단) return { ok: false, why: '중단했습니다 — 담긴 것은 그대로 둡니다.', aborted: true };
477
558
 
478
559
  const 사실 = !지은것;
@@ -506,6 +587,9 @@ export async function 커밋준비(session, ctx, { 전부 = false, 제목 = null
506
587
  살림뺌: 살림바뀜 || 뺀것.샌것.length > 0,
507
588
  링크로샌것: 뺀것.샌것,
508
589
  폴더통째: 전부 ? [] : (내것.폴더 ?? []),
590
+ // git 이 답을 못 한 것. 화면이 그대로 말한다 — 못 본 것을 안 본 척하면
591
+ // 메시지가 무엇을 근거로 쓰였는지가 사라진다.
592
+ diff못읽음,
509
593
  // 물어보는 사이에 담긴 것이 바뀌었는지 다시 보라고. 보여 준 것과 다른 것을
510
594
  // 찍으면 승인을 받은 뜻이 없어진다.
511
595
  다시확인: () => 담긴것(뿌리).파일들,