@luchenglong/cli-bridge 0.2.5 → 0.2.6

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/scripts/build.mjs CHANGED
@@ -1,253 +1,262 @@
1
- // scripts/build.mjs — 派生产物生成器(keystone)。
2
- //
3
- // 生成物(勿手改;`--check` 模式漂移即失败,npm test 里的 build-sync 测试调用它):
4
- // 1. dynamic/<id>.host.js ← dynamic/<id>.host.template.mjs
5
- // 把 core/bridge-kit.mjs 内联进 /*__KIT__*/、providers/<id>.mjs 内联进
6
- // /*__PROVIDER__*/、core/scheduling.mjs(+SCHED_ALL_IDS 常量)内联进
7
- // /*__SCHED__*/(动态沙箱禁止 import,只能文本注入;单标记替换防递归错乱)。
8
- // 注入时剥离各段的 `import ... from '...mjs'` 行并把 `export ` 关键字去掉。
9
- // 2. preset/<id>/agent.cordis.yml ← preset/<id>/plugin-list.mjs(唯一权威源)
10
- // 纯 JS 插件清单落地为 legacy 目录形态 YAML({__jsExpr} → !!js 块)。
11
- // 两者同源生成,结构性消除「yml 与声明制漂移」这一历史风险。
12
- // 3. home-plugin/cli-bridge-indicator/lib/preset-definitions/<id>.mjs
13
- // ← 同上(声明制形态:插件数组直接给 agentPresets.register 消费)。
14
- // ⚠️ 这一份由 preset-declarations.mjs 直接 import plugin-list,本脚本不再复制;
15
- // 保留目录仅作兼容占位(旧版生成物清理)。
16
- //
17
- // 用法:node scripts/build.mjs [--check]
18
-
19
- import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'node:fs'
20
- import { dirname, join, resolve } from 'node:path'
21
- import { fileURLToPath } from 'node:url'
22
- import { pathToFileURL } from 'node:url'
23
- import { PROVIDER_IDS } from '../providers/registry.mjs'
24
-
25
- const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
26
- // 单一 id 轴:新 provider 只改 registry(PROVIDER-CONTRACT.md §3.1),build 派生
27
- // 物(dynamic 宿主 / SCHED_ALL_IDS 内联 / legacy YAML / plugin-list)自动跟上。
28
- const ids = PROVIDER_IDS
29
- const PKG = '@luchenglong/cli-bridge'
30
-
31
- const MODE = process.argv.includes('--check') ? 'check' : 'write'
32
- // --only=<id> 让单个 provider 的作者只生成/校验自己的派生物,避免并发写他人产物。
33
- const onlyArg = process.argv.find((a) => a.startsWith('--only='))
34
- const ONLY = onlyArg ? onlyArg.slice('--only='.length) : null
35
- const drift = []
36
-
37
- function read(rel) { return readFileSync(join(ROOT, rel), 'utf8') }
38
-
39
- function emit(rel, content) {
40
- const abs = join(ROOT, rel)
41
- if (MODE === 'check') {
42
- const cur = existsSync(abs) ? readFileSync(abs, 'utf8') : null
43
- if (cur !== content) drift.push(rel)
44
- return
45
- }
46
- mkdirSync(dirname(abs), { recursive: true })
47
- writeFileSync(abs, content, 'utf8')
48
- console.log('generated ' + rel)
49
- }
50
-
51
- // ── 1. dynamic host(文本内联,沙箱禁 import)──────────────────────────────────
52
- function stripModuleSyntax(src, label) {
53
- // 多行 import(`import {\n a, b\n} from '...'`)在单行过滤下会残留 `import ` 前缀并
54
- // 触发下面的守卫误报 —— 先把跨行 import 折叠成一行再过滤(与单行同规则)。
55
- const collapsed = src.replace(
56
- /^\s*import\s*\{[^}]*\}\s*from\s*['"][^'"]+['"];?\s*$/gms,
57
- (m) => '// ' + m.replace(/\s+/g, ' ').trim()
58
- )
59
- const out = collapsed
60
- .split('\n')
61
- .filter((l) => !/^\s*import\s[^(]*from\s*['"][^'"]+['"];?\s*$/.test(l) && !/^\s*import\s*['"]/.test(l))
62
- .join('\n')
63
- // 缩进形态也要剥:`export function f()` 可能带前导空格(从 descriptor 闭包里
64
- // 搬出来的函数保留原缩进),只匹配行首会漏掉 → 残留 export 在脚本体里直接语法错。
65
- .replace(/^(\s*)export\s+(const|let|var|function|async function|class)\s/gm, '$1$2 ')
66
- .replace(/^(\s*)export\s+default\s+(async\s+)?function\s+([A-Za-z0-9_$]+)\s*/gm, '$1$2function $3 ')
67
- .replace(/^(\s*)export\s+default\s+(async\s+)?function\s*\(/gm, '$1$2function __default__(')
68
- .replace(/^(\s*)export\s*\{[^}]*\};?\s*$/gm, '')
69
- // 内联目标是「脚本体」:残留的 export default/import 说明源用了本转换器不支持
70
- // 的形态。静默产出坏代码会让 dynamic host 在沙箱里炸得莫名其妙,必须大声失败。
71
- if (/^\s*(export\s+default|import\s|import\()/m.test(out)) {
72
- throw new Error('build: ' + (label || 'source') + ' 用了沙箱内联不支持的模块形态(provider 需具名 `export default function defineProvider(...)`;bridge-kit 不得 import)')
73
- }
74
- return out
75
- }
76
-
77
- function buildDynamicHost(id) {
78
- const tplRel = 'dynamic/' + id + '.host.template.mjs'
79
- if (!existsSync(join(ROOT, tplRel))) return null
80
- const tpl = read(tplRel)
81
- const kit = stripModuleSyntax(read('core/bridge-kit.mjs'), 'core/bridge-kit.mjs')
82
- const prov = stripModuleSyntax(read('providers/' + id + '.mjs'), 'providers/' + id + '.mjs')
83
- // 调度单源(0.2.0):core/scheduling.mjs 无 import,可安全内联进沙箱作用域。
84
- // SCHED_ALL_IDS 由构建注入(全量 provider 清单)—— 沙箱禁 import,拿不到 registry。
85
- // 模板没有 /*__SCHED__*/ 占位(旧模板)→ 不注入,保持向后兼容。
86
- const sched = stripModuleSyntax(read('core/scheduling.mjs'), 'core/scheduling.mjs')
87
- + '\nconst SCHED_ALL_IDS = ' + JSON.stringify(ids) + '\n'
88
- // 可选的第二段内联:providers/<id>-catalog.mjs。存在且模板声明了占位符才注入 ——
89
- // 语义与 __SCHED__ 完全同构(模板没有该占位符 → 不注入,向后兼容)。
90
- // 目的:providers/<id>.mjs 超 600 行红线时把纯逻辑拆出去,沙箱里仍是单脚本。
91
- const extraRel = 'providers/' + id + '-catalog.mjs'
92
- const extra = existsSync(join(ROOT, extraRel)) ? stripModuleSyntax(read(extraRel), extraRel) : null
93
- if (!tpl.includes('/*__KIT__*/') || !tpl.includes('/*__PROVIDER__*/')) {
94
- throw new Error(tplRel + ' missing /*__KIT__*/ or /*__PROVIDER__*/ placeholder')
95
- }
96
- let out = tpl.replace('/*__KIT__*/', () => kit).replace('/*__PROVIDER__*/', () => prov)
97
- // __EXTRA__ 放在 KIT/PROVIDER 之后替换:避免其内容里若含同名占位符被二次展开。
98
- if (out.includes('/*__EXTRA__*/')) {
99
- if (!extra) throw new Error(tplRel + ': __EXTRA__ placeholder present but ' + extraRel + ' missing')
100
- out = out.replace('/*__EXTRA__*/', () => extra)
101
- } else if (tpl.includes('/*__EXTRA__*/')) throw new Error(tplRel + ': __EXTRA__ placeholder vanished after other replacements')
102
- if (out.includes('/*__SCHED__*/')) out = out.replace('/*__SCHED__*/', () => sched)
103
- else if (tpl.includes('/*__SCHED__*/')) throw new Error(tplRel + ': __SCHED__ placeholder vanished after other replacements')
104
- return out
105
- }
106
-
107
- for (const id of ids) {
108
- if (ONLY && id !== ONLY) continue
109
- const out = buildDynamicHost(id)
110
- if (out) emit('dynamic/' + id + '.host.js', out)
111
- }
112
-
113
- // ── 2. agent.cordis.yml ← plugin-list.mjs ─────────────────────────────────────
114
- function yamlString(v) {
115
- const s = String(v)
116
- if (s === '' || /[:#\n'"\[\]{}&*!|>%@`]/.test(s) || /^\s|\s$/.test(s)) {
117
- return "'" + s.replace(/'/g, "''") + "'"
118
- }
119
- return s
120
- }
121
-
122
- function yamlEntry(entry, indent) {
123
- // 递归序列化一个插件条目。字段序与官方 patch.yml 习惯一致:
124
- // id → name → entry → group → isolate → disabled/if → config。
125
- const pad = ' '.repeat(indent)
126
- const lines = [pad + '- id: ' + yamlString(entry.id)]
127
- if (entry.name !== undefined) lines.push(pad + ' name: ' + yamlString(entry.name))
128
- if (entry.entry !== undefined) lines.push(pad + ' entry: ' + yamlString(entry.entry))
129
- if (entry.group === true) lines.push(pad + ' group: true')
130
- if (entry.isolate && typeof entry.isolate === 'object') {
131
- lines.push(pad + ' isolate:')
132
- for (const k of Object.keys(entry.isolate)) lines.push(pad + ' ' + k + ': ' + String(entry.isolate[k]))
133
- }
134
- for (const k of ['disabled', 'if']) {
135
- if (entry[k] === undefined) continue
136
- const v = entry[k]
137
- if (v && typeof v === 'object' && typeof v.__jsExpr === 'string') lines.push(pad + ' ' + k + ": !!js '" + String(v.__jsExpr).replace(/'/g, "''") + "'")
138
- else lines.push(pad + ' ' + k + ': ' + String(v))
139
- }
140
- if (entry.config !== undefined) {
141
- if (Array.isArray(entry.config)) {
142
- lines.push(pad + ' config:')
143
- if (!entry.config.length) lines[lines.length - 1] = pad + ' config: []'
144
- for (const child of entry.config) lines.push(yamlEntry(child, indent + 4))
145
- } else if (entry.config && typeof entry.config === 'object') {
146
- lines.push(yamlConfig(entry.config, indent + 2))
147
- } else {
148
- lines.push(pad + ' config: ' + yamlString(entry.config))
149
- }
150
- }
151
- return lines.join('\n')
152
- }
153
-
154
- function yamlValue(v) {
155
- // '' 返回值 = 「值是嵌套结构/多行文本,需换行展开」;否则返回 ' 标量' 后缀。
156
- if (v && typeof v === 'object' && typeof v.__jsExpr === 'string') return " !!js '" + String(v.__jsExpr).replace(/'/g, "''") + "'"
157
- if (Array.isArray(v)) return v.length ? '' : ' []'
158
- if (v && typeof v === 'object') return ''
159
- if (typeof v === 'string') { if (v.indexOf('\n') >= 0) return ''; return ' ' + yamlString(v) }
160
- return ' ' + String(v)
161
- }
162
-
163
- // 多行字符串 → YAML 块标量('|' 保留结尾单换行;'|-' 裁掉)。行内引号折叠会改变
164
- // 文本语义(plan-mode 段含逐字换行),必须用块标量原样落地。
165
- function yamlBlockKey(key, v, keyPad, valPad) {
166
- const clip = v.endsWith('\n') ? '|' : '|-'
167
- const body = (clip === '|' ? v.replace(/\n$/, '') : v).split('\n')
168
- const lines = [keyPad + key + ': ' + clip]
169
- for (const l of body) lines.push(l === '' ? '' : valPad + l)
170
- return lines
171
- }
172
-
173
- function yamlConfig(config, indent) {
174
- // indent:'config:' 键本身的列;子键 +2。
175
- const pad = ' '.repeat(indent)
176
- const lines = [pad + 'config:']
177
- for (const k of Object.keys(config)) {
178
- const v = config[k]
179
- const inline = yamlValue(v)
180
- if (inline !== '') { lines.push(pad + ' ' + k + ':' + inline); continue }
181
- if (typeof v === 'string') { lines.push(...yamlBlockKey(k, v, pad + ' ', pad + ' ')); continue }
182
- if (Array.isArray(v)) {
183
- lines.push(pad + ' ' + k + ':')
184
- for (const item of v) {
185
- if (item && typeof item === 'object' && !item.__jsExpr) {
186
- const keys = Object.keys(item)
187
- lines.push(pad + ' - ' + keys[0] + ': ' + yamlString(item[keys[0]]))
188
- for (let i = 1; i < keys.length; i++) lines.push(pad + ' ' + keys[i] + ': ' + yamlString(item[keys[i]]))
189
- } else lines.push(pad + ' - ' + yamlString(item))
190
- }
191
- } else {
192
- lines.push(pad + ' ' + k + ':')
193
- lines.push(yamlInlineObject(v, indent + 4))
194
- }
195
- }
196
- return lines.join('\n')
197
- }
198
-
199
- function yamlInlineObject(obj, indent) {
200
- const pad = ' '.repeat(indent)
201
- const lines = []
202
- for (const k of Object.keys(obj)) {
203
- const v = obj[k]
204
- const inline = yamlValue(v)
205
- if (inline !== '') lines.push(pad + k + ':' + inline)
206
- else if (typeof v === 'string') lines.push(...yamlBlockKey(k, v, pad, pad + ' '))
207
- else {
208
- lines.push(pad + k + ':')
209
- if (Array.isArray(v)) { for (const item of v) lines.push(pad + ' - ' + yamlString(item)) }
210
- else lines.push(yamlInlineObject(v, indent + 2))
211
- }
212
- }
213
- return lines.join('\n')
214
- }
215
-
216
- const PRESET_DIRS = { agy: 'agy-first', codebuddy: 'codebuddy-first', mimo: 'mimo-first', opencode: 'opencode-first' }
217
-
218
- async function loadPluginList(id) {
219
- const rel = 'preset/' + PRESET_DIRS[id] + '/plugin-list.mjs'
220
- const abs = join(ROOT, rel)
221
- if (!existsSync(abs)) return null
222
- const mod = await import(pathToFileURL(abs).href)
223
- return mod
224
- }
225
-
226
- for (const id of ids) {
227
- if (ONLY && id !== ONLY) continue
228
- const mod = await loadPluginList(id)
229
- if (!mod || typeof mod.buildPlugins !== 'function') continue
230
- const dir = PRESET_DIRS[id]
231
- const meta = mod.meta || {}
232
- const plugins = mod.buildPlugins({ bridgeName: PKG + '/preset-' + id })
233
- const head = [
234
- '# 生成物 — 由 node scripts/build.mjs 从 preset/' + dir + '/plugin-list.mjs 生成,勿手改。',
235
- '# legacy 目录形态($DSH_HOME/.agent-presets/' + meta.id + '/);新 DSH 走声明制',
236
- '# (home-plugin/cli-bridge-indicator/lib/preset-declarations.mjs 直接 import 同一源)。',
237
- ''
238
- ].join('\n')
239
- const body = plugins.map((e) => yamlEntry(e, 0)).join('\n')
240
- emit('preset/' + dir + '/agent.cordis.yml', head + body + '\n')
241
- emit('preset/' + dir + '/preset.yml', [
242
- 'name: ' + yamlString(meta.name || meta.id),
243
- 'description: ' + yamlString(meta.description || ''),
244
- 'order: ' + String(meta.order != null ? meta.order : 10),
245
- ''
246
- ].join('\n'))
247
- }
248
-
249
- if (drift.length) {
250
- console.error('[build] drift detected: ' + drift.join(', ') + ' — run `node scripts/build.mjs`')
251
- process.exit(1)
252
- }
253
- if (MODE === 'check') console.log('[build] up to date')
1
+ // scripts/build.mjs — 派生产物生成器(keystone)。
2
+ //
3
+ // 生成物(勿手改;`--check` 模式漂移即失败,npm test 里的 build-sync 测试调用它):
4
+ // 1. dynamic/<id>.host.js ← dynamic/<id>.host.template.mjs
5
+ // 把 core/bridge-kit.mjs 内联进 /*__KIT__*/、providers/<id>.mjs 内联进
6
+ // /*__PROVIDER__*/、core/scheduling.mjs(+SCHED_ALL_IDS 常量)内联进
7
+ // /*__SCHED__*/(动态沙箱禁止 import,只能文本注入;单标记替换防递归错乱)。
8
+ // 注入时剥离各段的 `import ... from '...mjs'` 行并把 `export ` 关键字去掉。
9
+ // 2. preset/<id>/agent.cordis.yml ← preset/<id>/plugin-list.mjs(唯一权威源)
10
+ // 纯 JS 插件清单落地为 legacy 目录形态 YAML({__jsExpr} → !!js 块)。
11
+ // 两者同源生成,结构性消除「yml 与声明制漂移」这一历史风险。
12
+ // 3. home-plugin/cli-bridge-indicator/lib/preset-definitions/<id>.mjs
13
+ // ← 同上(声明制形态:插件数组直接给 agentPresets.register 消费)。
14
+ // ⚠️ 这一份由 preset-declarations.mjs 直接 import plugin-list,本脚本不再复制;
15
+ // 保留目录仅作兼容占位(旧版生成物清理)。
16
+ //
17
+ // 用法:node scripts/build.mjs [--check]
18
+
19
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs'
20
+ import { dirname, join, resolve } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+ import { pathToFileURL } from 'node:url'
23
+ import { PROVIDER_IDS } from '../providers/registry.mjs'
24
+
25
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
26
+ // 单一 id 轴:新 provider 只改 registry(PROVIDER-CONTRACT.md §3.1),build 派生
27
+ // 物(dynamic 宿主 / SCHED_ALL_IDS 内联 / legacy YAML / plugin-list)自动跟上。
28
+ const ids = PROVIDER_IDS
29
+ const PKG = '@luchenglong/cli-bridge'
30
+
31
+ const MODE = process.argv.includes('--check') ? 'check' : 'write'
32
+ // --only=<id> 让单个 provider 的作者只生成/校验自己的派生物,避免并发写他人产物。
33
+ const onlyArg = process.argv.find((a) => a.startsWith('--only='))
34
+ const ONLY = onlyArg ? onlyArg.slice('--only='.length) : null
35
+ const drift = []
36
+
37
+ function read(rel) { return readFileSync(join(ROOT, rel), 'utf8') }
38
+
39
+ function emit(rel, content) {
40
+ const abs = join(ROOT, rel)
41
+ if (MODE === 'check') {
42
+ const cur = existsSync(abs) ? readFileSync(abs, 'utf8') : null
43
+ if (cur !== content) drift.push(rel)
44
+ return
45
+ }
46
+ mkdirSync(dirname(abs), { recursive: true })
47
+ writeFileSync(abs, content, 'utf8')
48
+ console.log('generated ' + rel)
49
+ }
50
+
51
+ // ── 1. dynamic host(文本内联,沙箱禁 import)──────────────────────────────────
52
+ function stripModuleSyntax(src, label) {
53
+ // 多行 import(`import {\n a, b\n} from '...'`)在单行过滤下会残留 `import ` 前缀并
54
+ // 触发下面的守卫误报 —— 先把跨行 import 折叠成一行再过滤(与单行同规则)。
55
+ const collapsed = src.replace(
56
+ /^\s*import\s*\{[^}]*\}\s*from\s*['"][^'"]+['"];?\s*$/gms,
57
+ (m) => '// ' + m.replace(/\s+/g, ' ').trim()
58
+ )
59
+ const out = collapsed
60
+ .split('\n')
61
+ .filter((l) => !/^\s*import\s[^(]*from\s*['"][^'"]+['"];?\s*$/.test(l) && !/^\s*import\s*['"]/.test(l))
62
+ .join('\n')
63
+ // 缩进形态也要剥:`export function f()` 可能带前导空格(从 descriptor 闭包里
64
+ // 搬出来的函数保留原缩进),只匹配行首会漏掉 → 残留 export 在脚本体里直接语法错。
65
+ .replace(/^(\s*)export\s+(const|let|var|function|async function|class)\s/gm, '$1$2 ')
66
+ .replace(/^(\s*)export\s+default\s+(async\s+)?function\s+([A-Za-z0-9_$]+)\s*/gm, '$1$2function $3 ')
67
+ .replace(/^(\s*)export\s+default\s+(async\s+)?function\s*\(/gm, '$1$2function __default__(')
68
+ .replace(/^(\s*)export\s*\{[^}]*\};?\s*$/gm, '')
69
+ // 内联目标是「脚本体」:残留的 export default/import 说明源用了本转换器不支持
70
+ // 的形态。静默产出坏代码会让 dynamic host 在沙箱里炸得莫名其妙,必须大声失败。
71
+ if (/^\s*(export\s+default|import\s|import\()/m.test(out)) {
72
+ throw new Error('build: ' + (label || 'source') + ' 用了沙箱内联不支持的模块形态(provider 需具名 `export default function defineProvider(...)`;bridge-kit 不得 import)')
73
+ }
74
+ return out
75
+ }
76
+
77
+ function buildDynamicHost(id) {
78
+ const tplRel = 'dynamic/' + id + '.host.template.mjs'
79
+ if (!existsSync(join(ROOT, tplRel))) return null
80
+ const tpl = read(tplRel)
81
+ const kit = stripModuleSyntax(read('core/bridge-kit.mjs'), 'core/bridge-kit.mjs')
82
+ const prov = stripModuleSyntax(read('providers/' + id + '.mjs'), 'providers/' + id + '.mjs')
83
+ // 调度单源(0.2.0):core/scheduling.mjs 无 import,可安全内联进沙箱作用域。
84
+ // SCHED_ALL_IDS 由构建注入(全量 provider 清单)—— 沙箱禁 import,拿不到 registry。
85
+ // 模板没有 /*__SCHED__*/ 占位(旧模板)→ 不注入,保持向后兼容。
86
+ const sched = stripModuleSyntax(read('core/scheduling.mjs'), 'core/scheduling.mjs')
87
+ + '\nconst SCHED_ALL_IDS = ' + JSON.stringify(ids) + '\n'
88
+ // 可选的多段内联:providers/<id>-*.mjs(如 <id>-catalog.mjs、<id>-models-tool.mjs)。
89
+ // 语义与 __SCHED__ 同构(模板没有该占位符 → 不注入,向后兼容)。
90
+ // 目的:providers/<id>.mjs 超 600 行红线时把纯逻辑拆出去,沙箱里仍是**单脚本**。
91
+ // 支持多个片文件(按文件名字典序拼接)而不是写死一个:每撞一次 600 行红线就改一次
92
+ // 构建器的做法不可持续,泛用性与顺序确定性都要有。
93
+ const extraFiles = readdirSync(join(ROOT, 'providers'))
94
+ .filter((f) => f.startsWith(id + '-') && f.endsWith('.mjs'))
95
+ .sort()
96
+ const extra = extraFiles.length
97
+ ? extraFiles.map((f) => {
98
+ const rel = 'providers/' + f
99
+ return '// ── inlined from ' + rel + ' ──\n' + stripModuleSyntax(read(rel), rel)
100
+ }).join('\n')
101
+ : null
102
+ if (!tpl.includes('/*__KIT__*/') || !tpl.includes('/*__PROVIDER__*/')) {
103
+ throw new Error(tplRel + ' missing /*__KIT__*/ or /*__PROVIDER__*/ placeholder')
104
+ }
105
+ let out = tpl.replace('/*__KIT__*/', () => kit).replace('/*__PROVIDER__*/', () => prov)
106
+ // __EXTRA__ 放在 KIT/PROVIDER 之后替换:避免其内容里若含同名占位符被二次展开。
107
+ if (out.includes('/*__EXTRA__*/')) {
108
+ if (!extra) throw new Error(tplRel + ': __EXTRA__ placeholder present but no providers/' + id + '-*.mjs fragment found')
109
+ out = out.replace('/*__EXTRA__*/', () => extra)
110
+ } else if (tpl.includes('/*__EXTRA__*/')) throw new Error(tplRel + ': __EXTRA__ placeholder vanished after other replacements')
111
+ if (out.includes('/*__SCHED__*/')) out = out.replace('/*__SCHED__*/', () => sched)
112
+ else if (tpl.includes('/*__SCHED__*/')) throw new Error(tplRel + ': __SCHED__ placeholder vanished after other replacements')
113
+ return out
114
+ }
115
+
116
+ for (const id of ids) {
117
+ if (ONLY && id !== ONLY) continue
118
+ const out = buildDynamicHost(id)
119
+ if (out) emit('dynamic/' + id + '.host.js', out)
120
+ }
121
+
122
+ // ── 2. agent.cordis.yml ← plugin-list.mjs ─────────────────────────────────────
123
+ function yamlString(v) {
124
+ const s = String(v)
125
+ if (s === '' || /[:#\n'"\[\]{}&*!|>%@`]/.test(s) || /^\s|\s$/.test(s)) {
126
+ return "'" + s.replace(/'/g, "''") + "'"
127
+ }
128
+ return s
129
+ }
130
+
131
+ function yamlEntry(entry, indent) {
132
+ // 递归序列化一个插件条目。字段序与官方 patch.yml 习惯一致:
133
+ // id → name → entry → group → isolate → disabled/if → config。
134
+ const pad = ' '.repeat(indent)
135
+ const lines = [pad + '- id: ' + yamlString(entry.id)]
136
+ if (entry.name !== undefined) lines.push(pad + ' name: ' + yamlString(entry.name))
137
+ if (entry.entry !== undefined) lines.push(pad + ' entry: ' + yamlString(entry.entry))
138
+ if (entry.group === true) lines.push(pad + ' group: true')
139
+ if (entry.isolate && typeof entry.isolate === 'object') {
140
+ lines.push(pad + ' isolate:')
141
+ for (const k of Object.keys(entry.isolate)) lines.push(pad + ' ' + k + ': ' + String(entry.isolate[k]))
142
+ }
143
+ for (const k of ['disabled', 'if']) {
144
+ if (entry[k] === undefined) continue
145
+ const v = entry[k]
146
+ if (v && typeof v === 'object' && typeof v.__jsExpr === 'string') lines.push(pad + ' ' + k + ": !!js '" + String(v.__jsExpr).replace(/'/g, "''") + "'")
147
+ else lines.push(pad + ' ' + k + ': ' + String(v))
148
+ }
149
+ if (entry.config !== undefined) {
150
+ if (Array.isArray(entry.config)) {
151
+ lines.push(pad + ' config:')
152
+ if (!entry.config.length) lines[lines.length - 1] = pad + ' config: []'
153
+ for (const child of entry.config) lines.push(yamlEntry(child, indent + 4))
154
+ } else if (entry.config && typeof entry.config === 'object') {
155
+ lines.push(yamlConfig(entry.config, indent + 2))
156
+ } else {
157
+ lines.push(pad + ' config: ' + yamlString(entry.config))
158
+ }
159
+ }
160
+ return lines.join('\n')
161
+ }
162
+
163
+ function yamlValue(v) {
164
+ // '' 返回值 = 「值是嵌套结构/多行文本,需换行展开」;否则返回 ' 标量' 后缀。
165
+ if (v && typeof v === 'object' && typeof v.__jsExpr === 'string') return " !!js '" + String(v.__jsExpr).replace(/'/g, "''") + "'"
166
+ if (Array.isArray(v)) return v.length ? '' : ' []'
167
+ if (v && typeof v === 'object') return ''
168
+ if (typeof v === 'string') { if (v.indexOf('\n') >= 0) return ''; return ' ' + yamlString(v) }
169
+ return ' ' + String(v)
170
+ }
171
+
172
+ // 多行字符串 → YAML 块标量('|' 保留结尾单换行;'|-' 裁掉)。行内引号折叠会改变
173
+ // 文本语义(plan-mode 段含逐字换行),必须用块标量原样落地。
174
+ function yamlBlockKey(key, v, keyPad, valPad) {
175
+ const clip = v.endsWith('\n') ? '|' : '|-'
176
+ const body = (clip === '|' ? v.replace(/\n$/, '') : v).split('\n')
177
+ const lines = [keyPad + key + ': ' + clip]
178
+ for (const l of body) lines.push(l === '' ? '' : valPad + l)
179
+ return lines
180
+ }
181
+
182
+ function yamlConfig(config, indent) {
183
+ // indent:'config:' 键本身的列;子键 +2。
184
+ const pad = ' '.repeat(indent)
185
+ const lines = [pad + 'config:']
186
+ for (const k of Object.keys(config)) {
187
+ const v = config[k]
188
+ const inline = yamlValue(v)
189
+ if (inline !== '') { lines.push(pad + ' ' + k + ':' + inline); continue }
190
+ if (typeof v === 'string') { lines.push(...yamlBlockKey(k, v, pad + ' ', pad + ' ')); continue }
191
+ if (Array.isArray(v)) {
192
+ lines.push(pad + ' ' + k + ':')
193
+ for (const item of v) {
194
+ if (item && typeof item === 'object' && !item.__jsExpr) {
195
+ const keys = Object.keys(item)
196
+ lines.push(pad + ' - ' + keys[0] + ': ' + yamlString(item[keys[0]]))
197
+ for (let i = 1; i < keys.length; i++) lines.push(pad + ' ' + keys[i] + ': ' + yamlString(item[keys[i]]))
198
+ } else lines.push(pad + ' - ' + yamlString(item))
199
+ }
200
+ } else {
201
+ lines.push(pad + ' ' + k + ':')
202
+ lines.push(yamlInlineObject(v, indent + 4))
203
+ }
204
+ }
205
+ return lines.join('\n')
206
+ }
207
+
208
+ function yamlInlineObject(obj, indent) {
209
+ const pad = ' '.repeat(indent)
210
+ const lines = []
211
+ for (const k of Object.keys(obj)) {
212
+ const v = obj[k]
213
+ const inline = yamlValue(v)
214
+ if (inline !== '') lines.push(pad + k + ':' + inline)
215
+ else if (typeof v === 'string') lines.push(...yamlBlockKey(k, v, pad, pad + ' '))
216
+ else {
217
+ lines.push(pad + k + ':')
218
+ if (Array.isArray(v)) { for (const item of v) lines.push(pad + ' - ' + yamlString(item)) }
219
+ else lines.push(yamlInlineObject(v, indent + 2))
220
+ }
221
+ }
222
+ return lines.join('\n')
223
+ }
224
+
225
+ const PRESET_DIRS = { agy: 'agy-first', codebuddy: 'codebuddy-first', mimo: 'mimo-first', opencode: 'opencode-first' }
226
+
227
+ async function loadPluginList(id) {
228
+ const rel = 'preset/' + PRESET_DIRS[id] + '/plugin-list.mjs'
229
+ const abs = join(ROOT, rel)
230
+ if (!existsSync(abs)) return null
231
+ const mod = await import(pathToFileURL(abs).href)
232
+ return mod
233
+ }
234
+
235
+ for (const id of ids) {
236
+ if (ONLY && id !== ONLY) continue
237
+ const mod = await loadPluginList(id)
238
+ if (!mod || typeof mod.buildPlugins !== 'function') continue
239
+ const dir = PRESET_DIRS[id]
240
+ const meta = mod.meta || {}
241
+ const plugins = mod.buildPlugins({ bridgeName: PKG + '/preset-' + id })
242
+ const head = [
243
+ '# 生成物 — 由 node scripts/build.mjs 从 preset/' + dir + '/plugin-list.mjs 生成,勿手改。',
244
+ '# legacy 目录形态($DSH_HOME/.agent-presets/' + meta.id + '/);新 DSH 走声明制',
245
+ '# (home-plugin/cli-bridge-indicator/lib/preset-declarations.mjs 直接 import 同一源)。',
246
+ ''
247
+ ].join('\n')
248
+ const body = plugins.map((e) => yamlEntry(e, 0)).join('\n')
249
+ emit('preset/' + dir + '/agent.cordis.yml', head + body + '\n')
250
+ emit('preset/' + dir + '/preset.yml', [
251
+ 'name: ' + yamlString(meta.name || meta.id),
252
+ 'description: ' + yamlString(meta.description || ''),
253
+ 'order: ' + String(meta.order != null ? meta.order : 10),
254
+ ''
255
+ ].join('\n'))
256
+ }
257
+
258
+ if (drift.length) {
259
+ console.error('[build] drift detected: ' + drift.join(', ') + ' — run `node scripts/build.mjs`')
260
+ process.exit(1)
261
+ }
262
+ if (MODE === 'check') console.log('[build] up to date')