dsh-paoding 0.3.10 → 0.4.0

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/README.md CHANGED
@@ -18,6 +18,11 @@ dsh-paoding(中文品牌「庖丁」,典出《庄子》——庖丁顺纹理
18
18
  - **装了什么都能认出来**:MCP 服务器、本地插件、已装技能,安装与配置时全程检测;默认只装 dsh 基础工具,检测到的 host 工具在「庖丁配置」里按需勾选;停用哪个,重新应用后相关工具自动剔除。
19
19
  - **按工作区各有各的班底**:不同项目可以配不同的主/子 agent——「庖丁配置」里选定工作区单独配置,每个工作区生成自己的预设(`orchestrator-<目录名>`),新建会话时选对应预设即可,全局默认不受影响。
20
20
  - **图形界面配置**:不习惯改配置文件的话,点开左侧栏底部动作条(设置行上方)的「庖丁配置」入口,整页点选即可,「保存并应用」即时重生成预设——与首装自动化同一条生成管线。
21
+ - **工具条目支持通配符**:`*` 与 `?` 在生成期展开成具体工具名——`mcp__tavily__tavily_*` 一行顶五行,MCP 工具族不必逐个点名;零匹配有提醒,产物与运行时行为不变。
22
+ - **保存前先体检**:预览/应用时自动过一遍检查——配置了但未挂载的工具、零工具角色、查无此名的模型、预设落后于配置,分级提示一目了然;CLI 侧 `dsh-paoding doctor` 六项只读自检(配置可解析、安装轨道、声明行托管块、孤儿目录、版本标记、预设漂移),一条命令查全身。
23
+ - **场景模板一键换班底**:轻量(只留实现)、调研(只留检索)、全开(全员到齐、主 agent 带满检测库存)三档点一下就切,只动「谁在场 + 主 agent 额外带什么」,精调过的角色配置分毫不动。
24
+ - **配置可导出可导入**:面板「更多」菜单一键下载 `dsh-paoding.config.yml`,换机迁移、备份整份拷走;导入先验格式,覆盖整体配置(含工作区段)需二次确认。
25
+ - **面板中英双语**:界面文案 zh/en 全覆盖,语言随浏览器自动识别,「更多」菜单一键切换。
21
26
  - **版本更新有提醒,面板一键升级**:面板会对比 npm registry(npmmirror 镜像、GitHub release 依次兜底)上的最新版本,发现新版就地提示并附上发布页链接;点「升级」就地换上新版(profile 自动探测:普通 profile 走 `dsh plugin update dsh-paoding`;desktop profile 被 dsh CLI 的 Electron 专属守卫挡住,改在 profile 目录直跑 `pnpm add dsh-paoding@<版本>`),重启 DSH 后生效,编排预设会在重启时按新版自动重生成。开发检出的 link 形态无法就地升级,面板会提示切回 registry 版的办法;检测失败时页头留一行「版本检测失败 · 重试」(完整原因与代理排查提示在悬浮提示里),不弹窗打扰。
22
27
  - **来去自由**:不改 DSH 一行源码;装完新会话即用,卸载即删目录。
23
28
  - **两代宿主都兼容**:DSH 0.1.5 – 0.2.0-rc.2 全支持(0.1.5 / 0.1.6 / 0.1.7-rc.1 / 0.1.7-rc.2 / 0.2.0-rc.1 / 0.2.0-rc.2)——两代宿主发现预设的机制不同(目录扫描 vs patch 声明行),安装器自动检测宿主版本选对落点;编排组合里的 workflow worker 行也按宿主版本自动适配(0.1.5 用 workflow-worker-thread,0.1.6+ 换 workflow-ptc——旧包 0.1.6 起已删除,残留行正是 0.2.0 面板上「加载失败」的元凶),宿主升级、降级后的首次保存/自愈自动迁移,全程无感。
package/README_en.md CHANGED
@@ -18,6 +18,11 @@ dsh-paoding (Chinese brand 「庖丁」/ Pao Ding, from the Zhuangzi parable of
18
18
  - **Whatever you've installed, it's recognized**: MCP servers, local plugins, installed skills — detected throughout install and configuration; the default install carries only DSH base tools, and detected host tools are opted into per tool in **Paoding Config**; disable one, re-apply, and its tools drop out on their own.
19
19
  - **A different lineup per workspace**: each project can carry its own main/sub-agent setup — pick the workspace in 庖丁配置, configure it separately, and it gets its own preset (`orchestrator-<basename>`); choose that preset when starting a session. The global default stays untouched.
20
20
  - **A visual configurator**: prefer clicking to editing YAML? the 庖丁配置 (Paoding Config) entry in the sidebar bottom action bar (above the Settings row) covers everything; Save & Apply regenerates the preset on the spot — the same generation pipeline as first-install automation.
21
+ - **Tool entries support wildcards**: `*` and `?` expand into concrete tool names at generation time — one `mcp__tavily__tavily_*` line covers the whole family; zero-match patterns get a warning, and artifacts and runtime behavior stay unchanged.
22
+ - **A health check before every save**: preview/apply runs a built-in check — tools configured but not mounted, zero-tool roles, unknown model names, a preset lagging behind the config — each flagged by severity; on the CLI side, `dsh-paoding doctor` runs six read-only checks (config parses, install track, managed declaration block, orphan directories, version marker, preset drift) in one command.
23
+ - **Scenario templates swap the roster in one click**: Lite (implement only), Research (search roles only), and Full (everyone on deck, main agent carrying the whole detected inventory) — templates only touch who is present and what the main agent carries; hand-tuned role settings are never touched.
24
+ - **Config export & import**: download `dsh-paoding.config.yml` from the panel's More menu for migration or backup; imports are validated first, and overwriting the whole config (workspaces included) asks for confirmation.
25
+ - **Panel in English and Chinese**: every UI string ships in both languages, auto-detected from the browser, switchable from the More menu.
21
26
  - **Notified of new releases, one-click upgrade in the panel**: the panel compares your local version against the latest npm registry publish (npmmirror and GitHub release as successive fallbacks) and points to the release page when a newer one is out. The Upgrade button swaps in the new version in place (profile auto-detected): regular profiles go through `dsh plugin update dsh-paoding`, while the Electron-owned desktop profile — which the dsh CLI guards against — gets a direct `pnpm add dsh-paoding@<version>` run in the profile directory; a DSH restart applies it, and the orchestrator preset regenerates itself against the new version on startup. A dev checkout in link: form cannot be upgraded in place — the panel tells you how to switch back to the registry version. If the check fails (offline, rate-limited), a subdued "version check failed · retry" row stays in the header (full causes and proxy hints in its tooltip) without popping anything up.
22
27
  - **Installs and uninstalls cleanly**: no DSH source changes; active in a new session right after install, gone when you delete one directory.
23
28
  - **Works on both host generations**: DSH 0.1.5 through 0.2.0-rc.2 are all supported (0.1.5 / 0.1.6 / 0.1.7-rc.1 / 0.1.7-rc.2 / 0.2.0-rc.1 / 0.2.0-rc.2) — the two generations discover presets differently (directory scan vs patch declaration rows), and the installer picks the right landing automatically; from 0.2.0 the workflow worker row in the orchestration composition also adapts to the host version at generation time (0.1.5 uses workflow-worker-thread, 0.1.6+ swaps to workflow-ptc). Migration happens on the first save/self-heal after a host upgrade or downgrade, all without user action.
@@ -15,9 +15,16 @@ const REFUSE =
15
15
  const usage = `dsh-paoding 安装快捷方式(等价于 dsh plugin --profile <name> add dsh-paoding@<version>)
16
16
 
17
17
  用法:npx dsh-paoding@latest [--profile <name>]
18
+ npx dsh-paoding@latest doctor [--profile <name>] [--config <file>]
19
+
20
+ 子命令:
21
+ doctor 本机安装体检(只读):配置文件 / 预设轨道 / 托管声明行 /
22
+ 孤儿工作区预设 / 预设漂移 / 版本标记,结尾给汇总;
23
+ 有错误级发现时退出码 1。
18
24
 
19
25
  选项:
20
26
  --profile <name> 插件装入的 profile,默认 web(dsh web 的默认 profile)
27
+ --config <file> 配置文件路径(仅 doctor 子命令使用)
21
28
  --help 显示本帮助
22
29
  --version 显示包版本
23
30
 
@@ -28,6 +35,22 @@ ${REFUSE}`;
28
35
  const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
29
36
 
30
37
  const argv = process.argv.slice(2);
38
+
39
+ // doctor 子命令:包不再携带独立 CLI,但 tools/ 随包发布(package.json files),
40
+ // 体检逻辑本体在 tools/lib/cli.mjs——这里 dynamic import 转发(--profile /
41
+ // --config 等参数原样透传,由它自己的 parseArgs 解析),退出码语义:
42
+ // 无错误 0,有 error 级发现 1。放在参数循环之前:doctor 的参数不用本入口的
43
+ // profile 解析兜底。
44
+ if (argv.includes('doctor')) {
45
+ try {
46
+ const { main } = await import('../tools/lib/cli.mjs');
47
+ process.exit((await main({ argv })) ?? 0);
48
+ } catch (err) {
49
+ console.error(`doctor 运行失败:${err?.message ?? err}`);
50
+ process.exit(1);
51
+ }
52
+ }
53
+
31
54
  let profile;
32
55
  for (let i = 0; i < argv.length; i++) {
33
56
  const arg = argv[i];
@@ -191,7 +191,7 @@ dsh-paoding(中文品牌「庖丁」,取庖丁解牛之意)是 DSH(DeepS
191
191
 
192
192
  > 注:`codegraph` 与 `memory_search` 已于 2026-09-02 从该角色移除——二者对应插件(codegraph MCP /
193
193
  > magic-memory)停用时不在子 agent 可见注册面内,`tools.restrict()` 会以 unknown tools 拒绝创建该角色;
194
- > 重新启用插件后按需加回即可(列表精确名匹配,不支持 glob,见「已知边界」一节)。
194
+ > 重新启用插件后按需加回即可(工具条目支持 `*` / `?` 通配,生成期展开,见「已知边界」一节)。
195
195
 
196
196
  ## 原理:对应 DSH 原生机制
197
197
 
@@ -293,8 +293,9 @@ DSH 0.1.7 起宿主默认升到 65536,显式值让 0.1.5 – 0.2.0-rc.2 全系
293
293
  - **角色工具面是静态绑定的**(由配置/`toolFilter.allow` 决定),不是模型在运行时任意指定;
294
294
  「选角色工具 = 选工具面」对固定分工恰好是优点,但无法在对话中临时给某个角色加工具
295
295
  (要加需改配置/静态源后重跑生成器)。
296
- - **`toolFilter.allow` 是精确名匹配,不支持 glob**:tavily 的 5 个工具必须逐个写全
297
- (`mcp__tavily__tavily_search/crawl/extract/map/research`)。
296
+ - **`toolFilter.allow` 支持 `*` / `?` 通配,但只在生成期展开**:tavily 的 5 个工具可以写成一行
297
+ `mcp__tavily__tavily_*`,生成器按候选工具面展开成具体名写入产物,运行时仍是精确名匹配——
298
+ 语义零变化;完整 glob 语法(字符类、转义等)不在此列。
298
299
  - **主 agent 白名单里未挂载的工具不会报错**:瀑布过滤只在已解析的 `assembly.tools` 里按名匹配,
299
300
  某工具(如被注释的 MCP)不在其中就只是不出现,生成与运行都静默通过——配置意图与实际挂载
300
301
  靠生成期检测对齐,改动 host patch 后在「庖丁配置」点「保存并应用」同步即可(「预览生成」可先看清单)。
@@ -248,8 +248,8 @@ note for that role below; see
248
248
  > Note: `codegraph` and `memory_search` were removed from this role on 2026-09-02 — when their plugins
249
249
  > (codegraph MCP / magic-memory) are disabled they are outside the sub-agent's visible registry, and
250
250
  > `tools.restrict()` would refuse to create the role with unknown tools. Re-add them on demand after
251
- > re-enabling the plugins (allow lists use exact name matching, no globs — see the "Known limitations"
252
- > section below).
251
+ > re-enabling the plugins (tool entries accept `*` / `?` wildcards, expanded at generation time — see the
252
+ > "Known limitations" section below).
253
253
 
254
254
  ## Under the hood
255
255
 
@@ -380,8 +380,10 @@ composition:
380
380
  arbitrarily by the model at runtime. "Picking a role tool picks a tool surface" is an advantage for fixed
381
381
  division of labor, but a tool cannot be granted to a role mid-conversation — changing it requires editing
382
382
  the configuration / static source and re-running the generator.
383
- - **`toolFilter.allow` uses exact name matching and supports no globs**: the five tavily tools must be
384
- listed in full (`mcp__tavily__tavily_search/crawl/extract/map/research`).
383
+ - **`toolFilter.allow` accepts `*` / `?` wildcards, but they expand only at generation time**: the five
384
+ tavily tools fit on one line as `mcp__tavily__tavily_*`; the generator expands patterns against the
385
+ candidate tool surface into concrete names in the generated preset, so runtime matching stays exact-name —
386
+ semantics unchanged. Full glob syntax (character classes, escapes, …) is not in scope.
385
387
  - **Tools absent from the main-agent whitelist raise no error**: the waterfall filter only matches by name
386
388
  against the resolved `assembly.tools`, so a tool that is not mounted (e.g. a commented-out MCP) simply
387
389
  never appears — generation and runtime both pass silently. Config intent and actual mounts are reconciled
@@ -131,7 +131,17 @@ skills: # skill -> 分配给的角色(写进角色 persona 的
131
131
 
132
132
  - `persona`:整体替换部署 persona。写入后该文本会进入 `dsh-tool-subagent` 实例的 `persona` 字段 —— 即子 agent 的 **deployment persona**(子 agent 会话里看到的身份与行为指引),不是追加。静态 persona 里内置的「失败汇报协议 / 行为边界」段落也会被一并替换——要保留请把原文(见 `presets/orchestrator/agent.cordis.yml` 对应块)并入你的文本。
133
133
  - `tools`:**全量重写**该角色的 `toolFilter.allow` 意图。一句话关系:`agent.cordis.yml` 里的 allow 是静态意图,安装器以实际检测结果重写它;配置里的 `roles.<name>.tools` 就是你要覆盖的新意图 —— 检测后能留多少留多少。
134
- - 角色 allow 是**精确名匹配**,不支持 glob:tavily 的 5 个工具要逐个写全(`mcp__tavily__tavily_search` … `mcp__tavily__tavily_research`)。
134
+ - 工具条目支持 `*` 与 `?` 通配(`*` 匹配任意长字符段、可为空,`?` 恰好一个字符)。展开发生在**生成期**:生成器把模式条目对候选工具面(preset 自带工具面 ∪ 检测清单)展开成具体名写进产物,运行时白名单语义与逐个写全完全一致——命中名仍按既有规则过滤(未检测到的 MCP 工具不会因命中模式而回填),某模式一个名字都没匹配上时会告警并忽略该条目。tavily 五个工具因此可以写成一行:
135
+
136
+ ```yaml
137
+ roles:
138
+ search_external:
139
+ tools:
140
+ - web_search
141
+ - mcp__tavily__tavily_* # 生成时展开为 search/crawl/extract/map/research(以实际检测为准)
142
+ ```
143
+
144
+ `main_agent_extra` 与自定义角色的 `tools` 同样支持。配置文件里模式条目原样存取(配置面板回显不受影响),只有生成的 preset 里落的是展开后的具体名。
135
145
 
136
146
  示例(替换 `implement` 的 persona 与 tools):下方 tools 与静态默认一致——注意它不含 `str_replace_editor`(原因见 8.2)。显式写出 tools 等于固定意图:将来静态源怎么改都不再影响本角色:
137
147
 
@@ -637,7 +647,7 @@ skills:
637
647
  ### 8.2 平台注册面细节
638
648
 
639
649
  - **`str_replace_editor` 刻意未列入 `implement`**:部分部署的注册面没有该工具,一旦列入 allow,子 agent 创建时 `tools.restrict()` 会以 unknown tools 拒绝。确认你的部署确实注册后再自行加回(静态源 implement allow,或配置 `roles.implement.tools`)。
640
- - 白名单 / allow 是**精确名匹配**,不支持 glob;tavily 五个工具要逐个写全。
650
+ - 白名单 / allow 的工具条目支持 `*` / `?` 通配,生成期展开成具体名(见 2.1);逐个写精确名自然也行。
641
651
  - 主 agent 白名单里未挂载的名字不报错(瀑布只在已解析工具里按名匹配)。
642
652
 
643
653
  ### 8.3 停用 MCP / 插件后的同步
@@ -693,3 +703,50 @@ workspaces:
693
703
  - 只装在某个项目里的技能(比如知识库项目里的 `bento-slides`),在全局配置里不可见、配不上;选中那个工作区后出现在技能网格里,带「项目」角标,可分配给该工作区预设的主 agent 或角色。
694
704
  - 生成时技能解析同口径:工作区预设能写出项目级技能的 SKILL.md 绝对路径(主 agent 用 `read` 按需读,路径在盘上就能读);全局预设只解析用户级,配了项目级技能名会告警跳过——全局预设要在所有项目通用,引用单一项目的技能本就不该写进去。
695
705
  - 兜底 CLI 没有工作区概念,只扫用户级两根(与全局默认一致)。
706
+
707
+ ## 10. 场景模板(轻量 / 调研 / 全开)
708
+
709
+ 「角色与工具」组顶部有一排**场景模板**快捷按钮。三个档位对应三种典型用法,点击即改当前配置视角的本地草稿(**不自动保存**,预览随之作废,toast 会提醒「保存并应用后生效」):
710
+
711
+ | 档位 | `roles_remove` | `main_agent_extra` | 典型场景 |
712
+ | --- | --- | --- | --- |
713
+ | 轻量档 | `search_external`、`design` | 清空 | 纯写代码:只留实现角色,主 agent 不额外带工具 |
714
+ | 调研档 | `design`、`implement` | 清空 | 只查不做:留检索角色(含深搜),去掉设计与实现 |
715
+ | 全开档 | `[]`(全留) | 勾上检测到的全部库存工具 | 完整编排:四个内置角色全在,主 agent 全副武装 |
716
+
717
+ 模板刻意**只动这两个键**,其余一概不碰:
718
+
719
+ - 角色工具面、显示名、persona、角色专用模型、会话/启动方式、`main_agent_remove`、技能分配全部原样;
720
+ - 自定义角色不受影响;
721
+ - `search_internal_deep` 是静态委派工具,不在配置层 `roles_remove` 的管辖范围(见 §2.2),三档都保留——调研档「留深搜」正是靠它,无需任何配置动作;
722
+ - 档位是**确定态**而不是「在现状上增减」:重复点同一档结果幂等;轻量/调研切回全开时,此前给被删角色精调的 persona / 模型 / 工具面原样复原(被删角色的条目键仍保留,卡片只是隐藏)。
723
+
724
+ ## 11. 配置导出与导入
725
+
726
+ 页头「更多」菜单提供整份配置文件的导出与导入(配置文件即 §1 的 `dsh-paoding.config.yml`,路径可用 `DSH_PAODING_CONFIG` 指到别处)。
727
+
728
+ **导出**:下载配置文件**原文**(字节不动,手编注释与排版原样保留)。文件不存在时回落为基础模板文本——与首装自愈写入的内容同源,导出永远是「当前生效配置」的忠实快照;文件名取实际配置文件名(默认 `dsh-paoding.config.yml`)。
729
+
730
+ **导入**:选一份 YAML 配置文件,确认弹窗(红色确认钮)后**整体覆盖**当前配置文件——注意工作区视角也不例外:`workspaces` 段(§9)随整份文件一起被替换,确认文案里已明说。流程与边界:
731
+
732
+ - 解析校验与 `loadConfig` 同口径:解析失败或顶层不是键值映射(空文件、列表、标量)一律 422 拒绝,**不写盘**;面板弹出失败 toast,原配置原封不动。
733
+ - 通过校验后走与面板 / CLI 每次保存相同的单一写盘路径(规范化 + 头注释 + `0600` 权限)。
734
+ - **往返取舍(有意为之)**:导入会把内容规范化后落盘,注释、未知键与排版不保留;全部 schema 字段保留,含「缺省 vs 显式空表」语义(如 `main_agent_extra: []` 的显式空表标记,见 §3)。
735
+ - 落盘即改 mtime,面板的检测状态缓存按对账自动失效,视图随后按新配置重建。
736
+ - 与「保存并应用」共用同一把写盘互斥闸:应用进行中导入(或反过来)会收到 409,稍后重试即可。
737
+
738
+ ## 12. 检查提示(体检)
739
+
740
+ 预览或应用后,若服务端体检发现问题,预览区上方会出现一张**检查提示**卡:逐条列出示意,红(error)/ 琥珀(warn)/ 中性(info)三色分级,悬停可见检查项代号(排障用)。没有问题就没有这张卡,零占位。
741
+
742
+ 条目来自结构化体检(`tools/lib/health.mjs`),代号与分级:
743
+
744
+ | 代号 | 级别 | 含义 |
745
+ | --- | --- | --- |
746
+ | `zero_tool_role` | error | 某角色过滤后没有任何可用工具——零工具角色会被拒绝安装,应用必失败;按提示补工具或 `roles_remove` 删除该角色 |
747
+ | `unmounted_tool` | warn | 配置里勾了当前未挂载的工具(如 MCP 服务没起),生成时会过滤掉 |
748
+ | `unknown_model` | warn | `roles.<tool>.model` 不在当前模型目录中(名单以设置页模型选择器为准),配错会被静默回落 |
749
+ | `preset_drift` | info | 已安装预设落后于当前配置(或尚未安装),重新应用即同步 |
750
+ | `generation` | warn | 生成器自身的提示信息(如默认值回退) |
751
+
752
+ 作废口径:**预览**产生的提示随配置草稿的任何改动一并作废(与预览同一逻辑);**应用**产生的提示描述的是已落盘结果,保留到下一次预览或应用。应用失败时错误 toast 照旧,同时卡片给出每条失败原因;应用成功但存在 warn/error 级提示时,成功 toast 会换成琥珀档「有 N 条检查提示,见下方」。
@@ -131,7 +131,17 @@ Of the four built-in delegation roles, the installer (the `ROLES` constant) mana
131
131
 
132
132
  - `persona`: **replaces the deployment persona wholesale**. The text lands in the `dsh-tool-subagent` instance's `persona` field — the child's **deployment persona** (the identity and behavior guidance the subagent sees in its session), not an appendix. The static persona's built-in "failure-report protocol / behavior boundary" paragraphs are replaced too — if you want to keep them, include the original text (see the matching block in `presets/orchestrator/agent.cordis.yml`) in yours.
133
133
  - `tools`: **fully overrides the intent** of that role's `toolFilter.allow`. The relationship in one sentence: the allow lists in `agent.cordis.yml` are static intent that the installer rewrites against actual detection results; the `tools` you write under `roles.<name>` are the new intent — whatever survives detection is what stays.
134
- - Role allow lists use **exact-name matching**; globs are not supported, so tavily's five tools must each be spelled out (`mcp__tavily__tavily_search` … `mcp__tavily__tavily_research`).
134
+ - Tool entries accept `*` and `?` wildcards (`*` matches any run of characters including none, `?` exactly one). Expansion happens at **generation time**: the generator expands pattern entries against the candidate tool surface (the preset's built-in surface ∪ the detection inventory) into concrete names in the generated preset, so runtime whitelist semantics are identical to spelling every name out — matched names still go through the regular filtering (an undetected MCP tool is not resurrected by a pattern), and a pattern that matches nothing warns and is dropped. The five tavily tools therefore fit on one line:
135
+
136
+ ```yaml
137
+ roles:
138
+ search_external:
139
+ tools:
140
+ - web_search
141
+ - mcp__tavily__tavily_* # expanded at generation to search/crawl/extract/map/research (per actual detection)
142
+ ```
143
+
144
+ `main_agent_extra` and custom-role `tools` support them too. Pattern entries are stored and read back verbatim in the config file (the config panel renders them fine); only the generated preset contains the expanded concrete names.
135
145
 
136
146
  Example — replacing `implement`'s persona and tools. The `tools` below match the static default — note that it does **not** contain `str_replace_editor` (why: see 8.2). Writing `tools` out explicitly freezes the intent: later changes to the static source no longer affect this role:
137
147
 
@@ -649,7 +659,7 @@ Omitting `agentOptions` means inheriting the main agent's model. The config sub-
649
659
  ### 8.2 Platform / registry details
650
660
 
651
661
  - **`str_replace_editor` is deliberately not in `implement`'s allow**: some deployments' registries do not provide it, and a child created with it in the allow would be rejected by `tools.restrict()` as an unknown tool. Add it back only after confirming your deployment registers it (edit the static implement allow, or `roles.implement.tools` in the config).
652
- - Allow lists use **exact-name matching**; globs are not supported; tavily's five tools must each be spelled out.
662
+ - Allow-list tool entries accept `*` / `?` wildcards, expanded to concrete names at generation time (see 2.1); spelling out exact names works as before.
653
663
  - Unmounted names in the main-agent allow never error (the waterfall matches names only against already-resolved tools).
654
664
 
655
665
  ### 8.3 Syncing after disabling MCPs / plugins
@@ -705,3 +715,50 @@ Skill candidates do not follow the directory the GUI was launched from: the **gl
705
715
  - A skill installed only inside one project (say `bento-slides` living in a knowledge-base repo) is invisible — and unassignable — in the global config; select that workspace and it appears in the skill grid with a "project" badge, assignable to that workspace preset's main agent or roles.
706
716
  - Generation resolves skills through the same lens: a workspace preset can emit the absolute SKILL.md path of a project-local skill (the main agent reads it on demand with `read` — any on-disk path is readable); the global preset resolves user roots only and warns-and-skips project-local skill names — a global preset travels across all projects and should not reference a single project's skill anyway.
707
717
  - The fallback CLI has no workspace concept and scans the two user roots only (same as the global default).
718
+
719
+ ## 10. Scenario templates (Lite / Research / Full)
720
+
721
+ The top of the "Roles & tools" group carries a row of **scenario template** quick buttons. The three presets map to three typical workflows; clicking one edits the local draft of the current config view (**nothing is saved automatically** — the preview is invalidated as usual and a toast reminds you that it takes effect after "save & apply"):
722
+
723
+ | Preset | `roles_remove` | `main_agent_extra` | Typical use |
724
+ | --- | --- | --- | --- |
725
+ | Lite | `search_external`, `design` | cleared | Pure coding: keep only the implement role, no extra main-agent tools |
726
+ | Research | `design`, `implement` | cleared | Look things up, build nothing: keep the search role (deep search included), drop design and implement |
727
+ | Full | `[]` (keep all) | check every inventoried tool | Full orchestration: all four built-in roles, fully armed main agent |
728
+
729
+ A template deliberately touches **only these two keys** and nothing else:
730
+
731
+ - Role tool surfaces, display names, personas, per-role models, session/start modes, `main_agent_remove`, and skill assignments are all left as they are;
732
+ - Custom roles are unaffected;
733
+ - `search_internal_deep` is a static delegation tool, outside the reach of `roles_remove` (see §2.2); all three presets keep it — the Research preset's "deep search stays" leans on exactly that, no config action needed;
734
+ - A preset is a **determined state**, not a delta on top of the current draft: clicking the same preset twice is idempotent, and switching Lite/Research back to Full restores the personas / models / tool surfaces you tuned for the previously removed roles (removed roles keep their entry keys; the cards are merely hidden).
735
+
736
+ ## 11. Exporting and importing the config
737
+
738
+ The header "More" menu offers export and import of the whole config file (the §1 `dsh-paoding.config.yml`; point `DSH_PAODING_CONFIG` elsewhere to relocate it).
739
+
740
+ **Export** downloads the config file **verbatim** (byte-for-byte — hand-written comments and formatting travel with it). When the file does not exist, the export falls back to the base-template text — the same content a fresh install would write, so an export is always a faithful snapshot of "what is in effect". The download name follows the actual config file name (default `dsh-paoding.config.yml`).
741
+
742
+ **Import** picks a YAML config file and, after a confirmation dialog with a red confirm button, **overwrites the entire config file** — the workspace view is no exception: the `workspaces` section (§9) is replaced together with the rest of the file, and the confirmation copy says so explicitly. Flow and boundaries:
743
+
744
+ - Validation follows the same rules as `loadConfig`: unparsable content or a non-mapping top level (empty file, list, scalar) is rejected with 422 and **nothing is written**; the panel shows a failure toast and the previous config stays untouched.
745
+ - Content that passes validation is written through the same single save path used by every panel / CLI save (normalized + header comment + `0600` mode).
746
+ - **Round-trip tradeoff (by design)**: import normalizes before writing — comments, unknown keys, and formatting are not preserved; every schema field survives, including the "absent vs explicitly empty" semantics (e.g. the explicit-empty marker for `main_agent_extra: []`, see §3).
747
+ - Writing changes the file's mtime, so the panel's detection-state cache invalidates automatically through the existing reconciliation, and the view rebuilds from the new config.
748
+ - Import shares the same in-flight write gate as "save & apply": applying during an import (or the reverse) answers 409 — retry shortly.
749
+
750
+ ## 12. Check messages (health review)
751
+
752
+ After a preview or an apply, if the server-side health review finds problems, a **check messages** card appears above the preview area: one row per finding, color-coded red (error) / amber (warn) / neutral (info), with the check code available on hover for troubleshooting. No findings — no card, zero footprint.
753
+
754
+ Entries come from the structured health review (`tools/lib/health.mjs`); codes and levels:
755
+
756
+ | Code | Level | Meaning |
757
+ | --- | --- | --- |
758
+ | `zero_tool_role` | error | A role ends up with no usable tools after filtering — zero-tool roles are refused at install time, so apply will fail; add tools or remove the role via `roles_remove` |
759
+ | `unmounted_tool` | warn | A configured tool is currently not mounted (e.g. its MCP server is down); it is filtered out at generation |
760
+ | `unknown_model` | warn | `roles.<tool>.model` is not in the current model directory (the list mirrors the settings-page model picker); a mismatch silently falls back |
761
+ | `preset_drift` | info | The installed preset lags behind the current config (or is not installed yet); re-apply to sync |
762
+ | `generation` | warn | Generator's own notices (e.g. default-value fallbacks) |
763
+
764
+ Invalidation scope: messages from a **preview** are discarded together with the draft on any config edit (same rule as the preview itself); messages from an **apply** describe what has already been written and survive until the next preview or apply. When an apply fails, the error toast stays as usual and the card lists every reason; when an apply succeeds but warn/error-level findings exist, the success toast switches to the amber variant "N check messages, see below".
@@ -135,7 +135,7 @@ dsh plugin --profile web add link:"$PWD"
135
135
 
136
136
  包直接落在仓库目录上,改动经 HMR 或重启生效,无需反复重装。此形态无法就地升级(见第 3 节);想切回 registry 版,`dsh plugin --profile web add dsh-paoding@latest` 即可。
137
137
 
138
- **仓库内兜底 CLI**(不宣传,仅限克隆了仓库的开发者):`node tools/install.mjs --auto`(有配置应用配置,没有写基础模板)、`--dry-run`(只打印检测报告与将生成的 allow,不写盘)、`--config <file>`(指定配置文件)、`--suggest`(fresh 安装无配置时按智能默认分配 host 工具;默认基础模板不写 host 工具)、`--profile <name>`(指定扫描哪个 profile 的 patch 层,默认 `web`)。不带 `--auto`(且非只读的 `--dry-run`)会被拒绝并提示——交互式安装已移除,可视化配置一律走插件通道自带的 Web 面板。
138
+ **仓库内兜底 CLI**(不宣传,仅限克隆了仓库的开发者):`node tools/install.mjs --auto`(有配置应用配置,没有写基础模板)、`--dry-run`(只打印检测报告、将生成的 allow 与配置体检,不写盘)、`--config <file>`(指定配置文件)、`--suggest`(fresh 安装无配置时按智能默认分配 host 工具;默认基础模板不写 host 工具)、`--profile <name>`(指定扫描哪个 profile 的 patch 层,默认 `web`)、`doctor`(只读安装体检,用法见第 8 节开头)。不带 `--auto`(且非只读的 `--dry-run` / `doctor`)会被拒绝并提示——交互式安装已移除,可视化配置一律走插件通道自带的 Web 面板。
139
139
 
140
140
  ## 5. host patch 检测机制
141
141
 
@@ -155,7 +155,7 @@ dsh plugin --profile web add link:"$PWD"
155
155
 
156
156
  - **启用判定**:patch 条目未被禁用(`enabled !== false` 且无 `disabled`)即视为启用;插件另查 `config.enabled`。
157
157
  - **MCP 服务器识别**:条目名的包名段**精确等于** `dsh-mcp-client`(scoped/子路径形式按 `/` 取末段比对,`not-dsh-mcp-client-foo` 这类不会误判),或配置含 `serverName` 且 transport 为 `stdio` / `streamable-http` / `sse`(或有 `command`)。
158
- - **精确工具名解析**(`toolFilter.allow` 是精确名匹配、不支持 glob,名字必须拼全):检测顺序是**所有 `stdio` / `streamable-http` 服务器一律先实时 JSON-RPC 握手**(stdio 拉起进程发 `initialize` + `tools/list`;streamable-http 走 POST;**15 秒超时**),把返回的工具名解析成 `mcp__<server>__<tool>`;静态已知表(`tavily` → 5 个工具 `tavily_search` / `tavily_crawl` / `tavily_extract` / `tavily_map` / `tavily_research`,`codegraph` → `codegraph_explore`)只是已知服务器**握手失败时的降级兜底**——工具按表保留,报告标注 static table,不丢工具;未知服务器握手失败才整服务器跳过。
158
+ - **精确工具名解析**(检测与产物按精确名对账;配置条目可写 `*`/`?` 通配,生成期展开成具体名,见配置文档):检测顺序是**所有 `stdio` / `streamable-http` 服务器一律先实时 JSON-RPC 握手**(stdio 拉起进程发 `initialize` + `tools/list`;streamable-http 走 POST;**15 秒超时**),把返回的工具名解析成 `mcp__<server>__<tool>`;静态已知表(`tavily` → 5 个工具 `tavily_search` / `tavily_crawl` / `tavily_extract` / `tavily_map` / `tavily_research`,`codegraph` → `codegraph_explore`)只是已知服务器**握手失败时的降级兜底**——工具按表保留,报告标注 static table,不丢工具;未知服务器握手失败才整服务器跳过。
159
159
  - **本地工具插件**:按白名单匹配 patch 条目名并登记其工具,如 `magic-memory` → `memory_search`(新增插件需扩展白名单,见 `tools/lib/host.mjs` 的 `KNOWN_HOST_PLUGINS`)。
160
160
  - **跳过不报错**:「握手失败、超时、`sse` 等不支持实时发现的服务器被跳过」只适用于**未知服务器**——整服务器跳过,对应角色只是缺这些工具,生成不报错(报告里会标注 `handshake failed` 及原因,URL/密钥会被打码);已知服务器握手失败回落静态表、工具保留,不在此列。
161
161
 
@@ -218,6 +218,18 @@ rm -rf "${DSH_HOME:-$HOME/.dsh}/.agent-presets"/orchestrator-*
218
218
 
219
219
  ## 8. 常见问题与排查
220
220
 
221
+ **先跑一遍体检**
222
+
223
+ 多数「装不上 / 不出现 / 行为怪」都能用只读体检直接定位:
224
+
225
+ ```bash
226
+ npx dsh-paoding@latest doctor # 或仓库内:node tools/install.mjs doctor
227
+ ```
228
+
229
+ doctor 不改任何文件,逐项检查并给出 `✔ 正常 / ⚠ 警告 / ✘ 错误 / – 跳过` 四档结论:配置文件能否解析、预设安装轨道与宿主版本是否匹配、托管声明行有无孤儿行或损坏块、`.agent-presets/orchestrator-*` 有没有配置里已不引用的孤儿目录、已装预设与当前配置的再生成结果有无漂移、`.generator-version` 版本标记是否对得上。结尾有汇总;出现 ✘ 时退出码为 1,⚠ 与 – 不影响使用(跳过多见于全新机器,属正常)。`--config` / `--profile` 与安装时同义。
230
+
231
+ 面板侧也有同源的保存时体检:每次「预览生成 / 保存并应用」的响应都带 `warnings` 字段(配置了未挂载工具、零工具角色、角色专用模型不在模型目录、预设落后于配置等),按条目提示处理即可,不用等运行时报错。
232
+
221
233
  **pnpm 缺失**
222
234
 
223
235
  `dsh plugin add` 报 pnpm 相关错误:`dsh plugin` 依赖 pnpm 装包。`npm i -g pnpm`(或 `corepack enable`)装好后重试。
@@ -135,7 +135,7 @@ dsh plugin --profile web add link:"$PWD"
135
135
 
136
136
  The package sits directly on the repository directory; changes go live via HMR or a restart, with no reinstalls. This form cannot be upgraded in place (see §3); to switch back to the registry version, run `dsh plugin --profile web add dsh-paoding@latest`.
137
137
 
138
- An **in-repo fallback CLI** also exists (not advertised; for developers who cloned the repo): `node tools/install.mjs --auto` (apply the config if present, else write the base template), `--dry-run` (print the detection report and the would-be allow lists without writing anything), `--config <file>` (select a config file), `--suggest` (on a fresh install without a config file, seed smart defaults that assign host tools; the default base template leaves host tools out), `--profile <name>` (which profile's patch layer to scan, default `web`). Without `--auto` (and without the read-only `--dry-run`) it refuses to run — interactive install has been removed; visual configuration belongs to the Web panel that ships with the plugin channel.
138
+ An **in-repo fallback CLI** also exists (not advertised; for developers who cloned the repo): `node tools/install.mjs --auto` (apply the config if present, else write the base template), `--dry-run` (print the detection report, the would-be allow lists and the config health check without writing anything), `--config <file>` (select a config file), `--suggest` (on a fresh install without a config file, seed smart defaults that assign host tools; the default base template leaves host tools out), `--profile <name>` (which profile's patch layer to scan, default `web`), `doctor` (read-only install health check — see the start of §8). Without `--auto` (and without the read-only `--dry-run` / `doctor`) it refuses to run — interactive install has been removed; visual configuration belongs to the Web panel that ships with the plugin channel.
139
139
 
140
140
  ## 5. Host patch detection
141
141
 
@@ -155,7 +155,7 @@ All layers are **included** (detection unions the enabled entries across layers)
155
155
 
156
156
  - **Enabled test**: a patch entry counts as enabled unless disabled (`enabled !== false` and no `disabled`); plugins additionally check `config.enabled`.
157
157
  - **MCP server recognition**: the entry's package-name segment **exactly equals** `dsh-mcp-client` (scoped/sub-path forms compare the last `/` segment; a name like `not-dsh-mcp-client-foo` does not match), or the config carries a `serverName` with transport `stdio` / `streamable-http` / `sse` (or has a `command`).
158
- - **Exact tool-name resolution** (`toolFilter.allow` matches exact names, no globs — names must be spelled out): the detection order is that **every `stdio` / `streamable-http` server gets a live JSON-RPC handshake first** (stdio spawns the process and sends `initialize` + `tools/list`; streamable-http POSTs; **15-second timeout**), and the returned tools become `mcp__<server>__<tool>` names; the static known table (`tavily` → 5 tools `tavily_search` / `tavily_crawl` / `tavily_extract` / `tavily_map` / `tavily_research`, `codegraph` → `codegraph_explore`) is only the degraded fallback when a **known** server's handshake fails — tools are kept per the table, the report flags a static table, and no tools are lost; only an unknown server that fails the handshake is skipped entirely.
158
+ - **Exact tool-name resolution** (detection and artifacts reconcile exact names; config entries may use `*`/`?` wildcards, expanded to concrete names at generation time — see the configuration guide): the detection order is that **every `stdio` / `streamable-http` server gets a live JSON-RPC handshake first** (stdio spawns the process and sends `initialize` + `tools/list`; streamable-http POSTs; **15-second timeout**), and the returned tools become `mcp__<server>__<tool>` names; the static known table (`tavily` → 5 tools `tavily_search` / `tavily_crawl` / `tavily_extract` / `tavily_map` / `tavily_research`, `codegraph` → `codegraph_explore`) is only the degraded fallback when a **known** server's handshake fails — tools are kept per the table, the report flags a static table, and no tools are lost; only an unknown server that fails the handshake is skipped entirely.
159
159
  - **Local tool plugins**: matched against a whitelist of patch entry names, registering their tools, e.g. `magic-memory` → `memory_search` (new plugins extend the whitelist — `KNOWN_HOST_PLUGINS` in `tools/lib/host.mjs`).
160
160
  - **Skipped, not fatal**: "servers whose handshake fails, times out, or uses a transport without live discovery (`sse`) are skipped" applies to **unknown servers** only — such a server is skipped entirely, the role simply lacks those tools, and generation raises no error (the report flags `handshake failed` with the reason; URLs and keys are masked); known servers fall back to the static table on a failed handshake and keep their tools, so they never land here.
161
161
 
@@ -218,6 +218,18 @@ Uninstalling touches nothing else on the host (MCP, plugins, sessions), and stay
218
218
 
219
219
  ## 8. Troubleshooting
220
220
 
221
+ **Run the doctor first**
222
+
223
+ Most "won't install / won't show up / behaves oddly" reports can be pinned down by the read-only health check:
224
+
225
+ ```bash
226
+ npx dsh-paoding@latest doctor # or from a checkout: node tools/install.mjs doctor
227
+ ```
228
+
229
+ The doctor writes nothing. It walks through each check and marks it `✔ ok / ⚠ warning / ✘ error / – skipped`: is the config file parseable, does the preset track match the host version, are there orphan or corrupted managed declaration rows, are there `orchestrator-*` preset directories no longer referenced by the config, does the installed preset drift from what the current config would regenerate, and does the `.generator-version` marker line up. A summary closes the report; any ✘ makes the exit code 1, while ⚠ and – are safe to live with (skips are normal on a fresh machine). `--config` / `--profile` mean the same as during installation.
230
+
231
+ The panel runs the same checks on every save: each Preview / Save & Apply response carries a `warnings` field (configured-but-unmounted tools, zero-tool roles, role models missing from the model directory, presets behind the config, and so on) — follow the per-item hints instead of waiting for a runtime error.
232
+
221
233
  **pnpm missing**
222
234
 
223
235
  `dsh plugin add` fails with a pnpm error: `dsh plugin` relies on pnpm to install packages. Install it (`npm i -g pnpm` or `corepack enable`) and retry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-paoding",
3
- "version": "0.3.10",
3
+ "version": "0.4.0",
4
4
  "description": "庖丁 — DSH (DeepSeek Harness) 编排模式安装器:host 感知的角色工具过滤 + Web 设置页可视化配置器",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,7 +11,10 @@ import path from 'node:path'
11
11
  import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
12
12
  import { readFile } from 'node:fs/promises'
13
13
  import { assignSlugs, collectState, generateAndInstall, presetIdOf } from '../../tools/install.mjs'
14
- import { autoAssignments } from '../../tools/lib/alloc.mjs'
14
+ import { autoAssignments, baseAssignments } from '../../tools/lib/alloc.mjs'
15
+ import { normalizeConfig, normalizeTargetFields, saveConfig, serializeConfig } from '../../tools/lib/config.mjs'
16
+ import { parseYamlSubset, preprocessPatchText } from '../../tools/lib/yaml.mjs'
17
+ import { captureWarnings, healthCheck } from '../../tools/lib/health.mjs'
15
18
  import {
16
19
  backfillHomePatchRows,
17
20
  detectHostVersionTriple,
@@ -159,12 +162,112 @@ export function serializeState(s) {
159
162
  }
160
163
 
161
164
  /** Compose + (optionally) install from a UI-supplied assignments object.
162
- * workspacePath 非 null 时为该工作区生成独立 preset(见 generateAndInstall)。 */
163
- export function installAssignments(state, assignments, { dryRun = false, saveConfigOnWrite = false, workspacePath = null } = {}) {
165
+ * workspacePath 非 null 时为该工作区生成独立 preset(见 generateAndInstall)。
166
+ *
167
+ * 0.3.10 起响应在既有字段之外纯增量附带 `warnings`(tools/lib/health.mjs 的
168
+ * 结构化体检结果,见 healthCheck):生成成功时把本次生成的产物直接喂体检
169
+ * (零重跑),生成失败(如零工具角色 throw)时体检照跑、items 挂在
170
+ * error.warnings 上随原错误上抛——路由层 422 响应可将其一并透传给面板。
171
+ * models(可选):面板 models 路由的目录(groups 数组),体检的 unknown_model
172
+ * 项据此核对角色专用模型名;调用方拿不到目录时传 null(缺省),该项自动跳过。 */
173
+ export function installAssignments(state, assignments, { dryRun = false, saveConfigOnWrite = false, workspacePath = null, models = null } = {}) {
164
174
  if (!assignments || typeof assignments !== 'object') {
165
175
  throw new Error('missing assignments object')
166
176
  }
167
- return generateAndInstall(state, assignments, { dryRun, saveConfigOnWrite, workspacePath })
177
+ let result = null
178
+ let generationWarnings = []
179
+ let generateError = null
180
+ try {
181
+ ;[result, generationWarnings] = captureWarnings(() =>
182
+ generateAndInstall(state, assignments, { dryRun, saveConfigOnWrite, workspacePath }),
183
+ )
184
+ } catch (err) {
185
+ generateError = err
186
+ }
187
+ const { items } = healthCheck({ state, assignments, models, dryRunResult: result, generationWarnings })
188
+ if (generateError !== null) {
189
+ generateError.warnings = items
190
+ throw generateError
191
+ }
192
+ return { ...result, warnings: items }
193
+ }
194
+
195
+ // ── 配置文件导出 / 导入(面板页头「更多」菜单的服务端核心)────────────────────
196
+
197
+ /**
198
+ * 基础模板配置文本:配置文件不存在时导出的回落内容。与 fresh 首装自愈
199
+ * (ensurePresetInstalled → generateAndInstall 的 saveConfig 分支)完全同源:
200
+ * baseAssignments + normalizeTargetFields + serializeConfig 单一序列化路径,
201
+ * 保证「导出的模板 → 再导入」往返出来的也是合法、可再解析的配置。
202
+ */
203
+ function baseTemplateConfigText(state) {
204
+ return serializeConfig({
205
+ profile: state.effectiveProfile,
206
+ ...normalizeTargetFields(baseAssignments(state.staticBase)),
207
+ workspaces: {},
208
+ })
209
+ }
210
+
211
+ /**
212
+ * 导出配置:返回配置文件原文(字节不动,用户手编的注释 / 排版原样带走);
213
+ * 文件不存在(ENOENT)时回落基础模板文本——与首装自愈写的内容一致,导出
214
+ * 即是「当前生效配置」的忠实快照。其余读失败(权限 / EIO 等)原样上抛:
215
+ * 静默回落模板会把「读不了」伪装成「没有」,误导用户拿模板覆盖回去。
216
+ * 只读不写,不触碰 state 缓存。
217
+ */
218
+ export async function exportConfigPayload() {
219
+ const file = resolveConfigFile()
220
+ let content = null
221
+ try {
222
+ content = await readFile(file, 'utf8')
223
+ } catch (err) {
224
+ if (err.code !== 'ENOENT') throw err
225
+ content = baseTemplateConfigText(await getState())
226
+ }
227
+ return { ok: true, filename: path.basename(file), content }
228
+ }
229
+
230
+ /**
231
+ * 导入配置:content(配置文件全文)过与 loadConfig 完全同口径的解析校验
232
+ *(preprocessPatchText → 宿主 yaml 模块优先、内置 parseYamlSubset 兜底 →
233
+ * 顶层映射检查 → normalizeConfig),解析失败 / 顶层非映射以 statusCode=422
234
+ * 上抛(路由层回 { error },不写盘);通过后经 saveConfig 单一写盘路径落盘
235
+ *(0o600 + 头注释 + 规范化,与 CLI / 面板任何一次保存同一口径)。写盘即改
236
+ * mtime,state 缓存靠既有对账自动失效,无需手动清理。
237
+ *
238
+ * 往返取舍(有意为之):load→normalize→serialize 会丢注释、未知键与排版,
239
+ * schema 字段(含 has_main_agent_extra 显式空表等「缺省 vs 显式」语义)全保留
240
+ * ——与面板 / CLI 每次保存的行为一致,换来落盘配置恒合法、可再解析。
241
+ */
242
+ export async function importConfigContent(content) {
243
+ const processed = preprocessPatchText(String(content ?? ''))
244
+ // 宿主 yaml 模块从 state 顺路取(collectState 已探测好);state 通道异常时
245
+ // 退化为仅内置解析器校验——内置解析器覆盖不了的长文法此时可能误报 422,
246
+ // 但绝不放过坏配置写盘,宁严勿漏。
247
+ let yamlMod = null
248
+ try {
249
+ yamlMod = (await getState())?.yamlMod ?? null
250
+ } catch { /* 校验口径降级,见上 */ }
251
+ let data = null
252
+ if (yamlMod) {
253
+ try { data = yamlMod.parse(processed) } catch { data = null }
254
+ }
255
+ if (data === null) {
256
+ try {
257
+ data = parseYamlSubset(processed)
258
+ } catch (err) {
259
+ const error = new Error(`配置解析失败:${err.message}`)
260
+ error.statusCode = 422
261
+ throw error
262
+ }
263
+ }
264
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
265
+ const error = new Error('配置解析失败:顶层必须是键值映射(mapping),不能是空文件、列表或标量')
266
+ error.statusCode = 422
267
+ throw error
268
+ }
269
+ saveConfig(resolveConfigFile(), normalizeConfig(data))
270
+ return { ok: true }
168
271
  }
169
272
 
170
273
  // ── 首装 / 升级后 preset 自动重生成 ─────────────────────────────────────────