deel-local-cli 1.20.9 → 1.20.11

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/src/repl.js CHANGED
@@ -39,12 +39,12 @@ import { 못박기 } from './agent/pins.js';
39
39
  import { 카드 } from './agent/card.js';
40
40
  import { 배움 } from './agent/evolve.js';
41
41
  import { 마크다운 } from './ui/md.js';
42
- import { askHidden, confirm } from './ui/prompt.js';
42
+ import { askHidden } from './ui/prompt.js';
43
43
  import { explain, shows as levelShows } from './ui/level.js';
44
44
  import { 고르기 as 승인고르기, 다음 as 승인다음 } from './ui/approve.js';
45
45
  import { 추천, 채울글 } from './ui/complete.js';
46
46
  import { 접어쓰기 } from './ui/wrap.js';
47
- import { 접을까 as 붙임접을까, 표만들기 as 붙임표, 펼치기 as 붙임펼치기, 쓴번호들 as 붙임쓴번호들, 남길글 } from './ui/pastechip.js';
47
+ import { 접을까 as 붙임접을까, 표만들기 as 붙임표, 펼치기 as 붙임펼치기, 쓴번호들 as 붙임쓴번호들, 남길글, 안쪽최대 as 붙임안쪽최대 } from './ui/pastechip.js';
48
48
  import { 고른것풀기, 계획답풀기 } from './ui/pick.js';
49
49
  import { 이력지킴이 } from './ui/histline.js';
50
50
  import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
