@noob-stupid/dsh-plugin-console 0.5.27 → 0.5.29

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,573 @@
1
+ // L1 · domain —— preset-declare.js(预设**声明行**:把 `.agent-presets/<id>/` 的内容写成 profile 补丁里的一行)
2
+ //
3
+ // 为什么必须有它(**框架 0.1.7-rc.x 的机制迁移**,2026-09-27 查实):
4
+ // · **0.1.5 及更早**:`@deepseek-ai/dsh-agent-presets` 扫描 `$DSH_HOME/.agent-presets/` 目录发现预设
5
+ // (常量 `USER_PRESET_DIR = '.agent-presets'`,逐项校验 `PRESET_ID = /^[a-z0-9][a-z0-9-]*$/`)。
6
+ // · **0.1.7-rc.2 起**:该包**不在依赖图里、也没被挂载**;`dsh-agent-preset-registry` 的 `definitions`
7
+ // 是**内存 Map,没有任何 fs 调用** —— 目录发现**彻底没了**。
8
+ // · 现在的载体是 profile `cordis.patch.yml` 里的**声明行**:
9
+ // - insert:
10
+ // - id: preset-<x>
11
+ // name: '@deepseek-ai/dsh-agent-preset'
12
+ // config: { id, name?, description?, order?, plugins }
13
+ // schema 见 `@deepseek-ai/dsh-agent-preset@0.1.7-rc.2/lib/index.js:13-25`(id/plugins 必填,name/description/order 可选)。
14
+ // **只有落了文件、没有声明行的预设,用户在界面上根本看不到**(桌面端「自定义」分组为空就是这个原因);
15
+ // 更糟的是老会话 resume 会 `RemoteError: Unknown agent preset: <id>`。
16
+ //
17
+ // 本模块只做一件事:**幂等地**把声明行写进目标 profile 的补丁文件(先备份 → 写 → 读回核实)。
18
+ // · 行 id 固定 `preset-<id>`(与官方内置 `preset-standard` / `preset-minimal` … 同一命名法);
19
+ // · 同 id 行已存在 → **原地更新**,绝不重复插入(重复会让注册表 `Duplicate agent preset` 直接抛);
20
+ // · 块内容与磁盘预设逐字节一致 → **一个字节都不写、不建备份**(真正的幂等);
21
+ // · 写不成 → 如实返回 `ok:false + reason`,调用方**不许**再宣称"新建会话时选择"。
22
+ //
23
+ // ⚠️ **相对路径基准变了**(最容易踩的一脚):声明行在 **profile 补丁**里,所以行内 `./x.mjs`
24
+ // 的相对基准是 **profile 目录**而不是预设目录。故 composition 里所有"确实存在于预设目录"的
25
+ // `./x.mjs` 一律改写成 `file:///` 绝对 URL(`pathToFileURL` 会做百分号编码,中文用户名也安全)。
26
+ // 预设目录里**不存在**的同名文件**原样保留** —— 那种相对引用在 profile 下本来就不通,
27
+ // 我们没有依据替它猜一个绝对路径,硬改只会把指向别处的引用改坏(如实保留,不猜)。
28
+ //
29
+ // 分层:L1 domain —— 不认识 cordis ctx;时间戳 / 文件 IO / URL 转换均可注入(便于离线断言)。
30
+ // 行数预算:本模块与 preset-install.js 都必须留在 600 行架构硬顶之内(test-architecture-guard.mjs)。
31
+
32
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
33
+ import { basename, dirname, join, resolve } from 'node:path'
34
+ import { fileURLToPath, pathToFileURL } from 'node:url'
35
+ import { queuedWrite } from '../infra/fsx.js'
36
+ import { validateAgentConfig } from './preset-yaml.js'
37
+ import { migratePluginRowKeys, validatePresetPluginRows } from './preset-rows.js'
38
+
39
+ /** 声明行指向的模块名(框架侧预设插件,0.1.7-rc.2 起才有)。 */
40
+ const AGENT_PRESET_MODULE = '@deepseek-ai/dsh-agent-preset'
41
+ /** 行 id 前缀:官方内置行是 `preset-standard` / `preset-minimal` / `preset-ptc` / `preset-cordis`,照抄同一命名法。 */
42
+ const PRESET_ROW_PREFIX = 'preset-'
43
+ /** composition 的嵌套缩进:`plugins:` 在 `config:` 下 = 8 空格,其内容再缩进 2 = **10 空格**
44
+ * (与真机既有 `preset-router-*` 行逐字节同形)。 */
45
+ const CONFIG_KEY_INDENT = 8
46
+ const COMPOSITION_INDENT = CONFIG_KEY_INDENT + 2
47
+
48
+ /** 预设 id 判据(与旧 discovery 的 `PRESET_ID = /^[a-z0-9][a-z0-9-]*$/` 同源,另容忍下划线/点以便通用)。
49
+ * 为什么必须校验:id 会进 YAML 的 `id:` 与注册表的 Map 键,形状可疑的值宁可拒绝也不写进用户补丁。 */
50
+ function isValidPresetId(id) {
51
+ return typeof id === 'string' && /^[a-z0-9][a-z0-9._-]*$/u.test(id)
52
+ }
53
+
54
+ /** YAML 单引号标量(`'` 用 `''` 转义;换行折成空格——单行值最不容易写坏用户补丁)。 */
55
+ function quoteScalar(value) {
56
+ const text = String(value).replace(/\r?\n/gu, ' ').trim()
57
+ return `'${text.replace(/'/gu, "''")}'`
58
+ }
59
+
60
+ /** 归一 composition 文本:CRLF → LF、去尾部空行、正好一个结尾换行。 */
61
+ function normalizeComposition(text) {
62
+ return `${String(text).replace(/\r\n/gu, '\n').replace(/\s+$/u, '')}\n`
63
+ }
64
+
65
+ /** 读预设清单(`preset.yml` 的 name/description/order;读不到返回 null,不抛)。
66
+ * **本模块是它的唯一定义处**:preset-source.js 从这里 re-export —— 这样依赖方向是
67
+ * `preset-source → preset-declare`(单向),不会出现"声明模块反过来依赖取源码模块"的循环。 */
68
+ function readPresetManifest(dir) {
69
+ try {
70
+ const text = readFileSync(join(dir, 'preset.yml'), 'utf8')
71
+ const pick = (key) => {
72
+ const m = text.match(new RegExp(`^\\s*${key}\\s*:\\s*(.+)$`, 'mu'))
73
+ return m === null ? null : m[1].trim().replace(/^["']|["']$/gu, '')
74
+ }
75
+ return { name: pick('name'), description: pick('description'), order: pick('order') }
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
81
+ /** 读预设目录里的 composition(`agent.cordis.yml` 优先,回退 `.yaml`)。读不到返回 null。 */
82
+ function readPresetComposition(dir) {
83
+ for (const name of ['agent.cordis.yml', 'agent.cordis.yaml']) {
84
+ const file = join(dir, name)
85
+ try {
86
+ if (existsSync(file)) return { file, text: readFileSync(file, 'utf8') }
87
+ } catch {}
88
+ }
89
+ return null
90
+ }
91
+
92
+ /**
93
+ * 读预设展示元数据(`preset.yml` 的 name/description/order),带**合理缺省**:
94
+ * · name 缺省 = 目录名(面板至少有个能认的名字;绝不发 `name: ''` 这种空标量);
95
+ * · description 缺省 = 不提这个键(可选字段,比编一句话诚实);
96
+ * · order 缺省 = 1000(排在内置预设之后,不抢它们的位置)。
97
+ * `preset.yml` 缺 name 时**也**回退目录名(readPresetManifest 会返回 null 值的 name)。
98
+ */
99
+ function presetDisplayMeta(dir, id) {
100
+ const raw = (() => {
101
+ try { return readPresetManifest(dir) } catch { return null }
102
+ })() ?? {}
103
+ const name = typeof raw.name === 'string' && raw.name.trim() !== '' ? raw.name.trim() : String(id)
104
+ const description = typeof raw.description === 'string' && raw.description.trim() !== '' ? raw.description.trim() : null
105
+ const orderNum = Number(raw.order)
106
+ const order = Number.isFinite(orderNum) ? orderNum : 1000
107
+ return { name, description, order }
108
+ }
109
+
110
+ /**
111
+ * composition 里的相对文件引用 → `file:///` 绝对 URL(**只改真存在于预设目录的那些**)。
112
+ * 形态判据:`name:` 的值是 `./x` 或 `../x`(`.mjs`/`.js`/`.cjs`/`.json`/任意相对文件)。
113
+ * 返回 `{ text, rewritten[] }`:rewritten 逐条记下 `<相对> → <file: URL>`,供调用方如实上报。
114
+ */
115
+ function absolutizeComposition(text, dir, { toFileUrl = pathToFileURL, exists = existsSync } = {}) {
116
+ const rewritten = []
117
+ const out = String(text).replace(/^([ \t]*name:[ \t]*)(\.\.?\/[^\s#]+)[ \t]*$/gmu, (all, head, spec) => {
118
+ let target = null
119
+ try { target = resolve(dir, spec) } catch { return all }
120
+ let present = false
121
+ try { present = exists(target) === true } catch { present = false }
122
+ if (!present) return all // 不在预设目录里 → 原样保留(我们没有依据替它猜路径,不猜)
123
+ let url = null
124
+ try { url = toFileUrl(target).href } catch { return all }
125
+ rewritten.push({ from: spec, to: url })
126
+ return `${head}${url}`
127
+ })
128
+ return { text: out, rewritten }
129
+ }
130
+
131
+ /** 把 composition 整段缩进成 `plugins:` 的嵌套列表(空行保持空行,不留尾随空格)。 */
132
+ function indentComposition(text, spaces = COMPOSITION_INDENT) {
133
+ const pad = ' '.repeat(spaces)
134
+ return String(text)
135
+ .replace(/\r\n/gu, '\n')
136
+ .split('\n')
137
+ .map((line) => (line.trim() === '' ? '' : `${pad}${line}`))
138
+ .join('\n')
139
+ }
140
+
141
+ /** 渲染一行完整的预设声明(`- insert:` 顶级项 + `- id:` 子项 + `config:`)。 */
142
+ function renderPresetRow(config, compositionText) {
143
+ const lines = [
144
+ '- insert:',
145
+ ` - id: ${PRESET_ROW_PREFIX}${config.id}`,
146
+ ` name: '${AGENT_PRESET_MODULE}'`,
147
+ ' config:',
148
+ ` id: ${config.id}`,
149
+ ]
150
+ if (typeof config.name === 'string' && config.name !== '') lines.push(` name: ${quoteScalar(config.name)}`)
151
+ if (typeof config.description === 'string' && config.description !== '') lines.push(` description: ${quoteScalar(config.description)}`)
152
+ if (config.order !== null && config.order !== undefined) lines.push(` order: ${config.order}`)
153
+ lines.push(' plugins:')
154
+ lines.push(indentComposition(compositionText).replace(/\s+$/u, ''))
155
+ return lines.join('\n')
156
+ }
157
+
158
+ /** 声明成功后那句核心文案(**成功/未改动两条路共用**,避免一边说"重启后可选"、另一边忘了说)。 */
159
+ function presetDeclarationSentence(rowId, patchPath, { unchanged = false } = {}) {
160
+ const tail = unchanged ? '(此前已声明、内容一致,本次未改动任何字节)' : ''
161
+ return `已声明为预设行 ${rowId}(写入 ${patchPath})${tail};**重启实例后**在新会话可选`
162
+ }
163
+
164
+ /**
165
+ * 找一个 `- insert:` 块的**结束**下标(独占)—— 这是本模块最容易写错的一处,故单独成函数。
166
+ *
167
+ * 为什么不能只判"下一个列 0 的 `^- `":那样在**声明行是文件最后一块**时(我们新写的行正是如此),
168
+ * 块的边界会一路吃到文件末尾,于是行级的 ` name: '…'` 被当成 `config.plugins` 的内容 ——
169
+ * 读回核实会报"plugins 与 agent.cordis.yml 不一致",而文件其实完全正确(自测真抓到过这个假红)。
170
+ *
171
+ * 判据(与真机 `dev-*.yml` 的缩进惯例一致:insert 子项 4 空格、行级键 6 空格、嵌套 6+):
172
+ * · 列 0 的 `^- ` → 新的顶级条目,块到此为止;
173
+ * · 缩进 **≤ 2** 的非空行 → 也是新的顶级条目(写成 `- id: x` 或 `-id` 都算);
174
+ * · 缩进 ≥ 4 → 仍是本块内容(`- id:` / `name:` / `config:` / composition…)。
175
+ * 返回下标 **不含**块尾的空行(尾随空行属于块与块之间的分隔,不该被搬来搬去)。
176
+ */
177
+ function findInsertBlockEnd(lines, start) {
178
+ let end = lines.length
179
+ let lastContent = start
180
+ for (let j = start + 1; j < lines.length; j += 1) {
181
+ const line = lines[j]
182
+ if (line.trim() === '') continue
183
+ const indent = (line.match(/^ */u) ?? [''])[0].length
184
+ if (/^- /u.test(line) || indent <= 2) { end = j; break }
185
+ lastContent = j
186
+ }
187
+ if (end === lines.length) return lastContent + 1
188
+ return end
189
+ }
190
+
191
+ /**
192
+ * 从整个补丁文件文本里找出**所有** `preset-*` 声明行(含它们的块边界)。
193
+ * 返回 `[{ id, start, end, text }]`(end 为**独占**下标,指向块后的第一个顶级行或文件末)。
194
+ */
195
+ function scanPresetRows(text) {
196
+ const lines = String(text ?? '').split(/\r?\n/u)
197
+ const rows = []
198
+ for (let i = 0; i < lines.length; i += 1) {
199
+ if (!/^- insert:\s*$/u.test(lines[i])) continue
200
+ const end = findInsertBlockEnd(lines, i)
201
+ let id = null
202
+ for (let j = i + 1; j < end; j += 1) {
203
+ const m = lines[j].match(new RegExp(`^ {4}- id: (${PRESET_ROW_PREFIX}[A-Za-z0-9._-]+)\\s*$`, 'u'))
204
+ if (m !== null) { id = m[1]; break }
205
+ }
206
+ if (id === null) { i = Math.max(i, end - 1); continue }
207
+ rows.push({ id, start: i, end, text: lines.slice(i, end).join('\n').replace(/\s+$/u, '') })
208
+ i = Math.max(i, end - 1)
209
+ }
210
+ return rows
211
+ }
212
+
213
+ /** 把一个区间(行下标,独占 end)从行数组里删掉。 */
214
+ function spliceOut(lines, start, end) {
215
+ return lines.slice(0, start).concat(lines.slice(end))
216
+ }
217
+
218
+ /** 收拾块间空行:不留连续空行,不留文件头/尾空行;结尾正好一个换行。 */
219
+ function tidyBlankLines(lines) {
220
+ const out = []
221
+ for (const line of lines) {
222
+ if (line.trim() === '' && out.length > 0 && out[out.length - 1].trim() === '') continue
223
+ out.push(line)
224
+ }
225
+ while (out.length > 0 && out[0].trim() === '') out.shift()
226
+ while (out.length > 0 && out[out.length - 1].trim() === '') out.pop()
227
+ return `${out.join('\n')}\n`
228
+ }
229
+
230
+ /**
231
+ * 把声明行**幂等**地写进补丁文本(纯文本变换,不碰盘;写盘与核实见 declarePresetRow)。
232
+ * 返回 `{ text, changed, created, removed }`:
233
+ * · 同 id 已有行 → 用新块替换(`created:false`);`removed` 记下被清掉的重复行 id;
234
+ * · 没有 → 追加到末尾(`created:true`);
235
+ * · 新块与旧块逐字节一致 → `changed:false`,`text` 与输入一致(**真正的幂等**)。
236
+ * 老的 `[]` 顶层占位符在这里就被去掉(issue #7 事故:`[]` 后面再跟条目是非法 YAML,启动直接崩)。
237
+ */
238
+ function upsertPresetRow(patchText, rowText, rowId) {
239
+ const original = String(patchText ?? '')
240
+ const withoutPlaceholder = original
241
+ .split(/\r?\n/u)
242
+ .filter((line) => !/^\s*\[\s*\]\s*$/u.test(line))
243
+ .join('\n')
244
+ const lines = withoutPlaceholder.split(/\r?\n/u)
245
+ const rows = scanPresetRows(lines.join('\n'))
246
+ const same = rows.filter((r) => r.id === rowId)
247
+ // 块尾补一个空行 = 与下一个顶级条目之间留出分隔(补丁文件的可读性;YAML 里空行无意义)。
248
+ // 旧实现不带它,于是"原地替换"会把原来那句 `- id: …` 贴到 composition 的最后一行上(自测抓到的形状退化)。
249
+ const block = [...rowText.split('\n'), '']
250
+ if (same.length > 0) {
251
+ if (same.length === 1 && same[0].text === rowText) {
252
+ const tidy = tidyBlankLines(lines)
253
+ return { text: tidy, changed: tidy !== original, created: false, removed: [] }
254
+ }
255
+ // 从后往前替换/删除,下标才不会失效
256
+ const removed = []
257
+ const ordered = [...same].sort((a, b) => b.start - a.start)
258
+ let next = lines
259
+ for (const [index, row] of ordered.entries()) {
260
+ if (index === 0) {
261
+ next = next.slice(0, row.start).concat(block, next.slice(row.end))
262
+ } else {
263
+ removed.push(row.id)
264
+ next = spliceOut(next, row.start, row.end)
265
+ }
266
+ }
267
+ return { text: tidyBlankLines(next), changed: true, created: false, removed }
268
+ }
269
+ const inserted = [...lines]
270
+ while (inserted.length > 0 && inserted[inserted.length - 1].trim() === '') inserted.pop()
271
+ inserted.push(...block)
272
+ return { text: tidyBlankLines(inserted), changed: true, created: true, removed: [] }
273
+ }
274
+
275
+ // ── 读回核实(**写后必做**):YAML 关键字段比对,逐条给出证据 ────────────────────────────
276
+
277
+ /** 从补丁文本里抽出某个 preset 行的 `config:` 段落,转成「键 → 值」的浅表(只认 config 的直接子键)。
278
+ * 值一律做"去掉包裹引号 + 折行拼接"的归一,用来做**关键字段比对**(不是通用 YAML 解析器)。 */
279
+ function readPresetRowConfig(patchText, rowId) {
280
+ const lines = String(patchText ?? '').split(/\r?\n/u)
281
+ const rows = scanPresetRows(lines.join('\n'))
282
+ const hit = rows.find((r) => r.id === rowId)
283
+ if (hit === undefined) return null
284
+ const block = lines.slice(hit.start, hit.end)
285
+ let cfgAt = -1
286
+ for (let i = 0; i < block.length; i += 1) {
287
+ if (new RegExp(`^ {6}config:\\s*$`, 'u').test(block[i])) { cfgAt = i; break }
288
+ }
289
+ if (cfgAt === -1) return null
290
+ const fields = {}
291
+ const plugins = []
292
+ let inPlugins = false
293
+ for (let i = cfgAt + 1; i < block.length; i += 1) {
294
+ const line = block[i]
295
+ if (line.trim() === '' || /^\s*#/u.test(line)) continue
296
+ const indent = (line.match(/^ */u) ?? [''])[0].length
297
+ if (indent < CONFIG_KEY_INDENT) break // 回到行级键(`- id:` / `name:`)
298
+ if (indent === CONFIG_KEY_INDENT) {
299
+ // ⚠️ `^ {8}`(= 至少 8 空格,后面直接是键名)而**不是**"正好 8 空格"的写法 —— 后者在 config 是
300
+ // 文档**最后一块**时会把每一行都当成 `plugins` 的内容,config 字段全部读成 undefined,
301
+ // 于是"写成功了却报核实不通过"(自测真抓到过)。
302
+ const kv = line.match(new RegExp(`^ {${CONFIG_KEY_INDENT}}([A-Za-z_][\\w-]*):\\s*(.*)$`, 'u'))
303
+ if (kv === null) continue
304
+ inPlugins = kv[1] === 'plugins'
305
+ if (!inPlugins) fields[kv[1]] = kv[2].trim()
306
+ continue
307
+ }
308
+ if (inPlugins) plugins.push(line.slice(COMPOSITION_INDENT))
309
+ }
310
+ return { fields, plugins }
311
+ }
312
+
313
+ /** 把「键 → 原始标量」归一成可比较的值(去引号、`''`→`'`、数字字面量转数字)。 */
314
+ function normalizeScalar(raw) {
315
+ if (raw === undefined || raw === null) return null
316
+ const text = String(raw).trim()
317
+ if (text === '') return ''
318
+ if (text.startsWith("'") && text.endsWith("'") && text.length >= 2) return text.slice(1, -1).replace(/''/gu, "'")
319
+ if (text.startsWith('"') && text.endsWith('"') && text.length >= 2) return text.slice(1, -1)
320
+ const num = Number(text)
321
+ return Number.isFinite(num) && text !== '' ? num : text
322
+ }
323
+
324
+ /**
325
+ * 写后读回核实:① 行在;② 模块名对;③ `config.id` 对;④ name/description/order 与期望一致;
326
+ * ⑤ `plugins` 与磁盘 `agent.cordis.yml` 逐行一致(已把相对引用改写成 file: URL 之后);
327
+ * ⑥ 没有残留的相对 `./` 引用(除非源目录里本来就没有那个文件)。
328
+ * 返回 `{ ok, problems[], checked }` —— 任一条不通过都算不通过(绝不"写没写成"靠假设)。
329
+ */
330
+ function verifyDeclaredRow(patchText, { rowId, config, compositionText }) {
331
+ const problems = []
332
+ const block = scanPresetRows(patchText).find((r) => r.id === rowId)
333
+ if (block === undefined) {
334
+ return { ok: false, problems: [`读回时找不到声明行 ${rowId}`], checked: 0 }
335
+ }
336
+ if (!block.text.includes(`name: '${AGENT_PRESET_MODULE}'`)) problems.push(`声明行的 name 不是 ${AGENT_PRESET_MODULE}`)
337
+ const parsed = readPresetRowConfig(patchText, rowId)
338
+ if (parsed === null) problems.push('声明行里读不到 config 段')
339
+ else {
340
+ const gotId = normalizeScalar(parsed.fields.id)
341
+ if (gotId !== config.id) problems.push(`config.id 读回是 ${JSON.stringify(gotId)},期望 ${JSON.stringify(config.id)}`)
342
+ if (typeof config.name === 'string' && normalizeScalar(parsed.fields.name) !== config.name) {
343
+ problems.push(`config.name 读回是 ${JSON.stringify(normalizeScalar(parsed.fields.name))},期望 ${JSON.stringify(config.name)}`)
344
+ }
345
+ if (typeof config.description === 'string' && normalizeScalar(parsed.fields.description) !== config.description) {
346
+ problems.push('config.description 与源 preset.yml 不一致')
347
+ }
348
+ if (config.order !== null && config.order !== undefined && normalizeScalar(parsed.fields.order) !== config.order) {
349
+ problems.push(`config.order 读回是 ${JSON.stringify(normalizeScalar(parsed.fields.order))},期望 ${JSON.stringify(config.order)}`)
350
+ }
351
+ const want = normalizeComposition(compositionText).replace(/\s+$/u, '').split('\n')
352
+ // 注释/空行不做逐字比对:YAML 里它们不是数据(config 顶层注释会被解析器丢掉,缩进更深的
353
+ // 注释会作为块标量内容留下)—— 逐字比对会把"文件完全正确"判成不一致(自测真抓到过这个假红)。
354
+ // 真正要钉的是**配置行**逐行一致,那才是框架读进去的东西。
355
+ const significant = (list) => list.filter((line) => line.trim() !== '' && !/^\s*#/u.test(line))
356
+ if (significant(parsed.plugins).join('\n') !== significant(want).join('\n')) {
357
+ const at = significant(want).findIndex((line, i) => significant(parsed.plugins)[i] !== line)
358
+ problems.push(`config.plugins 与 agent.cordis.yml 不一致(第 ${at + 1} 行起)`)
359
+ }
360
+ }
361
+ const residual = block.text.split('\n').filter((line) => /^\s+name:\s*\.\.?\//u.test(line))
362
+ if (residual.length > 0) problems.push(`声明行里仍有 ${residual.length} 处相对引用未改写:${residual[0].trim()}`)
363
+ return { ok: problems.length === 0, problems, checked: 6 }
364
+ }
365
+
366
+ // ── 落盘入口 ────────────────────────────────────────────────────────────────────────
367
+
368
+ /** 组装一行预设声明的**全部输入**(纯函数式,不写盘):composition / config / rowText。 */
369
+ function buildPresetDeclaration(dir, id, opts = {}) {
370
+ const presetId = String(id ?? '').trim()
371
+ if (!isValidPresetId(presetId)) return { ok: false, reason: 'invalid-preset-id', detail: `预设 id 不合法:${JSON.stringify(id)}` }
372
+ if (typeof dir !== 'string' || dir === '' || !existsSync(dir)) {
373
+ return { ok: false, reason: 'preset-dir-missing', detail: `预设目录不存在:${dir}` }
374
+ }
375
+ const composition = readPresetComposition(dir)
376
+ if (composition === null) return { ok: false, reason: 'no-composition', detail: `预设目录里没有 agent.cordis.yml/.yaml:${dir}` }
377
+ const verdict = validateAgentConfig(composition.text)
378
+ if (verdict.ok !== true) {
379
+ return { ok: false, reason: 'invalid-composition', detail: `agent.cordis.yml 未通过结构校验:${verdict.problems[0] ?? ''}` }
380
+ }
381
+ // ① 插件 config 旧键迁移(**加法**:把框架 schema 根本读不到的旧键改成真键;表见 preset-rows.js)。
382
+ // 与框架升级那一步**同一张表**(PRESET_CONFIG_KEY_MIGRATIONS),只是判据挂在**写行那一刻** ——
383
+ // 预设还会经「装预设型子包 / 源码装配 / 手工放文件」落盘,那些路完全不过升级步骤(真机 D-⑦)。
384
+ // 只动唯一命中、且**该行还没有真键**的那一行;版本判据过不去就一个字都不动。
385
+ const migrationVersion = opts.frameworkVersion ?? null
386
+ const migratedKeys = migratePluginRowKeys(composition.text, migrationVersion)
387
+ // ② 写行**之前**的契约校验:行结构 / `file:///` 目标存在性 / 已确证的框架契约。
388
+ // 命中即**如实拒绝写入** —— 绝不往用户 profile 里写一行必然「加载失败」的声明。
389
+ const rowsVerdict = validatePresetPluginRows(migratedKeys.text, {
390
+ checkFiles: true,
391
+ presetDir: dir,
392
+ exists: (localPath) => existsSync(localPath),
393
+ })
394
+ if (rowsVerdict.ok !== true) {
395
+ const first = rowsVerdict.problems[0]
396
+ return {
397
+ ok: false,
398
+ reason: first.code === 'file-target-missing' || first.code === 'file-url-unparsable' || first.code === 'relative-target-missing'
399
+ ? 'unresolvable-plugin-file'
400
+ : 'invalid-plugin-rows',
401
+ detail: `预设的 plugins 行未通过契约校验:${rowsVerdict.problems.map((p) => p.detail).join(';')}`
402
+ + `(出路:${first.hint})`,
403
+ problems: rowsVerdict.problems,
404
+ }
405
+ }
406
+ const absolutized = absolutizeComposition(migratedKeys.text, dir, opts)
407
+ const compositionText = normalizeComposition(absolutized.text)
408
+ const meta = presetDisplayMeta(dir, presetId)
409
+ const config = { id: presetId, name: meta.name, description: meta.description, order: meta.order }
410
+ const rowId = `${PRESET_ROW_PREFIX}${presetId}`
411
+ return {
412
+ ok: true, id: presetId, rowId, dir, config, compositionText,
413
+ compositionFile: composition.file, rewritten: absolutized.rewritten, aliased: migratedKeys.migrated,
414
+ migrationNote: migratedKeys.skippedReason,
415
+ rowText: renderPresetRow(config, compositionText),
416
+ }
417
+ }
418
+
419
+ /** 补丁文件路径:显式 `patchPath` 优先,否则 `profileDir/cordis.patch.yml`;两者都没有 → null。 */
420
+ function resolvePatchPath({ patchPath = null, profileDir = null } = {}) {
421
+ if (typeof patchPath === 'string' && patchPath !== '') return patchPath
422
+ if (typeof profileDir === 'string' && profileDir !== '') return join(profileDir, 'cordis.patch.yml')
423
+ return null
424
+ }
425
+
426
+ /** 备份名:`<补丁>.bak-preset-<时间戳>`(与既有 `.bak-<ts>` 家族同形状,可被同一套清理识别)。
427
+ * 已存在就自增后缀 —— 同一毫秒的两次声明绝不互相覆盖(F4 的同族教训)。 */
428
+ function presetRowBackupPath(patchPath, timeValue, exists = existsSync) {
429
+ const base = `${patchPath}.bak-preset-${timeValue}`
430
+ if (!exists(base)) return base
431
+ for (let n = 2; n < 1000; n += 1) {
432
+ const candidate = `${base}-${n}`
433
+ if (!exists(candidate)) return candidate
434
+ }
435
+ return `${base}-${Date.now()}`
436
+ }
437
+
438
+ /**
439
+ * **装配预设后写声明行**(唯一写盘点)。返回结构化结果,字段全部如实:
440
+ * `{ ok, status: 'created'|'updated'|'unchanged'|'skipped'|'failed', reason?, detail?, rowId, patchPath,
441
+ * backup, changed, verified: {ok, problems, checked} | null, rewritten[], note }`
442
+ *
443
+ * 五种结局(`reason` 只在非 created/updated/unchanged 时出现):
444
+ * · `created` — 新写了 `preset-<id>` 行(先备份原有补丁);
445
+ * · `updated` — 已有同 id 行,原地换成新内容(先备份原有补丁);
446
+ * · `unchanged` — 已有同 id 行且**逐字节一致**:一个字节都没写、不建备份(真正的幂等);
447
+ * · `skipped` — `no-patch-path`:调用方没给 profile(离线单测/无 profile 场景),**明确不宣称已声明**;
448
+ * · `failed` — 预设目录/id 不合法、YAML 校验不过、写盘失败、读回核实不通过(**绝不谎报**)。
449
+ */
450
+ function declarePresetRow(dir, id, opts = {}) {
451
+ const {
452
+ patchPath = null, profileDir = null, now = Date.now,
453
+ backup = true, verify = true, toFileUrl = pathToFileURL, exists = existsSync,
454
+ frameworkVersion = null,
455
+ } = opts
456
+ const built = buildPresetDeclaration(dir, id, { toFileUrl, exists, frameworkVersion })
457
+ if (built.ok !== true) {
458
+ return { ok: false, status: 'failed', reason: built.reason, detail: built.detail, rowId: null, patchPath: null, backup: null, changed: false, verified: null, rewritten: [], aliased: [], problems: built.problems ?? [], note: built.detail }
459
+ }
460
+ const target = resolvePatchPath({ patchPath, profileDir })
461
+ if (target === null) {
462
+ return {
463
+ ok: false, status: 'skipped', reason: 'no-patch-path', rowId: built.rowId, patchPath: null, backup: null,
464
+ changed: false, verified: null, rewritten: built.rewritten, aliased: built.aliased, config: built.config,
465
+ note: `未写入声明行:本次调用没有目标 profile(既没有 patchPath 也没有 profileDir)—— `
466
+ + `**文件已就位,但当前框架版本(0.1.7-rc.x 起)需要声明行才能显示**;请在目标 profile 的 cordis.patch.yml 里补一行 preset-${built.id}`,
467
+ }
468
+ }
469
+ // 补丁文件所在目录不存在 = 调用方给的 profile 是错的:宁可 skipped 也不凭空造一个 profile 目录
470
+ try {
471
+ if (!exists(dirname(target))) {
472
+ return {
473
+ ok: false, status: 'failed', reason: 'no-profile-dir', rowId: built.rowId, patchPath: target, backup: null,
474
+ changed: false, verified: null, rewritten: built.rewritten, aliased: built.aliased, config: built.config,
475
+ detail: `目标 profile 目录不存在:${dirname(target)}`,
476
+ note: `未写入声明行:目标 profile 目录不存在(${dirname(target)})—— **文件已就位,但当前框架版本需要声明行才能显示**`,
477
+ }
478
+ }
479
+ } catch {}
480
+ return queuedWrite(() => {
481
+ let before = ''
482
+ try { if (exists(target)) before = readFileSync(target, 'utf8') } catch { before = '' }
483
+ const outcome = upsertPresetRow(before, built.rowText, built.rowId)
484
+ if (outcome.changed !== true) {
485
+ return {
486
+ ok: true, status: 'unchanged', rowId: built.rowId, patchPath: target, backup: null, changed: false,
487
+ verified: verify === true ? verifyDeclaredRow(before, built) : null, rewritten: built.rewritten, aliased: built.aliased, config: built.config,
488
+ removed: outcome.removed,
489
+ // 与 created/updated 用**同一句**核心文案:幂等重装时用户同样要看懂"它在、重启后可选",
490
+ // 只多一句"本次未改动任何字节"(诚实交代用户的文件没被动过)。
491
+ note: presetDeclarationSentence(built.rowId, target, { unchanged: true }),
492
+ }
493
+ }
494
+ let backupPath = null
495
+ if (before.trim() !== '' && backup === true) {
496
+ backupPath = presetRowBackupPath(target, typeof now === 'function' ? now() : now, exists)
497
+ try {
498
+ copyFileSync(target, backupPath)
499
+ } catch (error) {
500
+ return {
501
+ ok: false, status: 'failed', reason: 'backup-failed', rowId: built.rowId, patchPath: target, backup: null,
502
+ changed: false, verified: null, rewritten: built.rewritten, aliased: built.aliased, config: built.config,
503
+ detail: `备份失败(绝不在没备份的情况下改用户补丁):${error instanceof Error ? error.message : String(error)}`,
504
+ note: '未写入声明行:改前备份失败,已放弃写入(补丁文件一个字节都没动)',
505
+ }
506
+ }
507
+ }
508
+ try {
509
+ mkdirSync(dirname(target), { recursive: true })
510
+ writeFileSync(target, outcome.text, 'utf8')
511
+ } catch (error) {
512
+ return {
513
+ ok: false, status: 'failed', reason: 'write-failed', rowId: built.rowId, patchPath: target, backup: backupPath,
514
+ changed: false, verified: null, rewritten: built.rewritten, aliased: built.aliased, config: built.config,
515
+ detail: `写入失败:${error instanceof Error ? error.message : String(error)}`,
516
+ note: `写入声明行失败(${error instanceof Error ? error.message : String(error)})—— `
517
+ + '**文件已就位,但当前框架版本(0.1.7-rc.x 起)需要声明行才能显示**',
518
+ }
519
+ }
520
+ let readBack = null
521
+ try { readBack = readFileSync(target, 'utf8') } catch { readBack = null }
522
+ if (readBack === null) {
523
+ return {
524
+ ok: false, status: 'failed', reason: 'readback-failed', rowId: built.rowId, patchPath: target, backup: backupPath,
525
+ changed: false, verified: null, rewritten: built.rewritten, aliased: built.aliased, config: built.config,
526
+ detail: '写后读回失败(读不到文件)', note: '写入声明行后读回失败:无法确认是否写成,请人工检查该补丁文件',
527
+ }
528
+ }
529
+ const checked = verify === true ? verifyDeclaredRow(readBack, built) : null
530
+ const ok = checked === null || checked.ok === true
531
+ const extra = outcome.removed.length > 0 ? `;顺带清掉 ${outcome.removed.length} 行重复的同 id 声明` : ''
532
+ return {
533
+ ok, status: ok ? (outcome.created ? 'created' : 'updated') : 'failed',
534
+ reason: ok ? null : 'verify-failed',
535
+ detail: ok ? null : `读回核实不通过:${checked.problems.join(';')}`,
536
+ rowId: built.rowId, patchPath: target, backup: backupPath, changed: true,
537
+ verified: checked, rewritten: built.rewritten, aliased: built.aliased, config: built.config, removed: outcome.removed,
538
+ note: ok
539
+ ? presetDeclarationSentence(built.rowId, target)
540
+ + (built.rewritten.length > 0 ? `;composition 里 ${built.rewritten.length} 处相对文件已改写为 file: URL(基准是 profile 目录,不是预设目录)` : '')
541
+ + (built.aliased.length > 0
542
+ ? `;composition 里 ${built.aliased.length} 处插件旧键已按框架 schema 迁移(`
543
+ + `${built.aliased.map((r) => `${r.pkg} 的 ${r.from} → ${r.to}(第 ${r.line} 行)`).join('、')})`
544
+ : '')
545
+ + extra
546
+ : `声明行写入后读回核实不通过:${checked.problems.join(';')}(已备份到 ${backupPath},请人工检查)`,
547
+ }
548
+ })
549
+ }
550
+
551
+ /** 面板/日志用的一句话(**按实际落盘与声明结果生成**,绝不无脑说"新建会话时选择"):
552
+ * · 声明成功 → 「已声明为预设行 preset-x(写入 …);**重启实例后**在新会话可选」;
553
+ * · 只落了文件 → 明确说「文件已就位,但当前框架版本需要声明行才能显示」并给出出路。 */
554
+ function presetDeclarationNote(result) {
555
+ const r = result ?? {}
556
+ if (r.ok === true && (r.status === 'created' || r.status === 'updated' || r.status === 'unchanged')) {
557
+ return presetDeclarationSentence(r.rowId, r.patchPath, { unchanged: r.status === 'unchanged' })
558
+ }
559
+ const why = r.detail ?? r.reason ?? '未知原因'
560
+ // ⚠️ 失败分支**不能**说"重启实例后即可选":那时还没写成,重启也不会出现它。
561
+ // ("重启实例后可选"是**声明成功**那一路的语义,只出现在 presetDeclarationSentence 里。)
562
+ return `**文件已就位,但当前框架版本(0.1.7-rc.x 起预设改为声明行)需要声明行才能在界面上显示** —— `
563
+ + `本次声明未写成(${why})。出路:在目标 profile 的 cordis.patch.yml 里补一行 `
564
+ + `\`- insert:\` / \`- id: preset-<预设 id>\` / \`name: '${AGENT_PRESET_MODULE}'\`(config 取该预设的 agent.cordis.yml 作为 plugins),补上后即可选用`
565
+ }
566
+
567
+ export {
568
+ AGENT_PRESET_MODULE, COMPOSITION_INDENT, CONFIG_KEY_INDENT, PRESET_ROW_PREFIX,
569
+ absolutizeComposition, buildPresetDeclaration, declarePresetRow, findInsertBlockEnd,
570
+ indentComposition, isValidPresetId, normalizeComposition, presetDeclarationNote, presetDisplayMeta,
571
+ presetRowBackupPath, quoteScalar, readPresetComposition, readPresetManifest, readPresetRowConfig,
572
+ renderPresetRow, resolvePatchPath, scanPresetRows, tidyBlankLines, upsertPresetRow, verifyDeclaredRow,
573
+ }
@@ -23,7 +23,7 @@
23
23
  import { existsSync } from 'node:fs'
24
24
  import { basename, join } from 'node:path'
25
25
  import { findPresetDirs, isPresetDir } from './preset-source.js'
26
- import { assemblePreset, recordPresetSource, suggestedOverwritePresetAction } from './preset-install.js'
26
+ import { assemblePreset, declarationClauseForReports, recordPresetSource, suggestedOverwritePresetAction } from './preset-install.js'
27
27
  import { noteChannel } from './git-channel.js'
28
28
  import { dshHome } from '../infra/paths.js'
29
29
 
@@ -61,13 +61,18 @@ async function installPresetsCarriedByPackage({ job, name, profileDir, repo = nu
61
61
  if (presets.length === 0) return { handled: false, installed: false, error: null }
62
62
 
63
63
  const presetsRoot = deps.presetsRoot ?? join(dshHome(), '.agent-presets')
64
+ // 声明行(框架 0.1.7-rc.x:预设改为 profile 补丁里的 `preset-<id>` 声明行,不再扫目录)。
65
+ // 这条通道天然知道 profileDir,所以能直接把它当目标写出声明行;离线单测可用 deps.patchPath 覆盖。
66
+ const patchPath = typeof deps.patchPath === 'string' && deps.patchPath !== '' ? deps.patchPath : null
64
67
  noteChannel(job, `装出来的包 ${packageName} 里带着预设(${presets.map((p) => basename(p)).join('、')}):按预设装配,不注册为插件(它没有插件入口)`)
65
68
  const limit = Number.isFinite(deps.limit) ? deps.limit : 8
66
69
  const reports = []
67
70
  for (const p of presets.slice(0, limit)) {
68
71
  // eslint-disable-next-line no-await-in-loop
69
- const report = assemblePreset(p, basename(p), {
72
+ const report = await assemblePreset(p, basename(p), {
70
73
  presetsRoot,
74
+ profileDir,
75
+ ...(patchPath === null ? {} : { patchPath }),
71
76
  overwrite: deps.overwrite === true, // 默认**只补不覆盖**(与 0.5.26 的语义一致)
72
77
  ...(deps.now === undefined ? {} : { now: deps.now }),
73
78
  })
@@ -82,6 +87,11 @@ async function installPresetsCarriedByPackage({ job, name, profileDir, repo = nu
82
87
  added: r.added?.length ?? 0, skipped: r.skipped?.length ?? 0, overwritten: r.overwritten?.length ?? 0,
83
88
  identical: r.identical?.length ?? 0, backup: r.backup ?? null, bytes: r.bytes ?? 0,
84
89
  marker: r.ok === true ? 'agent.cordis.yml' : null, carriedBy: packageName,
90
+ // 声明行结果(0.5.28 加法):结构字段下发,面板不必去解析 note 才知道"到底能不能选"
91
+ declaration: r.declaration === null || r.declaration === undefined ? null : {
92
+ ok: r.declaration.ok === true, status: r.declaration.status ?? null, rowId: r.declaration.rowId ?? null,
93
+ patchPath: r.declaration.patchPath ?? null, reason: r.declaration.reason ?? null, detail: r.declaration.detail ?? null,
94
+ },
85
95
  }))
86
96
  if (job !== null && job !== undefined) {
87
97
  job.presetInstalled = summary
@@ -96,7 +106,8 @@ async function installPresetsCarriedByPackage({ job, name, profileDir, repo = nu
96
106
  })
97
107
  }
98
108
  job.presetNote = `预设已装配:${okReports.map((r) => r.name).join('、') || '(无)'}`
99
- + `(落盘 ${presetsRoot};**新建会话时选择**)`
109
+ + `(落盘 ${presetsRoot})`
110
+ + declarationClauseForReports(okReports)
100
111
  + `;来源:包 ${packageName} 自带的预设目录(该包没有插件入口,已按预设装配、未注册为插件)`
101
112
  + (conflicted.length > 0 ? `;同名预设 ${conflicted.join('、')} 默认只补不覆盖(缺的文件已补、现有文件未改动),如需覆盖请点「覆盖该预设」` : '')
102
113
  + (failed.length > 0 ? `;${failed.length} 个预设未装配:${failed.map((r) => `${r.name}(${r.error})`).join('、')}` : '')