deel-local-cli 1.16.0 → 1.17.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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * 이름 붙인 하위 작업 — `.deel/agents/*.json`.
3
+ *
4
+ * ── 무엇이 달라지나 ─────────────────────────────────────────────────────
5
+ *
6
+ * `Task` 는 이미 있다. 다만 **매번 처음부터 적어야** 한다 — 무슨 모드로, 어느
7
+ * 모델에게, 무엇을 조심하며, 무엇이 끝나면 다 된 것인지. 팀에서 늘 같은 일을
8
+ * 시키는데도 그렇다.
9
+ *
10
+ * 그래서 그 묶음에 이름을 붙여 파일 하나로 둔다. 그다음부터 부모 모델이 할 일은
11
+ * 이것뿐이다 —
12
+ *
13
+ * Task({ agent: "리뷰어", purpose: "…", task: "…" })
14
+ *
15
+ * 모드·모델·도구·지침·걸음 수는 그 파일이 들고 있다. 모델이 매번 고르지
16
+ * 않으므로 **매번 다르게 고르는 일도 없어진다.** 사내에서 「검토는 이렇게
17
+ * 한다」 를 글이 아니라 파일로 못 박는 자리다.
18
+ *
19
+ * ── 이건 스킬과 같은 무게다 ────────────────────────────────────────────
20
+ *
21
+ * 훅(safety/hooks.js)은 믿는 폴더에서만 읽는다 — **명령을 돌리기** 때문이다.
22
+ * 여기는 안 돌린다. 지침은 글이고, 도구는 **줄이기만** 하고, 모델은 이 PC 설정에
23
+ * 적힌 프로필 이름으로만 고른다. 즉 스킬·DEEL.md 와 같은 갈래라, 같은 규칙으로
24
+ * 읽는다(skills/discover.js). 여기만 다른 규칙을 두면 사람은 왜 어떤 파일은
25
+ * 읽히고 어떤 파일은 안 읽히는지 알 수 없게 된다.
26
+ *
27
+ * 그래도 두 줄은 못 박는다.
28
+ *
29
+ * · **도구는 줄이기만 한다.** 정의에 적은 이름 중 모드가 이미 주는 것만
30
+ * 남긴다. 설계 모드에 `Write` 를 적어 넣는 것으로 「파일을 안 바꾼다」 는
31
+ * 약속을 깨고 나갈 길이 생기면 안 된다.
32
+ * · **모드는 부모보다 셀 수 없다.** task.js 의 하위모드() 를 그대로 지난다.
33
+ */
34
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
35
+ import { homedir } from 'node:os';
36
+ import { extname, join } from 'node:path';
37
+ import { frontmatter } from '../skills/discover.js';
38
+
39
+ /** 한 폴더에서 읽을 수 있는 수. 스무 개를 넘기면 목록이 프롬프트를 먹는다. */
40
+ export const 최대 = 24;
41
+ /** 설명 한 줄의 길이. 이 글이 매 요청에 실린다. */
42
+ export const 설명길이 = 140;
43
+
44
+ export const 자리들 = (밑) => [join(밑, '.deel', 'agents'), join(밑, '.claude', 'agents')];
45
+
46
+ /** 파일 이름에서 뽑은 이름. 확장자와 경로를 뗀 것. */
47
+ const 이름뽑기 = (파일) => 파일.replace(/\.(json|md)$/i, '').trim();
48
+
49
+ /**
50
+ * 정의 하나를 우리 모양으로 편다.
51
+ *
52
+ * 못 알아들으면 null 을 돌려주고, 왜인지는 부르는 쪽이 적는다. 조용히 버리면
53
+ * 사람은 제가 만든 에이전트가 왜 안 보이는지 영영 모른다.
54
+ */
55
+ export function 한정의(이름, 것, 출처) {
56
+ const 설명 = String(것?.설명 ?? 것?.description ?? '').trim();
57
+ if (!설명) return null; // 설명이 없으면 부모 모델이 고를 수가 없다
58
+ const 도구 = 것?.도구 ?? 것?.tools ?? null;
59
+ return {
60
+ 이름: String(이름).trim(),
61
+ 설명: 설명.length > 설명길이 ? `${설명.slice(0, 설명길이)}…` : 설명,
62
+ 모드: String(것?.모드 ?? 것?.mode ?? '').trim() || null,
63
+ 모델: String(것?.모델 ?? 것?.model ?? '').trim() || null,
64
+ /*
65
+ * 도구는 **적힌 것만** 쓴다. 배열로도 쉼표 글로도 받는다 — 사람이 손으로
66
+ * 적는 파일이라 둘 다 온다.
67
+ */
68
+ 도구: Array.isArray(도구)
69
+ ? 도구.map((x) => String(x).trim()).filter(Boolean)
70
+ : (typeof 도구 === 'string' && 도구.trim()
71
+ ? 도구.split(/[,\s]+/).map((x) => x.trim()).filter(Boolean)
72
+ : null),
73
+ 지침: String(것?.지침 ?? 것?.prompt ?? 것?.본문 ?? '').trim() || null,
74
+ 걸음: Number.isFinite(Number(것?.걸음 ?? 것?.maxSteps)) && Number(것?.걸음 ?? 것?.maxSteps) > 0
75
+ ? Math.min(Math.floor(Number(것?.걸음 ?? 것?.maxSteps)), 200)
76
+ : null,
77
+ 출처,
78
+ };
79
+ }
80
+
81
+ function 파일하나(경로, 파일, 출처) {
82
+ const 이름 = 이름뽑기(파일);
83
+ if (!이름) return { 왜: `${파일}: 이름이 없습니다` };
84
+ const 글 = readFileSync(경로, 'utf8');
85
+ if (extname(파일).toLowerCase() === '.json') {
86
+ let j;
87
+ try { j = JSON.parse(글); } catch (e) { return { 왜: `${파일}: JSON 이 아닙니다 — ${e.message}` }; }
88
+ const r = 한정의(j?.이름 ?? j?.name ?? 이름, j, 출처);
89
+ return r ? { 것: r } : { 왜: `${파일}: 설명이 없습니다 (설명 · description)` };
90
+ }
91
+ /*
92
+ * `.md` 도 받는다 — Claude Code 의 에이전트 파일이 그 모양이다.
93
+ *
94
+ * MCP 에서 mcpServers 를, 훅에서 hooks 를 그대로 받는 것과 같은 판단이다.
95
+ * 이미 그 파일을 가진 사람에게 「우리 모양으로 다시 적어라」 고 하면, 그
96
+ * 사람은 이 기능을 안 쓴다. 본문은 통째로 지침이 된다.
97
+ */
98
+ const { data, body } = frontmatter(글);
99
+ const r = 한정의(data?.name ?? data?.이름 ?? 이름, { ...data, 지침: data?.지침 ?? body }, 출처);
100
+ return r ? { 것: r } : { 왜: `${파일}: 앞머리에 description 이 없습니다` };
101
+ }
102
+
103
+ function 폴더하나(폴더, 출처, 모은것, 버린것) {
104
+ if (!existsSync(폴더)) return;
105
+ let 것들;
106
+ try { 것들 = readdirSync(폴더, { withFileTypes: true }); } catch { return; }
107
+ for (const d of 것들) {
108
+ if (!d.isFile()) continue;
109
+ if (!/\.(json|md)$/i.test(d.name)) continue;
110
+ if (모은것.size >= 최대) { 버린것.push(`${최대}개까지만 읽습니다 — ${d.name} 부터는 안 읽었습니다`); break; }
111
+ try {
112
+ const r = 파일하나(join(폴더, d.name), d.name, 출처);
113
+ if (r.것) 모은것.set(r.것.이름, r.것); else 버린것.push(r.왜);
114
+ } catch (e) {
115
+ 버린것.push(`${d.name}: 못 읽었습니다 — ${e.message}`);
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * 이 자리에서 쓸 에이전트를 다 읽는다.
122
+ *
123
+ * 이 PC 것을 먼저, 프로젝트 것을 나중에 읽는다 — 이름이 같으면 **가까운
124
+ * 쪽이 이긴다.** 스킬과 같은 규칙이다(skills/discover.js 의 dedupe).
125
+ */
126
+ export function 에이전트읽기(root, { 집 = homedir(), env = process.env } = {}) {
127
+ const 모은것 = new Map();
128
+ const 버린것 = [];
129
+ const 끔 = String(env.DEEL_AGENTS ?? '').trim().toLowerCase();
130
+ if (끔 === 'off' || 끔 === '0' || 끔 === 'false') {
131
+ return { 에이전트들: [], 버린것: [], 켜짐: false };
132
+ }
133
+ for (const 폴더 of 자리들(집)) 폴더하나(폴더, '이 PC', 모은것, 버린것);
134
+ for (const 폴더 of 자리들(root)) 폴더하나(폴더, '프로젝트', 모은것, 버린것);
135
+ return { 에이전트들: [...모은것.values()], 버린것, 켜짐: true };
136
+ }
137
+
138
+ /** 이름으로 찾는다. 대소문자는 안 가린다 — 사람이 부르는 이름이다. */
139
+ export function 찾기(에이전트들, 이름) {
140
+ const s = String(이름 ?? '').trim().toLowerCase();
141
+ if (!s) return null;
142
+ return (에이전트들 ?? []).find((a) => a.이름.toLowerCase() === s) ?? null;
143
+ }
144
+
145
+ /**
146
+ * `Task` 스키마의 `agent` 칸에 붙일 설명.
147
+ *
148
+ * 이름과 한 줄 설명만 싣는다. 지침 본문은 **고른 뒤에** 하위 프롬프트로 가지,
149
+ * 여기 실리지 않는다 — 여기 실으면 안 쓰는 에이전트의 지침까지 매 요청에
150
+ * 나간다. 스킬을 2단계로 올리는 것과 같은 셈법이다(skills/discover.js).
151
+ */
152
+ export function 고를말(에이전트들) {
153
+ if (!에이전트들?.length) return null;
154
+ const 목록 = 에이전트들.map((a) => `${a.이름}(${a.설명})`).join(' · ');
155
+ return '**미리 정해 둔 하위 작업**을 이름으로 고른다. 고르면 그 정의가 모드·모델·도구·지침을'
156
+ + ' 대신 정하므로, mode·model 은 안 적어도 된다. 쓸 수 있는 이름: ' + 목록
157
+ + '. 여기 없는 이름은 지어내지 마라 — 안 먹는다.';
158
+ }
159
+
160
+ /**
161
+ * 하위에게 줄 지침을 한 덩이로 만든다.
162
+ *
163
+ * 시킨 일보다 **앞에** 놓는다. 지침은 「어떻게 하는가」 이고 시킨 일은
164
+ * 「무엇을 하는가」 인데, 뒤에 놓으면 모델이 마지막에 읽은 것을 일로 여긴다.
165
+ */
166
+ export function 할일합치기(정의, 할일) {
167
+ if (!정의?.지침) return 할일;
168
+ return `${정의.지침}\n\n---\n\n${할일}`;
169
+ }
170
+
171
+ /**
172
+ * 정의가 적은 도구로 **줄인다.** 늘리지 않는다.
173
+ *
174
+ * 이게 이 파일에서 제일 중요한 함수다. 모드가 안 주는 도구를 정의에 적어
175
+ * 넣는 것으로 그 모드의 약속이 깨지면 안 된다 — 설계 모드에 `Write` 를
176
+ * 적는 것 한 줄이 「파일을 안 바꾼다」 를 없던 일로 만든다.
177
+ *
178
+ * 정의에 적은 이름 중 **하나도 안 남으면** 줄이지 않는다. 도구가 0개인
179
+ * 하위는 아무것도 못 하고 걸음만 태우는데, 그 까닭이 화면 어디에도 안
180
+ * 나타난다. 오타 하나가 조용한 실패가 되는 자리라 그렇게는 안 한다.
181
+ *
182
+ * @returns {{도구:string[], 못준것:string[]}}
183
+ */
184
+ export function 도구줄이기(정의, 모드가준것) {
185
+ const 있는것 = 모드가준것 ?? [];
186
+ if (!정의?.도구?.length) return { 도구: 있는것, 못준것: [] };
187
+ const 남길것 = 정의.도구.filter((n) => 있는것.includes(n));
188
+ const 못준것 = 정의.도구.filter((n) => !있는것.includes(n));
189
+ return { 도구: 남길것.length ? 남길것 : 있는것, 못준것 };
190
+ }
191
+
192
+ /** 화면 한 줄 (`/agents` · doctor). */
193
+ export function 에이전트줄들(r) {
194
+ const 줄 = [];
195
+ if (!r.켜짐) { 줄.push({ 상태: 'warn', 이름: '에이전트', 값: '꺼져 있습니다 (DEEL_AGENTS=off)' }); return 줄; }
196
+ for (const 왜 of r.버린것 ?? []) 줄.push({ 상태: 'warn', 이름: '에이전트 · 못 읽음', 값: 왜 });
197
+ if (!r.에이전트들.length) return 줄;
198
+ 줄.push({
199
+ 상태: 'ok', 이름: '에이전트', 값: `${r.에이전트들.length}개`,
200
+ 덧말: r.에이전트들.map((a) => a.이름).join(' · '),
201
+ });
202
+ return 줄;
203
+ }
package/src/agent/loop.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // 에이전트 루프. 모델 → 도구 → 결과 → 모델 을 답이 나올 때까지 돈다.
2
2
  // 화면에 그릴 것은 이벤트로 흘려보낸다 — 화면 코드와 섞지 않는다.
3
- import { chat, chatStream, assistantMessage, toolMessage, 말없이끝남, 보낸토큰, 부른것들, 도구결과인가 } from '../backend/adapter.js';
3
+ import { chat, chatStream, assistantMessage, toolMessage, 말없이끝남, 흐름멎음, 보낸토큰, 부른것들, 도구결과인가 } from '../backend/adapter.js';
4
4
  import { 그림메시지 } from '../backend/vision.js';
5
5
  import { 어떻게할까 } from '../safety/policy.js';
6
+ import { 자리돌리기, 막힘말 } from '../safety/hooks.js';
6
7
  import { toolSchemas, runTool, TOOLS, 파일현황 } from '../tools/index.js';
7
8
  import { isMutating } from '../safety/guard.js';
8
9
  import { effortFor, tokensFor, fullCap, wasCut, shiftLevel, 자동강도, 천장고르기, 인사인가 as 인사말인가 } from './effort.js';
@@ -13,6 +14,7 @@ import { compact, shouldCompact, shouldFold, foldToolResults, foldImages, 못박
13
14
  import { 걸음수, 하위걸음수, 요약길이 } from './budget.js';
14
15
  import { Session } from './session.js';
15
16
  import { 최대깊이, 하위모드, 하위요약 } from '../tools/task.js';
17
+ import { 찾기 as 에이전트찾기, 할일합치기, 도구줄이기 } from './agents.js';
16
18
  import { 프로필찾기, 쓸수있나, 연결만들기, 알릴말, 목록보기 } from './models.js';
17
19
  import { allowTemporarily, isOffline } from '../safety/network.js';
18
20
  import { 가리기, 훑기, 가렸다는말, 봤다는말, 가릴까 } from '../safety/secrets.js';
@@ -124,7 +126,20 @@ export function 묶기(calls) {
124
126
  return out;
125
127
  }
126
128
 
127
- export async function* run(session, ctx, userText, { signal = null, 깊이 = 0, 그림들 = null, 끼어들기 = null } = {}) {
129
+ /*
130
+ * `고쳐쓰기` — 방금 낸 답을 **모양에 맞춰 다시 적으라고** 시키는 턴.
131
+ *
132
+ * 새 일이 아니라 같은 일의 뒷정리라, 사람 말로 시작한 턴과 세 가지가 다르다.
133
+ * 되돌리기 턴을 새로 열지 않고(같은 턴이다), 되밀기를 안 걸고, 기록에도
134
+ * 사람이 새로 시킨 말이 아니라고 적는다.
135
+ *
136
+ * 되밀기를 꼭 꺼야 하는 까닭:
137
+ * 되밀기는 「시킨 일을 안 하고 끝냈나」 를 본다. 그런데 여기서 시킨 말은
138
+ * 스키마 설명과 안 맞은 자리 목록이라, 그 안의 낱말들이 「안 한 일」 로
139
+ * 잡힌다. 그러면 한 번 더 시키려고 부른 자리에서 모델을 **두 번** 부르고,
140
+ * 그 왕복은 사람이 낸다. 실제로 그랬다 — 되묻기 한 번에 부름이 셋이었다.
141
+ */
142
+ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0, 그림들 = null, 끼어들기 = null, 고쳐쓰기 = false } = {}) {
128
143
  /*
129
144
  * 되돌리기 턴은 **부모만** 연다.
130
145
  *
@@ -136,12 +151,37 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
136
151
  * 표시되고, 되감을 때 시킨 말까지 같이 걷힌다. 시킨 말만 남으면 모델은
137
152
  * 되돌린 일을 또 하려 든다.
138
153
  */
139
- if (!깊이) session.턴시작(ctx.history.nextTurn());
154
+ if (!깊이 && !고쳐쓰기) session.턴시작(ctx.history.nextTurn());
155
+
156
+ /*
157
+ * ── 사람 말이 나가기 전에 (safety/hooks.js) ─────────────────────────
158
+ *
159
+ * **밀어 넣기 전에** 돈다. 넣고 나서 막으면 막힌 말이 대화에 그대로 남고,
160
+ * 다음 요청에 실려 나간다 — 막은 것이 아니라 늦춘 것뿐이다. 사내 DLP 훅을
161
+ * 여기 거는 사람이 원하는 것은 정확히 그 반대다.
162
+ *
163
+ * 하위 작업(깊이>0)에서는 안 돈다. 하위가 받은 할일은 사람이 친 말이
164
+ * 아니라 부모 모델이 쓴 글이고, 그 글의 재료가 된 사람 말은 이미 여기를
165
+ * 한 번 지나왔다. 다시 돌리면 같은 말을 두 번 재는 셈이다.
166
+ */
167
+ if (!깊이 && ctx.훅들?.length) {
168
+ const 훅 = await 자리돌리기(ctx.훅들, '말전', {
169
+ 넣을것: { 말: String(userText ?? '') }, signal, audit: ctx.audit,
170
+ });
171
+ if (훅.막힘) {
172
+ ctx.audit.blocked('말전 훅이 막음', 훅.막힘.훅.명령);
173
+ yield { type: 'hook_block', 자리: '말전', 말: 막힘말(훅.막힘), 훅: 훅.막힘.훅 };
174
+ yield { type: 'done', steps: 0, text: 막힘말(훅.막힘), files: [], 빠진: [], 훅막힘: true };
175
+ return;
176
+ }
177
+ for (const 말 of 훅.말들) yield { type: 'hook_note', 자리: '말전', 말 };
178
+ }
179
+
140
180
  // @ 로 그림을 지목했으면 그 말과 함께 실어 보낸다 (backend/vision.js).
141
181
  session.push(그림들?.length
142
182
  ? 그림메시지(session.conn?.kind, { 글: userText, 그림들 })
143
183
  : { role: 'user', content: userText });
144
- ctx.audit.turn(깊이 ? `[하위작업 ${깊이}겹] ${userText}` : userText);
184
+ ctx.audit.turn(고쳐쓰기 ? `[다시 쓰기] ${userText}` : 깊이 ? `[하위작업 ${깊이}겹] ${userText}` : userText);
145
185
 
146
186
  /*
147
187
  * 중단 신호를 도구도 볼 수 있게 여기 걸어 둔다.
@@ -365,6 +405,8 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
365
405
  work: session.effectiveWork(), // 작업 모드가 쓰는 것만 (modes.js)
366
406
  // 밖에서 붙인 도구(MCP). 붙은 것이 없으면 아무것도 안 는다.
367
407
  mcp: ctx.mcp ?? null,
408
+ // 이름 붙인 하위 작업 (agent/agents.js). 이름과 한 줄 설명만 실린다.
409
+ 에이전트들: 깊이 + 1 >= 최대깊이 ? null : (ctx.에이전트들 ?? null),
368
410
  // 이 자리에 언어 서버가 있을 때만 Def·Refs 를 보여 준다 (tools/lsp.js).
369
411
  // 없는 자리에서 목록에 세워 두면 모델이 부르고, 실패를 받고, 또 부른다.
370
412
  lsp: session.lsp === true,
@@ -496,7 +538,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
496
538
 
497
539
  /** 이번 답을 되밀까. 밀 이유를 돌려주고, 아니면 null. */
498
540
  const 밀어줄까 = (글) => {
499
- if (민적 || 깊이) return null;
541
+ if (민적 || 깊이 || 고쳐쓰기) return null;
500
542
  if (인사인가) return null; // 시킨 것이 없으면 안 한 것도 없다
501
543
  if (손댄파일.size) return null; // 뭐라도 바꿨으면 일은 한 것이다
502
544
  if (!모드.tools.includes('Write')) return null; // 안 바꾸는 모드는 그게 맞다
@@ -525,7 +567,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
525
567
  let 빠뜨림민적 = false;
526
568
  const 빠뜨린것 = (글) => {
527
569
  // 하위 작업은 사람이 시킨 말이 아니다. 거기 할일은 부모가 이미 쪼개 준 것이다.
528
- if (깊이) return [];
570
+ if (깊이 || 고쳐쓰기) return [];
529
571
  return 빠진것({
530
572
  요청: userText,
531
573
  자국: [
@@ -810,14 +852,18 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
810
852
  cap: 마지막상한,
811
853
  정한값: conn.maxTokens ?? conn.maxOut ?? null,
812
854
  };
813
- } else if (msg?.stopped === 말없이끝남) {
855
+ } else if (msg?.stopped === 말없이끝남 || msg?.stopped === 흐름멎음) {
814
856
  /*
815
857
  * 상한 때문에 잘린 것과는 다른 사고다. 서버가 끝난 까닭을 한 번도 안
816
858
  * 주고 흘려보내기를 멈췄다 — 중계 프록시가 몸통을 자르고 연결을 곱게
817
859
  * 닫으면 이 모양이 된다. 상한을 올려 다시 부르는 것은 답이 아니라서
818
860
  * capped 와 섞으면 엉뚱한 곳(/out)을 고치게 만든다.
861
+ *
862
+ * 흐름이 멎어서 **우리가** 끊은 것은 한 겹 더 말해 준다. 그때는 몇 초를
863
+ * 기다렸는지가 곧 손댈 자리다 — 게이트웨이가 답을 통째로 모았다가 주는
864
+ * 자리면 잠잠 상한을 올리면 되고, 그걸 모르면 사람은 연결을 의심한다.
819
865
  */
820
- yield { type: 'cutoff' };
866
+ yield { type: 'cutoff', 멎은초: msg?.stopped === 흐름멎음 ? (msg.멎은초 ?? null) : null };
821
867
  }
822
868
 
823
869
  /*
@@ -1124,6 +1170,23 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1124
1170
  yield { type: 'nudge', why: '요청누락', 빠진, text: msg.content };
1125
1171
  continue;
1126
1172
  }
1173
+ /*
1174
+ * ── 턴이 끝나는 자리 (safety/hooks.js) ────────────────────────────
1175
+ *
1176
+ * 여기도 못 막는다 — 일은 이미 다 끝났다. 여기 훅을 거는 사람이 하려는
1177
+ * 것은 대개 뒷정리다: 검사 한 번 돌리기, 사내 알림 보내기, 바뀐 파일
1178
+ * 목록 적어 두기.
1179
+ *
1180
+ * 하위 작업에서는 안 돈다. 하위가 끝난 것은 사람이 시킨 일이 끝난 것이
1181
+ * 아니라 그 한 덩이가 끝난 것뿐인데, 여기서 돌리면 한 번 시킨 일에
1182
+ * 뒷정리가 세 번 돈다.
1183
+ */
1184
+ if (!깊이 && ctx.훅들?.length) {
1185
+ const 훅 = await 자리돌리기(ctx.훅들, '턴끝', {
1186
+ 넣을것: { 걸음: steps, 파일: [...손댄파일] }, signal, audit: ctx.audit,
1187
+ });
1188
+ for (const 말 of 훅.말들) yield { type: 'hook_note', 자리: '턴끝', 말 };
1189
+ }
1127
1190
  // 밀고도 그대로면 조용히 넘어가지 않는다. 사람이 알아야 다음을 정한다.
1128
1191
  yield { type: 'done', steps, text: msg.content, files: 마무리(), 빠진 };
1129
1192
  return;
@@ -1283,6 +1346,37 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1283
1346
  continue;
1284
1347
  }
1285
1348
 
1349
+ /*
1350
+ * ── 사람이 적어 둔 훅 (safety/hooks.js) ──────────────────────────
1351
+ *
1352
+ * 규칙 **다음**, 승인 **앞**이다. 자리가 여기여야 하는 이유가 둘이다.
1353
+ *
1354
+ * · 규칙으로 이미 막힌 것에 훅을 돌리면 남의 프로그램을 헛돌린다.
1355
+ * · 승인 앞에 두어야 사내 규칙이 사람을 안 귀찮게 하고 막는다.
1356
+ * 뒤에 두면 「y 를 치고 나서 막혔습니다」 가 되는데, 그건 사람에게
1357
+ * 제 손으로 허락한 것을 빼앗기는 것처럼 보인다.
1358
+ *
1359
+ * 승인을 훅이 대신할 수는 없다. 훅이 통과시켜도 strict 는 여전히
1360
+ * 묻는다 — 그래야 `허락 = 규칙 그리고 훅 그리고 사람` 이 된다.
1361
+ */
1362
+ if (ctx.훅들?.length) {
1363
+ const 훅 = await 자리돌리기(ctx.훅들, '도구전', {
1364
+ 도구: call.name, 넣을것: { 인자: call.args ?? {} }, signal, audit: ctx.audit,
1365
+ });
1366
+ if (훅.막힘) {
1367
+ const 까닭 = 막힘말(훅.막힘);
1368
+ ctx.audit?.blocked?.('도구전 훅이 막음', `${call.name} · ${훅.막힘.훅.명령}`);
1369
+ 거절(call, 까닭);
1370
+ if (막힘셈(call, '훅 막음')) 멈출까 = '훅이 막은 것을 계속 다시 부르고 있습니다';
1371
+ yield {
1372
+ type: 'tool', name: call.name, args: call.args, showLabel: true,
1373
+ result: { error: `훅이 막았습니다 — ${훅.막힘.훅.이름 ?? 훅.막힘.훅.출처}` },
1374
+ };
1375
+ continue;
1376
+ }
1377
+ for (const 말 of 훅.말들) yield { type: 'hook_note', 자리: '도구전', 도구: call.name, 말 };
1378
+ }
1379
+
1286
1380
  // 모드에 따라 물어본다. 기본(auto)은 안 묻고 되돌리기로 대응한다.
1287
1381
  const needsOk = 판정.답 === 'allow' ? false : session.mode === 'strict'
1288
1382
  ? ['Write', 'Edit', 'Bash'].includes(call.name)
@@ -1319,7 +1413,33 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1319
1413
  if (실행할것.length === 1 && 실행할것[0].name === 'Task') {
1320
1414
  const call = 실행할것[0];
1321
1415
  const 목적 = String(call.args?.purpose ?? call.args?.목적 ?? '').trim() || '이름 없는 작업';
1322
- const 할일 = String(call.args?.task ?? call.args?.할일 ?? '').trim();
1416
+ let 할일 = String(call.args?.task ?? call.args?.할일 ?? '').trim();
1417
+
1418
+ /*
1419
+ * ── 이름 붙인 하위 작업 (agent/agents.js) ────────────────────────
1420
+ *
1421
+ * 이름을 골랐으면 그 정의가 모드·모델·도구·지침·걸음 수를 대신 정한다.
1422
+ * 팀에서 늘 같은 일을 시키는데 매번 다르게 적히는 것을 막는 자리다.
1423
+ *
1424
+ * 못 찾으면 **조용히 그냥 돌지 않는다.** 시킨 쪽은 「리뷰어가 본다」 고
1425
+ * 알고 있는데 실제로는 평범한 하위가 도는 셈이 되고, 그 어긋남은 결과를
1426
+ * 읽어도 안 보인다. 모델 이름을 못 찾았을 때와 같은 자세다.
1427
+ */
1428
+ let 정의 = null;
1429
+ const 부른에이전트 = String(call.args?.agent ?? call.args?.에이전트 ?? '').trim();
1430
+ if (부른에이전트) {
1431
+ 정의 = 에이전트찾기(ctx.에이전트들, 부른에이전트);
1432
+ if (!정의) {
1433
+ const 있는것 = (ctx.에이전트들 ?? []).map((a) => a.이름).join(' · ') || '(정의해 둔 것이 없습니다)';
1434
+ 거절(call, `"${부른에이전트}" 라는 하위 작업 정의가 없습니다. 쓸 수 있는 이름: ${있는것}`);
1435
+ if (막힘셈(call, '없는 에이전트')) 멈출까 = '없는 하위 작업 이름을 계속 부르고 있습니다';
1436
+ yield {
1437
+ type: 'tool', name: 'Task', args: call.args, showLabel: true,
1438
+ result: { error: `"${부른에이전트}" 는 없는 하위 작업 이름입니다` },
1439
+ };
1440
+ continue;
1441
+ }
1442
+ }
1323
1443
 
1324
1444
  // 할 일이 비면 하위는 아무것도 모른 채로 시작한다. 하위는 이 대화를
1325
1445
  // 못 보므로, 여기서 통과시키면 걸음만 태우고 빈손으로 돌아온다.
@@ -1332,7 +1452,20 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1332
1452
  continue;
1333
1453
  }
1334
1454
 
1335
- const 자식모드 = 하위모드(call.args?.mode ?? call.args?.모드, 모드.id);
1455
+ /*
1456
+ * 정의의 지침은 **빈 할일 검사를 지난 뒤에** 붙인다.
1457
+ *
1458
+ * 먼저 붙이면 지침이 있는 에이전트는 할일을 비워도 그 검사를
1459
+ * 빠져나간다. 그러면 하위는 「어떻게」 만 받고 「무엇을」 은 모른 채로
1460
+ * 시작해서, 걸음만 태우고 빈손으로 돌아온다.
1461
+ */
1462
+ if (정의) 할일 = 할일합치기(정의, 할일);
1463
+
1464
+ /*
1465
+ * 모드도 모델도 정의가 먼저다 — 그러라고 이름을 붙인 것이다.
1466
+ * 다만 부모보다 셀 수는 없다: 하위모드() 를 그대로 지난다(task.js).
1467
+ */
1468
+ const 자식모드 = 하위모드(정의?.모드 ?? call.args?.mode ?? call.args?.모드, 모드.id);
1336
1469
 
1337
1470
  /*
1338
1471
  * ── 다른 모델에게 떼어 주기 ─────────────────────────────────────
@@ -1348,7 +1481,7 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1348
1481
  let 자식conn = conn;
1349
1482
  let 자리닫기 = null;
1350
1483
  let 모델알림 = null;
1351
- const 부른모델 = String(call.args?.model ?? call.args?.모델 ?? '').trim();
1484
+ const 부른모델 = String(정의?.모델 ?? call.args?.model ?? call.args?.모델 ?? '').trim();
1352
1485
  if (부른모델) {
1353
1486
  const 찾음 = 프로필찾기(부른모델);
1354
1487
  if (!찾음.ok) {
@@ -1387,19 +1520,27 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1387
1520
  think: session.think,
1388
1521
  effort: session.effort,
1389
1522
  web: session.web,
1390
- // 부모보다 적게 준다 (budget.js). 사람이 직접 정한 값이 있으면 절반.
1391
- maxSteps: session.stepsSet
1523
+ // 부모보다 적게 준다 (budget.js). 정의에 적힌 것 > 사람이 정한 값의 절반 > 모드.
1524
+ maxSteps: 정의?.걸음 ?? (session.stepsSet
1392
1525
  ? Math.max(4, Math.floor(session.maxSteps / 2))
1393
1526
  // 걸음 수는 **하위가 쓸 창**으로 잰다. 부모 창으로 재면, 작은 모델에게
1394
1527
  // 떼어 준 일이 제 창보다 큰 걸음 수를 받아 중간에 창이 찬 채로 돈다.
1395
- : 하위걸음수(자식모드, 자식conn.ctx),
1528
+ : 하위걸음수(자식모드, 자식conn.ctx)),
1396
1529
  });
1397
1530
  // 스킬·명령·기억은 부모가 켤 때 한 번 찾아 든 것이다. 하위도 같은 것을 본다.
1398
1531
  자식.skills = session.skills;
1399
1532
  자식.commands = session.commands;
1400
1533
  자식.plugins = session.plugins;
1401
1534
  자식.memory = session.memory;
1402
- 자식.도구제한 = 자식도구;
1535
+ /*
1536
+ * 정의가 적은 도구로 **줄인다.** 늘리지 않는다 (agent/agents.js).
1537
+ *
1538
+ * 자식도구 는 이미 모드가 거른 목록이다. 여기서 교집합을 취하므로,
1539
+ * 정의에 `Write` 를 적어 넣는 것으로 설계 모드의 약속을 깨고 나갈 길은
1540
+ * 안 생긴다. 모드가 안 주는 것을 적어 두면 그것만 무시된다.
1541
+ */
1542
+ const 줄인것 = 도구줄이기(정의, 자식도구);
1543
+ 자식.도구제한 = 줄인것.도구;
1403
1544
 
1404
1545
  /*
1405
1546
  * 여닫는 줄에는 깊이를 안 붙인다.
@@ -1417,8 +1558,21 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1417
1558
  * 있어서 하위가 딴 데로 나가는 것을 모른다. 그러니 이 줄과 감사기록이
1418
1559
  * 그 사실을 남기는 유일한 자리다.
1419
1560
  */
1561
+ /*
1562
+ * 정의에 적혔는데 모드가 안 주는 도구는 **말해 준다.**
1563
+ *
1564
+ * 조용히 빼면 사람은 제가 적은 도구가 도는 줄 알고, 하위가 그걸 안 했을
1565
+ * 때 정의 파일이 아니라 모델을 의심한다. 오타 하나가 며칠이 되는 자리다.
1566
+ */
1567
+ if (줄인것.못준것.length) {
1568
+ yield {
1569
+ type: 'hook_note', 자리: '에이전트',
1570
+ 말: `${정의.이름} 정의의 ${줄인것.못준것.join(' · ')} 은 ${자식모드} 모드가 안 주는 도구라 뺀습니다`,
1571
+ };
1572
+ }
1420
1573
  yield {
1421
1574
  type: 'task_start', 목적, 모드: 자식모드, steps: 자식.maxSteps,
1575
+ 에이전트: 정의?.이름 ?? null,
1422
1576
  모델: 모델알림?.말 ?? null, 밖으로: 모델알림?.밖으로 ?? false,
1423
1577
  };
1424
1578
  ctx.audit.tool('Task', { 목적, 모드: 자식모드, 모델: 모델알림?.말 ?? null },
@@ -1482,13 +1636,13 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1482
1636
  session.usage.못잰것 = (session.usage.못잰것 ?? 0) + (자식.usage.못잰것 ?? 0);
1483
1637
 
1484
1638
  const 글 = 하위요약({
1485
- 목적, 모드: 자식모드, 끝, 모델: 모델알림?.말 ?? null,
1639
+ 목적, 모드: 자식모드, 끝, 모델: 모델알림?.말 ?? null, 에이전트: 정의?.이름 ?? null,
1486
1640
  글자수: 요약길이(conn.ctx),
1487
1641
  보인이름: (경로) => ctx.scope?.show?.(경로) ?? 경로,
1488
1642
  });
1489
1643
  session.push(toolMessage(conn.kind, { callId: call.id, name: 'Task', content: 글 }));
1490
1644
  ctx.audit.tool('Task', { 목적 }, { summary: 글.slice(0, 300) });
1491
- yield { type: 'task_done', 목적, 모드: 자식모드, 끝, 모델: 모델알림?.말 ?? null };
1645
+ yield { type: 'task_done', 목적, 모드: 자식모드, 끝, 모델: 모델알림?.말 ?? null, 에이전트: 정의?.이름 ?? null };
1492
1646
 
1493
1647
  // 사용자가 중단했으면 부모도 여기서 멈춘다. 하위만 끊고 이어가면
1494
1648
  // 무엇이 중단된 것인지 알 수 없는 화면이 된다.
@@ -1569,6 +1723,36 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1569
1723
  */
1570
1724
  for (const f of result.바뀐것들 ?? []) 손댄파일.add(f);
1571
1725
 
1726
+ /*
1727
+ * ── 도구가 끝난 뒤 (safety/hooks.js) ─────────────────────────────
1728
+ *
1729
+ * 여기서는 **못 막는다.** 이미 파일이 바뀌었고 명령이 돌았다. 막을 수
1730
+ * 없는 것을 막는 척하면 그게 제일 나쁜 화면이다. 할 수 있는 것은
1731
+ * 모델에게 말해 주는 것뿐이고, 그것만 한다.
1732
+ *
1733
+ * 제일 흔한 쓰임이 포맷터와 검사기다. `Edit` 뒤에 사내 포맷터를 돌리고
1734
+ * 그 결과를 모델에게 돌려주면, 모델이 다음 걸음에서 스스로 맞춘다.
1735
+ *
1736
+ * 훅이 뱉은 글은 **명령 출력**이라 열쇠가 섞여 나오기 쉽다. 아래 비밀
1737
+ * 가리기는 도구 이름으로 갈래를 정하는데(가릴까), 훅은 어느 도구 뒤에도
1738
+ * 붙을 수 있어서 그 갈래를 못 믿는다. 그래서 여기서 한 번 가린다.
1739
+ */
1740
+ let 훅덧말 = '';
1741
+ if (ctx.훅들?.length) {
1742
+ const 훅 = await 자리돌리기(ctx.훅들, '도구후', {
1743
+ 도구: call.name,
1744
+ 넣을것: { 인자: call.args ?? {}, 실패: !!result.error, 바뀐것: result.changed ?? null },
1745
+ signal,
1746
+ audit: ctx.audit,
1747
+ });
1748
+ if (훅.말들.length) {
1749
+ const 가린 = 가리기(훅.말들.join('\n'), { 열쇠들: [conn.key, ...환경속열쇠들()].filter(Boolean) });
1750
+ const 글 = 가린.글 + (가린.가린것.length ? 가렸다는말(가린.가린것) : '');
1751
+ 훅덧말 = `\n\n[도구후 훅]\n${글}`;
1752
+ for (const 말 of 훅.말들) yield { type: 'hook_note', 자리: '도구후', 도구: call.name, 말 };
1753
+ }
1754
+ }
1755
+
1572
1756
  // 앞에서 똑같이 부른 적이 있고 결과도 같으면, 결과를 다시 싣지 않는다.
1573
1757
  let 실을것 = 실을글(result);
1574
1758
  const 날것 = 실을것; // 되풀이는 도구가 진짜 돌려준 것으로 센다(아래 참고)
@@ -1686,6 +1870,14 @@ export async function* run(session, ctx, userText, { signal = null, 깊이 = 0,
1686
1870
  * 무엇이 열쇠인지는 config.js 한 곳만 안다 (환경속열쇠들).
1687
1871
  */
1688
1872
  const 아는열쇠들 = [conn.key, ...환경속열쇠들()].filter(Boolean);
1873
+ /*
1874
+ * 훅이 한 말은 **되풀이 판정이 끝난 뒤에** 붙인다.
1875
+ *
1876
+ * 앞에 붙이면 같은 도구를 같은 인자로 두 번 불렀을 때, 훅이 시각이나
1877
+ * 줄 번호를 한 글자만 달리 뱉어도 「결과가 달라졌다」 가 된다. 그러면
1878
+ * 되풀이 그물이 통째로 풀려서, 헛도는 것을 안 잡는다.
1879
+ */
1880
+ if (훅덧말) 실을것 += 훅덧말;
1689
1881
  let 비밀 = [];
1690
1882
  // 바깥으로 나가면 파일에서 읽어 온 글까지 가린다 (safety/secrets.js 의 가릴까).
1691
1883
  const 이번엔가릴까 = 가릴까(call.name, { 바깥: 바깥으로나감 });
@@ -34,6 +34,8 @@
34
34
  */
35
35
  import { load, activeProfile, resolveKey } from '../config.js';
36
36
  import { isLocalHost, isOffline } from '../safety/network.js';
37
+ import { 잠잠기본 } from '../backend/http.js';
38
+ import { 인증서설정 } from '../backend/clientcert.js';
37
39
 
38
40
  // 컨텍스트를 못 알아냈을 때 쓰는 값. repl.js 와 같은 값을 봐야 한다.
39
41
  export const CTX_DEFAULT = 32768;
@@ -56,6 +58,8 @@ export function 연결만들기(prof, { ctx = null, maxTokens = null } = {}) {
56
58
  ctx: ctx ?? prof.ctx ?? CTX_DEFAULT,
57
59
  maxTokens: maxTokens ?? prof.maxTokens ?? null,
58
60
  streaming: prof.streaming ?? false,
61
+ 잠잠: prof.잠잠 ?? prof.streamIdleMs ?? 잠잠기본,
62
+ 인증서: 인증서설정(prof),
59
63
  tools: prof.tools ?? false,
60
64
  json: prof.json ?? false,
61
65
  think: prof.think ?? false,