@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.
@@ -24,6 +24,7 @@ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSy
24
24
  import { basename, dirname, join, relative, sep } from 'node:path'
25
25
  import { dshHome } from '../infra/paths.js'
26
26
  import { validateAgentConfig } from './preset-yaml.js'
27
+ import { declarePresetRow, presetDeclarationNote } from './preset-declare.js'
27
28
 
28
29
  /** 预设的判别文件(框架发现预设看的就是这个:`<presetsRoot>/<name>/agent.cordis.yml`)。 */
29
30
  const PRESET_MARKER_FILES = ['agent.cordis.yml', 'agent.cordis.yaml']
@@ -90,6 +91,78 @@ function verifyWrittenFiles(dest, srcDir, rels) {
90
91
  return { ok: mismatched.length === 0, checked: rels.length, mismatched }
91
92
  }
92
93
 
94
+ /**
95
+ * 装配后要不要写**声明行**(框架 0.1.7-rc.x 的机制迁移,见 preset-declare.js 顶部):
96
+ * 0.1.5 及更早靠"扫 `$DSH_HOME/.agent-presets/` 目录"发现预设;rc.2 起目录发现被彻底移除,
97
+ * 注册表只认 profile `cordis.patch.yml` 里的 `preset-<id>` 声明行。所以"只把文件铺上去"在
98
+ * 新框架上等于**用户看不见这个预设**(桌面端「自定义」为空就是这么来的),老会话 resume 还会
99
+ * `RemoteError: Unknown agent preset`。
100
+ *
101
+ * 语义边界(默认**不动**用户补丁):
102
+ * · 只在显式给了 `patchPath`/`profileDir` 时才写 —— 离线单测、无 profile 场景不凭空造文件;
103
+ * · 写前备份、写后读回核实(preset-declare.js 内部做全);
104
+ * · 写不成 → **如实返回失败原因**,调用方据此生成文案,**绝不**再说"新建会话时选择"。
105
+ * 返回声明结果对象,或 null(没给目标 profile / 调用方显式关闭)。**异步**:写盘走 fsx 的 queuedWrite
106
+ * 串行队列(与补丁文件的其它写者互斥),所以调用方必须 await —— 忘了 await 会拿到一个 Promise,
107
+ * `declaration.ok` 恒为 undefined,于是"明明写成了却报没写成"(自测真抓到过这个形状)。
108
+ */
109
+ async function runPresetDeclaration(srcDir, name, { patchPath = null, profileDir = null, declare = null, now = Date.now } = {}) {
110
+ if (typeof declare === 'function') {
111
+ try { return await declare(srcDir, name) } catch (error) {
112
+ return { ok: false, status: 'failed', reason: 'declare-threw', detail: error instanceof Error ? error.message : String(error), rowId: null, patchPath: null, backup: null, changed: false, verified: null, rewritten: [] }
113
+ }
114
+ }
115
+ if (patchPath === null && profileDir === null) return null
116
+ try {
117
+ return await declarePresetRow(srcDir, name, { patchPath, profileDir, now })
118
+ } catch (error) {
119
+ return {
120
+ ok: false, status: 'failed', reason: 'declare-threw', rowId: null, patchPath: patchPath ?? null, backup: null,
121
+ changed: false, verified: null, rewritten: [],
122
+ detail: `写声明行时异常:${error instanceof Error ? error.message : String(error)}`,
123
+ }
124
+ }
125
+ }
126
+
127
+ /** 面板文案里"这个预设到底能不能被看见"的那一句(**按实际结果生成**,绝不无脑说"新建会话时选择")。
128
+ * `null`(调用方没给目标 profile → 根本没尝试声明)也必须**明说**:文件已就位、但当前框架版本
129
+ * 需要声明行才显示,并给出出路 —— 这正是旧文案"新建会话时选择"撒谎的那一格。 */
130
+ function declarationClause(declaration) {
131
+ if (declaration === null || declaration === undefined) {
132
+ return ';**文件已就位,但当前框架版本(0.1.7-rc.x 起预设改为声明行)需要声明行才能在界面上显示** —— '
133
+ + '本次没有目标 profile 可写,请在目标 profile 的 cordis.patch.yml 里补 `preset-<预设 id>` 声明行'
134
+ }
135
+ if (declaration.ok === true) return `;${presetDeclarationNote(declaration)}`
136
+ return `;⚠️ ${presetDeclarationNote(declaration)}`
137
+ }
138
+
139
+ /** 多个预设装配报告 → 一句话(**成功逐条点名、失败如实报因**,两类都在,绝不只报好消息)。
140
+ * 为什么不是"有一个成功就整体乐观":一次装配可能有 3 个预设,2 个声明成功、1 个写失败 ——
141
+ * 用户必须知道**哪个**看不见,否则他会对着"已装配"这三个字找不到那一个预设。 */
142
+ function declarationClauseForReports(reports) {
143
+ const list = (Array.isArray(reports) ? reports : []).filter((r) => r !== null && typeof r === 'object')
144
+ const decls = list.map((r) => r.declaration).filter((d) => d !== null && d !== undefined)
145
+ if (decls.length === 0) {
146
+ return ';**文件已就位,但当前框架版本(0.1.7-rc.x 起预设改为声明行)需要声明行才能在界面上显示** —— '
147
+ + '本次没有目标 profile 可写,请在目标 profile 的 cordis.patch.yml 里补 `preset-<预设 id>` 声明行'
148
+ }
149
+ const okDecls = decls.filter((d) => d.ok === true)
150
+ const badDecls = decls.filter((d) => d.ok !== true)
151
+ const parts = []
152
+ if (okDecls.length > 0) {
153
+ const rows = okDecls.map((d) => d.rowId).filter((r) => typeof r === 'string' && r !== '')
154
+ const unchanged = okDecls.filter((d) => d.changed === false).length
155
+ parts.push(`已声明为预设行 ${rows.join('、')}`
156
+ + `${unchanged > 0 ? `(其中 ${unchanged} 个此前已声明、内容一致未改动)` : ''}`
157
+ + `${typeof okDecls[0].patchPath === 'string' ? `(写入 ${okDecls[0].patchPath})` : ''};**重启实例后**在新会话可选`)
158
+ }
159
+ if (badDecls.length > 0) {
160
+ parts.push(`${badDecls.length} 个预设**未写成声明行**(${badDecls.map((d) => d.detail ?? d.reason ?? '未知原因').join(';')})`
161
+ + '—— 文件已就位,但当前框架版本需要声明行才能显示')
162
+ }
163
+ return `;${parts.join(';')}`
164
+ }
165
+
93
166
  /**
94
167
  * 装配一个预设到 `<presetsRoot>/<name>`(**唯一实现**)。
95
168
  *
@@ -103,9 +176,11 @@ function verifyWrittenFiles(dest, srcDir, rels) {
103
176
  * 不通过 → **这个文件不写**,进 `skippedInvalid` 并如实报告(其它文件照常)。
104
177
  *
105
178
  * 返回 { ok, name, dest, backup, mode, added, skipped, overwritten, identical, conflicts,
106
- * skippedInvalid, verified, verifiedConfig, hashes, bytes, files, note, error }。
179
+ * skippedInvalid, verified, verifiedConfig, hashes, bytes, files, declaration, note, error }。
180
+ * `declaration` = 声明行结果(见 preset-declare.js);**没给目标 profile 时为 null**(那时 note 会
181
+ * 明说"文件已就位,但当前框架版本需要声明行才能显示",绝不宣称"新建会话时选择")。
107
182
  */
