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
@@ -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/asks.js CHANGED
@@ -93,6 +93,59 @@ const 시킴꼴 = /(줘|주세요|주라|해라|하라|하자|해다오|할래|
93
93
  // 줄머리 표. 번호(1. 2))·글머리(- * ·)·동그라미 숫자.
94
94
  const 줄머리 = /^\s*(?:(\d{1,2})\s*[.)]|[-*·▪️•]|[①-⑮])\s+/;
95
95
 
96
+ /*
97
+ * ── 시킨 것이 아니라 **조건**인 줄 ─────────────────────────────────────
98
+ *
99
+ * 실제로 이렇게 틀렸다. 사람이 친 말은 이랬다 —
100
+ *
101
+ * 이 기능을 분석해서 … 부분을 찾아줘.
102
+ * 각 문제마다 관련 파일과 함수, 발생 조건, 원인을 설명해줘.
103
+ * 코드는 수정하지 마.
104
+ * 최대 5개까지만 찾아줘.
105
+ *
106
+ * 네 줄 중 셋이 시킴꼴이라 **항목 셋**으로 잡혔고, 마지막 줄이 자국에 안
107
+ * 닿아서 이런 말이 나갔다 —
108
+ *
109
+ * 「시킨 것 중 1가지에 아직 손도 안 댔습니다: 3. 최대 5개까지만 찾아줘.
110
+ * 지금 하세요.」
111
+ *
112
+ * 그런데 「최대 5개까지만」 은 **할 일이 아니라 1번에 붙은 조건**이다. 이미
113
+ * 지킨 것을 안 했다고 말한 것이고, 이 파일 맨 위에 「제일 나쁘다」 고 적어
114
+ * 둔 바로 그 짓이다 — 한 번 이러면 그 뒤로 이 검사를 아무도 안 믿는다.
115
+ *
116
+ * ── 어떻게 가르나 ───────────────────────────────────────────────────────
117
+ *
118
+ * 낱말로 가른다. 그 줄에서 **알맹이를 다 빼고 나면 무엇이 남는가.** 수와
119
+ * 한계말과 「찾아줘」 같은 맨 동사밖에 안 남으면, 그 줄은 무엇을 하라는
120
+ * 말이 아니라 얼마나 하라는 말이다.
121
+ *
122
+ * 이렇게 하면 진짜 할 일은 안 걸린다 — 「테스트 최대 10개 만들어줘」 에는
123
+ * '테스트' 가 남아서 조건이 아니다. 가르는 자리를 수가 아니라 **남는 알맹이**
124
+ * 에 두었기 때문이다.
125
+ */
126
+ const 한계말 = new Set([
127
+ '최대', '최소', '많아야', '적어도', '이내', '이하', '이상', '까지', '정도', '넘게',
128
+ '최대한', '이상은', '개', '가지', '건', '줄', '짧게', '간단히', '간결하게', '되도록',
129
+ 'max', 'min', 'most', 'least', 'only', 'up',
130
+ ]);
131
+ // 무엇을 하라는 말이 아니라 그냥 「내놔라」 인 동사들. 이것만 남으면 알맹이가 없다.
132
+ const 맨동사 = new Set([
133
+ '찾아줘', '찾아', '찾기', '골라줘', '골라', '뽑아줘', '뽑아', '적어줘', '적어',
134
+ '알려줘', '보여줘', '제시해', '제시', '내줘', '달라', '말해줘', '써줘',
135
+ ]);
136
+ // 「…하지 마」 는 무슨 일이 있어도 할 일이 아니다. 안 하는 것이 다 한 것이다.
137
+ const 하지마 = /(?:하지|치지|시키지|되지|넣지|쓰지|고치지|바꾸지|건드리지|만지지)\s*(?:는)?\s*(?:마|말)/;
138
+ const 하지마En = /\b(?:do not|don'?t|never|without)\b/i;
139
+
140
+ /** 이 줄이 할 일이 아니라 그 일에 붙은 조건인가. */
141
+ export function 조건줄인가(글) {
142
+ const s = String(글 ?? '');
143
+ if (하지마.test(s) || 하지마En.test(s)) return true;
144
+ const 낱말 = 알맹이낱말(s);
145
+ if (!낱말.length) return false; // 잴 것이 없으면 안 건다 — 빠진것 이 따로 거른다
146
+ return 낱말.every((w) => 한계말.has(w) || 맨동사.has(w) || /^\d/.test(w));
147
+ }
148
+
96
149
  /**
97
150
  * 시킨 것을 항목으로 쪼갠다.
98
151
  *
@@ -100,6 +153,9 @@ const 줄머리 = /^\s*(?:(\d{1,2})\s*[.)]|[-*·▪️•]|[①-⑮])\s+/;
100
153
  * 문장 하나를 쉼표로 더 쪼개지는 않는다. 쪼갤수록 없는 항목이 생기고,
101
154
  * 없는 항목을 "안 했다" 고 말하는 것이 제일 나쁘다.
102
155
  *
156
+ * 조건인 줄은 항목에서 뺀다(조건줄인가). 개수 제한과 금지는 **지켰을 때
157
+ * 자국이 안 남는** 종류라, 그대로 두면 지킨 것이 안 한 것으로 잡힌다.
158
+ *
103
159
  * @returns {Array<{번호:number, 글:string}>}
104
160
  */
105
161
  export function 요청항목들(요청) {
@@ -111,12 +167,12 @@ export function 요청항목들(요청) {
111
167
  const m = 줄머리.exec(줄);
112
168
  if (!m) continue;
113
169
  const 글 = 줄.slice(m[0].length).trim();
114
- if (글) 표붙은것.push(글);
170
+ if (글 && !조건줄인가(글)) 표붙은것.push(글);
115
171
  }
116
172
  if (표붙은것.length >= 2) return 표붙은것.map((글, i) => ({ 번호: i + 1, 글 }));
117
173
 
118
174
  // 2) 표는 없지만 줄마다 시키는 꼴
119
- const 시킨줄 = 줄들.map((l) => l.trim()).filter((l) => l && 시킴꼴.test(l));
175
+ const 시킨줄 = 줄들.map((l) => l.trim()).filter((l) => l && 시킴꼴.test(l) && !조건줄인가(l));
120
176
  if (시킨줄.length >= 2) return 시킨줄.map((글, i) => ({ 번호: i + 1, 글 }));
121
177
 
122
178
  return [];
@@ -33,6 +33,18 @@ const 걸음규칙 = {
33
33
  ask: { 배수: 0.25, 최소: 4, 최대: 12 },
34
34
  plan: { 배수: 0.6, 최소: 8, 최대: 40 },
35
35
  architect: { 배수: 0.7, 최소: 8, 최대: 48 },
36
+ /*
37
+ * 점검은 **묻기보다 훨씬 넉넉해야** 한다.
38
+ *
39
+ * 이 값이 왜 여기 적혀 있는지가 중요하다. 같은 일이 묻기(ask)로 갔을 때
40
+ * 걸음이 열두 번에서 멈췄다 — 배수 0.25 에 최대 12 라, 창이 200k 여도 12 다.
41
+ * 파일 열 개를 훑어야 하는 점검에 열두 걸음이면 절반쯤 보고 답을 낸다.
42
+ * 그런데 화면에는 「다 봤다」 와 구별되는 표시가 아무것도 안 뜬다.
43
+ *
44
+ * 설계보다도 넉넉히 준다. 설계는 몇 파일의 관계를 보면 되지만 점검은
45
+ * **다 뒤져야** 한다 — 안 본 자리에 있는 결함은 없는 것과 같다.
46
+ */
47
+ inspect: { 배수: 1.2, 최소: 16, 최대: 120 },
36
48
  auto: { 배수: 1.5, 최소: 16, 최대: 200 },
37
49
  code: { 배수: 1.5, 최소: 16, 최대: 200 },
38
50
  debug: { 배수: 2, 최소: 16, 최대: 250 },
@@ -101,6 +113,27 @@ export function 웹글자수(ctx) {
101
113
  return 사이(k(ctx) * 400, 4000, 120000);
102
114
  }
103
115
 
116
+ /**
117
+ * 도구 하나가 한 번에 돌려줄 글자 수.
118
+ *
119
+ * ── 여기만 고정 숫자로 남아 있었다 ──────────────────────────────────────
120
+ *
121
+ * 이 파일 맨 위에 「고정한 것 자체가 틀린 것」 이라고 적어 놓고, 정작 도구
122
+ * 결과 상한만 30,000자로 박혀 있었다(tools/index.js 의 MAX_OUT). 그쪽 주석은
123
+ * 「모델에 맞춰 정한다」 고 말하고 있었으므로, 글과 코드가 서로 다른 말을
124
+ * 하는 자리이기도 했다.
125
+ *
126
+ * 8k 짜리 모델에 30,000자면 한 번으로 창이 넘친다. 넘치면 접히고, 접히면
127
+ * 앞엣말을 잊는다 — 화면에는 아무 표시가 없고, 사람 눈에는 그냥 "모델이
128
+ * 멍청해졌다" 로 보인다. 이 파일이 없애려던 고장이 바로 그것이다.
129
+ *
130
+ * 큰 창에서는 여태와 같은 값에 선다(30,000). 그 자리는 문제가 없었고,
131
+ * 문제 없던 자리를 같이 바꾸면 무엇이 좋아졌는지 못 가린다.
132
+ */
133
+ export function 한번에낼글자수(ctx) {
134
+ return 사이(k(ctx) * 160, 2000, 30000);
135
+ }
136
+
104
137
  /**
105
138
  * 하위 작업이 돌 걸음 수.
106
139
  *
@@ -180,6 +180,75 @@ export function 가벼운가(글) {
180
180
  return true;
181
181
  }
182
182
 
183
+ /*
184
+ * ── 반대쪽 문: **깊이 봐야 하는 말** ────────────────────────────────────
185
+ *
186
+ * 위 `가벼운가` 는 강도를 내리는 문이다. 이건 그 문을 **잠그는** 자리다.
187
+ *
188
+ * 왜 필요한가 — 모드가 강도를 낮추는 자리가 있다(modes.js 의 묻기: low).
189
+ * 그 값이 옳은 자리는 "이 함수 뭐야?" 같은 한 줄짜리다. 그런데 무엇이
190
+ * 묻기 모드로 가는지는 낱말이 정한다(route.js). "…설명해줘" 한 마디면
191
+ * 간다. 그래서 이런 것도 같이 갔다 —
192
+ *
193
+ * 「이 프로젝트의 공동작업 기능을 분석해서 동시 편집 시 데이터 손실,
194
+ * 덮어쓰기, 충돌 또는 상태 불일치가 발생할 가능성이 있는 부분을 찾아줘.
195
+ * 실제 코드 근거가 있는 문제만 제시하고 각 문제마다 관련 파일과 함수,
196
+ * 발생 조건, 원인을 설명해줘. 코드는 수정하지 마. 최대 5개까지만 찾아줘.」
197
+ *
198
+ * 파일 열 개를 읽고 경합 조건을 짚어야 하는 일인데, 「짧게 답하라」 는 글과
199
+ * `low` 를 달고 돌았다. 재 보면 이렇다 — 같은 프로젝트, 같은 질문 —
200
+ *
201
+ * 생각 블록: 이쪽 0자 · 깊게 본 쪽 165,000자
202
+ *
203
+ * 값이 5분의 1이면 싼 것이지만, **답이 다르면 싼 게 아니다.**
204
+ *
205
+ * ── 무엇으로 가르나 ─────────────────────────────────────────────────────
206
+ *
207
+ * 시킨 말의 **모양**으로 가른다. 줄이 여럿이면 시킨 것이 여럿이고, 길면
208
+ * 조건이 붙어 있고, 살펴보라는 낱말이 있으면 읽어야 답이 나온다. 셋 중
209
+ * 하나면 충분하다 — 틀리는 방향이 「조금 더 생각한다」 쪽이라 값이 싸다.
210
+ */
211
+ const 살펴보라는말 = /분석|검토|감사|점검|진단|살펴|파헤|훑어|짚어|찾아|가능성|위험|취약|누락|경합|충돌|불일치|원인|근거|비교|추적|audit|review|analy[sz]e|investigat|assess|diagnos|vulnerab|race\s*condition|compare|trace/i;
212
+
213
+ /**
214
+ * 얕게 보면 안 되는 말인가.
215
+ *
216
+ * `가벼운가` 의 반대말이 아니다. 둘 다 아닌 말이 대부분이고, 그때는 아무것도
217
+ * 안 움직인다 — 사람이 정한 천장 그대로 간다.
218
+ */
219
+ export function 무거운가(글) {
220
+ const s = String(글 ?? '').trim();
221
+ if (!s || 인사인가(s)) return false;
222
+ // 줄이 여럿이면 시킨 것이 여럿이다. 빈 줄은 안 센다 — 붙여넣기에 흔하다.
223
+ if (s.split(/\r?\n/).filter((l) => l.trim()).length >= 3) return true;
224
+ // 길면 조건이 붙어 있다. 조건이 붙은 말을 얕게 읽으면 조건부터 떨어진다.
225
+ if (s.length >= 200) return true;
226
+ // 짧아도 살펴보라는 말이면 읽어야 답이 나온다. 한 마디짜리는 뺀다.
227
+ // 스무 자다. 한국어는 빽빽해서 「공동작업 쪽에 경합 조건 있는지 좀 봐줘」 가 스물둘이다 —
228
+ // 마흔으로 잡으면 진짜 일이 가벼운 쪽으로 떨어진다. 틀리는 값이 한쪽은
229
+ // 「조금 더 생각한다」 이고 다른 쪽은 「얕게 답한다」 라, 싼 쪽으로 기운다.
230
+ return s.length >= 20 && 살펴보라는말.test(s);
231
+ }
232
+
233
+ /**
234
+ * 이번 턴의 **천장** — 모드가 정한 값과 사람이 정한 값 중에서 고른다.
235
+ *
236
+ * 모드가 더 세게 보라고 하면 그대로 따른다(설계·디버그·계획·점검). 그건
237
+ * 「이 일은 원래 깊다」 는 말이고, 사람이 손대지 않은 기본값보다 그 말이 낫다.
238
+ *
239
+ * 모드가 **낮출 때**만 한 번 더 본다. 낮추는 것이 맞는 자리는 한 줄짜리
240
+ * 물음이고, 시킨 것이 여럿이거나 살펴보라는 말이면 그 자리가 아니다.
241
+ * 그때는 사람이 정한 값으로 돌아간다 — 모드가 낮추기 전의 자리다.
242
+ */
243
+ export function 천장고르기(모드think, 세션think, 요청 = '') {
244
+ const 세션 = LEVELS.includes(세션think) ? 세션think : 'medium';
245
+ // 끈 것은 모드가 못 켠다. `off` 는 기본값이 아니라 사람이 고른 값이다.
246
+ if (세션 === 'off') return 'off';
247
+ if (!모드think || !LEVELS.includes(모드think)) return 세션;
248
+ if (LEVELS.indexOf(모드think) >= LEVELS.indexOf(세션)) return 모드think;
249
+ return 무거운가(요청) ? 세션 : 모드think;
250
+ }
251
+
183
252
  /**
184
253
  * 이번 턴의 기준 강도.
185
254
  *