dsh-paoding 0.4.5 → 0.4.7

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/tools/lib/cli.mjs CHANGED
@@ -11,18 +11,31 @@
11
11
  * host(removedReason)、state(PRESET_ASIS_FILES / collectState /
12
12
  * generateAndInstall)、alloc(autoAssignments,--auto 语义与插件首装共享)、
13
13
  * health(captureWarnings / healthCheck,--dry-run 报告的结构化体检)、
14
- * doctor(runDoctor,doctor 子命令的只读体检)、version(getVersionInfo,
15
- * main 收尾的新版提示)以及 node:os / node:path。
14
+ * doctor(runDoctor,doctor 子命令的只读体检)、lifecycle(uninstall 与
15
+ * doctor --fix 共用的清理原语与判据)、version(getVersionInfo,main 收尾的
16
+ * 新版提示)、yaml(loadYaml,--fix 的配置对账读法)以及 node:os / node:path /
17
+ * node:readline/promises。
16
18
  */
17
19
  import { homedir } from 'node:os'
18
20
  import path from 'node:path'
21
+ import readline from 'node:readline/promises'
19
22
  import { PROFILE_NAME_RE, ROLES, SRC_DIR } from './util.mjs'
20
23
  import { normalizeRoleName } from './config.mjs'
24
+ import { loadYaml } from './yaml.mjs'
21
25
  import { removedReason } from './host.mjs'
22
26
  import { PRESET_ASIS_FILES, collectState, generateAndInstall } from './state.mjs'
23
27
  import { autoAssignments } from './alloc.mjs'
24
28
  import { captureWarnings, healthCheck } from './health.mjs'
25
29
  import { runDoctor } from './doctor.mjs'
30
+ import {
31
+ cleanupPaodingArtifacts,
32
+ foreignPresetDirsRemaining,
33
+ loadConfigForReconcile,
34
+ orphanManagedRows,
35
+ planUninstall,
36
+ removeConfigAndBackups,
37
+ workspacePresetOrphans,
38
+ } from './lifecycle.mjs'
26
39
  import { getVersionInfo } from './version.mjs'
27
40
 
28
41
  // ── CLI ─────────────────────────────────────────────────────────────────────
@@ -40,6 +53,7 @@ export function printHelp() {
40
53
  Usage:
41
54
  node tools/install.mjs [options]
42
55
  node tools/install.mjs doctor [options]
56
+ node tools/install.mjs uninstall [options]
43
57
 
44
58
  Options:
45
59
  --profile <name> Profile whose patch layer is scanned (default: web).
@@ -57,6 +71,11 @@ Options:
57
71
  --suggest Fresh install without a config file: seed smart defaults
58
72
  from the detected host/MCP tools instead of the base
59
73
  template (host tools are folded in automatically).
74
+ --purge uninstall 专用:连配置文件与其 .bak* 备份一起删(默认保留)。
75
+ --yes uninstall / doctor --fix 专用:跳过逐类确认直接执行;
76
+ 非交互终端(非 TTY)下没带 --yes 时只列计划不落盘,退出码 2。
77
+ --fix doctor 专用:体检后自动修两类可安全处理的问题(孤儿工作区
78
+ 预设目录、孤儿/过期声明行),其余发现只给处理建议不代做。
60
79
  --help Show this help and exit.
61
80
 
62
81
  Subcommands:
@@ -64,6 +83,13 @@ Subcommands:
64
83
  preset track vs host version, managed patch rows, orphan
65
84
  workspace presets, config drift, version marker) and a
66
85
  summary; exit 1 only on error-level findings.
86
+ doctor --fix 体检并清理:只自动修 --fix 所述两类,输出逐项标注
87
+ 「已修 / 需手动」;托管块清理写盘前自动备份 .bak-1。
88
+ uninstall 清理庖丁写进宿主目录的东西:cordis.patch.yml 托管声明块、
89
+ .agent-presets 下的 orchestrator* 预设目录、.generator-version
90
+ 标记。默认保留 dsh-paoding.config.yml 及其备份(用户数据),
91
+ --purge 才连配置一起删。不碰插件包自身——包本体用
92
+ dsh plugin remove dsh-paoding 卸载。
67
93
 
68
94
  Environment:
69
95
  DSH_HOME Directory holding cordis.patch.yml etc. (default: $HOME/.dsh)