108
- function assemblePreset(srcDir, name, { presetsRoot = null, now = Date.now, overwrite = false, verify = true } = {}) {
183
+ async function assemblePreset(srcDir, name, { presetsRoot = null, now = Date.now, overwrite = false, verify = true, patchPath = null, profileDir = null, declare = null } = {}) {
109
184
  const root = presetsRoot ?? join(dshHome(), '.agent-presets')
110
185
  const presetName = String(name ?? basename(srcDir)).trim()
111
186
  if (presetName === '' || presetName === '.' || /[\\/]/u.test(presetName)) {
@@ -200,6 +275,13 @@ function assemblePreset(srcDir, name, { presetsRoot = null, now = Date.now, over
200
275
  : { ok: false, problems: ['目标目录里没有 agent.cordis.yml/.yaml'] }
201
276
  const mode = overwrite === true ? 'overwrite' : 'add-only'
202
277
 
278
+ // ── 声明行(**框架 0.1.7-rc.x 的机制迁移**的落点)──────────────────────────────────────
279
+ // 判据:磁盘上的预设配置合法(`destConfigValid.ok`)才声明 —— 声明一个挂不起来的预设会让
280
+ // 用户在新会话里选中它然后炸掉,比"不声明"更糟。写盘/备份/读回核实全在 preset-declare.js。
281
+ const declaration = destConfigValid.ok === true
282
+ ? await runPresetDeclaration(srcDir, presetName, { patchPath, profileDir, declare, now })
283
+ : null
284
+
203
285
  const noteParts = []
204
286
  if (existed !== true) {
205
287
  noteParts.push(`预设 ${presetName} 已装配到 ${dest}(新增 ${added.length} 个文件)`)
@@ -215,6 +297,10 @@ function assemblePreset(srcDir, name, { presetsRoot = null, now = Date.now, over
215
297
  }
216
298
  if (identical.length > 0) noteParts.push(`另有 ${identical.length} 个文件与你手上的内容一致,未改动`)
217
299
  }
300
+ // 「这个预设到底能不能被看见」必须**按实际结果**说(旧文案无条件说"新建会话时选择",
301
+ // 在 rc.2 上是一句谎话:没有声明行的预设根本不会出现在选择器里)
302
+ const declNote = declarationClause(declaration)
303
+ if (declNote !== '') noteParts.push(declNote.replace(/^;/u, ''))
218
304
  if (skippedInvalid.length > 0) {
219
305
  noteParts.push(`${skippedInvalid.length} 个预设配置文件**未通过结构校验、已跳过未写入**:`
220
306
  + skippedInvalid.map((s) => `${s.rel}(${s.problems[0]})`).join('、'))
@@ -240,6 +326,7 @@ function assemblePreset(srcDir, name, { presetsRoot = null, now = Date.now, over
240
326
  skippedInvalid,
241
327
  verified,
242
328
  verifiedConfig: destConfigValid,
329
+ declaration,
243
330
  hashes: written.map((rel) => ({ rel, sha256: sha256Of(readFileSync(join(dest, ...rel.split('/')))) })),
244
331
  bytes,
245
332
  files: srcFiles.map((f) => f.rel),
@@ -355,6 +442,7 @@ function presetJobView(job) {
355
442
 
356
443
  export {
357
444
  PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, PRESET_SOURCE_RECORD_LIMIT,
358
- assemblePreset, copyTreeStrict, listTreeFiles, presetJobView, presetSourceOf, presetSourcesFile,
359
- readPresetSources, recordPresetSource, sha256Of, suggestedOverwritePresetAction, uniqueBackupPath, verifyWrittenFiles,
445
+ assemblePreset, copyTreeStrict, declarationClause, declarationClauseForReports, listTreeFiles,
446
+ presetJobView, presetSourceOf, presetSourcesFile, readPresetSources, recordPresetSource,
447
+ runPresetDeclaration, sha256Of, suggestedOverwritePresetAction, uniqueBackupPath, verifyWrittenFiles,
360
448
  }
@@ -0,0 +1,285 @@
1
+ // L1 · domain —— preset-rows.js(预设 **plugins 行**的契约校验 + 已知别名归一;0.5.29 改错+加法)
2
+ //
3
+ // 为什么必须有它(**真机缺陷 D-⑦**,2026-09-27 定位):
4
+ // `preset-declare.js` 把 `.agent-presets/<id>/agent.cordis.yml` 原样搬成 profile 补丁里的
5
+ // `plugins:` —— 但**只做结构校验**(preset-yaml.js:空文件 / tab / 未闭合引号 / 缩进跳级)。
6
+ // 于是这样一份**结构完全合法、框架却挂不上**的 composition 被照写不误:
7
+ // - id: persona
8
+ // name: '@deepseek-ai/dsh-persona'
9
+ // config:
10
+ // text: You are a helpful software engineer assistant. # ← 该插件不认 text
11
+ // 框架侧 `@deepseek-ai/dsh-persona` 的 Config 是 `prefix: z.string().required()`,
12
+ // 装配时抛 `$.prefix missing required value`;注册表 `activate()` 把它吞成 `record.broken`,
13
+ // 界面那张卡片就永远顶着一枚红框「加载失败」(框架自己的 `brokenBadge`),用户点也点不动。
14
+ // 而**改前判据一条都拦不住**:YAML 合法、文件都在、包名都在。
15
+ //
16
+ // 本模块只做三件事,全部**证据驱动、可离线判定**(判据表见 PLUGIN_ROW_CONSTRAINTS):
17
+ // ① 行结构:不是映射 / 没有非空 `name`(框架 `entryListProblem` 同样会拒);
18
+ // ② `file:///` 引用目标**必须存在**(不存在 → 如实拒绝,绝不写一行必然加载失败的声明);
19
+ // ③ **已确证的框架契约**逐条核(缺必填键 / 用了别名键)。命中即拒绝并给可执行出路。
20
+ //
21
+ // ⚠️ 判据表**故意不猜**:只收「本机拿真机错误 + 框架真 schema 双向确证过」的条目。
22
+ // 没有证据的插件一律放行 —— 宁可漏判,也绝不因为猜错而把一个**本来可用**的预设挡在门外
23
+ // (那是把缺陷换个方向,与 preset-yaml.js 的宽严分寸同一取舍)。
24
+ //
25
+ // 分层:L1 domain —— 纯函数、无 IO、无 ctx;文件存在性 / 路径解码 / 包解析均可注入(便于离线断言)。
26
+ // 行数预算:本模块独立于 preset-declare.js,两者各自留在 600 行架构硬顶内。
27
+
28
+ import { join } from 'node:path'
29
+ import { fileURLToPath } from 'node:url'
30
+
31
+ /** 已知插件的行契约。每条都必须有**真机错误原文**背书(见文件头 D-⑦)。 */
32
+ const PLUGIN_ROW_CONSTRAINTS = [
33
+ {
34
+ /** 行 `name:` 精确匹配该插件的行才受这条约束。 */
35
+ module: '@deepseek-ai/dsh-persona',
36
+ /** 必填键(缺了框架 schema 直接抛 `$.<key> missing required value`)。 */
37
+ required: ['prefix'],
38
+ /** 报错时给用户的可执行出路。 */
39
+ hint: '@deepseek-ai/dsh-persona 只认 prefix/suffix/complete/includeRuntimeContext;'
40
+ + 'config.text 是旧版字段(0.1.5 起改名为 prefix),框架 schema 读不到它 → 请把 config.text 改名为 config.prefix',
41
+ },
42
+ ]
43
+
44
+ /**
45
+ * **插件 config 键改名迁移表 —— 全仓唯一出处。**
46
+ *
47
+ * ⚠️ 这张表原本只在 `presets.js`(`PRESET_CONFIG_MIGRATIONS`)里,**只在框架升级那一步**跑。
48
+ * 真实缺口:预设的来源不止"框架升级"一条 —— 装一个预设型子包 / 源码装配 / 手工放文件,
49
+ * 都会把 `agent.cordis.yml` 落到 `.agent-presets/`,**完全不经过升级步骤**,于是旧键原样留下。
50
+ * 本机事实(2026-09-27):`router-spec/agent.cordis.yml` 是当天 21:27 装配落盘的,
51
+ * 内容里仍是 `text:` → 声明行照写 → 卡片永远「加载失败」。所以判据要挂在**写行那一刻**。
52
+ *
53
+ * `presets.js` 从这里取同一张表,两处判据不会再分叉。
54
+ */
55
+ const PRESET_CONFIG_KEY_MIGRATIONS = [
56
+ { pkg: '@deepseek-ai/dsh-persona', from: 'text', to: 'prefix', since: '0.1.5' },
57
+ ]
58
+
59
+ /** 粗粒度版本解析(只认 `maj.min.pat`,`-rc.n` 之类预发布尾缀忽略);不可解析返回 null。 */
60
+ function parseDottedVersion(value) {
61
+ const m = String(value ?? '').match(/(\d+)\.(\d+)\.(\d+)/u)
62
+ return m === null ? null : { maj: Number(m[1]), min: Number(m[2]), pat: Number(m[3]) }
63
+ }
64
+
65
+ /** `target >= since`?任一侧不可解析 → false(**不敢迁移**比乱迁移安全)。 */
66
+ function isVersionAtLeast(target, since) {
67
+ const t = parseDottedVersion(target)
68
+ const s = parseDottedVersion(since)
69
+ if (t === null || s === null) return false
70
+ if (t.maj !== s.maj) return t.maj > s.maj
71
+ if (t.min !== s.min) return t.min > s.min
72
+ return t.pat >= s.pat
73
+ }
74
+
75
+ /**
76
+ * 纯文本版的键改名迁移(不碰盘):只改 `name:` 精确命中 `mig.pkg` 的行,且**仅当该行 config
77
+ * 里还没有真键**(已有 `prefix` 就一个字节都不动 —— 绝不覆盖用户自己写的值)。
78
+ * 返回 `{ text, migrated: [{ pkg, from, to, line }], skippedReason }`。
79
+ *
80
+ * ⚠️ `targetVersion` 为 `null`/不可解析时的取舍(**很要紧,别随手改**):
81
+ * 声明行这套机制**只在框架 0.1.7-rc.x 起才存在**(0.1.5 及更早靠目录发现,写行没有意义),
82
+ * 能走到本函数的场景必然 ≥ 0.1.7,因此**未知版本按「全部迁移都适用」处理**。
83
+ * 反过来若按 `isVersionAtLeast(null, …) === false` 处理,就会在"拿不到版本"时静默不动 ——
84
+ * 于是又写出那行必然「加载失败」的声明,把本函数存在的意义整条抹掉。
85
+ * 真机 D-⑦ 的现场正是"拿不到版本"(装配服务不知道框架版本)。
86
+ */
87
+ function migratePluginRowKeys(compositionText, targetVersion, opts = {}) {
88
+ const rules = opts.rules ?? PRESET_CONFIG_KEY_MIGRATIONS
89
+ const unknownVersion = parseDottedVersion(targetVersion) === null
90
+ const apply = unknownVersion ? [...rules] : rules.filter((r) => isVersionAtLeast(targetVersion, r.since))
91
+ const lines = String(compositionText ?? '').replace(/\r\n/gu, '\n').split('\n')
92
+ const migrated = []
93
+ if (apply.length === 0) {
94
+ return { text: lines.join('\n'), migrated, skippedReason: `目标版本 ${JSON.stringify(String(targetVersion ?? ''))} 低于全部迁移的引入版本 —— 不动用户配置` }
95
+ }
96
+ let rowName = null
97
+ let configIndent = -1
98
+ let rowIndent = -1
99
+ for (let i = 0; i < lines.length; i += 1) {
100
+ const raw = lines[i]
101
+ if (raw.trim() === '' || /^\s*#/u.test(raw)) continue
102
+ const indent = (raw.match(/^ */u) ?? [''])[0].length
103
+ const item = raw.match(/^(\s*)- id:\s*(\S+)\s*$/u)
104
+ if (item !== null) { rowIndent = indent; rowName = null; configIndent = -1; continue }
105
+ if (rowIndent < 0) continue
106
+ if (indent <= rowIndent) { rowName = null; configIndent = -1; continue }
107
+ const kv = raw.match(/^(\s*)([A-Za-z_][\w-]*):(.*)$/u)
108
+ if (kv === null) continue
109
+ const directKey = indent === rowIndent + 2
110
+ if (directKey && kv[2] === 'name') { rowName = kv[3].trim().replace(/^['"]|['"]$/gu, ''); configIndent = -1; continue }
111
+ if (directKey && kv[2] === 'config' && kv[3].trim() === '') { configIndent = indent; continue }
112
+ if (configIndent < 0 || indent <= configIndent) continue
113
+ for (const rule of apply) {
114
+ if (rule.pkg !== rowName || rule.from !== kv[2]) continue
115
+ // 该行 config 里已有真键 → 本次跳过(绝不覆盖用户自己的值)
116
+ let hasNew = false
117
+ for (let j = configIndent + 1; j < lines.length; j += 1) {
118
+ const l = lines[j]
119
+ if (l.trim() === '') continue
120
+ const ind = (l.match(/^ */u) ?? [''])[0].length
121
+ if (ind <= configIndent) break
122
+ if (new RegExp(`^\\s*${rule.to}:`, 'u').test(l)) { hasNew = true; break }
123
+ }
124
+ if (hasNew) continue
125
+ lines[i] = `${kv[1]}${rule.to}:${kv[3]}`
126
+ migrated.push({ pkg: rule.pkg, from: rule.from, to: rule.to, line: i + 1 })
127
+ }
128
+ }
129
+ return { text: lines.join('\n'), migrated, skippedReason: null }
130
+ }
131
+
132
+ /** 值归一:去引号、折行、`''`→`'`;空串一律视作**未提供**(框架 schema 对 `''` 与缺失的处理不同,这里只判存在性)。 */
133
+ /** 行解析:composition 里的 `- id:` / `name:` / `config:` + config 直属子键(只认缩进语义,不做通用 YAML)。 */
134
+ function parseCompositionRows(text) {
135
+ // ⚠️ 必须先归一 CRLF:真机磁盘上的 agent.cordis.yml 就是 CRLF(本机三个预设全是),
136
+ // 行尾的 `\r` 会让 `^(\s*)- id:\s*(\S+)\s*$` 这类判据整体失配(自测真抓到过:归一一条都不命中)。
137
+ const lines = String(text ?? '').replace(/\r\n/gu, '\n').split('\n')
138
+ const rows = []
139
+ let row = null
140
+ let configIndent = null
141
+ let blockKey = null
142
+ let blockIndent = 0
143
+ for (let index = 0; index < lines.length; index += 1) {
144
+ const raw = lines[index]
145
+ if (raw.trim() === '' || /^\s*#/u.test(raw)) {
146
+ if (blockKey !== null && row !== null) row.config[blockKey] += '\n'
147
+ continue
148
+ }
149
+ const indent = (raw.match(/^ */u) ?? [''])[0].length
150
+ if (blockKey !== null && indent > blockIndent) {
151
+ row.config[blockKey] += `${raw}\n`
152
+ continue
153
+ }
154
+ blockKey = null
155
+ const item = raw.match(/^(\s*)- id:\s*(\S+)\s*$/u)
156
+ if (item !== null) {
157
+ row = {
158
+ id: item[2].replace(/^['"]|['"]$/gu, ''),
159
+ indent,
160
+ name: null,
161
+ config: {},
162
+ line: index + 1,
163
+ }
164
+ rows.push(row)
165
+ configIndent = null
166
+ continue
167
+ }
168
+ if (row === null) continue
169
+ const kv = raw.match(/^\s*([A-Za-z_][\w-]*):\s*(.*)$/u)
170
+ if (kv === null) continue
171
+ const key = kv[1]
172
+ const value = kv[2].trim()
173
+ if (indent <= row.indent) { row = null; configIndent = null; continue }
174
+ if (indent === row.indent + 2 && key === 'name') { row.name = value.replace(/^['"]|['"]$/gu, ''); configIndent = null; continue }
175
+ if (indent === row.indent + 2 && key === 'config' && value === '') { configIndent = indent; continue }
176
+ if (key === 'id') continue
177
+ // config 直属子键:缩进必须比 `config:` 更深
178
+ if (configIndent !== null && indent > configIndent) {
179
+ row.config[key] = value
180
+ if (/^[|>][-+]?\d*$/u.test(value)) { blockKey = key; blockIndent = indent }
181
+ }
182
+ }
183
+ return rows
184
+ }
185
+
186
+ /** 默认解码器:`file:` URL → 磁盘路径(Node 自己的实现会拒绝坏百分号编码)。 */
187
+ const defaultToLocalPath = (url) => fileURLToPath(url)
188
+ /** 默认路径拼接(相对引用以预设目录为基准)。 */
189
+ const defaultJoinPath = (dir, relative) => join(dir, relative)
190
+
191
+ /** 值归一:去引号、折行、`''`→`'`;空串一律视作**未提供**(框架 schema 对 `''` 与缺失的处理不同,这里只判存在性)。 */
192
+ function normalizeConfigValue(raw) {
193
+ if (raw === undefined || raw === null) return null
194
+ const text = String(raw).trim()
195
+ if (text === '') return null
196
+ if (text.startsWith("'") && text.endsWith("'") && text.length >= 2) return text.slice(1, -1).replace(/''/gu, "'")
197
+ if (text.startsWith('"') && text.endsWith('"') && text.length >= 2) return text.slice(1, -1)
198
+ return text
199
+ }
200
+
201
+ /**
202
+ * ① 行结构 + ② `file:///` 目标存在性 + ③ 已确证契约。返回 `{ ok, problems: [{ code, row, detail, hint }] }`。
203
+ * `exists` 可注入(离线断言用);`checkFile` 为 false 时跳过 ②(只想要契约判据时用)。
204
+ */
205
+ function validatePresetPluginRows(compositionText, opts = {}) {
206
+ // `exists` 吃**磁盘路径**(不是 URL);`toLocalPath` / `joinPath` 可注入,便于离线断言。
207
+ const {
208
+ exists = null, checkFiles = true, presetDir = null,
209
+ toLocalPath = defaultToLocalPath, joinPath = defaultJoinPath,
210
+ } = opts
211
+ const problems = []
212
+ const rows = parseCompositionRows(compositionText)
213
+
214
+ if (rows.length === 0) {
215
+ problems.push({ code: 'no-plugin-rows', row: null, detail: 'composition 里没有任何 `- id:` 插件行', hint: '框架挂载空预设没有意义,请检查 agent.cordis.yml' })
216
+ return { ok: false, problems, rows: 0 }
217
+ }
218
+
219
+ for (const row of rows) {
220
+ // ① 结构:框架 `entryListProblem` 会先拒掉没有 name 的行
221
+ if (typeof row.name !== 'string' || row.name === '') {
222
+ problems.push({ code: 'row-names-no-plugin', row: row.id, detail: `行 ${row.id} 没有 name(框架要求非空字符串)`, hint: '补上 `name:`(包名或 file:/// URL)' })
223
+ continue
224
+ }
225
+ // ② file:/// 目标必须存在 —— 写一行指向不存在文件的声明,等于必然「加载失败」
226
+ if (checkFiles && /^file:\/\//iu.test(row.name)) {
227
+ let localPath = null
228
+ try {
229
+ const url = new URL(row.name)
230
+ if (url.protocol !== 'file:') throw new Error('不是 file: 协议')
231
+ // 必须真的解码成磁盘路径:坏百分号编码(`%zz`)在 URL 层是"合法"的,
232
+ // 到加载器才知道读不了 —— 那正是要在**写行之前**拦下的形状。
233
+ localPath = toLocalPath(url)
234
+ } catch (error) {
235
+ problems.push({ code: 'file-url-unparsable', row: row.id, detail: `行 ${row.id} 的 file: 引用无法解析成磁盘路径:${row.name}(${error.message})`, hint: '改成合法的 file:/// 绝对 URL(路径片段要按 URL 规则百分号编码)' })
236
+ continue
237
+ }
238
+ let present = null
239
+ try { present = exists === null ? null : exists(localPath) } catch { present = null }
240
+ if (present === false) {
241
+ problems.push({ code: 'file-target-missing', row: row.id, detail: `行 ${row.id} 引用的文件不存在:${localPath}`, hint: '该预设引用了磁盘上没有的文件 —— 请补齐文件或修正引用(绝不写必然加载失败的行)' })
242
+ }
243
+ }
244
+ // ②′ 相对引用(`./x.mjs` / `../x.mjs`):声明行落在 **profile 补丁**里,相对基准变成 profile 目录,
245
+ // 所以预设目录里不存在同名文件的相对引用**改不过去、留着也必然加载失败**。
246
+ // 改前行为是"照写、留给读回核实判红" —— 那已经**写坏用户补丁**了(自测真抓到过这个形状:
247
+ // ok:false + verify-failed + 补丁已被改动)。契约校验必须在**动手写之前**拦下它。
248
+ if (checkFiles && /^\.\.?\//u.test(row.name) && presetDir !== null) {
249
+ const target = joinPath(presetDir, row.name)
250
+ let present = null
251
+ try { present = exists === null ? null : exists(target) } catch { present = null }
252
+ if (present === false) {
253
+ problems.push({
254
+ code: 'relative-target-missing',
255
+ row: row.id,
256
+ detail: `行 ${row.id} 用相对引用 \`${row.name}\`,但预设目录里没有这个文件(${target})`,
257
+ hint: '相对引用在 profile 补丁里以 **profile 目录**为基准、且预设目录里没有该文件可改写 —— '
258
+ + '请补齐该文件,或把这一行改成真实存在的包名 / file:/// 绝对 URL(绝不写必然加载失败的行)',
259
+ })
260
+ }
261
+ }
262
+ // ③ 已确证的框架契约
263
+ for (const rule of PLUGIN_ROW_CONSTRAINTS) {
264
+ if (row.name !== rule.module) continue
265
+ const keys = Object.keys(row.config)
266
+ const present = keys.filter((k) => normalizeConfigValue(row.config[k]) !== null)
267
+ const missing = rule.required.filter((k) => !present.includes(k))
268
+ if (missing.length > 0) {
269
+ problems.push({
270
+ code: 'missing-required-config',
271
+ row: row.id,
272
+ detail: `行 ${row.id}(${row.name})缺少必填 config 键 ${missing.map((k) => `\`${k}\``).join('、')}`
273
+ + `(现有键:${keys.length === 0 ? '无' : keys.map((k) => `\`${k}\``).join('、')})—— 框架 schema 会抛 \`$.${missing[0]} missing required value\``,
274
+ hint: rule.hint,
275
+ })
276
+ }
277
+ }
278
+ }
279
+ return { ok: problems.length === 0, problems, rows: rows.length }
280
+ }
281
+
282
+ export {
283
+ PLUGIN_ROW_CONSTRAINTS, PRESET_CONFIG_KEY_MIGRATIONS, isVersionAtLeast, migratePluginRowKeys,
284
+ normalizeConfigValue, parseCompositionRows, parseDottedVersion, validatePresetPluginRows,
285
+ }
@@ -23,11 +23,12 @@ import { basename, join } from 'node:path'
23
23
  import { tmpdir } from 'node:os'
24
24
  import { gitCloneRepo, runGitArgs } from './repoland.js'
25
25
  import { noteChannel } from './git-channel.js'
26
+ import { readPresetManifest } from './preset-declare.js'
26
27
  import { evaluateSourceChannel } from './repo-size.js'
27
28
  import { disposeDir } from '../infra/fsx.js'
28
29
  import { dshHome } from '../infra/paths.js'
29
30
  import {
30
- PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, assemblePreset, listTreeFiles,
31
+ PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, assemblePreset, declarationClauseForReports, listTreeFiles,
31
32
  presetSourceOf, recordPresetSource, suggestedOverwritePresetAction,
32
33
  } from './preset-install.js'
33
34
 
@@ -71,19 +72,10 @@ function looksLikePresetPackageName(name) {
71
72
  return /(?:^|[-_/])(?:router|preset)(?:[-_/]|$)/iu.test(String(name ?? '').trim())
72
73
  }
73
74
 
74
- /** 读预设清单(preset.yml 的 name/description/order;读不到返回 null,不抛)。 */
75
- function readPresetManifest(dir) {
76
- try {
77
- const text = readFileSync(join(dir, PRESET_MANIFEST_FILE), 'utf8')
78
- const pick = (key) => {
79
- const m = text.match(new RegExp(`^\\s*${key}\\s*:\\s*(.+)$`, 'mu'))
80
- return m === null ? null : m[1].trim().replace(/^["']|["']$/gu, '')
81
- }
82
- return { name: pick('name'), description: pick('description'), order: pick('order') }
83
- } catch {
84
- return null
85
- }
86
- }
75
+ /** 读预设清单(preset.yml 的 name/description/order;读不到返回 null,不抛)。
76
+ * 实现已搬到 preset-declare.js(那里也要用它,且依赖方向必须单向 preset-source → preset-declare)——
77
+ * 这里 re-export,既有调用方与测试一个字都不用改。 */
78
+ export { readPresetManifest } from './preset-declare.js'
87
79
 
88
80
  /** 跑一条 git 命令并取回 stdout(ls-tree/show 要的就是输出)。仍然走 repoland 的 runGitArgs:
89
81
  * 那边已经把停滞判据 + kill-tree + wait-for-exit 做完了,这里只转发(`stdout` 由 runGitArgs 采集)。 */
@@ -214,13 +206,19 @@ async function tryPresetSourceChannel({ job, name, source = 'github', limit = 4,
214
206
  return { handled: true, installed: false, error: new Error(`预设型子包 ${candidateName}:${why}`), presets: [] }
215
207
  }
216
208
  const presetsRoot = deps.presetsRoot ?? join(dshHome(), '.agent-presets')
209
+ // 目标 profile 的补丁文件(可选):给了就**同时写声明行** —— 框架 0.1.7-rc.x 起预设不再靠目录
210
+ // 发现,只落文件不写声明行 = 用户看不见(见 preset-declare.js 顶部)。安装路径由 install-job.js
211
+ // 传 findPatchPath(ports),线上一律非空;离线单测不传 → 不写、且 note 如实说明。
212
+ const patchPath = typeof deps.patchPath === 'string' && deps.patchPath !== '' ? deps.patchPath : null
217
213
  const reports = []
218
214
  const skipped = Math.max(0, presets.length - limit)
219
215
  for (const p of presets.slice(0, limit)) {
220
216
  // eslint-disable-next-line no-await-in-loop
221
217
  // overwrite **永不**在这里为真:安装路径必须走"只补不覆盖"的默认语义。
222
218
  // 覆盖只能由用户显式点「覆盖该预设」→ 白名单动作 overwrite-preset 触发(见 plugin-actions.js)。
223
- const report = assemblePreset(p, basename(p), { presetsRoot, ...(deps.now === undefined ? {} : { now: deps.now }) })
219
+ const report = await assemblePreset(p, basename(p), {
220
+ presetsRoot, patchPath, ...(deps.now === undefined ? {} : { now: deps.now }),
221
+ })
224
222
  reports.push({ ...report, manifest: readPresetManifest(p), sparse: fetched.sparse === true, subdir: fetched.subdir })
225
223
  }
226
224
  const failed = reports.filter((r) => r.ok !== true)
@@ -235,6 +233,13 @@ async function tryPresetSourceChannel({ job, name, source = 'github', limit = 4,
235
233
  skipped: Array.isArray(r.skipped) ? r.skipped : [],
236
234
  skippedInvalid: Array.isArray(r.skippedInvalid) ? r.skippedInvalid : [],
237
235
  verified: r.verified === null || r.verified === undefined ? null : r.verified.ok === true,
236
+ // 声明行结果(0.5.28 加法):面板据此显示"已声明为 preset-x(重启实例后可选)",
237
+ // 或如实显示"文件已就位但没写成声明行" —— 结构字段 + note 两处都下发,绝不只在一处叙述。
238
+ declaration: r.declaration === null || r.declaration === undefined ? null : {
239
+ ok: r.declaration.ok === true, status: r.declaration.status ?? null, rowId: r.declaration.rowId ?? null,
240
+ patchPath: r.declaration.patchPath ?? null, backup: r.declaration.backup ?? null,
241
+ reason: r.declaration.reason ?? null, detail: r.declaration.detail ?? null,
242
+ },
238
243
  }))
239
244
  job.presetSource = { repo, subdir: fetched.subdir, sparse: fetched.sparse === true, downgraded: fetched.downgraded === true, root: fetched.root }
240
245
  // 源码出处落盘:`overwrite-preset` 动作据此**重新取源码**(临时 clone 目录在 finally 里已清理,
@@ -264,7 +269,8 @@ async function tryPresetSourceChannel({ job, name, source = 'github', limit = 4,
264
269
  const skippedFiles = reports.reduce((sum, r) => sum + (r.skipped?.length ?? 0), 0)
265
270
  const invalidFiles = reports.reduce((sum, r) => sum + (r.skippedInvalid?.length ?? 0), 0)
266
271
  const backedUp = reports.filter((r) => r.ok === true && typeof r.backup === 'string' && r.backup !== '')
267
- const note = `预设已装配:${okNames.join('、')}(落盘 ${presetsRoot};**新建会话时选择**)`
272
+ const note = `预设已装配:${okNames.join('、')}(落盘 ${presetsRoot})`
273
+ + declarationClauseForReports(reports)
268
274
  + (fetched.sparse === true ? ';源码用稀疏取源码(--filter=blob:none --sparse + sparse-checkout set),只取该子包目录' : '')
269
275
  + (skipped > 0 ? `;该仓库还有 ${skipped} 个预设本次未装配(单次上限 ${limit} 个)` : '')
270
276
  // 默认语义必须在面板上说清(用户要先知道"你的文件没被动过",才谈得上要不要覆盖)
@@ -343,7 +349,10 @@ async function overwritePreset({ presetName, deps = {} } = {}) {
343
349
  return { ok: false, status: 200, name: raw, repo, error: `仓库 ${repo} 的 ${fetched.subdir} 里没有找到预设目录` }
344
350
  }
345
351
  // 覆盖的永远是**用户点名的那一个**名字(不是源目录的 basename):用户点的是哪个就改哪个
346
- const report = assemblePreset(pick, raw, { presetsRoot, overwrite: true, ...(deps.now === undefined ? {} : { now: deps.now }) })
352
+ const patchPath = typeof deps.patchPath === 'string' && deps.patchPath !== '' ? deps.patchPath : null
353
+ const report = await assemblePreset(pick, raw, {
354
+ presetsRoot, overwrite: true, patchPath, ...(deps.now === undefined ? {} : { now: deps.now }),
355
+ })
347
356
  if (report.ok !== true) {
348
357
  return { ok: false, status: 200, name: raw, repo, backup: report.backup ?? null, error: report.error ?? '覆盖失败(文件未改动)', report }
349
358
  }
@@ -367,7 +376,7 @@ async function overwritePreset({ presetName, deps = {} } = {}) {
367
376
 
368
377
  export {
369
378
  PRESET_MARKER_FILES, PRESET_MANIFEST_FILE, GIT_READ_TIMEOUT_MS,
370
- findPresetDirs, isPresetDir, looksLikePresetPackageName, readPresetManifest, listTreeFiles,
379
+ findPresetDirs, isPresetDir, looksLikePresetPackageName, listTreeFiles,
371
380
  assemblePreset, locateSubpackageViaGit, fetchPresetSource, gitCapture, tryPresetSourceChannel,
372
381
  overwritePreset,
373
382
  }
@@ -6,6 +6,7 @@ import { join } from 'node:path'
6
6
  import { isVersionAtLeast } from './framework.js'
7
7
  import { escapeRegExp } from '../infra/mask.js'
8
8
  import { dshHome } from '../infra/paths.js'
9
+ import { PRESET_CONFIG_KEY_MIGRATIONS } from './preset-rows.js'
9
10
 
10
11
  /**
11
12
  * 预设/组合文件的配置迁移门禁(2026-09-10 事故):
@@ -13,10 +14,12 @@ import { dshHome } from '../infra/paths.js'
13
14
  * 适配门只扫「已装插件包源码」,**预设不在其中**(~/.dsh/.agent-presets/<name>/agent.cordis.yml),
14
15
  * 于是升级后预设挂载失败 → 服务/会话起不来(这次就是这么挂的)。
15
16
  * 升级前按目标版本扫描并自动迁移(写 .bak 备份),返回迁移清单供步骤展示。
17
+ *
18
+ * ⚠️ 0.5.29 起这张表**搬到 preset-rows.js**(全仓唯一出处):升级只是预设落盘的五条路之一,
19
+ * 装预设型子包 / 源码装配 / 手工放文件都不经过这里,于是旧键会原样留下、声明行照写、卡片永远「加载失败」
20
+ * (真机 D-⑦)。preset-declare.js 现在在**写行那一刻**用同一张表做纯文本迁移,判据不会再分叉。
16
21
  */
17
- const PRESET_CONFIG_MIGRATIONS = [
18
- { pkg: '@deepseek-ai/dsh-persona', from: 'text', to: 'prefix', since: '0.1.5' },
19
- ]
22
+ const PRESET_CONFIG_MIGRATIONS = PRESET_CONFIG_KEY_MIGRATIONS
20
23
 
21
24
  /** 待扫描的 agent 配置文件:所有预设 + profile 的 host 组合。 */
22
25
  function collectAgentConfigFiles(profileDir) {
@@ -282,14 +282,20 @@ async function runSuiteInstallJob(job, ports, deps = {}) {
282
282
  // 用户手上的同名预设;现在改为"先整目录备份、再逐文件合并",并把备份路径与覆盖清单写进报告。
283
283
  const presets = findPresetDirs(subDir, 2)
284
284
  for (const p of presets) {
285
- const assembled = assemblePreset(p, basename(p))
285
+ // 声明行同源(0.5.28 加法):目标 profile 就是本次套装的 profileDir —— 框架 0.1.7-rc.x 起
286
+ // 预设必须作为声明行出现在 cordis.patch.yml 里,只铺文件用户看不见(见 preset-declare.js 顶部)。
287
+ const assembled = await assemblePreset(p, basename(p), { profileDir })
286
288
  report.push({
287
289
  component: sub.name,
288
290
  type: 'preset',
289
291
  ok: assembled.ok === true,
290
- note: assembled.ok === true ? `${assembled.note}(新建会话时选择)` : `预设装配失败:${assembled.error}`,
292
+ // 文案按**实际声明结果**生成(旧文案无条件说"新建会话时选择",在 rc.2 上是一句谎话)
293
+ note: assembled.ok === true
294
+ ? assembled.note
295
+ : `预设装配失败:${assembled.error}`,
291
296
  presetPath: assembled.dest ?? null,
292
297
  backup: assembled.backup ?? null,
298
+ declaration: assembled.declaration ?? null,
293
299
  })
294
300
  if (assembled.ok === true) handled = true
295
301
  }
@@ -320,7 +326,19 @@ async function runSuiteInstallJob(job, ports, deps = {}) {
320
326
  const okCount = report.filter((r) => r.ok).length
321
327
  const failCount = report.filter((r) => !r.ok).length
322
328
  const bundleCount = report.filter((r) => r.type === 'bundle' && r.ok).length
323
- job.suiteNote = `套装安装完成:${okCount} 个组件成功${failCount > 0 ? `,${failCount} 个失败(详见报告)` : ''}${bundleCount > 0 ? '。bundle 组件需重启服务生效' : ''};预设需新建会话时选择。`
329
+ // 预设的可见性按**实际声明结果**说(0.5.28 改错):框架 0.1.7-rc.x 起预设不再靠目录发现,
330
+ // 只有落进 profile 补丁的 `preset-<id>` 声明行才会出现在选择器里 —— 旧文案"预设需新建会话时选择"
331
+ // 对新框架是错的(没有声明行时新建多少次会话都选不到)。有声明失败的条目就点名。
332
+ const presetRows = report.filter((r) => r.type === 'preset')
333
+ const presetDeclared = presetRows.filter((r) => r.declaration?.ok === true).length
334
+ const presetUndeclared = presetRows.filter((r) => r.ok === true && r.declaration?.ok !== true).length
335
+ const presetFailed = presetRows.filter((r) => !r.ok).length
336
+ job.suiteNote = `套装安装完成:${okCount} 个组件成功${failCount > 0 ? `,${failCount} 个失败(详见报告)` : ''}${bundleCount > 0 ? '。bundle 组件需重启服务生效' : ''}`
337
+ + (presetDeclared > 0 ? `;${presetDeclared} 个预设已声明为声明行(重启实例后在新会话可选)` : '')
338
+ + (presetUndeclared > 0
339
+ ? `;⚠️ ${presetUndeclared} 个预设**文件已就位,但当前框架版本(0.1.7-rc.x 起预设改为声明行)需要声明行才能在界面上显示**`
340
+ : '')
341
+ + (presetFailed > 0 ? `;${presetFailed} 个预设装配失败(详见报告)` : '')
324
342
  } catch (error) {
325
343
  job.status = 'failed'
326
344
  job.error = error instanceof Error ? error.message : String(error)
@@ -109,7 +109,9 @@ async function routeRepoLandConfig(req, res, rc) {
109
109
  const body = rc.body
110
110
  const dir = typeof body.dir === 'string' ? body.dir.trim() : ''
111
111
  if (dir === '' || !/^[A-Za-z]:[\\/]/.test(dir) && !/^\\\\/.test(dir)) {
112
- sendError(res, 400, '保存路径不合法:请提供绝对路径(如 D:\\dsh\\repos)')
112
+ // 0.5.28 改错:示例原本写成本机真实目录 `D:\dsh\repos`(把开发者的目录当成了通用示例)。
113
+ // 换成中性的任意绝对路径示例,并说明"任意绝对路径"——判据是形状(盘符/UNC),不是具体目录。
114
+ sendError(res, 400, '保存路径不合法:请提供绝对路径(任意绝对路径均可,如 C:\\repos 或 \\\\server\\share\\repos)')
113
115
  return
114
116
  }
115
117
  try {
@@ -23,9 +23,18 @@ const REGISTRY = 'https://registry.npmmirror.com'
23
23
 
24
24
  /** 预检的两个扫描面:用户预设目录 + profile 内每个已装插件包。
25
25
  * 框架自带包(解析到 profile 之外,即 npx 缓存里的 @deepseek-ai/*)标成 kind='framework':
26
- * 仍然扫(能暴露框架自身的契约不一致),但**只报告不改**——改它等于污染框架安装树。 */
26
+ * 仍然扫(能暴露框架自身的契约不一致),但**只报告不改**——改它等于污染框架安装树。
27
+ *
28
+ * ★ 2026-09-27 改错(真 bug):用户预设目录写成了 `join(dshHome(), 'agent-presets')` —— **少一个点**。
29
+ * 框架的目录是 `<DSH_HOME>/.agent-presets`(旧 discovery 的 `USER_PRESET_DIR = '.agent-presets'`),
30
+ * 所以这一路**一直指向一个不存在的目录**:静默扫到 0 个文件,预设里的 V3 生产方从来没被预检看见
31
+ * (升级前"0 blocker"的结论因此是**假绿**)。现在由 `userPresetsRoot()` 单点推导,配断言钉死。 */
32
+ function userPresetsRoot(home = undefined) {
33
+ return join(home ?? dshHome(), '.agent-presets')
34
+ }
35
+
27
36
  function preflightRoots(ports, profileDir) {
28
- const roots = [{ root: join(dshHome(), 'agent-presets'), kind: 'preset' }]
37
+ const roots = [{ root: userPresetsRoot(), kind: 'preset' }]
29
38
  // 控制台自身排除在外:它是工具、不是会话消息生产方,而它的报错文案/文档里本身就含
30
39
  // `kind: 'plugin'`、`.volatile(` 这类字样 —— 发版门槛自扫时正是这些字串报出了无意义条目。
31
40
  let selfName = null
@@ -161,4 +170,4 @@ async function routeFrameworkPreflightPatch(req, res, rc) {
161
170
  })
162
171
  }
163
172
 
164
- export { routeFrameworkPreflight, routeFrameworkPreflightPatch, preflightRoots, resolveTargetVersion, resolveCurrentVersion }
173
+ export { routeFrameworkPreflight, routeFrameworkPreflightPatch, preflightRoots, resolveTargetVersion, resolveCurrentVersion, userPresetsRoot }