dsh-paoding 0.3.3 → 0.3.5

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,557 @@
1
+ /**
2
+ * 预设安装机制双轨 —— DSH ≤0.1.6 靠宿主 dsh-agent-presets(复数)扫描
3
+ * $DSH_HOME/.agent-presets/<id>/ 目录成 preset(目录扫描轨);0.1.7 起目录扫描
4
+ * 机制删除,改为声明行:bundle/profile/home 任一 patch 层里 - insert: 一条
5
+ * name: '@deepseek-ai/dsh-agent-preset' 的行(config = { id 必填, name?,
6
+ * description?, order?, plugins[] 必填 },宿主 agent-preset-registry 挂载
7
+ * plugins 子树;行 name 相对声明行 baseUrl 解析,绝对 file: URL 直接采用)。
8
+ * 本模块为双轨化提供四件事:
9
+ * 1) 轨道探测 detectPresetSystem:运行时探测(Web 插件对宿主服务的实时反射,
10
+ * 最权威)> 进程 argv 宿主版本(插件进程内零开销,反映正在运行的宿主而非
11
+ * PATH 上的 dsh)> `dsh --version` 版本号 > $DSH_HOME 单数包存在性 > 默认
12
+ * directory(安全侧:声明行误落 0.1.6 会炸 profile 启动,目录轨误落
13
+ * 0.1.7 只是预设不显示);
14
+ * 2) 声明行构造 buildPresetRowText:把生成的 agent.cordis.yml 全文内联成
15
+ * 一条声明行的 config.plugins(restrict 行 name 换成 restrict.mjs 的绝对
16
+ * file: URL——声明轨没有「相对 preset 目录」语义,必须绝对化);
17
+ * 3) home patch 托管块手术 upsertHomePatchRows / stripHomePatchRows /
18
+ * readHomePatchRows:对 $DSH_HOME/cordis.patch.yml 的 MANAGED 标记块做
19
+ * 行级增删查,块外用户内容一字节不动;托管块损坏(缺结束标记)时拒绝
20
+ * 写盘并明确报错,与仓库「配置不可信时拒绝落盘」口径一致。
21
+ * 4) 轨道迁移对账 backfillHomePatchRows / listOrchestratorPresetIds /
22
+ * parseGeneratorMarker / formatGeneratorMarker:宿主只升 dsh 不动插件时
23
+ * (标记版本一致、轨道翻转——升级最常见路径),按磁盘产物补写缺失声明行
24
+ * 而不整盘重生成;生成器标记记「版本 + 轨道」两行,任一不符即触发自愈。
25
+ * 零运行时依赖(node:child_process / node:fs / node:path / node:url);被 state 引用。
26
+ */
27
+ import { spawnSync } from 'node:child_process'
28
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
29
+ import path from 'node:path'
30
+ import { pathToFileURL } from 'node:url'
31
+ import { warn } from './util.mjs'
32
+
33
+ /** 托管块起始标记:其后到结束标记之间是本工具自动生成的声明行,用户勿改。 */
34
+ export const MANAGED_BEGIN = '# --- dsh-paoding presets (auto-generated; do not edit) ---'
35
+ /** 托管块结束标记。两个标记成对出现;缺一即视为托管块损坏,拒绝写盘。 */
36
+ export const MANAGED_END = '# --- end dsh-paoding presets ---'
37
+
38
+ /** 声明行挂载的宿主 preset 包名(0.1.7+ 的 agent-preset-registry 组件包)。 */
39
+ export const PRESET_ROW_NAME = '@deepseek-ai/dsh-agent-preset'
40
+
41
+ /** preset 目录名(orchestrator / orchestrator-<slug>)→ home patch 声明行 id。 */
42
+ export function presetRowId(presetId) {
43
+ return `preset-${presetId}`
44
+ }
45
+
46
+ // ── 版本解析与轨道判定 ──────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * 解析裸版本号文本为 [major, minor, patch] 数字三元组;解析不了返回 null。
50
+ * prerelease / build 标签忽略(0.1.7-rc.1 → [0, 1, 7])。
51
+ */
52
+ export function parseVersionTriple(text) {
53
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(String(text ?? '').trim())
54
+ if (!m) return null
55
+ return [Number(m[1]), Number(m[2]), Number(m[3])]
56
+ }
57
+
58
+ /** 三元组是否达到声明行轨(0.1.7+):逐段数值比较,prerelease 已在上游剥掉。 */
59
+ export function isDeclarativeTriple(triple) {
60
+ if (!Array.isArray(triple) || triple.length !== 3 || triple.some((n) => !Number.isInteger(n))) return false
61
+ const base = [0, 1, 7]
62
+ for (let i = 0; i < 3; i++) {
63
+ if (triple[i] !== base[i]) return triple[i] > base[i]
64
+ }
65
+ return true
66
+ }
67
+
68
+ /**
69
+ * 探测当前宿主的预设安装轨道,返回 'declarative' | 'directory'。逐级降级:
70
+ * 1) runtimeSystem 直通:Web 插件运行时对宿主服务的实时探测结果最权威;
71
+ * 2) 进程 argv 宿主版本:插件与宿主同进程时零开销、且反映正在运行的宿主
72
+ * (PATH 上的 dsh 可能已换代而旧进程还在跑,此时以 argv 为准才不会把
73
+ * 声明行提前写出去);CLI / 测试进程不是 dsh,返回 null 落到下一级;
74
+ * 3) `dsh --version`(timeout 10s;spawn 失败 / 超时 / 输出不可解析一律吞掉
75
+ * 落到下一级);
76
+ * 4) 文件探测:单数包 @deepseek-ai/dsh-agent-preset 是 0.1.7 的声明行宿主
77
+ * 组件,在即声明轨(复数包时代没有它);
78
+ * 5) 默认 directory —— 安全侧兜底(理由见模块头注释)。
79
+ */
80
+ export async function detectPresetSystem({ dshHome, runtimeSystem = null } = {}) {
81
+ if (runtimeSystem === 'declarative' || runtimeSystem === 'directory') return runtimeSystem
82
+ const argvVersion = hostVersionFromArgv()
83
+ if (argvVersion !== null) {
84
+ const triple = parseVersionTriple(argvVersion)
85
+ if (triple !== null) return isDeclarativeTriple(triple) ? 'declarative' : 'directory'
86
+ }
87
+ const versionText = dshVersionText()
88
+ if (versionText !== null) {
89
+ const triple = parseVersionTriple(versionText)
90
+ if (triple !== null) return isDeclarativeTriple(triple) ? 'declarative' : 'directory'
91
+ }
92
+ if (existsSync(path.join(dshHome, 'node_modules', '@deepseek-ai', 'dsh-agent-preset'))) return 'declarative'
93
+ return 'directory'
94
+ }
95
+
96
+ /**
97
+ * 从当前进程 argv[1] 推断正在运行的宿主版本:沿脚本所在目录向上找最近的
98
+ * package.json,name 是 @deepseek-ai/dsh 即取其 version。插件跑在宿主进程里,
99
+ * 这比 PATH 上的 `dsh --version` 又快又准(无需子进程);找不到(CLI / 测试 /
100
+ * 任意非 dsh 进程)返回 null。找到的第一个 package.json 不是 dsh 即止——
101
+ * 向上穿越无关包目录没有意义。
102
+ */
103
+ function hostVersionFromArgv() {
104
+ try {
105
+ const entry = process.argv[1]
106
+ if (typeof entry !== 'string' || entry === '') return null
107
+ let dir = path.dirname(path.resolve(entry))
108
+ for (let i = 0; i < 6 && dir !== path.dirname(dir); i++) {
109
+ const pkgPath = path.join(dir, 'package.json')
110
+ if (existsSync(pkgPath)) {
111
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
112
+ return pkg?.name === '@deepseek-ai/dsh' ? String(pkg.version ?? '') : null
113
+ }
114
+ dir = path.dirname(dir)
115
+ }
116
+ return null
117
+ } catch {
118
+ return null
119
+ }
120
+ }
121
+
122
+ /** `dsh --version` 取裸版本号文本;任何失败(ENOENT / 超时 / 非零退出)返回 null。 */
123
+ function dshVersionText() {
124
+ try {
125
+ const res = spawnSync('dsh', ['--version'], { timeout: 10_000, encoding: 'utf8' })
126
+ if (res.error || res.status !== 0) return null
127
+ const text = String(res.stdout ?? '').trim()
128
+ return text === '' ? null : text
129
+ } catch {
130
+ return null
131
+ }
132
+ }
133
+
134
+ // ── 声明行构造 ──────────────────────────────────────────────────────────────
135
+
136
+ /**
137
+ * 构造一条 preset 声明行(insert 列表元素)的 YAML 文本:
138
+ *
139
+ * - id: preset-orchestrator
140
+ * name: '@deepseek-ai/dsh-agent-preset'
141
+ * config:
142
+ * id: orchestrator
143
+ * name: "编排模式 (Orchestrator)"
144
+ * description: "..."
145
+ * order: 5
146
+ * plugins:
147
+ * <agent.cordis.yml 全文,每非空行加 10 空格>
148
+ *
149
+ * 缩进基准:4 空格起排(`- insert:` 列表项层级由 upsertHomePatchRows 拼),
150
+ * plugins: 落 8 空格、插件条目行落 10 空格。name/description 用 JSON.stringify
151
+ * 落值——JSON 字符串即合法 YAML 双引号标量,换行/引号自动转义。agentYmlText
152
+ * 先把 restrict 行的 `name: ./restrict.mjs` 替换为 restrictFileUrl(绝对 file:
153
+ * URL),再整体 +10 缩进;空行保持空行(块标量相对缩进不变),`!!js` 表达式
154
+ * 行原样保留(宿主 patch schema 支持,求值仍由 cordis 装载时完成)。
155
+ */
156
+ export function buildPresetRowText({ presetId, name = null, description = null, order = null, agentYmlText, restrictFileUrl }) {
157
+ if (typeof presetId !== 'string' || !/^[a-z0-9][a-z0-9-]*$/.test(presetId)) {
158
+ throw new Error(`buildPresetRowText: 非法 presetId: ${presetId}`)
159
+ }
160
+ if (typeof agentYmlText !== 'string' || agentYmlText === '') {
161
+ throw new Error('buildPresetRowText: agentYmlText 不能为空')
162
+ }
163
+ if (typeof restrictFileUrl !== 'string' || restrictFileUrl === '') {
164
+ throw new Error('buildPresetRowText: restrictFileUrl 不能为空')
165
+ }
166
+ const lines = [
167
+ ` - id: ${presetRowId(presetId)}`,
168
+ ` name: '${PRESET_ROW_NAME}'`,
169
+ ' config:',
170
+ ` id: ${presetId}`,
171
+ ]
172
+ if (typeof name === 'string' && name !== '') lines.push(` name: ${JSON.stringify(name)}`)
173
+ if (typeof description === 'string' && description !== '') lines.push(` description: ${JSON.stringify(description)}`)
174
+ if (Number.isInteger(order)) lines.push(` order: ${order}`)
175
+ lines.push(' plugins:')
176
+ for (const line of String(agentYmlText).split(/\r?\n/)) {
177
+ if (line.trim() === '') {
178
+ lines.push('')
179
+ continue
180
+ }
181
+ // restrict 行换绝对 file: URL(按行精确内容匹配,缩进原样保留)
182
+ const replaced = line.trim() === 'name: ./restrict.mjs'
183
+ ? `${line.match(/^[ \t]*/)[0]}name: ${restrictFileUrl}`
184
+ : line
185
+ lines.push(` ${replaced}`)
186
+ }
187
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop() // 去尾部空行,段拼接才紧凑
188
+ return lines.join('\n')
189
+ }
190
+
191
+ // ── home patch 托管块手术 ───────────────────────────────────────────────────
192
+ //
193
+ // 托管块落在 $DSH_HOME/cordis.patch.yml(home 层 patch,作用于所有 profile),
194
+ // 形态:
195
+ //
196
+ // # --- dsh-paoding presets (auto-generated; do not edit) ---
197
+ // - insert:
198
+ // - id: preset-orchestrator
199
+ // name: '@deepseek-ai/dsh-agent-preset'
200
+ // config:
201
+ // ...
202
+ // # --- end dsh-paoding presets ---
203
+ //
204
+ // 每条声明行各自包一层 0 缩进的 `- insert:`(宿主约定「自定义【新增】条目必须
205
+ // 用 - insert: 包一层」,顶层 - id: 是修改已有条目语义),声明行本体按
206
+ // buildPresetRowText 的 4 空格基准缩进,作 insert 列表元素。块外用户内容除
207
+ // strip 的空行收敛外一字节不动。
208
+
209
+ /** home 层 patch 文件路径。 */
210
+ function patchFilePath(dshHome) {
211
+ return path.join(dshHome, 'cordis.patch.yml')
212
+ }
213
+
214
+ /** 读 home patch 为行数组;文件缺失当空文件。行尾换行符风格随原文件保留。 */
215
+ function readPatchLines(dshHome) {
216
+ let text = ''
217
+ try {
218
+ text = readFileSync(patchFilePath(dshHome), 'utf8')
219
+ } catch (err) {
220
+ if (err?.code !== 'ENOENT') throw err
221
+ return { lines: [], eol: '\n' }
222
+ }
223
+ if (text === '') return { lines: [], eol: '\n' }
224
+ return { lines: text.split(/\r?\n/), eol: text.includes('\r\n') ? '\r\n' : '\n' }
225
+ }
226
+
227
+ function writePatchLines(dshHome, lines, eol) {
228
+ mkdirSync(dshHome, { recursive: true })
229
+ writeFileSync(patchFilePath(dshHome), lines.join(eol), 'utf8')
230
+ }
231
+
232
+ /**
233
+ * 在行数组里定位托管块 [begin, end](闭区间下标)。无起始标记返回 null;
234
+ * 有起始无结束(托管块损坏,多半是用户手删了半截)throw 明确错误——后续写盘
235
+ * 一律拒绝,绝不覆盖手工内容。
236
+ */
237
+ function findManagedSpan(lines) {
238
+ const begin = lines.findIndex((l) => l.trim() === MANAGED_BEGIN)
239
+ if (begin === -1) return null
240
+ let end = -1
241
+ for (let i = begin + 1; i < lines.length; i++) {
242
+ if (lines[i].trim() === MANAGED_END) {
243
+ end = i
244
+ break
245
+ }
246
+ }
247
+ if (end === -1) {
248
+ throw new Error(
249
+ `cordis.patch.yml 的 dsh-paoding 托管块已损坏(只有起始标记、找不到结束标记 "${MANAGED_END}"):` +
250
+ '请手工补回结束标记,或把整个托管块删除后重试;已拒绝写盘以免覆盖手工内容',
251
+ )
252
+ }
253
+ return { begin, end }
254
+ }
255
+
256
+ /** 托管块内声明行段的起始行:4 空格缩进的 `- id: preset-...`。 */
257
+ const ROW_ID_LINE_RE = /^ {4}- id: (preset-\S+)[ \t]*$/
258
+
259
+ /**
260
+ * 把托管块内容行解析成行段数组 [{ rowId, lines }]:每个段由 `- insert:` 包裹行
261
+ * (若紧邻在 `- id:` 行上方)加声明行本体组成,到下一段或块尾止。段尾空行归
262
+ * 所属段,重写时裁掉保持块紧凑。
263
+ */
264
+ function parseRowSegments(blockLines) {
265
+ const marks = []
266
+ for (let i = 0; i < blockLines.length; i++) {
267
+ const m = ROW_ID_LINE_RE.exec(blockLines[i])
268
+ if (!m) continue
269
+ // 段首含紧邻的 `- insert:` 包裹行;包裹行不在(手工摆弄过)则从行本体起段
270
+ const segStart = i > 0 && blockLines[i - 1].trim() === '- insert:' ? i - 1 : i
271
+ marks.push({ rowId: m[1], segStart })
272
+ }
273
+ return marks.map((mk, idx) => ({
274
+ rowId: mk.rowId,
275
+ lines: blockLines.slice(mk.segStart, idx + 1 < marks.length ? marks[idx + 1].segStart : blockLines.length),
276
+ }))
277
+ }
278
+
279
+ /** 裁掉行数组尾部的空行(不含末尾那个代表「文件以换行结束」的空元素)。 */
280
+ function trimTrailingBlankLines(lines) {
281
+ const out = [...lines]
282
+ while (out.length > 1 && out[out.length - 1].trim() === '') out.pop()
283
+ return out
284
+ }
285
+
286
+ /** 3+ 连续空行收敛为一个空行(strip 撤行后的兜底收拾;1-2 连续空行原样保留)。 */
287
+ function collapseBlankRuns(lines) {
288
+ const out = []
289
+ let blankRun = 0
290
+ for (const line of lines) {
291
+ if (line.trim() === '') {
292
+ blankRun++
293
+ continue
294
+ }
295
+ if (blankRun > 0) {
296
+ if (blankRun >= 3) out.push('')
297
+ else for (let i = 0; i < blankRun; i++) out.push('')
298
+ blankRun = 0
299
+ }
300
+ out.push(line)
301
+ }
302
+ for (let i = 0; i < blankRun; i++) out.push('') // 文件尾空行原样保留
303
+ return out
304
+ }
305
+
306
+ /**
307
+ * 把若干声明行(buildPresetRowText 产物)行级 upsert 进 home patch 托管块。
308
+ * 托管块不存在则文末新建(非空文件先补尾换行、块前空一行);已存在则块内按
309
+ * rowId 替换 / 追加,块外用户内容一字节不动。托管块损坏时 throw(不写盘)。
310
+ */
311
+ export function upsertHomePatchRows(dshHome, rowTexts) {
312
+ const rows = (Array.isArray(rowTexts) ? rowTexts : [])
313
+ .filter((t) => typeof t === 'string' && t.trim() !== '')
314
+ if (rows.length === 0) return
315
+ const wanted = rows.map((text) => {
316
+ const rowLines = text.replace(/\r?\n$/, '').split('\n')
317
+ const m = ROW_ID_LINE_RE.exec(rowLines[0])
318
+ if (!m) {
319
+ throw new Error(`buildPresetRowText 产出的行文本缺少 4 空格缩进的 "- id: preset-..." 首行: ${rowLines[0]}`)
320
+ }
321
+ return { rowId: m[1], lines: ['- insert:', ...rowLines] }
322
+ })
323
+ // 同 rowId 传多条时后者生效(Map 去重,保证替换语义而非重复追加)
324
+ const wantedById = new Map(wanted.map((w) => [w.rowId, w]))
325
+
326
+ const { lines, eol } = readPatchLines(dshHome)
327
+ const span = findManagedSpan(lines) // 损坏块 throw,直接中止(未写任何盘)
328
+ if (span !== null) {
329
+ const segments = parseRowSegments(lines.slice(span.begin + 1, span.end))
330
+ const merged = []
331
+ for (const seg of segments) {
332
+ if (wantedById.has(seg.rowId)) merged.push(...wantedById.get(seg.rowId).lines)
333
+ else merged.push(...trimTrailingBlankLines(seg.lines)) // 块内其余 preset 的行原样保留
334
+ }
335
+ for (const w of wanted) {
336
+ if (!segments.some((s) => s.rowId === w.rowId)) merged.push(...w.lines) // 新行追加到块尾
337
+ }
338
+ const next = [...lines.slice(0, span.begin), MANAGED_BEGIN, ...merged, MANAGED_END, ...lines.slice(span.end + 1)]
339
+ writePatchLines(dshHome, next, eol)
340
+ return
341
+ }
342
+ // 无托管块:文末新建。非空文件先补尾换行(若缺)。块前空一行不再单独推:
343
+ // split 出的末尾 '' 元素(文件尾换行)后面紧跟新内容时,join 天然渲染出一个
344
+ // 空行,正好兼任块前分隔——补的尾换行元素同理。额外再推一个空行会变成两个。
345
+ const out = [...lines]
346
+ const hasContent = out.some((l) => l.trim() !== '')
347
+ if (hasContent && out[out.length - 1].trim() !== '') out.push('')
348
+ out.push(MANAGED_BEGIN)
349
+ for (const w of wanted) out.push(...w.lines)
350
+ out.push(MANAGED_END, '')
351
+ writePatchLines(dshHome, out, eol)
352
+ }
353
+
354
+ /**
355
+ * 从 home patch 托管块撤掉指定 rowId 的声明行段;rowIdsOrAll 传 null 删整块
356
+ * (含起止标记与块前自加的空行分隔,用户内容回到插入前的字节形态)。
357
+ * 块不存在 / 指定行都不在场返回 false(未写盘);托管块损坏时 warn 后返回
358
+ * false(撤行是锦上添花的自愈路径,不值得为此中断安装,修复由 upsert 的
359
+ * 明确报错引导)。
360
+ */
361
+ export function stripHomePatchRows(dshHome, rowIdsOrAll = null) {
362
+ let lines
363
+ let eol
364
+ try {
365
+ ({ lines, eol } = readPatchLines(dshHome))
366
+ } catch (err) {
367
+ warn(`cordis.patch.yml 读取失败,声明行撤除跳过: ${err?.message ?? err}`)
368
+ return false
369
+ }
370
+ let span = null
371
+ try {
372
+ span = findManagedSpan(lines)
373
+ } catch (err) {
374
+ warn(`${err.message}(声明行撤除跳过)`)
375
+ return false
376
+ }
377
+ if (span === null) return false
378
+
379
+ const allSegments = parseRowSegments(lines.slice(span.begin + 1, span.end))
380
+ const targets = rowIdsOrAll === null
381
+ ? allSegments
382
+ : allSegments.filter((s) => new Set(rowIdsOrAll).has(s.rowId))
383
+ if (targets.length === 0) return false
384
+
385
+ if (targets.length === allSegments.length) {
386
+ // 整块摘除:连同块前那行本工具自加的空行分隔一起拿掉(空行分隔是自己
387
+ // 加的,摘掉才算「用户内容回到插入前」)
388
+ let start = span.begin
389
+ if (start > 0 && lines[start - 1].trim() === '' && lines.slice(0, start).some((l) => l.trim() !== '')) start -= 1
390
+ const next = [...lines.slice(0, start), ...lines.slice(span.end + 1)]
391
+ // 文件撤空(只剩空行)就落空文件,不留一撮换行尾巴
392
+ const empty = next.every((l) => l.trim() === '')
393
+ writePatchLines(dshHome, empty ? [] : collapseBlankRuns(next), eol)
394
+ return true
395
+ }
396
+ // 块内还有其余 preset 的行:只摘指定段,标记与块结构保留
397
+ const removeIds = new Set(targets.map((t) => t.rowId))
398
+ const merged = []
399
+ for (const seg of allSegments) {
400
+ if (!removeIds.has(seg.rowId)) merged.push(...trimTrailingBlankLines(seg.lines))
401
+ }
402
+ const next = [...lines.slice(0, span.begin), MANAGED_BEGIN, ...merged, MANAGED_END, ...lines.slice(span.end + 1)]
403
+ writePatchLines(dshHome, collapseBlankRuns(next), eol)
404
+ return true
405
+ }
406
+
407
+ /**
408
+ * 读 home patch 托管块里现有的声明行 [{ rowId, presetId }](presetId 取自声明
409
+ * 行 config 的 id: 值,8 空格缩进;解析不出为 null)。文件缺失 / 无托管块 /
410
+ * 托管块损坏一律返回空数组——调用方只拿它做「块在不在」的判断。
411
+ */
412
+ export function readHomePatchRows(dshHome) {
413
+ let lines
414
+ try {
415
+ ({ lines } = readPatchLines(dshHome))
416
+ } catch {
417
+ return []
418
+ }
419
+ try {
420
+ const span = findManagedSpan(lines)
421
+ if (span === null) return []
422
+ const configIdRe = /^ {8}id: (\S+)[ \t]*$/
423
+ return parseRowSegments(lines.slice(span.begin + 1, span.end)).map((seg) => {
424
+ const idLine = seg.lines.find((l) => configIdRe.test(l))
425
+ return { rowId: seg.rowId, presetId: idLine ? configIdRe.exec(idLine)[1] : null }
426
+ })
427
+ } catch {
428
+ return []
429
+ }
430
+ }
431
+
432
+ // ── 轨道迁移对账(宿主升降级自愈)────────────────────────────────────────────
433
+ //
434
+ // 只升 dsh 不动插件是升级的最常见路径:标记版本照旧吻合、自愈不会触发,但
435
+ // .agent-presets/ 目录轨产物在 0.1.7 下已经无人扫描——preset 静默消失。这一节
436
+ // 的原语补上这条缝:标记从「只记版本」升级为「版本 + 轨道」两行,轨道翻转即
437
+ // 触发自愈;且声明轨上的自愈可以只补 home patch 声明行(磁盘产物原样保留、
438
+ // 字节不动),不必整盘重生成。
439
+
440
+ /** 编排类 preset 目录名(orchestrator / orchestrator-<slug>)的形状过滤。 */
441
+ const ORCHESTRATOR_PRESET_ID_RE = /^orchestrator(-[a-z0-9-]+)?$/
442
+
443
+ /**
444
+ * 列出 $DSH_HOME/.agent-presets/ 下的编排类 preset 目录名(含基础 orchestrator
445
+ * 与各工作区专属 orchestrator-<slug>),按名排序。目录不存在 / 不可读一律返回
446
+ * 空列表。非编排类目录(liangshen 之类自制 preset)不在此列——迁移对账只认
447
+ * 本工具生成的产物形状。
448
+ */
449
+ export function listOrchestratorPresetIds(dshHome) {
450
+ try {
451
+ return readdirSync(path.join(dshHome, '.agent-presets'))
452
+ .filter((name) => ORCHESTRATOR_PRESET_ID_RE.test(name))
453
+ .sort()
454
+ } catch {
455
+ return []
456
+ }
457
+ }
458
+
459
+ /**
460
+ * 读任意 preset 目录下 preset.yml 的显示元数据(name / description / order),
461
+ * 与 generateAndInstall 写 preset.yml / 声明行 config 的取值口径同源:顶层
462
+ * name / order 单行标量 + description 多行纯量(续行按 YAML 折叠语义以空格
463
+ * 拼接成单值)。读不到 / 解析不出时返回空元数据——声明行的 name /
464
+ * description / order 都是宿主可选键,缺省不致命。
465
+ */
466
+ export function readPresetDirMeta(dir) {
467
+ try {
468
+ const text = readFileSync(path.join(dir, 'preset.yml'), 'utf8')
469
+ const lines = text.split(/\r?\n/)
470
+ const firstMatch = (re) => {
471
+ for (const line of lines) {
472
+ const m = re.exec(line)
473
+ if (m) return m
474
+ }
475
+ return null
476
+ }
477
+ const name = firstMatch(/^name:[ \t]*(.+?)[ \t]*$/)?.[1] ?? null
478
+ const orderRaw = firstMatch(/^order:[ \t]*(\d+)[ \t]*$/)
479
+ const order = orderRaw ? Number(orderRaw[1]) : null
480
+ let description = null
481
+ const di = lines.findIndex((l) => /^description:/.test(l))
482
+ if (di !== -1) {
483
+ const parts = [lines[di].slice('description:'.length).trim()]
484
+ for (let i = di + 1; i < lines.length; i++) {
485
+ const line = lines[i]
486
+ if (line.trim() === '' || !/^[ \t]/.test(line)) break // 续行尽 / 撞上下一个顶层键
487
+ parts.push(line.trim())
488
+ }
489
+ description = parts.filter((p) => p !== '').join(' ') || null
490
+ }
491
+ return { name, description, order }
492
+ } catch {
493
+ return { name: null, description: null, order: null }
494
+ }
495
+ }
496
+
497
+ /**
498
+ * 把磁盘上已有的编排类 preset 目录补写进 home patch 托管块(缺行才补,已有行
499
+ * 原样不动);行在、目录没了的撤行——目录与行同生同灭。产物不全(缺
500
+ * agent.cordis.yml / restrict.mjs)的目录跳过并 warn,不拖垮其余补写。
501
+ * 返回实际补写成功的 presetId 列表(既没补也没撤时为空数组)。
502
+ */
503
+ export function backfillHomePatchRows(dshHome, presetIds) {
504
+ const rowsOnDisk = readHomePatchRows(dshHome)
505
+ const wanted = new Set((Array.isArray(presetIds) ? presetIds : []).filter((id) => ORCHESTRATOR_PRESET_ID_RE.test(id)))
506
+ const rows = []
507
+ const backfilled = []
508
+ for (const presetId of wanted) {
509
+ if (rowsOnDisk.some((r) => r.presetId === presetId)) continue
510
+ const dir = path.join(dshHome, '.agent-presets', presetId)
511
+ try {
512
+ const agentYmlText = readFileSync(path.join(dir, 'agent.cordis.yml'), 'utf8')
513
+ const restrictFile = path.join(dir, 'restrict.mjs')
514
+ if (!existsSync(restrictFile)) throw new Error('restrict.mjs 缺失')
515
+ const meta = readPresetDirMeta(dir)
516
+ rows.push(
517
+ buildPresetRowText({
518
+ presetId,
519
+ name: meta.name,
520
+ description: meta.description,
521
+ order: meta.order,
522
+ agentYmlText,
523
+ restrictFileUrl: pathToFileURL(restrictFile).href,
524
+ }),
525
+ )
526
+ backfilled.push(presetId)
527
+ } catch (err) {
528
+ warn(`声明行补写跳过 ${presetId}(产物不全,可整盘重生成修复): ${err?.message ?? err}`)
529
+ }
530
+ }
531
+ if (rows.length > 0) upsertHomePatchRows(dshHome, rows)
532
+ // 反向对账:托管块里有行、磁盘上已无对应目录的(用户手删目录等),行同步撤下
533
+ const orphanRowIds = rowsOnDisk.filter((r) => r.presetId !== null && !wanted.has(r.presetId)).map((r) => r.rowId)
534
+ if (orphanRowIds.length > 0) stripHomePatchRows(dshHome, orphanRowIds)
535
+ return backfilled
536
+ }
537
+
538
+ /**
539
+ * 解析 .generator-version 标记文本为 { version, track }。v2 格式两行:首行
540
+ * 生成器版本、次行预设安装轨道(declarative / directory);旧格式(单行版本,
541
+ * 0.3.4 及以前)解析出 track: null —— 恰是「宿主轨道已翻转但产物版本未动」的
542
+ * 迁移触发态。空文本 / 全空白返回 null(按未生成处理)。
543
+ */
544
+ export function parseGeneratorMarker(text) {
545
+ const lines = String(text ?? '')
546
+ .split(/\r?\n/)
547
+ .map((l) => l.trim())
548
+ .filter((l) => l !== '')
549
+ if (lines.length === 0) return null
550
+ const track = lines[1] === 'declarative' || lines[1] === 'directory' ? lines[1] : null
551
+ return { version: lines[0], track }
552
+ }
553
+
554
+ /** 生成 .generator-version 标记文本(版本 + 轨道两行,v2 格式)。 */
555
+ export function formatGeneratorMarker(version, presetSystem) {
556
+ return `${version}\n${presetSystem}\n`
557
+ }