@@ -80,7 +80,7 @@ const TOOL_GLYPH = {
80
80
  function toolLabel(name, args) {
81
81
  const a = args ?? {};
82
82
  // 이름 차례는 tools/label.js 한 곳에서 온다 (다섯 벌이던 것을 모았다).
83
- // 여기서 더 하는 일은 화면 사정뿐이다 — 52자에서 자른다.
83
+ // 여기서 더 하는 일은 화면 사정뿐이다 — 56자에서 자른다.
84
84
  const first =
85
85
  첫이름(a) ??
86
86
  (a.command ? String(a.command).replace(/\s+/g, ' ').slice(0, 52) : null) ??
@@ -105,7 +105,11 @@ function toolLabel(name, args) {
105
105
  (Array.isArray(a.paths) && a.paths.length ? 세말('count', a.paths.length) : null) ??
106
106
  // 할 일 목록은 보여줄 경로가 없다. 빈 괄호를 띄우느니 개수를 적는다.
107
107
  (Array.isArray(a.todos) ? 세말('hits', a.todos.length) : null) ?? '';
108
- const g = TOOL_GLYPH[name] ?? c.cyan('⏺');
108
+ // 물려받은 이름으로 오면 `?? ` 가 안 걸린다 — TOOL_GLYPH['constructor']
109
+ // 함수라서 참이고, 그대로 글로 바뀌어 `function Object() { [native code] }`
110
+ // 가 도구 줄에 찍힌다. 모델이 그런 이름으로 도구를 부르면 실제로 여기 온다
111
+ // (loop.js 가 모르는 도구도 showLabel 로 올린다).
112
+ const g = Object.hasOwn(TOOL_GLYPH, name) ? TOOL_GLYPH[name] : c.cyan('⏺');
109
113
  const 안 = clip(String(first ?? ''), 56);
110
114
  return `${g} ${c.bold(name)}${안 ? `${c.gray('(')}${c.gray(안)}${c.gray(')')}` : ''}`;
111
115
  }
@@ -352,7 +356,20 @@ export async function chatLoop(opts = {}) {
352
356
  say(` ${mark.warn} ${c.gray('이 연결은 열쇠를 받아 옵니다. 아래 명령을 띄웁니다:')}`);
353
357
  say(` ${c.white(clip(설정.명령, 88))}`);
354
358
  say(` ${c.gray('받은 열쇠는 이 판이 도는 동안 메모리에만 있고, 파일로 안 적습니다.')}`);
355
- const 예 = await confirm(' 띄울까요?', true);
359
+ /*
360
+ * 이 자리는 repl 자신의 ask() 를 쓴다.
361
+ *
362
+ * 여기 있던 것은 ui/prompt.js 의 confirm 이었는데, 그 파일이 스스로
363
+ * 「REPL 은 readline 이 stdin 을 쥐고 있어서 위의 ask 를 그대로 못
364
+ * 쓴다」 고 못박아 두었다(그래서 암호는 askHidden 을 따로 만들었다).
365
+ * 이 한 자리만 옛 길로 남아 셋을 한꺼번에 깨고 있었다 —
366
+ * · readline 도 같은 바이트를 받아 그 'y' 가 큐에 쌓인다
367
+ * · 끝나면서 raw 모드를 꺼서 ESC·Shift+Tab 이 그때부터 안 먹는다
368
+ * · 여기서 Ctrl+C 를 누르면 prompt.js 의 process.exit(130) 이 돌아
369
+ * MCP·언어 서버·뒤에서 돌던 명령을 하나도 안 거두고 죽는다
370
+ */
371
+ const 답 = String(await ask('띄울까요?', { def: 'y', 끝나면: 'n', 멈추면: 'n' })).trim().toLowerCase();
372
+ const 예 = !['n', 'no', 'ㄴ', '아니', '아니요', '아니오', '취소', '그만'].includes(답);
356
373
  say('');
357
374
  return 예;
358
375
  };
@@ -381,7 +398,7 @@ export async function chatLoop(opts = {}) {
381
398
  say(` ${c.gray('이어할 대화가 없습니다. 새로 시작합니다.')}`);
382
399
  } else {
383
400
  store = new Store(root, target);
384
- const { messages: 적힌것 } = store.load();
401
+ const { messages: 적힌것, 못읽음 } = store.load();
385
402
  /*
386
403
  * 도구가 도는 중에 죽었으면 호출만 적히고 결과가 없다. 그대로 보내면
387
404
  * 규격 서버가 400 을 내서 이어받자마자 첫 마디에서 죽는다. 손봐서 받는다.
@@ -394,6 +411,29 @@ export async function chatLoop(opts = {}) {
394
411
  if (고친것) {
395
412
  say(` ${c.gray(`중단된 도구 호출 ${고친것}개를 걷어냈습니다 — 그때 하던 일은 다시 시켜 주세요.`)}`);
396
413
  }
414
+ } else {
415
+ /*
416
+ * ── 못 이어받았으면 **말한다** ──────────────────────────────────
417
+ *
418
+ * 여태 이 갈래가 통째로 없었다. `--resume 오타난아이디` 를 치면
419
+ * 화면에 **아무 줄도 안 나오고** 곧바로 머리말이 뜬다. 그러고는
420
+ * 그 아이디로 새 파일이 만들어져서, 사람이 「이어서 해줘」 라고
421
+ * 하면 모델은 아무것도 모른다 — 이어받은 줄 알고 말을 잇는데
422
+ * 상대는 처음 보는 대화다.
423
+ *
424
+ * store.load() 는 못 읽은 까닭을 이미 돌려준다(agent/store.js 의
425
+ * 못읽음). 그것도 여기서 버리고 있었다 — 권한 때문인지 다른
426
+ * 프로그램이 잡고 있어서인지 화면 어디에도 안 남았다.
427
+ */
428
+ say('');
429
+ if (못읽음) {
430
+ say(` ${mark.warn} ${c.bold(target)} ${c.gray('— 그 대화를 못 읽었습니다')} ${c.gray('(' + 못읽음 + ')')}`);
431
+ } else if (고친것) {
432
+ say(` ${mark.warn} ${c.bold(target)} ${c.gray(`— 남은 것이 중단된 도구 호출 ${고친것}개뿐이라 이어받을 말이 없습니다.`)}`);
433
+ } else {
434
+ say(` ${mark.warn} ${c.bold(target)} ${c.gray('— 그런 대화가 없거나 비어 있습니다.')}`);
435
+ }
436
+ say(` ${c.gray('새 대화로 시작합니다. 이어할 것을 찾으려면')} ${c.cyan('/sessions')}`);
397
437
  }
398
438
  /*
399
439
  * 못 박아 둔 것도 같이 되살린다 (agent/pins.js).
@@ -410,7 +450,19 @@ export async function chatLoop(opts = {}) {
410
450
  }
411
451
  if (!store) store = new Store(root);
412
452
  store.begin({ model: conn.model, base: 주소가리기(conn.base), root });
413
- try { prune(root); } catch {}
453
+ // 머리말보다 먼저 일어난 탈은 여기 모았다가 아래 경고 자리에서 같이 찍는다.
454
+ const 켤때경고 = [];
455
+ // 되돌리기 이력을 못 읽는다는 말은 한 판에 한 번만 한다 — 턴마다 세는 자리라
456
+ // 안 막으면 같은 줄이 턴 수만큼 쌓인다.
457
+ let 되돌리기못읽음말했나 = false;
458
+ // 이력이 커졌는데 못 줄인 것·깨진 줄. 둘 다 한 판에 한 번만 말한다.
459
+ let 이력탈말했나 = false;
460
+ /*
461
+ * 정리가 실패해도 켜는 것은 막지 않는다. 다만 **삼키지는 않는다.**
462
+ * 계속 실패하면 .deel/sessions 가 끝없이 커지는데 화면에는 영영 안 떴다.
463
+ * 쓰기 실패는 store 가 세어서 알리는데(아래 저장 경고) 이 자리만 옛 모양이었다.
464
+ */
465
+ try { prune(root); } catch (e) { 켤때경고.push(`오래된 대화 정리를 못 했습니다 — ${clip(String(e?.message ?? e), 60)}`); }
414
466
 
415
467
  /*
416
468
  * 밖에서 붙인 도구(MCP) 서버를 띄운다.
@@ -646,6 +698,17 @@ export async function chatLoop(opts = {}) {
646
698
  // 상자를 걷어내고, 사람이 보낸 글을 대화에 남긴다. 안 남기면 스크롤을
647
699
  // 올렸을 때 답만 있고 무엇을 물었는지가 없다.
648
700
  const 이었나 = 이어쓰기줄들 !== null;
701
+ /*
702
+ * **지우기 전에** 챙긴다.
703
+ *
704
+ * 아래 스물몇 줄 뒤에서 `이어쓴것` 을 보고 「앞줄은 이미 찍혔으니
705
+ * 마지막 줄만 이어 찍는다」 를 고르는데, 그 값이 바로 여기서 거짓으로
706
+ * 지워지고 있었다. 그래서 그 갈래는 **한 번도 안 골렸다** — 백틱으로
707
+ * 이어 쓰면 줄쌓기() 가 이미 찍어 둔 앞줄이 통째로 다시 찍혔다.
708
+ * 바로 그 자리의 주석이 「안 그러면 같은 줄이 두 번 보인다」 고
709
+ * 적어 둔 것인데, 코드가 정확히 그것을 하고 있었다.
710
+ */
711
+ const 백틱으로이었나 = 이어쓴것;
649
712
  보낼것 = 이었나 ? [...이어쓰기줄들, l].join('\n') : l;
650
713
  이어쓰기줄들 = null; 이어쓴것 = false;
651
714
  화면.입력지움();
@@ -666,10 +729,10 @@ export async function chatLoop(opts = {}) {
666
729
  * 백틱으로 이어 쓴 경우에는 앞줄이 이미 찍혀 있으므로 마지막 줄만
667
730
  * 이어 찍는다 — 안 그러면 같은 줄이 두 번 보인다.
668
731
  */
669
- const 찍을것 = 남길글(이어쓴것 ? l : 보낼것, 붙인것들);
732
+ const 찍을것 = 남길글(백틱으로이었나 ? l : 보낼것, 붙인것들);
670
733
  if (찍을것.trim() || 이었나) {
671
734
  for (const [i, 한줄] of String(찍을것).split('\n').entries()) {
672
- say(`${i === 0 && !이어쓴것 ? ` ${c.hcyan('❯')} ` : ' '}${c.white(한줄)}`);
735
+ say(`${i === 0 && !백틱으로이었나 ? ` ${c.hcyan('❯')} ` : ' '}${c.white(한줄)}`);
673
736
  }
674
737
  }
675
738
  } else if (l.trim() || 이어쓰기줄들 !== null) {
@@ -1077,7 +1140,42 @@ export async function chatLoop(opts = {}) {
1077
1140
  * y 를 쳐도 화면에 아무것도 안 나타난다 — 먹은 건지 안 먹은 건지 모른다.
1078
1141
  */
1079
1142
  묻는중 = 상자쓰나 ? 앞 : null;
1080
- 이어쓰기줄들 = null; 이어쓴것 = false; // 되묻는 사이에 걸쳐 있던 미완성 이어쓰기는 버린다
1143
+ /*
1144
+ * 쌓아 둔 이어쓰기를 버릴 때는 **말한다.**
1145
+ *
1146
+ * SIGINT 쪽(아래)은 「이어쓰던 것을 버렸습니다」 를 반드시 찍는데 여기만
1147
+ * 조용했다. 일하는 도중에 줄 끝 백틱으로 여러 줄을 쌓아 두면 그 글은
1148
+ * 화면에 안 찍힌 채 버퍼에만 있다 — 그 사이 물음이 뜨면 통째로 사라지고,
1149
+ * 사람은 자기가 친 세 줄이 어디로 갔는지 알 길이 없다.
1150
+ */
1151
+ if (이어쓰기줄들 !== null) {
1152
+ const 버린줄수 = 이어쓰기줄들.length;
1153
+ 이어쓰기줄들 = null; 이어쓴것 = false;
1154
+ say(` ${c.gray(`쌓아 두던 ${버린줄수}줄은 버렸습니다 — 물음이 끼어들었습니다.`)}`);
1155
+ }
1156
+ /*
1157
+ * ── 물음이 뜨기 **전에** 쳐 둔 줄은 이 물음의 답이 아니다 ────────────
1158
+ *
1159
+ * nextLine 은 큐에 뭐가 있으면 그것부터 준다. 그래서 일하는 동안 미리
1160
+ * 쳐 둔 「다음은 테스트도 돌려줘」 가, 30초 뒤에 뜬 「실행할까요?」 의
1161
+ * 답으로 튀어나왔다. y 가 아니니 거부로 읽히고, 그 지시는 큐에서
1162
+ * 사라져 모델에게 **영영 안 간다.** 화면에는 사람이 답한 것과 똑같은
1163
+ * 모양이 남는다 — 무엇이 답으로 쓰였는지 아무 데도 안 적힌다.
1164
+ *
1165
+ * Ask 도구는 더 나쁘다. 「3번 파일도 고쳐줘」 의 앞머리 숫자를 골라진
1166
+ * 항목으로 읽어서, 사람이 3번을 고른 것과 똑같은 화면이 된다.
1167
+ *
1168
+ * 잠깐 빼 뒀다가 되돌려 놓는다. 버리는 것이 아니다 — 턴이 끝나면
1169
+ * 평소대로 처리돼야 한다.
1170
+ *
1171
+ * ── 파이프는 다르다 ────────────────────────────────────────────────
1172
+ *
1173
+ * 손으로 치는 자리가 없으면 **미리 넣는 것 말고는 답할 길이 없다.**
1174
+ * 답을 미리 실어 보내는 것(`echo y | deel`)이 정상 사용이고,
1175
+ * 여기서 그것까지 빼 두면 물음이 영영 안 끝난다. 사람이 앞에 앉아
1176
+ * 있을 때만 「먼저 친 줄은 답이 아니다」 가 참이다.
1177
+ */
1178
+ const 미리쳐둔 = process.stdin.isTTY ? queue.splice(0) : [];
1081
1179
  const 멈춤 = o.signal ?? turn?.signal ?? null;
1082
1180
  try {
1083
1181
  const a = await nextLine(멈춤);
@@ -1087,9 +1185,21 @@ export async function chatLoop(opts = {}) {
1087
1185
  say(` ${c.gray(옮긴말('ask.stopped'))}`);
1088
1186
  return o.멈추면 ?? '';
1089
1187
  }
1090
- if (a === null) return o.def ?? '';
1188
+ /*
1189
+ * 입력이 **끝난 것**과 사람이 그냥 Enter 를 친 것은 다르다.
1190
+ *
1191
+ * 여태 둘 다 `def` 였다. 파이프로 들어왔거나 Ctrl+D 를 누르면 아무도
1192
+ * 답하지 않았는데 기본값이 답으로 잡힌다 — 「나가도 될까요?」 의 def 는
1193
+ * 'y' 라, 사람이 한 글자도 안 친 판에서 바깥 연결이 허락된다.
1194
+ * 부르는 쪽이 `끝나면` 으로 그 경우를 따로 정할 수 있게 한다.
1195
+ */
1196
+ if (a === null) return o.끝나면 ?? o.def ?? '';
1091
1197
  return a.trim() || o.def || '';
1092
- } finally { 묻는중 = null; }
1198
+ } finally {
1199
+ 묻는중 = null;
1200
+ // 빼 뒀던 것을 앞에 되돌린다. 차례가 바뀌면 안 된다.
1201
+ if (미리쳐둔.length) queue.unshift(...미리쳐둔);
1202
+ }
1093
1203
  };
1094
1204
 
1095
1205
  /**
@@ -1436,7 +1546,22 @@ export async function chatLoop(opts = {}) {
1436
1546
  // --ctx 로 직접 주신 값이 있으면 안 건드린다. 사람이 고른 것을 뒤집지 않는다.
1437
1547
  const 길이알림 = []; // 잘 된 소식
1438
1548
  const 길이경고 = []; // 손을 봐야 하는 것
1439
- if (opts.ctx == null) {
1549
+ /*
1550
+ * ── 부르는 자리가 둘이라 함수로 뺀다 ──────────────────────────────────
1551
+ *
1552
+ * 바깥 연결에 아직 허락을 안 준 판에서는 이 두드림이 **우리 문지기에**
1553
+ * 막힌다(safety/network.js). 아래 「나가도 될까요?」 에 답해야 문이 열리는데,
1554
+ * 그 물음은 머리말 다음에 있어야 해서 여기보다 한참 뒤다.
1555
+ *
1556
+ * 그래서 여태 바깥 프로필을 처음 붙이는 사람은 **언제나** 이 줄을 봤다:
1557
+ * ⚠ 컨텍스트를 서버가 안 알려줍니다 — 우선 32,768 으로 잡았습니다
1558
+ * 서버는 이 물음을 받아 본 적도 없다. 그리고 허락한 뒤에도 다시 안 물어봐서,
1559
+ * 128k 짜리 모델을 그 판 내내 32,768 로 알고 쓴다 — 화면 어디에도 그 말이 없다.
1560
+ *
1561
+ * 매개변수 이름을 바깥 것과 똑같이 둔 것은 일부러다. 몸통이 그대로 먹는다.
1562
+ */
1563
+ const 길이알아내기 = async (길이알림, 길이경고) => {
1564
+ if (opts.ctx != null) return;
1440
1565
  // 이 컴퓨터 안의 서버면 눈 깜짝할 새다. 사내 게이트웨이는 몇 초 걸릴 수 있어
1441
1566
  // 무슨 일이 일어나는 중인지 알려 준다 — 멈춘 것처럼 보이면 안 된다.
1442
1567
  화면.돌리기(옮긴말('run.ctxProbe'));
@@ -1493,9 +1618,36 @@ export async function chatLoop(opts = {}) {
1493
1618
  + `\n 너무 길면 서버가 알려 주는 값으로 저절로 맞춥니다. 아는 값이 있으면 ${c.cyan('/ctx 8192')} 처럼 직접 정하세요`);
1494
1619
  }
1495
1620
  } else if (prof.ctx == null) {
1496
- 길이경고.push(`컨텍스트를 서버가 안 알려줍니다 — 우선 ${CTX_DEFAULT.toLocaleString()} 으로 잡았습니다. ${c.cyan('/ctx 655360')} 처럼 직접 지정하세요`);
1621
+ /*
1622
+ * **왜** 못 알아냈는지까지 적는다.
1623
+ *
1624
+ * probeCtx 는 그 까닭을 이미 들고 온다(backend/ctxsize.js 의 why) —
1625
+ * 「나갈 허락이 없다」 · 「응답은 왔는데 길이를 안 준다」 · 「아무 응답도
1626
+ * 없다」 는 사람이 할 일이 셋 다 다르다. 여태 이 자리에서 그걸 버리고
1627
+ * 늘 「서버가 안 알려줍니다」 한 문장으로 뭉갰다.
1628
+ */
1629
+ 길이경고.push(`컨텍스트를 못 알아냈습니다 ${c.gray('(' + (r?.why ?? '까닭 모름') + ')')} — 우선 ${CTX_DEFAULT.toLocaleString()} 으로 잡았습니다. ${c.cyan('/ctx 655360')} 처럼 직접 지정하세요`);
1630
+ } else if (r?.why && !r?.tried?.some((t) => t.ok)) {
1631
+ /*
1632
+ * 프로필에 값이 적혀 있으면 **아무 말도 안 하던** 자리다.
1633
+ *
1634
+ * 이 블록 맨 위에 적어 둔 약속이 「켤 때마다 서버에 물어본다. 저장된
1635
+ * 값을 그대로 믿지 않는다」 인데, 못 물어본 판에서는 그 약속이 그냥
1636
+ * 거짓이 된다 — 머리말에는 저장된 숫자가 **방금 서버에서 확인한 값**
1637
+ * 처럼 뜬다. 서버에서 창을 줄여 놨어도 알 길이 없다.
1638
+ *
1639
+ * 서버가 **응답은 했는데 길이만 안 주는** 흔한 판에서는 조용히 있는다
1640
+ * (tried 에 ok 가 하나라도 있으면 그 경우다). 그건 늘 그런 것이라,
1641
+ * 켤 때마다 경고를 붙이면 곧 아무도 안 읽는다. 여기서 말하는 것은
1642
+ * **아무 데서도 대답을 못 받은** 판뿐이다.
1643
+ */
1644
+ 길이경고.push(`컨텍스트를 서버에 못 물어봤습니다 ${c.gray('(' + r.why + ')')}`
1645
+ + ` — 프로필에 적힌 ${c.white(Number(prof.ctx).toLocaleString())} 을 그대로 씁니다. 서버에서 바뀌었으면 ${c.cyan('/ctx auto')}`);
1497
1646
  }
1498
- }
1647
+ };
1648
+
1649
+ // 허락을 물어야 하는 연결이면 여기서 안 두드린다 — 묻고 난 뒤에 부른다.
1650
+ if (!나감.물어볼까) await 길이알아내기(길이알림, 길이경고);
1499
1651
 
