deel-local-cli 1.6.1 → 1.6.2

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/agent/loop.js CHANGED
@@ -17,6 +17,7 @@ import { allowTemporarily, isOffline } from '../safety/network.js';
17
17
  import { 가리기, 훑기, 가렸다는말, 봤다는말, 가릴도구 } from '../safety/secrets.js';
18
18
  import { get as workMode } from './modes.js';
19
19
  import { 지시말 } from '../i18n/index.js';
20
+ import { 빠진것, 빠졌다는말 } from './asks.js';
20
21
 
21
22
  /*
22
23
  * 콜백으로만 소식을 주는 부름을, 제너레이터가 중간에 내보낼 수 있는 모양으로 바꾼다.
@@ -60,6 +61,36 @@ function 빈답인가(msg) {
60
61
  return !String(msg.content ?? '').trim();
61
62
  }
62
63
 
64
+ /**
65
+ * 도구 결과 중에서 **대화에 실을 글**을 고른다.
66
+ *
67
+ * ── 왜 따로 두나 ────────────────────────────────────────────────────────
68
+ *
69
+ * 여태 여기는 `result.content ?? ''` 한 줄이었다. 그런데 도구가 `summary` 만
70
+ * 돌려주는 자리가 있다 — Recall 이 지난 대화를 다 뒤졌지만 못 찾았을 때가
71
+ * 그렇다. 그 경우 모델에게 가는 것은 **빈 글**이다.
72
+ *
73
+ * 사람 화면에는 「지난 대화 12개를 다 뒤졌지만 없습니다」 가 멀쩡히 찍힌다.
74
+ * 그래서 아무도 눈치를 못 챈다. 정작 모델은 아무 말도 못 들었으므로,
75
+ * 못 찾았다는 것도 모르고 **찾아본 적이 있다는 것조차** 모른다. 그 자리에서
76
+ * 모델이 하는 일은 둘 중 하나다 — 없는 것을 지어내거나, 사람에게 엉뚱한
77
+ * 선택지를 들이민다. 실제 제보가 딱 그 모양이었다.
78
+ *
79
+ * 그래서 규칙을 셋으로 못 박는다.
80
+ * 1) 글이 있으면 그대로.
81
+ * 2) 글이 없고 요약이 있으면 요약을 싣는다 — 사람이 본 것과 같은 말이다.
82
+ * 3) 둘 다 없으면 **없다고 말한다.** 침묵은 지어내기를 부른다.
83
+ */
84
+ export function 실을글(result) {
85
+ if (result?.error) return `오류: ${result.error}`;
86
+ const 글 = result?.content;
87
+ if (String(글 ?? '').trim()) return String(글);
88
+ const 요약 = String(result?.summary ?? '').trim();
89
+ if (요약) return 요약;
90
+ return '도구는 돌았지만 돌려준 것이 없습니다.'
91
+ + ' 없는 것을 있다고 지어내지 말고, 못 얻었다는 것을 그대로 두고 다음으로 넘어가세요.';
92
+ }
93
+
63
94
  // think 값을 규격에 맞게. 'off' 는 사고를 끈다.
