deel-local-cli 1.15.1 → 1.17.1

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 (51) hide show
  1. package/README.ko.md +118 -17
  2. package/README.md +119 -16
  3. package/bin/deel.js +430 -6
  4. package/package.json +1 -1
  5. package/src/acp/serve.js +39 -1
  6. package/src/agent/agents.js +203 -0
  7. package/src/agent/asks.js +58 -2
  8. package/src/agent/budget.js +33 -0
  9. package/src/agent/effort.js +69 -0
  10. package/src/agent/loop.js +219 -19
  11. package/src/agent/models.js +4 -0
  12. package/src/agent/modes.js +179 -17
  13. package/src/agent/outschema.js +351 -0
  14. package/src/agent/route.js +119 -8
  15. package/src/agent/session.js +13 -2
  16. package/src/backend/adapter.js +94 -7
  17. package/src/backend/clientcert.js +164 -0
  18. package/src/backend/http.js +232 -26
  19. package/src/backend/mcp.js +194 -30
  20. package/src/backend/wire.js +10 -0
  21. package/src/cmdnames.js +3 -0
  22. package/src/commands.js +158 -15
  23. package/src/completion.js +35 -4
  24. package/src/config.js +86 -9
  25. package/src/configexplain.js +177 -0
  26. package/src/doctor.js +195 -0
  27. package/src/i18n/en.js +50 -0
  28. package/src/i18n/ja.js +50 -0
  29. package/src/i18n/ko.js +50 -0
  30. package/src/i18n/zh.js +50 -0
  31. package/src/lsp/client.js +2 -2
  32. package/src/oneshot.js +181 -4
  33. package/src/pack/sbom.js +77 -3
  34. package/src/pack/selfpack.js +1 -1
  35. package/src/pack/sheet.en.js +1 -1
  36. package/src/repl.js +74 -3
  37. package/src/safety/hooks.js +389 -0
  38. package/src/safety/policy.js +74 -0
  39. package/src/safety/shellenv.js +116 -0
  40. package/src/safety/trust.js +230 -0
  41. package/src/setup.js +9 -0
  42. package/src/stats.js +162 -0
  43. package/src/tools/doc2md.js +266 -0
  44. package/src/tools/docs.js +26 -6
  45. package/src/tools/fig.js +393 -0
  46. package/src/tools/hwpxwrite.js +268 -0
  47. package/src/tools/index.js +176 -16
  48. package/src/tools/jobs.js +11 -6
  49. package/src/tools/kiwi.js +293 -0
  50. package/src/tools/spawn.js +19 -1
  51. package/src/tools/task.js +8 -2
package/src/repl.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // 대화 화면. 루프가 보내는 이벤트를 Claude Code 풍으로 그린다.
2
2
  import { createInterface, emitKeypressEvents } from 'node:readline';
3
3
  import { 규칙모으기, 정책읽기 } from './safety/policy.js';
4
+ import { 훅읽기 } from './safety/hooks.js';
5
+ import { 에이전트읽기 } from './agent/agents.js';
4
6
  import { 받기설정 } from './safety/authcmd.js';
5
7
  import { 주소가리기 } from './safety/secrets.js';
6
8
  import { homedir } from 'node:os';
@@ -24,7 +26,9 @@ import { 언어서버있나 } from './tools/index.js';
24
26
  import { 모두끄기 as 언어서버다끄기 } from './lsp/client.js';
25
27
  import { History } from './safety/undo.js';
26
28
  import { Audit, 열쇠묻기 } from './safety/audit.js';
27
- import { activeProfile, load, resolveKey, save as saveCfg, homeDir, 잠금소식, 열쇠탈소식 } from './config.js';
29
+ import { activeProfile, load, resolveKey, save as saveCfg, homeDir, 잠금소식, 열쇠탈소식, 프로젝트설정소식 } from './config.js';
30
+ import { 프로젝트설정줄들 } from './safety/trust.js';
31
+ import { 남길것읽기 } from './safety/shellenv.js';
28
32
  import { discover } from './skills/discover.js';
29
33
  import { allowEndpoint, setOffline, isOffline } from './safety/network.js';
30
34
  import { 지금모드, 바깥인가, 나갈수있나 } from './safety/runmode.js';
