deel-local-cli 1.20.12 → 2.0.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.
Files changed (155) hide show
  1. package/README.ko.md +96 -77
  2. package/README.md +119 -80
  3. package/bin/deel.js +398 -32
  4. package/package.json +2 -1
  5. package/src/acp/jsonrpc.js +87 -7
  6. package/src/acp/map.js +62 -13
  7. package/src/acp/serve.js +338 -30
  8. package/src/agent/agents.js +110 -12
  9. package/src/agent/askcheck.js +50 -6
  10. package/src/agent/asks.js +59 -5
  11. package/src/agent/budget.js +9 -2
  12. package/src/agent/card.js +8 -2
  13. package/src/agent/commit.js +168 -16
  14. package/src/agent/compact.js +93 -21
  15. package/src/agent/{/354/213/240/353/242/260/353/217/204.js → confidence.js} +1 -1
  16. package/src/agent/effort.js +85 -15
  17. package/src/agent/evidence.js +63 -7
  18. package/src/agent/evolve.js +132 -24
  19. package/src/agent/grade.js +12 -3
  20. package/src/agent/loop.js +408 -81
  21. package/src/agent/memory.js +176 -29
  22. package/src/agent/mention.js +22 -5
  23. package/src/agent/models.js +31 -7
  24. package/src/agent/modes.js +25 -10
  25. package/src/agent/outschema.js +381 -56
  26. package/src/agent/pins.js +33 -2
  27. package/src/agent/project.js +73 -8
  28. package/src/agent/recall.js +40 -9
  29. package/src/agent/{/354/204/261/355/225/234/352/270/260/353/241/235.js → recordshape.js} +19 -8
  30. package/src/agent/review.js +143 -13
  31. package/src/agent/route.js +506 -32
  32. package/src/agent/salvage.js +2 -1
  33. package/src/agent/session.js +457 -31
  34. package/src/agent/store.js +232 -22
  35. package/src/agent/threads.js +52 -3
  36. package/src/backend/adapter.js +508 -85
  37. package/src/backend/azure.js +12 -1
  38. package/src/backend/cachemark.js +31 -5
  39. package/src/backend/clientcert.js +23 -1
  40. package/src/backend/ctxsize.js +56 -9
  41. package/src/backend/detect.js +252 -19
  42. package/src/backend/http.js +383 -42
  43. package/src/backend/learn.js +101 -11
  44. package/src/backend/mcp.js +333 -24
  45. package/src/backend/price.js +61 -13
  46. package/src/backend/probe.js +188 -36
  47. package/src/backend/proxy.js +108 -14
  48. package/src/backend/quota.js +226 -30
  49. package/src/backend/retry.js +46 -3
  50. package/src/backend/scan.js +47 -10
  51. package/src/backend/scanui.js +57 -7
  52. package/src/backend/toolfit.js +204 -22
  53. package/src/backend/vision.js +14 -1
  54. package/src/backend/wire.js +58 -10
  55. package/src/cmdnames.js +16 -0
  56. package/src/commands/common.js +70 -0
  57. package/src/commands/extend.js +300 -0
  58. package/src/commands/model.js +852 -0
  59. package/src/commands/view.js +328 -0
  60. package/src/commands/work.js +823 -0
  61. package/src/commands.js +193 -2082
  62. package/src/completion.js +45 -3
  63. package/src/config.js +452 -39
  64. package/src/configexplain.js +125 -14
  65. package/src/doctor.js +164 -26
  66. package/src/i18n/en.js +22 -0
  67. package/src/i18n/index.js +31 -1
  68. package/src/i18n/ja.js +25 -1
  69. package/src/i18n/ko.js +25 -2
  70. package/src/i18n/zh.js +25 -1
  71. package/src/lsp/client.js +111 -9
  72. package/src/lsp/servers.js +86 -22
  73. package/src/oneshot.js +325 -65
  74. package/src/pack/sbom.js +28 -5
  75. package/src/pack/selfpack.js +54 -5
  76. package/src/pack/sheet.en.js +24 -3
  77. package/src/pack/tar.js +45 -8
  78. package/src/pack/zip.js +103 -8
  79. package/src/plugins/manage.js +165 -14
  80. package/src/preview/serve.js +239 -23
  81. package/src/providers/bedrock.js +30 -5
  82. package/src/providers/gemini.js +8 -0
  83. package/src/providers/index.js +17 -1
  84. package/src/repl.js +254 -61
  85. package/src/report.js +7 -2
  86. package/src/reset.js +151 -25
  87. package/src/safety/audit.js +108 -8
  88. package/src/safety/authcmd.js +75 -5
  89. package/src/safety/guard.js +507 -15
  90. package/src/safety/hooks.js +172 -20
  91. package/src/safety/keystore.js +178 -18
  92. package/src/safety/network.js +209 -6
  93. package/src/safety/policy.js +377 -36
  94. package/src/safety/runmode.js +9 -2
  95. package/src/safety/secrets.js +243 -17
  96. package/src/safety/shellenv.js +64 -3
  97. package/src/safety/trust.js +298 -32
  98. package/src/safety/undo.js +409 -17
  99. package/src/setup.js +175 -17
  100. package/src/skills/discover.js +122 -19
  101. package/src/stats.js +57 -16
  102. package/src/tools/{/355/231/225/354/235/270/353/262/225.js → checkmethods.js} +26 -1
  103. package/src/tools/clipboard.js +53 -20
  104. package/src/tools/convert.js +118 -37
  105. package/src/tools/desc.en.js +48 -5
  106. package/src/tools/doc2md.js +11 -1
  107. package/src/tools/docs.js +94 -18
  108. package/src/tools/edit-match.js +212 -13
  109. package/src/tools/encoding.js +525 -28
  110. package/src/tools/excel-com.js +63 -15
  111. package/src/tools/excel.js +12 -7
  112. package/src/tools/fastgrep.js +444 -36
  113. package/src/tools/fig.js +40 -9
  114. package/src/tools/fsutil.js +235 -21
  115. package/src/tools/hwpxwrite.js +80 -19
  116. package/src/tools/ignore.js +55 -7
  117. package/src/tools/index.js +1029 -145
  118. package/src/tools/jobs.js +325 -50
  119. package/src/tools/kiwi.js +50 -5
  120. package/src/tools/lsp.js +164 -32
  121. package/src/tools/outline.js +52 -12
  122. package/src/tools/pdf.js +366 -56
  123. package/src/tools/shell.js +12 -4
  124. package/src/tools/spawn.js +203 -18
  125. package/src/tools/todo.js +73 -4
  126. package/src/tools/verify.js +312 -43
  127. package/src/tools/webfetch.js +184 -25
  128. package/src/tools/xlsx.js +232 -31
  129. package/src/ui/ansi.js +209 -15
  130. package/src/ui/banner.js +2 -1
  131. package/src/ui/complete.js +46 -4
  132. package/src/ui/diff.js +4 -2
  133. package/src/ui/export.js +91 -17
  134. package/src/ui/inputbox.js +58 -18
  135. package/src/ui/intro.js +9 -2
  136. package/src/ui/level.js +32 -7
  137. package/src/ui/md.js +101 -15
  138. package/src/ui/motion.js +5 -2
  139. package/src/ui/notify.js +7 -1
  140. package/src/ui/office.js +37 -5
  141. package/src/ui/pastechip.js +3 -2
  142. package/src/ui/pick.js +85 -12
  143. package/src/ui/prompt.js +176 -13
  144. package/src/ui/screen.js +2 -1
  145. package/src/ui/spinner.js +6 -1
  146. package/src/ui/status.js +80 -12
  147. package/src/ui/wrap.js +23 -37
  148. /package/src/agent/{/353/213/250/352/263/204.js" → phase.js} +0 -0
  149. /package/src/skills/builtin/{/352/271/212/354/235/264/354/236/210/352/262/214-/353/247/214/353/223/244/352/270/260/SKILL.md" → build-deep/SKILL.md} +0 -0
  150. /package/src/skills/builtin/{/354/260/250/352/267/274/354/260/250/352/267/274-/353/224/224/353/262/204/352/271/205/SKILL.md" → debug-step-by-step/SKILL.md} +0 -0
  151. /package/src/skills/builtin/{/353/201/235/352/271/214/354/247/200-/355/225/230/352/270/260/SKILL.md" → finish-all/SKILL.md} +0 -0
  152. /package/src/skills/builtin/{/354/212/244/354/212/244/353/241/234-/352/262/200/355/206/240/SKILL.md" → self-review/SKILL.md} +0 -0
  153. /package/src/skills/builtin/{/354/275/224/353/223/234-/354/244/204/354/235/264/352/270/260/SKILL.md" → simplify-code/SKILL.md} +0 -0
  154. /package/src/skills/builtin/{/354/260/224/353/237/254/353/263/264/352/270/260/SKILL.md" → spike/SKILL.md} +0 -0
  155. /package/src/skills/builtin/{/352/262/200/354/202/254-/353/250/274/354/240/200/SKILL.md" → test-first/SKILL.md} +0 -0
package/src/setup.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // 첫 실행 마법사 + 진단 실행.
2
- import { c, say, rule, mark } from './ui/ansi.js';
2
+ import { c, say, rule, mark, clip } from './ui/ansi.js';
3
3
  import { 주소가리기 } from './safety/secrets.js';
4
4
  import { ask, pick, confirm } from './ui/prompt.js';
5
5
  import { spin } from './ui/spinner.js';
@@ -7,13 +7,13 @@ import { detect } from './backend/detect.js';
7
7
  import { 규격이름 } from './backend/adapter.js';
8
8
  import { probe } from './backend/probe.js';
9
9
  import { renderHeader, renderLine, verdict, renderVerdict, plainReport } from './report.js';
10
- import { load, save, upsert, slug, resolveKey, activeProfile, configPath } from './config.js';
10
+ import { load, save, upsert, slug, resolveKey, activeProfile, configPath, 소식줄들 } from './config.js';
11
11
  import { 보관방식 } from './safety/keystore.js';
12
12
  import { 애저풀기, 애저base } from './backend/azure.js';
13
13
  import { allowEndpoint } from './safety/network.js';
14
14
  import { 인증서설정, 인증서등록 } from './backend/clientcert.js';
15
15
  import { 바깥인가, 봉인됐나 } from './safety/runmode.js';
16
- import { 제공자들, 제공자고르기, 어디것일까, 주소후보, 막힌까닭 } from './providers/index.js';
16
+ import { 제공자들, 제공자고르기, 어디것일까, 주소후보, 막힌까닭, 열쇠다듬기 } from './providers/index.js';
17
17
  import { writeFileSync } from 'node:fs';
18
18
 
19
19
  export function banner() {
@@ -22,6 +22,23 @@ export function banner() {
22
22
  say('');
23
23
  }
24
24
 
25
+ /**
26
+ * 사람이 친 주소가 주소 꼴인가. 틀렸으면 까닭(사람 말), 멀쩡하면 null.
27
+ * 스킴이 없으면 `http://` 를 붙여 본다 — connect 가 그렇게 붙어 보기 때문이다.
28
+ */
29
+ function 주소꼴탈(url) {
30
+ if (/\s/.test(url)) return '빈칸이 들어 있습니다 — 예: http://127.0.0.1:11434/v1';
31
+ const 스킴붙음 = /^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : `http://${url}`;
32
+ let u;
33
+ try { u = new URL(스킴붙음); } catch { return '주소 꼴이 아닙니다 — 예: http://127.0.0.1:11434/v1 · localhost:8000 · http://[::1]:8000'; }
34
+ if (!/^https?:$/.test(u.protocol)) return `http 나 https 만 됩니다 — 지금은 ${u.protocol}`;
35
+ // 빈 호스트는 `u.hostname` 으로 못 잡는다 — 호스트가 비면 위 `new URL` 이 먼저 던진다.
36
+ // 새는 꼴은 슬래시가 셋인 주소다: 파서가 경로를 호스트로 끌어올려 `/v1` 이 호스트 `v1` 이 된다.
37
+ // 파서가 고쳐 놓은 값 말고 적힌 글자를 본다 (doctor.js 의 주소살펴보기와 쌍둥이).
38
+ if (/^https?:\/\/\//i.test(스킴붙음)) return '호스트 이름이 없습니다 — 예: http://127.0.0.1:11434/v1';
39
+ return null;
40
+ }
41
+
25
42
  // 주소와 키를 받아 연결을 찾아낸다. 실패하면 null.
26
43
  //
27
44
  // 조용히 = 후보를 여럿 훑는 중이라, 실패해도 아직 화면에 안 적는다.
@@ -68,15 +85,30 @@ async function connect(url, key, { 조용히 = false, 제공자: 어디 = null }
68
85
  */
69
86
  const 까닭 = 막힌까닭(어디, { status: found.status ?? 0, 서버말: found.why ?? '' });
70
87
  /*
71
- * 옮길 말이 없으면 **받은 말을 그대로** 보여 준다.
88
+ * 표에 있는 말과 **받은 말을 둘 다** 낸다.
72
89
  *
73
90
  * 사람 말로 옮기는 표는 HTTP 상태코드가 있을 때만 쓸모가 있다. 닿지도
74
91
  * 못했을 때(status 0)가 오히려 원인이 또렷한 경우가 많다 — 봉인에 막힘 ·
75
92
  * 포트 닫힘 · 인증서 · 프록시. 그 한 줄을 버리면 화면에는 「연결 실패」
76
93
  * 네 글자만 남고, 정작 답이 적힌 문장을 우리가 지운 셈이 된다.
94
+ *
95
+ * 그래서 여태는 `if (까닭) … else if (found.why) …` 였는데, 그 else 가
96
+ * 거의 안 돌았다. 공통까닭() 은 401·403·404·429·402 에 전부 답이 있어서
97
+ * 표가 늘 먼저 이긴다. 그 바람에 detect.js 의 애저막힌말() 이 지은 세
98
+ * 줄(401·403·404)이 한 줄도 화면에 못 왔다 — 404 의 「주소에
99
+ * /openai/deployments/<배포이름> 까지 넣어 보세요」 까지 같이. 일반 문구는
100
+ * 「주소를 다시 보세요」 로 끝나는데, 정작 **어디를 어떻게** 고치는지는
101
+ * 덮인 쪽에만 적혀 있었다.
102
+ *
103
+ * 표는 고칠 갈래를 가르고(열쇠냐 주소냐 권한이냐), 받은 말은 그 갈래
104
+ * 안에서 어디를 고치는지 짚는다. 둘은 겹치는 말이 아니다. 같은 말이면
105
+ * 한 번만 낸다. 받은 말은 서버가 통째로 뱉은 본문일 수 있어 자른다 —
106
+ * 안 자르면 JSON 한 덩이가 화면을 밀어내고 「확인할 것」 이 안 보인다.
77
107
  */
78
- if (까닭) { say(` ${mark.warn} ${c.yellow(까닭)}`); say(''); }
79
- else if (found.why) { say(` ${mark.warn} ${c.yellow(found.why)}`); say(''); }
108
+ const 받은말 = String(found.why ?? '').trim();
109
+ if (까닭) say(` ${mark.warn} ${c.yellow(까닭)}`);
110
+ if (받은말 && 받은말 !== 까닭) say(` ${mark.warn} ${c.yellow(clip(받은말, 160))}`);
111
+ if (까닭 || 받은말) say('');
80
112
  say(` 시도한 주소:`);
81
113
  for (const t of found.tried) say(` ${c.gray(주소가리기(t.includes('?') ? t : `${t}/models`))}`);
82
114
  say('');
@@ -113,6 +145,9 @@ async function chooseModel(found) {
113
145
  * **봉인은 진단에도 걸린다.** 여기가 빠져 있었다 — 관리자가 정책으로 밖을
114
146
  * 막아 둔 PC 에서도 `deel diagnose` 는 그냥 나갔다. 자물쇠가 명령 하나로
115
147
  * 열리면 자물쇠가 아니다. 사내망 주소는 봉인에서도 그대로 간다.
148
+ *
149
+ * 부르는 쪽 **셋 다** 이 말을 넘긴다 — 마법사(설정받기)·`deel diagnose`·`--url`.
150
+ * 한 곳이라도 안 넘기면 그 명령 하나가 자물쇠를 통째로 연다. 마법사가 그랬다.
116
151
  */
117
152
  export async function runProbe(conn, { out = null, 봉인 = false } = {}) {
118
153
  if (봉인 && 바깥인가(conn.base)) {
@@ -171,7 +206,7 @@ async function 붙일곳고르기() {
171
206
  let 제공자 = null;
172
207
  let 열쇠 = '';
173
208
  if (고른.id === '열쇠먼저') {
174
- 열쇠 = (await ask('API 키', { mask: true })).trim();
209
+ 열쇠 = 열쇠다듬기(await ask('API 키', { mask: true }));
175
210
  if (!열쇠) { say(` ${mark.no} 열쇠가 비었습니다.`); return null; }
176
211
  const 짚은것 = 어디것일까(열쇠);
177
212
  if (짚은것) {
@@ -228,6 +263,20 @@ async function 붙일곳고르기() {
228
263
  }
229
264
  const url = (await ask('주소 (Base URL)')).trim();
230
265
  if (!url) { say(` ${mark.no} 주소가 비었습니다.`); return null; }
266
+ /*
267
+ * 틀린 주소는 **받은 자리에서** 알린다.
268
+ *
269
+ * `http://` · `https://` · `::1` · 빈칸 든 주소가 여기를 그냥 지나가, 열쇠를 묻고 이름을 묻고
270
+ * 붙어 볼 때에야 「Invalid URL」 이라는 날것의 말로 죽었다(`deel setup` 이 통째로 던졌다).
271
+ * 열쇠를 찾아 붙여넣은 수고가 버려진다. 스킴을 안 적은 `127.0.0.1:11434` 는 여태처럼 받는다 —
272
+ * 붙는 쪽(connect)이 스킴을 붙여 본다.
273
+ */
274
+ const 주소탈 = 주소꼴탈(url);
275
+ if (주소탈) {
276
+ say(` ${mark.no} ${c.yellow('주소가 올바르지 않습니다:')} ${url}`);
277
+ say(` ${c.gray(주소탈)}`);
278
+ return null;
279
+ }
231
280
  주소들 = [url];
232
281
  } else {
233
282
  say(` ${c.gray('주소')} ${주소들.map((u) => 주소가리기(u)).join(c.gray(' · '))}`);
@@ -236,7 +285,7 @@ async function 붙일곳고르기() {
236
285
  // ── 열쇠 ─────────────────────────────────────────────────────────────
237
286
  if (!열쇠) {
238
287
  if (제공자.열쇠받는곳) say(` ${c.gray('열쇠 받는 곳')} ${c.cyan(제공자.열쇠받는곳)}`);
239
- 열쇠 = (await ask('API 키', { mask: true })).trim();
288
+ 열쇠 = 열쇠다듬기(await ask('API 키', { mask: true }));
240
289
  }
241
290
 
242
291
  const 기본이름 = 제공자.id === 'custom' ? '사내게이트웨이' : 제공자.이름;
@@ -244,12 +293,53 @@ async function 붙일곳고르기() {
244
293
  return { 제공자, 주소들, 열쇠, 이름 };
245
294
  }
246
295
 
247
- export async function runSetup() {
296
+ /*
297
+ * ── 답할 사람이 없는데 목록 한가운데서 0 으로 끝났다 ────────────────────
298
+ *
299
+ * 표준입력이 닫힌 채(파이프 · CI · `deel setup < /dev/null`) 켜면 첫 물음에서
300
+ * 받을 글자가 영영 안 온다. 물음은 끝나지 않는 약속으로 남고, 이벤트 루프가
301
+ * 비면 Node 는 **그냥 0 으로** 나간다. 화면에는 「어디에 붙일까요? › 번호 [2]」
302
+ * 까지만 찍히고 설정은 없다. 스크립트는 설정이 된 줄 알고 다음 줄로 간다.
303
+ *
304
+ * reset.js 는 같은 자리에서 「물어볼 자리가 없습니다」 로 1 을 낸다. 여기도 그렇게
305
+ * 한다. 다만 **미리 막지는 않는다** — 답을 파이프로 흘려 넣어 setup 을 돌리는
306
+ * 쓰임이 있다. 답이 모자라서 입력이 끝났을 때만 멈춘다. 'end' 는 물음이 입력을
307
+ * 읽는 동안에만 날 수 있으므로(prompt.js 는 물음 사이에 입력을 멈춘다), 답을 다
308
+ * 받은 뒤 붙는 중에 끊기는 일은 없다.
309
+ */
310
+ const 입력끝남 = Symbol('입력끝남');
311
+
312
+ export async function runSetup(flags = {}) {
313
+ let 알리기 = null;
314
+ const 끝남 = new Promise((풀기) => { 알리기 = () => 풀기(입력끝남); });
315
+ const 표준입력 = process.stdin;
316
+ if (!표준입력.isTTY) 표준입력.once('end', 알리기);
317
+ try {
318
+ const r = await Promise.race([설정받기(flags), 끝남]);
319
+ if (r !== 입력끝남) return r;
320
+ say('');
321
+ say(` ${mark.no} ${c.yellow('표준입력이 닫혀 답을 더 받을 수 없습니다.')} ${c.gray('아무것도 저장하지 않았습니다.')}`);
322
+ say(` ${c.gray('터미널에서')} ${c.cyan('deel setup')} ${c.gray('을 다시 하거나, 이 PC 에 로컬 모델이 떠 있으면')} ${c.cyan('deel scan --save')}`);
323
+ say('');
324
+ return 1;
325
+ } finally {
326
+ 표준입력.off('end', 알리기);
327
+ }
328
+ }
329
+
330
+ async function 설정받기(flags = {}) {
248
331
  banner();
249
332
  // 설정에 적어 둔 api-version 을 **붙기 전에** 읽는다. load() 가 애저정하기()를
250
333
  // 부른다. 이걸 뒤에서 하면 사내에서 판을 고정해 둔 곳이 확인만 GA판으로 하고,
251
334
  // 문서에 적어 둔 대로 안 도는 셈이 된다.
252
- load();
335
+ const 첫설정 = load();
336
+ /*
337
+ * 모아 둔 소식을 여기서 비운다. `deel setup` 은 설정을 만지는 문인데
338
+ * 넷 중 하나도 안 비우고 있었다 — 열쇠를 방금 잠갔다는 말도, 이 폴더
339
+ * 설정을 안 읽었다는 말도, 관리 정책이 깨졌다는 말도 다 사라졌다.
340
+ * (config.js 의 소식줄들 머리말)
341
+ */
342
+ for (const 줄 of 소식줄들(첫설정)) say(줄);
253
343
  say(` ${c.gray('모델 연결을 설정합니다.')}`);
254
344
  // 빈칸 0개짜리 길을 제일 먼저 알려 준다. 이 PC 에 이미 모델이 떠 있는데
255
345
  // 주소를 손으로 치게 만드는 것은 우리 잘못이다.
@@ -260,6 +350,33 @@ export async function runSetup() {
260
350
  const { 제공자: 붙일곳, 주소들, 열쇠: key, 이름: name } = 고른것;
261
351
 
262
352
  say('');
353
+ /*
354
+ * ── 봉인은 이 문에도 걸린다 ───────────────────────────────────────────
355
+ *
356
+ * 위 runProbe 머리말이 「봉인은 진단에도 걸린다 … 자물쇠가 명령 하나로 열리면
357
+ * 자물쇠가 아니다」 라고 적어 두고, 정작 이 문은 그 말을 한 번도 안 넘겼다.
358
+ * 관리 정책이나 설정에 offline 을 켜 둔 PC 에서도 `deel setup` 은 후보 주소를
359
+ * 그대로 두드렸다 — 방금 적어 넣은 열쇠까지 실려서. 두드리기 **전에** 거른다.
360
+ * 사내망 주소는 봉인에서도 그대로 간다. 스킴을 안 적은 주소는 connect() 가
361
+ * 붙이는 것과 같은 꼴로 재 준다 — 안 맞추면 `127.0.0.1:11434` 가 바깥으로 샌다.
362
+ */
363
+ // 깃발도 같이 본다. `--help` 는 `--offline` 을 「기억해 둔 허가까지 무시하고 막습니다」
364
+ // 라고 적는데, 여기가 `{ cfg }` 만 넘겨서 그 깃발이 조용히 버려졌다 — 설정·정책에
365
+ // 적어 둔 봉인만 걸리고, 사람이 그 자리에서 친 `deel setup --offline` 은 안 걸렸다.
366
+ // 깃발을 믿고 사내 게이트웨이 주소를 넣은 사람은 방금 적은 열쇠까지 실어 보낸 셈이 된다.
367
+ // (diagnose 는 같은 판에서 `깃발: flags.offline` 을 넘긴다 — 두 문이 갈려 있었다.)
368
+ const 봉인 = 봉인됐나({ 깃발: flags?.offline, prof: null, cfg: 첫설정 });
369
+ const 두드릴것 = 봉인
370
+ ? 주소들.filter((u) => !바깥인가(/^https?:\/\//i.test(u) ? u : `http://${u}`))
371
+ : 주소들;
372
+ if (봉인 && !두드릴것.length) {
373
+ say('');
374
+ say(` ${mark.no} ${c.red('봉인되어 있어 이 주소는 안 두드립니다.')} ${c.gray(주소들.map((u) => 주소가리기(u)).join(' · '))}`);
375
+ say(` ${c.gray('관리 정책이나 설정에 offline 이 켜져 있습니다. 사내망 주소는 그대로 됩니다.')}`);
376
+ say('');
377
+ return 1;
378
+ }
379
+
263
380
  /*
264
381
  * 후보를 앞에서부터 두드린다. 하나라도 붙으면 그것으로 간다.
265
382
  *
@@ -268,8 +385,8 @@ export async function runSetup() {
268
385
  * 물어보면 되는 것을 짐작으로 정하지 않는다.
269
386
  */
270
387
  let found = null;
271
- for (const [n, url] of 주소들.entries()) {
272
- const 마지막 = n === 주소들.length - 1;
388
+ for (const [n, url] of 두드릴것.entries()) {
389
+ const 마지막 = n === 두드릴것.length - 1;
273
390
  found = await connect(url, key, { 조용히: !마지막, 제공자: 붙일곳 });
274
391
  if (found) break;
275
392
  }
@@ -291,7 +408,9 @@ export async function runSetup() {
291
408
  }
292
409
 
293
410
  const conn = { kind: found.kind, base: found.base, auth: found.auth, key, model };
294
- const { facts } = await runProbe(conn);
411
+ const { facts } = await runProbe(conn, { 봉인 });
412
+ // 봉인에 막혀 한 줄도 못 재 본 연결은 저장하지 않는다 — 안 재 본 값을 파일에 적는 셈이 된다.
413
+ if (!facts) return 1;
295
414
 
296
415
  /*
297
416
  * ── 「나가도 된다」 를 여기서 명시적으로 받는다 ───────────────────────
@@ -328,8 +447,14 @@ export async function runSetup() {
328
447
  // 어디 것인지 적어 둔다. 요금표 주소·열쇠 받는 곳처럼 「이 회사에만 해당되는
329
448
  // 안내」를 나중에 꺼내 쓸 때 이 한 칸이 없으면 주소를 보고 다시 짐작해야 한다.
330
449
  제공자: 붙일곳.id,
331
- // 로컬·사내망이면 이 칸 자체가 없다. 있는 것만으로도 「바깥이다」 라는 뜻이라,
332
- // 설정 파일을 열어 본 사람이 그것만 보고도 안다.
450
+ /*
451
+ * 이 칸은 **허락을 받았을 때만** 붙는다.
452
+ *
453
+ * 없다고 이 안이라는 뜻은 아니다 — 바깥 주소인데 사람이 「아니오」 를
454
+ * 고른 판도 없다. 바깥인지 아닌지는 언제나 **주소로** 가른다(바깥인가).
455
+ * 화면의 연결 목록도 그렇게 적는다(아래 `↗ 바깥 · 켤 때 물어봄`).
456
+ * 읽는 쪽은 전부 `online === true` 만 본다 — 없는 것과 false 는 같다.
457
+ */
333
458
  ...(나가도되나 ? { online: true } : {}),
334
459
  ctx: facts.ctx ?? null,
335
460
  streaming: facts.streaming ?? false,
@@ -362,6 +487,26 @@ export async function runDiagnose(flags) {
362
487
 
363
488
  if (flags.url) {
364
489
  쓴설정 = load(); // 설정의 api-version 을 먼저 읽는다 (runSetup 과 같은 이유)
490
+ /*
491
+ * 봉인은 **두드리기 전에** 본다.
492
+ *
493
+ * 아래 runProbe 도 봉인을 보지만 그건 붙은 뒤다 — 그때는 connect() 가
494
+ * 이미 그 주소로 나갔다(`/models` 를 후보마다 두드린다). 봉인을 켜 둔
495
+ * 사람이 「안 나갔겠지」 하는 동안 열쇠까지 실려 나간 셈이 된다. 설정에
496
+ * 없는 주소를 그 자리에서 치는 이 길이 오히려 제일 위험하다 —
497
+ * 아래 머리말이 그렇게 적어 두고도 정작 여기를 안 막고 있었다.
498
+ *
499
+ * 스킴을 안 적은 주소는 connect() 가 붙이는 것과 같은 꼴로 재 준다.
500
+ * 안 맞추면 `127.0.0.1:11434` 가 바깥으로 세어져 이 안까지 막힌다.
501
+ */
502
+ if (봉인됐나({ 깃발: flags.offline, prof: null, cfg: 쓴설정 })
503
+ && 바깥인가(/^https?:\/\//i.test(flags.url) ? flags.url : `http://${flags.url}`)) {
504
+ say('');
505
+ say(` ${mark.no} ${c.red('봉인되어 있어 이 주소는 안 두드립니다.')} ${c.gray(주소가리기(flags.url))}`);
506
+ say(` ${c.gray('관리 정책이나 설정에 offline 이 켜져 있습니다. 사내망 주소는 그대로 됩니다.')}`);
507
+ say('');
508
+ return 1;
509
+ }
365
510
  const key = flags.key ?? process.env.DEEL_API_KEY ?? '';
366
511
  const found = await connect(flags.url, key);
367
512
  if (!found) return 1;
@@ -398,8 +543,21 @@ export async function runDiagnose(flags) {
398
543
  * 봉인은 --url 로 주소를 직접 준 갈래에도 걸린다. 오히려 그쪽이 더 위험하다
399
544
  * — 설정에 없는 주소를 그 자리에서 두드리는 길이기 때문이다.
400
545
  */
401
- await runProbe(conn, { out: flags.out ?? null, 봉인: 봉인됐나({ 깃발: flags.offline, prof: 쓴프로필, cfg: 쓴설정 }) });
402
- return 0;
546
+ const { v, 봉인: 막혔나 } = await runProbe(conn, { out: flags.out ?? null, 봉인: 봉인됐나({ 깃발: flags.offline, prof: 쓴프로필, cfg: 쓴설정 }) });
547
+
548
+ /*
549
+ * ── 끝값은 판정을 그대로 옮긴다 ───────────────────────────────────────
550
+ *
551
+ * 여기가 무조건 `return 0` 이었다. 화면에는 빨간 「연결실패」 가 찍히는데 끝값은
552
+ * 0 이라, 사내에 넣기 전에 이 명령부터 돌리는 스크립트는 붙은 줄 알고 다음 줄로
553
+ * 갔다. 봉인에 막혀 **한 줄도 못 재 본** 판도 똑같이 0 이었다. 스크립트가 읽는
554
+ * 것은 화면 글이 아니라 이 숫자다 — 여기가 0 이면 진단을 돌린 뜻이 없다.
555
+ *
556
+ * 「돌아는 간다」(ready · limited)까지가 0 이다. blocked 는 도구 호출이 막힌
557
+ * 것이라 이 도구로 할 일 자체가 안 된다 — 사람에게는 실패로 알린다.
558
+ */
559
+ if (막혔나 || !v) return 1;
560
+ return v.level === 'ready' || v.level === 'limited' ? 0 : 1;
403
561
  }
404
562
 
405
563
  export async function showStatus() {
@@ -14,32 +14,74 @@ import { homedir } from 'node:os';
14
14
  import { homeDir } from '../config.js';
15
15
  import { pluginsDir } from '../plugins/manage.js';
16
16
  // 저장소에 딸려 온 스킬·명령이 시스템 글에 실리지 않게 한다 (discover 안구절 머리말).
17
- import { 믿나 } from '../safety/trust.js';
17
+ import { 믿나, BOM떼기 } from '../safety/trust.js';
18
18
 
19
19
  // 이 파일 옆의 builtin/ — 패키지에 같이 실려 나간다(package.json files: src).
20
20
  export const 내장자리 = join(dirname(fileURLToPath(import.meta.url)), 'builtin');
21
21
 
22
22
  // --- YAML 앞머리 읽기 (name, description 만 쓰므로 최소만 구현) -------------
23
+ /*
24
+ * ── 금은 **한 줄 통째로** `---` 여야 한다 (2.0.0 4회차 사냥) ───────────────
25
+ *
26
+ * 스킬·슬래시 명령·.md 에이전트(agent/agents.js)가 이 함수 하나를 같이 쓴다. 넷이 걸렸다.
27
+ *
28
+ * · 닫는 금을 `\n---` 로 찾았다 — `---extra` · `----` 줄도 금이 됐다.
29
+ * · 여는 금만 있고 안 닫힌 파일은 본문 뒤쪽의 `---`(마크다운 가로줄)까지를 앞머리로
30
+ * 읽어, 본문의 `name: hijack` 줄이 이름을 **덮었다.**
31
+ * · 닫는 금이 파일 끝(줄바꿈 없음)이면 본문이 앞머리를 포함한 파일 전체가 됐다.
32
+ * · BOM 이 붙으면 `---` 로 시작하지 않는 파일이 되어 이름·설명이 통째로 빠졌다
33
+ * (파워셸 5.1 로 저장한 SKILL.md — safety/trust.js 의 BOM떼기).
34
+ *
35
+ * 그래서 줄 단위로 걷는다. 금은 `---` 뒤에 빈칸·CR 만 봐준다. 금을 만나기 전에 YAML 에
36
+ * 설 수 없는 줄(산문)이 나오면 앞머리가 안 닫힌 것으로 치고 **앞머리 없음**으로 돌려준다 —
37
+ * 본문을 앞머리로 읽는 것보다 앞머리를 잃는 쪽이 덜 나쁘다(이름은 폴더 이름으로 간다).
38
+ * 앞머리가 몇 백 줄일 까닭은 없어서 찾는 줄 수에도 상한을 둔다. 같은 열쇠가 두 번 오면
39
+ * 첫 것을 둔다 — 뒤엣것은 대개 본문이 새어 들어온 것이다.
40
+ */
41
+ export const 앞머리최대줄 = 200;
42
+ const 금인가 = (줄) => /^---[ \t]*\r?$/.test(줄);
43
+ // YAML 앞머리에 설 수 있는 줄 꼴 — 빈 줄 · 들여 쓴 줄 · 주석 · 목록 · `열쇠:` 줄.
44
+ const 앞머리줄인가 = (줄) => {
45
+ const l = 줄.replace(/\r$/, '');
46
+ // 콜론 뒤에는 빈칸이나 줄 끝이 와야 열쇠다. YAML 에서 `foo:bar` 는 매핑이 아니라
47
+ // 글자 하나다 — 그걸 열쇠로 받아 주면 `메모:여기서부터 본문입니다` 같은 산문 한 줄에
48
+ // 앞머리가 본문 뒤 가로줄까지 늘어나고, 그 사이의 `description:` 이 앞머리로 실린다.
49
+ return !l.trim() || /^\s/.test(l) || l.startsWith('#') || /^-(\s|$)/.test(l) || /^[^\s#][^:]*:(\s|$)/.test(l);
50
+ };
51
+
23
52
  export function frontmatter(text) {
24
- if (!text.startsWith('---')) return { data: {}, body: text };
25
- const nl = text.indexOf('\n');
26
- if (nl < 0) return { data: {}, body: text };
27
- const end = text.indexOf('\n---', nl);
28
- if (end < 0) return { data: {}, body: text };
53
+ const 글 = BOM떼기(text);
54
+ const 없음 = { data: {}, body: 글 };
55
+ const 첫끝 = 글.indexOf('\n');
56
+ if (첫끝 < 0 || !금인가(글.slice(0, 첫끝))) return 없음;
57
+ const 머리 = [];
58
+ let 시작 = 첫끝 + 1;
59
+ for (let n = 0; n < 앞머리최대줄 && 시작 <= 글.length; n++) {
60
+ const 끝 = 글.indexOf('\n', 시작);
61
+ const 줄 = 끝 < 0 ? 글.slice(시작) : 글.slice(시작, 끝);
62
+ if (금인가(줄)) return { data: 머리펴기(머리), body: 끝 < 0 ? '' : 글.slice(끝 + 1) };
63
+ if (!앞머리줄인가(줄)) return 없음;
64
+ // CRLF 로 저장된 파일이 많다. \r 를 남겨 두면 정규식의 . 와 $ 가 그걸 줄 끝으로 보고
65
+ // 마지막 줄을 통째로 못 읽는다. 먼저 걷어낸다.
66
+ 머리.push(줄.replace(/\r/g, ''));
67
+ if (끝 < 0) break;
68
+ 시작 = 끝 + 1;
69
+ }
70
+ return 없음;
71
+ }
29
72
 
30
- // CRLF 로 저장된 파일이 많다. \r 를 남겨 두면 정규식의 . 와 $ 가 그걸 줄 끝으로 보고
31
- // 마지막 줄을 통째로 못 읽는다. 먼저 걷어낸다.
32
- const head = text.slice(nl + 1, end).replace(/\r/g, '');
33
- const body = text.slice(text.indexOf('\n', end + 1) + 1);
73
+ function 머리펴기(줄들) {
34
74
  const data = {};
35
75
  let key = null;
36
- for (const line of head.split('\n')) {
76
+ for (const line of 줄들) {
37
77
  if (/^\s/.test(line) && key) { // 이어지는 줄 (여러 줄 값)
38
78
  data[key] = (data[key] ? data[key] + ' ' : '') + line.trim();
39
79
  continue;
40
80
  }
41
81
  const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
42
82
  if (!m) { key = null; continue; }
83
+ // 같은 열쇠는 첫 것을 둔다(위 머리말). `__proto__` 는 받지 않는다 — 담는 그릇의 틀이 바뀐다.
84
+ if (Object.hasOwn(data, m[1]) || m[1] === '__proto__') { key = null; continue; }
43
85
  key = m[1];
44
86
  let v = m[2].trim();
45
87
  // YAML 블록 표기( > >- | |- )는 값이 다음 줄부터 온다는 뜻이다. 표시만 지우고 비워 둔다.
@@ -47,7 +89,7 @@ export function frontmatter(text) {
47
89
  else if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
48
90
  data[key] = v;
49
91
  }
50
- return { data, body };
92
+ return data;
51
93
  }
52
94
 
53
95
  const dirs = (p) => {
@@ -111,7 +153,8 @@ function readPlugin(rootDir, skills, commands, caps) {
111
153
  const manifest = join(rootDir, '.claude-plugin', 'plugin.json');
112
154
  let info = null;
113
155
  if (existsSync(manifest)) {
114
- try { info = JSON.parse(readFileSync(manifest, 'utf8')); } catch {}
156
+ // BOM 을 뗀다 — 떼지 않으면 이름이 폴더 이름으로 바뀌어 `/플러그인:스킬` 이 다른 이름이 됐다(2.0.0 3회차).
157
+ try { info = JSON.parse(BOM떼기(readFileSync(manifest, 'utf8'))); } catch {}
115
158
  }
116
159
  const ns = info?.name || basename(rootDir);
117
160
  let found = 0;
@@ -144,7 +187,7 @@ function findPluginRoots(base, depth = 4) {
144
187
 
145
188
  function manifestName(rootDir) {
146
189
  const f = join(rootDir, '.claude-plugin', 'plugin.json');
147
- try { return JSON.parse(readFileSync(f, 'utf8')).name || basename(rootDir); }
190
+ try { return JSON.parse(BOM떼기(readFileSync(f, 'utf8'))).name || basename(rootDir); }
148
191
  catch { return basename(rootDir); }
149
192
  }
150
193
 
@@ -267,12 +310,50 @@ export function discover(root, opts = {}) {
267
310
  try { return readdirSync(p).length > 0; } catch (e) { return e?.code !== 'ENOENT'; }
268
311
  });
269
312
 
313
+ /*
314
+ * ── 다만 안 믿는 폴더의 명령은 **내 명령을 덮지 못한다** (2.0.0 4회차 사냥) ──
315
+ *
316
+ * 명령을 그냥 두는 까닭은 「사람이 `/이름` 을 직접 칠 때만 펴진다」 였다. 그런데 사람이
317
+ * `/review` 를 칠 때 떠올리는 것은 **제가 만든** review 다. 저장소에 같은 이름(대소문자만
318
+ * 달라도 — commands.js 는 대소문자를 안 가리고 찾는다)을 넣어 두면 dedupe 가 가까운 쪽을
319
+ * 이기게 해서, 사람은 제 명령을 친 줄 알고 남이 적은 본문을 모델에게 보냈다.
320
+ *
321
+ * 그래서 안 믿는 폴더에서는 이미 있는 이름(사용자·플러그인 것, 플러그인은 `:` 뒤 꼬리도)과
322
+ * 겹치는 저장소 명령을 **빼고**, 뺀 이름을 `안믿은명령` 으로 돌려준다. 안 겹치는 것은
323
+ * 여전히 그대로 된다 — 위 「명령은 왜 그냥 두나」 의 판단은 그대로다. 집에서 켰으면
324
+ * 프로젝트 자리가 곧 사용자 자리라 건너뛴다(같은 파일을 「못 덮었다」 고 말하게 된다).
325
+ */
326
+ const 안믿은명령 = [];
327
+ let 있던이름 = null;
328
+
270
329
  // 2) 사용자 3) 프로젝트
271
330
  for (const [base, source] of [[home, 'user'], [root, 'project']]) {
272
331
  for (const cfgDir of ['.deel', '.claude']) {
273
332
  if (source !== 'project' || 믿는가) {
274
333
  skillsIn(join(base, cfgDir, 'skills'), source, null, skills, caps.skills);
275
334
  }
335
+ if (source === 'project' && !믿는가) {
336
+ if (같은자리(root, home)) continue;
337
+ 있던이름 ??= new Set(commands.flatMap((x) => [x.name.toLowerCase(), x.name.split(':').pop().toLowerCase()]));
338
+ const 저장소것 = [];
339
+ commandsIn(join(base, cfgDir, 'commands'), source, null, 저장소것, caps.commands);
340
+ for (const x of 저장소것) {
341
+ // 겹침을 **먼저** 본다. 상한을 먼저 보면 상한에 닿는 순간 겹친 이름이
342
+ // 안믿은명령 에서 빠지고, 화면(repl.js)은 그 목록으로만 말하므로 사람은
343
+ // 제 명령이 가려졌다는 것도 모른 채 저장소 명령이 안 도는 것만 본다.
344
+ if (있던이름.has(x.name.toLowerCase())) { 안믿은명령.push(x.name); continue; }
345
+ /*
346
+ * 여기가 `break` 였다 (8회차 판정). 차례는 맞게 뒀는데 상한에 닿는
347
+ * 순간 고리를 통째로 나가서, **그 뒤의 저장소 명령은 겹침 검사를 아예
348
+ * 못 받았다.** 위 주석이 막으려던 바로 그 실패가 상한 뒤에서 그대로
349
+ * 일어난다 — 재 보니 상한 뒤에 있던 겹친 이름이 안믿은명령 에서 빠졌다.
350
+ * `continue` 면 더 넣지는 않으면서 겹침은 끝까지 센다.
351
+ */
352
+ if (commands.length >= caps.commands) continue;
353
+ commands.push(x);
354
+ }
355
+ continue;
356
+ }
276
357
  commandsIn(join(base, cfgDir, 'commands'), source, null, commands, caps.commands);
277
358
  }
278
359
  }
@@ -283,13 +364,22 @@ export function discover(root, opts = {}) {
283
364
  plugins,
284
365
  // 파일은 있는데 폴더를 안 믿어서 안 읽은 경우. 화면이 이걸 말해야 한다.
285
366
  안믿음: !믿는가 && 프로젝트것있음,
367
+ // 안 믿는 폴더의 명령 중 내 명령과 이름이 겹쳐 뺀 것. 화면이 이것도 말한다.
368
+ 안믿은명령,
286
369
  };
287
370
  }
288
371
 
289
- // 같은 이름이면 나중 것(더 가까운 자리)이 이긴다.
372
+ /*
373
+ * 같은 이름이면 나중 것(더 가까운 자리)이 이긴다.
374
+ *
375
+ * 대소문자만 다른 것도 **같은 이름**이다. 여태 x.name 그대로 열쇠를 삼아,
376
+ * `review`(집)와 `Review`(저장소)가 둘 다 살아남았다 — 겹침을 재는 바로 윗자리는
377
+ * 처음부터 toLowerCase() 였고, 윈도·맥에서는 파일 이름부터 같은 것이라 사람은
378
+ * 둘이 있는 줄도 모른다. 이름 글자는 나중 것의 것을 그대로 쓴다.
379
+ */
290
380
  function dedupe(list) {
291
381
  const m = new Map();
292
- for (const x of list) m.set(x.name, x);
382
+ for (const x of list) m.set(x.name.toLowerCase(), x);
293
383
  return [...m.values()];
294
384
  }
295
385
 
@@ -311,9 +401,22 @@ export function loadCommand(cmd, args = '') {
311
401
  let text;
312
402
  try { text = readFileSync(cmd.path, 'utf8'); } catch (err) { return { error: err.message }; }
313
403
  const { body } = frontmatter(text);
404
+ /*
405
+ * **한 번에**, **함수로** 바꾼다 (2.0.0 4회차 사냥).
406
+ *
407
+ * 글자로 바꾸면 replace 가 준 말 안의 `$'` · `$&` · `` $` `` · `$$` 를 무늬로 풀었다 —
408
+ * `price is $'` 가 본문 뒤쪽을 통째로 끌어왔다. 그리고 두 번에 나눠 바꾸면 첫 번에 넣은
409
+ * 사람 말 안의 `$1` 을 둘째 번이 또 바꿨다. 자리 표시(`$1`·`$2`)는 Claude Code 명령 규격
410
+ * 그대로 두되, 채우는 값은 **사람이 준 말에서만** 온다.
411
+ *
412
+ * 자리는 `$1`…`$9` 뿐이다 (SK4 · 8회차). `\d` 로 받던 때는 `$0` 도 자리로 읽혀
413
+ * `조각[-1]` → 빈 글자가 되었다 — 명령 본문에 적어 둔 `basename $0` 의 `$0` 이
414
+ * 소리 없이 지워졌다. `$10` 은 열째 인자가 아니라 `$1` 뒤에 글자 `0` 이다.
415
+ * `\d+` 로 넓히지 않는 이유: `$1` 뒤에 숫자를 적어 둔 기존 명령이 조용히 뜻이 바뀐다.
416
+ */
417
+ const 인자 = String(args ?? '');
418
+ const 조각 = 인자.trim() ? 인자.trim().split(/\s+/) : [];
314
419
  return {
315
- text: body
316
- .replace(/\$ARGUMENTS/g, args)
317
- .replace(/\$(\d)/g, (_, n) => args.split(/\s+/)[Number(n) - 1] ?? ''),
420
+ text: body.replace(/\$ARGUMENTS|\$([1-9])/g, (_, n) => (n === undefined ? 인자 : (조각[Number(n) - 1] ?? ''))),
318
421
  };
319
422
  }
package/src/stats.js CHANGED
@@ -33,6 +33,23 @@ export function 기록자리(root = process.cwd()) {
33
33
  return join(root, '.deel', 'audit.jsonl');
34
34
  }
35
35
 
36
+ /** 하루. 이 PC 와 기록을 적은 PC 의 시계가 조금 어긋나는 것까지는 받는다. */
37
+ const 시계어긋남 = 86400000;
38
+
39
+ /**
40
+ * 감사기록의 시각이 우리가 적은 꼴인가. 맞으면 견줄 수 있게 ISO 글자로, 아니면 null.
41
+ *
42
+ * 글자가 아니면(숫자·없음) 버린다. `Date.parse` 는 `"5"` 도 날짜로 읽으므로 앞머리가
43
+ * `YYYY-MM-DDT` 인지도 본다. **이제보다 하루 넘게 뒤**인 것도 버린다 — 9999년 한 줄이
44
+ * 「마지막으로 쓴 날」 을 영영 차지한다.
45
+ */
46
+ function 성한시각(값, 이제) {
47
+ if (typeof 값 !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(값)) return null;
48
+ const t = Date.parse(값);
49
+ if (!Number.isFinite(t) || t > 이제.getTime() + 시계어긋남) return null;
50
+ return new Date(t).toISOString();
51
+ }
52
+
36
53
  /**
37
54
  * 감사기록을 읽어 센다.
38
55
  *
@@ -71,7 +88,9 @@ export function 세기(자리, { 날수 = 30, 이제 = new Date() } = {}) {
71
88
  대화: 0,
72
89
  도구: 0,
73
90
  도구실패: 0,
74
- 도구별: new Map(), // 이름 → { 수, 실패 }
91
+ // 됐는지 **모르는** 것. 성공에도 실패에도 안 넣는다 (아래 tool 갈래).
92
+ 도구모름: 0,
93
+ 도구별: new Map(), // 이름 → { 수, 실패, 모름 }
75
94
  막힘: 0,
76
95
  막힌까닭: new Map(), // 까닭 → 수
77
96
  되돌림: 0,
@@ -83,33 +102,54 @@ export function 세기(자리, { 날수 = 30, 이제 = new Date() } = {}) {
83
102
  셈.줄 += 1;
84
103
  let r;
85
104
  try { r = JSON.parse(줄); } catch { 셈.깨진줄 += 1; continue; }
86
- if (!r || typeof r !== 'object') { 셈.깨진줄 += 1; continue; }
87
- const at = typeof r.at === 'string' ? r.at : null;
88
- if (자름 && at && at < 자름) { 셈.지난줄 += 1; continue; }
105
+ if (!r || typeof r !== 'object' || Array.isArray(r)) { 셈.깨진줄 += 1; continue; }
106
+ /*
107
+ * ── 우리가 적은 꼴이 아닌 줄은 못 읽은 줄이다 ──────────────────────
108
+ *
109
+ * 시각이 없는 줄은 기간 자르기(`at && at < 자름`)를 그냥 지나 「최근 30일」
110
+ * 안으로 들어왔고, 숫자 시각도 그랬고, 9999년 줄은 「마지막」 을 9999-12-31
111
+ * 로 만들었다. 도구 이름이 객체면 「[object Object]」 라는 도구로 셌고, 이름이
112
+ * 아예 없으면 「?」 라는 도구를 지어내 그 이름으로 셌다.
113
+ * 감사기록(safety/audit.js)은 언제나 ISO 시각 글자와 도구 이름 글자를 적는다.
114
+ * 그 꼴이 아니면 손으로 고쳤거나 다른 것이 섞인 줄이다 — 셈에 넣으면 숫자가
115
+ * 조용히 틀리고, 버리기만 하면 몇 줄을 못 읽었는지가 사라진다. 그래서 센다.
116
+ */
117
+ const at = 성한시각(r.at, 이제);
118
+ if (!at) { 셈.깨진줄 += 1; continue; }
119
+ // 도구 이름은 **있어야** 한다. 없는 줄을 `?` 라는 도구로 세면 쓴 적 없는 도구가 화면에 생긴다.
120
+ if (r.kind === 'tool' && (typeof r.tool !== 'string' || !r.tool)) { 셈.깨진줄 += 1; continue; }
121
+ if (자름 && at < 자름) { 셈.지난줄 += 1; continue; }
89
122
 
90
- if (at) {
91
- if (!셈.처음 || at < 셈.처음) 셈.처음 = at;
92
- if (!셈.마지막 || at > 셈.마지막) 셈.마지막 = at;
93
- 셈.날.add(at.slice(0, 10));
94
- }
95
- if (r.session) 셈.세션.add(String(r.session));
123
+ if (!셈.처음 || at < 셈.처음) 셈.처음 = at;
124
+ if (!셈.마지막 || at > 셈.마지막) 셈.마지막 = at;
125
+ 셈.날.add(at.slice(0, 10));
126
+ if (typeof r.session === 'string' && r.session) 셈.세션.add(r.session);
96
127
 
97
128
  switch (r.kind) {
98
129
  case 'turn': 셈.대화 += 1; break;
99
130
  case 'tool': {
100
131
  셈.도구 += 1;
101
- const 이름 = String(r.tool ?? '?');
102
- const 것 = 셈.도구별.get(이름) ?? { 수: 0, 실패: 0 };
132
+ const 이름 = r.tool; // 위에서 빈 이름을 이미 걸렀다 — 여기서 지어내지 않는다
133
+ const 것 = 셈.도구별.get(이름) ?? { 수: 0, 실패: 0, 모름: 0 };
103
134
  것.수 += 1;
104
- // ok 가 아예 없는 옛 줄은 성공으로 안 친다 — 모르는 것을 좋은 쪽으로
105
- // 세면 실패율이 늘 실제보다 낮게 나온다.
135
+ /*
136
+ * ok 가 없는 줄은 **모른다.** 성공도 실패도 아니다.
137
+ *
138
+ * 바로 위 주석이 「모르는 것을 좋은 쪽으로 세면 실패율이 늘 실제보다
139
+ * 낮게 나온다」 라고 적어 두고, 정작 `ok === false` 만 세어서 ok 없는
140
+ * 줄이 분모에만 들어갔다 — 그게 곧 성공으로 세는 것이다. 감사기록은
141
+ * 언제나 ok 를 적으므로(safety/audit.js), 없는 줄은 옛 기록이거나 손으로
142
+ * 고친 줄이다. 실패로 미는 것도 지어내는 것이라 **따로 세어 말한다.**
143
+ */
106
144
  if (r.ok === false) { 것.실패 += 1; 셈.도구실패 += 1; }
145
+ else if (r.ok !== true) { 것.모름 += 1; 셈.도구모름 += 1; }
107
146
  셈.도구별.set(이름, 것);
108
147
  break;
109
148
  }
110
149
  case 'blocked': {
111
150
  셈.막힘 += 1;
112
- const 왜 = String(r.why ?? '(까닭 없음)');
151
+ // 글자가 아닌 까닭은 「[object Object]」 로 모이지 않게 없는 것으로 친다.
152
+ const 왜 = typeof r.why === 'string' && r.why ? r.why : '(까닭 없음)';
113
153
  셈.막힌까닭.set(왜, (셈.막힌까닭.get(왜) ?? 0) + 1);
114
154
  break;
115
155
  }
@@ -153,7 +193,8 @@ export function 셈JSON(셈) {
153
193
  turns: 셈.대화,
154
194
  tools: 셈.도구,
155
195
  toolFailures: 셈.도구실패,
156
- byTool: 도구차례(셈, 100).map((x) => ({ name: x.이름, calls: x.수, failures: x.실패 })),
196
+ toolUnknown: 셈.도구모름,
197
+ byTool: 도구차례(셈, 100).map((x) => ({ name: x.이름, calls: x.수, failures: x.실패, unknown: x.모름 })),
157
198
  blocked: 셈.막힘,
158
199
  blockedBy: 막힘차례(셈, 100).map((x) => ({ why: x.왜, count: x.수 })),
159
200
  undos: 셈.되돌림,