dsh-vibe-math 2.3.11 → 2.3.13

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 (50) hide show
  1. package/README.md +17 -40
  2. package/{AUDIT-CHECKLIST.md → docs/AUDIT-CHECKLIST.md} +323 -304
  3. package/docs/COMPAT-AUDIT-ROUND2.md +325 -0
  4. package/docs/generate_framework_diagram_v2.py +114 -0
  5. package/docs/generate_framework_diagram_v3.py +127 -0
  6. package/{RELEASE-NOTES-2.1.0.md → docs/release-notes/RELEASE-NOTES-2.1.0.md} +143 -143
  7. package/{RELEASE-NOTES-2.2.0.md → docs/release-notes/RELEASE-NOTES-2.2.0.md} +266 -266
  8. package/{RELEASE-NOTES-2.2.1.md → docs/release-notes/RELEASE-NOTES-2.2.1.md} +43 -43
  9. package/{RELEASE-NOTES-2.2.2.md → docs/release-notes/RELEASE-NOTES-2.2.2.md} +88 -88
  10. package/{RELEASE-NOTES-2.3.0.md → docs/release-notes/RELEASE-NOTES-2.3.0.md} +207 -207
  11. package/{RELEASE-NOTES-2.3.1.md → docs/release-notes/RELEASE-NOTES-2.3.1.md} +134 -134
  12. package/{RELEASE-NOTES-2.3.10.md → docs/release-notes/RELEASE-NOTES-2.3.10.md} +105 -105
  13. package/{RELEASE-NOTES-2.3.11.md → docs/release-notes/RELEASE-NOTES-2.3.11.md} +57 -57
  14. package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -0
  15. package/docs/release-notes/RELEASE-NOTES-2.3.13.md +137 -0
  16. package/{RELEASE-NOTES-2.3.2.md → docs/release-notes/RELEASE-NOTES-2.3.2.md} +145 -145
  17. package/{RELEASE-NOTES-2.3.3.md → docs/release-notes/RELEASE-NOTES-2.3.3.md} +115 -115
  18. package/{RELEASE-NOTES-2.3.4.md → docs/release-notes/RELEASE-NOTES-2.3.4.md} +69 -69
  19. package/{RELEASE-NOTES-2.3.5.md → docs/release-notes/RELEASE-NOTES-2.3.5.md} +63 -63
  20. package/{RELEASE-NOTES-2.3.6.md → docs/release-notes/RELEASE-NOTES-2.3.6.md} +66 -66
  21. package/{RELEASE-NOTES-2.3.7.md → docs/release-notes/RELEASE-NOTES-2.3.7.md} +59 -59
  22. package/{RELEASE-NOTES-2.3.8.md → docs/release-notes/RELEASE-NOTES-2.3.8.md} +45 -45
  23. package/{RELEASE-NOTES-2.3.9.md → docs/release-notes/RELEASE-NOTES-2.3.9.md} +70 -70
  24. package/docs/test-timing.md +18 -17
  25. package/installer.js +258 -53
  26. package/package.json +50 -40
  27. package/{audit-formal-sensitivity.mjs → tests/audit-formal-sensitivity.mjs} +342 -342
  28. package/tests/audit-installer-compat.test.mjs +136 -0
  29. package/tests/audit-installer-policy.test.mjs +245 -0
  30. package/{audit-persona-sensitivity.mjs → tests/audit-persona-sensitivity.mjs} +249 -249
  31. package/{audit-persona-surface.test.mjs → tests/audit-persona-surface.test.mjs} +349 -349
  32. package/{audit-prompt-invariants.mjs → tests/audit-prompt-invariants.mjs} +508 -508
  33. package/{audit-spec-traceability.mjs → tests/audit-spec-traceability.mjs} +193 -193
  34. package/{audit-v5-integrity.mjs → tests/audit-v5-integrity.mjs} +448 -448
  35. package/{audit-v5-sensitivity.mjs → tests/audit-v5-sensitivity.mjs} +384 -384
  36. package/{e2e-v5-round2.test.mjs → tests/e2e-v5-round2.test.mjs} +521 -521
  37. package/{formal-verify-v2.test.mjs → tests/formal-verify-v2.test.mjs} +1315 -1315
  38. package/{formal-verify-v3.test.mjs → tests/formal-verify-v3.test.mjs} +1257 -1257
  39. package/{formal-verify-v4.test.mjs → tests/formal-verify-v4.test.mjs} +1082 -1082
  40. package/{formal-verify-v5.test.mjs → tests/formal-verify-v5.test.mjs} +708 -708
  41. package/{prompt-v5-integrity.test.mjs → tests/prompt-v5-integrity.test.mjs} +3 -3
  42. package/{run-tests.mjs → tests/run-tests.mjs} +121 -118
  43. package/{selfdrive-v5.mjs → tests/selfdrive-v5.mjs} +470 -470
  44. package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1 -1
  45. package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1 -1
  46. package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +1 -1
  47. package//347/244/272/344/276/213/345/233/276//345/256/236/351/231/205/344/275/277/347/224/250/347/244/272/344/276/213-/351/225/277/346/210/252/345/233/276.png +0 -0
  48. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v2.png +0 -0
  49. package//347/244/272/344/276/213/345/233/276//346/241/206/346/236/266/345/233/276-v3.png +0 -0
  50. /package/{RELEASE-NOTES-2.0.22.md → docs/release-notes/RELEASE-NOTES-2.0.22.md} +0 -0
