dsh-paoding 0.3.9 → 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.
@@ -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
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * 结构化配置体检(save-time config health check):把散落在生成期 console.warn
3
+ * 里的告警(fork+model 互斥、roles_remove 命中 start_mode 等)与「配置了但宿主
4
+ * 未挂载」「零工具角色」「未知模型」「预设漂移」四类静默问题收口成
5
+ * { items: [{ level: 'error'|'warn'|'info', code, message }] },随面板 preview /
6
+ * apply 响应的 warnings 字段与 CLI --dry-run 报告透传(面板渲染由客户端批次做,
7
+ * 本模块只负责产出结构化字段)。
8
+ *
9
+ * 检查项一览(每项一个独立函数,可单测):
10
+ * - unmounted_tool (warn):assignments 配置的具体工具名(roles.*.tools 与
11
+ * main_agent_extra)不在 presetUniverse ∪ inventory。白名单语义与生成层
12
+ * filterUsableTools 同源(不另立判断,防两处漂移);含 `*`/`?` 的条目跳过
13
+ * (glob 支持由另一批次实现,这里不抢)。
14
+ * - zero_tool_role (error):内置角色过滤后 allow 为空(提前给可读信息;
15
+ * composeGenerated 的既有 throw 行为不变,体检在前、throw 兜底);自定义
16
+ * 角色 tools 为空同口径(renderCustomRoleBlocks 的 throw 同样不动)。
17
+ * - unknown_model (warn):roles.*.model 配了但模型目录(models,可选)查无
18
+ * 此名;models 为 null(CLI / 面板模型目录拿不到)时整项跳过。
19
+ * - preset_drift (info):已安装预设与当前配置的再生成结果不一致(复用
20
+ * generateAndInstall 的 dryRun 产物文本对比;dryRunResult 已由调用方传入时
21
+ * 直接消费,不再重跑生成)。
22
+ * - generation (warn):生成期 warn 原样透传(captureWarnings 接管 console.warn
23
+ * 抓取 warning: 前缀行),不重复实现判定逻辑。
24
+ *
25
+ * 依赖 util(ROLES)、compose(filterUsableTools)、state(generateAndInstall,
26
+ * 仅在未拿到 dryRunResult 时做一次 dry-run 预演);被 api-core / cli / doctor
27
+ * 引用。healthCheck 自身绝不 throw——体检坏了不该连带请求 500。
28
+ */
29
+ import { readFileSync } from 'node:fs'
30
+ import path from 'node:path'
31
+ import { ROLES } from './util.mjs'
32
+ import { filterUsableTools } from './compose.mjs'
33
+ import { generateAndInstall } from './state.mjs'
34
+
35
+ const WARN_PREFIX = 'warning: '
36
+
37
+ /**
38
+ * 临时接管 console.warn,收集窗口期内所有告警行。返回 [fn() 结果, 消息数组];
39
+ * 消息剥掉 util.warn 的 `warning: ` 前缀(透传给结构化 items 的 message 用)。
40
+ * 非字符串参数取 message 字段或 String() 兜底;finally 里必还原,fn 抛错也
41
+ * 不吞——需要「抓告警 + 容错」的调用方自己包 try/catch(见 installAssignments)。
42
+ */
43
+ export function captureWarnings(fn) {
44
+ const messages = []
45
+ const original = console.warn
46
+ console.warn = (...args) => {
47
+ const text = args.map((a) => (typeof a === 'string' ? a : String(a?.message ?? a))).join(' ')
48
+ messages.push(text.startsWith(WARN_PREFIX) ? text.slice(WARN_PREFIX.length) : text)
49
+ }
50
+ let result
51
+ try {
52
+ result = fn()
53
+ } finally {
54
+ console.warn = original
55
+ }
56
+ return [result, messages]
57
+ }
58
+
59
+ /** 体检结果条目工厂:统一形状,防各检查项字段漂移。 */
60
+ function item(level, code, message) {
61
+ return { level, code, message }
62
+ }
63
+
64
+ /**
65
+ * presetUniverse(restrict.mjs 主 agent 白名单 ∪ 源 preset 各角色 allow 自带面),
66
+ * 与 composeGenerated 同一构造。state 必须是原始 collectState 产物(blocks 是
67
+ * Map)——序列化视图喂进来 blocks 不可枚举时按空面兜底(结果只会多报
68
+ * unmounted_tool warn,不会误报 error)。
69
+ */
70
+ function presetUniverseOf(state) {
71
+ const universe = new Set(Array.isArray(state.restrictBase) ? state.restrictBase : [])
72
+ if (state.blocks instanceof Map) {
73
+ for (const block of state.blocks.values()) {
74
+ for (const name of block.names) universe.add(name)
75
+ }
76
+ }
77
+ return universe
78
+ }
79
+
80
+ /** inventory 兜底:原始 state 是 Set;容一个数组形态(序列化视图误入时)。 */
81
+ function inventoryOf(state) {
82
+ return state.inventory instanceof Set
83
+ ? state.inventory
84
+ : new Set(Array.isArray(state.inventory) ? state.inventory : [])
85
+ }
86
+
87
+ /** assignments 里配置的具体工具名 → [{ name, where }](roles.*.tools + main_agent_extra)。 */
88
+ function configuredToolRefs(assignments) {
89
+ const refs = []
90
+ const roles = assignments && typeof assignments.roles === 'object' && assignments.roles !== null ? assignments.roles : {}
91
+ for (const [role, conf] of Object.entries(roles)) {
92
+ if (!conf || typeof conf !== 'object') continue
93
+ const tools = Array.isArray(conf.tools) ? conf.tools : []
94
+ for (const tool of tools) {
95
+ if (typeof tool === 'string' && tool !== '') refs.push({ name: tool, where: `roles.${role}.tools` })
96
+ }
97
+ }
98
+ const extra = Array.isArray(assignments?.main_agent_extra) ? assignments.main_agent_extra : []
99
+ for (const tool of extra) {
100
+ if (typeof tool === 'string' && tool !== '') refs.push({ name: tool, where: 'main_agent_extra' })
101
+ }
102
+ return refs
103
+ }
104
+
105
+ /** 收集 roles.*.model 引用([{ toolName, model }],trim 后非空才算配置了)。 */
106
+ export function collectModelRefs(assignments) {
107
+ const refs = []
108
+ const roles = assignments && typeof assignments.roles === 'object' && assignments.roles !== null ? assignments.roles : {}
109
+ for (const [toolName, conf] of Object.entries(roles)) {
110
+ if (!conf || typeof conf !== 'object') continue
111
+ if (typeof conf.model === 'string' && conf.model.trim() !== '') {
112
+ refs.push({ toolName, model: conf.model.trim() })
113
+ }
114
+ }
115
+ return refs
116
+ }
117
+
118
+ // ── 检查项:unmounted_tool ──────────────────────────────────────────────────
119
+
120
+ /**
121
+ * 配置了但当前未挂载(presetUniverse ∪ inventory 都查无)的工具名。可用性判定
122
+ * 直接复用生成层的 filterUsableTools(同一白名单语义,零漂移);`*`/`?` 通配
123
+ * 条目跳过(glob 支持另一批次实现)。
124
+ */
125
+ function checkUnmountedTools(refs, presetUniverse, inventory) {
126
+ const names = [...new Set(refs.map((r) => r.name))]
127
+ const kept = new Set(filterUsableTools(names, presetUniverse, inventory))
128
+ const items = []
129
+ const seen = new Set()
130
+ for (const { name, where } of refs) {
131
+ if (name.includes('*') || name.includes('?')) continue
132
+ if (kept.has(name)) continue
133
+ const key = `${name}\u0000${where}`
134
+ if (seen.has(key)) continue
135
+ seen.add(key)
136
+ items.push(item('warn', 'unmounted_tool', `已配置但当前未挂载:${name}(${where})`))
137
+ }
138
+ return items
139
+ }
140
+
141
+ // ── 检查项:zero_tool_role ──────────────────────────────────────────────────
142
+
143
+ /**
144
+ * 过滤后工具为空的角色。意图(intent)取法与 composeGenerated 逐字对齐:
145
+ * 显式 `tools: []` 就按空算(不回落源模板 allow),只有键缺省才回落 blocks;
146
+ * 自定义角色不经过滤、tools 为空即零工具(与 renderCustomRoleBlocks 同口径)。
147
+ * 只产 error 条目,不在本层拦下生成——composeGenerated 的既有 throw 照旧兜底。
148
+ */
149
+ function checkZeroToolRoles(assignments, state, presetUniverse, inventory) {
150
+ const items = []
151
+ const roles = assignments && typeof assignments.roles === 'object' && assignments.roles !== null ? assignments.roles : {}
152
+ const removedRoles = new Set(Array.isArray(assignments?.roles_remove) ? assignments.roles_remove : [])
153
+ const blocks = state.blocks instanceof Map ? state.blocks : null
154
+ for (const role of ROLES) {
155
+ if (removedRoles.has(role)) continue
156
+ const conf = roles[role] && typeof roles[role] === 'object' ? roles[role] : null
157
+ const intent = Array.isArray(conf?.tools) ? conf.tools : (blocks?.get(role)?.names ?? [])
158
+ if (filterUsableTools(intent, presetUniverse, inventory).length === 0) {
159
+ items.push(
160
+ item(
161
+ 'error',
162
+ 'zero_tool_role',
163
+ `角色 ${role} 过滤后没有任何可用工具(零工具角色会被拒绝安装):` +
164
+ `请在配置里为它勾选至少一个已挂载工具,或加入 roles_remove 删除该角色`,
165
+ ),
166
+ )
167
+ }
168
+ }
169
+ for (const [toolName, conf] of Object.entries(roles)) {
170
+ if (ROLES.includes(toolName)) continue
171
+ const tools = Array.isArray(conf?.tools) ? conf.tools : []
172
+ if (tools.length === 0) {
173
+ items.push(
174
+ item(
175
+ 'error',
176
+ 'zero_tool_role',
177
+ `自定义角色 ${toolName} 没有配置任何工具(零工具角色会被拒绝安装):请在配置里为它写 tools 列表`,
178
+ ),
179
+ )
180
+ }
181
+ }
182
+ return items
183
+ }
184
+
185
+ // ── 检查项:unknown_model ───────────────────────────────────────────────────
186
+
187
+ /** 模型目录(面板 models 路由的 groups 形状)→ 已知模型 id 集合;容忍扁平 id 数组。 */
188
+ function knownModelIds(models) {
189
+ const ids = new Set()
190
+ if (!Array.isArray(models)) return ids
191
+ for (const entry of models) {
192
+ if (typeof entry === 'string' && entry !== '') {
193
+ ids.add(entry)
194
+ continue
195
+ }
196
+ if (!entry || typeof entry !== 'object') continue
197
+ if (typeof entry.id === 'string' && entry.id !== '') ids.add(entry.id)
198
+ if (Array.isArray(entry.models)) {
199
+ for (const model of entry.models) {
200
+ if (typeof model === 'string' && model !== '') ids.add(model)
201
+ else if (model && typeof model.id === 'string' && model.id !== '') ids.add(model.id)
202
+ }
203
+ }
204
+ }
205
+ return ids
206
+ }
207
+
208
+ /** roles.*.model 配了但目录查无 → warn。models 为 null(拿不到目录)整项跳过。 */
209
+ function checkUnknownModels(assignments, models) {
210
+ if (models === null || models === undefined) return []
211
+ const known = knownModelIds(models)
212
+ if (known.size === 0) return []
213
+ return collectModelRefs(assignments)
214
+ .filter(({ model }) => !known.has(model))
215
+ .map(({ toolName, model }) =>
216
+ item('warn', 'unknown_model', `roles.${toolName}.model: '${model}' 不在当前模型目录中(名单以设置页模型选择器为准,配错会被静默回落)`),
217
+ )
218
+ }
219
+
220
+ // ── 检查项:preset_drift ────────────────────────────────────────────────────
221
+
222
+ /**
223
+ * 已安装预设 vs 再生成结果:对比 dryRunResult.text 与落盘的 agent.cordis.yml
224
+ * (按 dryRunResult.presetId 定位,工作区预设同样适用)。文本一致 → 无条目;
225
+ * 落盘文件缺失(未安装)→ info 条目提示应用后生成;内容有差 → info「落后于
226
+ * 配置」。拿不到 dryRunResult(调用方没生成过)时返回空,由调用方决定是否
227
+ * 先做 dry-run 预演再传入。
228
+ */
229
+ function checkPresetDrift(state, dryRunResult) {
230
+ if (!dryRunResult || typeof dryRunResult.text !== 'string') return []
231
+ const presetId = typeof dryRunResult.presetId === 'string' && dryRunResult.presetId !== '' ? dryRunResult.presetId : 'orchestrator'
232
+ const dshHome = typeof state.dshHome === 'string' ? state.dshHome : null
233
+ if (!dshHome) return []
234
+ let installed = null
235
+ try {
236
+ installed = readFileSync(path.join(dshHome, '.agent-presets', presetId, 'agent.cordis.yml'), 'utf8')
237
+ } catch {
238
+ installed = null
239
+ }
240
+ if (installed === dryRunResult.text) return []
241
+ return [
242
+ item(
243
+ 'info',
244
+ 'preset_drift',
245
+ installed === null
246
+ ? `预设 ${presetId} 尚未安装:应用一次(面板「保存并应用」或 CLI --auto)即生成`
247
+ : `预设 ${presetId} 落后于配置,重新应用即同步`,
248
+ ),
249
+ ]
250
+ }
251
+
252
+ // ── 入口 ────────────────────────────────────────────────────────────────────
253
+
254
+ /**
255
+ * 跑全套体检,返回 { items }。参数:
256
+ * - state:原始 collectState 产物(restrictBase / blocks / inventory / dshHome)。
257
+ * - assignments:待检的目标字段集合(配置面板提交形状或 loadConfig 产物)。
258
+ * - models:模型目录(面板 models 路由的 groups 数组);null = 跳过 unknown_model。
259
+ * - dryRunResult:调用方已有的 generateAndInstall 产物(preview/apply 顺路传入,
260
+ * 不重跑生成);null 时本函数自己跑一次 dry-run 预演(doctor / 直接调用)。
261
+ * - generationWarnings:调用方在其生成窗口内用 captureWarnings 抓到的告警
262
+ * (installAssignments 传入,避免同一次生成跑两遍);null 且无 dryRunResult
263
+ * 时由本函数的预演自行抓取。
264
+ * 任何内部失败都折成条目(generation_error),本函数自身不 throw。
265
+ */
266
+ export function healthCheck({ state, assignments, models = null, dryRunResult = null, generationWarnings = null } = {}) {
267
+ const items = []
268
+ if (!state || typeof state !== 'object') return { items }
269
+
270
+ const presetUniverse = presetUniverseOf(state)
271
+ const inventory = inventoryOf(state)
272
+
273
+ // 生成(或复用调用方产物)与生成期告警:三条互斥路径——
274
+ // 1) dryRunResult 已给 → 全复用,零重跑;
275
+ // 2) 只有 generationWarnings(原生成已失败,installAssignments 容错路径)
276
+ // → 消费现成告警,不再重跑(失败场景重跑只会复现同一个错);
277
+ // 3) 都没有 → 自己做一次 dry-run 预演并抓告警;预演失败折成 generation_error。
278
+ let result = dryRunResult
279
+ let warns = Array.isArray(generationWarnings) ? generationWarnings : null
280
+ if (result === null && warns === null) {
281
+ try {
282
+ const [ran, captured] = captureWarnings(() => generateAndInstall(state, assignments ?? {}, { dryRun: true }))
283
+ result = ran
284
+ warns = captured
285
+ } catch (err) {
286
+ items.push(item('error', 'generation_error', `生成预演失败:${err?.message ?? err}`))
287
+ }
288
+ }
289
+
290
+ items.push(...checkUnmountedTools(configuredToolRefs(assignments), presetUniverse, inventory))
291
+ items.push(...checkZeroToolRoles(assignments, state, presetUniverse, inventory))
292
+ items.push(...checkUnknownModels(assignments, models))
293
+ if (Array.isArray(warns)) {
294
+ for (const message of warns) items.push(item('warn', 'generation', message))
295
+ }
296
+ items.push(...checkPresetDrift(state, result))
297
+ return { items }
298
+ }