64
95
  function thinkFor(conn, level) {
65
96
  if (level === 'off') return conn.kind === 'ollama' ? false : undefined;
@@ -122,6 +153,22 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
122
153
  */
123
154
  ctx.signal = signal;
124
155
 
156
+ /*
157
+ * 이번에 사람이 실제로 친 말. 도구가 볼 수 있게 여기 걸어 둔다.
158
+ *
159
+ * Ask 관문(agent/askcheck.js)이 이걸 본다 — "사용자가 이미 적어 준 것을
160
+ * 되묻고 있나" 는 원문 없이는 판단할 수 없다. 프롬프트에는 들어 있지만
161
+ * 그건 모델이 보는 것이고, 우리 코드가 볼 수 있는 자리가 없었다.
162
+ *
163
+ * 하위 작업에서는 그 하위가 받은 할일이 들어온다. 그게 맞다 — 하위에게
164
+ * '사용자의 말' 은 자기가 받은 할일이다.
165
+ */
166
+ ctx.요청 = String(userText ?? '');
167
+ // 이번 턴에 물어본 것. 관문이 "같은 것을 또 묻나" 를 보는 자리다 (agent/askcheck.js).
168
+ ctx.물은것 = [];
169
+ // 접힐 때 요약에 뭉개지지 않게 원문을 세션에도 박아 둔다 (compact.js 의 못박은요청).
170
+ session.이번요청 = ctx.요청;
171
+
125
172
  const conn = session.conn;
126
173
 
127
174
  /**
@@ -354,6 +401,34 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
354
401
  return null;
355
402
  };
356
403
 
404
+ /*
405
+ * ── 시킨 것을 다 했나 (agent/asks.js) ────────────────────────────────
406
+ *
407
+ * 위의 되밀기는 **아무것도 안 바꿨을 때**만 본다(손댄파일.size 로 빠져나간다).
408
+ * 그런데 제보받은 것은 그게 아니었다 — 네 가지를 시키면 셋을 고치고 끝냈다.
409
+ * 뭐라도 고쳤으니 되밀기는 안 걸리고, 안 한 하나는 말없이 사라진다.
410
+ *
411
+ * 그래서 여기서 따로 대조한다. 시킨 것을 항목으로 쪼개 두고, 항목마다
412
+ * 자국이 남았는지 본다 — 할 일 목록에 있나, 손댄 파일 이름에 있나,
413
+ * 마지막 보고에 있나. 하나도 없으면 잊은 것이다.
414
+ *
415
+ * 한 번만 민다. 밀고도 그대로면 사람에게 말한다 — 두 번 세 번 미는 것은
416
+ * 모델이 못 하는 일을 억지로 시키는 꼴이고, 그 왕복은 사람이 낸다.
417
+ */
418
+ let 빠뜨림민적 = false;
419
+ const 빠뜨린것 = (글) => {
420
+ // 하위 작업은 사람이 시킨 말이 아니다. 거기 할일은 부모가 이미 쪼개 준 것이다.
421
+ if (깊이) return [];
422
+ return 빠진것({
423
+ 요청: userText,
424
+ 자국: [
425
+ ...(ctx.todos ?? []).map((x) => x.text),
426
+ ...손댄파일,
427
+ String(글 ?? ''),
428
+ ],
429
+ });
430
+ };
431
+
357
432
  const 되미는말 = () => {
358
433
  const 시킨 = String(userText ?? '').replace(/\s+/g, ' ').trim().slice(0, 160);
359
434
  return 지시말() === 'en'
@@ -644,7 +719,15 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
644
719
  yield { type: 'nudge', why: 왜, text: msg.content };
645
720
  continue;
646
721
  }
647
- yield { type: 'done', steps, text: msg.content, files: 마무리() };
722
+ const 빠진 = 빠뜨린것(msg.content);
723
+ if (빠진.length && !빠뜨림민적) {
724
+ 빠뜨림민적 = true;
725
+ session.push({ role: 'user', content: 빠졌다는말(빠진, { 영어: 지시말() === 'en' }) });
726
+ yield { type: 'nudge', why: '요청누락', 빠진, text: msg.content };
727
+ continue;
728
+ }
729
+ // 밀고도 그대로면 조용히 넘어가지 않는다. 사람이 알아야 다음을 정한다.
730
+ yield { type: 'done', steps, text: msg.content, files: 마무리(), 빠진 };
648
731
  return;
649
732
  }
650
733
 
@@ -1058,7 +1141,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1058
1141
  for (const f of result.여럿 ?? []) if (f.ok && f.path) 손댄파일.add(f.path);
1059
1142
 
1060
1143
  // 앞에서 똑같이 부른 적이 있고 결과도 같으면, 결과를 다시 싣지 않는다.
