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.
- package/README.ko.md +118 -17
- package/README.md +119 -16
- package/bin/deel.js +430 -6
- package/package.json +1 -1
- package/src/acp/serve.js +39 -1
- package/src/agent/agents.js +203 -0
- package/src/agent/asks.js +58 -2
- package/src/agent/budget.js +33 -0
- package/src/agent/effort.js +69 -0
- package/src/agent/loop.js +219 -19
- package/src/agent/models.js +4 -0
- package/src/agent/modes.js +179 -17
- package/src/agent/outschema.js +351 -0
- package/src/agent/route.js +119 -8
- package/src/agent/session.js +13 -2
- package/src/backend/adapter.js +94 -7
- package/src/backend/clientcert.js +164 -0
- package/src/backend/http.js +232 -26
- package/src/backend/mcp.js +194 -30
- package/src/backend/wire.js +10 -0
- package/src/cmdnames.js +3 -0
- package/src/commands.js +158 -15
- package/src/completion.js +35 -4
- package/src/config.js +86 -9
- package/src/configexplain.js +177 -0
- package/src/doctor.js +195 -0
- package/src/i18n/en.js +50 -0
- package/src/i18n/ja.js +50 -0
- package/src/i18n/ko.js +50 -0
- package/src/i18n/zh.js +50 -0
- package/src/lsp/client.js +2 -2
- package/src/oneshot.js +181 -4
- package/src/pack/sbom.js +77 -3
- package/src/pack/selfpack.js +1 -1
- package/src/pack/sheet.en.js +1 -1
- package/src/repl.js +74 -3
- package/src/safety/hooks.js +389 -0
- package/src/safety/policy.js +74 -0
- package/src/safety/shellenv.js +116 -0
- package/src/safety/trust.js +230 -0
- package/src/setup.js +9 -0
- package/src/stats.js +162 -0
- package/src/tools/doc2md.js +266 -0
- package/src/tools/docs.js +26 -6
- package/src/tools/fig.js +393 -0
- package/src/tools/hwpxwrite.js +268 -0
- package/src/tools/index.js +176 -16
- package/src/tools/jobs.js +11 -6
- package/src/tools/kiwi.js +293 -0
- package/src/tools/spawn.js +19 -1
- package/src/tools/task.js +8 -2
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// 답을 **정해진 모양**으로 받는다 — `deel run --output-schema`.
|
|
2
|
+
//
|
|
3
|
+
// ── 왜 필요한가 ────────────────────────────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// `deel run` 은 글을 낸다. 사람이 읽으면 그만이지만, 스크립트에 물리면
|
|
6
|
+
// 거기서부터 일이 시작된다.
|
|
7
|
+
//
|
|
8
|
+
// deel run "이 계약서에서 계약 기간과 해지 조건을 뽑아 줘" spec.hwpx | ???
|
|
9
|
+
//
|
|
10
|
+
// 뒤에 무엇을 붙일 수 있나. 답이 매번 다른 모양으로 오므로 `grep` 이나
|
|
11
|
+
// `sed` 로 뜯어야 하고, 그 뜯는 코드는 모델이 다음번에 문장을 조금 다르게
|
|
12
|
+
// 쓰는 날 조용히 틀린다. **조용히** 가 핵심이다 — 빈 값을 받은 스크립트는
|
|
13
|
+
// 오류를 안 내고 그냥 빈 값으로 다음 단계에 넘긴다.
|
|
14
|
+
//
|
|
15
|
+
// 모양을 미리 못 박으면 이 문제가 통째로 사라진다.
|
|
16
|
+
//
|
|
17
|
+
// deel run --output-schema 계약.schema.json "..." | jq -r .해지조건
|
|
18
|
+
//
|
|
19
|
+
// 그러면 이 프로그램은 **셸 파이프라인에서 부를 수 있는 함수**가 된다.
|
|
20
|
+
// doc2md 와 붙이면 「hwpx 계약서 → 구조화 JSON」 이 되는데, 그건 지금
|
|
21
|
+
// 어느 경쟁 도구도 못 한다.
|
|
22
|
+
//
|
|
23
|
+
// ── 왜 우리가 직접 재나 ────────────────────────────────────────────────
|
|
24
|
+
//
|
|
25
|
+
// 「JSON 으로 답해 줘」 라고 부탁만 하고 안 재는 길도 있다. 그런데 그러면
|
|
26
|
+
// 지키는 날과 안 지키는 날이 생기고, 안 지킨 날에 스크립트가 깨진다 —
|
|
27
|
+
// 그것도 우리가 아니라 **뒤에 붙은 남의 코드**에서 깨진다. 부탁은 계약이
|
|
28
|
+
// 아니다. 계약이라고 부르려면 재야 한다.
|
|
29
|
+
//
|
|
30
|
+
// 그래서 받아 놓고 여기서 맞춰 본다. 안 맞으면 무엇이 어떻게 안 맞는지를
|
|
31
|
+
// 모델에게 그대로 돌려주고 한 번 더 시킨다. 그래도 안 맞으면 **0 이 아닌
|
|
32
|
+
// 값으로 끝낸다.** 모양이 안 맞는 JSON 을 표준출력으로 흘려보내는 것이
|
|
33
|
+
// 여기서 할 수 있는 제일 나쁜 일이다.
|
|
34
|
+
//
|
|
35
|
+
// ── 무엇을 안 하나 ─────────────────────────────────────────────────────
|
|
36
|
+
//
|
|
37
|
+
// **`$ref` 로 바깥을 안 본다.** JSON Schema 는 `$ref` 에 주소를 적을 수 있고,
|
|
38
|
+
// 검사기 대부분이 그걸 받아 온다. 우리는 안 받아 온다 — 스키마 파일 하나가
|
|
39
|
+
// 이 프로그램의 「나가는 주소는 사람이 정한 하나뿐」 을 깨뜨리는 길이 되면
|
|
40
|
+
// 안 된다. 같은 파일 안(`#/$defs/...`)만 따라간다.
|
|
41
|
+
//
|
|
42
|
+
// 규격을 다 받지도 않는다. 아래 목록에 없는 열쇠는 **본 척하지 않고 그냥
|
|
43
|
+
// 넘긴다.** 모르는 규칙을 아는 척 통과시키는 것이 모르는 채로 두는 것보다
|
|
44
|
+
// 나쁘다 — 사람은 잰 줄 알고 지낸다. 그래서 `모르는열쇠()` 로 무엇을 안
|
|
45
|
+
// 봤는지 셀 수 있게 해 두고, 화면이 그것을 말한다.
|
|
46
|
+
import { readFileSync } from 'node:fs';
|
|
47
|
+
|
|
48
|
+
/** 우리가 실제로 재는 열쇠들. 여기 없는 것은 안 재고, 안 쟀다고 말한다. */
|
|
49
|
+
export const 아는열쇠 = Object.freeze([
|
|
50
|
+
'type', 'enum', 'const',
|
|
51
|
+
'properties', 'required', 'additionalProperties',
|
|
52
|
+
'items', 'minItems', 'maxItems',
|
|
53
|
+
'minLength', 'maxLength', 'pattern',
|
|
54
|
+
'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
|
|
55
|
+
'anyOf', 'oneOf', 'allOf', 'not',
|
|
56
|
+
'$ref', '$defs', 'definitions',
|
|
57
|
+
// 재는 것이 아니라 적어 두는 것들. 모델에게 보여 줄 때 쓴다.
|
|
58
|
+
'$schema', '$id', 'title', 'description', 'default', 'examples',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const 아는것 = new Set(아는열쇠);
|
|
62
|
+
// 그 아래 열쇠가 규격 낱말이 아니라 '사람이 지은 칸 이름' 인 자리.
|
|
63
|
+
const 이름칸 = new Set(['properties', '$defs', 'definitions']);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 이 스키마에서 우리가 안 보는 열쇠들.
|
|
67
|
+
*
|
|
68
|
+
* 조용히 넘기면 사람은 잰 줄 알고 지낸다. 세어서 화면이 말하게 한다.
|
|
69
|
+
*/
|
|
70
|
+
export function 모르는열쇠(스키마, 본것 = new Set()) {
|
|
71
|
+
const out = new Set();
|
|
72
|
+
const 훑기 = (s) => {
|
|
73
|
+
if (!s || typeof s !== 'object' || 본것.has(s)) return;
|
|
74
|
+
본것.add(s);
|
|
75
|
+
if (Array.isArray(s)) { for (const x of s) 훑기(x); return; }
|
|
76
|
+
for (const [k, v] of Object.entries(s)) {
|
|
77
|
+
if (!아는것.has(k)) out.add(k);
|
|
78
|
+
if (!v || typeof v !== 'object') continue;
|
|
79
|
+
/*
|
|
80
|
+
* `properties`·`$defs` 아래 열쇠는 **사람이 지은 칸 이름**이지 규격
|
|
81
|
+
* 낱말이 아니다. 그걸 세면 칸을 하나 만들 때마다 「안 재는 열쇠」 가
|
|
82
|
+
* 하나씩 늘어난다 — 그러면 이 경고는 아무도 안 읽는 소음이 되고,
|
|
83
|
+
* 진짜로 안 재는 낱말(format 같은 것)이 그 속에 묻힌다.
|
|
84
|
+
*/
|
|
85
|
+
if (이름칸.has(k)) { for (const x of Object.values(v)) 훑기(x); }
|
|
86
|
+
else 훑기(v);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
훑기(스키마);
|
|
90
|
+
return [...out].sort();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 스키마 파일을 읽는다.
|
|
95
|
+
*
|
|
96
|
+
* 못 읽으면 **일을 시작하지 않는다.** 스키마가 없는데 그냥 도는 것은
|
|
97
|
+
* 「모양을 못 박아 달라」 는 부탁을 조용히 무시하는 것이고, 그 결과는 파이프
|
|
98
|
+
* 뒤에서 터진다.
|
|
99
|
+
*
|
|
100
|
+
* @returns {{ok: true, 스키마: object, 모른것: string[]} | {ok: false, 왜: string}}
|
|
101
|
+
*/
|
|
102
|
+
export function 스키마읽기(경로) {
|
|
103
|
+
let 글;
|
|
104
|
+
try { 글 = readFileSync(경로, 'utf8'); }
|
|
105
|
+
catch (err) { return { ok: false, 왜: `스키마 파일을 못 읽었습니다: ${경로}\n ${err?.message ?? err}` }; }
|
|
106
|
+
let s;
|
|
107
|
+
try { s = JSON.parse(글); }
|
|
108
|
+
catch (err) { return { ok: false, 왜: `스키마가 JSON 이 아닙니다: ${경로}\n ${err?.message ?? err}` }; }
|
|
109
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) {
|
|
110
|
+
return { ok: false, 왜: `스키마는 객체여야 합니다: ${경로}` };
|
|
111
|
+
}
|
|
112
|
+
// 바깥을 가리키는 $ref 는 여기서 막는다. 읽고 나서 막으면 이미 늦다.
|
|
113
|
+
const 바깥 = 바깥ref찾기(s);
|
|
114
|
+
if (바깥.length) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
왜: `스키마가 바깥을 가리킵니다: ${바깥.slice(0, 3).join(' · ')}\n`
|
|
118
|
+
+ ' 이 프로그램은 스키마를 받아 오지 않습니다 — 나가는 주소는 사람이 정한 하나뿐이어야 합니다.\n'
|
|
119
|
+
+ ' 같은 파일 안(#/$defs/...)으로 바꿔 주세요.',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, 스키마: s, 모른것: 모르는열쇠(s) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 같은 파일 안(`#`으로 시작)이 아닌 `$ref` 를 다 찾는다. */
|
|
126
|
+
export function 바깥ref찾기(스키마, 본것 = new Set()) {
|
|
127
|
+
const out = [];
|
|
128
|
+
const 훑기 = (s) => {
|
|
129
|
+
if (!s || typeof s !== 'object' || 본것.has(s)) return;
|
|
130
|
+
본것.add(s);
|
|
131
|
+
if (Array.isArray(s)) { for (const x of s) 훑기(x); return; }
|
|
132
|
+
if (typeof s.$ref === 'string' && !s.$ref.startsWith('#')) out.push(s.$ref);
|
|
133
|
+
for (const v of Object.values(s)) if (v && typeof v === 'object') 훑기(v);
|
|
134
|
+
};
|
|
135
|
+
훑기(스키마);
|
|
136
|
+
return [...new Set(out)];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `#/$defs/이름` 같은 자리를 뿌리에서 따라간다. 못 찾으면 null. */
|
|
140
|
+
function ref따라가기(뿌리, ref) {
|
|
141
|
+
if (typeof ref !== 'string' || !ref.startsWith('#')) return null;
|
|
142
|
+
const 길 = ref.slice(1).split('/').filter(Boolean)
|
|
143
|
+
.map((x) => decodeURIComponent(x.replace(/~1/g, '/').replace(/~0/g, '~')));
|
|
144
|
+
let 여기 = 뿌리;
|
|
145
|
+
for (const 조각 of 길) {
|
|
146
|
+
if (!여기 || typeof 여기 !== 'object') return null;
|
|
147
|
+
여기 = 여기[조각];
|
|
148
|
+
}
|
|
149
|
+
return 여기 && typeof 여기 === 'object' ? 여기 : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const 갈래 = (v) => {
|
|
153
|
+
if (v === null) return 'null';
|
|
154
|
+
if (Array.isArray(v)) return 'array';
|
|
155
|
+
if (Number.isInteger(v)) return 'integer';
|
|
156
|
+
return typeof v === 'number' ? 'number' : typeof v;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const 갈래맞나 = (v, t) => {
|
|
160
|
+
if (t === 'integer') return Number.isInteger(v);
|
|
161
|
+
if (t === 'number') return typeof v === 'number' && Number.isFinite(v);
|
|
162
|
+
if (t === 'array') return Array.isArray(v);
|
|
163
|
+
if (t === 'null') return v === null;
|
|
164
|
+
if (t === 'object') return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
165
|
+
return typeof v === t;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 값이 스키마에 맞나.
|
|
170
|
+
*
|
|
171
|
+
* 탈은 **다 모아서** 준다. 하나만 주면 고치고 다시 돌리고를 반복하게 되는데,
|
|
172
|
+
* 그 한 바퀴가 모델 호출 한 번이라 값이 비싸다. 그리고 사람이 스키마를
|
|
173
|
+
* 잘못 적은 경우에도 한 번에 다 보이는 편이 낫다.
|
|
174
|
+
*
|
|
175
|
+
* @returns {{ok: boolean, 탈: string[]}}
|
|
176
|
+
*/
|
|
177
|
+
export function 맞나(값, 스키마, { 뿌리 = 스키마, 자리 = '' } = {}) {
|
|
178
|
+
const 탈 = [];
|
|
179
|
+
const 여기 = 자리 || '(뿌리)';
|
|
180
|
+
const s = 스키마;
|
|
181
|
+
if (!s || typeof s !== 'object') return { ok: true, 탈 };
|
|
182
|
+
|
|
183
|
+
// true/false 스키마와 $ref 를 먼저 푼다.
|
|
184
|
+
if (typeof s.$ref === 'string') {
|
|
185
|
+
const 간것 = ref따라가기(뿌리, s.$ref);
|
|
186
|
+
if (!간것) return { ok: false, 탈: [`${여기}: 스키마 안에 ${s.$ref} 가 없습니다`] };
|
|
187
|
+
return 맞나(값, 간것, { 뿌리, 자리 });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (Array.isArray(s.type) ? !s.type.some((t) => 갈래맞나(값, t)) : (s.type && !갈래맞나(값, s.type))) {
|
|
191
|
+
탈.push(`${여기}: ${Array.isArray(s.type) ? s.type.join('|') : s.type} 이어야 하는데 ${갈래(값)} 입니다`);
|
|
192
|
+
// 갈래부터 틀리면 그 아래는 재 봐야 헛말만 늘어난다.
|
|
193
|
+
return { ok: false, 탈 };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (Array.isArray(s.enum) && !s.enum.some((x) => JSON.stringify(x) === JSON.stringify(값))) {
|
|
197
|
+
탈.push(`${여기}: ${s.enum.map((x) => JSON.stringify(x)).join(' · ')} 중 하나여야 합니다`);
|
|
198
|
+
}
|
|
199
|
+
if ('const' in s && JSON.stringify(s.const) !== JSON.stringify(값)) {
|
|
200
|
+
탈.push(`${여기}: ${JSON.stringify(s.const)} 여야 합니다`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (typeof 값 === 'string') {
|
|
204
|
+
if (Number.isFinite(s.minLength) && [...값].length < s.minLength) 탈.push(`${여기}: ${s.minLength}자 이상이어야 합니다`);
|
|
205
|
+
if (Number.isFinite(s.maxLength) && [...값].length > s.maxLength) 탈.push(`${여기}: ${s.maxLength}자 이하여야 합니다`);
|
|
206
|
+
if (typeof s.pattern === 'string') {
|
|
207
|
+
// 스키마를 적은 사람의 정규식이 잘못됐을 수 있다. 그것도 탈로 말한다 —
|
|
208
|
+
// 조용히 통과시키면 안 재고 있는 규칙이 하나 생긴다.
|
|
209
|
+
try { if (!new RegExp(s.pattern).test(값)) 탈.push(`${여기}: ${s.pattern} 모양이어야 합니다`); }
|
|
210
|
+
catch { 탈.push(`${여기}: 스키마의 pattern 이 잘못된 정규식입니다 (${s.pattern})`); }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (typeof 값 === 'number') {
|
|
215
|
+
if (Number.isFinite(s.minimum) && 값 < s.minimum) 탈.push(`${여기}: ${s.minimum} 이상이어야 합니다`);
|
|
216
|
+
if (Number.isFinite(s.maximum) && 값 > s.maximum) 탈.push(`${여기}: ${s.maximum} 이하여야 합니다`);
|
|
217
|
+
if (Number.isFinite(s.exclusiveMinimum) && 값 <= s.exclusiveMinimum) 탈.push(`${여기}: ${s.exclusiveMinimum} 보다 커야 합니다`);
|
|
218
|
+
if (Number.isFinite(s.exclusiveMaximum) && 값 >= s.exclusiveMaximum) 탈.push(`${여기}: ${s.exclusiveMaximum} 보다 작아야 합니다`);
|
|
219
|
+
if (Number.isFinite(s.multipleOf) && s.multipleOf > 0) {
|
|
220
|
+
const 나눈것 = 값 / s.multipleOf;
|
|
221
|
+
if (Math.abs(나눈것 - Math.round(나눈것)) > 1e-9) 탈.push(`${여기}: ${s.multipleOf} 의 배수여야 합니다`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (Array.isArray(값)) {
|
|
226
|
+
if (Number.isFinite(s.minItems) && 값.length < s.minItems) 탈.push(`${여기}: ${s.minItems}개 이상이어야 합니다`);
|
|
227
|
+
if (Number.isFinite(s.maxItems) && 값.length > s.maxItems) 탈.push(`${여기}: ${s.maxItems}개 이하여야 합니다`);
|
|
228
|
+
if (s.items && typeof s.items === 'object' && !Array.isArray(s.items)) {
|
|
229
|
+
값.forEach((v, i) => 탈.push(...맞나(v, s.items, { 뿌리, 자리: `${여기}[${i}]` }).탈));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (값 !== null && typeof 값 === 'object' && !Array.isArray(값)) {
|
|
234
|
+
for (const k of Array.isArray(s.required) ? s.required : []) {
|
|
235
|
+
if (!Object.prototype.hasOwnProperty.call(값, k)) 탈.push(`${여기}: ${k} 칸이 없습니다`);
|
|
236
|
+
}
|
|
237
|
+
const 칸들 = s.properties && typeof s.properties === 'object' ? s.properties : {};
|
|
238
|
+
for (const [k, v] of Object.entries(값)) {
|
|
239
|
+
if (Object.prototype.hasOwnProperty.call(칸들, k)) {
|
|
240
|
+
탈.push(...맞나(v, 칸들[k], { 뿌리, 자리: 여기 === '(뿌리)' ? k : `${여기}.${k}` }).탈);
|
|
241
|
+
} else if (s.additionalProperties === false) {
|
|
242
|
+
탈.push(`${여기}: ${k} 는 적으면 안 되는 칸입니다`);
|
|
243
|
+
} else if (s.additionalProperties && typeof s.additionalProperties === 'object') {
|
|
244
|
+
탈.push(...맞나(v, s.additionalProperties, { 뿌리, 자리: 여기 === '(뿌리)' ? k : `${여기}.${k}` }).탈);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (Array.isArray(s.allOf)) for (const x of s.allOf) 탈.push(...맞나(값, x, { 뿌리, 자리 }).탈);
|
|
250
|
+
if (Array.isArray(s.anyOf) && !s.anyOf.some((x) => 맞나(값, x, { 뿌리, 자리 }).ok)) {
|
|
251
|
+
탈.push(`${여기}: anyOf 중 어느 것에도 안 맞습니다`);
|
|
252
|
+
}
|
|
253
|
+
if (Array.isArray(s.oneOf)) {
|
|
254
|
+
const 맞은수 = s.oneOf.filter((x) => 맞나(값, x, { 뿌리, 자리 }).ok).length;
|
|
255
|
+
if (맞은수 !== 1) 탈.push(`${여기}: oneOf 중 정확히 하나에 맞아야 하는데 ${맞은수}개에 맞습니다`);
|
|
256
|
+
}
|
|
257
|
+
if (s.not && typeof s.not === 'object' && 맞나(값, s.not, { 뿌리, 자리 }).ok) {
|
|
258
|
+
탈.push(`${여기}: not 에 걸립니다`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return { ok: 탈.length === 0, 탈 };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 모델이 낸 글에서 JSON 을 뽑는다.
|
|
266
|
+
*
|
|
267
|
+
* 「JSON 만 내라」 고 적어 둬도 모델은 ```json 울타리를 두르거나 앞에 한 줄
|
|
268
|
+
* 인사를 붙인다. 그걸 실패로 치면 멀쩡한 답을 버리게 되므로, **뽑을 수 있으면
|
|
269
|
+
* 뽑는다.** 다만 뽑아 놓고 아무 말도 안 하면 안 된다 — 무엇을 걷어냈는지
|
|
270
|
+
* 돌려줘서 화면이 말하게 한다.
|
|
271
|
+
*
|
|
272
|
+
* @returns {{ok: true, 값: any, 군말: boolean} | {ok: false, 왜: string}}
|
|
273
|
+
*/
|
|
274
|
+
export function 답에서JSON뽑기(글) {
|
|
275
|
+
const s = String(글 ?? '').trim();
|
|
276
|
+
if (!s) return { ok: false, 왜: '답이 비었습니다' };
|
|
277
|
+
|
|
278
|
+
const 해보기 = (t) => { try { return { ok: true, 값: JSON.parse(t) }; } catch { return null; } };
|
|
279
|
+
|
|
280
|
+
const 그대로 = 해보기(s);
|
|
281
|
+
if (그대로) return { ...그대로, 군말: false };
|
|
282
|
+
|
|
283
|
+
// ```json … ``` 울타리
|
|
284
|
+
const 울타리 = /```(?:json|jsonc)?\s*\n([\s\S]*?)\n?```/i.exec(s);
|
|
285
|
+
if (울타리) {
|
|
286
|
+
const r = 해보기(울타리[1].trim());
|
|
287
|
+
if (r) return { ...r, 군말: true };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/*
|
|
291
|
+
* 앞뒤에 말이 붙은 경우. 첫 `{`(또는 `[`)부터 짝이 맞는 자리까지 잘라 본다.
|
|
292
|
+
*
|
|
293
|
+
* 따옴표 안의 괄호를 안 세는 것이 중요하다 — `{"글": "}"}` 를 깊이로만
|
|
294
|
+
* 세면 첫 `}` 에서 끊긴다.
|
|
295
|
+
*/
|
|
296
|
+
const 시작 = Math.min(...['{', '['].map((ch) => { const i = s.indexOf(ch); return i < 0 ? Infinity : i; }));
|
|
297
|
+
if (Number.isFinite(시작)) {
|
|
298
|
+
const 열림 = s[시작];
|
|
299
|
+
const 닫힘 = 열림 === '{' ? '}' : ']';
|
|
300
|
+
let 깊이 = 0;
|
|
301
|
+
let 글속 = false;
|
|
302
|
+
for (let i = 시작; i < s.length; i++) {
|
|
303
|
+
const ch = s[i];
|
|
304
|
+
if (글속) {
|
|
305
|
+
if (ch === '\\') { i++; continue; }
|
|
306
|
+
if (ch === '"') 글속 = false;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (ch === '"') { 글속 = true; continue; }
|
|
310
|
+
if (ch === 열림) 깊이++;
|
|
311
|
+
else if (ch === 닫힘 && --깊이 === 0) {
|
|
312
|
+
const r = 해보기(s.slice(시작, i + 1));
|
|
313
|
+
if (r) return { ...r, 군말: true };
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return { ok: false, 왜: '답에서 JSON 을 못 찾았습니다' };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 모델에게 붙일 말.
|
|
323
|
+
*
|
|
324
|
+
* 스키마를 통째로 싣는다 — 줄여 적으면 모델이 못 본 규칙을 어기고, 그러면
|
|
325
|
+
* 우리가 재서 되돌리는 한 바퀴가 더 돈다. 그 한 바퀴가 모델 호출 한 번이라
|
|
326
|
+
* 스키마를 다 싣는 것보다 비싸다.
|
|
327
|
+
*/
|
|
328
|
+
export function 시킬말(스키마, { 다시 = null } = {}) {
|
|
329
|
+
const 몸 = [
|
|
330
|
+
'',
|
|
331
|
+
'── 답의 모양이 정해져 있습니다 ─────────────────────────────────',
|
|
332
|
+
'',
|
|
333
|
+
'마지막 답은 **JSON 하나**여야 하고, 아래 스키마에 맞아야 합니다.',
|
|
334
|
+
'설명·인사·```울타리를 붙이지 마세요. JSON 만 내세요.',
|
|
335
|
+
'',
|
|
336
|
+
'```json',
|
|
337
|
+
JSON.stringify(스키마, null, 2),
|
|
338
|
+
'```',
|
|
339
|
+
'',
|
|
340
|
+
'모르는 값은 지어내지 말고, 스키마가 허락하면 null 이나 빈 배열로 두세요.',
|
|
341
|
+
'지어낸 값 하나가 이 답을 받아 쓰는 쪽에서 조용히 틀린 결정이 됩니다.',
|
|
342
|
+
];
|
|
343
|
+
if (다시?.length) {
|
|
344
|
+
몸.push(
|
|
345
|
+
'',
|
|
346
|
+
'방금 낸 답이 아래에서 안 맞았습니다. 그것만 고쳐서 JSON 을 다시 내세요.',
|
|
347
|
+
...다시.map((x) => ` - ${x}`),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return 몸.join('\n');
|
|
351
|
+
}
|
package/src/agent/route.js
CHANGED
|
@@ -31,6 +31,9 @@ const 표 = {
|
|
|
31
31
|
[/error/i, 3], [/\bexception\b|\bcrash|\bfail(s|ed|ing)?\b|\bbroken\b/i, 3],
|
|
32
32
|
[/고장/, 3], [/왜\s*이(래|러|렇게\s*나)/, 2],
|
|
33
33
|
[/왜\s*(못|안)/, 3], [/뭐\s*때문(이|에)/, 3],
|
|
34
|
+
// 원인을 짚으라는 말은 점검이 아니라 디버그다 — 증상이 이미 있다는 뜻이다.
|
|
35
|
+
// 이게 없으면 "오류 원인 분석해줘" 가 둘 사이에서 비겨 종합에 그대로 남는다.
|
|
36
|
+
[/원인(을|이|은)?\s*(찾|짚|파악|밝|알아|분석|살펴|봐)/, 4],
|
|
34
37
|
],
|
|
35
38
|
|
|
36
39
|
plan: [
|
|
@@ -56,9 +59,50 @@ const 표 = {
|
|
|
56
59
|
[/\bdesign\b|\bstructure\b|\brefactor(ing)?\s*(방향|plan)/i, 3],
|
|
57
60
|
],
|
|
58
61
|
|
|
62
|
+
/*
|
|
63
|
+
* 점검 — 「지금 무엇이 잘못돼 있나」 를 찾는 일.
|
|
64
|
+
*
|
|
65
|
+
* 이 갈래가 없던 동안 이런 말이 전부 묻기(ask)로 갔다. 「설명해줘」 한 마디가
|
|
66
|
+
* 5점이라 그것만으로 문턱을 넘기 때문이다. 그런데 묻기는 짧게 답하고, 얕게
|
|
67
|
+
* 생각하고(low), 열두 걸음에서 멈춘다 — 파일 열 개를 훑어야 하는 일에는
|
|
68
|
+
* 셋 다 틀린 값이다. 실제로 그렇게 돌아 본 턴이 있고, 같은 질문을 깊게 본
|
|
69
|
+
* 쪽과 견주면 답의 깊이가 눈에 띄게 달랐다.
|
|
70
|
+
*
|
|
71
|
+
* 여기 모은 낱말은 하나같이 **읽어야 답이 나오는** 말이다. 잘못 고르면
|
|
72
|
+
* 파일을 못 고쳐서 사람이 막히므로, 문턱은 읽기 전용답게 높인다.
|
|
73
|
+
*/
|
|
74
|
+
inspect: [
|
|
75
|
+
[/점검(해|좀|을|이)/, 5], [/분석(해|좀|을|이)/, 5],
|
|
76
|
+
[/검토(해|좀|를)/, 5], [/리뷰(해|좀)?/, 5],
|
|
77
|
+
// '감사합니다' 는 안 걸린다 — '합' 은 '해·하' 가 아니다.
|
|
78
|
+
[/감사(를)?\s*(해|하)/, 4], [/훑어\s*(봐|보고|줘)/, 4],
|
|
79
|
+
[/취약(점|한|성)/, 5], [/보안\s*(점검|검토|문제|취약|구멍)/, 5],
|
|
80
|
+
[/경합|레이스\s*컨디션|race\s*condition/i, 5],
|
|
81
|
+
[/데이터\s*(손실|유실)|덮어쓰(기|면)|유실/, 4],
|
|
82
|
+
[/동시\s*(편집|접근|요청|쓰기|성)/, 4],
|
|
83
|
+
[/충돌/, 3], [/불일치|어긋/, 3], [/누락(된|되는|이|을)/, 3],
|
|
84
|
+
[/위험(한|이|을|해)/, 3], [/결함/, 3],
|
|
85
|
+
[/가능성(이|을)?\s*있는/, 3],
|
|
86
|
+
[/문제(가|를|점)?\s*(있|찾|없|만)/, 3],
|
|
87
|
+
[/찾아\s*(줘|주세요|봐|보)/, 2],
|
|
88
|
+
[/\baudit\b|\breview\b|\banaly[sz]e\b|\binspect\b|\bvulnerab/i, 4],
|
|
89
|
+
[/\bdata\s*loss\b|\bdeadlock\b|\blost\s*update\b/i, 4],
|
|
90
|
+
],
|
|
91
|
+
|
|
59
92
|
ask: [
|
|
60
93
|
// 설명은 아무것도 안 바꾸는 모드라 잘못 고르면 답답해진다. 신호를 엄격히 본다.
|
|
61
94
|
[/뭐(야|예요|입니까|지)\s*\??$/m, 5], [/무엇(인가|입니까|이야)/, 5],
|
|
95
|
+
/*
|
|
96
|
+
* 「뭐 하는 파일이야?」 — 터미널에서 돌려 보다 걸린 자리다.
|
|
97
|
+
*
|
|
98
|
+
* 윗줄은 「뭐」 바로 뒤에 「야」 가 붙어야 걸린다. 그런데 사람이 실제로
|
|
99
|
+
* 치는 말은 「이거 뭐 하는 파일이야?」 처럼 **사이에 말이 낀다.** 그래서
|
|
100
|
+
* 제일 흔한 물음 하나가 통째로 안 걸렸고, 종합 모드에 그대로 남았다.
|
|
101
|
+
* 막히지는 않지만 물어본 것에 답만 하면 되는 턴이 파일을 고칠 수 있는
|
|
102
|
+
* 모드로 돌았다는 뜻이다.
|
|
103
|
+
*/
|
|
104
|
+
[/뭐\s*하는\s*(파일|함수|코드|모듈|클래스|녀석|건|거|것)/, 5],
|
|
105
|
+
[/뭐\s*하(는|나|니|냐)/, 3], [/무슨\s*(일|역할)/, 3],
|
|
62
106
|
[/설명(해|좀|을)/, 5], [/알려\s*(줘|주세요|줄래)/, 3],
|
|
63
107
|
[/어떻게\s*(동작|작동|돌아가)/, 5],
|
|
64
108
|
[/무슨\s*(뜻|의미)|의미(가|는)\s*뭐/, 5],
|
|
@@ -107,6 +151,7 @@ const 문턱 = {
|
|
|
107
151
|
orchestrator: 4,
|
|
108
152
|
plan: 5,
|
|
109
153
|
architect: 5,
|
|
154
|
+
inspect: 5,
|
|
110
155
|
ask: 5,
|
|
111
156
|
};
|
|
112
157
|
|
|
@@ -135,6 +180,45 @@ const 계획먼저 = new RegExp(`(${계획말})\\s*(?:을|를)?\\s*(?:${잇는
|
|
|
135
180
|
// 영어도 같이 본다 — "plan and then build it" 처럼 붙여 넣는 일이 있다.
|
|
136
181
|
const 영어겹침 = /\b(plan|design|outline|draft)\b[\s\S]{0,60}?\b(?:and|then|,)\s*(?:then\s*)?\b(build|implement|create|write|make|code)\b/i;
|
|
137
182
|
|
|
183
|
+
/*
|
|
184
|
+
* ── 두 말이 **얼마나 붙어 있어야** 겹침인가 ─────────────────────────────
|
|
185
|
+
*
|
|
186
|
+
* 예순 자다. 영어 쪽은 처음부터 `[\s\S]{0,60}?` 로 거리를 두고 봤는데
|
|
187
|
+
* 한국어 쪽만 「그 뒤 어디든」 이었다. 그 차이가 긴 글에서 그대로 터졌다.
|
|
188
|
+
*
|
|
189
|
+
* 실제로 겪은 것 — 1,300자짜리 지시문이었다. 「…배포 전 점검한다고 가정하고
|
|
190
|
+
* 전체적으로 검토해줘 … 실제로 개선하는 것이다 … 코드 수정만 수행해라 …
|
|
191
|
+
* 중간에 멈춰서 확인을 요청하지 말고 스스로 진행해라」.
|
|
192
|
+
*
|
|
193
|
+
* 여기서 800자 떨어진 두 조각 — 「검토해서」(9번째 문단)와 「추가하」(보고
|
|
194
|
+
* 항목 이름) — 이 짝지어져 겹침으로 잡혔다. 그래서 **계획 모드**로 갔다.
|
|
195
|
+
* 계획은 파일을 바꾸는 도구가 없는 모드이고, 그 지시문은 「고쳐라 · 테스트를
|
|
196
|
+
* 만들어라 · 멈추지 마라」 였다. 사람은 고쳐 달라고 했는데 계획서 한 장을
|
|
197
|
+
* 받고, 마지막에 「이대로 진행할까요?」 를 봤다 — 묻지 말라고 적어 둔 바로
|
|
198
|
+
* 그 자리에서.
|
|
199
|
+
*
|
|
200
|
+
* 이 규칙이 잡으려던 것은 「정리해서 만들어줘」 같은 **한 문장**이다. 한 문장
|
|
201
|
+
* 안에서 두 말은 예순 자를 안 넘는다. 그러니 거리를 재는 것이 규칙을 좁히는
|
|
202
|
+
* 것이 아니라 **원래 뜻대로 되돌리는 것**이다.
|
|
203
|
+
*/
|
|
204
|
+
const 겹침거리 = 60;
|
|
205
|
+
|
|
206
|
+
/*
|
|
207
|
+
* ── 「묻지 말고 끝까지」 라고 이미 적어 둔 사람 ─────────────────────────
|
|
208
|
+
*
|
|
209
|
+
* 겹침의 값은 「계획을 보여 주고 승인을 받는다」 다. 그런데 승인을 받지
|
|
210
|
+
* 말라고 **글로 적어 둔** 사람에게 그 값은 손해뿐이다. 계획 모드는 계획을
|
|
211
|
+
* 내고 멈추므로, 그 사람은 시킨 일의 절반도 못 받는다.
|
|
212
|
+
*
|
|
213
|
+
* 이건 짐작이 아니라 사람이 직접 쓴 말이다. 쓴 말이 규칙을 이긴다.
|
|
214
|
+
*/
|
|
215
|
+
const 멈추지마 = /(?:중간에\s*)?(?:멈추지|묻지|물어보지|확인\s*요청하지|되묻지)\s*(?:는)?\s*(?:말|마)|끝까지\s*(?:수행|진행|해)|스스로\s*진행|알아서\s*(?:진행|해)|승인\s*없이|\bwithout\s+(?:asking|stopping|confirmation)\b|\bdon'?t\s+(?:stop|ask)\b/i;
|
|
216
|
+
|
|
217
|
+
/** 사람이 「묻지 말고 끝까지 하라」 고 적어 뒀나. */
|
|
218
|
+
export function 묻지말라했나(text) {
|
|
219
|
+
return 멈추지마.test(String(text ?? ''));
|
|
220
|
+
}
|
|
221
|
+
|
|
138
222
|
/**
|
|
139
223
|
* 계획과 실행이 한 말에 같이 들었나.
|
|
140
224
|
*
|
|
@@ -142,14 +226,23 @@ const 영어겹침 = /\b(plan|design|outline|draft)\b[\s\S]{0,60}?\b(?:and|then|
|
|
|
142
226
|
*/
|
|
143
227
|
export function 겹친요청(text) {
|
|
144
228
|
const s = String(text ?? '');
|
|
229
|
+
// 묻지 말라고 적어 둔 사람에게 승인 창을 띄우지 않는다. 위 머리말 참고.
|
|
230
|
+
if (묻지말라했나(s)) return { 겹침: false, why: '' };
|
|
231
|
+
|
|
145
232
|
const 영어 = s.match(영어겹침);
|
|
146
233
|
if (영어) return { 겹침: true, why: `'${영어[1]}' 과 '${영어[2]}'` };
|
|
147
234
|
|
|
148
235
|
const 앞 = s.match(계획먼저);
|
|
149
236
|
if (!앞) return { 겹침: false, why: '' };
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
237
|
+
/*
|
|
238
|
+
* 실행말이 계획말 **바로 뒤에** 와야 한다.
|
|
239
|
+
*
|
|
240
|
+
* 「뒤에」 였지 「글 어디든」 이 아니었다 — 거리를 안 재면 긴 지시문에서는
|
|
241
|
+
* 언제나 짝이 하나쯤 걸린다(위 겹침거리 머리말). "만들고 나서 정리해줘" 가
|
|
242
|
+
* 안 걸리는 것도 그대로다. 그건 만드는 일이 먼저고, 미리 볼 계획이 없다.
|
|
243
|
+
*/
|
|
244
|
+
const 남은것 = s.slice(앞.index + 앞[0].length, 앞.index + 앞[0].length + 겹침거리);
|
|
245
|
+
const 뒤 = 남은것.match(실행말);
|
|
153
246
|
if (!뒤) return { 겹침: false, why: '' };
|
|
154
247
|
return { 겹침: true, why: `'${앞[0].trim()}' 뒤에 '${뒤[0].trim()}'` };
|
|
155
248
|
}
|
|
@@ -173,13 +266,31 @@ export function 겹친요청(text) {
|
|
|
173
266
|
* 하나는 읽고 말하라는 말이고 하나는 고치라는 말이다. 그래서 어미가 아니라
|
|
174
267
|
* **동사 목록**으로 본다. 목록에 없으면 손대라는 말이 아니다.
|
|
175
268
|
*/
|
|
176
|
-
const 손대는동사 = '
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
269
|
+
const 손대는동사 = '만들|고쳐|고치|바꿔|바꾸|추가|넣어|지워|삭제|옮겨|나눠|나누|합쳐|정리|개선|구현|작성|적용|설치|배포|리팩터링|리팩터|분리|통일|세팅|갈아|손봐|손보|수정';
|
|
270
|
+
/*
|
|
271
|
+
* 동사 바로 뒤에 시킴꼴이 붙어야 한다. 빈칸을 안 넘으므로 "나누는 설계만 봐줘"
|
|
272
|
+
* 는 안 걸린다 — 그건 나누라는 말이 아니라 나누는 안을 보자는 말이다.
|
|
273
|
+
*
|
|
274
|
+
* ── 해라체가 통째로 빠져 있었다 ─────────────────────────────────────────
|
|
275
|
+
*
|
|
276
|
+
* 끝맺음 목록이 전부 **부탁하는 말**이었다(줘 · 주세요 · 해라 · 할래). 그래서
|
|
277
|
+
* 시키는 말투로 쓴 사람은 이 검사에 한 번도 안 걸렸다 —
|
|
278
|
+
*
|
|
279
|
+
* 「재현 검사를 먼저 만들어라 … 최소 범위의 코드 수정만 수행해라
|
|
280
|
+
* … 중간에 멈추지 말고 스스로 진행해라」
|
|
281
|
+
*
|
|
282
|
+
* 이 지시문이 「고치라는 말이 아니다」 로 읽혀서 읽기 전용 모드로 갔다.
|
|
283
|
+
* 사람은 고쳐 달라고 세 번 적었는데 아무것도 안 바뀐 화면을 받는다.
|
|
284
|
+
*
|
|
285
|
+
* 「어라 · 아라」 와 맨 「라」 가 그 문을 연다. 맨 라 는 낱말 끝일 때만 본다 —
|
|
286
|
+
* 「지워라」 는 지우라는 말이지만 「바꾸라는 말」 은 아니다. 뒤에 무엇이
|
|
287
|
+
* 붙어 있나로 그 둘이 갈린다.
|
|
288
|
+
* 「만들어라」 는 언제나 만들라는 말이다.
|
|
289
|
+
*/
|
|
290
|
+
const 손대라는말 = new RegExp(`(${손대는동사})\\S{0,3}(줘|주세요|주라|해라|하라|하자|해다오|해줄래|할래|어라|아라|여라|라(?=[\s.!?~,]|$))`);
|
|
180
291
|
|
|
181
292
|
// 파일을 못 고치는 모드. modes.js 에서 쓰기 도구를 안 주는 것들과 같아야 한다.
|
|
182
|
-
const 읽기만하는모드 = new Set(['architect', 'plan', 'ask']);
|
|
293
|
+
const 읽기만하는모드 = new Set(['architect', 'plan', 'ask', 'inspect']);
|
|
183
294
|
|
|
184
295
|
/** 파일을 손대라는 말이 들었나. */
|
|
185
296
|
export function 손대라했나(text) {
|
package/src/agent/session.js
CHANGED
|
@@ -346,7 +346,18 @@ export class Session {
|
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
/**
|
|
349
|
-
* 이 폴더의 규칙 파일. DEEL.md → CLAUDE.md → AGENTS.md
|
|
349
|
+
* 이 폴더의 규칙 파일. DEEL.md → CLAUDE.md → AGENTS.md → GEMINI.md 중
|
|
350
|
+
* 먼저 읽히는 하나.
|
|
351
|
+
*
|
|
352
|
+
* 남의 이름을 읽는 것이 이 목록의 요점이다. 규칙 파일은 사람이 며칠에 걸쳐
|
|
353
|
+
* 다듬어 놓은 것이고, 옮겨 오는 사람에게 「그건 못 읽으니 다시 적으세요」
|
|
354
|
+
* 라고 하면 대부분 안 옮긴다. GEMINI.md 를 뒤에 붙인 것은 Gemini CLI 가
|
|
355
|
+
* 2026-06-18 로 개인 사용자를 안 받게 되면서 그 파일을 든 채로 갈 곳을
|
|
356
|
+
* 찾는 사람이 실제로 생겼기 때문이다.
|
|
357
|
+
*
|
|
358
|
+
* 순서는 **좁은 것부터**다. DEEL.md 는 우리한테 하는 말이라 제일 세고,
|
|
359
|
+
* GEMINI.md 는 남한테 하던 말이라 제일 약하다. 두 개가 같이 있는 폴더에서
|
|
360
|
+
* 우리 이름을 진 파일이 지는 일은 없어야 한다.
|
|
350
361
|
*
|
|
351
362
|
* 없는 것은 그냥 없는 것이라 아무 말도 안 한다. 그런데 **있는데 못 읽는**
|
|
352
363
|
* 것까지 같이 삼키면 안 된다 — 권한이 막혔거나 같은 이름의 폴더가 있으면
|
|
@@ -356,7 +367,7 @@ export class Session {
|
|
|
356
367
|
* 못 읽은 것은 적어 두고 /status 가 '없음' 대신 그 까닭을 말한다.
|
|
357
368
|
*/
|
|
358
369
|
#loadRules() {
|
|
359
|
-
for (const name of ['DEEL.md', 'CLAUDE.md', 'AGENTS.md']) {
|
|
370
|
+
for (const name of ['DEEL.md', 'CLAUDE.md', 'AGENTS.md', 'GEMINI.md']) {
|
|
360
371
|
try { return { name, text: readFileSync(join(this.root, name), 'utf8').slice(0, 20000) }; }
|
|
361
372
|
catch (err) {
|
|
362
373
|
// 없으면 그냥 없는 것이다 — 말할 일이 아니다. existsSync 로 먼저 보지
|