dsh-paoding 0.4.6 → 0.4.8

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.
@@ -15,23 +15,39 @@
15
15
  * 一条声明行的 config.plugins(restrict 行与 delegation-labels 行的本地
16
16
  * name 换成对应 .mjs 的绝对 file: URL——声明轨没有「相对 preset 目录」
17
17
  * 语义,必须绝对化);
18
- * 3) home patch 托管块手术 upsertHomePatchRows / stripHomePatchRows /
19
- * readHomePatchRows:对 $DSH_HOME/cordis.patch.yml 的 MANAGED 标记块做
20
- * 行级增删查,块外用户内容一字节不动;托管块损坏(缺结束标记)时拒绝
21
- * 写盘并明确报错,与仓库「配置不可信时拒绝落盘」口径一致。
22
- * 4) 轨道迁移对账 backfillHomePatchRows / listOrchestratorPresetIds /
18
+ * 3) patch 托管块手术 upsertHomePatchRows / stripHomePatchRows /
19
+ * readHomePatchRows:对 patch 文件的 MANAGED 标记块做行级增删查,块外用户
20
+ * 内容一字节不动;托管块损坏(缺结束标记)时拒绝写盘并明确报错,与仓库
21
+ * 「配置不可信时拒绝落盘」口径一致。层感知:patchFilePath 按 opts
22
+ * { layer, profileName } 把写盘落到 home 层($DSH_HOME/cordis.patch.yml)或
23
+ * profile 层($DSH_HOME/profiles/<name>/cordis.patch.yml),缺省 home(全部
24
+ * 既有调用点零改动);层判定见 isCompatShellLike / resolvePatchTargetLayer。
25
+ * 每次写盘后整文件 YAML 校验(validatePatchYaml),解析失败即回滚为写入前
26
+ * 字节并报错——Android compat 壳会重置/压平 home 层 patch(dsh-maoying#1
27
+ * 同款问题),坏文件一旦落盘宿主会在任何插件代码运行前死掉,宁可不写。
28
+ * 4) 层判定与迁移 isCompatShellLike / resolvePatchTargetLayer /
29
+ * reconcilePatchLayers:platform=android 或 dshHome 形如 /data/(user|data)/*
30
+ * 的容器壳 → 默认 profile 层(config patch_layer 键 / DSH_PAODING_PATCH_LAYER
31
+ * 环境变量可显式钉死);目标层写成功后 reconcile 撤掉另一层的托管块,
32
+ * 声明行永不跨层重复。
33
+ * 5) 可疑行探测 findSuspectPresetRows:托管块外扫「- id: <显示名 (英文名)>」
34
+ * 形状的坏行(id 含空白或非 ASCII——preset/package id 不会这样),WARN 级
35
+ * 供 doctor / 启动自愈提示,绝不自动删除。
36
+ * 6) 轨道迁移对账 backfillHomePatchRows / listOrchestratorPresetIds /
23
37
  * parseGeneratorMarker / formatGeneratorMarker:宿主只升 dsh 不动插件时
24
38
  * (标记版本一致、轨道翻转或 workflow worker 形态换代——升级最常见路径),
25
39
  * 按磁盘产物补写缺失声明行而不整盘重生成;生成器标记记「版本 + 轨道 +
26
40
  * worker 形态」三行,任一不符即触发自愈(worker 形态换代必须整盘重生成,
27
41
  * 见 detectHostVersionTriple / isPtcWorkerHost)。
28
- * 零运行时依赖(node:child_process / node:fs / node:path / node:url);被 state 引用。
42
+ * 零运行时依赖(node:child_process / node:fs / node:path / node:url;yaml 校验
43
+ * 经兄弟模块 yaml.mjs 的惰性 loadYaml);被 state 引用。
29
44
  */
30
45
  import { spawnSync } from 'node:child_process'
31
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
46
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
32
47
  import path from 'node:path'
33
48
  import { pathToFileURL } from 'node:url'
34
- import { warn } from './util.mjs'
49
+ import { warn, PROFILE_NAME_RE } from './util.mjs'
50
+ import { loadYaml, parseYamlSubset, preprocessPatchText } from './yaml.mjs'
35
51
 
36
52
  /** 托管块起始标记:其后到结束标记之间是本工具自动生成的声明行,用户勿改。 */
37
53
  export const MANAGED_BEGIN = '# --- dsh-paoding presets (auto-generated; do not edit) ---'