@@ -84,14 +110,17 @@ function assertProfileName(value) {
84
110
  }
85
111
 
86
112
  export function parseArgs(argv) {
87
- const opts = { command: 'install', profile: null, patches: [], dryRun: false, help: false, auto: false, config: undefined, suggest: false }
113
+ const opts = { command: 'install', profile: null, patches: [], dryRun: false, help: false, auto: false, config: undefined, suggest: false, purge: false, yes: false, fix: false }
88
114
  for (let i = 0; i < argv.length; i++) {
89
115
  const arg = argv[i]
90
116
  if (arg === 'doctor') {
91
- // 子命令:doctor = 只读体检;缺省 install(原 installer 主流程)。位置参数
92
- // 只认这一个,其余仍按未知选项报错;--help / --profile / --config 等照常
93
- // 解析,doctor 分支共用。
117
+ // 子命令:doctor = 只读体检(--fix 升级为「体检并修」);缺省 install
118
+ //(原 installer 主流程)。位置参数只认 doctor / uninstall 两个,其余仍按
119
+ // 未知选项报错;--help / --profile / --config 等照常解析,子命令分支共用。
94
120
  opts.command = 'doctor'
121
+ } else if (arg === 'uninstall') {
122
+ // 子命令:uninstall = 清理庖丁写进宿主目录的东西(不动插件包自身)。
123
+ opts.command = 'uninstall'
95
124
  } else if (arg === '--help' || arg === '-h') {
96
125
  opts.help = true
97
126
  } else if (arg === '--dry-run') {
@@ -101,6 +130,16 @@ export function parseArgs(argv) {
101
130
  } else if (arg === '--suggest') {
102
131
  // fresh 安装时用智能默认(自动纳入检测到的 host 工具)而非基础模板。
103
132
  opts.suggest = true
133
+ } else if (arg === '--purge') {
134
+ // 仅 uninstall:连配置文件与其备份一起删(默认保留用户配置)。
135
+ opts.purge = true
136
+ } else if (arg === '--yes') {
137
+ // uninstall / doctor --fix:跳过逐类确认直接执行;非 TTY 无 --yes 时
138
+ // 只列计划不落盘(防呆)。
139
+ opts.yes = true
140
+ } else if (arg === '--fix') {
141
+ // 仅 doctor:对体检发现里可安全自动修的两类动手(孤儿目录、孤儿声明行)。
142
+ opts.fix = true
104
143
  } else if (arg === '--config') {
105
144
  const value = argv[++i]
106
145
  if (value === undefined || value.startsWith('--')) {
@@ -266,6 +305,230 @@ export function printDoctorReport(report, { dshHome, configFile }) {
266
305
  }
267
306
  }
268
307
 
308
+ // ── 生命周期清理(uninstall / doctor --fix)────────────────────────────────
309
+ //
310
+ // 「怎么删」全部在 lib/lifecycle.mjs(判据与 doctor 同源);这里只做三件事:
311
+ // 计划打印、确认口径(--yes 直干 / TTY 逐类问 / 非 TTY 防呆拒做)、结果汇报。
312
+
313
+ /** 非 TTY(管道 / 重定向)下逐类确认问不了,只能列计划等显式 --yes。 */
314
+ function stdinIsTTY() {
315
+ return Boolean(process.stdin?.isTTY)
316
+ }
317
+
318
+ /** 逐类确认(仅 TTY 调用):回车 / 非 y 一律视为拒绝——删东西默认说不。 */
319
+ async function confirm(question) {
320
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
321
+ try {
322
+ const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase()
323
+ return answer === 'y' || answer === 'yes'
324
+ } finally {
325
+ rl.close()
326
+ }
327
+ }
328
+
329
+ /** 包本体卸载提示(uninstall 收尾专用):插件包的卸载通道只有 dsh plugin
330
+ * remove,本命令绝不碰自己的包目录。 */
331
+ function printPluginRemovalHint() {
332
+ console.log()
333
+ console.log('收尾:插件包本体走插件通道卸载——')
334
+ console.log(' dsh plugin remove dsh-paoding')
335
+ console.log('(desktop profile 由 Electron 守卫拦着,请在 DSH 面板 / 宿主侧处理)')
336
+ }
337
+
338
+ /** uninstall 的计划打印(防呆拒做时同一份,见 runUninstall)。 */
339
+ function printUninstallPlan(plan, { purge }) {
340
+ console.log('dsh-paoding uninstall — 清理庖丁写进宿主目录的东西(不碰插件包自身)')
341
+ console.log(` DSH_HOME : ${plan.dshHome}`)
342
+ console.log(` 配置文件 : ${plan.configFile}`)
343
+ console.log()
344
+ console.log('将清理:')
345
+ if (plan.blockPresent && !plan.blockCorrupt) {
346
+ console.log(` - ${plan.patchFile} 里的托管声明块(${plan.blockRowCount} 条声明行;写前备份 ${path.basename(plan.patchFile)}.bak-1)`)
347
+ }
348
+ if (plan.blockCorrupt) {
349
+ console.log(' - 托管声明块已损坏(有起始标记无结束标记):自动清理做不了,需手工整块删除或补回结束标记')
350
+ }
351
+ if (plan.presetDirs.length > 0 || plan.markerExists) {
352
+ const markerNote = plan.markerExists ? '(含版本标记 .generator-version)' : ''
353
+ console.log(` - 预设目录 ${plan.presetDirs.join('、') || '(无)'}${markerNote}`)
354
+ }
355
+ if (purge && (plan.configExists || plan.configBackups.length > 0)) {
356
+ const backups = plan.configBackups.length > 0 ? ` 及 ${plan.configBackups.length} 个 .bak* 备份` : ''
357
+ console.log(` - 配置文件 ${plan.configFile}${backups}(--purge)`)
358
+ }
359
+ console.log('将保留:')
360
+ if (!purge && (plan.configExists || plan.configBackups.length > 0)) {
361
+ console.log(` - 配置文件 ${plan.configFile}${plan.configBackups.length > 0 ? ` 及 ${plan.configBackups.length} 个 .bak* 备份` : ''}(用户数据;--purge 才删)`)
362
+ }
363
+ const foreign = foreignPresetDirsRemaining(plan.dshHome)
364
+ if (foreign.length > 0) {
365
+ console.log(` - .agent-presets 下非庖丁目录:${foreign.join('、')}`)
366
+ }
367
+ }
368
+
369
+ /** uninstall 主流程,返回退出码。 */
370
+ async function runUninstall({ dshHome, configFile, purge, yes }) {
371
+ const plan = planUninstall({ dshHome, configFile })
372
+
373
+ // 无事可做:不打扰确认(反正一个字节都不会动),直接交代现状。
374
+ const hasWork =
375
+ plan.blockPresent ||
376
+ plan.presetDirs.length > 0 ||
377
+ plan.markerExists ||
378
+ (purge && (plan.configExists || plan.configBackups.length > 0))
379
+ if (!hasWork) {
380
+ console.log('dsh-paoding uninstall — 宿主目录里没有庖丁的安装产物(托管声明块、orchestrator* 预设目录、版本标记均不在场),无事可做。')
381
+ if (plan.configExists) console.log(`配置已保留在 ${configFile},确认不要可手动删(连同 .bak* 备份)。`)
382
+ printPluginRemovalHint()
383
+ return 0
384
+ }
385
+
386
+ printUninstallPlan(plan, { purge })
387
+
388
+ // 防呆:非交互终端问不了,宁可不做——列出计划,等显式 --yes。
389
+ if (!yes && !stdinIsTTY()) {
390
+ console.error()
391
+ console.error('当前不是交互终端且未加 --yes:只列计划不执行,重跑时加 --yes 确认。')
392
+ return 2
393
+ }
394
+
395
+ // 逐类确认(--yes 跳过;损坏块不进确认——自动路径反正删不动,按需手工报)。
396
+ let doBlock = plan.blockPresent && !plan.blockCorrupt
397
+ let doDirs = plan.presetDirs.length > 0
398
+ let doConfig = purge && (plan.configExists || plan.configBackups.length > 0)
399
+ if (!yes) {
400
+ if (doBlock) doBlock = await confirm(`移除 ${plan.patchFile} 里的庖丁托管声明块?`)
401
+ if (doDirs) doDirs = await confirm(`删除预设目录 ${plan.presetDirs.join('、')}?`)
402
+ if (doConfig) {
403
+ doConfig = await confirm(
404
+ `删除配置文件及其备份(${configFile}${plan.configBackups.length > 0 ? ` + ${plan.configBackups.length} 个 .bak*` : ''})?`,
405
+ )
406
+ }
407
+ }
408
+
409
+ const cleanup = cleanupPaodingArtifacts(dshHome, {
410
+ stripBlock: doBlock,
411
+ removeDirIds: doDirs ? plan.presetDirs : [],
412
+ })
413
+
414
+ console.log()
415
+ if (plan.blockCorrupt) {
416
+ console.log(`⚠ 托管声明块损坏(有起始标记无结束标记),自动清理已跳过——请手工补回结束标记或整块删除 ${plan.patchFile}`)
417
+ } else if (doBlock) {
418
+ if (cleanup.blockRemoved) {
419
+ console.log(`✓ 已移除托管声明块${cleanup.backupPath ? `(原文件备份:${cleanup.backupPath})` : ''}`)
420
+ } else {
421
+ console.log(`⚠ 托管声明块未能移除,请检查 ${plan.patchFile}`)
422
+ }
423
+ }
424
+ if (doDirs && cleanup.removedDirs.length > 0) {
425
+ console.log(`✓ 已删除预设目录:${cleanup.removedDirs.join('、')}${plan.markerExists ? ',版本标记 .generator-version 随目录一并删除' : ''}`)
426
+ }
427
+ if (cleanup.markerRemoved) {
428
+ console.log('✓ 已删除版本标记 .generator-version')
429
+ }
430
+ for (const failure of cleanup.dirFailures) {
431
+ console.log(`⚠ 预设目录删除失败:${failure.id}(${failure.error})——请手工处理`)
432
+ }
433
+
434
+ if (purge) {
435
+ if (doConfig) {
436
+ const removed = removeConfigAndBackups(configFile)
437
+ if (removed.length > 0) {
438
+ console.log(`✓ 已删除配置文件及其备份(${removed.length} 个文件)`)
439
+ }
440
+ } else if (plan.configExists || plan.configBackups.length > 0) {
441
+ console.log(`ℹ 配置未删(未确认):${configFile}`)
442
+ }
443
+ } else if (plan.configExists || plan.configBackups.length > 0) {
444
+ console.log(`ℹ 配置已保留在 ${configFile},确认不要可手动删(连同 .bak* 备份)。`)
445
+ }
446
+
447
+ printPluginRemovalHint()
448
+
449
+ const failed =
450
+ cleanup.dirFailures.length > 0 ||
451
+ plan.blockCorrupt ||
452
+ (doBlock && !cleanup.blockRemoved)
453
+ return failed ? 1 : 0
454
+ }
455
+
456
+ /** doctor --fix 对「预设漂移」这类体检行文的补充建议:各行 detail 多数自带
457
+ * 处理建议,这里只补体检行文没点到的 --auto 路径,不逐行复读。 */
458
+ const DOCTOR_FIX_HINTS = {
459
+ 预设漂移: '(或运行 dsh-paoding --auto 重新生成)',
460
+ }
461
+
462
+ /** doctor --fix 主流程,返回退出码:全量只读体检(runDoctor,与不带 --fix 的
463
+ * doctor 同一份诊断)→ 可修两类动手 → 逐项标注「已修 / 需手动」。可修判据
464
+ * 与 doctor 同源(lifecycle 的孤儿目录 / 孤儿声明行两个函数),不另立标准。 */
465
+ async function runDoctorFix({ dshHome, configFile, profile, yes }) {
466
+ const report = await runDoctor({ dshHome, configFile, profile })
467
+
468
+ // 配置存在但不可解析时目录对账不可信(doctor 该项同样降级 skip)——只报不修。
469
+ const reconcile = loadConfigForReconcile(configFile, loadYaml(dshHome))
470
+ const dirsReconcilable = !reconcile.exists || reconcile.usable
471
+ const orphanDirIds = dirsReconcilable ? workspacePresetOrphans(dshHome, reconcile.config).orphans : []
472
+ const orphanRowIds = orphanManagedRows(dshHome).map((row) => row.rowId)
473
+
474
+ let doDirs = orphanDirIds.length > 0
475
+ let doRows = orphanRowIds.length > 0
476
+
477
+ // 防呆:非交互终端问不了——只列将修什么,等显式 --yes(退出码 2)。
478
+ const planOnly = !yes && !stdinIsTTY()
479
+ if (!planOnly && !yes) {
480
+ if (doDirs) doDirs = await confirm(`删除孤儿预设目录 ${orphanDirIds.join('、')}?`)
481
+ if (doRows) doRows = await confirm(`撤下孤儿声明行 ${orphanRowIds.join('、')}?`)
482
+ }
483
+
484
+ const cleanup = planOnly
485
+ ? null
486
+ : cleanupPaodingArtifacts(dshHome, {
487
+ pruneRowIds: doRows ? orphanRowIds : [],
488
+ removeDirIds: doDirs ? orphanDirIds : [],
489
+ })
490
+ const dirsFixed = cleanup !== null && doDirs && cleanup.removedDirs.length > 0
491
+ const rowsFixed = cleanup !== null && doRows && cleanup.prunedRowIds.length > 0
492
+
493
+ const marks = { ok: '✔', error: '✘', warn: '⚠', skip: '–' }
494
+ console.log(planOnly
495
+ ? 'dsh-paoding doctor --fix — 体检完成,以下为可自动修复项(未执行)'
496
+ : 'dsh-paoding doctor --fix — 体检并清理可自动修复的问题')
497
+ console.log(` DSH_HOME : ${dshHome}`)
498
+ console.log(` 配置文件 : ${configFile}`)
499
+ console.log()
500
+ let fixedCount = 0
501
+ let manualCount = 0
502
+ for (const item of report.items) {
503
+ const dirsHit = item.label === '工作区预设目录' && /孤儿目录|全部视为孤儿/.test(item.detail) && (dirsFixed || (planOnly && doDirs))
504
+ const rowsHit = item.label === '声明行托管块' && /孤儿声明行/.test(item.detail) && (rowsFixed || (planOnly && doRows))
505
+ if (dirsHit) {
506
+ fixedCount++
507
+ const what = planOnly ? orphanDirIds.join('、') : cleanup.removedDirs.join('、')
508
+ const rowNote = planOnly ? '' : ';受影响声明行已同步撤下'
509
+ console.log(`✓ ${planOnly ? '将修' : '已修'} ${item.label} — ${planOnly ? '将删除' : '已删除'}孤儿目录 ${what}${rowNote}`)
510
+ } else if (rowsHit) {
511
+ fixedCount++
512
+ const what = planOnly ? orphanRowIds.join('、') : cleanup.prunedRowIds.join('、')
513
+ const backupNote = planOnly ? '' : `(patch 原文件备份:${cleanup.backupPath ?? '无'})`
514
+ console.log(`✓ ${planOnly ? '将修' : '已修'} ${item.label} — ${planOnly ? '将撤下' : '已撤下'}孤儿声明行 ${what}${backupNote}`)
515
+ } else if (item.status === 'warn' || item.status === 'error') {
516
+ manualCount++
517
+ const hint = DOCTOR_FIX_HINTS[item.label] ?? ''
518
+ console.log(`${marks[item.status] ?? '·'} 需手动 ${item.label} — ${item.detail}${hint}`)
519
+ } else {
520
+ console.log(`${marks[item.status] ?? '·'} ${item.label} — ${item.detail}`)
521
+ }
522
+ }
523
+ console.log()
524
+ if (planOnly) {
525
+ console.error('当前不是交互终端且未加 --yes:只列将修什么不执行,重跑时加 --yes 确认。')
526
+ return 2
527
+ }
528
+ console.log(`汇总: ${report.items.length} 项检查,已修 ${fixedCount} 项${manualCount > 0 ? `,需手动 ${manualCount} 项(见 ⚠/✘ 行)` : ''}`)
529
+ return report.hasErrors ? 1 : 0
530
+ }
531
+
269
532
  // ── main ────────────────────────────────────────────────────────────────────
270
533
 
271
534
  /**
@@ -287,16 +550,43 @@ export async function main({ argv = process.argv.slice(2) } = {}) {
287
550
  return 0
288
551
  }
289
552
 
290
- // doctor 子命令:只读体检,不走 --auto 门禁(门禁仅拦会动盘的安装主流程),
291
- // 也不用 collectState 的全量检测——runDoctor 内部按项自行探测与降级。
553
+ // 旗标与子命令的配属校验:装错地方的旗标直接报错(与未知选项同款 exit 1),
554
+ // 不静默忽略——静默会让用户以为 --purge / --fix 生效了。
555
+ if (opts.fix && opts.command !== 'doctor') {
556
+ console.error('error: --fix 仅 doctor 子命令支持')
557
+ return 1
558
+ }
559
+ if (opts.purge && opts.command !== 'uninstall') {
560
+ console.error('error: --purge 仅 uninstall 子命令支持')
561
+ return 1
562
+ }
563
+ if (opts.yes && !(opts.command === 'uninstall' || (opts.command === 'doctor' && opts.fix))) {
564
+ console.error('error: --yes 仅 uninstall / doctor --fix 支持')
565
+ return 1
566
+ }
567
+
568
+ // doctor 子命令:只读体检(--fix 升级为「体检并修」),不走 --auto 门禁
569
+ //(门禁仅拦会动盘的安装主流程),也不用 collectState 的全量检测——runDoctor
570
+ // 内部按项自行探测与降级。
292
571
  if (opts.command === 'doctor') {
293
572
  const dshHome = process.env.DSH_HOME || path.join(homedir(), '.dsh')
294
573
  const configFile = opts.config || path.join(dshHome, 'dsh-paoding.config.yml')
574
+ if (opts.fix) {
575
+ return runDoctorFix({ dshHome, configFile, profile: opts.profile, yes: opts.yes })
576
+ }
295
577
  const report = await runDoctor({ dshHome, configFile, profile: opts.profile })
296
578
  printDoctorReport(report, { dshHome, configFile })
297
579
  return report.hasErrors ? 1 : 0
298
580
  }
299
581
 
582
+ // uninstall 子命令:清理庖丁写进宿主目录的东西(托管块 / 预设目录 / 标记),
583
+ // 确认口径与退出码见 runUninstall。
584
+ if (opts.command === 'uninstall') {
585
+ const dshHome = process.env.DSH_HOME || path.join(homedir(), '.dsh')
586
+ const configFile = opts.config || path.join(dshHome, 'dsh-paoding.config.yml')
587
+ return runUninstall({ dshHome, configFile, purge: opts.purge, yes: opts.yes })
588
+ }
589
+
300
590
  // 无 --auto 直接拒绝(交互模式已删):--dry-run 只读不动盘,放行作检测预览。
301
591
  // 门禁放在 collectState 之前:被拒路径不做任何检测 / 握手,快速失败。
302
592
  if (!opts.auto && !opts.dryRun) {
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * dsh-paoding.config.yml 键规范化与读写:main_agent_display_name /
3
- * roles.<toolName>.name /
3
+ * patch_layer(声明行落盘层 'home' | 'profile',缺省自动判定,非法值 warn 回
4
+ * 未设置)/ roles.<toolName>.name /
4
5
  * roles.<toolName>.model / roles.<toolName>.provider(角色专用模型,生成层映射到
5
6
  * dsh-tool-subagent 的 agentOptions)/ roles.<toolName>.start_mode(角色启动方式,
6
7
  * 生成层把内置委派块的块级 provider 行改写为 fork,'fresh' | 'fork')/
@@ -212,11 +213,26 @@ export function normalizeConfig(data) {
212
213
  }
213
214
  return {
214
215
  profile: typeof data.profile === 'string' && data.profile !== '' ? data.profile : null,
216
+ patch_layer: normalizePatchLayer(data.patch_layer),
215
217
  ...normalizeTargetFields(data),
216
218
  workspaces: normalizeWorkspaces(data.workspaces),
217
219
  }
218
220
  }
219
221
 
222
+ /**
223
+ * 声明行落盘层(顶层键 patch_layer,仅 'home' | 'profile' 两个合法值):缺省 /
224
+ * null = 按环境自动判定(Android compat 壳 / 容器路径走 profile 层,其余 home,
225
+ * 判定在 preset-system.resolvePatchTargetLayer);非法值(类型或取值不对)warn
226
+ * 后当未设置——配置错了降级提醒而非中断加载,与 background_mode / start_mode
227
+ * 的非法值口径一致。normalize 后恒为 'home' | 'profile' | null。
228
+ */
229
+ function normalizePatchLayer(value) {
230
+ if (value === null || value === undefined || value === '') return null
231
+ if (value === 'home' || value === 'profile') return value
232
+ warn(`patch_layer: 非法值已忽略(${JSON.stringify(value)}),按自动判定(可选: 'home' / 'profile')`)
233
+ return null
234
+ }
235
+
220
236
  /**
221
237
  * 工作区条目段(顶层键 workspaces)规范化:只收 trim 后非空的字符串键(键 =
222
238
  * 工作区目录绝对路径),值过 normalizeTargetFields;值不是映射、键为空串则
@@ -359,6 +375,123 @@ export function stripNonTargetKeys(assignments) {
359
375
  return out
360
376
  }
361
377
 
378
+ // ── 目标字段差分(面板 preview / apply 响应的 diff 字段)──────────────────────
379
+
380
+ /** 序列差集:added = 新表按新序取不在旧表的项;removed = 旧表按旧序取不在新表的项。 */
381
+ function listDiff(oldList, newList) {
382
+ const oldSet = new Set(oldList)
383
+ const newSet = new Set(newList)
384
+ return {
385
+ added: newList.filter((n) => !oldSet.has(n)),
386
+ removed: oldList.filter((n) => !newSet.has(n)),
387
+ }
388
+ }
389
+
390
+ /** 合并去重保序(toolsAdded / toolsRemoved 由 extra 与 remove 两路合并而来)。 */
391
+ function dedupKeepOrder(list) {
392
+ return [...new Set(list)]
393
+ }
394
+
395
+ /** 技能映射等值:键集合一致且各键下角色列表逐项一致(顺序敏感,与落盘序一致)。 */
396
+ function skillsMapEq(a, b) {
397
+ const ak = Object.keys(a)
398
+ if (ak.length !== Object.keys(b).length || !ak.every((k) => k in b)) return false
399
+ return ak.every((k) => a[k].length === b[k].length && a[k].every((v, i) => v === b[k][i]))
400
+ }
401
+
402
+ /**
403
+ * 目标字段集合差分(纯函数,零文案——返回纯结构化结果,展示串一律由客户端
404
+ * i18n 渲染)。两侧先各过一次 normalizeTargetFields(未知键 / profile /
405
+ * workspaces 段天然被忽略,手写未规范化的输入也得到与落盘一致的口径),再按
406
+ * 维度对比:
407
+ * - mainExtra:main_agent_extra 列表的增删;
408
+ * - mainAgent:主 agent 四维——工具增删(extra 增删 ∪ remove 增删的合并视图:
409
+ * 「新增 remove 项」即工具减少、「撤销 remove 项」即工具恢复)、persona
410
+ * (main_agent_persona_extra,null 与 '' 语义不同:默认常量 vs 追加空串);
411
+ * model 恒 false(主 agent 无专用模型配置键,字段只为与角色条目同形方便
412
+ * 客户端统一渲染);skills(main_agent_skills / main_agent_skills_inline /
413
+ * skills 映射三者任一变化——角色级技能分配没有别的位置可报,并入此布尔);
414
+ * - roles:按角色名(键)对齐,新增 / 删除 / 变更三态;变更条目带六个子维度
415
+ * (tools 增删——null 与空表同视、persona、model(含 provider 路由)、
416
+ * start_mode、background_mode)。roles.<x>.name(角色显示名)不在对比维度。
417
+ * - rolesRemove:内置角色删除名单的增删。
418
+ * changed = 任一子维度有变化。oldAssign 传 null(磁盘无配置)按全空目标字段算,
419
+ * 即首次保存时一切都算「新增」。
420
+ */
421
+ export function diffAssignments(oldAssign, newAssign) {
422
+ const oldT = normalizeTargetFields(oldAssign ?? {})
423
+ const newT = normalizeTargetFields(newAssign ?? {})
424
+
425
+ // 主 agent:extra 增删 + remove 增删合并成同一对工具增删视图
426
+ const extraDiff = listDiff(oldT.main_agent_extra, newT.main_agent_extra)
427
+ const removeDiff = listDiff(oldT.main_agent_remove, newT.main_agent_remove)
428
+ const toolsAdded = dedupKeepOrder([...extraDiff.added, ...removeDiff.removed])
429
+ const toolsRemoved = dedupKeepOrder([...extraDiff.removed, ...removeDiff.added])
430
+ const personaChanged = (oldT.main_agent_persona_extra ?? null) !== (newT.main_agent_persona_extra ?? null)
431
+ const softSkillDiff = listDiff(oldT.main_agent_skills, newT.main_agent_skills)
432
+ const hardSkillDiff = listDiff(oldT.main_agent_skills_inline, newT.main_agent_skills_inline)
433
+ const skillsChanged =
434
+ !skillsMapEq(oldT.skills, newT.skills) ||
435
+ softSkillDiff.added.length > 0 || softSkillDiff.removed.length > 0 ||
436
+ hardSkillDiff.added.length > 0 || hardSkillDiff.removed.length > 0
437
+ const mainAgent = { toolsAdded, toolsRemoved, personaChanged, modelChanged: false, skillsChanged }
438
+
439
+ // 角色按名对齐:旧键序在前、新独有键序在后(新增角色在结果里的顺序可预期)
440
+ const roles = []
441
+ const roleNames = [
442
+ ...Object.keys(oldT.roles),
443
+ ...Object.keys(newT.roles).filter((n) => !(n in oldT.roles)),
444
+ ]
445
+ for (const name of roleNames) {
446
+ const o = oldT.roles[name]
447
+ const n = newT.roles[name]
448
+ if (o && !n) { roles.push({ name, op: 'removed' }); continue }
449
+ if (!o && n) { roles.push({ name, op: 'added' }); continue }
450
+ const tools = listDiff(o.tools, n.tools)
451
+ const changedRole = {
452
+ name,
453
+ op: 'changed',
454
+ toolsAdded: tools.added,
455
+ toolsRemoved: tools.removed,
456
+ // provider 与 model 同属「角色专用模型」维度(LLM 路由成对生效)
457
+ personaChanged: (o.persona ?? null) !== (n.persona ?? null),
458
+ modelChanged: (o.model ?? null) !== (n.model ?? null) || (o.provider ?? null) !== (n.provider ?? null),
459
+ startModeChanged: o.start_mode !== n.start_mode,
460
+ backgroundModeChanged: o.background_mode !== n.background_mode,
461
+ }
462
+ if (
463
+ changedRole.toolsAdded.length > 0 ||
464
+ changedRole.toolsRemoved.length > 0 ||
465
+ changedRole.personaChanged ||
466
+ changedRole.modelChanged ||
467
+ changedRole.startModeChanged ||
468
+ changedRole.backgroundModeChanged
469
+ ) {
470
+ roles.push(changedRole)
471
+ }
472
+ }
473
+
474
+ const rolesRemove = listDiff(oldT.roles_remove, newT.roles_remove)
475
+ const changed =
476
+ toolsAdded.length > 0 ||
477
+ toolsRemoved.length > 0 ||
478
+ personaChanged ||
479
+ skillsChanged ||
480
+ roles.length > 0 ||
481
+ rolesRemove.added.length > 0 ||
482
+ rolesRemove.removed.length > 0 ||
483
+ extraDiff.added.length > 0 ||
484
+ extraDiff.removed.length > 0
485
+
486
+ return {
487
+ changed,
488
+ mainAgent,
489
+ roles,
490
+ rolesRemove: { added: rolesRemove.added, removed: rolesRemove.removed },
491
+ mainExtra: { added: extraDiff.added, removed: extraDiff.removed },
492
+ }
493
+ }
494
+
362
495
  /**
363
496
  * 目标字段集合(roles → skills,除
364
497
  * profile / workspaces 外的全部顶层键)写出体:pad 为该层缩进前缀(顶层 ''、workspaces.<path> 条目里
@@ -489,6 +622,12 @@ function writeWorkspaces(lines, workspaces) {
489
622
  export function serializeConfig(config) {
490
623
  const lines = ['# dsh-paoding(庖丁)安装配置 —— 可手编;改后在侧栏底部「庖丁配置」入口应用,或重跑 node tools/install.mjs --auto']
491
624
  lines.push(`profile: ${config.profile ?? 'web'}`)
625
+ // patch_layer 显式钉层才落盘(null/缺省 = 自动判定,输出与旧版逐字节一致);
626
+ // 落盘它才能在 loadConfig → saveConfig 往返中保住用户手编的层钉(否则面板
627
+ // 一保存该键就被抹掉,层判定退回自动)。
628
+ if (config.patch_layer === 'home' || config.patch_layer === 'profile') {
629
+ lines.push(`patch_layer: ${config.patch_layer}`)
630
+ }
492
631
  writeTargetFields(lines, '', config)
493
632
  // workspaces 段可选:键缺失 / 空对象整段省略(不含 workspaces 的配置输出与
494
633
  // 旧版逐字节一致)。