deel-local-cli 1.20.11 → 1.20.12

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%2C125%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.12 ⌂ 이 안
305
305
  보냄 이 컴퓨터 안 127.0.0.1:11434 ← 여기 말고는 어디로도 안 갑니다
306
306
  ```
307
307
 
@@ -1289,6 +1289,7 @@ zip 은 진짜 `unzip` 으로, tar 는 진짜 `tar` 가 만든 것을 읽혀 교
1289
1289
 
1290
1290
  | 판 | 무엇이 바뀌었나 |
1291
1291
  |---|---|
1292
+ | [1.20.12](docs/ko/releases/1.20.md#12012) | 켠다고 적은 자물쇠가 아무것도 안 잠그고 있었습니다 |
1292
1293
  | [1.20.11](docs/ko/releases/1.20.md#12011) | 안전망이 있다고 말하는데 없었습니다 |
1293
1294
  | [1.20.10](docs/ko/releases/1.20.md#12010) | 답하지 않았는데 답한 것이 되어 있었습니다 |
1294
1295
  | [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%2C125%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.12 ⌂ inside
378
378
  Sends to this machine 127.0.0.1:11434 ← nowhere else
379
379
  ```
380
380
 
@@ -1350,6 +1350,7 @@ so one run tells you everything.
1350
1350
 
1351
1351
  | Version | What changed |
1352
1352
  |---|---|
