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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paoding-config-ui",
3
- "version": "0.3.10",
3
+ "version": "0.4.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "main": "index.mjs",
package/tools/lib/cli.mjs CHANGED
@@ -10,7 +10,9 @@
10
10
  * 依赖 util(ROLES / SRC_DIR / PROFILE_NAME_RE)、config(normalizeRoleName)、
11
11
  * host(removedReason)、state(PRESET_ASIS_FILES / collectState /
12
12
  * generateAndInstall)、alloc(autoAssignments,--auto 语义与插件首装共享)、
13
- * version(getVersionInfo,main 收尾的新版提示)以及 node:os / node:path。
13
+ * health(captureWarnings / healthCheck,--dry-run 报告的结构化体检)、
14
+ * doctor(runDoctor,doctor 子命令的只读体检)、version(getVersionInfo,
15
+ * main 收尾的新版提示)以及 node:os / node:path。
14
16
  */
15
17
  import { homedir } from 'node:os'
16
18
  import path from 'node:path'
@@ -19,6 +21,8 @@ import { normalizeRoleName } from './config.mjs'
19
21
  import { removedReason } from './host.mjs'
20
22
  import { PRESET_ASIS_FILES, collectState, generateAndInstall } from './state.mjs'
21
23
  import { autoAssignments } from './alloc.mjs'
24
+ import { captureWarnings, healthCheck } from './health.mjs'
25
+ import { runDoctor } from './doctor.mjs'
22
26
  import { getVersionInfo } from './version.mjs'
23
27
 
24
28
  // ── CLI ─────────────────────────────────────────────────────────────────────