1500
1652
  /*
1501
1653
  * ── 켤 때 도는 글자 모션 ──────────────────────────────────────────────
@@ -1557,6 +1709,7 @@ export async function chatLoop(opts = {}) {
1557
1709
  }
1558
1710
  if (!conn.tools) warn.push(옮긴말('run.noTools'));
1559
1711
  if (!conn.streaming) warn.push(옮긴말('run.noStream'));
1712
+ warn.push(...켤때경고);
1560
1713
  warn.push(...길이경고);
1561
1714
  // 잘 된 것은 경고 표시를 달지 않는다. ⚠ 가 붙으면 뭘 고쳐야 하나 싶어진다.
1562
1715
  for (const l of 길이알림) say(` ${mark.ok} ${c.gray(l)}`);
@@ -1576,6 +1729,22 @@ export async function chatLoop(opts = {}) {
1576
1729
  * 뒤집으면서 쓰던 사람을 안 깨뜨리는 길이 이것이다. 막는 것이 목적이 아니라
1577
1730
  * **모르고 나가는 것**을 없애는 것이 목적이다.
1578
1731
  */
1732
+ /*
1733
+ * 여기서 나가도 **띄운 것은 거둔다.**
1734
+ *
1735
+ * 이 자리의 `rl.close(); return 0;` 은 맨 아래 정리 블록을 통째로 건너뛰었다.
1736
+ * MCP 서버는 이미 떠 있다(위의 다붙이기) — 남의 프로세스가 그대로 남는다.
1737
+ * 「deel 을 껐는데도 그 서버가 계속 돌고 있게 된다」 는 바로 그 자리의
1738
+ * 주석이 없애겠다고 적어 둔 것인데, 이 길로 나가면 그대로 일어났다.
1739
+ */
1740
+ const 일찍끝내기 = (코드) => {
1741
+ rl.close();
1742
+ for (const 서버 of mcp붙임.서버들) 서버.닫기();
1743
+ 언어서버다끄기().catch(() => {});
1744
+ 화면.close();
1745
+ return 코드;
1746
+ };
1747
+
1579
1748
  if (나감.물어볼까) {
1580
1749
  const 어디 = (() => { try { return new URL(conn.base).host; } catch { return conn.base; } })();
1581
1750
  say('');
@@ -1584,14 +1753,39 @@ export async function chatLoop(opts = {}) {
1584
1753
  say(` ${c.gray('보내는 것 — 시킨 말, 그리고 모델이 읽은 파일의 내용입니다.')}`);
1585
1754
  say(` ${c.gray('로컬 모델(127.0.0.1)이나 사내망 주소였다면 이 물음이 안 나옵니다.')}`);
1586
1755
  say('');
1587
- const 답 = String(await ask(`나가도 될까요? ${c.gray('⏎ 허락하고 기억 · n 그만')}`, { def: 'y' }))
1756
+
1757
+ /*
1758
+ * ── 답할 사람이 없으면 **묻지 않는다** ────────────────────────────
1759
+ *
1760
+ * 이 자리가 이 프로그램이 파는 단 하나의 약속이다 — 「모르고 나가는
1761
+ * 것을 없앤다」. 그런데 파이프로 들어오면 그 약속이 조용히 꺼졌다:
1762
+ *
1763
+ * echo "이 폴더 요약해줘" | deel
1764
+ * › 나가도 될까요? ⏎ 허락하고 기억 · n 그만 [y] 이 폴더 요약해줘
1765
+ * 허락을 적어 뒀습니다 — 이 프로필은 다음부터 안 묻습니다.
1766
+ *
1767
+ * 사람은 한 글자도 답하지 않았다. 시킨 말이 답으로 먹혔고(그래서
1768
+ * 모델에게 가지도 않았다), online: true 가 설정 파일에 박혀 그 프로필은
1769
+ * 앞으로 영영 안 묻는다. 빈 파이프여도 같다 — EOF 가 def 'y' 로 잡혔다.
1770
+ *
1771
+ * 손으로 켠 판에서만 묻는다. 그 밖에는 안 나간다 — 미리 허락해 두는
1772
+ * 길(설정의 online, --online)이 이미 있고, 그 길은 사람이 마음먹고
1773
+ * 지나가는 자리다.
1774
+ */
1775
+ if (!process.stdin.isTTY) {
1776
+ say(` ${mark.warn} ${c.gray('파이프로 들어와 있어 물어볼 수가 없습니다 — 나가지 않았습니다.')}`);
1777
+ say(` ${c.gray('내보내려면 먼저 손으로 한 번 켜서 허락하거나,')} ${c.cyan('--online')} ${c.gray('을 붙이세요.')}`);
1778
+ return 일찍끝내기(0);
1779
+ }
1780
+
1781
+ // 멈춤(ESC)도 입력 끝(Ctrl+D)도 **허락이 아니다.** def 로 물러나면 둘 다 'y' 가 된다.
1782
+ const 답 = String(await ask(`나가도 될까요? ${c.gray('⏎ 허락하고 기억 · n 그만')}`, { def: 'y', 끝나면: 'n', 멈추면: 'n' }))
1588
1783
  .trim().toLowerCase();
1589
1784
  if (['n', 'no', 'ㄴ', '아니', '아니요', '아니오', '취소', '그만'].includes(답)) {
1590
1785
  say('');
1591
1786
  say(` ${c.gray('나가지 않았습니다. 아무것도 안 보냈습니다.')}`);
1592
1787
  say(` ${c.gray('이 컴퓨터 안의 모델을 붙이려면')} ${c.cyan('deel setup')} ${c.gray('또는')} ${c.cyan('deel scan --save')} ${c.gray('를 쓰세요.')}`);
1593
- rl.close();
1594
- return 0;
1788
+ return 일찍끝내기(0);
1595
1789
  }
1596
1790
  prof.online = true;
1597
1791
  try {
@@ -1603,6 +1797,18 @@ export async function chatLoop(opts = {}) {
1603
1797
  say(` ${mark.warn} ${c.gray(`허락을 저장하지 못했습니다 (${e.message}). 이번에만 나갑니다.`)}`);
1604
1798
  }
1605
1799
  allowEndpoint(conn.base);
1800
+
1801
+ /*
1802
+ * 이제야 문이 열렸다. 컨텍스트 길이는 **여기서** 알아본다.
1803
+ *
1804
+ * 위쪽 자리에서 두드렸다면 방금 연 이 문 때문에 전부 막혀 있었다.
1805
+ * 늦게 찍히는 만큼 자리를 따로 잡아 준다 — 머리말에 섞여 들어가면
1806
+ * 방금 답한 물음과 아무 상관 없는 줄로 보인다.
1807
+ */
1808
+ const 늦은알림 = []; const 늦은경고 = [];
1809
+ await 길이알아내기(늦은알림, 늦은경고);
1810
+ for (const l of 늦은알림) say(` ${mark.ok} ${c.gray(l)}`);
1811
+ for (const w of 늦은경고) say(` ${mark.warn} ${c.gray(w)}`);
1606
1812
  }
1607
1813
 
1608
1814
  /*
@@ -1704,12 +1910,55 @@ export async function chatLoop(opts = {}) {
1704
1910
  text = line.trim();
1705
1911
  if (!text) continue;
1706
1912
  if (예약이었나 && 상자쓰나) {
1913
+ /*
1914
+ * ── 통째로 쏟지 않는다 ──────────────────────────────────────────
1915
+ *
1916
+ * 큐에 든 것은 **펼쳐진 원문**이다(위 붙임펼치기). 여기서 그대로
1917
+ * say 하면 도구가 도는 사이에 붙여넣은 50줄이 그 자리에 쏟아진다.
1918
+ * 기다릴 때 보낸 것은 남길글() 로 `[붙여넣기 #1 · 47줄 · 2.1KB]` 로
1919
+ * 줄여 남기는데 이 길만 옛 모양이었다.
1920
+ *
1921
+ * 넓이도 지켜야 한다. 상자는 아래 몇 줄을 제 자리로 잡아 두고 거기에
1922
+ * 다시 그리는데, 터미널보다 넓은 줄을 밀어 넣으면 그 줄이 접히면서
1923
+ * 줄 셈이 어긋나고 위쪽 대화를 갉아먹는다.
1924
+ */
1925
+ const 폭 = Math.max(20, (process.stdout.columns ?? 80) - 8);
1926
+ const 줄들 = String(text).split('\n');
1927
+ const 넘치나 = 줄들.length > 붙임안쪽최대;
1928
+ const 보일것 = 넘치나 ? 줄들.slice(0, 2) : 줄들;
1707
1929
  화면.입력지움();
1708
- say(` ${c.hcyan('❯')} ${c.white(text)} ${c.gray('(미리 )')}`);
1930
+ for (const [i, 한줄] of 보일것.entries()) {
1931
+ say(`${i === 0 ? ` ${c.hcyan('❯')} ` : ' '}${c.white(clip(한줄, 폭))}`
1932
+ + `${i === 0 ? ` ${c.gray('(미리 쳐 둔 것)')}` : ''}`);
1933
+ }
1934
+ if (넘치나) {
1935
+ say(` ${c.gray(`… 그 밖에 ${줄들.length - 2}줄 — 통째로 보냈습니다.`)}`);
1936
+ }
1709
1937
  }
1710
1938
  }
