dsh-vibe-math 2.7.3 → 2.8.1

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.
Files changed (106) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +682 -682
  3. package/README.md +683 -683
  4. package/docs/AUDIT-CHECKLIST.md +758 -744
  5. package/docs/COMPAT-AUDIT-ROUND2.md +351 -351
  6. package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
  7. package/docs/audit-methodology.md +93 -0
  8. package/docs/final-paper.md +172 -172
  9. package/docs/formal-verification.md +613 -588
  10. package/docs/generate_framework_diagram_v2.py +114 -114
  11. package/docs/generate_framework_diagram_v3.py +127 -127
  12. package/docs/math-computation.md +195 -194
  13. package/docs/release-notes/README.en.md +2 -0
  14. package/docs/release-notes/README.md +2 -0
  15. package/docs/release-notes/RELEASE-NOTES-2.0.22.md +112 -112
  16. package/docs/release-notes/RELEASE-NOTES-2.1.0.md +143 -143
  17. package/docs/release-notes/RELEASE-NOTES-2.2.0.md +266 -266
  18. package/docs/release-notes/RELEASE-NOTES-2.2.1.md +43 -43
  19. package/docs/release-notes/RELEASE-NOTES-2.2.2.md +88 -88
  20. package/docs/release-notes/RELEASE-NOTES-2.3.0.md +207 -207
  21. package/docs/release-notes/RELEASE-NOTES-2.3.1.md +134 -134
  22. package/docs/release-notes/RELEASE-NOTES-2.3.10.md +105 -105
  23. package/docs/release-notes/RELEASE-NOTES-2.3.11.md +57 -57
  24. package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -80
  25. package/docs/release-notes/RELEASE-NOTES-2.3.2.md +145 -145
  26. package/docs/release-notes/RELEASE-NOTES-2.3.3.md +115 -115
  27. package/docs/release-notes/RELEASE-NOTES-2.3.4.md +69 -69
  28. package/docs/release-notes/RELEASE-NOTES-2.3.5.md +63 -63
  29. package/docs/release-notes/RELEASE-NOTES-2.3.6.md +66 -66
  30. package/docs/release-notes/RELEASE-NOTES-2.3.7.md +59 -59
  31. package/docs/release-notes/RELEASE-NOTES-2.3.8.md +45 -45
  32. package/docs/release-notes/RELEASE-NOTES-2.3.9.md +70 -70
  33. package/docs/release-notes/RELEASE-NOTES-2.8.0.en.md +65 -0
  34. package/docs/release-notes/RELEASE-NOTES-2.8.0.md +65 -0
  35. package/docs/release-notes/RELEASE-NOTES-2.8.1.en.md +49 -0
  36. package/docs/release-notes/RELEASE-NOTES-2.8.1.md +49 -0
  37. package/docs/release-playbook.md +111 -0
  38. package/docs/slv-playbook.md +117 -0
  39. package/docs/test-timing.md +158 -157
  40. package/docs//346/236/266/346/236/204/345/233/276.md +77 -77
  41. package/installer.js +792 -792
  42. package/package.json +18 -3
  43. package/prompt-corpus-v2/formal-verify-v2.json +2 -2
  44. package/prompt-corpus-v2/formal-verify-v2.md +4 -0
  45. package/prompt-corpus-v3/formal-verify-v3.json +7 -7
  46. package/prompt-corpus-v3/formal-verify-v3.md +14 -0
  47. package/prompt-corpus-v5/prompt-corpus-v5.json +61 -61
  48. package/prompt-corpus-v5/prompt-corpus-v5.md +122 -0
  49. package/scripts/clean-temp.mjs +234 -0
  50. package/scripts/release-check.mjs +181 -0
  51. package/tests/audit-engine-faces.mjs +298 -0
  52. package/tests/audit-formal-sensitivity.mjs +345 -342
  53. package/tests/audit-installer-compat.test.mjs +256 -256
  54. package/tests/audit-math-computation-sensitivity.mjs +1 -1
  55. package/tests/audit-persona-sensitivity.mjs +323 -323
  56. package/tests/audit-persona-surface.test.mjs +431 -431
  57. package/tests/audit-prompt-duplication.mutants.mjs +3 -2
  58. package/tests/audit-prompt-invariants.mjs +589 -546
  59. package/tests/audit-registration.mjs +78 -78
  60. package/tests/audit-spec-traceability.mjs +193 -193
  61. package/tests/audit-status-report-fields.mjs +172 -172
  62. package/tests/audit-v5-integrity.mjs +795 -795
  63. package/tests/audit-v5-sensitivity.mjs +433 -432
  64. package/tests/e2e-v5-round2.test.mjs +2453 -2426
  65. package/tests/formal-verify-v2.test.mjs +1806 -1800
  66. package/tests/formal-verify-v3.test.mjs +1757 -1750
  67. package/tests/formal-verify-v4.test.mjs +2114 -2106
  68. package/tests/formal-verify-v5.test.mjs +774 -767
  69. package/tests/math-computation-discovery.mutants.mjs +3 -3
  70. package/tests/math-computation-shared.test.mjs +122 -5
  71. package/tests/math-computation-v2.test.mjs +14 -2
  72. package/tests/math-computation-v3.test.mjs +14 -2
  73. package/tests/math-computation-v4.test.mjs +739 -739
  74. package/tests/prompt-v5-integrity.test.mjs +1291 -1291
  75. package/tests/release-check.mutants.mjs +78 -0
  76. package/tests/run-tests.mjs +393 -332
  77. package/tests/selfdrive-v5.mjs +560 -560
  78. package/tests/temp-hygiene.mutants.mjs +80 -0
  79. package/tests/v2-fix-probes.mutants.mjs +4 -0
  80. package/tests/v2-fix-probes.test.mjs +1034 -1033
  81. package/tests/v2v3-interrupt.mutants.mjs +111 -0
  82. package/tests/v3-fix-probes.mutants.mjs +99 -95
  83. package/tests/v3-fix-probes.test.mjs +891 -890
  84. package/tests/v4-final-paper.mutants.mjs +116 -0
  85. package/tests/v4-final-paper.test.mjs +628 -0
  86. package/tests/v5-institute-fixes.mutants.mjs +218 -190
  87. package/vibe-math-v2/agent.cordis.yml +376 -376
  88. package/vibe-math-v2/math-computation.js +17 -15
  89. package/vibe-math-v2/preset.yml +2 -2
  90. package/vibe-math-v2/v2/347/232/204/351/242/235/345/244/226/350/246/201/346/261/202.txt +21 -21
  91. package/vibe-math-v2/vibe-math-v2.js +5078 -5011
  92. package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +486 -486
  93. package/vibe-math-v3/agent.cordis.yml +420 -420
  94. package/vibe-math-v3/math-computation.js +17 -15
  95. package/vibe-math-v3/preset.yml +2 -2
  96. package/vibe-math-v3/vibe-math-v3.js +6215 -6149
  97. package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +649 -649
  98. package/vibe-math-v4/agent.cordis.yml +438 -438
  99. package/vibe-math-v4/math-computation.js +17 -15
  100. package/vibe-math-v4/preset.yml +2 -2
  101. package/vibe-math-v4/vibe-math-v4.js +4520 -4422
  102. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1015 -1015
  103. package/vibe-math-v5/math-computation.js +17 -15
  104. package/vibe-math-v5/vibe-math-v5.js +93 -11
  105. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1728
  106. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
