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,302 @@
1
+ /**
2
+ * hwpx 만들기 — 글 → 한글 문서.
3
+ *
4
+ * ── 왜 만드나 ───────────────────────────────────────────────────────────
5
+ *
6
+ * 읽기(tools/docs.js)를 넣고 나서 남은 말이 늘 같았다. 「읽어 주는 건 알겠는데,
7
+ * 결과를 그 서식으로 주면 안 되나요.」
8
+ *
9
+ * 사내에서 오가는 것은 md 가 아니다. 주간보고도 회의록도 검토 의견도 한글
10
+ * 문서다. 지금까지는 deel 이 md 를 내놓고 사람이 그걸 한글에 붙여 넣어 서식을
11
+ * 다시 잡았다. 그 붙여넣기가 하루에 몇 번씩이다.
12
+ *
13
+ * ── 고치기는 여전히 안 한다 ────────────────────────────────────────────
14
+ *
15
+ * docs.js 가 「문서는 못 고침」 이라고 못 박은 것은 그대로다. 서식·그림·양식이
16
+ * 든 문서를 글로 왕복시키면 반드시 뭔가 잃고, 잃은 채로 저장된 문서는 겉보기에
17
+ * 멀쩡해서 알아차렸을 때는 원본이 없다.
18
+ *
19
+ * **새로 만드는 것은 다르다.** 잃을 원본이 없다. 그래서 여기서 하는 일은 하나뿐
20
+ * 이다 — 빈 종이에 글을 적어 hwpx 로 내놓는다. 있는 파일을 덮어쓰지도 않는다.
21
+ *
22
+ * ── 무엇을 옮기고 무엇을 안 옮기나 ─────────────────────────────────────
23
+ *
24
+ * 옮긴다 제목 세 단계(#·##·###) · 문단 · 글머리표(-·*·1.) · 빈 줄
25
+ * 안 옮긴다 표 · 그림 · 굵게·기울임 · 링크
26
+ *
27
+ * 표를 안 넣는 것은 **못 해서가 아니라 확인할 수 없어서**다. OWPML 의 표는 칸
28
+ * 크기·여백·병합을 다 적어야 하고, 그 중 하나가 틀리면 한글이 **파일 전체**를
29
+ * 안 연다. 이 자리에는 한글이 없어서 「열린다」 를 잴 방법이 없다. 문단만 있는
30
+ * 문서는 구조가 단순해 어긋날 자리가 거의 없고, 표는 그 반대다. 표는 줄 글로
31
+ * 적어 내보내고 **그렇게 했다고 말한다** — 조용히 빼면 사람은 표가 사라진 것을
32
+ * 나중에야 안다.
33
+ *
34
+ * ── 규격에 대해 ────────────────────────────────────────────────────────
35
+ *
36
+ * hwpx 는 ZIP + XML 이다(한컴 공개 규격 OWPML). 우리 zip 쓰개(pack/zip.js)와
37
+ * 글자 이어붙이기면 되고, 새로 들이는 것이 없다 — 읽기와 같은 자세다.
38
+ *
39
+ * `mimetype` 은 **맨 앞에, 안 눌러서** 담는다. ZIP 을 안 풀고도 앞머리만 보고
40
+ * 무슨 파일인지 알게 하려는 규약이다. pack/zip.js 는 눌러서 커지면 그냥 담으므로
41
+ * (20바이트짜리는 늘 그렇다) 차례만 맞추면 된다.
42
+ */
43
+ import { makeZip } from '../pack/zip.js';
44
+
45
+ /** 1pt = 100 HWPUNIT. 규격이 그렇다. */
46
+ const PT = 100;
47
+ /** 한 문단이 이보다 길면 자른다. 모델이 통째로 쏟아붓는 것을 막는다. */
48
+ export const 문단최대 = 20000;
49
+ /** 문단 수 상한. 넘으면 자르고 **잘랐다고 말한다.** */
50
+ export const 문단수최대 = 5000;
51
+
52
+ const 갈래 = { 제목1: 1, 제목2: 2, 제목3: 3, 본문: 0, 글머리: 0 };
53
+
54
+ const esc = (s) => String(s ?? '')
55
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
56
+ .replace(/"/g, '&quot;')
57
+ /*
58
+ * XML 1.0 이 아예 못 담는 제어문자를 걷어낸다.
59
+ *
60
+ * 모델이 낸 글에 이런 것이 섞이는 일이 실제로 있다 — 터미널 출력을 그대로
61
+ * 옮겨 적으면 ESC(0x1B)가 딸려 온다. 남겨 두면 한글이 **파일 전체**를 안 여는데,
62
+ * 화면에는 「손상된 문서」 한 줄만 뜼다. 글자 몇 개를 잃는 쪽이 문서를 통째로
63
+ * 잃는 쪽보다 낫다.
64
+ */
65
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
66
+
67
+ /**
68
+ * 글을 문단 목록으로 읽는다.
69
+ *
70
+ * 마크다운을 다 아는 척하지 않는다. 우리가 내놓는 글에 실제로 들어 있는 것만
71
+ * 본다 — 제목, 글머리표, 문단. 굵게·링크 같은 것은 **글자 그대로** 남긴다.
72
+ * 지우면 `**중요**` 가 `중요` 가 되어 강조가 사라지고, 옮기려 들면 서식 조합이
73
+ * 폭발한다. 남기면 적어도 사람이 무엇을 뜻했는지 읽을 수 있다.
74
+ *
75
+ * @returns {{문단들: Array<{갈래:string, 글:string}>, 잘림: number, 표몇개: number}}
76
+ */
77
+ export function 글읽기(글) {
78
+ const 줄들 = String(글 ?? '').replace(/\r\n?/g, '\n').split('\n');
79
+ const 문단들 = [];
80
+ let 표몇개 = 0;
81
+ let 이어붙일것 = null;
82
+
83
+ const 닫기 = () => {
84
+ if (이어붙일것 && 이어붙일것.글.trim()) 문단들.push(이어붙일것);
85
+ 이어붙일것 = null;
86
+ };
87
+
88
+ for (const 날것 of 줄들) {
89
+ const 줄 = 날것.replace(/\s+$/, '');
90
+ if (!줄.trim()) { 닫기(); continue; }
91
+
92
+ const 제목 = /^(#{1,3})\s+(.*)$/.exec(줄);
93
+ if (제목) {
94
+ 닫기();
95
+ 문단들.push({ 갈래: `제목${제목[1].length}`, 글: 제목[2].trim() });
96
+ continue;
97
+ }
98
+
99
+ /*
100
+ * 표는 줄 글로 내보낸다. 그리고 **몇 개였는지 센다** — 부르는 쪽이
101
+ * 그 수를 사람에게 말한다. 조용히 바꿔치기하면 사람은 표가 글이 된 것을
102
+ * 문서를 열어 보고서야 안다.
103
+ */
104
+ if (/^\s*\|.*\|\s*$/.test(줄)) {
105
+ // 표의 구분선(|---|---|)은 글로 옮길 것이 없다.
106
+ if (/^\s*\|[\s:|-]+\|\s*$/.test(줄)) { 표몇개 += 1; continue; }
107
+ 닫기();
108
+ 문단들.push({ 갈래: '본문', 글: 줄.replace(/^\s*\|/, '').replace(/\|\s*$/, '').split('|').map((x) => x.trim()).join(' · ') });
109
+ continue;
110
+ }
111
+
112
+ const 글머리 = /^\s*([-*+]|\d+[.)])\s+(.*)$/.exec(줄);
113
+ if (글머리) {
114
+ 닫기();
115
+ // 한글의 글머리표 규격을 안 쓴다. 문자 하나로 적으면 어느 한글 판에서도
116
+ // 똑같이 보이고, 서식이 어긋날 자리가 없다.
117
+ const 표시 = /^\d/.test(글머리[1]) ? `${글머리[1]} ` : '· ';
118
+ 문단들.push({ 갈래: '글머리', 글: `${표시}${글머리[2].trim()}` });
119
+ continue;
120
+ }
121
+
122
+ // 이어지는 줄은 한 문단으로 붙인다. 마크다운의 규칙이고, 한글에서도
123
+ // 줄마다 문단을 끊으면 줄 간격이 통째로 어색해진다.
124
+ if (이어붙일것) 이어붙일것.글 += ` ${줄.trim()}`;
125
+ else 이어붙일것 = { 갈래: '본문', 글: 줄.trim() };
126
+ }
127
+ 닫기();
128
+
129
+ let 잘림 = 0;
130
+ if (문단들.length > 문단수최대) { 잘림 = 문단들.length - 문단수최대; 문단들.length = 문단수최대; }
131
+ for (const p of 문단들) if (p.글.length > 문단최대) p.글 = `${p.글.slice(0, 문단최대)} …(잘림)`;
132
+ return { 문단들, 잘림, 표몇개 };
133
+ }
134
+
135
+ // ── 이하 규격 조각들 ────────────────────────────────────────────────────
136
+
137
+ const 머리 = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
138
+
139
+ const VERSION_XML = `${머리}
140
+ <hv:HCFVersion xmlns:hv="http://www.hancom.co.kr/hwpml/2011/version" tagetApplication="WORDPROCESSOR" major="5" minor="1" micro="1" buildNumber="0" os="1" xmlVersion="1.4" application="deel" appVersion="1.0"/>`;
141
+
142
+ const CONTAINER_XML = `${머리}
143
+ <ocf:container xmlns:ocf="urn:oasis:names:tc:opendocument:xmlns:container" xmlns:hpf="http://www.hancom.co.kr/schema/2011/hpf">
144
+ <ocf:rootfiles><ocf:rootfile full-path="Contents/content.hpf" media-type="application/hwpml-package+xml"/></ocf:rootfiles>
145
+ </ocf:container>`;
146
+
147
+ const MANIFEST_XML = `${머리}
148
+ <odf:manifest xmlns:odf="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" odf:version="1.2">
149
+ <odf:file-entry odf:full-path="/" odf:media-type="application/hwp+zip"/>
150
+ </odf:manifest>`;
151
+
152
+ const SETTINGS_XML = `${머리}
153
+ <ha:HWPApplicationSetting xmlns:ha="http://www.hancom.co.kr/hwpml/2011/app" xmlns:config="http://www.hancom.co.kr/hwpml/2011/configuration">
154
+ <ha:CaretPosition listIDRef="0" paraIDRef="0" pos="0"/>
155
+ </ha:HWPApplicationSetting>`;
156
+
157
+ const contentHpf = (제목) => `${머리}
158
+ <opf:package xmlns:opf="http://www.idpf.org/2007/opf/" xmlns:dc="http://purl.org/dc/elements/1.1/" version="" unique-identifier="" id="">
159
+ <opf:metadata>
160
+ <opf:title>${esc(제목)}</opf:title>
161
+ <opf:language>ko</opf:language>
162
+ <opf:meta name="creator" content="deel"/>
163
+ </opf:metadata>
164
+ <opf:manifest>
165
+ <opf:item id="header" href="Contents/header.xml" media-type="application/xml"/>
166
+ <opf:item id="section0" href="Contents/section0.xml" media-type="application/xml"/>
167
+ <opf:item id="settings" href="settings.xml" media-type="application/xml"/>
168
+ </opf:manifest>
169
+ <opf:spine><opf:itemref idref="header" linear="yes"/><opf:itemref idref="section0" linear="yes"/></opf:spine>
170
+ </opf:package>`;
171
+
172
+ /*
173
+ * 글자·문단 모양표.
174
+ *
175
+ * 네 벌만 둔다 — 본문과 제목 셋. 더 두면 그만큼 어긋날 자리가 늘고, 우리가
176
+ * 내놓는 글에는 그 이상이 없다.
177
+ *
178
+ * 글꼴은 **이름만 적고 안 싣는다.** 사내 PC 에 함초롬바탕이 없는 일은 거의
179
+ * 없고, 없더라도 한글이 알아서 대체한다. 글꼴 파일을 담기 시작하면 그건
180
+ * 저작권을 우리가 지고 가는 일이다.
181
+ */
182
+ const HEADER_XML = `${머리}
183
+ <hh:head xmlns:hh="http://www.hancom.co.kr/hwpml/2011/head" xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core" version="1.4" secCnt="1">
184
+ <hh:beginNum page="1" footnote="1" endnote="1" pic="1" tbl="1" equation="1"/>
185
+ <hh:refList>
186
+ <hh:fontfaces itemCnt="7">
187
+ ${['HANGUL', 'LATIN', 'HANJA', 'JAPANESE', 'OTHER', 'SYMBOL', 'USER'].map((갈) => `<hh:fontface lang="${갈}" fontCnt="1"><hh:font id="0" face="함초롬바탕" type="TTF" isEmbedded="0"><hh:typeInfo familyType="FCAT_GOTHIC" weight="0" proportion="0" contrast="0" strokeVariation="0" armStyle="0" letterform="0" midline="0" xHeight="0"/></hh:font></hh:fontface>`).join('\n')}
188
+ </hh:fontfaces>
189
+ <hh:borderFills itemCnt="1"><hh:borderFill id="1" threeD="0" shadow="0" centerLine="NONE" breakCellSeparateLine="0"><hh:slash type="NONE" Crooked="0" isCounter="0"/><hh:backSlash type="NONE" Crooked="0" isCounter="0"/><hh:leftBorder type="NONE" width="0.1 mm" color="#000000"/><hh:rightBorder type="NONE" width="0.1 mm" color="#000000"/><hh:topBorder type="NONE" width="0.1 mm" color="#000000"/><hh:bottomBorder type="NONE" width="0.1 mm" color="#000000"/><hh:diagonal type="SOLID" width="0.1 mm" color="#000000"/></hh:borderFill></hh:borderFills>
190
+ <hh:tabProperties itemCnt="1"><hh:tabPr id="0" autoTabLeft="0" autoTabRight="0"/></hh:tabProperties>
191
+ <hh:charProperties itemCnt="4">
192
+ ${[[0, 10], [1, 16], [2, 14], [3, 12]].map(([id, pt]) => `<hh:charPr id="${id}" height="${pt * PT}" textColor="#000000" shadeColor="none" useFontSpace="0" useKerning="0" symMark="NONE" borderFillIDRef="1"><hh:fontRef hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/><hh:ratio hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:spacing hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/><hh:relSz hangul="100" latin="100" hanja="100" japanese="100" other="100" symbol="100" user="100"/><hh:offset hangul="0" latin="0" hanja="0" japanese="0" other="0" symbol="0" user="0"/>${id === 0 ? '' : '<hh:bold/>'}</hh:charPr>`).join('\n')}
193
+ </hh:charProperties>
194
+ <hh:paraProperties itemCnt="4">
195
+ ${[0, 1, 2, 3].map((id) => `<hh:paraPr id="${id}" tabPrIDRef="0" condense="0" fontLineHeight="0" snapToGrid="1" suppressLineNumbers="0" checked="0"><hh:align horizontal="JUSTIFY" vertical="BASELINE"/><hh:heading type="NONE" idRef="0" level="0"/><hh:breakSetting breakLatinWord="KEEP_WORD" breakNonLatinWord="KEEP_WORD" widowOrphan="0" keepWithNext="${id === 0 ? '0' : '1'}" keepLines="0" pageBreakBefore="0" lineWrap="BREAK"/><hh:margin><hc:intent value="0" unit="HWPUNIT"/><hc:left value="0" unit="HWPUNIT"/><hc:right value="0" unit="HWPUNIT"/><hc:prev value="${id === 0 ? 0 : 800}" unit="HWPUNIT"/><hc:next value="${id === 0 ? 0 : 300}" unit="HWPUNIT"/></hh:margin><hh:lineSpacing type="PERCENT" value="160" unit="HWPUNIT"/><hh:border borderFillIDRef="1" offsetLeft="0" offsetRight="0" offsetTop="0" offsetBottom="0" connect="0" ignoreMargin="0"/></hh:paraPr>`).join('\n')}
196
+ </hh:paraProperties>
197
+ <hh:styles itemCnt="4">
198
+ ${[['바탕글', 'Normal', 0], ['개요 1', 'Outline 1', 1], ['개요 2', 'Outline 2', 2], ['개요 3', 'Outline 3', 3]].map(([이름, 영, id]) => `<hh:style id="${id}" type="PARA" name="${이름}" engName="${영}" paraPrIDRef="${id}" charPrIDRef="${id}" nextStyleIDRef="0" langID="1042" lockForm="0"/>`).join('\n')}
199
+ </hh:styles>
200
+ </hh:refList>
201
+ </hh:head>`;
202
+
203
+ /*
204
+ * 구역 속성. 첫 문단의 첫 런 안에 **딱 한 번** 들어간다.
205
+ *
206
+ * 이게 없으면 한글이 파일을 통째로 거절한다 — 조판 엔진을 세울 용지·여백이
207
+ * 없기 때문이다. 우리 읽개(tools/docs.js)는 <hp:t> 만 훑으니 없어도 멀쩡히
208
+ * 읽혔고, 그래서 검사가 전부 초록인 채로 「한글이 못 여는 파일」 을 냈다.
209
+ * 다른 눈(Gemini 3.8 Flash)이 규격을 대조해서 짚어 준 자리다.
210
+ *
211
+ * 값은 A4 세로다. 단위는 HWPUNIT(1/7200인치) —
212
+ * 210mm = 59528 · 297mm = 84188 · 좌우 30mm = 8504 · 위 20mm = 5668 · 아래·머리·꼬리 15mm = 4252
213
+ */
214
+ const SEC_PR = '<hp:secPr id="" textDirection="HORIZONTAL" spaceColumns="1134" tabStop="8000" tabStopVal="4000" tabStopUnit="HWPUNIT" outlineShapeIDRef="0" memoShapeIDRef="0" textVerticalWidthHead="0" masterPageCnt="0">'
215
+ + '<hp:grid lineGrid="0" charGrid="0" wonggojiFormat="0" strtnum="0"/>'
216
+ + '<hp:startNum pageStartsOn="BOTH" page="0" pic="0" tbl="0" equation="0"/>'
217
+ + '<hp:visibility hideFirstHeader="0" hideFirstFooter="0" hideFirstMasterPage="0" border="SHOW_ALL" fill="SHOW_ALL" hideFirstPageNum="0" hideFirstEmptyLine="0" showLineNumber="0"/>'
218
+ + '<hp:lineNumberShape restartType="0" countBy="0" distance="0" startNumber="0"/>'
219
+ + '<hp:pagePr landscape="WIDELY" width="59528" height="84188" gutterType="LEFT_ONLY">'
220
+ + '<hp:margin header="4252" footer="4252" gutter="0" left="8504" right="8504" top="5668" bottom="4252"/>'
221
+ + '</hp:pagePr>'
222
+ + '<hp:footNotePr><hp:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar=")" supscript="0"/><hp:noteLine length="-1" type="SOLID" width="0.12 mm" color="#000000"/><hp:noteSpacing betweenNotes="850" belowLine="567" aboveLine="850"/><hp:numbering type="CONTINUOUS" newNum="1"/><hp:placement place="EACH_COLUMN" beneathText="0"/></hp:footNotePr>'
223
+ + '<hp:endNotePr><hp:autoNumFormat type="DIGIT" userChar="" prefixChar="" suffixChar=")" supscript="0"/><hp:noteLine length="14692344" type="SOLID" width="0.12 mm" color="#000000"/><hp:noteSpacing betweenNotes="0" belowLine="567" aboveLine="850"/><hp:numbering type="CONTINUOUS" newNum="1"/><hp:placement place="END_OF_DOCUMENT" beneathText="0"/></hp:endNotePr>'
224
+ + '<hp:pageBorderFill type="BOTH" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER"><hp:offset left="1417" right="1417" top="1417" bottom="1417"/></hp:pageBorderFill>'
225
+ + '<hp:pageBorderFill type="EVEN" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER"><hp:offset left="1417" right="1417" top="1417" bottom="1417"/></hp:pageBorderFill>'
226
+ + '<hp:pageBorderFill type="ODD" borderFillIDRef="1" textBorder="PAPER" headerInside="0" footerInside="0" fillArea="PAPER"><hp:offset left="1417" right="1417" top="1417" bottom="1417"/></hp:pageBorderFill>'
227
+ + '</hp:secPr>';
228
+
229
+ /*
230
+ * 줄 나눔 캐시(linesegarray)는 **안 적는다.**
231
+ *
232
+ * 전에는 문단마다 같은 값 하나를 박아 넣었다. 글자 수도 줄 수도 안 보고 적는
233
+ * 값이라, 두 줄이 넘는 문단에서는 그 캐시가 사실과 다르다. 규격에서 이 칸은
234
+ * 선택이고, 없으면 한글이 제 엔진으로 다시 잰다. **모르는 것을 지어내
235
+ * 적어 두느니 비워 두는 편이 낫다** — 이 저장소가 값을 다루는 방식 그대로다.
236
+ */
237
+ const 문단XML = (p, 번호) => {
238
+ const id = 갈래[p.갈래] ?? 0;
239
+ return `<hp:p id="${번호}" paraPrIDRef="${id}" styleIDRef="${id}" pageBreak="0" columnBreak="0" merged="0">`
240
+ + `<hp:run charPrIDRef="${id}">${번호 === 0 ? SEC_PR : ''}<hp:t>${esc(p.글)}</hp:t></hp:run>`
241
+ + '</hp:p>';
242
+ };
243
+
244
+ const sectionXML = (문단들) => `${머리}
245
+ <hs:sec xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section" xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph" xmlns:hc="http://www.hancom.co.kr/hwpml/2011/core">
246
+ ${(문단들.length ? 문단들 : [{ 갈래: '본문', 글: '' }]).map(문단XML).join('\n')}
247
+ </hs:sec>`;
248
+
249
+ /**
250
+ * 글을 hwpx 한 덩이(Buffer)로 만든다.
251
+ *
252
+ * 파일로 쓰지 않는다 — 쓰는 자리는 울타리(safety/guard.js)를 지나야 하고,
253
+ * 그 판단은 도구 쪽이 한다. 여기는 바이트만 만든다.
254
+ *
255
+ * @param {string} 글 마크다운스러운 글
256
+ * @param {object} [o]
257
+ * @param {string} [o.제목] 문서 속성의 제목. 안 주면 첫 제목 줄, 그것도 없으면 '문서'
258
+ * @returns {{buf: Buffer, 문단수: number, 잘림: number, 표몇개: number, 제목: string}}
259
+ */
260
+ export function hwpx만들기(글, { 제목 = null } = {}) {
261
+ const { 문단들, 잘림, 표몇개 } = 글읽기(글);
262
+ const 쓸제목 = String(제목 ?? '').trim()
263
+ || 문단들.find((p) => p.갈래.startsWith('제목'))?.글
264
+ || '문서';
265
+
266
+ /*
267
+ * 미리보기 글을 같이 담는다.
268
+ *
269
+ * 한글은 이걸로 파일 목록의 미리보기를 그리고, 윈도우 탐색기의 내용 검색도
270
+ * 이 파일을 본다. 없다고 안 열리지는 않지만, 없으면 **사내 공유폴더에서
271
+ * 검색이 안 걸린다** — 문서를 만들어 주는 값의 절반이 거기 있다.
272
+ */
273
+ const 미리보기 = 문단들.map((p) => p.글).join('\n').slice(0, 3000);
274
+
275
+ const 담을것 = [
276
+ // mimetype 은 맨 앞에. 20바이트라 눌러도 커지므로 그냥 담긴다 (pack/zip.js).
277
+ { name: 'mimetype', data: Buffer.from('application/hwp+zip', 'utf8') },
278
+ { name: 'version.xml', data: Buffer.from(VERSION_XML, 'utf8') },
279
+ { name: 'META-INF/container.xml', data: Buffer.from(CONTAINER_XML, 'utf8') },
280
+ { name: 'META-INF/manifest.xml', data: Buffer.from(MANIFEST_XML, 'utf8') },
281
+ { name: 'Contents/content.hpf', data: Buffer.from(contentHpf(쓸제목), 'utf8') },
282
+ { name: 'Contents/header.xml', data: Buffer.from(HEADER_XML, 'utf8') },
283
+ { name: 'Contents/section0.xml', data: Buffer.from(sectionXML(문단들), 'utf8') },
284
+ { name: 'Preview/PrvText.txt', data: Buffer.from(미리보기, 'utf8') },
285
+ { name: 'settings.xml', data: Buffer.from(SETTINGS_XML, 'utf8') },
286
+ ];
287
+ return { buf: makeZip(담을것), 문단수: 문단들.length, 잘림, 표몇개, 제목: 쓸제목 };
288
+ }
289
+
290
+ /**
291
+ * 만들고 나서 사람에게 할 말.
292
+ *
293
+ * **바꿔치기한 것을 반드시 적는다.** 표를 줄 글로 폈으면 그렇다고 적고, 문단을
294
+ * 잘랐으면 몇 개를 잘랐는지 적는다. 조용히 넘기면 사람은 문서를 열어 보고서야
295
+ * 알고, 그때는 이미 그 문서를 남에게 보낸 뒤다.
296
+ */
297
+ export function 만든말(r, 보인이름) {
298
+ const 줄 = [`${보인이름} · ${r.문단수}문단`];
299
+ if (r.표몇개) 줄.push(`표 ${r.표몇개}개는 줄 글로 폈습니다 (표 서식은 안 만듭니다)`);
300
+ if (r.잘림) 줄.push(`문단 ${r.잘림}개는 넘쳐서 잘랐습니다`);
301
+ return 줄.join(' · ');
302
+ }
@@ -15,15 +15,18 @@ import { loadSkill } from '../skills/discover.js';
15
15
  import { WEB_FETCH_TOOL } from './webfetch.js';
16
16
  import { TODO_TOOL } from './todo.js';
17
17
  import { TASK_TOOL } from './task.js';
18
+ import { 고를말 } from '../agent/agents.js';
18
19
  import { OUTLINE_TOOL } from './outline.js';
19
20
  import { VERIFY_TOOL } from './verify.js';
20
21
  import { DEF_TOOL, REFS_TOOL } from './lsp.js';
21
22
  import { 편집후진단, 붙이기 as 진단붙이기, 데우기 } from '../lsp/diag.js';
22
23
  import { 프로젝트갈래 } from '../lsp/servers.js';
23
24
  import { allow as allowedIn } from '../agent/modes.js';
24
- import { 도구정의, 이름풀기, 열쇠뺀환경 } from '../backend/mcp.js';
25
+ import { 도구정의, 이름풀기 } from '../backend/mcp.js';
26
+ import { 셸환경 } from '../safety/shellenv.js';
25
27
  import { isExcelPath, readExcel, toText as excelText, summarize as excelSummary } from './excel.js';
26
28
  import { isDocPath, readDoc, toText as docText, summarize as docSummary, looksOldHwp, 옛hwp안내, 문서는못고침 } from './docs.js';
29
+ import { hwpx만들기, 만든말 } from './hwpxwrite.js';
27
30
  import { 바꿔볼까, 직접못읽나, 변환기찾기, 글로바꾸기, 못바꿈말 } from './convert.js';
28
31
  import { 물음검사 } from '../agent/askcheck.js';
29
32
  import { isPdfPath, readPdf, toText as pdfText, summarize as pdfSummary, 못읽은말, pdf는못고침, 한쪽도못읽음말 } from './pdf.js';
@@ -822,6 +825,39 @@ async function 여러개옮기기(목록, ctx) {
822
825
  };
823
826
  }
