deel-local-cli 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/commands.js CHANGED
@@ -3,17 +3,25 @@ import { writeFileSync, existsSync, statSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { c, say, rule, pad, bar, mark, width, clip } from './ui/ansi.js';
5
5
  import { compact } from './agent/compact.js';
6
+ import { 증거모으기, 증거적기 } from './agent/evidence.js';
6
7
  import { allowEndpoint } from './safety/network.js';
7
8
  import { pick } from './ui/prompt.js';
8
9
  import { load, save, resolveKey, upsert } from './config.js';
10
+ import { 종, 알릴만한초 } from './ui/notify.js';
11
+ import { 말, 언어, 언어정하기, 언어고르기, 옮긴만큼 } from './i18n/index.js';
12
+ import { 프로필찾기, 쓸수있나, 연결만들기, 알릴말, 목록보기 } from './agent/models.js';
13
+ import { allowTemporarily } from './safety/network.js';
14
+ import { chat } from './backend/adapter.js';
9
15
  import { TOOLS } from './tools/index.js';
16
+ import { 둘러보기, 프로젝트갈래 } from './lsp/servers.js';
17
+ import { 지금것들 } from './lsp/client.js';
10
18
  import { loadCommand, discover } from './skills/discover.js';
11
19
  import { install, list, remove, pack } from './plugins/manage.js';
12
20
  import { spin } from './ui/spinner.js';
13
21
  import { PROFILES, LEVELS as THINK_LEVELS, normalizeProfile, table as effortTable } from './agent/effort.js';
14
22
  import { scanLocal, toProfiles } from './backend/scan.js';
15
23
  import { list as listSessions } from './agent/store.js';
16
- import { MODES as WORK_MODES, ORDER as WORK_ORDER, DEFAULT as WORK_DEFAULT, normalize as normWork, get as getWork, canWrite } from './agent/modes.js';
24
+ import { MODES as WORK_MODES, ORDER as WORK_ORDER, DEFAULT as WORK_DEFAULT, normalize as normWork, get as getWork, canWrite, 보일이름, 보일한줄 } from './agent/modes.js';
17
25
  import { LEVELS, ORDER as LEVEL_ORDER, DEFAULT as LEVEL_DEFAULT, normalize as normLevel, shows as levelShows } from './ui/level.js';
18
26
  import { diffLines, renderDiff, shortStat } from './ui/diff.js';
19
27
  import { readTextFull } from './tools/fsutil.js';
@@ -24,48 +32,82 @@ const MODES = {
24
32
  strict: '엄격 — 파일 변경·명령 전부 물어봄',
25
33
  };
26
34
 
27
- export const COMMANDS = {
28
- help: { desc: '명령 목록' },
29
- clear: { desc: '대화 비우기' },
30
- context: { desc: '컨텍스트 사용량 보기' },
31
- ctx: { desc: '컨텍스트 길이 — 모델에 맞춰 다시 재거나 직접 지정', arg: '[auto|숫자|640k]' },
32
- out: { desc: '한 번에 받을 길이 상한 파일이 잘리면 여기를 올린다', arg: '[숫자|32k|auto]' },
33
- grade: { desc: '모델 얼마나 알아서 하나. 크기와는 다른 축', arg: '[작음|보통|큼|auto]' },
34
- compact: { desc: '오래된 대화 줄이기' },
35
- model: { desc: '연결·모델 바꾸기 (이름 일부 · list · models)', arg: '[이름|list|models]' },
36
- think: { desc: '추론 강도 (off/low/medium/high/max)', arg: '<수준>' },
37
- mode: { desc: '승인 정책 얼마나 물어보나 (auto/confirm/strict)', arg: '<모드>' },
38
- work: { desc: '작업 모드 무슨 일을 하는 중인가 (종합이면 저절로)', arg: '[모드]' },
39
- // 이름이 /mode auto 겹쳐 보이므로 설명에서 못을 박는다.
40
- // /mode auto 는 '얼마나 물어보나', /auto 는 '무슨 일을 하는 중인가' 다.
41
- auto: { desc: '작업 모드 종합 (요청에 따라 저절로 옮겨 감. /mode 다름)' },
42
- code: { desc: '작업 모드 코드 (고치고 만든다)' },
43
- plan: { desc: '작업 모드 → 계획 (먼저 계획만)' },
44
- architect:{ desc: '작업 모드 → 설계 (구조를 짠다)' },
45
- debug: { desc: '작업 모드 → 디버그 (원인을 찾는다)' },
46
- ask: { desc: '작업 모드 → 묻기 (설명만)' },
47
- orchestrator: { desc: '작업 모드 → 총괄 (큰 일을 쪼개서)' },
48
- undo: { desc: '직전 작업 되돌리기', arg: '[턴수]' },
49
- diff: { desc: '이번 대화에서 바뀐 파일 · 바뀐 자리 보기', arg: '[파일]' },
50
- preview: { desc: '만든 웹을 이 자리에서 띄워 본다 — 올릴 필요 없음', arg: '[폴더|off]' },
51
- tools: { desc: '쓸 수 있는 도구 보기' },
52
- skills: { desc: '스킬 보기·검색·골라 올리기', arg: '[검색어|all|off]' },
53
- plugin: { desc: '플러그인 목록·설치·삭제·반입묶음', arg: '[install|remove|pack]' },
54
- cost: { desc: '이번 세션 사용량' },
55
- status: { desc: '연결 상태' },
56
- scan: { desc: '이 PC 의 로컬 모델 서버 훑기', arg: '[save]' },
57
- thread: { desc: '대화 갈래 — 곁가지를 딴 자리에서. 연결·되돌리기는 같이 씀', arg: '[new|fork|close|번호|이름]' },
58
- learned: { desc: '쓰면서 저절로 알게 된 것 — 되는 명령·이 모델의 버릇', arg: '[지우기]' },
59
- sessions:{ desc: '이 폴더의 지난 대화 목록' },
60
- recall: { desc: '지난 대화에서 찾기 — 목록 말고 내용으로', arg: '<찾을 말>' },
61
- mcp: { desc: '밖에서 붙인 도구(MCP) 서버 보기' },
62
- memory: { desc: '대화가 끝나도 남는 기억 — 보기·지우기', arg: '[지우기 <번호>|비우기|<적을 말>]' },
63
- level: { desc: '사용자 수준 (쉬움/개발자)', arg: '[수준]' },
64
- init: { desc: 'DEEL.md 규칙 파일 만들기' },
65
- exit: { desc: '끝내기' },
66
- quit: { desc: '끝내기' },
35
+ /*
36
+ * 슬래시 명령 목록.
37
+ *
38
+ * ── 설명이 여기 없나 ─────────────────────────────────────────────────
39
+ *
40
+ * 이름과 인자 유무만 여기 두고, **사람이 읽는 글은 i18n/ko.js 있다.**
41
+ * 명령 이름은 원래부터 영어였는데(help·model·undo…) 옆의 설명이 전부
42
+ * 한글이라, 저장소를 처음 영어권 사람은 `/help` 를 쳐 봐도 무엇을 하는
43
+ * 명령인지 수가 없었다. 그래서 설명만 갈아 끼울 있게 뺐다.
44
+ *
45
+ * desc·arg **게터로** 둔다. 한 번 읽어 굳히면 /lang 으로 바꿔도
46
+ * 목록이 따라오고, 그러면 바뀐 것처럼 보이다가 바뀐 자리가 남는다.
47
+ * 때마다 지금 언어로 읽는 편이 어긋날 자리가 없다.
48
+ *
49
+ * 없는 열쇠는 열쇠 이름이 그대로 나온다 빈칸이 아니다. 명령을 새로 넣고
50
+ * ko.js 에 적는 것을 빠뜨리면 `cmd.foo.desc` 화면에 찍혀서 바로 눈에 띈다.
51
+ * (검사도 그걸 잡는다.)
52
+ */
53
+ const 명령들 = {
54
+ help: { arg: false },
55
+ clear: { arg: false },
56
+ context: { arg: false },
57
+ ctx: { arg: true },
58
+ out: { arg: true },
59
+ grade: { arg: true },
60
+ compact: { arg: false },
61
+ model: { arg: true },
62
+ think: { arg: true },
63
+ mode: { arg: true },
64
+ work: { arg: true },
65
+ auto: { arg: false },
66
+ code: { arg: false },
67
+ plan: { arg: false },
68
+ architect: { arg: false },
69
+ debug: { arg: false },
70
+ ask: { arg: false },
71
+ orchestrator: { arg: false },
72
+ undo: { arg: true },
73
+ diff: { arg: true },
74
+ preview: { arg: true },
75
+ tools: { arg: false },
76
+ skills: { arg: true },
77
+ plugin: { arg: true },
78
+ cost: { arg: false },
79
+ status: { arg: false },
80
+ scan: { arg: true },
81
+ thread: { arg: true },
82
+ learned: { arg: true },
83
+ pin: { arg: true },
84
+ evidence: { arg: true },
85
+ sessions: { arg: false },
86
+ recall: { arg: true },
87
+ mcp: { arg: false },
88
+ memory: { arg: true },
89
+ level: { arg: true },
90
+ bell: { arg: true },
91
+ consult: { arg: true },
92
+ lang: { arg: true },
93
+ lsp: { arg: true },
94
+ init: { arg: false },
95
+ exit: { arg: false },
96
+ quit: { arg: false },
67
97
  };
68
98
 
99
+ /**
100
+ * 화면에 낼 명령표. desc·arg 는 볼 때마다 지금 언어로 읽는다.
101
+ *
102
+ * @type {Record<string, {desc: string, arg?: string}>}
103
+ */
104
+ export const COMMANDS = Object.fromEntries(Object.entries(명령들).map(([이름, 꼴]) => {
105
+ const 것 = { get desc() { return 말(`cmd.${이름}.desc`); } };
106
+ if (꼴.arg) Object.defineProperty(것, 'arg', { get: () => 말(`cmd.${이름}.arg`), enumerable: true });
107
+ return [이름, 것];
108
+ }));
109
+
110
+
69
111
  /**
70
112
  * 이 줄이 명령이 아니라 '경로' 인가.
71
113
  *
@@ -98,6 +140,276 @@ export async function handle(line, session, ctx) {
98
140
  switch (name) {
99
141
  case 'help': return help(session), { handled: true };
100
142
  case 'level': return showLevel(session, arg), { handled: true };
143
+
144
+ /*
145
+ * 종소리 · 창 제목.
146
+ *
147
+ * 끄는 길을 굳이 명령으로 둔 이유가 있다. 사무실에서 소리가 나면 곤란한
148
+ * 사람이 있고, 그런 사람은 알림 자체를 못 쓰게 되는 것이 아니라 **그 자리에서
149
+ * 껐다 켤 수 있어야** 한다. 환경변수만 두면 프로그램을 껐다 켜야 한다.
150
+ */
151
+ /*
152
+ * 화면 말 — 한국어 / English.
153
+ *
154
+ * 코드는 한국어로 둔다. 함수 이름도 변수 이름도 한글이고 그게 이 저장소의
155
+ * 뜻이다. 바꾸는 것은 **화면에 나가는 말**뿐이다.
156
+ *
157
+ * 얼마나 옮겼는지를 숨기지 않고 그대로 적는다. 다 된 척하는 것보다
158
+ * "298개 중 47개는 아직 한국어" 가 낫다 — 그래야 안 옮긴 자리를 봤을 때
159
+ * 사람이 고장으로 안 읽고, 도와줄 사람도 어디를 도울지 안다.
160
+ */
161
+ /*
162
+ * 다른 모델에게 한 번 물어보기.
163
+ *
164
+ * ── 왜 따로 두나 ────────────────────────────────────────────────────
165
+ *
166
+ * 로컬에서는 모델 하나를 골라도 늘 어딘가 아쉽다. 7B 는 계획을 잘 세우는데
167
+ * 파일 열 개를 고치다 창이 차고, 1.5B 는 창은 넉넉한데 무엇을 할지를 못
168
+ * 정한다. 그런데 지금까지는 다른 것에게 물어보려면 /model 로 **갈아타야**
169
+ * 했고, 갈아타면 하던 대화가 그 모델의 것이 된다 — 한마디 물어보자고
170
+ * 판을 통째로 옮기는 셈이었다.
171
+ *
172
+ * ── 무엇을 보내나 ───────────────────────────────────────────────────
173
+ *
174
+ * **친 질문만** 보낸다. 지금 대화를 통째로 딸려 보내지 않는다.
175
+ *
176
+ * 이건 편의가 아니라 경계선 문제다. 대화에는 이 폴더의 소스가 통째로
177
+ * 들어 있는데, 그것이 어느 서버로 가는지는 사람이 정할 일이지 명령 하나가
178
+ * 조용히 정할 일이 아니다. 필요한 배경은 질문에 적으면 된다.
179
+ *
180
+ * 답은 화면에 보여 주고 **대화에도 넣는다.** 안 넣으면 사람이 그 답을
181
+ * 손으로 옮겨 적어야 하는데, 그러면 이건 다른 창에서 물어보는 것과 다를
182
+ * 것이 없다. 다만 누가 한 말인지 표를 달아 넣는다 — 지금 쓰는 모델이
183
+ * 제가 한 말로 착각하면 안 된다.
184
+ */
185
+ case 'consult': {
186
+ const 조각 = String(arg ?? '').trim().split(/\s+/);
187
+ const 이름 = 조각.shift() ?? '';
188
+ const 질문 = 조각.join(' ').trim();
189
+ const 목록 = 목록보기();
190
+
191
+ if (!이름) {
192
+ say('');
193
+ rule(말('consult.title'), 70);
194
+ if (!목록.length) {
195
+ say(` ${c.gray(말('consult.noProfiles'))}`);
196
+ } else {
197
+ for (const p of 목록) {
198
+ const 표 = p.로컬 ? c.hgreen('⌂') : c.hyellow('↗');
199
+ say(` ${표} ${c.cyan(pad(p.id, 16))} ${c.white(pad(p.model, 26))} ${c.gray(p.어디)}${p.지금 ? c.gray(` ${말('consult.current')}`) : ''}`);
200
+ }
201
+ }
202
+ say('');
203
+ say(` ${c.gray(말('consult.usage'))}`);
204
+ say(` ${c.gray(말('consult.questionOnly'))}`);
205
+ say('');
206
+ return { handled: true };
207
+ }
208
+
209
+ const 찾음 = 프로필찾기(이름);
210
+ if (!찾음.ok) {
211
+ say(` ${c.gray(찾음.why)}`);
212
+ if (찾음.후보.length) say(` ${c.gray(말('consult.candidates'))} ${찾음.후보.map((p) => c.cyan(p.id)).join(c.gray(' · '))}`);
213
+ else if (목록.length) say(` ${c.gray(말('consult.have'))} ${목록.map((p) => c.cyan(p.id)).join(c.gray(' · '))}`);
214
+ say('');
215
+ return { handled: true };
216
+ }
217
+ const 되나 = 쓸수있나(찾음.prof);
218
+ if (!되나.ok) { say(` ${c.yellow('!')} ${c.gray(되나.why)}`); say(''); return { handled: true }; }
219
+ if (!질문) {
220
+ say(` ${c.gray(말('consult.needQuestion', { 이름: 찾음.prof.id }))}`);
221
+ say('');
222
+ return { handled: true };
223
+ }
224
+
225
+ const 새conn = 연결만들기(찾음.prof);
226
+ const 알림 = 알릴말(session.conn, 새conn);
227
+ say('');
228
+ say(알림.밖으로
229
+ ? ` ${c.hyellow('↗')} ${c.hyellow(알림.말)} ${c.gray(말('consult.goesOut'))}`
230
+ : ` ${c.hgreen('⌂')} ${c.gray(알림.말)}`);
231
+
232
+ // 그 한 번 동안만 연다. 끝나면 반드시 닫는다.
233
+ const 닫기 = 알림.다른자리 ? allowTemporarily(새conn.base) : null;
234
+ const 돌림 = spin(말('consult.asking', { 모델: 새conn.model }));
235
+ let 답 = null;
236
+ let 탈 = null;
237
+ const 잰때 = Date.now();
238
+ try {
239
+ const m = await chat(새conn, {
240
+ messages: [
241
+ { role: 'system', content: 말('consult.system') },
242
+ { role: 'user', content: 질문 },
243
+ ],
244
+ temperature: 0.3,
245
+ });
246
+ 답 = String(m?.content ?? '').trim();
247
+ } catch (err) {
248
+ 탈 = String(err?.message ?? err);
249
+ } finally {
250
+ 돌림.stop();
251
+ try { 닫기?.(); } catch { /* 닫다 터져도 이번 대화는 이어간다 */ }
252
+ }
253
+
254
+ if (탈) {
255
+ say(` ${c.yellow('!')} ${c.gray(말('consult.failed', { 왜: clip(탈, 120) }))}`);
256
+ say('');
257
+ return { handled: true };
258
+ }
259
+ if (!답) {
260
+ say(` ${c.gray(말('consult.empty'))}`);
261
+ say('');
262
+ return { handled: true };
263
+ }
264
+
265
+ const 초 = ((Date.now() - 잰때) / 1000).toFixed(1);
266
+ say(` ${c.gray('─'.repeat(2))} ${c.gray(`${찾음.prof.id} · ${초}초`)}`);
267
+ say('');
268
+ for (const l of 답.split('\n')) say(` ${l}`);
269
+ say('');
270
+
271
+ /*
272
+ * 대화에 넣을 때 **누가 한 말인지 표를 단다.**
273
+ *
274
+ * 안 달면 지금 쓰는 모델이 제가 아까 한 말로 읽는다. 작은 모델이 대충
275
+ * 답한 것을 제 판단으로 삼아 그 위에 쌓으면, 틀린 자리가 어디서 왔는지
276
+ * 아무도 못 찾는다.
277
+ */
278
+ session.push({
279
+ role: 'user',
280
+ content: `[${찾음.prof.id} (${새conn.model}) 에게 따로 물어본 결과다. 네가 한 말이 아니다.]\n`
281
+ + `물음: ${질문}\n답: ${답}`,
282
+ });
283
+ ctx.audit.tool('Consult', { 프로필: 찾음.prof.id, 모델: 새conn.model, 질문: clip(질문, 200) },
284
+ { summary: `${알림.말} · ${답.length}자` });
285
+ say(` ${c.gray(말('consult.added'))}`);
286
+ say('');
287
+ return { handled: true };
288
+ }
289
+
290
+ case 'lang': {
291
+ const 값 = String(arg ?? '').trim();
292
+ const 이름 = (l) => 말(`lang.${l}`);
293
+ if (!값) {
294
+ const p = 옮긴만큼();
295
+ say('');
296
+ say(` ${c.gray(말('lang.now', { 이름: 이름(언어()) }))}`);
297
+ say(` ${c.gray(말('lang.progress', { 언어: 언어(), 옮김: p.옮김, 전체: p.전체 }))}`);
298
+ if (p.남음) say(` ${c.gray(말('lang.partial'))}`);
299
+ say(` ${c.gray(말('lang.codeStays'))}`);
300
+ say(` ${c.gray(말('lang.howto'))} ${c.cyan('/lang ko')} ${c.gray('·')} ${c.cyan('/lang en')}`);
301
+ say(` ${c.gray(말('lang.envHint'))}`);
302
+ say('');
303
+ return { handled: true };
304
+ }
305
+ if (!언어고르기(값)) {
306
+ say(` ${c.gray(말('lang.unknown'))}`);
307
+ say('');
308
+ return { handled: true };
309
+ }
310
+ 언어정하기(값);
311
+ const cfg = load();
312
+ cfg.lang = 언어();
313
+ try { save(cfg); } catch { /* 못 남겨도 이번 세션에는 먹는다 */ }
314
+ const p = 옮긴만큼();
315
+ say('');
316
+ say(` ${mark.ok} ${말('lang.changed', { 이름: 이름(언어()) })}`);
317
+ say(` ${c.gray(말('lang.progress', { 언어: 언어(), 옮김: p.옮김, 전체: p.전체 }))}`);
318
+ if (p.남음) say(` ${c.gray(말('lang.partial'))}`);
319
+ say('');
320
+ return { handled: true };
321
+ }
322
+
323
+ /*
324
+ * 언어 서버 — 무엇이 깔려 있고, 지금 무엇이 떠 있나.
325
+ *
326
+ * 이 화면이 필요한 이유는 하나다. Def·Refs 는 서버가 없으면 **목록에 아예
327
+ * 안 나온다.** 그러면 사용자 눈에는 "왜 어떤 프로젝트에서는 되고 어떤
328
+ * 데서는 안 되지" 로 보인다. 여기서 그 까닭과 깔 방법을 대신 말해 준다.
329
+ *
330
+ * 대신 **여기서도 안 깔아 준다.** 깔 명령을 글자로 보여 줄 뿐이고,
331
+ * 칠지 말지는 사람이 정한다 (lsp/servers.js 머리말).
332
+ */
333
+ case 'lsp': {
334
+ const 값 = String(arg ?? '').trim().toLowerCase();
335
+ if (값 === 'off' || 값 === '끔' || 값 === '꺼') {
336
+ if (ctx?.lsp) ctx.lsp.켬 = false;
337
+ say('');
338
+ say(` ${mark.ok} ${말('lsp.diagOff')}`);
339
+ say('');
340
+ return { handled: true };
341
+ }
342
+ if (값 === 'on' || 값 === '켬' || 값 === '켜') {
343
+ if (ctx?.lsp) ctx.lsp.켬 = true;
344
+ say('');
345
+ say(` ${mark.ok} ${말('lsp.diagOn')}`);
346
+ say('');
347
+ return { handled: true };
348
+ }
349
+
350
+ const { 있는것, 없는것 } = 둘러보기();
351
+ const 뿌리것 = ctx?.scope?.root ? 프로젝트갈래(ctx.scope.root) : null;
352
+ const 떠있는것 = 지금것들();
353
+ say('');
354
+ if (있는것.length) {
355
+ say(` ${c.hcyan('◈')} ${c.white(말('lsp.found', { 수: 있는것.length }))}`);
356
+ for (const it of 있는것) say(` ${c.hgreen('✓')} ${c.white(it.갈래.padEnd(5))} ${c.gray(it.이름)}`);
357
+ } else {
358
+ say(` ${c.gray(말('lsp.none'))}`);
359
+ }
360
+ say('');
361
+ say(` ${c.gray(말('lsp.thisFolder'))} ${뿌리것 ? c.hcyan(뿌리것.갈래) + c.gray(` · ${말('lsp.fileCount', { 수: 뿌리것.개수 })}`) : c.gray(말('lsp.noneHere'))}`);
362
+ say(` ${c.gray(말('lsp.tools'))} ${session.lsp ? c.hgreen('Def · Refs') : c.gray(말('lsp.toolsHidden'))}`);
363
+ say(` ${c.gray(말('lsp.diag'))} ${ctx?.lsp?.켬 === false ? c.gray(말('lsp.stateOff')) : c.hgreen(말('lsp.stateOn'))}`);
364
+ if (떠있는것.length) {
365
+ for (const it of 떠있는것) {
366
+ const 꼴 = it.죽음 ? c.yellow(it.죽음) : (it.준비 ? c.hgreen(말('lsp.ready')) : c.gray(말('lsp.starting')));
367
+ say(` ${c.gray('·')} ${c.white(it.이름 ?? it.갈래)} ${꼴}`);
368
+ }
369
+ }
370
+ if (없는것.length && !있는것.length) {
371
+ say('');
372
+ say(` ${c.gray(말('lsp.installHint'))}`);
373
+ for (const it of 없는것.slice(0, 4)) say(` ${c.gray(`${it.갈래.padEnd(5)} ${it.깔기}`)}`);
374
+ say(` ${c.gray(말('lsp.neverInstalls'))}`);
375
+ }
376
+ say(` ${c.gray(말('lsp.howto'))} ${c.cyan('/lsp on')} ${c.gray('·')} ${c.cyan('/lsp off')}`);
377
+ say('');
378
+ return { handled: true };
379
+ }
380
+
381
+ case 'bell': {
382
+ const cfg = load();
383
+ const 켜는말 = ['on', '켬', '켜', '켜기', 'y', 'yes', 'ㅇ'];
384
+ const 끄는말 = ['off', '끔', '꺼', '끄기', 'n', 'no', 'ㄴ'];
385
+ const 값 = String(arg ?? '').trim().toLowerCase();
386
+ if (!값) {
387
+ const 켜져있나 = cfg.bell !== false;
388
+ const 상태 = 켜져있나 ? c.hgreen(말('bell.stateOn')) : c.gray(말('bell.stateOff'));
389
+ say('');
390
+ say(` ${c.gray(말('bell.now', { 상태 }))}`);
391
+ say(` ${c.gray(말('bell.when', { 초: 알릴만한초 }))}`);
392
+ say(` ${c.gray(말('bell.title'))}`);
393
+ say(` ${c.gray(말('bell.howto'))} ${c.cyan('/bell on')} ${c.gray('·')} ${c.cyan('/bell off')}`);
394
+ if (!process.stdout.isTTY && !process.stderr.isTTY) say(` ${c.gray(말('bell.notATty'))}`);
395
+ say('');
396
+ return { handled: true };
397
+ }
398
+ if (!켜는말.includes(값) && !끄는말.includes(값)) {
399
+ say(` ${c.gray(말('bell.needOnOff'))}`);
400
+ say('');
401
+ return { handled: true };
402
+ }
403
+ cfg.bell = 켜는말.includes(값);
404
+ try { save(cfg); } catch { /* 못 남겨도 이번 세션에는 먹는다 */ }
405
+ say('');
406
+ say(` ${mark.ok} ${말(cfg.bell ? 'bell.turnedOn' : 'bell.turnedOff')}`);
407
+ // 켤 때는 한 번 울려 준다. "켰다는데 소리가 나나?" 를 그 자리에서 확인하게.
408
+ if (cfg.bell) { 종(); say(` ${c.gray(말('bell.justRang'))}`); }
409
+ say(` ${c.gray(말('bell.appliesNow'))}`);
410
+ say('');
411
+ return { handled: true };
412
+ }
101
413
  case 'exit':
102
414
  case 'quit': return { handled: true, exit: true };
103
415
 
@@ -139,7 +451,10 @@ export async function handle(line, session, ctx) {
139
451
  return { handled: true };
140
452
  }
141
453
 
142
- case 'model': return await switchModel(session, ctx, arg), { handled: true };
454
+ case 'model':
455
+ // `/model 카드` 는 모델을 바꾸는 게 아니라 이 모델을 겪어 본 결과를 본다.
456
+ if (/^(카드|card)$/i.test(String(arg ?? '').trim())) return 카드명령(session, ctx), { handled: true };
457
+ return await switchModel(session, ctx, arg), { handled: true };
143
458
 
144
459
  /*
145
460
  * /think — '얼마나 생각하나' 하나만 정한다.
@@ -246,15 +561,52 @@ export async function handle(line, session, ctx) {
246
561
  return { handled: true };
247
562
  }
248
563
 
564
+ /*
565
+ * 되돌리기 — 파일과 **대화를 같이** 되감는다.
566
+ *
567
+ * 전에는 파일만 되돌렸다. 대화에는 "src/runner.js 를 고쳤습니다" 가 그대로
568
+ * 남아 있어서, 그다음 턴에 모델은 이미 고쳐 놓은 줄 알고 그 위에 이어
569
+ * 일했다 — 없는 코드를 고치려 들고, 없는 함수를 부른다. 사람 눈에는 모델이
570
+ * 헛소리하는 것으로 보이지만, 사실은 우리가 모델에게 거짓말을 남겨 둔 것이다.
571
+ *
572
+ * 세 군데를 같이 맞춰야 한 벌이 된다 — 오간 말, 적어 둔 파일(--resume 이
573
+ * 이어 여는 자리), 그리고 /diff 가 세는 '바뀐 파일'. 하나라도 빠지면
574
+ * 되돌린 것이 다음 순간 되살아난다.
575
+ */
249
576
  case 'undo': {
250
577
  const n = Math.max(1, parseInt(arg, 10) || 1);
251
578
  const r = ctx.history.undo(n);
252
579
  ctx.audit.undo({ turns: r.turns, files: r.restored.length });
253
580
  if (!r.restored.length) {
254
- say(` ${c.gray('되돌릴 것이 없습니다.')}`);
581
+ say(` ${c.gray(말('undo.nothing'))}`);
582
+ say('');
583
+ return { handled: true };
584
+ }
585
+ say(` ${mark.ok} ${말('undo.done', { turns: r.turns, files: r.restored.length })}`);
586
+ for (const f of r.restored) say(` ${c.gray(ctx.scope.show(f.path))} ${c.gray(f.how)}`);
587
+
588
+ // /diff 가 세던 것에서도 뺀다. 되돌린 파일이 '바뀐 파일' 로 남아 있으면
589
+ // 사람도 되돌아간 줄 모른다.
590
+ for (const f of r.restored) if (!f.skipped) session.changes.delete(f.path);
591
+ // 상태줄의 '되돌릴 턴' 도 여기서 줄여 준다. 안 줄이면 방금 되돌린 것을
592
+ // 아직 되돌릴 수 있는 것처럼 세고 있게 된다.
593
+ try { session.되돌릴턴 = ctx.history.turns().length; } catch { /* 못 세면 그냥 둔다 */ }
594
+
595
+ const 되감음 = session.되감기(r.turnIds ?? []);
596
+ if (되감음.걷은것) {
597
+ say(` ${c.gray(말('undo.alsoTalk', { n: 되감음.걷은것 }))}`);
598
+ if (되감음.고친것) say(` ${c.gray(말('undo.repaired', { n: 되감음.고친것 }))}`);
599
+ try { ctx?.갈래?.현재store?.()?.replace(session.messages, `되돌리기 — ${r.turns}개 턴`); }
600
+ catch { /* 적어 두지 못해도 이번 대화는 이어진다 */ }
601
+ if (되감음.사람말) {
602
+ const 한줄 = 되감음.사람말.replace(/\s+/g, ' ').trim();
603
+ say(` ${c.gray(말('undo.saidWas'))} ${c.cyan(한줄.length > 60 ? `${한줄.slice(0, 60)}…` : 한줄)}`);
604
+ }
255
605
  } else {
256
- say(` ${mark.ok} ${r.turns}개 턴, 파일 ${r.restored.length}개를 되돌렸습니다.`);
257
- for (const f of r.restored) say(` ${c.gray(ctx.scope.show(f.path))} ${c.gray(f.how)}`);
606
+ // 접기·요약이 자리를 이미 가져갔을 때다. 파일은 되돌아갔지만 대화는
607
+ // 걷었다는 것을 숨기지 않는다 — 숨기면 위의 사고가 그대로 난다.
608
+ say(` ${c.gray(말('undo.talkKept'))}`);
609
+ say(` ${c.gray(말('undo.talkKeptWhy'))}`);
258
610
  }
259
611
  say('');
260
612
  return { handled: true };
@@ -331,7 +683,7 @@ export async function handle(line, session, ctx) {
331
683
  session.routed = null;
332
684
  const w = getWork(골라진);
333
685
  say('');
334
- say(` ${c.hcyan(w.glyph)} ${c.bold(w.name)} ${c.gray('(' + w.en + ')')} ${c.gray(w.hint)}`);
686
+ say(` ${c.hcyan(w.glyph)} ${c.bold(보일이름(w.id))} ${c.gray('(' + w.en + ')')} ${c.gray(보일한줄(w.id))}`);
335
687
  say(` ${c.gray('도구')} ${canWrite(골라진) ? c.yellow('읽기 + 파일 바꾸기') : c.green('읽기만 — 파일을 못 바꿉니다')}`);
336
688
  say(` ${c.gray('생각')} ${c.white(w.think ?? session.think)}${c.gray('·')}${c.magenta(w.effort)} ${c.gray('최대 ' + w.steps + '걸음')}`);
337
689
  if (골라진 === 'auto') {
@@ -538,6 +890,12 @@ export async function handle(line, session, ctx) {
538
890
  case 'learned':
539
891
  case '배움': return 배움명령(session, ctx, arg), { handled: true };
540
892
 
893
+ case 'pin':
894
+ case '못박기': return 못박기명령(session, ctx, arg), { handled: true };
895
+
896
+ case 'evidence':
897
+ case '증거': return 증거명령(session, ctx, arg), { handled: true };
898
+
541
899
  case 'sessions': {
542
900
  const rows = listSessions(session.root, { limit: 12 });
543
901
  rule('이 폴더의 지난 대화', 70);
@@ -857,7 +1215,7 @@ function help(session) {
857
1215
  const level = session?.level ?? LEVEL_DEFAULT;
858
1216
  const 쉬움 = !levelShows(level, '__전부__'); // 개발자면 show 가 null 이라 전부 참
859
1217
  say('');
860
- rule(쉬움 ? '명령 (자주 쓰는 것)' : '명령', 70);
1218
+ rule(말(쉬움 ? 'help.titleCommon' : 'help.title'), 70);
861
1219
  let 감춘수 = 0;
862
1220
  for (const [n, m] of Object.entries(COMMANDS)) {
863
1221
  if (n === 'quit') continue;
@@ -867,11 +1225,11 @@ function help(session) {
867
1225
  say('');
868
1226
  // 감춘 것은 '못 쓰는 것' 이 아니다. 그 말을 분명히 해 둔다.
869
1227
  if (감춘수) {
870
- say(` ${c.gray(`이 밖에 ${감춘수}개가 있습니다 — 직접 입력하면 그대로 실행됩니다.`)}`);
871
- say(` ${c.gray('전부 보려면')} ${c.cyan('/level 개발자')}`);
1228
+ say(` ${c.gray(말('help.moreHidden', { n: 감춘수 }))}`);
1229
+ say(` ${c.gray(말('help.showAll'))} ${c.cyan('/level developer')}`);
872
1230
  say('');
873
1231
  }
874
- say(` ${c.gray('그 밖의 입력은 모델에게 보냅니다. 빈 줄에서 Ctrl+C 로 끝냅니다.')}`);
1232
+ say(` ${c.gray(말('help.restGoesToModel'))}`);
875
1233
  say('');
876
1234
  }
877
1235
 
@@ -1075,6 +1433,181 @@ function 갈래명령(session, ctx, arg = '') {
1075
1433
  * 모델이 왜 그렇게 답했는지 알 수 없게 된다 — 여기서 통째로 볼 수 있어야
1076
1434
  * '자동으로 쌓인다' 가 무섭지 않은 말이 된다. 지우는 길도 같이 둔다.
1077
1435
  */
1436
+ /**
1437
+ * 증거 — 「다 됐습니다」 대신 검토할 수 있는 것.
1438
+ *
1439
+ * 화면에서 제일 중요한 것은 맨 아래 '증명 안 된 것' 이다. 바꾼 것을 늘어놓는
1440
+ * 일은 /diff 도 한다. 안 한 것을 말하는 자리는 여기뿐이다.
1441
+ */
1442
+ function 증거명령(session, ctx, arg = '') {
1443
+ const e = 증거모으기(session, { audit: ctx?.audit });
1444
+ const 말 = String(arg ?? '').trim();
1445
+ say('');
1446
+ rule('작업 증거', 70);
1447
+
1448
+ if (!e.바꾼것.length && !e.돌린것.length) {
1449
+ say(` ${c.gray('이번 대화에서 아직 바꾸거나 돌린 것이 없습니다.')}`);
1450
+ say('');
1451
+ return;
1452
+ }
1453
+
1454
+ if (e.바꾼것.length) {
1455
+ say(` ${c.bold('바꾼 것')} ${c.gray(`— 파일 ${e.셈.파일}개 · +${e.셈.더한줄} / -${e.셈.뺀줄}`)}`);
1456
+ for (const x of e.바꾼것.slice(0, 12)) {
1457
+ const 표 = x.증명 ? c.green('✓') : c.yellow('?');
1458
+ const 뒤 = x.증명 ? c.gray(`← ${x.증명}`) : c.yellow('확인 안 됨');
1459
+ say(` ${표} ${pad(x.파일, 34)} ${c.gray(`+${x.더한줄} -${x.뺀줄}`)} ${뒤}`);
1460
+ }
1461
+ if (e.바꾼것.length > 12) say(` ${c.gray(`… 그 밖에 ${e.바꾼것.length - 12}개`)}`);
1462
+ say('');
1463
+ }
1464
+
1465
+ if (e.돌린것.length) {
1466
+ say(` ${c.bold('돌린 것')} ${c.gray(`— ${e.셈.돌린것}개${e.셈.실패한것 ? `, 그중 ${e.셈.실패한것}개 실패` : ''}`)}`);
1467
+ for (const x of e.돌린것.slice(-10)) {
1468
+ const 표 = x.됐나 ? c.green('✓') : c.red('✗');
1469
+ say(` ${표} ${clip(x.무엇 || x.도구, 44)} ${c.gray(clip(x.남긴말 ?? '', 24))}`);
1470
+ }
1471
+ say('');
1472
+ }
1473
+
1474
+ // 여기가 요점이다.
1475
+ if (e.증명안된것.length) {
1476
+ say(` ${c.yellow('증명 안 된 것')} ${c.gray(`— ${e.증명안된것.length}개`)}`);
1477
+ for (const x of e.증명안된것.slice(0, 8)) {
1478
+ say(` ${c.yellow('·')} ${c.bold(x.파일)}`);
1479
+ say(` ${c.gray(x.왜)}`);
1480
+ }
1481
+ say('');
1482
+ say(` ${c.gray('돌려 볼 것이 있으면 지금 돌리고 다시 보세요.')}`);
1483
+ } else {
1484
+ say(` ${c.green('증명 안 된 것 없음')} ${c.gray('— 바꾼 것마다 그 뒤에 돌린 확인이 있습니다.')}`);
1485
+ }
1486
+ say('');
1487
+
1488
+ if (/^(파일|file|저장|save)$/i.test(말)) {
1489
+ const 이름 = `${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '')}`;
1490
+ const 자리 = 증거적기(session.root, e, 이름);
1491
+ if (자리) say(` ${mark.ok} ${c.gray('남겼습니다 —')} ${c.cyan(자리)}`);
1492
+ else say(` ${c.gray('파일로 못 남겼습니다. 화면 것만 쓰세요.')}`);
1493
+ say('');
1494
+ } else {
1495
+ say(` ${c.gray('파일로 남기려면')} ${c.cyan('/evidence 파일')}`);
1496
+ say('');
1497
+ }
1498
+ }
1499
+
1500
+ /**
1501
+ * 모델 카드 — 겪어 본 버릇과, 그 때문에 deel 이 바꾼 것.
1502
+ *
1503
+ * 여기서 중요한 것은 아래쪽 '그래서 바꾼 것' 이다. 위쪽 숫자만 보여 주면
1504
+ * 그냥 통계지만, 무엇이 달라졌는지까지 보여야 사람이 판단할 수 있다 —
1505
+ * "이 모델을 계속 쓸까, 다른 걸 받을까" 가 실제로 묻는 것이다.
1506
+ */
1507
+ function 카드명령(session, ctx) {
1508
+ const 장 = ctx?.카드다시?.() ?? ctx?.카드 ?? null;
1509
+ say('');
1510
+ if (!장) {
1511
+ say(` ${c.gray('이 자리에서는 못 봅니다 — 대화 화면에서만 됩니다.')}`);
1512
+ say('');
1513
+ return;
1514
+ }
1515
+
1516
+ rule('모델 카드', 70);
1517
+ say(` ${c.bold(장.모델 || '(이름 없음)')} ${c.gray(`· 같이 걸어 본 걸음 ${장.걸음}`)}`);
1518
+ say('');
1519
+
1520
+ const 줄 = (이름, v) => {
1521
+ if (!v?.n) return;
1522
+ const 센가 = v.율 >= 0.15;
1523
+ const 표 = 센가 ? c.yellow('●') : c.gray('○');
1524
+ say(` ${표} ${pad(이름, 18)} ${pad(String(v.n), 5, 'right')} ${c.gray(`(${Math.round(v.율 * 100)}%)`)}`);
1525
+ };
1526
+ say(` ${c.bold('겪어 본 버릇')}`);
1527
+ 줄('인자가 잘림', 장.버릇.잘린인자);
1528
+ 줄('빈 답', 장.버릇.빈답);
1529
+ 줄('편집이 빗나감', 장.버릇.편집실패);
1530
+ 줄('같은 것 되풀이', 장.버릇.되풀이);
1531
+ if (!Object.values(장.버릇).some((v) => v.n)) say(` ${c.gray('아직 걸린 것이 없습니다.')}`);
1532
+ if (장.보정 && Math.abs(장.보정 - 1) > 0.01) {
1533
+ say(` ${c.gray(pad('토큰 추정 보정', 18))} ${pad(`×${장.보정.toFixed(2)}`, 5, 'right')}`);
1534
+ }
1535
+ say('');
1536
+
1537
+ say(` ${c.bold('그래서 바꾼 것')}`);
1538
+ if (장.아직모름) {
1539
+ say(` ${c.gray('아직 판단하지 않습니다 — 몇 걸음 안 걸어 보고 바꾸면 멀쩡한 모델을 붙들어 맵니다.')}`);
1540
+ } else if (!장.왜.length) {
1541
+ say(` ${c.gray('바꾼 것 없음. 이 모델은 그대로 두어도 괜찮습니다.')}`);
1542
+ } else {
1543
+ for (const w of 장.왜) say(` ${c.hcyan('→')} ${w}`);
1544
+ }
1545
+ say('');
1546
+ say(` ${c.gray('이 카드는 프롬프트에 안 실립니다 — deel 이 제 행동을 바꾸는 것이라 모델에게 말할 필요가 없습니다.')}`);
1547
+ say('');
1548
+ }
1549
+
1550
+ /**
1551
+ * 못 박기.
1552
+ *
1553
+ * 여기서 하는 일은 화면과 말뿐이다. 들고 있는 것은 agent/pins.js 이고,
1554
+ * 그것이 session 에 붙어 있어서 접기·요약이 닿지 못한다.
1555
+ */
1556
+ function 못박기명령(session, ctx, arg = '') {
1557
+ const 못 = session?.못박은것;
1558
+ say('');
1559
+ if (!못) {
1560
+ say(` ${c.gray('이 자리에서는 못 씁니다 — 대화 화면에서만 됩니다.')}`);
1561
+ say('');
1562
+ return;
1563
+ }
1564
+
1565
+ const 적어두기 = () => { try { ctx?.갈래?.현재store?.()?.못박기목록(못.직렬화()); } catch { /* 못 적어도 대화는 계속된다 */ } };
1566
+ const 말 = String(arg ?? '').trim();
1567
+ const [머리, ...나머지] = 말.split(/\s+/);
1568
+
1569
+ if (/^(지우기|빼기|clear|rm|remove)$/i.test(머리 ?? '')) {
1570
+ const r = 못.지우기(나머지.join(' ') || '전부');
1571
+ if (!r.ok) say(` ${c.red(r.why)}`);
1572
+ else if (typeof r.뺀것 === 'number') say(` ${mark.ok} ${c.gray(`못 박아 둔 것 ${r.뺀것}개를 뺐습니다.`)}`);
1573
+ else say(` ${mark.ok} ${c.gray('뺐습니다 —')} ${r.뺀것}`);
1574
+ 적어두기();
1575
+ say('');
1576
+ return;
1577
+ }
1578
+
1579
+ if (말) {
1580
+ const r = 못.더하기(말);
1581
+ if (!r.ok) {
1582
+ say(` ${c.red(r.why)}`);
1583
+ say('');
1584
+ return;
1585
+ }
1586
+ 적어두기();
1587
+ say(` ${c.hcyan('📌')} ${c.bold(`${r.번호}.`)} ${말}`);
1588
+ say(` ${c.gray('접거나 요약해도 안 지워집니다. 빼려면')} ${c.cyan(`/pin 지우기 ${r.번호}`)}`);
1589
+ say('');
1590
+ return;
1591
+ }
1592
+
1593
+ const 목록 = 못.목록();
1594
+ rule('못 박은 것', 70);
1595
+ if (!목록.length) {
1596
+ say(` ${c.gray('아직 없습니다. 대화 내내 지켜야 할 말을 박아 두세요.')}`);
1597
+ say(` ${c.gray('예:')} ${c.cyan('/pin 운영 DB 는 건드리지 마라')}`);
1598
+ say('');
1599
+ return;
1600
+ }
1601
+ for (const x of 목록) say(` ${c.hcyan(`${x.번호}.`)} ${x.말}`);
1602
+ say('');
1603
+ const 실린것 = 못.실린것();
1604
+ say(` ${c.gray(`매 턴 프롬프트에 실립니다 — 지금 ${실린것.개수}개, ${실린것.토큰}토큰.`)}`);
1605
+ if (!실린것.다실렸나) {
1606
+ say(` ${c.yellow(`자리가 모자라 뒤의 ${목록.length - 실린것.개수}개는 안 실립니다.`)} ${c.gray('짧게 줄이거나 빼세요.')}`);
1607
+ }
1608
+ say('');
1609
+ }
1610
+
1078
1611
  function 배움명령(session, ctx, arg = '') {
1079
1612
  const 배움 = ctx?.배움;
1080
1613
  say('');
@@ -1743,8 +2276,9 @@ function showWork(session) {
1743
2276
  const w = WORK_MODES[k];
1744
2277
  const 지금 = k === (normWork(session.work) ?? WORK_DEFAULT);
1745
2278
  const 표 = 지금 ? c.hgreen('●') : c.gray('·');
1746
- const 이름 = 지금 ? c.bold(c.white(pad(w.name, 8))) : c.gray(pad(w.name, 8));
1747
- say(` ${표} ${c.hcyan(w.glyph)} ${이름}${c.gray(pad(w.en, 14))}${c.gray(w.hint)}`);
2279
+ const 보임 = 보일이름(w.id);
2280
+ const 이름 = 지금 ? c.bold(c.white(pad(보임, 8))) : c.gray(pad(보임, 8));
2281
+ say(` ${표} ${c.hcyan(w.glyph)} ${이름}${c.gray(pad(w.en, 14))}${c.gray(보일한줄(w.id))}`);
1748
2282
  say(` ${canWrite(k) ? c.gray('파일 바꿈') : c.green('읽기만')}${c.gray(' · 생각 ' + (w.think ?? '그대로') + '·' + w.effort + ' · 최대 ' + w.steps + '걸음')}`);
1749
2283
  }
1750
2284
  say('');