@@ -35,6 +39,7 @@ export function printHelp() {
35
39
 
36
40
  Usage:
37
41
  node tools/install.mjs [options]
42
+ node tools/install.mjs doctor [options]
38
43
 
39
44
  Options:
40
45
  --profile <name> Profile whose patch layer is scanned (default: web).
@@ -47,13 +52,19 @@ Options:
47
52
  --auto Non-interactive: apply the config file when present,
48
53
  otherwise apply the base template (--suggest seeds smart
49
54
  defaults on a fresh install instead).
50
- --dry-run Print the detection report and the generated allow lists
51
- without writing anything; exit 0.
55
+ --dry-run Print the detection report, the generated allow lists and
56
+ the config health check without writing anything; exit 0.
52
57
  --suggest Fresh install without a config file: seed smart defaults
53
58
  from the detected host/MCP tools instead of the base
54
59
  template (host tools are folded in automatically).
55
60
  --help Show this help and exit.
56
61
 
62
+ Subcommands:
63
+ doctor Read-only health check of the local install (config file,
64
+ preset track vs host version, managed patch rows, orphan
65
+ workspace presets, config drift, version marker) and a
66
+ summary; exit 1 only on error-level findings.
67
+
57
68
  Environment:
58
69
  DSH_HOME Directory holding cordis.patch.yml etc. (default: $HOME/.dsh)
59
70
 
@@ -73,10 +84,15 @@ function assertProfileName(value) {
73
84
  }
74
85
 
75
86
  export function parseArgs(argv) {
76
- const opts = { profile: null, patches: [], dryRun: false, help: false, auto: false, config: undefined, suggest: false }
87
+ const opts = { command: 'install', profile: null, patches: [], dryRun: false, help: false, auto: false, config: undefined, suggest: false }
77
88
  for (let i = 0; i < argv.length; i++) {
78
89
  const arg = argv[i]
79
- if (arg === '--help' || arg === '-h') {
90
+ if (arg === 'doctor') {
91
+ // 子命令:doctor = 只读体检;缺省 install(原 installer 主流程)。位置参数
92
+ // 只认这一个,其余仍按未知选项报错;--help / --profile / --config 等照常
93
+ // 解析,doctor 分支共用。
94
+ opts.command = 'doctor'
95
+ } else if (arg === '--help' || arg === '-h') {
80
96
  opts.help = true
81
97
  } else if (arg === '--dry-run') {
82
98
  opts.dryRun = true
@@ -130,7 +146,7 @@ export function parseArgs(argv) {
130
146
 
131
147
  // ── report & install ────────────────────────────────────────────────────────
132
148
 
133
- export function printReport({ scanned, mcpReports, pluginReports, roleResults, dstDir, dryRun, staleNote, mainAgent }) {
149
+ export function printReport({ scanned, mcpReports, pluginReports, roleResults, dstDir, dryRun, staleNote, mainAgent, health = null }) {
134
150
  const pad = (s, n) => String(s).padEnd(n)
135
151
  console.log('dsh-paoding installer — orchestrator preset (host-aware tool filtering)')
136
152
  console.log(` preset source : ${SRC_DIR}`)
@@ -188,6 +204,21 @@ export function printReport({ scanned, mcpReports, pluginReports, roleResults, d
188
204
  } else {
189
205
  console.log(' removed: none — every host-dependent allow entry is enabled')
190
206
  }
207
+ if (health !== null) {
208
+ // 配置体检小节(--dry-run 报告专属;health 为 null 时不打印,--auto 输出
209
+ // 保持原样)。条目结构与面板 preview/apply 响应的 warnings 字段同源
210
+ //(tools/lib/health.mjs healthCheck)。
211
+ console.log()
212
+ console.log('config health (体检):')
213
+ if (health.length === 0) {
214
+ console.log(' ✔ 未发现问题')
215
+ } else {
216
+ for (const h of health) {
217
+ const mark = h.level === 'error' ? '✘' : h.level === 'warn' ? '⚠' : 'ℹ'
218
+ console.log(` ${mark} ${h.code} — ${h.message}`)
219
+ }
220
+ }
221
+ }
191
222
  if (staleNote) {
192
223
  console.log('\nnote: 检测到 patch 配置在本次安装后有过修改,改动后请重跑 node tools/install.mjs --auto')
193
224
  }
@@ -211,6 +242,30 @@ export function printReport({ scanned, mcpReports, pluginReports, roleResults, d
211
242
  console.log(`uninstall: rm -rf "${dstDir}"`)
212
243
  }
213
244
 
245
+ /** doctor 子命令的人读报告:每行「✔/✘/⚠/– 项目 — 一句说明」,结尾汇总。
246
+ * 渲染与检查本体分离(tools/lib/doctor.mjs 只产结构化 items,便于测试)。 */
247
+ export function printDoctorReport(report, { dshHome, configFile }) {
248
+ const marks = { ok: '✔', error: '✘', warn: '⚠', skip: '–' }
249
+ console.log('dsh-paoding doctor — 安装体检(只读,不改任何文件)')
250
+ console.log(` DSH_HOME : ${dshHome}`)
251
+ console.log(` 配置文件 : ${configFile}`)
252
+ console.log()
253
+ for (const { status, label, detail } of report.items) {
254
+ console.log(`${marks[status] ?? '·'} ${label} — ${detail}`)
255
+ }
256
+ const count = (status) => report.items.filter((i) => i.status === status).length
257
+ const errors = count('error')
258
+ const warns = count('warn')
259
+ console.log()
260
+ if (errors > 0) {
261
+ console.log(`汇总: ${report.items.length} 项检查,${errors} 项错误(✘)${warns > 0 ? `、${warns} 项警告(⚠)` : ''}——请先处理 ✘ 再使用;详细说明见 docs/installation.md 排查节`)
262
+ } else if (warns > 0) {
263
+ console.log(`汇总: ${report.items.length} 项检查,无错误;${warns} 项警告(⚠)不影响使用,可按各行说明处理`)
264
+ } else {
265
+ console.log(`汇总: ${report.items.length} 项检查全部正常`)
266
+ }
267
+ }
268
+
214
269
  // ── main ────────────────────────────────────────────────────────────────────
215
270
 
216
271
  /**
@@ -232,6 +287,16 @@ export async function main({ argv = process.argv.slice(2) } = {}) {
232
287
  return 0
233
288
  }
234
289
 
290
+ // doctor 子命令:只读体检,不走 --auto 门禁(门禁仅拦会动盘的安装主流程),
291
+ // 也不用 collectState 的全量检测——runDoctor 内部按项自行探测与降级。
292
+ if (opts.command === 'doctor') {
293
+ const dshHome = process.env.DSH_HOME || path.join(homedir(), '.dsh')
294
+ const configFile = opts.config || path.join(dshHome, 'dsh-paoding.config.yml')
295
+ const report = await runDoctor({ dshHome, configFile, profile: opts.profile })
296
+ printDoctorReport(report, { dshHome, configFile })
297
+ return report.hasErrors ? 1 : 0
298
+ }
299
+
235
300
  // 无 --auto 直接拒绝(交互模式已删):--dry-run 只读不动盘,放行作检测预览。
236
301
  // 门禁放在 collectState 之前:被拒路径不做任何检测 / 握手,快速失败。
237
302
  if (!opts.auto && !opts.dryRun) {
@@ -281,12 +346,24 @@ export async function main({ argv = process.argv.slice(2) } = {}) {
281
346
  // saveConfigOnWrite:fresh 非交互安装把基础模板落进配置文件(显式
282
347
  // main_agent_extra: [] 锚定「未开启 host 工具」);existing 路径绝不覆写
283
348
  // 用户配置文件。
349
+ // --dry-run 顺路做一次结构化体检(captureWarnings 抓生成期告警 + healthCheck,
350
+ // 产物 dryRunResult 直喂体检零重跑;models 缺省 null,CLI 不做模型目录发现):
351
+ // health 条目由 printReport 的新小节打印。--auto 路径不体检,输出保持原样。
284
352
  let composed
353
+ let healthItems = null
285
354
  try {
286
- composed = generateAndInstall(state, assignments, {
287
- dryRun: opts.dryRun,
288
- saveConfigOnWrite: !existing && !opts.dryRun,
289
- })
355
+ if (opts.dryRun) {
356
+ const [result, generationWarnings] = captureWarnings(() =>
357
+ generateAndInstall(state, assignments, { dryRun: true, saveConfigOnWrite: false }),
358
+ )
359
+ composed = result
360
+ healthItems = healthCheck({ state, assignments, dryRunResult: composed, generationWarnings }).items
361
+ } else {
362
+ composed = generateAndInstall(state, assignments, {
363
+ dryRun: false,
364
+ saveConfigOnWrite: !existing,
365
+ })
366
+ }
290
367
  } catch (err) {
291
368
  console.error(`error: ${err.message}`)
292
369
  return 1
@@ -307,6 +384,7 @@ export async function main({ argv = process.argv.slice(2) } = {}) {
307
384
  extra: assignments.main_agent_extra ?? [],
308
385
  removed: assignments.main_agent_remove ?? [],
309
386
  },
387
+ health: healthItems,
310
388
  })
311
389
  if (!existing && !opts.dryRun) {
312
390
  // fresh 非交互安装:提示基础模板已落盘、host/MCP 工具去哪补(--suggest 可
@@ -23,6 +23,11 @@
23
23
  * allow 过滤走白名单语义(usableWith / presetUniverse ∪ inventory,见
24
24
  * filterUsableTools 上方说明):手写进配置、既不在 preset 自带工具面也不在
25
25
  * 检测库存的名字一律从 allow 丢弃,不再「非 host 依赖名直通」。
26
+ * 工具条目支持 `*` / `?` 通配(expandToolPatterns,见 glob.mjs):主 agent 侧
27
+ * (computeRestrictAllow 的 base / mainAgentExtra / customToolNames 三路)与角色
28
+ * 侧(内置角色 intent、自定义角色 tools)都在白名单过滤**之前**先对候选集展开
29
+ * 为具体名 —— 语义叠加不替代,展开后的名字仍走既有 usableWith / 直通过滤;
30
+ * 产物只落具体名,运行时 toolFilter 语义不变。
26
31
  * workflow worker 行按宿主版本生成期适配(adaptWorkflowWorkerRow:≥0.1.6 把
27
32
  * 0.1.5-only 的 workflow-worker-thread 条目换成 workflow-ptc;探测不到版本
28
33
  * 保守保留源行,产物零 diff 性质随之保留)。
@@ -33,13 +38,14 @@
33
38
  * agent persona 委派行(composeMainPersonaEdit 第 4 参 customBullets →
34
39
  * `- <职责>: delegate to <toolName>.`),否则主 agent 永远不会委派给自定义角色。
35
40
  * 依赖 util(ROLES / warn / DEFAULT_MAIN_AGENT_PERSONA_EXTRA)、config
36
- * (normalizeModelRef / yamlScalar)与 host
41
+ * (normalizeModelRef / yamlScalar)、glob(expandToolPatterns)与 host
37
42
  * (isHostDependent);被 state 引用。
38
43
  */
39
44
  import { DEFAULT_MAIN_AGENT_PERSONA_EXTRA, ROLES, warn } from './util.mjs'
40
45
  import { normalizeModelRef, yamlScalar } from './config.mjs'
41
46
  import { isHostDependent } from './host.mjs'
42
47
  import { isPtcWorkerHost } from './preset-system.mjs'
48
+ import { expandToolPatterns } from './glob.mjs'
43
49
 
44
50
  // ── workflow worker 行的宿主版本适配 ─────────────────────────────────────────
45
51
 
@@ -220,18 +226,25 @@ export function replaceRoleIdentity(text, displayName) {
220
226
  /**
221
227
  * Render the delegation block(s) for custom roles, matching the existing
222
228
  * delegation-* indentation (4-space list items).
229
+ * expandCandidates(可选,Set/数组):传入时角色 tools 先过通配符展开(与内置
230
+ * 角色同一候选集口径,见 composeGenerated),展开后的具体名直接进块内 allow ——
231
+ * 自定义角色的 allow 不走 usableWith(角色名本身就不在既有工具面里),展开是
232
+ * 纯加法,不过滤;模式零匹配被丢弃后 tools 若变空,走下方既有的零工具 throw
233
+ *(拒绝安装零工具角色,warn 已提示是哪个模式没匹配上)。缺省不展开(直接调用
234
+ * 方/测试的旧签名行为不变)。
223
235
  */
224
- export function renderCustomRoleBlocks(roles, skillsMap, restrictBase = []) {
236
+ export function renderCustomRoleBlocks(roles, skillsMap, restrictBase = [], expandCandidates = null) {
225
237
  const custom = Object.entries(roles).filter(([toolName]) => !ROLES.includes(toolName))
226
238
  if (custom.length === 0) return ''
227
239
  const chunks = []
228
240
  for (const [toolName, role] of custom) {
229
241
  // 保留名校验(见 assertCustomToolName)+ 零工具防护(与 composeGenerated
230
- // 内置角色循环同款):tools 为空(缺省或 `tools:` 空列表)会生成
231
- // `toolFilter.allow:`(YAML null),子 agent 创建后零工具——宁可拒绝安装,
232
- // 也不静默生成一个干不了活的角色。
242
+ // 内置角色循环同款):tools 为空(缺省或 `tools:` 空列表,或传入候选集时
243
+ // 通配符展开后零命中)会生成 `toolFilter.allow:`(YAML null),子 agent
244
+ // 创建后零工具——宁可拒绝安装,也不静默生成一个干不了活的角色。
233
245
  assertCustomToolName(toolName, restrictBase)
234
- if ((role.tools ?? []).length === 0) {
246
+ const tools = expandCandidates === null ? (role.tools ?? []) : expandToolPatterns(role.tools ?? [], expandCandidates)
247
+ if (tools.length === 0) {
235
248
  throw new Error(`role "${toolName}" would end up with an empty toolFilter.allow — refusing to install a zero-tool role.`)
236
249
  }
237
250
  const persona = withSkillGuidance(
@@ -281,7 +294,7 @@ export function renderCustomRoleBlocks(roles, skillsMap, restrictBase = []) {
281
294
  ...persona.split('\n').map((l) => ` ${l}`),
282
295
  ' toolFilter:',
283
296
  ' allow:',
284
- ...(role.tools ?? []).map((t) => ` - ${yamlScalar(t)}`),
297
+ ...tools.map((t) => ` - ${yamlScalar(t)}`),
285
298
  ].join('\n'),
286
299
  )
287
300
  }
@@ -302,16 +315,23 @@ export function firstLine(text) {
302
315
  * removed: once roles_remove is non-empty the explicit allow MUST be injected,
303
316
  * otherwise the static restrict.mjs MAIN_AGENT_ALLOW would keep delegating to
304
317
  * the deleted role (删除不生效).
318
+ * 三路入参(base / mainAgentExtra / customToolNames)先过通配符展开(候选集 =
319
+ * presetUniverse ∪ inventory,与 usableWith 判定用的同一集合),展开后的名字仍
320
+ * 走各自既有的过滤口径(base / extra 过 usableWith,customToolNames 直通收)——
321
+ * 语义叠加不替代。base 与 customToolNames 实际都是精确名(restrict.mjs 源名单 /
322
+ * assignments.roles 的键),展开为恒等变换,零风险;mainAgentExtra 是手写配置
323
+ * 条目,通配符的主战场。零通配时展开是同引用恒等,默认路径输出逐字节不变。
305
324
  */
306
325
  export function computeRestrictAllow(base, presetUniverse, inventory, mainAgentExtra, mainAgentRemove, customToolNames, removedRoleNames = []) {
326
+ const expandCandidates = new Set([...presetUniverse, ...inventory])
307
327
  const kept = []
308
- for (const name of base) {
328
+ for (const name of expandToolPatterns(base, expandCandidates)) {
309
329
  if (usableWith(name, presetUniverse, inventory)) kept.push(name)
310
330
  }
311
- for (const name of mainAgentExtra) {
331
+ for (const name of expandToolPatterns(mainAgentExtra, expandCandidates)) {
312
332
  if (!kept.includes(name) && usableWith(name, presetUniverse, inventory)) kept.push(name)
313
333
  }
314
- for (const name of customToolNames) {
334
+ for (const name of expandToolPatterns(customToolNames, expandCandidates)) {
315
335
  if (!kept.includes(name)) kept.push(name)
316
336
  }
317
337
  const removed = new Set([...(mainAgentRemove ?? []), ...(removedRoleNames ?? [])])
@@ -730,11 +750,18 @@ export function composeGenerated(srcText, blocks, personaBlocks, assignments, in
730
750
  for (const name of block.names) presetUniverse.add(name)
731
751
  }
732
752
 
753
+ // 通配符展开候选集 = presetUniverse ∪ inventory(与 usableWith 判定用的同一
754
+ // 集合):内置角色 intent / 自定义角色 tools / main_agent_extra 三处的 `*` /
755
+ // `?` 模式条目都先对它展开成具体名,再进各自既有的过滤(语义叠加不替代)。
756
+ const expandCandidates = new Set([...presetUniverse, ...inventory])
757
+
733
758
  // Role allow results (default roles only; custom roles are rendered fresh).
734
759
  const roleResults = []
735
760
  for (const role of ROLES) {
736
761
  if (removedRoles.includes(role)) continue // 已删除角色:不产 roleResults / allow edits
737
- const intent = assignments.roles[role]?.tools ?? blocks.get(role).names
762
+ // 意图先过通配符展开再进白名单过滤;roleResults 的 intent/removed 都记展开
763
+ // 后的具体名(报告口径「配了几个 → 留几个」对模式条目才有意义)。
764
+ const intent = expandToolPatterns(assignments.roles[role]?.tools ?? blocks.get(role).names, expandCandidates)
738
765
  const filtered = filterUsableTools(intent, presetUniverse, inventory)
739
766
  if (filtered.length === 0) {
740
767
  throw new Error(`role "${role}" would end up with an empty toolFilter.allow — refusing to install a zero-tool role.`)
@@ -886,17 +913,23 @@ export function composeGenerated(srcText, blocks, personaBlocks, assignments, in
886
913
  // workflow worker 行宿主版本适配(≥0.1.6 换 workflow-ptc;见 adaptWorkflowWorkerRow)。
887
914
  out = adaptWorkflowWorkerRow(out, hostVersionTriple)
888
915
 
889
- const customBlock = renderCustomRoleBlocks(assignments.roles, skillsMap, restrictBase)
916
+ // 自定义角色块:tools 里的通配符条目按同一候选集展开成具体名再进 allow
917
+ //(第 4 参见 renderCustomRoleBlocks)。
918
+ const customBlock = renderCustomRoleBlocks(assignments.roles, skillsMap, restrictBase, expandCandidates)
890
919
  if (customBlock !== '') out = insertCustomRoles(out, customBlock)
891
920
 
892
921
  const mainAgentRemove = assignments.main_agent_remove ?? []
893
922
  // Warn about removals that are not part of the main agent's current allow
894
923
  // (undetected host tools, typos, names never present) and skip them.
924
+ // main_agent_extra 贡献先过通配符展开(与 computeRestrictAllow 的 allow 口径
925
+ // 一致),否则 extra 里的模式条目会让精确名的 remove 误报「不在当前 allow」。
895
926
  if (mainAgentRemove.length > 0) {
896
927
  const removable = new Set(
897
- [...restrictBase, ...(assignments.main_agent_extra ?? []), ...customToolNames].filter((n) =>
898
- usableWith(n, presetUniverse, inventory),
899
- ),
928
+ [
929
+ ...restrictBase,
930
+ ...expandToolPatterns(assignments.main_agent_extra ?? [], expandCandidates),
931
+ ...customToolNames,
932
+ ].filter((n) => usableWith(n, presetUniverse, inventory)),
900
933
  )
901
934
  for (const name of mainAgentRemove) {
902
935
  if (!removable.has(name)) warn(`main_agent_remove: '${name}' 不在主 agent 当前 allow(未检测或不存在),已跳过`)
@@ -0,0 +1,245 @@
1
+ /**
2
+ * `dsh-paoding doctor` 的检查本体:只读体检,不动任何盘。与 health.mjs 的分工
3
+ * ——healthCheck 管「这次保存/应用会怎样」(面向 preview / apply 响应与
4
+ * --dry-run 报告的单次配置体检);本模块管「这台机器装得正不正」(配置文件、
5
+ * 预设安装轨道、托管声明行、孤儿工作区预设目录、版本标记、预设漂移六项环境
6
+ * 对账),结果形状是人读导向的 { items: [{ status, label, detail }] }:
7
+ * status: 'ok' | 'warn' | 'error' | 'skip'(skip = 前置事实缺失,降级不炸——
8
+ * 无 DSH_HOME / 无配置的干净机器上各项依此跳过)。
9
+ *
10
+ * 有意与 healthCheck 解耦、不复用其返回形状: doctor 的每项都有自己的前置探测
11
+ * 与降级语义,硬塞进 level/code 模型只会两头别扭;仅「预设漂移」一项复用
12
+ * healthCheck 的 preset_drift 判定(同一份 dry-run 对比,不另立标准)。
13
+ *
14
+ * 依赖 config(loadConfig)/ yaml(loadYaml)/ alloc(autoAssignments)/
15
+ * state(collectState)/ workspaces(assignSlugs / presetIdOf)/
16
+ * preset-system(轨道、托管块、标记)与 health(healthCheck);被 cli 引用。
17
+ * 零运行时依赖(node:fs / node:path / node:url)。
18
+ */
19
+ import { existsSync, readFileSync } from 'node:fs'
20
+ import path from 'node:path'
21
+ import { loadConfig } from './config.mjs'
22
+ import { loadYaml } from './yaml.mjs'
23
+ import { autoAssignments } from './alloc.mjs'
24
+ import { collectState } from './state.mjs'
25
+ import { assignSlugs, presetIdOf } from './workspaces.mjs'
26
+ import {
27
+ MANAGED_BEGIN,
28
+ MANAGED_END,
29
+ detectHostVersionTriple,
30
+ detectPresetSystem,
31
+ isDeclarativeTriple,
32
+ isPtcWorkerHost,
33
+ listOrchestratorPresetIds,
34
+ parseGeneratorMarker,
35
+ readHomePatchRows,
36
+ } from './preset-system.mjs'
37
+ import { healthCheck } from './health.mjs'
38
+
39
+ const TRACK_LABELS = { declarative: '声明行轨 (declarative,DSH ≥ 0.1.7)', directory: '目录轨 (directory,DSH ≤ 0.1.6)' }
40
+ const trackLabel = (track) => TRACK_LABELS[track] ?? String(track ?? '未知')
41
+
42
+ /** 插件包版本(doctor 跑在插件包内,package.json 就在仓库根;读失败返回 null)。 */
43
+ function repoVersion() {
44
+ try {
45
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
46
+ return typeof pkg?.version === 'string' && pkg.version !== '' ? pkg.version : null
47
+ } catch {
48
+ return null
49
+ }
50
+ }
51
+
52
+ function managedMarkerLines(text) {
53
+ return new Set(
54
+ String(text ?? '')
55
+ .split(/\r?\n/)
56
+ .map((l) => l.trim())
57
+ )
58
+ }
59
+
60
+ /** 孤儿工作区预设目录对账:配置 workspaces 键组统一过 assignSlugs(与生成层
61
+ * 同源,slug 稳定),得到「配置在册」的 presetId 集合;目录有、名册无 → 孤儿。 */
62
+ function workspacePresetOrphans(dshHome, config) {
63
+ const dirs = listOrchestratorPresetIds(dshHome).filter((id) => id !== 'orchestrator')
64
+ if (dirs.length === 0) return { dirs, orphans: [] }
65
+ const wsPaths = Object.keys(config?.workspaces ?? {})
66
+ const inRegister = new Set([...assignSlugs(wsPaths).values()].map(presetIdOf))
67
+ return { dirs, orphans: dirs.filter((id) => !inRegister.has(id)) }
68
+ }
69
+
70
+ /**
71
+ * 跑全套 doctor 检查。参数(全可缺省,干净机器上逐项降级):
72
+ * - dshHome / configFile:DSH 家目录与配置文件路径。
73
+ * - profile:传给 collectState 的 patch 层(--profile,默认全缺省)。
74
+ * - state:调用方已采集的原始 state(测试注入用);缺省时内部 collectState。
75
+ * - generatorVersion:标记对账基准;缺省读包内 package.json(测试可注入)。
76
+ * 返回 { items, hasErrors };本函数自身绝不 throw——单项探测失败折成条目。
77
+ */
78
+ export async function runDoctor({ dshHome, configFile, profile = null, state = null, generatorVersion = null } = {}) {
79
+ const items = []
80
+ const add = (status, label, detail) => items.push({ status, label, detail })
81
+ const version = generatorVersion ?? repoVersion()
82
+
83
+ // 0) state:漂移与轨道两项的公共前置。拿不到时这两项降级,其余照查。
84
+ let stateError = null
85
+ if (state === null) {
86
+ try {
87
+ state = await collectState({ dshHome, configFile, profile, cwd: process.cwd() })
88
+ } catch (err) {
89
+ state = null
90
+ stateError = err?.message ?? String(err)
91
+ }
92
+ }
93
+
94
+ // ── 1. 配置文件存在性 / 可解析性 ──────────────────────────────────────────
95
+ let config = null
96
+ let configUsable = false
97
+ if (!existsSync(configFile)) {
98
+ add('skip', '配置文件', `${configFile} 不存在(全新安装语义:首装自动化会写入基础模板,不处理)`)
99
+ } else {
100
+ try {
101
+ config = loadConfig(configFile, loadYaml(dshHome))
102
+ configUsable = true
103
+ add('ok', '配置文件', `${configFile} 存在且可解析`)
104
+ } catch (err) {
105
+ add('error', '配置文件', `${configFile} 无法解析:${err?.message ?? err}——请先修复(面板加载 / 生成都会被拒)`)
106
+ }
107
+ }
108
+
109
+ // ── 2. 预设安装轨道 vs 宿主版本 ───────────────────────────────────────────
110
+ const track = state?.presetSystem ?? (await detectPresetSystem({ dshHome }).catch(() => null))
111
+ const hostTriple = detectHostVersionTriple()
112
+ if (state === null && stateError !== null) {
113
+ add('skip', '预设安装轨道', `检测管线不可用(${stateError}),跳过轨道核对`)
114
+ } else if (hostTriple === null) {
115
+ add('warn', '预设安装轨道', `当前判定 ${trackLabel(track)},但宿主版本探测不到(PATH 上没有可用的 dsh),无法交叉验证`)
116
+ } else {
117
+ const expected = isDeclarativeTriple(hostTriple) ? 'declarative' : 'directory'
118
+ const versionText = `${hostTriple[0]}.${hostTriple[1]}.${hostTriple[2]}`
119
+ if (expected === track) {
120
+ add('ok', '预设安装轨道', `宿主 ${versionText} ↔ ${trackLabel(track)},一致`)
121
+ } else {
122
+ add('warn', '预设安装轨道', `宿主 ${versionText} 应走 ${TRACK_LABELS[expected]},当前判定 ${trackLabel(track)}(下次 DSH 启动自愈会自动迁移)`)
123
+ }
124
+ }
125
+
126
+ // ── 3. 声明行托管块(~/.dsh/cordis.patch.yml)────────────────────────────
127
+ // readHomePatchRows 对损坏块自捕返 [],读不出「损坏」——这里直接扫标记行补一道:
128
+ // 有 BEGIN 没 END = 托管块损坏,所有托管写盘都会被拒绝。
129
+ let patchText = null
130
+ try {
131
+ patchText = readFileSync(path.join(dshHome, 'cordis.patch.yml'), 'utf8')
132
+ } catch (err) {
133
+ if (err?.code === 'ENOENT') add('skip', '声明行托管块', 'cordis.patch.yml 不存在(尚无声明行;目录轨宿主也不需要)')
134
+ else add('skip', '声明行托管块', `cordis.patch.yml 读取失败(${err?.message ?? err}),跳过该项`)
135
+ }
136
+ if (patchText !== null) {
137
+ const markers = managedMarkerLines(patchText)
138
+ const hasBegin = markers.has(MANAGED_BEGIN)
139
+ const hasEnd = markers.has(MANAGED_END)
140
+ if (hasBegin && !hasEnd) {
141
+ add('error', '声明行托管块', '托管块损坏(只有起始标记没有结束标记):补回结束标记或删除整块,否则面板保存 / 自愈都会拒绝写盘')
142
+ } else {
143
+ let rows = []
144
+ try {
145
+ rows = readHomePatchRows(dshHome)
146
+ } catch {
147
+ rows = []
148
+ }
149
+ if (rows.length === 0) {
150
+ add('ok', '声明行托管块', hasBegin ? '托管块存在但无声明行' : 'cordis.patch.yml 无托管块(目录轨宿主无需声明行)')
151
+ } else {
152
+ const orphans = rows.filter((row) => row.presetId && !existsSync(path.join(dshHome, '.agent-presets', row.presetId)))
153
+ if (track === 'directory') {
154
+ add('warn', '声明行托管块', `检出 ${rows.length} 条声明行,但宿主是目录轨(≤ 0.1.6)——残留声明行会让 profile 启动失败,重启 DSH 触发自愈撤下`)
155
+ } else if (orphans.length > 0) {
156
+ add('warn', '声明行托管块', `孤儿声明行(指向已删目录):${orphans.map((o) => o.rowId).join('、')}——重启 DSH 自愈会撤下,或重新应用一次同步`)
157
+ } else {
158
+ add('ok', '声明行托管块', `${rows.length} 条声明行均指向存在的预设目录`)
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ // ── 4. 孤儿工作区预设目录(.agent-presets/orchestrator-*)─────────────────
165
+ if (!configUsable && existsSync(configFile)) {
166
+ add('skip', '工作区预设目录', '配置文件无法解析,无法对账(先修配置)')
167
+ } else {
168
+ const { dirs, orphans } = workspacePresetOrphans(dshHome, config)
169
+ if (dirs.length === 0) {
170
+ add('ok', '工作区预设目录', '无 orchestrator-<slug> 目录(未配置过工作区)')
171
+ } else if (!configUsable) {
172
+ add('warn', '工作区预设目录', `无配置文件可对账,以下目录全部视为孤儿:${dirs.join('、')}——重新应用可恢复,不用可手动删除`)
173
+ } else if (orphans.length === 0) {
174
+ add('ok', '工作区预设目录', `${dirs.length} 个工作区预设目录均在配置名册内`)
175
+ } else {
176
+ add('warn', '工作区预设目录', `孤儿目录(配置 workspaces 已无对应条目):${orphans.join('、')}——下次「保存并应用」会自动回收,不用可手动删除`)
177
+ }
178
+ }
179
+
180
+ // ── 5. 版本标记对账(.generator-version:版本 + 轨道 + worker 形态)────────
181
+ const presetDir = path.join(dshHome, '.agent-presets', 'orchestrator')
182
+ if (!existsSync(presetDir)) {
183
+ add('skip', '版本标记', '基础预设未安装(无 orchestrator 目录):首装自动化或「保存并应用」会生成')
184
+ } else {
185
+ let marker = null
186
+ try {
187
+ marker = parseGeneratorMarker(readFileSync(path.join(presetDir, '.generator-version'), 'utf8'))
188
+ } catch {
189
+ marker = null
190
+ }
191
+ if (marker === null) {
192
+ add('warn', '版本标记', '.generator-version 缺失或不可解析——下次 DSH 启动自愈会补一次生成(无伤大雅,多跑一遍而已)')
193
+ } else {
194
+ const parts = []
195
+ if (version !== null && marker.version !== version) {
196
+ parts.push(`标记版本 ${marker.version} ≠ 当前生成器 v${version}(下次启动自动按新版重生成)`)
197
+ }
198
+ if (marker.track !== null && track !== null && marker.track !== track) {
199
+ parts.push(`标记轨道 ${TRACK_LABELS[marker.track] ?? marker.track} 与当前 ${trackLabel(track)} 不符(下次启动自动迁移)`)
200
+ }
201
+ // worker 形态只与探测得到的宿主版本比(探测不到不猜,保守不报)。
202
+ if (marker.worker !== null && hostTriple !== null) {
203
+ const workerNow = isPtcWorkerHost(hostTriple) ? 'ptc' : 'worker-thread'
204
+ if (marker.worker !== workerNow) {
205
+ parts.push(`标记 worker 形态 ${marker.worker} 与宿主所需 ${workerNow} 不符(下次启动整盘重生成)`)
206
+ }
207
+ }
208
+ if (parts.length === 0) {
209
+ add('ok', '版本标记', `标记 ${marker.version}(${marker.track ?? '轨道未知'})与当前生成器一致`)
210
+ } else {
211
+ add('warn', '版本标记', parts.join(';'))
212
+ }
213
+ }
214
+ }
215
+
216
+ // ── 6. 预设与配置漂移(复用 healthCheck 的 preset_drift,同一把尺子)────────
217
+ if (state === null) {
218
+ add('skip', '预设漂移', `检测管线不可用(${stateError ?? 'state 缺失'}),跳过该项`)
219
+ } else {
220
+ try {
221
+ const assignments = autoAssignments({
222
+ existing: state.existing,
223
+ suggested: state.suggested,
224
+ staticBase: state.staticBase,
225
+ })
226
+ const { items: healthItems } = healthCheck({ state, assignments })
227
+ let sawDrift = false
228
+ for (const h of healthItems) {
229
+ if (h.code === 'preset_drift') {
230
+ sawDrift = true
231
+ add('warn', '预设漂移', h.message)
232
+ } else if (h.level === 'error') {
233
+ add('error', '配置体检', h.message)
234
+ }
235
+ }
236
+ if (!sawDrift && !healthItems.some((h) => h.level === 'error')) {
237
+ add('ok', '预设漂移', '已安装预设与当前配置的再生成结果一致')
238
+ }
239
+ } catch (err) {
240
+ add('skip', '预设漂移', `体检执行失败,已跳过:${err?.message ?? err}`)
241
+ }
242
+ }
243
+
244
+ return { items, hasErrors: items.some((i) => i.status === 'error') }
245
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 工具条目通配符展开(生成期专用):配置里的工具条目支持 `*`(任意长字符段,
3
+ * 可为空)与 `?`(恰一个字符)两个通配符 —— 够 MCP 工具族场景使用(如
4
+ * `mcp__tavily__tavily_*` 展开为 search/crawl/extract/map/research 五个),不引
5
+ * 第三方依赖,也不支持字符类 `[...]` 与转义(工具名里它们不是合法成员,无需)。
6
+ * 展开只发生在生成期:产物里只落具体工具名,运行时 toolFilter 语义完全不变。
7
+ * 依赖 util(warn);被 compose 引用。
8
+ */
9
+ import { warn } from './util.mjs'
10
+
11
+ /**
12
+ * 条目是否含通配符(`*` 或 `?`):非字符串一律视为不含(配置规范化只放行非空
13
+ * 字符串,这里是 compose 层对 UI / 测试直传 assignments 的兜底口径)。
14
+ */
15
+ export function hasWildcard(entry) {
16
+ return typeof entry === 'string' && /[*?]/.test(entry)
17
+ }
18
+
19
+ /** 通配符模式 → 全串锚定正则:`*` → 任意长字符段、`?` → 恰一字符,其余字符
20
+ * 按字面匹配(正则元字符一律转义,防 `mcp__x(y)` 之类名字被当正则解读)。 */
21
+ function patternToRegExp(pattern) {
22
+ const source = [...pattern]
23
+ .map((ch) => {
24
+ if (ch === '*') return '[\\s\\S]*'
25
+ if (ch === '?') return '[\\s\\S]'
26
+ return ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
27
+ })
28
+ .join('')
29
+ return new RegExp(`^${source}$`)
30
+ }
31
+
32
+ /**
33
+ * 把含通配符的条目对候选集展开为具体工具名列表;不含通配符的条目原样保留。
34
+ * - 输入不含任何通配符 → 原数组原样返回(同一引用):零通配的默认路径零开销、
35
+ * 零行为变化,全默认生成产物与源逐字节一致的金测因此不受影响;
36
+ * - 含通配符时逐条目原位展开:精确名条目留在原相对位置;每个模式条目展开为
37
+ * 命中的候选名,命中名按候选集自身的迭代顺序(Set 插入序,即调用方构造
38
+ * presetUniverse ∪ inventory 时的稳定顺序)排列。选候选集顺序而非「匹配发现
39
+ * 顺序」的理由:(a) 候选集顺序与条目书写顺序、模式数量正交 —— 两个角色写
40
+ * 同一模式得到完全一致的展开序列,反复生成产物稳定、diff 可比;(b) 原位展开
41
+ * 保住作者书写条目的顺序语义(精确名不动、每个模式的展开成组可读)。若按
42
+ * 匹配发现顺序输出,命中名会被拉离其模式条目的位置、序列还随模式间交互
43
+ * 漂移,产物可读性与稳定性都差;
44
+ * - 整体保序去重:先出现者保留(跨精确名/展开名统一去重,模式命中已在候选集
45
+ * 里的精确名时不产生重复行);
46
+ * - 某模式零匹配 → warn 一条(含模式串本身)并丢弃该条目:展开的契约是「产物
47
+ * 只含具体名」,保留裸模式串等于往运行时 allow 里塞一个永远匹配不上的字面量。
48
+ * candidates 接受 Set 或数组;entries 非数组时原样返回(tools 缺省为 null 的
49
+ * 既有口径由调用方 `?? []` 兜底,这里不再二次加工)。
50
+ */
51
+ export function expandToolPatterns(entries, candidates) {
52
+ if (!Array.isArray(entries)) return entries
53
+ if (!entries.some((entry) => hasWildcard(entry))) return entries
54
+ const candidateList = candidates instanceof Set ? [...candidates] : Array.isArray(candidates) ? [...candidates] : []
55
+ const out = []
56
+ const seen = new Set()
57
+ for (const entry of entries) {
58
+ if (!hasWildcard(entry)) {
59
+ if (!seen.has(entry)) {
60
+ seen.add(entry)
61
+ out.push(entry)
62
+ }
63
+ continue
64
+ }
65
+ const re = patternToRegExp(entry)
66
+ let matched = 0
67
+ for (const candidate of candidateList) {
68
+ if (typeof candidate !== 'string' || seen.has(candidate) || !re.test(candidate)) continue
69
+ seen.add(candidate)
70
+ out.push(candidate)
71
+ matched++
72
+ }
73
+ if (matched === 0) warn(`工具通配符模式 '${entry}' 未匹配到任何候选工具名,已忽略该条目`)
74
+ }
75
+ return out
76
+ }