@wooojin/forgen 0.5.7 → 0.5.9

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.
@@ -17,7 +17,7 @@ import * as os from 'node:os';
17
17
  import * as path from 'node:path';
18
18
  import { generateHooksJson } from '../hooks/hooks-generator.js';
19
19
  import { HOOK_REGISTRY } from '../hooks/hook-registry.js';
20
- import { hasManagedSkillMarker, isManagedAgentToml } from './managed-marker.js';
20
+ import { hasManagedSkillMarker, installSkillFile, isManagedAgentToml, removeOwnedDevGuideSkills } from './managed-marker.js';
21
21
  const MCP_MARKER_BEGIN = '# >>> forgen-managed-mcp';
22
22
  const MCP_MARKER_END = '# <<< forgen-managed-mcp';
23
23
  const NOTIFY_MARKER_BEGIN = '# >>> forgen-managed-notify';
@@ -69,32 +69,177 @@ export function readJsonFile(p) {
69
69
  return null;
70
70
  }
71
71
  }
72
- function splitManagedSpan(lines, begin, end) {
73
- const b = lines.findIndex((l) => l.trim() === begin);
74
- if (b === -1)
75
- return null;
76
- const rel = lines.slice(b + 1).findIndex((l) => l.trim() === end);
77
- if (rel === -1)
78
- return null;
79
- const e = b + 1 + rel;
80
- return { before: lines.slice(0, b), inner: lines.slice(b + 1, e), after: lines.slice(e + 1) };
81
- }
82
- function trimBlankEdges(lines) {
83
- let a = 0;
84
- let z = lines.length;
85
- while (a < z && lines[a].trim() === '')
86
- a += 1;
87
- while (z > a && lines[z - 1].trim() === '')
88
- z -= 1;
89
- return lines.slice(a, z);
72
+ // ── config.toml managed blocks ─────────────────────────────────────────
73
+ //
74
+ // Codex 는 config.toml 을 스스로 다시 쓴다 (toml_edit): `/hooks` 승인은 `[hooks.state."…"]` 테이블을,
75
+ // 모델 변경 등은 root 키를 추가한다. 주석은 "다음 항목의 장식" 으로 취급되므로 forgen 의 마커 주석은
76
+ // **제자리에 있지 않는다**:
77
+ // - END 마커가 파일 끝/첫 테이블 앞에 있으면 Codex 가 쓴 내용이 BEGIN…END 사이에 끼어든다
78
+ // (0.5.6 critic — 블록을 통째로 교체하면 훅 신뢰 22건과 사용자 설정이 사라졌다).
79
+ // - Codex 0.160 은 테이블을 재배치한다: BEGIN 은 forgen 테이블과 함께 파일 끝으로 가고 END 는 앞쪽에
80
+ // 고아로 남는다 (2026-10-02 실머신).
81
+ // - `codex mcp add <다른 서버>` 는 mcp_servers 를 통째로 다시 써서 BEGIN 마커를 **없앤다** (0.5.8 critic).
82
+ // 그래서 마커를 범위로도, 유일한 소유 근거로도 쓰지 않는다. 범위는 TOML 구조(테이블 헤더 ~ 다음 헤더, notify 한 줄),
83
+ // 소유는 "마커가 있거나, forgen 만 쓰는 내용 시그니처가 있다" 로 판정한다. forgen 이 쓴 줄만 고치고 나머지는 그대로 둔다.
84
+ const FORGEN_SERVER_KEY = `(?:forgen-compound|"forgen-compound"|'forgen-compound')`;
85
+ /** `[mcp_servers.forgen-compound]` — 따옴표/공백 표기도 같은 테이블이다 */
86
+ const MCP_HEADER_RE = new RegExp(`^\\[\\s*mcp_servers\\s*\\.\\s*${FORGEN_SERVER_KEY}\\s*\\]\\s*(#.*)?$`);
87
+ /** forgen 서버의 하위 테이블 (`[mcp_servers.forgen-compound.env]`, `[[…things]]`) */
88
+ const MCP_SUBTABLE_RE = new RegExp(`^\\[{1,2}\\s*mcp_servers\\s*\\.\\s*${FORGEN_SERVER_KEY}\\s*\\.`);
89
+ /** 같은 서버를 헤더가 아닌 형태로 정의한 줄 (inline table / dotted key) — 손으로 쓴 설정 */
90
+ const MCP_ALT_FORM_RE = new RegExp(`^\\s*(?:mcp_servers\\s*\\.\\s*)?${FORGEN_SERVER_KEY}\\s*(?:=|\\.)`);
91
+ /** forgen 이 쓰는 args 의 시그니처: `…/dist/mcp/server.js` + `--host=codex` (이 플래그는 forgen 전용) */
92
+ const MCP_SIGNATURE_RE = /[\\/]dist[\\/]mcp[\\/]server\.js["'][\s\S]*--host=codex/;
93
+ const MCP_TABLE_HEADER = '[mcp_servers.forgen-compound]';
94
+ const MCP_OWN_KEY_RE = /^\s*(command|args)\s*=/;
95
+ const TOML_KEY = `(?:[A-Za-z0-9_-]+|"[^"]*"|'[^']*')`;
96
+ /** 테이블 헤더 한 줄: `[a.b]`, `[[a.b]]`, `[a."b c"] # 주석`. 배열 값의 한 줄(`["x", "y"],`)과 구분한다. */
97
+ const TABLE_HEADER_RE = new RegExp(`^\\s*\\[\\[?\\s*${TOML_KEY}(?:\\s*\\.\\s*${TOML_KEY})*\\s*\\]\\]?\\s*(#.*)?$`);
98
+ /**
99
+ * 줄을 구조적으로 분류한다. 여러 줄 문자열(`"""`/`'''`)이나 여러 줄 배열의 이어지는 줄(`cont`)은 값의 일부이므로
100
+ * 그 안의 `[` 로 시작하는 줄을 헤더로, 마커처럼 보이는 줄을 마커로 오인하면 안 된다 (0.5.8 critic m6).
101
+ * 완전한 TOML 파서는 아니다 — 문자열 밖의 대괄호 균형과 삼중따옴표 열림/닫힘만 추적한다.
102
+ */
103
+ function classifyLines(lines) {
104
+ const kinds = [];
105
+ let inString = null;
106
+ let depth = 0;
107
+ const stripInline = (l) => l.replace(/"(?:[^"\\\\]|\\\\.)*"|'[^']*'/g, '""').replace(/#.*$/, '');
108
+ const balance = (l) => (l.match(/\[/g)?.length ?? 0) - (l.match(/\]/g)?.length ?? 0);
109
+ for (const raw of lines) {
110
+ const line = raw.replace(/\r$/, '');
111
+ if (inString !== null) {
112
+ kinds.push('cont');
113
+ if (line.includes(inString))
114
+ inString = null;
115
+ continue;
116
+ }
117
+ if (depth > 0) {
118
+ kinds.push('cont');
119
+ depth = Math.max(0, depth + balance(stripInline(line)));
120
+ continue;
121
+ }
122
+ if (TABLE_HEADER_RE.test(line)) {
123
+ kinds.push('header');
124
+ continue;
125
+ }
126
+ kinds.push('other');
127
+ const t = line.trim();
128
+ if (t === '' || t.startsWith('#'))
129
+ continue;
130
+ const triple = line.match(/"""|'''/g) ?? [];
131
+ if (triple.length % 2 === 1) {
132
+ inString = triple[triple.length - 1];
133
+ continue;
134
+ }
135
+ depth = Math.max(0, balance(stripInline(line)));
136
+ }
137
+ return kinds;
90
138
  }
91
139
  /** BOM 은 파일 맨 앞에 있어야 한다 — 떼어 두었다가 결과 맨 앞에 다시 붙인다. 줄 끝(CRLF)도 보존. */
92
140
  function tomlShape(toml) {
93
141
  const bom = toml.startsWith('') ? '' : '';
94
142
  return { bom, body: toml.slice(bom.length), cr: /\r\n/.test(toml) ? '\r' : '' };
95
143
  }
96
- const MCP_TABLE_HEADER = '[mcp_servers.forgen-compound]';
97
- const MCP_OWN_KEY_RE = /^\s*(command|args)\s*=/;
144
+ /** 줄 배열 → 파일 내용. CRLF 파일의 마지막 줄이 `\r` 로만 끝나면(개행 없음) Codex 가 거부하므로 `\n` 을 붙인다. */
145
+ function joinToml(bom, lines) {
146
+ const text = lines.join('\n');
147
+ return bom + (text.endsWith('\r') ? `${text}\n` : text);
148
+ }
149
+ /**
150
+ * 지정한 줄들을 지운다. 지운 자리 양옆이 모두 빈 줄(또는 파일 시작)이면 빈 줄 하나도 함께 지워
151
+ * 제거/재설치를 반복해도 빈 줄이 쌓이지 않게 한다. 지우지 않은 구간의 서식은 건드리지 않는다.
152
+ */
153
+ function deleteLines(lines, drop) {
154
+ const out = [];
155
+ let justDropped = false;
156
+ for (let i = 0; i < lines.length; i += 1) {
157
+ if (drop.has(i)) {
158
+ justDropped = true;
159
+ continue;
160
+ }
161
+ const blank = lines[i].trim() === '';
162
+ if (justDropped && blank && (out.length === 0 || out[out.length - 1].trim() === '')) {
163
+ // 마지막 요소('' = 파일 끝 개행)는 남기고 앞의 빈 줄을 대신 버린다. CRLF 파일에서 앞의 빈 줄은 '\r' 이라
164
+ // 그것을 마지막에 남기면 bare CR 로 끝나 Codex 가 로드하지 못한다 (0.5.8 critic C1).
165
+ if (i === lines.length - 1) {
166
+ if (out.length > 0)
167
+ out[out.length - 1] = lines[i];
168
+ else
169
+ out.push(lines[i]);
170
+ }
171
+ continue;
172
+ }
173
+ justDropped = false;
174
+ out.push(lines[i]);
175
+ }
176
+ return out;
177
+ }
178
+ /**
179
+ * 제거 뒤 파일 끝을 정리한다: 끝의 빈 줄은 걷어내고, 내용이 있으면 개행 하나로 끝나게 한다.
180
+ * 설치↔제거를 반복해도 결과가 같아지게 하기 위함 (설치는 블록을 개행으로 끝내므로).
181
+ */
182
+ function trimEofBlankLines(lines) {
183
+ const out = [...lines];
184
+ while (out.length > 1 && out[out.length - 1] === '' && out[out.length - 2].trim() === '')
185
+ out.splice(out.length - 2, 1);
186
+ if (out.length > 0 && out[out.length - 1] !== '' && out.some((l) => l.trim() !== '')) {
187
+ // CRLF 파일이면 마지막 줄도 CRLF 로 끝낸다
188
+ if (out.some((l) => l.endsWith('\r')) && !out[out.length - 1].endsWith('\r'))
189
+ out[out.length - 1] += '\r';
190
+ out.push('');
191
+ }
192
+ return out;
193
+ }
194
+ /** 테이블 본문의 끝: 다음 헤더/마커 직전. 끝의 빈 줄과 주석은 *다음 항목의 장식* 이므로 본문이 아니다. */
195
+ function tableBodyEnd(lines, kinds, header, isMarker) {
196
+ let end = header + 1;
197
+ while (end < lines.length) {
198
+ if (kinds[end] === 'header' || (kinds[end] === 'other' && isMarker(lines[end].trim())))
199
+ break;
200
+ end += 1;
201
+ }
202
+ while (end > header + 1) {
203
+ const t = lines[end - 1].trim();
204
+ if (kinds[end - 1] === 'other' && (t === '' || t.startsWith('#')))
205
+ end -= 1;
206
+ else
207
+ break;
208
+ }
209
+ return end;
210
+ }
211
+ const isMcpMarker = (t) => t === MCP_MARKER_BEGIN || t === MCP_MARKER_END;
212
+ function locateMcp(lines) {
213
+ const kinds = classifyLines(lines);
214
+ const beginIdx = [];
215
+ const endIdx = [];
216
+ let header = -1;
217
+ lines.forEach((l, i) => {
218
+ if (kinds[i] === 'cont')
219
+ return; // 여러 줄 값 안의 텍스트
220
+ const t = l.trim();
221
+ if (t === MCP_MARKER_BEGIN)
222
+ beginIdx.push(i);
223
+ else if (t === MCP_MARKER_END)
224
+ endIdx.push(i);
225
+ else if (header === -1 && kinds[i] === 'header' && MCP_HEADER_RE.test(t))
226
+ header = i;
227
+ });
228
+ const bodyEnd = header === -1 ? 0 : tableBodyEnd(lines, kinds, header, isMcpMarker);
229
+ // 헤더 바로 위(빈 줄과 forgen 의 다른 마커/주석은 건너뛴다)에 BEGIN 이 있는가
230
+ let above = header - 1;
231
+ while (above >= 0) {
232
+ const t = lines[above].trim();
233
+ if (t === '' || t === MCP_MARKER_END || isNotifyMarker(t) || isNotifyOwnComment(t))
234
+ above -= 1;
235
+ else
236
+ break;
237
+ }
238
+ const beginAdjacent = above >= 0 && beginIdx.includes(above);
239
+ const owned = header !== -1
240
+ && (beginAdjacent || MCP_SIGNATURE_RE.test(lines.slice(header + 1, bodyEnd).join('\n')));
241
+ return { kinds, beginIdx, endIdx, header, bodyEnd, owned };
242
+ }
98
243
  function upsertMcpBlock(currentToml, pkgRoot) {
99
244
  const { bom, body, cr } = tomlShape(currentToml);
100
245
  const serverPath = path.join(pkgRoot, 'dist', 'mcp', 'server.js');
@@ -103,67 +248,72 @@ function upsertMcpBlock(currentToml, pkgRoot) {
103
248
  // correction-record evidence 박제 시 host:"codex" 로 정확히 태깅되게 한다 (spec §10-5).
104
249
  const ownKeys = ['command = "node"', `args = [${JSON.stringify(serverPath)}, "--host=codex"]`];
105
250
  const lines = body.split('\n');
106
- const span = splitManagedSpan(lines, MCP_MARKER_BEGIN, MCP_MARKER_END);
107
- if (!span) {
108
- // 마커 없이 같은 테이블이 있으면(사용자 직접 작성 / 마커 손상) append 하지 않는다 — 중복 테이블 = 파싱 실패.
109
- if (lines.some((l) => l.trim() === MCP_TABLE_HEADER))
251
+ const at = locateMcp(lines);
252
+ if (at.header === -1) {
253
+ // 헤더가 아닌 형태(inline table / dotted key)로 같은 서버가 정의돼 있으면 append 하지 않는다 —
254
+ // 중복 정의는 Codex 가 config 를 로드하지 못하게 한다.
255
+ if (lines.some((l, i) => at.kinds[i] === 'other' && MCP_ALT_FORM_RE.test(l)))
110
256
  return { content: currentToml, alreadyPresent: true };
257
+ // 테이블이 없다 → 끝에 새 블록. 고아 마커(테이블만 지워진 흔적)는 걷어낸다.
258
+ const cleaned = deleteLines(lines, new Set([...at.beginIdx, ...at.endIdx])).join('\n');
111
259
  const block = [MCP_MARKER_BEGIN, MCP_TABLE_HEADER, ...ownKeys, MCP_MARKER_END].map((l) => l + cr).join('\n');
112
- const trimmed = body.replace(/\s+$/, '');
260
+ const trimmed = cleaned.replace(/\s+$/, '');
113
261
  const sep = trimmed.length > 0 ? `${cr}\n${cr}\n` : '';
114
262
  return { content: `${bom}${trimmed}${sep}${block}\n`, alreadyPresent: false };
115
263
  }
116
- const h = span.inner.findIndex((l) => l.trim() === MCP_TABLE_HEADER);
117
- let extraKeys = [];
118
- let foreign = span.inner;
119
- if (h !== -1) {
120
- const afterHeader = span.inner.slice(h + 1);
121
- const next = afterHeader.findIndex((l) => /^\s*\[/.test(l));
122
- const tableBody = next === -1 ? afterHeader : afterHeader.slice(0, next);
123
- // 손으로 고쳐 여러 줄이 된 command/args 는 안전하게 다시 쓸 수 없다 — 블록을 그대로 둔다.
124
- const own = tableBody.filter((l) => MCP_OWN_KEY_RE.test(l));
125
- if (own.some((l) => !/(["'\]])\s*(#.*)?$/.test(l.trim())))
126
- return { content: currentToml, alreadyPresent: true };
127
- // 사용자가 Codex 로 이 서버에 붙인 설정(enabled, startup_timeout_sec …)은 테이블 안에 유지.
128
- extraKeys = tableBody.filter((l) => l.trim() !== '' && !MCP_OWN_KEY_RE.test(l)).map((l) => l.replace(/\r$/, ''));
129
- foreign = [...span.inner.slice(0, h), ...(next === -1 ? [] : afterHeader.slice(next))];
130
- }
131
- const block = [MCP_MARKER_BEGIN, MCP_TABLE_HEADER, ...ownKeys, ...extraKeys, MCP_MARKER_END].map((l) => l + cr);
132
- const moved = trimBlankEdges(foreign);
133
- const out = [...span.before, ...block, ...(moved.length > 0 ? [cr, ...moved] : []), ...span.after];
134
- const content = bom + out.join('\n');
264
+ // 마커도 forgen 시그니처도 없는 같은 이름의 테이블 = 사용자가 직접 관리. 건드리지도, append 하지도 않는다.
265
+ if (!at.owned)
266
+ return { content: currentToml, alreadyPresent: true };
267
+ const tableBody = lines.slice(at.header + 1, at.bodyEnd);
268
+ // 손으로 고쳐 여러 줄이 된 command/args 는 안전하게 다시 쓸 수 없다 — 그대로 둔다.
269
+ const own = tableBody.filter((l) => MCP_OWN_KEY_RE.test(l));
270
+ if (own.some((l) => !/(["'\]])\s*(#.*)?$/.test(l.trim())))
271
+ return { content: currentToml, alreadyPresent: true };
272
+ // 사용자가 Codex 로 이 서버에 붙인 설정(enabled, startup_timeout_sec …)은 테이블 안에 유지.
273
+ const extraKeys = tableBody.filter((l) => l.trim() !== '' && !MCP_OWN_KEY_RE.test(l)).map((l) => l.replace(/\r$/, ''));
274
+ const block = [MCP_MARKER_BEGIN, lines[at.header].replace(/\r$/, ''), ...ownKeys, ...extraKeys, MCP_MARKER_END].map((l) => l + cr);
275
+ // 마커는 어디에 있든 전부 걷어내고, 테이블 바로 위/아래에 다시 둔다 (재배치/소실된 마커 정규화).
276
+ const SENTINEL = '\u0000forgen-mcp-block\u0000';
277
+ const drop = new Set([...at.beginIdx, ...at.endIdx]);
278
+ for (let i = at.header + 1; i < at.bodyEnd; i += 1)
279
+ drop.add(i);
280
+ const kept = deleteLines(lines.map((l, i) => (i === at.header ? SENTINEL : l)), drop);
281
+ const pos = kept.indexOf(SENTINEL);
282
+ const next = kept[pos + 1];
283
+ // 블록 뒤에 다른 내용이 바로 붙으면 빈 줄로 구분
284
+ const needsGap = next !== undefined && next.trim() !== '';
285
+ kept.splice(pos, 1, ...block, ...(needsGap ? [cr] : []));
286
+ const content = joinToml(bom, kept);
135
287
  return { content, alreadyPresent: content === currentToml };
136
288
  }
137
289
  /**
138
- * forgen MCP 블록 제거 (uninstall, ADR-016 D4). forgen 테이블(본문 + `[mcp_servers.forgen-compound.*]` 하위
139
- * 테이블)과 마커만 걷어내고, 블록 사이에 Codex 가 끼워 넣은 다른 내용은 그 자리에 보존한다.
140
- * 마커 없는 사용자 관리 테이블은 건드리지 않는다.
290
+ * forgen MCP 블록 제거 (uninstall, ADR-016 D4). forgen 테이블(본문 + 하위 테이블)과 마커 줄만 걷어낸다.
291
+ * 마커도 시그니처도 없는 같은 이름의 테이블(사용자 관리)은 건드리지 않는다.
292
+ * `removed` 는 테이블을 실제로 지웠을 때만 true — 고아 마커만 치운 경우는 false.
141
293
  */
142
294
  export function removeMcpBlock(currentToml) {
143
295
  const { bom, body } = tomlShape(currentToml);
144
- const span = splitManagedSpan(body.split('\n'), MCP_MARKER_BEGIN, MCP_MARKER_END);
145
- if (!span)
296
+ const lines = body.split('\n');
297
+ const at = locateMcp(lines);
298
+ if (at.beginIdx.length === 0 && at.endIdx.length === 0 && !at.owned)
146
299
  return { content: currentToml, removed: false };
147
- const kept = [];
148
- let dropping = false;
149
- for (const line of span.inner) {
150
- const t = line.trim();
151
- // 헤더 줄에서만 상태를 바꾼다: forgen 테이블 본체, 그 하위 테이블/배열 테이블은 버리고 나머지는 보존.
152
- if (/^\[/.test(t))
153
- dropping = /^\[{1,2}mcp_servers\.forgen-compound(\]{1,2}|\.)/.test(t);
154
- if (!dropping)
155
- kept.push(line);
156
- }
157
- const foreign = trimBlankEdges(kept);
158
- const before = [...span.before];
159
- // 블록 앞의 구분용 빈 줄은 블록과 함께 정리 (재설치/제거를 반복해도 빈 줄이 쌓이지 않게)
160
- if (foreign.length === 0)
161
- while (before.length > 0 && before[before.length - 1].trim() === '')
162
- before.pop();
163
- const out = [...before, ...foreign, ...span.after];
300
+ const drop = new Set([...at.beginIdx, ...at.endIdx]);
301
+ if (at.owned) {
302
+ for (let i = at.header; i < at.bodyEnd; i += 1)
303
+ drop.add(i);
304
+ // 하위 테이블은 Codex 가 어디로 옮겼든 함께 제거 (command 없는 서버 정의가 남지 않게).
305
+ // 각 하위 테이블도 끝의 빈 줄/주석(다음 항목의 장식)은 남긴다.
306
+ lines.forEach((l, i) => {
307
+ if (at.kinds[i] !== 'header' || !MCP_SUBTABLE_RE.test(l.trim()))
308
+ return;
309
+ for (let k = i; k < tableBodyEnd(lines, at.kinds, i, isMcpMarker); k += 1)
310
+ drop.add(k);
311
+ });
312
+ }
313
+ const out = trimEofBlankLines(deleteLines(lines, drop));
164
314
  while (out.length > 1 && out[0].trim() === '')
165
315
  out.shift(); // 파일 맨 앞 빈 줄
166
- return { content: bom + out.join('\n'), removed: true };
316
+ return { content: joinToml(bom, out), removed: at.owned };
167
317
  }
168
318
  // ── ADR-016 D1: notify 폴백 (config.toml top-level `notify`) ───────────
169
319
  /** forgen notify 바이너리 argv 접두 (`--` 뒤는 사용자가 수동으로 붙인 체인 프로그램). */
@@ -174,87 +324,128 @@ const NOTIFY_OWN_COMMENTS = [
174
324
  '# forgen turn-complete fallback (ADR-016): works even while forgen hooks are untrusted.',
175
325
  '# To chain your own notifier, append: "--", "<program>", "<args…>" (kept across re-install).',
176
326
  ];
327
+ const isNotifyOwnComment = (t) => t.startsWith('# forgen turn-complete fallback') || t.startsWith('# To chain your own notifier');
328
+ const isNotifyMarker = (t) => t === NOTIFY_MARKER_BEGIN || t === NOTIFY_MARKER_END;
177
329
  /** `notify`, `"notify"`, `'notify'` 키 (dotted `notify.x` 포함) — 어느 것이든 forgen 의 notify 와 충돌한다. */
178
330
  const NOTIFY_KEY_RE = /^[ \t]*(?:notify|"notify"|'notify')[ \t]*[.=]/;
331
+ /** forgen notify argv 의 시그니처: `…/dist/host/codex-notify.js` */
332
+ const NOTIFY_SIGNATURE_RE = /[\\/]dist[\\/]host[\\/]codex-notify\.js$/;
333
+ /** `notify = ["a","b"]` 한 줄을 argv 로. 여러 줄 배열·홑따옴표·뒤 주석 등 JSON 으로 못 읽으면 null. */
334
+ function parseNotifyArgvLine(line) {
335
+ const value = line.trim().match(/^(?:notify|"notify"|'notify')[ \t]*=[ \t]*(\[.*\])$/)?.[1];
336
+ if (!value)
337
+ return null;
338
+ try {
339
+ const parsed = JSON.parse(value);
340
+ return Array.isArray(parsed) && parsed.every((a) => typeof a === 'string') ? parsed : null;
341
+ }
342
+ catch {
343
+ return null;
344
+ }
345
+ }
346
+ /**
347
+ * forgen notify 줄은 **argv 시그니처**(`…/dist/host/codex-notify.js`)로 찾는다. 마커는 Codex 가 옮기거나
348
+ * 없앨 수 있고, Codex 가 `notify` 값을 직접 바꾸면 forgen 블록 안에 사용자의 값이 들어앉기도 한다
349
+ * (그때 그 줄은 forgen 것이 아니다 — 마커/주석만 걷어내고 값은 보존).
350
+ */
179
351
  function parseNotifyBlock(lines) {
180
- const span = splitManagedSpan(lines, NOTIFY_MARKER_BEGIN, NOTIFY_MARKER_END);
181
- if (!span)
182
- return { span: null, foreign: [], argv: null };
183
- const idx = span.inner.findIndex((l) => NOTIFY_KEY_RE.test(l));
352
+ const kinds = classifyLines(lines);
353
+ const own = new Set();
354
+ lines.forEach((l, i) => {
355
+ if (kinds[i] === 'cont')
356
+ return; // 여러 줄 값 안의 텍스트는 건드리지 않는다
357
+ const t = l.trim();
358
+ if (isNotifyMarker(t) || isNotifyOwnComment(t))
359
+ own.add(i);
360
+ });
184
361
  let argv = null;
185
- if (idx !== -1) {
186
- argv = 'unparseable';
187
- const value = span.inner[idx].trim().match(/^notify[ \t]*=[ \t]*(\[.*\])$/)?.[1];
188
- try {
189
- const parsed = value ? JSON.parse(value) : null;
190
- if (Array.isArray(parsed) && parsed.every((a) => typeof a === 'string'))
191
- argv = parsed;
192
- }
193
- catch { /* multi-line / single-quoted / trailing comment — 아래에서 블록을 그대로 둔다 */ }
362
+ const forgenIdx = lines.findIndex((l, i) => {
363
+ if (kinds[i] !== 'other' || !NOTIFY_KEY_RE.test(l))
364
+ return false;
365
+ const parsed = parseNotifyArgvLine(l);
366
+ if (!parsed?.some((a) => NOTIFY_SIGNATURE_RE.test(a)))
367
+ return false;
368
+ argv = parsed;
369
+ return true;
370
+ });
371
+ if (forgenIdx !== -1)
372
+ own.add(forgenIdx);
373
+ // BEGIN 바로 아래(forgen 주석만 사이)의 notify 키가 한 줄 JSON 이 아니면 손편집된 블록 — 호출부가 그대로 둔다.
374
+ let custom = false;
375
+ const begin = lines.findIndex((l) => l.trim() === NOTIFY_MARKER_BEGIN);
376
+ if (begin !== -1) {
377
+ let i = begin + 1;
378
+ while (i < lines.length && isNotifyOwnComment(lines[i].trim()))
379
+ i += 1;
380
+ if (i < lines.length && NOTIFY_KEY_RE.test(lines[i]) && parseNotifyArgvLine(lines[i]) === null)
381
+ custom = true;
194
382
  }
195
- const foreign = span.inner.filter((l, i) => i !== idx && l.trim() !== '' && !l.trim().startsWith('# forgen turn-complete fallback') && !l.trim().startsWith('# To chain your own notifier'));
196
- return { span, foreign, argv };
383
+ return {
384
+ touched: own.size > 0,
385
+ hasForgenLine: forgenIdx !== -1,
386
+ argv,
387
+ custom,
388
+ rest: deleteLines(lines, own),
389
+ userNotifyKey: lines.some((l, i) => !own.has(i) && kinds[i] === 'other' && NOTIFY_KEY_RE.test(l)),
390
+ };
197
391
  }
198
392
  /**
199
393
  * config.toml 에 forgen notify 블록을 upsert.
200
394
  *
201
395
  * - Codex 의 `notify` 는 top-level 단일 argv 다. 사용자가 이미 정의했으면 **건드리지 않는다** — 그리고
202
- * forgen 블록이 남아 있으면 제거한다 (중복 키 = config.toml 파싱 실패 → Codex 기동 불가).
396
+ * forgen 줄이 남아 있으면 제거한다 (중복 키 = config.toml 파싱 실패 → Codex 기동 불가).
203
397
  * - top-level 키는 첫 테이블 헤더 앞에 와야 하므로 블록은 항상 파일 최상단(BOM 뒤)에 둔다.
204
- * - 사용자가 forgen 블록의 argv 뒤에 `"--", "<prog>", …` 로 자기 notifier 를 체인해 뒀으면 그 꼬리를 보존.
398
+ * - 사용자가 forgen argv 뒤에 `"--", "<prog>", …` 로 자기 notifier 를 체인해 뒀으면 그 꼬리를 보존.
205
399
  * 블록의 notify 줄을 한 줄 JSON 으로 읽을 수 없으면(여러 줄 배열 등 손편집) 아무것도 바꾸지 않는다.
206
- * - 블록 사이에 Codex 가 끼워 넣은 줄(root 키)은 블록 바로 뒤로 옮겨 보존한다.
400
+ * - forgen 이 쓴 줄 외에는 원래 순서 그대로 둔다 (Codex 가 사이에 끼워 넣은 root 키 포함).
207
401
  */
208
402
  export function upsertNotifyBlock(currentToml, pkgRoot) {
209
403
  const { bom, body, cr } = tomlShape(currentToml);
210
404
  const lines = body.split('\n');
211
- const { span, foreign, argv: existingArgv } = parseNotifyBlock(lines);
212
- const outside = span ? [...span.before, ...span.after] : lines;
213
- // 보수적 판정: 블록 밖 어디든 `notify =` 줄이 있으면 사용자 정의로 본다 (프로필 테이블 안이어도 skip —
405
+ const { touched, rest, argv: existingArgv, custom, userNotifyKey } = parseNotifyBlock(lines);
406
+ if (custom)
407
+ return { content: currentToml, status: 'custom-block' };
408
+ // 보수적 판정: forgen 줄 밖 어디든 `notify =` 줄이 있으면 사용자 정의로 본다 (프로필 테이블 안이어도 skip —
214
409
  // 폴백을 못 넣는 쪽이 config 를 깨뜨리는 쪽보다 낫다).
215
- if (outside.some((l) => NOTIFY_KEY_RE.test(l))) {
216
- if (!span)
217
- return { content: currentToml, status: 'user-defined' };
218
- return { content: bom + [...span.before, ...foreign, ...span.after].join('\n'), status: 'user-defined' };
410
+ if (userNotifyKey) {
411
+ return { content: touched ? joinToml(bom, rest) : currentToml, status: 'user-defined' };
219
412
  }
220
- if (existingArgv === 'unparseable')
221
- return { content: currentToml, status: 'custom-block' };
222
413
  const sep = existingArgv ? existingArgv.indexOf('--') : -1;
223
414
  const chainTail = existingArgv && sep !== -1 ? existingArgv.slice(sep) : [];
224
415
  const argv = [...forgenNotifyArgv(pkgRoot), ...chainTail];
225
416
  const block = [NOTIFY_MARKER_BEGIN, ...NOTIFY_OWN_COMMENTS, `notify = ${JSON.stringify(argv)}`, NOTIFY_MARKER_END].map((l) => l + cr);
226
- const rest = span ? [...span.before, ...foreign, ...span.after] : lines;
227
417
  let start = 0;
228
418
  while (start < rest.length && rest[start].trim() === '')
229
419
  start += 1;
230
420
  const tail = rest.slice(start);
231
- const content = bom + (tail.length > 0 ? [...block, cr, ...tail] : [...block, '']).join('\n');
421
+ const content = joinToml(bom, tail.length > 0 ? [...block, cr, ...tail] : [...block, '']);
232
422
  return { content, status: content === currentToml ? 'already-present' : 'installed' };
233
423
  }
234
424
  /**
235
- * forgen notify 블록 제거 (`--no-notify`, uninstall). 블록 사이에 끼어든 다른 줄은 보존.
425
+ * forgen notify 블록 제거 (`--no-notify`, uninstall).
236
426
  *
237
427
  * - 사용자가 블록의 notify 줄을 여러 줄 배열 등으로 손편집했으면(`custom`) **건드리지 않는다** — 첫 줄만
238
428
  * 지우면 남은 줄이 깨진 TOML 이 되어 Codex 가 기동하지 못한다 (critic 2026-10-02).
239
429
  * - `"--"` 뒤에 사용자가 체인해 둔 자기 notifier 가 있으면 그 argv 만으로 `notify` 를 되돌려 놓는다.
430
+ * - `removed` 는 forgen notify 줄을 실제로 지웠을 때만 true. 고아 마커/주석만 치운 경우는 false
431
+ * (내용은 정리된 것을 돌려준다).
240
432
  */
241
433
  export function removeNotifyBlock(currentToml) {
242
434
  const { bom, body, cr } = tomlShape(currentToml);
243
- const { span, foreign, argv } = parseNotifyBlock(body.split('\n'));
244
- if (!span)
245
- return { content: currentToml, removed: false, custom: false, restoredChain: [] };
246
- if (argv === 'unparseable')
435
+ const { touched, hasForgenLine, rest, argv, custom } = parseNotifyBlock(body.split('\n'));
436
+ if (custom)
247
437
  return { content: currentToml, removed: false, custom: true, restoredChain: [] };
438
+ if (!touched)
439
+ return { content: currentToml, removed: false, custom: false, restoredChain: [] };
248
440
  const sep = argv ? argv.indexOf('--') : -1;
249
441
  const restoredChain = argv && sep !== -1 ? argv.slice(sep + 1) : [];
250
- const restored = restoredChain.length > 0 ? [`notify = ${JSON.stringify(restoredChain)}${cr}`] : [];
251
- const rest = [...span.before, ...restored, ...foreign, ...span.after];
252
442
  let start = 0;
253
- if (span.before.every((l) => l.trim() === '') && restored.length === 0 && foreign.length === 0) {
254
- while (start < rest.length && rest[start].trim() === '')
255
- start += 1;
256
- }
257
- return { content: bom + rest.slice(start).join('\n'), removed: true, custom: false, restoredChain };
443
+ while (start < rest.length - 1 && rest[start].trim() === '')
444
+ start += 1;
445
+ const tail = trimEofBlankLines(rest.slice(start));
446
+ const hasContent = tail.some((l) => l.trim() !== '');
447
+ const restored = restoredChain.length > 0 ? [`notify = ${JSON.stringify(restoredChain)}${cr}`, ...(hasContent ? [cr] : [])] : [];
448
+ return { content: joinToml(bom, [...restored, ...tail]), removed: hasForgenLine, custom: false, restoredChain };
258
449
  }
259
450
  export function planCodexInstall(opts) {
260
451
  const codexHome = resolveCodexHome(opts);
@@ -336,10 +527,9 @@ export function planCodexInstall(opts) {
336
527
  else {
337
528
  // opt-out 은 "더 이상 등록하지 않음" 이 아니라 "없앰" 이어야 한다 (이전 설치의 블록이 남지 않게).
338
529
  const r = removeNotifyBlock(configToml);
339
- if (r.removed) {
530
+ configToml = r.content; // 고아 마커만 정리된 경우도 반영
531
+ if (r.removed)
340
532
  notify = 'removed';
341
- configToml = r.content;
342
- }
343
533
  else if (r.custom)
344
534
  notify = 'custom-block';
345
535
  }
@@ -363,6 +553,8 @@ export function planCodexInstall(opts) {
363
553
  // pkgRoot 의 git repo root 의 AGENTS.md, 또는 explicit override.
364
554
  const agentsMdPath = opts.agentsMdPath ?? resolveAgentsMdPath(opts.pkgRoot);
365
555
  const agentsResult = upsertForgenRulesInAgentsMd({ agentsMdPath, pkgRoot: opts.pkgRoot, dryRun: opts.dryRun ?? false });
556
+ if (!opts.dryRun)
557
+ recordAgentsMdInstall(codexHome, agentsMdPath);
366
558
  // 8) v0.4.9: dev-guide skills → ~/.codex/skills/forgen-<stack>-<skill>/SKILL.md
367
559
  const devGuideResult = installDevGuideSkillsToCodex({
368
560
  pkgRoot: opts.pkgRoot,
@@ -757,33 +949,13 @@ function installDevGuideSkillsToCodex(opts) {
757
949
  return { devGuideSkillsPath: codexSkillsDir, devGuideSkillsInstalled: entries.length, devGuideSkillsRemoved: 0 };
758
950
  }
759
951
  fs.mkdirSync(codexSkillsDir, { recursive: true });
760
- // Stale cleanup: dev-guide pattern 만 정리 (forgen 자체 commands 보존)
761
- let removed = 0;
762
- for (const entry of fs.readdirSync(codexSkillsDir)) {
763
- if (DEV_GUIDE_SKILL_PATTERN.test(entry)) {
764
- try {
765
- fs.rmSync(path.join(codexSkillsDir, entry), { recursive: true, force: true });
766
- removed++;
767
- }
768
- catch { /* best-effort */ }
769
- }
770
- }
771
- // Install via symlink → copyFileSync fallback
952
+ // stale 정리 — forgen 이 설치한 dev-guide 스킬만 (사용자가 만든 `forgen-<stack>-*` 스킬은 보존, ADR-016 0.5.9)
953
+ const removed = removeOwnedDevGuideSkills(codexSkillsDir, opts.pkgRoot);
954
+ // 설치 — 이미 있는 것(사용자 심링크 디렉토리, 남아 있는 SKILL.md)은 덮어쓰지 않는다.
772
955
  let installed = 0;
773
956
  for (const { name, src } of entries) {
774
- const dstDir = path.join(codexSkillsDir, name);
775
- fs.mkdirSync(dstDir, { recursive: true });
776
- const dst = path.join(dstDir, 'SKILL.md');
777
- let linked = false;
778
- try {
779
- fs.symlinkSync(src, dst, 'file');
780
- linked = true;
781
- }
782
- catch { /* fallback */ }
783
- if (!linked) {
784
- fs.copyFileSync(src, dst);
785
- }
786
- installed++;
957
+ if (installSkillFile(src, codexSkillsDir, name))
958
+ installed++;
787
959
  }
788
960
  return { devGuideSkillsPath: codexSkillsDir, devGuideSkillsInstalled: installed, devGuideSkillsRemoved: removed };
789
961
  }
@@ -891,26 +1063,36 @@ export function upsertForgenRulesInAgentsMd(opts) {
891
1063
  if (fs.existsSync(agentsMdPath)) {
892
1064
  current = fs.readFileSync(agentsMdPath, 'utf-8');
893
1065
  }
894
- // Phase 3 critic fix #1: RegExp lastIndex 위험 회피 — g flag 제거 + 매번 새 RegExp.
895
- const reMarker = new RegExp(`${escapeRegex(AGENTS_MD_BEGIN)}[\\s\\S]*?${escapeRegex(AGENTS_MD_END)}`);
896
- const hasBlock = reMarker.test(current);
897
- // Phase 3 critic fix #2: AGENTS.md self-heal — begin marker 만 있고 end 손상 시
898
- // 누적 방지. begin 부터 파일 끝까지 + AGENTS_MD_END 미존재 = 손상으로 판단,
899
- // begin 부터 파일 끝까지를 *전부* 새 block 으로 교체.
1066
+ // 블록 경계는 마커가 한 줄을 통째로 차지할 때만 인정한다 (본문에 마커 문자열을 인용한 줄과 구분 —
1067
+ // 이전의 문자열 정규식은 인용된 BEGIN 부터 실제 END 까지를 블록으로 보고 그 사이의 사용자 글을 덮어썼다).
1068
+ const lines = current.split('\n');
1069
+ const blocks = findAgentsBlocks(lines);
1070
+ const blockLines = block.split('\n');
900
1071
  let newContent;
901
- if (hasBlock) {
902
- newContent = current.replace(reMarker, block);
1072
+ if (blocks.length > 0) {
1073
+ // 첫 블록 자리에 새 블록, 나머지 중복 블록은 제거
1074
+ const drop = new Set();
1075
+ for (const b of blocks)
1076
+ for (let i = b.begin; i <= b.end; i += 1)
1077
+ drop.add(i);
1078
+ const out = [];
1079
+ lines.forEach((l, i) => {
1080
+ if (i === blocks[0].begin)
1081
+ out.push(...blockLines);
1082
+ if (!drop.has(i))
1083
+ out.push(l);
1084
+ });
1085
+ newContent = out.join('\n');
903
1086
  }
904
1087
  else {
905
- const beginIdx = current.indexOf(AGENTS_MD_BEGIN);
906
- const endIdx = current.indexOf(AGENTS_MD_END);
907
- if (beginIdx !== -1 && endIdx === -1) {
908
- // 손상: begin 만 있음 → begin 부터 끝까지 교체 (self-heal)
909
- newContent = `${current.slice(0, beginIdx).replace(/\s+$/, '')}\n\n${block}\n`;
1088
+ // self-heal: BEGIN 줄만 있고 END 가 없으면(손상) BEGIN 부터 파일 끝까지를 새 블록으로 교체 — 누적 방지.
1089
+ const beginIdx = lines.findIndex((l) => l.trim() === AGENTS_MD_BEGIN);
1090
+ if (beginIdx !== -1) {
1091
+ newContent = `${lines.slice(0, beginIdx).join('\n').replace(/\s+$/, '')}\n\n${block}\n`.replace(/^\n+/, '');
910
1092
  }
911
1093
  else {
912
- // 깨끗한 신규 또는 둘 다 없음 → 끝에 append
913
- newContent = `${current.replace(/\s+$/, '')}${current.length > 0 ? '\n\n' : ''}${block}\n`;
1094
+ // 깨끗한 신규 → 끝에 append
1095
+ newContent = `${current.replace(/\s+$/, '')}${current.trim().length > 0 ? '\n\n' : ''}${block}\n`;
914
1096
  }
915
1097
  }
916
1098
  if (dryRun)
@@ -919,7 +1101,89 @@ export function upsertForgenRulesInAgentsMd(opts) {
919
1101
  fs.writeFileSync(agentsMdPath, newContent, 'utf-8');
920
1102
  return { injected: newContent !== current };
921
1103
  }
922
- /** AGENTS.md 의 forgen 블록 제거 (uninstall). 블록뿐이던 파일은 삭제한다. */
1104
+ // ── AGENTS.md 설치 위치 기록 (ADR-016 0.5.9) ────────────────────────────
1105
+ //
1106
+ // `forgen install codex|opencode` 는 실행한 cwd 의 AGENTS.md 에 블록을 쓴다. 어디에 썼는지 남기지 않으면
1107
+ // uninstall 은 그때의 cwd 것만 지울 수 있다. host 설정 디렉토리에 경로 목록을 둔다 (전역 forgen state 가
1108
+ // 아니라 host 디렉토리 — 그 host 설치의 흔적이고, 격리 설치끼리 기록이 섞이지 않는다).
1109
+ const AGENTS_MD_REGISTRY = 'forgen-agents-md.json';
1110
+ export function readAgentsMdInstalls(hostDir) {
1111
+ try {
1112
+ const data = JSON.parse(fs.readFileSync(path.join(hostDir, AGENTS_MD_REGISTRY), 'utf-8'));
1113
+ return Array.isArray(data.paths) ? data.paths.filter((p) => typeof p === 'string') : [];
1114
+ }
1115
+ catch {
1116
+ return [];
1117
+ }
1118
+ }
1119
+ export function recordAgentsMdInstall(hostDir, agentsMdPath) {
1120
+ try {
1121
+ const abs = path.resolve(agentsMdPath);
1122
+ const paths = readAgentsMdInstalls(hostDir);
1123
+ if (paths.includes(abs))
1124
+ return;
1125
+ fs.mkdirSync(hostDir, { recursive: true });
1126
+ fs.writeFileSync(path.join(hostDir, AGENTS_MD_REGISTRY), `${JSON.stringify({ paths: [...paths, abs] }, null, 2)}\n`, 'utf-8');
1127
+ }
1128
+ catch { /* 기록 실패는 설치를 막지 않는다 */ }
1129
+ }
1130
+ export function clearAgentsMdInstalls(hostDir) {
1131
+ try {
1132
+ fs.unlinkSync(path.join(hostDir, AGENTS_MD_REGISTRY));
1133
+ }
1134
+ catch { /* 없음 */ }
1135
+ }
1136
+ /**
1137
+ * 기록된 경로 + 지금의 cwd 경로에서 forgen 블록을 걷어낸다.
1138
+ * 쓰지 못한 경로(읽기 전용 등)는 `failed` 로 돌려주고 **기록에 남긴다** — 다음 uninstall 이 다시 시도할 수 있게.
1139
+ */
1140
+ export function removeForgenRulesEverywhere(opts) {
1141
+ const recorded = readAgentsMdInstalls(opts.hostDir);
1142
+ const candidates = [...new Set([...recorded, path.resolve(opts.cwdAgentsMdPath)])];
1143
+ const cleaned = [];
1144
+ const failed = [];
1145
+ for (const p of candidates) {
1146
+ try {
1147
+ if (removeForgenRulesFromAgentsMd({ agentsMdPath: p, dryRun: opts.dryRun }).removed)
1148
+ cleaned.push(p);
1149
+ }
1150
+ catch (e) {
1151
+ failed.push({ path: p, error: e instanceof Error ? e.message : String(e) });
1152
+ }
1153
+ }
1154
+ if (!opts.dryRun) {
1155
+ const keep = recorded.filter((p) => failed.some((f) => f.path === p));
1156
+ if (keep.length === 0)
1157
+ clearAgentsMdInstalls(opts.hostDir);
1158
+ else {
1159
+ try {
1160
+ fs.writeFileSync(path.join(opts.hostDir, AGENTS_MD_REGISTRY), `${JSON.stringify({ paths: keep }, null, 2)}\n`, 'utf-8');
1161
+ }
1162
+ catch { /* best-effort */ }
1163
+ }
1164
+ }
1165
+ return { cleaned, failed };
1166
+ }
1167
+ /** 마커가 *한 줄을 통째로* 차지할 때만 블록 경계로 본다 — 본문에 마커 문자열을 인용한 줄과 구분. */
1168
+ function findAgentsBlocks(lines) {
1169
+ const blocks = [];
1170
+ let begin = -1;
1171
+ lines.forEach((l, i) => {
1172
+ const t = l.trim();
1173
+ if (t === AGENTS_MD_BEGIN && begin === -1)
1174
+ begin = i;
1175
+ else if (t === AGENTS_MD_END && begin !== -1) {
1176
+ blocks.push({ begin, end: i });
1177
+ begin = -1;
1178
+ }
1179
+ });
1180
+ return blocks;
1181
+ }
1182
+ /**
1183
+ * AGENTS.md 의 forgen 블록 제거 (uninstall). 블록뿐이던 파일은 삭제한다.
1184
+ * BEGIN/END 마커가 각각 한 줄로, 순서대로 있을 때만 동작한다 (블록이 여러 개면 전부 제거).
1185
+ * 읽을 수 없으면 no-op, 쓸 수 없으면 throw — 호출부가 보고한다.
1186
+ */
923
1187
  export function removeForgenRulesFromAgentsMd(opts) {
924
1188
  let current;
925
1189
  try {
@@ -928,10 +1192,18 @@ export function removeForgenRulesFromAgentsMd(opts) {
928
1192
  catch {
929
1193
  return { removed: false, fileDeleted: false };
930
1194
  }
931
- const re = new RegExp(`\\n*${escapeRegex(AGENTS_MD_BEGIN)}[\\s\\S]*?${escapeRegex(AGENTS_MD_END)}\\n?`);
932
- if (!re.test(current))
1195
+ const lines = current.split('\n');
1196
+ const blocks = findAgentsBlocks(lines);
1197
+ if (blocks.length === 0)
933
1198
  return { removed: false, fileDeleted: false };
934
- const rest = current.replace(re, '\n').replace(/^\n+/, '');
1199
+ const drop = new Set();
1200
+ for (const b of blocks)
1201
+ for (let i = b.begin; i <= b.end; i += 1)
1202
+ drop.add(i);
1203
+ const kept = trimEofBlankLines(deleteLines(lines, drop));
1204
+ while (kept.length > 1 && kept[0].trim() === '')
1205
+ kept.shift();
1206
+ const rest = kept.join('\n');
935
1207
  const empty = rest.trim().length === 0;
936
1208
  // 심링크면 링크를 지우지 않고 대상 파일에 써 넣는다 (링크만 지우면 대상에 블록이 남는다).
937
1209
  const isLink = fs.lstatSync(opts.agentsMdPath).isSymbolicLink();
@@ -940,10 +1212,7 @@ export function removeForgenRulesFromAgentsMd(opts) {
940
1212
  if (deleteFile)
941
1213
  fs.unlinkSync(opts.agentsMdPath);
942
1214
  else
943
- fs.writeFileSync(opts.agentsMdPath, empty ? '' : (rest.endsWith('\n') ? rest : `${rest}\n`), 'utf-8');
1215
+ fs.writeFileSync(opts.agentsMdPath, empty ? '' : rest, 'utf-8');
944
1216
  }
945
1217
  return { removed: true, fileDeleted: deleteFile };
946
1218
  }
947
- function escapeRegex(s) {
948
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
949
- }