1711
1939
 
1712
- const cmd = await handle(text, session, ctx);
1940
+ /*
1941
+ * ── 명령 하나가 대화를 끝내면 안 된다 ──────────────────────────────
1942
+ *
1943
+ * 여기는 어떤 try 안에도 없었다. 슬래시 명령 안에서 파일을 쓰다 던지면
1944
+ * (`.deel` 이 읽기 전용이거나 디스크가 찼을 때 writeFileSync 가 그렇다)
1945
+ * 그대로 위로 새어 나가 **프로세스가 끝났다.** `/memory 어쩌구` 한 줄에
1946
+ * 대화가 통째로 사라지는 것이다 — 오간 말도, 이어 갈 자리도 같이.
1947
+ *
1948
+ * 보이는 실패이긴 하지만 대가가 대화 전체다. 여기서 받아 한 줄 적고
1949
+ * 이어 간다. 무엇 때문인지도 같이 적는다 — 안 적으면 그 사람은 명령이
1950
+ * 왜 안 먹는지 영영 모른다.
1951
+ */
1952
+ let cmd;
1953
+ try {
1954
+ cmd = await handle(text, session, ctx);
1955
+ } catch (e) {
1956
+ say('');
1957
+ say(` ${c.red('✗')} ${c.gray('명령을 처리하다 막혔습니다')} ${c.gray('— ' + clip(String(e?.message ?? e), 90))}`);
1958
+ say(` ${c.gray('대화는 그대로입니다. 다른 명령을 치거나 그냥 이어서 말씀하세요.')}`);
1959
+ say('');
1960
+ continue;
1961
+ }
1713
1962
  if (cmd.exit) break;