@@ -44,6 +48,8 @@ import { 접을까 as 붙임접을까, 표만들기 as 붙임표, 펼치기 as
44
48
  import { 고른것풀기, 계획답풀기 } from './ui/pick.js';
45
49
  import { 이력지킴이 } from './ui/histline.js';
46
50
  import { probeCtx, 기본값 as CTX_DEFAULT } from './backend/ctxsize.js';
51
+ import { 잠잠기본 } from './backend/http.js';
52
+ import { 인증서설정 } from './backend/clientcert.js';
47
53
  import { renderDiff, shortStat } from './ui/diff.js';
48
54
  import { expand as expandMentions } from './agent/mention.js';
49
55
  import { 크기말 } from './backend/vision.js';
@@ -172,6 +178,15 @@ export async function chatLoop(opts = {}) {
172
178
  바로쓰기(` ${mark.ok} ${잠금}`);
173
179
  }
174
180
 
181
+ /*
182
+ * 이 폴더의 프로젝트 설정을 안 읽었거나 일부를 걷어냈으면 그렇다고 한 줄.
183
+ *
184
+ * 조용히 무시하면 적어 둔 사람은 걸린 줄 알고, 실제로는 안 걸린 채로 일이
185
+ * 돈다. 그 어긋남이 설정 전체를 못 믿게 만든다 — 「.deel/config.json 은
186
+ * 가끔 먹는 파일」 이 되는 순간 아무도 안 쓴다.
187
+ */
188
+ for (const 줄 of 프로젝트설정줄들(프로젝트설정소식())) 바로쓰기(줄);
189
+
175
190
  /*
176
191
  * 잠근 열쇠를 못 풀었으면 **그 까닭을** 적는다.
177
192
  *
@@ -226,6 +241,17 @@ export async function chatLoop(opts = {}) {
226
241
  tools: prof.tools ?? false, json: prof.json ?? false, think: prof.think ?? false,
227
242
  // 그림을 볼 수 있는 모델인지. 못 보면 바이트를 아예 안 싣는다 (backend/vision.js).
228
243
  vision: prof.vision ?? false,
244
+ /*
245
+ * 흘려 받다가 이만큼 잠잠하면 끊는다 (밀리초, backend/http.js).
246
+ *
247
+ * 답이 다 오는 데 걸린 시간이 아니라 **아무것도 안 온 시간**이다. 그래서
248
+ * 30분짜리 답은 안 끊기고 30초 멎은 연결은 30초에 끊긴다. 답을 통째로
249
+ * 모았다가 한 번에 주는 사내 게이트웨이면 이 값을 올린다.
250
+ */
251
+ 잠잠: prof.잠잠 ?? prof.streamIdleMs ?? 잠잠기본,
252
+ // 게이트웨이가 우리 인증서를 요구하면 (mTLS, backend/clientcert.js).
253
+ // 파일 경로만 싣는다 — 알맹이는 요청 직전에 읽는다.
254
+ 인증서: 인증서설정(prof),
229
255
  };
230
256
 
231
257
  // conn 을 짓느라 resolveKey 가 방금 불렸다. 못 푼 것이 있으면 여기서 말한다.
@@ -1123,6 +1149,17 @@ export async function chatLoop(opts = {}) {
1123
1149
  for (const x of 빠진) say(` ${c.gray(`${x.번호}. ${clip(x.글, 68)}`)}`);
1124
1150
  };
1125
1151
 