1061
- let 실을것 = result.error ? `오류: ${result.error}` : result.content ?? '';
1144
+ let 실을것 = 실을글(result);
1062
1145
  if (!result.error) {
1063
1146
  const 서명 = 서명만들기(call);
1064
1147
  // 결과도 한 모양으로 맞춰 견준다. 여러 도구가 받은 경로를 답에 그대로
@@ -154,6 +154,38 @@ export function 겹친요청(text) {
154
154
  return { 겹침: true, why: `'${앞[0].trim()}' 뒤에 '${뒤[0].trim()}'` };
155
155
  }
156
156
 
157
+ /*
158
+ * ── 손대라는 말 ─────────────────────────────────────────────────────────
159
+ *
160
+ * "폴더 구조 개선해줘" 가 설계 모드로 갔다. 설계는 **읽기만 하는 모드**라,
161
+ * 사람은 고쳐 달라고 했는데 설계안 한 장을 받고 끝났다. 이 파일 맨 위에
162
+ * 적어 둔 것이 정확히 그 경우다 — "읽기 전용 모드로 잘못 보내면 사용자는
163
+ * '왜 파일을 안 고쳐?' 하고 막힌다."
164
+ *
165
+ * 낱말 점수로는 못 막는다. '구조' 와 '개선' 이 둘 다 설계의 강한 신호라서,
166
+ * 뒤에 붙은 '해줘' 를 아무리 봐도 점수로는 안 뒤집힌다.
167
+ *
168
+ * 그래서 **동사를 따로 본다.** 손대라는 말이 있으면 읽기만 하는 모드
169
+ * (설계·계획·묻기)는 후보에서 뺀다. 남은 것 중에 고르고, 없으면 종합에
170
+ * 그대로 둔다 — 종합은 파일을 고칠 수 있다.
171
+ *
172
+ * 가르는 자리가 미묘하다. '설명해줘' 와 '개선해줘' 는 둘 다 '…해줘' 지만
173
+ * 하나는 읽고 말하라는 말이고 하나는 고치라는 말이다. 그래서 어미가 아니라
174
+ * **동사 목록**으로 본다. 목록에 없으면 손대라는 말이 아니다.
175
+ */
176
+ const 손대는동사 = '만들|고쳐|고치|바꿔|바꾸|추가|넣어|지워|삭제|옮겨|나눠|나누|합쳐|정리|개선|구현|작성|적용|설치|배포|리팩터링|리팩터|분리|통일|세팅|갈아|손봐|손보';
177
+ // 동사 바로 뒤에 시킴꼴이 붙어야 한다. 빈칸을 안 넘으므로 "나누는 설계만 봐줘"
178
+ // 는 안 걸린다 — 그건 나누라는 말이 아니라 나누는 안을 보자는 말이다.
179
+ const 손대라는말 = new RegExp(`(${손대는동사})\\S{0,3}(줘|주세요|주라|해라|하라|하자|해다오|해줄래|할래)`);
180
+
181
+ // 파일을 못 고치는 모드. modes.js 에서 쓰기 도구를 안 주는 것들과 같아야 한다.
182
+ const 읽기만하는모드 = new Set(['architect', 'plan', 'ask']);
183
+
184
+ /** 파일을 손대라는 말이 들었나. */
185
+ export function 손대라했나(text) {
186
+ return 손대라는말.test(String(text ?? ''));
187
+ }
188
+
157
189
  /**
158
190
  * 이 한마디가 무슨 일인지 고른다.
159
191
  *
@@ -186,10 +218,28 @@ export function route(text) {
186
218
  근거[mode] = 걸린것;
187
219
  }
188
220
 
189
- const 순위 = Object.entries(점수들).sort((a, b) => b[1] - a[1]);
221
+ /*
222
+ * 손대라는 말이 있으면 읽기만 하는 모드는 후보에서 뺀다.
223
+ *
224
+ * 점수를 깎지 않고 **줄에서 뺀다.** 깎으면 얼마나 깎을지가 또 하나의 눈금이
225
+ * 되고, 그 눈금은 아무도 못 지킨다. 여기서 가르는 것은 정도의 문제가 아니라
226
+ * 종류의 문제다 — 고칠 수 있는 자리인가 아닌가.
227
+ */
228
+ const 손대라 = 손대라했나(s);
229
+ const 뺀것 = [];
230
+ let 순위 = Object.entries(점수들).sort((a, b) => b[1] - a[1]);
231
+ if (손대라) {
232
+ for (const [m, n] of 순위) if (읽기만하는모드.has(m) && n > 0) 뺀것.push(m);
233
+ 순위 = 순위.filter(([m]) => !읽기만하는모드.has(m));
234
+ }
190
235
  const [으뜸, 점수] = 순위[0] ?? [null, 0];
191
236
  if (!으뜸 || 점수 < (문턱[으뜸] ?? 4)) {
192
- return { mode: null, score: 점수, why: '무슨 일인지 뚜렷하지 않음', 겹침: false, 점수들 };
237
+ // 바꿨는지는 화면에 그대로 뜬다. "설계로 뻔했는데 고치라는 말이라
238
+ // 안 보냈다" 와 "그냥 모르겠다" 는 사람에게 아주 다른 말이다.
239
+ const 왜 = 뺀것.length
240
+ ? `${뺀것.join('·')} 신호가 있었지만 고치라는 말이라 읽기 전용 모드로 안 보냄`
241
+ : '무슨 일인지 뚜렷하지 않음';
242
+ return { mode: null, score: 점수, why: 왜, 겹침: false, 점수들, 뺀것 };
193
243
  }
194
244
 
195
245
  // 1·2등이 붙으면 고르지 않는다. 반반인 것을 억지로 정하면 절반은 틀린다.
@@ -201,7 +251,8 @@ export function route(text) {
201
251
  return {
202
252
  mode: 으뜸,
203
253
  score: 점수,
204
- why: 근거[으뜸].slice(0, 2).map((x) => `'${x}'`).join(', '),
254
+ why: [근거[으뜸].slice(0, 2).map((x) => `'${x}'`).join(', '),
255
+ 뺀것.length ? `(${뺀것.join('·')} 는 고치라는 말이라 뺐습니다)` : ''].filter(Boolean).join(' '),
205
256
  겹침: false,
206
257
  점수들,
207
258
  };
package/src/repl.js CHANGED
@@ -36,6 +36,7 @@ import { explain, shows as levelShows } from './ui/level.js';
36
36
  import { 고르기 as 승인고르기, 다음 as 승인다음 } from './ui/approve.js';
37
37
  import { 추천, 채울글 } from './ui/complete.js';
38
38
  import { 접어쓰기 } from './ui/wrap.js';
39
+ import { 접을까 as 붙임접을까, 표만들기 as 붙임표, 펼치기 as 붙임펼치기, 쓴번호들 as 붙임쓴번호들 } from './ui/pastechip.js';
39
40
  import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
40
41
  import { renderDiff, shortStat } from './ui/diff.js';
41
42
  import { expand as expandMentions } from './agent/mention.js';
@@ -430,9 +431,39 @@ export async function chatLoop(opts = {}) {
430
431
  * (TDZ). 켜자마자 아무 키나 누르면 죽는 프로그램이 되는 셈이다.
431
432
  */
432
433
  let turn = null;
434
+
435
+ /*
436
+ * 멈추라는 것이 닿았다고 **곧바로** 화면에 적는다.
437
+ *
438
+ * 멈추기는 즉시가 아니다. 돌던 자식 프로그램(rg·soffice)을 죽이고, 안 돈
439
+ * 도구 자리를 채우고, 대화를 성하게 닫는 데 잠깐이 걸린다. 그 잠깐 동안
440
+ * 화면이 아까 하던 말을 그대로 하고 있으면 사람은 안 먹었다고 판단한다.
441
+ * 그러면 또 누르고, 그러다 Ctrl+C 로 손이 가고, 두 번 누르면 대화가 닫힌다.
442
+ *
443
+ * "ESC 를 눌러도 안 멈춘다" 는 제보의 절반은 진짜로 안 멈춘 것이었고
444
+ * (동기 호출이 키를 안 배달했다), 나머지 절반이 이것이다 — 멈추고는
445
+ * 있는데 그렇게 보이지 않았다.
446
+ */
447
+ const 멈추는중 = () => { try { 화면.일바꿈('멈춤'); } catch { /* 상자가 없는 화면도 있다 */ } };
448
+
433
449
  // 붙여넣는 중인가 (bracketed paste). 키 처리와 'line' 이 같이 본다.
434
450
  let 붙여넣는중 = false;
435
451
 
452
+ /*
453
+ * ── 붙여넣은 덩이를 접어 둔다 ────────────────────────────────────────
454
+ *
455
+ * 상자에 남는 것은 `[붙여넣기 #1 · 47줄 · 2.1KB]` 한 줄이고, 원문은
456
+ * 여기 그대로 있다가 보낼 때 되돌아간다. 왜 접는지는 pastechip.js 에.
457
+ *
458
+ * 접기 전 화면이 어디까지였는지도 같이 적어 둔다. 붙여넣기는 커서
459
+ * 자리에 끼어 들어오므로, 무엇이 새로 온 것인지는 시작 표를 받은
460
+ * 순간을 기억해 두어야만 안다.
461
+ */
462
+ const 붙인것들 = new Map();
463
+ let 붙임번호 = 0;
464
+ let 붙임앞줄수 = 0;
465
+ let 붙임앞입력 = '';
466
+
436
467
  /** 줄을 쌓아 두고 "다음 줄" 이라고 알린다. 백틱 표시와 Alt+Enter 가 같이 쓴다. */
437
468
  const 줄쌓기 = (줄) => {
438
469
  const 첫줄 = 이어쓰기줄들 === null;
@@ -529,8 +560,22 @@ export async function chatLoop(opts = {}) {
529
560
  화면.대기갱신('', queue.length + 1);
530
561
  }
531
562
  }
532
- if (waiter) { const w = waiter; waiter = null; w(보낼것); }
533
- else queue.push(보낼것);
563
+ /*
564
+ * ── 접어 둔 붙여넣기를 여기서 편다 ──────────────────────────────────
565
+ *
566
+ * 화면에 남는 것과 모델에게 가는 것이 여기서만 일부러 다르다. 화면에는
567
+ * `[붙여넣기 #1 · 47줄 · 2.1KB]` 이 남고, 모델에게는 그 47줄이 그대로
568
+ * 간다. 표가 무엇을 담고 있었는지를 말해 주므로 나중에 스크롤을 올려도
569
+ * 무엇을 시켰는지 알아볼 수 있다.
570
+ *
571
+ * 펴는 자리는 여기 한 군데다. 미리 쳐 둔 것이든 기다리다 보낸 것이든
572
+ * 결국 이 줄을 지나가므로, 여기서 펴면 어느 길로 와도 원문이 간다.
573
+ */
574
+ const 펴진것 = 붙임펼치기(보낼것, 붙인것들);
575
+ // 다 쓴 것은 치운다. 안 치우면 대화가 길어질수록 붙인 원문이 계속 쌓인다.
576
+ for (const 번호 of 붙임쓴번호들(보낼것)) 붙인것들.delete(번호);
577
+ if (waiter) { const w = waiter; waiter = null; w(펴진것); }
578
+ else queue.push(펴진것);
534
579
  });
535
580
  rl.on('close', () => {
536
581
  closed = true;
@@ -592,11 +637,53 @@ export async function chatLoop(opts = {}) {
592
637
  process.on('exit', () => { try { process.stdout.write('\x1b[?2004l'); } catch { /* 이미 닫혔다 */ } });
593
638
 
594
639
  process.stdin.on('keypress', (_ch, key) => {
595
- if (key?.sequence === '\x1b[200~') { 붙여넣는중 = true; return; }
640
+ if (key?.sequence === '\x1b[200~') {
641
+ 붙여넣는중 = true;
642
+ 붙임앞줄수 = 이어쓰기줄들?.length ?? 0;
643
+ 붙임앞입력 = rl.line ?? '';
644
+ return;
645
+ }
596
646
  if (key?.sequence === '\x1b[201~') {
597
647
  붙여넣는중 = false;
598
- // 쌓인 것이 몇 줄인지 보여 준다. 안 보이면 사라진 줄 알고 다시 붙인다.
599
- if (이어쓰기줄들?.length && 입력기다림) {
648
+ /*
649
+ * 이번에 들어온 것만 골라낸다.
650
+ *
651
+ * 붙여넣기에 줄바꿈이 있었으면 readline 이 줄마다 'line' 을 쐈고,
652
+ * 그 줄들은 붙임앞줄수 뒤에 쌓여 있다. 마지막 조각은 아직 입력칸에
653
+ * 남아 있다. 그리고 붙이기 전에 치고 있던 글(앞머리)은 **첫 줄
654
+ * 앞에 붙어서** 같이 나갔으므로 떼어 내야 한다.
655
+ *
656
+ * 커서를 글 가운데 두고 붙이면 어디까지가 붙인 것인지 가릴 수
657
+ * 없다. 그때는 접지 않는다 — 접기는 되돌릴 수 있어야 하고, 잘못
658
+ * 가른 접기는 못 되돌린다.
659
+ */
660
+ const 새줄들 = (이어쓰기줄들 ?? []).slice(붙임앞줄수);
661
+ const 지금입력 = rl.line ?? '';
662
+ const 앞머리 = 붙임앞입력;
663
+ let 붙인줄들 = null;
664
+ if (새줄들.length) {
665
+ if (!앞머리 || 새줄들[0].startsWith(앞머리)) {
666
+ 붙인줄들 = [새줄들[0].slice(앞머리.length), ...새줄들.slice(1), 지금입력];
667
+ }
668
+ } else if (지금입력.startsWith(앞머리)) {
669
+ 붙인줄들 = [지금입력.slice(앞머리.length)];
670
+ }
671
+ const 붙인것 = 붙인줄들 ? 펴기(붙인줄들.join('\n')) : '';
672
+ const 폭 = Math.max(20, (process.stdout.columns ?? 80) - 8);
673
+
674
+ if (붙인줄들 && 입력기다림 && 묻는중 === null && 붙임접을까(붙인것, { 폭 })) {
675
+ const 번호 = ++붙임번호;
676
+ 붙인것들.set(번호, 붙인것);
677
+ 이어쓰기줄들 = 붙임앞줄수 ? (이어쓰기줄들 ?? []).slice(0, 붙임앞줄수) : null;
678
+ if (이어쓰기줄들 === null) 이어쓴것 = false;
679
+ const 표 = 붙임표(번호, 붙인것);
680
+ rl.line = 앞머리 + 표;
681
+ rl.cursor = rl.line.length;
682
+ 화면.입력지움();
683
+ say(` ${c.gray(`${표} — 접어 뒀습니다. 보낼 때 그대로 펴집니다.`)}`);
684
+ } else if (이어쓰기줄들?.length && 입력기다림) {
685
+ // 접지 않은 덩이는 몇 줄인지라도 알려 준다. 안 보이면 사라진 줄
686
+ // 알고 다시 붙인다.
600
687
  화면.입력지움();
601
688
  say(` ${c.gray(`${이어쓰기줄들.length + 1}줄을 붙여넣었습니다 — Enter 로 한 번에 보냅니다`)}`);
602
689
  }
@@ -615,7 +702,7 @@ export async function chatLoop(opts = {}) {
615
702
  * 치던 글이 날아가면 그게 더 놀랍다.
616
703
  */
617
704
  if (key?.name === 'escape' && !key.ctrl && !key.meta && !key.shift) {
618
- if (turn && !turn.signal.aborted) { turn.abort(); return; }
705
+ if (turn && !turn.signal.aborted) { turn.abort(); 멈추는중(); return; }
619
706
  return;
620
707
  }
621
708
  // Shift+Tab — 승인 방식 (자동 → 위험만 → 모두)
@@ -909,11 +996,26 @@ export async function chatLoop(opts = {}) {
909
996
  say('');
910
997
  },
911
998
 
912
- ask물음: async (물음, 고를것 = []) => {
999
+ ask물음: async (물음, 고를것 = [], 이해 = '') => {
913
1000
  if (closed) return null;
1001
+ const 폭 = Math.max(40, Math.min(88, (process.stdout.columns || 80) - 6)) - 4;
914
1002
  say('');
915
1003
  say(` ${c.hcyan('┌')} ${c.bold(옮긴말('ask.title'))}`);
916
- for (const 줄 of 접어쓰기(물음, Math.max(40, Math.min(88, (process.stdout.columns || 80) - 6)) - 4)) {
1004
+ /*
1005
+ * 물음보다 **이해를 먼저** 보여 준다.
1006
+ *
1007
+ * 사람이 이 상자에서 제일 먼저 알고 싶은 것은 "얘가 내 말을 알아듣긴
1008
+ * 했나" 이다. 그걸 모르는 채 선택지부터 보면, 답을 고르는 것이 아니라
1009
+ * 딴소리를 하는지 아닌지를 먼저 가늠해야 한다. 순서가 뒤바뀌면
1010
+ * 물음 자체가 짐이 된다.
1011
+ */
1012
+ if (String(이해 ?? '').trim()) {
1013
+ for (const 줄 of 접어쓰기(`알아들은 것: ${String(이해).trim()}`, 폭)) {
1014
+ say(` ${c.hcyan('│')} ${c.gray(줄)}`);
1015
+ }
1016
+ say(` ${c.hcyan('│')}`);
1017
+ }
1018
+ for (const 줄 of 접어쓰기(물음, 폭)) {
917
1019
  say(` ${c.hcyan('│')} ${c.white(줄)}`);
918
1020
  }
919
1021
  if (고를것.length) {
@@ -1138,6 +1240,7 @@ export async function chatLoop(opts = {}) {
1138
1240
  }
1139
1241
  if (turn && !turn.signal.aborted) {
1140
1242
  turn.abort();
1243
+ 멈추는중();
1141
1244
  return; // 화면 정리는 루프 쪽 'aborted' 이벤트가 한다
1142
1245
  }
1143
1246
  if (interrupted) { rl.close(); return; }
@@ -1298,6 +1401,15 @@ export async function chatLoop(opts = {}) {
1298
1401
  // 이 턴이 탈 없이 끝났나. 끊겼거나 터진 뒤에 승인 창을 띄우면 안 된다 —
1299
1402
  // 계획이 반만 나온 것을 두고 "이대로 진행할까요?" 를 묻는 꼴이 된다.
1300
1403
  let 턴탈났나 = false;
1404
+ /*
1405
+ * 걸음을 다 써서 중간에 끊긴 턴인가. 끊겼을 때 남은 할 일을 여기 담는다.
1406
+ *
1407
+ * 「중간에 끊기는 이슈가 생기는거 같은데」 — 여기가 그 자리다. 여태는
1408
+ * "이어서 하려면 다시 말씀하세요" 라고만 하고 끝났다. 사람은 방금 화면에
1409
+ * 다 적혀 있는 남은 할 일을 손으로 다시 옮겨 적거나, "이어서 해줘" 라고만
1410
+ * 쳤다. 그러면 모델은 무엇을 잇는지 몰라서 처음부터 다시 뒤진다.
1411
+ */
1412
+ let 끊긴할일 = null;
1301
1413
  let thinkChars = 0;
1302
1414
  let streamed = false;
1303
1415
  /*
@@ -1720,8 +1832,9 @@ export async function chatLoop(opts = {}) {
1720
1832
  break;
1721
1833
 
1722
1834
  case 'limit':
1835
+ 끊긴할일 = ev.남은할일 ?? [];
1723
1836
  say('');
1724
- say(` ${mark.warn} 도구 호출 ${ev.steps}회에서 멈췄습니다. ${c.gray('이어서 하려면 다시 말씀하세요.')}`);
1837
+ say(` ${mark.warn} 도구 호출 ${ev.steps}회에서 멈췄습니다.`);
1725
1838
  // 무엇이 안 끝났는지 그 자리에 적는다. 위로 스크롤해 도구 줄을
1726
1839
  // 세어 보게 하면, 이어서 시킬 때 무엇을 시켜야 할지 알 수 없다.
1727
1840
  if (ev.남은할일?.length) {
@@ -1788,13 +1901,28 @@ export async function chatLoop(opts = {}) {
1788
1901
  clearThinking();
1789
1902
  if (streamed) { 답비우기(); say(''); streamed = false; }
1790
1903
  say('');
1791
- say(` ${c.gray('↺ 읽기만 하고 끝내려고 해서 한 번 되밀었습니다')}`);
1904
+ say(ev.why === '요청누락'
1905
+ ? ` ${c.gray(`↺ 시킨 것 ${ev.빠진?.length ?? 0}가지를 빼놓고 끝내려고 해서 되밀었습니다`)}`
1906
+ : ` ${c.gray('↺ 읽기만 하고 끝내려고 해서 한 번 되밀었습니다')}`);
1792
1907
  break;
1793
1908
 
1794
1909
  case 'done':
1795
1910
  clearThinking();
1796
1911
  if (streamed) { 답비우기(); say(''); }
1797
1912
  만든파일보이기(ev.files);
1913
+ /*
1914
+ * 되밀고도 그대로면 사람에게 말한다.
1915
+ *
1916
+ * 조용히 넘어가면 사람은 다 된 줄 알고 그 위에 다음 것을 쌓는다.
1917
+ * 나중에 발견했을 때는 이미 늦다. 「몇 번 까먹거나 누락되거나」라는
1918
+ * 제보가 정확히 그 모양이었다 — 빠진 것 자체보다, 빠졌다는 말을
1919
+ * 안 한 것이 문제다.
1920
+ */
1921
+ if (ev.빠진?.length) {
1922
+ say('');
1923
+ say(` ${c.yellow('!')} ${c.white(`시킨 것 중 ${ev.빠진.length}가지가 안 다뤄졌습니다`)}`);
1924
+ for (const x of ev.빠진) say(` ${c.gray(`${x.번호}. ${clip(x.글, 68)}`)}`);
1925
+ }
1798
1926
  break;
1799
1927
  }
1800
1928
  }
@@ -1900,6 +2028,51 @@ export async function chatLoop(opts = {}) {
1900
2028
  say('');
1901
2029
  say(` ${c.hcyan('☰')} ${c.gray('그 점을 반영해 계획을 다시 냅니다.')}`);
1902
2030
  }
2031
+ } else if (끊긴할일) {
2032
+ /*
2033
+ * ── 끊긴 자리에서 ⏎ 하나로 잇는다 ────────────────────────────────
2034
+ *
2035
+ * 「중간에 끊기는 이슈가 생기는거 같은데 해당 문제점들이 극복되면 좋겠어」
2036
+ *
2037
+ * 걸음 상한은 안전장치라 없앨 수 없다 — 그게 없으면 헛도는 턴이
2038
+ * 컨텍스트를 다 먹을 때까지 안 멈춘다. 없앨 게 아니라 **잇는 길**을
2039
+ * 놔야 한다.
2040
+ *
2041
+ * 이어갈 말에는 남은 단계를 **글자 그대로 싣는다.** 계획 승인에서
2042
+ * 배운 것과 같은 이유다 — "아까 하던 것 이어서 해라" 는 가리키기만
2043
+ * 하고 안 주는 말이라, 작은 모델은 그 '아까' 를 찾으러 지난 대화를
2044
+ * 뒤지기 시작한다.
2045
+ */
2046
+ // 끝난 것을 빼는 것은 loop.js 가 이미 한 번 한다. 여기서 또 하는 것은
2047
+ // 이 말이 **끝난 일을 다시 시키는 말**이 되면 안 되기 때문이다 —
2048
+ // 저쪽 걸러내기가 언젠가 바뀌어도 이 자리는 안 흔들려야 한다.
2049
+ const 남은 = 끊긴할일.filter((t) => t && t.state !== 'done');
2050
+ say('');
2051
+ if (알릴까({ 물어봄: true, 켬: 알림.켬 })) 종();
2052
+ 창제목(제목글('물어봄', { 폴더: 알림.폴더 }));
2053
+ const 답 = String(await ask(
2054
+ `이어서 할까요? ${c.gray('⏎ 이어서 · n 그만')}`,
2055
+ { def: 'y' },
2056
+ )).trim().toLowerCase();
2057
+
2058
+ if (['n', 'no', 'ㄴ', '취소', '그만', '아니', '아니요', '아니오'].includes(답)) {
2059
+ say(` ${c.gray('여기서 멈췄습니다. 나중에 「이어서 해줘」 라고 하셔도 됩니다.')}`);
2060
+ } else {
2061
+ const 단계글 = 남은.map((t, i) => `${i + 1}. ${String(t.text ?? '')}`).join('\n');
2062
+ 이어갈것 = (남은.length
2063
+ ? '걸음 수를 다 써서 중간에 멈췄다. 사람이 이어서 하라고 했다.\n\n'
2064
+ + `아직 안 끝난 것은 아래 그대로다.\n\n${단계글}\n\n`
2065
+ + '이것부터 이어서 실제로 해라.'
2066
+ : '걸음 수를 다 써서 중간에 멈췄다. 사람이 이어서 하라고 했다.'
2067
+ + ' 하던 자리에서 이어서 해라.')
2068
+ + ' **이미 끝낸 것은 다시 하지 마라.** 지난 대화를 뒤지지 마라 —'
2069
+ + ' 필요한 것은 이 대화 안에 다 있다. 처음부터 다시 시작하지도 마라.';
2070
+ // 모드를 다시 고르지 않는다. 이 말에는 '해라' 가 들어 있어서 다시
2071
+ // 고르면 엉뚱한 데로 가고, 애초에 하던 일을 잇는 것이라 바뀔 이유가 없다.
2072
+ 이어갈모드 = session.routed ?? (session.work === 'auto' ? 'code' : session.work);
2073
+ say('');
2074
+ say(` ${c.hgreen('▶')} ${c.gray(`끊긴 자리에서 이어서 합니다${남은.length ? ` (남은 ${남은.length}가지)` : ''}.`)}`);
2075
+ }
1903
2076
  }
1904
2077
  }
1905
2078
 
@@ -25,6 +25,7 @@
25
25
  //
26
26
  // 바뀐 글만 쓰고 원본은 한 글자도 안 건드린다. 변환은 읽기의 곁길이다.
27
27
  import { spawnSync } from 'node:child_process';
28
+ import { 돌려보기 } from './spawn.js';
28
29
  import {
29
30
  existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync,
30
31
  } from 'node:fs';
@@ -90,17 +91,44 @@ const soffice자리 = [
90
91
  * `cmd /s /c` 는 뒤엣것이 따옴표로 시작해 따옴표로 끝나면 바깥 한 쌍을 떼어
91
92
  * 내므로, 한 겹 더 둘러서 넘긴다.
92
93
  */
93
- function 부르기(cmd, 인자, opts = {}) {
94
+ function 명령짓기(cmd, 인자) {
94
95
  const 셸필요 = process.platform === 'win32' && /\.(cmd|bat)$/i.test(String(cmd));
95
- if (!셸필요) return spawnSync(cmd, 인자, { encoding: 'utf8', windowsHide: true, ...opts });
96
+ if (!셸필요) return { 실행: cmd, 인자, 덤: {} };
96
97
  const 몰아쓰기 = `""${cmd}" ${인자.map((a) => `"${a}"`).join(' ')}"`;
97
- return spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 몰아쓰기], {
98
- encoding: 'utf8', windowsHide: true, windowsVerbatimArguments: true, ...opts,
99
- });
98
+ return {
99
+ 실행: process.env.ComSpec || 'cmd.exe',
100
+ 인자: ['/d', '/s', '/c', 몰아쓰기],
101
+ 덤: { windowsVerbatimArguments: true },
102
+ };
100
103
  }
101
104
 
105
+ /*
106
+ * 변환은 **비동기로** 부른다.
107
+ *
108
+ * soffice 는 처음 뜰 때 20초, 큰 문서면 그 이상 걸린다. 전에는 이 자리가
109
+ * spawnSync 였고, 그동안 Node 는 이벤트 루프를 통째로 멈췄다 — 그 90초 내내
110
+ * ESC 가 배달되지 않았다. 화면에는 도는 그림이 보이는데 키가 안 먹으니
111
+ * 사람 눈에는 프로그램이 고장 난 것처럼 보인다.
112
+ *
113
+ * 자식을 띄우고 기다리는 일은 tools/spawn.js 한 벌만 안다. 찾기(fastgrep)와
114
+ * 여기가 같은 것을 쓴다 — 따로 적어 두면 언젠가 한쪽만 고쳐진다.
115
+ */
116
+ function 부르기(cmd, 인자, opts = {}) {
117
+ const c = 명령짓기(cmd, 인자);
118
+ return 돌려보기(c.실행, c.인자, { ...opts, 덤: c.덤 });
119
+ }
120
+
121
+ /*
122
+ * 있는지 본다. 여기는 **동기로 둔다.**
123
+ *
124
+ * `--version` 은 곧바로 답하고, 한 세션에 한 번만 부른다(변환기찾기가 기억한다).
125
+ * 이걸 비동기로 만들면 변환기찾기·변환기말·못바꿈말이 줄줄이 비동기가 되고,
126
+ * 그 함수들은 안내문을 만드는 자리라 부르는 데가 많다. 90초를 없애려다
127
+ * 열 군데를 흔드는 것은 값이 안 맞는다.
128
+ */
102
129
  function 돌아가나(cmd, 인자) {
103
- const r = 부르기(cmd, 인자, { timeout: 15000 });
130
+ const c = 명령짓기(cmd, 인자);
131
+ const r = spawnSync(c.실행, c.인자, { encoding: 'utf8', timeout: 15000, windowsHide: true, ...c.덤 });
104
132
  return !r.error && r.status === 0;
105
133
  }
106
134
 
@@ -189,7 +217,7 @@ export function 임시자리(root) {
189
217
  * @param {string} root 작업 폴더 — 바꾼 것을 여기 안에 떨군다
190
218
  * @returns {{ok:true, text:string, 쓴것:string} | {ok:false, 왜:string, 없음?:boolean}}
191
219
  */
192
- export function 글로바꾸기(abs, root, { timeout = 90000, 찾은것 = null } = {}) {
220
+ export async function 글로바꾸기(abs, root, { timeout = 90000, 찾은것 = null, signal = null } = {}) {
193
221
  const 있는것 = 찾은것 ?? 변환기찾기();
194
222
  if (!있는것.soffice && !있는것.textutil) {
195
223
  return { ok: false, 없음: true, 왜: 있는것.왜 ?? '이 PC 에 변환기가 없습니다' };
@@ -210,7 +238,7 @@ export function 글로바꾸기(abs, root, { timeout = 90000, 찾은것 = null }
210
238
  const 확장자 = extname(abs).toLowerCase();
211
239
  if (있는것.textutil && ['.doc', '.rtf', '.odt', '.docx'].includes(확장자)) {
212
240
  const 나온것 = join(받을곳, `${basename(abs, 확장자)}.txt`);
213
- const r = 부르기('textutil', ['-convert', 'txt', '-output', 나온것, abs], { timeout });
241
+ const r = await 부르기('textutil', ['-convert', 'txt', '-output', 나온것, abs], { timeout, signal });
214
242
  if (!r.error && r.status === 0 && existsSync(나온것)) {
215
243
  const text = readFileSync(나온것, 'utf8');
216
244
  // soffice 쪽과 같은 규칙 — 읽었으면 사본을 남기지 않는다.
@@ -234,13 +262,13 @@ export function 글로바꾸기(abs, root, { timeout = 90000, 찾은것 = null }
234
262
  */
235
263
  const 프로필 = join(받을곳, '.soffice-profile');
236
264
  const 전 = new Set(existsSync(받을곳) ? readdirSync(받을곳) : []);
237
- const r = 부르기(있는것.soffice, [
265
+ const r = await 부르기(있는것.soffice, [
238
266
  `-env:UserInstallation=file:///${프로필.replace(/\\/g, '/').replace(/^\/+/, '')}`,
239
267
  '--headless', '--norestore',
240
268
  '--convert-to', 'txt:Text',
241
269
  '--outdir', 받을곳,
242
270
  abs,
243
- ], { timeout });
271
+ ], { timeout, signal });
244
272
 
245
273
  if (r.error) {
246
274
  return { ok: false, 왜: `변환기를 못 돌렸습니다: ${r.error.message}` };
@@ -134,12 +134,16 @@ export const 도구설명EN = {
134
134
  },
135
135
  },
136
136
  Ask: {
137
- desc: 'Ask the person one question at a genuine fork. Never write "let me know" and stop;'
138
- + ' asking in prose ends the turn and throws away everything you have looked at.'
139
- + ' Give 2-4 options and they answer with a single number.'
137
+ desc: 'Ask the person one question at a genuine fork. **First fill in `이해` with what you'
138
+ + ' understood this request to be**, and only ask if something is genuinely left to decide.'
139
+ + ' Never write "let me know" and stop; asking in prose ends the turn and throws away'
140
+ + ' everything you have looked at. Give 2-4 options and they answer with a single number.'
140
141
  + ' **Never ask back something the user already said.** "Tidy up the files" is the answer —'
141
142
  + ' go and do it. Deciding how is your job, not theirs.',
142
143
  params: {
144
+ 이해: 'one line: what you understood this request to be. The person reads this line to judge'
145
+ + ' whether you actually read them. "I understand your request" is not an understanding —'
146
+ + ' name the actual work',
143
147
  question: 'one sentence. Make it clear what has to be decided',
144
148
  options: '2-4 choices, one line each, so the difference is visible',
145
149
  },