1353
+ | [1.20.12](docs/en/releases/1.20.md#12012) | The lock we said was on was not attached to anything |
1353
1354
  | [1.20.11](docs/en/releases/1.20.md#12011) | The safety net said it was there when it wasn't |
1354
1355
  | [1.20.10](docs/en/releases/1.20.md#12010) | Nobody answered, yet an answer had been given |
1355
1356
  | [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.12",
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/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
  /*
@@ -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
  다시확인: () => 담긴것(뿌리).파일들,
@@ -43,14 +43,24 @@ export function runSessions(flags = {}) {
43
43
 
44
44
  const wId = Math.max(...rows.map((r) => width(r.id)));
45
45
  for (const [i, r] of rows.entries()) {
46
- const 최근 = i === 0 ? c.hgreen('●') : c.gray('·');
46
+ // 못 읽은 것도 목록에 올린다. 안 올리면 「없다」 가 되는데, 없는 것과
47
+ // 못 여는 것은 사람이 할 일이 다르다 (agent/store.js 의 list).
48
+ const 최근 = r.못읽음 ? c.yellow(mark.warn) : i === 0 ? c.hgreen('●') : c.gray('·');
47
49
  say(` ${최근} ${c.bold(pad(r.id, wId))} ${c.gray(pad(언제(r.at), 9))}`
48
- + `${c.gray(pad(`${r.turns}턴`, 6, 'right'))} ${c.gray(pad(clip(r.model, 22), 24))}`);
49
- say(` ${c.gray(' ')}${clip(r.first, 68)}`);
50
+ + `${c.gray(pad(r.못읽음 ? '' : `${r.turns}턴`, 6, 'right'))} ${c.gray(pad(clip(r.model, 22), 24))}`);
51
+ say(` ${c.gray(' ')}${r.못읽음 ? c.yellow(clip(r.first, 68)) : clip(r.first, 68)}`);
50
52
  }
51
53
  say('');
54
+ const 읽히는것 = rows.filter((r) => !r.못읽음);
55
+ if (!읽히는것.length) {
56
+ say(` ${mark.warn} ${c.yellow('여기 있는 대화를 하나도 못 열었습니다.')} ${c.gray('권한이나 다른 프로그램이 잡고 있는지 보세요.')}`);
57
+ say('');
58
+ say(` ${c.gray(`저장 위치: ${sessionsDir(root)} (.gitignore 에 들어 있어 깃에 안 올라갑니다)`)}`);
59
+ say('');
60
+ return 1;
61
+ }
52
62
  say(` ${c.gray('가장 최근 것 이어하기')} ${c.cyan('deel --continue')}`);
53
- say(` ${c.gray('골라서 이어하기')} ${c.cyan(`deel --resume ${rows[0].id}`)}`);
63
+ say(` ${c.gray('골라서 이어하기')} ${c.cyan(`deel --resume ${읽히는것[0].id}`)}`);
54
64
  say(` ${c.gray('하나 지우기')} ${c.cyan(`deel sessions --rm ${rows.at(-1).id}`)}`);
55
65
  say('');
56
66
  say(` ${c.gray(`저장 위치: ${sessionsDir(root)} (.gitignore 에 들어 있어 깃에 안 올라갑니다)`)}`);
@@ -14,7 +14,7 @@
14
14
  // 마지막 줄까지는 성하다. 통째로 다시 쓰는 방식이면 그 순간 파일이 깨진다.
15
15
  import {
16
16
  existsSync, mkdirSync, readdirSync, readFileSync,
17
- appendFileSync, writeFileSync, statSync, rmSync, chmodSync,
17
+ appendFileSync, writeFileSync, statSync, rmSync, chmodSync, renameSync,
18
18
  } from 'node:fs';
19
19
  import { join } from 'node:path';
20
20
 
@@ -41,7 +41,14 @@ export function freeId(dir, at = new Date()) {
41
41
  const id = `${base}-${n}`;
42
42
  if (!existsSync(join(dir, `${id}.jsonl`))) return id;
43
43
  }
44
- return `${base}-${process.pid}`;
44
+ // 여기까지 왔으면 마지막 이름도 비어 있는지 봐야 한다. 안 보고 돌려주면
45
+ // begin() 이 같은 이름으로 쉰 번을 되풀이하다 **아무 파일도 없이** 대화를
46
+ // 시작한다 — 저장한다고 적어 놓고 한 줄도 안 남는 판이 그때 만들어진다.
47
+ for (let n = 0; n < 1000; n++) {
48
+ const id = n ? `${base}-${process.pid}-${n}` : `${base}-${process.pid}`;
49
+ if (!existsSync(join(dir, `${id}.jsonl`))) return id;
50
+ }
51
+ return `${base}-${process.pid}-${Date.now().toString(36)}`;
45
52
  }
46
53
 
47
54
  export class Store {
@@ -86,6 +93,12 @@ export class Store {
86
93
  #잠그기() {
87
94
  if (this.#잠갔나) return;
88
95
  this.#잠갔나 = true;
96
+ /*
97
+ * 윈도우에서 chmod 는 **아무 일도 안 하고 성공한다.** 그 성공을 그대로
98
+ * 적으면 바로 위 머리말이 스스로 금지한 「잠근 척」 이 된다 — 같은 PC 를
99
+ * 여럿이 쓰는 사람이 화면만 보고 안심한다. 안 걸었으면 안 걸었다고 적는다.
100
+ */
101
+ if (process.platform === 'win32') { this.잠금 = { 못함: 'windows' }; return; }
89
102
  try { chmodSync(this.file, 0o600); this.잠금 = { 모드: 0o600 }; }
90
103
  catch (err) { this.잠금 = { 못함: err?.code ?? String(err) }; }
91
104
  }
@@ -96,8 +109,10 @@ export class Store {
96
109
  // 끊긴다 — 기록을 남기려다 작업을 죽이면 본말이 뒤집힌다.
97
110
  // 조용히 넘기는 것도 아니다: 폴더가 없으면 바로 아래 쓰기가 깨지고,
98
111
  // 그 자리에서 세어 화면에 오른다.
99
- try { mkdirSync(this.dir, { recursive: true }); } catch { /* 아래 쓰기가 센다 */ }
100
- this.opened = true;
112
+ // 못 만들었으면 **연 것이 아니다.** 연 것으로 적어 두면 잠깐 막혔던 것이
113
+ // 풀린 뒤에도 두 번 다시 안 만들어 본다 — 그 대화는 끝까지 한 줄도 안 남는다.
114
+ try { mkdirSync(this.dir, { recursive: true }); this.opened = true; }
115
+ catch { /* 아래 쓰기가 센다. 다음 줄에서 다시 만들어 본다 */ }
101
116
  }
102
117
 
103
118
  /*
@@ -161,14 +176,19 @@ export class Store {
161
176
  this.#use(freeId(this.dir)); // 누가 채 갔다. 옆자리로.
162
177
  }
163
178
  }
179
+ // 쉰 번을 다 써도 자리를 못 잡았다. 여기서 잠자코 돌아가면 화면은
180
+ // 「계속 저장되고 있습니다」 라고 말하는데 파일은 하나도 없다.
181
+ this.#못썼다({ code: 'EEXIST' });
164
182
  return this;
165
183
  }
166
184
 
185
+ /** 한 줄 적는다. 적었으면 true. 못 적었으면 false 이고 셈에 오른다. */
167
186
  #write(obj) {
168
187
  try {
169
188
  appendFileSync(this.file, JSON.stringify(obj) + '\n', 'utf8');
170
189
  this.#잠그기();
171
- } catch (err) { this.#못썼다(err); } // 계속은 하되, 몇 줄을 잃었는지는 센다
190
+ return true;
191
+ } catch (err) { this.#못썼다(err); return false; } // 계속은 하되, 몇 줄을 잃었는지는 센다
172
192
  }
173
193
 
174
194
  // 메시지 하나를 덧붙인다. 대화가 진행되는 대로 즉시 남긴다.
@@ -225,17 +245,24 @@ export class Store {
225
245
  * 그 값은 매번 사람이 기다리는 시간이다. pins 를 사람이 고칠 때만 적는
226
246
  * 것과 같은 뜻이다.
227
247
  */
248
+ /*
249
+ * 적은 것으로 치는 것은 **정말 적힌 뒤**다.
250
+ *
251
+ * 못 적었는데 적은 셈으로 표시해 두면, 디스크가 다시 나아져도 그 값은 이미
252
+ * 「안 바뀐 것」 이라 두 번 다시 안 적힌다. 잠깐 막혔던 것이 영영 사라지는
253
+ * 자리다 — 여기가 바로 남은 할 일과 시킨 말이 조용히 없어지던 길이다.
254
+ */
228
255
  살림적기(session = this.따라갈세션) {
229
256
  if (!session) return;
230
257
  const 할일 = JSON.stringify(session.할일 ?? []);
231
- if (할일 !== this.적은할일) {
258
+ if (할일 !== this.적은할일
259
+ && this.#write({ t: 'todo', at: new Date().toISOString(), 목록: session.할일 ?? [] })) {
232
260
  this.적은할일 = 할일;
233
- this.#write({ t: 'todo', at: new Date().toISOString(), 목록: session.할일 ?? [] });
234
261
  }
235
262
  const 요청 = String(session.이번요청 ?? '');
236
- if (요청 !== this.적은요청) {
263
+ if (요청 !== this.적은요청
264
+ && this.#write({ t: 'request', at: new Date().toISOString(), 글: 요청 })) {
237
265
  this.적은요청 = 요청;
238
- this.#write({ t: 'request', at: new Date().toISOString(), 글: 요청 });
239
266
  }
240
267
  }
241
268
 
@@ -289,15 +316,28 @@ export class Store {
289
316
  if (이번요청) lines.push(JSON.stringify({ t: 'request', at: new Date().toISOString(), 글: 이번요청 }));
290
317
  for (const m of messages) lines.push(JSON.stringify({ t: 'msg', m }));
291
318
  /*
292
- * 통째로 다시 쓰면 파일이 새로 만들어진다 — 그때 잠금도 다시 걸어야 한다.
293
- * 그리고 여기서 깨지면 앞의 append 들이 남긴 것까지 통째로 못 고친 셈이
294
- * 된다. 제일 크게 잃는 자리라 더더욱 조용히 넘길 수 없다.
319
+ * 옆에 다 쓰고 나서 **한 번에 갈아 끼운다.**
320
+ *
321
+ * 이 파일 머리말이 jsonl 을 고른 까닭이 「통째로 다시 쓰면 그 순간 파일이
322
+ * 깨진다」 인데, 정작 통째로 다시 쓰는 자리가 여기다. 디스크가 반쯤 쓰다
323
+ * 차면 원본은 이미 잘려 있고 새것은 안 끝났다 — 그 한 번에 대화 전체가
324
+ * 없어진다. 옆에 써 두고 이름만 바꾸면 성한 쪽 아니면 성한 쪽이다.
325
+ *
326
+ * 실패하면 옆에 쓴 것을 치운다. 안 치우면 폴더에 부스러기가 쌓이고,
327
+ * 다음에 볼 사람은 그게 대화인지 찌꺼기인지 모른다.
295
328
  */
329
+ const 옆 = `${this.file}.새로`;
296
330
  try {
297
- writeFileSync(this.file, lines.join('\n') + '\n', 'utf8');
331
+ writeFileSync(옆, lines.join('\n') + '\n', 'utf8');
332
+ // 윈도우에서는 읽기 전용 파일 위로 이름을 못 바꾼다. 우리 파일이니 푼다.
333
+ try { chmodSync(this.file, 0o600); } catch { /* 없거나 이미 쓸 수 있다 */ }
334
+ renameSync(옆, this.file);
298
335
  this.#잠갔나 = false;
299
336
  this.#잠그기();
300
- } catch (err) { this.#못썼다(err); }
337
+ } catch (err) {
338
+ try { rmSync(옆, { force: true }); } catch { /* 못 치워도 원본은 성하다 */ }
339
+ this.#못썼다(err);
340
+ }
301
341
  }
302
342
 
303
343
  readMeta() {
@@ -343,8 +383,16 @@ export class Store {
343
383
 
344
384
  // 사람에게 보여줄 첫 마디. 이게 목록에서 대화를 알아보는 유일한 단서다.
345
385
  function firstAsk(messages) {
346
- const m = messages.find((x) => x.role === 'user' && typeof x.content === 'string'
347
- && x.content.trim() && !x.content.startsWith('['));
386
+ const 쓸만한 = messages.filter((x) => x.role === 'user' && typeof x.content === 'string' && x.content.trim());
387
+ /*
388
+ * 대괄호로 여는 것은 우리가 끼워 넣은 글이라 건너뛴다. 그런데 사람도
389
+ * 「[급함] 로그인이 안 됩니다」 처럼 적는다 — 그 대화는 목록에서 「(빈
390
+ * 대화)」 가 됐다. 있는 대화를 없다고 적은 것이다.
391
+ *
392
+ * 그래서 건너뛰되, 남는 것이 하나도 없으면 첫 마디를 그대로 쓴다.
393
+ * 알아보기 어려운 제목이 「빈 대화」 라는 거짓말보다 낫다.
394
+ */
395
+ const m = 쓸만한.find((x) => !x.content.trim().startsWith('[')) ?? 쓸만한[0];
348
396
  return m ? m.content.replace(/\s+/g, ' ').trim() : '(빈 대화)';
349
397
  }
350
398
 
@@ -387,29 +435,73 @@ export function list(root, { limit = 20, 속까지 = true } = {}) {
387
435
  const out = [];
388
436
  for (const c of 후보) {
389
437
  if (out.length >= limit) break;
390
- const { meta, messages } = new Store(root, c.id).load();
391
- if (!messages.length) continue; // 빈 파일은 목록에 안 올린다
392
- out.push({
393
- ...c,
394
- model: meta?.model ?? '?',
395
- turns: messages.filter((m) => m.role === 'user').length,
396
- messages: messages.length,
397
- first: firstAsk(messages),
398
- });
438
+ const 줄 = 한줄(root, c);
439
+ if (줄) out.push(줄);
399
440
  }
400
441
  return out;
401
442
  }
402
443
 
444
+ /**
445
+ * 후보 하나를 열어 목록 한 줄로 만든다. 빈 파일이면 null.
446
+ *
447
+ * **못 읽은 것은 없는 것이 아니다.**
448
+ *
449
+ * load() 는 까닭을 담아 돌려주는데 부르는 쪽이 그것을 버리고 있었다. 그래서
450
+ * 권한이 막히거나 다른 프로그램이 잡고 있는 대화는 목록에서 통째로 사라졌고,
451
+ * 그런 것뿐이면 화면은 「아직 없습니다. 지금 이 대화가 첫 번째입니다」 라고
452
+ * 말했다 — 어제 한 일이 그대로 있는데 없다고 한 것이다. 이어할 수는 없어도
453
+ * **거기 있다는 것**은 말해야 사람이 손을 쓴다.
454
+ */
455
+ function 한줄(root, c) {
456
+ const { meta, messages, 못읽음 } = new Store(root, c.id).load();
457
+ if (못읽음) {
458
+ return { ...c, 못읽음, model: '?', turns: 0, messages: 0, first: `못 읽었습니다 — ${못읽음}` };
459
+ }
460
+ if (!messages.length) return null; // 빈 파일은 목록에 안 올린다
461
+ return {
462
+ ...c,
463
+ model: meta?.model ?? '?',
464
+ turns: messages.filter((m) => m.role === 'user').length,
465
+ messages: messages.length,
466
+ first: firstAsk(messages),
467
+ };
468
+ }
469
+
403
470
  export function remove(root, id) {
471
+ /*
472
+ * 이름에 상위 경로가 섞이면 대화 폴더 **밖**을 지운다.
473
+ * `deel sessions --rm ../../어딘가` 는 화면에 「지웠습니다」 라고 적고
474
+ * 엉뚱한 파일을 없앤다 — 무엇을 지웠는지 사람이 못 알아본다.
475
+ */
476
+ if (!/^[^\\/:*?"<>|]+$/.test(String(id)) || String(id).includes('..')) {
477
+ return { error: `대화 이름이 아닙니다: ${id}` };
478
+ }
404
479
  const f = join(sessionsDir(root), `${id}.jsonl`);
405
480
  if (!existsSync(f)) return { error: `그런 대화가 없습니다: ${id}` };
406
- rmSync(f, { force: true });
481
+ try { rmSync(f, { force: true }); }
482
+ catch (err) { return { error: `못 지웠습니다: ${id} — ${err?.code ?? err?.message ?? String(err)}` }; }
407
483
  return { removed: id };
408
484
  }
409
485
 
410
- /** 가장 최근 대화. --continue 가 집어 오는 것. */
486
+ /**
487
+ * 가장 최근 대화. --continue 가 집어 오는 것.
488
+ *
489
+ * 못 읽는 것은 건너뛴다. 그것을 집어 오면 --continue 가 **빈 대화**로 열리고,
490
+ * 사람은 어제 하던 것이 이어진 줄 알고 말을 건다.
491
+ */
411
492
  export function latest(root) {
412
- return list(root, { limit: 1 })[0] ?? null;
493
+ /*
494
+ * **한 개씩 열어 보고 첫 성한 것에서 멈춘다.**
495
+ *
496
+ * `list(root, { limit: 20 })` 로 스무 개를 통째로 읽으면 안 된다 — 이 파일이
497
+ * 위에서 경고한 그 자리다. 대화 파일 하나가 수 MB 이고, 이건 켤 때마다 돈다.
498
+ * 보통은 첫 파일 하나만 읽고 끝난다.
499
+ */
500
+ for (const c of list(root, { limit: 20, 속까지: false })) {
501
+ const 줄 = 한줄(root, c);
502
+ if (줄 && !줄.못읽음) return 줄;
503
+ }
504
+ return null;
413
505
  }
414
506
 
415
507
  /**
@@ -417,10 +509,18 @@ export function latest(root) {
417
509
  * 최근 keep 개는 무조건 남기고, 그보다 오래되고 days 를 넘긴 것만 지운다.
418
510
  */
419
511
  export function prune(root, { keep = 30, days = 30 } = {}) {
420
- // 지울지 말지는 파일 시각만 보면 안다. 내용은 필요 없다 —
421
- // 여기서 전부 읽던 것이 켤 때 몇 초씩 멈추던 원인이었다.
422
- const all = list(root, { limit: 1000, 속까지: false });
512
+ /*
513
+ * 지울지 말지는 파일 시각만 보면 안다. 내용은 필요 없다 —
514
+ * 여기서 전부 읽던 것이 켤 때 몇 초씩 멈추던 원인이었다.
515
+ *
516
+ * 개수를 1000 으로 끊어 놓으면 정작 **제일 오래된 것들**이 그 뒤에 있어서
517
+ * 영영 안 지워진다. 폴더가 끝없이 자라지 말라고 있는 함수가 딱 그 일을
518
+ * 못 하게 된다. 열지도 않고 시각만 보는 목록이라 전부 봐도 싸다.
519
+ */
520
+ const all = list(root, { limit: Infinity, 속까지: false });
423
521
  const 자를것 = all.slice(keep).filter((s) => (Date.now() - s.at.getTime()) > days * 86400000);
424
- for (const s of 자를것) { try { rmSync(s.file, { force: true }); } catch {} }
425
- return 자를것.length;
522
+ // 지웠다고 세는 것은 **정말 지워진 것**만이다.
523
+ let 지운수 = 0;
524
+ for (const s of 자를것) { try { rmSync(s.file, { force: true }); 지운수 += 1; } catch { /* 잡혀 있으면 다음에 */ } }
525
+ return 지운수;
426
526
  }