package/installer.js CHANGED
@@ -1,28 +1,36 @@
1
1
  // dsh-vibe-math merged bundle installer — VERSIONED AUTO-UPDATE.
2
2
  // When this bundle is installed (e.g. `dsh plugin add dsh-vibe-math` or from the
3
- // dsh-market), this plugin copies ALL THREE agent presets out of the package into
4
- // the DSH preset root, so the user immediately gets three presets in the picker:
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
5
  // vibe-math-v2/ (probability-driven architecture)
6
6
  // vibe-math-v3/ (THIRD-generation: paper-style Markdown knowledge base +
7
7
  // planner-agent scheduling + universal theory/method library)
8
8
  // vibe-math-v4/ (FOURTH-generation: persistent self-organizing resident
9
9
  // subagents — message bus / meetings / unanimous-consensus
10
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)
11
14
  //
12
15
  // (vibe-math-v1 — the classic pipeline — was removed at v2.0.0; this bundle now
13
- // ships v2/v3/v4 only.)
16
+ // ships v2/v3/v4/v5.)
14
17
  //
15
18
  // UPDATE POLICY (state recorded in <presetRoot>/.vibe-math-installed.json):
16
- // - baseline (no state file — e.g. upgrading from an installer that predates
17
- // this mechanism): every existing owned file is refreshed to the current
18
- // package version and recorded as package-owned (user policy: auto-update
19
- // old installs; any manual edits made before this baseline are overwritten
20
- // once — from then on edits are protected).
21
- // - upgrade (recorded version != current package.json version): every owned
22
- // file that is byte-identical to the previously installed copy (i.e. NOT
23
- // user-edited since) is overwritten with the new version; user-edited files
24
- // are preserved and reported via the logger.
25
- // - same version: no-op (idempotent). Missing files are ALWAYS restored.
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, the user's copy is kept under
31
+ // <presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file> and named in the log.
32
+ // - same version: no-op (idempotent) — restarting DSH never rewrites a file or churns the
33
+ // preset's generation stamp. Missing files are ALWAYS restored, at any version.
26
34
  // - force a full refresh at any time: delete the preset dirs and restart DSH.
27
35
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync, rmdirSync } from 'node:fs'
28
36
  import { createRequire } from 'node:module'
@@ -33,7 +41,10 @@ import { fileURLToPath } from 'node:url'
33
41
 
34
42
  export const name = 'vibe-math-preset-installer'
35
43
 