1714
1963
  if (cmd.handled) continue;
1715
1964
  const toSend = cmd.text ?? text; // 슬래시 명령이면 펼쳐진 내용을 보낸다
@@ -1818,6 +2067,9 @@ export async function chatLoop(opts = {}) {
1818
2067
  // 이 턴이 탈 없이 끝났나. 끊겼거나 터진 뒤에 승인 창을 띄우면 안 된다 —
1819
2068
  // 계획이 반만 나온 것을 두고 "이대로 진행할까요?" 를 묻는 꼴이 된다.
1820
2069
  let 턴탈났나 = false;
2070
+ // 이 턴에 TodoWrite 가 실제로 돌았나. ctx.todos 는 세션 내내 살아 있어서
2071
+ // 이걸 안 세면 지난 턴의 남은 할 일을 이번 계획으로 착각한다 (아래 계획 승인).
2072
+ let 이번턴할일적었나 = false;
1821
2073
  /*
1822
2074
  * 걸음을 다 써서 중간에 끊긴 턴인가. 끊겼을 때 남은 할 일을 여기 담는다.
1823
2075
  *
@@ -2024,6 +2276,7 @@ export async function chatLoop(opts = {}) {
2024
2276
  // 같이 돈 것은 이름을 다시 적어 준다. 안 그러면 어느 결과인지 모른다.
2025
2277
  if (ev.parallel || ev.showLabel) say(` ${toolLabel(ev.name, ev.args)}`);
2026
2278
  if (ev.name === 'TodoWrite' && ev.result?.todos) {
2279
+ 이번턴할일적었나 = true;
2027
2280
  // 사무실 화이트보드도 이걸 본다. 할 일은 세션이 아니라 턴 문맥에
2028
2281
  // 있어서 상자가 스스로 못 읽는다 — 아는 자리에서 넣어 준다.
2029
2282
  화면.할일갱신(ev.result.todos);
@@ -2061,15 +2314,44 @@ export async function chatLoop(opts = {}) {
2061
2314
  * 다른 일이다. 화면에 초록 ✓ 가 없으면 아직 아무도 안 돌려 본
2062
2315
  * 것이고, 그 구분이 상태줄에 있어야 사람이 속지 않는다.
2063
2316
  */
2064
- if (ev.name === 'Verify' && ev.result) {
2317
+ /*
2318
+ * ── 안 돈 것을 돌았다고 세지 않는다 ──────────────────────────
2319
+ *
2320
+ * `ev.result` 만 보면 `{error:'거부됨'}` · `{error:'모르는 도구'}`
2321
+ * 에도 돈횟수 가 오른다. 상태줄은 돈횟수 가 0 이 아니고 탈 이
2322
+ * 없으면 **초록 ✓0** 을 그리는데(ui/status.js), 그 표시의 뜻은
2323
+ * 같은 파일이 「초록 ✓ 가 없으면 아직 아무도 안 돌려 본 것이다」
2324
+ * 라고 못박아 두었다. 거부된 Verify 가 초록 ✓ 로 뜨면 그 문장이
2325
+ * 거꾸로 선다 — 안 돌려 본 것을 돌려 봤다고 읽게 된다.
2326
+ *
2327
+ * 못 확인한 것도 센다. 도구 한 줄에서는 노랑으로 보여 주면서
2328
+ * 쌓는 자리에서만 사라지고 있었다.
2329
+ */
2330
+ if (ev.name === 'Verify' && ev.result && !ev.result.error) {
2065
2331
  session.검증.돈횟수 += 1;
2066
2332
  session.검증.확인 += ev.result.확인됨 ?? 0;
2067
2333
  session.검증.탈 += ev.result.탈 ?? 0;
2334
+ session.검증.못확인 = (session.검증.못확인 ?? 0) + (ev.result.못확인 ?? 0);
2068
2335
  }
2069
2336
  if (ev.result?.되돌릴것?.length) {
2070
2337
  const 것들 = ev.result.되돌릴것;
2071
2338
  say(` ${c.gray(`↩ ${것들.slice(0, 3).join(' · ')}${것들.length > 3 ? ` 외 ${것들.length - 3}개` : ''} 는 떠 뒀습니다 — /undo 로 되돌아갑니다`)}`);
2072
2339
  }
2340
+ /*
2341
+ * ── 못 뜬 것은 **되돌아가지 않는다** ──────────────────────
2342
+ *
2343
+ * 위 한 줄만 있을 때, `rm 로고.png` 는 「로고.png 는 떠 뒀습니다
2344
+ * — /undo 로 되돌아갑니다」 를 찍었다. 그림·xlsx·zip 은 내용을
2345
+ * 못 떠서 /undo 가 손도 안 대는데도 그랬다. 파일은 이미 없고,
2346
+ * 안전망이 있다는 말만 남는다.
2347
+ */
2348
+ if (ev.result?.못뜬것?.length) {
2349
+ const 못 = ev.result.못뜬것;
2350
+ say(` ${mark.warn} ${c.gray(`${못.slice(0, 3).join(' · ')}${못.length > 3 ? ` 외 ${못.length - 3}개` : ''} 는 못 떠 뒀습니다 — /undo 로 안 돌아옵니다`)}`);
2351
+ }
2352
+ if (ev.result?.스냅샷상한걸림) {
2353
+ say(` ${mark.warn} ${c.gray(`대상이 많아 앞 ${ev.result.되돌릴것?.length ?? 0}개만 떠 뒀습니다 — 나머지는 /undo 로 안 돌아옵니다`)}`);
2354
+ }
2073
2355
  // 파일을 고쳤으면 무엇이 바뀌었는지 바로 보여 준다.
2074
2356
  //
2075
2357
  // auto 모드는 안 물어보고 고친다. 여기서 안 보여주면 사람이
@@ -2118,6 +2400,20 @@ export async function chatLoop(opts = {}) {
2118
2400
  * 줄 수는 0 이다 — 옮긴 것이지 고친 것이 아니다.
2119
2401
  */
2120
2402
  for (const f of ev.result.바뀐것들) session.noteChange(f, { added: 0, removed: 0 });
2403
+ } else if (ev.result?.changed) {
2404
+ /*
2405
+ * ── 바뀐 **경로만** 주는 도구가 있다 ─────────────────────
2406
+ *
2407
+ * 파일 하나짜리 Move 와 hwpx Write 가 그렇다. 옮긴 것·새로 만든
2408
+ * 문서라 「몇 줄 바뀌었나」 가 없다. 그런데 위 세 갈래가 전부
2409
+ * diff·여럿·바뀐것들 을 보므로 셋 다 안 걸리고, 그러면
2410
+ * noteChange 가 **아예 안 불린다.**
2411
+ *
2412
+ * 파일은 진짜로 옮겨졌는데 /diff 목록에 없고, 상태줄 ✎ 에도 안
2413
+ * 들고, /commit 이 담는 목록에도 없다. 커밋은 초록으로 끝나고
2414
+ * 옮긴 파일만 안 담긴다 — 나중에 원격에서만 깨진다.
2415
+ */
2416
+ session.noteChange(ev.result.changed, { added: 0, removed: 0 });
2121
2417
  }
2122
2418
  }
2123
2419
  flush(); // 도구가 하나 끝날 때마다 적어 둔다
@@ -2364,7 +2660,24 @@ export async function chatLoop(opts = {}) {
2364
2660
  * (오류로 끝나면 뒤에 done 이 없다), 무엇이 잘렸는지 보라고 띄운
2365
2661
  * 경고가 정작 그 답의 마지막 줄보다 **위에** 찍힌다.
2366
2662
  */
2663
+ /*
2664
+ * ── 이 넷도 「탈 없이 끝난 턴」 이 아니다 ────────────────────────
2665
+ *
2666
+ * 아래 계획 승인은 `계획승인받나 && !턴탈났나` 로 걸린다. 그 규칙을
2667
+ * 적어 둔 주석은 「계획이 반만 나온 것을 두고 이대로 진행할까요? 를
2668
+ * 묻는 꼴이 된다」 였는데, 정작 **계획이 반만 나오는 대표 사건 넷**이
2669
+ * 이 깃발을 안 세우고 있었다:
2670
+ * capped — 출력 상한에서 글이 잘렸다
2671
+ * cutoff — 서버가 끝난 까닭도 없이 흐름을 멈췄다
2672
+ * limit — 걸음 수를 다 썼다
2673
+ * refusal — 모델이 거절했다. 계획이 아예 없다
2674
+ * 넷 다 for-await 는 예외 없이 끝나므로 깃발은 거짓인 채 남았다.
2675
+ * 그래서 3단계에서 잘린 반쪽에 `⏎ 그대로 진행` 이 뜨고, 창 제목은
2676
+ * 「끝남」 이었다. limit 은 한 술 더 떠서, 이 갈래가 이겨 버리는 바람에
2677
+ * 「끊긴 자리에서 이어서」 물음(else if)이 아예 안 돌았다.
2678
+ */
2367
2679
  case 'capped':
2680
+ 턴탈났나 = true;
2368
2681
  clearThinking();
2369
2682
  if (streamed) { 답비우기(); say(''); streamed = false; }
2370
2683
  say(` ${mark.warn} ${c.gray(`답이 ${c.white(ev.cap.toLocaleString())} 토큰에서 잘렸습니다`)}`
@@ -2379,6 +2692,7 @@ export async function chatLoop(opts = {}) {
2379
2692
  * /out 을 권하면 안 된다 — 상한 얘기가 아니라 연결 얘기다.
2380
2693
  */
2381
2694
  case 'cutoff':
2695
+ 턴탈났나 = true;
2382
2696
  clearThinking();
2383
2697
  if (streamed) { 답비우기(); say(''); streamed = false; }
2384
2698
  if (ev.멎은초) {
@@ -2398,6 +2712,7 @@ export async function chatLoop(opts = {}) {
2398
2712
  break;
2399
2713
 
2400
2714
  case 'limit':
2715
+ 턴탈났나 = true;
2401
2716
  끊긴할일 = ev.남은할일 ?? [];
2402
2717
  say('');
2403
2718
  say(` ${mark.warn} 도구 호출 ${ev.steps}회에서 멈췄습니다.`);
@@ -2489,10 +2804,25 @@ export async function chatLoop(opts = {}) {
2489
2804
  }
2490
2805
  break;
2491
2806
 
2807
+ /*
2808
+ * 훅이 막았다. **왜 막았는지**까지 적는다.
2809
+ *
2810
+ * 여태 `ev.말` 을 안 썼다. 거기에는 safety/hooks.js 의 막힘말 이
2811
+ * 들어 있고, 그 안에 두 가지가 담긴다 — 훅이 뱉은 진짜 사유와,
2812
+ * 「훅을 못 돌렸습니다 — <오류>」·「훅이 <코드> 로 끝났습니다」.
2813
+ * 그걸 버리면 화면은 `✗ 훅이 막았습니다 (dlp-check)` 한 줄뿐이라,
2814
+ * 사내 정책이 진짜로 막은 것인지 훅 파일이 없어져 못 돈 것인지
2815
+ * 구별할 수가 없다. 뒤엣것은 사람이 고칠 수 있는 탈인데도.
2816
+ */
2492
2817
  case 'hook_block':
2493
2818
  clearThinking();
2494
2819
  say('');
2495
2820
  say(` ${c.yellow('✗')} ${c.white(옮긴말('ev.hookBlock'))} ${c.gray(`(${ev.훅?.이름 ?? ev.훅?.명령 ?? ''})`)}`);
2821
+ if (ev.말) {
2822
+ for (const 한줄 of String(ev.말).split('\n').slice(0, 4)) {
2823
+ if (한줄.trim()) say(` ${c.gray(clip(한줄.trim(), 88))}`);
2824
+ }
2825
+ }
2496
2826
  break;
2497
2827
 
2498
2828
  case 'nudge':
@@ -2512,6 +2842,7 @@ export async function chatLoop(opts = {}) {
2512
2842
  * 또 거절이고, 요금만 두 배가 된다. 거절은 거절이라고 적는다.
2513
2843
  */
2514
2844
  case 'refusal': {
2845
+ 턴탈났나 = true;
2515
2846
  clearThinking();
2516
2847
  // 아래에서 볼 값이라 **지우기 전에** 챙긴다. 순서를 놓치면
2517
2848
  // `!streamed` 가 늘 참이 되어 화면에 이미 있는 답을 또 찍는다.
@@ -2571,6 +2902,10 @@ export async function chatLoop(opts = {}) {
2571
2902
  // 중간에 끊겨도 자리는 비운다. 안 비우면 다음 턴이 시작될 때까지
2572
2903
  // 아무도 안 도는 방에 사람이 앉아 있다.
2573
2904
  화면.함께갱신(0);
2905
+ // 화면만 비우면 안 된다. 세는 값도 같이 내린다 — 하위 작업이 도는 중에
2906
+ // 턴이 터지면 task_done 이 안 오고, 그 1 이 남아서 다음 턴의 첫 하위
2907
+ // 작업이 2 로 뜬다. 사무실에 앉은 사람 수가 실제와 어긋난다.
2908
+ 도는하위 = 0;
2574
2909
  화면.하위갱신(0);
2575
2910
  clearInterval(제목시계);
2576
2911
  /*
@@ -2585,7 +2920,45 @@ export async function chatLoop(opts = {}) {
2585
2920
  * 다시 그려지고, 거기서 이력 파일을 열면 타이핑이 끊긴다. 턴 경계에서
2586
2921
  * 한 번 세어 두면 화면은 그 숫자만 읽으면 된다.
2587
2922
  */
2588
- try { session.되돌릴턴 = ctx.history.turns().length; } catch { /* 못 세면 그냥 둔다 */ }
2923
+ /*
2924
+ * 못 세면 **옛 숫자를 지운다.**
2925
+ *
2926
+ * 여태 `catch {}` 라 직전 값이 그대로 남았고, 상태줄은 계속 `↩ 3` 을
2927
+ * 그렸다. 그 숫자를 status.js 는 「안전망의 잔량」 이라고 부른다 —
2928
+ * 이력을 못 읽는 상태는 곧 /undo 도 못 하는 상태인데, 화면에는 되돌릴
2929
+ * 것이 셋 남았다고 떠 있는 셈이다. 한 번은 말하고, 숫자는 내린다.
2930
+ */
2931
+ try {
2932
+ session.되돌릴턴 = ctx.history.turns().length;
2933
+ /*
2934
+ * ── 안전망이 **제 무게로 무너지는** 것도 여기서 잡힌다 ──────────
2935
+ *
2936
+ * 되돌리기 이력은 파일 내용을 통째로 담아서 금방 수십 MB 가 된다.
2937
+ * 32MB 를 넘으면 오래된 턴부터 버리는데, 그 버리기가 실패하면
2938
+ * (읽기 전용·디스크 참) 이력은 끝없이 자라고 화면에는 영영 안 떴다.
2939
+ * 실패는 대개 이어지는 종류라 저절로 낫지도 않는다.
2940
+ *
2941
+ * 깨진 줄도 같다. 이력 한 줄이 곧 한 파일의 원문이라, 줄이 깨졌다는
2942
+ * 것은 그 파일을 되돌릴 길이 사라졌다는 뜻이다. 상태줄의 `↩ 3` 은
2943
+ * 그 셋이 다 되돌아간다는 말인데, 깨진 줄이 있으면 그게 아니다.
2944
+ */
2945
+ if (!이력탈말했나 && (ctx.history.줄이기못함 || ctx.history.깨진줄)) {
2946
+ 이력탈말했나 = true;
2947
+ if (ctx.history.줄이기못함) {
2948
+ say(` ${mark.warn} ${c.gray('되돌리기 이력이 커졌는데 못 줄였습니다')} ${c.gray('— ' + clip(String(ctx.history.줄이기못함), 60))}`);
2949
+ }
2950
+ if (ctx.history.깨진줄) {
2951
+ say(` ${mark.warn} ${c.gray(`되돌리기 이력에서 ${ctx.history.깨진줄}줄을 못 읽었습니다 — 그만큼은 ${'/undo'} 로 안 돌아옵니다.`)}`);
2952
+ }
2953
+ }
2954
+ } catch (e) {
2955
+ session.되돌릴턴 = 0;
2956
+ if (!되돌리기못읽음말했나) {
2957
+ 되돌리기못읽음말했나 = true;
2958
+ say(` ${mark.warn} ${c.gray('되돌리기 이력을 못 읽었습니다')} ${c.gray('— ' + clip(String(e?.message ?? e), 60))}`);
2959
+ say(` ${c.gray('이 판에서는')} ${c.cyan('/undo')} ${c.gray('가 안 될 수 있습니다.')}`);
2960
+ }
2961
+ }
2589
2962
  if (알릴까({ 걸린밀리초: Date.now() - started, 켬: 알림.켬 })) 종();
2590
2963
  창제목(제목글(턴탈났나 ? '탈남' : '끝남', { 폴더: 알림.폴더 }));
2591
2964
  turn = null;
@@ -2654,7 +3027,23 @@ export async function chatLoop(opts = {}) {
2654
3027
  * 애초에 계획 같은 걸 안 보려 든다.
2655
3028
  */
2656
3029
  if (계획승인받나 && !턴탈났나) {
2657
- const 할일 = (ctx.todos ?? []).filter((t) => t.state !== 'done');
3030
+ /*
3031
+ * ── 이번 턴에 적은 계획만 이번 계획이다 ──────────────────────────
3032
+ *
3033
+ * `ctx` 는 켤 때 한 번 만들어 세션 내내 쓰이고, `ctx.todos` 를 비우는
3034
+ * 자리는 어디에도 없다. 그래서 이런 판이 난다:
3035
+ *
3036
+ * 턴 A 로그인 작업이 걸음 상한에 걸려 안 끝난 할 일 둘이 남는다
3037
+ * 턴 B 「결제 모듈 설계하고 만들어줘」 → 겹침이라 계획 승인이 켜진다
3038
+ * 모델은 TodoWrite 없이 산문으로만 설계를 낸다
3039
+ * 화면 ┌ 계획 / 1. 로그인 폼 만들기 / 2. 세션 쿠키 / ⏎ 그대로 진행
3040
+ *
3041
+ * ⏎ 를 누르면 그 **지난 턴 목록**이 「방금 낸 계획」 이라는 이름표를
3042
+ * 달고 모델에게 실려 나간다. 이 파일은 「TodoWrite 를 안 쓴 계획도
3043
+ * 있다」 를 이미 대비해 뒀지만(할일.length === 0 갈래), 목록이 **비지
3044
+ * 않고 옛것으로 차 있는** 경우가 그 그물을 통과했다.
3045
+ */
3046
+ const 할일 = 이번턴할일적었나 ? (ctx.todos ?? []).filter((t) => t.state !== 'done') : [];
2658
3047
  say('');
2659
3048
  계획상자(할일);
2660
3049
  say('');
@@ -2733,7 +3122,15 @@ export async function chatLoop(opts = {}) {
2733
3122
  ? 옮긴말('plan.replanStep', { 단계: 계획답.단계 })
2734
3123
  : 옮긴말('plan.replan'))}`);
2735
3124
  }
2736
- } else if (끊긴할일) {
3125
+ /*
3126
+ * 빈 배열은 「이어갈 것이 있다」 가 아니다.
3127
+ *
3128
+ * `끊긴할일 = ev.남은할일 ?? []` 라 할 일이 0개인 채 걸음 상한에
3129
+ * 걸려도 여기가 참이 됐다. 그러면 물음이 뜨고, ⏎ 한 번에 「하던
3130
+ * 자리에서 이어서 해라」 로 모델을 한 판 더 부른다 — 이어갈 자리가
3131
+ * 없는데.
3132
+ */
3133
+ } else if (끊긴할일?.length) {
2737
3134
  /*
2738
3135
  * ── 끊긴 자리에서 ⏎ 하나로 잇는다 ────────────────────────────────
2739
3136
  *