@@ -290,16 +306,105 @@ export function buildPresetRowText({ presetId, name = null, description = null,
290
306
  // buildPresetRowText 的 4 空格基准缩进,作 insert 列表元素。块外用户内容除
291
307
  // strip 的空行收敛外一字节不动。
292
308
 
293
- /** home 层 patch 文件路径。 */
294
- function patchFilePath(dshHome) {
295
- return path.join(dshHome, 'cordis.patch.yml')
309
+ // ── patch 层判定与路径(home / profile 双层)────────────────────────────────
310
+ //
311
+ // Android compat 壳(DeepSeekHarnessCompat)会在每次 boot / repair 时把 home 层
312
+ // $DSH_HOME/cordis.patch.yml 重置回出厂种子,还会把声明行的显示元数据压平成
313
+ // 「- id: 显示名 (英文名)」+ 8 空格键的坏行——宿主在任何插件代码运行前就死于
314
+ // YAML 解析(dsh-maoying#1 同款问题)。对策:这类环境默认把托管块写进 profile
315
+ // 层 $DSH_HOME/profiles/<name>/cordis.patch.yml(壳不重置它,且启动自愈每轮
316
+ // 重写可自愈),并撤掉 home 层的遗留块。
317
+
318
+ /**
319
+ * 兼容壳判定(可注入便于测试):platform === 'android',或 dshHomePath 落在
320
+ * Android 容器路径 ^/data/(user|data)/ 下即 true——两者都是 compat 壳会重写
321
+ * home 层 patch 的环境特征。
322
+ */
323
+ export function isCompatShellLike({ platform = process.platform, dshHomePath = null } = {}) {
324
+ if (platform === 'android') return true
325
+ return typeof dshHomePath === 'string' && /^\/data\/(user|data)\//.test(dshHomePath)
326
+ }
327
+
328
+ /** 从 argv 解析显式 profile 名:`--profile <name>` 或 `--profile=<name>`。 */
329
+ function profileNameFromArgv(argv) {
330
+ const args = Array.isArray(argv) ? argv : []
331
+ for (let i = 0; i < args.length; i++) {
332
+ const arg = String(args[i])
333
+ if (arg === '--profile') {
334
+ const next = args[i + 1]
335
+ return typeof next === 'string' && next.trim() !== '' ? next.trim() : null
336
+ }
337
+ const m = /^--profile=(.+)$/.exec(arg)
338
+ if (m) return m[1].trim()
339
+ }
340
+ return null
341
+ }
342
+
343
+ /** 'home' | 'profile' 之外的值一律 null(调用方 warn 后回落)。 */
344
+ const normalizeLayerValue = (value) => (value === 'home' || value === 'profile' ? value : null)
345
+
346
+ /**
347
+ * 解析声明行的目标 patch 层。优先级:
348
+ * 配置键 patch_layer('home' | 'profile')> 环境变量 DSH_PAODING_PATCH_LAYER
349
+ * (测试/诊断钩子)> isCompatShellLike(android / 容器路径 → 'profile')>
350
+ * 缺省 'home'。非法值 warn 后按未设置处理。
351
+ * profileName:argv `--profile` > 配置键 profile > 'web';拼进路径前按
352
+ * PROFILE_NAME_RE 白名单校验(同名口径见 util.mjs),非法回落 'web'——
353
+ * profile 名要拼进 patch 文件路径,不能放过路径穿越形状。
354
+ * 返回 { layer, profileName, patchPath, legacyPatchPath };legacyPatchPath 是
355
+ * 另一层的路径(同 profileName 基准),供 reconcilePatchLayers 迁移对账。
356
+ */
357
+ export function resolvePatchTargetLayer(dshHome, { config = null, argv = process.argv, platform = process.platform } = {}) {
358
+ let layer = normalizeLayerValue(config?.patch_layer)
359
+ if (layer === null && config?.patch_layer !== undefined && config?.patch_layer !== null) {
360
+ warn(`patch_layer: 非法值已忽略(${JSON.stringify(config.patch_layer)}),按自动判定(可选: 'home' / 'profile')`)
361
+ }
362
+ if (layer === null) {
363
+ const fromEnv = normalizeLayerValue(process.env.DSH_PAODING_PATCH_LAYER)
364
+ if (fromEnv === null && process.env.DSH_PAODING_PATCH_LAYER !== undefined && process.env.DSH_PAODING_PATCH_LAYER !== '') {
365
+ warn(`DSH_PAODING_PATCH_LAYER: 非法值已忽略(${JSON.stringify(process.env.DSH_PAODING_PATCH_LAYER)}),按自动判定(可选: 'home' / 'profile')`)
366
+ }
367
+ layer = fromEnv
368
+ }
369
+ if (layer === null) layer = isCompatShellLike({ platform, dshHomePath: dshHome }) ? 'profile' : 'home'
370
+
371
+ let profileName = profileNameFromArgv(argv)
372
+ if (profileName === null && typeof config?.profile === 'string' && config.profile.trim() !== '') profileName = config.profile.trim()
373
+ if (profileName === null) profileName = 'web'
374
+ if (!PROFILE_NAME_RE.test(profileName)) {
375
+ warn(`profile 名 ${JSON.stringify(profileName)} 不合法(首字符字母数字,其余 [A-Za-z0-9._-]),patch 层回落 'web'`)
376
+ profileName = 'web'
377
+ }
378
+ return {
379
+ layer,
380
+ profileName,
381
+ patchPath: patchFilePath(dshHome, { layer, profileName }),
382
+ legacyPatchPath: patchFilePath(dshHome, { layer: layer === 'profile' ? 'home' : 'profile', profileName }),
383
+ }
384
+ }
385
+
386
+ /** 手术 opts { layer?, profileName? } → 规范形状(缺省 home 层,全部旧调用点零改动)。 */
387
+ function patchOptsOf(opts = {}) {
388
+ const layer = opts?.layer === 'profile' ? 'profile' : 'home'
389
+ return { layer, profileName: typeof opts?.profileName === 'string' && opts.profileName !== '' ? opts.profileName : 'web' }
390
+ }
391
+
392
+ /**
393
+ * patch 文件路径:home 层 $DSH_HOME/cordis.patch.yml;profile 层
394
+ * $DSH_HOME/profiles/<profileName>/cordis.patch.yml(父目录在写盘时创建)。
395
+ */
396
+ function patchFilePath(dshHome, opts = {}) {
397
+ const { layer, profileName } = patchOptsOf(opts)
398
+ return layer === 'profile'
399
+ ? path.join(dshHome, 'profiles', profileName, 'cordis.patch.yml')
400
+ : path.join(dshHome, 'cordis.patch.yml')
296
401
  }
297
402
 
298
- /** 读 home patch 为行数组;文件缺失当空文件。行尾换行符风格随原文件保留。 */
299
- function readPatchLines(dshHome) {
403
+ /** 读 patch 为行数组;文件缺失当空文件。行尾换行符风格随原文件保留。 */
404
+ function readPatchLines(dshHome, opts = {}) {
300
405
  let text = ''
301
406
  try {
302
- text = readFileSync(patchFilePath(dshHome), 'utf8')
407
+ text = readFileSync(patchFilePath(dshHome, opts), 'utf8')
303
408
  } catch (err) {
304
409
  if (err?.code !== 'ENOENT') throw err
305
410
  return { lines: [], eol: '\n' }
@@ -308,9 +413,116 @@ function readPatchLines(dshHome) {
308
413
  return { lines: text.split(/\r?\n/), eol: text.includes('\r\n') ? '\r\n' : '\n' }
309
414
  }
310
415
 
311
- function writePatchLines(dshHome, lines, eol) {
312
- mkdirSync(dshHome, { recursive: true })
313
- writeFileSync(patchFilePath(dshHome), lines.join(eol), 'utf8')
416
+ function writePatchLines(dshHome, opts, lines, eol) {
417
+ const target = patchFilePath(dshHome, opts)
418
+ mkdirSync(path.dirname(target), { recursive: true })
419
+ writeFileSync(target, lines.join(eol), 'utf8')
420
+ }
421
+
422
+ /**
423
+ * 整文件 YAML 校验(写盘后的回滚判据,doctor 整文件解析检查同源复用):
424
+ * 宿主的 yaml 包($DSH_HOME/node_modules,经 loadYaml 惰性加载)可用时以它为
425
+ * 准——宿主用什么解析我们就用什么验证;!!js 表达式标签按 state.mjs 同款技巧
426
+ * 中和成 true(校验只关心结构合法性,不求值表达式)。yaml 包不可用时退回
427
+ * 内置 patch 子集解析器(lenient,挡不住坏缩进,仅兜 gross 损坏)。
428
+ * 返回 { ok: true } 或 { ok: false, message }(message 含解析器报的行号)。
429
+ */
430
+ export function validatePatchYaml(dshHome, text) {
431
+ const yamlMod = loadYaml(dshHome)
432
+ if (yamlMod) {
433
+ try {
434
+ yamlMod.parse(String(text).replace(/!!js\s+[^\r\n]*/g, 'true'))
435
+ return { ok: true }
436
+ } catch (err) {
437
+ return { ok: false, message: err?.message ?? String(err) }
438
+ }
439
+ }
440
+ try {
441
+ parseYamlSubset(preprocessPatchText(text))
442
+ return { ok: true }
443
+ } catch (err) {
444
+ return { ok: false, message: err?.message ?? String(err) }
445
+ }
446
+ }
447
+
448
+ /**
449
+ * 写盘 + 整文件校验 + 失败回滚。写前记下原字节,写后把整文件过
450
+ * validatePatchYaml;解析失败即把原字节原样写回(写入前不存在则删掉残留),
451
+ * 再抛点名文件、解析错误(含行号)与两类可能原因的明确错误——坏文件绝不
452
+ * 留在盘上(compat 壳环境里宿主会在任何插件代码运行前死于解析)。
453
+ */
454
+ function writePatchLinesValidated(dshHome, opts, lines, eol) {
455
+ const target = patchFilePath(dshHome, opts)
456
+ let before = null
457
+ let readError = null
458
+ try {
459
+ before = readFileSync(target, 'utf8')
460
+ } catch (err) {
461
+ if (err?.code !== 'ENOENT') readError = err
462
+ }
463
+ writePatchLines(dshHome, opts, lines, eol)
464
+ let verdict = { ok: false, message: '写入后文件不可读' }
465
+ if (readError === null) {
466
+ try {
467
+ verdict = validatePatchYaml(dshHome, readFileSync(target, 'utf8'))
468
+ } catch (err) {
469
+ verdict = { ok: false, message: err?.message ?? String(err) }
470
+ }
471
+ } else {
472
+ verdict = { ok: false, message: `写入前原文件已不可读(${readError.message}),拒绝覆盖写` }
473
+ }
474
+ if (verdict.ok) return
475
+ // 回滚为写入前的精确字节(写入前不存在 → 删除本次新建的残留)。
476
+ let restoreNote = '文件已回滚为写入前内容,一字节未变'
477
+ try {
478
+ if (before === null) rmSync(target, { force: true })
479
+ else writeFileSync(target, before, 'utf8')
480
+ } catch (restoreErr) {
481
+ restoreNote = `回滚失败(${restoreErr?.message ?? restoreErr}),请从备份/手改恢复后再试`
482
+ }
483
+ const lineHint = /line (\d+)/i.exec(verdict.message)
484
+ throw new Error(
485
+ `写入 ${target} 后 YAML 校验失败,${restoreNote}。解析错误${lineHint ? `(约第 ${lineHint[1]} 行)` : ''}: ` +
486
+ verdict.message.replace(/\s+/g, ' ').trim() +
487
+ '\n可能原因: (a) 文件在写入前已含坏块(compat 壳重写/手改)→ 运行 `dsh-paoding doctor` 检查并清理坏行;' +
488
+ '(b) paoding 自身产物缺陷 → 请报 issue(附该文件内容)',
489
+ )
490
+ }
491
+
492
+ /**
493
+ * 目标层写盘成功后对另一层做迁移对账:撤掉 legacy 层的整个托管块(只撤不补
494
+ * ——声明行永不跨层重复,复用 stripHomePatchRows 的整块摘除逻辑)。目标层为
495
+ * home 时对称撤 profile 层遗留块。返回是否实际撤过。
496
+ */
497
+ export function reconcilePatchLayers(dshHome, opts = {}) {
498
+ const { layer, profileName } = patchOptsOf(opts)
499
+ return stripHomePatchRows(dshHome, null, { layer: layer === 'profile' ? 'home' : 'profile', profileName })
500
+ }
501
+
502
+ /**
503
+ * 托管块外扫可疑声明行(maoying 坏行形状,dsh-maoying#1):`- id: <值>` 单行
504
+ * 且值含空白或非 ASCII 字符——preset / package id 永远是紧凑 ASCII(如
505
+ * `preset-orchestrator`),「- id: 内阁首辅 (Orchestrator)」这种是显示名被
506
+ * 压平进 id 位的表现。托管块内的行不算(自家声明行有 `- insert:` 包裹 + 4 空
507
+ * 格缩进 id,形状本就不同且在块内);托管块损坏(只有起始标记)时按整文件块
508
+ * 外处理。返回 [{ lineNo, id }](lineNo 1 起)。WARN 级探测,绝不自动删除。
509
+ */
510
+ export function findSuspectPresetRows(text) {
511
+ const lines = String(text ?? '').split(/\r?\n/)
512
+ let outside = lines.map((_, i) => i)
513
+ try {
514
+ const span = findManagedSpan(lines)
515
+ if (span !== null) outside = outside.filter((i) => i < span.begin || i > span.end)
516
+ } catch {
517
+ /* 托管块损坏:整文件按块外处理 */
518
+ }
519
+ const suspects = []
520
+ for (const i of outside) {
521
+ const m = /^[ \t]*-[ \t]+id:[ \t]*(\S.*\S)[ \t]*$/.exec(lines[i])
522
+ if (!m) continue
523
+ if (/\s/.test(m[1]) || /[^\x00-\x7F]/.test(m[1])) suspects.push({ lineNo: i + 1, id: m[1] })
524
+ }
525
+ return suspects
314
526
  }
315
527
 
316
528
  /**
@@ -388,11 +600,13 @@ function collapseBlankRuns(lines) {
388
600
  }
389
601
 
390
602
  /**
391
- * 把若干声明行(buildPresetRowText 产物)行级 upsert 进 home patch 托管块。
603
+ * 把若干声明行(buildPresetRowText 产物)行级 upsert 进 patch 托管块。
392
604
  * 托管块不存在则文末新建(非空文件先补尾换行、块前空一行);已存在则块内按
393
605
  * rowId 替换 / 追加,块外用户内容一字节不动。托管块损坏时 throw(不写盘)。
606
+ * 尾参 opts = { layer?, profileName? }(缺省 home 层,旧调用点零改动);写盘
607
+ * 后整文件校验、失败回滚(见 writePatchLinesValidated)。
394
608
  */
395
- export function upsertHomePatchRows(dshHome, rowTexts) {
609
+ export function upsertHomePatchRows(dshHome, rowTexts, opts = {}) {
396
610
  const rows = (Array.isArray(rowTexts) ? rowTexts : [])
397
611
  .filter((t) => typeof t === 'string' && t.trim() !== '')
398
612
  if (rows.length === 0) return
@@ -407,7 +621,7 @@ export function upsertHomePatchRows(dshHome, rowTexts) {
407
621
  // 同 rowId 传多条时后者生效(Map 去重,保证替换语义而非重复追加)
408
622
  const wantedById = new Map(wanted.map((w) => [w.rowId, w]))
409
623
 
410
- const { lines, eol } = readPatchLines(dshHome)
624
+ const { lines, eol } = readPatchLines(dshHome, opts)
411
625
  const span = findManagedSpan(lines) // 损坏块 throw,直接中止(未写任何盘)
412
626
  if (span !== null) {
413
627
  const segments = parseRowSegments(lines.slice(span.begin + 1, span.end))
@@ -420,7 +634,7 @@ export function upsertHomePatchRows(dshHome, rowTexts) {
420
634
  if (!segments.some((s) => s.rowId === w.rowId)) merged.push(...w.lines) // 新行追加到块尾
421
635
  }
422
636
  const next = [...lines.slice(0, span.begin), MANAGED_BEGIN, ...merged, MANAGED_END, ...lines.slice(span.end + 1)]
423
- writePatchLines(dshHome, next, eol)
637
+ writePatchLinesValidated(dshHome, opts, next, eol)
424
638
  return
425
639
  }
426
640
  // 无托管块:文末新建。非空文件先补尾换行(若缺)。块前空一行不再单独推:
@@ -432,23 +646,24 @@ export function upsertHomePatchRows(dshHome, rowTexts) {
432
646
  out.push(MANAGED_BEGIN)
433
647
  for (const w of wanted) out.push(...w.lines)
434
648
  out.push(MANAGED_END, '')
435
- writePatchLines(dshHome, out, eol)
649
+ writePatchLinesValidated(dshHome, opts, out, eol)
436
650
  }
437
651
 
438
652
  /**
439
- * 从 home patch 托管块撤掉指定 rowId 的声明行段;rowIdsOrAll 传 null 删整块
653
+ * 从 patch 托管块撤掉指定 rowId 的声明行段;rowIdsOrAll 传 null 删整块
440
654
  * (含起止标记与块前自加的空行分隔,用户内容回到插入前的字节形态)。
441
655
  * 块不存在 / 指定行都不在场返回 false(未写盘);托管块损坏时 warn 后返回
442
656
  * false(撤行是锦上添花的自愈路径,不值得为此中断安装,修复由 upsert 的
443
- * 明确报错引导)。
657
+ * 明确报错引导)。尾参 opts 同 upsertHomePatchRows;写盘同样过校验回滚。
444
658
  */
445
- export function stripHomePatchRows(dshHome, rowIdsOrAll = null) {
659
+ export function stripHomePatchRows(dshHome, rowIdsOrAll = null, opts = {}) {
660
+ const target = patchFilePath(dshHome, opts)
446
661
  let lines
447
662
  let eol
448
663
  try {
449
- ({ lines, eol } = readPatchLines(dshHome))
664
+ ({ lines, eol } = readPatchLines(dshHome, opts))
450
665
  } catch (err) {
451
- warn(`cordis.patch.yml 读取失败,声明行撤除跳过: ${err?.message ?? err}`)
666
+ warn(`${target} 读取失败,声明行撤除跳过: ${err?.message ?? err}`)
452
667
  return false
453
668
  }
454
669
  let span = null
@@ -474,7 +689,7 @@ export function stripHomePatchRows(dshHome, rowIdsOrAll = null) {
474
689
  const next = [...lines.slice(0, start), ...lines.slice(span.end + 1)]
475
690
  // 文件撤空(只剩空行)就落空文件,不留一撮换行尾巴
476
691
  const empty = next.every((l) => l.trim() === '')
477
- writePatchLines(dshHome, empty ? [] : collapseBlankRuns(next), eol)
692
+ writePatchLinesValidated(dshHome, opts, empty ? [] : collapseBlankRuns(next), eol)
478
693
  return true
479
694
  }
480
695
  // 块内还有其余 preset 的行:只摘指定段,标记与块结构保留
@@ -484,19 +699,20 @@ export function stripHomePatchRows(dshHome, rowIdsOrAll = null) {
484
699
  if (!removeIds.has(seg.rowId)) merged.push(...trimTrailingBlankLines(seg.lines))
485
700
  }
486
701
  const next = [...lines.slice(0, span.begin), MANAGED_BEGIN, ...merged, MANAGED_END, ...lines.slice(span.end + 1)]
487
- writePatchLines(dshHome, collapseBlankRuns(next), eol)
702
+ writePatchLinesValidated(dshHome, opts, collapseBlankRuns(next), eol)
488
703
  return true
489
704
  }
490
705
 
491
706
  /**
492
- * 读 home patch 托管块里现有的声明行 [{ rowId, presetId }](presetId 取自声明
707
+ * 读 patch 托管块里现有的声明行 [{ rowId, presetId }](presetId 取自声明
493
708
  * 行 config 的 id: 值,8 空格缩进;解析不出为 null)。文件缺失 / 无托管块 /
494
709
  * 托管块损坏一律返回空数组——调用方只拿它做「块在不在」的判断。
710
+ * 尾参 opts 同 upsertHomePatchRows(layer=profile 时读 profile 层)。
495
711
  */
496
- export function readHomePatchRows(dshHome) {
712
+ export function readHomePatchRows(dshHome, opts = {}) {
497
713
  let lines
498
714
  try {
499
- ({ lines } = readPatchLines(dshHome))
715
+ ({ lines } = readPatchLines(dshHome, opts))
500
716
  } catch {
501
717
  return []
502
718
  }
@@ -600,7 +816,7 @@ export function detectAgentRolesFlavor(agentYmlText) {
600
816
  }
601
817
 
602
818
  /**
603
- * 轨道迁移对账:把 $DSH_HOME/cordis.patch.yml MANAGED 块里缺失的编排类 preset
819
+ * 轨道迁移对账:把目标层 patch 文件 MANAGED 块里缺失的编排类 preset
604
820
  * 声明行按磁盘产物补写回来。只补「行缺失」:行在则零写盘(内容对账是
605
821
  * ensurePresetInstalled 的整盘重生成路径,不在这里做)。
606
822
  *
@@ -609,10 +825,10 @@ export function detectAgentRolesFlavor(agentYmlText) {
609
825
  * 0.2.1 而磁盘还是内联形状——补出去的声明行会让 0.2.1 宿主挂编排 preset 的
610
826
  * 新建会话直接被 tool-subagent 守卫拒绝。此时跳过该 preset 并 warn(可整盘
611
827
  * 重生成修复),绝不按旧产物写行;反方向(宿主降级、磁盘已是新形状)同理。
612
- * 守卫经 DSH_PAODING_FAKE_HOST_VERSION 可测。
828
+ * 守卫经 DSH_PAODING_FAKE_HOST_VERSION 可测。尾参 opts 同 upsertHomePatchRows。
613
829
  */
614
- export function backfillHomePatchRows(dshHome, presetIds) {
615
- const rowsOnDisk = readHomePatchRows(dshHome)
830
+ export function backfillHomePatchRows(dshHome, presetIds, opts = {}) {
831
+ const rowsOnDisk = readHomePatchRows(dshHome, opts)
616
832
  const wanted = new Set((Array.isArray(presetIds) ? presetIds : []).filter((id) => ORCHESTRATOR_PRESET_ID_RE.test(id)))
617
833
  const hostRolesFlavor = presetRolesFlavorOf(detectHostVersionTriple())
618
834
  const rows = []
@@ -657,10 +873,10 @@ export function backfillHomePatchRows(dshHome, presetIds) {
657
873
  warn(`声明行补写跳过 ${presetId}(产物不全,可整盘重生成修复): ${err?.message ?? err}`)
658
874
  }
659
875
  }
660
- if (rows.length > 0) upsertHomePatchRows(dshHome, rows)
876
+ if (rows.length > 0) upsertHomePatchRows(dshHome, rows, opts)
661
877
  // 反向对账:托管块里有行、磁盘上已无对应目录的(用户手删目录等),行同步撤下
662
878
  const orphanRowIds = rowsOnDisk.filter((r) => r.presetId !== null && !wanted.has(r.presetId)).map((r) => r.rowId)
663
- if (orphanRowIds.length > 0) stripHomePatchRows(dshHome, orphanRowIds)
879
+ if (orphanRowIds.length > 0) stripHomePatchRows(dshHome, orphanRowIds, opts)
664
880
  return backfilled
665
881
  }
666
882
 
@@ -5,7 +5,9 @@
5
5
  * saveConfig,支持按工作区落到 orchestrator-<slug> 独立 preset 目录并把条目
6
6
  * 并进配置 workspaces 段;配置读取失败 / 状态不可信时拒绝破坏性落盘,成功后
7
7
  * 回收孤儿工作区 preset;并按 presetSystem 双轨收尾——0.1.7+ 声明行轨把声明行
8
- * upsert 进 home patch 托管块,≤0.1.6 目录扫描轨撤掉残留声明块自愈)。
8
+ * upsert 进目标层 patch 托管块(层判定见 preset-system.resolvePatchTargetLayer:
9
+ * Android compat 壳 / patch_layer 钉层落 profile 层,写后 reconcile 撤 home 遗留块),
10
+ * ≤0.1.6 目录扫描轨撤掉残留声明块自愈)。
9
11
  * install.mjs 的公共导出(collectState / generateAndInstall)即来自本模块。
10
12
  * 依赖 util / yaml / config / host / skills / spans / compose / alloc /
11
13
  * workspaces / preset-system 与 node:fs / node:path / node:url;被 cli 与
@@ -54,6 +56,8 @@ import {
54
56
  presetRowId,
55
57
  readHomePatchRows,
56
58
  readPresetDirMeta,
59
+ reconcilePatchLayers,
60
+ resolvePatchTargetLayer,
57
61
  stripHomePatchRows,
58
62
  upsertHomePatchRows,
59
63
  } from './preset-system.mjs'
@@ -369,6 +373,12 @@ export function generateAndInstall(state, assignments, { dryRun = false, saveCon
369
373
  throw new Error(`现有配置 ${configFile} 读取失败,已中止(未做任何落盘): ${err.message}`)
370
374
  }
371
375
 
376
+ // 声明行目标 patch 层解析一次(prev 配置在场:patch_layer / profile 键生效;
377
+ // argv 缺省取本进程——Android compat 壳环境自动落 profile 层,见
378
+ // preset-system.resolvePatchTargetLayer 的层判定链)。
379
+ const patchLayerTarget = resolvePatchTargetLayer(dshHome, { config: prev })
380
+ const patchOpts = { layer: patchLayerTarget.layer, profileName: patchLayerTarget.profileName }
381
+
372
382
  if (wsPath !== null) {
373
383
  // slug 取自磁盘配置的 workspaces 键集合 ∪ 本路径(prev 已在上方统一读取):
374
384
  // 整组统一分配才能让同 basename 的冲突方都拿到稳定的 hash 后缀。
@@ -489,11 +499,13 @@ export function generateAndInstall(state, assignments, { dryRun = false, saveCon
489
499
  const next = wsPath === null
490
500
  ? {
491
501
  profile: effectiveProfile,
502
+ patch_layer: prev?.patch_layer ?? null,
492
503
  ...normalizeTargetFields(assignments),
493
504
  workspaces: prev?.workspaces ?? {},
494
505
  }
495
506
  : {
496
507
  profile: prev?.profile ?? effectiveProfile,
508
+ patch_layer: prev?.patch_layer ?? null,
497
509
  ...normalizeTargetFields(prev),
498
510
  workspaces: { ...(prev?.workspaces ?? {}), [wsPath]: normalizeTargetFields(assignments) },
499
511
  }
@@ -525,10 +537,10 @@ export function generateAndInstall(state, assignments, { dryRun = false, saveCon
525
537
  continue
526
538
  }
527
539
  if (presetSystem === 'declarative') {
528
- // 声明行轨:目录回收了,home patch 托管块里的对应声明行同步撤下——
540
+ // 声明行轨:目录回收了,目标层 patch 托管块里的对应声明行同步撤下——
529
541
  // 目录与行必须同生同灭,否则 0.1.7 宿主会挂出一个指向已删目录的 preset。
530
542
  try {
531
- stripHomePatchRows(dshHome, [presetRowId(name)])
543
+ stripHomePatchRows(dshHome, [presetRowId(name)], patchOpts)
532
544
  } catch (err) {
533
545
  warn(`孤儿 preset 的声明行撤除失败(${presetRowId(name)}): ${err?.message ?? err}`)
534
546
  }
@@ -538,11 +550,13 @@ export function generateAndInstall(state, assignments, { dryRun = false, saveCon
538
550
 
539
551
  // ── 预设安装双轨收尾(wrote=true 才动;dry-run 零副作用)────────────────
540
552
  // declarative(0.1.7+):目录扫描机制已删,必须把本 preset 以一条 `- insert:`
541
- // 声明行写进 home patch 托管块才算安装完成(preset 元信息与写 preset.yml 同
553
+ // 声明行写进目标层 patch 托管块才算安装完成(preset 元信息与写 preset.yml 同
542
554
  // 源:显示名取 displayName ?? SRC preset.yml 的 name,description / order 取
543
555
  // SRC;restrict.mjs / delegation-labels.mjs / role-restrict.mjs 用绝对 file: URL——声明行没有
544
556
  // 「相对 preset 目录」语义;role-restrict URL 仅供 0.2.1+ AgentPreset 形态产物的角色面行消费)。
545
- // directory(≤0.1.6):上面落盘即完成;home patch 里若有残留托管声明块
557
+ // 目标层为 profile(compat 壳 / patch_layer 钉层)时,写成功后顺手撤掉 home
558
+ // 层的遗留托管块(reconcilePatchLayers)——声明行永不跨层重复。
559
+ // directory(≤0.1.6):上面落盘即完成;目标层 patch 里若有残留托管声明块
546
560
  //(宿主刚从 0.1.7 降级等场景)整块撤下自愈——残留声明行会让 profile 启动失败。
547
561
  if (wrote) {
548
562
  if (presetSystem === 'declarative') {
@@ -558,10 +572,11 @@ export function generateAndInstall(state, assignments, { dryRun = false, saveCon
558
572
  delegationLabelsFileUrl: pathToFileURL(path.join(dstDir, 'delegation-labels.mjs')).href,
559
573
  roleRestrictFileUrl: pathToFileURL(path.join(dstDir, 'role-restrict.mjs')).href,
560
574
  }),
561
- ])
562
- } else if (readHomePatchRows(dshHome).length > 0) {
563
- stripHomePatchRows(dshHome, null)
564
- warn('cordis.patch.yml 检出 dsh-paoding 声明块,但当前宿主是目录扫描轨(DSH ≤ 0.1.6),已整块撤下(残留声明行会导致 profile 启动失败)')
575
+ ], patchOpts)
576
+ if (patchOpts.layer === 'profile') reconcilePatchLayers(dshHome, patchOpts)
577
+ } else if (readHomePatchRows(dshHome, patchOpts).length > 0) {
578
+ stripHomePatchRows(dshHome, null, patchOpts)
579
+ warn(`${patchLayerTarget.patchPath} 检出 dsh-paoding 声明块,但当前宿主是目录扫描轨(DSH ≤ 0.1.6),已整块撤下(残留声明行会导致 profile 启动失败)`)
565
580
  }
566
581
  }
567
582