36
- const PRESETS = [
44
+ // Exported so the shipped policy suite can prove the managed list still covers what each preset
45
+ // needs at runtime (a file present in the preset directory but missing here is copied by nobody,
46
+ // and the installed preset then cannot mount).
47
+ export const PRESETS = [
37
48
  {
38
49
  src: 'vibe-math-v2',
39
50
  dst: 'vibe-math-v2',
@@ -59,9 +70,34 @@ const PRESETS = [
59
70
  ]
60
71
 
61
72
  const STATE_FILE = '.vibe-math-installed.json'
73
+ // where a replaced user edit is preserved; a leading dot keeps DSH's preset discovery from ever
74
+ // treating it as a preset directory (ids must match [a-z0-9][a-z0-9-]*)
75
+ const BACKUP_DIR = '.vibe-math-backup'
62
76
 
63
77
  function sha256(buf) { return createHash('sha256').update(buf).digest('hex') }
64
78
 
79
+ /**
80
+ * Preserve one file that is about to be replaced by the shipped version, under
81
+ * `<presetRoot>/.vibe-math-backup/<fromVersion>/<preset>/<file>`.
82
+ *
83
+ * Returns `'written'` (a copy was made now), `'kept'` (a copy for this version was already there —
84
+ * the earliest edit is the one worth keeping, and a re-run must not overwrite it with an
85
+ * already-replaced file) or `'failed'`. A caller must not report `'kept'` as a failure: the user's
86
+ * bytes are preserved, just from an earlier run.
87
+ */
88
+ function backupReplacedFile(presetRoot, fromVersion, presetDir, fileName, buf) {
89
+ try {
90
+ const dir = join(presetRoot, BACKUP_DIR, String(fromVersion || 'unversioned'), presetDir)
91
+ const dst = join(dir, fileName)
92
+ if (existsSync(dst)) return 'kept' // the earliest copy for this version is the one worth keeping
93
+ mkdirSync(dir, { recursive: true })
94
+ writeFileSync(dst, buf)
95
+ return 'written'
96
+ } catch (e) {
97
+ return 'failed' // a backup failure must never stop the update; it is reported by the caller
98
+ }
99
+ }
100
+
65
101
  function readState(path) {
66
102
  try {
67
103
  const raw = readFileSync(path, 'utf8')
@@ -99,22 +135,160 @@ function detectDshVersion() {
99
135
  return undefined
100
136
  }
101
137
 
138
+ /**
139
+ * Minimal semver-range matcher for the host requirement.
140
+ *
141
+ * WHY THIS EXISTS: the DSH requirement a host is judged against lives in `package.json`
142
+ * (`engines.dsh` / `dsh.engines.dsh`) — the same declaration dsh-market shows on a plugin's card.
143
+ * Judging the host by a *second* source (the `dsh.compatibility.dshReleases` map) let the two
144
+ * disagree: the card could say "compatible" while this self-check warned, or the reverse. So the
145
+ * range is evaluated here, and the map remains only a fallback for manifests that declare no range.
146
+ *
147
+ * Supported: `*`, exact, `^`, `~`, `>=`, `>`, `<=`, `<`, whitespace-separated sets, `||`
148
+ * alternatives — everything the ecosystem publishes (see `dshmarket`'s own
149
+ * `^0.1.0-rc.7 || ^0.1.1-rc.2 || ^0.1.2-alpha.2` shape). Anything else returns null = unknown
150
+ * (reported, never asserted), never a silent "incompatible".
151
+ *
152
+ * PRERELEASE RULE (npm's, applied per comparator SET — one `||` alternative is one set): a version
153
+ * carrying a prerelease tag satisfies a set only when at least one comparator in that set shares its
154
+ * [major, minor, patch] tuple AND carries a prerelease of its own. This is why
155
+ * `>=0.1.2-rc.1 <0.2.0` does NOT match `0.1.5-rc.2`, and why this package declares explicit
156
+ * per-tuple branches instead. Callers pass `includePrerelease: true` for host checks because every
157
+ * published DSH release line is itself a prerelease.
158
+ */
159
+ export function satisfiesDshRange(version, range, options = {}) {
160
+ const v = parseSemver(version)
161
+ if (v === null || typeof range !== 'string' || range.trim() === '') return null
162
+ const versionHasPre = v.pre.length > 0
163
+ let sawUnknown = false
164
+ for (const set of range.split('||')) {
165
+ const parts = set.trim().split(/\s+/).filter((p) => p !== '')
166
+ if (parts.length === 0) return true // an empty alternative is `*`
167
+ const parsed = parts.map(comparator)
168
+ if (parsed.some((p) => p === null)) { sawUnknown = true; continue }
169
+ if (versionHasPre && options.includePrerelease !== true) {
170
+ const admitted = parsed.some((p) => p.target !== null && p.target.pre.length > 0 &&
171
+ p.target.major === v.major && p.target.minor === v.minor && p.target.patch === v.patch)
172
+ if (!admitted) continue
173
+ } if (parsed.every((p) => matchesComparator(v, p))) return true
174
+ }
175
+ return sawUnknown ? null : false
176
+ }
177
+
178
+ function parseSemver(value) {
179
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value == null ? '' : value).trim())
180
+ if (m === null) return null
181
+ const pre = m[4] === undefined ? [] : m[4].split('.')
182
+ for (const id of pre) if (/^\d+$/.test(id) && id.length > 1 && id[0] === '0') return null
183
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre }
184
+ }
185
+
186
+ function compareSemver(a, b) {
187
+ if (a.major !== b.major) return a.major < b.major ? -1 : 1
188
+ if (a.minor !== b.minor) return a.minor < b.minor ? -1 : 1
189
+ if (a.patch !== b.patch) return a.patch < b.patch ? -1 : 1
190
+ if (a.pre.length === 0 && b.pre.length === 0) return 0
191
+ if (a.pre.length === 0) return 1 // a release outranks its prereleases
192
+ if (b.pre.length === 0) return -1
193
+ for (let i = 0; i < Math.max(a.pre.length, b.pre.length); i++) {
194
+ const x = a.pre[i], y = b.pre[i]
195
+ if (x === undefined) return -1
196
+ if (y === undefined) return 1
197
+ const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y)
198
+ if (xn && yn) { const d = Number(x) - Number(y); if (d !== 0) return d < 0 ? -1 : 1; continue }
199
+ if (xn !== yn) return xn ? -1 : 1 // numeric identifiers sort below alphanumeric ones
200
+ if (x !== y) return x < y ? -1 : 1
201
+ }
202
+ return 0
203
+ }
204
+
205
+ /** One comparator such as `^0.1.0-rc.7`. Returns null when the target is not a version. */
206
+ function comparator(part) {
207
+ const p = part.trim()
208
+ // `*`, `x`, `X` and an empty token mean "any version" (npm: still not a prerelease unless the
209
+ // caller opts into includePrerelease — the set-level gate below is what enforces that).
210
+ if (p === '' || p === '*' || p === 'x' || p === 'X') return { op: 'any', target: null }
211
+ const m = /^(\^|~|>=|<=|>|<)?(.*)$/.exec(p)
212
+ const op = m === null || m[1] === undefined ? '' : m[1]
213
+ const target = parseSemver(m === null ? '' : m[2])
214
+ return target === null ? null : { op, target }
215
+ }
216
+
217
+ function matchesComparator(v, { op, target }) {
218
+ if (op === 'any') return true
219
+ const c = compareSemver(v, target)
220
+ switch (op) {
221
+ case '': return c === 0
222
+ case '>=': return c >= 0
223
+ case '>': return c > 0
224
+ case '<=': return c <= 0
225
+ case '<': return c < 0
226
+ case '^': {
227
+ const upper = target.major > 0
228
+ ? { major: target.major + 1, minor: 0, patch: 0, pre: [ '0' ] }
229
+ : target.minor > 0
230
+ ? { major: 0, minor: target.minor + 1, patch: 0, pre: [ '0' ] }
231
+ : { major: 0, minor: 0, patch: target.patch + 1, pre: [ '0' ] }
232
+ return c >= 0 && compareSemver(v, upper) < 0
233
+ }
234
+ case '~': {
235
+ const upper = { major: target.major, minor: target.minor + 1, patch: 0, pre: [ '0' ] }
236
+ return c >= 0 && compareSemver(v, upper) < 0
237
+ }
238
+ default: return false
239
+ }
240
+ }
241
+
242
+ /**
243
+ * The host verdict for a DSH version against a package manifest.
244
+ * Prefers the declared range (`engines.dsh`, then `dsh.engines.dsh`); falls back to the
245
+ * `dsh.compatibility.dshReleases` map so packages that declare only that keep working.
246
+ */
247
+ export function dshVersionVerdict(version, manifest) {
248
+ const pkg = manifest && typeof manifest === 'object' ? manifest : {}
249
+ const declared = (pkg.engines && typeof pkg.engines.dsh === 'string' && pkg.engines.dsh.trim() !== '')
250
+ ? pkg.engines.dsh
251
+ : (pkg.dsh && pkg.dsh.engines && typeof pkg.dsh.engines.dsh === 'string' && pkg.dsh.engines.dsh.trim() !== '')
252
+ ? pkg.dsh.engines.dsh
253
+ : null
254
+ if (declared !== null) {
255
+ // includePrerelease: the whole published DSH line is prerelease builds.
256
+ const sat = satisfiesDshRange(version, declared, { includePrerelease: true })
257
+ return {
258
+ basis: 'engines',
259
+ requirement: declared,
260
+ status: sat === true ? 'compatible' : sat === false ? 'incompatible' : 'unknown',
261
+ }
262
+ }
263
+ const rel = (pkg.dsh && pkg.dsh.compatibility && pkg.dsh.compatibility.dshReleases) || {}
264
+ const status = rel[version]
265
+ if (status === 'compatible' || status === 'incompatible') {
266
+ return { basis: 'dshReleases', requirement: null, status }
267
+ }
268
+ return { basis: 'dshReleases', requirement: null, status: status === 'unknown' ? 'unknown' : 'undeclared' }
269
+ }
270
+
102
271
  async function checkHostCapabilities(ctx, logger) {
103
272
  const problems = []
104
273
  // 1) DSH version compatibility (best-effort, only when the version is detectable).
105
- // Declared under package.json dsh.compatibility.dshReleases (per the DSH STORE contract):
106
- // each full DSH release maps to 'compatible' | 'incompatible' | 'unknown'. A version that is
107
- // absent or 'unknown' is a soft warning; 'incompatible' is a hard "please use X" message.
108
- let dshRel = {}
109
- try { const m = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')); dshRel = (m.dsh && m.dsh.compatibility && m.dsh.compatibility.dshReleases) || {} } catch (e) {}
274
+ // The AUTHORITATIVE source is the declared range `engines.dsh` / `dsh.engines.dsh` — the same
275
+ // field dsh-market renders on the plugin card, so the two verdicts cannot disagree. The
276
+ // `dsh.compatibility.dshReleases` map is the fallback for manifests without a range.
277
+ let manifest = {}
278
+ try { manifest = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')) } catch (e) {}
279
+ const dshRel = (manifest.dsh && manifest.dsh.compatibility && manifest.dsh.compatibility.dshReleases) || {}
110
280
  const supported = Object.keys(dshRel).sort()
111
281
  const dshVersion = detectDshVersion()
112
282
  if (dshVersion) {
113
- const status = dshRel[dshVersion]
114
- if (status === 'incompatible') {
115
- problems.push('当前 DSH 版本 v' + dshVersion + ' 被本包声明为 incompatible;请使用 ' + supported.join(' / ') + '。')
116
- } else if (status === undefined || status === 'unknown') {
283
+ const verdict = dshVersionVerdict(dshVersion, manifest)
284
+ if (verdict.status === 'incompatible') {
285
+ problems.push(verdict.basis === 'engines'
286
+ ? '当前 DSH 版本 v' + dshVersion + ' 不满足本包声明的宿主版本要求(engines.dsh = ' + verdict.requirement + ')。'
287
+ : '当前 DSH 版本 v' + dshVersion + ' 被本包声明为 incompatible;请使用 ' + supported.join(' / ') + '。')
288
+ } else if (verdict.status === 'undeclared' || (verdict.status === 'unknown' && verdict.basis === 'dshReleases')) {
117
289
  problems.push('当前 DSH 版本 v' + dshVersion + ' 尚未被本包声明为兼容(dshReleases 仅声明 ' + supported.join(' / ') + ');建议使用 ' + supported.join(' / ') + ',或将该版本在 dshReleases 中标注后再自行验证。')
290
+ } else if (verdict.status === 'unknown' && verdict.basis === 'engines') {
291
+ problems.push('当前 DSH 版本 v' + dshVersion + ' 无法与 engines.dsh 的范围比对(声明值 ' + verdict.requirement + ' 不是本安装器能解析的范围);请按该范围自行确认。')
118
292
  }
119
293
  }
120
294
  // 2) capability self-check (the authoritative mounting gate; also covers hosts whose version
@@ -209,11 +383,23 @@ export async function apply(ctx) {
209
383
  const state = readState(stateFile)
210
384
  const prevFiles = (state && state.files) || {}
211
385
  const isUpgrade = state !== null && pkgVersion !== '' && state.version !== pkgVersion
212
- const isBaseline = state === null // no recorded history → refresh everything (user policy: auto-update old installs)
386
+ const isBaseline = state === null // no recorded history → refresh everything
387
+ // A version change (or a first sighting) REPLACES the managed files; only a same-version boot
388
+ // leaves the working tree alone. See UPDATE POLICY at the top of this file.
389
+ const refresh = isBaseline || isUpgrade
390
+ const fromVersion = (state && state.version) || '(unversioned)'
391
+ if (pkgVersion === '') {
392
+ // Without a version there is nothing to compare against, so this run must NOT replace
393
+ // anything (a wrong guess would overwrite files for no reason); it still restores missing
394
+ // ones and keeps the previously recorded version, so the next readable run reports the
395
+ // right "from" version.
396
+ logger?.warn?.('[dsh-vibe-math] 读不到本包版本(package.json 缺失或损坏):本次不做版本比对,只补回缺失的 preset 文件。')
397
+ }
213
398
 
214
399
  const nextFiles = {}
215
- let installed = 0, updated = 0, kept = 0
216
- const keptList = []
400
+ let installed = 0, updated = 0, kept = 0, backedUp = 0
401
+ const replacedEdits = []
402
+ const backupFailures = []
217
403
 
218
404
  for (const p of PRESETS) {
219
405
  const srcDir = join(here, p.src)
@@ -234,32 +420,35 @@ export async function apply(ctx) {
234
420
  nextFiles[key] = { hash: curHash, provenance: 'package' }
235
421
  continue
236
422
  }
237
- const destHash = sha256(readFileSync(d))
238
- if (isBaseline) {
239
- // no recorded history: refresh to the current package (one-time; edits
240
- // made before this mechanism are overwritten, later edits are protected)
241
- if (destHash === curHash) { nextFiles[key] = { hash: curHash, provenance: 'package' } }
242
- else { writeFileSync(d, cur); updated += 1; nextFiles[key] = { hash: curHash, provenance: 'package' } }
243
- continue
244
- }
423
+ const destBuf = readFileSync(d)
424
+ const destHash = sha256(destBuf)
245
425
  const prev = prevFiles[key]
246
426
  const prevRec = (prev && typeof prev === 'object') ? prev : { hash: prev, provenance: 'package' }
247
- const prevProv = (prevRec.provenance === 'user') ? 'user' : 'package' // 未知来源按包文件处理
248
- if (prevProv === 'package' && destHash === prevRec.hash) {
249
- // 包文件且未被改动 → 可安全升级(内容相同则跳过写入)
250
- if (destHash !== curHash) { writeFileSync(d, cur); updated += 1 }
427
+ if (destHash === curHash) {
428
+ // already the shipped bytes: never rewrite, so the file's mtime (which keys the preset's
429
+ // DSH generation) stays put
251
430
  nextFiles[key] = { hash: curHash, provenance: 'package' }
252
- } else if (prevProv === 'user') {
253
- // 用户持有 → 永不覆盖
254
- kept += 1
255
- if (isUpgrade) keptList.push(key + ' (用户持有)')
256
- nextFiles[key] = { hash: destHash, provenance: 'user' }
257
- } else {
258
- // 包文件但自上次安装后已被用户改动
431
+ continue
432
+ }
433
+ if (!refresh) {
434
+ // same version: nothing is being updated, so a file that differs from the package is left
435
+ // exactly as it is. The recorded hash stays "what this installer last wrote" (or unknown),
436
+ // so the drift is still recognised — and backed up — at the next version change.
259
437
  kept += 1
260
- if (isUpgrade) keptList.push(key + ' (已修改)')
261
- nextFiles[key] = { hash: destHash, provenance: 'user' }
438
+ nextFiles[key] = typeof prevRec.hash === 'string' ? { hash: prevRec.hash, provenance: 'package' } : { provenance: 'package' }
439
+ continue
440
+ }
441
+ // replacing: preserve the user's bytes when they are not what this installer last wrote
442
+ // (a legacy state without a hash cannot tell, so it backs the file up rather than risk it)
443
+ if (typeof prevRec.hash !== 'string' || destHash !== prevRec.hash) {
444
+ const backupResult = backupReplacedFile(presetRoot, fromVersion, p.dst, f, destBuf)
445
+ if (backupResult === 'failed') backupFailures.push(key)
446
+ else backedUp += 1
447
+ replacedEdits.push(key)
262
448
  }
449
+ writeFileSync(d, cur)
450
+ updated += 1
451
+ nextFiles[key] = { hash: curHash, provenance: 'package' }
263
452
  }
264
453
  }
265
454
 
@@ -293,22 +482,38 @@ export async function apply(ctx) {
293
482
  if (dirEmpty && existsSync(dir)) { try { rmdirSync(dir); removedDirs.push(dir) } catch (e) {} }
294
483
  }
295
484
 
296
- writeState(stateFile, { version: pkgVersion, files: nextFiles, updatedAt: Date.now() })
485
+ writeState(stateFile, { version: pkgVersion || (state && state.version) || '', files: nextFiles, updatedAt: Date.now() })
297
486
 
298
487
  if (removedFiles > 0 || removedDirs.length > 0) {
299
488
  logger?.info?.('[dsh-vibe-math] preset cleanup: removed ' + removedFiles + ' file(s) from ' + removedDirs.length + ' stale preset dir(s) (' + removedDirs.map(d => d.split(/[\\/]/).pop()).join(', ') + ') that are no longer shipped.')
300
489
  }
301
490
 
302
491
  if (isUpgrade) {
303
- logger?.info?.('[dsh-vibe-math] preset auto-update: version ' + (state.version || '(none)') + ' → ' + pkgVersion +
304
- ' — 新增 ' + installed + ' 个文件,更新 ' + updated + ' 个文件' +
305
- (kept > 0 ? ',保留 ' + kept + ' 个未覆盖文件(' + keptList.join('; ') + ')' : '') +
306
- '。新版本 preset 将在新会话生效。')
492
+ logger?.info?.('[dsh-vibe-math] preset auto-update: version ' + fromVersion + ' → ' + pkgVersion +
493
+ ' — 新增 ' + installed + ' 个文件,更新 ' + updated + ' 个文件。' +
494
+ (replacedEdits.length > 0
495
+ ? '其中 ' + replacedEdits.length + ' 个文件与上一次安装的字节不同(被改过),已按版本一致化覆盖' +
496
+ (backupFailures.length === 0
497
+ ? ',原文备份在 ' + join(presetRoot, BACKUP_DIR, String(fromVersion)) + ':' + replacedEdits.join(', ')
498
+ : ';这 ' + backupFailures.length + ' 个文件**备份失败**(原文未保留):' + backupFailures.join(', ')) +
499
+ '。要自定义 preset,请复制一份而不是改这几个文件——被管理的文件在下一次版本变更时一定会被替换。'
500
+ : '') +
501
+ '新版本 preset 将在新会话生效。')
307
502
  } else if (isBaseline) {
308
503
  logger?.info?.('[dsh-vibe-math] preset baseline: refreshed ' + (installed + updated) + ' file(s) to v' + pkgVersion +
309
- ' — 已启用自动更新(后续版本升级将自动替换未被手动修改的 preset 文件)。')
504
+ (replacedEdits.length > 0
505
+ ? ',其中 ' + replacedEdits.length + ' 个原有文件与随包版本不同' +
506
+ (backupFailures.length === 0 ? ',原文已备份在 ' + join(presetRoot, BACKUP_DIR, String(fromVersion))
507
+ : ',但有 ' + backupFailures.length + ' 个备份失败(原文未保留):' + backupFailures.join(', '))
508
+ : '') +
509
+ ' — 已启用版本化自动更新(后续版本变更会直接替换被管理的 preset 文件)。')
310
510
  } else if (installed > 0) {
311
511
  logger?.info?.('[dsh-vibe-math] restored ' + installed + ' missing preset file(s)')
512
+ } else if (kept > 0) {
513
+ // same version, and some managed file on disk differs from the package: reported, never
514
+ // rewritten mid-version (it is replaced, with a backup, at the next version change)
515
+ logger?.info?.('[dsh-vibe-math] preset files untouched (v' + pkgVersion + ' unchanged): ' + kept +
516
+ ' file(s) differ from the shipped copy; they will be replaced on the next version change (原件会先备份)')
312
517
  }
313
518
  await checkHostCapabilities(ctx, logger)
314
519
  } catch (err) {