824
827
 
828
+ /** `.hwpx` 인가. 새로 만들 수 있는 유일한 문서 형식이다. */
829
+ const isHwpxPath = (p) => /\.hwpx$/i.test(String(p ?? ''));
830
+
831
+ /**
832
+ * 글을 한글 문서로 **새로** 만든다 (tools/hwpxwrite.js).
833
+ *
834
+ * 사내에서 오가는 것은 md 가 아니다. 주간보고도 회의록도 검토 의견도 한글
835
+ * 문서라, 여태 사람이 우리 md 를 한글에 붙여 넣어 서식을 다시 잡았다.
836
+ *
837
+ * 바꿔치기한 것은 **반드시 적어 돌려준다.** 표를 줄 글로 폈으면 그렇다고
838
+ * 적는다 — 조용히 넘기면 사람은 문서를 열어 보고서야 알고, 그때는 이미 그
839
+ * 문서를 남에게 보낸 뒤다.
840
+ */
841
+ function hwpx새로만들기(args, ctx, abs) {
842
+ const 만듦 = hwpx만들기(args.content, { 제목: null });
843
+ ctx.history.snapshot(abs, 'Write');
844
+ mkdirSync(dirname(abs), { recursive: true });
845
+ writeFileSync(abs, 만듦.buf);
846
+ /*
847
+ * `seen` 에 안 올린다.
848
+ *
849
+ * seen 은 「Edit 로 고쳐도 되는 것을 읽어 뒀다」 는 표다. hwpx 는 만든
850
+ * 다음에도 고칠 수 없으므로, 올려 두면 모델이 Edit 를 부르고 거절당하고
851
+ * 또 부른다. Read 쪽이 문서를 seen 에 안 올리는 것과 같은 판단이다.
852
+ */
853
+ return {
854
+ content: `새로 만듦: ${만든말(만듦, ctx.scope.show(abs))}\n`
855
+ + ' 이 문서는 이제 고칠 수 없습니다. 내용을 바꾸려면 Write 로 다시 만드세요.',
856
+ summary: 이어(세말('lines', 만듦.문단수), 만듦.표몇개 ? `표 ${만듦.표몇개}개는 글로` : ''),
857
+ changed: abs,
858
+ };
859
+ }
860
+
825
861
  function 한파일쓰기(args, ctx) {
826
862
  const abs = ctx.scope.resolve(args.file_path);
827
863
  if (typeof args.content !== 'string') return { error: 'content 가 문자열이 아닙니다' };
@@ -832,6 +868,16 @@ function 한파일쓰기(args, ctx) {
832
868
  // 엑셀 파일을 통째로 덮어쓰면 xlsx 가 아니라 그냥 글 파일이 된다.
833
869
  // 열리지도 않는 파일이 되고, 원본은 이미 없다. 아예 막는다.
834
870
  if (isExcelPath(abs)) return { error: 엑셀은못고침(args.file_path) };
871
+ /*
872
+ * ── hwpx 는 **새로 만드는 것만** 된다 (tools/hwpxwrite.js) ──────────
873
+ *
874
+ * 고치기를 막는 까닭은 「글로 왕복시키면 반드시 뭔가 잃는데, 잃은 채로
875
+ * 저장된 문서는 겉보기에 멀쩡해서 알아차렸을 때는 원본이 없다」 였다.
876
+ * 새로 만드는 자리에는 **잃을 원본이 없다.** 그래서 여기만 연다.
877
+ *
878
+ * 있는 파일이면 그대로 아래 거절로 내려간다 — 덮어쓰기는 여전히 안 된다.
879
+ */
880
+ if (isHwpxPath(abs) && !existsSync(abs)) return hwpx새로만들기(args, ctx, abs);
835
881
  // 문서(hwpx·docx·pptx)도 같은 이유로 또렷하게 거절한다. 일반 '바이너리'
836
882
  // 오류로 넘기면 왜 안 되는지가 안 실려서, 모델이 우회로를 찾는다.
837
883
  if (isDocPath(abs)) return { error: 문서는못고침(args.file_path) };
@@ -1287,7 +1333,17 @@ export const TOOLS = {
1287
1333
  description: '파일을 새로 쓰거나 통째로 덮어쓴다. 일부만 고칠 때는 Edit 을 쓴다.'
1288
1334
  + ' **여러 파일을 한 번에 만들 수 있다** — files 에 배열로 넣으면 된다.'
1289
1335
  + ' 폴더 구조를 처음 잡을 때는 그렇게 해라. 한 개씩 부르면 파일 수만큼 모델을'
1290
- + ' 다시 불러야 해서, 여덟 개짜리 뼈대에 몇 분이 그냥 간다.',
1336
+ + ' 다시 불러야 해서, 여덟 개짜리 뼈대에 몇 분이 그냥 간다.'
1337
+ /*
1338
+ * hwpx 를 여기 적는 까닭.
1339
+ *
1340
+ * 안 적으면 모델은 이 길이 있는 줄 모른다. 사람이 「한글로 줘」 라고
1341
+ * 해도 md 를 만들고 「한글에서 열면 됩니다」 라고 답한다 — 실제로
1342
+ * 그랬다. 도구 목록에 없는 기능은 없는 기능이다.
1343
+ */
1344
+ + ' 이름을 `.hwpx` 로 끝내면 **한글 문서로 만든다** (제목 #·##·### 과 글머리표를 옮긴다).'
1345
+ + ' 사내에 낼 보고서·회의록을 달라고 하면 그렇게 해라. 단 **새로 만들 때만** 되고,'
1346
+ + ' 이미 있는 문서는 못 고친다.',
1291
1347
  parameters: {
1292
1348
  type: 'object',
1293
1349
  properties: {
@@ -1839,7 +1895,11 @@ export const TOOLS = {
1839
1895
 
1840
1896
  // 끝나지 않는 명령은 뒤에서 띄운다. 여기서 기다리면 그 턴이 통째로 멈춘다.
1841
1897
  if (args.background === true) {
1842
- const r = await 띄우기(cmd, { cwd: ctx.scope.root, 설명: args.description ?? null });
1898
+ const r = await 띄우기(cmd, {
1899
+ cwd: ctx.scope.root,
1900
+ 설명: args.description ?? null,
1901
+ 남길것: ctx.셸남길것 ?? [],
1902
+ });
1843
1903
  if (r.error) return { error: r.error };
1844
1904
  if (!r.떴나) {
1845
1905
  // 지켜보는 사이에 죽었다. 포트가 물려 있거나 명령이 틀린 경우다.
@@ -1871,6 +1931,9 @@ export const TOOLS = {
1871
1931
  const shell = 셸명령(cmd);
1872
1932
 
1873
1933
  const 제한 = args.timeout ?? 120000;
1934
+ // 무엇을 빼고 넘길지 여기서 한 번 정한다. 뺀 이름은 아래에서 명령이
1935
+ // 실패했을 때만 쓴다 — 잘 돌 때마다 적으면 매 부름에 군말이 붙는다.
1936
+ const 셸것 = 셸환경(process.env, { 남길것: ctx.셸남길것 ?? [] });
1874
1937
  return new Promise((끝) => {
1875
1938
  /*
1876
1939
  * 끝맺음은 한 번만. 그리고 **기다리지 않는다.**
@@ -1888,9 +1951,17 @@ export const TOOLS = {
1888
1951
  // '�Ľ� ����' 이 된다. 바이트로 받아 이 컴퓨터가 쓰는 것으로 해독한다.
1889
1952
  const kid = 무리로돌리기(shell.file, shell.args, {
1890
1953
  cwd: ctx.scope.root,
1891
- // 열쇠만 빼고 나머지는 그대로 물려준다 (backend/mcp.js 열쇠뺀환경 머리말).
1892
- // 빼면 `env` 줄이 열쇠를 화면과 대화 기록에 그대로 싣는다.
1893
- env: 열쇠뺀환경(),
1954
+ /*
1955
+ * 열쇠처럼 생긴 것만 빼고 나머지는 그대로 물려준다 (safety/shellenv.js).
1956
+ *
1957
+ * 여기 도는 것은 **사용자 제 프로젝트**라 PATH·NODE_ENV·사내 프록시가
1958
+ * 다 있어야 한다. 그런데 여태 뺀 것은 우리 열쇠뿐이었고, 그래서
1959
+ * OPENAI_API_KEY·GITHUB_TOKEN·DB_PASSWORD 가 그대로 넘어갔다.
1960
+ * 모델이 `env | grep -i proxy` 한 번 부르면 — 사내 프록시를 확인하는
1961
+ * 아주 정상적인 행동이다 — 그 값들이 도구 결과에 실려 게이트웨이로
1962
+ * 나가고 대화 기록으로 디스크에도 남는다.
1963
+ */
1964
+ env: 셸것.env,
1894
1965
  // 우리 시계(아래 뒷북)가 제한에서 먼저 나무를 끊는다. 이건 그것마저
1895
1966
  // 못 돌았을 때 서는 마지막 그물이라 뒤에 세운다.
1896
1967
  timeout: 제한 + 3000,
@@ -1971,8 +2042,27 @@ export const TOOLS = {
1971
2042
  + ' `| head -n 200` 처럼 줄이거나 파일로 받아서 다시 부르세요]'
1972
2043
  : 숫자코드 === null ? `\n\n[${글자코드} — 명령을 아예 못 돌렸습니다]`
1973
2044
  : `\n\n[종료코드 ${code}]`;
2045
+ /*
2046
+ * 실패했을 때만, 무엇을 빼고 넘겼는지 이름을 적는다.
2047
+ *
2048
+ * 이 한 줄이 이 기능에서 제일 중요하다. 사내 저장소를 쓰는 사람은
2049
+ * `npm ci` 에 NPM_TOKEN 이 필요하다. 우리가 조용히 빼면 npm 이 401 로
2050
+ * 죽고 화면에는 npm 의 401 만 남는다. 사람은 토큰이 만료된 줄 알고
2051
+ * 새로 발급받으러 가고, 새로 받아도 똑같은 화면을 본다.
2052
+ *
2053
+ * 값은 절대 안 적는다. 이름만 적는다 — 이름을 적는 것은 원인을
2054
+ * 알려 주는 일이고, 값을 적는 것은 방금 막은 것을 되돌리는 일이다.
2055
+ *
2056
+ * 잘 돌았을 때는 안 적는다. 매 부름마다 붙으면 아무도 안 읽는 군말이
2057
+ * 되고, 진짜 필요할 때도 안 읽힌다.
2058
+ */
2059
+ const 뺀말 = (!잘됨 && 셸것.뺀것.length)
2060
+ ? `\n[열쇠처럼 생긴 환경변수 ${셸것.뺀것.length}개는 안 넘겼습니다: ${셸것.뺀것.slice(0, 8).join(' · ')}`
2061
+ + `${셸것.뺀것.length > 8 ? ' …' : ''}.`
2062
+ + ' 이 명령에 필요하면 설정에 "셸환경": { "남길것": ["이름"] } 을 적으세요]'
2063
+ : '';
1974
2064
  done({
1975
- content: clip(out || '(출력 없음)', 실을만큼(ctx)) + 꼬리,
2065
+ content: clip(out || '(출력 없음)', 실을만큼(ctx)) + 꼬리 + 뺀말,
1976
2066
  summary: 잘됨 ? 말('sum.ok')
1977
2067
  : 시그널 ? 말('sum.killedBy', { 시그널 })
1978
2068
  : 넘침 ? 말('sum.tooMuchOut')
@@ -2517,7 +2607,7 @@ export function 영어설명(schema, 이름, 쓸말 = 지시말()) {
2517
2607
 
2518
2608
  // 모델에게 넘길 도구 정의 목록.
2519
2609
  // 스킬이 없으면 Skill 도구는 빼서 자리를 아낀다.
2520
- export function toolSchemas(names = null, { hasSkills = false, web = true, work = null, mcp = null, ctx = null, lsp = false, vision = false } = {}) {
2610
+ export function toolSchemas(names = null, { hasSkills = false, web = true, work = null, mcp = null, ctx = null, lsp = false, vision = false, 에이전트들 = null } = {}) {
2521
2611
  let list = names ?? Object.keys(TOOLS).filter((n) => {
2522
2612
  if (n === 'Skill') return hasSkills;
2523
2613
  if (n === 'WebFetch') return web;
@@ -2572,6 +2662,33 @@ export function toolSchemas(names = null, { hasSkills = false, web = true, work
2572
2662
  * 한 모드에서 '모르는 것' 을 쥐여 주면 그 약속이 약속이 아니게 된다.
2573
2663
  */
2574
2664
  if (mcp?.length && (!work || allowedIn(work, ['Write']).length)) 우리것.push(...도구정의(mcp));
2665
+
2666
+ /*
2667
+ * 이름 붙인 하위 작업이 있으면 `Task` 에 그 목록을 알려 준다 (agent/agents.js).
2668
+ *
2669
+ * 이름과 한 줄 설명만 싱는다. 지침 본문은 **고른 뒤에** 하위 프롬프트로 가지
2670
+ * 여기 실리지 않는다 — 여기 실으면 안 쓰는 에이전트의 지침까지 매 요청에
2671
+ * 나간다. 스킬을 2단계로 올리는 것과 같은 셈법이다.
2672
+ *
2673
+ * 원본 스키마를 안 건드린다. TOOLS 는 한 벌이라 거기 썼으면 다음 부름에도
2674
+ * 남고, 폴더가 다른 방(ACP)의 목록까지 같이 물들인다.
2675
+ */
2676
+ const 골라말 = 에이전트들?.length ? 고를말(에이전트들) : null;
2677
+ if (골라말) {
2678
+ for (const t of 우리것) {
2679
+ if (t.function?.name !== 'Task') continue;
2680
+ t.function = {
2681
+ ...t.function,
2682
+ parameters: {
2683
+ ...t.function.parameters,
2684
+ properties: {
2685
+ ...t.function.parameters.properties,
2686
+ agent: { type: 'string', description: 골라말 },
2687
+ },
2688
+ },
2689
+ };
2690
+ }
2691
+ }
2575
2692
  return 우리것;
2576
2693
  }
2577
2694
 
@@ -2714,7 +2831,9 @@ async function runMcpTool(name, args, ctx) {
2714
2831
  if (!갈린것) return { error: `${name} 은 MCP 도구 이름 꼴이 아닙니다` };
2715
2832
  const 서버 = (ctx.mcp ?? []).find((s) => s.이름 === 갈린것.서버);
2716
2833
  if (!서버) return { error: `${갈린것.서버} 서버가 붙어 있지 않습니다 — /mcp 로 확인하세요` };
2717
- if (!서버.살아있나()) return { error: `${갈린것.서버} 서버가 죽었습니다: ${서버.죽음 ?? '이유 모름'}` };
2834
+ // 대기 중인 것은 부르는 순간 뜼다 (backend/mcp.js 깨우기). 죽은 것과 가른다 —
2835
+ // 하나로 뭉개면 안 띄운 서버를 「죽었다」 고 적어 사람을 없는 탈로 보낸다.
2836
+ if (!서버.쓸수있나()) return { error: `${갈린것.서버} 서버가 죽었습니다: ${서버.죽음 ?? '이유 모름'}` };
2718
2837
  try {
2719
2838
  // 사람이 누른 ESC 를 남의 프로그램에까지 데려간다. 안 넘기면 도구 한 번에
2720
2839
  // 60초(부르기제한)를 꼬박 기다리는데, 그 사이 ESC 는 아무 일도 안 한다.
package/src/tools/jobs.js CHANGED
@@ -26,7 +26,7 @@
26
26
  import { spawn, execFileSync } from 'node:child_process';
27
27
  import { decode as decodeBytes, consoleCodepage } from './encoding.js';
28
28
  import { 셸명령 } from './shell.js';
29
- import { 열쇠뺀환경 } from '../backend/mcp.js';
29
+ import { 셸환경 } from '../safety/shellenv.js';
30
30
  import { 말, 세말 } from '../i18n/index.js';
31
31
 
32
32
  /* 결과 한 줄을 잇는다 — 빈 조각은 버린다(tools/index.js 의 이어 와 같은 것). */
@@ -85,12 +85,17 @@ export { 셸명령 };
85
85
  * 윈도우에서는 반대로 무리를 안 만든다. detached 는 거기서 새 콘솔 창을
86
86
  * 띄우는 쪽으로 작동해서, 조용히 돌아야 할 것이 화면에 튀어나온다.
87
87
  */
88
- export function 띄우기옵션(cwd) {
88
+ export function 띄우기옵션(cwd, { 남길것 = [] } = {}) {
89
89
  const win = process.platform === 'win32';
90
90
  return {
91
91
  cwd,
92
- // 열쇠만 빼고 나머지는 그대로 물려준다 (backend/mcp.js 열쇠뺀환경 머리말).
93
- env: 열쇠뺀환경(),
92
+ /*
93
+ * 열쇠처럼 생긴 것만 빼고 나머지는 그대로 물려준다 (safety/shellenv.js).
94
+ *
95
+ * 뒤에서 도는 일감은 Bash 보다 더 샌다 — 출력이 계속 쌓이고 Jobs 로
96
+ * 언제든 다시 읽히므로, 한 번 찍힌 열쇠는 그 대화가 끝날 때까지 남는다.
97
+ */
98
+ env: 셸환경(process.env, { 남길것 }).env,
94
99
  windowsHide: true,
95
100
  detached: !win,
96
101
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -489,7 +494,7 @@ function 끝난것치우기() {
489
494
  * 돌려준다. 포트가 이미 물려 있는 경우가 제일 흔한데, 그때 "떴습니다" 라고
490
495
  * 답하면 모델은 다음 단계로 넘어가고 사용자는 안 뜬 서버를 찾아다닌다.
491
496
  */
492
- export async function 띄우기(명령, { cwd, 설명 = null, 기다림 = 1500 } = {}) {
497
+ export async function 띄우기(명령, { cwd, 설명 = null, 기다림 = 1500, 남길것 = [] } = {}) {
493
498
  const 도는것 = [...일감들.values()].filter((j) => j.상태 === '도는중');
494
499
  if (도는것.length >= 최대일감) {
495
500
  return { error: `뒤에서 도는 명령이 이미 ${도는것.length}개입니다. Jobs 로 안 쓰는 것을 끝내고 다시 하세요.` };
@@ -500,7 +505,7 @@ export async function 띄우기(명령, { cwd, 설명 = null, 기다림 = 1500 }
500
505
  let kid;
501
506
  try {
502
507
  kid = spawn(shell.file, shell.args, {
503
- ...띄우기옵션(cwd),
508
+ ...띄우기옵션(cwd, { 남길것 }),
504
509
  windowsVerbatimArguments: shell.verbatim,
505
510
  });
506
511
  } catch (err) {
@@ -33,10 +33,13 @@ import { spawn } from 'node:child_process';
33
33
  * @param {number} [옵션.timeout] 이 시간을 넘기면 죽이고 실패로 돌려준다
34
34
  * @param {number} [옵션.maxBuffer] 나온 글이 이보다 커지면 죽인다
35
35
  * @param {AbortSignal|null} [옵션.signal] 눌리면 자식을 죽인다
36
+ * @param {string|null} [옵션.넣을것] stdin 으로 흘려 줄 글. 다 쓰면 닫는다.
36
37
  * @param {object} [옵션.덤] spawn 에 그대로 넘길 것 (windowsVerbatimArguments 같은)
37
38
  * @returns {Promise<{error:Error|null, status:number|null, stdout:string, stderr:string}>}
38
39
  */
39
- export function 돌려보기(이름, 인자, { timeout = 20000, maxBuffer = 32 * 1024 * 1024, signal = null, 덤 = {} } = {}) {
40
+ export function 돌려보기(이름, 인자, {
41
+ timeout = 20000, maxBuffer = 32 * 1024 * 1024, signal = null, 넣을것 = null, 덤 = {},
42
+ } = {}) {
40
43
  return new Promise((resolve) => {
41
44
  // 이미 눌렸으면 띄우지도 않는다. 여럿을 함께 돌릴 때 뒤엣것이 여기로 온다.
42
45
  if (signal?.aborted) { resolve({ error: new Error('중단했습니다'), status: null, stdout: '', stderr: '' }); return; }
@@ -78,6 +81,21 @@ export function 돌려보기(이름, 인자, { timeout = 20000, maxBuffer = 32 *
78
81
  // 이 타이머가 프로그램을 붙들면 안 된다. 자식의 파이프가 이미 루프를 붙들고 있다.
79
82
  시계.unref?.();
80
83
 
84
+ /*
85
+ * 넘길 것이 있으면 stdin 으로 흘리고 **닫는다.**
86
+ *
87
+ * 안 닫으면 자식이 stdin 을 끝까지 읽는 모양일 때 영영 안 끝난다 —
88
+ * 시한에 걸려 죽을 때까지 기다리게 되고, 사람 눈에는 '훅이 느리다' 로 보인다.
89
+ *
90
+ * 자식이 다 읽기 전에 죽으면 EPIPE 가 온다. 그건 우리 탈이 아니다 —
91
+ * 자식은 제 할 말을 이미 했고, 그 결과는 status 로 온다. 여기서 던지면
92
+ * 프로그램이 통째로 죽으므로 삼킨다.
93
+ */
94
+ if (넣을것 != null) {
95
+ 아이.stdin?.on('error', () => { /* 자식이 먼저 닫았다 — 결과는 status 로 온다 */ });
96
+ try { 아이.stdin?.end(String(넣을것)); } catch { /* 이미 닫혔다 */ }
97
+ }
98
+
81
99
  아이.stdout?.setEncoding('utf8');
82
100
  아이.stderr?.setEncoding('utf8');
83
101
  아이.stdout?.on('data', (조각) => {
package/src/tools/task.js CHANGED
@@ -131,7 +131,7 @@ export function 하위모드(요청, 부모모드) {
131
131
  * 못 끝낸 것을 빼면 안 된다. 부모는 그걸 보고 이어서 할지 정한다 —
132
132
  * 다 됐다고 알면 안 끝난 일 위에 다음 일을 쌓는다.
133
133
  */
134
- export function 하위요약({ 목적, 모드, 끝, 글자수, 모델 = null, 보인이름 = (p) => p }) {
134
+ export function 하위요약({ 목적, 모드, 끝, 글자수, 모델 = null, 에이전트 = null, 보인이름 = (p) => p }) {
135
135
  const 줄 = [];
136
136
  const w = workMode(모드);
137
137
  const 왜끝났나 = {
@@ -148,7 +148,13 @@ export function 하위요약({ 목적, 모드, 끝, 글자수, 모델 = null,
148
148
  * 모델이 대충 해 놓은 것을 제가 한 것처럼 믿는다. "1.5B 가 했다" 가 적혀
149
149
  * 있으면 미심쩍은 자리를 다시 볼 여지가 생긴다.
150
150
  */
151
- 줄.push(`하위 작업 "${목적}" · ${w.name} 모드${모델 ? ` · ${모델}` : ''} · ${끝?.steps ?? 0}걸음 · ${왜끝났나}`);
151
+ /*
152
+ * 어느 정의가 한 일인지도 적는다 (agent/agents.js).
153
+ *
154
+ * 모델 이름을 적는 것과 같은 까닭이다. 부모는 이 요약만 보고 이어서 일하는데,
155
+ * 「리뷰어가 봤다」 와 「그냥 하위가 봤다」 는 믿을 만한 정도가 다르다.
156
+ */
157
+ 줄.push(`하위 작업 "${목적}"${에이전트 ? ` · ${에이전트}` : ''} · ${w.name} 모드${모델 ? ` · ${모델}` : ''} · ${끝?.steps ?? 0}걸음 · ${왜끝났나}`);
152
158
 
153
159
  const 파일들 = (끝?.files ?? []).filter((f) => !f.dir);
154
160
  if (파일들.length) {