package/installer.js CHANGED
@@ -1,793 +1,793 @@
1
- // dsh-vibe-math merged bundle installer — VERSIONED AUTO-UPDATE.
2
- // When this bundle is installed (e.g. `dsh plugin --profile <name> add dsh-vibe-math`, or from the
3
- // dsh-market), this plugin copies ALL FOUR agent presets out of the package into
4
- // the DSH preset root, so the user immediately gets four presets in the picker:
5
- // vibe-math-v2/ (probability-driven architecture)
6
- // vibe-math-v3/ (THIRD-generation: paper-style Markdown knowledge base +
7
- // planner-agent scheduling + universal theory/method library)
8
- // vibe-math-v4/ (FOURTH-generation: persistent self-organizing resident
9
- // subagents — message bus / meetings / unanimous-consensus
10
- // verification / per-resident libraries)
11
- // vibe-math-v5/ (FIFTH-generation: research institute — academician who
12
- // assigns work, permanent researchers who vote, temp workers,
13
- // group chat + meetings + m-vote consensus)
14
- //
15
- // (vibe-math-v1 — the classic pipeline — was removed at v2.0.0; this bundle now
16
- // ships v2/v3/v4/v5.)
17
- //
18
- // UPDATE POLICY (state recorded in <presetRoot>/.vibe-math-installed.json):
19
- // - FORCE-REPLACE ON VERSION CHANGE. When the recorded version differs from this package's
20
- // version — or there is no record at all (an install made by an older installer) — every
21
- // managed file is overwritten with the shipped bytes. This is deliberately NOT conditional on
22
- // the file being unmodified. Two reasons:
23
- // · a preset assembled from two different versions (the old policy updated a file's
24
- // neighbours and kept the file the user had touched) is exactly the state that fails to
25
- // mount or misbehaves subtly, and the user has no way to see that from the outside;
26
- // · editing a shipped preset in place is not the supported way to customize one — DSH
27
- // provides a real one (copy the preset: the picker's copy action, or a new directory
28
- // under <presetRoot>), which leaves the managed set updatable.
29
- // - Nothing is destroyed silently: before a file whose bytes are not what the installer last
30
- // wrote is REPLACED — or DELETED along with a preset this bundle no longer ships — the user's
31
- // copy is kept under <presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file> and named in
32
- // the log. A file that still matches the hash recorded for it is a copy of the package, so
33
- // removing it destroys nothing and needs no backup.
34
- // - Files are written ATOMICALLY: a uniquely named temp file is written and then renamed over the
35
- // destination, so an interrupted boot (a kill, a full disk, two sessions writing at once) can
36
- // never leave a half-written preset for the next same-version run to mistake for a user edit.
37
- // - same version: no-op (idempotent) — restarting DSH never rewrites a preset file, never churns
38
- // the preset's generation stamp and never rewrites this state file (it is written only when its
39
- // content actually changes). Missing files are ALWAYS restored, at any version.
40
- // - force a full refresh at any time: delete the preset dirs and restart DSH.
41
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync, rmdirSync } from 'node:fs'
42
- import { createRequire } from 'node:module'
43
- import { createHash } from 'node:crypto'
44
- import { homedir } from 'node:os'
45
- import { dirname, join } from 'node:path'
46
- import { fileURLToPath } from 'node:url'
47
-
48
- export const name = 'vibe-math-preset-installer'
49
-
50
- // Exported so the shipped policy suite can prove the managed list still covers what each preset
51
- // needs at runtime (a file present in the preset directory but missing here is copied by nobody,
52
- // and the installed preset then cannot mount).
53
- export const PRESETS = [
54
- {
55
- src: 'vibe-math-v2',
56
- dst: 'vibe-math-v2',
57
- // math-computation.js + math-engines.js are PER-PRESET copies of the shared module (they must be
58
- // listed here: on the <=0.1.6 line only these files are copied into .agent-presets/<id>/, and the
59
- // preset JS imports them relatively). guard: tests/audit-math-computation-parity.mjs
60
- files: ['agent.cordis.yml', 'preset.yml', 'vibe-math-v2.js', '实现方案.md', 'math-computation.js', 'math-engines.js'],
61
- },
62
- {
63
- src: 'vibe-math-v3',
64
- dst: 'vibe-math-v3',
65
- files: ['agent.cordis.yml', 'preset.yml', 'vibe-math-v3.js', '实现方案.md', 'math-computation.js', 'math-engines.js'],
66
- },
67
- {
68
- src: 'vibe-math-v4',
69
- dst: 'vibe-math-v4',
70
- files: ['agent.cordis.yml', 'preset.yml', 'vibe-math-v4.js', '实现方案.md', 'math-computation.js', 'math-engines.js'],
71
- },
72
- {
73
- src: 'vibe-math-v5',
74
- dst: 'vibe-math-v5',
75
- // 架构图.md belongs here for the same reason 实现方案.md does: the installer's policy is to put
76
- // the preset's documentation next to the preset, and the shipped v5 directory carries both.
77
- files: ['agent.cordis.yml', 'preset.yml', 'vibe-math-v5.js', '实现方案.md', '架构图.md', 'math-computation.js', 'math-engines.js'],
78
- },
79
- ]
80
-
81
- const STATE_FILE = '.vibe-math-installed.json'
82
- // where a replaced user edit is preserved; a leading dot keeps DSH's preset discovery from ever
83
- // treating it as a preset directory (ids must match [a-z0-9][a-z0-9-]*)
84
- const BACKUP_DIR = '.vibe-math-backup'
85
-
86
- function sha256(buf) { return createHash('sha256').update(buf).digest('hex') }
87
-
88
- let presetTmpSeq = 0
89
- /**
90
- * Write one preset file ATOMICALLY: a uniquely named temp sibling, then a rename over the
91
- * destination. An in-place `writeFileSync` can be interrupted (a killed or crashed DSH, a full disk,
92
- * two sessions writing the same path) and leave a TRUNCATED preset behind — and the next
93
- * same-version boot would take those bytes for the user's own edit and never repair them (a
94
- * same-version run only restores MISSING files). The pid plus a per-process sequence make the temp
95
- * name unique, so concurrent writers cannot rename each other's half-written file. A leftover temp
96
- * (possible only after SIGKILL between the two calls) is inert: DSH reads the named preset files.
97
- */
98
- function writePresetFile(dest, buf) {
99
- const tmp = dest + '.' + process.pid + '.' + (presetTmpSeq++) + '.vibe-math-tmp'
100
- try {
101
- writeFileSync(tmp, buf)
102
- renameSync(tmp, dest)
103
- } catch (e) {
104
- try { unlinkSync(tmp) } catch (e2) { /* nothing of ours to clean up */ }
105
- throw e
106
- }
107
- }
108
-
109
- /**
110
- * Preserve one file that is about to be replaced by the shipped version, under
111
- * `<presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file>`.
112
- *
113
- * Returns `'written'` (a copy was made now), `'kept'` (a copy for this version was already there —
114
- * the earliest edit is the one worth keeping, and a re-run must not overwrite it with an
115
- * already-replaced file) or `'failed'`. A caller must not report `'kept'` as a failure: the user's
116
- * bytes are preserved, just from an earlier run.
117
- */
118
- function backupReplacedFile(presetRoot, fromVersion, presetDir, fileName, buf) {
119
- try {
120
- const dir = join(presetRoot, BACKUP_DIR, String(fromVersion || 'unversioned'), presetDir)
121
- const dst = join(dir, fileName)
122
- if (existsSync(dst)) return 'kept' // the earliest copy for this version is the one worth keeping
123
- mkdirSync(dir, { recursive: true })
124
- writeFileSync(dst, buf)
125
- return 'written'
126
- } catch (e) {
127
- return 'failed' // a backup failure must never stop the update; it is reported by the caller
128
- }
129
- }
130
-
131
- function readState(path) {
132
- try {
133
- const raw = readFileSync(path, 'utf8')
134
- const obj = JSON.parse(raw)
135
- if (obj && typeof obj === 'object' && obj.files && typeof obj.files === 'object') return obj
136
- } catch (e) { /* missing or corrupt — treat as no state (baseline) */ }
137
- return null
138
- }
139
-
140
- /**
141
- * Does the new payload equal the recorded one? Compared field by field (never as JSON text) so key
142
- * ORDER cannot fake a change, and the timestamp is ignored on purpose: `updatedAt` records when the
143
- * file was written, not that anything happened.
144
- */
145
- function sameStatePayload(previous, next) {
146
- if (!previous || !next) return false
147
- if (String(previous.version || '') !== String(next.version || '')) return false
148
- const a = previous.files || {}
149
- const b = next.files || {}
150
- const ka = Object.keys(a).sort()
151
- const kb = Object.keys(b).sort()
152
- if (ka.length !== kb.length) return false
153
- for (let i = 0; i < ka.length; i++) {
154
- if (ka[i] !== kb[i]) return false
155
- const ra = a[ka[i]] || {}
156
- const rb = b[kb[i]] || {}
157
- if (String(ra.hash || '') !== String(rb.hash || '')) return false
158
- if (String(ra.provenance || '') !== String(rb.provenance || '')) return false
159
- }
160
- return true
161
- }
162
-
163
- let stateTmpSeq = 0
164
- let stateWriteWarned = false
165
- /**
166
- * Persist the state file, atomically. Three things here are deliberate:
167
- * · an UNCHANGED payload is not written at all — a same-version boot must not churn this file (the
168
- * "restarting DSH writes nothing" promise covers the state file too, not only the presets);
169
- * · the temp name carries the pid and a sequence, so two concurrent boots cannot rename each
170
- * other's half-written file (with a fixed `path + '.tmp'` the loser's rename failed and the
171
- * whole write vanished without a word);
172
- * · a failure is REPORTED once instead of being swallowed. The copy step already did its work; a
173
- * missing state only means the next boot re-compares everything (at worst it backs up one extra
174
- * original, it never overwrites a user edit).
175
- */
176
- function writeState(path, state, previous, logger) {
177
- if (sameStatePayload(previous, state)) return 'unchanged'
178
- const tmp = path + '.' + process.pid + '.' + (stateTmpSeq++) + '.tmp'
179
- try {
180
- writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8')
181
- renameSync(tmp, path)
182
- return 'written'
183
- } catch (e) {
184
- try { unlinkSync(tmp) } catch (e2) { /* nothing of ours to clean up */ }
185
- if (!stateWriteWarned) {
186
- stateWriteWarned = true
187
- logger?.warn?.('[dsh-vibe-math] 状态文件写入失败(' + path + '):' + String((e && e.message) || e) +
188
- '。preset 文件本身已按上面的日志处理完毕;下一次启动会按「没有记录」重新比对(最坏情况是多备份一份原文,不会覆盖你的改动)。')
189
- }
190
- return 'failed'
191
- }
192
- }
193
-
194
- // DSH 适配性自检(版本 + preset 形态 + 能力)。
195
- // 检查 preset 运行时需要的宿主服务与关键 API 形状是否可用,缺失时打 warning。
196
- // Best-effort DSH host-version detection, in order of authority:
197
- // 1. `process.env.DSH_VERSION` — an explicit override, always honoured first;
198
- // 2. the `pluginManager` service's bundle list — every host bundle carries the runtime version and
199
- // `@deepseek-ai/dsh-base` is present in every base-backed profile (service since 0.1.7);
200
- // 3. `@deepseek-ai/dsh-app-boot`'s exported `getDshRuntimeVersion()` — the documented API; it only
201
- // resolves when this plugin's module scope can reach the host packages;
202
- // 4. the installed `@deepseek-ai/dsh/package.json` — the historical probe, still the only source
203
- // that works on a global <= 0.1.6 install.
204
- // When none resolves, the capability self-check below is still the authoritative gate.
205
- const __require = createRequire(import.meta.url)
206
-
207
- // The detected version is only ever used for diagnostics, and this probe sits on the installer ROW's
208
- // activation path: a service that never answers must not hold the boot there. On timeout (or on a
209
- // rejection) the next source is tried instead — no verdict depends on this value.
210
- const LIST_BUNDLES_TIMEOUT_MS = 1500
211
- function withTimeout(promise, ms) {
212
- return new Promise((resolve) => {
213
- let settled = false
214
- let timer
215
- const done = (value) => { if (!settled) { settled = true; clearTimeout(timer); resolve(value) } }
216
- timer = setTimeout(() => { if (!settled) { settled = true; resolve(undefined) } }, ms)
217
- if (timer && typeof timer.unref === 'function') timer.unref()
218
- Promise.resolve(promise).then(done, () => done(undefined))
219
- })
220
- }
221
-
222
- export async function detectDshVersion(ctx) {
223
- // Installer review (finding): several sources can report this host's version, and "first available
224
- // wins" hid a CONFLICTING host state (the same lesson the `engines.dsh`-vs-`dshReleases` rule already
225
- // records below). Probe them ALL, keep the first available authoritative (priority unchanged), and
226
- // record the rest so a disagreement is visible instead of silent.
227
- const probes = []
228
- const add = (source, version) => { const v = (version === undefined || version === null) ? '' : String(version).trim(); if (v) probes.push({ source: source, version: v }) }
229
- try { add('DSH_VERSION', process.env.DSH_VERSION) } catch (e) {}
230
- try {
231
- const pm = (ctx && ctx.get) ? ctx.get('pluginManager') : undefined
232
- if (pm && typeof pm.listBundles === 'function') {
233
- // withTimeout: a slow or stuck pluginManager falls through to the next source instead of
234
- // stalling this row's activation (non-fatal either way — see the constant's comment).
235
- const bundles = await withTimeout(pm.listBundles(), LIST_BUNDLES_TIMEOUT_MS)
236
- const host = (bundles || []).find((b) => b && (b.name === '@deepseek-ai/dsh-base' || b.name === '@deepseek-ai/dsh'))
237
- if (host && host.version) add('pluginManager:' + host.name, host.version)
238
- }
239
- } catch (e) { /* no such service, or it cannot list yet */ }
240
- try {
241
- const boot = await import('@deepseek-ai/dsh-app-boot')
242
- if (boot && typeof boot.getDshRuntimeVersion === 'function') add('dsh-app-boot', boot.getDshRuntimeVersion())
243
- } catch (e) { /* host packages are not reachable from this plugin's module scope */ }
244
- try {
245
- const p = __require.resolve('@deepseek-ai/dsh/package.json')
246
- add('@deepseek-ai/dsh/package.json', JSON.parse(readFileSync(p, 'utf8')).version || '')
247
- } catch (e) { /* not a global install layout — rely on the capability check */ }
248
- if (!probes.length) return undefined
249
- const first = probes[0]
250
- const conflicts = probes.slice(1).filter((x) => x.version !== first.version)
251
- const out = { version: first.version, source: first.source, probes: probes.slice() }
252
- // Assemble the disagreement slot ONLY when there is one (no empty slot for agreeing hosts).
253
- if (conflicts.length) out.disagreement = { primary: first.source, conflicts: conflicts.slice() }
254
- return out
255
- }
256
-
257
- /**
258
- * How this host declares agent presets.
259
- * 'rows' — DSH >= 0.1.7: composition rows declared in this package's cordis.patch.yml (the rows
260
- * name our own preset-declaration module, which calls `agentPresets.register`).
261
- * Nothing reads the preset directory any more.
262
- * 'directory' — DSH <= 0.1.6: <DSH_HOME>/.agent-presets/<id>/agent.cordis.yml, which is what the
263
- * copy in apply() installs.
264
- *
265
- * Judged from the LOADER TREE first, then from the one capability that actually differs between the
266
- * two lines: the `agentPresets` service. That service exists on BOTH lines with different meaning (a
267
- * directory scanner below 0.1.7, a row registry from 0.1.7 on) and may not be up yet when a bundle is
268
- * activated on its own, which is why the tree is asked first — but when only the service can be seen,
269
- * `register` (never `list`, which both have) is what tells them apart. Missing the row line would skip
270
- * the only working install path, so this decision has to be the reliable one.
271
- */
272
- export function detectPresetMechanism(ctx) {
273
- // 1) the LOADER TREE: only the row-based line mounts the agent-preset package (or its registry).
274
- // This is visible before any plugin activates, which matters because a bundle activation can run
275
- // this installer before the services are up (then ctx.get('agentPresets') is still undefined).
276
- try {
277
- const loader = (ctx && ctx.get) ? ctx.get('loader') : undefined
278
- if (loader && typeof loader.entries === 'function') {
279
- const entries = loader.entries()
280
- for (const entry of entries) {
281
- const name = entry && entry.options ? entry.options.name : undefined
282
- if (name === '@deepseek-ai/dsh-agent-preset' || name === '@deepseek-ai/dsh-agent-preset-registry') return 'rows'
283
- }
284
- }
285
- } catch (e) { /* no loader service: fall through to the service probe */ }
286
- // 2) the service itself, when it is already available. The PRECISE capability difference between
287
- // the two lines is `register`: the >= 0.1.7 row registry has it, the <= 0.1.6 directory scanner
288
- // is INFERRED to lack it. The record is partial — a real 0.1.6-alpha.2 boot exposed the service
289
- // and its `list()` returned the directory roster (_oneoff/roster-016a2.json), but that probe
290
- // never ASKED for `register`; the inference follows from the package difference (npm:
291
- // 0.1.6-alpha.2 ships no `@deepseek-ai/dsh-agent-preset`, while 0.1.7-rc.2 adds it). The
292
- // misclassification this guards against is therefore a LATENT RISK, not an observed failure:
293
- // testing `list` (which BOTH lines have) would classify a service-ready old host as the row
294
- // line, and apply() would then return before writing a single preset directory — "installed but
295
- // invisible", the 2.4.0 defect mirrored onto the older line. That outcome was never observed:
296
- // the recorded 0.1.6 boot DID write all four preset directories. Never test a capability both
297
- // lines share.
298
- try {
299
- const ap = (ctx && ctx.get) ? ctx.get('agentPresets') : undefined
300
- if (ap !== undefined) return typeof ap.register === 'function' ? 'rows' : 'directory'
301
- } catch (e) { /* no service to ask: the directory form */ }
302
- return 'directory'
303
- }
304
-
305
- /**
306
- * Minimal semver-range matcher for the host requirement.
307
- *
308
- * WHY THIS EXISTS: the DSH requirement a host is judged against lives in `package.json`
309
- * (`engines.dsh` / `dsh.engines.dsh`) — the same declaration dsh-market shows on a plugin's card.
310
- * Judging the host by a *second* source (the `dsh.compatibility.dshReleases` map) let the two
311
- * disagree: the card could say "compatible" while this self-check warned, or the reverse. So the
312
- * range is evaluated here, and the map remains only a fallback for manifests that declare no range.
313
- *
314
- * Supported: `*`, exact, `^`, `~`, `>=`, `>`, `<=`, `<`, whitespace-separated sets, `||`
315
- * alternatives — everything the ecosystem publishes (see `dshmarket`'s own
316
- * `^0.1.0-rc.7 || ^0.1.1-rc.2 || ^0.1.2-alpha.2` shape). Anything else returns null = unknown
317
- * (reported, never asserted), never a silent "incompatible".
318
- *
319
- * PRERELEASE RULE (npm's, applied per comparator SET — one `||` alternative is one set): a version
320
- * carrying a prerelease tag satisfies a set only when at least one comparator in that set shares its
321
- * [major, minor, patch] tuple AND carries a prerelease of its own. This is why
322
- * `>=0.1.2-rc.1 <0.2.0` does NOT match `0.1.5-rc.2`, and why this package declares explicit
323
- * per-tuple branches instead. Callers pass `includePrerelease: true` for host checks because every
324
- * published DSH release line is itself a prerelease.
325
- */
326
- export function satisfiesDshRange(version, range, options = {}) {
327
- const v = parseSemver(version)
328
- if (v === null || typeof range !== 'string' || range.trim() === '') return null
329
- const versionHasPre = v.pre.length > 0
330
- let sawUnknown = false
331
- for (const set of range.split('||')) {
332
- const parts = set.trim().split(/\s+/).filter((p) => p !== '')
333
- if (parts.length === 0) return true // an empty alternative is `*`
334
- const parsed = parts.map(comparator)
335
- if (parsed.some((p) => p === null)) { sawUnknown = true; continue }
336
- if (versionHasPre && options.includePrerelease !== true) {
337
- const admitted = parsed.some((p) => p.target !== null && p.target.pre.length > 0 &&
338
- p.target.major === v.major && p.target.minor === v.minor && p.target.patch === v.patch)
339
- if (!admitted) continue
340
- }
341
- if (parsed.every((p) => matchesComparator(v, p))) return true
342
- }
343
- return sawUnknown ? null : false
344
- }
345
-
346
- function parseSemver(value) {
347
- const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value == null ? '' : value).trim())
348
- if (m === null) return null
349
- const pre = m[4] === undefined ? [] : m[4].split('.')
350
- for (const id of pre) if (/^\d+$/.test(id) && id.length > 1 && id[0] === '0') return null
351
- return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre }
352
- }
353
-
354
- function compareSemver(a, b) {
355
- if (a.major !== b.major) return a.major < b.major ? -1 : 1
356
- if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1
357
- if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1
358
- if (a.pre.length === 0 && b.pre.length === 0) return 0
359
- if (a.pre.length === 0) return 1 // a release outranks its prereleases
360
- if (b.pre.length === 0) return -1
361
- for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) {
362
- const x = a.pre[i], y = b.pre[i]
363
- if (x === undefined) return -1
364
- if (y === undefined) return 1
365
- const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y)
366
- if (xn && yn) { const d = Number(x) - Number(y); if (d !== 0) return d < 0 ? -1 : 1; continue }
367
- if (xn !== yn) return xn ? -1 : 1 // numeric identifiers sort below alphanumeric ones
368
- if (x !== y) return x < y ? -1 : 1
369
- }
370
- return 0
371
- }
372
-
373
- /** One comparator such as `^0.1.0-rc.7`. Returns null when the target is not a version. */
374
- function comparator(part) {
375
- const p = part.trim()
376
- // `*`, `x`, `X` and an empty token mean "any version" (npm: still not a prerelease unless the
377
- // caller opts into includePrerelease — the set-level gate below is what enforces that).
378
- if (p === '' || p === '*' || p === 'x' || p === 'X') return { op: 'any', target: null }
379
- const m = /^(\^|~|>=|<=|>|<)?(.*)$/.exec(p)
380
- const op = m === null || m[1] === undefined ? '' : m[1]
381
- const target = parseSemver(m === null ? '' : m[2])
382
- return target === null ? null : { op, target }
383
- }
384
-
385
- function matchesComparator(v, { op, target }) {
386
- if (op === 'any') return true
387
- const c = compareSemver(v, target)
388
- switch (op) {
389
- case '': return c === 0
390
- case '>=': return c >= 0
391
- case '>': return c > 0
392
- case '<=': return c <= 0
393
- case '<': return c < 0
394
- case '^': {
395
- const upper = target.major > 0
396
- ? { major: target.major + 1, minor: 0, patch: 0, pre: [ '0' ] }
397
- : target.minor > 0
398
- ? { major: 0, minor: target.minor + 1, patch: 0, pre: [ '0' ] }
399
- : { major: 0, minor: 0, patch: target.patch + 1, pre: [ '0' ] }
400
- return c >= 0 && compareSemver(v, upper) < 0
401
- }
402
- case '~': {
403
- const upper = { major: target.major, minor: target.minor + 1, patch: 0, pre: [ '0' ] }
404
- return c >= 0 && compareSemver(v, upper) < 0
405
- }
406
- default: return false
407
- }
408
- }
409
-
410
- /**
411
- * The host verdict for a DSH version against a package manifest.
412
- * Prefers the declared range (`engines.dsh`, then `dsh.engines.dsh`); falls back to the
413
- * `dsh.compatibility.dshReleases` map so packages that declare only that keep working.
414
- */
415
- export function dshVersionVerdict(version, manifest) {
416
- const pkg = manifest && typeof manifest === 'object' ? manifest : {}
417
- const declared = (pkg.engines && typeof pkg.engines.dsh === 'string' && pkg.engines.dsh.trim() !== '')
418
- ? pkg.engines.dsh
419
- : (pkg.dsh && pkg.dsh.engines && typeof pkg.dsh.engines.dsh === 'string' && pkg.dsh.engines.dsh.trim() !== '')
420
- ? pkg.dsh.engines.dsh
421
- : null
422
- if (declared !== null) {
423
- // includePrerelease: the whole published DSH line is prerelease builds.
424
- const sat = satisfiesDshRange(version, declared, { includePrerelease: true })
425
- return {
426
- basis: 'engines',
427
- requirement: declared,
428
- status: sat === true ? 'compatible' : sat === false ? 'incompatible' : 'unknown',
429
- }
430
- }
431
- const rel = (pkg.dsh && pkg.dsh.compatibility && pkg.dsh.compatibility.dshReleases) || {}
432
- const status = rel[version]
433
- if (status === 'compatible' || status === 'incompatible') {
434
- return { basis: 'dshReleases', requirement: null, status }
435
- }
436
- return { basis: 'dshReleases', requirement: null, status: status === 'unknown' ? 'unknown' : 'undeclared' }
437
- }
438
-
439
- async function checkHostCapabilities(ctx, logger) {
440
- const problems = []
441
- // 1) DSH version compatibility (best-effort, only when the version is detectable).
442
- // The AUTHORITATIVE source is the declared range `engines.dsh` / `dsh.engines.dsh` — the same
443
- // field dsh-market renders on the plugin card, so the two verdicts cannot disagree. The
444
- // `dsh.compatibility.dshReleases` map is the fallback for manifests without a range.
445
- let manifest = {}
446
- try { manifest = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')) } catch (e) {}
447
- const dshRel = (manifest.dsh && manifest.dsh.compatibility && manifest.dsh.compatibility.dshReleases) || {}
448
- const supported = Object.keys(dshRel).sort()
449
- const detected = await detectDshVersion(ctx)
450
- const dshVersion = detected && detected.version
451
- const versionSource = detected && detected.source
452
- const mechanism = detectPresetMechanism(ctx)
453
- if (dshVersion) {
454
- const verdict = dshVersionVerdict(dshVersion, manifest)
455
- if (verdict.status === 'incompatible') {
456
- problems.push(verdict.basis === 'engines'
457
- ? '当前 DSH 版本 v' + dshVersion + ' 不满足本包声明的宿主版本要求(engines.dsh = ' + verdict.requirement + ')。'
458
- : '当前 DSH 版本 v' + dshVersion + ' 被本包声明为 incompatible;请使用 ' + supported.join(' / ') + '。')
459
- } else if (verdict.status === 'undeclared' || (verdict.status === 'unknown' && verdict.basis === 'dshReleases')) {
460
- problems.push('当前 DSH 版本 v' + dshVersion + ' 尚未被本包声明为兼容(dshReleases 仅声明 ' + supported.join(' / ') + ');建议使用 ' + supported.join(' / ') + ',或将该版本在 dshReleases 中标注后再自行验证。')
461
- } else if (verdict.status === 'unknown' && verdict.basis === 'engines') {
462
- problems.push('当前 DSH 版本 v' + dshVersion + ' 无法与 engines.dsh 的范围比对(声明值 ' + verdict.requirement + ' 不是本安装器能解析的范围);请按该范围自行确认。')
463
- }
464
- }
465
- // 2) capability self-check (the authoritative mounting gate; also covers hosts whose version
466
- // could not be read). subagents / agents / tools / commands / fs shapes + v4 capabilities.
467
- // `required: true` services are the mounting gate; the rest are optional services the presets
468
- // read with ctx.get(). Their absence does not stop a mount but degrades SILENTLY, so report
469
- // them instead of letting the field discover them: without `subprocess` no directory-creation
470
- // shell runs, without `sandboxPolicy` writes carry no explicit fence, without `compaction` the
471
- // v4 real /compact path is inert.
472
- const checks = [
473
- // subagents 服务的续做/唤醒方法是 sendMessage(sender, targetId, content, {signal});
474
- // followup 不是 subagents 服务的方法(它只是 Agent 对象方法)。同时探测两者,能用一个即可。
475
- { svc: 'subagents', methods: ['startContinuable', 'interrupt'], required: true },
476
- { svc: 'agents', methods: ['roots'], required: true },
477
- { svc: 'tools', methods: ['register'], required: true },
478
- { svc: 'commands', methods: ['register'], required: true },
479
- { svc: 'fs', methods: ['resolve', 'stat', 'readText', 'writeText', 'listDir'], required: true },
480
- { svc: 'subprocess', methods: ['spawn'], required: false },
481
- { svc: 'sandboxPolicy', methods: ['resolve'], required: false },
482
- { svc: 'compaction', methods: ['compactIfNeeded'], required: false },
483
- // v5 keeps its institute state in a hardened JSON file (State/<institute>.v5state.json) and does
484
- // NOT use the session-projection registry or the session store any more: DSH refuses to load a
485
- // session whose log carries event types outside its known set, so writing institute events into
486
- // the user's session used to make that session unresumable. Neither service is checked here.
487
- ]
488
- const degradations = []
489
- for (let i = 0; i < checks.length; i++) {
490
- const svc = checks[i].svc
491
- const methods = checks[i].methods
492
- const required = checks[i].required === true
493
- const report = required ? ((m) => problems.push(m)) : ((m) => degradations.push(m))
494
- let s
495
- try { s = (ctx && ctx.get) ? ctx.get(svc) : undefined } catch (e) { s = undefined }
496
- if (s === undefined) { report('宿主缺少服务 ' + svc); continue }
497
- for (let j = 0; j < methods.length; j++) {
498
- if (typeof s[methods[j]] !== 'function') report(svc + '.' + methods[j] + ' 不可用(宿主版本可能过旧)')
499
- }
500
- // subagents continuation (wake) API: sendMessage (modern) OR followup (legacy) must exist.
501
- if (svc === 'subagents' && typeof s.sendMessage !== 'function' && typeof s.followup !== 'function') {
502
- problems.push('subagents 缺少续做/唤醒方法(需 sendMessage 或 followup 至少其一)')
503
- }
504
- }
505
- // fs API shape: DSH 0.1.1 起 resolve 返回 {targetKey, displayPath} 对象(旧版返回字符串路径)
506
- try {
507
- const f = (ctx && ctx.get) ? ctx.get('fs') : undefined
508
- if (f && typeof f.resolve === 'function') {
509
- const r = await f.resolve('x', { cwd: process.cwd() })
510
- if (typeof r !== 'object' || r === null || typeof r.targetKey !== 'string') {
511
- problems.push('fs.resolve 返回形状不符(期望 {targetKey, displayPath},v3/v4 预设要求 DSH ≥ 0.1.1)')
512
- }
513
- }
514
- } catch (e) { problems.push('fs.resolve 能力检测失败:' + String((e && e.message) || e)) }
515
- // v4 依赖 subagents.startContinuable 的 agentOptions / toolFilter 能力(DSH 0.1.2 起由
516
- // dsh-subagent 声明 SubagentCapabilities.agentOptions;spawn/fork 进程内 provider 均支持。
517
- // 缺省 provider 名按 spawn 探测;探测失败不视为致命(等价于回退到再试一次、只警告)。
518
- try {
519
- const sa = (ctx && ctx.get) ? ctx.get('subagents') : undefined
520
- if (sa && typeof sa.list === 'function') {
521
- const names = (sa.list ? sa.list() : [])
522
- const name = names.indexOf('spawn') !== -1 ? 'spawn' : (names[0] || '')
523
- if (name && typeof sa.getProvider === 'function') {
524
- const cap = (sa.getProvider(name) || {}).capabilities
525
- if (cap && cap.agentOptions === false) problems.push('subagents provider "' + name + '" 不支持 agentOptions(v4 指定常驻模型/路由需要)')
526
- if (cap && cap.toolFilter === false) problems.push('subagents provider "' + name + '" 不支持 toolFilter(v4 常驻工具权限需要)')
527
- }
528
- }
529
- } catch (e) { /* 探测失败不致命 */ }
530
- if (degradations.length > 0) {
531
- logger?.warn?.('[dsh-vibe-math] 可选宿主服务缺失,功能会静默降级(不影响挂载):' + degradations.join(';') + '。subprocess 缺失则无法用 shell 创建目录树(仅靠 fs 自动建父目录兜底);sandboxPolicy 缺失则插件写入不带显式围栏;compaction 缺失则 v4 的真实 /compact 路径与 v5 的真实压缩不生效(v5 回退到自述浓缩)')
532
- }
533
- if (problems.length > 0) {
534
- logger?.warn?.('[dsh-vibe-math] 宿主自检:' + problems.length + ' 项不满足(' + problems.join(';') + ')。v2/v3/v4/v5 预设依赖这些宿主服务/API,旧版或未经声明兼容的 DSH 可能无法挂载' + (dshVersion ? '(当前检测到 DSH v' + dshVersion + ',来源 ' + versionSource + ',本包适配 ' + (supported.length ? supported.join(' / ') : '(未声明)') + ')' : '') + '。')
535
- } else {
536
- logger?.info?.('[dsh-vibe-math] 宿主自检通过(“自检通过” ≠ “兼容”:这里只证明服务/API 的**形状**,不证明**行为**语义):subagents / agents / tools / commands / fs 服务及关键 API 均可用' + (degradations.length === 0 ? ',可选服务 subprocess / sandboxPolicy / compaction 亦齐备' : '(可选服务有缺失,见上方警告)') + (dshVersion ? '(当前 DSH v' + dshVersion + ',来源 ' + versionSource + ';本包已声明兼容 ' + supported.join(' / ') + ')' : '') + '。')
537
- }
538
- return { dshVersion, versionSource, mechanism }
539
- }
540
-
541
- export async function apply(ctx) {
542
- const logger = ctx && ctx.logger
543
- try {
544
- const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh')
545
- const here = dirname(fileURLToPath(import.meta.url))
546
- const presetRoot = join(dshHome, '.agent-presets')
547
- const stateFile = join(presetRoot, STATE_FILE)
548
-
549
- // WHICH MECHANISM THIS HOST USES decides whether the directory work below means anything, so it
550
- // is decided (and reported) before anything is written. The host self-check runs here too.
551
- const host = await checkHostCapabilities(ctx, logger)
552
- if (host && host.mechanism === 'rows') {
553
- logger?.info?.('[dsh-vibe-math] preset 声明方式:本宿主以组合行声明 agent preset' +
554
- (host.dshVersion ? '(DSH v' + host.dshVersion + ')' : '') + ',本包随附的 cordis.patch.yml 已声明四个 preset(dsh-vibe-math/preset-declaration);' +
555
- '跳过 ' + presetRoot + ' 目录同步(该目录自 DSH 0.1.7 起不再被读取)。')
556
- if (existsSync(stateFile)) {
557
- logger?.info?.('[dsh-vibe-math] 提示:' + presetRoot + ' 里还留着旧版 DSH(≤ 0.1.6)读取过的 preset 副本,' +
558
- '当前宿主不会再读它们,可以安全删除(要自定义 preset,请在预设选择器里复制一份,或改 profile 的 cordis.patch.yml)。')
559
- }
560
- return
561
- }
562
-
563
- // current package version (the source of truth for "is this an upgrade?")
564
- let pkgVersion = ''
565
- try { pkgVersion = String((JSON.parse(readFileSync(join(here, 'package.json'), 'utf8')).version) || '') } catch (e) { pkgVersion = '' }
566
-
567
- const state = readState(stateFile)
568
- const prevFiles = (state && state.files) || {}
569
- const isUpgrade = state !== null && pkgVersion !== '' && state.version !== pkgVersion
570
- const isBaseline = state === null // no recorded history → refresh everything
571
- // A version change (or a first sighting) REPLACES the managed files; only a same-version boot
572
- // leaves the working tree alone. See UPDATE POLICY at the top of this file.
573
- const refresh = isBaseline || isUpgrade
574
- const fromVersion = (state && state.version) || '(unversioned)'
575
- if (pkgVersion === '') {
576
- // Without a version there is nothing to compare against, so this run must NOT replace
577
- // anything (a wrong guess would overwrite files for no reason); it still restores missing
578
- // ones and keeps the previously recorded version, so the next readable run reports the
579
- // right "from" version.
580
- logger?.warn?.('[dsh-vibe-math] 读不到本包版本(package.json 缺失或损坏):本次不做版本比对,只补回缺失的 preset 文件。')
581
- }
582
- // A recorded version NEWER than this package means this run replaces preset bytes BACKWARDS.
583
- // The policy still does it — "the preset directory equals the installed package" is the whole
584
- // point — but a silent downgrade (an old bundle still installed in the profile while the presets
585
- // were synced from a newer one) is exactly the surprise worth naming.
586
- if (isUpgrade) {
587
- const recorded = parseSemver(fromVersion)
588
- const installed = parseSemver(pkgVersion)
589
- if (recorded !== null && installed !== null && compareSemver(recorded, installed) > 0) {
590
- logger?.warn?.('[dsh-vibe-math] 记录里的版本(' + fromVersion + ')比本包版本(' + pkgVersion + ')新,本次会把 preset 换回旧字节。' +
591
- '若这不是你想要的,请先升级 profile 里的依赖:dsh plugin --profile <name> add dsh-vibe-math@latest,再重启 DSH。')
592
- }
593
- }
594
-
595
- const nextFiles = {}
596
- let installed = 0, updated = 0, kept = 0
597
- let userEditedKept = 0
598
- const replacedEdits = []
599
- const unknownPrev = []
600
- const backupFailures = []
601
-
602
- for (const p of PRESETS) {
603
- const srcDir = join(here, p.src)
604
- const dstDir = join(presetRoot, p.dst)
605
- if (!existsSync(srcDir)) continue
606
- mkdirSync(dstDir, { recursive: true })
607
- for (const f of p.files) {
608
- const s = join(srcDir, f)
609
- const d = join(dstDir, f)
610
- if (!existsSync(s)) continue
611
- const key = p.src + '/' + f
612
- const cur = readFileSync(s)
613
- const curHash = sha256(cur)
614
- if (!existsSync(d)) {
615
- // missing file: always restore, whatever the version
616
- writePresetFile(d, cur)
617
- installed += 1
618
- nextFiles[key] = { hash: curHash, provenance: 'package' }
619
- continue
620
- }
621
- const destBuf = readFileSync(d)
622
- const destHash = sha256(destBuf)
623
- const prev = prevFiles[key]
624
- const prevRec = (prev && typeof prev === 'object') ? prev : { hash: prev, provenance: 'package' }
625
- if (destHash === curHash) {
626
- // already the shipped bytes: never rewrite, so the file's mtime (which keys the preset's
627
- // DSH generation) stays put
628
- nextFiles[key] = { hash: curHash, provenance: 'package' }
629
- continue
630
- }
631
- if (!refresh) {
632
- // same version: nothing is being updated, so a file that differs from the package is left
633
- // exactly as it is. The recorded hash stays "what this installer last wrote" (or unknown),
634
- // so the drift is still recognised — and backed up — at the next version change.
635
- //
636
- // PROVENANCE, recorded honestly: when the on-disk bytes are NOT what this installer last
637
- // wrote, something else edited this managed file, so it is marked user-owned — which is
638
- // what makes the stale-preset cleanup below KEEP it instead of unlinking it. A legacy
639
- // state with no hash cannot tell, so it stays 'package' (it is still backed up before any
640
- // version-change replacement; the cleanup backs it up before deleting too).
641
- kept += 1
642
- const userEdited = typeof prevRec.hash === 'string' && destHash !== prevRec.hash
643
- if (userEdited) userEditedKept += 1
644
- nextFiles[key] = userEdited
645
- ? { hash: prevRec.hash, provenance: 'user' }
646
- : typeof prevRec.hash === 'string' ? { hash: prevRec.hash, provenance: 'package' } : { provenance: 'package' }
647
- continue
648
- }
649
- // replacing: preserve the user's bytes when they are not what this installer last wrote.
650
- // The two cases are NOT the same claim and are counted apart: bytes that differ from the
651
- // recorded hash are a provable edit, while a legacy state WITHOUT a hash cannot tell — that
652
- // file is backed up all the same, but the log must not call it "被改过" (it cannot know).
653
- if (typeof prevRec.hash !== 'string') {
654
- if (backupReplacedFile(presetRoot, fromVersion, p.dst, f, destBuf) === 'failed') backupFailures.push(key)
655
- unknownPrev.push(key)
656
- } else if (destHash !== prevRec.hash) {
657
- if (backupReplacedFile(presetRoot, fromVersion, p.dst, f, destBuf) === 'failed') backupFailures.push(key)
658
- replacedEdits.push(key)
659
- }
660
- writePresetFile(d, cur)
661
- updated += 1
662
- nextFiles[key] = { hash: curHash, provenance: 'package' }
663
- }
664
- }
665
-
666
- // Clean up preset dirs that this bundle NO LONGER manages (e.g. vibe-math-v1 after it was
667
- // removed at v2.0.0). The copy loop only adds/updates PRESETS; it never deletes a preset that
668
- // was dropped, so an old removed preset would linger in the picker forever. Here we remove the
669
- // files this installer previously recorded as package-owned under a prefix that is no longer in
670
- // PRESETS, then drop the dir if it became empty. Files recorded as user-owned are KEPT.
671
- //
672
- // DELETION IS THE ONE PATH THAT DOES NOT REPLACE, so it obeys the same rule as the replacement
673
- // path: a file whose bytes are not what this installer last wrote is copied to
674
- // <presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file> FIRST and named in the log. A file
675
- // that still matches its recorded hash IS the package's copy, so removing it destroys nothing.
676
- const currentPrefixes = new Set(PRESETS.map(p => p.src + '/'))
677
- let removedFiles = 0
678
- let matchedPackageFiles = 0
679
- let keptUserFiles = 0
680
- const removedDirs = []
681
- const backedUpStale = []
682
- const backupFailedStale = []
683
- const unreadableStale = []
684
- const keptUserDirs = new Set()
685
- const stale = new Map() // prefix -> [keys]
686
- for (const key of Object.keys(prevFiles)) {
687
- const slash = key.indexOf('/')
688
- if (slash === -1) continue
689
- const prefix = key.slice(0, slash + 1)
690
- if (currentPrefixes.has(prefix)) continue
691
- if (!stale.has(prefix)) stale.set(prefix, [])
692
- stale.get(prefix).push(key)
693
- }
694
- for (const [prefix, keys] of stale) {
695
- let dirEmpty = true
696
- for (const key of keys) {
697
- const rec = (prevFiles[key] && typeof prevFiles[key] === 'object') ? prevFiles[key] : { provenance: 'package' }
698
- if (rec.provenance === 'user') { dirEmpty = false; keptUserFiles += 1; keptUserDirs.add(prefix.slice(0, -1)); continue } // 用户文件 → 保留
699
- const f = join(presetRoot, key)
700
- if (existsSync(f)) {
701
- let buf = null
702
- try { buf = readFileSync(f) } catch (e) { /* unreadable: treated as "cannot prove it is ours" */ }
703
- // A file that still matches its recorded hash IS the copy this installer wrote, so deleting
704
- // it destroys nothing. Anything else — edited bytes, or a legacy record with no hash that
705
- // cannot tell — is copied to the backup root first. Deletion is allowed ONLY when nothing of
706
- // the user's can be lost: a package copy, or bytes that really were preserved. An unreadable
707
- // file cannot be backed up, and a FAILED backup means the bytes were not preserved either —
708
- // both are KEPT and named in the log. A stale directory left behind is cosmetic; destroyed
709
- // user bytes are not. (The REPLACEMENT path above keeps its own rule — a failed backup must
710
- // not block a version upgrade — because there the file is overwritten, not removed.)
711
- const isPackageCopy = buf !== null && typeof rec.hash === 'string' && sha256(buf) === rec.hash
712
- let mayDelete = false
713
- if (isPackageCopy) {
714
- matchedPackageFiles += 1
715
- mayDelete = true
716
- } else if (buf !== null) {
717
- const cut = key.lastIndexOf('/')
718
- const backup = backupReplacedFile(presetRoot, fromVersion, key.slice(0, cut), key.slice(cut + 1), buf)
719
- if (backup === 'failed') backupFailedStale.push(key)
720
- else { backedUpStale.push(key); mayDelete = true }
721
- } else {
722
- unreadableStale.push(key)
723
- }
724
- if (mayDelete) { try { unlinkSync(f); removedFiles += 1 } catch (e) {} }
725
- }
726
- if (existsSync(f)) dirEmpty = false
727
- }
728
- const dir = join(presetRoot, prefix.slice(0, -1))
729
- if (dirEmpty && existsSync(dir)) { try { rmdirSync(dir); removedDirs.push(dir) } catch (e) {} }
730
- }
731
-
732
- writeState(stateFile, { version: pkgVersion || (state && state.version) || '', files: nextFiles, updatedAt: Date.now() }, state, logger)
733
-
734
- const cleanupNotes = []
735
- if (matchedPackageFiles > 0) cleanupNotes.push(matchedPackageFiles + ' 个仍是本安装器写入的字节(删掉的不是你的改动)')
736
- if (backedUpStale.length > 0) cleanupNotes.push(backedUpStale.length + ' 个的字节与上一次安装不同(被改过),原文已备份在 ' + join(presetRoot, BACKUP_DIR, String(fromVersion)) + ':' + backedUpStale.join(', '))
737
- if (backupFailedStale.length > 0) cleanupNotes.push(backupFailedStale.length + ' 个被改过的文件**备份失败**(原文未保留,已跳过删除并保留原文件):' + backupFailedStale.join(', '))
738
- if (unreadableStale.length > 0) cleanupNotes.push(unreadableStale.length + ' 个文件**无法读取**(既不能证明是本安装器写入的,也无法备份),已跳过删除并保留原文件:' + unreadableStale.join(', '))
739
- if (keptUserFiles > 0) cleanupNotes.push(keptUserFiles + ' 个记为用户所有的文件被保留(' + [...keptUserDirs].join(', ') + '),要清理请手动删除')
740
- if (removedFiles > 0 || removedDirs.length > 0 || cleanupNotes.length > 0) {
1
+ // dsh-vibe-math merged bundle installer — VERSIONED AUTO-UPDATE.
2
+ // When this bundle is installed (e.g. `dsh plugin --profile <name> add dsh-vibe-math`, or from the
3
+ // dsh-market), this plugin copies ALL FOUR agent presets out of the package into
4
+ // the DSH preset root, so the user immediately gets four presets in the picker:
5
+ // vibe-math-v2/ (probability-driven architecture)
6
+ // vibe-math-v3/ (THIRD-generation: paper-style Markdown knowledge base +
7
+ // planner-agent scheduling + universal theory/method library)
8
+ // vibe-math-v4/ (FOURTH-generation: persistent self-organizing resident
9
+ // subagents — message bus / meetings / unanimous-consensus
10
+ // verification / per-resident libraries)
11
+ // vibe-math-v5/ (FIFTH-generation: research institute — academician who
12
+ // assigns work, permanent researchers who vote, temp workers,
13
+ // group chat + meetings + m-vote consensus)
14
+ //
15
+ // (vibe-math-v1 — the classic pipeline — was removed at v2.0.0; this bundle now
16
+ // ships v2/v3/v4/v5.)
17
+ //
18
+ // UPDATE POLICY (state recorded in <presetRoot>/.vibe-math-installed.json):
19
+ // - FORCE-REPLACE ON VERSION CHANGE. When the recorded version differs from this package's
20
+ // version — or there is no record at all (an install made by an older installer) — every
21
+ // managed file is overwritten with the shipped bytes. This is deliberately NOT conditional on
22
+ // the file being unmodified. Two reasons:
23
+ // · a preset assembled from two different versions (the old policy updated a file's
24
+ // neighbours and kept the file the user had touched) is exactly the state that fails to
25
+ // mount or misbehaves subtly, and the user has no way to see that from the outside;
26
+ // · editing a shipped preset in place is not the supported way to customize one — DSH
27
+ // provides a real one (copy the preset: the picker's copy action, or a new directory
28
+ // under <presetRoot>), which leaves the managed set updatable.
29
+ // - Nothing is destroyed silently: before a file whose bytes are not what the installer last
30
+ // wrote is REPLACED — or DELETED along with a preset this bundle no longer ships — the user's
31
+ // copy is kept under <presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file> and named in
32
+ // the log. A file that still matches the hash recorded for it is a copy of the package, so
33
+ // removing it destroys nothing and needs no backup.
34
+ // - Files are written ATOMICALLY: a uniquely named temp file is written and then renamed over the
35
+ // destination, so an interrupted boot (a kill, a full disk, two sessions writing at once) can
36
+ // never leave a half-written preset for the next same-version run to mistake for a user edit.
37
+ // - same version: no-op (idempotent) — restarting DSH never rewrites a preset file, never churns
38
+ // the preset's generation stamp and never rewrites this state file (it is written only when its
39
+ // content actually changes). Missing files are ALWAYS restored, at any version.
40
+ // - force a full refresh at any time: delete the preset dirs and restart DSH.
41
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync, rmdirSync } from 'node:fs'
42
+ import { createRequire } from 'node:module'
43
+ import { createHash } from 'node:crypto'
44
+ import { homedir } from 'node:os'
45
+ import { dirname, join } from 'node:path'
46
+ import { fileURLToPath } from 'node:url'
47
+
48
+ export const name = 'vibe-math-preset-installer'
49
+
50
+ // Exported so the shipped policy suite can prove the managed list still covers what each preset
51
+ // needs at runtime (a file present in the preset directory but missing here is copied by nobody,
52
+ // and the installed preset then cannot mount).
53
+ export const PRESETS = [
54
+ {
55
+ src: 'vibe-math-v2',
56
+ dst: 'vibe-math-v2',
57
+ // math-computation.js + math-engines.js are PER-PRESET copies of the shared module (they must be
58
+ // listed here: on the <=0.1.6 line only these files are copied into .agent-presets/<id>/, and the
59
+ // preset JS imports them relatively). guard: tests/audit-math-computation-parity.mjs
60
+ files: ['agent.cordis.yml', 'preset.yml', 'vibe-math-v2.js', '实现方案.md', 'math-computation.js', 'math-engines.js'],
61
+ },
62
+ {
63
+ src: 'vibe-math-v3',
64
+ dst: 'vibe-math-v3',
65
+ files: ['agent.cordis.yml', 'preset.yml', 'vibe-math-v3.js', '实现方案.md', 'math-computation.js', 'math-engines.js'],
66
+ },
67
+ {
68
+ src: 'vibe-math-v4',
69
+ dst: 'vibe-math-v4',
70
+ files: ['agent.cordis.yml', 'preset.yml', 'vibe-math-v4.js', '实现方案.md', 'math-computation.js', 'math-engines.js'],
71
+ },
72
+ {
73
+ src: 'vibe-math-v5',
74
+ dst: 'vibe-math-v5',
75
+ // 架构图.md belongs here for the same reason 实现方案.md does: the installer's policy is to put
76
+ // the preset's documentation next to the preset, and the shipped v5 directory carries both.
77
+ files: ['agent.cordis.yml', 'preset.yml', 'vibe-math-v5.js', '实现方案.md', '架构图.md', 'math-computation.js', 'math-engines.js'],
78
+ },
79
+ ]
80
+
81
+ const STATE_FILE = '.vibe-math-installed.json'
82
+ // where a replaced user edit is preserved; a leading dot keeps DSH's preset discovery from ever
83
+ // treating it as a preset directory (ids must match [a-z0-9][a-z0-9-]*)
84
+ const BACKUP_DIR = '.vibe-math-backup'
85
+
86
+ function sha256(buf) { return createHash('sha256').update(buf).digest('hex') }
87
+
88
+ let presetTmpSeq = 0
89
+ /**
90
+ * Write one preset file ATOMICALLY: a uniquely named temp sibling, then a rename over the
91
+ * destination. An in-place `writeFileSync` can be interrupted (a killed or crashed DSH, a full disk,
92
+ * two sessions writing the same path) and leave a TRUNCATED preset behind — and the next
93
+ * same-version boot would take those bytes for the user's own edit and never repair them (a
94
+ * same-version run only restores MISSING files). The pid plus a per-process sequence make the temp
95
+ * name unique, so concurrent writers cannot rename each other's half-written file. A leftover temp
96
+ * (possible only after SIGKILL between the two calls) is inert: DSH reads the named preset files.
97
+ */
98
+ function writePresetFile(dest, buf) {
99
+ const tmp = dest + '.' + process.pid + '.' + (presetTmpSeq++) + '.vibe-math-tmp'
100
+ try {
101
+ writeFileSync(tmp, buf)
102
+ renameSync(tmp, dest)
103
+ } catch (e) {
104
+ try { unlinkSync(tmp) } catch (e2) { /* nothing of ours to clean up */ }
105
+ throw e
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Preserve one file that is about to be replaced by the shipped version, under
111
+ * `<presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file>`.
112
+ *
113
+ * Returns `'written'` (a copy was made now), `'kept'` (a copy for this version was already there —
114
+ * the earliest edit is the one worth keeping, and a re-run must not overwrite it with an
115
+ * already-replaced file) or `'failed'`. A caller must not report `'kept'` as a failure: the user's
116
+ * bytes are preserved, just from an earlier run.
117
+ */
118
+ function backupReplacedFile(presetRoot, fromVersion, presetDir, fileName, buf) {
119
+ try {
120
+ const dir = join(presetRoot, BACKUP_DIR, String(fromVersion || 'unversioned'), presetDir)
121
+ const dst = join(dir, fileName)
122
+ if (existsSync(dst)) return 'kept' // the earliest copy for this version is the one worth keeping
123
+ mkdirSync(dir, { recursive: true })
124
+ writeFileSync(dst, buf)
125
+ return 'written'
126
+ } catch (e) {
127
+ return 'failed' // a backup failure must never stop the update; it is reported by the caller
128
+ }
129
+ }
130
+
131
+ function readState(path) {
132
+ try {
133
+ const raw = readFileSync(path, 'utf8')
134
+ const obj = JSON.parse(raw)
135
+ if (obj && typeof obj === 'object' && obj.files && typeof obj.files === 'object') return obj
136
+ } catch (e) { /* missing or corrupt — treat as no state (baseline) */ }
137
+ return null
138
+ }
139
+
140
+ /**
141
+ * Does the new payload equal the recorded one? Compared field by field (never as JSON text) so key
142
+ * ORDER cannot fake a change, and the timestamp is ignored on purpose: `updatedAt` records when the
143
+ * file was written, not that anything happened.
144
+ */
145
+ function sameStatePayload(previous, next) {
146
+ if (!previous || !next) return false
147
+ if (String(previous.version || '') !== String(next.version || '')) return false
148
+ const a = previous.files || {}
149
+ const b = next.files || {}
150
+ const ka = Object.keys(a).sort()
151
+ const kb = Object.keys(b).sort()
152
+ if (ka.length !== kb.length) return false
153
+ for (let i = 0; i < ka.length; i++) {
154
+ if (ka[i] !== kb[i]) return false
155
+ const ra = a[ka[i]] || {}
156
+ const rb = b[kb[i]] || {}
157
+ if (String(ra.hash || '') !== String(rb.hash || '')) return false
158
+ if (String(ra.provenance || '') !== String(rb.provenance || '')) return false
159
+ }
160
+ return true
161
+ }
162
+
163
+ let stateTmpSeq = 0
164
+ let stateWriteWarned = false
165
+ /**
166
+ * Persist the state file, atomically. Three things here are deliberate:
167
+ * · an UNCHANGED payload is not written at all — a same-version boot must not churn this file (the
168
+ * "restarting DSH writes nothing" promise covers the state file too, not only the presets);
169
+ * · the temp name carries the pid and a sequence, so two concurrent boots cannot rename each
170
+ * other's half-written file (with a fixed `path + '.tmp'` the loser's rename failed and the
171
+ * whole write vanished without a word);
172
+ * · a failure is REPORTED once instead of being swallowed. The copy step already did its work; a
173
+ * missing state only means the next boot re-compares everything (at worst it backs up one extra
174
+ * original, it never overwrites a user edit).
175
+ */
176
+ function writeState(path, state, previous, logger) {
177
+ if (sameStatePayload(previous, state)) return 'unchanged'
178
+ const tmp = path + '.' + process.pid + '.' + (stateTmpSeq++) + '.tmp'
179
+ try {
180
+ writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf8')
181
+ renameSync(tmp, path)
182
+ return 'written'
183
+ } catch (e) {
184
+ try { unlinkSync(tmp) } catch (e2) { /* nothing of ours to clean up */ }
185
+ if (!stateWriteWarned) {
186
+ stateWriteWarned = true
187
+ logger?.warn?.('[dsh-vibe-math] 状态文件写入失败(' + path + '):' + String((e && e.message) || e) +
188
+ '。preset 文件本身已按上面的日志处理完毕;下一次启动会按「没有记录」重新比对(最坏情况是多备份一份原文,不会覆盖你的改动)。')
189
+ }
190
+ return 'failed'
191
+ }
192
+ }
193
+
194
+ // DSH 适配性自检(版本 + preset 形态 + 能力)。
195
+ // 检查 preset 运行时需要的宿主服务与关键 API 形状是否可用,缺失时打 warning。
196
+ // Best-effort DSH host-version detection, in order of authority:
197
+ // 1. `process.env.DSH_VERSION` — an explicit override, always honoured first;
198
+ // 2. the `pluginManager` service's bundle list — every host bundle carries the runtime version and
199
+ // `@deepseek-ai/dsh-base` is present in every base-backed profile (service since 0.1.7);
200
+ // 3. `@deepseek-ai/dsh-app-boot`'s exported `getDshRuntimeVersion()` — the documented API; it only
201
+ // resolves when this plugin's module scope can reach the host packages;
202
+ // 4. the installed `@deepseek-ai/dsh/package.json` — the historical probe, still the only source
203
+ // that works on a global <= 0.1.6 install.
204
+ // When none resolves, the capability self-check below is still the authoritative gate.
205
+ const __require = createRequire(import.meta.url)
206
+
207
+ // The detected version is only ever used for diagnostics, and this probe sits on the installer ROW's
208
+ // activation path: a service that never answers must not hold the boot there. On timeout (or on a
209
+ // rejection) the next source is tried instead — no verdict depends on this value.
210
+ const LIST_BUNDLES_TIMEOUT_MS = 1500
211
+ function withTimeout(promise, ms) {
212
+ return new Promise((resolve) => {
213
+ let settled = false
214
+ let timer
215
+ const done = (value) => { if (!settled) { settled = true; clearTimeout(timer); resolve(value) } }
216
+ timer = setTimeout(() => { if (!settled) { settled = true; resolve(undefined) } }, ms)
217
+ if (timer && typeof timer.unref === 'function') timer.unref()
218
+ Promise.resolve(promise).then(done, () => done(undefined))
219
+ })
220
+ }
221
+
222
+ export async function detectDshVersion(ctx) {
223
+ // Installer review (finding): several sources can report this host's version, and "first available
224
+ // wins" hid a CONFLICTING host state (the same lesson the `engines.dsh`-vs-`dshReleases` rule already
225
+ // records below). Probe them ALL, keep the first available authoritative (priority unchanged), and
226
+ // record the rest so a disagreement is visible instead of silent.
227
+ const probes = []
228
+ const add = (source, version) => { const v = (version === undefined || version === null) ? '' : String(version).trim(); if (v) probes.push({ source: source, version: v }) }
229
+ try { add('DSH_VERSION', process.env.DSH_VERSION) } catch (e) {}
230
+ try {
231
+ const pm = (ctx && ctx.get) ? ctx.get('pluginManager') : undefined
232
+ if (pm && typeof pm.listBundles === 'function') {
233
+ // withTimeout: a slow or stuck pluginManager falls through to the next source instead of
234
+ // stalling this row's activation (non-fatal either way — see the constant's comment).
235
+ const bundles = await withTimeout(pm.listBundles(), LIST_BUNDLES_TIMEOUT_MS)
236
+ const host = (bundles || []).find((b) => b && (b.name === '@deepseek-ai/dsh-base' || b.name === '@deepseek-ai/dsh'))
237
+ if (host && host.version) add('pluginManager:' + host.name, host.version)
238
+ }
239
+ } catch (e) { /* no such service, or it cannot list yet */ }
240
+ try {
241
+ const boot = await import('@deepseek-ai/dsh-app-boot')
242
+ if (boot && typeof boot.getDshRuntimeVersion === 'function') add('dsh-app-boot', boot.getDshRuntimeVersion())
243
+ } catch (e) { /* host packages are not reachable from this plugin's module scope */ }
244
+ try {
245
+ const p = __require.resolve('@deepseek-ai/dsh/package.json')
246
+ add('@deepseek-ai/dsh/package.json', JSON.parse(readFileSync(p, 'utf8')).version || '')
247
+ } catch (e) { /* not a global install layout — rely on the capability check */ }
248
+ if (!probes.length) return undefined
249
+ const first = probes[0]
250
+ const conflicts = probes.slice(1).filter((x) => x.version !== first.version)
251
+ const out = { version: first.version, source: first.source, probes: probes.slice() }
252
+ // Assemble the disagreement slot ONLY when there is one (no empty slot for agreeing hosts).
253
+ if (conflicts.length) out.disagreement = { primary: first.source, conflicts: conflicts.slice() }
254
+ return out
255
+ }
256
+
257
+ /**
258
+ * How this host declares agent presets.
259
+ * 'rows' — DSH >= 0.1.7: composition rows declared in this package's cordis.patch.yml (the rows
260
+ * name our own preset-declaration module, which calls `agentPresets.register`).
261
+ * Nothing reads the preset directory any more.
262
+ * 'directory' — DSH <= 0.1.6: <DSH_HOME>/.agent-presets/<id>/agent.cordis.yml, which is what the
263
+ * copy in apply() installs.
264
+ *
265
+ * Judged from the LOADER TREE first, then from the one capability that actually differs between the
266
+ * two lines: the `agentPresets` service. That service exists on BOTH lines with different meaning (a
267
+ * directory scanner below 0.1.7, a row registry from 0.1.7 on) and may not be up yet when a bundle is
268
+ * activated on its own, which is why the tree is asked first — but when only the service can be seen,
269
+ * `register` (never `list`, which both have) is what tells them apart. Missing the row line would skip
270
+ * the only working install path, so this decision has to be the reliable one.
271
+ */
272
+ export function detectPresetMechanism(ctx) {
273
+ // 1) the LOADER TREE: only the row-based line mounts the agent-preset package (or its registry).
274
+ // This is visible before any plugin activates, which matters because a bundle activation can run
275
+ // this installer before the services are up (then ctx.get('agentPresets') is still undefined).
276
+ try {
277
+ const loader = (ctx && ctx.get) ? ctx.get('loader') : undefined
278
+ if (loader && typeof loader.entries === 'function') {
279
+ const entries = loader.entries()
280
+ for (const entry of entries) {
281
+ const name = entry && entry.options ? entry.options.name : undefined
282
+ if (name === '@deepseek-ai/dsh-agent-preset' || name === '@deepseek-ai/dsh-agent-preset-registry') return 'rows'
283
+ }
284
+ }
285
+ } catch (e) { /* no loader service: fall through to the service probe */ }
286
+ // 2) the service itself, when it is already available. The PRECISE capability difference between
287
+ // the two lines is `register`: the >= 0.1.7 row registry has it, the <= 0.1.6 directory scanner
288
+ // is INFERRED to lack it. The record is partial — a real 0.1.6-alpha.2 boot exposed the service
289
+ // and its `list()` returned the directory roster (_oneoff/roster-016a2.json), but that probe
290
+ // never ASKED for `register`; the inference follows from the package difference (npm:
291
+ // 0.1.6-alpha.2 ships no `@deepseek-ai/dsh-agent-preset`, while 0.1.7-rc.2 adds it). The
292
+ // misclassification this guards against is therefore a LATENT RISK, not an observed failure:
293
+ // testing `list` (which BOTH lines have) would classify a service-ready old host as the row
294
+ // line, and apply() would then return before writing a single preset directory — "installed but
295
+ // invisible", the 2.4.0 defect mirrored onto the older line. That outcome was never observed:
296
+ // the recorded 0.1.6 boot DID write all four preset directories. Never test a capability both
297
+ // lines share.
298
+ try {
299
+ const ap = (ctx && ctx.get) ? ctx.get('agentPresets') : undefined
300
+ if (ap !== undefined) return typeof ap.register === 'function' ? 'rows' : 'directory'
301
+ } catch (e) { /* no service to ask: the directory form */ }
302
+ return 'directory'
303
+ }
304
+
305
+ /**
306
+ * Minimal semver-range matcher for the host requirement.
307
+ *
308
+ * WHY THIS EXISTS: the DSH requirement a host is judged against lives in `package.json`
309
+ * (`engines.dsh` / `dsh.engines.dsh`) — the same declaration dsh-market shows on a plugin's card.
310
+ * Judging the host by a *second* source (the `dsh.compatibility.dshReleases` map) let the two
311
+ * disagree: the card could say "compatible" while this self-check warned, or the reverse. So the
312
+ * range is evaluated here, and the map remains only a fallback for manifests that declare no range.
313
+ *
314
+ * Supported: `*`, exact, `^`, `~`, `>=`, `>`, `<=`, `<`, whitespace-separated sets, `||`
315
+ * alternatives — everything the ecosystem publishes (see `dshmarket`'s own
316
+ * `^0.1.0-rc.7 || ^0.1.1-rc.2 || ^0.1.2-alpha.2` shape). Anything else returns null = unknown
317
+ * (reported, never asserted), never a silent "incompatible".
318
+ *
319
+ * PRERELEASE RULE (npm's, applied per comparator SET — one `||` alternative is one set): a version
320
+ * carrying a prerelease tag satisfies a set only when at least one comparator in that set shares its
321
+ * [major, minor, patch] tuple AND carries a prerelease of its own. This is why
322
+ * `>=0.1.2-rc.1 <0.2.0` does NOT match `0.1.5-rc.2`, and why this package declares explicit
323
+ * per-tuple branches instead. Callers pass `includePrerelease: true` for host checks because every
324
+ * published DSH release line is itself a prerelease.
325
+ */
326
+ export function satisfiesDshRange(version, range, options = {}) {
327
+ const v = parseSemver(version)
328
+ if (v === null || typeof range !== 'string' || range.trim() === '') return null
329
+ const versionHasPre = v.pre.length > 0
330
+ let sawUnknown = false
331
+ for (const set of range.split('||')) {
332
+ const parts = set.trim().split(/\s+/).filter((p) => p !== '')
333
+ if (parts.length === 0) return true // an empty alternative is `*`
334
+ const parsed = parts.map(comparator)
335
+ if (parsed.some((p) => p === null)) { sawUnknown = true; continue }
336
+ if (versionHasPre && options.includePrerelease !== true) {
337
+ const admitted = parsed.some((p) => p.target !== null && p.target.pre.length > 0 &&
338
+ p.target.major === v.major && p.target.minor === v.minor && p.target.patch === v.patch)
339
+ if (!admitted) continue
340
+ }
341
+ if (parsed.every((p) => matchesComparator(v, p))) return true
342
+ }
343
+ return sawUnknown ? null : false
344
+ }
345
+
346
+ function parseSemver(value) {
347
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value == null ? '' : value).trim())
348
+ if (m === null) return null
349
+ const pre = m[4] === undefined ? [] : m[4].split('.')
350
+ for (const id of pre) if (/^\d+$/.test(id) && id.length > 1 && id[0] === '0') return null
351
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre }
352
+ }
353
+
354
+ function compareSemver(a, b) {
355
+ if (a.major !== b.major) return a.major < b.major ? -1 : 1
356
+ if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1
357
+ if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1
358
+ if (a.pre.length === 0 && b.pre.length === 0) return 0
359
+ if (a.pre.length === 0) return 1 // a release outranks its prereleases
360
+ if (b.pre.length === 0) return -1
361
+ for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) {
362
+ const x = a.pre[i], y = b.pre[i]
363
+ if (x === undefined) return -1
364
+ if (y === undefined) return 1
365
+ const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y)
366
+ if (xn && yn) { const d = Number(x) - Number(y); if (d !== 0) return d < 0 ? -1 : 1; continue }
367
+ if (xn !== yn) return xn ? -1 : 1 // numeric identifiers sort below alphanumeric ones
368
+ if (x !== y) return x < y ? -1 : 1
369
+ }
370
+ return 0
371
+ }
372
+
373
+ /** One comparator such as `^0.1.0-rc.7`. Returns null when the target is not a version. */
374
+ function comparator(part) {
375
+ const p = part.trim()
376
+ // `*`, `x`, `X` and an empty token mean "any version" (npm: still not a prerelease unless the
377
+ // caller opts into includePrerelease — the set-level gate below is what enforces that).
378
+ if (p === '' || p === '*' || p === 'x' || p === 'X') return { op: 'any', target: null }
379
+ const m = /^(\^|~|>=|<=|>|<)?(.*)$/.exec(p)
380
+ const op = m === null || m[1] === undefined ? '' : m[1]
381
+ const target = parseSemver(m === null ? '' : m[2])
382
+ return target === null ? null : { op, target }
383
+ }
384
+
385
+ function matchesComparator(v, { op, target }) {
386
+ if (op === 'any') return true
387
+ const c = compareSemver(v, target)
388
+ switch (op) {
389
+ case '': return c === 0
390
+ case '>=': return c >= 0
391
+ case '>': return c > 0
392
+ case '<=': return c <= 0
393
+ case '<': return c < 0
394
+ case '^': {
395
+ const upper = target.major > 0
396
+ ? { major: target.major + 1, minor: 0, patch: 0, pre: [ '0' ] }
397
+ : target.minor > 0
398
+ ? { major: 0, minor: target.minor + 1, patch: 0, pre: [ '0' ] }
399
+ : { major: 0, minor: 0, patch: target.patch + 1, pre: [ '0' ] }
400
+ return c >= 0 && compareSemver(v, upper) < 0
401
+ }
402
+ case '~': {
403
+ const upper = { major: target.major, minor: target.minor + 1, patch: 0, pre: [ '0' ] }
404
+ return c >= 0 && compareSemver(v, upper) < 0
405
+ }
406
+ default: return false
407
+ }
408
+ }
409
+
410
+ /**
411
+ * The host verdict for a DSH version against a package manifest.
412
+ * Prefers the declared range (`engines.dsh`, then `dsh.engines.dsh`); falls back to the
413
+ * `dsh.compatibility.dshReleases` map so packages that declare only that keep working.
414
+ */
415
+ export function dshVersionVerdict(version, manifest) {
416
+ const pkg = manifest && typeof manifest === 'object' ? manifest : {}
417
+ const declared = (pkg.engines && typeof pkg.engines.dsh === 'string' && pkg.engines.dsh.trim() !== '')
418
+ ? pkg.engines.dsh
419
+ : (pkg.dsh && pkg.dsh.engines && typeof pkg.dsh.engines.dsh === 'string' && pkg.dsh.engines.dsh.trim() !== '')
420
+ ? pkg.dsh.engines.dsh
421
+ : null
422
+ if (declared !== null) {
423
+ // includePrerelease: the whole published DSH line is prerelease builds.
424
+ const sat = satisfiesDshRange(version, declared, { includePrerelease: true })
425
+ return {
426
+ basis: 'engines',
427
+ requirement: declared,
428
+ status: sat === true ? 'compatible' : sat === false ? 'incompatible' : 'unknown',
429
+ }
430
+ }
431
+ const rel = (pkg.dsh && pkg.dsh.compatibility && pkg.dsh.compatibility.dshReleases) || {}
432
+ const status = rel[version]
433
+ if (status === 'compatible' || status === 'incompatible') {
434
+ return { basis: 'dshReleases', requirement: null, status }
435
+ }
436
+ return { basis: 'dshReleases', requirement: null, status: status === 'unknown' ? 'unknown' : 'undeclared' }
437
+ }
438
+
439
+ async function checkHostCapabilities(ctx, logger) {
440
+ const problems = []
441
+ // 1) DSH version compatibility (best-effort, only when the version is detectable).
442
+ // The AUTHORITATIVE source is the declared range `engines.dsh` / `dsh.engines.dsh` — the same
443
+ // field dsh-market renders on the plugin card, so the two verdicts cannot disagree. The
444
+ // `dsh.compatibility.dshReleases` map is the fallback for manifests without a range.
445
+ let manifest = {}
446
+ try { manifest = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')) } catch (e) {}
447
+ const dshRel = (manifest.dsh && manifest.dsh.compatibility && manifest.dsh.compatibility.dshReleases) || {}
448
+ const supported = Object.keys(dshRel).sort()
449
+ const detected = await detectDshVersion(ctx)
450
+ const dshVersion = detected && detected.version
451
+ const versionSource = detected && detected.source
452
+ const mechanism = detectPresetMechanism(ctx)
453
+ if (dshVersion) {
454
+ const verdict = dshVersionVerdict(dshVersion, manifest)
455
+ if (verdict.status === 'incompatible') {
456
+ problems.push(verdict.basis === 'engines'
457
+ ? '当前 DSH 版本 v' + dshVersion + ' 不满足本包声明的宿主版本要求(engines.dsh = ' + verdict.requirement + ')。'
458
+ : '当前 DSH 版本 v' + dshVersion + ' 被本包声明为 incompatible;请使用 ' + supported.join(' / ') + '。')
459
+ } else if (verdict.status === 'undeclared' || (verdict.status === 'unknown' && verdict.basis === 'dshReleases')) {
460
+ problems.push('当前 DSH 版本 v' + dshVersion + ' 尚未被本包声明为兼容(dshReleases 仅声明 ' + supported.join(' / ') + ');建议使用 ' + supported.join(' / ') + ',或将该版本在 dshReleases 中标注后再自行验证。')
461
+ } else if (verdict.status === 'unknown' && verdict.basis === 'engines') {
462
+ problems.push('当前 DSH 版本 v' + dshVersion + ' 无法与 engines.dsh 的范围比对(声明值 ' + verdict.requirement + ' 不是本安装器能解析的范围);请按该范围自行确认。')
463
+ }
464
+ }
465
+ // 2) capability self-check (the authoritative mounting gate; also covers hosts whose version
466
+ // could not be read). subagents / agents / tools / commands / fs shapes + v4 capabilities.
467
+ // `required: true` services are the mounting gate; the rest are optional services the presets
468
+ // read with ctx.get(). Their absence does not stop a mount but degrades SILENTLY, so report
469
+ // them instead of letting the field discover them: without `subprocess` no directory-creation
470
+ // shell runs, without `sandboxPolicy` writes carry no explicit fence, without `compaction` the
471
+ // v4 real /compact path is inert.
472
+ const checks = [
473
+ // subagents 服务的续做/唤醒方法是 sendMessage(sender, targetId, content, {signal});
474
+ // followup 不是 subagents 服务的方法(它只是 Agent 对象方法)。同时探测两者,能用一个即可。
475
+ { svc: 'subagents', methods: ['startContinuable', 'interrupt'], required: true },
476
+ { svc: 'agents', methods: ['roots'], required: true },
477
+ { svc: 'tools', methods: ['register'], required: true },
478
+ { svc: 'commands', methods: ['register'], required: true },
479
+ { svc: 'fs', methods: ['resolve', 'stat', 'readText', 'writeText', 'listDir'], required: true },
480
+ { svc: 'subprocess', methods: ['spawn'], required: false },
481
+ { svc: 'sandboxPolicy', methods: ['resolve'], required: false },
482
+ { svc: 'compaction', methods: ['compactIfNeeded'], required: false },
483
+ // v5 keeps its institute state in a hardened JSON file (State/<institute>.v5state.json) and does
484
+ // NOT use the session-projection registry or the session store any more: DSH refuses to load a
485
+ // session whose log carries event types outside its known set, so writing institute events into
486
+ // the user's session used to make that session unresumable. Neither service is checked here.
487
+ ]
488
+ const degradations = []
489
+ for (let i = 0; i < checks.length; i++) {
490
+ const svc = checks[i].svc
491
+ const methods = checks[i].methods
492
+ const required = checks[i].required === true
493
+ const report = required ? ((m) => problems.push(m)) : ((m) => degradations.push(m))
494
+ let s
495
+ try { s = (ctx && ctx.get) ? ctx.get(svc) : undefined } catch (e) { s = undefined }
496
+ if (s === undefined) { report('宿主缺少服务 ' + svc); continue }
497
+ for (let j = 0; j < methods.length; j++) {
498
+ if (typeof s[methods[j]] !== 'function') report(svc + '.' + methods[j] + ' 不可用(宿主版本可能过旧)')
499
+ }
500
+ // subagents continuation (wake) API: sendMessage (modern) OR followup (legacy) must exist.
501
+ if (svc === 'subagents' && typeof s.sendMessage !== 'function' && typeof s.followup !== 'function') {
502
+ problems.push('subagents 缺少续做/唤醒方法(需 sendMessage 或 followup 至少其一)')
503
+ }
504
+ }
505
+ // fs API shape: DSH 0.1.1 起 resolve 返回 {targetKey, displayPath} 对象(旧版返回字符串路径)
506
+ try {
507
+ const f = (ctx && ctx.get) ? ctx.get('fs') : undefined
508
+ if (f && typeof f.resolve === 'function') {
509
+ const r = await f.resolve('x', { cwd: process.cwd() })
510
+ if (typeof r !== 'object' || r === null || typeof r.targetKey !== 'string') {
511
+ problems.push('fs.resolve 返回形状不符(期望 {targetKey, displayPath},v3/v4 预设要求 DSH ≥ 0.1.1)')
512
+ }
513
+ }
514
+ } catch (e) { problems.push('fs.resolve 能力检测失败:' + String((e && e.message) || e)) }
515
+ // v4 依赖 subagents.startContinuable 的 agentOptions / toolFilter 能力(DSH 0.1.2 起由
516
+ // dsh-subagent 声明 SubagentCapabilities.agentOptions;spawn/fork 进程内 provider 均支持。
517
+ // 缺省 provider 名按 spawn 探测;探测失败不视为致命(等价于回退到再试一次、只警告)。
518
+ try {
519
+ const sa = (ctx && ctx.get) ? ctx.get('subagents') : undefined
520
+ if (sa && typeof sa.list === 'function') {
521
+ const names = (sa.list ? sa.list() : [])
522
+ const name = names.indexOf('spawn') !== -1 ? 'spawn' : (names[0] || '')
523
+ if (name && typeof sa.getProvider === 'function') {
524
+ const cap = (sa.getProvider(name) || {}).capabilities
525
+ if (cap && cap.agentOptions === false) problems.push('subagents provider "' + name + '" 不支持 agentOptions(v4 指定常驻模型/路由需要)')
526
+ if (cap && cap.toolFilter === false) problems.push('subagents provider "' + name + '" 不支持 toolFilter(v4 常驻工具权限需要)')
527
+ }
528
+ }
529
+ } catch (e) { /* 探测失败不致命 */ }
530
+ if (degradations.length > 0) {
531
+ logger?.warn?.('[dsh-vibe-math] 可选宿主服务缺失,功能会静默降级(不影响挂载):' + degradations.join(';') + '。subprocess 缺失则无法用 shell 创建目录树(仅靠 fs 自动建父目录兜底);sandboxPolicy 缺失则插件写入不带显式围栏;compaction 缺失则 v4 的真实 /compact 路径与 v5 的真实压缩不生效(v5 回退到自述浓缩)')
532
+ }
533
+ if (problems.length > 0) {
534
+ logger?.warn?.('[dsh-vibe-math] 宿主自检:' + problems.length + ' 项不满足(' + problems.join(';') + ')。v2/v3/v4/v5 预设依赖这些宿主服务/API,旧版或未经声明兼容的 DSH 可能无法挂载' + (dshVersion ? '(当前检测到 DSH v' + dshVersion + ',来源 ' + versionSource + ',本包适配 ' + (supported.length ? supported.join(' / ') : '(未声明)') + ')' : '') + '。')
535
+ } else {
536
+ logger?.info?.('[dsh-vibe-math] 宿主自检通过(“自检通过” ≠ “兼容”:这里只证明服务/API 的**形状**,不证明**行为**语义):subagents / agents / tools / commands / fs 服务及关键 API 均可用' + (degradations.length === 0 ? ',可选服务 subprocess / sandboxPolicy / compaction 亦齐备' : '(可选服务有缺失,见上方警告)') + (dshVersion ? '(当前 DSH v' + dshVersion + ',来源 ' + versionSource + ';本包已声明兼容 ' + supported.join(' / ') + ')' : '') + '。')
537
+ }
538
+ return { dshVersion, versionSource, mechanism }
539
+ }
540
+
541
+ export async function apply(ctx) {
542
+ const logger = ctx && ctx.logger
543
+ try {
544
+ const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh')
545
+ const here = dirname(fileURLToPath(import.meta.url))
546
+ const presetRoot = join(dshHome, '.agent-presets')
547
+ const stateFile = join(presetRoot, STATE_FILE)
548
+
549
+ // WHICH MECHANISM THIS HOST USES decides whether the directory work below means anything, so it
550
+ // is decided (and reported) before anything is written. The host self-check runs here too.
551
+ const host = await checkHostCapabilities(ctx, logger)
552
+ if (host && host.mechanism === 'rows') {
553
+ logger?.info?.('[dsh-vibe-math] preset 声明方式:本宿主以组合行声明 agent preset' +
554
+ (host.dshVersion ? '(DSH v' + host.dshVersion + ')' : '') + ',本包随附的 cordis.patch.yml 已声明四个 preset(dsh-vibe-math/preset-declaration);' +
555
+ '跳过 ' + presetRoot + ' 目录同步(该目录自 DSH 0.1.7 起不再被读取)。')
556
+ if (existsSync(stateFile)) {
557
+ logger?.info?.('[dsh-vibe-math] 提示:' + presetRoot + ' 里还留着旧版 DSH(≤ 0.1.6)读取过的 preset 副本,' +
558
+ '当前宿主不会再读它们,可以安全删除(要自定义 preset,请在预设选择器里复制一份,或改 profile 的 cordis.patch.yml)。')
559
+ }
560
+ return
561
+ }
562
+
563
+ // current package version (the source of truth for "is this an upgrade?")
564
+ let pkgVersion = ''
565
+ try { pkgVersion = String((JSON.parse(readFileSync(join(here, 'package.json'), 'utf8')).version) || '') } catch (e) { pkgVersion = '' }
566
+
567
+ const state = readState(stateFile)
568
+ const prevFiles = (state && state.files) || {}
569
+ const isUpgrade = state !== null && pkgVersion !== '' && state.version !== pkgVersion
570
+ const isBaseline = state === null // no recorded history → refresh everything
571
+ // A version change (or a first sighting) REPLACES the managed files; only a same-version boot
572
+ // leaves the working tree alone. See UPDATE POLICY at the top of this file.
573
+ const refresh = isBaseline || isUpgrade
574
+ const fromVersion = (state && state.version) || '(unversioned)'
575
+ if (pkgVersion === '') {
576
+ // Without a version there is nothing to compare against, so this run must NOT replace
577
+ // anything (a wrong guess would overwrite files for no reason); it still restores missing
578
+ // ones and keeps the previously recorded version, so the next readable run reports the
579
+ // right "from" version.
580
+ logger?.warn?.('[dsh-vibe-math] 读不到本包版本(package.json 缺失或损坏):本次不做版本比对,只补回缺失的 preset 文件。')
581
+ }
582
+ // A recorded version NEWER than this package means this run replaces preset bytes BACKWARDS.
583
+ // The policy still does it — "the preset directory equals the installed package" is the whole
584
+ // point — but a silent downgrade (an old bundle still installed in the profile while the presets
585
+ // were synced from a newer one) is exactly the surprise worth naming.
586
+ if (isUpgrade) {
587
+ const recorded = parseSemver(fromVersion)
588
+ const installed = parseSemver(pkgVersion)
589
+ if (recorded !== null && installed !== null && compareSemver(recorded, installed) > 0) {
590
+ logger?.warn?.('[dsh-vibe-math] 记录里的版本(' + fromVersion + ')比本包版本(' + pkgVersion + ')新,本次会把 preset 换回旧字节。' +
591
+ '若这不是你想要的,请先升级 profile 里的依赖:dsh plugin --profile <name> add dsh-vibe-math@latest,再重启 DSH。')
592
+ }
593
+ }
594
+
595
+ const nextFiles = {}
596
+ let installed = 0, updated = 0, kept = 0
597
+ let userEditedKept = 0
598
+ const replacedEdits = []
599
+ const unknownPrev = []
600
+ const backupFailures = []
601
+
602
+ for (const p of PRESETS) {
603
+ const srcDir = join(here, p.src)
604
+ const dstDir = join(presetRoot, p.dst)
605
+ if (!existsSync(srcDir)) continue
606
+ mkdirSync(dstDir, { recursive: true })
607
+ for (const f of p.files) {
608
+ const s = join(srcDir, f)
609
+ const d = join(dstDir, f)
610
+ if (!existsSync(s)) continue
611
+ const key = p.src + '/' + f
612
+ const cur = readFileSync(s)
613
+ const curHash = sha256(cur)
614
+ if (!existsSync(d)) {
615
+ // missing file: always restore, whatever the version
616
+ writePresetFile(d, cur)
617
+ installed += 1
618
+ nextFiles[key] = { hash: curHash, provenance: 'package' }
619
+ continue
620
+ }
621
+ const destBuf = readFileSync(d)
622
+ const destHash = sha256(destBuf)
623
+ const prev = prevFiles[key]
624
+ const prevRec = (prev && typeof prev === 'object') ? prev : { hash: prev, provenance: 'package' }
625
+ if (destHash === curHash) {
626
+ // already the shipped bytes: never rewrite, so the file's mtime (which keys the preset's
627
+ // DSH generation) stays put
628
+ nextFiles[key] = { hash: curHash, provenance: 'package' }
629
+ continue
630
+ }
631
+ if (!refresh) {
632
+ // same version: nothing is being updated, so a file that differs from the package is left
633
+ // exactly as it is. The recorded hash stays "what this installer last wrote" (or unknown),
634
+ // so the drift is still recognised — and backed up — at the next version change.
635
+ //
636
+ // PROVENANCE, recorded honestly: when the on-disk bytes are NOT what this installer last
637
+ // wrote, something else edited this managed file, so it is marked user-owned — which is
638
+ // what makes the stale-preset cleanup below KEEP it instead of unlinking it. A legacy
639
+ // state with no hash cannot tell, so it stays 'package' (it is still backed up before any
640
+ // version-change replacement; the cleanup backs it up before deleting too).
641
+ kept += 1
642
+ const userEdited = typeof prevRec.hash === 'string' && destHash !== prevRec.hash
643
+ if (userEdited) userEditedKept += 1
644
+ nextFiles[key] = userEdited
645
+ ? { hash: prevRec.hash, provenance: 'user' }
646
+ : typeof prevRec.hash === 'string' ? { hash: prevRec.hash, provenance: 'package' } : { provenance: 'package' }
647
+ continue
648
+ }
649
+ // replacing: preserve the user's bytes when they are not what this installer last wrote.
650
+ // The two cases are NOT the same claim and are counted apart: bytes that differ from the
651
+ // recorded hash are a provable edit, while a legacy state WITHOUT a hash cannot tell — that
652
+ // file is backed up all the same, but the log must not call it "被改过" (it cannot know).
653
+ if (typeof prevRec.hash !== 'string') {
654
+ if (backupReplacedFile(presetRoot, fromVersion, p.dst, f, destBuf) === 'failed') backupFailures.push(key)
655
+ unknownPrev.push(key)
656
+ } else if (destHash !== prevRec.hash) {
657
+ if (backupReplacedFile(presetRoot, fromVersion, p.dst, f, destBuf) === 'failed') backupFailures.push(key)
658
+ replacedEdits.push(key)
659
+ }
660
+ writePresetFile(d, cur)
661
+ updated += 1
662
+ nextFiles[key] = { hash: curHash, provenance: 'package' }
663
+ }
664
+ }
665
+
666
+ // Clean up preset dirs that this bundle NO LONGER manages (e.g. vibe-math-v1 after it was
667
+ // removed at v2.0.0). The copy loop only adds/updates PRESETS; it never deletes a preset that
668
+ // was dropped, so an old removed preset would linger in the picker forever. Here we remove the
669
+ // files this installer previously recorded as package-owned under a prefix that is no longer in
670
+ // PRESETS, then drop the dir if it became empty. Files recorded as user-owned are KEPT.
671
+ //
672
+ // DELETION IS THE ONE PATH THAT DOES NOT REPLACE, so it obeys the same rule as the replacement
673
+ // path: a file whose bytes are not what this installer last wrote is copied to
674
+ // <presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file> FIRST and named in the log. A file
675
+ // that still matches its recorded hash IS the package's copy, so removing it destroys nothing.
676
+ const currentPrefixes = new Set(PRESETS.map(p => p.src + '/'))
677
+ let removedFiles = 0
678
+ let matchedPackageFiles = 0
679
+ let keptUserFiles = 0
680
+ const removedDirs = []
681
+ const backedUpStale = []
682
+ const backupFailedStale = []
683
+ const unreadableStale = []
684
+ const keptUserDirs = new Set()
685
+ const stale = new Map() // prefix -> [keys]
686
+ for (const key of Object.keys(prevFiles)) {
687
+ const slash = key.indexOf('/')
688
+ if (slash === -1) continue
689
+ const prefix = key.slice(0, slash + 1)
690
+ if (currentPrefixes.has(prefix)) continue
691
+ if (!stale.has(prefix)) stale.set(prefix, [])
692
+ stale.get(prefix).push(key)
693
+ }
694
+ for (const [prefix, keys] of stale) {
695
+ let dirEmpty = true
696
+ for (const key of keys) {
697
+ const rec = (prevFiles[key] && typeof prevFiles[key] === 'object') ? prevFiles[key] : { provenance: 'package' }
698
+ if (rec.provenance === 'user') { dirEmpty = false; keptUserFiles += 1; keptUserDirs.add(prefix.slice(0, -1)); continue } // 用户文件 → 保留
699
+ const f = join(presetRoot, key)
700
+ if (existsSync(f)) {
701
+ let buf = null
702
+ try { buf = readFileSync(f) } catch (e) { /* unreadable: treated as "cannot prove it is ours" */ }
703
+ // A file that still matches its recorded hash IS the copy this installer wrote, so deleting
704
+ // it destroys nothing. Anything else — edited bytes, or a legacy record with no hash that
705
+ // cannot tell — is copied to the backup root first. Deletion is allowed ONLY when nothing of
706
+ // the user's can be lost: a package copy, or bytes that really were preserved. An unreadable
707
+ // file cannot be backed up, and a FAILED backup means the bytes were not preserved either —
708
+ // both are KEPT and named in the log. A stale directory left behind is cosmetic; destroyed
709
+ // user bytes are not. (The REPLACEMENT path above keeps its own rule — a failed backup must
710
+ // not block a version upgrade — because there the file is overwritten, not removed.)
711
+ const isPackageCopy = buf !== null && typeof rec.hash === 'string' && sha256(buf) === rec.hash
712
+ let mayDelete = false
713
+ if (isPackageCopy) {
714
+ matchedPackageFiles += 1
715
+ mayDelete = true
716
+ } else if (buf !== null) {
717
+ const cut = key.lastIndexOf('/')
718
+ const backup = backupReplacedFile(presetRoot, fromVersion, key.slice(0, cut), key.slice(cut + 1), buf)
719
+ if (backup === 'failed') backupFailedStale.push(key)
720
+ else { backedUpStale.push(key); mayDelete = true }
721
+ } else {
722
+ unreadableStale.push(key)
723
+ }
724
+ if (mayDelete) { try { unlinkSync(f); removedFiles += 1 } catch (e) {} }
725
+ }
726
+ if (existsSync(f)) dirEmpty = false
727
+ }
728
+ const dir = join(presetRoot, prefix.slice(0, -1))
729
+ if (dirEmpty && existsSync(dir)) { try { rmdirSync(dir); removedDirs.push(dir) } catch (e) {} }
730
+ }
731
+
732
+ writeState(stateFile, { version: pkgVersion || (state && state.version) || '', files: nextFiles, updatedAt: Date.now() }, state, logger)
733
+
734
+ const cleanupNotes = []
735
+ if (matchedPackageFiles > 0) cleanupNotes.push(matchedPackageFiles + ' 个仍是本安装器写入的字节(删掉的不是你的改动)')
736
+ if (backedUpStale.length > 0) cleanupNotes.push(backedUpStale.length + ' 个的字节与上一次安装不同(被改过),原文已备份在 ' + join(presetRoot, BACKUP_DIR, String(fromVersion)) + ':' + backedUpStale.join(', '))
737
+ if (backupFailedStale.length > 0) cleanupNotes.push(backupFailedStale.length + ' 个被改过的文件**备份失败**(原文未保留,已跳过删除并保留原文件):' + backupFailedStale.join(', '))
738
+ if (unreadableStale.length > 0) cleanupNotes.push(unreadableStale.length + ' 个文件**无法读取**(既不能证明是本安装器写入的,也无法备份),已跳过删除并保留原文件:' + unreadableStale.join(', '))
739
+ if (keptUserFiles > 0) cleanupNotes.push(keptUserFiles + ' 个记为用户所有的文件被保留(' + [...keptUserDirs].join(', ') + '),要清理请手动删除')
740
+ if (removedFiles > 0 || removedDirs.length > 0 || cleanupNotes.length > 0) {
741
741
  // 约定:安装器面向用户的日志统一中文(英文片段混进中文句子会让用户读到半句话)。
742
- logger?.info?.('[dsh-vibe-math] 预设清理:从 ' + removedDirs.length + ' 个已不再随包发布的过期预设目录(' + removedDirs.map(d => d.split(/[\\/]/).pop()).join(', ') + ') 中删除 ' + removedFiles + ' 个文件' +
743
- (cleanupNotes.length > 0 ? ';' + cleanupNotes.join(';') : '') + '。')
744
- }
745
-
746
- const backupsDir = join(presetRoot, BACKUP_DIR, String(fromVersion))
747
- // Two kinds of preserved bytes, reported apart because they are not the same claim:
748
- // · replacedEdits — the bytes on disk are NOT what this installer last wrote: a provable edit.
749
- // · unknownPrev — a legacy state with no hash cannot tell; backed up all the same, and never
750
- // called "被改过" (the old wording claimed an edit it could not see).
751
- const editClause = replacedEdits.length > 0
752
- ? '其中 ' + replacedEdits.length + ' 个文件与上一次安装的字节不同(被改过),已按版本一致化覆盖' +
753
- (backupFailures.length === 0
754
- ? ',原文备份在 ' + backupsDir + ':' + replacedEdits.join(', ')
755
- : ';这 ' + backupFailures.length + ' 个文件**备份失败**(原文未保留):' + backupFailures.join(', ')) +
756
- '。要自定义 preset,请复制一份而不是改这几个文件——被管理的文件在下一次版本变更时一定会被替换。'
757
- : ''
758
- const unknownClause = unknownPrev.length > 0
759
- ? '另有 ' + unknownPrev.length + ' 个文件在旧版状态里没有哈希(无法判断你是否改过),已先备份原文到 ' + backupsDir + ' 再替换' +
760
- (backupFailures.length > 0 ? '(其中备份失败:' + backupFailures.join(', ') + ')' : '') + '。'
761
- : ''
762
-
763
- if (isUpgrade) {
764
- logger?.info?.('[dsh-vibe-math] 预设自动更新:版本 ' + fromVersion + ' → ' + pkgVersion +
765
- ' — 新增 ' + installed + ' 个文件,更新 ' + updated + ' 个文件。' +
766
- editClause + unknownClause +
767
- '新版本 preset 将在新会话生效。')
768
- } else if (isBaseline) {
769
- logger?.info?.('[dsh-vibe-math] 预设基线:已刷新 ' + (installed + updated) + ' file(s) to v' + pkgVersion +
770
- (replacedEdits.length > 0
771
- ? ',其中 ' + replacedEdits.length + ' 个原有文件与随包版本不同' +
772
- (backupFailures.length === 0 ? ',原文已备份在 ' + backupsDir
773
- : ',但有 ' + backupFailures.length + ' 个备份失败(原文未保留):' + backupFailures.join(', '))
774
- : '') +
775
- unknownClause +
776
- ' — 已启用版本化自动更新(后续版本变更会直接替换被管理的 preset 文件)。')
777
- } else if (installed > 0 || kept > 0) {
778
- // SAME VERSION, and BOTH facts are reported: a missing file being restored must not hide the
779
- // drift notice (the audit's repro: the log said "restored 1" and never mentioned the drift).
780
- const parts = []
781
- if (installed > 0) parts.push('还原了 ' + installed + ' 个缺失的预设文件')
782
- if (kept > 0) {
783
- parts.push(kept + ' 个文件与随包副本不同,已保持原样(v' + pkgVersion + ' 未变)' +
784
- (userEditedKept > 0 ? ',其中 ' + userEditedKept + ' 个是安装之后的改动(记为用户所有:弃用 preset 的清理不会删除它们)' : '') +
785
- ';下一次版本变更会先备份原文再替换')
786
- }
787
- logger?.info?.('[dsh-vibe-math] 预设文件:' + parts.join(';') + '。')
788
- }
789
- /* the host self-check ran at the top of apply() — it also decides the preset mechanism */
790
- } catch (err) {
791
- logger?.warn?.('[dsh-vibe-math] 预设安装/更新失败:' + String((err && err.message) || err))
792
- }
793
- }
742
+ logger?.info?.('[dsh-vibe-math] 预设清理:从 ' + removedDirs.length + ' 个已不再随包发布的过期预设目录(' + removedDirs.map(d => d.split(/[\\/]/).pop()).join(', ') + ') 中删除 ' + removedFiles + ' 个文件' +
743
+ (cleanupNotes.length > 0 ? ';' + cleanupNotes.join(';') : '') + '。')
744
+ }
745
+
746
+ const backupsDir = join(presetRoot, BACKUP_DIR, String(fromVersion))
747
+ // Two kinds of preserved bytes, reported apart because they are not the same claim:
748
+ // · replacedEdits — the bytes on disk are NOT what this installer last wrote: a provable edit.
749
+ // · unknownPrev — a legacy state with no hash cannot tell; backed up all the same, and never
750
+ // called "被改过" (the old wording claimed an edit it could not see).
751
+ const editClause = replacedEdits.length > 0
752
+ ? '其中 ' + replacedEdits.length + ' 个文件与上一次安装的字节不同(被改过),已按版本一致化覆盖' +
753
+ (backupFailures.length === 0
754
+ ? ',原文备份在 ' + backupsDir + ':' + replacedEdits.join(', ')
755
+ : ';这 ' + backupFailures.length + ' 个文件**备份失败**(原文未保留):' + backupFailures.join(', ')) +
756
+ '。要自定义 preset,请复制一份而不是改这几个文件——被管理的文件在下一次版本变更时一定会被替换。'
757
+ : ''
758
+ const unknownClause = unknownPrev.length > 0
759
+ ? '另有 ' + unknownPrev.length + ' 个文件在旧版状态里没有哈希(无法判断你是否改过),已先备份原文到 ' + backupsDir + ' 再替换' +
760
+ (backupFailures.length > 0 ? '(其中备份失败:' + backupFailures.join(', ') + ')' : '') + '。'
761
+ : ''
762
+
763
+ if (isUpgrade) {
764
+ logger?.info?.('[dsh-vibe-math] 预设自动更新:版本 ' + fromVersion + ' → ' + pkgVersion +
765
+ ' — 新增 ' + installed + ' 个文件,更新 ' + updated + ' 个文件。' +
766
+ editClause + unknownClause +
767
+ '新版本 preset 将在新会话生效。')
768
+ } else if (isBaseline) {
769
+ logger?.info?.('[dsh-vibe-math] 预设基线:已刷新 ' + (installed + updated) + ' file(s) to v' + pkgVersion +
770
+ (replacedEdits.length > 0
771
+ ? ',其中 ' + replacedEdits.length + ' 个原有文件与随包版本不同' +
772
+ (backupFailures.length === 0 ? ',原文已备份在 ' + backupsDir
773
+ : ',但有 ' + backupFailures.length + ' 个备份失败(原文未保留):' + backupFailures.join(', '))
774
+ : '') +
775
+ unknownClause +
776
+ ' — 已启用版本化自动更新(后续版本变更会直接替换被管理的 preset 文件)。')
777
+ } else if (installed > 0 || kept > 0) {
778
+ // SAME VERSION, and BOTH facts are reported: a missing file being restored must not hide the
779
+ // drift notice (the audit's repro: the log said "restored 1" and never mentioned the drift).
780
+ const parts = []
781
+ if (installed > 0) parts.push('还原了 ' + installed + ' 个缺失的预设文件')
782
+ if (kept > 0) {
783
+ parts.push(kept + ' 个文件与随包副本不同,已保持原样(v' + pkgVersion + ' 未变)' +
784
+ (userEditedKept > 0 ? ',其中 ' + userEditedKept + ' 个是安装之后的改动(记为用户所有:弃用 preset 的清理不会删除它们)' : '') +
785
+ ';下一次版本变更会先备份原文再替换')
786
+ }
787
+ logger?.info?.('[dsh-vibe-math] 预设文件:' + parts.join(';') + '。')
788
+ }
789
+ /* the host self-check ran at the top of apply() — it also decides the preset mechanism */
790
+ } catch (err) {
791
+ logger?.warn?.('[dsh-vibe-math] 预设安装/更新失败:' + String((err && err.message) || err))
792
+ }
793
+ }