@quill507/dsh-orchestrator-preset 0.1.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,424 @@
1
+ // ============================================================================
2
+ // gen-cordis-patch.mjs — 由声明清单渲染 $BUNDLE/cordis.patch.yml(含 --check 门禁)
3
+ // ============================================================================
4
+ //
5
+ // 单一事实源:tools/preset-declaration.mjs(28 个 row 的声明)。
6
+ // 权威缩进参照:dsh-web-app/presets/standard.patch.yml(146 行,六级缩进 0/4/6/8/10/14);
7
+ // **不得**用 profiles/*/cordis.yml(它是派生件,每级 2 空格且 `>` 折叠已破坏多行文本)。
8
+ //
9
+ // 用法:
10
+ // node tools/gen-cordis-patch.mjs # 写 $BUNDLE/cordis.patch.yml
11
+ // node tools/gen-cordis-patch.mjs --check # 不写;逐字节比较,不一致 ⇒ stale + exit 1
12
+ // node tools/gen-cordis-patch.mjs --help
13
+ // 输出(stdout):wrote <path> (29 rows) / ok <path> (29 rows) / stale <path>
14
+ // exit:0 = 成功(写成功或 --check 一致)· 1 = --check 不一致 · 2 = 用法错误 / 输入非法
15
+ // 无第三方依赖(只用 node: 内置模块)。
16
+ //
17
+ // ⛔ 本生成器**不做**任何工具名闭集断言(该机制已按用户裁决删除,见 DESIGN.md §3.3.4 的 ⛔ 段)。
18
+ // ⇒ deny / allow 里出现 LEGAL_TOOL_NAMES 之外的名字(典型:4 个 mcp_* 名)**必须放行并正常渲染**。
19
+ // LEGAL_TOOL_NAMES 仅作为**参考清单**打印规模,不参与任何判定。
20
+ //
21
+ // 行数口径(⚠️ 两套数字,别混):`(29 rows)` = DESIGN §2.4 的口径
22
+ // (14 顶层 + 3 group + 3 delegation 控制 + 9 lane;= `topRows + groups + delegation.rows`),
23
+ // 这是计划 `:1216`/`:1220`/`:1228` 与 T08 `:1388` / T09 `:1512` 六处一致使用的数。
24
+ // 而渲染出的 `- id:` 行数是 **34**(多出的 4 = planning 1 + compaction 3 个子行 —— 它们**确实渲染**,
25
+ // 只是被 DESIGN 的算式折进其 group 条目里)⇒ 生成器把两个数都打印出来,不做数字粉饰。
26
+ //
27
+ // ⚠️ import 本模块**无副作用**(CLI 只在「直接执行」时运行)⇒ `import()` 只得到导出(供 T08/T09 复用)。
28
+
29
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
30
+ import { homedir } from 'node:os'
31
+ import { dirname, join } from 'node:path'
32
+ import { fileURLToPath, pathToFileURL } from 'node:url'
33
+
34
+ const HERE = dirname(fileURLToPath(import.meta.url))
35
+ const BUNDLE_DIR = dirname(HERE)
36
+ export const OUT_PATH = join(BUNDLE_DIR, 'cordis.patch.yml')
37
+ const DECL_URL = pathToFileURL(join(HERE, 'preset-declaration.mjs')).href
38
+
39
+ // ── T13(C 路线实现)`SKELETON` 兜底顺序(**已删除硬编码兜底字面量**)────────────
40
+ // ① `--skeleton <path>`(CLI,最高优先)—— **显式指定 ⇒ 不存在即硬错**,**不回落**到 ②③④
41
+ // ② `DSH_SKELETON` 环境变量 —— 同 ①:**显式指定 ⇒ 不存在即硬错**,**不回落**到 ③④
42
+ // ③ **DSH home 推导** `node_modules/@deepseek-ai/dsh-web-app/presets/standard.patch.yml`;
43
+ // home 取值规则 = `$DSH_HOME`(去空白;空串视同未设置)→ 未设置则回落 **`~/.dsh`**
44
+ // ⇒ 与宿主 `@deepseek-ai/dsh-home-paths` 的 `resolveDshHome()` **同一套优先级**(见下 `dshHome()`)。
45
+ // ④ **仓内 vendor 骨架** `presets/standard.patch.yml` —— **相对脚本自身位置**解析:
46
+ // 仓内布局是 `bundle/tools/gen-cordis-patch.mjs` + 仓根 `presets/` ⇒ `../../presets/…`;
47
+ // 同时兼容「bundle 直接在仓根」的 `../presets/…`。
48
+ // ⚠️ 活机 bundle(`~/.dsh/plugins/<id>-bundle/tools/`)下 ④ 的**这两个相对路径都不存在** ——
49
+ // 那里靠 ②/③ 兜住;`--check` 仍应 exit 0。若 ①②③④ 全部取不到 ⇒ **loud 停、exit 2**,
50
+ // **不引入默认值兜底、不编造**(§2.2 G5)。⚠️ 注意 ③ 的回落 home **不是默认值兜底**:
51
+ // 回落出来的路径仍要 `existsSync` 命中才采用,取不到照样停。
52
+ // ⚠️ 为什么不直接依赖宿主 `@deepseek-ai/dsh-home-paths` 的 `resolveDshHome()`?两条**契约**理由:
53
+ // (1) 文件头「无第三方依赖(只用 node: 内置模块)」是本工具的既有契约;
54
+ // (2) 仓内布局未安装该包时,静态 import 会在**模块加载期**抛 `ERR_MODULE_NOT_FOUND` 而直接崩,
55
+ // 根本走不到 `main()` 的 exit 2 —— 那才会真的**削弱** fail-closed。
56
+ // ⚠️ 勿把「同步加载不了」当理由:Node ≥ 22.12 的 `require(esm)` 已能同步加载该包
57
+ // (实测 v22.23.1 上 `createRequire(...)('@deepseek-ai/dsh-home-paths')` **成功**),
58
+ // 但那把工具的 Node 版本下限抬到 22.12,更早的 Node 上同一行会抛 `ERR_REQUIRE_ESM`。
59
+ // ⇒ 若仓主要求「单一事实源」优先、愿意改掉无第三方依赖契约并抬高 Node 下限,
60
+ // 把 `dshHome()` 换成 `createRequire(import.meta.url)('@deepseek-ai/dsh-home-paths').resolveDshHome()`
61
+ // 即可,**不要**同时保留两份规则。
62
+ // ⚠️ **已接受并拥有的漂移风险**:宿主若改动 `resolveDshHome` 的优先级,本镜像会**静默**分叉且无人拦截。
63
+ // ⚠️ 告警不在 import 期打印(保持「import 本模块无副作用」的不变量),由 main() 打印。
64
+ export const PARAMETERIZATION_WARNINGS = []
65
+ const envTrim = (v) => (typeof v === 'string' ? v.trim() : '')
66
+
67
+ /** 仓内 vendor 骨架的候选路径(相对脚本自身,逐个 existsSync 试)。 */
68
+ export function vendorSkeletonCandidates() {
69
+ return [
70
+ join(BUNDLE_DIR, '..', '..', 'presets', 'standard.patch.yml'),
71
+ join(BUNDLE_DIR, '..', 'presets', 'standard.patch.yml'),
72
+ ]
73
+ }
74
+
75
+ /** 权威骨架在某个 DSH home 下的相对位置(仓内 `node_modules/@deepseek-ai/dsh-web-app/…`)。 */
76
+ export function skeletonUnderHome(home) {
77
+ return join(home, 'node_modules', '@deepseek-ai', 'dsh-web-app', 'presets', 'standard.patch.yml')
78
+ }
79
+
80
+ /**
81
+ * 取 DSH home —— 与宿主 `@deepseek-ai/dsh-home-paths` 的 `resolveDshHome()` **取值优先级一致**:
82
+ * `$DSH_HOME`(去空白;空串/纯空白视同未设置)→ 未设置则回落 `~/.dsh`。此处不引入该包,理由见上。
83
+ * ⚠️ 这**不是**默认值兜底:返回的 home 后面仍要走 `existsSync`,取不到照样进「全部失败 ⇒ exit 2」。
84
+ * ⚠️ 与宿主仍有两处**既有**差异(本修复未扩大也未缩小,属另一张单,别混在本改动里):
85
+ * 宿主会对 `$DSH_HOME` 做 `~` 展开与分隔符归一,本函数只去尾部斜杠 ⇒
86
+ * `DSH_HOME='~'` 在两边的结果不同(宿主给 home,本函数给字面 `~` → 后面 existsSync 落空 ⇒ exit 2)。
87
+ * @returns {{ path: string, via: 'DSH_HOME'|'default-home', label: string }}
88
+ */
89
+ export function dshHome() {
90
+ const fromEnv = envTrim(process.env.DSH_HOME)
91
+ if (fromEnv) return { path: fromEnv.replace(/[\\/]+$/, ''), via: 'DSH_HOME', label: 'DSH_HOME' }
92
+ return { path: join(homedir(), '.dsh'), via: 'default-home', label: '默认 home(~/.dsh)' }
93
+ }
94
+
95
+ /** 返回解析结果,取不到**不抛错**:
96
+ * - `{ path, tried, via }` —— 命中;
97
+ * - `{ path: null, tried, via: null, fatal }` —— **显式指定(①/②)但该路径不存在**:硬错,**不回落**;
98
+ * - `{ path: null, tried, via: null }` —— 链上全部取不到(③④ 都落空):同样是硬停。 */
99
+ export function resolveSkeleton(cliPath = null) {
100
+ const tried = []
101
+ // ⚠️ ①/② 是**显式指定**:用户点名了要哪个骨架,不存在就必须点名报错并停 ——
102
+ // 继续往下走会把**另一个**骨架当成他指定的那个用掉,那正是「不得编造」(§2.2 G5)。
103
+ if (cliPath) {
104
+ if (existsSync(cliPath)) { tried.push(`--skeleton ${cliPath}:存在`); return { path: cliPath, tried, via: '--skeleton' } }
105
+ tried.push(`--skeleton ${cliPath}:不存在`)
106
+ return { path: null, tried, via: null, fatal: `--skeleton 指定的骨架不存在 ⇒ 停:${cliPath}` }
107
+ }
108
+ tried.push('--skeleton:未给')
109
+ const env = envTrim(process.env.DSH_SKELETON)
110
+ if (env) {
111
+ if (existsSync(env)) { tried.push(`DSH_SKELETON ${env}:存在`); return { path: env, tried, via: 'DSH_SKELETON' } }
112
+ tried.push(`DSH_SKELETON ${env}:不存在`)
113
+ return { path: null, tried, via: null, fatal: `DSH_SKELETON 指定的骨架不存在 ⇒ 停:${env}` }
114
+ }
115
+ tried.push('DSH_SKELETON:未设置')
116
+ // ③ DSH home 推导:`$DSH_HOME` 未设置时**回落 `~/.dsh`**(与宿主 `resolveDshHome()` 同规则)
117
+ const home = dshHome()
118
+ if (home.via === 'default-home') tried.push(`DSH_HOME:未设置 ⇒ 回落默认 home ${home.path}`)
119
+ const homeCandidate = skeletonUnderHome(home.path)
120
+ if (existsSync(homeCandidate)) { tried.push(`${home.label} 推导 ${homeCandidate}:存在`); return { path: homeCandidate, tried, via: home.via } }
121
+ tried.push(`${home.label} 推导 ${homeCandidate}:不存在`)
122
+ for (const candidate of vendorSkeletonCandidates()) {
123
+ if (existsSync(candidate)) { tried.push(`vendor ${candidate}:存在`); return { path: candidate, tried, via: 'vendor' } }
124
+ tried.push(`vendor ${candidate}:不存在`)
125
+ }
126
+ return { path: null, tried, via: null }
127
+ }
128
+
129
+ /** 不带 CLI 参数时的解析结果(供 `import` 侧使用;取值 = ②→③→④)。 */
130
+ export const SKELETON_RESOLVED = resolveSkeleton(null)
131
+ export const SKELETON = SKELETON_RESOLVED.path
132
+ const PRESET_ROW_ID = 'preset-dsh-orchestrator-preset'
133
+
134
+ // ── 取值槽:plan-mode 的 section 文本 ────────────────────────────────────────
135
+ // T06 契约把 `PLAN_MODE_SECTION` 留作**有意的实现期取值槽**(= `''`),由本生成器在渲染时填入。
136
+ // 取法 = 「缩进判定 + 非空门」:只有**缩进大于** `section: |` 键的行属于块内容;**块内空行属于值**
137
+ // (⚠️ 独立裁决 A:段间 5 个空行必须保留 —— 删掉等于把多行结构粘掉,计划 `:1326`/`:1327` 的逐字对照门会拦)。
138
+ // ⚠️ 非空门必须保留(改判据为「至少一个非空行」)—— 取不到内容时若静默渲染,会往产物里写一个空 section。
139
+ export function extractPlanModeSection(path = SKELETON) {
140
+ const lines = readFileSync(path, 'utf8').split('\n')
141
+ const out = []
142
+ let base = -1
143
+ let started = false
144
+ for (const line of lines) {
145
+ const indent = /^ */.exec(line)[0].length
146
+ if (!started) {
147
+ if (/section: \|$/.test(line)) { started = true; base = indent }
148
+ continue
149
+ }
150
+ // ⚠️ 先判「空行」再判缩进:空行的 indent = 0,若先判 `indent > base` 会走到 break,块内空行会丢
151
+ if (line.trim() === '') { out.push(''); continue }
152
+ if (indent > base) { out.push(line.slice(base + 2)); continue }
153
+ break // 缩进回到 ≤ 键缩进 ⇒ 块结束
154
+ }
155
+ // YAML 的 `|`(clip)**不含尾部空行** ⇒ 块尾若吞进了空行必须去掉,否则会多出一个 `\n`
156
+ while (out.length > 0 && out[out.length - 1] === '') out.pop()
157
+ if (!out.some((l) => l !== '')) {
158
+ throw new Error(`PLAN_MODE_SECTION EMPTY ⇒ 停(${path} 里取不到 section 块内容)`)
159
+ }
160
+ return out
161
+ }
162
+
163
+ // ── YAML 渲染原语 ───────────────────────────────────────────────────────────
164
+ const q = (s) => `'${String(s).replace(/'/g, "''")}'` // 字符串值:单引号 + '' 转义
165
+ const RAW = (text) => ({ __raw: text }) // 整行原样(如 `!!js ...` / `cordis:group`)
166
+ const BLOCK = (lines) => ({ __block: lines }) // 块标量(内容行缩进 = 键缩进 + 2)
167
+ // 这三个键的字符串值**裸写**(与权威骨架 standard.patch.yml:92-102 及旧 bundle 一致)。
168
+ // 理由(可机械核对):T08 自检 `grep -o "toolName: subagent_[a-z]*"` 期望 **10** 命中
169
+ // (9 lane + fork)⇒ 若写成 `toolName: 'subagent_fork'` 则该断言恒 0(又一个假通过面)。
170
+ const BARE_STRING_KEYS = new Set(['provider', 'toolName', 'backgroundMode'])
171
+
172
+ function pushValue(lines, indent, key, value) {
173
+ const pad = ' '.repeat(indent)
174
+ if (value === null || value === undefined) throw new Error(`配置值缺失:${key}`)
175
+ if (typeof value === 'object' && '__raw' in value) { lines.push(`${pad}${key}: ${value.__raw}`); return }
176
+ if (typeof value === 'object' && '__block' in value) {
177
+ lines.push(`${pad}${key}: |`)
178
+ // ⚠️ 空行必须渲染成**真 0 字节行**(不能带 `indent + 2` 个尾随空格,否则与骨架不是逐字节相同)
179
+ for (const l of value.__block) lines.push(l === '' ? '' : ' '.repeat(indent + 2) + l)
180
+ return
181
+ }
182
+ if (typeof value === 'boolean' || typeof value === 'number') { lines.push(`${pad}${key}: ${value}`); return }
183
+ if (typeof value === 'string') {
184
+ lines.push(`${pad}${key}: ${BARE_STRING_KEYS.has(key) ? value : q(value)}`)
185
+ return
186
+ }
187
+ if (Array.isArray(value)) { // 列表项**裸写**(加引号会让抽取正则恒空)
188
+ lines.push(`${pad}${key}:`)
189
+ for (const item of value) lines.push(`${' '.repeat(indent + 2)}- ${item}`)
190
+ return
191
+ }
192
+ lines.push(`${pad}${key}:`) // 嵌套映射
193
+ for (const [k, v] of Object.entries(value)) pushValue(lines, indent + 2, k, v)
194
+ }
195
+
196
+ function pushDisabled(lines, indent, value) {
197
+ if (value === true) { lines.push(`${' '.repeat(indent)}disabled: true`); return }
198
+ if (typeof value === 'string') { lines.push(`${' '.repeat(indent)}disabled: !!js ${value}`); return }
199
+ throw new Error(`disabled 只支持 true 或 JS 表达式字符串,收到 ${JSON.stringify(value)}`)
200
+ }
201
+
202
+ function pushRow(lines, row, indent) {
203
+ const k = indent + 2
204
+ lines.push(`${' '.repeat(indent)}- id: ${row.id}`)
205
+ // `name` 通常是字符串(单引号);group 的 `name: cordis:group` 用 RAW 保持与权威骨架逐字一致
206
+ lines.push(`${' '.repeat(k)}name: ${typeof row.name === 'object' ? row.name.__raw : q(row.name)}`)
207
+ if (row.disabled !== undefined) pushDisabled(lines, k, row.disabled)
208
+ if (row.group !== undefined) lines.push(`${' '.repeat(k)}group: ${row.group}`)
209
+ if (row.isolate !== undefined) pushValue(lines, k, 'isolate', row.isolate)
210
+ if (row.children !== undefined) { // group 的子行列表挂在 `config:` 下
211
+ lines.push(`${' '.repeat(k)}config:`)
212
+ for (const child of row.children) pushRow(lines, child, k + 2)
213
+ }
214
+ if (row.config !== undefined) {
215
+ lines.push(`${' '.repeat(k)}config:`)
216
+ for (const [ck, cv] of Object.entries(row.config)) pushValue(lines, k + 2, ck, cv)
217
+ }
218
+ }
219
+
220
+ const personaExpr = (bundlePkg, file) =>
221
+ `!!js process.getBuiltinModule('node:fs').readFileSync(process.getBuiltinModule('node:module').createRequire(baseUrl).resolve('${bundlePkg}/personas/${file}'), 'utf8')`
222
+
223
+ export function laneRow(lane, d) {
224
+ const common = d.LANE_COMMON // ⚠️ 必须复用声明里的那对值,不得另写一份
225
+ const filter = lane.deny !== undefined ? { deny: lane.deny } : { allow: lane.allow }
226
+ return {
227
+ id: `tool-subagent-${lane.tool.replace(/^subagent_/, '').replace(/_/g, '-')}`,
228
+ name: '@deepseek-ai/dsh-tool-subagent',
229
+ config: {
230
+ // provider / toolName / backgroundMode 的字符串值由 BARE_STRING_KEYS 规则**裸写**(见上)
231
+ provider: common.provider,
232
+ toolName: lane.tool,
233
+ backgroundMode: common.backgroundMode,
234
+ maxDepth: lane.maxDepth,
235
+ persona: RAW(personaExpr(d.bundlePkg, lane.persona)),
236
+ toolFilter: filter,
237
+ },
238
+ }
239
+ }
240
+
241
+ // 取值槽:`plan-mode.config.section` 在声明里是 `''` ⇒ 渲染时填入从权威骨架取来的 6 行原文
242
+ const withSection = (row, section) =>
243
+ row.config?.section !== undefined ? { ...row, config: { ...row.config, section: BLOCK(section) } } : row
244
+
245
+ export function groupEntry(group, d, section) {
246
+ const entry = { id: group.id, name: RAW('cordis:group'), group: true }
247
+ if (group.isolate !== undefined) entry.isolate = group.isolate
248
+ entry.children = group.rows.map((r) => withSection(r.tool !== undefined ? laneRow(r, d) : r, section))
249
+ return entry
250
+ }
251
+
252
+ export const countRows = (d) =>
253
+ d.topRows.length + d.groups.length + d.groups.find((g) => g.id === 'delegation').rows.length
254
+
255
+ export const countFileEntries = (d) =>
256
+ d.topRows.length + d.groups.length + d.groups.reduce((n, g) => n + g.rows.length, 0)
257
+
258
+ export function render(d, section, shellSource) {
259
+ const lines = [
260
+ '# Generated by tools/gen-cordis-patch.mjs — DO NOT EDIT BY HAND.',
261
+ '# Source of truth: tools/preset-declaration.mjs',
262
+ '# Regenerate: node tools/gen-cordis-patch.mjs',
263
+ // T07(C1):本行是**产物里唯一**记录 shell 来源的地方 ⇒ 绝对路径与 32 位裸 md5
264
+ // 一律不进产物(否则一份产物就把发布面钉死在某台机器上)。
265
+ // 只保留:来源种类 / 是否回退 / bash 与 pwsh 的生效值与来源行号 / 12 位规范化摘要。
266
+ `# Shell source: kind=${shellSource.kind} fallback=${shellSource.fallback} bash=${shellSource.bash} pwsh=${shellSource.pwsh} normalized-digest=${shellSource.normalizedDigest} (absolute path and raw md5 intentionally omitted; digest is path-normalized)`,
267
+ '#',
268
+ "# This file's `plugins[]` rows are declared in full because DSH agent presets",
269
+ '# have no inheritance or composition mechanism (see DESIGN.md §2.1).',
270
+ '# Rows marked [official] below are configuration derived from DeepSeek Harness',
271
+ '# (MIT License, Copyright (c) 2026 DeepSeek); see LICENSE for the attribution.',
272
+ '- insert:',
273
+ ` - id: ${PRESET_ROW_ID}`,
274
+ " name: '@deepseek-ai/dsh-agent-preset'",
275
+ ' config:',
276
+ ` id: ${q(d.presetId)}`,
277
+ ` name: ${q(d.presetName)}`,
278
+ ' description: |-',
279
+ ` ${d.description}`,
280
+ ' plugins:',
281
+ ]
282
+ for (const row of d.topRows) pushRow(lines, withSection(row, section), 10)
283
+ for (const g of d.groups) pushRow(lines, groupEntry(g, d, section), 10)
284
+ // bundle 级 provider 行:与 preset 行**同层**,属顶层 `- insert:`,不嵌进 preset.config.plugins[]。
285
+ // 它挂载本包自己的 skill provider,让四个 orch-* 技能随包自带,不再拷进用户全局目录。
286
+ lines.push(` - id: ${d.providerRowId}`)
287
+ lines.push(` name: '${d.providerEntry}'`)
288
+ // aegis 前缀行:同一层的第二个挂载点。路由表按 aegis-* 点名二十一个技能,而上游
289
+ // 按裸名注册;这一行是两者之间那座桥。
290
+ // 两个开关默认值不同,各有理由:前缀关掉会让路由表悬空(默认开);描述汉化是
291
+ // 读者偏好(默认关,由使用者在自己的层里打开)。
292
+ lines.push(` - id: ${d.prefixRowId}`)
293
+ lines.push(` name: '${d.prefixEntry}'`)
294
+ lines.push(' config:')
295
+ lines.push(` prefixAegisSkills: ${d.prefixAegisSkills}`)
296
+ lines.push(` describeAegisSkillsInZh: ${d.describeAegisSkillsInZh}`)
297
+ return lines.join('\n') + '\n'
298
+ }
299
+
300
+ // ── shell 来源交叉核对(协调者裁决 1 步骤 2:`dsh --profile web --dump-config`)──
301
+ function crossCheckShell(d) {
302
+ let live
303
+ try { live = d.readShellRowsFromDumpConfig('web') }
304
+ catch (e) { return { ok: false, line: `SHELL-SOURCE-CROSSCHECK: unavailable (${e.message.split('\n')[0]})` } }
305
+ const same = live.rows.bash.disabled === d.HOST_SHELL_ROWS.bash.disabled
306
+ && live.rows.pwsh.disabled === d.HOST_SHELL_ROWS.pwsh.disabled
307
+ if (same) {
308
+ return { ok: true, line: `SHELL-SOURCE-CROSSCHECK: dump-config agrees (md5=${live.md5}; bash=${live.rows.bash.disabled}@${live.rows.bash.line}; pwsh=${live.rows.pwsh.disabled}@${live.rows.pwsh.line})` }
309
+ }
310
+ // 不一致 ⇒ loud 标记,并**以 live dump 为准**给出 shell 名(本 preset 的 patch 不渲染 shell 名,
311
+ // 故只影响参考清单口径;打印两侧原始值以免静默漂移)。
312
+ return {
313
+ ok: false,
314
+ line: `SHELL-SOURCE-DRIFT frozen(bash=${d.HOST_SHELL_ROWS.bash.disabled},pwsh=${d.HOST_SHELL_ROWS.pwsh.disabled}) `
315
+ + `vs dump-config(bash=${live.rows.bash.disabled},pwsh=${live.rows.pwsh.disabled}) ⇒ live dump 为准 `
316
+ + `⇒ shellNames=${JSON.stringify(d.shellNamesFor(process.platform, live.rows))}`,
317
+ }
318
+ }
319
+
320
+ const USAGE = [
321
+ 'usage: node tools/gen-cordis-patch.mjs [--check] [--skeleton <path>] [--no-crosscheck] [--help]',
322
+ '',
323
+ ' (no flag) render $BUNDLE/cordis.patch.yml and write it',
324
+ ' --check render and compare with the existing file (no write); mismatch => stale + exit 1',
325
+ ' --skeleton <p> authoritative indentation reference (plan-mode section source).',
326
+ ' Resolution order: --skeleton > $DSH_SKELETON >',
327
+ ' <DSH home>/node_modules/@deepseek-ai/dsh-web-app/presets/standard.patch.yml >',
328
+ ' vendored ../../presets/standard.patch.yml (relative to this script).',
329
+ ' <DSH home> = $DSH_HOME when set, else ~/.dsh (host resolveDshHome() rule).',
330
+ ' Explicit --skeleton or $DSH_SKELETON that does not exist => loud stop, exit 2',
331
+ ' (never falls through to the other candidates).',
332
+ ' All four unavailable => loud stop, exit 2 (no default fallback).',
333
+ ' --no-crosscheck skip the `dsh --profile web --dump-config` cross-check. Default: cross-check ON.',
334
+ ' Skipping never changes the rendered product — the cross-check only prints.',
335
+ ' It is also non-fatal by design: unavailable DSH => "unavailable", still exit 0.',
336
+ ' --help print this usage',
337
+ '',
338
+ 'env: DSH_SKELETON / DSH_SHELL_ROWS / DSH_FROZEN_ASSEMBLED / DSH_BIN / DSH_HOME',
339
+ 'exit: 0 ok · 1 --check mismatch · 2 usage error / invalid input / skeleton unresolvable',
340
+ ].join('\n')
341
+
342
+ export async function main(argv = process.argv.slice(2)) {
343
+ if (argv.includes('--help') || argv.includes('-h')) { console.log(USAGE); return 0 }
344
+ // T13:显式解析三个开关;未识别的参数才 exit 2
345
+ const unknown = []
346
+ let skeletonCli = null
347
+ let crosscheck = true
348
+ for (let i = 0; i < argv.length; i += 1) {
349
+ const a = argv[i]
350
+ if (a === '--check') continue
351
+ if (a === '--no-crosscheck') { crosscheck = false; continue }
352
+ if (a === '--skeleton') {
353
+ if (i + 1 >= argv.length) { console.error(`--skeleton needs a path argument\n\n${USAGE}`); return 2 }
354
+ skeletonCli = argv[i += 1]
355
+ continue
356
+ }
357
+ unknown.push(a)
358
+ }
359
+ if (unknown.length > 0) { console.error(`unknown argument: ${unknown.join(' ')}\n\n${USAGE}`); return 2 }
360
+ const check = argv.includes('--check')
361
+
362
+ let d
363
+ try { d = await import(DECL_URL) } catch (e) { console.error(`declaration load failed ⇒ 停:${e.message}`); return 2 }
364
+ // T06:参数化告警在这里才打印(import 期不得有副作用)
365
+ for (const w of [...PARAMETERIZATION_WARNINGS, ...(d.PARAMETERIZATION_WARNINGS ?? [])]) {
366
+ console.error(`⚠ 参数化告警:${w}`)
367
+ }
368
+ // T13:骨架解析(CLI 优先);显式指定却不存在 ⇒ 硬错停;链上全落空 ⇒ 也停。两者都 exit 2
369
+ const skeleton = resolveSkeleton(skeletonCli)
370
+ for (const t of skeleton.tried) console.error(` 骨架候选 ${t}`)
371
+ if (skeleton.fatal) {
372
+ console.error(`${skeleton.fatal}\n 显式指定的骨架不存在 ⇒ **不得回落**到其余候选(回落 = 拿另一个骨架冒充它):\n ${skeleton.tried.join('\n ')}`)
373
+ return 2
374
+ }
375
+ if (skeleton.path === null) {
376
+ console.error(`skeleton 来源链全部失败 ⇒ 停(不得编造、不得用默认值兜底):\n ${skeleton.tried.join('\n ')}`)
377
+ return 2
378
+ }
379
+ console.error(`⚠ 取值槽:SKELETON=${skeleton.path} (via ${skeleton.via})`)
380
+ console.error(`⚠ 取值槽:DSH_BIN=${d.DSH_BIN ?? '未解析(crosscheck 将不可用,但不致命)'}`)
381
+ console.error(`⚠ 取值槽:SHELL_SOURCE_CHAIN=${JSON.stringify(d.SHELL_SOURCE_CHAIN)}`)
382
+ let section
383
+ try { section = extractPlanModeSection(skeleton.path) } catch (e) { console.error(e.message); return 2 }
384
+
385
+ let content
386
+ try { content = render(d, section, d.SHELL_SOURCE) } catch (e) { console.error(`render failed ⇒ 停:${e.message}`); return 2 }
387
+
388
+ const rows = countRows(d)
389
+ const entries = countFileEntries(d)
390
+ // ⚠️ 口径不再歧义(独立裁决的次要项):`entries` 是 **`plugins[]` 内的条目数**;
391
+ // 文件级 `- id:` 行数**从渲染结果实测**(含 preset row @4),不写死常数。
392
+ const fileIdLines = content.split('\n').filter((l) => /^\s*- id:/.test(l)).length
393
+ const detail = [
394
+ `rows-detail: ${rows} = ${d.topRows.length} top-level + ${d.groups.length} group + ${d.groups.find((g) => g.id === 'delegation').rows.length} delegation (3 control + ${d.LANES.length} lane)`,
395
+ `plugins-entries: ${entries} \`plugins[]\` entries (the ${entries - rows} extra vs the ${rows} declared rows = planning 1 + compaction 3 children; they DO render)`,
396
+ `file-id-lines: ${fileIdLines} \`- id:\` lines in the rendered file (measured from content; includes the preset row @4, which is outside \`plugins[]\`)`,
397
+ `reference-list: LEGAL_TOOL_NAMES size ${d.LEGAL_TOOL_NAMES.size} — REFERENCE ONLY, not a gate (deny/allow names are NOT validated here)`,
398
+ `SHELL-SOURCE: ${d.SHELL_SOURCE.source} md5=${d.SHELL_SOURCE.md5}`,
399
+ `SHELL-SOURCE-FALLBACK: ${d.SHELL_SOURCE.fallback}`,
400
+ // T13:crosscheck 默认**开**;`--no-crosscheck` 只跳过这一行打印,**不改渲染**。
401
+ // crossCheckShell 自身在 DSH 不可用时 catch 掉并报 `unavailable` ⇒ **不致命**。
402
+ crosscheck ? crossCheckShell(d).line : 'SHELL-SOURCE-CROSSCHECK: skipped (--no-crosscheck)',
403
+ ]
404
+
405
+ if (check) {
406
+ let existing = null
407
+ try { existing = readFileSync(OUT_PATH, 'utf8') } catch { existing = null }
408
+ if (existing === content) { console.log(`ok ${OUT_PATH} (${rows} rows)`); for (const l of detail) console.log(l); return 0 }
409
+ console.log(`stale ${OUT_PATH}${existing === null ? ' (artifact absent)' : ''}`)
410
+ for (const l of detail) console.log(l)
411
+ return 1
412
+ }
413
+
414
+ writeFileSync(OUT_PATH, content)
415
+ console.log(`wrote ${OUT_PATH} (${rows} rows)`)
416
+ for (const l of detail) console.log(l)
417
+ return 0
418
+ }
419
+
420
+ const invokedDirectly =
421
+ process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url
422
+ if (invokedDirectly) {
423
+ main().then((code) => process.exit(code)).catch((e) => { console.error(`fatal: ${e.stack ?? e}`); process.exit(2) })
424
+ }