1152
+ /*
1153
+ * 사람이 적어 둔 훅을 읽는다 (safety/hooks.js).
1154
+ *
1155
+ * 여기서 **한 번만** 읽는다. 도구를 부를 때마다 읽으면 대화 도중에 훅
1156
+ * 파일이 바뀌는 것이 곧 「방금 통과한 것이 다음 걸음에 막힌다」 가 되고,
1157
+ * 그건 원인을 찾을 길이 없는 화면이다. 설정을 켤 때 한 번 읽는 것과 같다.
1158
+ */
1159
+ const 훅정보 = 훅읽기(root, { 켜짐: opts.hooks === false ? false : null });
1160
+ // 이름 붙인 하위 작업 (agent/agents.js). 스킬처럼 켜질 때 한 번 찾는다.
1161
+ const 에이전트정보 = 에이전트읽기(root);
1162
+
1126
1163
  const ctx = {
1127
1164
  scope: makeScope(root),
1128
1165
  // 도구가 한 번에 돌려줄 양을 이 값에서 뽑는다 (agent/budget.js).
@@ -1132,6 +1169,14 @@ export async function chatLoop(opts = {}) {
1132
1169
  get 눈있나() { return !!conn.vision; },
1133
1170
  // 적어 둔 허락·금지 규칙 (safety/policy.js). 승인 모드보다 먼저 본다.
1134
1171
  규칙들: 규칙모으기(cfg),
1172
+ // 사람이 적어 둔 훅 (safety/hooks.js). 프로젝트 파일은 믿는 폴더에서만 읽는다.
1173
+ 훅들: 훅정보.훅들,
1174
+ // 이름 붙인 하위 작업. 목록은 Task 스키마에, 지침은 고른 뒤에만 실린다.
1175
+ 에이전트들: 에이전트정보.에이전트들,
1176
+ // Bash 자식에게 되살려 줄 환경변수 이름 (safety/shellenv.js).
1177
+ // 기본은 열쇠처럼 생긴 이름을 다 빼는 것이고, 여기 적은 것만 되살린다 —
1178
+ // 사내 저장소를 쓰는 사람은 npm ci 에 NPM_TOKEN 이 실제로 필요하다.
1179
+ 셸남길것: 남길것읽기(cfg),
1135
1180
  history: new History(root),
1136
1181
  audit: new Audit(root, { 열쇠들: 열쇠묻기(conn) }),
1137
1182
  seen: new Set(),
@@ -2212,8 +2257,15 @@ export async function chatLoop(opts = {}) {
2212
2257
  case 'cutoff':
2213
2258
  clearThinking();
2214
2259
  if (streamed) { 답비우기(); say(''); streamed = false; }
2215
- say(` ${mark.warn} ${c.gray('서버가 끝났다는 말 없이 답을 멈췄습니다 — 중간에서 끊겼을 수 있습니다.')}`);
2216
- say(` ${c.gray('중계 프록시·게이트웨이를 거치면 나는 일입니다. 같은 것을 다시 물어 보세요.')}`);
2260
+ if (ev.멎은초) {
2261
+ // 흐름이 멎어서 우리가 끊은 자리. 손댈 곳이 다르므로 다른 말을 한다 —
2262
+ // 「다시 물어 보세요」 는 여기서 틀린 조언이다. 같은 게이트웨이면 또 멎는다.
2263
+ say(` ${mark.warn} ${c.gray(`${ev.멎은초}초 동안 아무것도 안 와서 끊었습니다 — 받은 데까지만 보여 드립니다.`)}`);
2264
+ say(` ${c.gray('답을 통째로 모았다가 한 번에 주는 게이트웨이면 이렇게 됩니다. 설정의 잠잠 을 올려 보세요.')}`);
2265
+ } else {
2266
+ say(` ${mark.warn} ${c.gray('서버가 끝났다는 말 없이 답을 멈췄습니다 — 중간에서 끊겼을 수 있습니다.')}`);
2267
+ say(` ${c.gray('중계 프록시·게이트웨이를 거치면 나는 일입니다. 같은 것을 다시 물어 보세요.')}`);
2268
+ }
2217
2269
  break;
2218
2270
 
2219
2271
  case 'compact_failed':
@@ -2300,6 +2352,25 @@ export async function chatLoop(opts = {}) {
2300
2352
  * 조용히 밀면 모델이 말을 들은 것인지 우연히 다시 생각한 것인지
2301
2353
  * 구분이 안 된다. 한 줄 적어 둠다 — 또 이러면 사람이 알아볼 수 있어야 한다.
2302
2354
  */
2355
+ /*
2356
+ * 훅이 무슨 말을 했다 (safety/hooks.js).
2357
+ *
2358
+ * 조용히 삼키면 안 된다. 훅은 사람이 제 손으로 건 것이고, 그게
2359
+ * 도는지 안 도는지 볼 길이 없으면 아무도 못 믿는다.
2360
+ */
2361
+ case 'hook_note':
2362
+ clearThinking();
2363
+ for (const 줄 of String(ev.말).split('\n').slice(0, 8)) {
2364
+ say(` ${c.gray(`· ${clip(줄, 80)}`)}`);
2365
+ }
2366
+ break;
2367
+
2368
+ case 'hook_block':
2369
+ clearThinking();
2370
+ say('');
2371
+ say(` ${c.yellow('✗')} ${c.white(옮긴말('ev.hookBlock'))} ${c.gray(`(${ev.훅?.이름 ?? ev.훅?.명령 ?? ''})`)}`);
2372
+ break;
2373
+
2303
2374
  case 'nudge':
2304
2375
  clearThinking();
2305
2376
  if (streamed) { 답비우기(); say(''); streamed = false; }
@@ -0,0 +1,389 @@
1
+ /**
2
+ * 훅 — 사람이 적어 둔 명령을 **정해진 자리**에서 돌린다.
3
+ *
4
+ * ── 왜 필요한가 ─────────────────────────────────────────────────────────
5
+ *
6
+ * 사내마다 지켜야 하는 것이 다르다. 어떤 팀은 `git push` 를 절대 못 하게
7
+ * 해야 하고, 어떤 팀은 파일을 고칠 때마다 사내 포맷터를 돌려야 하고, 어떤
8
+ * 팀은 사람이 친 말에 주민번호가 섞였는지를 먼저 봐야 한다.
9
+ *
10
+ * 이걸 전부 이 프로그램 안에 넣을 수는 없다. 넣으면 팀마다 포크를 뜨게 되고,
11
+ * 포크를 뜨면 그 순간부터 우리가 고치는 것이 그 팀에 안 간다. 그래서 **자리만
12
+ * 내준다.** 무엇을 할지는 그 팀이 제 파일에 적는다.
13
+ *
14
+ * 규칙(safety/policy.js)과는 다른 축이다. 규칙은 「이 무늬면 막는다」 는 표라서
15
+ * 우리가 아는 것만 잰다. 훅은 아무 프로그램이나 부를 수 있어서, 사내 DLP 나
16
+ * 사내 승인 서버처럼 **우리가 영영 모르는 것**을 물어볼 수 있다.
17
+ *
18
+ * ── 이건 남의 프로그램을 돌리는 일이다 ─────────────────────────────────
19
+ *
20
+ * MCP 와 같은 무게로 다룬다. 다만 가르는 점이 하나 있다 — MCP 서버는 밖에서
21
+ * 받아 온 남의 프로그램이고, 훅은 **이 사람이 제 파일에 제 손으로 적은 명령**
22
+ * 이다. Bash 도구로 치는 것과 같은 무게다. 그래서 자물쇠(--offline)가 걸려도
23
+ * 훅은 돈다 — Bash 를 안 막으면서 훅만 막으면 말이 안 맞는다.
24
+ *
25
+ * 1) **기본은 꺼져 있다.** hooks.json 에 사람이 직접 적어야만 돈다.
26
+ * 2) **프로젝트 파일은 믿는 폴더에서만 읽는다.** 남의 저장소를 clone 하는
27
+ * 것만으로 명령이 돌면 안 된다 (safety/trust.js).
28
+ * 3) **감사기록에 남긴다.** 무엇이 언제 돌았고 무엇을 막았는지.
29
+ * 4) **DEEL_HOOKS=off 로 끈다.** 훅이 망가지면 프로그램 전체가 멈추는데,
30
+ * 그때 고칠 길이 훅 파일을 지우는 것뿐이면 안 된다.
31
+ *
32
+ * ── 고장 나면 막는다 ───────────────────────────────────────────────────
33
+ *
34
+ * 문지기가 쓰러져 있으면 문은 **잠긴 것이 아니다.** 그런데 흔한 규격은
35
+ * 「2번으로 끝나면 막고, 나머지 실패는 지나간다」 다. 그러면 훅 파일에 오타
36
+ * 하나(`pythno check.py`)가 나는 순간 그 문은 조용히 열린 채로 남는다 —
37
+ * 화면에는 여전히 「훅 3개」 라고 떠 있는 채로.
38
+ *
39
+ * 그래서 **막는 자리의 훅**(도구전·말전)은 고장 나면 막는다. 못 잰 것을
40
+ * 초록으로 세지 않는 것과 같은 판단이다. 지나가게 하려면 그 훅에
41
+ * `"고장나면": "지나가기"` 라고 **적어야** 한다 — 적힌 것만 지나간다.
42
+ */
43
+ import { existsSync, readFileSync } from 'node:fs';
44
+ import { homedir } from 'node:os';
45
+ import { join, resolve } from 'node:path';
46
+ import { 돌려보기 } from '../tools/spawn.js';
47
+ import { 셸고르기 } from '../tools/shell.js';
48
+ import { 믿나 } from './trust.js';
49
+
50
+ /** 훅이 이 시간 안에 안 끝나면 죽인다. 사람이 훅마다 따로 정할 수 있다. */
51
+ export const 제한기본 = 15000;
52
+ /** 한 훅이 뱉을 수 있는 글의 최대. 넘으면 자르고 잘랐다고 말한다. */
53
+ export const 글최대 = 16 * 1024;
54
+ /** 한 자리에서 돌릴 수 있는 훅 수. 이보다 많으면 설정이 잘못된 것이다. */
55
+ export const 훅최대 = 32;
56
+
57
+ /**
58
+ * 훅을 걸 수 있는 자리.
59
+ *
60
+ * 영문 이름도 같이 받는다 — Claude Code 의 hooks 설정을 그대로 복사해 붙일 수
61
+ * 있어야 한다. MCP 설정에서 `mcpServers` 를 그대로 받는 것과 같은 이유다.
62
+ * 이미 그 파일을 갖고 있는 사람에게 「우리 말로 다시 적어라」 고 하면, 그
63
+ * 사람은 훅을 안 쓴다.
64
+ */
65
+ export const 자리표 = Object.freeze({
66
+ PreToolUse: '도구전',
67
+ PostToolUse: '도구후',
68
+ UserPromptSubmit: '말전',
69
+ Stop: '턴끝',
70
+ });
71
+ export const 자리들 = Object.freeze(['도구전', '도구후', '말전', '턴끝']);
72
+ /** 막을 수 있는 자리. 나머지는 무슨 소리를 해도 못 막는다. */
73
+ export const 막는자리 = Object.freeze(['도구전', '말전']);
74
+
75
+ export const 프로젝트자리 = (root) => join(root, '.deel', 'hooks.json');
76
+ /**
77
+ * 이 PC 것이 놓이는 자리.
78
+ *
79
+ * `DEEL_HOME` 을 먼저 본다 — 설정도 믿는 목록도 다 그 폴더를 따라가는데
80
+ * (config.js · safety/trust.js) 훅만 진짜 집 폴더를 보면, USB 로 들고 다니는
81
+ * 설치에서 훅만 안 따라온다. 그리고 그건 아무 데도 안 찍힌다.
82
+ */
83
+ export const 이PC자리 = (집 = homedir(), env = process.env) => (
84
+ env.DEEL_HOME ? join(resolve(env.DEEL_HOME), 'hooks.json') : join(집, '.deel', 'hooks.json')
85
+ );
86
+
87
+ /** 사람이 `pre-tool-use` 처럼 적어도 알아듣는다. */
88
+ function 자리풀기(값) {
89
+ const s = String(값 ?? '').trim();
90
+ if (자리들.includes(s)) return s;
91
+ const 납작 = s.replace(/[-_\s]/g, '').toLowerCase();
92
+ for (const [영, 한] of Object.entries(자리표)) if (영.toLowerCase() === 납작) return 한;
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * 도구 이름 무늬를 짓는다 — **양끝을 묶는다.**
98
+ *
99
+ * 여기가 묶여 있지 않으면 `Read` 라고 적은 무늬가 `TodoWrite` 에도 걸린다.
100
+ * 막는 쪽이면 안 막을 것을 막고, 고치는 쪽이면 엉뚱한 것을 고친다. 둘 다
101
+ * 「왜 이러지」 로 며칠을 쓰게 되는 종류의 어긋남이다.
102
+ *
103
+ * 무늬가 잘못 적혀 있으면 **null 을 돌려준다.** 아무것도 안 걸리는 무늬로
104
+ * 삼키면 그 훅은 있으나 마나가 되는데, 화면에는 여전히 세어져 있다.
105
+ */
106
+ export function 무늬짓기(값) {
107
+ const s = String(값 ?? '').trim();
108
+ if (!s || s === '*') return { 다냐: true, re: null };
109
+ try { return { 다냐: false, re: new RegExp(`^(?:${s})$`) }; } catch { return null; }
110
+ }
111
+
112
+ /** 이 훅이 이 도구에 걸리나. */
113
+ export function 걸리나(훅, 도구) {
114
+ if (!훅.무늬 || 훅.무늬.다냐) return true;
115
+ return 훅.무늬.re.test(String(도구 ?? ''));
116
+ }
117
+
118
+ /**
119
+ * 훅 하나를 우리 모양으로 편다.
120
+ *
121
+ * @returns {object|null} 못 알아들으면 null (부르는 쪽이 왜인지 적는다)
122
+ */
123
+ function 한훅(자리, 무늬글, 것, 출처) {
124
+ const 명령 = String(것?.명령 ?? 것?.command ?? '').trim();
125
+ if (!명령) return null;
126
+ if (것?.type && 것.type !== 'command') return null; // 다른 갈래는 아직 없다
127
+ const 무늬 = 무늬짓기(무늬글);
128
+ if (!무늬) return null;
129
+ const 초 = Number(것?.제한초 ?? 것?.timeout ?? 0);
130
+ const 고장나면 = String(것?.고장나면 ?? 것?.onError ?? '').trim();
131
+ return {
132
+ 자리,
133
+ 무늬글: String(무늬글 ?? '*'),
134
+ 무늬,
135
+ 명령,
136
+ 제한: Number.isFinite(초) && 초 > 0 ? Math.min(초 * 1000, 120000) : 제한기본,
137
+ // 적힌 것만 지나간다. 이 파일 머리말의 「고장 나면 막는다」 가 여기다.
138
+ 지나갈까: 고장나면 === '지나가기' || 고장나면 === 'pass',
139
+ 출처,
140
+ 이름: String(것?.이름 ?? 것?.name ?? '').trim() || null,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * 한 파일에서 훅을 읽는다. 두 가지 모양을 다 받는다.
146
+ *
147
+ * 납작한 것 { "hooks": [ { "때": "도구전", "도구": "Bash", "명령": "…" } ] }
148
+ * Claude 것 { "hooks": { "PreToolUse": [ { "matcher": "Bash",
149
+ * "hooks": [ { "type": "command", "command": "…" } ] } ] } }
150
+ */
151
+ export function 훅펴기(raw, 출처) {
152
+ const 훅들 = [];
153
+ const 버린것 = [];
154
+ const 것 = raw?.hooks ?? raw?.훅 ?? raw;
155
+
156
+ if (Array.isArray(것)) {
157
+ for (const x of 것) {
158
+ const 자리 = 자리풀기(x?.때 ?? x?.자리 ?? x?.event);
159
+ if (!자리) { 버린것.push(`모르는 자리: ${x?.때 ?? x?.event ?? '(없음)'}`); continue; }
160
+ const h = 한훅(자리, x?.도구 ?? x?.matcher ?? '*', x, 출처);
161
+ if (h) 훅들.push(h); else 버린것.push(`${자리}: 명령이나 무늬가 잘못 적혀 있습니다`);
162
+ }
163
+ return { 훅들, 버린것 };
164
+ }
165
+
166
+ if (것 && typeof 것 === 'object') {
167
+ for (const [열쇠, 값] of Object.entries(것)) {
168
+ const 자리 = 자리풀기(열쇠);
169
+ if (!자리) { 버린것.push(`모르는 자리: ${열쇠}`); continue; }
170
+ for (const 묶음 of Array.isArray(값) ? 값 : [값]) {
171
+ const 무늬글 = 묶음?.matcher ?? 묶음?.도구 ?? '*';
172
+ const 안것 = Array.isArray(묶음?.hooks) ? 묶음.hooks : [묶음];
173
+ for (const x of 안것) {
174
+ const h = 한훅(자리, 무늬글, x, 출처);
175
+ if (h) 훅들.push(h); else 버린것.push(`${자리}: 명령이나 무늬가 잘못 적혀 있습니다`);
176
+ }
177
+ }
178
+ }
179
+ }
180
+ return { 훅들, 버린것 };
181
+ }
182
+
183
+ function 파일하나(경로, 출처) {
184
+ if (!existsSync(경로)) return { 훅들: [], 버린것: [], 있음: false, 자리: 경로 };
185
+ let j;
186
+ try { j = JSON.parse(readFileSync(경로, 'utf8')); } catch (e) {
187
+ return { 훅들: [], 버린것: [], 있음: true, 자리: 경로, 오류: `못 읽었습니다: ${e.message}` };
188
+ }
189
+ return { ...훅펴기(j, 출처), 있음: true, 자리: 경로 };
190
+ }
191
+
192
+ /**
193
+ * 이 자리에서 쓸 훅을 다 모은다.
194
+ *
195
+ * 이 PC 것이 먼저 돌고 프로젝트 것이 나중에 돈다. 순서가 값을 갖는 자리는
196
+ * 하나뿐이다 — 막는 훅은 **먼저 막는 것이 이긴다.** 이 PC 에 적은 것이
197
+ * 프로젝트 파일에 덮이면 안 되기 때문이다.
198
+ *
199
+ * @returns {{훅들, 켜짐, 왜꺼짐, 이PC, 프로젝트, 안믿음}}
200
+ */
201
+ export function 훅읽기(root, { env = process.env, 집 = homedir(), 켜짐 = null } = {}) {
202
+ const 끔 = String(env.DEEL_HOOKS ?? '').trim().toLowerCase();
203
+ const 꺼짐 = 켜짐 === false || 끔 === 'off' || 끔 === '0' || 끔 === 'false';
204
+
205
+ const 이PC = 파일하나(이PC자리(집, env), '이 PC');
206
+ /*
207
+ * 프로젝트 파일은 **믿는 폴더에서만** 읽는다.
208
+ *
209
+ * 이게 이 파일에서 제일 중요한 한 줄이다. 없으면 `git clone` 한 번이 곧
210
+ * 남이 적어 둔 명령을 내 PC 에서 도는 것이 된다 — 사람이 아무것도 안 쳐도
211
+ * 첫 도구 호출에서 돈다. 프로젝트 설정을 믿는 폴더에서만 읽는 것과 같은
212
+ * 규칙이고(safety/trust.js), 여기가 그 규칙이 제일 필요한 자리다.
213
+ */
214
+ const 믿는가 = 믿나(root, { env });
215
+ const 프로젝트 = 믿는가
216
+ ? 파일하나(프로젝트자리(root), '프로젝트')
217
+ : { 훅들: [], 버린것: [], 있음: existsSync(프로젝트자리(root)), 자리: 프로젝트자리(root) };
218
+
219
+ const 다 = [...이PC.훅들, ...프로젝트.훅들].slice(0, 훅최대);
220
+ return {
221
+ 훅들: 꺼짐 ? [] : 다,
222
+ 켜짐: !꺼짐,
223
+ 왜꺼짐: 꺼짐 ? (켜짐 === false ? '--no-hooks' : 'DEEL_HOOKS=off') : null,
224
+ 이PC,
225
+ 프로젝트,
226
+ // 파일은 있는데 폴더를 안 믿어서 안 읽은 경우. 화면이 이걸 말해야 한다 —
227
+ // 안 그러면 사람은 제가 적은 훅이 왜 안 도는지 영영 모른다.
228
+ 안믿음: !믿는가 && 프로젝트.있음,
229
+ 넘침: [...이PC.훅들, ...프로젝트.훅들].length > 훅최대,
230
+ };
231
+ }
232
+
233
+ /** 이 자리에 걸린 훅만 고른다. */
234
+ export function 고를것(훅들, 자리, 도구 = null) {
235
+ return (훅들 ?? []).filter((h) => h.자리 === 자리 && (자리 !== '도구전' && 자리 !== '도구후' ? true : 걸리나(h, 도구)));
236
+ }
237
+
238
+ /*
239
+ * ── 셸을 거친다. 여기서는 그게 맞다 ────────────────────────────────────
240
+ *
241
+ * 문서 변환(tools/convert.js)은 셸을 안 거친다. 거기 들어가는 파일 이름은
242
+ * 모델이 정하기 때문이다 — 따옴표 한 개가 명령이 된다.
243
+ *
244
+ * 여기는 반대다. 명령은 **사람이 제 파일에 적은 글**이고, 모델이 만든 것은
245
+ * 한 글자도 안 섞인다(아래 넣을것 은 전부 stdin 으로 간다). 그리고 사람이
246
+ * 훅에 적고 싶은 것은 대개 `npx prettier --write "$@" && git diff --exit-code`
247
+ * 같은 것이다. 셸을 안 거치면 그 줄을 적을 길이 없어서, 아무도 훅을 안 쓴다.
248
+ *
249
+ * 그래서 Bash 도구와 **같은 셸**을 쓴다(tools/shell.js). 같은 자리에서 같은
250
+ * 말이 통해야 사람이 훅을 짤 수 있다.
251
+ */
252
+ function 셸로(명령) {
253
+ const 셸 = 셸고르기();
254
+ return { file: 셸.file, args: 셸.명령(명령), verbatim: !!셸.verbatim };
255
+ }
256
+
257
+ /**
258
+ * 훅 하나를 돌린다.
259
+ *
260
+ * 넘길 것은 **전부 stdin 으로 간다.** 명령줄에 끼우지 않는다 — 모델이 만든
261
+ * 글(파일 경로·명령·사람 말)이 명령줄에 들어가면 그 자리가 곧 주입 구멍이다.
262
+ *
263
+ * @returns {Promise<{훅, 코드, 말, 막나, 왜, ms, 잘림}>}
264
+ */
265
+ export async function 훅돌리기(훅, 넣을것, { signal = null, 돌리개 = 돌려보기 } = {}) {
266
+ const t0 = Date.now();
267
+ const { file, args, verbatim } = 셸로(훅.명령);
268
+ const r = await 돌리개(file, args, {
269
+ timeout: 훅.제한,
270
+ maxBuffer: 글최대,
271
+ signal,
272
+ /*
273
+ * 자리는 **여기서** 붙인다.
274
+ *
275
+ * 부르는 쪽에 맡기면 길이 둘이라(자리돌리기·직접 부르기) 한쪽만 붙이는
276
+ * 날이 온다. 그러면 자리마다 갈래를 두는 훅 하나가 조용히 엉뚱한 갈래로
277
+ * 떨어지는데, 그건 훅을 짠 사람도 우리도 못 알아차린다.
278
+ */
279
+ 넣을것: `${JSON.stringify({ 자리: 훅.자리, ...(넣을것 ?? {}) })}\n`,
280
+ 덤: { windowsVerbatimArguments: verbatim, windowsHide: true },
281
+ });
282
+ const ms = Date.now() - t0;
283
+
284
+ const 날것 = `${r.stdout ?? ''}${r.stderr ?? ''}`.trim();
285
+ const 잘림 = 날것.length > 글최대;
286
+ const 말 = 잘림 ? `${날것.slice(0, 글최대)}\n…(훅이 뱉은 글이 길어서 여기까지만 옮겼습니다)` : 날것;
287
+
288
+ // 못 돌렸다 · 시한을 넘겼다 · 중단됐다. 셋 다 「잰 적이 없다」 는 뜻이다.
289
+ if (r.error) {
290
+ return {
291
+ 훅, 코드: null, 말, ms, 잘림,
292
+ 막나: 막는자리.includes(훅.자리) && !훅.지나갈까,
293
+ 왜: `훅을 못 돌렸습니다 — ${r.error.message}`,
294
+ };
295
+ }
296
+ if (r.status === 0) return { 훅, 코드: 0, 말, ms, 잘림, 막나: false, 왜: null };
297
+ /*
298
+ * 2 는 「막으라」 는 뜻이다 (흔한 규격 그대로).
299
+ *
300
+ * 나머지 0 아닌 값은 「훅이 고장났다」 로 본다. 막는 자리에서는 그것도
301
+ * 막는다 — 이 파일 머리말의 이유다. `"고장나면": "지나가기"` 를 적어 두면
302
+ * 그때만 지나간다.
303
+ */
304
+ if (r.status === 2) return { 훅, 코드: 2, 말, ms, 잘림, 막나: 막는자리.includes(훅.자리), 왜: null };
305
+ return {
306
+ 훅, 코드: r.status, 말, ms, 잘림,
307
+ 막나: 막는자리.includes(훅.자리) && !훅.지나갈까,
308
+ 왜: `훅이 ${r.status} 로 끝났습니다`,
309
+ };
310
+ }
311
+
312
+ /**
313
+ * 한 자리의 훅을 차례로 돌린다.
314
+ *
315
+ * **차례로** 도는 것이 핵심이다. 같이 돌리면 두 훅이 같은 파일을 고칠 때
316
+ * 결과가 매번 달라진다 — 포맷터를 훅으로 거는 것이 제일 흔한 쓰임인데
317
+ * 하필 그게 그 모양이다.
318
+ *
319
+ * 막는 자리에서는 **첫 막힘에서 멈춘다.** 이미 막힌 것에 남은 훅을 더
320
+ * 돌리는 것은 시간만 쓰는 일이고, 사람에게 보여 줄 까닭도 첫 것이 맞다.
321
+ *
322
+ * @returns {Promise<{막힘: object|null, 결과들: object[], 말들: string[]}>}
323
+ */
324
+ export async function 자리돌리기(훅들, 자리, {
325
+ 도구 = null, 넣을것 = {}, signal = null, audit = null, 돌리개 = 돌려보기,
326
+ } = {}) {
327
+ const 것들 = 고를것(훅들, 자리, 도구);
328
+ const 결과들 = [];
329
+ let 막힘 = null;
330
+ for (const h of 것들) {
331
+ if (signal?.aborted) break;
332
+ const r = await 훅돌리기(h, { 도구, ...넣을것 }, { signal, 돌리개 });
333
+ 결과들.push(r);
334
+ /*
335
+ * 감사기록에 남긴다 — **돈 것 전부.**
336
+ *
337
+ * 막은 것만 남기면 「훅이 안 돌았다」 와 「훅이 돌았는데 통과시켰다」 를
338
+ * 나중에 구별할 수 없다. 사내 심사에서 물어보는 것이 정확히 그 둘의
339
+ * 차이다.
340
+ */
341
+ audit?.write?.('hook', {
342
+ 자리, 도구, 명령: h.명령, 출처: h.출처, 코드: r.코드, ms: r.ms, 막음: !!r.막나,
343
+ });
344
+ if (r.막나) { 막힘 = r; break; }
345
+ }
346
+ return { 막힘, 결과들, 말들: 결과들.map((r) => r.말).filter(Boolean) };
347
+ }
348
+
349
+ /**
350
+ * 막혔을 때 모델에게 할 말.
351
+ *
352
+ * 훅이 뱉은 글을 **그대로** 싣는다. 우리가 요약하면 사내 규칙의 문구가
353
+ * 뭉개지는데, 사람이 그 문구를 보고 담당자를 찾아가야 한다. 대신 우리가
354
+ * 무엇을 했는지는 우리가 적는다.
355
+ */
356
+ export function 막힘말(r, { 보인출처 = (h) => h.출처 } = {}) {
357
+ const 줄 = [`${r.훅.자리} 훅이 막았습니다 (${보인출처(r.훅)}: ${r.훅.이름 ?? r.훅.명령}).`];
358
+ if (r.왜) 줄.push(` ${r.왜} — 문지기가 쓰러져 있으면 통과시키지 않습니다.`);
359
+ if (r.말) { 줄.push(''); 줄.push(r.말); }
360
+ 줄.push('');
361
+ 줄.push('같은 것을 다시 부르지 마세요. 다른 길을 찾거나, 왜 필요한지 사용자에게 말하세요.');
362
+ return 줄.join('\n');
363
+ }
364
+
365
+ /** 화면 한 장 (doctor · /status). 명령은 적되 값은 안 만든다. */
366
+ export function 훅줄들(r) {
367
+ const 줄 = [];
368
+ if (!r.켜짐) { 줄.push({ 상태: 'warn', 이름: '훅', 값: `꺼져 있습니다 (${r.왜꺼짐})` }); return 줄; }
369
+ if (r.이PC.오류) 줄.push({ 상태: 'no', 이름: '훅 · 이 PC', 값: r.이PC.자리, 덧말: r.이PC.오류 });
370
+ if (r.프로젝트.오류) 줄.push({ 상태: 'no', 이름: '훅 · 프로젝트', 값: r.프로젝트.자리, 덧말: r.프로젝트.오류 });
371
+ if (r.안믿음) {
372
+ 줄.push({
373
+ 상태: 'warn', 이름: '훅 · 프로젝트', 값: r.프로젝트.자리,
374
+ 덧말: '믿는 폴더가 아니라서 안 읽습니다 — 읽게 하려면 deel trust',
375
+ });
376
+ }
377
+ for (const 것 of [r.이PC, r.프로젝트]) {
378
+ for (const 왜 of 것.버린것 ?? []) 줄.push({ 상태: 'warn', 이름: '훅 · 못 알아들음', 값: 왜 });
379
+ }
380
+ if (r.넘침) 줄.push({ 상태: 'warn', 이름: '훅', 값: `${훅최대}개까지만 씁니다 — 나머지는 안 돕니다` });
381
+ if (!r.훅들.length) { 줄.push({ 상태: 'ok', 이름: '훅', 값: '없습니다' }); return 줄; }
382
+ const 셈 = {};
383
+ for (const h of r.훅들) 셈[h.자리] = (셈[h.자리] ?? 0) + 1;
384
+ 줄.push({
385
+ 상태: 'ok', 이름: '훅', 값: `${r.훅들.length}개`,
386
+ 덧말: 자리들.filter((z) => 셈[z]).map((z) => `${z} ${셈[z]}`).join(' · '),
387
+ });
388
+ return 줄;
389
+ }
@@ -207,3 +207,77 @@ export function 규칙말(규칙들) {
207
207
  if (규칙들.탈) 조각.push(규칙들.탈);
208
208
  return 조각.join(' · ');
209
209
  }
210
+
211
+ /*
212
+ * ── 규칙이 진짜 그렇게 도나 ─────────────────────────────────────────────
213
+ *
214
+ * 규칙은 적어 두면 조용히 돈다. 그래서 **잘못 적은 규칙은 티가 안 난다.**
215
+ *
216
+ * "deny": ["Bash(rm -rf*)"]
217
+ *
218
+ * 이건 `rm -rf /` 를 막는다. 그런데 `sudo rm -rf /` 는 안 막는다 — 무늬가
219
+ * 앞부터 맞아야 하기 때문이다. 적은 사람은 막힌 줄 알고 지낸다. 안 막혔다는
220
+ * 것은 진짜로 지워진 날에야 안다.
221
+ *
222
+ * 그래서 두 가지를 낸다.
223
+ *
224
+ * 1) `deel rules check "sudo rm -rf /"` — 이 명령을 어느 규칙이 어떻게
225
+ * 정하는지 그 자리에서 말한다. 규칙을 적자마자 확인할 수 있어야 한다.
226
+ *
227
+ * 2) 설정에 보기를 적어 두면 그것을 돌린다. CI 에 걸 수 있는 모양이다.
228
+ *
229
+ * "permissions": {
230
+ * "deny": ["Bash(*rm -rf*)"],
231
+ * "확인": [
232
+ * { "도구": "Bash", "값": "sudo rm -rf /tmp", "이래야": "deny" },
233
+ * { "도구": "Bash", "값": "npm run rf", "이래야": "모름" }
234
+ * ]
235
+ * }
236
+ *
237
+ * 2번이 있어야 규칙을 **고칠 때** 안전하다. 무늬 하나를 다듬다가 다른 것이
238
+ * 같이 풀리는 일이 실제로 흔한데, 보기를 적어 두면 그 자리에서 빨개진다.
239
+ */
240
+
241
+ /** 설정에 적어 둔 확인 보기들. 없으면 빈 배열. */
242
+ export function 확인목록(cfg) {
243
+ const 것 = cfg?.permissions?.확인 ?? cfg?.permissions?.checks;
244
+ if (!Array.isArray(것)) return [];
245
+ const out = [];
246
+ for (const x of 것) {
247
+ if (!x || typeof x !== 'object') continue;
248
+ const 도구 = String(x.도구 ?? x.tool ?? 'Bash');
249
+ const 값 = x.값 ?? x.value;
250
+ const 이래야 = String(x.이래야 ?? x.expect ?? '').trim();
251
+ if (typeof 값 !== 'string' || !값) continue;
252
+ if (!['allow', 'deny', '모름'].includes(이래야)) continue;
253
+ out.push({ 도구, 값, 이래야 });
254
+ }
255
+ return out;
256
+ }
257
+
258
+ /**
259
+ * 도구 이름에 맞는 인자 모양으로 값을 싼다.
260
+ *
261
+ * 걸리나() 는 도구마다 다른 칸을 본다 — Bash 는 command, 파일 도구는
262
+ * file_path. 확인 보기에서는 사람이 값 하나만 적으므로, 여기서 그 도구가
263
+ * 보는 칸에 넣어 준다. 안 그러면 보기가 늘 「안 걸림」 으로 나와서, 검사가
264
+ * 아무것도 안 재면서 초록으로 남는다.
265
+ */
266
+ export function 확인인자(도구, 값) {
267
+ if (도구 === 'Bash') return { command: 값 };
268
+ if (도구 === 'WebFetch') return { url: 값 };
269
+ if (도구 === 'Grep') return { pattern: 값 };
270
+ return { file_path: 값 };
271
+ }
272
+
273
+ /**
274
+ * 확인 보기를 다 돌린다.
275
+ *
276
+ * @returns {Array<{도구,값,이래야,나온것,맞나,규칙,출처}>}
277
+ */
278
+ export function 확인돌리기(규칙들, 보기들) {
279
+ return (보기들 ?? []).map((b) => {
280
+ const r = 어떻게할까(규칙들, b.도구, 확인인자(b.도구, b.값));
281
+ return { ...b, 나온것: r.답, 맞나: r.답 === b.이래야, 규칙: r.규칙, 출처: r.출처 };
282
+ });
283
+ }